diff --git a/.agents/skills/flexo-interop/SKILL.md b/.agents/skills/flexo-interop/SKILL.md index 07cd3bb5ec..173ae2a1bf 100644 --- a/.agents/skills/flexo-interop/SKILL.md +++ b/.agents/skills/flexo-interop/SKILL.md @@ -5,14 +5,14 @@ description: How to bring up a real Flexo MMS stack (Fuseki + Layer 1 + the SysM # Measuring this project's RDF against a running Flexo MMS -`internal/core/export`'s RDF path writes the SysML v2 vocabulary the Flexo MMS SysML v2 service +`internal/translate/export`'s RDF path writes the SysML v2 vocabulary the Flexo MMS SysML v2 service reads. Matching that service's `Namespaces.kt` is not evidence: a predicate can be spelled correctly and still be dropped, unreadable, or invisible to the read path. The only evidence is a -round trip through a running stack, which is what `internal/interop/flexo` performs and -`internal/interop/flexo/testdata/interop_expected.txt` records. +round trip through a running stack, which is what `internal/translate/interop/flexo` performs and +`internal/translate/interop/flexo/testdata/interop_expected.txt` records. The gate is **opt-in and skips by default** (`FLEXO_INTEROP`), exactly like the corpus gates in -`internal/core/model/corpus_gate_test.go`. `go test ./...` on a machine without Docker stays green, +`tests/corpus/corpus_gate_test.go`. `go test ./...` on a machine without Docker stays green, and a skipped run proves nothing — the skip says so on stderr. ## Bring the stack up @@ -53,7 +53,7 @@ Never commit a token or paste one into a report. The harness reads it from the e ```bash FLEXO_INTEROP=1 FLEXO_INTEROP_TOKEN="$FLEXO_INTEROP_TOKEN" \ - go test -count=1 ./internal/interop/flexo -run TestFlexoInterop + go test -count=1 ./internal/translate/interop/flexo -run TestFlexoInterop ``` About 10–20 s against a local stack. Override `FLEXO_LAYER1_URL`, `FLEXO_SYSMLV2_URL` or @@ -67,12 +67,12 @@ interoperability statement: ```bash FLEXO_INTEROP=1 FLEXO_INTEROP_TOKEN="$FLEXO_INTEROP_TOKEN" \ - go test -count=1 ./internal/interop/flexo -run TestFlexoInterop -update-flexo -git diff internal/interop/flexo/testdata/interop_expected.txt + go test -count=1 ./internal/translate/interop/flexo -run TestFlexoInterop -update-flexo +git diff internal/translate/interop/flexo/testdata/interop_expected.txt ``` Everything else in the package (report determinism, the fixtures' coverage of the known gaps) runs -without a stack, so `go test ./internal/interop/flexo` is worth running on any change to it. +without a stack, so `go test ./internal/translate/interop/flexo` is worth running on any change to it. ## What a run does, and why each side exists @@ -166,6 +166,6 @@ are not known up front: ## Scope -The harness measures; it does not fix. Do not change `internal/core/export` or `internal/core/rdf` +The harness measures; it does not fix. Do not change `internal/translate/export` or `internal/translate/rdf` encoding behavior to move a number in the expectation file, and never replace the stack with a mock — a mocked Flexo measures our own assumptions, which is the one thing this gate exists to avoid. diff --git a/.agents/skills/testing-doc-counts/SKILL.md b/.agents/skills/testing-doc-counts/SKILL.md index 431cf4104b..8a251700a1 100644 --- a/.agents/skills/testing-doc-counts/SKILL.md +++ b/.agents/skills/testing-doc-counts/SKILL.md @@ -1,23 +1,55 @@ --- name: testing-doc-counts -description: How to end-to-end test the generated documentation figures (cmd/doc-counts + internal/doccounts + `make docs-counts`) on Linux — proving `-check` is a real gate, that the block consumers cannot drift, that marker mutations fail loudly, and that no measured number moved. +description: How to end-to-end test the generated documentation figures (tools/cmd/doc-counts + tools/census/doccounts + `make docs-counts`, and the build-time suite figures of scripts/mkdocs_suite_figures.py) on Linux — proving `-check` is a real gate, that the block consumers cannot drift, that marker mutations fail loudly, that the site renders the tree's figures, and that no measured number moved. --- -# Testing the generated documentation figures (`cmd/doc-counts`) +# Testing the generated documentation figures (`tools/cmd/doc-counts`) -`cmd/doc-counts` regenerates two kinds of derived documentation from the committed baselines: +`tools/cmd/doc-counts` regenerates three kinds of derived documentation: -1. single-copy baseline lines in `README.md` (`**Reference differential:**`, `**Rejection oracle:**`); +1. single-copy baseline lines in `README.md` (`**Reference differential:**`, `**Rejection oracle:**`), + from the committed baselines; 2. the HTML-comment-delimited named block `` … ``, rendered from **one** template in - `internal/doccounts/doccounts.go` into **two** consumers (`README.md` and + `tools/census/doccounts/doccounts.go` into **two** consumers (`README.md` and `docs/internals/architecture.md`), differing only by `Block.LinkPrefix` - (`docs/project/` vs `../project/`). + (`docs/project/` vs `../project/`); +3. the README's `**Behavioral execution:**` figure, the **inline** block + `every conformance case passing` + — the one suite figure still committed, because it moves only with `known_failures.txt`. + +The rest of the test-suite figures are **site blocks** (`doccounts.SiteBlocks()`, named in +`siteSuiteBlocks` in `tools/census/doccounts/suite_blocks.go`): the compliance map's `**Test Coverage:**` +inventory (`inventory-conformance`, `-robustness`, `-runtime-tests`, `-golden-asts`, `-traces`, +`-negatives`, `-grpc`, `-tests`) and the LSP `**Measured coverage:**` line (`lsp-tests`). In git each +holds a sentence naming what is counted and **no digit**; `go run -C tools ./cmd/doc-counts -check` refuses +one that states a figure (`the block named "inventory-robustness" states a figure`). The figures +are rendered when the site builds: `go run -C tools ./cmd/doc-counts -site-blocks` prints +`{"docs/project/spec-compliance.md": {"inventory-robustness": "470 runtime robustness cases (…)", …}}` +and `scripts/mkdocs_suite_figures.py` (an `on_pre_build` + `on_page_markdown` hook in +`mkdocs.yml`) splices the text into the blocks, dropping the markers. Their inputs are the +**tree**, read by `doccounts.ReadSuiteCounts` the way the gates enumerate them: `tests/fixtures` +lists the conformance cases (the same package `TestExecutionConformance` and the gRPC conformance +gate iterate), the parse and trace goldens are stat'ed against the case that owns them, and the +robustness, negative and `Test`-function figures are counted from the `_test.go` files with +`go/ast` (first-level `t.Run` calls across every `TestRuntimeRobustness*` / `TestGRPCRobustness*` +function, multiplied out over the table literal a `range` walks, read in statement order and +lexical scope, so a table rebound after the loop or shadowed by a `:=` in an inner block, branch or +clause does not leak into it). The test and subtest total of a run is **not** generated — only a +run can state it, so the prose does not quote one. + +A third consumer, `` in `docs/project/spec-compliance.md`, +renders the per-library table from `docs/project/analysis-library-census.json`, which +`TestAnalysisLibraryCensus` (`internal/exec/runtime/library_census_test.go`) writes under +`-update-library-census` and otherwise asserts. Its inputs are `doccounts.ReadFigures` +(the refereed baselines plus the census); the same stale/marker/read-only checks below apply to it, +and a census JSON mutated by hand (a declaration dropped from `evaluated`) must fail both `-check` +(`has 0 verdicts, want 1`) and the runtime test. The compliance map's own row census (`The map below tracks N semantic rules: …`) is **not** committed anywhere: `scripts/mkdocs_census.py` counts it from the rows and fills the `` block in `docs/project/spec-compliance.md` while the site builds -(`make docs`). `doc-counts` and the `cmd/pilot-diff` guard only refuse a `🚧` row. Test the hook with +(`make docs`). `doc-counts` and the `tools/referee/diff` guard only refuse a `🚧` row. Test the hook with `python3 scripts/mkdocs_census-test.py`, and prove it live by grepping the built `site/project/spec-compliance/index.html` for `semantic rules:` after adding a row. @@ -41,12 +73,19 @@ to a possibly-unpublished record must use. `scripts/check-doc-links.py` only wal so it never sees `overrides/*.html`; the hook is the only guard, and both of its warnings (`which no page publishes`, `which does not exist`) fail `--strict`. -Inputs are the three committed baselines +Inputs to the refereed figures are the three committed baselines `docs/project/pilot-{differential,xpect,rejection}-baseline.json` (`doccounts.ReadRefereedCounts`); -`docs/project/spec-compliance.md` is read only to refuse a `🚧` row. +`docs/project/spec-compliance.md` is read to refuse a `🚧` row and, since it carries the inventory +blocks, is also a consumer. -`make docs-counts` = generate → `go run ./cmd/doc-counts -check` → `go test -count=1 ./cmd/pilot-diff -./cmd/pilot-reject ./cmd/doc-counts`. +`make docs-counts` = generate → `go run -C tools ./cmd/doc-counts -check` → `go run -C tools ./cmd/validation-census +-check` → `go test -C tools -count=1 ./census/doccounts ./census/validation` → +`go test -C tools -count=1 ./referee/diff ./referee/reject`. + +Adding a test or fixture anywhere in the module moves a site figure and **nothing committed**: +`-check` stays `already current`, `-site-blocks` and the built site change. Only a baseline, the +library census or `known_failures.txt` moving makes `TestCheckCommittedTreeIsCurrent` fail until +`make docs-counts` runs. That is by design — CI runs `-check` and builds the site with `--strict`. ## Never test in a checkout someone else is using @@ -71,7 +110,7 @@ Copy **all** `build/pilot-*` dirs together: the validator launchers resolve the - **Idempotence:** `make docs-counts` twice; both must print `doc-counts: already current` for the generate *and* the `-check` step, and `git status --short` must stay empty. - **`-check` is a gate, not decoration:** perturb one number *inside* the block in one consumer. - `go run ./cmd/doc-counts -check` must exit **1**, print `doc-counts: is stale` plus a + `go run -C tools ./cmd/doc-counts -check` must exit **1**, print `doc-counts: is stale` plus a `--- (current) / +++ (generated)` diff with `@@ line N @@` hunks, name **only** that file, and leave the file's `sha256sum` unchanged. Then the plain generator must restore it byte-identically to the committed hash. @@ -94,7 +133,53 @@ Copy **all** `build/pilot-*` dirs together: the validator launchers resolve the the whole block. Each must make *both* the generator and `-check` exit 1 with `named block "refereed-figures" is missing or unterminated` or `duplicate "" marker`, and `wc -c` on the file must be - unchanged (no truncation, no `already current`). + unchanged (no truncation, no `already current`). For an inline block, also: put the end marker + before the begin marker on the line (`ends before it begins`), repeat the pair on one line or + add a second copy on a line of its own (`duplicate markers of the block named`), and drop the + end marker (`missing or unterminated`). +- **Tree propagation goes to the site, not to git:** drop a `state_probe.expected.json` into + `internal/exec/runtime/testdata/conformance/`, or a + `robustness_zz_probe_test.go` with a two-subtest `TestRuntimeRobustnessProbe` into + `internal/exec/runtime/`, or a `TestSomething` into any `_test.go`: `-check` must still print + `already current` and `git status --short` must show only the probe, while `-site-blocks` + moves the matching figure (`state×228` → `state×229`, `470 runtime robustness cases` → `472`, + the `Test`-function figure by one) and `make docs` renders the new number into + `site/project/spec-compliance/index.html`. `go test -run TestRuntimeRobustnessProbe -v` must run + both subtests — the counter follows Go discovery, not the other way round. List a real case in + `known_failures.txt`; now the **committed** README block goes stale (`every conformance case + passing` → `1 listed in known_failures.txt`) and `-check` must name `README.md`. Remove the + probes afterwards (`git status --short` must be empty again). +- **A typed figure in a site block is refused:** put a digit inside any `inventory-*` or + `lsp-tests` block; `-check` must exit 1 naming the block and leave the file unchanged. Break a + site block's marker (drop the end marker, duplicate the pair): `-check` fails, and `make docs` + must abort under `--strict` with the hook's warning rather than publish the placeholder + sentence. Hide `go` from `PATH`: `make docs` must abort with `go: not found; the test-suite + figures need the Go toolchain`, and `python3 scripts/mkdocs_suite_figures-test.py` must fail + its real-tree case (not skip it). +- **The site shows the tree's figures:** after `make docs`, grep + `site/project/spec-compliance/index.html` for `runtime robustness cases` and + `functions in internal/frontend/lsp`; each must carry a number, no `doc-counts:begin inventory-` + or `lsp-tests` marker may remain (only the committed `analysis-libraries` markers do), and + none of the placeholder sentences (`the runtime robustness cases`) may be visible. Open the + served page in a browser to confirm the inventory reads naturally with the numbers spliced + into the sentences. +- **The counters refuse what they cannot count:** a `.trace.golden` owned by no case, a + `.typo.trace.golden` under no sweep policy, a `.declared.trace.golden` of a case + with no `outcomes` (or no default golden), a `.sysml` under `testdata/parse/` with no `.golden`, + a `known_failures.txt` entry naming no case, a `for i := 0; i < n; i++ { t.Run(...) }` loop, + a `range` over a table the function `append`s to or rebinds under a condition before the loop, or + an `if cond { t.Run(...) }` in `TestRuntimeRobustness` must each make the generator and `-check` + exit 1 naming the file, rather than print a smaller (or larger) number. A `range` or `if` that + runs no subtest is passed over, and so are the goldens of a case `known_failures.txt` lists, + since `TestExecutionTrace` skips the case. +- **The figures are the gates' figures** (read them from `-site-blocks`): `go test -count=1 -v + -run 'TestExecutionConformance$' ./internal/exec/runtime | grep -cE '^=== RUN TestExecutionConformance/[^/]+$'` + must equal the conformance figure; the same shape with `TestRuntimeRobustness` and + `TestGRPCRobustness` (in `./internal/frontend/grpc`) — unanchored, summing the first-level `=== RUN` + lines of every function the prefix matches — `TestGolden$` and `Negative` (in + `./tests/parser` and `./internal/syntax/parser`, summing per function) must equal theirs; and + `go test -list '.*' ./... | grep -c '^Test'` must equal the `Test`-function figure. Test names + carry digits (`TestF62F63Negative`), so match `[^/ ]+`, not `[A-Za-z_]+`. - **Every landing link resolves on the built site:** grep the `href`s out of `/tmp/site/index.html` and check each one — a site-relative target must exist under `/tmp/site`, a repository target must exist under `docs/` — then click them in a browser @@ -113,10 +198,12 @@ Copy **all** `build/pilot-*` dirs together: the validator launchers resolve the `git show main:README.md | grep -o '[0-9][0-9]*'` vs the same on HEAD, `diff` must be empty (same for `docs/internals/architecture.md`), and `git diff main -- 'docs/project/pilot-*-baseline.json'` must be empty. This is the cheapest proof a "generate it instead of hand-maintaining it" refactor - restated exactly what was there. + restated exactly what was there. The site figures are not in either file, so compare them + against the gates instead (previous bullet): a figure `-site-blocks` prints that the matching + `go test -v` enumeration does not reproduce is a counting bug, not a fixture landing. - **Live oracle reproduction is a separate claim** from doc↔baseline consistency: the guards read only committed JSON. Run all three under a fresh cache - (`XDG_CACHE_HOME=$(mktemp -d) go run ./cmd/pilot-{xpect,reject,diff} -out /tmp/oN`) and `cmp` + (`XDG_CACHE_HOME=$(mktemp -d) go run -C tools ./cmd/pilot-{xpect,reject,diff} -out /tmp/oN`) and `cmp` each against its committed baseline. ## Gotchas @@ -133,9 +220,10 @@ Copy **all** `build/pilot-*` dirs together: the validator launchers resolve the ## Recording -The `cmd/doc-counts` checks are shell-only; no GUI, so no recording is needed. If the change -touches `overrides/home.html`, the landing page itself must be verified in a browser (band -renders, links navigate, light/dark, narrow viewport) — record that part. Serve the build +The `tools/cmd/doc-counts` checks are shell-only; no GUI, so no recording is needed. If the change +touches `overrides/home.html` or the site blocks, the built page must be verified in a browser +(band renders, links navigate, light/dark, narrow viewport; the compliance map's inventory shows +numbers, not placeholder sentences) — record that part. Serve the build with `python3 -m http.server 8899 -d /tmp/site`, maximize Chrome with `wmctrl -r :ACTIVE: -b add,maximized_vert,maximized_horz`, and force a narrow viewport with `xdotool getactivewindow windowsize 620 1100` plus a couple of `ctrl+plus` page zooms diff --git a/.agents/skills/testing-doc-html/SKILL.md b/.agents/skills/testing-doc-html/SKILL.md index 94993a8c31..342e813103 100644 --- a/.agents/skills/testing-doc-html/SKILL.md +++ b/.agents/skills/testing-doc-html/SKILL.md @@ -1,6 +1,6 @@ --- name: testing-doc-html -description: How to end-to-end test the sysml HTML document backend (internal/core/docrender/html.go + `-doc-form html`) in a real browser on Linux — the exact CLI invocations, which fixtures exercise which feature, how to prove the CSS-override/cascade-layer contract visually, and the traps that make an HTML render look fine when it is not. +description: How to end-to-end test the sysml HTML document backend (internal/doc/docrender/html.go + `-doc-form html`) in a real browser on Linux — the exact CLI invocations, which fixtures exercise which feature, how to prove the CSS-override/cascade-layer contract visually, and the traps that make an HTML render look fine when it is not. --- # Testing the HTML document backend @@ -9,8 +9,8 @@ description: How to end-to-end test the sysml HTML document backend (internal/co ```bash make build # bin/sysml -mkdir -p /tmp/ht && cp internal/core/docrender/testdata/telescope_report.sysml \ - internal/core/docrender/testdata/linked_reports.sysml /tmp/ht/ +mkdir -p /tmp/ht && cp internal/doc/docrender/testdata/telescope_report.sysml \ + internal/doc/docrender/testdata/linked_reports.sysml /tmp/ht/ ``` - `telescope_report.sysml` → document `Observatory::MassReport`. The single best fixture: @@ -20,8 +20,11 @@ mkdir -p /tmp/ht && cp internal/core/docrender/testdata/telescope_report.sysml \ names (`baffle|shroud *tricky*`, `&plain`) that double as escaping cases. - `linked_reports.sysml` → `Observatory::SystemReport` + `Observatory::Mass Appendix`, the cross-document link pair. File names are percent-ish escaped: - `Observatory-Mass.20Appendix.html`. See `internal/core/docrender/html_crossdoc_test.go` + `Observatory-Mass.20Appendix.html`. See `internal/doc/docrender/html_crossdoc_test.go` for the exact expected `href`/anchor shapes. +- `math_report.sysml` → `Optics::OpticsReport`, the formula fixture: an inline `style = "math"` + span next to a prose `$`, a captioned `Formula` block that a `Ref` targets, a display formula + containing `\$`, and math list items driven by a query. ## Invocations that matter @@ -32,6 +35,7 @@ bin/sysml m.sysml -render-document Observatory::MassReport -doc-form html \ bin/sysml m.sysml -render-document Observatory::MassReport -doc-form html \ -html-css theme.css -o report_theme.html # file is inlined; a URL is ed bin/sysml m.sysml -render-document Observatory::MassReport -doc-form html -html-fragment -o frag.html +bin/sysml math_report.sysml -render-document Optics::OpticsReport -doc-form html -html-math cdn -o math.html bin/sysml m.sysml -render-document Observatory::MassReport -doc-form html -html-no-default-css -o nocss.html bin/sysml linked_reports.sysml -render-documents site -doc-form html # pages + sysml-document.css bin/sysml -html-default-css -o default.css # no model needed @@ -49,7 +53,7 @@ network requests. A useful sanity check: DevTools → Network shows exactly **1* ## Proving the CSS-override contract (the part a weak test misses) -`internal/core/docrender/document.css` wraps everything in `@layer opensysml`, so *any* +`internal/doc/docrender/document.css` wraps everything in `@layer opensysml`, so *any* unlayered reader rule must win regardless of specificity. Test all three mechanisms with a sheet that uses **no `!important`**: @@ -71,13 +75,19 @@ rather than shared. - Mermaid diagrams are emitted as `
` **source**, intentionally. Nothing
   is drawn unless you add a Mermaid script yourself. Not a bug.
+- Formulas likewise stay LaTeX source between `\(…\)`/`\[…\]` in `.sysml-math` elements until
+  `-html-math cdn|` adds MathJax (`MathScriptURL` in `internal/doc/docrender/html.go` pins
+  the release). Typesetting is confined to `.sysml-math` (`processHtmlClass`), so the prose `$`
+  next to the inline formula must stay a literal dollar sign once MathJax has run — that is the
+  check that distinguishes scoped typesetting from a page-wide scan. Loading from the CDN needs
+  network access; without it the page degrades to source, which is expected.
 - A query that matches nothing yields a header-only `` (no ``), and an empty
   `List` renders nothing at all. Expect the "Missing Subsystems" section to look bare.
 - Linked-set pages can be short enough to fit the viewport, so a cross-document
   `...#anchor` navigation may not visibly scroll. Verify the anchor's section is on screen
   and the URL fragment matches rather than expecting movement.
 - `-html-default-css -o file` should be byte-identical to
-  `internal/core/docrender/document.css` and to the `sysml-document.css` a set writes;
+  `internal/doc/docrender/document.css` and to the `sysml-document.css` a set writes;
   `cmp` all three.
 - Escaping: build an adversarial model whose titles/cells/captions/link targets contain
   ``, `"`, `&`, `-->`. Everything must appear as
diff --git a/.agents/skills/testing-doc-pdf/SKILL.md b/.agents/skills/testing-doc-pdf/SKILL.md
index e527730a0d..5dde9bb8bf 100644
--- a/.agents/skills/testing-doc-pdf/SKILL.md
+++ b/.agents/skills/testing-doc-pdf/SKILL.md
@@ -1,24 +1,35 @@
 ---
 name: testing-doc-pdf
-description: How to end-to-end test the sysml PDF document backend (internal/docpdf + -doc-form pdf) on Linux — provisioning the pinned WeasyPrint/mermaid toolchain, rendering the worked example and the docrender golden, and proving inline runs, anchors and links render rather than appearing literal.
+description: How to end-to-end test the sysml PDF document backend (internal/doc/docpdf + -doc-form pdf) on Linux — provisioning the pinned WeasyPrint/mermaid/KaTeX/Graphviz/PlantUML toolchain, rendering the worked example and the docrender goldens, and proving inline runs, anchors, links, LaTeX formulas and every diagram form render rather than appearing literal.
 ---
 
 # Testing the PDF document backend
 
 ## Toolchain
-- Provision once: `./scripts/download-doc-pdf-toolchain.sh` installs a pinned WeasyPrint venv and mermaid-cli under `build/doc-pdf/`.
+- Provision once: `./scripts/download-doc-pdf-toolchain.sh` installs a pinned WeasyPrint venv, pandoc, mermaid-cli, KaTeX, Graphviz (unpacked from the upstream Ubuntu .debs; its `neato` plugin needs the host's `libgts-0.7-5`) and the PlantUML jar under `build/doc-pdf/`. PlantUML needs a `java` on `PATH` (or `OPENSYSML_JAVA`).
 - Export before any PDF render (env does NOT persist between exec tool calls even in the same shell session — re-export in the same command line):
   - `OPENSYSML_WEASYPRINT=$PWD/build/doc-pdf/weasyprint/bin/weasyprint`
   - `OPENSYSML_MMDC=$PWD/build/doc-pdf/mermaid/node_modules/.bin/mmdc`
   - `OPENSYSML_MMDC_PUPPETEER=$PWD/build/doc-pdf/mermaid/puppeteer.json`
+  - `OPENSYSML_PANDOC=$PWD/build/doc-pdf/pandoc-3.10.2/bin/pandoc` (for `-pdf-engine pandoc`)
+  - `OPENSYSML_KATEX=$PWD/build/doc-pdf/katex/node_modules/.bin/katex` (only a document with formulas needs it)
+  - `OPENSYSML_DOT=$PWD/build/doc-pdf/graphviz/bin/dot` (for `-diagram-form dot`; absent, DOT blocks stay as source under a notice)
+  - `OPENSYSML_PLANTUML_JAR=$PWD/build/doc-pdf/plantuml/plantuml-1.2026.8.jar` (for `-diagram-form plantuml`; absent, PlantUML blocks stay as source under a notice)
+- `go test -run Installed ./internal/doc/docpdf` runs the real-toolchain integration tests (they skip per missing tool, `pdftotext`/`pdfimages` from `poppler-utils` included — the tests read every PDF back through them, so install the package or the text assertions never run); the rest of the package's tests use fake tools and need nothing installed. `OPENSYSML_REQUIRE_PDF_TOOLCHAIN=1` turns every such skip but Prince's into a failure — set it once the script has run, so a misconfigured variable cannot pass as a skip. This is how the CI `pdf-toolchain` job runs them.
+- Diagram forms: render the worked example with `-diagram-form dot` and `-diagram-form plantuml` too; the PDF must contain no "did not draw" notice (pypdf `extract_text`) and the page's XObjects grow by one per diagram. With `OPENSYSML_DOT=/nonexistent` the render still succeeds and the notice names `OPENSYSML_DOT`. A `// layout: neato -n` block (the DOT writer emits one for a positioned diagram) must run neato: `dot -Kneato` failing with "no layout engine support" means the GTS library is missing.
 
 ## Rendering
 - Worked example: `bin/sysml docs/manual/examples/observatory.sysml -render-document Observatory::MassReport -doc-form pdf -pdf-title-page -pdf-toc -pdf-number-sections -o /tmp/observatory.pdf`. It exercises emphasis, code span, external Link, Ref (`#breakdown`), a standalone `` anchor line, grouped table (`**zone: ...**` headings), numbered list and two mermaid diagrams.
-- Richest input: `internal/core/docrender/testdata/telescope_report.golden.md` covers every inline construct including escaped `\*`/`\|`/`` \` `` prose and reference links. Drive it via a throwaway `tmp_render_main.go` at repo root calling `docpdf.Render(md, "weasyprint", docpdf.Options{...})` with `go run` (delete afterwards).
-- Markdown regression: `-o foo.md` must diff-equal `docs/manual/examples/observatory.md`. Note `-doc-form` takes `markdown` or `pdf` (not `md`).
-- Captions: docrender writes `` on its own line immediately before every caption's `*text*` line; docpdf only treats marked lines as captions (`p.caption`, small/gray), so a bare `*text*` paragraph must stay body-sized. To test the distinction, author a Document with a Paragraph whose single Span has `style = "emphasis"` alongside a captioned Table (model needs `private import DocumentQueries::*;` and `private import KerML::Root::Element;`; queries take `in root : Element` bound per-Table). Font-level proof: pypdf `extract_text(visitor_text=...)` size param — caption renders ~12.67pt vs 14.67pt body. Verify under BOTH engines (`-pdf-engine weasyprint` and `pandoc`): the pandoc path rewrites markers to `[*text*]{.caption}` spans, so also assert extracted text contains no `{.caption}` or `[*`.
+- Richest input: `internal/doc/docrender/testdata/telescope_report.sysml` → `Observatory::MassReport` covers every inline construct including escaped `\*`/`\|`/`` \` `` prose and reference links; `internal/doc/docpdf/fixture_test.go` evaluates it into a `*docir.Document`, which is what `docpdf.Render(document, "weasyprint", docpdf.Options{...})` takes (not Markdown).
+- Markdown regression: `-o foo.md` must diff-equal `docs/manual/examples/observatory.md`. Note `-doc-form` takes `markdown`, `html` or `pdf` (not `md`).
+- WeasyPrint and Prince read the HTML backend's page (`docrender.HTML`, the same markup as `-doc-form html`) under `internal/doc/docpdf/print.css`, so `-html-theme`, `-html-css` and `-html-no-default-css` change the PDF; pandoc reads the Markdown and refuses the theme flags with a typed error. Inspect the prepared input with `docpdf.Render`'s fake-tool tests (`TestRenderHTMLIsTheBackendsPage`) or a fake `OPENSYSML_WEASYPRINT` script that copies its input file aside.
+- Stylesheet assets: a `-html-css` sheet's relative `url()`/`@import` resolve against the `-o` PDF's directory (`Options.BaseDir`; WeasyPrint `--base-url`, Prince `--baseurl`, pandoc `--resource-path` + `--pdf-engine-opt=--base-url`), not the temp working dir. `TestRenderStylesheetAssetsBesideTheOutput` proves it under the installed engines with `internal/doc/docpdf/testdata/mark.png` (12×12) copied beside the output and an imported sheet writing `IMPORTEDBESIDE` — check `pdfimages -list` for the 12×12 image and `pdftotext` for the word; `TestRenderBaseDirEveryEngine` pins the argv without the toolchain. Generated files (diagram SVGs, KaTeX CSS) are referenced by absolute `file://` URL for that reason.
+- Captions: Markdown carries no caption marker; a caption is an emphasized `*text*` paragraph like any other. The HTML page marks captions as `figcaption.sysml-caption` from the IR, so under `weasyprint` a bare emphasized Paragraph must stay body-sized while a caption is small/gray. Under `pandoc`, the generated `artwork.lua` filter matches emphasized paragraphs against `docrender.Captions` in order and only ahead of a captionable block (table, display math, diagram fence, the `` comment a table-kind diagram opens with, grouped table's key); an emphasized paragraph elsewhere stays prose. One missed caption stalls every later one (matching is ordinal), so a document mixing table-kind diagrams with other captioned blocks is the fixture to run. To test the distinction, author a Document with a Paragraph whose single Span has `style = "emphasis"` alongside a captioned Table (model needs `private import DocumentQueries::*;` and `private import KerML::Root::Element;`; queries take `in root : Element` bound per-Table). Font-level proof: pypdf `extract_text(visitor_text=...)` size param. Verify under BOTH engines, and assert extracted text contains no `{.caption}`, `[*` or `` marker not followed by a fully-emphasized line is a typed `dangling-caption` error ("caption marker without a caption line after it").
+- Diagrams: Mermaid is drawn once through `mmdc` and the image replaces the block (`HTMLOptions.DiagramImages` / the Lua filter); a table-kind diagram stays a table. DOT and PlantUML are not drawn — the PDF carries an emphasized notice and the source, under both engines.
+- Linked sets: `-render-documents ... -doc-form pdf` is refused with a typed error; render each document with `-render-document`. A `Ref` into another document then links to that document's page file name, which no PDF beside it carries.
 
 ## Verifying the PDF is real, not literal
 - Install pypdf into the WeasyPrint venv (`build/doc-pdf/weasyprint/bin/pip install pypdf`) — the box's system python is a venv where `pip --user` fails.
diff --git a/.agents/skills/testing-element-filters/SKILL.md b/.agents/skills/testing-element-filters/SKILL.md
index b2079d2061..ff19b8b68e 100644
--- a/.agents/skills/testing-element-filters/SKILL.md
+++ b/.agents/skills/testing-element-filters/SKILL.md
@@ -7,7 +7,7 @@ description: How to observe SysML element filters (`filter ;`, `import P::
 
 ## The only observable surface is diagnostics, not `%instantiate`
 
-`internal/repl/lookup.go` resolves a qualified name with `idx.LookupQualified` and a simple name with
+`internal/frontend/repl/lookup.go` resolves a qualified name with `idx.LookupQualified` and a simple name with
 `resolve.New(idx).LookupName` **without** `SetModel`, so no filter is ever evaluated on that path:
 `%instantiate Facade::hiddenPart` succeeds even for an element a filter rejects. Do not read that as
 "filters do not work".
@@ -124,7 +124,7 @@ filter @Meta::Safety + 1; -> warning: this filter condition cannot be evaluated,
 A REPL session document is never cached; only *library* files are. Make the model a library:
 
 ```bash
-cp -r internal/core/libs/stdlib/* /tmp/flib/          # OPENSYSML_LIBRARY_PATH REPLACES the stdlib, so copy it
+cp -r internal/workspace/libs/stdlib/* /tmp/flib/          # OPENSYSML_LIBRARY_PATH REPLACES the stdlib, so copy it
 mkdir -p /tmp/flib/Filters && cp model.sysml /tmp/flib/Filters/
 export OPENSYSML_LIBRARY_PATH=/tmp/flib XDG_CACHE_HOME=/tmp/fc
 rm -rf /tmp/fc                                        # run 1 = cache miss (parsed), run 2+ = cache hit (restored)
@@ -197,7 +197,7 @@ side by side on **one model**, and expect them to be able to disagree:
   it must still be `true`. Check subtype conformance in **both directions** too
   (`airBag @ CrashSafety` true, `seatBelt @ CrashSafety` false), since a name-based fallback that
   ignores direction would pass the positive case alone.
-- **`go test -run TestExecutionConformance ./internal/core/runtime` can pass while the binary fails
+- **`go test -run TestExecutionConformance ./internal/exec/runtime` can pass while the binary fails
   the same fixture.** Conformance builds its own index; the REPL/CLI path does not. Always re-run
   conformance fixtures through the binary:
   `./bin/sysml  -instantiate test::Vehicle -constraint test::Vehicle::tagged`.
diff --git a/.agents/skills/testing-errata-overlay/SKILL.md b/.agents/skills/testing-errata-overlay/SKILL.md
index 1e3175801f..ccaa6292c8 100644
--- a/.agents/skills/testing-errata-overlay/SKILL.md
+++ b/.agents/skills/testing-errata-overlay/SKILL.md
@@ -1,12 +1,14 @@
 ---
 name: testing-errata-overlay
-description: How to end-to-end test the declared-errata overlay (internal/errata + the errata blocks in cmd/pilot-diff, cmd/pilot-xpect, cmd/pilot-reject and the doccounts errata sentence) on Linux — proving the published corpora are never edited, that the pilot really re-runs over the corrected copy, and that registry provenance checks are load-bearing.
+description: How to end-to-end test the declared-errata overlay (tools/oracle/errata + the errata blocks in tools/referee/diff, tools/referee/xpect, tools/referee/reject and the doccounts errata sentence) on Linux — proving the published corpora are never edited, that the pilot really re-runs over the corrected copy, and that registry provenance checks are load-bearing.
 ---
 
 # Testing the declared-errata overlay
 
-`internal/errata` declares defects in OMG-published reference material (file, line,
-as-published bytes, corrected bytes, spec citation, derivation). Each oracle driver reports its
+`tools/oracle/errata` declares defects in OMG-published reference material (file, line,
+as-published bytes, corrected bytes, spec citation, derivation); the entry type, the overlay
+applied on read and the bundled library's entries are `internal/workspace/libs/errata`, which the
+registry builds on. Each oracle driver reports its
 census twice: as published (the conformance statement) and over a **materialised corrected
 copy**.
 
@@ -17,7 +19,7 @@ Standard blueprint provisioning is enough (`scripts/download-pilot-corpora.sh`,
 `download-pilot-xpect.sh`; java 21 + go). `build/syside` is optional — without it the
 differential is two-way, which is what the committed baselines record.
 
-**Always use a fresh library cache per oracle run**: `XDG_CACHE_HOME=$(mktemp -d) go run ./cmd/pilot-diff`.
+**Always use a fresh library cache per oracle run**: `XDG_CACHE_HOME=$(mktemp -d) go run -C tools ./cmd/pilot-diff`.
 Stale on-disk index records have produced wrong oracle numbers before.
 
 ## Where the corrected copy actually lives
@@ -36,7 +38,7 @@ hash_tree examples/pilot-corpora; hash_tree build/pilot-xpect-corpus   # before
 git status --porcelain examples/
 grep -rn '(22/2\*25.4 + 110)' examples/ build/pilot-xpect-corpus       # corrected bytes must not appear
 ```
-Every write path in `internal/errata/errata.go` must be rooted at `dst`; `repo/dir` is only ever
+Every write path in `tools/oracle/errata/errata.go` must be rooted at `dst`; `repo/dir` is only ever
 read (`os.CopyFS(dst, os.DirFS(repo/dir))`).
 
 ## Proving the *pilot* re-runs over the corrected text (the important one)
@@ -54,7 +56,7 @@ f="$root/Geometry Examples/VehicleGeometryAndCoordinateFrames.sysml"
 exec /path/to/build/pilot-sysml-validator/validate-sysml-batch "$@"
 EOF
 chmod +x /tmp/wrap/validate-sysml-batch
-XDG_CACHE_HOME=$(mktemp -d) go run ./cmd/pilot-diff -validator /tmp/wrap/validate-sysml-batch -out /tmp/pd-wrap
+XDG_CACHE_HOME=$(mktemp -d) go run -C tools ./cmd/pilot-diff -validator /tmp/wrap/validate-sysml-batch -out /tmp/pd-wrap
 ```
 Expect two logged invocations: one with `--root …/examples/pilot-corpora/sysml-examples` showing
 the published line and one with `--root /errata-corpora/pilot-examples` showing the
@@ -70,7 +72,7 @@ containing only the erratum's file and point the oracle at it:
 ```bash
 mkdir -p /tmp/mini/"examples/pilot-corpora/sysml-examples/Geometry Examples"
 cp "examples/pilot-corpora/sysml-examples/Geometry Examples/VehicleGeometryAndCoordinateFrames.sysml" /tmp/mini/…/
-XDG_CACHE_HOME=$(mktemp -d) go run ./cmd/pilot-diff -repo /tmp/mini \
+XDG_CACHE_HOME=$(mktemp -d) go run -C tools ./cmd/pilot-diff -repo /tmp/mini \
   -validator $PWD/build/pilot-sysml-validator/validate-sysml-batch \
   -kerml-validator $PWD/build/pilot-kerml-validator/validate-kerml -out /tmp/pd-mini
 ```
@@ -79,16 +81,17 @@ mutated `AsPublished` it must exit 1 with `F82: …:38 reads "…", the entry re
 
 ## Registry mutation matrix
 
-`cp internal/errata/errata.go /tmp/bak` first, apply one mutation at a time with a Python
-string replace (watch the trailing comma — a dropped `,` yields a *build* failure, which is not
-evidence the check works), run `go test -count=1 ./internal/errata`, then restore and confirm
-`git status --porcelain internal/errata` is empty. Mutations that must fail: AsPublished byte,
-empty Citation, empty Derivation, wrong Line, no-op Corrected, second entry for the same file,
-path outside `publishedRoots`.
+`cp tools/oracle/errata/errata.go /tmp/bak` first (library entries: `internal/workspace/libs/errata/errata.go`),
+apply one mutation at a time with a Python string replace (watch the trailing comma — a dropped
+`,` yields a *build* failure, which is not evidence the check works), run
+`go test -count=1 ./internal/workspace/libs/errata` and `go test -C tools -count=1 ./oracle/errata`, then restore and confirm
+`git status --porcelain tools/oracle/errata internal/workspace/libs/errata` is empty. Mutations that must
+fail: AsPublished byte, empty Citation, empty Derivation, wrong Line, no-op Corrected, second
+entry for the same file, path outside `errata.Roots`.
 
 ## Doc gate
 
-`make docs-counts` → `doc-counts: already current`; `go run ./cmd/doc-counts -check` → exit 0.
+`make docs-counts` → `doc-counts: already current`; `go run -C tools ./cmd/doc-counts -check` → exit 0.
 Mutating the generated "Declared errata:" sentence in `README.md` (e.g. `325 of 353` → `326 of 353`)
 must make `-check` exit 1 with `README.md is stale` and a line diff; restore with `git checkout README.md`.
 
diff --git a/.agents/skills/testing-feature-accessibility/SKILL.md b/.agents/skills/testing-feature-accessibility/SKILL.md
index 708282dce9..68acd176fa 100644
--- a/.agents/skills/testing-feature-accessibility/SKILL.md
+++ b/.agents/skills/testing-feature-accessibility/SKILL.md
@@ -1,11 +1,11 @@
 ---
 name: testing-feature-accessibility
-description: How to end-to-end test the constraint-tier feature-accessibility rule in internal/core/passes/w8c_feature_reference.go ("Must be an accessible feature") — building a non-vacuous old-vs-new differential, sweeping examples/ and testdata/ for false positives, refereeing both directions against the pinned pilot validator, and the traps that have reverted this rule before.
+description: How to end-to-end test the constraint-tier feature-accessibility rule in internal/check/passes/w8c_feature_reference.go ("Must be an accessible feature") — building a non-vacuous old-vs-new differential, sweeping examples/ and tests/testdata/ for false positives, refereeing both directions against the pinned pilot validator, and the traps that have reverted this rule before.
 ---
 
 # Testing the `Must be an accessible feature` rule (W8C FeatureReferencePass)
 
-`internal/core/passes/w8c_feature_reference.go` runs at `LevelConstraint` and emits
+`internal/check/passes/w8c_feature_reference.go` runs at `LevelConstraint` and emits
 `Must be an accessible feature (use dot notation for nesting)`
 (code `feature-reference-featuring-types`) and `Must be a valid feature`
 (`feature-reference-referent`). This rule has been **written, reverted, and rewritten** because the
@@ -37,23 +37,23 @@ long time), so a case that fires today may not be attributable to the diff under
 parent build and compare:
 
 ```bash
-cp internal/core/passes/w8c_feature_reference.go /tmp/w8c_new.go
-git show HEAD~1:internal/core/passes/w8c_feature_reference.go > internal/core/passes/w8c_feature_reference.go
+cp internal/check/passes/w8c_feature_reference.go /tmp/w8c_new.go
+git show HEAD~1:internal/check/passes/w8c_feature_reference.go > internal/check/passes/w8c_feature_reference.go
 go build -o /tmp/sysml_old ./cmd/sysml
-cp /tmp/w8c_new.go internal/core/passes/w8c_feature_reference.go   # restore, then verify git diff
+cp /tmp/w8c_new.go internal/check/passes/w8c_feature_reference.go   # restore, then verify git diff
 ```
 
 Expect surprises: e.g. `calc def C { return x = P::Q::n; }` fires on *both* builds, because
 `return x = …` is a usage with a value and was already covered.
 
-## Sweep examples/ and testdata/ with both builds — this is where regressions surface
+## Sweep examples/ and tests/testdata/ with both builds — this is where regressions surface
 
 `go test ./...` does **not** protect this rule's corpora: the runtime conformance suite executes its
 models without running the constraint tier, so a model can start erroring in the CLI while the whole
 suite stays green. Diff the flagged-file sets:
 
 ```bash
-sweep() { find examples testdata internal/core/parser/testdata internal/core/runtime/testdata \
+sweep() { find examples tests/testdata tests/parser/testdata internal/exec/runtime/testdata \
     -type f \( -name '*.sysml' -o -name '*.kerml' \) -print0 |
   while IFS= read -r -d '' f; do
     n=$("$1" "$f" &1 | grep -c 'Must be an accessible feature')
@@ -63,7 +63,7 @@ sweep /tmp/sysml_old > /tmp/old.txt; sweep ./bin/sysml > /tmp/new.txt
 comm -13 /tmp/old.txt /tmp/new.txt   # newly flagged files
 ```
 
-**Known sensitive cluster:** `internal/core/runtime/testdata/conformance/` models where a named
+**Known sensitive cluster:** `internal/exec/runtime/testdata/conformance/` models where a named
 `action r accept msg : T;` node's parameter is read from a *sibling* node's body
 (`action p { assign total := msg; }`). OpenSysML deliberately shares accept parameters with sibling
 nodes (a divergence noted in the source near `w8cOwnedByImplicitNode`), but that escape hatch only
@@ -89,8 +89,15 @@ Filter `grep -v log4j`. Two caveats:
 
 **Gap class worth re-checking on any change here:** a body inside a *nested definition* that reads
 the *enclosing definition's* feature — `part def P { attribute n = 1; calc def E { n + 1 } }`, and
-the same shape with `constraint def` or a `state def` transition guard. The pilot reports; OpenSysML
-has been silent on all three. Minimal probes reproduce it in three lines, so include them.
+the same shape with `constraint def`, an `action def`'s `assign`/`if`, or a `state def` transition
+guard. The pilot reports each; OpenSysML now does too, because `passes/constraint.go`
+`featuringContexts` derives a featuring type only for a feature — a nested definition is an owned
+member of its owner, not featured by it. The boundary is the nested *usage* (`calc e { n + 1 }`),
+which is featured by `P` and must stay clean, as must a nested definition reading its own,
+inherited or redefined feature or a package-level one. `TestW8CFeatureReferenceBodyInaccessible` /
+`TestW8CFeatureReferenceBodyAccessible` pin both sides; the clean table asserts *no error of any
+tier*, so a fixture that trips a lower tier cannot pass by masking. Minimal probes reproduce the
+class in three lines, so keep including them.
 
 Element-filter expressions (`filter …;`, `import P::*[@T]`) are accessibility-checked with the
 candidate element as the featuring context; the referent is accessible when its declaration comes
@@ -119,8 +126,8 @@ Clean: a body naming its own / inherited / redefined feature; `s.mass` via a req
 ```bash
 go build ./... && go vet ./... && gofmt -l . && go test ./...
 OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 \
-  go test -count=1 ./internal/core/model -run 'TestTrainingExamples|TestPilotCorpora|TestCorpusGates'
-go run ./cmd/pilot-diff    # summary also lands in build/pilot-diff/pilot-diff.txt lines 5-7
+  go test -count=1 ./tests/corpus -run 'TestTrainingExamples|TestPilotCorpora|TestCorpusGates'
+go run -C tools ./cmd/pilot-diff    # summary also lands in build/pilot-diff/pilot-diff.txt lines 5-7
 ```
 
 `pilot-diff` takes a couple of minutes and writes only under `build/` (gitignored) — confirm
diff --git a/.agents/skills/testing-grammar-coverage/SKILL.md b/.agents/skills/testing-grammar-coverage/SKILL.md
index d2852becba..20d460845d 100644
--- a/.agents/skills/testing-grammar-coverage/SKILL.md
+++ b/.agents/skills/testing-grammar-coverage/SKILL.md
@@ -1,9 +1,9 @@
 ---
 name: testing-grammar-coverage
-description: How to verify the advisory grammar-production coverage harness (cmd/grammar-coverage + scripts/download-pilot-grammars.sh) end to end on Linux — provisioning the pinned OMG Xtext grammars, reproducing the committed compact baseline, checking the per-file-evidence property, and the adversarial paths worth trying.
+description: How to verify the advisory grammar-production coverage harness (tools/census/grammar + scripts/download-pilot-grammars.sh) end to end on Linux — provisioning the pinned OMG Xtext grammars, reproducing the committed compact baseline, checking the per-file-evidence property, and the adversarial paths worth trying.
 ---
 
-# Testing the grammar-coverage harness (`cmd/grammar-coverage`)
+# Testing the grammar-coverage harness (`tools/census/grammar`)
 
 Sibling of the pilot-differential harness (see `testing-pilot-differential/SKILL.md` — the same
 pin, the same "committed artifact, testable by reproduction" shape, the same Konsole recording
@@ -31,7 +31,7 @@ form-level unseen diagnostics.
 ```bash
 rm -rf build/pilot-grammars build/grammar-coverage
 ./scripts/download-pilot-grammars.sh          # 3 .xtext + PILOT_TAG (2026-08) under build/pilot-grammars
-go run ./cmd/grammar-coverage -baseline /tmp/nb.json     # ~15 s
+go run -C tools ./cmd/grammar-coverage -baseline /tmp/nb.json     # ~15 s
 cmp /tmp/nb.json docs/project/grammar-coverage-baseline.json    # must be silent
 ```
 
@@ -96,10 +96,10 @@ the file it drops into the REPL and blocks, so the next typed command is swallow
 
 ## Measuring an extra corpus root
 
-There is no flag for extra scanned roots (`cmd/grammar-coverage` takes only `-repo`, `-grammars`,
+There is no flag for extra scanned roots (`tools/census/grammar` takes only `-repo`, `-grammars`,
 `-out`, `-baseline`), so claims of the form "corpus X closes the unseen-form gap" need a temporary
-`corpusRoot` appended to `evidenceRoots` in `cmd/grammar-coverage/corpus.go`, then reverted. Example
-at `PILOT_TAG=2026-07`: adding `cmd/pilot-reject/testdata/negative` contributes 119 files / 842
+`corpusRoot` appended to `evidenceRoots` in `tools/census/grammar/corpus.go`, then reverted. Example
+at `PILOT_TAG=2026-07`: adding `tools/referee/reject/testdata/negative` contributes 119 files / 842
 lines and takes unseen forms 5 → 0 with `indistinguishable` unchanged at 244. Such a configuration
 is not reproduced by CI, so report it as a local measurement, not a baseline movement.
 
diff --git a/.agents/skills/testing-homebrew-packaging/SKILL.md b/.agents/skills/testing-homebrew-packaging/SKILL.md
index 39779323fa..39c9d403e5 100644
--- a/.agents/skills/testing-homebrew-packaging/SKILL.md
+++ b/.agents/skills/testing-homebrew-packaging/SKILL.md
@@ -125,7 +125,7 @@ untap old taps to silence it, or `brew trust local/`.
 
 ## Proving the solver stays optional at runtime (companion to `depends_on "z3"`)
 
-Discovery lives in `internal/core/solve/solver.go` (`OPENSYSML_SMT`, then `z3`, then `cvc5`) and
+Discovery lives in `internal/exec/solve/solver.go` (`OPENSYSML_SMT`, then `z3`, then `cvc5`) and
 its messages in `errors.go`. Drive `bin/sysml` with a scrubbed PATH — one directory per
 scenario, symlinking only the solver you want — and feed meta-commands on stdin:
 
@@ -156,7 +156,7 @@ only way to exercise it is a hand-built bundle served over `file://`:
 ```bash
 make build VERSION=v9.9.9
 R=/tmp/rel/Open-MBEE/OpenSysML/releases/download/v9.9.9; mkdir -p "$R" /tmp/stage/share/man/man1
-cp bin/sysml bin/sysml-lsp /tmp/stage/; cp man/man1/*.1 /tmp/stage/share/man/man1/
+cp bin/sysml bin/sysml-lsp /tmp/stage/; cp packaging/man/man1/*.1 /tmp/stage/share/man/man1/
 tar czf "$R/opensysml-linux-amd64.tar.gz" -C /tmp/stage sysml sysml-lsp share
 # hand-made SHA256SUMS with that one sum repeated for all four assets, then render, then in the
 # tap copy only: s|https://github.com/…/download|file:///tmp/rel/…/download|g
diff --git a/.agents/skills/testing-man-pages/SKILL.md b/.agents/skills/testing-man-pages/SKILL.md
index a00431dd5d..5e6d0e29f5 100644
--- a/.agents/skills/testing-man-pages/SKILL.md
+++ b/.agents/skills/testing-man-pages/SKILL.md
@@ -1,14 +1,14 @@
 ---
 name: testing-man-pages
-description: How to end-to-end test the generated manual pages and GNU-style installation (internal/usage, ` -man`, `make man` / `make man-check` / `make install-tree`, man/man1/*.1) on Linux — proving the drift gate is load-bearing, that `man` really renders an installed page, and that rendering is reproducible.
+description: How to end-to-end test the generated manual pages and GNU-style installation (internal/frontend/usage, ` -man`, `make man` / `make man-check` / `make install-tree`, packaging/man/man1/*.1) on Linux — proving the drift gate is load-bearing, that `man` really renders an installed page, and that rendering is reproducible.
 ---
 
 # Verifying the generated manual pages and `make install-tree` (Linux)
 
 Each command (`sysml`, `sysml-lsp`, `sysml-grpc`) declares a `usage.Doc` in its `usage.go`;
-`internal/usage` renders both the terminal help (`-help`) and the roff page (`-man`) from it, and
+`internal/frontend/usage` renders both the terminal help (`-help`) and the roff page (`-man`) from it, and
 options are enumerated from the command's `flag.FlagSet`. The pages are **committed** under
-`man/man1/*.1` and gated by `make man-check`.
+`packaging/man/man1/*.1` and gated by `make man-check`.
 
 ## Provisioning (neither is preinstalled by the blueprint)
 
@@ -31,14 +31,14 @@ packages plus the formatter lint. Both of these must FAIL, and `make man` must r
 
 ```bash
 # 1. mutate a committed page
-sed -i 's/SysML v2 and KerML models/SysML models/' man/man1/sysml.1
-make man-check   # → man/man1/sysml.1 is not what sysml -man now writes; run make man  (make Error 1)
-make man && git diff --stat man/   # empty: regeneration is byte-for-byte
+sed -i 's/SysML v2 and KerML models/SysML models/' packaging/man/man1/sysml.1
+make man-check   # → packaging/man/man1/sysml.1 is not what sysml -man now writes; run make man  (make Error 1)
+make man && git diff --stat packaging/man/   # empty: regeneration is byte-for-byte
 
 # 2. add a flag without regenerating
 #    insert fs.Bool("throwaway-flag", …) into cmd/sysml-lsp/usage.go registerFlags
-make man-check   # → fails naming man/man1/sysml-lsp.1
-make man && grep throwaway man/man1/sysml-lsp.1   # → .B \-throwaway\-flag
+make man-check   # → fails naming packaging/man/man1/sysml-lsp.1
+make man && grep throwaway packaging/man/man1/sysml-lsp.1   # → .B \-throwaway\-flag
 git checkout cmd/sysml-lsp/usage.go && make man && git status --short   # must end empty
 ```
 
@@ -52,7 +52,7 @@ emitted as `.B \-flag` or `.BI \-flag " value"`:
 
 ```bash
 ./bin/$c -help 2>&1 | grep -oE '^  -[a-zA-Z-]+' | tr -d ' ' | sort -u
-grep -oE '^\.BI? \\-[a-zA-Z\\-]+' man/man1/$c.1 | sed -E 's/^\.BI? //; s/\\//g' | sort -u
+grep -oE '^\.BI? \\-[a-zA-Z\\-]+' packaging/man/man1/$c.1 | sed -E 's/^\.BI? //; s/\\//g' | sort -u
 ```
 
 Expected today: `sysml` 34 flags, `sysml-lsp` 7, `sysml-grpc` 12, `diff` silent for each.
@@ -61,7 +61,7 @@ Also check ` -man` exits 0 and writes **0 bytes to stderr** (it is piped in
 
 ## Reproducibility
 
-`internal/usage/meta.go` reads `SOURCE_DATE_EPOCH` (seconds) and otherwise uses a constant
+`internal/frontend/usage/meta.go` reads `SOURCE_DATE_EPOCH` (seconds) and otherwise uses a constant
 `manDate`. So:
 
 ```bash
diff --git a/.agents/skills/testing-notation-warnings/SKILL.md b/.agents/skills/testing-notation-warnings/SKILL.md
index c44ad6e62e..9b74622a2e 100644
--- a/.agents/skills/testing-notation-warnings/SKILL.md
+++ b/.agents/skills/testing-notation-warnings/SKILL.md
@@ -1,11 +1,11 @@
 ---
 name: testing-notation-warnings
-description: How to end-to-end test a new `nonstandard-notation` warning in internal/core/passes/nonstandard_notation.go — which surfaces show it, how to build non-vacuous false-positive scans over the four OMG corpora, how strict-conformance escalation is observed, and how to referee the boundary against the pinned pilot validator.
+description: How to end-to-end test a new `nonstandard-notation` warning in internal/check/passes/nonstandard_notation.go — which surfaces show it, how to build non-vacuous false-positive scans over the four OMG corpora, how strict-conformance escalation is observed, and how to referee the boundary against the pinned pilot validator.
 ---
 
 # Testing a new `nonstandard-notation` warning
 
-`internal/core/passes/nonstandard_notation.go` emits one code, `nonstandard-notation`, at
+`internal/check/passes/nonstandard_notation.go` emits one code, `nonstandard-notation`, at
 `LevelSyntax`. Each rule matches an AST node shape and reports
 `` is an OpenSysML extension with no SysML v2 production: ``. Testing a newly
 added rule means proving four things: it fires at the user-facing surfaces, it does **not** fire on
@@ -17,7 +17,7 @@ boundary each spelling sits.
 `bin/sysml` has **no** `-conformance` flag: asking for one prints
 `flag provided but not defined: -conformance` plus the whole usage block and exits **2**, which
 reads exactly like the model failing to analyse. `-conformance auto|default|strict` belongs to
-`cmd/pilot-reject` (and the other pilot harnesses) only. The CLI spelling is `-validate -strict`,
+`tools/referee/reject` (and the other pilot harnesses) only. The CLI spelling is `-validate -strict`,
 the REPL's is `%strict on`, and the LSP's is the `strictConformance` initialization option.
 
 ## Severity-only claims must be tested against the pass tiers, not just the message
@@ -51,15 +51,15 @@ A severity move can preserve every diagnostic and still change behavior, because
 ask "is there an error?" independently of the pass runner. Grep for them before believing a
 severity-only claim; as of wave 10C the predicate is `Diagnostic.Blocking()`
 (`SeverityError && !Notation`) and the gates that use it are `Registry.Run`'s `hasError` and the
-REPL's `hasError` + `analysisBlocked` (`internal/repl/render.go`), while these deliberately keep a
+REPL's `hasError` + `analysisBlocked` (`internal/frontend/repl/render.go`), while these deliberately keep a
 **raw** `SeverityError` check:
 
-- `internal/repl/run.go`'s `Session.hasAnalysisErrors` → `HasErrors()`, which gates the CLI check
-  flags (`cmd/sysml/check.go`), `%query` (`internal/repl/oslc_query.go`) and `LoadReport.Errors`
-- `internal/core/edit/validate.go`'s `errorsOnly`
+- `internal/frontend/repl/run.go`'s `Session.hasAnalysisErrors` → `HasErrors()`, which gates the CLI check
+  flags (`cmd/sysml/check.go`), `%query` (`internal/frontend/repl/oslc_query.go`) and `LoadReport.Errors`
+- `internal/check/edit/validate.go`'s `errorsOnly`
 
 ```bash
-grep -rn 'SeverityError' --include=*.go internal/ cmd/ | grep -v internal/core/passes/
+grep -rn 'SeverityError' --include=*.go internal/ cmd/ | grep -v internal/check/passes/
 ```
 
 So test three distinct things, not one: (1) no diagnostic is lost, (2) the REPL still prints its
@@ -71,7 +71,7 @@ The trap to watch for: a diagnostic that is **both** `Notation` and `SeverityErr
 will disagree with `Blocking()` on every raw-severity gate. Find them with
 
 ```bash
-grep -rn -B8 'Notation:\s*true\|Notation\s*=\s*true' --include=*.go internal/core/passes/ | grep -E 'Severity|func '
+grep -rn -B8 'Notation:\s*true\|Notation\s*=\s*true' --include=*.go internal/check/passes/ | grep -E 'Severity|func '
 ```
 
 Historically `import-visibility` is the only such case (`nonstandard-notation` is a warning by
@@ -121,7 +121,7 @@ invents cross-file diagnostics — keep one file per invocation and parallelize
 per file, 429 files × 2 binaries × 2 modes finishes in well under a minute:
 
 ```bash
-find examples internal/core/libs/stdlib -name '*.sysml' -o -name '*.kerml' | sort > /tmp/files.txt
+find examples internal/workspace/libs/stdlib -name '*.sysml' -o -name '*.kerml' | sort > /tmp/files.txt
 xargs -a /tmp/files.txt -d '\n' -P8 -I{} /tmp/scan.sh /tmp/sysml-pr "" {} > /tmp/diag_pr.txt
 ```
 
@@ -174,7 +174,7 @@ caret line. Eyeballing two screenshots is not.
 It parses the 95 embedded stdlib files and asserts zero **parse** diagnostics against
 `testdata/stdlib_known_failures.txt`; it never runs the passes. So it is *not* evidence that a new
 pass rule or a severity change leaves the stdlib alone. For that, grep the stdlib for the
-construct directly (e.g. `grep -rlE '^[[:space:]]*import ' internal/core/libs/stdlib` returns 0
+construct directly (e.g. `grep -rlE '^[[:space:]]*import ' internal/workspace/libs/stdlib` returns 0
 files, which is the real reason an import-visibility change cannot touch it).
 
 ## Recording the CLI/REPL surfaces
@@ -226,13 +226,13 @@ Use an explicit `private import ScalarValues::*;` only when you need the file to
 
 The OMG-authored corpora are asserted clean, so **any** hit is a defect. Roots:
 `examples/pilot-corpora` (includes `sysml-examples` and `kerml-examples`),
-`examples/sysml-v2-training`, and the stdlib `internal/core/libs/stdlib`.
+`examples/sysml-v2-training`, and the stdlib `internal/workspace/libs/stdlib`.
 
 ```bash
 while IFS= read -r f; do
   bin/sysml -validate "$f" 2>&1 | grep -E "" | sed "s|^|HIT |"
 done < <(grep -rl --include=*.sysml --include=*.kerml -E '(^|[^A-Za-z_])(return|assert|assume) ' \
-           examples/pilot-corpora examples/sysml-v2-training internal/core/libs/stdlib)
+           examples/pilot-corpora examples/sysml-v2-training internal/workspace/libs/stdlib)
 ```
 
 - Use `grep -rl` on the *keyword* to pick candidate files (99 files for `return`/`assert`/`assume`),
@@ -287,7 +287,7 @@ expecting EOF`) and it also emits its own `Duplicate of other owned member name`
 The single most likely coverage gap when a rule covers "assert/assume conditions": a condition in a
 **constraint** body is `*ast.ConstraintMember` (fields `Keyword`, `IsNegated`, `Expression`, `Name`,
 `Body`), but the same-looking condition in a **requirement** body is `*ast.AssumeMember` /
-`*ast.RequireMember` (`internal/core/ast/behavior.go`, "Phase C2: Requirement Body Members"), with a
+`*ast.RequireMember` (`internal/syntax/ast/behavior.go`, "Phase C2: Requirement Body Members"), with a
 different field set. A rule keyed on `*ast.ConstraintMember` therefore covers a constraint-body condition and
 misses the requirement-body ones. The keyworded inline condition
 (`constraint c { assert x >= 0; }`, `requirement r { require x > 0; }`) is now rejected by the
@@ -300,7 +300,7 @@ than only checking the lines the task named.
 
 ## Verify oracle baselines against a LIVE run, not just against the docs
 
-`cmd/pilot-diff/doc_counts_test.go` guards documentation prose against the **committed**
+`tools/referee/diff/doc_counts_test.go` guards documentation prose against the **committed**
 `docs/project/pilot-xpect-baseline.json` / `pilot-rejection-baseline.json`. It therefore cannot
 notice that both the prose *and* the committed baseline have drifted away from what the code now
 does — they stay self-consistent while both go stale, and `go test ./...` stays green. A change
@@ -310,7 +310,7 @@ gated now surface.
 So always run the oracle and diff the totals yourself:
 
 ```bash
-go run ./cmd/pilot-xpect -jobs 8
+go run -C tools ./cmd/pilot-xpect -jobs 8
 cmp build/pilot-xpect/pilot-xpect.json docs/project/pilot-xpect-baseline.json   # may legitimately differ
 python3 -c "
 import json
@@ -332,7 +332,7 @@ worktree before escalating, because "the branch broke the oracle" and "the basel
 stale" need completely different responses:
 
 ```bash
-cd /home/ubuntu/wt-main && git log --oneline -1 && go run ./cmd/pilot-xpect -jobs 8
+cd /home/ubuntu/wt-main && git log --oneline -1 && go run -C tools ./cmd/pilot-xpect -jobs 8
 ```
 
 A worked example: xpect measured 939/387/0 live against a committed 845/481/18, which looked like a
@@ -375,10 +375,10 @@ obvious tests only catch one:
 So always pin the positive case on the **real binary**, not only in unit tests:
 
 ```bash
-G=cmd/pilot-reject/testdata/negative/grammar/g15-keyword-as-name.sysml   # part def part;
+G=tools/referee/reject/testdata/negative/grammar/g15-keyword-as-name.sysml   # part def part;
 sysml -validate        $G   # expect: warning only at 3:14, exit 0
 sysml -validate -strict $G   # expect: error at 3:14 + "did not analyse cleanly", exit 2
-go run ./cmd/pilot-reject -conformance strict   # expect 116 both reject / 3 pilot-only
+go run -C tools ./cmd/pilot-reject -conformance strict   # expect 116 both reject / 3 pilot-only
 ```
 
 A strict run that produces no error, or a rejection count slipping to 115/4, means the escalation
@@ -390,11 +390,11 @@ intact. Two forwarders do that, and if either dropped the code the rule would si
 everywhere:
 
 ```bash
-grep -n "Code:" internal/core/model/workspace.go   # ~244, CLI + LSP path
-grep -n "Code:" internal/repl/session.go           # ~555, REPL path
+grep -n "Code:" internal/workspace/model/workspace.go   # ~244, CLI + LSP path
+grep -n "Code:" internal/frontend/repl/session.go           # ~555, REPL path
 ```
 
-So a keyword-as-name check that passes only in `internal/core/passes` unit tests proves nothing about
+So a keyword-as-name check that passes only in `internal/check/passes` unit tests proves nothing about
 the CLI, REPL or LSP — exercise at least one real surface.
 
 ## Every diagnostic from the notation pass is Notation, whatever its code
@@ -407,14 +407,14 @@ notation. A hand-maintained set will misclassify one of them and produce a false
 
 ```bash
 grep -rn "Notation:\s*true\|\.Notation = true" --include=*.go internal/ | grep -v _test
-grep -n "Code[A-Za-z]* =" internal/core/passes/nonstandard_notation.go
+grep -n "Code[A-Za-z]* =" internal/check/passes/nonstandard_notation.go
 ```
 
 ## Surface and flag names that waste time
 
 - The CLI strict flag is **`-strict`**, not `-conformance strict`. The latter is a flag-parse error:
   it dumps usage and exits 2, which looks exactly like a legitimate refusal and will silently fake
-  a "strict refuses" pass. Confirm with `sysml -h | grep -i strict`. (`cmd/pilot-reject` *does*
+  a "strict refuses" pass. Confirm with `sysml -h | grep -i strict`. (`tools/referee/reject` *does*
   take `-conformance default|strict` — the two binaries differ.)
 - There is no `-check` flag and no `%run` command. Use the check flags
   (`-instantiate`, `-constraint`, `-satisfy`, `-query`, ...) and `%strict on|off`.
@@ -455,15 +455,15 @@ not a regression of the declared-members summary. Compare summaries in **default
 
 ## De-duplicating a parser warning against a notation error: sweep every construct
 
-`dropEscalatedWarnings` (`internal/core/passes/analyze.go`) drops a parse **warning** only where a
+`dropEscalatedWarnings` (`internal/check/passes/analyze.go`) drops a parse **warning** only where a
 pass reported an **error** of the same code at the same span, after the whole registry ran. Filtering
 by code alone — as `SyntaxPass.Run` once did — is asymmetric and is the thing to attack:
 
 - the parser emits the warning from **one** general site, `parseIdentification()` in
-  `internal/core/parser/namespace.go` (~line 222), so *any* construct whose name flows through it
+  `internal/syntax/parser/namespace.go` (~line 222), so *any* construct whose name flows through it
   can produce the warning;
 - the notation walker calls `keywordAsName` from only a handful of cases in
-  `internal/core/passes/nonstandard_notation.go` — the `Ident` of `ast.Namespace`, `ast.Package`,
+  `internal/check/passes/nonstandard_notation.go` — the `Ident` of `ast.Namespace`, `ast.Package`,
   `ast.Definition` and `ast.Usage`.
 
 Any construct in the first set but not the second **loses its only diagnostic under strict**, making
@@ -493,7 +493,7 @@ Before trusting a corpus differential as evidence for a keyword-as-name change,
 code actually produces across the corpora:
 
 ```bash
-find examples/pilot-corpora internal/core/libs/stdlib -name '*.sysml' -o -name '*.kerml' \
+find examples/pilot-corpora internal/workspace/libs/stdlib -name '*.sysml' -o -name '*.kerml' \
   | xargs -P8 -I{} sh -c '/tmp/sysml -validate -strict "{}" 2>&1' | grep -c 'reserved keyword'
 ```
 
@@ -525,7 +525,7 @@ Point the *same* parsing logic at `g15-keyword-as-name.sysml` on a build that pr
 (`85aa4140`): it must report exactly **1** duplicate span (`3:14` holding both `error` and
 `warning`), and the build under test must report **0** on that same file. Without that column, a
 regex that silently matches nothing looks identical to a clean result. Note g15 lives under
-`cmd/pilot-reject/testdata/`, which is *outside* the usual corpus roots, so the corpus scan alone
+`tools/referee/reject/testdata/`, which is *outside* the usual corpus roots, so the corpus scan alone
 never touches the one file that exercises the pair.
 
 ## Testing the *removal* of a notation spelling (the mirror image of adding a rule)
@@ -540,7 +540,7 @@ warning is replaced by a parser error. Test it as a pair of claims, not one:
    word, and exit **2**. One fixture per construct — parser recovery cascades and invents
    `expected a namespace member` errors on the closing braces if you put several in one file.
 2. **The word must still work as an ordinary name**, because these words are *unreserved*
-   (`internal/core/lexer/contextual.go`, `parser/notation.go` `notationWords`). Put
+   (`internal/syntax/lexer/contextual.go`, `parser/notation.go` `notationWords`). Put
    `attribute final : Boolean;`, `action initial;`, `out decision : Boolean;`, a kindless `final;`
    and a kindless `decision;`, and `first initial then final2;` in one clean file: default mode must
    exit 0 and `-convert sysml` must reproduce every member verbatim.
@@ -568,7 +568,7 @@ for f in state_markers.sysml oneended_first.sysml; do for m in "" "-strict"; do
   [ "$a" = "$b" ] && echo "IDENTICAL $f [$m]" || diff <(echo "$b") <(echo "$a"); done; done
 ```
 
-A whole-repo sweep of the same comparison (`examples`, `testdata`, `internal/core/*/testdata`;
+A whole-repo sweep of the same comparison (`examples`, `testdata`, `internal/*//testdata`;
 ~1440 files, ~4 min serial) is the false-positive control, and it is *non-vacuous* here because the
 hand-written removed-spelling fixtures above do differ under the identical method — cite that as the
 control rather than reporting "0 differences" alone.
diff --git a/.agents/skills/testing-pilot-corpora-gate/SKILL.md b/.agents/skills/testing-pilot-corpora-gate/SKILL.md
index 8cf9c297c7..eeeeacca87 100644
--- a/.agents/skills/testing-pilot-corpora-gate/SKILL.md
+++ b/.agents/skills/testing-pilot-corpora-gate/SKILL.md
@@ -1,14 +1,14 @@
 ---
 name: testing-pilot-corpora-gate
-description: How to verify the four OMG corpus gates (internal/core/model/corpus_gate_test.go + pilot_corpora_test.go + training_examples_test.go and their testdata expectations) end to end on Linux — running both gates, proving the baselines are reproducible/machine-independent, the adversarial mutations that must fail, absence handling per root, and exercising the shared pilot-pin.sh downloader.
+description: How to verify the four OMG corpus gates (tests/corpus/corpus_gate_test.go + pilot_corpora_test.go + training_examples_test.go and their testdata expectations) end to end on Linux — running both gates, proving the baselines are reproducible/machine-independent, the adversarial mutations that must fail, absence handling per root, and exercising the shared pilot-pin.sh downloader.
 ---
 
 # Testing the OMG corpus gates (training assertion + pilot-corpora ratchet)
 
-Shell-only; no GUI or recording needed. One full `./internal/core/model` run is ~60s; the two
+Shell-only; no GUI or recording needed. One full `./internal/workspace/model` run is ~60s; the two
 corpus tests alone are ~4s, so iterate with `-run` and only do the full run at the start/end.
 
-Four pinned OMG model roots, **one mechanism, two policies** (`internal/core/model/corpus_gate_test.go`):
+Four pinned OMG model roots, **one mechanism, two policies** (`tests/corpus/corpus_gate_test.go`):
 
 | root | dir | policy | expectation file |
 |---|---|---|---|
@@ -23,7 +23,7 @@ Four pinned OMG model roots, **one mechanism, two policies** (`internal/core/mod
 ./scripts/download-training-examples.sh   # examples/sysml-v2-training  (untracked/gitignored)
 ./scripts/download-pilot-corpora.sh       # examples/pilot-corpora      (untracked/gitignored)
 OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 \
-  go test -count=1 -v ./internal/core/model
+  go test -count=1 -v ./internal/workspace/model
 ```
 
 The corpora are gitignored, so **copy them aside first** (`cp -a examples/pilot-corpora
@@ -46,21 +46,21 @@ output with `grep -E "a|b"` breaks when a pattern starts with `-`; use `grep -E
 ## Reproducibility of the baselines
 
 ```bash
-go test -count=1 ./internal/core/model -run TestPilotCorporaDiagnostics    -update-pilot-corpora
-go test -count=1 ./internal/core/model -run TestTrainingExamplesSemanticErrors -update-training
-git diff --exit-code -- internal/core/model/testdata/
+go test -count=1 ./tests/corpus -run TestPilotCorporaDiagnostics    -update-pilot-corpora
+go test -count=1 ./tests/corpus -run TestTrainingExamplesSemanticErrors -update-training
+git diff --exit-code -- internal/workspace/model/testdata/
 ```
 
 Regeneration must be byte-identical across runs, from a different cwd
 (`cd /tmp && XDG_CACHE_HOME=$(mktemp -d) go test -C  ...`) and with a fresh `XDG_CACHE_HOME` —
 each test sets `XDG_CACHE_HOME` to a temp dir itself, which is what makes it machine-independent.
 Also check no absolute paths leak:
-`grep -nE '(^|[[:space:]])/(home|tmp|Users)' internal/core/model/testdata/*_expected.txt` must find nothing.
+`grep -nE '(^|[[:space:]])/(home|tmp|Users)' tests/corpus/testdata/*_expected.txt` must find nothing.
 
 ## Adversarial mutations that must each fail (restore with `git checkout --` after each)
 
-Mutate `internal/core/model/testdata/pilot_corpora_expected.txt` and re-run
-`OPENSYSML_REQUIRE_PILOT_CORPORA=1 go test -count=1 ./internal/core/model -run TestPilotCorporaDiagnostics`:
+Mutate `tests/corpus/testdata/pilot_corpora_expected.txt` and re-run
+`OPENSYSML_REQUIRE_PILOT_CORPORA=1 go test -count=1 ./tests/corpus -run TestPilotCorporaDiagnostics`:
 
 | mutation | expected message |
 |---|---|
@@ -106,7 +106,7 @@ For each of the four roots, in three shapes (moved aside / empty-but-present / o
   `... holds no model files: ...`
 - without it → exit 0 with `--- SKIP` plus the `!!! GATE NOT RUN` stderr banner naming the right
   fetch script for that gate.
-- CI catch: `go test -count=1 -v ./internal/core/model -run 'TestTrainingExamples|TestCorpusGates' | tee corpus-gate.log`
+- CI catch: `go test -count=1 -v ./tests/corpus -run 'TestTrainingExamples|TestCorpusGates' | tee corpus-gate.log`
   then `grep -qE '^\s*--- SKIP' corpus-gate.log`. Note an absent **pilot** root surfaces in that CI
   command only as `--- SKIP: TestCorpusGatesCacheStateIndependent/pilot-corpora` (the ratchet test
   itself is not in the `-run` pattern) — the grep still catches it, and that is worth verifying.
@@ -174,7 +174,7 @@ checkout so the gate does not skip):
    and keep a genuine violation of the same check nearby as the negative control, so "fixed" is
    distinguished from "check dropped".
 
-Print spans for one corpus file by dropping a scratch `*_test.go` into `internal/core/model`: the
+Print spans for one corpus file by dropping a scratch `*_test.go` into `internal/workspace/model`: the
 gate's own helpers are package-private but reusable (`pilotCorporaGate.files(t)` /
 `.counts(t, files)`), diagnostics carry byte offsets only, so map them with
 `source.New(name, content).Lines().PosAt(d.Span.Offset)`.
@@ -182,9 +182,9 @@ gate's own helpers are package-private but reusable (`pilotCorporaGate.files(t)`
 ## Tooling on this box
 
 `actionlint`, `shellcheck`, `python3 scripts/check-doc-links.py`, `gofmt`, `go vet`,
-`go run ./cmd/pilot-diff` (validators pre-downloaded; ~4min, prints e.g.
-the headline the committed baseline holds — `375 file(s), 345 fully agreeing; 38 agreed
-diagnostic(s), 38 only ours, 1109 only the pilot's` after the argument-binding conformance round at the `2026-08` pin, so read it from
+`go run -C tools ./cmd/pilot-diff` (validators pre-downloaded; ~4min, prints e.g.
+the headline the committed baseline holds — `379 file(s), 347 fully agreeing; 38 agreed
+diagnostic(s), 38 only ours, 1582 only the pilot's` at the `2026-08` pin, so read it from
 `docs/project/pilot-differential-baseline.json` rather than from this line)
 and `make lint` (staticcheck+gosec, ~2min) all work. There is **no** `yamllint` and **no**
 `circleci` CLI, so `.circleci/config.yml` can only be parsed as YAML, not schema-validated — say so
diff --git a/.agents/skills/testing-pilot-differential/SKILL.md b/.agents/skills/testing-pilot-differential/SKILL.md
index ff0cb087c4..bea4275b73 100644
--- a/.agents/skills/testing-pilot-differential/SKILL.md
+++ b/.agents/skills/testing-pilot-differential/SKILL.md
@@ -1,9 +1,9 @@
 ---
 name: testing-pilot-differential
-description: How to verify the advisory pilot-implementation differential harness (cmd/pilot-diff + scripts/download-pilot-sysml-validator.sh) end to end on Linux — provisioning the batch SysML/KerML oracles, reproducing the committed baseline, and the adversarial paths (bad pin, missing tools, wrong flags) worth checking.
+description: How to verify the advisory pilot-implementation differential harness (tools/referee/diff + scripts/download-pilot-sysml-validator.sh) end to end on Linux — provisioning the batch SysML/KerML oracles, reproducing the committed baseline, and the adversarial paths (bad pin, missing tools, wrong flags) worth checking.
 ---
 
-# Testing the pilot differential harness (`cmd/pilot-diff`)
+# Testing the pilot differential harness (`tools/referee/diff`)
 
 **Since F6 (PR #397) the default SysML oracle is the plain-Java batch bridge
 `build/pilot-sysml-validator/validate-sysml-batch`, not the DeciSym CLI
@@ -14,26 +14,26 @@ compiles and runs against them). It loads every `.sysml` file of a corpus root i
 resource set (`SysMLUtil.readResource`/`addInputResource`) and only then validates, printing
 GNU-format diagnostics **relative to `--root`**. Consequences for testing:
 
-- `cmd/pilot-diff/order.go` (`orderByImports`) and `batchByBaseName` are **deleted**; one root
+- `tools/referee/diff/order.go` (`orderByImports`) and `batchByBaseName` are **deleted**; one root
   is exactly one invocation regardless of duplicate base names or import order. Anything in
   this file that still says "topologically sorted" or "split by basename" applies to history
   only. The KerML and SysML sides now share one `pilotDiagnostics`.
-- The pin `cmd/pilot-diff` reports comes from `build/pilot-sysml-validator/pilot-pin.txt`
+- The pin `tools/referee/diff` reports comes from `build/pilot-sysml-validator/pilot-pin.txt`
   (written by the new script), not from the DeciSym `pom.xml`.
 - `-validator /nonexistent` now says `run ./scripts/download-pilot-sysml-validator.sh`.
-- Measured after the argument-binding conformance round at the `2026-08` pin, with a fresh library cache: `375 file(s), 345 fully agreeing; 38 agreed,
-  38 only ours, 1109 only the pilot's`, JSON totals `openSysMLDiagnostics 79 / pilotDiagnostics
-  1150 / severityMismatch 3`; ~2 min wall, byte-identical across runs *and* after a from-scratch
+- Measured at the `2026-08` pin, with a fresh library cache: `379 file(s), 347 fully agreeing; 38 agreed,
+  38 only ours, 1582 only the pilot's`, JSON totals `openSysMLDiagnostics 79 / pilotDiagnostics
+  1623 / severityMismatch 3`; ~2 min wall, byte-identical across runs *and* after a from-scratch
   rebuild of `build/pilot-validator`. The six `kerml-examples` pilot-only rows the `2026-07` run
   carried (`The opposite features 'owningType' … do not refer to each other`) are gone: the pilot
   fixed its `ownedDisjoining` delegate, and nothing on our side moved. `kerml-examples` carries no `syntax` diagnostic on either
   side. Refresh this paragraph with every rebaseline, and treat a stale one as a finding.
-- **`cmd/pilot-diff` has no `-jobs` flag.** Its full flag set is
+- **`tools/referee/diff` has no `-jobs` flag.** Its full flag set is
   `-repo -validator -kerml-validator -syside -out -timeout`; passing `-jobs` exits **2** with
-  `flag provided but not defined: -jobs`. Only `cmd/pilot-xpect` is job-parallel. So a PR that
+  `flag provided but not defined: -jobs`. Only `tools/referee/xpect` is job-parallel. So a PR that
   claims the differential is "deterministic across `-jobs` settings" is claiming something
   untestable — prove differential determinism instead with two or three *independent* fresh-cache
-  runs (`rm -rf /tmp/cN && XDG_CACHE_HOME=/tmp/cN go run ./cmd/pilot-diff -out /tmp/pd-runN`) and
+  runs (`rm -rf /tmp/cN && XDG_CACHE_HOME=/tmp/cN go run -C tools ./cmd/pilot-diff -out /tmp/pd-runN`) and
   compare each `pilot-diff.json` to the committed baseline with `cmp`, or hash all of them and
   assert a single distinct sha256.
 - **Assert the pilot column is non-empty before believing any census (the silent-zero trap).** If
@@ -78,7 +78,7 @@ GNU-format diagnostics **relative to `--root`**. Consequences for testing:
   goes stale exactly like the differential one, and a merge of `main` into the branch can move
   it. At wave 11E both branch and control measured `120 / 116 both reject / 4 pilot-only`
   while the committed baseline still held `114 / 6` — the two moved cases were `main`'s
-  metadata-evaluability work, not the branch's. Run `cmd/pilot-reject` on the control before
+  metadata-evaluability work, not the branch's. Run `tools/referee/reject` on the control before
   crediting a rejection delta to the PR, and compare with
   `diff <(jq -S 'del(.validator,.pilot)' a) <(jq -S 'del(.validator,.pilot)' b)` so the
   volatile pin/validator fields do not mask the real comparison.
@@ -90,7 +90,7 @@ GNU-format diagnostics **relative to `--root`**. Consequences for testing:
   errors + 1 `kind-mismatch` + 8 `unmapped` warnings appeared instead. Tier unblocking is the
   normal consequence of accepting new syntax, so expect it — but each newly revealed only-ours
   *error* is a fresh candidate false positive and should be named in the report even when the
-  committed per-file ratchet (`internal/core/model/testdata/pilot_corpora_expected.txt`) already
+  committed per-file ratchet (`tests/corpus/testdata/pilot_corpora_expected.txt`) already
   records the new number.
 - `TestPilotDifferentialDocumentCountsMatchBaseline` reads only the *committed* baseline JSON, so
   it proves doc ↔ baseline consistency and cannot detect a committed baseline that no longer
@@ -98,7 +98,7 @@ GNU-format diagnostics **relative to `--root`**. Consequences for testing:
 - **Symlinked corpus roots are silently skipped.** The corpus walker does not follow symlinks, so a
   baseline worktree whose `examples/sysml-v2-training` or `examples/pilot-corpora` is a symlink into
   the real checkout drops that whole root from the report without warning — the file count simply
-  comes out lower. Run the baseline as `go run ./cmd/pilot-diff -repo ` instead.
+  comes out lower. Run the baseline as `go run -C tools ./cmd/pilot-diff -repo ` instead.
 - **Silence can be a tier artifact, not a missing rule.** A type-tier error suppresses every
   constraint-tier pass for the whole file, so when a CLI run is silent, prove the rule is live with a
   positive control in the same file shape before concluding anything.
@@ -107,7 +107,7 @@ GNU-format diagnostics **relative to `--root`**. Consequences for testing:
     `pilot-differential-baseline.json` (the existing guard);
   - differential headlines and `openSysMLDiagnostics`/`pilotDiagnostics`/`severityMismatch`
     lines in every `.agents/skills/**/SKILL.md` against the same baseline (the new guard in
-    `cmd/pilot-diff/w6f_skill_counts_test.go`);
+    `tools/referee/diff/w6f_skill_counts_test.go`);
   - the Totals block, per-kind table, per-suite table, and census prose in
     `docs/project/pilot-xpect.md` against `pilot-xpect-baseline.json`.
   - A live-looking headline is checked by default. `` (optionally
@@ -125,7 +125,7 @@ GNU-format diagnostics **relative to `--root`**. Consequences for testing:
   clean, exit 0; `build/pilot-validator/validate-sysml` on the same argv reports
   `Couldn't resolve reference to Namespace 'PkgB'` — order dependence, exit 1.
 - `Duplicate of other owned member name` is **not** a wrapper artifact: it reproduces on a
-  single file in isolation under both oracles (e.g. `testdata/passes/corpus_notation.sysml`
+  single file in isolation under both oracles (e.g. `tests/testdata/passes/corpus_notation.sysml`
   lines 33/34, the `timeslice item item1` / `snapshot item item1` inside `item item1`), with
   or without `--root`, and its count does not grow with batch size. 25 warnings (summed `xK`
   multiplicities, over 7 files of `testdata` and `examples`) remain in the F6 report; 23 of
@@ -136,8 +136,8 @@ The harness compares OpenSysML diagnostics against the OMG SysML v2 Pilot Implem
 (via two pinned plain-Java bridges over the pilot's own validators) over four corpus roots and writes
 `build/pilot-diff/pilot-diff.{txt,json}`. `docs/project/pilot-differential-baseline.json` is the
 committed result of the *last refreshed* run, so **the harness is testable by reproduction** —
-but only while the baseline is current. Check that first. As of the rebaseline that came with the argument-binding conformance round it **is**
-current: a live run gives `375 file(s), 345 fully agreeing; 38 agreed, 38 only ours, 1109 only the
+but only while the baseline is current. Check that first. As of the rebaseline that came when the Legend of the Red Dragon example left for its own repository it **is**
+current: a live run gives `379 file(s), 347 fully agreeing; 38 agreed, 38 only ours, 1582 only the
 pilot's`, byte-identical to the committed baseline, and `docs/project/pilot-differential.md`'s
 "Results" table matches. The rebaseline before it, at the architecture self-model's landing, covered two rounds, because the succession-shorthand
 removal before it landed without refreshing the baseline; a control run of its merge commit gives
@@ -158,7 +158,7 @@ regression — see "Isolating one change's effect" below.
   absent, so provisioning either is enough.
 - The SysML oracle: `./scripts/download-pilot-sysml-validator.sh` (needs `javac`). It compiles
   `scripts/pilot-sysml-validator/ValidateSysML.java` into
-  `build/pilot-sysml-validator/validate-sysml-batch`, which is what `cmd/pilot-diff` runs by
+  `build/pilot-sysml-validator/validate-sysml-batch`, which is what `tools/referee/diff` runs by
   default. It batch-loads: every file of a root enters one resource set before any is validated.
   The older `build/pilot-validator/validate-sysml` (the DeciSym interactive CLI) is still built by
   the provisioning script and is still the handiest way to ask the reference about a single file.
@@ -196,7 +196,7 @@ regression — see "Isolating one change's effect" below.
 ## The core check (fast, ~20 s per run)
 
 ```bash
-rm -rf build/pilot-diff && go run ./cmd/pilot-diff        # ~19 s wall, ~1 min CPU
+rm -rf build/pilot-diff && go run -C tools ./cmd/pilot-diff        # ~19 s wall, ~1 min CPU
 diff <(jq -S . docs/project/pilot-differential-baseline.json) \
      <(jq -S . build/pilot-diff/pilot-diff.json)          # must be empty
 ```
@@ -315,8 +315,8 @@ diff the two JSONs:
 
 ```bash
 git worktree add /tmp/wt-base 
-(cd /tmp/wt-base && go run ./cmd/pilot-diff -repo /path/to/real/checkout -out /tmp/pd-base)
-go run ./cmd/pilot-diff -out /tmp/pd-head
+(cd /tmp/wt-base && go run -C tools ./cmd/pilot-diff -repo /path/to/real/checkout -out /tmp/pd-base)
+go run -C tools ./cmd/pilot-diff -out /tmp/pd-head
 diff <(jq -S . /tmp/pd-base/pilot-diff.json) <(jq -S . /tmp/pd-head/pilot-diff.json)
 ```
 
@@ -392,7 +392,7 @@ pilot-side diagnostics for it (observed: 6 pilot-only + 1 agreed on that one fil
 Since F34, language is a per-file property (`source.KindOf`), so a root collects both extensions
 and runs one reference invocation per language over all of that language's files. stderr prints one
 line per language per root (`testdata: 10 SysML file(s)` then `testdata: 1 KerML file(s)`), and our
-own `.kerml` fixtures under `testdata/` and `examples/` are compared.
+own `.kerml` fixtures under `tests/testdata/` and `examples/` are compared.
 
 The control for a dispatch change: a synthetic repo with byte-identical `testdata/adv.sysml` and
 `testdata/adv.kerml`, run at HEAD and in a parent worktree (`-repo` plus absolute validator flags).
@@ -402,19 +402,19 @@ run reporting only `adv.sysml` is the proof the delta belongs to the change.
 ## Testing language-scoped (`.sysml` vs `.kerml`) diagnostic behaviour
 
 Some checks are gated on the document's language via `source.KindOf(name)` (e.g. the KerML
-type tier in `internal/core/passes/typecheck.go`). **Which surface you observe from decides
+type tier in `internal/check/passes/typecheck.go`). **Which surface you observe from decides
 whether you see it at all**, because only some surfaces analyse under the real file name:
 
 | Surface | Document name passed to `passes.Analyze` | Language honoured? |
 |---|---|---|
-| `cmd/pilot-diff` (`opensysml.go`, `ws.Open(rel, ...)`) | corpus-relative path with extension | **yes** |
-| `sysml-lsp` / `sysml-grpc` (`internal/core/model/workspace.go`) | the opened file's URI/path | **yes** |
-| `cmd/sysml -validate ` (`internal/repl/session.go`) | the real path — `session.go` branches on `source.KindOf(origin)` | **yes** (verified at `5ac8b6fb`) |
+| `tools/referee/diff` (`opensysml.go`, `ws.Open(rel, ...)`) | corpus-relative path with extension | **yes** |
+| `sysml-lsp` / `sysml-grpc` (`internal/workspace/model/workspace.go`) | the opened file's URI/path | **yes** |
+| `cmd/sysml -validate ` (`internal/frontend/repl/session.go`) | the real path — `session.go` branches on `source.KindOf(origin)` | **yes** (verified at `5ac8b6fb`) |
 | `cmd/sysml` interactive REPL typing / stdin (`-`) | the constant `""` | **no** — `KindUnknown`, so SysML rules |
 
 The REPL caveat applies to text *typed into* the session (or piped on stdin), which lands in one
 accumulated buffer named `` that `typecheck.go` deliberately reads as SysML. A file named on
-the command line under `-validate` is **not** in that bucket: `internal/repl/session.go` (the
+the command line under `-validate` is **not** in that bucket: `internal/frontend/repl/session.go` (the
 `source.KindOf(origin) == source.KindKerML` branch, line ~37 at `5ac8b6fb`) honours the extension,
 so `bin/sysml -validate 'examples/pilot-corpora/kerml-examples/Simple Tests/Conjugation.kerml'`
 **is** a valid, and by far the cheapest, KerML surface — it printed `no errors` / exit 0 there
@@ -422,14 +422,14 @@ while the same file under a reverted fix printed the KerML-only diagnostics. Re-
 rather than trusting either claim blindly, but do not skip the CLI on the assumption it is
 language-blind.
 
-### Fixture-backed `internal/core/passes` tests can be silently vacuous
+### Fixture-backed `internal/check/passes` tests can be silently vacuous
 
-The shared helper `diagsIn` (`internal/core/passes/typecheck_kerml_language_test.go`) builds a bare
+The shared helper `diagsIn` (`internal/check/passes/typecheck_kerml_language_test.go`) builds a bare
 `symbols.NewIndex()` and loads **no standard library**. Because the passes are tiered, any fixture
 that names a library type (`Base::Anything`, `Objects::Object`, …) collects `name-resolution`
 errors, which **skip the type tier entirely** — so a test that asserts "zero `type` diagnostics"
 over such a fixture passes no matter what the type checker does. Observed at `5ac8b6fb`:
-`testdata/passes/f90_conjugation.kerml` yields 3 `unresolved reference: Base::Anything`
+`tests/testdata/passes/f90_conjugation.kerml` yields 3 `unresolved reference: Base::Anything`
 name-resolution diagnostics and 0 type diagnostics, and
 `TestF90KerMLConjugationIsNotAPortTyping` therefore still **PASSED** with the fix reverted, while
 its inline-source sibling `TestF90KerMLConjugationFormsAreClean` (short snippets naming no library
@@ -450,7 +450,7 @@ Two cheap surfaces that *do* prove the split:
 
 1. **A synthetic two-language mini-repo through pilot-diff.** Put byte-identical content in
    `/testdata/adv.sysml` and `/examples/pilot-corpora/kerml-examples/adv.kerml`, then
-   `go run ./cmd/pilot-diff -repo  -validator /build/pilot-sysml-validator/validate-sysml-batch \
+   `go run -C tools ./cmd/pilot-diff -repo  -validator /build/pilot-sysml-validator/validate-sysml-batch \
    -kerml-validator /build/pilot-kerml-validator/validate-kerml -out `.
    The other five roots warn `skipping ...: no .sysml files` and are skipped, which is fine.
    Read `pilot-diff.txt`: the message must appear under `adv.sysml` and be absent under
@@ -464,7 +464,7 @@ Two cheap surfaces that *do* prove the split:
 
 ## The library index cache can hold *poisoned* records from an abandoned iteration
 
-`internal/core/libs/record.go` invalidates on-disk records by a single integer, `formatVersion`,
+`internal/workspace/libs/record.go` invalidates on-disk records by a single integer, `formatVersion`,
 and the record filename ends in `-v.idx` under `$XDG_CACHE_HOME/sysml-ls/libs/`. The records
 persist a symbol's **kind**, and for a cached library symbol (`sym.Decl == nil`) the runtime reads
 that kind directly (`runtime/invoke_calc.go: isCalcSymbol`, `isActionSymbol`, …). Consequences when
@@ -480,16 +480,16 @@ testing a PR that changes how a library element is classified *and* bumps `forma
   `XDG_CACHE_HOME=$(mktemp -d)`, and treat a difference as a cache-record problem, not a code bug.
   A green `go test ./...` will not catch it: tests use `t.TempDir()` caches.
 - To find out *which* kind a record persisted, drop a throwaway `*_test.go` into
-  `internal/core/libs` that `gob`-decodes each `*.idx` into `IndexRecord` and prints
+  `internal/workspace/libs` that `gob`-decodes each `*.idx` into `IndexRecord` and prints
   `symRecord.FQN` + `.Kind` (`symRecord` is unexported, so it must live in that package). Run it
-  with `go test -v -run ... ./internal/core/libs` — plain `go test` swallows stdout. Delete the file
+  with `go test -v -run ... ./internal/workspace/libs` — plain `go test` swallows stdout. Delete the file
   afterwards and confirm `git status` is clean.
 - Selective bisect: copy the cache aside and delete only `*-v.idx` or only `*-v.idx` to
   see which generation is responsible.
 - Worth flagging to the author: a version bump only protects users who never ran an intermediate
   build of the same branch; if development churned through the same number, bumping once more is
   the cheap fix.
-- Cross-check the harness too: re-run `go run ./cmd/pilot-diff` under a fresh `XDG_CACHE_HOME` and
+- Cross-check the harness too: re-run `go run -C tools ./cmd/pilot-diff` under a fresh `XDG_CACHE_HOME` and
   diff the JSON against the ambient-cache run (observed identical at `501d70fd`) — otherwise the
   differential numbers you reproduce may be a property of your cache.
 
@@ -533,7 +533,7 @@ d=/tmp/ref && mkdir -p $d && cp f.sysml $d/
   is visible without a golden. It cannot distinguish `first a.b then c.d` as SuccessionAsUsage
   from InitialNode (both print identically) — for that read the committed `.golden`
   (`Usage kind="succession"` with two `FeatureChainExpr` ends vs `(InitialNode …)`).
-- Golden fixtures under `internal/core/parser/testdata/parse/` are only non-vacuous if the parent
+- Golden fixtures under `tests/parser/testdata/parse/` are only non-vacuous if the parent
   binary *rejects* the same input; confirm that with `/tmp/sysml-main` rather than assuming it.
 
 ## Running the pilot validator directly
@@ -601,7 +601,7 @@ directory first (or restore to a fresh path) and verify with `ls build/pilot-val
 ## The optional SysIDE third column (F7)
 
 `./scripts/download-syside.sh` builds Sensmetry SysIDE (`sensmetry/sysml-2ls`, pinned `0.9.1`,
-`2024-12` standard library) into `build/syside/`, and `cmd/pilot-diff` picks
+`2024-12` standard library) into `build/syside/`, and `tools/referee/diff` picks
 `build/syside/validate-syside` up automatically. Needs `node` (18+) and `pnpm`; ~15 s from a warm
 pnpm store, ~2 min cold. It is **static only** — SysIDE executes nothing, so it is never evidence
 about behavioral rows — and it never adjudicates: the two-way buckets and totals are byte-identical
@@ -610,10 +610,10 @@ either way.
 The two checks that actually distinguish working from broken:
 
 ```bash
-mv build/syside /tmp/syside-aside && go run ./cmd/pilot-diff -out /tmp/pd-two-way
+mv build/syside /tmp/syside-aside && go run -C tools ./cmd/pilot-diff -out /tmp/pd-two-way
 diff <(jq -S . docs/project/pilot-differential-baseline.json) \
      <(jq -S . /tmp/pd-two-way/pilot-diff.json)             # must be empty (no third column)
-mv /tmp/syside-aside build/syside && go run ./cmd/pilot-diff -out /tmp/pd-three-way
+mv /tmp/syside-aside build/syside && go run -C tools ./cmd/pilot-diff -out /tmp/pd-three-way
 jq '.totals, .syside.totals' /tmp/pd-three-way/pilot-diff.json
 ```
 
@@ -672,17 +672,17 @@ the pilot first): fake the pilot with a script that answers `--version` and exit
 build/pilot-validator/pom.xml` next to it (`pilotVersion` reads `/pom.xml`), point
 `-syside` at a `sleep 30` launcher and use `-timeout 3s` → exit 1,
 `… failed (signal: killed)`, no report. Fast iteration for all of these: `-repo /tmp/mini` with a
-copy of `cmd/pilot-diff/testdata` only (4 files, other roots just warn `skipping`).
+copy of `tools/referee/diff/testdata` only (4 files, other roots just warn `skipping`).
 
 Timings at `286f420f` (8 vCPU): two-way `1m14s`, three-way `2m44s`, SysIDE alone on 4 files ~11 s.
 SysIDE prints `Collected standard library: [...]` on **stdout**; the harness discards stdout, so
 only stderr matters.
 
-## Refereeing the name-distinguishability rule (`internal/core/resolve/distinguishability.go`)
+## Refereeing the name-distinguishability rule (`internal/semantic/resolve/distinguishability.go`)
 
 `build/pilot-validator/validate-sysml ` is a faithful oracle for this rule — the four
 messages reproduce on a single file with no `--root` — so hand fixtures are the right surface, and
-`cmd/pilot-diff` is *not*: a corpus-scale scan at `2836471c` found 0 only-ours and 23 pilot-only
+`tools/referee/diff` is *not*: a corpus-scale scan at `2836471c` found 0 only-ours and 23 pilot-only
 `Duplicate of …` diagnostics, and **all 23 sit on a line where the pilot itself emits a syntax
 error** (`namespace` in `.sysml`, imports without visibility), i.e. recovery collateral. So the
 harness cannot see gaps in this rule; only fixtures can.
@@ -717,7 +717,7 @@ from HEAD alone, and the second is the risk when a rule is rewritten to match a
 When a parser fix adds `TestNegative` rows for forms that must stay rejected, a passing row proves
 nothing on its own — the input may be rejected by an unrelated earlier error. Flip the guard the
 fix introduced (e.g. `if allowBody && p.accept2(lexer.LBrace)` → `if p.accept2(lexer.LBrace)`),
-rerun `go test ./internal/core/parser -run TestNegative`, and check *which* rows fail. Rows that
+rerun `go test ./tests/parser ./internal/syntax/parser -run TestNegative`, and check *which* rows fail. Rows that
 still pass under the mutation are guarding a different code path (a package-level `then` is caught
 by `expected a namespace member` before it ever reaches `parseSuccessionEdge`), which is worth
 saying out loud rather than claiming all rows guard the new guard. Restore from a `cp` backup and
@@ -733,7 +733,7 @@ be discriminated instead of scoring it a pass.
 ## Recording
 
 This is CLI work: record a maximized Konsole on `DISPLAY=:0` (see the "Recording setup" section
-of `testing-sysml-repl/SKILL.md`). A single `go run ./cmd/pilot-diff` prints only four progress
+of `testing-sysml-repl/SKILL.md`). A single `go run -C tools ./cmd/pilot-diff` prints only four progress
 lines and a summary, so pair every run with the `jq`/`diff` command that turns it into a visible
 pass/fail line (`&& echo '... IDENTICAL'`), otherwise the video shows nothing checkable.
 
diff --git a/.agents/skills/testing-pilot-execution-referee/SKILL.md b/.agents/skills/testing-pilot-execution-referee/SKILL.md
index e9a69c22cb..01e3443194 100644
--- a/.agents/skills/testing-pilot-execution-referee/SKILL.md
+++ b/.agents/skills/testing-pilot-execution-referee/SKILL.md
@@ -1,6 +1,6 @@
 ---
 name: testing-pilot-execution-referee
-description: How to verify the pilot execution referee (cmd/pilot-exec-diff + scripts/download-pilot-evaluator.sh) end to end on Linux — provisioning the headless pilot expression evaluator, reproducing the committed bucket counts, proving the determinism and disagree detectors are live, and the adversarial paths that actually distinguish working from broken.
+description: How to verify the pilot execution referee (tools/referee/exec + scripts/download-pilot-evaluator.sh) end to end on Linux — provisioning the headless pilot expression evaluator, reproducing the committed bucket counts, proving the determinism and disagree detectors are live, and the adversarial paths that actually distinguish working from broken.
 ---
 
 # Testing the pilot execution referee
@@ -55,7 +55,7 @@ Provisioning notes worth knowing before you test it:
 ## Run
 
 ```sh
-go run ./cmd/pilot-exec-diff          # ~13 s wall with the default fixtures
+go run -C tools ./cmd/pilot-exec-diff          # ~13 s wall with the default fixtures
 ```
 
 Use `-cases DIR` for another directory of `.cases` files, `-out DIR`,
@@ -147,9 +147,9 @@ pilot answers the representation's own. See
   source line included:
   `pilot-exec-diff: :: model no/such/model.sysml: stat : no
   such file or directory`.
-- **Additivity.** `go run ./cmd/pilot-diff` must still print the headline the
-  committed baseline holds (`375 file(s), 345 fully agreeing; 38 agreed
-  diagnostic(s), 38 only ours, 1109 only the pilot's` after the argument-binding conformance round at the `2026-08` pin — read it from the baseline JSON, not from this line, since each
+- **Additivity.** `go run -C tools ./cmd/pilot-diff` must still print the headline the
+  committed baseline holds (`379 file(s), 347 fully agreeing; 38 agreed
+  diagnostic(s), 38 only ours, 1582 only the pilot's` at the `2026-08` pin — read it from the baseline JSON, not from this line, since each
   fix round moves it) and `jq -S` diff clean against
   `docs/project/pilot-differential-baseline.json`; `git status --porcelain`
   empty at the end.
@@ -159,13 +159,13 @@ pilot answers the representation's own. See
 Any wave that flips a status flag in `docs/project/spec-compliance.md` (for
 example a row moving ⚠️ → ✅ because the pilot can now referee it) must also
 update **two aggregate count lines**, or
-`cmd/pilot-diff/doc_counts_test.go:TestPilotDifferentialDocumentCountsMatchBaseline`
+`tools/referee/diff/doc_counts_test.go:TestPilotDifferentialDocumentCountsMatchBaseline`
 fails with `coverage ✅ faithful: want N (spec-compliance.md ✅ rows), got M`:
 
 - `README.md` — the `**Measured status:**` line
 - `docs/internals/architecture.md` — the `**Current coverage:**` line
 
-This is invisible to `go run ./cmd/pilot-exec-diff` and to the `jq -S`
+This is invisible to `go run -C tools ./cmd/pilot-exec-diff` and to the `jq -S`
 baseline comparison, and it is **not** in the blueprint's abbreviated
 `-run 'TestTrainingExamples|TestPilotCorpora|TestCorpusGates'` selector, so
 always run the unfiltered `go test ./...` too. Confirm any failure is
@@ -251,10 +251,10 @@ The report's `rawPilot`/`rawOurs` are byte-reproducible (modulo pilot UUIDs):
 ```sh
 printf 'big\t\tProbe::Big()\n' > /tmp/c.tsv          # id  target  expression
 build/pilot-evaluator/eval-sysml --cases /tmp/c.tsv \
-  --model cmd/pilot-exec-diff/testdata/models/expr_values.sysml
+  --model tools/referee/exec/testdata/models/expr_values.sysml
 # → LiteralRational 1.099511627776E12 ()   [~150 library "Reading ..." lines first]
 make build-sysml
-./bin/sysml -e 'Probe::Big()' cmd/pilot-exec-diff/testdata/models/expr_values.sysml
+./bin/sysml -e 'Probe::Big()' tools/referee/exec/testdata/models/expr_values.sysml
 # → ✓ package Probe / ✓ Probe::Big() /   = 1099511627776
 ```
 
diff --git a/.agents/skills/testing-pilot-rejection/SKILL.md b/.agents/skills/testing-pilot-rejection/SKILL.md
index dd88c980c2..08b71cd055 100644
--- a/.agents/skills/testing-pilot-rejection/SKILL.md
+++ b/.agents/skills/testing-pilot-rejection/SKILL.md
@@ -1,15 +1,15 @@
 ---
 name: testing-pilot-rejection
-description: How to verify the advisory pilot rejection oracle (cmd/pilot-reject + its committed negative corpus) end to end on Linux — provisioning the two pinned reference validators, reproducing the committed baseline, proving determinism, extending the corpus, and inspecting permissiveness gaps.
+description: How to verify the advisory pilot rejection oracle (tools/referee/reject + its committed negative corpus) end to end on Linux — provisioning the two pinned reference validators, reproducing the committed baseline, proving determinism, extending the corpus, and inspecting permissiveness gaps.
 ---
 
-# Testing the pilot rejection oracle (`cmd/pilot-reject`)
+# Testing the pilot rejection oracle (`tools/referee/reject`)
 
 Sibling of `testing-pilot-differential` and `testing-pilot-xpect` (same pin
 `scripts/pilot-pin.sh`, same committed-baseline shape), but pointed the other way: the
 differential measures what the reference accepts and we reject; this oracle measures what the
 reference **rejects and we accept** — permissiveness gaps. Its corpus is committed under
-`cmd/pilot-reject/testdata/negative/` (306 hand-written invalid models, one violated rule + citation
+`tools/referee/reject/testdata/negative/` (306 hand-written invalid models, one violated rule + citation
 in each file's mandatory `// Invalid: ...` first line), so no corpus download exists. Method and
 findings: `docs/project/pilot-rejection.md`.
 
@@ -25,12 +25,12 @@ findings: `docs/project/pilot-rejection.md`.
 ## The core check (~10 s per run once provisioned)
 
 ```bash
-rm -rf build/pilot-reject && go run ./cmd/pilot-reject
+rm -rf build/pilot-reject && go run -C tools ./cmd/pilot-reject
 cmp build/pilot-reject/pilot-reject.json docs/project/pilot-rejection-baseline.json   # must be silent
 ```
 
 `docs/project/pilot-rejection-baseline.json` is the only authority for the counts; the numbers
-quoted here are as-of values, and `cmd/pilot-reject/doc_counts_test.go` fails if they drift from it.
+quoted here are as-of values, and `tools/referee/reject/doc_counts_test.go` fails if they drift from it.
 As of the `semantic/` source (named pilot constraints, KerML and SysML, the control-node
 succession rules, the feature-value overriding rule, the enumeration-variation rules, the send-action cases,
 the metadata typing, annotated-element and body rules, the trigger-argument typing rules, the owning-body member rules, the cross-subsetting rules, the variant port rule, the association arity, binary-link end
@@ -52,9 +52,9 @@ default mode. The report names each case's mode and lists the four strict-only a
 separately, so a strict agreement never reads as a default one.
 
 ```bash
-go run ./cmd/pilot-reject -conformance default -out build/pilot-reject-default
+go run -C tools ./cmd/pilot-reject -conformance default -out build/pilot-reject-default
 # as of the `semantic/` source: 193 agreements, 25 gaps — the numbers strict mode leaves alone
-go run ./cmd/pilot-reject -conformance lenient   # must fail: unknown conformance policy
+go run -C tools ./cmd/pilot-reject -conformance lenient   # must fail: unknown conformance policy
 ```
 
 The `default` numbers are the ones the other waves' rules produce and must not move: strict mode is
@@ -66,7 +66,7 @@ The reports carry no timestamps and no absolute paths; case order is the sorted
 Prove it with two independent runs:
 
 ```bash
-go run ./cmd/pilot-reject && go run ./cmd/pilot-reject -out build/pilot-reject-2
+go run -C tools ./cmd/pilot-reject && go run -C tools ./cmd/pilot-reject -out build/pilot-reject-2
 cmp build/pilot-reject/pilot-reject.json build/pilot-reject-2/pilot-reject.json   # must be silent
 rm -rf build/pilot-reject-2
 ```
@@ -96,14 +96,14 @@ build/pilot-kerml-validator/validate-kerml /.kerml
 ```
 
 Then recommit the baseline
-(`go run ./cmd/pilot-reject -update`) and update
+(`go run -C tools ./cmd/pilot-reject -update`) and update
 the counts and gap table in `docs/project/pilot-rejection.md`, the README's rejection-oracle line,
 and the headline above. `TestPilotRejectionDocumentCountsMatchBaseline` (CI-cheap, reads only
 committed files) fails on any stale count, and on a gap table that does not enumerate exactly the
 baseline's `pilot-only-rejects` cases:
 
 ```bash
-go test -count=1 ./cmd/pilot-reject
+go test -C tools -count=1 ./referee/reject
 ```
 
 ## Inspecting a gap
@@ -127,7 +127,7 @@ separate refresh, so `cmp` against the baseline is the wrong check. Attribute th
 ```bash
 git worktree add /home/ubuntu/wt-main origin/main
 # run the harness from main, but over the branch checkout's corpus and the provisioned validators
-cd /home/ubuntu/wt-main && go run ./cmd/pilot-reject -repo /path/to/branch/checkout -out /tmp/mn
+cd /home/ubuntu/wt-main && go run -C tools ./cmd/pilot-reject -repo /path/to/branch/checkout -out /tmp/mn
 ```
 
 A `main` run whose JSON is byte-identical to `docs/project/pilot-rejection-baseline.json` proves the
@@ -159,7 +159,7 @@ Adversarial checks worth running for such a PR:
 - **Strict must never be more permissive than default.** Sweep every construct that can declare a
   reserved keyword as its name in both modes and require the same count in each.
 - **Corpus/stdlib regression scan.** Diff severity-normalised diagnostics between the two binaries
-  over `examples/pilot-corpora`, `examples/sysml-v2-training` and `internal/core/libs/stdlib`. Note
+  over `examples/pilot-corpora`, `examples/sysml-v2-training` and `internal/workspace/libs/stdlib`. Note
   this is **structurally vacuous** for `reserved-keyword-name`/`sysml-notation` (0 hits corpus-wide,
   since OMG corpora contain no such names) — report it as a regression control and state the 0-hit
   count, never as coverage proof. Two traps: prefix rows with `awk -v f="$F" '{print f" "$0}'`
diff --git a/.agents/skills/testing-pilot-xpect/SKILL.md b/.agents/skills/testing-pilot-xpect/SKILL.md
index 7a395b99e6..e36f10d4e1 100644
--- a/.agents/skills/testing-pilot-xpect/SKILL.md
+++ b/.agents/skills/testing-pilot-xpect/SKILL.md
@@ -1,9 +1,9 @@
 ---
 name: testing-pilot-xpect
-description: How to verify the advisory pilot Xpect oracle harness (cmd/pilot-xpect + scripts/download-pilot-xpect.sh) end to end on Linux — provisioning the pinned .xt suites, reproducing the committed baseline, proving determinism under -jobs concurrency, spot-checking the oracle's truthfulness against an independent surface, and the adversarial mutations worth trying.
+description: How to verify the advisory pilot Xpect oracle harness (tools/referee/xpect + scripts/download-pilot-xpect.sh) end to end on Linux — provisioning the pinned .xt suites, reproducing the committed baseline, proving determinism under -jobs concurrency, spot-checking the oracle's truthfulness against an independent surface, and the adversarial mutations worth trying.
 ---
 
-# Testing the pilot Xpect oracle harness (`cmd/pilot-xpect`)
+# Testing the pilot Xpect oracle harness (`tools/referee/xpect`)
 
 Third sibling of `testing-pilot-differential` and `testing-grammar-coverage` (same pin
 `scripts/pilot-pin.sh`, same "committed artifact, testable by reproduction" shape, same
@@ -24,7 +24,7 @@ front end. Only `exportedObjects` (1 assertion) is still `not adjudicated`.
 ## The core check (~5 s per run)
 
 ```bash
-rm -rf build/pilot-xpect && go run ./cmd/pilot-xpect
+rm -rf build/pilot-xpect && go run -C tools ./cmd/pilot-xpect
 cmp build/pilot-xpect/pilot-xpect.json docs/project/pilot-xpect-baseline.json   # must be silent
 ```
 
@@ -64,7 +64,7 @@ visibility fixtures declare file-wide silence and the protected-import errors at
 implementation satisfies both.
 Read the live totals from the baseline rather than this paragraph; it is an anchor, not the check.
 
-**`wording-only` is a verdict, not a tolerance.** `cmd/pilot-xpect/wording.go` admits a row into
+**`wording-only` is a verdict, not a tolerance.** `tools/referee/xpect/wording.go` admits a row into
 agreement only when the declared and our message state the same rule about the same element; the
 caller has already matched severity and offset, and those two alone are never enough. Rows that keep
 the offset but change the rule stay `same-location` disagreements, so a jump in `agree` after
@@ -89,7 +89,7 @@ the same direction.
 
 **A wave that moves the verdicts must also rebaseline.** `cmp` against
 `docs/project/pilot-xpect-baseline.json` is the only thing that catches a missed rebaseline:
-`cmd/pilot-diff`'s `TestW6FXpectDocumentCountsMatchBaseline` only guards `pilot-xpect.md`
+`tools/referee/diff`'s `TestW6FXpectDocumentCountsMatchBaseline` only guards `pilot-xpect.md`
 *against the baseline JSON*, so a branch that leaves **both** stale still passes `go test ./...`.
 Always run the `cmp` explicitly and diff `jq .totals` of the two — this was a real finding on the
 wave-8F branch (harness emitted 564/762/0 while the committed baseline and doc still held
@@ -104,7 +104,7 @@ See `testing-pilot-differential/SKILL.md` for the complete mechanical-guard scop
 into one shared result slice). Test it explicitly, not just by repeating the default run:
 
 ```bash
-for j in 1 3 16 0; do go run ./cmd/pilot-xpect -jobs $j -out /tmp/xj$j; done
+for j in 1 3 16 0; do go run -C tools ./cmd/pilot-xpect -jobs $j -out /tmp/xj$j; done
 for j in 1 3 16 0; do cmp /tmp/xj$j/pilot-xpect.json docs/project/pilot-xpect-baseline.json; done
 ```
 
@@ -131,7 +131,7 @@ for j in 1 3 16 0; do cmp /tmp/xj$j/pilot-xpect.json docs/project/pilot-xpect-ba
 
 ## No-corpus degradation
 
-`mv` the corpus aside, then `go run ./cmd/pilot-xpect -out /tmp/nocorpus` → **exit 1**, stderr
+`mv` the corpus aside, then `go run -C tools ./cmd/pilot-xpect -out /tmp/nocorpus` → **exit 1**, stderr
 `skipping kerml: build/pilot-xpect-corpus/kerml is absent (run scripts/download-pilot-xpect.sh)`
 (same for sysml) then `pilot-xpect: no suite found under build/pilot-xpect-corpus; …`, and
 `/tmp/nocorpus` is never created. It must never exit 0 or report 100 % agreement.
@@ -172,7 +172,7 @@ sibling files:
   files its `XPECT_SETUP` `ResourceSet` names, insert `alias __pN for ;` into
   the *same namespace* as the anchor, and read `publishDiagnostics`: an in-scope name is clean, an
   out-of-scope one gives `unresolved reference: …`. `model.VisibleNames`/`ElementOnPath`/`FQNOf`/
-  `ScopeAt` have no caller outside `cmd/pilot-xpect/scope.go` (grep to re-confirm), so the LSP
+  `ScopeAt` have no caller outside `tools/referee/xpect/scope.go` (grep to re-confirm), so the LSP
   really is an independent surface.
 - **Caveat that will confuse you:** a scope "missing" name is missing from the *enumeration*, not
   from the resolver. `VisibleNames` truncates a path at the first repeated element, so e.g.
@@ -190,7 +190,7 @@ library file* arrive attached to the file under test. The harness drops them **b
 `source.Span` carries no file identity, so "outside the fixture's model text" (past EOF, or inside a
 note per `xtFile.Noted`) is the only signal. That filter can only make the comparison weaker, so
 audit it rather than trusting the count. The cheap, decisive probe is a throwaway
-`cmd/pilot-xpect/zz_tmp_*_test.go` (package `main`, delete it afterwards) that walks the corpus,
+`tools/referee/xpect/zz_tmp_*_test.go` (package `main`, delete it afterwards) that walks the corpus,
 calls `loadResourceSet` + `ws.Diagnostics(main)` exactly as `compareFile` does, and for each
 diagnostic where `foreignDiagnostic(f, off)` is true prints the offset, the fixture's own text at
 that span, and the text at the *same span* in every declared resource. Provenance is proven when the
@@ -213,7 +213,7 @@ must give `disagree … extra 1 (sysml::Class: NameEscape::zzz_extra)`.
 A wave that claims "N rows recovered" must also be shown to have given nothing back — a net total
 can hide an equal-sized swap, and on a stacked branch the committed baseline is often deliberately
 stale, so `cmp` against `docs/project/pilot-xpect-baseline.json` cannot serve as the check. Capture
-the pre-change run as a snapshot (`go run ./cmd/pilot-xpect -out build/pilot-xpect-before` on the
+the pre-change run as a snapshot (`go run -C tools ./cmd/pilot-xpect -out build/pilot-xpect-before` on the
 parent commit, or `git show :docs/project/pilot-xpect-baseline.json`) and diff the *keys* of
 the non-`agree` rows, keyed on `suite name + file path + row line + row kind`:
 
@@ -366,10 +366,10 @@ worktree to check that a new regression test is load-bearing (fails at the paren
 
 ### Diagnostic counts move between tiers — count per tier, not just per line
 
-`internal/core/resolve` is not the only diagnostic producer, so "the resolver no longer complains"
+`internal/semantic/resolve` is not the only diagnostic producer, so "the resolver no longer complains"
 and "the line is clean" are different claims. `resolve` emits `unresolved reference: …` (sole emitter:
 `qualified.go`'s `unresolved`/`unresolvedNamespace`), while the tier-4 constraint pass in
-`internal/core/passes/constraint.go` emits its own, e.g. `"%s redefines %s, but %s is not an inherited
+`internal/check/passes/constraint.go` emits its own, e.g. `"%s redefines %s, but %s is not an inherited
 member of %s"` (code `redefinition-no-inherited`). A fix that makes a reference *resolve* can hand the
 line straight to a later tier, so an end-to-end `publishDiagnostics` count stays 1 while the message
 changes completely. A unit test asserting `len(r.Diagnostics) == 0` on a bare `Resolver` is therefore
@@ -378,7 +378,7 @@ Report the message text, not just the count, and confirm which package emits it
 before calling a leftover diagnostic a regression.
 
 To attribute a diagnostic definitively, temporarily instrument `Resolver.report` in
-`internal/core/resolve/resolver.go` with an env-gated `debug.Stack()` dump and rebuild the LSP; if the
+`internal/semantic/resolve/resolver.go` with an env-gated `debug.Stack()` dump and rebuild the LSP; if the
 stack never fires for a message you can see in the editor, that message is coming from another package
 entirely. Restore the file and `git update-index --refresh` before checking `git status --porcelain`.
 
@@ -401,8 +401,8 @@ restore and re-`cmp` against the baseline afterwards.
 ## Test-suite liveness
 
 ```bash
-go test -count=1 ./cmd/pilot-xpect
-OPENSYSML_REQUIRE_PILOT_XPECT=1 go test -count=1 ./cmd/pilot-xpect
+go test -C tools -count=1 ./referee/xpect
+OPENSYSML_REQUIRE_PILOT_XPECT=1 go test -C tools -count=1 ./referee/xpect
 ```
 
 With the corpus moved aside the plain run **skips** (`ok`, 0.001 s) and the `REQUIRE` run **fails**
@@ -415,9 +415,9 @@ census in `w5c_census_test.go` is live two ways: perturb one pinned triple (e.g.
 
 ## Regression neighbour
 
-`go run ./cmd/pilot-diff` (~1m12s) must still print the headline the *committed* baseline holds —
-after the argument-binding conformance round at the `2026-08` pin that is `375 file(s), 345 fully agreeing; 38 agreed diagnostic(s), 38
-only ours, 1109 only the pilot's`. Read the number out of
+`go run -C tools ./cmd/pilot-diff` (~1m12s) must still print the headline the *committed* baseline holds —
+at the `2026-08` pin that is `379 file(s), 347 fully agreeing; 38 agreed diagnostic(s), 38
+only ours, 1582 only the pilot's`. Read the number out of
 `docs/project/pilot-differential-baseline.json` rather than trusting this line, since a landing fix
 round moves it. When the baseline is itself stale (it was at `19a3ce03`, holding 273 / 281 / 317), a
 failing `cmp` against it is *not* evidence of an Xpect regression — compare the summary line, and see
@@ -425,7 +425,7 @@ failing `cmp` against it is *not* evidence of an Xpect regression — compare th
 
 ## Reviewing a wave that adds validation passes (not just the harness)
 
-A wave whose passes move the Xpect verdicts (e.g. wave 9C's `internal/core/passes/w9c_*.go`) is best
+A wave whose passes move the Xpect verdicts (e.g. wave 9C's `internal/check/passes/w9c_*.go`) is best
 judged by running the harness on **both revisions** rather than by trusting the committed baseline:
 
 ```bash
@@ -434,7 +434,7 @@ mkdir -p /tmp/osml-main/build
 ln -s "$PWD/build/pilot-xpect-corpus"       /tmp/osml-main/build/pilot-xpect-corpus
 ln -s "$PWD/examples/pilot-corpora"         /tmp/osml-main/examples/pilot-corpora
 ln -s "$PWD/examples/sysml-v2-training"     /tmp/osml-main/examples/sysml-v2-training
-(cd /tmp/osml-main && go run ./cmd/pilot-xpect -out /tmp/xpect-main)
+(cd /tmp/osml-main && go run -C tools ./cmd/pilot-xpect -out /tmp/xpect-main)
 ```
 
 The corpora are gitignored, so a fresh worktree has none — symlinking them in is what makes the
@@ -450,7 +450,7 @@ stale. Confirm the intent with the lead before filing it — measure and report
 
 **Do not forget `make docs-counts`.** Adding rows to `docs/project/spec-compliance.md` without
 regenerating the three derived count lines (`README.md`, `docs/internals/architecture.md`,
-`docs/project/spec-compliance.md:13`) fails `cmd/pilot-diff`'s
+`docs/project/spec-compliance.md:13`) fails `tools/referee/diff`'s
 `TestPilotDifferentialDocumentCountsMatchBaseline` (`coverage total: want 690 …, got 688 — run
 \`make docs-counts\``). This is only visible in a full `go test ./...`, so always run the whole gate,
 and confirm the same test passes on the base revision before calling it a branch regression.
@@ -471,7 +471,7 @@ add nor a removal, and a *degradation* would hide there.
 
 ## Cache independence of a library-reading rule
 
-`libs.NewCache()` resolves `$XDG_CACHE_HOME/sysml-ls/libs` (`internal/core/libs/cache.go`). A unit
+`libs.NewCache()` resolves `$XDG_CACHE_HOME/sysml-ls/libs` (`internal/workspace/libs/cache.go`). A unit
 test that just passes a `*libs.Cache` to `libs.NewLoader` **does not exercise a warm cache**: records
 are only written by `Loader.Persist`, which the test helpers do not call, so the "warm" pass reads an
 empty directory. To prove a rule behaves identically cold and warm, drive `bin/sysml` twice under a
diff --git a/.agents/skills/testing-pssm-referee/SKILL.md b/.agents/skills/testing-pssm-referee/SKILL.md
index aad7d4aa4e..3167ce535a 100644
--- a/.agents/skills/testing-pssm-referee/SKILL.md
+++ b/.agents/skills/testing-pssm-referee/SKILL.md
@@ -1,6 +1,6 @@
 ---
 name: testing-pssm-referee
-description: How to verify the PSSM state-machine referee (cmd/pssm-referee + scripts/download-pssm-suite.sh + internal/pssm) end to end on Linux — provisioning the pinned OMG test suite, reproducing the committed bucket counts, proving the determinism, count and trace-disagreement detectors are live, and the adversarial paths (bad checksum, missing suite, a hand-broken translation) that distinguish working from broken.
+description: How to verify the PSSM state-machine referee (tools/cmd/pssm-referee + scripts/download-pssm-suite.sh + tools/referee/pssm) end to end on Linux — provisioning the pinned OMG test suite, reproducing the committed bucket counts, proving the determinism, count and trace-disagreement detectors are live, and the adversarial paths (bad checksum, missing suite, a hand-broken translation) that distinguish working from broken.
 ---
 
 # Testing the PSSM referee
@@ -9,7 +9,7 @@ The referee reads the OMG PSSM 1.0 test suite (`ptc/18-11-06`, `PSSM_TestSuite.x
 tests), translates each state-machine test that has a SysML v2 spelling into textual notation
 in memory, runs it under the runtime's own state-machine driver with the `explore` schedule,
 and compares the set of `log` values reachable against the suite's expected traces. It files
-each test as `pass`, `fail`, `not-expressible`, `terminate-gap` or `differs-by-design`.
+each test as `pass`, `fail`, `not-expressible` or `differs-by-design`.
 
 A pass checks that the runtime reproduces UML behavior where the model has a defensible
 SysML v2 mapping, provides a second opinion on the tool-choice rows of
@@ -35,7 +35,7 @@ over HTTPS only, checks the sha256 in a staging directory and only then moves th
 - The early exit also hashes the file: `truncate -s -1 build/pssm/PSSM_TestSuite.xmi` with
   the stamp intact makes the next run print `Corrupt suite at ...; re-downloading.` and
   restore the pinned bytes (check with `sha256sum`).
-- The Go side reads the same pin the same way: `PSSM_SUITE_SHA256=0000… go run
+- The Go side reads the same pin the same way: `PSSM_SUITE_SHA256=0000… go run -C tools
   ./cmd/pssm-referee` refuses the downloaded file (`has sha256 c355b2…, not the pinned
   0000…`) rather than verifying against the script's default, so the downloader and the
   referee can never disagree about which pin is in force.
@@ -62,11 +62,11 @@ Record pre-existing untracked files before testing and do not remove them.
 ## Run
 
 ```sh
-go run ./cmd/pssm-referee                 # < 1 s wall for the whole suite
-go run ./cmd/pssm-referee -check          # exit 0 iff the bucket counts match the baseline
-go run ./cmd/pssm-referee -json           # the full report, byte-stable
-go run ./cmd/pssm-referee -filter "Deferred 006"
-go run ./cmd/pssm-referee -keep /tmp/pssm-models   # writes every translated .sysml
+go run -C tools ./cmd/pssm-referee                 # < 1 s wall for the whole suite
+go run -C tools ./cmd/pssm-referee -check          # exit 0 iff the bucket counts match the baseline
+go run -C tools ./cmd/pssm-referee -json           # the full report, byte-stable
+go run -C tools ./cmd/pssm-referee -filter "Deferred 006"
+go run -C tools ./cmd/pssm-referee -keep /tmp/pssm-models   # writes every translated .sysml
 ```
 
 `-h` must open with the `meaning` field from the committed baseline, verbatim
@@ -85,12 +85,12 @@ The summary prints the counts, then every non-passing test under its bucket with
 reasons: `reached a trace the suite does not admit: …` and `admitted trace not reached: …`
 for a trace-set disagreement, `run error: …` for a typed runtime error or an exhausted
 budget, and `reports on SM (): <verdict>` when the committed row table
-(`internal/pssm/rows.go`) maps the test to an alignment-note row.
+(`tools/referee/pssm/rows.go`) maps the test to an alignment-note row.
 
 ## Checks that actually distinguish working from broken
 
-- **Determinism.** `go run ./cmd/pssm-referee -json -jobs 1 | sha256sum` equals the same
-  with `-jobs 8`, and two `-jobs 8` runs agree. `go run -race ./cmd/pssm-referee -jobs 8`
+- **Determinism.** `go run -C tools ./cmd/pssm-referee -json -jobs 1 | sha256sum` equals the same
+  with `-jobs 8`, and two `-jobs 8` runs agree. `go run -race -C tools ./cmd/pssm-referee -jobs 8`
   prints no `DATA RACE`. `TestRefereeDeterministic` pins the JSON across job counts in-process.
 - **The count gate is live.** Copy the baseline aside, edit one count (`"pass": 36` → `35`
   and `"fail": 23` → `24`, keeping the total), run `-check`: exit 1 with `does not
@@ -103,7 +103,7 @@ budget, and `reports on SM<n> (<title>): <verdict>` when the committed row table
 - **Provenance is compared before counts.** Edit `"suiteDigest"` in the copy: `-check`
   reports `provenance: baseline measured …, this run …` and does not attribute the movement
   to the runtime.
-- **`-update` is reproducible.** `go run ./cmd/pssm-referee -update -develop <sha>` followed
+- **`-update` is reproducible.** `go run -C tools ./cmd/pssm-referee -update -develop <sha>` followed
   by `git diff --stat docs/project/pssm-referee-baseline.json` changes only the `recorded`
   date (and `develop` if you passed a different sha); there may be no change when
   both match the baseline already. Restore deliberate baseline mutations before
@@ -129,7 +129,7 @@ budget, and `reports on SM<n> (<title>): <verdict>` when the committed row table
   exact runtime result, temporarily log `row.Bucket`, `row.Reasons`, and `row.Runs`
   after the fixture call and run with `-v -count=1`; the reason should name
   `exploration incomplete: runs budget 1 hit after 1 runs`. Restore the logging.
-- **The step budget follows the environment.** `OPENSYSML_MAX_STEPS=200000 go run
+- **The step budget follows the environment.** `OPENSYSML_MAX_STEPS=200000 go run -C tools
   ./cmd/pssm-referee -filter "History 002-D"` reports `evaluation step limit exceeded
   (200000 steps; …)` where the unset default reports `100000 steps`; the raised budget does
   not rescue the test, because the machine loops. `OPENSYSML_MAX_STEPS=plenty` is an error
@@ -138,13 +138,13 @@ budget, and `reports on SM<n> (<title>): <verdict>` when the committed row table
 
 ## Adversarial paths
 
-- **Missing suite.** `go run ./cmd/pssm-referee -suite /tmp/empty` (an existing directory
+- **Missing suite.** `go run -C tools ./cmd/pssm-referee -suite /tmp/empty` (an existing directory
   with no XMI) → exit **0**, prints `PSSM test suite is absent at /tmp/empty/PSSM_TestSuite.xmi;
   run ./scripts/download-pssm-suite.sh to provision it`, and writes nothing. With
   `OPENSYSML_REQUIRE_PSSM_SUITE=1` the same command exits **1** with `OPENSYSML_REQUIRE_PSSM_SUITE
   is set: PSSM test suite is absent …`. The Go gates (`TestSuiteRead`,
   `TestSuiteClassification`, `TestEmitSuite`) skip with the same message and fail with the
-  variable set: `OPENSYSML_REQUIRE_PSSM_SUITE=1 go test ./internal/pssm -run TestEmitSuite`
+  variable set: `OPENSYSML_REQUIRE_PSSM_SUITE=1 go test -C tools ./referee/pssm -run TestEmitSuite`
   against an absent `build/pssm` must fail, not skip. Use a fresh `-suite` path for
   the CLI so an existing `build/pssm` cannot make the assertion vacuous. The Go
   gates have no suite-root override: temporarily move `build/pssm` aside, install
@@ -161,10 +161,10 @@ budget, and `reports on SM<n> (<title>): <verdict>` when the committed row table
   rather than measured; `TestRefereeRefusesASuiteReadInPart` pins that.
 - **A hand-broken translation.** The emitter runs in memory, so break it at the source, then
   put it back:
-  1. In `internal/pssm/emit.go`, change the `"::"` separator in the `trace` translation to
-     `"--"`. `OPENSYSML_REQUIRE_PSSM_SUITE=1 go test ./internal/pssm -run TestEmitSuite` still
+  1. In `tools/referee/pssm/emit.go`, change the `"::"` separator in the `trace` translation to
+     `"--"`. `OPENSYSML_REQUIRE_PSSM_SUITE=1 go test -C tools ./referee/pssm -run TestEmitSuite` still
      passes (the model is syntactically fine), and that is the point: the parse gate cannot
-     see it. `go run ./cmd/pssm-referee` then moves every multi-segment `pass` to `fail`
+     see it. `go run -C tools ./cmd/pssm-referee` then moves every multi-segment `pass` to `fail`
      with `reached a trace the suite does not admit: S1(entry)--…` — `-check` exits 1 with
      `pass: baseline 36, this run <n>`. Restore the file; `-check` is green again.
   2. Write a model the front end rejects: in `emit.go` change `attribute log : String = ""`
@@ -178,7 +178,7 @@ budget, and `reports on SM<n> (<title>): <verdict>` when the committed row table
      Inspect the current rejection cases before choosing a mutation; do not
      assume structured payloads or operation arguments are unsupported.
 - **Reclassification is pinned.** `TestSuiteClassification` pins the per-area and total
-  counts (31 standard / 30 extension / 3 terminate-gap / 39 not-expressible). Moving one
+  counts (34 standard / 31 extension / 38 not-expressible). Moving one
   construct between buckets in `classify.go` fails it with the area that moved; the note's
   test-suite section and `docs/project/pssm-referee.md` must move with it — they are the
   record of every move and its reason.
@@ -187,7 +187,7 @@ budget, and `reports on SM<n> (<title>): <verdict>` when the committed row table
 
 The pull-request workflow provisions the suite (`./scripts/download-pssm-suite.sh`, cached on
 the pin), sets `OPENSYSML_REQUIRE_PSSM_SUITE=1` so the Go gates cannot skip, and runs
-`go run ./cmd/pssm-referee -check` as its own step so the counts are legible in the log. A
+`go run -C tools ./cmd/pssm-referee -check` as its own step so the counts are legible in the log. A
 movement in any bucket fails that step until the baseline is regenerated with `-update` and
 the movement adjudicated in the pull-request body. It never gates on all-pass.
 
diff --git a/.agents/skills/testing-rdf-roundtrip/SKILL.md b/.agents/skills/testing-rdf-roundtrip/SKILL.md
index 4ccdd2703f..c62375184e 100644
--- a/.agents/skills/testing-rdf-roundtrip/SKILL.md
+++ b/.agents/skills/testing-rdf-roundtrip/SKILL.md
@@ -1,9 +1,9 @@
 ---
 name: testing-rdf-roundtrip
-description: How to end-to-end test the `internal/core/export` RDF mapping (`sysml -convert ttl|sysml`) so the test is load-bearing — stripping `sysx:sourceText` to force the structural predicates to carry the round trip, proving `.ttl` idempotence, and the negative controls that distinguish a working mapping from a decorative one.
+description: How to end-to-end test the `internal/translate/export` RDF mapping (`sysml -convert ttl|sysml`) so the test is load-bearing — stripping `sysx:sourceText` to force the structural predicates to carry the round trip, proving `.ttl` idempotence, and the negative controls that distinguish a working mapping from a decorative one.
 ---
 
-# Testing the SysML ↔ RDF Turtle round trip (`internal/core/export`)
+# Testing the SysML ↔ RDF Turtle round trip (`internal/translate/export`)
 
 ## Normative library identity checks
 
@@ -15,8 +15,11 @@ description: How to end-to-end test the `internal/core/export` RDF mapping (`sys
 - The outermost package has no owner or owning membership in this mapping. Check
   the package element ID and a child datatype's owning membership; do not demand
   the root package's membership UUID in a standalone document.
-- External library type references remain qualified-name literals, e.g.
-  `sysml:type "ScalarValues::Real"`; this is not an encoded element identity.
+- A reference to a library element is its normative IRI, e.g. `sysml:type
+  <urn:sysmlv2:element:14c0aa22-5489-59b5-b438-ded26e83ba31>` for `ScalarValues::Real`,
+  whether or not the library is in the graph; only a name that resolves to nothing
+  (`Missing::Kind`) stays a literal. A metadata prefix written by short name (`#moe`)
+  comes back by the element's name (`#MeasureOfEffectiveness`) without source text.
 - Test both source-backed and source-text-stripped imports. Normative IDs must
   not become ElementId annotations; a user-declared `abc-123` must remain an
   annotation. A declaration without an enclosing ProjectRef can still exercise
@@ -51,7 +54,7 @@ long as the broken predicate is missing from both graphs alike.
 
 To make the test load-bearing, strip `sysx:sourceText` and `sysx:sourceTail` from the intermediate
 `.ttl` first (the same thing the `withoutTriples` test helper in
-`internal/core/export/export_test.go` does in-process) and only then convert back. Every literal is
+`tests/export/export_test.go` does in-process) and only then convert back. Every literal is
 written on one line, newlines escaped, so a small Python filter is enough — drop any line containing
 the predicate, and when the dropped line ended the triple block with ` .`, turn the previous line's
 trailing `;` into ` .`:
@@ -104,7 +107,7 @@ a failed structural mapping, without weakening the graph comparison.
 ### Heads that are *not* expected to survive without sourceText (as of this writing)
 
 - **Any end-binding head that says more than its ends.** `endForm` in
-  `internal/core/export/end_forms.go` is only emitted when rebuilding the head reproduces its
+  `internal/translate/export/end_forms.go` is only emitted when rebuilding the head reproduces its
   *tokens* (layout and comments aside, so a line break inside `connect a\n to b;` is fine), so an
   unsupported inline payload declaration or a head with a body may carry no
   `sysx:endForm`, and the sourceText-free hop is refused with `it has no sysx:endForm, and the ends
@@ -120,7 +123,7 @@ a failed structural mapping, without weakening the graph comparison.
 ## Negative controls that prove each predicate is load-bearing
 
 Strip the structural predicates the same way and re-convert. The four notation predicates below are
-defined in `internal/core/export/rdf_out.go` (`xEndForm`, `xEndVerb`, `xSourceMember`,
+defined in `internal/translate/export/rdf_out.go` (`xEndForm`, `xEndVerb`, `xSourceMember`,
 `xTargetMember`) and are *additional* to the older end triples `sysx:endIndex`, `sysx:endRole` and
 `sysx:relatedFeature`, which carry the participants rather than the notation — stripping the
 notation ones is what makes the participants insufficient. Confirm the vocabulary before trusting
@@ -143,7 +146,7 @@ degraded output still validates clean, so judge it by the *text*, not by the exi
 
 Use `make build-sysml` and `bin/sysml FILE -convert kerml -o BACK` for KerML.
 `connector eng to tanks.main;` is anonymous; only `connector link from eng to tanks.main;`
-declares the connector name. `internal/core/export/testdata/convert/connector_ends.kerml`
+declares the connector name. `tests/export/testdata/convert/connector_ends.kerml`
 covers named ends, per-end multiplicities, `from`, `all`, and n-ary connectors.
 
 For `connector a ::> a.x to b;`, the connector's `sysx:relatedFeature` selects an
@@ -169,7 +172,7 @@ graph through the same serializer and require identical reconstructed notation;
 mutation refusal is vacuous. Check the graph delta is exactly the intended triple.
 
 A corpus file that `-convert ttl` refuses is not automatically an "unrelated" refusal: check
-its verdict in `internal/core/export/testdata/corpus_roundtrip_expected.txt` first. A file
+its verdict in `tests/corpus/testdata/corpus_roundtrip_expected.txt` first. A file
 pinned `stable` there that now refuses is a regression to fix, not a failure to preserve.
 Only when the baseline itself records the refusal may you isolate your feature in a modified
 copy — and label that as modified-copy evidence, never as a pass for the original file.
@@ -207,12 +210,12 @@ before converting; a fixture that does not analyse cleanly makes every later res
 test fixture. Grep before claiming coverage:
 
 ```bash
-grep -rn "targetMember" internal/core/export/testdata/ internal/core/export/*_test.go
+grep -rn "targetMember" internal/translate/export/testdata/ internal/translate/export/*_test.go
 ```
 
 If it is still absent, say so — the predicate is decoder-only and its encoder branch is untested.
 
-## Flag / import / succession-end predicates (fixtures under `internal/core/export/testdata/convert/`)
+## Flag / import / succession-end predicates (fixtures under `tests/export/testdata/convert/`)
 
 Each of these degrades visibly (exit 0, judge by text) or is refused when stripped together with
 `sysx:sourceText`; if the notation comes back unchanged the predicate has become decorative:
@@ -259,7 +262,7 @@ Every `@M;`, `@M { … }`, `metadata m : M about a, b;` and `#M part def P;` is
 `sysml:MetadataUsage` with `sysml:type`, one `sysml:annotatedElement` per `about` target,
 `sysx:hasBody`, `sysx:declaredKeyword` `"@"`/`"#"` (absent for the `metadata` keyword) and body
 members as owned members ordered by `sysx:memberIndex`. Fixtures:
-`internal/core/export/testdata/convert/metadata_bodies.sysml` and `metadata_prefixes.sysml`
+`tests/export/testdata/convert/metadata_bodies.sysml` and `metadata_prefixes.sysml`
 (neither validates clean on its own — unqualified `Integer`/`Real` and a `variant` outside a
 `variation` — so judge semantic equality by identical `-validate` diagnostics, or add
 `private import ScalarValues::*;` to a copy). Hand-edit the stripped `.ttl` for the controls:
@@ -284,16 +287,16 @@ directory alone matches nothing and `sysml` then complains about a missing exten
 
 ## The corpus round-trip ratchet (run it before and after any writer/encoder change)
 
-`TestCorpusRoundTrip` (`internal/core/export/corpus_roundtrip_test.go`) runs the three-hop trip
+`TestCorpusRoundTrip` (`tests/corpus/roundtrip_test.go`) runs the three-hop trip
 over **every** `.sysml`/`.kerml` under `examples/` — the 32 committed models, the 100-file
 training corpus and the three pilot corpora (213 files) — and pins one verdict per file in
-`internal/core/export/testdata/corpus_roundtrip_expected.txt`. It runs in about two seconds.
+`tests/corpus/testdata/corpus_roundtrip_expected.txt`. It runs in about two seconds.
 Record: `docs/project/rdf-corpus-roundtrip.md`.
 
 ```bash
 ./scripts/download-training-examples.sh && ./scripts/download-pilot-corpora.sh   # once
 OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 \
-  go test -count=1 -v ./internal/core/export -run TestCorpusRoundTrip
+  go test -count=1 -v ./tests/corpus -run TestCorpusRoundTrip
 ```
 
 Without the require variables an absent corpus **skips** the gate with a `GATE NOT RUN` banner on
@@ -318,8 +321,8 @@ regression does, so every movement is adjudicated. When your change moves files:
    record.
 3. Regenerate and commit the expectation file in the same PR:
    ```bash
-   go test ./internal/core/export -run TestCorpusRoundTrip -update-corpus-roundtrip
-   git diff --stat internal/core/export/testdata/corpus_roundtrip_expected.txt
+   go test ./tests/corpus -run TestCorpusRoundTrip -update-corpus-roundtrip
+   git diff --stat tests/corpus/testdata/corpus_roundtrip_expected.txt
    ```
    Run the update twice and confirm the second run leaves the file unchanged; the run is
    deterministic (a worker pool, results indexed by sorted path) and a diff between two runs is a
diff --git a/.agents/skills/testing-signed-release-manifest/SKILL.md b/.agents/skills/testing-signed-release-manifest/SKILL.md
index 611c27a7a9..83f81de86b 100644
--- a/.agents/skills/testing-signed-release-manifest/SKILL.md
+++ b/.agents/skills/testing-signed-release-manifest/SKILL.md
@@ -1,6 +1,6 @@
 ---
 name: testing-signed-release-manifest
-description: How to end-to-end test the signed release checksum manifest (clients/python/opensysml/signing.py, the signing branch of binary.py, and the cosign steps in .circleci/config.yml) on Linux without tagging a release — offline fixture bundles, cosign cross-checks, adversarial trust-model probes, and the CI-config static review.
+description: How to end-to-end test the signed release checksum manifest (client/python/opensysml/signing.py, the signing branch of binary.py, and the cosign steps in .circleci/config.yml) on Linux without tagging a release — offline fixture bundles, cosign cross-checks, adversarial trust-model probes, and the CI-config static review.
 ---
 
 # Testing the signed release manifest
@@ -38,13 +38,13 @@ unrelated to release signing.
 
 ## Proving the signing tests are offline
 
-`unshare -rn ~/pv/bin/python -m pytest clients/python/tests/test_signing.py clients/python/tests/test_binary.py -q`
+`unshare -rn ~/pv/bin/python -m pytest client/python/tests/test_signing.py client/python/tests/test_binary.py -q`
 Sanity-check the namespace first by attempting `urlopen('https://example.com')` inside it and
 seeing `URLError`. Equal pass count networked and isolated is the evidence.
 
 ## Adversarial probes worth writing (drop-in temp test file, delete afterwards)
 
-Reuse the `release`/`signer` fixture pattern in `clients/python/tests/test_signing.py`: monkeypatch
+Reuse the `release`/`signer` fixture pattern in `client/python/tests/test_signing.py`: monkeypatch
 `urllib.request.urlopen` to serve a dict of asset name → bytes, `opensysml.binary.get_binary_path`
 to a tmp path, `opensysml.binary.release_asset_name`, and set `opensysml.binary.PINNED_SHA256 = {}`.
 The recorded signer must use `trusted_root=tests/fixtures/signed_release/trusted_root.json`
@@ -69,7 +69,7 @@ Probes that actually distinguish working from broken:
 
 ## Independent cosign cross-check of the fixtures
 
-From `clients/python/tests/fixtures/signed_release/`:
+From `client/python/tests/fixtures/signed_release/`:
 
 ```
 cosign verify-blob SHA256SUMS.txt --bundle SHA256SUMS.txt.bundle \
diff --git a/.agents/skills/testing-sysml-repl/SKILL.md b/.agents/skills/testing-sysml-repl/SKILL.md
index 762a4b3ef1..62daf15a23 100644
--- a/.agents/skills/testing-sysml-repl/SKILL.md
+++ b/.agents/skills/testing-sysml-repl/SKILL.md
@@ -5,6 +5,72 @@ description: How to build, drive, and record end-to-end tests of the OpenSysML s
 
 # Testing the `sysml` REPL end-to-end
 
+## Recording analysis runs and querying persisted values
+
+- Use the worked Demo model in `docs/manual/recording-analysis-runs.md`.
+  `%record Demo::timed` defaults to the sibling top-level `Records` package:
+  a `Descendants` query rooted at `Demo` cannot see it. Query `root=Records`,
+  or explicitly record `into Demo::Log` before querying `root=Demo`.
+- A document can find default records without referring to an undeclared
+  element statically: compose `Descendants(source = Named(qualifiedName =
+  "Records"), maxDepth = 10)`, `WhereMetadata('metadata' =
+  "AnalysisRecords::RecordedRun")`, and `Project(properties = ("name", "gain",
+  "x"))`. For a no-record control, declare an empty `package Records {}`;
+  otherwise `Named` reports a missing element rather than returning zero rows.
+- Compare values separately from presentation: `%run-query` and saved SysML
+  preserve Real literals such as `2.0`; document table cells may display `2`.
+  Generated-file byte counts also vary with embedded tool-version provenance.
+- For runtime-failure atomicity, first validate the negative fixture. An analysis
+  definition needs its subject first, and a usage needs a subject binding; use
+  the Demo Probe pattern with an output `1.0 / divisor` and divisor `0.0`.
+  Compare both the original input and a pre-existing output after the failed
+  `-record-run ... -convert sysml -o ...`; a static rejection alone does not
+  exercise failed analysis execution.
+
+### Devin Secrets Needed
+
+None for local recording and document-query testing.
+
+## State/event document queries and lifetime diagnostics
+
+- Build query fixtures with `DocumentQueries::*`, `KerML::Root::Element`,
+  `ScalarValues::*`, and `SI::*` imports. The parallel lamp in
+  `internal/frontend/repl/docquery_states_test.go` is a validating REPL model;
+  the timer-driven fixture in `cmd/sysml/run_query_test.go` also works through
+  `-trace -instantiate lamp -state "lp lamp" -advance 4 -run-query ...`.
+- Turn `%trace on` on before the behaviors whose events are needed. Starting
+  tracing after instantiation omits initial entry records. A posted signal is
+  dispatched at the current clock instant when `%advance` runs, not at its
+  destination time: send at 1, advance to 2, and query `[1 [s], 2 [s])` to
+  include that signal while excluding a subsequent signal sent at 2.
+- `%state lp lamp1` attaches to the exhibited machine; parallel `States` rows
+  show one leaf per region. `InState(name="on")` returns the object once, not
+  once per active leaf.
+- Lifetime diagnostics (`destroyed at N`) use an **execution-order activation
+  mark**, not simulation-clock seconds. Cross-check N against `%features obj`
+  after `%eval in obj : OccurrenceFunctions::destroy(this)`; do not assert that
+  N equals the last `%advance` time. Error kind identity is not visible at the
+  REPL: report verification of the public diagnostic, not of a Go error type.
+- Distinguish termination (States returns zero rows and Events remains readable)
+  from destruction (both refuse). Completion reports the final `done` leaf.
+
+## Dynamic object lifecycle through the REPL
+
+- `%instantiate <PartDef>` already runs the classifier actions the definition
+  performs, so the objects they create with `new` exist on `#1` right after it;
+  a separate detached `%action` builds its own objects and never shows them.
+- `%instances` lists session roots only, not nested objects created at runtime.
+  Read them through the holder: `%features #1`, `%eval in #1 : cars`, and
+  `%eval in #1 : size(all P::Car)` for the live extent — which also counts the
+  declared singleton parts, not only the `new` objects.
+- A destroyed nested object shows its lifetime note under the alias that holds it
+  (`%features P::Fleet.spare`); `%eval in #1 : spare.n` prints the typed error's
+  text (`occurrence was destroyed`), so identity (`errors.Is`) is not observable here.
+
+### Devin Secrets Needed
+
+None.
+
 ## Action checker and witness replay
 
 - Low-level runtime conformance fixtures may omit scalar imports because their
@@ -66,7 +132,7 @@ None for local choice-pseudostate CLI/REPL testing.
 
 ## Model-level uncertainty versus object-level empty values
 
-Use `cmd/pilot-exec-diff/testdata/models/undetermined_operands.sysml` to
+Use `tools/referee/exec/testdata/models/undetermined_operands.sysml` to
 contrast model and object evaluation without inventing a fixture. Before
 instantiation, `%eval U::u` and
 `%eval SequenceFunctions::size(T::rack.gear)` answer `<undetermined>`,
@@ -132,7 +198,7 @@ and unprefixed inputs to check the magnitude, not just the displayed unit.
 
 ## Exploration scheduling
 
-Use `internal/core/runtime/testdata/conformance/action_explore_three_writers.sysml`
+Use `internal/exec/runtime/testdata/conformance/action_explore_three_writers.sysml`
 with the qualified action `test::race`: `-schedule explore` should produce three
 outcomes (`x=1,2,3`), two linearizations each, and `complete (6 runs)`. Compare
 two outputs byte-for-byte, not just their counts. `explore:runs=2` exits 2;
@@ -153,7 +219,7 @@ then query `%schedule` to prove the refusal preserved reverse. `%action`
 starts the executor; `%continue` is needed to see its final result.
 
 The REPL is the user-facing surface of `cmd/sysml`. Test it by actually running the binary, not
-just via `go test ./internal/repl`.
+just via `go test ./internal/frontend/repl`.
 
 ## Build
 
@@ -188,7 +254,7 @@ the Makefile preserves the version ldflags before the binary is copied out of th
 The document-query engine renders `part def`s specializing `DocumentQueries::Document` to Markdown:
 
 ```bash
-./bin/sysml -render-document Observatory::MassReport internal/core/docrender/testdata/telescope_report.sysml
+./bin/sysml -render-document Observatory::MassReport internal/doc/docrender/testdata/telescope_report.sysml
 printf '%%load <file.sysml>\n%%render-document <Qualified::Name>\n%%quit\n' | timeout 30 ./bin/sysml
 ```
 
@@ -213,7 +279,7 @@ printf '%%load <file.sysml>\n%%render-document <Qualified::Name>\n%%quit\n' | ti
 ## Library-cache cold/warm testing (`XDG_CACHE_HOME`)
 
 `bin/sysml` persists stdlib symbol indexes under `$XDG_CACHE_HOME/sysml-ls/libs/*-v<N>.idx`
-(`internal/core/libs`, `formatVersion` in `record.go`). Cache-dependent bugs only show up on the
+(`internal/workspace/libs`, `formatVersion` in `record.go`). Cache-dependent bugs only show up on the
 *second* run, so any change touching `symbols`/`libs`/`resolve` should be tested like this:
 
 ```bash
@@ -269,7 +335,7 @@ in `cmd/sysml/main.go`: walk every mode and assert the status, since a `return`
 
 ## Diagnostics-unchanged checks for core refactors
 
-For perf refactors in `internal/core` (scope indexes, resolve memoization, redefinition-owner
+For perf refactors in the layer packages (scope indexes, resolve memoization, redefinition-owner
 lookup) the only convincing evidence is a **byte-for-byte diff against a binary built from the
 parent commit** (see the contrast-binary recipe above):
 
@@ -324,7 +390,7 @@ parser's file-kind gates are testable through the binary:
 1. **Non-interactive (fast, for exploration and expected-value discovery).** The REPL reads a
    script from stdin fine:
    ```bash
-   printf '%%load internal/repl/testdata/vehicle_package.sysml\n%%instantiate Vehicle\n%%features Demo::Vehicle\n%%quit\n' | timeout 30 ./bin/sysml
+   printf '%%load internal/frontend/repl/testdata/vehicle_package.sysml\n%%instantiate Vehicle\n%%features Demo::Vehicle\n%%quit\n' | timeout 30 ./bin/sysml
    ```
    Note `%%` in `printf` format strings. Always wrap in `timeout` so a hang shows up as a
    non-zero exit rather than stalling the session.
@@ -348,13 +414,13 @@ In Konsole, `xdotool key ctrl+shift+plus` may type a literal `+`; use
 
 ### Driving a `type: "calc"` conformance fixture from the CLI
 
-Fixtures under `internal/core/runtime/testdata/conformance/` whose `.expected.json` says
+Fixtures under `internal/exec/runtime/testdata/conformance/` whose `.expected.json` says
 `{"type": "calc", "evaluate": "test::probe"}` are run non-interactively with `-calc` and an
 explicit argument list — the parentheses are required even when the calc takes none:
 
 ```bash
-./bin/sysml -calc "test::probe()" internal/core/runtime/testdata/conformance/<name>.sysml
-./bin/sysml -validate internal/core/runtime/testdata/conformance/<name>.sysml   # cheap clean-model check
+./bin/sysml -calc "test::probe()" internal/exec/runtime/testdata/conformance/<name>.sysml
+./bin/sysml -validate internal/exec/runtime/testdata/conformance/<name>.sysml   # cheap clean-model check
 ```
 
 Reals print as the shortest decimal that reads back as the same value, a whole one keeping its
@@ -378,7 +444,7 @@ flags, so `%save` says
 their advice differently and neither mentions the other's remedy.
 
 Things worth setting up as fixtures before a save/write test, since each exercises a different
-branch of `internal/core/export/write.go`:
+branch of `internal/translate/export/write.go`:
 
 - a FIFO (`mkfifo`) with a **background reader** — the write blocks until something reads; assert
   `ls -l` still shows `prw-` afterwards, i.e. the pipe was written through, not renamed over.
@@ -445,7 +511,7 @@ through `sort | uniq -c` so the evidence is one aggregate count instead of a scr
 the failures off the top:
 
 ```bash
-for f in examples/*.sysml internal/repl/testdata/*.sysml; do
+for f in examples/*.sysml internal/frontend/repl/testdata/*.sysml; do
   ./bin/sysml "$f" -convert sysml > /tmp/p1 2>/dev/null
   ./bin/sysml /tmp/p1 -convert sysml -from sysml > /tmp/p2 2>/dev/null
   cmp -s /tmp/p1 /tmp/p2 && echo idempotent || echo "NOT IDEMPOTENT: $f"
@@ -514,9 +580,9 @@ reaches paths that used to stop at the first behavioral node. What to know:
   wrote no kind keyword (`in x : Real`) is flagged `sysx:isKindImplicit` instead of gaining a kind
   on the way back. An `@` annotation ahead of a definition is refused, because the parser records
   it on the declaration before the one it prefixes — worth re-probing if the parser changes.
-- `export.ExperimentalNotice` (internal/core/export/experimental.go) is printed verbatim by the CLI
+- `export.ExperimentalNotice` (internal/translate/export/experimental.go) is printed verbatim by the CLI
   (stderr), `%save` and `ConvertResponse`, and the same wording is duplicated in
-  `cmd/sysml/main.go`, `clients/python/opensysml/`, `api/proto/` and `docs/guide/`. Check every copy whenever
+  `cmd/sysml/main.go`, `client/python/opensysml/`, `api/proto/` and `docs/guide/`. Check every copy whenever
   the mapping's coverage changes.
 
 ### Checking the experimental notice's copies (PR #271)
@@ -525,12 +591,12 @@ When a PR claims one wording is stated from one place, check the *runtime* copie
 the *documented* claims by running them:
 
 - Extract the Go literal and compare collapsed whitespace, rather than eyeballing:
-  parse `internal/core/export/experimental.go` for the quoted pieces of `ExperimentalNotice`, join
+  parse `internal/translate/export/experimental.go` for the quoted pieces of `ExperimentalNotice`, join
   them, then compare `" ".join(x.split())` against the paragraph `./bin/sysml -help` prints between
   "Turtle is normalized." and "Every run that converts RDF". `cmd/sysml/main.go`'s `wrapped(…, 78)`
   wraps on `strings.Fields`, so also assert every line's **rune** count ≤ 78 — the notice contains
   `§` (2 bytes), so a byte-based wrapper would pass a naive byte check.
-- `clients/python/opensysml/conversion.py:EXPERIMENTAL_NOTICE` should equal the same literal; compare it in
+- `client/python/opensysml/conversion.py:EXPERIMENTAL_NOTICE` should equal the same literal; compare it in
   Python against the Go file directly.
 - The client fallback lives in `Connection.convert` (`connection.py`, `response.experimental_notice
   or EXPERIMENTAL_NOTICE`). To exercise it, wrap the stub: `Connection._stub` is a read-only
@@ -546,13 +612,13 @@ the *documented* claims by running them:
   substate member") and its prose "a model whose point is a behavior does not [convert]" are wrong
   since #270 — the model converts, and all ten `examples/parser_features_demo_*.kerml` convert too.
   Grep for the *old* wording (`model structure only`, `bodies state behavior`) across `docs/ cmd/
-  clients/python/ api/proto/ internal/` to catch leftover copies, and check re-worded prose paragraphs did
+  client/python/ api/proto/ internal/` to catch leftover copies, and check re-worded prose paragraphs did
   not leave one line far wider than its siblings (`awk '{print NR": "length($0)}'`).
 
 ### Round-trip fidelity of a declaration head (PR #272)
 
 Two narrow export paths decide whether a declaration comes back spelled the way it was written:
-`encodeSubaction`/`bareWord` (`internal/core/export/behavior.go`) for a combined state subaction, and
+`encodeSubaction`/`bareWord` (`internal/translate/export/behavior.go`) for a combined state subaction, and
 `wroteKindKeyword`/`withoutComments` (`rdf_out.go`) for a kind keyword ahead of a name. Testing them:
 
 - **A fixture only exercises `wroteKindKeyword` when the commented word equals the keyword of the
@@ -618,32 +684,32 @@ explicitly, because a permutation bug that drops `--` still looks fine for ordin
 
 ## Fixtures worth knowing
 
-- `internal/repl/testdata/vehicle_package.sysml` — everything nested in `package Demo`
+- `internal/frontend/repl/testdata/vehicle_package.sysml` — everything nested in `package Demo`
   (`Engine`/`power`, `Vehicle`/`mass`+`engine`, `calc add`, a passing `withinMassLimit` and a
   failing `overMassLimit` constraint, `requirement SafeMass`). Ideal for package-scoped vs
   qualified lookup, and for pass *and* fail constraint paths in one file.
-- `internal/repl/testdata/action_debug.sysml` — `Debug::tally` with named nodes
+- `internal/frontend/repl/testdata/action_debug.sysml` — `Debug::tally` with named nodes
   `start, accumulate, end`, so `%break accumulate` has something to stop at; completes with
   `total = 5`.
-- `internal/repl/testdata/state_debug.sysml` — `Debug::Cycle`, timed transitions at +10 and +5.
+- `internal/frontend/repl/testdata/state_debug.sysml` — `Debug::Cycle`, timed transitions at +10 and +5.
   Good for `%advance` accumulation: `%advance 1` then `%advance 9` reaches the event due at 10
   (`working`), and ten successive `%advance 1` calls process exactly two events (one at t=0, one at
   t=10) with zeros in between — a per-call deadline that did not accumulate would never reach t=10.
-- `internal/core/runtime/testdata/conformance/state_orthogonal_regions.sysml` — `Test::TrafficLight`
+- `internal/exec/runtime/testdata/conformance/state_orthogonal_regions.sysml` — `Test::TrafficLight`
   with two regions; `%current` should print one state per region joined by `|`
   (e.g. `start | start`, then `Walk | Green`), never `<unknown>`.
 - Write your own for ambiguity: the same simple name (`part def Vehicle`) in two packages forces
   `error: symbol "Vehicle" is ambiguous: Alpha::Vehicle, Beta::Vehicle (use a qualified name)`.
 - Write your own for parse errors (e.g. `attribute mass = ;` plus a missing `}`): the parser never
   panics, so the REPL should print diagnostics and keep accepting commands.
-- `internal/core/runtime/testdata/conformance/state_body_state_local_member.sysml` — `test::monitor`,
+- `internal/exec/runtime/testdata/conformance/state_body_state_local_member.sysml` — `test::monitor`,
   whose substate `working` declares `localGain` that its own entry action reads together with the
   package's `pkgBonus`; `%state monitor` + `%advance 1` reaches `done` with `result = 5.00`. The
   scope-regression canary for states declared directly in a machine body.
 
 ## Variations, variants and redefinition-inherited members
 
-Fixtures live in `internal/core/runtime/testdata/conformance/`: `variation_attribute_selection.sysml`
+Fixtures live in `internal/exec/runtime/testdata/conformance/`: `variation_attribute_selection.sysml`
 (`test::idealDiamond`), `variation_part_selection.sysml` (`test::electricVehicle`),
 `variation_interface_selection.sysml` (`test::nestedAssembly`), `variation_unselected.sysml`
 (`test::unconfiguredDiamond`) and `ballandchain_variant_configuration.sysml`. Each `.expected.json`
@@ -799,9 +865,9 @@ because the obvious ones cannot:
 - There is **no `%what` command** — check `%help` before believing a task description. The lookup
   surface for "does this name resolve?" is `%instantiate` / `%features` / `%eval` (a `part def` is
   easiest via `%instantiate`, an attribute via `%eval`), all funnelling through
-  `internal/repl/lookup.go`. A request phrased as "`%what`/lookup" means those.
+  `internal/frontend/repl/lookup.go`. A request phrased as "`%what`/lookup" means those.
 - Symbol-taking commands: `%instantiate %features %eval %calc %constraint %requirement %action %state`.
-  All go through one helper (`internal/repl/lookup.go`), so test each with a **simple** name and a
+  All go through one helper (`internal/frontend/repl/lookup.go`), so test each with a **simple** name and a
   **qualified** one.
 - `%slots`, the pre-0.1.0 spelling, is **removed**: it reads as `unknown command "%slots"` and is
   offered by neither `%help` nor tab completion. `%features` is the only listing command, so a
@@ -841,7 +907,7 @@ because the obvious ones cannot:
   "fires" and "doesn't re-fire" are separate bugs.
 - `%advance` also drives a state's **do behavior**: when the only queued event is past the deadline
   but the current state has do actions left, they run and the output gains a
-  `Do behavior actions run: N` line (`internal/repl/testdata/state_do_far_event.sysml`, event at
+  `Do behavior actions run: N` line (`internal/frontend/repl/testdata/state_do_far_event.sysml`, event at
   t=100 — `%advance 1` runs 2 do actions and `%current` shows `count = 2`). Assert the clock does
   **not** jump to the far event and the event stays queued, and that repeating small advances does
   not re-run the behavior (`count` stays 2, `0 event(s) processed`).
@@ -869,7 +935,7 @@ because the obvious ones cannot:
 An action token that reaches an `accept` with no matching message **parks** instead of failing:
 `%step` reports `State: Waiting` and keeps the token at the accept node. Two paths worth testing:
 
-- Satisfiable: `internal/core/runtime/testdata/conformance/action_send_accept.sysml`
+- Satisfiable: `internal/exec/runtime/testdata/conformance/action_send_accept.sysml`
   (`%action communicator`) — `%break counter` pauses on the accept node, and resuming completes with
   `number = 50` / `n = 50` (the typed accept skips the String and takes the Integer). Loading these
   fixtures used to print a tier-2 `unresolved reference: n` diagnostic for the `assign` that reads
@@ -886,7 +952,7 @@ Always run these under `timeout` when driving over a pipe; a hang is the failure
 
 ### An accept node's payload as a body-scoped name (PR #196)
 
-`internal/core/resolve/accept_payload.go` contributes `action r accept msg : T;`'s payload to the
+`internal/semantic/resolve/accept_payload.go` contributes `action r accept msg : T;`'s payload to the
 **body** the accept node is declared in, so sibling nodes read it by simple name. Test it on both
 surfaces — `bin/sysml -validate f.sysml` for the diagnostic and
 `printf '%%load f\n%%action <n>\n%%continue\n%%quit\n' | timeout 30 ./bin/sysml` for the value —
@@ -916,14 +982,14 @@ because check-clean alone never proves the runtime bound anything.
 - A **qualified** `receiver::msg` reference resolves at check time and then fails the run with
   `usage receiver::msg has no value` — identical on both binaries, i.e. pre-existing rather than a
   regression of this change. Always A/B it before reporting it as a defect.
-- Cheap corpus gate for a resolve change: loop `internal/core/runtime/testdata/conformance/*.sysml`
+- Cheap corpus gate for a resolve change: loop `internal/exec/runtime/testdata/conformance/*.sysml`
   and `examples/*.sysml` comparing `grep -c 'error:'` counts new vs old binary and print only files
   where new > old (~1 min).
 
 ## Addressed sends and per-object message identity (`send S() to t`, PR #267)
 
 To observe *which object* consumed a message you must drive one performer at a time. `%state` and
-`%action` take an object argument (`%state <machine> <object>`, `internal/repl/meta.go:320`) but the
+`%action` take an object argument (`%state <machine> <object>`, `internal/frontend/repl/meta.go:320`) but the
 object must already exist, so **`%instantiate <Pkg>::<part>` first** — otherwise every command
 answers `error: no instance of "…" (use %instantiate first)` followed by `no active state machine
 session`, which reads like a broken session.
@@ -958,7 +1024,7 @@ Limits worth knowing before writing fixtures:
   receiver is named `self`) — expect `waiting`, not an error. A target naming a **part** rather than a
   port (`one.mid.inner`) is also delivered-but-unaccepted, not an error.
 - The two unroutable wordings are distinct and both must stay reachable
-  (`internal/core/runtime/routing.go`): an addressed target gives
+  (`internal/exec/runtime/routing.go`): an addressed target gives
   `send reaches no receiving port: "alpha.count" names no port of an object the sender can address`,
   while a `via` port gives `port "lonely" is joined to no port that can receive it` /
   `port "dst" is joined only to outbound ends (src)`. A pre-#267 binary **completes successfully**
@@ -987,7 +1053,7 @@ Testing this family end-to-end has a few traps that cost a whole run if hit late
   signals in flight from the timed-event queue. For a port-addressed signal, use a model sender:
   give the machine `port out : P; port in : P; connect out to in;` and a state whose
   `entry send Item(9) via out;` feeds the transition (the shape of
-  `internal/core/runtime/testdata/conformance/state_transition_accept_via_port.sysml`). The shipped
+  `internal/exec/runtime/testdata/conformance/state_transition_accept_via_port.sysml`). The shipped
   `state_transition_accept_payload.sysml` has **no** sender — its event comes from the
   `.expected.json` `events` array, so in the REPL it waits until a signal is injected.
   The harness event array is not automatically replayed by `%load`.
@@ -1026,7 +1092,7 @@ Testing this family end-to-end has a few traps that cost a whole run if hit late
 
 An action performance holds **one** value space (`ActionExecutor.data`, read back by `Results()` and
 `Data()`); a fork duplicates control only, a join merges nothing, and a retiring token carries
-nothing out. Consequences worth asserting whenever anything in `internal/core/runtime`'s action path
+nothing out. Consequences worth asserting whenever anything in `internal/exec/runtime`'s action path
 changes:
 
 - Every branch's writes must appear in `Results:` together. The historical bug (pre-#170) was that
@@ -1077,7 +1143,7 @@ is real is an A/B against a binary built from `main` in a `git worktree` — sam
 number.
 
 Things that must fail rather than hang: the REPL builds its runtime context with a step budget that
-defaults to **10000000** (`runtime.DefaultMaxSteps`, `internal/core/runtime/budget.go`; sessions
+defaults to **10000000** (`runtime.DefaultMaxSteps`, `internal/exec/runtime/budget.go`; sessions
 carry the five budgets via `Session.SetBudgets(runtime.Budgets)`), and every loop iteration spends
 several steps, so a runaway loop (or an empty loop body, whose condition can never change) returns
 `error: execution failed: eval … : evaluation step limit exceeded (10000000 steps; raise OPENSYSML_MAX_STEPS to allow more)`
@@ -1090,7 +1156,7 @@ and only for a value that states a *computation* (a body expression). See the ne
 
 ### `for` over a scalar is a typed error (PR #231)
 
-Since PR #231 `forElements` (`internal/core/runtime/statements.go`) only iterates a **sequence** or a
+Since PR #231 `forElements` (`internal/exec/runtime/statements.go`) only iterates a **sequence** or a
 **set**; `null` iterates zero times, and *everything else* — Integer, Real, Boolean, String, an
 expression — is `type mismatch: 'for' iterates a collection, and <describeValue> is not one`. Exact
 texts observed on `bin/sysml`, worth asserting verbatim:
@@ -1127,7 +1193,7 @@ Before PR #202 a loop body or `if` branch containing an **action node** (a neste
 declaration, or a `perform`) aborted the run at lowering:
 `action node iterate: action usage "scale" in a body is not executable` /
 `'perform' in a body is not executable`. After it, the block becomes a token flow of its own
-(`internal/core/lower/block_graph.go`) and runs. That old message is the **ideal A/B contrast** —
+(`internal/ir/lower/block_graph.go`) and runs. That old message is the **ideal A/B contrast** —
 build the parent commit into `/tmp/old-sysml` (recipe above) and run the same model through both;
 the old binary aborting while the new one prints numbers is far stronger than a screenshot of the
 new numbers alone.
@@ -1233,7 +1299,7 @@ a pre-fix binary, so build the contrast binary from the merge-base first (see ab
   input names, then reads `p.b`; the other reads both outputs before the assignment. Both must give
   the **same** number — the read order is not observable. The pre-fix signature is the interleaved
   one answering from mixed state (e.g. `1020.00` against `1010.00`). `%calc` on each is enough;
-  ready-made as `internal/core/runtime/testdata/conformance/calc_usage_outputs_one_binding.sysml`.
+  ready-made as `internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.sysml`.
   Two assertions must accompany it, since the fix relaxes the memoization key and could over-share:
   a genuine output cycle (`out a = b + 1.0; out b = a + 1.0;`) still has to report
   `cyclic output dependency: output a of calc … depends on itself`, and two usages of one calc def
@@ -1262,7 +1328,7 @@ a pre-fix binary, so build the contrast binary from the merge-base first (see ab
   number; pre-fix both were `usage Pkg::lander has no value`. Reading the usage **without** naming
   an output must name the outputs instead:
   `no value: calc usage mass computes output features (mProp, mDry, mWet); read one of them`.
-  The model lives in `internal/core/runtime/part_feature_chain_test.go` as `partChainModel` — copy
+  The model lives in `internal/exec/runtime/part_feature_chain_test.go` as `partChainModel` — copy
   it rather than inventing constants, and take the expected value from that test
   (`mDry = 100.0 + 250.0 * 0.4` → `200.00`); task descriptions quoting other magnitudes usually
   refer to an earlier draft of the model.
@@ -1279,7 +1345,7 @@ follow them with the cheap canaries: `%action tally` + `%continue` → `total =
 ## Session-accumulation trap (bites both testers and features)
 
 Whether re-typing a namespace **adds to** it or **replaces** it depends on where the earlier one
-came from (`mergeSubmission`, internal/repl/merge.go):
+came from (`mergeSubmission`, internal/frontend/repl/merge.go):
 
 - **Typed earlier at the prompt → merged.** `package Demo { part def Trailer; }` folds into the
   `package Demo` already typed: `note: added to the existing package Demo (its other members are
@@ -1295,7 +1361,7 @@ forkJoin, action conditional no longer declared)` — every member it declared 
 - An **empty body** (`package Demo { }`) is the deliberate way to empty a namespace, and a
   submission with a different header (or declaring more than one thing) replaces rather than merges.
 
-`Submit` **carries instances over** what a submission did not change (`internal/repl/carryover.go`,
+`Submit` **carries instances over** what a submission did not change (`internal/frontend/repl/carryover.go`,
 `runtime.Adopt`): after an unrelated `part def B;`, `%instances` still lists the instance with the
 **same ID**, `%features` still prints its values, and the next `%instantiate` gets a *fresh* ID rather
 than `ID: 1`. What the submission invalidated still goes — redeclaring the instance's own definition,
@@ -1371,7 +1437,7 @@ scoped to the submission just made, so one bad snippet no longer keeps re-printi
 later submissions. Two consequences when testing:
 
 - Reported line/column numbers are **relative to what you just typed** (`Result.Offset` /
-  `baseLine()` in `internal/repl/render.go`), so a one-line submission reports `1:36:` no matter how
+  `baseLine()` in `internal/frontend/repl/render.go`), so a one-line submission reports `1:36:` no matter how
   much is already in the buffer. Only `%verbosity debug` numbers against the whole buffer.
 - While an earlier error is unresolved, the next clean submission prints
   `note: deeper checks may not have run here: the error on buffer line N is unresolved (see it with
@@ -1383,7 +1449,7 @@ and pollutes the buffer. `%clear` resets the session.
 
 ## The session-long symbol index and wildcard re-exports (PR #95)
 
-`Session.symbolIndex()` (`internal/repl/session.go`) keeps **one** `symbols.Index` for the whole
+`Session.symbolIndex()` (`internal/frontend/repl/session.go`) keeps **one** `symbols.Index` for the whole
 session: the stdlib is loaded into it once (`model.LoadStdlibInto`) and only the session document is
 re-indexed, when `doc.Version` changed. So stale/duplicated symbols are the failure mode to hunt,
 and every assertion should be re-checked *late* in a long session, not only on the first submission.
@@ -1418,7 +1484,7 @@ Notes that save time:
 - Because the REPL has a **single** document, a re-index removes and re-adds all of its re-exports
   wholesale. Index bugs that need one document's member to change while a *different* importing
   document survives are therefore not reachable from the prompt — verify those in
-  `internal/core/symbols` tests instead of hunting them at the REPL. In particular a top-level
+  `internal/semantic/symbols` tests instead of hunting them at the REPL. In particular a top-level
   (outside any `package`) `import Lib::*;` followed by a submission that drops the surfaced
   declaration still correctly reports `not found` at the prompt.
 - Stdlib staleness check: quantity/unit evaluation resolves its unit through the session index, so it
@@ -1461,11 +1527,11 @@ diagnostic with the pass that produced it (`[syntax/syntax]`, `[type/type.expr]`
 
 Tracing prefixes every recorded line with `[trace] `. Evaluation entries are **post-order and
 indented**: sub-expressions appear before, and one level deeper than, the expression that consumed
-them (`internal/core/runtime/trace.go`). `%features` on a model with derived attributes is the easiest
+them (`internal/exec/runtime/trace.go`). `%features` on a model with derived attributes is the easiest
 way to see a full tree — `derived_package.sysml` gives `eval operator * -> 3000.0` and a nested
 `eval feature power -> 300.0` / `eval operator * -> 270.0` / `eval operator + -> 1770.0`.
 
-The recorder is **drained per command** (`drainTrace` in `internal/repl/trace.go`), so each command
+The recorder is **drained per command** (`drainTrace` in `internal/frontend/repl/trace.go`), so each command
 reports only its own steps. Always assert the negative too: run an unrelated command such as
 `%instances` straight afterwards and confirm it prints **no** `[trace]` lines — a recorder that is
 not cleared would replay the previous command's tree.
@@ -1473,11 +1539,11 @@ not cleared would replay the previous command's tree.
 ### Regression watch: traces during a debugging session
 
 `ActionExecutor.trace()` reads the recorder off `e.ctx` rather than caching it on the executor
-(`internal/core/runtime/action_executor.go`). That is what makes `%trace on` reach an execution
+(`internal/exec/runtime/action_executor.go`). That is what makes `%trace on` reach an execution
 already under way, and what makes **expression** traces appear alongside step traces. The
 historically broken sequence, worth re-running after any change in this area:
 
-1. `%load internal/repl/testdata/action_debug.sysml`, `%action tally`
+1. `%load internal/frontend/repl/testdata/action_debug.sysml`, `%action tally`
 2. submit an unrelated declaration, e.g. `package Unrelated { part def Widget { attribute size = 1.0; } }`
 3. `%trace on`, then `%step` repeatedly
 
@@ -1486,7 +1552,7 @@ Expect **both** `[trace] step N: token 1@accumulate` **and** `[trace] eval featu
 signature. Then `%trace off` must silence output in that same session.
 
 Control nodes are named by what they do, not by Go type: an unnamed fork/join/final reports
-`token 1@fork` / `@join` / `@final` (`nodeIdentifier` in `internal/core/runtime/trace.go`). A `*ast.`
+`token 1@fork` / `@join` / `@final` (`nodeIdentifier` in `internal/exec/runtime/trace.go`). A `*ast.`
 type name in trace output means a node kind is missing from that switch.
 
 ## Spot-checking the docs against the binary
@@ -1522,8 +1588,8 @@ Traps worth re-checking after any doc or REPL edit:
 
 ## The gRPC service and the `opensysml` Python client
 
-The REPL is not the only user-facing surface: `cmd/sysml-grpc` plus `clients/python/opensysml` is the path a
-Python user takes, and the two can disagree. When a change touches `internal/grpc/convert.go` or
+The REPL is not the only user-facing surface: `cmd/sysml-grpc` plus `client/python/opensysml` is the path a
+Python user takes, and the two can disagree. When a change touches `internal/frontend/grpc/convert.go` or
 the runtime's slot evaluation, **test both and diff them** — that comparison is the highest-value
 assertion available.
 
@@ -1531,11 +1597,11 @@ assertion available.
 export PATH=/usr/local/go/bin:$PATH
 make build && make build-grpc              # -> bin/sysml, bin/sysml-grpc
 mkdir -p ~/.opensysml/bin && cp bin/sysml-grpc ~/.opensysml/bin/   # where the client looks
-pip install -e clients/python/
+pip install -e client/python/
 ```
 
 Do **not** start the service by hand for model-semantics work. `Connection._ensure_service`
-(`clients/python/opensysml/connection.py`) spawns a **private child** of the interpreter on `-port 0` and
+(`client/python/opensysml/connection.py`) spawns a **private child** of the interpreter on `-port 0` and
 learns the address from the child's stdout, which is the realistic user path. There is no pidfile,
 no lockfile and no adoption of a service the client did not start: a service you started yourself is
 reached only by naming it (`connect(host, port)`, `OPENSYSML_SERVICE=host:port`, or
@@ -1559,7 +1625,7 @@ real time:
   `addr=:0`, so a naive `grep -o 'addr=[^ ]*' | head -1` grabs the health line and you dial port 0
   ("Connection refused"). Always filter on `gRPC server listening` first, and take the port with
   `${ADDR##*:}` — `cut -d: -f3` yields `]` for `[::]:41325`.
-- Expected values at 0cf94e80 for `internal/grpc/testdata/conformance/instantiate_derived_slot.sysml`:
+- Expected values at 0cf94e80 for `tests/grpc/testdata/conformance/instantiate_derived_slot.sysml`:
   `mass` → `materialized=True kind=real_value 1500.0`, `doubled` → `real_value 3000.0`; a missing
   model path raises `opensysml.errors.ModelFileNotFoundError` ("file not found: open …") and the
   server logs `code = NotFound` for `/sysml.SysMLService/ParseFile` while staying alive. An already
@@ -1577,7 +1643,7 @@ real time:
 resolves to in a tool shell may be another project's venv, and a venv built from it gets a
 mismatched `sys.path` — `pyvenv.cfg` naming one minor version while `bin/python` runs another, so
 the editable install lands in a `site-packages` the interpreter never searches and `import opensysml`
-(or `import grpc`) fails right after a *successful* `pip install -e clients/python/`. Always build the venv
+(or `import grpc`) fails right after a *successful* `pip install -e client/python/`. Always build the venv
 from an explicit real interpreter (`/home/ubuntu/.pyenv/versions/3.12.8/bin/python3.12 -m venv ~/pv`,
 or `/usr/bin/python3.10`) and verify `<venv>/bin/python -c 'import opensysml'` before blaming the
 client. `$HOME/pv` is created by the blueprint, so prefer reusing it.
@@ -1630,7 +1696,7 @@ OPENSYSML_GRPC_VERSION=v0.0.7 python -c '...connect(port=50099)...'   # -> Stale
   process (two `connect()`s): 1 → 2 → 1, the service still serving the remaining holder and stopped
   only when the last one closes. Across two processes there is nothing to share: each starts its own.
 - **A leftover service may be answering 50051 from a path you never built.** A previous session can
-  leave e.g. `/tmp/sysml-grpc` listening, in which case `python -m pytest clients/python/tests/test_runtime_integration.py`
+  leave e.g. `/tmp/sysml-grpc` listening, in which case `python -m pytest client/python/tests/test_runtime_integration.py`
   reports `N passed` in ~0.05 s against *unknown* code — the integration suite neither skips nor
   tells you whose binary served it, so a green run proves nothing about your commit. Before trusting
   any client result, run `pgrep -af sysml-grpc` — `-x` does find `/tmp/sysml-grpc` (it matches the
@@ -1686,7 +1752,7 @@ Fixture trap when proving `Model.execute_action` / `Model.execute_state` "exist
 that only declares parameters (`action add : Add { in x = 2.0; out z = x + y; }`) validates clean but
 raises `ExecutionError: initialize action: no initial node found in action add` — there is nothing to
 execute. Do not read that as a broken RPC; borrow a body-bearing fixture instead, e.g.
-`internal/core/runtime/testdata/conformance/action_body_local_calc_usage.sysml`
+`internal/exec/runtime/testdata/conformance/action_body_local_calc_usage.sysml`
 (`execute_action("test::run")` → `{'v': 2.0, 'i': 3, 'doubled': 4.0, 'acc': 12.0}`) and
 `state_anonymous_action_body.sysml`
 (`execute_state("Test::Bodies")` → `states_visited ['start','working','nstart','nested','done']`,
@@ -1697,7 +1763,7 @@ Suite baseline: `cd python && python -m pytest tests/ -q` with no service runnin
 `148 passed, 24 skipped` (~40s; it was `75 passed, 18 skipped` before the Tier 1/Tier 2 client
 work), and `158 passed, 14 skipped` with a service running. As of the 0.0.8 prep branch
 (`b0f5f23`) that baseline is `368 passed, 26 skipped` in ~42 s from the repo root
-(`python -m pytest clients/python/tests/ -q`), with one expected `UserWarning` from
+(`python -m pytest client/python/tests/ -q`), with one expected `UserWarning` from
 `test_a_cache_survives_a_replacement_that_cannot_be_downloaded` — re-measure rather than trusting an
 older count. The skips are the integration
 tests gating on a live service. `pytest` is **not**
@@ -1717,7 +1783,7 @@ To hold a service alive for a whole test run, keep a client process open, e.g.
 `(setsid python -c "import opensysml,time; opensysml.connect(); time.sleep(300)" &)` — a plain
 backgrounded `python -c` from a non-tty shell may exit before it prints, so verify the port.
 
-Download paths (`clients/python/opensysml/binary.py`) are testable without a real release: move
+Download paths (`client/python/opensysml/binary.py`) are testable without a real release: move
 `~/.opensysml/bin/sysml-grpc` aside, unset `OPENSYSML_GRPC_VERSION`, and call `ensure_binary()`,
 `resolve_latest_version()`, `download_binary('latest')`. All three must raise `ConnectionError`
 naming the path or URL. `OPENSYSML_GITHUB_REPO` overrides the repo. Beware: these hit the
@@ -1766,7 +1832,7 @@ worth asserting, with the wording each produces:
 
 #### Proving a *pinned release digest* really unblocks a download (PR #316)
 
-`PINNED_SHA256` in `clients/python/opensysml/binary.py` is what `download_binary(version)` verifies against;
+`PINNED_SHA256` in `client/python/opensysml/binary.py` is what `download_binary(version)` verifies against;
 without an entry for the tag, `expected_digest` raises `UnpinnedReleaseError` (a subclass of
 `ChecksumMismatchError`) instead of trusting the `.sha256` served beside the asset. Verifying a new
 pin end to end needs a **real download**, so isolate the cache first:
@@ -1789,10 +1855,10 @@ pin end to end needs a **real download**, so isolate the cache first:
   `sysml-grpc.tmp` behind: a published-but-unpinned tag (v0.0.9 is the standing example) ⇒
   `UnpinnedReleaseError`; and an in-memory tampered pin (`PINNED_SHA256[…][asset] = '0'*64`) ⇒
   `ChecksumMismatchError` naming both digests, refused before the ~24 MB binary is installed.
-- `clients/python/scripts/pin_release_checksums.py --check` re-hashes every pinned asset and is the only
+- `client/python/scripts/pin_release_checksums.py --check` re-hashes every pinned asset and is the only
   coverage for the darwin/windows pins on a Linux box. It needs a token:
-  `GITHUB_TOKEN=$(gh auth token) python clients/python/scripts/pin_release_checksums.py --check` (exit 0 and
-  one digest line per asset). Confirm it is not vacuous by copying `clients/python/` aside, corrupting one
+  `GITHUB_TOKEN=$(gh auth token) python client/python/scripts/pin_release_checksums.py --check` (exit 0 and
+  one digest line per asset). Confirm it is not vacuous by copying `client/python/` aside, corrupting one
   digest and re-running — it must exit 1 with `… now hashes to X, but Y is pinned`.
 
 #### Service start-up timing and its failure paths (PR #250)
@@ -1801,7 +1867,7 @@ pin end to end needs a **real download**, so isolate the cache first:
 (`START_PROBE_INITIAL_DELAY` 10 ms, doubling to `START_PROBE_MAX_DELAY` 250 ms) until
 `START_TIMEOUT` (2.5 s). Timing claims here need a **contrast run against the parent revision**,
 which needs no rebuild since opensysml is pure Python: `git worktree add /tmp/mainwt main`, copy the
-generated `clients/python/opensysml/proto/*.py` in if they are missing, then run the same script twice, once
+generated `client/python/opensysml/proto/*.py` in if they are missing, then run the same script twice, once
 plain and once with `PYTHONPATH=/tmp/mainwt/python`, on the *same* `$HOME/pv` venv. Numbers seen at
 c590253e on a free port with nothing listening: **21 ms on the branch vs 515 ms on main**; the
 connection must then really work (`conn.load_from_content(...)` + `Model.eval('1 + 1') == 2`), since
@@ -1846,11 +1912,11 @@ Recipes for the failure paths, all with a port of their own so the :50051 tests
 ### Verification RPCs, typed errors and strict loading (`opensysml` Tier 3, PR #149)
 
 The verification questions the REPL answers with `%constraint`, `%requirement`, `%satisfy` and
-`%calc` are also RPCs (`internal/grpc/verify.go`), wrapped as `Model.verify_constraint /
+`%calc` are also RPCs (`internal/frontend/grpc/verify.go`), wrapped as `Model.verify_constraint /
 verify_requirement / verify_satisfaction / satisfied / calc`. Testing them from Python:
 
 - Use a **clean venv** — the box's default `python3` may carry an incompatible `protobuf`, which
-  fails at `import opensysml`. A venv with `pip install -e clients/python/` (e.g. `~/pv`) is the reliable
+  fails at `import opensysml`. A venv with `pip install -e client/python/` (e.g. `~/pv`) is the reliable
   interpreter; rebuild with `make build-grpc` and **re-copy** `bin/sysml-grpc` to
   `~/.opensysml/bin/` after every rebuild or the client silently auto-starts the old binary.
 - Argument order bites: `Connection.eval(expression, model_hash)`,
@@ -1891,7 +1957,7 @@ verify_requirement / verify_satisfaction / satisfied / calc`. Testing them from
 
 ### The shared library index, `OPENSYSML_GRPC_INDEX_POOL` (PR #252; shared base since slice A of L3)
 
-`internal/grpc/libindex.go` builds **one** frozen standard library index and gives each model an
+`internal/frontend/grpc/libindex.go` builds **one** frozen standard library index and gives each model an
 overlay over it (any positive `OPENSYSML_GRPC_INDEX_POOL` prewarms that build; `0` restores the
 per-cache-miss build). It was a pool of N per-model indexes until slice A, so the drain-and-refill
 behaviour below no longer applies: there is nothing to drain, and a tight sweep of distinct models
@@ -1971,7 +2037,7 @@ service-side perf change:
   (including one sent immediately after the port opens, while the prewarm build is still in flight)
   exits 0 in a few ms.
 
-#### The shared on-disk library index cache (`internal/core/libs/cache.go`)
+#### The shared on-disk library index cache (`internal/workspace/libs/cache.go`)
 
 The REPL, the LSP and the gRPC service all read `~/.cache/sysml-ls/libs` (or
 `$XDG_CACHE_HOME/sysml-ls/libs`), 95 content-addressed `*.idx` files — so a change to that file needs
@@ -1985,7 +2051,7 @@ exit 0), never an error mentioning the cache.
 
 ### The `Query` RPC / `model.query(...)` (SysML v2 API & Services, PR #155)
 
-`internal/grpc/query.go` + `clients/python/opensysml/query.py` implement the standard's Query resource
+`internal/frontend/grpc/query.go` + `client/python/opensysml/query.py` implement the standard's Query resource
 (`scope`/`select`/`where`, `PrimitiveConstraint` with `=`/`>`/`<` and `inverse`,
 `CompositeConstraint` with `and`/`or`). Testing notes that generalize:
 
@@ -2056,13 +2122,13 @@ attributes (`Level { low { :>> n = 1; } high { :>> n = 9; } }`), read as `eval("
   `main`'s quantity support landed. After any merge that touches the `Value` oneof, re-run both
   arms *on the same part* (one `part def` with `attribute c : Color = Color::red;` **and**
   `attribute mass = 1500.0 [SI::kg];`): a field-number mismatch shows up as `None`/`unsupported`,
-  not as an exception. `clients/python/tests/test_wire_compat.py` pins the numbers at unit level.
+  not as an exception. `client/python/tests/test_wire_compat.py` pins the numbers at unit level.
 - **Incoming values go through one converter.** `ProtoToValueIn(pv, idx, sem)` in
-  `internal/grpc/convert.go` dispatches the quantity and literal arms and recurses into sequences;
-  it is called from `internal/grpc/service.go` (action inputs) and `internal/grpc/verify.go` (calc
+  `internal/frontend/grpc/convert.go` dispatches the quantity and literal arms and recurses into sequences;
+  it is called from `internal/frontend/grpc/service.go` (action inputs) and `internal/frontend/grpc/verify.go` (calc
   arguments). The error wording is layer-specific and worth asserting verbatim:
   calc → `calc argument could not be read: …`, action → `input "c" could not be read: …`.
-- **Identity is `literal_id` alone** (`clients/python/opensysml/enumeration.py` marks `enumeration_id` and
+- **Identity is `literal_id` alone** (`client/python/opensysml/enumeration.py` marks `enumeration_id` and
   `name` `compare=False`). Comparing two *wire-populated* literals passes even when this is broken,
   so always include the bare-vs-populated cases: with `bare = EnumLiteral("D::Color::red")` and the
   feature value, assert `bare == car.c`, `hash(bare) == hash(car.c)`,
@@ -2110,15 +2176,15 @@ attributes (`Level { low { :>> n = 1; } high { :>> n = 9; } }`), read as `eval("
 the **live service** (so it auto-starts `sysml-grpc`) and prints/writes one class per SysML
 definition deriving from `opensysml.typed.TypedObject`. Useful facts when testing it:
 
-- The reference fixture is `internal/repl/testdata/vehicle_package.sysml` and the committed
-  golden is `clients/python/tests/golden/vehicle_types.py`; `cmp` them for a byte-for-byte assertion and
+- The reference fixture is `internal/frontend/repl/testdata/vehicle_package.sysml` and the committed
+  golden is `client/python/tests/golden/vehicle_types.py`; `cmp` them for a byte-for-byte assertion and
   generate twice + `cmp` for determinism. Emission is FQN-ordered with base classes first.
 - Only instance feature usages become properties (`attribute/part/item/occurrence/port/enum`);
   `calc`, `constraint` and `requirement` members are deliberately absent — a generated class
   that grows a `withinMassLimit` property is a bug, not progress.
 - Annotations are the whole point: `attribute power = 300.0;` must render `-> float` and
   `part engine : Engine;` must render `-> Engine`. If everything renders `object`, the typefacts
-  path (`internal/grpc/typefacts.go` → `SymbolInfo.type_info`) is broken.
+  path (`internal/frontend/grpc/typefacts.go` → `SymbolInfo.type_info`) is broken.
 - Static-check evidence needs `MYPYPATH=<repo>/python mypy --follow-imports=silent script.py`
   and the venv mypy (`~/opensysml-venv/bin/mypy`). Without `MYPYPATH`, mypy silently treats
   `TypedObject` as `Any` and *misses* attribute-typo errors, so a "clean" mypy run proves nothing
@@ -2157,7 +2223,7 @@ definition deriving from `opensysml.typed.TypedObject`. Useful facts when testin
   binary), not `pkill -f 'bin/sysml-grpc'` — that pattern also matches the tool shell running the
   command and kills your own session. Rebuilding leaves the old process serving a `(deleted)`
   binary, which silently tests the previous revision.
-- `clients/python/tests/test_lifecycle.py::TestLifecycleRobustness::test_service_shuts_down_when_last_process_exits`
+- `client/python/tests/test_lifecycle.py::TestLifecycleRobustness::test_service_shuts_down_when_last_process_exits`
   fails (`FileNotFoundError: ~/.opensysml/sysml-grpc.pid`) whenever an externally started service is
   already listening on 50051 — a known service-ownership gap, reproducible on `main`. Confirm on a
   `main` worktree before reporting it as a regression.
@@ -2169,9 +2235,9 @@ definition deriving from `opensysml.typed.TypedObject`. Useful facts when testin
 ## Built-in library functions (sqrt/sin/exp/ln/log/atan2 …)
 
 The runtime supplies bodies for the function-library declarations in
-`internal/core/runtime/library_functions.go`; the non-normative extensions
+`internal/exec/runtime/library_functions.go`; the non-normative extensions
 (`exp`, `ln`, `log`, `atan2`) live in
-`internal/core/libs/stdlib/OpenSysML Libraries/OpenSysMLMathFunctions.kerml`.
+`internal/workspace/libs/stdlib/OpenSysML Libraries/OpenSysMLMathFunctions.kerml`.
 Testing notes that generalize to any future built-in:
 
 - The fastest end-to-end surface is the batch flag, which loads a model *and* evaluates
@@ -2203,18 +2269,18 @@ Testing notes that generalize to any future built-in:
 ## `.kerml` vs `.sysml` file kind: which surfaces actually keep it
 
 Anything in the parser gated on `p.src.Kind() == source.KindKerML` (e.g. `parser.unreserved` in
-`internal/core/parser/notation.go`, which reclassifies SysML-only literals such as `at`, `while`,
+`internal/syntax/parser/notation.go`, which reclassifies SysML-only literals such as `at`, `while`,
 `merge`, `decide` as names in a `.kerml` file) is **invisible on the REPL/`-validate` path**: the
 session buffers every submission into one document named by the constant `docName = "<repl>"`
-(`internal/repl/session.go:25`, opened at `session.go:728 ws.Open(docName, …)`), so
+(`internal/frontend/repl/session.go:25`, opened at `session.go:728 ws.Open(docName, …)`), so
 `source.KindOf("<repl>")` is `KindUnknown` and the gate never fires. `%load`ing a `.kerml` file
 behaves the same way.
 
 Surfaces that *do* pass the real path, and are therefore the ones to test file-kind behavior on:
 
-- `sysml <file>.kerml -convert ttl` → `internal/core/export/convert.go:278 source.New(name, data)`.
-- the LSP / `model.newDocument` (`internal/core/model/document.go:26`) with a real URI.
-- the stdlib loader `internal/core/libs/loader.go` and `cmd/pilot-diff`.
+- `sysml <file>.kerml -convert ttl` → `internal/translate/export/convert.go:278 source.New(name, data)`.
+- the LSP / `model.newDocument` (`internal/workspace/model/document.go:26`) with a real URI.
+- the stdlib loader `internal/workspace/libs/loader.go` and `tools/referee/diff`.
 
 Only the *pass* layer has a compensating hack for the buffer's missing kind
 (`session.go dropKerMLNotationOfKerMLFiles` drops the `kerml-notation` warning for spans that came
@@ -2251,7 +2317,7 @@ Three cheap, high-signal sweeps:
      diff <(./bin/sysml -quiet /tmp/sweep.sysml </dev/null 2>&1) \
           <(/tmp/mainwt/sysml-main -quiet /tmp/sweep.sysml </dev/null 2>&1) >/dev/null \
        || { d=$((d+1)); echo "DIFF: $f"; }
-   done < <(find examples testdata internal/repl/testdata -name '*.sysml' -print0)
+   done < <(find examples tests/testdata internal/frontend/repl/testdata -name '*.sysml' -print0)
    echo "compared $n, differing $d"
    ```
    A `for f in $(find …)` loop word-splits those paths and silently compares nothing for them: on
@@ -2329,7 +2395,7 @@ Fixture hygiene for these files: use `public import ScalarValues::*;` (a bare
 `ControlFunctions` / `NumericalFunctions` for arrow and operator-name shapes. Trailing
 expressions *are* name-resolved as of PR #581, so a body that validated clean on an older binary
 can now report `unresolved reference: <name>` — that is the intended tightening, not a bug. The
-committed conformance fixtures `internal/core/runtime/testdata/conformance/calc_simple_add.sysml`
+committed conformance fixtures `internal/exec/runtime/testdata/conformance/calc_simple_add.sysml`
 and `calc_unary_operators.sysml` lack the ScalarValues import and therefore exit 2 on
 `unresolved reference: Integer/Boolean` **on the parent binary too**; judge such a run by the
 absence of parse/`return`-related diagnostics, not by the exit code.
@@ -2361,7 +2427,7 @@ fixed from broken, each with a visible A/B against `main`:
 - **State** — `state redefines waiting; accept go then active;`. A lost name makes the sourceless
   accept vanish: `%state` shows `Events: 0` and `%advance 1` never leaves the initial state.
 
-Ready-made fixtures for all of these live in `internal/core/runtime/testdata/conformance/`
+Ready-made fixtures for all of these live in `internal/exec/runtime/testdata/conformance/`
 (`action_redefined_attribute_default[_symbol]`, `action_redefined_step_ordering`,
 `calc_redefined_parameter[_symbol]`, `state_redefined_state_accept[_symbol]`) — load them straight
 into the REPL with `%action test::run` / `%eval test::Scaled(7)` / `%state Test::Machine` rather than
@@ -2392,7 +2458,7 @@ A name in the unit position of `x [u]` is an ordinary feature reference, so it r
   `500.0 [m]`, with `public import SI::*` in the enclosing package. Expect, verbatim, from all four:
   `not a measurement unit: m resolves to the attributeUsage m declared in <NS>, shadowing the
   measurement unit SI::metre — write SI::m to name the unit`. Ready-made:
-  `internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_{slot,action,calc,constraint}.sysml`,
+  `internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_{slot,action,calc,constraint}.sysml`,
   plus `unit_shadowed_by_local_unit` (a sibling that *is* a unit must still evaluate) and
   `unit_undeclared` (`unresolved unit furlong` — a different message, assert it stays different).
 - Assert the **neighbouring** quantity too: `1000.0 [kg]` next to the shadowing `m` must still print
@@ -2434,7 +2500,7 @@ unsupported node type: *ast.ErrorNode`. Follow the sweep with `%eval 1 + 1` →
 a violated assertion renders the bracket form as source (`Assertion evaluated to false:
 1.0 [m] > 500.0 [m]`; a missing `*ast.IndexExpr` case shows `index > index`), and a result table
 formats the magnitude like a bare Real (`%action test::propagate` +`%continue` on
-`internal/core/runtime/testdata/conformance/action_body_quantity_descent.sysml` → `t = 17.20 [s]`,
+`internal/exec/runtime/testdata/conformance/action_body_quantity_descent.sysml` → `t = 17.20 [s]`,
 `h = -0.42 [m]`, `v = -42.86 [m/s]`; raw floats such as `17.19999999999997 [s]` are the pre-fix
 signature). Note the action in that file is named **`propagate`**, not `descent`.
 
@@ -2465,7 +2531,7 @@ its output rather than in an exit code — so assert on the exact rendered text:
 ## Multi-file projects: `%load <path>...` and positional dirs/globs (PR #146)
 
 `sysml <dir|glob|file>...` and `%load <path>...` expand to model files via
-`internal/core/project.Expand`, and every file is accepted before one analysis pass
+`internal/workspace/project.Expand`, and every file is accepted before one analysis pass
 (`Session.SubmitAll`), so load order does not affect name resolution. Shapes to expect:
 
 - More than one file prints a `loaded N files:` header listing each path (a single file prints no
@@ -2514,7 +2580,7 @@ Since #161 every run that is not a prompt splits its output: **results on stdout
 conversion bytes, verdict lines, `✓` echoes of what a load declared) and **findings on stderr**
 (diagnostics, warnings, the `sysml: … did not analyse cleanly` note, `wrote <file> (ttl, N bytes)`),
 with `0` = done, `1` = the model answered a check false, `2` = nothing could be decided. Any change in
-`cmd/sysml/{main.go,status.go,check.go}` or `internal/repl/{load.go,render.go}` can break it silently,
+`cmd/sysml/{main.go,status.go,check.go}` or `internal/frontend/repl/{load.go,render.go}` can break it silently,
 so test it as a **table over every mode**, always with `>out 2>err </dev/null` and `echo $?`:
 capturing `2>&1` hides exactly the defect. A ready-made driver pattern (one `PASS`/`FAIL` line per
 row, asserting status + required stdout needles + stderr needles that must be **absent** from stdout
@@ -2581,7 +2647,7 @@ DISPLAY=:0 wmctrl -r :ACTIVE: -b add,maximized_vert,maximized_horz
 
 Enlarge the font before recording with the `ctrl+plus` key combo a few times (`ctrl+shift+plus`
 types literal `+` characters into the shell instead of zooming). Konsole starts a shell whose PATH
-lacks the Python that `pip install -e clients/python/` installed into, so `import opensysml` fails there while
+lacks the Python that `pip install -e client/python/` installed into, so `import opensysml` fails there while
 it works from a tool shell; run `source ~/opensysml-venv/bin/activate` (or
 whichever interpreter `python -c 'import sys; print(sys.executable)'` reports in the tool shell)
 as a setup step before recording. `~/opensysml-venv` may not exist at all, and the default `python3`
@@ -2589,7 +2655,7 @@ on PATH can be another project's venv (e.g. `~/repos/fprime/fprime-venv`) whose
 `google.protobuf` makes `import opensysml` die with
 `cannot import name 'runtime_version' from 'google.protobuf'`. The reliable fallback is a throwaway
 venv off the system interpreter:
-`/usr/bin/python3 -m venv /tmp/pv && /tmp/pv/bin/pip install -e clients/python/` (~1 min), then
+`/usr/bin/python3 -m venv /tmp/pv && /tmp/pv/bin/pip install -e client/python/` (~1 min), then
 `source /tmp/pv/bin/activate` in Konsole. Also re-copy the freshly built service
 (`make build-grpc && cp bin/sysml-grpc ~/.opensysml/bin/`) or the auto-start path serves a stale
 revision. Discover expected values with the
@@ -2699,7 +2765,7 @@ on `main`; the "old" shapes double as A/B canaries against the parent commit.
   assertion on them.
 - **Variant-interface routing is only partly reachable from the REPL.** `%features` proves the
   *materialization* side: in
-  `internal/core/runtime/testdata/conformance/ballandchain_variant_configuration.sysml` the selected
+  `internal/exec/runtime/testdata/conformance/ballandchain_variant_configuration.sysml` the selected
   `engagementRingToBand = engagementRingToBandConnected (Instance ID: 24)` holds
   `engagementRing.ringPort` / `band.ringPort`, not the disconnected variant's ports. But the
   send/accept *routing* side cannot be driven: an action usage does not inherit its `action def`'s
@@ -2712,7 +2778,7 @@ on `main`; the "old" shapes double as A/B canaries against the parent commit.
   completes with `atGood = 100`, while the same model with an unrealized `variation interface`
   returns the typed accept-deadlock instead of delivering to the wrong port or hanging. Treat
   selected-variant routing as unit-test-only coverage.
-- **Cheap end-to-end fixture for the whole family:** `internal/core/runtime/testdata/conformance/`
+- **Cheap end-to-end fixture for the whole family:** `internal/exec/runtime/testdata/conformance/`
   `connector_end_identity.sysml`, `ballandchain_interface_connected.sysml` and
   `…_disconnected.sysml`; each `.expected.json` has an `identical` / `distinct` array that names
   exactly which end must be which port — the cheapest source of the IDs `%features` should tie together.
@@ -2805,7 +2871,7 @@ actually descend into:
   line (`eval operator + -> 1`) instead of on `%eval`.
 - **`%state` works on a `state def` as well as a state usage** (`%state P::S`); the executor starts
   in whatever state the `entry; then <s>;` chain reaches.
-- **Conformance fixtures under `internal/core/runtime/testdata/conformance/` often write bare
+- **Conformance fixtures under `internal/exec/runtime/testdata/conformance/` often write bare
   `Integer`**, which the conformance harness resolves but the REPL does not: loading them prints
   `error: unresolved reference: Integer`. That is REPL-only noise, not a regression — when a test
   asserts "loads with no diagnostics", copy the fixture through
@@ -2841,10 +2907,10 @@ Discovered while testing inline `entry action { … }` bodies and calc `out` ass
   written that way silently tests *only* the entry behavior and never the exit behavior. To exercise
   exit behaviors and ordering, use **completion transitions**: `entry; then start; … then start work;
   then work done;` (the `state_anonymous_action_body.sysml` conformance fixture is the model to copy).
-- **An inline body is one action per do round.** After a do body has run to its end the state has
-  no more pending work, so further `%advance` calls do not re-run it; a counter incremented by a
-  `do action { … }` reaches 1 and stays there unless a transition re-enters the state. The
-  one-action-per-statement `do { … }` form is what interleaves and re-runs per statement.
+- **An inline body runs once, one statement per do round.** After a do body has run to its end the
+  state has no more pending work, so further `%advance` calls do not re-run it; a counter incremented
+  by a `do action { … }` reaches 1 and stays there unless a transition re-enters the state. A braced
+  `do { … }` is the same anonymous action and behaves the same way.
 - **Notation gotchas that cost fixture rewrites:**
   - a self-send must name the machine, statement style: `entry action { send Ping to Driver1; }`
     with `item def Ping;` — `send Sig() to self` with an `attribute def` parses but never delivers.
@@ -2868,7 +2934,7 @@ Discovered while testing inline `entry action { … }` bodies and calc `out` ass
 
 ## "Did you mean" suggestions on unresolved references (PR #167)
 
-The suggestion is produced in the resolver (`internal/core/suggest` + `internal/core/resolve/suggest.go`),
+The suggestion is produced in the resolver (`internal/semantic/suggest` + `internal/semantic/resolve/suggest.go`),
 so it belongs to the diagnostic and every surface renders the same string. Verify all three surfaces —
 they used to disagree, and the REPL used to post-annotate its own copy:
 
@@ -3011,7 +3077,7 @@ object whose type conforms to the type declaring the condition, else declared de
 
 ## A lone `-` as standard input (PR #179)
 
-`internal/core/project.ReadFile` reads `os.Stdin` once (memoized with `sync.Once`) whenever a path
+`internal/workspace/project.ReadFile` reads `os.Stdin` once (memoized with `sync.Once`) whenever a path
 is exactly `-`, names it `<stdin>` in diagnostics, and refuses a terminal with
 `standard input is a terminal; redirect it or name a file`. That makes stdin usable in every
 path-taking mode: `-validate`, `-e`, `-calc`, `-constraint`, `-action`, `-state`, `-convert`, and a
@@ -3069,7 +3135,7 @@ parent binary, so verify against the contrast binary before reporting it.
 
 ## Static dimension (unit-commensurability) warnings (PR #184)
 
-`checkDimensions` (`internal/core/passes/typecheck_dimension.go`) emits a **type-tier warning** for
+`checkDimensions` (`internal/check/passes/typecheck_dimension.go`) emits a **type-tier warning** for
 `+ - < > <= >= == !=` when both operand dimensions are statically known and incommensurable, e.g.
 `operator '<' combines incommensurable quantities: ISQBase::MassValue (dimension M) and m (dimension L)`.
 It is a warning, so `-validate` still exits **0**; evaluating the same constraint still fails with
@@ -3107,7 +3173,7 @@ is reached through a stdlib **alias** currently does **not** warn even though th
 Cheap false-positive sweep, worth running for any diagnostic-adding pass:
 
 ```bash
-for f in $(find examples testdata -name '*.sysml'); do ./bin/sysml -validate "$f" 2>&1 \
+for f in $(find examples tests/testdata -name '*.sysml'); do ./bin/sysml -validate "$f" 2>&1 \
   | grep 'incommensurable quantities'; done   # expect no output (403 files, ~90 s)
 ```
 
@@ -3131,7 +3197,7 @@ number rather than quoting the table.
   71 / 44 / 44 (65 / 38 / 38 counting the 110 `.sysml` files alone — state the denominator, since
   the published limitation counts both languages).
 - **Counted rows go stale fast, and the Python row depends on the environment.** With no service
-  listening `pytest clients/python/tests/ -q` was 369 passed / 26 skipped at 0.0.8; with a service already
+  listening `pytest client/python/tests/ -q` was 369 passed / 26 skipped at 0.0.8; with a service already
   listening the integration tests run instead of skipping (since PR #204 nothing fails either way,
   and CI now starts a service). Say which way a row was measured. `go test -race -count=1 ./...`
   was 3,682 pass / 5 skip / 3,687 total at 0.0.8, and 4,440 / 7 / 4,447 at 0.0.9 — recount rather
@@ -3143,7 +3209,7 @@ number rather than quoting the table.
   spec-compliance rather than adding a fifth copy.
 - **Error-class claims: check the export path.** A class can exist in `opensysml.errors` and be absent
   from the package surface — `hasattr(opensysml, name)` is the check, and
-  `TestPackageSurface` in `clients/python/tests/test_errors.py` now locks every exception in
+  `TestPackageSurface` in `client/python/tests/test_errors.py` now locks every exception in
   `errors.__all__` onto `opensysml`.
 - **LSP capability claims** are cheap to check with a framed JSON-RPC driver: assert
   `semanticTokensProvider` has `full: true`, `range: true` and no `delta` key anywhere, that
@@ -3226,7 +3292,7 @@ When a change moves a declaration from one `symbols.SymbolKind` to another (modi
 kinds, KerML classifier classification, `classifyUsage` edits), `-validate` proves nothing: a model
 can be clean on both revisions while every kind is wrong. The probe is the REPL's
 `%search <prefix>`, which prints `<fqn>  <kind>` from `sym.Kind.String()`
-(`internal/repl/discover.go`, names in `internal/core/symbols/symbol.go`):
+(`internal/frontend/repl/discover.go`, names in `internal/semantic/symbols/symbol.go`):
 
 ```bash
 printf '%%load /tmp/m.sysml\n%%search Pkg::\n%%quit\n' | timeout 30 ./bin/sysml -quiet
@@ -3261,7 +3327,7 @@ Pre-existing traps worth not re-reporting as regressions:
   old kinds and `go test ./...` passes locally while CI fails. Gate with
   `export XDG_CACHE_HOME=$(mktemp -d) && go test -count=1 ./...`; this is how PR #210's
   `function` → `kermlType` regression (every `IntegerFunctions` operator stopped being a calc,
-  `internal/repl/discover_test.go` pinned `ScalarValues::Integer attributeDef`) stayed hidden.
+  `internal/frontend/repl/discover_test.go` pinned `ScalarValues::Integer attributeDef`) stayed hidden.
 
 ## Multiplicity of a feature's default value (PR #199, Track A / A2)
 
@@ -3300,11 +3366,11 @@ Consequences worth checking on every change here:
   `-validate` as an error (exit 2) — this is the cheapest static probe of that path, and it is
   clean on any build predating the fix.
 
-Fixtures live in `internal/core/runtime/testdata/conformance/multiplicity_default_*.sysml`
+Fixtures live in `internal/exec/runtime/testdata/conformance/multiplicity_default_*.sysml`
 (merged / composite / nonconforming / redefinition). Drive them over a pipe to discover values:
 
 ```bash
-printf '%%load internal/core/runtime/testdata/conformance/multiplicity_default_merged.sysml\n%%instantiate test::ranges\n%%features test::ranges\n%%quit\n' | timeout 60 ./bin/sysml
+printf '%%load internal/exec/runtime/testdata/conformance/multiplicity_default_merged.sysml\n%%instantiate test::ranges\n%%features test::ranges\n%%quit\n' | timeout 60 ./bin/sysml
 ```
 
 Expected: `exact = [1.00, 2.00, 3.00]`, `star = [1.00, 2.00]`, `empty = []`, `plus = [5.00]`,
@@ -3364,9 +3430,9 @@ Discriminators that separate a working string runtime from a broken one:
 
 ## Runtime budgets, and `calc` recursion in particular (PR #198)
 
-Every budget in `internal/core/runtime/budget.go` is reachable from the CLI as an env var and is
+Every budget in `internal/exec/runtime/budget.go` is reachable from the CLI as an env var and is
 listed by `%budget` in the REPL — that meta-command is the cheapest proof a new bound was wired
-into `internal/repl/meta.go`. `OPENSYSML_MAX_CALC_DEPTH` (default 10000) bounds nested `calc`
+into `internal/frontend/repl/meta.go`. `OPENSYSML_MAX_CALC_DEPTH` (default 10000) bounds nested `calc`
 invocations, i.e. recursion depth, and is the only budget with a **ceiling** (25000).
 
 Four distinct surfaces to assert for any budget change, since they fail independently:
@@ -3453,7 +3519,7 @@ With `OPENSYSML_REQUIRE_SERVICE=1` and no service, collection must **error** (ex
 "none answers on localhost:50051"), never skip. A whole run must leave an operator-started service
 on 50051 with the same pid, and must leave no `sysml-grpc` of its own behind.
 
-## Orthogonal regions and cross-region transitions (`internal/core/runtime/state_region_transition.go`)
+## Orthogonal regions and cross-region transitions (`internal/exec/runtime/state_region_transition.go`)
 
 A transition whose source and target sit in different orthogonal regions of the same composite
 state must exit only its source side, not the enclosing composite. The REPL surfaces needed to
@@ -3561,7 +3627,7 @@ All reproduce on several commits (not caused by any one PR), but they silently m
   still 0 — a state left running in a region reacts and flips it.
 
 Practical notes for a recorded pass: shipped conformance fixtures under
-`internal/core/runtime/testdata/conformance/` emit harmless
+`internal/exec/runtime/testdata/conformance/` emit harmless
 `unresolved reference: Integer — did you mean ScalarValues::Integer?` diagnostics (they omit the
 `ScalarValues::*` import); the runtime still executes and the `.expected.json` next to each fixture
 is the cheapest source of expected counter values. `/tmp` is wiped between sessions, so adversarial
@@ -3572,11 +3638,11 @@ name written in a previous run's plan: a rebuilt model can end up with a differe
 bug mid-recording. Typing `clear` at the `sysml>` prompt is parsed as SysML and errors — `%quit`
 first, then clear at the shell (this also rules out `clear; %load …` as a one-liner).
 
-`clients/python/scripts/pin_release_checksums.py --check` hits the GitHub releases API for every pinned
+`client/python/scripts/pin_release_checksums.py --check` hits the GitHub releases API for every pinned
 asset and dies with `HTTP Error 403: rate limit exceeded` once the unauthenticated budget is spent;
 it reads `$GITHUB_TOKEN`. Set it without putting the token on camera:
 `read -rs GITHUB_TOKEN; export GITHUB_TOKEN`. Careful with `--version <tag> --write`: it edits
-`clients/python/opensysml/binary.py`, so `git checkout clients/python/opensysml/binary.py` afterwards. For the
+`client/python/opensysml/binary.py`, so `git checkout client/python/opensysml/binary.py` afterwards. For the
 "release publishes no assets" refusal use an old tag (`v0.0.4`) — v0.0.5..v0.0.8 all publish
 binaries now. The unpinned-download refusal is testable offline-ish with
 `HOME=/tmp/fakehome $PY -c "...ensure_binary(version='v9.9.9')"`, which keeps the real
@@ -3633,7 +3699,7 @@ Only the paths that decode with the model's `symbols.Index` work. `EvaluateCalc`
 typed errors worth asserting verbatim (`calc argument could not be read: unknown base unit:
 SI::nope` / `unit scale is not a usable ratio: 1/0` / `quantity in "SI::m" carries no magnitude`,
 all with `FAILURE_REASON_EVALUATION`, and the service stays alive). `ExecuteAction` (in
-`internal/grpc/service.go`) decodes its `inputs` map the same index-aware way, so a quantity input
+`internal/frontend/grpc/service.go`) decodes its `inputs` map the same index-aware way, so a quantity input
 binds (`ExecuteActionRequest(inputs={"mass": Value(quantity=q)})` on an action whose `in mass :
 ISQ::MassValue` body does `assign heavier := mass + 1.0 [SI::kg]` returns `6 [SI::kg]`), and a
 malformed one comes back as `ExecuteActionResponse.Error` = `input "<name>" could not be read:
@@ -3653,7 +3719,7 @@ type get it: an untyped derived attribute (`attribute derivedSpeed = 10.0 [SI::m
 has no type facts and still generates `-> object` / `_t.as_object`, even though the runtime value is
 a `Quantity`. Don't read that as a bug in the quantity typing.
 
-To make mypy actually enforce it, **set `MYPYPATH` to the repo's `clients/python/` directory** — without it
+To make mypy actually enforce it, **set `MYPYPATH` to the repo's `client/python/` directory** — without it
 mypy cannot resolve the editable-installed `opensysml`, silently treats `_t.Quantity` as `Any` and
 reports *no* errors on obvious misuse (a false pass that looks like a passing test):
 
@@ -3717,7 +3783,7 @@ False-positive traps to always include as *legal* rows, since each exercises a d
 
 - A transition into a **sibling orthogonal region** (`transition first lidle then rtarget;` across
   `region left` / `region right`) — legal per UML §14.2.3.9.
-  `internal/core/runtime/testdata/conformance/state_transition_sibling_region.sysml` is the shipped
+  `internal/exec/runtime/testdata/conformance/state_transition_sibling_region.sysml` is the shipped
   one; run it with `%state TransitionSiblingRegion` + `%advance 1` → `Current state: lidle | rtarget`
   and `crossed = 1`.
 - `entry point into;` / `exit point outOf;` as endpoints (`state_entry_exit_points.sysml`).
@@ -3732,7 +3798,7 @@ Cheap whole-repo false-positive sweep (~40 s, 83 models at 11d0ed72, expect `dif
 
 ```bash
 for f in $(grep -rl 'state def\|state .*{' --include=*.sysml examples \
-             internal/core/runtime/testdata/conformance testdata | sort); do
+             internal/exec/runtime/testdata/conformance testdata | sort); do
   a=$(./bin/sysml -validate "$f" 2>&1; echo $?); b=$(/tmp/old-sysml -validate "$f" 2>&1; echo $?)
   [ "$a" != "$b" ] && echo "DIFFERS: $f"
 done
@@ -3855,7 +3921,7 @@ Change triggers (`accept when`) are reachable through state `%step`, which calls
 `PollChangeEvents`. A clean end-to-end example is:
 
 ```text
-%load internal/repl/testdata/change_condition_object.sysml
+%load internal/frontend/repl/testdata/change_condition_object.sysml
 %instantiate Watch::Sensor
 %state Watch::Sensor
 %step
@@ -3978,7 +4044,7 @@ All four arrive as `error: execution failed: <typed message>` on `%continue`. Mi
 Also worth asserting: **the REPL survives each of these**. Follow the error with `%eval 1 + 1` and
 check `= 2`; an executor that leaves the session wedged is a separate defect from the wording.
 
-Fixture noise to expect, not report: `internal/core/runtime/testdata/conformance/
+Fixture noise to expect, not report: `internal/exec/runtime/testdata/conformance/
 action_fork_branches_share_features.sysml` omits `import ScalarValues::*;`, so `%load` prints two
 `unresolved reference: Integer — did you mean ScalarValues::Integer?` errors before running to
 `x = 1, y = 2` correctly. It is pre-existing (identical on the parent binary); the sibling
@@ -3990,7 +4056,7 @@ quiet load is looking at fixture hygiene, not a regression.
 The wave-1/0.1.0 surfaces below were verified end to end at `870da1fd`. Each entry is the
 assertion that actually distinguishes working from broken.
 
-### The single experimental notice (`internal/core/export/experimental.go`)
+### The single experimental notice (`internal/translate/export/experimental.go`)
 
 `export.ExperimentalNotice` is the one wording; `export.IsExperimental(from,to)` is true iff either
 side is Turtle (notation→notation is never experimental). **Read the constant from source at the
@@ -4085,7 +4151,7 @@ def …`) rather than an ambiguity error.
 
 - A guard on a succession leaving an *ordinary* action node is evaluated: fixture with `level = 4`
   and branches `if level > 10` / `else` must end with the false branch's counter at 0.
-- Fork fixtures under `internal/core/runtime/testdata/conformance/` (e.g.
+- Fork fixtures under `internal/exec/runtime/testdata/conformance/` (e.g.
   `action_succession_guard_fork_branch_pruned.sysml`) run fine in the REPL but **emit unresolved
   `Integer` diagnostics** because they rely on the test harness's implicit `ScalarValues` import.
   Copy the fixture and add `import ScalarValues::*;` if you want a clean transcript.
@@ -4186,7 +4252,7 @@ Two cases need process work rather than a Python call:
 
 ### Traps that cost time when re-testing the edit surface
 
-- **`clients/python/tests/test_edit.py`'s `real_service` fixture prefers `<repo>/bin/sysml-grpc` over
+- **`client/python/tests/test_edit.py`'s `real_service` fixture prefers `<repo>/bin/sysml-grpc` over
   `~/.opensysml/bin/sysml-grpc`** (`GRPC_BINARIES`, test_edit.py:61). A stale `bin/sysml-grpc` left
   from an earlier snapshot therefore fails all 13 `TestEditRoundTripAgainstRealService` cases with
   `MissingCapabilityError('apply_edits')` / `assert has('apply_edits') == False`, which reads like a
@@ -4265,7 +4331,7 @@ below was observed at 41dc35cb with `/usr/bin/z3` 4.8.12; `bin/sysml` needs no e
   `attribute :>> best = <expr>;` inside the objective body, and feasibility from the case's
   `require`/`assume` conditions plus the objective's own. `private import TradeStudies::*;` is
   needed. A ready fixture with a dozen discriminating cases is
-  `internal/core/solve/testdata/objectives.sysml` — start there rather than hand-writing one.
+  `internal/exec/solve/testdata/objectives.sysml` — start there rather than hand-writing one.
 - **The three headers are the fastest read:** `✓ … is optimized`, `! … has no optimum: an objective
   improves without limit`, `! … is satisfiable, but its optimum was not established`, and
   `✗ … has no values satisfying its conditions` for unsat. A number must never appear on the
@@ -4290,7 +4356,7 @@ below was observed at 41dc35cb with `/usr/bin/z3` 4.8.12; `bin/sysml` needs no e
 - **Bad input:** `%optimize` alone → `usage: %optimize <name>`; an unknown name →
   `error: unresolved reference: X`; a package/part def/constraint def →
   `error: not an analysis case: X is a <kind>, not an analysis case definition or usage`
-  (the article follows the kind's first letter, `articleFor` in `internal/core/runtime/describe.go`).
+  (the article follows the kind's first letter, `articleFor` in `internal/exec/runtime/describe.go`).
 - **Read-only check that actually discriminates:** `%action <A>`, `%instances`
   (`(no instances created)`), `%optimize <case>`, then `%step` twice to `State: Completed` with the
   right `Results:` — and `%instances` still `(no instances created)`. Run `%optimize` twice in a row
@@ -4300,7 +4366,7 @@ below was observed at 41dc35cb with `/usr/bin/z3` 4.8.12; `bin/sysml` needs no e
   file`. cvc5 (in `$HOME/.local/cvc5/bin`) is the other interesting backend: optimization is a z3
   extension, so it must be a typed error rather than a plain check-sat presented as an optimum.
 - Writing an `action` fixture to host the read-only test: successions are
-  `first s1 then s2;` (see `internal/core/runtime/testdata/conformance/action_succession_guard_holds.sysml`).
+  `first s1 then s2;` (see `internal/exec/runtime/testdata/conformance/action_succession_guard_holds.sysml`).
   `then first second;` / `first then second;` do **not** parse or lower — budget a minute for this
   rather than inventing syntax.
 
@@ -4313,7 +4379,7 @@ below was observed at 41dc35cb with `/usr/bin/z3` 4.8.12; `bin/sysml` needs no e
   shared instance ID between `cost` and `template` — the **instance IDs in `%features` are the
   cheapest tell** that a body materialized an object of its own rather than aliasing the source.
   Ready-made fixtures with in-model `assert constraint`s live at
-  `internal/core/runtime/testdata/conformance/attribute_body_over_inherited_value*.sysml`; loading
+  `internal/exec/runtime/testdata/conformance/attribute_body_over_inherited_value*.sysml`; loading
   one and reading `<constraint: satisfied>` / `<constraint: violated>` in `%features` is a stronger
   frame than eyeballing numbers. Note `%satisfy` answers `no satisfaction assertion in the session`
   for such a fixture (those are `assert constraint`, not `assert satisfy`) — not a failure.
@@ -4344,12 +4410,12 @@ below was observed at 41dc35cb with `/usr/bin/z3` 4.8.12; `bin/sysml` needs no e
 ## `%explain <name>` — unsat cores (PR #291)
 
 `%explain` asks the solver which conditions of an unsatisfiable constraint/requirement/satisfy
-element conflict. It shares `solveQueries` with `%check` (`internal/repl/check.go:149`), so the two
+element conflict. It shares `solveQueries` with `%check` (`internal/frontend/repl/check.go:149`), so the two
 must always reach the same verdict; the split is that `%check` prints a satisfying assignment and
-`%explain` never does. Rendering lives in `internal/repl/explain.go`, core reduction in
-`internal/core/solve/core.go`.
+`%explain` never does. Rendering lives in `internal/frontend/repl/explain.go`, core reduction in
+`internal/exec/solve/core.go`.
 
-`internal/repl/testdata/explain_conflicts.sysml` is the fixture that covers every core-row shape at
+`internal/frontend/repl/testdata/explain_conflicts.sysml` is the fixture that covers every core-row shape at
 once, so prefer it over hand-rolled models. Values observed at 04d0c4b with z3 4.8.12, loading that
 file **alone** (locations are buffer-relative — see below):
 
@@ -4378,9 +4444,9 @@ Things that look like bugs but are not, and traps:
   location assertion must therefore fix the load order. Always `%load` the fixture **alone**, or
   pass it as a CLI argument (`./bin/sysml <fixture>`), when asserting line/col.
 - **A 1-member core has its own minimality wording**: `The condition below is the whole conflict:
-  nothing else is needed for it.` (`internal/repl/explain.go` `minimality`), not the multi-condition
+  nothing else is needed for it.` (`internal/frontend/repl/explain.go` `minimality`), not the multi-condition
   `dropping any one leaves the rest satisfiable`. `rig::always` is the case that exercises it.
-- **`String` IS in the translatable subset** (`SortString`, `internal/core/solve/reference.go:214`),
+- **`String` IS in the translatable subset** (`SortString`, `internal/exec/solve/reference.go:214`),
   so `constraint { s == "x" }` answers `satisfiable` and is useless as an "outside the subset" case.
   Untranslatable cases that do work: a **calc invocation** in a condition
   (`assert constraint { Twice(i) > 4 }` → `invocation not translatable for solving: it is outside
@@ -4392,7 +4458,7 @@ Things that look like bugs but are not, and traps:
 - **Header durations include core-reduction time** (since 04d0c4b), so an unsat `%explain` reads
   ~3x the matching `%check` (24–30ms vs 7ms). Never assert exact milliseconds.
 - **`%explain` is read-only.** An `%action Debug::tally` session (fixture
-  `internal/repl/testdata/action_debug.sysml`) must survive it: `%step` after `%explain` still
+  `internal/frontend/repl/testdata/action_debug.sysml`) must survive it: `%step` after `%explain` still
   prints `✓ Step complete` and the run ends `total = 5`. A regression here shows up as
   `error: no active action session`.
 
@@ -4403,11 +4469,18 @@ must be set on the process, not toggled mid-session.
 
 ## Rendering a view: `%render` and `sysml -render` (PR #288 class)
 
-A view's `render` member is consumed by `internal/core/view`. Kinds: **tree** (default when the view
+A view's `render` member is consumed by `internal/ir/view`. Kinds: **tree** (default when the view
 states no rendering), **interconnection**, **state**, **action**, **table**
 (`render asElementTable;` or a `StandardViewDefinitions::GridView`-typed view).
 
-Forms and defaults (`internal/core/view/form.go`):
+Forms and defaults (`internal/ir/view/form.go`):
+
+When verifying walkthrough output, test the literal command in a real terminal:
+`-render` is destination-dependent (text at a TTY, machine form through a pipe).
+A piped discovery run can therefore match a Mermaid transcript while the user's
+terminal command does not. Use explicit `-render-form mermaid` when the walkthrough
+promises Mermaid independent of destination; compare the shown diagram block and
+its effect labels, not only whether a renderer exits cleanly.
 
 | kind | text | machine form (`Kind.MachineForm()`) |
 | --- | --- | --- |
@@ -4430,7 +4503,7 @@ Forms and defaults (`internal/core/view/form.go`):
   elements declared inside them use local names; a nested view is a row followed by its own exposures.
 - **Empty state rendering must be `state "the view exposes nothing; the rendering is empty" as empty`,
   never a bare `note "…"`** — a bare top-level `note` is invalid Mermaid. Fixture:
-  `internal/core/view/testdata/errors.sysml` → `ErrorViews::emptyStateView`. Proof pattern for this
+  `internal/ir/view/testdata/errors.sysml` → `ErrorViews::emptyStateView`. Proof pattern for this
   class of fix: build the pre-fix commit with `git worktree add`, render the same view, and show
   `mmdc` failing (`Parse error on line 2`, exit 1) on the old artifact and passing on the new one.
 - Mermaid grammar check (independent of Go tests):
@@ -4442,14 +4515,14 @@ Forms and defaults (`internal/core/view/form.go`):
   `file:///tmp/x.svg`) for visual proof.
 - **`%render` must be read-only.** Proof sequence: `%action Gear::Spin` → `%step` → `%tokens` →
   `%render …` → `%tokens` (identical) → `%step` → `%continue` (completes) → `%instances`
-  (`(no instances created)`). `internal/core/view/testdata/action.sysml` is **unusable** for this —
+  (`(no instances created)`). `internal/ir/view/testdata/action.sysml` is **unusable** for this —
   its `action provide : Provide` has no initial node, so `%continue` fails there on `main` too; write
   your own steppable action.
 - Byte-identity regression harness: build the previous commit via `git worktree add`, then loop the
   four graph kinds × `text`/`mermaid` through both binaries and `diff` — expect all `IDENTICAL`.
 - Known nit (unfixed): completion of a *partially typed quoted* name offers nothing
   (`%render Quoted::'My` + Tab) because `nameWord` keeps the leading `'` while the index holds
-  unquoted FQNs (`internal/repl/complete.go`); with a trailing space inside the open quote it dumps
+  unquoted FQNs (`internal/frontend/repl/complete.go`); with a trailing space inside the open quote it dumps
   every library name. Forms are correctly withheld until the quote closes.
 - `filters.sysml` → `FilteredViews::safetyView` duplicates `Systems::Airbag` and
   `Systems::Braking::Brake` through the shipping CLI/REPL while the goldens list them once. It
@@ -4461,7 +4534,7 @@ Forms and defaults (`internal/core/view/form.go`):
 - After the OpenSysML rename the module is `github.com/Open-MBEE/OpenSysML` and env vars are
   `OPENSYSML_*`, but the **checkout directory may still be named `Systemica`**. When grepping output
   for stale branding, exclude build paths, and expect the deliberate legacy RDF namespace
-  `urn:systemica:sysml:` (`internal/core/rdf/vocab.go`) to remain — it exists so a pre-rename graph is
+  `urn:systemica:sysml:` (`internal/translate/rdf/vocab.go`) to remain — it exists so a pre-rename graph is
   refused rather than misread.
 
 ## SMT logic selection, the capability model, and `%optimize`
@@ -4483,7 +4556,7 @@ chmod +x /tmp/fakesmt/z3-tee
 solver invocation. **Capability probes run through the same wrapper**, so the log also holds the
 probes' own scripts; select the real query's with `grep -l <ModelName> /tmp/smtlog/*.smt2` (each
 script starts `; OpenSysML SMT-LIB2 translation of constraint <Name>`). Expected logics from
-`internal/core/solve/testdata/logic_selection.sysml`: `CratesPerPallet` (`crates / 12`) → `QF_LIA`
+`internal/exec/solve/testdata/logic_selection.sysml`: `CratesPerPallet` (`crates / 12`) → `QF_LIA`
 (integer division does **not** widen the logic); `CratesPerRun` (variable divisor) → `QF_NIA`;
 `MassAndCrates` (Int + Real) → `AUFLIRA`; a datatype/variant model (`ring_variants.sysml`) → `ALL`,
 preceded by `; no SMT-LIB logic covers algebraic datatypes (declare-datatypes), …`.
@@ -4492,7 +4565,7 @@ preceded by `; no SMT-LIB logic covers algebraic datatypes (declare-datatypes),
 `%optimize <name>` takes an **analysis case** (`runtime.RequireAnalysis`), not a constraint — and
 conversely an analysis def is not a `%check`/`%explain` target (`%check test::SomeAnalysis` answers
 `error: no satisfaction assertion in …`). Fixtures live in
-`internal/core/solve/testdata/objectives.sysml`, e.g. `test::CrewSizing` → `maximize largestCrew =
+`internal/exec/solve/testdata/objectives.sysml`, e.g. `test::CrewSizing` → `maximize largestCrew =
 `crew`: 7`, `test::CostThenMargin` → `minimize cheapest = `cost`: 3` then `maximize widestMargin =
 `margin`: 6` (lexicographic, in declaration order), `test::UnboundedLoad` → `! … has no optimum: an
 objective improves without limit` plus `the assignment below attains 1.0` — a bound or a feasible
@@ -4505,7 +4578,7 @@ through the capability model (`Solver.requireOptimization` preflights `CapOptimi
 with `(get-objectives)`, a solver extension: it rejected the script: Parse Error: …; install z3 or
 set OPENSYSML_SMT to it`. Probe results are cached per executable+args, so a second `%optimize` in
 the same session must print the identical refusal — a differing second error means the cache is not
-holding. `internal/repl/optimize_test.go` skips its solver cases through
+holding. `internal/frontend/repl/optimize_test.go` skips its solver cases through
 `requireOptimizingSolver`, so **`go test` alone proves nothing about `%optimize` on cvc5**; drive the
 REPL, or read the `TestPortability -v` report, which must show `refuse objective optimization` for
 cvc5 and `pass` for z3.
@@ -4536,7 +4609,7 @@ discovery precedes both the capability preflight and the "states no variation po
 
 Cross-solver agreement worth asserting (z3 4.8.12 vs cvc5 1.3.4): the verdict line, the witness
 values (`crates = 36`; `MassPerCrate` → `crates = 1`, `mass = 0.0`), the unsat-core rows of
-`internal/repl/testdata/explain_conflicts.sysml`, and the **count and set** — not the order — of
+`internal/frontend/repl/testdata/explain_conflicts.sysml`, and the **count and set** — not the order — of
 `%configure test::ringFamily::variantsAgree all` on `nested_variants.sysml` (3 selections; the
 solvers list them in different orders, which is not a defect). `%configure` names the **constraint**
 (`…::variantsAgree`), not the part.
@@ -4702,7 +4775,7 @@ Parser PRs that "unblock a form" are best proven with three surfaces, in this or
 while IFS= read -r -d '' f; do cp "$f" /tmp/sw.sysml
   diff <(./bin/sysml -validate /tmp/sw.sysml 2>&1; echo $?) \
        <(/tmp/old-sysml -validate /tmp/sw.sysml 2>&1; echo $?) >/dev/null || echo "DIFF: $f"
-done < <(find examples internal/core/runtime/testdata/conformance -name '*.sysml' -print0)
+done < <(find examples internal/exec/runtime/testdata/conformance -name '*.sysml' -print0)
 ```
 
 Copy each file to a fixed path first — corpus paths contain spaces, and comparing the *output plus
@@ -4897,7 +4970,7 @@ at the prompt; it parses as a model line and produces `expected a namespace memb
 A kindless parameter (`in x : Real`, `out mass : Real`) is a kindless/attribute usage, so
 `sysml -convert=turtle` emits `a sysml:AttributeUsage`. Hand-written fixtures are often *not*
 discriminating (both old and new binaries agree); the repo fixture
-`internal/core/export/testdata/convert/views_flows_parameters.sysml` is, because its
+`tests/export/testdata/convert/views_flows_parameters.sysml` is, because its
 `action def Measure { out mass : Real; }` prints `AttributeUsage` on the new binary and
 `PartUsage` on a parent-commit binary. Prefer an A/B against `/tmp/old-sysml` over asserting a
 single output.
@@ -4913,7 +4986,7 @@ wider/narrower, not automatically as an OpenSysML defect.
 
 ## The conformance harness loads no libraries; the REPL does (wave 7D)
 
-`internal/core/runtime/testdata/conformance/*.sysml` runs with **no standard library loaded**, so a
+`internal/exec/runtime/testdata/conformance/*.sysml` runs with **no standard library loaded**, so a
 fixture that passes there can still fail at the CLI, where the stdlib is always present. Two defects
 of that exact shape were only visible through `bin/sysml`:
 
@@ -4925,7 +4998,7 @@ of that exact shape were only visible through `bin/sysml`:
   `private import ScalarValues::*;` (or qualified `ScalarValues::Integer`) or the session fills with
   unresolved-`Integer` noise that hides the result you are checking.
 
-So: for any change under `internal/core/runtime`, re-run the shipped fixture through the REPL with
+So: for any change under `internal/exec/runtime`, re-run the shipped fixture through the REPL with
 libraries loaded, and add a library-loaded unit test (`buildRuntimeWithLibraries`) beside the
 library-free one. Prefer a name a library also declares (`receiver`, `source`, `target`) when
 choosing fixture names — those are the ones that break.
@@ -4976,7 +5049,7 @@ Pitfalls that cost time:
   The same text in a `.sysml` file can fail earlier with `only a definition may specialize; found a
   usage`, masking the behaviour under test.
 - A batch regression sweep is cheap and is the strongest "no false positives" evidence: run every
-  file in `examples/` and `testdata/{passes,resolve}` under both binaries and require byte-identical
+  file in `examples/` and `tests/testdata/{passes,resolve}` under both binaries and require byte-identical
   output plus matching exit status.
 - Cold vs warm run under a scratch `XDG_CACHE_HOME` catches resolution that depends on the on-disk
   symbol index; diagnostics must be byte-identical.
@@ -5005,10 +5078,10 @@ suggestion is normal because `%search` sees the reference-derived index entry.
 
 ### Which REPL surfaces are visibility-aware
 
-`%search` and readline name completion browse the **raw symbol index** (`internal/repl/discover.go`,
+`%search` and readline name completion browse the **raw symbol index** (`internal/frontend/repl/discover.go`,
 `complete.go`) and are *not* filtered by member visibility, so a `private` member is still listed
 even when resolution rejects every reference to it. The visibility-filtered surface is
-`model.Workspace.VisibleNames/VisibleNamesAt`, which today is reached only from `cmd/pilot-xpect`
+`model.Workspace.VisibleNames/VisibleNamesAt`, which today is reached only from `tools/referee/xpect`
 scope checks and not from any REPL meta-command — do not report a `%search` listing of a private
 name as a regression without A/B-ing it against the parent build first. `%view <name>` *is* useful
 for `expose`: it lists what a view exposes, and an `expose`/`import all` is expected to reach its
@@ -5016,7 +5089,7 @@ target's own private members.
 
 ## End-to-end testing `ApplyEdits` / `model.edit()` over gRPC (PR #509 and later edit work)
 
-Any change in `internal/core/edit` (index reuse, reparse/validate ordering, refusal kinds) is
+Any change in `internal/check/edit` (index reuse, reparse/validate ordering, refusal kinds) is
 testable entirely through the real service plus the Python client; the strongest evidence is
 **a batch of N dependent operations against the same operations sent one per request**.
 
@@ -5024,7 +5097,7 @@ testable entirely through the real service plus the Python client; the strongest
 export PATH=/usr/local/go/bin:$PATH
 make build-grpc && cp bin/sysml-grpc ~/.opensysml/bin/
 XDG_CACHE_HOME=$(mktemp -d) ./bin/sysml-grpc -port 50123 &     # -port, not -addr
-/home/ubuntu/pv/bin/pip install -e clients/python/                     # see the venv trap above
+/home/ubuntu/pv/bin/pip install -e client/python/                     # see the venv trap above
 ```
 
 - Drive it with `opensysml.connect(port=50123, auto_start=False)` and
@@ -5067,7 +5140,7 @@ None — the service, client and stdlib are all local.
 
 ## Proving "the stdlib is parsed on every load path" (record-format waves, e.g. formatVersion 25)
 
-When `internal/core/libs` changes what the on-disk cache persists (derived facts only:
+When `internal/workspace/libs` changes what the on-disk cache persists (derived facts only:
 `Supers`/`Unit`/`Dimension`/`Abstract`, installed onto already-parsed symbols), the load-bearing
 property is **cold == warm == no-cache**, observed from outside the Go tests. Three cache states,
 one scratch dir:
@@ -5089,12 +5162,12 @@ XDG_CACHE_HOME=/proc/self/nope ./bin/sysml ...   # no-cache fallback
   `-validate`, `-e '2.0 [SI::kg] + 3.0 [SI::kg]'`, `-e <lib-typed attr>`, `-constraint <c>`,
   `-calc 'Sum(2, 40)'`, `-instantiate <def> -json`, `-query`, `-convert sysml`, plus the
   pilot-reject negative fixture. Then repeat over
-  `internal/core/runtime/testdata/conformance/*.sysml` (360 files) for a corpus-level A/B.
+  `internal/exec/runtime/testdata/conformance/*.sysml` (360 files) for a corpus-level A/B.
 
 ### Library feature multiplicity: declared `0..1` vs assumed `1..1`
 Neither `-query` nor gRPC `GetSymbol` reaches standard-library symbols, so you cannot read a
 library feature's multiplicity directly. The observable surface is
-`internal/core/passes/multiplicity_conformance.go`: redefine a library feature with a wider or
+`internal/check/passes/multiplicity_conformance.go`: redefine a library feature with a wider or
 weaker bound and check the warning text.
 
 ```sysml
@@ -5110,7 +5183,7 @@ back to assumed `1..1`), so silence-vs-warning is the discriminator; `[0..1]` ag
 ### gRPC/Python control when library attributes are NOT withheld
 With no L3-3 projection, `GetSymbol` on a part returns own attributes **first, in declaration
 order**, then ~55 inherited from `Occurrences`/`Objects`/`Base` (e.g. `demo::Car` in
-`internal/grpc/testdata/conformance/symbol_attributes.sysml`: 6 own + 55 = 61). Assert the head
+`tests/grpc/testdata/conformance/symbol_attributes.sysml`: 6 own + 55 = 61). Assert the head
 order and that the client can read every row; don't assert a total.
 Two traps that reproduce on **base too** (do not attribute them to a record-format PR):
 - A `@Metadata` annotation written *inside* a part def collapses that symbol's gRPC attribute list
@@ -5154,9 +5227,9 @@ What to diff, in order of how much it catches:
 - **LSP edit cycles**, for cache staleness: drive `bin/sysml-lsp` over stdio with `initialize`,
   `didOpen`, then several full-text `didChange`s that cycle content v1→v2→v3→v1, printing every
   `publishDiagnostics` range. Diff the sequences from both binaries. (Documents are rebuilt per
-  version in `internal/core/model/document.go`, so a per-SourceFile memoized index is safe — but
+  version in `internal/workspace/model/document.go`, so a per-SourceFile memoized index is safe — but
   this is the test that would catch it if that ever changes.)
-- `go test -race` on `./internal/core/source/... ./internal/repl/... ./internal/core/runtime/... ./internal/lsp/...`,
+- `go test -race` on `./internal/syntax/source/... ./internal/frontend/repl/... ./internal/exec/runtime/... ./internal/frontend/lsp/...`,
   plus, for a memoized accessor, a throwaway package inside the module (`mkdir tmp_racecmd`, one
   `_test.go` firing 64 goroutines at `sf.Lines().PosAt(...)`, then `rm -rf` it) — an in-repo dir is
   required because `internal/...` is unimportable from outside the module.
@@ -5168,7 +5241,7 @@ some older baseline is a **separate known regression**, not a perf-PR failure.
 
 ## Testing generated typed views (Tier 2) over a live service
 
-The typed helpers in `clients/python/opensysml/typed.py` are only reachable through *generated* modules, so
+The typed helpers in `client/python/opensysml/typed.py` are only reachable through *generated* modules, so
 assert on generated code, never on hand-built protobuf messages:
 
 ```bash
@@ -5210,8 +5283,8 @@ package next to your scratch script, swap in the parent revision's file, and re-
 with `PYTHONPATH`:
 
 ```bash
-cp -r clients/python/opensysml /home/ubuntu/scratch/prefix/
-git show <fix-sha>^:clients/python/opensysml/typed.py > /home/ubuntu/scratch/prefix/opensysml/typed.py
+cp -r client/python/opensysml /home/ubuntu/scratch/prefix/
+git show <fix-sha>^:client/python/opensysml/typed.py > /home/ubuntu/scratch/prefix/opensysml/typed.py
 PYTHONPATH=/home/ubuntu/scratch/prefix /home/ubuntu/pv/bin/python run.py   # must fail where the fix bites
 ```
 
@@ -5246,7 +5319,7 @@ provisions `~/pv`, but it can be stale — check before trusting it, and reinsta
 
 ```bash
 ~/pv/bin/python -c "import opensysml, grpc, google.protobuf as p; print(p.__version__)" \
-  || ~/pv/bin/pip install -e clients/python/
+  || ~/pv/bin/pip install -e client/python/
 ```
 
 Client API names that are easy to guess wrong: `Connection(port=…, auto_start=False)`,
@@ -5362,7 +5435,7 @@ answers arrive first, so ids are mandatory. Closing stdin ends a healthy session
 
 ### The benchmark harness
 
-`~/pv/bin/python clients/python/scripts/bench_transports.py --iterations 30 --spawns 3 --json out.json`
+`~/pv/bin/python client/python/scripts/bench_transports.py --iterations 30 --spawns 3 --json out.json`
 runs in a couple of minutes and reproduces the published *shape*: large-model `Query` costs
 ~6-7 ms in protobuf on all transports and ~40-47 ms in JSON (≈6-7×, serialization CPU, not
 bytes), cold start is ~4 ms for stdio vs ~6-8 ms over TCP, and every small-payload cell has an
@@ -5373,7 +5446,7 @@ to diff against a document's table verbatim.
 ## Driving state-machine completion (`then done;`) and its surfaces
 
 Use `%send <SignalName> [to <object>]` followed by `%step` to drive signal transitions.
-The signal lists in `internal/core/runtime/testdata/conformance/*.expected.json` belong to the
+The signal lists in `internal/exec/runtime/testdata/conformance/*.expected.json` belong to the
 conformance harness and are not automatically injected by the REPL. Alternatively, write
 fixtures with **timed triggers** (`state a; accept after 5 then done;`)
 and step them with `%advance <t>`; each region can be given
@@ -5442,7 +5515,7 @@ against the contrast binary.
 
 ## Capability availability and the test-only withholding switch
 
-`sysml-grpc` reports 14 capability names from `internal/grpc/service.go` (`capabilities`), and
+`sysml-grpc` reports 14 capability names from `internal/frontend/grpc/service.go` (`capabilities`), and
 `OPENSYSML_TEST_WITHHOLD_CAPABILITIES=<comma list>` makes a hand-started service behave as a build
 that lacks them. The switch is validated at startup: an unknown name aborts with
 `level=ERROR msg="Invalid service configuration" error="unknown capability \"…\""` and exit 1 (grep
@@ -5461,7 +5534,7 @@ The three classes behave differently and each needs its own service:
   naive "is it refused?" test.
 - **Response-only** (`type_facts`, `symbol_attributes`, `feature_values`, `enum_values`,
   `unset_value`) — no RPC is refused; fields are dropped or downgraded in
-  `internal/grpc/capability_response.go`. Only assert these against a **default service snapshot of
+  `internal/frontend/grpc/capability_response.go`. Only assert these against a **default service snapshot of
   the same model**, otherwise "absent" proves nothing.
 
 Fixture shapes that actually exercise the value filters: an `enum def` plus
@@ -5497,7 +5570,7 @@ the "convert works" check and keep RDF out of capability fixtures.
 ## grpc-go made test-only; transport error-parity probes (PR #612)
 
 Production code no longer imports `google.golang.org/grpc`: service errors are
-`connect.NewError(connect.CodeX, ...)` (`internal/grpc/`), the legacy `-transport grpc`
+`connect.NewError(connect.CodeX, ...)` (`internal/frontend/grpc/`), the legacy `-transport grpc`
 server lives in `cmd/sysml-grpc/grpcserver.go` behind an interceptor translating
 `*connect.Error` → grpc statuses, and `scripts/check-grpc-imports.sh` gates imports in CI.
 When testing error-message parity across transports:
@@ -5547,12 +5620,12 @@ Pitfalls that produce *false* differences (each one cost real time):
 `./bin/sysml -validate -memstats <big model>` prints wall time, MiB allocated, allocation
 count — compare against the old binary on the same file.
 
-Useful corpora for a sweep: `examples`, `testdata`, `internal/repl/testdata`,
-`internal/core/runtime/testdata/conformance` (~750–900 files, a few minutes per pass).
+Useful corpora for a sweep: `examples`, `testdata`, `internal/frontend/repl/testdata`,
+`internal/exec/runtime/testdata/conformance` (~750–900 files, a few minutes per pass).
 
 ## Numeric display: which surfaces render a Real, and how to compare them
 
-Real rendering is centralised in `runtime.FormatReal` (`internal/core/runtime/value.go`).
+Real rendering is centralised in `runtime.FormatReal` (`internal/exec/runtime/value.go`).
 When a PR touches numeric display, every one of these surfaces must be checked, because
 each has its own call site and they have drifted apart before:
 
@@ -5633,7 +5706,7 @@ The committed walkthroughs to diff a demo run against are
 `examples/ACTION-EXECUTOR-DEMO.md` is only a pointer — `docs/guide/06-behavior.md`
 ("Token-flow patterns").
 
-## Parse-memoization / "reuse across invocations" refactors in `internal/repl`
+## Parse-memoization / "reuse across invocations" refactors in `internal/frontend/repl`
 
 When a PR caches what command text parsed to (argument lists for `%calc`, the name a run
 target is looked up by for `%calc`/`%action`/`%state`/`%instantiate`), repeated successful calls
@@ -5664,7 +5737,7 @@ The shape that actually distinguishes working from broken:
 
 ## The embedded stdlib snapshot: proving the fast path and the fallback are both live (PR #776)
 
-`libs.SharedBase()` decodes `internal/core/libs/stdlib.snapshot` instead of parsing the 97 bundled
+`libs.SharedBase()` decodes `internal/workspace/libs/stdlib.snapshot` instead of parsing the 97 bundled
 library files, and falls back to parsing when the snapshot's recorded digest, format version or
 stream structure does not match. `bin/sysml -memstats -e '2+3' model.sysml` is the whole
 instrument: the two paths differ by an order of magnitude in allocations, so the memstats line
@@ -5672,13 +5745,13 @@ tells you which one ran without any debug flag.
 
 | path | how to force it | expected memstats (b7cfcf19) |
 |---|---|---|
-| snapshot | default, or `OPENSYSML_LIBRARY_PATH=<byte-identical copy of internal/core/libs/stdlib>` | 13–17 ms, ~67k allocations |
+| snapshot | default, or `OPENSYSML_LIBRARY_PATH=<byte-identical copy of internal/workspace/libs/stdlib>` | 13–17 ms, ~67k allocations |
 | parse fallback | `OPENSYSML_LIBRARY_PATH=<copy with one comment appended to any .kerml>` | ~70 ms warm / ~220 ms cold cache, ~455k allocations |
 | structurally corrupt blob | worktree, truncate `stdlib.snapshot`, `make build-sysml` | `WARN stdlib snapshot unreadable … pack: corrupt stream`, then the fallback numbers |
 
 - The unmodified-copy case is the one that catches a digest computed over the *path* instead of
   the *content*: it must be as fast as the default, not as slow as the edited copy.
-- `cp -r internal/core/libs/stdlib /tmp/x` keeps LICENSE/NOTICE; only `.kerml`/`.sysml` enter the
+- `cp -r internal/workspace/libs/stdlib /tmp/x` keeps LICENSE/NOTICE; only `.kerml`/`.sysml` enter the
   digest, so editing those is a no-op for the fallback test — append to a `.kerml`.
 - **Byte flips inside the payload must be refused, not misread.** The header carries a CRC-32C over
   the stream, so flipping 64 bytes in the string table (offset ~200000 of the 3.4 MB blob) has to
@@ -5687,7 +5760,7 @@ tells you which one ran without any debug flag.
   `KerML::Kernel::Interaction` and `Connector::association` from `%search` — that is the failure
   this check exists for. `TestDecodeSnapshotRejectsCorruption` covers the same flips in-process.
 - Differential battery that proved behavior-neutrality: run `-validate` over
-  `examples/*.sysml` + `testdata/passes/*.sysml`, a piped REPL transcript (`%load` robot demo,
+  `examples/*.sysml` + `tests/testdata/passes/*.sysml`, a piped REPL transcript (`%load` robot demo,
   `%search`, `%eval 1 [SI::m] + 2 [SI::m]`, `%instantiate`/`%features`, `%print`), `-e 2+3` and
   `-convert ttl -o /dev/stdout`, each with `echo "exit=$?"` appended, under snapshot / edited-copy
   (cold and warm `XDG_CACHE_HOME`) / unmodified-copy / merge-base binary, then `diff -r` the four
@@ -5700,7 +5773,7 @@ tells you which one ran without any debug flag.
   `grep -v '^'` and filters everything. Filter on `'= 5'` / `'sysml:'` instead.
 - Konsole starts at a small font: `Ctrl++` three times before recording makes memstats lines legible.
 - The blueprint's venv is `~/pv` (see the maintenance block), but it can exist without the editable
-  `opensysml` install; `~/pv/bin/pip install -e clients/python` (or a fresh `python3 -m venv`) takes
+  `opensysml` install; `~/pv/bin/pip install -e client/python` (or a fresh `python3 -m venv`) takes
   under a minute and the non-tty one-shot `python script.py` with auto-start worked (35 ms connect).
 
 ## Library feature tiers on `%features`/`%eval` (PR #830) and headless screenshot fallback
@@ -5738,12 +5811,12 @@ when you try to prove this from the binary:
   which then reports `usage Demo::massLimit has no value` (pre-existing).
 - **`XDG_CACHE_HOME` cold/warm is a no-op on the default path**: the embedded snapshot serves the
   library and the dir stays empty. To exercise the on-disk cache, point `OPENSYSML_LIBRARY_PATH` at
-  a copy of `internal/core/libs/stdlib` with a comment appended to a `.kerml`; the cold run then
+  a copy of `internal/workspace/libs/stdlib` with a comment appended to a `.kerml`; the cold run then
   writes 97 `*.idx` files under `$XDG_CACHE_HOME/sysml-ls/libs` and the warm run must diff empty
   against both cold and the default-path transcript.
 - `printf "$CMDS"` with `%load` in the variable is a format-string bug (`0ad …` on line 1): use a
   heredoc file and `./bin/sysml < cmds.txt`, or `printf '%%load …'`.
-- `pip install -e clients/python/` into `~/pv` took ~1 min; `sysml-grpc -port 50123 -health-port 0`
+- `pip install -e client/python/` into `~/pv` took ~1 min; `sysml-grpc -port 50123 -health-port 0`
   then `opensysml.connect(port=50123, auto_start=False)`. `Model.load` needs an **absolute** path
   (the service resolves relative paths against *its* cwd). `pkill -x sysml-grpc`, never `-f`.
 
@@ -5761,7 +5834,7 @@ the shell before `import` runs; use `pkill -x konsole` and a separate call.
 Since PR #845 the one-argument `%state <machine>` form walks the *held* objects for exhibitors of
 the machine and attaches to the one found (`Debugging state machine "lp" exhibited by object #1
 of "TA::Sys"`), refusing with a typed `ExhibitorsError` for zero or several. The load-bearing
-probe is `internal/repl/testdata/exhibited_timer.sysml`: `%instantiate TA::Sys`, `%state lp`,
+probe is `internal/frontend/repl/testdata/exhibited_timer.sysml`: `%instantiate TA::Sys`, `%state lp`,
 `%advance 2.5 [s]`, `%features #1` → `x = 0.6000000000000001`, `n = 3`; a detached run (the
 pre-#845 behaviour, or any regression to it) leaves `x = 0.2`, `n = 1` while still printing
 `Advanced to 2.5 (2 event(s) processed)`, so assert on `%features`, not on the advance line.
@@ -5911,7 +5984,7 @@ and use <kbd>Shift</kbd>+<kbd>PageUp</kbd>.
   attribute access (`inst.dir`) raises `FeatureValueError` by design. `m.eval('2 [m] * 3')` on the
   model fails with `unresolved unit m` (model-scope eval sees no SI import) — use unitless
   expressions (`m.eval('3 * 4')`) as the service-survival check. A throwaway venv
-  (`python3 -m venv ~/pr-venv && ~/pr-venv/bin/pip install -e clients/python`) is ~1 min; drive the
+  (`python3 -m venv ~/pr-venv && ~/pr-venv/bin/pip install -e client/python`) is ~1 min; drive the
   freshly built `./bin/sysml-grpc -port 50123` with `auto_start=False`.
 
 ## Sets, tensors, and capability refusal
@@ -5971,7 +6044,7 @@ gRPC `choice-point` diagnostic encoding on the CLI surface.
 - Build both CLI and service via Makefile; stop any older service before attaching
   with `Connection(port=50051, auto_start=False)`. Check `server_info().version`
   and capability `final_time` before trusting response values.
-- Use `internal/repl/testdata/timed_action.sysml`: start `Timed::pinger` and
+- Use `internal/frontend/repl/testdata/timed_action.sysml`: start `Timed::pinger` and
   `Timed::listener`, step twice, advance 2 then 3. The action's `%step` wait hint
   reports its current clock; `%current` reports the state debugger only.
   Expect action count 1, listener pinged, Last event at 5. The listener has no
diff --git a/.agents/skills/testing-validation-census/SKILL.md b/.agents/skills/testing-validation-census/SKILL.md
index 14e1686009..99f221bda3 100644
--- a/.agents/skills/testing-validation-census/SKILL.md
+++ b/.agents/skills/testing-validation-census/SKILL.md
@@ -1,15 +1,15 @@
 ---
 name: testing-validation-census
-description: How to verify the validation-constraint census gate (cmd/validation-census) — re-extracting the pilot's constraint names from the pinned jar, checking the census document and its probes against the committed baseline, and the mutations the gate must catch.
+description: How to verify the validation-constraint census gate (tools/census/validation) — re-extracting the pilot's constraint names from the pinned jar, checking the census document and its probes against the committed baseline, and the mutations the gate must catch.
 ---
 
-# Testing the validation-constraint census (`cmd/validation-census`)
+# Testing the validation-constraint census (`tools/census/validation`)
 
 The census (`docs/project/validation-constraints.md`) answers "which of the pilot's named
 validation constraints does OpenSysML report?". Its denominator is read from the pinned jar's
 two validator classes and committed as `docs/project/validation-constraints-baseline.json`
 together with each name's census status; the evidence for every ✅/⚠️ row is a violating model
-under `cmd/validation-census/testdata/probes/`.
+under `tools/census/validation/testdata/probes/`.
 
 ## Prerequisites
 
@@ -23,14 +23,14 @@ under `cmd/validation-census/testdata/probes/`.
 ## The core checks
 
 ```bash
-go run ./cmd/validation-census -check            # baseline ↔ document ↔ probes (↔ jar if present)
-go run ./cmd/validation-census -check -require-jar
-go test -count=1 ./cmd/validation-census         # the same gate plus every probe run through the workspace
+go run -C tools ./cmd/validation-census -check            # baseline ↔ document ↔ probes (↔ jar if present)
+go run -C tools ./cmd/validation-census -check -require-jar
+go test -C tools -count=1 ./census/validation         # the same gate plus every probe run through the workspace
 ```
 
 Reproduction of the baseline: `-update` re-extracts the names and keeps every recorded status,
 so on a current tree it must leave the file byte-identical (`git diff --exit-code docs/project/validation-constraints-baseline.json`).
-`go run ./cmd/validation-census` (no flag) rewrites the `**Pilot:**`, `**Jar:**` and `**Census:**`
+`go run -C tools ./cmd/validation-census` (no flag) rewrites the `**Pilot:**`, `**Jar:**` and `**Census:**`
 lines from the baseline and must likewise be a no-op on a current tree.
 
 ## Mutations the gate must catch
@@ -51,7 +51,7 @@ afterwards (`git checkout -- <file>` on a clean tree, or keep a copy).
   the cell is rejected as not a backticked name/path, not read as the normalized name.
 - Blank or misspell the baseline's `recorded` date, or change its `jar.name` away from the pinned
   artifact's filename: the baseline is rejected before any jar is read.
-- Point a row's *Negative case* at a file that is not under `cmd/pilot-reject/testdata/negative/`.
+- Point a row's *Negative case* at a file that is not under `tools/referee/reject/testdata/negative/`.
 - Delete a probe of a ✅/⚠️ row, or add a probe for a ❌/❔ row.
 - Delete the `.sysml` (or `.kerml`) probe of a row whose language is `both`: each notation needs one.
 
@@ -65,7 +65,7 @@ afterwards (`git checkout -- <file>` on a clean tree, or keep a copy).
 The header names the row and the severity plus a fragment of the message OpenSysML must report;
 `TestProbesReportTheirConstraint` opens the model with `model.NewWorkspace` and fails if no
 diagnostic matches. A probe is evidence for the *mapping*, not a corpus case: it is not run by
-`cmd/pilot-reject`, and adding one does not change any oracle baseline.
+`tools/referee/reject`, and adding one does not change any oracle baseline.
 
 ## Adjudicating a status change
 
@@ -73,5 +73,5 @@ Statuses are edited by hand in the baseline (the names are not). Moving a row to
 probe that passes the test above and, ideally, a run through the pinned pilot validators
 (`build/pilot-sysml-validator/validate-sysml-batch`, `build/pilot-kerml-validator/validate-kerml`)
 confirming the pilot reports the same constraint on that model. Then rerun
-`go run ./cmd/validation-census` so the summary line follows, and edit the row's status cell to
+`go run -C tools ./cmd/validation-census` so the summary line follows, and edit the row's status cell to
 match — `-check` fails until all three agree.
diff --git a/.agents/skills/testing-vscode-extension/SKILL.md b/.agents/skills/testing-vscode-extension/SKILL.md
index acd6cb7478..f48554756b 100644
--- a/.agents/skills/testing-vscode-extension/SKILL.md
+++ b/.agents/skills/testing-vscode-extension/SKILL.md
@@ -38,13 +38,42 @@ DISPLAY=:0 nohup code --no-sandbox --disable-gpu "$PWD" &   # $PWD = repo root,
 DISPLAY=:0 wmctrl -r :ACTIVE: -b add,maximized_vert,maximized_horz
 ```
 
-Workspace trust must be granted — Restricted Mode silently disables the extension (no LSP, no outline).
-The trust banner appears on the Welcome tab; "Manage" → "Trust" reloads the window.
-
-**Always open the repo *folder*, not a lone `.sysml` file.** `code <file.sysml>` gives the window no
-workspace folder, so `resolveServer` skips the `<workspace>/bin/sysml-lsp` fallback, finds nothing on
-PATH, and you get an empty "SysML v2" channel plus 0 problems — which looks exactly like a broken
-server. Launch with the repo root as the argument, then open the file from the Explorer.
+**Choose workspace trust deliberately.** The extension declares limited support in Restricted Mode:
+SysML/KerML recognition and TextMate highlighting remain available, but the workspace's own
+`bin/sysml-lsp` is never selected. Only a User-configured server path or PATH server may run;
+workspace `opensysml.server.path` and `opensysml.server.args` are ignored. Older VSIX builds
+without this declaration can disable even language recognition, showing Plain Text.
+
+For the trust boundary test, use a fresh scratch folder outside all trusted roots. Copy the
+real server to its `bin/sysml-lsp`, clear User `opensysml.server.path`, and ensure no server
+is on PATH. Keep trust enabled (do not use `security.workspace.trust.enabled: false`).
+Open a `.sysml` file: the status bar must say SysML v2 with colors, the SysML v2 Output
+channel must log that the workspace build is skipped, the missing-server warning must appear,
+and no workspace server process should exist. Banner **Manage → Trust** must then start the
+workspace binary automatically, without a manual restart. Confirm its `Starting ...` Output
+line plus live symbol hover and an unresolved-reference edit/undo.
+
+To repeat the test, **Workspaces: Manage Workspace Trust → Don't Trust** resets the folder
+(the button may be below the fold), or use another fresh folder. For a User-path regression,
+keep the second folder untrusted and configure the absolute server path through User Settings.
+A workspace settings file containing a nonexistent alternate path and invalid argument makes
+the restricted-configuration assertion non-vacuous: the User server must still start, its argv
+must not contain the workspace argument, and hover/diagnostics must work.
+
+**Choose server discovery deliberately for folder versus lone-file testing.** Opening the repo
+folder enables the `<workspace>/bin/sysml-lsp` fallback. For a lone `.sysml` file or a scratch
+folder outside the repo, set User `opensysml.server.path` to the freshly built absolute binary
+path (or put it on PATH). Otherwise an empty Problems panel may merely mean no server started.
+In an empty window, use File > Open File and keep Explorer's "No Folder Opened" visible.
+Confirm a real server process and resolved symbol hover; mutate a reference to an undefined
+name and undo to prove diagnostics are live.
+
+For unsaved-buffer authority during sibling indexing, use `main.sysml` above a
+`parts/lib.sysml`. Open only the library, change its declaration without saving, then restart
+the language server. Open the parent main file: its first directory scan revisits the already
+open library. The unsaved declaration must resolve and the disk-only declaration must not.
+Simply opening both files and restarting does not establish which buffer was open first.
+Revert both files before changing to folder-open mode.
 
 ## Server discovery (`editors/vscode/src/extension.ts`)
 
@@ -85,7 +114,7 @@ empty the Outline view, but keep TextMate colors. Clearing the setting auto-rest
 `[Error - hh:mm:ss] Server process exited with code 0.` in the channel is benign shutdown noise from
 vscode-languageclient after a clean stop — not a crash.
 
-## Completion expectations (`internal/lsp/completion.go`)
+## Completion expectations (`internal/frontend/lsp/completion.go`)
 
 Trigger characters are `.` and `:`. Inside a body:
 - `engine.` → only that type's members with real kinds/details (`power` → `attributeUsage`,
@@ -98,7 +127,7 @@ Trigger characters are `.` and `:`. Inside a body:
   empty line shows LSP items with `keyword` details and `{}` library packages.
 - `ScalarValues::` → library members (`Real`, `Boolean`, `Integer`, ... with `attributeDef` detail).
 
-## Semantic tokens (`internal/lsp/semantictokens.go`, `internal/core/highlight`)
+## Semantic tokens (`internal/frontend/lsp/semantictokens.go`, `internal/semantic/highlight`)
 
 The client enables `textDocument/semanticTokens/full` automatically; only `editor.semanticHighlighting.enabled`
 gates it (note a workspace `.vscode/settings.json` value overrides the User setting — flip it in the
@@ -117,7 +146,7 @@ gives type names the same `#4EC9B0` the semantic `class` gets, so compare a **ke
 Deltas (`semanticTokens/full/delta`) are deliberately unimplemented — the server answers -32601;
 verify that over stdio JSON-RPC, not from the GUI.
 
-## Quick-fix code actions (`internal/lsp/codeaction.go`, `internal/core/resolve/fixes.go`)
+## Quick-fix code actions (`internal/frontend/lsp/codeaction.go`, `internal/semantic/resolve/fixes.go`)
 
 Cursor on the diagnostic + **Ctrl+.** (`ctrl+period` via xdotool works). Copilot always injects its
 own `Fix`/`Explain` entries, so "no server fix offered" looks like a menu with *only* those two —
@@ -132,13 +161,17 @@ not the "No code actions available" message. Expected titles/edits:
 A fast way to learn exact titles/ranges before driving the GUI is a small stdio JSON-RPC probe
 script against `bin/sysml-lsp` (initialize → didOpen → semanticTokens/full → codeAction).
 
-## Lifecycle / process-leak testing (`cmd/sysml-lsp/main.go`, `internal/lsp/lifecycle.go`)
+## Lifecycle / process-leak testing (`cmd/sysml-lsp/main.go`, `internal/frontend/lsp/lifecycle.go`)
 
 - The client always appends `--stdio` (`vscode-languageclient/lib/node/main.js`: `TransportKind.stdio`
   → `args.push('--stdio')`), so the server binary must accept that flag or the client crash-loops.
 - `pgrep -af sysml-lsp` run from a shell whose own command line contains the string `sysml-lsp`
   matches that bash process and gives a false positive. Put the check in a tiny script
   (`/tmp/lspcheck.sh`) and call it, so the output is only real servers.
+- `pgrep -a -x sysml-lsp` avoids shell-command false positives. On a shared machine, inspect
+  each PID's PPID/argv before attributing it to VS Code: another agent's direct LSP probe can
+  run the same binary concurrently. The extension's server has `--stdio` and an extension-host
+  parent; scope both absence and restart assertions to the editor under test.
 - Expected argv while a window is open: exactly one `<repo>/bin/sysml-lsp --stdio`. After **File →
   Close Window** it must disappear within a few seconds; a surviving process is the leak bug.
 - Cheap, high-signal stdio probe for exit statuses (no GUI): initialize → `shutdown` → any request
@@ -165,7 +198,7 @@ script against `bin/sysml-lsp` (initialize → didOpen → semanticTokens/full 
 - Undo a stray edit with Command Palette **"File: Revert File"** — it is far more reliable than
   counting Ctrl+Z presses, and leaves the git tree clean.
 
-## Multi-file / workspace-indexing testing (`internal/lsp/files.go`, `sync.go`)
+## Multi-file / workspace-indexing testing (`internal/frontend/lsp/files.go`, `sync.go`)
 
 The cleanest fixture is a **throwaway folder outside the repo** (e.g. `/home/ubuntu/ws-multifile`)
 holding only a couple of tiny models, so the Problems count is entirely about the feature:
@@ -233,7 +266,7 @@ main.sysml  package Main { import Lib::*; part w : Widget; }
   A **declared** `render asElementTable` view (`LanderViews::partsTable`) does render — a probe from a
   workspace holding only `views-demo.sysml` reports it `supported:true`, `form=markdown`. It only lists
   `supported:false` when the workspace *is this repository*, because the parser fixture
-  `internal/core/parser/testdata/parse/view_expose.sysml` declares a `package Views` that shadows the
+  `tests/parser/testdata/parse/view_expose.sysml` declares a `package Views` that shadows the
   standard library's, so `render asElementTable` no longer resolves to a standard rendering. Test the
   diagram panel from a scratch folder, not the repo root, or expect that shadowing.
 - "Never blank" needs the webview-state cache. Hiding the panel (switching the other tab group to a
@@ -313,21 +346,21 @@ asserting the contextual list, and a separate fixture with them for the "still o
 Confirm the whole expected list cheaply first with a stdio JSON-RPC completion probe against
 `bin/sysml-lsp`, then prove it in the GUI.
 
-## Name resolution / alias / rename testing (`internal/core/resolve`, `internal/lsp/rename.go`)
+## Name resolution / alias / rename testing (`internal/semantic/resolve`, `internal/frontend/lsp/rename.go`)
 
-- **Never name a fixture package after a standard-library package.** `internal/core/libs/stdlib`
+- **Never name a fixture package after a standard-library package.** `internal/workspace/libs/stdlib`
   ships `Domain Libraries/Geometry/ShapeItems.sysml`, which itself declares
   `alias Box for RectangularCuboid`. A fixture `package ShapeItems { ... alias Box for Cube; }`
   therefore collides: `%explain ShapeItems::Box` reports `is ambiguous`, and a broken
   `ShapeItems::Box` reference can still resolve (to the stdlib alias), silently masking failures.
   Use a unique package name (`Shapes`, `Demo`) and re-run any assertion first taken with a colliding
   name. Grep before choosing a name:
-  `grep -rn "\balias Box\b" internal/core/libs/`.
-- **LSP rename/references do NOT go through `internal/core/edit/rename.go`.** `Server.Rename` uses
+  `grep -rn "\balias Box\b" internal/workspace/libs/`.
+- **LSP rename/references do NOT go through `internal/check/edit/rename.go`.** `Server.Rename` uses
   `Workspace.ResolveReferenceNameSegmentsInDoc` (the name a segment *wrote*, so an alias use belongs
   to the alias) and `References` unions that with `ResolveReferenceSegmentsInDoc`, comparing with
   `symbols.SameElement`. A resolver change to segment identity therefore changes rename/references
-  even when `go test ./internal/core/edit` is green: test both in the editor *and* with a probe.
+  even when `go test ./internal/check/edit` is green: test both in the editor *and* with a probe.
 - Cheap oracle before driving the GUI: a stdio JSON-RPC probe that sends `textDocument/rename`
   (with `newName`) and `textDocument/references` for both the alias declaration and the target
   declaration, printing `(line, char, newText)` per edit. Run the same probe against a
@@ -353,7 +386,7 @@ Confirm the whole expected list cheaply first with a stdio JSON-RPC completion p
   `length/width/height` with `attributeUsage` details) even though the file is momentarily a syntax
   error; keep the fixture otherwise valid and `Escape` + revert the line afterwards.
 
-### Overload / ambiguous-call navigation (`internal/lsp/definition.go`, `hover.go`, `references.go`, `rename.go`)
+### Overload / ambiguous-call navigation (`internal/frontend/lsp/definition.go`, `hover.go`, `references.go`, `rename.go`)
 
 - A compact fixture: two packages each declaring `calc def pick { in x : Integer; ... }`, one of them
   also `calc def pick { in x : String; ... }`, and a `package Use` importing both with
@@ -376,7 +409,7 @@ Confirm the whole expected list cheaply first with a stdio JSON-RPC completion p
 - `ctrl+shift+m` (Problems), `ctrl+g` (Go to Line `line:col`), `F2`, `F12`, `ctrl+comma` all reach
   VS Code via xdotool; `F1` opens the Command Palette (use it for "References: Find All References").
 
-## Metadata annotation body testing (`internal/lsp/metadata.go`, `internal/core/model/metadata.go`)
+## Metadata annotation body testing (`internal/frontend/lsp/metadata.go`, `internal/workspace/model/metadata.go`)
 
 For `@Anno { x = ...; }` bodies (KerML 7.4.7 implicit redefinition), a compact fixture is
 `metadata def Base { attribute inherited; }` / `metadata def Anno :> Base { attribute own : ScalarValues::Integer; }`
@@ -403,7 +436,7 @@ degradation case.
 
 ### Sequence diagrams and the pseudo-view picker
 
-- Ready-made sequence fixtures live in `internal/core/view/testdata/`: `sequence.sysml`
+- Ready-made sequence fixtures live in `internal/ir/view/testdata/`: `sequence.sysml`
   (`SequenceViews::pubSubView`, 3 participants `part producer/server/consumer`) and
   `sequence-vehicle.sysml` (`VehicleSequenceViews::startVehicleView`, 2 participants
   `part driver (Driver)` / `part vehicle (Vehicle)`). Copy them into a scratch workspace; both
@@ -414,7 +447,7 @@ degradation case.
   is a click, and no `Layout` is written. `npm test` (`src/webview/canvas.test.ts`) draws the same
   shapes under jsdom and is the cheapest pre-GUI check.
 - The picker's pseudo-view entries come from the server's `opensysml/views` → `pseudoViews`
-  (`internal/lsp/render.go`, `view.PseudoViewSpecs()`), labelled by
+  (`internal/frontend/lsp/render.go`, `view.PseudoViewSpecs()`), labelled by
   `PSEUDO_VIEW_LABELS` in `editors/vscode/src/diagram.ts` — e.g. `#sequence` →
   `Message sequence (no view declared)`. A pre-#624 server omits the field and the client falls back
   to a 5-entry historical list, which makes a **server build from before the change the perfect
@@ -422,7 +455,7 @@ degradation case.
 - An **unsupported** view (`geometry`) is rendered as a *disabled* `<option>` with text suffix
   `(not drawable)`, and its `reason` is also written under the diagram in a `1 view not drawable`
   collapsible (`#undrawable`) — expand it to assert the reason text on screen, rather than hovering
-  the option's `title`. A geometry-view fixture is `internal/core/view/testdata/errors.sysml`
+  the option's `title`. A geometry-view fixture is `internal/ir/view/testdata/errors.sysml`
   (`ErrorViews::geometryView`); `examples/views-demo.sysml` no longer declares any unsupported view
   (all 7 of its views are `supported:true` when opened from a scratch folder).
 - To exercise the **pluralised** summary (`N views not drawable`) no committed fixture has two
@@ -478,13 +511,13 @@ Cheap message-flow oracles that need no devtools (all three strings are produced
 guarded handler):
 - the picker filling with 13 entries for `examples/views-demo.sysml` (7 declared + 6 pseudo-views);
 - the status line `<path>: declares 7 views (…); name the one to render` — that text comes from
-  `internal/core/model/render.go`, i.e. a server error relayed as a `{type:"error"}` message;
+  `internal/workspace/model/render.go`, i.e. a server error relayed as a `{type:"error"}` message;
 - opened from a *scratch* folder that file has **0 Problems** (the ~13 problems in the skill above are
   the repo-root `package Views` shadowing), so use a deliberate error such as
   `port broken : NoSuchPort;` (expect `unresolved reference: NoSuchPort`) as the LSP smoke oracle
   rather than a non-zero problem count.
 
-## Hover presentation testing (`internal/lsp/hover.go`)
+## Hover presentation testing (`internal/frontend/lsp/hover.go`)
 
 VS Code advertises `hover.contentFormat: ["markdown", ...]`, so the GUI always exercises the
 Markdown branch (fenced ```sysml block + prose). The plain-text branch is only reachable from a
@@ -514,7 +547,7 @@ probe that advertises `["plaintext"]` — test it there, not in the editor.
   invalid signature like `partDef Wheel` renders as one plain identifier.
 - Hover popups are sticky: `mouse_move` to an empty area, wait ~2 s, then move onto the target, or
   you will screenshot the previous symbol's popup and think the hover is wrong.
-- Completion `detail` comes from the same `Notation()` (`internal/lsp/completion.go`), so
+- Completion `detail` comes from the same `Notation()` (`internal/frontend/lsp/completion.go`), so
   `Wheel → part def` / `w → part : Wheel` in the detail column is the cheap second surface.
   Note the completion **documentation** panel still shows the raw comment text with `/*` `*/`
   (`symbolDocumentation` does no stripping) — that is unrelated to a hover fix, do not report it as
@@ -531,9 +564,9 @@ Record the VS Code window maximized (wmctrl above). Verify visual claims by `zoo
 (language indicator "SysML v2"/"KerML", problem counts) and the completion popup — the popup's detail
 column is too small to read in a 1024x768 full screenshot.
 
-## Document-query authoring / `opensysml/renderDocument` (`internal/lsp/document.go`, `editors/vscode/src/document.ts`)
+## Document-query authoring / `opensysml/renderDocument` (`internal/frontend/lsp/document.go`, `editors/vscode/src/document.ts`)
 
-- The fixture in `internal/lsp/document_test.go` (`package Observatory` with `DocumentQueries::*`,
+- The fixture in `internal/frontend/lsp/document_test.go` (`package Observatory` with `DocumentQueries::*`,
   `KerML::Root::Element`, `Subsystems`/`SubsystemTable :> Query`, `MassReport :> Document`) works
   verbatim in a scratch workspace with 0 Problems — copy it and every expected value (documents list
   `Observatory::MassReport`, markdown `# Telescope Mass Report` + `| optics | 8.5 |`, error
@@ -553,7 +586,7 @@ column is too small to read in a 1024x768 full screenshot.
   `Subsystem`. In binding-name position (delete `root` before `= telescope`) the list is exactly
   one item `root` with detail `attribute : Element`.
 
-## References / rename latency testing on a large workspace (`internal/core/model/refindex.go`)
+## References / rename latency testing on a large workspace (`internal/workspace/model/refindex.go`)
 
 - The training corpus `examples/sysml-v2-training` (100 files, fetch with
   `./scripts/download-training-examples.sh`) is a ready-made large workspace. Open that *folder*; it has
@@ -582,6 +615,79 @@ column is too small to read in a 1024x768 full screenshot.
   the ` DONE  Packaged: opensysml-sysml.vsix` line before `ls editors/vscode/*.vsix`, or you will
   conclude the build failed while it is still installing node modules.
 
+## Diagram authoring (`opensysml/applyModelEdit`)
+
+- Use a small interconnection fixture with **explicit private imports** and expose the owner,
+  not only its children:
+  ```sysml
+  package Vehicle {
+      private import StandardViewDefinitions::*;
+      private import Views::*;
+      port def FuelPort;
+      part def Tank { port fuelOut : FuelPort; }
+      part def Engine { port fuelIn : FuelPort; }
+      part def Car {
+          part tank : Tank;
+          part engine : Engine;
+      }
+      view carView : GeneralView {
+          expose Car;
+          render asInterconnectionDiagram;
+      }
+  }
+  ```
+  Without the imports the view names are unresolved; imports without visibility produce
+  diagnostics. `expose Car::*` renders tank/engine as separate roots, leaving no shared
+  rendered owner for a connection. `expose Car` retains the Car node and its children.
+- Keep the Problems panel visible: this fixture should start and remain at zero. An
+  empty Problems panel alone is not sufficient; confirm the diagram and live server too.
+- Palette **Add part…** uses the source cursor owner. Put the cursor on the Car declaration
+  after its indentation, then verify the prompt title says `Add part to Vehicle::Car`
+  before typing a name. Part prompts for a type; connection prompts allow an empty name.
+- The custom context menu labels are **Connection from here…**, **Rename…**, **Delete…**.
+  Connection target quick-pick also matches type details: typing `engine` can match both
+  `engine : Engine` and `battery : Engine`, so explicitly choose the intended label.
+- All Delete actions first show a native confirmation dialog. Referenced deletion then
+  shows a second dialog with **Delete all**. Unreferenced deletion should not show the
+  second cascade dialog. Canceling the first dialog does not exercise server refusal.
+- Focus the source editor before Ctrl+Z / Ctrl+Y. One undo should remove one diagram
+  operation (e.g. connection), the next the preceding operation (e.g. added part); each
+  diagram update should arrive automatically, without Refresh.
+- For duplicate refusal, use the current name after any rename. Expect a bottom-right
+  toast such as `Vehicle::Car already declares "motor"` and `Model edit refused:` in
+  **Output: Show Output Channels… → SysML v2**. The displayed message need not include
+  the protocol's failure-code spelling.
+- Table rendering is non-SVG, but may still receive a full member/connection palette.
+  Check the current palette table in `internal/frontend/lsp/render.go` rather than
+  assuming non-SVG means authoring is hidden.
+
+## Edit-latency and semantic-token comparisons
+
+- Use the same scratch workspace and isolated keystrokes for both server builds.
+  Keep ambiguous wildcard imports at document level as well as inside a package;
+  include a resolved root wildcard import so there are actual root re-exports.
+  Start from the current index invalidation reproducer rather than assuming a
+  nested-only import triggers document-root invalidation. Small fixtures may
+  still not reproduce the reported multi-second stall; report this limitation.
+- Measure `didChange` to `publishDiagnostics` with a transparent framed stdio
+  relay or timestamped client trace, keeping payloads unchanged. Distinguish
+  server transport latency from visible screen repaint and editor debounce.
+  For publications without versions, isolate changes with settled intervals;
+  do not attribute a queued older publication to the newest edit.
+- A relay must forward/handle EOF and termination so changing the server setting
+  does not leave orphan servers. Check the actual child executable, not merely
+  the setting value. Separate logs for each server/workload.
+- Disable word-based suggestions to prove newly declared names come from the
+  LSP, but leave ordinary quick suggestions enabled when checking popup behavior.
+  Type a prefix with the list open to prove it does not block editing.
+- Inspect the reference with **Developer: Inspect Editor Tokens and Colors**
+  after renaming and shifting source lines. Check exact identifier length and
+  semantic token type; matching syntax colour alone does not prove a semantic
+  token arrived. A finite GUI sequence cannot exclude every scheduling race.
+- Show the normal hover before typing with it open. A disappearing hover and
+  inserted text prove input was not blocked; the diagnostic squiggle and Problems
+  entry are not modal notifications.
+
 ## Devin Secrets Needed
 
 None.
diff --git a/.circleci/config.yml b/.circleci/config.yml
index efb2d634e4..f00812e8ae 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -2,8 +2,6 @@ version: 2.1
 
 orbs:
   go: circleci/go@1.10.0
-  # Pinned exactly, matching Open-MBEE/flexo-mms-layer1-service.
-  sonarcloud: sonarsource/sonarcloud@4.0.0
 
 executors:
   go-executor:
@@ -52,7 +50,7 @@ commands:
       - run:
           name: Install cvc5
           command: |
-            curl -fsSL -o /tmp/cvc5.zip \
+            curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors -o /tmp/cvc5.zip \
               https://github.com/cvc5/cvc5/releases/download/cvc5-1.3.4/cvc5-Linux-x86_64-static.zip
             echo "dcdbfada0ce493ee98259c0816e0daafc561c223aadb3af298c2968e73ea39c6  /tmp/cvc5.zip" | sha256sum --check --strict
             unzip -p /tmp/cvc5.zip '*/bin/cvc5' > /tmp/cvc5-bin
@@ -63,6 +61,11 @@ commands:
   # release tag could be cut over a corpus regression without them.
   download-corpora:
     steps:
+      # Every corpus below comes through this downloader, so it is checked first.
+      - run:
+          name: Test the pilot downloader
+          command: scripts/pilot-pin-test.sh
+
       - restore_cache:
           keys:
             - training-corpus-v1-{{ checksum "scripts/download-training-examples.sh" }}-{{ checksum "scripts/pilot-pin.sh" }}
@@ -78,7 +81,7 @@ commands:
 
       # The three pilot corpora, gated the same way and keyed the same way. Their
       # gate is our-side-only: the comparison against the reference implementation
-      # (cmd/pilot-diff) needs Java validators and is not run in CI.
+      # (tools/referee/diff) needs Java validators and is not run in CI.
       - restore_cache:
           keys:
             - pilot-corpora-v1-{{ checksum "scripts/download-pilot-corpora.sh" }}-{{ checksum "scripts/pilot-pin.sh" }}
@@ -92,7 +95,7 @@ commands:
           paths:
             - examples/pilot-corpora
 
-      # The PSSM test suite, one pinned file, gated the same way by internal/pssm.
+      # The PSSM test suite, one pinned file, gated the same way by tools/referee/pssm.
       - restore_cache:
           keys:
             - pssm-suite-v1-{{ checksum "scripts/download-pssm-suite.sh" }}-{{ checksum "scripts/pssm-pin.sh" }}
@@ -106,7 +109,22 @@ commands:
           paths:
             - build/pssm
 
-      # The pilot's XMI of the standard library, gated by internal/core/identity.
+      # The fUML reference implementation's models and jar, gated by tools/referee/fuml;
+      # the jar is never run here (docs/project/fuml-referee.md).
+      - restore_cache:
+          keys:
+            - fuml-suite-v1-{{ checksum "scripts/download-fuml-suite.sh" }}-{{ checksum "scripts/fuml-pin.sh" }}
+
+      - run:
+          name: Download the fUML reference-implementation suite
+          command: ./scripts/download-fuml-suite.sh
+
+      - save_cache:
+          key: fuml-suite-v1-{{ checksum "scripts/download-fuml-suite.sh" }}-{{ checksum "scripts/fuml-pin.sh" }}
+          paths:
+            - build/fuml
+
+      # The pilot's XMI of the standard library, gated by tests/identity.
       - restore_cache:
           keys:
             - pilot-library-xmi-v1-{{ checksum "scripts/download-pilot-library-xmi.sh" }}-{{ checksum "scripts/pilot-pin.sh" }}
@@ -133,8 +151,9 @@ commands:
             done
 
 # The Go suite is four jobs rather than one because the plan caps a job at 60
-# minutes and the race run and the coverage run alone take ~45 of them. Each
-# job runs on its own executor and every downstream job requires all four.
+# minutes and the race run and the coverage run alone take ~45 of them. The
+# race run gates the workflow but not the client tests or the scan, which need
+# only the coverage profile and the binaries.
 jobs:
   # Formatting, vet, static analysis, the documentation gates and the protobuf
   # schema checks: everything that needs neither a solver nor the corpora.
@@ -158,7 +177,9 @@ jobs:
 
       - run:
           name: Run Go vet
-          command: go vet ./...
+          command: |
+            go vet ./...
+            go vet -C tools ./...
 
       - run:
           name: Check documentation links
@@ -189,7 +210,7 @@ jobs:
       # table row must be a constraint the baseline records (and vice versa).
       - run:
           name: Check the validation-constraint census matches its baseline
-          command: go run ./cmd/validation-census -check
+          command: go run -C tools ./cmd/validation-census -check
 
       # Changelog entries live in changes/unreleased/ until a release folds them in.
       - run:
@@ -201,15 +222,23 @@ jobs:
           name: Check the compliance census hook
           command: python3 scripts/mkdocs_census-test.py
 
+      # The test-suite figures are counted when the site is built, never committed.
+      - run:
+          name: Check the test-suite figures hook
+          command: python3 scripts/mkdocs_suite_figures-test.py
+
       # Each client verifies a download against the copy of the pinned digests it
       # ships, so a copy that drifts from the table pins something else.
       - run:
           name: Check the clients ship the pinned release digests
           command: python3 scripts/sync-release-digests.py --check
 
+      # no_output_timeout: gosec runs with -quiet and prints nothing until it has
+      # walked every package, which now takes longer than CircleCI's 10m default.
       - run:
           name: Run static analysis (staticcheck + gosec)
           command: make lint
+          no_output_timeout: 30m
 
       - run:
           name: Check grpc-go stays out of production code
@@ -247,15 +276,16 @@ jobs:
           command: |
             make proto-buf
             git add -N api/proto \
-              clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
+              client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
             git diff --exit-code api/proto \
-              clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
+              client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
 
   # The race-enabled suite. It is the longest single step in the pipeline, so
-  # it gets the larger class and a job to itself.
+  # it gets the larger class and four executors, one package shard each.
   go-race-test:
     executor: go-executor
     resource_class: large
+    parallelism: 4
     steps:
       - checkout
       - go/load-cache
@@ -264,13 +294,17 @@ jobs:
       - install-solvers
       - download-corpora
 
-      # Per-package timeout: under -race, passes and model exceed go's 10m default
-      # on this executor. Matches `make test` and the GitHub Actions PR workflow.
-      # no_output_timeout: go test buffers a package's output until it finishes,
-      # so a >10m package looks silent and trips CircleCI's 10m default.
+      # Sharded by package: the suite's slowest packages run near the 30m
+      # per-package timeout when they share one executor.
       - run:
           name: Run Go race tests
-          command: make test
+          command: |
+            set -o pipefail
+            pkgs=$(go list ./... | circleci tests split --split-by=name)
+            go test -v -race -pgo=off -timeout 30m $pkgs
+            if [ "$CIRCLE_NODE_INDEX" = "0" ]; then
+              go test -C tools -v -race -pgo=off -timeout 30m ./...
+            fi
           no_output_timeout: 30m
           environment:
             # Turns "corpus absent" into a failure rather than a skip, as the
@@ -278,6 +312,7 @@ jobs:
             OPENSYSML_REQUIRE_TRAINING_CORPUS: "1"
             OPENSYSML_REQUIRE_PILOT_CORPORA: "1"
             OPENSYSML_REQUIRE_PSSM_SUITE: "1"
+            OPENSYSML_REQUIRE_FUML_SUITE: "1"
             OPENSYSML_REQUIRE_PILOT_LIBRARY_XMI: "1"
             # Same for the solver installed above: absent means failure, not skip.
             OPENSYSML_REQUIRE_SMT: "1"
@@ -286,6 +321,8 @@ jobs:
   # with -coverpkg, which is too slow to instrument under -race.
   go-coverage:
     executor: go-executor
+    # 4 vCPU: the instrumented suite takes ~39 min on medium.
+    resource_class: large
     steps:
       - checkout
       - go/load-cache
@@ -302,6 +339,7 @@ jobs:
             OPENSYSML_REQUIRE_TRAINING_CORPUS: "1"
             OPENSYSML_REQUIRE_PILOT_CORPORA: "1"
             OPENSYSML_REQUIRE_PSSM_SUITE: "1"
+            OPENSYSML_REQUIRE_FUML_SUITE: "1"
             OPENSYSML_REQUIRE_PILOT_LIBRARY_XMI: "1"
             OPENSYSML_REQUIRE_SMT: "1"
 
@@ -330,7 +368,7 @@ jobs:
           name: Run pilot corpora gate
           command: |
             set -o pipefail
-            go test -count=1 -v ./internal/core/model -run 'TestPilotCorpora' | tee pilot-corpora-gate.log
+            go test -count=1 -v ./tests/corpus -run 'TestPilotCorpora' | tee pilot-corpora-gate.log
             grep -E 'pilot corpus files clean' pilot-corpora-gate.log
             if grep -qE '^\s*--- SKIP' pilot-corpora-gate.log; then
               echo "error: the pilot corpora gate skipped" >&2
@@ -344,7 +382,7 @@ jobs:
           name: Run pilot library identity gate
           command: |
             set -o pipefail
-            go test -count=1 -v ./internal/core/identity -run 'TestPilotLibraryXMI' | tee pilot-library-xmi-gate.log
+            go test -count=1 -v ./tests/identity -run 'TestPilotLibraryXMI' | tee pilot-library-xmi-gate.log
             grep -E 'pilot library XMI: [0-9]+ derived' pilot-library-xmi-gate.log
             if grep -qE '^\s*--- SKIP' pilot-library-xmi-gate.log; then
               echo "error: the pilot library identity gate skipped" >&2
@@ -359,7 +397,7 @@ jobs:
           name: Run RDF corpus round-trip gate
           command: |
             set -o pipefail
-            go test -count=1 -v ./internal/core/export -run 'TestCorpusRoundTrip' | tee corpus-roundtrip-gate.log
+            go test -count=1 -v ./tests/corpus -run 'TestCorpusRoundTrip' | tee corpus-roundtrip-gate.log
             grep -E 'corpus round trip: [0-9]+ files:' corpus-roundtrip-gate.log
             if grep -qE '^\s*--- SKIP' corpus-roundtrip-gate.log; then
               echo "error: the RDF corpus round-trip gate skipped" >&2
@@ -369,28 +407,74 @@ jobs:
             OPENSYSML_REQUIRE_TRAINING_CORPUS: "1"
             OPENSYSML_REQUIRE_PILOT_CORPORA: "1"
 
+      # The same ratchet over the API's JSON element form, beside the RDF one.
+      - run:
+          name: Run api-json corpus round-trip gate
+          command: |
+            set -o pipefail
+            go test -count=1 -v ./tests/corpus -run 'TestCorpusAPIJSONRoundTrip' | tee api-json-roundtrip-gate.log
+            grep -E 'api-json corpus round trip: [0-9]+ files:' api-json-roundtrip-gate.log
+            if grep -qE '^\s*--- SKIP' api-json-roundtrip-gate.log; then
+              echo "error: the api-json corpus round-trip gate skipped" >&2
+              exit 1
+            fi
+          environment:
+            OPENSYSML_REQUIRE_TRAINING_CORPUS: "1"
+            OPENSYSML_REQUIRE_PILOT_CORPORA: "1"
+
       # The PSSM suite gates alone, then the referee's bucket-count check
       # (never pass/fail; see docs/project/pssm-referee.md).
       - run:
           name: Run PSSM suite gates
           command: |
             set -o pipefail
-            go test -count=1 -v ./internal/pssm -run 'TestSuiteRead|TestSuiteClassification|TestEmitSuite' | tee pssm-gate.log
+            go test -C tools -count=1 -v ./referee/pssm -run 'TestSuiteRead|TestSuiteClassification|TestEmitSuite' | tee pssm-gate.log
             if grep -qE '^\s*--- SKIP' pssm-gate.log; then
               echo "error: the PSSM suite gate skipped" >&2
               exit 1
             fi
-            go run ./cmd/pssm-referee -check
+            go run -C tools ./cmd/pssm-referee -check
           environment:
             OPENSYSML_REQUIRE_PSSM_SUITE: "1"
 
+      # The migration of the PSSM suite to v2 notation alone, so its report totals
+      # are legible in the log and a skip cannot hide behind a green suite
+      # (docs/project/pssm-migration.md).
+      - run:
+          name: Run PSSM migration gate
+          command: |
+            set -o pipefail
+            go test -count=1 -v ./tests/corpus -run 'TestPSSMSuiteMigration' | tee pssm-migration-gate.log
+            grep -E 'PSSM_TestSuite.xmi: migrated [0-9]+ element' pssm-migration-gate.log
+            if grep -qE '^\s*--- SKIP' pssm-migration-gate.log; then
+              echo "error: the PSSM migration gate skipped" >&2
+              exit 1
+            fi
+          environment:
+            OPENSYSML_REQUIRE_PSSM_SUITE: "1"
+
+      # The fUML reader and classifier over the pinned models, likewise, then the
+      # referee's bucket-count check against the committed record; the jar never runs.
+      - run:
+          name: Run fUML suite gates
+          command: |
+            set -o pipefail
+            go test -C tools -count=1 -v ./referee/fuml -run 'TestSuite' | tee fuml-gate.log
+            if grep -qE '^\s*--- SKIP' fuml-gate.log; then
+              echo "error: the fUML suite gate skipped" >&2
+              exit 1
+            fi
+            go run -C tools ./cmd/fuml-referee -check
+          environment:
+            OPENSYSML_REQUIRE_FUML_SUITE: "1"
+
       # Re-run the differential gate alone so its summary counts, which make
       # coverage drift reviewable, are visible rather than buried in the suite.
       - run:
           name: Run solver differential gate
           command: |
             set -o pipefail
-            go test -count=1 -v ./internal/core/solve -run TestDifferential | tee differential-gate.log
+            go test -count=1 -v ./internal/exec/solve -run TestDifferential | tee differential-gate.log
             grep -E 'differential gate .*: .* elements:' differential-gate.log
             if grep -qE '^\s*--- SKIP' differential-gate.log; then
               echo "error: a differential gate skipped" >&2
@@ -408,7 +492,7 @@ jobs:
           command: |
             set -o pipefail
             for solver in z3 cvc5; do
-              OPENSYSML_SMT="$solver" go test -count=1 -v ./internal/core/solve \
+              OPENSYSML_SMT="$solver" go test -count=1 -v ./internal/exec/solve \
                 -run 'TestPortability' | tee "portability-$solver.log"
               grep -E "portability of $solver" "portability-$solver.log"
               if grep -qE '^\s*--- SKIP' "portability-$solver.log"; then
@@ -425,7 +509,7 @@ jobs:
           name: Run solver gate (cvc5)
           command: |
             set -o pipefail
-            OPENSYSML_SMT=cvc5 go test -count=1 -v ./internal/core/solve ./internal/repl \
+            OPENSYSML_SMT=cvc5 go test -count=1 -v ./internal/exec/solve ./internal/frontend/repl \
               -run 'TestSolver|TestDiscovery|TestSolved|TestDivisor|TestCheck|TestSolve|TestConfigure|TestSynthesis|TestPinned|TestFixed|TestExplain|TestCore' \
               | tee cvc5-gate.log
             if grep -qE '^\s*--- SKIP' cvc5-gate.log; then
@@ -443,7 +527,7 @@ jobs:
           command: |
             set -o pipefail
             env -u OPENSYSML_REQUIRE_SMT OPENSYSML_SMT=/nonexistent/no-such-solver \
-              go test -count=1 -v ./internal/core/solve ./internal/repl | tee no-solver-gate.log
+              go test -count=1 -v ./internal/exec/solve ./internal/frontend/repl | tee no-solver-gate.log
             for name in TestCheckReportsAnAbsentSolver TestExplainReportsAnAbsentSolver \
               TestSolveReportsAnAbsentSolver TestConfigureReportsAnAbsentSolver \
               TestOptimizeReportsAnAbsentSolver; do
@@ -474,7 +558,7 @@ jobs:
       # cannot rot untested.
       - run:
           name: Run conformance suite against -transport grpc
-          command: go run ./cmd/conformance -transport grpc -protocols grpc -report bin/conformance-grpc-transport-report.json
+          command: go run -C tools ./cmd/conformance -transport grpc -protocols grpc -report bin/conformance-grpc-transport-report.json
 
       - store_artifacts:
           path: bin/conformance-grpc-transport-report.json
@@ -522,14 +606,17 @@ jobs:
   scan:
     docker:
       - image: cimg/go:1.25
-    # The Go sensor holds a whole directory's parse in memory; internal/core/runtime
-    # (168 files) overflows the default heap (a quarter of the container). 8 GB is
-    # the largest class in the project's plan (xlarge is rejected).
+    # The Go sensor holds a whole directory's parse in memory; internal/exec/runtime
+    # (168 files) overflows the default heap. The 8 GB container, the largest
+    # class in the plan, carries: 5.5 GB analysis heap + Node capped at 1 GB
+    # (sonar-project.properties) + a small launcher JVM.
     resource_class: large
     environment:
       # sonar-scanner-cli 8 forks the analysis into a second JVM whose arguments
-      # come from this variable only; SONAR_SCANNER_OPTS sizes just the launcher.
-      SONAR_SCANNER_JAVA_OPTS: -Xmx6500m
+      # come from SONAR_SCANNER_JAVA_OPTS; SONAR_SCANNER_OPTS sizes the launcher
+      # and carries -D properties appended by later steps.
+      SONAR_SCANNER_JAVA_OPTS: -Xmx5500m
+      SONAR_SCANNER_OPTS: -Xmx256m
     steps:
       # Forked PRs get no context, so the token is absent there: end green
       # rather than fail every outside contribution. With a token, a failing
@@ -561,12 +648,12 @@ jobs:
           name: Show the reports and Java binaries the scan reads
           command: |
             ls -l coverage.txt coverage-python.xml coverage-scripts.xml coverage-node.lcov \
-              clients/java/opensysml-client/target/site/jacoco/jacoco.xml \
-              clients/java/opensysml-conformance/target/site/jacoco/jacoco.xml
+              client/java/opensysml-client/target/site/jacoco/jacoco.xml \
+              client/java/opensysml-conformance/target/site/jacoco/jacoco.xml
             head -n 3 coverage.txt
             for module in opensysml-client opensysml-conformance; do
               for dir in classes test-classes dependency; do
-                path="clients/java/$module/target/$dir"
+                path="client/java/$module/target/$dir"
                 if [ -z "$(ls -A "$path" 2>/dev/null)" ]; then
                   echo "$path is empty or missing; java-test must persist it."
                   exit 1
@@ -625,7 +712,70 @@ jobs:
             # properties and hands them to the forked analysis JVM.
             echo "export SONAR_SCANNER_OPTS=\"${SONAR_SCANNER_OPTS} -Dsonar.projectVersion=${version#v}\"" >> "$BASH_ENV"
 
-      - sonarcloud/scan
+      - run: mkdir -p /tmp/cache/scanner sonar-logs
+
+      - restore_cache:
+          keys:
+            - v1-sonar-scanner-8.0.1.6346
+
+      # The sonar-scanner-cli distribution, cached by version; what the
+      # sonarsource/sonarcloud orb's scan command did. A transient SonarCloud
+      # API failure retries once after 30s; an analysis failure exits at once.
+      - run:
+          name: SonarQube Cloud
+          no_output_timeout: 20m
+          command: |
+            set -eo pipefail
+            VERSION=8.0.1.6346
+            if [ -z "${SONAR_TOKEN:-}" ]; then
+              echo "SONAR_TOKEN is required to scan" >&2
+              exit 1
+            fi
+            SCANNER_DIRECTORY=/tmp/cache/scanner
+            export SONAR_USER_HOME=$SCANNER_DIRECTORY/.sonar
+            if ! ls "$SCANNER_DIRECTORY/sonar-scanner-$VERSION-linux-x64/bin/sonar-scanner" >/dev/null 2>&1; then
+              curl -sSLo /tmp/scanner-cli.zip "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-$VERSION-linux-x64.zip"
+              unzip -q -d "$SCANNER_DIRECTORY" /tmp/scanner-cli.zip
+              rm /tmp/scanner-cli.zip
+              chmod +x "$SCANNER_DIRECTORY/sonar-scanner-$VERSION-linux-x64/bin/sonar-scanner" "$SCANNER_DIRECTORY/sonar-scanner-$VERSION-linux-x64/jre/bin/java"
+            fi
+            scanner="$SCANNER_DIRECTORY/sonar-scanner-$VERSION-linux-x64/bin/sonar-scanner"
+            transient='Failed to query JRE metadata|failed with HTTP 5[0-9][0-9]|Connection reset|Connect timed out|Read timed out|SocketTimeoutException|UnknownHostException'
+            for attempt in 1 2; do
+              set +e
+              "$scanner" 2>&1 | tee "sonar-logs/sonar-scan-$attempt.log"
+              status=${PIPESTATUS[0]}
+              set -e
+              [ "$status" -eq 0 ] && exit 0
+              if [ "$attempt" -eq 1 ] && grep -Eq "$transient" "sonar-logs/sonar-scan-$attempt.log"; then
+                echo "SonarCloud API error; retrying once in 30s"
+                sleep 30
+                continue
+              fi
+              exit "$status"
+            done
+
+      # An OOM kill surfaces as a bare exit 3 in the scan's log; the cgroup
+      # counters name it (v2 first, v1 fallback).
+      - run:
+          name: Report the container's memory events
+          when: always
+          command: |
+            cat /sys/fs/cgroup/memory.max 2>/dev/null || true
+            cat /sys/fs/cgroup/memory.peak 2>/dev/null || true
+            cat /sys/fs/cgroup/memory.events 2>/dev/null || true
+            cat /sys/fs/cgroup/memory/memory.max_usage_in_bytes 2>/dev/null || true
+            cat /sys/fs/cgroup/memory/memory.oom_control 2>/dev/null || true
+
+      - save_cache:
+          key: v1-sonar-scanner-8.0.1.6346
+          paths:
+            - /tmp/cache/scanner
+
+      - store_artifacts:
+          path: sonar-logs/
+          destination: sonar-logs
+          when: always
 
       # SonarCloud does not create a project from a CI scan, so the first run
       # against an unprovisioned project fails; point at the setup doc.
@@ -645,7 +795,16 @@ jobs:
       
       - attach_workspace:
           at: .
-      
+
+      # make scripts-coverage runs the test-suite figures test, which renders the figures with go run.
+      - go/install:
+          version: "1.25.0"
+
+      - run:
+          name: Verify Go version
+          command: |
+            go version
+
       - run:
           name: Install sysml-grpc
           command: |
@@ -728,8 +887,8 @@ jobs:
             # (GRPC_GENERATED_VERSION), so a newer release cannot fail this check.
             pip install "grpcio-tools==1.83.0"
             make python-proto BUF=buf
-            git add -N clients/python/opensysml/proto
-            git diff --exit-code clients/python/opensysml/proto
+            git add -N client/python/opensysml/proto
+            git diff --exit-code client/python/opensysml/proto
 
       - run:
           name: Verify Python client import
@@ -761,16 +920,16 @@ jobs:
 
       - run:
           name: Check Rust formatting
-          command: cd clients/rust && cargo fmt --all -- --check
+          command: cd client/rust && cargo fmt --all -- --check
 
       - run:
           name: Run Rust clippy
-          command: cd clients/rust && cargo clippy --all-targets -- -D warnings
+          command: cd client/rust && cargo clippy --all-targets -- -D warnings
 
       - run:
           name: Run Rust tests
           command: |
-            cd clients/rust
+            cd client/rust
             OPENSYSML_GRPC_BINARY="$PWD/../../bin/sysml-grpc" \
             OPENSYSML_REQUIRE_SERVICE=1 \
               cargo test --workspace
@@ -781,7 +940,7 @@ jobs:
 
       - run:
           name: Package Rust client
-          command: cd clients/rust && cargo package -p opensysml
+          command: cd client/rust && cargo package -p opensysml
 
       - run:
           name: Install Buf
@@ -796,8 +955,8 @@ jobs:
           name: Verify Rust generated artifacts
           command: |
             make BUF=/tmp/buf proto-rust
-            git add -N clients/rust/opensysml/src/proto clients/rust/conformance/sysml.descriptor.binpb
-            git diff --exit-code clients/rust/opensysml/src/proto clients/rust/conformance/sysml.descriptor.binpb
+            git add -N client/rust/opensysml/src/proto client/rust/conformance/sysml.descriptor.binpb
+            git diff --exit-code client/rust/opensysml/src/proto client/rust/conformance/sysml.descriptor.binpb
 
       - store_artifacts:
           path: bin/conformance-report-rust.json
@@ -821,15 +980,15 @@ jobs:
 
       - restore_cache:
           keys:
-            - maven-v1-{{ checksum "clients/java/pom.xml" }}-{{ checksum "clients/java/opensysml-client/pom.xml" }}-{{ checksum "clients/java/opensysml-conformance/pom.xml" }}
+            - maven-v1-{{ checksum "client/java/pom.xml" }}-{{ checksum "client/java/opensysml-client/pom.xml" }}-{{ checksum "client/java/opensysml-conformance/pom.xml" }}
             - maven-v1-
 
       - run:
           name: Resolve dependencies
-          command: mvn -B -f clients/java/pom.xml -DskipTests dependency:go-offline
+          command: mvn -B -f client/java/pom.xml -DskipTests dependency:go-offline
 
       - save_cache:
-          key: maven-v1-{{ checksum "clients/java/pom.xml" }}-{{ checksum "clients/java/opensysml-client/pom.xml" }}-{{ checksum "clients/java/opensysml-conformance/pom.xml" }}
+          key: maven-v1-{{ checksum "client/java/pom.xml" }}-{{ checksum "client/java/opensysml-client/pom.xml" }}-{{ checksum "client/java/opensysml-conformance/pom.xml" }}
           paths:
             - ~/.m2/repository
 
@@ -840,14 +999,14 @@ jobs:
       - run:
           name: Test and install the client
           command: |
-            mvn -B -f clients/java/pom.xml install -Dopensysml.requireService=true
+            mvn -B -f client/java/pom.xml install -Dopensysml.requireService=true
 
       # The scan's Java sensor needs the dependency jars on a path it can read,
       # and ~/.m2 is this job's cache, not the workspace.
       - run:
           name: Collect the dependency jars the scan reads
           command: |
-            mvn -B -q -f clients/java/pom.xml dependency:copy-dependencies \
+            mvn -B -q -f client/java/pom.xml dependency:copy-dependencies \
               -DincludeScope=test -DoutputDirectory=target/dependency
 
       # JaCoCo writes the reports during `test`, `install` writes the classes,
@@ -855,33 +1014,33 @@ jobs:
       - persist_to_workspace:
           root: .
           paths:
-            - clients/java/opensysml-client/target/site/jacoco/jacoco.xml
-            - clients/java/opensysml-conformance/target/site/jacoco/jacoco.xml
-            - clients/java/opensysml-client/target/classes
-            - clients/java/opensysml-conformance/target/classes
-            - clients/java/opensysml-client/target/test-classes
-            - clients/java/opensysml-conformance/target/test-classes
-            - clients/java/opensysml-client/target/dependency
-            - clients/java/opensysml-conformance/target/dependency
+            - client/java/opensysml-client/target/site/jacoco/jacoco.xml
+            - client/java/opensysml-conformance/target/site/jacoco/jacoco.xml
+            - client/java/opensysml-client/target/classes
+            - client/java/opensysml-conformance/target/classes
+            - client/java/opensysml-client/target/test-classes
+            - client/java/opensysml-conformance/target/test-classes
+            - client/java/opensysml-client/target/dependency
+            - client/java/opensysml-conformance/target/dependency
 
       - run:
           name: Run the conformance suite through the client
           command: |
-            mvn -B -q -f clients/java/pom.xml -pl opensysml-conformance \
+            mvn -B -q -f client/java/pom.xml -pl opensysml-conformance \
               dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
-            java -cp "clients/java/opensysml-conformance/target/classes:$(cat /tmp/cp.txt)" \
+            java -cp "client/java/opensysml-conformance/target/classes:$(cat /tmp/cp.txt)" \
               org.openmbee.opensysml.conformance.Main \
               -binary bin/sysml-grpc -protocols connect,connect-json -allow-skips \
               -report bin/conformance-report-java.json
 
       - store_test_results:
-          path: clients/java/opensysml-client/target/surefire-reports
+          path: client/java/opensysml-client/target/surefire-reports
 
       - store_artifacts:
           path: bin/conformance-report-java.json
 
       - store_artifacts:
-          path: clients/java/opensysml-client/target
+          path: client/java/opensysml-client/target
 
       # The committed Java stubs are checked in go-static, which has the Go
       # toolchain the one buf template needs.
@@ -900,25 +1059,25 @@ jobs:
       - run:
           name: Install the client's dependencies
           command: |
-            cd clients/node
+            cd client/node
             npm ci
 
       - run:
           name: Build the package
           command: |
-            cd clients/node
+            cd client/node
             npm run build
 
       - run:
           name: Typecheck
           command: |
-            cd clients/node
+            cd client/node
             npm run typecheck
 
       - run:
           name: Lint
           command: |
-            cd clients/node
+            cd client/node
             npm run lint
 
       - run:
@@ -941,7 +1100,7 @@ jobs:
           name: Run the conformance suite through the client
           command: |
             export OPENSYSML_BINARY="$(pwd)/bin/sysml-grpc"
-            cd clients/node
+            cd client/node
             npm run conformance -- --binary "$OPENSYSML_BINARY" --allow-skips \
               --report /tmp/node-conformance.json
 
@@ -950,7 +1109,7 @@ jobs:
           name: Check the conformance runner catches a broken client
           command: |
             export OPENSYSML_BINARY="$(pwd)/bin/sysml-grpc"
-            cd clients/node
+            cd client/node
             for mutation in hide-capability drop-diagnostics blank-symbol-kind shift-integer drop-feature-values; do
               if npm run conformance -- --binary "$OPENSYSML_BINARY" --allow-skips \
                    --protocols connect --mutate "$mutation" > /dev/null 2>&1; then
@@ -981,19 +1140,12 @@ jobs:
           name: Verify the committed TypeScript stubs are current
           command: |
             make proto-ts BUF=buf
-            git add -N clients/node/src/generated
-            git diff --exit-code clients/node/src/generated
-
-  # Publishes opensysml to PyPI on a `opensysml-v<version>` tag. PyPI has no
-  # trusted-publishing (OIDC) support for CircleCI — the supported providers are
-  # GitHub Actions, Google Cloud, ActiveState and GitLab CI/CD, and
-  # pypi/warehouse#13888 is the open request — so this authenticates with an API
-  # token supplied by the restricted `PyPI` context. An upload cannot be
-  # taken back (a version can be yanked, never re-uploaded), so everything that
-  # can fail is made to fail before `twine upload` runs: the tag/version check,
-  # `twine check --strict`, an install of the built wheel into a clean
-  # virtualenv, and a refusal when the version is already on the index.
-  publish-pypi:
+            git add -N client/node/src/generated
+            git diff --exit-code client/node/src/generated
+
+  # Builds the opensysml wheel and sdist on a core `v<version>` tag, which must name
+  # the declared version; build-release and publish-pypi ship exactly these bytes.
+  build-python-package:
     executor: python-executor
     steps:
       - checkout
@@ -1002,13 +1154,91 @@ jobs:
           name: Install build tooling
           command: python -m pip install --upgrade pip build twine packaging
 
+      - run:
+          name: Resolve the version from the tag
+          command: |
+            # Fails loudly when the tag names a version other than the one the
+            # package would publish, before anything is built.
+            VERSION="$(python client/python/scripts/check_version.py)"
+            PRE_RELEASE="$(python client/python/scripts/check_version.py --pre-release)"
+            echo "export OPENSYSML_VERSION=${VERSION}" >> "$BASH_ENV"
+            echo "Building opensysml ${VERSION} (pre-release: ${PRE_RELEASE})"
+
+      - run:
+          name: Build the wheel and the sdist
+          command: |
+            rm -rf client/python/dist
+            python -m build --outdir client/python/dist client/python/
+            ls -l client/python/dist
+            # The version is PEP 440 in canonical form, so these are the names
+            # the build tools give the files; the release jobs rely on them.
+            ls client/python/dist/"opensysml-${OPENSYSML_VERSION}-py3-none-any.whl" \
+               client/python/dist/"opensysml-${OPENSYSML_VERSION}.tar.gz" > /dev/null
+
+      - run:
+          name: Check the metadata (twine check --strict)
+          command: python -m twine check --strict client/python/dist/*
+
+      - run:
+          name: Install the built wheel into a clean virtualenv and import it
+          command: |
+            # A broken artifact fails the job rather than PyPI.
+            python -m venv /tmp/verify-wheel
+            /tmp/verify-wheel/bin/python -m pip install --upgrade pip
+            /tmp/verify-wheel/bin/python -m pip install client/python/dist/*.whl
+            # The backslash is CircleCI's escape for a literal heredoc operator
+            # in a 2.1 config; the shell sees a plain heredoc.
+            /tmp/verify-wheel/bin/python - \<<'EOF'
+            import os
+            import opensysml
+            expected = os.environ["OPENSYSML_VERSION"]
+            assert opensysml.__version__ == expected, (
+                f"installed wheel reports {opensysml.__version__}, expected {expected}"
+            )
+            print(f"opensysml {opensysml.__version__} imported from the built wheel")
+            EOF
+
+      - store_artifacts:
+          path: client/python/dist
+          destination: opensysml-dist
+
+      - persist_to_workspace:
+          root: .
+          paths:
+            - client/python/dist
+
+  # Uploads the distribution build-python-package made. PyPI has no OIDC publishing
+  # for CircleCI (pypi/warehouse#13888), so a token from the `PyPI` context is used.
+  publish-pypi:
+    executor: python-executor
+    steps:
+      - checkout
+      - attach_workspace:
+          at: .
+
+      - run:
+          name: Install publishing tooling
+          command: python -m pip install --upgrade pip twine packaging
+
+      - run:
+          name: Resolve the version from the tag
+          command: |
+            VERSION="$(python client/python/scripts/check_version.py)"
+            PRE_RELEASE="$(python client/python/scripts/check_version.py --pre-release)"
+            echo "export OPENSYSML_VERSION=${VERSION}" >> "$BASH_ENV"
+            echo "export OPENSYSML_PRE_RELEASE=${PRE_RELEASE}" >> "$BASH_ENV"
+            echo "Publishing opensysml ${VERSION} (pre-release: ${PRE_RELEASE})"
+            # The distribution must be the one build-python-package made for this tag.
+            ls -l client/python/dist/"opensysml-${VERSION}-py3-none-any.whl" \
+                  client/python/dist/"opensysml-${VERSION}.tar.gz"
+
       - run:
           name: Require the publishing token
           command: |
             # A pre-release version goes to TestPyPI (the dry run described in
             # docs/project/releasing.md), everything else to PyPI, so only the token for
             # the index actually being used is required.
-            if [ "$(python clients/python/scripts/check_version.py --pre-release)" = "yes" ]; then
+            if [ "$OPENSYSML_PRE_RELEASE" = "yes" ]; then
               if [ -z "${TEST_PYPI_API_TOKEN:-}" ]; then
                 echo "Error: TEST_PYPI_API_TOKEN is not set, and ${CIRCLE_TAG} names a"
                 echo "pre-release version, which is published to TestPyPI."
@@ -1026,17 +1256,6 @@ jobs:
               exit 1
             fi
 
-      - run:
-          name: Resolve the version from the tag
-          command: |
-            # Fails loudly when the tag names a version other than the one the
-            # package would publish, before anything is built.
-            VERSION="$(python clients/python/scripts/check_version.py)"
-            PRE_RELEASE="$(python clients/python/scripts/check_version.py --pre-release)"
-            echo "export OPENSYSML_VERSION=${VERSION}" >> "$BASH_ENV"
-            echo "export OPENSYSML_PRE_RELEASE=${PRE_RELEASE}" >> "$BASH_ENV"
-            echo "Publishing opensysml ${VERSION} (pre-release: ${PRE_RELEASE})"
-
       - run:
           name: Refuse a version the index already has
           command: |
@@ -1050,44 +1269,19 @@ jobs:
             if [ "$STATUS" = "200" ]; then
               echo "Error: opensysml ${OPENSYSML_VERSION} is already on ${INDEX_HOST}."
               echo "A PyPI version is immutable: it can be yanked but never replaced,"
-              echo "so this job fails rather than half-publishing. Re-running a"
-              echo "release for the same version is therefore not an operation PyPI"
-              echo "supports, unlike publish-github-release, which replaces assets"
-              echo "with 'ghr -replace'. Bump the version in"
-              echo "clients/python/opensysml/_version.py and tag opensysml-v<new version>."
+              echo "so this job fails rather than half-publishing. If this is a"
+              echo "re-run of an already published release, this failure is expected"
+              echo "and the package needs no action: publish-github-release replaces"
+              echo "the GitHub assets on its own. If the package itself must change,"
+              echo "that is a new core release: bump the version in"
+              echo "client/python/opensysml/_version.py and tag v<new version>."
               exit 1
             fi
             echo "${INDEX_HOST} has no opensysml ${OPENSYSML_VERSION} (HTTP ${STATUS}); continuing"
 
-      - run:
-          name: Build the wheel and the sdist
-          command: |
-            rm -rf clients/python/dist
-            python -m build --outdir clients/python/dist clients/python/
-            ls -l clients/python/dist
-
       - run:
           name: Check the metadata (twine check --strict)
-          command: python -m twine check --strict clients/python/dist/*
-
-      - run:
-          name: Install the built wheel into a clean virtualenv and import it
-          command: |
-            # A broken artifact fails the job rather than PyPI.
-            python -m venv /tmp/verify-wheel
-            /tmp/verify-wheel/bin/python -m pip install --upgrade pip
-            /tmp/verify-wheel/bin/python -m pip install clients/python/dist/*.whl
-            # The backslash is CircleCI's escape for a literal heredoc operator
-            # in a 2.1 config; the shell sees a plain heredoc.
-            /tmp/verify-wheel/bin/python - \<<'EOF'
-            import os
-            import opensysml
-            expected = os.environ["OPENSYSML_VERSION"]
-            assert opensysml.__version__ == expected, (
-                f"installed wheel reports {opensysml.__version__}, expected {expected}"
-            )
-            print(f"opensysml {opensysml.__version__} imported from the built wheel")
-            EOF
+          command: python -m twine check --strict client/python/dist/*
 
       - run:
           name: Upload to the index
@@ -1105,11 +1299,7 @@ jobs:
             fi
             # No --skip-existing: a version that appeared since the check above
             # must fail the job rather than pass silently.
-            python -m twine upload clients/python/dist/*
-
-      - store_artifacts:
-          path: clients/python/dist
-          destination: opensysml-dist
+            python -m twine upload client/python/dist/*
 
   # Publishes packaging/pypi-pysysml on a `pysysml-v<version>` tag: the final
   # release of the pre-rename name, which raises on import to point at
@@ -1200,10 +1390,14 @@ jobs:
           path: packaging/pypi-pysysml/dist
           destination: pysysml-placeholder-dist
 
+  # Builds every binary the release ships, plus the Python distribution from
+  # build-python-package, so one signed manifest covers all of them.
   build-release:
     executor: go-executor
     steps:
       - checkout
+      - attach_workspace:
+          at: .
       - go/load-cache
       - go/mod-download
       - go/save-cache
@@ -1292,7 +1486,7 @@ jobs:
               mkdir -p "$stage/share/man/man1"
               cp "sysml-${platform}" "${stage}/sysml"
               cp "sysml-lsp-${platform}" "${stage}/sysml-lsp"
-              cp ../man/man1/sysml.1 ../man/man1/sysml-lsp.1 "${stage}/share/man/man1/"
+              cp ../packaging/man/man1/sysml.1 ../packaging/man/man1/sysml-lsp.1 "${stage}/share/man/man1/"
               tar czf "opensysml-${platform}.tar.gz" -C "$stage" sysml sysml-lsp share
             done
             mkdir -p stage/windows-amd64
@@ -1301,9 +1495,12 @@ jobs:
             (cd stage/windows-amd64 && zip -q ../../opensysml-windows-amd64.zip sysml.exe sysml-lsp.exe)
             rm -rf stage
             
+            # The opensysml wheel and sdist, released with the binaries they drive.
+            cp ../client/python/dist/opensysml-*.whl ../client/python/dist/opensysml-*.tar.gz .
+            
             # Checksums over every published archive, so a Homebrew formula
             # (or a careful user) can verify a download.
-            sha256sum ./*.tar.gz ./*.zip | sed 's|\./||' > SHA256SUMS.txt
+            sha256sum ./*.tar.gz ./*.zip ./*.whl | sed 's|\./||' > SHA256SUMS.txt
             
             # Same for the raw gRPC binaries, plus a per-file sidecar, which is
             # the only checksum opensysml reads.
@@ -1396,6 +1593,18 @@ jobs:
                 fail "$binary" "the version string is absent from the binary"
               fi
             done
+            # build-python-package named the Python distribution after the tag's
+            # version (SemVer 'v0.9.0-rc1' is PEP 440 '0.9.0rc1'); one of each is due.
+            for pattern in "dist/opensysml-*-py3-none-any.whl" "dist/opensysml-*.tar.gz"; do
+              set -- $pattern
+              if [ ! -f "$1" ]; then
+                fail "$pattern" "build-python-package did not produce it"
+              elif [ $# -ne 1 ]; then
+                fail "$pattern" "matches $# files, not one"
+              else
+                echo "ok: $1 is the distribution for ${CIRCLE_TAG}"
+              fi
+            done
             exit $status
 
       - store_artifacts:
@@ -1466,10 +1675,10 @@ jobs:
       - run:
           name: Check the tag matches the package version
           command: |
-            version=$(node -p "require('./clients/node/package.json').version")
+            version=$(node -p "require('./client/node/package.json').version")
             expected="client-node-v${version}"
             if [ "${CIRCLE_TAG}" != "$expected" ]; then
-              echo "Error: tag ${CIRCLE_TAG} does not match clients/node/package.json"
+              echo "Error: tag ${CIRCLE_TAG} does not match client/node/package.json"
               echo "version ${version}; the tag for it is ${expected}. Nothing was published."
               exit 1
             fi
@@ -1477,13 +1686,13 @@ jobs:
       - run:
           name: Refuse a version already on the registry
           command: |
-            version=$(node -p "require('./clients/node/package.json').version")
+            version=$(node -p "require('./client/node/package.json').version")
             for name in @opensysml/client @opensysml/sysml-grpc-linux-x64 \
                         @opensysml/sysml-grpc-linux-arm64 @opensysml/sysml-grpc-darwin-x64 \
                         @opensysml/sysml-grpc-darwin-arm64 @opensysml/sysml-grpc-win32-x64; do
               if npm view "${name}@${version}" version > /dev/null 2>&1; then
                 echo "Error: ${name}@${version} is already published; a version cannot be"
-                echo "replaced. Bump clients/node/package.json and tag again."
+                echo "replaced. Bump client/node/package.json and tag again."
                 exit 1
               fi
             done
@@ -1493,7 +1702,7 @@ jobs:
           command: |
             chmod +x dist/grpc/sysml-grpc-linux-amd64
             export OPENSYSML_BINARY="$(pwd)/dist/grpc/sysml-grpc-linux-amd64"
-            cd clients/node
+            cd client/node
             npm ci
             npm run build
             npm run typecheck
@@ -1503,7 +1712,7 @@ jobs:
       - run:
           name: Build the per-platform packages
           command: |
-            cd clients/node
+            cd client/node
             npm run platform-packages -- --binaries ../../dist/grpc
 
       - run:
@@ -1520,14 +1729,14 @@ jobs:
       - run:
           name: Publish the per-platform packages
           command: |
-            for directory in clients/node/packages/sysml-grpc-*; do
+            for directory in client/node/packages/sysml-grpc-*; do
               npm publish "$directory" --access public
             done
 
       - run:
           name: Publish @opensysml/client
           command: |
-            cd clients/node
+            cd client/node
             npm publish --access public
 
   publish-github-release:
@@ -1551,6 +1760,8 @@ jobs:
             # Move only tarballs to a release directory
             mkdir -p dist/release
             mv dist/*.tar.gz dist/*.zip dist/release/ 2>/dev/null || true
+            # The opensysml wheel, the same bytes publish-pypi uploads.
+            mv dist/*.whl dist/release/
             mv dist/grpc/* dist/release/
             mv dist/SHA256SUMS.txt dist/release/
             # The signature over that manifest, which the Python client verifies
@@ -1583,7 +1794,8 @@ workflows:
   version: 2
 
   # Build and test every merge into main or develop; pull requests run .github/workflows/pr.yml instead.
-  # The Go suite is the four `Go …` jobs, and everything downstream requires all of them.
+  # The race run gates the workflow but not the client tests or the scan, which
+  # need only the coverage profile and the binaries.
   build-test:
     jobs:
       - go-static:
@@ -1604,23 +1816,22 @@ workflows:
           filters: *integration-branches
       - python-test:
           name: Python client tests
-          requires: &go-suite
+          requires: &scan-inputs
             - Go static checks
-            - Go race tests
             - Go coverage profile
             - Go gates and binaries
           filters: *integration-branches
       - rust-test:
           name: Rust client tests
-          requires: *go-suite
+          requires: *scan-inputs
           filters: *integration-branches
       - java-test:
           name: Java client tests
-          requires: *go-suite
+          requires: *scan-inputs
           filters: *integration-branches
       - node-test:
           name: Node client tests
-          requires: *go-suite
+          requires: *scan-inputs
           filters: *integration-branches
       - scan:
           name: SonarCloud scan
@@ -1629,7 +1840,6 @@ workflows:
             - SonarCloud
           requires:
             - Go static checks
-            - Go race tests
             # Persists coverage.txt, the Go report the scan reads.
             - Go coverage profile
             - Go gates and binaries
@@ -1641,7 +1851,8 @@ workflows:
           filters: *integration-branches
 
   # Build and release on tags. The suite runs here too: a tag can point at any
-  # commit, so a release is only published from a revision proven green.
+  # commit, so a release is only published from a revision proven green. One tag
+  # publishes the binaries and the opensysml package, at the same version.
   release:
     jobs:
       - go-static:
@@ -1661,69 +1872,57 @@ workflows:
           name: Go gates and binaries
           filters: *release-tags
 
+      - python-test:
+          name: Python client tests
+          requires: &go-suite
+            - Go static checks
+            - Go race tests
+            - Go coverage profile
+            - Go gates and binaries
+          filters: *release-tags
+
+      # Fails the release before anything is built when the tag does not name
+      # the version client/python/opensysml/_version.py declares.
+      - build-python-package:
+          name: Build Python distribution
+          requires: *go-suite
+          filters: *release-tags
+
       - build-release:
           name: Build release artifacts
-          requires: *go-suite
+          requires:
+            - Go static checks
+            - Go race tests
+            - Go coverage profile
+            - Go gates and binaries
+            - Build Python distribution
           filters: *release-tags
 
       - publish-github-release:
           name: Publish GitHub release
           requires:
             - Build release artifacts
+            - Python client tests
           filters: *release-tags
 
-  # Publish opensysml to PyPI on its own tag. The package's version is not
-  # lockstep with the core's: it resolves a sysml-grpc binary at runtime from
-  # whichever release the caller asks for (OPENSYSML_GRPC_VERSION, or 'latest'),
-  # not from a release matching its own version, so a client fix should not need
-  # a core release and a core release should not need a PyPI upload. Keeping the
-  # irreversible half on its own tag also keeps `v*` re-runnable, which
-  # publish-github-release relies on ('ghr -replace').
-  release-python:
-    jobs:
-      - go-static:
-          name: Go static checks
-          filters: &python-tags
-            branches:
-              ignore: /.*/
-            tags:
-              only: /^opensysml-v.*/
-      - go-race-test:
-          name: Go race tests
-          filters: *python-tags
-      - go-coverage:
-          name: Go coverage profile
-          filters: *python-tags
-      - go-gates:
-          name: Go gates and binaries
-          filters: *python-tags
-
-      - python-test:
-          name: Python client tests
-          requires: *go-suite
-          filters: *python-tags
-
       - publish-pypi:
           name: Publish opensysml to PyPI
-          # Last, and only after the Go suite and the Python client's own tests
-          # have passed on this revision: an upload cannot be undone.
+          # Last of all: an upload cannot be undone, so it waits for the GitHub
+          # release the package version promises to exist. A re-run of a published
+          # release fails here by design; the index has the version.
           # The organization context is named 'PyPI'; a context reference is
           # matched exactly, so the case here has to match it.
           context:
             - PyPI
           requires:
-            - Go static checks
-            - Go race tests
-            - Go coverage profile
-            - Go gates and binaries
+            - Publish GitHub release
             - Python client tests
-          filters: *python-tags
+          filters: *release-tags
 
-  # Publish the npm client on its own tag, for the same reason the Python client
-  # has one: the package resolves a service binary at run time, so a client fix
-  # should not need a core release and a core release should not need an npm
-  # publish. The binaries the platform packages carry are built here rather than
-  # downloaded from a release.
+  # Publish the npm client on its own tag: the package resolves a service binary
+  # at run time, so a client fix should not need a core release and a core
+  # release should not need an npm publish. The binaries the platform packages
+  # carry are built here rather than downloaded from a release.
   release-node:
     jobs:
       - go-static:
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
new file mode 100644
index 0000000000..a0dc47d6dd
--- /dev/null
+++ b/.github/workflows/nightly.yml
@@ -0,0 +1,262 @@
+name: Nightly snapshot
+
+# Builds the newest green `develop` commit into the release assets and publishes it
+# as the moving prerelease `nightly`, never marked latest. See docs/project/nightly.md.
+on:
+  schedule:
+    - cron: '23 3 * * *'
+  workflow_dispatch:
+    inputs:
+      force:
+        description: Rebuild and republish even when the newest green develop commit is already the published snapshot
+        type: boolean
+        default: false
+
+permissions:
+  contents: read
+
+concurrency:
+  group: nightly-snapshot
+  cancel-in-progress: false
+
+env:
+  BRANCH: develop
+  TAG: nightly
+  # The commit is built by its own copy of this script; a green commit that predates it cannot be built.
+  BUILD_SCRIPT: scripts/build-release-artifacts.sh
+
+jobs:
+  select:
+    name: Pick the commit
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    outputs:
+      sha: ${{ steps.pick.outputs.sha }}
+      publish: ${{ steps.pick.outputs.publish }}
+    steps:
+      - name: Check out develop
+        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+        with:
+          ref: ${{ env.BRANCH }}
+          # Full history: the candidates are the branch's own commits, walked from the head.
+          fetch-depth: 0
+
+      - name: Find the newest green develop commit
+        id: pick
+        env:
+          GH_TOKEN: ${{ github.token }}
+          GH_REPO: ${{ github.repository }}
+          FORCE: ${{ inputs.force }}
+        run: |
+          # The tag moves before the release exists, so a night counts as done only once a published
+          # release sits at it; `gh release create` uploads into a draft and publishes last.
+          # Both lookups answer an empty match, not 404, when there is no snapshot yet, so an API failure
+          # stops the job rather than passing for "no snapshot". The release is recreated nightly, so it is recent.
+          published=$(gh api "repos/$GH_REPO/git/matching-refs/tags/$TAG" \
+            --jq ".[] | select(.ref == \"refs/tags/$TAG\") | .object.sha")
+          released=$(gh api "repos/$GH_REPO/releases?per_page=100" \
+            --jq ".[] | select(.tag_name == \"$TAG\" and .draft == false) | .id")
+
+          # First-parent commits only (a merged branch's commits carry its PR's statuses); CircleCI's
+          # combined state is `success` only when every job passed. The walk ends at the published snapshot.
+          sha=
+          while read -r candidate; do
+            state=$(gh api "repos/$GH_REPO/commits/$candidate/status" --jq .state)
+            if [ "$state" = success ] && ! git cat-file -e "$candidate:$BUILD_SCRIPT" 2>/dev/null; then
+              state="success, but has no $BUILD_SCRIPT"
+            fi
+            echo "$candidate: $state"
+            if [ "$state" = success ]; then
+              sha=$candidate
+              break
+            fi
+            if [ "$candidate" = "$published" ]; then
+              break
+            fi
+          done < <(git rev-list --first-parent --max-count=100 HEAD)
+          if [ -z "$sha" ]; then
+            if [ -n "$published" ]; then
+              echo "::notice::Nothing on $BRANCH newer than the published snapshot ${published:0:7} is green among the newest 100 commits; the previous snapshot stands."
+              echo "publish=false" >> "$GITHUB_OUTPUT"
+              exit 0
+            fi
+            echo "::error::No commit among the newest 100 on $BRANCH has a green CircleCI status; nothing to publish."
+            exit 1
+          fi
+          echo "sha=$sha" >> "$GITHUB_OUTPUT"
+
+          if [ "$published" = "$sha" ] && [ -n "$released" ] && [ "$FORCE" != true ]; then
+            echo "::notice::$TAG already points at $sha, the newest green commit on $BRANCH, with its release; nothing to rebuild."
+            echo "publish=false" >> "$GITHUB_OUTPUT"
+          else
+            echo "Publishing $sha as $TAG (previously ${published:-unpublished})."
+            echo "publish=true" >> "$GITHUB_OUTPUT"
+          fi
+
+  publish:
+    name: Build and publish the snapshot
+    needs: select
+    if: needs.select.outputs.publish == 'true'
+    runs-on: ubuntu-latest
+    timeout-minutes: 60
+    permissions:
+      contents: write
+      id-token: write
+    steps:
+      - name: Check out the selected commit
+        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+        with:
+          ref: ${{ needs.select.outputs.sha }}
+          # Full history: the notes count commits since the last `v*` tag.
+          fetch-depth: 0
+
+      - name: Set up Go
+        uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
+        with:
+          go-version-file: go.mod
+          cache: true
+
+      - name: Set up Node.js
+        uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
+        with:
+          node-version: '22'
+          cache: npm
+          cache-dependency-path: editors/vscode/package-lock.json
+
+      - name: Derive the snapshot version
+        id: version
+        run: |
+          commit=$(git rev-parse --short HEAD)
+          {
+            echo "version=nightly-$(date -u +%Y%m%d)-${commit}"
+            echo "date=$(date -u +%Y-%m-%d)"
+            echo "base=$(git describe --tags --abbrev=0 --match 'v[0-9]*' HEAD)"
+          } >> "$GITHUB_OUTPUT"
+
+      - name: Build the release artifacts
+        env:
+          VERSION: ${{ steps.version.outputs.version }}
+        run: '"$BUILD_SCRIPT" dist'
+
+      # The extension is side-loaded, not published to a marketplace, so the snapshot
+      # is its distribution. Its version is the manifest's with the snapshot version as
+      # the pre-release part, so two nights' builds are told apart in VS Code and any
+      # stable release of the extension ranks above them. The selected commit may be
+      # older than this workflow, so the version is passed to the extension's own
+      # package script rather than through a Makefile variable it may not have yet.
+      - name: Package the VS Code extension
+        env:
+          VERSION: ${{ steps.version.outputs.version }}
+        run: |
+          base=$(node -p "require('./editors/vscode/package.json').version")
+          expected="${base}-${VERSION}"
+          (cd editors/vscode && npm ci --ignore-scripts && npm run package -- "$expected" --no-update-package-json)
+          unzip -q -o editors/vscode/opensysml-sysml.vsix extension/package.json -d /tmp/vsix
+          stamped=$(node -p "require('/tmp/vsix/extension/package.json').version")
+          if [ "$stamped" != "$expected" ]; then
+            echo "::error::opensysml-sysml.vsix carries version '$stamped', not the '$expected' it is being published as."
+            exit 1
+          fi
+          echo "ok: opensysml-sysml.vsix carries $stamped"
+          cp editors/vscode/opensysml-sysml.vsix dist/
+          (cd dist && sha256sum opensysml-sysml.vsix >> SHA256SUMS.txt && cat SHA256SUMS.txt)
+
+      # Keyless, like the stable release; the identity is this workflow's OIDC identity.
+      - name: Install cosign
+        uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+        with:
+          cosign-release: v3.0.3
+
+      - name: Sign the checksum manifest with cosign keyless
+        run: |
+          cd dist
+          cosign sign-blob SHA256SUMS.txt \
+            --bundle SHA256SUMS.txt.bundle \
+            --use-signing-config=false \
+            --yes
+
+      - name: Verify the signature against the identity the snapshot page documents
+        env:
+          WORKFLOW_REF: ${{ github.workflow_ref }}
+        run: |
+          cd dist
+          cosign verify-blob SHA256SUMS.txt \
+            --bundle SHA256SUMS.txt.bundle \
+            --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+            --certificate-identity "https://github.com/${WORKFLOW_REF}"
+
+      - name: Write the release notes
+        env:
+          GH_REPO: ${{ github.repository }}
+          VERSION: ${{ steps.version.outputs.version }}
+          DATE: ${{ steps.version.outputs.date }}
+          BASE: ${{ steps.version.outputs.base }}
+          WORKFLOW_REF: ${{ github.workflow_ref }}
+          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+        run: |
+          sha=$(git rev-parse HEAD)
+          count=$(git rev-list --count "$BASE..HEAD")
+          case $count in 1) commits="1 commit" ;; *) commits="$count commits" ;; esac
+          # GitHub renders a release body with hard line breaks, so each paragraph is one line.
+          {
+            printf '%s' "**Development snapshot of \`$BRANCH\`, not a release.** Built $DATE from"
+            printf '%s' " [\`${sha:0:7}\`](https://github.com/$GH_REPO/commit/$sha), $commits after"
+            printf '%s' " [$BASE](https://github.com/$GH_REPO/releases/tag/$BASE)"
+            printf '%s' " ([compare](https://github.com/$GH_REPO/compare/$BASE...$sha)), by"
+            printf '%s\n' " [this workflow run]($RUN_URL). Every binary reports \`$VERSION\`."
+            echo
+            printf '%s' "The snapshot is replaced each night and is never the \`latest\` release; Homebrew,"
+            printf '%s' " PyPI, npm and the Windows installer follow the stable line only. The"
+            printf '%s' " [snapshot page](https://opensysml.org/project/nightly/) explains what it is, how"
+            printf '%s' " to verify it and what to expect from it; the"
+            printf '%s\n' " [install guide](https://opensysml.org/guide/01-install/) covers the stable releases."
+            echo
+            printf '%s' "\`opensysml-sysml.vsix\` is the VS Code extension built from the same commit:"
+            printf '%s' " \`code --install-extension opensysml-sysml.vsix\`, with a snapshot's \`sysml-lsp\`"
+            printf '%s\n' " on your \`PATH\` or named by \`opensysml.server.path\`."
+            echo
+            echo "Verify a download against the signed manifest:"
+            echo
+            echo '```bash'
+            echo "sha256sum -c --ignore-missing SHA256SUMS.txt"
+            echo "cosign verify-blob SHA256SUMS.txt --bundle SHA256SUMS.txt.bundle \\"
+            echo "  --certificate-oidc-issuer https://token.actions.githubusercontent.com \\"
+            echo "  --certificate-identity https://github.com/$WORKFLOW_REF"
+            echo '```'
+            echo
+            echo "## Unreleased changes in this snapshot"
+            echo
+            printf '%s' "One line per entry; the full text is under"
+            printf '%s' " [\`changes/unreleased/\`](https://github.com/$GH_REPO/tree/$sha/changes/unreleased)"
+            printf '%s\n' " and \`CHANGELOG.md\` at this commit."
+            echo
+            # GitHub caps a release body at 125,000 characters; a one-line summary stays far under it.
+            python3 scripts/changelog.py summary | head -c 100000
+          } > notes.md
+          cat notes.md
+
+      # Recreated rather than edited, so no asset of a previous night survives.
+      # `gh release delete` leaves the tag behind; it is moved by hand.
+      - name: Publish the prerelease at the moving tag
+        env:
+          GH_TOKEN: ${{ github.token }}
+          GH_REPO: ${{ github.repository }}
+          VERSION: ${{ steps.version.outputs.version }}
+          DATE: ${{ steps.version.outputs.date }}
+        run: |
+          sha=$(git rev-parse HEAD)
+          if gh release view "$TAG" --json tagName >/dev/null 2>&1; then
+            gh release delete "$TAG" --yes
+          fi
+          git tag --force "$TAG" "$sha"
+          git push --force origin "refs/tags/$TAG"
+          gh release create "$TAG" \
+            --target "$sha" \
+            --title "Nightly snapshot $DATE ($VERSION)" \
+            --notes-file notes.md \
+            --prerelease \
+            --latest=false \
+            dist/*.tar.gz dist/*.zip dist/opensysml-sysml.vsix \
+            dist/SHA256SUMS.txt dist/SHA256SUMS.txt.bundle \
+            dist/grpc/sysml-grpc-*
+          gh release view "$TAG" --json url,assets --jq '.url, (.assets[].name)'
diff --git a/.github/workflows/oracle-reproduction.yml b/.github/workflows/oracle-reproduction.yml
index 4a150bb9e9..1e39e5ac8b 100644
--- a/.github/workflows/oracle-reproduction.yml
+++ b/.github/workflows/oracle-reproduction.yml
@@ -71,28 +71,28 @@ jobs:
       # re-record with -update). Each step runs even if an earlier one failed,
       # so one run reports all three verdicts.
       - name: Reference differential reproduces its baseline
-        run: go run ./cmd/pilot-diff -check
+        run: go run -C tools ./cmd/pilot-diff -check
 
       - name: Xpect oracle reproduces its baseline
         if: ${{ !cancelled() }}
-        run: go run ./cmd/pilot-xpect -check
+        run: go run -C tools ./cmd/pilot-xpect -check
 
       - name: Rejection oracle reproduces its baseline
         if: ${{ !cancelled() }}
-        run: go run ./cmd/pilot-reject -check
+        run: go run -C tools ./cmd/pilot-reject -check
 
       # Bucket counts only: a pass is never evidence of SysML v2 conformance.
       - name: PSSM referee reproduces its baseline counts
         if: ${{ !cancelled() }}
         env:
           OPENSYSML_REQUIRE_PSSM_SUITE: "1"
-        run: go run ./cmd/pssm-referee -check
+        run: go run -C tools ./cmd/pssm-referee -check
 
       # The census baseline lists the constraint names the pinned jar's validator
       # classes declare; only here is the jar present to compare against.
       - name: Validation-constraint baseline lists what the pinned jar contains
         if: ${{ !cancelled() }}
-        run: go run ./cmd/validation-census -check -require-jar
+        run: go run -C tools ./cmd/validation-census -check -require-jar
 
       # The reports themselves, so a failing run can be read without
       # reproducing the environment.
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 6befdc57b4..f9b4ee5456 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -1,20 +1,12 @@
 name: Pages
 
-# The site is docs/ rendered by MkDocs, republished when a page or the site
-# configuration changes on main. --strict: a broken link fails the deployment.
+# The site is docs/ rendered by MkDocs, republished on every push to main: the
+# test-suite figures are counted from the whole tree as it builds, so any
+# fixture or test landing in a release moves them. --strict: a broken link
+# fails the deployment.
 on:
   push:
     branches: [main]
-    paths:
-      - 'docs/**'
-      - 'mkdocs.yml'
-      - 'docs-requirements.txt'
-      - 'scripts/mkdocs_repo_links.py'
-      - 'scripts/mkdocs_landing.py'
-      - 'scripts/mkdocs_header_menu.py'
-      - 'scripts/mkdocs_census.py'
-      - 'overrides/**'
-      - '.github/workflows/pages.yml'
   workflow_dispatch:
 
 # One deployment at a time; cancelling one in flight would leave the site behind.
@@ -42,6 +34,14 @@ jobs:
           cache: pip
           cache-dependency-path: docs-requirements.txt
 
+      # The test-suite figures are counted from the tree by tools/cmd/doc-counts as the
+      # site is built, so the build needs the Go toolchain.
+      - name: Set up Go
+        uses: actions/setup-go@v5
+        with:
+          go-version-file: go.mod
+          cache: true
+
       - name: Install documentation toolchain
         run: make docs-install
 
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 2a11d98bb2..69045fbb27 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -12,18 +12,20 @@ concurrency:
 env:
   # The OMG training corpus is not vendored. Requiring it here turns "corpus
   # absent" into a test failure instead of a skip, so the gate cannot pass green
-  # without actually running (see internal/core/model/training_examples_test.go).
+  # without actually running (see tests/corpus/training_examples_test.go).
   OPENSYSML_REQUIRE_TRAINING_CORPUS: "1"
   # Same for the three pinned OMG pilot corpora, which are downloaded below and
-  # gated by internal/core/model/pilot_corpora_test.go.
+  # gated by tests/corpus/pilot_corpora_test.go.
   OPENSYSML_REQUIRE_PILOT_CORPORA: "1"
-  # Same for the pinned OMG PSSM test suite, gated by internal/pssm and cmd/pssm-referee.
+  # Same for the pinned OMG PSSM test suite, gated by tools/referee/pssm and tools/cmd/pssm-referee.
   OPENSYSML_REQUIRE_PSSM_SUITE: "1"
+  # Same for the pinned fUML reference-implementation suite, gated by tools/referee/fuml.
+  OPENSYSML_REQUIRE_FUML_SUITE: "1"
   # Same for the pilot's XMI of the standard library, downloaded below and
-  # compared against by internal/core/identity (TestPilotLibraryXMI).
+  # compared against by tests/identity (TestPilotLibraryXMI).
   OPENSYSML_REQUIRE_PILOT_LIBRARY_XMI: "1"
   # z3 is installed below, so "no solver, therefore skip" would exercise nothing:
-  # this turns an absent solver into a failure (see internal/core/solve).
+  # this turns an absent solver into a failure (see internal/exec/solve).
   OPENSYSML_REQUIRE_SMT: "1"
 
 jobs:
@@ -43,6 +45,8 @@ jobs:
       java: ${{ steps.areas.outputs.java }}
       rust: ${{ steps.areas.outputs.rust }}
       vscode: ${{ steps.areas.outputs.vscode }}
+      cameo: ${{ steps.areas.outputs.cameo }}
+      syson: ${{ steps.areas.outputs.syson }}
     steps:
       - name: Check out repository
         uses: actions/checkout@v4
@@ -65,7 +69,7 @@ jobs:
     runs-on: ubuntu-latest
     permissions:
       contents: read
-    timeout-minutes: 40
+    timeout-minutes: 50
     steps:
       - name: Check out repository
         uses: actions/checkout@v4
@@ -96,9 +100,9 @@ jobs:
         run: |
           make proto-buf BUF=buf
           git add -N api/proto \
-            clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
+            client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
           git diff --exit-code api/proto \
-            clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
+            client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
 
       # The solver is an external process, never linked in: the solver-dependent
       # tests need one on PATH to run rather than skip.
@@ -173,6 +177,18 @@ jobs:
       - name: Download the OMG PSSM test suite
         run: ./scripts/download-pssm-suite.sh
 
+      # The fUML reference implementation's models and jar, checksummed; keyed
+      # on its download script and pin. Never run in CI: the Go tests read the
+      # committed expected-record (docs/project/fuml-referee.md).
+      - name: Cache the fUML reference-implementation suite
+        uses: actions/cache@v4
+        with:
+          path: build/fuml
+          key: fuml-suite-${{ hashFiles('scripts/download-fuml-suite.sh', 'scripts/fuml-pin.sh') }}
+
+      - name: Download the fUML reference-implementation suite
+        run: ./scripts/download-fuml-suite.sh
+
       # The pilot's XMI of the standard library, one subtree of the release
       # repository; keyed on its download script and the shared pilot pin.
       - name: Cache the pilot library XMI
@@ -232,6 +248,10 @@ jobs:
           sudo install -m 0755 /tmp/cvc5-bin /usr/local/bin/cvc5
           cvc5 --version | head -1
 
+      # Every corpus below comes through this downloader, so it is checked first.
+      - name: Test the pilot downloader
+        run: scripts/pilot-pin-test.sh
+
       - name: Cache the OMG training corpus
         uses: actions/cache@v4
         with:
@@ -279,6 +299,15 @@ jobs:
       - name: Download the OMG PSSM test suite
         run: ./scripts/download-pssm-suite.sh
 
+      - name: Cache the fUML reference-implementation suite
+        uses: actions/cache@v4
+        with:
+          path: build/fuml
+          key: fuml-suite-${{ hashFiles('scripts/download-fuml-suite.sh', 'scripts/fuml-pin.sh') }}
+
+      - name: Download the fUML reference-implementation suite
+        run: ./scripts/download-fuml-suite.sh
+
       # The pilot's XMI of the standard library, one subtree of the release
       # repository; keyed on its download script and the shared pilot pin.
       - name: Cache the pilot library XMI
@@ -300,7 +329,9 @@ jobs:
           fi
 
       - name: Run Go vet
-        run: go vet ./...
+        run: |
+          go vet ./...
+          go vet -C tools ./...
 
       - name: Run static analysis (staticcheck + gosec)
         run: make lint
@@ -331,7 +362,7 @@ jobs:
       - name: Run training corpus gate
         run: |
           set -o pipefail
-          go test -count=1 -v ./internal/core/model -run 'TestTrainingExamples|TestCorpusGates' | tee corpus-gate.log
+          go test -count=1 -v ./tests/corpus -run 'TestTrainingExamples|TestCorpusGates' | tee corpus-gate.log
           grep -E 'training files clean' corpus-gate.log
           if grep -qE '^\s*--- SKIP' corpus-gate.log; then
             echo "error: the corpus gate skipped" >&2
@@ -341,11 +372,11 @@ jobs:
       # The pilot corpora gate on its own, for the same reason: its per-root
       # verdict lines are the reviewable output, and a skip must not pass.
       # It gates our own diagnostics only; the comparison against the reference
-      # implementation (cmd/pilot-diff) needs Java validators and is not run here.
+      # implementation (tools/referee/diff) needs Java validators and is not run here.
       - name: Run pilot corpora gate
         run: |
           set -o pipefail
-          go test -count=1 -v ./internal/core/model -run 'TestPilotCorpora' | tee pilot-corpora-gate.log
+          go test -count=1 -v ./tests/corpus -run 'TestPilotCorpora' | tee pilot-corpora-gate.log
           grep -E 'pilot corpus files clean' pilot-corpora-gate.log
           if grep -qE '^\s*--- SKIP' pilot-corpora-gate.log; then
             echo "error: the pilot corpora gate skipped" >&2
@@ -357,7 +388,7 @@ jobs:
       - name: Run pilot library identity gate
         run: |
           set -o pipefail
-          go test -count=1 -v ./internal/core/identity -run 'TestPilotLibraryXMI' | tee pilot-library-xmi-gate.log
+          go test -count=1 -v ./tests/identity -run 'TestPilotLibraryXMI' | tee pilot-library-xmi-gate.log
           grep -E 'pilot library XMI: [0-9]+ derived' pilot-library-xmi-gate.log
           if grep -qE '^\s*--- SKIP' pilot-library-xmi-gate.log; then
             echo "error: the pilot library identity gate skipped" >&2
@@ -368,7 +399,7 @@ jobs:
       - name: Run PSSM suite gates
         run: |
           set -o pipefail
-          go test -count=1 -v ./internal/pssm -run 'TestSuiteRead|TestSuiteClassification|TestEmitSuite' | tee pssm-gate.log
+          go test -C tools -count=1 -v ./referee/pssm -run 'TestSuiteRead|TestSuiteClassification|TestEmitSuite' | tee pssm-gate.log
           if grep -qE '^\s*--- SKIP' pssm-gate.log; then
             echo "error: the PSSM suite gate skipped" >&2
             exit 1
@@ -376,26 +407,63 @@ jobs:
 
       # Bucket counts only, never pass/fail; see docs/project/pssm-referee.md.
       - name: PSSM referee reproduces its baseline counts
-        run: go run ./cmd/pssm-referee -check
+        run: go run -C tools ./cmd/pssm-referee -check
+
+      # The migration of the PSSM suite to v2 notation on its own, so its report
+      # totals are legible in the log and a skip must not pass; see
+      # docs/project/pssm-migration.md.
+      - name: Run PSSM migration gate
+        run: |
+          set -o pipefail
+          go test -count=1 -v ./tests/corpus -run 'TestPSSMSuiteMigration' | tee pssm-migration-gate.log
+          grep -E 'PSSM_TestSuite.xmi: migrated [0-9]+ element' pssm-migration-gate.log
+          if grep -qE '^\s*--- SKIP' pssm-migration-gate.log; then
+            echo "error: the PSSM migration gate skipped" >&2
+            exit 1
+          fi
+
+      # The fUML reader and classifier over the pinned models on their own, likewise.
+      - name: Run fUML suite gates
+        run: |
+          set -o pipefail
+          go test -C tools -count=1 -v ./referee/fuml -run 'TestSuite' | tee fuml-gate.log
+          if grep -qE '^\s*--- SKIP' fuml-gate.log; then
+            echo "error: the fUML suite gate skipped" >&2
+            exit 1
+          fi
+
+      # Bucket counts only, never pass/fail; see docs/project/fuml-referee.md.
+      - name: fUML referee reproduces its baseline counts
+        run: go run -C tools ./cmd/fuml-referee -check
 
       # The RDF round-trip ratchet over every example on its own, so its
       # per-verdict counts are legible in the log and a skip must not pass.
       - name: Run RDF corpus round-trip gate
         run: |
           set -o pipefail
-          go test -count=1 -v ./internal/core/export -run 'TestCorpusRoundTrip' | tee corpus-roundtrip-gate.log
+          go test -count=1 -v ./tests/corpus -run 'TestCorpusRoundTrip' | tee corpus-roundtrip-gate.log
           grep -E 'corpus round trip: [0-9]+ files:' corpus-roundtrip-gate.log
           if grep -qE '^\s*--- SKIP' corpus-roundtrip-gate.log; then
             echo "error: the RDF corpus round-trip gate skipped" >&2
             exit 1
           fi
 
+      - name: Run api-json corpus round-trip gate
+        run: |
+          set -o pipefail
+          go test -count=1 -v ./tests/corpus -run 'TestCorpusAPIJSONRoundTrip' | tee api-json-roundtrip-gate.log
+          grep -E 'api-json corpus round trip: [0-9]+ files:' api-json-roundtrip-gate.log
+          if grep -qE '^\s*--- SKIP' api-json-roundtrip-gate.log; then
+            echo "error: the api-json corpus round-trip gate skipped" >&2
+            exit 1
+          fi
+
       # Re-run the solver gate on its own, as the corpus gate is, so a skip
       # cannot hide behind a green suite.
       - name: Run solver gate (Z3)
         run: |
           set -o pipefail
-          go test -count=1 -v ./internal/core/solve ./internal/repl -run 'TestSolver|TestDiscovery|TestSolved|TestDivisor|TestCheck|TestSolve|TestConfigure|TestSynthesis|TestPinned|TestFixed|TestOptimize|TestOptimum' | tee solver-gate.log
+          go test -count=1 -v ./internal/exec/solve ./internal/frontend/repl -run 'TestSolver|TestDiscovery|TestSolved|TestDivisor|TestCheck|TestSolve|TestConfigure|TestSynthesis|TestPinned|TestFixed|TestOptimize|TestOptimum' | tee solver-gate.log
           if grep -qE '^\s*--- SKIP' solver-gate.log; then
             echo "error: a solver-dependent test skipped" >&2
             exit 1
@@ -406,7 +474,7 @@ jobs:
       - name: Run solver differential gate
         run: |
           set -o pipefail
-          go test -count=1 -v ./internal/core/solve -run TestDifferential | tee differential-gate.log
+          go test -count=1 -v ./internal/exec/solve -run TestDifferential | tee differential-gate.log
           grep -E 'differential gate .*: .* elements:' differential-gate.log
           if grep -qE '^\s*--- SKIP' differential-gate.log; then
             echo "error: a differential gate skipped" >&2
@@ -420,7 +488,7 @@ jobs:
         run: |
           set -o pipefail
           for solver in z3 cvc5; do
-            OPENSYSML_SMT="$solver" go test -count=1 -v ./internal/core/solve \
+            OPENSYSML_SMT="$solver" go test -count=1 -v ./internal/exec/solve \
               -run 'TestPortability' | tee "portability-$solver.log"
             grep -E "portability of $solver" "portability-$solver.log"
             if grep -qE '^\s*--- SKIP' "portability-$solver.log"; then
@@ -434,7 +502,7 @@ jobs:
       - name: Run solver gate (cvc5)
         run: |
           set -o pipefail
-          OPENSYSML_SMT=cvc5 go test -count=1 -v ./internal/core/solve ./internal/repl \
+          OPENSYSML_SMT=cvc5 go test -count=1 -v ./internal/exec/solve ./internal/frontend/repl \
             -run 'TestSolver|TestDiscovery|TestSolved|TestDivisor|TestCheck|TestSolve|TestConfigure|TestSynthesis|TestPinned|TestFixed|TestExplain|TestCore' \
             | tee cvc5-gate.log
           if grep -qE '^\s*--- SKIP' cvc5-gate.log; then
@@ -451,7 +519,7 @@ jobs:
         run: |
           set -o pipefail
           env -u OPENSYSML_REQUIRE_SMT OPENSYSML_SMT=/nonexistent/no-such-solver \
-            go test -count=1 -v ./internal/core/solve ./internal/repl | tee no-solver-gate.log
+            go test -count=1 -v ./internal/exec/solve ./internal/frontend/repl | tee no-solver-gate.log
           for name in TestCheckReportsAnAbsentSolver TestExplainReportsAnAbsentSolver \
             TestSolveReportsAnAbsentSolver TestConfigureReportsAnAbsentSolver \
             TestOptimizeReportsAnAbsentSolver; do
@@ -471,7 +539,9 @@ jobs:
       needs.changes.outputs.node == 'true' ||
       needs.changes.outputs.python == 'true' ||
       needs.changes.outputs.java == 'true' ||
-      needs.changes.outputs.rust == 'true'
+      needs.changes.outputs.rust == 'true' ||
+      needs.changes.outputs.cameo == 'true' ||
+      needs.changes.outputs.syson == 'true'
     runs-on: ubuntu-latest
     permissions:
       contents: read
@@ -519,10 +589,13 @@ jobs:
     needs:
       - changes
       - build
+      - cameo-plugin
       - conformance-pkg
       - docs
       - java-test
+      - syson-plugin
       - node-test
+      - pdf-toolchain
       - python-test
       - race-tests
       - rust-test
@@ -537,10 +610,13 @@ jobs:
         env:
           CHANGES_RESULT: ${{ needs.changes.result }}
           BUILD_RESULT: ${{ needs.build.result }}
+          CAMEO_PLUGIN_RESULT: ${{ needs.cameo-plugin.result }}
           CONFORMANCE_PKG_RESULT: ${{ needs.conformance-pkg.result }}
           DOCS_RESULT: ${{ needs.docs.result }}
           JAVA_TEST_RESULT: ${{ needs.java-test.result }}
+          SYSON_PLUGIN_RESULT: ${{ needs.syson-plugin.result }}
           NODE_TEST_RESULT: ${{ needs.node-test.result }}
+          PDF_TOOLCHAIN_RESULT: ${{ needs.pdf-toolchain.result }}
           PYTHON_TEST_RESULT: ${{ needs.python-test.result }}
           RACE_TESTS_RESULT: ${{ needs.race-tests.result }}
           RUST_TEST_RESULT: ${{ needs.rust-test.result }}
@@ -550,10 +626,13 @@ jobs:
           ok() { [ "$1" = success ] || [ "$1" = skipped ]; }
           if [ "$CHANGES_RESULT" != success ] ||
              ! ok "$BUILD_RESULT" ||
+             ! ok "$CAMEO_PLUGIN_RESULT" ||
              ! ok "$CONFORMANCE_PKG_RESULT" ||
              ! ok "$DOCS_RESULT" ||
              ! ok "$JAVA_TEST_RESULT" ||
+             ! ok "$SYSON_PLUGIN_RESULT" ||
              ! ok "$NODE_TEST_RESULT" ||
+             ! ok "$PDF_TOOLCHAIN_RESULT" ||
              ! ok "$PYTHON_TEST_RESULT" ||
              ! ok "$RACE_TESTS_RESULT" ||
              ! ok "$RUST_TEST_RESULT" ||
@@ -561,10 +640,13 @@ jobs:
              ! ok "$VSCODE_EXTENSION_RESULT"; then
             echo "changes=$CHANGES_RESULT"
             echo "build=$BUILD_RESULT"
+            echo "cameo-plugin=$CAMEO_PLUGIN_RESULT"
             echo "conformance-pkg=$CONFORMANCE_PKG_RESULT"
             echo "docs=$DOCS_RESULT"
             echo "java-test=$JAVA_TEST_RESULT"
+            echo "syson-plugin=$SYSON_PLUGIN_RESULT"
             echo "node-test=$NODE_TEST_RESULT"
+            echo "pdf-toolchain=$PDF_TOOLCHAIN_RESULT"
             echo "python-test=$PYTHON_TEST_RESULT"
             echo "race-tests=$RACE_TESTS_RESULT"
             echo "rust-test=$RUST_TEST_RESULT"
@@ -573,6 +655,80 @@ jobs:
             exit 1
           fi
 
+  # The PDF backend drives external tools (WeasyPrint, pandoc, mermaid-cli,
+  # KaTeX, Graphviz, the PlantUML jar) as subprocesses; the Go suite above
+  # tests that contract with fakes. This job runs the integration tests
+  # against the pinned toolchain, with an absent tool a failure, not a skip.
+  pdf-toolchain:
+    name: PDF toolchain tests
+    needs: changes
+    if: needs.changes.outputs.go == 'true'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    timeout-minutes: 20
+    env:
+      OPENSYSML_REQUIRE_PDF_TOOLCHAIN: "1"
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v4
+
+      - name: Set up Go
+        uses: actions/setup-go@v5
+        with:
+          go-version-file: go.mod
+          cache: true
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: '22'
+
+      - name: Set up Python
+        id: setup-python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.12'
+
+      - name: Set up Java
+        uses: actions/setup-java@v4
+        with:
+          distribution: temurin
+          java-version: '21'
+
+      # Keyed on the provisioning script, whose pins and digests are the
+      # toolchain's identity; the venv is not restored across Python builds.
+      - name: Cache the PDF toolchain
+        id: toolchain-cache
+        uses: actions/cache@v4
+        with:
+          path: build/doc-pdf
+          key: doc-pdf-toolchain-${{ runner.os }}-${{ hashFiles('scripts/download-doc-pdf-toolchain.sh') }}-py${{ steps.setup-python.outputs.python-version }}
+
+      # WeasyPrint's venv links against the system's Pango, the browser
+      # mermaid-cli launches needs its shared libraries, Graphviz's neato
+      # layout plugin needs GTS; the tests read PDFs back with poppler-utils.
+      - name: Install the toolchain's system libraries
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y --no-install-recommends libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 \
+            libnss3 libatk-bridge2.0-0 libgbm1 libxkbcommon0 libasound2t64 fonts-dejavu-core libgts-0.7-5 \
+            poppler-utils
+
+      - name: Provision the PDF toolchain
+        run: ./scripts/download-doc-pdf-toolchain.sh
+
+      - name: Run the PDF integration tests
+        env:
+          OPENSYSML_PANDOC: ${{ github.workspace }}/build/doc-pdf/pandoc-3.10.2/bin/pandoc
+          OPENSYSML_WEASYPRINT: ${{ github.workspace }}/build/doc-pdf/weasyprint/bin/weasyprint
+          OPENSYSML_MMDC: ${{ github.workspace }}/build/doc-pdf/mermaid/node_modules/.bin/mmdc
+          OPENSYSML_MMDC_PUPPETEER: ${{ github.workspace }}/build/doc-pdf/mermaid/puppeteer.json
+          OPENSYSML_KATEX: ${{ github.workspace }}/build/doc-pdf/katex/node_modules/.bin/katex
+          OPENSYSML_DOT: ${{ github.workspace }}/build/doc-pdf/graphviz/bin/dot
+          OPENSYSML_PLANTUML_JAR: ${{ github.workspace }}/build/doc-pdf/plantuml/plantuml-1.2026.8.jar
+        run: go test -count=1 -v -run Installed ./internal/doc/docpdf
+
   vscode-extension:
     name: VS Code extension
     needs: changes
@@ -604,6 +760,40 @@ jobs:
           path: editors/vscode/opensysml-sysml.vsix
           if-no-files-found: error
 
+  cameo-plugin:
+    name: Cameo plugin
+    needs: [changes, build]
+    if: needs.changes.outputs.cameo == 'true'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    timeout-minutes: 15
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/download-artifact@v4
+        with:
+          name: binaries
+          path: bin
+
+      # 17 is the plugin's compile target (the oldest supported Cameo runs on it).
+      - uses: actions/setup-java@v4
+        with:
+          distribution: temurin
+          java-version: '17'
+          cache: maven
+
+      - name: Make the service binary executable
+        run: chmod +x bin/sysml-grpc
+
+      - name: Install the Java client the plugin builds against
+        run: mvn -B -q -f client/java/pom.xml -pl opensysml-client -am install -DskipTests
+
+      # Compiles against the compile-only OpenAPI stubs and runs the unit tests plus
+      # the pipeline test against the freshly built service.
+      - name: Build and test the plugin
+        run: mvn -B -f editors/cameo/pom.xml verify -Dopensysml.requireService=true
+
   docs:
     name: Documentation site
     needs: changes
@@ -655,7 +845,7 @@ jobs:
       # The census figures are derived from the committed baseline, and every
       # table row must be a constraint the baseline records (and vice versa).
       - name: Check the validation-constraint census matches its baseline
-        run: go run ./cmd/validation-census -check
+        run: go run -C tools ./cmd/validation-census -check
 
       # Changelog entries live in changes/unreleased/ until a release folds them in.
       - name: Check changelog fragments
@@ -665,6 +855,10 @@ jobs:
       - name: Check the compliance census hook
         run: python3 scripts/mkdocs_census-test.py
 
+      # The test-suite figures are counted when the site is built, never committed.
+      - name: Check the test-suite figures hook
+        run: python3 scripts/mkdocs_suite_figures-test.py
+
       # --strict: a link or anchor a moved page left behind fails here rather than
       # publishing a 404 from main.
       - name: Build documentation site
@@ -694,7 +888,7 @@ jobs:
         with:
           node-version: '22'
           cache: npm
-          cache-dependency-path: clients/node/package-lock.json
+          cache-dependency-path: client/node/package-lock.json
 
       # The tests and the conformance runner start private children of this
       # binary; without it they would build one from the checkout, needing Go.
@@ -704,33 +898,33 @@ jobs:
           echo "OPENSYSML_BINARY=$(pwd)/bin/sysml-grpc" >> "$GITHUB_ENV"
 
       - name: Install the client's dependencies
-        working-directory: clients/node
+        working-directory: client/node
         # --ignore-scripts: no dependency of this client needs an install hook,
         # and one added upstream would run here with the workflow's token.
         run: npm ci --ignore-scripts
 
       - name: Build, typecheck and lint
-        working-directory: clients/node
+        working-directory: client/node
         run: |
           npm run build
           npm run typecheck
           npm run lint
 
       - name: Run the tests
-        working-directory: clients/node
+        working-directory: client/node
         run: npm test
 
       # --allow-skips because v1 covers 5 of the 15 RPCs; the skipped count is
       # reported and the artifact records which scenarios they were.
       - name: Run the conformance suite through the client
-        working-directory: clients/node
+        working-directory: client/node
         run: |
           npm run conformance -- --binary "$OPENSYSML_BINARY" --allow-skips \
             --report node-conformance.json
 
       # A runner that passes against a deliberately broken client tests nothing.
       - name: Check the conformance runner catches a broken client
-        working-directory: clients/node
+        working-directory: client/node
         run: |
           for mutation in hide-capability drop-diagnostics blank-symbol-kind shift-integer drop-feature-values; do
             if npm run conformance -- --binary "$OPENSYSML_BINARY" --allow-skips \
@@ -744,7 +938,7 @@ jobs:
       - uses: actions/upload-artifact@v4
         with:
           name: node-conformance
-          path: clients/node/node-conformance.json
+          path: client/node/node-conformance.json
 
       # Pinned by digest, as cvc5 is above, so a substituted asset is not run.
       - name: Install buf
@@ -760,8 +954,8 @@ jobs:
       - name: Verify the committed TypeScript stubs are current
         run: |
           make proto-ts BUF=buf
-          git add -N clients/node/src/generated
-          git diff --exit-code clients/node/src/generated
+          git add -N client/node/src/generated
+          git diff --exit-code client/node/src/generated
 
   python-test:
     name: Python client tests
@@ -853,8 +1047,8 @@ jobs:
           # (GRPC_GENERATED_VERSION), so a newer release cannot fail this check.
           pip install --only-binary :all: grpcio-tools==1.83.0
           make python-proto BUF=buf
-          git add -N clients/python/opensysml/proto
-          git diff --exit-code clients/python/opensysml/proto
+          git add -N client/python/opensysml/proto
+          git diff --exit-code client/python/opensysml/proto
 
   rust-test:
     name: Rust client tests
@@ -891,17 +1085,17 @@ jobs:
           cargo --version
 
       - name: Check Rust formatting
-        working-directory: clients/rust
+        working-directory: client/rust
         run: cargo fmt --all -- --check
 
       - name: Run Rust clippy
-        working-directory: clients/rust
+        working-directory: client/rust
         run: cargo clippy --all-targets -- -D warnings
 
       # A missing service must fail rather than silently skipping integration
       # tests, because the downloaded binary is required by this job.
       - name: Run Rust tests
-        working-directory: clients/rust
+        working-directory: client/rust
         env:
           OPENSYSML_GRPC_BINARY: ${{ github.workspace }}/bin/sysml-grpc
           OPENSYSML_REQUIRE_SERVICE: 1
@@ -913,12 +1107,12 @@ jobs:
         env:
           OPENSYSML_GRPC_BINARY: ${{ github.workspace }}/bin/sysml-grpc
         run: |
-          cargo run --manifest-path clients/rust/Cargo.toml -p opensysml-conformance -- \
+          cargo run --manifest-path client/rust/Cargo.toml -p opensysml-conformance -- \
             -binary "$GITHUB_WORKSPACE/bin/sysml-grpc" \
             -report bin/conformance-report-rust.json
 
       - name: Package Rust client
-        working-directory: clients/rust
+        working-directory: client/rust
         run: cargo package -p opensysml
 
       # The digest pins the generator binary so a substituted release cannot
@@ -934,8 +1128,8 @@ jobs:
       - name: Verify Rust generated artifacts
         run: |
           make BUF=/tmp/buf proto-rust
-          git add -N clients/rust/opensysml/src/proto clients/rust/conformance/sysml.descriptor.binpb
-          git diff --exit-code clients/rust/opensysml/src/proto clients/rust/conformance/sysml.descriptor.binpb
+          git add -N client/rust/opensysml/src/proto client/rust/conformance/sysml.descriptor.binpb
+          git diff --exit-code client/rust/opensysml/src/proto client/rust/conformance/sysml.descriptor.binpb
 
       - uses: actions/upload-artifact@v4
         with:
@@ -978,13 +1172,13 @@ jobs:
       # opensysml.requireService turns "no binary, so skip" into a failure, and
       # `install` builds the sources and javadoc jars a release needs on every commit.
       - name: Test and install the client
-        run: mvn -B -f clients/java/pom.xml install -Dopensysml.requireService=true
+        run: mvn -B -f client/java/pom.xml install -Dopensysml.requireService=true
 
       - name: Run the conformance suite through the client
         run: |
-          mvn -B -q -f clients/java/pom.xml -pl opensysml-conformance \
+          mvn -B -q -f client/java/pom.xml -pl opensysml-conformance \
             dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
-          java -cp "clients/java/opensysml-conformance/target/classes:$(cat /tmp/cp.txt)" \
+          java -cp "client/java/opensysml-conformance/target/classes:$(cat /tmp/cp.txt)" \
             org.openmbee.opensysml.conformance.Main \
             -binary bin/sysml-grpc -protocols connect,connect-json -allow-skips \
             -report conformance-report-java.json
@@ -1016,8 +1210,59 @@ jobs:
       - name: Verify the committed Java stubs are current
         run: |
           make proto-buf BUF=buf
-          git add -N clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
-          git diff --exit-code clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
+          git add -N client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
+          git diff --exit-code client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto
+
+  syson-plugin:
+    name: SysON plugin
+    needs: [changes, build]
+    if: needs.changes.outputs.syson == 'true'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    timeout-minutes: 20
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v4
+
+      - name: Download binary artifacts
+        uses: actions/download-artifact@v4
+        with:
+          name: binaries
+          path: bin
+
+      - name: Make the service binary executable
+        run: chmod +x bin/sysml-grpc
+
+      # SysON 2026.9.0 runs on 21; the module compiles at release 17.
+      - name: Set up Java
+        uses: actions/setup-java@v4
+        with:
+          distribution: temurin
+          java-version: '21'
+          cache: maven
+
+      - name: Install the client dependency
+        run: mvn -B -q -f client/java/pom.xml install -DskipTests -Dmaven.javadoc.skip=true
+
+      - name: Test and install the SysON backend
+        run: mvn -B -f editors/syson/pom.xml install -Dopensysml.requireService=true
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: '24'
+          cache: npm
+          cache-dependency-path: editors/syson/frontend/package-lock.json
+
+      - name: Build and test the SysON frontend
+        working-directory: editors/syson/frontend
+        run: |
+          npm ci --ignore-scripts
+          npm run typecheck
+          npm run format:check
+          npm test
+          npm run build
 
   conformance-pkg:
     name: Conformance suite
@@ -1045,7 +1290,7 @@ jobs:
       # The same suite against the legacy grpc-go transport, so -transport grpc
       # cannot rot untested.
       - name: Run conformance suite against -transport grpc
-        run: go run ./cmd/conformance -transport grpc -protocols grpc -report bin/conformance-grpc-transport-report.json
+        run: go run -C tools ./cmd/conformance -transport grpc -protocols grpc -report bin/conformance-grpc-transport-report.json
 
       # The same suite through the public Go API (client/opensysml); the pkg
       # protocol runs in-process and pkg-connect dials the service, so this proves
diff --git a/.github/workflows/syson-artifacts.yml b/.github/workflows/syson-artifacts.yml
new file mode 100644
index 0000000000..fb814afd4f
--- /dev/null
+++ b/.github/workflows/syson-artifacts.yml
@@ -0,0 +1,50 @@
+name: SysON plugin against real artifacts
+
+on:
+  workflow_dispatch:
+
+jobs:
+  syson-artifacts:
+    name: SysON plugin against real artifacts
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    timeout-minutes: 20
+    env:
+      GITHUB_ACTOR: ${{ github.actor }}
+      GITHUB_TOKEN: ${{ secrets.SYSON_PACKAGES_TOKEN }}
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v4
+
+      - name: Build the service binary
+        run: make build-grpc
+
+      # SysON 2026.9.0 runs on 21; the module compiles at release 17.
+      - name: Set up Java
+        uses: actions/setup-java@v4
+        with:
+          distribution: temurin
+          java-version: '21'
+          cache: maven
+
+      - name: Install the client dependency
+        run: mvn -B -q -f client/java/pom.xml install -DskipTests -Dmaven.javadoc.skip=true
+
+      - name: Build against the real SysON artifacts
+        run: mvn -B -s editors/syson/settings.xml -f editors/syson/pom.xml -Psyson-artifacts -P '!stubs' install -Dopensysml.requireService=true
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: '24'
+          cache: npm
+          cache-dependency-path: editors/syson/frontend/package-lock.json
+
+      - name: Build against the real Sirius packages
+        working-directory: editors/syson/frontend
+        run: |
+          npm ci --ignore-scripts
+          cp .npmrc.example .npmrc
+          npm run install:syson
+          npm run build:syson
diff --git a/.gitignore b/.gitignore
index 7a4cc08318..16ab8e708c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,7 +19,7 @@
 /examples/pilot-corpora/
 
 # Python
-/src/  # Legacy Python layout shadow directory (superseded by clients/python/)
+/src/  # Legacy Python layout shadow directory (superseded by client/python/)
 __pycache__/
 *.py[cod]
 *$py.class
@@ -36,15 +36,21 @@ coverage-python.xml
 coverage-scripts.xml
 .coverage-scripts
 coverage-node.lcov
-/clients/node/coverage/
+/client/node/coverage/
 .coverage
 
 # Generated stubs for the upcoming clients (make proto-ts / proto-rust)
 /gen/
-/clients/rust/target/
+/client/rust/target/
 
-# Maven build output for the Java client
-/clients/java/**/target/
+# Maven build output for the Java client and the Cameo plugin
+/client/java/**/target/
+/editors/cameo/**/target/
+/editors/syson/**/target/
+!/editors/cameo/dist/
+
+# Partial oracle record a failed `make fuml-expected` leaves for diagnosis
+/docs/project/fuml-referee-expected.json.failed
 
 # Windows VERSIONINFO resources, written by go-winres for GOOS=windows builds
 cmd/*/rsrc_windows_*.syso
diff --git a/AGENTS.md b/AGENTS.md
index bb86229aa8..b223d8e3a0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -39,8 +39,8 @@ Raw equivalents / targeted runs:
 go build ./...                                   # must always be clean
 go vet ./...                                     # must be clean (catches unused/dead code)
 go test ./...                                    # all tests
-go test ./internal/core/runtime/...             # one package tree
-go test -run TestExecutionConformance ./internal/core/runtime
+go test ./internal/exec/runtime/...             # one package tree
+go test -run TestExecutionConformance ./internal/exec/runtime
 go test -race ./...                             # race detector (CI runs this)
 gofmt -l .                                      # must print nothing (CI enforces gofmt)
 ```
@@ -49,25 +49,35 @@ gofmt -l .                                      # must print nothing (CI enforce
 
 The OMG training-corpus gate is part of that suite but skips while the corpus is absent, so
 fetch it once with `./scripts/download-training-examples.sh` and re-run
-`go test -count=1 ./internal/core/model -run TestTrainingExamples`. CI downloads the corpus
+`go test -count=1 ./tests/corpus -run TestTrainingExamples`. CI downloads the corpus
 too and sets `OPENSYSML_REQUIRE_TRAINING_CORPUS=1`, so there an absent corpus fails rather
 than skips.
 
 The three OMG pilot corpora are gated the same way: fetch them with
 `./scripts/download-pilot-corpora.sh` and run
-`go test -count=1 ./internal/core/model -run TestPilotCorpora`. CI sets
+`go test -count=1 ./tests/corpus -run TestPilotCorpora`. CI sets
 `OPENSYSML_REQUIRE_PILOT_CORPORA=1`. See `docs/project/pilot-corpora.md`.
 
-All four roots share one mechanism (`internal/core/model/corpus_gate_test.go`) but two
+So is the pilot's XMI of the standard library, which the identity gate reads: fetch it with
+`./scripts/download-pilot-library-xmi.sh` and run
+`go test -count=1 ./tests/identity -run TestPilotLibraryXMI`. CI sets
+`OPENSYSML_REQUIRE_PILOT_LIBRARY_XMI=1`. So is the OMG PSSM test suite, which the SysML v1
+migrator is gated over: fetch it with `./scripts/download-pssm-suite.sh` and run
+`go test -count=1 ./tests/corpus -run TestPSSMSuiteMigration`. CI sets
+`OPENSYSML_REQUIRE_PSSM_SUITE=1`. See `docs/project/pssm-migration.md`. Whatever sets a require
+variable must run the matching download script first; the scripts are idempotent, and none
+reports success over an empty corpus.
+
+All four roots share one mechanism (`tests/corpus/corpus_gate_test.go`) but two
 policies, and the difference is deliberate: the training corpus is **asserted** clean, so its
 expectation file holds no per-file counts and `-update-training` refuses to record one, while
 the other three are a **per-file ratchet** whose every movement must be adjudicated. Do not
 turn the assertion into a ratchet.
 
 The RDF mapping has a per-file ratchet of its own over every model under `examples/`, the
-downloaded corpora included: `TestCorpusRoundTrip` in `internal/core/export` converts each file
+downloaded corpora included: `TestCorpusRoundTrip` in `tests/corpus` converts each file
 notation → Turtle → notation → Turtle and pins the verdict. Run it with both require variables
-set after any change to `internal/core/export`, adjudicate every movement, then regenerate with
+set after any change to `internal/translate/export`, adjudicate every movement, then regenerate with
 `-update-corpus-roundtrip`. See `docs/project/rdf-corpus-roundtrip.md`.
 
 ---
@@ -78,22 +88,24 @@ set after any change to `internal/core/export`, adjudicate every movement, then
 cmd/
   sysml/                 REPL binary
   sysml-lsp/             LSP server binary
-internal/core/
-  source/                source files, spans, line indexing
-  lexer/                 hand-written scanner (~200 keywords)
-  parser/                recursive-descent parser (never panics; emits ErrorNodes)
-  ast/                   syntax tree nodes — IMMUTABLE after parse
-  symbols/               symbol tables, scope trees
-  resolve/               lazy, memoized name resolution
-  semantics/             type system, conformance, multiplicity, const-folding eval
-  passes/                tiered validation (syntax → nameres → type → constraint)
-  lower/                 AST → execution IR (ActionGraph, StateGraph) for the runtime
-  runtime/               execution engine (eval, instances, action/state executors)
-  model/                 workspace / document management
-  libs/                  stdlib bundling + conformance gate
-internal/lsp/            LSP protocol implementation
-internal/repl/           REPL loop
-testdata/                shared fixtures (.sysml, .kerml, .golden)
+internal/                one directory per layer; a package imports only the layers below it
+  syntax/                source files and spans, diagnostics, lexer, parser, AST, pack, format
+  semantic/              symbol tables, name resolution, suggestions, semantics, identity, highlight, query
+  ir/                    lowered execution IR (ActionGraph, StateGraph), query/doc plans, views
+  check/                 tiered validation passes (syntax → nameres → type → constraint), workspace edits
+  exec/                  execution engine, SMT solving, analysis framework and engines
+  translate/             RDF, XMI and notation conversion, code generation, interop
+  doc/                   query execution, document IR, Markdown/HTML/PDF backends
+  workspace/             workspace and document management, stdlib bundling, project files, env vars
+  frontend/              LSP, REPL, gRPC and stdio transports, protobuf conversion, usage
+tests/                   black-box suites and their fixtures
+  hygiene/               module-wide checks (no production code imports testing)
+  perf/                  benchmark harness (go test ./tests/perf -run '^$' -bench .)
+  testutil/              gobuild (build a command under test), graphcmp (pointer-graph comparison)
+  parser/                golden ASTs (TestGolden, -update) and negative cases, with testdata/parse
+  grpc/                  gRPC conformance cases (TestGRPCConformance) driven over the RPC surface
+  export/, resolve/, …   external-package (package x_test) suites, each beside its own testdata
+  testdata/              shared fixtures (.sysml, .kerml, .golden)
 examples/                example models and demos
 docs/                    guide/ (handbook), reference/, internals/, project/ (status)
 ```
@@ -104,11 +116,11 @@ Read `docs/internals/architecture.md` before non-trivial work — it documents t
 
 ## 4. Architecture Invariants (do not violate)
 
-- **Immutable AST.** `internal/core/ast` is syntax-only and is never mutated after parsing. All derived/semantic data lives in **side tables keyed by node/symbol**.
+- **Immutable AST.** `internal/syntax/ast` is syntax-only and is never mutated after parsing. All derived/semantic data lives in **side tables keyed by node/symbol**.
 - **Parser never fails.** `parser.New(src).ParseFile()` always returns a tree; malformed input yields `ErrorNode`s + diagnostics, never a panic.
 - **Lazy + memoized semantics.** Name resolution and type queries compute on demand and cache. Don't force eager work.
 - **Tiered passes.** Higher validation tiers are skipped when a lower tier errors, unless a pass declares `passes.ElementScoped` and gates itself per subject via `Context.DownstreamOfFailure`. Keep passes independent and level-scoped.
-- **Runtime consumes lowered IR.** Executors should operate on `internal/core/lower` graphs (`ActionGraph`/`StateGraph`) as the single source of truth — do **not** re-parse `symbol.Decl` inside executors, and do not build parallel/duplicate structures that can drift. Lowering must be lossless (carry guards, triggers, effects, pseudostate edges).
+- **Runtime consumes lowered IR.** Executors should operate on `internal/ir/lower` graphs (`ActionGraph`/`StateGraph`) as the single source of truth — do **not** re-parse `symbol.Decl` inside executors, and do not build parallel/duplicate structures that can drift. Lowering must be lossless (carry guards, triggers, effects, pseudostate edges).
 - **Error timing is part of the contract.** Constructors (`newActionExecutor`, `newStateExecutor`) succeed on structurally-empty inputs; "no initial node/state" errors surface at `initialize()`. Don't move error points without updating the corresponding tests intentionally.
 
 ---
@@ -117,23 +129,23 @@ Read `docs/internals/architecture.md` before non-trivial work — it documents t
 
 ### 5.1 Parser features — four-layer contract
 When touching the lexer/parser or adding grammar:
-1. **Conformance gate:** `go test -run TestStdlibConformance ./internal/core/libs` — all official stdlib files must still parse clean (no regressions).
-2. **Golden ASTs:** `go test -run TestGolden ./internal/core/parser`. Add a representative fixture under `internal/core/parser/testdata/parse/*.sysml`.
-3. **Negative tests:** `go test -run TestNegative ./internal/core/parser` — malformed input must produce diagnostics without panicking.
-4. **Update goldens only after intentional changes:** `go test -run TestGolden -update ./internal/core/parser`, then review the diff carefully.
+1. **Conformance gate:** `go test -run TestStdlibConformance ./internal/workspace/libs` — all official stdlib files must still parse clean (no regressions).
+2. **Golden ASTs:** `go test -run TestGolden ./tests/parser`. Add a representative fixture under `tests/parser/testdata/parse/*.sysml`.
+3. **Negative tests:** `go test -run TestNegative ./tests/parser ./internal/syntax/parser` — malformed input must produce diagnostics without panicking.
+4. **Update goldens only after intentional changes:** `go test -run TestGolden -update ./tests/parser`, then review the diff carefully.
 
 ### 5.2 Behavioral features (actions/states/calc/constraints/requirements) — four-layer contract
-1. **Golden AST fixture** locking parse structure (`internal/core/parser/testdata/parse/`).
-2. **Execution conformance:** add `.sysml` + `.expected.json` under `internal/core/runtime/testdata/conformance/`; run `go test -run TestExecutionConformance ./internal/core/runtime`. Schema is documented in that dir's `README.md`.
-3. **Golden execution traces** for ordering-sensitive behavior (fork/join, transitions): `go test -run TestExecutionTrace ./internal/core/runtime` (update flag: `-update-traces`).
-4. **Robustness:** add a failure-mode case to `robustness_test.go` (deadlock, unbound params, missing refs, dangling transitions, step budget). Must return typed errors, never panic or hang.
+1. **Golden AST fixture** locking parse structure (`tests/parser/testdata/parse/`).
+2. **Execution conformance:** add `.sysml` + `.expected.json` under `internal/exec/runtime/testdata/conformance/`; run `go test -run TestExecutionConformance ./internal/exec/runtime`. Schema is documented in that dir's `README.md`.
+3. **Golden execution traces** for ordering-sensitive behavior (fork/join, transitions): `go test -run TestExecutionTrace ./internal/exec/runtime` (update flag: `-update-traces`).
+4. **Robustness:** add a failure-mode case (deadlock, unbound params, missing refs, dangling transitions, step budget) as a subtest of a `TestRuntimeRobustness<Feature>` function in `internal/exec/runtime/robustness_<feature>_test.go` — a new file for a new feature, so branches never edit one shared registry; `robustness_test.go` holds the shared cases and is not where new ones go. Must return typed errors, never panic or hang. The suite counters read every `TestRuntimeRobustness*` function, and gRPC cases follow the same pattern with `TestGRPCRobustness*`.
 
 Then update `docs/project/spec-compliance.md` mapping: semantic rule → implementation (file:function) → test → status (✅ faithful / ⚠️ approximate / ❌ not implemented / 🚧 known failure).
 
 ### 5.3 General
 - Unit tests live beside code as `*_test.go`, one concern per test.
 - Design/adjust tests **before or alongside** implementation; don't retrofit weak tests afterward.
-- Prefer real SysML models in `testdata/` over hand-built ASTs when exercising end-to-end behavior; hand-built ASTs are fine for targeted unit tests.
+- Prefer real SysML models in `tests/testdata/` over hand-built ASTs when exercising end-to-end behavior; hand-built ASTs are fine for targeted unit tests.
 
 ---
 
@@ -193,7 +205,7 @@ Bug-fix discipline (small, scoped diffs) does **not** apply to feature work. For
 
 **Do:**
 - **Implement the whole feature**, including the hard cases (nesting, hierarchy, orthogonal regions, error/edge paths), not just the happy path.
-- **Follow the layering.** Put logic in the correct layer (lexer → parser → lower → runtime). If a feature needs new IR, extend `internal/core/lower` losslessly rather than re-deriving data downstream.
+- **Follow the layering.** Put logic in the correct layer (lexer → parser → lower → runtime). If a feature needs new IR, extend `internal/ir/lower` losslessly rather than re-deriving data downstream.
 - **Refactor when the design requires it.** If the clean implementation needs a new type, an interface change, or migrating existing callers, do that — and migrate *all* callers, deleting the superseded code.
 - **Prefer completeness over diff size** every time the two conflict.
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ecaa7acfa..11a3aed684 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,1567 @@ release is described in [docs/project/releasing.md](docs/project/releasing.md).
 
 ## Unreleased
 
+## 0.9.0 — 2026-09-24
+
+### Added
+
+- **The analysis libraries' conformance is measured, per package, by a runtime test.** `TestAnalysisLibraryCensus` enumerates every public callable declaration of `SampledFunctions`, `TradeStudies`, `StateSpaceRepresentation`, `AnalysisTooling`, `VectorFunctions` and `OccurrenceFunctions`, invokes each through the runtime with a representative model, and records whether the value it produced passed its check, which typed error refused it, or what its value got wrong. The verdicts are committed to `docs/project/analysis-library-census.json`, which the test holds current, and `make docs-counts` renders them as the per-library table in `docs/project/spec-compliance.md`; `go run ./cmd/doc-counts -check` fails when the table and the file disagree. `StateSpaceRepresentation` is recorded as refused by name where its abstract dynamics need a state-space runner, not worked around.
+
+- **Added `examples/analysis-results-demo/`, a worked example of saving analysis runs into the model and reporting them in a document.** The records are `part` usages on the bundled `AnalysisRecords` vocabulary — the same shape `-record-run`/`%record` emits, written by hand to keep pre-edit values — and a generated report groups, filters and lists them, flags the record a later model edit made stale through a derived `drift`/`stale` pair, and contrasts them with `Verdicts` recomputed live at render time.
+
+- **`-convert` and `-from` accept `api-json`, the OMG SysML v2 API element form.** A model written as `api-json` is the JSON array of element objects the SysML v2 API serves — `"@type"`, `"@id"` and the metamodel properties as keys — over the same RDF graph the Turtle mapping builds, so `sysml`, `ttl` and `api-json` convert between each other losslessly; `.json` names the format by extension, and reading or writing it reports the RDF mapping's experimental status as Turtle does.
+
+- **Expressions are written in the KerML abstract-syntax shape.** An `InvocationExpression`, `ConstructorExpression`, `OperatorExpression`, `FeatureChainExpression`, `IndexExpression`, `CollectExpression` or `SelectExpression` now owns one `ParameterMembership` → `Feature` → `FeatureValue` per operand or argument, a `ReturnParameterMembership` → `Feature` for its result, and — for an invocation — a `Membership` whose member is the function it calls (the pilot's serialization of `instantiatedType`); a chained callee is the `FeatureChainExpression` it names, and a body argument is the anonymous `Expression` owned through a `FeatureMembership`, as in the pilot XMI. The collapsed `function`, `operator`, `argument` and `sysx:sourceText` properties stay beside them; the reader accepts either alone and refuses a graph where the two disagree. Graphs earlier releases wrote — a `->` receiver named by `operand` alone, `new` as `sysx:isConstructor` on an `InvocationExpression`, a `return` parameter under a plain `FeatureMembership` — still read.
+- **`-convert api-json` writes the pilot's root `Namespace`.** The element form opens with an unnamed `Namespace` (`<root>_ns`, a UUIDv5 under `-id uuid`) whose `OwningMembership`s own the document's top-level packages, as every `.kermlx` of the pilot does; the reader treats the wrapper as transparent, so notation → `api-json` → notation is byte-identical, and the Turtle form is unchanged.
+- **Views are written with the SysML v2 view metaclasses.** `ViewDefinition`, `ViewUsage`, `ViewpointDefinition`, `ViewpointUsage`, `RenderingDefinition`, `RenderingUsage`, `Expose`/`NamespaceExpose`/`MembershipExpose`, `ViewRenderingMembership`, `ElementFilterMembership` and `FramedConcernMembership` replace the collapsed view properties, and read back to the `view … { expose …; filter …; render …; }` they came from.
+- **Behavior nodes drop this project's extension metaclasses where the metamodel has a form.** `first start`/`then done` are memberships and successions to `Actions::Action::start`/`done`, `if` branches are `ParameterMembership`-owned parameters of the `IfActionUsage`, a state's `entry`/`do`/`exit` is a `StateSubactionMembership` with the normative `kind`, a transition's effect a `TransitionFeatureMembership` of kind `effect`, an alias a `Membership` with `memberName`, and a named multiplicity a `MultiplicityRange`. Graphs written with the older `sysx:InitialNode`, `sysx:FinalNode` and `sysx:IfBranch` still read. `sysx:Pseudostate`, `sysx:DeferMember` and `sysx:ActionExecutionNode` remain, since the metamodel has no element for the notation they carry; `docs/reference/rdf-mapping.md` says why.
+- **`-convert sysml -from api-json` reads more of sysml-toolkit's interchange.** A `then` succession whose source is the member before it, a metadata usage that references `ModelingMetadata::Refinement`, the unnamed chain `Feature` (`FeatureChaining`s) an expression reaches or invokes, a membership an expression node owns, the toolkit's `StateSubactionMembership`s and `TransitionFeatureMembership`s, and a full-JSON implied relationship whose derived `relatedFeature`/`chainingFeature` list is ordered differently from its owned ends all decode — the owned structure is authoritative and the derived list is read from it, since a derived property cannot contradict what it is derived from.
+
+- **`-convert api-json` and `-convert ttl` materialize the relationship elements the notation implies.** Typings, specializations, subsettings, redefinitions, multiplicity ranges, conjugated-port definitions, subject/constraint/result/filter memberships and referent memberships are now written as the first-class elements the SysML v2 metamodel defines — `FeatureTyping` (`<S>_ft0`), `Subclassification`, `Subsetting`, `ReferenceSubsetting`, `Redefinition`, `MultiplicityRange` (`<S>_mult`), `ConjugatedPortDefinition`/`PortConjugation` (`<S>_conjugated`, `<S>_pc`), `SubjectMembership`, `RequirementConstraintMembership`, `ResultExpressionMembership`, `ElementFilterMembership` and the `Membership` an expression's `referent`/`targetFeature` is carried by — beside the collapsed properties that already stated them, so element-count comparisons against sysml-toolkit's interchange JSON match and the toolkit's lifter reads the output back to notation. A declared relationship member is owned through an `OwningMembership` like any other member.
+- **`-convert sysml` reads sysml-toolkit interchange JSON.** Both `convert --to compact-json` and `convert --to full-json` decode through `ReadAPIJSON`, to byte-identical notation: the toolkit's root `Namespace`+`OwningMembership` wrapper is transparent, `{"@ref": <name>}` and `unresolved:`-derived targets read as the name they spell, stated defaults collapse back off `isImpliedIncluded` elements, and ends the notation cannot place are refused rather than guessed.
+- **`-convert` takes `-id uuid`.** Passing `-id uuid` to `-convert ttl` or `-convert api-json` mints name-based uuids the way the SysML v2 library convention does — `uuid5(NamespaceURL, elementIRI(root))` for a root package, `uuid5(pkg, <the qualified-form id>)` for every derived subject under it — while declared and normative ids are never re-derived. Decoding a uuid-form document reproduces the same notation with the ids implied, and only a non-matching `elementId` stays a declared `@ElementId` annotation.
+
+- **A design note on surface parity** (`docs/internals/design/api-surface-parity.md`). It
+  inventories what the REPL, the CLI, the editor, the public Go package and the wire each expose,
+  sorts every difference as shared already, missing and worth adding, interactive, protocol-bound
+  or local-only, and stages the work: one assembly per operation that all four surfaces call; the
+  stateless operations the wire lacks (satisfiability, model-checker options, inline replay, view
+  rendering, library search, codegen and the XMI migration report through `Convert`); a session
+  API for the action and state debuggers designed apart from the stateless calls; and a `repl`
+  protocol in the conformance runner so the agreement is tested rather than claimed. The binaries
+  keep calling the engine in-process; nothing is implemented, the note exists to be reviewed
+  before code is written.
+
+- **Binding and non-message flow usages now materialize as connector objects.** Their ends hold the connected feature values through the same connector path used by `connect` usages; message flows and one-ended bindings remain excluded.
+
+- **The checker warns (`undefined-operator`) on every use of the unary `~` operator.** KerML 1.0 §8.2.5.8.1 leaves `~` abstract and undefined, asking a tool for exactly this warning; the runtime keeps refusing it with a typed error, and the design record `docs/project/bitwise-complement.md` explains why no value is given.
+
+- **A design note for a Cameo Systems Modeler plugin** (`docs/internals/design/cameo-plugin.md`).
+  It answers, from the vendor's public documentation and Javadoc, how a plugin for Cameo 2026x
+  Refresh1 (bundled JDK 21; 2024x Refresh3 on JDK 17 as the minimum) is declared, loaded and
+  distributed; where it contributes browser and
+  diagram actions, a docking results panel and progress with cancel; how a selection leaves the
+  tool (a saved `.mdzip` today, since no dialog-free OMG XMI 2.5 export was found in the OpenAPI);
+  how a Cameo `xmi:id` maps to the SysML v2 name the migration writes through the per-element
+  migration report, and what the service must return for that; how verdicts land on elements
+  through annotations or a custom table; what the Simulation Toolkit covers and what OpenSysML
+  adds; and how the release's own SysML v2 project type, textual import/export and v1-to-v2
+  transformation change what OpenSysML parses (none of these was found for 2024x Refresh3). A benchmark
+  converts twenty public SysML v1 models — the repository's XMI fixtures, Cameo `.mdzip` projects
+  and Papyrus models — and tabulates mapped, approximated, unmapped and skipped elements. It closes
+  with the proposed `editors/cameo/` layout and build, the *Run with OpenSysML* sequence, a phased
+  plan and the risks, every claim that could not be verified marked as such. Nothing is
+  implemented; `editors/README.md` now lists the entry.
+
+- **A Cameo Systems Modeler plugin runs models on OpenSysML** (`editors/cameo/`). Right-clicking
+  an element in the containment tree or on a diagram offers an *OpenSysML* group with Instantiate,
+  Execute action, Execute state machine, Verify requirement/constraint, Evaluate calc and Run
+  analysis. A SysML v2 project (Cameo 2026x) is exported through the textual notation service; a
+  SysML v1 project is saved as a `.mdzip` and migrated through `Convert`; either is parsed and run
+  on `sysml-grpc` through the Java client, off the event thread with progress and cancel. Outcomes,
+  diagnostics, final time and the state schedule appear in a docking *OpenSysML Results* window
+  (double-click selects the element in the browser) and as validation annotations on the elements,
+  matched by qualified name on both paths. The module compiles against compile-only stubs of the
+  OpenAPI so CI needs no licence; `CAMEO_HOME` compiles it against a real installation, and the
+  `dist` build stages the digest-pinned service binaries for every platform into a Resource
+  Manager zip.
+
+- **A persistent session in the public Go API.** `opensysml.OpenSession` opens a `Session` over
+  a model a `New` client parsed: an interactive run that keeps its clock, its scheduling policy
+  and the objects it instantiated between calls, where `ExecuteAction` and `ExecuteState` run a
+  whole behaviour and return. `SetSchedule` governs the turns from then on, the machines and
+  clock already running included (`runtime.Context.Reschedule`), where they stand kept.
+  `Instantiate` makes an object and starts the state machines it
+  exhibits; `ActiveStates` and `Transitions` say where each machine stands and what could fire
+  next, by name; `Accepts` says whether a signal would be taken, read from the machines dispatch would let take it
+  — one whose guards all fail yields it to a sibling that would fire on or defer it — whether a
+  transition is triggered by it and whether a guard holds now;
+  `Send` posts it and `Advance` dispatches it, completion transitions included; `Perform` runs an
+  action on the object and reports its outputs, the `ChoicePoint`s the schedule resolved and the
+  `Branch` each decision left by, with `TurnedAway()` for an action that declined at its opening
+  decision; `Feature`, `SetFeature`, `Evaluate` and `Members` read and write the state the runs
+  left. Every answer is a fact copied out of the engine, never one of its graphs or objects, and
+  misuse — a closed session, a signal no transition accepts, an unknown action, an exploration
+  policy — is a typed refusal. The session is opened from a `Client` but is not part of the
+  `Client` interface: a `Dial` client refuses it with `CodeUnimplemented`, because the service
+  exposes no RPC for state held between calls, and the parity contract on `Client` is untouched.
+  The [Legend of the Red Dragon browser game](https://github.com/Open-MBEE/SysML-LoRD), a
+  program on this surface compiled to WebAssembly, is its first client.
+
+- **The SysML v1 migrator writes a composite state's entry and exit points.** A UML `State.connectionPoint` pseudostate, on a nested composite state or on one with orthogonal regions, is a `junction` of the state, reached by path (`then Work::start;`, `first Work::leave then Idle;`), so the runtime runs the state's entry behavior before the entry point's outgoing transition and the transition into the exit point before the state's exit behavior, the order UML and PSSM give connection points. An entry point whose transitions each start a region of an orthogonal state is a `fork`, an exit point its regions reach from each side a `join`, and an entry point no transition leaves is the state's default entry. An entry point leading straight to an exit point of the same state, a route from a connection point on into a history pseudostate, and the other shapes with no faithful form are refused with the shape named in the report; before, every connection point on a state and every transition through it was unmapped. The OMG PSSM test suite's connection points, which the report refused wholesale, migrate under this rule, and the migrated suite validates with no syntax errors.
+
+- **Diagram layout is written into the document that declares what holds it, across the workspace.** `opensysml/applyModelEdit`'s `setLayout`, `setRoute` and `setCanvas` resolve their target through the workspace index rather than the requesting document alone: a view-local `Layout` or `Route` and a `Canvas` go into the view's body in the view's document, an inline `Layout` or `Route` into the element's body in the element's document, whichever document the panel renders. The answer is one `WorkspaceEdit` with a versioned `TextDocumentEdit` per document changed, validated together as rename and delete are; a bundled library file, or a document the index holds without its source, is refused as `referenced-elsewhere` naming the file. A rendering's node or edge that another document declares carries its `fqn`, `owners` or `declaration` as any does, with `origin.uri` naming the document, and a `setLayout` or `setRoute` by declaration takes `declaredIn` to say which document the range is one of. The VS Code diagram panel drags a node another file declares: the annotation lands in the right file, the edit is applied only while every file it names is at the version it was computed against, and one <kbd>Ctrl</kbd>+<kbd>Z</kbd> reverts every file. Server and extension each advertise the contract as `openSysmlCrossDocumentLayout`, and each treats the other's lack of it as the single-document contract before, so an older extension is handed no other file's name to place unpinned and an older server's nodes keep every editing action.
+
+- **`RelatedElements` traverses requirement derivation and refinement.** The relationship kinds `"derivation"` and `"refinement"` join the eight the query engine already walked, in both directions and chained to `maxDepth` like the others. A `derivation` edge runs from an original requirement to each requirement derived from it, read from every notation of a `RequirementDerivation::Derivation`: a connection usage typed by it or written `#derivation`, whose ends state their roles by subsetting `originalRequirements`/`derivedRequirements`, by the `#original`/`#derive` metadata, or by the ends of the definition typing them, an end stating no role taking the one left over (the first such end is the original unless another end is, the rest are derived); and a `connection def` specializing `Derivation` whose ends are typed by requirement definitions, as the v1 migrator writes, which relates those definitions. A `refinement` edge runs from each client to each supplier of a `dependency` annotated `@ModelingMetadata::Refinement` (prefix or body form); a plain connection or dependency states neither kind. The query cookbook gains "Derive relationships" and "Refine relationships" recipes over an extended `cookbook.sysml`.
+
+- **The VS Code diagram is open by default.** A `.sysml` or `.kerml` file shown in an editor gets its diagram beside it without being asked, focus staying in the text; the diagrams of all files share one editor group, so switching files adds a tab there rather than a column. A diagram the user closes stays closed for that file — across editor switches and window reloads — until `SysML: Open Diagram` asks for it again. `opensysml.diagram.autoOpen` (default `true`) turns the automatic opening off.
+
+- **The VS Code diagram moves a declaration when a node is dropped on another with
+  <kbd>Shift</kbd> held.** While <kbd>Shift</kbd> is down, the node under the dragged one is
+  outlined when its body admits the dragged declaration — the same admission the node menu's
+  **Move to…** applies — and the status line says what releasing does; a node that cannot hold
+  it is not outlined and releasing there puts the node back with the reason. Releasing writes
+  the declaration's new position and its move as one `applyModelEdit` request, so one
+  <kbd>Ctrl</kbd>+<kbd>Z</kbd> undoes both, and the diagram redraws it under its new owner where
+  it was dropped; a move the server refuses — a name already taken, a declaration another file
+  refers to — is shown in the status line and the node goes back. A drag without
+  <kbd>Shift</kbd>, or released over empty canvas, writes a `Layout` as before.
+
+- **The VS Code diagram panel opens a document that declares several views intelligently, and can show several of them at once.** `SysML: Open Diagram` picks the view whose declaration holds the editor's cursor, else the one last chosen for that document in the workspace, else asks — a quick pick of the drawable views by name and kind, an **All views** entry that opens each in its own panel, and the pseudo-views last; a view the server cannot draw is left out of the list (the panel's own picker still shows it, disabled, with the reason), and cancelling opens nothing. A document may have one panel per view, titled `Diagram: <file> — <view>` while there are several; each redraws on change, highlights the cursor's node, and comes back on its view after a reload. Opening a view a panel already shows reveals it; a panel's picker retargets that panel unless another already draws the view, which is revealed instead. Documents declaring zero or one drawable view open as before, without a prompt. A diagram that opens on its own follows the same choice up to the quick pick and opens nothing rather than ask; closing a document's last panel is what keeps it closed. `opensysml/views` now carries each declared view's optional `range` and `selectionRange`, which the client uses for the cursor step and older servers may omit.
+
+- **The VS Code diagram panel draws in the pilot visualizer's style, and in colour, on request.** A **Style** list in the panel's toolbar, backed by the `opensysml.diagram.style` setting, picks the look of every diagram: `theme` (the default) follows the VS Code colour theme as before; `pilot` is the pilot's Standard B&W that the DOT and PlantUML forms already follow — white canvas, black sans-serif text, thin dark borders, square definitions and rounded usages, heavier packages, dashed regions, bold names over an italic keyword, thick arrowless connections, filled pseudo-states; and each of the eight colourblind-safe palettes (`okabe-ito`, `tol-bright`, `tol-muted`, `tol-light`, `brewer-set2`, `brewer-dark2`, `viridis`, `cividis`) is that look filled by keyword family, a usage a lighter tint of its definition's colour, text black. Changing the list keeps the choice in the settings and redraws every open diagram. The colours come from the server: `opensysml/render` with a `palette` now gives each node its `fill` and `border`, the same hex the DOT and PlantUML forms of that view take, advertised as `openSysmlRenderPalette`; against an older `sysml-lsp` a palette draws as `pilot` and the panel says why.
+
+- **A due `do` step against the dispatch due at the same instant is a recorded choice point.** A state machine ran every due `do` behavior one step before it dispatched the occurrence at the head of its pool, with no choice recorded for `explore` to vary. Under `check`, `replay` and `explore` the machine now runs one unit at a time — one step of one due `do` action of the round under way, or the dispatch — and the draw is a choice point (`choice at t=<instant>: next do <state>, dispatch <event> (unordered; took do <state> first)`; `dispatch change <condition>` for a change trigger risen; a message in flight is delivered behind the events already queued, so it is drawn as `dispatch accept <signal>` once nothing is ahead of it and hidden by a queued event nothing accepts until the round closes) written to the trace and the witness, replayed, refused and rolled back with its move, and enumerated by `explore` and `check`, whose moves offer the due `do` steps and the dispatch together. Only a dispatch that would take its occurrence — fire a transition, or let a `do` behavior parked at an `accept` go on — is drawn; one that would defer or drop it waits for the round to close, and events tied at the head are judged one by one, so a trigger the dispatch would drop hides no tied trigger it would fire. The fixed policies (`declared`, `reverse`, `seed:<n>`) finish the round before they dispatch, so no event order under a fixed policy moved and no existing trace golden changed. The grain is the token move: a due `do` behavior — an inline body, a behavior given as an action, a loop, a nested perform — advances one token, and the draw is made again after every move while a `do` behavior is due, so a dispatch may cut the flow anywhere or wait for it to rest, a body parked at an `accept` offers no move until its occurrence is dispatched, and the fixed policies' whole round then the dispatch is one path of the enumeration — `check` reports *exhaustive* where it has every path and no longer names a run left out. A control node with a body of its own (`fork split { assign x := 1; }`) performs it as the token passes, so it is a move the dispatch is drawn on either side of; only a bodiless control node over unguarded successions routes silently. Seven conformance cases state the admissible sets (`state_do_step_or_dispatch`, `state_do_step_among_completions`, `state_do_step_or_tied_dispatch`, `state_do_step_cuts_typed_do`, `state_do_step_cuts_nested_perform`, `state_do_step_cuts_control_node_body`, `state_do_action_loop_timed_exit`, the last two searched to completion over their five and four outcomes), and the runtime showcase's spacecraft, checked as vehicle and ground station together, reaches the fixed policies' `battery = 39` beside the cut sweeps' `41`. The PSSM referee moves *Behavior 003 A* to `pass`, reaches the do step's admitted places in *Terminate 002* and *Transition 017*, and moves *Exiting 002* to `fail` on a trace the suite registers for the same race one test earlier and not for this one, recorded as the suite's in `docs/project/omg-issues.md` (51 pass, 13 fail, 38 not expressible, 1 differs by design).
+
+- **A DocGen «Image» over an activity or state machine diagram is drawn in the migrated document.** A `Diagram` block whose `source` is a migrated `StandardViewDefinitions::ActionFlowView` or `StateTransitionView` is written and rendered as the graph the view draws, positioned by its MTIP layout when `-layout` was given; drawability is decided by the same rule that chose the view's form, so only a diagram whose view renders as textual notation — a sequence diagram, whose Interaction is written as a scenario rather than as the occurrence parts a `SequenceView` draws — is refused, with that reason.
+- **The migrator reads what an `.mdzip` diagram draws, and leaves an empty figure out.** Every diagram is read from the archive entry its `binaryObject` names, MagicDraw's serialization of the diagram's symbols: the elements the symbols stand for are shown and exposed, with the elements of the tool's used-element list a symbol displays without one of their own, by standing for an element they are owned under; a listed element no symbol displays is dropped, and symbols standing for none — a pasted image, a text box, a note — are counted as free content, so a stream whose symbols name no element shows nothing, whatever the list names. An «Image» over a diagram that shows nothing writes no `Diagram`, so the rendered document holds no empty figure; the step is reported with what the diagram draws and its caption stays as a paragraph, as DocGen shows it.
+- **DocGen collectors and filters gain their query spellings.** A chain follows the source elements it collects, so `CollectOwnedElements` gathers the diagrams an exposed package or block owns, as DocGen finds its figures; `FilterByDiagramType` keeps the diagrams of the presentation types named; `CollectThingsOnDiagram` names the elements the collected diagrams show, and is refused when any of them names a stream the archive does not hold or holds unreadable, since what it shows beyond the tool's list is then unknown; a fork's branches carry on or end the doubt a step before it leaves, so the rejoined step knows what it draws when every branch names its own targets; `CollectByAssociation` names the types reached through attributes of the aggregation kind to the depth asked; a sort by name or documentation orders the diagrams, and the elements they are collected from, as `OrderBy` orders the rows; `FilterByNames` reads the v1 name as DocGen does, naming the elements it keeps where the v2 name an anonymous element gains would match otherwise, and a step with no query spelling refuses the «Image» after it as it does every other block. A diagram's own comment, its documentation in the tool, is written as its view's `doc`. A requirement's `Id` and `Text` columns are its `shortName` and `documentation`. An «Image» whose chain holds no diagram, and a step over a stereotype with neither a v2 metaclass nor a `metadata def`, a `MonteCarloAnalysis` statistic column, or a chain whose elements are known only when the query runs, are reported with the reason rather than refused blindly.
+- **A `uml:Expression` tree that spells nothing is skipped as notation.** A constraint whose specification has no symbol at any node and, as leaves, only `InstanceValue`s naming no instance (Cameo Collaborator's presentation constraints on a «Document») carries nothing to translate and is skipped as notation-only wherever it stands; a tree with a symbol or a leaf naming an instance is translated or refused as before.
+
+- **Three convention themes for `-html-theme`: `nasa`, `ieee` and `acm`.** Each follows a published manuscript convention — the NASA STI Report Series (Times 12pt body, Arial headings, tables and captions, letter page with one-inch margins, roman-numbered front matter), IEEE Transactions (Times 10pt body, 8pt captions and tables, centred small-caps section heads, italic subheads, justified with a one-pica indent, letter page with 0.67in margins) and ACM's `acmart` (Libertine 10pt body falling back to Times, bold sans numbered heads, 9pt captions, letter page) — black on white, with thin horizontal table rules, and sets the same faces and point sizes on screen as on paper so a page and its PDF agree. IEEE and ACM output is single-column; the sources, the values verified against them and the choices made where a convention is silent are recorded in `docs/project/html-document-backend.md`.
+- **A theme now governs the PDF page.** A bundled theme may carry a print companion, `themes/<name>.print.css`, which the PDF backend lays over its print stylesheet in a third cascade layer, `opensysml-print-theme`, so the theme's page size and margins, faces, body size, heading scale, caption and table sizes and page-number footer reach paper instead of being overwritten by the print sheet's defaults; the `print` and `report` themes carry one too, so `report`'s Charter/Georgia stack and larger body now print. The order is the default sheet and theme, the print sheet, the theme's companion, then `-html-css` sheets unlayered; `-html-no-default-css` leaves every bundled sheet out, and the pandoc engine still refuses `-html-theme`.
+
+- **Document queries read the objects a session holds.** A `%run-query`/`-run-query` parameter
+  written as a usage's name binds the object the session holds under it while it holds one
+  (`car` after `%instantiate car`), `#2` binds an object by id and `car.wheels[2]` a nested one by
+  path, and the element as before when nothing is held. Every query operation that takes an
+  element takes an object and reads what it holds: `OwnedElements` and `Descendants` are the
+  objects it holds as parts, each element of a collection under its own path (`wheels[1]`,
+  `wheels[2]`), `Ancestors` the objects holding it, `WhereType` tests its types, `WhereName` its
+  path, and `WhereFeature`, `Project`, `OrderBy` and `Column` read the values it holds now — after
+  a run changed them, not the declared defaults. The new `DocumentQueries::Objects(type = T)`
+  enumerates every object the session holds that is of the type; outside a session it is refused
+  with an error saying to instantiate an object first. `WhereMetadata` tests the usage an object
+  stands for; `RelatedElements` reads the model's relationships and refuses an object row. A document renders over objects too: `-instantiate <name>`
+  is now accepted beside `-render-document`/`-render-documents` and creates the objects first, and
+  a document parameter bound to a usage's name binds the object held under it. Objects render by
+  path in Markdown and PDF; in HTML each carries `data-object="#<id>"` beside the `data-element`
+  of the usage it stands for and is a `span.sysml-object`.
+
+- **Document queries report which constraints and requirements hold.** The new
+  `DocumentQueries::Verdicts(source, kind = "all")` checks the object behind each row as a whole —
+  the object the session holds when the binding is one (`%instantiate car`, `-instantiate`), the
+  element's declared object otherwise — and answers one **verdict row** per assertion about it and
+  the objects it holds: every `assert constraint`, every requirement carried, every `satisfy` whose
+  subject it is, and each verification case verifying such a requirement, a collection's members
+  under their own paths (`car.wheels[2]`). A verdict row stands for the assertion (so `name`,
+  `WhereName` and `WhereType` read the constraint or requirement) and adds `kind`, `carrier`,
+  `path`, `verdict` (`holds`, `violated`, `undecided`), `condition`, `reason` and `verification`,
+  which `Project`, `WhereFeature`, `OrderBy` and `Column` read; `kind = "constraint"`
+  (`requirement`, `satisfaction`, `verification`) keeps one kind. A row that is no object, and a
+  walk the runtime could not complete, are typed errors rather than a table missing rows. Verdicts
+  print in `%run-query`/`-run-query` as `<assertion> on <path>: <verdict>`, render in Markdown and
+  PDF as that text and in HTML as a `span.sysml-verdict` (`data-verdict`, `data-path`,
+  `data-object`), and `RunDocumentQuery` answers them as the new `verdict` arm of `DocumentValue`
+  (`DocumentVerdict`), decoded by the Go and Python clients as `DocumentVerdict`; a verdict bound
+  as a parameter is refused.
+
+- **PDF output draws Graphviz DOT and PlantUML diagrams.** `-render-document -doc-form pdf -diagram-form dot` runs each diagram block through Graphviz — the `dot` named by `OPENSYSML_DOT`, else the one on `PATH` — as SVG under the layout engine the block's `// layout:` header names (`dot`, `neato`, `neato -n`), so a view the model positions with `DiagramLayout` is drawn where it was placed; `-diagram-form plantuml` runs each block through the PlantUML jar named by `OPENSYSML_PLANTUML_JAR` (`java -jar <jar> -tsvg -pipe`, the `java` from `OPENSYSML_JAVA` or `PATH`). Both tools are optional: without one the block stays in the PDF as source under a notice naming the variable to set, and the render succeeds; a tool that fails is the same typed `tool-failed` error, with its stderr, that a failing Mermaid CLI produces. `scripts/download-doc-pdf-toolchain.sh` provisions a pinned Graphviz and PlantUML jar beside WeasyPrint, Mermaid CLI and KaTeX, and CI's PDF job draws through all of them.
+- **The VS Code diagram panel exports every form the server writes.** `SysML: Export Diagram` now asks which form to save — Mermaid (`.mmd`), Graphviz DOT (`.dot`), PlantUML (`.puml`), Markdown (`.md`) or text (`.txt`) — from the list the server advertises under the new `openSysmlRenderForms` capability of `initialize`, sends the pick as the `form` of `opensysml/render`, and opens the save dialog on that form's extension and filter; before, it saved whatever the server defaulted to.
+
+- **A run creates and destroys objects.** `new T(args)` (KerML §7.4.9 instantiation expression) in any expression position — an assignment, a feature value, an argument, `send new Data(…)` — makes a first-class occurrence of the run with an identity of its own, classified by its type, its exhibited and performed behaviors started, so a loop creates one object per iteration and a context holds several objects of one usage. Writing an object into a feature holds it: the feature's type classifies it and a composite feature adopts an ownerless object as a portion of the owner, so `assign cars := (cars, new Car(n))` leaves the fleet owning each car (a write that would make a whole a portion of itself, or give an ended whole a portion live or ended after it, is refused as `ErrOccurrenceLifetime`; an object its home drops moves home to another composite feature still holding it), reached by `all Car`, feature chains and `%features`; the behaviors the feature's type starts on the object run once the feature holds it, so one reading the feature sees the object it started for, and the behaviors a constructor's arguments start run once every argument is stored; a write or constructor rolled back because a behavior it started failed leaves no trace records of that behavior's run. `destroy` ends the occurrence and its portions, terminates the state machine it exhibits and the actions it performs where they stand (it no longer refuses with `ErrOccurrenceLifetime`), releases it from `all T` and drops the messages addressed to it or routed to a port it ends; a feature still naming it keeps the value and reading through it is `ErrOccurrenceDestroyed`, and a `=` value that read the object, `isDuring` of it or `all T` is derived again rather than answering what it derived before, in a context a held image is materialized into as well, where a value derived from `all T` before the image arrived counts the imaged objects too. Creation and destruction are deterministic under `explore` and survive a snapshot.
+
+- **A state's exit behavior reads the data of the transition leaving it.** An `exit action` parameter bound to a transition's accepted payload by the transition's name — `in level : Integer = warn.w ?? alarm.a;` for `transition warn first idle accept w : Warning …`, `in p : Boolean = 'T1.1.2'.p1` for a call trigger's argument — binds when that transition fires, before its effect runs, since the exit is a step of the transition performance that accepted the occurrence (`StatePerformances.kerml`: `accept then transitionLinkSource.exit`). A transition not being taken reads as nothing, so `??` chooses among several leaving transitions; an outer transition's data reaches the exits of the substates it leaves; a completion or data-less transition binds nothing and the parameter keeps its default; a payload of the wrong type, or a read of a transition not taken with no fallback, refuses the firing with a typed error and leaves the state as it was. The lowered transition carries the names its trigger binds (`Transition.Accepted`), and the executor holds the transition being taken from the exit through the entry; a do behavior reads the transition that entered its state for its whole run, whichever draw the entry front makes. The PSSM referee spells this for an exit behavior with parameters, so *Event 017 B*, *Event 019 B* and *Event 019 C* run and pass on exactly their admitted traces (60 pass, 30 not expressible); its reader also leaves out an activity node whose required input pin nothing ever feeds, as UML never executes it, and an exit some leaving paths bind nothing on declares its inputs `[0..1]` and guards only the statements that need them with `if notEmpty(…)`, so the exit still runs on those paths.
+
+- **An exploration runs a behavior on an object nested inside an assembly, named by a path from a declaration.** `-schedule explore`, `-engine check`, `smt`, `sweep` and `-engine all` take a performer, subject or object written as `<declaration>.<usage>[.<usage>…]`, with `[i]` on a multi-valued usage — `-state "Comms::Ground::listen Comms::pair.ground"`, `-analysis "Dyn::Analysis Fleet::fleet.rovers[2]"`. Each run instantiates the declaration the path starts from, its parts and connectors with it, and walks the rest of the path inside that object as `%state` walks it in the session's. The declaration is instantiated once per run however many behaviors name paths under it, so two machines on sibling parts of one `pair` share it and the messages its connector carries between them are what the exploration tables, where naming a part's definition alone ran it deaf to its neighbours. The path is checked against the declarations before any run starts — an unknown usage, an index on a single-valued usage, a step through a value are refused by name — and what only a run can know (a part its recipe left unbuilt) is that run's error, an outcome of the table. A witness the checker writes for a machine on a nested object replays on it.
+- **`-instantiate` gives its object to every explored run.** Under `-schedule explore`, `-engine check`, `smt` or `all`, each run creates an object of the `-instantiate`d declaration of its own before its behaviors start: a `-state` or `-action` named alone attaches to the performance the run's one object exhibiting or performing it already runs (several such objects are refused by name), and a path under the declaration (`Comms::pair.ground`) walks into the same object rather than creating another; a declaration `-instantiate`d twice is two objects of every run, as it is two of the session's, the later the one its name denotes. The prompt's `%instantiate` still creates the session's object alone, which no run sees. The `-instantiate`, `-state` and `-action` help and the CLI reference describe the rule under *Objects an exploration runs on*.
+- **A service request names the performer of an action or state machine.** `ExecuteActionRequest` and `ExecuteStateRequest` carry `performer_symbol_id`, a declaration or a declaration-rooted path spelled as the CLI spells it, and `subject_symbol_id` of an analysis request takes the same paths; a service that honours it advertises the `performer` capability. The Go client has `opensysml.PerformedBy(path)`, the Python client `performer=` on `execute_action`, `explore_action`, `execute_state` and `explore_state`, and the Node, Java and Rust clients read the capability.
+
+- **A migrated view exposes the edges its Cameo diagram draws.** A control flow, object flow, transition, connector, binding, dependency, satisfy or verify some diagram shows is written as a named member — its v1 name, else a name spelled from its ends, `succession 'start to call' first start then call;`, `transition 'Wait accept Done then Retrieve' first Wait accept Done then Retrieve;`, `binding 'a.p = b.q' bind a.p = b.q;` — unique in its body by the migrator's usual suffixes, and the view `expose`s it. An edge no diagram shows is written anonymously as before, so naming changes nothing in a model without diagrams, and the names never depend on an MTIP `-layout` export. An `include` is exposable too.
+- **Activity and state machine diagrams migrate to typed views.** An activity diagram owned by its activity becomes a `StandardViewDefinitions::ActionFlowView` and a state machine diagram owned by its machine or a composite state a `StandardViewDefinitions::StateTransitionView`, each exposing the behavior whose graph it draws, so the renderer draws the successions, flows and transitions and pins their routes.
+- **MTIP routes join the named edges, and the rest are itemized.** `-layout` writes a `DiagramLayout::Route` for every connector whose member the view exposes (or whose graph it draws) and whose rendering draws that edge kind. The report and the `results` sidecar (`routesByKind`) count the routes by v1 kind and reason — `written`, `no v2 member` (a generalization, composition, association), `not drawn` (a dependency or satisfy on a tree view, a message step), `not written`, `unnamed`, `not exposed`, `duplicate`, `dangling` — so an unpinned route says why.
+- **The interconnection rendering draws bindings.** A `binding` between two features is an edge of its own kind, an undirected line beside the heavy connection in the text, Mermaid, DOT and PlantUML forms, and its route is pinned like a connection's.
+
+- **Feature-owned multiplicities now report invalid featuring types.** A `featuring` relationship that gives a feature's owned multiplicity a featuring type outside the feature's featuring contexts is diagnosed.
+
+- **`-convert` reads and pushes a Flexo MMS project branch.** `sysml <branch-url> -convert sysml` (or `ttl`) reads a branch as its head commit's RDF graph — the URL is `http(s)://host[:port][/base]/projects/{project}/branches/{branch}` or `flexo://{project}/{branch}`, both naming the endpoint `FLEXO_SYSMLV2_URL` configures — and `sysml model.sysml -convert ttl -o <branch-url>` replaces the branch's whole model graph, conditional on the branch's etag so a head the sync state says moved is refused with nothing written. Both sides need the bearer token in `FLEXO_INTEROP_TOKEN` and record the head commit in the sync state (`-sync-state`, `<output>.sync.json` on a read, `<model>.sync.json` on a push).
+
+- **The SysML v1 migrator maps calls to the fUML and Alf standard-library primitives to the SysML v2 library.** A `CallBehaviorAction` whose behavior is an element of `fUML_Library.xmi` or `Alf-Library.xmi` — known by the library document its href names and the fragment within it, whatever date the URI carries and whether or not the model bundles the library; or, as MagicDraw and Cameo reference the library, by an href into the used project `fUML-Library.mdzip` or `Alf-Library.mdzip` whose target — the bundled copy, or the `referentPath` recorded beside the href — sits under the library's own root package, family and name, so a package of the model's own named `fUML_Library` stays its own — is written with its pins, each result pin valued by the v2 library expression over the arguments (`StringFunctions::'+'` for `Concat`, `IntegerFunctions::ToString`, `SequenceFunctions::including`, `SequenceFunctions::size`, …), so the flows out of it carry the computed value and a migrated activity that builds a string or a list runs. Every behavior of both documents is inventoried in the reference: mapped, approximated with the semantic difference in the note (an index outside the sequence fails in v2 where v1 gives no result; `Real` `ToString` writes the shortest text; `ToBoolean` reads lower-case text only), or refused with the reason (`IndexOf`, `ReplacingOne`, the `BitStringFunctions`, `WriteLine`). A scalar parameter the call passes nothing for, or whose pin only flows from something that produces no value, starves the call as in v1, so the action is written empty and carries the token. The report notes which provenance identified each call. Such calls were refused as behaviors with no v2 declaration.
+
+- **The fUML referee's emitter translates classes, objects, signals and active classes.** A fUML `Class` with attributes and generalizations becomes a `part def`, `CreateObjectAction` a `new` occurrence on its result pin, and the structural feature actions (`Read`, `Add`, `Remove`, `Clear`) feature reads and assignments on the object at the `object` pin, positioned as the reference implementation positions them; a `Signal` becomes an `attribute def` specializing its generals, `SendSignalAction` a `send new <Signal>(…) to target` and `AcceptEventAction` an `accept` node whose result pin is the instance received, an instance of a specialized signal satisfying an accept of its general. A class's owned behavior becomes an `action def` nested in its `part def`, its classifier behavior an `action classifierBehavior : <Behavior>;` member no creation starts, `ReadSelfAction` in it `this`, an activity instantiated as an object such a `part def` around its own body, and `StartObjectBehaviorAction` a `perform object.classifierBehavior.start;`; an owned behavior's row is refereed through the executed activities that start an object of its owner, directly or through a call. The referee gives class-typed inputs defaulted objects and compares object outputs by class and feature values; eight activities of the reference suite move from `not-expressible` to `pass` (23 pass, 0 fail, 28 not-expressible, 4 differs-by-design). An edge weight other than 1 and an object-flow cycle through control nodes stay typed translation refusals; no activity of the suite has either.
+- **An accepted signal flows on from the accept node's result pin.** The action-graph lowering records an `accept <name> : <Signal>` node's payload as an output feature, and the executor binds the value received on the accept's own performance as well as in the enclosing body, so `flow receiver.msg to consumer.value` carries it to the consumer and `value.level` reads its attributes.
+- **A declared behavior starts on an explicit `perform obj.beh.start;`.** An action or state usage a `part def` declares without exhibiting or performing it (`action count : Count;`) is bound to no object at creation — `new T()` and a materialization run nothing of it — and a `perform` naming its `start` gives the object its own execution of it: `this` in the body is the object, its writes land on the object's features, a message sent afterwards wakes an accept it parks at, and the object outlives the behavior's completion. A second start of a running behavior starts nothing more, a start on no one object or of a member that is no behavior of the object is a typed error, and a start that fails is undone whole. The `start` is the shot of the behavior named, told from a feature the type declares under that name: `perform vehicle.start;` where `Vehicle` declares an `action start : Launch;` performs that action.
+
+- **The fUML test models are read and every activity is classified before any is translated.**
+  `internal/fuml` reads the pinned Eclipse UML2 XMI — activities, nodes, pins, control and object
+  flows with guards and weights, parameters, classes, operations, signals, associations,
+  structured nodes, exception handlers and cross-references into the foundational library —
+  through the XMI element walker shared with the PSSM referee, which now accepts every OMG XMI
+  namespace version. `Classify` files each of the 43 test-model activities and 12
+  exception-model activities as expressible, `differs-by-design` (an action the reference
+  implementation fired once per object token, which SysML v2 performs once with every delivery)
+  or `not-expressible`, with a reason naming the construct and where it occurs; the per-activity
+  checklist and counts (24, 4, 15; 12) are pinned by test, and CI downloads the suite and runs
+  the reader and classifier gates on their own. See `docs/project/fuml-referee.md`.
+
+- **The fUML reference implementation's activity tests are provisioned as an oracle for the
+  action executor.** `./scripts/download-fuml-suite.sh` fetches ModelDriven's pinned
+  `fUML-Tests.uml`, `fUML-Exception-Tests.uml`, the foundational library and the `fuml-1.5.0a`
+  jar with its Maven runtime dependencies, every one by checksum, into the ignored `build/fuml/`;
+  `make fuml-expected` runs the implementation over both models through a small Java driver
+  that selects each activity by XMI id and records its outputs, its nested
+  `Execute`/`Fire`/`Output`/`Complete` trace with the XMI id of each activity and action node
+  the trace names unambiguously, and provenance in `docs/project/fuml-referee-expected.json`.
+  A failed activity leaves the committed record unchanged. The record is committed and
+  `internal/fuml` reads it back, refusing one whose provenance is not the current pin's, so the
+  ordinary test gate never runs Java. See `docs/project/fuml-referee.md`.
+
+- **The fUML test activities referee the action executor.** `cmd/fuml-referee` translates
+  every expressible activity of the pinned fUML reference implementation's test model into a
+  `fuml::<Activity>` action definition by rule — parameters and their multiplicities, control
+  and object flows with an enabling succession beside each flow whose target has no control
+  predecessor, forks, joins, merges and guarded decisions, value specifications, nested
+  behavior calls with same-named parameters spelled apart, the primitive and list library
+  functions KerML has counterparts for — runs it under every schedule the runtime's explorer
+  reaches, and requires the values left in its output parameters to be the ones the reference
+  implementation recorded, as a multiset where the fUML parameter is unordered; the reference's
+  firing sequence being among the reachable ones is reported and never a verdict. Each activity
+  is filed as `pass`, `fail`, `not-expressible` or `differs-by-design` (an action the reference
+  fires once per object token), the counts are pinned in `docs/project/fuml-referee-baseline.json`
+  and checked in CI over the downloaded suite by `go run ./cmd/fuml-referee -check`; an
+  activity the emitter does not yet translate (object creation, structural-feature actions,
+  accept-event actions, active classes) is `not-expressible` with the construct named. The
+  `-json`, `-filter`, `-keep` and `-jobs` flags report, narrow, retain the emitted models and
+  parallelize the run; a filtered run never updates the baseline. `docs/project/fuml-referee.md`
+  documents the translation rules and the adjudication of every row, the spec-compliance action
+  section gains its row, and the precise-semantics alignment note gains row A15 for per-token
+  re-firing.
+
+- **A document query over gRPC binds an object the service holds.** `Instantiate` now keeps
+  the object it creates, in one runtime per cached model, for as long as the model stays cached;
+  instantiating the same usage again denotes the new object and keeps the earlier one by id.
+  `RunDocumentQuery` binds a parameter to such an object through the new `object` arm of
+  `DocumentValue` — a `DocumentObject` naming it by `instance_id`, by `path` (`car`,
+  `Garage::car`, `#2`, `car.wheels[2]`: what `%run-query` accepts) or by both — and runs the
+  query in that runtime over the held population, so `DocumentQueries::Objects(type = T)`
+  enumerates what the model holds (no rows before the first `Instantiate`) and `Verdicts`
+  checks a bound object's current values. A row that is an object, and an object-valued cell,
+  is answered as the `object` arm with the object's id, the path it is reached under and the
+  usage it stands for; `RenderDocument` renders over the same population, as `-render-document`
+  does beside `-instantiate`. A binding while nothing is held or naming an unknown id or usage
+  is `NOT_FOUND`; a path that does not reach an object, an out-of-range index, an object bound
+  to a non-`Element` parameter or an id its path disagrees with is `INVALID_ARGUMENT`, with the
+  REPL's wording. The Go client binds with `opensysml.ObjectByID`/`ObjectByPath` and decodes
+  `Object` (`ID`, `Path`, `Element`) as a cell and as `Row.Object`; the Python client binds with
+  `ObjectRef(id=…)`/`ObjectRef(path=…)` and decodes `ObjectRef` as a cell and as
+  `DocumentRow.object`; the Node, Java and Rust clients carry the regenerated stubs. What one
+  model holds is bounded by `OPENSYSML_GRPC_MAX_HELD_OBJECTS` (default `10000`, nested objects
+  counted): an `Instantiate`, query or render whose objects would pass it fails whole with
+  `RESOURCE_EXHAUSTED`, leaving none of them, until the model leaves the cache, which releases
+  its objects; none is evicted behind an id a client holds.
+
+- **`ApplyEdits` edits a model of several documents as one atomic batch.** A model parsed by
+  `ParseSources` was refused with `FAILED_PRECONDITION`; its operations are now applied through
+  the same cross-document path the LSP uses: a rename or cascade delete follows its references
+  into the model's other documents, every document touched is re-parsed and re-analysed together,
+  and either all of them are answered or none is. `ApplyEditsResponse.documents` (new field 7)
+  lists every document the batch rewrote — one entry for a model of one document — as
+  `EditedDocument{name, content}`, `name` being the name the parse request gave it, so a new
+  client has one code path for both shapes; `content` (field 1) keeps the edited notation of a
+  model of exactly one document and is empty for a model of several, even when only one changed.
+  Each `AppliedEdit` names its `document` (new field 7), and a refusal — which still carries no
+  content — names each referrer with its document in `referrers` (new field 8, `Referrer{name,
+  document}`) beside the textual `referring_elements`. `ApplyEditsRequest.document` (new field 3)
+  selects a document other than the model's first for the operations to target; a name that is
+  not one of the model's is `INVALID_ARGUMENT`. `ApplyEditsRequest.accept_documents` (new field 4)
+  says the client reads `documents`: a model of several is edited only for a request setting it,
+  and one leaving it unset — every request a client of the previous schema sends — is refused on
+  such a model with `FAILED_PRECONDITION` as before, so a client reading `content` alone is never
+  answered an empty one; a model of one document ignores it. The `edit_documents` capability
+  advertises all of this: a service without it answers `content` alone — no `documents`,
+  `referrers` or applied-edit `document` — refuses a model of several documents with
+  `FAILED_PRECONDITION` and a request naming a document with `UNIMPLEMENTED`, so a client reading
+  `documents` checks it first. `EDIT_FAILURE_REFERENCED_ELSEWHERE` is appended
+  for a rename, delete or move referred to from a document the edit cannot rewrite — a move
+  respells references in its own document only, so one referred to from another document of the
+  model is refused this way. No existing field changed number, type or meaning, so a generated
+  client of the previous schema decodes every answer. `Convert` from a model handle still
+  requires a model of one document. The Go client answers `EditResult.Documents`,
+  `AppliedEdit.Document` and `EditError.Referrers`, and `ApplyDocumentEdits` names the document
+  to edit; the Python client answers `EditResult.documents`, `AppliedEdit.document`,
+  `EditError.referrers` and raises `ReferencedElsewhereError`; the Node, Java and Rust clients
+  carry the regenerated messages. The conformance suite parses a model of several documents by
+  naming `fixtures` rather than one `fixture`, and gains scenarios for a rename and a cascade
+  delete crossing documents, an edit answering only the document it touched, and a refusal
+  naming a referrer in another document.
+
+- **Imports are followed to the files beside a loaded one.** A file named to `sysml`, `%load` or `sysml -check` that imports a root namespace neither the named files nor the standard library declare now has the `.sysml` and `.kerml` files beside and below it searched for one declaring that name, and each such file is loaded with it, its own imports followed the same way; unrelated siblings and hidden directories stay out. The language server does the same for a document opened from outside every workspace folder, indexing the document's directory so its imports of sibling files resolve instead of being reported unresolved; an open buffer stays authoritative over the file on disk (`project.Dependencies`, `TestLoadingOneFilePullsInTheSiblingsItImports`, `TestOpeningFileOutsideFoldersIndexesItsDirectory`).
+
+- **The Java client wraps every RPC the service offers.** `Connection` gains `parseSources` (a model of several documents), `convert`/`convertFile`, and `listEngines`; `Model` gains `executeAction`/`executeState` and `exploreAction`/`exploreState`, `verifyConstraint`/`verifyRequirement`/`verifySatisfaction`/`validateInstance`, `evaluateCalc`, `runAnalysis`/`exploreAnalysis`, `runSweep`, `applyEdits`, `query`/`queryOslc`, `runDocumentQuery`, `renderDocument`, `convert` and `withEngine`. Each answers an immutable record — `ActionRun`, `StateRun`, `Exploration` of `Outcome`s, `Verification`, `Satisfaction`, `Validation`, `Verdict`, `VerificationVerdict`, `Calculation`, `Analysis`, `CaseEvaluation`, `Standing`, `QueryElement`, `EngineInfo`, `Conversion`, `EditResult`, `Sweep` of `SweepRow`s, `DocumentQueryResult` of `DocumentRow`s, `RenderedDocument` — with no generated protobuf type in the public API. A false verdict is returned as a decided answer rather than thrown; `ModelException.failureReason()` classifies an in-band failure, `AnalysisException.partial()` keeps what a failed analysis computed before it stopped, and `EditException` carries the `EditFailure` kind and the referrers a refused batch named. The Java conformance runner now covers every RPC through the public API — 129 of the 134 scenarios run and pass per protocol, and the five skips are only requests the public API cannot express.
+
+- **A design for scaling to very large models** (`docs/project/large-model-scaling-design.md`).
+  Starting from the satellite-network stress test's profiles, it separates the four costs a large
+  model pays — per element once, per workspace per edit, per process on one core, and per modeled
+  object by construction — and designs one approach against each: a persistent resolver and
+  semantic model owned by the workspace and invalidated through a document dependency relation
+  rather than cleared on every change; closed documents held as interface records (the facts other
+  documents can observe, plus stored diagnostics) that hydrate to a full tree only when opened or
+  queried, generalizing the standard library's snapshot and index-record cache; parallel
+  per-document analysis over a read-only index; and one definition with many occurrences in the
+  runtime, with sparse per-occurrence values. Each names its differential test against the
+  unoptimized path, the measurement that decides it, and its place in the sequence. Nothing is
+  implemented; the page exists to be reviewed before code is written.
+
+- **Documents typeset LaTeX mathematics, inline and displayed.** A `Span` or `SpanColumn` with
+  `style = "math"` is an inline formula, and the new `Formula` content block (required `source`,
+  optional `caption`, a `Ref` target when named) a displayed one; the LaTeX reaches every backend
+  unescaped while a `$` in ordinary prose is escaped so it never opens a formula. Markdown writes
+  `$…$` spans and `$$…$$` blocks; HTML wraps each formula in a `sysml-math` element between MathJax
+  delimiters, and `-html-math cdn|<url>` has the page load MathJax, confined to those elements;
+  PDF typesets each formula with KaTeX's command line (`katex`, or `OPENSYSML_KATEX`, its stylesheet
+  found beside it or named by `OPENSYSML_KATEX_CSS`) and embeds its fonts under every engine, so the
+  PDF shows mathematics rather than source. A blank formula, a blank math span or a query row
+  supplying no LaTeX to a math column is a typed error; a document without formulas needs no KaTeX.
+  `scripts/download-doc-pdf-toolchain.sh` now provisions a pinned KaTeX beside the other tools.
+
+- **The language server runs the behavior a diagram draws.** The new `opensysml/debug/*`
+  requests (`start`, `step`, `continue`, `send`, `advance`, `breakpoints`, `stop`), advertised as
+  the `openSysmlDebug` capability, execute the state machine or action a `state` or `action` view
+  renders — with the executors the REPL's `%state` and `%action` debuggers use, optionally as
+  performed by an instantiated part — and answer every request with a snapshot in the IDs of that
+  view's `opensysml/render` result: the active states (composite states and regions included, one
+  chain per orthogonal region), each action token with the node it sits at, the edge it arrived by
+  and the join edges or signal it waits for, the transitions and successions taken since the last
+  snapshot, the events queued and the messages pending, the runtime's clock, its notes, a completed
+  action's results, and whether the run is `running`, `waiting`, `suspended`, `completed`,
+  `failed` or `ended`. Breakpoints are set by render node ID and pause a run as a token reaches the
+  node or the state becomes active; a signal the behavior accepts nowhere is refused rather than
+  queued to be lost. A session follows the document: an edit that leaves as they were the
+  declarations the run reads — the target's, its performer's, and every declaration those name
+  and the named name in turn (specialized and typing definitions, invoked actions, accepted
+  signals, feature types, values a guard or a `send` names) — keeps it running and reports the
+  snapshot in the fresh IDs (a pause reached at a breakpoint moved with them) through the new
+  `opensysml/debugChanged` notification — every snapshot numbered by `revision`, so a client keeps
+  the newest whatever order the answers and notifications arrive in — while one that
+  rewrites or removes any of them, makes the run read one it did not, or rewrites the declared
+  view, ends it and says why. To place runtime state on a rendering, the runtime
+  now records the transitions a state machine fires (`StateExecutor.FiredTransitions`) and the
+  successions each token travels (`ActionExecutor.Traversals`, `Token.Within` for the nested flows
+  it runs in) — a driver reading them step by step takes only what a mark it kept has not seen
+  (`FiredSince`, `TraversalsSince`, `NotesSince`) — a held image of an object carries its
+  debugger's state with it (the breakpoints set, the pause reached, the record so far), a
+  `view.StateLocator`/`view.ActionLocator` map lowered vertices, action nodes and edges to render
+  IDs by their position within the declaration, and document, they were written in
+  (a node or edge inherited from another document is drawn from that document and keeps its place
+  as either document is edited), and `model.Workspace.NewRuntime` builds a
+  runtime model over a workspace's documents whose `Dependencies` lists the declarations a run
+  reads; a lowered `StateGraph` or `ActionGraph` lists the
+  declarations it took content from besides its own (`Inherited`), which the root node of a
+  `state` or `action` rendering carries as `view.Node.Inherited`. See
+  [the LSP reference](docs/reference/lsp.md).
+
+- **The SysML v1 migrator writes every diagram as a `view`.** A `uml:Diagram` serialized in a tool's `xmi:Extension` with its diagram representation (MagicDraw and Cameo's `DiagramRepresentationObject`) is read as a tool-neutral diagram record — name, kind, owner and shown elements — and written as a `view` usage in the body of the v2 element its owner became, exposing every shown element the document writes and rendered by the standard `Views` library's `asTreeDiagram`, `asInterconnectionDiagram`, `asElementTable` or `asTextualNotation` according to the diagram's kind. A diagram whose owner has no v2 body is written in the nearest ancestor that has one, one showing nothing writable is an empty view, and each is reported as mapped or approximated with the reason instead of skipped as tool content; layout stays unmigrated. `render` and the other reference members of a view now accept a globally qualified name, `render $::Views::asTreeDiagram;`, as the grammar allows.
+
+- **The SysML v1 migrator writes a simulation tool's Monte Carlo analysis pattern as an analysis case.** A block generalizing the SysML customization module's `MonteCarloAnalysis` — recognised by the module's provenance alone, so a user's own block of that name stays an ordinary block — keeps its `part def` and gains a sibling `analysis def '<Block> Monte Carlo' :> Simulation::MonteCarlo` whose subject is the part def, whose one run performs the block's classifier behavior, whose `observed` is the value the block binds to `Mean`, and which returns one statistic per bound `Mean`, `Deviation`, `N` or `OutOfSpec` (`return Mean : Real = mean;`, `out Deviation : Real[0..1] = deviation;` …); its result snapshots record the four statistic slots as an `analysis` of that def, and the `-migration-results` sidecar names the analysis def and its declared statistics. The generalization, the binding connectors and the slots move out of the report's unmapped rows; a binding of another statistic, of a non-numeric value, of one statistic twice, or of a statistic of nothing bound to `Mean` is a comment naming the reason.
+- **`Simulation::MonteCarlo`, an analysis of repeated runs, joins the OpenSysML library, and `-runs` runs it.** `sysml -analysis "<case> <subject>" -runs <n> -seed <s>` (or `%runs <n> <seed> <case> <subject>`) performs the case's steps on a fresh subject per run, seeded from the seed and the run number, tables what each run observed, and concludes the case once over the sample with `runs`, `mean`, `deviation` (the sample standard deviation, empty under two runs) and `outOfSpec` bound and its own outputs evaluated over them. A case that specializes no `Simulation::MonteCarlo`, a subject named by `#id`, or `-observe` is refused; a single run leaves the statistics unbound rather than passing one run off as many. A quantity-valued `observed` is sampled by magnitude in the first run's unit and `mean` and `deviation` are quantities in it; when every run fails, the table keeps each run's row and error and the case is reported unconcluded. A check decided run by run counts the runs it failed in as `outOfSpec` and is not judged again at the conclusion, which decides only the checks of the statistics; the count of runs is validated against the sweep budget before anything is sized by it; and the `-runs` statistics sum Integer deviations exactly and scale Real deviations before squaring, so a finite sample never overflows. A failed run fails the case whatever comes of the sample; only a table of completed runs is left unresolved when the sample or the conclusion cannot be made.
+- **`-compare-results` compares a Monte Carlo analysis statistic by statistic.** Under the observable's table, one row per statistic of the migrated analysis case — its declared returns, then the outputs of `Simulation::MonteCarlo` the tool stored without a return: the tool's pooled `Mean` and `Deviation` against the runs' by the same aggregation with their relative difference (the deviation is newly compared), `N` side by side, and `OutOfSpec` recorded but not compared, being the tool's own criterion.
+
+- **The SysML v1 migrator follows stereotype generalization and writes user profiles as metadata.** An applied stereotype is resolved to its `uml:Stereotype` in the document's profiles and its generalizations followed — through OMG `href`s, MagicDraw `referentPath`s and Papyrus pathmaps, across diamonds and cycles — so a user stereotype specializing «Requirement», «Block», «ValueType», «Satisfy», «Verify», «Refine», «Trace», «DeriveReqt», «Allocate», … takes that standard stereotype's v2 form with its tags (`Id`/`Text` become the short name and `doc`). User profiles become packages of `metadata def`s typed from their tag definitions (`attribute` for String/Integer/Real/Boolean/enumeration tags, `ref` for element references, `:>` between user stereotypes), and their applications `@Profile::Name { tag = value; }` usages instead of comments; the modeling tool's own profiles, known by exact namespace path, are skipped together with the content they mark (specification-dialog customization, UI prototyping mockups, simulation-tool configuration), with a reason naming what it is. A same-named stereotype with no standard general, and an application whose profile the document does not define, keep their previous form.
+
+- **The SysML v1 migrator writes a Cameo/MagicDraw table, dependency matrix or relation map as an executable query and a renderable document.** A diagram carrying «InstanceTable», «DiagramTable» or «RelationMap» from the MagicDraw profile, or «DependencyMatrix» with its «MatrixFilter», is written beside its `view` as a `calc def '<Diagram> Rows' :> DocumentQueries::Query` — the scope as `Descendants` of the named roots, explicit rows in one `Union`, the row type as `WhereType` (and `isIndividual` for an instance table) or `WhereMetadata` for a migrated user stereotype, columns as `Project` and `Column`, sorts as `OrderBy(missing = "last", multiple = "first")`, a matrix criterion as a `RelatedColumn` over the column scope, a relation map as `RelatedElements` — and a `part def '<Diagram> Document' :> DocumentQueries::Document` holding the `Table`, so `-run-query` lists the rows and `-render-document` renders the table. Only the exact profile namespaces define a table; a same-named user stereotype elsewhere is ordinary metadata. A «DeriveReqt» criterion is walked from the original requirement, as the v2 `derivation` runs, and a criterion excluding subtypes of a stereotype the model specializes is approximated with the specializing stereotypes named, since their relationships are written as the same v2 relationship. A criterion no relationship kind spells, a malformed scope, sort, depth or criterion XML, or a table naming no row type is refused with every fault stated and the view kept.
+- **The migrator writes an MDK DocGen «Document» as a `DocumentQueries::Document`.** The document's view tree becomes nested `Section`s in declaration order, and each view's viewpoint method activity is lowered from its initial node along control flow: the «Expose» suppliers are the root, `CollectOwnedElements`, `CollectOwners`, `CollectByDirectedRelationshipStereotypes`, `FilterByMetaclasses`, `FilterByStereotypes` (`include = false` as `Except`), `FilterByNames`, `SortByName`, `SortByAttribute`, `Union` forks and nested groups wrap the query, and `TableStructure`, `BulletedList`, `Paragraph`, collaborator paragraphs, `Image` and `Dynamic View` end it as a `Table`, `List`, `Paragraph`, view-backed `Diagram` or nested `Section`. A step with no query spelling (`CollectTypes`, OCL expressions, user scripts…), a recursive dynamic view, a viewpoint that names no method or a collaborator paragraph that names no view is refused with the construct quoted, and the sections around it are still written.
+- **`DocumentQueries` gains `Named`, unbounded walks, matrix targets and `isIndividual`.** `Named(qualifiedName = (…))` resolves qualified names to elements, so a query can be rooted at a package or definition; `maxDepth` on `Descendants`, `Ancestors`, `RelatedElements`, `WhereRelated` and `RelatedColumn` may be omitted or `null` for no bound; `RelatedColumn(targets = …)` keeps only the related elements a second query lists, which is a dependency matrix's cell; and `WhereFeature('feature' = "isIndividual", …)` selects individuals. A declared `satisfy`/`verify` assertion typed by a requirement definition now relates its subject to that definition as well as to the assertion usage, so a matrix over requirement definitions finds its satisfiers.
+- **The migrator reads MagicDraw's «typeModifier».** `[]` on a property or parameter with no collection multiplicity writes `[0..*] ordered nonunique`, `[n]` writes `[n] ordered nonunique`, and `*` on a part or item property writes it `ref`; a two-dimensional shape, `[]` on an existing collection and `*` on an attribute or parameter stay comments with the reason reported.
+
+- **The v1 migration writes views, viewpoints, use cases, UML Expression trees and interface realizations, which it refused before.** A «View» class is a `view` usage that satisfies the viewpoint its tag or «Conform» relationships name and exposes what its «Expose» dependencies do (`expose P::**;` for a package); a «Viewpoint» class is a `viewpoint` usage with its stakeholders, its concerns framed and its purpose, language and method documented. A UseCase is a `use case def` with its subject, the actors its associations reach, and its inclusions; an Extend is a dependency on the extended case, since v2 has no `extend`. A UML Expression tree — a constraint's specification, a default or a slot value — is lowered to a v2 expression over arithmetic, comparison and Boolean operators (spelled as signs or by name, `Plus`, `Equal`), feature references — a bare symbol, or the ElementValue operands MagicDraw keeps in an `xmi:Extension`, which the XMI reader now reads as operands — and the calls the opaque-language subset already translates. An InterfaceRealization is a port typed by the interface's `port def` on a block, or a specialization on an interface block. Whatever v2 has no form for — an exposed diagram, an extension point, an operator outside the set, an interface outside the document — stays a comment and the report names the reason.
+
+- **The Windows installer now runs a setup wizard.** Double-clicking the MSI used to flash Windows Installer's bare progress window and close with no indication of what had happened. It now walks through Welcome, a *Destination Folder* page (with a folder browser), a *Choose components* tree for the optional gRPC service and bundled Z3 solver, a *Ready to install* confirmation, progress, and a *Completed* page that names the install folder and reminds you to open a new terminal for the updated `PATH`. Running the MSI again offers *Repair* and *Remove*, and a newer MSI proposes the folder chosen last time (recorded under `HKLM\Software\Open-MBEE\OpenSysML`). The dialogs are authored from the standard Windows Installer controls in `packaging/msi/wizard.wxs`, so the MSI carries no custom-action code or binaries; `ADDLOCAL`/`REMOVE` and a new `INSTALLFOLDER` property keep working for silent installs.
+
+- **`-layout <mtip-export.xml>` lays out a SysML v1 migration's views from an MTIP export.** `sysml model.mdzip -convert sysml -layout model_mtip.xml` joins the export's diagram records to the migrated diagrams by element identifier and writes their geometry as `DiagramLayout` metadata — `Layout` per exposed element, `Route` per exposed connector, `@Canvas` sized by what was written — which every rendering honors. What the export shows but the view does not expose, records matching no diagram, malformed records and unsupported presentation properties are counted and reported, never dropped silently; a layout exported from a different project is refused. Without `-layout` the migration is unchanged.
+
+- **Nested action flows in loop and branch bodies now execute with their stated succession, fork, join, and termination semantics.** Body-local attributes are initialized for each iteration.
+- **Action-node body execution now covers stated flows in action and state behaviors.** Invalid or unsupported body forms report typed runtime errors.
+
+- **A nightly snapshot of `develop` is published as the prerelease `nightly`.** Every night
+  the newest green `develop` commit is built into the same archives, raw `sysml-grpc`
+  binaries and signed `SHA256SUMS.txt` a release ships, and published under the moving
+  `nightly` tag; a version of the form `nightly-<yyyymmdd>-<commit>` tells a snapshot apart
+  from a release. The snapshot is never marked latest, so `releases/latest`, Homebrew and the
+  client packages keep following the stable line. The new *Nightly snapshots* page, linked
+  from the landing page and the install guide, says where the snapshot is, what it contains,
+  how to verify one and what to expect from it.
+
+- **The nightly snapshot ships the VS Code extension.** `opensysml-sysml.vsix`, packaged from the same commit as the binaries, is attached to the `nightly` prerelease and listed in its signed `SHA256SUMS.txt`, so the extension can be installed with `code --install-extension` without a checkout. Its version is the manifest's with the snapshot version as the pre-release part (`0.1.0-nightly-<yyyymmdd>-<commit>`), so a later night installs over an earlier one as an update; `make vscode-package VSIX_VERSION=…` stamps a version the same way.
+
+- **`sysml-lsp` is declared to OpenCode.** A checkout carries an `opencode.json` that starts the language server for `.sysml` and `.kerml` files, so the coding agent reads its diagnostics the way it does `gopls` for Go; the editors chapter of the guide explains the configuration and how to enable it for every project.
+
+- **An operation is invoked with a positional argument list.** `Context.InvokeOperationWith` takes `OperationArguments`, positional or named, and `%invoke <object> <op>` accepts bare expressions (`%invoke rover drive 10 20`) beside its `<parameter>=<expression>` pairs. Positionals bind the operation's `in`/`inout` parameters in signature order, a trailing defaulted parameter may be omitted, and among same-named operations the one the arguments fit is selected as an invocation expression would. A list mixing the two forms is refused (`ErrMixedArguments`), as is a surplus argument (`ErrOperationArity`).
+
+- **A design record on protocol state machines** (`docs/project/protocol-state-machines.md`). It
+  establishes that SysML v2 has no counterpart to UML's protocol state machine and needs none for
+  the half it can express: the legal order of receptions on a port or part is an ordinary exhibited
+  state machine, which the runtime runs on parts with `accept … via`. It also records what the
+  runtime does not yet do: a message a model sends that the active state neither accepts nor defers
+  is held on the bus and taken by a later state rather than dropped and reported as a directly
+  injected event is, and a machine exhibited by a port definition does not take a model's messages
+  routed to that port. Post-conditions, conformance between machines, static sequence checking and
+  the gating of operation calls by state have no SysML v2 spelling. The record specifies the runtime
+  follow-up with its proof fixtures; the roadmap item stays open and the compliance bullet points at
+  the record.
+
+- **The PSSM referee spells entry, do and effect behaviors with parameters, bound to the triggering event's data, and behaviors that return a call's result.** The accepting transition's effect stores the accept's signal payload or operation arguments in attributes of the machine and the target state's entry or do action declares its parameters bound to them (`in p : T = trigger_…;`), an effect reads the `accept`'s own parameters, and a behavior producing the operation's result becomes an `action def` with `out` parameters the runtime returns to the caller; the classifier refuses only an exit behavior with parameters, which runs before the leaving transition's effect, the first place the accepted data is readable, and every other refusal reason is unchanged. Same-named operations keep their identity through the call, its carried attributes and the tester's stimulus; a trigger naming one of two overloads with a single `accept` spelling between them is refused. *Event 019 D*, *Event 019 E*, *Deferred 007* and *Standalone 003* move from `not-expressible` to `pass` (56 pass / 13 fail / 33 not-expressible / 1 differs-by-design).
+
+- **A synchronous call of an operation a state machine accepts as a call event returns the operation's outputs to the caller.** `StateExecutor.Call` queues the call event, runs the machine through the run-to-completion step dispatching it — later events that step queued and timers it armed wait for the machine's next run, while a call a state defers holds its caller until the machine recalls it — and releases the caller with the values the behaviors that step fired — the transition's effect, an entry or an exit — returned or assigned to the operation's `out` and result parameters, by name — the parameters the operation declares as a member of the machine's owner when it declares one, an `inout` the step left unwritten going back as passed, every output the step returned otherwise — as PSSM §8.5.9 resumes a synchronous caller after the run-to-completion step; a call the run leaves queued or deferred is reported as `ErrCallNotReturned`, one no transition accepts is discarded. The arguments are checked against the declaration the call selects among same-named operations before the call is queued — an unbound, unknown or wrong-typed one is refused, an omitted input carries its default — and the queued call event carries that declaration, so it fires only the triggers naming it and same-named overloads whose parameter names differ reach their own transitions. A snapshot and a held image capture the call in flight. A nested action's `return` or output assignment reaches the enclosing behavior's parameter of that name on the way. Conformance case `state_call_trigger_results` and `TestRuntimeRobustnessCallResults` cover it.
+- **The PSSM referee drives the tester's stimulation in the tester's order and reads a standalone state machine as the class under test.** The driver performs each send, synchronous call and `trace(...)` of the tester's behavior as the tester does, appending a traced value to the target's `log` once the call it embeds has returned, with the suite's test library (`Concat`, `ToString`, `formatParameterValue`) read into the model and evaluated generically; the reader reads a `StateMachine` that is itself the class under test as a target with its attributes, operations and constructor. *Event 019 A* moves from `not-expressible` to `pass` (52 pass / 13 fail / 37 not-expressible / 1 differs-by-design); tests needing an entry, exit or do behavior with parameters, or an effect that returns the call's result, stay `not-expressible` on exactly those reasons until the emitter spells them, and every other test's result and reason is unchanged.
+
+- **Document queries express requirement coverage gaps.** The new
+  `DocumentQueries::WhereRelated(source, relationshipKind, direction, maxDepth, exists = true)`
+  keeps each row by whether at least one element is reachable from it over a named relationship —
+  every kind `RelatedElements` accepts, through the same edge tables, typed errors and visit
+  budget — and `exists = false` keeps the rows with none, so "which requirements does nothing
+  satisfy or verify" is one filter over incoming `satisfaction` or `verification` edges. The new
+  ordered set operations `Except(source, exclude)` and `Union(source, other)` combine query results
+  by the identity traversal already deduplicates by (a model element by its declaration, a held
+  object by the object, a verdict by its assertion and the object it was checked on), keeping
+  source order and projected columns. The query cookbook gains a Coverage section
+  (`UnsatisfiedRequirements`, `UnverifiedRequirements`, their union and difference) and a
+  Requirement hierarchy recipe that lists nested requirement usages and definitions under a root
+  in tree order with `shortName`, `name` and `documentation`, which the requirements example
+  renders as a table of its report.
+
+- **Record analysis runs into the model.** `%record <case> [into <package>]` at the REPL and `-record-run <case>` on the command line run an analysis case as `%analysis`/`-analysis` does and write the run into the model as `AnalysisRecords` elements — a record definition per case, one part per run carrying the inputs bound and outputs produced, and `@AnalysisRecords::RecordedRun` provenance metadata. Sweeps (`-sweep`) and Monte Carlo samples (`-runs`/`-seed`) record one part per run, plus one for the sample's conclusion under kind `sample`; records compose with `-convert sysml -o` and `-render-document`, are found by document queries, and an `inout` records the value the run left and a `<name>In` companion for the value it was bound with, and values supplied as Integer and Real alike settle a member to Real and scalar-valued enum literals keep their literal, and a failed run records nothing. A verification case's record carries the verdict its body decided — the `verdict` attribute — and one `VerdictRecord` row apiece for it and each subcase's.
+
+- **The order in which a state's regions are entered, exited and fired across is a recorded choice point.** A composite state's regions, a fork's branches and a history's restored regions were entered in declaration order, exited in declaration order, and the transitions one occurrence selects across regions fired one whole firing at a time, with no choice recorded for `explore` to vary. Each site now runs its regions as the queues of one front, a unit — one state's entry, one state's exit, one segment's effect — at a time, and each draw of which region's next unit runs is a choice point (`choice entering work: next left(entry), right(entry) …`, `exiting <state>`, `fork <name>`, and `on <event>` for the units of the firings) written to the trace and the witness, replayed, refused and rolled back with its move, and enumerated by `explore` and `check`. `declared` and `reverse` take the order the runtime always took, so no event order under a fixed policy moved and existing trace goldens gain only `choice` lines; a unit that performs no behavior is drawn with the performing unit beside it, so exploration counts linearizations of behaviors. Seven conformance cases state the admissible sets (`state_region_entry_order`, `state_region_entry_order_uneven`, `state_region_entry_nested_front`, `state_fork_branch_order`, `state_history_restore_order`, `state_region_exit_order`, `state_firing_units_interleaved`). The PSSM referee moves *Exiting 001*, *Exiting 003*, *Fork 002*, *Terminate 001* and *Deferred 006 C* to `pass` (51 pass, 13 fail, 38 not expressible, 1 differs by design).
+- **A design note on the order of orthogonal regions** (`docs/internals/design/region-order-scheduling.md`) records the sites above as implemented, the alignment row deciding that a firing is not atomic across regions (argued from the KerML library's successions), and the designs of two further sites: a due `do` step against the dispatch at the head of the pool, drawn per token move of the `do` flow (landed below), and the firing of a completion a region's entry enables drawn inside the entry front, still open. Until that one lands, *Entering 010*, *Entering 011*, *Junction 005*, *History 001-C*, *History 002-B* and *Terminate 002* stay `fail` in the referee record with that attribution.
+
+- **Run-to-completion redefinitions now execute with their lowered values and scopes.** Entry cascades expose free-dispatch versus held-entry ordering to execution, checking, exploration and replay, while invalid scopes remain typed lowering errors.
+
+- **A satellite-network stress workload and its scaling record.** `go run ./cmd/stress-model -planes P -satellites S -ground-stations G` writes a constellation in which every spacecraft is modeled to its components — seven subsystems, twenty components with unit-bearing attributes, power and data connections, mass and power budgets, requirements with satisfy assertions and a mode machine — plus crosslinks and ground-station downlinks; `-stats` reports the satellites, components, connections, requirements, declared elements and bytes it wrote. `tests/stressmodel` generates the same model in-process and benchmarks loading (with retained heap), satisfaction and an editor keystroke beside the open model. `docs/project/satellite-network-stress-test.md` records how `sysml -validate`, `sysml -satisfy` and per-edit re-analysis scale from 2 to 12 800 satellites (2.4 million elements), where each stops being practical, and what the profiles show.
+
+- **The architecture self-model now shows the runtime from the inside.** A new
+  `examples/self-model/execution.sysml` models the instance layer as the six units it is — the
+  schema built once per type, the allocator, the lazy reader, the binding propagator, admission
+  and the dependency tracker — with the value flows between them, the scheduler with its kinds
+  of choice and policy spellings, and one action executed as an interaction from the surface's
+  request through lowering, stepping, scheduling, evaluation and every feature read or written.
+  `behavior.sysml` gains `ReadFeatureValue`, one feature read whose decision nodes are the cases
+  a feature can be in (undeclared, bound, held, a variation, a `default` yielding to
+  contributions, a stated value, a connector, a composite). Three views render them —
+  `instanceLayer` as an interconnection diagram, `featureReadFlow` as an action flow and
+  `actionExecution` as a sequence diagram — and the architecture document embeds all three in
+  its validation-and-execution section. The self-model test checks the modelled layer against
+  the runtime: the effective feature's field count, the schema's memoization, the refusals the
+  reader and admission spell, the scheduler's choice kinds and policy spellings, and every path
+  of the feature read.
+
+- **A send's `to` clause accepts any expression and addresses the objects it yields.** `send m to cars#(2)` or `send m via p to cars#(2)` now deliver to the objects the expression evaluates to, where before a receiver that was no name or feature chain silently fell back to the sender itself. A receiver yielding no object, a non-object value, or a destroyed object is a typed error, as is a routed send whose receiver object no connection reaches.
+
+- **Sets and tensor quantities in RDF are the expressions that produce them, resolved by design.** The RDF mapping writes a model, never an evaluation, for every value kind, so a `Set`-, `UniqueCollection`- or `Map`-typed feature and a tensor of any rank need no literal form: they export as standard `OperatorExpression`/`LiteralExpression`/`FeatureReferenceExpression`/`InvocationExpression` trees with no new `sysx:` term, round trip exactly with the source text stripped, and the model read back evaluates to sets equal in whatever order their members were written and to tensors of the same shape and components. New tests pin the contract, including negative controls that remove each structural predicate; the native compilation of both remains open.
+
+- **`ShapeItems` derived geometry evaluates where the library determines it.** A binding connector's own multiplicity is the number of links it declares, so `binding [1] bind [0..*] base.edges = [0..*] be` relates a `Disc`'s edge to some value of `be` instead of binding `be [2]` whole: a `Cylinder`'s or `Cone`'s `faces`, `base.edges` and `af.edges` answer (before, every read through `be` was a multiplicity violation), and so do those of a `Cylinder` nested as a `Box`'s `voids`. A `Box` also answers its per-face `vertices`. What the library leaves open stays a typed error naming why — the `[0..1]`-bound edge and vertex groups (`tfe`, `tflv`, `Box::vertices`), the curved face's edges through `cf : Surface`, and the frame features `matingOccurrences`/`spaceBoundary` — and binding diagnostics quote the connector multiplicity (`binding [1] bind …`). The declared link count is checked too: `binding [2]` identifying one value is a multiplicity violation, and `binding [0]` links nothing.
+
+- The site's header menu (and its footer row on narrow screens) links to lord.opensysml.org.
+
+- **A fifth runtime-showcase model: a spacecraft downlink.** `examples/runtime-showcase/spacecraft-comms.sysml`
+  re-spells the OpenSE Cookbook's Spacecraft Example in current SysML v2: a ground station and a
+  spacecraft with conjugate ports on a `CommunicationLink` interface, a `parallel` state machine
+  whose `dataTransit` region sends frames and drains the battery in a forked do action while its
+  `charging` region recharges on a change trigger, a `BatteryLow` signal that interrupts the
+  transmission and a change trigger that resumes it. The walkthrough runs both parts on one clock
+  with `%advance`, reads values off either object with `%eval in <object> : <expr>`, and shows
+  where three timers falling due at the same instant leave `-schedule` a choice — 49 or 50 frames
+  before the first interruption — while every schedule reaches the same end: 100 frames received
+  at t=241. The REPL tests pin those checkpoints under `reverse`, `declared` and two seeds.
+
+- **A document query reads the state a session's objects are in and the trace their run recorded.** `States(source = <rows>)` answers one row per active leaf state of each object's machine — every orthogonal region, with `machine`, `name`, `statePath`, `region` and the `enclosing` composite states — and `InState(name = "<state>")` the held objects whose machine is in that state, by leaf or enclosing name or dotted path. `Events(source, kind, since, before)` answers the trace as rows in the order the run made them — accepts, sends, transitions, entry, exit and do steps, `choice` draws with their alternatives and the one taken, unevaluable guards — each with its instant on the clock, its object and machine, the states touched, the payload and the line `-trace` prints; `kind` keeps one or several kinds and `[since, before)` an interval inclusive at the start and exclusive at the end, in the clock's unit or a duration. `WhereFeature`, `WhereName`, `WhereType`, `Project`, `OrderBy` and `Column` read the rows as they read object and verdict rows. Each unsupported path is a typed error: no session, no trace recorded, an object exhibiting no state machine, a state no machine declares, a bound that is no instant on the clock, an interval that ends before it starts, or a state or event row where an element or object is asked for. The rows print from `%run-query` and `-run-query` — which now runs after `-state`, `-action` and `-advance`, so it reads the run's end — render in Markdown, HTML (`span.sysml-state`, `span.sysml-event`) and PDF documents, and cross `RunDocumentQuery` as the `state` and `event` arms of `DocumentValue`, which the Go and Python clients decode and refuse to bind; the recipes are in the query cookbook.
+- **The trace is a typed record the printer writes from.** `runtime.TraceRecorder` keeps each accept, send, transition, entry, exit, do step, choice and guard as a `TraceRecord` with its instant, object and behavior, and prints `-trace`'s lines from those records, so the printed trace and the `Events` rows cannot disagree; the printed output is unchanged.
+- **A manual page tells the query kinds apart.** *Which query is which* distinguishes document queries over elements, objects, verdicts, states and events, the API `Query` over a project, `Evaluate`, `solve`, and the runtime population `all T`: what each returns, what each cannot see, and where each is reached.
+- **`States` and `Events` refuse a destroyed object.** A `source` bound to an object the run destroyed fails with a typed `object-destroyed` error naming the object and the activation mark at which it was destroyed, as `%features` reports lifetimes, instead of answering a stale row or a feature-evaluation failure; a terminated machine answers no state rows, while a completed one reports its final state. A destroyed object leaves the population — `Objects`, `InState` and element-derived sources skip it, and `Events` still resolves its label — so a session's other objects keep answering once one is destroyed.
+
+- **A continuous model runs by time-stepping.** An action specializing the analysis library's
+  `ContinuousStateSpaceDynamics` or `DiscreteStateSpaceDynamics` is run as a fixed-step
+  state-space simulation: the bundled `StateSpaceIntegration` library adds `FixedStepDynamics`
+  (`timeStep`, an optional `stopTime`, a `time` the run writes), the integrators `Euler` and `RK4`
+  a model binds to `getNextState`'s `integrate` (RK4 when it binds none) and the `ZeroCrossing`
+  event; discrete dynamics step by `getDifference`. Each step advances the runtime's shared clock,
+  so a state machine exhibited beside the dynamics sees the same time, its `accept after`/`at`
+  triggers fire in step order, and a step and a trigger due together are a `due order` choice
+  point the scheduling policy decides. An `event occurrence` typed by `ZeroCrossing` posts an
+  event of its type when its `guard` changes sign at a step, which a machine's `accept` takes, and
+  ends the dynamics when `terminal`. The run records `state: <action> t=<instant> x=<state>
+  y=<output>` per step in the execution trace and reports `stateSpace`, `output` and `time` as
+  the action's outputs. A shape the runner cannot run — a state, input, derivative or output that
+  is not a vector, a protocol calc left abstract, an integrator the runtime does not provide, a
+  step that is absent, zero or negative, a state that leaves a step non-finite — is a typed error
+  naming the action and the member at fault.
+
+- **A model states its own odds.** Two non-normative OpenSysML libraries add what SysML v2 has no
+  notation for: `Stochastic::Probability` weights the successions out of a decision node
+  (`first d then fast { @Probability { p = 0.7; } }`), and `RandomFunctions` declares `uniform`,
+  `uniformInteger`, `triangular` and `normal`, so `attribute d : Real = uniform(0.0, 1.0);` and
+  `accept after uniform(1, 80) [s]` run. The weights are validated at lowering — every succession
+  out of a decision weighted or none, each in `[0, 1]`, constant weights summing to one. The flow among an analysis or verification case's steps reads the same weights, and the exported action graph carries each weight beside its edge. Modeled randomness is a
+  stream of its own, apart from the token-shuffle stream: `-seed <n>` and `%seed <n>` fix it
+  whatever the scheduling policy, `seed:<n>` seeds it too when no model seed is set, an unseeded
+  weighted decision under `declared` or `reverse` takes its most probable branch, and an
+  unseeded random function is refused naming the flags that seed it. The scheduling choice points
+  the spec leaves open — token, write, region and due order — stay unweighted, and `explore` and
+  `check` still enumerate and search weighted branches as a set. Every draw is recorded in the
+  witness (`draw uniform(0.0, 1.0) = 0.7748…`) beside the weighted pick, so `%replay` and
+  `-schedule replay:<file>` reproduce a run exactly and refuse a witness whose draws they cannot
+  consume; the trace reports each weighted decision with its weights and its draw.
+- **Monte Carlo runs.** `%runs <n> <seed> <action> [<observable>...]` and
+  `sysml -action <a> -runs <n> -seed <s> [-observe <f>]` run an action `n` times, each under a
+  model seed derived from the seed and the run number, on the sweep machinery, and report the
+  table of the observables — every feature the action holds and `clock` when none is named —
+  then each numeric observable's min, mean, max, p50, p90 and a compact histogram, as
+  `%samples` reports a table. Runs are reproducible for a seed and distinct across seeds.
+
+- **A plain `flow` between action parameters streams.** SysML v2 §7.16 makes parameters streaming unless a flow is designated a `succession flow`, and the runtime now reads them so: each value written to the source pin — in the source's body, or carried back from a node under it — reaches the pin of every ongoing performance of the target at once, so a consumer performing beside its producer reads each value the producer writes; a value written while no performance of the target is under way waits at the pin for its next one, and a further write from the same source performance replaces it, so a target begun after its source reads the pin as the source left it. The lowered `ObjectFlow` carries the kind (`FlowStreaming` or `FlowSuccession`) from the declaration, and a `succession flow` runs as before: the value the pin holds when the source completes moves, and the target begins after. A source that completes without ever writing the pin (`ErrFlowSource`), a write after the target's last performance ended that no later performance takes (`ErrStreamUnreceived`), a stream to a pin the target does not declare (`ErrNodePin`), and streaming flows that lead a value back to the pin it was written to (`ErrStreamCycle`) are typed errors. The outputs of an action a node performs stream from the node as the performance writes them, its declared output values as the performance begins. The checker's footprints count the target pins a node's writes stream to, and the SMT encoding streams writes as the interpreter does, a value arriving after its target's last performance failing the action as it completes.
+
+- **SysML v1 migration is documented on every surface it reaches.** Guide chapter 11 walks one v1 export through `-convert`: reading the report's four verdicts, running a migrated activity under the action debugger, comparing a migrated run configuration with the results its tool stored, and finishing by hand what the mapping reports as unmapped. The gRPC `Convert` contract, the proto comments, the Go client's `FormatXMI` and the Python client's `convert` docstrings say that `xmi`, `uml` and `mdzip` are read and migrated, never written, and that the conversion is reported experimental; the Go client gains a regression test that migrates the vehicle fixture over the wire. The roadmap records what the migration still leaves — units and quantity kinds, the report over gRPC, identity across a re-migration — as its own item.
+
+- **A design note for an Eclipse SysON plugin** (`docs/internals/design/syson-plugin.md`). It
+  records, against SysON release `v2026.9.0`, how a jar contributes beans to the SysON backend
+  and a React component to its frontend, the textual exporter and its gaps, the SysIDE-based
+  importer and where a pre-import check sits, the partial SysML v2 REST API and the fact that
+  SysON's standard-library `elementId`s equal the normative UUIDs `internal/semantic/identity`
+  derives, how a qualified-name `Symbol.id` maps to an EMF element, how diagnostics reach the
+  Validation view, and the verdict of `sysml -validate` on every textual model in the SysON
+  repository; then the architecture of a future `editors/syson/`, a four-phase plan and the
+  unknowns. `editors/README.md` now introduces each editor integration. Nothing is implemented.
+
+- **The SysON plugin can run OpenSysML from the explorer.** The `runWithOpenSysML` mutation
+  supports instantiation, action and state execution or exploration, constraint and requirement
+  verification, satisfaction verification, calculation evaluation, analysis and instance
+  validation; the “Run with OpenSysML…” entry opens a dialog, maps diagnostics into the Validation
+  view, and supports an offline compile-only stub build plus an opt-in real-artifact profile and
+  workflow.
+
+- **`terminate` runs in an action.** A `then terminate;` node, a named terminate action usage
+  (`action stop terminate;`, whose marker the parser used to drop) reached by a succession, and a
+  `terminate;` statement of a nested action node's body (which lowering used to leave out) end the
+  performance they are written in with the outputs assigned so far: later nodes do not run, every
+  other token of that performance is dropped — a forked branch still running or parked at an
+  `accept` included — in an order the trace records, and a nested node's parent continues along the
+  node's succession. `terminate <name>;` ends every ongoing performance of the named action node
+  of the flow it is in or of a flow around it, the node itself included. A performance that already
+  ended and a name that is no action node of an enclosing flow are each a typed error rather
+  than a silent no-op; a `terminate` in a calculation is refused as before.
+
+- **`terminate` ends an occurrence, a state's behavior or the state machine.**
+  `terminate <occurrence>;` evaluates its target — `this`, a part's feature chain
+  (`terminate vehicle.engine;`), a nested action node's own occurrence — and ends that
+  occurrence's lifetime: its owned parts, the behaviors it exhibits or performs, and any action
+  or state performance running on it stop where they are, keeping their values, and a part of
+  it first read afterwards is reached ended too, no behavior of it started; a name that
+  denotes no occurrence, one already ended, and one `destroy` emptied are each a typed error.
+  A `terminate;` in a state's `entry`, `do` or `exit` body ends that behavior at the statement,
+  the state stays active and the machine keeps dispatching. A transition whose target is a
+  terminate action (`transition first idle accept Abort then stop; action stop terminate;`) ends
+  the state machine's performance as SysML v2 §7.18.3 and the PSSM's terminate pseudostate
+  both prescribe: the source exits and the transition's effect run, then no further state is
+  exited, running do behaviors are abandoned and no state remains active — reached directly, or
+  through a choice, junction or join, from inside a composite state or one region of an
+  orthogonal one. Such a run reports `Outcome.Terminated` with no final state, the REPL says
+  `State machine terminated` and `Execution state: Terminated`, the LSP debug snapshot's `state`
+  is `terminated`, `%instances` lists a terminated object as `ended`, and `explore`/`check`
+  count the terminated run as one outcome. The PSSM referee translates the suite's terminate
+  pseudostates the same way, so its `terminate-gap` bucket is retired: *Terminate 003* passes,
+  and *Terminate 001/002* fail on the order an orthogonal state's regions are entered in, which
+  the referee's record already attributes to an open finding.
+
+- **Four graded requirements-traceability examples in the manual.** `docs/manual/traceability-examples.md` walks from three flat requirements and the parts satisfying them (`trace-1-basic.sysml`), through a nested requirement tree with verification verdicts and `Union`/`Except` coverage sets (`trace-2-hierarchy.sysml`) and derivation chains walked one hop and to their ends in both directions (`trace-3-derivation.sysml`), to a multi-package program whose ten-column matrix is grouped by owning team with list, count and any columns side by side (`trace-4-program.sysml`). Each source is committed beside its rendered Markdown, and a test re-renders all four and compares them, so the outputs shown are what the current binary produces.
+- **A wide table in a PDF lands on landscape pages.** A table of seven or more columns, with the heading and caption that introduce it, is placed on a landscape page while the surrounding pages stay portrait; a traceability matrix no longer squeezes ten columns into a portrait text width.
+
+- **`RelatedColumn(name, relationshipKind, direction, maxDepth, aggregate)` projects the elements a relationship reaches from each row.** A `Project` column beside `Column(...)`: `aggregate = "list"` (the default) yields the related elements as a multi-valued cell in traversal order, `"count"` an integer and `"any"` a Boolean, over every relationship kind and direction `RelatedElements` accepts, with the same typed errors and visit budget. The values feed `WhereFeature`, `OrderBy` and a document table's `groupBy` — an element-valued cell compares and sorts as its qualified name — so one query now produces a traceability matrix — every requirement with its satisfiers and verifiers — where a table per requirement was needed before. The cookbook gains a "Traceability matrix" recipe and the manual a rendered traceability report (`docs/manual/examples/traceability.sysml`).
+- **A satisfaction asserted by an object's type and found again in a validation scope is reported once.** Verdicts deduplicate assertions by the declaration they name, not by scope-tree symbol, so a document scope indexed twice no longer doubles a requirement's satisfaction row.
+
+- **`@Probability` weights state transitions.** The `Stochastic::Probability` notation that
+  weights the branches out of a decision node now weights transitions too: of the transitions
+  out of a state competing on one trigger (or the completion transitions), of all the branches
+  out of a `choice` or `junction`, all carry a weight or none does, each weight lies in
+  `[0, 1]`, and the group's weights must sum to one — checked at lowering for constants and at
+  dispatch for expressions. A weighted pick is drawn once among the enabled transitions, after
+  triggers, guards and innermost-wins have run, and is recorded in the witness, so `%replay`
+  reproduces it, `explore` enumerates every weighted alternative, and the trace prints the
+  drawn branch with its weight. Transitions sharing a time-trigger spelling fire as one
+  occurrence drawn by weight, and a weight may read the trigger's bound arguments.
+- **`explore` and `check` report probabilities.** The explore outcome table gains a
+  `probability` column — the product of the shares each linearization's picks resolved with (a
+  weighted pick its stated weight's share, an unweighted choice the uniform `1/n` a seed takes
+  each alternative with), summed over the runs reaching each outcome — and `check` reports each
+  violation's probability mass the same way, `(probability 0.3)` on its line and `mass` in the
+  JSON report. Both are the model's own probabilities where every choice point is weighted, a
+  uniform assumption otherwise; an incomplete exploration prefixes them `≥` and a check that
+  hit a bound, revisited a state or left a move out marks them lower bounds
+  (`probabilitiesLowerBound` / `massLowerBound` in the JSON, `Outcome.probability` and
+  `ExplorationStatus.probabilities_lower_bound` on the wire).
+
+- **The SysML v1 migration writes behaviors that run.** An Activity becomes an `action def`
+  the action executor performs — `first start`, nested `action x : Def;` calls with `bind`/`flow`
+  for their pins, `fork`/`join`/`decide`/`merge`, `send new Sig() to this.part`, `accept p : Sig`,
+  `accept after 2.0 [SI::s]`, `accept when c`, `action x terminate;` for a final node, `if`
+  guards where the guard parses and resolves and the guard text as a comment where it does not —
+  and a block's classifier behavior is performed by a `perform action` usage of its `part def`.
+  A `DurationConstraint` on an action is a wait before it, `accept after lo [SI::s]` for a point
+  interval and `accept after RandomFunctions::uniform(lo, hi) [SI::s]` otherwise, with `1s`,
+  `80ms`, `2 min` literals scaled to seconds; «Probability» on the edges out of a decision is
+  `@Stochastic::Probability { p = … }` when every edge carries one, scaled when they do not sum
+  to one. A StateMachine becomes a `state def` the state debugger steps — nested states, the
+  regions of an orthogonal state as sub-states of a `parallel` state, a submachine state as a
+  `state` usage typed by the referenced machine's `state def`, `entry`/`do`/`exit` behaviors,
+  `transition first s accept sig : Sig if g do e then t;` with relative time and change events
+  as triggers, a deferrable signal trigger as `defer Sig;` — exhibited by an `exhibit state`
+  usage of its block. An Operation is an `action def` owned by the block with its parameters,
+  its method as body — the method's parameters standing for the operation's at the same
+  position under the operation's names — and its conditions as `assert constraint`s; a
+  `CallOperationAction` on an
+  object performs it on that object through
+  `perform action x ::> target.op;`. An OpaqueBehavior or FunctionBehavior whose body is a v2
+  expression is a `calc def`; an Interaction whose messages are all signal sends to parts is a
+  scenario `action def` of `send`s; a Reception is a comment naming its signal. Absolute time
+  events, internal transitions, entry points and history pseudostates, synchronous interaction
+  messages and a tool's time variable have no v2 form and stay comments the report accounts for.
+- **A send addressed to a parameter, pin or local of the sending action reaches the object it
+  holds.** `send new Go() to recipient` under `in recipient : Worker` is delivered to whatever
+  object the caller bound, a chain from it (`team.lead`) walked through that object; a binding
+  holding no object is refused with a typed error rather than the message dropped. A target no
+  binding leads — `this.part`, a port, a name in scope — is resolved as before.
+- **`perform action x ::> part.action;` and `exit part.action;` run on the part.** An action
+  usage referencing a feature chain performs the chain's last action on the object the chain
+  reaches from the performer, as a state's entry, do or exit behavior does; an empty or
+  many-valued receiver, a chain ending in no action and a destroyed receiver are refused with
+  typed errors.
+
+- **Monte Carlo summaries come across as statistics.** A result snapshot of a «SimulationConfig»
+  whose target specializes MagicDraw's `MonteCarloAnalysis` records `N`, `Mean`, `Deviation` and
+  `OutOfSpec` beside the observed values; the `-migration-results` sidecar now writes them as the
+  snapshot's `statistics` of the observable the analysis binds its `Mean` to, standing for `N`
+  runs, rather than as one more run — `deviation` and `outOfSpec` only when the snapshot records
+  them, so a missing deviation is not a zero — and notes a summary that is incomplete, counts
+  no runs or more than a count holds, holds a statistic over several slots, binds no observable,
+  states an `OutOfSpec` that is no count of its runs, or summarises another configuration's.
+- **Every run configuration is compared.** `-compare-results` runs a configuration the tool
+  stored no snapshot of and prints its statistics under a `tool (no stored result to compare)`
+  row; runs one stating no `numberOfRuns` once, as the tool does, under a note saying so; counts
+  the runs a summary stands for and pools raw values with summary means; shows only the
+  statistics a summary holds; and notes a summarising snapshot another configuration stores under
+  the same name with the same statistics as a likely copy, naming that configuration and its
+  result location; and notes summaries of one observable whose means lie more than three
+  standard errors apart, which cannot be of runs of one and the same model, so the pooled mean
+  they are compared by blends them.
+- **A run configuration resolves to an inherited classifier behavior.** The `executionTarget`'s
+  classifier behavior is looked up through its generalizations, nearest first, and a test-case
+  behavior is performed where its scenario is migrated. A target with no classifier behavior at
+  any level whose parts hold constraint properties — a parametric configuration the tool solves
+  for values — is reported per constraint property, naming its constraint block and whether the
+  block's rule is migrated as a constraint or which call of an opaque rule stops it, in place of
+  a blanket refusal.
+- **Snapshots of a run on another classifier are set aside.** A result location may hold
+  snapshots the tool named after a classifier that is neither the configuration's execution
+  target nor a general or special of it; they are of another configuration stored in the same
+  package, so they are not read as the configuration's results and the sidecar says so, and the
+  sidecar carries the notes saying why a configuration runs no behavior.
+- **The clock can tick by a fixed step.** `-clock-step <seconds>` and `%clock-step` make every
+  wait of a run — `accept after`, `accept at`, a state's timer, a case's timed step — come due at
+  the first multiple of the step not before the instant it ends, as a simulation tool's fixed-step
+  clock does; `0`, the default, keeps the continuous clock. The step reaches the run, explore,
+  check, sweep and standing engines, the external-engine protocol (`clockStep`) and the gRPC
+  handlers as the draw policy does; a witness of a stepped run records `clock steps by <seconds>`
+  and replays on it. A migrated «SimulationConfig» stating `startTime` ran on the tool's internal
+  clock, so the `-migration-results` sidecar records its `stepSize` in `timeUnit` (`1.0` and the
+  millisecond, the tool's defaults, unless stated; `(endTime − startTime) / numberOfSteps` when
+  those two stand in for the step) as `clockStep`, in seconds, and
+  `-compare-results` runs the configuration on it — a unit of no fixed length, a step of zero or
+  less, one of more seconds than a number holds or fewer than it tells from none, and an unstated
+  unit are noted. The tool's clock started at `startTime` and a run's starts at 0, so a
+  `startTime` other than 0 is noted, in the report, the sidecar and the comparison, as offsetting
+  every instant read on the clock.
+- **A script's console print is left out.** A `print(…)`, `println(…)` or `System.out.println(…)`
+  statement of an opaque body writes to the tool's console and changes nothing of the model, so
+  the SysML v1 migration leaves it out of the translation, keeps the other statements of the body,
+  and notes each print left out as an approximation; a body of prints alone is an empty action.
+  A print whose argument assigns, counts, deletes, constructs or calls anything but a function of the table computing
+  a value (a Java `equals` counts only on a receiver known to be a string; any other type's is
+  that type's own method) could change the model, so it is refused rather than left out; a call
+  not in the table, or a print used as a value, is refused as before.
+
+- **The v1 migration translates opaque JavaScript and English bodies into executable v2.** A bounded subset of a v1 tool's scripting language — assignments and compound assignments, `var x = e`, literals, feature paths, arithmetic, comparisons, `&& || !`, a trivial ternary, `Math.min/max/abs/floor/ceil/sqrt/pow` and `java.util.Collections.max/min` — becomes `assign` statements in an action body, an `if` guard on a succession, an attribute default, a `constraint def` expression or a `calc def` body; a guard in English (`TRUE`, a Boolean property's name, `not X and Y`, `a = b`) becomes the `if` it means. Anything outside the subset is refused whole with the offending token in the report, never translated in part, and a body the translator reads is never re-read as v2 syntax; an English body has no calls, and a script label is read only when every word of it names JavaScript, ECMAScript, JS, Rhino, Nashorn or one version (`JavaScript Expression Language` is another language). A Java body's `/` of two whole numbers is `OpenSysMLMathFunctions::quotient(x, y)`, the exact quotient truncated toward zero; its `Math.floor` and `Math.ceil` answer a double, so a `/` after them stays real division, while its `Math.round` answers a long. A Java body's `a.equals(b)` on strings is `a == b`, and its `==`/`!=` with a string operand is refused, Java comparing strings there by identity. A constraint's specification is checked to yield a Boolean: an integer, real, string or enumeration literal is left as a comment naming the value, a string spelling `true`/`false` written as that Boolean.
+- **`OpenSysMLMathFunctions::ceiling(x)` and `quotient(x, y)`** join the non-normative math extension library. `ceiling(x)` is the least Integer not less than `x`, the counterpart of `RealFunctions::floor` (`ceiling(-2.5)` is `-2`; the least Integer is a value, where `-floor(-x)` overflows on its negation), and `ErrArithmeticOverflow` at or beyond 2⁶³ or below −2⁶³. `quotient(x, y)` is the Integer quotient of two Integers truncated toward zero (`quotient(-7, 2)` is `-3`), exact over the whole range where `/` answers a rounded Real, `ErrDivisionByZero` for `y == 0` and `ErrArithmeticOverflow` for the one pair whose quotient is 2⁶³, the least Integer by `-1`.
+- **Swimlanes give names their object.** Names in a body or guard resolve first against the object the `ActivityPartition` `represents` — a property of the context block, written `this.tcs.i`, through nested partitions, `this.tank.valve.open`, or the block itself — then against the activity's parameters and locals, then the owning block. A partition that resolved a name is reported *mapped*; one with `represents` unset, dangling, untyped, or naming a classifier the activity does not run in falls back to the activity and says so. A part whose multiplicity is not written in numbers may hold one object or several, so a name read through it, a partition representing it, and a partition whose object is reached through it are refused with the part named, never read as one object.
+- **The simulation clock is executable.** A body reading the tool's time variable (`simtime`, or the `SimulationConfig.timeVariableName` the model sets) reads `localClock.currentTime`, the standard library's own form, which the runtime evaluates against the run's clock (`Occurrence::localClock`, `Clock::currentTime`), so `Time_Acq_Total = simtime - Time_Acq_Total` is an attribute a run tables with `-observe this.Time_Acq_Total`. A `DurationObservation` between two nodes of an activity becomes such an attribute (`[0..1]`, no default), stamped at the first and assigned the elapsed clock at the second (an initial node is stamped right after `start`, so an observation from it to the final node spans the run; a flow final or a control node no edge leaves as the token reaches it, before `done`), and left without a value by a run that does not reach both; one whose events are not nodes of the activity, name an element the document does not define, or owned outside one, is a comment whose report line says which. An assignment to a clock's `currentTime`, under that name or a redefinition's (`attribute now :>> currentTime;`), is the typed `ErrClockNotAssignable`, and the redefinition reads the clock as `currentTime` does.
+- **Report hygiene for leaf steps and symbolic durations.** A `CallBehaviorAction` calling no behavior whose only content is a `DurationConstraint` is a leaf step reported *mapped*, its wait written; only a call with pins or an unresolved behavior stays *unmapped*, the pins named. A duration bound that names a property (`ditSetup s`) resolves like any other name and is written `accept after this.tcs.ditSetup [SI::s]`.
+- **An object typed by a behavior runs no classifier behaviors of its own.** A `perform`/`exhibit` member of an `action def` is a step of the performance that runs it, not a behavior bound to the performance occurrence, so a migrated workflow's sub-activities run once each, as the performer.
+
+- **A run resolves its random draws under a policy.** `-draws random|min|max|average` and
+  `%draws` state how every `RandomFunctions` call of a run resolves: `random` (the default) draws
+  from the seed as before; `min`, `max` and `average` take each call's least, greatest or mean
+  value — `uniform(1, 80)` is `1`, `80` or `40.5`; `uniformInteger(1, 6)` averages to `4`;
+  `triangular` to `(lo + mode + hi) / 3`; `normal` averages to its mean and, unless its
+  deviation is zero, has no `min` or `max`, which is a typed error naming the call — and need no seed, so a random duration
+  becomes a fixed one and `-runs`/`%runs` run without `-seed` under a fixed policy (`%runs <n>
+  <action>`, the seed left out). Weighted decisions draw from the seed whatever the policy and
+  take their most probable branch unseeded. The policy is a property of the run's context, so
+  it reaches the analysis engines, the wire (`"draws":"max"`) and gRPC as the model seed does;
+  a witness records it as `draws by max` and `replay:` reproduces the run under it, refusing a
+  witness whose draws the recorded policy could not have made; a witness naming a fixed policy
+  and recording no draw — an external engine's schedule, which carries choices alone — leaves
+  them to the policy. A conformance case pins it with `"draws"`.
+- **A «Probability» that names a property is a feature reference.** The SysML v1 migration
+  writes `@Probability { p = ProbabilityBTOOP; }` when the tag names a property visible from
+  the activity or its context block, by name or id, instead of the property's default, so the
+  object the behavior runs on decides the branch weights; a tag naming nothing visible, a
+  private property, a non-numeric or a multi-valued property is a report entry with the reason.
+  A feature-valued weight is type-checked against `Probability::p` where it is written, and the
+  checks lowering makes of constants — each in `[0, 1]`, the set summing to one — are made of
+  the values read when the decision is reached, each a typed `ErrBranchWeights`.
+- **Simulation run configurations migrate.** A MagicDraw «SimulationConfig», recognised by its
+  profile's provenance, becomes an `action def` holding its `executionTarget` individual as
+  `part target` and performing the target's classifier behavior on it, annotated
+  `@Simulation::Configuration { runs = …; draws = DrawPolicy::…; timeVariable = …; startTime = …;
+  stepSize = …; timeUnit = …; parallelForks = …; }` — a new non-normative library beside
+  `Stochastic` that records how the tool ran the behavior and applies none of it — with every
+  setting of no v2 meaning kept in a comment. The configuration, its target and result
+  instances and the probability edges it reads are mapped rather than unmapped; a target the
+  migration did not write, one that is no part, a state machine or a classifier with no
+  behavior is reported with the reason. The outcome of an action spells `<part>.<attribute>`
+  for the one object each of its own parts denotes, so `-observe target.duration` reads
+  the target's attribute after the run.
+- **The tool's results come across, and a harness compares them.** `-migration-results
+  <file>` writes a JSON sidecar indexing the tool's result-snapshot instances — typed, or
+  classifier-less under a configuration's `resultLocation` with slots of features of one lineage
+  of blocks the target is of, which types them; a classifier-less instance anywhere else stays
+  unmapped — per configuration and observable; `sysml <migrated>.sysml -compare-results <file> [-action
+  <configuration>...] [-runs <n>] [-draws <policy>] [-seed <s>] [-observe <stored>[=<feature>]...]`
+  runs each configuration with its recorded run count and policy, or the ones given, and
+  reports the tool's and OpenSysML's min, mean, p50, p90 and max of each observable with the
+  relative difference; a configuration with no stored snapshots, a stored observable no run
+  holds and a non-numeric one are reported, never left out, and a run that fails fails the
+  comparison with its error under the table; an `-action` no configuration bears fails the
+  check beside the ones compared. The numbers are reported as run.
+
+- **The SysML v1 migration writes interactions, receptions and the rest of a state machine
+  executably.** An Interaction owned by a block is a scenario `action def` of every message
+  kind: a signal send, a `synchCall`/`asynchCall` of an operation as a typed perform on the
+  lifeline's object — `perform action spin : Motor::Spin ::> drive.motor.spin { in rpm = 30.0; }`,
+  the arguments bound to the operation's `in` and `inout` parameters by name or position — an
+  unnamed argument taking the next parameter no named one claims — each
+  with the parameter's direction so an `inout` value is written back — and a `reply`
+  as the assignment of the call's result to the caller lifeline's attribute; a lifeline is
+  resolved to the feature path through the block's parts, ports and references or to an `in`
+  parameter, and `alt`/`opt`/`loop`/`par` fragments are `if`/`for`/`while`/`fork` structures
+  when their guards parse and resolve, a reply answering the latest open call of its operation
+  between its lifelines, never one another alternative or a concurrent operand made; a duration
+  constraint between two messages with steps
+  between them is a wait forked after the earlier step and joined before the later, so those
+  steps count toward the interval, and one whose interval is open on one side (a min with no
+  max, a max of `*`) is reported with the bound it lacks rather than written as a wait at the
+  bound it has, the min beside an expressionless max of a MagicDraw document — its encoding of
+  a one-valued `{60s}` — excepted. A lifeline or guard that does not resolve, a create or
+  delete message and a message-less timing trace are refused with the reason, as is a call
+  or signal message leaving an `in` parameter or signal attribute — inherited ones included —
+  with no default and a lower bound above zero unbound; two parts of
+  one type are two paths, so a lifeline standing for a part of that type is ambiguous. A Reception is
+  an `action def` of the block that accepts its signal, runs its method with the signal's
+  attributes bound to the method's parameters of the same name and accepts again, performed by
+  every object of the block from creation — a method that is also the method of an operation runs
+  as that operation's `action def` — so a signal sent to the object at any time runs the
+  method against the object; where the signal arrives at ports of the block, the accept is forked
+  into one loop per port, `accept … via <port>`, beside the one from the object; where the method requires a value no attribute supplies,
+  or a same-named attribute does not fit its parameter's type or multiplicity, the reception only
+  accepts the signal and says so. State machines gain transitions across regions and
+  nesting levels named by path, `junction`/`choice`/`fork`/`join`/`history`/`deep history`
+  pseudostates, entry and exit points of a submachine as states of its `state def` addressed
+  by path, internal transitions as self transitions where re-entry is not observable (written
+  with no target, as some tools do, they stay in their source; one targeting another vertex or
+  leaving a pseudostate is refused), and
+  absolute time events as `accept at <instant>` over a `Time::TimeInstantValue` attribute of
+  the behavior. A `CallOperationAction` over a port performs the operation on the part a
+  connector of the caller's block joins to that port, the way connector paths resolve.
+- **The migration report counts what nothing refers to apart from gaps.** An event no trigger
+  names is skipped as a model element nothing refers to, counted apart from profile and library
+  content in the summary line, rather than reported as unmapped.
+- **A typed action usage that references a feature chain performs the chain on the object it
+  reaches.** `perform action x : Def ::> part.action { in p = v; }` runs the part's action with
+  the part as performer and binds the callee's inputs from its body, and an accept payload is
+  visible from the body of a typed usage in the same action body, so a nested typed action can
+  read the accepted message (`in level = msg.level`).
+- **A migrated state values its entry and do parameters from the signal that enters it.** When
+  every transition into a state accepts the same signal and its attributes fit the behavior's
+  parameters in order, type and multiplicity — the attributes the signal inherits from its
+  generals counted with its own — the `state def` keeps the signal in an item
+  (`item setPoint : SetPoint;`) each transition assigns and the parameters read
+  (`in target : ScalarValues::Real = setPoint.level;`); a state entered without a signal or with
+  one that does not fit is reported with the transition or attribute that is the reason. A
+  region holding no vertex is skipped when the machine's states are named as when they are
+  written, so a machine whose other region is populated is written inline and a transition
+  across nesting levels names its far end by a path that exists. A
+  trigger naming no port is also written accepting via each port of the owner its signal arrives
+  at — one the document's connectors and delegations carry a send of it to, one an item flow
+  conveys it to, or one whose type (generals and realized interfaces included) declares an inward
+  flow property or a reception of it — with the ports that declare nothing and receive no send
+  reported as left unrouted, and an activity whose required input pin only
+  parameters nothing values flow into is reported as never firing instead of written to wait.
+- **A `via` path can start at a bound reference, and delegated, redefined and untyped ports
+  route.** `send … via ctx.p` from a behavior whose `ctx` is bound to another object leaves that
+  object's port even when the performer owns a feature of the same name, the binding shadowing
+  it as it does in every other expression, while `via this.ctx.p` stays the performer's own,
+  and a state transition's `accept … via ctx.p` resolves its path the same way through the
+  machine's parameters; the behavior's own connectors are read the same way, so a
+  `connect ctx.p to snk.local` between two bound references carries that send beside the
+  connections of the object holding the port, and an addressed `send … via ctx.p to m`
+  names a machine of the object the path is re-rooted to;
+  a part's port is known to the connectors its type inherits under the name the
+  part was declared with before redefinition; a `ref` usage holds what is bound to it rather than
+  an object of its own; and an untyped `port` materializes as a `Ports::Port`, so a binding
+  connector can join it and a signal sent inward over it reaches the bound part's machine.
+- **A migrated call or send that v1 fires without a required value keeps its place and performs
+  nothing.** A call passing no argument for a parameter that must hold a value — a parameter of
+  the operation's action def, which declares the operation's parameters and then those its method
+  adds, so a call binds and is checked against exactly what is declared — or a call or
+  signal send passing none for a signal attribute that must, one passing a pin of a type the attribute cannot take, or one whose pin is fed only by flows no value travels — from a parameter nothing values,
+  an unmigrated opaque or value specification action, a callee whose own activity gives that
+  `out` parameter no value, judged through any depth of nesting, or a call's result pin past
+  the callee's `out` parameters, which stands for none — is written as an empty action carrying the token,
+  with the reason in its comment and report line, and the object flow is kept as a comment
+  rather than written from a feature that will hold nothing. Control and buffer nodes only
+  object flows lead to route their values from source to pin, a control node no edge leaves
+  ends the token as `done` does, and an action fed by an object flow from outside its control
+  path waits for the value only when the producer runs on every pass of the surrounding loop.
+
+- **An object is validated as a whole.** `%validate <object>` at the prompt, `-validate=<object>`
+  on the command line and the `ValidateInstance` RPC evaluate every assertion about an object and
+  the objects it holds — each `assert constraint` the carrier's type declares or inherits, each
+  requirement usage it carries, and each `satisfy` assertion whose subject is in the tree — against
+  the concrete object carrying it, nested parts and every element of a collection included, and
+  report one verdict per assertion per object, labelled by the path from the object validated
+  (`car.wheels[2]`), then one verdict about the object itself. A condition that evaluated false is
+  violated; one that could not be evaluated is undecided with the reason, and leaves the object
+  not shown valid rather than valid; a walk cut short by an object graph without end is reported
+  bounded and not valid; an object no assertion is about decides nothing and is not shown valid
+  either. A constraint declared without `assert` is not swept, and a symbol with no object to
+  validate — a package, an attribute — is refused as the wrong kind. The object is named
+  as every prompt command names one: by the name it was instantiated under, by id, or by a path
+  into what it holds. `-validate` without an object still checks only that the model analyses
+  cleanly. Over the wire the response carries each verdict's `instance_path` and a `summary`
+  verdict of kind `object`; the Go client answers `Client.ValidateInstance` with a `Validation`
+  (`Valid()`, `Violated()`, `Bounded`) and the Python client `Model.validate_instance` with a
+  `Validation` (`valid`, `violated`, `undecided`, `bounded`), each verdict carrying its
+  `instance_path`.
+
+- **A worked example of verdict queries.** `examples/verdicts-demo/` holds a rover whose
+  constraints, requirement, `satisfy` and verification case are read as a `Verdicts(...)` table:
+  twelve rows over the declared object, then the same queries over the object a session holds
+  after a drive, with a violated satisfaction beside a passed verification and an undecided
+  constraint beside a violated one. The walkthrough spells out what the table checks that
+  evaluating one expression does not.
+
+### Changed
+
+- **The RDF graph a model converts to states its relationships as elements.** Turtle and `api-json` output now carry the metamodel's relationship elements alongside the collapsed properties — graphs written by earlier releases still read, but a re-conversion of the same notation states more subjects (the materialized elements), and the same holds for the goldens the convert fixtures pin. Keyword-headed usages that wrote the plain metaclass now write the usage's own — `perform action` is `PerformActionUsage`, `exhibit state` is `ExhibitStateUsage`, `include use case` is `IncludeUseCaseUsage`, and a bare or metadata-body member that takes no kind keyword is a `ReferenceUsage` rather than the kind's usage.
+
+- **A braced `entry { … }`, `do { … }`, `exit { … }` or transition `do { … }` block is one anonymous action.** The block parses as the one action usage SysML.xtext reads it as, the same tree as `entry action { … }`, rather than as one action per statement. A declaration inside the block (`entry { attribute k : Integer = 2; assign log := k; }`) is local to the block and shadows the state's, and is not visible outside it; a `terminate;` in the block resolves to the block's own performance and ends the whole block; a `do` block runs one statement a round as an inline do body does, so orthogonal regions still interleave statement by statement and a transition out of the state drops the rest of the block. `-trace` output changes shape: the block's statements are indented under one `stmt action body` line and the per-statement behavior lines and the `terminate …: ended before it began` lines of the statements after a `terminate` are gone. In the RDF mapping a braced block is the nested anonymous `ActionUsage` — a state subaction's one member, a transition effect's under `sysx:hasEffect` — no longer its statements under the membership with `sysx:hasBody` or `sysx:bracedEffect`; a Turtle graph written in that older shape is refused as unsupported rather than read back as something else. The formatter and `sysml -convert sysml` keep the spelling as written.
+
+- The Cameo v1 export is now written under `~/.opensysml/cameo-exports` instead of the system temp directory.
+
+- **The Python, Node, Java and Rust clients moved from `clients/` to `client/`, beside the Go client.** `client/java`, `client/node`, `client/python`, `client/rust` and the shared `client/release-digests.json` replace their `clients/` counterparts; package names, Maven coordinates, crate names and the Go import path `client/opensysml` are unchanged. Build targets, protobuf generation outputs, CI jobs and the changed-area detection follow the new paths.
+
+- **A completion enabled by a region's entry stays a run-to-completion step of its own; the
+  design that would have drawn its firing inside the entry front is closed without code.**
+  Enumerated against the PSSM traces the five tests held on it admit
+  (`docs/internals/design/region-order-scheduling.md`), that rule reaches every admitted set
+  only by also reaching orders the suite refuses, and the sets turn out to want three
+  different things: *Entering 010*, *Entering 011* and *Junction 005* want a UML initial
+  transition's *effect* run as part of the region's default entry, which SysML v2 can spell
+  only as the effect of a completion transition out of a start state — a translation limit,
+  not a scheduling one; folding the effect into the target state's entry action was run
+  against the three and refused (it runs the effect on every entry of the state, which
+  *Entering 010* refuses, merges two behaviors into one unit, and has no target where the
+  initial transition ends at a junction); *History 001-C* and *History 002-B* register
+  incompatible orders for structurally identical halves, each contradicting the
+  specification's own account of the test (`docs/project/omg-issues.md` gains the entry);
+  *Terminate 002*'s remaining trace is a do step against a sibling's entry unit. The one
+  runtime gap the enumeration confirms — under `reverse`, `seed:<n>` and `explore`, the
+  completion events two regions' entries generate are dispatched in region declaration order
+  rather than the order the regions were entered — is recorded for a runtime change of its
+  own, since it moves no test alone. The alignment note and the referee record carry the
+  adjudication; no bucket, golden or baseline moves (51 pass, 13 fail, 38 not expressible,
+  1 differs by design).
+
+- **`Diagnostic` and `Severity` moved from `internal/core/passes` to the leaf package `internal/core/diag`.** Every layer that reports a finding — the parser's warnings, the validation passes, the runtime's informational guard and tool notes, the editor and the service — now spells `diag.Diagnostic` and `diag.SeverityError`/`Warning`/`Info`/`Hint`; `passes` keeps no alias. The runtime no longer imports the validation suite for its diagnostic type.
+
+- **The PDF document backend sits beside the renderer it drives.** `internal/docpdf` is
+  `internal/core/docpdf`, next to `internal/core/docrender`, whose Markdown it converts; nothing
+  changes for `sysml -render-document -doc-form pdf`.
+
+- The design record for exception handlers, `docs/project/exception-handlers.md`, closes the compliance mapping's last UML-referenced action item as not a SysML v2 construct: the language spells no raise, handler or propagation, and a failure is handled with a result routed by `decide` or a failure signal accepted beside the work and a `terminate`. The compliance mapping, the roadmap and the behavior guide say so, and no runtime work follows.
+
+- The design record for concurrent per-element performance ("expansion regions"), `docs/project/expansion-regions.md`, closes the roadmap item: the iterative form is `for`; the parallel form is not SysML v2. The compliance mapping's bullet and the roadmap say so, and no executor work follows.
+
+- **The refusal of a session object under exploration says what to name instead.** `"#2.ground" names an object of this session, which an exploration does not run on: each explored run creates its own objects, so name a declaration to instantiate, or a path from one to an object it holds (Assembly::part.nested)` replaces the message that refused every dotted name; a machine named alone with no run object exhibiting it is refused naming the run's objects, not the session's (`no object of the explored run exhibits …`).
+- **A check's outcome carries the attributes the performing objects hold.** The outcome table and the `-check-diverge` comparison of a behavior run on an object include that object's attributes (`received = 2`) beside the behavior's own, as `this.<feature>` names them, so two schedules that leave a nested part's counter apart are two outcomes; a `-check-diverge` that selects another feature of the object (`this.mode`, an item) tells them apart the same way. The service's explored `ExecuteAction` and `ExecuteState` outcomes carry the performer's attributes in `outputs` under the same names.
+- **An action executed on an object performing it runs that one performance.** `ExecuteAction` with a performer whose declaration performs the action (`perform action fill`) answers with the performance the object started when it was created, its results and the object's attributes as that run left them, where it started a second run of the action on the object and wrote it twice. Inputs for such an action are refused (`inputs for a performed action`), as its declaration binds its arguments; an object performing the action under several usages is refused naming them (`ambiguous action: the object performs Fill as morning and evening`), as a machine exhibited twice over is.
+
+- **Conversions are driven from `internal/core/convert`, not from the RDF mapping.** The format names, `Convert`, `ConvertTolerant`, `SysMLElement`, `Migrate`, `SysMLToRDF` and `SyntaxError` moved out of `internal/core/export` into a conversion entry point that parses notation and runs the SysML v1 migration before handing the tree or graph to the mapping; `internal/core/export` keeps `ToRDF` and `ToSysML` and no longer imports the migration. `sysml -convert`, `%save`, `%print` and the service's `Convert` behave as before, and a hygiene test keeps the mapping free of the migration.
+
+- **An edge is labelled by its name only when it has no text of its own.** A transition's trigger, guard and effect, a succession's guard and a flow's payload take the label, whatever the edge is named; a plain named succession, connection, binding or completion transition is labelled by its name, as the graphical notation draws them.
+- **A comment on a state or a named transition says `about` it.** A state, the parallel state standing for an orthogonal region, and a transition written under its v1 name are members a qualified name reaches, so a comment annotating one is written `comment about <member>` rather than unanchored, and the report records it `mapped`.
+
+- **The conformance mode lives with the diagnostics it grades.** `internal/core/conformance` is folded into `internal/core/diag`: `conformance.Mode`, `ModeDefault`, `ModeStrict`, `ModeOf` and `ParseMode` are now `diag.ConformanceMode`, `diag.ConformanceDefault`, `diag.ConformanceStrict`, `diag.ConformanceModeOf` and `diag.ParseConformanceMode`. The mode's spellings, `default` and `strict`, are unchanged.
+
+- **Atomic file replacement lives with the other file-level primitives.** The one-function `internal/fsutil` package is folded into `internal/core/source`: `fsutil.Replace` is now `source.ReplaceFile`, with the same rename-over-target semantics.
+
+- **Normative library ids are minted in `identity` itself.** `internal/core/identity/normative` is folded into `internal/core/identity`: `normative.Language`, `KerML`, `SysML`, `ElementID`, `OwningMembershipID`, `EscapeName` and `NamespaceURL` are now `identity.Language`, `identity.KerML`, and so on. No behavior changes.
+
+- **A symbol's source origin is read off the symbol.** `internal/core/provenance` is folded into `internal/core/symbols`: `provenance.Origin` is `symbols.Origin`, `provenance.Symbol(sym)` is `sym.Origin()`, and `provenance.Node` and `provenance.At` are `symbols.NodeOrigin` and `symbols.OriginAt`. No behavior changes.
+
+- **Quick-fix edits live with the diagnostics that carry them.** `internal/core/quickfix` is folded into `internal/core/diag`: `quickfix.Fix`, `Edit`, `Insert`, `InsertLine` and `Replace` are now `diag.Fix`, `diag.Edit`, `diag.Insert`, `diag.InsertLine` and `diag.Replace`. No behavior changes.
+
+- **Rename conflict checking lives with the rename edit.** `internal/core/rename` is folded into `internal/core/edit`: `rename.Occurrence`, `rename.Conflict` and `rename.Check` are now `edit.RenameOccurrence`, `edit.RenameConflict` and `edit.CheckRename`. No behavior changes.
+
+- **The fUML referee driver source lives under `scripts/fuml-driver/io/opensysml/fuml/`.** The path now matches the `io.opensysml.fuml` package the source declares; `scripts/fuml-expected.sh` compiles it from there and its output is unchanged.
+
+- **The test-suite figures are counted from the tree when the documentation site is built, never committed.** The conformance-case, golden-AST, golden-trace, negative-parser, robustness, gRPC and `Test`-function counts in the compliance map's test inventory are counted by `cmd/doc-counts` the way the gates enumerate them — the conformance cases through `tests/fixtures`, which the runtime and gRPC conformance tests read too — and rendered by the site build (`scripts/mkdocs_suite_figures.py`), which now republishes on every push to `main` so a release's figures follow its tree; in git each block names what is counted, and `go run ./cmd/doc-counts -check` refuses a figure typed into one. A branch adding a test or a fixture therefore no longer rewrites `README.md` or the compliance map, so concurrent branches stop conflicting on them. The README's status table names the gates without restating their counts; the only suite figure still committed is whether every conformance case passes, which moves with `known_failures.txt` alone. The hand-typed figures the generated ones replaced lagged the tree (889 conformance cases where it carried 904); the tests-and-subtests total of a run, which only a run can state, is not quoted.
+
+- **The RDF exporter no longer carries the engine model forms.** The `graphs:1` form of the lowered action and state graphs, `GraphsVersion`, the `sources` form and the form refusals live in `internal/core/analysis/modelform`, in the execution layer beside the analysis framework that hands them to external engines; `internal/core/analysis` no longer imports `internal/core/export`. The bytes of the `graphs:1` form are unchanged and now pinned by goldens.
+
+- **`sysml -help` and the manual page list the options by task.** The 84 flags come under headings — General, Evaluating, Checking a model, Running behaviors, Analysis engines, Checking every schedule, Converting and migrating, Compiling natively, Rendering views, Rendering documents, Styling HTML documents, Syncing against a repository, Diagnostics and profiling, Deprecated — instead of in one alphabetical list. Each is spelled once with its shorthand folded in (`-e, -eval <expr>`, `-o, -output <file>`) and its argument named (`<format>`, `<duration>`, `<policy>`) rather than typed (`string`, `value`); a description is a wrapped line or two, with the detail it carried moved into the section on that mode (`Checking a model`, `Running actions and state machines`, `Checking every schedule`, ...), and the three `-pdf-*` spellings sit under *Deprecated* pointing at their `-doc-*` names. The examples come first. A flag added without a heading fails a test.
+- **A mistyped flag prints the error, the synopsis and `Run 'sysml -help' for the options.`** — three lines, rather than the whole help after the error.
+- **`%help` groups every REPL command under a heading and wraps its description.** The commands that had none come under *Session*, *Settings*, *Analysis engines* and *Checking every schedule*; a description wraps into the column after its command, and a signature too wide for that column takes a line of its own. `%exit` is noted beside `%quit` rather than listed as a command.
+
+- **The Java client's `Condition.equal` is renamed `Condition.equalTo`.** The old name collided with `Object.equals` on every use; `Condition.equalTo(property, values)` is a drop-in rename.
+
+- **The simple name a qualified name ends in is computed in one place.** `symbols.LastSegment` is exported and the identical copy in `suggest` is gone; `ast.QualifiedNameOf` builds a qualified name from its segments, replacing the copies in `solve` and the resolver tests. No behavior changes.
+
+- **Moved the validation packages to `internal/check`.** `passes` (with `kit`, `behavior`, `document`, `diagram` and `identity`) and `edit` now live under `internal/check/`.
+
+- **Moved the document packages to `internal/doc`.** `queryexec`, `docir`, `docrender` and `docpdf` now live under `internal/doc/`.
+
+- **Moved the execution packages to `internal/exec`.** `runtime`, `solve`, `smt`, `analysis` (with `enginewire` and `modelform`), `engines` and `objref` now live under `internal/exec/`.
+
+- **Moved the frontend packages to `internal/frontend`.** `protoconv`, `repl`, `lsp`, `grpc`, `stdiorpc` and `usage` now live under `internal/frontend/`.
+
+- **Moved the semantic IR packages to `internal/ir`.** `lower`, `queryplan`, `docplan` and `view` now live under `internal/ir/`.
+
+- **Moved the semantic packages to `internal/semantic`.** `symbols`, `suggest`, `resolve`, `semantics`, `identity`, `highlight` and `query` now live under `internal/semantic/`; `query` declares the namespace IRIs it needs rather than importing the RDF vocabulary.
+
+- **Moved the foundation and syntax packages to `internal/syntax`.** `source`, `ast` (with `astcodec`), `pack`, `diag`, `lexer`, `parser` and `format` now live under `internal/syntax/`.
+
+- **Moved the translation packages to `internal/translate`.** `rdf` (with `ontology`), `export`, `migrate`, `convert`, `xmi` (with `sysmlv1`), `codegen` and `interop` (`flexo`, `reposync`) now live under `internal/translate/`.
+
+- **Moved the workspace packages to `internal/workspace`.** `model`, `libs` (with `errata`), `project` and `envvar` now live under `internal/workspace/`.
+
+- **The SysML v1 migrator writes a call behavior action that names no behavior yet owns pins as a declared stub action.** `action x { in a : T; out r : U[0..1]; }` takes its parameters from the pins, typed and bounded as they are, keeps its place in the activity's successions, and is reported as approximated, computing nothing: each output is declared admitting no value. The flows into and out of its pins and the «Allocate» relations to it are written by the existing paths instead of being dropped as ends of an unmigrated action, an allocation to a node in an operation's method naming it under the operation. A pin a called behavior has no parameter for is still refused, the reason now naming the behavior and its parameters of that direction, while the call itself is kept. At run time a flow out of an unassigned output declared admitting no value carries nothing, so the target reads the parameter empty rather than the run failing; a required output or input left without a value is still a typed error.
+
+- **The notation-text helpers moved from `internal/core/lexer` to `internal/core/source`.** `NameText`, `QualifiedNameText`, `StringValue`, `StringText`, `UnrestrictedNameText`, `CommentBody`, the keyword sets (`Keywords`, `IsKeyword`, `IsKeywordIn`) and `IsIdentifier` now live beside the source files they read and write, so the type system, the runtime and the exporter no longer import the scanner to spell a name or read a comment body; `internal/core/semantics` imports `lexer` in no file. A layering test in `tests/hygiene` assigns every package to a layer of the package-layering plan and permits exactly the cross-layer imports that exist today, so a removed edge cannot return unnoticed.
+
+- **The migration and referee XMI readers now share one parser.** The generic element tree in `internal/core/xmi` feeds both the SysML v1 migration reader and the fUML/PSSM referees, and the referees now accept the schema.omg.org and unversioned XMI namespace forms.
+
+- **PDF output is laid out from the HTML document backend's page.** `-doc-form pdf` hands
+  WeasyPrint and Prince the same semantic HTML `-doc-form html` writes, under the backend's
+  default stylesheet and a print stylesheet layered after it, instead of a page reconstructed from
+  the rendered Markdown; pandoc keeps reading the Markdown. `-html-theme`, `-html-no-default-css`
+  and `-html-css` now reach a WeasyPrint or Prince PDF exactly as they reach HTML — a user
+  stylesheet is unlayered, so it overrides the default and print layers without `!important` — and
+  are refused with a typed error for pandoc, whose page is its own. A sheet's relative `url()`
+  and `@import` references resolve against the PDF's directory under every engine, as a page's
+  resolve against the page's. Tables, figures, formulas and cross-references carry their
+  `sysml-*` classes and `data-*` attributes into the PDF's HTML.
+- **Markdown output no longer carries a caption marker.** The `<!-- caption -->` comment the
+  Markdown backend wrote ahead of a table's or diagram's emphasized caption is gone; the caption
+  stays an emphasized paragraph. Pandoc recognizes captions by matching those paragraphs against
+  the document's captions in order, so an emphasized paragraph elsewhere stays prose. A caption
+  is written without its surrounding blanks, which CommonMark would otherwise read as literal
+  asterisks or as indented code; a blank caption writes no paragraph.
+
+- **The SysML v1 migration no longer writes MagicDraw's property-kind markers as comments.** «ValueProperty», «PartProperty», «SharedProperty», «ReferenceProperty» and «ConstraintProperty» applied from MagicDraw's SysML customization profile to a UML Property, carrying no tag, say only what the usage's keyword (`attribute`, `part`, `ref part`, `constraint`, `in …`) already says, so the `/* applied stereotype «ValueProperty» */` body they forced on every such property is gone and the declaration is one line. The marker is recognised by the provenance of its profile, as «ConstraintParameter» already was: a same-named stereotype from a user profile is still written as a metadata usage or comment, a marker that carries a tag is still written with it, and a marker on a property written as another kind is kept. Report verdicts are unchanged.
+
+- **The conversion between runtime values and the API's protobuf messages is its own
+  package, `internal/protoconv`.** The instance-graph serialization (`InstanceGraphToProto`,
+  `GraphBounds`) and the value conversions in both directions moved there out of the gRPC
+  service, so the REPL's `%features … json` and the Go client's value marshalling no longer
+  link the service or its Connect transport; `sysml` reaches gRPC-Go only through the
+  generated service stubs that share the `api/proto` package with the messages. The
+  `features` JSON is unchanged.
+
+- **Every failing PSSM test is attributed.** The fourteen failures the referee's record tabled
+  as unadjudicated each cite either a *differs, v2 silent* row of the alignment note or an open
+  finding against the runtime: *Junction 004* and *Join003* report on SM32 (a junction or join
+  with no way through), *Join001* and *Transition 019* on SM34 (a join — where its owner is left
+  and in what order its segments fire, now recorded there), nine cite the new finding 9 (the
+  order in which orthogonal regions are entered, exited and stepped against a dispatch is not a
+  recorded choice point, so `explore` never reaches the other interleavings) and *Junction 005*
+  the new finding 10 (a segment leaving a junction inside a composite state runs its effect
+  before the composite is entered). No bucket count moves; the record tables the twenty
+  failures the first baseline left unadjudicated by root cause and where each landed.
+
+- **The PSSM referee's refusal of a guard that acts on the model is settled, not provisional.**
+  *Choice 005* traces its four guards to show when a junction's and a choice's are read.
+  Recording the runtime's own guard reads as the referee's observable was tried and refused:
+  the suite reads the junction on the entered state's default entry before the incoming
+  transition's effect and the state's entry, where the runtime and `StatePerformances.kerml`
+  read a transition inside a state after its entry, and the runtime's trace keeps only the
+  first read at each vertex, the others rolled back with the probe that made them. A `calc def`
+  with a side effect is refused too: a v2 expression is pure, and UML 2.5.1 §14.5.11 calls a
+  guard with a side effect ill formed. The alignment note tables the three candidates against
+  the admitted trace, and no bucket, reason or trace moves.
+
+- **The PSSM referee classifies a guard whose behavior acts on the model as having no
+  translation.** A SysML v2 guard is a Boolean expression; a UML guard whose activity calls
+  `trace(...)` before returning its value has no spelling, and the translation used to carry the
+  value alone and silently drop the call. The classifier now names the construct (*guard side
+  effect*), the emitter refuses it, and *Choice 005* moves from `fail` to `not-expressible`
+  (17 `fail`, 40 `not-expressible`), adjudicated in `docs/project/pssm-referee.md`.
+
+- **The Python client is released with the core, from the same `v*` tag and at the same version.** `v0.9.0` publishes `opensysml` 0.9.0 to PyPI and puts the wheel and sdist on the GitHub release, listed in the signed `SHA256SUMS.txt` beside the binaries, so `pip install opensysml==0.9.0` with `OPENSYSML_GRPC_VERSION=v0.9.0` is the package and the `sysml-grpc` that were tested together. The release workflow fails before building anything when `client/python/opensysml/_version.py` does not declare the tag's version (compared as versions, so the SemVer tag `v0.9.0-rc1` names the PEP 440 `0.9.0rc1`); the separate `opensysml-v*` tag and its `release-python` workflow are gone. The PyPI upload runs only after the GitHub release is published, so a package version never exists without its release. Re-running a published tag still replaces the GitHub assets, while the PyPI upload — which cannot be repeated — fails by design for a version the index already has.
+
+- **Qualified-name rendering and declaration-body lookup each have one home.** `ast.QualifiedName` gained a `Text` method ("A::B::C", empty for a nil name) and `ast` a `DeclMembers` function returning the body of a definition or usage; the per-package copies of both helpers in the lowering, validation, runtime, query-plan, document-plan and symbol packages are gone. No behavior changes.
+
+- **Expression roots and operands now use standard ownership vocabulary in RDF.** Roots are carried by `OwningMembership`/`FeatureValue` and operands by `ParameterMembership` input Features and FeatureValues, while legacy positional graphs remain importable.
+- **Connector ends now use standard `connectorEnd` ownership.** EndFeatureMembership and ReferenceUsage nodes carry end references and chains, with binary source/target features and transition source/target predicates.
+- **Legacy RDF shapes remain compatible.** Earlier argument, positional end, and transition endpoint predicates still import, while conflicting old and new representations are refused.
+- **Connector-end references use `ReferenceSubsetting`.** End targets are emitted through standard relationship ownership; interim `sysml:references` remains accepted on import.
+
+- **A state's inline `do` body is interrupted between its statements.** `do action { s1; s2; s3; }` runs one statement per do round — each statement of a `for` or `while` iteration, of a nested block or branch its own, one step of a flow the body states (each of its tokens one node) — so a transition out of the state triggered after `s1` leaves `s2` and `s3` unrun, as §7.18.3 has the source state's do action interrupted "if it is still being performed"; the `exit` behavior runs as before. The inline bodies of orthogonal regions interleave statement by statement, as the braced `do { … }` form does, the order within a round the same do-round choice point. A body paused mid-loop when its state is left drops the rest of the iteration and the iterations after it with nothing kept on the clock, and a non-terminating inline body still ends with the do-step budget.
+
+- **Runtime and gRPC robustness cases are registered per feature, not in one shared function.** A feature's failure-mode subtests live in their own `robustness_<feature>_test.go` under a `TestRuntimeRobustness<Feature>` (or `TestGRPCRobustness<Feature>`) function, and the documentation counters sum the first-level subtests across every `TestRuntimeRobustness*` and `TestGRPCRobustness*` function, so two branches adding cases no longer edit the same lines of `robustness_test.go`. `go test ./...` runs every case as before.
+
+- **Buf configuration now lives in `api/proto/`, and the manual pages now live in `packaging/man/man1/`.**
+
+- **The runtime no longer imports the parser.** The notation text a run reads — a witness file's input values and the unit a tool answers in — is parsed through the `runtime.ExpressionParser` the frontend installs on the model (`Model.SetExpressionParser`, `parser.ParseOneExpression`); reaching either with none installed is the typed `runtime.ErrNoExpressionParser`. `internal/core/runtime` no longer depends on `internal/core/parser`, and a hygiene test keeps it so.
+
+- **The runtime selects call overloads through the semantic model, with the argument typing its caller installed.** `runtime.NewModel` no longer installs the checker's argument typing itself; every path that builds a runtime model constructs its semantic model with `passes.NewTypedModel`, and a call selected on a model carrying no typing fails with `runtime.ErrNoArgumentTyper` instead of selecting by arity alone. The invocation AST helpers `InvocationArgs` and `ChainCallee` moved from `passes` to `semantics`.
+
+- **The SonarCloud scan fits its container and retries transient failures.** The scan job's memory budget is now split between the analysis JVM (5.5 GB), the JS/TS sensor's Node process (1 GB) and the launcher (256 MB) so the 8 GB `large` container is not OOM-killed — a silent `EXECUTION FAILURE` exit 3 at the JS/TS sensor — and a `when: always` step prints the cgroup memory counters so an OOM kill names itself. The scan runs inline instead of through the orb, retrying once on transient SonarCloud API or network errors and storing each attempt's log as an artifact, and it no longer waits on the Go race run, so a race-test failure no longer hides the analysis.
+
+- **Sorted map keys come from the standard library.** The seven private `sortedKeys` helpers in the symbols, validation, SMT, view, runtime and interop packages are replaced by `slices.Sorted(maps.Keys(m))`. No behavior changes.
+
+- **The declared errata overlay now covers the bundled standard library.** `internal/errata`
+  accepts entries under `internal/core/libs/stdlib` beside the example corpora, and the nine
+  dimension defects the expression type checker reports in the published `SI.sysml` and
+  `USCustomaryUnits.sysml` are its entries, each with a citation, a derivation and the published
+  line it must still match. Three have one reading with the declared dimension and carry a
+  correction (`eV*m^-2/kg` → `eV*m^2/kg`, `m^3/C*m^3*s^-1*A^-1` → `m^3/C`, `229835/900 [K]` →
+  `(229835/900) [K]`); the other six are documented without one. The library a process loads
+  (`libs.BundledSource`, `libs.DefaultSource` and the generated `stdlib.snapshot`) is the
+  published text with the corrected lines substituted on read — the vendored bytes are never
+  edited, `libs.EmbeddedSource` still serves them as published, and a directory named by
+  `OPENSYSML_LIBRARY_PATH` is read as it stands. A library read fails rather than serve the file
+  uncorrected when a declared line no longer matches, and two entries naming one line are refused.
+  `TestExprTypeCheckPublishedStdlibDefects` pins all nine findings over the published text;
+  `TestExprTypeCheckNoStdlibFalsePositives` pins exactly the six uncorrected ones over the bundled
+  library. Derivations are in `docs/project/omg-issues.md` ("Defects in the vendored quantity
+  libraries"); nothing is filed upstream.
+
+- **The built binaries are about a quarter smaller.** `make build` and the release builds now link with `-s -w`, dropping the symbol table and DWARF debug data that a shipped binary never reads; on Linux `sysml` goes from 50 MB to 37 MB. The version stamps (`sysml --version`), the embedded build info the standard-library cache keys on, and panic stack traces are unchanged.
+
+- **A symbol's declaring element is read through one method.** `symbols.Symbol` gained `Owner()` (nil at a document's root); the identical `ownerOf` copies in the resolver, semantics, validation, code generation, views and editing packages are gone, as are four private "FQN or name" helpers that were `symbols.FQNOf` under another name, since `FQNOf` always ends in the symbol's own name. No behavior changes.
+
+- **Test-support packages live under `tests/`.** `internal/fixtures` (conformance-case and library-census fixture readers) and `internal/stressmodel` (the satellite-network generator) are now `tests/fixtures` and `tests/stressmodel`; the runtime, gRPC and model tests and the `tools` module import them from there, and the layering test fails any `internal/` or `cmd/` package that reaches into `tests/`. No shipped binary imported either. No behavior changes.
+
+- **The OMG corpus gates and the RDF round-trip ratchet move to `tests/corpus`.** `TestTrainingExamplesSemanticErrors`, `TestPilotCorporaDiagnostics` and `TestCorpusGatesCacheStateIndependent` leave `internal/core/model`, `TestCorpusRoundTrip` leaves `internal/core/export`, and their expectation files move to `tests/corpus/testdata/` (`go test -count=1 ./tests/corpus -run 'TestTrainingExamples|TestPilotCorpora|TestCorpusRoundTrip'`). The policies are unchanged: the training corpus is still asserted clean and `-update-training` still refuses to record a per-file count, while the pilot roots and the round trip remain per-file ratchets with `-update-pilot-corpora` and `-update-corpus-roundtrip`. The download scripts, the `OPENSYSML_REQUIRE_*` variables and the CI steps that set them now run the gates from the new package.
+
+- **The gRPC conformance suite and the external-package tests move under `tests/`.** `TestGRPCConformance` and its fixtures leave `internal/grpc` for `tests/grpc` (`go test -count=1 ./tests/grpc`), driving the service through `grpc.NewService` and the RPC surface alone; the package-local gRPC tests, including the `TestGRPCRobustness*` census, stay put. The `package x_test` files that exercised `export`, `resolve`, `semantics`, `migrate`, `suggest`, `identity`, `queryplan`, `model`, `rdf/ontology` and `interop/reposync` through their exported surface move to the matching `tests/<package>` directory together with the `convert`, `superseded` and `xmi` fixture trees they read; the pilot library identity gate now runs as `go test -count=1 ./tests/identity -run TestPilotLibraryXMI`. The LSP and REPL suites stay beside their packages: they share package-local helpers and reach unexported server state.
+
+- **The parser's black-box suites move to `tests/parser`.** `TestGolden` (with its `-update` flag), the `TestNegative` table and `TestNegativeKerML` now drive the parser through its exported API from `tests/parser`, and the golden fixtures move with them from `internal/core/parser/testdata/parse` to `tests/parser/testdata/parse` (`go test -run TestGolden ./tests/parser`). The parser's white-box tests stay beside the package; the documentation census counts negative subtests across both directories, and the grammar coverage's `parser-fixtures` root follows the fixtures.
+
+- **The repository root gains a `tests/` tree for black-box test code and fixtures.** The module-wide hygiene check moves from `internal/hygiene` to `tests/hygiene`, the benchmark harness from `internal/perfbench` to `tests/perf` (`go test ./tests/perf -run '^$' -bench .`), the `gobuild` and `graphcmp` test-support packages from `internal/testutil` to `tests/testutil`, and the shared `.sysml`/`.kerml` fixtures from the top-level `testdata/` to `tests/testdata/`. `scripts/pgo-profile.sh` and the pilot differential's and grammar coverage's `testdata` root follow the fixtures; nothing shipped in a binary changes.
+
+- **The counting gates and the remaining unreleased programs live in the tools module.** The
+  validation-constraint census (`cmd/validation-census`), the grammar-coverage harness
+  (`cmd/grammar-coverage`) and the documentation-figure gate (`cmd/doc-counts` with
+  `internal/doccounts`) are `tools/census/{validation,grammar,doccounts}`, the conformance runner
+  (`cmd/conformance`) and the stress-model writer (`cmd/stress-model`) are `tools/cmd/conformance`
+  and `tools/cmd/stress-model`, and the baseline provenance and JUnit writers they share
+  (`internal/baseline`, `internal/junit`) are `tools/oracle/baseline` and `tools/oracle/junit`;
+  every one runs with `go run -C tools ./cmd/<name>`. The analysis-library census schema that the
+  runtime's own test writes is `tests/fixtures`, which the doc-counts gate reads from there;
+  the model tests load the stress model from `tests/stressmodel`. No figure moved.
+
+- **The development tools are a nested Go module, `tools/`.** `tools/go.mod` requires the
+  product module through `replace … => ../`, so the tools build against the working tree while
+  `go build ./...` and `go test ./...` at the root stay product-only; `make test` and `make lint`
+  run both modules. The library snapshot generator and the ontology table generator are its first
+  residents, at `tools/gen/snapshot` and `tools/gen/ontology`, invoked with
+  `go run -C tools ./gen/<name>` (the `go:generate` directives and `make stdlib-snapshot-check`
+  follow); both resolve the repository root through `tools/oracle/repo` rather than the working
+  directory.
+- **The errata overlay is split from the registry.** The entry type, the overlay applied to the
+  bundled standard library on read and the library's own entries are the product's
+  `internal/core/libs/errata`; `internal/errata` keeps the registry the oracles read — the corpus
+  entries, the published roots and the corrected copy of a corpus root — and builds on it.
+
+- **The referees live in the tools module.** The fUML and PSSM referees (`internal/fuml`,
+  `internal/pssm`) are `tools/referee/fuml` and `tools/referee/pssm`; the pilot differential,
+  rejection, Xpect and execution oracles (`cmd/pilot-diff`, `cmd/pilot-reject`, `cmd/pilot-xpect`,
+  `cmd/pilot-exec-diff`) are `tools/referee/{diff,reject,xpect,exec}` with one thin `main` each under
+  `tools/cmd/`, so every referee is run with `go run -C tools ./cmd/<name>`. What they share moved
+  beside them: the XMI reader is `internal/core/xmi`, the corpus errata registry is
+  `tools/oracle/errata`, the repository-root and develop-commit lookups every tool carried a copy of
+  are `tools/oracle/repo`, and the report files and verdict buckets are `tools/oracle/report`. The
+  committed baselines record the new corpus paths; no figure moved.
+
+- **PDF headings and captions stay with what they introduce.** A heading, a caption, or the paragraph directly before a table is no longer left at the foot of one page with its table or figure at the head of the next, and a column header is no longer broken inside a word to fit; a page break can move by a paragraph in an existing document, but page counts do not change.
+
+- **A call whose required input receives no value is performed.** The SysML v1 migration
+  wrote a call passing no argument, or a pin no value reaches, for a parameter with no default
+  and a lower bound above zero as a placeholder performing nothing; v1 runs the callee with the
+  parameter unset, so the parameter or pin is now declared admitting no value (`[0..upper]`),
+  the call is written and performed, the absence propagates through the pins, nested activity
+  outputs and method parameters it feeds, and a write of a feature requiring a value from one
+  that may be absent is guarded (`if x->SequenceFunctions::notEmpty() { assign … }`). The report
+  says on each parameter and pin why a value may fail to reach it. A send of a signal whose
+  required attribute gets no value still stands in for itself, since v2 admits no such send. A
+  call behavior action that names no behavior yet has pins stays unresolved with its pins and
+  the reason; an «Allocate» from the action to a part is named in it as saying where the action
+  runs, not what it does, and no behavior or value is made up for it.
+- **«Probability» is read by provenance.** The SysML v1 migration weights a decision's
+  branches only by the OMG SysML profile's «Probability», recognised by the namespace its
+  application is serialised under as every standard stereotype is; a same-named stereotype from
+  another profile weights nothing, and the report says which profile it comes from.
+- **A duration with no unit is in milliseconds.** The SysML v1 migration read a duration
+  constraint, time event or `SimulationConfig` step written as a bare number — `200`, `t = 1500`,
+  an expression naming no unit — in seconds; the simulation toolkit's default unit is the
+  millisecond, so such a duration is now scaled from milliseconds (`accept after 0.2 [SI::s]`,
+  `this.settle * 0.001`) and the report notes the reading. A duration with a unit is read as
+  before, and `m`, `wk`, `millisec`, `microsec` and `nsec` are read as the toolkit spells them.
+- **A run configuration is refused by name.** `-compare-results` refuses a configuration whose
+  behavior was not migrated, or whose `durationSimulationMode` is no draw policy, or whose run
+  fails, naming the configuration in the refusal, so the refusals of several configurations
+  printed together tell which is which.
+
+- **The diagram panel lays unplaced nodes out in layers and routes edges around boxes.** Nodes the model does not place took slots in a square grid and every edge ran straight between centres, across whatever lay between; they are now laid out by the ELK layered algorithm, edges orthogonal and routed around the boxes. A node the model places, or one dragged, is drawn where stated as before. Renderings of more than 600 nodes keep the grid.
+
+- **A workspace copy of a library file rooted at the library's packages is the library, as it
+  is for the RDF mapping.** The language server treated such a copy as the user's file — derived
+  ids, a minting action on every declaration — where `sysml -convert` recognised the same bytes
+  as the bundled file. The workspace now applies the one recognition
+  (`identity.Catalog.DocumentRootedAt`, moved out of `internal/core/export`): a document whose
+  every root is a top-level package of one bundled library file (a package two library files
+  declare at their top names neither), stating that package's
+  normative id or declared as the library declares it, and in the file's language (the text of a
+  `.kerml` file under a `.sysml` name was parsed as SysML), stands in for the bundled file. Its
+  declarations are what the library's names resolve to, its elements keep their normative ids
+  (hover states `(normative, KerML)`) and get no minting action, and it is not warned for its
+  `standard library` keyword. Editing a root so it no longer qualifies, or closing a version
+  whose on-disk text is the user's, puts the bundled file back. The library identity the
+  runtime names library types by (`symbols.Index.LibraryIdentity`) digests each library
+  document's language, tier and text, no longer its name, so an unchanged version standing in
+  leaves it — and the objects carried across a re-analysis — as they were. A workspace over a
+  caller-built index (`model.NewWorkspaceWithIndex`) treats the library files that index marks
+  the same way, a file `MarkLibrary` marks at the generic tier included: a document may stand in
+  for one or take its name, and closing it puts the file back. The library is what the index
+  shows, a file the overlay shadows under a frozen base's name included. An edit's temporary
+  index keeps the documents such an index holds beyond its frozen base, marked or not, and
+  resolves against a shadowing file rather than the one it shadows, and does not bring back a
+  base document the overlay removed.
+- **A file opened under a bundled library's own name no longer removes that library from the
+  workspace when it is closed.** Closing it put nothing back, so every later document was checked
+  against a library missing that file; the standard-library expression gate
+  (`TestExprTypeCheckNoStdlibFalsePositives`) passed on an incomplete library for that reason.
+  Over the whole library it now reports nine dimension defects in the published `SI.sysml` and
+  `USCustomaryUnits.sysml`, pinned as an exact set and recorded in `docs/project/omg-issues.md`
+  ("Defects in the vendored quantity libraries"); the library bytes are unchanged.
+
+- **The XMI element walker the PSSM referee reads its suite with is now its own package,
+  `internal/xmi`**, so other UML-based test suites can be read with it. `internal/pssm`
+  behaves exactly as before.
+
+### Fixed
+
+- **The API element form spells a multi-valued property as an array even with one member.** The writer decided array-vs-object by member count — an array only where the `json:` annotation of two or more members stated one — so `ownedRelationship` or `ownedMember` holding a single element went out as a bare `{"@id": …}` object, which conforming readers such as sysml-toolkit drop silently. The shape now follows the metamodel's upper multiplicity, read from `SysML.ecore` into the generated `internal/translate/rdf/ontology` table (`Property.Many`, resolved per metaclass by `PropertyOf`): an unbounded property is always an array and a single-valued one an object or scalar.
+- **The element-form reader accepts `{"@ref": <name>}`.** sysml-toolkit writes an unresolved reference target as `{"@ref": "<name>"}` rather than `{"@id": …}`; the value now reads as the name literal the mapping already uses for a name-valued reference, and the refusal message for any other object spelling names both forms.
+
+- **Completion events are queued in the order their states were entered.** When the entries of two orthogonal regions each left a state that completes at once — a start state, a state with a completion transition and nothing to perform — the runtime queued the completion events once the move had settled, region by region in declaration order, whatever order the entry draw had entered them in; PSSM §8.5.9 puts a completion event behind those already in the pool, so under `reverse`, `seed:<n>`, `check` and `explore` the region drawn second could have its completion dispatched first. A state's completion is now queued as its entry unit is performed, on every way in — a composite's default entry, a fork's branches, a shallow or deep history's restore, the target of a transition — so the pool dispatches the completions in the entry draw's order; time triggers are still scheduled once the move settles, and a completion a running do behavior or a nested composite's regions hold back keeps its timing. An entry that performs nothing but generates a completion is drawn as an alternative of the entry front rather than riding with the neighboring performing unit as a silent unit, since the pool's order makes it observable, so a `choice entering <state>` line appears where two such entries meet; `declared` takes the same order as before and no default outcome moved, while `check` and `explore` reach the runs in which the regions were entered the other way round — `state_concurrent_do` and its kin reach eight values of `seq` where they reached four, `state_do_step_among_completions` six logs where it reached three (`state_completion_pool_entry_order`, `state_completion_pool_history_order`, `state_completion_pool_deep_history_order`, `state_completion_pool_fork_order`, `state_region_completes_at_own_done`; `TestRuntimeRobustnessCompletionOrder`). On the PSSM suite no bucket moved: *History 001-C* and *Entering 011* each reach one more admitted trace, *History 002-B* one more and the two §8.5.9 gives that the suite does not register, and *Transition 017* reaches six of its eight, the two it misses being the suite's own defect.
+
+- **Several completion transitions out of one state are one choice.** A state's completion queued one event per enabled completion transition and dispatched them in declaration order, so the first always fired, the rest went stale, and no choice point was reported: `explore` could never reach the run in which another of them fires. The completion is now one occurrence: when it is dispatched, the queued completion transitions of the state are read again and one is drawn by the scheduling policy — declaration order by default — and recorded as a `transition` choice point that `explore` enumerates and `check` compares, the others leaving the queue (`state_explore_completion_choice`).
+
+- **A compound transition through a pseudostate inside a composite state exits and runs its effects segment by segment.** A transition into a junction of a composite state, continued by the junction's outgoing transition out of the state, now exits the source, runs the first effect, exits the composite state, then runs the second effect and enters the target, as UML 2.5.1 §14.2.3.8.4 orders the segments; before, every exit ran before any effect. A join of the state its orthogonal regions leave through runs each region's exit and effect, then the state's exit, then the outgoing transition; a route ending at a terminate action or a history pseudostate keeps its effects in that order.
+
+- **A connector whose first end is missing is reported at the `to`/`then`, with
+  one diagnostic.** `connection c connect  to ;` read the `to` as the first
+  end's name and then wanted a second `to`, producing the misleading `expected
+  'to' between connector ends` at the semicolon, and `connect to a;` produced a
+  second spurious `expected '{' or ';' after declaration`. The clause now reports
+  `expected a connector end before 'to'` (or `'then'` for successions) at the
+  keyword and still parses the end after it, so recovery adds no further error;
+  `connect to to b;`, where the first end is genuinely named `to`, is unchanged.
+
+- **A diagram panel left open across an extension update restored blank.** VS Code restores a webview panel with the options it was created with, whose resource roots named the directory of the extension version that created it; after installing another version the bundled script lived elsewhere and was blocked, so the restored panel drew nothing and listed no views until closed and reopened. A restored panel now sets its resource roots from the installed version before its page is set.
+
+- **A due do step is drawn against the entry units left in the move: the sibling regions' and the state's own substates'.** A state's do behavior is started by its entry and, in KerML's `StatePerformance`, ordered after that entry and against nothing a sibling region or a substate performs (`succession entry then middle`; PSSM §8.5.5 has the do activity run concurrently with the behaviors that follow it in entering the state, the substates' entries among them), but under `check`, `replay` and `explore` its first token move waited for the whole entry move to settle and was drawn only against the dispatch, so the run in which a do activity's first action precedes a sibling region's entry, or a composite's own do activity precedes its substate's entry, was never reached. A do behavior now begins as its state's entry unit ends, before the state's regions or serial body are entered, and each due token move of the do behaviors the move began is a drawn unit, `do <state>`, against the entry units still ahead: on a front, under the front's own `entering <state>` (or `fork <name>`, or a firing's `on <event>`) choice while another queue has a unit left; down a serial body, against each entry unit on the way under the owner's `entering <state>`; then against the dispatch after the move settles as before. A do step is never silent, and a do behavior that ends while its state's body is still ahead of the move completes nothing — the body's `done` completes the state. The fixed policies (`declared`, `reverse`, `seed:<n>`) enter every state whole and run the do round after, as they always did, and no default, `declared` or seeded trace moved (`state_do_step_before_sibling_entry`, `state_do_step_before_sibling_entries`, `state_do_step_before_nested_entries`, `state_do_step_nested_before_outer_entry`, `state_do_step_before_fork_branch`, `state_do_step_typed_before_sibling_entry`, `state_do_step_before_history_restore`, `state_do_step_cut_by_sibling_completion`, `state_do_step_cut_by_sibling_terminate`, `state_do_step_before_own_substate_entries`, `state_do_step_before_own_body_entry`, `state_do_step_machine_before_top_entries`, `state_do_step_way_down_before_fork_branch`; `TestRuntimeRobustnessDoStepEntryFront`). On the PSSM suite *Terminate 002* reaches its fifth admitted trace, the do activity's first segment before the sibling region's entry, and passes (57 pass / 12 fail); *Deferred 006 C* and *Transition 017* explore more linearizations and reach the same traces as before.
+
+- **A PDF draws every state and tree figure a migrated document holds.** A state transition's label is written with its colons as entities, so a trigger or guard naming a qualified element (`accept Signals::Go`) no longer reads as a Mermaid class marker and fails the chart; and each Mermaid chart is drawn under a configuration sized to it — as is an HTML page that loads Mermaid with `-html-mermaid`, whose script is configured for its largest chart — so a tree or flowchart of more than 500 edges or 50 000 characters is drawn rather than refused by Mermaid's defaults. The configuration stops at twenty times those defaults, and a chart past 1 000 000 characters or 10 000 edges is refused with a typed `oversized-diagram` error naming it and its size — to be drawn as `dot` or `plantuml` — so no model asks a browser or `mmdc` for unbounded work. A figure taller than the page is scaled onto one page with its caption instead of running off the page's foot with the caption on the next.
+
+- **The SysML v1 migrator writes a transition effect with no body as `do action effect { }`.** An effect activity with no nodes was written `do action effect;`, which ended the transition clause before its `then`, a syntax error in the migrated notation; the braces are now kept so the `then` still belongs to the transition.
+
+- **An `exhibit state` usage naming nothing exhibits itself.** Per SysML v2
+  §8.3.17 (`ExhibitStateUsage::exhibitedState` redefines `performedAction` — the
+  reference feature of the owned reference subsetting, or the usage itself when
+  there is none), `exhibit state modes { in cmd = port.cmd; … }` and
+  `exhibit state idle;` are state usages with their own (possibly empty) body,
+  not references to one held elsewhere; `exhibit state modes;` no longer fails
+  instantiation with `classifier behavior names no body`, and a body declaring
+  no initial state fails with `ErrNoInitialState` at initialization like any
+  machine stating an empty body. An `exhibit` declaration that does name an
+  element — the `exhibit m;` reference form, a `references`/`::>` clause, or a
+  typing — that resolves to no behavior body is still reported.
+
+- **`explore` varies the first run's choice points earliest first.** The runs took the untried alternatives deepest first, so a choice met early with a long tail of closed choices behind it — the `do round` at t=79 of the runtime showcase's spacecraft, after two hundred token orders — was varied only after every order of the tail, and `explore:runs=300` tabled one of the two outcomes `-engine check` finds. The prefixes an exploration leaves now run in plan order: every prefix departing from the first run at one choice before any departing at two, earliest choice first, so a `runs` budget of one more than the first run's choice points varies each of them at least once, and the table is the same at any `-jobs`. A choice point past the `depth` budget is still never varied, and every choice point a run met is in its witness, which sizes `depth`: the spacecraft's run to t=80 meets 277, so `explore:runs=300,depth=512` tables both outcomes where the default depth of 64 cannot (`action_explore_early_race_long_tail`, `TestExploreTablesTheSpacecraftRaceWithinItsBudget`). The run a witness names moves with the order: the three-writer race's `x = 1` is now reached by run 5 rather than run 4.
+
+- **A fork may enter orthogonal regions that have no initial state.** The lowerer used to refuse
+  every `parallel` region without an `entry; then <state>;`, even when a `fork`'s outgoing
+  transitions were the only way in, which UML allows. Each fork's branches are now read into a
+  plan — one target state per orthogonal region of one composite state, at least two branches,
+  none guarded or triggered — and a region a fork enters needs no entry transition of its own; a
+  region with neither is still refused with the same diagnostic, and so is a machine with
+  another way into the composite state — a transition to the state itself, to another of its
+  regions or to its history, the machine's entry naming it, or another fork passing through it
+  on the way to a state nested deeper — since that way would start the region by default and it
+  has no default start. Entering through a fork leaves the source configuration down to
+  the ancestor the source and the composite share, as a move to a single state does, so an
+  active ancestor is neither exited nor entered again — a fork reached from inside the
+  composite's own regions leaves every one of them, in declaration order, ending their do
+  behaviors, while the composite stays active; then the first branch runs its effect
+  and enters the states still on the way down to the composite, and every region enters in
+  declaration order, each branch's effect before its target — which may lie below a
+  region's own substates, the branch entering every state on the way — so a branch's effect
+  precedes the composite's `entry` when the fork sits outside it, even when a region no branch
+  names is declared first, and such a region starts at its own initial state. A branch's effect reads and writes the attributes of the
+  state declaring the fork, as a transition leaving one of its substates does. A composite state entered on the way down does not start the
+  region the branches pass through at its own initial state — only its other regions start as
+  usual — and a do behavior in a region the fork leaves untouched still takes the occurrence
+  that fired it. Branches that end at `done` complete the composite state, or the machine, as an
+  ordinary entry does, and the checker's footprint of a fork covers the regions it leaves to
+  start by default, so their entry behaviors' reads and writes count as the dispatch's. The PSSM
+  referee's classifier stops filing
+  *Fork 002* and *Join 001* as not expressible; both translate and run, and the baseline moves
+  from 39 to 37 `not-expressible` and 18 to 20 `fail`, adjudicated in
+  `docs/project/pssm-referee.md`.
+
+- **Graphical renderings head a root by its name within the view, not its whole qualified name.** The interconnection, state, action and tree forms labelled every exposed root `TMT::'01 TMT PO'::'System Model'::…::tcs : TMT::…::TCS` while its nested members read `pump : Pump`, so a migrated diagram's fixed-size boxes were overrun by their own labels and the picture was unreadable. The Mermaid, DOT and PlantUML writers now drop the namespace every root shares from the roots' names (`Plant::Loop` heads `Loop`; `Systems::Radio` beside `Systems::Braking::Brake` heads `Radio` beside `Braking::Brake`; roots from unrelated packages keep their whole names) and name a type by the name each reference ends in, `~` kept (`~Ports::FuelPort` is `~FuelPort`). The name and type a node carries — `name` and `type` in the rendering JSON, the text form's declarations — are unchanged, and the DOT writer sizes a box from the label it emits.
+
+- **A transition guard reads the accepted payload by the transition's name.** `transition raise first idle accept l : Level if raise.l > 5 then high;` failed with `eval guard of transition raise: no value for feature raise`, as did `if raise.d.level > 5` and a guard on a segment out of a choice or junction reading the accepting segment's payload, `transition up first pick if raise.l > 5 then high;` — the bare `l` worked, and so did `raise.l` in the state's exit and the transition's effect, since only those were evaluated within the transition's firing. A guard, and a probability on a route out of a choice, is now evaluated within the firing of the transition it belongs to: the candidate transition's own, with the payload its trigger just bound, for a plain guard, and the compound transition's, the accepting segment included, for a segment past a choice or junction, an occurrence released from a deferral included. A guard naming a transition that is not being taken reads null, so comparing it is the operator's type error (`type mismatch: operator '<' is not defined for null and an Integer`) rather than a silent false, and a guard the read leaves non-Boolean is `type mismatch: guard of transition raise must be boolean, got an Integer`. Time-trigger durations, change conditions, entry guards and run-to-completion values are evaluated as before, outside any firing (`state_choice_guard_reads_accepting_segment`, `state_junction_guard_reads_call_argument`, `state_guard_reads_own_payload_member`, `state_guard_names_transition_not_taken`, `state_guard_reads_deferred_payload`; `TestRuntimeRobustnessGuardPayload`). The PSSM referee's every row is unchanged.
+
+- **A transition into a history pseudostate restores the configuration it is leaving.** The
+  record a history restores is written when its owning composite state is exited, but a
+  transition whose source is that owner — its self-transition or its completion transition into
+  its own history — used to read the record before its exits ran, so it restored the previous
+  visit's configuration (or, on the first visit, performed a default entry) instead of the one
+  being left. The record is now read after the transition's exits and effects; a history's
+  default transition is taken from inside the owner once it is entered, so the owner's `entry`
+  runs before the default transition's effect and the region's initial transition does not run
+  beside it. A `history` declared in a state machine's own body restores the machine's top-level
+  configuration instead of being refused as a history outside any composite state.
+- **The PSSM referee's translation no longer folds an initial transition's effect into the entry
+  action of the state or region it starts.** The effect ran before the state's own `entry` and
+  again on every re-entry, a history restore included; the initial transition now enters an
+  empty helper state whose completion transition carries the effect. With the history fix, the
+  baseline moves from 36 to 41 `pass` and 23 to 18 `fail` (History 001-A, 001-B, 001-D, 002-A
+  and 002-D), adjudicated in `docs/project/pssm-referee.md`.
+
+- **A join runs the effect of every transition into it.** The transitions into a join and the one
+  out of it are segments of one compound transition, but firing the join ran only the effect of
+  the incoming transition that completed last and dropped the others'. Every incoming segment now
+  fires — its source exited, then its effect — before the state owning the join is exited and the
+  outgoing segment's effect runs; the order among the incoming segments is a region-order choice
+  the scheduling policy draws (`choice join <name>` in traces, source declaration order by
+  default), and a failing effect on any incoming segment fails the step. The static footprint of a
+  transition into a join folds in the other incoming effects too.
+
+- **A join's segments fire with their own trigger's arguments, and a refused replay undoes them
+  all.** A transition into a join that accepts a payload or a call had its effect run without the
+  arguments its trigger names bound, so it read the previous values or failed; each segment now
+  binds what its own trigger takes from the occurrence being dispatched before its effect runs. A
+  join two of whose incoming transitions leave the same region is refused when lowered — UML has
+  the segments originate in different orthogonal regions, so none is an alternative to another
+  — and a replay refused at a later draw among the segments undoes the segments already fired
+  with the rest of the move rather than leaving some sources exited. A segment fired by a timer
+  or a change condition's rise now holds the join, as one fired by a signal does, until the same
+  occurrence enables every other segment into it; and a join whose sources all lie nested below
+  the states of the owner's regions exits those wrappers and the owner, where before it found
+  no owner and left them active. A segment leaving a composite state whose substate is active
+  now holds and fires the join as the occurrence reaches that state from within, exiting the
+  substate first, where before the join never fired. A join of the machine's own regions whose
+  segment leaves a state nested in an orthogonal state of a region now records that region, so
+  the segment exits the nested state and its wrappers once, where before the innermost region
+  was recorded and the orthogonal state was exited a second time when the regions were left. A
+  segment drawn among several transitions out of its source, whose join an earlier region's
+  effect disarms before its turn, fires nothing and records no choice, where before the draw
+  stood among the run's choices as though the segment had fired. A timer's expiry selects the
+  segment it fires as a signal dispatch does — its guard holding and the join it leads into
+  ready — before the route out of the join is resolved, so an expiry that does not fire the join
+  reads no guard beyond it, where before a junction beyond the join with no guard holding aborted
+  the run. Two time-triggered segments into a join whose timers are due at one instant fire the
+  join, whichever expiry is dispatched first, where before each expiry found the other segment's
+  timer to be a different occurrence and the join never fired; timers due at different instants
+  still never fire it. A signal or call dispatched at the instant a segment's timer is due does
+  not stand in for that expiry, so it enables no time-triggered segment; nor does a completion
+  event queued at that instant, so a source completing when a sibling segment's timer is due
+  leaves the join to that timer's own expiry. The checker no longer stops a machine whose closed
+  do round finds no dispatch due short of its timers: it rests, and the clock's advance to the
+  next expiry resumes it, as a run outside the checker does. A change condition's
+  rise selects the segment it fires as a signal dispatch does, the join it leads into ready,
+  before the route out of the join is resolved, so a rise that does not fire the join reads no
+  guard beyond it. The draw among a source's transitions that selects a segment into a join is
+  recorded within the join's move, so a replay refused at a later draw among the segments undoes
+  that record too, where before it stood among the run's choices after the move was undone. The
+  check oracle's snapshot of a run's draws is copied rather than aliased, so a run restored to an
+  earlier point no longer trims a snapshot taken after it. A rise that enables only a segment
+  whose join is not ready is dispatched as a signal nothing takes is — consumed, and counted
+  as one occurrence — so a run stepped under the check policy takes the dispatch it was offered,
+  where before the checker offered a dispatch the poll then refused as nothing to do; and a
+  segment drawn among several that fires nothing is reported as firing nothing rather than as
+  a transition taken. A segment into a join that has no trigger
+  is enabled by another segment's occurrence only once its source has completed — no do behavior
+  of it running and, where one runs, its body done — so the join no longer fires and abandons
+  that behavior; it waits for the next occurrence after the source completes. The footprint of a
+  transition into a join now covers what firing the join reads and writes: every other segment's
+  source, trigger and guard, the exits of every source up to the owner and of every region the
+  owner (or the machine, joining its own regions) has, and the effects of every segment — so the
+  checker's reduction no longer treats a step writing what a sibling segment's guard or exit
+  touches as independent of the join.
+
+- **A junction with several enabled outgoing branches draws one of them as a choice point.**
+  The guards of a junction's outgoing transitions are still read before the incoming transition
+  fires, against the data as it then stands, but where several hold the runtime used to take the
+  first in declaration order; it now draws and records the transition choice point at the junction,
+  as it does at a choice, so a `seed` policy replays its draw, `explore` enumerates every branch, and
+  the trace and the `choice` note name the junction. The draw is made only as the transition fires
+  — after the order among several regions' transitions is drawn and the transition's own guard is
+  read again — so a witness lists the region order before the junction's draw and a transition
+  another region's effect disarms draws nothing, no guard beyond the junction is read again (the
+  route on from each enabled branch, through any further junction, is settled with the
+  transition, and a branch beyond which no guard holds fails only the run that draws it, with
+  the guards it noted on its way), and a
+  replay refused at a choice beyond the junction undoes the draw with the rest
+  of the move; a history's default transition through such
+  a junction records its draw the same way. The unguarded branches remain the default
+  when no guard holds, and a junction with no enabled branch still leaves the compound transition
+  unenabled. The PSSM referee's baseline moves from 44 to 45 `pass` and 16 to 15 `fail`
+  (Junction 003), adjudicated in `docs/project/pssm-referee.md`.
+
+- **A DOT rendering positions a node from the routes that meet it, so a migrated diagram is written for `neato -n2` throughout.** The `start` node and the initial and final pseudo-states of a migrated activity or state machine have no member a `DiagramLayout::Layout` could name, so every view drawing one had an unpositioned node, the header fell back to plain `neato` and the written `Route`s were redrawn rather than kept. A node with no `Layout` now takes its box from the first waypoint of a route leaving it or the last of one reaching it, sized as the writer already sizes it; a stated `Layout` still wins, a one-point route places nothing, and a node with neither stays unpositioned so the engine degrades as before. A cluster with no `Layout` is boxed round its positioned members and its anchor pinned there, which `neato -n2` requires.
+- **The SysML v1 migrator writes a `Layout` for the control, buffer and final nodes it declares.** A fork, join, decision, merge, buffer or activity final node written as a named member of the migrated action (`fork 'fork';`, `action final terminate;`) was counted "not exposed" by the diagram join and left without a placement; it is now exposed and positioned like an action, while an initial or flow final node, which the migrator spells as the inherited `start` and `done`, is still counted not exposed and positioned by the renderer from its routes.
+- **`-render-all` writes a view whose name is not a bare filename instead of stopping the run.** A qualified view name containing `/`, `\`, `:`, `%`, `.`, a control character or a character Windows reserves stopped the whole run with "does not form a safe rendering filename"; the unsafe bytes are now percent-encoded (`%2F` for `/`), a Windows device-name stem has its first byte encoded, and the encoding reverses to the view name. A name past the 255 bytes a path component holds is cut and tagged `~` and a hash of the whole, and a save writes its temporary file under a name that fits beside a destination that long. Two views meeting in the same path, letter case aside, are refused together.
+
+- Editing a document no longer recomputes workspace-wide gathers inside the edit; they are recomputed on the first diagnostics or query after it, so an edit itself is as cheap as before those gathers existed.
+
+- **Untyped usages now subset the standard-library base feature for their kind and derive their type from that feature, preserving inherited members through recorded library specialization edges.**
+- **Parameters of a step or calc typed by a standard-library behavior are redefined by position whether the library is parsed, restored from the on-disk cache, or loaded from the embedded snapshot; this is now covered by tests.**
+
+- **`Session.LoadFile` follows imports to sibling model files.** Loading one file through the session's exported single-file API now pulls in the `.sysml`/`.kerml` files beside and below it that declare an imported root namespace, as `%load`, `-check`, `-compile` and `-render` already did, and submits them together so a reference into a sibling package resolves.
+
+- **Loop and branch bodies now preserve member-attached `then` successions.** These flows are retained when parsed and exported instead of silently losing their positional edge.
+
+- **An edit no longer re-derives every wildcard import in the workspace.** An import whose target did not resolve — an ambiguous or unknown package — was bookkept as reading from the document root, so a keystroke anywhere purged and rebuilt the re-exports of every importing file before the language server could answer again. Diagnostics, completion and semantic highlighting now catch up in milliseconds after an edit rather than seconds.
+- **Completion keeps its name table across edits.** The table of simple names the language server completes and suggests corrections from was rebuilt from the whole index after every change; it is now refiled for the names that changed, so the first completion after an edit answers as fast as the next.
+- **Semantic tokens index the text they were computed from.** The tokens and the document content were read separately, so an edit landing between the two could encode one revision's tokens against another's lines; both are now taken in one read.
+
+- The language server no longer scans the filesystem root for sibling files when a document at the root is opened outside every workspace folder; the walk could take minutes.
+
+- **Diagrams and diagnostics of a large document no longer stall the language server.** Byte offsets are mapped to editor positions through the document's line index instead of a scan from the start of the text on every span, so `opensysml/render` on a multi-megabyte model answers in seconds rather than minutes.
+
+- **Name lookup no longer resolves a membership import's target when the import cannot surface the name.** A non-recursive `import P::x` (or `expose x`) only surfaces `x` or the target's short name, so an unqualified lookup of any other name skips it; previously each import's target resolution re-walked the scope's sibling imports, making lookups in a namespace with many `expose` or `import` members factorial in their number.
+
+- SysML v1 migration no longer copies opaque expressions whose cast multiplicity bounds or body-local connection/flow endpoints name members the model lacks, and treats declared connector-end names as in scope within their connector bodies.
+
+- **The v1 migration writes a transition whose effect has an empty body in a form the parser accepts.** An effect that is an Activity with no nodes was written `do action effect;` before the transition's `then <target>`, which the parser rejects, and one such transition made the whole migrated file unwritable. The effect is now `do action effect { }`; a state's `entry`, `do` and `exit` actions, which stand alone, keep `entry action x;`. A transition whose `effect` refers to a behavior owned elsewhere, which was dropped without a word, now runs it: `do action : Def`, with no `;` before `then`. The report says when an action is empty, and an effect or state action whose every node is refused, or whose opaque body is in a language the mapping cannot write, is reported approximated with the reason. The migrated OMG PSSM test suite, which this made unwritable, is gated in `tests/corpus` (`TestPSSMSuiteMigration`): its notation must parse and its report totals ratchet; see `docs/project/pssm-migration.md`.
+
+- **Validating deeply nested calls no longer takes exponential time.** Typing a call read its arguments again for every reader of the enclosing call — the argument check, the result type, the held element type — so each level of nesting doubled the work and ten nested `calc` invocations took seconds while a generated query nesting twenty took longer than anyone waits. The checker now types every call once per scope and answers its silent re-readers from that memo, keyed by the call and its scope, with a call still being typed never recorded as its provisional unknown type; the reporting checker types as before, so each diagnostic is still reported once at the place it arose. The semantic model likewise answers a memoized call selection without retyping its arguments.
+
+- **A body inside a nested definition no longer reaches the enclosing definition's features
+  by their bare names.** `part def P { attribute n = 1; calc def E { n + 1 } }` — and the same
+  shape with a `constraint def`, an `action def`'s `assign`/`if`, or a `state def`'s transition
+  guard — now reports `Must be an accessible feature (use dot notation for nesting)`, as the
+  reference implementation does: a nested *definition* is a new type with no featuring
+  relationship to the one that owns it, so `n` is a feature of `P`, not of `E`. The featuring
+  contexts of a definition were being derived from its owner as if it were a feature. A nested
+  *usage* (`calc e { n + 1 }`) is featured by `P` and still reaches `n`, and a nested definition
+  still reaches its own, inherited and redefined features and every package-level feature.
+
+- **The nightly snapshot signs again.** The cosign installer action was pinned at a v3 release that fetches a detached `.sig` for the requested cosign, but cosign v3.0.1+ ships `.sigstore.json` bundles instead, so the install step failed with a 404 before signing. The workflow now pins cosign-installer v4, which verifies those bundles.
+
+- The nightly snapshot's release notes no longer break mid-sentence: GitHub renders a release body with hard line breaks, so each paragraph is written as one line.
+
+- The nightly snapshot no longer falls back past the commit it was last built from when `develop`'s head is red: the walk for the newest green commit ends at the published snapshot, so a night with nothing newer green leaves the previous snapshot standing instead of publishing an older commit — or failing on one that predates `scripts/build-release-artifacts.sh`, which is now skipped.
+
+- **Feature chains are now written in the normative interchange shape.** A chain (`connect a.b.c to d`, `:>> a.b`, `references a.b`) converts to a chain `Feature` that owns one `FeatureChaining` relationship per link — owned by the `ReferenceSubsetting`/`Redefinition`/`Subsetting` that states it, or an `OwningMembership` for an invocation's chain — beside the derived `chainingFeature` list, so other tools' graphs read and a re-conversion states the normative elements. Unresolved links are `{"@ref": "<name>"}` rather than bare strings; reading accepts the normative form, the derived list, or both, and refuses a graph where the two disagree. Interface ends are `PortUsage` (the grammar's `InterfaceEnd`), `then` successions state their two ends in `EndFeatureMembership`s, and `perform`/`exhibit`/`include`/`assert`/`satisfy` usages spell their qualifier from the metaclass alone — named forms like `perform action pa : A`, unnamed reference forms like `perform sub.sa :>> a2` and `assert c1` — so graphs carrying no `sysx:` annotations still write the qualified notation.
+
+- **A region-owning state is not re-entered by a transition inside its region.** In a parallel state, a transition between two substates of one region (`state left { entry action …; state prep; state work; transition first prep when Go then work; }`) ran the entry behavior of `left` again and restarted its do behavior, although `left` never became inactive; a counter its entry incremented read 2 where KerML `StatePerformance` runs `entry` once per activation. The move now keeps the region owner active, whether it is the region of a top-level parallel machine or of a parallel state nested deeper, so only the substates below it are exited and entered; a transition out of the owner still exits it, and a transition into it still enters it afresh (`state_parallel_owner_entry_once_intra_region`, `state_nested_parallel_owner_entry_once_intra_region`).
+
+- **A PDF's default faces are Times, Arial and Courier, not whatever the generic family resolves to.** The print stylesheet asked for bare `serif`, `sans-serif` and `monospace`, which fontconfig resolves to DejaVu on most Linux machines — a face some 15 % wider and taller than Times at the same nominal size, so an 11pt page read like 13pt — while the metric-compatible Liberation faces installed beside it were never chosen. The default body, heading, code and page-number stacks now name the conventional families first, their free metric-compatible equivalents next (`"Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif`; `Arial, Helvetica, "Liberation Sans", "Nimbus Sans", sans-serif`; `"Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace`) and the generic family last, for the pandoc engine's page as for WeasyPrint's and Prince's. Page size, margins and every point size are unchanged, and the HTML page keeps its system face.
+
+- **The PDF toolchain CI job now reads its rendered PDFs back.** The job installs `poppler-utils`, and the integration tests treat an absent `pdftotext`/`pdfimages` like an absent converter: a skip locally, a failure under `OPENSYSML_REQUIRE_PDF_TOOLCHAIN`. Before, the text and image assertions (headings, captions, formulas, diagram source kept off the page) silently skipped in CI for want of `pdftotext`.
+
+- **A `perform action` usage naming nothing performs itself.** Per SysML v2
+  §8.3.16 (`EventOccurrenceUsage::eventOccurrence` — the reference feature of the
+  owned reference subsetting, or the usage itself when there is none) and §8.3.17,
+  `perform action boost { in amount = level; }` and `perform action idle;` are
+  action usages with their own (possibly empty) body, not references to one held
+  elsewhere; instantiation no longer fails with `classifier behavior names no
+  body` on them, and the body's `in` members bind the performance's parameters.
+  A `perform` declaration that does name an element — the `perform a;` reference
+  form, a `references`/`::>` clause, or a typing — that resolves to no behavior
+  body is still reported.
+
+- **The pilot corpus downloader refuses to report success over an empty corpus.**
+  `pilot_fetch_subtrees` in `scripts/pilot-pin.sh` now fails, installing nothing, when a subtree
+  of the pinned release holds no file of the kinds asked for, and re-fetches a destination that
+  is stamped at the current pin but holds no such file instead of reporting it present; before,
+  either left a stamped, empty directory that a required corpus gate would then fail on with no
+  hint of why. `scripts/pilot-pin-test.sh` checks the downloader against a throwaway release
+  repository and runs in CI before any corpus is fetched. The contributor docs now list all
+  three download scripts beside the `OPENSYSML_REQUIRE_*` variables that make their gates
+  mandatory.
+
+- **A positioned node's label fits the box its `DiagramLayout::Layout` states; the box is never grown to the label.** The DOT writer word-wraps the head at the stated width and draws it at the largest font size from 14 pt down to 8 pt at which the wrapped lines fit the height, keeps the `«keyword»` and detail lines only while height remains, and cuts and ellipsizes a head that overruns even at 8 pt, using the same glyph estimate the unsized boxes are fitted with; the box's `margin=0` gives the whole of it to the label, as the fit assumes. A migrated Cameo diagram, whose boxes were sized for the name alone, reads as it did: a 449×14 px attribute row holds its one line, `call : doTracking` no longer spills out of its action box, and Graphviz's `size too small for label` warnings on such a model drop to none. A stated box that holds other stated boxes — a part drawn round its members, a definition over its compartment rows — sets its title in the strip above the topmost of them, fitted to that strip, so the title is read as the frame's header rather than covered by the members, which stay where the Layout put them; a box drawn as a cluster round its children is fitted the same way. A stated box, or the strip its members leave it, too short for one 8 pt line or too narrow for one glyph holds no text, and sets its head beside the box instead. A node without a stated size keeps its label-fitted box.
+- **A control node or port in a stated box is drawn as its notation symbol, with no text inside.** A decision, merge or choice is a diamond, a fork or join the filled bar, an initial node the filled dot, a final node or terminate action the double ring, and a port its small square; the node's name is set beside the symbol as an `xlabel`, and left out when the view IR marks it as one the model did not give (`Node.NameSynthesized`). Without a stated box these kinds keep their labelled shapes, except that an action's `start` and `done` — the language's names, not the body's — are now the filled dot and the double ring in every graphical form, where they were a large labelled circle.
+- **A name the SysML v1 migration made up is not drawn.** The migrator now records every name it spells for an element its source left unnamed — `'start to call'`, `fork2`, `decide`, an `unnamed` ref — once per body, as `metadata MigrationMetadata::SynthesizedName about …;` from the new bundled `MigrationMetadata` library. The renderings read the marker from the model: such a node is drawn as its source drew it — a control node as its bare symbol, a typed usage as `: Type` alone — and an edge whose only text would be such a name carries none, while a triggered transition or a guarded succession keeps its trigger and guard. Names the source gave, however spelled, are never marked; the migration report and results are unchanged.
+- **A member drawn under its owner is headed by its name below that owner.** A nested node, or an exposed element whose owner is drawn in the same rendering, no longer repeats the owner's qualified path: `'K-Mirror Offset'::'interpolation Error' : 'Interpolation Error'` inside the `'K-Mirror Offset'` box reads `'interpolation Error' : 'Interpolation Error'`, as a diagram frame shows it. Only the graphical forms' heads change; the text and JSON forms and the LSP keep the qualified name.
+- **A positioned DOT drawing leaves the nodes no `Layout` places undrawn, so none lands on a placed box.** When some nodes of a view are positioned and others are not, the DOT writer left the others to `neato`, which set them wherever it found room — over the positioned boxes, in a migrated diagram whose source never drew them. They are now left out, with the edges at them, under a `// not represented:` notice that counts them, so the drawing shows what the source diagram showed; every node drawn is pinned, so the `// layout:` header names `neato -n` or `neato -n2` and never plain `neato`. The new `-render-unplaced strip` (`Options.Unplaced` in the view API) keeps them instead, boxed and packed in rows below the canvas or the positioned boxes' extent, clear of them and of one another; it applies to `-render`, `-render-all` and the `dot` diagrams of `-render-document` and `-render-documents`, and an unknown placement is refused with the two there are. A view with no positioned node is laid out by `dot` as before.
+- **A view's layout annotations and `render` members are not drawn as nodes.** The member walk every rendering kind shares leaves out `DiagramLayout::Canvas`, `Layout` and `Route` annotations, wherever they are owned, the `MigrationMetadata::SynthesizedName` markers a migration leaves in a body, and the `render` members a view holds, so a tree over a package of migrated views no longer fills with `metadata`, `x`, `y`, `width`, `height` and `asTreeDiagram` nodes. Every other metadata usage, and a rendering usage outside a view, is drawn as before.
+- **A parallel state may carry a metadata usage in its body.** Lowering a `state … parallel` body treated a `metadata` member as unsupported content and refused the whole state machine, so its state rendering came out empty; the annotation is now the state's own, like an attribute or a port, and its substates alone are the regions.
+
+- **`make proto-breaking` reads only `api/proto` from the baseline.** The baseline archive is
+  taken from the `api/proto` subtree of `BUF_BREAKING_REF` rather than from the whole commit with
+  a pathspec, which walked the whole tree and, from a blobless checkout, lazily fetched every blob
+  the commit does not share with the checkout — a fetch CircleCI's checkout cannot always make, so
+  the check failed with `could not fetch … from promisor remote` on a merge that touched no
+  protobuf file. The cvc5 download in the same pipeline retries a failed transfer instead of
+  failing the job on one bad response from the release host.
+
+- **A pseudostate can carry a quoted name.** `fork 'spread 2';`, `join`, `junction`, `choice`, `history` and `deep history` read their name as every other declaration does, an unrestricted name `'…'` included, where only an identifier or keyword parsed before; a transition then reaches it by the same quoted name.
+
+- **The PSSM referee's translation carries the values a test's constructor writes.** A test
+  class whose `<Class>$factory` activity assigns a literal to an attribute of the new instance
+  (*Join003*'s `value = 15`, read by the guard of the join's outgoing transition) lost the
+  assignment: the attribute was declared without a value and the run failed with `no value for
+  feature value` before reaching the guard. The literal is now the attribute's initial value; a
+  constructor that does anything else — writes a feature the class does not own, writes
+  something other than the new instance, or computes a value — is refused as untranslatable
+  rather than dropped. No bucket count moves: *Join003* still fails, now at the join itself
+  (`docs/project/pssm-referee.md`).
+
+- Checking a constraint or writing a feature no longer rescans every object's behaviors when nothing has changed since the last scan found them all idle, so a batch of checks over many instantiated objects is linear again.
+
+- **The RDF mapping links references instead of naming them.** A property the SysML v2 API defines as a reference — `sysml:type`, `sysml:importedNamespace`, `sysml:importedMembership`, a succession's `sourceFeature`, a feature chain's `targetFeature`, a feature reference's `referent`, an invocation's `function` — is now the IRI of the element the name resolves to: an element of the graph by its own id, and a standard library element by its normative id whether or not the library is in the graph (`attribute mass : MassValue` links `<urn:sysmlv2:element:9cd0e404-efee-50e5-a59b-681065bd188c>`). Only a name that resolves to nothing the model declares stays a literal, and an element declaring the id the norm fixes for a library element the graph links is refused rather than merged with it. Every metaclass written is concrete, as every element the API returns is: an import is `sysml:NamespaceImport` or `sysml:MembershipImport` and an `expose` is `sysml:NamespaceExpose` or `sysml:MembershipExpose` rather than abstract `sysml:Import`, an import written through an alias links the alias's owning membership, and a KerML `connector` is `sysml:Connector` rather than `sysml:ConnectorAsUsage`. Graphs written by earlier releases still read: the decoder accepts a literal where a link now stands, and the two abstract classes, and writes the current form on the next hop. Name resolution now also finds the `start` an implied `first start then a` names when the action inherits it from the library, a feature chain's target from its featuring usage, and the names in a `dependency` body, so a metadata usage annotating a dependency links its type and reads back from the graph alone.
+
+- **Relationship queries no longer spend their visit budget building edge tables.** `RelatedElements`, `WhereRelated` and relationship-derived columns build the edge table of a relationship kind by scanning every declaration in the workspace, and each declaration scanned was charged to the query's visit budget — so on a model past ~100,000 declarations every relationship query failed with `visit-budget` before traversing anything (a migrated TMT requirements-mapping document, 101,014 declarations, needed about 200 visits for its rows). The scan is a fixed cost of the model, not of the query: it is now memoized per model in `queryexec.Context.Related`, shared by every query a document (or a linked set of documents) evaluates, and left uncharged; the budget still bounds the traversal itself, paying one visit per element reached, and `visit-budget` is still the typed failure when that is exceeded.
+
+- **`-render-all` writes two views whose names differ in letter case alone instead of stopping.** A model naming two views `Report` and `report` (or two migrated Cameo diagrams named `iRIS …` and `IRIS …` in one package) stopped the run with "have the same rendering path", because a filesystem that ignores case would hand both one file. Each such view is now written under its name tagged with `~` and a hash of the encoded name, as a name too long for a path component already is, so the rest of the model still renders; a view whose name meets no other keeps its plain filename.
+
+- **A redefinition is masked along an alias path too.** The workspace built two scope trees for each of its documents — one the index resolved and analyzed, one it enumerated visible names and positions from — while the resolver memoizes a reference's answer by its syntax node, shared by both. Once a document had been analyzed, an alias resolved from the enumeration's tree came back as the index's copy of its target, whose members the redefinition mask (keyed by the enumeration's symbols) did not recognize: at `feature B redefines A` inside `A`, the redefinition being written reappeared as `test.A.A.B` through `alias A for A1`, though not on any direct path. A workspace document now hands its own scope tree to the index (`Index.AddDocumentScope`), so every route reaches one symbol and the pilot Xpect suite is back at its recorded 1296 agreeing expectations.
+
+- **A segment leaving a junction or choice declared inside a composite state runs its effect after
+  that state's entry.** A compound transition used to run every effect of its route after its
+  exits and before any state on the way down to the target was entered, so a segment out of a
+  pseudostate declared in a composite state — the composite being entered on the way to the
+  pseudostate — logged its effect before the composite's `entry`. Each effect now runs once the
+  states down to the one declaring the pseudostate it leaves are entered: the composite's `entry`,
+  then the segment's effect, then the entry of the target below, at every depth of nesting, for a
+  junction as for a choice (whose guards are read once the state declaring it is entered and the
+  effects into it have run, so a guard testing what that state's `entry` wrote reads the new
+  value), for a pseudostate in one region of a parallel state (the parallel state entered first,
+  the other regions starting as usual) and for a history's default transition through such a
+  pseudostate. A choice whose branches end in different states enters only the states every branch
+  enters before its guards are read. The PSSM referee's counts do not move: *Junction 005* now
+  reaches an admitted trace and misses only the interleavings of the other region's entry, so it
+  stays `fail` on the region-order gap alone, adjudicated in `docs/project/pssm-referee.md`.
+
+- **Fixed a send receiver expression leaking what it built.** A `send … to <expr>` whose expression constructed objects — `send new Ping() via out to new Car()` — left the constructed object and its behaviors behind when the send then failed to deliver; the payload and receiver are now abandoned together. A `to` expression yielding a selected variant — such as a selection over `engine` where `engine::electric` is the chosen variant — now addresses the object the variant materializes instead of reporting that it holds no object.
+
+- **The npm platform packages and the nightly pipeline build statically linked binaries too.** `CGO_ENABLED=0` now governs the npm platform packages as well as `make build`, and `make static-check` runs in the nightly and npm pipelines beside the release and pull-request ones, so a dynamically linked binary fails those builds instead of shipping.
+
+- **Two flows out of one pin deliver twice.** A value a streaming flow carries to a target not yet under way waits at the target's pin, and a later write from the same source performance replaces it; the waiting place was keyed by the source performance and pin alone, so two `flow` declarations out of one pin into one target pin — the two routes of a fork duplicating a token, or a pin named by its inherited and its redefining name — collapsed into one delivery, and a second performance of a nested action definition with a required input ran with the input unbound (`unbound parameter`). Each `flow` declaration is a transfer of its own: the runtime (and the `smt` engine's encoding) keys the place by source performance, pin and flow, so the two flows each stage the write and two performances of the target each take one, while a later write along the same flow still replaces its earlier one (`action_flow_streaming_two_flows_one_pin_to_call`, `action_flow_streaming_aliased_source_pin`; on the fUML suite `ForkMergeData` reaches its recorded `0, 0` again).
+
+- **SysON plugin diagnostics land on the element they are about.** A parser diagnostic inside a nested element now maps to the innermost named element enclosing its line rather than to nothing; a serializer warning that names an element by id is attributed to that element (or its nearest named owner) rather than to the run target; and an anonymous satisfy or constraint usage can be the target of `verifySatisfaction` and `validateInstance`, which run against its enclosing named element.
+
+- **`terminate;` in a braced `entry { … }`, `do { … }`, `exit { … }` or transition `do { … }` block ends the whole block.** A `terminate` among the block's statements ended only the statement it was written in, so the assignments after it still ran — `entry { assign e := 1; terminate; assign e := 9; }` left `e` at 9 where the same body as `entry action a { … }` left it at 1. The block is now the action the `terminate` ends, in a state's and in the machine's own `entry`/`do`/`exit`, in a transition's `do` effect and in the blocks a definition's usages inherit: the statements after it do not run, a `do` block's later `accept` never parks, while a named action beside the block (`entry action first { … }`), the sibling regions' blocks, the state's `do` after its `entry` and the transition's completion after its `exit` or effect run as before (`state_terminate_braced_entry_do_exit_effect`, `state_terminate_braced_do_after_accept`, `state_terminate_braced_entry_among_named`, `state_terminate_braced_inherited_by_two_usages`, `state_terminate_braced_do_in_one_region`; `TestRuntimeRobustnessTerminateBlock`).
+
+- **A document binding may name a nested usage in dot notation.** `in req = specification.mission.range;` in a document's content block resolves the chain to the nested requirement; it was rejected as an unsupported binding before, so a query parameter could only be bound to a top-level usage or a qualified name.
+- **A verification case is matched to a requirement by the declaration it names.** `Verdicts(...)` compared the requirement a verification case verifies by scope-tree symbol, so a requirement nested inside a part that the document indexed under a second scope root had its verification cases dropped; the match now uses element identity, and the case's verdict row appears.
+- **The pandoc PDF engine no longer receives a `document-css` variable.** Passing `document-css=false` did not turn pandoc's built-in stylesheet off; pandoc read the variable as set, and its screen layout narrowed the page beside the print stylesheet. Only the print stylesheet is passed now.
+
+- **A wide table in a PDF document stays within the page.** The PDF stylesheet let a table grow past the text width when its cells held long unbreakable tokens such as qualified names, so the rightmost columns were cut off at the page edge; tables now take the text width and cells wrap anywhere they must.
+
+- **The SysML v1 migrator reads a transition guard serialized as a reference.** A UML `Transition.guard` some exporters write as a `guard="…"` reference to an owned rule of the transition, rather than as a `guard` child, is now found and written as the `if` clause, reported and kept in a comment when it has no v2 form; before, such a transition was written unguarded and its constraint left out of the report. A `LiteralBoolean` guard whose `value` the file omits is read as `false`, the UML default, where a transition guard is concerned; before, it was taken for `true` and dropped.
+
+- A transition from a substate into the composite state enclosing it no longer restarts that state's default substate: the composite is already active, so it is not re-entered, and the body the substate left completes — a composite with no other region completes and its completion transition fires, a parallel owner waits for its other regions. The PSSM referee's *Transition 011 C* moves `fail` → `pass` (`docs/project/pssm-referee.md`).
+
+- **A case's timed steps run on the clock.** A case body's action flow waited on the clock for
+  its own `accept after`, but the clock did not list its waits, so a timed step in an analysis
+  or verification case deadlocked; the flow is now on the clock for the run. A case an action
+  body performs as a step pauses that body, whose executor lists the case's waits among its own
+  and resumes the step when the instant comes, so a wait for a message nothing posts is the
+  typed `ErrAcceptDeadlock` of the performing action, and an expression reading a case's output
+  while it waits is the typed `ErrCaseReadWaits` naming the wait (`analysis_steps_wait_on_clock`,
+  `action_case_step_waits_on_clock`).
+- **A state behavior of no content executes as nothing.** A state's entry, do or exit behavior,
+  or a transition's effect, written as an action usage with neither a body nor an action
+  performed (`entry action hello;`, `do action log`) was refused at run time as performing no
+  action; it now executes as nothing, as a bodyless nested action of an action body does
+  (`state_behavior_action_of_no_content`).
+- **A binding end at a performed action's node reads the body's names.** A `bind` written at a
+  node of a `perform action` resolved a simple name to the performing part's feature before the
+  enclosing action's same-named parameter, so a parameter given no value read the part's value
+  instead of being empty; the name now resolves in the body's scope first, as an expression of
+  the body does. A pin valued by its own name (`inout log = log`) reads the feature it masks
+  around the usage owning the pin rather than itself, which was refused as a cyclic feature
+  value (`performed_action_binding_end_names_parameter`).
+
+- **A parallel region stood for by a stateless state needs no initial.** A parallel state or machine whose direct substate declares no substates of its own is one region that starts in that state and stays there; lowering demanded an `entry; then <state>;` of it and refused the machine with "region … has no initial state", whether the region was a parallel state's or the machine's own. Both now lower and run, and a state's entry, do and exit behaviors, transitions and deferred events do not make it composite (`state_parallel_stateless_region`, `state_parallel_stateless_top_region`, `state_parallel_stateless_region_with_behaviors`).
+
+- **A `via` path that is a bare bound port reference leaves the bound port.** `send … via p` and `accept … via p` under an `in ref port p` that the caller binds to another object's port were rooted at the performer, so the message left or was awaited at the performer's same-named port, or was refused as unconnected; the bound port's owner now sends and receives it, and an action's own connector may end at such a reference by name. A binding that holds an object which is no port is refused as `ErrSendViaNotPort` rather than falling back to the performer.
+
+- **A private v1 property shown on a diagram in another namespace is no longer written `private`.** The view's `expose` — and any layout annotation naming it — must be able to refer to the feature, and v2 hides a private member from every qualified path; the migration report notes "private visibility is not written: view … exposes it".
+
+- **Transition triggers are labelled by the name their signal or operation ends in.** A rendered state or action view wrote an `accept` trigger as its source text, so a migrated transition accepting `TMT::'02 JPL'::…::Control::'Post-Segment Exchange Alignment'` carried that whole path across the drawing. The DOT, Mermaid, PlantUML and text forms now head the trigger by its end name — `accept 'Post-Segment Exchange Alignment'`, `accept msg : Halt`, `accept setSpeed(value)` — the way a node's type is headed; time and change events keep their written text.
+
+- **`.sysml` and `.kerml` files are recognized in VS Code Restricted Mode.** The extension now declares limited untrusted-workspace support, so files are no longer opened as Plain Text when the folder is untrusted; looking up `bin/sysml-lsp` inside the workspace requires a trusted workspace.
+
+- **The diagram panel shows an element table as a table.** A table-kind view or the `#table` pseudo-view was written into the panel as its Markdown source; it is now drawn as a table from the rendering's rows, and clicking a row opens its element in the editor.
+
+- **Every witness `check` writes replays.** A replay past its witness's last choice line went on as `reverse`, a sweep giving every token its turn in one step, where the checker that wrote the witness made one move a step to the end; a `do` body looping through timed waits kept stepping after the last order the checker had to record, so the replay left another trace and `-engine check` reported `replay disagrees with the witness` — the runtime showcase's spacecraft, checked on `SpacecraftComms::mission.spacecraftVehicle`, wrote a witness of `battery = 39` it could not reproduce. A replay now stays one token a step past the witness, picking as `reverse` would, and the run it re-makes is the checker's (`state_do_action_loop_timed_exit`). An action performed inline in another's flow steps its own tokens within the performer's step, and the checker records the inner branches' order at the performer's step number; replay followed that line against the performer's frame, where the performer's token alone is able to act, and refused it — it now follows an order over the tokens of the flow holding them all, so the line is resolved in the inner flow, and an order naming a token no flow holds is refused once the step is past (`TestCheckWitnessesOfAnInlinePerformanceReplay`, `TestRuntimeRobustnessReplay`).
+
+- **XMI metadata attributes on stereotype applications are no longer emitted as stereotype tags.** Migration now ignores tool metadata such as `xmi:uuid` instead of reporting it as an unsupported tag.
+
+### Performance
+
+- Validating a model rich in membership imports no longer re-scans the names registered under a segment on every lookup: `ShortNamed` is memoized per index generation, undoing a ~25% whole-model validate slowdown introduced with the import-prune fix.
+
+- **A workspace keeps its semantic model between edits and invalidates it per document.**
+  `model.Workspace` owns one `resolve.Resolver` and one `semantics.Model` for its lifetime and
+  hands them to every analysis it runs; the resolver keeps a frame per document owning what was
+  memoized while that document was analyzed and records which documents it read (a namespace it
+  imports that another contributes to, a namespace both contribute to, a symbol of another that a
+  resolution returned). Replacing a document drops its frame and, transitively, its dependents' —
+  their memo entries, cached diagnostics and reverse references — and nothing else, where every
+  edit used to clear the whole workspace. The OOSEM, MOSA and identity-metadata audits and the
+  coherent-quantity ranking gather each document's facts once into the workspace and judge each
+  analyzed document over the union, where they gathered every document once per document
+  analyzed. `TestIncrementalEqualsFresh` replays scripted and random edit sequences over the
+  fixtures and the OMG corpora and compares diagnostics, resolutions and references with a fresh
+  workspace after every step. On the satellite-network stress test, editing a two-line file beside
+  512 satellites goes from 861 ms and 327 MiB per edit to 8.7 ms and 2.0 MiB; editing the library
+  every file of the split network imports costs one analysis of the model (8.8 s to 5.3 s at 512
+  satellites), and loading the 1 600-satellite network split into 34 files through one workspace
+  goes from 126 s to 18 s. A loaded workspace holds about twice the heap (254 MiB to 478 MiB at
+  512 satellites), the memo tables that were allocated and discarded on every analysis, and a
+  thousand edits grow it by 4.5%. A one-shot `sysml -validate` pays the dependency recording it
+  never uses: about a sixth more wall time (1.9 s to 2.2 s at 200 satellites) and 4% more
+  allocation. Figures and the machine they were taken on are in `docs/internals/performance.md`
+  and `docs/project/satellite-network-stress-test.md`.
+
+- **A references query made right after an edit is slower than in 0.8.1, in exchange for
+  incremental invalidation.** The resolver now records which documents and names each
+  resolution read, so an edit invalidates only what depended on it: a rename or an edit beside
+  a large document is several times faster than before. The recording is paid on the first
+  query after an edit that walks a long wildcard-import chain, where cold references measure
+  about 40% slower on the LSP benchmark; the query itself returns the same locations.
+
+- **Resolving a name through a scope no longer rescans every anonymous member of that scope for implicit parameters.** The resolver used to walk all anonymous members and test each for an implied redefinition on every unqualified lookup, so a definition with many anonymous interface usages cost more per name the larger it grew. The candidates are now collected once per scope and journaled with the other per-scope caches; validating a 1 600-satellite constellation of fully modeled spacecraft drops from 30 s to 19 s.
+- **Checking `n` satisfy assertions no longer walks the model `n` times for verification cases.** The runtime collected every verification case beneath the model root on each satisfaction check; the walk is now memoized per scope for the life of the runtime model, so `sysml -satisfy` over 600 assertions on a 200-satellite constellation drops from 5.9 s and 2.2 GiB allocated to 3.9 s and 1.5 GiB.
+
+- The `~` undefined-operator warning now reads the operator sites the parser records instead of walking every node of every document, removing about 8% from load and validation time.
+
+- **A state machine's poll of the signals in flight is memoized.** A run holding many active
+  objects probed every state machine's transitions against every queued message at each
+  scheduling step; the probe's answer is now kept until a queue, a write, a nested call, a
+  rollback or another executor changes what it could see, which makes a long stochastic run of
+  a model with many active parts several times faster with the same trace.
+
 ## 0.8.1 — 2026-09-16
 
 ### Added
@@ -2782,7 +4343,7 @@ release is described in [docs/project/releasing.md](docs/project/releasing.md).
   are reported as not compared rather than diffed forever. The opt-in Flexo harness measures
   the apply against the real stack — an initial load, a revision with a retained-id rename and
   gated deletes, a conflict staged behind the sync's back — and records what read back at the
-  recorded commit ([the report](internal/interop/flexo/testdata/identity_apply_expected.txt)).
+  recorded commit ([the report](internal/translate/interop/flexo/testdata/identity_apply_expected.txt)).
 - **Action and state execution has a referee outside the executor.** Six conformance cases —
   a join fed by branches of unequal length, a join fed twice over one succession, a node two
   successions reach, two fork branches writing one feature, the specification's `ChargeBattery`
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bd915f2bd1..0ee1934641 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -21,13 +21,22 @@ make build  # builds bin/sysml, bin/sysml-lsp, and bin/sysml-grpc with version i
 make test   # runs all tests
 make lint   # runs staticcheck and gosec, as CI does
 
-./scripts/download-training-examples.sh   # fetch the OMG corpus the gate needs
+./scripts/download-training-examples.sh   # fetch the OMG training corpus
+./scripts/download-pilot-corpora.sh       # fetch the three OMG pilot corpora
+./scripts/download-pilot-library-xmi.sh   # fetch the pilot's XMI of the standard library
+./scripts/download-pssm-suite.sh          # fetch the OMG PSSM test suite
 ```
 
-The OMG training-corpus gate (`internal/core/model/training_examples_test.go`) skips while
-`examples/sysml-v2-training/` is absent, so run the download script once before trusting a
-local `make test`. CI runs the script itself and sets `OPENSYSML_REQUIRE_TRAINING_CORPUS=1`,
-which makes a missing corpus a failure there instead of a skip.
+The gates over those downloads (`tests/corpus/training_examples_test.go`,
+`tests/corpus/pilot_corpora_test.go`, `tests/identity/pilot_library_xmi_test.go`,
+`tests/corpus/pssm_migration_test.go`)
+skip while their corpus is absent, so run the four scripts once before trusting a local
+`make test`; a corpus already at the pin is left alone. CI runs the scripts itself and sets
+`OPENSYSML_REQUIRE_TRAINING_CORPUS=1`, `OPENSYSML_REQUIRE_PILOT_CORPORA=1`,
+`OPENSYSML_REQUIRE_PILOT_LIBRARY_XMI=1` and `OPENSYSML_REQUIRE_PSSM_SUITE=1`, which make a
+missing corpus a failure there instead of a skip; an environment that sets any of them must run
+the matching script first. See [docs/project/pilot-corpora.md](docs/project/pilot-corpora.md)
+and [docs/project/pssm-migration.md](docs/project/pssm-migration.md).
 
 ## Development Workflow
 
@@ -85,15 +94,15 @@ go test -race ./...
 make test-short
 
 # Specific package
-go test ./internal/core/parser
+go test ./internal/syntax/parser
 ```
 
 **Parser-specific tests:** When modifying the parser, ensure the four-layer test contract passes:
 
-1. **Conformance gate:** `go test -run TestStdlibConformance ./internal/core/libs`
-2. **Golden ASTs:** `go test -run TestGolden ./internal/core/parser`
-3. **Negative tests:** `go test -run TestNegative ./internal/core/parser`
-4. **Update goldens** (after intentional changes): `go test -run TestGolden -update ./internal/core/parser`
+1. **Conformance gate:** `go test -run TestStdlibConformance ./internal/workspace/libs`
+2. **Golden ASTs:** `go test -run TestGolden ./tests/parser`
+3. **Negative tests:** `go test -run TestNegative ./tests/parser ./internal/syntax/parser`
+4. **Update goldens** (after intentional changes): `go test -run TestGolden -update ./tests/parser`
 
 See [docs/internals/architecture.md](docs/internals/architecture.md#parser-test-contract) for full details on the parser testing contract.
 
@@ -297,30 +306,24 @@ PRs must pass the GitHub Actions `Build and test` check, which requires:
 ```
 github.com/Open-MBEE/OpenSysML
 ├── cmd/                    # Binaries (sysml, sysml-lsp, sysml-grpc)
-├── internal/core/          # Core implementation
-│   ├── source/            # Source file handling
-│   ├── lexer/             # Tokenization
-│   ├── parser/            # Parsing
-│   ├── ast/               # AST nodes
-│   ├── symbols/           # Symbol tables
-│   ├── resolve/           # Name resolution
-│   ├── semantics/         # Type system
-│   ├── passes/            # Validation
-│   ├── lower/             # AST → execution IR (ActionGraph/StateGraph)
-│   ├── runtime/           # Execution
-│   ├── model/             # Workspace
-│   └── libs/              # Standard library bundling
-├── internal/lsp/          # LSP implementation
-├── internal/grpc/         # gRPC service implementation
-├── internal/repl/         # REPL implementation
-├── clients/python/        # Python client bindings (opensysml)
-├── clients/rust/          # Rust client (opensysml) and its conformance runner
+├── internal/              # One directory per layer; a package imports only the layers below it
+│   ├── syntax/            # source, diag, lexer, parser, ast, pack, format
+│   ├── semantic/          # symbols, resolve, suggest, semantics, identity, highlight, query
+│   ├── ir/                # lower, queryplan, docplan, view
+│   ├── check/             # passes, edit
+│   ├── exec/              # runtime, solve, smt, analysis, engines, objref
+│   ├── translate/         # rdf, export, xmi, migrate, convert, codegen, interop
+│   ├── doc/               # queryexec, docir, docrender, docpdf
+│   ├── workspace/         # model, libs, project, envvar
+│   └── frontend/          # protoconv, grpc, lsp, repl, stdiorpc, usage
+├── client/python/         # Python client bindings (opensysml)
+├── client/rust/           # Rust client (opensysml) and its conformance runner
 ├── docs/                  # Documentation
 │   ├── guide/             # The handbook, in reading order
 │   ├── reference/         # CLI, REPL, environment, APIs, RDF mapping
 │   ├── internals/         # Architecture, testing, performance, design notes
 │   └── project/           # Compliance, roadmap, releasing, measurements
-├── testdata/              # Test fixtures
+├── tests/                 # Black-box suites, benchmarks, shared fixtures (tests/parser, tests/testdata, …)
 └── .circleci/             # CI configuration
 ```
 
@@ -345,20 +348,37 @@ When a change needs documenting:
 - **Explain in the guide, enumerate in the reference.** Do not repeat a flag table in both; link to it.
 - **Measured numbers have one home** (fixture, corpus and conversion counts live in
   [docs/project/](docs/project/)); elsewhere, link to it instead of restating a number that
-  will drift. Three release-gate surfaces are the deliberate exception, because
-  [docs/project/releasing.md](docs/project/releasing.md) checks the numbers they print:
-  `README.md`'s coverage line and status table, and the gate tables in
-  [docs/project/roadmap.md](docs/project/roadmap.md) and
-  [docs/project/training-examples.md](docs/project/training-examples.md). Recount all four
-  together, in one commit.
+  will drift. Two release-gate surfaces are the deliberate exception, because
+  [docs/project/releasing.md](docs/project/releasing.md) checks the numbers they print: the gate
+  tables in [docs/project/roadmap.md](docs/project/roadmap.md) and
+  [docs/project/training-examples.md](docs/project/training-examples.md). Recount both together,
+  in one commit.
+- **The test-suite figures are counted at build time, never committed.** The conformance-case,
+  golden-AST, golden-trace, negative-parser, robustness, gRPC and `Test`-function counts in the
+  compliance map's test inventory are `<!-- doc-counts:begin inventory-… -->` blocks whose
+  committed text names what is counted and states no figure; the site build
+  (`scripts/mkdocs_suite_figures.py`, run by `make docs`) splices in the figures from
+  `go run -C tools ./cmd/doc-counts -site-blocks`, which counts the tree the way the gates enumerate it.
+  `go run -C tools ./cmd/doc-counts -check` refuses a figure typed into one of those blocks, so adding a
+  test or a fixture is the whole change and two branches cannot conflict on a count. `README.md`
+  names the gates without their counts; the one suite figure still committed there is whether
+  every conformance case passes, which moves with `known_failures.txt` alone.
+- **Robustness cases are registered per feature.** A runtime failure-mode subtest goes in
+  `internal/exec/runtime/robustness_<feature>_test.go` under a `TestRuntimeRobustness<Feature>`
+  function (gRPC: `internal/frontend/grpc/robustness_<feature>_test.go`, `TestGRPCRobustness<Feature>`),
+  a new file for a new feature; `robustness_test.go` holds the shared cases and is not where new
+  ones go. `go test` discovers them like any test, and the build-time counters sum every
+  `TestRuntimeRobustness*` and `TestGRPCRobustness*` function, so two branches adding cases never
+  edit one registry.
 - **The compliance-row census is counted at build time, never committed.** Adding or changing a
   `✅`/`⚠️`/`❌`/`⛔` row in [docs/project/spec-compliance.md](docs/project/spec-compliance.md) is the
   whole change: no header, `README.md` line or `docs/internals/architecture.md` line restates the
   count, so two branches that both add rows cannot conflict on one. The site build
   (`scripts/mkdocs_census.py`, run by `make docs`) counts the rows into the
   `<!-- doc-counts:begin census -->` block and refuses a `🚧` row, as do `make docs-counts` and
-  `go test ./cmd/pilot-diff`. `make docs-counts` still restates the externally refereed oracle
-  numbers from the baseline JSONs; run it only when a baseline moved.
+  `go test -C tools ./referee/diff`. `make docs-counts` still restates the externally refereed oracle
+  numbers from the baseline JSONs (and the README's conformance-passing sentence); run it only
+  when a baseline or `known_failures.txt` moved.
 - **Changelog entries are fragments, not edits to `CHANGELOG.md`.** A change that a user
   should read about adds one file, `changes/unreleased/<slug>.<section>.md`, holding the list
   item(s) for that section (`added`, `changed`, `fixed`, …); see the README there. Two branches
@@ -399,7 +419,7 @@ See [ARCHITECTURE.md](docs/internals/architecture.md) for detailed design.
 
 - **Unit tests:** Per-package (`*_test.go`)
 - **Integration tests:** Cross-package scenarios
-- **Fixtures:** Real SysML v2 models in `testdata/`
+- **Fixtures:** Real SysML v2 models in `tests/testdata/`
 - **Golden files:** Expected outputs (where applicable)
 
 ## Getting Help
diff --git a/DEVELOPING.md b/DEVELOPING.md
index 12cae63c02..a3c6bb0a9c 100644
--- a/DEVELOPING.md
+++ b/DEVELOPING.md
@@ -4,7 +4,7 @@ This guide is for contributors changing the OpenSysML implementation. It is
 kept at the repository root deliberately: the public site is built from
 `docs/`, so this file is not part of the published documentation.
 
-The guide focuses on the Go implementation under `internal/core`. It explains
+The guide focuses on the Go implementation under `internal/`. It explains
 how source text becomes an abstract syntax tree (AST), how names and semantics
 are derived without mutating that tree, and how executable behavior is lowered
 and run. The LSP and REPL are covered only where they help trace a symptom back
@@ -75,7 +75,7 @@ Each main package (`cmd/sysml`, `cmd/sysml-lsp`, `cmd/sysml-grpc`) carries a
 automatically (`-pgo=auto` is the default; pass `-pgo=off` to compare against an
 unoptimized build). The three files are identical: one profile of a
 representative mix — the core test suites, the calc and REPL benchmarks, the
-gRPC service, the `internal/perfbench` harness, and the `sysml` CLI validating
+gRPC service, the `tests/perf` harness, and the `sysml` CLI validating
 every example and corpus model in the checkout.
 
 `make test` and `make coverage` pass `-pgo=off`: a coverage-instrumented
@@ -132,16 +132,16 @@ Start a change in the narrowest layer that owns the invariant:
 
 | Symptom or change | Start in |
 | --- | --- |
-| A character sequence becomes the wrong token | `internal/core/lexer` |
-| Valid syntax is rejected or the AST shape is wrong | `internal/core/parser` and `internal/core/ast` |
-| A declaration is absent from lookup | `internal/core/symbols` |
-| A reference resolves to the wrong declaration | `internal/core/resolve` |
-| A valid tree violates a language rule | `internal/core/semantics` or `internal/core/passes` |
-| Valid behavior loses guards, triggers, data flow, or structure | `internal/core/lower` |
-| Correct lowered behavior executes incorrectly | `internal/core/runtime` |
-| Open files and disk files disagree | `internal/core/model` |
-| Only an editor operation is wrong | `internal/lsp`, after checking core results |
-| Only an interactive command is wrong | `internal/repl`, after checking core results |
+| A character sequence becomes the wrong token | `internal/syntax/lexer` |
+| Valid syntax is rejected or the AST shape is wrong | `internal/syntax/parser` and `internal/syntax/ast` |
+| A declaration is absent from lookup | `internal/semantic/symbols` |
+| A reference resolves to the wrong declaration | `internal/semantic/resolve` |
+| A valid tree violates a language rule | `internal/semantic/semantics` or `internal/check/passes` |
+| Valid behavior loses guards, triggers, data flow, or structure | `internal/ir/lower` |
+| Correct lowered behavior executes incorrectly | `internal/exec/runtime` |
+| Open files and disk files disagree | `internal/workspace/model` |
+| Only an editor operation is wrong | `internal/frontend/lsp`, after checking core results |
+| Only an interactive command is wrong | `internal/frontend/repl`, after checking core results |
 
 Avoid fixing a frontend symptom by duplicating parser, resolver, semantic, or
 runtime logic in the frontend.
@@ -159,7 +159,7 @@ Several rules shape almost every implementation decision:
 - **Validation is tiered.** Do not emit downstream type or constraint noise for
   syntax or resolution failures that already block meaningful analysis.
 - **Execution consumes lowered IR.** Runtime executors must not reparse or
-  reinterpret declaration ASTs independently of `internal/core/lower`.
+  reinterpret declaration ASTs independently of `internal/ir/lower`.
 - **Failure timing is observable behavior.** Preserve whether an error is
   reported during construction, initialization, stepping, or completion.
 - **Tests are executable contracts.** Do not weaken a test or normalize away a
@@ -170,19 +170,16 @@ Several rules shape almost every implementation decision:
 The core packages have intentionally separate responsibilities:
 
 ```text
-internal/core/
-├── source/      source bytes, spans, line/column indexes, language kind
-├── lexer/       pull-based tokenization, keywords, trivia
-├── parser/      recursive-descent grammar and recovery
-├── ast/         syntax-only node types and AST dumping
-├── symbols/     declaration symbols, scopes, indexes, imports
-├── resolve/     lazy reference and endpoint resolution
-├── semantics/   reusable semantic facts and model queries
-├── passes/      ordered validation and diagnostics
-├── model/       documents, workspaces, reindexing, diagnostic caches
-├── lower/       syntax/semantic structures to execution IR
-├── runtime/     expression, action, and state execution
-└── libs/        bundled libraries and library-index construction
+internal/
+├── syntax/      source bytes and spans, lexer, parser, AST, packed files, format
+├── semantic/    symbols, scopes, name resolution, semantics, identity, highlight, query
+├── ir/          lowered execution IR, query/doc plans, views
+├── check/       ordered validation passes and diagnostics, workspace edits
+├── exec/        expression, action, and state execution; solving; analysis
+├── translate/   RDF, XMI and notation conversion, code generation, interop
+├── doc/         query execution, document IR, Markdown/HTML/PDF backends
+├── workspace/   documents, workspaces, reindexing, diagnostic caches, libraries
+└── frontend/    LSP, REPL, gRPC and stdio transports, protobuf conversion, usage
 ```
 
 The dependency direction matters. For example, a validation pass may ask the
@@ -205,7 +202,7 @@ can be rebuilt when documents change.
 
 ## Source files, spans, and language selection
 
-The source layer is in `internal/core/source`.
+The source layer is in `internal/syntax/source`.
 
 ### `SourceFile`
 
@@ -244,7 +241,7 @@ Do not assume that syntax accepted in a `.sysml` file is also legal in a
 
 ## Lexer
 
-The lexer is a handwritten, pull-based scanner in `internal/core/lexer`.
+The lexer is a handwritten, pull-based scanner in `internal/syntax/lexer`.
 `lexer.New` accepts a `SourceFile`; callers repeatedly call `Next`.
 
 ### Token model
@@ -334,8 +331,8 @@ better represented by existing tokens and contextual lookahead.
 ## Parser
 
 The parser is a handwritten recursive-descent parser in
-`internal/core/parser`. It consumes non-trivia tokens from the lexer, constructs
-nodes from `internal/core/ast`, and records syntax diagnostics.
+`internal/syntax/parser`. It consumes non-trivia tokens from the lexer, constructs
+nodes from `internal/syntax/ast`, and records syntax diagnostics.
 
 ### Entry point and parser state
 
@@ -477,7 +474,7 @@ that syntax in the AST. If the fact is derived, keep it out of the AST.
 
 ## AST
 
-AST nodes live in `internal/core/ast`. They represent syntax and source
+AST nodes live in `internal/syntax/ast`. They represent syntax and source
 structure, not resolved or inferred meaning.
 
 ### Node contract
@@ -538,7 +535,7 @@ golden diff; do not update snapshots blindly.
 
 ## Symbols, scopes, and the index
 
-`internal/core/symbols` derives declarations and lexical lookup structure from
+`internal/semantic/symbols` derives declarations and lexical lookup structure from
 the AST.
 
 ### Symbols
@@ -605,16 +602,16 @@ with `NewOverlay`, sharing the immutable library base while keeping project
 writes separate.
 
 The bundled standard library's frozen index is not built at start-up but decoded
-from `internal/core/libs/stdlib.snapshot`, a generated artifact embedded in the
-binary (`symbols.WriteSnapshot`/`ReadSnapshot` over `internal/core/pack` and
-`internal/core/ast/astcodec`). The OMG files under `internal/core/libs/stdlib/`
+from `internal/workspace/libs/stdlib.snapshot`, a generated artifact embedded in the
+binary (`symbols.WriteSnapshot`/`ReadSnapshot` over `internal/syntax/pack` and
+`internal/syntax/ast/astcodec`). The OMG files under `internal/workspace/libs/stdlib/`
 remain the source of truth: a process falls back to parsing them whenever their
 digest or the snapshot's format version differs from what the snapshot records.
 After editing a bundled library file, the snapshot's format, or anything the
 frozen index holds, regenerate and commit it:
 
 ```bash
-make stdlib-snapshot          # go generate ./internal/core/libs
+make stdlib-snapshot          # go generate ./internal/workspace/libs
 make stdlib-snapshot-check    # what CI runs; TestEmbeddedSnapshotIsCurrent fails too
 ```
 
@@ -637,7 +634,7 @@ reference starts.
 
 ## Name resolution
 
-`internal/core/resolve` resolves syntax references against a `symbols.Index`.
+`internal/semantic/resolve` resolves syntax references against a `symbols.Index`.
 Resolution is lazy and memoized.
 
 ### Resolver lifecycle
@@ -720,8 +717,8 @@ second type error whose only cause is the unresolved reference.
 ## Semantic model and validation passes
 
 Semantic analysis is split between reusable model queries in
-`internal/core/semantics` and diagnostic-producing passes in
-`internal/core/passes`.
+`internal/semantic/semantics` and diagnostic-producing passes in
+`internal/check/passes`.
 
 ### Semantic model
 
@@ -824,7 +821,7 @@ can identify more accurately.
 
 ## Workspace and incremental analysis
 
-`internal/core/model` coordinates documents, the index, and analysis for the
+`internal/workspace/model` coordinates documents, the index, and analysis for the
 frontends.
 
 ### Document construction
@@ -864,7 +861,7 @@ Replacement is the invalidation boundary.
 
 ## Lowering executable behavior
 
-`internal/core/lower` converts selected declarations into explicit
+`internal/ir/lower` converts selected declarations into explicit
 runtime-facing intermediate representations. This is where syntactic and
 resolved model structures become execution structure.
 
@@ -929,7 +926,7 @@ The complete path must preserve its meaning through lowering and execution.
 
 ## Runtime
 
-`internal/core/runtime` executes expressions, actions, states, calculations,
+`internal/exec/runtime` executes expressions, actions, states, calculations,
 constraints, requirements, and model instances.
 
 ### Runtime context
@@ -1045,7 +1042,7 @@ semantics.
 
 ### REPL
 
-`internal/repl` merges snippets into model content, reparses through the core
+`internal/frontend/repl` merges snippets into model content, reparses through the core
 workspace, displays located diagnostics, resolves query targets, and invokes
 runtime entrypoints.
 
@@ -1062,7 +1059,7 @@ resolution, conformance, and execution rules are core concerns.
 
 ### LSP
 
-`internal/lsp` synchronizes buffers with the workspace and translates core
+`internal/frontend/lsp` synchronizes buffers with the workspace and translates core
 results into LSP responses:
 
 - diagnostics come from workspace analysis;
@@ -1242,17 +1239,17 @@ Run focused tests while iterating, then the full repository checks.
 Parser changes use four complementary layers:
 
 ```bash
-go test -run TestStdlibConformance ./internal/core/libs
-go test -run TestGolden ./internal/core/parser
-go test -run TestNegative ./internal/core/parser
-go test ./internal/core/parser
+go test -run TestStdlibConformance ./internal/workspace/libs
+go test -run TestGolden ./tests/parser
+go test -run TestNegative ./tests/parser ./internal/syntax/parser
+go test ./internal/syntax/parser
 ```
 
-Golden fixtures live in `internal/core/parser/testdata/parse`. Update them only
+Golden fixtures live in `tests/parser/testdata/parse`. Update them only
 after an intentional AST change:
 
 ```bash
-go test -run TestGolden -update ./internal/core/parser
+go test -run TestGolden -update ./tests/parser
 ```
 
 Review every generated diff. A widespread snapshot change often identifies an
@@ -1266,11 +1263,11 @@ prove termination and preservation of later declarations.
 Run the directly changed package and its immediate consumers:
 
 ```bash
-go test ./internal/core/symbols
-go test ./internal/core/resolve
-go test ./internal/core/semantics
-go test ./internal/core/passes
-go test ./internal/core/model
+go test ./internal/semantic/symbols
+go test ./internal/semantic/resolve
+go test ./internal/semantic/semantics
+go test ./internal/check/passes
+go test ./internal/workspace/model
 ```
 
 Cross-file changes should include imports, aliases, visibility, insertion order,
@@ -1281,12 +1278,12 @@ and incremental replacement where relevant.
 Behavior changes use:
 
 ```bash
-go test -run TestExecutionConformance ./internal/core/runtime
-go test -run TestExecutionTrace ./internal/core/runtime
-go test -run TestRuntimeRobustness -timeout 60s ./internal/core/runtime
+go test -run TestExecutionConformance ./internal/exec/runtime
+go test -run TestExecutionTrace ./internal/exec/runtime
+go test -run TestRuntimeRobustness -timeout 60s ./internal/exec/runtime
 ```
 
-Execution fixtures live in `internal/core/runtime/testdata/conformance`:
+Execution fixtures live in `internal/exec/runtime/testdata/conformance`:
 
 ```text
 case.sysml
@@ -1301,7 +1298,7 @@ Update traces only after intentionally changing scheduling or observable
 ordering:
 
 ```bash
-go test -run TestExecutionTrace -update-traces ./internal/core/runtime
+go test -run TestExecutionTrace -update-traces ./internal/exec/runtime
 ```
 
 ### Corpora
diff --git a/Makefile b/Makefile
index 2b41677f30..e00380d377 100644
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,16 @@
-.PHONY: all build build-sysml build-lsp build-grpc static-check windows-versioninfo-check man man-check install-tree pgo-profile conformance conformance-pkg conformance-rust test coverage lint clean install help python-test python-coverage scripts-coverage node-coverage python-install proto proto-buf python-proto proto-ts proto-rust proto-lint proto-breaking vscode-grammar vscode-build vscode-package docs docs-install docs-serve docs-counts docs-check changelog-check changelog-render self-model
+.PHONY: all build build-sysml build-lsp build-grpc static-check windows-versioninfo-check man man-check install-tree pgo-profile conformance conformance-pkg conformance-rust test coverage lint clean install help fuml-expected python-test python-coverage scripts-coverage node-coverage python-install proto proto-buf python-proto proto-ts proto-rust proto-lint proto-breaking vscode-grammar vscode-build vscode-package docs docs-install docs-serve docs-counts docs-check changelog-check changelog-render self-model
 
 # Version information
-VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
+# Only release tags describe a build; the moving `nightly` tag is not a version.
+VERSION ?= $(shell git describe --tags --always --dirty --match 'v[0-9]*' 2>/dev/null || echo "dev")
 COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
 BUILD_TIME ?= $(shell date -u '+%Y-%m-%d_%H:%M:%S')
 GO_VERSION ?= $(shell go version | awk '{print $$3}')
 
 # Build flags
-LDFLAGS := -X main.Version=$(VERSION) \
+# -s -w drop the symbol table and DWARF; version stamps, build info and stack traces stay.
+LDFLAGS := -s -w \
+           -X main.Version=$(VERSION) \
            -X main.Commit=$(COMMIT) \
            -X main.BuildTime=$(BUILD_TIME) \
            -X main.GoVersion=$(GO_VERSION)
@@ -42,13 +45,13 @@ endef
 
 # Build output directory
 BIN_DIR := bin
-PYTHON_DIR := clients/python
-NODE_DIR := clients/node
+PYTHON_DIR := client/python
+NODE_DIR := client/node
 # The TypeScript protobuf plugin, installed by `npm ci` from the client's lockfile.
 PROTOC_GEN_ES := $(NODE_DIR)/node_modules/.bin/protoc-gen-es
 VSCODE_DIR := editors/vscode
 PYTHON ?= python3
-# buf.gen.python.yaml starts the interpreter this names.
+# api/proto/buf.gen.python.yaml starts the interpreter this names.
 export PYTHON
 SITE_DIR := site
 # Where make self-model writes the architecture self-model's rendered views.
@@ -56,11 +59,14 @@ SELF_MODEL_DIR := examples/self-model
 SELF_MODEL_OUT ?= build/self-model
 # Where the commands the Go tests build and run write their coverage counters.
 GO_COUNTER_DIR := $(CURDIR)/build/gocoverdir
-LIBS_DIR := internal/core/libs
+LIBS_DIR := internal/workspace/libs
+# The development tools are a nested module; go's ./... at the root stops at
+# its go.mod, so every whole-tree target runs go a second time in it.
+TOOLS_DIR := tools
 
 # The commands whose manual pages are generated and shipped, in section 1.
 COMMANDS := sysml sysml-lsp sysml-grpc
-MAN_DIR := man/man1
+MAN_DIR := packaging/man/man1
 MAN_PAGES := $(addprefix $(MAN_DIR)/,$(addsuffix .1,$(COMMANDS)))
 
 # Installation paths, as a distribution's packaging expects to set them.
@@ -134,18 +140,18 @@ pgo-profile: ## Regenerate cmd/*/default.pgo, the CPU profile go build optimizes
 conformance: ## Run the language-independent conformance suite against sysml-grpc
 	@echo "Running the conformance suite..."
 	@mkdir -p $(BIN_DIR)
-	go run ./cmd/conformance -withhold-capabilities strict_conformance,oslc_query -report $(BIN_DIR)/conformance-report.json -junit $(BIN_DIR)/conformance-report.xml
+	go run -C $(TOOLS_DIR) ./cmd/conformance -withhold-capabilities strict_conformance,oslc_query -report $(CURDIR)/$(BIN_DIR)/conformance-report.json -junit $(CURDIR)/$(BIN_DIR)/conformance-report.xml
 	@echo "✓ Conformance suite passed ($(BIN_DIR)/conformance-report.json, $(BIN_DIR)/conformance-report.xml)"
 
 conformance-rust: ## Run the conformance suite with the blocking Rust client
 	$(MAKE) build
 	@mkdir -p $(BIN_DIR)
-	OPENSYSML_GRPC_BINARY="$(CURDIR)/$(BIN_DIR)/sysml-grpc" cargo run --manifest-path clients/rust/Cargo.toml -p opensysml-conformance -- -binary "$(CURDIR)/$(BIN_DIR)/sysml-grpc" -report "$(CURDIR)/$(BIN_DIR)/conformance-report-rust.json"
+	OPENSYSML_GRPC_BINARY="$(CURDIR)/$(BIN_DIR)/sysml-grpc" cargo run --manifest-path client/rust/Cargo.toml -p opensysml-conformance -- -binary "$(CURDIR)/$(BIN_DIR)/sysml-grpc" -report "$(CURDIR)/$(BIN_DIR)/conformance-report-rust.json"
 
 conformance-pkg: ## Run the conformance suite through the public Go API (client/opensysml)
 	@echo "Running the conformance suite through client/opensysml..."
 	@mkdir -p $(BIN_DIR)
-	go run ./cmd/conformance -protocols pkg,pkg-connect -allow-skips -report $(BIN_DIR)/conformance-pkg-report.json
+	go run -C $(TOOLS_DIR) ./cmd/conformance -protocols pkg,pkg-connect -allow-skips -report $(CURDIR)/$(BIN_DIR)/conformance-pkg-report.json
 	@echo "✓ Conformance suite passed through client/opensysml ($(BIN_DIR)/conformance-pkg-report.json)"
 
 test: ## Run Go tests with race detection and coverage
@@ -153,6 +159,7 @@ test: ## Run Go tests with race detection and coverage
 	@# Per-package timeout: under -race, passes and model run within 1% of go's 10m default.
 	@# -pgo=off: coverage plus cmd/*/default.pgo trips golang/go#80891 (link: fingerprint mismatch).
 	go test -v -race -pgo=off -timeout 30m -coverprofile=coverage.txt -covermode=atomic ./...
+	go test -C $(TOOLS_DIR) -v -race -pgo=off -timeout 30m ./...
 
 coverage: ## Write the coverage profile the SonarCloud scan reads
 	@echo "Writing coverage.txt..."
@@ -162,13 +169,17 @@ coverage: ## Write the coverage profile the SonarCloud scan reads
 	@# make test above runs instead. -pgo=off as in make test.
 	@# -count=1: a replayed result carries zero blocks for the -coverpkg packages it does
 	@# not link, keyed to the sources of its own run, so they go stale as those change.
-	@# Tests that run a built command (internal/testutil/gobuild) instrument it and point
+	@# Tests that run a built command (tests/testutil/gobuild) instrument it and point
 	@# it at this directory; go test folds in only its own binary's counters.
 	rm -rf $(GO_COUNTER_DIR)
 	mkdir -p $(GO_COUNTER_DIR)
 	OPENSYSML_GOCOVERDIR=$(GO_COUNTER_DIR) go test -count=1 -pgo=off -timeout 30m -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic ./...
 	go tool covdata textfmt -i=$(GO_COUNTER_DIR) -o $(GO_COUNTER_DIR)/profile.txt
 	tail -n +2 $(GO_COUNTER_DIR)/profile.txt >> coverage.txt
+	@# The tools' tests exercise product packages too; their profile credits those.
+	go test -C $(TOOLS_DIR) -count=1 -pgo=off -timeout 30m -coverpkg=github.com/Open-MBEE/OpenSysML/... -coverprofile=../coverage-tools.txt -covermode=atomic ./...
+	tail -n +2 coverage-tools.txt >> coverage.txt
+	rm coverage-tools.txt
 	@# -coverpkg repeats every block once per test binary; see the script's header.
 	python3 scripts/dedupe-coverage.py coverage.txt
 	@go tool cover -func=coverage.txt | tail -n 1
@@ -176,23 +187,30 @@ coverage: ## Write the coverage profile the SonarCloud scan reads
 lint: ## Run static analysis (staticcheck + gosec), as CI does
 	@echo "Running staticcheck..."
 	go run honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION) ./...
+	go run -C $(TOOLS_DIR) honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION) ./...
 	@echo "Running gosec..."
 	@# Generated protobuf code is excluded: its unsafe.Pointer use (G103) comes
 	@# from protoc-gen-go and is not ours to change.
 	go run github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) -quiet -exclude-generated ./...
+	go run -C $(TOOLS_DIR) github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) -quiet -exclude-generated ./...
 	@echo "✓ Lint passed"
 
 test-short: ## Run Go tests without race detection
 	@echo "Running Go tests without race detection..."
 	go test -v ./...
+	go test -C $(TOOLS_DIR) -v ./...
 
 stdlib-snapshot: ## Regenerate the embedded snapshot of the bundled library after editing $(LIBS_DIR)/stdlib
 	go generate ./$(LIBS_DIR)
 
 stdlib-snapshot-check: ## Verify the committed library snapshot matches the bundled library, as CI does
-	go run ./$(LIBS_DIR)/gensnapshot -check -out $(LIBS_DIR)/stdlib.snapshot
+	go run -C $(TOOLS_DIR) ./gen/snapshot -check
 	@echo "✓ stdlib.snapshot is current"
 
+fuml-expected: ## Regenerate docs/project/fuml-referee-expected.json from the pinned fUML reference implementation (needs a JDK)
+	./scripts/fuml-expected.sh
+	@echo "✓ fuml-referee-expected.json regenerated"
+
 clean: ## Remove build artifacts
 	@echo "Cleaning..."
 	rm -rf $(BIN_DIR)
@@ -235,36 +253,37 @@ proto: proto-buf python-proto proto-ts proto-rust ## Regenerate all protobuf stu
 # The Java plugin is a remote one, so this needs the Buf Schema Registry.
 proto-buf: ## Regenerate the Go and Java protobuf stubs
 	@echo "Regenerating Go and Java protobuf stubs..."
-	$(BUF) generate
+	$(BUF) generate api/proto --template api/proto/buf.gen.yaml
 	@echo "✓ Regenerated Go and Java stubs"
 
 python-proto: ## Regenerate Python protobuf stubs
 	@echo "Regenerating Python protobuf stubs..."
 	@$(PYTHON) -c "import grpc_tools.protoc" >/dev/null 2>&1 || { echo "Error: grpcio-tools not installed. Run: $(PYTHON) -m pip install grpcio-tools"; exit 1; }
-	$(BUF) generate --template buf.gen.python.yaml
+	$(BUF) generate api/proto --template api/proto/buf.gen.python.yaml
 	@echo "✓ Regenerated Python stubs"
 
-proto-ts: $(PROTOC_GEN_ES) ## Regenerate the TypeScript stubs the npm client in clients/node ships
+proto-ts: $(PROTOC_GEN_ES) ## Regenerate the TypeScript stubs the npm client in client/node ships
 	@echo "Regenerating TypeScript protobuf stubs..."
-	$(BUF) generate --template buf.gen.ts.yaml
+	$(BUF) generate api/proto --template api/proto/buf.gen.ts.yaml
 	@echo "✓ Regenerated TypeScript stubs"
 
 $(PROTOC_GEN_ES): $(NODE_DIR)/package-lock.json
 	cd $(NODE_DIR) && npm ci --ignore-scripts
 
 proto-rust: ## Generate Rust stubs and the descriptor for the Rust clients
-	$(BUF) generate --template buf.gen.rust.yaml
-	$(BUF) build -o clients/rust/conformance/sysml.descriptor.binpb
+	$(BUF) generate api/proto --template api/proto/buf.gen.rust.yaml
+	$(BUF) build api/proto -o client/rust/conformance/sysml.descriptor.binpb
 
 proto-lint: ## Lint the protobuf schema
-	$(BUF) lint
+	$(BUF) lint api/proto
 	@echo "✓ Proto lint passed"
 
 proto-breaking: ## Check the protobuf schema for wire-breaking changes against develop
 	@# An archive, not the .git directory: buf would clone that, which a blobless (CI) checkout cannot serve.
+	@# The subtree as the tree-ish, not a pathspec: a pathspec walks the whole tree and lazily fetches its blobs.
 	baseline=$$(mktemp -t proto-baseline.XXXXXX) && trap 'rm -f "$$baseline"' EXIT && \
-	git archive --format=tar -o "$$baseline" '$(BUF_BREAKING_REF)' api/proto && \
-	$(BUF) breaking --against "$$baseline#format=tar,subdir=api/proto"
+	git archive --format=tar -o "$$baseline" '$(BUF_BREAKING_REF):api/proto' && \
+	$(BUF) breaking api/proto --against "$$baseline#format=tar"
 	@echo "✓ No breaking schema changes"
 
 python-install: ## Install the Python client in editable mode
@@ -286,7 +305,7 @@ python-coverage: ## Run Python client tests and write coverage-python.xml
 
 # The repository scripts the checks run, measured the same way. Each script runs
 # the way CI runs it, so the report credits what the checks execute. The release
-# scripts under clients/python/scripts are loaded by path, so their tests run here too.
+# scripts under client/python/scripts are loaded by path, so their tests run here too.
 SCRIPTS_COVERAGE := $(PYTHON) -m coverage run --append --rcfile=scripts/coverage-scripts.ini
 
 scripts-coverage: ## Run the repository scripts and their tests under coverage and write coverage-scripts.xml
@@ -295,6 +314,7 @@ scripts-coverage: ## Run the repository scripts and their tests under coverage a
 	$(SCRIPTS_COVERAGE) scripts/changelog-test.py
 	$(SCRIPTS_COVERAGE) scripts/changelog.py check
 	$(SCRIPTS_COVERAGE) scripts/mkdocs_census-test.py
+	$(SCRIPTS_COVERAGE) scripts/mkdocs_suite_figures-test.py
 	$(SCRIPTS_COVERAGE) scripts/dedupe-coverage-test.py
 	$(SCRIPTS_COVERAGE) scripts/check-doc-links.py
 	$(SCRIPTS_COVERAGE) scripts/check-doc-ids.py
@@ -313,7 +333,7 @@ node-coverage: ## Run Node client tests and write coverage-node.lcov
 	sed -e 's|^SF:|SF:$(NODE_DIR)/|' $(NODE_DIR)/coverage/lcov.info > coverage-node.lcov
 	@echo "✓ Wrote coverage-node.lcov"
 
-vscode-grammar: ## Regenerate the VS Code TextMate grammars from the lexer keywords
+vscode-grammar: ## Regenerate the VS Code TextMate grammars from the keyword lists
 	@echo "Generating TextMate grammars..."
 	go run ./$(VSCODE_DIR)/tools/gengrammar -out $(VSCODE_DIR)/syntaxes
 	@echo "✓ Grammars generated"
@@ -323,9 +343,10 @@ vscode-build: ## Type-check and bundle the VS Code extension
 	cd $(VSCODE_DIR) && npm ci && npm run typecheck && npm run build
 	@echo "✓ Built $(VSCODE_DIR)/dist/extension.js"
 
-vscode-package: ## Package the VS Code extension as a .vsix for side-loading
+vscode-package: ## Package the VS Code extension as a .vsix for side-loading (VSIX_VERSION= stamps a version other than the manifest's)
 	@echo "Packaging the VS Code extension..."
-	cd $(VSCODE_DIR) && npm ci && npm run package
+	@# `npm run package -- <args>` appends the arguments to the script's last command, `vsce package`.
+	cd $(VSCODE_DIR) && npm ci && npm run package $(if $(VSIX_VERSION),-- $(VSIX_VERSION) --no-update-package-json)
 	@echo "✓ Packaged $(VSCODE_DIR)/opensysml-sysml.vsix"
 
 self-model: build-sysml ## Render the architecture self-model's views (see examples/self-model/README.md)
@@ -339,20 +360,21 @@ self-model: build-sysml ## Render the architecture self-model's views (see examp
 	$(BIN_DIR)/sysml $(SELF_MODEL_DIR)/*.sysml -render-documents "$(SELF_MODEL_OUT)"
 	@echo "✓ Rendered the self-model's views and document into $(SELF_MODEL_OUT)/"
 
-docs-counts: ## Regenerate and verify all derived documentation counts
+docs-counts: ## Regenerate and verify the committed documentation counts; the test-suite figures are counted when the site is built
 	@echo "Regenerating the documentation count lines and refereed figures..."
-	go run ./cmd/doc-counts
-	go run ./cmd/doc-counts -check
-	go run ./cmd/validation-census -check
-	go test -count=1 ./cmd/pilot-diff ./cmd/pilot-reject ./cmd/doc-counts ./cmd/validation-census
+	go run -C $(TOOLS_DIR) ./cmd/doc-counts
+	go run -C $(TOOLS_DIR) ./cmd/doc-counts -check
+	go run -C $(TOOLS_DIR) ./cmd/validation-census -check
+	go test -C $(TOOLS_DIR) -count=1 ./census/doccounts ./census/validation ./referee/diff ./referee/reject
 	@echo "✓ Documentation counts and refereed figures are current"
 
-docs-check: ## Verify documentation links, internal-label hygiene, quoted oracle figures, changelog fragments and the build-time compliance census
+docs-check: ## Verify documentation links, internal-label hygiene, quoted oracle figures, changelog fragments and the build-time census and test-suite figures
 	$(PYTHON) scripts/check-doc-links.py
 	$(PYTHON) scripts/check-doc-ids.py
 	$(PYTHON) scripts/check-doc-figures.py
 	$(PYTHON) scripts/changelog.py check
 	$(PYTHON) scripts/mkdocs_census-test.py
+	$(PYTHON) scripts/mkdocs_suite_figures-test.py
 
 changelog-check: ## Verify every changelog fragment under changes/unreleased/ and the folding script
 	$(PYTHON) scripts/changelog-test.py
diff --git a/README.md b/README.md
index 263b5ba30b..495c1e8288 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ It is fast. The public [Apollo 11 SysML v2 model](https://github.com/airbus/apol
 validates against the full standard library in **0.43 s**. The measurement and how to repeat
 it are in [performance](docs/internals/performance.md#a-real-model-apollo-11). The same model
 is where the runtime earns its keep: the pinned OMG pilot validator passes all 28 files without
-a finding, and OpenSysML's validation reports 37 warnings and no error, yet asked to *run* the
+a finding, and OpenSysML's validation reports 4 warnings and no error, yet asked to *run* the
 model's delta-v, reliability and injection calculations, or to instantiate the mission
 individual that performs the top-level `PerformLunarMission` action, the runtime stops each one
 at a defect only execution reaches — an `e` that resolves to a valueless ISO 80000 quantity, a
@@ -261,9 +261,9 @@ SysML v2:
 - **Constraint Solving** *(experimental)* — In addition to evaluating what holds of an object, an external SMT solver determines whether a constraint, requirement or satisfaction assertion *can* hold, which conditions conflict when it cannot, which values would satisfy it, which variants a model permits, and what optimizes an `analysis def`'s objectives. The solver is optional and discovered at runtime. [The REPL command reference](docs/reference/repl-commands.md) documents each command, and [installing a solver](docs/guide/01-install.md#installing-a-solver-optional) describes how to obtain one. The design follows OpenMBEE's [HMF](https://github.com/hivecore-dev/hmf) (see [Acknowledgements](#acknowledgements)).
 - **Embeddable Go API** — `client/opensysml` is the public Go surface: parse, look up symbols, evaluate expressions and instantiate parts from Go code, answered in process by the engine the calling binary already links (no port, no child process and no serialization round trip), or over the Connect protocol against an externally hosted service. See [client/opensysml/README.md](client/opensysml/README.md).
 - **Python Client Library** — gRPC-based Python bindings for programmatic access: parse models, resolve symbols, evaluate expressions, instantiate parts, execute actions/state machines. Includes IPython display hooks for Jupyter notebooks and pandas DataFrame integration. Constraint, requirement, satisfaction and calc verdicts are available as RPCs (`verify_constraint`, `verify_requirement`, `verify_satisfaction`, `calc`).
-- **Node/TypeScript Client Library** — `@opensysml/client` for Node and the browser, over the Connect protocol with protobuf bodies: parse, evaluate, look up symbols and instantiate, with values as discriminated unions. No native addon and nothing downloaded at install time ([clients/node/README.md](clients/node/README.md)).
-- **Java Client Library** — `org.openmbee:opensysml-client` for a JVM host application it does not own, on the JDK's own `java.net.http.HttpClient`, so no gRPC, Netty or `tcnative` reaches the host ([clients/java/README.md](clients/java/README.md)).
-- **Rust Client Library** — A blocking client for the local `sysml-grpc` service, with no asynchronous runtime in its default dependency tree, available from the [Rust crate documentation](clients/rust/README.md).
+- **Node/TypeScript Client Library** — `@opensysml/client` for Node and the browser, over the Connect protocol with protobuf bodies: parse, evaluate, look up symbols and instantiate, with values as discriminated unions. No native addon and nothing downloaded at install time ([client/node/README.md](client/node/README.md)).
+- **Java Client Library** — `org.openmbee:opensysml-client` for a JVM host application it does not own, on the JDK's own `java.net.http.HttpClient`, so no gRPC, Netty or `tcnative` reaches the host ([client/java/README.md](client/java/README.md)).
+- **Rust Client Library** — A blocking client for the local `sysml-grpc` service, with no asynchronous runtime in its default dependency tree, available from the [Rust crate documentation](client/rust/README.md).
 
 Guidance on selecting a client, the coverage of the four newer clients, and the functionality they intentionally defer to a future version is provided in [docs/reference/clients.md](docs/reference/clients.md).
 - **Modern Toolchain** — Incremental compilation, a bundled standard library and persistent semantic caches. A model is a set of files, named on the command line or opened by the editor.
@@ -271,7 +271,7 @@ Guidance on selecting a client, the coverage of the four newer clients, and the
 ## Goals
 
 - **Performance:** sub-millisecond parsing, a single static binary, and no JVM or Eclipse runtime
-- **Completeness:** SysML v2 textual notation support (100 of 100 standard library files parse cleanly: 94 vendored OMG files and 6 OpenSysML extensions)
+- **Completeness:** SysML v2 textual notation support (105 of 105 standard library files parse cleanly: 94 vendored OMG files and 11 OpenSysML extensions)
 - **Executable models:** instantiate, evaluate and simulate, turning specifications into running systems
 - **Practical ergonomics:** multi-file workspaces, incremental analysis and detailed diagnostics
 
@@ -281,7 +281,7 @@ The project is under active development, with the core infrastructure operationa
 
 | Component | Status |
 |-----------|--------|
-| Lexer/Parser (structural + behavioral grammar) | ✅ Operational (100/100 stdlib clean - see [conformance gate](internal/core/libs/stdlib_conformance_test.go)) |
+| Lexer/Parser (structural + behavioral grammar) | ✅ Operational (105/105 stdlib clean - see [conformance gate](internal/workspace/libs/stdlib_conformance_test.go)) |
 | Symbol resolution & type system | ✅ Complete |
 | Semantic layer (operators, builtins, validation) | ✅ Complete |
 | Feature chain resolution (member access) | ✅ Complete |
@@ -290,10 +290,10 @@ The project is under active development, with the core infrastructure operationa
 | Expression evaluator & instance model (runtime Tiers 1-3) | ✅ Complete |
 | Runtime operators (equality, logical, negation) | ✅ Complete |
 | Workspace/reindex/file watching | ✅ Complete |
-| Behavioral parser (unified grammar with graceful fallback) | ✅ Complete (195 golden ASTs, 249 negative tests) |
-| Calc invocation, constraint & requirement evaluation | ✅ Complete (conformance gate: 185 calc/constraint/requirement/satisfy cases passing) |
-| Action execution engine (Tier 5) | ✅ Complete (129 conformance cases passing) |
-| State machine runtime (Tier 5) | ✅ Complete (104 conformance cases: transitions, accept events, sourceless) |
+| Behavioral parser (unified grammar with graceful fallback) | ✅ Complete (golden ASTs and negative tests, counted in [spec compliance](docs/project/spec-compliance.md) when the documentation site is built) |
+| Calc invocation, constraint & requirement evaluation | ✅ Complete (conformance gate over the calc/constraint/requirement/satisfy cases) |
+| Action execution engine (Tier 5) | ✅ Complete (conformance gate over the action cases) |
+| State machine runtime (Tier 5) | ✅ Complete (conformance gate over the state cases: transitions, accept events, sourceless) |
 | REPL debugging commands | ✅ Complete — `%constraint`, `%requirement`, `%satisfy` and `%calc` also answer from the command line (`-constraint`, `-requirement`, `-satisfy`, `-calc`) and over gRPC, on one evaluation |
 | Model save to notation (`%save model.sysml`, `sysml -convert sysml`) | ✅ Complete — writes the source through the formatter, so comments and spacing survive |
 | SysML ↔ RDF Turtle conversion (`%save model.ttl`, `sysml -convert ttl`) | 🧪 **Experimental** — packages, definitions, usages, ports, connections, values, documentation, and the nodes an action or state body states (every one of the 346 models under `examples/` converts and round-trips; what is not mapped is refused with the construct named), but the vocabulary may change without a compatibility path. Every run says so; see [the RDF mapping's status](docs/reference/rdf-mapping.md#status-experimental) and [worked example](examples/rdf-interop-demo.sysml) |
@@ -306,18 +306,18 @@ The project is under active development, with the core infrastructure operationa
 | gRPC service layer | ✅ Complete (parse, symbols, diagnostics, runtime, verification, conversion, edit and Query RPCs), served as gRPC, gRPC-Web and the Connect protocol on one port |
 | Public Go API (`client/opensysml`) | ✅ Complete for its v1 scope: parse, diagnostics, symbols, evaluation, instantiation and capability negotiation, answered in process or over Connect, with the edit API, conversion, verification, behaviour execution and Query out of scope ([client/opensysml/README.md](client/opensysml/README.md)) |
 | Python client library | ✅ Complete for the RPCs that exist (connection lifecycle, parse/symbols/eval/instantiate/execute, constraint/requirement/satisfaction/calc verification, conversion, edits, Query, IPython hooks, DataFrame) |
-| Rust client library | 🚧 Blocking v1 client for parse, diagnostics, symbols, evaluation and instantiation; see the [Rust client README](clients/rust/README.md) |
-| Java client library | ✅ Complete for its v1 scope, with the remaining scope stated explicitly: connection lifecycle, parse/symbols/eval/instantiate and capability negotiation, with the edit API, conversion, verification, behaviour execution and Query out of scope. Connect protocol over the JDK's own HTTP client, so no gRPC or Netty reaches a host application ([clients/java/README.md](clients/java/README.md)) |
-| Node/TypeScript client library | ✅ Complete for the same v1 scope, in Node and the browser, over the Connect protocol with protobuf bodies and no native addon; values arrive as discriminated unions ([clients/node/README.md](clients/node/README.md)) |
+| Rust client library | 🚧 Blocking v1 client for parse, diagnostics, symbols, evaluation and instantiation; see the [Rust client README](client/rust/README.md) |
+| Java client library | ✅ Connection lifecycle, parse/symbols/eval/instantiate and capability negotiation, plus typed immutable results for behaviour execution and exploration, verification and validation, calculation, analysis with engine selection, and structured and OSLC query; the edit API, multi-document parsing, conversion, sweeps and the document RPCs are stated as out of scope. Connect protocol over the JDK's own HTTP client, so no gRPC or Netty reaches a host application ([client/java/README.md](client/java/README.md)) |
+| Node/TypeScript client library | ✅ Complete for the same v1 scope, in Node and the browser, over the Connect protocol with protobuf bodies and no native addon; values arrive as discriminated unions ([client/node/README.md](client/node/README.md)) |
 
 <!-- doc-counts:begin refereed-figures -->
 **Measured against the pinned reference** (`PILOT_TAG=2026-08`, artifact `0.62.0`). Every number below is generated by `make docs-counts` from the committed baselines and gated; none of them is typed in by hand.
 
-- **Corpus agreement:** 345 of 375 files agree diagnostic-by-diagnostic; 38 diagnostics are ours alone and 1109 the reference's alone, and the first number must be read by root: our diagnostics against the reference's own corpora fell while our non-standard-notation warnings on our own example models rose ([differential](docs/project/pilot-differential.md), `go run ./cmd/pilot-diff`).
-- **Declared-diagnostic silence:** of the 512 declared `errors` rows in the reference's own Xpect suites, we report nothing for 0. 245 we report word-for-word; 248 wording-only and 7 location-only differences are agreement in substance and are not counted as gaps; 0 more we report as a warning and 2 elsewhere in the file ([Xpect oracle](docs/project/pilot-xpect.md), `go run ./cmd/pilot-xpect`).
+- **Corpus agreement:** 347 of 379 files agree diagnostic-by-diagnostic; 38 diagnostics are ours alone and 1582 the reference's alone, and the first number must be read by root: our diagnostics against the reference's own corpora fell while our non-standard-notation warnings on our own example models rose ([differential](docs/project/pilot-differential.md), `go run -C tools ./cmd/pilot-diff`).
+- **Declared-diagnostic silence:** of the 512 declared `errors` rows in the reference's own Xpect suites, we report nothing for 0. 245 we report word-for-word; 248 wording-only and 7 location-only differences are agreement in substance and are not counted as gaps; 0 more we report as a warning and 2 elsewhere in the file ([Xpect oracle](docs/project/pilot-xpect.md), `go run -C tools ./cmd/pilot-xpect`).
 - **Scope agreement:** 230 of 230 declared scope assertions match exactly (same source).
-- **Permissiveness gaps:** of 306 invalid models we wrote ourselves, the reference rejects 4 that we accept by default, and 293 both reject; 4 further cases agree only when we are asked strictly. We authored every one of these cases ourselves, so the denominator measures the reach of our own corpus and not our conformance; agreement reached only under an opt-in strict mode is weaker evidence than agreement by default ([rejection oracle](docs/project/pilot-rejection.md), `go run ./cmd/pilot-reject`).
-- **Declared errata:** the registry declares 3 defect(s) in the published reference material — 1 with a specification-derived correction, 2 documented without one, since no intended reading can be inferred ([OMG issues](docs/project/omg-issues.md), `internal/errata`). Every figure above is as published and stays the conformance statement; running the same oracles over the corrected text instead reports 346 of 375 files agreeing, 37 diagnostics ours alone and 1109 the reference's alone, 0 declared rows we are silent on, and 0 of 306 authored cases the reference alone rejects. The corrected figures are diagnostic only: an erratum never reclassifies a divergence category, and the published corpus is never edited.
+- **Permissiveness gaps:** of 306 invalid models we wrote ourselves, the reference rejects 4 that we accept by default, and 293 both reject; 4 further cases agree only when we are asked strictly. We authored every one of these cases ourselves, so the denominator measures the reach of our own corpus and not our conformance; agreement reached only under an opt-in strict mode is weaker evidence than agreement by default ([rejection oracle](docs/project/pilot-rejection.md), `go run -C tools ./cmd/pilot-reject`).
+- **Declared errata:** the registry declares 12 defect(s) in the published reference material — 4 with a specification-derived correction, 8 documented without one, since no intended reading can be inferred ([OMG issues](docs/project/omg-issues.md), `tools/oracle/errata`). Every figure above is as published and stays the conformance statement; running the same oracles over the corrected text instead reports 348 of 379 files agreeing, 37 diagnostics ours alone and 1582 the reference's alone, 0 declared rows we are silent on, and 0 of 306 authored cases the reference alone rejects. The corrected figures are diagnostic only: an erratum never reclassifies a divergence category, and the published corpus is never edited.
 - **Self-assessed surface:** the action, state-machine and classifier-behavior rows have no external referee at all — the four refereed figures above cannot see them, because the pinned artifact evaluates expressions but executes neither actions nor state machines. [Spec compliance](docs/project/spec-compliance.md) counts them.
 
 What these numbers cannot show: the OMG corpora are demonstrations rather than an official conformance suite; the differential is one-directional, comparing the diagnostics the two implementations report on the same files; the Xpect suites are the pilot authors' test intent rather than a certification oracle; and none of these is a percentage of the specification — no global compliance figure is claimed anywhere.
@@ -326,12 +326,12 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a
 <!-- doc-counts:end refereed-figures -->
 
 **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`).
-**Test coverage:** 22,637 tests and subtests (21,916 pass, 721 skip — 701 are the held-image round trip declining a conformance case that creates no instance, 3 skip themselves, 17 gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack; 7,865 top-level `Test` functions; counted with the OMG corpora downloaded and an SMT solver installed) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 204 golden ASTs, 252 negatives, 889 conformance cases, 228 golden traces, 457 runtime robustness cases, 15 gRPC conformance cases and 8 gRPC robustness cases.
-**Parser coverage:** 100/100 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml` and `OpenSysML Libraries/MOSA.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext).
-**Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (889/889 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md).
-**Reference differential:** 375 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 345 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`.
-**Rejection oracle:** the reverse direction — do we reject what the reference rejects? 306 hand-written invalid models validated by both implementations, 297 rejected by both, 0 the pinned pilot rejects and we accept; the remainder only we reject — the control-node succession rules the pinned pilot leaves unimplemented and a non-Boolean succession guard it accepts once the standard library types it — and every permissiveness gap is enumerated with a reproducer and likely root cause in [the rejection oracle](docs/project/pilot-rejection.md), reproducible with `go run ./cmd/pilot-reject`. We wrote every case, so the count measures our coverage of the rejection surface, not our conformance — a sample, not a proof.
-**Training examples:** 100/100 files clean, gated by `internal/core/model/testdata/training_examples_expected.txt`. Download with `./scripts/download-training-examples.sh` (from the [OMG training directory](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/sysml/src/training)). See [training examples](docs/project/training-examples.md) for analysis.
+**Test coverage:** top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation), behind golden ASTs, negatives, execution conformance cases, golden traces, runtime robustness cases and gRPC conformance and robustness cases. The figures are counted from the tree when the documentation site is built into the test inventory of [spec compliance](docs/project/spec-compliance.md), never committed, so a branch adding a test does not rewrite this page. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail.
+**Parser coverage:** 105/105 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml`, `OpenSysML Libraries/StateSpaceIntegration.sysml`, `OpenSysML Libraries/Stochastic.sysml`, `OpenSysML Libraries/RandomFunctions.kerml`, `OpenSysML Libraries/Simulation.sysml` and `OpenSysML Libraries/MigrationMetadata.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/workspace/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext).
+**Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (<!-- doc-counts:begin conformance-passing -->every conformance case passing<!-- doc-counts:end conformance-passing -->). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md).
+**Reference differential:** 379 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 347 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run -C tools ./cmd/pilot-diff`.
+**Rejection oracle:** the reverse direction — do we reject what the reference rejects? 306 hand-written invalid models validated by both implementations, 297 rejected by both, 0 the pinned pilot rejects and we accept; the remainder only we reject — the control-node succession rules the pinned pilot leaves unimplemented and a non-Boolean succession guard it accepts once the standard library types it — and every permissiveness gap is enumerated with a reproducer and likely root cause in [the rejection oracle](docs/project/pilot-rejection.md), reproducible with `go run -C tools ./cmd/pilot-reject`. We wrote every case, so the count measures our coverage of the rejection surface, not our conformance — a sample, not a proof.
+**Training examples:** 100/100 files clean, gated by `tests/corpus/testdata/training_examples_expected.txt`. Download with `./scripts/download-training-examples.sh` (from the [OMG training directory](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/sysml/src/training)). See [training examples](docs/project/training-examples.md) for analysis.
 **Semantic layer:** a complete implementation of runtime operators, feature chains and validation rules. See [examples/semantic-layer/](examples/semantic-layer/) for a full demonstration.
 
 ## Architecture
@@ -366,29 +366,23 @@ github.com/Open-MBEE/OpenSysML
 │   ├── sysml-lsp/          # LSP server binary
 │   ├── sysml-grpc/         # gRPC server binary (Python bindings)
 │   └── sysml/              # Interactive REPL binary
-├── internal/core/
-│   ├── source/             # Source files, spans, line indexing
-│   ├── lexer/              # Hand-written scanner
-│   ├── parser/             # Recursive-descent parser
-│   ├── ast/                # Syntax tree nodes
-│   ├── symbols/            # Symbol tables, scope trees
-│   ├── resolve/            # Name resolution (lazy, memoized)
-│   ├── semantics/          # Type system, conformance, multiplicity
-│   ├── passes/             # Validation passes (syntax → constraints)
-│   ├── lower/              # AST → execution IR (ActionGraph/StateGraph)
-│   ├── runtime/            # Execution engine (eval, instances, builtins)
-│   ├── model/              # Workspace, document management
-│   └── libs/               # Standard library bundling & caching
-├── internal/lsp/           # LSP protocol implementation
-├── internal/grpc/          # gRPC service implementation
-├── internal/repl/          # REPL loop implementation
+├── internal/               # One directory per layer; a package imports only the layers below it
+│   ├── syntax/             # source, diag, lexer, parser, ast, pack, format
+│   ├── semantic/           # symbols, resolve, suggest, semantics, identity, highlight, query
+│   ├── ir/                 # lower, queryplan, docplan, view
+│   ├── check/              # passes, edit
+│   ├── exec/               # runtime, solve, smt, analysis, engines, objref
+│   ├── translate/          # rdf, export, xmi, migrate, convert, codegen, interop
+│   ├── doc/                # queryexec, docir, docrender, docpdf
+│   ├── workspace/          # model, libs, project, envvar
+│   └── frontend/           # protoconv, grpc, lsp, repl, stdiorpc, usage
 ├── client/opensysml/       # The public Go API (in-process and remote)
-├── clients/java/           # Java client (org.openmbee:opensysml-client)
-├── clients/node/           # Node/TypeScript client (@opensysml/client)
-├── clients/python/         # Python client bindings (opensysml)
-├── clients/rust/           # Rust client (opensysml) and its conformance runner
+├── client/java/           # Java client (org.openmbee:opensysml-client)
+├── client/node/           # Node/TypeScript client (@opensysml/client)
+├── client/python/         # Python client bindings (opensysml)
+├── client/rust/           # Rust client (opensysml) and its conformance runner
 ├── docs/                   # Design specs, architecture docs
-└── testdata/               # Test fixtures (.sysml, .kerml)
+└── tests/                  # Black-box suites, benchmarks, shared fixtures (tests/parser, tests/testdata, …)
 ```
 
 ## Technology
@@ -397,7 +391,7 @@ github.com/Open-MBEE/OpenSysML
 - **Parser:** hand-written recursive descent (no framework overhead, full error recovery, sub-millisecond parses)
 - **Grammar source:** OMG pilot Xtext grammars (`SysML.xtext` and `KerMLExpressions`)
 - **Spec compliance:** [OMG SysML v2.1 Beta 1 / KerML 1.1](https://www.omg.org/spec/SysML/2.0) (2026-08 release)
-- **Standard library:** 94 files from [SysML v2 Pilot Implementation 2026-08](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/releases/tag/2026-08), byte-identical, plus the six non-normative `OpenSysML Libraries/` extensions (`OpenSysMLMathFunctions`, `DocumentQueries`, `IdentityMetadata`, `DiagramLayout`, [`OOSEM`](docs/project/oosem-library.md) and [`MOSA`](docs/project/mosa-library.md))
+- **Standard library:** 94 files from [SysML v2 Pilot Implementation 2026-08](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/releases/tag/2026-08), byte-identical, plus the eleven non-normative `OpenSysML Libraries/` extensions (`OpenSysMLMathFunctions`, `DocumentQueries`, `IdentityMetadata`, `DiagramLayout`, [`OOSEM`](docs/project/oosem-library.md), [`MOSA`](docs/project/mosa-library.md), `StateSpaceIntegration`, `Stochastic`, `RandomFunctions`, `Simulation` and `MigrationMetadata`)
 - **CI/CD:** GitHub Actions checks pull requests; CircleCI builds and tests `main` and `develop` and publishes releases from tags
 
 ## Releases
@@ -415,16 +409,17 @@ Pre-built binaries for Linux, macOS, and Windows are available on the [Releases
   binaries are published to GitHub Releases. Maintainer procedure:
   [docs/project/releasing.md](docs/project/releasing.md); what changed per release:
   [CHANGELOG.md](CHANGELOG.md)
-- The Python client is released on its own tag (`opensysml-v*`), which uploads `opensysml` to
-  PyPI — its version is not coupled to the core's, since it resolves a `sysml-grpc` binary
-  at runtime from whichever release the caller names
-- The Java client is not yet published: consume it with `mvn -f clients/java/pom.xml install`. The
+- The Python client is released by the same `v*` tag, which uploads `opensysml` to PyPI at
+  the core's version — `v0.9.0` publishes `opensysml` 0.9.0 — so pinning one version
+  (`pip install opensysml==0.9.0`, `OPENSYSML_GRPC_VERSION=v0.9.0`) gets the package and
+  the `sysml-grpc` binary that were tested together
+- The Java client is not yet published: consume it with `mvn -f client/java/pom.xml install`. The
   prerequisites a maintainer must obtain for a first Maven Central upload are listed in
   [docs/project/releasing.md](docs/project/releasing.md)
 - The Node client is released the same way on `client-node-v*`, which publishes
   `@opensysml/client` and the five per-platform packages that carry the service binary
 - The Rust client is not yet published to crates.io: use a path or Git dependency, and see
-  [clients/rust/README.md](clients/rust/README.md) and
+  [client/rust/README.md](client/rust/README.md) and
   [docs/project/releasing.md](docs/project/releasing.md) for the requirements of a first publish
 - `client/opensysml`, the public Go API, requires no release of its own. It is part of this
   module, so a Go program pins it with `go get github.com/Open-MBEE/OpenSysML@v0.3.0`
@@ -486,8 +481,10 @@ In particular, none of `sysml`, `sysml-lsp` and `sysml-grpc` checks for updates,
 telemetry or downloads anything on its own. The only network activity any of them performs is
 what the operator asks for by name: `sysml-lsp` speaks only over its standard input and output,
 `sysml-grpc` serves the address it is started with and answers only the clients that connect to
-it, and `sysml -sync-diff` / `sysml -sync-apply` contact a SysML v2 API / Flexo MMS repository
-only when the operator names that endpoint's `http://` or `https://` URL on the command line.
+it, `sysml -sync-diff` / `sysml -sync-apply` contact a SysML v2 API / Flexo MMS repository
+only when the operator names that endpoint's `http://` or `https://` URL on the command line,
+and `sysml -convert` reads or pushes a Flexo MMS project branch only when the operator names
+its branch URL (`http(s)://…/projects/{p}/branches/{b}` or `flexo://{p}/{b}`) there.
 The OMG pilot corpora and reference tools used by the test suite are fetched by developer
 scripts under `scripts/`, which are not part of the shipped binaries.
 
@@ -517,8 +514,8 @@ so a CI system can render them without parsing text:
 
 ```bash
 make conformance      # bin/conformance-report.json + bin/conformance-report.xml
-go run ./cmd/conformance -report - -junit bin/conformance-report.xml
-go run ./cmd/pilot-diff   # build/pilot-diff/pilot-diff.{txt,json,xml,sarif}
+go run -C tools ./cmd/conformance -report - -junit bin/conformance-report.xml
+go run -C tools ./cmd/pilot-diff   # build/pilot-diff/pilot-diff.{txt,json,xml,sarif}
 ```
 
 - **JSON** stays the source of truth for both: aggregate totals, and per scenario or per file every
@@ -542,7 +539,7 @@ how to choose; [guide chapter 9](docs/guide/09-clients.md) works through each on
 | Surface | Reaches the engine by | Published | API reference |
 |---|---|---|---|
 | Go, `client/opensysml` | in process, or Connect to a service | with the core (`v*` tags) | [Go packages](docs/reference/api.md) |
-| Python, `opensysml` | gRPC, to a private child service or a named one | PyPI, on `opensysml-v*` tags | [Python API](docs/reference/python-api.md) |
+| Python, `opensysml` | gRPC, to a private child service or a named one | PyPI, on the core `v*` tags, at the core's version | [Python API](docs/reference/python-api.md) |
 | Node/TypeScript, `@opensysml/client` | Connect, from Node or a browser page | not yet | [Node API](docs/reference/node-api.md) |
 | Java, `org.openmbee:opensysml-client` | Connect, over the JDK's own HTTP client | not yet | [Java API](docs/reference/java-api.md) |
 | Rust, `opensysml` | Connect, blocking, no async runtime | not yet | [Rust API](docs/reference/rust-api.md) |
@@ -560,7 +557,7 @@ evaluation and instantiation — enumerated in the client libraries page.
 pip install opensysml          # from PyPI
 
 # Or from a checkout, in development mode
-pip install -e clients/python/
+pip install -e client/python/
 ```
 
 **Quick example:**
@@ -586,7 +583,7 @@ print(instance.slots["mass"])
 - full runtime API access (evaluation, instantiation, action and state execution)
 
 Detailed installation and usage instructions are in
-[clients/python/INSTALL.md](clients/python/INSTALL.md), and the API in
+[client/python/INSTALL.md](client/python/INSTALL.md), and the API in
 [docs/reference/python-api.md](docs/reference/python-api.md).
 
 ### Node/TypeScript
@@ -604,7 +601,7 @@ const radius = await model.eval("0.3 * 2");
 
 Version 1 covers loading, evaluation, symbol lookup and instantiation, and negotiates against the
 capabilities the service advertises. It is not yet published. See the
-[Node API](docs/reference/node-api.md) and [clients/node/README.md](clients/node/README.md) for the
+[Node API](docs/reference/node-api.md) and [client/node/README.md](client/node/README.md) for the
 two lifecycle modes (a private child of the calling process, or an externally hosted service), the
 capabilities and limitations of the browser entry point, and the functionality version 1 omits.
 
@@ -620,9 +617,9 @@ mass, err := client.Evaluate(ctx, model, "mass", opensysml.WithSubject("Demo::se
 The Go API is documented type by type in [Go packages](docs/reference/api.md) and
 [client/opensysml/README.md](client/opensysml/README.md). The Java client is a `try`-with-resources
 `Connection` over the JDK's HTTP client ([Java API](docs/reference/java-api.md),
-[clients/java/README.md](clients/java/README.md)); the Rust client is blocking, with no async
+[client/java/README.md](client/java/README.md)); the Rust client is blocking, with no async
 runtime in its default dependency tree ([Rust API](docs/reference/rust-api.md),
-[clients/rust/README.md](clients/rust/README.md)). Neither is published yet.
+[client/rust/README.md](client/rust/README.md)). Neither is published yet.
 
 ## Documentation
 
@@ -645,7 +642,7 @@ The constraint-solving capability set — satisfiability checking, conflict expl
 through unsat cores, value synthesis and objective optimization through an SMT solver —
 follows the design of the `ConstraintSolverService` in OpenMBEE's
 [HMF (Hivecore Model Framework)](https://github.com/hivecore-dev/hmf) (Apache 2.0). The
-implementation in `internal/core/solve` is independent: it translates conditions to
+implementation in `internal/exec/solve` is independent: it translates conditions to
 SMT-LIB 2 for an external `z3`/`cvc5` process rather than binding to Z3 in-process.
 
 ## Contributing
diff --git a/buf.gen.python.yaml b/api/proto/buf.gen.python.yaml
similarity index 82%
rename from buf.gen.python.yaml
rename to api/proto/buf.gen.python.yaml
index d1a144fe9c..6a20d7d834 100644
--- a/buf.gen.python.yaml
+++ b/api/proto/buf.gen.python.yaml
@@ -5,10 +5,10 @@ version: v2
 plugins:
   - local: ["sh", "-c", 'exec "${PYTHON:-python3}" scripts/buf-plugin-python.py']
     opt: python
-    out: clients/python/opensysml/proto
+    out: client/python/opensysml/proto
   - local: ["sh", "-c", 'exec "${PYTHON:-python3}" scripts/buf-plugin-python.py']
     opt: pyi
-    out: clients/python/opensysml/proto
+    out: client/python/opensysml/proto
   - local: ["sh", "-c", 'exec "${PYTHON:-python3}" scripts/buf-plugin-python.py']
     opt: grpc_python
-    out: clients/python/opensysml/proto
+    out: client/python/opensysml/proto
diff --git a/api/proto/buf.gen.rust.yaml b/api/proto/buf.gen.rust.yaml
new file mode 100644
index 0000000000..b3c0d5d603
--- /dev/null
+++ b/api/proto/buf.gen.rust.yaml
@@ -0,0 +1,4 @@
+version: v2
+plugins:
+  - remote: buf.build/community/neoeinstein-prost:v0.5.0
+    out: client/rust/opensysml/src/proto
diff --git a/api/proto/buf.gen.ts.yaml b/api/proto/buf.gen.ts.yaml
new file mode 100644
index 0000000000..0f03ed1831
--- /dev/null
+++ b/api/proto/buf.gen.ts.yaml
@@ -0,0 +1,9 @@
+version: v2
+# TypeScript stubs for the npm client in client/node. Committed, like the Python
+# stubs, so `npm install` needs neither buf nor network. Run via `make proto-ts`
+# (which `make proto` includes). The plugin is the client's lockfile-pinned
+# devDependency, so it always matches the @bufbuild/protobuf runtime.
+plugins:
+  - local: client/node/node_modules/.bin/protoc-gen-es
+    out: client/node/src/generated
+    opt: target=ts,import_extension=.js
diff --git a/buf.gen.yaml b/api/proto/buf.gen.yaml
similarity index 96%
rename from buf.gen.yaml
rename to api/proto/buf.gen.yaml
index 465f75e480..d695065ced 100644
--- a/buf.gen.yaml
+++ b/api/proto/buf.gen.yaml
@@ -33,4 +33,4 @@ plugins:
   # Java message classes only: the client speaks the Connect protocol over java.net.http, so
   # grpc-java (and Netty) is not a dependency of it. The remote plugin needs network access.
   - remote: buf.build/protocolbuffers/java:v33.1
-    out: clients/java/opensysml-client/src/main/java
+    out: client/java/opensysml-client/src/main/java
diff --git a/api/proto/buf.yaml b/api/proto/buf.yaml
new file mode 100644
index 0000000000..b477cc7ebf
--- /dev/null
+++ b/api/proto/buf.yaml
@@ -0,0 +1,16 @@
+version: v2
+modules:
+  - path: .
+lint:
+  use:
+    - STANDARD
+  # The schema is frozen for wire compatibility (see client/python/tests/test_wire_compat.py),
+  # so the rules below cannot be satisfied without breaking released clients.
+  except:
+    - PACKAGE_DIRECTORY_MATCH
+    - PACKAGE_VERSION_SUFFIX
+    - RPC_REQUEST_STANDARD_NAME
+    - RPC_RESPONSE_STANDARD_NAME
+breaking:
+  use:
+    - FILE
diff --git a/api/proto/protoconnect/sysml.connect.go b/api/proto/protoconnect/sysml.connect.go
index 7e9624b9a4..b3e1cb73be 100644
--- a/api/proto/protoconnect/sysml.connect.go
+++ b/api/proto/protoconnect/sysml.connect.go
@@ -70,6 +70,9 @@ const (
 	// SysMLServiceVerifySatisfactionProcedure is the fully-qualified name of the SysMLService's
 	// VerifySatisfaction RPC.
 	SysMLServiceVerifySatisfactionProcedure = "/sysml.SysMLService/VerifySatisfaction"
+	// SysMLServiceValidateInstanceProcedure is the fully-qualified name of the SysMLService's
+	// ValidateInstance RPC.
+	SysMLServiceValidateInstanceProcedure = "/sysml.SysMLService/ValidateInstance"
 	// SysMLServiceEvaluateCalcProcedure is the fully-qualified name of the SysMLService's EvaluateCalc
 	// RPC.
 	SysMLServiceEvaluateCalcProcedure = "/sysml.SysMLService/EvaluateCalc"
@@ -130,6 +133,13 @@ type SysMLServiceClient interface {
 	VerifyConstraint(context.Context, *connect.Request[proto.VerifyConstraintRequest]) (*connect.Response[proto.VerifyConstraintResponse], error)
 	VerifyRequirement(context.Context, *connect.Request[proto.VerifyRequirementRequest]) (*connect.Response[proto.VerifyRequirementResponse], error)
 	VerifySatisfaction(context.Context, *connect.Request[proto.VerifySatisfactionRequest]) (*connect.Response[proto.VerifySatisfactionResponse], error)
+	// Validate an object as a whole, as the REPL's %validate and the CLI's
+	// -validate=<object> do: every assertion about an object of the named part
+	// and about the objects it holds — the asserted constraints of their types,
+	// the requirements they carry and the satisfactions they are the subject of —
+	// each answered on the concrete object it is about. Reported as the
+	// "verification" capability.
+	ValidateInstance(context.Context, *connect.Request[proto.ValidateInstanceRequest]) (*connect.Response[proto.ValidateInstanceResponse], error)
 	EvaluateCalc(context.Context, *connect.Request[proto.EvaluateCalcRequest]) (*connect.Response[proto.EvaluateCalcResponse], error)
 	RunAnalysis(context.Context, *connect.Request[proto.RunAnalysisRequest]) (*connect.Response[proto.RunAnalysisResponse], error)
 	// Run one analysis case or calc once per row of a parameter sweep, as the
@@ -254,6 +264,12 @@ func NewSysMLServiceClient(httpClient connect.HTTPClient, baseURL string, opts .
 			connect.WithSchema(sysMLServiceMethods.ByName("VerifySatisfaction")),
 			connect.WithClientOptions(opts...),
 		),
+		validateInstance: connect.NewClient[proto.ValidateInstanceRequest, proto.ValidateInstanceResponse](
+			httpClient,
+			baseURL+SysMLServiceValidateInstanceProcedure,
+			connect.WithSchema(sysMLServiceMethods.ByName("ValidateInstance")),
+			connect.WithClientOptions(opts...),
+		),
 		evaluateCalc: connect.NewClient[proto.EvaluateCalcRequest, proto.EvaluateCalcResponse](
 			httpClient,
 			baseURL+SysMLServiceEvaluateCalcProcedure,
@@ -315,6 +331,7 @@ type sysMLServiceClient struct {
 	verifyConstraint   *connect.Client[proto.VerifyConstraintRequest, proto.VerifyConstraintResponse]
 	verifyRequirement  *connect.Client[proto.VerifyRequirementRequest, proto.VerifyRequirementResponse]
 	verifySatisfaction *connect.Client[proto.VerifySatisfactionRequest, proto.VerifySatisfactionResponse]
+	validateInstance   *connect.Client[proto.ValidateInstanceRequest, proto.ValidateInstanceResponse]
 	evaluateCalc       *connect.Client[proto.EvaluateCalcRequest, proto.EvaluateCalcResponse]
 	runAnalysis        *connect.Client[proto.RunAnalysisRequest, proto.RunAnalysisResponse]
 	runSweep           *connect.Client[proto.RunSweepRequest, proto.RunSweepResponse]
@@ -394,6 +411,11 @@ func (c *sysMLServiceClient) VerifySatisfaction(ctx context.Context, req *connec
 	return c.verifySatisfaction.CallUnary(ctx, req)
 }
 
+// ValidateInstance calls sysml.SysMLService.ValidateInstance.
+func (c *sysMLServiceClient) ValidateInstance(ctx context.Context, req *connect.Request[proto.ValidateInstanceRequest]) (*connect.Response[proto.ValidateInstanceResponse], error) {
+	return c.validateInstance.CallUnary(ctx, req)
+}
+
 // EvaluateCalc calls sysml.SysMLService.EvaluateCalc.
 func (c *sysMLServiceClient) EvaluateCalc(ctx context.Context, req *connect.Request[proto.EvaluateCalcRequest]) (*connect.Response[proto.EvaluateCalcResponse], error) {
 	return c.evaluateCalc.CallUnary(ctx, req)
@@ -468,6 +490,13 @@ type SysMLServiceHandler interface {
 	VerifyConstraint(context.Context, *connect.Request[proto.VerifyConstraintRequest]) (*connect.Response[proto.VerifyConstraintResponse], error)
 	VerifyRequirement(context.Context, *connect.Request[proto.VerifyRequirementRequest]) (*connect.Response[proto.VerifyRequirementResponse], error)
 	VerifySatisfaction(context.Context, *connect.Request[proto.VerifySatisfactionRequest]) (*connect.Response[proto.VerifySatisfactionResponse], error)
+	// Validate an object as a whole, as the REPL's %validate and the CLI's
+	// -validate=<object> do: every assertion about an object of the named part
+	// and about the objects it holds — the asserted constraints of their types,
+	// the requirements they carry and the satisfactions they are the subject of —
+	// each answered on the concrete object it is about. Reported as the
+	// "verification" capability.
+	ValidateInstance(context.Context, *connect.Request[proto.ValidateInstanceRequest]) (*connect.Response[proto.ValidateInstanceResponse], error)
 	EvaluateCalc(context.Context, *connect.Request[proto.EvaluateCalcRequest]) (*connect.Response[proto.EvaluateCalcResponse], error)
 	RunAnalysis(context.Context, *connect.Request[proto.RunAnalysisRequest]) (*connect.Response[proto.RunAnalysisResponse], error)
 	// Run one analysis case or calc once per row of a parameter sweep, as the
@@ -588,6 +617,12 @@ func NewSysMLServiceHandler(svc SysMLServiceHandler, opts ...connect.HandlerOpti
 		connect.WithSchema(sysMLServiceMethods.ByName("VerifySatisfaction")),
 		connect.WithHandlerOptions(opts...),
 	)
+	sysMLServiceValidateInstanceHandler := connect.NewUnaryHandler(
+		SysMLServiceValidateInstanceProcedure,
+		svc.ValidateInstance,
+		connect.WithSchema(sysMLServiceMethods.ByName("ValidateInstance")),
+		connect.WithHandlerOptions(opts...),
+	)
 	sysMLServiceEvaluateCalcHandler := connect.NewUnaryHandler(
 		SysMLServiceEvaluateCalcProcedure,
 		svc.EvaluateCalc,
@@ -660,6 +695,8 @@ func NewSysMLServiceHandler(svc SysMLServiceHandler, opts ...connect.HandlerOpti
 			sysMLServiceVerifyRequirementHandler.ServeHTTP(w, r)
 		case SysMLServiceVerifySatisfactionProcedure:
 			sysMLServiceVerifySatisfactionHandler.ServeHTTP(w, r)
+		case SysMLServiceValidateInstanceProcedure:
+			sysMLServiceValidateInstanceHandler.ServeHTTP(w, r)
 		case SysMLServiceEvaluateCalcProcedure:
 			sysMLServiceEvaluateCalcHandler.ServeHTTP(w, r)
 		case SysMLServiceRunAnalysisProcedure:
@@ -739,6 +776,10 @@ func (UnimplementedSysMLServiceHandler) VerifySatisfaction(context.Context, *con
 	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("sysml.SysMLService.VerifySatisfaction is not implemented"))
 }
 
+func (UnimplementedSysMLServiceHandler) ValidateInstance(context.Context, *connect.Request[proto.ValidateInstanceRequest]) (*connect.Response[proto.ValidateInstanceResponse], error) {
+	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("sysml.SysMLService.ValidateInstance is not implemented"))
+}
+
 func (UnimplementedSysMLServiceHandler) EvaluateCalc(context.Context, *connect.Request[proto.EvaluateCalcRequest]) (*connect.Response[proto.EvaluateCalcResponse], error) {
 	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("sysml.SysMLService.EvaluateCalc is not implemented"))
 }
diff --git a/api/proto/sysml.pb.go b/api/proto/sysml.pb.go
index fc59a762ff..bcb9d3fe35 100644
--- a/api/proto/sysml.pb.go
+++ b/api/proto/sysml.pb.go
@@ -98,6 +98,10 @@ const (
 	EditFailure_EDIT_FAILURE_DELETE_REFERENCED   EditFailure = 15 // delete would leave references
 	EditFailure_EDIT_FAILURE_OWNER_INSIDE_TARGET EditFailure = 16 // move owner is the target or inside it
 	EditFailure_EDIT_FAILURE_MOVE_REFERENCED     EditFailure = 17 // move would leave a reference no spelling restores
+	// the target is referred to from a document the edit cannot rewrite: a
+	// library document, or any other document for a move, which respells
+	// references in the target's own document only
+	EditFailure_EDIT_FAILURE_REFERENCED_ELSEWHERE EditFailure = 18
 )
 
 // Enum value maps for EditFailure.
@@ -121,26 +125,28 @@ var (
 		15: "EDIT_FAILURE_DELETE_REFERENCED",
 		16: "EDIT_FAILURE_OWNER_INSIDE_TARGET",
 		17: "EDIT_FAILURE_MOVE_REFERENCED",
+		18: "EDIT_FAILURE_REFERENCED_ELSEWHERE",
 	}
 	EditFailure_value = map[string]int32{
-		"EDIT_FAILURE_UNSPECIFIED":         0,
-		"EDIT_FAILURE_NO_OPERATIONS":       1,
-		"EDIT_FAILURE_UNKNOWN_TARGET":      2,
-		"EDIT_FAILURE_AMBIGUOUS_TARGET":    3,
-		"EDIT_FAILURE_NOT_VALUED":          4,
-		"EDIT_FAILURE_INVALID_VALUE":       5,
-		"EDIT_FAILURE_INVALID_NAME":        6,
-		"EDIT_FAILURE_NOT_NAMED":           7,
-		"EDIT_FAILURE_RENAME_REFERENCED":   8,
-		"EDIT_FAILURE_OVERLAPPING_EDITS":   9,
-		"EDIT_FAILURE_RESULT_INVALID":      10,
-		"EDIT_FAILURE_OWNER_UNKNOWN":       11,
-		"EDIT_FAILURE_OWNER_NOT_NAMESPACE": 12,
-		"EDIT_FAILURE_ILLEGAL_KIND":        13,
-		"EDIT_FAILURE_MEMBER_NAME_TAKEN":   14,
-		"EDIT_FAILURE_DELETE_REFERENCED":   15,
-		"EDIT_FAILURE_OWNER_INSIDE_TARGET": 16,
-		"EDIT_FAILURE_MOVE_REFERENCED":     17,
+		"EDIT_FAILURE_UNSPECIFIED":          0,
+		"EDIT_FAILURE_NO_OPERATIONS":        1,
+		"EDIT_FAILURE_UNKNOWN_TARGET":       2,
+		"EDIT_FAILURE_AMBIGUOUS_TARGET":     3,
+		"EDIT_FAILURE_NOT_VALUED":           4,
+		"EDIT_FAILURE_INVALID_VALUE":        5,
+		"EDIT_FAILURE_INVALID_NAME":         6,
+		"EDIT_FAILURE_NOT_NAMED":            7,
+		"EDIT_FAILURE_RENAME_REFERENCED":    8,
+		"EDIT_FAILURE_OVERLAPPING_EDITS":    9,
+		"EDIT_FAILURE_RESULT_INVALID":       10,
+		"EDIT_FAILURE_OWNER_UNKNOWN":        11,
+		"EDIT_FAILURE_OWNER_NOT_NAMESPACE":  12,
+		"EDIT_FAILURE_ILLEGAL_KIND":         13,
+		"EDIT_FAILURE_MEMBER_NAME_TAKEN":    14,
+		"EDIT_FAILURE_DELETE_REFERENCED":    15,
+		"EDIT_FAILURE_OWNER_INSIDE_TARGET":  16,
+		"EDIT_FAILURE_MOVE_REFERENCED":      17,
+		"EDIT_FAILURE_REFERENCED_ELSEWHERE": 18,
 	}
 )
 
@@ -279,7 +285,8 @@ func (CompositeOperator) EnumDescriptor() ([]byte, []int) {
 type Verdict struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// What was verified: "constraint", "requirement" or "satisfy"; for a check
-	// an analysis case run made, "objective" or "assertion".
+	// an analysis case run made, "objective" or "assertion"; for the summary
+	// of a ValidateInstance, "object".
 	Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"`
 	// FQN of the element verified; empty for an anonymous satisfy assertion or
 	// an anonymous assertion in a case body.
@@ -321,7 +328,12 @@ type Verdict struct {
 	// engine decided it.
 	Strength string `protobuf:"bytes,12,opt,name=strength,proto3" json:"strength,omitempty"`
 	// The bounds the engine ran under, each marked when it stopped the run.
-	Bounds        []*Bound `protobuf:"bytes,13,rep,name=bounds,proto3" json:"bounds,omitempty"`
+	Bounds []*Bound `protobuf:"bytes,13,rep,name=bounds,proto3" json:"bounds,omitempty"`
+	// For a verdict ValidateInstance reports: the path from the validated object
+	// to the object this verdict is about, as the REPL spells it ("engine",
+	// "wheels[2]", "engine.injector"). Empty for the validated object itself,
+	// and for every other RPC.
+	InstancePath  string `protobuf:"bytes,14,opt,name=instance_path,json=instancePath,proto3" json:"instance_path,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
@@ -447,6 +459,13 @@ func (x *Verdict) GetBounds() []*Bound {
 	return nil
 }
 
+func (x *Verdict) GetInstancePath() string {
+	if x != nil {
+		return x.InstancePath
+	}
+	return ""
+}
+
 // Bound is one limit an engine ran under, as its plan names it: a count, or
 // milliseconds for a time, and whether the run stopped at it.
 type Bound struct {
@@ -523,7 +542,10 @@ type VerifyConstraintRequest struct {
 	// Optional FQN of a part/usage to instantiate and evaluate the constraint
 	// against, so the verdict is about concrete values rather than declared
 	// defaults. The prompt evaluates against the object a %instantiate created;
-	// a call carries no session, so it names the subject instead.
+	// a call carries no session, so it names the subject instead. A path from a
+	// declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+	// evaluates against the object the path reaches, inside the assembly built
+	// around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
 	SubjectSymbolId string `protobuf:"bytes,3,opt,name=subject_symbol_id,json=subjectSymbolId,proto3" json:"subject_symbol_id,omitempty"`
 	// The engine the question is put to, as ListEngines names it, "auto" for the
 	// strongest covering one, or "all" for every covering one. Unset is "auto".
@@ -672,7 +694,8 @@ type VerifyRequirementRequest struct {
 	ModelHash string                 `protobuf:"bytes,1,opt,name=model_hash,json=modelHash,proto3" json:"model_hash,omitempty"`
 	// FQN of the requirement definition or usage to evaluate.
 	SymbolId string `protobuf:"bytes,2,opt,name=symbol_id,json=symbolId,proto3" json:"symbol_id,omitempty"`
-	// Optional FQN of a part/usage to instantiate and evaluate against.
+	// Optional FQN of a part/usage to instantiate and evaluate against, or a path
+	// from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
 	SubjectSymbolId string `protobuf:"bytes,3,opt,name=subject_symbol_id,json=subjectSymbolId,proto3" json:"subject_symbol_id,omitempty"`
 	// The engine the question is put to; see VerifyConstraintRequest.engine.
 	Engine        string `protobuf:"bytes,4,opt,name=engine,proto3" json:"engine,omitempty"`
@@ -1063,6 +1086,188 @@ func (x *VerifySatisfactionResponse) GetVerificationVerdicts() []*VerificationVe
 	return nil
 }
 
+// ValidateInstanceRequest asks for every assertion about an object of a part,
+// as %validate does: the object is built for the call.
+type ValidateInstanceRequest struct {
+	state     protoimpl.MessageState `protogen:"open.v1"`
+	ModelHash string                 `protobuf:"bytes,1,opt,name=model_hash,json=modelHash,proto3" json:"model_hash,omitempty"`
+	// FQN of the part or usage an object of which is validated.
+	SymbolId string `protobuf:"bytes,2,opt,name=symbol_id,json=symbolId,proto3" json:"symbol_id,omitempty"`
+	// The engine the validation is put to; see VerifyConstraintRequest.engine.
+	Engine        string `protobuf:"bytes,3,opt,name=engine,proto3" json:"engine,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *ValidateInstanceRequest) Reset() {
+	*x = ValidateInstanceRequest{}
+	mi := &file_sysml_proto_msgTypes[9]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *ValidateInstanceRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ValidateInstanceRequest) ProtoMessage() {}
+
+func (x *ValidateInstanceRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_sysml_proto_msgTypes[9]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ValidateInstanceRequest.ProtoReflect.Descriptor instead.
+func (*ValidateInstanceRequest) Descriptor() ([]byte, []int) {
+	return file_sysml_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *ValidateInstanceRequest) GetModelHash() string {
+	if x != nil {
+		return x.ModelHash
+	}
+	return ""
+}
+
+func (x *ValidateInstanceRequest) GetSymbolId() string {
+	if x != nil {
+		return x.SymbolId
+	}
+	return ""
+}
+
+func (x *ValidateInstanceRequest) GetEngine() string {
+	if x != nil {
+		return x.Engine
+	}
+	return ""
+}
+
+// ValidateInstanceResponse carries one verdict per assertion, in the order the
+// objects were reached from the validated one, and the verdict about the object
+// as a whole. An object no assertion is about has an empty list and a summary
+// that decides nothing.
+type ValidateInstanceResponse struct {
+	state protoimpl.MessageState `protogen:"open.v1"`
+	// One per assertion: kind "constraint", "requirement" or "satisfy", about the
+	// object `instance_id` names, reached along `instance_path`.
+	Verdicts []*Verdict `protobuf:"bytes,1,rep,name=verdicts,proto3" json:"verdicts,omitempty"`
+	// The object as a whole, kind "object": holds when every assertion holds and
+	// every held object was reached. With `error` empty, false is the model's
+	// answer, some assertion failing; with `error` set, nothing was decided, an
+	// assertion being undecided or nesting left unreached.
+	Summary *Verdict `protobuf:"bytes,2,opt,name=summary,proto3" json:"summary,omitempty"`
+	// Every object reached, the validated one first, so a client can read the
+	// feature values behind each verdict.
+	Instances   []*Instance   `protobuf:"bytes,3,rep,name=instances,proto3" json:"instances,omitempty"`
+	Error       string        `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"`
+	Diagnostics []*Diagnostic `protobuf:"bytes,5,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"`
+	// What kind of failure `error` reports.
+	FailureReason FailureReason `protobuf:"varint,6,opt,name=failure_reason,json=failureReason,proto3,enum=sysml.FailureReason" json:"failure_reason,omitempty"`
+	// What the body of every verification case verifying a requirement a verdict
+	// is about answered, once per requirement; each names its requirement, which
+	// the requirement and satisfy verdicts carry as their requirement_id.
+	VerificationVerdicts []*VerificationVerdict `protobuf:"bytes,7,rep,name=verification_verdicts,json=verificationVerdicts,proto3" json:"verification_verdicts,omitempty"`
+	// True when nesting deeper than the validation descends, or past its budget,
+	// was left unvalidated; the summary then decides nothing.
+	Bounded       bool `protobuf:"varint,8,opt,name=bounded,proto3" json:"bounded,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *ValidateInstanceResponse) Reset() {
+	*x = ValidateInstanceResponse{}
+	mi := &file_sysml_proto_msgTypes[10]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *ValidateInstanceResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ValidateInstanceResponse) ProtoMessage() {}
+
+func (x *ValidateInstanceResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_sysml_proto_msgTypes[10]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ValidateInstanceResponse.ProtoReflect.Descriptor instead.
+func (*ValidateInstanceResponse) Descriptor() ([]byte, []int) {
+	return file_sysml_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *ValidateInstanceResponse) GetVerdicts() []*Verdict {
+	if x != nil {
+		return x.Verdicts
+	}
+	return nil
+}
+
+func (x *ValidateInstanceResponse) GetSummary() *Verdict {
+	if x != nil {
+		return x.Summary
+	}
+	return nil
+}
+
+func (x *ValidateInstanceResponse) GetInstances() []*Instance {
+	if x != nil {
+		return x.Instances
+	}
+	return nil
+}
+
+func (x *ValidateInstanceResponse) GetError() string {
+	if x != nil {
+		return x.Error
+	}
+	return ""
+}
+
+func (x *ValidateInstanceResponse) GetDiagnostics() []*Diagnostic {
+	if x != nil {
+		return x.Diagnostics
+	}
+	return nil
+}
+
+func (x *ValidateInstanceResponse) GetFailureReason() FailureReason {
+	if x != nil {
+		return x.FailureReason
+	}
+	return FailureReason_FAILURE_REASON_UNSPECIFIED
+}
+
+func (x *ValidateInstanceResponse) GetVerificationVerdicts() []*VerificationVerdict {
+	if x != nil {
+		return x.VerificationVerdicts
+	}
+	return nil
+}
+
+func (x *ValidateInstanceResponse) GetBounded() bool {
+	if x != nil {
+		return x.Bounded
+	}
+	return false
+}
+
 // EvaluateCalcRequest invokes a calculation, as %calc does. Arguments are bound
 // positionally; a calc usage named with no arguments binds its inputs from its
 // own members and reports every output feature it computes (SysML 7.17).
@@ -1082,7 +1287,7 @@ type EvaluateCalcRequest struct {
 
 func (x *EvaluateCalcRequest) Reset() {
 	*x = EvaluateCalcRequest{}
-	mi := &file_sysml_proto_msgTypes[9]
+	mi := &file_sysml_proto_msgTypes[11]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1094,7 +1299,7 @@ func (x *EvaluateCalcRequest) String() string {
 func (*EvaluateCalcRequest) ProtoMessage() {}
 
 func (x *EvaluateCalcRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[9]
+	mi := &file_sysml_proto_msgTypes[11]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1107,7 +1312,7 @@ func (x *EvaluateCalcRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EvaluateCalcRequest.ProtoReflect.Descriptor instead.
 func (*EvaluateCalcRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{9}
+	return file_sysml_proto_rawDescGZIP(), []int{11}
 }
 
 func (x *EvaluateCalcRequest) GetModelHash() string {
@@ -1162,7 +1367,7 @@ type EvaluateCalcResponse struct {
 
 func (x *EvaluateCalcResponse) Reset() {
 	*x = EvaluateCalcResponse{}
-	mi := &file_sysml_proto_msgTypes[10]
+	mi := &file_sysml_proto_msgTypes[12]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1174,7 +1379,7 @@ func (x *EvaluateCalcResponse) String() string {
 func (*EvaluateCalcResponse) ProtoMessage() {}
 
 func (x *EvaluateCalcResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[10]
+	mi := &file_sysml_proto_msgTypes[12]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1187,7 +1392,7 @@ func (x *EvaluateCalcResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EvaluateCalcResponse.ProtoReflect.Descriptor instead.
 func (*EvaluateCalcResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{10}
+	return file_sysml_proto_rawDescGZIP(), []int{12}
 }
 
 func (x *EvaluateCalcResponse) GetResult() *Value {
@@ -1257,7 +1462,7 @@ type CalcOutput struct {
 
 func (x *CalcOutput) Reset() {
 	*x = CalcOutput{}
-	mi := &file_sysml_proto_msgTypes[11]
+	mi := &file_sysml_proto_msgTypes[13]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1269,7 +1474,7 @@ func (x *CalcOutput) String() string {
 func (*CalcOutput) ProtoMessage() {}
 
 func (x *CalcOutput) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[11]
+	mi := &file_sysml_proto_msgTypes[13]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1282,7 +1487,7 @@ func (x *CalcOutput) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use CalcOutput.ProtoReflect.Descriptor instead.
 func (*CalcOutput) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{11}
+	return file_sysml_proto_rawDescGZIP(), []int{13}
 }
 
 func (x *CalcOutput) GetName() string {
@@ -1325,7 +1530,7 @@ type CaseEvaluation struct {
 
 func (x *CaseEvaluation) Reset() {
 	*x = CaseEvaluation{}
-	mi := &file_sysml_proto_msgTypes[12]
+	mi := &file_sysml_proto_msgTypes[14]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1337,7 +1542,7 @@ func (x *CaseEvaluation) String() string {
 func (*CaseEvaluation) ProtoMessage() {}
 
 func (x *CaseEvaluation) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[12]
+	mi := &file_sysml_proto_msgTypes[14]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1350,7 +1555,7 @@ func (x *CaseEvaluation) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use CaseEvaluation.ProtoReflect.Descriptor instead.
 func (*CaseEvaluation) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{12}
+	return file_sysml_proto_rawDescGZIP(), []int{14}
 }
 
 func (x *CaseEvaluation) GetFunctionId() string {
@@ -1406,7 +1611,10 @@ type RunAnalysisRequest struct {
 	SymbolId string `protobuf:"bytes,2,opt,name=symbol_id,json=symbolId,proto3" json:"symbol_id,omitempty"`
 	// Optional FQN of a part/usage to instantiate as the case's subject. Empty
 	// leaves the case's own `subject s = ...` binding to supply it; a case that
-	// binds none and is given none fails to run.
+	// binds none and is given none fails to run. A path from a declaration, as
+	// `Mission::mission.vehicle`, makes the subject the object the path reaches in
+	// an object of the declaration built for the run — every explored run its own
+	// — so what the assembly binds and connects on it is in force.
 	SubjectSymbolId string `protobuf:"bytes,3,opt,name=subject_symbol_id,json=subjectSymbolId,proto3" json:"subject_symbol_id,omitempty"`
 	// Positional arguments for the case's input parameters, in declaration order;
 	// the subject is never among them.
@@ -1428,7 +1636,7 @@ type RunAnalysisRequest struct {
 
 func (x *RunAnalysisRequest) Reset() {
 	*x = RunAnalysisRequest{}
-	mi := &file_sysml_proto_msgTypes[13]
+	mi := &file_sysml_proto_msgTypes[15]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1440,7 +1648,7 @@ func (x *RunAnalysisRequest) String() string {
 func (*RunAnalysisRequest) ProtoMessage() {}
 
 func (x *RunAnalysisRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[13]
+	mi := &file_sysml_proto_msgTypes[15]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1453,7 +1661,7 @@ func (x *RunAnalysisRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RunAnalysisRequest.ProtoReflect.Descriptor instead.
 func (*RunAnalysisRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{13}
+	return file_sysml_proto_rawDescGZIP(), []int{15}
 }
 
 func (x *RunAnalysisRequest) GetModelHash() string {
@@ -1553,7 +1761,7 @@ type RunAnalysisResponse struct {
 
 func (x *RunAnalysisResponse) Reset() {
 	*x = RunAnalysisResponse{}
-	mi := &file_sysml_proto_msgTypes[14]
+	mi := &file_sysml_proto_msgTypes[16]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1565,7 +1773,7 @@ func (x *RunAnalysisResponse) String() string {
 func (*RunAnalysisResponse) ProtoMessage() {}
 
 func (x *RunAnalysisResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[14]
+	mi := &file_sysml_proto_msgTypes[16]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1578,7 +1786,7 @@ func (x *RunAnalysisResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RunAnalysisResponse.ProtoReflect.Descriptor instead.
 func (*RunAnalysisResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{14}
+	return file_sysml_proto_rawDescGZIP(), []int{16}
 }
 
 func (x *RunAnalysisResponse) GetOutputs() []*CalcOutput {
@@ -1696,14 +1904,17 @@ type Outcome struct {
 	// What the witness run noted about itself: its choice points and the guards
 	// it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
 	// one run.
-	Diagnostics   []*Diagnostic `protobuf:"bytes,7,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"`
+	Diagnostics []*Diagnostic `protobuf:"bytes,7,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"`
+	// The probability of the linearizations reaching this outcome, as explore
+	// computes it; a lower bound when the exploration is incomplete.
+	Probability   float64 `protobuf:"fixed64,8,opt,name=probability,proto3" json:"probability,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
 
 func (x *Outcome) Reset() {
 	*x = Outcome{}
-	mi := &file_sysml_proto_msgTypes[15]
+	mi := &file_sysml_proto_msgTypes[17]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1715,7 +1926,7 @@ func (x *Outcome) String() string {
 func (*Outcome) ProtoMessage() {}
 
 func (x *Outcome) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[15]
+	mi := &file_sysml_proto_msgTypes[17]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1728,7 +1939,7 @@ func (x *Outcome) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Outcome.ProtoReflect.Descriptor instead.
 func (*Outcome) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{15}
+	return file_sysml_proto_rawDescGZIP(), []int{17}
 }
 
 func (x *Outcome) GetOutputs() map[string]*Value {
@@ -1780,6 +1991,13 @@ func (x *Outcome) GetDiagnostics() []*Diagnostic {
 	return nil
 }
 
+func (x *Outcome) GetProbability() float64 {
+	if x != nil {
+		return x.Probability
+	}
+	return 0
+}
+
 // ExplorationStatus is how an exploration ended: whether every linearization
 // within the budget was run, and which budget stopped it when not.
 type ExplorationStatus struct {
@@ -1792,15 +2010,18 @@ type ExplorationStatus struct {
 	BudgetsHit []string `protobuf:"bytes,3,rep,name=budgets_hit,json=budgetsHit,proto3" json:"budgets_hit,omitempty"`
 	// The budget the exploration ran under: runs it may make, and choice points
 	// one run may resolve before the rest take their first alternative.
-	RunsBudget    int32 `protobuf:"varint,4,opt,name=runs_budget,json=runsBudget,proto3" json:"runs_budget,omitempty"`
-	DepthBudget   int32 `protobuf:"varint,5,opt,name=depth_budget,json=depthBudget,proto3" json:"depth_budget,omitempty"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
+	RunsBudget  int32 `protobuf:"varint,4,opt,name=runs_budget,json=runsBudget,proto3" json:"runs_budget,omitempty"`
+	DepthBudget int32 `protobuf:"varint,5,opt,name=depth_budget,json=depthBudget,proto3" json:"depth_budget,omitempty"`
+	// True when the outcomes' probabilities are lower bounds: a budget kept some
+	// linearizations unexplored.
+	ProbabilitiesLowerBound bool `protobuf:"varint,6,opt,name=probabilities_lower_bound,json=probabilitiesLowerBound,proto3" json:"probabilities_lower_bound,omitempty"`
+	unknownFields           protoimpl.UnknownFields
+	sizeCache               protoimpl.SizeCache
 }
 
 func (x *ExplorationStatus) Reset() {
 	*x = ExplorationStatus{}
-	mi := &file_sysml_proto_msgTypes[16]
+	mi := &file_sysml_proto_msgTypes[18]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1812,7 +2033,7 @@ func (x *ExplorationStatus) String() string {
 func (*ExplorationStatus) ProtoMessage() {}
 
 func (x *ExplorationStatus) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[16]
+	mi := &file_sysml_proto_msgTypes[18]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1825,7 +2046,7 @@ func (x *ExplorationStatus) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ExplorationStatus.ProtoReflect.Descriptor instead.
 func (*ExplorationStatus) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{16}
+	return file_sysml_proto_rawDescGZIP(), []int{18}
 }
 
 func (x *ExplorationStatus) GetComplete() bool {
@@ -1863,6 +2084,13 @@ func (x *ExplorationStatus) GetDepthBudget() int32 {
 	return 0
 }
 
+func (x *ExplorationStatus) GetProbabilitiesLowerBound() bool {
+	if x != nil {
+		return x.ProbabilitiesLowerBound
+	}
+	return false
+}
+
 // ListEnginesRequest asks for the analysis engines registered in this build.
 type ListEnginesRequest struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
@@ -1872,7 +2100,7 @@ type ListEnginesRequest struct {
 
 func (x *ListEnginesRequest) Reset() {
 	*x = ListEnginesRequest{}
-	mi := &file_sysml_proto_msgTypes[17]
+	mi := &file_sysml_proto_msgTypes[19]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1884,7 +2112,7 @@ func (x *ListEnginesRequest) String() string {
 func (*ListEnginesRequest) ProtoMessage() {}
 
 func (x *ListEnginesRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[17]
+	mi := &file_sysml_proto_msgTypes[19]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1897,7 +2125,7 @@ func (x *ListEnginesRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ListEnginesRequest.ProtoReflect.Descriptor instead.
 func (*ListEnginesRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{17}
+	return file_sysml_proto_rawDescGZIP(), []int{19}
 }
 
 // EngineInfo is one analysis engine: what it answers, how strongly it can, and
@@ -1946,7 +2174,7 @@ type EngineInfo struct {
 
 func (x *EngineInfo) Reset() {
 	*x = EngineInfo{}
-	mi := &file_sysml_proto_msgTypes[18]
+	mi := &file_sysml_proto_msgTypes[20]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1958,7 +2186,7 @@ func (x *EngineInfo) String() string {
 func (*EngineInfo) ProtoMessage() {}
 
 func (x *EngineInfo) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[18]
+	mi := &file_sysml_proto_msgTypes[20]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1971,7 +2199,7 @@ func (x *EngineInfo) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EngineInfo.ProtoReflect.Descriptor instead.
 func (*EngineInfo) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{18}
+	return file_sysml_proto_rawDescGZIP(), []int{20}
 }
 
 func (x *EngineInfo) GetName() string {
@@ -2082,7 +2310,7 @@ type ListEnginesResponse struct {
 
 func (x *ListEnginesResponse) Reset() {
 	*x = ListEnginesResponse{}
-	mi := &file_sysml_proto_msgTypes[19]
+	mi := &file_sysml_proto_msgTypes[21]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2094,7 +2322,7 @@ func (x *ListEnginesResponse) String() string {
 func (*ListEnginesResponse) ProtoMessage() {}
 
 func (x *ListEnginesResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[19]
+	mi := &file_sysml_proto_msgTypes[21]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2107,7 +2335,7 @@ func (x *ListEnginesResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ListEnginesResponse.ProtoReflect.Descriptor instead.
 func (*ListEnginesResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{19}
+	return file_sysml_proto_rawDescGZIP(), []int{21}
 }
 
 func (x *ListEnginesResponse) GetEngines() []*EngineInfo {
@@ -2142,7 +2370,7 @@ type ParseFileRequest struct {
 
 func (x *ParseFileRequest) Reset() {
 	*x = ParseFileRequest{}
-	mi := &file_sysml_proto_msgTypes[20]
+	mi := &file_sysml_proto_msgTypes[22]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2154,7 +2382,7 @@ func (x *ParseFileRequest) String() string {
 func (*ParseFileRequest) ProtoMessage() {}
 
 func (x *ParseFileRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[20]
+	mi := &file_sysml_proto_msgTypes[22]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2167,7 +2395,7 @@ func (x *ParseFileRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ParseFileRequest.ProtoReflect.Descriptor instead.
 func (*ParseFileRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{20}
+	return file_sysml_proto_rawDescGZIP(), []int{22}
 }
 
 func (x *ParseFileRequest) GetSource() isParseFileRequest_Source {
@@ -2254,7 +2482,7 @@ type SourceDocument struct {
 
 func (x *SourceDocument) Reset() {
 	*x = SourceDocument{}
-	mi := &file_sysml_proto_msgTypes[21]
+	mi := &file_sysml_proto_msgTypes[23]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2266,7 +2494,7 @@ func (x *SourceDocument) String() string {
 func (*SourceDocument) ProtoMessage() {}
 
 func (x *SourceDocument) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[21]
+	mi := &file_sysml_proto_msgTypes[23]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2279,7 +2507,7 @@ func (x *SourceDocument) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use SourceDocument.ProtoReflect.Descriptor instead.
 func (*SourceDocument) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{21}
+	return file_sysml_proto_rawDescGZIP(), []int{23}
 }
 
 func (x *SourceDocument) GetSource() isSourceDocument_Source {
@@ -2351,7 +2579,7 @@ type ParseSourcesRequest struct {
 
 func (x *ParseSourcesRequest) Reset() {
 	*x = ParseSourcesRequest{}
-	mi := &file_sysml_proto_msgTypes[22]
+	mi := &file_sysml_proto_msgTypes[24]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2363,7 +2591,7 @@ func (x *ParseSourcesRequest) String() string {
 func (*ParseSourcesRequest) ProtoMessage() {}
 
 func (x *ParseSourcesRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[22]
+	mi := &file_sysml_proto_msgTypes[24]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2376,7 +2604,7 @@ func (x *ParseSourcesRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ParseSourcesRequest.ProtoReflect.Descriptor instead.
 func (*ParseSourcesRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{22}
+	return file_sysml_proto_rawDescGZIP(), []int{24}
 }
 
 func (x *ParseSourcesRequest) GetDocuments() []*SourceDocument {
@@ -2409,7 +2637,7 @@ type ParseSourcesResponse struct {
 
 func (x *ParseSourcesResponse) Reset() {
 	*x = ParseSourcesResponse{}
-	mi := &file_sysml_proto_msgTypes[23]
+	mi := &file_sysml_proto_msgTypes[25]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2421,7 +2649,7 @@ func (x *ParseSourcesResponse) String() string {
 func (*ParseSourcesResponse) ProtoMessage() {}
 
 func (x *ParseSourcesResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[23]
+	mi := &file_sysml_proto_msgTypes[25]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2434,7 +2662,7 @@ func (x *ParseSourcesResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ParseSourcesResponse.ProtoReflect.Descriptor instead.
 func (*ParseSourcesResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{23}
+	return file_sysml_proto_rawDescGZIP(), []int{25}
 }
 
 func (x *ParseSourcesResponse) GetModelHash() string {
@@ -2478,7 +2706,7 @@ type ParseFileResponse struct {
 
 func (x *ParseFileResponse) Reset() {
 	*x = ParseFileResponse{}
-	mi := &file_sysml_proto_msgTypes[24]
+	mi := &file_sysml_proto_msgTypes[26]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2490,7 +2718,7 @@ func (x *ParseFileResponse) String() string {
 func (*ParseFileResponse) ProtoMessage() {}
 
 func (x *ParseFileResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[24]
+	mi := &file_sysml_proto_msgTypes[26]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2503,7 +2731,7 @@ func (x *ParseFileResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ParseFileResponse.ProtoReflect.Descriptor instead.
 func (*ParseFileResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{24}
+	return file_sysml_proto_rawDescGZIP(), []int{26}
 }
 
 func (x *ParseFileResponse) GetModelHash() string {
@@ -2545,7 +2773,7 @@ type GetSymbolRequest struct {
 
 func (x *GetSymbolRequest) Reset() {
 	*x = GetSymbolRequest{}
-	mi := &file_sysml_proto_msgTypes[25]
+	mi := &file_sysml_proto_msgTypes[27]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2557,7 +2785,7 @@ func (x *GetSymbolRequest) String() string {
 func (*GetSymbolRequest) ProtoMessage() {}
 
 func (x *GetSymbolRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[25]
+	mi := &file_sysml_proto_msgTypes[27]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2570,7 +2798,7 @@ func (x *GetSymbolRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetSymbolRequest.ProtoReflect.Descriptor instead.
 func (*GetSymbolRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{25}
+	return file_sysml_proto_rawDescGZIP(), []int{27}
 }
 
 func (x *GetSymbolRequest) GetModelHash() string {
@@ -2598,7 +2826,7 @@ type SymbolResponse struct {
 
 func (x *SymbolResponse) Reset() {
 	*x = SymbolResponse{}
-	mi := &file_sysml_proto_msgTypes[26]
+	mi := &file_sysml_proto_msgTypes[28]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2610,7 +2838,7 @@ func (x *SymbolResponse) String() string {
 func (*SymbolResponse) ProtoMessage() {}
 
 func (x *SymbolResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[26]
+	mi := &file_sysml_proto_msgTypes[28]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2623,7 +2851,7 @@ func (x *SymbolResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use SymbolResponse.ProtoReflect.Descriptor instead.
 func (*SymbolResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{26}
+	return file_sysml_proto_rawDescGZIP(), []int{28}
 }
 
 func (x *SymbolResponse) GetSymbol() *SymbolInfo {
@@ -2650,7 +2878,7 @@ type DiagnosticsRequest struct {
 
 func (x *DiagnosticsRequest) Reset() {
 	*x = DiagnosticsRequest{}
-	mi := &file_sysml_proto_msgTypes[27]
+	mi := &file_sysml_proto_msgTypes[29]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2662,7 +2890,7 @@ func (x *DiagnosticsRequest) String() string {
 func (*DiagnosticsRequest) ProtoMessage() {}
 
 func (x *DiagnosticsRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[27]
+	mi := &file_sysml_proto_msgTypes[29]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2675,7 +2903,7 @@ func (x *DiagnosticsRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use DiagnosticsRequest.ProtoReflect.Descriptor instead.
 func (*DiagnosticsRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{27}
+	return file_sysml_proto_rawDescGZIP(), []int{29}
 }
 
 func (x *DiagnosticsRequest) GetModelHash() string {
@@ -2696,7 +2924,7 @@ type DiagnosticsResponse struct {
 
 func (x *DiagnosticsResponse) Reset() {
 	*x = DiagnosticsResponse{}
-	mi := &file_sysml_proto_msgTypes[28]
+	mi := &file_sysml_proto_msgTypes[30]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2708,7 +2936,7 @@ func (x *DiagnosticsResponse) String() string {
 func (*DiagnosticsResponse) ProtoMessage() {}
 
 func (x *DiagnosticsResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[28]
+	mi := &file_sysml_proto_msgTypes[30]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2721,7 +2949,7 @@ func (x *DiagnosticsResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use DiagnosticsResponse.ProtoReflect.Descriptor instead.
 func (*DiagnosticsResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{28}
+	return file_sysml_proto_rawDescGZIP(), []int{30}
 }
 
 func (x *DiagnosticsResponse) GetDiagnostics() []*Diagnostic {
@@ -2753,7 +2981,7 @@ type EvaluateRequest struct {
 
 func (x *EvaluateRequest) Reset() {
 	*x = EvaluateRequest{}
-	mi := &file_sysml_proto_msgTypes[29]
+	mi := &file_sysml_proto_msgTypes[31]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2765,7 +2993,7 @@ func (x *EvaluateRequest) String() string {
 func (*EvaluateRequest) ProtoMessage() {}
 
 func (x *EvaluateRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[29]
+	mi := &file_sysml_proto_msgTypes[31]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2778,7 +3006,7 @@ func (x *EvaluateRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EvaluateRequest.ProtoReflect.Descriptor instead.
 func (*EvaluateRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{29}
+	return file_sysml_proto_rawDescGZIP(), []int{31}
 }
 
 func (x *EvaluateRequest) GetModelHash() string {
@@ -2821,7 +3049,7 @@ type EvaluateResponse struct {
 
 func (x *EvaluateResponse) Reset() {
 	*x = EvaluateResponse{}
-	mi := &file_sysml_proto_msgTypes[30]
+	mi := &file_sysml_proto_msgTypes[32]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2833,7 +3061,7 @@ func (x *EvaluateResponse) String() string {
 func (*EvaluateResponse) ProtoMessage() {}
 
 func (x *EvaluateResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[30]
+	mi := &file_sysml_proto_msgTypes[32]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2846,7 +3074,7 @@ func (x *EvaluateResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EvaluateResponse.ProtoReflect.Descriptor instead.
 func (*EvaluateResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{30}
+	return file_sysml_proto_rawDescGZIP(), []int{32}
 }
 
 func (x *EvaluateResponse) GetResult() *Value {
@@ -2883,7 +3111,7 @@ type Instance struct {
 
 func (x *Instance) Reset() {
 	*x = Instance{}
-	mi := &file_sysml_proto_msgTypes[31]
+	mi := &file_sysml_proto_msgTypes[33]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2895,7 +3123,7 @@ func (x *Instance) String() string {
 func (*Instance) ProtoMessage() {}
 
 func (x *Instance) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[31]
+	mi := &file_sysml_proto_msgTypes[33]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2908,7 +3136,7 @@ func (x *Instance) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Instance.ProtoReflect.Descriptor instead.
 func (*Instance) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{31}
+	return file_sysml_proto_rawDescGZIP(), []int{33}
 }
 
 func (x *Instance) GetId() int64 {
@@ -2946,7 +3174,7 @@ type FeatureValue struct {
 
 func (x *FeatureValue) Reset() {
 	*x = FeatureValue{}
-	mi := &file_sysml_proto_msgTypes[32]
+	mi := &file_sysml_proto_msgTypes[34]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2958,7 +3186,7 @@ func (x *FeatureValue) String() string {
 func (*FeatureValue) ProtoMessage() {}
 
 func (x *FeatureValue) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[32]
+	mi := &file_sysml_proto_msgTypes[34]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2971,7 +3199,7 @@ func (x *FeatureValue) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use FeatureValue.ProtoReflect.Descriptor instead.
 func (*FeatureValue) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{32}
+	return file_sysml_proto_rawDescGZIP(), []int{34}
 }
 
 func (x *FeatureValue) GetFeatureName() string {
@@ -3020,7 +3248,7 @@ type InstantiateRequest struct {
 
 func (x *InstantiateRequest) Reset() {
 	*x = InstantiateRequest{}
-	mi := &file_sysml_proto_msgTypes[33]
+	mi := &file_sysml_proto_msgTypes[35]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3032,7 +3260,7 @@ func (x *InstantiateRequest) String() string {
 func (*InstantiateRequest) ProtoMessage() {}
 
 func (x *InstantiateRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[33]
+	mi := &file_sysml_proto_msgTypes[35]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3045,7 +3273,7 @@ func (x *InstantiateRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use InstantiateRequest.ProtoReflect.Descriptor instead.
 func (*InstantiateRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{33}
+	return file_sysml_proto_rawDescGZIP(), []int{35}
 }
 
 func (x *InstantiateRequest) GetModelHash() string {
@@ -3062,7 +3290,11 @@ func (x *InstantiateRequest) GetSymbolId() string {
 	return ""
 }
 
-// InstantiateResponse contains the created instance
+// InstantiateResponse contains the created instance. The service keeps the
+// object for the model it was created in: a later RunDocumentQuery on the same
+// model_hash binds it by its id or by the name it was instantiated under, and
+// `DocumentQueries::Objects` enumerates it. Instantiating the same symbol again
+// creates a new object under the name; the earlier one stays reachable by id.
 type InstantiateResponse struct {
 	state       protoimpl.MessageState `protogen:"open.v1"`
 	Instance    *Instance              `protobuf:"bytes,1,opt,name=instance,proto3" json:"instance,omitempty"`
@@ -3077,7 +3309,7 @@ type InstantiateResponse struct {
 
 func (x *InstantiateResponse) Reset() {
 	*x = InstantiateResponse{}
-	mi := &file_sysml_proto_msgTypes[34]
+	mi := &file_sysml_proto_msgTypes[36]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3089,7 +3321,7 @@ func (x *InstantiateResponse) String() string {
 func (*InstantiateResponse) ProtoMessage() {}
 
 func (x *InstantiateResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[34]
+	mi := &file_sysml_proto_msgTypes[36]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3102,7 +3334,7 @@ func (x *InstantiateResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use InstantiateResponse.ProtoReflect.Descriptor instead.
 func (*InstantiateResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{34}
+	return file_sysml_proto_rawDescGZIP(), []int{36}
 }
 
 func (x *InstantiateResponse) GetInstance() *Instance {
@@ -3145,14 +3377,20 @@ type ExecuteActionRequest struct {
 	// the action once per linearization the library admits, within a budget of
 	// runs (default 1024) and of choice points per run (default 64), each run on
 	// a fresh context, and answers with every distinct outcome reached.
-	Schedule      string `protobuf:"bytes,4,opt,name=schedule,proto3" json:"schedule,omitempty"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
+	Schedule string `protobuf:"bytes,4,opt,name=schedule,proto3" json:"schedule,omitempty"`
+	// Optional FQN of a part/usage to perform the action on, or a path from one to
+	// a nested object, as `Mission::mission.vehicle`: the object is created for the
+	// run — inside the assembly the path walks, so its connectors carry what the
+	// action sends — and under explore each run creates its own. Empty performs
+	// the action outside any object.
+	PerformerSymbolId string `protobuf:"bytes,5,opt,name=performer_symbol_id,json=performerSymbolId,proto3" json:"performer_symbol_id,omitempty"`
+	unknownFields     protoimpl.UnknownFields
+	sizeCache         protoimpl.SizeCache
 }
 
 func (x *ExecuteActionRequest) Reset() {
 	*x = ExecuteActionRequest{}
-	mi := &file_sysml_proto_msgTypes[35]
+	mi := &file_sysml_proto_msgTypes[37]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3164,7 +3402,7 @@ func (x *ExecuteActionRequest) String() string {
 func (*ExecuteActionRequest) ProtoMessage() {}
 
 func (x *ExecuteActionRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[35]
+	mi := &file_sysml_proto_msgTypes[37]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3177,7 +3415,7 @@ func (x *ExecuteActionRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ExecuteActionRequest.ProtoReflect.Descriptor instead.
 func (*ExecuteActionRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{35}
+	return file_sysml_proto_rawDescGZIP(), []int{37}
 }
 
 func (x *ExecuteActionRequest) GetModelHash() string {
@@ -3208,6 +3446,13 @@ func (x *ExecuteActionRequest) GetSchedule() string {
 	return ""
 }
 
+func (x *ExecuteActionRequest) GetPerformerSymbolId() string {
+	if x != nil {
+		return x.PerformerSymbolId
+	}
+	return ""
+}
+
 // ExecuteActionResponse contains action execution results
 type ExecuteActionResponse struct {
 	state       protoimpl.MessageState `protogen:"open.v1"`
@@ -3230,7 +3475,7 @@ type ExecuteActionResponse struct {
 
 func (x *ExecuteActionResponse) Reset() {
 	*x = ExecuteActionResponse{}
-	mi := &file_sysml_proto_msgTypes[36]
+	mi := &file_sysml_proto_msgTypes[38]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3242,7 +3487,7 @@ func (x *ExecuteActionResponse) String() string {
 func (*ExecuteActionResponse) ProtoMessage() {}
 
 func (x *ExecuteActionResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[36]
+	mi := &file_sysml_proto_msgTypes[38]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3255,7 +3500,7 @@ func (x *ExecuteActionResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ExecuteActionResponse.ProtoReflect.Descriptor instead.
 func (*ExecuteActionResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{36}
+	return file_sysml_proto_rawDescGZIP(), []int{38}
 }
 
 func (x *ExecuteActionResponse) GetOutputs() map[string]*Value {
@@ -3311,14 +3556,20 @@ type ExecuteStateRequest struct {
 	// default, "reverse"; any other spelling is INVALID_ARGUMENT. Under explore
 	// the response answers with `outcomes` and `exploration` in place of one run's
 	// states_visited, final_context and error (see ExecuteActionResponse).
-	Schedule      string `protobuf:"bytes,4,opt,name=schedule,proto3" json:"schedule,omitempty"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
+	Schedule string `protobuf:"bytes,4,opt,name=schedule,proto3" json:"schedule,omitempty"`
+	// Optional FQN of a part/usage to run the machine on, or a path from one to a
+	// nested object (see ExecuteActionRequest.performer_symbol_id). An object
+	// exhibiting the machine runs the one it exhibits, so what its assembly
+	// connects to it reaches the run; one exhibiting it under several usages is
+	// an error. Empty runs the machine outside any object.
+	PerformerSymbolId string `protobuf:"bytes,5,opt,name=performer_symbol_id,json=performerSymbolId,proto3" json:"performer_symbol_id,omitempty"`
+	unknownFields     protoimpl.UnknownFields
+	sizeCache         protoimpl.SizeCache
 }
 
 func (x *ExecuteStateRequest) Reset() {
 	*x = ExecuteStateRequest{}
-	mi := &file_sysml_proto_msgTypes[37]
+	mi := &file_sysml_proto_msgTypes[39]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3330,7 +3581,7 @@ func (x *ExecuteStateRequest) String() string {
 func (*ExecuteStateRequest) ProtoMessage() {}
 
 func (x *ExecuteStateRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[37]
+	mi := &file_sysml_proto_msgTypes[39]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3343,7 +3594,7 @@ func (x *ExecuteStateRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ExecuteStateRequest.ProtoReflect.Descriptor instead.
 func (*ExecuteStateRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{37}
+	return file_sysml_proto_rawDescGZIP(), []int{39}
 }
 
 func (x *ExecuteStateRequest) GetModelHash() string {
@@ -3374,6 +3625,13 @@ func (x *ExecuteStateRequest) GetSchedule() string {
 	return ""
 }
 
+func (x *ExecuteStateRequest) GetPerformerSymbolId() string {
+	if x != nil {
+		return x.PerformerSymbolId
+	}
+	return ""
+}
+
 // ExecuteStateResponse contains state machine execution trace
 type ExecuteStateResponse struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
@@ -3397,7 +3655,7 @@ type ExecuteStateResponse struct {
 
 func (x *ExecuteStateResponse) Reset() {
 	*x = ExecuteStateResponse{}
-	mi := &file_sysml_proto_msgTypes[38]
+	mi := &file_sysml_proto_msgTypes[40]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3409,7 +3667,7 @@ func (x *ExecuteStateResponse) String() string {
 func (*ExecuteStateResponse) ProtoMessage() {}
 
 func (x *ExecuteStateResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[38]
+	mi := &file_sysml_proto_msgTypes[40]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3422,7 +3680,7 @@ func (x *ExecuteStateResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ExecuteStateResponse.ProtoReflect.Descriptor instead.
 func (*ExecuteStateResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{38}
+	return file_sysml_proto_rawDescGZIP(), []int{40}
 }
 
 func (x *ExecuteStateResponse) GetStatesVisited() []string {
@@ -3485,11 +3743,14 @@ type ConvertRequest struct {
 	//	*ConvertRequest_Content
 	//	*ConvertRequest_ModelHash
 	Source isConvertRequest_Source `protobuf_oneof:"source"`
-	// "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-	// file_path's extension, and is notation for a model_hash, since that is what
-	// parse reads; inline content has neither, so it must say.
+	// "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+	// "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+	// v2 and never written. Empty infers from file_path's extension, and is
+	// notation for a model_hash, since that is what parse reads; inline content
+	// has neither, so it must say.
 	FromFormat string `protobuf:"bytes,3,opt,name=from_format,json=fromFormat,proto3" json:"from_format,omitempty"`
-	// Format to write, named as in from_format. Empty is rejected.
+	// Format to write, named as in from_format; the v1 names are refused, since
+	// a v2 model has no v1 form. Empty is rejected.
 	ToFormat string `protobuf:"bytes,4,opt,name=to_format,json=toFormat,proto3" json:"to_format,omitempty"`
 	// Write notation back out even when the parser could not read all of it,
 	// reporting its syntax errors as diagnostics. Notation to notation only:
@@ -3502,7 +3763,7 @@ type ConvertRequest struct {
 
 func (x *ConvertRequest) Reset() {
 	*x = ConvertRequest{}
-	mi := &file_sysml_proto_msgTypes[39]
+	mi := &file_sysml_proto_msgTypes[41]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3514,7 +3775,7 @@ func (x *ConvertRequest) String() string {
 func (*ConvertRequest) ProtoMessage() {}
 
 func (x *ConvertRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[39]
+	mi := &file_sysml_proto_msgTypes[41]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3527,7 +3788,7 @@ func (x *ConvertRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ConvertRequest.ProtoReflect.Descriptor instead.
 func (*ConvertRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{39}
+	return file_sysml_proto_rawDescGZIP(), []int{41}
 }
 
 func (x *ConvertRequest) GetSource() isConvertRequest_Source {
@@ -3619,10 +3880,12 @@ type ConvertResponse struct {
 	// Syntax errors tolerated under tolerate_syntax_errors, or those that failed
 	// the conversion.
 	Diagnostics []*Diagnostic `protobuf:"bytes,5,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"`
-	// Set when either format is RDF, whose mapping is experimental: it covers
-	// model structure and the behavior its bodies state, refuses what it cannot
-	// write back, and its vocabulary may change without a compatibility path.
-	// Notation to notation is stable and leaves this unset.
+	// Set when either format is RDF or the API's JSON element form, whose
+	// mapping is experimental: it covers model structure and the behavior its
+	// bodies state, refuses what it cannot write back, and its vocabulary may
+	// change without a compatibility path. Also set when the source is SysML v1,
+	// whose migration is experimental in the same sense. Notation to notation is
+	// stable and leaves this unset.
 	Experimental bool `protobuf:"varint,6,opt,name=experimental,proto3" json:"experimental,omitempty"`
 	// What is experimental about the conversion, in the wording every surface
 	// reports it in. Empty when experimental is false.
@@ -3633,7 +3896,7 @@ type ConvertResponse struct {
 
 func (x *ConvertResponse) Reset() {
 	*x = ConvertResponse{}
-	mi := &file_sysml_proto_msgTypes[40]
+	mi := &file_sysml_proto_msgTypes[42]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3645,7 +3908,7 @@ func (x *ConvertResponse) String() string {
 func (*ConvertResponse) ProtoMessage() {}
 
 func (x *ConvertResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[40]
+	mi := &file_sysml_proto_msgTypes[42]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3658,7 +3921,7 @@ func (x *ConvertResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ConvertResponse.ProtoReflect.Descriptor instead.
 func (*ConvertResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{40}
+	return file_sysml_proto_rawDescGZIP(), []int{42}
 }
 
 func (x *ConvertResponse) GetContent() string {
@@ -3712,20 +3975,36 @@ func (x *ConvertResponse) GetExperimentalNotice() string {
 
 // ApplyEditsRequest asks for a model's source with edits applied to it. The
 // source edited is the one parse read, named by its hash, so an edit is applied
-// to the model that was inspected.
+// to the model that was inspected. A model of several documents (ParseSources)
+// is edited as one when the request sets `accept_documents`: the operations
+// target declarations of the document named by `document`, and a rename or
+// cascade delete follows references into every other document of the model,
+// rewriting those too. `document` and `accept_documents` are advertised as the
+// "edit_documents" capability: a service without it edits a model of one
+// document alone and answers `content` alone, so a client checks it before
+// naming a document or reading `documents`.
 type ApplyEditsRequest struct {
 	state     protoimpl.MessageState `protogen:"open.v1"`
-	ModelHash string                 `protobuf:"bytes,1,opt,name=model_hash,json=modelHash,proto3" json:"model_hash,omitempty"` // from ParseFile response
+	ModelHash string                 `protobuf:"bytes,1,opt,name=model_hash,json=modelHash,proto3" json:"model_hash,omitempty"` // from a ParseFile or ParseSources response
 	// Edits to apply, all of them or none. Empty is rejected: it names no edit,
 	// and answering with the unedited source would look like one was made.
-	Operations    []*EditOperation `protobuf:"bytes,2,rep,name=operations,proto3" json:"operations,omitempty"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
+	Operations []*EditOperation `protobuf:"bytes,2,rep,name=operations,proto3" json:"operations,omitempty"`
+	// The document whose declarations the operations target, named as the parse
+	// request named it; empty names the model's first document, which is the
+	// only one of a ParseFile model. An operation targeting a declaration of
+	// another document is refused as an unknown target, naming that document. A
+	// name no document of the model has fails the call as an invalid argument.
+	Document string `protobuf:"bytes,3,opt,name=document,proto3" json:"document,omitempty"`
+	// Whether the client reads the response's `documents`. A model of several documents is
+	// edited only when set; unset, such a model is refused as a failed precondition, as before.
+	AcceptDocuments bool `protobuf:"varint,4,opt,name=accept_documents,json=acceptDocuments,proto3" json:"accept_documents,omitempty"`
+	unknownFields   protoimpl.UnknownFields
+	sizeCache       protoimpl.SizeCache
 }
 
 func (x *ApplyEditsRequest) Reset() {
 	*x = ApplyEditsRequest{}
-	mi := &file_sysml_proto_msgTypes[41]
+	mi := &file_sysml_proto_msgTypes[43]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3737,7 +4016,7 @@ func (x *ApplyEditsRequest) String() string {
 func (*ApplyEditsRequest) ProtoMessage() {}
 
 func (x *ApplyEditsRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[41]
+	mi := &file_sysml_proto_msgTypes[43]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3750,7 +4029,7 @@ func (x *ApplyEditsRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ApplyEditsRequest.ProtoReflect.Descriptor instead.
 func (*ApplyEditsRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{41}
+	return file_sysml_proto_rawDescGZIP(), []int{43}
 }
 
 func (x *ApplyEditsRequest) GetModelHash() string {
@@ -3767,6 +4046,20 @@ func (x *ApplyEditsRequest) GetOperations() []*EditOperation {
 	return nil
 }
 
+func (x *ApplyEditsRequest) GetDocument() string {
+	if x != nil {
+		return x.Document
+	}
+	return ""
+}
+
+func (x *ApplyEditsRequest) GetAcceptDocuments() bool {
+	if x != nil {
+		return x.AcceptDocuments
+	}
+	return false
+}
+
 // EditOperation is one source-preserving change to make.
 type EditOperation struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
@@ -3784,7 +4077,7 @@ type EditOperation struct {
 
 func (x *EditOperation) Reset() {
 	*x = EditOperation{}
-	mi := &file_sysml_proto_msgTypes[42]
+	mi := &file_sysml_proto_msgTypes[44]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3796,7 +4089,7 @@ func (x *EditOperation) String() string {
 func (*EditOperation) ProtoMessage() {}
 
 func (x *EditOperation) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[42]
+	mi := &file_sysml_proto_msgTypes[44]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3809,7 +4102,7 @@ func (x *EditOperation) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EditOperation.ProtoReflect.Descriptor instead.
 func (*EditOperation) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{42}
+	return file_sysml_proto_rawDescGZIP(), []int{44}
 }
 
 func (x *EditOperation) GetOperation() isEditOperation_Operation {
@@ -3921,7 +4214,7 @@ type AddMemberEdit struct {
 
 func (x *AddMemberEdit) Reset() {
 	*x = AddMemberEdit{}
-	mi := &file_sysml_proto_msgTypes[43]
+	mi := &file_sysml_proto_msgTypes[45]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3933,7 +4226,7 @@ func (x *AddMemberEdit) String() string {
 func (*AddMemberEdit) ProtoMessage() {}
 
 func (x *AddMemberEdit) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[43]
+	mi := &file_sysml_proto_msgTypes[45]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3946,7 +4239,7 @@ func (x *AddMemberEdit) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use AddMemberEdit.ProtoReflect.Descriptor instead.
 func (*AddMemberEdit) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{43}
+	return file_sysml_proto_rawDescGZIP(), []int{45}
 }
 
 func (x *AddMemberEdit) GetOwner() string {
@@ -4011,7 +4304,7 @@ type DeleteEdit struct {
 
 func (x *DeleteEdit) Reset() {
 	*x = DeleteEdit{}
-	mi := &file_sysml_proto_msgTypes[44]
+	mi := &file_sysml_proto_msgTypes[46]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4023,7 +4316,7 @@ func (x *DeleteEdit) String() string {
 func (*DeleteEdit) ProtoMessage() {}
 
 func (x *DeleteEdit) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[44]
+	mi := &file_sysml_proto_msgTypes[46]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4036,7 +4329,7 @@ func (x *DeleteEdit) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use DeleteEdit.ProtoReflect.Descriptor instead.
 func (*DeleteEdit) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{44}
+	return file_sysml_proto_rawDescGZIP(), []int{46}
 }
 
 func (x *DeleteEdit) GetTarget() string {
@@ -4069,7 +4362,7 @@ type MoveEdit struct {
 
 func (x *MoveEdit) Reset() {
 	*x = MoveEdit{}
-	mi := &file_sysml_proto_msgTypes[45]
+	mi := &file_sysml_proto_msgTypes[47]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4081,7 +4374,7 @@ func (x *MoveEdit) String() string {
 func (*MoveEdit) ProtoMessage() {}
 
 func (x *MoveEdit) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[45]
+	mi := &file_sysml_proto_msgTypes[47]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4094,7 +4387,7 @@ func (x *MoveEdit) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use MoveEdit.ProtoReflect.Descriptor instead.
 func (*MoveEdit) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{45}
+	return file_sysml_proto_rawDescGZIP(), []int{47}
 }
 
 func (x *MoveEdit) GetTarget() string {
@@ -4126,7 +4419,7 @@ type SetValueEdit struct {
 
 func (x *SetValueEdit) Reset() {
 	*x = SetValueEdit{}
-	mi := &file_sysml_proto_msgTypes[46]
+	mi := &file_sysml_proto_msgTypes[48]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4138,7 +4431,7 @@ func (x *SetValueEdit) String() string {
 func (*SetValueEdit) ProtoMessage() {}
 
 func (x *SetValueEdit) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[46]
+	mi := &file_sysml_proto_msgTypes[48]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4151,7 +4444,7 @@ func (x *SetValueEdit) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use SetValueEdit.ProtoReflect.Descriptor instead.
 func (*SetValueEdit) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{46}
+	return file_sysml_proto_rawDescGZIP(), []int{48}
 }
 
 func (x *SetValueEdit) GetTarget() string {
@@ -4168,9 +4461,10 @@ func (x *SetValueEdit) GetValue() string {
 	return ""
 }
 
-// RenameEdit rewrites the name token of a declaration. References to the
-// element are not updated: a rename of an element referenced anywhere is
-// refused, naming the referring elements, rather than leaving a broken model.
+// RenameEdit rewrites the name token of a declaration and every reference to
+// it in the model's documents. A rename that reaches a reference in a document
+// the edit cannot rewrite is refused, naming the referring elements, rather
+// than leaving a broken model.
 type RenameEdit struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Target        string                 `protobuf:"bytes,1,opt,name=target,proto3" json:"target,omitempty"`                  // element to rename, as SymbolInfo.id names it
@@ -4181,7 +4475,7 @@ type RenameEdit struct {
 
 func (x *RenameEdit) Reset() {
 	*x = RenameEdit{}
-	mi := &file_sysml_proto_msgTypes[47]
+	mi := &file_sysml_proto_msgTypes[49]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4193,7 +4487,7 @@ func (x *RenameEdit) String() string {
 func (*RenameEdit) ProtoMessage() {}
 
 func (x *RenameEdit) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[47]
+	mi := &file_sysml_proto_msgTypes[49]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4206,7 +4500,7 @@ func (x *RenameEdit) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RenameEdit.ProtoReflect.Descriptor instead.
 func (*RenameEdit) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{47}
+	return file_sysml_proto_rawDescGZIP(), []int{49}
 }
 
 func (x *RenameEdit) GetTarget() string {
@@ -4224,30 +4518,51 @@ func (x *RenameEdit) GetNewName() string {
 }
 
 // ApplyEditsResponse carries the edited source, or says why nothing was edited.
+// The edited notation is in `documents`, one entry per document the edits
+// rewrote; `content` repeats it for a single-document model only.
 type ApplyEditsResponse struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
-	// The edited notation, byte-identical to the source outside the edited spans.
-	// Empty when the edits were refused, so a refusal never writes a file.
+	// The edited notation of a single-document model, byte-identical to the
+	// source outside the edited spans. Empty when the edits were refused, so a
+	// refusal never writes a file, and empty for a model of several documents,
+	// whose edited notation is in `documents` alone: a client that reads
+	// `content` alone was written for one document and must not write one
+	// document's notation over another's. `documents` carries the same notation
+	// for a single-document model, so a client needs one code path.
 	Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"`
-	// What each operation changed, in request order.
+	// What each operation changed, grouped by document in the order `documents`
+	// lists them and in request order within a document.
 	Applied []*AppliedEdit `protobuf:"bytes,2,rep,name=applied,proto3" json:"applied,omitempty"`
-	Error   string         `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` // non-empty if the edits were refused; content is unset
+	Error   string         `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` // non-empty if the edits were refused; content and documents are unset
 	// Which kind of refusal `error` reports, so a client acts on the kind rather
 	// than on the message text.
 	Failure EditFailure `protobuf:"varint,4,opt,name=failure,proto3,enum=sysml.EditFailure" json:"failure,omitempty"`
 	// Diagnostics behind a refusal: the parse errors of an unreadable new value,
-	// or the errors the edited source was found to have.
+	// or the errors the edited source was found to have. A diagnostic's span
+	// names the document it is in.
 	Diagnostics []*Diagnostic `protobuf:"bytes,5,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"`
-	// Where the references to a declaration whose rename was refused are made:
-	// the FQN of each referring namespace.
+	// Where the references to a declaration whose rename, delete or move was
+	// refused are made: the FQN of each referring namespace, suffixed with its
+	// document in parentheses when that is not the document being edited.
+	// `referrers` carries the same list with the document as a field of its own.
 	ReferringElements []string `protobuf:"bytes,6,rep,name=referring_elements,json=referringElements,proto3" json:"referring_elements,omitempty"`
-	unknownFields     protoimpl.UnknownFields
-	sizeCache         protoimpl.SizeCache
+	// The edited notation of every document the edits rewrote, named as the
+	// parse request named it: the document being edited first, then the others
+	// in name order. A document of several the edits left as parsed is not
+	// listed; the one document of a single-document model always is. Empty when
+	// the edits were refused.
+	Documents []*EditedDocument `protobuf:"bytes,7,rep,name=documents,proto3" json:"documents,omitempty"`
+	// The declarations referring to the target of a refused rename, delete or
+	// move, each with the document declaring it, in document then name order.
+	// Empty when `referring_elements` is.
+	Referrers     []*Referrer `protobuf:"bytes,8,rep,name=referrers,proto3" json:"referrers,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
 }
 
 func (x *ApplyEditsResponse) Reset() {
 	*x = ApplyEditsResponse{}
-	mi := &file_sysml_proto_msgTypes[48]
+	mi := &file_sysml_proto_msgTypes[50]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4259,7 +4574,7 @@ func (x *ApplyEditsResponse) String() string {
 func (*ApplyEditsResponse) ProtoMessage() {}
 
 func (x *ApplyEditsResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[48]
+	mi := &file_sysml_proto_msgTypes[50]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4272,7 +4587,7 @@ func (x *ApplyEditsResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ApplyEditsResponse.ProtoReflect.Descriptor instead.
 func (*ApplyEditsResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{48}
+	return file_sysml_proto_rawDescGZIP(), []int{50}
 }
 
 func (x *ApplyEditsResponse) GetContent() string {
@@ -4317,38 +4632,48 @@ func (x *ApplyEditsResponse) GetReferringElements() []string {
 	return nil
 }
 
-// AppliedEdit is one byte range of the original source that an operation
-// replaced, so a client can report or locate what changed.
-type AppliedEdit struct {
+func (x *ApplyEditsResponse) GetDocuments() []*EditedDocument {
+	if x != nil {
+		return x.Documents
+	}
+	return nil
+}
+
+func (x *ApplyEditsResponse) GetReferrers() []*Referrer {
+	if x != nil {
+		return x.Referrers
+	}
+	return nil
+}
+
+// EditedDocument is the edited notation of one document of the model.
+type EditedDocument struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
-	// Index of the operation in the request, so an answer maps back to its ask.
-	OperationIndex int32  `protobuf:"varint,1,opt,name=operation_index,json=operationIndex,proto3" json:"operation_index,omitempty"`
-	Target         string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` // element edited, as the request named it
-	// Batch offsets use the original source; sequential offsets use the
-	// intermediate source seen by that operation.
-	Offset        int32  `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"`
-	Length        int32  `protobuf:"varint,4,opt,name=length,proto3" json:"length,omitempty"`                 // bytes replaced; zero for text inserted
-	OldText       string `protobuf:"bytes,5,opt,name=old_text,json=oldText,proto3" json:"old_text,omitempty"` // what was there, empty for an insertion
-	NewText       string `protobuf:"bytes,6,opt,name=new_text,json=newText,proto3" json:"new_text,omitempty"` // what was written
+	// The document's name as the parse request gave it: the file path of a
+	// file_path document, the name of an inline one, or the position it was
+	// given when it was named neither.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// The edited notation, byte-identical to the source outside the edited spans.
+	Content       string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
 
-func (x *AppliedEdit) Reset() {
-	*x = AppliedEdit{}
-	mi := &file_sysml_proto_msgTypes[49]
+func (x *EditedDocument) Reset() {
+	*x = EditedDocument{}
+	mi := &file_sysml_proto_msgTypes[51]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
 
-func (x *AppliedEdit) String() string {
+func (x *EditedDocument) String() string {
 	return protoimpl.X.MessageStringOf(x)
 }
 
-func (*AppliedEdit) ProtoMessage() {}
+func (*EditedDocument) ProtoMessage() {}
 
-func (x *AppliedEdit) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[49]
+func (x *EditedDocument) ProtoReflect() protoreflect.Message {
+	mi := &file_sysml_proto_msgTypes[51]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4359,63 +4684,190 @@ func (x *AppliedEdit) ProtoReflect() protoreflect.Message {
 	return mi.MessageOf(x)
 }
 
-// Deprecated: Use AppliedEdit.ProtoReflect.Descriptor instead.
-func (*AppliedEdit) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{49}
+// Deprecated: Use EditedDocument.ProtoReflect.Descriptor instead.
+func (*EditedDocument) Descriptor() ([]byte, []int) {
+	return file_sysml_proto_rawDescGZIP(), []int{51}
 }
 
-func (x *AppliedEdit) GetOperationIndex() int32 {
+func (x *EditedDocument) GetName() string {
 	if x != nil {
-		return x.OperationIndex
+		return x.Name
 	}
-	return 0
+	return ""
 }
 
-func (x *AppliedEdit) GetTarget() string {
+func (x *EditedDocument) GetContent() string {
 	if x != nil {
-		return x.Target
+		return x.Content
 	}
 	return ""
 }
 
-func (x *AppliedEdit) GetOffset() int32 {
-	if x != nil {
-		return x.Offset
-	}
-	return 0
+// Referrer is one declaration referring to the target of a refused edit.
+type Referrer struct {
+	state protoimpl.MessageState `protogen:"open.v1"`
+	// The declaration as the notation names it: the FQN of a named one, or the
+	// heading of an anonymous one within its namespace.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// The document declaring it, named as the parse request named it.
+	Document      string `protobuf:"bytes,2,opt,name=document,proto3" json:"document,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
 }
 
-func (x *AppliedEdit) GetLength() int32 {
+func (x *Referrer) Reset() {
+	*x = Referrer{}
+	mi := &file_sysml_proto_msgTypes[52]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *Referrer) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Referrer) ProtoMessage() {}
+
+func (x *Referrer) ProtoReflect() protoreflect.Message {
+	mi := &file_sysml_proto_msgTypes[52]
 	if x != nil {
-		return x.Length
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
 	}
-	return 0
+	return mi.MessageOf(x)
 }
 
-func (x *AppliedEdit) GetOldText() string {
+// Deprecated: Use Referrer.ProtoReflect.Descriptor instead.
+func (*Referrer) Descriptor() ([]byte, []int) {
+	return file_sysml_proto_rawDescGZIP(), []int{52}
+}
+
+func (x *Referrer) GetName() string {
 	if x != nil {
-		return x.OldText
+		return x.Name
 	}
 	return ""
 }
 
-func (x *AppliedEdit) GetNewText() string {
+func (x *Referrer) GetDocument() string {
 	if x != nil {
-		return x.NewText
+		return x.Document
 	}
 	return ""
 }
 
-// SymbolInfo represents any SysML element
-type SymbolInfo struct {
-	state      protoimpl.MessageState `protogen:"open.v1"`
-	Id         string                 `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // Unique identifier (fully qualified name)
-	Name       string                 `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
-	Kind       string                 `protobuf:"bytes,3,opt,name=kind,proto3" json:"kind,omitempty"`                                                                                   // "PartDefinition", "AttributeUsage", etc
-	Metadata   map[string]string      `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // multiplicity, type, etc
-	ChildIds   []string               `protobuf:"bytes,5,rep,name=child_ids,json=childIds,proto3" json:"child_ids,omitempty"`                                                           // References to children
-	Attributes []*AttributeInfo       `protobuf:"bytes,6,rep,name=attributes,proto3" json:"attributes,omitempty"`
-	// Static type facts; unset when the element is not a def/usage.
+// AppliedEdit is one byte range of the original source that an operation
+// replaced, so a client can report or locate what changed.
+type AppliedEdit struct {
+	state protoimpl.MessageState `protogen:"open.v1"`
+	// Index of the operation in the request, so an answer maps back to its ask.
+	OperationIndex int32  `protobuf:"varint,1,opt,name=operation_index,json=operationIndex,proto3" json:"operation_index,omitempty"`
+	Target         string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` // element edited, as the request named it
+	// Batch offsets use the original source; sequential offsets use the
+	// intermediate source seen by that operation.
+	Offset  int32  `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"`
+	Length  int32  `protobuf:"varint,4,opt,name=length,proto3" json:"length,omitempty"`                 // bytes replaced; zero for text inserted
+	OldText string `protobuf:"bytes,5,opt,name=old_text,json=oldText,proto3" json:"old_text,omitempty"` // what was there, empty for an insertion
+	NewText string `protobuf:"bytes,6,opt,name=new_text,json=newText,proto3" json:"new_text,omitempty"` // what was written
+	// The document the bytes are in, named as the parse request named it and as
+	// ApplyEditsResponse.documents lists it. Set for a single-document model too.
+	Document      string `protobuf:"bytes,7,opt,name=document,proto3" json:"document,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *AppliedEdit) Reset() {
+	*x = AppliedEdit{}
+	mi := &file_sysml_proto_msgTypes[53]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *AppliedEdit) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AppliedEdit) ProtoMessage() {}
+
+func (x *AppliedEdit) ProtoReflect() protoreflect.Message {
+	mi := &file_sysml_proto_msgTypes[53]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use AppliedEdit.ProtoReflect.Descriptor instead.
+func (*AppliedEdit) Descriptor() ([]byte, []int) {
+	return file_sysml_proto_rawDescGZIP(), []int{53}
+}
+
+func (x *AppliedEdit) GetOperationIndex() int32 {
+	if x != nil {
+		return x.OperationIndex
+	}
+	return 0
+}
+
+func (x *AppliedEdit) GetTarget() string {
+	if x != nil {
+		return x.Target
+	}
+	return ""
+}
+
+func (x *AppliedEdit) GetOffset() int32 {
+	if x != nil {
+		return x.Offset
+	}
+	return 0
+}
+
+func (x *AppliedEdit) GetLength() int32 {
+	if x != nil {
+		return x.Length
+	}
+	return 0
+}
+
+func (x *AppliedEdit) GetOldText() string {
+	if x != nil {
+		return x.OldText
+	}
+	return ""
+}
+
+func (x *AppliedEdit) GetNewText() string {
+	if x != nil {
+		return x.NewText
+	}
+	return ""
+}
+
+func (x *AppliedEdit) GetDocument() string {
+	if x != nil {
+		return x.Document
+	}
+	return ""
+}
+
+// SymbolInfo represents any SysML element
+type SymbolInfo struct {
+	state      protoimpl.MessageState `protogen:"open.v1"`
+	Id         string                 `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // Unique identifier (fully qualified name)
+	Name       string                 `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
+	Kind       string                 `protobuf:"bytes,3,opt,name=kind,proto3" json:"kind,omitempty"`                                                                                   // "PartDefinition", "AttributeUsage", etc
+	Metadata   map[string]string      `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // multiplicity, type, etc
+	ChildIds   []string               `protobuf:"bytes,5,rep,name=child_ids,json=childIds,proto3" json:"child_ids,omitempty"`                                                           // References to children
+	Attributes []*AttributeInfo       `protobuf:"bytes,6,rep,name=attributes,proto3" json:"attributes,omitempty"`
+	// Static type facts; unset when the element is not a def/usage.
 	TypeInfo *TypeInfo `protobuf:"bytes,7,opt,name=type_info,json=typeInfo,proto3" json:"type_info,omitempty"`
 	// Declared multiplicity; unset when none is declared.
 	Multiplicity *MultiplicityInfo `protobuf:"bytes,8,opt,name=multiplicity,proto3" json:"multiplicity,omitempty"`
@@ -4431,7 +4883,7 @@ type SymbolInfo struct {
 
 func (x *SymbolInfo) Reset() {
 	*x = SymbolInfo{}
-	mi := &file_sysml_proto_msgTypes[50]
+	mi := &file_sysml_proto_msgTypes[54]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4443,7 +4895,7 @@ func (x *SymbolInfo) String() string {
 func (*SymbolInfo) ProtoMessage() {}
 
 func (x *SymbolInfo) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[50]
+	mi := &file_sysml_proto_msgTypes[54]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4456,7 +4908,7 @@ func (x *SymbolInfo) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use SymbolInfo.ProtoReflect.Descriptor instead.
 func (*SymbolInfo) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{50}
+	return file_sysml_proto_rawDescGZIP(), []int{54}
 }
 
 func (x *SymbolInfo) GetId() string {
@@ -4546,7 +4998,7 @@ type Specialization struct {
 
 func (x *Specialization) Reset() {
 	*x = Specialization{}
-	mi := &file_sysml_proto_msgTypes[51]
+	mi := &file_sysml_proto_msgTypes[55]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4558,7 +5010,7 @@ func (x *Specialization) String() string {
 func (*Specialization) ProtoMessage() {}
 
 func (x *Specialization) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[51]
+	mi := &file_sysml_proto_msgTypes[55]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4571,7 +5023,7 @@ func (x *Specialization) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Specialization.ProtoReflect.Descriptor instead.
 func (*Specialization) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{51}
+	return file_sysml_proto_rawDescGZIP(), []int{55}
 }
 
 func (x *Specialization) GetKind() string {
@@ -4628,7 +5080,7 @@ type TypeInfo struct {
 
 func (x *TypeInfo) Reset() {
 	*x = TypeInfo{}
-	mi := &file_sysml_proto_msgTypes[52]
+	mi := &file_sysml_proto_msgTypes[56]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4640,7 +5092,7 @@ func (x *TypeInfo) String() string {
 func (*TypeInfo) ProtoMessage() {}
 
 func (x *TypeInfo) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[52]
+	mi := &file_sysml_proto_msgTypes[56]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4653,7 +5105,7 @@ func (x *TypeInfo) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use TypeInfo.ProtoReflect.Descriptor instead.
 func (*TypeInfo) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{52}
+	return file_sysml_proto_rawDescGZIP(), []int{56}
 }
 
 func (x *TypeInfo) GetDeclared() string {
@@ -4717,7 +5169,7 @@ type MultiplicityInfo struct {
 
 func (x *MultiplicityInfo) Reset() {
 	*x = MultiplicityInfo{}
-	mi := &file_sysml_proto_msgTypes[53]
+	mi := &file_sysml_proto_msgTypes[57]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4729,7 +5181,7 @@ func (x *MultiplicityInfo) String() string {
 func (*MultiplicityInfo) ProtoMessage() {}
 
 func (x *MultiplicityInfo) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[53]
+	mi := &file_sysml_proto_msgTypes[57]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4742,7 +5194,7 @@ func (x *MultiplicityInfo) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use MultiplicityInfo.ProtoReflect.Descriptor instead.
 func (*MultiplicityInfo) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{53}
+	return file_sysml_proto_rawDescGZIP(), []int{57}
 }
 
 func (x *MultiplicityInfo) GetLower() string {
@@ -4772,7 +5224,7 @@ type AttributeInfo struct {
 
 func (x *AttributeInfo) Reset() {
 	*x = AttributeInfo{}
-	mi := &file_sysml_proto_msgTypes[54]
+	mi := &file_sysml_proto_msgTypes[58]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4784,7 +5236,7 @@ func (x *AttributeInfo) String() string {
 func (*AttributeInfo) ProtoMessage() {}
 
 func (x *AttributeInfo) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[54]
+	mi := &file_sysml_proto_msgTypes[58]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4797,7 +5249,7 @@ func (x *AttributeInfo) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use AttributeInfo.ProtoReflect.Descriptor instead.
 func (*AttributeInfo) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{54}
+	return file_sysml_proto_rawDescGZIP(), []int{58}
 }
 
 func (x *AttributeInfo) GetName() string {
@@ -4861,7 +5313,7 @@ type Value struct {
 
 func (x *Value) Reset() {
 	*x = Value{}
-	mi := &file_sysml_proto_msgTypes[55]
+	mi := &file_sysml_proto_msgTypes[59]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4873,7 +5325,7 @@ func (x *Value) String() string {
 func (*Value) ProtoMessage() {}
 
 func (x *Value) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[55]
+	mi := &file_sysml_proto_msgTypes[59]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4886,7 +5338,7 @@ func (x *Value) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Value.ProtoReflect.Descriptor instead.
 func (*Value) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{55}
+	return file_sysml_proto_rawDescGZIP(), []int{59}
 }
 
 func (x *Value) GetKind() isValue_Kind {
@@ -5244,7 +5696,7 @@ type Metaobject struct {
 
 func (x *Metaobject) Reset() {
 	*x = Metaobject{}
-	mi := &file_sysml_proto_msgTypes[56]
+	mi := &file_sysml_proto_msgTypes[60]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5256,7 +5708,7 @@ func (x *Metaobject) String() string {
 func (*Metaobject) ProtoMessage() {}
 
 func (x *Metaobject) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[56]
+	mi := &file_sysml_proto_msgTypes[60]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5269,7 +5721,7 @@ func (x *Metaobject) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Metaobject.ProtoReflect.Descriptor instead.
 func (*Metaobject) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{56}
+	return file_sysml_proto_rawDescGZIP(), []int{60}
 }
 
 func (x *Metaobject) GetElementId() string {
@@ -5306,7 +5758,7 @@ type Undetermined struct {
 
 func (x *Undetermined) Reset() {
 	*x = Undetermined{}
-	mi := &file_sysml_proto_msgTypes[57]
+	mi := &file_sysml_proto_msgTypes[61]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5318,7 +5770,7 @@ func (x *Undetermined) String() string {
 func (*Undetermined) ProtoMessage() {}
 
 func (x *Undetermined) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[57]
+	mi := &file_sysml_proto_msgTypes[61]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5331,7 +5783,7 @@ func (x *Undetermined) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Undetermined.ProtoReflect.Descriptor instead.
 func (*Undetermined) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{57}
+	return file_sysml_proto_rawDescGZIP(), []int{61}
 }
 
 func (x *Undetermined) GetReason() string {
@@ -5370,7 +5822,7 @@ type Function struct {
 
 func (x *Function) Reset() {
 	*x = Function{}
-	mi := &file_sysml_proto_msgTypes[58]
+	mi := &file_sysml_proto_msgTypes[62]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5382,7 +5834,7 @@ func (x *Function) String() string {
 func (*Function) ProtoMessage() {}
 
 func (x *Function) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[58]
+	mi := &file_sysml_proto_msgTypes[62]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5395,7 +5847,7 @@ func (x *Function) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Function.ProtoReflect.Descriptor instead.
 func (*Function) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{58}
+	return file_sysml_proto_rawDescGZIP(), []int{62}
 }
 
 func (x *Function) GetCalcId() string {
@@ -5429,7 +5881,7 @@ type ValueSet struct {
 
 func (x *ValueSet) Reset() {
 	*x = ValueSet{}
-	mi := &file_sysml_proto_msgTypes[59]
+	mi := &file_sysml_proto_msgTypes[63]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5441,7 +5893,7 @@ func (x *ValueSet) String() string {
 func (*ValueSet) ProtoMessage() {}
 
 func (x *ValueSet) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[59]
+	mi := &file_sysml_proto_msgTypes[63]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5454,7 +5906,7 @@ func (x *ValueSet) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ValueSet.ProtoReflect.Descriptor instead.
 func (*ValueSet) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{59}
+	return file_sysml_proto_rawDescGZIP(), []int{63}
 }
 
 func (x *ValueSet) GetElements() []*Value {
@@ -5481,7 +5933,7 @@ type TensorQuantity struct {
 
 func (x *TensorQuantity) Reset() {
 	*x = TensorQuantity{}
-	mi := &file_sysml_proto_msgTypes[60]
+	mi := &file_sysml_proto_msgTypes[64]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5493,7 +5945,7 @@ func (x *TensorQuantity) String() string {
 func (*TensorQuantity) ProtoMessage() {}
 
 func (x *TensorQuantity) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[60]
+	mi := &file_sysml_proto_msgTypes[64]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5506,7 +5958,7 @@ func (x *TensorQuantity) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use TensorQuantity.ProtoReflect.Descriptor instead.
 func (*TensorQuantity) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{60}
+	return file_sysml_proto_rawDescGZIP(), []int{64}
 }
 
 func (x *TensorQuantity) GetDimensions() []int64 {
@@ -5538,7 +5990,7 @@ type Array struct {
 
 func (x *Array) Reset() {
 	*x = Array{}
-	mi := &file_sysml_proto_msgTypes[61]
+	mi := &file_sysml_proto_msgTypes[65]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5550,7 +6002,7 @@ func (x *Array) String() string {
 func (*Array) ProtoMessage() {}
 
 func (x *Array) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[61]
+	mi := &file_sysml_proto_msgTypes[65]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5563,7 +6015,7 @@ func (x *Array) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Array.ProtoReflect.Descriptor instead.
 func (*Array) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{61}
+	return file_sysml_proto_rawDescGZIP(), []int{65}
 }
 
 func (x *Array) GetDimensions() []int64 {
@@ -5593,7 +6045,7 @@ type Vector struct {
 
 func (x *Vector) Reset() {
 	*x = Vector{}
-	mi := &file_sysml_proto_msgTypes[62]
+	mi := &file_sysml_proto_msgTypes[66]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5605,7 +6057,7 @@ func (x *Vector) String() string {
 func (*Vector) ProtoMessage() {}
 
 func (x *Vector) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[62]
+	mi := &file_sysml_proto_msgTypes[66]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5618,7 +6070,7 @@ func (x *Vector) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Vector.ProtoReflect.Descriptor instead.
 func (*Vector) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{62}
+	return file_sysml_proto_rawDescGZIP(), []int{66}
 }
 
 func (x *Vector) GetComponents() []*Value {
@@ -5641,7 +6093,7 @@ type VectorQuantity struct {
 
 func (x *VectorQuantity) Reset() {
 	*x = VectorQuantity{}
-	mi := &file_sysml_proto_msgTypes[63]
+	mi := &file_sysml_proto_msgTypes[67]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5653,7 +6105,7 @@ func (x *VectorQuantity) String() string {
 func (*VectorQuantity) ProtoMessage() {}
 
 func (x *VectorQuantity) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[63]
+	mi := &file_sysml_proto_msgTypes[67]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5666,7 +6118,7 @@ func (x *VectorQuantity) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use VectorQuantity.ProtoReflect.Descriptor instead.
 func (*VectorQuantity) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{63}
+	return file_sysml_proto_rawDescGZIP(), []int{67}
 }
 
 func (x *VectorQuantity) GetComponents() []*Quantity {
@@ -5688,7 +6140,7 @@ type Complex struct {
 
 func (x *Complex) Reset() {
 	*x = Complex{}
-	mi := &file_sysml_proto_msgTypes[64]
+	mi := &file_sysml_proto_msgTypes[68]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5700,7 +6152,7 @@ func (x *Complex) String() string {
 func (*Complex) ProtoMessage() {}
 
 func (x *Complex) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[64]
+	mi := &file_sysml_proto_msgTypes[68]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5713,7 +6165,7 @@ func (x *Complex) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Complex.ProtoReflect.Descriptor instead.
 func (*Complex) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{64}
+	return file_sysml_proto_rawDescGZIP(), []int{68}
 }
 
 func (x *Complex) GetReal() float64 {
@@ -5750,7 +6202,7 @@ type EnumLiteral struct {
 
 func (x *EnumLiteral) Reset() {
 	*x = EnumLiteral{}
-	mi := &file_sysml_proto_msgTypes[65]
+	mi := &file_sysml_proto_msgTypes[69]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5762,7 +6214,7 @@ func (x *EnumLiteral) String() string {
 func (*EnumLiteral) ProtoMessage() {}
 
 func (x *EnumLiteral) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[65]
+	mi := &file_sysml_proto_msgTypes[69]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5775,7 +6227,7 @@ func (x *EnumLiteral) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EnumLiteral.ProtoReflect.Descriptor instead.
 func (*EnumLiteral) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{65}
+	return file_sysml_proto_rawDescGZIP(), []int{69}
 }
 
 func (x *EnumLiteral) GetLiteralId() string {
@@ -5815,7 +6267,7 @@ type ValueSequence struct {
 
 func (x *ValueSequence) Reset() {
 	*x = ValueSequence{}
-	mi := &file_sysml_proto_msgTypes[66]
+	mi := &file_sysml_proto_msgTypes[70]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5827,7 +6279,7 @@ func (x *ValueSequence) String() string {
 func (*ValueSequence) ProtoMessage() {}
 
 func (x *ValueSequence) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[66]
+	mi := &file_sysml_proto_msgTypes[70]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5840,7 +6292,7 @@ func (x *ValueSequence) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ValueSequence.ProtoReflect.Descriptor instead.
 func (*ValueSequence) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{66}
+	return file_sysml_proto_rawDescGZIP(), []int{70}
 }
 
 func (x *ValueSequence) GetElements() []*Value {
@@ -5874,7 +6326,7 @@ type Quantity struct {
 
 func (x *Quantity) Reset() {
 	*x = Quantity{}
-	mi := &file_sysml_proto_msgTypes[67]
+	mi := &file_sysml_proto_msgTypes[71]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5886,7 +6338,7 @@ func (x *Quantity) String() string {
 func (*Quantity) ProtoMessage() {}
 
 func (x *Quantity) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[67]
+	mi := &file_sysml_proto_msgTypes[71]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -5899,7 +6351,7 @@ func (x *Quantity) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Quantity.ProtoReflect.Descriptor instead.
 func (*Quantity) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{67}
+	return file_sysml_proto_rawDescGZIP(), []int{71}
 }
 
 func (x *Quantity) GetMagnitude() isQuantity_Magnitude {
@@ -5985,7 +6437,7 @@ type MeasurementRef struct {
 
 func (x *MeasurementRef) Reset() {
 	*x = MeasurementRef{}
-	mi := &file_sysml_proto_msgTypes[68]
+	mi := &file_sysml_proto_msgTypes[72]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -5997,7 +6449,7 @@ func (x *MeasurementRef) String() string {
 func (*MeasurementRef) ProtoMessage() {}
 
 func (x *MeasurementRef) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[68]
+	mi := &file_sysml_proto_msgTypes[72]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6010,7 +6462,7 @@ func (x *MeasurementRef) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use MeasurementRef.ProtoReflect.Descriptor instead.
 func (*MeasurementRef) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{68}
+	return file_sysml_proto_rawDescGZIP(), []int{72}
 }
 
 func (x *MeasurementRef) GetUnit() string {
@@ -6049,7 +6501,7 @@ type UnitTerm struct {
 
 func (x *UnitTerm) Reset() {
 	*x = UnitTerm{}
-	mi := &file_sysml_proto_msgTypes[69]
+	mi := &file_sysml_proto_msgTypes[73]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6061,7 +6513,7 @@ func (x *UnitTerm) String() string {
 func (*UnitTerm) ProtoMessage() {}
 
 func (x *UnitTerm) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[69]
+	mi := &file_sysml_proto_msgTypes[73]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6074,7 +6526,7 @@ func (x *UnitTerm) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use UnitTerm.ProtoReflect.Descriptor instead.
 func (*UnitTerm) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{69}
+	return file_sysml_proto_rawDescGZIP(), []int{73}
 }
 
 func (x *UnitTerm) GetScaleNum() float64 {
@@ -6110,7 +6562,7 @@ type UnitFactor struct {
 
 func (x *UnitFactor) Reset() {
 	*x = UnitFactor{}
-	mi := &file_sysml_proto_msgTypes[70]
+	mi := &file_sysml_proto_msgTypes[74]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6122,7 +6574,7 @@ func (x *UnitFactor) String() string {
 func (*UnitFactor) ProtoMessage() {}
 
 func (x *UnitFactor) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[70]
+	mi := &file_sysml_proto_msgTypes[74]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6135,7 +6587,7 @@ func (x *UnitFactor) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use UnitFactor.ProtoReflect.Descriptor instead.
 func (*UnitFactor) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{70}
+	return file_sysml_proto_rawDescGZIP(), []int{74}
 }
 
 func (x *UnitFactor) GetUnitId() string {
@@ -6167,7 +6619,7 @@ type Diagnostic struct {
 
 func (x *Diagnostic) Reset() {
 	*x = Diagnostic{}
-	mi := &file_sysml_proto_msgTypes[71]
+	mi := &file_sysml_proto_msgTypes[75]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6179,7 +6631,7 @@ func (x *Diagnostic) String() string {
 func (*Diagnostic) ProtoMessage() {}
 
 func (x *Diagnostic) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[71]
+	mi := &file_sysml_proto_msgTypes[75]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6192,7 +6644,7 @@ func (x *Diagnostic) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Diagnostic.ProtoReflect.Descriptor instead.
 func (*Diagnostic) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{71}
+	return file_sysml_proto_rawDescGZIP(), []int{75}
 }
 
 func (x *Diagnostic) GetSeverity() string {
@@ -6237,7 +6689,7 @@ type Span struct {
 
 func (x *Span) Reset() {
 	*x = Span{}
-	mi := &file_sysml_proto_msgTypes[72]
+	mi := &file_sysml_proto_msgTypes[76]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6249,7 +6701,7 @@ func (x *Span) String() string {
 func (*Span) ProtoMessage() {}
 
 func (x *Span) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[72]
+	mi := &file_sysml_proto_msgTypes[76]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6262,7 +6714,7 @@ func (x *Span) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Span.ProtoReflect.Descriptor instead.
 func (*Span) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{72}
+	return file_sysml_proto_rawDescGZIP(), []int{76}
 }
 
 func (x *Span) GetFile() string {
@@ -6310,7 +6762,7 @@ type ServerInfoRequest struct {
 
 func (x *ServerInfoRequest) Reset() {
 	*x = ServerInfoRequest{}
-	mi := &file_sysml_proto_msgTypes[73]
+	mi := &file_sysml_proto_msgTypes[77]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6322,7 +6774,7 @@ func (x *ServerInfoRequest) String() string {
 func (*ServerInfoRequest) ProtoMessage() {}
 
 func (x *ServerInfoRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[73]
+	mi := &file_sysml_proto_msgTypes[77]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6335,7 +6787,7 @@ func (x *ServerInfoRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ServerInfoRequest.ProtoReflect.Descriptor instead.
 func (*ServerInfoRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{73}
+	return file_sysml_proto_rawDescGZIP(), []int{77}
 }
 
 // ServerInfoResponse describes the running service.
@@ -6415,6 +6867,14 @@ type ServerInfoResponse struct {
 	//	               rather than read as another value.
 	//	"apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
 	//	               preserving everything the edit did not touch.
+	//	"edit_documents" - ApplyEdits edits a model of several documents as one
+	//	               batch for a request setting accept_documents, targets the
+	//	               document the request names, and answers each edited
+	//	               document by name in `documents`, each referrer of a refusal
+	//	               with its document in `referrers`, and each applied edit's
+	//	               `document`. Without it those fields are empty, a model of
+	//	               several documents is refused with FAILED_PRECONDITION, and
+	//	               a request naming a document is refused with UNIMPLEMENTED.
 	//	"document_query" - the RunDocumentQuery RPC runs a named document query
 	//	               and answers with typed rows.
 	//	"render_document" - the RenderDocument RPC renders a named document to
@@ -6458,7 +6918,7 @@ type ServerInfoResponse struct {
 
 func (x *ServerInfoResponse) Reset() {
 	*x = ServerInfoResponse{}
-	mi := &file_sysml_proto_msgTypes[74]
+	mi := &file_sysml_proto_msgTypes[78]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6470,7 +6930,7 @@ func (x *ServerInfoResponse) String() string {
 func (*ServerInfoResponse) ProtoMessage() {}
 
 func (x *ServerInfoResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[74]
+	mi := &file_sysml_proto_msgTypes[78]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6483,7 +6943,7 @@ func (x *ServerInfoResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ServerInfoResponse.ProtoReflect.Descriptor instead.
 func (*ServerInfoResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{74}
+	return file_sysml_proto_rawDescGZIP(), []int{78}
 }
 
 func (x *ServerInfoResponse) GetVersion() string {
@@ -6513,7 +6973,7 @@ type QueryRequest struct {
 
 func (x *QueryRequest) Reset() {
 	*x = QueryRequest{}
-	mi := &file_sysml_proto_msgTypes[75]
+	mi := &file_sysml_proto_msgTypes[79]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6525,7 +6985,7 @@ func (x *QueryRequest) String() string {
 func (*QueryRequest) ProtoMessage() {}
 
 func (x *QueryRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[75]
+	mi := &file_sysml_proto_msgTypes[79]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6538,7 +6998,7 @@ func (x *QueryRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use QueryRequest.ProtoReflect.Descriptor instead.
 func (*QueryRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{75}
+	return file_sysml_proto_rawDescGZIP(), []int{79}
 }
 
 func (x *QueryRequest) GetModelHash() string {
@@ -6575,7 +7035,7 @@ type QueryResponse struct {
 
 func (x *QueryResponse) Reset() {
 	*x = QueryResponse{}
-	mi := &file_sysml_proto_msgTypes[76]
+	mi := &file_sysml_proto_msgTypes[80]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6587,7 +7047,7 @@ func (x *QueryResponse) String() string {
 func (*QueryResponse) ProtoMessage() {}
 
 func (x *QueryResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[76]
+	mi := &file_sysml_proto_msgTypes[80]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6600,7 +7060,7 @@ func (x *QueryResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use QueryResponse.ProtoReflect.Descriptor instead.
 func (*QueryResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{76}
+	return file_sysml_proto_rawDescGZIP(), []int{80}
 }
 
 func (x *QueryResponse) GetElements() []*QueryResultElement {
@@ -6630,7 +7090,7 @@ type Query struct {
 
 func (x *Query) Reset() {
 	*x = Query{}
-	mi := &file_sysml_proto_msgTypes[77]
+	mi := &file_sysml_proto_msgTypes[81]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6642,7 +7102,7 @@ func (x *Query) String() string {
 func (*Query) ProtoMessage() {}
 
 func (x *Query) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[77]
+	mi := &file_sysml_proto_msgTypes[81]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6655,7 +7115,7 @@ func (x *Query) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Query.ProtoReflect.Descriptor instead.
 func (*Query) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{77}
+	return file_sysml_proto_rawDescGZIP(), []int{81}
 }
 
 func (x *Query) GetScope() []string {
@@ -6694,7 +7154,7 @@ type Constraint struct {
 
 func (x *Constraint) Reset() {
 	*x = Constraint{}
-	mi := &file_sysml_proto_msgTypes[78]
+	mi := &file_sysml_proto_msgTypes[82]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6706,7 +7166,7 @@ func (x *Constraint) String() string {
 func (*Constraint) ProtoMessage() {}
 
 func (x *Constraint) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[78]
+	mi := &file_sysml_proto_msgTypes[82]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6719,7 +7179,7 @@ func (x *Constraint) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Constraint.ProtoReflect.Descriptor instead.
 func (*Constraint) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{78}
+	return file_sysml_proto_rawDescGZIP(), []int{82}
 }
 
 func (x *Constraint) GetConstraint() isConstraint_Constraint {
@@ -6782,7 +7242,7 @@ type PrimitiveConstraint struct {
 
 func (x *PrimitiveConstraint) Reset() {
 	*x = PrimitiveConstraint{}
-	mi := &file_sysml_proto_msgTypes[79]
+	mi := &file_sysml_proto_msgTypes[83]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6794,7 +7254,7 @@ func (x *PrimitiveConstraint) String() string {
 func (*PrimitiveConstraint) ProtoMessage() {}
 
 func (x *PrimitiveConstraint) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[79]
+	mi := &file_sysml_proto_msgTypes[83]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6807,7 +7267,7 @@ func (x *PrimitiveConstraint) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use PrimitiveConstraint.ProtoReflect.Descriptor instead.
 func (*PrimitiveConstraint) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{79}
+	return file_sysml_proto_rawDescGZIP(), []int{83}
 }
 
 func (x *PrimitiveConstraint) GetInverse() bool {
@@ -6850,7 +7310,7 @@ type CompositeConstraint struct {
 
 func (x *CompositeConstraint) Reset() {
 	*x = CompositeConstraint{}
-	mi := &file_sysml_proto_msgTypes[80]
+	mi := &file_sysml_proto_msgTypes[84]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6862,7 +7322,7 @@ func (x *CompositeConstraint) String() string {
 func (*CompositeConstraint) ProtoMessage() {}
 
 func (x *CompositeConstraint) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[80]
+	mi := &file_sysml_proto_msgTypes[84]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6875,7 +7335,7 @@ func (x *CompositeConstraint) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use CompositeConstraint.ProtoReflect.Descriptor instead.
 func (*CompositeConstraint) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{80}
+	return file_sysml_proto_rawDescGZIP(), []int{84}
 }
 
 func (x *CompositeConstraint) GetOperator() CompositeOperator {
@@ -6907,7 +7367,7 @@ type QueryResultElement struct {
 
 func (x *QueryResultElement) Reset() {
 	*x = QueryResultElement{}
-	mi := &file_sysml_proto_msgTypes[81]
+	mi := &file_sysml_proto_msgTypes[85]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6919,7 +7379,7 @@ func (x *QueryResultElement) String() string {
 func (*QueryResultElement) ProtoMessage() {}
 
 func (x *QueryResultElement) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[81]
+	mi := &file_sysml_proto_msgTypes[85]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -6932,7 +7392,7 @@ func (x *QueryResultElement) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use QueryResultElement.ProtoReflect.Descriptor instead.
 func (*QueryResultElement) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{81}
+	return file_sysml_proto_rawDescGZIP(), []int{85}
 }
 
 func (x *QueryResultElement) GetId() string {
@@ -6977,7 +7437,7 @@ type SweepRange struct {
 
 func (x *SweepRange) Reset() {
 	*x = SweepRange{}
-	mi := &file_sysml_proto_msgTypes[82]
+	mi := &file_sysml_proto_msgTypes[86]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -6989,7 +7449,7 @@ func (x *SweepRange) String() string {
 func (*SweepRange) ProtoMessage() {}
 
 func (x *SweepRange) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[82]
+	mi := &file_sysml_proto_msgTypes[86]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7002,7 +7462,7 @@ func (x *SweepRange) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use SweepRange.ProtoReflect.Descriptor instead.
 func (*SweepRange) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{82}
+	return file_sysml_proto_rawDescGZIP(), []int{86}
 }
 
 func (x *SweepRange) GetParameter() string {
@@ -7042,7 +7502,8 @@ type RunSweepRequest struct {
 	ModelHash string                 `protobuf:"bytes,1,opt,name=model_hash,json=modelHash,proto3" json:"model_hash,omitempty"`
 	// FQN of the analysis case or calc, definition or usage.
 	SymbolId string `protobuf:"bytes,2,opt,name=symbol_id,json=symbolId,proto3" json:"symbol_id,omitempty"`
-	// Optional FQN of a part/usage to instantiate as an analysis case's subject.
+	// Optional FQN of a part/usage to instantiate as an analysis case's subject,
+	// or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
 	SubjectSymbolId string `protobuf:"bytes,3,opt,name=subject_symbol_id,json=subjectSymbolId,proto3" json:"subject_symbol_id,omitempty"`
 	// Positional arguments for the target's input parameters, in declaration
 	// order, as every row binds them.
@@ -7066,7 +7527,7 @@ type RunSweepRequest struct {
 
 func (x *RunSweepRequest) Reset() {
 	*x = RunSweepRequest{}
-	mi := &file_sysml_proto_msgTypes[83]
+	mi := &file_sysml_proto_msgTypes[87]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7078,7 +7539,7 @@ func (x *RunSweepRequest) String() string {
 func (*RunSweepRequest) ProtoMessage() {}
 
 func (x *RunSweepRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[83]
+	mi := &file_sysml_proto_msgTypes[87]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7091,7 +7552,7 @@ func (x *RunSweepRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RunSweepRequest.ProtoReflect.Descriptor instead.
 func (*RunSweepRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{83}
+	return file_sysml_proto_rawDescGZIP(), []int{87}
 }
 
 func (x *RunSweepRequest) GetModelHash() string {
@@ -7185,7 +7646,7 @@ type SweepRow struct {
 
 func (x *SweepRow) Reset() {
 	*x = SweepRow{}
-	mi := &file_sysml_proto_msgTypes[84]
+	mi := &file_sysml_proto_msgTypes[88]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7197,7 +7658,7 @@ func (x *SweepRow) String() string {
 func (*SweepRow) ProtoMessage() {}
 
 func (x *SweepRow) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[84]
+	mi := &file_sysml_proto_msgTypes[88]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7210,7 +7671,7 @@ func (x *SweepRow) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use SweepRow.ProtoReflect.Descriptor instead.
 func (*SweepRow) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{84}
+	return file_sysml_proto_rawDescGZIP(), []int{88}
 }
 
 func (x *SweepRow) GetInputs() []*CalcOutput {
@@ -7296,7 +7757,7 @@ type RunSweepResponse struct {
 
 func (x *RunSweepResponse) Reset() {
 	*x = RunSweepResponse{}
-	mi := &file_sysml_proto_msgTypes[85]
+	mi := &file_sysml_proto_msgTypes[89]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7308,7 +7769,7 @@ func (x *RunSweepResponse) String() string {
 func (*RunSweepResponse) ProtoMessage() {}
 
 func (x *RunSweepResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[85]
+	mi := &file_sysml_proto_msgTypes[89]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7321,7 +7782,7 @@ func (x *RunSweepResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RunSweepResponse.ProtoReflect.Descriptor instead.
 func (*RunSweepResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{85}
+	return file_sysml_proto_rawDescGZIP(), []int{89}
 }
 
 func (x *RunSweepResponse) GetRows() []*SweepRow {
@@ -7404,7 +7865,9 @@ func (x *RunSweepResponse) GetBounds() []*Bound {
 // RunDocumentQueryRequest runs a named document query — a calc def
 // specializing DocumentQueries::Query — against a model the service already
 // parsed, binding its entry parameters. It answers as %run-query does, but with
-// typed rows rather than formatted lines.
+// typed rows rather than formatted lines. The query runs over the objects
+// Instantiate created for the model, which a binding may name (DocumentObject)
+// and `DocumentQueries::Objects` and `Verdicts` reach.
 type RunDocumentQueryRequest struct {
 	state     protoimpl.MessageState `protogen:"open.v1"`
 	ModelHash string                 `protobuf:"bytes,1,opt,name=model_hash,json=modelHash,proto3" json:"model_hash,omitempty"` // from ParseFile response
@@ -7420,7 +7883,7 @@ type RunDocumentQueryRequest struct {
 
 func (x *RunDocumentQueryRequest) Reset() {
 	*x = RunDocumentQueryRequest{}
-	mi := &file_sysml_proto_msgTypes[86]
+	mi := &file_sysml_proto_msgTypes[90]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7432,7 +7895,7 @@ func (x *RunDocumentQueryRequest) String() string {
 func (*RunDocumentQueryRequest) ProtoMessage() {}
 
 func (x *RunDocumentQueryRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[86]
+	mi := &file_sysml_proto_msgTypes[90]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7445,7 +7908,7 @@ func (x *RunDocumentQueryRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RunDocumentQueryRequest.ProtoReflect.Descriptor instead.
 func (*RunDocumentQueryRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{86}
+	return file_sysml_proto_rawDescGZIP(), []int{90}
 }
 
 func (x *RunDocumentQueryRequest) GetModelHash() string {
@@ -7480,7 +7943,7 @@ type DocumentQueryBinding struct {
 
 func (x *DocumentQueryBinding) Reset() {
 	*x = DocumentQueryBinding{}
-	mi := &file_sysml_proto_msgTypes[87]
+	mi := &file_sysml_proto_msgTypes[91]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7492,7 +7955,7 @@ func (x *DocumentQueryBinding) String() string {
 func (*DocumentQueryBinding) ProtoMessage() {}
 
 func (x *DocumentQueryBinding) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[87]
+	mi := &file_sysml_proto_msgTypes[91]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7505,7 +7968,7 @@ func (x *DocumentQueryBinding) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use DocumentQueryBinding.ProtoReflect.Descriptor instead.
 func (*DocumentQueryBinding) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{87}
+	return file_sysml_proto_rawDescGZIP(), []int{91}
 }
 
 func (x *DocumentQueryBinding) GetParameter() string {
@@ -7523,9 +7986,9 @@ func (x *DocumentQueryBinding) GetValues() []*DocumentValue {
 }
 
 // DocumentValue is one typed document-query value. A request binds a model
-// element by qualified name in element_id; a response also says what the
-// element is in element_type. `infinity` denotes an unbounded multiplicity and
-// is only ever answered, never bound.
+// element by qualified name in element_id, or an object the service holds in
+// object; a response also says what the element is in element_type. `infinity`
+// denotes an unbounded multiplicity and is only ever answered, never bound.
 type DocumentValue struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// Types that are valid to be assigned to Kind:
@@ -7537,6 +8000,10 @@ type DocumentValue struct {
 	//	*DocumentValue_BoolValue
 	//	*DocumentValue_Infinity
 	//	*DocumentValue_Quantity
+	//	*DocumentValue_Verdict
+	//	*DocumentValue_Object
+	//	*DocumentValue_State
+	//	*DocumentValue_Event
 	Kind isDocumentValue_Kind `protobuf_oneof:"kind"`
 	// Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
 	ElementType   string `protobuf:"bytes,7,opt,name=element_type,json=elementType,proto3" json:"element_type,omitempty"`
@@ -7546,7 +8013,7 @@ type DocumentValue struct {
 
 func (x *DocumentValue) Reset() {
 	*x = DocumentValue{}
-	mi := &file_sysml_proto_msgTypes[88]
+	mi := &file_sysml_proto_msgTypes[92]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7558,7 +8025,7 @@ func (x *DocumentValue) String() string {
 func (*DocumentValue) ProtoMessage() {}
 
 func (x *DocumentValue) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[88]
+	mi := &file_sysml_proto_msgTypes[92]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7571,7 +8038,7 @@ func (x *DocumentValue) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use DocumentValue.ProtoReflect.Descriptor instead.
 func (*DocumentValue) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{88}
+	return file_sysml_proto_rawDescGZIP(), []int{92}
 }
 
 func (x *DocumentValue) GetKind() isDocumentValue_Kind {
@@ -7644,6 +8111,42 @@ func (x *DocumentValue) GetQuantity() *Quantity {
 	return nil
 }
 
+func (x *DocumentValue) GetVerdict() *DocumentVerdict {
+	if x != nil {
+		if x, ok := x.Kind.(*DocumentValue_Verdict); ok {
+			return x.Verdict
+		}
+	}
+	return nil
+}
+
+func (x *DocumentValue) GetObject() *DocumentObject {
+	if x != nil {
+		if x, ok := x.Kind.(*DocumentValue_Object); ok {
+			return x.Object
+		}
+	}
+	return nil
+}
+
+func (x *DocumentValue) GetState() *DocumentState {
+	if x != nil {
+		if x, ok := x.Kind.(*DocumentValue_State); ok {
+			return x.State
+		}
+	}
+	return nil
+}
+
+func (x *DocumentValue) GetEvent() *DocumentEvent {
+	if x != nil {
+		if x, ok := x.Kind.(*DocumentValue_Event); ok {
+			return x.Event
+		}
+	}
+	return nil
+}
+
 func (x *DocumentValue) GetElementType() string {
 	if x != nil {
 		return x.ElementType
@@ -7683,6 +8186,22 @@ type DocumentValue_Quantity struct {
 	Quantity *Quantity `protobuf:"bytes,8,opt,name=quantity,proto3,oneof"` // magnitude in a unit, `2290000 [kg]`
 }
 
+type DocumentValue_Verdict struct {
+	Verdict *DocumentVerdict `protobuf:"bytes,9,opt,name=verdict,proto3,oneof"` // a row Verdicts answered; answered, never bound
+}
+
+type DocumentValue_Object struct {
+	Object *DocumentObject `protobuf:"bytes,10,opt,name=object,proto3,oneof"` // an object Instantiate created; bound and answered
+}
+
+type DocumentValue_State struct {
+	State *DocumentState `protobuf:"bytes,11,opt,name=state,proto3,oneof"` // a row States answered; answered, never bound
+}
+
+type DocumentValue_Event struct {
+	Event *DocumentEvent `protobuf:"bytes,12,opt,name=event,proto3,oneof"` // a row Events answered; answered, never bound
+}
+
 func (*DocumentValue_ElementId) isDocumentValue_Kind() {}
 
 func (*DocumentValue_StringValue) isDocumentValue_Kind() {}
@@ -7697,6 +8216,466 @@ func (*DocumentValue_Infinity) isDocumentValue_Kind() {}
 
 func (*DocumentValue_Quantity) isDocumentValue_Kind() {}
 
+func (*DocumentValue_Verdict) isDocumentValue_Kind() {}
+
+func (*DocumentValue_Object) isDocumentValue_Kind() {}
+
+func (*DocumentValue_State) isDocumentValue_Kind() {}
+
+func (*DocumentValue_Event) isDocumentValue_Kind() {}
+
+// DocumentObject is an object the service holds for the model, created by
+// Instantiate, as a query binds and answers it. A request names it by path
+// when path is set, and by instance_id otherwise; a request setting both must
+// name one object by both. A response answers both, and the element the object
+// stands for.
+type DocumentObject struct {
+	state protoimpl.MessageState `protogen:"open.v1"`
+	// The object's id, as Instantiate answered it. Fails with NOT_FOUND when no
+	// object of the model has it.
+	InstanceId int64 `protobuf:"varint,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"`
+	// The object by the label a session reaches it under: the qualified name it
+	// was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+	// feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+	// count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+	// a segment reaches no object, and NOT_FOUND when the name it starts from has
+	// no object.
+	Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"`
+	// The usage the object is held under — its own definition or usage for an
+	// object Instantiate created — as an element value with its qualified name
+	// and metamodel type. Answered, ignored when bound.
+	Element       *DocumentValue `protobuf:"bytes,3,opt,name=element,proto3" json:"element,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *DocumentObject) Reset() {
+	*x = DocumentObject{}
+	mi := &file_sysml_proto_msgTypes[93]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *DocumentObject) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DocumentObject) ProtoMessage() {}
+
+func (x *DocumentObject) ProtoReflect() protoreflect.Message {
+	mi := &file_sysml_proto_msgTypes[93]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use DocumentObject.ProtoReflect.Descriptor instead.
+func (*DocumentObject) Descriptor() ([]byte, []int) {
+	return file_sysml_proto_rawDescGZIP(), []int{93}
+}
+
+func (x *DocumentObject) GetInstanceId() int64 {
+	if x != nil {
+		return x.InstanceId
+	}
+	return 0
+}
+
+func (x *DocumentObject) GetPath() string {
+	if x != nil {
+		return x.Path
+	}
+	return ""
+}
+
+func (x *DocumentObject) GetElement() *DocumentValue {
+	if x != nil {
+		return x.Element
+	}
+	return nil
+}
+
+// DocumentVerdict is one row a `Verdicts` query answered: an assertion checked
+// on the object at `path`, which the row stands for as its `element`.
+type DocumentVerdict struct {
+	state protoimpl.MessageState `protogen:"open.v1"`
+	// The assertion checked, as an element value: the constraint, requirement,
+	// satisfy usage or verification case, its element_id empty when anonymous.
+	Assertion *DocumentValue `protobuf:"bytes,1,opt,name=assertion,proto3" json:"assertion,omitempty"`
+	// "constraint", "requirement", "satisfaction" or "verification".
+	Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"`
+	// The assertion as written ("assert constraint massKnown", "satisfy Range by
+	// cruise", "verification Tests::massTest"), naming an anonymous one.
+	Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"`
+	// The object checked, by path from the element the query was bound to
+	// ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+	Path string `protobuf:"bytes,4,opt,name=path,proto3" json:"path,omitempty"`
+	// "holds", "violated" or "undecided".
+	Verdict string `protobuf:"bytes,5,opt,name=verdict,proto3" json:"verdict,omitempty"`
+	// The condition that evaluated to false, as written; empty otherwise.
+	Condition string `protobuf:"bytes,6,opt,name=condition,proto3" json:"condition,omitempty"`
+	// Why the assertion is violated or undecided; empty when it holds.
+	Reason string `protobuf:"bytes,7,opt,name=reason,proto3" json:"reason,omitempty"`
+	// The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+	// verification cases verifying the requirement the row is about; a
+	// verification row's own kind. Empty for a constraint.
+	Verification  []string `protobuf:"bytes,8,rep,name=verification,proto3" json:"verification,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *DocumentVerdict) Reset() {
+	*x = DocumentVerdict{}
+	mi := &file_sysml_proto_msgTypes[94]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *DocumentVerdict) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DocumentVerdict) ProtoMessage() {}
+
+func (x *DocumentVerdict) ProtoReflect() protoreflect.Message {
+	mi := &file_sysml_proto_msgTypes[94]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use DocumentVerdict.ProtoReflect.Descriptor instead.
+func (*DocumentVerdict) Descriptor() ([]byte, []int) {
+	return file_sysml_proto_rawDescGZIP(), []int{94}
+}
+
+func (x *DocumentVerdict) GetAssertion() *DocumentValue {
+	if x != nil {
+		return x.Assertion
+	}
+	return nil
+}
+
+func (x *DocumentVerdict) GetKind() string {
+	if x != nil {
+		return x.Kind
+	}
+	return ""
+}
+
+func (x *DocumentVerdict) GetText() string {
+	if x != nil {
+		return x.Text
+	}
+	return ""
+}
+
+func (x *DocumentVerdict) GetPath() string {
+	if x != nil {
+		return x.Path
+	}
+	return ""
+}
+
+func (x *DocumentVerdict) GetVerdict() string {
+	if x != nil {
+		return x.Verdict
+	}
+	return ""
+}
+
+func (x *DocumentVerdict) GetCondition() string {
+	if x != nil {
+		return x.Condition
+	}
+	return ""
+}
+
+func (x *DocumentVerdict) GetReason() string {
+	if x != nil {
+		return x.Reason
+	}
+	return ""
+}
+
+func (x *DocumentVerdict) GetVerification() []string {
+	if x != nil {
+		return x.Verification
+	}
+	return nil
+}
+
+// DocumentState is one row a `States` query answered: an active leaf state of
+// the state machine `object` exhibits, which the row stands for as its `element`
+// when the model declares it.
+type DocumentState struct {
+	state protoimpl.MessageState `protogen:"open.v1"`
+	// The object in the state, with the path the session reaches it by.
+	Object *DocumentObject `protobuf:"bytes,1,opt,name=object,proto3" json:"object,omitempty"`
+	// The state machine, by the name the object exhibits it under (`lp`), else
+	// its declared name.
+	Machine string `protobuf:"bytes,2,opt,name=machine,proto3" json:"machine,omitempty"`
+	// The leaf state's name.
+	Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
+	// The leaf state's path in its machine, composite states first (`on.dim`).
+	StatePath string `protobuf:"bytes,4,opt,name=state_path,json=statePath,proto3" json:"state_path,omitempty"`
+	// The leaf state's declaration as an element value; empty when the machine
+	// declares no element for it.
+	State *DocumentValue `protobuf:"bytes,5,opt,name=state,proto3" json:"state,omitempty"`
+	// The orthogonal region the leaf is declared in; empty outside one.
+	Region string `protobuf:"bytes,6,opt,name=region,proto3" json:"region,omitempty"`
+	// The composite states enclosing the leaf, outermost first; each is active.
+	Enclosing     []string `protobuf:"bytes,7,rep,name=enclosing,proto3" json:"enclosing,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *DocumentState) Reset() {
+	*x = DocumentState{}
+	mi := &file_sysml_proto_msgTypes[95]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *DocumentState) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DocumentState) ProtoMessage() {}
+
+func (x *DocumentState) ProtoReflect() protoreflect.Message {
+	mi := &file_sysml_proto_msgTypes[95]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use DocumentState.ProtoReflect.Descriptor instead.
+func (*DocumentState) Descriptor() ([]byte, []int) {
+	return file_sysml_proto_rawDescGZIP(), []int{95}
+}
+
+func (x *DocumentState) GetObject() *DocumentObject {
+	if x != nil {
+		return x.Object
+	}
+	return nil
+}
+
+func (x *DocumentState) GetMachine() string {
+	if x != nil {
+		return x.Machine
+	}
+	return ""
+}
+
+func (x *DocumentState) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *DocumentState) GetStatePath() string {
+	if x != nil {
+		return x.StatePath
+	}
+	return ""
+}
+
+func (x *DocumentState) GetState() *DocumentValue {
+	if x != nil {
+		return x.State
+	}
+	return nil
+}
+
+func (x *DocumentState) GetRegion() string {
+	if x != nil {
+		return x.Region
+	}
+	return ""
+}
+
+func (x *DocumentState) GetEnclosing() []string {
+	if x != nil {
+		return x.Enclosing
+	}
+	return nil
+}
+
+// DocumentEvent is one row an `Events` query answered: one record of the
+// session's trace, in the order the run made it.
+type DocumentEvent struct {
+	state protoimpl.MessageState `protogen:"open.v1"`
+	// "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+	Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"`
+	// The clock's instant when the record was made: a quantity in the clock's
+	// unit when the library reduces one, else a bare real of clock units.
+	Time *DocumentValue `protobuf:"bytes,2,opt,name=time,proto3" json:"time,omitempty"`
+	// The object whose behavior made the record; unset for the run's own
+	// records (a due-order choice, a message posted from outside the model).
+	Object *DocumentObject `protobuf:"bytes,3,opt,name=object,proto3" json:"object,omitempty"`
+	// The behavior the record is about, by the name the object exhibits it
+	// under, else its declared name; empty for the run's own records.
+	Machine string `protobuf:"bytes,4,opt,name=machine,proto3" json:"machine,omitempty"`
+	// The state entered, exited or stepped by an entry, exit or do record.
+	State string `protobuf:"bytes,5,opt,name=state,proto3" json:"state,omitempty"`
+	// A fired transition's source and target states.
+	From string `protobuf:"bytes,6,opt,name=from,proto3" json:"from,omitempty"`
+	To   string `protobuf:"bytes,7,opt,name=to,proto3" json:"to,omitempty"`
+	// The object a send was addressed to; unset for every other kind and a
+	// send addressed to no object.
+	Target *DocumentObject `protobuf:"bytes,8,opt,name=target,proto3" json:"target,omitempty"`
+	// The signal or event accepted or sent, or the trigger a transition fired on.
+	Event string `protobuf:"bytes,9,opt,name=event,proto3" json:"event,omitempty"`
+	// An accept's payload, one `name = value` entry per parameter in name order.
+	Payload []string `protobuf:"bytes,10,rep,name=payload,proto3" json:"payload,omitempty"`
+	// A choice's alternatives, in the order they were offered, and the one taken.
+	Alternatives []string `protobuf:"bytes,11,rep,name=alternatives,proto3" json:"alternatives,omitempty"`
+	Taken        string   `protobuf:"bytes,12,opt,name=taken,proto3" json:"taken,omitempty"`
+	// The line the trace prints for the record.
+	Text          string `protobuf:"bytes,13,opt,name=text,proto3" json:"text,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *DocumentEvent) Reset() {
+	*x = DocumentEvent{}
+	mi := &file_sysml_proto_msgTypes[96]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *DocumentEvent) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DocumentEvent) ProtoMessage() {}
+
+func (x *DocumentEvent) ProtoReflect() protoreflect.Message {
+	mi := &file_sysml_proto_msgTypes[96]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use DocumentEvent.ProtoReflect.Descriptor instead.
+func (*DocumentEvent) Descriptor() ([]byte, []int) {
+	return file_sysml_proto_rawDescGZIP(), []int{96}
+}
+
+func (x *DocumentEvent) GetKind() string {
+	if x != nil {
+		return x.Kind
+	}
+	return ""
+}
+
+func (x *DocumentEvent) GetTime() *DocumentValue {
+	if x != nil {
+		return x.Time
+	}
+	return nil
+}
+
+func (x *DocumentEvent) GetObject() *DocumentObject {
+	if x != nil {
+		return x.Object
+	}
+	return nil
+}
+
+func (x *DocumentEvent) GetMachine() string {
+	if x != nil {
+		return x.Machine
+	}
+	return ""
+}
+
+func (x *DocumentEvent) GetState() string {
+	if x != nil {
+		return x.State
+	}
+	return ""
+}
+
+func (x *DocumentEvent) GetFrom() string {
+	if x != nil {
+		return x.From
+	}
+	return ""
+}
+
+func (x *DocumentEvent) GetTo() string {
+	if x != nil {
+		return x.To
+	}
+	return ""
+}
+
+func (x *DocumentEvent) GetTarget() *DocumentObject {
+	if x != nil {
+		return x.Target
+	}
+	return nil
+}
+
+func (x *DocumentEvent) GetEvent() string {
+	if x != nil {
+		return x.Event
+	}
+	return ""
+}
+
+func (x *DocumentEvent) GetPayload() []string {
+	if x != nil {
+		return x.Payload
+	}
+	return nil
+}
+
+func (x *DocumentEvent) GetAlternatives() []string {
+	if x != nil {
+		return x.Alternatives
+	}
+	return nil
+}
+
+func (x *DocumentEvent) GetTaken() string {
+	if x != nil {
+		return x.Taken
+	}
+	return ""
+}
+
+func (x *DocumentEvent) GetText() string {
+	if x != nil {
+		return x.Text
+	}
+	return ""
+}
+
 // DocumentQueryColumn is one projected property, in projection order.
 type DocumentQueryColumn struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
@@ -7707,7 +8686,7 @@ type DocumentQueryColumn struct {
 
 func (x *DocumentQueryColumn) Reset() {
 	*x = DocumentQueryColumn{}
-	mi := &file_sysml_proto_msgTypes[89]
+	mi := &file_sysml_proto_msgTypes[97]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7719,7 +8698,7 @@ func (x *DocumentQueryColumn) String() string {
 func (*DocumentQueryColumn) ProtoMessage() {}
 
 func (x *DocumentQueryColumn) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[89]
+	mi := &file_sysml_proto_msgTypes[97]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7732,7 +8711,7 @@ func (x *DocumentQueryColumn) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use DocumentQueryColumn.ProtoReflect.Descriptor instead.
 func (*DocumentQueryColumn) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{89}
+	return file_sysml_proto_rawDescGZIP(), []int{97}
 }
 
 func (x *DocumentQueryColumn) GetName() string {
@@ -7752,7 +8731,7 @@ type DocumentQueryCell struct {
 
 func (x *DocumentQueryCell) Reset() {
 	*x = DocumentQueryCell{}
-	mi := &file_sysml_proto_msgTypes[90]
+	mi := &file_sysml_proto_msgTypes[98]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7764,7 +8743,7 @@ func (x *DocumentQueryCell) String() string {
 func (*DocumentQueryCell) ProtoMessage() {}
 
 func (x *DocumentQueryCell) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[90]
+	mi := &file_sysml_proto_msgTypes[98]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7777,7 +8756,7 @@ func (x *DocumentQueryCell) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use DocumentQueryCell.ProtoReflect.Descriptor instead.
 func (*DocumentQueryCell) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{90}
+	return file_sysml_proto_rawDescGZIP(), []int{98}
 }
 
 func (x *DocumentQueryCell) GetValues() []*DocumentValue {
@@ -7791,7 +8770,10 @@ func (x *DocumentQueryCell) GetValues() []*DocumentValue {
 // column in column order.
 type DocumentQueryRow struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
-	// The selected element itself, an element value with its qualified name.
+	// The selected element itself, an element value with its qualified name; an
+	// object value for a row over an object the service holds; a verdict value
+	// for a row `Verdicts` answered; a state value for a row `States` answered;
+	// an event value for a row `Events` answered.
 	Element       *DocumentValue       `protobuf:"bytes,1,opt,name=element,proto3" json:"element,omitempty"`
 	Cells         []*DocumentQueryCell `protobuf:"bytes,2,rep,name=cells,proto3" json:"cells,omitempty"`
 	unknownFields protoimpl.UnknownFields
@@ -7800,7 +8782,7 @@ type DocumentQueryRow struct {
 
 func (x *DocumentQueryRow) Reset() {
 	*x = DocumentQueryRow{}
-	mi := &file_sysml_proto_msgTypes[91]
+	mi := &file_sysml_proto_msgTypes[99]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7812,7 +8794,7 @@ func (x *DocumentQueryRow) String() string {
 func (*DocumentQueryRow) ProtoMessage() {}
 
 func (x *DocumentQueryRow) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[91]
+	mi := &file_sysml_proto_msgTypes[99]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7825,7 +8807,7 @@ func (x *DocumentQueryRow) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use DocumentQueryRow.ProtoReflect.Descriptor instead.
 func (*DocumentQueryRow) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{91}
+	return file_sysml_proto_rawDescGZIP(), []int{99}
 }
 
 func (x *DocumentQueryRow) GetElement() *DocumentValue {
@@ -7856,7 +8838,7 @@ type RunDocumentQueryResponse struct {
 
 func (x *RunDocumentQueryResponse) Reset() {
 	*x = RunDocumentQueryResponse{}
-	mi := &file_sysml_proto_msgTypes[92]
+	mi := &file_sysml_proto_msgTypes[100]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7868,7 +8850,7 @@ func (x *RunDocumentQueryResponse) String() string {
 func (*RunDocumentQueryResponse) ProtoMessage() {}
 
 func (x *RunDocumentQueryResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[92]
+	mi := &file_sysml_proto_msgTypes[100]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7881,7 +8863,7 @@ func (x *RunDocumentQueryResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RunDocumentQueryResponse.ProtoReflect.Descriptor instead.
 func (*RunDocumentQueryResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{92}
+	return file_sysml_proto_rawDescGZIP(), []int{100}
 }
 
 func (x *RunDocumentQueryResponse) GetColumns() []*DocumentQueryColumn {
@@ -7899,21 +8881,26 @@ func (x *RunDocumentQueryResponse) GetRows() []*DocumentQueryRow {
 }
 
 // RenderDocumentRequest renders a named document — a part def specializing
-// DocumentQueries::Document — to Markdown. A document binds its queries'
-// parameters in the model, so the request carries none.
+// DocumentQueries::Document — to Markdown or HTML. A document binds its
+// queries' parameters in the model, so the request carries none.
 type RenderDocumentRequest struct {
 	state     protoimpl.MessageState `protogen:"open.v1"`
 	ModelHash string                 `protobuf:"bytes,1,opt,name=model_hash,json=modelHash,proto3" json:"model_hash,omitempty"` // from ParseFile response
 	// FQN of the document to render. Fails with NOT_FOUND when the model does
 	// not declare it, and INVALID_ARGUMENT when it declares something else.
-	DocumentId    string `protobuf:"bytes,2,opt,name=document_id,json=documentId,proto3" json:"document_id,omitempty"`
+	DocumentId string `protobuf:"bytes,2,opt,name=document_id,json=documentId,proto3" json:"document_id,omitempty"`
+	// Form to render: "markdown" (the default when empty) or "html", the
+	// standalone page with the default stylesheet that the CLI's -doc-form html
+	// writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+	// converter toolchain and is not offered here.
+	Form          string `protobuf:"bytes,3,opt,name=form,proto3" json:"form,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
 
 func (x *RenderDocumentRequest) Reset() {
 	*x = RenderDocumentRequest{}
-	mi := &file_sysml_proto_msgTypes[93]
+	mi := &file_sysml_proto_msgTypes[101]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7925,7 +8912,7 @@ func (x *RenderDocumentRequest) String() string {
 func (*RenderDocumentRequest) ProtoMessage() {}
 
 func (x *RenderDocumentRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[93]
+	mi := &file_sysml_proto_msgTypes[101]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7938,7 +8925,7 @@ func (x *RenderDocumentRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RenderDocumentRequest.ProtoReflect.Descriptor instead.
 func (*RenderDocumentRequest) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{93}
+	return file_sysml_proto_rawDescGZIP(), []int{101}
 }
 
 func (x *RenderDocumentRequest) GetModelHash() string {
@@ -7955,18 +8942,27 @@ func (x *RenderDocumentRequest) GetDocumentId() string {
 	return ""
 }
 
-// RenderDocumentResponse carries the rendered Markdown, byte-for-byte what the
-// CLI's -render-document writes.
+func (x *RenderDocumentRequest) GetForm() string {
+	if x != nil {
+		return x.Form
+	}
+	return ""
+}
+
+// RenderDocumentResponse carries the rendered document in the form requested,
+// byte-for-byte what the CLI's -render-document writes: markdown for the
+// Markdown form, html for the HTML form, the other left empty.
 type RenderDocumentResponse struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Markdown      string                 `protobuf:"bytes,1,opt,name=markdown,proto3" json:"markdown,omitempty"`
+	Html          string                 `protobuf:"bytes,2,opt,name=html,proto3" json:"html,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
 
 func (x *RenderDocumentResponse) Reset() {
 	*x = RenderDocumentResponse{}
-	mi := &file_sysml_proto_msgTypes[94]
+	mi := &file_sysml_proto_msgTypes[102]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -7978,7 +8974,7 @@ func (x *RenderDocumentResponse) String() string {
 func (*RenderDocumentResponse) ProtoMessage() {}
 
 func (x *RenderDocumentResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_sysml_proto_msgTypes[94]
+	mi := &file_sysml_proto_msgTypes[102]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -7991,7 +8987,7 @@ func (x *RenderDocumentResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RenderDocumentResponse.ProtoReflect.Descriptor instead.
 func (*RenderDocumentResponse) Descriptor() ([]byte, []int) {
-	return file_sysml_proto_rawDescGZIP(), []int{94}
+	return file_sysml_proto_rawDescGZIP(), []int{102}
 }
 
 func (x *RenderDocumentResponse) GetMarkdown() string {
@@ -8001,11 +8997,18 @@ func (x *RenderDocumentResponse) GetMarkdown() string {
 	return ""
 }
 
+func (x *RenderDocumentResponse) GetHtml() string {
+	if x != nil {
+		return x.Html
+	}
+	return ""
+}
+
 var File_sysml_proto protoreflect.FileDescriptor
 
 const file_sysml_proto_rawDesc = "" +
 	"\n" +
-	"\vsysml.proto\x12\x05sysml\"\xa9\x03\n" +
+	"\vsysml.proto\x12\x05sysml\"\xce\x03\n" +
 	"\aVerdict\x12\x12\n" +
 	"\x04kind\x18\x01 \x01(\tR\x04kind\x12\x1d\n" +
 	"\n" +
@@ -8022,7 +9025,8 @@ const file_sysml_proto_rawDesc = "" +
 	" \x01(\tR\rrequirementId\x12\x16\n" +
 	"\x06engine\x18\v \x01(\tR\x06engine\x12\x1a\n" +
 	"\bstrength\x18\f \x01(\tR\bstrength\x12$\n" +
-	"\x06bounds\x18\r \x03(\v2\f.sysml.BoundR\x06bounds\"K\n" +
+	"\x06bounds\x18\r \x03(\v2\f.sysml.BoundR\x06bounds\x12#\n" +
+	"\rinstance_path\x18\x0e \x01(\tR\finstancePath\"K\n" +
 	"\x05Bound\x12\x12\n" +
 	"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
 	"\x05limit\x18\x02 \x01(\x03R\x05limit\x12\x18\n" +
@@ -8067,7 +9071,21 @@ const file_sysml_proto_rawDesc = "" +
 	"\x05error\x18\x03 \x01(\tR\x05error\x123\n" +
 	"\vdiagnostics\x18\x04 \x03(\v2\x11.sysml.DiagnosticR\vdiagnostics\x12;\n" +
 	"\x0efailure_reason\x18\x05 \x01(\x0e2\x14.sysml.FailureReasonR\rfailureReason\x12O\n" +
-	"\x15verification_verdicts\x18\x06 \x03(\v2\x1a.sysml.VerificationVerdictR\x14verificationVerdicts\"\x95\x01\n" +
+	"\x15verification_verdicts\x18\x06 \x03(\v2\x1a.sysml.VerificationVerdictR\x14verificationVerdicts\"m\n" +
+	"\x17ValidateInstanceRequest\x12\x1d\n" +
+	"\n" +
+	"model_hash\x18\x01 \x01(\tR\tmodelHash\x12\x1b\n" +
+	"\tsymbol_id\x18\x02 \x01(\tR\bsymbolId\x12\x16\n" +
+	"\x06engine\x18\x03 \x01(\tR\x06engine\"\x92\x03\n" +
+	"\x18ValidateInstanceResponse\x12*\n" +
+	"\bverdicts\x18\x01 \x03(\v2\x0e.sysml.VerdictR\bverdicts\x12(\n" +
+	"\asummary\x18\x02 \x01(\v2\x0e.sysml.VerdictR\asummary\x12-\n" +
+	"\tinstances\x18\x03 \x03(\v2\x0f.sysml.InstanceR\tinstances\x12\x14\n" +
+	"\x05error\x18\x04 \x01(\tR\x05error\x123\n" +
+	"\vdiagnostics\x18\x05 \x03(\v2\x11.sysml.DiagnosticR\vdiagnostics\x12;\n" +
+	"\x0efailure_reason\x18\x06 \x01(\x0e2\x14.sysml.FailureReasonR\rfailureReason\x12O\n" +
+	"\x15verification_verdicts\x18\a \x03(\v2\x1a.sysml.VerificationVerdictR\x14verificationVerdicts\x12\x18\n" +
+	"\abounded\x18\b \x01(\bR\abounded\"\x95\x01\n" +
 	"\x13EvaluateCalcRequest\x12\x1d\n" +
 	"\n" +
 	"model_hash\x18\x01 \x01(\tR\tmodelHash\x12\x1b\n" +
@@ -8121,7 +9139,7 @@ const file_sysml_proto_rawDesc = "" +
 	" \x03(\v2\x15.sysml.CaseEvaluationR\vevaluations\x12\x16\n" +
 	"\x06engine\x18\v \x01(\tR\x06engine\x12\x1a\n" +
 	"\bstrength\x18\f \x01(\tR\bstrength\x12$\n" +
-	"\x06bounds\x18\r \x03(\v2\f.sysml.BoundR\x06bounds\"\xdf\x02\n" +
+	"\x06bounds\x18\r \x03(\v2\f.sysml.BoundR\x06bounds\"\x81\x03\n" +
 	"\aOutcome\x125\n" +
 	"\aoutputs\x18\x01 \x03(\v2\x1b.sysml.Outcome.OutputsEntryR\aoutputs\x12\x1f\n" +
 	"\vfinal_state\x18\x02 \x01(\tR\n" +
@@ -8130,10 +9148,11 @@ const file_sysml_proto_rawDesc = "" +
 	"\x05error\x18\x04 \x01(\tR\x05error\x12&\n" +
 	"\x0elinearizations\x18\x05 \x01(\x05R\x0elinearizations\x12\x18\n" +
 	"\awitness\x18\x06 \x03(\tR\awitness\x123\n" +
-	"\vdiagnostics\x18\a \x03(\v2\x11.sysml.DiagnosticR\vdiagnostics\x1aH\n" +
+	"\vdiagnostics\x18\a \x03(\v2\x11.sysml.DiagnosticR\vdiagnostics\x12 \n" +
+	"\vprobability\x18\b \x01(\x01R\vprobability\x1aH\n" +
 	"\fOutputsEntry\x12\x10\n" +
 	"\x03key\x18\x01 \x01(\tR\x03key\x12\"\n" +
-	"\x05value\x18\x02 \x01(\v2\f.sysml.ValueR\x05value:\x028\x01\"\xa8\x01\n" +
+	"\x05value\x18\x02 \x01(\v2\f.sysml.ValueR\x05value:\x028\x01\"\xe4\x01\n" +
 	"\x11ExplorationStatus\x12\x1a\n" +
 	"\bcomplete\x18\x01 \x01(\bR\bcomplete\x12\x12\n" +
 	"\x04runs\x18\x02 \x01(\x05R\x04runs\x12\x1f\n" +
@@ -8141,7 +9160,8 @@ const file_sysml_proto_rawDesc = "" +
 	"budgetsHit\x12\x1f\n" +
 	"\vruns_budget\x18\x04 \x01(\x05R\n" +
 	"runsBudget\x12!\n" +
-	"\fdepth_budget\x18\x05 \x01(\x05R\vdepthBudget\"\x14\n" +
+	"\fdepth_budget\x18\x05 \x01(\x05R\vdepthBudget\x12:\n" +
+	"\x19probabilities_lower_bound\x18\x06 \x01(\bR\x17probabilitiesLowerBound\"\x14\n" +
 	"\x12ListEnginesRequest\"\xfb\x02\n" +
 	"\n" +
 	"EngineInfo\x12\x12\n" +
@@ -8236,13 +9256,14 @@ const file_sysml_proto_rawDesc = "" +
 	"\binstance\x18\x01 \x01(\v2\x0f.sysml.InstanceR\binstance\x12\x14\n" +
 	"\x05error\x18\x02 \x01(\tR\x05error\x123\n" +
 	"\vdiagnostics\x18\x03 \x03(\v2\x11.sysml.DiagnosticR\vdiagnostics\x12-\n" +
-	"\tinstances\x18\x04 \x03(\v2\x0f.sysml.InstanceR\tinstances\"\x85\x02\n" +
+	"\tinstances\x18\x04 \x03(\v2\x0f.sysml.InstanceR\tinstances\"\xb5\x02\n" +
 	"\x14ExecuteActionRequest\x12\x1d\n" +
 	"\n" +
 	"model_hash\x18\x01 \x01(\tR\tmodelHash\x12(\n" +
 	"\x10action_symbol_id\x18\x02 \x01(\tR\x0eactionSymbolId\x12?\n" +
 	"\x06inputs\x18\x03 \x03(\v2'.sysml.ExecuteActionRequest.InputsEntryR\x06inputs\x12\x1a\n" +
-	"\bschedule\x18\x04 \x01(\tR\bschedule\x1aG\n" +
+	"\bschedule\x18\x04 \x01(\tR\bschedule\x12.\n" +
+	"\x13performer_symbol_id\x18\x05 \x01(\tR\x11performerSymbolId\x1aG\n" +
 	"\vInputsEntry\x12\x10\n" +
 	"\x03key\x18\x01 \x01(\tR\x03key\x12\"\n" +
 	"\x05value\x18\x02 \x01(\v2\f.sysml.ValueR\x05value:\x028\x01\"\xf8\x02\n" +
@@ -8256,13 +9277,14 @@ const file_sysml_proto_rawDesc = "" +
 	"final_time\x18\x06 \x01(\x01R\tfinalTime\x1aH\n" +
 	"\fOutputsEntry\x12\x10\n" +
 	"\x03key\x18\x01 \x01(\tR\x03key\x12\"\n" +
-	"\x05value\x18\x02 \x01(\v2\f.sysml.ValueR\x05value:\x028\x01\"\x9f\x01\n" +
+	"\x05value\x18\x02 \x01(\v2\f.sysml.ValueR\x05value:\x028\x01\"\xcf\x01\n" +
 	"\x13ExecuteStateRequest\x12\x1d\n" +
 	"\n" +
 	"model_hash\x18\x01 \x01(\tR\tmodelHash\x125\n" +
 	"\x17state_machine_symbol_id\x18\x02 \x01(\tR\x14stateMachineSymbolId\x12\x16\n" +
 	"\x06events\x18\x03 \x03(\tR\x06events\x12\x1a\n" +
-	"\bschedule\x18\x04 \x01(\tR\bschedule\"\xb2\x03\n" +
+	"\bschedule\x18\x04 \x01(\tR\bschedule\x12.\n" +
+	"\x13performer_symbol_id\x18\x05 \x01(\tR\x11performerSymbolId\"\xb2\x03\n" +
 	"\x14ExecuteStateResponse\x12%\n" +
 	"\x0estates_visited\x18\x01 \x03(\tR\rstatesVisited\x12R\n" +
 	"\rfinal_context\x18\x02 \x03(\v2-.sysml.ExecuteStateResponse.FinalContextEntryR\ffinalContext\x12\x14\n" +
@@ -8293,13 +9315,15 @@ const file_sysml_proto_rawDesc = "" +
 	"\x05error\x18\x04 \x01(\tR\x05error\x123\n" +
 	"\vdiagnostics\x18\x05 \x03(\v2\x11.sysml.DiagnosticR\vdiagnostics\x12\"\n" +
 	"\fexperimental\x18\x06 \x01(\bR\fexperimental\x12/\n" +
-	"\x13experimental_notice\x18\a \x01(\tR\x12experimentalNotice\"h\n" +
+	"\x13experimental_notice\x18\a \x01(\tR\x12experimentalNotice\"\xaf\x01\n" +
 	"\x11ApplyEditsRequest\x12\x1d\n" +
 	"\n" +
 	"model_hash\x18\x01 \x01(\tR\tmodelHash\x124\n" +
 	"\n" +
 	"operations\x18\x02 \x03(\v2\x14.sysml.EditOperationR\n" +
-	"operations\"\x88\x02\n" +
+	"operations\x12\x1a\n" +
+	"\bdocument\x18\x03 \x01(\tR\bdocument\x12)\n" +
+	"\x10accept_documents\x18\x04 \x01(\bR\x0facceptDocuments\"\x88\x02\n" +
 	"\rEditOperation\x122\n" +
 	"\tset_value\x18\x01 \x01(\v2\x13.sysml.SetValueEditH\x00R\bsetValue\x12+\n" +
 	"\x06rename\x18\x02 \x01(\v2\x11.sysml.RenameEditH\x00R\x06rename\x125\n" +
@@ -8329,21 +9353,30 @@ const file_sysml_proto_rawDesc = "" +
 	"\n" +
 	"RenameEdit\x12\x16\n" +
 	"\x06target\x18\x01 \x01(\tR\x06target\x12\x19\n" +
-	"\bnew_name\x18\x02 \x01(\tR\anewName\"\x84\x02\n" +
+	"\bnew_name\x18\x02 \x01(\tR\anewName\"\xe8\x02\n" +
 	"\x12ApplyEditsResponse\x12\x18\n" +
 	"\acontent\x18\x01 \x01(\tR\acontent\x12,\n" +
 	"\aapplied\x18\x02 \x03(\v2\x12.sysml.AppliedEditR\aapplied\x12\x14\n" +
 	"\x05error\x18\x03 \x01(\tR\x05error\x12,\n" +
 	"\afailure\x18\x04 \x01(\x0e2\x12.sysml.EditFailureR\afailure\x123\n" +
 	"\vdiagnostics\x18\x05 \x03(\v2\x11.sysml.DiagnosticR\vdiagnostics\x12-\n" +
-	"\x12referring_elements\x18\x06 \x03(\tR\x11referringElements\"\xb4\x01\n" +
+	"\x12referring_elements\x18\x06 \x03(\tR\x11referringElements\x123\n" +
+	"\tdocuments\x18\a \x03(\v2\x15.sysml.EditedDocumentR\tdocuments\x12-\n" +
+	"\treferrers\x18\b \x03(\v2\x0f.sysml.ReferrerR\treferrers\">\n" +
+	"\x0eEditedDocument\x12\x12\n" +
+	"\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" +
+	"\acontent\x18\x02 \x01(\tR\acontent\":\n" +
+	"\bReferrer\x12\x12\n" +
+	"\x04name\x18\x01 \x01(\tR\x04name\x12\x1a\n" +
+	"\bdocument\x18\x02 \x01(\tR\bdocument\"\xd0\x01\n" +
 	"\vAppliedEdit\x12'\n" +
 	"\x0foperation_index\x18\x01 \x01(\x05R\x0eoperationIndex\x12\x16\n" +
 	"\x06target\x18\x02 \x01(\tR\x06target\x12\x16\n" +
 	"\x06offset\x18\x03 \x01(\x05R\x06offset\x12\x16\n" +
 	"\x06length\x18\x04 \x01(\x05R\x06length\x12\x19\n" +
 	"\bold_text\x18\x05 \x01(\tR\aoldText\x12\x19\n" +
-	"\bnew_text\x18\x06 \x01(\tR\anewText\"\xfd\x03\n" +
+	"\bnew_text\x18\x06 \x01(\tR\anewText\x12\x1a\n" +
+	"\bdocument\x18\a \x01(\tR\bdocument\"\xfd\x03\n" +
 	"\n" +
 	"SymbolInfo\x12\x0e\n" +
 	"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
@@ -8580,7 +9613,7 @@ const file_sysml_proto_rawDesc = "" +
 	"\bbindings\x18\x03 \x03(\v2\x1b.sysml.DocumentQueryBindingR\bbindings\"b\n" +
 	"\x14DocumentQueryBinding\x12\x1c\n" +
 	"\tparameter\x18\x01 \x01(\tR\tparameter\x12,\n" +
-	"\x06values\x18\x02 \x03(\v2\x14.sysml.DocumentValueR\x06values\"\xae\x02\n" +
+	"\x06values\x18\x02 \x03(\v2\x14.sysml.DocumentValueR\x06values\"\xef\x03\n" +
 	"\rDocumentValue\x12\x1f\n" +
 	"\n" +
 	"element_id\x18\x01 \x01(\tH\x00R\telementId\x12#\n" +
@@ -8591,9 +9624,52 @@ const file_sysml_proto_rawDesc = "" +
 	"\n" +
 	"bool_value\x18\x05 \x01(\bH\x00R\tboolValue\x12\x1c\n" +
 	"\binfinity\x18\x06 \x01(\bH\x00R\binfinity\x12-\n" +
-	"\bquantity\x18\b \x01(\v2\x0f.sysml.QuantityH\x00R\bquantity\x12!\n" +
+	"\bquantity\x18\b \x01(\v2\x0f.sysml.QuantityH\x00R\bquantity\x122\n" +
+	"\averdict\x18\t \x01(\v2\x16.sysml.DocumentVerdictH\x00R\averdict\x12/\n" +
+	"\x06object\x18\n" +
+	" \x01(\v2\x15.sysml.DocumentObjectH\x00R\x06object\x12,\n" +
+	"\x05state\x18\v \x01(\v2\x14.sysml.DocumentStateH\x00R\x05state\x12,\n" +
+	"\x05event\x18\f \x01(\v2\x14.sysml.DocumentEventH\x00R\x05event\x12!\n" +
 	"\felement_type\x18\a \x01(\tR\velementTypeB\x06\n" +
-	"\x04kind\")\n" +
+	"\x04kind\"u\n" +
+	"\x0eDocumentObject\x12\x1f\n" +
+	"\vinstance_id\x18\x01 \x01(\x03R\n" +
+	"instanceId\x12\x12\n" +
+	"\x04path\x18\x02 \x01(\tR\x04path\x12.\n" +
+	"\aelement\x18\x03 \x01(\v2\x14.sysml.DocumentValueR\aelement\"\xf5\x01\n" +
+	"\x0fDocumentVerdict\x122\n" +
+	"\tassertion\x18\x01 \x01(\v2\x14.sysml.DocumentValueR\tassertion\x12\x12\n" +
+	"\x04kind\x18\x02 \x01(\tR\x04kind\x12\x12\n" +
+	"\x04text\x18\x03 \x01(\tR\x04text\x12\x12\n" +
+	"\x04path\x18\x04 \x01(\tR\x04path\x12\x18\n" +
+	"\averdict\x18\x05 \x01(\tR\averdict\x12\x1c\n" +
+	"\tcondition\x18\x06 \x01(\tR\tcondition\x12\x16\n" +
+	"\x06reason\x18\a \x01(\tR\x06reason\x12\"\n" +
+	"\fverification\x18\b \x03(\tR\fverification\"\xed\x01\n" +
+	"\rDocumentState\x12-\n" +
+	"\x06object\x18\x01 \x01(\v2\x15.sysml.DocumentObjectR\x06object\x12\x18\n" +
+	"\amachine\x18\x02 \x01(\tR\amachine\x12\x12\n" +
+	"\x04name\x18\x03 \x01(\tR\x04name\x12\x1d\n" +
+	"\n" +
+	"state_path\x18\x04 \x01(\tR\tstatePath\x12*\n" +
+	"\x05state\x18\x05 \x01(\v2\x14.sysml.DocumentValueR\x05state\x12\x16\n" +
+	"\x06region\x18\x06 \x01(\tR\x06region\x12\x1c\n" +
+	"\tenclosing\x18\a \x03(\tR\tenclosing\"\xfd\x02\n" +
+	"\rDocumentEvent\x12\x12\n" +
+	"\x04kind\x18\x01 \x01(\tR\x04kind\x12(\n" +
+	"\x04time\x18\x02 \x01(\v2\x14.sysml.DocumentValueR\x04time\x12-\n" +
+	"\x06object\x18\x03 \x01(\v2\x15.sysml.DocumentObjectR\x06object\x12\x18\n" +
+	"\amachine\x18\x04 \x01(\tR\amachine\x12\x14\n" +
+	"\x05state\x18\x05 \x01(\tR\x05state\x12\x12\n" +
+	"\x04from\x18\x06 \x01(\tR\x04from\x12\x0e\n" +
+	"\x02to\x18\a \x01(\tR\x02to\x12-\n" +
+	"\x06target\x18\b \x01(\v2\x15.sysml.DocumentObjectR\x06target\x12\x14\n" +
+	"\x05event\x18\t \x01(\tR\x05event\x12\x18\n" +
+	"\apayload\x18\n" +
+	" \x03(\tR\apayload\x12\"\n" +
+	"\falternatives\x18\v \x03(\tR\falternatives\x12\x14\n" +
+	"\x05taken\x18\f \x01(\tR\x05taken\x12\x12\n" +
+	"\x04text\x18\r \x01(\tR\x04text\")\n" +
 	"\x13DocumentQueryColumn\x12\x12\n" +
 	"\x04name\x18\x01 \x01(\tR\x04name\"A\n" +
 	"\x11DocumentQueryCell\x12,\n" +
@@ -8603,19 +9679,21 @@ const file_sysml_proto_rawDesc = "" +
 	"\x05cells\x18\x02 \x03(\v2\x18.sysml.DocumentQueryCellR\x05cells\"}\n" +
 	"\x18RunDocumentQueryResponse\x124\n" +
 	"\acolumns\x18\x01 \x03(\v2\x1a.sysml.DocumentQueryColumnR\acolumns\x12+\n" +
-	"\x04rows\x18\x02 \x03(\v2\x17.sysml.DocumentQueryRowR\x04rows\"W\n" +
+	"\x04rows\x18\x02 \x03(\v2\x17.sysml.DocumentQueryRowR\x04rows\"k\n" +
 	"\x15RenderDocumentRequest\x12\x1d\n" +
 	"\n" +
 	"model_hash\x18\x01 \x01(\tR\tmodelHash\x12\x1f\n" +
 	"\vdocument_id\x18\x02 \x01(\tR\n" +
-	"documentId\"4\n" +
+	"documentId\x12\x12\n" +
+	"\x04form\x18\x03 \x01(\tR\x04form\"H\n" +
 	"\x16RenderDocumentResponse\x12\x1a\n" +
-	"\bmarkdown\x18\x01 \x01(\tR\bmarkdown*\x93\x01\n" +
+	"\bmarkdown\x18\x01 \x01(\tR\bmarkdown\x12\x12\n" +
+	"\x04html\x18\x02 \x01(\tR\x04html*\x93\x01\n" +
 	"\rFailureReason\x12\x1e\n" +
 	"\x1aFAILURE_REASON_UNSPECIFIED\x10\x00\x12\x1d\n" +
 	"\x19FAILURE_REASON_EVALUATION\x10\x01\x12\x1d\n" +
 	"\x19FAILURE_REASON_WRONG_KIND\x10\x02\x12$\n" +
-	" FAILURE_REASON_AMBIGUOUS_SUBJECT\x10\x03*\xe5\x04\n" +
+	" FAILURE_REASON_AMBIGUOUS_SUBJECT\x10\x03*\x8c\x05\n" +
 	"\vEditFailure\x12\x1c\n" +
 	"\x18EDIT_FAILURE_UNSPECIFIED\x10\x00\x12\x1e\n" +
 	"\x1aEDIT_FAILURE_NO_OPERATIONS\x10\x01\x12\x1f\n" +
@@ -8635,7 +9713,8 @@ const file_sysml_proto_rawDesc = "" +
 	"\x1eEDIT_FAILURE_MEMBER_NAME_TAKEN\x10\x0e\x12\"\n" +
 	"\x1eEDIT_FAILURE_DELETE_REFERENCED\x10\x0f\x12$\n" +
 	" EDIT_FAILURE_OWNER_INSIDE_TARGET\x10\x10\x12 \n" +
-	"\x1cEDIT_FAILURE_MOVE_REFERENCED\x10\x11*\x92\x01\n" +
+	"\x1cEDIT_FAILURE_MOVE_REFERENCED\x10\x11\x12%\n" +
+	"!EDIT_FAILURE_REFERENCED_ELSEWHERE\x10\x12*\x92\x01\n" +
 	"\x11PrimitiveOperator\x12\"\n" +
 	"\x1ePRIMITIVE_OPERATOR_UNSPECIFIED\x10\x00\x12\x1c\n" +
 	"\x18PRIMITIVE_OPERATOR_EQUAL\x10\x01\x12\x1e\n" +
@@ -8644,7 +9723,7 @@ const file_sysml_proto_rawDesc = "" +
 	"\x11CompositeOperator\x12\"\n" +
 	"\x1eCOMPOSITE_OPERATOR_UNSPECIFIED\x10\x00\x12\x1a\n" +
 	"\x16COMPOSITE_OPERATOR_AND\x10\x01\x12\x19\n" +
-	"\x15COMPOSITE_OPERATOR_OR\x10\x022\xea\v\n" +
+	"\x15COMPOSITE_OPERATOR_OR\x10\x022\xbf\f\n" +
 	"\fSysMLService\x12D\n" +
 	"\rGetServerInfo\x12\x18.sysml.ServerInfoRequest\x1a\x19.sysml.ServerInfoResponse\x12>\n" +
 	"\tParseFile\x12\x17.sysml.ParseFileRequest\x1a\x18.sysml.ParseFileResponse\x12G\n" +
@@ -8660,7 +9739,8 @@ const file_sysml_proto_rawDesc = "" +
 	"ApplyEdits\x12\x18.sysml.ApplyEditsRequest\x1a\x19.sysml.ApplyEditsResponse\x12S\n" +
 	"\x10VerifyConstraint\x12\x1e.sysml.VerifyConstraintRequest\x1a\x1f.sysml.VerifyConstraintResponse\x12V\n" +
 	"\x11VerifyRequirement\x12\x1f.sysml.VerifyRequirementRequest\x1a .sysml.VerifyRequirementResponse\x12Y\n" +
-	"\x12VerifySatisfaction\x12 .sysml.VerifySatisfactionRequest\x1a!.sysml.VerifySatisfactionResponse\x12G\n" +
+	"\x12VerifySatisfaction\x12 .sysml.VerifySatisfactionRequest\x1a!.sysml.VerifySatisfactionResponse\x12S\n" +
+	"\x10ValidateInstance\x12\x1e.sysml.ValidateInstanceRequest\x1a\x1f.sysml.ValidateInstanceResponse\x12G\n" +
 	"\fEvaluateCalc\x12\x1a.sysml.EvaluateCalcRequest\x1a\x1b.sysml.EvaluateCalcResponse\x12D\n" +
 	"\vRunAnalysis\x12\x19.sysml.RunAnalysisRequest\x1a\x1a.sysml.RunAnalysisResponse\x12;\n" +
 	"\bRunSweep\x12\x16.sysml.RunSweepRequest\x1a\x17.sysml.RunSweepResponse\x12D\n" +
@@ -8683,7 +9763,7 @@ func file_sysml_proto_rawDescGZIP() []byte {
 }
 
 var file_sysml_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_sysml_proto_msgTypes = make([]protoimpl.MessageInfo, 104)
+var file_sysml_proto_msgTypes = make([]protoimpl.MessageInfo, 112)
 var file_sysml_proto_goTypes = []any{
 	(FailureReason)(0),                 // 0: sysml.FailureReason
 	(EditFailure)(0),                   // 1: sysml.EditFailure
@@ -8698,293 +9778,322 @@ var file_sysml_proto_goTypes = []any{
 	(*VerifyRequirementResponse)(nil),  // 10: sysml.VerifyRequirementResponse
 	(*VerifySatisfactionRequest)(nil),  // 11: sysml.VerifySatisfactionRequest
 	(*VerifySatisfactionResponse)(nil), // 12: sysml.VerifySatisfactionResponse
-	(*EvaluateCalcRequest)(nil),        // 13: sysml.EvaluateCalcRequest
-	(*EvaluateCalcResponse)(nil),       // 14: sysml.EvaluateCalcResponse
-	(*CalcOutput)(nil),                 // 15: sysml.CalcOutput
-	(*CaseEvaluation)(nil),             // 16: sysml.CaseEvaluation
-	(*RunAnalysisRequest)(nil),         // 17: sysml.RunAnalysisRequest
-	(*RunAnalysisResponse)(nil),        // 18: sysml.RunAnalysisResponse
-	(*Outcome)(nil),                    // 19: sysml.Outcome
-	(*ExplorationStatus)(nil),          // 20: sysml.ExplorationStatus
-	(*ListEnginesRequest)(nil),         // 21: sysml.ListEnginesRequest
-	(*EngineInfo)(nil),                 // 22: sysml.EngineInfo
-	(*ListEnginesResponse)(nil),        // 23: sysml.ListEnginesResponse
-	(*ParseFileRequest)(nil),           // 24: sysml.ParseFileRequest
-	(*SourceDocument)(nil),             // 25: sysml.SourceDocument
-	(*ParseSourcesRequest)(nil),        // 26: sysml.ParseSourcesRequest
-	(*ParseSourcesResponse)(nil),       // 27: sysml.ParseSourcesResponse
-	(*ParseFileResponse)(nil),          // 28: sysml.ParseFileResponse
-	(*GetSymbolRequest)(nil),           // 29: sysml.GetSymbolRequest
-	(*SymbolResponse)(nil),             // 30: sysml.SymbolResponse
-	(*DiagnosticsRequest)(nil),         // 31: sysml.DiagnosticsRequest
-	(*DiagnosticsResponse)(nil),        // 32: sysml.DiagnosticsResponse
-	(*EvaluateRequest)(nil),            // 33: sysml.EvaluateRequest
-	(*EvaluateResponse)(nil),           // 34: sysml.EvaluateResponse
-	(*Instance)(nil),                   // 35: sysml.Instance
-	(*FeatureValue)(nil),               // 36: sysml.FeatureValue
-	(*InstantiateRequest)(nil),         // 37: sysml.InstantiateRequest
-	(*InstantiateResponse)(nil),        // 38: sysml.InstantiateResponse
-	(*ExecuteActionRequest)(nil),       // 39: sysml.ExecuteActionRequest
-	(*ExecuteActionResponse)(nil),      // 40: sysml.ExecuteActionResponse
-	(*ExecuteStateRequest)(nil),        // 41: sysml.ExecuteStateRequest
-	(*ExecuteStateResponse)(nil),       // 42: sysml.ExecuteStateResponse
-	(*ConvertRequest)(nil),             // 43: sysml.ConvertRequest
-	(*ConvertResponse)(nil),            // 44: sysml.ConvertResponse
-	(*ApplyEditsRequest)(nil),          // 45: sysml.ApplyEditsRequest
-	(*EditOperation)(nil),              // 46: sysml.EditOperation
-	(*AddMemberEdit)(nil),              // 47: sysml.AddMemberEdit
-	(*DeleteEdit)(nil),                 // 48: sysml.DeleteEdit
-	(*MoveEdit)(nil),                   // 49: sysml.MoveEdit
-	(*SetValueEdit)(nil),               // 50: sysml.SetValueEdit
-	(*RenameEdit)(nil),                 // 51: sysml.RenameEdit
-	(*ApplyEditsResponse)(nil),         // 52: sysml.ApplyEditsResponse
-	(*AppliedEdit)(nil),                // 53: sysml.AppliedEdit
-	(*SymbolInfo)(nil),                 // 54: sysml.SymbolInfo
-	(*Specialization)(nil),             // 55: sysml.Specialization
-	(*TypeInfo)(nil),                   // 56: sysml.TypeInfo
-	(*MultiplicityInfo)(nil),           // 57: sysml.MultiplicityInfo
-	(*AttributeInfo)(nil),              // 58: sysml.AttributeInfo
-	(*Value)(nil),                      // 59: sysml.Value
-	(*Metaobject)(nil),                 // 60: sysml.Metaobject
-	(*Undetermined)(nil),               // 61: sysml.Undetermined
-	(*Function)(nil),                   // 62: sysml.Function
-	(*ValueSet)(nil),                   // 63: sysml.ValueSet
-	(*TensorQuantity)(nil),             // 64: sysml.TensorQuantity
-	(*Array)(nil),                      // 65: sysml.Array
-	(*Vector)(nil),                     // 66: sysml.Vector
-	(*VectorQuantity)(nil),             // 67: sysml.VectorQuantity
-	(*Complex)(nil),                    // 68: sysml.Complex
-	(*EnumLiteral)(nil),                // 69: sysml.EnumLiteral
-	(*ValueSequence)(nil),              // 70: sysml.ValueSequence
-	(*Quantity)(nil),                   // 71: sysml.Quantity
-	(*MeasurementRef)(nil),             // 72: sysml.MeasurementRef
-	(*UnitTerm)(nil),                   // 73: sysml.UnitTerm
-	(*UnitFactor)(nil),                 // 74: sysml.UnitFactor
-	(*Diagnostic)(nil),                 // 75: sysml.Diagnostic
-	(*Span)(nil),                       // 76: sysml.Span
-	(*ServerInfoRequest)(nil),          // 77: sysml.ServerInfoRequest
-	(*ServerInfoResponse)(nil),         // 78: sysml.ServerInfoResponse
-	(*QueryRequest)(nil),               // 79: sysml.QueryRequest
-	(*QueryResponse)(nil),              // 80: sysml.QueryResponse
-	(*Query)(nil),                      // 81: sysml.Query
-	(*Constraint)(nil),                 // 82: sysml.Constraint
-	(*PrimitiveConstraint)(nil),        // 83: sysml.PrimitiveConstraint
-	(*CompositeConstraint)(nil),        // 84: sysml.CompositeConstraint
-	(*QueryResultElement)(nil),         // 85: sysml.QueryResultElement
-	(*SweepRange)(nil),                 // 86: sysml.SweepRange
-	(*RunSweepRequest)(nil),            // 87: sysml.RunSweepRequest
-	(*SweepRow)(nil),                   // 88: sysml.SweepRow
-	(*RunSweepResponse)(nil),           // 89: sysml.RunSweepResponse
-	(*RunDocumentQueryRequest)(nil),    // 90: sysml.RunDocumentQueryRequest
-	(*DocumentQueryBinding)(nil),       // 91: sysml.DocumentQueryBinding
-	(*DocumentValue)(nil),              // 92: sysml.DocumentValue
-	(*DocumentQueryColumn)(nil),        // 93: sysml.DocumentQueryColumn
-	(*DocumentQueryCell)(nil),          // 94: sysml.DocumentQueryCell
-	(*DocumentQueryRow)(nil),           // 95: sysml.DocumentQueryRow
-	(*RunDocumentQueryResponse)(nil),   // 96: sysml.RunDocumentQueryResponse
-	(*RenderDocumentRequest)(nil),      // 97: sysml.RenderDocumentRequest
-	(*RenderDocumentResponse)(nil),     // 98: sysml.RenderDocumentResponse
-	nil,                                // 99: sysml.RunAnalysisRequest.NamedArgumentsEntry
-	nil,                                // 100: sysml.Outcome.OutputsEntry
-	nil,                                // 101: sysml.Instance.FeatureValuesEntry
-	nil,                                // 102: sysml.ExecuteActionRequest.InputsEntry
-	nil,                                // 103: sysml.ExecuteActionResponse.OutputsEntry
-	nil,                                // 104: sysml.ExecuteStateResponse.FinalContextEntry
-	nil,                                // 105: sysml.SymbolInfo.MetadataEntry
-	nil,                                // 106: sysml.QueryResultElement.PropertiesEntry
-	nil,                                // 107: sysml.RunSweepRequest.NamedArgumentsEntry
+	(*ValidateInstanceRequest)(nil),    // 13: sysml.ValidateInstanceRequest
+	(*ValidateInstanceResponse)(nil),   // 14: sysml.ValidateInstanceResponse
+	(*EvaluateCalcRequest)(nil),        // 15: sysml.EvaluateCalcRequest
+	(*EvaluateCalcResponse)(nil),       // 16: sysml.EvaluateCalcResponse
+	(*CalcOutput)(nil),                 // 17: sysml.CalcOutput
+	(*CaseEvaluation)(nil),             // 18: sysml.CaseEvaluation
+	(*RunAnalysisRequest)(nil),         // 19: sysml.RunAnalysisRequest
+	(*RunAnalysisResponse)(nil),        // 20: sysml.RunAnalysisResponse
+	(*Outcome)(nil),                    // 21: sysml.Outcome
+	(*ExplorationStatus)(nil),          // 22: sysml.ExplorationStatus
+	(*ListEnginesRequest)(nil),         // 23: sysml.ListEnginesRequest
+	(*EngineInfo)(nil),                 // 24: sysml.EngineInfo
+	(*ListEnginesResponse)(nil),        // 25: sysml.ListEnginesResponse
+	(*ParseFileRequest)(nil),           // 26: sysml.ParseFileRequest
+	(*SourceDocument)(nil),             // 27: sysml.SourceDocument
+	(*ParseSourcesRequest)(nil),        // 28: sysml.ParseSourcesRequest
+	(*ParseSourcesResponse)(nil),       // 29: sysml.ParseSourcesResponse
+	(*ParseFileResponse)(nil),          // 30: sysml.ParseFileResponse
+	(*GetSymbolRequest)(nil),           // 31: sysml.GetSymbolRequest
+	(*SymbolResponse)(nil),             // 32: sysml.SymbolResponse
+	(*DiagnosticsRequest)(nil),         // 33: sysml.DiagnosticsRequest
+	(*DiagnosticsResponse)(nil),        // 34: sysml.DiagnosticsResponse
+	(*EvaluateRequest)(nil),            // 35: sysml.EvaluateRequest
+	(*EvaluateResponse)(nil),           // 36: sysml.EvaluateResponse
+	(*Instance)(nil),                   // 37: sysml.Instance
+	(*FeatureValue)(nil),               // 38: sysml.FeatureValue
+	(*InstantiateRequest)(nil),         // 39: sysml.InstantiateRequest
+	(*InstantiateResponse)(nil),        // 40: sysml.InstantiateResponse
+	(*ExecuteActionRequest)(nil),       // 41: sysml.ExecuteActionRequest
+	(*ExecuteActionResponse)(nil),      // 42: sysml.ExecuteActionResponse
+	(*ExecuteStateRequest)(nil),        // 43: sysml.ExecuteStateRequest
+	(*ExecuteStateResponse)(nil),       // 44: sysml.ExecuteStateResponse
+	(*ConvertRequest)(nil),             // 45: sysml.ConvertRequest
+	(*ConvertResponse)(nil),            // 46: sysml.ConvertResponse
+	(*ApplyEditsRequest)(nil),          // 47: sysml.ApplyEditsRequest
+	(*EditOperation)(nil),              // 48: sysml.EditOperation
+	(*AddMemberEdit)(nil),              // 49: sysml.AddMemberEdit
+	(*DeleteEdit)(nil),                 // 50: sysml.DeleteEdit
+	(*MoveEdit)(nil),                   // 51: sysml.MoveEdit
+	(*SetValueEdit)(nil),               // 52: sysml.SetValueEdit
+	(*RenameEdit)(nil),                 // 53: sysml.RenameEdit
+	(*ApplyEditsResponse)(nil),         // 54: sysml.ApplyEditsResponse
+	(*EditedDocument)(nil),             // 55: sysml.EditedDocument
+	(*Referrer)(nil),                   // 56: sysml.Referrer
+	(*AppliedEdit)(nil),                // 57: sysml.AppliedEdit
+	(*SymbolInfo)(nil),                 // 58: sysml.SymbolInfo
+	(*Specialization)(nil),             // 59: sysml.Specialization
+	(*TypeInfo)(nil),                   // 60: sysml.TypeInfo
+	(*MultiplicityInfo)(nil),           // 61: sysml.MultiplicityInfo
+	(*AttributeInfo)(nil),              // 62: sysml.AttributeInfo
+	(*Value)(nil),                      // 63: sysml.Value
+	(*Metaobject)(nil),                 // 64: sysml.Metaobject
+	(*Undetermined)(nil),               // 65: sysml.Undetermined
+	(*Function)(nil),                   // 66: sysml.Function
+	(*ValueSet)(nil),                   // 67: sysml.ValueSet
+	(*TensorQuantity)(nil),             // 68: sysml.TensorQuantity
+	(*Array)(nil),                      // 69: sysml.Array
+	(*Vector)(nil),                     // 70: sysml.Vector
+	(*VectorQuantity)(nil),             // 71: sysml.VectorQuantity
+	(*Complex)(nil),                    // 72: sysml.Complex
+	(*EnumLiteral)(nil),                // 73: sysml.EnumLiteral
+	(*ValueSequence)(nil),              // 74: sysml.ValueSequence
+	(*Quantity)(nil),                   // 75: sysml.Quantity
+	(*MeasurementRef)(nil),             // 76: sysml.MeasurementRef
+	(*UnitTerm)(nil),                   // 77: sysml.UnitTerm
+	(*UnitFactor)(nil),                 // 78: sysml.UnitFactor
+	(*Diagnostic)(nil),                 // 79: sysml.Diagnostic
+	(*Span)(nil),                       // 80: sysml.Span
+	(*ServerInfoRequest)(nil),          // 81: sysml.ServerInfoRequest
+	(*ServerInfoResponse)(nil),         // 82: sysml.ServerInfoResponse
+	(*QueryRequest)(nil),               // 83: sysml.QueryRequest
+	(*QueryResponse)(nil),              // 84: sysml.QueryResponse
+	(*Query)(nil),                      // 85: sysml.Query
+	(*Constraint)(nil),                 // 86: sysml.Constraint
+	(*PrimitiveConstraint)(nil),        // 87: sysml.PrimitiveConstraint
+	(*CompositeConstraint)(nil),        // 88: sysml.CompositeConstraint
+	(*QueryResultElement)(nil),         // 89: sysml.QueryResultElement
+	(*SweepRange)(nil),                 // 90: sysml.SweepRange
+	(*RunSweepRequest)(nil),            // 91: sysml.RunSweepRequest
+	(*SweepRow)(nil),                   // 92: sysml.SweepRow
+	(*RunSweepResponse)(nil),           // 93: sysml.RunSweepResponse
+	(*RunDocumentQueryRequest)(nil),    // 94: sysml.RunDocumentQueryRequest
+	(*DocumentQueryBinding)(nil),       // 95: sysml.DocumentQueryBinding
+	(*DocumentValue)(nil),              // 96: sysml.DocumentValue
+	(*DocumentObject)(nil),             // 97: sysml.DocumentObject
+	(*DocumentVerdict)(nil),            // 98: sysml.DocumentVerdict
+	(*DocumentState)(nil),              // 99: sysml.DocumentState
+	(*DocumentEvent)(nil),              // 100: sysml.DocumentEvent
+	(*DocumentQueryColumn)(nil),        // 101: sysml.DocumentQueryColumn
+	(*DocumentQueryCell)(nil),          // 102: sysml.DocumentQueryCell
+	(*DocumentQueryRow)(nil),           // 103: sysml.DocumentQueryRow
+	(*RunDocumentQueryResponse)(nil),   // 104: sysml.RunDocumentQueryResponse
+	(*RenderDocumentRequest)(nil),      // 105: sysml.RenderDocumentRequest
+	(*RenderDocumentResponse)(nil),     // 106: sysml.RenderDocumentResponse
+	nil,                                // 107: sysml.RunAnalysisRequest.NamedArgumentsEntry
+	nil,                                // 108: sysml.Outcome.OutputsEntry
+	nil,                                // 109: sysml.Instance.FeatureValuesEntry
+	nil,                                // 110: sysml.ExecuteActionRequest.InputsEntry
+	nil,                                // 111: sysml.ExecuteActionResponse.OutputsEntry
+	nil,                                // 112: sysml.ExecuteStateResponse.FinalContextEntry
+	nil,                                // 113: sysml.SymbolInfo.MetadataEntry
+	nil,                                // 114: sysml.QueryResultElement.PropertiesEntry
+	nil,                                // 115: sysml.RunSweepRequest.NamedArgumentsEntry
 }
 var file_sysml_proto_depIdxs = []int32{
 	0,   // 0: sysml.Verdict.failure_reason:type_name -> sysml.FailureReason
 	5,   // 1: sysml.Verdict.bounds:type_name -> sysml.Bound
 	4,   // 2: sysml.VerifyConstraintResponse.verdict:type_name -> sysml.Verdict
-	35,  // 3: sysml.VerifyConstraintResponse.instances:type_name -> sysml.Instance
-	75,  // 4: sysml.VerifyConstraintResponse.diagnostics:type_name -> sysml.Diagnostic
+	37,  // 3: sysml.VerifyConstraintResponse.instances:type_name -> sysml.Instance
+	79,  // 4: sysml.VerifyConstraintResponse.diagnostics:type_name -> sysml.Diagnostic
 	4,   // 5: sysml.VerifyRequirementResponse.verdict:type_name -> sysml.Verdict
-	35,  // 6: sysml.VerifyRequirementResponse.instances:type_name -> sysml.Instance
-	75,  // 7: sysml.VerifyRequirementResponse.diagnostics:type_name -> sysml.Diagnostic
+	37,  // 6: sysml.VerifyRequirementResponse.instances:type_name -> sysml.Instance
+	79,  // 7: sysml.VerifyRequirementResponse.diagnostics:type_name -> sysml.Diagnostic
 	9,   // 8: sysml.VerifyRequirementResponse.verification_verdicts:type_name -> sysml.VerificationVerdict
 	4,   // 9: sysml.VerifySatisfactionResponse.verdicts:type_name -> sysml.Verdict
-	35,  // 10: sysml.VerifySatisfactionResponse.instances:type_name -> sysml.Instance
-	75,  // 11: sysml.VerifySatisfactionResponse.diagnostics:type_name -> sysml.Diagnostic
+	37,  // 10: sysml.VerifySatisfactionResponse.instances:type_name -> sysml.Instance
+	79,  // 11: sysml.VerifySatisfactionResponse.diagnostics:type_name -> sysml.Diagnostic
 	0,   // 12: sysml.VerifySatisfactionResponse.failure_reason:type_name -> sysml.FailureReason
 	9,   // 13: sysml.VerifySatisfactionResponse.verification_verdicts:type_name -> sysml.VerificationVerdict
-	59,  // 14: sysml.EvaluateCalcRequest.arguments:type_name -> sysml.Value
-	59,  // 15: sysml.EvaluateCalcResponse.result:type_name -> sysml.Value
-	15,  // 16: sysml.EvaluateCalcResponse.outputs:type_name -> sysml.CalcOutput
-	75,  // 17: sysml.EvaluateCalcResponse.diagnostics:type_name -> sysml.Diagnostic
-	0,   // 18: sysml.EvaluateCalcResponse.failure_reason:type_name -> sysml.FailureReason
-	5,   // 19: sysml.EvaluateCalcResponse.bounds:type_name -> sysml.Bound
-	59,  // 20: sysml.CalcOutput.value:type_name -> sysml.Value
-	59,  // 21: sysml.CaseEvaluation.arguments:type_name -> sysml.Value
-	59,  // 22: sysml.CaseEvaluation.result:type_name -> sysml.Value
-	59,  // 23: sysml.RunAnalysisRequest.arguments:type_name -> sysml.Value
-	99,  // 24: sysml.RunAnalysisRequest.named_arguments:type_name -> sysml.RunAnalysisRequest.NamedArgumentsEntry
-	15,  // 25: sysml.RunAnalysisResponse.outputs:type_name -> sysml.CalcOutput
-	4,   // 26: sysml.RunAnalysisResponse.verdicts:type_name -> sysml.Verdict
-	35,  // 27: sysml.RunAnalysisResponse.instances:type_name -> sysml.Instance
-	75,  // 28: sysml.RunAnalysisResponse.diagnostics:type_name -> sysml.Diagnostic
-	0,   // 29: sysml.RunAnalysisResponse.failure_reason:type_name -> sysml.FailureReason
-	9,   // 30: sysml.RunAnalysisResponse.verification_verdicts:type_name -> sysml.VerificationVerdict
-	19,  // 31: sysml.RunAnalysisResponse.outcomes:type_name -> sysml.Outcome
-	20,  // 32: sysml.RunAnalysisResponse.exploration:type_name -> sysml.ExplorationStatus
-	16,  // 33: sysml.RunAnalysisResponse.evaluations:type_name -> sysml.CaseEvaluation
-	5,   // 34: sysml.RunAnalysisResponse.bounds:type_name -> sysml.Bound
-	100, // 35: sysml.Outcome.outputs:type_name -> sysml.Outcome.OutputsEntry
-	75,  // 36: sysml.Outcome.diagnostics:type_name -> sysml.Diagnostic
-	22,  // 37: sysml.ListEnginesResponse.engines:type_name -> sysml.EngineInfo
-	25,  // 38: sysml.ParseSourcesRequest.documents:type_name -> sysml.SourceDocument
-	54,  // 39: sysml.ParseSourcesResponse.roots:type_name -> sysml.SymbolInfo
-	75,  // 40: sysml.ParseSourcesResponse.diagnostics:type_name -> sysml.Diagnostic
-	54,  // 41: sysml.ParseFileResponse.root:type_name -> sysml.SymbolInfo
-	75,  // 42: sysml.ParseFileResponse.diagnostics:type_name -> sysml.Diagnostic
-	54,  // 43: sysml.SymbolResponse.symbol:type_name -> sysml.SymbolInfo
-	75,  // 44: sysml.DiagnosticsResponse.diagnostics:type_name -> sysml.Diagnostic
-	59,  // 45: sysml.EvaluateResponse.result:type_name -> sysml.Value
-	75,  // 46: sysml.EvaluateResponse.diagnostics:type_name -> sysml.Diagnostic
-	101, // 47: sysml.Instance.feature_values:type_name -> sysml.Instance.FeatureValuesEntry
-	59,  // 48: sysml.FeatureValue.value:type_name -> sysml.Value
-	59,  // 49: sysml.FeatureValue.values:type_name -> sysml.Value
-	35,  // 50: sysml.InstantiateResponse.instance:type_name -> sysml.Instance
-	75,  // 51: sysml.InstantiateResponse.diagnostics:type_name -> sysml.Diagnostic
-	35,  // 52: sysml.InstantiateResponse.instances:type_name -> sysml.Instance
-	102, // 53: sysml.ExecuteActionRequest.inputs:type_name -> sysml.ExecuteActionRequest.InputsEntry
-	103, // 54: sysml.ExecuteActionResponse.outputs:type_name -> sysml.ExecuteActionResponse.OutputsEntry
-	75,  // 55: sysml.ExecuteActionResponse.diagnostics:type_name -> sysml.Diagnostic
-	19,  // 56: sysml.ExecuteActionResponse.outcomes:type_name -> sysml.Outcome
-	20,  // 57: sysml.ExecuteActionResponse.exploration:type_name -> sysml.ExplorationStatus
-	104, // 58: sysml.ExecuteStateResponse.final_context:type_name -> sysml.ExecuteStateResponse.FinalContextEntry
-	75,  // 59: sysml.ExecuteStateResponse.diagnostics:type_name -> sysml.Diagnostic
-	19,  // 60: sysml.ExecuteStateResponse.outcomes:type_name -> sysml.Outcome
-	20,  // 61: sysml.ExecuteStateResponse.exploration:type_name -> sysml.ExplorationStatus
-	75,  // 62: sysml.ConvertResponse.diagnostics:type_name -> sysml.Diagnostic
-	46,  // 63: sysml.ApplyEditsRequest.operations:type_name -> sysml.EditOperation
-	50,  // 64: sysml.EditOperation.set_value:type_name -> sysml.SetValueEdit
-	51,  // 65: sysml.EditOperation.rename:type_name -> sysml.RenameEdit
-	47,  // 66: sysml.EditOperation.add_member:type_name -> sysml.AddMemberEdit
-	48,  // 67: sysml.EditOperation.delete:type_name -> sysml.DeleteEdit
-	49,  // 68: sysml.EditOperation.move:type_name -> sysml.MoveEdit
-	53,  // 69: sysml.ApplyEditsResponse.applied:type_name -> sysml.AppliedEdit
-	1,   // 70: sysml.ApplyEditsResponse.failure:type_name -> sysml.EditFailure
-	75,  // 71: sysml.ApplyEditsResponse.diagnostics:type_name -> sysml.Diagnostic
-	105, // 72: sysml.SymbolInfo.metadata:type_name -> sysml.SymbolInfo.MetadataEntry
-	58,  // 73: sysml.SymbolInfo.attributes:type_name -> sysml.AttributeInfo
-	56,  // 74: sysml.SymbolInfo.type_info:type_name -> sysml.TypeInfo
-	57,  // 75: sysml.SymbolInfo.multiplicity:type_name -> sysml.MultiplicityInfo
-	55,  // 76: sysml.SymbolInfo.specializations:type_name -> sysml.Specialization
-	59,  // 77: sysml.AttributeInfo.value:type_name -> sysml.Value
-	70,  // 78: sysml.Value.sequence:type_name -> sysml.ValueSequence
-	71,  // 79: sysml.Value.quantity:type_name -> sysml.Quantity
-	69,  // 80: sysml.Value.enum_literal:type_name -> sysml.EnumLiteral
-	68,  // 81: sysml.Value.complex:type_name -> sysml.Complex
-	65,  // 82: sysml.Value.array:type_name -> sysml.Array
-	66,  // 83: sysml.Value.vector:type_name -> sysml.Vector
-	67,  // 84: sysml.Value.vector_quantity:type_name -> sysml.VectorQuantity
-	72,  // 85: sysml.Value.measurement_ref:type_name -> sysml.MeasurementRef
-	62,  // 86: sysml.Value.function:type_name -> sysml.Function
-	63,  // 87: sysml.Value.set:type_name -> sysml.ValueSet
-	64,  // 88: sysml.Value.tensor_quantity:type_name -> sysml.TensorQuantity
-	60,  // 89: sysml.Value.metaobject:type_name -> sysml.Metaobject
-	61,  // 90: sysml.Value.undetermined:type_name -> sysml.Undetermined
-	57,  // 91: sysml.Undetermined.count:type_name -> sysml.MultiplicityInfo
-	59,  // 92: sysml.ValueSet.elements:type_name -> sysml.Value
-	71,  // 93: sysml.TensorQuantity.components:type_name -> sysml.Quantity
-	59,  // 94: sysml.Array.elements:type_name -> sysml.Value
-	59,  // 95: sysml.Vector.components:type_name -> sysml.Value
-	71,  // 96: sysml.VectorQuantity.components:type_name -> sysml.Quantity
-	59,  // 97: sysml.EnumLiteral.value:type_name -> sysml.Value
-	59,  // 98: sysml.ValueSequence.elements:type_name -> sysml.Value
-	73,  // 99: sysml.Quantity.unit_term:type_name -> sysml.UnitTerm
-	73,  // 100: sysml.MeasurementRef.unit_term:type_name -> sysml.UnitTerm
-	74,  // 101: sysml.UnitTerm.factors:type_name -> sysml.UnitFactor
-	76,  // 102: sysml.Diagnostic.span:type_name -> sysml.Span
-	81,  // 103: sysml.QueryRequest.query:type_name -> sysml.Query
-	85,  // 104: sysml.QueryResponse.elements:type_name -> sysml.QueryResultElement
-	82,  // 105: sysml.Query.where:type_name -> sysml.Constraint
-	83,  // 106: sysml.Constraint.primitive:type_name -> sysml.PrimitiveConstraint
-	84,  // 107: sysml.Constraint.composite:type_name -> sysml.CompositeConstraint
-	2,   // 108: sysml.PrimitiveConstraint.operator:type_name -> sysml.PrimitiveOperator
-	3,   // 109: sysml.CompositeConstraint.operator:type_name -> sysml.CompositeOperator
-	82,  // 110: sysml.CompositeConstraint.constraint:type_name -> sysml.Constraint
-	106, // 111: sysml.QueryResultElement.properties:type_name -> sysml.QueryResultElement.PropertiesEntry
-	59,  // 112: sysml.SweepRange.start:type_name -> sysml.Value
-	59,  // 113: sysml.SweepRange.end:type_name -> sysml.Value
-	59,  // 114: sysml.SweepRange.step:type_name -> sysml.Value
-	59,  // 115: sysml.RunSweepRequest.arguments:type_name -> sysml.Value
-	107, // 116: sysml.RunSweepRequest.named_arguments:type_name -> sysml.RunSweepRequest.NamedArgumentsEntry
-	86,  // 117: sysml.RunSweepRequest.ranges:type_name -> sysml.SweepRange
-	15,  // 118: sysml.SweepRow.inputs:type_name -> sysml.CalcOutput
-	15,  // 119: sysml.SweepRow.outputs:type_name -> sysml.CalcOutput
-	4,   // 120: sysml.SweepRow.verdicts:type_name -> sysml.Verdict
-	0,   // 121: sysml.SweepRow.failure_reason:type_name -> sysml.FailureReason
-	16,  // 122: sysml.SweepRow.evaluations:type_name -> sysml.CaseEvaluation
-	88,  // 123: sysml.RunSweepResponse.rows:type_name -> sysml.SweepRow
-	75,  // 124: sysml.RunSweepResponse.diagnostics:type_name -> sysml.Diagnostic
-	0,   // 125: sysml.RunSweepResponse.failure_reason:type_name -> sysml.FailureReason
-	35,  // 126: sysml.RunSweepResponse.instances:type_name -> sysml.Instance
-	5,   // 127: sysml.RunSweepResponse.bounds:type_name -> sysml.Bound
-	91,  // 128: sysml.RunDocumentQueryRequest.bindings:type_name -> sysml.DocumentQueryBinding
-	92,  // 129: sysml.DocumentQueryBinding.values:type_name -> sysml.DocumentValue
-	71,  // 130: sysml.DocumentValue.quantity:type_name -> sysml.Quantity
-	92,  // 131: sysml.DocumentQueryCell.values:type_name -> sysml.DocumentValue
-	92,  // 132: sysml.DocumentQueryRow.element:type_name -> sysml.DocumentValue
-	94,  // 133: sysml.DocumentQueryRow.cells:type_name -> sysml.DocumentQueryCell
-	93,  // 134: sysml.RunDocumentQueryResponse.columns:type_name -> sysml.DocumentQueryColumn
-	95,  // 135: sysml.RunDocumentQueryResponse.rows:type_name -> sysml.DocumentQueryRow
-	59,  // 136: sysml.RunAnalysisRequest.NamedArgumentsEntry.value:type_name -> sysml.Value
-	59,  // 137: sysml.Outcome.OutputsEntry.value:type_name -> sysml.Value
-	36,  // 138: sysml.Instance.FeatureValuesEntry.value:type_name -> sysml.FeatureValue
-	59,  // 139: sysml.ExecuteActionRequest.InputsEntry.value:type_name -> sysml.Value
-	59,  // 140: sysml.ExecuteActionResponse.OutputsEntry.value:type_name -> sysml.Value
-	59,  // 141: sysml.ExecuteStateResponse.FinalContextEntry.value:type_name -> sysml.Value
-	59,  // 142: sysml.RunSweepRequest.NamedArgumentsEntry.value:type_name -> sysml.Value
-	77,  // 143: sysml.SysMLService.GetServerInfo:input_type -> sysml.ServerInfoRequest
-	24,  // 144: sysml.SysMLService.ParseFile:input_type -> sysml.ParseFileRequest
-	26,  // 145: sysml.SysMLService.ParseSources:input_type -> sysml.ParseSourcesRequest
-	29,  // 146: sysml.SysMLService.GetSymbol:input_type -> sysml.GetSymbolRequest
-	31,  // 147: sysml.SysMLService.GetDiagnostics:input_type -> sysml.DiagnosticsRequest
-	33,  // 148: sysml.SysMLService.Evaluate:input_type -> sysml.EvaluateRequest
-	37,  // 149: sysml.SysMLService.Instantiate:input_type -> sysml.InstantiateRequest
-	39,  // 150: sysml.SysMLService.ExecuteAction:input_type -> sysml.ExecuteActionRequest
-	41,  // 151: sysml.SysMLService.ExecuteState:input_type -> sysml.ExecuteStateRequest
-	43,  // 152: sysml.SysMLService.Convert:input_type -> sysml.ConvertRequest
-	45,  // 153: sysml.SysMLService.ApplyEdits:input_type -> sysml.ApplyEditsRequest
-	6,   // 154: sysml.SysMLService.VerifyConstraint:input_type -> sysml.VerifyConstraintRequest
-	8,   // 155: sysml.SysMLService.VerifyRequirement:input_type -> sysml.VerifyRequirementRequest
-	11,  // 156: sysml.SysMLService.VerifySatisfaction:input_type -> sysml.VerifySatisfactionRequest
-	13,  // 157: sysml.SysMLService.EvaluateCalc:input_type -> sysml.EvaluateCalcRequest
-	17,  // 158: sysml.SysMLService.RunAnalysis:input_type -> sysml.RunAnalysisRequest
-	87,  // 159: sysml.SysMLService.RunSweep:input_type -> sysml.RunSweepRequest
-	21,  // 160: sysml.SysMLService.ListEngines:input_type -> sysml.ListEnginesRequest
-	79,  // 161: sysml.SysMLService.Query:input_type -> sysml.QueryRequest
-	90,  // 162: sysml.SysMLService.RunDocumentQuery:input_type -> sysml.RunDocumentQueryRequest
-	97,  // 163: sysml.SysMLService.RenderDocument:input_type -> sysml.RenderDocumentRequest
-	78,  // 164: sysml.SysMLService.GetServerInfo:output_type -> sysml.ServerInfoResponse
-	28,  // 165: sysml.SysMLService.ParseFile:output_type -> sysml.ParseFileResponse
-	27,  // 166: sysml.SysMLService.ParseSources:output_type -> sysml.ParseSourcesResponse
-	30,  // 167: sysml.SysMLService.GetSymbol:output_type -> sysml.SymbolResponse
-	32,  // 168: sysml.SysMLService.GetDiagnostics:output_type -> sysml.DiagnosticsResponse
-	34,  // 169: sysml.SysMLService.Evaluate:output_type -> sysml.EvaluateResponse
-	38,  // 170: sysml.SysMLService.Instantiate:output_type -> sysml.InstantiateResponse
-	40,  // 171: sysml.SysMLService.ExecuteAction:output_type -> sysml.ExecuteActionResponse
-	42,  // 172: sysml.SysMLService.ExecuteState:output_type -> sysml.ExecuteStateResponse
-	44,  // 173: sysml.SysMLService.Convert:output_type -> sysml.ConvertResponse
-	52,  // 174: sysml.SysMLService.ApplyEdits:output_type -> sysml.ApplyEditsResponse
-	7,   // 175: sysml.SysMLService.VerifyConstraint:output_type -> sysml.VerifyConstraintResponse
-	10,  // 176: sysml.SysMLService.VerifyRequirement:output_type -> sysml.VerifyRequirementResponse
-	12,  // 177: sysml.SysMLService.VerifySatisfaction:output_type -> sysml.VerifySatisfactionResponse
-	14,  // 178: sysml.SysMLService.EvaluateCalc:output_type -> sysml.EvaluateCalcResponse
-	18,  // 179: sysml.SysMLService.RunAnalysis:output_type -> sysml.RunAnalysisResponse
-	89,  // 180: sysml.SysMLService.RunSweep:output_type -> sysml.RunSweepResponse
-	23,  // 181: sysml.SysMLService.ListEngines:output_type -> sysml.ListEnginesResponse
-	80,  // 182: sysml.SysMLService.Query:output_type -> sysml.QueryResponse
-	96,  // 183: sysml.SysMLService.RunDocumentQuery:output_type -> sysml.RunDocumentQueryResponse
-	98,  // 184: sysml.SysMLService.RenderDocument:output_type -> sysml.RenderDocumentResponse
-	164, // [164:185] is the sub-list for method output_type
-	143, // [143:164] is the sub-list for method input_type
-	143, // [143:143] is the sub-list for extension type_name
-	143, // [143:143] is the sub-list for extension extendee
-	0,   // [0:143] is the sub-list for field type_name
+	4,   // 14: sysml.ValidateInstanceResponse.verdicts:type_name -> sysml.Verdict
+	4,   // 15: sysml.ValidateInstanceResponse.summary:type_name -> sysml.Verdict
+	37,  // 16: sysml.ValidateInstanceResponse.instances:type_name -> sysml.Instance
+	79,  // 17: sysml.ValidateInstanceResponse.diagnostics:type_name -> sysml.Diagnostic
+	0,   // 18: sysml.ValidateInstanceResponse.failure_reason:type_name -> sysml.FailureReason
+	9,   // 19: sysml.ValidateInstanceResponse.verification_verdicts:type_name -> sysml.VerificationVerdict
+	63,  // 20: sysml.EvaluateCalcRequest.arguments:type_name -> sysml.Value
+	63,  // 21: sysml.EvaluateCalcResponse.result:type_name -> sysml.Value
+	17,  // 22: sysml.EvaluateCalcResponse.outputs:type_name -> sysml.CalcOutput
+	79,  // 23: sysml.EvaluateCalcResponse.diagnostics:type_name -> sysml.Diagnostic
+	0,   // 24: sysml.EvaluateCalcResponse.failure_reason:type_name -> sysml.FailureReason
+	5,   // 25: sysml.EvaluateCalcResponse.bounds:type_name -> sysml.Bound
+	63,  // 26: sysml.CalcOutput.value:type_name -> sysml.Value
+	63,  // 27: sysml.CaseEvaluation.arguments:type_name -> sysml.Value
+	63,  // 28: sysml.CaseEvaluation.result:type_name -> sysml.Value
+	63,  // 29: sysml.RunAnalysisRequest.arguments:type_name -> sysml.Value
+	107, // 30: sysml.RunAnalysisRequest.named_arguments:type_name -> sysml.RunAnalysisRequest.NamedArgumentsEntry
+	17,  // 31: sysml.RunAnalysisResponse.outputs:type_name -> sysml.CalcOutput
+	4,   // 32: sysml.RunAnalysisResponse.verdicts:type_name -> sysml.Verdict
+	37,  // 33: sysml.RunAnalysisResponse.instances:type_name -> sysml.Instance
+	79,  // 34: sysml.RunAnalysisResponse.diagnostics:type_name -> sysml.Diagnostic
+	0,   // 35: sysml.RunAnalysisResponse.failure_reason:type_name -> sysml.FailureReason
+	9,   // 36: sysml.RunAnalysisResponse.verification_verdicts:type_name -> sysml.VerificationVerdict
+	21,  // 37: sysml.RunAnalysisResponse.outcomes:type_name -> sysml.Outcome
+	22,  // 38: sysml.RunAnalysisResponse.exploration:type_name -> sysml.ExplorationStatus
+	18,  // 39: sysml.RunAnalysisResponse.evaluations:type_name -> sysml.CaseEvaluation
+	5,   // 40: sysml.RunAnalysisResponse.bounds:type_name -> sysml.Bound
+	108, // 41: sysml.Outcome.outputs:type_name -> sysml.Outcome.OutputsEntry
+	79,  // 42: sysml.Outcome.diagnostics:type_name -> sysml.Diagnostic
+	24,  // 43: sysml.ListEnginesResponse.engines:type_name -> sysml.EngineInfo
+	27,  // 44: sysml.ParseSourcesRequest.documents:type_name -> sysml.SourceDocument
+	58,  // 45: sysml.ParseSourcesResponse.roots:type_name -> sysml.SymbolInfo
+	79,  // 46: sysml.ParseSourcesResponse.diagnostics:type_name -> sysml.Diagnostic
+	58,  // 47: sysml.ParseFileResponse.root:type_name -> sysml.SymbolInfo
+	79,  // 48: sysml.ParseFileResponse.diagnostics:type_name -> sysml.Diagnostic
+	58,  // 49: sysml.SymbolResponse.symbol:type_name -> sysml.SymbolInfo
+	79,  // 50: sysml.DiagnosticsResponse.diagnostics:type_name -> sysml.Diagnostic
+	63,  // 51: sysml.EvaluateResponse.result:type_name -> sysml.Value
+	79,  // 52: sysml.EvaluateResponse.diagnostics:type_name -> sysml.Diagnostic
+	109, // 53: sysml.Instance.feature_values:type_name -> sysml.Instance.FeatureValuesEntry
+	63,  // 54: sysml.FeatureValue.value:type_name -> sysml.Value
+	63,  // 55: sysml.FeatureValue.values:type_name -> sysml.Value
+	37,  // 56: sysml.InstantiateResponse.instance:type_name -> sysml.Instance
+	79,  // 57: sysml.InstantiateResponse.diagnostics:type_name -> sysml.Diagnostic
+	37,  // 58: sysml.InstantiateResponse.instances:type_name -> sysml.Instance
+	110, // 59: sysml.ExecuteActionRequest.inputs:type_name -> sysml.ExecuteActionRequest.InputsEntry
+	111, // 60: sysml.ExecuteActionResponse.outputs:type_name -> sysml.ExecuteActionResponse.OutputsEntry
+	79,  // 61: sysml.ExecuteActionResponse.diagnostics:type_name -> sysml.Diagnostic
+	21,  // 62: sysml.ExecuteActionResponse.outcomes:type_name -> sysml.Outcome
+	22,  // 63: sysml.ExecuteActionResponse.exploration:type_name -> sysml.ExplorationStatus
+	112, // 64: sysml.ExecuteStateResponse.final_context:type_name -> sysml.ExecuteStateResponse.FinalContextEntry
+	79,  // 65: sysml.ExecuteStateResponse.diagnostics:type_name -> sysml.Diagnostic
+	21,  // 66: sysml.ExecuteStateResponse.outcomes:type_name -> sysml.Outcome
+	22,  // 67: sysml.ExecuteStateResponse.exploration:type_name -> sysml.ExplorationStatus
+	79,  // 68: sysml.ConvertResponse.diagnostics:type_name -> sysml.Diagnostic
+	48,  // 69: sysml.ApplyEditsRequest.operations:type_name -> sysml.EditOperation
+	52,  // 70: sysml.EditOperation.set_value:type_name -> sysml.SetValueEdit
+	53,  // 71: sysml.EditOperation.rename:type_name -> sysml.RenameEdit
+	49,  // 72: sysml.EditOperation.add_member:type_name -> sysml.AddMemberEdit
+	50,  // 73: sysml.EditOperation.delete:type_name -> sysml.DeleteEdit
+	51,  // 74: sysml.EditOperation.move:type_name -> sysml.MoveEdit
+	57,  // 75: sysml.ApplyEditsResponse.applied:type_name -> sysml.AppliedEdit
+	1,   // 76: sysml.ApplyEditsResponse.failure:type_name -> sysml.EditFailure
+	79,  // 77: sysml.ApplyEditsResponse.diagnostics:type_name -> sysml.Diagnostic
+	55,  // 78: sysml.ApplyEditsResponse.documents:type_name -> sysml.EditedDocument
+	56,  // 79: sysml.ApplyEditsResponse.referrers:type_name -> sysml.Referrer
+	113, // 80: sysml.SymbolInfo.metadata:type_name -> sysml.SymbolInfo.MetadataEntry
+	62,  // 81: sysml.SymbolInfo.attributes:type_name -> sysml.AttributeInfo
+	60,  // 82: sysml.SymbolInfo.type_info:type_name -> sysml.TypeInfo
+	61,  // 83: sysml.SymbolInfo.multiplicity:type_name -> sysml.MultiplicityInfo
+	59,  // 84: sysml.SymbolInfo.specializations:type_name -> sysml.Specialization
+	63,  // 85: sysml.AttributeInfo.value:type_name -> sysml.Value
+	74,  // 86: sysml.Value.sequence:type_name -> sysml.ValueSequence
+	75,  // 87: sysml.Value.quantity:type_name -> sysml.Quantity
+	73,  // 88: sysml.Value.enum_literal:type_name -> sysml.EnumLiteral
+	72,  // 89: sysml.Value.complex:type_name -> sysml.Complex
+	69,  // 90: sysml.Value.array:type_name -> sysml.Array
+	70,  // 91: sysml.Value.vector:type_name -> sysml.Vector
+	71,  // 92: sysml.Value.vector_quantity:type_name -> sysml.VectorQuantity
+	76,  // 93: sysml.Value.measurement_ref:type_name -> sysml.MeasurementRef
+	66,  // 94: sysml.Value.function:type_name -> sysml.Function
+	67,  // 95: sysml.Value.set:type_name -> sysml.ValueSet
+	68,  // 96: sysml.Value.tensor_quantity:type_name -> sysml.TensorQuantity
+	64,  // 97: sysml.Value.metaobject:type_name -> sysml.Metaobject
+	65,  // 98: sysml.Value.undetermined:type_name -> sysml.Undetermined
+	61,  // 99: sysml.Undetermined.count:type_name -> sysml.MultiplicityInfo
+	63,  // 100: sysml.ValueSet.elements:type_name -> sysml.Value
+	75,  // 101: sysml.TensorQuantity.components:type_name -> sysml.Quantity
+	63,  // 102: sysml.Array.elements:type_name -> sysml.Value
+	63,  // 103: sysml.Vector.components:type_name -> sysml.Value
+	75,  // 104: sysml.VectorQuantity.components:type_name -> sysml.Quantity
+	63,  // 105: sysml.EnumLiteral.value:type_name -> sysml.Value
+	63,  // 106: sysml.ValueSequence.elements:type_name -> sysml.Value
+	77,  // 107: sysml.Quantity.unit_term:type_name -> sysml.UnitTerm
+	77,  // 108: sysml.MeasurementRef.unit_term:type_name -> sysml.UnitTerm
+	78,  // 109: sysml.UnitTerm.factors:type_name -> sysml.UnitFactor
+	80,  // 110: sysml.Diagnostic.span:type_name -> sysml.Span
+	85,  // 111: sysml.QueryRequest.query:type_name -> sysml.Query
+	89,  // 112: sysml.QueryResponse.elements:type_name -> sysml.QueryResultElement
+	86,  // 113: sysml.Query.where:type_name -> sysml.Constraint
+	87,  // 114: sysml.Constraint.primitive:type_name -> sysml.PrimitiveConstraint
+	88,  // 115: sysml.Constraint.composite:type_name -> sysml.CompositeConstraint
+	2,   // 116: sysml.PrimitiveConstraint.operator:type_name -> sysml.PrimitiveOperator
+	3,   // 117: sysml.CompositeConstraint.operator:type_name -> sysml.CompositeOperator
+	86,  // 118: sysml.CompositeConstraint.constraint:type_name -> sysml.Constraint
+	114, // 119: sysml.QueryResultElement.properties:type_name -> sysml.QueryResultElement.PropertiesEntry
+	63,  // 120: sysml.SweepRange.start:type_name -> sysml.Value
+	63,  // 121: sysml.SweepRange.end:type_name -> sysml.Value
+	63,  // 122: sysml.SweepRange.step:type_name -> sysml.Value
+	63,  // 123: sysml.RunSweepRequest.arguments:type_name -> sysml.Value
+	115, // 124: sysml.RunSweepRequest.named_arguments:type_name -> sysml.RunSweepRequest.NamedArgumentsEntry
+	90,  // 125: sysml.RunSweepRequest.ranges:type_name -> sysml.SweepRange
+	17,  // 126: sysml.SweepRow.inputs:type_name -> sysml.CalcOutput
+	17,  // 127: sysml.SweepRow.outputs:type_name -> sysml.CalcOutput
+	4,   // 128: sysml.SweepRow.verdicts:type_name -> sysml.Verdict
+	0,   // 129: sysml.SweepRow.failure_reason:type_name -> sysml.FailureReason
+	18,  // 130: sysml.SweepRow.evaluations:type_name -> sysml.CaseEvaluation
+	92,  // 131: sysml.RunSweepResponse.rows:type_name -> sysml.SweepRow
+	79,  // 132: sysml.RunSweepResponse.diagnostics:type_name -> sysml.Diagnostic
+	0,   // 133: sysml.RunSweepResponse.failure_reason:type_name -> sysml.FailureReason
+	37,  // 134: sysml.RunSweepResponse.instances:type_name -> sysml.Instance
+	5,   // 135: sysml.RunSweepResponse.bounds:type_name -> sysml.Bound
+	95,  // 136: sysml.RunDocumentQueryRequest.bindings:type_name -> sysml.DocumentQueryBinding
+	96,  // 137: sysml.DocumentQueryBinding.values:type_name -> sysml.DocumentValue
+	75,  // 138: sysml.DocumentValue.quantity:type_name -> sysml.Quantity
+	98,  // 139: sysml.DocumentValue.verdict:type_name -> sysml.DocumentVerdict
+	97,  // 140: sysml.DocumentValue.object:type_name -> sysml.DocumentObject
+	99,  // 141: sysml.DocumentValue.state:type_name -> sysml.DocumentState
+	100, // 142: sysml.DocumentValue.event:type_name -> sysml.DocumentEvent
+	96,  // 143: sysml.DocumentObject.element:type_name -> sysml.DocumentValue
+	96,  // 144: sysml.DocumentVerdict.assertion:type_name -> sysml.DocumentValue
+	97,  // 145: sysml.DocumentState.object:type_name -> sysml.DocumentObject
+	96,  // 146: sysml.DocumentState.state:type_name -> sysml.DocumentValue
+	96,  // 147: sysml.DocumentEvent.time:type_name -> sysml.DocumentValue
+	97,  // 148: sysml.DocumentEvent.object:type_name -> sysml.DocumentObject
+	97,  // 149: sysml.DocumentEvent.target:type_name -> sysml.DocumentObject
+	96,  // 150: sysml.DocumentQueryCell.values:type_name -> sysml.DocumentValue
+	96,  // 151: sysml.DocumentQueryRow.element:type_name -> sysml.DocumentValue
+	102, // 152: sysml.DocumentQueryRow.cells:type_name -> sysml.DocumentQueryCell
+	101, // 153: sysml.RunDocumentQueryResponse.columns:type_name -> sysml.DocumentQueryColumn
+	103, // 154: sysml.RunDocumentQueryResponse.rows:type_name -> sysml.DocumentQueryRow
+	63,  // 155: sysml.RunAnalysisRequest.NamedArgumentsEntry.value:type_name -> sysml.Value
+	63,  // 156: sysml.Outcome.OutputsEntry.value:type_name -> sysml.Value
+	38,  // 157: sysml.Instance.FeatureValuesEntry.value:type_name -> sysml.FeatureValue
+	63,  // 158: sysml.ExecuteActionRequest.InputsEntry.value:type_name -> sysml.Value
+	63,  // 159: sysml.ExecuteActionResponse.OutputsEntry.value:type_name -> sysml.Value
+	63,  // 160: sysml.ExecuteStateResponse.FinalContextEntry.value:type_name -> sysml.Value
+	63,  // 161: sysml.RunSweepRequest.NamedArgumentsEntry.value:type_name -> sysml.Value
+	81,  // 162: sysml.SysMLService.GetServerInfo:input_type -> sysml.ServerInfoRequest
+	26,  // 163: sysml.SysMLService.ParseFile:input_type -> sysml.ParseFileRequest
+	28,  // 164: sysml.SysMLService.ParseSources:input_type -> sysml.ParseSourcesRequest
+	31,  // 165: sysml.SysMLService.GetSymbol:input_type -> sysml.GetSymbolRequest
+	33,  // 166: sysml.SysMLService.GetDiagnostics:input_type -> sysml.DiagnosticsRequest
+	35,  // 167: sysml.SysMLService.Evaluate:input_type -> sysml.EvaluateRequest
+	39,  // 168: sysml.SysMLService.Instantiate:input_type -> sysml.InstantiateRequest
+	41,  // 169: sysml.SysMLService.ExecuteAction:input_type -> sysml.ExecuteActionRequest
+	43,  // 170: sysml.SysMLService.ExecuteState:input_type -> sysml.ExecuteStateRequest
+	45,  // 171: sysml.SysMLService.Convert:input_type -> sysml.ConvertRequest
+	47,  // 172: sysml.SysMLService.ApplyEdits:input_type -> sysml.ApplyEditsRequest
+	6,   // 173: sysml.SysMLService.VerifyConstraint:input_type -> sysml.VerifyConstraintRequest
+	8,   // 174: sysml.SysMLService.VerifyRequirement:input_type -> sysml.VerifyRequirementRequest
+	11,  // 175: sysml.SysMLService.VerifySatisfaction:input_type -> sysml.VerifySatisfactionRequest
+	13,  // 176: sysml.SysMLService.ValidateInstance:input_type -> sysml.ValidateInstanceRequest
+	15,  // 177: sysml.SysMLService.EvaluateCalc:input_type -> sysml.EvaluateCalcRequest
+	19,  // 178: sysml.SysMLService.RunAnalysis:input_type -> sysml.RunAnalysisRequest
+	91,  // 179: sysml.SysMLService.RunSweep:input_type -> sysml.RunSweepRequest
+	23,  // 180: sysml.SysMLService.ListEngines:input_type -> sysml.ListEnginesRequest
+	83,  // 181: sysml.SysMLService.Query:input_type -> sysml.QueryRequest
+	94,  // 182: sysml.SysMLService.RunDocumentQuery:input_type -> sysml.RunDocumentQueryRequest
+	105, // 183: sysml.SysMLService.RenderDocument:input_type -> sysml.RenderDocumentRequest
+	82,  // 184: sysml.SysMLService.GetServerInfo:output_type -> sysml.ServerInfoResponse
+	30,  // 185: sysml.SysMLService.ParseFile:output_type -> sysml.ParseFileResponse
+	29,  // 186: sysml.SysMLService.ParseSources:output_type -> sysml.ParseSourcesResponse
+	32,  // 187: sysml.SysMLService.GetSymbol:output_type -> sysml.SymbolResponse
+	34,  // 188: sysml.SysMLService.GetDiagnostics:output_type -> sysml.DiagnosticsResponse
+	36,  // 189: sysml.SysMLService.Evaluate:output_type -> sysml.EvaluateResponse
+	40,  // 190: sysml.SysMLService.Instantiate:output_type -> sysml.InstantiateResponse
+	42,  // 191: sysml.SysMLService.ExecuteAction:output_type -> sysml.ExecuteActionResponse
+	44,  // 192: sysml.SysMLService.ExecuteState:output_type -> sysml.ExecuteStateResponse
+	46,  // 193: sysml.SysMLService.Convert:output_type -> sysml.ConvertResponse
+	54,  // 194: sysml.SysMLService.ApplyEdits:output_type -> sysml.ApplyEditsResponse
+	7,   // 195: sysml.SysMLService.VerifyConstraint:output_type -> sysml.VerifyConstraintResponse
+	10,  // 196: sysml.SysMLService.VerifyRequirement:output_type -> sysml.VerifyRequirementResponse
+	12,  // 197: sysml.SysMLService.VerifySatisfaction:output_type -> sysml.VerifySatisfactionResponse
+	14,  // 198: sysml.SysMLService.ValidateInstance:output_type -> sysml.ValidateInstanceResponse
+	16,  // 199: sysml.SysMLService.EvaluateCalc:output_type -> sysml.EvaluateCalcResponse
+	20,  // 200: sysml.SysMLService.RunAnalysis:output_type -> sysml.RunAnalysisResponse
+	93,  // 201: sysml.SysMLService.RunSweep:output_type -> sysml.RunSweepResponse
+	25,  // 202: sysml.SysMLService.ListEngines:output_type -> sysml.ListEnginesResponse
+	84,  // 203: sysml.SysMLService.Query:output_type -> sysml.QueryResponse
+	104, // 204: sysml.SysMLService.RunDocumentQuery:output_type -> sysml.RunDocumentQueryResponse
+	106, // 205: sysml.SysMLService.RenderDocument:output_type -> sysml.RenderDocumentResponse
+	184, // [184:206] is the sub-list for method output_type
+	162, // [162:184] is the sub-list for method input_type
+	162, // [162:162] is the sub-list for extension type_name
+	162, // [162:162] is the sub-list for extension extendee
+	0,   // [0:162] is the sub-list for field type_name
 }
 
 func init() { file_sysml_proto_init() }
@@ -8992,27 +10101,27 @@ func file_sysml_proto_init() {
 	if File_sysml_proto != nil {
 		return
 	}
-	file_sysml_proto_msgTypes[20].OneofWrappers = []any{
+	file_sysml_proto_msgTypes[22].OneofWrappers = []any{
 		(*ParseFileRequest_FilePath)(nil),
 		(*ParseFileRequest_Content)(nil),
 	}
-	file_sysml_proto_msgTypes[21].OneofWrappers = []any{
+	file_sysml_proto_msgTypes[23].OneofWrappers = []any{
 		(*SourceDocument_FilePath)(nil),
 		(*SourceDocument_Content)(nil),
 	}
-	file_sysml_proto_msgTypes[39].OneofWrappers = []any{
+	file_sysml_proto_msgTypes[41].OneofWrappers = []any{
 		(*ConvertRequest_FilePath)(nil),
 		(*ConvertRequest_Content)(nil),
 		(*ConvertRequest_ModelHash)(nil),
 	}
-	file_sysml_proto_msgTypes[42].OneofWrappers = []any{
+	file_sysml_proto_msgTypes[44].OneofWrappers = []any{
 		(*EditOperation_SetValue)(nil),
 		(*EditOperation_Rename)(nil),
 		(*EditOperation_AddMember)(nil),
 		(*EditOperation_Delete)(nil),
 		(*EditOperation_Move)(nil),
 	}
-	file_sysml_proto_msgTypes[55].OneofWrappers = []any{
+	file_sysml_proto_msgTypes[59].OneofWrappers = []any{
 		(*Value_IntValue)(nil),
 		(*Value_RealValue)(nil),
 		(*Value_BoolValue)(nil),
@@ -9035,15 +10144,15 @@ func file_sysml_proto_init() {
 		(*Value_Metaobject)(nil),
 		(*Value_Undetermined)(nil),
 	}
-	file_sysml_proto_msgTypes[67].OneofWrappers = []any{
+	file_sysml_proto_msgTypes[71].OneofWrappers = []any{
 		(*Quantity_IntMagnitude)(nil),
 		(*Quantity_RealMagnitude)(nil),
 	}
-	file_sysml_proto_msgTypes[78].OneofWrappers = []any{
+	file_sysml_proto_msgTypes[82].OneofWrappers = []any{
 		(*Constraint_Primitive)(nil),
 		(*Constraint_Composite)(nil),
 	}
-	file_sysml_proto_msgTypes[88].OneofWrappers = []any{
+	file_sysml_proto_msgTypes[92].OneofWrappers = []any{
 		(*DocumentValue_ElementId)(nil),
 		(*DocumentValue_StringValue)(nil),
 		(*DocumentValue_IntValue)(nil),
@@ -9051,6 +10160,10 @@ func file_sysml_proto_init() {
 		(*DocumentValue_BoolValue)(nil),
 		(*DocumentValue_Infinity)(nil),
 		(*DocumentValue_Quantity)(nil),
+		(*DocumentValue_Verdict)(nil),
+		(*DocumentValue_Object)(nil),
+		(*DocumentValue_State)(nil),
+		(*DocumentValue_Event)(nil),
 	}
 	type x struct{}
 	out := protoimpl.TypeBuilder{
@@ -9058,7 +10171,7 @@ func file_sysml_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: unsafe.Slice(unsafe.StringData(file_sysml_proto_rawDesc), len(file_sysml_proto_rawDesc)),
 			NumEnums:      4,
-			NumMessages:   104,
+			NumMessages:   112,
 			NumExtensions: 0,
 			NumServices:   1,
 		},
diff --git a/api/proto/sysml.proto b/api/proto/sysml.proto
index 2da8184d0c..bf978a6060 100644
--- a/api/proto/sysml.proto
+++ b/api/proto/sysml.proto
@@ -51,6 +51,13 @@ service SysMLService {
   rpc VerifyConstraint(VerifyConstraintRequest) returns (VerifyConstraintResponse);
   rpc VerifyRequirement(VerifyRequirementRequest) returns (VerifyRequirementResponse);
   rpc VerifySatisfaction(VerifySatisfactionRequest) returns (VerifySatisfactionResponse);
+  // Validate an object as a whole, as the REPL's %validate and the CLI's
+  // -validate=<object> do: every assertion about an object of the named part
+  // and about the objects it holds — the asserted constraints of their types,
+  // the requirements they carry and the satisfactions they are the subject of —
+  // each answered on the concrete object it is about. Reported as the
+  // "verification" capability.
+  rpc ValidateInstance(ValidateInstanceRequest) returns (ValidateInstanceResponse);
   rpc EvaluateCalc(EvaluateCalcRequest) returns (EvaluateCalcResponse);
   rpc RunAnalysis(RunAnalysisRequest) returns (RunAnalysisResponse);
 
@@ -98,7 +105,8 @@ enum FailureReason {
 // it did not, which condition the model answered false about.
 message Verdict {
   // What was verified: "constraint", "requirement" or "satisfy"; for a check
-  // an analysis case run made, "objective" or "assertion".
+  // an analysis case run made, "objective" or "assertion"; for the summary
+  // of a ValidateInstance, "object".
   string kind = 1;
   // FQN of the element verified; empty for an anonymous satisfy assertion or
   // an anonymous assertion in a case body.
@@ -141,6 +149,11 @@ message Verdict {
   string strength = 12;
   // The bounds the engine ran under, each marked when it stopped the run.
   repeated Bound bounds = 13;
+  // For a verdict ValidateInstance reports: the path from the validated object
+  // to the object this verdict is about, as the REPL spells it ("engine",
+  // "wheels[2]", "engine.injector"). Empty for the validated object itself,
+  // and for every other RPC.
+  string instance_path = 14;
 }
 
 // Bound is one limit an engine ran under, as its plan names it: a count, or
@@ -164,7 +177,10 @@ message VerifyConstraintRequest {
   // Optional FQN of a part/usage to instantiate and evaluate the constraint
   // against, so the verdict is about concrete values rather than declared
   // defaults. The prompt evaluates against the object a %instantiate created;
-  // a call carries no session, so it names the subject instead.
+  // a call carries no session, so it names the subject instead. A path from a
+  // declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+  // evaluates against the object the path reaches, inside the assembly built
+  // around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
   string subject_symbol_id = 3;
   // The engine the question is put to, as ListEngines names it, "auto" for the
   // strongest covering one, or "all" for every covering one. Unset is "auto".
@@ -191,7 +207,8 @@ message VerifyRequirementRequest {
   string model_hash = 1;
   // FQN of the requirement definition or usage to evaluate.
   string symbol_id = 2;
-  // Optional FQN of a part/usage to instantiate and evaluate against.
+  // Optional FQN of a part/usage to instantiate and evaluate against, or a path
+  // from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
   string subject_symbol_id = 3;
   // The engine the question is put to; see VerifyConstraintRequest.engine.
   string engine = 4;
@@ -257,6 +274,45 @@ message VerifySatisfactionResponse {
   repeated VerificationVerdict verification_verdicts = 6;
 }
 
+// ValidateInstanceRequest asks for every assertion about an object of a part,
+// as %validate does: the object is built for the call.
+message ValidateInstanceRequest {
+  string model_hash = 1;
+  // FQN of the part or usage an object of which is validated.
+  string symbol_id = 2;
+  // The engine the validation is put to; see VerifyConstraintRequest.engine.
+  string engine = 3;
+}
+
+// ValidateInstanceResponse carries one verdict per assertion, in the order the
+// objects were reached from the validated one, and the verdict about the object
+// as a whole. An object no assertion is about has an empty list and a summary
+// that decides nothing.
+message ValidateInstanceResponse {
+  // One per assertion: kind "constraint", "requirement" or "satisfy", about the
+  // object `instance_id` names, reached along `instance_path`.
+  repeated Verdict verdicts = 1;
+  // The object as a whole, kind "object": holds when every assertion holds and
+  // every held object was reached. With `error` empty, false is the model's
+  // answer, some assertion failing; with `error` set, nothing was decided, an
+  // assertion being undecided or nesting left unreached.
+  Verdict summary = 2;
+  // Every object reached, the validated one first, so a client can read the
+  // feature values behind each verdict.
+  repeated Instance instances = 3;
+  string error = 4;
+  repeated Diagnostic diagnostics = 5;
+  // What kind of failure `error` reports.
+  FailureReason failure_reason = 6;
+  // What the body of every verification case verifying a requirement a verdict
+  // is about answered, once per requirement; each names its requirement, which
+  // the requirement and satisfy verdicts carry as their requirement_id.
+  repeated VerificationVerdict verification_verdicts = 7;
+  // True when nesting deeper than the validation descends, or past its budget,
+  // was left unvalidated; the summary then decides nothing.
+  bool bounded = 8;
+}
+
 // EvaluateCalcRequest invokes a calculation, as %calc does. Arguments are bound
 // positionally; a calc usage named with no arguments binds its inputs from its
 // own members and reports every output feature it computes (SysML 7.17).
@@ -327,7 +383,10 @@ message RunAnalysisRequest {
   string symbol_id = 2;
   // Optional FQN of a part/usage to instantiate as the case's subject. Empty
   // leaves the case's own `subject s = ...` binding to supply it; a case that
-  // binds none and is given none fails to run.
+  // binds none and is given none fails to run. A path from a declaration, as
+  // `Mission::mission.vehicle`, makes the subject the object the path reaches in
+  // an object of the declaration built for the run — every explored run its own
+  // — so what the assembly binds and connects on it is in force.
   string subject_symbol_id = 3;
   // Positional arguments for the case's input parameters, in declaration order;
   // the subject is never among them.
@@ -412,6 +471,9 @@ message Outcome {
   // it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
   // one run.
   repeated Diagnostic diagnostics = 7;
+  // The probability of the linearizations reaching this outcome, as explore
+  // computes it; a lower bound when the exploration is incomplete.
+  double probability = 8;
 }
 
 // ExplorationStatus is how an exploration ended: whether every linearization
@@ -427,6 +489,9 @@ message ExplorationStatus {
   // one run may resolve before the rest take their first alternative.
   int32 runs_budget = 4;
   int32 depth_budget = 5;
+  // True when the outcomes' probabilities are lower bounds: a budget kept some
+  // linearizations unexplored.
+  bool probabilities_lower_bound = 6;
 }
 
 // ListEnginesRequest asks for the analysis engines registered in this build.
@@ -603,7 +668,11 @@ message InstantiateRequest {
   string symbol_id = 2;          // FQN of part/usage to instantiate
 }
 
-// InstantiateResponse contains the created instance
+// InstantiateResponse contains the created instance. The service keeps the
+// object for the model it was created in: a later RunDocumentQuery on the same
+// model_hash binds it by its id or by the name it was instantiated under, and
+// `DocumentQueries::Objects` enumerates it. Instantiating the same symbol again
+// creates a new object under the name; the earlier one stays reachable by id.
 message InstantiateResponse {
   Instance instance = 1;
   string error = 2;
@@ -625,6 +694,12 @@ message ExecuteActionRequest {
   // runs (default 1024) and of choice points per run (default 64), each run on
   // a fresh context, and answers with every distinct outcome reached.
   string schedule = 4;
+  // Optional FQN of a part/usage to perform the action on, or a path from one to
+  // a nested object, as `Mission::mission.vehicle`: the object is created for the
+  // run — inside the assembly the path walks, so its connectors carry what the
+  // action sends — and under explore each run creates its own. Empty performs
+  // the action outside any object.
+  string performer_symbol_id = 5;
 }
 
 // ExecuteActionResponse contains action execution results
@@ -655,6 +730,12 @@ message ExecuteStateRequest {
   // the response answers with `outcomes` and `exploration` in place of one run's
   // states_visited, final_context and error (see ExecuteActionResponse).
   string schedule = 4;
+  // Optional FQN of a part/usage to run the machine on, or a path from one to a
+  // nested object (see ExecuteActionRequest.performer_symbol_id). An object
+  // exhibiting the machine runs the one it exhibits, so what its assembly
+  // connects to it reaches the run; one exhibiting it under several usages is
+  // an error. Empty runs the machine outside any object.
+  string performer_symbol_id = 5;
 }
 
 // ExecuteStateResponse contains state machine execution trace
@@ -684,11 +765,14 @@ message ConvertRequest {
     string content = 2;
     string model_hash = 6;
   }
-  // "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-  // file_path's extension, and is notation for a model_hash, since that is what
-  // parse reads; inline content has neither, so it must say.
+  // "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+  // "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+  // v2 and never written. Empty infers from file_path's extension, and is
+  // notation for a model_hash, since that is what parse reads; inline content
+  // has neither, so it must say.
   string from_format = 3;
-  // Format to write, named as in from_format. Empty is rejected.
+  // Format to write, named as in from_format; the v1 names are refused, since
+  // a v2 model has no v1 form. Empty is rejected.
   string to_format = 4;
   // Write notation back out even when the parser could not read all of it,
   // reporting its syntax errors as diagnostics. Notation to notation only:
@@ -708,10 +792,12 @@ message ConvertResponse {
   // Syntax errors tolerated under tolerate_syntax_errors, or those that failed
   // the conversion.
   repeated Diagnostic diagnostics = 5;
-  // Set when either format is RDF, whose mapping is experimental: it covers
-  // model structure and the behavior its bodies state, refuses what it cannot
-  // write back, and its vocabulary may change without a compatibility path.
-  // Notation to notation is stable and leaves this unset.
+  // Set when either format is RDF or the API's JSON element form, whose
+  // mapping is experimental: it covers model structure and the behavior its
+  // bodies state, refuses what it cannot write back, and its vocabulary may
+  // change without a compatibility path. Also set when the source is SysML v1,
+  // whose migration is experimental in the same sense. Notation to notation is
+  // stable and leaves this unset.
   bool experimental = 6;
   // What is experimental about the conversion, in the wording every surface
   // reports it in. Empty when experimental is false.
@@ -720,12 +806,28 @@ message ConvertResponse {
 
 // ApplyEditsRequest asks for a model's source with edits applied to it. The
 // source edited is the one parse read, named by its hash, so an edit is applied
-// to the model that was inspected.
+// to the model that was inspected. A model of several documents (ParseSources)
+// is edited as one when the request sets `accept_documents`: the operations
+// target declarations of the document named by `document`, and a rename or
+// cascade delete follows references into every other document of the model,
+// rewriting those too. `document` and `accept_documents` are advertised as the
+// "edit_documents" capability: a service without it edits a model of one
+// document alone and answers `content` alone, so a client checks it before
+// naming a document or reading `documents`.
 message ApplyEditsRequest {
-  string model_hash = 1;  // from ParseFile response
+  string model_hash = 1;  // from a ParseFile or ParseSources response
   // Edits to apply, all of them or none. Empty is rejected: it names no edit,
   // and answering with the unedited source would look like one was made.
   repeated EditOperation operations = 2;
+  // The document whose declarations the operations target, named as the parse
+  // request named it; empty names the model's first document, which is the
+  // only one of a ParseFile model. An operation targeting a declaration of
+  // another document is refused as an unknown target, naming that document. A
+  // name no document of the model has fails the call as an invalid argument.
+  string document = 3;
+  // Whether the client reads the response's `documents`. A model of several documents is
+  // edited only when set; unset, such a model is refused as a failed precondition, as before.
+  bool accept_documents = 4;
 }
 
 // EditOperation is one source-preserving change to make.
@@ -786,31 +888,72 @@ message SetValueEdit {
   string value = 2;
 }
 
-// RenameEdit rewrites the name token of a declaration. References to the
-// element are not updated: a rename of an element referenced anywhere is
-// refused, naming the referring elements, rather than leaving a broken model.
+// RenameEdit rewrites the name token of a declaration and every reference to
+// it in the model's documents. A rename that reaches a reference in a document
+// the edit cannot rewrite is refused, naming the referring elements, rather
+// than leaving a broken model.
 message RenameEdit {
   string target = 1;    // element to rename, as SymbolInfo.id names it
   string new_name = 2;  // new declared name; must lex as an identifier
 }
 
 // ApplyEditsResponse carries the edited source, or says why nothing was edited.
+// The edited notation is in `documents`, one entry per document the edits
+// rewrote; `content` repeats it for a single-document model only.
 message ApplyEditsResponse {
-  // The edited notation, byte-identical to the source outside the edited spans.
-  // Empty when the edits were refused, so a refusal never writes a file.
+  // The edited notation of a single-document model, byte-identical to the
+  // source outside the edited spans. Empty when the edits were refused, so a
+  // refusal never writes a file, and empty for a model of several documents,
+  // whose edited notation is in `documents` alone: a client that reads
+  // `content` alone was written for one document and must not write one
+  // document's notation over another's. `documents` carries the same notation
+  // for a single-document model, so a client needs one code path.
   string content = 1;
-  // What each operation changed, in request order.
+  // What each operation changed, grouped by document in the order `documents`
+  // lists them and in request order within a document.
   repeated AppliedEdit applied = 2;
-  string error = 3;  // non-empty if the edits were refused; content is unset
+  string error = 3;  // non-empty if the edits were refused; content and documents are unset
   // Which kind of refusal `error` reports, so a client acts on the kind rather
   // than on the message text.
   EditFailure failure = 4;
   // Diagnostics behind a refusal: the parse errors of an unreadable new value,
-  // or the errors the edited source was found to have.
+  // or the errors the edited source was found to have. A diagnostic's span
+  // names the document it is in.
   repeated Diagnostic diagnostics = 5;
-  // Where the references to a declaration whose rename was refused are made:
-  // the FQN of each referring namespace.
+  // Where the references to a declaration whose rename, delete or move was
+  // refused are made: the FQN of each referring namespace, suffixed with its
+  // document in parentheses when that is not the document being edited.
+  // `referrers` carries the same list with the document as a field of its own.
   repeated string referring_elements = 6;
+  // The edited notation of every document the edits rewrote, named as the
+  // parse request named it: the document being edited first, then the others
+  // in name order. A document of several the edits left as parsed is not
+  // listed; the one document of a single-document model always is. Empty when
+  // the edits were refused.
+  repeated EditedDocument documents = 7;
+  // The declarations referring to the target of a refused rename, delete or
+  // move, each with the document declaring it, in document then name order.
+  // Empty when `referring_elements` is.
+  repeated Referrer referrers = 8;
+}
+
+// EditedDocument is the edited notation of one document of the model.
+message EditedDocument {
+  // The document's name as the parse request gave it: the file path of a
+  // file_path document, the name of an inline one, or the position it was
+  // given when it was named neither.
+  string name = 1;
+  // The edited notation, byte-identical to the source outside the edited spans.
+  string content = 2;
+}
+
+// Referrer is one declaration referring to the target of a refused edit.
+message Referrer {
+  // The declaration as the notation names it: the FQN of a named one, or the
+  // heading of an anonymous one within its namespace.
+  string name = 1;
+  // The document declaring it, named as the parse request named it.
+  string document = 2;
 }
 
 // AppliedEdit is one byte range of the original source that an operation
@@ -825,6 +968,9 @@ message AppliedEdit {
   int32 length = 4;     // bytes replaced; zero for text inserted
   string old_text = 5;  // what was there, empty for an insertion
   string new_text = 6;  // what was written
+  // The document the bytes are in, named as the parse request named it and as
+  // ApplyEditsResponse.documents lists it. Set for a single-document model too.
+  string document = 7;
 }
 
 // EditFailure says why edits were refused. Every refusal is one of these: an
@@ -848,6 +994,10 @@ enum EditFailure {
   EDIT_FAILURE_DELETE_REFERENCED = 15; // delete would leave references
   EDIT_FAILURE_OWNER_INSIDE_TARGET = 16; // move owner is the target or inside it
   EDIT_FAILURE_MOVE_REFERENCED = 17;  // move would leave a reference no spelling restores
+  // the target is referred to from a document the edit cannot rewrite: a
+  // library document, or any other document for a move, which respells
+  // references in the target's own document only
+  EDIT_FAILURE_REFERENCED_ELSEWHERE = 18;
 }
 
 // SymbolInfo represents any SysML element
@@ -1232,6 +1382,14 @@ message ServerInfoResponse {
   //                  rather than read as another value.
   //   "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
   //                  preserving everything the edit did not touch.
+  //   "edit_documents" - ApplyEdits edits a model of several documents as one
+  //                  batch for a request setting accept_documents, targets the
+  //                  document the request names, and answers each edited
+  //                  document by name in `documents`, each referrer of a refusal
+  //                  with its document in `referrers`, and each applied edit's
+  //                  `document`. Without it those fields are empty, a model of
+  //                  several documents is refused with FAILED_PRECONDITION, and
+  //                  a request naming a document is refused with UNIMPLEMENTED.
   //   "document_query" - the RunDocumentQuery RPC runs a named document query
   //                  and answers with typed rows.
   //   "render_document" - the RenderDocument RPC renders a named document to
@@ -1381,7 +1539,8 @@ message RunSweepRequest {
   string model_hash = 1;
   // FQN of the analysis case or calc, definition or usage.
   string symbol_id = 2;
-  // Optional FQN of a part/usage to instantiate as an analysis case's subject.
+  // Optional FQN of a part/usage to instantiate as an analysis case's subject,
+  // or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
   string subject_symbol_id = 3;
   // Positional arguments for the target's input parameters, in declaration
   // order, as every row binds them.
@@ -1456,7 +1615,9 @@ message RunSweepResponse {
 // RunDocumentQueryRequest runs a named document query — a calc def
 // specializing DocumentQueries::Query — against a model the service already
 // parsed, binding its entry parameters. It answers as %run-query does, but with
-// typed rows rather than formatted lines.
+// typed rows rather than formatted lines. The query runs over the objects
+// Instantiate created for the model, which a binding may name (DocumentObject)
+// and `DocumentQueries::Objects` and `Verdicts` reach.
 message RunDocumentQueryRequest {
   string model_hash = 1;  // from ParseFile response
   // FQN of the document query to run. Fails with NOT_FOUND when the model does
@@ -1474,9 +1635,9 @@ message DocumentQueryBinding {
 }
 
 // DocumentValue is one typed document-query value. A request binds a model
-// element by qualified name in element_id; a response also says what the
-// element is in element_type. `infinity` denotes an unbounded multiplicity and
-// is only ever answered, never bound.
+// element by qualified name in element_id, or an object the service holds in
+// object; a response also says what the element is in element_type. `infinity`
+// denotes an unbounded multiplicity and is only ever answered, never bound.
 message DocumentValue {
   oneof kind {
     string element_id = 1;   // qualified name of a model element
@@ -1486,11 +1647,118 @@ message DocumentValue {
     bool bool_value = 5;
     bool infinity = 6;
     Quantity quantity = 8;   // magnitude in a unit, `2290000 [kg]`
+    DocumentVerdict verdict = 9;  // a row Verdicts answered; answered, never bound
+    DocumentObject object = 10;   // an object Instantiate created; bound and answered
+    DocumentState state = 11;     // a row States answered; answered, never bound
+    DocumentEvent event = 12;     // a row Events answered; answered, never bound
   }
   // Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
   string element_type = 7;
 }
 
+// DocumentObject is an object the service holds for the model, created by
+// Instantiate, as a query binds and answers it. A request names it by path
+// when path is set, and by instance_id otherwise; a request setting both must
+// name one object by both. A response answers both, and the element the object
+// stands for.
+message DocumentObject {
+  // The object's id, as Instantiate answered it. Fails with NOT_FOUND when no
+  // object of the model has it.
+  int64 instance_id = 1;
+  // The object by the label a session reaches it under: the qualified name it
+  // was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+  // feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+  // count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+  // a segment reaches no object, and NOT_FOUND when the name it starts from has
+  // no object.
+  string path = 2;
+  // The usage the object is held under — its own definition or usage for an
+  // object Instantiate created — as an element value with its qualified name
+  // and metamodel type. Answered, ignored when bound.
+  DocumentValue element = 3;
+}
+
+// DocumentVerdict is one row a `Verdicts` query answered: an assertion checked
+// on the object at `path`, which the row stands for as its `element`.
+message DocumentVerdict {
+  // The assertion checked, as an element value: the constraint, requirement,
+  // satisfy usage or verification case, its element_id empty when anonymous.
+  DocumentValue assertion = 1;
+  // "constraint", "requirement", "satisfaction" or "verification".
+  string kind = 2;
+  // The assertion as written ("assert constraint massKnown", "satisfy Range by
+  // cruise", "verification Tests::massTest"), naming an anonymous one.
+  string text = 3;
+  // The object checked, by path from the element the query was bound to
+  // ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+  string path = 4;
+  // "holds", "violated" or "undecided".
+  string verdict = 5;
+  // The condition that evaluated to false, as written; empty otherwise.
+  string condition = 6;
+  // Why the assertion is violated or undecided; empty when it holds.
+  string reason = 7;
+  // The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+  // verification cases verifying the requirement the row is about; a
+  // verification row's own kind. Empty for a constraint.
+  repeated string verification = 8;
+}
+
+// DocumentState is one row a `States` query answered: an active leaf state of
+// the state machine `object` exhibits, which the row stands for as its `element`
+// when the model declares it.
+message DocumentState {
+  // The object in the state, with the path the session reaches it by.
+  DocumentObject object = 1;
+  // The state machine, by the name the object exhibits it under (`lp`), else
+  // its declared name.
+  string machine = 2;
+  // The leaf state's name.
+  string name = 3;
+  // The leaf state's path in its machine, composite states first (`on.dim`).
+  string state_path = 4;
+  // The leaf state's declaration as an element value; empty when the machine
+  // declares no element for it.
+  DocumentValue state = 5;
+  // The orthogonal region the leaf is declared in; empty outside one.
+  string region = 6;
+  // The composite states enclosing the leaf, outermost first; each is active.
+  repeated string enclosing = 7;
+}
+
+// DocumentEvent is one row an `Events` query answered: one record of the
+// session's trace, in the order the run made it.
+message DocumentEvent {
+  // "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+  string kind = 1;
+  // The clock's instant when the record was made: a quantity in the clock's
+  // unit when the library reduces one, else a bare real of clock units.
+  DocumentValue time = 2;
+  // The object whose behavior made the record; unset for the run's own
+  // records (a due-order choice, a message posted from outside the model).
+  DocumentObject object = 3;
+  // The behavior the record is about, by the name the object exhibits it
+  // under, else its declared name; empty for the run's own records.
+  string machine = 4;
+  // The state entered, exited or stepped by an entry, exit or do record.
+  string state = 5;
+  // A fired transition's source and target states.
+  string from = 6;
+  string to = 7;
+  // The object a send was addressed to; unset for every other kind and a
+  // send addressed to no object.
+  DocumentObject target = 8;
+  // The signal or event accepted or sent, or the trigger a transition fired on.
+  string event = 9;
+  // An accept's payload, one `name = value` entry per parameter in name order.
+  repeated string payload = 10;
+  // A choice's alternatives, in the order they were offered, and the one taken.
+  repeated string alternatives = 11;
+  string taken = 12;
+  // The line the trace prints for the record.
+  string text = 13;
+}
+
 // DocumentQueryColumn is one projected property, in projection order.
 message DocumentQueryColumn {
   string name = 1;
@@ -1504,7 +1772,10 @@ message DocumentQueryCell {
 // DocumentQueryRow is one selected element and its projected cells, one per
 // column in column order.
 message DocumentQueryRow {
-  // The selected element itself, an element value with its qualified name.
+  // The selected element itself, an element value with its qualified name; an
+  // object value for a row over an object the service holds; a verdict value
+  // for a row `Verdicts` answered; a state value for a row `States` answered;
+  // an event value for a row `Events` answered.
   DocumentValue element = 1;
   repeated DocumentQueryCell cells = 2;
 }
@@ -1519,17 +1790,24 @@ message RunDocumentQueryResponse {
 }
 
 // RenderDocumentRequest renders a named document — a part def specializing
-// DocumentQueries::Document — to Markdown. A document binds its queries'
-// parameters in the model, so the request carries none.
+// DocumentQueries::Document — to Markdown or HTML. A document binds its
+// queries' parameters in the model, so the request carries none.
 message RenderDocumentRequest {
   string model_hash = 1;  // from ParseFile response
   // FQN of the document to render. Fails with NOT_FOUND when the model does
   // not declare it, and INVALID_ARGUMENT when it declares something else.
   string document_id = 2;
+  // Form to render: "markdown" (the default when empty) or "html", the
+  // standalone page with the default stylesheet that the CLI's -doc-form html
+  // writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+  // converter toolchain and is not offered here.
+  string form = 3;
 }
 
-// RenderDocumentResponse carries the rendered Markdown, byte-for-byte what the
-// CLI's -render-document writes.
+// RenderDocumentResponse carries the rendered document in the form requested,
+// byte-for-byte what the CLI's -render-document writes: markdown for the
+// Markdown form, html for the HTML form, the other left empty.
 message RenderDocumentResponse {
   string markdown = 1;
+  string html = 2;
 }
diff --git a/api/proto/sysml_grpc.pb.go b/api/proto/sysml_grpc.pb.go
index 17b89cdaa7..7f5afa56ce 100644
--- a/api/proto/sysml_grpc.pb.go
+++ b/api/proto/sysml_grpc.pb.go
@@ -33,6 +33,7 @@ const (
 	SysMLService_VerifyConstraint_FullMethodName   = "/sysml.SysMLService/VerifyConstraint"
 	SysMLService_VerifyRequirement_FullMethodName  = "/sysml.SysMLService/VerifyRequirement"
 	SysMLService_VerifySatisfaction_FullMethodName = "/sysml.SysMLService/VerifySatisfaction"
+	SysMLService_ValidateInstance_FullMethodName   = "/sysml.SysMLService/ValidateInstance"
 	SysMLService_EvaluateCalc_FullMethodName       = "/sysml.SysMLService/EvaluateCalc"
 	SysMLService_RunAnalysis_FullMethodName        = "/sysml.SysMLService/RunAnalysis"
 	SysMLService_RunSweep_FullMethodName           = "/sysml.SysMLService/RunSweep"
@@ -83,6 +84,13 @@ type SysMLServiceClient interface {
 	VerifyConstraint(ctx context.Context, in *VerifyConstraintRequest, opts ...grpc.CallOption) (*VerifyConstraintResponse, error)
 	VerifyRequirement(ctx context.Context, in *VerifyRequirementRequest, opts ...grpc.CallOption) (*VerifyRequirementResponse, error)
 	VerifySatisfaction(ctx context.Context, in *VerifySatisfactionRequest, opts ...grpc.CallOption) (*VerifySatisfactionResponse, error)
+	// Validate an object as a whole, as the REPL's %validate and the CLI's
+	// -validate=<object> do: every assertion about an object of the named part
+	// and about the objects it holds — the asserted constraints of their types,
+	// the requirements they carry and the satisfactions they are the subject of —
+	// each answered on the concrete object it is about. Reported as the
+	// "verification" capability.
+	ValidateInstance(ctx context.Context, in *ValidateInstanceRequest, opts ...grpc.CallOption) (*ValidateInstanceResponse, error)
 	EvaluateCalc(ctx context.Context, in *EvaluateCalcRequest, opts ...grpc.CallOption) (*EvaluateCalcResponse, error)
 	RunAnalysis(ctx context.Context, in *RunAnalysisRequest, opts ...grpc.CallOption) (*RunAnalysisResponse, error)
 	// Run one analysis case or calc once per row of a parameter sweep, as the
@@ -246,6 +254,15 @@ func (c *sysMLServiceClient) VerifySatisfaction(ctx context.Context, in *VerifyS
 	return out, nil
 }
 
+func (c *sysMLServiceClient) ValidateInstance(ctx context.Context, in *ValidateInstanceRequest, opts ...grpc.CallOption) (*ValidateInstanceResponse, error) {
+	out := new(ValidateInstanceResponse)
+	err := c.cc.Invoke(ctx, SysMLService_ValidateInstance_FullMethodName, in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
 func (c *sysMLServiceClient) EvaluateCalc(ctx context.Context, in *EvaluateCalcRequest, opts ...grpc.CallOption) (*EvaluateCalcResponse, error) {
 	out := new(EvaluateCalcResponse)
 	err := c.cc.Invoke(ctx, SysMLService_EvaluateCalc_FullMethodName, in, out, opts...)
@@ -350,6 +367,13 @@ type SysMLServiceServer interface {
 	VerifyConstraint(context.Context, *VerifyConstraintRequest) (*VerifyConstraintResponse, error)
 	VerifyRequirement(context.Context, *VerifyRequirementRequest) (*VerifyRequirementResponse, error)
 	VerifySatisfaction(context.Context, *VerifySatisfactionRequest) (*VerifySatisfactionResponse, error)
+	// Validate an object as a whole, as the REPL's %validate and the CLI's
+	// -validate=<object> do: every assertion about an object of the named part
+	// and about the objects it holds — the asserted constraints of their types,
+	// the requirements they carry and the satisfactions they are the subject of —
+	// each answered on the concrete object it is about. Reported as the
+	// "verification" capability.
+	ValidateInstance(context.Context, *ValidateInstanceRequest) (*ValidateInstanceResponse, error)
 	EvaluateCalc(context.Context, *EvaluateCalcRequest) (*EvaluateCalcResponse, error)
 	RunAnalysis(context.Context, *RunAnalysisRequest) (*RunAnalysisResponse, error)
 	// Run one analysis case or calc once per row of a parameter sweep, as the
@@ -426,6 +450,9 @@ func (UnimplementedSysMLServiceServer) VerifyRequirement(context.Context, *Verif
 func (UnimplementedSysMLServiceServer) VerifySatisfaction(context.Context, *VerifySatisfactionRequest) (*VerifySatisfactionResponse, error) {
 	return nil, status.Errorf(codes.Unimplemented, "method VerifySatisfaction not implemented")
 }
+func (UnimplementedSysMLServiceServer) ValidateInstance(context.Context, *ValidateInstanceRequest) (*ValidateInstanceResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ValidateInstance not implemented")
+}
 func (UnimplementedSysMLServiceServer) EvaluateCalc(context.Context, *EvaluateCalcRequest) (*EvaluateCalcResponse, error) {
 	return nil, status.Errorf(codes.Unimplemented, "method EvaluateCalc not implemented")
 }
@@ -712,6 +739,24 @@ func _SysMLService_VerifySatisfaction_Handler(srv interface{}, ctx context.Conte
 	return interceptor(ctx, in, info, handler)
 }
 
+func _SysMLService_ValidateInstance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ValidateInstanceRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(SysMLServiceServer).ValidateInstance(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: SysMLService_ValidateInstance_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(SysMLServiceServer).ValidateInstance(ctx, req.(*ValidateInstanceRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
 func _SysMLService_EvaluateCalc_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
 	in := new(EvaluateCalcRequest)
 	if err := dec(in); err != nil {
@@ -901,6 +946,10 @@ var SysMLService_ServiceDesc = grpc.ServiceDesc{
 			MethodName: "VerifySatisfaction",
 			Handler:    _SysMLService_VerifySatisfaction_Handler,
 		},
+		{
+			MethodName: "ValidateInstance",
+			Handler:    _SysMLService_ValidateInstance_Handler,
+		},
 		{
 			MethodName: "EvaluateCalc",
 			Handler:    _SysMLService_EvaluateCalc_Handler,
diff --git a/buf.gen.rust.yaml b/buf.gen.rust.yaml
deleted file mode 100644
index 2da27611b5..0000000000
--- a/buf.gen.rust.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-version: v2
-plugins:
-  - remote: buf.build/community/neoeinstein-prost:v0.5.0
-    out: clients/rust/opensysml/src/proto
diff --git a/buf.gen.ts.yaml b/buf.gen.ts.yaml
deleted file mode 100644
index 186bc335fd..0000000000
--- a/buf.gen.ts.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-version: v2
-# TypeScript stubs for the npm client in clients/node. Committed, like the Python
-# stubs, so `npm install` needs neither buf nor network. Run via `make proto-ts`
-# (which `make proto` includes). The plugin is the client's lockfile-pinned
-# devDependency, so it always matches the @bufbuild/protobuf runtime.
-plugins:
-  - local: clients/node/node_modules/.bin/protoc-gen-es
-    out: clients/node/src/generated
-    opt: target=ts,import_extension=.js
diff --git a/buf.yaml b/buf.yaml
deleted file mode 100644
index 1c62e0a3cf..0000000000
--- a/buf.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-version: v2
-modules:
-  - path: api/proto
-lint:
-  use:
-    - STANDARD
-  # The schema is frozen for wire compatibility (see clients/python/tests/test_wire_compat.py),
-  # so the rules below cannot be satisfied without breaking released clients.
-  except:
-    - PACKAGE_DIRECTORY_MATCH
-    - PACKAGE_VERSION_SUFFIX
-    - RPC_REQUEST_STANDARD_NAME
-    - RPC_RESPONSE_STANDARD_NAME
-breaking:
-  use:
-    - FILE
diff --git a/client/java/README.md b/client/java/README.md
new file mode 100644
index 0000000000..7100fda89e
--- /dev/null
+++ b/client/java/README.md
@@ -0,0 +1,413 @@
+# opensysml-client (Java)
+
+Java client for OpenSysML: parse, inspect and evaluate SysML v2 models over the
+`sysml-grpc` service, from inside a JVM host application it does not own — an
+Eclipse-based tool, a Cameo plugin, a web service.
+
+```xml
+<dependency>
+  <groupId>org.openmbee</groupId>
+  <artifactId>opensysml-client</artifactId>
+  <version>0.1.0-SNAPSHOT</version>
+</dependency>
+```
+
+Nothing is published yet. Build and install it into the local repository from a
+checkout:
+
+```bash
+make build                                  # bin/sysml-grpc, which the tests start
+mvn -f client/java/pom.xml install          # sources and javadoc jars included
+```
+
+```java
+try (Connection connection = Connection.open()) {      // starts a private sysml-grpc
+  Model model = connection.load(Path.of("model.sysml"));
+
+  Value sum = model.eval("1 + 2 * 3");                 // Value.IntegerValue[value=7]
+  Value mass = model.evalWithSubject("mass", "Demo::sedan");
+
+  Symbol vehicle = model.symbol("Demo::Vehicle");      // findSymbol returns Optional
+  Instantiation built = model.instantiate("Demo::Vehicle");
+
+  ActionRun run = model.executeAction("Test::addFive");           // outputs, final time, diagnostics
+  Verification v = model.verifyConstraint("Demo::Vehicle::massLight");
+  boolean holds = v.holds();                                        // false is an answer, not a failure
+  Analysis study = model.runAnalysis("Trade::lightest");            // outputs, verdicts, case evaluations
+  List<QueryElement> parts = model.query(
+      Query.all().where(Condition.equalTo("@type", List.of("PartUsage"))));
+
+  connection.capabilities().require(Capabilities.FEATURE_VALUES);
+}
+```
+
+Every value the API answers with is immutable: `Value` is a sealed interface over
+records (`IntegerValue`, `RealValue`, `ComplexValue`, `QuantityValue`, `ArrayValue`,
+`VectorValue`, `VectorQuantityValue`, `SetValue`, `TensorQuantityValue`, `MeasurementRefValue`,
+`FunctionValue`, `MetaobjectValue`, `EnumerationValue` (whose `EnumLiteral` carries the scalar a
+`high = 3` literal was given as `value()`), `InstanceReference`, `Sequence`, `NullValue`,
+`UnsetValue`, `UndeterminedValue`, `InfinityValue`), and `Symbol`,
+`Diagnostic`, `Instance` and `Instantiation` are records with copied collections, as are
+the answers of execution (`ActionRun`, `StateRun`, `Exploration` of `Outcome`s),
+verification (`Verification`, `Satisfaction`, `Validation`, each over `Verdict`s and
+`VerificationVerdict`s), calculation and analysis (`Calculation`, `Analysis` of
+`CaseEvaluation`s, with the engine `Standing`), query (`Query`, `Condition`,
+`QueryElement`), conversion (`Conversion`), editing (`Edit`, `EditResult`,
+`AppliedEdit`, `EditedDocument`, `Referrer`), sweeps (`Sweep` of `SweepRow`s),
+documents (`DocumentValue`, `DocumentRow`, `DocumentQueryResult`,
+`RenderedDocument`) and `EngineInfo`. `SourceDocument` names a document a
+`parseSources` call reads; `Edit` is sealed over `SetValue`, `Rename`,
+`AddMember`, `Delete` and `Move`. Every RPC the service offers is a method.
+No generated protobuf message or builder appears in the public API. A `Diagnostic`
+is `(severity, message, code, span)`; `code()` is the identifier to branch on
+(`"syntax"`, a validation code such as `"unresolved"`, `"choice-point"`,
+`"guard-unevaluable"`; `""` when the service assigned none), `message()` is for reading. A
+service that populates `code` advertises `Capabilities.DIAGNOSTIC_CODES`; without it every
+code is `""`.
+
+## Exceptions: unchecked, and the distinction that matters
+
+Everything the client throws is unchecked and descends from `OpenSysMLException`.
+A host application handling a model is not helped by checked exceptions on every
+call, and `AutoCloseable`'s `close()` here throws nothing.
+
+| exception              | what happened                                                     |
+| ---------------------- | ----------------------------------------------------------------- |
+| `ServiceException`     | the call was refused, with a `StatusCode` (`NOT_FOUND`, …)         |
+| `ModelException`       | the call succeeded and the answer reports a model failure; `failureReason()` classifies it |
+| `AnalysisException`    | a `ModelException` from `runAnalysis` whose `partial()` holds what the run computed before it stopped |
+| `EditException`        | a `ModelException` from `applyEdits` whose `failure()` names the `EditFailure` kind and whose `referringElements()`/`referrers()` name what a refused delete or move is referenced from |
+| `TransportException`   | HTTP or IO failure; the service was not reached or answered. `UNAVAILABLE`, except `DEADLINE_EXCEEDED` for a call that outlived its `requestTimeout` |
+| `CapabilityException`  | the service does not advertise a capability the call needs         |
+| `ServiceStartException`| no binary, a digest mismatch, or a child that would not start      |
+| `ChecksumMismatchException` | a binary's bytes are not the digest required of them ([the service binary](#the-service-binary)) |
+
+The `ServiceException`/`ModelException` split is the one the conformance suite
+draws too: an expression that will not evaluate is a successful call carrying an
+error, not a service problem. A verdict that is false is neither: the model has
+answered, and `verifyConstraint` returns it with `Verdict.decided()` true. Only a
+verdict carrying an `error` — the condition could not be evaluated, the symbol is
+of another kind, the subject is ambiguous — is undecided.
+
+## Maven, and a JDK 17 baseline
+
+Maven, because a consumer of this ecosystem expects a POM: Eclipse tooling, Cameo
+plugin builds and `mvn dependency:tree` all read one, and a Gradle consumer reads
+the published POM as well.
+
+`maven.compiler.release` is **17**, not the 21 this repository's environment has.
+17 is the lowest baseline a realistic host can offer: Eclipse 2023-03 and later
+require 17, so does IntelliJ 2023.2+, and Spring Boot 3 requires it. The client
+uses records, sealed interfaces, switch patterns and text blocks — all 17 — and
+nothing from 21, so 21 would exclude hosts for no gain.
+
+## Dependency footprint
+
+The compile-scope dependency is protobuf-java. Nothing else, by default:
+
+| jar                          |  size | when                                    |
+| ---------------------------- | ----: | --------------------------------------- |
+| `protobuf-java` 4.33.1       | 1.8 M | always: the generated messages need it  |
+| `protobuf-java-util` 4.33.1  |  76 K | only `Encoding.JSON`, declared optional  |
+| `gson` 2.11.0                | 291 K | only `Encoding.JSON`, behind the above   |
+
+There is no gRPC, no Netty and no `tcnative`. The transport is
+`java.net.http.HttpClient` from the JDK, speaking the Connect protocol: unary
+`POST /sysml.SysMLService/<Method>` with an `application/proto` body and
+`Connect-Protocol-Version: 1`. That decision is about the host application, not
+about elegance:
+
+- `grpc-java` brings `grpc-netty(-shaded)`, `guava`, `perfmark` and optionally
+  `netty-tcnative-boringssl-static`. Inside an OSGi/Eclipse runtime or a Spring
+  application that already has its own Netty, that is the classic shading and
+  classloader conflict, and a client is a bad reason to inflict it.
+- `connect-kotlin` would add the Kotlin stdlib and OkHttp for a Java consumer.
+- The service serves gRPC, gRPC-Web and Connect on one port, so choosing Connect
+  costs no functionality: the same port, the same protobuf bodies.
+
+Only the client's own transport is affected. A host that already uses `grpc-java`
+for something else keeps it; nothing here conflicts with it.
+
+## Protobuf bodies by default
+
+`Encoding.PROTOBUF` is the default. `docs/internals/design/transport-evaluation.md`
+measured a 468 KB `Query` answer at ~6.5 ms with protobuf against ~42 ms with
+JSON — that is JSON parsing cost, not bytes on the wire. `Encoding.JSON` exists
+so an answer can be compared against `curl`, and it needs the optional
+`protobuf-java-util`; the conformance suite runs over both.
+
+## Service ownership
+
+A connection uses a service of its own and never stops one it did not start.
+
+- `Connection.open()` starts a **private child**: `sysml-grpc -port 0
+  -health-port 0 -report-address -exit-with-parent`. The kernel assigns the port
+  and the child prints the address it was given on its first stdout line, so no
+  port is chosen, probed or retried.
+- **One child per classloader.** The registry holding it is static, so the copy
+  of the client an Eclipse plugin loaded, the copy a web application loaded and a
+  copy shaded inside a third library each own one child, while every connection
+  made through one copy shares a child — and therefore its parse cache, which is
+  what makes a second connection and a repeat parse cheap. Per-instance would
+  spawn a service per connection and reparse every model; a JVM-wide singleton
+  would put one tenant's models in another tenant's cache and would outlive an
+  undeployed application.
+- A host that must not share a cache across tenants passes
+  `ConnectionOptions.builder().isolatedService(true)`, which starts a child for
+  that connection alone and stops it when that connection closes.
+- A private child stops when the **last** connection holding it closes.
+  `Connection.close()` is idempotent, and `Connection.stopSharedServices()` stops
+  what this classloader still owns — call it from a plugin's `stop()` or a
+  `ServletContextListener`, since unloading a classloader does not by itself stop
+  a child (below).
+- Reaching a service the client did not start is explicit: `service(host, port)`,
+  `$OPENSYSML_SERVICE=host:port`, or `autoStart(false)` to require one. Closing
+  such a connection leaves it running, always.
+
+### No orphans
+
+The client holds the write end of the child's **stdin pipe** and never writes to
+it; the child exits at end of file. Nothing else holds that write end, so the
+kernel closes it when the owning JVM goes away — which is what survives
+`SIGKILL`, `Runtime.halt`, an `OutOfMemoryError` during shutdown and a JVM crash.
+`ProcessHandle.onExit()` and shutdown hooks do not. On an orderly close the
+client closes stdin itself and then destroys the process it started, so exit is
+prompt rather than eventual, and it only ever signals the `Process` object of a
+child it started — never a pid read from disk.
+
+`OrphanSafetyTest` proves it: a child JVM opens a connection, prints the service
+pid, and is killed with `kill -9`; the test then waits for that pid to be gone.
+
+Per platform:
+
+- **Linux**, **macOS**: as above. The JVM does not leak the write end into other
+  children, since `ProcessBuilder` does not pass a parent's pipe endpoints on.
+- **Windows**: the same anonymous pipe is closed by the OS when the owning
+  process exits however it exits, so the guarantee is unchanged; `taskkill /F` is
+  the `kill -9` of the test. The `-exit-with-parent` flag adds a job-object tie
+  on the service side.
+- **A classloader that is unloaded**: the child is not tied to the classloader,
+  so a host that undeploys an application without closing its connections would
+  leave a service running until its JVM exits. `ClassLoaderTest` loads three
+  isolated copies of the client, checks each owns a different child, checks that
+  closing the connections leaves none, and checks the copies are collectable.
+  Nothing the client starts is a non-daemon thread, so an unloaded copy holds no
+  thread either: the HTTP executor and the child's output pumps are daemon threads
+  (`opensysml-http-*`, `opensysml-service-*`). Collection is not instant, though —
+  on 17 the JDK's own `HttpClient-N-SelectorManager` thread outlives a closed
+  client until the client is collected, and pins the loader meanwhile.
+
+## Thread safety
+
+`Connection` and `Model` are safe for concurrent use by many threads: a
+connection holds one `HttpClient`, the shared-service registry is guarded by a
+single lock, and `close()` is a compare-and-set. `LifecycleTest` opens
+connections from eight threads at once and asserts one child was started and the
+reference count reaches zero. Value types are immutable and therefore shareable.
+
+## The service binary
+
+The client resolves an installed binary, in order:
+
+1. `ConnectionOptions.binaryPath(...)`;
+2. `$OPENSYSML_GRPC_BINARY`;
+3. `~/.opensysml/bin/sysml-grpc` (`.exe` on Windows) — the cache it shares with the
+   Python client;
+4. `PATH`.
+
+`expectedBinarySha256("<hex>")` verifies the file's digest before it is executed
+and refuses it otherwise, whichever binary is resolved and whether it was
+downloaded or installed. An external service needs no binary at all.
+
+### Downloading a release
+
+Ask for a release and the client downloads it into that shared cache:
+
+```java
+ConnectionOptions.builder().downloadVersion("v0.3.0").build();   // or "latest"
+```
+
+The version is the caller's, else `$OPENSYSML_GRPC_VERSION`, else nothing —
+**no version, no download**: without one the client only resolves what is
+already there, so it never fetches a binary a caller did not ask for. `latest`
+is resolved through the GitHub releases API. The repository is
+`Open-MBEE/OpenSysML`, overridable with `ConnectionOptions.githubRepo(...)` or
+`$OPENSYSML_GITHUB_REPO`. Every request times out after 15 seconds, and a
+response body that stops arriving for that long is abandoned too, so a release
+of any size may be downloaded but a hung origin never hangs a connection.
+
+The release asset for the running platform (`sysml-grpc-linux-amd64`,
+`-linux-arm64`, `-darwin-amd64`, `-darwin-arm64`, `-windows-amd64.exe`; any
+other pair fails naming itself) is downloaded to a temporary file, verified, and
+only then moved over the cached path, `chmod 0700` where the filesystem keeps
+POSIX modes. A download that fails or does not verify leaves the cached binary
+untouched and removes the temporary file. A response is read to a bound — 512
+MiB for a binary, 8 MiB for a checksum, manifest, bundle or release listing —
+so an origin cannot answer a download with an endless body. Deciding whether the
+cache is the release asked for and replacing it when it is not is done holding
+`~/.opensysml/bin/sysml-grpc.lock`, so concurrent connections — in this JVM, in
+another, or in the Python or Rust clients, which take the same lock over the same
+span — do not install over each other.
+
+What a service is started from is not the shared path but a hard link to it
+named for its digest, `~/.opensysml/bin/sysml-grpc-<first 16 hex>`, made while
+the lock is held. A release installed over the cache afterwards is therefore not
+the one a connection already resolved runs; a filesystem without hard links gets
+a copy, and one that gives neither falls back to the shared path with a warning.
+
+The cache is read and written exactly as the Python client does, so the two
+share one binary: `~/.opensysml/bin/sysml-grpc.json` beside it records
+`{"version": ..., "sha256": ..., "repo": ...}`. A cached binary of another
+release, or one from another repository, is replaced with a warning rather than
+silently used, and its digest is re-checked so a binary swapped in by hand is
+not read as the release it displaced. A release that cannot be reached, or that
+nothing vouches for, is no reason to lose a working binary: the installed one is
+kept with a warning that it may be older than the release asked for. A download
+that contradicts a pin or a signature is never answered from the cache — it
+fails.
+
+### What a download is verified against
+
+In order, and each step is a refusal rather than a fallback:
+
+1. **A pinned digest.** `release-digests.json` — this jar's synced copy of
+   `client/release-digests.json`, loaded from the classpath — pins a SHA-256
+   per (repository, release, asset). Where a pin exists it is what the bytes
+   must hash to, and a served `.sha256` that disagrees with it is a release
+   republished with another binary: the download is refused.
+2. **The signed checksum manifest.** For a release nothing is pinned for, the
+   client downloads `SHA256SUMS.txt` and its sigstore bundle
+   `SHA256SUMS.txt.bundle` and verifies the bundle with
+   `dev.sigstore:sigstore-java` against the manifest's digest, requiring the
+   release pipeline's identity — CircleCI OIDC issuer
+   `https://oidc.circleci.com/org/1169df8b-0b59-400f-82d2-c9d8e98bdb62` and a
+   pipeline definition of project
+   `https://circleci.com/api/v2/projects/eeb0dddd-237f-4f02-9e51-8e24caef589d`.
+   The asset's digest then comes from that verified manifest. A bundle that does
+   not verify, another signer, an expired certificate or a manifest changed
+   after signing is refused — never a quiet fall back to the served checksum.
+3. **Nothing.** With no pin and no signature, the download is refused, naming
+   the version, because the only checksum left is the one served beside the
+   binary, which a compromised release would serve too.
+
+The last step is the one opt-out: `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=1`, or set
+to a comma-separated list naming `owner/repo`, accepts that served `.sha256`
+with a warning. That is **same-origin trust** — it detects a corrupted transfer,
+and nothing at all about a compromised release origin.
+
+Excluding the `dev.sigstore:sigstore-java` dependency does not weaken any of
+this: without it nothing verifies, which is refused exactly as an unsigned
+release is, so only pinned releases install.
+
+### Limitations
+
+- A release published after this client's `release-digests.json` was synced is
+  installed on its signature, so a jar built with `sigstore-java` excluded needs
+  a client whose table pins that release.
+- `latest` is one unauthenticated call to `api.github.com`, so a rate-limited
+  host should name the version instead.
+- The cache is one path per user, so two applications asking for different
+  releases replace one another's cached binary — each still runs the release it
+  asked for, from the digest-named link, but the next start re-downloads. Name
+  the binary with `ConnectionOptions.binaryPath()` where that matters.
+- The digest-named links are never collected: a cache that has held many
+  releases keeps a link per release until `~/.opensysml/bin` is cleared.
+
+## Capability negotiation
+
+`Connection.open` calls `GetServerInfo` once and keeps what it reported.
+`connection.capabilities().require(Capabilities.EVALUATE_SUBJECT)` throws
+`CapabilityException` when a capability is absent. Negotiation is on the
+advertised **names**, never on the version string. The service does refuse a
+request that needs a capability it lacks — `UNIMPLEMENTED`, naming the
+capability, which arrives here as a `ServiceException` — but a capability that
+only describes how a response is populated omits its fields instead, so a call
+that relied on failure alone would silently read an answer computed without
+them. That is why `Model.evalWithSubject` checks before it calls: a
+`CapabilityException` names the missing capability and the service that lacks
+it, before a round trip.
+
+## What the client does not do
+
+Deliberately out of scope, rather than half-implemented: **generated
+model-ergonomics types** — no code generation from a model into Java classes.
+Every RPC the service serves is a public method; what remains outside is only a
+surface that would be generated per model rather than part of the client.
+
+## Generated messages
+
+`org.openmbee.opensysml.proto` is committed, generated by `buf` from a plugin entry in
+`api/proto/buf.gen.yaml` with the version pinned inline, and regenerated by
+`make proto` — no Maven plugin calls `protoc` and nothing here is hand-written.
+Only the message classes are generated: the Connect protocol needs no service
+stubs, so grpc-java never enters the build.
+
+## Conformance
+
+The runner in `opensysml-conformance` reads `conformance/scenarios/*.json` and
+`conformance/fixtures/`, makes each call **through the public API** and compares
+what the client read out of the answer, by the rules in `conformance/README.md`.
+It writes the report shape `tools/cmd/conformance` writes:
+
+```bash
+make build
+mvn -f client/java/pom.xml install -DskipTests
+mvn -f client/java/pom.xml -pl opensysml-conformance -q \
+  dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
+java -cp "client/java/opensysml-conformance/target/classes:$(cat /tmp/cp.txt)" \
+  org.openmbee.opensysml.conformance.Main -binary bin/sysml-grpc -allow-skips \
+  -protocols connect,connect-json -report bin/conformance-report-java.json
+```
+
+`-run <regexp>` selects scenarios by id, `-service host:port` runs against a
+service the runner did not start, `-mutate` is below, and without `-allow-skips`
+a skipped scenario is an exit code, so a shrinking API surface cannot go
+unnoticed.
+
+Or as a test, which is what CI runs: `mvn -f client/java/pom.xml test`.
+
+Per protocol, of 134 scenarios:
+
+| protocol       | ran | passed | failed | skipped |
+| -------------- | --: | -----: | -----: | ------: |
+| `connect`      | 129 |    129 |      0 |       5 |
+| `connect-json` | 129 |    129 |      0 |       5 |
+
+**5 skipped**: only the requests the public API cannot express —
+`parse/naming_no_source_is_invalid` (the API always names a
+source), a `Query` carrying both a structured and an OSLC query and one whose
+comparison has no operator (`Query` and `Condition` are values that cannot be built
+that way), and two `EvaluateCalc` arguments malformed on the wire, which the
+client's own `Value` reader refuses before any request could carry them. A model
+of several documents is parsed by `Connection.parseSources`, which names each
+document as the fixture file named it, so the `ApplyEdits` scenarios over several
+documents run rather than skip. The suite
+test asserts that no scenario of a covered RPC is skipped as uncovered, so a
+shrinking surface cannot pass quietly. gRPC is not run at all: this client does not speak it.
+
+The runner is not vacuous. `-mutate` corrupts every answer before it is compared,
+and `SuiteTest.aCorruptedAnswerIsCaught` asserts each corruption is caught:
+
+| `-mutate`         | what it does to every answer     | scenarios that fail |
+| ----------------- | -------------------------------- | ------------------: |
+| `perturb-reals`   | moves each real by a millionth   |                  24 |
+| `truncate-lists`  | drops the last repeated element  |                  50 |
+| `rewrite-strings` | replaces each string            |                  70 |
+
+## Running the tests
+
+```bash
+make build                                   # bin/sysml-grpc; tests skip without it
+mvn -f client/java/pom.xml test             # 236 client tests, 36 conformance tests
+mvn -f client/java/pom.xml test -Dopensysml.requireService=true   # CI: absence fails
+```
+
+## Publishing
+
+Nothing has been published. The build produces a correct, signable artifact
+(sources and javadoc jars, complete POM metadata, a `release` profile that signs
+with GPG and stages to Sonatype Central with `autoPublish=false`), and
+`mvn install` works today. What a maintainer must obtain first — a verified
+`org.openmbee` namespace, a published GPG key, and Central portal
+tokens — is in
+[docs/project/releasing.md](../../docs/project/releasing.md#releasing-the-java-client-to-maven-central).
diff --git a/client/java/opensysml-client/pom.xml b/client/java/opensysml-client/pom.xml
new file mode 100644
index 0000000000..dfa00d9bce
--- /dev/null
+++ b/client/java/opensysml-client/pom.xml
@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openmbee</groupId>
+    <artifactId>opensysml-parent</artifactId>
+    <version>0.1.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>opensysml-client</artifactId>
+  <packaging>jar</packaging>
+
+  <name>OpenSysML Java client</name>
+  <description>Parse, inspect and evaluate SysML v2 models over the sysml-grpc service's Connect
+    protocol, from inside a long-lived JVM host application.</description>
+  <url>https://github.com/Open-MBEE/OpenSysML/tree/main/client/java</url>
+
+  <dependencies>
+    <dependency>
+      <groupId>com.google.protobuf</groupId>
+      <artifactId>protobuf-java</artifactId>
+    </dependency>
+    <!-- Only Encoding.JSON needs it, a debugging affordance rather than the
+         default, so a host that never asks for JSON carries neither it nor gson. -->
+    <dependency>
+      <groupId>com.google.protobuf</groupId>
+      <artifactId>protobuf-java-util</artifactId>
+      <optional>true</optional>
+    </dependency>
+    <!-- Verifies the sigstore bundle over a release's SHA256SUMS.txt, which is what lets a
+         release newer than this client's pinned digests install. -->
+    <dependency>
+      <groupId>dev.sigstore</groupId>
+      <artifactId>sigstore-java</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter</artifactId>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-surefire-plugin</artifactId>
+        <configuration>
+          <!-- The orphan-safety test starts a JVM of its own, so the test JVM
+               must see its real classpath rather than a manifest-only jar. -->
+          <useManifestOnlyJar>false</useManifestOnlyJar>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-source-plugin</artifactId>
+        <executions>
+          <execution>
+            <id>attach-sources</id>
+            <goals>
+              <goal>jar-no-fork</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-javadoc-plugin</artifactId>
+        <configuration>
+          <!-- The generated message classes document the schema, which
+               sysml.proto already does, and their javadoc is not the client's. -->
+          <excludePackageNames>org.openmbee.opensysml.proto</excludePackageNames>
+          <doclint>all,-missing</doclint>
+          <notimestamp>true</notimestamp>
+        </configuration>
+        <executions>
+          <execution>
+            <id>attach-javadocs</id>
+            <goals>
+              <goal>jar</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ActionRun.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ActionRun.java
new file mode 100644
index 0000000000..a921988669
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ActionRun.java
@@ -0,0 +1,31 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.OptionalDouble;
+
+/**
+ * What one execution of an action produced: what {@link Model#executeAction(String)} answers.
+ *
+ * @param outputs the action's output parameters by name, empty for an action producing none
+ * @param finalTime the run's simulation clock when it ended, in seconds from the 0 it started at;
+ *     absent from a service without the {@code final_time} capability
+ * @param diagnostics what the service reported while executing
+ */
+public record ActionRun(
+    Map<String, Value> outputs, OptionalDouble finalTime, List<Diagnostic> diagnostics) {
+
+  /**
+   * Creates an action run, copying its collections.
+   *
+   * @param outputs the outputs by name
+   * @param finalTime the final time, when reported
+   * @param diagnostics the diagnostics
+   */
+  public ActionRun {
+    outputs = Map.copyOf(outputs);
+    Objects.requireNonNull(finalTime, "finalTime");
+    diagnostics = List.copyOf(diagnostics);
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Analysis.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Analysis.java
new file mode 100644
index 0000000000..8018cf0429
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Analysis.java
@@ -0,0 +1,104 @@
+package org.openmbee.opensysml;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * What one run of an analysis case produced: what {@link Model#runAnalysis(String)} answers.
+ *
+ * @param outputs the case's out and return parameters by name, in declaration order; a body
+ *     returning into an unnamed result is the output {@code "result"}
+ * @param verdicts the case's objectives, in order, then the assertions in its body, of kinds {@link
+ *     Verdict#KIND_OBJECTIVE} and {@link Verdict#KIND_ASSERTION}
+ * @param verifications the body verdicts of the case and of the verification cases it performs,
+ *     empty for an analysis case
+ * @param evaluations the applications the run made of the case's own calcs, in the order made: for
+ *     a trade study, its evaluation function applied to each alternative in subject order
+ * @param instances the objects the run reported: those reachable from the subject, including it,
+ *     and those the outputs and evaluations refer to
+ * @param diagnostics what the service reported while running
+ * @param standing how strongly the run's answer stands
+ */
+public record Analysis(
+    Map<String, Value> outputs,
+    List<Verdict> verdicts,
+    List<VerificationVerdict> verifications,
+    List<CaseEvaluation> evaluations,
+    List<Instance> instances,
+    List<Diagnostic> diagnostics,
+    Standing standing) {
+
+  /**
+   * Creates an analysis, copying its collections and keeping the outputs' order.
+   *
+   * @param outputs the outputs by name
+   * @param verdicts the verdicts
+   * @param verifications the body verdicts
+   * @param evaluations the evaluations
+   * @param instances the objects
+   * @param diagnostics the diagnostics
+   * @param standing the standing, never {@code null}
+   */
+  public Analysis {
+    outputs = Collections.unmodifiableMap(new LinkedHashMap<>(outputs));
+    verdicts = List.copyOf(verdicts);
+    verifications = List.copyOf(verifications);
+    evaluations = List.copyOf(evaluations);
+    instances = List.copyOf(instances);
+    diagnostics = List.copyOf(diagnostics);
+    Objects.requireNonNull(standing, "standing");
+  }
+
+  /**
+   * Whether every objective and assertion was decided and held. A case stating none holds
+   * trivially.
+   *
+   * @return {@code true} when no verdict is undecided or violated
+   */
+  public boolean holds() {
+    return verdicts.stream().allMatch(verdict -> verdict.decided() && verdict.holds());
+  }
+
+  /**
+   * The case's objective.
+   *
+   * @return the first objective verdict, absent for a case stating none
+   */
+  public Optional<Verdict> objective() {
+    return verdicts.stream().filter(verdict -> Verdict.KIND_OBJECTIVE.equals(verdict.kind())).findFirst();
+  }
+
+  /**
+   * The evaluation a trade study selected.
+   *
+   * @return the selected evaluation, absent when none was
+   */
+  public Optional<CaseEvaluation> selected() {
+    return evaluations.stream().filter(CaseEvaluation::selected).findFirst();
+  }
+
+  /**
+   * The object a value refers to.
+   *
+   * @param reference a reference the run reported
+   * @return the object, absent when it is not among those reported
+   */
+  public Optional<Instance> resolve(Value.InstanceReference reference) {
+    Objects.requireNonNull(reference, "reference");
+    return Instances.find(instances, reference.instanceId());
+  }
+
+  /**
+   * The object of an id.
+   *
+   * @param instanceId the id the service gave the object
+   * @return the object, absent when it is not among those reported
+   */
+  public Optional<Instance> instance(long instanceId) {
+    return Instances.find(instances, instanceId);
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisException.java
new file mode 100644
index 0000000000..d60abee53a
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisException.java
@@ -0,0 +1,43 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * An analysis case that could not run to its end but left something to inspect: the outputs and
+ * evaluations made before an alternative failed, an objective the failure left undecided.
+ *
+ * <p>A request refused before the run, or a failure leaving nothing to report, is a plain {@link
+ * ModelException} whose {@link #failureReason()} says why.
+ */
+public class AnalysisException extends ModelException {
+
+  private static final long serialVersionUID = 1L;
+
+  private final transient Analysis partial;
+
+  /**
+   * Creates an analysis exception.
+   *
+   * @param message the failure, as the service worded it
+   * @param failureReason what kind of failure it is
+   * @param diagnostics diagnostics the answer carried
+   * @param partial what the run left
+   */
+  public AnalysisException(
+      String message, FailureReason failureReason, List<Diagnostic> diagnostics, Analysis partial) {
+    super(message, failureReason, diagnostics);
+    this.partial = Objects.requireNonNull(partial, "partial");
+  }
+
+  /**
+   * What the run left: the outputs and evaluations made, the objects they name, each verdict
+   * undecided.
+   *
+   * @return the partial result; absent after Java serialization, which does not carry it
+   */
+  public Optional<Analysis> partial() {
+    return Optional.ofNullable(partial);
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisOptions.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisOptions.java
new file mode 100644
index 0000000000..4d1ff62b25
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/AnalysisOptions.java
@@ -0,0 +1,99 @@
+package org.openmbee.opensysml;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * How an analysis case is run.
+ *
+ * @param subject FQN of the part definition or usage to instantiate and bind as the case's subject;
+ *     absent for a usage binding its own
+ * @param arguments values binding the case's {@code in} parameters in declaration order, the subject
+ *     excluded
+ * @param namedArguments values binding its {@code in} parameters by name
+ * @param schedule the policy the actions the case performs resolve their choice points under, as
+ *     for {@link ExecutionOptions#schedule()}
+ */
+public record AnalysisOptions(
+    Optional<String> subject,
+    List<Value> arguments,
+    Map<String, Value> namedArguments,
+    Optional<String> schedule) {
+
+  /**
+   * Validates the options, copying their collections.
+   *
+   * @param subject the subject, when named
+   * @param arguments the positional arguments
+   * @param namedArguments the named arguments
+   * @param schedule the schedule, when named
+   */
+  public AnalysisOptions {
+    Objects.requireNonNull(subject, "subject");
+    arguments = List.copyOf(arguments);
+    namedArguments = Collections.unmodifiableMap(new LinkedHashMap<>(namedArguments));
+    Objects.requireNonNull(schedule, "schedule");
+  }
+
+  /**
+   * No subject, no arguments, the service's default schedule.
+   *
+   * @return the default options
+   */
+  public static AnalysisOptions defaults() {
+    return new AnalysisOptions(Optional.empty(), List.of(), Map.of(), Optional.empty());
+  }
+
+  /**
+   * The same options run on a subject.
+   *
+   * @param subject FQN of the part definition or usage
+   * @return options naming it
+   */
+  public AnalysisOptions withSubject(String subject) {
+    return new AnalysisOptions(Optional.of(subject), arguments, namedArguments, schedule);
+  }
+
+  /**
+   * The same options with these positional arguments.
+   *
+   * @param arguments the arguments, in parameter order
+   * @return options carrying them
+   */
+  public AnalysisOptions withArguments(List<Value> arguments) {
+    return new AnalysisOptions(subject, arguments, namedArguments, schedule);
+  }
+
+  /**
+   * The same options with these named arguments.
+   *
+   * @param namedArguments the arguments by parameter name
+   * @return options carrying them
+   */
+  public AnalysisOptions withNamedArguments(Map<String, Value> namedArguments) {
+    return new AnalysisOptions(subject, arguments, namedArguments, schedule);
+  }
+
+  /**
+   * The same options under another schedule.
+   *
+   * @param schedule the policy
+   * @return options naming it
+   */
+  public AnalysisOptions withSchedule(String schedule) {
+    return new AnalysisOptions(subject, arguments, namedArguments, Optional.of(schedule));
+  }
+
+  /**
+   * Whether the schedule explores every order rather than running one.
+   *
+   * @return {@code true} for {@code "explore"} and {@code "explore:<options>"}
+   */
+  public boolean explores() {
+    return schedule.filter(ExecutionOptions::explores).isPresent();
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/AppliedEdit.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/AppliedEdit.java
new file mode 100644
index 0000000000..0a4147b7e4
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/AppliedEdit.java
@@ -0,0 +1,45 @@
+package org.openmbee.opensysml;
+
+import java.util.Objects;
+
+/**
+ * One byte range of the original source that an applied operation replaced, so a caller can report
+ * or locate what changed.
+ *
+ * @param operationIndex index of the operation in the batch, so an applied edit maps back to what
+ *     asked for it
+ * @param target the element edited, as the request named it
+ * @param offset the byte offset where the replacement starts, into the source the operation saw
+ * @param length the bytes replaced; zero for text inserted where a feature had no value
+ * @param oldText what was there; empty for an insertion
+ * @param newText what replaced it
+ * @param document the document the bytes are in, named as the parse named it and as {@link
+ *     EditResult#documents()} lists it; set for a single-document model too
+ */
+public record AppliedEdit(
+    int operationIndex,
+    String target,
+    int offset,
+    int length,
+    String oldText,
+    String newText,
+    String document) {
+
+  /**
+   * Creates an applied edit.
+   *
+   * @param operationIndex the operation's index
+   * @param target the element edited, never {@code null}
+   * @param offset where the replacement starts
+   * @param length the bytes replaced
+   * @param oldText what was there, never {@code null}
+   * @param newText what was written, never {@code null}
+   * @param document the document, never {@code null}
+   */
+  public AppliedEdit {
+    Objects.requireNonNull(target, "target");
+    Objects.requireNonNull(oldText, "oldText");
+    Objects.requireNonNull(newText, "newText");
+    Objects.requireNonNull(document, "document");
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Calculation.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Calculation.java
new file mode 100644
index 0000000000..2e38956f6c
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Calculation.java
@@ -0,0 +1,54 @@
+package org.openmbee.opensysml;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * What {@link Model#evaluateCalc(String, List)} computed.
+ *
+ * <p>A calc definition, or a usage invoked with arguments, computes one {@link #result()}. A calc
+ * usage evaluated from its own members computes its {@link #outputs()} by name instead, in
+ * declaration order; a body returning into an unnamed result is the output {@code "result"}.
+ *
+ * @param result the value computed, absent when the calc answers through its outputs
+ * @param outputs the calc's out and return parameters by name, empty when it answers with a result
+ * @param diagnostics what the service reported while computing
+ * @param standing how strongly the answer stands
+ */
+public record Calculation(
+    Optional<Value> result,
+    Map<String, Value> outputs,
+    List<Diagnostic> diagnostics,
+    Standing standing) {
+
+  /**
+   * Creates a calculation, copying its collections and keeping the outputs' order.
+   *
+   * @param result the result, when there is one
+   * @param outputs the outputs by name
+   * @param diagnostics the diagnostics
+   * @param standing the standing, never {@code null}
+   */
+  public Calculation {
+    Objects.requireNonNull(result, "result");
+    outputs = Collections.unmodifiableMap(new LinkedHashMap<>(outputs));
+    diagnostics = List.copyOf(diagnostics);
+    Objects.requireNonNull(standing, "standing");
+  }
+
+  /**
+   * The one value the calc computed: its result, or its single output.
+   *
+   * @return the value, absent when the calc computed none or several outputs
+   */
+  public Optional<Value> value() {
+    if (result.isPresent()) {
+      return result;
+    }
+    return outputs.size() == 1 ? Optional.of(outputs.values().iterator().next()) : Optional.empty();
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Capabilities.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Capabilities.java
similarity index 89%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Capabilities.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Capabilities.java
index 406b8dbaaa..e17acb65ce 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Capabilities.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Capabilities.java
@@ -80,9 +80,21 @@ public final class Capabilities {
   /** {@code ApplyEdits} can add members and delete declarations. */
   public static final String AUTHORING = "authoring";
 
+  /** {@code ApplyEdits} edits a model of several documents as one batch, answering each edited document by name. */
+  public static final String EDIT_DOCUMENTS = "edit_documents";
+
   /** Inline content may name the notation it is written in. */
   public static final String INLINE_LANGUAGE = "inline_language";
 
+  /** The {@code ParseSources} RPC parses several documents as one model. */
+  public static final String PARSE_SOURCES = "parse_sources";
+
+  /** The {@code RunDocumentQuery} RPC runs a named document query. */
+  public static final String DOCUMENT_QUERY = "document_query";
+
+  /** The {@code RenderDocument} RPC renders a named document to Markdown. */
+  public static final String RENDER_DOCUMENT = "render_document";
+
   /** A parse can judge the source as conforming SysML v2. */
   public static final String STRICT_CONFORMANCE = "strict_conformance";
 
@@ -95,6 +107,9 @@ public final class Capabilities {
   /** The {@code explore} schedule answers with every {@code outcomes} entry and an {@code exploration} status. */
   public static final String SCHEDULE_EXPLORE = "schedule_explore";
 
+  /** The action and state requests take a {@code performer_symbol_id}, the object the behavior runs on: a declaration or a path from one into its parts. */
+  public static final String PERFORMER = "performer";
+
   /** The execution responses report {@code final_time}, the run's simulation clock when it ended, in seconds. */
   public static final String FINAL_TIME = "final_time";
 
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/CapabilityException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/CapabilityException.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/CapabilityException.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/CapabilityException.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/CaseEvaluation.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/CaseEvaluation.java
new file mode 100644
index 0000000000..fb9fad3c2d
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/CaseEvaluation.java
@@ -0,0 +1,45 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * One application of a calc an analysis case declares, made while the case ran. For a trade study
+ * it is the evaluation function scoring one alternative. Reported by a service advertising the
+ * {@code case_evaluations} capability.
+ *
+ * @param functionId FQN of the calc applied
+ * @param arguments what it was applied to, in parameter order; an alternative is an {@link
+ *     Value.InstanceReference} the {@link Analysis} resolves
+ * @param result what it computed, absent when {@code error} says why it computed nothing
+ * @param error why the application failed, absent when it computed a value
+ * @param selected whether this is the evaluation whose argument {@code selectOne} picked and the
+ *     case returned: the alternative a trade study selected
+ * @param tied whether this evaluation computed what the selected one did without being it
+ */
+public record CaseEvaluation(
+    String functionId,
+    List<Value> arguments,
+    Optional<Value> result,
+    Optional<String> error,
+    boolean selected,
+    boolean tied) {
+
+  /**
+   * Creates an evaluation, copying its arguments.
+   *
+   * @param functionId the calc's FQN, never {@code null}
+   * @param arguments the arguments
+   * @param result the result, when there is one
+   * @param error the failure, when there is one
+   * @param selected whether it was selected
+   * @param tied whether it tied the selected one
+   */
+  public CaseEvaluation {
+    Objects.requireNonNull(functionId, "functionId");
+    arguments = List.copyOf(arguments);
+    Objects.requireNonNull(result, "result");
+    Objects.requireNonNull(error, "error");
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ChecksumMismatchException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ChecksumMismatchException.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ChecksumMismatchException.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ChecksumMismatchException.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java
new file mode 100644
index 0000000000..61d7ba06be
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Condition.java
@@ -0,0 +1,153 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A {@link Query} filter: one comparison of a property, or several conditions combined. Build one
+ * with {@link #equalTo}, {@link #greater}, {@link #less}, {@link #all} or {@link #any}, and negate it
+ * with {@link #negated()}.
+ */
+public sealed interface Condition {
+
+  /**
+   * A condition matching an element whose property is any of the values given. The property names
+   * one of the query properties the API reference documents ({@code "@type"}, {@code "name"},
+   * {@code "qualifiedName"}, ...); an unknown one fails the call.
+   *
+   * @param property the property
+   * @param values the values it may equal
+   * @return the comparison
+   */
+  static Comparison equalTo(String property, List<String> values) {
+    return new Comparison(property, Comparison.Operator.EQUAL, values, false);
+  }
+
+  /**
+   * A condition matching an element whose property is greater than the value.
+   *
+   * @param property the property
+   * @param value the value
+   * @return the comparison
+   */
+  static Comparison greater(String property, String value) {
+    return new Comparison(property, Comparison.Operator.GREATER, List.of(value), false);
+  }
+
+  /**
+   * A condition matching an element whose property is less than the value.
+   *
+   * @param property the property
+   * @param value the value
+   * @return the comparison
+   */
+  static Comparison less(String property, String value) {
+    return new Comparison(property, Comparison.Operator.LESS, List.of(value), false);
+  }
+
+  /**
+   * A condition matching an element every condition matches. An empty list fails the call: it has
+   * no defensible verdict.
+   *
+   * @param conditions the conditions
+   * @return the combination
+   */
+  static Combination all(List<Condition> conditions) {
+    return new Combination(Combination.Operator.AND, conditions);
+  }
+
+  /**
+   * A condition matching an element at least one condition matches. An empty list fails the call.
+   *
+   * @param conditions the conditions
+   * @return the combination
+   */
+  static Combination any(List<Condition> conditions) {
+    return new Combination(Combination.Operator.OR, conditions);
+  }
+
+  /**
+   * The condition matching what this one does not. A comparison negates its verdict; a
+   * combination, which the wire has no negation for, negates each operand and swaps {@code all}
+   * for {@code any}, which says the same thing.
+   *
+   * @return the negation
+   */
+  Condition negated();
+
+  /**
+   * One comparison of a property against values.
+   *
+   * @param property the property compared
+   * @param operator how it is compared
+   * @param values what it is compared against: any of them for {@link Operator#EQUAL}, the one
+   *     value for the others
+   * @param inverse whether the comparison's verdict is negated
+   */
+  record Comparison(String property, Operator operator, List<String> values, boolean inverse)
+      implements Condition {
+
+    /** How a property is compared. */
+    public enum Operator {
+      /** The property equals one of the values. */
+      EQUAL,
+      /** The property is greater than the value. */
+      GREATER,
+      /** The property is less than the value. */
+      LESS
+    }
+
+    /**
+     * Creates a comparison, copying its values.
+     *
+     * @param property the property, never {@code null}
+     * @param operator the operator, never {@code null}
+     * @param values the values
+     * @param inverse whether it is negated
+     */
+    public Comparison {
+      Objects.requireNonNull(property, "property");
+      Objects.requireNonNull(operator, "operator");
+      values = List.copyOf(values);
+    }
+
+    @Override
+    public Comparison negated() {
+      return new Comparison(property, operator, values, !inverse);
+    }
+  }
+
+  /**
+   * Several conditions combined.
+   *
+   * @param operator how they combine
+   * @param conditions the conditions combined
+   */
+  record Combination(Operator operator, List<Condition> conditions) implements Condition {
+
+    /** How conditions combine. */
+    public enum Operator {
+      /** Every condition must match. */
+      AND,
+      /** At least one condition must match. */
+      OR
+    }
+
+    /**
+     * Creates a combination, copying its conditions.
+     *
+     * @param operator the operator, never {@code null}
+     * @param conditions the conditions
+     */
+    public Combination {
+      Objects.requireNonNull(operator, "operator");
+      conditions = List.copyOf(conditions);
+    }
+
+    @Override
+    public Combination negated() {
+      Operator swapped = operator == Operator.AND ? Operator.OR : Operator.AND;
+      return new Combination(swapped, conditions.stream().map(Condition::negated).toList());
+    }
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java
new file mode 100644
index 0000000000..29d0f89a5c
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java
@@ -0,0 +1,484 @@
+package org.openmbee.opensysml;
+
+import com.google.protobuf.Message;
+import org.openmbee.opensysml.internal.ConnectTransport;
+import org.openmbee.opensysml.internal.PrivateService;
+import org.openmbee.opensysml.internal.Protos;
+import org.openmbee.opensysml.internal.ServiceRegistry;
+import org.openmbee.opensysml.proto.ConvertRequest;
+import org.openmbee.opensysml.proto.ConvertResponse;
+import org.openmbee.opensysml.proto.ListEnginesRequest;
+import org.openmbee.opensysml.proto.ListEnginesResponse;
+import org.openmbee.opensysml.proto.ParseFileRequest;
+import org.openmbee.opensysml.proto.ParseFileResponse;
+import org.openmbee.opensysml.proto.ParseSourcesRequest;
+import org.openmbee.opensysml.proto.ParseSourcesResponse;
+import org.openmbee.opensysml.proto.ServerInfoRequest;
+import org.openmbee.opensysml.proto.ServerInfoResponse;
+import java.nio.file.Path;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A connection to a {@code sysml-grpc} service, and the entry point of this client.
+ *
+ * <p>Two lifecycle modes, chosen by {@link ConnectionOptions}:
+ *
+ * <ul>
+ *   <li><b>Private service.</b> The default. The connection starts {@code sysml-grpc} as a child of
+ *       this JVM on a port the kernel chose, and every connection made by the classloader that
+ *       loaded this class shares that child, so they share its parse cache. The last connection to
+ *       close stops it. The child cannot be orphaned: see {@link PrivateService}.
+ *   <li><b>External service.</b> Opt in by naming a host and port, or by setting
+ *       {@code $OPENSYSML_SERVICE} to {@code host:port}. Closing such a connection leaves the
+ *       service running.
+ * </ul>
+ *
+ * <p>Thread-safe: a connection may be shared by any number of threads, since a call carries all of
+ * its own state and {@code java.net.http} is itself thread-safe. {@link #close()} is idempotent.
+ *
+ * <p>Every failure is unchecked; see {@link OpenSysMLException} for the one exception rule.
+ *
+ * <pre>{@code
+ * try (Connection connection = Connection.open()) {
+ *   Model model = connection.load(Path.of("vehicle.sysml"));
+ *   Value mass = model.evalInContext("mass", "Demo::Vehicle");
+ * }
+ * }</pre>
+ */
+public final class Connection implements AutoCloseable {
+  private static final String NAME_OPTIONS = "options";
+  private static final String NAME_CONTENT = "content";
+
+  private final ConnectTransport transport;
+  private final String address;
+  private final Optional<PrivateService> ownedService;
+  private final Capabilities capabilities;
+  private final AtomicBoolean closed = new AtomicBoolean();
+
+  private Connection(
+      ConnectTransport transport, String address, Optional<PrivateService> ownedService) {
+    this.transport = transport;
+    this.address = address;
+    this.ownedService = ownedService;
+    this.capabilities = readCapabilities();
+  }
+
+  /** A connection standing on these capabilities, for tests of what a call checks first. */
+  Connection(ConnectTransport transport, Capabilities capabilities) {
+    this.transport = transport;
+    this.address = "";
+    this.ownedService = Optional.empty();
+    this.capabilities = capabilities;
+  }
+
+  /**
+   * Opens a connection with the default options: a private service, protobuf bodies.
+   *
+   * @return an open connection the caller must close
+   */
+  public static Connection open() {
+    return open(ConnectionOptions.defaults());
+  }
+
+  /**
+   * Opens a connection.
+   *
+   * @param options how to reach the service
+   * @return an open connection the caller must close
+   * @throws ServiceStartException if no service was named and none could be started
+   * @throws TransportException if the service could not be reached
+   */
+  public static Connection open(ConnectionOptions options) {
+    Objects.requireNonNull(options, NAME_OPTIONS);
+    Optional<String> external = externalAddress(options);
+    if (external.isPresent()) {
+      String address = external.get();
+      ConnectTransport transport =
+          new ConnectTransport(address, options.encoding(), options.requestTimeout());
+      try {
+        return new Connection(transport, address, Optional.empty());
+      } catch (RuntimeException e) {
+        transport.close();
+        throw e;
+      }
+    }
+    if (!options.autoStart()) {
+      throw new ServiceStartException(
+          "no service was named and autoStart is false; name one with "
+              + "ConnectionOptions.service(host, port) or $"
+              + ConnectionOptions.SERVICE_ENV);
+    }
+    PrivateService service = ServiceRegistry.acquire(options);
+    ConnectTransport transport =
+        new ConnectTransport(service.address(), options.encoding(), options.requestTimeout());
+    try {
+      return new Connection(transport, service.address(), Optional.of(service));
+    } catch (RuntimeException e) {
+      transport.close();
+      ServiceRegistry.release(service);
+      throw e;
+    }
+  }
+
+  /**
+   * Stops every private service this classloader's connections share, whether or not connections
+   * still hold them. For a host application unloading the client, where nothing else will run.
+   *
+   * @return how many services were stopped
+   */
+  public static int stopSharedServices() {
+    return ServiceRegistry.stopAll();
+  }
+
+  /**
+   * What the service says it can do, read once when the connection opened.
+   *
+   * <p>Negotiate on these names. The service does not answer {@code UNIMPLEMENTED} for a capability
+   * it lacks, so a call is not a test for one.
+   *
+   * @return the capabilities and the service's version string
+   */
+  public Capabilities capabilities() {
+    return capabilities;
+  }
+
+  /**
+   * The analysis engines the service answers with, in name order: what each answers, how strongly
+   * it can, and whether it can run here. Name one with {@link Model#withEngine(String)}.
+   *
+   * @return the engines
+   * @throws CapabilityException if the service does not advertise {@code engines}
+   */
+  public List<EngineInfo> listEngines() {
+    checkOpen();
+    capabilities.require(Capabilities.ENGINES);
+    ListEnginesResponse response =
+        call(
+            "ListEngines",
+            ListEnginesRequest.getDefaultInstance(),
+            ListEnginesResponse.getDefaultInstance());
+    return Protos.engines(response.getEnginesList());
+  }
+
+  /**
+   * The {@code host:port} this connection talks to.
+   *
+   * @return the address
+   */
+  public String address() {
+    return address;
+  }
+
+  /**
+   * Whether closing this connection can stop the service.
+   *
+   * @return {@code true} for a private service, {@code false} for an external one
+   */
+  public boolean ownsService() {
+    return ownedService.isPresent();
+  }
+
+  /**
+   * Parses a file the service can read, and caches it there.
+   *
+   * <p>The path is interpreted by the service, which for a private child is this machine. It is not
+   * read here, so a path that is not there is the service's {@link StatusCode#NOT_FOUND}.
+   *
+   * @param file the source to parse
+   * @return the parsed model
+   * @throws ModelException if the source could not be parsed at all
+   * @throws ServiceException if the service could not read it
+   */
+  public Model load(Path file) {
+    Objects.requireNonNull(file, "file");
+    return parsed(ParseFileRequest.newBuilder().setFilePath(file.toString()).build());
+  }
+
+  /**
+   * Parses a file, with options.
+   *
+   * @param file the source to parse
+   * @param options notation and how strictly to judge it
+   * @return the parsed model
+   */
+  public Model load(Path file, ParseOptions options) {
+    Objects.requireNonNull(file, "file");
+    return parsed(request(options).setFilePath(file.toString()).build());
+  }
+
+  /**
+   * Parses notation given inline.
+   *
+   * @param content SysML notation
+   * @return the parsed model
+   * @throws ModelException if the source could not be parsed at all
+   */
+  public Model parse(String content) {
+    Objects.requireNonNull(content, NAME_CONTENT);
+    return parsed(ParseFileRequest.newBuilder().setContent(content).build());
+  }
+
+  /**
+   * Parses notation given inline, with options.
+   *
+   * @param content notation in {@code options}' language
+   * @param options notation and how strictly to judge it
+   * @return the parsed model
+   */
+  public Model parse(String content, ParseOptions options) {
+    Objects.requireNonNull(content, NAME_CONTENT);
+    return parsed(request(options).setContent(content).build());
+  }
+
+  /**
+   * Parses several source documents as one model, which every later call then names by one hash.
+   *
+   * <p>Documents are parsed in the order given, each {@link SourceDocument} naming a file or
+   * inline notation and, for inline notation, the name other documents import it by. A document's
+   * own {@link SourceDocument#language()} is sent when it names one; it is not inferred from the
+   * options.
+   *
+   * @param documents the documents to parse, in order
+   * @return the parsed model, its {@link Model#roots()} naming one root per document
+   * @throws ModelException if the documents could not be parsed at all
+   * @throws ServiceException if the request names no documents or two documents by one name
+   * @throws CapabilityException if the service does not advertise {@code parse_sources}, or an
+   *     inline document names a language and it does not advertise {@code inline_language}
+   */
+  public Model parseSources(List<SourceDocument> documents) {
+    return parseSources(documents, ParseOptions.defaults());
+  }
+
+  /**
+   * Parses several source documents as one model, with options.
+   *
+   * @param documents the documents to parse, in order
+   * @param options how strictly to judge the notation; its language does not apply to the
+   *     documents, which name their own
+   * @return the parsed model, its {@link Model#roots()} naming one root per document
+   * @throws ModelException if the documents could not be parsed at all
+   * @throws ServiceException if the request names no documents or two documents by one name
+   * @throws CapabilityException if the service does not advertise {@code parse_sources}, an inline
+   *     document names a language and it does not advertise {@code inline_language}, or strict
+   *     conformance is asked and it does not advertise {@code strict_conformance}
+   */
+  public Model parseSources(List<SourceDocument> documents, ParseOptions options) {
+    Objects.requireNonNull(documents, "documents");
+    Objects.requireNonNull(options, NAME_OPTIONS);
+    capabilities.require(Capabilities.PARSE_SOURCES);
+    if (options.strictConformance()) {
+      capabilities.require(Capabilities.STRICT_CONFORMANCE);
+    }
+    ParseSourcesRequest.Builder request =
+        ParseSourcesRequest.newBuilder().setStrictConformance(options.strictConformance());
+    for (SourceDocument document : documents) {
+      if (document.language().isPresent()) {
+        capabilities.require(Capabilities.INLINE_LANGUAGE);
+      }
+      request.addDocuments(Protos.proto(document));
+    }
+    ParseSourcesResponse response =
+        call("ParseSources", request.build(), ParseSourcesResponse.getDefaultInstance());
+    List<Diagnostic> diagnostics = Protos.diagnostics(response.getDiagnosticsList());
+    if (!response.getError().isEmpty()) {
+      throw new ModelException(response.getError(), diagnostics);
+    }
+    List<Symbol> roots = new java.util.ArrayList<>(response.getRootsCount());
+    for (org.openmbee.opensysml.proto.SymbolInfo root : response.getRootsList()) {
+      roots.add(Protos.symbol(root));
+    }
+    return new Model(this, response.getModelHash(), roots, diagnostics);
+  }
+
+  /**
+   * Converts notation given inline into another format.
+   *
+   * @param content the notation to convert, which must name its format in {@code options} since
+   *     inline content has no extension to infer it from
+   * @param toFormat the format to write, named as the service names formats ({@code "sysml"},
+   *     {@code "kerml"}, {@code "ttl"}, {@code "api-json"}, …)
+   * @return the conversion, carrying the text and the formats used
+   * @throws ModelException if the conversion failed; its diagnostics say why
+   * @throws CapabilityException if the service does not advertise {@code convert}
+   */
+  public Conversion convert(String content, String toFormat) {
+    return convert(content, toFormat, ConversionOptions.defaults());
+  }
+
+  /**
+   * Converts notation given inline into another format, with options.
+   *
+   * @param content the notation to convert
+   * @param toFormat the format to write
+   * @param options the source format and whether unreadable notation is written back anyway
+   * @return the conversion, carrying the text and the formats used
+   * @throws ModelException if the conversion failed; its diagnostics say why
+   * @throws ServiceException if the source format could not be inferred or the request was
+   *     refused
+   * @throws CapabilityException if the service does not advertise {@code convert}
+   */
+  public Conversion convert(String content, String toFormat, ConversionOptions options) {
+    Objects.requireNonNull(content, NAME_CONTENT);
+    return converted(ConvertRequest.newBuilder().setContent(content), toFormat, options);
+  }
+
+  /**
+   * Converts a file into another format, its format inferred from its extension.
+   *
+   * @param file the source to convert
+   * @param toFormat the format to write
+   * @return the conversion, carrying the text and the formats used
+   * @throws ModelException if the conversion failed; its diagnostics say why
+   * @throws ServiceException if the service could not read the file
+   * @throws CapabilityException if the service does not advertise {@code convert}
+   */
+  public Conversion convertFile(Path file, String toFormat) {
+    return convertFile(file, toFormat, ConversionOptions.defaults());
+  }
+
+  /**
+   * Converts a file into another format, with options.
+   *
+   * @param file the source to convert
+   * @param toFormat the format to write
+   * @param options the source format, which overrides the file's extension, and whether unreadable
+   *     notation is written back anyway
+   * @return the conversion, carrying the text and the formats used
+   * @throws ModelException if the conversion failed; its diagnostics say why
+   * @throws ServiceException if the service could not read the file
+   * @throws CapabilityException if the service does not advertise {@code convert}
+   */
+  public Conversion convertFile(Path file, String toFormat, ConversionOptions options) {
+    Objects.requireNonNull(file, "file");
+    return converted(
+        ConvertRequest.newBuilder().setFilePath(file.toString()), toFormat, options);
+  }
+
+  /**
+   * A handle on a model the service parsed already, named by its hash.
+   *
+   * <p>For a host that kept a hash across connections. Nothing is called here, so a hash the
+   * service does not hold is reported by the first call made on the model, not by this one, and the
+   * handle carries neither a root nor the diagnostics of that parse.
+   *
+   * @param modelHash a hash a parse returned
+   * @return a handle on that model
+   */
+  public Model model(String modelHash) {
+    checkOpen();
+    Objects.requireNonNull(modelHash, "modelHash");
+    if (modelHash.isBlank()) {
+      throw new IllegalArgumentException("modelHash must not be blank");
+    }
+    return new Model(this, modelHash, List.of(), List.of());
+  }
+
+  /**
+   * Closes the connection, and the private service when this was the last connection holding it.
+   * Idempotent; an external service is never stopped.
+   */
+  @Override
+  public void close() {
+    if (!closed.compareAndSet(false, true)) {
+      return;
+    }
+    try {
+      transport.close();
+    } finally {
+      ownedService.ifPresent(ServiceRegistry::release);
+    }
+  }
+
+  /** The private service this connection holds, for a lifecycle test. */
+  Optional<PrivateService> ownedService() {
+    return ownedService;
+  }
+
+  /** Calls the service. Package-private: generated messages are not part of the public surface. */
+  <T extends Message> T call(String method, Message request, T responseDefault) {
+    checkOpen();
+    return transport.call(method, request, responseDefault);
+  }
+
+  private Model parsed(ParseFileRequest request) {
+    if (request.getStrictConformance()) {
+      capabilities.require(Capabilities.STRICT_CONFORMANCE);
+    }
+    ParseFileResponse response = call("ParseFile", request, ParseFileResponse.getDefaultInstance());
+    List<Diagnostic> diagnostics = Protos.diagnostics(response.getDiagnosticsList());
+    if (!response.getError().isEmpty()) {
+      throw new ModelException(response.getError(), diagnostics);
+    }
+    List<Symbol> roots =
+        response.hasRoot() ? List.of(Protos.symbol(response.getRoot())) : List.of();
+    return new Model(this, response.getModelHash(), roots, diagnostics);
+  }
+
+  private Conversion converted(
+      ConvertRequest.Builder request, String toFormat, ConversionOptions options) {
+    Objects.requireNonNull(toFormat, "toFormat");
+    Objects.requireNonNull(options, NAME_OPTIONS);
+    capabilities.require(Capabilities.CONVERT);
+    request.setToFormat(toFormat).setTolerateSyntaxErrors(options.tolerateSyntaxErrors());
+    options.fromFormat().ifPresent(request::setFromFormat);
+    ConvertResponse response =
+        call("Convert", request.build(), ConvertResponse.getDefaultInstance());
+    List<Diagnostic> diagnostics = Protos.diagnostics(response.getDiagnosticsList());
+    if (!response.getError().isEmpty()) {
+      throw new ModelException(response.getError(), diagnostics);
+    }
+    return Protos.conversion(response);
+  }
+
+  private static ParseFileRequest.Builder request(ParseOptions options) {
+    Objects.requireNonNull(options, NAME_OPTIONS);
+    return ParseFileRequest.newBuilder()
+        .setLanguage(options.language().wireName())
+        .setStrictConformance(options.strictConformance());
+  }
+
+  private Capabilities readCapabilities() {
+    ServerInfoResponse info =
+        transport.call(
+            "GetServerInfo",
+            ServerInfoRequest.getDefaultInstance(),
+            ServerInfoResponse.getDefaultInstance());
+    Set<String> names = new LinkedHashSet<>(info.getCapabilitiesList());
+    return new Capabilities(info.getVersion(), names);
+  }
+
+  private static Optional<String> externalAddress(ConnectionOptions options) {
+    Optional<String> host = options.host();
+    if (host.isPresent()) {
+      return Optional.of(host.get() + ":" + options.port());
+    }
+    String named = System.getenv(ConnectionOptions.SERVICE_ENV);
+    if (named == null || named.isBlank()) {
+      return Optional.empty();
+    }
+    String address = named.trim();
+    int separator = address.lastIndexOf(':');
+    if (separator <= 0 || separator == address.length() - 1) {
+      throw new IllegalArgumentException(
+          "$" + ConnectionOptions.SERVICE_ENV + " must be host:port, not " + named);
+    }
+    try {
+      Integer.parseInt(address.substring(separator + 1));
+    } catch (NumberFormatException e) {
+      throw new IllegalArgumentException(
+          "$" + ConnectionOptions.SERVICE_ENV + " must be host:port, not " + named, e);
+    }
+    return Optional.of(address);
+  }
+
+  private void checkOpen() {
+    if (closed.get()) {
+      throw new IllegalStateException("this connection is closed");
+    }
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ConnectionOptions.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ConnectionOptions.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ConnectionOptions.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ConnectionOptions.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Conversion.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Conversion.java
new file mode 100644
index 0000000000..af719a08d8
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Conversion.java
@@ -0,0 +1,45 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A model written out in one of the formats the service writes.
+ *
+ * @param content the converted model
+ * @param fromFormat the format the source was read as; reported even when it was inferred, so a
+ *     caller learns what the inference decided
+ * @param toFormat the format {@code content} is written in
+ * @param diagnostics syntax errors the service tolerated under {@code tolerateSyntaxErrors};
+ *     empty otherwise, a conversion that failed being a {@link ModelException}
+ * @param experimental whether the conversion went through the RDF mapping, which is experimental;
+ *     a notation conversion is stable
+ * @param experimentalNotice what is experimental about it, in the service's own wording; empty
+ *     when {@code experimental} is false
+ */
+public record Conversion(
+    String content,
+    String fromFormat,
+    String toFormat,
+    List<Diagnostic> diagnostics,
+    boolean experimental,
+    String experimentalNotice) {
+
+  /**
+   * Creates a conversion, copying its diagnostics.
+   *
+   * @param content the converted model, never {@code null}
+   * @param fromFormat the format read
+   * @param toFormat the format written
+   * @param diagnostics the diagnostics
+   * @param experimental whether the conversion is experimental
+   * @param experimentalNotice the notice, never {@code null}
+   */
+  public Conversion {
+    Objects.requireNonNull(content, "content");
+    Objects.requireNonNull(fromFormat, "fromFormat");
+    Objects.requireNonNull(toFormat, "toFormat");
+    diagnostics = List.copyOf(diagnostics);
+    Objects.requireNonNull(experimentalNotice, "experimentalNotice");
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ConversionOptions.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ConversionOptions.java
new file mode 100644
index 0000000000..260302ab8a
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ConversionOptions.java
@@ -0,0 +1,57 @@
+package org.openmbee.opensysml;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * How a conversion reads its source.
+ *
+ * @param fromFormat the format to read the source as ({@code "sysml"}, {@code "kerml"}, {@code
+ *     "text"}, {@code "ttl"}, {@code "turtle"}, {@code "rdf"}, {@code "api-json"} or {@code
+ *     "json"}); absent infers it from a file's
+ *     extension and reads a model's parsed source as notation — inline content has neither, so it
+ *     must say
+ * @param tolerateSyntaxErrors write notation back out even when the parser could not read all of
+ *     it, reporting its syntax errors as the conversion's diagnostics; notation to notation only
+ */
+public record ConversionOptions(Optional<String> fromFormat, boolean tolerateSyntaxErrors) {
+
+  /**
+   * Validates the options.
+   *
+   * @param fromFormat the source format, when named
+   * @param tolerateSyntaxErrors whether unreadable notation is still written back
+   */
+  public ConversionOptions {
+    Objects.requireNonNull(fromFormat, "fromFormat");
+  }
+
+  /**
+   * The format inferred, and no tolerance of syntax errors.
+   *
+   * @return the default options
+   */
+  public static ConversionOptions defaults() {
+    return new ConversionOptions(Optional.empty(), false);
+  }
+
+  /**
+   * The same options reading the source as one format.
+   *
+   * @param fromFormat the format to read
+   * @return options naming it
+   */
+  public ConversionOptions withFromFormat(String fromFormat) {
+    return new ConversionOptions(Optional.of(fromFormat), tolerateSyntaxErrors);
+  }
+
+  /**
+   * The same options, tolerating syntax errors or not.
+   *
+   * @param tolerateSyntaxErrors whether to write unreadable notation back
+   * @return options with that tolerance
+   */
+  public ConversionOptions withTolerateSyntaxErrors(boolean tolerateSyntaxErrors) {
+    return new ConversionOptions(fromFormat, tolerateSyntaxErrors);
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Diagnostic.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Diagnostic.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Diagnostic.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Diagnostic.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/DocumentQueryResult.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/DocumentQueryResult.java
new file mode 100644
index 0000000000..cf915feeea
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/DocumentQueryResult.java
@@ -0,0 +1,25 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+
+/**
+ * A document query's answer: its projected columns and typed rows, both in the deterministic order
+ * the engine reports. A query that selects nothing answers with no rows; one that could not be run
+ * fails the call with a status, which is a {@link ServiceException}.
+ *
+ * @param columns the projected property names, in projection order
+ * @param rows the selected rows, in the engine's order
+ */
+public record DocumentQueryResult(List<String> columns, List<DocumentRow> rows) {
+
+  /**
+   * Creates a result, copying its collections.
+   *
+   * @param columns the column names
+   * @param rows the rows
+   */
+  public DocumentQueryResult {
+    columns = List.copyOf(columns);
+    rows = List.copyOf(rows);
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/DocumentRow.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/DocumentRow.java
new file mode 100644
index 0000000000..dd4d088713
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/DocumentRow.java
@@ -0,0 +1,48 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * One selected element of a document query and its projected cells, one per column.
+ *
+ * @param element the selected element itself; for an object row, the usage the object is held
+ *     under; for a row a {@code Verdicts} query answered, the assertion checked; for a state or
+ *     event row, the usage of the object the row is about
+ * @param cells one value sequence per column, in column order
+ * @param verdict the verdict a row a {@code Verdicts} query answered carries; absent for any other
+ *     row
+ * @param object the object a row over held objects is about — one an {@code Objects} query
+ *     enumerated, a bound object's part, or the object a state or event row is about; absent for
+ *     any other row
+ * @param state the state a row a {@code States} query answered carries; absent for any other row
+ * @param event the event a row an {@code Events} query answered carries; absent for any other row
+ */
+public record DocumentRow(
+    DocumentValue.ElementRef element,
+    List<List<DocumentValue>> cells,
+    Optional<DocumentValue.DocumentVerdict> verdict,
+    Optional<DocumentValue.ObjectRef> object,
+    Optional<DocumentValue.DocumentState> state,
+    Optional<DocumentValue.DocumentEvent> event) {
+
+  /**
+   * Creates a row, copying its cells.
+   *
+   * @param element the selected element, never {@code null}
+   * @param cells the cells, in column order
+   * @param verdict the verdict, when the row is one
+   * @param object the object, when the row is about one
+   * @param state the state, when the row is one
+   * @param event the event, when the row is one
+   */
+  public DocumentRow {
+    Objects.requireNonNull(element, "element");
+    cells = cells.stream().map(List::copyOf).toList();
+    Objects.requireNonNull(verdict, "verdict");
+    Objects.requireNonNull(object, "object");
+    Objects.requireNonNull(state, "state");
+    Objects.requireNonNull(event, "event");
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/DocumentValue.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/DocumentValue.java
new file mode 100644
index 0000000000..423bcbdd5d
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/DocumentValue.java
@@ -0,0 +1,294 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * One typed document-query value: what a {@code bindings} value of {@link
+ * Model#runDocumentQuery(String, java.util.Map)} carries, and what a {@link DocumentRow}'s element
+ * and cells answer.
+ *
+ * <p>An element is bound by qualified name ({@link ElementRef}) and an object the service holds by
+ * id or by path ({@link ObjectRef}); a row a {@code Verdicts}, {@code States} or {@code Events}
+ * query answered is a {@link DocumentVerdict}, {@link DocumentState} or {@link DocumentEvent}, and
+ * an unbounded multiplicity is {@link InfinityValue}. Binding a verdict, state, event or infinity
+ * is sent as-is: the service refuses it, rather than the client deciding which kinds bind.
+ */
+public sealed interface DocumentValue {
+
+  /**
+   * A model element, named by qualified name.
+   *
+   * @param id the element's qualified name; empty for an anonymous element a query answered
+   * @param type the element's metamodel type ({@code "PartUsage"}, …); reported when answered,
+   *     carried when bound so an anonymous element can be named by type alone
+   */
+  record ElementRef(String id, String type) implements DocumentValue {
+
+    /**
+     * Creates an element reference.
+     *
+     * @param id the qualified name, never {@code null}
+     * @param type the metamodel type, empty rather than {@code null} when unreported
+     */
+    public ElementRef {
+      Objects.requireNonNull(id, "id");
+      Objects.requireNonNull(type, "type");
+    }
+  }
+
+  /**
+   * An object the service holds for the model, created by {@link Model#instantiate(String)}.
+   *
+   * <p>Bound, it names the object by {@code path} when set and by {@code id} otherwise; one setting
+   * both must name one object by both. Answered, it carries all three fields.
+   *
+   * @param id the object's id, as the instantiation answered it
+   * @param path the object by the label a session reaches it under: the qualified name it was
+   *     instantiated as ({@code "Garage::car"}), its id ({@code "#2"}), or a path through feature
+   *     values of either ({@code "Garage::car.wheels[2]"}; indexes count from 1)
+   * @param element the usage the object is held under, its definition or usage; reported when
+   *     answered, ignored when bound
+   */
+  record ObjectRef(long id, String path, Optional<ElementRef> element) implements DocumentValue {
+
+    /**
+     * Creates an object reference.
+     *
+     * @param id the object's id
+     * @param path the object's path, empty rather than {@code null} when unnamed
+     * @param element the holding usage, when reported
+     */
+    public ObjectRef {
+      Objects.requireNonNull(path, "path");
+      Objects.requireNonNull(element, "element");
+    }
+  }
+
+  /**
+   * A string.
+   *
+   * @param value the text
+   */
+  record StringValue(String value) implements DocumentValue {
+
+    /**
+     * Creates the value.
+     *
+     * @param value the text, never {@code null}
+     */
+    public StringValue {
+      Objects.requireNonNull(value, "value");
+    }
+  }
+
+  /**
+   * An integer.
+   *
+   * @param value the integer
+   */
+  record IntegerValue(long value) implements DocumentValue {}
+
+  /**
+   * A real.
+   *
+   * @param value the real
+   */
+  record RealValue(double value) implements DocumentValue {}
+
+  /**
+   * A boolean.
+   *
+   * @param value the boolean
+   */
+  record BooleanValue(boolean value) implements DocumentValue {}
+
+  /** The unbounded value {@code *}: answered, never bound. */
+  record InfinityValue() implements DocumentValue {}
+
+  /**
+   * A magnitude in a unit.
+   *
+   * @param quantity the quantity
+   */
+  record QuantityValue(Quantity quantity) implements DocumentValue {
+
+    /**
+     * Creates the value.
+     *
+     * @param quantity the quantity, never {@code null}
+     */
+    public QuantityValue {
+      Objects.requireNonNull(quantity, "quantity");
+    }
+  }
+
+  /**
+   * A row a {@code Verdicts} query answered: an assertion checked on one object. Answered only;
+   * binding one is refused.
+   *
+   * @param assertion the constraint, requirement, satisfy usage or verification case checked; its
+   *     id is empty when the assertion is anonymous
+   * @param kind {@code "constraint"}, {@code "requirement"}, {@code "satisfaction"} or {@code
+   *     "verification"}
+   * @param text the assertion as written ({@code "assert constraint massKnown"})
+   * @param path the object checked, named from the element the query was bound to ({@code
+   *     "Garage::car.wheels[2]"})
+   * @param status {@code "holds"}, {@code "violated"} or {@code "undecided"}
+   * @param condition the condition that evaluated to false, as written; empty otherwise
+   * @param reason why the assertion is violated or undecided; empty when it holds
+   * @param verification the verdict kinds ({@code "pass"}, {@code "fail"}, …) of the verification
+   *     cases verifying the requirement the row is about; a verification row's own kind
+   */
+  record DocumentVerdict(
+      ElementRef assertion,
+      String kind,
+      String text,
+      String path,
+      String status,
+      String condition,
+      String reason,
+      List<String> verification)
+      implements DocumentValue {
+
+    /**
+     * Creates a verdict row, copying its verification kinds.
+     *
+     * @param assertion the assertion checked, never {@code null}
+     * @param kind the verdict's kind, never {@code null}
+     * @param text the assertion as written, never {@code null}
+     * @param path the object checked, never {@code null}
+     * @param status how it came out, never {@code null}
+     * @param condition the failed condition, never {@code null}
+     * @param reason why it failed, never {@code null}
+     * @param verification the verification verdict kinds
+     */
+    public DocumentVerdict {
+      Objects.requireNonNull(assertion, "assertion");
+      Objects.requireNonNull(kind, "kind");
+      Objects.requireNonNull(text, "text");
+      Objects.requireNonNull(path, "path");
+      Objects.requireNonNull(status, "status");
+      Objects.requireNonNull(condition, "condition");
+      Objects.requireNonNull(reason, "reason");
+      verification = List.copyOf(verification);
+    }
+  }
+
+  /**
+   * A row a {@code States} query answered: one active leaf state of one object. Answered only;
+   * binding one is refused.
+   *
+   * @param object the object whose state machine the row reads
+   * @param machine the exhibited state usage ({@code "lp"}), or the state def's name
+   * @param name the active leaf state's own name ({@code "dim"})
+   * @param path the leaf's path from the machine's top level ({@code "on.dim"})
+   * @param state the leaf's declaration, absent when the machine declares no element for it
+   * @param region the orthogonal region declaring the leaf ({@code "light"}); empty when the leaf
+   *     is not in one
+   * @param enclosing the active composite states around the leaf, outermost first
+   */
+  record DocumentState(
+      ObjectRef object,
+      String machine,
+      String name,
+      String path,
+      Optional<ElementRef> state,
+      String region,
+      List<String> enclosing)
+      implements DocumentValue {
+
+    /**
+     * Creates a state row, copying its enclosing states.
+     *
+     * @param object the object, never {@code null}
+     * @param machine the machine's name, never {@code null}
+     * @param name the leaf's name, never {@code null}
+     * @param path the leaf's path, never {@code null}
+     * @param state the leaf's declaration, when the model names one
+     * @param region the orthogonal region, never {@code null}
+     * @param enclosing the enclosing states
+     */
+    public DocumentState {
+      Objects.requireNonNull(object, "object");
+      Objects.requireNonNull(machine, "machine");
+      Objects.requireNonNull(name, "name");
+      Objects.requireNonNull(path, "path");
+      Objects.requireNonNull(state, "state");
+      Objects.requireNonNull(region, "region");
+      enclosing = List.copyOf(enclosing);
+    }
+  }
+
+  /**
+   * A row an {@code Events} query answered: one record of a session's trace. Answered only;
+   * binding one is refused.
+   *
+   * @param kind {@code "accept"}, {@code "send"}, {@code "transition"}, {@code "entry"}, {@code
+   *     "exit"}, {@code "do"}, {@code "choice"} or {@code "guard"}
+   * @param time the instant the record was written at, in the runtime clock's unit — a {@link
+   *     QuantityValue} when the clock carries a unit, a plain number otherwise
+   * @param text the record as the trace prints it
+   * @param object the object the record is about; absent for a record of the run as a whole
+   * @param machine the state machine the record is about, as {@link DocumentState#machine()} names
+   *     it
+   * @param state the state entered, exited or run (entry, exit and do records)
+   * @param from the transition's source state
+   * @param to the transition's target state
+   * @param target the object a send was delivered to; absent for every other kind
+   * @param event the accepted or sent event's type name
+   * @param payload the accept's payload, one {@code name = value} text per attribute
+   * @param alternatives what a choice drew from, in order
+   * @param taken the alternative the choice took
+   */
+  record DocumentEvent(
+      String kind,
+      DocumentValue time,
+      String text,
+      Optional<ObjectRef> object,
+      String machine,
+      String state,
+      String from,
+      String to,
+      Optional<ObjectRef> target,
+      String event,
+      List<String> payload,
+      List<String> alternatives,
+      String taken)
+      implements DocumentValue {
+
+    /**
+     * Creates an event row, copying its lists.
+     *
+     * @param kind the record's kind, never {@code null}
+     * @param time the record's instant, never {@code null}
+     * @param text the record as printed, never {@code null}
+     * @param object the object the record is about, when there is one
+     * @param machine the machine's name, never {@code null}
+     * @param state the state, never {@code null}
+     * @param from the source state, never {@code null}
+     * @param to the target state, never {@code null}
+     * @param target the addressed object, when there is one
+     * @param event the event's type name, never {@code null}
+     * @param payload the payload entries
+     * @param alternatives the alternatives
+     * @param taken the one taken, never {@code null}
+     */
+    public DocumentEvent {
+      Objects.requireNonNull(kind, "kind");
+      Objects.requireNonNull(time, "time");
+      Objects.requireNonNull(text, "text");
+      Objects.requireNonNull(object, "object");
+      Objects.requireNonNull(machine, "machine");
+      Objects.requireNonNull(state, "state");
+      Objects.requireNonNull(from, "from");
+      Objects.requireNonNull(to, "to");
+      Objects.requireNonNull(target, "target");
+      Objects.requireNonNull(event, "event");
+      payload = List.copyOf(payload);
+      alternatives = List.copyOf(alternatives);
+      Objects.requireNonNull(taken, "taken");
+    }
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Edit.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Edit.java
new file mode 100644
index 0000000000..e42d668375
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Edit.java
@@ -0,0 +1,202 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * One source-preserving change to a model, applied by {@link Model#applyEdits(List)}.
+ *
+ * <p>Every target names its element by the id a read reports ({@link Symbol#id()}), and the whole
+ * batch applies or none does: a refusal is an {@link EditException} naming why.
+ */
+public sealed interface Edit {
+
+  private static void requireTarget(String target) {
+    Objects.requireNonNull(target, "target");
+  }
+
+  /**
+   * Sets the value of a feature that already exists, replacing the expression of its {@code =
+   * <expr>} or adding one before the declaration's {@code ;}.
+   *
+   * @param target the feature, as {@link Symbol#id()} names it ({@code "Demo::sc::unitMass"})
+   * @param value the new value in notation ({@code "1050.0[SI::kg]"}, {@code "\"m1\""}, {@code
+   *     "true"}, {@code "mass * 2"}); it must parse as an expression and resolve in the feature's
+   *     own scope
+   */
+  record SetValue(String target, String value) implements Edit {
+
+    /**
+     * Creates the edit.
+     *
+     * @param target the feature, never {@code null}
+     * @param value the new value, never {@code null}
+     */
+    public SetValue {
+      requireTarget(target);
+      Objects.requireNonNull(value, "value");
+    }
+  }
+
+  /**
+   * Rewrites the name token of a declaration and every reference to it in the model's documents. A
+   * rename that reaches a reference in a document the edit cannot rewrite is refused, naming the
+   * referring elements.
+   *
+   * @param target the element to rename, as {@link Symbol#id()} names it
+   * @param newName the new declared name; it must lex as an identifier
+   */
+  record Rename(String target, String newName) implements Edit {
+
+    /**
+     * Creates the edit.
+     *
+     * @param target the element, never {@code null}
+     * @param newName the new name, never {@code null}
+     */
+    public Rename {
+      requireTarget(target);
+      Objects.requireNonNull(newName, "newName");
+    }
+  }
+
+  /**
+   * Inserts a declaration into a namespace or the document root.
+   *
+   * @param owner FQN of the namespace to receive the declaration; empty for the document root
+   * @param kind the written declaration kind, such as {@code "part def"} or {@code "class"}
+   * @param name the declared identifier
+   * @param type a type target for a usage, written as notation
+   * @param multiplicity a multiplicity, including brackets, such as {@code "[0..*]"}
+   * @param value a value expression, written as notation
+   * @param specializes specialization targets for a definition
+   */
+  record AddMember(
+      String owner,
+      String kind,
+      String name,
+      Optional<String> type,
+      Optional<String> multiplicity,
+      Optional<String> value,
+      List<String> specializes)
+      implements Edit {
+
+    /**
+     * Creates the edit, copying the specializations.
+     *
+     * @param owner the receiving namespace, never {@code null}
+     * @param kind the declaration kind, never {@code null}
+     * @param name the identifier, never {@code null}
+     * @param type the type target, when written
+     * @param multiplicity the multiplicity, when written
+     * @param value the value expression, when written
+     * @param specializes the specialization targets
+     */
+    public AddMember {
+      Objects.requireNonNull(owner, "owner");
+      Objects.requireNonNull(kind, "kind");
+      Objects.requireNonNull(name, "name");
+      Objects.requireNonNull(type, "type");
+      Objects.requireNonNull(multiplicity, "multiplicity");
+      Objects.requireNonNull(value, "value");
+      specializes = List.copyOf(specializes);
+    }
+
+    /**
+     * A member declaration with none of its optional parts.
+     *
+     * @param owner the receiving namespace, empty for the document root
+     * @param kind the declaration kind
+     * @param name the identifier
+     * @return the edit
+     */
+    public static AddMember of(String owner, String kind, String name) {
+      return new AddMember(
+          owner, kind, name, Optional.empty(), Optional.empty(), Optional.empty(), List.of());
+    }
+
+    /**
+     * The same member declared with a type.
+     *
+     * @param type the type target, as notation
+     * @return the edit carrying it
+     */
+    public AddMember withType(String type) {
+      return new AddMember(
+          owner, kind, name, Optional.of(type), multiplicity, value, specializes);
+    }
+
+    /**
+     * The same member declared with a multiplicity.
+     *
+     * @param multiplicity the multiplicity, including brackets
+     * @return the edit carrying it
+     */
+    public AddMember withMultiplicity(String multiplicity) {
+      return new AddMember(
+          owner, kind, name, type, Optional.of(multiplicity), value, specializes);
+    }
+
+    /**
+     * The same member declared with a value.
+     *
+     * @param value the value expression, as notation
+     * @return the edit carrying it
+     */
+    public AddMember withValue(String value) {
+      return new AddMember(owner, kind, name, type, multiplicity, Optional.of(value), specializes);
+    }
+
+    /**
+     * The same member declared with specialization targets.
+     *
+     * @param specializes the targets, as qualified names
+     * @return the edit carrying them
+     */
+    public AddMember withSpecializes(List<String> specializes) {
+      return new AddMember(owner, kind, name, type, multiplicity, value, specializes);
+    }
+  }
+
+  /**
+   * Removes a declaration and its owned trivia.
+   *
+   * @param target the declaration to remove, as {@link Symbol#id()} names it
+   * @param cascade whether to also remove the declarations that refer to the target
+   */
+  record Delete(String target, boolean cascade) implements Edit {
+
+    /**
+     * Creates the edit.
+     *
+     * @param target the declaration, never {@code null}
+     * @param cascade whether referring declarations go too
+     */
+    public Delete {
+      requireTarget(target);
+    }
+  }
+
+  /**
+   * Re-parents a declaration: the span {@link Delete} would remove is written where {@link
+   * AddMember} would insert it, and the references the move breaks are respelled so the model
+   * stays valid. A move whose references cannot be respelled is refused, naming them.
+   *
+   * @param target the declaration to move, as {@link Symbol#id()} names it
+   * @param owner FQN of the namespace to receive it; empty for the document root
+   */
+  record Move(String target, String owner) implements Edit {
+
+    /**
+     * Creates the edit.
+     *
+     * @param target the declaration, never {@code null}
+     * @param owner the receiving namespace, never {@code null}
+     */
+    public Move {
+      requireTarget(target);
+      Objects.requireNonNull(owner, "owner");
+    }
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditException.java
new file mode 100644
index 0000000000..0a8b3ad19b
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditException.java
@@ -0,0 +1,129 @@
+package org.openmbee.opensysml;
+
+import java.io.IOException;
+import java.io.InvalidObjectException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serial;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * An edit batch the service answered but refused: the request was well formed, and this is why the
+ * model was not edited.
+ *
+ * <p>A {@link ModelException}, since the refusal is reported inside an answer rather than as a
+ * call's status; the {@link #failure()} kind is what a caller acts on.
+ */
+public final class EditException extends ModelException {
+
+  private static final long serialVersionUID = 1L;
+
+  private static final int MAX_SERIALIZED_REFERRERS = 100_000;
+
+  private final EditFailure failure;
+  private final String failureName;
+  private final List<String> referringElements;
+  private transient List<Referrer> referrers;
+
+  /**
+   * Creates an edit refusal.
+   *
+   * @param message the refusal, as the service worded it
+   * @param failure what kind of refusal it is
+   * @param failureName the refusal's wire name, kept for a kind this release does not know
+   * @param diagnostics diagnostics the answer carried
+   * @param referringElements where references to the refused target are made, as the service
+   *     spelled them
+   * @param referrers the referring declarations, each with its document
+   */
+  public EditException(
+      String message,
+      EditFailure failure,
+      String failureName,
+      List<Diagnostic> diagnostics,
+      List<String> referringElements,
+      List<Referrer> referrers) {
+    super(message, diagnostics);
+    this.failure = Objects.requireNonNull(failure, "failure");
+    this.failureName = Objects.requireNonNull(failureName, "failureName");
+    this.referringElements = List.copyOf(Objects.requireNonNull(referringElements, "referringElements"));
+    this.referrers = List.copyOf(Objects.requireNonNull(referrers, "referrers"));
+  }
+
+  /**
+   * What kind of refusal this is.
+   *
+   * @return the kind; {@link EditFailure#UNRECOGNIZED} for one this release does not know
+   */
+  public EditFailure failure() {
+    return failure;
+  }
+
+  /**
+   * The refusal's wire name, such as {@code "EDIT_FAILURE_UNKNOWN_TARGET"}: the enum's name for a
+   * known kind, and the unrecognized name the service sent for one it does not have.
+   *
+   * @return the failure's name on the wire
+   */
+  public String failureName() {
+    return failureName;
+  }
+
+  /**
+   * Where the references to a refused rename's, delete's or move's target are made: the qualified
+   * name of each referring namespace, suffixed with its document in parentheses when that is not
+   * the document edited.
+   *
+   * @return the referring elements, empty for a refusal that names none
+   */
+  public List<String> referringElements() {
+    return referringElements;
+  }
+
+  /**
+   * The declarations referring to the refused target, each with the document declaring it, in
+   * document then name order.
+   *
+   * @return the referrers, empty for a refusal that names none
+   */
+  public List<Referrer> referrers() {
+    return referrers;
+  }
+
+  @Serial
+  private void writeObject(ObjectOutputStream stream) throws IOException {
+    stream.defaultWriteObject();
+    if (referrers.size() > MAX_SERIALIZED_REFERRERS) {
+      throw new InvalidObjectException("too many referrers");
+    }
+    stream.writeInt(referrers.size());
+    for (Referrer referrer : referrers) {
+      stream.writeObject(referrer.name());
+      stream.writeObject(referrer.document());
+    }
+  }
+
+  @Serial
+  private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException {
+    stream.defaultReadObject();
+    int count = stream.readInt();
+    if (count < 0) {
+      throw new InvalidObjectException("negative referrer count");
+    }
+    if (count > MAX_SERIALIZED_REFERRERS) {
+      throw new InvalidObjectException("too many referrers");
+    }
+    List<Referrer> restored = new ArrayList<>(count);
+    for (int index = 0; index < count; index++) {
+      Object name = stream.readObject();
+      Object document = stream.readObject();
+      if (!(name instanceof String referrerName) || !(document instanceof String referrerDocument)) {
+        throw new InvalidObjectException("invalid referrer");
+      }
+      restored.add(new Referrer(referrerName, referrerDocument));
+    }
+    referrers = List.copyOf(restored);
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditFailure.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditFailure.java
new file mode 100644
index 0000000000..d85c5f76d2
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditFailure.java
@@ -0,0 +1,48 @@
+package org.openmbee.opensysml;
+
+/**
+ * What kind of refusal an {@link EditException} reports, so a caller acts on the kind rather than
+ * on the message text. Every refusal is one of these: an edit is never silently dropped.
+ */
+public enum EditFailure {
+  /** No failure, or one the service did not classify. */
+  UNSPECIFIED,
+  /** The request named no edit. */
+  NO_OPERATIONS,
+  /** No element of that name is in the model. */
+  UNKNOWN_TARGET,
+  /** The name denotes several declarations. */
+  AMBIGUOUS_TARGET,
+  /** The element can carry no value. */
+  NOT_VALUED,
+  /** The new value does not parse as an expression. */
+  INVALID_VALUE,
+  /** The new name does not lex as an identifier. */
+  INVALID_NAME,
+  /** The element declares no name to rewrite. */
+  NOT_NAMED,
+  /** References to the element would break. */
+  RENAME_REFERENCED,
+  /** Two edits cover the same bytes. */
+  OVERLAPPING_EDITS,
+  /** The edited source has errors the original had not. */
+  RESULT_INVALID,
+  /** An add-member owner does not exist. */
+  OWNER_UNKNOWN,
+  /** The owner cannot contain members. */
+  OWNER_NOT_NAMESPACE,
+  /** The kind is invalid for the document's language. */
+  ILLEGAL_KIND,
+  /** The owner already declares the name. */
+  MEMBER_NAME_TAKEN,
+  /** A delete would leave references dangling. */
+  DELETE_REFERENCED,
+  /** A move's owner is the target or inside it. */
+  OWNER_INSIDE_TARGET,
+  /** A move would leave a reference no spelling restores. */
+  MOVE_REFERENCED,
+  /** The target is referred to from a document the edit cannot rewrite. */
+  REFERENCED_ELSEWHERE,
+  /** A refusal kind this release of the client does not know. */
+  UNRECOGNIZED
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditOptions.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditOptions.java
new file mode 100644
index 0000000000..481f58c383
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditOptions.java
@@ -0,0 +1,56 @@
+package org.openmbee.opensysml;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * How a batch of edits is applied.
+ *
+ * @param acceptDocuments whether the response's {@code documents} are read; a model of several
+ *     documents is edited only when set — unset, such a model is refused as a failed precondition.
+ *     {@code true} by default: this client reads {@code documents}
+ * @param document the document whose declarations the operations target, named as the parse named
+ *     it; absent names the model's first document, which is the only one of a single-document
+ *     model
+ */
+public record EditOptions(boolean acceptDocuments, Optional<String> document) {
+
+  /**
+   * Validates the options.
+   *
+   * @param acceptDocuments whether the response's documents are read
+   * @param document the document targeted, when named
+   */
+  public EditOptions {
+    Objects.requireNonNull(document, "document");
+  }
+
+  /**
+   * Documents accepted, the model's first document edited.
+   *
+   * @return the default options
+   */
+  public static EditOptions defaults() {
+    return new EditOptions(true, Optional.empty());
+  }
+
+  /**
+   * The same options, reading the response's documents or not.
+   *
+   * @param acceptDocuments whether documents are read
+   * @return options with that reading
+   */
+  public EditOptions withAcceptDocuments(boolean acceptDocuments) {
+    return new EditOptions(acceptDocuments, document);
+  }
+
+  /**
+   * The same options targeting one document's declarations.
+   *
+   * @param document the document's name, as the parse gave it
+   * @return options naming it
+   */
+  public EditOptions withDocument(String document) {
+    return new EditOptions(acceptDocuments, Optional.of(document));
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditResult.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditResult.java
new file mode 100644
index 0000000000..9e4f807c31
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditResult.java
@@ -0,0 +1,38 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * The edited notation a batch of edits produced, and what each operation changed.
+ *
+ * @param content the edited notation of a single-document model; empty for a model of several
+ *     documents, whose edited notation is in {@code documents} alone
+ * @param applied what each operation changed, grouped by document in the order {@code documents}
+ *     lists them and in request order within a document
+ * @param documents the edited notation of every document the edits rewrote, the edited document
+ *     first, then the others in name order; a document of several the edits left as parsed is not
+ *     listed
+ * @param diagnostics what the service reported while editing
+ */
+public record EditResult(
+    String content,
+    List<AppliedEdit> applied,
+    List<EditedDocument> documents,
+    List<Diagnostic> diagnostics) {
+
+  /**
+   * Creates an edit result, copying its collections.
+   *
+   * @param content the edited notation of a single-document model, never {@code null}
+   * @param applied the applied edits
+   * @param documents the edited documents
+   * @param diagnostics the diagnostics
+   */
+  public EditResult {
+    Objects.requireNonNull(content, "content");
+    applied = List.copyOf(applied);
+    documents = List.copyOf(documents);
+    diagnostics = List.copyOf(diagnostics);
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditedDocument.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditedDocument.java
new file mode 100644
index 0000000000..7ccd86d38b
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EditedDocument.java
@@ -0,0 +1,24 @@
+package org.openmbee.opensysml;
+
+import java.util.Objects;
+
+/**
+ * The edited notation of one document of the model.
+ *
+ * @param name the document's name as the parse named it: the file path of a loaded file, or the
+ *     name inline content was given
+ * @param content the edited notation, byte-identical to the source outside the edited spans
+ */
+public record EditedDocument(String name, String content) {
+
+  /**
+   * Creates an edited document.
+   *
+   * @param name the document's name, never {@code null}
+   * @param content the edited notation, never {@code null}
+   */
+  public EditedDocument {
+    Objects.requireNonNull(name, "name");
+    Objects.requireNonNull(content, "content");
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Encoding.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Encoding.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Encoding.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Encoding.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EngineInfo.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EngineInfo.java
new file mode 100644
index 0000000000..fc9ad33bc8
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EngineInfo.java
@@ -0,0 +1,77 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * One analysis engine the service answers with: what it answers, how strongly it can, and whether
+ * it can run here.
+ *
+ * @param name the engine's name, as {@link Model#withEngine(String)} selects it
+ * @param authority the strongest evidence the engine can ever produce, spelled as {@link
+ *     Standing#strength()} is
+ * @param answers the kinds of question the engine answers ({@code "evaluate"}, {@code "holds"},
+ *     {@code "outcomes"}, ...)
+ * @param bounds the bounds the engine takes, in the order it reports them
+ * @param process the external process the engine needs, empty for one running in-process
+ * @param processFound where the process was found, empty when it was not or none is needed
+ * @param ready whether the engine can run: it is served, and needs no process or its process was
+ *     found
+ * @param unavailable why the engine cannot run, empty when it can
+ * @param kind where the engine comes from: {@code "built-in"}, or the kind of the manifest entry
+ *     registering it ({@code "tool"}, {@code "engine"}, {@code "policy"}, {@code "sampler"})
+ * @param protocol how the engine is spoken to, empty for a built-in one
+ * @param source the manifest entry the engine was registered from, empty for a built-in one
+ * @param command the command the entry resolved to, empty for a built-in one
+ * @param version the version the entry declares, empty for a built-in one
+ * @param served whether this service runs the engine for a request that names it
+ */
+public record EngineInfo(
+    String name,
+    String authority,
+    List<String> answers,
+    List<String> bounds,
+    String process,
+    String processFound,
+    boolean ready,
+    String unavailable,
+    String kind,
+    String protocol,
+    String source,
+    String command,
+    String version,
+    boolean served) {
+
+  /**
+   * Creates an engine description, copying its lists.
+   *
+   * @param name the name, never {@code null}
+   * @param authority the authority, never {@code null}
+   * @param answers the questions answered
+   * @param bounds the bounds taken
+   * @param process the process, never {@code null}
+   * @param processFound where it was found, never {@code null}
+   * @param ready whether it can run
+   * @param unavailable why it cannot, never {@code null}
+   * @param kind its origin, never {@code null}
+   * @param protocol its protocol, never {@code null}
+   * @param source its manifest entry, never {@code null}
+   * @param command its command, never {@code null}
+   * @param version its version, never {@code null}
+   * @param served whether it is served
+   */
+  public EngineInfo {
+    Objects.requireNonNull(name, "name");
+    Objects.requireNonNull(authority, "authority");
+    answers = List.copyOf(answers);
+    bounds = List.copyOf(bounds);
+    Objects.requireNonNull(process, "process");
+    Objects.requireNonNull(processFound, "processFound");
+    Objects.requireNonNull(unavailable, "unavailable");
+    Objects.requireNonNull(kind, "kind");
+    Objects.requireNonNull(protocol, "protocol");
+    Objects.requireNonNull(source, "source");
+    Objects.requireNonNull(command, "command");
+    Objects.requireNonNull(version, "version");
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/EnumLiteral.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EnumLiteral.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/EnumLiteral.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/EnumLiteral.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ExecutionOptions.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ExecutionOptions.java
new file mode 100644
index 0000000000..072358fae9
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ExecutionOptions.java
@@ -0,0 +1,73 @@
+package org.openmbee.opensysml;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * How a behavior is run.
+ *
+ * @param schedule the policy the run resolves its choice points under, as {@code sysml -schedule}
+ *     spells it: {@code "declared"}, {@code "reverse"} (the service's default) or {@code
+ *     "seed:<n>"} for one run; {@code "explore"} or {@code "explore:runs=<n>,depth=<d>"} for an
+ *     exploration. Absent leaves the choice to the call.
+ * @param performer the object the behavior runs on, as {@code sysml -action "<action> <object>"}
+ *     names it: a part definition or usage to make an object of, or a path from one into its parts
+ *     ({@code "Mission::mission.vehicle"}), made for the run. Absent runs outside any object.
+ */
+public record ExecutionOptions(Optional<String> schedule, Optional<String> performer) {
+
+  private static final String EXPLORE = "explore";
+
+  /**
+   * Validates the options.
+   *
+   * @param schedule the schedule, when named
+   * @param performer the performer, when named
+   */
+  public ExecutionOptions {
+    Objects.requireNonNull(schedule, "schedule");
+    Objects.requireNonNull(performer, "performer");
+  }
+
+  /**
+   * The service's default schedule, outside any object.
+   *
+   * @return the default options
+   */
+  public static ExecutionOptions defaults() {
+    return new ExecutionOptions(Optional.empty(), Optional.empty());
+  }
+
+  /**
+   * The same options under another schedule.
+   *
+   * @param schedule the policy
+   * @return options naming it
+   */
+  public ExecutionOptions withSchedule(String schedule) {
+    return new ExecutionOptions(Optional.of(schedule), performer);
+  }
+
+  /**
+   * The same options, performed by an object.
+   *
+   * @param performer the object, as a declaration or a path into one
+   * @return options naming it
+   */
+  public ExecutionOptions withPerformer(String performer) {
+    return new ExecutionOptions(schedule, Optional.of(performer));
+  }
+
+  /**
+   * Whether the schedule explores every order rather than running one.
+   *
+   * @return {@code true} for {@code "explore"} and {@code "explore:<options>"}
+   */
+  public boolean explores() {
+    return schedule.filter(ExecutionOptions::explores).isPresent();
+  }
+
+  static boolean explores(String schedule) {
+    return schedule.equals(EXPLORE) || schedule.startsWith(EXPLORE + ":");
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Exploration.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Exploration.java
new file mode 100644
index 0000000000..c5b360484d
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Exploration.java
@@ -0,0 +1,66 @@
+package org.openmbee.opensysml;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Every distinct outcome a behavior reaches under some order of its choice points, and how the
+ * search for them ended: what {@link Model#exploreAction(String)} and its siblings answer.
+ *
+ * @param outcomes the outcomes reached, in the order first reached
+ * @param complete whether every order within the budget ran
+ * @param runs how many runs were made
+ * @param budgetsHit the budgets that ended the search ({@code "runs"}, {@code "depth"}), empty when
+ *     it completed
+ * @param runsBudget the most runs the search would make
+ * @param depthBudget the most choice points one run would resolve
+ * @param probabilitiesLowerBound whether the outcomes' probabilities are lower bounds: a
+ *     budget kept some orders unexplored
+ */
+public record Exploration(
+    List<Outcome> outcomes,
+    boolean complete,
+    int runs,
+    List<String> budgetsHit,
+    int runsBudget,
+    int depthBudget,
+    boolean probabilitiesLowerBound) {
+
+  /**
+   * Creates an exploration, copying its collections.
+   *
+   * @param outcomes the outcomes
+   * @param complete whether the search completed
+   * @param runs the runs made
+   * @param budgetsHit the budgets that ended it
+   * @param runsBudget the run budget
+   * @param depthBudget the depth budget
+   * @param probabilitiesLowerBound whether the probabilities are lower bounds
+   */
+  public Exploration {
+    outcomes = List.copyOf(outcomes);
+    budgetsHit = List.copyOf(budgetsHit);
+  }
+
+  /**
+   * How the search ended, as the {@code sysml} command renders it: {@code "complete (6 runs)"} or
+   * {@code "incomplete: runs budget 100 hit after 100 runs"}.
+   *
+   * @return the status line
+   */
+  public String status() {
+    if (complete) {
+      return "complete (" + runs + " runs)";
+    }
+    List<String> named = new ArrayList<>(budgetsHit.size());
+    for (String budget : budgetsHit) {
+      int limit = budget.equals("depth") ? depthBudget : runsBudget;
+      named.add(budget + " budget " + limit);
+    }
+    return "incomplete: "
+        + String.join(" and ", named)
+        + " hit after "
+        + runs
+        + " runs; probabilities are lower bounds";
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/FailureReason.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/FailureReason.java
new file mode 100644
index 0000000000..d62c296090
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/FailureReason.java
@@ -0,0 +1,18 @@
+package org.openmbee.opensysml;
+
+/**
+ * What kind of failure an undecided {@link Verdict} or a failed calculation, analysis or validation
+ * reports, so a caller acts on the kind rather than on the message text.
+ */
+public enum FailureReason {
+  /** No failure, or one the service did not classify. */
+  UNSPECIFIED,
+  /** A condition or calculation that could not be evaluated. */
+  EVALUATION,
+  /** A symbol that declares something else than was asked about. */
+  WRONG_KIND,
+  /** Several objects carry the element: name one as the subject. */
+  AMBIGUOUS_SUBJECT,
+  /** A reason this release of the client does not know. */
+  UNKNOWN
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instance.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instance.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instance.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instance.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instances.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instances.java
new file mode 100644
index 0000000000..bc2fff9271
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instances.java
@@ -0,0 +1,19 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Optional;
+
+/** Lookup shared by the results that carry the objects their verdicts and values refer to. */
+final class Instances {
+
+  private Instances() {}
+
+  static Optional<Instance> find(List<Instance> instances, long instanceId) {
+    for (Instance instance : instances) {
+      if (instance.id() == instanceId) {
+        return Optional.of(instance);
+      }
+    }
+    return Optional.empty();
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instantiation.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instantiation.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instantiation.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Instantiation.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Language.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Language.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Language.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Language.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ManifestSignatureException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ManifestSignatureException.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ManifestSignatureException.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ManifestSignatureException.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java
new file mode 100644
index 0000000000..defdf86388
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java
@@ -0,0 +1,1091 @@
+package org.openmbee.opensysml;
+
+import org.openmbee.opensysml.internal.Protos;
+import org.openmbee.opensysml.proto.ApplyEditsRequest;
+import org.openmbee.opensysml.proto.ApplyEditsResponse;
+import org.openmbee.opensysml.proto.ConvertRequest;
+import org.openmbee.opensysml.proto.ConvertResponse;
+import org.openmbee.opensysml.proto.DiagnosticsRequest;
+import org.openmbee.opensysml.proto.DiagnosticsResponse;
+import org.openmbee.opensysml.proto.EvaluateCalcRequest;
+import org.openmbee.opensysml.proto.EvaluateCalcResponse;
+import org.openmbee.opensysml.proto.EvaluateRequest;
+import org.openmbee.opensysml.proto.EvaluateResponse;
+import org.openmbee.opensysml.proto.ExecuteActionRequest;
+import org.openmbee.opensysml.proto.ExecuteActionResponse;
+import org.openmbee.opensysml.proto.ExecuteStateRequest;
+import org.openmbee.opensysml.proto.ExecuteStateResponse;
+import org.openmbee.opensysml.proto.GetSymbolRequest;
+import org.openmbee.opensysml.proto.InstantiateRequest;
+import org.openmbee.opensysml.proto.InstantiateResponse;
+import org.openmbee.opensysml.proto.QueryRequest;
+import org.openmbee.opensysml.proto.QueryResponse;
+import org.openmbee.opensysml.proto.RenderDocumentRequest;
+import org.openmbee.opensysml.proto.RenderDocumentResponse;
+import org.openmbee.opensysml.proto.RunAnalysisRequest;
+import org.openmbee.opensysml.proto.RunAnalysisResponse;
+import org.openmbee.opensysml.proto.RunDocumentQueryRequest;
+import org.openmbee.opensysml.proto.RunDocumentQueryResponse;
+import org.openmbee.opensysml.proto.RunSweepRequest;
+import org.openmbee.opensysml.proto.RunSweepResponse;
+import org.openmbee.opensysml.proto.SymbolResponse;
+import org.openmbee.opensysml.proto.ValidateInstanceRequest;
+import org.openmbee.opensysml.proto.ValidateInstanceResponse;
+import org.openmbee.opensysml.proto.VerifyConstraintRequest;
+import org.openmbee.opensysml.proto.VerifyConstraintResponse;
+import org.openmbee.opensysml.proto.VerifyRequirementRequest;
+import org.openmbee.opensysml.proto.VerifyRequirementResponse;
+import org.openmbee.opensysml.proto.VerifySatisfactionRequest;
+import org.openmbee.opensysml.proto.VerifySatisfactionResponse;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * A model the service has parsed, named by the hash every later call carries.
+ *
+ * <p>Obtained from {@link Connection#load(java.nio.file.Path)}, {@link Connection#parse(String)},
+ * {@link Connection#parseSources(List)} or {@link Connection#model(String)}. Immutable and thread-safe; it holds no state of its own beyond
+ * the hash, what the parse reported and the engine {@link #withEngine(String)} named.
+ *
+ * <p>Reading a model is {@link #eval}, {@link #symbol} and {@link #instantiate}. Running it is
+ * {@link #executeAction} and {@link #executeState} for one run under a schedule, {@link
+ * #exploreAction} and {@link #exploreState} for every run. Checking it is {@link
+ * #verifyConstraint}, {@link #verifyRequirement}, {@link #verifySatisfaction} and {@link
+ * #validateInstance}, which answer verdicts: a condition the model answers false about is a
+ * {@link Verdict} that does not hold, not an exception. {@link #evaluateCalc} and {@link
+ * #runAnalysis} compute, and {@link #query} selects elements.
+ *
+ * <p>A model also edits and projects itself: {@link #applyEdits(List, EditOptions)} applies
+ * element-level edits to its source text and answers the text they produce, {@link
+ * #convert(String, ConversionOptions)} rewrites it in another format, {@link #runSweep(String,
+ * List, SweepOptions)} runs a case, a verification or an analysis once per combination of its
+ * swept parameters, {@link #runDocumentQuery(String, Map)} projects its elements through a
+ * document query, and {@link #renderDocument(String)} renders a Markdown view over it. An
+ * apply-edits refusal is an {@link EditException}, a {@link ModelException} carrying the refusal's
+ * {@link EditFailure} and referrers.
+ *
+ * <p>Every call answers what the service reported or throws: {@link ModelException} when the
+ * service answered but reported a failure about the model, {@link ServiceException} when it refused
+ * the call, {@link CapabilityException} before anything is sent when the service does not advertise
+ * what the call needs.
+ */
+public final class Model {
+  private static final String EXPLORE = "explore";
+  private static final String NAME_SUBJECT_SYMBOL_ID = "subjectSymbolId";
+  private static final String NAME_SYMBOL_ID = "symbolId";
+  private static final String NAME_OPTIONS = "options";
+
+  private final Connection connection;
+  private final String hash;
+  private final List<Symbol> roots;
+  private final List<Diagnostic> parseDiagnostics;
+  private final Optional<String> engine;
+
+  Model(Connection connection, String hash, List<Symbol> roots, List<Diagnostic> parseDiagnostics) {
+    this(connection, hash, roots, parseDiagnostics, Optional.empty());
+  }
+
+  private Model(
+      Connection connection,
+      String hash,
+      List<Symbol> roots,
+      List<Diagnostic> parseDiagnostics,
+      Optional<String> engine) {
+    this.connection = connection;
+    this.hash = hash;
+    this.roots = List.copyOf(roots);
+    this.parseDiagnostics = List.copyOf(parseDiagnostics);
+    this.engine = engine;
+  }
+
+  /**
+   * The hash the service knows this model by.
+   *
+   * @return the model hash
+   */
+  public String hash() {
+    return hash;
+  }
+
+  /**
+   * The connection this model is read over.
+   *
+   * @return the connection
+   */
+  public Connection connection() {
+    return connection;
+  }
+
+  /**
+   * The model's outermost elements, in document order: one per document a {@link
+   * Connection#parseSources(List)} model carries, the one root a one-document model or a handle
+   * addressed by hash declares, none for an empty model.
+   *
+   * @return the roots, never {@code null}
+   */
+  public List<Symbol> roots() {
+    return roots;
+  }
+
+  /**
+   * The root namespace of the parse: the first of {@link #roots()}, which is the only one for a
+   * one-document model.
+   *
+   * @return the root symbol, absent for an empty model or a model addressed by hash alone
+   */
+  public Optional<Symbol> root() {
+    return roots.isEmpty() ? Optional.empty() : Optional.of(roots.get(0));
+  }
+
+  /**
+   * What the parse that produced this model reported. Empty for a model addressed by hash alone,
+   * where {@link #diagnostics()} asks the service instead.
+   *
+   * @return the diagnostics of the parse, in order
+   */
+  public List<Diagnostic> parseDiagnostics() {
+    return parseDiagnostics;
+  }
+
+  /**
+   * The engine this model's verifications, calculations and analyses are put to.
+   *
+   * @return the engine, absent when the service chooses
+   */
+  public Optional<String> engine() {
+    return engine;
+  }
+
+  /**
+   * The same model, its verifications, calculations and analyses put to one engine: an engine's
+   * name as {@link Connection#listEngines()} reports it, {@link Standing#ENGINE_ALL} for every
+   * engine that covers the question, or {@link Standing#ENGINE_AUTO} to leave the choice to the
+   * service. A name the service does not register fails the call with {@link
+   * StatusCode#INVALID_ARGUMENT}. The {@code "explore"} engine answers a question with every
+   * outcome rather than one run's, so it needs {@code schedule_explore} beside {@code engines}.
+   *
+   * @param engine the engine
+   * @return a model bound to it
+   * @throws CapabilityException if the service does not advertise {@code engines}, which it would
+   *     otherwise ignore rather than refuse, or the engine is {@code "explore"} and the service
+   *     does not advertise {@code schedule_explore}
+   */
+  public Model withEngine(String engine) {
+    Objects.requireNonNull(engine, "engine");
+    connection.capabilities().require(Capabilities.ENGINES);
+    if (engine.equals(EXPLORE)) {
+      connection.capabilities().require(Capabilities.SCHEDULE_EXPLORE);
+    }
+    return new Model(connection, hash, roots, parseDiagnostics, Optional.of(engine));
+  }
+
+  /**
+   * Asks the service for this model's diagnostics.
+   *
+   * @return the diagnostics, in order
+   * @throws ServiceException if the service does not hold this model
+   * @throws ModelException if the service reported a failure in its answer
+   */
+  public List<Diagnostic> diagnostics() {
+    DiagnosticsResponse response =
+        connection.call(
+            "GetDiagnostics",
+            DiagnosticsRequest.newBuilder().setModelHash(hash).build(),
+            DiagnosticsResponse.getDefaultInstance());
+    List<Diagnostic> diagnostics = Protos.diagnostics(response.getDiagnosticsList());
+    if (!response.getError().isEmpty()) {
+      throw new ModelException(response.getError(), diagnostics);
+    }
+    return diagnostics;
+  }
+
+  /**
+   * A symbol by qualified name.
+   *
+   * @param symbolId a qualified name, such as {@code "Demo::Vehicle"}
+   * @return the symbol
+   * @throws ModelException if the model declares no such symbol
+   * @throws ServiceException if the service does not hold this model
+   */
+  public Symbol symbol(String symbolId) {
+    SymbolResponse response = symbolResponse(symbolId);
+    if (!response.getError().isEmpty()) {
+      throw new ModelException(response.getError(), List.of());
+    }
+    return Protos.symbol(response.getSymbol());
+  }
+
+  /**
+   * A symbol by qualified name, absent when the model declares no such symbol.
+   *
+   * @param symbolId a qualified name
+   * @return the symbol, or empty
+   * @throws ServiceException if the service does not hold this model
+   */
+  public Optional<Symbol> findSymbol(String symbolId) {
+    SymbolResponse response = symbolResponse(symbolId);
+    return response.getError().isEmpty()
+        ? Optional.of(Protos.symbol(response.getSymbol()))
+        : Optional.empty();
+  }
+
+  /**
+   * Evaluates an expression against the model's declarations.
+   *
+   * @param expression a SysML expression, such as {@code "2 + 2"}
+   * @return what it evaluated to
+   * @throws ModelException if the expression could not be evaluated
+   * @throws ServiceException if the service does not hold this model
+   */
+  public Value eval(String expression) {
+    return evaluated(request(expression).build());
+  }
+
+  /**
+   * Evaluates an expression in the scope of a symbol, so its features are in scope and a feature
+   * reads the declared default.
+   *
+   * @param expression a SysML expression
+   * @param contextSymbolId qualified name of the scope to evaluate in
+   * @return what it evaluated to
+   */
+  public Value evalInContext(String expression, String contextSymbolId) {
+    Objects.requireNonNull(contextSymbolId, "contextSymbolId");
+    return evaluated(request(expression).setContextSymbolId(contextSymbolId).build());
+  }
+
+  /**
+   * Evaluates an expression against an object of a symbol, so a feature reads that object's value
+   * rather than the declared default.
+   *
+   * @param expression a SysML expression
+   * @param subjectSymbolId qualified name of the symbol to instantiate and evaluate against
+   * @return what it evaluated to
+   * @throws CapabilityException if the service does not advertise {@code evaluate_subject}, which it
+   *     would otherwise ignore rather than refuse
+   */
+  public Value evalWithSubject(String expression, String subjectSymbolId) {
+    Objects.requireNonNull(subjectSymbolId, NAME_SUBJECT_SYMBOL_ID);
+    connection.capabilities().require(Capabilities.EVALUATE_SUBJECT);
+    return evaluated(request(expression).setSubjectSymbolId(subjectSymbolId).build());
+  }
+
+  /**
+   * Builds an object of a part definition or usage, and everything reachable from it.
+   *
+   * @param symbolId qualified name of the definition or usage to instantiate
+   * @return the object built
+   * @throws ModelException if it could not be built
+   * @throws ServiceException if the service does not hold this model
+   */
+  public Instantiation instantiate(String symbolId) {
+    Objects.requireNonNull(symbolId, NAME_SYMBOL_ID);
+    InstantiateResponse response =
+        connection.call(
+            "Instantiate",
+            InstantiateRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId).build(),
+            InstantiateResponse.getDefaultInstance());
+    if (!response.getError().isEmpty()) {
+      throw new ModelException(
+          response.getError(), Protos.diagnostics(response.getDiagnosticsList()));
+    }
+    return Protos.instantiation(response);
+  }
+
+  /**
+   * Executes an action once, under the service's default schedule and outside any object.
+   *
+   * @param actionSymbolId qualified name of the action definition or usage
+   * @return the outputs it produced
+   * @throws ModelException if the action could not be executed
+   * @throws ServiceException if the service does not hold this model
+   */
+  public ActionRun executeAction(String actionSymbolId) {
+    return executeAction(actionSymbolId, Map.of(), ExecutionOptions.defaults());
+  }
+
+  /**
+   * Executes an action once, with its input parameters bound.
+   *
+   * @param actionSymbolId qualified name of the action definition or usage
+   * @param inputs values for its input parameters, by name
+   * @return the outputs it produced
+   * @throws ModelException if the action could not be executed
+   * @throws ServiceException if the service does not hold this model
+   */
+  public ActionRun executeAction(String actionSymbolId, Map<String, Value> inputs) {
+    return executeAction(actionSymbolId, inputs, ExecutionOptions.defaults());
+  }
+
+  /**
+   * Executes an action once, under a schedule and on an object.
+   *
+   * @param actionSymbolId qualified name of the action definition or usage
+   * @param inputs values for its input parameters, by name
+   * @param options the schedule the run resolves its choice points under and the object performing
+   *     it; an exploring schedule belongs to {@link #exploreAction}
+   * @return the outputs it produced
+   * @throws IllegalArgumentException if the schedule explores
+   * @throws ModelException if the action could not be executed
+   * @throws ServiceException if the service does not hold this model, or the schedule names no
+   *     policy
+   * @throws CapabilityException if a schedule is named and the service does not advertise {@code
+   *     schedule}, or a performer and it does not advertise {@code performer}
+   */
+  public ActionRun executeAction(
+      String actionSymbolId, Map<String, Value> inputs, ExecutionOptions options) {
+    ExecuteActionResponse response = executeAction(actionSymbolId, inputs, options, false);
+    return Protos.actionRun(response, connection.capabilities().has(Capabilities.FINAL_TIME));
+  }
+
+  /**
+   * Runs an action once per valid order of its choice points, within the {@code explore} budget.
+   *
+   * @param actionSymbolId qualified name of the action definition or usage
+   * @return every distinct outcome reached, and how the search ended
+   * @throws ModelException if the action could not be explored at all
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code schedule_explore}
+   */
+  public Exploration exploreAction(String actionSymbolId) {
+    return exploreAction(actionSymbolId, Map.of(), ExecutionOptions.defaults());
+  }
+
+  /**
+   * Runs an action once per valid order of its choice points, within a budget.
+   *
+   * <p>Runs agreeing on their outputs are one {@link Outcome}; a run failing under some order is an
+   * outcome whose error is set, not a failure of the call.
+   *
+   * @param actionSymbolId qualified name of the action definition or usage
+   * @param inputs values for its input parameters, by name
+   * @param options the exploring schedule ({@code "explore"}, the default when none is named, or
+   *     {@code "explore:runs=<n>,depth=<d>"}) and the object performing each run, made anew for it
+   * @return every distinct outcome reached, and how the search ended
+   * @throws IllegalArgumentException if the schedule does not explore
+   * @throws ModelException if the action could not be explored at all
+   * @throws ServiceException if the service does not hold this model, or the schedule's budget is
+   *     malformed
+   * @throws CapabilityException if the service does not advertise {@code schedule_explore}, or a
+   *     performer is named and it does not advertise {@code performer}
+   */
+  public Exploration exploreAction(
+      String actionSymbolId, Map<String, Value> inputs, ExecutionOptions options) {
+    ExecuteActionResponse response = executeAction(actionSymbolId, inputs, options, true);
+    return Protos.exploration(response.getOutcomesList(), response.getExploration());
+  }
+
+  private ExecuteActionResponse executeAction(
+      String actionSymbolId, Map<String, Value> inputs, ExecutionOptions options, boolean explore) {
+    Objects.requireNonNull(actionSymbolId, "actionSymbolId");
+    Objects.requireNonNull(inputs, "inputs");
+    ExecuteActionRequest.Builder request =
+        ExecuteActionRequest.newBuilder()
+            .setModelHash(hash)
+            .setActionSymbolId(actionSymbolId)
+            .putAllInputs(Protos.protos(inputs))
+            .setSchedule(schedule(options, explore));
+    options.performer().ifPresent(request::setPerformerSymbolId);
+    ExecuteActionResponse response =
+        connection.call("ExecuteAction", request.build(), ExecuteActionResponse.getDefaultInstance());
+    failed(response.getError(), FailureReason.UNSPECIFIED, response.getDiagnosticsList());
+    return response;
+  }
+
+  /**
+   * Executes a state machine once, under the service's default schedule and outside any object.
+   *
+   * @param stateMachineSymbolId qualified name of the state definition or usage
+   * @param events the events to send it, in order
+   * @return the states it visited and its final context
+   * @throws ModelException if the machine could not be executed
+   * @throws ServiceException if the service does not hold this model
+   */
+  public StateRun executeState(String stateMachineSymbolId, List<String> events) {
+    return executeState(stateMachineSymbolId, events, ExecutionOptions.defaults());
+  }
+
+  /**
+   * Executes a state machine once, under a schedule and on an object.
+   *
+   * @param stateMachineSymbolId qualified name of the state definition or usage
+   * @param events the events to send it, in order
+   * @param options the schedule and performer, as for {@link #executeAction(String, Map,
+   *     ExecutionOptions)}
+   * @return the states it visited and its final context
+   * @throws IllegalArgumentException if the schedule explores
+   * @throws ModelException if the machine could not be executed
+   * @throws ServiceException if the service does not hold this model, or the schedule names no
+   *     policy
+   * @throws CapabilityException if a schedule is named and the service does not advertise {@code
+   *     schedule}, or a performer and it does not advertise {@code performer}
+   */
+  public StateRun executeState(
+      String stateMachineSymbolId, List<String> events, ExecutionOptions options) {
+    ExecuteStateResponse response = executeState(stateMachineSymbolId, events, options, false);
+    return Protos.stateRun(response, connection.capabilities().has(Capabilities.FINAL_TIME));
+  }
+
+  /**
+   * Runs a state machine once per valid order of its choice points, within the {@code explore}
+   * budget.
+   *
+   * @param stateMachineSymbolId qualified name of the state definition or usage
+   * @param events the events to send it, in order
+   * @return every distinct outcome reached, and how the search ended
+   * @throws ModelException if the machine could not be explored at all
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code schedule_explore}
+   */
+  public Exploration exploreState(String stateMachineSymbolId, List<String> events) {
+    return exploreState(stateMachineSymbolId, events, ExecutionOptions.defaults());
+  }
+
+  /**
+   * Runs a state machine once per valid order of its choice points, within a budget.
+   *
+   * @param stateMachineSymbolId qualified name of the state definition or usage
+   * @param events the events to send it, in order
+   * @param options the exploring schedule and performer, as for {@link #exploreAction(String, Map,
+   *     ExecutionOptions)}
+   * @return every distinct outcome reached, and how the search ended
+   * @throws IllegalArgumentException if the schedule does not explore
+   * @throws ModelException if the machine could not be explored at all
+   * @throws ServiceException if the service does not hold this model, or the schedule's budget is
+   *     malformed
+   * @throws CapabilityException if the service does not advertise {@code schedule_explore}, or a
+   *     performer is named and it does not advertise {@code performer}
+   */
+  public Exploration exploreState(
+      String stateMachineSymbolId, List<String> events, ExecutionOptions options) {
+    ExecuteStateResponse response = executeState(stateMachineSymbolId, events, options, true);
+    return Protos.exploration(response.getOutcomesList(), response.getExploration());
+  }
+
+  private ExecuteStateResponse executeState(
+      String stateMachineSymbolId, List<String> events, ExecutionOptions options, boolean explore) {
+    Objects.requireNonNull(stateMachineSymbolId, "stateMachineSymbolId");
+    Objects.requireNonNull(events, "events");
+    ExecuteStateRequest.Builder request =
+        ExecuteStateRequest.newBuilder()
+            .setModelHash(hash)
+            .setStateMachineSymbolId(stateMachineSymbolId)
+            .addAllEvents(events)
+            .setSchedule(schedule(options, explore));
+    options.performer().ifPresent(request::setPerformerSymbolId);
+    ExecuteStateResponse response =
+        connection.call("ExecuteState", request.build(), ExecuteStateResponse.getDefaultInstance());
+    failed(response.getError(), FailureReason.UNSPECIFIED, response.getDiagnosticsList());
+    return response;
+  }
+
+  /**
+   * Verifies a constraint against the model's declared values.
+   *
+   * @param symbolId qualified name of the constraint definition or usage
+   * @return its verdict, which does not hold when the model answers false and is undecided when
+   *     the constraint could not be evaluated
+   * @throws ModelException if the service could not answer at all
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code verification}
+   */
+  public Verification verifyConstraint(String symbolId) {
+    return verifyConstraint(symbolId, Optional.empty());
+  }
+
+  /**
+   * Verifies a constraint against an object's values.
+   *
+   * @param symbolId qualified name of the constraint definition or usage
+   * @param subjectSymbolId qualified name of the part definition or usage to instantiate and verify
+   *     the constraint on
+   * @return its verdict, about that object
+   * @throws ModelException if the service could not answer at all
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code verification}
+   */
+  public Verification verifyConstraint(String symbolId, String subjectSymbolId) {
+    Objects.requireNonNull(subjectSymbolId, NAME_SUBJECT_SYMBOL_ID);
+    return verifyConstraint(symbolId, Optional.of(subjectSymbolId));
+  }
+
+  private Verification verifyConstraint(String symbolId, Optional<String> subjectSymbolId) {
+    Objects.requireNonNull(symbolId, NAME_SYMBOL_ID);
+    connection.capabilities().require(Capabilities.VERIFICATION);
+    VerifyConstraintRequest.Builder request =
+        VerifyConstraintRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId);
+    subjectSymbolId.ifPresent(request::setSubjectSymbolId);
+    engine.ifPresent(request::setEngine);
+    VerifyConstraintResponse response =
+        connection.call(
+            "VerifyConstraint", request.build(), VerifyConstraintResponse.getDefaultInstance());
+    failed(response.getError(), FailureReason.UNSPECIFIED, response.getDiagnosticsList());
+    return Protos.verification(response);
+  }
+
+  /**
+   * Verifies a requirement's constraints against the model's declared values.
+   *
+   * @param symbolId qualified name of the requirement definition or usage
+   * @return its verdict, with what the verification cases verifying it answered
+   * @throws ModelException if the service could not answer at all
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code verification}
+   */
+  public Verification verifyRequirement(String symbolId) {
+    return verifyRequirement(symbolId, Optional.empty());
+  }
+
+  /**
+   * Verifies a requirement's constraints against an object's values.
+   *
+   * @param symbolId qualified name of the requirement definition or usage
+   * @param subjectSymbolId qualified name of the part definition or usage to instantiate and verify
+   *     the requirement on
+   * @return its verdict, about that object
+   * @throws ModelException if the service could not answer at all
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code verification}
+   */
+  public Verification verifyRequirement(String symbolId, String subjectSymbolId) {
+    Objects.requireNonNull(subjectSymbolId, NAME_SUBJECT_SYMBOL_ID);
+    return verifyRequirement(symbolId, Optional.of(subjectSymbolId));
+  }
+
+  private Verification verifyRequirement(String symbolId, Optional<String> subjectSymbolId) {
+    Objects.requireNonNull(symbolId, NAME_SYMBOL_ID);
+    connection.capabilities().require(Capabilities.VERIFICATION);
+    VerifyRequirementRequest.Builder request =
+        VerifyRequirementRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId);
+    subjectSymbolId.ifPresent(request::setSubjectSymbolId);
+    engine.ifPresent(request::setEngine);
+    VerifyRequirementResponse response =
+        connection.call(
+            "VerifyRequirement", request.build(), VerifyRequirementResponse.getDefaultInstance());
+    failed(response.getError(), FailureReason.UNSPECIFIED, response.getDiagnosticsList());
+    return Protos.verification(response);
+  }
+
+  /**
+   * Evaluates every {@code satisfy} assertion in the model.
+   *
+   * @return one verdict per assertion, in declaration order
+   * @throws ModelException if the service could not answer at all
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code verification}
+   */
+  public Satisfaction verifySatisfaction() {
+    return verifySatisfaction(Optional.empty());
+  }
+
+  /**
+   * Evaluates the {@code satisfy} assertions within one element.
+   *
+   * @param scopeSymbolId qualified name of the package, definition or usage whose assertions are
+   *     evaluated
+   * @return one verdict per assertion, in declaration order
+   * @throws ModelException if the service could not answer at all
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code verification}
+   */
+  public Satisfaction verifySatisfaction(String scopeSymbolId) {
+    Objects.requireNonNull(scopeSymbolId, "scopeSymbolId");
+    return verifySatisfaction(Optional.of(scopeSymbolId));
+  }
+
+  private Satisfaction verifySatisfaction(Optional<String> scopeSymbolId) {
+    connection.capabilities().require(Capabilities.VERIFICATION);
+    VerifySatisfactionRequest.Builder request =
+        VerifySatisfactionRequest.newBuilder().setModelHash(hash);
+    scopeSymbolId.ifPresent(request::setSymbolId);
+    engine.ifPresent(request::setEngine);
+    VerifySatisfactionResponse response =
+        connection.call(
+            "VerifySatisfaction", request.build(), VerifySatisfactionResponse.getDefaultInstance());
+    failed(response.getError(), response.getFailureReason(), response.getDiagnosticsList());
+    return Protos.satisfaction(response);
+  }
+
+  /**
+   * Checks every assertion about an object of a part definition or usage, and about the objects it
+   * holds, against their values.
+   *
+   * @param symbolId qualified name of the part definition or usage to instantiate and validate
+   * @return one verdict per assertion, and one for the object as a whole
+   * @throws ModelException if the service could not answer at all: an unknown symbol, or one that
+   *     declares no object
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code verification}
+   */
+  public Validation validateInstance(String symbolId) {
+    Objects.requireNonNull(symbolId, NAME_SYMBOL_ID);
+    connection.capabilities().require(Capabilities.VERIFICATION);
+    ValidateInstanceRequest.Builder request =
+        ValidateInstanceRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId);
+    engine.ifPresent(request::setEngine);
+    ValidateInstanceResponse response =
+        connection.call(
+            "ValidateInstance", request.build(), ValidateInstanceResponse.getDefaultInstance());
+    failed(response.getError(), response.getFailureReason(), response.getDiagnosticsList());
+    return Protos.validation(response);
+  }
+
+  /**
+   * Evaluates a calc.
+   *
+   * @param symbolId qualified name of the calc definition or usage
+   * @param arguments values for its {@code in} parameters, in declaration order; empty evaluates a
+   *     usage from its own members
+   * @return what it computed
+   * @throws ModelException if the calc could not be evaluated, its {@link
+   *     ModelException#failureReason()} saying why
+   * @throws ServiceException if the service does not hold this model
+   */
+  public Calculation evaluateCalc(String symbolId, List<Value> arguments) {
+    Objects.requireNonNull(symbolId, NAME_SYMBOL_ID);
+    Objects.requireNonNull(arguments, "arguments");
+    EvaluateCalcRequest.Builder request =
+        EvaluateCalcRequest.newBuilder()
+            .setModelHash(hash)
+            .setSymbolId(symbolId)
+            .addAllArguments(Protos.protos(arguments));
+    engine.ifPresent(request::setEngine);
+    EvaluateCalcResponse response =
+        connection.call("EvaluateCalc", request.build(), EvaluateCalcResponse.getDefaultInstance());
+    failed(response.getError(), response.getFailureReason(), response.getDiagnosticsList());
+    return Protos.calculation(response);
+  }
+
+  /**
+   * Runs an analysis case usage that binds its own subject.
+   *
+   * @param symbolId qualified name of the analysis case usage
+   * @return what it computed and the verdicts of its objective and assertions
+   * @throws AnalysisException if the case could not run to its end but left something to inspect
+   * @throws ModelException if the request was refused before the run, or the failure left nothing to
+   *     report; its {@link ModelException#failureReason()} says why
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code verification}
+   */
+  public Analysis runAnalysis(String symbolId) {
+    return runAnalysis(symbolId, AnalysisOptions.defaults());
+  }
+
+  /**
+   * Runs an analysis case once, on a subject and with its parameters bound.
+   *
+   * <p>The subject named is instantiated and bound as the case's subject; positional arguments
+   * bind its {@code in} parameters in declaration order, the subject excluded, and named arguments
+   * bind them by name. Its objective and each {@code assert constraint} in its body are then checked
+   * against what it computed.
+   *
+   * @param symbolId qualified name of the analysis case definition or usage
+   * @param options the subject, arguments and schedule; an exploring schedule belongs to {@link
+   *     #exploreAnalysis}
+   * @return what it computed and the verdicts of its objective and assertions
+   * @throws IllegalArgumentException if the schedule or the engine explores
+   * @throws AnalysisException if the case could not run to its end but left something to inspect
+   * @throws ModelException if the request was refused before the run, or the failure left nothing to
+   *     report; its {@link ModelException#failureReason()} says why
+   * @throws ServiceException if the service does not hold this model, or the schedule names no
+   *     policy
+   * @throws CapabilityException if the service does not advertise {@code verification}, or a
+   *     schedule is named and it does not advertise {@code schedule}
+   */
+  public Analysis runAnalysis(String symbolId, AnalysisOptions options) {
+    RunAnalysisResponse response = runAnalysis(symbolId, options, false);
+    Analysis analysis = Protos.analysis(response);
+    if (response.getError().isEmpty()) {
+      return analysis;
+    }
+    FailureReason reason = Protos.failureReason(response.getFailureReason());
+    if (analysis.outputs().isEmpty()
+        && analysis.verdicts().isEmpty()
+        && analysis.evaluations().isEmpty()
+        && analysis.instances().isEmpty()) {
+      throw new ModelException(response.getError(), reason, analysis.diagnostics());
+    }
+    throw new AnalysisException(response.getError(), reason, analysis.diagnostics(), analysis);
+  }
+
+  /**
+   * Runs an analysis case once per valid order of the choice points its actions meet.
+   *
+   * <p>Runs agreeing on the case's outputs and verdicts are one {@link Outcome}; a verdict is
+   * reported among the outcome's outputs as {@code "objective <name>"} or {@code "assertion
+   * <name>"}.
+   *
+   * @param symbolId qualified name of the analysis case definition or usage
+   * @param options the subject, arguments and exploring schedule ({@code "explore"}, the default
+   *     when none is named, or {@code "explore:runs=<n>,depth=<d>"})
+   * @return every distinct outcome reached, and how the search ended
+   * @throws IllegalArgumentException if the schedule does not explore
+   * @throws ModelException if the case could not be explored at all
+   * @throws ServiceException if the service does not hold this model, or the schedule's budget is
+   *     malformed
+   * @throws CapabilityException if the service does not advertise {@code verification} or {@code
+   *     schedule_explore}
+   */
+  public Exploration exploreAnalysis(String symbolId, AnalysisOptions options) {
+    RunAnalysisResponse response = runAnalysis(symbolId, options, true);
+    failed(response.getError(), response.getFailureReason(), response.getDiagnosticsList());
+    return Protos.exploration(response.getOutcomesList(), response.getExploration());
+  }
+
+  private RunAnalysisResponse runAnalysis(
+      String symbolId, AnalysisOptions options, boolean explore) {
+    Objects.requireNonNull(symbolId, NAME_SYMBOL_ID);
+    Objects.requireNonNull(options, NAME_OPTIONS);
+    connection.capabilities().require(Capabilities.VERIFICATION);
+    if (!explore && engine.isPresent() && engine.orElseThrow().equals(EXPLORE)) {
+      throw new IllegalArgumentException(
+          "engine explore answers every outcome; use exploreAnalysis");
+    }
+    RunAnalysisRequest.Builder request =
+        RunAnalysisRequest.newBuilder()
+            .setModelHash(hash)
+            .setSymbolId(symbolId)
+            .addAllArguments(Protos.protos(options.arguments()))
+            .putAllNamedArguments(Protos.protos(options.namedArguments()))
+            .setSchedule(schedule(options.schedule(), options.explores(), explore));
+    options.subject().ifPresent(request::setSubjectSymbolId);
+    engine.ifPresent(request::setEngine);
+    return connection.call("RunAnalysis", request.build(), RunAnalysisResponse.getDefaultInstance());
+  }
+
+  /**
+   * Selects elements of the model.
+   *
+   * @param query the elements considered, the properties reported and the filter applied
+   * @return the elements selected, each with the properties asked for
+   * @throws ServiceException if the service does not hold this model, or the query names an unknown
+   *     scope or property, or leaves a comparison's operator unset
+   * @throws CapabilityException if the service does not advertise {@code query}
+   */
+  public List<QueryElement> query(Query query) {
+    Objects.requireNonNull(query, "query");
+    connection.capabilities().require(Capabilities.QUERY);
+    return selected(QueryRequest.newBuilder().setModelHash(hash).setQuery(Protos.proto(query)));
+  }
+
+  /**
+   * Selects elements of the model with an OSLC query string, such as {@code
+   * "oslc.where=rdf:type=\"PartUsage\"&oslc.select=sysml:name"}.
+   *
+   * @param oslcQuery the query, as OSLC Query Syntax spells it
+   * @return the elements selected, each with the properties asked for
+   * @throws ServiceException if the service does not hold this model, or the query does not parse
+   * @throws CapabilityException if the service does not advertise {@code oslc_query}
+   */
+  public List<QueryElement> queryOslc(String oslcQuery) {
+    Objects.requireNonNull(oslcQuery, "oslcQuery");
+    connection.capabilities().require(Capabilities.OSLC_QUERY);
+    return selected(QueryRequest.newBuilder().setModelHash(hash).setOslcQuery(oslcQuery));
+  }
+
+  private List<QueryElement> selected(QueryRequest.Builder request) {
+    QueryResponse response =
+        connection.call("Query", request.build(), QueryResponse.getDefaultInstance());
+    return Protos.queryElements(response.getElementsList());
+  }
+
+  /**
+   * Rewrites the model in another format.
+   *
+   * @param toFormat the format to write, named as the service names formats ({@code "sysml"},
+   *     {@code "kerml"}, {@code "ttl"}, {@code "api-json"}, …)
+   * @return the conversion, carrying the text and the formats used
+   * @throws ModelException if the conversion failed; its diagnostics say why
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code convert}
+   */
+  public Conversion convert(String toFormat) {
+    return convert(toFormat, ConversionOptions.defaults());
+  }
+
+  /**
+   * Rewrites the model in another format, with options.
+   *
+   * @param toFormat the format to write
+   * @param options the source format and whether unreadable notation is written back anyway
+   * @return the conversion, carrying the text and the formats used
+   * @throws ModelException if the conversion failed; its diagnostics say why
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code convert}
+   */
+  public Conversion convert(String toFormat, ConversionOptions options) {
+    Objects.requireNonNull(toFormat, "toFormat");
+    Objects.requireNonNull(options, NAME_OPTIONS);
+    connection.capabilities().require(Capabilities.CONVERT);
+    ConvertRequest.Builder request =
+        ConvertRequest.newBuilder()
+            .setModelHash(hash)
+            .setToFormat(toFormat)
+            .setTolerateSyntaxErrors(options.tolerateSyntaxErrors());
+    options.fromFormat().ifPresent(request::setFromFormat);
+    ConvertResponse response =
+        connection.call("Convert", request.build(), ConvertResponse.getDefaultInstance());
+    failed(response.getError(), FailureReason.UNSPECIFIED, response.getDiagnosticsList());
+    return Protos.conversion(response);
+  }
+
+  /**
+   * Applies edits to the model's source text, answering the text they produce.
+   *
+   * <p>An empty batch is refused in band, as {@link EditFailure#NO_OPERATIONS}.
+   *
+   * @param edits the edits, applied in order as one batch
+   * @return the rewritten text, which edits applied where, and the new documents' content when the
+   *     batch accepted them
+   * @throws EditException if the service refused the batch; its {@link EditException#failure()}
+   *     says why and its {@link EditException#referrers()} name what references the target
+   * @throws ServiceException if the request itself was rejected
+   * @throws CapabilityException if the service does not advertise {@code apply_edits}, or an edit
+   *     is an {@link Edit.AddMember}, {@link Edit.Delete} or {@link Edit.Move} and it does not
+   *     advertise {@code authoring}
+   */
+  public EditResult applyEdits(List<Edit> edits) {
+    return applyEdits(edits, EditOptions.defaults());
+  }
+
+  /**
+   * Applies edits to the model's source text, with options.
+   *
+   * @param edits the edits, applied in order as one batch
+   * @param options the document the edits target and whether the answer's documents are read;
+   *     {@link EditOptions#defaults()} accepts them
+   * @return the rewritten text, which edits applied where, and the new documents' content when the
+   *     batch accepted them
+   * @throws EditException if the service refused the batch
+   * @throws ServiceException if the request itself was rejected, or a document is named that no
+   *     document of the model has
+   * @throws CapabilityException if the service does not advertise {@code apply_edits}, an edit
+   *     writes a declaration and it does not advertise {@code authoring}, or a document is named
+   *     and it does not advertise {@code edit_documents}
+   */
+  public EditResult applyEdits(List<Edit> edits, EditOptions options) {
+    Objects.requireNonNull(edits, "edits");
+    Objects.requireNonNull(options, NAME_OPTIONS);
+    connection.capabilities().require(Capabilities.APPLY_EDITS);
+    for (Edit edit : edits) {
+      if (edit instanceof Edit.AddMember || edit instanceof Edit.Delete || edit instanceof Edit.Move) {
+        connection.capabilities().require(Capabilities.AUTHORING);
+        break;
+      }
+    }
+    options.document().ifPresent(document -> connection.capabilities().require(Capabilities.EDIT_DOCUMENTS));
+    ApplyEditsRequest.Builder request =
+        ApplyEditsRequest.newBuilder()
+            .setModelHash(hash)
+            .addAllOperations(Protos.edits(edits))
+            .setAcceptDocuments(options.acceptDocuments());
+    options.document().ifPresent(request::setDocument);
+    ApplyEditsResponse response =
+        connection.call("ApplyEdits", request.build(), ApplyEditsResponse.getDefaultInstance());
+    if (!response.getError().isEmpty()) {
+      throw new EditException(
+          response.getError(),
+          Protos.editFailure(response.getFailure()),
+          Protos.editFailureName(response.getFailure(), response.getFailureValue()),
+          Protos.diagnostics(response.getDiagnosticsList()),
+          response.getReferringElementsList(),
+          Protos.referrers(response.getReferrersList()));
+    }
+    return Protos.editResult(response);
+  }
+
+  /**
+   * Runs a case or a calc once per combination of its swept parameters.
+   *
+   * @param symbolId qualified name of the analysis case or calc, definition or usage
+   * @param ranges the swept parameters, several of which make one row per point of their cartesian
+   *     product, the first varying slowest; empty sweeps the target's declared values
+   * @return the table of rows, in the deterministic order they ran
+   * @throws ModelException if the sweep itself failed — the target unreadable or not one that
+   *     sweeps; one combination's failure is that {@link SweepRow}'s error, not the sweep's
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code verification}
+   */
+  public Sweep runSweep(String symbolId, List<SweepRange> ranges) {
+    return runSweep(symbolId, ranges, SweepOptions.defaults());
+  }
+
+  /**
+   * Runs a case or a calc once per combination of its swept parameters, on a subject and with
+   * parameters bound.
+   *
+   * @param symbolId qualified name of the analysis case or calc, definition or usage
+   * @param ranges the swept parameters
+   * @param options the subject, the arguments every row binds, and the sampling ({@code samples}
+   *     draws rows uniformly and needs {@code seed}; zero steps through each range)
+   * @return the table of rows, in the deterministic order they ran
+   * @throws ModelException if the sweep itself failed; one combination's failure is that {@link
+   *     SweepRow}'s error, not the sweep's
+   * @throws ServiceException if the service does not hold this model
+   * @throws CapabilityException if the service does not advertise {@code verification}
+   */
+  public Sweep runSweep(String symbolId, List<SweepRange> ranges, SweepOptions options) {
+    Objects.requireNonNull(symbolId, NAME_SYMBOL_ID);
+    Objects.requireNonNull(ranges, "ranges");
+    Objects.requireNonNull(options, NAME_OPTIONS);
+    connection.capabilities().require(Capabilities.VERIFICATION);
+    RunSweepRequest.Builder request =
+        RunSweepRequest.newBuilder()
+            .setModelHash(hash)
+            .setSymbolId(symbolId)
+            .addAllArguments(Protos.protos(options.arguments()))
+            .putAllNamedArguments(Protos.protos(options.namedArguments()))
+            .setSamples(options.samples())
+            .setSeed(options.seed());
+    options.subject().ifPresent(request::setSubjectSymbolId);
+    engine.ifPresent(request::setEngine);
+    for (SweepRange range : ranges) {
+      request.addRanges(Protos.proto(range));
+    }
+    RunSweepResponse response =
+        connection.call("RunSweep", request.build(), RunSweepResponse.getDefaultInstance());
+    failed(response.getError(), response.getFailureReason(), response.getDiagnosticsList());
+    return Protos.sweep(response);
+  }
+
+  /**
+   * Runs a document query declared in the model, answering its columns and typed rows.
+   *
+   * @param queryId qualified name of the document query
+   * @return the projected columns and rows
+   * @throws ServiceException if the model declares no such query or it declares something else
+   * @throws CapabilityException if the service does not advertise {@code document_query}
+   */
+  public DocumentQueryResult runDocumentQuery(String queryId) {
+    return runDocumentQuery(queryId, Map.of());
+  }
+
+  /**
+   * Runs a document query with its entry parameters bound.
+   *
+   * <p>Each binding names a {@link DocumentValue}: an {@link DocumentValue.ElementRef} binds an
+   * element by qualified name, an {@link DocumentValue.ObjectRef} an object the service holds by
+   * path or id, and a literal its value. A parameter bound by several values is a nonscalar
+   * binding.
+   *
+   * @param queryId qualified name of the document query
+   * @param bindings the entry parameters' values, by parameter name
+   * @return the projected columns and rows
+   * @throws ServiceException if the model declares no such query, it declares something else, or a
+   *     binding is refused
+   * @throws CapabilityException if the service does not advertise {@code document_query}
+   */
+  public DocumentQueryResult runDocumentQuery(
+      String queryId, Map<String, List<DocumentValue>> bindings) {
+    Objects.requireNonNull(queryId, "queryId");
+    Objects.requireNonNull(bindings, "bindings");
+    connection.capabilities().require(Capabilities.DOCUMENT_QUERY);
+    RunDocumentQueryRequest.Builder request =
+        RunDocumentQueryRequest.newBuilder().setModelHash(hash).setQueryId(queryId);
+    bindings.forEach(
+        (parameter, values) ->
+            request.addBindings(
+                org.openmbee.opensysml.proto.DocumentQueryBinding.newBuilder()
+                    .setParameter(parameter)
+                    .addAllValues(
+                        values.stream().map(Protos::proto).toList())));
+    RunDocumentQueryResponse response =
+        connection.call(
+            "RunDocumentQuery", request.build(), RunDocumentQueryResponse.getDefaultInstance());
+    return Protos.documentQueryResult(response);
+  }
+
+  /**
+   * Renders a document declared in the model to Markdown.
+   *
+   * @param documentId qualified name of the document
+   * @return the rendered document, byte-for-byte what the command line writes
+   * @throws ServiceException if the model declares no such document or it declares something else
+   * @throws CapabilityException if the service does not advertise {@code render_document}
+   */
+  public RenderedDocument renderDocument(String documentId) {
+    Objects.requireNonNull(documentId, "documentId");
+    connection.capabilities().require(Capabilities.RENDER_DOCUMENT);
+    RenderDocumentResponse response =
+        connection.call(
+            "RenderDocument",
+            RenderDocumentRequest.newBuilder()
+                .setModelHash(hash)
+                .setDocumentId(documentId)
+                .build(),
+            RenderDocumentResponse.getDefaultInstance());
+    return new RenderedDocument(response.getMarkdown());
+  }
+
+  private String schedule(ExecutionOptions options, boolean explore) {
+    Objects.requireNonNull(options, NAME_OPTIONS);
+    if (options.performer().isPresent()) {
+      connection.capabilities().require(Capabilities.PERFORMER);
+    }
+    return schedule(options.schedule(), options.explores(), explore);
+  }
+
+  private String schedule(Optional<String> schedule, boolean explores, boolean explore) {
+    if (explore) {
+      if (schedule.isPresent() && !explores) {
+        throw new IllegalArgumentException(
+            "schedule " + schedule.orElseThrow() + " runs once; an exploration takes explore");
+      }
+      connection.capabilities().require(Capabilities.SCHEDULE_EXPLORE);
+      return schedule.orElse(EXPLORE);
+    }
+    if (explores) {
+      throw new IllegalArgumentException(
+          "schedule " + schedule.orElseThrow() + " answers every outcome; use the explore call");
+    }
+    if (schedule.isPresent()) {
+      connection.capabilities().require(Capabilities.SCHEDULE);
+    }
+    return schedule.orElse("");
+  }
+
+  private static void failed(
+      String error,
+      org.openmbee.opensysml.proto.FailureReason reason,
+      List<org.openmbee.opensysml.proto.Diagnostic> diagnostics) {
+    failed(error, Protos.failureReason(reason), diagnostics);
+  }
+
+  private static void failed(
+      String error, FailureReason reason, List<org.openmbee.opensysml.proto.Diagnostic> diagnostics) {
+    if (!error.isEmpty()) {
+      throw new ModelException(error, reason, Protos.diagnostics(diagnostics));
+    }
+  }
+
+  private SymbolResponse symbolResponse(String symbolId) {
+    Objects.requireNonNull(symbolId, NAME_SYMBOL_ID);
+    return connection.call(
+        "GetSymbol",
+        GetSymbolRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId).build(),
+        SymbolResponse.getDefaultInstance());
+  }
+
+  private EvaluateRequest.Builder request(String expression) {
+    Objects.requireNonNull(expression, "expression");
+    return EvaluateRequest.newBuilder().setModelHash(hash).setExpression(expression);
+  }
+
+  private Value evaluated(EvaluateRequest request) {
+    EvaluateResponse response =
+        connection.call("Evaluate", request, EvaluateResponse.getDefaultInstance());
+    List<Diagnostic> diagnostics = Protos.diagnostics(response.getDiagnosticsList());
+    if (!response.getError().isEmpty()) {
+      throw new ModelException(response.getError(), diagnostics);
+    }
+    return Protos.value(response.getResult())
+        .orElseThrow(
+            () ->
+                new ModelException(
+                    "the service answered neither a value nor a failure for "
+                        + request.getExpression(),
+                    diagnostics));
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ModelException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ModelException.java
similarity index 82%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ModelException.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ModelException.java
index 77f714038b..d93449a893 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ModelException.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ModelException.java
@@ -23,6 +23,7 @@ public class ModelException extends OpenSysMLException {
   private static final long serialVersionUID = 2L;
   private static final int MAX_SERIALIZED_DIAGNOSTICS = 100_000;
 
+  private final FailureReason failureReason;
   private transient List<Diagnostic> diagnostics;
 
   /**
@@ -32,10 +33,31 @@ public class ModelException extends OpenSysMLException {
    * @param diagnostics diagnostics the answer carried
    */
   public ModelException(String message, List<Diagnostic> diagnostics) {
+    this(message, FailureReason.UNSPECIFIED, diagnostics);
+  }
+
+  /**
+   * Creates a model exception the service classified.
+   *
+   * @param message the failure, as the service worded it
+   * @param failureReason what kind of failure it is
+   * @param diagnostics diagnostics the answer carried
+   */
+  public ModelException(String message, FailureReason failureReason, List<Diagnostic> diagnostics) {
     super(message);
+    this.failureReason = Objects.requireNonNull(failureReason, "failureReason");
     this.diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
   }
 
+  /**
+   * What kind of failure this is, so a caller acts on the kind rather than on the message text.
+   *
+   * @return the reason; {@link FailureReason#UNSPECIFIED} when the service did not classify it
+   */
+  public FailureReason failureReason() {
+    return failureReason;
+  }
+
   /**
    * The diagnostics the answer carried.
    *
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/OpenSysMLException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/OpenSysMLException.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/OpenSysMLException.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/OpenSysMLException.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Outcome.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Outcome.java
new file mode 100644
index 0000000000..5a8c8218d9
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Outcome.java
@@ -0,0 +1,64 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * One distinct result an exploration reached: the runs agreeing on it are counted as its
+ * linearizations, and one of them is its witness.
+ *
+ * @param outputs an action's output parameters, or a state machine's final context, by name; for
+ *     an analysis case, its outputs and, as {@code "objective <name>"} and {@code "assertion
+ *     <name>"} entries, what its verdicts answered
+ * @param finalState the state a machine ended in, absent for an action
+ * @param statesVisited the trace of states a machine entered, empty for an action
+ * @param error why the runs reaching this outcome failed, absent when they completed
+ * @param linearizations how many of the orders explored reached this outcome
+ * @param probability the share of the explored orders' likelihood reaching it, from the
+ *     model's {@code @Probability} weights (uniform for unweighted picks); a lower bound while
+ *     the exploration is incomplete
+ * @param witness the choices of one run that reached it, each as {@code "<choice point>: <taken>"}
+ * @param diagnostics what the service reported for the witness run
+ */
+public record Outcome(
+    Map<String, Value> outputs,
+    Optional<String> finalState,
+    List<String> statesVisited,
+    Optional<String> error,
+    int linearizations,
+    double probability,
+    List<String> witness,
+    List<Diagnostic> diagnostics) {
+
+  /**
+   * Creates an outcome, copying its collections.
+   *
+   * @param outputs the outputs by name
+   * @param finalState the final state, when there is one
+   * @param statesVisited the trace
+   * @param error the failure, when the runs failed
+   * @param linearizations the number of orders reaching it
+   * @param probability the share of the explored orders' likelihood reaching it
+   * @param witness one run's choices
+   * @param diagnostics the diagnostics
+   */
+  public Outcome {
+    outputs = Map.copyOf(outputs);
+    Objects.requireNonNull(finalState, "finalState");
+    statesVisited = List.copyOf(statesVisited);
+    Objects.requireNonNull(error, "error");
+    witness = List.copyOf(witness);
+    diagnostics = List.copyOf(diagnostics);
+  }
+
+  /**
+   * Whether the runs reaching this outcome completed rather than failed.
+   *
+   * @return {@code true} when no error was reported
+   */
+  public boolean completed() {
+    return error.isEmpty();
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ParseOptions.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ParseOptions.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ParseOptions.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ParseOptions.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Quantity.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Quantity.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Quantity.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Quantity.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Query.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Query.java
new file mode 100644
index 0000000000..0f5b597b75
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Query.java
@@ -0,0 +1,70 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * A selection of a model's elements, as the SysML v2 API and Services Query resource makes one: the
+ * elements considered, the properties reported for each and the filter every one must satisfy.
+ *
+ * @param scope FQNs of the elements considered, each together with everything nested inside it;
+ *     empty considers the whole model
+ * @param select the properties to report ({@code "name"}, {@code "owner"}, {@code
+ *     "qualifiedName"}, ...); empty reports all of them
+ * @param where the filter every considered element must satisfy, absent for none
+ */
+public record Query(List<String> scope, List<String> select, Optional<Condition> where) {
+
+  /**
+   * Creates a query, copying its lists.
+   *
+   * @param scope the scope
+   * @param select the selection
+   * @param where the filter, when there is one
+   */
+  public Query {
+    scope = List.copyOf(scope);
+    select = List.copyOf(select);
+    Objects.requireNonNull(where, "where");
+  }
+
+  /**
+   * Every element of the model, with every property.
+   *
+   * @return the unconstrained query
+   */
+  public static Query all() {
+    return new Query(List.of(), List.of(), Optional.empty());
+  }
+
+  /**
+   * The same query over these elements and what they nest.
+   *
+   * @param scope FQNs of the elements
+   * @return a query scoped to them
+   */
+  public Query withScope(List<String> scope) {
+    return new Query(scope, select, where);
+  }
+
+  /**
+   * The same query reporting these properties.
+   *
+   * @param select the property names
+   * @return a query selecting them
+   */
+  public Query withSelect(List<String> select) {
+    return new Query(scope, select, where);
+  }
+
+  /**
+   * The same query filtered by a condition.
+   *
+   * @param where the filter
+   * @return a query every element of which satisfies it
+   */
+  public Query where(Condition where) {
+    return new Query(scope, select, Optional.of(where));
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/QueryElement.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/QueryElement.java
new file mode 100644
index 0000000000..b9c45f28b2
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/QueryElement.java
@@ -0,0 +1,27 @@
+package org.openmbee.opensysml;
+
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * One element a {@link Query} selected.
+ *
+ * @param id the element's qualified name
+ * @param type its metamodel type name ({@code "PartUsage"}, {@code "PartDefinition"}, ...)
+ * @param properties what the query selected, omitting a property the element does not have
+ */
+public record QueryElement(String id, String type, Map<String, String> properties) {
+
+  /**
+   * Creates a query element, copying its properties.
+   *
+   * @param id the qualified name, never {@code null}
+   * @param type the type name, never {@code null}
+   * @param properties the properties
+   */
+  public QueryElement {
+    Objects.requireNonNull(id, "id");
+    Objects.requireNonNull(type, "type");
+    properties = Map.copyOf(properties);
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Referrer.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Referrer.java
new file mode 100644
index 0000000000..29d1e27a45
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Referrer.java
@@ -0,0 +1,24 @@
+package org.openmbee.opensysml;
+
+import java.util.Objects;
+
+/**
+ * One declaration referring to the target of a refused rename, delete or move.
+ *
+ * @param name the declaration as the notation names it: the qualified name of a named one, or the
+ *     heading of an anonymous one within its namespace
+ * @param document the document declaring it, named as the parse named it
+ */
+public record Referrer(String name, String document) {
+
+  /**
+   * Creates a referrer.
+   *
+   * @param name the declaration, never {@code null}
+   * @param document the document, never {@code null}
+   */
+  public Referrer {
+    Objects.requireNonNull(name, "name");
+    Objects.requireNonNull(document, "document");
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/RenderedDocument.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/RenderedDocument.java
new file mode 100644
index 0000000000..886e6cacc2
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/RenderedDocument.java
@@ -0,0 +1,21 @@
+package org.openmbee.opensysml;
+
+import java.util.Objects;
+
+/**
+ * A rendered document: the Markdown {@link Model#renderDocument(String)} answers, byte-for-byte
+ * what the service's command line writes.
+ *
+ * @param markdown the rendered document
+ */
+public record RenderedDocument(String markdown) {
+
+  /**
+   * Creates a rendered document.
+   *
+   * @param markdown the Markdown, never {@code null}
+   */
+  public RenderedDocument {
+    Objects.requireNonNull(markdown, "markdown");
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Satisfaction.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Satisfaction.java
new file mode 100644
index 0000000000..30a6e49120
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Satisfaction.java
@@ -0,0 +1,87 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The verdict of each {@code satisfy} assertion evaluated, in declaration order: what {@link
+ * Model#verifySatisfaction()} answers. A model stating none answers with no verdicts.
+ *
+ * @param verdicts one verdict per assertion
+ * @param verifications the body verdicts of every requirement the verdicts are about, each naming
+ *     its requirement, which the verdicts carry as their {@link Verdict#requirementId()}
+ * @param instances the objects the verdicts are about, each reported once
+ * @param diagnostics what the service reported while verifying
+ */
+public record Satisfaction(
+    List<Verdict> verdicts,
+    List<VerificationVerdict> verifications,
+    List<Instance> instances,
+    List<Diagnostic> diagnostics) {
+
+  /**
+   * Creates a satisfaction result, copying its collections.
+   *
+   * @param verdicts the verdicts
+   * @param verifications the body verdicts
+   * @param instances the objects
+   * @param diagnostics the diagnostics
+   */
+  public Satisfaction {
+    verdicts = List.copyOf(verdicts);
+    verifications = List.copyOf(verifications);
+    instances = List.copyOf(instances);
+    diagnostics = List.copyOf(diagnostics);
+  }
+
+  /**
+   * Whether every assertion was decided and held. A model stating none holds trivially.
+   *
+   * @return {@code true} when no verdict is undecided or violated
+   */
+  public boolean holds() {
+    return verdicts.stream().allMatch(verdict -> verdict.decided() && verdict.holds());
+  }
+
+  /**
+   * The assertions the model answered false about.
+   *
+   * @return the violated verdicts, in declaration order
+   */
+  public List<Verdict> violated() {
+    return verdicts.stream().filter(Verdict::violated).toList();
+  }
+
+  /**
+   * The assertions that could not be evaluated.
+   *
+   * @return the undecided verdicts, in declaration order
+   */
+  public List<Verdict> undecided() {
+    return verdicts.stream().filter(verdict -> !verdict.decided()).toList();
+  }
+
+  /**
+   * The body verdicts reported for one requirement.
+   *
+   * @param requirementId FQN of the requirement
+   * @return its verification cases' verdicts, in the order reported
+   */
+  public List<VerificationVerdict> verificationsOf(String requirementId) {
+    Objects.requireNonNull(requirementId, "requirementId");
+    return verifications.stream()
+        .filter(verdict -> verdict.requirementId().filter(requirementId::equals).isPresent())
+        .toList();
+  }
+
+  /**
+   * The object of an id.
+   *
+   * @param instanceId the id the service gave the object
+   * @return the object, absent when it is not among those reported
+   */
+  public Optional<Instance> instance(long instanceId) {
+    return Instances.find(instances, instanceId);
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ServiceException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ServiceException.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ServiceException.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ServiceException.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ServiceStartException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ServiceStartException.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/ServiceStartException.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/ServiceStartException.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SourceDocument.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SourceDocument.java
new file mode 100644
index 0000000000..09a157f6bb
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SourceDocument.java
@@ -0,0 +1,94 @@
+package org.openmbee.opensysml;
+
+import java.nio.file.Path;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * One document of a model {@link Connection#parseSources(java.util.List)} parses: a file the
+ * service reads, or content carried inline.
+ *
+ * <p>An inline document is reported under its {@link #name()} in diagnostics and is indexed under
+ * it, so two documents of a model need distinct names; a file is named by its path. A document's
+ * own {@link #language()} is sent when present; {@link ParseOptions#language()} does not apply per
+ * document.
+ *
+ * @param file the source the service reads, present when the document is a file
+ * @param content the notation carried inline, present when the document is inline
+ * @param name the name an inline document is reported and indexed under, absent to be named by
+ *     position
+ * @param language the notation inline content is written in, absent for the service's default;
+ *     ignored for a file, whose extension says which
+ */
+public record SourceDocument(
+    Optional<Path> file, Optional<String> content, Optional<String> name,
+    Optional<Language> language) {
+
+  /**
+   * Validates the document: exactly one of file and content.
+   *
+   * @param file the file, when the document is one
+   * @param content the inline content, when the document is one
+   * @param name the inline document's name, when given
+   * @param language the inline content's notation, when given
+   */
+  public SourceDocument {
+    Objects.requireNonNull(file, "file");
+    Objects.requireNonNull(content, "content");
+    Objects.requireNonNull(name, "name");
+    Objects.requireNonNull(language, "language");
+    if (file.isPresent() == content.isPresent()) {
+      throw new IllegalArgumentException(
+          "a source document is a file or inline content, not both and not neither");
+    }
+    if (file.isPresent() && (name.isPresent() || language.isPresent())) {
+      throw new IllegalArgumentException("a file's name and language come from the file itself");
+    }
+  }
+
+  /**
+   * A document the service reads from a file.
+   *
+   * @param file the source path, as the service resolves it
+   * @return the document
+   */
+  public static SourceDocument file(Path file) {
+    return new SourceDocument(
+        Optional.of(Objects.requireNonNull(file, "file")),
+        Optional.empty(),
+        Optional.empty(),
+        Optional.empty());
+  }
+
+  /**
+   * A document of inline content, named for its diagnostics and its index entry.
+   *
+   * @param name the name the document is reported and indexed under
+   * @param content the notation
+   * @return the document
+   */
+  public static SourceDocument inline(String name, String content) {
+    return new SourceDocument(
+        Optional.empty(), Optional.of(content), Optional.of(name), Optional.empty());
+  }
+
+  /**
+   * The same document under another name.
+   *
+   * @param name the name the document is reported and indexed under
+   * @return the document named so
+   */
+  public SourceDocument withName(String name) {
+    return new SourceDocument(file, content, Optional.of(name), language);
+  }
+
+  /**
+   * The same document read in another notation.
+   *
+   * @param language the notation the content is written in
+   * @return the document read as that notation
+   */
+  public SourceDocument withLanguage(Language language) {
+    return new SourceDocument(file, content, name, Optional.of(language));
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Standing.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Standing.java
new file mode 100644
index 0000000000..629294d233
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Standing.java
@@ -0,0 +1,74 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * How strongly an answer stands: the engine that answered, the strength of its evidence and the
+ * bounds it ran under. A service without the {@code engines} capability reports none of it.
+ *
+ * @param engine the engine that answered, as {@link Connection#listEngines()} names it; empty when
+ *     the service reported none
+ * @param strength {@code "not covered"}, {@code "observed"}, {@code "witnessed"}, {@code "bounded"}
+ *     or {@code "proved"}; empty when the service reported none
+ * @param bounds the bounds the answer ran under
+ */
+public record Standing(String engine, String strength, List<Bound> bounds) {
+
+  /** The engine selection that leaves the choice of engine to the service. */
+  public static final String ENGINE_AUTO = "auto";
+
+  /** The engine selection that puts the question to every engine covering it. */
+  public static final String ENGINE_ALL = "all";
+
+  /**
+   * Creates a standing, copying its bounds.
+   *
+   * @param engine the engine, empty rather than {@code null} when none was reported
+   * @param strength the strength, empty rather than {@code null} when none was reported
+   * @param bounds the bounds
+   */
+  public Standing {
+    Objects.requireNonNull(engine, "engine");
+    Objects.requireNonNull(strength, "strength");
+    bounds = List.copyOf(bounds);
+  }
+
+  /**
+   * The standing a service that reports none leaves.
+   *
+   * @return a standing naming no engine
+   */
+  public static Standing none() {
+    return new Standing("", "", List.of());
+  }
+
+  /**
+   * Whether the service said which engine answered.
+   *
+   * @return {@code true} when an engine is named
+   */
+  public boolean reported() {
+    return !engine.isEmpty();
+  }
+
+  /**
+   * One bound an engine ran under.
+   *
+   * @param name the bound, as the engine names it ({@code "runs"}, {@code "depth"})
+   * @param limit the value it ran under
+   * @param reached whether the run met it, which is what keeps the answer from being stronger
+   */
+  public record Bound(String name, long limit, boolean reached) {
+    /**
+     * Creates a bound.
+     *
+     * @param name the bound's name, never {@code null}
+     * @param limit the limit
+     * @param reached whether it was met
+     */
+    public Bound {
+      Objects.requireNonNull(name, "name");
+    }
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/StateRun.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/StateRun.java
new file mode 100644
index 0000000000..38c9c50994
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/StateRun.java
@@ -0,0 +1,50 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalDouble;
+
+/**
+ * What one execution of a state machine produced: what {@link Model#executeState(String, List)}
+ * answers.
+ *
+ * @param statesVisited the trace of states entered, in order
+ * @param finalContext the machine's context when execution stopped, by feature name
+ * @param finalTime the run's simulation clock when it ended, in seconds from the 0 it started at;
+ *     absent from a service without the {@code final_time} capability
+ * @param diagnostics what the service reported while executing
+ */
+public record StateRun(
+    List<String> statesVisited,
+    Map<String, Value> finalContext,
+    OptionalDouble finalTime,
+    List<Diagnostic> diagnostics) {
+
+  /**
+   * Creates a state run, copying its collections.
+   *
+   * @param statesVisited the trace
+   * @param finalContext the context by feature name
+   * @param finalTime the final time, when reported
+   * @param diagnostics the diagnostics
+   */
+  public StateRun {
+    statesVisited = List.copyOf(statesVisited);
+    finalContext = Map.copyOf(finalContext);
+    Objects.requireNonNull(finalTime, "finalTime");
+    diagnostics = List.copyOf(diagnostics);
+  }
+
+  /**
+   * The state the machine ended in.
+   *
+   * @return the last state visited, absent when none was entered
+   */
+  public Optional<String> finalState() {
+    return statesVisited.isEmpty()
+        ? Optional.empty()
+        : Optional.of(statesVisited.get(statesVisited.size() - 1));
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/StatusCode.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/StatusCode.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/StatusCode.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/StatusCode.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Sweep.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Sweep.java
new file mode 100644
index 0000000000..30db1219ef
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Sweep.java
@@ -0,0 +1,92 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Every run of one sweep, in the order the runs were made: what {@link Model#runSweep(String,
+ * java.util.List)} answers.
+ *
+ * <p>A swept table runs lexicographically over its parameters in the order their ranges were
+ * given; a sampled one runs in draw order, and echoes the seed it was drawn from so the table can
+ * be reproduced.
+ *
+ * @param rows one row per run
+ * @param parameters the swept parameters, in the order their ranges were given, which is the order
+ *     each row's inputs are in
+ * @param sampled whether the rows were drawn rather than stepped through
+ * @param seed the seed the rows were drawn from; 0 for a swept table
+ * @param instances the subjects the runs were about and the objects reachable from them; empty
+ *     when no run bound a subject
+ * @param diagnostics what the service reported while running
+ * @param standing the engine that ran the table, the strength of its evidence and the bounds it
+ *     ran under; unreported when the service predates {@code engines}
+ */
+public record Sweep(
+    List<SweepRow> rows,
+    List<String> parameters,
+    boolean sampled,
+    long seed,
+    List<Instance> instances,
+    List<Diagnostic> diagnostics,
+    Standing standing) {
+
+  /**
+   * Creates a sweep table, copying its collections.
+   *
+   * @param rows the runs
+   * @param parameters the swept parameters
+   * @param sampled whether the rows were drawn
+   * @param seed the draws' seed
+   * @param instances the objects the runs reported
+   * @param diagnostics the diagnostics
+   * @param standing the standing, never {@code null}
+   */
+  public Sweep {
+    rows = List.copyOf(rows);
+    parameters = List.copyOf(parameters);
+    instances = List.copyOf(instances);
+    diagnostics = List.copyOf(diagnostics);
+    Objects.requireNonNull(standing, "standing");
+  }
+
+  /**
+   * Whether every run succeeded and every verdict of them holds.
+   *
+   * @return {@code true} when no row failed and no verdict is undecided or violated
+   */
+  public boolean holds() {
+    return !rows.isEmpty() && rows.stream().allMatch(SweepRow::holds);
+  }
+
+  /**
+   * The runs that failed.
+   *
+   * @return the failed rows
+   */
+  public List<SweepRow> failures() {
+    return rows.stream().filter(SweepRow::failed).toList();
+  }
+
+  /**
+   * The object a value refers to.
+   *
+   * @param reference a reference a run reported
+   * @return the object, absent when it is not among those reported
+   */
+  public Optional<Instance> resolve(Value.InstanceReference reference) {
+    Objects.requireNonNull(reference, "reference");
+    return Instances.find(instances, reference.instanceId());
+  }
+
+  /**
+   * The object of an id.
+   *
+   * @param instanceId the id the service gave the object
+   * @return the object, absent when it is not among those reported
+   */
+  public Optional<Instance> instance(long instanceId) {
+    return Instances.find(instances, instanceId);
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SweepOptions.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SweepOptions.java
new file mode 100644
index 0000000000..6cb926c1b9
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SweepOptions.java
@@ -0,0 +1,103 @@
+package org.openmbee.opensysml;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * How a sweep's rows run.
+ *
+ * @param subject FQN of a part definition or usage to instantiate and bind as an analysis case's
+ *     subject; absent for a usage binding its own, and refused for a calc, which has none
+ * @param arguments values binding the target's {@code in} parameters in declaration order, as
+ *     every row binds them
+ * @param namedArguments values binding them by name, as every row binds them
+ * @param samples rows to draw uniformly from each range instead of stepping through it, in draw
+ *     order; 0 steps through the ranges
+ * @param seed the seed the draws are taken from: the same seed draws the same table on every
+ *     platform; ignored when {@code samples} is 0
+ */
+public record SweepOptions(
+    Optional<String> subject,
+    List<Value> arguments,
+    Map<String, Value> namedArguments,
+    long samples,
+    long seed) {
+
+  /**
+   * Validates the options, copying their collections.
+   *
+   * @param subject the subject, when named
+   * @param arguments the positional arguments
+   * @param namedArguments the named arguments
+   * @param samples the rows to draw
+   * @param seed the draws' seed
+   */
+  public SweepOptions {
+    Objects.requireNonNull(subject, "subject");
+    arguments = List.copyOf(arguments);
+    namedArguments = Collections.unmodifiableMap(new LinkedHashMap<>(namedArguments));
+  }
+
+  /**
+   * No subject, no arguments, ranges stepped through.
+   *
+   * @return the default options
+   */
+  public static SweepOptions defaults() {
+    return new SweepOptions(Optional.empty(), List.of(), Map.of(), 0, 0);
+  }
+
+  /**
+   * The same options run on a subject.
+   *
+   * @param subject FQN of the part definition or usage
+   * @return options naming it
+   */
+  public SweepOptions withSubject(String subject) {
+    return new SweepOptions(Optional.of(subject), arguments, namedArguments, samples, seed);
+  }
+
+  /**
+   * The same options with these positional arguments.
+   *
+   * @param arguments the arguments, in parameter order
+   * @return options carrying them
+   */
+  public SweepOptions withArguments(List<Value> arguments) {
+    return new SweepOptions(subject, arguments, namedArguments, samples, seed);
+  }
+
+  /**
+   * The same options with these named arguments.
+   *
+   * @param namedArguments the arguments by parameter name
+   * @return options carrying them
+   */
+  public SweepOptions withNamedArguments(Map<String, Value> namedArguments) {
+    return new SweepOptions(subject, arguments, namedArguments, samples, seed);
+  }
+
+  /**
+   * The same options drawing rows rather than stepping through the ranges.
+   *
+   * @param samples the rows to draw
+   * @return options drawing them
+   */
+  public SweepOptions withSamples(long samples) {
+    return new SweepOptions(subject, arguments, namedArguments, samples, seed);
+  }
+
+  /**
+   * The same options drawing from another seed.
+   *
+   * @param seed the seed
+   * @return options drawing from it
+   */
+  public SweepOptions withSeed(long seed) {
+    return new SweepOptions(subject, arguments, namedArguments, samples, seed);
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SweepRange.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SweepRange.java
new file mode 100644
index 0000000000..3809f005bc
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SweepRange.java
@@ -0,0 +1,55 @@
+package org.openmbee.opensysml;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * One parameter's sweep range: the values it binds, one per row of the table {@link
+ * Model#runSweep(String, java.util.List)} answers.
+ *
+ * @param parameter the input parameter the range binds, which the target must declare and the
+ *     sweep's own arguments must not bind
+ * @param start the endpoint the range's rows start at; required
+ * @param end the endpoint they run to, inclusive where the step lands on it; required
+ * @param step what the range advances by; absent steps by one over whole-number endpoints, and is
+ *     refused for a sampled table, which draws instead
+ */
+public record SweepRange(String parameter, Value start, Value end, Optional<Value> step) {
+
+  /**
+   * Creates a range.
+   *
+   * @param parameter the parameter's name, never {@code null}
+   * @param start the start endpoint, never {@code null}
+   * @param end the end endpoint, never {@code null}
+   * @param step the step, when the range states one
+   */
+  public SweepRange {
+    Objects.requireNonNull(parameter, "parameter");
+    Objects.requireNonNull(start, "start");
+    Objects.requireNonNull(end, "end");
+    Objects.requireNonNull(step, "step");
+  }
+
+  /**
+   * A range stating no step.
+   *
+   * @param parameter the parameter's name
+   * @param start the start endpoint
+   * @param end the end endpoint
+   * @return the range
+   */
+  public static SweepRange of(String parameter, Value start, Value end) {
+    return new SweepRange(parameter, start, end, Optional.empty());
+  }
+
+  /**
+   * The same range advancing by a step.
+   *
+   * @param step the step
+   * @return the range carrying it
+   */
+  public SweepRange withStep(Value step) {
+    return new SweepRange(parameter, start, end, Optional.of(step));
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SweepRow.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SweepRow.java
new file mode 100644
index 0000000000..46d695808b
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/SweepRow.java
@@ -0,0 +1,72 @@
+package org.openmbee.opensysml;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * One run of a sweep: what it bound, what it produced, and how long it took. A run that failed is
+ * a row like any other, carrying {@link #error()} in place of outputs, so one failing run does not
+ * lose the rest of the table.
+ *
+ * @param inputs the swept parameters as this run bound them, by name in range order
+ * @param outputs what the run produced, by name; a calc's returned value is named {@code "result"}
+ * @param verdicts the objective and assertion verdicts of an analysis case; empty for a calc
+ * @param evaluations each application this run made of one of the case's calcs as a value — a
+ *     trade study's evaluation of each alternative, in subject order; a failed run keeps the ones
+ *     it made. Empty for a calc, or for a service without the {@code case_evaluations} capability
+ * @param elapsed the wall time of this run
+ * @param error why this run failed; empty when it did not
+ * @param failureReason what kind of failure {@code error} reports
+ */
+public record SweepRow(
+    Map<String, Value> inputs,
+    Map<String, Value> outputs,
+    List<Verdict> verdicts,
+    List<CaseEvaluation> evaluations,
+    Duration elapsed,
+    String error,
+    FailureReason failureReason) {
+
+  /**
+   * Creates a sweep row, copying its collections.
+   *
+   * @param inputs the bound parameters by name
+   * @param outputs the outputs by name
+   * @param verdicts the verdicts
+   * @param evaluations the case's evaluations
+   * @param elapsed the run's wall time, never {@code null}
+   * @param error the failure, empty rather than {@code null} when the run did not fail
+   * @param failureReason the failure's kind, never {@code null}
+   */
+  public SweepRow {
+    inputs = Collections.unmodifiableMap(new LinkedHashMap<>(inputs));
+    outputs = Collections.unmodifiableMap(new LinkedHashMap<>(outputs));
+    verdicts = List.copyOf(verdicts);
+    evaluations = List.copyOf(evaluations);
+    Objects.requireNonNull(elapsed, "elapsed");
+    Objects.requireNonNull(error, "error");
+    Objects.requireNonNull(failureReason, "failureReason");
+  }
+
+  /**
+   * Whether this run failed rather than producing outputs.
+   *
+   * @return {@code true} when the run carries an error
+   */
+  public boolean failed() {
+    return !error.isEmpty();
+  }
+
+  /**
+   * Whether the run succeeded and every verdict of it holds.
+   *
+   * @return {@code true} when the run did not fail and no verdict is undecided or violated
+   */
+  public boolean holds() {
+    return !failed() && verdicts.stream().allMatch(verdict -> verdict.decided() && verdict.holds());
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Symbol.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Symbol.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Symbol.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Symbol.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/TransportException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/TransportException.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/TransportException.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/TransportException.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/UnpinnedReleaseException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/UnpinnedReleaseException.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/UnpinnedReleaseException.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/UnpinnedReleaseException.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/UnsignedReleaseException.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/UnsignedReleaseException.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/UnsignedReleaseException.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/UnsignedReleaseException.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Validation.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Validation.java
new file mode 100644
index 0000000000..4835fee431
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Validation.java
@@ -0,0 +1,76 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Every assertion about one object and the objects it holds, checked against their values: what
+ * {@link Model#validateInstance(String)} answers.
+ *
+ * @param summary the object as a whole, of kind {@link Verdict#KIND_OBJECT}: holds when every
+ *     assertion holds and every held object was reached; undecided when some assertion was, or
+ *     nesting was left unreached
+ * @param verdicts one per assertion, each about the object its {@link Verdict#instanceId()} names,
+ *     reached along its {@link Verdict#instancePath()}
+ * @param verifications the body verdicts of the verification cases verifying each requirement a
+ *     verdict is about, each naming its requirement
+ * @param instances every object reached, the validated one first
+ * @param diagnostics what the service reported while validating
+ * @param bounded whether nesting deeper than the validation descends, or past its budget, was left
+ *     unvalidated, in which case the summary decides nothing
+ */
+public record Validation(
+    Verdict summary,
+    List<Verdict> verdicts,
+    List<VerificationVerdict> verifications,
+    List<Instance> instances,
+    List<Diagnostic> diagnostics,
+    boolean bounded) {
+
+  /**
+   * Creates a validation, copying its collections.
+   *
+   * @param summary the object's verdict, never {@code null}
+   * @param verdicts the assertion verdicts
+   * @param verifications the body verdicts
+   * @param instances the objects
+   * @param diagnostics the diagnostics
+   * @param bounded whether nesting was left unvalidated
+   */
+  public Validation {
+    Objects.requireNonNull(summary, "summary");
+    verdicts = List.copyOf(verdicts);
+    verifications = List.copyOf(verifications);
+    instances = List.copyOf(instances);
+    diagnostics = List.copyOf(diagnostics);
+  }
+
+  /**
+   * Whether the object as a whole was decided and holds.
+   *
+   * @return {@code true} for a decided summary that holds
+   */
+  public boolean holds() {
+    return summary.decided() && summary.holds();
+  }
+
+  /**
+   * The validated object.
+   *
+   * @return the object, absent when the service reported none
+   */
+  public Optional<Instance> root() {
+    return instances.isEmpty() ? Optional.empty() : Optional.of(instances.get(0));
+  }
+
+  /**
+   * The object of an id.
+   *
+   * @param instanceId the id the service gave the object
+   * @return the object, absent when it is not among those reported
+   */
+  public Optional<Instance> instance(long instanceId) {
+    return Instances.find(instances, instanceId);
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Value.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Value.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Value.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Value.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verdict.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verdict.java
new file mode 100644
index 0000000000..f6e7e8610c
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verdict.java
@@ -0,0 +1,111 @@
+package org.openmbee.opensysml;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * One verification's answer: whether a condition held and, when it did not, what the model answered
+ * false about.
+ *
+ * <p>{@code holds} false with {@code error} absent is the model's answer of false; false with an
+ * error present is no answer at all, see {@link #decided()}. Neither is an exception: a verdict is
+ * a result to read, and only a call the service could not answer throws {@link ModelException}.
+ *
+ * @param kind what was verified: {@link #KIND_CONSTRAINT}, {@link #KIND_REQUIREMENT}, {@link
+ *     #KIND_SATISFY}, {@link #KIND_OBJECTIVE}, {@link #KIND_ASSERTION} or {@link #KIND_OBJECT}
+ * @param elementId FQN of the element verified, absent for an anonymous satisfy assertion
+ * @param element the element as a reader names it, or the assertion as written
+ * @param holds the model's answer, meaningful only when {@link #decided()}
+ * @param condition the condition that evaluated to false, as written, when the runtime names one
+ * @param instanceId id of the object the verdict is about, absent when it is about declared values
+ *     alone; the result carrying the verdict resolves it to an {@link Instance}
+ * @param instanceTypeId FQN of that object's type, absent with {@code instanceId}
+ * @param error why evaluation failed, present only when nothing was decided
+ * @param failureReason what kind of failure {@code error} reports
+ * @param requirementId FQN of the requirement a satisfaction verdict asserts satisfied, absent when
+ *     the assertion names none
+ * @param instancePath the features held from a validated object to the one this verdict is about
+ *     ({@code "engine.pump"}, {@code "wheels[2]"}), absent for the validated object itself and
+ *     outside a {@link Validation}
+ * @param standing how strongly the verdict stands
+ */
+public record Verdict(
+    String kind,
+    Optional<String> elementId,
+    String element,
+    boolean holds,
+    Optional<String> condition,
+    Optional<Long> instanceId,
+    Optional<String> instanceTypeId,
+    Optional<String> error,
+    FailureReason failureReason,
+    Optional<String> requirementId,
+    Optional<String> instancePath,
+    Standing standing) {
+
+  /** A constraint verified against declared or an object's values. */
+  public static final String KIND_CONSTRAINT = "constraint";
+
+  /** A requirement's require constraints verified. */
+  public static final String KIND_REQUIREMENT = "requirement";
+
+  /** A {@code satisfy} assertion. */
+  public static final String KIND_SATISFY = "satisfy";
+
+  /** An analysis case's objective. */
+  public static final String KIND_OBJECTIVE = "objective";
+
+  /** An {@code assert constraint} in a case's body. */
+  public static final String KIND_ASSERTION = "assertion";
+
+  /** A validated object as a whole. */
+  public static final String KIND_OBJECT = "object";
+
+  /**
+   * Creates a verdict.
+   *
+   * @param kind what was verified, never {@code null}
+   * @param elementId the element's FQN, when it has one
+   * @param element the element as named, never {@code null}
+   * @param holds the answer
+   * @param condition the failing condition, when named
+   * @param instanceId the object, when there is one
+   * @param instanceTypeId that object's type, when there is one
+   * @param error the failure, when evaluation failed
+   * @param failureReason the kind of failure, never {@code null}
+   * @param requirementId the requirement asserted satisfied, when named
+   * @param instancePath the path to the object, when validating
+   * @param standing the standing, never {@code null}
+   */
+  public Verdict {
+    Objects.requireNonNull(kind, "kind");
+    Objects.requireNonNull(elementId, "elementId");
+    Objects.requireNonNull(element, "element");
+    Objects.requireNonNull(condition, "condition");
+    Objects.requireNonNull(instanceId, "instanceId");
+    Objects.requireNonNull(instanceTypeId, "instanceTypeId");
+    Objects.requireNonNull(error, "error");
+    Objects.requireNonNull(failureReason, "failureReason");
+    Objects.requireNonNull(requirementId, "requirementId");
+    Objects.requireNonNull(instancePath, "instancePath");
+    Objects.requireNonNull(standing, "standing");
+  }
+
+  /**
+   * Whether the model answered at all: evaluation succeeded and {@link #holds()} is its answer.
+   *
+   * @return {@code true} when no error was reported
+   */
+  public boolean decided() {
+    return error.isEmpty();
+  }
+
+  /**
+   * Whether the model answered false: the condition was evaluated and did not hold.
+   *
+   * @return {@code true} for a decided verdict that does not hold
+   */
+  public boolean violated() {
+    return decided() && !holds;
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verification.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verification.java
new file mode 100644
index 0000000000..787897eb57
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/Verification.java
@@ -0,0 +1,66 @@
+package org.openmbee.opensysml;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * One constraint's or requirement's verdict, with the objects it is about: what {@link
+ * Model#verifyConstraint(String)} and {@link Model#verifyRequirement(String)} answer.
+ *
+ * @param verdict the answer
+ * @param verifications the body verdicts of the verification cases verifying this requirement,
+ *     empty for a constraint and for a service reporting none
+ * @param instances the objects reachable from the verdict's subject, including it, so its feature
+ *     values need no further call
+ * @param diagnostics what the service reported while verifying
+ */
+public record Verification(
+    Verdict verdict,
+    List<VerificationVerdict> verifications,
+    List<Instance> instances,
+    List<Diagnostic> diagnostics) {
+
+  /**
+   * Creates a verification, copying its collections.
+   *
+   * @param verdict the answer, never {@code null}
+   * @param verifications the body verdicts
+   * @param instances the objects
+   * @param diagnostics the diagnostics
+   */
+  public Verification {
+    Objects.requireNonNull(verdict, "verdict");
+    verifications = List.copyOf(verifications);
+    instances = List.copyOf(instances);
+    diagnostics = List.copyOf(diagnostics);
+  }
+
+  /**
+   * Whether the model answered that the condition holds.
+   *
+   * @return {@code true} for a decided verdict that holds
+   */
+  public boolean holds() {
+    return verdict.decided() && verdict.holds();
+  }
+
+  /**
+   * The object the verdict is about.
+   *
+   * @return the object, absent when the verdict is about declared values alone
+   */
+  public Optional<Instance> subject() {
+    return verdict.instanceId().flatMap(id -> Instances.find(instances, id));
+  }
+
+  /**
+   * The object of an id.
+   *
+   * @param instanceId the id the service gave the object
+   * @return the object, absent when it is not among those reported
+   */
+  public Optional<Instance> instance(long instanceId) {
+    return Instances.find(instances, instanceId);
+  }
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/VerificationVerdict.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/VerificationVerdict.java
new file mode 100644
index 0000000000..a8761d4328
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/VerificationVerdict.java
@@ -0,0 +1,60 @@
+package org.openmbee.opensysml;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * What the body of a verification case answered when it ran, which is a separate answer from
+ * whether the requirement it verifies is satisfied. Reported by a service advertising the {@code
+ * verification_verdicts} capability.
+ *
+ * @param caseId FQN of the verification case that ran
+ * @param kind the verdict its body produced: {@link #PASS}, {@link #FAIL}, {@link #INCONCLUSIVE} or
+ *     {@link #ERROR}
+ * @param detail the text of an error verdict, or why an inconclusive one decided nothing; absent
+ *     for a pass and a fail
+ * @param subcase whether this is the verdict of a case another performed, reported on its own
+ *     because the library states no roll-up for it
+ * @param requirementId FQN of the requirement the verdict was reported for, absent when the case
+ *     ran for itself
+ */
+public record VerificationVerdict(
+    String caseId, String kind, Optional<String> detail, boolean subcase, Optional<String> requirementId) {
+
+  /** A body whose verdict value is {@code VerdictKind::pass}. */
+  public static final String PASS = "pass";
+
+  /** A body whose verdict value is {@code VerdictKind::fail}. */
+  public static final String FAIL = "fail";
+
+  /** A body that ran and produced no verdict value. */
+  public static final String INCONCLUSIVE = "inconclusive";
+
+  /** A body whose run could not be carried out. */
+  public static final String ERROR = "error";
+
+  /**
+   * Creates a verification verdict.
+   *
+   * @param caseId the case's FQN, never {@code null}
+   * @param kind the verdict kind, never {@code null}
+   * @param detail the detail, when there is one
+   * @param subcase whether it is a subcase's
+   * @param requirementId the requirement, when reported for one
+   */
+  public VerificationVerdict {
+    Objects.requireNonNull(caseId, "caseId");
+    Objects.requireNonNull(kind, "kind");
+    Objects.requireNonNull(detail, "detail");
+    Objects.requireNonNull(requirementId, "requirementId");
+  }
+
+  /**
+   * Whether the body passed.
+   *
+   * @return {@code true} for a {@link #PASS} verdict
+   */
+  public boolean passed() {
+    return PASS.equals(kind);
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/BinaryDownloader.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/BinaryDownloader.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/BinaryDownloader.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/BinaryDownloader.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/BinaryResolver.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/BinaryResolver.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/BinaryResolver.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/BinaryResolver.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ConnectTransport.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ConnectTransport.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ConnectTransport.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ConnectTransport.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Json.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Json.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Json.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Json.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/JsonBodies.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/JsonBodies.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/JsonBodies.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/JsonBodies.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/PrivateService.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/PrivateService.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/PrivateService.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/PrivateService.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Protos.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Protos.java
new file mode 100644
index 0000000000..c68e6c632a
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Protos.java
@@ -0,0 +1,1463 @@
+package org.openmbee.opensysml.internal;
+
+import org.openmbee.opensysml.ActionRun;
+import org.openmbee.opensysml.Analysis;
+import org.openmbee.opensysml.AppliedEdit;
+import org.openmbee.opensysml.Calculation;
+import org.openmbee.opensysml.CaseEvaluation;
+import org.openmbee.opensysml.Condition;
+import org.openmbee.opensysml.Conversion;
+import org.openmbee.opensysml.Diagnostic;
+import org.openmbee.opensysml.DocumentQueryResult;
+import org.openmbee.opensysml.DocumentRow;
+import org.openmbee.opensysml.DocumentValue;
+import org.openmbee.opensysml.Edit;
+import org.openmbee.opensysml.EditFailure;
+import org.openmbee.opensysml.EditResult;
+import org.openmbee.opensysml.EditedDocument;
+import org.openmbee.opensysml.EngineInfo;
+import org.openmbee.opensysml.EnumLiteral;
+import org.openmbee.opensysml.Exploration;
+import org.openmbee.opensysml.FailureReason;
+import org.openmbee.opensysml.Instance;
+import org.openmbee.opensysml.Instantiation;
+import org.openmbee.opensysml.Outcome;
+import org.openmbee.opensysml.Quantity;
+import org.openmbee.opensysml.Query;
+import org.openmbee.opensysml.QueryElement;
+import org.openmbee.opensysml.Referrer;
+import org.openmbee.opensysml.Satisfaction;
+import org.openmbee.opensysml.SourceDocument;
+import org.openmbee.opensysml.Standing;
+import org.openmbee.opensysml.StateRun;
+import org.openmbee.opensysml.Sweep;
+import org.openmbee.opensysml.SweepRange;
+import org.openmbee.opensysml.SweepRow;
+import org.openmbee.opensysml.Symbol;
+import org.openmbee.opensysml.TransportException;
+import org.openmbee.opensysml.Validation;
+import org.openmbee.opensysml.Value;
+import org.openmbee.opensysml.Verdict;
+import org.openmbee.opensysml.Verification;
+import org.openmbee.opensysml.VerificationVerdict;
+import org.openmbee.opensysml.proto.AttributeInfo;
+import org.openmbee.opensysml.proto.Bound;
+import org.openmbee.opensysml.proto.CalcOutput;
+import org.openmbee.opensysml.proto.ApplyEditsResponse;
+import org.openmbee.opensysml.proto.CompositeConstraint;
+import org.openmbee.opensysml.proto.CompositeOperator;
+import org.openmbee.opensysml.proto.Constraint;
+import org.openmbee.opensysml.proto.ConvertResponse;
+import org.openmbee.opensysml.proto.ExecuteActionResponse;
+import org.openmbee.opensysml.proto.ExecuteStateResponse;
+import org.openmbee.opensysml.proto.ExplorationStatus;
+import org.openmbee.opensysml.proto.FeatureValue;
+import org.openmbee.opensysml.proto.InstantiateResponse;
+import org.openmbee.opensysml.proto.MultiplicityInfo;
+import org.openmbee.opensysml.proto.PrimitiveConstraint;
+import org.openmbee.opensysml.proto.PrimitiveOperator;
+import org.openmbee.opensysml.proto.QueryResultElement;
+import org.openmbee.opensysml.proto.RunAnalysisResponse;
+import org.openmbee.opensysml.proto.RunDocumentQueryResponse;
+import org.openmbee.opensysml.proto.RunSweepResponse;
+import org.openmbee.opensysml.proto.Span;
+import org.openmbee.opensysml.proto.Specialization;
+import org.openmbee.opensysml.proto.SymbolInfo;
+import org.openmbee.opensysml.proto.TensorQuantity;
+import org.openmbee.opensysml.proto.TypeInfo;
+import org.openmbee.opensysml.proto.Undetermined;
+import org.openmbee.opensysml.proto.UnitFactor;
+import org.openmbee.opensysml.proto.UnitTerm;
+import org.openmbee.opensysml.proto.ValidateInstanceResponse;
+import org.openmbee.opensysml.proto.ValueSequence;
+import org.openmbee.opensysml.proto.ValueSet;
+import org.openmbee.opensysml.proto.VerifyConstraintResponse;
+import org.openmbee.opensysml.proto.VerifyRequirementResponse;
+import org.openmbee.opensysml.proto.VerifySatisfactionResponse;
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalDouble;
+
+/**
+ * Reads the generated messages into the client's own immutable types, so no generated class and no
+ * builder reaches a caller, and writes the client's values into the messages a request carries.
+ */
+public final class Protos {
+
+  private Protos() {}
+
+  /**
+   * A value, absent when the message names no kind.
+   *
+   * @param value the generated value
+   * @return the value, or empty when no arm is set
+   */
+  public static Optional<Value> value(org.openmbee.opensysml.proto.Value value) {
+    return switch (value.getKindCase()) {
+      case INT_VALUE -> Optional.of(new Value.IntegerValue(value.getIntValue()));
+      case REAL_VALUE -> Optional.of(new Value.RealValue(value.getRealValue()));
+      case COMPLEX ->
+          Optional.of(
+              new Value.ComplexValue(value.getComplex().getReal(), value.getComplex().getImaginary()));
+      case BOOL_VALUE -> Optional.of(new Value.BooleanValue(value.getBoolValue()));
+      case STRING_VALUE -> Optional.of(new Value.StringValue(value.getStringValue()));
+      case INSTANCE_ID -> Optional.of(new Value.InstanceReference(value.getInstanceId()));
+      case SEQUENCE -> Optional.of(sequence(value));
+      case NULL -> Optional.of(new Value.NullValue());
+      case QUANTITY -> Optional.of(new Value.QuantityValue(quantity(value.getQuantity())));
+      case ENUM_LITERAL -> Optional.of(new Value.EnumerationValue(literal(value.getEnumLiteral())));
+      case UNSET -> Optional.of(new Value.UnsetValue());
+      case UNDETERMINED -> Optional.of(undetermined(value.getUndetermined()));
+      case INFINITY -> Optional.of(infinity(value));
+      case ARRAY -> Optional.of(array(value.getArray()));
+      case VECTOR -> Optional.of(vector(value.getVector()));
+      case VECTOR_QUANTITY -> Optional.of(vectorQuantity(value.getVectorQuantity()));
+      case MEASUREMENT_REF -> Optional.of(measurementRef(value.getMeasurementRef()));
+      case FUNCTION -> Optional.of(function(value.getFunction()));
+      case SET -> Optional.of(set(value.getSet()));
+      case TENSOR_QUANTITY -> Optional.of(tensorQuantity(value.getTensorQuantity()));
+      case METAOBJECT -> Optional.of(metaobject(value.getMetaobject()));
+      case KIND_NOT_SET -> Optional.empty();
+    };
+  }
+
+  /** Only an asserted arm carries the unbounded value. */
+  private static Value infinity(org.openmbee.opensysml.proto.Value value) {
+    if (!value.getInfinity()) {
+      throw new TransportException(
+          "the service answered a malformed value: the infinity arm states no value unless it is"
+              + " true",
+          null);
+    }
+    return new Value.InfinityValue();
+  }
+
+  private static Value undetermined(org.openmbee.opensysml.proto.Undetermined undetermined) {
+    org.openmbee.opensysml.proto.MultiplicityInfo count = undetermined.getCount();
+    return new Value.UndeterminedValue(undetermined.getReason(), count.getLower(), count.getUpper());
+  }
+
+  private static Value array(org.openmbee.opensysml.proto.Array array) {
+    List<Value> elements = new ArrayList<>(array.getElementsCount());
+    for (org.openmbee.opensysml.proto.Value element : array.getElementsList()) {
+      elements.add(readable(element));
+    }
+    try {
+      return new Value.ArrayValue(array.getDimensionsList(), elements);
+    } catch (IllegalArgumentException | ArithmeticException malformed) {
+      throw new TransportException(
+          "the service answered a malformed array: " + malformed.getMessage(), malformed);
+    }
+  }
+
+  private static Value vector(org.openmbee.opensysml.proto.Vector vector) {
+    List<Value> components = new ArrayList<>(vector.getComponentsCount());
+    for (org.openmbee.opensysml.proto.Value component : vector.getComponentsList()) {
+      components.add(
+          switch (component.getKindCase()) {
+            case INT_VALUE -> new Value.IntegerValue(component.getIntValue());
+            case REAL_VALUE -> new Value.RealValue(component.getRealValue());
+            default ->
+                throw new TransportException(
+                    "the service answered a malformed vector: component is "
+                        + component.getKindCase().name().toLowerCase(Locale.ROOT)
+                        + ", not a number",
+                    null);
+          });
+    }
+    return new Value.VectorValue(components);
+  }
+
+  private static Value vectorQuantity(org.openmbee.opensysml.proto.VectorQuantity vector) {
+    if (vector.getComponentsCount() == 0) {
+      throw new TransportException(
+          "the service answered a malformed vector quantity: it has no components", null);
+    }
+    List<Quantity> components = new ArrayList<>(vector.getComponentsCount());
+    for (org.openmbee.opensysml.proto.Quantity component : vector.getComponentsList()) {
+      components.add(quantity(component));
+    }
+    return new Value.VectorQuantityValue(components);
+  }
+
+  private static Value set(org.openmbee.opensysml.proto.ValueSet set) {
+    List<Value> elements = new ArrayList<>(set.getElementsCount());
+    for (org.openmbee.opensysml.proto.Value element : set.getElementsList()) {
+      elements.add(readable(element));
+    }
+    try {
+      return new Value.SetValue(elements);
+    } catch (IllegalArgumentException malformed) {
+      throw new TransportException(
+          "the service answered a malformed set: " + malformed.getMessage(), malformed);
+    }
+  }
+
+  private static Value tensorQuantity(org.openmbee.opensysml.proto.TensorQuantity tensor) {
+    List<Quantity> components = new ArrayList<>(tensor.getComponentsCount());
+    for (org.openmbee.opensysml.proto.Quantity component : tensor.getComponentsList()) {
+      components.add(quantity(component));
+    }
+    try {
+      return new Value.TensorQuantityValue(tensor.getDimensionsList(), components);
+    } catch (IllegalArgumentException | ArithmeticException malformed) {
+      throw new TransportException(
+          "the service answered a malformed tensor quantity: " + malformed.getMessage(),
+          malformed);
+    }
+  }
+
+  private static Value measurementRef(org.openmbee.opensysml.proto.MeasurementRef ref) {
+    if (ref.getUnit().isEmpty() && ref.getUnitId().isEmpty() && !ref.hasUnitTerm()) {
+      throw new TransportException(
+          "the service answered a malformed measurement reference: it names no unit", null);
+    }
+    if (!ref.hasUnitTerm()) {
+      throw new TransportException(
+          "the service answered a malformed measurement reference "
+              + (ref.getUnit().isEmpty() ? ref.getUnitId() : ref.getUnit())
+              + ": it has no reduction to base units",
+          null);
+    }
+    return new Value.MeasurementRefValue(
+        ref.getUnit(), unitTerm(ref.getUnitTerm()), present(ref.getUnitId()));
+  }
+
+  private static Value metaobject(org.openmbee.opensysml.proto.Metaobject metaobject) {
+    if (metaobject.getElementId().isEmpty()) {
+      throw new TransportException(
+          "the service answered a malformed metaobject: it names no element", null);
+    }
+    return new Value.MetaobjectValue(metaobject.getElementId(), metaobject.getMetaclassId());
+  }
+
+  private static Value function(org.openmbee.opensysml.proto.Function function) {
+    if (function.getCalcId().isEmpty()) {
+      throw new TransportException(
+          "the service answered a malformed function: it names no calc", null);
+    }
+    return new Value.FunctionValue(
+        function.getCalcId(),
+        function.getSelfId() == 0 ? Optional.empty() : Optional.of(function.getSelfId()));
+  }
+
+  private static Value sequence(org.openmbee.opensysml.proto.Value value) {
+    List<Value> elements = new ArrayList<>();
+    for (org.openmbee.opensysml.proto.Value element : value.getSequence().getElementsList()) {
+      elements.add(readable(element));
+    }
+    return new Value.Sequence(elements);
+  }
+
+  /**
+   * A value the client must read rather than drop: answering nothing for a value that was sent
+   * would read as a shorter sequence, or as a feature holding no value at all.
+   */
+  private static Value readable(org.openmbee.opensysml.proto.Value value) {
+    return value(value)
+        .orElseThrow(
+            () ->
+                new TransportException(
+                    "the service answered a value of a kind this client does not know", null));
+  }
+
+  /**
+   * A quantity.
+   *
+   * @param quantity the generated quantity
+   * @return the immutable quantity
+   * @throws TransportException when the quantity carries no magnitude, which no number stands in for
+   */
+  public static Quantity quantity(org.openmbee.opensysml.proto.Quantity quantity) {
+    Number magnitude =
+        switch (quantity.getMagnitudeCase()) {
+          case INT_MAGNITUDE -> Long.valueOf(quantity.getIntMagnitude());
+          case REAL_MAGNITUDE -> Double.valueOf(quantity.getRealMagnitude());
+          case MAGNITUDE_NOT_SET ->
+              throw new TransportException(
+                  "the service answered a malformed quantity in ["
+                      + quantity.getUnit()
+                      + "]: it has no magnitude",
+                  null);
+        };
+    Optional<Quantity.UnitTerm> reduction =
+        quantity.hasUnitTerm() ? Optional.of(unitTerm(quantity.getUnitTerm())) : Optional.empty();
+    return new Quantity(magnitude, present(quantity.getUnit()), reduction);
+  }
+
+  private static Quantity.UnitTerm unitTerm(UnitTerm term) {
+    List<Quantity.UnitFactor> factors = new ArrayList<>();
+    for (UnitFactor factor : term.getFactorsList()) {
+      factors.add(new Quantity.UnitFactor(factor.getUnitId(), factor.getExponent()));
+    }
+    return new Quantity.UnitTerm(term.getScaleNum(), term.getScaleDen(), factors);
+  }
+
+  /**
+   * An enumeration literal.
+   *
+   * @param literal the generated literal
+   * @return the immutable literal
+   */
+  public static EnumLiteral literal(org.openmbee.opensysml.proto.EnumLiteral literal) {
+    return new EnumLiteral(
+        literal.getLiteralId(),
+        literal.getEnumerationId(),
+        literal.getName(),
+        literal.hasValue() ? Optional.of(readable(literal.getValue())) : Optional.empty());
+  }
+
+  /**
+   * Diagnostics.
+   *
+   * @param diagnostics the generated diagnostics
+   * @return immutable diagnostics, in order
+   */
+  public static List<Diagnostic> diagnostics(
+      List<org.openmbee.opensysml.proto.Diagnostic> diagnostics) {
+    List<Diagnostic> read = new ArrayList<>(diagnostics.size());
+    for (org.openmbee.opensysml.proto.Diagnostic diagnostic : diagnostics) {
+      read.add(
+          new Diagnostic(
+              Diagnostic.Severity.fromWireName(diagnostic.getSeverity()),
+              diagnostic.getMessage(),
+              diagnostic.getCode(),
+              diagnostic.hasSpan() ? Optional.of(span(diagnostic.getSpan())) : Optional.empty()));
+    }
+    return List.copyOf(read);
+  }
+
+  private static Diagnostic.Span span(Span span) {
+    return new Diagnostic.Span(
+        span.getFile(),
+        span.getStartLine(),
+        span.getStartCol(),
+        span.getEndLine(),
+        span.getEndCol());
+  }
+
+  /**
+   * A symbol.
+   *
+   * @param symbol the generated symbol
+   * @return the immutable symbol
+   */
+  public static Symbol symbol(SymbolInfo symbol) {
+    List<Symbol.Attribute> attributes = new ArrayList<>(symbol.getAttributesCount());
+    for (AttributeInfo attribute : symbol.getAttributesList()) {
+      attributes.add(
+          new Symbol.Attribute(
+              attribute.getName(),
+              attribute.getType(),
+              attribute.hasValue()
+                  ? Optional.of(readable(attribute.getValue()))
+                  : Optional.<Value>empty(),
+              present(attribute.getUnit())));
+    }
+    List<Symbol.Specialization> specializations = new ArrayList<>(symbol.getSpecializationsCount());
+    for (Specialization specialization : symbol.getSpecializationsList()) {
+      specializations.add(
+          new Symbol.Specialization(
+              specialization.getKind(),
+              specialization.getDeclared(),
+              present(specialization.getTargetId()),
+              present(specialization.getTargetKind())));
+    }
+    return new Symbol(
+        symbol.getId(),
+        symbol.getName(),
+        symbol.getKind(),
+        symbol.getMetadataMap(),
+        symbol.getChildIdsList(),
+        attributes,
+        symbol.hasTypeInfo() ? Optional.of(typeFacts(symbol.getTypeInfo())) : Optional.empty(),
+        symbol.hasMultiplicity()
+            ? Optional.of(multiplicity(symbol.getMultiplicity()))
+            : Optional.empty(),
+        specializations,
+        symbol.getWithheldLibraryAttributes());
+  }
+
+  private static Symbol.TypeFacts typeFacts(TypeInfo typeInfo) {
+    return new Symbol.TypeFacts(
+        present(typeInfo.getDeclared()),
+        present(typeInfo.getResolvedId()),
+        present(typeInfo.getResolvedKind()),
+        present(typeInfo.getPrimitive()),
+        present(typeInfo.getPrimitiveSource()),
+        typeInfo.getQuantity(),
+        present(typeInfo.getUnit()));
+  }
+
+  private static Symbol.Multiplicity multiplicity(MultiplicityInfo multiplicity) {
+    return new Symbol.Multiplicity(
+        present(multiplicity.getLower()), present(multiplicity.getUpper()));
+  }
+
+  /**
+   * An instance.
+   *
+   * @param instance the generated instance
+   * @return the immutable instance
+   */
+  public static Instance instance(org.openmbee.opensysml.proto.Instance instance) {
+    Map<String, Instance.FeatureValue> featureValues = new LinkedHashMap<>();
+    for (Map.Entry<String, FeatureValue> entry : instance.getFeatureValuesMap().entrySet()) {
+      FeatureValue featureValue = entry.getValue();
+      List<Value> values = new ArrayList<>(featureValue.getValuesCount());
+      for (org.openmbee.opensysml.proto.Value each : featureValue.getValuesList()) {
+        values.add(readable(each));
+      }
+      featureValues.put(
+          entry.getKey(),
+          new Instance.FeatureValue(
+              featureValue.getFeatureName(),
+              featureValue.hasValue()
+                  ? Optional.of(readable(featureValue.getValue()))
+                  : Optional.<Value>empty(),
+              values,
+              featureValue.getMaterialized(),
+              present(featureValue.getError())));
+    }
+    return new Instance(instance.getId(), instance.getTypeSymbolId(), featureValues);
+  }
+
+  /**
+   * What an instantiation built.
+   *
+   * @param response the generated answer
+   * @return the immutable instantiation
+   */
+  public static Instantiation instantiation(InstantiateResponse response) {
+    List<Instance> reachable = new ArrayList<>(response.getInstancesCount());
+    for (org.openmbee.opensysml.proto.Instance instance : response.getInstancesList()) {
+      reachable.add(instance(instance));
+    }
+    return new Instantiation(
+        instance(response.getInstance()), reachable, diagnostics(response.getDiagnosticsList()));
+  }
+
+  /**
+   * A string field, absent when it holds its default.
+   *
+   * @param field the field value
+   * @return the string, or empty when it is empty
+   */
+  public static Optional<String> present(String field) {
+    return field.isEmpty() ? Optional.empty() : Optional.of(field);
+  }
+
+  /**
+   * A value as a request carries it.
+   *
+   * @param value the immutable value
+   * @return the generated value
+   */
+  public static org.openmbee.opensysml.proto.Value proto(Value value) {
+    org.openmbee.opensysml.proto.Value.Builder builder =
+        org.openmbee.opensysml.proto.Value.newBuilder();
+    if (value instanceof Value.IntegerValue integral) {
+      builder.setIntValue(integral.value());
+    } else if (value instanceof Value.RealValue real) {
+      builder.setRealValue(real.value());
+    } else if (value instanceof Value.ComplexValue complex) {
+      builder.setComplex(
+          org.openmbee.opensysml.proto.Complex.newBuilder()
+              .setReal(complex.real())
+              .setImaginary(complex.imaginary()));
+    } else if (value instanceof Value.BooleanValue flag) {
+      builder.setBoolValue(flag.value());
+    } else if (value instanceof Value.StringValue text) {
+      builder.setStringValue(text.value());
+    } else if (value instanceof Value.InstanceReference reference) {
+      builder.setInstanceId(reference.instanceId());
+    } else if (value instanceof Value.Sequence sequence) {
+      ValueSequence.Builder elements = ValueSequence.newBuilder();
+      sequence.elements().forEach(element -> elements.addElements(proto(element)));
+      builder.setSequence(elements);
+    } else if (value instanceof Value.NullValue) {
+      builder.setNull("");
+    } else if (value instanceof Value.UnsetValue) {
+      builder.setUnset(true);
+    } else if (value instanceof Value.UndeterminedValue undetermined) {
+      builder.setUndetermined(
+          Undetermined.newBuilder()
+              .setReason(undetermined.reason())
+              .setCount(
+                  MultiplicityInfo.newBuilder()
+                      .setLower(undetermined.countLower())
+                      .setUpper(undetermined.countUpper())));
+    } else if (value instanceof Value.InfinityValue) {
+      builder.setInfinity(true);
+    } else if (value instanceof Value.QuantityValue quantity) {
+      builder.setQuantity(proto(quantity.quantity()));
+    } else if (value instanceof Value.EnumerationValue literal) {
+      builder.setEnumLiteral(proto(literal.literal()));
+    } else if (value instanceof Value.ArrayValue array) {
+      org.openmbee.opensysml.proto.Array.Builder elements =
+          org.openmbee.opensysml.proto.Array.newBuilder().addAllDimensions(array.dimensions());
+      array.elements().forEach(element -> elements.addElements(proto(element)));
+      builder.setArray(elements);
+    } else if (value instanceof Value.VectorValue vector) {
+      org.openmbee.opensysml.proto.Vector.Builder components =
+          org.openmbee.opensysml.proto.Vector.newBuilder();
+      vector.components().forEach(component -> components.addComponents(proto(component)));
+      builder.setVector(components);
+    } else if (value instanceof Value.VectorQuantityValue vector) {
+      org.openmbee.opensysml.proto.VectorQuantity.Builder components =
+          org.openmbee.opensysml.proto.VectorQuantity.newBuilder();
+      vector.components().forEach(component -> components.addComponents(proto(component)));
+      builder.setVectorQuantity(components);
+    } else if (value instanceof Value.MeasurementRefValue ref) {
+      org.openmbee.opensysml.proto.MeasurementRef.Builder reference =
+          org.openmbee.opensysml.proto.MeasurementRef.newBuilder()
+              .setUnit(ref.unit())
+              .setUnitTerm(proto(ref.reduction()));
+      ref.unitId().ifPresent(reference::setUnitId);
+      builder.setMeasurementRef(reference);
+    } else if (value instanceof Value.FunctionValue function) {
+      builder.setFunction(
+          org.openmbee.opensysml.proto.Function.newBuilder()
+              .setCalcId(function.calcId())
+              .setSelfId(function.selfId().orElse(0L)));
+    } else if (value instanceof Value.SetValue set) {
+      ValueSet.Builder elements = ValueSet.newBuilder();
+      set.elements().forEach(element -> elements.addElements(proto(element)));
+      builder.setSet(elements);
+    } else if (value instanceof Value.TensorQuantityValue tensor) {
+      TensorQuantity.Builder components =
+          TensorQuantity.newBuilder().addAllDimensions(tensor.dimensions());
+      tensor.components().forEach(component -> components.addComponents(proto(component)));
+      builder.setTensorQuantity(components);
+    } else if (value instanceof Value.MetaobjectValue metaobject) {
+      builder.setMetaobject(
+          org.openmbee.opensysml.proto.Metaobject.newBuilder()
+              .setElementId(metaobject.elementId())
+              .setMetaclassId(metaobject.metaclassId()));
+    } else {
+      throw new IllegalArgumentException("no wire form for " + value.getClass().getName());
+    }
+    return builder.build();
+  }
+
+  /**
+   * Values by name, as a request's map carries them.
+   *
+   * @param values the immutable values by name
+   * @return the generated values by name
+   */
+  public static Map<String, org.openmbee.opensysml.proto.Value> protos(Map<String, Value> values) {
+    Map<String, org.openmbee.opensysml.proto.Value> out = new LinkedHashMap<>();
+    values.forEach((name, value) -> out.put(name, proto(value)));
+    return out;
+  }
+
+  /**
+   * Values in order, as a request's list carries them.
+   *
+   * @param values the immutable values
+   * @return the generated values, in order
+   */
+  public static List<org.openmbee.opensysml.proto.Value> protos(List<Value> values) {
+    return values.stream().map(Protos::proto).toList();
+  }
+
+  private static org.openmbee.opensysml.proto.Quantity proto(Quantity quantity) {
+    org.openmbee.opensysml.proto.Quantity.Builder builder =
+        org.openmbee.opensysml.proto.Quantity.newBuilder();
+    if (quantity.magnitude() instanceof Long integral) {
+      builder.setIntMagnitude(integral);
+    } else {
+      builder.setRealMagnitude(quantity.magnitude().doubleValue());
+    }
+    quantity.unit().ifPresent(builder::setUnit);
+    quantity.reduction().ifPresent(reduction -> builder.setUnitTerm(proto(reduction)));
+    return builder.build();
+  }
+
+  private static UnitTerm proto(Quantity.UnitTerm reduction) {
+    UnitTerm.Builder term =
+        UnitTerm.newBuilder()
+            .setScaleNum(reduction.scaleNumerator())
+            .setScaleDen(reduction.scaleDenominator());
+    for (Quantity.UnitFactor factor : reduction.factors()) {
+      term.addFactors(
+          UnitFactor.newBuilder().setUnitId(factor.unitId()).setExponent(factor.exponent()));
+    }
+    return term.build();
+  }
+
+  private static org.openmbee.opensysml.proto.EnumLiteral proto(EnumLiteral literal) {
+    org.openmbee.opensysml.proto.EnumLiteral.Builder builder =
+        org.openmbee.opensysml.proto.EnumLiteral.newBuilder()
+            .setLiteralId(literal.literalId())
+            .setEnumerationId(literal.enumerationId())
+            .setName(literal.name());
+    literal.value().ifPresent(value -> builder.setValue(proto(value)));
+    return builder.build();
+  }
+
+  /**
+   * A query as a request carries it.
+   *
+   * @param query the immutable query
+   * @return the generated query
+   */
+  public static org.openmbee.opensysml.proto.Query proto(Query query) {
+    org.openmbee.opensysml.proto.Query.Builder builder =
+        org.openmbee.opensysml.proto.Query.newBuilder()
+            .addAllScope(query.scope())
+            .addAllSelect(query.select());
+    query.where().ifPresent(where -> builder.setWhere(proto(where)));
+    return builder.build();
+  }
+
+  private static Constraint proto(Condition condition) {
+    Constraint.Builder builder = Constraint.newBuilder();
+    if (condition instanceof Condition.Comparison comparison) {
+      builder.setPrimitive(
+          PrimitiveConstraint.newBuilder()
+              .setInverse(comparison.inverse())
+              .setProperty(comparison.property())
+              .setOperator(proto(comparison.operator()))
+              .addAllValue(comparison.values()));
+    } else if (condition instanceof Condition.Combination combination) {
+      CompositeConstraint.Builder composite =
+          CompositeConstraint.newBuilder().setOperator(proto(combination.operator()));
+      combination.conditions().forEach(each -> composite.addConstraint(proto(each)));
+      builder.setComposite(composite);
+    }
+    return builder.build();
+  }
+
+  private static PrimitiveOperator proto(Condition.Comparison.Operator operator) {
+    return switch (operator) {
+      case EQUAL -> PrimitiveOperator.PRIMITIVE_OPERATOR_EQUAL;
+      case GREATER -> PrimitiveOperator.PRIMITIVE_OPERATOR_GREATER;
+      case LESS -> PrimitiveOperator.PRIMITIVE_OPERATOR_LESS;
+    };
+  }
+
+  private static CompositeOperator proto(Condition.Combination.Operator operator) {
+    return switch (operator) {
+      case AND -> CompositeOperator.COMPOSITE_OPERATOR_AND;
+      case OR -> CompositeOperator.COMPOSITE_OPERATOR_OR;
+    };
+  }
+
+  /**
+   * The elements a query selected.
+   *
+   * @param elements the generated elements
+   * @return immutable elements, in order
+   */
+  public static List<QueryElement> queryElements(List<QueryResultElement> elements) {
+    return elements.stream()
+        .map(
+            element ->
+                new QueryElement(element.getId(), element.getType(), element.getPropertiesMap()))
+        .toList();
+  }
+
+  /**
+   * A failure reason.
+   *
+   * @param reason the generated reason
+   * @return the matching reason, {@link FailureReason#UNKNOWN} for one this release does not know
+   */
+  public static FailureReason failureReason(org.openmbee.opensysml.proto.FailureReason reason) {
+    return switch (reason) {
+      case FAILURE_REASON_UNSPECIFIED -> FailureReason.UNSPECIFIED;
+      case FAILURE_REASON_EVALUATION -> FailureReason.EVALUATION;
+      case FAILURE_REASON_WRONG_KIND -> FailureReason.WRONG_KIND;
+      case FAILURE_REASON_AMBIGUOUS_SUBJECT -> FailureReason.AMBIGUOUS_SUBJECT;
+      case UNRECOGNIZED -> FailureReason.UNKNOWN;
+    };
+  }
+
+  /**
+   * The standing of an answer.
+   *
+   * @param engine the engine field
+   * @param strength the strength field
+   * @param bounds the bounds
+   * @return the immutable standing
+   */
+  public static Standing standing(String engine, String strength, List<Bound> bounds) {
+    List<Standing.Bound> out = new ArrayList<>(bounds.size());
+    for (Bound bound : bounds) {
+      out.add(new Standing.Bound(bound.getName(), bound.getLimit(), bound.getReached()));
+    }
+    return new Standing(engine, strength, out);
+  }
+
+  /**
+   * A verdict.
+   *
+   * @param verdict the generated verdict
+   * @return the immutable verdict
+   */
+  public static Verdict verdict(org.openmbee.opensysml.proto.Verdict verdict) {
+    return new Verdict(
+        verdict.getKind(),
+        present(verdict.getElementId()),
+        verdict.getElement(),
+        verdict.getHolds(),
+        present(verdict.getCondition()),
+        verdict.getInstanceId() == 0 ? Optional.empty() : Optional.of(verdict.getInstanceId()),
+        present(verdict.getInstanceTypeId()),
+        present(verdict.getError()),
+        failureReason(verdict.getFailureReason()),
+        present(verdict.getRequirementId()),
+        present(verdict.getInstancePath()),
+        standing(verdict.getEngine(), verdict.getStrength(), verdict.getBoundsList()));
+  }
+
+  /**
+   * Verdicts.
+   *
+   * @param verdicts the generated verdicts
+   * @return immutable verdicts, in order
+   */
+  public static List<Verdict> verdicts(List<org.openmbee.opensysml.proto.Verdict> verdicts) {
+    return verdicts.stream().map(Protos::verdict).toList();
+  }
+
+  /**
+   * The body verdicts of verification cases.
+   *
+   * @param verdicts the generated verdicts
+   * @return immutable verdicts, in order
+   */
+  public static List<VerificationVerdict> verificationVerdicts(
+      List<org.openmbee.opensysml.proto.VerificationVerdict> verdicts) {
+    return verdicts.stream()
+        .map(
+            verdict ->
+                new VerificationVerdict(
+                    verdict.getCaseId(),
+                    verdict.getKind(),
+                    present(verdict.getDetail()),
+                    verdict.getSubcase(),
+                    present(verdict.getRequirementId())))
+        .toList();
+  }
+
+  /**
+   * Instances.
+   *
+   * @param instances the generated instances
+   * @return immutable instances, in order
+   */
+  public static List<Instance> instances(List<org.openmbee.opensysml.proto.Instance> instances) {
+    return instances.stream().map(Protos::instance).toList();
+  }
+
+  /**
+   * A constraint's verification.
+   *
+   * @param response the generated answer
+   * @return the immutable verification
+   */
+  public static Verification verification(VerifyConstraintResponse response) {
+    return new Verification(
+        verdict(response.getVerdict()),
+        List.of(),
+        instances(response.getInstancesList()),
+        diagnostics(response.getDiagnosticsList()));
+  }
+
+  /**
+   * A requirement's verification.
+   *
+   * @param response the generated answer
+   * @return the immutable verification
+   */
+  public static Verification verification(VerifyRequirementResponse response) {
+    return new Verification(
+        verdict(response.getVerdict()),
+        verificationVerdicts(response.getVerificationVerdictsList()),
+        instances(response.getInstancesList()),
+        diagnostics(response.getDiagnosticsList()));
+  }
+
+  /**
+   * The satisfaction assertions' verdicts.
+   *
+   * @param response the generated answer
+   * @return the immutable satisfaction
+   */
+  public static Satisfaction satisfaction(VerifySatisfactionResponse response) {
+    return new Satisfaction(
+        verdicts(response.getVerdictsList()),
+        verificationVerdicts(response.getVerificationVerdictsList()),
+        instances(response.getInstancesList()),
+        diagnostics(response.getDiagnosticsList()));
+  }
+
+  /**
+   * An object's validation.
+   *
+   * @param response the generated answer
+   * @return the immutable validation
+   */
+  public static Validation validation(ValidateInstanceResponse response) {
+    return new Validation(
+        verdict(response.getSummary()),
+        verdicts(response.getVerdictsList()),
+        verificationVerdicts(response.getVerificationVerdictsList()),
+        instances(response.getInstancesList()),
+        diagnostics(response.getDiagnosticsList()),
+        response.getBounded());
+  }
+
+  /**
+   * Values by name, read from a request's or an answer's map.
+   *
+   * @param values the generated values by name
+   * @return immutable values by name
+   */
+  public static Map<String, Value> values(Map<String, org.openmbee.opensysml.proto.Value> values) {
+    Map<String, Value> out = new LinkedHashMap<>();
+    values.forEach((name, value) -> out.put(name, readable(value)));
+    return out;
+  }
+
+  /**
+   * Named outputs, in the order reported.
+   *
+   * @param outputs the generated outputs
+   * @return immutable values by name
+   */
+  public static Map<String, Value> outputs(List<CalcOutput> outputs) {
+    Map<String, Value> out = new LinkedHashMap<>();
+    for (CalcOutput output : outputs) {
+      out.put(output.getName(), readable(output.getValue()));
+    }
+    return out;
+  }
+
+  /**
+   * What a calc computed.
+   *
+   * @param response the generated answer
+   * @return the immutable calculation
+   */
+  public static Calculation calculation(
+      org.openmbee.opensysml.proto.EvaluateCalcResponse response) {
+    return new Calculation(
+        response.hasResult() ? Optional.of(readable(response.getResult())) : Optional.empty(),
+        outputs(response.getOutputsList()),
+        diagnostics(response.getDiagnosticsList()),
+        standing(response.getEngine(), response.getStrength(), response.getBoundsList()));
+  }
+
+  /**
+   * What an analysis case's run produced.
+   *
+   * @param response the generated answer
+   * @return the immutable analysis
+   */
+  public static Analysis analysis(RunAnalysisResponse response) {
+    List<CaseEvaluation> evaluations = new ArrayList<>(response.getEvaluationsCount());
+    for (org.openmbee.opensysml.proto.CaseEvaluation evaluation : response.getEvaluationsList()) {
+      List<Value> arguments = new ArrayList<>(evaluation.getArgumentsCount());
+      for (org.openmbee.opensysml.proto.Value argument : evaluation.getArgumentsList()) {
+        arguments.add(readable(argument));
+      }
+      evaluations.add(
+          new CaseEvaluation(
+              evaluation.getFunctionId(),
+              arguments,
+              evaluation.hasResult() ? Optional.of(readable(evaluation.getResult())) : Optional.empty(),
+              present(evaluation.getError()),
+              evaluation.getSelected(),
+              evaluation.getTied()));
+    }
+    return new Analysis(
+        outputs(response.getOutputsList()),
+        verdicts(response.getVerdictsList()),
+        verificationVerdicts(response.getVerificationVerdictsList()),
+        evaluations,
+        instances(response.getInstancesList()),
+        diagnostics(response.getDiagnosticsList()),
+        standing(response.getEngine(), response.getStrength(), response.getBoundsList()));
+  }
+
+  /**
+   * What an action's run produced.
+   *
+   * @param response the generated answer
+   * @param finalTimeReported whether the service reports a run's final time
+   * @return the immutable run
+   */
+  public static ActionRun actionRun(ExecuteActionResponse response, boolean finalTimeReported) {
+    return new ActionRun(
+        values(response.getOutputsMap()),
+        finalTimeReported ? OptionalDouble.of(response.getFinalTime()) : OptionalDouble.empty(),
+        diagnostics(response.getDiagnosticsList()));
+  }
+
+  /**
+   * What a state machine's run produced.
+   *
+   * @param response the generated answer
+   * @param finalTimeReported whether the service reports a run's final time
+   * @return the immutable run
+   */
+  public static StateRun stateRun(ExecuteStateResponse response, boolean finalTimeReported) {
+    return new StateRun(
+        response.getStatesVisitedList(),
+        values(response.getFinalContextMap()),
+        finalTimeReported ? OptionalDouble.of(response.getFinalTime()) : OptionalDouble.empty(),
+        diagnostics(response.getDiagnosticsList()));
+  }
+
+  /**
+   * What an exploration reached.
+   *
+   * @param outcomes the generated outcomes
+   * @param status how the search ended
+   * @return the immutable exploration
+   */
+  public static Exploration exploration(
+      List<org.openmbee.opensysml.proto.Outcome> outcomes, ExplorationStatus status) {
+    List<Outcome> out = new ArrayList<>(outcomes.size());
+    for (org.openmbee.opensysml.proto.Outcome outcome : outcomes) {
+      out.add(
+          new Outcome(
+              values(outcome.getOutputsMap()),
+              present(outcome.getFinalState()),
+              outcome.getStatesVisitedList(),
+              present(outcome.getError()),
+              outcome.getLinearizations(),
+              outcome.getProbability(),
+              outcome.getWitnessList(),
+              diagnostics(outcome.getDiagnosticsList())));
+    }
+    return new Exploration(
+        out,
+        status.getComplete(),
+        status.getRuns(),
+        status.getBudgetsHitList(),
+        status.getRunsBudget(),
+        status.getDepthBudget(),
+        status.getProbabilitiesLowerBound());
+  }
+
+  /**
+   * The engines a service answers with.
+   *
+   * @param engines the generated descriptions
+   * @return immutable descriptions, in order
+   */
+  public static List<EngineInfo> engines(List<org.openmbee.opensysml.proto.EngineInfo> engines) {
+    return engines.stream()
+        .map(
+            engine ->
+                new EngineInfo(
+                    engine.getName(),
+                    engine.getAuthority(),
+                    engine.getAnswersList(),
+                    engine.getBoundsList(),
+                    engine.getProcess(),
+                    engine.getProcessFound(),
+                    engine.getReady(),
+                    engine.getUnavailable(),
+                    engine.getKind(),
+                    engine.getProtocol(),
+                    engine.getSource(),
+                    engine.getCommand(),
+                    engine.getVersion(),
+                    engine.getServed()))
+        .toList();
+  }
+
+  /**
+   * A source document as a parse request carries it.
+   *
+   * @param document the immutable document
+   * @return the generated document
+   */
+  public static org.openmbee.opensysml.proto.SourceDocument proto(SourceDocument document) {
+    org.openmbee.opensysml.proto.SourceDocument.Builder builder =
+        org.openmbee.opensysml.proto.SourceDocument.newBuilder();
+    document.file().ifPresent(file -> builder.setFilePath(file.toString()));
+    document.content().ifPresent(builder::setContent);
+    document.name().ifPresent(builder::setName);
+    document.language().ifPresent(language -> builder.setLanguage(language.wireName()));
+    return builder.build();
+  }
+
+  /**
+   * One edit as an apply-edits request carries it.
+   *
+   * @param edit the immutable edit
+   * @return the generated operation
+   */
+  public static org.openmbee.opensysml.proto.EditOperation proto(Edit edit) {
+    org.openmbee.opensysml.proto.EditOperation.Builder builder =
+        org.openmbee.opensysml.proto.EditOperation.newBuilder();
+    if (edit instanceof Edit.SetValue setValue) {
+      builder.setSetValue(
+          org.openmbee.opensysml.proto.SetValueEdit.newBuilder()
+              .setTarget(setValue.target())
+              .setValue(setValue.value()));
+    } else if (edit instanceof Edit.Rename rename) {
+      builder.setRename(
+          org.openmbee.opensysml.proto.RenameEdit.newBuilder()
+              .setTarget(rename.target())
+              .setNewName(rename.newName()));
+    } else if (edit instanceof Edit.AddMember addMember) {
+      org.openmbee.opensysml.proto.AddMemberEdit.Builder add =
+          org.openmbee.opensysml.proto.AddMemberEdit.newBuilder()
+              .setOwner(addMember.owner())
+              .setKind(addMember.kind())
+              .setName(addMember.name());
+      addMember.type().ifPresent(add::setType);
+      addMember.multiplicity().ifPresent(add::setMultiplicity);
+      addMember.value().ifPresent(add::setValue);
+      add.addAllSpecializes(addMember.specializes());
+      builder.setAddMember(add);
+    } else if (edit instanceof Edit.Delete delete) {
+      builder.setDelete(
+          org.openmbee.opensysml.proto.DeleteEdit.newBuilder()
+              .setTarget(delete.target())
+              .setCascade(delete.cascade()));
+    } else if (edit instanceof Edit.Move move) {
+      builder.setMove(
+          org.openmbee.opensysml.proto.MoveEdit.newBuilder()
+              .setTarget(move.target())
+              .setOwner(move.owner()));
+    }
+    return builder.build();
+  }
+
+  /**
+   * Edits as an apply-edits request carries them.
+   *
+   * @param edits the immutable edits
+   * @return the generated operations, in order
+   */
+  public static List<org.openmbee.opensysml.proto.EditOperation> edits(List<Edit> edits) {
+    return edits.stream().map(Protos::proto).toList();
+  }
+
+  /**
+   * A conversion's answer.
+   *
+   * @param response the generated answer
+   * @return the immutable conversion
+   */
+  public static Conversion conversion(ConvertResponse response) {
+    return new Conversion(
+        response.getContent(),
+        response.getFromFormat(),
+        response.getToFormat(),
+        diagnostics(response.getDiagnosticsList()),
+        response.getExperimental(),
+        response.getExperimentalNotice());
+  }
+
+  /**
+   * An edit batch's answer.
+   *
+   * @param response the generated answer
+   * @return the immutable result
+   */
+  public static EditResult editResult(ApplyEditsResponse response) {
+    List<AppliedEdit> applied = new ArrayList<>(response.getAppliedCount());
+    for (org.openmbee.opensysml.proto.AppliedEdit edit : response.getAppliedList()) {
+      applied.add(
+          new AppliedEdit(
+              edit.getOperationIndex(),
+              edit.getTarget(),
+              edit.getOffset(),
+              edit.getLength(),
+              edit.getOldText(),
+              edit.getNewText(),
+              edit.getDocument()));
+    }
+    List<EditedDocument> documents = new ArrayList<>(response.getDocumentsCount());
+    for (org.openmbee.opensysml.proto.EditedDocument document : response.getDocumentsList()) {
+      documents.add(new EditedDocument(document.getName(), document.getContent()));
+    }
+    return new EditResult(
+        response.getContent(),
+        applied,
+        documents,
+        diagnostics(response.getDiagnosticsList()));
+  }
+
+  /**
+   * An edit refusal's kind.
+   *
+   * @param failure the generated kind
+   * @return the matching kind, {@link EditFailure#UNRECOGNIZED} for one this release does not know
+   */
+  public static EditFailure editFailure(org.openmbee.opensysml.proto.EditFailure failure) {
+    return switch (failure) {
+      case EDIT_FAILURE_UNSPECIFIED -> EditFailure.UNSPECIFIED;
+      case EDIT_FAILURE_NO_OPERATIONS -> EditFailure.NO_OPERATIONS;
+      case EDIT_FAILURE_UNKNOWN_TARGET -> EditFailure.UNKNOWN_TARGET;
+      case EDIT_FAILURE_AMBIGUOUS_TARGET -> EditFailure.AMBIGUOUS_TARGET;
+      case EDIT_FAILURE_NOT_VALUED -> EditFailure.NOT_VALUED;
+      case EDIT_FAILURE_INVALID_VALUE -> EditFailure.INVALID_VALUE;
+      case EDIT_FAILURE_INVALID_NAME -> EditFailure.INVALID_NAME;
+      case EDIT_FAILURE_NOT_NAMED -> EditFailure.NOT_NAMED;
+      case EDIT_FAILURE_RENAME_REFERENCED -> EditFailure.RENAME_REFERENCED;
+      case EDIT_FAILURE_OVERLAPPING_EDITS -> EditFailure.OVERLAPPING_EDITS;
+      case EDIT_FAILURE_RESULT_INVALID -> EditFailure.RESULT_INVALID;
+      case EDIT_FAILURE_OWNER_UNKNOWN -> EditFailure.OWNER_UNKNOWN;
+      case EDIT_FAILURE_OWNER_NOT_NAMESPACE -> EditFailure.OWNER_NOT_NAMESPACE;
+      case EDIT_FAILURE_ILLEGAL_KIND -> EditFailure.ILLEGAL_KIND;
+      case EDIT_FAILURE_MEMBER_NAME_TAKEN -> EditFailure.MEMBER_NAME_TAKEN;
+      case EDIT_FAILURE_DELETE_REFERENCED -> EditFailure.DELETE_REFERENCED;
+      case EDIT_FAILURE_OWNER_INSIDE_TARGET -> EditFailure.OWNER_INSIDE_TARGET;
+      case EDIT_FAILURE_MOVE_REFERENCED -> EditFailure.MOVE_REFERENCED;
+      case EDIT_FAILURE_REFERENCED_ELSEWHERE -> EditFailure.REFERENCED_ELSEWHERE;
+      case UNRECOGNIZED -> EditFailure.UNRECOGNIZED;
+    };
+  }
+
+  /**
+   * An edit refusal's wire name: the enum's for a known kind, {@code EDIT_FAILURE_<n>} for a number
+   * this build has no name for.
+   *
+   * @param failure the generated kind
+   * @param value its number, as {@code getFailureValue()} reports it — {@link
+   *     org.openmbee.opensysml.proto.EditFailure#UNRECOGNIZED} itself carries none
+   * @return its name on the wire
+   */
+  public static String editFailureName(
+      org.openmbee.opensysml.proto.EditFailure failure, int value) {
+    return failure == org.openmbee.opensysml.proto.EditFailure.UNRECOGNIZED
+        ? "EDIT_FAILURE_" + value
+        : failure.name();
+  }
+
+  /**
+   * The referrers of a refused edit.
+   *
+   * @param referrers the generated referrers
+   * @return immutable referrers, in order
+   */
+  public static List<Referrer> referrers(
+      List<org.openmbee.opensysml.proto.Referrer> referrers) {
+    return referrers.stream()
+        .map(referrer -> new Referrer(referrer.getName(), referrer.getDocument()))
+        .toList();
+  }
+
+  /**
+   * A sweep range as a request carries it.
+   *
+   * @param range the immutable range
+   * @return the generated range
+   */
+  public static org.openmbee.opensysml.proto.SweepRange proto(SweepRange range) {
+    org.openmbee.opensysml.proto.SweepRange.Builder builder =
+        org.openmbee.opensysml.proto.SweepRange.newBuilder()
+            .setParameter(range.parameter())
+            .setStart(proto(range.start()))
+            .setEnd(proto(range.end()));
+    range.step().ifPresent(step -> builder.setStep(proto(step)));
+    return builder.build();
+  }
+
+  /**
+   * What a sweep ran.
+   *
+   * @param response the generated answer
+   * @return the immutable table
+   */
+  public static Sweep sweep(RunSweepResponse response) {
+    List<SweepRow> rows = new ArrayList<>(response.getRowsCount());
+    for (org.openmbee.opensysml.proto.SweepRow row : response.getRowsList()) {
+      List<CaseEvaluation> evaluations = new ArrayList<>(row.getEvaluationsCount());
+      for (org.openmbee.opensysml.proto.CaseEvaluation evaluation : row.getEvaluationsList()) {
+        List<Value> arguments = new ArrayList<>(evaluation.getArgumentsCount());
+        for (org.openmbee.opensysml.proto.Value argument : evaluation.getArgumentsList()) {
+          arguments.add(readable(argument));
+        }
+        evaluations.add(
+            new CaseEvaluation(
+                evaluation.getFunctionId(),
+                arguments,
+                evaluation.hasResult()
+                    ? Optional.of(readable(evaluation.getResult()))
+                    : Optional.empty(),
+                present(evaluation.getError()),
+                evaluation.getSelected(),
+                evaluation.getTied()));
+      }
+      rows.add(
+          new SweepRow(
+              outputs(row.getInputsList()),
+              outputs(row.getOutputsList()),
+              verdicts(row.getVerdictsList()),
+              evaluations,
+              Duration.of(row.getElapsedMicros(), ChronoUnit.MICROS),
+              row.getError(),
+              failureReason(row.getFailureReason())));
+    }
+    return new Sweep(
+        rows,
+        response.getParametersList(),
+        response.getSampled(),
+        response.getSeed(),
+        instances(response.getInstancesList()),
+        diagnostics(response.getDiagnosticsList()),
+        standing(response.getEngine(), response.getStrength(), response.getBoundsList()));
+  }
+
+  /**
+   * A document-query value.
+   *
+   * @param value the generated value
+   * @return the immutable value; an {@link DocumentValue.ElementRef} for one naming no kind, which
+   *     is how an anonymous element's bare {@code element_type} reads
+   */
+  public static DocumentValue documentValue(org.openmbee.opensysml.proto.DocumentValue value) {
+    DocumentValue read =
+        switch (value.getKindCase()) {
+          case ELEMENT_ID -> new DocumentValue.ElementRef(value.getElementId(), "");
+          case STRING_VALUE -> new DocumentValue.StringValue(value.getStringValue());
+          case INT_VALUE -> new DocumentValue.IntegerValue(value.getIntValue());
+          case REAL_VALUE -> new DocumentValue.RealValue(value.getRealValue());
+          case BOOL_VALUE -> new DocumentValue.BooleanValue(value.getBoolValue());
+          case INFINITY -> new DocumentValue.InfinityValue();
+          case QUANTITY -> new DocumentValue.QuantityValue(quantity(value.getQuantity()));
+          case VERDICT -> documentVerdict(value.getVerdict());
+          case OBJECT -> objectRef(value.getObject());
+          case STATE -> documentState(value.getState());
+          case EVENT -> documentEvent(value.getEvent());
+          case KIND_NOT_SET ->
+              new DocumentValue.ElementRef("", "");
+        };
+    if (read instanceof DocumentValue.ElementRef element && !value.getElementType().isEmpty()) {
+      return new DocumentValue.ElementRef(element.id(), value.getElementType());
+    }
+    return read;
+  }
+
+  private static DocumentValue.ObjectRef objectRef(
+      org.openmbee.opensysml.proto.DocumentObject object) {
+    return new DocumentValue.ObjectRef(
+        object.getInstanceId(),
+        object.getPath(),
+        object.hasElement()
+            ? Optional.of(elementRef(object.getElement()))
+            : Optional.empty());
+  }
+
+  /** An element value as the reference it names, including an anonymous one's bare type. */
+  private static DocumentValue.ElementRef elementRef(
+      org.openmbee.opensysml.proto.DocumentValue value) {
+    if (value.getKindCase()
+        == org.openmbee.opensysml.proto.DocumentValue.KindCase.ELEMENT_ID) {
+      return new DocumentValue.ElementRef(value.getElementId(), value.getElementType());
+    }
+    return new DocumentValue.ElementRef("", value.getElementType());
+  }
+
+  private static DocumentValue.DocumentVerdict documentVerdict(
+      org.openmbee.opensysml.proto.DocumentVerdict verdict) {
+    return new DocumentValue.DocumentVerdict(
+        verdict.hasAssertion()
+            ? elementRef(verdict.getAssertion())
+            : new DocumentValue.ElementRef("", ""),
+        verdict.getKind(),
+        verdict.getText(),
+        verdict.getPath(),
+        verdict.getVerdict(),
+        verdict.getCondition(),
+        verdict.getReason(),
+        verdict.getVerificationList());
+  }
+
+  private static DocumentValue.DocumentState documentState(
+      org.openmbee.opensysml.proto.DocumentState state) {
+    return new DocumentValue.DocumentState(
+        objectRef(state.getObject()),
+        state.getMachine(),
+        state.getName(),
+        state.getStatePath(),
+        state.hasState() ? Optional.of(elementRef(state.getState())) : Optional.empty(),
+        state.getRegion(),
+        state.getEnclosingList());
+  }
+
+  private static DocumentValue.DocumentEvent documentEvent(
+      org.openmbee.opensysml.proto.DocumentEvent event) {
+    return new DocumentValue.DocumentEvent(
+        event.getKind(),
+        documentValue(event.getTime()),
+        event.getText(),
+        event.hasObject() ? Optional.of(objectRef(event.getObject())) : Optional.empty(),
+        event.getMachine(),
+        event.getState(),
+        event.getFrom(),
+        event.getTo(),
+        event.hasTarget() ? Optional.of(objectRef(event.getTarget())) : Optional.empty(),
+        event.getEvent(),
+        event.getPayloadList(),
+        event.getAlternativesList(),
+        event.getTaken());
+  }
+
+  /**
+   * A document-query value as a request's binding carries it.
+   *
+   * @param value the immutable value
+   * @return the generated value
+   */
+  public static org.openmbee.opensysml.proto.DocumentValue proto(DocumentValue value) {
+    org.openmbee.opensysml.proto.DocumentValue.Builder builder =
+        org.openmbee.opensysml.proto.DocumentValue.newBuilder();
+    if (value instanceof DocumentValue.ElementRef element) {
+      if (!element.id().isEmpty()) {
+        builder.setElementId(element.id());
+      }
+      if (!element.type().isEmpty()) {
+        builder.setElementType(element.type());
+      }
+    } else if (value instanceof DocumentValue.ObjectRef object) {
+      org.openmbee.opensysml.proto.DocumentObject.Builder reference =
+          org.openmbee.opensysml.proto.DocumentObject.newBuilder()
+              .setInstanceId(object.id())
+              .setPath(object.path());
+      object.element().ifPresent(element -> reference.setElement(proto(element)));
+      builder.setObject(reference);
+    } else if (value instanceof DocumentValue.StringValue text) {
+      builder.setStringValue(text.value());
+    } else if (value instanceof DocumentValue.IntegerValue integer) {
+      builder.setIntValue(integer.value());
+    } else if (value instanceof DocumentValue.RealValue real) {
+      builder.setRealValue(real.value());
+    } else if (value instanceof DocumentValue.BooleanValue flag) {
+      builder.setBoolValue(flag.value());
+    } else if (value instanceof DocumentValue.InfinityValue) {
+      builder.setInfinity(true);
+    } else if (value instanceof DocumentValue.QuantityValue quantity) {
+      builder.setQuantity(proto(quantity.quantity()));
+    } else if (value instanceof DocumentValue.DocumentVerdict verdict) {
+      builder.setVerdict(
+          org.openmbee.opensysml.proto.DocumentVerdict.newBuilder()
+              .setAssertion(proto(verdict.assertion()))
+              .setKind(verdict.kind())
+              .setText(verdict.text())
+              .setPath(verdict.path())
+              .setVerdict(verdict.status())
+              .setCondition(verdict.condition())
+              .setReason(verdict.reason())
+              .addAllVerification(verdict.verification()));
+    } else if (value instanceof DocumentValue.DocumentState state) {
+      org.openmbee.opensysml.proto.DocumentState.Builder row =
+          org.openmbee.opensysml.proto.DocumentState.newBuilder()
+              .setObject(protoObject(state.object()))
+              .setMachine(state.machine())
+              .setName(state.name())
+              .setStatePath(state.path())
+              .setRegion(state.region())
+              .addAllEnclosing(state.enclosing());
+      state.state().ifPresent(element -> row.setState(proto(element)));
+      builder.setState(row);
+    } else if (value instanceof DocumentValue.DocumentEvent event) {
+      org.openmbee.opensysml.proto.DocumentEvent.Builder record =
+          org.openmbee.opensysml.proto.DocumentEvent.newBuilder()
+              .setKind(event.kind())
+              .setTime(proto(event.time()))
+              .setText(event.text())
+              .setMachine(event.machine())
+              .setState(event.state())
+              .setFrom(event.from())
+              .setTo(event.to())
+              .setEvent(event.event())
+              .addAllPayload(event.payload())
+              .addAllAlternatives(event.alternatives())
+              .setTaken(event.taken());
+      event.object().ifPresent(object -> record.setObject(protoObject(object)));
+      event.target().ifPresent(target -> record.setTarget(protoObject(target)));
+      builder.setEvent(record);
+    }
+    return builder.build();
+  }
+
+  private static org.openmbee.opensysml.proto.DocumentObject protoObject(
+      DocumentValue.ObjectRef object) {
+    org.openmbee.opensysml.proto.DocumentObject.Builder builder =
+        org.openmbee.opensysml.proto.DocumentObject.newBuilder()
+            .setInstanceId(object.id())
+            .setPath(object.path());
+    object.element().ifPresent(element -> builder.setElement(proto(element)));
+    return builder.build();
+  }
+
+  /**
+   * A document query's answer: its columns, and each row's element read the way the query's kind
+   * reports it.
+   *
+   * @param response the generated answer
+   * @return the immutable result
+   */
+  public static DocumentQueryResult documentQueryResult(RunDocumentQueryResponse response) {
+    List<String> columns = new ArrayList<>(response.getColumnsCount());
+    for (org.openmbee.opensysml.proto.DocumentQueryColumn column : response.getColumnsList()) {
+      columns.add(column.getName());
+    }
+    List<DocumentRow> rows = new ArrayList<>(response.getRowsCount());
+    for (org.openmbee.opensysml.proto.DocumentQueryRow row : response.getRowsList()) {
+      List<List<DocumentValue>> cells = new ArrayList<>(row.getCellsCount());
+      for (org.openmbee.opensysml.proto.DocumentQueryCell cell : row.getCellsList()) {
+        List<DocumentValue> values = new ArrayList<>(cell.getValuesCount());
+        for (org.openmbee.opensysml.proto.DocumentValue value : cell.getValuesList()) {
+          values.add(documentValue(value));
+        }
+        cells.add(values);
+      }
+      rows.add(documentRow(row.getElement(), cells));
+    }
+    return new DocumentQueryResult(columns, rows);
+  }
+
+  private static DocumentRow documentRow(
+      org.openmbee.opensysml.proto.DocumentValue element, List<List<DocumentValue>> cells) {
+    DocumentValue value = documentValue(element);
+    if (value instanceof DocumentValue.DocumentVerdict verdict) {
+      return new DocumentRow(
+          verdict.assertion(), cells, Optional.of(verdict), Optional.empty(),
+          Optional.empty(), Optional.empty());
+    }
+    if (value instanceof DocumentValue.ObjectRef object) {
+      return new DocumentRow(
+          object.element().orElse(new DocumentValue.ElementRef("", "")), cells, Optional.empty(),
+          Optional.of(object), Optional.empty(), Optional.empty());
+    }
+    if (value instanceof DocumentValue.DocumentState state) {
+      return new DocumentRow(
+          state.object().element().orElse(new DocumentValue.ElementRef("", "")), cells,
+          Optional.empty(), Optional.of(state.object()), Optional.of(state), Optional.empty());
+    }
+    if (value instanceof DocumentValue.DocumentEvent event) {
+      return new DocumentRow(
+          event.object()
+              .flatMap(DocumentValue.ObjectRef::element)
+              .orElse(new DocumentValue.ElementRef("", "")),
+          cells, Optional.empty(), event.object(), Optional.empty(), Optional.of(event));
+    }
+    if (value instanceof DocumentValue.ElementRef ref) {
+      return new DocumentRow(
+          ref, cells, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty());
+    }
+    return new DocumentRow(
+        new DocumentValue.ElementRef("", element.getElementType()),
+        cells, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty());
+  }
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ReleaseDigests.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ReleaseDigests.java
similarity index 96%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ReleaseDigests.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ReleaseDigests.java
index ab0b9ddda1..12d37b713b 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ReleaseDigests.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ReleaseDigests.java
@@ -10,7 +10,7 @@
  * The SHA-256 digest this client pins for a release asset, keyed by repository, release tag and
  * asset name.
  *
- * <p>Read from the copy of {@code clients/release-digests.json} shipped in this jar: a table
+ * <p>Read from the copy of {@code client/release-digests.json} shipped in this jar: a table
  * resolved at run time from outside the published artifact would not be a pin.
  */
 public final class ReleaseDigests {
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ReleasePlatform.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ReleasePlatform.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ReleasePlatform.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ReleasePlatform.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ServiceRegistry.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ServiceRegistry.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ServiceRegistry.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/ServiceRegistry.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/SignedManifest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/SignedManifest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/SignedManifest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/SignedManifest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/SigstoreVerifier.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/SigstoreVerifier.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/SigstoreVerifier.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/SigstoreVerifier.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/package-info.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/package-info.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/package-info.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/package-info.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/package-info.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/package-info.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/package-info.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/package-info.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AddMemberEdit.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AddMemberEdit.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AddMemberEdit.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AddMemberEdit.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AddMemberEditOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AddMemberEditOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AddMemberEditOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AddMemberEditOrBuilder.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEdit.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEdit.java
new file mode 100644
index 0000000000..ea4041cd03
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEdit.java
@@ -0,0 +1,1289 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * AppliedEdit is one byte range of the original source that an operation
+ * replaced, so a client can report or locate what changed.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.AppliedEdit}
+ */
+@com.google.protobuf.Generated
+public final class AppliedEdit extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.AppliedEdit)
+    AppliedEditOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "AppliedEdit");
+  }
+  // Use AppliedEdit.newBuilder() to construct.
+  private AppliedEdit(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private AppliedEdit() {
+    target_ = "";
+    oldText_ = "";
+    newText_ = "";
+    document_ = "";
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_AppliedEdit_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_AppliedEdit_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.AppliedEdit.class, org.openmbee.opensysml.proto.AppliedEdit.Builder.class);
+  }
+
+  public static final int OPERATION_INDEX_FIELD_NUMBER = 1;
+  private int operationIndex_ = 0;
+  /**
+   * <pre>
+   * Index of the operation in the request, so an answer maps back to its ask.
+   * </pre>
+   *
+   * <code>int32 operation_index = 1 [json_name = "operationIndex"];</code>
+   * @return The operationIndex.
+   */
+  @java.lang.Override
+  public int getOperationIndex() {
+    return operationIndex_;
+  }
+
+  public static final int TARGET_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object target_ = "";
+  /**
+   * <pre>
+   * element edited, as the request named it
+   * </pre>
+   *
+   * <code>string target = 2 [json_name = "target"];</code>
+   * @return The target.
+   */
+  @java.lang.Override
+  public java.lang.String getTarget() {
+    java.lang.Object ref = target_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      target_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * element edited, as the request named it
+   * </pre>
+   *
+   * <code>string target = 2 [json_name = "target"];</code>
+   * @return The bytes for target.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getTargetBytes() {
+    java.lang.Object ref = target_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      target_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int OFFSET_FIELD_NUMBER = 3;
+  private int offset_ = 0;
+  /**
+   * <pre>
+   * Batch offsets use the original source; sequential offsets use the
+   * intermediate source seen by that operation.
+   * </pre>
+   *
+   * <code>int32 offset = 3 [json_name = "offset"];</code>
+   * @return The offset.
+   */
+  @java.lang.Override
+  public int getOffset() {
+    return offset_;
+  }
+
+  public static final int LENGTH_FIELD_NUMBER = 4;
+  private int length_ = 0;
+  /**
+   * <pre>
+   * bytes replaced; zero for text inserted
+   * </pre>
+   *
+   * <code>int32 length = 4 [json_name = "length"];</code>
+   * @return The length.
+   */
+  @java.lang.Override
+  public int getLength() {
+    return length_;
+  }
+
+  public static final int OLD_TEXT_FIELD_NUMBER = 5;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object oldText_ = "";
+  /**
+   * <pre>
+   * what was there, empty for an insertion
+   * </pre>
+   *
+   * <code>string old_text = 5 [json_name = "oldText"];</code>
+   * @return The oldText.
+   */
+  @java.lang.Override
+  public java.lang.String getOldText() {
+    java.lang.Object ref = oldText_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      oldText_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * what was there, empty for an insertion
+   * </pre>
+   *
+   * <code>string old_text = 5 [json_name = "oldText"];</code>
+   * @return The bytes for oldText.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getOldTextBytes() {
+    java.lang.Object ref = oldText_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      oldText_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int NEW_TEXT_FIELD_NUMBER = 6;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object newText_ = "";
+  /**
+   * <pre>
+   * what was written
+   * </pre>
+   *
+   * <code>string new_text = 6 [json_name = "newText"];</code>
+   * @return The newText.
+   */
+  @java.lang.Override
+  public java.lang.String getNewText() {
+    java.lang.Object ref = newText_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      newText_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * what was written
+   * </pre>
+   *
+   * <code>string new_text = 6 [json_name = "newText"];</code>
+   * @return The bytes for newText.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getNewTextBytes() {
+    java.lang.Object ref = newText_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      newText_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int DOCUMENT_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object document_ = "";
+  /**
+   * <pre>
+   * The document the bytes are in, named as the parse request named it and as
+   * ApplyEditsResponse.documents lists it. Set for a single-document model too.
+   * </pre>
+   *
+   * <code>string document = 7 [json_name = "document"];</code>
+   * @return The document.
+   */
+  @java.lang.Override
+  public java.lang.String getDocument() {
+    java.lang.Object ref = document_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      document_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The document the bytes are in, named as the parse request named it and as
+   * ApplyEditsResponse.documents lists it. Set for a single-document model too.
+   * </pre>
+   *
+   * <code>string document = 7 [json_name = "document"];</code>
+   * @return The bytes for document.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getDocumentBytes() {
+    java.lang.Object ref = document_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      document_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (operationIndex_ != 0) {
+      output.writeInt32(1, operationIndex_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(target_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, target_);
+    }
+    if (offset_ != 0) {
+      output.writeInt32(3, offset_);
+    }
+    if (length_ != 0) {
+      output.writeInt32(4, length_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(oldText_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 5, oldText_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(newText_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 6, newText_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(document_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 7, document_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (operationIndex_ != 0) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeInt32Size(1, operationIndex_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(target_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, target_);
+    }
+    if (offset_ != 0) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeInt32Size(3, offset_);
+    }
+    if (length_ != 0) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeInt32Size(4, length_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(oldText_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(5, oldText_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(newText_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(6, newText_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(document_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(7, document_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.AppliedEdit)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.AppliedEdit other = (org.openmbee.opensysml.proto.AppliedEdit) obj;
+
+    if (getOperationIndex()
+        != other.getOperationIndex()) return false;
+    if (!getTarget()
+        .equals(other.getTarget())) return false;
+    if (getOffset()
+        != other.getOffset()) return false;
+    if (getLength()
+        != other.getLength()) return false;
+    if (!getOldText()
+        .equals(other.getOldText())) return false;
+    if (!getNewText()
+        .equals(other.getNewText())) return false;
+    if (!getDocument()
+        .equals(other.getDocument())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + OPERATION_INDEX_FIELD_NUMBER;
+    hash = (53 * hash) + getOperationIndex();
+    hash = (37 * hash) + TARGET_FIELD_NUMBER;
+    hash = (53 * hash) + getTarget().hashCode();
+    hash = (37 * hash) + OFFSET_FIELD_NUMBER;
+    hash = (53 * hash) + getOffset();
+    hash = (37 * hash) + LENGTH_FIELD_NUMBER;
+    hash = (53 * hash) + getLength();
+    hash = (37 * hash) + OLD_TEXT_FIELD_NUMBER;
+    hash = (53 * hash) + getOldText().hashCode();
+    hash = (37 * hash) + NEW_TEXT_FIELD_NUMBER;
+    hash = (53 * hash) + getNewText().hashCode();
+    hash = (37 * hash) + DOCUMENT_FIELD_NUMBER;
+    hash = (53 * hash) + getDocument().hashCode();
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.AppliedEdit parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.AppliedEdit parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.AppliedEdit prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * AppliedEdit is one byte range of the original source that an operation
+   * replaced, so a client can report or locate what changed.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.AppliedEdit}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.AppliedEdit)
+      org.openmbee.opensysml.proto.AppliedEditOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_AppliedEdit_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_AppliedEdit_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.AppliedEdit.class, org.openmbee.opensysml.proto.AppliedEdit.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.AppliedEdit.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      operationIndex_ = 0;
+      target_ = "";
+      offset_ = 0;
+      length_ = 0;
+      oldText_ = "";
+      newText_ = "";
+      document_ = "";
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_AppliedEdit_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.AppliedEdit getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.AppliedEdit.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.AppliedEdit build() {
+      org.openmbee.opensysml.proto.AppliedEdit result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.AppliedEdit buildPartial() {
+      org.openmbee.opensysml.proto.AppliedEdit result = new org.openmbee.opensysml.proto.AppliedEdit(this);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.AppliedEdit result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.operationIndex_ = operationIndex_;
+      }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.target_ = target_;
+      }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.offset_ = offset_;
+      }
+      if (((from_bitField0_ & 0x00000008) != 0)) {
+        result.length_ = length_;
+      }
+      if (((from_bitField0_ & 0x00000010) != 0)) {
+        result.oldText_ = oldText_;
+      }
+      if (((from_bitField0_ & 0x00000020) != 0)) {
+        result.newText_ = newText_;
+      }
+      if (((from_bitField0_ & 0x00000040) != 0)) {
+        result.document_ = document_;
+      }
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.AppliedEdit) {
+        return mergeFrom((org.openmbee.opensysml.proto.AppliedEdit)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.AppliedEdit other) {
+      if (other == org.openmbee.opensysml.proto.AppliedEdit.getDefaultInstance()) return this;
+      if (other.getOperationIndex() != 0) {
+        setOperationIndex(other.getOperationIndex());
+      }
+      if (!other.getTarget().isEmpty()) {
+        target_ = other.target_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      if (other.getOffset() != 0) {
+        setOffset(other.getOffset());
+      }
+      if (other.getLength() != 0) {
+        setLength(other.getLength());
+      }
+      if (!other.getOldText().isEmpty()) {
+        oldText_ = other.oldText_;
+        bitField0_ |= 0x00000010;
+        onChanged();
+      }
+      if (!other.getNewText().isEmpty()) {
+        newText_ = other.newText_;
+        bitField0_ |= 0x00000020;
+        onChanged();
+      }
+      if (!other.getDocument().isEmpty()) {
+        document_ = other.document_;
+        bitField0_ |= 0x00000040;
+        onChanged();
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 8: {
+              operationIndex_ = input.readInt32();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 8
+            case 18: {
+              target_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            case 24: {
+              offset_ = input.readInt32();
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 24
+            case 32: {
+              length_ = input.readInt32();
+              bitField0_ |= 0x00000008;
+              break;
+            } // case 32
+            case 42: {
+              oldText_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000010;
+              break;
+            } // case 42
+            case 50: {
+              newText_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000020;
+              break;
+            } // case 50
+            case 58: {
+              document_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000040;
+              break;
+            } // case 58
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private int operationIndex_ ;
+    /**
+     * <pre>
+     * Index of the operation in the request, so an answer maps back to its ask.
+     * </pre>
+     *
+     * <code>int32 operation_index = 1 [json_name = "operationIndex"];</code>
+     * @return The operationIndex.
+     */
+    @java.lang.Override
+    public int getOperationIndex() {
+      return operationIndex_;
+    }
+    /**
+     * <pre>
+     * Index of the operation in the request, so an answer maps back to its ask.
+     * </pre>
+     *
+     * <code>int32 operation_index = 1 [json_name = "operationIndex"];</code>
+     * @param value The operationIndex to set.
+     * @return This builder for chaining.
+     */
+    public Builder setOperationIndex(int value) {
+
+      operationIndex_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Index of the operation in the request, so an answer maps back to its ask.
+     * </pre>
+     *
+     * <code>int32 operation_index = 1 [json_name = "operationIndex"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearOperationIndex() {
+      bitField0_ = (bitField0_ & ~0x00000001);
+      operationIndex_ = 0;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object target_ = "";
+    /**
+     * <pre>
+     * element edited, as the request named it
+     * </pre>
+     *
+     * <code>string target = 2 [json_name = "target"];</code>
+     * @return The target.
+     */
+    public java.lang.String getTarget() {
+      java.lang.Object ref = target_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        target_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * element edited, as the request named it
+     * </pre>
+     *
+     * <code>string target = 2 [json_name = "target"];</code>
+     * @return The bytes for target.
+     */
+    public com.google.protobuf.ByteString
+        getTargetBytes() {
+      java.lang.Object ref = target_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        target_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * element edited, as the request named it
+     * </pre>
+     *
+     * <code>string target = 2 [json_name = "target"];</code>
+     * @param value The target to set.
+     * @return This builder for chaining.
+     */
+    public Builder setTarget(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      target_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * element edited, as the request named it
+     * </pre>
+     *
+     * <code>string target = 2 [json_name = "target"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearTarget() {
+      target_ = getDefaultInstance().getTarget();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * element edited, as the request named it
+     * </pre>
+     *
+     * <code>string target = 2 [json_name = "target"];</code>
+     * @param value The bytes for target to set.
+     * @return This builder for chaining.
+     */
+    public Builder setTargetBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      target_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
+    private int offset_ ;
+    /**
+     * <pre>
+     * Batch offsets use the original source; sequential offsets use the
+     * intermediate source seen by that operation.
+     * </pre>
+     *
+     * <code>int32 offset = 3 [json_name = "offset"];</code>
+     * @return The offset.
+     */
+    @java.lang.Override
+    public int getOffset() {
+      return offset_;
+    }
+    /**
+     * <pre>
+     * Batch offsets use the original source; sequential offsets use the
+     * intermediate source seen by that operation.
+     * </pre>
+     *
+     * <code>int32 offset = 3 [json_name = "offset"];</code>
+     * @param value The offset to set.
+     * @return This builder for chaining.
+     */
+    public Builder setOffset(int value) {
+
+      offset_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Batch offsets use the original source; sequential offsets use the
+     * intermediate source seen by that operation.
+     * </pre>
+     *
+     * <code>int32 offset = 3 [json_name = "offset"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearOffset() {
+      bitField0_ = (bitField0_ & ~0x00000004);
+      offset_ = 0;
+      onChanged();
+      return this;
+    }
+
+    private int length_ ;
+    /**
+     * <pre>
+     * bytes replaced; zero for text inserted
+     * </pre>
+     *
+     * <code>int32 length = 4 [json_name = "length"];</code>
+     * @return The length.
+     */
+    @java.lang.Override
+    public int getLength() {
+      return length_;
+    }
+    /**
+     * <pre>
+     * bytes replaced; zero for text inserted
+     * </pre>
+     *
+     * <code>int32 length = 4 [json_name = "length"];</code>
+     * @param value The length to set.
+     * @return This builder for chaining.
+     */
+    public Builder setLength(int value) {
+
+      length_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * bytes replaced; zero for text inserted
+     * </pre>
+     *
+     * <code>int32 length = 4 [json_name = "length"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearLength() {
+      bitField0_ = (bitField0_ & ~0x00000008);
+      length_ = 0;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object oldText_ = "";
+    /**
+     * <pre>
+     * what was there, empty for an insertion
+     * </pre>
+     *
+     * <code>string old_text = 5 [json_name = "oldText"];</code>
+     * @return The oldText.
+     */
+    public java.lang.String getOldText() {
+      java.lang.Object ref = oldText_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        oldText_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * what was there, empty for an insertion
+     * </pre>
+     *
+     * <code>string old_text = 5 [json_name = "oldText"];</code>
+     * @return The bytes for oldText.
+     */
+    public com.google.protobuf.ByteString
+        getOldTextBytes() {
+      java.lang.Object ref = oldText_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        oldText_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * what was there, empty for an insertion
+     * </pre>
+     *
+     * <code>string old_text = 5 [json_name = "oldText"];</code>
+     * @param value The oldText to set.
+     * @return This builder for chaining.
+     */
+    public Builder setOldText(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      oldText_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * what was there, empty for an insertion
+     * </pre>
+     *
+     * <code>string old_text = 5 [json_name = "oldText"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearOldText() {
+      oldText_ = getDefaultInstance().getOldText();
+      bitField0_ = (bitField0_ & ~0x00000010);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * what was there, empty for an insertion
+     * </pre>
+     *
+     * <code>string old_text = 5 [json_name = "oldText"];</code>
+     * @param value The bytes for oldText to set.
+     * @return This builder for chaining.
+     */
+    public Builder setOldTextBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      oldText_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object newText_ = "";
+    /**
+     * <pre>
+     * what was written
+     * </pre>
+     *
+     * <code>string new_text = 6 [json_name = "newText"];</code>
+     * @return The newText.
+     */
+    public java.lang.String getNewText() {
+      java.lang.Object ref = newText_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        newText_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * what was written
+     * </pre>
+     *
+     * <code>string new_text = 6 [json_name = "newText"];</code>
+     * @return The bytes for newText.
+     */
+    public com.google.protobuf.ByteString
+        getNewTextBytes() {
+      java.lang.Object ref = newText_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        newText_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * what was written
+     * </pre>
+     *
+     * <code>string new_text = 6 [json_name = "newText"];</code>
+     * @param value The newText to set.
+     * @return This builder for chaining.
+     */
+    public Builder setNewText(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      newText_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * what was written
+     * </pre>
+     *
+     * <code>string new_text = 6 [json_name = "newText"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearNewText() {
+      newText_ = getDefaultInstance().getNewText();
+      bitField0_ = (bitField0_ & ~0x00000020);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * what was written
+     * </pre>
+     *
+     * <code>string new_text = 6 [json_name = "newText"];</code>
+     * @param value The bytes for newText to set.
+     * @return This builder for chaining.
+     */
+    public Builder setNewTextBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      newText_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object document_ = "";
+    /**
+     * <pre>
+     * The document the bytes are in, named as the parse request named it and as
+     * ApplyEditsResponse.documents lists it. Set for a single-document model too.
+     * </pre>
+     *
+     * <code>string document = 7 [json_name = "document"];</code>
+     * @return The document.
+     */
+    public java.lang.String getDocument() {
+      java.lang.Object ref = document_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        document_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The document the bytes are in, named as the parse request named it and as
+     * ApplyEditsResponse.documents lists it. Set for a single-document model too.
+     * </pre>
+     *
+     * <code>string document = 7 [json_name = "document"];</code>
+     * @return The bytes for document.
+     */
+    public com.google.protobuf.ByteString
+        getDocumentBytes() {
+      java.lang.Object ref = document_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        document_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The document the bytes are in, named as the parse request named it and as
+     * ApplyEditsResponse.documents lists it. Set for a single-document model too.
+     * </pre>
+     *
+     * <code>string document = 7 [json_name = "document"];</code>
+     * @param value The document to set.
+     * @return This builder for chaining.
+     */
+    public Builder setDocument(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      document_ = value;
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The document the bytes are in, named as the parse request named it and as
+     * ApplyEditsResponse.documents lists it. Set for a single-document model too.
+     * </pre>
+     *
+     * <code>string document = 7 [json_name = "document"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearDocument() {
+      document_ = getDefaultInstance().getDocument();
+      bitField0_ = (bitField0_ & ~0x00000040);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The document the bytes are in, named as the parse request named it and as
+     * ApplyEditsResponse.documents lists it. Set for a single-document model too.
+     * </pre>
+     *
+     * <code>string document = 7 [json_name = "document"];</code>
+     * @param value The bytes for document to set.
+     * @return This builder for chaining.
+     */
+    public Builder setDocumentBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      document_ = value;
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.AppliedEdit)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.AppliedEdit)
+  private static final org.openmbee.opensysml.proto.AppliedEdit DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.AppliedEdit();
+  }
+
+  public static org.openmbee.opensysml.proto.AppliedEdit getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<AppliedEdit>
+      PARSER = new com.google.protobuf.AbstractParser<AppliedEdit>() {
+    @java.lang.Override
+    public AppliedEdit parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<AppliedEdit> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<AppliedEdit> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.AppliedEdit getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEditOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEditOrBuilder.java
similarity index 77%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEditOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEditOrBuilder.java
index 74fadda10e..9060d218b3 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEditOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEditOrBuilder.java
@@ -100,4 +100,26 @@ public interface AppliedEditOrBuilder extends
    */
   com.google.protobuf.ByteString
       getNewTextBytes();
+
+  /**
+   * <pre>
+   * The document the bytes are in, named as the parse request named it and as
+   * ApplyEditsResponse.documents lists it. Set for a single-document model too.
+   * </pre>
+   *
+   * <code>string document = 7 [json_name = "document"];</code>
+   * @return The document.
+   */
+  java.lang.String getDocument();
+  /**
+   * <pre>
+   * The document the bytes are in, named as the parse request named it and as
+   * ApplyEditsResponse.documents lists it. Set for a single-document model too.
+   * </pre>
+   *
+   * <code>string document = 7 [json_name = "document"];</code>
+   * @return The bytes for document.
+   */
+  com.google.protobuf.ByteString
+      getDocumentBytes();
 }
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequest.java
new file mode 100644
index 0000000000..22d62fb7c1
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequest.java
@@ -0,0 +1,1304 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * ApplyEditsRequest asks for a model's source with edits applied to it. The
+ * source edited is the one parse read, named by its hash, so an edit is applied
+ * to the model that was inspected. A model of several documents (ParseSources)
+ * is edited as one when the request sets `accept_documents`: the operations
+ * target declarations of the document named by `document`, and a rename or
+ * cascade delete follows references into every other document of the model,
+ * rewriting those too. `document` and `accept_documents` are advertised as the
+ * "edit_documents" capability: a service without it edits a model of one
+ * document alone and answers `content` alone, so a client checks it before
+ * naming a document or reading `documents`.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.ApplyEditsRequest}
+ */
+@com.google.protobuf.Generated
+public final class ApplyEditsRequest extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.ApplyEditsRequest)
+    ApplyEditsRequestOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "ApplyEditsRequest");
+  }
+  // Use ApplyEditsRequest.newBuilder() to construct.
+  private ApplyEditsRequest(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private ApplyEditsRequest() {
+    modelHash_ = "";
+    operations_ = java.util.Collections.emptyList();
+    document_ = "";
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsRequest_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsRequest_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.ApplyEditsRequest.class, org.openmbee.opensysml.proto.ApplyEditsRequest.Builder.class);
+  }
+
+  public static final int MODEL_HASH_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object modelHash_ = "";
+  /**
+   * <pre>
+   * from a ParseFile or ParseSources response
+   * </pre>
+   *
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The modelHash.
+   */
+  @java.lang.Override
+  public java.lang.String getModelHash() {
+    java.lang.Object ref = modelHash_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      modelHash_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * from a ParseFile or ParseSources response
+   * </pre>
+   *
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The bytes for modelHash.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getModelHashBytes() {
+    java.lang.Object ref = modelHash_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      modelHash_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int OPERATIONS_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.EditOperation> operations_;
+  /**
+   * <pre>
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.EditOperation> getOperationsList() {
+    return operations_;
+  }
+  /**
+   * <pre>
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.EditOperationOrBuilder> 
+      getOperationsOrBuilderList() {
+    return operations_;
+  }
+  /**
+   * <pre>
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+   */
+  @java.lang.Override
+  public int getOperationsCount() {
+    return operations_.size();
+  }
+  /**
+   * <pre>
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.EditOperation getOperations(int index) {
+    return operations_.get(index);
+  }
+  /**
+   * <pre>
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.EditOperationOrBuilder getOperationsOrBuilder(
+      int index) {
+    return operations_.get(index);
+  }
+
+  public static final int DOCUMENT_FIELD_NUMBER = 3;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object document_ = "";
+  /**
+   * <pre>
+   * The document whose declarations the operations target, named as the parse
+   * request named it; empty names the model's first document, which is the
+   * only one of a ParseFile model. An operation targeting a declaration of
+   * another document is refused as an unknown target, naming that document. A
+   * name no document of the model has fails the call as an invalid argument.
+   * </pre>
+   *
+   * <code>string document = 3 [json_name = "document"];</code>
+   * @return The document.
+   */
+  @java.lang.Override
+  public java.lang.String getDocument() {
+    java.lang.Object ref = document_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      document_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The document whose declarations the operations target, named as the parse
+   * request named it; empty names the model's first document, which is the
+   * only one of a ParseFile model. An operation targeting a declaration of
+   * another document is refused as an unknown target, naming that document. A
+   * name no document of the model has fails the call as an invalid argument.
+   * </pre>
+   *
+   * <code>string document = 3 [json_name = "document"];</code>
+   * @return The bytes for document.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getDocumentBytes() {
+    java.lang.Object ref = document_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      document_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int ACCEPT_DOCUMENTS_FIELD_NUMBER = 4;
+  private boolean acceptDocuments_ = false;
+  /**
+   * <pre>
+   * Whether the client reads the response's `documents`. A model of several documents is
+   * edited only when set; unset, such a model is refused as a failed precondition, as before.
+   * </pre>
+   *
+   * <code>bool accept_documents = 4 [json_name = "acceptDocuments"];</code>
+   * @return The acceptDocuments.
+   */
+  @java.lang.Override
+  public boolean getAcceptDocuments() {
+    return acceptDocuments_;
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(modelHash_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 1, modelHash_);
+    }
+    for (int i = 0; i < operations_.size(); i++) {
+      output.writeMessage(2, operations_.get(i));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(document_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 3, document_);
+    }
+    if (acceptDocuments_ != false) {
+      output.writeBool(4, acceptDocuments_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(modelHash_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, modelHash_);
+    }
+    for (int i = 0; i < operations_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(2, operations_.get(i));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(document_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(3, document_);
+    }
+    if (acceptDocuments_ != false) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeBoolSize(4, acceptDocuments_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.ApplyEditsRequest)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.ApplyEditsRequest other = (org.openmbee.opensysml.proto.ApplyEditsRequest) obj;
+
+    if (!getModelHash()
+        .equals(other.getModelHash())) return false;
+    if (!getOperationsList()
+        .equals(other.getOperationsList())) return false;
+    if (!getDocument()
+        .equals(other.getDocument())) return false;
+    if (getAcceptDocuments()
+        != other.getAcceptDocuments()) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + MODEL_HASH_FIELD_NUMBER;
+    hash = (53 * hash) + getModelHash().hashCode();
+    if (getOperationsCount() > 0) {
+      hash = (37 * hash) + OPERATIONS_FIELD_NUMBER;
+      hash = (53 * hash) + getOperationsList().hashCode();
+    }
+    hash = (37 * hash) + DOCUMENT_FIELD_NUMBER;
+    hash = (53 * hash) + getDocument().hashCode();
+    hash = (37 * hash) + ACCEPT_DOCUMENTS_FIELD_NUMBER;
+    hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean(
+        getAcceptDocuments());
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.ApplyEditsRequest prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * ApplyEditsRequest asks for a model's source with edits applied to it. The
+   * source edited is the one parse read, named by its hash, so an edit is applied
+   * to the model that was inspected. A model of several documents (ParseSources)
+   * is edited as one when the request sets `accept_documents`: the operations
+   * target declarations of the document named by `document`, and a rename or
+   * cascade delete follows references into every other document of the model,
+   * rewriting those too. `document` and `accept_documents` are advertised as the
+   * "edit_documents" capability: a service without it edits a model of one
+   * document alone and answers `content` alone, so a client checks it before
+   * naming a document or reading `documents`.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.ApplyEditsRequest}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.ApplyEditsRequest)
+      org.openmbee.opensysml.proto.ApplyEditsRequestOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsRequest_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsRequest_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.ApplyEditsRequest.class, org.openmbee.opensysml.proto.ApplyEditsRequest.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.ApplyEditsRequest.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      modelHash_ = "";
+      if (operationsBuilder_ == null) {
+        operations_ = java.util.Collections.emptyList();
+      } else {
+        operations_ = null;
+        operationsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000002);
+      document_ = "";
+      acceptDocuments_ = false;
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsRequest_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ApplyEditsRequest getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.ApplyEditsRequest.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ApplyEditsRequest build() {
+      org.openmbee.opensysml.proto.ApplyEditsRequest result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ApplyEditsRequest buildPartial() {
+      org.openmbee.opensysml.proto.ApplyEditsRequest result = new org.openmbee.opensysml.proto.ApplyEditsRequest(this);
+      buildPartialRepeatedFields(result);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartialRepeatedFields(org.openmbee.opensysml.proto.ApplyEditsRequest result) {
+      if (operationsBuilder_ == null) {
+        if (((bitField0_ & 0x00000002) != 0)) {
+          operations_ = java.util.Collections.unmodifiableList(operations_);
+          bitField0_ = (bitField0_ & ~0x00000002);
+        }
+        result.operations_ = operations_;
+      } else {
+        result.operations_ = operationsBuilder_.build();
+      }
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.ApplyEditsRequest result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.modelHash_ = modelHash_;
+      }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.document_ = document_;
+      }
+      if (((from_bitField0_ & 0x00000008) != 0)) {
+        result.acceptDocuments_ = acceptDocuments_;
+      }
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.ApplyEditsRequest) {
+        return mergeFrom((org.openmbee.opensysml.proto.ApplyEditsRequest)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.ApplyEditsRequest other) {
+      if (other == org.openmbee.opensysml.proto.ApplyEditsRequest.getDefaultInstance()) return this;
+      if (!other.getModelHash().isEmpty()) {
+        modelHash_ = other.modelHash_;
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      if (operationsBuilder_ == null) {
+        if (!other.operations_.isEmpty()) {
+          if (operations_.isEmpty()) {
+            operations_ = other.operations_;
+            bitField0_ = (bitField0_ & ~0x00000002);
+          } else {
+            ensureOperationsIsMutable();
+            operations_.addAll(other.operations_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.operations_.isEmpty()) {
+          if (operationsBuilder_.isEmpty()) {
+            operationsBuilder_.dispose();
+            operationsBuilder_ = null;
+            operations_ = other.operations_;
+            bitField0_ = (bitField0_ & ~0x00000002);
+            operationsBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetOperationsFieldBuilder() : null;
+          } else {
+            operationsBuilder_.addAllMessages(other.operations_);
+          }
+        }
+      }
+      if (!other.getDocument().isEmpty()) {
+        document_ = other.document_;
+        bitField0_ |= 0x00000004;
+        onChanged();
+      }
+      if (other.getAcceptDocuments() != false) {
+        setAcceptDocuments(other.getAcceptDocuments());
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              modelHash_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              org.openmbee.opensysml.proto.EditOperation m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.EditOperation.parser(),
+                      extensionRegistry);
+              if (operationsBuilder_ == null) {
+                ensureOperationsIsMutable();
+                operations_.add(m);
+              } else {
+                operationsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 18
+            case 26: {
+              document_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 26
+            case 32: {
+              acceptDocuments_ = input.readBool();
+              bitField0_ |= 0x00000008;
+              break;
+            } // case 32
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.lang.Object modelHash_ = "";
+    /**
+     * <pre>
+     * from a ParseFile or ParseSources response
+     * </pre>
+     *
+     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+     * @return The modelHash.
+     */
+    public java.lang.String getModelHash() {
+      java.lang.Object ref = modelHash_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        modelHash_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * from a ParseFile or ParseSources response
+     * </pre>
+     *
+     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+     * @return The bytes for modelHash.
+     */
+    public com.google.protobuf.ByteString
+        getModelHashBytes() {
+      java.lang.Object ref = modelHash_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        modelHash_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * from a ParseFile or ParseSources response
+     * </pre>
+     *
+     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+     * @param value The modelHash to set.
+     * @return This builder for chaining.
+     */
+    public Builder setModelHash(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      modelHash_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * from a ParseFile or ParseSources response
+     * </pre>
+     *
+     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearModelHash() {
+      modelHash_ = getDefaultInstance().getModelHash();
+      bitField0_ = (bitField0_ & ~0x00000001);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * from a ParseFile or ParseSources response
+     * </pre>
+     *
+     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+     * @param value The bytes for modelHash to set.
+     * @return This builder for chaining.
+     */
+    public Builder setModelHashBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      modelHash_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+
+    private java.util.List<org.openmbee.opensysml.proto.EditOperation> operations_ =
+      java.util.Collections.emptyList();
+    private void ensureOperationsIsMutable() {
+      if (!((bitField0_ & 0x00000002) != 0)) {
+        operations_ = new java.util.ArrayList<org.openmbee.opensysml.proto.EditOperation>(operations_);
+        bitField0_ |= 0x00000002;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.EditOperation, org.openmbee.opensysml.proto.EditOperation.Builder, org.openmbee.opensysml.proto.EditOperationOrBuilder> operationsBuilder_;
+
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.EditOperation> getOperationsList() {
+      if (operationsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(operations_);
+      } else {
+        return operationsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public int getOperationsCount() {
+      if (operationsBuilder_ == null) {
+        return operations_.size();
+      } else {
+        return operationsBuilder_.getCount();
+      }
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public org.openmbee.opensysml.proto.EditOperation getOperations(int index) {
+      if (operationsBuilder_ == null) {
+        return operations_.get(index);
+      } else {
+        return operationsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public Builder setOperations(
+        int index, org.openmbee.opensysml.proto.EditOperation value) {
+      if (operationsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureOperationsIsMutable();
+        operations_.set(index, value);
+        onChanged();
+      } else {
+        operationsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public Builder setOperations(
+        int index, org.openmbee.opensysml.proto.EditOperation.Builder builderForValue) {
+      if (operationsBuilder_ == null) {
+        ensureOperationsIsMutable();
+        operations_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        operationsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public Builder addOperations(org.openmbee.opensysml.proto.EditOperation value) {
+      if (operationsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureOperationsIsMutable();
+        operations_.add(value);
+        onChanged();
+      } else {
+        operationsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public Builder addOperations(
+        int index, org.openmbee.opensysml.proto.EditOperation value) {
+      if (operationsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureOperationsIsMutable();
+        operations_.add(index, value);
+        onChanged();
+      } else {
+        operationsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public Builder addOperations(
+        org.openmbee.opensysml.proto.EditOperation.Builder builderForValue) {
+      if (operationsBuilder_ == null) {
+        ensureOperationsIsMutable();
+        operations_.add(builderForValue.build());
+        onChanged();
+      } else {
+        operationsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public Builder addOperations(
+        int index, org.openmbee.opensysml.proto.EditOperation.Builder builderForValue) {
+      if (operationsBuilder_ == null) {
+        ensureOperationsIsMutable();
+        operations_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        operationsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public Builder addAllOperations(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.EditOperation> values) {
+      if (operationsBuilder_ == null) {
+        ensureOperationsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, operations_);
+        onChanged();
+      } else {
+        operationsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public Builder clearOperations() {
+      if (operationsBuilder_ == null) {
+        operations_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000002);
+        onChanged();
+      } else {
+        operationsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public Builder removeOperations(int index) {
+      if (operationsBuilder_ == null) {
+        ensureOperationsIsMutable();
+        operations_.remove(index);
+        onChanged();
+      } else {
+        operationsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public org.openmbee.opensysml.proto.EditOperation.Builder getOperationsBuilder(
+        int index) {
+      return internalGetOperationsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public org.openmbee.opensysml.proto.EditOperationOrBuilder getOperationsOrBuilder(
+        int index) {
+      if (operationsBuilder_ == null) {
+        return operations_.get(index);  } else {
+        return operationsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.EditOperationOrBuilder> 
+         getOperationsOrBuilderList() {
+      if (operationsBuilder_ != null) {
+        return operationsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(operations_);
+      }
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public org.openmbee.opensysml.proto.EditOperation.Builder addOperationsBuilder() {
+      return internalGetOperationsFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.EditOperation.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public org.openmbee.opensysml.proto.EditOperation.Builder addOperationsBuilder(
+        int index) {
+      return internalGetOperationsFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.EditOperation.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+     * and answering with the unedited source would look like one was made.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.EditOperation.Builder> 
+         getOperationsBuilderList() {
+      return internalGetOperationsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.EditOperation, org.openmbee.opensysml.proto.EditOperation.Builder, org.openmbee.opensysml.proto.EditOperationOrBuilder> 
+        internalGetOperationsFieldBuilder() {
+      if (operationsBuilder_ == null) {
+        operationsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.EditOperation, org.openmbee.opensysml.proto.EditOperation.Builder, org.openmbee.opensysml.proto.EditOperationOrBuilder>(
+                operations_,
+                ((bitField0_ & 0x00000002) != 0),
+                getParentForChildren(),
+                isClean());
+        operations_ = null;
+      }
+      return operationsBuilder_;
+    }
+
+    private java.lang.Object document_ = "";
+    /**
+     * <pre>
+     * The document whose declarations the operations target, named as the parse
+     * request named it; empty names the model's first document, which is the
+     * only one of a ParseFile model. An operation targeting a declaration of
+     * another document is refused as an unknown target, naming that document. A
+     * name no document of the model has fails the call as an invalid argument.
+     * </pre>
+     *
+     * <code>string document = 3 [json_name = "document"];</code>
+     * @return The document.
+     */
+    public java.lang.String getDocument() {
+      java.lang.Object ref = document_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        document_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The document whose declarations the operations target, named as the parse
+     * request named it; empty names the model's first document, which is the
+     * only one of a ParseFile model. An operation targeting a declaration of
+     * another document is refused as an unknown target, naming that document. A
+     * name no document of the model has fails the call as an invalid argument.
+     * </pre>
+     *
+     * <code>string document = 3 [json_name = "document"];</code>
+     * @return The bytes for document.
+     */
+    public com.google.protobuf.ByteString
+        getDocumentBytes() {
+      java.lang.Object ref = document_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        document_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The document whose declarations the operations target, named as the parse
+     * request named it; empty names the model's first document, which is the
+     * only one of a ParseFile model. An operation targeting a declaration of
+     * another document is refused as an unknown target, naming that document. A
+     * name no document of the model has fails the call as an invalid argument.
+     * </pre>
+     *
+     * <code>string document = 3 [json_name = "document"];</code>
+     * @param value The document to set.
+     * @return This builder for chaining.
+     */
+    public Builder setDocument(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      document_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The document whose declarations the operations target, named as the parse
+     * request named it; empty names the model's first document, which is the
+     * only one of a ParseFile model. An operation targeting a declaration of
+     * another document is refused as an unknown target, naming that document. A
+     * name no document of the model has fails the call as an invalid argument.
+     * </pre>
+     *
+     * <code>string document = 3 [json_name = "document"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearDocument() {
+      document_ = getDefaultInstance().getDocument();
+      bitField0_ = (bitField0_ & ~0x00000004);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The document whose declarations the operations target, named as the parse
+     * request named it; empty names the model's first document, which is the
+     * only one of a ParseFile model. An operation targeting a declaration of
+     * another document is refused as an unknown target, naming that document. A
+     * name no document of the model has fails the call as an invalid argument.
+     * </pre>
+     *
+     * <code>string document = 3 [json_name = "document"];</code>
+     * @param value The bytes for document to set.
+     * @return This builder for chaining.
+     */
+    public Builder setDocumentBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      document_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+
+    private boolean acceptDocuments_ ;
+    /**
+     * <pre>
+     * Whether the client reads the response's `documents`. A model of several documents is
+     * edited only when set; unset, such a model is refused as a failed precondition, as before.
+     * </pre>
+     *
+     * <code>bool accept_documents = 4 [json_name = "acceptDocuments"];</code>
+     * @return The acceptDocuments.
+     */
+    @java.lang.Override
+    public boolean getAcceptDocuments() {
+      return acceptDocuments_;
+    }
+    /**
+     * <pre>
+     * Whether the client reads the response's `documents`. A model of several documents is
+     * edited only when set; unset, such a model is refused as a failed precondition, as before.
+     * </pre>
+     *
+     * <code>bool accept_documents = 4 [json_name = "acceptDocuments"];</code>
+     * @param value The acceptDocuments to set.
+     * @return This builder for chaining.
+     */
+    public Builder setAcceptDocuments(boolean value) {
+
+      acceptDocuments_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Whether the client reads the response's `documents`. A model of several documents is
+     * edited only when set; unset, such a model is refused as a failed precondition, as before.
+     * </pre>
+     *
+     * <code>bool accept_documents = 4 [json_name = "acceptDocuments"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearAcceptDocuments() {
+      bitField0_ = (bitField0_ & ~0x00000008);
+      acceptDocuments_ = false;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.ApplyEditsRequest)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.ApplyEditsRequest)
+  private static final org.openmbee.opensysml.proto.ApplyEditsRequest DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.ApplyEditsRequest();
+  }
+
+  public static org.openmbee.opensysml.proto.ApplyEditsRequest getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<ApplyEditsRequest>
+      PARSER = new com.google.protobuf.AbstractParser<ApplyEditsRequest>() {
+    @java.lang.Override
+    public ApplyEditsRequest parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<ApplyEditsRequest> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<ApplyEditsRequest> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.ApplyEditsRequest getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequestOrBuilder.java
new file mode 100644
index 0000000000..167a4802e6
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequestOrBuilder.java
@@ -0,0 +1,120 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface ApplyEditsRequestOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.ApplyEditsRequest)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * from a ParseFile or ParseSources response
+   * </pre>
+   *
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The modelHash.
+   */
+  java.lang.String getModelHash();
+  /**
+   * <pre>
+   * from a ParseFile or ParseSources response
+   * </pre>
+   *
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The bytes for modelHash.
+   */
+  com.google.protobuf.ByteString
+      getModelHashBytes();
+
+  /**
+   * <pre>
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+   */
+  java.util.List<org.openmbee.opensysml.proto.EditOperation> 
+      getOperationsList();
+  /**
+   * <pre>
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+   */
+  org.openmbee.opensysml.proto.EditOperation getOperations(int index);
+  /**
+   * <pre>
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+   */
+  int getOperationsCount();
+  /**
+   * <pre>
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+   */
+  java.util.List<? extends org.openmbee.opensysml.proto.EditOperationOrBuilder> 
+      getOperationsOrBuilderList();
+  /**
+   * <pre>
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
+   */
+  org.openmbee.opensysml.proto.EditOperationOrBuilder getOperationsOrBuilder(
+      int index);
+
+  /**
+   * <pre>
+   * The document whose declarations the operations target, named as the parse
+   * request named it; empty names the model's first document, which is the
+   * only one of a ParseFile model. An operation targeting a declaration of
+   * another document is refused as an unknown target, naming that document. A
+   * name no document of the model has fails the call as an invalid argument.
+   * </pre>
+   *
+   * <code>string document = 3 [json_name = "document"];</code>
+   * @return The document.
+   */
+  java.lang.String getDocument();
+  /**
+   * <pre>
+   * The document whose declarations the operations target, named as the parse
+   * request named it; empty names the model's first document, which is the
+   * only one of a ParseFile model. An operation targeting a declaration of
+   * another document is refused as an unknown target, naming that document. A
+   * name no document of the model has fails the call as an invalid argument.
+   * </pre>
+   *
+   * <code>string document = 3 [json_name = "document"];</code>
+   * @return The bytes for document.
+   */
+  com.google.protobuf.ByteString
+      getDocumentBytes();
+
+  /**
+   * <pre>
+   * Whether the client reads the response's `documents`. A model of several documents is
+   * edited only when set; unset, such a model is refused as a failed precondition, as before.
+   * </pre>
+   *
+   * <code>bool accept_documents = 4 [json_name = "acceptDocuments"];</code>
+   * @return The acceptDocuments.
+   */
+  boolean getAcceptDocuments();
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponse.java
new file mode 100644
index 0000000000..c539eb88bc
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponse.java
@@ -0,0 +1,3134 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * ApplyEditsResponse carries the edited source, or says why nothing was edited.
+ * The edited notation is in `documents`, one entry per document the edits
+ * rewrote; `content` repeats it for a single-document model only.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.ApplyEditsResponse}
+ */
+@com.google.protobuf.Generated
+public final class ApplyEditsResponse extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.ApplyEditsResponse)
+    ApplyEditsResponseOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "ApplyEditsResponse");
+  }
+  // Use ApplyEditsResponse.newBuilder() to construct.
+  private ApplyEditsResponse(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private ApplyEditsResponse() {
+    content_ = "";
+    applied_ = java.util.Collections.emptyList();
+    error_ = "";
+    failure_ = 0;
+    diagnostics_ = java.util.Collections.emptyList();
+    referringElements_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    documents_ = java.util.Collections.emptyList();
+    referrers_ = java.util.Collections.emptyList();
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsResponse_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsResponse_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.ApplyEditsResponse.class, org.openmbee.opensysml.proto.ApplyEditsResponse.Builder.class);
+  }
+
+  public static final int CONTENT_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object content_ = "";
+  /**
+   * <pre>
+   * The edited notation of a single-document model, byte-identical to the
+   * source outside the edited spans. Empty when the edits were refused, so a
+   * refusal never writes a file, and empty for a model of several documents,
+   * whose edited notation is in `documents` alone: a client that reads
+   * `content` alone was written for one document and must not write one
+   * document's notation over another's. `documents` carries the same notation
+   * for a single-document model, so a client needs one code path.
+   * </pre>
+   *
+   * <code>string content = 1 [json_name = "content"];</code>
+   * @return The content.
+   */
+  @java.lang.Override
+  public java.lang.String getContent() {
+    java.lang.Object ref = content_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      content_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The edited notation of a single-document model, byte-identical to the
+   * source outside the edited spans. Empty when the edits were refused, so a
+   * refusal never writes a file, and empty for a model of several documents,
+   * whose edited notation is in `documents` alone: a client that reads
+   * `content` alone was written for one document and must not write one
+   * document's notation over another's. `documents` carries the same notation
+   * for a single-document model, so a client needs one code path.
+   * </pre>
+   *
+   * <code>string content = 1 [json_name = "content"];</code>
+   * @return The bytes for content.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getContentBytes() {
+    java.lang.Object ref = content_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      content_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int APPLIED_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.AppliedEdit> applied_;
+  /**
+   * <pre>
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   * </pre>
+   *
+   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.AppliedEdit> getAppliedList() {
+    return applied_;
+  }
+  /**
+   * <pre>
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   * </pre>
+   *
+   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.AppliedEditOrBuilder> 
+      getAppliedOrBuilderList() {
+    return applied_;
+  }
+  /**
+   * <pre>
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   * </pre>
+   *
+   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+   */
+  @java.lang.Override
+  public int getAppliedCount() {
+    return applied_.size();
+  }
+  /**
+   * <pre>
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   * </pre>
+   *
+   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.AppliedEdit getApplied(int index) {
+    return applied_.get(index);
+  }
+  /**
+   * <pre>
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   * </pre>
+   *
+   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.AppliedEditOrBuilder getAppliedOrBuilder(
+      int index) {
+    return applied_.get(index);
+  }
+
+  public static final int ERROR_FIELD_NUMBER = 3;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object error_ = "";
+  /**
+   * <pre>
+   * non-empty if the edits were refused; content and documents are unset
+   * </pre>
+   *
+   * <code>string error = 3 [json_name = "error"];</code>
+   * @return The error.
+   */
+  @java.lang.Override
+  public java.lang.String getError() {
+    java.lang.Object ref = error_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      error_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * non-empty if the edits were refused; content and documents are unset
+   * </pre>
+   *
+   * <code>string error = 3 [json_name = "error"];</code>
+   * @return The bytes for error.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getErrorBytes() {
+    java.lang.Object ref = error_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      error_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int FAILURE_FIELD_NUMBER = 4;
+  private int failure_ = 0;
+  /**
+   * <pre>
+   * Which kind of refusal `error` reports, so a client acts on the kind rather
+   * than on the message text.
+   * </pre>
+   *
+   * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
+   * @return The enum numeric value on the wire for failure.
+   */
+  @java.lang.Override public int getFailureValue() {
+    return failure_;
+  }
+  /**
+   * <pre>
+   * Which kind of refusal `error` reports, so a client acts on the kind rather
+   * than on the message text.
+   * </pre>
+   *
+   * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
+   * @return The failure.
+   */
+  @java.lang.Override public org.openmbee.opensysml.proto.EditFailure getFailure() {
+    org.openmbee.opensysml.proto.EditFailure result = org.openmbee.opensysml.proto.EditFailure.forNumber(failure_);
+    return result == null ? org.openmbee.opensysml.proto.EditFailure.UNRECOGNIZED : result;
+  }
+
+  public static final int DIAGNOSTICS_FIELD_NUMBER = 5;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.Diagnostic> diagnostics_;
+  /**
+   * <pre>
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.Diagnostic> getDiagnosticsList() {
+    return diagnostics_;
+  }
+  /**
+   * <pre>
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+      getDiagnosticsOrBuilderList() {
+    return diagnostics_;
+  }
+  /**
+   * <pre>
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public int getDiagnosticsCount() {
+    return diagnostics_.size();
+  }
+  /**
+   * <pre>
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index) {
+    return diagnostics_.get(index);
+  }
+  /**
+   * <pre>
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
+      int index) {
+    return diagnostics_.get(index);
+  }
+
+  public static final int REFERRING_ELEMENTS_FIELD_NUMBER = 6;
+  @SuppressWarnings("serial")
+  private com.google.protobuf.LazyStringArrayList referringElements_ =
+      com.google.protobuf.LazyStringArrayList.emptyList();
+  /**
+   * <pre>
+   * Where the references to a declaration whose rename, delete or move was
+   * refused are made: the FQN of each referring namespace, suffixed with its
+   * document in parentheses when that is not the document being edited.
+   * `referrers` carries the same list with the document as a field of its own.
+   * </pre>
+   *
+   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+   * @return A list containing the referringElements.
+   */
+  public com.google.protobuf.ProtocolStringList
+      getReferringElementsList() {
+    return referringElements_;
+  }
+  /**
+   * <pre>
+   * Where the references to a declaration whose rename, delete or move was
+   * refused are made: the FQN of each referring namespace, suffixed with its
+   * document in parentheses when that is not the document being edited.
+   * `referrers` carries the same list with the document as a field of its own.
+   * </pre>
+   *
+   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+   * @return The count of referringElements.
+   */
+  public int getReferringElementsCount() {
+    return referringElements_.size();
+  }
+  /**
+   * <pre>
+   * Where the references to a declaration whose rename, delete or move was
+   * refused are made: the FQN of each referring namespace, suffixed with its
+   * document in parentheses when that is not the document being edited.
+   * `referrers` carries the same list with the document as a field of its own.
+   * </pre>
+   *
+   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+   * @param index The index of the element to return.
+   * @return The referringElements at the given index.
+   */
+  public java.lang.String getReferringElements(int index) {
+    return referringElements_.get(index);
+  }
+  /**
+   * <pre>
+   * Where the references to a declaration whose rename, delete or move was
+   * refused are made: the FQN of each referring namespace, suffixed with its
+   * document in parentheses when that is not the document being edited.
+   * `referrers` carries the same list with the document as a field of its own.
+   * </pre>
+   *
+   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the referringElements at the given index.
+   */
+  public com.google.protobuf.ByteString
+      getReferringElementsBytes(int index) {
+    return referringElements_.getByteString(index);
+  }
+
+  public static final int DOCUMENTS_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.EditedDocument> documents_;
+  /**
+   * <pre>
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.EditedDocument> getDocumentsList() {
+    return documents_;
+  }
+  /**
+   * <pre>
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.EditedDocumentOrBuilder> 
+      getDocumentsOrBuilderList() {
+    return documents_;
+  }
+  /**
+   * <pre>
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+   */
+  @java.lang.Override
+  public int getDocumentsCount() {
+    return documents_.size();
+  }
+  /**
+   * <pre>
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.EditedDocument getDocuments(int index) {
+    return documents_.get(index);
+  }
+  /**
+   * <pre>
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.EditedDocumentOrBuilder getDocumentsOrBuilder(
+      int index) {
+    return documents_.get(index);
+  }
+
+  public static final int REFERRERS_FIELD_NUMBER = 8;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.Referrer> referrers_;
+  /**
+   * <pre>
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   * </pre>
+   *
+   * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.Referrer> getReferrersList() {
+    return referrers_;
+  }
+  /**
+   * <pre>
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   * </pre>
+   *
+   * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.ReferrerOrBuilder> 
+      getReferrersOrBuilderList() {
+    return referrers_;
+  }
+  /**
+   * <pre>
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   * </pre>
+   *
+   * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+   */
+  @java.lang.Override
+  public int getReferrersCount() {
+    return referrers_.size();
+  }
+  /**
+   * <pre>
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   * </pre>
+   *
+   * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Referrer getReferrers(int index) {
+    return referrers_.get(index);
+  }
+  /**
+   * <pre>
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   * </pre>
+   *
+   * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.ReferrerOrBuilder getReferrersOrBuilder(
+      int index) {
+    return referrers_.get(index);
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(content_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 1, content_);
+    }
+    for (int i = 0; i < applied_.size(); i++) {
+      output.writeMessage(2, applied_.get(i));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 3, error_);
+    }
+    if (failure_ != org.openmbee.opensysml.proto.EditFailure.EDIT_FAILURE_UNSPECIFIED.getNumber()) {
+      output.writeEnum(4, failure_);
+    }
+    for (int i = 0; i < diagnostics_.size(); i++) {
+      output.writeMessage(5, diagnostics_.get(i));
+    }
+    for (int i = 0; i < referringElements_.size(); i++) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 6, referringElements_.getRaw(i));
+    }
+    for (int i = 0; i < documents_.size(); i++) {
+      output.writeMessage(7, documents_.get(i));
+    }
+    for (int i = 0; i < referrers_.size(); i++) {
+      output.writeMessage(8, referrers_.get(i));
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(content_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, content_);
+    }
+    for (int i = 0; i < applied_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(2, applied_.get(i));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(3, error_);
+    }
+    if (failure_ != org.openmbee.opensysml.proto.EditFailure.EDIT_FAILURE_UNSPECIFIED.getNumber()) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeEnumSize(4, failure_);
+    }
+    for (int i = 0; i < diagnostics_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(5, diagnostics_.get(i));
+    }
+    {
+      int dataSize = 0;
+      for (int i = 0; i < referringElements_.size(); i++) {
+        dataSize += computeStringSizeNoTag(referringElements_.getRaw(i));
+      }
+      size += dataSize;
+      size += 1 * getReferringElementsList().size();
+    }
+    for (int i = 0; i < documents_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(7, documents_.get(i));
+    }
+    for (int i = 0; i < referrers_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(8, referrers_.get(i));
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.ApplyEditsResponse)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.ApplyEditsResponse other = (org.openmbee.opensysml.proto.ApplyEditsResponse) obj;
+
+    if (!getContent()
+        .equals(other.getContent())) return false;
+    if (!getAppliedList()
+        .equals(other.getAppliedList())) return false;
+    if (!getError()
+        .equals(other.getError())) return false;
+    if (failure_ != other.failure_) return false;
+    if (!getDiagnosticsList()
+        .equals(other.getDiagnosticsList())) return false;
+    if (!getReferringElementsList()
+        .equals(other.getReferringElementsList())) return false;
+    if (!getDocumentsList()
+        .equals(other.getDocumentsList())) return false;
+    if (!getReferrersList()
+        .equals(other.getReferrersList())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + CONTENT_FIELD_NUMBER;
+    hash = (53 * hash) + getContent().hashCode();
+    if (getAppliedCount() > 0) {
+      hash = (37 * hash) + APPLIED_FIELD_NUMBER;
+      hash = (53 * hash) + getAppliedList().hashCode();
+    }
+    hash = (37 * hash) + ERROR_FIELD_NUMBER;
+    hash = (53 * hash) + getError().hashCode();
+    hash = (37 * hash) + FAILURE_FIELD_NUMBER;
+    hash = (53 * hash) + failure_;
+    if (getDiagnosticsCount() > 0) {
+      hash = (37 * hash) + DIAGNOSTICS_FIELD_NUMBER;
+      hash = (53 * hash) + getDiagnosticsList().hashCode();
+    }
+    if (getReferringElementsCount() > 0) {
+      hash = (37 * hash) + REFERRING_ELEMENTS_FIELD_NUMBER;
+      hash = (53 * hash) + getReferringElementsList().hashCode();
+    }
+    if (getDocumentsCount() > 0) {
+      hash = (37 * hash) + DOCUMENTS_FIELD_NUMBER;
+      hash = (53 * hash) + getDocumentsList().hashCode();
+    }
+    if (getReferrersCount() > 0) {
+      hash = (37 * hash) + REFERRERS_FIELD_NUMBER;
+      hash = (53 * hash) + getReferrersList().hashCode();
+    }
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.ApplyEditsResponse prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * ApplyEditsResponse carries the edited source, or says why nothing was edited.
+   * The edited notation is in `documents`, one entry per document the edits
+   * rewrote; `content` repeats it for a single-document model only.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.ApplyEditsResponse}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.ApplyEditsResponse)
+      org.openmbee.opensysml.proto.ApplyEditsResponseOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsResponse_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsResponse_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.ApplyEditsResponse.class, org.openmbee.opensysml.proto.ApplyEditsResponse.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.ApplyEditsResponse.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      content_ = "";
+      if (appliedBuilder_ == null) {
+        applied_ = java.util.Collections.emptyList();
+      } else {
+        applied_ = null;
+        appliedBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000002);
+      error_ = "";
+      failure_ = 0;
+      if (diagnosticsBuilder_ == null) {
+        diagnostics_ = java.util.Collections.emptyList();
+      } else {
+        diagnostics_ = null;
+        diagnosticsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000010);
+      referringElements_ =
+          com.google.protobuf.LazyStringArrayList.emptyList();
+      if (documentsBuilder_ == null) {
+        documents_ = java.util.Collections.emptyList();
+      } else {
+        documents_ = null;
+        documentsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000040);
+      if (referrersBuilder_ == null) {
+        referrers_ = java.util.Collections.emptyList();
+      } else {
+        referrers_ = null;
+        referrersBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000080);
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsResponse_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ApplyEditsResponse getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.ApplyEditsResponse.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ApplyEditsResponse build() {
+      org.openmbee.opensysml.proto.ApplyEditsResponse result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ApplyEditsResponse buildPartial() {
+      org.openmbee.opensysml.proto.ApplyEditsResponse result = new org.openmbee.opensysml.proto.ApplyEditsResponse(this);
+      buildPartialRepeatedFields(result);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartialRepeatedFields(org.openmbee.opensysml.proto.ApplyEditsResponse result) {
+      if (appliedBuilder_ == null) {
+        if (((bitField0_ & 0x00000002) != 0)) {
+          applied_ = java.util.Collections.unmodifiableList(applied_);
+          bitField0_ = (bitField0_ & ~0x00000002);
+        }
+        result.applied_ = applied_;
+      } else {
+        result.applied_ = appliedBuilder_.build();
+      }
+      if (diagnosticsBuilder_ == null) {
+        if (((bitField0_ & 0x00000010) != 0)) {
+          diagnostics_ = java.util.Collections.unmodifiableList(diagnostics_);
+          bitField0_ = (bitField0_ & ~0x00000010);
+        }
+        result.diagnostics_ = diagnostics_;
+      } else {
+        result.diagnostics_ = diagnosticsBuilder_.build();
+      }
+      if (documentsBuilder_ == null) {
+        if (((bitField0_ & 0x00000040) != 0)) {
+          documents_ = java.util.Collections.unmodifiableList(documents_);
+          bitField0_ = (bitField0_ & ~0x00000040);
+        }
+        result.documents_ = documents_;
+      } else {
+        result.documents_ = documentsBuilder_.build();
+      }
+      if (referrersBuilder_ == null) {
+        if (((bitField0_ & 0x00000080) != 0)) {
+          referrers_ = java.util.Collections.unmodifiableList(referrers_);
+          bitField0_ = (bitField0_ & ~0x00000080);
+        }
+        result.referrers_ = referrers_;
+      } else {
+        result.referrers_ = referrersBuilder_.build();
+      }
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.ApplyEditsResponse result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.content_ = content_;
+      }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.error_ = error_;
+      }
+      if (((from_bitField0_ & 0x00000008) != 0)) {
+        result.failure_ = failure_;
+      }
+      if (((from_bitField0_ & 0x00000020) != 0)) {
+        referringElements_.makeImmutable();
+        result.referringElements_ = referringElements_;
+      }
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.ApplyEditsResponse) {
+        return mergeFrom((org.openmbee.opensysml.proto.ApplyEditsResponse)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.ApplyEditsResponse other) {
+      if (other == org.openmbee.opensysml.proto.ApplyEditsResponse.getDefaultInstance()) return this;
+      if (!other.getContent().isEmpty()) {
+        content_ = other.content_;
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      if (appliedBuilder_ == null) {
+        if (!other.applied_.isEmpty()) {
+          if (applied_.isEmpty()) {
+            applied_ = other.applied_;
+            bitField0_ = (bitField0_ & ~0x00000002);
+          } else {
+            ensureAppliedIsMutable();
+            applied_.addAll(other.applied_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.applied_.isEmpty()) {
+          if (appliedBuilder_.isEmpty()) {
+            appliedBuilder_.dispose();
+            appliedBuilder_ = null;
+            applied_ = other.applied_;
+            bitField0_ = (bitField0_ & ~0x00000002);
+            appliedBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetAppliedFieldBuilder() : null;
+          } else {
+            appliedBuilder_.addAllMessages(other.applied_);
+          }
+        }
+      }
+      if (!other.getError().isEmpty()) {
+        error_ = other.error_;
+        bitField0_ |= 0x00000004;
+        onChanged();
+      }
+      if (other.failure_ != 0) {
+        setFailureValue(other.getFailureValue());
+      }
+      if (diagnosticsBuilder_ == null) {
+        if (!other.diagnostics_.isEmpty()) {
+          if (diagnostics_.isEmpty()) {
+            diagnostics_ = other.diagnostics_;
+            bitField0_ = (bitField0_ & ~0x00000010);
+          } else {
+            ensureDiagnosticsIsMutable();
+            diagnostics_.addAll(other.diagnostics_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.diagnostics_.isEmpty()) {
+          if (diagnosticsBuilder_.isEmpty()) {
+            diagnosticsBuilder_.dispose();
+            diagnosticsBuilder_ = null;
+            diagnostics_ = other.diagnostics_;
+            bitField0_ = (bitField0_ & ~0x00000010);
+            diagnosticsBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetDiagnosticsFieldBuilder() : null;
+          } else {
+            diagnosticsBuilder_.addAllMessages(other.diagnostics_);
+          }
+        }
+      }
+      if (!other.referringElements_.isEmpty()) {
+        if (referringElements_.isEmpty()) {
+          referringElements_ = other.referringElements_;
+          bitField0_ |= 0x00000020;
+        } else {
+          ensureReferringElementsIsMutable();
+          referringElements_.addAll(other.referringElements_);
+        }
+        onChanged();
+      }
+      if (documentsBuilder_ == null) {
+        if (!other.documents_.isEmpty()) {
+          if (documents_.isEmpty()) {
+            documents_ = other.documents_;
+            bitField0_ = (bitField0_ & ~0x00000040);
+          } else {
+            ensureDocumentsIsMutable();
+            documents_.addAll(other.documents_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.documents_.isEmpty()) {
+          if (documentsBuilder_.isEmpty()) {
+            documentsBuilder_.dispose();
+            documentsBuilder_ = null;
+            documents_ = other.documents_;
+            bitField0_ = (bitField0_ & ~0x00000040);
+            documentsBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetDocumentsFieldBuilder() : null;
+          } else {
+            documentsBuilder_.addAllMessages(other.documents_);
+          }
+        }
+      }
+      if (referrersBuilder_ == null) {
+        if (!other.referrers_.isEmpty()) {
+          if (referrers_.isEmpty()) {
+            referrers_ = other.referrers_;
+            bitField0_ = (bitField0_ & ~0x00000080);
+          } else {
+            ensureReferrersIsMutable();
+            referrers_.addAll(other.referrers_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.referrers_.isEmpty()) {
+          if (referrersBuilder_.isEmpty()) {
+            referrersBuilder_.dispose();
+            referrersBuilder_ = null;
+            referrers_ = other.referrers_;
+            bitField0_ = (bitField0_ & ~0x00000080);
+            referrersBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetReferrersFieldBuilder() : null;
+          } else {
+            referrersBuilder_.addAllMessages(other.referrers_);
+          }
+        }
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              content_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              org.openmbee.opensysml.proto.AppliedEdit m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.AppliedEdit.parser(),
+                      extensionRegistry);
+              if (appliedBuilder_ == null) {
+                ensureAppliedIsMutable();
+                applied_.add(m);
+              } else {
+                appliedBuilder_.addMessage(m);
+              }
+              break;
+            } // case 18
+            case 26: {
+              error_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 26
+            case 32: {
+              failure_ = input.readEnum();
+              bitField0_ |= 0x00000008;
+              break;
+            } // case 32
+            case 42: {
+              org.openmbee.opensysml.proto.Diagnostic m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.Diagnostic.parser(),
+                      extensionRegistry);
+              if (diagnosticsBuilder_ == null) {
+                ensureDiagnosticsIsMutable();
+                diagnostics_.add(m);
+              } else {
+                diagnosticsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 42
+            case 50: {
+              java.lang.String s = input.readStringRequireUtf8();
+              ensureReferringElementsIsMutable();
+              referringElements_.add(s);
+              break;
+            } // case 50
+            case 58: {
+              org.openmbee.opensysml.proto.EditedDocument m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.EditedDocument.parser(),
+                      extensionRegistry);
+              if (documentsBuilder_ == null) {
+                ensureDocumentsIsMutable();
+                documents_.add(m);
+              } else {
+                documentsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 58
+            case 66: {
+              org.openmbee.opensysml.proto.Referrer m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.Referrer.parser(),
+                      extensionRegistry);
+              if (referrersBuilder_ == null) {
+                ensureReferrersIsMutable();
+                referrers_.add(m);
+              } else {
+                referrersBuilder_.addMessage(m);
+              }
+              break;
+            } // case 66
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.lang.Object content_ = "";
+    /**
+     * <pre>
+     * The edited notation of a single-document model, byte-identical to the
+     * source outside the edited spans. Empty when the edits were refused, so a
+     * refusal never writes a file, and empty for a model of several documents,
+     * whose edited notation is in `documents` alone: a client that reads
+     * `content` alone was written for one document and must not write one
+     * document's notation over another's. `documents` carries the same notation
+     * for a single-document model, so a client needs one code path.
+     * </pre>
+     *
+     * <code>string content = 1 [json_name = "content"];</code>
+     * @return The content.
+     */
+    public java.lang.String getContent() {
+      java.lang.Object ref = content_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        content_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The edited notation of a single-document model, byte-identical to the
+     * source outside the edited spans. Empty when the edits were refused, so a
+     * refusal never writes a file, and empty for a model of several documents,
+     * whose edited notation is in `documents` alone: a client that reads
+     * `content` alone was written for one document and must not write one
+     * document's notation over another's. `documents` carries the same notation
+     * for a single-document model, so a client needs one code path.
+     * </pre>
+     *
+     * <code>string content = 1 [json_name = "content"];</code>
+     * @return The bytes for content.
+     */
+    public com.google.protobuf.ByteString
+        getContentBytes() {
+      java.lang.Object ref = content_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        content_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The edited notation of a single-document model, byte-identical to the
+     * source outside the edited spans. Empty when the edits were refused, so a
+     * refusal never writes a file, and empty for a model of several documents,
+     * whose edited notation is in `documents` alone: a client that reads
+     * `content` alone was written for one document and must not write one
+     * document's notation over another's. `documents` carries the same notation
+     * for a single-document model, so a client needs one code path.
+     * </pre>
+     *
+     * <code>string content = 1 [json_name = "content"];</code>
+     * @param value The content to set.
+     * @return This builder for chaining.
+     */
+    public Builder setContent(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      content_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of a single-document model, byte-identical to the
+     * source outside the edited spans. Empty when the edits were refused, so a
+     * refusal never writes a file, and empty for a model of several documents,
+     * whose edited notation is in `documents` alone: a client that reads
+     * `content` alone was written for one document and must not write one
+     * document's notation over another's. `documents` carries the same notation
+     * for a single-document model, so a client needs one code path.
+     * </pre>
+     *
+     * <code>string content = 1 [json_name = "content"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearContent() {
+      content_ = getDefaultInstance().getContent();
+      bitField0_ = (bitField0_ & ~0x00000001);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of a single-document model, byte-identical to the
+     * source outside the edited spans. Empty when the edits were refused, so a
+     * refusal never writes a file, and empty for a model of several documents,
+     * whose edited notation is in `documents` alone: a client that reads
+     * `content` alone was written for one document and must not write one
+     * document's notation over another's. `documents` carries the same notation
+     * for a single-document model, so a client needs one code path.
+     * </pre>
+     *
+     * <code>string content = 1 [json_name = "content"];</code>
+     * @param value The bytes for content to set.
+     * @return This builder for chaining.
+     */
+    public Builder setContentBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      content_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+
+    private java.util.List<org.openmbee.opensysml.proto.AppliedEdit> applied_ =
+      java.util.Collections.emptyList();
+    private void ensureAppliedIsMutable() {
+      if (!((bitField0_ & 0x00000002) != 0)) {
+        applied_ = new java.util.ArrayList<org.openmbee.opensysml.proto.AppliedEdit>(applied_);
+        bitField0_ |= 0x00000002;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.AppliedEdit, org.openmbee.opensysml.proto.AppliedEdit.Builder, org.openmbee.opensysml.proto.AppliedEditOrBuilder> appliedBuilder_;
+
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.AppliedEdit> getAppliedList() {
+      if (appliedBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(applied_);
+      } else {
+        return appliedBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public int getAppliedCount() {
+      if (appliedBuilder_ == null) {
+        return applied_.size();
+      } else {
+        return appliedBuilder_.getCount();
+      }
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public org.openmbee.opensysml.proto.AppliedEdit getApplied(int index) {
+      if (appliedBuilder_ == null) {
+        return applied_.get(index);
+      } else {
+        return appliedBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public Builder setApplied(
+        int index, org.openmbee.opensysml.proto.AppliedEdit value) {
+      if (appliedBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureAppliedIsMutable();
+        applied_.set(index, value);
+        onChanged();
+      } else {
+        appliedBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public Builder setApplied(
+        int index, org.openmbee.opensysml.proto.AppliedEdit.Builder builderForValue) {
+      if (appliedBuilder_ == null) {
+        ensureAppliedIsMutable();
+        applied_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        appliedBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public Builder addApplied(org.openmbee.opensysml.proto.AppliedEdit value) {
+      if (appliedBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureAppliedIsMutable();
+        applied_.add(value);
+        onChanged();
+      } else {
+        appliedBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public Builder addApplied(
+        int index, org.openmbee.opensysml.proto.AppliedEdit value) {
+      if (appliedBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureAppliedIsMutable();
+        applied_.add(index, value);
+        onChanged();
+      } else {
+        appliedBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public Builder addApplied(
+        org.openmbee.opensysml.proto.AppliedEdit.Builder builderForValue) {
+      if (appliedBuilder_ == null) {
+        ensureAppliedIsMutable();
+        applied_.add(builderForValue.build());
+        onChanged();
+      } else {
+        appliedBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public Builder addApplied(
+        int index, org.openmbee.opensysml.proto.AppliedEdit.Builder builderForValue) {
+      if (appliedBuilder_ == null) {
+        ensureAppliedIsMutable();
+        applied_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        appliedBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public Builder addAllApplied(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.AppliedEdit> values) {
+      if (appliedBuilder_ == null) {
+        ensureAppliedIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, applied_);
+        onChanged();
+      } else {
+        appliedBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public Builder clearApplied() {
+      if (appliedBuilder_ == null) {
+        applied_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000002);
+        onChanged();
+      } else {
+        appliedBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public Builder removeApplied(int index) {
+      if (appliedBuilder_ == null) {
+        ensureAppliedIsMutable();
+        applied_.remove(index);
+        onChanged();
+      } else {
+        appliedBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public org.openmbee.opensysml.proto.AppliedEdit.Builder getAppliedBuilder(
+        int index) {
+      return internalGetAppliedFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public org.openmbee.opensysml.proto.AppliedEditOrBuilder getAppliedOrBuilder(
+        int index) {
+      if (appliedBuilder_ == null) {
+        return applied_.get(index);  } else {
+        return appliedBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.AppliedEditOrBuilder> 
+         getAppliedOrBuilderList() {
+      if (appliedBuilder_ != null) {
+        return appliedBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(applied_);
+      }
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public org.openmbee.opensysml.proto.AppliedEdit.Builder addAppliedBuilder() {
+      return internalGetAppliedFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.AppliedEdit.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public org.openmbee.opensysml.proto.AppliedEdit.Builder addAppliedBuilder(
+        int index) {
+      return internalGetAppliedFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.AppliedEdit.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * What each operation changed, grouped by document in the order `documents`
+     * lists them and in request order within a document.
+     * </pre>
+     *
+     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.AppliedEdit.Builder> 
+         getAppliedBuilderList() {
+      return internalGetAppliedFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.AppliedEdit, org.openmbee.opensysml.proto.AppliedEdit.Builder, org.openmbee.opensysml.proto.AppliedEditOrBuilder> 
+        internalGetAppliedFieldBuilder() {
+      if (appliedBuilder_ == null) {
+        appliedBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.AppliedEdit, org.openmbee.opensysml.proto.AppliedEdit.Builder, org.openmbee.opensysml.proto.AppliedEditOrBuilder>(
+                applied_,
+                ((bitField0_ & 0x00000002) != 0),
+                getParentForChildren(),
+                isClean());
+        applied_ = null;
+      }
+      return appliedBuilder_;
+    }
+
+    private java.lang.Object error_ = "";
+    /**
+     * <pre>
+     * non-empty if the edits were refused; content and documents are unset
+     * </pre>
+     *
+     * <code>string error = 3 [json_name = "error"];</code>
+     * @return The error.
+     */
+    public java.lang.String getError() {
+      java.lang.Object ref = error_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        error_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * non-empty if the edits were refused; content and documents are unset
+     * </pre>
+     *
+     * <code>string error = 3 [json_name = "error"];</code>
+     * @return The bytes for error.
+     */
+    public com.google.protobuf.ByteString
+        getErrorBytes() {
+      java.lang.Object ref = error_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        error_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * non-empty if the edits were refused; content and documents are unset
+     * </pre>
+     *
+     * <code>string error = 3 [json_name = "error"];</code>
+     * @param value The error to set.
+     * @return This builder for chaining.
+     */
+    public Builder setError(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      error_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * non-empty if the edits were refused; content and documents are unset
+     * </pre>
+     *
+     * <code>string error = 3 [json_name = "error"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearError() {
+      error_ = getDefaultInstance().getError();
+      bitField0_ = (bitField0_ & ~0x00000004);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * non-empty if the edits were refused; content and documents are unset
+     * </pre>
+     *
+     * <code>string error = 3 [json_name = "error"];</code>
+     * @param value The bytes for error to set.
+     * @return This builder for chaining.
+     */
+    public Builder setErrorBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      error_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+
+    private int failure_ = 0;
+    /**
+     * <pre>
+     * Which kind of refusal `error` reports, so a client acts on the kind rather
+     * than on the message text.
+     * </pre>
+     *
+     * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
+     * @return The enum numeric value on the wire for failure.
+     */
+    @java.lang.Override public int getFailureValue() {
+      return failure_;
+    }
+    /**
+     * <pre>
+     * Which kind of refusal `error` reports, so a client acts on the kind rather
+     * than on the message text.
+     * </pre>
+     *
+     * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
+     * @param value The enum numeric value on the wire for failure to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFailureValue(int value) {
+      failure_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Which kind of refusal `error` reports, so a client acts on the kind rather
+     * than on the message text.
+     * </pre>
+     *
+     * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
+     * @return The failure.
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.EditFailure getFailure() {
+      org.openmbee.opensysml.proto.EditFailure result = org.openmbee.opensysml.proto.EditFailure.forNumber(failure_);
+      return result == null ? org.openmbee.opensysml.proto.EditFailure.UNRECOGNIZED : result;
+    }
+    /**
+     * <pre>
+     * Which kind of refusal `error` reports, so a client acts on the kind rather
+     * than on the message text.
+     * </pre>
+     *
+     * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
+     * @param value The failure to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFailure(org.openmbee.opensysml.proto.EditFailure value) {
+      if (value == null) { throw new NullPointerException(); }
+      bitField0_ |= 0x00000008;
+      failure_ = value.getNumber();
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Which kind of refusal `error` reports, so a client acts on the kind rather
+     * than on the message text.
+     * </pre>
+     *
+     * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearFailure() {
+      bitField0_ = (bitField0_ & ~0x00000008);
+      failure_ = 0;
+      onChanged();
+      return this;
+    }
+
+    private java.util.List<org.openmbee.opensysml.proto.Diagnostic> diagnostics_ =
+      java.util.Collections.emptyList();
+    private void ensureDiagnosticsIsMutable() {
+      if (!((bitField0_ & 0x00000010) != 0)) {
+        diagnostics_ = new java.util.ArrayList<org.openmbee.opensysml.proto.Diagnostic>(diagnostics_);
+        bitField0_ |= 0x00000010;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> diagnosticsBuilder_;
+
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Diagnostic> getDiagnosticsList() {
+      if (diagnosticsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(diagnostics_);
+      } else {
+        return diagnosticsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public int getDiagnosticsCount() {
+      if (diagnosticsBuilder_ == null) {
+        return diagnostics_.size();
+      } else {
+        return diagnosticsBuilder_.getCount();
+      }
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index) {
+      if (diagnosticsBuilder_ == null) {
+        return diagnostics_.get(index);
+      } else {
+        return diagnosticsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder setDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic value) {
+      if (diagnosticsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDiagnosticsIsMutable();
+        diagnostics_.set(index, value);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder setDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        diagnosticsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(org.openmbee.opensysml.proto.Diagnostic value) {
+      if (diagnosticsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(value);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic value) {
+      if (diagnosticsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(index, value);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(
+        org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(builderForValue.build());
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder addAllDiagnostics(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.Diagnostic> values) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, diagnostics_);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder clearDiagnostics() {
+      if (diagnosticsBuilder_ == null) {
+        diagnostics_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000010);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder removeDiagnostics(int index) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.remove(index);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic.Builder getDiagnosticsBuilder(
+        int index) {
+      return internalGetDiagnosticsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
+        int index) {
+      if (diagnosticsBuilder_ == null) {
+        return diagnostics_.get(index);  } else {
+        return diagnosticsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+         getDiagnosticsOrBuilderList() {
+      if (diagnosticsBuilder_ != null) {
+        return diagnosticsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(diagnostics_);
+      }
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder() {
+      return internalGetDiagnosticsFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.Diagnostic.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder(
+        int index) {
+      return internalGetDiagnosticsFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.Diagnostic.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+     * or the errors the edited source was found to have. A diagnostic's span
+     * names the document it is in.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Diagnostic.Builder> 
+         getDiagnosticsBuilderList() {
+      return internalGetDiagnosticsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+        internalGetDiagnosticsFieldBuilder() {
+      if (diagnosticsBuilder_ == null) {
+        diagnosticsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder>(
+                diagnostics_,
+                ((bitField0_ & 0x00000010) != 0),
+                getParentForChildren(),
+                isClean());
+        diagnostics_ = null;
+      }
+      return diagnosticsBuilder_;
+    }
+
+    private com.google.protobuf.LazyStringArrayList referringElements_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    private void ensureReferringElementsIsMutable() {
+      if (!referringElements_.isModifiable()) {
+        referringElements_ = new com.google.protobuf.LazyStringArrayList(referringElements_);
+      }
+      bitField0_ |= 0x00000020;
+    }
+    /**
+     * <pre>
+     * Where the references to a declaration whose rename, delete or move was
+     * refused are made: the FQN of each referring namespace, suffixed with its
+     * document in parentheses when that is not the document being edited.
+     * `referrers` carries the same list with the document as a field of its own.
+     * </pre>
+     *
+     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+     * @return A list containing the referringElements.
+     */
+    public com.google.protobuf.ProtocolStringList
+        getReferringElementsList() {
+      referringElements_.makeImmutable();
+      return referringElements_;
+    }
+    /**
+     * <pre>
+     * Where the references to a declaration whose rename, delete or move was
+     * refused are made: the FQN of each referring namespace, suffixed with its
+     * document in parentheses when that is not the document being edited.
+     * `referrers` carries the same list with the document as a field of its own.
+     * </pre>
+     *
+     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+     * @return The count of referringElements.
+     */
+    public int getReferringElementsCount() {
+      return referringElements_.size();
+    }
+    /**
+     * <pre>
+     * Where the references to a declaration whose rename, delete or move was
+     * refused are made: the FQN of each referring namespace, suffixed with its
+     * document in parentheses when that is not the document being edited.
+     * `referrers` carries the same list with the document as a field of its own.
+     * </pre>
+     *
+     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+     * @param index The index of the element to return.
+     * @return The referringElements at the given index.
+     */
+    public java.lang.String getReferringElements(int index) {
+      return referringElements_.get(index);
+    }
+    /**
+     * <pre>
+     * Where the references to a declaration whose rename, delete or move was
+     * refused are made: the FQN of each referring namespace, suffixed with its
+     * document in parentheses when that is not the document being edited.
+     * `referrers` carries the same list with the document as a field of its own.
+     * </pre>
+     *
+     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+     * @param index The index of the value to return.
+     * @return The bytes of the referringElements at the given index.
+     */
+    public com.google.protobuf.ByteString
+        getReferringElementsBytes(int index) {
+      return referringElements_.getByteString(index);
+    }
+    /**
+     * <pre>
+     * Where the references to a declaration whose rename, delete or move was
+     * refused are made: the FQN of each referring namespace, suffixed with its
+     * document in parentheses when that is not the document being edited.
+     * `referrers` carries the same list with the document as a field of its own.
+     * </pre>
+     *
+     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+     * @param index The index to set the value at.
+     * @param value The referringElements to set.
+     * @return This builder for chaining.
+     */
+    public Builder setReferringElements(
+        int index, java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureReferringElementsIsMutable();
+      referringElements_.set(index, value);
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Where the references to a declaration whose rename, delete or move was
+     * refused are made: the FQN of each referring namespace, suffixed with its
+     * document in parentheses when that is not the document being edited.
+     * `referrers` carries the same list with the document as a field of its own.
+     * </pre>
+     *
+     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+     * @param value The referringElements to add.
+     * @return This builder for chaining.
+     */
+    public Builder addReferringElements(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureReferringElementsIsMutable();
+      referringElements_.add(value);
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Where the references to a declaration whose rename, delete or move was
+     * refused are made: the FQN of each referring namespace, suffixed with its
+     * document in parentheses when that is not the document being edited.
+     * `referrers` carries the same list with the document as a field of its own.
+     * </pre>
+     *
+     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+     * @param values The referringElements to add.
+     * @return This builder for chaining.
+     */
+    public Builder addAllReferringElements(
+        java.lang.Iterable<java.lang.String> values) {
+      ensureReferringElementsIsMutable();
+      com.google.protobuf.AbstractMessageLite.Builder.addAll(
+          values, referringElements_);
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Where the references to a declaration whose rename, delete or move was
+     * refused are made: the FQN of each referring namespace, suffixed with its
+     * document in parentheses when that is not the document being edited.
+     * `referrers` carries the same list with the document as a field of its own.
+     * </pre>
+     *
+     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearReferringElements() {
+      referringElements_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+      bitField0_ = (bitField0_ & ~0x00000020);;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Where the references to a declaration whose rename, delete or move was
+     * refused are made: the FQN of each referring namespace, suffixed with its
+     * document in parentheses when that is not the document being edited.
+     * `referrers` carries the same list with the document as a field of its own.
+     * </pre>
+     *
+     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+     * @param value The bytes of the referringElements to add.
+     * @return This builder for chaining.
+     */
+    public Builder addReferringElementsBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      ensureReferringElementsIsMutable();
+      referringElements_.add(value);
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+
+    private java.util.List<org.openmbee.opensysml.proto.EditedDocument> documents_ =
+      java.util.Collections.emptyList();
+    private void ensureDocumentsIsMutable() {
+      if (!((bitField0_ & 0x00000040) != 0)) {
+        documents_ = new java.util.ArrayList<org.openmbee.opensysml.proto.EditedDocument>(documents_);
+        bitField0_ |= 0x00000040;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.EditedDocument, org.openmbee.opensysml.proto.EditedDocument.Builder, org.openmbee.opensysml.proto.EditedDocumentOrBuilder> documentsBuilder_;
+
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.EditedDocument> getDocumentsList() {
+      if (documentsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(documents_);
+      } else {
+        return documentsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public int getDocumentsCount() {
+      if (documentsBuilder_ == null) {
+        return documents_.size();
+      } else {
+        return documentsBuilder_.getCount();
+      }
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public org.openmbee.opensysml.proto.EditedDocument getDocuments(int index) {
+      if (documentsBuilder_ == null) {
+        return documents_.get(index);
+      } else {
+        return documentsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public Builder setDocuments(
+        int index, org.openmbee.opensysml.proto.EditedDocument value) {
+      if (documentsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDocumentsIsMutable();
+        documents_.set(index, value);
+        onChanged();
+      } else {
+        documentsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public Builder setDocuments(
+        int index, org.openmbee.opensysml.proto.EditedDocument.Builder builderForValue) {
+      if (documentsBuilder_ == null) {
+        ensureDocumentsIsMutable();
+        documents_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        documentsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public Builder addDocuments(org.openmbee.opensysml.proto.EditedDocument value) {
+      if (documentsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDocumentsIsMutable();
+        documents_.add(value);
+        onChanged();
+      } else {
+        documentsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public Builder addDocuments(
+        int index, org.openmbee.opensysml.proto.EditedDocument value) {
+      if (documentsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDocumentsIsMutable();
+        documents_.add(index, value);
+        onChanged();
+      } else {
+        documentsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public Builder addDocuments(
+        org.openmbee.opensysml.proto.EditedDocument.Builder builderForValue) {
+      if (documentsBuilder_ == null) {
+        ensureDocumentsIsMutable();
+        documents_.add(builderForValue.build());
+        onChanged();
+      } else {
+        documentsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public Builder addDocuments(
+        int index, org.openmbee.opensysml.proto.EditedDocument.Builder builderForValue) {
+      if (documentsBuilder_ == null) {
+        ensureDocumentsIsMutable();
+        documents_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        documentsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public Builder addAllDocuments(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.EditedDocument> values) {
+      if (documentsBuilder_ == null) {
+        ensureDocumentsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, documents_);
+        onChanged();
+      } else {
+        documentsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public Builder clearDocuments() {
+      if (documentsBuilder_ == null) {
+        documents_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000040);
+        onChanged();
+      } else {
+        documentsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public Builder removeDocuments(int index) {
+      if (documentsBuilder_ == null) {
+        ensureDocumentsIsMutable();
+        documents_.remove(index);
+        onChanged();
+      } else {
+        documentsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public org.openmbee.opensysml.proto.EditedDocument.Builder getDocumentsBuilder(
+        int index) {
+      return internalGetDocumentsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public org.openmbee.opensysml.proto.EditedDocumentOrBuilder getDocumentsOrBuilder(
+        int index) {
+      if (documentsBuilder_ == null) {
+        return documents_.get(index);  } else {
+        return documentsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.EditedDocumentOrBuilder> 
+         getDocumentsOrBuilderList() {
+      if (documentsBuilder_ != null) {
+        return documentsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(documents_);
+      }
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public org.openmbee.opensysml.proto.EditedDocument.Builder addDocumentsBuilder() {
+      return internalGetDocumentsFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.EditedDocument.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public org.openmbee.opensysml.proto.EditedDocument.Builder addDocumentsBuilder(
+        int index) {
+      return internalGetDocumentsFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.EditedDocument.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * The edited notation of every document the edits rewrote, named as the
+     * parse request named it: the document being edited first, then the others
+     * in name order. A document of several the edits left as parsed is not
+     * listed; the one document of a single-document model always is. Empty when
+     * the edits were refused.
+     * </pre>
+     *
+     * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.EditedDocument.Builder> 
+         getDocumentsBuilderList() {
+      return internalGetDocumentsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.EditedDocument, org.openmbee.opensysml.proto.EditedDocument.Builder, org.openmbee.opensysml.proto.EditedDocumentOrBuilder> 
+        internalGetDocumentsFieldBuilder() {
+      if (documentsBuilder_ == null) {
+        documentsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.EditedDocument, org.openmbee.opensysml.proto.EditedDocument.Builder, org.openmbee.opensysml.proto.EditedDocumentOrBuilder>(
+                documents_,
+                ((bitField0_ & 0x00000040) != 0),
+                getParentForChildren(),
+                isClean());
+        documents_ = null;
+      }
+      return documentsBuilder_;
+    }
+
+    private java.util.List<org.openmbee.opensysml.proto.Referrer> referrers_ =
+      java.util.Collections.emptyList();
+    private void ensureReferrersIsMutable() {
+      if (!((bitField0_ & 0x00000080) != 0)) {
+        referrers_ = new java.util.ArrayList<org.openmbee.opensysml.proto.Referrer>(referrers_);
+        bitField0_ |= 0x00000080;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Referrer, org.openmbee.opensysml.proto.Referrer.Builder, org.openmbee.opensysml.proto.ReferrerOrBuilder> referrersBuilder_;
+
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Referrer> getReferrersList() {
+      if (referrersBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(referrers_);
+      } else {
+        return referrersBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public int getReferrersCount() {
+      if (referrersBuilder_ == null) {
+        return referrers_.size();
+      } else {
+        return referrersBuilder_.getCount();
+      }
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public org.openmbee.opensysml.proto.Referrer getReferrers(int index) {
+      if (referrersBuilder_ == null) {
+        return referrers_.get(index);
+      } else {
+        return referrersBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public Builder setReferrers(
+        int index, org.openmbee.opensysml.proto.Referrer value) {
+      if (referrersBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureReferrersIsMutable();
+        referrers_.set(index, value);
+        onChanged();
+      } else {
+        referrersBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public Builder setReferrers(
+        int index, org.openmbee.opensysml.proto.Referrer.Builder builderForValue) {
+      if (referrersBuilder_ == null) {
+        ensureReferrersIsMutable();
+        referrers_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        referrersBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public Builder addReferrers(org.openmbee.opensysml.proto.Referrer value) {
+      if (referrersBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureReferrersIsMutable();
+        referrers_.add(value);
+        onChanged();
+      } else {
+        referrersBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public Builder addReferrers(
+        int index, org.openmbee.opensysml.proto.Referrer value) {
+      if (referrersBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureReferrersIsMutable();
+        referrers_.add(index, value);
+        onChanged();
+      } else {
+        referrersBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public Builder addReferrers(
+        org.openmbee.opensysml.proto.Referrer.Builder builderForValue) {
+      if (referrersBuilder_ == null) {
+        ensureReferrersIsMutable();
+        referrers_.add(builderForValue.build());
+        onChanged();
+      } else {
+        referrersBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public Builder addReferrers(
+        int index, org.openmbee.opensysml.proto.Referrer.Builder builderForValue) {
+      if (referrersBuilder_ == null) {
+        ensureReferrersIsMutable();
+        referrers_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        referrersBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public Builder addAllReferrers(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.Referrer> values) {
+      if (referrersBuilder_ == null) {
+        ensureReferrersIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, referrers_);
+        onChanged();
+      } else {
+        referrersBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public Builder clearReferrers() {
+      if (referrersBuilder_ == null) {
+        referrers_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000080);
+        onChanged();
+      } else {
+        referrersBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public Builder removeReferrers(int index) {
+      if (referrersBuilder_ == null) {
+        ensureReferrersIsMutable();
+        referrers_.remove(index);
+        onChanged();
+      } else {
+        referrersBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public org.openmbee.opensysml.proto.Referrer.Builder getReferrersBuilder(
+        int index) {
+      return internalGetReferrersFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public org.openmbee.opensysml.proto.ReferrerOrBuilder getReferrersOrBuilder(
+        int index) {
+      if (referrersBuilder_ == null) {
+        return referrers_.get(index);  } else {
+        return referrersBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.ReferrerOrBuilder> 
+         getReferrersOrBuilderList() {
+      if (referrersBuilder_ != null) {
+        return referrersBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(referrers_);
+      }
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public org.openmbee.opensysml.proto.Referrer.Builder addReferrersBuilder() {
+      return internalGetReferrersFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.Referrer.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public org.openmbee.opensysml.proto.Referrer.Builder addReferrersBuilder(
+        int index) {
+      return internalGetReferrersFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.Referrer.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * The declarations referring to the target of a refused rename, delete or
+     * move, each with the document declaring it, in document then name order.
+     * Empty when `referring_elements` is.
+     * </pre>
+     *
+     * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Referrer.Builder> 
+         getReferrersBuilderList() {
+      return internalGetReferrersFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Referrer, org.openmbee.opensysml.proto.Referrer.Builder, org.openmbee.opensysml.proto.ReferrerOrBuilder> 
+        internalGetReferrersFieldBuilder() {
+      if (referrersBuilder_ == null) {
+        referrersBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.Referrer, org.openmbee.opensysml.proto.Referrer.Builder, org.openmbee.opensysml.proto.ReferrerOrBuilder>(
+                referrers_,
+                ((bitField0_ & 0x00000080) != 0),
+                getParentForChildren(),
+                isClean());
+        referrers_ = null;
+      }
+      return referrersBuilder_;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.ApplyEditsResponse)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.ApplyEditsResponse)
+  private static final org.openmbee.opensysml.proto.ApplyEditsResponse DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.ApplyEditsResponse();
+  }
+
+  public static org.openmbee.opensysml.proto.ApplyEditsResponse getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<ApplyEditsResponse>
+      PARSER = new com.google.protobuf.AbstractParser<ApplyEditsResponse>() {
+    @java.lang.Override
+    public ApplyEditsResponse parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<ApplyEditsResponse> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<ApplyEditsResponse> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.ApplyEditsResponse getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponseOrBuilder.java
new file mode 100644
index 0000000000..8bce176990
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponseOrBuilder.java
@@ -0,0 +1,359 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface ApplyEditsResponseOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.ApplyEditsResponse)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * The edited notation of a single-document model, byte-identical to the
+   * source outside the edited spans. Empty when the edits were refused, so a
+   * refusal never writes a file, and empty for a model of several documents,
+   * whose edited notation is in `documents` alone: a client that reads
+   * `content` alone was written for one document and must not write one
+   * document's notation over another's. `documents` carries the same notation
+   * for a single-document model, so a client needs one code path.
+   * </pre>
+   *
+   * <code>string content = 1 [json_name = "content"];</code>
+   * @return The content.
+   */
+  java.lang.String getContent();
+  /**
+   * <pre>
+   * The edited notation of a single-document model, byte-identical to the
+   * source outside the edited spans. Empty when the edits were refused, so a
+   * refusal never writes a file, and empty for a model of several documents,
+   * whose edited notation is in `documents` alone: a client that reads
+   * `content` alone was written for one document and must not write one
+   * document's notation over another's. `documents` carries the same notation
+   * for a single-document model, so a client needs one code path.
+   * </pre>
+   *
+   * <code>string content = 1 [json_name = "content"];</code>
+   * @return The bytes for content.
+   */
+  com.google.protobuf.ByteString
+      getContentBytes();
+
+  /**
+   * <pre>
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   * </pre>
+   *
+   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+   */
+  java.util.List<org.openmbee.opensysml.proto.AppliedEdit> 
+      getAppliedList();
+  /**
+   * <pre>
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   * </pre>
+   *
+   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+   */
+  org.openmbee.opensysml.proto.AppliedEdit getApplied(int index);
+  /**
+   * <pre>
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   * </pre>
+   *
+   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+   */
+  int getAppliedCount();
+  /**
+   * <pre>
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   * </pre>
+   *
+   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+   */
+  java.util.List<? extends org.openmbee.opensysml.proto.AppliedEditOrBuilder> 
+      getAppliedOrBuilderList();
+  /**
+   * <pre>
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   * </pre>
+   *
+   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
+   */
+  org.openmbee.opensysml.proto.AppliedEditOrBuilder getAppliedOrBuilder(
+      int index);
+
+  /**
+   * <pre>
+   * non-empty if the edits were refused; content and documents are unset
+   * </pre>
+   *
+   * <code>string error = 3 [json_name = "error"];</code>
+   * @return The error.
+   */
+  java.lang.String getError();
+  /**
+   * <pre>
+   * non-empty if the edits were refused; content and documents are unset
+   * </pre>
+   *
+   * <code>string error = 3 [json_name = "error"];</code>
+   * @return The bytes for error.
+   */
+  com.google.protobuf.ByteString
+      getErrorBytes();
+
+  /**
+   * <pre>
+   * Which kind of refusal `error` reports, so a client acts on the kind rather
+   * than on the message text.
+   * </pre>
+   *
+   * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
+   * @return The enum numeric value on the wire for failure.
+   */
+  int getFailureValue();
+  /**
+   * <pre>
+   * Which kind of refusal `error` reports, so a client acts on the kind rather
+   * than on the message text.
+   * </pre>
+   *
+   * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
+   * @return The failure.
+   */
+  org.openmbee.opensysml.proto.EditFailure getFailure();
+
+  /**
+   * <pre>
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  java.util.List<org.openmbee.opensysml.proto.Diagnostic> 
+      getDiagnosticsList();
+  /**
+   * <pre>
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index);
+  /**
+   * <pre>
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  int getDiagnosticsCount();
+  /**
+   * <pre>
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+      getDiagnosticsOrBuilderList();
+  /**
+   * <pre>
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
+      int index);
+
+  /**
+   * <pre>
+   * Where the references to a declaration whose rename, delete or move was
+   * refused are made: the FQN of each referring namespace, suffixed with its
+   * document in parentheses when that is not the document being edited.
+   * `referrers` carries the same list with the document as a field of its own.
+   * </pre>
+   *
+   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+   * @return A list containing the referringElements.
+   */
+  java.util.List<java.lang.String>
+      getReferringElementsList();
+  /**
+   * <pre>
+   * Where the references to a declaration whose rename, delete or move was
+   * refused are made: the FQN of each referring namespace, suffixed with its
+   * document in parentheses when that is not the document being edited.
+   * `referrers` carries the same list with the document as a field of its own.
+   * </pre>
+   *
+   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+   * @return The count of referringElements.
+   */
+  int getReferringElementsCount();
+  /**
+   * <pre>
+   * Where the references to a declaration whose rename, delete or move was
+   * refused are made: the FQN of each referring namespace, suffixed with its
+   * document in parentheses when that is not the document being edited.
+   * `referrers` carries the same list with the document as a field of its own.
+   * </pre>
+   *
+   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+   * @param index The index of the element to return.
+   * @return The referringElements at the given index.
+   */
+  java.lang.String getReferringElements(int index);
+  /**
+   * <pre>
+   * Where the references to a declaration whose rename, delete or move was
+   * refused are made: the FQN of each referring namespace, suffixed with its
+   * document in parentheses when that is not the document being edited.
+   * `referrers` carries the same list with the document as a field of its own.
+   * </pre>
+   *
+   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the referringElements at the given index.
+   */
+  com.google.protobuf.ByteString
+      getReferringElementsBytes(int index);
+
+  /**
+   * <pre>
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+   */
+  java.util.List<org.openmbee.opensysml.proto.EditedDocument> 
+      getDocumentsList();
+  /**
+   * <pre>
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+   */
+  org.openmbee.opensysml.proto.EditedDocument getDocuments(int index);
+  /**
+   * <pre>
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+   */
+  int getDocumentsCount();
+  /**
+   * <pre>
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+   */
+  java.util.List<? extends org.openmbee.opensysml.proto.EditedDocumentOrBuilder> 
+      getDocumentsOrBuilderList();
+  /**
+   * <pre>
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   * </pre>
+   *
+   * <code>repeated .sysml.EditedDocument documents = 7 [json_name = "documents"];</code>
+   */
+  org.openmbee.opensysml.proto.EditedDocumentOrBuilder getDocumentsOrBuilder(
+      int index);
+
+  /**
+   * <pre>
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   * </pre>
+   *
+   * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+   */
+  java.util.List<org.openmbee.opensysml.proto.Referrer> 
+      getReferrersList();
+  /**
+   * <pre>
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   * </pre>
+   *
+   * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+   */
+  org.openmbee.opensysml.proto.Referrer getReferrers(int index);
+  /**
+   * <pre>
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   * </pre>
+   *
+   * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+   */
+  int getReferrersCount();
+  /**
+   * <pre>
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   * </pre>
+   *
+   * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+   */
+  java.util.List<? extends org.openmbee.opensysml.proto.ReferrerOrBuilder> 
+      getReferrersOrBuilderList();
+  /**
+   * <pre>
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   * </pre>
+   *
+   * <code>repeated .sysml.Referrer referrers = 8 [json_name = "referrers"];</code>
+   */
+  org.openmbee.opensysml.proto.ReferrerOrBuilder getReferrersOrBuilder(
+      int index);
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Array.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Array.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Array.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Array.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ArrayOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ArrayOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ArrayOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ArrayOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AttributeInfo.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AttributeInfo.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AttributeInfo.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AttributeInfo.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AttributeInfoOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AttributeInfoOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AttributeInfoOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AttributeInfoOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Bound.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Bound.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Bound.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Bound.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/BoundOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/BoundOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/BoundOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/BoundOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CalcOutput.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CalcOutput.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CalcOutput.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CalcOutput.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CalcOutputOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CalcOutputOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CalcOutputOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CalcOutputOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CaseEvaluation.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CaseEvaluation.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CaseEvaluation.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CaseEvaluation.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CaseEvaluationOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CaseEvaluationOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CaseEvaluationOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CaseEvaluationOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Complex.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Complex.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Complex.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Complex.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ComplexOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ComplexOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ComplexOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ComplexOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeConstraint.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeConstraint.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeConstraint.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeConstraint.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeConstraintOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeConstraintOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeConstraintOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeConstraintOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeOperator.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeOperator.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeOperator.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/CompositeOperator.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Constraint.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Constraint.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Constraint.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Constraint.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConstraintOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConstraintOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConstraintOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConstraintOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequest.java
similarity index 92%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequest.java
index 9d5fe0599e..863725dbe0 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequest.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequest.java
@@ -256,9 +256,11 @@ public java.lang.String getModelHash() {
   private volatile java.lang.Object fromFormat_ = "";
   /**
    * <pre>
-   * "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-   * file_path's extension, and is notation for a model_hash, since that is what
-   * parse reads; inline content has neither, so it must say.
+   * "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+   * "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+   * v2 and never written. Empty infers from file_path's extension, and is
+   * notation for a model_hash, since that is what parse reads; inline content
+   * has neither, so it must say.
    * </pre>
    *
    * <code>string from_format = 3 [json_name = "fromFormat"];</code>
@@ -279,9 +281,11 @@ public java.lang.String getFromFormat() {
   }
   /**
    * <pre>
-   * "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-   * file_path's extension, and is notation for a model_hash, since that is what
-   * parse reads; inline content has neither, so it must say.
+   * "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+   * "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+   * v2 and never written. Empty infers from file_path's extension, and is
+   * notation for a model_hash, since that is what parse reads; inline content
+   * has neither, so it must say.
    * </pre>
    *
    * <code>string from_format = 3 [json_name = "fromFormat"];</code>
@@ -307,7 +311,8 @@ public java.lang.String getFromFormat() {
   private volatile java.lang.Object toFormat_ = "";
   /**
    * <pre>
-   * Format to write, named as in from_format. Empty is rejected.
+   * Format to write, named as in from_format; the v1 names are refused, since
+   * a v2 model has no v1 form. Empty is rejected.
    * </pre>
    *
    * <code>string to_format = 4 [json_name = "toFormat"];</code>
@@ -328,7 +333,8 @@ public java.lang.String getToFormat() {
   }
   /**
    * <pre>
-   * Format to write, named as in from_format. Empty is rejected.
+   * Format to write, named as in from_format; the v1 names are refused, since
+   * a v2 model has no v1 form. Empty is rejected.
    * </pre>
    *
    * <code>string to_format = 4 [json_name = "toFormat"];</code>
@@ -1112,9 +1118,11 @@ public Builder setModelHashBytes(
     private java.lang.Object fromFormat_ = "";
     /**
      * <pre>
-     * "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-     * file_path's extension, and is notation for a model_hash, since that is what
-     * parse reads; inline content has neither, so it must say.
+     * "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+     * "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+     * v2 and never written. Empty infers from file_path's extension, and is
+     * notation for a model_hash, since that is what parse reads; inline content
+     * has neither, so it must say.
      * </pre>
      *
      * <code>string from_format = 3 [json_name = "fromFormat"];</code>
@@ -1134,9 +1142,11 @@ public java.lang.String getFromFormat() {
     }
     /**
      * <pre>
-     * "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-     * file_path's extension, and is notation for a model_hash, since that is what
-     * parse reads; inline content has neither, so it must say.
+     * "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+     * "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+     * v2 and never written. Empty infers from file_path's extension, and is
+     * notation for a model_hash, since that is what parse reads; inline content
+     * has neither, so it must say.
      * </pre>
      *
      * <code>string from_format = 3 [json_name = "fromFormat"];</code>
@@ -1157,9 +1167,11 @@ public java.lang.String getFromFormat() {
     }
     /**
      * <pre>
-     * "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-     * file_path's extension, and is notation for a model_hash, since that is what
-     * parse reads; inline content has neither, so it must say.
+     * "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+     * "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+     * v2 and never written. Empty infers from file_path's extension, and is
+     * notation for a model_hash, since that is what parse reads; inline content
+     * has neither, so it must say.
      * </pre>
      *
      * <code>string from_format = 3 [json_name = "fromFormat"];</code>
@@ -1176,9 +1188,11 @@ public Builder setFromFormat(
     }
     /**
      * <pre>
-     * "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-     * file_path's extension, and is notation for a model_hash, since that is what
-     * parse reads; inline content has neither, so it must say.
+     * "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+     * "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+     * v2 and never written. Empty infers from file_path's extension, and is
+     * notation for a model_hash, since that is what parse reads; inline content
+     * has neither, so it must say.
      * </pre>
      *
      * <code>string from_format = 3 [json_name = "fromFormat"];</code>
@@ -1192,9 +1206,11 @@ public Builder clearFromFormat() {
     }
     /**
      * <pre>
-     * "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-     * file_path's extension, and is notation for a model_hash, since that is what
-     * parse reads; inline content has neither, so it must say.
+     * "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+     * "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+     * v2 and never written. Empty infers from file_path's extension, and is
+     * notation for a model_hash, since that is what parse reads; inline content
+     * has neither, so it must say.
      * </pre>
      *
      * <code>string from_format = 3 [json_name = "fromFormat"];</code>
@@ -1214,7 +1230,8 @@ public Builder setFromFormatBytes(
     private java.lang.Object toFormat_ = "";
     /**
      * <pre>
-     * Format to write, named as in from_format. Empty is rejected.
+     * Format to write, named as in from_format; the v1 names are refused, since
+     * a v2 model has no v1 form. Empty is rejected.
      * </pre>
      *
      * <code>string to_format = 4 [json_name = "toFormat"];</code>
@@ -1234,7 +1251,8 @@ public java.lang.String getToFormat() {
     }
     /**
      * <pre>
-     * Format to write, named as in from_format. Empty is rejected.
+     * Format to write, named as in from_format; the v1 names are refused, since
+     * a v2 model has no v1 form. Empty is rejected.
      * </pre>
      *
      * <code>string to_format = 4 [json_name = "toFormat"];</code>
@@ -1255,7 +1273,8 @@ public java.lang.String getToFormat() {
     }
     /**
      * <pre>
-     * Format to write, named as in from_format. Empty is rejected.
+     * Format to write, named as in from_format; the v1 names are refused, since
+     * a v2 model has no v1 form. Empty is rejected.
      * </pre>
      *
      * <code>string to_format = 4 [json_name = "toFormat"];</code>
@@ -1272,7 +1291,8 @@ public Builder setToFormat(
     }
     /**
      * <pre>
-     * Format to write, named as in from_format. Empty is rejected.
+     * Format to write, named as in from_format; the v1 names are refused, since
+     * a v2 model has no v1 form. Empty is rejected.
      * </pre>
      *
      * <code>string to_format = 4 [json_name = "toFormat"];</code>
@@ -1286,7 +1306,8 @@ public Builder clearToFormat() {
     }
     /**
      * <pre>
-     * Format to write, named as in from_format. Empty is rejected.
+     * Format to write, named as in from_format; the v1 names are refused, since
+     * a v2 model has no v1 form. Empty is rejected.
      * </pre>
      *
      * <code>string to_format = 4 [json_name = "toFormat"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequestOrBuilder.java
similarity index 76%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequestOrBuilder.java
index a2ad392930..52bad6d344 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequestOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertRequestOrBuilder.java
@@ -63,9 +63,11 @@ public interface ConvertRequestOrBuilder extends
 
   /**
    * <pre>
-   * "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-   * file_path's extension, and is notation for a model_hash, since that is what
-   * parse reads; inline content has neither, so it must say.
+   * "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+   * "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+   * v2 and never written. Empty infers from file_path's extension, and is
+   * notation for a model_hash, since that is what parse reads; inline content
+   * has neither, so it must say.
    * </pre>
    *
    * <code>string from_format = 3 [json_name = "fromFormat"];</code>
@@ -74,9 +76,11 @@ public interface ConvertRequestOrBuilder extends
   java.lang.String getFromFormat();
   /**
    * <pre>
-   * "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-   * file_path's extension, and is notation for a model_hash, since that is what
-   * parse reads; inline content has neither, so it must say.
+   * "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+   * "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+   * v2 and never written. Empty infers from file_path's extension, and is
+   * notation for a model_hash, since that is what parse reads; inline content
+   * has neither, so it must say.
    * </pre>
    *
    * <code>string from_format = 3 [json_name = "fromFormat"];</code>
@@ -87,7 +91,8 @@ public interface ConvertRequestOrBuilder extends
 
   /**
    * <pre>
-   * Format to write, named as in from_format. Empty is rejected.
+   * Format to write, named as in from_format; the v1 names are refused, since
+   * a v2 model has no v1 form. Empty is rejected.
    * </pre>
    *
    * <code>string to_format = 4 [json_name = "toFormat"];</code>
@@ -96,7 +101,8 @@ public interface ConvertRequestOrBuilder extends
   java.lang.String getToFormat();
   /**
    * <pre>
-   * Format to write, named as in from_format. Empty is rejected.
+   * Format to write, named as in from_format; the v1 names are refused, since
+   * a v2 model has no v1 form. Empty is rejected.
    * </pre>
    *
    * <code>string to_format = 4 [json_name = "toFormat"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponse.java
similarity index 97%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponse.java
index 75d7758ff3..1b74221085 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponse.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponse.java
@@ -297,10 +297,12 @@ public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
   private boolean experimental_ = false;
   /**
    * <pre>
-   * Set when either format is RDF, whose mapping is experimental: it covers
-   * model structure and the behavior its bodies state, refuses what it cannot
-   * write back, and its vocabulary may change without a compatibility path.
-   * Notation to notation is stable and leaves this unset.
+   * Set when either format is RDF or the API's JSON element form, whose
+   * mapping is experimental: it covers model structure and the behavior its
+   * bodies state, refuses what it cannot write back, and its vocabulary may
+   * change without a compatibility path. Also set when the source is SysML v1,
+   * whose migration is experimental in the same sense. Notation to notation is
+   * stable and leaves this unset.
    * </pre>
    *
    * <code>bool experimental = 6 [json_name = "experimental"];</code>
@@ -1516,10 +1518,12 @@ public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder(
     private boolean experimental_ ;
     /**
      * <pre>
-     * Set when either format is RDF, whose mapping is experimental: it covers
-     * model structure and the behavior its bodies state, refuses what it cannot
-     * write back, and its vocabulary may change without a compatibility path.
-     * Notation to notation is stable and leaves this unset.
+     * Set when either format is RDF or the API's JSON element form, whose
+     * mapping is experimental: it covers model structure and the behavior its
+     * bodies state, refuses what it cannot write back, and its vocabulary may
+     * change without a compatibility path. Also set when the source is SysML v1,
+     * whose migration is experimental in the same sense. Notation to notation is
+     * stable and leaves this unset.
      * </pre>
      *
      * <code>bool experimental = 6 [json_name = "experimental"];</code>
@@ -1531,10 +1535,12 @@ public boolean getExperimental() {
     }
     /**
      * <pre>
-     * Set when either format is RDF, whose mapping is experimental: it covers
-     * model structure and the behavior its bodies state, refuses what it cannot
-     * write back, and its vocabulary may change without a compatibility path.
-     * Notation to notation is stable and leaves this unset.
+     * Set when either format is RDF or the API's JSON element form, whose
+     * mapping is experimental: it covers model structure and the behavior its
+     * bodies state, refuses what it cannot write back, and its vocabulary may
+     * change without a compatibility path. Also set when the source is SysML v1,
+     * whose migration is experimental in the same sense. Notation to notation is
+     * stable and leaves this unset.
      * </pre>
      *
      * <code>bool experimental = 6 [json_name = "experimental"];</code>
@@ -1550,10 +1556,12 @@ public Builder setExperimental(boolean value) {
     }
     /**
      * <pre>
-     * Set when either format is RDF, whose mapping is experimental: it covers
-     * model structure and the behavior its bodies state, refuses what it cannot
-     * write back, and its vocabulary may change without a compatibility path.
-     * Notation to notation is stable and leaves this unset.
+     * Set when either format is RDF or the API's JSON element form, whose
+     * mapping is experimental: it covers model structure and the behavior its
+     * bodies state, refuses what it cannot write back, and its vocabulary may
+     * change without a compatibility path. Also set when the source is SysML v1,
+     * whose migration is experimental in the same sense. Notation to notation is
+     * stable and leaves this unset.
      * </pre>
      *
      * <code>bool experimental = 6 [json_name = "experimental"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponseOrBuilder.java
similarity index 91%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponseOrBuilder.java
index aeec919b8a..4aa98e65bf 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponseOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ConvertResponseOrBuilder.java
@@ -127,10 +127,12 @@ org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
 
   /**
    * <pre>
-   * Set when either format is RDF, whose mapping is experimental: it covers
-   * model structure and the behavior its bodies state, refuses what it cannot
-   * write back, and its vocabulary may change without a compatibility path.
-   * Notation to notation is stable and leaves this unset.
+   * Set when either format is RDF or the API's JSON element form, whose
+   * mapping is experimental: it covers model structure and the behavior its
+   * bodies state, refuses what it cannot write back, and its vocabulary may
+   * change without a compatibility path. Also set when the source is SysML v1,
+   * whose migration is experimental in the same sense. Notation to notation is
+   * stable and leaves this unset.
    * </pre>
    *
    * <code>bool experimental = 6 [json_name = "experimental"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DeleteEdit.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DeleteEdit.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DeleteEdit.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DeleteEdit.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DeleteEditOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DeleteEditOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DeleteEditOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DeleteEditOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Diagnostic.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Diagnostic.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Diagnostic.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Diagnostic.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsRequest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsRequest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DiagnosticsResponseOrBuilder.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentEvent.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentEvent.java
new file mode 100644
index 0000000000..1bb66066e7
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentEvent.java
@@ -0,0 +1,2864 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * DocumentEvent is one row an `Events` query answered: one record of the
+ * session's trace, in the order the run made it.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.DocumentEvent}
+ */
+@com.google.protobuf.Generated
+public final class DocumentEvent extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.DocumentEvent)
+    DocumentEventOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "DocumentEvent");
+  }
+  // Use DocumentEvent.newBuilder() to construct.
+  private DocumentEvent(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private DocumentEvent() {
+    kind_ = "";
+    machine_ = "";
+    state_ = "";
+    from_ = "";
+    to_ = "";
+    event_ = "";
+    payload_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    alternatives_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    taken_ = "";
+    text_ = "";
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentEvent_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentEvent_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.DocumentEvent.class, org.openmbee.opensysml.proto.DocumentEvent.Builder.class);
+  }
+
+  private int bitField0_;
+  public static final int KIND_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object kind_ = "";
+  /**
+   * <pre>
+   * "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+   * </pre>
+   *
+   * <code>string kind = 1 [json_name = "kind"];</code>
+   * @return The kind.
+   */
+  @java.lang.Override
+  public java.lang.String getKind() {
+    java.lang.Object ref = kind_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      kind_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+   * </pre>
+   *
+   * <code>string kind = 1 [json_name = "kind"];</code>
+   * @return The bytes for kind.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getKindBytes() {
+    java.lang.Object ref = kind_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      kind_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int TIME_FIELD_NUMBER = 2;
+  private org.openmbee.opensysml.proto.DocumentValue time_;
+  /**
+   * <pre>
+   * The clock's instant when the record was made: a quantity in the clock's
+   * unit when the library reduces one, else a bare real of clock units.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+   * @return Whether the time field is set.
+   */
+  @java.lang.Override
+  public boolean hasTime() {
+    return ((bitField0_ & 0x00000001) != 0);
+  }
+  /**
+   * <pre>
+   * The clock's instant when the record was made: a quantity in the clock's
+   * unit when the library reduces one, else a bare real of clock units.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+   * @return The time.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentValue getTime() {
+    return time_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : time_;
+  }
+  /**
+   * <pre>
+   * The clock's instant when the record was made: a quantity in the clock's
+   * unit when the library reduces one, else a bare real of clock units.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentValueOrBuilder getTimeOrBuilder() {
+    return time_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : time_;
+  }
+
+  public static final int OBJECT_FIELD_NUMBER = 3;
+  private org.openmbee.opensysml.proto.DocumentObject object_;
+  /**
+   * <pre>
+   * The object whose behavior made the record; unset for the run's own
+   * records (a due-order choice, a message posted from outside the model).
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+   * @return Whether the object field is set.
+   */
+  @java.lang.Override
+  public boolean hasObject() {
+    return ((bitField0_ & 0x00000002) != 0);
+  }
+  /**
+   * <pre>
+   * The object whose behavior made the record; unset for the run's own
+   * records (a due-order choice, a message posted from outside the model).
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+   * @return The object.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentObject getObject() {
+    return object_ == null ? org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : object_;
+  }
+  /**
+   * <pre>
+   * The object whose behavior made the record; unset for the run's own
+   * records (a due-order choice, a message posted from outside the model).
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentObjectOrBuilder getObjectOrBuilder() {
+    return object_ == null ? org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : object_;
+  }
+
+  public static final int MACHINE_FIELD_NUMBER = 4;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object machine_ = "";
+  /**
+   * <pre>
+   * The behavior the record is about, by the name the object exhibits it
+   * under, else its declared name; empty for the run's own records.
+   * </pre>
+   *
+   * <code>string machine = 4 [json_name = "machine"];</code>
+   * @return The machine.
+   */
+  @java.lang.Override
+  public java.lang.String getMachine() {
+    java.lang.Object ref = machine_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      machine_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The behavior the record is about, by the name the object exhibits it
+   * under, else its declared name; empty for the run's own records.
+   * </pre>
+   *
+   * <code>string machine = 4 [json_name = "machine"];</code>
+   * @return The bytes for machine.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getMachineBytes() {
+    java.lang.Object ref = machine_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      machine_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int STATE_FIELD_NUMBER = 5;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object state_ = "";
+  /**
+   * <pre>
+   * The state entered, exited or stepped by an entry, exit or do record.
+   * </pre>
+   *
+   * <code>string state = 5 [json_name = "state"];</code>
+   * @return The state.
+   */
+  @java.lang.Override
+  public java.lang.String getState() {
+    java.lang.Object ref = state_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      state_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The state entered, exited or stepped by an entry, exit or do record.
+   * </pre>
+   *
+   * <code>string state = 5 [json_name = "state"];</code>
+   * @return The bytes for state.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getStateBytes() {
+    java.lang.Object ref = state_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      state_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int FROM_FIELD_NUMBER = 6;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object from_ = "";
+  /**
+   * <pre>
+   * A fired transition's source and target states.
+   * </pre>
+   *
+   * <code>string from = 6 [json_name = "from"];</code>
+   * @return The from.
+   */
+  @java.lang.Override
+  public java.lang.String getFrom() {
+    java.lang.Object ref = from_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      from_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * A fired transition's source and target states.
+   * </pre>
+   *
+   * <code>string from = 6 [json_name = "from"];</code>
+   * @return The bytes for from.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getFromBytes() {
+    java.lang.Object ref = from_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      from_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int TO_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object to_ = "";
+  /**
+   * <code>string to = 7 [json_name = "to"];</code>
+   * @return The to.
+   */
+  @java.lang.Override
+  public java.lang.String getTo() {
+    java.lang.Object ref = to_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      to_ = s;
+      return s;
+    }
+  }
+  /**
+   * <code>string to = 7 [json_name = "to"];</code>
+   * @return The bytes for to.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getToBytes() {
+    java.lang.Object ref = to_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      to_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int TARGET_FIELD_NUMBER = 8;
+  private org.openmbee.opensysml.proto.DocumentObject target_;
+  /**
+   * <pre>
+   * The object a send was addressed to; unset for every other kind and a
+   * send addressed to no object.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+   * @return Whether the target field is set.
+   */
+  @java.lang.Override
+  public boolean hasTarget() {
+    return ((bitField0_ & 0x00000004) != 0);
+  }
+  /**
+   * <pre>
+   * The object a send was addressed to; unset for every other kind and a
+   * send addressed to no object.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+   * @return The target.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentObject getTarget() {
+    return target_ == null ? org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : target_;
+  }
+  /**
+   * <pre>
+   * The object a send was addressed to; unset for every other kind and a
+   * send addressed to no object.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentObjectOrBuilder getTargetOrBuilder() {
+    return target_ == null ? org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : target_;
+  }
+
+  public static final int EVENT_FIELD_NUMBER = 9;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object event_ = "";
+  /**
+   * <pre>
+   * The signal or event accepted or sent, or the trigger a transition fired on.
+   * </pre>
+   *
+   * <code>string event = 9 [json_name = "event"];</code>
+   * @return The event.
+   */
+  @java.lang.Override
+  public java.lang.String getEvent() {
+    java.lang.Object ref = event_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      event_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The signal or event accepted or sent, or the trigger a transition fired on.
+   * </pre>
+   *
+   * <code>string event = 9 [json_name = "event"];</code>
+   * @return The bytes for event.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getEventBytes() {
+    java.lang.Object ref = event_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      event_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int PAYLOAD_FIELD_NUMBER = 10;
+  @SuppressWarnings("serial")
+  private com.google.protobuf.LazyStringArrayList payload_ =
+      com.google.protobuf.LazyStringArrayList.emptyList();
+  /**
+   * <pre>
+   * An accept's payload, one `name = value` entry per parameter in name order.
+   * </pre>
+   *
+   * <code>repeated string payload = 10 [json_name = "payload"];</code>
+   * @return A list containing the payload.
+   */
+  public com.google.protobuf.ProtocolStringList
+      getPayloadList() {
+    return payload_;
+  }
+  /**
+   * <pre>
+   * An accept's payload, one `name = value` entry per parameter in name order.
+   * </pre>
+   *
+   * <code>repeated string payload = 10 [json_name = "payload"];</code>
+   * @return The count of payload.
+   */
+  public int getPayloadCount() {
+    return payload_.size();
+  }
+  /**
+   * <pre>
+   * An accept's payload, one `name = value` entry per parameter in name order.
+   * </pre>
+   *
+   * <code>repeated string payload = 10 [json_name = "payload"];</code>
+   * @param index The index of the element to return.
+   * @return The payload at the given index.
+   */
+  public java.lang.String getPayload(int index) {
+    return payload_.get(index);
+  }
+  /**
+   * <pre>
+   * An accept's payload, one `name = value` entry per parameter in name order.
+   * </pre>
+   *
+   * <code>repeated string payload = 10 [json_name = "payload"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the payload at the given index.
+   */
+  public com.google.protobuf.ByteString
+      getPayloadBytes(int index) {
+    return payload_.getByteString(index);
+  }
+
+  public static final int ALTERNATIVES_FIELD_NUMBER = 11;
+  @SuppressWarnings("serial")
+  private com.google.protobuf.LazyStringArrayList alternatives_ =
+      com.google.protobuf.LazyStringArrayList.emptyList();
+  /**
+   * <pre>
+   * A choice's alternatives, in the order they were offered, and the one taken.
+   * </pre>
+   *
+   * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+   * @return A list containing the alternatives.
+   */
+  public com.google.protobuf.ProtocolStringList
+      getAlternativesList() {
+    return alternatives_;
+  }
+  /**
+   * <pre>
+   * A choice's alternatives, in the order they were offered, and the one taken.
+   * </pre>
+   *
+   * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+   * @return The count of alternatives.
+   */
+  public int getAlternativesCount() {
+    return alternatives_.size();
+  }
+  /**
+   * <pre>
+   * A choice's alternatives, in the order they were offered, and the one taken.
+   * </pre>
+   *
+   * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+   * @param index The index of the element to return.
+   * @return The alternatives at the given index.
+   */
+  public java.lang.String getAlternatives(int index) {
+    return alternatives_.get(index);
+  }
+  /**
+   * <pre>
+   * A choice's alternatives, in the order they were offered, and the one taken.
+   * </pre>
+   *
+   * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the alternatives at the given index.
+   */
+  public com.google.protobuf.ByteString
+      getAlternativesBytes(int index) {
+    return alternatives_.getByteString(index);
+  }
+
+  public static final int TAKEN_FIELD_NUMBER = 12;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object taken_ = "";
+  /**
+   * <code>string taken = 12 [json_name = "taken"];</code>
+   * @return The taken.
+   */
+  @java.lang.Override
+  public java.lang.String getTaken() {
+    java.lang.Object ref = taken_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      taken_ = s;
+      return s;
+    }
+  }
+  /**
+   * <code>string taken = 12 [json_name = "taken"];</code>
+   * @return The bytes for taken.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getTakenBytes() {
+    java.lang.Object ref = taken_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      taken_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int TEXT_FIELD_NUMBER = 13;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object text_ = "";
+  /**
+   * <pre>
+   * The line the trace prints for the record.
+   * </pre>
+   *
+   * <code>string text = 13 [json_name = "text"];</code>
+   * @return The text.
+   */
+  @java.lang.Override
+  public java.lang.String getText() {
+    java.lang.Object ref = text_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      text_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The line the trace prints for the record.
+   * </pre>
+   *
+   * <code>string text = 13 [json_name = "text"];</code>
+   * @return The bytes for text.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getTextBytes() {
+    java.lang.Object ref = text_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      text_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(kind_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 1, kind_);
+    }
+    if (((bitField0_ & 0x00000001) != 0)) {
+      output.writeMessage(2, getTime());
+    }
+    if (((bitField0_ & 0x00000002) != 0)) {
+      output.writeMessage(3, getObject());
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(machine_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 4, machine_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(state_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 5, state_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(from_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 6, from_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(to_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 7, to_);
+    }
+    if (((bitField0_ & 0x00000004) != 0)) {
+      output.writeMessage(8, getTarget());
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(event_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 9, event_);
+    }
+    for (int i = 0; i < payload_.size(); i++) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 10, payload_.getRaw(i));
+    }
+    for (int i = 0; i < alternatives_.size(); i++) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 11, alternatives_.getRaw(i));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(taken_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 12, taken_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(text_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 13, text_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(kind_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, kind_);
+    }
+    if (((bitField0_ & 0x00000001) != 0)) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(2, getTime());
+    }
+    if (((bitField0_ & 0x00000002) != 0)) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(3, getObject());
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(machine_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(4, machine_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(state_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(5, state_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(from_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(6, from_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(to_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(7, to_);
+    }
+    if (((bitField0_ & 0x00000004) != 0)) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(8, getTarget());
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(event_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(9, event_);
+    }
+    {
+      int dataSize = 0;
+      for (int i = 0; i < payload_.size(); i++) {
+        dataSize += computeStringSizeNoTag(payload_.getRaw(i));
+      }
+      size += dataSize;
+      size += 1 * getPayloadList().size();
+    }
+    {
+      int dataSize = 0;
+      for (int i = 0; i < alternatives_.size(); i++) {
+        dataSize += computeStringSizeNoTag(alternatives_.getRaw(i));
+      }
+      size += dataSize;
+      size += 1 * getAlternativesList().size();
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(taken_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(12, taken_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(text_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(13, text_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.DocumentEvent)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.DocumentEvent other = (org.openmbee.opensysml.proto.DocumentEvent) obj;
+
+    if (!getKind()
+        .equals(other.getKind())) return false;
+    if (hasTime() != other.hasTime()) return false;
+    if (hasTime()) {
+      if (!getTime()
+          .equals(other.getTime())) return false;
+    }
+    if (hasObject() != other.hasObject()) return false;
+    if (hasObject()) {
+      if (!getObject()
+          .equals(other.getObject())) return false;
+    }
+    if (!getMachine()
+        .equals(other.getMachine())) return false;
+    if (!getState()
+        .equals(other.getState())) return false;
+    if (!getFrom()
+        .equals(other.getFrom())) return false;
+    if (!getTo()
+        .equals(other.getTo())) return false;
+    if (hasTarget() != other.hasTarget()) return false;
+    if (hasTarget()) {
+      if (!getTarget()
+          .equals(other.getTarget())) return false;
+    }
+    if (!getEvent()
+        .equals(other.getEvent())) return false;
+    if (!getPayloadList()
+        .equals(other.getPayloadList())) return false;
+    if (!getAlternativesList()
+        .equals(other.getAlternativesList())) return false;
+    if (!getTaken()
+        .equals(other.getTaken())) return false;
+    if (!getText()
+        .equals(other.getText())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + KIND_FIELD_NUMBER;
+    hash = (53 * hash) + getKind().hashCode();
+    if (hasTime()) {
+      hash = (37 * hash) + TIME_FIELD_NUMBER;
+      hash = (53 * hash) + getTime().hashCode();
+    }
+    if (hasObject()) {
+      hash = (37 * hash) + OBJECT_FIELD_NUMBER;
+      hash = (53 * hash) + getObject().hashCode();
+    }
+    hash = (37 * hash) + MACHINE_FIELD_NUMBER;
+    hash = (53 * hash) + getMachine().hashCode();
+    hash = (37 * hash) + STATE_FIELD_NUMBER;
+    hash = (53 * hash) + getState().hashCode();
+    hash = (37 * hash) + FROM_FIELD_NUMBER;
+    hash = (53 * hash) + getFrom().hashCode();
+    hash = (37 * hash) + TO_FIELD_NUMBER;
+    hash = (53 * hash) + getTo().hashCode();
+    if (hasTarget()) {
+      hash = (37 * hash) + TARGET_FIELD_NUMBER;
+      hash = (53 * hash) + getTarget().hashCode();
+    }
+    hash = (37 * hash) + EVENT_FIELD_NUMBER;
+    hash = (53 * hash) + getEvent().hashCode();
+    if (getPayloadCount() > 0) {
+      hash = (37 * hash) + PAYLOAD_FIELD_NUMBER;
+      hash = (53 * hash) + getPayloadList().hashCode();
+    }
+    if (getAlternativesCount() > 0) {
+      hash = (37 * hash) + ALTERNATIVES_FIELD_NUMBER;
+      hash = (53 * hash) + getAlternativesList().hashCode();
+    }
+    hash = (37 * hash) + TAKEN_FIELD_NUMBER;
+    hash = (53 * hash) + getTaken().hashCode();
+    hash = (37 * hash) + TEXT_FIELD_NUMBER;
+    hash = (53 * hash) + getText().hashCode();
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentEvent parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentEvent parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentEvent parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentEvent parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentEvent parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentEvent parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentEvent parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.DocumentEvent parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentEvent parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentEvent parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentEvent parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.DocumentEvent parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.DocumentEvent prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * DocumentEvent is one row an `Events` query answered: one record of the
+   * session's trace, in the order the run made it.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.DocumentEvent}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.DocumentEvent)
+      org.openmbee.opensysml.proto.DocumentEventOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentEvent_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentEvent_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.DocumentEvent.class, org.openmbee.opensysml.proto.DocumentEvent.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.DocumentEvent.newBuilder()
+    private Builder() {
+      maybeForceBuilderInitialization();
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+      maybeForceBuilderInitialization();
+    }
+    private void maybeForceBuilderInitialization() {
+      if (com.google.protobuf.GeneratedMessage
+              .alwaysUseFieldBuilders) {
+        internalGetTimeFieldBuilder();
+        internalGetObjectFieldBuilder();
+        internalGetTargetFieldBuilder();
+      }
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      kind_ = "";
+      time_ = null;
+      if (timeBuilder_ != null) {
+        timeBuilder_.dispose();
+        timeBuilder_ = null;
+      }
+      object_ = null;
+      if (objectBuilder_ != null) {
+        objectBuilder_.dispose();
+        objectBuilder_ = null;
+      }
+      machine_ = "";
+      state_ = "";
+      from_ = "";
+      to_ = "";
+      target_ = null;
+      if (targetBuilder_ != null) {
+        targetBuilder_.dispose();
+        targetBuilder_ = null;
+      }
+      event_ = "";
+      payload_ =
+          com.google.protobuf.LazyStringArrayList.emptyList();
+      alternatives_ =
+          com.google.protobuf.LazyStringArrayList.emptyList();
+      taken_ = "";
+      text_ = "";
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentEvent_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentEvent getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.DocumentEvent.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentEvent build() {
+      org.openmbee.opensysml.proto.DocumentEvent result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentEvent buildPartial() {
+      org.openmbee.opensysml.proto.DocumentEvent result = new org.openmbee.opensysml.proto.DocumentEvent(this);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.DocumentEvent result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.kind_ = kind_;
+      }
+      int to_bitField0_ = 0;
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.time_ = timeBuilder_ == null
+            ? time_
+            : timeBuilder_.build();
+        to_bitField0_ |= 0x00000001;
+      }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.object_ = objectBuilder_ == null
+            ? object_
+            : objectBuilder_.build();
+        to_bitField0_ |= 0x00000002;
+      }
+      if (((from_bitField0_ & 0x00000008) != 0)) {
+        result.machine_ = machine_;
+      }
+      if (((from_bitField0_ & 0x00000010) != 0)) {
+        result.state_ = state_;
+      }
+      if (((from_bitField0_ & 0x00000020) != 0)) {
+        result.from_ = from_;
+      }
+      if (((from_bitField0_ & 0x00000040) != 0)) {
+        result.to_ = to_;
+      }
+      if (((from_bitField0_ & 0x00000080) != 0)) {
+        result.target_ = targetBuilder_ == null
+            ? target_
+            : targetBuilder_.build();
+        to_bitField0_ |= 0x00000004;
+      }
+      if (((from_bitField0_ & 0x00000100) != 0)) {
+        result.event_ = event_;
+      }
+      if (((from_bitField0_ & 0x00000200) != 0)) {
+        payload_.makeImmutable();
+        result.payload_ = payload_;
+      }
+      if (((from_bitField0_ & 0x00000400) != 0)) {
+        alternatives_.makeImmutable();
+        result.alternatives_ = alternatives_;
+      }
+      if (((from_bitField0_ & 0x00000800) != 0)) {
+        result.taken_ = taken_;
+      }
+      if (((from_bitField0_ & 0x00001000) != 0)) {
+        result.text_ = text_;
+      }
+      result.bitField0_ |= to_bitField0_;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.DocumentEvent) {
+        return mergeFrom((org.openmbee.opensysml.proto.DocumentEvent)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.DocumentEvent other) {
+      if (other == org.openmbee.opensysml.proto.DocumentEvent.getDefaultInstance()) return this;
+      if (!other.getKind().isEmpty()) {
+        kind_ = other.kind_;
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      if (other.hasTime()) {
+        mergeTime(other.getTime());
+      }
+      if (other.hasObject()) {
+        mergeObject(other.getObject());
+      }
+      if (!other.getMachine().isEmpty()) {
+        machine_ = other.machine_;
+        bitField0_ |= 0x00000008;
+        onChanged();
+      }
+      if (!other.getState().isEmpty()) {
+        state_ = other.state_;
+        bitField0_ |= 0x00000010;
+        onChanged();
+      }
+      if (!other.getFrom().isEmpty()) {
+        from_ = other.from_;
+        bitField0_ |= 0x00000020;
+        onChanged();
+      }
+      if (!other.getTo().isEmpty()) {
+        to_ = other.to_;
+        bitField0_ |= 0x00000040;
+        onChanged();
+      }
+      if (other.hasTarget()) {
+        mergeTarget(other.getTarget());
+      }
+      if (!other.getEvent().isEmpty()) {
+        event_ = other.event_;
+        bitField0_ |= 0x00000100;
+        onChanged();
+      }
+      if (!other.payload_.isEmpty()) {
+        if (payload_.isEmpty()) {
+          payload_ = other.payload_;
+          bitField0_ |= 0x00000200;
+        } else {
+          ensurePayloadIsMutable();
+          payload_.addAll(other.payload_);
+        }
+        onChanged();
+      }
+      if (!other.alternatives_.isEmpty()) {
+        if (alternatives_.isEmpty()) {
+          alternatives_ = other.alternatives_;
+          bitField0_ |= 0x00000400;
+        } else {
+          ensureAlternativesIsMutable();
+          alternatives_.addAll(other.alternatives_);
+        }
+        onChanged();
+      }
+      if (!other.getTaken().isEmpty()) {
+        taken_ = other.taken_;
+        bitField0_ |= 0x00000800;
+        onChanged();
+      }
+      if (!other.getText().isEmpty()) {
+        text_ = other.text_;
+        bitField0_ |= 0x00001000;
+        onChanged();
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              kind_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              input.readMessage(
+                  internalGetTimeFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            case 26: {
+              input.readMessage(
+                  internalGetObjectFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 26
+            case 34: {
+              machine_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000008;
+              break;
+            } // case 34
+            case 42: {
+              state_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000010;
+              break;
+            } // case 42
+            case 50: {
+              from_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000020;
+              break;
+            } // case 50
+            case 58: {
+              to_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000040;
+              break;
+            } // case 58
+            case 66: {
+              input.readMessage(
+                  internalGetTargetFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              bitField0_ |= 0x00000080;
+              break;
+            } // case 66
+            case 74: {
+              event_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000100;
+              break;
+            } // case 74
+            case 82: {
+              java.lang.String s = input.readStringRequireUtf8();
+              ensurePayloadIsMutable();
+              payload_.add(s);
+              break;
+            } // case 82
+            case 90: {
+              java.lang.String s = input.readStringRequireUtf8();
+              ensureAlternativesIsMutable();
+              alternatives_.add(s);
+              break;
+            } // case 90
+            case 98: {
+              taken_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000800;
+              break;
+            } // case 98
+            case 106: {
+              text_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00001000;
+              break;
+            } // case 106
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.lang.Object kind_ = "";
+    /**
+     * <pre>
+     * "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+     * </pre>
+     *
+     * <code>string kind = 1 [json_name = "kind"];</code>
+     * @return The kind.
+     */
+    public java.lang.String getKind() {
+      java.lang.Object ref = kind_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        kind_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+     * </pre>
+     *
+     * <code>string kind = 1 [json_name = "kind"];</code>
+     * @return The bytes for kind.
+     */
+    public com.google.protobuf.ByteString
+        getKindBytes() {
+      java.lang.Object ref = kind_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        kind_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+     * </pre>
+     *
+     * <code>string kind = 1 [json_name = "kind"];</code>
+     * @param value The kind to set.
+     * @return This builder for chaining.
+     */
+    public Builder setKind(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      kind_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+     * </pre>
+     *
+     * <code>string kind = 1 [json_name = "kind"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearKind() {
+      kind_ = getDefaultInstance().getKind();
+      bitField0_ = (bitField0_ & ~0x00000001);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+     * </pre>
+     *
+     * <code>string kind = 1 [json_name = "kind"];</code>
+     * @param value The bytes for kind to set.
+     * @return This builder for chaining.
+     */
+    public Builder setKindBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      kind_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+
+    private org.openmbee.opensysml.proto.DocumentValue time_;
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder> timeBuilder_;
+    /**
+     * <pre>
+     * The clock's instant when the record was made: a quantity in the clock's
+     * unit when the library reduces one, else a bare real of clock units.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+     * @return Whether the time field is set.
+     */
+    public boolean hasTime() {
+      return ((bitField0_ & 0x00000002) != 0);
+    }
+    /**
+     * <pre>
+     * The clock's instant when the record was made: a quantity in the clock's
+     * unit when the library reduces one, else a bare real of clock units.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+     * @return The time.
+     */
+    public org.openmbee.opensysml.proto.DocumentValue getTime() {
+      if (timeBuilder_ == null) {
+        return time_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : time_;
+      } else {
+        return timeBuilder_.getMessage();
+      }
+    }
+    /**
+     * <pre>
+     * The clock's instant when the record was made: a quantity in the clock's
+     * unit when the library reduces one, else a bare real of clock units.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+     */
+    public Builder setTime(org.openmbee.opensysml.proto.DocumentValue value) {
+      if (timeBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        time_ = value;
+      } else {
+        timeBuilder_.setMessage(value);
+      }
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The clock's instant when the record was made: a quantity in the clock's
+     * unit when the library reduces one, else a bare real of clock units.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+     */
+    public Builder setTime(
+        org.openmbee.opensysml.proto.DocumentValue.Builder builderForValue) {
+      if (timeBuilder_ == null) {
+        time_ = builderForValue.build();
+      } else {
+        timeBuilder_.setMessage(builderForValue.build());
+      }
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The clock's instant when the record was made: a quantity in the clock's
+     * unit when the library reduces one, else a bare real of clock units.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+     */
+    public Builder mergeTime(org.openmbee.opensysml.proto.DocumentValue value) {
+      if (timeBuilder_ == null) {
+        if (((bitField0_ & 0x00000002) != 0) &&
+          time_ != null &&
+          time_ != org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance()) {
+          getTimeBuilder().mergeFrom(value);
+        } else {
+          time_ = value;
+        }
+      } else {
+        timeBuilder_.mergeFrom(value);
+      }
+      if (time_ != null) {
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The clock's instant when the record was made: a quantity in the clock's
+     * unit when the library reduces one, else a bare real of clock units.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+     */
+    public Builder clearTime() {
+      bitField0_ = (bitField0_ & ~0x00000002);
+      time_ = null;
+      if (timeBuilder_ != null) {
+        timeBuilder_.dispose();
+        timeBuilder_ = null;
+      }
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The clock's instant when the record was made: a quantity in the clock's
+     * unit when the library reduces one, else a bare real of clock units.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentValue.Builder getTimeBuilder() {
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return internalGetTimeFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * The clock's instant when the record was made: a quantity in the clock's
+     * unit when the library reduces one, else a bare real of clock units.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentValueOrBuilder getTimeOrBuilder() {
+      if (timeBuilder_ != null) {
+        return timeBuilder_.getMessageOrBuilder();
+      } else {
+        return time_ == null ?
+            org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : time_;
+      }
+    }
+    /**
+     * <pre>
+     * The clock's instant when the record was made: a quantity in the clock's
+     * unit when the library reduces one, else a bare real of clock units.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder> 
+        internalGetTimeFieldBuilder() {
+      if (timeBuilder_ == null) {
+        timeBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder>(
+                getTime(),
+                getParentForChildren(),
+                isClean());
+        time_ = null;
+      }
+      return timeBuilder_;
+    }
+
+    private org.openmbee.opensysml.proto.DocumentObject object_;
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder> objectBuilder_;
+    /**
+     * <pre>
+     * The object whose behavior made the record; unset for the run's own
+     * records (a due-order choice, a message posted from outside the model).
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+     * @return Whether the object field is set.
+     */
+    public boolean hasObject() {
+      return ((bitField0_ & 0x00000004) != 0);
+    }
+    /**
+     * <pre>
+     * The object whose behavior made the record; unset for the run's own
+     * records (a due-order choice, a message posted from outside the model).
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+     * @return The object.
+     */
+    public org.openmbee.opensysml.proto.DocumentObject getObject() {
+      if (objectBuilder_ == null) {
+        return object_ == null ? org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : object_;
+      } else {
+        return objectBuilder_.getMessage();
+      }
+    }
+    /**
+     * <pre>
+     * The object whose behavior made the record; unset for the run's own
+     * records (a due-order choice, a message posted from outside the model).
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+     */
+    public Builder setObject(org.openmbee.opensysml.proto.DocumentObject value) {
+      if (objectBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        object_ = value;
+      } else {
+        objectBuilder_.setMessage(value);
+      }
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object whose behavior made the record; unset for the run's own
+     * records (a due-order choice, a message posted from outside the model).
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+     */
+    public Builder setObject(
+        org.openmbee.opensysml.proto.DocumentObject.Builder builderForValue) {
+      if (objectBuilder_ == null) {
+        object_ = builderForValue.build();
+      } else {
+        objectBuilder_.setMessage(builderForValue.build());
+      }
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object whose behavior made the record; unset for the run's own
+     * records (a due-order choice, a message posted from outside the model).
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+     */
+    public Builder mergeObject(org.openmbee.opensysml.proto.DocumentObject value) {
+      if (objectBuilder_ == null) {
+        if (((bitField0_ & 0x00000004) != 0) &&
+          object_ != null &&
+          object_ != org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance()) {
+          getObjectBuilder().mergeFrom(value);
+        } else {
+          object_ = value;
+        }
+      } else {
+        objectBuilder_.mergeFrom(value);
+      }
+      if (object_ != null) {
+        bitField0_ |= 0x00000004;
+        onChanged();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The object whose behavior made the record; unset for the run's own
+     * records (a due-order choice, a message posted from outside the model).
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+     */
+    public Builder clearObject() {
+      bitField0_ = (bitField0_ & ~0x00000004);
+      object_ = null;
+      if (objectBuilder_ != null) {
+        objectBuilder_.dispose();
+        objectBuilder_ = null;
+      }
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object whose behavior made the record; unset for the run's own
+     * records (a due-order choice, a message posted from outside the model).
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentObject.Builder getObjectBuilder() {
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return internalGetObjectFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * The object whose behavior made the record; unset for the run's own
+     * records (a due-order choice, a message posted from outside the model).
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentObjectOrBuilder getObjectOrBuilder() {
+      if (objectBuilder_ != null) {
+        return objectBuilder_.getMessageOrBuilder();
+      } else {
+        return object_ == null ?
+            org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : object_;
+      }
+    }
+    /**
+     * <pre>
+     * The object whose behavior made the record; unset for the run's own
+     * records (a due-order choice, a message posted from outside the model).
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder> 
+        internalGetObjectFieldBuilder() {
+      if (objectBuilder_ == null) {
+        objectBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder>(
+                getObject(),
+                getParentForChildren(),
+                isClean());
+        object_ = null;
+      }
+      return objectBuilder_;
+    }
+
+    private java.lang.Object machine_ = "";
+    /**
+     * <pre>
+     * The behavior the record is about, by the name the object exhibits it
+     * under, else its declared name; empty for the run's own records.
+     * </pre>
+     *
+     * <code>string machine = 4 [json_name = "machine"];</code>
+     * @return The machine.
+     */
+    public java.lang.String getMachine() {
+      java.lang.Object ref = machine_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        machine_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The behavior the record is about, by the name the object exhibits it
+     * under, else its declared name; empty for the run's own records.
+     * </pre>
+     *
+     * <code>string machine = 4 [json_name = "machine"];</code>
+     * @return The bytes for machine.
+     */
+    public com.google.protobuf.ByteString
+        getMachineBytes() {
+      java.lang.Object ref = machine_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        machine_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The behavior the record is about, by the name the object exhibits it
+     * under, else its declared name; empty for the run's own records.
+     * </pre>
+     *
+     * <code>string machine = 4 [json_name = "machine"];</code>
+     * @param value The machine to set.
+     * @return This builder for chaining.
+     */
+    public Builder setMachine(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      machine_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The behavior the record is about, by the name the object exhibits it
+     * under, else its declared name; empty for the run's own records.
+     * </pre>
+     *
+     * <code>string machine = 4 [json_name = "machine"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearMachine() {
+      machine_ = getDefaultInstance().getMachine();
+      bitField0_ = (bitField0_ & ~0x00000008);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The behavior the record is about, by the name the object exhibits it
+     * under, else its declared name; empty for the run's own records.
+     * </pre>
+     *
+     * <code>string machine = 4 [json_name = "machine"];</code>
+     * @param value The bytes for machine to set.
+     * @return This builder for chaining.
+     */
+    public Builder setMachineBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      machine_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object state_ = "";
+    /**
+     * <pre>
+     * The state entered, exited or stepped by an entry, exit or do record.
+     * </pre>
+     *
+     * <code>string state = 5 [json_name = "state"];</code>
+     * @return The state.
+     */
+    public java.lang.String getState() {
+      java.lang.Object ref = state_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        state_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The state entered, exited or stepped by an entry, exit or do record.
+     * </pre>
+     *
+     * <code>string state = 5 [json_name = "state"];</code>
+     * @return The bytes for state.
+     */
+    public com.google.protobuf.ByteString
+        getStateBytes() {
+      java.lang.Object ref = state_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        state_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The state entered, exited or stepped by an entry, exit or do record.
+     * </pre>
+     *
+     * <code>string state = 5 [json_name = "state"];</code>
+     * @param value The state to set.
+     * @return This builder for chaining.
+     */
+    public Builder setState(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      state_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The state entered, exited or stepped by an entry, exit or do record.
+     * </pre>
+     *
+     * <code>string state = 5 [json_name = "state"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearState() {
+      state_ = getDefaultInstance().getState();
+      bitField0_ = (bitField0_ & ~0x00000010);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The state entered, exited or stepped by an entry, exit or do record.
+     * </pre>
+     *
+     * <code>string state = 5 [json_name = "state"];</code>
+     * @param value The bytes for state to set.
+     * @return This builder for chaining.
+     */
+    public Builder setStateBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      state_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object from_ = "";
+    /**
+     * <pre>
+     * A fired transition's source and target states.
+     * </pre>
+     *
+     * <code>string from = 6 [json_name = "from"];</code>
+     * @return The from.
+     */
+    public java.lang.String getFrom() {
+      java.lang.Object ref = from_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        from_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * A fired transition's source and target states.
+     * </pre>
+     *
+     * <code>string from = 6 [json_name = "from"];</code>
+     * @return The bytes for from.
+     */
+    public com.google.protobuf.ByteString
+        getFromBytes() {
+      java.lang.Object ref = from_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        from_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * A fired transition's source and target states.
+     * </pre>
+     *
+     * <code>string from = 6 [json_name = "from"];</code>
+     * @param value The from to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFrom(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      from_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * A fired transition's source and target states.
+     * </pre>
+     *
+     * <code>string from = 6 [json_name = "from"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearFrom() {
+      from_ = getDefaultInstance().getFrom();
+      bitField0_ = (bitField0_ & ~0x00000020);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * A fired transition's source and target states.
+     * </pre>
+     *
+     * <code>string from = 6 [json_name = "from"];</code>
+     * @param value The bytes for from to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFromBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      from_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object to_ = "";
+    /**
+     * <code>string to = 7 [json_name = "to"];</code>
+     * @return The to.
+     */
+    public java.lang.String getTo() {
+      java.lang.Object ref = to_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        to_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <code>string to = 7 [json_name = "to"];</code>
+     * @return The bytes for to.
+     */
+    public com.google.protobuf.ByteString
+        getToBytes() {
+      java.lang.Object ref = to_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        to_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <code>string to = 7 [json_name = "to"];</code>
+     * @param value The to to set.
+     * @return This builder for chaining.
+     */
+    public Builder setTo(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      to_ = value;
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string to = 7 [json_name = "to"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearTo() {
+      to_ = getDefaultInstance().getTo();
+      bitField0_ = (bitField0_ & ~0x00000040);
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string to = 7 [json_name = "to"];</code>
+     * @param value The bytes for to to set.
+     * @return This builder for chaining.
+     */
+    public Builder setToBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      to_ = value;
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+
+    private org.openmbee.opensysml.proto.DocumentObject target_;
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder> targetBuilder_;
+    /**
+     * <pre>
+     * The object a send was addressed to; unset for every other kind and a
+     * send addressed to no object.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+     * @return Whether the target field is set.
+     */
+    public boolean hasTarget() {
+      return ((bitField0_ & 0x00000080) != 0);
+    }
+    /**
+     * <pre>
+     * The object a send was addressed to; unset for every other kind and a
+     * send addressed to no object.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+     * @return The target.
+     */
+    public org.openmbee.opensysml.proto.DocumentObject getTarget() {
+      if (targetBuilder_ == null) {
+        return target_ == null ? org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : target_;
+      } else {
+        return targetBuilder_.getMessage();
+      }
+    }
+    /**
+     * <pre>
+     * The object a send was addressed to; unset for every other kind and a
+     * send addressed to no object.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+     */
+    public Builder setTarget(org.openmbee.opensysml.proto.DocumentObject value) {
+      if (targetBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        target_ = value;
+      } else {
+        targetBuilder_.setMessage(value);
+      }
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object a send was addressed to; unset for every other kind and a
+     * send addressed to no object.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+     */
+    public Builder setTarget(
+        org.openmbee.opensysml.proto.DocumentObject.Builder builderForValue) {
+      if (targetBuilder_ == null) {
+        target_ = builderForValue.build();
+      } else {
+        targetBuilder_.setMessage(builderForValue.build());
+      }
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object a send was addressed to; unset for every other kind and a
+     * send addressed to no object.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+     */
+    public Builder mergeTarget(org.openmbee.opensysml.proto.DocumentObject value) {
+      if (targetBuilder_ == null) {
+        if (((bitField0_ & 0x00000080) != 0) &&
+          target_ != null &&
+          target_ != org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance()) {
+          getTargetBuilder().mergeFrom(value);
+        } else {
+          target_ = value;
+        }
+      } else {
+        targetBuilder_.mergeFrom(value);
+      }
+      if (target_ != null) {
+        bitField0_ |= 0x00000080;
+        onChanged();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The object a send was addressed to; unset for every other kind and a
+     * send addressed to no object.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+     */
+    public Builder clearTarget() {
+      bitField0_ = (bitField0_ & ~0x00000080);
+      target_ = null;
+      if (targetBuilder_ != null) {
+        targetBuilder_.dispose();
+        targetBuilder_ = null;
+      }
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object a send was addressed to; unset for every other kind and a
+     * send addressed to no object.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentObject.Builder getTargetBuilder() {
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return internalGetTargetFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * The object a send was addressed to; unset for every other kind and a
+     * send addressed to no object.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentObjectOrBuilder getTargetOrBuilder() {
+      if (targetBuilder_ != null) {
+        return targetBuilder_.getMessageOrBuilder();
+      } else {
+        return target_ == null ?
+            org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : target_;
+      }
+    }
+    /**
+     * <pre>
+     * The object a send was addressed to; unset for every other kind and a
+     * send addressed to no object.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder> 
+        internalGetTargetFieldBuilder() {
+      if (targetBuilder_ == null) {
+        targetBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder>(
+                getTarget(),
+                getParentForChildren(),
+                isClean());
+        target_ = null;
+      }
+      return targetBuilder_;
+    }
+
+    private java.lang.Object event_ = "";
+    /**
+     * <pre>
+     * The signal or event accepted or sent, or the trigger a transition fired on.
+     * </pre>
+     *
+     * <code>string event = 9 [json_name = "event"];</code>
+     * @return The event.
+     */
+    public java.lang.String getEvent() {
+      java.lang.Object ref = event_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        event_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The signal or event accepted or sent, or the trigger a transition fired on.
+     * </pre>
+     *
+     * <code>string event = 9 [json_name = "event"];</code>
+     * @return The bytes for event.
+     */
+    public com.google.protobuf.ByteString
+        getEventBytes() {
+      java.lang.Object ref = event_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        event_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The signal or event accepted or sent, or the trigger a transition fired on.
+     * </pre>
+     *
+     * <code>string event = 9 [json_name = "event"];</code>
+     * @param value The event to set.
+     * @return This builder for chaining.
+     */
+    public Builder setEvent(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      event_ = value;
+      bitField0_ |= 0x00000100;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The signal or event accepted or sent, or the trigger a transition fired on.
+     * </pre>
+     *
+     * <code>string event = 9 [json_name = "event"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearEvent() {
+      event_ = getDefaultInstance().getEvent();
+      bitField0_ = (bitField0_ & ~0x00000100);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The signal or event accepted or sent, or the trigger a transition fired on.
+     * </pre>
+     *
+     * <code>string event = 9 [json_name = "event"];</code>
+     * @param value The bytes for event to set.
+     * @return This builder for chaining.
+     */
+    public Builder setEventBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      event_ = value;
+      bitField0_ |= 0x00000100;
+      onChanged();
+      return this;
+    }
+
+    private com.google.protobuf.LazyStringArrayList payload_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    private void ensurePayloadIsMutable() {
+      if (!payload_.isModifiable()) {
+        payload_ = new com.google.protobuf.LazyStringArrayList(payload_);
+      }
+      bitField0_ |= 0x00000200;
+    }
+    /**
+     * <pre>
+     * An accept's payload, one `name = value` entry per parameter in name order.
+     * </pre>
+     *
+     * <code>repeated string payload = 10 [json_name = "payload"];</code>
+     * @return A list containing the payload.
+     */
+    public com.google.protobuf.ProtocolStringList
+        getPayloadList() {
+      payload_.makeImmutable();
+      return payload_;
+    }
+    /**
+     * <pre>
+     * An accept's payload, one `name = value` entry per parameter in name order.
+     * </pre>
+     *
+     * <code>repeated string payload = 10 [json_name = "payload"];</code>
+     * @return The count of payload.
+     */
+    public int getPayloadCount() {
+      return payload_.size();
+    }
+    /**
+     * <pre>
+     * An accept's payload, one `name = value` entry per parameter in name order.
+     * </pre>
+     *
+     * <code>repeated string payload = 10 [json_name = "payload"];</code>
+     * @param index The index of the element to return.
+     * @return The payload at the given index.
+     */
+    public java.lang.String getPayload(int index) {
+      return payload_.get(index);
+    }
+    /**
+     * <pre>
+     * An accept's payload, one `name = value` entry per parameter in name order.
+     * </pre>
+     *
+     * <code>repeated string payload = 10 [json_name = "payload"];</code>
+     * @param index The index of the value to return.
+     * @return The bytes of the payload at the given index.
+     */
+    public com.google.protobuf.ByteString
+        getPayloadBytes(int index) {
+      return payload_.getByteString(index);
+    }
+    /**
+     * <pre>
+     * An accept's payload, one `name = value` entry per parameter in name order.
+     * </pre>
+     *
+     * <code>repeated string payload = 10 [json_name = "payload"];</code>
+     * @param index The index to set the value at.
+     * @param value The payload to set.
+     * @return This builder for chaining.
+     */
+    public Builder setPayload(
+        int index, java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensurePayloadIsMutable();
+      payload_.set(index, value);
+      bitField0_ |= 0x00000200;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * An accept's payload, one `name = value` entry per parameter in name order.
+     * </pre>
+     *
+     * <code>repeated string payload = 10 [json_name = "payload"];</code>
+     * @param value The payload to add.
+     * @return This builder for chaining.
+     */
+    public Builder addPayload(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensurePayloadIsMutable();
+      payload_.add(value);
+      bitField0_ |= 0x00000200;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * An accept's payload, one `name = value` entry per parameter in name order.
+     * </pre>
+     *
+     * <code>repeated string payload = 10 [json_name = "payload"];</code>
+     * @param values The payload to add.
+     * @return This builder for chaining.
+     */
+    public Builder addAllPayload(
+        java.lang.Iterable<java.lang.String> values) {
+      ensurePayloadIsMutable();
+      com.google.protobuf.AbstractMessageLite.Builder.addAll(
+          values, payload_);
+      bitField0_ |= 0x00000200;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * An accept's payload, one `name = value` entry per parameter in name order.
+     * </pre>
+     *
+     * <code>repeated string payload = 10 [json_name = "payload"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearPayload() {
+      payload_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+      bitField0_ = (bitField0_ & ~0x00000200);;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * An accept's payload, one `name = value` entry per parameter in name order.
+     * </pre>
+     *
+     * <code>repeated string payload = 10 [json_name = "payload"];</code>
+     * @param value The bytes of the payload to add.
+     * @return This builder for chaining.
+     */
+    public Builder addPayloadBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      ensurePayloadIsMutable();
+      payload_.add(value);
+      bitField0_ |= 0x00000200;
+      onChanged();
+      return this;
+    }
+
+    private com.google.protobuf.LazyStringArrayList alternatives_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    private void ensureAlternativesIsMutable() {
+      if (!alternatives_.isModifiable()) {
+        alternatives_ = new com.google.protobuf.LazyStringArrayList(alternatives_);
+      }
+      bitField0_ |= 0x00000400;
+    }
+    /**
+     * <pre>
+     * A choice's alternatives, in the order they were offered, and the one taken.
+     * </pre>
+     *
+     * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+     * @return A list containing the alternatives.
+     */
+    public com.google.protobuf.ProtocolStringList
+        getAlternativesList() {
+      alternatives_.makeImmutable();
+      return alternatives_;
+    }
+    /**
+     * <pre>
+     * A choice's alternatives, in the order they were offered, and the one taken.
+     * </pre>
+     *
+     * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+     * @return The count of alternatives.
+     */
+    public int getAlternativesCount() {
+      return alternatives_.size();
+    }
+    /**
+     * <pre>
+     * A choice's alternatives, in the order they were offered, and the one taken.
+     * </pre>
+     *
+     * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+     * @param index The index of the element to return.
+     * @return The alternatives at the given index.
+     */
+    public java.lang.String getAlternatives(int index) {
+      return alternatives_.get(index);
+    }
+    /**
+     * <pre>
+     * A choice's alternatives, in the order they were offered, and the one taken.
+     * </pre>
+     *
+     * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+     * @param index The index of the value to return.
+     * @return The bytes of the alternatives at the given index.
+     */
+    public com.google.protobuf.ByteString
+        getAlternativesBytes(int index) {
+      return alternatives_.getByteString(index);
+    }
+    /**
+     * <pre>
+     * A choice's alternatives, in the order they were offered, and the one taken.
+     * </pre>
+     *
+     * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+     * @param index The index to set the value at.
+     * @param value The alternatives to set.
+     * @return This builder for chaining.
+     */
+    public Builder setAlternatives(
+        int index, java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureAlternativesIsMutable();
+      alternatives_.set(index, value);
+      bitField0_ |= 0x00000400;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * A choice's alternatives, in the order they were offered, and the one taken.
+     * </pre>
+     *
+     * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+     * @param value The alternatives to add.
+     * @return This builder for chaining.
+     */
+    public Builder addAlternatives(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureAlternativesIsMutable();
+      alternatives_.add(value);
+      bitField0_ |= 0x00000400;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * A choice's alternatives, in the order they were offered, and the one taken.
+     * </pre>
+     *
+     * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+     * @param values The alternatives to add.
+     * @return This builder for chaining.
+     */
+    public Builder addAllAlternatives(
+        java.lang.Iterable<java.lang.String> values) {
+      ensureAlternativesIsMutable();
+      com.google.protobuf.AbstractMessageLite.Builder.addAll(
+          values, alternatives_);
+      bitField0_ |= 0x00000400;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * A choice's alternatives, in the order they were offered, and the one taken.
+     * </pre>
+     *
+     * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearAlternatives() {
+      alternatives_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+      bitField0_ = (bitField0_ & ~0x00000400);;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * A choice's alternatives, in the order they were offered, and the one taken.
+     * </pre>
+     *
+     * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+     * @param value The bytes of the alternatives to add.
+     * @return This builder for chaining.
+     */
+    public Builder addAlternativesBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      ensureAlternativesIsMutable();
+      alternatives_.add(value);
+      bitField0_ |= 0x00000400;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object taken_ = "";
+    /**
+     * <code>string taken = 12 [json_name = "taken"];</code>
+     * @return The taken.
+     */
+    public java.lang.String getTaken() {
+      java.lang.Object ref = taken_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        taken_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <code>string taken = 12 [json_name = "taken"];</code>
+     * @return The bytes for taken.
+     */
+    public com.google.protobuf.ByteString
+        getTakenBytes() {
+      java.lang.Object ref = taken_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        taken_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <code>string taken = 12 [json_name = "taken"];</code>
+     * @param value The taken to set.
+     * @return This builder for chaining.
+     */
+    public Builder setTaken(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      taken_ = value;
+      bitField0_ |= 0x00000800;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string taken = 12 [json_name = "taken"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearTaken() {
+      taken_ = getDefaultInstance().getTaken();
+      bitField0_ = (bitField0_ & ~0x00000800);
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string taken = 12 [json_name = "taken"];</code>
+     * @param value The bytes for taken to set.
+     * @return This builder for chaining.
+     */
+    public Builder setTakenBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      taken_ = value;
+      bitField0_ |= 0x00000800;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object text_ = "";
+    /**
+     * <pre>
+     * The line the trace prints for the record.
+     * </pre>
+     *
+     * <code>string text = 13 [json_name = "text"];</code>
+     * @return The text.
+     */
+    public java.lang.String getText() {
+      java.lang.Object ref = text_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        text_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The line the trace prints for the record.
+     * </pre>
+     *
+     * <code>string text = 13 [json_name = "text"];</code>
+     * @return The bytes for text.
+     */
+    public com.google.protobuf.ByteString
+        getTextBytes() {
+      java.lang.Object ref = text_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        text_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The line the trace prints for the record.
+     * </pre>
+     *
+     * <code>string text = 13 [json_name = "text"];</code>
+     * @param value The text to set.
+     * @return This builder for chaining.
+     */
+    public Builder setText(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      text_ = value;
+      bitField0_ |= 0x00001000;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The line the trace prints for the record.
+     * </pre>
+     *
+     * <code>string text = 13 [json_name = "text"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearText() {
+      text_ = getDefaultInstance().getText();
+      bitField0_ = (bitField0_ & ~0x00001000);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The line the trace prints for the record.
+     * </pre>
+     *
+     * <code>string text = 13 [json_name = "text"];</code>
+     * @param value The bytes for text to set.
+     * @return This builder for chaining.
+     */
+    public Builder setTextBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      text_ = value;
+      bitField0_ |= 0x00001000;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.DocumentEvent)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.DocumentEvent)
+  private static final org.openmbee.opensysml.proto.DocumentEvent DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.DocumentEvent();
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentEvent getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<DocumentEvent>
+      PARSER = new com.google.protobuf.AbstractParser<DocumentEvent>() {
+    @java.lang.Override
+    public DocumentEvent parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<DocumentEvent> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<DocumentEvent> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentEvent getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentEventOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentEventOrBuilder.java
new file mode 100644
index 0000000000..12d84f0aa0
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentEventOrBuilder.java
@@ -0,0 +1,330 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface DocumentEventOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.DocumentEvent)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+   * </pre>
+   *
+   * <code>string kind = 1 [json_name = "kind"];</code>
+   * @return The kind.
+   */
+  java.lang.String getKind();
+  /**
+   * <pre>
+   * "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+   * </pre>
+   *
+   * <code>string kind = 1 [json_name = "kind"];</code>
+   * @return The bytes for kind.
+   */
+  com.google.protobuf.ByteString
+      getKindBytes();
+
+  /**
+   * <pre>
+   * The clock's instant when the record was made: a quantity in the clock's
+   * unit when the library reduces one, else a bare real of clock units.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+   * @return Whether the time field is set.
+   */
+  boolean hasTime();
+  /**
+   * <pre>
+   * The clock's instant when the record was made: a quantity in the clock's
+   * unit when the library reduces one, else a bare real of clock units.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+   * @return The time.
+   */
+  org.openmbee.opensysml.proto.DocumentValue getTime();
+  /**
+   * <pre>
+   * The clock's instant when the record was made: a quantity in the clock's
+   * unit when the library reduces one, else a bare real of clock units.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue time = 2 [json_name = "time"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentValueOrBuilder getTimeOrBuilder();
+
+  /**
+   * <pre>
+   * The object whose behavior made the record; unset for the run's own
+   * records (a due-order choice, a message posted from outside the model).
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+   * @return Whether the object field is set.
+   */
+  boolean hasObject();
+  /**
+   * <pre>
+   * The object whose behavior made the record; unset for the run's own
+   * records (a due-order choice, a message posted from outside the model).
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+   * @return The object.
+   */
+  org.openmbee.opensysml.proto.DocumentObject getObject();
+  /**
+   * <pre>
+   * The object whose behavior made the record; unset for the run's own
+   * records (a due-order choice, a message posted from outside the model).
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 3 [json_name = "object"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentObjectOrBuilder getObjectOrBuilder();
+
+  /**
+   * <pre>
+   * The behavior the record is about, by the name the object exhibits it
+   * under, else its declared name; empty for the run's own records.
+   * </pre>
+   *
+   * <code>string machine = 4 [json_name = "machine"];</code>
+   * @return The machine.
+   */
+  java.lang.String getMachine();
+  /**
+   * <pre>
+   * The behavior the record is about, by the name the object exhibits it
+   * under, else its declared name; empty for the run's own records.
+   * </pre>
+   *
+   * <code>string machine = 4 [json_name = "machine"];</code>
+   * @return The bytes for machine.
+   */
+  com.google.protobuf.ByteString
+      getMachineBytes();
+
+  /**
+   * <pre>
+   * The state entered, exited or stepped by an entry, exit or do record.
+   * </pre>
+   *
+   * <code>string state = 5 [json_name = "state"];</code>
+   * @return The state.
+   */
+  java.lang.String getState();
+  /**
+   * <pre>
+   * The state entered, exited or stepped by an entry, exit or do record.
+   * </pre>
+   *
+   * <code>string state = 5 [json_name = "state"];</code>
+   * @return The bytes for state.
+   */
+  com.google.protobuf.ByteString
+      getStateBytes();
+
+  /**
+   * <pre>
+   * A fired transition's source and target states.
+   * </pre>
+   *
+   * <code>string from = 6 [json_name = "from"];</code>
+   * @return The from.
+   */
+  java.lang.String getFrom();
+  /**
+   * <pre>
+   * A fired transition's source and target states.
+   * </pre>
+   *
+   * <code>string from = 6 [json_name = "from"];</code>
+   * @return The bytes for from.
+   */
+  com.google.protobuf.ByteString
+      getFromBytes();
+
+  /**
+   * <code>string to = 7 [json_name = "to"];</code>
+   * @return The to.
+   */
+  java.lang.String getTo();
+  /**
+   * <code>string to = 7 [json_name = "to"];</code>
+   * @return The bytes for to.
+   */
+  com.google.protobuf.ByteString
+      getToBytes();
+
+  /**
+   * <pre>
+   * The object a send was addressed to; unset for every other kind and a
+   * send addressed to no object.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+   * @return Whether the target field is set.
+   */
+  boolean hasTarget();
+  /**
+   * <pre>
+   * The object a send was addressed to; unset for every other kind and a
+   * send addressed to no object.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+   * @return The target.
+   */
+  org.openmbee.opensysml.proto.DocumentObject getTarget();
+  /**
+   * <pre>
+   * The object a send was addressed to; unset for every other kind and a
+   * send addressed to no object.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject target = 8 [json_name = "target"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentObjectOrBuilder getTargetOrBuilder();
+
+  /**
+   * <pre>
+   * The signal or event accepted or sent, or the trigger a transition fired on.
+   * </pre>
+   *
+   * <code>string event = 9 [json_name = "event"];</code>
+   * @return The event.
+   */
+  java.lang.String getEvent();
+  /**
+   * <pre>
+   * The signal or event accepted or sent, or the trigger a transition fired on.
+   * </pre>
+   *
+   * <code>string event = 9 [json_name = "event"];</code>
+   * @return The bytes for event.
+   */
+  com.google.protobuf.ByteString
+      getEventBytes();
+
+  /**
+   * <pre>
+   * An accept's payload, one `name = value` entry per parameter in name order.
+   * </pre>
+   *
+   * <code>repeated string payload = 10 [json_name = "payload"];</code>
+   * @return A list containing the payload.
+   */
+  java.util.List<java.lang.String>
+      getPayloadList();
+  /**
+   * <pre>
+   * An accept's payload, one `name = value` entry per parameter in name order.
+   * </pre>
+   *
+   * <code>repeated string payload = 10 [json_name = "payload"];</code>
+   * @return The count of payload.
+   */
+  int getPayloadCount();
+  /**
+   * <pre>
+   * An accept's payload, one `name = value` entry per parameter in name order.
+   * </pre>
+   *
+   * <code>repeated string payload = 10 [json_name = "payload"];</code>
+   * @param index The index of the element to return.
+   * @return The payload at the given index.
+   */
+  java.lang.String getPayload(int index);
+  /**
+   * <pre>
+   * An accept's payload, one `name = value` entry per parameter in name order.
+   * </pre>
+   *
+   * <code>repeated string payload = 10 [json_name = "payload"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the payload at the given index.
+   */
+  com.google.protobuf.ByteString
+      getPayloadBytes(int index);
+
+  /**
+   * <pre>
+   * A choice's alternatives, in the order they were offered, and the one taken.
+   * </pre>
+   *
+   * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+   * @return A list containing the alternatives.
+   */
+  java.util.List<java.lang.String>
+      getAlternativesList();
+  /**
+   * <pre>
+   * A choice's alternatives, in the order they were offered, and the one taken.
+   * </pre>
+   *
+   * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+   * @return The count of alternatives.
+   */
+  int getAlternativesCount();
+  /**
+   * <pre>
+   * A choice's alternatives, in the order they were offered, and the one taken.
+   * </pre>
+   *
+   * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+   * @param index The index of the element to return.
+   * @return The alternatives at the given index.
+   */
+  java.lang.String getAlternatives(int index);
+  /**
+   * <pre>
+   * A choice's alternatives, in the order they were offered, and the one taken.
+   * </pre>
+   *
+   * <code>repeated string alternatives = 11 [json_name = "alternatives"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the alternatives at the given index.
+   */
+  com.google.protobuf.ByteString
+      getAlternativesBytes(int index);
+
+  /**
+   * <code>string taken = 12 [json_name = "taken"];</code>
+   * @return The taken.
+   */
+  java.lang.String getTaken();
+  /**
+   * <code>string taken = 12 [json_name = "taken"];</code>
+   * @return The bytes for taken.
+   */
+  com.google.protobuf.ByteString
+      getTakenBytes();
+
+  /**
+   * <pre>
+   * The line the trace prints for the record.
+   * </pre>
+   *
+   * <code>string text = 13 [json_name = "text"];</code>
+   * @return The text.
+   */
+  java.lang.String getText();
+  /**
+   * <pre>
+   * The line the trace prints for the record.
+   * </pre>
+   *
+   * <code>string text = 13 [json_name = "text"];</code>
+   * @return The bytes for text.
+   */
+  com.google.protobuf.ByteString
+      getTextBytes();
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentObject.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentObject.java
new file mode 100644
index 0000000000..c4cfc4df36
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentObject.java
@@ -0,0 +1,933 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * DocumentObject is an object the service holds for the model, created by
+ * Instantiate, as a query binds and answers it. A request names it by path
+ * when path is set, and by instance_id otherwise; a request setting both must
+ * name one object by both. A response answers both, and the element the object
+ * stands for.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.DocumentObject}
+ */
+@com.google.protobuf.Generated
+public final class DocumentObject extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.DocumentObject)
+    DocumentObjectOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "DocumentObject");
+  }
+  // Use DocumentObject.newBuilder() to construct.
+  private DocumentObject(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private DocumentObject() {
+    path_ = "";
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentObject_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentObject_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.DocumentObject.class, org.openmbee.opensysml.proto.DocumentObject.Builder.class);
+  }
+
+  private int bitField0_;
+  public static final int INSTANCE_ID_FIELD_NUMBER = 1;
+  private long instanceId_ = 0L;
+  /**
+   * <pre>
+   * The object's id, as Instantiate answered it. Fails with NOT_FOUND when no
+   * object of the model has it.
+   * </pre>
+   *
+   * <code>int64 instance_id = 1 [json_name = "instanceId"];</code>
+   * @return The instanceId.
+   */
+  @java.lang.Override
+  public long getInstanceId() {
+    return instanceId_;
+  }
+
+  public static final int PATH_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object path_ = "";
+  /**
+   * <pre>
+   * The object by the label a session reaches it under: the qualified name it
+   * was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+   * feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+   * count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+   * a segment reaches no object, and NOT_FOUND when the name it starts from has
+   * no object.
+   * </pre>
+   *
+   * <code>string path = 2 [json_name = "path"];</code>
+   * @return The path.
+   */
+  @java.lang.Override
+  public java.lang.String getPath() {
+    java.lang.Object ref = path_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      path_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The object by the label a session reaches it under: the qualified name it
+   * was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+   * feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+   * count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+   * a segment reaches no object, and NOT_FOUND when the name it starts from has
+   * no object.
+   * </pre>
+   *
+   * <code>string path = 2 [json_name = "path"];</code>
+   * @return The bytes for path.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getPathBytes() {
+    java.lang.Object ref = path_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      path_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int ELEMENT_FIELD_NUMBER = 3;
+  private org.openmbee.opensysml.proto.DocumentValue element_;
+  /**
+   * <pre>
+   * The usage the object is held under — its own definition or usage for an
+   * object Instantiate created — as an element value with its qualified name
+   * and metamodel type. Answered, ignored when bound.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+   * @return Whether the element field is set.
+   */
+  @java.lang.Override
+  public boolean hasElement() {
+    return ((bitField0_ & 0x00000001) != 0);
+  }
+  /**
+   * <pre>
+   * The usage the object is held under — its own definition or usage for an
+   * object Instantiate created — as an element value with its qualified name
+   * and metamodel type. Answered, ignored when bound.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+   * @return The element.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentValue getElement() {
+    return element_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : element_;
+  }
+  /**
+   * <pre>
+   * The usage the object is held under — its own definition or usage for an
+   * object Instantiate created — as an element value with its qualified name
+   * and metamodel type. Answered, ignored when bound.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentValueOrBuilder getElementOrBuilder() {
+    return element_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : element_;
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (instanceId_ != 0L) {
+      output.writeInt64(1, instanceId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(path_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, path_);
+    }
+    if (((bitField0_ & 0x00000001) != 0)) {
+      output.writeMessage(3, getElement());
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (instanceId_ != 0L) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeInt64Size(1, instanceId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(path_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, path_);
+    }
+    if (((bitField0_ & 0x00000001) != 0)) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(3, getElement());
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.DocumentObject)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.DocumentObject other = (org.openmbee.opensysml.proto.DocumentObject) obj;
+
+    if (getInstanceId()
+        != other.getInstanceId()) return false;
+    if (!getPath()
+        .equals(other.getPath())) return false;
+    if (hasElement() != other.hasElement()) return false;
+    if (hasElement()) {
+      if (!getElement()
+          .equals(other.getElement())) return false;
+    }
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + INSTANCE_ID_FIELD_NUMBER;
+    hash = (53 * hash) + com.google.protobuf.Internal.hashLong(
+        getInstanceId());
+    hash = (37 * hash) + PATH_FIELD_NUMBER;
+    hash = (53 * hash) + getPath().hashCode();
+    if (hasElement()) {
+      hash = (37 * hash) + ELEMENT_FIELD_NUMBER;
+      hash = (53 * hash) + getElement().hashCode();
+    }
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentObject parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentObject parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentObject parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentObject parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentObject parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentObject parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentObject parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.DocumentObject parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentObject parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentObject parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentObject parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.DocumentObject parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.DocumentObject prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * DocumentObject is an object the service holds for the model, created by
+   * Instantiate, as a query binds and answers it. A request names it by path
+   * when path is set, and by instance_id otherwise; a request setting both must
+   * name one object by both. A response answers both, and the element the object
+   * stands for.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.DocumentObject}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.DocumentObject)
+      org.openmbee.opensysml.proto.DocumentObjectOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentObject_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentObject_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.DocumentObject.class, org.openmbee.opensysml.proto.DocumentObject.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.DocumentObject.newBuilder()
+    private Builder() {
+      maybeForceBuilderInitialization();
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+      maybeForceBuilderInitialization();
+    }
+    private void maybeForceBuilderInitialization() {
+      if (com.google.protobuf.GeneratedMessage
+              .alwaysUseFieldBuilders) {
+        internalGetElementFieldBuilder();
+      }
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      instanceId_ = 0L;
+      path_ = "";
+      element_ = null;
+      if (elementBuilder_ != null) {
+        elementBuilder_.dispose();
+        elementBuilder_ = null;
+      }
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentObject_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentObject getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentObject build() {
+      org.openmbee.opensysml.proto.DocumentObject result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentObject buildPartial() {
+      org.openmbee.opensysml.proto.DocumentObject result = new org.openmbee.opensysml.proto.DocumentObject(this);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.DocumentObject result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.instanceId_ = instanceId_;
+      }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.path_ = path_;
+      }
+      int to_bitField0_ = 0;
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.element_ = elementBuilder_ == null
+            ? element_
+            : elementBuilder_.build();
+        to_bitField0_ |= 0x00000001;
+      }
+      result.bitField0_ |= to_bitField0_;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.DocumentObject) {
+        return mergeFrom((org.openmbee.opensysml.proto.DocumentObject)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.DocumentObject other) {
+      if (other == org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance()) return this;
+      if (other.getInstanceId() != 0L) {
+        setInstanceId(other.getInstanceId());
+      }
+      if (!other.getPath().isEmpty()) {
+        path_ = other.path_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      if (other.hasElement()) {
+        mergeElement(other.getElement());
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 8: {
+              instanceId_ = input.readInt64();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 8
+            case 18: {
+              path_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            case 26: {
+              input.readMessage(
+                  internalGetElementFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 26
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private long instanceId_ ;
+    /**
+     * <pre>
+     * The object's id, as Instantiate answered it. Fails with NOT_FOUND when no
+     * object of the model has it.
+     * </pre>
+     *
+     * <code>int64 instance_id = 1 [json_name = "instanceId"];</code>
+     * @return The instanceId.
+     */
+    @java.lang.Override
+    public long getInstanceId() {
+      return instanceId_;
+    }
+    /**
+     * <pre>
+     * The object's id, as Instantiate answered it. Fails with NOT_FOUND when no
+     * object of the model has it.
+     * </pre>
+     *
+     * <code>int64 instance_id = 1 [json_name = "instanceId"];</code>
+     * @param value The instanceId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setInstanceId(long value) {
+
+      instanceId_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object's id, as Instantiate answered it. Fails with NOT_FOUND when no
+     * object of the model has it.
+     * </pre>
+     *
+     * <code>int64 instance_id = 1 [json_name = "instanceId"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearInstanceId() {
+      bitField0_ = (bitField0_ & ~0x00000001);
+      instanceId_ = 0L;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object path_ = "";
+    /**
+     * <pre>
+     * The object by the label a session reaches it under: the qualified name it
+     * was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+     * feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+     * count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+     * a segment reaches no object, and NOT_FOUND when the name it starts from has
+     * no object.
+     * </pre>
+     *
+     * <code>string path = 2 [json_name = "path"];</code>
+     * @return The path.
+     */
+    public java.lang.String getPath() {
+      java.lang.Object ref = path_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        path_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The object by the label a session reaches it under: the qualified name it
+     * was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+     * feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+     * count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+     * a segment reaches no object, and NOT_FOUND when the name it starts from has
+     * no object.
+     * </pre>
+     *
+     * <code>string path = 2 [json_name = "path"];</code>
+     * @return The bytes for path.
+     */
+    public com.google.protobuf.ByteString
+        getPathBytes() {
+      java.lang.Object ref = path_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        path_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The object by the label a session reaches it under: the qualified name it
+     * was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+     * feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+     * count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+     * a segment reaches no object, and NOT_FOUND when the name it starts from has
+     * no object.
+     * </pre>
+     *
+     * <code>string path = 2 [json_name = "path"];</code>
+     * @param value The path to set.
+     * @return This builder for chaining.
+     */
+    public Builder setPath(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      path_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object by the label a session reaches it under: the qualified name it
+     * was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+     * feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+     * count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+     * a segment reaches no object, and NOT_FOUND when the name it starts from has
+     * no object.
+     * </pre>
+     *
+     * <code>string path = 2 [json_name = "path"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearPath() {
+      path_ = getDefaultInstance().getPath();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object by the label a session reaches it under: the qualified name it
+     * was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+     * feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+     * count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+     * a segment reaches no object, and NOT_FOUND when the name it starts from has
+     * no object.
+     * </pre>
+     *
+     * <code>string path = 2 [json_name = "path"];</code>
+     * @param value The bytes for path to set.
+     * @return This builder for chaining.
+     */
+    public Builder setPathBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      path_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
+    private org.openmbee.opensysml.proto.DocumentValue element_;
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder> elementBuilder_;
+    /**
+     * <pre>
+     * The usage the object is held under — its own definition or usage for an
+     * object Instantiate created — as an element value with its qualified name
+     * and metamodel type. Answered, ignored when bound.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+     * @return Whether the element field is set.
+     */
+    public boolean hasElement() {
+      return ((bitField0_ & 0x00000004) != 0);
+    }
+    /**
+     * <pre>
+     * The usage the object is held under — its own definition or usage for an
+     * object Instantiate created — as an element value with its qualified name
+     * and metamodel type. Answered, ignored when bound.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+     * @return The element.
+     */
+    public org.openmbee.opensysml.proto.DocumentValue getElement() {
+      if (elementBuilder_ == null) {
+        return element_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : element_;
+      } else {
+        return elementBuilder_.getMessage();
+      }
+    }
+    /**
+     * <pre>
+     * The usage the object is held under — its own definition or usage for an
+     * object Instantiate created — as an element value with its qualified name
+     * and metamodel type. Answered, ignored when bound.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+     */
+    public Builder setElement(org.openmbee.opensysml.proto.DocumentValue value) {
+      if (elementBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        element_ = value;
+      } else {
+        elementBuilder_.setMessage(value);
+      }
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The usage the object is held under — its own definition or usage for an
+     * object Instantiate created — as an element value with its qualified name
+     * and metamodel type. Answered, ignored when bound.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+     */
+    public Builder setElement(
+        org.openmbee.opensysml.proto.DocumentValue.Builder builderForValue) {
+      if (elementBuilder_ == null) {
+        element_ = builderForValue.build();
+      } else {
+        elementBuilder_.setMessage(builderForValue.build());
+      }
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The usage the object is held under — its own definition or usage for an
+     * object Instantiate created — as an element value with its qualified name
+     * and metamodel type. Answered, ignored when bound.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+     */
+    public Builder mergeElement(org.openmbee.opensysml.proto.DocumentValue value) {
+      if (elementBuilder_ == null) {
+        if (((bitField0_ & 0x00000004) != 0) &&
+          element_ != null &&
+          element_ != org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance()) {
+          getElementBuilder().mergeFrom(value);
+        } else {
+          element_ = value;
+        }
+      } else {
+        elementBuilder_.mergeFrom(value);
+      }
+      if (element_ != null) {
+        bitField0_ |= 0x00000004;
+        onChanged();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The usage the object is held under — its own definition or usage for an
+     * object Instantiate created — as an element value with its qualified name
+     * and metamodel type. Answered, ignored when bound.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+     */
+    public Builder clearElement() {
+      bitField0_ = (bitField0_ & ~0x00000004);
+      element_ = null;
+      if (elementBuilder_ != null) {
+        elementBuilder_.dispose();
+        elementBuilder_ = null;
+      }
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The usage the object is held under — its own definition or usage for an
+     * object Instantiate created — as an element value with its qualified name
+     * and metamodel type. Answered, ignored when bound.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentValue.Builder getElementBuilder() {
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return internalGetElementFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * The usage the object is held under — its own definition or usage for an
+     * object Instantiate created — as an element value with its qualified name
+     * and metamodel type. Answered, ignored when bound.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentValueOrBuilder getElementOrBuilder() {
+      if (elementBuilder_ != null) {
+        return elementBuilder_.getMessageOrBuilder();
+      } else {
+        return element_ == null ?
+            org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : element_;
+      }
+    }
+    /**
+     * <pre>
+     * The usage the object is held under — its own definition or usage for an
+     * object Instantiate created — as an element value with its qualified name
+     * and metamodel type. Answered, ignored when bound.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder> 
+        internalGetElementFieldBuilder() {
+      if (elementBuilder_ == null) {
+        elementBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder>(
+                getElement(),
+                getParentForChildren(),
+                isClean());
+        element_ = null;
+      }
+      return elementBuilder_;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.DocumentObject)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.DocumentObject)
+  private static final org.openmbee.opensysml.proto.DocumentObject DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.DocumentObject();
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentObject getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<DocumentObject>
+      PARSER = new com.google.protobuf.AbstractParser<DocumentObject>() {
+    @java.lang.Override
+    public DocumentObject parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<DocumentObject> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<DocumentObject> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentObject getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentObjectOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentObjectOrBuilder.java
new file mode 100644
index 0000000000..8b77a1ef92
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentObjectOrBuilder.java
@@ -0,0 +1,86 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface DocumentObjectOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.DocumentObject)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * The object's id, as Instantiate answered it. Fails with NOT_FOUND when no
+   * object of the model has it.
+   * </pre>
+   *
+   * <code>int64 instance_id = 1 [json_name = "instanceId"];</code>
+   * @return The instanceId.
+   */
+  long getInstanceId();
+
+  /**
+   * <pre>
+   * The object by the label a session reaches it under: the qualified name it
+   * was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+   * feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+   * count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+   * a segment reaches no object, and NOT_FOUND when the name it starts from has
+   * no object.
+   * </pre>
+   *
+   * <code>string path = 2 [json_name = "path"];</code>
+   * @return The path.
+   */
+  java.lang.String getPath();
+  /**
+   * <pre>
+   * The object by the label a session reaches it under: the qualified name it
+   * was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+   * feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+   * count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+   * a segment reaches no object, and NOT_FOUND when the name it starts from has
+   * no object.
+   * </pre>
+   *
+   * <code>string path = 2 [json_name = "path"];</code>
+   * @return The bytes for path.
+   */
+  com.google.protobuf.ByteString
+      getPathBytes();
+
+  /**
+   * <pre>
+   * The usage the object is held under — its own definition or usage for an
+   * object Instantiate created — as an element value with its qualified name
+   * and metamodel type. Answered, ignored when bound.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+   * @return Whether the element field is set.
+   */
+  boolean hasElement();
+  /**
+   * <pre>
+   * The usage the object is held under — its own definition or usage for an
+   * object Instantiate created — as an element value with its qualified name
+   * and metamodel type. Answered, ignored when bound.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+   * @return The element.
+   */
+  org.openmbee.opensysml.proto.DocumentValue getElement();
+  /**
+   * <pre>
+   * The usage the object is held under — its own definition or usage for an
+   * object Instantiate created — as an element value with its qualified name
+   * and metamodel type. Answered, ignored when bound.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue element = 3 [json_name = "element"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentValueOrBuilder getElementOrBuilder();
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryBinding.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryBinding.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryBinding.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryBinding.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryBindingOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryBindingOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryBindingOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryBindingOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryCell.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryCell.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryCell.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryCell.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryCellOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryCellOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryCellOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryCellOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryColumn.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryColumn.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryColumn.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryColumn.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryColumnOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryColumnOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryColumnOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryColumnOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRow.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRow.java
similarity index 92%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRow.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRow.java
index 2651a4d735..311fffb4a2 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRow.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRow.java
@@ -54,7 +54,10 @@ private DocumentQueryRow() {
   private org.openmbee.opensysml.proto.DocumentValue element_;
   /**
    * <pre>
-   * The selected element itself, an element value with its qualified name.
+   * The selected element itself, an element value with its qualified name; an
+   * object value for a row over an object the service holds; a verdict value
+   * for a row `Verdicts` answered; a state value for a row `States` answered;
+   * an event value for a row `Events` answered.
    * </pre>
    *
    * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -66,7 +69,10 @@ public boolean hasElement() {
   }
   /**
    * <pre>
-   * The selected element itself, an element value with its qualified name.
+   * The selected element itself, an element value with its qualified name; an
+   * object value for a row over an object the service holds; a verdict value
+   * for a row `Verdicts` answered; a state value for a row `States` answered;
+   * an event value for a row `Events` answered.
    * </pre>
    *
    * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -78,7 +84,10 @@ public org.openmbee.opensysml.proto.DocumentValue getElement() {
   }
   /**
    * <pre>
-   * The selected element itself, an element value with its qualified name.
+   * The selected element itself, an element value with its qualified name; an
+   * object value for a row over an object the service holds; a verdict value
+   * for a row `Verdicts` answered; a state value for a row `States` answered;
+   * an event value for a row `Events` answered.
    * </pre>
    *
    * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -527,7 +536,10 @@ public Builder mergeFrom(
         org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder> elementBuilder_;
     /**
      * <pre>
-     * The selected element itself, an element value with its qualified name.
+     * The selected element itself, an element value with its qualified name; an
+     * object value for a row over an object the service holds; a verdict value
+     * for a row `Verdicts` answered; a state value for a row `States` answered;
+     * an event value for a row `Events` answered.
      * </pre>
      *
      * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -538,7 +550,10 @@ public boolean hasElement() {
     }
     /**
      * <pre>
-     * The selected element itself, an element value with its qualified name.
+     * The selected element itself, an element value with its qualified name; an
+     * object value for a row over an object the service holds; a verdict value
+     * for a row `Verdicts` answered; a state value for a row `States` answered;
+     * an event value for a row `Events` answered.
      * </pre>
      *
      * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -553,7 +568,10 @@ public org.openmbee.opensysml.proto.DocumentValue getElement() {
     }
     /**
      * <pre>
-     * The selected element itself, an element value with its qualified name.
+     * The selected element itself, an element value with its qualified name; an
+     * object value for a row over an object the service holds; a verdict value
+     * for a row `Verdicts` answered; a state value for a row `States` answered;
+     * an event value for a row `Events` answered.
      * </pre>
      *
      * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -573,7 +591,10 @@ public Builder setElement(org.openmbee.opensysml.proto.DocumentValue value) {
     }
     /**
      * <pre>
-     * The selected element itself, an element value with its qualified name.
+     * The selected element itself, an element value with its qualified name; an
+     * object value for a row over an object the service holds; a verdict value
+     * for a row `Verdicts` answered; a state value for a row `States` answered;
+     * an event value for a row `Events` answered.
      * </pre>
      *
      * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -591,7 +612,10 @@ public Builder setElement(
     }
     /**
      * <pre>
-     * The selected element itself, an element value with its qualified name.
+     * The selected element itself, an element value with its qualified name; an
+     * object value for a row over an object the service holds; a verdict value
+     * for a row `Verdicts` answered; a state value for a row `States` answered;
+     * an event value for a row `Events` answered.
      * </pre>
      *
      * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -616,7 +640,10 @@ public Builder mergeElement(org.openmbee.opensysml.proto.DocumentValue value) {
     }
     /**
      * <pre>
-     * The selected element itself, an element value with its qualified name.
+     * The selected element itself, an element value with its qualified name; an
+     * object value for a row over an object the service holds; a verdict value
+     * for a row `Verdicts` answered; a state value for a row `States` answered;
+     * an event value for a row `Events` answered.
      * </pre>
      *
      * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -633,7 +660,10 @@ public Builder clearElement() {
     }
     /**
      * <pre>
-     * The selected element itself, an element value with its qualified name.
+     * The selected element itself, an element value with its qualified name; an
+     * object value for a row over an object the service holds; a verdict value
+     * for a row `Verdicts` answered; a state value for a row `States` answered;
+     * an event value for a row `Events` answered.
      * </pre>
      *
      * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -645,7 +675,10 @@ public org.openmbee.opensysml.proto.DocumentValue.Builder getElementBuilder() {
     }
     /**
      * <pre>
-     * The selected element itself, an element value with its qualified name.
+     * The selected element itself, an element value with its qualified name; an
+     * object value for a row over an object the service holds; a verdict value
+     * for a row `Verdicts` answered; a state value for a row `States` answered;
+     * an event value for a row `Events` answered.
      * </pre>
      *
      * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -660,7 +693,10 @@ public org.openmbee.opensysml.proto.DocumentValueOrBuilder getElementOrBuilder()
     }
     /**
      * <pre>
-     * The selected element itself, an element value with its qualified name.
+     * The selected element itself, an element value with its qualified name; an
+     * object value for a row over an object the service holds; a verdict value
+     * for a row `Verdicts` answered; a state value for a row `States` answered;
+     * an event value for a row `Events` answered.
      * </pre>
      *
      * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRowOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRowOrBuilder.java
similarity index 75%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRowOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRowOrBuilder.java
index 2cfe70b38d..ff71b25d6e 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRowOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentQueryRowOrBuilder.java
@@ -12,7 +12,10 @@ public interface DocumentQueryRowOrBuilder extends
 
   /**
    * <pre>
-   * The selected element itself, an element value with its qualified name.
+   * The selected element itself, an element value with its qualified name; an
+   * object value for a row over an object the service holds; a verdict value
+   * for a row `Verdicts` answered; a state value for a row `States` answered;
+   * an event value for a row `Events` answered.
    * </pre>
    *
    * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -21,7 +24,10 @@ public interface DocumentQueryRowOrBuilder extends
   boolean hasElement();
   /**
    * <pre>
-   * The selected element itself, an element value with its qualified name.
+   * The selected element itself, an element value with its qualified name; an
+   * object value for a row over an object the service holds; a verdict value
+   * for a row `Verdicts` answered; a state value for a row `States` answered;
+   * an event value for a row `Events` answered.
    * </pre>
    *
    * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
@@ -30,7 +36,10 @@ public interface DocumentQueryRowOrBuilder extends
   org.openmbee.opensysml.proto.DocumentValue getElement();
   /**
    * <pre>
-   * The selected element itself, an element value with its qualified name.
+   * The selected element itself, an element value with its qualified name; an
+   * object value for a row over an object the service holds; a verdict value
+   * for a row `Verdicts` answered; a state value for a row `States` answered;
+   * an event value for a row `Events` answered.
    * </pre>
    *
    * <code>.sysml.DocumentValue element = 1 [json_name = "element"];</code>
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentState.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentState.java
new file mode 100644
index 0000000000..4dec67fad0
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentState.java
@@ -0,0 +1,1768 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * DocumentState is one row a `States` query answered: an active leaf state of
+ * the state machine `object` exhibits, which the row stands for as its `element`
+ * when the model declares it.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.DocumentState}
+ */
+@com.google.protobuf.Generated
+public final class DocumentState extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.DocumentState)
+    DocumentStateOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "DocumentState");
+  }
+  // Use DocumentState.newBuilder() to construct.
+  private DocumentState(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private DocumentState() {
+    machine_ = "";
+    name_ = "";
+    statePath_ = "";
+    region_ = "";
+    enclosing_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentState_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentState_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.DocumentState.class, org.openmbee.opensysml.proto.DocumentState.Builder.class);
+  }
+
+  private int bitField0_;
+  public static final int OBJECT_FIELD_NUMBER = 1;
+  private org.openmbee.opensysml.proto.DocumentObject object_;
+  /**
+   * <pre>
+   * The object in the state, with the path the session reaches it by.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+   * @return Whether the object field is set.
+   */
+  @java.lang.Override
+  public boolean hasObject() {
+    return ((bitField0_ & 0x00000001) != 0);
+  }
+  /**
+   * <pre>
+   * The object in the state, with the path the session reaches it by.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+   * @return The object.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentObject getObject() {
+    return object_ == null ? org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : object_;
+  }
+  /**
+   * <pre>
+   * The object in the state, with the path the session reaches it by.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentObjectOrBuilder getObjectOrBuilder() {
+    return object_ == null ? org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : object_;
+  }
+
+  public static final int MACHINE_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object machine_ = "";
+  /**
+   * <pre>
+   * The state machine, by the name the object exhibits it under (`lp`), else
+   * its declared name.
+   * </pre>
+   *
+   * <code>string machine = 2 [json_name = "machine"];</code>
+   * @return The machine.
+   */
+  @java.lang.Override
+  public java.lang.String getMachine() {
+    java.lang.Object ref = machine_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      machine_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The state machine, by the name the object exhibits it under (`lp`), else
+   * its declared name.
+   * </pre>
+   *
+   * <code>string machine = 2 [json_name = "machine"];</code>
+   * @return The bytes for machine.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getMachineBytes() {
+    java.lang.Object ref = machine_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      machine_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int NAME_FIELD_NUMBER = 3;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object name_ = "";
+  /**
+   * <pre>
+   * The leaf state's name.
+   * </pre>
+   *
+   * <code>string name = 3 [json_name = "name"];</code>
+   * @return The name.
+   */
+  @java.lang.Override
+  public java.lang.String getName() {
+    java.lang.Object ref = name_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      name_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The leaf state's name.
+   * </pre>
+   *
+   * <code>string name = 3 [json_name = "name"];</code>
+   * @return The bytes for name.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getNameBytes() {
+    java.lang.Object ref = name_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      name_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int STATE_PATH_FIELD_NUMBER = 4;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object statePath_ = "";
+  /**
+   * <pre>
+   * The leaf state's path in its machine, composite states first (`on.dim`).
+   * </pre>
+   *
+   * <code>string state_path = 4 [json_name = "statePath"];</code>
+   * @return The statePath.
+   */
+  @java.lang.Override
+  public java.lang.String getStatePath() {
+    java.lang.Object ref = statePath_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      statePath_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The leaf state's path in its machine, composite states first (`on.dim`).
+   * </pre>
+   *
+   * <code>string state_path = 4 [json_name = "statePath"];</code>
+   * @return The bytes for statePath.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getStatePathBytes() {
+    java.lang.Object ref = statePath_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      statePath_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int STATE_FIELD_NUMBER = 5;
+  private org.openmbee.opensysml.proto.DocumentValue state_;
+  /**
+   * <pre>
+   * The leaf state's declaration as an element value; empty when the machine
+   * declares no element for it.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+   * @return Whether the state field is set.
+   */
+  @java.lang.Override
+  public boolean hasState() {
+    return ((bitField0_ & 0x00000002) != 0);
+  }
+  /**
+   * <pre>
+   * The leaf state's declaration as an element value; empty when the machine
+   * declares no element for it.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+   * @return The state.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentValue getState() {
+    return state_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : state_;
+  }
+  /**
+   * <pre>
+   * The leaf state's declaration as an element value; empty when the machine
+   * declares no element for it.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentValueOrBuilder getStateOrBuilder() {
+    return state_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : state_;
+  }
+
+  public static final int REGION_FIELD_NUMBER = 6;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object region_ = "";
+  /**
+   * <pre>
+   * The orthogonal region the leaf is declared in; empty outside one.
+   * </pre>
+   *
+   * <code>string region = 6 [json_name = "region"];</code>
+   * @return The region.
+   */
+  @java.lang.Override
+  public java.lang.String getRegion() {
+    java.lang.Object ref = region_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      region_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The orthogonal region the leaf is declared in; empty outside one.
+   * </pre>
+   *
+   * <code>string region = 6 [json_name = "region"];</code>
+   * @return The bytes for region.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getRegionBytes() {
+    java.lang.Object ref = region_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      region_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int ENCLOSING_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private com.google.protobuf.LazyStringArrayList enclosing_ =
+      com.google.protobuf.LazyStringArrayList.emptyList();
+  /**
+   * <pre>
+   * The composite states enclosing the leaf, outermost first; each is active.
+   * </pre>
+   *
+   * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+   * @return A list containing the enclosing.
+   */
+  public com.google.protobuf.ProtocolStringList
+      getEnclosingList() {
+    return enclosing_;
+  }
+  /**
+   * <pre>
+   * The composite states enclosing the leaf, outermost first; each is active.
+   * </pre>
+   *
+   * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+   * @return The count of enclosing.
+   */
+  public int getEnclosingCount() {
+    return enclosing_.size();
+  }
+  /**
+   * <pre>
+   * The composite states enclosing the leaf, outermost first; each is active.
+   * </pre>
+   *
+   * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+   * @param index The index of the element to return.
+   * @return The enclosing at the given index.
+   */
+  public java.lang.String getEnclosing(int index) {
+    return enclosing_.get(index);
+  }
+  /**
+   * <pre>
+   * The composite states enclosing the leaf, outermost first; each is active.
+   * </pre>
+   *
+   * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the enclosing at the given index.
+   */
+  public com.google.protobuf.ByteString
+      getEnclosingBytes(int index) {
+    return enclosing_.getByteString(index);
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (((bitField0_ & 0x00000001) != 0)) {
+      output.writeMessage(1, getObject());
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(machine_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, machine_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(name_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 3, name_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(statePath_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 4, statePath_);
+    }
+    if (((bitField0_ & 0x00000002) != 0)) {
+      output.writeMessage(5, getState());
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(region_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 6, region_);
+    }
+    for (int i = 0; i < enclosing_.size(); i++) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 7, enclosing_.getRaw(i));
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (((bitField0_ & 0x00000001) != 0)) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(1, getObject());
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(machine_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, machine_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(name_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(3, name_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(statePath_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(4, statePath_);
+    }
+    if (((bitField0_ & 0x00000002) != 0)) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(5, getState());
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(region_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(6, region_);
+    }
+    {
+      int dataSize = 0;
+      for (int i = 0; i < enclosing_.size(); i++) {
+        dataSize += computeStringSizeNoTag(enclosing_.getRaw(i));
+      }
+      size += dataSize;
+      size += 1 * getEnclosingList().size();
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.DocumentState)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.DocumentState other = (org.openmbee.opensysml.proto.DocumentState) obj;
+
+    if (hasObject() != other.hasObject()) return false;
+    if (hasObject()) {
+      if (!getObject()
+          .equals(other.getObject())) return false;
+    }
+    if (!getMachine()
+        .equals(other.getMachine())) return false;
+    if (!getName()
+        .equals(other.getName())) return false;
+    if (!getStatePath()
+        .equals(other.getStatePath())) return false;
+    if (hasState() != other.hasState()) return false;
+    if (hasState()) {
+      if (!getState()
+          .equals(other.getState())) return false;
+    }
+    if (!getRegion()
+        .equals(other.getRegion())) return false;
+    if (!getEnclosingList()
+        .equals(other.getEnclosingList())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    if (hasObject()) {
+      hash = (37 * hash) + OBJECT_FIELD_NUMBER;
+      hash = (53 * hash) + getObject().hashCode();
+    }
+    hash = (37 * hash) + MACHINE_FIELD_NUMBER;
+    hash = (53 * hash) + getMachine().hashCode();
+    hash = (37 * hash) + NAME_FIELD_NUMBER;
+    hash = (53 * hash) + getName().hashCode();
+    hash = (37 * hash) + STATE_PATH_FIELD_NUMBER;
+    hash = (53 * hash) + getStatePath().hashCode();
+    if (hasState()) {
+      hash = (37 * hash) + STATE_FIELD_NUMBER;
+      hash = (53 * hash) + getState().hashCode();
+    }
+    hash = (37 * hash) + REGION_FIELD_NUMBER;
+    hash = (53 * hash) + getRegion().hashCode();
+    if (getEnclosingCount() > 0) {
+      hash = (37 * hash) + ENCLOSING_FIELD_NUMBER;
+      hash = (53 * hash) + getEnclosingList().hashCode();
+    }
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentState parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentState parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentState parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentState parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentState parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentState parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentState parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.DocumentState parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentState parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentState parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentState parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.DocumentState parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.DocumentState prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * DocumentState is one row a `States` query answered: an active leaf state of
+   * the state machine `object` exhibits, which the row stands for as its `element`
+   * when the model declares it.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.DocumentState}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.DocumentState)
+      org.openmbee.opensysml.proto.DocumentStateOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentState_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentState_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.DocumentState.class, org.openmbee.opensysml.proto.DocumentState.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.DocumentState.newBuilder()
+    private Builder() {
+      maybeForceBuilderInitialization();
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+      maybeForceBuilderInitialization();
+    }
+    private void maybeForceBuilderInitialization() {
+      if (com.google.protobuf.GeneratedMessage
+              .alwaysUseFieldBuilders) {
+        internalGetObjectFieldBuilder();
+        internalGetStateFieldBuilder();
+      }
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      object_ = null;
+      if (objectBuilder_ != null) {
+        objectBuilder_.dispose();
+        objectBuilder_ = null;
+      }
+      machine_ = "";
+      name_ = "";
+      statePath_ = "";
+      state_ = null;
+      if (stateBuilder_ != null) {
+        stateBuilder_.dispose();
+        stateBuilder_ = null;
+      }
+      region_ = "";
+      enclosing_ =
+          com.google.protobuf.LazyStringArrayList.emptyList();
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentState_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentState getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.DocumentState.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentState build() {
+      org.openmbee.opensysml.proto.DocumentState result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentState buildPartial() {
+      org.openmbee.opensysml.proto.DocumentState result = new org.openmbee.opensysml.proto.DocumentState(this);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.DocumentState result) {
+      int from_bitField0_ = bitField0_;
+      int to_bitField0_ = 0;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.object_ = objectBuilder_ == null
+            ? object_
+            : objectBuilder_.build();
+        to_bitField0_ |= 0x00000001;
+      }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.machine_ = machine_;
+      }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.name_ = name_;
+      }
+      if (((from_bitField0_ & 0x00000008) != 0)) {
+        result.statePath_ = statePath_;
+      }
+      if (((from_bitField0_ & 0x00000010) != 0)) {
+        result.state_ = stateBuilder_ == null
+            ? state_
+            : stateBuilder_.build();
+        to_bitField0_ |= 0x00000002;
+      }
+      if (((from_bitField0_ & 0x00000020) != 0)) {
+        result.region_ = region_;
+      }
+      if (((from_bitField0_ & 0x00000040) != 0)) {
+        enclosing_.makeImmutable();
+        result.enclosing_ = enclosing_;
+      }
+      result.bitField0_ |= to_bitField0_;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.DocumentState) {
+        return mergeFrom((org.openmbee.opensysml.proto.DocumentState)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.DocumentState other) {
+      if (other == org.openmbee.opensysml.proto.DocumentState.getDefaultInstance()) return this;
+      if (other.hasObject()) {
+        mergeObject(other.getObject());
+      }
+      if (!other.getMachine().isEmpty()) {
+        machine_ = other.machine_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      if (!other.getName().isEmpty()) {
+        name_ = other.name_;
+        bitField0_ |= 0x00000004;
+        onChanged();
+      }
+      if (!other.getStatePath().isEmpty()) {
+        statePath_ = other.statePath_;
+        bitField0_ |= 0x00000008;
+        onChanged();
+      }
+      if (other.hasState()) {
+        mergeState(other.getState());
+      }
+      if (!other.getRegion().isEmpty()) {
+        region_ = other.region_;
+        bitField0_ |= 0x00000020;
+        onChanged();
+      }
+      if (!other.enclosing_.isEmpty()) {
+        if (enclosing_.isEmpty()) {
+          enclosing_ = other.enclosing_;
+          bitField0_ |= 0x00000040;
+        } else {
+          ensureEnclosingIsMutable();
+          enclosing_.addAll(other.enclosing_);
+        }
+        onChanged();
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              input.readMessage(
+                  internalGetObjectFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              machine_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            case 26: {
+              name_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 26
+            case 34: {
+              statePath_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000008;
+              break;
+            } // case 34
+            case 42: {
+              input.readMessage(
+                  internalGetStateFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              bitField0_ |= 0x00000010;
+              break;
+            } // case 42
+            case 50: {
+              region_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000020;
+              break;
+            } // case 50
+            case 58: {
+              java.lang.String s = input.readStringRequireUtf8();
+              ensureEnclosingIsMutable();
+              enclosing_.add(s);
+              break;
+            } // case 58
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private org.openmbee.opensysml.proto.DocumentObject object_;
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder> objectBuilder_;
+    /**
+     * <pre>
+     * The object in the state, with the path the session reaches it by.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+     * @return Whether the object field is set.
+     */
+    public boolean hasObject() {
+      return ((bitField0_ & 0x00000001) != 0);
+    }
+    /**
+     * <pre>
+     * The object in the state, with the path the session reaches it by.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+     * @return The object.
+     */
+    public org.openmbee.opensysml.proto.DocumentObject getObject() {
+      if (objectBuilder_ == null) {
+        return object_ == null ? org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : object_;
+      } else {
+        return objectBuilder_.getMessage();
+      }
+    }
+    /**
+     * <pre>
+     * The object in the state, with the path the session reaches it by.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+     */
+    public Builder setObject(org.openmbee.opensysml.proto.DocumentObject value) {
+      if (objectBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        object_ = value;
+      } else {
+        objectBuilder_.setMessage(value);
+      }
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object in the state, with the path the session reaches it by.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+     */
+    public Builder setObject(
+        org.openmbee.opensysml.proto.DocumentObject.Builder builderForValue) {
+      if (objectBuilder_ == null) {
+        object_ = builderForValue.build();
+      } else {
+        objectBuilder_.setMessage(builderForValue.build());
+      }
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object in the state, with the path the session reaches it by.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+     */
+    public Builder mergeObject(org.openmbee.opensysml.proto.DocumentObject value) {
+      if (objectBuilder_ == null) {
+        if (((bitField0_ & 0x00000001) != 0) &&
+          object_ != null &&
+          object_ != org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance()) {
+          getObjectBuilder().mergeFrom(value);
+        } else {
+          object_ = value;
+        }
+      } else {
+        objectBuilder_.mergeFrom(value);
+      }
+      if (object_ != null) {
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The object in the state, with the path the session reaches it by.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+     */
+    public Builder clearObject() {
+      bitField0_ = (bitField0_ & ~0x00000001);
+      object_ = null;
+      if (objectBuilder_ != null) {
+        objectBuilder_.dispose();
+        objectBuilder_ = null;
+      }
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object in the state, with the path the session reaches it by.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentObject.Builder getObjectBuilder() {
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return internalGetObjectFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * The object in the state, with the path the session reaches it by.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentObjectOrBuilder getObjectOrBuilder() {
+      if (objectBuilder_ != null) {
+        return objectBuilder_.getMessageOrBuilder();
+      } else {
+        return object_ == null ?
+            org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance() : object_;
+      }
+    }
+    /**
+     * <pre>
+     * The object in the state, with the path the session reaches it by.
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder> 
+        internalGetObjectFieldBuilder() {
+      if (objectBuilder_ == null) {
+        objectBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder>(
+                getObject(),
+                getParentForChildren(),
+                isClean());
+        object_ = null;
+      }
+      return objectBuilder_;
+    }
+
+    private java.lang.Object machine_ = "";
+    /**
+     * <pre>
+     * The state machine, by the name the object exhibits it under (`lp`), else
+     * its declared name.
+     * </pre>
+     *
+     * <code>string machine = 2 [json_name = "machine"];</code>
+     * @return The machine.
+     */
+    public java.lang.String getMachine() {
+      java.lang.Object ref = machine_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        machine_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The state machine, by the name the object exhibits it under (`lp`), else
+     * its declared name.
+     * </pre>
+     *
+     * <code>string machine = 2 [json_name = "machine"];</code>
+     * @return The bytes for machine.
+     */
+    public com.google.protobuf.ByteString
+        getMachineBytes() {
+      java.lang.Object ref = machine_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        machine_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The state machine, by the name the object exhibits it under (`lp`), else
+     * its declared name.
+     * </pre>
+     *
+     * <code>string machine = 2 [json_name = "machine"];</code>
+     * @param value The machine to set.
+     * @return This builder for chaining.
+     */
+    public Builder setMachine(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      machine_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The state machine, by the name the object exhibits it under (`lp`), else
+     * its declared name.
+     * </pre>
+     *
+     * <code>string machine = 2 [json_name = "machine"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearMachine() {
+      machine_ = getDefaultInstance().getMachine();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The state machine, by the name the object exhibits it under (`lp`), else
+     * its declared name.
+     * </pre>
+     *
+     * <code>string machine = 2 [json_name = "machine"];</code>
+     * @param value The bytes for machine to set.
+     * @return This builder for chaining.
+     */
+    public Builder setMachineBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      machine_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object name_ = "";
+    /**
+     * <pre>
+     * The leaf state's name.
+     * </pre>
+     *
+     * <code>string name = 3 [json_name = "name"];</code>
+     * @return The name.
+     */
+    public java.lang.String getName() {
+      java.lang.Object ref = name_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        name_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The leaf state's name.
+     * </pre>
+     *
+     * <code>string name = 3 [json_name = "name"];</code>
+     * @return The bytes for name.
+     */
+    public com.google.protobuf.ByteString
+        getNameBytes() {
+      java.lang.Object ref = name_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        name_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The leaf state's name.
+     * </pre>
+     *
+     * <code>string name = 3 [json_name = "name"];</code>
+     * @param value The name to set.
+     * @return This builder for chaining.
+     */
+    public Builder setName(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      name_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The leaf state's name.
+     * </pre>
+     *
+     * <code>string name = 3 [json_name = "name"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearName() {
+      name_ = getDefaultInstance().getName();
+      bitField0_ = (bitField0_ & ~0x00000004);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The leaf state's name.
+     * </pre>
+     *
+     * <code>string name = 3 [json_name = "name"];</code>
+     * @param value The bytes for name to set.
+     * @return This builder for chaining.
+     */
+    public Builder setNameBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      name_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object statePath_ = "";
+    /**
+     * <pre>
+     * The leaf state's path in its machine, composite states first (`on.dim`).
+     * </pre>
+     *
+     * <code>string state_path = 4 [json_name = "statePath"];</code>
+     * @return The statePath.
+     */
+    public java.lang.String getStatePath() {
+      java.lang.Object ref = statePath_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        statePath_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The leaf state's path in its machine, composite states first (`on.dim`).
+     * </pre>
+     *
+     * <code>string state_path = 4 [json_name = "statePath"];</code>
+     * @return The bytes for statePath.
+     */
+    public com.google.protobuf.ByteString
+        getStatePathBytes() {
+      java.lang.Object ref = statePath_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        statePath_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The leaf state's path in its machine, composite states first (`on.dim`).
+     * </pre>
+     *
+     * <code>string state_path = 4 [json_name = "statePath"];</code>
+     * @param value The statePath to set.
+     * @return This builder for chaining.
+     */
+    public Builder setStatePath(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      statePath_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The leaf state's path in its machine, composite states first (`on.dim`).
+     * </pre>
+     *
+     * <code>string state_path = 4 [json_name = "statePath"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearStatePath() {
+      statePath_ = getDefaultInstance().getStatePath();
+      bitField0_ = (bitField0_ & ~0x00000008);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The leaf state's path in its machine, composite states first (`on.dim`).
+     * </pre>
+     *
+     * <code>string state_path = 4 [json_name = "statePath"];</code>
+     * @param value The bytes for statePath to set.
+     * @return This builder for chaining.
+     */
+    public Builder setStatePathBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      statePath_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+
+    private org.openmbee.opensysml.proto.DocumentValue state_;
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder> stateBuilder_;
+    /**
+     * <pre>
+     * The leaf state's declaration as an element value; empty when the machine
+     * declares no element for it.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+     * @return Whether the state field is set.
+     */
+    public boolean hasState() {
+      return ((bitField0_ & 0x00000010) != 0);
+    }
+    /**
+     * <pre>
+     * The leaf state's declaration as an element value; empty when the machine
+     * declares no element for it.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+     * @return The state.
+     */
+    public org.openmbee.opensysml.proto.DocumentValue getState() {
+      if (stateBuilder_ == null) {
+        return state_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : state_;
+      } else {
+        return stateBuilder_.getMessage();
+      }
+    }
+    /**
+     * <pre>
+     * The leaf state's declaration as an element value; empty when the machine
+     * declares no element for it.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+     */
+    public Builder setState(org.openmbee.opensysml.proto.DocumentValue value) {
+      if (stateBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        state_ = value;
+      } else {
+        stateBuilder_.setMessage(value);
+      }
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The leaf state's declaration as an element value; empty when the machine
+     * declares no element for it.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+     */
+    public Builder setState(
+        org.openmbee.opensysml.proto.DocumentValue.Builder builderForValue) {
+      if (stateBuilder_ == null) {
+        state_ = builderForValue.build();
+      } else {
+        stateBuilder_.setMessage(builderForValue.build());
+      }
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The leaf state's declaration as an element value; empty when the machine
+     * declares no element for it.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+     */
+    public Builder mergeState(org.openmbee.opensysml.proto.DocumentValue value) {
+      if (stateBuilder_ == null) {
+        if (((bitField0_ & 0x00000010) != 0) &&
+          state_ != null &&
+          state_ != org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance()) {
+          getStateBuilder().mergeFrom(value);
+        } else {
+          state_ = value;
+        }
+      } else {
+        stateBuilder_.mergeFrom(value);
+      }
+      if (state_ != null) {
+        bitField0_ |= 0x00000010;
+        onChanged();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The leaf state's declaration as an element value; empty when the machine
+     * declares no element for it.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+     */
+    public Builder clearState() {
+      bitField0_ = (bitField0_ & ~0x00000010);
+      state_ = null;
+      if (stateBuilder_ != null) {
+        stateBuilder_.dispose();
+        stateBuilder_ = null;
+      }
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The leaf state's declaration as an element value; empty when the machine
+     * declares no element for it.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentValue.Builder getStateBuilder() {
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return internalGetStateFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * The leaf state's declaration as an element value; empty when the machine
+     * declares no element for it.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentValueOrBuilder getStateOrBuilder() {
+      if (stateBuilder_ != null) {
+        return stateBuilder_.getMessageOrBuilder();
+      } else {
+        return state_ == null ?
+            org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : state_;
+      }
+    }
+    /**
+     * <pre>
+     * The leaf state's declaration as an element value; empty when the machine
+     * declares no element for it.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder> 
+        internalGetStateFieldBuilder() {
+      if (stateBuilder_ == null) {
+        stateBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder>(
+                getState(),
+                getParentForChildren(),
+                isClean());
+        state_ = null;
+      }
+      return stateBuilder_;
+    }
+
+    private java.lang.Object region_ = "";
+    /**
+     * <pre>
+     * The orthogonal region the leaf is declared in; empty outside one.
+     * </pre>
+     *
+     * <code>string region = 6 [json_name = "region"];</code>
+     * @return The region.
+     */
+    public java.lang.String getRegion() {
+      java.lang.Object ref = region_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        region_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The orthogonal region the leaf is declared in; empty outside one.
+     * </pre>
+     *
+     * <code>string region = 6 [json_name = "region"];</code>
+     * @return The bytes for region.
+     */
+    public com.google.protobuf.ByteString
+        getRegionBytes() {
+      java.lang.Object ref = region_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        region_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The orthogonal region the leaf is declared in; empty outside one.
+     * </pre>
+     *
+     * <code>string region = 6 [json_name = "region"];</code>
+     * @param value The region to set.
+     * @return This builder for chaining.
+     */
+    public Builder setRegion(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      region_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The orthogonal region the leaf is declared in; empty outside one.
+     * </pre>
+     *
+     * <code>string region = 6 [json_name = "region"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearRegion() {
+      region_ = getDefaultInstance().getRegion();
+      bitField0_ = (bitField0_ & ~0x00000020);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The orthogonal region the leaf is declared in; empty outside one.
+     * </pre>
+     *
+     * <code>string region = 6 [json_name = "region"];</code>
+     * @param value The bytes for region to set.
+     * @return This builder for chaining.
+     */
+    public Builder setRegionBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      region_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+
+    private com.google.protobuf.LazyStringArrayList enclosing_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    private void ensureEnclosingIsMutable() {
+      if (!enclosing_.isModifiable()) {
+        enclosing_ = new com.google.protobuf.LazyStringArrayList(enclosing_);
+      }
+      bitField0_ |= 0x00000040;
+    }
+    /**
+     * <pre>
+     * The composite states enclosing the leaf, outermost first; each is active.
+     * </pre>
+     *
+     * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+     * @return A list containing the enclosing.
+     */
+    public com.google.protobuf.ProtocolStringList
+        getEnclosingList() {
+      enclosing_.makeImmutable();
+      return enclosing_;
+    }
+    /**
+     * <pre>
+     * The composite states enclosing the leaf, outermost first; each is active.
+     * </pre>
+     *
+     * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+     * @return The count of enclosing.
+     */
+    public int getEnclosingCount() {
+      return enclosing_.size();
+    }
+    /**
+     * <pre>
+     * The composite states enclosing the leaf, outermost first; each is active.
+     * </pre>
+     *
+     * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+     * @param index The index of the element to return.
+     * @return The enclosing at the given index.
+     */
+    public java.lang.String getEnclosing(int index) {
+      return enclosing_.get(index);
+    }
+    /**
+     * <pre>
+     * The composite states enclosing the leaf, outermost first; each is active.
+     * </pre>
+     *
+     * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+     * @param index The index of the value to return.
+     * @return The bytes of the enclosing at the given index.
+     */
+    public com.google.protobuf.ByteString
+        getEnclosingBytes(int index) {
+      return enclosing_.getByteString(index);
+    }
+    /**
+     * <pre>
+     * The composite states enclosing the leaf, outermost first; each is active.
+     * </pre>
+     *
+     * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+     * @param index The index to set the value at.
+     * @param value The enclosing to set.
+     * @return This builder for chaining.
+     */
+    public Builder setEnclosing(
+        int index, java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureEnclosingIsMutable();
+      enclosing_.set(index, value);
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The composite states enclosing the leaf, outermost first; each is active.
+     * </pre>
+     *
+     * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+     * @param value The enclosing to add.
+     * @return This builder for chaining.
+     */
+    public Builder addEnclosing(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureEnclosingIsMutable();
+      enclosing_.add(value);
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The composite states enclosing the leaf, outermost first; each is active.
+     * </pre>
+     *
+     * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+     * @param values The enclosing to add.
+     * @return This builder for chaining.
+     */
+    public Builder addAllEnclosing(
+        java.lang.Iterable<java.lang.String> values) {
+      ensureEnclosingIsMutable();
+      com.google.protobuf.AbstractMessageLite.Builder.addAll(
+          values, enclosing_);
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The composite states enclosing the leaf, outermost first; each is active.
+     * </pre>
+     *
+     * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearEnclosing() {
+      enclosing_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+      bitField0_ = (bitField0_ & ~0x00000040);;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The composite states enclosing the leaf, outermost first; each is active.
+     * </pre>
+     *
+     * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+     * @param value The bytes of the enclosing to add.
+     * @return This builder for chaining.
+     */
+    public Builder addEnclosingBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      ensureEnclosingIsMutable();
+      enclosing_.add(value);
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.DocumentState)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.DocumentState)
+  private static final org.openmbee.opensysml.proto.DocumentState DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.DocumentState();
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentState getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<DocumentState>
+      PARSER = new com.google.protobuf.AbstractParser<DocumentState>() {
+    @java.lang.Override
+    public DocumentState parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<DocumentState> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<DocumentState> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentState getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentStateOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentStateOrBuilder.java
new file mode 100644
index 0000000000..41b88aa1f0
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentStateOrBuilder.java
@@ -0,0 +1,192 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface DocumentStateOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.DocumentState)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * The object in the state, with the path the session reaches it by.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+   * @return Whether the object field is set.
+   */
+  boolean hasObject();
+  /**
+   * <pre>
+   * The object in the state, with the path the session reaches it by.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+   * @return The object.
+   */
+  org.openmbee.opensysml.proto.DocumentObject getObject();
+  /**
+   * <pre>
+   * The object in the state, with the path the session reaches it by.
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 1 [json_name = "object"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentObjectOrBuilder getObjectOrBuilder();
+
+  /**
+   * <pre>
+   * The state machine, by the name the object exhibits it under (`lp`), else
+   * its declared name.
+   * </pre>
+   *
+   * <code>string machine = 2 [json_name = "machine"];</code>
+   * @return The machine.
+   */
+  java.lang.String getMachine();
+  /**
+   * <pre>
+   * The state machine, by the name the object exhibits it under (`lp`), else
+   * its declared name.
+   * </pre>
+   *
+   * <code>string machine = 2 [json_name = "machine"];</code>
+   * @return The bytes for machine.
+   */
+  com.google.protobuf.ByteString
+      getMachineBytes();
+
+  /**
+   * <pre>
+   * The leaf state's name.
+   * </pre>
+   *
+   * <code>string name = 3 [json_name = "name"];</code>
+   * @return The name.
+   */
+  java.lang.String getName();
+  /**
+   * <pre>
+   * The leaf state's name.
+   * </pre>
+   *
+   * <code>string name = 3 [json_name = "name"];</code>
+   * @return The bytes for name.
+   */
+  com.google.protobuf.ByteString
+      getNameBytes();
+
+  /**
+   * <pre>
+   * The leaf state's path in its machine, composite states first (`on.dim`).
+   * </pre>
+   *
+   * <code>string state_path = 4 [json_name = "statePath"];</code>
+   * @return The statePath.
+   */
+  java.lang.String getStatePath();
+  /**
+   * <pre>
+   * The leaf state's path in its machine, composite states first (`on.dim`).
+   * </pre>
+   *
+   * <code>string state_path = 4 [json_name = "statePath"];</code>
+   * @return The bytes for statePath.
+   */
+  com.google.protobuf.ByteString
+      getStatePathBytes();
+
+  /**
+   * <pre>
+   * The leaf state's declaration as an element value; empty when the machine
+   * declares no element for it.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+   * @return Whether the state field is set.
+   */
+  boolean hasState();
+  /**
+   * <pre>
+   * The leaf state's declaration as an element value; empty when the machine
+   * declares no element for it.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+   * @return The state.
+   */
+  org.openmbee.opensysml.proto.DocumentValue getState();
+  /**
+   * <pre>
+   * The leaf state's declaration as an element value; empty when the machine
+   * declares no element for it.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue state = 5 [json_name = "state"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentValueOrBuilder getStateOrBuilder();
+
+  /**
+   * <pre>
+   * The orthogonal region the leaf is declared in; empty outside one.
+   * </pre>
+   *
+   * <code>string region = 6 [json_name = "region"];</code>
+   * @return The region.
+   */
+  java.lang.String getRegion();
+  /**
+   * <pre>
+   * The orthogonal region the leaf is declared in; empty outside one.
+   * </pre>
+   *
+   * <code>string region = 6 [json_name = "region"];</code>
+   * @return The bytes for region.
+   */
+  com.google.protobuf.ByteString
+      getRegionBytes();
+
+  /**
+   * <pre>
+   * The composite states enclosing the leaf, outermost first; each is active.
+   * </pre>
+   *
+   * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+   * @return A list containing the enclosing.
+   */
+  java.util.List<java.lang.String>
+      getEnclosingList();
+  /**
+   * <pre>
+   * The composite states enclosing the leaf, outermost first; each is active.
+   * </pre>
+   *
+   * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+   * @return The count of enclosing.
+   */
+  int getEnclosingCount();
+  /**
+   * <pre>
+   * The composite states enclosing the leaf, outermost first; each is active.
+   * </pre>
+   *
+   * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+   * @param index The index of the element to return.
+   * @return The enclosing at the given index.
+   */
+  java.lang.String getEnclosing(int index);
+  /**
+   * <pre>
+   * The composite states enclosing the leaf, outermost first; each is active.
+   * </pre>
+   *
+   * <code>repeated string enclosing = 7 [json_name = "enclosing"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the enclosing at the given index.
+   */
+  com.google.protobuf.ByteString
+      getEnclosingBytes(int index);
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValue.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValue.java
new file mode 100644
index 0000000000..83ad089ecc
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValue.java
@@ -0,0 +1,2650 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * DocumentValue is one typed document-query value. A request binds a model
+ * element by qualified name in element_id, or an object the service holds in
+ * object; a response also says what the element is in element_type. `infinity`
+ * denotes an unbounded multiplicity and is only ever answered, never bound.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.DocumentValue}
+ */
+@com.google.protobuf.Generated
+public final class DocumentValue extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.DocumentValue)
+    DocumentValueOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "DocumentValue");
+  }
+  // Use DocumentValue.newBuilder() to construct.
+  private DocumentValue(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private DocumentValue() {
+    elementType_ = "";
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentValue_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentValue_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.DocumentValue.class, org.openmbee.opensysml.proto.DocumentValue.Builder.class);
+  }
+
+  private int kindCase_ = 0;
+  @SuppressWarnings("serial")
+  private java.lang.Object kind_;
+  public enum KindCase
+      implements com.google.protobuf.Internal.EnumLite,
+          com.google.protobuf.AbstractMessage.InternalOneOfEnum {
+    ELEMENT_ID(1),
+    STRING_VALUE(2),
+    INT_VALUE(3),
+    REAL_VALUE(4),
+    BOOL_VALUE(5),
+    INFINITY(6),
+    QUANTITY(8),
+    VERDICT(9),
+    OBJECT(10),
+    STATE(11),
+    EVENT(12),
+    KIND_NOT_SET(0);
+    private final int value;
+    private KindCase(int value) {
+      this.value = value;
+    }
+    /**
+     * @param value The number of the enum to look for.
+     * @return The enum associated with the given number.
+     * @deprecated Use {@link #forNumber(int)} instead.
+     */
+    @java.lang.Deprecated
+    public static KindCase valueOf(int value) {
+      return forNumber(value);
+    }
+
+    public static KindCase forNumber(int value) {
+      switch (value) {
+        case 1: return ELEMENT_ID;
+        case 2: return STRING_VALUE;
+        case 3: return INT_VALUE;
+        case 4: return REAL_VALUE;
+        case 5: return BOOL_VALUE;
+        case 6: return INFINITY;
+        case 8: return QUANTITY;
+        case 9: return VERDICT;
+        case 10: return OBJECT;
+        case 11: return STATE;
+        case 12: return EVENT;
+        case 0: return KIND_NOT_SET;
+        default: return null;
+      }
+    }
+    public int getNumber() {
+      return this.value;
+    }
+  };
+
+  public KindCase
+  getKindCase() {
+    return KindCase.forNumber(
+        kindCase_);
+  }
+
+  public static final int ELEMENT_ID_FIELD_NUMBER = 1;
+  /**
+   * <pre>
+   * qualified name of a model element
+   * </pre>
+   *
+   * <code>string element_id = 1 [json_name = "elementId"];</code>
+   * @return Whether the elementId field is set.
+   */
+  public boolean hasElementId() {
+    return kindCase_ == 1;
+  }
+  /**
+   * <pre>
+   * qualified name of a model element
+   * </pre>
+   *
+   * <code>string element_id = 1 [json_name = "elementId"];</code>
+   * @return The elementId.
+   */
+  public java.lang.String getElementId() {
+    java.lang.Object ref = "";
+    if (kindCase_ == 1) {
+      ref = kind_;
+    }
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      if (kindCase_ == 1) {
+        kind_ = s;
+      }
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * qualified name of a model element
+   * </pre>
+   *
+   * <code>string element_id = 1 [json_name = "elementId"];</code>
+   * @return The bytes for elementId.
+   */
+  public com.google.protobuf.ByteString
+      getElementIdBytes() {
+    java.lang.Object ref = "";
+    if (kindCase_ == 1) {
+      ref = kind_;
+    }
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      if (kindCase_ == 1) {
+        kind_ = b;
+      }
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int STRING_VALUE_FIELD_NUMBER = 2;
+  /**
+   * <code>string string_value = 2 [json_name = "stringValue"];</code>
+   * @return Whether the stringValue field is set.
+   */
+  public boolean hasStringValue() {
+    return kindCase_ == 2;
+  }
+  /**
+   * <code>string string_value = 2 [json_name = "stringValue"];</code>
+   * @return The stringValue.
+   */
+  public java.lang.String getStringValue() {
+    java.lang.Object ref = "";
+    if (kindCase_ == 2) {
+      ref = kind_;
+    }
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      if (kindCase_ == 2) {
+        kind_ = s;
+      }
+      return s;
+    }
+  }
+  /**
+   * <code>string string_value = 2 [json_name = "stringValue"];</code>
+   * @return The bytes for stringValue.
+   */
+  public com.google.protobuf.ByteString
+      getStringValueBytes() {
+    java.lang.Object ref = "";
+    if (kindCase_ == 2) {
+      ref = kind_;
+    }
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      if (kindCase_ == 2) {
+        kind_ = b;
+      }
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int INT_VALUE_FIELD_NUMBER = 3;
+  /**
+   * <code>int64 int_value = 3 [json_name = "intValue"];</code>
+   * @return Whether the intValue field is set.
+   */
+  @java.lang.Override
+  public boolean hasIntValue() {
+    return kindCase_ == 3;
+  }
+  /**
+   * <code>int64 int_value = 3 [json_name = "intValue"];</code>
+   * @return The intValue.
+   */
+  @java.lang.Override
+  public long getIntValue() {
+    if (kindCase_ == 3) {
+      return (java.lang.Long) kind_;
+    }
+    return 0L;
+  }
+
+  public static final int REAL_VALUE_FIELD_NUMBER = 4;
+  /**
+   * <code>double real_value = 4 [json_name = "realValue"];</code>
+   * @return Whether the realValue field is set.
+   */
+  @java.lang.Override
+  public boolean hasRealValue() {
+    return kindCase_ == 4;
+  }
+  /**
+   * <code>double real_value = 4 [json_name = "realValue"];</code>
+   * @return The realValue.
+   */
+  @java.lang.Override
+  public double getRealValue() {
+    if (kindCase_ == 4) {
+      return (java.lang.Double) kind_;
+    }
+    return 0D;
+  }
+
+  public static final int BOOL_VALUE_FIELD_NUMBER = 5;
+  /**
+   * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
+   * @return Whether the boolValue field is set.
+   */
+  @java.lang.Override
+  public boolean hasBoolValue() {
+    return kindCase_ == 5;
+  }
+  /**
+   * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
+   * @return The boolValue.
+   */
+  @java.lang.Override
+  public boolean getBoolValue() {
+    if (kindCase_ == 5) {
+      return (java.lang.Boolean) kind_;
+    }
+    return false;
+  }
+
+  public static final int INFINITY_FIELD_NUMBER = 6;
+  /**
+   * <code>bool infinity = 6 [json_name = "infinity"];</code>
+   * @return Whether the infinity field is set.
+   */
+  @java.lang.Override
+  public boolean hasInfinity() {
+    return kindCase_ == 6;
+  }
+  /**
+   * <code>bool infinity = 6 [json_name = "infinity"];</code>
+   * @return The infinity.
+   */
+  @java.lang.Override
+  public boolean getInfinity() {
+    if (kindCase_ == 6) {
+      return (java.lang.Boolean) kind_;
+    }
+    return false;
+  }
+
+  public static final int QUANTITY_FIELD_NUMBER = 8;
+  /**
+   * <pre>
+   * magnitude in a unit, `2290000 [kg]`
+   * </pre>
+   *
+   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+   * @return Whether the quantity field is set.
+   */
+  @java.lang.Override
+  public boolean hasQuantity() {
+    return kindCase_ == 8;
+  }
+  /**
+   * <pre>
+   * magnitude in a unit, `2290000 [kg]`
+   * </pre>
+   *
+   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+   * @return The quantity.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Quantity getQuantity() {
+    if (kindCase_ == 8) {
+       return (org.openmbee.opensysml.proto.Quantity) kind_;
+    }
+    return org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
+  }
+  /**
+   * <pre>
+   * magnitude in a unit, `2290000 [kg]`
+   * </pre>
+   *
+   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.QuantityOrBuilder getQuantityOrBuilder() {
+    if (kindCase_ == 8) {
+       return (org.openmbee.opensysml.proto.Quantity) kind_;
+    }
+    return org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
+  }
+
+  public static final int VERDICT_FIELD_NUMBER = 9;
+  /**
+   * <pre>
+   * a row Verdicts answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+   * @return Whether the verdict field is set.
+   */
+  @java.lang.Override
+  public boolean hasVerdict() {
+    return kindCase_ == 9;
+  }
+  /**
+   * <pre>
+   * a row Verdicts answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+   * @return The verdict.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentVerdict getVerdict() {
+    if (kindCase_ == 9) {
+       return (org.openmbee.opensysml.proto.DocumentVerdict) kind_;
+    }
+    return org.openmbee.opensysml.proto.DocumentVerdict.getDefaultInstance();
+  }
+  /**
+   * <pre>
+   * a row Verdicts answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentVerdictOrBuilder getVerdictOrBuilder() {
+    if (kindCase_ == 9) {
+       return (org.openmbee.opensysml.proto.DocumentVerdict) kind_;
+    }
+    return org.openmbee.opensysml.proto.DocumentVerdict.getDefaultInstance();
+  }
+
+  public static final int OBJECT_FIELD_NUMBER = 10;
+  /**
+   * <pre>
+   * an object Instantiate created; bound and answered
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+   * @return Whether the object field is set.
+   */
+  @java.lang.Override
+  public boolean hasObject() {
+    return kindCase_ == 10;
+  }
+  /**
+   * <pre>
+   * an object Instantiate created; bound and answered
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+   * @return The object.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentObject getObject() {
+    if (kindCase_ == 10) {
+       return (org.openmbee.opensysml.proto.DocumentObject) kind_;
+    }
+    return org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance();
+  }
+  /**
+   * <pre>
+   * an object Instantiate created; bound and answered
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentObjectOrBuilder getObjectOrBuilder() {
+    if (kindCase_ == 10) {
+       return (org.openmbee.opensysml.proto.DocumentObject) kind_;
+    }
+    return org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance();
+  }
+
+  public static final int STATE_FIELD_NUMBER = 11;
+  /**
+   * <pre>
+   * a row States answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+   * @return Whether the state field is set.
+   */
+  @java.lang.Override
+  public boolean hasState() {
+    return kindCase_ == 11;
+  }
+  /**
+   * <pre>
+   * a row States answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+   * @return The state.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentState getState() {
+    if (kindCase_ == 11) {
+       return (org.openmbee.opensysml.proto.DocumentState) kind_;
+    }
+    return org.openmbee.opensysml.proto.DocumentState.getDefaultInstance();
+  }
+  /**
+   * <pre>
+   * a row States answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentStateOrBuilder getStateOrBuilder() {
+    if (kindCase_ == 11) {
+       return (org.openmbee.opensysml.proto.DocumentState) kind_;
+    }
+    return org.openmbee.opensysml.proto.DocumentState.getDefaultInstance();
+  }
+
+  public static final int EVENT_FIELD_NUMBER = 12;
+  /**
+   * <pre>
+   * a row Events answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+   * @return Whether the event field is set.
+   */
+  @java.lang.Override
+  public boolean hasEvent() {
+    return kindCase_ == 12;
+  }
+  /**
+   * <pre>
+   * a row Events answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+   * @return The event.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentEvent getEvent() {
+    if (kindCase_ == 12) {
+       return (org.openmbee.opensysml.proto.DocumentEvent) kind_;
+    }
+    return org.openmbee.opensysml.proto.DocumentEvent.getDefaultInstance();
+  }
+  /**
+   * <pre>
+   * a row Events answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentEventOrBuilder getEventOrBuilder() {
+    if (kindCase_ == 12) {
+       return (org.openmbee.opensysml.proto.DocumentEvent) kind_;
+    }
+    return org.openmbee.opensysml.proto.DocumentEvent.getDefaultInstance();
+  }
+
+  public static final int ELEMENT_TYPE_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object elementType_ = "";
+  /**
+   * <pre>
+   * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
+   * </pre>
+   *
+   * <code>string element_type = 7 [json_name = "elementType"];</code>
+   * @return The elementType.
+   */
+  @java.lang.Override
+  public java.lang.String getElementType() {
+    java.lang.Object ref = elementType_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      elementType_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
+   * </pre>
+   *
+   * <code>string element_type = 7 [json_name = "elementType"];</code>
+   * @return The bytes for elementType.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getElementTypeBytes() {
+    java.lang.Object ref = elementType_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      elementType_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (kindCase_ == 1) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 1, kind_);
+    }
+    if (kindCase_ == 2) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, kind_);
+    }
+    if (kindCase_ == 3) {
+      output.writeInt64(
+          3, (long)((java.lang.Long) kind_));
+    }
+    if (kindCase_ == 4) {
+      output.writeDouble(
+          4, (double)((java.lang.Double) kind_));
+    }
+    if (kindCase_ == 5) {
+      output.writeBool(
+          5, (boolean)((java.lang.Boolean) kind_));
+    }
+    if (kindCase_ == 6) {
+      output.writeBool(
+          6, (boolean)((java.lang.Boolean) kind_));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(elementType_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 7, elementType_);
+    }
+    if (kindCase_ == 8) {
+      output.writeMessage(8, (org.openmbee.opensysml.proto.Quantity) kind_);
+    }
+    if (kindCase_ == 9) {
+      output.writeMessage(9, (org.openmbee.opensysml.proto.DocumentVerdict) kind_);
+    }
+    if (kindCase_ == 10) {
+      output.writeMessage(10, (org.openmbee.opensysml.proto.DocumentObject) kind_);
+    }
+    if (kindCase_ == 11) {
+      output.writeMessage(11, (org.openmbee.opensysml.proto.DocumentState) kind_);
+    }
+    if (kindCase_ == 12) {
+      output.writeMessage(12, (org.openmbee.opensysml.proto.DocumentEvent) kind_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (kindCase_ == 1) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, kind_);
+    }
+    if (kindCase_ == 2) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, kind_);
+    }
+    if (kindCase_ == 3) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeInt64Size(
+            3, (long)((java.lang.Long) kind_));
+    }
+    if (kindCase_ == 4) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeDoubleSize(
+            4, (double)((java.lang.Double) kind_));
+    }
+    if (kindCase_ == 5) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeBoolSize(
+            5, (boolean)((java.lang.Boolean) kind_));
+    }
+    if (kindCase_ == 6) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeBoolSize(
+            6, (boolean)((java.lang.Boolean) kind_));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(elementType_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(7, elementType_);
+    }
+    if (kindCase_ == 8) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(8, (org.openmbee.opensysml.proto.Quantity) kind_);
+    }
+    if (kindCase_ == 9) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(9, (org.openmbee.opensysml.proto.DocumentVerdict) kind_);
+    }
+    if (kindCase_ == 10) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(10, (org.openmbee.opensysml.proto.DocumentObject) kind_);
+    }
+    if (kindCase_ == 11) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(11, (org.openmbee.opensysml.proto.DocumentState) kind_);
+    }
+    if (kindCase_ == 12) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(12, (org.openmbee.opensysml.proto.DocumentEvent) kind_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.DocumentValue)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.DocumentValue other = (org.openmbee.opensysml.proto.DocumentValue) obj;
+
+    if (!getElementType()
+        .equals(other.getElementType())) return false;
+    if (!getKindCase().equals(other.getKindCase())) return false;
+    switch (kindCase_) {
+      case 1:
+        if (!getElementId()
+            .equals(other.getElementId())) return false;
+        break;
+      case 2:
+        if (!getStringValue()
+            .equals(other.getStringValue())) return false;
+        break;
+      case 3:
+        if (getIntValue()
+            != other.getIntValue()) return false;
+        break;
+      case 4:
+        if (java.lang.Double.doubleToLongBits(getRealValue())
+            != java.lang.Double.doubleToLongBits(
+                other.getRealValue())) return false;
+        break;
+      case 5:
+        if (getBoolValue()
+            != other.getBoolValue()) return false;
+        break;
+      case 6:
+        if (getInfinity()
+            != other.getInfinity()) return false;
+        break;
+      case 8:
+        if (!getQuantity()
+            .equals(other.getQuantity())) return false;
+        break;
+      case 9:
+        if (!getVerdict()
+            .equals(other.getVerdict())) return false;
+        break;
+      case 10:
+        if (!getObject()
+            .equals(other.getObject())) return false;
+        break;
+      case 11:
+        if (!getState()
+            .equals(other.getState())) return false;
+        break;
+      case 12:
+        if (!getEvent()
+            .equals(other.getEvent())) return false;
+        break;
+      case 0:
+      default:
+    }
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + ELEMENT_TYPE_FIELD_NUMBER;
+    hash = (53 * hash) + getElementType().hashCode();
+    switch (kindCase_) {
+      case 1:
+        hash = (37 * hash) + ELEMENT_ID_FIELD_NUMBER;
+        hash = (53 * hash) + getElementId().hashCode();
+        break;
+      case 2:
+        hash = (37 * hash) + STRING_VALUE_FIELD_NUMBER;
+        hash = (53 * hash) + getStringValue().hashCode();
+        break;
+      case 3:
+        hash = (37 * hash) + INT_VALUE_FIELD_NUMBER;
+        hash = (53 * hash) + com.google.protobuf.Internal.hashLong(
+            getIntValue());
+        break;
+      case 4:
+        hash = (37 * hash) + REAL_VALUE_FIELD_NUMBER;
+        hash = (53 * hash) + com.google.protobuf.Internal.hashLong(
+            java.lang.Double.doubleToLongBits(getRealValue()));
+        break;
+      case 5:
+        hash = (37 * hash) + BOOL_VALUE_FIELD_NUMBER;
+        hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean(
+            getBoolValue());
+        break;
+      case 6:
+        hash = (37 * hash) + INFINITY_FIELD_NUMBER;
+        hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean(
+            getInfinity());
+        break;
+      case 8:
+        hash = (37 * hash) + QUANTITY_FIELD_NUMBER;
+        hash = (53 * hash) + getQuantity().hashCode();
+        break;
+      case 9:
+        hash = (37 * hash) + VERDICT_FIELD_NUMBER;
+        hash = (53 * hash) + getVerdict().hashCode();
+        break;
+      case 10:
+        hash = (37 * hash) + OBJECT_FIELD_NUMBER;
+        hash = (53 * hash) + getObject().hashCode();
+        break;
+      case 11:
+        hash = (37 * hash) + STATE_FIELD_NUMBER;
+        hash = (53 * hash) + getState().hashCode();
+        break;
+      case 12:
+        hash = (37 * hash) + EVENT_FIELD_NUMBER;
+        hash = (53 * hash) + getEvent().hashCode();
+        break;
+      case 0:
+      default:
+    }
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentValue parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentValue parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.DocumentValue prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * DocumentValue is one typed document-query value. A request binds a model
+   * element by qualified name in element_id, or an object the service holds in
+   * object; a response also says what the element is in element_type. `infinity`
+   * denotes an unbounded multiplicity and is only ever answered, never bound.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.DocumentValue}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.DocumentValue)
+      org.openmbee.opensysml.proto.DocumentValueOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentValue_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentValue_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.DocumentValue.class, org.openmbee.opensysml.proto.DocumentValue.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      if (quantityBuilder_ != null) {
+        quantityBuilder_.clear();
+      }
+      if (verdictBuilder_ != null) {
+        verdictBuilder_.clear();
+      }
+      if (objectBuilder_ != null) {
+        objectBuilder_.clear();
+      }
+      if (stateBuilder_ != null) {
+        stateBuilder_.clear();
+      }
+      if (eventBuilder_ != null) {
+        eventBuilder_.clear();
+      }
+      elementType_ = "";
+      kindCase_ = 0;
+      kind_ = null;
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentValue_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentValue getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentValue build() {
+      org.openmbee.opensysml.proto.DocumentValue result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentValue buildPartial() {
+      org.openmbee.opensysml.proto.DocumentValue result = new org.openmbee.opensysml.proto.DocumentValue(this);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      buildPartialOneofs(result);
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.DocumentValue result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000800) != 0)) {
+        result.elementType_ = elementType_;
+      }
+    }
+
+    private void buildPartialOneofs(org.openmbee.opensysml.proto.DocumentValue result) {
+      result.kindCase_ = kindCase_;
+      result.kind_ = this.kind_;
+      if (kindCase_ == 8 &&
+          quantityBuilder_ != null) {
+        result.kind_ = quantityBuilder_.build();
+      }
+      if (kindCase_ == 9 &&
+          verdictBuilder_ != null) {
+        result.kind_ = verdictBuilder_.build();
+      }
+      if (kindCase_ == 10 &&
+          objectBuilder_ != null) {
+        result.kind_ = objectBuilder_.build();
+      }
+      if (kindCase_ == 11 &&
+          stateBuilder_ != null) {
+        result.kind_ = stateBuilder_.build();
+      }
+      if (kindCase_ == 12 &&
+          eventBuilder_ != null) {
+        result.kind_ = eventBuilder_.build();
+      }
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.DocumentValue) {
+        return mergeFrom((org.openmbee.opensysml.proto.DocumentValue)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.DocumentValue other) {
+      if (other == org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance()) return this;
+      if (!other.getElementType().isEmpty()) {
+        elementType_ = other.elementType_;
+        bitField0_ |= 0x00000800;
+        onChanged();
+      }
+      switch (other.getKindCase()) {
+        case ELEMENT_ID: {
+          kindCase_ = 1;
+          kind_ = other.kind_;
+          onChanged();
+          break;
+        }
+        case STRING_VALUE: {
+          kindCase_ = 2;
+          kind_ = other.kind_;
+          onChanged();
+          break;
+        }
+        case INT_VALUE: {
+          setIntValue(other.getIntValue());
+          break;
+        }
+        case REAL_VALUE: {
+          setRealValue(other.getRealValue());
+          break;
+        }
+        case BOOL_VALUE: {
+          setBoolValue(other.getBoolValue());
+          break;
+        }
+        case INFINITY: {
+          setInfinity(other.getInfinity());
+          break;
+        }
+        case QUANTITY: {
+          mergeQuantity(other.getQuantity());
+          break;
+        }
+        case VERDICT: {
+          mergeVerdict(other.getVerdict());
+          break;
+        }
+        case OBJECT: {
+          mergeObject(other.getObject());
+          break;
+        }
+        case STATE: {
+          mergeState(other.getState());
+          break;
+        }
+        case EVENT: {
+          mergeEvent(other.getEvent());
+          break;
+        }
+        case KIND_NOT_SET: {
+          break;
+        }
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              java.lang.String s = input.readStringRequireUtf8();
+              kindCase_ = 1;
+              kind_ = s;
+              break;
+            } // case 10
+            case 18: {
+              java.lang.String s = input.readStringRequireUtf8();
+              kindCase_ = 2;
+              kind_ = s;
+              break;
+            } // case 18
+            case 24: {
+              kind_ = input.readInt64();
+              kindCase_ = 3;
+              break;
+            } // case 24
+            case 33: {
+              kind_ = input.readDouble();
+              kindCase_ = 4;
+              break;
+            } // case 33
+            case 40: {
+              kind_ = input.readBool();
+              kindCase_ = 5;
+              break;
+            } // case 40
+            case 48: {
+              kind_ = input.readBool();
+              kindCase_ = 6;
+              break;
+            } // case 48
+            case 58: {
+              elementType_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000800;
+              break;
+            } // case 58
+            case 66: {
+              input.readMessage(
+                  internalGetQuantityFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              kindCase_ = 8;
+              break;
+            } // case 66
+            case 74: {
+              input.readMessage(
+                  internalGetVerdictFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              kindCase_ = 9;
+              break;
+            } // case 74
+            case 82: {
+              input.readMessage(
+                  internalGetObjectFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              kindCase_ = 10;
+              break;
+            } // case 82
+            case 90: {
+              input.readMessage(
+                  internalGetStateFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              kindCase_ = 11;
+              break;
+            } // case 90
+            case 98: {
+              input.readMessage(
+                  internalGetEventFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              kindCase_ = 12;
+              break;
+            } // case 98
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int kindCase_ = 0;
+    private java.lang.Object kind_;
+    public KindCase
+        getKindCase() {
+      return KindCase.forNumber(
+          kindCase_);
+    }
+
+    public Builder clearKind() {
+      kindCase_ = 0;
+      kind_ = null;
+      onChanged();
+      return this;
+    }
+
+    private int bitField0_;
+
+    /**
+     * <pre>
+     * qualified name of a model element
+     * </pre>
+     *
+     * <code>string element_id = 1 [json_name = "elementId"];</code>
+     * @return Whether the elementId field is set.
+     */
+    @java.lang.Override
+    public boolean hasElementId() {
+      return kindCase_ == 1;
+    }
+    /**
+     * <pre>
+     * qualified name of a model element
+     * </pre>
+     *
+     * <code>string element_id = 1 [json_name = "elementId"];</code>
+     * @return The elementId.
+     */
+    @java.lang.Override
+    public java.lang.String getElementId() {
+      java.lang.Object ref = "";
+      if (kindCase_ == 1) {
+        ref = kind_;
+      }
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        if (kindCase_ == 1) {
+          kind_ = s;
+        }
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * qualified name of a model element
+     * </pre>
+     *
+     * <code>string element_id = 1 [json_name = "elementId"];</code>
+     * @return The bytes for elementId.
+     */
+    @java.lang.Override
+    public com.google.protobuf.ByteString
+        getElementIdBytes() {
+      java.lang.Object ref = "";
+      if (kindCase_ == 1) {
+        ref = kind_;
+      }
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        if (kindCase_ == 1) {
+          kind_ = b;
+        }
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * qualified name of a model element
+     * </pre>
+     *
+     * <code>string element_id = 1 [json_name = "elementId"];</code>
+     * @param value The elementId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setElementId(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      kindCase_ = 1;
+      kind_ = value;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * qualified name of a model element
+     * </pre>
+     *
+     * <code>string element_id = 1 [json_name = "elementId"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearElementId() {
+      if (kindCase_ == 1) {
+        kindCase_ = 0;
+        kind_ = null;
+        onChanged();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * qualified name of a model element
+     * </pre>
+     *
+     * <code>string element_id = 1 [json_name = "elementId"];</code>
+     * @param value The bytes for elementId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setElementIdBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      kindCase_ = 1;
+      kind_ = value;
+      onChanged();
+      return this;
+    }
+
+    /**
+     * <code>string string_value = 2 [json_name = "stringValue"];</code>
+     * @return Whether the stringValue field is set.
+     */
+    @java.lang.Override
+    public boolean hasStringValue() {
+      return kindCase_ == 2;
+    }
+    /**
+     * <code>string string_value = 2 [json_name = "stringValue"];</code>
+     * @return The stringValue.
+     */
+    @java.lang.Override
+    public java.lang.String getStringValue() {
+      java.lang.Object ref = "";
+      if (kindCase_ == 2) {
+        ref = kind_;
+      }
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        if (kindCase_ == 2) {
+          kind_ = s;
+        }
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <code>string string_value = 2 [json_name = "stringValue"];</code>
+     * @return The bytes for stringValue.
+     */
+    @java.lang.Override
+    public com.google.protobuf.ByteString
+        getStringValueBytes() {
+      java.lang.Object ref = "";
+      if (kindCase_ == 2) {
+        ref = kind_;
+      }
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        if (kindCase_ == 2) {
+          kind_ = b;
+        }
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <code>string string_value = 2 [json_name = "stringValue"];</code>
+     * @param value The stringValue to set.
+     * @return This builder for chaining.
+     */
+    public Builder setStringValue(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      kindCase_ = 2;
+      kind_ = value;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string string_value = 2 [json_name = "stringValue"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearStringValue() {
+      if (kindCase_ == 2) {
+        kindCase_ = 0;
+        kind_ = null;
+        onChanged();
+      }
+      return this;
+    }
+    /**
+     * <code>string string_value = 2 [json_name = "stringValue"];</code>
+     * @param value The bytes for stringValue to set.
+     * @return This builder for chaining.
+     */
+    public Builder setStringValueBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      kindCase_ = 2;
+      kind_ = value;
+      onChanged();
+      return this;
+    }
+
+    /**
+     * <code>int64 int_value = 3 [json_name = "intValue"];</code>
+     * @return Whether the intValue field is set.
+     */
+    public boolean hasIntValue() {
+      return kindCase_ == 3;
+    }
+    /**
+     * <code>int64 int_value = 3 [json_name = "intValue"];</code>
+     * @return The intValue.
+     */
+    public long getIntValue() {
+      if (kindCase_ == 3) {
+        return (java.lang.Long) kind_;
+      }
+      return 0L;
+    }
+    /**
+     * <code>int64 int_value = 3 [json_name = "intValue"];</code>
+     * @param value The intValue to set.
+     * @return This builder for chaining.
+     */
+    public Builder setIntValue(long value) {
+
+      kindCase_ = 3;
+      kind_ = value;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>int64 int_value = 3 [json_name = "intValue"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearIntValue() {
+      if (kindCase_ == 3) {
+        kindCase_ = 0;
+        kind_ = null;
+        onChanged();
+      }
+      return this;
+    }
+
+    /**
+     * <code>double real_value = 4 [json_name = "realValue"];</code>
+     * @return Whether the realValue field is set.
+     */
+    public boolean hasRealValue() {
+      return kindCase_ == 4;
+    }
+    /**
+     * <code>double real_value = 4 [json_name = "realValue"];</code>
+     * @return The realValue.
+     */
+    public double getRealValue() {
+      if (kindCase_ == 4) {
+        return (java.lang.Double) kind_;
+      }
+      return 0D;
+    }
+    /**
+     * <code>double real_value = 4 [json_name = "realValue"];</code>
+     * @param value The realValue to set.
+     * @return This builder for chaining.
+     */
+    public Builder setRealValue(double value) {
+
+      kindCase_ = 4;
+      kind_ = value;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>double real_value = 4 [json_name = "realValue"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearRealValue() {
+      if (kindCase_ == 4) {
+        kindCase_ = 0;
+        kind_ = null;
+        onChanged();
+      }
+      return this;
+    }
+
+    /**
+     * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
+     * @return Whether the boolValue field is set.
+     */
+    public boolean hasBoolValue() {
+      return kindCase_ == 5;
+    }
+    /**
+     * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
+     * @return The boolValue.
+     */
+    public boolean getBoolValue() {
+      if (kindCase_ == 5) {
+        return (java.lang.Boolean) kind_;
+      }
+      return false;
+    }
+    /**
+     * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
+     * @param value The boolValue to set.
+     * @return This builder for chaining.
+     */
+    public Builder setBoolValue(boolean value) {
+
+      kindCase_ = 5;
+      kind_ = value;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearBoolValue() {
+      if (kindCase_ == 5) {
+        kindCase_ = 0;
+        kind_ = null;
+        onChanged();
+      }
+      return this;
+    }
+
+    /**
+     * <code>bool infinity = 6 [json_name = "infinity"];</code>
+     * @return Whether the infinity field is set.
+     */
+    public boolean hasInfinity() {
+      return kindCase_ == 6;
+    }
+    /**
+     * <code>bool infinity = 6 [json_name = "infinity"];</code>
+     * @return The infinity.
+     */
+    public boolean getInfinity() {
+      if (kindCase_ == 6) {
+        return (java.lang.Boolean) kind_;
+      }
+      return false;
+    }
+    /**
+     * <code>bool infinity = 6 [json_name = "infinity"];</code>
+     * @param value The infinity to set.
+     * @return This builder for chaining.
+     */
+    public Builder setInfinity(boolean value) {
+
+      kindCase_ = 6;
+      kind_ = value;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>bool infinity = 6 [json_name = "infinity"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearInfinity() {
+      if (kindCase_ == 6) {
+        kindCase_ = 0;
+        kind_ = null;
+        onChanged();
+      }
+      return this;
+    }
+
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.Quantity, org.openmbee.opensysml.proto.Quantity.Builder, org.openmbee.opensysml.proto.QuantityOrBuilder> quantityBuilder_;
+    /**
+     * <pre>
+     * magnitude in a unit, `2290000 [kg]`
+     * </pre>
+     *
+     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+     * @return Whether the quantity field is set.
+     */
+    @java.lang.Override
+    public boolean hasQuantity() {
+      return kindCase_ == 8;
+    }
+    /**
+     * <pre>
+     * magnitude in a unit, `2290000 [kg]`
+     * </pre>
+     *
+     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+     * @return The quantity.
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Quantity getQuantity() {
+      if (quantityBuilder_ == null) {
+        if (kindCase_ == 8) {
+          return (org.openmbee.opensysml.proto.Quantity) kind_;
+        }
+        return org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
+      } else {
+        if (kindCase_ == 8) {
+          return quantityBuilder_.getMessage();
+        }
+        return org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
+      }
+    }
+    /**
+     * <pre>
+     * magnitude in a unit, `2290000 [kg]`
+     * </pre>
+     *
+     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+     */
+    public Builder setQuantity(org.openmbee.opensysml.proto.Quantity value) {
+      if (quantityBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        kind_ = value;
+        onChanged();
+      } else {
+        quantityBuilder_.setMessage(value);
+      }
+      kindCase_ = 8;
+      return this;
+    }
+    /**
+     * <pre>
+     * magnitude in a unit, `2290000 [kg]`
+     * </pre>
+     *
+     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+     */
+    public Builder setQuantity(
+        org.openmbee.opensysml.proto.Quantity.Builder builderForValue) {
+      if (quantityBuilder_ == null) {
+        kind_ = builderForValue.build();
+        onChanged();
+      } else {
+        quantityBuilder_.setMessage(builderForValue.build());
+      }
+      kindCase_ = 8;
+      return this;
+    }
+    /**
+     * <pre>
+     * magnitude in a unit, `2290000 [kg]`
+     * </pre>
+     *
+     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+     */
+    public Builder mergeQuantity(org.openmbee.opensysml.proto.Quantity value) {
+      if (quantityBuilder_ == null) {
+        if (kindCase_ == 8 &&
+            kind_ != org.openmbee.opensysml.proto.Quantity.getDefaultInstance()) {
+          kind_ = org.openmbee.opensysml.proto.Quantity.newBuilder((org.openmbee.opensysml.proto.Quantity) kind_)
+              .mergeFrom(value).buildPartial();
+        } else {
+          kind_ = value;
+        }
+        onChanged();
+      } else {
+        if (kindCase_ == 8) {
+          quantityBuilder_.mergeFrom(value);
+        } else {
+          quantityBuilder_.setMessage(value);
+        }
+      }
+      kindCase_ = 8;
+      return this;
+    }
+    /**
+     * <pre>
+     * magnitude in a unit, `2290000 [kg]`
+     * </pre>
+     *
+     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+     */
+    public Builder clearQuantity() {
+      if (quantityBuilder_ == null) {
+        if (kindCase_ == 8) {
+          kindCase_ = 0;
+          kind_ = null;
+          onChanged();
+        }
+      } else {
+        if (kindCase_ == 8) {
+          kindCase_ = 0;
+          kind_ = null;
+        }
+        quantityBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * magnitude in a unit, `2290000 [kg]`
+     * </pre>
+     *
+     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+     */
+    public org.openmbee.opensysml.proto.Quantity.Builder getQuantityBuilder() {
+      return internalGetQuantityFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * magnitude in a unit, `2290000 [kg]`
+     * </pre>
+     *
+     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.QuantityOrBuilder getQuantityOrBuilder() {
+      if ((kindCase_ == 8) && (quantityBuilder_ != null)) {
+        return quantityBuilder_.getMessageOrBuilder();
+      } else {
+        if (kindCase_ == 8) {
+          return (org.openmbee.opensysml.proto.Quantity) kind_;
+        }
+        return org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
+      }
+    }
+    /**
+     * <pre>
+     * magnitude in a unit, `2290000 [kg]`
+     * </pre>
+     *
+     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.Quantity, org.openmbee.opensysml.proto.Quantity.Builder, org.openmbee.opensysml.proto.QuantityOrBuilder> 
+        internalGetQuantityFieldBuilder() {
+      if (quantityBuilder_ == null) {
+        if (!(kindCase_ == 8)) {
+          kind_ = org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
+        }
+        quantityBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.Quantity, org.openmbee.opensysml.proto.Quantity.Builder, org.openmbee.opensysml.proto.QuantityOrBuilder>(
+                (org.openmbee.opensysml.proto.Quantity) kind_,
+                getParentForChildren(),
+                isClean());
+        kind_ = null;
+      }
+      kindCase_ = 8;
+      onChanged();
+      return quantityBuilder_;
+    }
+
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentVerdict, org.openmbee.opensysml.proto.DocumentVerdict.Builder, org.openmbee.opensysml.proto.DocumentVerdictOrBuilder> verdictBuilder_;
+    /**
+     * <pre>
+     * a row Verdicts answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+     * @return Whether the verdict field is set.
+     */
+    @java.lang.Override
+    public boolean hasVerdict() {
+      return kindCase_ == 9;
+    }
+    /**
+     * <pre>
+     * a row Verdicts answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+     * @return The verdict.
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentVerdict getVerdict() {
+      if (verdictBuilder_ == null) {
+        if (kindCase_ == 9) {
+          return (org.openmbee.opensysml.proto.DocumentVerdict) kind_;
+        }
+        return org.openmbee.opensysml.proto.DocumentVerdict.getDefaultInstance();
+      } else {
+        if (kindCase_ == 9) {
+          return verdictBuilder_.getMessage();
+        }
+        return org.openmbee.opensysml.proto.DocumentVerdict.getDefaultInstance();
+      }
+    }
+    /**
+     * <pre>
+     * a row Verdicts answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+     */
+    public Builder setVerdict(org.openmbee.opensysml.proto.DocumentVerdict value) {
+      if (verdictBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        kind_ = value;
+        onChanged();
+      } else {
+        verdictBuilder_.setMessage(value);
+      }
+      kindCase_ = 9;
+      return this;
+    }
+    /**
+     * <pre>
+     * a row Verdicts answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+     */
+    public Builder setVerdict(
+        org.openmbee.opensysml.proto.DocumentVerdict.Builder builderForValue) {
+      if (verdictBuilder_ == null) {
+        kind_ = builderForValue.build();
+        onChanged();
+      } else {
+        verdictBuilder_.setMessage(builderForValue.build());
+      }
+      kindCase_ = 9;
+      return this;
+    }
+    /**
+     * <pre>
+     * a row Verdicts answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+     */
+    public Builder mergeVerdict(org.openmbee.opensysml.proto.DocumentVerdict value) {
+      if (verdictBuilder_ == null) {
+        if (kindCase_ == 9 &&
+            kind_ != org.openmbee.opensysml.proto.DocumentVerdict.getDefaultInstance()) {
+          kind_ = org.openmbee.opensysml.proto.DocumentVerdict.newBuilder((org.openmbee.opensysml.proto.DocumentVerdict) kind_)
+              .mergeFrom(value).buildPartial();
+        } else {
+          kind_ = value;
+        }
+        onChanged();
+      } else {
+        if (kindCase_ == 9) {
+          verdictBuilder_.mergeFrom(value);
+        } else {
+          verdictBuilder_.setMessage(value);
+        }
+      }
+      kindCase_ = 9;
+      return this;
+    }
+    /**
+     * <pre>
+     * a row Verdicts answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+     */
+    public Builder clearVerdict() {
+      if (verdictBuilder_ == null) {
+        if (kindCase_ == 9) {
+          kindCase_ = 0;
+          kind_ = null;
+          onChanged();
+        }
+      } else {
+        if (kindCase_ == 9) {
+          kindCase_ = 0;
+          kind_ = null;
+        }
+        verdictBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * a row Verdicts answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentVerdict.Builder getVerdictBuilder() {
+      return internalGetVerdictFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * a row Verdicts answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentVerdictOrBuilder getVerdictOrBuilder() {
+      if ((kindCase_ == 9) && (verdictBuilder_ != null)) {
+        return verdictBuilder_.getMessageOrBuilder();
+      } else {
+        if (kindCase_ == 9) {
+          return (org.openmbee.opensysml.proto.DocumentVerdict) kind_;
+        }
+        return org.openmbee.opensysml.proto.DocumentVerdict.getDefaultInstance();
+      }
+    }
+    /**
+     * <pre>
+     * a row Verdicts answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentVerdict, org.openmbee.opensysml.proto.DocumentVerdict.Builder, org.openmbee.opensysml.proto.DocumentVerdictOrBuilder> 
+        internalGetVerdictFieldBuilder() {
+      if (verdictBuilder_ == null) {
+        if (!(kindCase_ == 9)) {
+          kind_ = org.openmbee.opensysml.proto.DocumentVerdict.getDefaultInstance();
+        }
+        verdictBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentVerdict, org.openmbee.opensysml.proto.DocumentVerdict.Builder, org.openmbee.opensysml.proto.DocumentVerdictOrBuilder>(
+                (org.openmbee.opensysml.proto.DocumentVerdict) kind_,
+                getParentForChildren(),
+                isClean());
+        kind_ = null;
+      }
+      kindCase_ = 9;
+      onChanged();
+      return verdictBuilder_;
+    }
+
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder> objectBuilder_;
+    /**
+     * <pre>
+     * an object Instantiate created; bound and answered
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+     * @return Whether the object field is set.
+     */
+    @java.lang.Override
+    public boolean hasObject() {
+      return kindCase_ == 10;
+    }
+    /**
+     * <pre>
+     * an object Instantiate created; bound and answered
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+     * @return The object.
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentObject getObject() {
+      if (objectBuilder_ == null) {
+        if (kindCase_ == 10) {
+          return (org.openmbee.opensysml.proto.DocumentObject) kind_;
+        }
+        return org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance();
+      } else {
+        if (kindCase_ == 10) {
+          return objectBuilder_.getMessage();
+        }
+        return org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance();
+      }
+    }
+    /**
+     * <pre>
+     * an object Instantiate created; bound and answered
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+     */
+    public Builder setObject(org.openmbee.opensysml.proto.DocumentObject value) {
+      if (objectBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        kind_ = value;
+        onChanged();
+      } else {
+        objectBuilder_.setMessage(value);
+      }
+      kindCase_ = 10;
+      return this;
+    }
+    /**
+     * <pre>
+     * an object Instantiate created; bound and answered
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+     */
+    public Builder setObject(
+        org.openmbee.opensysml.proto.DocumentObject.Builder builderForValue) {
+      if (objectBuilder_ == null) {
+        kind_ = builderForValue.build();
+        onChanged();
+      } else {
+        objectBuilder_.setMessage(builderForValue.build());
+      }
+      kindCase_ = 10;
+      return this;
+    }
+    /**
+     * <pre>
+     * an object Instantiate created; bound and answered
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+     */
+    public Builder mergeObject(org.openmbee.opensysml.proto.DocumentObject value) {
+      if (objectBuilder_ == null) {
+        if (kindCase_ == 10 &&
+            kind_ != org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance()) {
+          kind_ = org.openmbee.opensysml.proto.DocumentObject.newBuilder((org.openmbee.opensysml.proto.DocumentObject) kind_)
+              .mergeFrom(value).buildPartial();
+        } else {
+          kind_ = value;
+        }
+        onChanged();
+      } else {
+        if (kindCase_ == 10) {
+          objectBuilder_.mergeFrom(value);
+        } else {
+          objectBuilder_.setMessage(value);
+        }
+      }
+      kindCase_ = 10;
+      return this;
+    }
+    /**
+     * <pre>
+     * an object Instantiate created; bound and answered
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+     */
+    public Builder clearObject() {
+      if (objectBuilder_ == null) {
+        if (kindCase_ == 10) {
+          kindCase_ = 0;
+          kind_ = null;
+          onChanged();
+        }
+      } else {
+        if (kindCase_ == 10) {
+          kindCase_ = 0;
+          kind_ = null;
+        }
+        objectBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * an object Instantiate created; bound and answered
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentObject.Builder getObjectBuilder() {
+      return internalGetObjectFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * an object Instantiate created; bound and answered
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentObjectOrBuilder getObjectOrBuilder() {
+      if ((kindCase_ == 10) && (objectBuilder_ != null)) {
+        return objectBuilder_.getMessageOrBuilder();
+      } else {
+        if (kindCase_ == 10) {
+          return (org.openmbee.opensysml.proto.DocumentObject) kind_;
+        }
+        return org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance();
+      }
+    }
+    /**
+     * <pre>
+     * an object Instantiate created; bound and answered
+     * </pre>
+     *
+     * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder> 
+        internalGetObjectFieldBuilder() {
+      if (objectBuilder_ == null) {
+        if (!(kindCase_ == 10)) {
+          kind_ = org.openmbee.opensysml.proto.DocumentObject.getDefaultInstance();
+        }
+        objectBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentObject, org.openmbee.opensysml.proto.DocumentObject.Builder, org.openmbee.opensysml.proto.DocumentObjectOrBuilder>(
+                (org.openmbee.opensysml.proto.DocumentObject) kind_,
+                getParentForChildren(),
+                isClean());
+        kind_ = null;
+      }
+      kindCase_ = 10;
+      onChanged();
+      return objectBuilder_;
+    }
+
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentState, org.openmbee.opensysml.proto.DocumentState.Builder, org.openmbee.opensysml.proto.DocumentStateOrBuilder> stateBuilder_;
+    /**
+     * <pre>
+     * a row States answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+     * @return Whether the state field is set.
+     */
+    @java.lang.Override
+    public boolean hasState() {
+      return kindCase_ == 11;
+    }
+    /**
+     * <pre>
+     * a row States answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+     * @return The state.
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentState getState() {
+      if (stateBuilder_ == null) {
+        if (kindCase_ == 11) {
+          return (org.openmbee.opensysml.proto.DocumentState) kind_;
+        }
+        return org.openmbee.opensysml.proto.DocumentState.getDefaultInstance();
+      } else {
+        if (kindCase_ == 11) {
+          return stateBuilder_.getMessage();
+        }
+        return org.openmbee.opensysml.proto.DocumentState.getDefaultInstance();
+      }
+    }
+    /**
+     * <pre>
+     * a row States answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+     */
+    public Builder setState(org.openmbee.opensysml.proto.DocumentState value) {
+      if (stateBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        kind_ = value;
+        onChanged();
+      } else {
+        stateBuilder_.setMessage(value);
+      }
+      kindCase_ = 11;
+      return this;
+    }
+    /**
+     * <pre>
+     * a row States answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+     */
+    public Builder setState(
+        org.openmbee.opensysml.proto.DocumentState.Builder builderForValue) {
+      if (stateBuilder_ == null) {
+        kind_ = builderForValue.build();
+        onChanged();
+      } else {
+        stateBuilder_.setMessage(builderForValue.build());
+      }
+      kindCase_ = 11;
+      return this;
+    }
+    /**
+     * <pre>
+     * a row States answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+     */
+    public Builder mergeState(org.openmbee.opensysml.proto.DocumentState value) {
+      if (stateBuilder_ == null) {
+        if (kindCase_ == 11 &&
+            kind_ != org.openmbee.opensysml.proto.DocumentState.getDefaultInstance()) {
+          kind_ = org.openmbee.opensysml.proto.DocumentState.newBuilder((org.openmbee.opensysml.proto.DocumentState) kind_)
+              .mergeFrom(value).buildPartial();
+        } else {
+          kind_ = value;
+        }
+        onChanged();
+      } else {
+        if (kindCase_ == 11) {
+          stateBuilder_.mergeFrom(value);
+        } else {
+          stateBuilder_.setMessage(value);
+        }
+      }
+      kindCase_ = 11;
+      return this;
+    }
+    /**
+     * <pre>
+     * a row States answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+     */
+    public Builder clearState() {
+      if (stateBuilder_ == null) {
+        if (kindCase_ == 11) {
+          kindCase_ = 0;
+          kind_ = null;
+          onChanged();
+        }
+      } else {
+        if (kindCase_ == 11) {
+          kindCase_ = 0;
+          kind_ = null;
+        }
+        stateBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * a row States answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentState.Builder getStateBuilder() {
+      return internalGetStateFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * a row States answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentStateOrBuilder getStateOrBuilder() {
+      if ((kindCase_ == 11) && (stateBuilder_ != null)) {
+        return stateBuilder_.getMessageOrBuilder();
+      } else {
+        if (kindCase_ == 11) {
+          return (org.openmbee.opensysml.proto.DocumentState) kind_;
+        }
+        return org.openmbee.opensysml.proto.DocumentState.getDefaultInstance();
+      }
+    }
+    /**
+     * <pre>
+     * a row States answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentState, org.openmbee.opensysml.proto.DocumentState.Builder, org.openmbee.opensysml.proto.DocumentStateOrBuilder> 
+        internalGetStateFieldBuilder() {
+      if (stateBuilder_ == null) {
+        if (!(kindCase_ == 11)) {
+          kind_ = org.openmbee.opensysml.proto.DocumentState.getDefaultInstance();
+        }
+        stateBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentState, org.openmbee.opensysml.proto.DocumentState.Builder, org.openmbee.opensysml.proto.DocumentStateOrBuilder>(
+                (org.openmbee.opensysml.proto.DocumentState) kind_,
+                getParentForChildren(),
+                isClean());
+        kind_ = null;
+      }
+      kindCase_ = 11;
+      onChanged();
+      return stateBuilder_;
+    }
+
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentEvent, org.openmbee.opensysml.proto.DocumentEvent.Builder, org.openmbee.opensysml.proto.DocumentEventOrBuilder> eventBuilder_;
+    /**
+     * <pre>
+     * a row Events answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+     * @return Whether the event field is set.
+     */
+    @java.lang.Override
+    public boolean hasEvent() {
+      return kindCase_ == 12;
+    }
+    /**
+     * <pre>
+     * a row Events answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+     * @return The event.
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentEvent getEvent() {
+      if (eventBuilder_ == null) {
+        if (kindCase_ == 12) {
+          return (org.openmbee.opensysml.proto.DocumentEvent) kind_;
+        }
+        return org.openmbee.opensysml.proto.DocumentEvent.getDefaultInstance();
+      } else {
+        if (kindCase_ == 12) {
+          return eventBuilder_.getMessage();
+        }
+        return org.openmbee.opensysml.proto.DocumentEvent.getDefaultInstance();
+      }
+    }
+    /**
+     * <pre>
+     * a row Events answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+     */
+    public Builder setEvent(org.openmbee.opensysml.proto.DocumentEvent value) {
+      if (eventBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        kind_ = value;
+        onChanged();
+      } else {
+        eventBuilder_.setMessage(value);
+      }
+      kindCase_ = 12;
+      return this;
+    }
+    /**
+     * <pre>
+     * a row Events answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+     */
+    public Builder setEvent(
+        org.openmbee.opensysml.proto.DocumentEvent.Builder builderForValue) {
+      if (eventBuilder_ == null) {
+        kind_ = builderForValue.build();
+        onChanged();
+      } else {
+        eventBuilder_.setMessage(builderForValue.build());
+      }
+      kindCase_ = 12;
+      return this;
+    }
+    /**
+     * <pre>
+     * a row Events answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+     */
+    public Builder mergeEvent(org.openmbee.opensysml.proto.DocumentEvent value) {
+      if (eventBuilder_ == null) {
+        if (kindCase_ == 12 &&
+            kind_ != org.openmbee.opensysml.proto.DocumentEvent.getDefaultInstance()) {
+          kind_ = org.openmbee.opensysml.proto.DocumentEvent.newBuilder((org.openmbee.opensysml.proto.DocumentEvent) kind_)
+              .mergeFrom(value).buildPartial();
+        } else {
+          kind_ = value;
+        }
+        onChanged();
+      } else {
+        if (kindCase_ == 12) {
+          eventBuilder_.mergeFrom(value);
+        } else {
+          eventBuilder_.setMessage(value);
+        }
+      }
+      kindCase_ = 12;
+      return this;
+    }
+    /**
+     * <pre>
+     * a row Events answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+     */
+    public Builder clearEvent() {
+      if (eventBuilder_ == null) {
+        if (kindCase_ == 12) {
+          kindCase_ = 0;
+          kind_ = null;
+          onChanged();
+        }
+      } else {
+        if (kindCase_ == 12) {
+          kindCase_ = 0;
+          kind_ = null;
+        }
+        eventBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * a row Events answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentEvent.Builder getEventBuilder() {
+      return internalGetEventFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * a row Events answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentEventOrBuilder getEventOrBuilder() {
+      if ((kindCase_ == 12) && (eventBuilder_ != null)) {
+        return eventBuilder_.getMessageOrBuilder();
+      } else {
+        if (kindCase_ == 12) {
+          return (org.openmbee.opensysml.proto.DocumentEvent) kind_;
+        }
+        return org.openmbee.opensysml.proto.DocumentEvent.getDefaultInstance();
+      }
+    }
+    /**
+     * <pre>
+     * a row Events answered; answered, never bound
+     * </pre>
+     *
+     * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentEvent, org.openmbee.opensysml.proto.DocumentEvent.Builder, org.openmbee.opensysml.proto.DocumentEventOrBuilder> 
+        internalGetEventFieldBuilder() {
+      if (eventBuilder_ == null) {
+        if (!(kindCase_ == 12)) {
+          kind_ = org.openmbee.opensysml.proto.DocumentEvent.getDefaultInstance();
+        }
+        eventBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentEvent, org.openmbee.opensysml.proto.DocumentEvent.Builder, org.openmbee.opensysml.proto.DocumentEventOrBuilder>(
+                (org.openmbee.opensysml.proto.DocumentEvent) kind_,
+                getParentForChildren(),
+                isClean());
+        kind_ = null;
+      }
+      kindCase_ = 12;
+      onChanged();
+      return eventBuilder_;
+    }
+
+    private java.lang.Object elementType_ = "";
+    /**
+     * <pre>
+     * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
+     * </pre>
+     *
+     * <code>string element_type = 7 [json_name = "elementType"];</code>
+     * @return The elementType.
+     */
+    public java.lang.String getElementType() {
+      java.lang.Object ref = elementType_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        elementType_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
+     * </pre>
+     *
+     * <code>string element_type = 7 [json_name = "elementType"];</code>
+     * @return The bytes for elementType.
+     */
+    public com.google.protobuf.ByteString
+        getElementTypeBytes() {
+      java.lang.Object ref = elementType_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        elementType_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
+     * </pre>
+     *
+     * <code>string element_type = 7 [json_name = "elementType"];</code>
+     * @param value The elementType to set.
+     * @return This builder for chaining.
+     */
+    public Builder setElementType(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      elementType_ = value;
+      bitField0_ |= 0x00000800;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
+     * </pre>
+     *
+     * <code>string element_type = 7 [json_name = "elementType"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearElementType() {
+      elementType_ = getDefaultInstance().getElementType();
+      bitField0_ = (bitField0_ & ~0x00000800);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
+     * </pre>
+     *
+     * <code>string element_type = 7 [json_name = "elementType"];</code>
+     * @param value The bytes for elementType to set.
+     * @return This builder for chaining.
+     */
+    public Builder setElementTypeBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      elementType_ = value;
+      bitField0_ |= 0x00000800;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.DocumentValue)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.DocumentValue)
+  private static final org.openmbee.opensysml.proto.DocumentValue DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.DocumentValue();
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentValue getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<DocumentValue>
+      PARSER = new com.google.protobuf.AbstractParser<DocumentValue>() {
+    @java.lang.Override
+    public DocumentValue parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<DocumentValue> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<DocumentValue> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentValue getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValueOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValueOrBuilder.java
new file mode 100644
index 0000000000..de69db46d9
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValueOrBuilder.java
@@ -0,0 +1,259 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface DocumentValueOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.DocumentValue)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * qualified name of a model element
+   * </pre>
+   *
+   * <code>string element_id = 1 [json_name = "elementId"];</code>
+   * @return Whether the elementId field is set.
+   */
+  boolean hasElementId();
+  /**
+   * <pre>
+   * qualified name of a model element
+   * </pre>
+   *
+   * <code>string element_id = 1 [json_name = "elementId"];</code>
+   * @return The elementId.
+   */
+  java.lang.String getElementId();
+  /**
+   * <pre>
+   * qualified name of a model element
+   * </pre>
+   *
+   * <code>string element_id = 1 [json_name = "elementId"];</code>
+   * @return The bytes for elementId.
+   */
+  com.google.protobuf.ByteString
+      getElementIdBytes();
+
+  /**
+   * <code>string string_value = 2 [json_name = "stringValue"];</code>
+   * @return Whether the stringValue field is set.
+   */
+  boolean hasStringValue();
+  /**
+   * <code>string string_value = 2 [json_name = "stringValue"];</code>
+   * @return The stringValue.
+   */
+  java.lang.String getStringValue();
+  /**
+   * <code>string string_value = 2 [json_name = "stringValue"];</code>
+   * @return The bytes for stringValue.
+   */
+  com.google.protobuf.ByteString
+      getStringValueBytes();
+
+  /**
+   * <code>int64 int_value = 3 [json_name = "intValue"];</code>
+   * @return Whether the intValue field is set.
+   */
+  boolean hasIntValue();
+  /**
+   * <code>int64 int_value = 3 [json_name = "intValue"];</code>
+   * @return The intValue.
+   */
+  long getIntValue();
+
+  /**
+   * <code>double real_value = 4 [json_name = "realValue"];</code>
+   * @return Whether the realValue field is set.
+   */
+  boolean hasRealValue();
+  /**
+   * <code>double real_value = 4 [json_name = "realValue"];</code>
+   * @return The realValue.
+   */
+  double getRealValue();
+
+  /**
+   * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
+   * @return Whether the boolValue field is set.
+   */
+  boolean hasBoolValue();
+  /**
+   * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
+   * @return The boolValue.
+   */
+  boolean getBoolValue();
+
+  /**
+   * <code>bool infinity = 6 [json_name = "infinity"];</code>
+   * @return Whether the infinity field is set.
+   */
+  boolean hasInfinity();
+  /**
+   * <code>bool infinity = 6 [json_name = "infinity"];</code>
+   * @return The infinity.
+   */
+  boolean getInfinity();
+
+  /**
+   * <pre>
+   * magnitude in a unit, `2290000 [kg]`
+   * </pre>
+   *
+   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+   * @return Whether the quantity field is set.
+   */
+  boolean hasQuantity();
+  /**
+   * <pre>
+   * magnitude in a unit, `2290000 [kg]`
+   * </pre>
+   *
+   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+   * @return The quantity.
+   */
+  org.openmbee.opensysml.proto.Quantity getQuantity();
+  /**
+   * <pre>
+   * magnitude in a unit, `2290000 [kg]`
+   * </pre>
+   *
+   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
+   */
+  org.openmbee.opensysml.proto.QuantityOrBuilder getQuantityOrBuilder();
+
+  /**
+   * <pre>
+   * a row Verdicts answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+   * @return Whether the verdict field is set.
+   */
+  boolean hasVerdict();
+  /**
+   * <pre>
+   * a row Verdicts answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+   * @return The verdict.
+   */
+  org.openmbee.opensysml.proto.DocumentVerdict getVerdict();
+  /**
+   * <pre>
+   * a row Verdicts answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentVerdict verdict = 9 [json_name = "verdict"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentVerdictOrBuilder getVerdictOrBuilder();
+
+  /**
+   * <pre>
+   * an object Instantiate created; bound and answered
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+   * @return Whether the object field is set.
+   */
+  boolean hasObject();
+  /**
+   * <pre>
+   * an object Instantiate created; bound and answered
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+   * @return The object.
+   */
+  org.openmbee.opensysml.proto.DocumentObject getObject();
+  /**
+   * <pre>
+   * an object Instantiate created; bound and answered
+   * </pre>
+   *
+   * <code>.sysml.DocumentObject object = 10 [json_name = "object"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentObjectOrBuilder getObjectOrBuilder();
+
+  /**
+   * <pre>
+   * a row States answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+   * @return Whether the state field is set.
+   */
+  boolean hasState();
+  /**
+   * <pre>
+   * a row States answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+   * @return The state.
+   */
+  org.openmbee.opensysml.proto.DocumentState getState();
+  /**
+   * <pre>
+   * a row States answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentState state = 11 [json_name = "state"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentStateOrBuilder getStateOrBuilder();
+
+  /**
+   * <pre>
+   * a row Events answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+   * @return Whether the event field is set.
+   */
+  boolean hasEvent();
+  /**
+   * <pre>
+   * a row Events answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+   * @return The event.
+   */
+  org.openmbee.opensysml.proto.DocumentEvent getEvent();
+  /**
+   * <pre>
+   * a row Events answered; answered, never bound
+   * </pre>
+   *
+   * <code>.sysml.DocumentEvent event = 12 [json_name = "event"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentEventOrBuilder getEventOrBuilder();
+
+  /**
+   * <pre>
+   * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
+   * </pre>
+   *
+   * <code>string element_type = 7 [json_name = "elementType"];</code>
+   * @return The elementType.
+   */
+  java.lang.String getElementType();
+  /**
+   * <pre>
+   * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
+   * </pre>
+   *
+   * <code>string element_type = 7 [json_name = "elementType"];</code>
+   * @return The bytes for elementType.
+   */
+  com.google.protobuf.ByteString
+      getElementTypeBytes();
+
+  org.openmbee.opensysml.proto.DocumentValue.KindCase getKindCase();
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentVerdict.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentVerdict.java
new file mode 100644
index 0000000000..894edf0f5d
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentVerdict.java
@@ -0,0 +1,1894 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * DocumentVerdict is one row a `Verdicts` query answered: an assertion checked
+ * on the object at `path`, which the row stands for as its `element`.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.DocumentVerdict}
+ */
+@com.google.protobuf.Generated
+public final class DocumentVerdict extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.DocumentVerdict)
+    DocumentVerdictOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "DocumentVerdict");
+  }
+  // Use DocumentVerdict.newBuilder() to construct.
+  private DocumentVerdict(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private DocumentVerdict() {
+    kind_ = "";
+    text_ = "";
+    path_ = "";
+    verdict_ = "";
+    condition_ = "";
+    reason_ = "";
+    verification_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentVerdict_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentVerdict_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.DocumentVerdict.class, org.openmbee.opensysml.proto.DocumentVerdict.Builder.class);
+  }
+
+  private int bitField0_;
+  public static final int ASSERTION_FIELD_NUMBER = 1;
+  private org.openmbee.opensysml.proto.DocumentValue assertion_;
+  /**
+   * <pre>
+   * The assertion checked, as an element value: the constraint, requirement,
+   * satisfy usage or verification case, its element_id empty when anonymous.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+   * @return Whether the assertion field is set.
+   */
+  @java.lang.Override
+  public boolean hasAssertion() {
+    return ((bitField0_ & 0x00000001) != 0);
+  }
+  /**
+   * <pre>
+   * The assertion checked, as an element value: the constraint, requirement,
+   * satisfy usage or verification case, its element_id empty when anonymous.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+   * @return The assertion.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentValue getAssertion() {
+    return assertion_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : assertion_;
+  }
+  /**
+   * <pre>
+   * The assertion checked, as an element value: the constraint, requirement,
+   * satisfy usage or verification case, its element_id empty when anonymous.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentValueOrBuilder getAssertionOrBuilder() {
+    return assertion_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : assertion_;
+  }
+
+  public static final int KIND_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object kind_ = "";
+  /**
+   * <pre>
+   * "constraint", "requirement", "satisfaction" or "verification".
+   * </pre>
+   *
+   * <code>string kind = 2 [json_name = "kind"];</code>
+   * @return The kind.
+   */
+  @java.lang.Override
+  public java.lang.String getKind() {
+    java.lang.Object ref = kind_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      kind_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * "constraint", "requirement", "satisfaction" or "verification".
+   * </pre>
+   *
+   * <code>string kind = 2 [json_name = "kind"];</code>
+   * @return The bytes for kind.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getKindBytes() {
+    java.lang.Object ref = kind_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      kind_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int TEXT_FIELD_NUMBER = 3;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object text_ = "";
+  /**
+   * <pre>
+   * The assertion as written ("assert constraint massKnown", "satisfy Range by
+   * cruise", "verification Tests::massTest"), naming an anonymous one.
+   * </pre>
+   *
+   * <code>string text = 3 [json_name = "text"];</code>
+   * @return The text.
+   */
+  @java.lang.Override
+  public java.lang.String getText() {
+    java.lang.Object ref = text_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      text_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The assertion as written ("assert constraint massKnown", "satisfy Range by
+   * cruise", "verification Tests::massTest"), naming an anonymous one.
+   * </pre>
+   *
+   * <code>string text = 3 [json_name = "text"];</code>
+   * @return The bytes for text.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getTextBytes() {
+    java.lang.Object ref = text_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      text_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int PATH_FIELD_NUMBER = 4;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object path_ = "";
+  /**
+   * <pre>
+   * The object checked, by path from the element the query was bound to
+   * ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+   * </pre>
+   *
+   * <code>string path = 4 [json_name = "path"];</code>
+   * @return The path.
+   */
+  @java.lang.Override
+  public java.lang.String getPath() {
+    java.lang.Object ref = path_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      path_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The object checked, by path from the element the query was bound to
+   * ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+   * </pre>
+   *
+   * <code>string path = 4 [json_name = "path"];</code>
+   * @return The bytes for path.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getPathBytes() {
+    java.lang.Object ref = path_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      path_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int VERDICT_FIELD_NUMBER = 5;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object verdict_ = "";
+  /**
+   * <pre>
+   * "holds", "violated" or "undecided".
+   * </pre>
+   *
+   * <code>string verdict = 5 [json_name = "verdict"];</code>
+   * @return The verdict.
+   */
+  @java.lang.Override
+  public java.lang.String getVerdict() {
+    java.lang.Object ref = verdict_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      verdict_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * "holds", "violated" or "undecided".
+   * </pre>
+   *
+   * <code>string verdict = 5 [json_name = "verdict"];</code>
+   * @return The bytes for verdict.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getVerdictBytes() {
+    java.lang.Object ref = verdict_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      verdict_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int CONDITION_FIELD_NUMBER = 6;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object condition_ = "";
+  /**
+   * <pre>
+   * The condition that evaluated to false, as written; empty otherwise.
+   * </pre>
+   *
+   * <code>string condition = 6 [json_name = "condition"];</code>
+   * @return The condition.
+   */
+  @java.lang.Override
+  public java.lang.String getCondition() {
+    java.lang.Object ref = condition_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      condition_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The condition that evaluated to false, as written; empty otherwise.
+   * </pre>
+   *
+   * <code>string condition = 6 [json_name = "condition"];</code>
+   * @return The bytes for condition.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getConditionBytes() {
+    java.lang.Object ref = condition_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      condition_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int REASON_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object reason_ = "";
+  /**
+   * <pre>
+   * Why the assertion is violated or undecided; empty when it holds.
+   * </pre>
+   *
+   * <code>string reason = 7 [json_name = "reason"];</code>
+   * @return The reason.
+   */
+  @java.lang.Override
+  public java.lang.String getReason() {
+    java.lang.Object ref = reason_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      reason_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * Why the assertion is violated or undecided; empty when it holds.
+   * </pre>
+   *
+   * <code>string reason = 7 [json_name = "reason"];</code>
+   * @return The bytes for reason.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getReasonBytes() {
+    java.lang.Object ref = reason_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      reason_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int VERIFICATION_FIELD_NUMBER = 8;
+  @SuppressWarnings("serial")
+  private com.google.protobuf.LazyStringArrayList verification_ =
+      com.google.protobuf.LazyStringArrayList.emptyList();
+  /**
+   * <pre>
+   * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+   * verification cases verifying the requirement the row is about; a
+   * verification row's own kind. Empty for a constraint.
+   * </pre>
+   *
+   * <code>repeated string verification = 8 [json_name = "verification"];</code>
+   * @return A list containing the verification.
+   */
+  public com.google.protobuf.ProtocolStringList
+      getVerificationList() {
+    return verification_;
+  }
+  /**
+   * <pre>
+   * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+   * verification cases verifying the requirement the row is about; a
+   * verification row's own kind. Empty for a constraint.
+   * </pre>
+   *
+   * <code>repeated string verification = 8 [json_name = "verification"];</code>
+   * @return The count of verification.
+   */
+  public int getVerificationCount() {
+    return verification_.size();
+  }
+  /**
+   * <pre>
+   * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+   * verification cases verifying the requirement the row is about; a
+   * verification row's own kind. Empty for a constraint.
+   * </pre>
+   *
+   * <code>repeated string verification = 8 [json_name = "verification"];</code>
+   * @param index The index of the element to return.
+   * @return The verification at the given index.
+   */
+  public java.lang.String getVerification(int index) {
+    return verification_.get(index);
+  }
+  /**
+   * <pre>
+   * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+   * verification cases verifying the requirement the row is about; a
+   * verification row's own kind. Empty for a constraint.
+   * </pre>
+   *
+   * <code>repeated string verification = 8 [json_name = "verification"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the verification at the given index.
+   */
+  public com.google.protobuf.ByteString
+      getVerificationBytes(int index) {
+    return verification_.getByteString(index);
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (((bitField0_ & 0x00000001) != 0)) {
+      output.writeMessage(1, getAssertion());
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(kind_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, kind_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(text_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 3, text_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(path_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 4, path_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(verdict_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 5, verdict_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(condition_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 6, condition_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(reason_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 7, reason_);
+    }
+    for (int i = 0; i < verification_.size(); i++) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 8, verification_.getRaw(i));
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (((bitField0_ & 0x00000001) != 0)) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(1, getAssertion());
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(kind_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, kind_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(text_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(3, text_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(path_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(4, path_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(verdict_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(5, verdict_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(condition_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(6, condition_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(reason_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(7, reason_);
+    }
+    {
+      int dataSize = 0;
+      for (int i = 0; i < verification_.size(); i++) {
+        dataSize += computeStringSizeNoTag(verification_.getRaw(i));
+      }
+      size += dataSize;
+      size += 1 * getVerificationList().size();
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.DocumentVerdict)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.DocumentVerdict other = (org.openmbee.opensysml.proto.DocumentVerdict) obj;
+
+    if (hasAssertion() != other.hasAssertion()) return false;
+    if (hasAssertion()) {
+      if (!getAssertion()
+          .equals(other.getAssertion())) return false;
+    }
+    if (!getKind()
+        .equals(other.getKind())) return false;
+    if (!getText()
+        .equals(other.getText())) return false;
+    if (!getPath()
+        .equals(other.getPath())) return false;
+    if (!getVerdict()
+        .equals(other.getVerdict())) return false;
+    if (!getCondition()
+        .equals(other.getCondition())) return false;
+    if (!getReason()
+        .equals(other.getReason())) return false;
+    if (!getVerificationList()
+        .equals(other.getVerificationList())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    if (hasAssertion()) {
+      hash = (37 * hash) + ASSERTION_FIELD_NUMBER;
+      hash = (53 * hash) + getAssertion().hashCode();
+    }
+    hash = (37 * hash) + KIND_FIELD_NUMBER;
+    hash = (53 * hash) + getKind().hashCode();
+    hash = (37 * hash) + TEXT_FIELD_NUMBER;
+    hash = (53 * hash) + getText().hashCode();
+    hash = (37 * hash) + PATH_FIELD_NUMBER;
+    hash = (53 * hash) + getPath().hashCode();
+    hash = (37 * hash) + VERDICT_FIELD_NUMBER;
+    hash = (53 * hash) + getVerdict().hashCode();
+    hash = (37 * hash) + CONDITION_FIELD_NUMBER;
+    hash = (53 * hash) + getCondition().hashCode();
+    hash = (37 * hash) + REASON_FIELD_NUMBER;
+    hash = (53 * hash) + getReason().hashCode();
+    if (getVerificationCount() > 0) {
+      hash = (37 * hash) + VERIFICATION_FIELD_NUMBER;
+      hash = (53 * hash) + getVerificationList().hashCode();
+    }
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.DocumentVerdict parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.DocumentVerdict prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * DocumentVerdict is one row a `Verdicts` query answered: an assertion checked
+   * on the object at `path`, which the row stands for as its `element`.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.DocumentVerdict}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.DocumentVerdict)
+      org.openmbee.opensysml.proto.DocumentVerdictOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentVerdict_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentVerdict_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.DocumentVerdict.class, org.openmbee.opensysml.proto.DocumentVerdict.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.DocumentVerdict.newBuilder()
+    private Builder() {
+      maybeForceBuilderInitialization();
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+      maybeForceBuilderInitialization();
+    }
+    private void maybeForceBuilderInitialization() {
+      if (com.google.protobuf.GeneratedMessage
+              .alwaysUseFieldBuilders) {
+        internalGetAssertionFieldBuilder();
+      }
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      assertion_ = null;
+      if (assertionBuilder_ != null) {
+        assertionBuilder_.dispose();
+        assertionBuilder_ = null;
+      }
+      kind_ = "";
+      text_ = "";
+      path_ = "";
+      verdict_ = "";
+      condition_ = "";
+      reason_ = "";
+      verification_ =
+          com.google.protobuf.LazyStringArrayList.emptyList();
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentVerdict_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentVerdict getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.DocumentVerdict.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentVerdict build() {
+      org.openmbee.opensysml.proto.DocumentVerdict result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.DocumentVerdict buildPartial() {
+      org.openmbee.opensysml.proto.DocumentVerdict result = new org.openmbee.opensysml.proto.DocumentVerdict(this);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.DocumentVerdict result) {
+      int from_bitField0_ = bitField0_;
+      int to_bitField0_ = 0;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.assertion_ = assertionBuilder_ == null
+            ? assertion_
+            : assertionBuilder_.build();
+        to_bitField0_ |= 0x00000001;
+      }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.kind_ = kind_;
+      }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.text_ = text_;
+      }
+      if (((from_bitField0_ & 0x00000008) != 0)) {
+        result.path_ = path_;
+      }
+      if (((from_bitField0_ & 0x00000010) != 0)) {
+        result.verdict_ = verdict_;
+      }
+      if (((from_bitField0_ & 0x00000020) != 0)) {
+        result.condition_ = condition_;
+      }
+      if (((from_bitField0_ & 0x00000040) != 0)) {
+        result.reason_ = reason_;
+      }
+      if (((from_bitField0_ & 0x00000080) != 0)) {
+        verification_.makeImmutable();
+        result.verification_ = verification_;
+      }
+      result.bitField0_ |= to_bitField0_;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.DocumentVerdict) {
+        return mergeFrom((org.openmbee.opensysml.proto.DocumentVerdict)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.DocumentVerdict other) {
+      if (other == org.openmbee.opensysml.proto.DocumentVerdict.getDefaultInstance()) return this;
+      if (other.hasAssertion()) {
+        mergeAssertion(other.getAssertion());
+      }
+      if (!other.getKind().isEmpty()) {
+        kind_ = other.kind_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      if (!other.getText().isEmpty()) {
+        text_ = other.text_;
+        bitField0_ |= 0x00000004;
+        onChanged();
+      }
+      if (!other.getPath().isEmpty()) {
+        path_ = other.path_;
+        bitField0_ |= 0x00000008;
+        onChanged();
+      }
+      if (!other.getVerdict().isEmpty()) {
+        verdict_ = other.verdict_;
+        bitField0_ |= 0x00000010;
+        onChanged();
+      }
+      if (!other.getCondition().isEmpty()) {
+        condition_ = other.condition_;
+        bitField0_ |= 0x00000020;
+        onChanged();
+      }
+      if (!other.getReason().isEmpty()) {
+        reason_ = other.reason_;
+        bitField0_ |= 0x00000040;
+        onChanged();
+      }
+      if (!other.verification_.isEmpty()) {
+        if (verification_.isEmpty()) {
+          verification_ = other.verification_;
+          bitField0_ |= 0x00000080;
+        } else {
+          ensureVerificationIsMutable();
+          verification_.addAll(other.verification_);
+        }
+        onChanged();
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              input.readMessage(
+                  internalGetAssertionFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              kind_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            case 26: {
+              text_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 26
+            case 34: {
+              path_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000008;
+              break;
+            } // case 34
+            case 42: {
+              verdict_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000010;
+              break;
+            } // case 42
+            case 50: {
+              condition_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000020;
+              break;
+            } // case 50
+            case 58: {
+              reason_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000040;
+              break;
+            } // case 58
+            case 66: {
+              java.lang.String s = input.readStringRequireUtf8();
+              ensureVerificationIsMutable();
+              verification_.add(s);
+              break;
+            } // case 66
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private org.openmbee.opensysml.proto.DocumentValue assertion_;
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder> assertionBuilder_;
+    /**
+     * <pre>
+     * The assertion checked, as an element value: the constraint, requirement,
+     * satisfy usage or verification case, its element_id empty when anonymous.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+     * @return Whether the assertion field is set.
+     */
+    public boolean hasAssertion() {
+      return ((bitField0_ & 0x00000001) != 0);
+    }
+    /**
+     * <pre>
+     * The assertion checked, as an element value: the constraint, requirement,
+     * satisfy usage or verification case, its element_id empty when anonymous.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+     * @return The assertion.
+     */
+    public org.openmbee.opensysml.proto.DocumentValue getAssertion() {
+      if (assertionBuilder_ == null) {
+        return assertion_ == null ? org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : assertion_;
+      } else {
+        return assertionBuilder_.getMessage();
+      }
+    }
+    /**
+     * <pre>
+     * The assertion checked, as an element value: the constraint, requirement,
+     * satisfy usage or verification case, its element_id empty when anonymous.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+     */
+    public Builder setAssertion(org.openmbee.opensysml.proto.DocumentValue value) {
+      if (assertionBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        assertion_ = value;
+      } else {
+        assertionBuilder_.setMessage(value);
+      }
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The assertion checked, as an element value: the constraint, requirement,
+     * satisfy usage or verification case, its element_id empty when anonymous.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+     */
+    public Builder setAssertion(
+        org.openmbee.opensysml.proto.DocumentValue.Builder builderForValue) {
+      if (assertionBuilder_ == null) {
+        assertion_ = builderForValue.build();
+      } else {
+        assertionBuilder_.setMessage(builderForValue.build());
+      }
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The assertion checked, as an element value: the constraint, requirement,
+     * satisfy usage or verification case, its element_id empty when anonymous.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+     */
+    public Builder mergeAssertion(org.openmbee.opensysml.proto.DocumentValue value) {
+      if (assertionBuilder_ == null) {
+        if (((bitField0_ & 0x00000001) != 0) &&
+          assertion_ != null &&
+          assertion_ != org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance()) {
+          getAssertionBuilder().mergeFrom(value);
+        } else {
+          assertion_ = value;
+        }
+      } else {
+        assertionBuilder_.mergeFrom(value);
+      }
+      if (assertion_ != null) {
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The assertion checked, as an element value: the constraint, requirement,
+     * satisfy usage or verification case, its element_id empty when anonymous.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+     */
+    public Builder clearAssertion() {
+      bitField0_ = (bitField0_ & ~0x00000001);
+      assertion_ = null;
+      if (assertionBuilder_ != null) {
+        assertionBuilder_.dispose();
+        assertionBuilder_ = null;
+      }
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The assertion checked, as an element value: the constraint, requirement,
+     * satisfy usage or verification case, its element_id empty when anonymous.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentValue.Builder getAssertionBuilder() {
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return internalGetAssertionFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * The assertion checked, as an element value: the constraint, requirement,
+     * satisfy usage or verification case, its element_id empty when anonymous.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+     */
+    public org.openmbee.opensysml.proto.DocumentValueOrBuilder getAssertionOrBuilder() {
+      if (assertionBuilder_ != null) {
+        return assertionBuilder_.getMessageOrBuilder();
+      } else {
+        return assertion_ == null ?
+            org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance() : assertion_;
+      }
+    }
+    /**
+     * <pre>
+     * The assertion checked, as an element value: the constraint, requirement,
+     * satisfy usage or verification case, its element_id empty when anonymous.
+     * </pre>
+     *
+     * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder> 
+        internalGetAssertionFieldBuilder() {
+      if (assertionBuilder_ == null) {
+        assertionBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.DocumentValue, org.openmbee.opensysml.proto.DocumentValue.Builder, org.openmbee.opensysml.proto.DocumentValueOrBuilder>(
+                getAssertion(),
+                getParentForChildren(),
+                isClean());
+        assertion_ = null;
+      }
+      return assertionBuilder_;
+    }
+
+    private java.lang.Object kind_ = "";
+    /**
+     * <pre>
+     * "constraint", "requirement", "satisfaction" or "verification".
+     * </pre>
+     *
+     * <code>string kind = 2 [json_name = "kind"];</code>
+     * @return The kind.
+     */
+    public java.lang.String getKind() {
+      java.lang.Object ref = kind_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        kind_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * "constraint", "requirement", "satisfaction" or "verification".
+     * </pre>
+     *
+     * <code>string kind = 2 [json_name = "kind"];</code>
+     * @return The bytes for kind.
+     */
+    public com.google.protobuf.ByteString
+        getKindBytes() {
+      java.lang.Object ref = kind_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        kind_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * "constraint", "requirement", "satisfaction" or "verification".
+     * </pre>
+     *
+     * <code>string kind = 2 [json_name = "kind"];</code>
+     * @param value The kind to set.
+     * @return This builder for chaining.
+     */
+    public Builder setKind(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      kind_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * "constraint", "requirement", "satisfaction" or "verification".
+     * </pre>
+     *
+     * <code>string kind = 2 [json_name = "kind"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearKind() {
+      kind_ = getDefaultInstance().getKind();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * "constraint", "requirement", "satisfaction" or "verification".
+     * </pre>
+     *
+     * <code>string kind = 2 [json_name = "kind"];</code>
+     * @param value The bytes for kind to set.
+     * @return This builder for chaining.
+     */
+    public Builder setKindBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      kind_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object text_ = "";
+    /**
+     * <pre>
+     * The assertion as written ("assert constraint massKnown", "satisfy Range by
+     * cruise", "verification Tests::massTest"), naming an anonymous one.
+     * </pre>
+     *
+     * <code>string text = 3 [json_name = "text"];</code>
+     * @return The text.
+     */
+    public java.lang.String getText() {
+      java.lang.Object ref = text_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        text_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The assertion as written ("assert constraint massKnown", "satisfy Range by
+     * cruise", "verification Tests::massTest"), naming an anonymous one.
+     * </pre>
+     *
+     * <code>string text = 3 [json_name = "text"];</code>
+     * @return The bytes for text.
+     */
+    public com.google.protobuf.ByteString
+        getTextBytes() {
+      java.lang.Object ref = text_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        text_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The assertion as written ("assert constraint massKnown", "satisfy Range by
+     * cruise", "verification Tests::massTest"), naming an anonymous one.
+     * </pre>
+     *
+     * <code>string text = 3 [json_name = "text"];</code>
+     * @param value The text to set.
+     * @return This builder for chaining.
+     */
+    public Builder setText(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      text_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The assertion as written ("assert constraint massKnown", "satisfy Range by
+     * cruise", "verification Tests::massTest"), naming an anonymous one.
+     * </pre>
+     *
+     * <code>string text = 3 [json_name = "text"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearText() {
+      text_ = getDefaultInstance().getText();
+      bitField0_ = (bitField0_ & ~0x00000004);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The assertion as written ("assert constraint massKnown", "satisfy Range by
+     * cruise", "verification Tests::massTest"), naming an anonymous one.
+     * </pre>
+     *
+     * <code>string text = 3 [json_name = "text"];</code>
+     * @param value The bytes for text to set.
+     * @return This builder for chaining.
+     */
+    public Builder setTextBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      text_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object path_ = "";
+    /**
+     * <pre>
+     * The object checked, by path from the element the query was bound to
+     * ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+     * </pre>
+     *
+     * <code>string path = 4 [json_name = "path"];</code>
+     * @return The path.
+     */
+    public java.lang.String getPath() {
+      java.lang.Object ref = path_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        path_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The object checked, by path from the element the query was bound to
+     * ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+     * </pre>
+     *
+     * <code>string path = 4 [json_name = "path"];</code>
+     * @return The bytes for path.
+     */
+    public com.google.protobuf.ByteString
+        getPathBytes() {
+      java.lang.Object ref = path_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        path_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The object checked, by path from the element the query was bound to
+     * ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+     * </pre>
+     *
+     * <code>string path = 4 [json_name = "path"];</code>
+     * @param value The path to set.
+     * @return This builder for chaining.
+     */
+    public Builder setPath(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      path_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object checked, by path from the element the query was bound to
+     * ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+     * </pre>
+     *
+     * <code>string path = 4 [json_name = "path"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearPath() {
+      path_ = getDefaultInstance().getPath();
+      bitField0_ = (bitField0_ & ~0x00000008);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object checked, by path from the element the query was bound to
+     * ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+     * </pre>
+     *
+     * <code>string path = 4 [json_name = "path"];</code>
+     * @param value The bytes for path to set.
+     * @return This builder for chaining.
+     */
+    public Builder setPathBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      path_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object verdict_ = "";
+    /**
+     * <pre>
+     * "holds", "violated" or "undecided".
+     * </pre>
+     *
+     * <code>string verdict = 5 [json_name = "verdict"];</code>
+     * @return The verdict.
+     */
+    public java.lang.String getVerdict() {
+      java.lang.Object ref = verdict_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        verdict_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * "holds", "violated" or "undecided".
+     * </pre>
+     *
+     * <code>string verdict = 5 [json_name = "verdict"];</code>
+     * @return The bytes for verdict.
+     */
+    public com.google.protobuf.ByteString
+        getVerdictBytes() {
+      java.lang.Object ref = verdict_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        verdict_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * "holds", "violated" or "undecided".
+     * </pre>
+     *
+     * <code>string verdict = 5 [json_name = "verdict"];</code>
+     * @param value The verdict to set.
+     * @return This builder for chaining.
+     */
+    public Builder setVerdict(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      verdict_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * "holds", "violated" or "undecided".
+     * </pre>
+     *
+     * <code>string verdict = 5 [json_name = "verdict"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearVerdict() {
+      verdict_ = getDefaultInstance().getVerdict();
+      bitField0_ = (bitField0_ & ~0x00000010);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * "holds", "violated" or "undecided".
+     * </pre>
+     *
+     * <code>string verdict = 5 [json_name = "verdict"];</code>
+     * @param value The bytes for verdict to set.
+     * @return This builder for chaining.
+     */
+    public Builder setVerdictBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      verdict_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object condition_ = "";
+    /**
+     * <pre>
+     * The condition that evaluated to false, as written; empty otherwise.
+     * </pre>
+     *
+     * <code>string condition = 6 [json_name = "condition"];</code>
+     * @return The condition.
+     */
+    public java.lang.String getCondition() {
+      java.lang.Object ref = condition_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        condition_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The condition that evaluated to false, as written; empty otherwise.
+     * </pre>
+     *
+     * <code>string condition = 6 [json_name = "condition"];</code>
+     * @return The bytes for condition.
+     */
+    public com.google.protobuf.ByteString
+        getConditionBytes() {
+      java.lang.Object ref = condition_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        condition_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The condition that evaluated to false, as written; empty otherwise.
+     * </pre>
+     *
+     * <code>string condition = 6 [json_name = "condition"];</code>
+     * @param value The condition to set.
+     * @return This builder for chaining.
+     */
+    public Builder setCondition(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      condition_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The condition that evaluated to false, as written; empty otherwise.
+     * </pre>
+     *
+     * <code>string condition = 6 [json_name = "condition"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearCondition() {
+      condition_ = getDefaultInstance().getCondition();
+      bitField0_ = (bitField0_ & ~0x00000020);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The condition that evaluated to false, as written; empty otherwise.
+     * </pre>
+     *
+     * <code>string condition = 6 [json_name = "condition"];</code>
+     * @param value The bytes for condition to set.
+     * @return This builder for chaining.
+     */
+    public Builder setConditionBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      condition_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object reason_ = "";
+    /**
+     * <pre>
+     * Why the assertion is violated or undecided; empty when it holds.
+     * </pre>
+     *
+     * <code>string reason = 7 [json_name = "reason"];</code>
+     * @return The reason.
+     */
+    public java.lang.String getReason() {
+      java.lang.Object ref = reason_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        reason_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Why the assertion is violated or undecided; empty when it holds.
+     * </pre>
+     *
+     * <code>string reason = 7 [json_name = "reason"];</code>
+     * @return The bytes for reason.
+     */
+    public com.google.protobuf.ByteString
+        getReasonBytes() {
+      java.lang.Object ref = reason_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        reason_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Why the assertion is violated or undecided; empty when it holds.
+     * </pre>
+     *
+     * <code>string reason = 7 [json_name = "reason"];</code>
+     * @param value The reason to set.
+     * @return This builder for chaining.
+     */
+    public Builder setReason(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      reason_ = value;
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Why the assertion is violated or undecided; empty when it holds.
+     * </pre>
+     *
+     * <code>string reason = 7 [json_name = "reason"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearReason() {
+      reason_ = getDefaultInstance().getReason();
+      bitField0_ = (bitField0_ & ~0x00000040);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Why the assertion is violated or undecided; empty when it holds.
+     * </pre>
+     *
+     * <code>string reason = 7 [json_name = "reason"];</code>
+     * @param value The bytes for reason to set.
+     * @return This builder for chaining.
+     */
+    public Builder setReasonBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      reason_ = value;
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+
+    private com.google.protobuf.LazyStringArrayList verification_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    private void ensureVerificationIsMutable() {
+      if (!verification_.isModifiable()) {
+        verification_ = new com.google.protobuf.LazyStringArrayList(verification_);
+      }
+      bitField0_ |= 0x00000080;
+    }
+    /**
+     * <pre>
+     * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+     * verification cases verifying the requirement the row is about; a
+     * verification row's own kind. Empty for a constraint.
+     * </pre>
+     *
+     * <code>repeated string verification = 8 [json_name = "verification"];</code>
+     * @return A list containing the verification.
+     */
+    public com.google.protobuf.ProtocolStringList
+        getVerificationList() {
+      verification_.makeImmutable();
+      return verification_;
+    }
+    /**
+     * <pre>
+     * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+     * verification cases verifying the requirement the row is about; a
+     * verification row's own kind. Empty for a constraint.
+     * </pre>
+     *
+     * <code>repeated string verification = 8 [json_name = "verification"];</code>
+     * @return The count of verification.
+     */
+    public int getVerificationCount() {
+      return verification_.size();
+    }
+    /**
+     * <pre>
+     * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+     * verification cases verifying the requirement the row is about; a
+     * verification row's own kind. Empty for a constraint.
+     * </pre>
+     *
+     * <code>repeated string verification = 8 [json_name = "verification"];</code>
+     * @param index The index of the element to return.
+     * @return The verification at the given index.
+     */
+    public java.lang.String getVerification(int index) {
+      return verification_.get(index);
+    }
+    /**
+     * <pre>
+     * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+     * verification cases verifying the requirement the row is about; a
+     * verification row's own kind. Empty for a constraint.
+     * </pre>
+     *
+     * <code>repeated string verification = 8 [json_name = "verification"];</code>
+     * @param index The index of the value to return.
+     * @return The bytes of the verification at the given index.
+     */
+    public com.google.protobuf.ByteString
+        getVerificationBytes(int index) {
+      return verification_.getByteString(index);
+    }
+    /**
+     * <pre>
+     * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+     * verification cases verifying the requirement the row is about; a
+     * verification row's own kind. Empty for a constraint.
+     * </pre>
+     *
+     * <code>repeated string verification = 8 [json_name = "verification"];</code>
+     * @param index The index to set the value at.
+     * @param value The verification to set.
+     * @return This builder for chaining.
+     */
+    public Builder setVerification(
+        int index, java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureVerificationIsMutable();
+      verification_.set(index, value);
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+     * verification cases verifying the requirement the row is about; a
+     * verification row's own kind. Empty for a constraint.
+     * </pre>
+     *
+     * <code>repeated string verification = 8 [json_name = "verification"];</code>
+     * @param value The verification to add.
+     * @return This builder for chaining.
+     */
+    public Builder addVerification(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureVerificationIsMutable();
+      verification_.add(value);
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+     * verification cases verifying the requirement the row is about; a
+     * verification row's own kind. Empty for a constraint.
+     * </pre>
+     *
+     * <code>repeated string verification = 8 [json_name = "verification"];</code>
+     * @param values The verification to add.
+     * @return This builder for chaining.
+     */
+    public Builder addAllVerification(
+        java.lang.Iterable<java.lang.String> values) {
+      ensureVerificationIsMutable();
+      com.google.protobuf.AbstractMessageLite.Builder.addAll(
+          values, verification_);
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+     * verification cases verifying the requirement the row is about; a
+     * verification row's own kind. Empty for a constraint.
+     * </pre>
+     *
+     * <code>repeated string verification = 8 [json_name = "verification"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearVerification() {
+      verification_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+      bitField0_ = (bitField0_ & ~0x00000080);;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+     * verification cases verifying the requirement the row is about; a
+     * verification row's own kind. Empty for a constraint.
+     * </pre>
+     *
+     * <code>repeated string verification = 8 [json_name = "verification"];</code>
+     * @param value The bytes of the verification to add.
+     * @return This builder for chaining.
+     */
+    public Builder addVerificationBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      ensureVerificationIsMutable();
+      verification_.add(value);
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.DocumentVerdict)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.DocumentVerdict)
+  private static final org.openmbee.opensysml.proto.DocumentVerdict DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.DocumentVerdict();
+  }
+
+  public static org.openmbee.opensysml.proto.DocumentVerdict getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<DocumentVerdict>
+      PARSER = new com.google.protobuf.AbstractParser<DocumentVerdict>() {
+    @java.lang.Override
+    public DocumentVerdict parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<DocumentVerdict> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<DocumentVerdict> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DocumentVerdict getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentVerdictOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentVerdictOrBuilder.java
new file mode 100644
index 0000000000..f8ae3ec2a4
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentVerdictOrBuilder.java
@@ -0,0 +1,215 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface DocumentVerdictOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.DocumentVerdict)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * The assertion checked, as an element value: the constraint, requirement,
+   * satisfy usage or verification case, its element_id empty when anonymous.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+   * @return Whether the assertion field is set.
+   */
+  boolean hasAssertion();
+  /**
+   * <pre>
+   * The assertion checked, as an element value: the constraint, requirement,
+   * satisfy usage or verification case, its element_id empty when anonymous.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+   * @return The assertion.
+   */
+  org.openmbee.opensysml.proto.DocumentValue getAssertion();
+  /**
+   * <pre>
+   * The assertion checked, as an element value: the constraint, requirement,
+   * satisfy usage or verification case, its element_id empty when anonymous.
+   * </pre>
+   *
+   * <code>.sysml.DocumentValue assertion = 1 [json_name = "assertion"];</code>
+   */
+  org.openmbee.opensysml.proto.DocumentValueOrBuilder getAssertionOrBuilder();
+
+  /**
+   * <pre>
+   * "constraint", "requirement", "satisfaction" or "verification".
+   * </pre>
+   *
+   * <code>string kind = 2 [json_name = "kind"];</code>
+   * @return The kind.
+   */
+  java.lang.String getKind();
+  /**
+   * <pre>
+   * "constraint", "requirement", "satisfaction" or "verification".
+   * </pre>
+   *
+   * <code>string kind = 2 [json_name = "kind"];</code>
+   * @return The bytes for kind.
+   */
+  com.google.protobuf.ByteString
+      getKindBytes();
+
+  /**
+   * <pre>
+   * The assertion as written ("assert constraint massKnown", "satisfy Range by
+   * cruise", "verification Tests::massTest"), naming an anonymous one.
+   * </pre>
+   *
+   * <code>string text = 3 [json_name = "text"];</code>
+   * @return The text.
+   */
+  java.lang.String getText();
+  /**
+   * <pre>
+   * The assertion as written ("assert constraint massKnown", "satisfy Range by
+   * cruise", "verification Tests::massTest"), naming an anonymous one.
+   * </pre>
+   *
+   * <code>string text = 3 [json_name = "text"];</code>
+   * @return The bytes for text.
+   */
+  com.google.protobuf.ByteString
+      getTextBytes();
+
+  /**
+   * <pre>
+   * The object checked, by path from the element the query was bound to
+   * ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+   * </pre>
+   *
+   * <code>string path = 4 [json_name = "path"];</code>
+   * @return The path.
+   */
+  java.lang.String getPath();
+  /**
+   * <pre>
+   * The object checked, by path from the element the query was bound to
+   * ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+   * </pre>
+   *
+   * <code>string path = 4 [json_name = "path"];</code>
+   * @return The bytes for path.
+   */
+  com.google.protobuf.ByteString
+      getPathBytes();
+
+  /**
+   * <pre>
+   * "holds", "violated" or "undecided".
+   * </pre>
+   *
+   * <code>string verdict = 5 [json_name = "verdict"];</code>
+   * @return The verdict.
+   */
+  java.lang.String getVerdict();
+  /**
+   * <pre>
+   * "holds", "violated" or "undecided".
+   * </pre>
+   *
+   * <code>string verdict = 5 [json_name = "verdict"];</code>
+   * @return The bytes for verdict.
+   */
+  com.google.protobuf.ByteString
+      getVerdictBytes();
+
+  /**
+   * <pre>
+   * The condition that evaluated to false, as written; empty otherwise.
+   * </pre>
+   *
+   * <code>string condition = 6 [json_name = "condition"];</code>
+   * @return The condition.
+   */
+  java.lang.String getCondition();
+  /**
+   * <pre>
+   * The condition that evaluated to false, as written; empty otherwise.
+   * </pre>
+   *
+   * <code>string condition = 6 [json_name = "condition"];</code>
+   * @return The bytes for condition.
+   */
+  com.google.protobuf.ByteString
+      getConditionBytes();
+
+  /**
+   * <pre>
+   * Why the assertion is violated or undecided; empty when it holds.
+   * </pre>
+   *
+   * <code>string reason = 7 [json_name = "reason"];</code>
+   * @return The reason.
+   */
+  java.lang.String getReason();
+  /**
+   * <pre>
+   * Why the assertion is violated or undecided; empty when it holds.
+   * </pre>
+   *
+   * <code>string reason = 7 [json_name = "reason"];</code>
+   * @return The bytes for reason.
+   */
+  com.google.protobuf.ByteString
+      getReasonBytes();
+
+  /**
+   * <pre>
+   * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+   * verification cases verifying the requirement the row is about; a
+   * verification row's own kind. Empty for a constraint.
+   * </pre>
+   *
+   * <code>repeated string verification = 8 [json_name = "verification"];</code>
+   * @return A list containing the verification.
+   */
+  java.util.List<java.lang.String>
+      getVerificationList();
+  /**
+   * <pre>
+   * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+   * verification cases verifying the requirement the row is about; a
+   * verification row's own kind. Empty for a constraint.
+   * </pre>
+   *
+   * <code>repeated string verification = 8 [json_name = "verification"];</code>
+   * @return The count of verification.
+   */
+  int getVerificationCount();
+  /**
+   * <pre>
+   * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+   * verification cases verifying the requirement the row is about; a
+   * verification row's own kind. Empty for a constraint.
+   * </pre>
+   *
+   * <code>repeated string verification = 8 [json_name = "verification"];</code>
+   * @param index The index of the element to return.
+   * @return The verification at the given index.
+   */
+  java.lang.String getVerification(int index);
+  /**
+   * <pre>
+   * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+   * verification cases verifying the requirement the row is about; a
+   * verification row's own kind. Empty for a constraint.
+   * </pre>
+   *
+   * <code>repeated string verification = 8 [json_name = "verification"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the verification at the given index.
+   */
+  com.google.protobuf.ByteString
+      getVerificationBytes(int index);
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditFailure.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditFailure.java
new file mode 100644
index 0000000000..327ddc77cc
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditFailure.java
@@ -0,0 +1,439 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * EditFailure says why edits were refused. Every refusal is one of these: an
+ * edit is never silently dropped.
+ * </pre>
+ *
+ * Protobuf enum {@code sysml.EditFailure}
+ */
+@com.google.protobuf.Generated
+public enum EditFailure
+    implements com.google.protobuf.ProtocolMessageEnum {
+  /**
+   * <pre>
+   * no failure
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_UNSPECIFIED = 0;</code>
+   */
+  EDIT_FAILURE_UNSPECIFIED(0),
+  /**
+   * <pre>
+   * the request named no edit
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_NO_OPERATIONS = 1;</code>
+   */
+  EDIT_FAILURE_NO_OPERATIONS(1),
+  /**
+   * <pre>
+   * no element of that name in the model
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_UNKNOWN_TARGET = 2;</code>
+   */
+  EDIT_FAILURE_UNKNOWN_TARGET(2),
+  /**
+   * <pre>
+   * the name denotes several declarations
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_AMBIGUOUS_TARGET = 3;</code>
+   */
+  EDIT_FAILURE_AMBIGUOUS_TARGET(3),
+  /**
+   * <pre>
+   * the element can carry no value
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_NOT_VALUED = 4;</code>
+   */
+  EDIT_FAILURE_NOT_VALUED(4),
+  /**
+   * <pre>
+   * the new value does not parse as an expression
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_INVALID_VALUE = 5;</code>
+   */
+  EDIT_FAILURE_INVALID_VALUE(5),
+  /**
+   * <pre>
+   * the new name does not lex as an identifier
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_INVALID_NAME = 6;</code>
+   */
+  EDIT_FAILURE_INVALID_NAME(6),
+  /**
+   * <pre>
+   * the element declares no name to rewrite
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_NOT_NAMED = 7;</code>
+   */
+  EDIT_FAILURE_NOT_NAMED(7),
+  /**
+   * <pre>
+   * references to the element would break
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_RENAME_REFERENCED = 8;</code>
+   */
+  EDIT_FAILURE_RENAME_REFERENCED(8),
+  /**
+   * <pre>
+   * two edits cover the same bytes
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_OVERLAPPING_EDITS = 9;</code>
+   */
+  EDIT_FAILURE_OVERLAPPING_EDITS(9),
+  /**
+   * <pre>
+   * the edited source has errors the original had not
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_RESULT_INVALID = 10;</code>
+   */
+  EDIT_FAILURE_RESULT_INVALID(10),
+  /**
+   * <pre>
+   * add-member owner does not exist
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_OWNER_UNKNOWN = 11;</code>
+   */
+  EDIT_FAILURE_OWNER_UNKNOWN(11),
+  /**
+   * <pre>
+   * owner cannot contain members
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_OWNER_NOT_NAMESPACE = 12;</code>
+   */
+  EDIT_FAILURE_OWNER_NOT_NAMESPACE(12),
+  /**
+   * <pre>
+   * kind is invalid for document language
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_ILLEGAL_KIND = 13;</code>
+   */
+  EDIT_FAILURE_ILLEGAL_KIND(13),
+  /**
+   * <pre>
+   * owner already declares the name
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_MEMBER_NAME_TAKEN = 14;</code>
+   */
+  EDIT_FAILURE_MEMBER_NAME_TAKEN(14),
+  /**
+   * <pre>
+   * delete would leave references
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_DELETE_REFERENCED = 15;</code>
+   */
+  EDIT_FAILURE_DELETE_REFERENCED(15),
+  /**
+   * <pre>
+   * move owner is the target or inside it
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_OWNER_INSIDE_TARGET = 16;</code>
+   */
+  EDIT_FAILURE_OWNER_INSIDE_TARGET(16),
+  /**
+   * <pre>
+   * move would leave a reference no spelling restores
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_MOVE_REFERENCED = 17;</code>
+   */
+  EDIT_FAILURE_MOVE_REFERENCED(17),
+  /**
+   * <pre>
+   * the target is referred to from a document the edit cannot rewrite: a
+   * library document, or any other document for a move, which respells
+   * references in the target's own document only
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_REFERENCED_ELSEWHERE = 18;</code>
+   */
+  EDIT_FAILURE_REFERENCED_ELSEWHERE(18),
+  UNRECOGNIZED(-1),
+  ;
+
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "EditFailure");
+  }
+  /**
+   * <pre>
+   * no failure
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_UNSPECIFIED = 0;</code>
+   */
+  public static final int EDIT_FAILURE_UNSPECIFIED_VALUE = 0;
+  /**
+   * <pre>
+   * the request named no edit
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_NO_OPERATIONS = 1;</code>
+   */
+  public static final int EDIT_FAILURE_NO_OPERATIONS_VALUE = 1;
+  /**
+   * <pre>
+   * no element of that name in the model
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_UNKNOWN_TARGET = 2;</code>
+   */
+  public static final int EDIT_FAILURE_UNKNOWN_TARGET_VALUE = 2;
+  /**
+   * <pre>
+   * the name denotes several declarations
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_AMBIGUOUS_TARGET = 3;</code>
+   */
+  public static final int EDIT_FAILURE_AMBIGUOUS_TARGET_VALUE = 3;
+  /**
+   * <pre>
+   * the element can carry no value
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_NOT_VALUED = 4;</code>
+   */
+  public static final int EDIT_FAILURE_NOT_VALUED_VALUE = 4;
+  /**
+   * <pre>
+   * the new value does not parse as an expression
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_INVALID_VALUE = 5;</code>
+   */
+  public static final int EDIT_FAILURE_INVALID_VALUE_VALUE = 5;
+  /**
+   * <pre>
+   * the new name does not lex as an identifier
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_INVALID_NAME = 6;</code>
+   */
+  public static final int EDIT_FAILURE_INVALID_NAME_VALUE = 6;
+  /**
+   * <pre>
+   * the element declares no name to rewrite
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_NOT_NAMED = 7;</code>
+   */
+  public static final int EDIT_FAILURE_NOT_NAMED_VALUE = 7;
+  /**
+   * <pre>
+   * references to the element would break
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_RENAME_REFERENCED = 8;</code>
+   */
+  public static final int EDIT_FAILURE_RENAME_REFERENCED_VALUE = 8;
+  /**
+   * <pre>
+   * two edits cover the same bytes
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_OVERLAPPING_EDITS = 9;</code>
+   */
+  public static final int EDIT_FAILURE_OVERLAPPING_EDITS_VALUE = 9;
+  /**
+   * <pre>
+   * the edited source has errors the original had not
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_RESULT_INVALID = 10;</code>
+   */
+  public static final int EDIT_FAILURE_RESULT_INVALID_VALUE = 10;
+  /**
+   * <pre>
+   * add-member owner does not exist
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_OWNER_UNKNOWN = 11;</code>
+   */
+  public static final int EDIT_FAILURE_OWNER_UNKNOWN_VALUE = 11;
+  /**
+   * <pre>
+   * owner cannot contain members
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_OWNER_NOT_NAMESPACE = 12;</code>
+   */
+  public static final int EDIT_FAILURE_OWNER_NOT_NAMESPACE_VALUE = 12;
+  /**
+   * <pre>
+   * kind is invalid for document language
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_ILLEGAL_KIND = 13;</code>
+   */
+  public static final int EDIT_FAILURE_ILLEGAL_KIND_VALUE = 13;
+  /**
+   * <pre>
+   * owner already declares the name
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_MEMBER_NAME_TAKEN = 14;</code>
+   */
+  public static final int EDIT_FAILURE_MEMBER_NAME_TAKEN_VALUE = 14;
+  /**
+   * <pre>
+   * delete would leave references
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_DELETE_REFERENCED = 15;</code>
+   */
+  public static final int EDIT_FAILURE_DELETE_REFERENCED_VALUE = 15;
+  /**
+   * <pre>
+   * move owner is the target or inside it
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_OWNER_INSIDE_TARGET = 16;</code>
+   */
+  public static final int EDIT_FAILURE_OWNER_INSIDE_TARGET_VALUE = 16;
+  /**
+   * <pre>
+   * move would leave a reference no spelling restores
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_MOVE_REFERENCED = 17;</code>
+   */
+  public static final int EDIT_FAILURE_MOVE_REFERENCED_VALUE = 17;
+  /**
+   * <pre>
+   * the target is referred to from a document the edit cannot rewrite: a
+   * library document, or any other document for a move, which respells
+   * references in the target's own document only
+   * </pre>
+   *
+   * <code>EDIT_FAILURE_REFERENCED_ELSEWHERE = 18;</code>
+   */
+  public static final int EDIT_FAILURE_REFERENCED_ELSEWHERE_VALUE = 18;
+
+
+  public final int getNumber() {
+    if (this == UNRECOGNIZED) {
+      throw new java.lang.IllegalArgumentException(
+          "Can't get the number of an unknown enum value.");
+    }
+    return value;
+  }
+
+  /**
+   * @param value The numeric wire value of the corresponding enum entry.
+   * @return The enum associated with the given numeric wire value.
+   * @deprecated Use {@link #forNumber(int)} instead.
+   */
+  @java.lang.Deprecated
+  public static EditFailure valueOf(int value) {
+    return forNumber(value);
+  }
+
+  /**
+   * @param value The numeric wire value of the corresponding enum entry.
+   * @return The enum associated with the given numeric wire value.
+   */
+  public static EditFailure forNumber(int value) {
+    switch (value) {
+      case 0: return EDIT_FAILURE_UNSPECIFIED;
+      case 1: return EDIT_FAILURE_NO_OPERATIONS;
+      case 2: return EDIT_FAILURE_UNKNOWN_TARGET;
+      case 3: return EDIT_FAILURE_AMBIGUOUS_TARGET;
+      case 4: return EDIT_FAILURE_NOT_VALUED;
+      case 5: return EDIT_FAILURE_INVALID_VALUE;
+      case 6: return EDIT_FAILURE_INVALID_NAME;
+      case 7: return EDIT_FAILURE_NOT_NAMED;
+      case 8: return EDIT_FAILURE_RENAME_REFERENCED;
+      case 9: return EDIT_FAILURE_OVERLAPPING_EDITS;
+      case 10: return EDIT_FAILURE_RESULT_INVALID;
+      case 11: return EDIT_FAILURE_OWNER_UNKNOWN;
+      case 12: return EDIT_FAILURE_OWNER_NOT_NAMESPACE;
+      case 13: return EDIT_FAILURE_ILLEGAL_KIND;
+      case 14: return EDIT_FAILURE_MEMBER_NAME_TAKEN;
+      case 15: return EDIT_FAILURE_DELETE_REFERENCED;
+      case 16: return EDIT_FAILURE_OWNER_INSIDE_TARGET;
+      case 17: return EDIT_FAILURE_MOVE_REFERENCED;
+      case 18: return EDIT_FAILURE_REFERENCED_ELSEWHERE;
+      default: return null;
+    }
+  }
+
+  public static com.google.protobuf.Internal.EnumLiteMap<EditFailure>
+      internalGetValueMap() {
+    return internalValueMap;
+  }
+  private static final com.google.protobuf.Internal.EnumLiteMap<
+      EditFailure> internalValueMap =
+        new com.google.protobuf.Internal.EnumLiteMap<EditFailure>() {
+          public EditFailure findValueByNumber(int number) {
+            return EditFailure.forNumber(number);
+          }
+        };
+
+  public final com.google.protobuf.Descriptors.EnumValueDescriptor
+      getValueDescriptor() {
+    if (this == UNRECOGNIZED) {
+      throw new java.lang.IllegalStateException(
+          "Can't get the descriptor of an unrecognized enum value.");
+    }
+    return getDescriptor().getValues().get(ordinal());
+  }
+  public final com.google.protobuf.Descriptors.EnumDescriptor
+      getDescriptorForType() {
+    return getDescriptor();
+  }
+  public static com.google.protobuf.Descriptors.EnumDescriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.getDescriptor().getEnumTypes().get(1);
+  }
+
+  private static final EditFailure[] VALUES = values();
+
+  public static EditFailure valueOf(
+      com.google.protobuf.Descriptors.EnumValueDescriptor desc) {
+    if (desc.getType() != getDescriptor()) {
+      throw new java.lang.IllegalArgumentException(
+        "EnumValueDescriptor is not for this type.");
+    }
+    if (desc.getIndex() == -1) {
+      return UNRECOGNIZED;
+    }
+    return VALUES[desc.getIndex()];
+  }
+
+  private final int value;
+
+  private EditFailure(int value) {
+    this.value = value;
+  }
+
+  // @@protoc_insertion_point(enum_scope:sysml.EditFailure)
+}
+
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditOperation.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditOperation.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditOperation.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditOperation.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditOperationOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditOperationOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditOperationOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditOperationOrBuilder.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditedDocument.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditedDocument.java
new file mode 100644
index 0000000000..9f3effa6bc
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditedDocument.java
@@ -0,0 +1,716 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * EditedDocument is the edited notation of one document of the model.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.EditedDocument}
+ */
+@com.google.protobuf.Generated
+public final class EditedDocument extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.EditedDocument)
+    EditedDocumentOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "EditedDocument");
+  }
+  // Use EditedDocument.newBuilder() to construct.
+  private EditedDocument(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private EditedDocument() {
+    name_ = "";
+    content_ = "";
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_EditedDocument_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_EditedDocument_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.EditedDocument.class, org.openmbee.opensysml.proto.EditedDocument.Builder.class);
+  }
+
+  public static final int NAME_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object name_ = "";
+  /**
+   * <pre>
+   * The document's name as the parse request gave it: the file path of a
+   * file_path document, the name of an inline one, or the position it was
+   * given when it was named neither.
+   * </pre>
+   *
+   * <code>string name = 1 [json_name = "name"];</code>
+   * @return The name.
+   */
+  @java.lang.Override
+  public java.lang.String getName() {
+    java.lang.Object ref = name_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      name_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The document's name as the parse request gave it: the file path of a
+   * file_path document, the name of an inline one, or the position it was
+   * given when it was named neither.
+   * </pre>
+   *
+   * <code>string name = 1 [json_name = "name"];</code>
+   * @return The bytes for name.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getNameBytes() {
+    java.lang.Object ref = name_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      name_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int CONTENT_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object content_ = "";
+  /**
+   * <pre>
+   * The edited notation, byte-identical to the source outside the edited spans.
+   * </pre>
+   *
+   * <code>string content = 2 [json_name = "content"];</code>
+   * @return The content.
+   */
+  @java.lang.Override
+  public java.lang.String getContent() {
+    java.lang.Object ref = content_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      content_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The edited notation, byte-identical to the source outside the edited spans.
+   * </pre>
+   *
+   * <code>string content = 2 [json_name = "content"];</code>
+   * @return The bytes for content.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getContentBytes() {
+    java.lang.Object ref = content_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      content_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(name_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 1, name_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(content_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, content_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(name_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, name_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(content_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, content_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.EditedDocument)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.EditedDocument other = (org.openmbee.opensysml.proto.EditedDocument) obj;
+
+    if (!getName()
+        .equals(other.getName())) return false;
+    if (!getContent()
+        .equals(other.getContent())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + NAME_FIELD_NUMBER;
+    hash = (53 * hash) + getName().hashCode();
+    hash = (37 * hash) + CONTENT_FIELD_NUMBER;
+    hash = (53 * hash) + getContent().hashCode();
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.EditedDocument parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.EditedDocument parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.EditedDocument parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.EditedDocument parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.EditedDocument parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.EditedDocument parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.EditedDocument parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.EditedDocument parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.EditedDocument parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.EditedDocument parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.EditedDocument parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.EditedDocument parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.EditedDocument prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * EditedDocument is the edited notation of one document of the model.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.EditedDocument}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.EditedDocument)
+      org.openmbee.opensysml.proto.EditedDocumentOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_EditedDocument_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_EditedDocument_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.EditedDocument.class, org.openmbee.opensysml.proto.EditedDocument.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.EditedDocument.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      name_ = "";
+      content_ = "";
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_EditedDocument_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.EditedDocument getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.EditedDocument.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.EditedDocument build() {
+      org.openmbee.opensysml.proto.EditedDocument result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.EditedDocument buildPartial() {
+      org.openmbee.opensysml.proto.EditedDocument result = new org.openmbee.opensysml.proto.EditedDocument(this);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.EditedDocument result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.name_ = name_;
+      }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.content_ = content_;
+      }
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.EditedDocument) {
+        return mergeFrom((org.openmbee.opensysml.proto.EditedDocument)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.EditedDocument other) {
+      if (other == org.openmbee.opensysml.proto.EditedDocument.getDefaultInstance()) return this;
+      if (!other.getName().isEmpty()) {
+        name_ = other.name_;
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      if (!other.getContent().isEmpty()) {
+        content_ = other.content_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              name_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              content_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.lang.Object name_ = "";
+    /**
+     * <pre>
+     * The document's name as the parse request gave it: the file path of a
+     * file_path document, the name of an inline one, or the position it was
+     * given when it was named neither.
+     * </pre>
+     *
+     * <code>string name = 1 [json_name = "name"];</code>
+     * @return The name.
+     */
+    public java.lang.String getName() {
+      java.lang.Object ref = name_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        name_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The document's name as the parse request gave it: the file path of a
+     * file_path document, the name of an inline one, or the position it was
+     * given when it was named neither.
+     * </pre>
+     *
+     * <code>string name = 1 [json_name = "name"];</code>
+     * @return The bytes for name.
+     */
+    public com.google.protobuf.ByteString
+        getNameBytes() {
+      java.lang.Object ref = name_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        name_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The document's name as the parse request gave it: the file path of a
+     * file_path document, the name of an inline one, or the position it was
+     * given when it was named neither.
+     * </pre>
+     *
+     * <code>string name = 1 [json_name = "name"];</code>
+     * @param value The name to set.
+     * @return This builder for chaining.
+     */
+    public Builder setName(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      name_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The document's name as the parse request gave it: the file path of a
+     * file_path document, the name of an inline one, or the position it was
+     * given when it was named neither.
+     * </pre>
+     *
+     * <code>string name = 1 [json_name = "name"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearName() {
+      name_ = getDefaultInstance().getName();
+      bitField0_ = (bitField0_ & ~0x00000001);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The document's name as the parse request gave it: the file path of a
+     * file_path document, the name of an inline one, or the position it was
+     * given when it was named neither.
+     * </pre>
+     *
+     * <code>string name = 1 [json_name = "name"];</code>
+     * @param value The bytes for name to set.
+     * @return This builder for chaining.
+     */
+    public Builder setNameBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      name_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object content_ = "";
+    /**
+     * <pre>
+     * The edited notation, byte-identical to the source outside the edited spans.
+     * </pre>
+     *
+     * <code>string content = 2 [json_name = "content"];</code>
+     * @return The content.
+     */
+    public java.lang.String getContent() {
+      java.lang.Object ref = content_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        content_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The edited notation, byte-identical to the source outside the edited spans.
+     * </pre>
+     *
+     * <code>string content = 2 [json_name = "content"];</code>
+     * @return The bytes for content.
+     */
+    public com.google.protobuf.ByteString
+        getContentBytes() {
+      java.lang.Object ref = content_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        content_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The edited notation, byte-identical to the source outside the edited spans.
+     * </pre>
+     *
+     * <code>string content = 2 [json_name = "content"];</code>
+     * @param value The content to set.
+     * @return This builder for chaining.
+     */
+    public Builder setContent(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      content_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation, byte-identical to the source outside the edited spans.
+     * </pre>
+     *
+     * <code>string content = 2 [json_name = "content"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearContent() {
+      content_ = getDefaultInstance().getContent();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The edited notation, byte-identical to the source outside the edited spans.
+     * </pre>
+     *
+     * <code>string content = 2 [json_name = "content"];</code>
+     * @param value The bytes for content to set.
+     * @return This builder for chaining.
+     */
+    public Builder setContentBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      content_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.EditedDocument)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.EditedDocument)
+  private static final org.openmbee.opensysml.proto.EditedDocument DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.EditedDocument();
+  }
+
+  public static org.openmbee.opensysml.proto.EditedDocument getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<EditedDocument>
+      PARSER = new com.google.protobuf.AbstractParser<EditedDocument>() {
+    @java.lang.Override
+    public EditedDocument parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<EditedDocument> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<EditedDocument> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.EditedDocument getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditedDocumentOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditedDocumentOrBuilder.java
new file mode 100644
index 0000000000..9835557e00
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditedDocumentOrBuilder.java
@@ -0,0 +1,56 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface EditedDocumentOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.EditedDocument)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * The document's name as the parse request gave it: the file path of a
+   * file_path document, the name of an inline one, or the position it was
+   * given when it was named neither.
+   * </pre>
+   *
+   * <code>string name = 1 [json_name = "name"];</code>
+   * @return The name.
+   */
+  java.lang.String getName();
+  /**
+   * <pre>
+   * The document's name as the parse request gave it: the file path of a
+   * file_path document, the name of an inline one, or the position it was
+   * given when it was named neither.
+   * </pre>
+   *
+   * <code>string name = 1 [json_name = "name"];</code>
+   * @return The bytes for name.
+   */
+  com.google.protobuf.ByteString
+      getNameBytes();
+
+  /**
+   * <pre>
+   * The edited notation, byte-identical to the source outside the edited spans.
+   * </pre>
+   *
+   * <code>string content = 2 [json_name = "content"];</code>
+   * @return The content.
+   */
+  java.lang.String getContent();
+  /**
+   * <pre>
+   * The edited notation, byte-identical to the source outside the edited spans.
+   * </pre>
+   *
+   * <code>string content = 2 [json_name = "content"];</code>
+   * @return The bytes for content.
+   */
+  com.google.protobuf.ByteString
+      getContentBytes();
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EngineInfo.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EngineInfo.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EngineInfo.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EngineInfo.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EngineInfoOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EngineInfoOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EngineInfoOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EngineInfoOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EnumLiteral.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EnumLiteral.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EnumLiteral.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EnumLiteral.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EnumLiteralOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EnumLiteralOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EnumLiteralOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EnumLiteralOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcRequest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcRequest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateCalcResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateRequest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateRequest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EvaluateResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequest.java
similarity index 85%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequest.java
index 47decd3f52..10d797dbbc 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequest.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequest.java
@@ -35,6 +35,7 @@ private ExecuteActionRequest() {
     modelHash_ = "";
     actionSymbolId_ = "";
     schedule_ = "";
+    performerSymbolId_ = "";
   }
 
   public static final com.google.protobuf.Descriptors.Descriptor
@@ -300,6 +301,61 @@ public java.lang.String getSchedule() {
     }
   }
 
+  public static final int PERFORMER_SYMBOL_ID_FIELD_NUMBER = 5;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object performerSymbolId_ = "";
+  /**
+   * <pre>
+   * Optional FQN of a part/usage to perform the action on, or a path from one to
+   * a nested object, as `Mission::mission.vehicle`: the object is created for the
+   * run — inside the assembly the path walks, so its connectors carry what the
+   * action sends — and under explore each run creates its own. Empty performs
+   * the action outside any object.
+   * </pre>
+   *
+   * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+   * @return The performerSymbolId.
+   */
+  @java.lang.Override
+  public java.lang.String getPerformerSymbolId() {
+    java.lang.Object ref = performerSymbolId_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      performerSymbolId_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * Optional FQN of a part/usage to perform the action on, or a path from one to
+   * a nested object, as `Mission::mission.vehicle`: the object is created for the
+   * run — inside the assembly the path walks, so its connectors carry what the
+   * action sends — and under explore each run creates its own. Empty performs
+   * the action outside any object.
+   * </pre>
+   *
+   * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+   * @return The bytes for performerSymbolId.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getPerformerSymbolIdBytes() {
+    java.lang.Object ref = performerSymbolId_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      performerSymbolId_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
   private byte memoizedIsInitialized = -1;
   @java.lang.Override
   public final boolean isInitialized() {
@@ -329,6 +385,9 @@ public void writeTo(com.google.protobuf.CodedOutputStream output)
     if (!com.google.protobuf.GeneratedMessage.isStringEmpty(schedule_)) {
       com.google.protobuf.GeneratedMessage.writeString(output, 4, schedule_);
     }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(performerSymbolId_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 5, performerSymbolId_);
+    }
     getUnknownFields().writeTo(output);
   }
 
@@ -357,6 +416,9 @@ public int getSerializedSize() {
     if (!com.google.protobuf.GeneratedMessage.isStringEmpty(schedule_)) {
       size += com.google.protobuf.GeneratedMessage.computeStringSize(4, schedule_);
     }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(performerSymbolId_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(5, performerSymbolId_);
+    }
     size += getUnknownFields().getSerializedSize();
     memoizedSize = size;
     return size;
@@ -380,6 +442,8 @@ public boolean equals(final java.lang.Object obj) {
         other.internalGetInputs())) return false;
     if (!getSchedule()
         .equals(other.getSchedule())) return false;
+    if (!getPerformerSymbolId()
+        .equals(other.getPerformerSymbolId())) return false;
     if (!getUnknownFields().equals(other.getUnknownFields())) return false;
     return true;
   }
@@ -401,6 +465,8 @@ public int hashCode() {
     }
     hash = (37 * hash) + SCHEDULE_FIELD_NUMBER;
     hash = (53 * hash) + getSchedule().hashCode();
+    hash = (37 * hash) + PERFORMER_SYMBOL_ID_FIELD_NUMBER;
+    hash = (53 * hash) + getPerformerSymbolId().hashCode();
     hash = (29 * hash) + getUnknownFields().hashCode();
     memoizedHashCode = hash;
     return hash;
@@ -562,6 +628,7 @@ public Builder clear() {
       actionSymbolId_ = "";
       internalGetMutableInputs().clear();
       schedule_ = "";
+      performerSymbolId_ = "";
       return this;
     }
 
@@ -607,6 +674,9 @@ private void buildPartial0(org.openmbee.opensysml.proto.ExecuteActionRequest res
       if (((from_bitField0_ & 0x00000008) != 0)) {
         result.schedule_ = schedule_;
       }
+      if (((from_bitField0_ & 0x00000010) != 0)) {
+        result.performerSymbolId_ = performerSymbolId_;
+      }
     }
 
     @java.lang.Override
@@ -639,6 +709,11 @@ public Builder mergeFrom(org.openmbee.opensysml.proto.ExecuteActionRequest other
         bitField0_ |= 0x00000008;
         onChanged();
       }
+      if (!other.getPerformerSymbolId().isEmpty()) {
+        performerSymbolId_ = other.performerSymbolId_;
+        bitField0_ |= 0x00000010;
+        onChanged();
+      }
       this.mergeUnknownFields(other.getUnknownFields());
       onChanged();
       return this;
@@ -689,6 +764,11 @@ public Builder mergeFrom(
               bitField0_ |= 0x00000008;
               break;
             } // case 34
+            case 42: {
+              performerSymbolId_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000010;
+              break;
+            } // case 42
             default: {
               if (!super.parseUnknownField(input, extensionRegistry, tag)) {
                 done = true; // was an endgroup tag
@@ -1174,6 +1254,118 @@ public Builder setScheduleBytes(
       return this;
     }
 
+    private java.lang.Object performerSymbolId_ = "";
+    /**
+     * <pre>
+     * Optional FQN of a part/usage to perform the action on, or a path from one to
+     * a nested object, as `Mission::mission.vehicle`: the object is created for the
+     * run — inside the assembly the path walks, so its connectors carry what the
+     * action sends — and under explore each run creates its own. Empty performs
+     * the action outside any object.
+     * </pre>
+     *
+     * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+     * @return The performerSymbolId.
+     */
+    public java.lang.String getPerformerSymbolId() {
+      java.lang.Object ref = performerSymbolId_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        performerSymbolId_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Optional FQN of a part/usage to perform the action on, or a path from one to
+     * a nested object, as `Mission::mission.vehicle`: the object is created for the
+     * run — inside the assembly the path walks, so its connectors carry what the
+     * action sends — and under explore each run creates its own. Empty performs
+     * the action outside any object.
+     * </pre>
+     *
+     * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+     * @return The bytes for performerSymbolId.
+     */
+    public com.google.protobuf.ByteString
+        getPerformerSymbolIdBytes() {
+      java.lang.Object ref = performerSymbolId_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        performerSymbolId_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Optional FQN of a part/usage to perform the action on, or a path from one to
+     * a nested object, as `Mission::mission.vehicle`: the object is created for the
+     * run — inside the assembly the path walks, so its connectors carry what the
+     * action sends — and under explore each run creates its own. Empty performs
+     * the action outside any object.
+     * </pre>
+     *
+     * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+     * @param value The performerSymbolId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setPerformerSymbolId(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      performerSymbolId_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Optional FQN of a part/usage to perform the action on, or a path from one to
+     * a nested object, as `Mission::mission.vehicle`: the object is created for the
+     * run — inside the assembly the path walks, so its connectors carry what the
+     * action sends — and under explore each run creates its own. Empty performs
+     * the action outside any object.
+     * </pre>
+     *
+     * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearPerformerSymbolId() {
+      performerSymbolId_ = getDefaultInstance().getPerformerSymbolId();
+      bitField0_ = (bitField0_ & ~0x00000010);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Optional FQN of a part/usage to perform the action on, or a path from one to
+     * a nested object, as `Mission::mission.vehicle`: the object is created for the
+     * run — inside the assembly the path walks, so its connectors carry what the
+     * action sends — and under explore each run creates its own. Empty performs
+     * the action outside any object.
+     * </pre>
+     *
+     * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+     * @param value The bytes for performerSymbolId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setPerformerSymbolIdBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      performerSymbolId_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+
     // @@protoc_insertion_point(builder_scope:sysml.ExecuteActionRequest)
   }
 
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequestOrBuilder.java
similarity index 76%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequestOrBuilder.java
index 13f2191498..7c68020f86 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequestOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionRequestOrBuilder.java
@@ -125,4 +125,32 @@ org.openmbee.opensysml.proto.Value getInputsOrThrow(
    */
   com.google.protobuf.ByteString
       getScheduleBytes();
+
+  /**
+   * <pre>
+   * Optional FQN of a part/usage to perform the action on, or a path from one to
+   * a nested object, as `Mission::mission.vehicle`: the object is created for the
+   * run — inside the assembly the path walks, so its connectors carry what the
+   * action sends — and under explore each run creates its own. Empty performs
+   * the action outside any object.
+   * </pre>
+   *
+   * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+   * @return The performerSymbolId.
+   */
+  java.lang.String getPerformerSymbolId();
+  /**
+   * <pre>
+   * Optional FQN of a part/usage to perform the action on, or a path from one to
+   * a nested object, as `Mission::mission.vehicle`: the object is created for the
+   * run — inside the assembly the path walks, so its connectors carry what the
+   * action sends — and under explore each run creates its own. Empty performs
+   * the action outside any object.
+   * </pre>
+   *
+   * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+   * @return The bytes for performerSymbolId.
+   */
+  com.google.protobuf.ByteString
+      getPerformerSymbolIdBytes();
 }
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteActionResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequest.java
similarity index 82%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequest.java
index bddfaaf0b8..6f892963b0 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequest.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequest.java
@@ -37,6 +37,7 @@ private ExecuteStateRequest() {
     events_ =
         com.google.protobuf.LazyStringArrayList.emptyList();
     schedule_ = "";
+    performerSymbolId_ = "";
   }
 
   public static final com.google.protobuf.Descriptors.Descriptor
@@ -238,6 +239,61 @@ public java.lang.String getSchedule() {
     }
   }
 
+  public static final int PERFORMER_SYMBOL_ID_FIELD_NUMBER = 5;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object performerSymbolId_ = "";
+  /**
+   * <pre>
+   * Optional FQN of a part/usage to run the machine on, or a path from one to a
+   * nested object (see ExecuteActionRequest.performer_symbol_id). An object
+   * exhibiting the machine runs the one it exhibits, so what its assembly
+   * connects to it reaches the run; one exhibiting it under several usages is
+   * an error. Empty runs the machine outside any object.
+   * </pre>
+   *
+   * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+   * @return The performerSymbolId.
+   */
+  @java.lang.Override
+  public java.lang.String getPerformerSymbolId() {
+    java.lang.Object ref = performerSymbolId_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      performerSymbolId_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * Optional FQN of a part/usage to run the machine on, or a path from one to a
+   * nested object (see ExecuteActionRequest.performer_symbol_id). An object
+   * exhibiting the machine runs the one it exhibits, so what its assembly
+   * connects to it reaches the run; one exhibiting it under several usages is
+   * an error. Empty runs the machine outside any object.
+   * </pre>
+   *
+   * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+   * @return The bytes for performerSymbolId.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getPerformerSymbolIdBytes() {
+    java.lang.Object ref = performerSymbolId_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      performerSymbolId_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
   private byte memoizedIsInitialized = -1;
   @java.lang.Override
   public final boolean isInitialized() {
@@ -264,6 +320,9 @@ public void writeTo(com.google.protobuf.CodedOutputStream output)
     if (!com.google.protobuf.GeneratedMessage.isStringEmpty(schedule_)) {
       com.google.protobuf.GeneratedMessage.writeString(output, 4, schedule_);
     }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(performerSymbolId_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 5, performerSymbolId_);
+    }
     getUnknownFields().writeTo(output);
   }
 
@@ -290,6 +349,9 @@ public int getSerializedSize() {
     if (!com.google.protobuf.GeneratedMessage.isStringEmpty(schedule_)) {
       size += com.google.protobuf.GeneratedMessage.computeStringSize(4, schedule_);
     }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(performerSymbolId_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(5, performerSymbolId_);
+    }
     size += getUnknownFields().getSerializedSize();
     memoizedSize = size;
     return size;
@@ -313,6 +375,8 @@ public boolean equals(final java.lang.Object obj) {
         .equals(other.getEventsList())) return false;
     if (!getSchedule()
         .equals(other.getSchedule())) return false;
+    if (!getPerformerSymbolId()
+        .equals(other.getPerformerSymbolId())) return false;
     if (!getUnknownFields().equals(other.getUnknownFields())) return false;
     return true;
   }
@@ -334,6 +398,8 @@ public int hashCode() {
     }
     hash = (37 * hash) + SCHEDULE_FIELD_NUMBER;
     hash = (53 * hash) + getSchedule().hashCode();
+    hash = (37 * hash) + PERFORMER_SYMBOL_ID_FIELD_NUMBER;
+    hash = (53 * hash) + getPerformerSymbolId().hashCode();
     hash = (29 * hash) + getUnknownFields().hashCode();
     memoizedHashCode = hash;
     return hash;
@@ -474,6 +540,7 @@ public Builder clear() {
       events_ =
           com.google.protobuf.LazyStringArrayList.emptyList();
       schedule_ = "";
+      performerSymbolId_ = "";
       return this;
     }
 
@@ -520,6 +587,9 @@ private void buildPartial0(org.openmbee.opensysml.proto.ExecuteStateRequest resu
       if (((from_bitField0_ & 0x00000008) != 0)) {
         result.schedule_ = schedule_;
       }
+      if (((from_bitField0_ & 0x00000010) != 0)) {
+        result.performerSymbolId_ = performerSymbolId_;
+      }
     }
 
     @java.lang.Override
@@ -559,6 +629,11 @@ public Builder mergeFrom(org.openmbee.opensysml.proto.ExecuteStateRequest other)
         bitField0_ |= 0x00000008;
         onChanged();
       }
+      if (!other.getPerformerSymbolId().isEmpty()) {
+        performerSymbolId_ = other.performerSymbolId_;
+        bitField0_ |= 0x00000010;
+        onChanged();
+      }
       this.mergeUnknownFields(other.getUnknownFields());
       onChanged();
       return this;
@@ -606,6 +681,11 @@ public Builder mergeFrom(
               bitField0_ |= 0x00000008;
               break;
             } // case 34
+            case 42: {
+              performerSymbolId_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000010;
+              break;
+            } // case 42
             default: {
               if (!super.parseUnknownField(input, extensionRegistry, tag)) {
                 done = true; // was an endgroup tag
@@ -1026,6 +1106,118 @@ public Builder setScheduleBytes(
       return this;
     }
 
+    private java.lang.Object performerSymbolId_ = "";
+    /**
+     * <pre>
+     * Optional FQN of a part/usage to run the machine on, or a path from one to a
+     * nested object (see ExecuteActionRequest.performer_symbol_id). An object
+     * exhibiting the machine runs the one it exhibits, so what its assembly
+     * connects to it reaches the run; one exhibiting it under several usages is
+     * an error. Empty runs the machine outside any object.
+     * </pre>
+     *
+     * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+     * @return The performerSymbolId.
+     */
+    public java.lang.String getPerformerSymbolId() {
+      java.lang.Object ref = performerSymbolId_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        performerSymbolId_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Optional FQN of a part/usage to run the machine on, or a path from one to a
+     * nested object (see ExecuteActionRequest.performer_symbol_id). An object
+     * exhibiting the machine runs the one it exhibits, so what its assembly
+     * connects to it reaches the run; one exhibiting it under several usages is
+     * an error. Empty runs the machine outside any object.
+     * </pre>
+     *
+     * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+     * @return The bytes for performerSymbolId.
+     */
+    public com.google.protobuf.ByteString
+        getPerformerSymbolIdBytes() {
+      java.lang.Object ref = performerSymbolId_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        performerSymbolId_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Optional FQN of a part/usage to run the machine on, or a path from one to a
+     * nested object (see ExecuteActionRequest.performer_symbol_id). An object
+     * exhibiting the machine runs the one it exhibits, so what its assembly
+     * connects to it reaches the run; one exhibiting it under several usages is
+     * an error. Empty runs the machine outside any object.
+     * </pre>
+     *
+     * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+     * @param value The performerSymbolId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setPerformerSymbolId(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      performerSymbolId_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Optional FQN of a part/usage to run the machine on, or a path from one to a
+     * nested object (see ExecuteActionRequest.performer_symbol_id). An object
+     * exhibiting the machine runs the one it exhibits, so what its assembly
+     * connects to it reaches the run; one exhibiting it under several usages is
+     * an error. Empty runs the machine outside any object.
+     * </pre>
+     *
+     * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearPerformerSymbolId() {
+      performerSymbolId_ = getDefaultInstance().getPerformerSymbolId();
+      bitField0_ = (bitField0_ & ~0x00000010);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Optional FQN of a part/usage to run the machine on, or a path from one to a
+     * nested object (see ExecuteActionRequest.performer_symbol_id). An object
+     * exhibiting the machine runs the one it exhibits, so what its assembly
+     * connects to it reaches the run; one exhibiting it under several usages is
+     * an error. Empty runs the machine outside any object.
+     * </pre>
+     *
+     * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+     * @param value The bytes for performerSymbolId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setPerformerSymbolIdBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      performerSymbolId_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+
     // @@protoc_insertion_point(builder_scope:sysml.ExecuteStateRequest)
   }
 
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequestOrBuilder.java
new file mode 100644
index 0000000000..ad054995e1
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequestOrBuilder.java
@@ -0,0 +1,133 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface ExecuteStateRequestOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.ExecuteStateRequest)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The modelHash.
+   */
+  java.lang.String getModelHash();
+  /**
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The bytes for modelHash.
+   */
+  com.google.protobuf.ByteString
+      getModelHashBytes();
+
+  /**
+   * <code>string state_machine_symbol_id = 2 [json_name = "stateMachineSymbolId"];</code>
+   * @return The stateMachineSymbolId.
+   */
+  java.lang.String getStateMachineSymbolId();
+  /**
+   * <code>string state_machine_symbol_id = 2 [json_name = "stateMachineSymbolId"];</code>
+   * @return The bytes for stateMachineSymbolId.
+   */
+  com.google.protobuf.ByteString
+      getStateMachineSymbolIdBytes();
+
+  /**
+   * <pre>
+   * sequence of event names to process
+   * </pre>
+   *
+   * <code>repeated string events = 3 [json_name = "events"];</code>
+   * @return A list containing the events.
+   */
+  java.util.List<java.lang.String>
+      getEventsList();
+  /**
+   * <pre>
+   * sequence of event names to process
+   * </pre>
+   *
+   * <code>repeated string events = 3 [json_name = "events"];</code>
+   * @return The count of events.
+   */
+  int getEventsCount();
+  /**
+   * <pre>
+   * sequence of event names to process
+   * </pre>
+   *
+   * <code>repeated string events = 3 [json_name = "events"];</code>
+   * @param index The index of the element to return.
+   * @return The events at the given index.
+   */
+  java.lang.String getEvents(int index);
+  /**
+   * <pre>
+   * sequence of event names to process
+   * </pre>
+   *
+   * <code>repeated string events = 3 [json_name = "events"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the events at the given index.
+   */
+  com.google.protobuf.ByteString
+      getEventsBytes(int index);
+
+  /**
+   * <pre>
+   * Scheduling policy the run resolves its choice points under: "declared",
+   * "reverse", "seed:<n>" or "explore[:runs=<n>,depth=<d>]". Empty is the
+   * default, "reverse"; any other spelling is INVALID_ARGUMENT. Under explore
+   * the response answers with `outcomes` and `exploration` in place of one run's
+   * states_visited, final_context and error (see ExecuteActionResponse).
+   * </pre>
+   *
+   * <code>string schedule = 4 [json_name = "schedule"];</code>
+   * @return The schedule.
+   */
+  java.lang.String getSchedule();
+  /**
+   * <pre>
+   * Scheduling policy the run resolves its choice points under: "declared",
+   * "reverse", "seed:<n>" or "explore[:runs=<n>,depth=<d>]". Empty is the
+   * default, "reverse"; any other spelling is INVALID_ARGUMENT. Under explore
+   * the response answers with `outcomes` and `exploration` in place of one run's
+   * states_visited, final_context and error (see ExecuteActionResponse).
+   * </pre>
+   *
+   * <code>string schedule = 4 [json_name = "schedule"];</code>
+   * @return The bytes for schedule.
+   */
+  com.google.protobuf.ByteString
+      getScheduleBytes();
+
+  /**
+   * <pre>
+   * Optional FQN of a part/usage to run the machine on, or a path from one to a
+   * nested object (see ExecuteActionRequest.performer_symbol_id). An object
+   * exhibiting the machine runs the one it exhibits, so what its assembly
+   * connects to it reaches the run; one exhibiting it under several usages is
+   * an error. Empty runs the machine outside any object.
+   * </pre>
+   *
+   * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+   * @return The performerSymbolId.
+   */
+  java.lang.String getPerformerSymbolId();
+  /**
+   * <pre>
+   * Optional FQN of a part/usage to run the machine on, or a path from one to a
+   * nested object (see ExecuteActionRequest.performer_symbol_id). An object
+   * exhibiting the machine runs the one it exhibits, so what its assembly
+   * connects to it reaches the run; one exhibiting it under several usages is
+   * an error. Empty runs the machine outside any object.
+   * </pre>
+   *
+   * <code>string performer_symbol_id = 5 [json_name = "performerSymbolId"];</code>
+   * @return The bytes for performerSymbolId.
+   */
+  com.google.protobuf.ByteString
+      getPerformerSymbolIdBytes();
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatus.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatus.java
similarity index 90%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatus.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatus.java
index 95e2ba8c47..78635f82d1 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatus.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatus.java
@@ -160,6 +160,22 @@ public int getDepthBudget() {
     return depthBudget_;
   }
 
+  public static final int PROBABILITIES_LOWER_BOUND_FIELD_NUMBER = 6;
+  private boolean probabilitiesLowerBound_ = false;
+  /**
+   * <pre>
+   * True when the outcomes' probabilities are lower bounds: a budget kept some
+   * linearizations unexplored.
+   * </pre>
+   *
+   * <code>bool probabilities_lower_bound = 6 [json_name = "probabilitiesLowerBound"];</code>
+   * @return The probabilitiesLowerBound.
+   */
+  @java.lang.Override
+  public boolean getProbabilitiesLowerBound() {
+    return probabilitiesLowerBound_;
+  }
+
   private byte memoizedIsInitialized = -1;
   @java.lang.Override
   public final boolean isInitialized() {
@@ -189,6 +205,9 @@ public void writeTo(com.google.protobuf.CodedOutputStream output)
     if (depthBudget_ != 0) {
       output.writeInt32(5, depthBudget_);
     }
+    if (probabilitiesLowerBound_ != false) {
+      output.writeBool(6, probabilitiesLowerBound_);
+    }
     getUnknownFields().writeTo(output);
   }
 
@@ -222,6 +241,10 @@ public int getSerializedSize() {
       size += com.google.protobuf.CodedOutputStream
         .computeInt32Size(5, depthBudget_);
     }
+    if (probabilitiesLowerBound_ != false) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeBoolSize(6, probabilitiesLowerBound_);
+    }
     size += getUnknownFields().getSerializedSize();
     memoizedSize = size;
     return size;
@@ -247,6 +270,8 @@ public boolean equals(final java.lang.Object obj) {
         != other.getRunsBudget()) return false;
     if (getDepthBudget()
         != other.getDepthBudget()) return false;
+    if (getProbabilitiesLowerBound()
+        != other.getProbabilitiesLowerBound()) return false;
     if (!getUnknownFields().equals(other.getUnknownFields())) return false;
     return true;
   }
@@ -271,6 +296,9 @@ public int hashCode() {
     hash = (53 * hash) + getRunsBudget();
     hash = (37 * hash) + DEPTH_BUDGET_FIELD_NUMBER;
     hash = (53 * hash) + getDepthBudget();
+    hash = (37 * hash) + PROBABILITIES_LOWER_BOUND_FIELD_NUMBER;
+    hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean(
+        getProbabilitiesLowerBound());
     hash = (29 * hash) + getUnknownFields().hashCode();
     memoizedHashCode = hash;
     return hash;
@@ -413,6 +441,7 @@ public Builder clear() {
           com.google.protobuf.LazyStringArrayList.emptyList();
       runsBudget_ = 0;
       depthBudget_ = 0;
+      probabilitiesLowerBound_ = false;
       return this;
     }
 
@@ -462,6 +491,9 @@ private void buildPartial0(org.openmbee.opensysml.proto.ExplorationStatus result
       if (((from_bitField0_ & 0x00000010) != 0)) {
         result.depthBudget_ = depthBudget_;
       }
+      if (((from_bitField0_ & 0x00000020) != 0)) {
+        result.probabilitiesLowerBound_ = probabilitiesLowerBound_;
+      }
     }
 
     @java.lang.Override
@@ -498,6 +530,9 @@ public Builder mergeFrom(org.openmbee.opensysml.proto.ExplorationStatus other) {
       if (other.getDepthBudget() != 0) {
         setDepthBudget(other.getDepthBudget());
       }
+      if (other.getProbabilitiesLowerBound() != false) {
+        setProbabilitiesLowerBound(other.getProbabilitiesLowerBound());
+      }
       this.mergeUnknownFields(other.getUnknownFields());
       onChanged();
       return this;
@@ -550,6 +585,11 @@ public Builder mergeFrom(
               bitField0_ |= 0x00000010;
               break;
             } // case 40
+            case 48: {
+              probabilitiesLowerBound_ = input.readBool();
+              bitField0_ |= 0x00000020;
+              break;
+            } // case 48
             default: {
               if (!super.parseUnknownField(input, extensionRegistry, tag)) {
                 done = true; // was an endgroup tag
@@ -881,6 +921,53 @@ public Builder clearDepthBudget() {
       return this;
     }
 
+    private boolean probabilitiesLowerBound_ ;
+    /**
+     * <pre>
+     * True when the outcomes' probabilities are lower bounds: a budget kept some
+     * linearizations unexplored.
+     * </pre>
+     *
+     * <code>bool probabilities_lower_bound = 6 [json_name = "probabilitiesLowerBound"];</code>
+     * @return The probabilitiesLowerBound.
+     */
+    @java.lang.Override
+    public boolean getProbabilitiesLowerBound() {
+      return probabilitiesLowerBound_;
+    }
+    /**
+     * <pre>
+     * True when the outcomes' probabilities are lower bounds: a budget kept some
+     * linearizations unexplored.
+     * </pre>
+     *
+     * <code>bool probabilities_lower_bound = 6 [json_name = "probabilitiesLowerBound"];</code>
+     * @param value The probabilitiesLowerBound to set.
+     * @return This builder for chaining.
+     */
+    public Builder setProbabilitiesLowerBound(boolean value) {
+
+      probabilitiesLowerBound_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * True when the outcomes' probabilities are lower bounds: a budget kept some
+     * linearizations unexplored.
+     * </pre>
+     *
+     * <code>bool probabilities_lower_bound = 6 [json_name = "probabilitiesLowerBound"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearProbabilitiesLowerBound() {
+      bitField0_ = (bitField0_ & ~0x00000020);
+      probabilitiesLowerBound_ = false;
+      onChanged();
+      return this;
+    }
+
     // @@protoc_insertion_point(builder_scope:sysml.ExplorationStatus)
   }
 
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatusOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatusOrBuilder.java
similarity index 88%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatusOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatusOrBuilder.java
index 052fc62946..90c2758f1c 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatusOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExplorationStatusOrBuilder.java
@@ -87,4 +87,15 @@ public interface ExplorationStatusOrBuilder extends
    * @return The depthBudget.
    */
   int getDepthBudget();
+
+  /**
+   * <pre>
+   * True when the outcomes' probabilities are lower bounds: a budget kept some
+   * linearizations unexplored.
+   * </pre>
+   *
+   * <code>bool probabilities_lower_bound = 6 [json_name = "probabilitiesLowerBound"];</code>
+   * @return The probabilitiesLowerBound.
+   */
+  boolean getProbabilitiesLowerBound();
 }
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FailureReason.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FailureReason.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FailureReason.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FailureReason.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FeatureValue.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FeatureValue.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FeatureValue.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FeatureValue.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FeatureValueOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FeatureValueOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FeatureValueOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FeatureValueOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Function.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Function.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Function.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Function.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FunctionOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FunctionOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FunctionOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/FunctionOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/GetSymbolRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/GetSymbolRequest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/GetSymbolRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/GetSymbolRequest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/GetSymbolRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/GetSymbolRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/GetSymbolRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/GetSymbolRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Instance.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Instance.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Instance.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Instance.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstanceOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstanceOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstanceOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstanceOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateRequest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateRequest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateResponse.java
similarity index 98%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateResponse.java
index c99246816a..127cddf200 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateResponse.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateResponse.java
@@ -7,7 +7,11 @@
 
 /**
  * <pre>
- * InstantiateResponse contains the created instance
+ * InstantiateResponse contains the created instance. The service keeps the
+ * object for the model it was created in: a later RunDocumentQuery on the same
+ * model_hash binds it by its id or by the name it was instantiated under, and
+ * `DocumentQueries::Objects` enumerates it. Instantiating the same symbol again
+ * creates a new object under the name; the earlier one stays reachable by id.
  * </pre>
  *
  * Protobuf type {@code sysml.InstantiateResponse}
@@ -423,7 +427,11 @@ protected Builder newBuilderForType(
   }
   /**
    * <pre>
-   * InstantiateResponse contains the created instance
+   * InstantiateResponse contains the created instance. The service keeps the
+   * object for the model it was created in: a later RunDocumentQuery on the same
+   * model_hash binds it by its id or by the name it was instantiated under, and
+   * `DocumentQueries::Objects` enumerates it. Instantiating the same symbol again
+   * creates a new object under the name; the earlier one stays reachable by id.
    * </pre>
    *
    * Protobuf type {@code sysml.InstantiateResponse}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/InstantiateResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesRequest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesRequest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ListEnginesResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MeasurementRef.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MeasurementRef.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MeasurementRef.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MeasurementRef.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MeasurementRefOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MeasurementRefOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MeasurementRefOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MeasurementRefOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Metaobject.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Metaobject.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Metaobject.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Metaobject.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MetaobjectOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MetaobjectOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MetaobjectOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MetaobjectOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MoveEdit.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MoveEdit.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MoveEdit.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MoveEdit.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MoveEditOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MoveEditOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MoveEditOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MoveEditOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MultiplicityInfo.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MultiplicityInfo.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MultiplicityInfo.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MultiplicityInfo.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MultiplicityInfoOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MultiplicityInfoOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MultiplicityInfoOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/MultiplicityInfoOrBuilder.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Outcome.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Outcome.java
new file mode 100644
index 0000000000..f1d683560b
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Outcome.java
@@ -0,0 +1,2199 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * Outcome is one distinct outcome an exploration reached: the observables a
+ * conformance case compares, how many linearizations reached it, and the choice
+ * sequence of one run that did. Two runs agreeing on their observables are one
+ * outcome.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.Outcome}
+ */
+@com.google.protobuf.Generated
+public final class Outcome extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.Outcome)
+    OutcomeOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "Outcome");
+  }
+  // Use Outcome.newBuilder() to construct.
+  private Outcome(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private Outcome() {
+    finalState_ = "";
+    statesVisited_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    error_ = "";
+    witness_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    diagnostics_ = java.util.Collections.emptyList();
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_descriptor;
+  }
+
+  @SuppressWarnings({"rawtypes"})
+  @java.lang.Override
+  protected com.google.protobuf.MapFieldReflectionAccessor internalGetMapFieldReflection(
+      int number) {
+    switch (number) {
+      case 1:
+        return internalGetOutputs();
+      default:
+        throw new RuntimeException(
+            "Invalid map field number: " + number);
+    }
+  }
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.Outcome.class, org.openmbee.opensysml.proto.Outcome.Builder.class);
+  }
+
+  public static final int OUTPUTS_FIELD_NUMBER = 1;
+  private static final class OutputsDefaultEntryHolder {
+    static final com.google.protobuf.MapEntry<
+        java.lang.String, org.openmbee.opensysml.proto.Value> defaultEntry =
+            com.google.protobuf.MapEntry
+            .<java.lang.String, org.openmbee.opensysml.proto.Value>newDefaultInstance(
+                org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_OutputsEntry_descriptor, 
+                com.google.protobuf.WireFormat.FieldType.STRING,
+                "",
+                com.google.protobuf.WireFormat.FieldType.MESSAGE,
+                org.openmbee.opensysml.proto.Value.getDefaultInstance());
+  }
+  @SuppressWarnings("serial")
+  private com.google.protobuf.MapField<
+      java.lang.String, org.openmbee.opensysml.proto.Value> outputs_;
+  private com.google.protobuf.MapField<java.lang.String, org.openmbee.opensysml.proto.Value>
+  internalGetOutputs() {
+    if (outputs_ == null) {
+      return com.google.protobuf.MapField.emptyMapField(
+          OutputsDefaultEntryHolder.defaultEntry);
+    }
+    return outputs_;
+  }
+  public int getOutputsCount() {
+    return internalGetOutputs().getMap().size();
+  }
+  /**
+   * <pre>
+   * The values the run ended with: an action's outputs, a state machine's final
+   * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+   * </pre>
+   *
+   * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+   */
+  @java.lang.Override
+  public boolean containsOutputs(
+      java.lang.String key) {
+    if (key == null) { throw new NullPointerException("map key"); }
+    return internalGetOutputs().getMap().containsKey(key);
+  }
+  /**
+   * Use {@link #getOutputsMap()} instead.
+   */
+  @java.lang.Override
+  @java.lang.Deprecated
+  public java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> getOutputs() {
+    return getOutputsMap();
+  }
+  /**
+   * <pre>
+   * The values the run ended with: an action's outputs, a state machine's final
+   * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+   * </pre>
+   *
+   * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+   */
+  @java.lang.Override
+  public java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> getOutputsMap() {
+    return internalGetOutputs().getMap();
+  }
+  /**
+   * <pre>
+   * The values the run ended with: an action's outputs, a state machine's final
+   * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+   * </pre>
+   *
+   * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+   */
+  @java.lang.Override
+  public /* nullable */
+org.openmbee.opensysml.proto.Value getOutputsOrDefault(
+      java.lang.String key,
+      /* nullable */
+org.openmbee.opensysml.proto.Value defaultValue) {
+    if (key == null) { throw new NullPointerException("map key"); }
+    java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> map =
+        internalGetOutputs().getMap();
+    return map.containsKey(key) ? map.get(key) : defaultValue;
+  }
+  /**
+   * <pre>
+   * The values the run ended with: an action's outputs, a state machine's final
+   * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+   * </pre>
+   *
+   * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Value getOutputsOrThrow(
+      java.lang.String key) {
+    if (key == null) { throw new NullPointerException("map key"); }
+    java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> map =
+        internalGetOutputs().getMap();
+    if (!map.containsKey(key)) {
+      throw new java.lang.IllegalArgumentException();
+    }
+    return map.get(key);
+  }
+
+  public static final int FINAL_STATE_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object finalState_ = "";
+  /**
+   * <pre>
+   * The state a machine rests in and the states it entered, in order; both
+   * empty for an action or a case.
+   * </pre>
+   *
+   * <code>string final_state = 2 [json_name = "finalState"];</code>
+   * @return The finalState.
+   */
+  @java.lang.Override
+  public java.lang.String getFinalState() {
+    java.lang.Object ref = finalState_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      finalState_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The state a machine rests in and the states it entered, in order; both
+   * empty for an action or a case.
+   * </pre>
+   *
+   * <code>string final_state = 2 [json_name = "finalState"];</code>
+   * @return The bytes for finalState.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getFinalStateBytes() {
+    java.lang.Object ref = finalState_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      finalState_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int STATES_VISITED_FIELD_NUMBER = 3;
+  @SuppressWarnings("serial")
+  private com.google.protobuf.LazyStringArrayList statesVisited_ =
+      com.google.protobuf.LazyStringArrayList.emptyList();
+  /**
+   * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+   * @return A list containing the statesVisited.
+   */
+  public com.google.protobuf.ProtocolStringList
+      getStatesVisitedList() {
+    return statesVisited_;
+  }
+  /**
+   * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+   * @return The count of statesVisited.
+   */
+  public int getStatesVisitedCount() {
+    return statesVisited_.size();
+  }
+  /**
+   * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+   * @param index The index of the element to return.
+   * @return The statesVisited at the given index.
+   */
+  public java.lang.String getStatesVisited(int index) {
+    return statesVisited_.get(index);
+  }
+  /**
+   * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the statesVisited at the given index.
+   */
+  public com.google.protobuf.ByteString
+      getStatesVisitedBytes(int index) {
+    return statesVisited_.getByteString(index);
+  }
+
+  public static final int ERROR_FIELD_NUMBER = 4;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object error_ = "";
+  /**
+   * <pre>
+   * What the runs reaching this outcome failed with; empty for one they completed.
+   * </pre>
+   *
+   * <code>string error = 4 [json_name = "error"];</code>
+   * @return The error.
+   */
+  @java.lang.Override
+  public java.lang.String getError() {
+    java.lang.Object ref = error_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      error_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * What the runs reaching this outcome failed with; empty for one they completed.
+   * </pre>
+   *
+   * <code>string error = 4 [json_name = "error"];</code>
+   * @return The bytes for error.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getErrorBytes() {
+    java.lang.Object ref = error_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      error_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int LINEARIZATIONS_FIELD_NUMBER = 5;
+  private int linearizations_ = 0;
+  /**
+   * <pre>
+   * How many linearizations within the budget reached this outcome.
+   * </pre>
+   *
+   * <code>int32 linearizations = 5 [json_name = "linearizations"];</code>
+   * @return The linearizations.
+   */
+  @java.lang.Override
+  public int getLinearizations() {
+    return linearizations_;
+  }
+
+  public static final int WITNESS_FIELD_NUMBER = 6;
+  @SuppressWarnings("serial")
+  private com.google.protobuf.LazyStringArrayList witness_ =
+      com.google.protobuf.LazyStringArrayList.emptyList();
+  /**
+   * <pre>
+   * One run's choice sequence in run order, one entry per choice point it
+   * resolved, each spelling the alternatives and the one taken. Empty when the
+   * run faced no choice point.
+   * </pre>
+   *
+   * <code>repeated string witness = 6 [json_name = "witness"];</code>
+   * @return A list containing the witness.
+   */
+  public com.google.protobuf.ProtocolStringList
+      getWitnessList() {
+    return witness_;
+  }
+  /**
+   * <pre>
+   * One run's choice sequence in run order, one entry per choice point it
+   * resolved, each spelling the alternatives and the one taken. Empty when the
+   * run faced no choice point.
+   * </pre>
+   *
+   * <code>repeated string witness = 6 [json_name = "witness"];</code>
+   * @return The count of witness.
+   */
+  public int getWitnessCount() {
+    return witness_.size();
+  }
+  /**
+   * <pre>
+   * One run's choice sequence in run order, one entry per choice point it
+   * resolved, each spelling the alternatives and the one taken. Empty when the
+   * run faced no choice point.
+   * </pre>
+   *
+   * <code>repeated string witness = 6 [json_name = "witness"];</code>
+   * @param index The index of the element to return.
+   * @return The witness at the given index.
+   */
+  public java.lang.String getWitness(int index) {
+    return witness_.get(index);
+  }
+  /**
+   * <pre>
+   * One run's choice sequence in run order, one entry per choice point it
+   * resolved, each spelling the alternatives and the one taken. Empty when the
+   * run faced no choice point.
+   * </pre>
+   *
+   * <code>repeated string witness = 6 [json_name = "witness"];</code>
+   * @param index The index of the value to return.
+   * @return The bytes of the witness at the given index.
+   */
+  public com.google.protobuf.ByteString
+      getWitnessBytes(int index) {
+    return witness_.getByteString(index);
+  }
+
+  public static final int DIAGNOSTICS_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.Diagnostic> diagnostics_;
+  /**
+   * <pre>
+   * What the witness run noted about itself: its choice points and the guards
+   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+   * one run.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.Diagnostic> getDiagnosticsList() {
+    return diagnostics_;
+  }
+  /**
+   * <pre>
+   * What the witness run noted about itself: its choice points and the guards
+   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+   * one run.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+      getDiagnosticsOrBuilderList() {
+    return diagnostics_;
+  }
+  /**
+   * <pre>
+   * What the witness run noted about itself: its choice points and the guards
+   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+   * one run.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public int getDiagnosticsCount() {
+    return diagnostics_.size();
+  }
+  /**
+   * <pre>
+   * What the witness run noted about itself: its choice points and the guards
+   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+   * one run.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index) {
+    return diagnostics_.get(index);
+  }
+  /**
+   * <pre>
+   * What the witness run noted about itself: its choice points and the guards
+   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+   * one run.
+   * </pre>
+   *
+   * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
+      int index) {
+    return diagnostics_.get(index);
+  }
+
+  public static final int PROBABILITY_FIELD_NUMBER = 8;
+  private double probability_ = 0D;
+  /**
+   * <pre>
+   * The probability of the linearizations reaching this outcome, as explore
+   * computes it; a lower bound when the exploration is incomplete.
+   * </pre>
+   *
+   * <code>double probability = 8 [json_name = "probability"];</code>
+   * @return The probability.
+   */
+  @java.lang.Override
+  public double getProbability() {
+    return probability_;
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    com.google.protobuf.GeneratedMessage
+      .serializeStringMapTo(
+        output,
+        internalGetOutputs(),
+        OutputsDefaultEntryHolder.defaultEntry,
+        1);
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(finalState_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, finalState_);
+    }
+    for (int i = 0; i < statesVisited_.size(); i++) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 3, statesVisited_.getRaw(i));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 4, error_);
+    }
+    if (linearizations_ != 0) {
+      output.writeInt32(5, linearizations_);
+    }
+    for (int i = 0; i < witness_.size(); i++) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 6, witness_.getRaw(i));
+    }
+    for (int i = 0; i < diagnostics_.size(); i++) {
+      output.writeMessage(7, diagnostics_.get(i));
+    }
+    if (java.lang.Double.doubleToRawLongBits(probability_) != 0) {
+      output.writeDouble(8, probability_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    for (java.util.Map.Entry<java.lang.String, org.openmbee.opensysml.proto.Value> entry
+         : internalGetOutputs().getMap().entrySet()) {
+      com.google.protobuf.MapEntry<java.lang.String, org.openmbee.opensysml.proto.Value>
+      outputs__ = OutputsDefaultEntryHolder.defaultEntry.newBuilderForType()
+          .setKey(entry.getKey())
+          .setValue(entry.getValue())
+          .build();
+      size += com.google.protobuf.CodedOutputStream
+          .computeMessageSize(1, outputs__);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(finalState_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, finalState_);
+    }
+    {
+      int dataSize = 0;
+      for (int i = 0; i < statesVisited_.size(); i++) {
+        dataSize += computeStringSizeNoTag(statesVisited_.getRaw(i));
+      }
+      size += dataSize;
+      size += 1 * getStatesVisitedList().size();
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(4, error_);
+    }
+    if (linearizations_ != 0) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeInt32Size(5, linearizations_);
+    }
+    {
+      int dataSize = 0;
+      for (int i = 0; i < witness_.size(); i++) {
+        dataSize += computeStringSizeNoTag(witness_.getRaw(i));
+      }
+      size += dataSize;
+      size += 1 * getWitnessList().size();
+    }
+    for (int i = 0; i < diagnostics_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(7, diagnostics_.get(i));
+    }
+    if (java.lang.Double.doubleToRawLongBits(probability_) != 0) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeDoubleSize(8, probability_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.Outcome)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.Outcome other = (org.openmbee.opensysml.proto.Outcome) obj;
+
+    if (!internalGetOutputs().equals(
+        other.internalGetOutputs())) return false;
+    if (!getFinalState()
+        .equals(other.getFinalState())) return false;
+    if (!getStatesVisitedList()
+        .equals(other.getStatesVisitedList())) return false;
+    if (!getError()
+        .equals(other.getError())) return false;
+    if (getLinearizations()
+        != other.getLinearizations()) return false;
+    if (!getWitnessList()
+        .equals(other.getWitnessList())) return false;
+    if (!getDiagnosticsList()
+        .equals(other.getDiagnosticsList())) return false;
+    if (java.lang.Double.doubleToLongBits(getProbability())
+        != java.lang.Double.doubleToLongBits(
+            other.getProbability())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    if (!internalGetOutputs().getMap().isEmpty()) {
+      hash = (37 * hash) + OUTPUTS_FIELD_NUMBER;
+      hash = (53 * hash) + internalGetOutputs().hashCode();
+    }
+    hash = (37 * hash) + FINAL_STATE_FIELD_NUMBER;
+    hash = (53 * hash) + getFinalState().hashCode();
+    if (getStatesVisitedCount() > 0) {
+      hash = (37 * hash) + STATES_VISITED_FIELD_NUMBER;
+      hash = (53 * hash) + getStatesVisitedList().hashCode();
+    }
+    hash = (37 * hash) + ERROR_FIELD_NUMBER;
+    hash = (53 * hash) + getError().hashCode();
+    hash = (37 * hash) + LINEARIZATIONS_FIELD_NUMBER;
+    hash = (53 * hash) + getLinearizations();
+    if (getWitnessCount() > 0) {
+      hash = (37 * hash) + WITNESS_FIELD_NUMBER;
+      hash = (53 * hash) + getWitnessList().hashCode();
+    }
+    if (getDiagnosticsCount() > 0) {
+      hash = (37 * hash) + DIAGNOSTICS_FIELD_NUMBER;
+      hash = (53 * hash) + getDiagnosticsList().hashCode();
+    }
+    hash = (37 * hash) + PROBABILITY_FIELD_NUMBER;
+    hash = (53 * hash) + com.google.protobuf.Internal.hashLong(
+        java.lang.Double.doubleToLongBits(getProbability()));
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.Outcome parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.Outcome parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Outcome parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.Outcome parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Outcome parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.Outcome parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Outcome parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.Outcome parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.Outcome parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.Outcome parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Outcome parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.Outcome parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.Outcome prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * Outcome is one distinct outcome an exploration reached: the observables a
+   * conformance case compares, how many linearizations reached it, and the choice
+   * sequence of one run that did. Two runs agreeing on their observables are one
+   * outcome.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.Outcome}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.Outcome)
+      org.openmbee.opensysml.proto.OutcomeOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_descriptor;
+    }
+
+    @SuppressWarnings({"rawtypes"})
+    protected com.google.protobuf.MapFieldReflectionAccessor internalGetMapFieldReflection(
+        int number) {
+      switch (number) {
+        case 1:
+          return internalGetOutputs();
+        default:
+          throw new RuntimeException(
+              "Invalid map field number: " + number);
+      }
+    }
+    @SuppressWarnings({"rawtypes"})
+    protected com.google.protobuf.MapFieldReflectionAccessor internalGetMutableMapFieldReflection(
+        int number) {
+      switch (number) {
+        case 1:
+          return internalGetMutableOutputs();
+        default:
+          throw new RuntimeException(
+              "Invalid map field number: " + number);
+      }
+    }
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.Outcome.class, org.openmbee.opensysml.proto.Outcome.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.Outcome.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      internalGetMutableOutputs().clear();
+      finalState_ = "";
+      statesVisited_ =
+          com.google.protobuf.LazyStringArrayList.emptyList();
+      error_ = "";
+      linearizations_ = 0;
+      witness_ =
+          com.google.protobuf.LazyStringArrayList.emptyList();
+      if (diagnosticsBuilder_ == null) {
+        diagnostics_ = java.util.Collections.emptyList();
+      } else {
+        diagnostics_ = null;
+        diagnosticsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000040);
+      probability_ = 0D;
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Outcome getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.Outcome.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Outcome build() {
+      org.openmbee.opensysml.proto.Outcome result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Outcome buildPartial() {
+      org.openmbee.opensysml.proto.Outcome result = new org.openmbee.opensysml.proto.Outcome(this);
+      buildPartialRepeatedFields(result);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartialRepeatedFields(org.openmbee.opensysml.proto.Outcome result) {
+      if (diagnosticsBuilder_ == null) {
+        if (((bitField0_ & 0x00000040) != 0)) {
+          diagnostics_ = java.util.Collections.unmodifiableList(diagnostics_);
+          bitField0_ = (bitField0_ & ~0x00000040);
+        }
+        result.diagnostics_ = diagnostics_;
+      } else {
+        result.diagnostics_ = diagnosticsBuilder_.build();
+      }
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.Outcome result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.outputs_ = internalGetOutputs().build(OutputsDefaultEntryHolder.defaultEntry);
+      }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.finalState_ = finalState_;
+      }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        statesVisited_.makeImmutable();
+        result.statesVisited_ = statesVisited_;
+      }
+      if (((from_bitField0_ & 0x00000008) != 0)) {
+        result.error_ = error_;
+      }
+      if (((from_bitField0_ & 0x00000010) != 0)) {
+        result.linearizations_ = linearizations_;
+      }
+      if (((from_bitField0_ & 0x00000020) != 0)) {
+        witness_.makeImmutable();
+        result.witness_ = witness_;
+      }
+      if (((from_bitField0_ & 0x00000080) != 0)) {
+        result.probability_ = probability_;
+      }
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.Outcome) {
+        return mergeFrom((org.openmbee.opensysml.proto.Outcome)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.Outcome other) {
+      if (other == org.openmbee.opensysml.proto.Outcome.getDefaultInstance()) return this;
+      internalGetMutableOutputs().mergeFrom(
+          other.internalGetOutputs());
+      bitField0_ |= 0x00000001;
+      if (!other.getFinalState().isEmpty()) {
+        finalState_ = other.finalState_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      if (!other.statesVisited_.isEmpty()) {
+        if (statesVisited_.isEmpty()) {
+          statesVisited_ = other.statesVisited_;
+          bitField0_ |= 0x00000004;
+        } else {
+          ensureStatesVisitedIsMutable();
+          statesVisited_.addAll(other.statesVisited_);
+        }
+        onChanged();
+      }
+      if (!other.getError().isEmpty()) {
+        error_ = other.error_;
+        bitField0_ |= 0x00000008;
+        onChanged();
+      }
+      if (other.getLinearizations() != 0) {
+        setLinearizations(other.getLinearizations());
+      }
+      if (!other.witness_.isEmpty()) {
+        if (witness_.isEmpty()) {
+          witness_ = other.witness_;
+          bitField0_ |= 0x00000020;
+        } else {
+          ensureWitnessIsMutable();
+          witness_.addAll(other.witness_);
+        }
+        onChanged();
+      }
+      if (diagnosticsBuilder_ == null) {
+        if (!other.diagnostics_.isEmpty()) {
+          if (diagnostics_.isEmpty()) {
+            diagnostics_ = other.diagnostics_;
+            bitField0_ = (bitField0_ & ~0x00000040);
+          } else {
+            ensureDiagnosticsIsMutable();
+            diagnostics_.addAll(other.diagnostics_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.diagnostics_.isEmpty()) {
+          if (diagnosticsBuilder_.isEmpty()) {
+            diagnosticsBuilder_.dispose();
+            diagnosticsBuilder_ = null;
+            diagnostics_ = other.diagnostics_;
+            bitField0_ = (bitField0_ & ~0x00000040);
+            diagnosticsBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetDiagnosticsFieldBuilder() : null;
+          } else {
+            diagnosticsBuilder_.addAllMessages(other.diagnostics_);
+          }
+        }
+      }
+      if (java.lang.Double.doubleToRawLongBits(other.getProbability()) != 0) {
+        setProbability(other.getProbability());
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              com.google.protobuf.MapEntry<java.lang.String, org.openmbee.opensysml.proto.Value>
+              outputs__ = input.readMessage(
+                  OutputsDefaultEntryHolder.defaultEntry.getParserForType(), extensionRegistry);
+              internalGetMutableOutputs().ensureBuilderMap().put(
+                  outputs__.getKey(), outputs__.getValue());
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              finalState_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            case 26: {
+              java.lang.String s = input.readStringRequireUtf8();
+              ensureStatesVisitedIsMutable();
+              statesVisited_.add(s);
+              break;
+            } // case 26
+            case 34: {
+              error_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000008;
+              break;
+            } // case 34
+            case 40: {
+              linearizations_ = input.readInt32();
+              bitField0_ |= 0x00000010;
+              break;
+            } // case 40
+            case 50: {
+              java.lang.String s = input.readStringRequireUtf8();
+              ensureWitnessIsMutable();
+              witness_.add(s);
+              break;
+            } // case 50
+            case 58: {
+              org.openmbee.opensysml.proto.Diagnostic m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.Diagnostic.parser(),
+                      extensionRegistry);
+              if (diagnosticsBuilder_ == null) {
+                ensureDiagnosticsIsMutable();
+                diagnostics_.add(m);
+              } else {
+                diagnosticsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 58
+            case 65: {
+              probability_ = input.readDouble();
+              bitField0_ |= 0x00000080;
+              break;
+            } // case 65
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private static final class OutputsConverter implements com.google.protobuf.MapFieldBuilder.Converter<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder, org.openmbee.opensysml.proto.Value> {
+      @java.lang.Override
+      public org.openmbee.opensysml.proto.Value build(org.openmbee.opensysml.proto.ValueOrBuilder val) {
+        if (val instanceof org.openmbee.opensysml.proto.Value) { return (org.openmbee.opensysml.proto.Value) val; }
+        return ((org.openmbee.opensysml.proto.Value.Builder) val).build();
+      }
+
+      @java.lang.Override
+      public com.google.protobuf.MapEntry<java.lang.String, org.openmbee.opensysml.proto.Value> defaultEntry() {
+        return OutputsDefaultEntryHolder.defaultEntry;
+      }
+    };
+    private static final OutputsConverter outputsConverter = new OutputsConverter();
+
+    private com.google.protobuf.MapFieldBuilder<
+        java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder, org.openmbee.opensysml.proto.Value, org.openmbee.opensysml.proto.Value.Builder> outputs_;
+    private com.google.protobuf.MapFieldBuilder<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder, org.openmbee.opensysml.proto.Value, org.openmbee.opensysml.proto.Value.Builder>
+        internalGetOutputs() {
+      if (outputs_ == null) {
+        return new com.google.protobuf.MapFieldBuilder<>(outputsConverter);
+      }
+      return outputs_;
+    }
+    private com.google.protobuf.MapFieldBuilder<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder, org.openmbee.opensysml.proto.Value, org.openmbee.opensysml.proto.Value.Builder>
+        internalGetMutableOutputs() {
+      if (outputs_ == null) {
+        outputs_ = new com.google.protobuf.MapFieldBuilder<>(outputsConverter);
+      }
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return outputs_;
+    }
+    public int getOutputsCount() {
+      return internalGetOutputs().ensureBuilderMap().size();
+    }
+    /**
+     * <pre>
+     * The values the run ended with: an action's outputs, a state machine's final
+     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+     * </pre>
+     *
+     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+     */
+    @java.lang.Override
+    public boolean containsOutputs(
+        java.lang.String key) {
+      if (key == null) { throw new NullPointerException("map key"); }
+      return internalGetOutputs().ensureBuilderMap().containsKey(key);
+    }
+    /**
+     * Use {@link #getOutputsMap()} instead.
+     */
+    @java.lang.Override
+    @java.lang.Deprecated
+    public java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> getOutputs() {
+      return getOutputsMap();
+    }
+    /**
+     * <pre>
+     * The values the run ended with: an action's outputs, a state machine's final
+     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+     * </pre>
+     *
+     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+     */
+    @java.lang.Override
+    public java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> getOutputsMap() {
+      return internalGetOutputs().getImmutableMap();
+    }
+    /**
+     * <pre>
+     * The values the run ended with: an action's outputs, a state machine's final
+     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+     * </pre>
+     *
+     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+     */
+    @java.lang.Override
+    public /* nullable */
+org.openmbee.opensysml.proto.Value getOutputsOrDefault(
+        java.lang.String key,
+        /* nullable */
+org.openmbee.opensysml.proto.Value defaultValue) {
+      if (key == null) { throw new NullPointerException("map key"); }
+      java.util.Map<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder> map = internalGetMutableOutputs().ensureBuilderMap();
+      return map.containsKey(key) ? outputsConverter.build(map.get(key)) : defaultValue;
+    }
+    /**
+     * <pre>
+     * The values the run ended with: an action's outputs, a state machine's final
+     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+     * </pre>
+     *
+     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Value getOutputsOrThrow(
+        java.lang.String key) {
+      if (key == null) { throw new NullPointerException("map key"); }
+      java.util.Map<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder> map = internalGetMutableOutputs().ensureBuilderMap();
+      if (!map.containsKey(key)) {
+        throw new java.lang.IllegalArgumentException();
+      }
+      return outputsConverter.build(map.get(key));
+    }
+    public Builder clearOutputs() {
+      bitField0_ = (bitField0_ & ~0x00000001);
+      internalGetMutableOutputs().clear();
+      return this;
+    }
+    /**
+     * <pre>
+     * The values the run ended with: an action's outputs, a state machine's final
+     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+     * </pre>
+     *
+     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+     */
+    public Builder removeOutputs(
+        java.lang.String key) {
+      if (key == null) { throw new NullPointerException("map key"); }
+      internalGetMutableOutputs().ensureBuilderMap()
+          .remove(key);
+      return this;
+    }
+    /**
+     * Use alternate mutation accessors instead.
+     */
+    @java.lang.Deprecated
+    public java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value>
+        getMutableOutputs() {
+      bitField0_ |= 0x00000001;
+      return internalGetMutableOutputs().ensureMessageMap();
+    }
+    /**
+     * <pre>
+     * The values the run ended with: an action's outputs, a state machine's final
+     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+     * </pre>
+     *
+     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+     */
+    public Builder putOutputs(
+        java.lang.String key,
+        org.openmbee.opensysml.proto.Value value) {
+      if (key == null) { throw new NullPointerException("map key"); }
+      if (value == null) { throw new NullPointerException("map value"); }
+      internalGetMutableOutputs().ensureBuilderMap()
+          .put(key, value);
+      bitField0_ |= 0x00000001;
+      return this;
+    }
+    /**
+     * <pre>
+     * The values the run ended with: an action's outputs, a state machine's final
+     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+     * </pre>
+     *
+     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+     */
+    public Builder putAllOutputs(
+        java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> values) {
+      for (java.util.Map.Entry<java.lang.String, org.openmbee.opensysml.proto.Value> e : values.entrySet()) {
+        if (e.getKey() == null || e.getValue() == null) {
+          throw new NullPointerException();
+        }
+      }
+      internalGetMutableOutputs().ensureBuilderMap()
+          .putAll(values);
+      bitField0_ |= 0x00000001;
+      return this;
+    }
+    /**
+     * <pre>
+     * The values the run ended with: an action's outputs, a state machine's final
+     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+     * </pre>
+     *
+     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
+     */
+    public org.openmbee.opensysml.proto.Value.Builder putOutputsBuilderIfAbsent(
+        java.lang.String key) {
+      java.util.Map<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder> builderMap = internalGetMutableOutputs().ensureBuilderMap();
+      org.openmbee.opensysml.proto.ValueOrBuilder entry = builderMap.get(key);
+      if (entry == null) {
+        entry = org.openmbee.opensysml.proto.Value.newBuilder();
+        builderMap.put(key, entry);
+      }
+      if (entry instanceof org.openmbee.opensysml.proto.Value) {
+        entry = ((org.openmbee.opensysml.proto.Value) entry).toBuilder();
+        builderMap.put(key, entry);
+      }
+      return (org.openmbee.opensysml.proto.Value.Builder) entry;
+    }
+
+    private java.lang.Object finalState_ = "";
+    /**
+     * <pre>
+     * The state a machine rests in and the states it entered, in order; both
+     * empty for an action or a case.
+     * </pre>
+     *
+     * <code>string final_state = 2 [json_name = "finalState"];</code>
+     * @return The finalState.
+     */
+    public java.lang.String getFinalState() {
+      java.lang.Object ref = finalState_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        finalState_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The state a machine rests in and the states it entered, in order; both
+     * empty for an action or a case.
+     * </pre>
+     *
+     * <code>string final_state = 2 [json_name = "finalState"];</code>
+     * @return The bytes for finalState.
+     */
+    public com.google.protobuf.ByteString
+        getFinalStateBytes() {
+      java.lang.Object ref = finalState_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        finalState_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The state a machine rests in and the states it entered, in order; both
+     * empty for an action or a case.
+     * </pre>
+     *
+     * <code>string final_state = 2 [json_name = "finalState"];</code>
+     * @param value The finalState to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFinalState(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      finalState_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The state a machine rests in and the states it entered, in order; both
+     * empty for an action or a case.
+     * </pre>
+     *
+     * <code>string final_state = 2 [json_name = "finalState"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearFinalState() {
+      finalState_ = getDefaultInstance().getFinalState();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The state a machine rests in and the states it entered, in order; both
+     * empty for an action or a case.
+     * </pre>
+     *
+     * <code>string final_state = 2 [json_name = "finalState"];</code>
+     * @param value The bytes for finalState to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFinalStateBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      finalState_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
+    private com.google.protobuf.LazyStringArrayList statesVisited_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    private void ensureStatesVisitedIsMutable() {
+      if (!statesVisited_.isModifiable()) {
+        statesVisited_ = new com.google.protobuf.LazyStringArrayList(statesVisited_);
+      }
+      bitField0_ |= 0x00000004;
+    }
+    /**
+     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+     * @return A list containing the statesVisited.
+     */
+    public com.google.protobuf.ProtocolStringList
+        getStatesVisitedList() {
+      statesVisited_.makeImmutable();
+      return statesVisited_;
+    }
+    /**
+     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+     * @return The count of statesVisited.
+     */
+    public int getStatesVisitedCount() {
+      return statesVisited_.size();
+    }
+    /**
+     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+     * @param index The index of the element to return.
+     * @return The statesVisited at the given index.
+     */
+    public java.lang.String getStatesVisited(int index) {
+      return statesVisited_.get(index);
+    }
+    /**
+     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+     * @param index The index of the value to return.
+     * @return The bytes of the statesVisited at the given index.
+     */
+    public com.google.protobuf.ByteString
+        getStatesVisitedBytes(int index) {
+      return statesVisited_.getByteString(index);
+    }
+    /**
+     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+     * @param index The index to set the value at.
+     * @param value The statesVisited to set.
+     * @return This builder for chaining.
+     */
+    public Builder setStatesVisited(
+        int index, java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureStatesVisitedIsMutable();
+      statesVisited_.set(index, value);
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+     * @param value The statesVisited to add.
+     * @return This builder for chaining.
+     */
+    public Builder addStatesVisited(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureStatesVisitedIsMutable();
+      statesVisited_.add(value);
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+     * @param values The statesVisited to add.
+     * @return This builder for chaining.
+     */
+    public Builder addAllStatesVisited(
+        java.lang.Iterable<java.lang.String> values) {
+      ensureStatesVisitedIsMutable();
+      com.google.protobuf.AbstractMessageLite.Builder.addAll(
+          values, statesVisited_);
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearStatesVisited() {
+      statesVisited_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+      bitField0_ = (bitField0_ & ~0x00000004);;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
+     * @param value The bytes of the statesVisited to add.
+     * @return This builder for chaining.
+     */
+    public Builder addStatesVisitedBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      ensureStatesVisitedIsMutable();
+      statesVisited_.add(value);
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object error_ = "";
+    /**
+     * <pre>
+     * What the runs reaching this outcome failed with; empty for one they completed.
+     * </pre>
+     *
+     * <code>string error = 4 [json_name = "error"];</code>
+     * @return The error.
+     */
+    public java.lang.String getError() {
+      java.lang.Object ref = error_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        error_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * What the runs reaching this outcome failed with; empty for one they completed.
+     * </pre>
+     *
+     * <code>string error = 4 [json_name = "error"];</code>
+     * @return The bytes for error.
+     */
+    public com.google.protobuf.ByteString
+        getErrorBytes() {
+      java.lang.Object ref = error_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        error_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * What the runs reaching this outcome failed with; empty for one they completed.
+     * </pre>
+     *
+     * <code>string error = 4 [json_name = "error"];</code>
+     * @param value The error to set.
+     * @return This builder for chaining.
+     */
+    public Builder setError(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      error_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * What the runs reaching this outcome failed with; empty for one they completed.
+     * </pre>
+     *
+     * <code>string error = 4 [json_name = "error"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearError() {
+      error_ = getDefaultInstance().getError();
+      bitField0_ = (bitField0_ & ~0x00000008);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * What the runs reaching this outcome failed with; empty for one they completed.
+     * </pre>
+     *
+     * <code>string error = 4 [json_name = "error"];</code>
+     * @param value The bytes for error to set.
+     * @return This builder for chaining.
+     */
+    public Builder setErrorBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      error_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+
+    private int linearizations_ ;
+    /**
+     * <pre>
+     * How many linearizations within the budget reached this outcome.
+     * </pre>
+     *
+     * <code>int32 linearizations = 5 [json_name = "linearizations"];</code>
+     * @return The linearizations.
+     */
+    @java.lang.Override
+    public int getLinearizations() {
+      return linearizations_;
+    }
+    /**
+     * <pre>
+     * How many linearizations within the budget reached this outcome.
+     * </pre>
+     *
+     * <code>int32 linearizations = 5 [json_name = "linearizations"];</code>
+     * @param value The linearizations to set.
+     * @return This builder for chaining.
+     */
+    public Builder setLinearizations(int value) {
+
+      linearizations_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * How many linearizations within the budget reached this outcome.
+     * </pre>
+     *
+     * <code>int32 linearizations = 5 [json_name = "linearizations"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearLinearizations() {
+      bitField0_ = (bitField0_ & ~0x00000010);
+      linearizations_ = 0;
+      onChanged();
+      return this;
+    }
+
+    private com.google.protobuf.LazyStringArrayList witness_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+    private void ensureWitnessIsMutable() {
+      if (!witness_.isModifiable()) {
+        witness_ = new com.google.protobuf.LazyStringArrayList(witness_);
+      }
+      bitField0_ |= 0x00000020;
+    }
+    /**
+     * <pre>
+     * One run's choice sequence in run order, one entry per choice point it
+     * resolved, each spelling the alternatives and the one taken. Empty when the
+     * run faced no choice point.
+     * </pre>
+     *
+     * <code>repeated string witness = 6 [json_name = "witness"];</code>
+     * @return A list containing the witness.
+     */
+    public com.google.protobuf.ProtocolStringList
+        getWitnessList() {
+      witness_.makeImmutable();
+      return witness_;
+    }
+    /**
+     * <pre>
+     * One run's choice sequence in run order, one entry per choice point it
+     * resolved, each spelling the alternatives and the one taken. Empty when the
+     * run faced no choice point.
+     * </pre>
+     *
+     * <code>repeated string witness = 6 [json_name = "witness"];</code>
+     * @return The count of witness.
+     */
+    public int getWitnessCount() {
+      return witness_.size();
+    }
+    /**
+     * <pre>
+     * One run's choice sequence in run order, one entry per choice point it
+     * resolved, each spelling the alternatives and the one taken. Empty when the
+     * run faced no choice point.
+     * </pre>
+     *
+     * <code>repeated string witness = 6 [json_name = "witness"];</code>
+     * @param index The index of the element to return.
+     * @return The witness at the given index.
+     */
+    public java.lang.String getWitness(int index) {
+      return witness_.get(index);
+    }
+    /**
+     * <pre>
+     * One run's choice sequence in run order, one entry per choice point it
+     * resolved, each spelling the alternatives and the one taken. Empty when the
+     * run faced no choice point.
+     * </pre>
+     *
+     * <code>repeated string witness = 6 [json_name = "witness"];</code>
+     * @param index The index of the value to return.
+     * @return The bytes of the witness at the given index.
+     */
+    public com.google.protobuf.ByteString
+        getWitnessBytes(int index) {
+      return witness_.getByteString(index);
+    }
+    /**
+     * <pre>
+     * One run's choice sequence in run order, one entry per choice point it
+     * resolved, each spelling the alternatives and the one taken. Empty when the
+     * run faced no choice point.
+     * </pre>
+     *
+     * <code>repeated string witness = 6 [json_name = "witness"];</code>
+     * @param index The index to set the value at.
+     * @param value The witness to set.
+     * @return This builder for chaining.
+     */
+    public Builder setWitness(
+        int index, java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureWitnessIsMutable();
+      witness_.set(index, value);
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * One run's choice sequence in run order, one entry per choice point it
+     * resolved, each spelling the alternatives and the one taken. Empty when the
+     * run faced no choice point.
+     * </pre>
+     *
+     * <code>repeated string witness = 6 [json_name = "witness"];</code>
+     * @param value The witness to add.
+     * @return This builder for chaining.
+     */
+    public Builder addWitness(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      ensureWitnessIsMutable();
+      witness_.add(value);
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * One run's choice sequence in run order, one entry per choice point it
+     * resolved, each spelling the alternatives and the one taken. Empty when the
+     * run faced no choice point.
+     * </pre>
+     *
+     * <code>repeated string witness = 6 [json_name = "witness"];</code>
+     * @param values The witness to add.
+     * @return This builder for chaining.
+     */
+    public Builder addAllWitness(
+        java.lang.Iterable<java.lang.String> values) {
+      ensureWitnessIsMutable();
+      com.google.protobuf.AbstractMessageLite.Builder.addAll(
+          values, witness_);
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * One run's choice sequence in run order, one entry per choice point it
+     * resolved, each spelling the alternatives and the one taken. Empty when the
+     * run faced no choice point.
+     * </pre>
+     *
+     * <code>repeated string witness = 6 [json_name = "witness"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearWitness() {
+      witness_ =
+        com.google.protobuf.LazyStringArrayList.emptyList();
+      bitField0_ = (bitField0_ & ~0x00000020);;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * One run's choice sequence in run order, one entry per choice point it
+     * resolved, each spelling the alternatives and the one taken. Empty when the
+     * run faced no choice point.
+     * </pre>
+     *
+     * <code>repeated string witness = 6 [json_name = "witness"];</code>
+     * @param value The bytes of the witness to add.
+     * @return This builder for chaining.
+     */
+    public Builder addWitnessBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      ensureWitnessIsMutable();
+      witness_.add(value);
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+
+    private java.util.List<org.openmbee.opensysml.proto.Diagnostic> diagnostics_ =
+      java.util.Collections.emptyList();
+    private void ensureDiagnosticsIsMutable() {
+      if (!((bitField0_ & 0x00000040) != 0)) {
+        diagnostics_ = new java.util.ArrayList<org.openmbee.opensysml.proto.Diagnostic>(diagnostics_);
+        bitField0_ |= 0x00000040;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> diagnosticsBuilder_;
+
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Diagnostic> getDiagnosticsList() {
+      if (diagnosticsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(diagnostics_);
+      } else {
+        return diagnosticsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public int getDiagnosticsCount() {
+      if (diagnosticsBuilder_ == null) {
+        return diagnostics_.size();
+      } else {
+        return diagnosticsBuilder_.getCount();
+      }
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index) {
+      if (diagnosticsBuilder_ == null) {
+        return diagnostics_.get(index);
+      } else {
+        return diagnosticsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public Builder setDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic value) {
+      if (diagnosticsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDiagnosticsIsMutable();
+        diagnostics_.set(index, value);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public Builder setDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        diagnosticsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(org.openmbee.opensysml.proto.Diagnostic value) {
+      if (diagnosticsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(value);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic value) {
+      if (diagnosticsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(index, value);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(
+        org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(builderForValue.build());
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public Builder addAllDiagnostics(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.Diagnostic> values) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, diagnostics_);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public Builder clearDiagnostics() {
+      if (diagnosticsBuilder_ == null) {
+        diagnostics_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000040);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public Builder removeDiagnostics(int index) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.remove(index);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic.Builder getDiagnosticsBuilder(
+        int index) {
+      return internalGetDiagnosticsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
+        int index) {
+      if (diagnosticsBuilder_ == null) {
+        return diagnostics_.get(index);  } else {
+        return diagnosticsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+         getDiagnosticsOrBuilderList() {
+      if (diagnosticsBuilder_ != null) {
+        return diagnosticsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(diagnostics_);
+      }
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder() {
+      return internalGetDiagnosticsFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.Diagnostic.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder(
+        int index) {
+      return internalGetDiagnosticsFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.Diagnostic.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * What the witness run noted about itself: its choice points and the guards
+     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+     * one run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Diagnostic.Builder> 
+         getDiagnosticsBuilderList() {
+      return internalGetDiagnosticsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+        internalGetDiagnosticsFieldBuilder() {
+      if (diagnosticsBuilder_ == null) {
+        diagnosticsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder>(
+                diagnostics_,
+                ((bitField0_ & 0x00000040) != 0),
+                getParentForChildren(),
+                isClean());
+        diagnostics_ = null;
+      }
+      return diagnosticsBuilder_;
+    }
+
+    private double probability_ ;
+    /**
+     * <pre>
+     * The probability of the linearizations reaching this outcome, as explore
+     * computes it; a lower bound when the exploration is incomplete.
+     * </pre>
+     *
+     * <code>double probability = 8 [json_name = "probability"];</code>
+     * @return The probability.
+     */
+    @java.lang.Override
+    public double getProbability() {
+      return probability_;
+    }
+    /**
+     * <pre>
+     * The probability of the linearizations reaching this outcome, as explore
+     * computes it; a lower bound when the exploration is incomplete.
+     * </pre>
+     *
+     * <code>double probability = 8 [json_name = "probability"];</code>
+     * @param value The probability to set.
+     * @return This builder for chaining.
+     */
+    public Builder setProbability(double value) {
+
+      probability_ = value;
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The probability of the linearizations reaching this outcome, as explore
+     * computes it; a lower bound when the exploration is incomplete.
+     * </pre>
+     *
+     * <code>double probability = 8 [json_name = "probability"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearProbability() {
+      bitField0_ = (bitField0_ & ~0x00000080);
+      probability_ = 0D;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.Outcome)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.Outcome)
+  private static final org.openmbee.opensysml.proto.Outcome DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.Outcome();
+  }
+
+  public static org.openmbee.opensysml.proto.Outcome getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<Outcome>
+      PARSER = new com.google.protobuf.AbstractParser<Outcome>() {
+    @java.lang.Override
+    public Outcome parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<Outcome> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<Outcome> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Outcome getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/OutcomeOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/OutcomeOrBuilder.java
similarity index 96%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/OutcomeOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/OutcomeOrBuilder.java
index f142cd4382..548fe00f29 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/OutcomeOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/OutcomeOrBuilder.java
@@ -248,4 +248,15 @@ org.openmbee.opensysml.proto.Value getOutputsOrThrow(
    */
   org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
       int index);
+
+  /**
+   * <pre>
+   * The probability of the linearizations reaching this outcome, as explore
+   * computes it; a lower bound when the exploration is incomplete.
+   * </pre>
+   *
+   * <code>double probability = 8 [json_name = "probability"];</code>
+   * @return The probability.
+   */
+  double getProbability();
 }
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequest.java
similarity index 99%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequest.java
index a982d6911a..d2a1b0d87c 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequest.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequest.java
@@ -206,7 +206,7 @@ public java.lang.String getContent() {
    *
    * <code>string content_hash = 3 [json_name = "contentHash", deprecated = true];</code>
    * @deprecated sysml.ParseFileRequest.content_hash is deprecated.
-   *     See sysml.proto;l=488
+   *     See sysml.proto;l=553
    * @return The contentHash.
    */
   @java.lang.Override
@@ -230,7 +230,7 @@ public java.lang.String getContent() {
    *
    * <code>string content_hash = 3 [json_name = "contentHash", deprecated = true];</code>
    * @deprecated sysml.ParseFileRequest.content_hash is deprecated.
-   *     See sysml.proto;l=488
+   *     See sysml.proto;l=553
    * @return The bytes for contentHash.
    */
   @java.lang.Override
@@ -942,7 +942,7 @@ public Builder setContentBytes(
      *
      * <code>string content_hash = 3 [json_name = "contentHash", deprecated = true];</code>
      * @deprecated sysml.ParseFileRequest.content_hash is deprecated.
-     *     See sysml.proto;l=488
+     *     See sysml.proto;l=553
      * @return The contentHash.
      */
     @java.lang.Deprecated public java.lang.String getContentHash() {
@@ -965,7 +965,7 @@ public Builder setContentBytes(
      *
      * <code>string content_hash = 3 [json_name = "contentHash", deprecated = true];</code>
      * @deprecated sysml.ParseFileRequest.content_hash is deprecated.
-     *     See sysml.proto;l=488
+     *     See sysml.proto;l=553
      * @return The bytes for contentHash.
      */
     @java.lang.Deprecated public com.google.protobuf.ByteString
@@ -989,7 +989,7 @@ public Builder setContentBytes(
      *
      * <code>string content_hash = 3 [json_name = "contentHash", deprecated = true];</code>
      * @deprecated sysml.ParseFileRequest.content_hash is deprecated.
-     *     See sysml.proto;l=488
+     *     See sysml.proto;l=553
      * @param value The contentHash to set.
      * @return This builder for chaining.
      */
@@ -1009,7 +1009,7 @@ public Builder setContentBytes(
      *
      * <code>string content_hash = 3 [json_name = "contentHash", deprecated = true];</code>
      * @deprecated sysml.ParseFileRequest.content_hash is deprecated.
-     *     See sysml.proto;l=488
+     *     See sysml.proto;l=553
      * @return This builder for chaining.
      */
     @java.lang.Deprecated public Builder clearContentHash() {
@@ -1026,7 +1026,7 @@ public Builder setContentBytes(
      *
      * <code>string content_hash = 3 [json_name = "contentHash", deprecated = true];</code>
      * @deprecated sysml.ParseFileRequest.content_hash is deprecated.
-     *     See sysml.proto;l=488
+     *     See sysml.proto;l=553
      * @param value The bytes for contentHash to set.
      * @return This builder for chaining.
      */
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequestOrBuilder.java
similarity index 98%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequestOrBuilder.java
index 65de261239..e624aae917 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequestOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileRequestOrBuilder.java
@@ -52,7 +52,7 @@ public interface ParseFileRequestOrBuilder extends
    *
    * <code>string content_hash = 3 [json_name = "contentHash", deprecated = true];</code>
    * @deprecated sysml.ParseFileRequest.content_hash is deprecated.
-   *     See sysml.proto;l=488
+   *     See sysml.proto;l=553
    * @return The contentHash.
    */
   @java.lang.Deprecated java.lang.String getContentHash();
@@ -64,7 +64,7 @@ public interface ParseFileRequestOrBuilder extends
    *
    * <code>string content_hash = 3 [json_name = "contentHash", deprecated = true];</code>
    * @deprecated sysml.ParseFileRequest.content_hash is deprecated.
-   *     See sysml.proto;l=488
+   *     See sysml.proto;l=553
    * @return The bytes for contentHash.
    */
   @java.lang.Deprecated com.google.protobuf.ByteString
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseFileResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesRequest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesRequest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ParseSourcesResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveConstraint.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveConstraint.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveConstraint.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveConstraint.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveConstraintOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveConstraintOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveConstraintOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveConstraintOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveOperator.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveOperator.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveOperator.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/PrimitiveOperator.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Quantity.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Quantity.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Quantity.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Quantity.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QuantityOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QuantityOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QuantityOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QuantityOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Query.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Query.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Query.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Query.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryRequest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryRequest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResultElement.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResultElement.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResultElement.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResultElement.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResultElementOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResultElementOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResultElementOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/QueryResultElementOrBuilder.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Referrer.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Referrer.java
new file mode 100644
index 0000000000..eda3381d18
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Referrer.java
@@ -0,0 +1,709 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * Referrer is one declaration referring to the target of a refused edit.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.Referrer}
+ */
+@com.google.protobuf.Generated
+public final class Referrer extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.Referrer)
+    ReferrerOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "Referrer");
+  }
+  // Use Referrer.newBuilder() to construct.
+  private Referrer(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private Referrer() {
+    name_ = "";
+    document_ = "";
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Referrer_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Referrer_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.Referrer.class, org.openmbee.opensysml.proto.Referrer.Builder.class);
+  }
+
+  public static final int NAME_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object name_ = "";
+  /**
+   * <pre>
+   * The declaration as the notation names it: the FQN of a named one, or the
+   * heading of an anonymous one within its namespace.
+   * </pre>
+   *
+   * <code>string name = 1 [json_name = "name"];</code>
+   * @return The name.
+   */
+  @java.lang.Override
+  public java.lang.String getName() {
+    java.lang.Object ref = name_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      name_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The declaration as the notation names it: the FQN of a named one, or the
+   * heading of an anonymous one within its namespace.
+   * </pre>
+   *
+   * <code>string name = 1 [json_name = "name"];</code>
+   * @return The bytes for name.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getNameBytes() {
+    java.lang.Object ref = name_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      name_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int DOCUMENT_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object document_ = "";
+  /**
+   * <pre>
+   * The document declaring it, named as the parse request named it.
+   * </pre>
+   *
+   * <code>string document = 2 [json_name = "document"];</code>
+   * @return The document.
+   */
+  @java.lang.Override
+  public java.lang.String getDocument() {
+    java.lang.Object ref = document_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      document_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The document declaring it, named as the parse request named it.
+   * </pre>
+   *
+   * <code>string document = 2 [json_name = "document"];</code>
+   * @return The bytes for document.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getDocumentBytes() {
+    java.lang.Object ref = document_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      document_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(name_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 1, name_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(document_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, document_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(name_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, name_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(document_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, document_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.Referrer)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.Referrer other = (org.openmbee.opensysml.proto.Referrer) obj;
+
+    if (!getName()
+        .equals(other.getName())) return false;
+    if (!getDocument()
+        .equals(other.getDocument())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + NAME_FIELD_NUMBER;
+    hash = (53 * hash) + getName().hashCode();
+    hash = (37 * hash) + DOCUMENT_FIELD_NUMBER;
+    hash = (53 * hash) + getDocument().hashCode();
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.Referrer parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.Referrer parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Referrer parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.Referrer parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Referrer parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.Referrer parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Referrer parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.Referrer parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.Referrer parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.Referrer parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Referrer parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.Referrer parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.Referrer prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * Referrer is one declaration referring to the target of a refused edit.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.Referrer}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.Referrer)
+      org.openmbee.opensysml.proto.ReferrerOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Referrer_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Referrer_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.Referrer.class, org.openmbee.opensysml.proto.Referrer.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.Referrer.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      name_ = "";
+      document_ = "";
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Referrer_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Referrer getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.Referrer.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Referrer build() {
+      org.openmbee.opensysml.proto.Referrer result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Referrer buildPartial() {
+      org.openmbee.opensysml.proto.Referrer result = new org.openmbee.opensysml.proto.Referrer(this);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.Referrer result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.name_ = name_;
+      }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.document_ = document_;
+      }
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.Referrer) {
+        return mergeFrom((org.openmbee.opensysml.proto.Referrer)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.Referrer other) {
+      if (other == org.openmbee.opensysml.proto.Referrer.getDefaultInstance()) return this;
+      if (!other.getName().isEmpty()) {
+        name_ = other.name_;
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      if (!other.getDocument().isEmpty()) {
+        document_ = other.document_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              name_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              document_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.lang.Object name_ = "";
+    /**
+     * <pre>
+     * The declaration as the notation names it: the FQN of a named one, or the
+     * heading of an anonymous one within its namespace.
+     * </pre>
+     *
+     * <code>string name = 1 [json_name = "name"];</code>
+     * @return The name.
+     */
+    public java.lang.String getName() {
+      java.lang.Object ref = name_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        name_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The declaration as the notation names it: the FQN of a named one, or the
+     * heading of an anonymous one within its namespace.
+     * </pre>
+     *
+     * <code>string name = 1 [json_name = "name"];</code>
+     * @return The bytes for name.
+     */
+    public com.google.protobuf.ByteString
+        getNameBytes() {
+      java.lang.Object ref = name_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        name_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The declaration as the notation names it: the FQN of a named one, or the
+     * heading of an anonymous one within its namespace.
+     * </pre>
+     *
+     * <code>string name = 1 [json_name = "name"];</code>
+     * @param value The name to set.
+     * @return This builder for chaining.
+     */
+    public Builder setName(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      name_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The declaration as the notation names it: the FQN of a named one, or the
+     * heading of an anonymous one within its namespace.
+     * </pre>
+     *
+     * <code>string name = 1 [json_name = "name"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearName() {
+      name_ = getDefaultInstance().getName();
+      bitField0_ = (bitField0_ & ~0x00000001);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The declaration as the notation names it: the FQN of a named one, or the
+     * heading of an anonymous one within its namespace.
+     * </pre>
+     *
+     * <code>string name = 1 [json_name = "name"];</code>
+     * @param value The bytes for name to set.
+     * @return This builder for chaining.
+     */
+    public Builder setNameBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      name_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object document_ = "";
+    /**
+     * <pre>
+     * The document declaring it, named as the parse request named it.
+     * </pre>
+     *
+     * <code>string document = 2 [json_name = "document"];</code>
+     * @return The document.
+     */
+    public java.lang.String getDocument() {
+      java.lang.Object ref = document_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        document_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The document declaring it, named as the parse request named it.
+     * </pre>
+     *
+     * <code>string document = 2 [json_name = "document"];</code>
+     * @return The bytes for document.
+     */
+    public com.google.protobuf.ByteString
+        getDocumentBytes() {
+      java.lang.Object ref = document_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        document_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The document declaring it, named as the parse request named it.
+     * </pre>
+     *
+     * <code>string document = 2 [json_name = "document"];</code>
+     * @param value The document to set.
+     * @return This builder for chaining.
+     */
+    public Builder setDocument(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      document_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The document declaring it, named as the parse request named it.
+     * </pre>
+     *
+     * <code>string document = 2 [json_name = "document"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearDocument() {
+      document_ = getDefaultInstance().getDocument();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The document declaring it, named as the parse request named it.
+     * </pre>
+     *
+     * <code>string document = 2 [json_name = "document"];</code>
+     * @param value The bytes for document to set.
+     * @return This builder for chaining.
+     */
+    public Builder setDocumentBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      document_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.Referrer)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.Referrer)
+  private static final org.openmbee.opensysml.proto.Referrer DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.Referrer();
+  }
+
+  public static org.openmbee.opensysml.proto.Referrer getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<Referrer>
+      PARSER = new com.google.protobuf.AbstractParser<Referrer>() {
+    @java.lang.Override
+    public Referrer parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<Referrer> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<Referrer> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Referrer getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ReferrerOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ReferrerOrBuilder.java
new file mode 100644
index 0000000000..ff8a42be24
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ReferrerOrBuilder.java
@@ -0,0 +1,54 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface ReferrerOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.Referrer)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * The declaration as the notation names it: the FQN of a named one, or the
+   * heading of an anonymous one within its namespace.
+   * </pre>
+   *
+   * <code>string name = 1 [json_name = "name"];</code>
+   * @return The name.
+   */
+  java.lang.String getName();
+  /**
+   * <pre>
+   * The declaration as the notation names it: the FQN of a named one, or the
+   * heading of an anonymous one within its namespace.
+   * </pre>
+   *
+   * <code>string name = 1 [json_name = "name"];</code>
+   * @return The bytes for name.
+   */
+  com.google.protobuf.ByteString
+      getNameBytes();
+
+  /**
+   * <pre>
+   * The document declaring it, named as the parse request named it.
+   * </pre>
+   *
+   * <code>string document = 2 [json_name = "document"];</code>
+   * @return The document.
+   */
+  java.lang.String getDocument();
+  /**
+   * <pre>
+   * The document declaring it, named as the parse request named it.
+   * </pre>
+   *
+   * <code>string document = 2 [json_name = "document"];</code>
+   * @return The bytes for document.
+   */
+  com.google.protobuf.ByteString
+      getDocumentBytes();
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenameEdit.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenameEdit.java
similarity index 97%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenameEdit.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenameEdit.java
index 94927fa964..a985223ca9 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenameEdit.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenameEdit.java
@@ -7,9 +7,10 @@
 
 /**
  * <pre>
- * RenameEdit rewrites the name token of a declaration. References to the
- * element are not updated: a rename of an element referenced anywhere is
- * refused, naming the referring elements, rather than leaving a broken model.
+ * RenameEdit rewrites the name token of a declaration and every reference to
+ * it in the model's documents. A rename that reaches a reference in a document
+ * the edit cannot rewrite is refused, naming the referring elements, rather
+ * than leaving a broken model.
  * </pre>
  *
  * Protobuf type {@code sysml.RenameEdit}
@@ -313,9 +314,10 @@ protected Builder newBuilderForType(
   }
   /**
    * <pre>
-   * RenameEdit rewrites the name token of a declaration. References to the
-   * element are not updated: a rename of an element referenced anywhere is
-   * refused, naming the referring elements, rather than leaving a broken model.
+   * RenameEdit rewrites the name token of a declaration and every reference to
+   * it in the model's documents. A rename that reaches a reference in a document
+   * the edit cannot rewrite is refused, naming the referring elements, rather
+   * than leaving a broken model.
    * </pre>
    *
    * Protobuf type {@code sysml.RenameEdit}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenameEditOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenameEditOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenameEditOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenameEditOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequest.java
similarity index 78%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequest.java
index 7cfeb61693..643a235a65 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequest.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequest.java
@@ -8,8 +8,8 @@
 /**
  * <pre>
  * RenderDocumentRequest renders a named document — a part def specializing
- * DocumentQueries::Document — to Markdown. A document binds its queries'
- * parameters in the model, so the request carries none.
+ * DocumentQueries::Document — to Markdown or HTML. A document binds its
+ * queries' parameters in the model, so the request carries none.
  * </pre>
  *
  * Protobuf type {@code sysml.RenderDocumentRequest}
@@ -36,6 +36,7 @@ private RenderDocumentRequest(com.google.protobuf.GeneratedMessage.Builder<?> bu
   private RenderDocumentRequest() {
     modelHash_ = "";
     documentId_ = "";
+    form_ = "";
   }
 
   public static final com.google.protobuf.Descriptors.Descriptor
@@ -147,6 +148,59 @@ public java.lang.String getDocumentId() {
     }
   }
 
+  public static final int FORM_FIELD_NUMBER = 3;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object form_ = "";
+  /**
+   * <pre>
+   * Form to render: "markdown" (the default when empty) or "html", the
+   * standalone page with the default stylesheet that the CLI's -doc-form html
+   * writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+   * converter toolchain and is not offered here.
+   * </pre>
+   *
+   * <code>string form = 3 [json_name = "form"];</code>
+   * @return The form.
+   */
+  @java.lang.Override
+  public java.lang.String getForm() {
+    java.lang.Object ref = form_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      form_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * Form to render: "markdown" (the default when empty) or "html", the
+   * standalone page with the default stylesheet that the CLI's -doc-form html
+   * writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+   * converter toolchain and is not offered here.
+   * </pre>
+   *
+   * <code>string form = 3 [json_name = "form"];</code>
+   * @return The bytes for form.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getFormBytes() {
+    java.lang.Object ref = form_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      form_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
   private byte memoizedIsInitialized = -1;
   @java.lang.Override
   public final boolean isInitialized() {
@@ -167,6 +221,9 @@ public void writeTo(com.google.protobuf.CodedOutputStream output)
     if (!com.google.protobuf.GeneratedMessage.isStringEmpty(documentId_)) {
       com.google.protobuf.GeneratedMessage.writeString(output, 2, documentId_);
     }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(form_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 3, form_);
+    }
     getUnknownFields().writeTo(output);
   }
 
@@ -182,6 +239,9 @@ public int getSerializedSize() {
     if (!com.google.protobuf.GeneratedMessage.isStringEmpty(documentId_)) {
       size += com.google.protobuf.GeneratedMessage.computeStringSize(2, documentId_);
     }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(form_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(3, form_);
+    }
     size += getUnknownFields().getSerializedSize();
     memoizedSize = size;
     return size;
@@ -201,6 +261,8 @@ public boolean equals(final java.lang.Object obj) {
         .equals(other.getModelHash())) return false;
     if (!getDocumentId()
         .equals(other.getDocumentId())) return false;
+    if (!getForm()
+        .equals(other.getForm())) return false;
     if (!getUnknownFields().equals(other.getUnknownFields())) return false;
     return true;
   }
@@ -216,6 +278,8 @@ public int hashCode() {
     hash = (53 * hash) + getModelHash().hashCode();
     hash = (37 * hash) + DOCUMENT_ID_FIELD_NUMBER;
     hash = (53 * hash) + getDocumentId().hashCode();
+    hash = (37 * hash) + FORM_FIELD_NUMBER;
+    hash = (53 * hash) + getForm().hashCode();
     hash = (29 * hash) + getUnknownFields().hashCode();
     memoizedHashCode = hash;
     return hash;
@@ -316,8 +380,8 @@ protected Builder newBuilderForType(
   /**
    * <pre>
    * RenderDocumentRequest renders a named document — a part def specializing
-   * DocumentQueries::Document — to Markdown. A document binds its queries'
-   * parameters in the model, so the request carries none.
+   * DocumentQueries::Document — to Markdown or HTML. A document binds its
+   * queries' parameters in the model, so the request carries none.
    * </pre>
    *
    * Protobuf type {@code sysml.RenderDocumentRequest}
@@ -355,6 +419,7 @@ public Builder clear() {
       bitField0_ = 0;
       modelHash_ = "";
       documentId_ = "";
+      form_ = "";
       return this;
     }
 
@@ -394,6 +459,9 @@ private void buildPartial0(org.openmbee.opensysml.proto.RenderDocumentRequest re
       if (((from_bitField0_ & 0x00000002) != 0)) {
         result.documentId_ = documentId_;
       }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.form_ = form_;
+      }
     }
 
     @java.lang.Override
@@ -418,6 +486,11 @@ public Builder mergeFrom(org.openmbee.opensysml.proto.RenderDocumentRequest othe
         bitField0_ |= 0x00000002;
         onChanged();
       }
+      if (!other.getForm().isEmpty()) {
+        form_ = other.form_;
+        bitField0_ |= 0x00000004;
+        onChanged();
+      }
       this.mergeUnknownFields(other.getUnknownFields());
       onChanged();
       return this;
@@ -454,6 +527,11 @@ public Builder mergeFrom(
               bitField0_ |= 0x00000002;
               break;
             } // case 18
+            case 26: {
+              form_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 26
             default: {
               if (!super.parseUnknownField(input, extensionRegistry, tag)) {
                 done = true; // was an endgroup tag
@@ -660,6 +738,113 @@ public Builder setDocumentIdBytes(
       return this;
     }
 
+    private java.lang.Object form_ = "";
+    /**
+     * <pre>
+     * Form to render: "markdown" (the default when empty) or "html", the
+     * standalone page with the default stylesheet that the CLI's -doc-form html
+     * writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+     * converter toolchain and is not offered here.
+     * </pre>
+     *
+     * <code>string form = 3 [json_name = "form"];</code>
+     * @return The form.
+     */
+    public java.lang.String getForm() {
+      java.lang.Object ref = form_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        form_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Form to render: "markdown" (the default when empty) or "html", the
+     * standalone page with the default stylesheet that the CLI's -doc-form html
+     * writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+     * converter toolchain and is not offered here.
+     * </pre>
+     *
+     * <code>string form = 3 [json_name = "form"];</code>
+     * @return The bytes for form.
+     */
+    public com.google.protobuf.ByteString
+        getFormBytes() {
+      java.lang.Object ref = form_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        form_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Form to render: "markdown" (the default when empty) or "html", the
+     * standalone page with the default stylesheet that the CLI's -doc-form html
+     * writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+     * converter toolchain and is not offered here.
+     * </pre>
+     *
+     * <code>string form = 3 [json_name = "form"];</code>
+     * @param value The form to set.
+     * @return This builder for chaining.
+     */
+    public Builder setForm(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      form_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Form to render: "markdown" (the default when empty) or "html", the
+     * standalone page with the default stylesheet that the CLI's -doc-form html
+     * writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+     * converter toolchain and is not offered here.
+     * </pre>
+     *
+     * <code>string form = 3 [json_name = "form"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearForm() {
+      form_ = getDefaultInstance().getForm();
+      bitField0_ = (bitField0_ & ~0x00000004);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Form to render: "markdown" (the default when empty) or "html", the
+     * standalone page with the default stylesheet that the CLI's -doc-form html
+     * writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+     * converter toolchain and is not offered here.
+     * </pre>
+     *
+     * <code>string form = 3 [json_name = "form"];</code>
+     * @param value The bytes for form to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFormBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      form_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+
     // @@protoc_insertion_point(builder_scope:sysml.RenderDocumentRequest)
   }
 
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequestOrBuilder.java
new file mode 100644
index 0000000000..76a79380fa
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequestOrBuilder.java
@@ -0,0 +1,80 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface RenderDocumentRequestOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.RenderDocumentRequest)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * from ParseFile response
+   * </pre>
+   *
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The modelHash.
+   */
+  java.lang.String getModelHash();
+  /**
+   * <pre>
+   * from ParseFile response
+   * </pre>
+   *
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The bytes for modelHash.
+   */
+  com.google.protobuf.ByteString
+      getModelHashBytes();
+
+  /**
+   * <pre>
+   * FQN of the document to render. Fails with NOT_FOUND when the model does
+   * not declare it, and INVALID_ARGUMENT when it declares something else.
+   * </pre>
+   *
+   * <code>string document_id = 2 [json_name = "documentId"];</code>
+   * @return The documentId.
+   */
+  java.lang.String getDocumentId();
+  /**
+   * <pre>
+   * FQN of the document to render. Fails with NOT_FOUND when the model does
+   * not declare it, and INVALID_ARGUMENT when it declares something else.
+   * </pre>
+   *
+   * <code>string document_id = 2 [json_name = "documentId"];</code>
+   * @return The bytes for documentId.
+   */
+  com.google.protobuf.ByteString
+      getDocumentIdBytes();
+
+  /**
+   * <pre>
+   * Form to render: "markdown" (the default when empty) or "html", the
+   * standalone page with the default stylesheet that the CLI's -doc-form html
+   * writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+   * converter toolchain and is not offered here.
+   * </pre>
+   *
+   * <code>string form = 3 [json_name = "form"];</code>
+   * @return The form.
+   */
+  java.lang.String getForm();
+  /**
+   * <pre>
+   * Form to render: "markdown" (the default when empty) or "html", the
+   * standalone page with the default stylesheet that the CLI's -doc-form html
+   * writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+   * converter toolchain and is not offered here.
+   * </pre>
+   *
+   * <code>string form = 3 [json_name = "form"];</code>
+   * @return The bytes for form.
+   */
+  com.google.protobuf.ByteString
+      getFormBytes();
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponse.java
similarity index 79%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponse.java
index 1bfbb8cfda..8228c2d170 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponse.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponse.java
@@ -7,8 +7,9 @@
 
 /**
  * <pre>
- * RenderDocumentResponse carries the rendered Markdown, byte-for-byte what the
- * CLI's -render-document writes.
+ * RenderDocumentResponse carries the rendered document in the form requested,
+ * byte-for-byte what the CLI's -render-document writes: markdown for the
+ * Markdown form, html for the HTML form, the other left empty.
  * </pre>
  *
  * Protobuf type {@code sysml.RenderDocumentResponse}
@@ -34,6 +35,7 @@ private RenderDocumentResponse(com.google.protobuf.GeneratedMessage.Builder<?> b
   }
   private RenderDocumentResponse() {
     markdown_ = "";
+    html_ = "";
   }
 
   public static final com.google.protobuf.Descriptors.Descriptor
@@ -88,6 +90,45 @@ public java.lang.String getMarkdown() {
     }
   }
 
+  public static final int HTML_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object html_ = "";
+  /**
+   * <code>string html = 2 [json_name = "html"];</code>
+   * @return The html.
+   */
+  @java.lang.Override
+  public java.lang.String getHtml() {
+    java.lang.Object ref = html_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      html_ = s;
+      return s;
+    }
+  }
+  /**
+   * <code>string html = 2 [json_name = "html"];</code>
+   * @return The bytes for html.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getHtmlBytes() {
+    java.lang.Object ref = html_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      html_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
   private byte memoizedIsInitialized = -1;
   @java.lang.Override
   public final boolean isInitialized() {
@@ -105,6 +146,9 @@ public void writeTo(com.google.protobuf.CodedOutputStream output)
     if (!com.google.protobuf.GeneratedMessage.isStringEmpty(markdown_)) {
       com.google.protobuf.GeneratedMessage.writeString(output, 1, markdown_);
     }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(html_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, html_);
+    }
     getUnknownFields().writeTo(output);
   }
 
@@ -117,6 +161,9 @@ public int getSerializedSize() {
     if (!com.google.protobuf.GeneratedMessage.isStringEmpty(markdown_)) {
       size += com.google.protobuf.GeneratedMessage.computeStringSize(1, markdown_);
     }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(html_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, html_);
+    }
     size += getUnknownFields().getSerializedSize();
     memoizedSize = size;
     return size;
@@ -134,6 +181,8 @@ public boolean equals(final java.lang.Object obj) {
 
     if (!getMarkdown()
         .equals(other.getMarkdown())) return false;
+    if (!getHtml()
+        .equals(other.getHtml())) return false;
     if (!getUnknownFields().equals(other.getUnknownFields())) return false;
     return true;
   }
@@ -147,6 +196,8 @@ public int hashCode() {
     hash = (19 * hash) + getDescriptor().hashCode();
     hash = (37 * hash) + MARKDOWN_FIELD_NUMBER;
     hash = (53 * hash) + getMarkdown().hashCode();
+    hash = (37 * hash) + HTML_FIELD_NUMBER;
+    hash = (53 * hash) + getHtml().hashCode();
     hash = (29 * hash) + getUnknownFields().hashCode();
     memoizedHashCode = hash;
     return hash;
@@ -246,8 +297,9 @@ protected Builder newBuilderForType(
   }
   /**
    * <pre>
-   * RenderDocumentResponse carries the rendered Markdown, byte-for-byte what the
-   * CLI's -render-document writes.
+   * RenderDocumentResponse carries the rendered document in the form requested,
+   * byte-for-byte what the CLI's -render-document writes: markdown for the
+   * Markdown form, html for the HTML form, the other left empty.
    * </pre>
    *
    * Protobuf type {@code sysml.RenderDocumentResponse}
@@ -284,6 +336,7 @@ public Builder clear() {
       super.clear();
       bitField0_ = 0;
       markdown_ = "";
+      html_ = "";
       return this;
     }
 
@@ -320,6 +373,9 @@ private void buildPartial0(org.openmbee.opensysml.proto.RenderDocumentResponse r
       if (((from_bitField0_ & 0x00000001) != 0)) {
         result.markdown_ = markdown_;
       }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.html_ = html_;
+      }
     }
 
     @java.lang.Override
@@ -339,6 +395,11 @@ public Builder mergeFrom(org.openmbee.opensysml.proto.RenderDocumentResponse oth
         bitField0_ |= 0x00000001;
         onChanged();
       }
+      if (!other.getHtml().isEmpty()) {
+        html_ = other.html_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
       this.mergeUnknownFields(other.getUnknownFields());
       onChanged();
       return this;
@@ -370,6 +431,11 @@ public Builder mergeFrom(
               bitField0_ |= 0x00000001;
               break;
             } // case 10
+            case 18: {
+              html_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
             default: {
               if (!super.parseUnknownField(input, extensionRegistry, tag)) {
                 done = true; // was an endgroup tag
@@ -459,6 +525,78 @@ public Builder setMarkdownBytes(
       return this;
     }
 
+    private java.lang.Object html_ = "";
+    /**
+     * <code>string html = 2 [json_name = "html"];</code>
+     * @return The html.
+     */
+    public java.lang.String getHtml() {
+      java.lang.Object ref = html_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        html_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <code>string html = 2 [json_name = "html"];</code>
+     * @return The bytes for html.
+     */
+    public com.google.protobuf.ByteString
+        getHtmlBytes() {
+      java.lang.Object ref = html_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        html_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <code>string html = 2 [json_name = "html"];</code>
+     * @param value The html to set.
+     * @return This builder for chaining.
+     */
+    public Builder setHtml(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      html_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string html = 2 [json_name = "html"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearHtml() {
+      html_ = getDefaultInstance().getHtml();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string html = 2 [json_name = "html"];</code>
+     * @param value The bytes for html to set.
+     * @return This builder for chaining.
+     */
+    public Builder setHtmlBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      html_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
     // @@protoc_insertion_point(builder_scope:sysml.RenderDocumentResponse)
   }
 
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponseOrBuilder.java
new file mode 100644
index 0000000000..da29ef9058
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponseOrBuilder.java
@@ -0,0 +1,36 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface RenderDocumentResponseOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.RenderDocumentResponse)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <code>string markdown = 1 [json_name = "markdown"];</code>
+   * @return The markdown.
+   */
+  java.lang.String getMarkdown();
+  /**
+   * <code>string markdown = 1 [json_name = "markdown"];</code>
+   * @return The bytes for markdown.
+   */
+  com.google.protobuf.ByteString
+      getMarkdownBytes();
+
+  /**
+   * <code>string html = 2 [json_name = "html"];</code>
+   * @return The html.
+   */
+  java.lang.String getHtml();
+  /**
+   * <code>string html = 2 [json_name = "html"];</code>
+   * @return The bytes for html.
+   */
+  com.google.protobuf.ByteString
+      getHtmlBytes();
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequest.java
similarity index 97%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequest.java
index b49515844d..871051afa5 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequest.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequest.java
@@ -161,7 +161,10 @@ public java.lang.String getSymbolId() {
    * <pre>
    * Optional FQN of a part/usage to instantiate as the case's subject. Empty
    * leaves the case's own `subject s = ...` binding to supply it; a case that
-   * binds none and is given none fails to run.
+   * binds none and is given none fails to run. A path from a declaration, as
+   * `Mission::mission.vehicle`, makes the subject the object the path reaches in
+   * an object of the declaration built for the run — every explored run its own
+   * — so what the assembly binds and connects on it is in force.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -184,7 +187,10 @@ public java.lang.String getSubjectSymbolId() {
    * <pre>
    * Optional FQN of a part/usage to instantiate as the case's subject. Empty
    * leaves the case's own `subject s = ...` binding to supply it; a case that
-   * binds none and is given none fails to run.
+   * binds none and is given none fails to run. A path from a declaration, as
+   * `Mission::mission.vehicle`, makes the subject the object the path reaches in
+   * an object of the declaration built for the run — every explored run its own
+   * — so what the assembly binds and connects on it is in force.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -1168,7 +1174,10 @@ public Builder setSymbolIdBytes(
      * <pre>
      * Optional FQN of a part/usage to instantiate as the case's subject. Empty
      * leaves the case's own `subject s = ...` binding to supply it; a case that
-     * binds none and is given none fails to run.
+     * binds none and is given none fails to run. A path from a declaration, as
+     * `Mission::mission.vehicle`, makes the subject the object the path reaches in
+     * an object of the declaration built for the run — every explored run its own
+     * — so what the assembly binds and connects on it is in force.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -1190,7 +1199,10 @@ public java.lang.String getSubjectSymbolId() {
      * <pre>
      * Optional FQN of a part/usage to instantiate as the case's subject. Empty
      * leaves the case's own `subject s = ...` binding to supply it; a case that
-     * binds none and is given none fails to run.
+     * binds none and is given none fails to run. A path from a declaration, as
+     * `Mission::mission.vehicle`, makes the subject the object the path reaches in
+     * an object of the declaration built for the run — every explored run its own
+     * — so what the assembly binds and connects on it is in force.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -1213,7 +1225,10 @@ public java.lang.String getSubjectSymbolId() {
      * <pre>
      * Optional FQN of a part/usage to instantiate as the case's subject. Empty
      * leaves the case's own `subject s = ...` binding to supply it; a case that
-     * binds none and is given none fails to run.
+     * binds none and is given none fails to run. A path from a declaration, as
+     * `Mission::mission.vehicle`, makes the subject the object the path reaches in
+     * an object of the declaration built for the run — every explored run its own
+     * — so what the assembly binds and connects on it is in force.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -1232,7 +1247,10 @@ public Builder setSubjectSymbolId(
      * <pre>
      * Optional FQN of a part/usage to instantiate as the case's subject. Empty
      * leaves the case's own `subject s = ...` binding to supply it; a case that
-     * binds none and is given none fails to run.
+     * binds none and is given none fails to run. A path from a declaration, as
+     * `Mission::mission.vehicle`, makes the subject the object the path reaches in
+     * an object of the declaration built for the run — every explored run its own
+     * — so what the assembly binds and connects on it is in force.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -1248,7 +1266,10 @@ public Builder clearSubjectSymbolId() {
      * <pre>
      * Optional FQN of a part/usage to instantiate as the case's subject. Empty
      * leaves the case's own `subject s = ...` binding to supply it; a case that
-     * binds none and is given none fails to run.
+     * binds none and is given none fails to run. A path from a declaration, as
+     * `Mission::mission.vehicle`, makes the subject the object the path reaches in
+     * an object of the declaration built for the run — every explored run its own
+     * — so what the assembly binds and connects on it is in force.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequestOrBuilder.java
similarity index 91%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequestOrBuilder.java
index a9b2dd6c0f..196368c6ca 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequestOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisRequestOrBuilder.java
@@ -46,7 +46,10 @@ public interface RunAnalysisRequestOrBuilder extends
    * <pre>
    * Optional FQN of a part/usage to instantiate as the case's subject. Empty
    * leaves the case's own `subject s = ...` binding to supply it; a case that
-   * binds none and is given none fails to run.
+   * binds none and is given none fails to run. A path from a declaration, as
+   * `Mission::mission.vehicle`, makes the subject the object the path reaches in
+   * an object of the declaration built for the run — every explored run its own
+   * — so what the assembly binds and connects on it is in force.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -57,7 +60,10 @@ public interface RunAnalysisRequestOrBuilder extends
    * <pre>
    * Optional FQN of a part/usage to instantiate as the case's subject. Empty
    * leaves the case's own `subject s = ...` binding to supply it; a case that
-   * binds none and is given none fails to run.
+   * binds none and is given none fails to run. A path from a declaration, as
+   * `Mission::mission.vehicle`, makes the subject the object the path reaches in
+   * an object of the declaration built for the run — every explored run its own
+   * — so what the assembly binds and connects on it is in force.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunAnalysisResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryRequest.java
similarity index 98%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryRequest.java
index b4c9ff8acb..0f4e472fd6 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryRequest.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryRequest.java
@@ -10,7 +10,9 @@
  * RunDocumentQueryRequest runs a named document query — a calc def
  * specializing DocumentQueries::Query — against a model the service already
  * parsed, binding its entry parameters. It answers as %run-query does, but with
- * typed rows rather than formatted lines.
+ * typed rows rather than formatted lines. The query runs over the objects
+ * Instantiate created for the model, which a binding may name (DocumentObject)
+ * and `DocumentQueries::Objects` and `Verdicts` reach.
  * </pre>
  *
  * Protobuf type {@code sysml.RunDocumentQueryRequest}
@@ -399,7 +401,9 @@ protected Builder newBuilderForType(
    * RunDocumentQueryRequest runs a named document query — a calc def
    * specializing DocumentQueries::Query — against a model the service already
    * parsed, binding its entry parameters. It answers as %run-query does, but with
-   * typed rows rather than formatted lines.
+   * typed rows rather than formatted lines. The query runs over the objects
+   * Instantiate created for the model, which a binding may name (DocumentObject)
+   * and `DocumentQueries::Objects` and `Verdicts` reach.
    * </pre>
    *
    * Protobuf type {@code sysml.RunDocumentQueryRequest}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunDocumentQueryResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequest.java
similarity index 99%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequest.java
index 6c250af93b..1347d5f963 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequest.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequest.java
@@ -159,7 +159,8 @@ public java.lang.String getSymbolId() {
   private volatile java.lang.Object subjectSymbolId_ = "";
   /**
    * <pre>
-   * Optional FQN of a part/usage to instantiate as an analysis case's subject.
+   * Optional FQN of a part/usage to instantiate as an analysis case's subject,
+   * or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -180,7 +181,8 @@ public java.lang.String getSubjectSymbolId() {
   }
   /**
    * <pre>
-   * Optional FQN of a part/usage to instantiate as an analysis case's subject.
+   * Optional FQN of a part/usage to instantiate as an analysis case's subject,
+   * or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -1295,7 +1297,8 @@ public Builder setSymbolIdBytes(
     private java.lang.Object subjectSymbolId_ = "";
     /**
      * <pre>
-     * Optional FQN of a part/usage to instantiate as an analysis case's subject.
+     * Optional FQN of a part/usage to instantiate as an analysis case's subject,
+     * or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -1315,7 +1318,8 @@ public java.lang.String getSubjectSymbolId() {
     }
     /**
      * <pre>
-     * Optional FQN of a part/usage to instantiate as an analysis case's subject.
+     * Optional FQN of a part/usage to instantiate as an analysis case's subject,
+     * or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -1336,7 +1340,8 @@ public java.lang.String getSubjectSymbolId() {
     }
     /**
      * <pre>
-     * Optional FQN of a part/usage to instantiate as an analysis case's subject.
+     * Optional FQN of a part/usage to instantiate as an analysis case's subject,
+     * or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -1353,7 +1358,8 @@ public Builder setSubjectSymbolId(
     }
     /**
      * <pre>
-     * Optional FQN of a part/usage to instantiate as an analysis case's subject.
+     * Optional FQN of a part/usage to instantiate as an analysis case's subject,
+     * or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -1367,7 +1373,8 @@ public Builder clearSubjectSymbolId() {
     }
     /**
      * <pre>
-     * Optional FQN of a part/usage to instantiate as an analysis case's subject.
+     * Optional FQN of a part/usage to instantiate as an analysis case's subject,
+     * or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequestOrBuilder.java
similarity index 97%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequestOrBuilder.java
index 523acb1c7f..2f7e6b4ec1 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequestOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepRequestOrBuilder.java
@@ -44,7 +44,8 @@ public interface RunSweepRequestOrBuilder extends
 
   /**
    * <pre>
-   * Optional FQN of a part/usage to instantiate as an analysis case's subject.
+   * Optional FQN of a part/usage to instantiate as an analysis case's subject,
+   * or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -53,7 +54,8 @@ public interface RunSweepRequestOrBuilder extends
   java.lang.String getSubjectSymbolId();
   /**
    * <pre>
-   * Optional FQN of a part/usage to instantiate as an analysis case's subject.
+   * Optional FQN of a part/usage to instantiate as an analysis case's subject,
+   * or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RunSweepResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoRequest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoRequest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponse.java
similarity index 94%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponse.java
index b0fb9971b0..f4c3e77cbf 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponse.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponse.java
@@ -176,6 +176,14 @@ public java.lang.String getVersion() {
    * rather than read as another value.
    * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
    * preserving everything the edit did not touch.
+   * "edit_documents" - ApplyEdits edits a model of several documents as one
+   * batch for a request setting accept_documents, targets the
+   * document the request names, and answers each edited
+   * document by name in `documents`, each referrer of a refusal
+   * with its document in `referrers`, and each applied edit's
+   * `document`. Without it those fields are empty, a model of
+   * several documents is refused with FAILED_PRECONDITION, and
+   * a request naming a document is refused with UNIMPLEMENTED.
    * "document_query" - the RunDocumentQuery RPC runs a named document query
    * and answers with typed rows.
    * "render_document" - the RenderDocument RPC renders a named document to
@@ -292,6 +300,14 @@ public java.lang.String getVersion() {
    * rather than read as another value.
    * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
    * preserving everything the edit did not touch.
+   * "edit_documents" - ApplyEdits edits a model of several documents as one
+   * batch for a request setting accept_documents, targets the
+   * document the request names, and answers each edited
+   * document by name in `documents`, each referrer of a refusal
+   * with its document in `referrers`, and each applied edit's
+   * `document`. Without it those fields are empty, a model of
+   * several documents is refused with FAILED_PRECONDITION, and
+   * a request naming a document is refused with UNIMPLEMENTED.
    * "document_query" - the RunDocumentQuery RPC runs a named document query
    * and answers with typed rows.
    * "render_document" - the RenderDocument RPC renders a named document to
@@ -407,6 +423,14 @@ public int getCapabilitiesCount() {
    * rather than read as another value.
    * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
    * preserving everything the edit did not touch.
+   * "edit_documents" - ApplyEdits edits a model of several documents as one
+   * batch for a request setting accept_documents, targets the
+   * document the request names, and answers each edited
+   * document by name in `documents`, each referrer of a refusal
+   * with its document in `referrers`, and each applied edit's
+   * `document`. Without it those fields are empty, a model of
+   * several documents is refused with FAILED_PRECONDITION, and
+   * a request naming a document is refused with UNIMPLEMENTED.
    * "document_query" - the RunDocumentQuery RPC runs a named document query
    * and answers with typed rows.
    * "render_document" - the RenderDocument RPC renders a named document to
@@ -523,6 +547,14 @@ public java.lang.String getCapabilities(int index) {
    * rather than read as another value.
    * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
    * preserving everything the edit did not touch.
+   * "edit_documents" - ApplyEdits edits a model of several documents as one
+   * batch for a request setting accept_documents, targets the
+   * document the request names, and answers each edited
+   * document by name in `documents`, each referrer of a refusal
+   * with its document in `referrers`, and each applied edit's
+   * `document`. Without it those fields are empty, a model of
+   * several documents is refused with FAILED_PRECONDITION, and
+   * a request naming a document is refused with UNIMPLEMENTED.
    * "document_query" - the RunDocumentQuery RPC runs a named document query
    * and answers with typed rows.
    * "render_document" - the RenderDocument RPC renders a named document to
@@ -1088,6 +1120,14 @@ private void ensureCapabilitiesIsMutable() {
      * rather than read as another value.
      * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
      * preserving everything the edit did not touch.
+     * "edit_documents" - ApplyEdits edits a model of several documents as one
+     * batch for a request setting accept_documents, targets the
+     * document the request names, and answers each edited
+     * document by name in `documents`, each referrer of a refusal
+     * with its document in `referrers`, and each applied edit's
+     * `document`. Without it those fields are empty, a model of
+     * several documents is refused with FAILED_PRECONDITION, and
+     * a request naming a document is refused with UNIMPLEMENTED.
      * "document_query" - the RunDocumentQuery RPC runs a named document query
      * and answers with typed rows.
      * "render_document" - the RenderDocument RPC renders a named document to
@@ -1205,6 +1245,14 @@ private void ensureCapabilitiesIsMutable() {
      * rather than read as another value.
      * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
      * preserving everything the edit did not touch.
+     * "edit_documents" - ApplyEdits edits a model of several documents as one
+     * batch for a request setting accept_documents, targets the
+     * document the request names, and answers each edited
+     * document by name in `documents`, each referrer of a refusal
+     * with its document in `referrers`, and each applied edit's
+     * `document`. Without it those fields are empty, a model of
+     * several documents is refused with FAILED_PRECONDITION, and
+     * a request naming a document is refused with UNIMPLEMENTED.
      * "document_query" - the RunDocumentQuery RPC runs a named document query
      * and answers with typed rows.
      * "render_document" - the RenderDocument RPC renders a named document to
@@ -1320,6 +1368,14 @@ public int getCapabilitiesCount() {
      * rather than read as another value.
      * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
      * preserving everything the edit did not touch.
+     * "edit_documents" - ApplyEdits edits a model of several documents as one
+     * batch for a request setting accept_documents, targets the
+     * document the request names, and answers each edited
+     * document by name in `documents`, each referrer of a refusal
+     * with its document in `referrers`, and each applied edit's
+     * `document`. Without it those fields are empty, a model of
+     * several documents is refused with FAILED_PRECONDITION, and
+     * a request naming a document is refused with UNIMPLEMENTED.
      * "document_query" - the RunDocumentQuery RPC runs a named document query
      * and answers with typed rows.
      * "render_document" - the RenderDocument RPC renders a named document to
@@ -1436,6 +1492,14 @@ public java.lang.String getCapabilities(int index) {
      * rather than read as another value.
      * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
      * preserving everything the edit did not touch.
+     * "edit_documents" - ApplyEdits edits a model of several documents as one
+     * batch for a request setting accept_documents, targets the
+     * document the request names, and answers each edited
+     * document by name in `documents`, each referrer of a refusal
+     * with its document in `referrers`, and each applied edit's
+     * `document`. Without it those fields are empty, a model of
+     * several documents is refused with FAILED_PRECONDITION, and
+     * a request naming a document is refused with UNIMPLEMENTED.
      * "document_query" - the RunDocumentQuery RPC runs a named document query
      * and answers with typed rows.
      * "render_document" - the RenderDocument RPC renders a named document to
@@ -1553,6 +1617,14 @@ public java.lang.String getCapabilities(int index) {
      * rather than read as another value.
      * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
      * preserving everything the edit did not touch.
+     * "edit_documents" - ApplyEdits edits a model of several documents as one
+     * batch for a request setting accept_documents, targets the
+     * document the request names, and answers each edited
+     * document by name in `documents`, each referrer of a refusal
+     * with its document in `referrers`, and each applied edit's
+     * `document`. Without it those fields are empty, a model of
+     * several documents is refused with FAILED_PRECONDITION, and
+     * a request naming a document is refused with UNIMPLEMENTED.
      * "document_query" - the RunDocumentQuery RPC runs a named document query
      * and answers with typed rows.
      * "render_document" - the RenderDocument RPC renders a named document to
@@ -1676,6 +1748,14 @@ public Builder setCapabilities(
      * rather than read as another value.
      * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
      * preserving everything the edit did not touch.
+     * "edit_documents" - ApplyEdits edits a model of several documents as one
+     * batch for a request setting accept_documents, targets the
+     * document the request names, and answers each edited
+     * document by name in `documents`, each referrer of a refusal
+     * with its document in `referrers`, and each applied edit's
+     * `document`. Without it those fields are empty, a model of
+     * several documents is refused with FAILED_PRECONDITION, and
+     * a request naming a document is refused with UNIMPLEMENTED.
      * "document_query" - the RunDocumentQuery RPC runs a named document query
      * and answers with typed rows.
      * "render_document" - the RenderDocument RPC renders a named document to
@@ -1798,6 +1878,14 @@ public Builder addCapabilities(
      * rather than read as another value.
      * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
      * preserving everything the edit did not touch.
+     * "edit_documents" - ApplyEdits edits a model of several documents as one
+     * batch for a request setting accept_documents, targets the
+     * document the request names, and answers each edited
+     * document by name in `documents`, each referrer of a refusal
+     * with its document in `referrers`, and each applied edit's
+     * `document`. Without it those fields are empty, a model of
+     * several documents is refused with FAILED_PRECONDITION, and
+     * a request naming a document is refused with UNIMPLEMENTED.
      * "document_query" - the RunDocumentQuery RPC runs a named document query
      * and answers with typed rows.
      * "render_document" - the RenderDocument RPC renders a named document to
@@ -1920,6 +2008,14 @@ public Builder addAllCapabilities(
      * rather than read as another value.
      * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
      * preserving everything the edit did not touch.
+     * "edit_documents" - ApplyEdits edits a model of several documents as one
+     * batch for a request setting accept_documents, targets the
+     * document the request names, and answers each edited
+     * document by name in `documents`, each referrer of a refusal
+     * with its document in `referrers`, and each applied edit's
+     * `document`. Without it those fields are empty, a model of
+     * several documents is refused with FAILED_PRECONDITION, and
+     * a request naming a document is refused with UNIMPLEMENTED.
      * "document_query" - the RunDocumentQuery RPC runs a named document query
      * and answers with typed rows.
      * "render_document" - the RenderDocument RPC renders a named document to
@@ -2039,6 +2135,14 @@ public Builder clearCapabilities() {
      * rather than read as another value.
      * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
      * preserving everything the edit did not touch.
+     * "edit_documents" - ApplyEdits edits a model of several documents as one
+     * batch for a request setting accept_documents, targets the
+     * document the request names, and answers each edited
+     * document by name in `documents`, each referrer of a refusal
+     * with its document in `referrers`, and each applied edit's
+     * `document`. Without it those fields are empty, a model of
+     * several documents is refused with FAILED_PRECONDITION, and
+     * a request naming a document is refused with UNIMPLEMENTED.
      * "document_query" - the RunDocumentQuery RPC runs a named document query
      * and answers with typed rows.
      * "render_document" - the RenderDocument RPC renders a named document to
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponseOrBuilder.java
similarity index 92%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponseOrBuilder.java
index e694fac1cc..9832446e59 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponseOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ServerInfoResponseOrBuilder.java
@@ -105,6 +105,14 @@ public interface ServerInfoResponseOrBuilder extends
    * rather than read as another value.
    * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
    * preserving everything the edit did not touch.
+   * "edit_documents" - ApplyEdits edits a model of several documents as one
+   * batch for a request setting accept_documents, targets the
+   * document the request names, and answers each edited
+   * document by name in `documents`, each referrer of a refusal
+   * with its document in `referrers`, and each applied edit's
+   * `document`. Without it those fields are empty, a model of
+   * several documents is refused with FAILED_PRECONDITION, and
+   * a request naming a document is refused with UNIMPLEMENTED.
    * "document_query" - the RunDocumentQuery RPC runs a named document query
    * and answers with typed rows.
    * "render_document" - the RenderDocument RPC renders a named document to
@@ -219,6 +227,14 @@ public interface ServerInfoResponseOrBuilder extends
    * rather than read as another value.
    * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
    * preserving everything the edit did not touch.
+   * "edit_documents" - ApplyEdits edits a model of several documents as one
+   * batch for a request setting accept_documents, targets the
+   * document the request names, and answers each edited
+   * document by name in `documents`, each referrer of a refusal
+   * with its document in `referrers`, and each applied edit's
+   * `document`. Without it those fields are empty, a model of
+   * several documents is refused with FAILED_PRECONDITION, and
+   * a request naming a document is refused with UNIMPLEMENTED.
    * "document_query" - the RunDocumentQuery RPC runs a named document query
    * and answers with typed rows.
    * "render_document" - the RenderDocument RPC renders a named document to
@@ -332,6 +348,14 @@ public interface ServerInfoResponseOrBuilder extends
    * rather than read as another value.
    * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
    * preserving everything the edit did not touch.
+   * "edit_documents" - ApplyEdits edits a model of several documents as one
+   * batch for a request setting accept_documents, targets the
+   * document the request names, and answers each edited
+   * document by name in `documents`, each referrer of a refusal
+   * with its document in `referrers`, and each applied edit's
+   * `document`. Without it those fields are empty, a model of
+   * several documents is refused with FAILED_PRECONDITION, and
+   * a request naming a document is refused with UNIMPLEMENTED.
    * "document_query" - the RunDocumentQuery RPC runs a named document query
    * and answers with typed rows.
    * "render_document" - the RenderDocument RPC renders a named document to
@@ -446,6 +470,14 @@ public interface ServerInfoResponseOrBuilder extends
    * rather than read as another value.
    * "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
    * preserving everything the edit did not touch.
+   * "edit_documents" - ApplyEdits edits a model of several documents as one
+   * batch for a request setting accept_documents, targets the
+   * document the request names, and answers each edited
+   * document by name in `documents`, each referrer of a refusal
+   * with its document in `referrers`, and each applied edit's
+   * `document`. Without it those fields are empty, a model of
+   * several documents is refused with FAILED_PRECONDITION, and
+   * a request naming a document is refused with UNIMPLEMENTED.
    * "document_query" - the RunDocumentQuery RPC runs a named document query
    * and answers with typed rows.
    * "render_document" - the RenderDocument RPC renders a named document to
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SetValueEdit.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SetValueEdit.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SetValueEdit.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SetValueEdit.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SetValueEditOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SetValueEditOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SetValueEditOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SetValueEditOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SourceDocument.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SourceDocument.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SourceDocument.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SourceDocument.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SourceDocumentOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SourceDocumentOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SourceDocumentOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SourceDocumentOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Span.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Span.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Span.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Span.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SpanOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SpanOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SpanOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SpanOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Specialization.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Specialization.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Specialization.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Specialization.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SpecializationOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SpecializationOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SpecializationOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SpecializationOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRange.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRange.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRange.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRange.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRangeOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRangeOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRangeOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRangeOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRow.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRow.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRow.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRow.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRowOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRowOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRowOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SweepRowOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolInfo.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolInfo.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolInfo.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolInfo.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolInfoOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolInfoOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolInfoOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolInfoOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/SymbolResponseOrBuilder.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Sysml.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Sysml.java
new file mode 100644
index 0000000000..c0f9d525b8
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Sysml.java
@@ -0,0 +1,1825 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public final class Sysml extends com.google.protobuf.GeneratedFile {
+  private Sysml() {}
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "Sysml");
+  }
+  public static void registerAllExtensions(
+      com.google.protobuf.ExtensionRegistryLite registry) {
+  }
+
+  public static void registerAllExtensions(
+      com.google.protobuf.ExtensionRegistry registry) {
+    registerAllExtensions(
+        (com.google.protobuf.ExtensionRegistryLite) registry);
+  }
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Verdict_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Verdict_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Bound_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Bound_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_VerifyConstraintRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_VerifyConstraintRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_VerifyConstraintResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_VerifyConstraintResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_VerifyRequirementRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_VerifyRequirementRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_VerificationVerdict_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_VerificationVerdict_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_VerifyRequirementResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_VerifyRequirementResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_VerifySatisfactionRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_VerifySatisfactionRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_VerifySatisfactionResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_VerifySatisfactionResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ValidateInstanceRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ValidateInstanceRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ValidateInstanceResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ValidateInstanceResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_EvaluateCalcRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_EvaluateCalcRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_EvaluateCalcResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_EvaluateCalcResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_CalcOutput_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_CalcOutput_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_CaseEvaluation_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_CaseEvaluation_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RunAnalysisRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RunAnalysisRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RunAnalysisRequest_NamedArgumentsEntry_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RunAnalysisRequest_NamedArgumentsEntry_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RunAnalysisResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RunAnalysisResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Outcome_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Outcome_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Outcome_OutputsEntry_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Outcome_OutputsEntry_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ExplorationStatus_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ExplorationStatus_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ListEnginesRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ListEnginesRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_EngineInfo_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_EngineInfo_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ListEnginesResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ListEnginesResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ParseFileRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ParseFileRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_SourceDocument_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_SourceDocument_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ParseSourcesRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ParseSourcesRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ParseSourcesResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ParseSourcesResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ParseFileResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ParseFileResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_GetSymbolRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_GetSymbolRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_SymbolResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_SymbolResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DiagnosticsRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DiagnosticsRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DiagnosticsResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DiagnosticsResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_EvaluateRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_EvaluateRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_EvaluateResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_EvaluateResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Instance_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Instance_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Instance_FeatureValuesEntry_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Instance_FeatureValuesEntry_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_FeatureValue_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_FeatureValue_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_InstantiateRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_InstantiateRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_InstantiateResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_InstantiateResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ExecuteActionRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ExecuteActionRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ExecuteActionRequest_InputsEntry_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ExecuteActionRequest_InputsEntry_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ExecuteActionResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ExecuteActionResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ExecuteActionResponse_OutputsEntry_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ExecuteActionResponse_OutputsEntry_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ExecuteStateRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ExecuteStateRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ExecuteStateResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ExecuteStateResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ExecuteStateResponse_FinalContextEntry_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ExecuteStateResponse_FinalContextEntry_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ConvertRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ConvertRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ConvertResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ConvertResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ApplyEditsRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ApplyEditsRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_EditOperation_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_EditOperation_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_AddMemberEdit_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_AddMemberEdit_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DeleteEdit_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DeleteEdit_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_MoveEdit_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_MoveEdit_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_SetValueEdit_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_SetValueEdit_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RenameEdit_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RenameEdit_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ApplyEditsResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ApplyEditsResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_EditedDocument_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_EditedDocument_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Referrer_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Referrer_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_AppliedEdit_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_AppliedEdit_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_SymbolInfo_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_SymbolInfo_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_SymbolInfo_MetadataEntry_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_SymbolInfo_MetadataEntry_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Specialization_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Specialization_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_TypeInfo_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_TypeInfo_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_MultiplicityInfo_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_MultiplicityInfo_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_AttributeInfo_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_AttributeInfo_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Value_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Value_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Metaobject_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Metaobject_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Undetermined_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Undetermined_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Function_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Function_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ValueSet_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ValueSet_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_TensorQuantity_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_TensorQuantity_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Array_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Array_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Vector_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Vector_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_VectorQuantity_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_VectorQuantity_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Complex_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Complex_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_EnumLiteral_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_EnumLiteral_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ValueSequence_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ValueSequence_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Quantity_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Quantity_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_MeasurementRef_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_MeasurementRef_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_UnitTerm_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_UnitTerm_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_UnitFactor_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_UnitFactor_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Diagnostic_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Diagnostic_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Span_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Span_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ServerInfoRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ServerInfoRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_ServerInfoResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_ServerInfoResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_QueryRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_QueryRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_QueryResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_QueryResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Query_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Query_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_Constraint_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_Constraint_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_PrimitiveConstraint_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_PrimitiveConstraint_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_CompositeConstraint_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_CompositeConstraint_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_QueryResultElement_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_QueryResultElement_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_QueryResultElement_PropertiesEntry_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_QueryResultElement_PropertiesEntry_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_SweepRange_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_SweepRange_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RunSweepRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RunSweepRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RunSweepRequest_NamedArgumentsEntry_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RunSweepRequest_NamedArgumentsEntry_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_SweepRow_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_SweepRow_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RunSweepResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RunSweepResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RunDocumentQueryRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RunDocumentQueryRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DocumentQueryBinding_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DocumentQueryBinding_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DocumentValue_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DocumentValue_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DocumentObject_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DocumentObject_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DocumentVerdict_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DocumentVerdict_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DocumentState_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DocumentState_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DocumentEvent_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DocumentEvent_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DocumentQueryColumn_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DocumentQueryColumn_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DocumentQueryCell_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DocumentQueryCell_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_DocumentQueryRow_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_DocumentQueryRow_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RunDocumentQueryResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RunDocumentQueryResponse_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RenderDocumentRequest_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RenderDocumentRequest_fieldAccessorTable;
+  static final com.google.protobuf.Descriptors.Descriptor
+    internal_static_sysml_RenderDocumentResponse_descriptor;
+  static final 
+    com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internal_static_sysml_RenderDocumentResponse_fieldAccessorTable;
+
+  public static com.google.protobuf.Descriptors.FileDescriptor
+      getDescriptor() {
+    return descriptor;
+  }
+  private static  com.google.protobuf.Descriptors.FileDescriptor
+      descriptor;
+  static {
+    java.lang.String[] descriptorData = {
+      "\n\013sysml.proto\022\005sysml\"\316\003\n\007Verdict\022\022\n\004kind" +
+      "\030\001 \001(\tR\004kind\022\035\n\nelement_id\030\002 \001(\tR\telemen" +
+      "tId\022\030\n\007element\030\003 \001(\tR\007element\022\024\n\005holds\030\004" +
+      " \001(\010R\005holds\022\034\n\tcondition\030\005 \001(\tR\tconditio" +
+      "n\022\037\n\013instance_id\030\006 \001(\003R\ninstanceId\022(\n\020in" +
+      "stance_type_id\030\007 \001(\tR\016instanceTypeId\022\024\n\005" +
+      "error\030\010 \001(\tR\005error\022;\n\016failure_reason\030\t \001" +
+      "(\0162\024.sysml.FailureReasonR\rfailureReason\022" +
+      "%\n\016requirement_id\030\n \001(\tR\rrequirementId\022\026" +
+      "\n\006engine\030\013 \001(\tR\006engine\022\032\n\010strength\030\014 \001(\t" +
+      "R\010strength\022$\n\006bounds\030\r \003(\0132\014.sysml.Bound" +
+      "R\006bounds\022#\n\rinstance_path\030\016 \001(\tR\014instanc" +
+      "ePath\"K\n\005Bound\022\022\n\004name\030\001 \001(\tR\004name\022\024\n\005li" +
+      "mit\030\002 \001(\003R\005limit\022\030\n\007reached\030\003 \001(\010R\007reach" +
+      "ed\"\231\001\n\027VerifyConstraintRequest\022\035\n\nmodel_" +
+      "hash\030\001 \001(\tR\tmodelHash\022\033\n\tsymbol_id\030\002 \001(\t" +
+      "R\010symbolId\022*\n\021subject_symbol_id\030\003 \001(\tR\017s" +
+      "ubjectSymbolId\022\026\n\006engine\030\004 \001(\tR\006engine\"\276" +
+      "\001\n\030VerifyConstraintResponse\022(\n\007verdict\030\001" +
+      " \001(\0132\016.sysml.VerdictR\007verdict\022-\n\tinstanc" +
+      "es\030\002 \003(\0132\017.sysml.InstanceR\tinstances\022\024\n\005" +
+      "error\030\003 \001(\tR\005error\0223\n\013diagnostics\030\004 \003(\0132" +
+      "\021.sysml.DiagnosticR\013diagnostics\"\232\001\n\030Veri" +
+      "fyRequirementRequest\022\035\n\nmodel_hash\030\001 \001(\t" +
+      "R\tmodelHash\022\033\n\tsymbol_id\030\002 \001(\tR\010symbolId" +
+      "\022*\n\021subject_symbol_id\030\003 \001(\tR\017subjectSymb" +
+      "olId\022\026\n\006engine\030\004 \001(\tR\006engine\"\233\001\n\023Verific" +
+      "ationVerdict\022\027\n\007case_id\030\001 \001(\tR\006caseId\022\022\n" +
+      "\004kind\030\002 \001(\tR\004kind\022\026\n\006detail\030\003 \001(\tR\006detai" +
+      "l\022\030\n\007subcase\030\004 \001(\010R\007subcase\022%\n\016requireme" +
+      "nt_id\030\005 \001(\tR\rrequirementId\"\220\002\n\031VerifyReq" +
+      "uirementResponse\022(\n\007verdict\030\001 \001(\0132\016.sysm" +
+      "l.VerdictR\007verdict\022-\n\tinstances\030\002 \003(\0132\017." +
+      "sysml.InstanceR\tinstances\022\024\n\005error\030\003 \001(\t" +
+      "R\005error\0223\n\013diagnostics\030\004 \003(\0132\021.sysml.Dia" +
+      "gnosticR\013diagnostics\022O\n\025verification_ver" +
+      "dicts\030\005 \003(\0132\032.sysml.VerificationVerdictR" +
+      "\024verificationVerdicts\"o\n\031VerifySatisfact" +
+      "ionRequest\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHas" +
+      "h\022\033\n\tsymbol_id\030\002 \001(\tR\010symbolId\022\026\n\006engine" +
+      "\030\003 \001(\tR\006engine\"\320\002\n\032VerifySatisfactionRes" +
+      "ponse\022*\n\010verdicts\030\001 \003(\0132\016.sysml.VerdictR" +
+      "\010verdicts\022-\n\tinstances\030\002 \003(\0132\017.sysml.Ins" +
+      "tanceR\tinstances\022\024\n\005error\030\003 \001(\tR\005error\0223" +
+      "\n\013diagnostics\030\004 \003(\0132\021.sysml.DiagnosticR\013" +
+      "diagnostics\022;\n\016failure_reason\030\005 \001(\0162\024.sy" +
+      "sml.FailureReasonR\rfailureReason\022O\n\025veri" +
+      "fication_verdicts\030\006 \003(\0132\032.sysml.Verifica" +
+      "tionVerdictR\024verificationVerdicts\"m\n\027Val" +
+      "idateInstanceRequest\022\035\n\nmodel_hash\030\001 \001(\t" +
+      "R\tmodelHash\022\033\n\tsymbol_id\030\002 \001(\tR\010symbolId" +
+      "\022\026\n\006engine\030\003 \001(\tR\006engine\"\222\003\n\030ValidateIns" +
+      "tanceResponse\022*\n\010verdicts\030\001 \003(\0132\016.sysml." +
+      "VerdictR\010verdicts\022(\n\007summary\030\002 \001(\0132\016.sys" +
+      "ml.VerdictR\007summary\022-\n\tinstances\030\003 \003(\0132\017" +
+      ".sysml.InstanceR\tinstances\022\024\n\005error\030\004 \001(" +
+      "\tR\005error\0223\n\013diagnostics\030\005 \003(\0132\021.sysml.Di" +
+      "agnosticR\013diagnostics\022;\n\016failure_reason\030" +
+      "\006 \001(\0162\024.sysml.FailureReasonR\rfailureReas" +
+      "on\022O\n\025verification_verdicts\030\007 \003(\0132\032.sysm" +
+      "l.VerificationVerdictR\024verificationVerdi" +
+      "cts\022\030\n\007bounded\030\010 \001(\010R\007bounded\"\225\001\n\023Evalua" +
+      "teCalcRequest\022\035\n\nmodel_hash\030\001 \001(\tR\tmodel" +
+      "Hash\022\033\n\tsymbol_id\030\002 \001(\tR\010symbolId\022*\n\targ" +
+      "uments\030\003 \003(\0132\014.sysml.ValueR\targuments\022\026\n" +
+      "\006engine\030\004 \001(\tR\006engine\"\313\002\n\024EvaluateCalcRe" +
+      "sponse\022$\n\006result\030\001 \001(\0132\014.sysml.ValueR\006re" +
+      "sult\022+\n\007outputs\030\002 \003(\0132\021.sysml.CalcOutput" +
+      "R\007outputs\022\024\n\005error\030\003 \001(\tR\005error\0223\n\013diagn" +
+      "ostics\030\004 \003(\0132\021.sysml.DiagnosticR\013diagnos" +
+      "tics\022;\n\016failure_reason\030\005 \001(\0162\024.sysml.Fai" +
+      "lureReasonR\rfailureReason\022\026\n\006engine\030\006 \001(" +
+      "\tR\006engine\022\032\n\010strength\030\007 \001(\tR\010strength\022$\n" +
+      "\006bounds\030\010 \003(\0132\014.sysml.BoundR\006bounds\"D\n\nC" +
+      "alcOutput\022\022\n\004name\030\001 \001(\tR\004name\022\"\n\005value\030\002" +
+      " \001(\0132\014.sysml.ValueR\005value\"\311\001\n\016CaseEvalua" +
+      "tion\022\037\n\013function_id\030\001 \001(\tR\nfunctionId\022*\n" +
+      "\targuments\030\002 \003(\0132\014.sysml.ValueR\targument" +
+      "s\022$\n\006result\030\003 \001(\0132\014.sysml.ValueR\006result\022" +
+      "\024\n\005error\030\004 \001(\tR\005error\022\032\n\010selected\030\005 \001(\010R" +
+      "\010selected\022\022\n\004tied\030\006 \001(\010R\004tied\"\205\003\n\022RunAna" +
+      "lysisRequest\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelH" +
+      "ash\022\033\n\tsymbol_id\030\002 \001(\tR\010symbolId\022*\n\021subj" +
+      "ect_symbol_id\030\003 \001(\tR\017subjectSymbolId\022*\n\t" +
+      "arguments\030\004 \003(\0132\014.sysml.ValueR\targuments" +
+      "\022V\n\017named_arguments\030\005 \003(\0132-.sysml.RunAna" +
+      "lysisRequest.NamedArgumentsEntryR\016namedA" +
+      "rguments\022\032\n\010schedule\030\006 \001(\tR\010schedule\022\026\n\006" +
+      "engine\030\007 \001(\tR\006engine\032O\n\023NamedArgumentsEn" +
+      "try\022\020\n\003key\030\001 \001(\tR\003key\022\"\n\005value\030\002 \001(\0132\014.s" +
+      "ysml.ValueR\005value:\0028\001\"\361\004\n\023RunAnalysisRes" +
+      "ponse\022+\n\007outputs\030\001 \003(\0132\021.sysml.CalcOutpu" +
+      "tR\007outputs\022*\n\010verdicts\030\002 \003(\0132\016.sysml.Ver" +
+      "dictR\010verdicts\022-\n\tinstances\030\003 \003(\0132\017.sysm" +
+      "l.InstanceR\tinstances\022\024\n\005error\030\004 \001(\tR\005er" +
+      "ror\0223\n\013diagnostics\030\005 \003(\0132\021.sysml.Diagnos" +
+      "ticR\013diagnostics\022;\n\016failure_reason\030\006 \001(\016" +
+      "2\024.sysml.FailureReasonR\rfailureReason\022O\n" +
+      "\025verification_verdicts\030\007 \003(\0132\032.sysml.Ver" +
+      "ificationVerdictR\024verificationVerdicts\022*" +
+      "\n\010outcomes\030\010 \003(\0132\016.sysml.OutcomeR\010outcom" +
+      "es\022:\n\013exploration\030\t \001(\0132\030.sysml.Explorat" +
+      "ionStatusR\013exploration\0227\n\013evaluations\030\n " +
+      "\003(\0132\025.sysml.CaseEvaluationR\013evaluations\022" +
+      "\026\n\006engine\030\013 \001(\tR\006engine\022\032\n\010strength\030\014 \001(" +
+      "\tR\010strength\022$\n\006bounds\030\r \003(\0132\014.sysml.Boun" +
+      "dR\006bounds\"\201\003\n\007Outcome\0225\n\007outputs\030\001 \003(\0132\033" +
+      ".sysml.Outcome.OutputsEntryR\007outputs\022\037\n\013" +
+      "final_state\030\002 \001(\tR\nfinalState\022%\n\016states_" +
+      "visited\030\003 \003(\tR\rstatesVisited\022\024\n\005error\030\004 " +
+      "\001(\tR\005error\022&\n\016linearizations\030\005 \001(\005R\016line" +
+      "arizations\022\030\n\007witness\030\006 \003(\tR\007witness\0223\n\013" +
+      "diagnostics\030\007 \003(\0132\021.sysml.DiagnosticR\013di" +
+      "agnostics\022 \n\013probability\030\010 \001(\001R\013probabil" +
+      "ity\032H\n\014OutputsEntry\022\020\n\003key\030\001 \001(\tR\003key\022\"\n" +
+      "\005value\030\002 \001(\0132\014.sysml.ValueR\005value:\0028\001\"\344\001" +
+      "\n\021ExplorationStatus\022\032\n\010complete\030\001 \001(\010R\010c" +
+      "omplete\022\022\n\004runs\030\002 \001(\005R\004runs\022\037\n\013budgets_h" +
+      "it\030\003 \003(\tR\nbudgetsHit\022\037\n\013runs_budget\030\004 \001(" +
+      "\005R\nrunsBudget\022!\n\014depth_budget\030\005 \001(\005R\013dep" +
+      "thBudget\022:\n\031probabilities_lower_bound\030\006 " +
+      "\001(\010R\027probabilitiesLowerBound\"\024\n\022ListEngi" +
+      "nesRequest\"\373\002\n\nEngineInfo\022\022\n\004name\030\001 \001(\tR" +
+      "\004name\022\034\n\tauthority\030\002 \001(\tR\tauthority\022\030\n\007a" +
+      "nswers\030\003 \003(\tR\007answers\022\026\n\006bounds\030\004 \003(\tR\006b" +
+      "ounds\022\030\n\007process\030\005 \001(\tR\007process\022#\n\rproce" +
+      "ss_found\030\006 \001(\tR\014processFound\022\024\n\005ready\030\007 " +
+      "\001(\010R\005ready\022 \n\013unavailable\030\010 \001(\tR\013unavail" +
+      "able\022\022\n\004kind\030\t \001(\tR\004kind\022\032\n\010protocol\030\n \001" +
+      "(\tR\010protocol\022\026\n\006source\030\013 \001(\tR\006source\022\030\n\007" +
+      "command\030\014 \001(\tR\007command\022\030\n\007version\030\r \001(\tR" +
+      "\007version\022\026\n\006served\030\016 \001(\010R\006served\"B\n\023List" +
+      "EnginesResponse\022+\n\007engines\030\001 \003(\0132\021.sysml" +
+      ".EngineInfoR\007engines\"\311\001\n\020ParseFileReques" +
+      "t\022\035\n\tfile_path\030\001 \001(\tH\000R\010filePath\022\032\n\007cont" +
+      "ent\030\002 \001(\tH\000R\007content\022%\n\014content_hash\030\003 \001" +
+      "(\tB\002\030\001R\013contentHash\022\032\n\010language\030\004 \001(\tR\010l" +
+      "anguage\022-\n\022strict_conformance\030\005 \001(\010R\021str" +
+      "ictConformanceB\010\n\006source\"\205\001\n\016SourceDocum" +
+      "ent\022\035\n\tfile_path\030\001 \001(\tH\000R\010filePath\022\032\n\007co" +
+      "ntent\030\002 \001(\tH\000R\007content\022\032\n\010language\030\003 \001(\t" +
+      "R\010language\022\022\n\004name\030\004 \001(\tR\004nameB\010\n\006source" +
+      "\"y\n\023ParseSourcesRequest\0223\n\tdocuments\030\001 \003" +
+      "(\0132\025.sysml.SourceDocumentR\tdocuments\022-\n\022" +
+      "strict_conformance\030\002 \001(\010R\021strictConforma" +
+      "nce\"\251\001\n\024ParseSourcesResponse\022\035\n\nmodel_ha" +
+      "sh\030\001 \001(\tR\tmodelHash\022\'\n\005roots\030\002 \003(\0132\021.sys" +
+      "ml.SymbolInfoR\005roots\0223\n\013diagnostics\030\003 \003(" +
+      "\0132\021.sysml.DiagnosticR\013diagnostics\022\024\n\005err" +
+      "or\030\004 \001(\tR\005error\"\244\001\n\021ParseFileResponse\022\035\n" +
+      "\nmodel_hash\030\001 \001(\tR\tmodelHash\022%\n\004root\030\002 \001" +
+      "(\0132\021.sysml.SymbolInfoR\004root\0223\n\013diagnosti" +
+      "cs\030\003 \003(\0132\021.sysml.DiagnosticR\013diagnostics" +
+      "\022\024\n\005error\030\004 \001(\tR\005error\"N\n\020GetSymbolReque" +
+      "st\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash\022\033\n\tsym" +
+      "bol_id\030\002 \001(\tR\010symbolId\"Q\n\016SymbolResponse" +
+      "\022)\n\006symbol\030\001 \001(\0132\021.sysml.SymbolInfoR\006sym" +
+      "bol\022\024\n\005error\030\002 \001(\tR\005error\"3\n\022Diagnostics" +
+      "Request\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash\"`" +
+      "\n\023DiagnosticsResponse\0223\n\013diagnostics\030\001 \003" +
+      "(\0132\021.sysml.DiagnosticR\013diagnostics\022\024\n\005er" +
+      "ror\030\002 \001(\tR\005error\"\250\001\n\017EvaluateRequest\022\035\n\n" +
+      "model_hash\030\001 \001(\tR\tmodelHash\022\036\n\nexpressio" +
+      "n\030\002 \001(\tR\nexpression\022*\n\021context_symbol_id" +
+      "\030\003 \001(\tR\017contextSymbolId\022*\n\021subject_symbo" +
+      "l_id\030\004 \001(\tR\017subjectSymbolId\"\203\001\n\020Evaluate" +
+      "Response\022$\n\006result\030\001 \001(\0132\014.sysml.ValueR\006" +
+      "result\022\024\n\005error\030\002 \001(\tR\005error\0223\n\013diagnost" +
+      "ics\030\003 \003(\0132\021.sysml.DiagnosticR\013diagnostic" +
+      "s\"\357\001\n\010Instance\022\016\n\002id\030\001 \001(\003R\002id\022$\n\016type_s" +
+      "ymbol_id\030\002 \001(\tR\014typeSymbolId\022I\n\016feature_" +
+      "values\030\004 \003(\0132\".sysml.Instance.FeatureVal" +
+      "uesEntryR\rfeatureValues\032U\n\022FeatureValues" +
+      "Entry\022\020\n\003key\030\001 \001(\tR\003key\022)\n\005value\030\002 \001(\0132\023" +
+      ".sysml.FeatureValueR\005value:\0028\001J\004\010\003\020\004R\005sl" +
+      "ots\"\265\001\n\014FeatureValue\022!\n\014feature_name\030\001 \001" +
+      "(\tR\013featureName\022\"\n\005value\030\002 \001(\0132\014.sysml.V" +
+      "alueR\005value\022$\n\006values\030\003 \003(\0132\014.sysml.Valu" +
+      "eR\006values\022\"\n\014materialized\030\004 \001(\010R\014materia" +
+      "lized\022\024\n\005error\030\005 \001(\tR\005error\"P\n\022Instantia" +
+      "teRequest\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash" +
+      "\022\033\n\tsymbol_id\030\002 \001(\tR\010symbolId\"\274\001\n\023Instan" +
+      "tiateResponse\022+\n\010instance\030\001 \001(\0132\017.sysml." +
+      "InstanceR\010instance\022\024\n\005error\030\002 \001(\tR\005error" +
+      "\0223\n\013diagnostics\030\003 \003(\0132\021.sysml.Diagnostic" +
+      "R\013diagnostics\022-\n\tinstances\030\004 \003(\0132\017.sysml" +
+      ".InstanceR\tinstances\"\265\002\n\024ExecuteActionRe" +
+      "quest\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash\022(\n\020" +
+      "action_symbol_id\030\002 \001(\tR\016actionSymbolId\022?" +
+      "\n\006inputs\030\003 \003(\0132\'.sysml.ExecuteActionRequ" +
+      "est.InputsEntryR\006inputs\022\032\n\010schedule\030\004 \001(" +
+      "\tR\010schedule\022.\n\023performer_symbol_id\030\005 \001(\t" +
+      "R\021performerSymbolId\032G\n\013InputsEntry\022\020\n\003ke" +
+      "y\030\001 \001(\tR\003key\022\"\n\005value\030\002 \001(\0132\014.sysml.Valu" +
+      "eR\005value:\0028\001\"\370\002\n\025ExecuteActionResponse\022C" +
+      "\n\007outputs\030\001 \003(\0132).sysml.ExecuteActionRes" +
+      "ponse.OutputsEntryR\007outputs\022\024\n\005error\030\002 \001" +
+      "(\tR\005error\0223\n\013diagnostics\030\003 \003(\0132\021.sysml.D" +
+      "iagnosticR\013diagnostics\022*\n\010outcomes\030\004 \003(\013" +
+      "2\016.sysml.OutcomeR\010outcomes\022:\n\013exploratio" +
+      "n\030\005 \001(\0132\030.sysml.ExplorationStatusR\013explo" +
+      "ration\022\035\n\nfinal_time\030\006 \001(\001R\tfinalTime\032H\n" +
+      "\014OutputsEntry\022\020\n\003key\030\001 \001(\tR\003key\022\"\n\005value" +
+      "\030\002 \001(\0132\014.sysml.ValueR\005value:\0028\001\"\317\001\n\023Exec" +
+      "uteStateRequest\022\035\n\nmodel_hash\030\001 \001(\tR\tmod" +
+      "elHash\0225\n\027state_machine_symbol_id\030\002 \001(\tR" +
+      "\024stateMachineSymbolId\022\026\n\006events\030\003 \003(\tR\006e" +
+      "vents\022\032\n\010schedule\030\004 \001(\tR\010schedule\022.\n\023per" +
+      "former_symbol_id\030\005 \001(\tR\021performerSymbolI" +
+      "d\"\262\003\n\024ExecuteStateResponse\022%\n\016states_vis" +
+      "ited\030\001 \003(\tR\rstatesVisited\022R\n\rfinal_conte" +
+      "xt\030\002 \003(\0132-.sysml.ExecuteStateResponse.Fi" +
+      "nalContextEntryR\014finalContext\022\024\n\005error\030\003" +
+      " \001(\tR\005error\0223\n\013diagnostics\030\004 \003(\0132\021.sysml" +
+      ".DiagnosticR\013diagnostics\022*\n\010outcomes\030\005 \003" +
+      "(\0132\016.sysml.OutcomeR\010outcomes\022:\n\013explorat" +
+      "ion\030\006 \001(\0132\030.sysml.ExplorationStatusR\013exp" +
+      "loration\022\035\n\nfinal_time\030\007 \001(\001R\tfinalTime\032" +
+      "M\n\021FinalContextEntry\022\020\n\003key\030\001 \001(\tR\003key\022\"" +
+      "\n\005value\030\002 \001(\0132\014.sysml.ValueR\005value:\0028\001\"\352" +
+      "\001\n\016ConvertRequest\022\035\n\tfile_path\030\001 \001(\tH\000R\010" +
+      "filePath\022\032\n\007content\030\002 \001(\tH\000R\007content\022\037\n\n" +
+      "model_hash\030\006 \001(\tH\000R\tmodelHash\022\037\n\013from_fo" +
+      "rmat\030\003 \001(\tR\nfromFormat\022\033\n\tto_format\030\004 \001(" +
+      "\tR\010toFormat\0224\n\026tolerate_syntax_errors\030\005 " +
+      "\001(\010R\024tolerateSyntaxErrorsB\010\n\006source\"\211\002\n\017" +
+      "ConvertResponse\022\030\n\007content\030\001 \001(\tR\007conten" +
+      "t\022\037\n\013from_format\030\002 \001(\tR\nfromFormat\022\033\n\tto" +
+      "_format\030\003 \001(\tR\010toFormat\022\024\n\005error\030\004 \001(\tR\005" +
+      "error\0223\n\013diagnostics\030\005 \003(\0132\021.sysml.Diagn" +
+      "osticR\013diagnostics\022\"\n\014experimental\030\006 \001(\010" +
+      "R\014experimental\022/\n\023experimental_notice\030\007 " +
+      "\001(\tR\022experimentalNotice\"\257\001\n\021ApplyEditsRe" +
+      "quest\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash\0224\n\n" +
+      "operations\030\002 \003(\0132\024.sysml.EditOperationR\n" +
+      "operations\022\032\n\010document\030\003 \001(\tR\010document\022)" +
+      "\n\020accept_documents\030\004 \001(\010R\017acceptDocument" +
+      "s\"\210\002\n\rEditOperation\0222\n\tset_value\030\001 \001(\0132\023" +
+      ".sysml.SetValueEditH\000R\010setValue\022+\n\006renam" +
+      "e\030\002 \001(\0132\021.sysml.RenameEditH\000R\006rename\0225\n\n" +
+      "add_member\030\003 \001(\0132\024.sysml.AddMemberEditH\000" +
+      "R\taddMember\022+\n\006delete\030\004 \001(\0132\021.sysml.Dele" +
+      "teEditH\000R\006delete\022%\n\004move\030\005 \001(\0132\017.sysml.M" +
+      "oveEditH\000R\004moveB\013\n\toperation\"\275\001\n\rAddMemb" +
+      "erEdit\022\024\n\005owner\030\001 \001(\tR\005owner\022\022\n\004kind\030\002 \001" +
+      "(\tR\004kind\022\022\n\004name\030\003 \001(\tR\004name\022\022\n\004type\030\004 \001" +
+      "(\tR\004type\022\"\n\014multiplicity\030\005 \001(\tR\014multipli" +
+      "city\022\024\n\005value\030\006 \001(\tR\005value\022 \n\013specialize" +
+      "s\030\007 \003(\tR\013specializes\">\n\nDeleteEdit\022\026\n\006ta" +
+      "rget\030\001 \001(\tR\006target\022\030\n\007cascade\030\002 \001(\010R\007cas" +
+      "cade\"8\n\010MoveEdit\022\026\n\006target\030\001 \001(\tR\006target" +
+      "\022\024\n\005owner\030\002 \001(\tR\005owner\"<\n\014SetValueEdit\022\026" +
+      "\n\006target\030\001 \001(\tR\006target\022\024\n\005value\030\002 \001(\tR\005v" +
+      "alue\"?\n\nRenameEdit\022\026\n\006target\030\001 \001(\tR\006targ" +
+      "et\022\031\n\010new_name\030\002 \001(\tR\007newName\"\350\002\n\022ApplyE" +
+      "ditsResponse\022\030\n\007content\030\001 \001(\tR\007content\022," +
+      "\n\007applied\030\002 \003(\0132\022.sysml.AppliedEditR\007app" +
+      "lied\022\024\n\005error\030\003 \001(\tR\005error\022,\n\007failure\030\004 " +
+      "\001(\0162\022.sysml.EditFailureR\007failure\0223\n\013diag" +
+      "nostics\030\005 \003(\0132\021.sysml.DiagnosticR\013diagno" +
+      "stics\022-\n\022referring_elements\030\006 \003(\tR\021refer" +
+      "ringElements\0223\n\tdocuments\030\007 \003(\0132\025.sysml." +
+      "EditedDocumentR\tdocuments\022-\n\treferrers\030\010" +
+      " \003(\0132\017.sysml.ReferrerR\treferrers\">\n\016Edit" +
+      "edDocument\022\022\n\004name\030\001 \001(\tR\004name\022\030\n\007conten" +
+      "t\030\002 \001(\tR\007content\":\n\010Referrer\022\022\n\004name\030\001 \001" +
+      "(\tR\004name\022\032\n\010document\030\002 \001(\tR\010document\"\320\001\n" +
+      "\013AppliedEdit\022\'\n\017operation_index\030\001 \001(\005R\016o" +
+      "perationIndex\022\026\n\006target\030\002 \001(\tR\006target\022\026\n" +
+      "\006offset\030\003 \001(\005R\006offset\022\026\n\006length\030\004 \001(\005R\006l" +
+      "ength\022\031\n\010old_text\030\005 \001(\tR\007oldText\022\031\n\010new_" +
+      "text\030\006 \001(\tR\007newText\022\032\n\010document\030\007 \001(\tR\010d" +
+      "ocument\"\375\003\n\nSymbolInfo\022\016\n\002id\030\001 \001(\tR\002id\022\022" +
+      "\n\004name\030\002 \001(\tR\004name\022\022\n\004kind\030\003 \001(\tR\004kind\022;" +
+      "\n\010metadata\030\004 \003(\0132\037.sysml.SymbolInfo.Meta" +
+      "dataEntryR\010metadata\022\033\n\tchild_ids\030\005 \003(\tR\010" +
+      "childIds\0224\n\nattributes\030\006 \003(\0132\024.sysml.Att" +
+      "ributeInfoR\nattributes\022,\n\ttype_info\030\007 \001(" +
+      "\0132\017.sysml.TypeInfoR\010typeInfo\022;\n\014multipli" +
+      "city\030\010 \001(\0132\027.sysml.MultiplicityInfoR\014mul" +
+      "tiplicity\022?\n\017specializations\030\t \003(\0132\025.sys" +
+      "ml.SpecializationR\017specializations\022>\n\033wi" +
+      "thheld_library_attributes\030\n \001(\005R\031withhel" +
+      "dLibraryAttributes\032;\n\rMetadataEntry\022\020\n\003k" +
+      "ey\030\001 \001(\tR\003key\022\024\n\005value\030\002 \001(\tR\005value:\0028\001\"" +
+      "~\n\016Specialization\022\022\n\004kind\030\001 \001(\tR\004kind\022\032\n" +
+      "\010declared\030\002 \001(\tR\010declared\022\033\n\ttarget_id\030\003" +
+      " \001(\tR\010targetId\022\037\n\013target_kind\030\004 \001(\tR\ntar" +
+      "getKind\"\345\001\n\010TypeInfo\022\032\n\010declared\030\001 \001(\tR\010" +
+      "declared\022\037\n\013resolved_id\030\002 \001(\tR\nresolvedI" +
+      "d\022#\n\rresolved_kind\030\003 \001(\tR\014resolvedKind\022\034" +
+      "\n\tprimitive\030\004 \001(\tR\tprimitive\022)\n\020primitiv" +
+      "e_source\030\005 \001(\tR\017primitiveSource\022\032\n\010quant" +
+      "ity\030\006 \001(\010R\010quantity\022\022\n\004unit\030\007 \001(\tR\004unit\"" +
+      ">\n\020MultiplicityInfo\022\024\n\005lower\030\001 \001(\tR\005lowe" +
+      "r\022\024\n\005upper\030\002 \001(\tR\005upper\"o\n\rAttributeInfo" +
+      "\022\022\n\004name\030\001 \001(\tR\004name\022\022\n\004type\030\002 \001(\tR\004type" +
+      "\022\"\n\005value\030\003 \001(\0132\014.sysml.ValueR\005value\022\022\n\004" +
+      "unit\030\004 \001(\tR\004unit\"\245\007\n\005Value\022\035\n\tint_value\030" +
+      "\001 \001(\003H\000R\010intValue\022\037\n\nreal_value\030\002 \001(\001H\000R" +
+      "\trealValue\022\037\n\nbool_value\030\003 \001(\010H\000R\tboolVa" +
+      "lue\022#\n\014string_value\030\004 \001(\tH\000R\013stringValue" +
+      "\022!\n\013instance_id\030\005 \001(\003H\000R\ninstanceId\0222\n\010s" +
+      "equence\030\006 \001(\0132\024.sysml.ValueSequenceH\000R\010s" +
+      "equence\022\024\n\004null\030\007 \001(\tH\000R\004null\022-\n\010quantit" +
+      "y\030\010 \001(\0132\017.sysml.QuantityH\000R\010quantity\0227\n\014" +
+      "enum_literal\030\t \001(\0132\022.sysml.EnumLiteralH\000" +
+      "R\013enumLiteral\022\026\n\005unset\030\n \001(\010H\000R\005unset\022*\n" +
+      "\007complex\030\013 \001(\0132\016.sysml.ComplexH\000R\007comple" +
+      "x\022$\n\005array\030\014 \001(\0132\014.sysml.ArrayH\000R\005array\022" +
+      "\'\n\006vector\030\r \001(\0132\r.sysml.VectorH\000R\006vector" +
+      "\022@\n\017vector_quantity\030\016 \001(\0132\025.sysml.Vector" +
+      "QuantityH\000R\016vectorQuantity\022@\n\017measuremen" +
+      "t_ref\030\017 \001(\0132\025.sysml.MeasurementRefH\000R\016me" +
+      "asurementRef\022\034\n\010infinity\030\020 \001(\010H\000R\010infini" +
+      "ty\022-\n\010function\030\021 \001(\0132\017.sysml.FunctionH\000R" +
+      "\010function\022#\n\003set\030\022 \001(\0132\017.sysml.ValueSetH" +
+      "\000R\003set\022@\n\017tensor_quantity\030\023 \001(\0132\025.sysml." +
+      "TensorQuantityH\000R\016tensorQuantity\0223\n\nmeta" +
+      "object\030\024 \001(\0132\021.sysml.MetaobjectH\000R\nmetao" +
+      "bject\0229\n\014undetermined\030\025 \001(\0132\023.sysml.Unde" +
+      "terminedH\000R\014undeterminedB\006\n\004kind\"N\n\nMeta" +
+      "object\022\035\n\nelement_id\030\001 \001(\tR\telementId\022!\n" +
+      "\014metaclass_id\030\002 \001(\tR\013metaclassId\"U\n\014Unde" +
+      "termined\022\026\n\006reason\030\001 \001(\tR\006reason\022-\n\005coun" +
+      "t\030\002 \001(\0132\027.sysml.MultiplicityInfoR\005count\"" +
+      "<\n\010Function\022\027\n\007calc_id\030\001 \001(\tR\006calcId\022\027\n\007" +
+      "self_id\030\002 \001(\003R\006selfId\"4\n\010ValueSet\022(\n\010ele" +
+      "ments\030\001 \003(\0132\014.sysml.ValueR\010elements\"a\n\016T" +
+      "ensorQuantity\022\036\n\ndimensions\030\001 \003(\003R\ndimen" +
+      "sions\022/\n\ncomponents\030\002 \003(\0132\017.sysml.Quanti" +
+      "tyR\ncomponents\"Q\n\005Array\022\036\n\ndimensions\030\001 " +
+      "\003(\003R\ndimensions\022(\n\010elements\030\002 \003(\0132\014.sysm" +
+      "l.ValueR\010elements\"6\n\006Vector\022,\n\ncomponent" +
+      "s\030\001 \003(\0132\014.sysml.ValueR\ncomponents\"A\n\016Vec" +
+      "torQuantity\022/\n\ncomponents\030\001 \003(\0132\017.sysml." +
+      "QuantityR\ncomponents\";\n\007Complex\022\022\n\004real\030" +
+      "\001 \001(\001R\004real\022\034\n\timaginary\030\002 \001(\001R\timaginar" +
+      "y\"\213\001\n\013EnumLiteral\022\035\n\nliteral_id\030\001 \001(\tR\tl" +
+      "iteralId\022%\n\016enumeration_id\030\002 \001(\tR\renumer" +
+      "ationId\022\022\n\004name\030\003 \001(\tR\004name\022\"\n\005value\030\004 \001" +
+      "(\0132\014.sysml.ValueR\005value\"9\n\rValueSequence" +
+      "\022(\n\010elements\030\001 \003(\0132\014.sysml.ValueR\010elemen" +
+      "ts\"\251\001\n\010Quantity\022%\n\rint_magnitude\030\001 \001(\003H\000" +
+      "R\014intMagnitude\022\'\n\016real_magnitude\030\002 \001(\001H\000" +
+      "R\rrealMagnitude\022\022\n\004unit\030\003 \001(\tR\004unit\022,\n\tu" +
+      "nit_term\030\004 \001(\0132\017.sysml.UnitTermR\010unitTer" +
+      "mB\013\n\tmagnitude\"k\n\016MeasurementRef\022\022\n\004unit" +
+      "\030\001 \001(\tR\004unit\022,\n\tunit_term\030\002 \001(\0132\017.sysml." +
+      "UnitTermR\010unitTerm\022\027\n\007unit_id\030\003 \001(\tR\006uni" +
+      "tId\"q\n\010UnitTerm\022\033\n\tscale_num\030\001 \001(\001R\010scal" +
+      "eNum\022\033\n\tscale_den\030\002 \001(\001R\010scaleDen\022+\n\007fac" +
+      "tors\030\003 \003(\0132\021.sysml.UnitFactorR\007factors\"A" +
+      "\n\nUnitFactor\022\027\n\007unit_id\030\001 \001(\tR\006unitId\022\032\n" +
+      "\010exponent\030\002 \001(\001R\010exponent\"w\n\nDiagnostic\022" +
+      "\032\n\010severity\030\001 \001(\tR\010severity\022\030\n\007message\030\002" +
+      " \001(\tR\007message\022\037\n\004span\030\003 \001(\0132\013.sysml.Span" +
+      "R\004span\022\022\n\004code\030\004 \001(\tR\004code\"\212\001\n\004Span\022\022\n\004f" +
+      "ile\030\001 \001(\tR\004file\022\035\n\nstart_line\030\002 \001(\005R\tsta" +
+      "rtLine\022\033\n\tstart_col\030\003 \001(\005R\010startCol\022\031\n\010e" +
+      "nd_line\030\004 \001(\005R\007endLine\022\027\n\007end_col\030\005 \001(\005R" +
+      "\006endCol\"\023\n\021ServerInfoRequest\"R\n\022ServerIn" +
+      "foResponse\022\030\n\007version\030\001 \001(\tR\007version\022\"\n\014" +
+      "capabilities\030\002 \003(\tR\014capabilities\"p\n\014Quer" +
+      "yRequest\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash\022" +
+      "\"\n\005query\030\002 \001(\0132\014.sysml.QueryR\005query\022\035\n\no" +
+      "slc_query\030\003 \001(\tR\toslcQuery\"F\n\rQueryRespo" +
+      "nse\0225\n\010elements\030\001 \003(\0132\031.sysml.QueryResul" +
+      "tElementR\010elements\"^\n\005Query\022\024\n\005scope\030\001 \003" +
+      "(\tR\005scope\022\026\n\006select\030\002 \003(\tR\006select\022\'\n\005whe" +
+      "re\030\003 \001(\0132\021.sysml.ConstraintR\005where\"\222\001\n\nC" +
+      "onstraint\022:\n\tprimitive\030\001 \001(\0132\032.sysml.Pri" +
+      "mitiveConstraintH\000R\tprimitive\022:\n\tcomposi" +
+      "te\030\002 \001(\0132\032.sysml.CompositeConstraintH\000R\t" +
+      "compositeB\014\n\nconstraint\"\227\001\n\023PrimitiveCon" +
+      "straint\022\030\n\007inverse\030\001 \001(\010R\007inverse\022\032\n\010pro" +
+      "perty\030\002 \001(\tR\010property\0224\n\010operator\030\003 \001(\0162" +
+      "\030.sysml.PrimitiveOperatorR\010operator\022\024\n\005v" +
+      "alue\030\004 \003(\tR\005value\"~\n\023CompositeConstraint" +
+      "\0224\n\010operator\030\001 \001(\0162\030.sysml.CompositeOper" +
+      "atorR\010operator\0221\n\nconstraint\030\002 \003(\0132\021.sys" +
+      "ml.ConstraintR\nconstraint\"\302\001\n\022QueryResul" +
+      "tElement\022\016\n\002id\030\001 \001(\tR\002id\022\022\n\004type\030\002 \001(\tR\004" +
+      "type\022I\n\nproperties\030\003 \003(\0132).sysml.QueryRe" +
+      "sultElement.PropertiesEntryR\nproperties\032" +
+      "=\n\017PropertiesEntry\022\020\n\003key\030\001 \001(\tR\003key\022\024\n\005" +
+      "value\030\002 \001(\tR\005value:\0028\001\"\220\001\n\nSweepRange\022\034\n" +
+      "\tparameter\030\001 \001(\tR\tparameter\022\"\n\005start\030\002 \001" +
+      "(\0132\014.sysml.ValueR\005start\022\036\n\003end\030\003 \001(\0132\014.s" +
+      "ysml.ValueR\003end\022 \n\004step\030\004 \001(\0132\014.sysml.Va" +
+      "lueR\004step\"\274\003\n\017RunSweepRequest\022\035\n\nmodel_h" +
+      "ash\030\001 \001(\tR\tmodelHash\022\033\n\tsymbol_id\030\002 \001(\tR" +
+      "\010symbolId\022*\n\021subject_symbol_id\030\003 \001(\tR\017su" +
+      "bjectSymbolId\022*\n\targuments\030\004 \003(\0132\014.sysml" +
+      ".ValueR\targuments\022S\n\017named_arguments\030\005 \003" +
+      "(\0132*.sysml.RunSweepRequest.NamedArgument" +
+      "sEntryR\016namedArguments\022)\n\006ranges\030\006 \003(\0132\021" +
+      ".sysml.SweepRangeR\006ranges\022\030\n\007samples\030\007 \001" +
+      "(\003R\007samples\022\022\n\004seed\030\010 \001(\004R\004seed\022\026\n\006engin" +
+      "e\030\t \001(\tR\006engine\032O\n\023NamedArgumentsEntry\022\020",
+      "\n\003key\030\001 \001(\tR\003key\022\"\n\005value\030\002 \001(\0132\014.sysml." +
+      "ValueR\005value:\0028\001\"\301\002\n\010SweepRow\022)\n\006inputs\030" +
+      "\001 \003(\0132\021.sysml.CalcOutputR\006inputs\022+\n\007outp" +
+      "uts\030\002 \003(\0132\021.sysml.CalcOutputR\007outputs\022*\n" +
+      "\010verdicts\030\003 \003(\0132\016.sysml.VerdictR\010verdict" +
+      "s\022%\n\016elapsed_micros\030\004 \001(\003R\relapsedMicros" +
+      "\022\024\n\005error\030\005 \001(\tR\005error\022;\n\016failure_reason" +
+      "\030\006 \001(\0162\024.sysml.FailureReasonR\rfailureRea" +
+      "son\0227\n\013evaluations\030\007 \003(\0132\025.sysml.CaseEva" +
+      "luationR\013evaluations\"\226\003\n\020RunSweepRespons" +
+      "e\022#\n\004rows\030\001 \003(\0132\017.sysml.SweepRowR\004rows\022\036" +
+      "\n\nparameters\030\002 \003(\tR\nparameters\022\030\n\007sample" +
+      "d\030\003 \001(\010R\007sampled\022\022\n\004seed\030\004 \001(\004R\004seed\022\024\n\005" +
+      "error\030\005 \001(\tR\005error\0223\n\013diagnostics\030\006 \003(\0132" +
+      "\021.sysml.DiagnosticR\013diagnostics\022;\n\016failu" +
+      "re_reason\030\007 \001(\0162\024.sysml.FailureReasonR\rf" +
+      "ailureReason\022-\n\tinstances\030\010 \003(\0132\017.sysml." +
+      "InstanceR\tinstances\022\026\n\006engine\030\t \001(\tR\006eng" +
+      "ine\022\032\n\010strength\030\n \001(\tR\010strength\022$\n\006bound" +
+      "s\030\013 \003(\0132\014.sysml.BoundR\006bounds\"\214\001\n\027RunDoc" +
+      "umentQueryRequest\022\035\n\nmodel_hash\030\001 \001(\tR\tm" +
+      "odelHash\022\031\n\010query_id\030\002 \001(\tR\007queryId\0227\n\010b" +
+      "indings\030\003 \003(\0132\033.sysml.DocumentQueryBindi" +
+      "ngR\010bindings\"b\n\024DocumentQueryBinding\022\034\n\t" +
+      "parameter\030\001 \001(\tR\tparameter\022,\n\006values\030\002 \003" +
+      "(\0132\024.sysml.DocumentValueR\006values\"\357\003\n\rDoc" +
+      "umentValue\022\037\n\nelement_id\030\001 \001(\tH\000R\telemen" +
+      "tId\022#\n\014string_value\030\002 \001(\tH\000R\013stringValue" +
+      "\022\035\n\tint_value\030\003 \001(\003H\000R\010intValue\022\037\n\nreal_" +
+      "value\030\004 \001(\001H\000R\trealValue\022\037\n\nbool_value\030\005" +
+      " \001(\010H\000R\tboolValue\022\034\n\010infinity\030\006 \001(\010H\000R\010i" +
+      "nfinity\022-\n\010quantity\030\010 \001(\0132\017.sysml.Quanti" +
+      "tyH\000R\010quantity\0222\n\007verdict\030\t \001(\0132\026.sysml." +
+      "DocumentVerdictH\000R\007verdict\022/\n\006object\030\n \001" +
+      "(\0132\025.sysml.DocumentObjectH\000R\006object\022,\n\005s" +
+      "tate\030\013 \001(\0132\024.sysml.DocumentStateH\000R\005stat" +
+      "e\022,\n\005event\030\014 \001(\0132\024.sysml.DocumentEventH\000" +
+      "R\005event\022!\n\014element_type\030\007 \001(\tR\013elementTy" +
+      "peB\006\n\004kind\"u\n\016DocumentObject\022\037\n\013instance" +
+      "_id\030\001 \001(\003R\ninstanceId\022\022\n\004path\030\002 \001(\tR\004pat" +
+      "h\022.\n\007element\030\003 \001(\0132\024.sysml.DocumentValue" +
+      "R\007element\"\365\001\n\017DocumentVerdict\0222\n\tasserti" +
+      "on\030\001 \001(\0132\024.sysml.DocumentValueR\tassertio" +
+      "n\022\022\n\004kind\030\002 \001(\tR\004kind\022\022\n\004text\030\003 \001(\tR\004tex" +
+      "t\022\022\n\004path\030\004 \001(\tR\004path\022\030\n\007verdict\030\005 \001(\tR\007" +
+      "verdict\022\034\n\tcondition\030\006 \001(\tR\tcondition\022\026\n" +
+      "\006reason\030\007 \001(\tR\006reason\022\"\n\014verification\030\010 " +
+      "\003(\tR\014verification\"\355\001\n\rDocumentState\022-\n\006o" +
+      "bject\030\001 \001(\0132\025.sysml.DocumentObjectR\006obje" +
+      "ct\022\030\n\007machine\030\002 \001(\tR\007machine\022\022\n\004name\030\003 \001" +
+      "(\tR\004name\022\035\n\nstate_path\030\004 \001(\tR\tstatePath\022" +
+      "*\n\005state\030\005 \001(\0132\024.sysml.DocumentValueR\005st" +
+      "ate\022\026\n\006region\030\006 \001(\tR\006region\022\034\n\tenclosing" +
+      "\030\007 \003(\tR\tenclosing\"\375\002\n\rDocumentEvent\022\022\n\004k" +
+      "ind\030\001 \001(\tR\004kind\022(\n\004time\030\002 \001(\0132\024.sysml.Do" +
+      "cumentValueR\004time\022-\n\006object\030\003 \001(\0132\025.sysm" +
+      "l.DocumentObjectR\006object\022\030\n\007machine\030\004 \001(" +
+      "\tR\007machine\022\024\n\005state\030\005 \001(\tR\005state\022\022\n\004from" +
+      "\030\006 \001(\tR\004from\022\016\n\002to\030\007 \001(\tR\002to\022-\n\006target\030\010" +
+      " \001(\0132\025.sysml.DocumentObjectR\006target\022\024\n\005e" +
+      "vent\030\t \001(\tR\005event\022\030\n\007payload\030\n \003(\tR\007payl" +
+      "oad\022\"\n\014alternatives\030\013 \003(\tR\014alternatives\022" +
+      "\024\n\005taken\030\014 \001(\tR\005taken\022\022\n\004text\030\r \001(\tR\004tex" +
+      "t\")\n\023DocumentQueryColumn\022\022\n\004name\030\001 \001(\tR\004" +
+      "name\"A\n\021DocumentQueryCell\022,\n\006values\030\001 \003(" +
+      "\0132\024.sysml.DocumentValueR\006values\"r\n\020Docum" +
+      "entQueryRow\022.\n\007element\030\001 \001(\0132\024.sysml.Doc" +
+      "umentValueR\007element\022.\n\005cells\030\002 \003(\0132\030.sys" +
+      "ml.DocumentQueryCellR\005cells\"}\n\030RunDocume" +
+      "ntQueryResponse\0224\n\007columns\030\001 \003(\0132\032.sysml" +
+      ".DocumentQueryColumnR\007columns\022+\n\004rows\030\002 " +
+      "\003(\0132\027.sysml.DocumentQueryRowR\004rows\"k\n\025Re" +
+      "nderDocumentRequest\022\035\n\nmodel_hash\030\001 \001(\tR" +
+      "\tmodelHash\022\037\n\013document_id\030\002 \001(\tR\ndocumen" +
+      "tId\022\022\n\004form\030\003 \001(\tR\004form\"H\n\026RenderDocumen" +
+      "tResponse\022\032\n\010markdown\030\001 \001(\tR\010markdown\022\022\n" +
+      "\004html\030\002 \001(\tR\004html*\223\001\n\rFailureReason\022\036\n\032F" +
+      "AILURE_REASON_UNSPECIFIED\020\000\022\035\n\031FAILURE_R" +
+      "EASON_EVALUATION\020\001\022\035\n\031FAILURE_REASON_WRO" +
+      "NG_KIND\020\002\022$\n FAILURE_REASON_AMBIGUOUS_SU" +
+      "BJECT\020\003*\214\005\n\013EditFailure\022\034\n\030EDIT_FAILURE_" +
+      "UNSPECIFIED\020\000\022\036\n\032EDIT_FAILURE_NO_OPERATI" +
+      "ONS\020\001\022\037\n\033EDIT_FAILURE_UNKNOWN_TARGET\020\002\022!" +
+      "\n\035EDIT_FAILURE_AMBIGUOUS_TARGET\020\003\022\033\n\027EDI" +
+      "T_FAILURE_NOT_VALUED\020\004\022\036\n\032EDIT_FAILURE_I" +
+      "NVALID_VALUE\020\005\022\035\n\031EDIT_FAILURE_INVALID_N" +
+      "AME\020\006\022\032\n\026EDIT_FAILURE_NOT_NAMED\020\007\022\"\n\036EDI" +
+      "T_FAILURE_RENAME_REFERENCED\020\010\022\"\n\036EDIT_FA" +
+      "ILURE_OVERLAPPING_EDITS\020\t\022\037\n\033EDIT_FAILUR" +
+      "E_RESULT_INVALID\020\n\022\036\n\032EDIT_FAILURE_OWNER" +
+      "_UNKNOWN\020\013\022$\n EDIT_FAILURE_OWNER_NOT_NAM" +
+      "ESPACE\020\014\022\035\n\031EDIT_FAILURE_ILLEGAL_KIND\020\r\022" +
+      "\"\n\036EDIT_FAILURE_MEMBER_NAME_TAKEN\020\016\022\"\n\036E" +
+      "DIT_FAILURE_DELETE_REFERENCED\020\017\022$\n EDIT_" +
+      "FAILURE_OWNER_INSIDE_TARGET\020\020\022 \n\034EDIT_FA" +
+      "ILURE_MOVE_REFERENCED\020\021\022%\n!EDIT_FAILURE_" +
+      "REFERENCED_ELSEWHERE\020\022*\222\001\n\021PrimitiveOper" +
+      "ator\022\"\n\036PRIMITIVE_OPERATOR_UNSPECIFIED\020\000" +
+      "\022\034\n\030PRIMITIVE_OPERATOR_EQUAL\020\001\022\036\n\032PRIMIT" +
+      "IVE_OPERATOR_GREATER\020\002\022\033\n\027PRIMITIVE_OPER" +
+      "ATOR_LESS\020\003*n\n\021CompositeOperator\022\"\n\036COMP" +
+      "OSITE_OPERATOR_UNSPECIFIED\020\000\022\032\n\026COMPOSIT" +
+      "E_OPERATOR_AND\020\001\022\031\n\025COMPOSITE_OPERATOR_O" +
+      "R\020\0022\277\014\n\014SysMLService\022D\n\rGetServerInfo\022\030." +
+      "sysml.ServerInfoRequest\032\031.sysml.ServerIn" +
+      "foResponse\022>\n\tParseFile\022\027.sysml.ParseFil" +
+      "eRequest\032\030.sysml.ParseFileResponse\022G\n\014Pa" +
+      "rseSources\022\032.sysml.ParseSourcesRequest\032\033" +
+      ".sysml.ParseSourcesResponse\022;\n\tGetSymbol" +
+      "\022\027.sysml.GetSymbolRequest\032\025.sysml.Symbol" +
+      "Response\022G\n\016GetDiagnostics\022\031.sysml.Diagn" +
+      "osticsRequest\032\032.sysml.DiagnosticsRespons" +
+      "e\022;\n\010Evaluate\022\026.sysml.EvaluateRequest\032\027." +
+      "sysml.EvaluateResponse\022D\n\013Instantiate\022\031." +
+      "sysml.InstantiateRequest\032\032.sysml.Instant" +
+      "iateResponse\022J\n\rExecuteAction\022\033.sysml.Ex" +
+      "ecuteActionRequest\032\034.sysml.ExecuteAction" +
+      "Response\022G\n\014ExecuteState\022\032.sysml.Execute" +
+      "StateRequest\032\033.sysml.ExecuteStateRespons" +
+      "e\0228\n\007Convert\022\025.sysml.ConvertRequest\032\026.sy" +
+      "sml.ConvertResponse\022A\n\nApplyEdits\022\030.sysm" +
+      "l.ApplyEditsRequest\032\031.sysml.ApplyEditsRe" +
+      "sponse\022S\n\020VerifyConstraint\022\036.sysml.Verif" +
+      "yConstraintRequest\032\037.sysml.VerifyConstra" +
+      "intResponse\022V\n\021VerifyRequirement\022\037.sysml" +
+      ".VerifyRequirementRequest\032 .sysml.Verify" +
+      "RequirementResponse\022Y\n\022VerifySatisfactio" +
+      "n\022 .sysml.VerifySatisfactionRequest\032!.sy" +
+      "sml.VerifySatisfactionResponse\022S\n\020Valida" +
+      "teInstance\022\036.sysml.ValidateInstanceReque" +
+      "st\032\037.sysml.ValidateInstanceResponse\022G\n\014E" +
+      "valuateCalc\022\032.sysml.EvaluateCalcRequest\032" +
+      "\033.sysml.EvaluateCalcResponse\022D\n\013RunAnaly" +
+      "sis\022\031.sysml.RunAnalysisRequest\032\032.sysml.R" +
+      "unAnalysisResponse\022;\n\010RunSweep\022\026.sysml.R" +
+      "unSweepRequest\032\027.sysml.RunSweepResponse\022" +
+      "D\n\013ListEngines\022\031.sysml.ListEnginesReques" +
+      "t\032\032.sysml.ListEnginesResponse\0222\n\005Query\022\023" +
+      ".sysml.QueryRequest\032\024.sysml.QueryRespons" +
+      "e\022S\n\020RunDocumentQuery\022\036.sysml.RunDocumen" +
+      "tQueryRequest\032\037.sysml.RunDocumentQueryRe" +
+      "sponse\022M\n\016RenderDocument\022\034.sysml.RenderD" +
+      "ocumentRequest\032\035.sysml.RenderDocumentRes" +
+      "ponseBJ\n\034org.openmbee.opensysml.protoP\001Z" +
+      "(github.com/Open-MBEE/OpenSysML/api/prot" +
+      "ob\006proto3"
+    };
+    descriptor = com.google.protobuf.Descriptors.FileDescriptor
+      .internalBuildGeneratedFileFrom(descriptorData,
+        new com.google.protobuf.Descriptors.FileDescriptor[] {
+        });
+    internal_static_sysml_Verdict_descriptor =
+      getDescriptor().getMessageType(0);
+    internal_static_sysml_Verdict_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Verdict_descriptor,
+        new java.lang.String[] { "Kind", "ElementId", "Element", "Holds", "Condition", "InstanceId", "InstanceTypeId", "Error", "FailureReason", "RequirementId", "Engine", "Strength", "Bounds", "InstancePath", });
+    internal_static_sysml_Bound_descriptor =
+      getDescriptor().getMessageType(1);
+    internal_static_sysml_Bound_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Bound_descriptor,
+        new java.lang.String[] { "Name", "Limit", "Reached", });
+    internal_static_sysml_VerifyConstraintRequest_descriptor =
+      getDescriptor().getMessageType(2);
+    internal_static_sysml_VerifyConstraintRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_VerifyConstraintRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "SymbolId", "SubjectSymbolId", "Engine", });
+    internal_static_sysml_VerifyConstraintResponse_descriptor =
+      getDescriptor().getMessageType(3);
+    internal_static_sysml_VerifyConstraintResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_VerifyConstraintResponse_descriptor,
+        new java.lang.String[] { "Verdict", "Instances", "Error", "Diagnostics", });
+    internal_static_sysml_VerifyRequirementRequest_descriptor =
+      getDescriptor().getMessageType(4);
+    internal_static_sysml_VerifyRequirementRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_VerifyRequirementRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "SymbolId", "SubjectSymbolId", "Engine", });
+    internal_static_sysml_VerificationVerdict_descriptor =
+      getDescriptor().getMessageType(5);
+    internal_static_sysml_VerificationVerdict_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_VerificationVerdict_descriptor,
+        new java.lang.String[] { "CaseId", "Kind", "Detail", "Subcase", "RequirementId", });
+    internal_static_sysml_VerifyRequirementResponse_descriptor =
+      getDescriptor().getMessageType(6);
+    internal_static_sysml_VerifyRequirementResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_VerifyRequirementResponse_descriptor,
+        new java.lang.String[] { "Verdict", "Instances", "Error", "Diagnostics", "VerificationVerdicts", });
+    internal_static_sysml_VerifySatisfactionRequest_descriptor =
+      getDescriptor().getMessageType(7);
+    internal_static_sysml_VerifySatisfactionRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_VerifySatisfactionRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "SymbolId", "Engine", });
+    internal_static_sysml_VerifySatisfactionResponse_descriptor =
+      getDescriptor().getMessageType(8);
+    internal_static_sysml_VerifySatisfactionResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_VerifySatisfactionResponse_descriptor,
+        new java.lang.String[] { "Verdicts", "Instances", "Error", "Diagnostics", "FailureReason", "VerificationVerdicts", });
+    internal_static_sysml_ValidateInstanceRequest_descriptor =
+      getDescriptor().getMessageType(9);
+    internal_static_sysml_ValidateInstanceRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ValidateInstanceRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "SymbolId", "Engine", });
+    internal_static_sysml_ValidateInstanceResponse_descriptor =
+      getDescriptor().getMessageType(10);
+    internal_static_sysml_ValidateInstanceResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ValidateInstanceResponse_descriptor,
+        new java.lang.String[] { "Verdicts", "Summary", "Instances", "Error", "Diagnostics", "FailureReason", "VerificationVerdicts", "Bounded", });
+    internal_static_sysml_EvaluateCalcRequest_descriptor =
+      getDescriptor().getMessageType(11);
+    internal_static_sysml_EvaluateCalcRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_EvaluateCalcRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "SymbolId", "Arguments", "Engine", });
+    internal_static_sysml_EvaluateCalcResponse_descriptor =
+      getDescriptor().getMessageType(12);
+    internal_static_sysml_EvaluateCalcResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_EvaluateCalcResponse_descriptor,
+        new java.lang.String[] { "Result", "Outputs", "Error", "Diagnostics", "FailureReason", "Engine", "Strength", "Bounds", });
+    internal_static_sysml_CalcOutput_descriptor =
+      getDescriptor().getMessageType(13);
+    internal_static_sysml_CalcOutput_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_CalcOutput_descriptor,
+        new java.lang.String[] { "Name", "Value", });
+    internal_static_sysml_CaseEvaluation_descriptor =
+      getDescriptor().getMessageType(14);
+    internal_static_sysml_CaseEvaluation_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_CaseEvaluation_descriptor,
+        new java.lang.String[] { "FunctionId", "Arguments", "Result", "Error", "Selected", "Tied", });
+    internal_static_sysml_RunAnalysisRequest_descriptor =
+      getDescriptor().getMessageType(15);
+    internal_static_sysml_RunAnalysisRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RunAnalysisRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "SymbolId", "SubjectSymbolId", "Arguments", "NamedArguments", "Schedule", "Engine", });
+    internal_static_sysml_RunAnalysisRequest_NamedArgumentsEntry_descriptor =
+      internal_static_sysml_RunAnalysisRequest_descriptor.getNestedType(0);
+    internal_static_sysml_RunAnalysisRequest_NamedArgumentsEntry_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RunAnalysisRequest_NamedArgumentsEntry_descriptor,
+        new java.lang.String[] { "Key", "Value", });
+    internal_static_sysml_RunAnalysisResponse_descriptor =
+      getDescriptor().getMessageType(16);
+    internal_static_sysml_RunAnalysisResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RunAnalysisResponse_descriptor,
+        new java.lang.String[] { "Outputs", "Verdicts", "Instances", "Error", "Diagnostics", "FailureReason", "VerificationVerdicts", "Outcomes", "Exploration", "Evaluations", "Engine", "Strength", "Bounds", });
+    internal_static_sysml_Outcome_descriptor =
+      getDescriptor().getMessageType(17);
+    internal_static_sysml_Outcome_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Outcome_descriptor,
+        new java.lang.String[] { "Outputs", "FinalState", "StatesVisited", "Error", "Linearizations", "Witness", "Diagnostics", "Probability", });
+    internal_static_sysml_Outcome_OutputsEntry_descriptor =
+      internal_static_sysml_Outcome_descriptor.getNestedType(0);
+    internal_static_sysml_Outcome_OutputsEntry_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Outcome_OutputsEntry_descriptor,
+        new java.lang.String[] { "Key", "Value", });
+    internal_static_sysml_ExplorationStatus_descriptor =
+      getDescriptor().getMessageType(18);
+    internal_static_sysml_ExplorationStatus_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ExplorationStatus_descriptor,
+        new java.lang.String[] { "Complete", "Runs", "BudgetsHit", "RunsBudget", "DepthBudget", "ProbabilitiesLowerBound", });
+    internal_static_sysml_ListEnginesRequest_descriptor =
+      getDescriptor().getMessageType(19);
+    internal_static_sysml_ListEnginesRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ListEnginesRequest_descriptor,
+        new java.lang.String[] { });
+    internal_static_sysml_EngineInfo_descriptor =
+      getDescriptor().getMessageType(20);
+    internal_static_sysml_EngineInfo_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_EngineInfo_descriptor,
+        new java.lang.String[] { "Name", "Authority", "Answers", "Bounds", "Process", "ProcessFound", "Ready", "Unavailable", "Kind", "Protocol", "Source", "Command", "Version", "Served", });
+    internal_static_sysml_ListEnginesResponse_descriptor =
+      getDescriptor().getMessageType(21);
+    internal_static_sysml_ListEnginesResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ListEnginesResponse_descriptor,
+        new java.lang.String[] { "Engines", });
+    internal_static_sysml_ParseFileRequest_descriptor =
+      getDescriptor().getMessageType(22);
+    internal_static_sysml_ParseFileRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ParseFileRequest_descriptor,
+        new java.lang.String[] { "FilePath", "Content", "ContentHash", "Language", "StrictConformance", "Source", });
+    internal_static_sysml_SourceDocument_descriptor =
+      getDescriptor().getMessageType(23);
+    internal_static_sysml_SourceDocument_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_SourceDocument_descriptor,
+        new java.lang.String[] { "FilePath", "Content", "Language", "Name", "Source", });
+    internal_static_sysml_ParseSourcesRequest_descriptor =
+      getDescriptor().getMessageType(24);
+    internal_static_sysml_ParseSourcesRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ParseSourcesRequest_descriptor,
+        new java.lang.String[] { "Documents", "StrictConformance", });
+    internal_static_sysml_ParseSourcesResponse_descriptor =
+      getDescriptor().getMessageType(25);
+    internal_static_sysml_ParseSourcesResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ParseSourcesResponse_descriptor,
+        new java.lang.String[] { "ModelHash", "Roots", "Diagnostics", "Error", });
+    internal_static_sysml_ParseFileResponse_descriptor =
+      getDescriptor().getMessageType(26);
+    internal_static_sysml_ParseFileResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ParseFileResponse_descriptor,
+        new java.lang.String[] { "ModelHash", "Root", "Diagnostics", "Error", });
+    internal_static_sysml_GetSymbolRequest_descriptor =
+      getDescriptor().getMessageType(27);
+    internal_static_sysml_GetSymbolRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_GetSymbolRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "SymbolId", });
+    internal_static_sysml_SymbolResponse_descriptor =
+      getDescriptor().getMessageType(28);
+    internal_static_sysml_SymbolResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_SymbolResponse_descriptor,
+        new java.lang.String[] { "Symbol", "Error", });
+    internal_static_sysml_DiagnosticsRequest_descriptor =
+      getDescriptor().getMessageType(29);
+    internal_static_sysml_DiagnosticsRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DiagnosticsRequest_descriptor,
+        new java.lang.String[] { "ModelHash", });
+    internal_static_sysml_DiagnosticsResponse_descriptor =
+      getDescriptor().getMessageType(30);
+    internal_static_sysml_DiagnosticsResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DiagnosticsResponse_descriptor,
+        new java.lang.String[] { "Diagnostics", "Error", });
+    internal_static_sysml_EvaluateRequest_descriptor =
+      getDescriptor().getMessageType(31);
+    internal_static_sysml_EvaluateRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_EvaluateRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "Expression", "ContextSymbolId", "SubjectSymbolId", });
+    internal_static_sysml_EvaluateResponse_descriptor =
+      getDescriptor().getMessageType(32);
+    internal_static_sysml_EvaluateResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_EvaluateResponse_descriptor,
+        new java.lang.String[] { "Result", "Error", "Diagnostics", });
+    internal_static_sysml_Instance_descriptor =
+      getDescriptor().getMessageType(33);
+    internal_static_sysml_Instance_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Instance_descriptor,
+        new java.lang.String[] { "Id", "TypeSymbolId", "FeatureValues", });
+    internal_static_sysml_Instance_FeatureValuesEntry_descriptor =
+      internal_static_sysml_Instance_descriptor.getNestedType(0);
+    internal_static_sysml_Instance_FeatureValuesEntry_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Instance_FeatureValuesEntry_descriptor,
+        new java.lang.String[] { "Key", "Value", });
+    internal_static_sysml_FeatureValue_descriptor =
+      getDescriptor().getMessageType(34);
+    internal_static_sysml_FeatureValue_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_FeatureValue_descriptor,
+        new java.lang.String[] { "FeatureName", "Value", "Values", "Materialized", "Error", });
+    internal_static_sysml_InstantiateRequest_descriptor =
+      getDescriptor().getMessageType(35);
+    internal_static_sysml_InstantiateRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_InstantiateRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "SymbolId", });
+    internal_static_sysml_InstantiateResponse_descriptor =
+      getDescriptor().getMessageType(36);
+    internal_static_sysml_InstantiateResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_InstantiateResponse_descriptor,
+        new java.lang.String[] { "Instance", "Error", "Diagnostics", "Instances", });
+    internal_static_sysml_ExecuteActionRequest_descriptor =
+      getDescriptor().getMessageType(37);
+    internal_static_sysml_ExecuteActionRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ExecuteActionRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "ActionSymbolId", "Inputs", "Schedule", "PerformerSymbolId", });
+    internal_static_sysml_ExecuteActionRequest_InputsEntry_descriptor =
+      internal_static_sysml_ExecuteActionRequest_descriptor.getNestedType(0);
+    internal_static_sysml_ExecuteActionRequest_InputsEntry_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ExecuteActionRequest_InputsEntry_descriptor,
+        new java.lang.String[] { "Key", "Value", });
+    internal_static_sysml_ExecuteActionResponse_descriptor =
+      getDescriptor().getMessageType(38);
+    internal_static_sysml_ExecuteActionResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ExecuteActionResponse_descriptor,
+        new java.lang.String[] { "Outputs", "Error", "Diagnostics", "Outcomes", "Exploration", "FinalTime", });
+    internal_static_sysml_ExecuteActionResponse_OutputsEntry_descriptor =
+      internal_static_sysml_ExecuteActionResponse_descriptor.getNestedType(0);
+    internal_static_sysml_ExecuteActionResponse_OutputsEntry_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ExecuteActionResponse_OutputsEntry_descriptor,
+        new java.lang.String[] { "Key", "Value", });
+    internal_static_sysml_ExecuteStateRequest_descriptor =
+      getDescriptor().getMessageType(39);
+    internal_static_sysml_ExecuteStateRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ExecuteStateRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "StateMachineSymbolId", "Events", "Schedule", "PerformerSymbolId", });
+    internal_static_sysml_ExecuteStateResponse_descriptor =
+      getDescriptor().getMessageType(40);
+    internal_static_sysml_ExecuteStateResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ExecuteStateResponse_descriptor,
+        new java.lang.String[] { "StatesVisited", "FinalContext", "Error", "Diagnostics", "Outcomes", "Exploration", "FinalTime", });
+    internal_static_sysml_ExecuteStateResponse_FinalContextEntry_descriptor =
+      internal_static_sysml_ExecuteStateResponse_descriptor.getNestedType(0);
+    internal_static_sysml_ExecuteStateResponse_FinalContextEntry_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ExecuteStateResponse_FinalContextEntry_descriptor,
+        new java.lang.String[] { "Key", "Value", });
+    internal_static_sysml_ConvertRequest_descriptor =
+      getDescriptor().getMessageType(41);
+    internal_static_sysml_ConvertRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ConvertRequest_descriptor,
+        new java.lang.String[] { "FilePath", "Content", "ModelHash", "FromFormat", "ToFormat", "TolerateSyntaxErrors", "Source", });
+    internal_static_sysml_ConvertResponse_descriptor =
+      getDescriptor().getMessageType(42);
+    internal_static_sysml_ConvertResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ConvertResponse_descriptor,
+        new java.lang.String[] { "Content", "FromFormat", "ToFormat", "Error", "Diagnostics", "Experimental", "ExperimentalNotice", });
+    internal_static_sysml_ApplyEditsRequest_descriptor =
+      getDescriptor().getMessageType(43);
+    internal_static_sysml_ApplyEditsRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ApplyEditsRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "Operations", "Document", "AcceptDocuments", });
+    internal_static_sysml_EditOperation_descriptor =
+      getDescriptor().getMessageType(44);
+    internal_static_sysml_EditOperation_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_EditOperation_descriptor,
+        new java.lang.String[] { "SetValue", "Rename", "AddMember", "Delete", "Move", "Operation", });
+    internal_static_sysml_AddMemberEdit_descriptor =
+      getDescriptor().getMessageType(45);
+    internal_static_sysml_AddMemberEdit_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_AddMemberEdit_descriptor,
+        new java.lang.String[] { "Owner", "Kind", "Name", "Type", "Multiplicity", "Value", "Specializes", });
+    internal_static_sysml_DeleteEdit_descriptor =
+      getDescriptor().getMessageType(46);
+    internal_static_sysml_DeleteEdit_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DeleteEdit_descriptor,
+        new java.lang.String[] { "Target", "Cascade", });
+    internal_static_sysml_MoveEdit_descriptor =
+      getDescriptor().getMessageType(47);
+    internal_static_sysml_MoveEdit_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_MoveEdit_descriptor,
+        new java.lang.String[] { "Target", "Owner", });
+    internal_static_sysml_SetValueEdit_descriptor =
+      getDescriptor().getMessageType(48);
+    internal_static_sysml_SetValueEdit_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_SetValueEdit_descriptor,
+        new java.lang.String[] { "Target", "Value", });
+    internal_static_sysml_RenameEdit_descriptor =
+      getDescriptor().getMessageType(49);
+    internal_static_sysml_RenameEdit_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RenameEdit_descriptor,
+        new java.lang.String[] { "Target", "NewName", });
+    internal_static_sysml_ApplyEditsResponse_descriptor =
+      getDescriptor().getMessageType(50);
+    internal_static_sysml_ApplyEditsResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ApplyEditsResponse_descriptor,
+        new java.lang.String[] { "Content", "Applied", "Error", "Failure", "Diagnostics", "ReferringElements", "Documents", "Referrers", });
+    internal_static_sysml_EditedDocument_descriptor =
+      getDescriptor().getMessageType(51);
+    internal_static_sysml_EditedDocument_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_EditedDocument_descriptor,
+        new java.lang.String[] { "Name", "Content", });
+    internal_static_sysml_Referrer_descriptor =
+      getDescriptor().getMessageType(52);
+    internal_static_sysml_Referrer_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Referrer_descriptor,
+        new java.lang.String[] { "Name", "Document", });
+    internal_static_sysml_AppliedEdit_descriptor =
+      getDescriptor().getMessageType(53);
+    internal_static_sysml_AppliedEdit_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_AppliedEdit_descriptor,
+        new java.lang.String[] { "OperationIndex", "Target", "Offset", "Length", "OldText", "NewText", "Document", });
+    internal_static_sysml_SymbolInfo_descriptor =
+      getDescriptor().getMessageType(54);
+    internal_static_sysml_SymbolInfo_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_SymbolInfo_descriptor,
+        new java.lang.String[] { "Id", "Name", "Kind", "Metadata", "ChildIds", "Attributes", "TypeInfo", "Multiplicity", "Specializations", "WithheldLibraryAttributes", });
+    internal_static_sysml_SymbolInfo_MetadataEntry_descriptor =
+      internal_static_sysml_SymbolInfo_descriptor.getNestedType(0);
+    internal_static_sysml_SymbolInfo_MetadataEntry_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_SymbolInfo_MetadataEntry_descriptor,
+        new java.lang.String[] { "Key", "Value", });
+    internal_static_sysml_Specialization_descriptor =
+      getDescriptor().getMessageType(55);
+    internal_static_sysml_Specialization_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Specialization_descriptor,
+        new java.lang.String[] { "Kind", "Declared", "TargetId", "TargetKind", });
+    internal_static_sysml_TypeInfo_descriptor =
+      getDescriptor().getMessageType(56);
+    internal_static_sysml_TypeInfo_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_TypeInfo_descriptor,
+        new java.lang.String[] { "Declared", "ResolvedId", "ResolvedKind", "Primitive", "PrimitiveSource", "Quantity", "Unit", });
+    internal_static_sysml_MultiplicityInfo_descriptor =
+      getDescriptor().getMessageType(57);
+    internal_static_sysml_MultiplicityInfo_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_MultiplicityInfo_descriptor,
+        new java.lang.String[] { "Lower", "Upper", });
+    internal_static_sysml_AttributeInfo_descriptor =
+      getDescriptor().getMessageType(58);
+    internal_static_sysml_AttributeInfo_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_AttributeInfo_descriptor,
+        new java.lang.String[] { "Name", "Type", "Value", "Unit", });
+    internal_static_sysml_Value_descriptor =
+      getDescriptor().getMessageType(59);
+    internal_static_sysml_Value_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Value_descriptor,
+        new java.lang.String[] { "IntValue", "RealValue", "BoolValue", "StringValue", "InstanceId", "Sequence", "Null", "Quantity", "EnumLiteral", "Unset", "Complex", "Array", "Vector", "VectorQuantity", "MeasurementRef", "Infinity", "Function", "Set", "TensorQuantity", "Metaobject", "Undetermined", "Kind", });
+    internal_static_sysml_Metaobject_descriptor =
+      getDescriptor().getMessageType(60);
+    internal_static_sysml_Metaobject_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Metaobject_descriptor,
+        new java.lang.String[] { "ElementId", "MetaclassId", });
+    internal_static_sysml_Undetermined_descriptor =
+      getDescriptor().getMessageType(61);
+    internal_static_sysml_Undetermined_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Undetermined_descriptor,
+        new java.lang.String[] { "Reason", "Count", });
+    internal_static_sysml_Function_descriptor =
+      getDescriptor().getMessageType(62);
+    internal_static_sysml_Function_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Function_descriptor,
+        new java.lang.String[] { "CalcId", "SelfId", });
+    internal_static_sysml_ValueSet_descriptor =
+      getDescriptor().getMessageType(63);
+    internal_static_sysml_ValueSet_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ValueSet_descriptor,
+        new java.lang.String[] { "Elements", });
+    internal_static_sysml_TensorQuantity_descriptor =
+      getDescriptor().getMessageType(64);
+    internal_static_sysml_TensorQuantity_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_TensorQuantity_descriptor,
+        new java.lang.String[] { "Dimensions", "Components", });
+    internal_static_sysml_Array_descriptor =
+      getDescriptor().getMessageType(65);
+    internal_static_sysml_Array_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Array_descriptor,
+        new java.lang.String[] { "Dimensions", "Elements", });
+    internal_static_sysml_Vector_descriptor =
+      getDescriptor().getMessageType(66);
+    internal_static_sysml_Vector_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Vector_descriptor,
+        new java.lang.String[] { "Components", });
+    internal_static_sysml_VectorQuantity_descriptor =
+      getDescriptor().getMessageType(67);
+    internal_static_sysml_VectorQuantity_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_VectorQuantity_descriptor,
+        new java.lang.String[] { "Components", });
+    internal_static_sysml_Complex_descriptor =
+      getDescriptor().getMessageType(68);
+    internal_static_sysml_Complex_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Complex_descriptor,
+        new java.lang.String[] { "Real", "Imaginary", });
+    internal_static_sysml_EnumLiteral_descriptor =
+      getDescriptor().getMessageType(69);
+    internal_static_sysml_EnumLiteral_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_EnumLiteral_descriptor,
+        new java.lang.String[] { "LiteralId", "EnumerationId", "Name", "Value", });
+    internal_static_sysml_ValueSequence_descriptor =
+      getDescriptor().getMessageType(70);
+    internal_static_sysml_ValueSequence_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ValueSequence_descriptor,
+        new java.lang.String[] { "Elements", });
+    internal_static_sysml_Quantity_descriptor =
+      getDescriptor().getMessageType(71);
+    internal_static_sysml_Quantity_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Quantity_descriptor,
+        new java.lang.String[] { "IntMagnitude", "RealMagnitude", "Unit", "UnitTerm", "Magnitude", });
+    internal_static_sysml_MeasurementRef_descriptor =
+      getDescriptor().getMessageType(72);
+    internal_static_sysml_MeasurementRef_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_MeasurementRef_descriptor,
+        new java.lang.String[] { "Unit", "UnitTerm", "UnitId", });
+    internal_static_sysml_UnitTerm_descriptor =
+      getDescriptor().getMessageType(73);
+    internal_static_sysml_UnitTerm_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_UnitTerm_descriptor,
+        new java.lang.String[] { "ScaleNum", "ScaleDen", "Factors", });
+    internal_static_sysml_UnitFactor_descriptor =
+      getDescriptor().getMessageType(74);
+    internal_static_sysml_UnitFactor_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_UnitFactor_descriptor,
+        new java.lang.String[] { "UnitId", "Exponent", });
+    internal_static_sysml_Diagnostic_descriptor =
+      getDescriptor().getMessageType(75);
+    internal_static_sysml_Diagnostic_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Diagnostic_descriptor,
+        new java.lang.String[] { "Severity", "Message", "Span", "Code", });
+    internal_static_sysml_Span_descriptor =
+      getDescriptor().getMessageType(76);
+    internal_static_sysml_Span_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Span_descriptor,
+        new java.lang.String[] { "File", "StartLine", "StartCol", "EndLine", "EndCol", });
+    internal_static_sysml_ServerInfoRequest_descriptor =
+      getDescriptor().getMessageType(77);
+    internal_static_sysml_ServerInfoRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ServerInfoRequest_descriptor,
+        new java.lang.String[] { });
+    internal_static_sysml_ServerInfoResponse_descriptor =
+      getDescriptor().getMessageType(78);
+    internal_static_sysml_ServerInfoResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_ServerInfoResponse_descriptor,
+        new java.lang.String[] { "Version", "Capabilities", });
+    internal_static_sysml_QueryRequest_descriptor =
+      getDescriptor().getMessageType(79);
+    internal_static_sysml_QueryRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_QueryRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "Query", "OslcQuery", });
+    internal_static_sysml_QueryResponse_descriptor =
+      getDescriptor().getMessageType(80);
+    internal_static_sysml_QueryResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_QueryResponse_descriptor,
+        new java.lang.String[] { "Elements", });
+    internal_static_sysml_Query_descriptor =
+      getDescriptor().getMessageType(81);
+    internal_static_sysml_Query_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Query_descriptor,
+        new java.lang.String[] { "Scope", "Select", "Where", });
+    internal_static_sysml_Constraint_descriptor =
+      getDescriptor().getMessageType(82);
+    internal_static_sysml_Constraint_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_Constraint_descriptor,
+        new java.lang.String[] { "Primitive", "Composite", "Constraint", });
+    internal_static_sysml_PrimitiveConstraint_descriptor =
+      getDescriptor().getMessageType(83);
+    internal_static_sysml_PrimitiveConstraint_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_PrimitiveConstraint_descriptor,
+        new java.lang.String[] { "Inverse", "Property", "Operator", "Value", });
+    internal_static_sysml_CompositeConstraint_descriptor =
+      getDescriptor().getMessageType(84);
+    internal_static_sysml_CompositeConstraint_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_CompositeConstraint_descriptor,
+        new java.lang.String[] { "Operator", "Constraint", });
+    internal_static_sysml_QueryResultElement_descriptor =
+      getDescriptor().getMessageType(85);
+    internal_static_sysml_QueryResultElement_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_QueryResultElement_descriptor,
+        new java.lang.String[] { "Id", "Type", "Properties", });
+    internal_static_sysml_QueryResultElement_PropertiesEntry_descriptor =
+      internal_static_sysml_QueryResultElement_descriptor.getNestedType(0);
+    internal_static_sysml_QueryResultElement_PropertiesEntry_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_QueryResultElement_PropertiesEntry_descriptor,
+        new java.lang.String[] { "Key", "Value", });
+    internal_static_sysml_SweepRange_descriptor =
+      getDescriptor().getMessageType(86);
+    internal_static_sysml_SweepRange_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_SweepRange_descriptor,
+        new java.lang.String[] { "Parameter", "Start", "End", "Step", });
+    internal_static_sysml_RunSweepRequest_descriptor =
+      getDescriptor().getMessageType(87);
+    internal_static_sysml_RunSweepRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RunSweepRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "SymbolId", "SubjectSymbolId", "Arguments", "NamedArguments", "Ranges", "Samples", "Seed", "Engine", });
+    internal_static_sysml_RunSweepRequest_NamedArgumentsEntry_descriptor =
+      internal_static_sysml_RunSweepRequest_descriptor.getNestedType(0);
+    internal_static_sysml_RunSweepRequest_NamedArgumentsEntry_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RunSweepRequest_NamedArgumentsEntry_descriptor,
+        new java.lang.String[] { "Key", "Value", });
+    internal_static_sysml_SweepRow_descriptor =
+      getDescriptor().getMessageType(88);
+    internal_static_sysml_SweepRow_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_SweepRow_descriptor,
+        new java.lang.String[] { "Inputs", "Outputs", "Verdicts", "ElapsedMicros", "Error", "FailureReason", "Evaluations", });
+    internal_static_sysml_RunSweepResponse_descriptor =
+      getDescriptor().getMessageType(89);
+    internal_static_sysml_RunSweepResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RunSweepResponse_descriptor,
+        new java.lang.String[] { "Rows", "Parameters", "Sampled", "Seed", "Error", "Diagnostics", "FailureReason", "Instances", "Engine", "Strength", "Bounds", });
+    internal_static_sysml_RunDocumentQueryRequest_descriptor =
+      getDescriptor().getMessageType(90);
+    internal_static_sysml_RunDocumentQueryRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RunDocumentQueryRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "QueryId", "Bindings", });
+    internal_static_sysml_DocumentQueryBinding_descriptor =
+      getDescriptor().getMessageType(91);
+    internal_static_sysml_DocumentQueryBinding_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DocumentQueryBinding_descriptor,
+        new java.lang.String[] { "Parameter", "Values", });
+    internal_static_sysml_DocumentValue_descriptor =
+      getDescriptor().getMessageType(92);
+    internal_static_sysml_DocumentValue_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DocumentValue_descriptor,
+        new java.lang.String[] { "ElementId", "StringValue", "IntValue", "RealValue", "BoolValue", "Infinity", "Quantity", "Verdict", "Object", "State", "Event", "ElementType", "Kind", });
+    internal_static_sysml_DocumentObject_descriptor =
+      getDescriptor().getMessageType(93);
+    internal_static_sysml_DocumentObject_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DocumentObject_descriptor,
+        new java.lang.String[] { "InstanceId", "Path", "Element", });
+    internal_static_sysml_DocumentVerdict_descriptor =
+      getDescriptor().getMessageType(94);
+    internal_static_sysml_DocumentVerdict_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DocumentVerdict_descriptor,
+        new java.lang.String[] { "Assertion", "Kind", "Text", "Path", "Verdict", "Condition", "Reason", "Verification", });
+    internal_static_sysml_DocumentState_descriptor =
+      getDescriptor().getMessageType(95);
+    internal_static_sysml_DocumentState_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DocumentState_descriptor,
+        new java.lang.String[] { "Object", "Machine", "Name", "StatePath", "State", "Region", "Enclosing", });
+    internal_static_sysml_DocumentEvent_descriptor =
+      getDescriptor().getMessageType(96);
+    internal_static_sysml_DocumentEvent_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DocumentEvent_descriptor,
+        new java.lang.String[] { "Kind", "Time", "Object", "Machine", "State", "From", "To", "Target", "Event", "Payload", "Alternatives", "Taken", "Text", });
+    internal_static_sysml_DocumentQueryColumn_descriptor =
+      getDescriptor().getMessageType(97);
+    internal_static_sysml_DocumentQueryColumn_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DocumentQueryColumn_descriptor,
+        new java.lang.String[] { "Name", });
+    internal_static_sysml_DocumentQueryCell_descriptor =
+      getDescriptor().getMessageType(98);
+    internal_static_sysml_DocumentQueryCell_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DocumentQueryCell_descriptor,
+        new java.lang.String[] { "Values", });
+    internal_static_sysml_DocumentQueryRow_descriptor =
+      getDescriptor().getMessageType(99);
+    internal_static_sysml_DocumentQueryRow_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_DocumentQueryRow_descriptor,
+        new java.lang.String[] { "Element", "Cells", });
+    internal_static_sysml_RunDocumentQueryResponse_descriptor =
+      getDescriptor().getMessageType(100);
+    internal_static_sysml_RunDocumentQueryResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RunDocumentQueryResponse_descriptor,
+        new java.lang.String[] { "Columns", "Rows", });
+    internal_static_sysml_RenderDocumentRequest_descriptor =
+      getDescriptor().getMessageType(101);
+    internal_static_sysml_RenderDocumentRequest_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RenderDocumentRequest_descriptor,
+        new java.lang.String[] { "ModelHash", "DocumentId", "Form", });
+    internal_static_sysml_RenderDocumentResponse_descriptor =
+      getDescriptor().getMessageType(102);
+    internal_static_sysml_RenderDocumentResponse_fieldAccessorTable = new
+      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
+        internal_static_sysml_RenderDocumentResponse_descriptor,
+        new java.lang.String[] { "Markdown", "Html", });
+    descriptor.resolveAllFeaturesImmutable();
+  }
+
+  // @@protoc_insertion_point(outer_class_scope)
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TensorQuantity.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TensorQuantity.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TensorQuantity.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TensorQuantity.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TensorQuantityOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TensorQuantityOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TensorQuantityOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TensorQuantityOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TypeInfo.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TypeInfo.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TypeInfo.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TypeInfo.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TypeInfoOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TypeInfoOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TypeInfoOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/TypeInfoOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Undetermined.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Undetermined.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Undetermined.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Undetermined.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UndeterminedOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UndeterminedOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UndeterminedOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UndeterminedOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitFactor.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitFactor.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitFactor.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitFactor.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitFactorOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitFactorOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitFactorOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitFactorOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitTerm.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitTerm.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitTerm.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitTerm.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitTermOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitTermOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitTermOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/UnitTermOrBuilder.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceRequest.java
new file mode 100644
index 0000000000..e1c3ca3705
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceRequest.java
@@ -0,0 +1,840 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * ValidateInstanceRequest asks for every assertion about an object of a part,
+ * as %validate does: the object is built for the call.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.ValidateInstanceRequest}
+ */
+@com.google.protobuf.Generated
+public final class ValidateInstanceRequest extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.ValidateInstanceRequest)
+    ValidateInstanceRequestOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "ValidateInstanceRequest");
+  }
+  // Use ValidateInstanceRequest.newBuilder() to construct.
+  private ValidateInstanceRequest(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private ValidateInstanceRequest() {
+    modelHash_ = "";
+    symbolId_ = "";
+    engine_ = "";
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ValidateInstanceRequest_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ValidateInstanceRequest_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.ValidateInstanceRequest.class, org.openmbee.opensysml.proto.ValidateInstanceRequest.Builder.class);
+  }
+
+  public static final int MODEL_HASH_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object modelHash_ = "";
+  /**
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The modelHash.
+   */
+  @java.lang.Override
+  public java.lang.String getModelHash() {
+    java.lang.Object ref = modelHash_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      modelHash_ = s;
+      return s;
+    }
+  }
+  /**
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The bytes for modelHash.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getModelHashBytes() {
+    java.lang.Object ref = modelHash_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      modelHash_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int SYMBOL_ID_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object symbolId_ = "";
+  /**
+   * <pre>
+   * FQN of the part or usage an object of which is validated.
+   * </pre>
+   *
+   * <code>string symbol_id = 2 [json_name = "symbolId"];</code>
+   * @return The symbolId.
+   */
+  @java.lang.Override
+  public java.lang.String getSymbolId() {
+    java.lang.Object ref = symbolId_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      symbolId_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * FQN of the part or usage an object of which is validated.
+   * </pre>
+   *
+   * <code>string symbol_id = 2 [json_name = "symbolId"];</code>
+   * @return The bytes for symbolId.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getSymbolIdBytes() {
+    java.lang.Object ref = symbolId_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      symbolId_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int ENGINE_FIELD_NUMBER = 3;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object engine_ = "";
+  /**
+   * <pre>
+   * The engine the validation is put to; see VerifyConstraintRequest.engine.
+   * </pre>
+   *
+   * <code>string engine = 3 [json_name = "engine"];</code>
+   * @return The engine.
+   */
+  @java.lang.Override
+  public java.lang.String getEngine() {
+    java.lang.Object ref = engine_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      engine_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The engine the validation is put to; see VerifyConstraintRequest.engine.
+   * </pre>
+   *
+   * <code>string engine = 3 [json_name = "engine"];</code>
+   * @return The bytes for engine.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getEngineBytes() {
+    java.lang.Object ref = engine_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      engine_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(modelHash_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 1, modelHash_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(symbolId_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, symbolId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(engine_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 3, engine_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(modelHash_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, modelHash_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(symbolId_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, symbolId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(engine_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(3, engine_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.ValidateInstanceRequest)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.ValidateInstanceRequest other = (org.openmbee.opensysml.proto.ValidateInstanceRequest) obj;
+
+    if (!getModelHash()
+        .equals(other.getModelHash())) return false;
+    if (!getSymbolId()
+        .equals(other.getSymbolId())) return false;
+    if (!getEngine()
+        .equals(other.getEngine())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + MODEL_HASH_FIELD_NUMBER;
+    hash = (53 * hash) + getModelHash().hashCode();
+    hash = (37 * hash) + SYMBOL_ID_FIELD_NUMBER;
+    hash = (53 * hash) + getSymbolId().hashCode();
+    hash = (37 * hash) + ENGINE_FIELD_NUMBER;
+    hash = (53 * hash) + getEngine().hashCode();
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.ValidateInstanceRequest prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * ValidateInstanceRequest asks for every assertion about an object of a part,
+   * as %validate does: the object is built for the call.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.ValidateInstanceRequest}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.ValidateInstanceRequest)
+      org.openmbee.opensysml.proto.ValidateInstanceRequestOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ValidateInstanceRequest_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ValidateInstanceRequest_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.ValidateInstanceRequest.class, org.openmbee.opensysml.proto.ValidateInstanceRequest.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.ValidateInstanceRequest.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      modelHash_ = "";
+      symbolId_ = "";
+      engine_ = "";
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ValidateInstanceRequest_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ValidateInstanceRequest getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.ValidateInstanceRequest.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ValidateInstanceRequest build() {
+      org.openmbee.opensysml.proto.ValidateInstanceRequest result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ValidateInstanceRequest buildPartial() {
+      org.openmbee.opensysml.proto.ValidateInstanceRequest result = new org.openmbee.opensysml.proto.ValidateInstanceRequest(this);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.ValidateInstanceRequest result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.modelHash_ = modelHash_;
+      }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.symbolId_ = symbolId_;
+      }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.engine_ = engine_;
+      }
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.ValidateInstanceRequest) {
+        return mergeFrom((org.openmbee.opensysml.proto.ValidateInstanceRequest)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.ValidateInstanceRequest other) {
+      if (other == org.openmbee.opensysml.proto.ValidateInstanceRequest.getDefaultInstance()) return this;
+      if (!other.getModelHash().isEmpty()) {
+        modelHash_ = other.modelHash_;
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      if (!other.getSymbolId().isEmpty()) {
+        symbolId_ = other.symbolId_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      if (!other.getEngine().isEmpty()) {
+        engine_ = other.engine_;
+        bitField0_ |= 0x00000004;
+        onChanged();
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              modelHash_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              symbolId_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            case 26: {
+              engine_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 26
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.lang.Object modelHash_ = "";
+    /**
+     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+     * @return The modelHash.
+     */
+    public java.lang.String getModelHash() {
+      java.lang.Object ref = modelHash_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        modelHash_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+     * @return The bytes for modelHash.
+     */
+    public com.google.protobuf.ByteString
+        getModelHashBytes() {
+      java.lang.Object ref = modelHash_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        modelHash_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+     * @param value The modelHash to set.
+     * @return This builder for chaining.
+     */
+    public Builder setModelHash(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      modelHash_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearModelHash() {
+      modelHash_ = getDefaultInstance().getModelHash();
+      bitField0_ = (bitField0_ & ~0x00000001);
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+     * @param value The bytes for modelHash to set.
+     * @return This builder for chaining.
+     */
+    public Builder setModelHashBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      modelHash_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object symbolId_ = "";
+    /**
+     * <pre>
+     * FQN of the part or usage an object of which is validated.
+     * </pre>
+     *
+     * <code>string symbol_id = 2 [json_name = "symbolId"];</code>
+     * @return The symbolId.
+     */
+    public java.lang.String getSymbolId() {
+      java.lang.Object ref = symbolId_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        symbolId_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * FQN of the part or usage an object of which is validated.
+     * </pre>
+     *
+     * <code>string symbol_id = 2 [json_name = "symbolId"];</code>
+     * @return The bytes for symbolId.
+     */
+    public com.google.protobuf.ByteString
+        getSymbolIdBytes() {
+      java.lang.Object ref = symbolId_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        symbolId_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * FQN of the part or usage an object of which is validated.
+     * </pre>
+     *
+     * <code>string symbol_id = 2 [json_name = "symbolId"];</code>
+     * @param value The symbolId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setSymbolId(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      symbolId_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * FQN of the part or usage an object of which is validated.
+     * </pre>
+     *
+     * <code>string symbol_id = 2 [json_name = "symbolId"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearSymbolId() {
+      symbolId_ = getDefaultInstance().getSymbolId();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * FQN of the part or usage an object of which is validated.
+     * </pre>
+     *
+     * <code>string symbol_id = 2 [json_name = "symbolId"];</code>
+     * @param value The bytes for symbolId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setSymbolIdBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      symbolId_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object engine_ = "";
+    /**
+     * <pre>
+     * The engine the validation is put to; see VerifyConstraintRequest.engine.
+     * </pre>
+     *
+     * <code>string engine = 3 [json_name = "engine"];</code>
+     * @return The engine.
+     */
+    public java.lang.String getEngine() {
+      java.lang.Object ref = engine_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        engine_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The engine the validation is put to; see VerifyConstraintRequest.engine.
+     * </pre>
+     *
+     * <code>string engine = 3 [json_name = "engine"];</code>
+     * @return The bytes for engine.
+     */
+    public com.google.protobuf.ByteString
+        getEngineBytes() {
+      java.lang.Object ref = engine_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        engine_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The engine the validation is put to; see VerifyConstraintRequest.engine.
+     * </pre>
+     *
+     * <code>string engine = 3 [json_name = "engine"];</code>
+     * @param value The engine to set.
+     * @return This builder for chaining.
+     */
+    public Builder setEngine(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      engine_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The engine the validation is put to; see VerifyConstraintRequest.engine.
+     * </pre>
+     *
+     * <code>string engine = 3 [json_name = "engine"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearEngine() {
+      engine_ = getDefaultInstance().getEngine();
+      bitField0_ = (bitField0_ & ~0x00000004);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The engine the validation is put to; see VerifyConstraintRequest.engine.
+     * </pre>
+     *
+     * <code>string engine = 3 [json_name = "engine"];</code>
+     * @param value The bytes for engine to set.
+     * @return This builder for chaining.
+     */
+    public Builder setEngineBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      engine_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.ValidateInstanceRequest)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.ValidateInstanceRequest)
+  private static final org.openmbee.opensysml.proto.ValidateInstanceRequest DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.ValidateInstanceRequest();
+  }
+
+  public static org.openmbee.opensysml.proto.ValidateInstanceRequest getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<ValidateInstanceRequest>
+      PARSER = new com.google.protobuf.AbstractParser<ValidateInstanceRequest>() {
+    @java.lang.Override
+    public ValidateInstanceRequest parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<ValidateInstanceRequest> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<ValidateInstanceRequest> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.ValidateInstanceRequest getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceRequestOrBuilder.java
new file mode 100644
index 0000000000..364adb058b
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceRequestOrBuilder.java
@@ -0,0 +1,64 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface ValidateInstanceRequestOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.ValidateInstanceRequest)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The modelHash.
+   */
+  java.lang.String getModelHash();
+  /**
+   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
+   * @return The bytes for modelHash.
+   */
+  com.google.protobuf.ByteString
+      getModelHashBytes();
+
+  /**
+   * <pre>
+   * FQN of the part or usage an object of which is validated.
+   * </pre>
+   *
+   * <code>string symbol_id = 2 [json_name = "symbolId"];</code>
+   * @return The symbolId.
+   */
+  java.lang.String getSymbolId();
+  /**
+   * <pre>
+   * FQN of the part or usage an object of which is validated.
+   * </pre>
+   *
+   * <code>string symbol_id = 2 [json_name = "symbolId"];</code>
+   * @return The bytes for symbolId.
+   */
+  com.google.protobuf.ByteString
+      getSymbolIdBytes();
+
+  /**
+   * <pre>
+   * The engine the validation is put to; see VerifyConstraintRequest.engine.
+   * </pre>
+   *
+   * <code>string engine = 3 [json_name = "engine"];</code>
+   * @return The engine.
+   */
+  java.lang.String getEngine();
+  /**
+   * <pre>
+   * The engine the validation is put to; see VerifyConstraintRequest.engine.
+   * </pre>
+   *
+   * <code>string engine = 3 [json_name = "engine"];</code>
+   * @return The bytes for engine.
+   */
+  com.google.protobuf.ByteString
+      getEngineBytes();
+}
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceResponse.java
new file mode 100644
index 0000000000..f3366089e1
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceResponse.java
@@ -0,0 +1,2776 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * ValidateInstanceResponse carries one verdict per assertion, in the order the
+ * objects were reached from the validated one, and the verdict about the object
+ * as a whole. An object no assertion is about has an empty list and a summary
+ * that decides nothing.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.ValidateInstanceResponse}
+ */
+@com.google.protobuf.Generated
+public final class ValidateInstanceResponse extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.ValidateInstanceResponse)
+    ValidateInstanceResponseOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "ValidateInstanceResponse");
+  }
+  // Use ValidateInstanceResponse.newBuilder() to construct.
+  private ValidateInstanceResponse(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private ValidateInstanceResponse() {
+    verdicts_ = java.util.Collections.emptyList();
+    instances_ = java.util.Collections.emptyList();
+    error_ = "";
+    diagnostics_ = java.util.Collections.emptyList();
+    failureReason_ = 0;
+    verificationVerdicts_ = java.util.Collections.emptyList();
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ValidateInstanceResponse_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ValidateInstanceResponse_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.ValidateInstanceResponse.class, org.openmbee.opensysml.proto.ValidateInstanceResponse.Builder.class);
+  }
+
+  private int bitField0_;
+  public static final int VERDICTS_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.Verdict> verdicts_;
+  /**
+   * <pre>
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   * </pre>
+   *
+   * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.Verdict> getVerdictsList() {
+    return verdicts_;
+  }
+  /**
+   * <pre>
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   * </pre>
+   *
+   * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.VerdictOrBuilder> 
+      getVerdictsOrBuilderList() {
+    return verdicts_;
+  }
+  /**
+   * <pre>
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   * </pre>
+   *
+   * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+   */
+  @java.lang.Override
+  public int getVerdictsCount() {
+    return verdicts_.size();
+  }
+  /**
+   * <pre>
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   * </pre>
+   *
+   * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Verdict getVerdicts(int index) {
+    return verdicts_.get(index);
+  }
+  /**
+   * <pre>
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   * </pre>
+   *
+   * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.VerdictOrBuilder getVerdictsOrBuilder(
+      int index) {
+    return verdicts_.get(index);
+  }
+
+  public static final int SUMMARY_FIELD_NUMBER = 2;
+  private org.openmbee.opensysml.proto.Verdict summary_;
+  /**
+   * <pre>
+   * The object as a whole, kind "object": holds when every assertion holds and
+   * every held object was reached. With `error` empty, false is the model's
+   * answer, some assertion failing; with `error` set, nothing was decided, an
+   * assertion being undecided or nesting left unreached.
+   * </pre>
+   *
+   * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+   * @return Whether the summary field is set.
+   */
+  @java.lang.Override
+  public boolean hasSummary() {
+    return ((bitField0_ & 0x00000001) != 0);
+  }
+  /**
+   * <pre>
+   * The object as a whole, kind "object": holds when every assertion holds and
+   * every held object was reached. With `error` empty, false is the model's
+   * answer, some assertion failing; with `error` set, nothing was decided, an
+   * assertion being undecided or nesting left unreached.
+   * </pre>
+   *
+   * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+   * @return The summary.
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Verdict getSummary() {
+    return summary_ == null ? org.openmbee.opensysml.proto.Verdict.getDefaultInstance() : summary_;
+  }
+  /**
+   * <pre>
+   * The object as a whole, kind "object": holds when every assertion holds and
+   * every held object was reached. With `error` empty, false is the model's
+   * answer, some assertion failing; with `error` set, nothing was decided, an
+   * assertion being undecided or nesting left unreached.
+   * </pre>
+   *
+   * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.VerdictOrBuilder getSummaryOrBuilder() {
+    return summary_ == null ? org.openmbee.opensysml.proto.Verdict.getDefaultInstance() : summary_;
+  }
+
+  public static final int INSTANCES_FIELD_NUMBER = 3;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.Instance> instances_;
+  /**
+   * <pre>
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   * </pre>
+   *
+   * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.Instance> getInstancesList() {
+    return instances_;
+  }
+  /**
+   * <pre>
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   * </pre>
+   *
+   * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.InstanceOrBuilder> 
+      getInstancesOrBuilderList() {
+    return instances_;
+  }
+  /**
+   * <pre>
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   * </pre>
+   *
+   * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+   */
+  @java.lang.Override
+  public int getInstancesCount() {
+    return instances_.size();
+  }
+  /**
+   * <pre>
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   * </pre>
+   *
+   * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Instance getInstances(int index) {
+    return instances_.get(index);
+  }
+  /**
+   * <pre>
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   * </pre>
+   *
+   * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.InstanceOrBuilder getInstancesOrBuilder(
+      int index) {
+    return instances_.get(index);
+  }
+
+  public static final int ERROR_FIELD_NUMBER = 4;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object error_ = "";
+  /**
+   * <code>string error = 4 [json_name = "error"];</code>
+   * @return The error.
+   */
+  @java.lang.Override
+  public java.lang.String getError() {
+    java.lang.Object ref = error_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      error_ = s;
+      return s;
+    }
+  }
+  /**
+   * <code>string error = 4 [json_name = "error"];</code>
+   * @return The bytes for error.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getErrorBytes() {
+    java.lang.Object ref = error_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      error_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int DIAGNOSTICS_FIELD_NUMBER = 5;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.Diagnostic> diagnostics_;
+  /**
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.Diagnostic> getDiagnosticsList() {
+    return diagnostics_;
+  }
+  /**
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+      getDiagnosticsOrBuilderList() {
+    return diagnostics_;
+  }
+  /**
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public int getDiagnosticsCount() {
+    return diagnostics_.size();
+  }
+  /**
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index) {
+    return diagnostics_.get(index);
+  }
+  /**
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
+      int index) {
+    return diagnostics_.get(index);
+  }
+
+  public static final int FAILURE_REASON_FIELD_NUMBER = 6;
+  private int failureReason_ = 0;
+  /**
+   * <pre>
+   * What kind of failure `error` reports.
+   * </pre>
+   *
+   * <code>.sysml.FailureReason failure_reason = 6 [json_name = "failureReason"];</code>
+   * @return The enum numeric value on the wire for failureReason.
+   */
+  @java.lang.Override public int getFailureReasonValue() {
+    return failureReason_;
+  }
+  /**
+   * <pre>
+   * What kind of failure `error` reports.
+   * </pre>
+   *
+   * <code>.sysml.FailureReason failure_reason = 6 [json_name = "failureReason"];</code>
+   * @return The failureReason.
+   */
+  @java.lang.Override public org.openmbee.opensysml.proto.FailureReason getFailureReason() {
+    org.openmbee.opensysml.proto.FailureReason result = org.openmbee.opensysml.proto.FailureReason.forNumber(failureReason_);
+    return result == null ? org.openmbee.opensysml.proto.FailureReason.UNRECOGNIZED : result;
+  }
+
+  public static final int VERIFICATION_VERDICTS_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.VerificationVerdict> verificationVerdicts_;
+  /**
+   * <pre>
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   * </pre>
+   *
+   * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.VerificationVerdict> getVerificationVerdictsList() {
+    return verificationVerdicts_;
+  }
+  /**
+   * <pre>
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   * </pre>
+   *
+   * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.VerificationVerdictOrBuilder> 
+      getVerificationVerdictsOrBuilderList() {
+    return verificationVerdicts_;
+  }
+  /**
+   * <pre>
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   * </pre>
+   *
+   * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+   */
+  @java.lang.Override
+  public int getVerificationVerdictsCount() {
+    return verificationVerdicts_.size();
+  }
+  /**
+   * <pre>
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   * </pre>
+   *
+   * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.VerificationVerdict getVerificationVerdicts(int index) {
+    return verificationVerdicts_.get(index);
+  }
+  /**
+   * <pre>
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   * </pre>
+   *
+   * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.VerificationVerdictOrBuilder getVerificationVerdictsOrBuilder(
+      int index) {
+    return verificationVerdicts_.get(index);
+  }
+
+  public static final int BOUNDED_FIELD_NUMBER = 8;
+  private boolean bounded_ = false;
+  /**
+   * <pre>
+   * True when nesting deeper than the validation descends, or past its budget,
+   * was left unvalidated; the summary then decides nothing.
+   * </pre>
+   *
+   * <code>bool bounded = 8 [json_name = "bounded"];</code>
+   * @return The bounded.
+   */
+  @java.lang.Override
+  public boolean getBounded() {
+    return bounded_;
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    for (int i = 0; i < verdicts_.size(); i++) {
+      output.writeMessage(1, verdicts_.get(i));
+    }
+    if (((bitField0_ & 0x00000001) != 0)) {
+      output.writeMessage(2, getSummary());
+    }
+    for (int i = 0; i < instances_.size(); i++) {
+      output.writeMessage(3, instances_.get(i));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 4, error_);
+    }
+    for (int i = 0; i < diagnostics_.size(); i++) {
+      output.writeMessage(5, diagnostics_.get(i));
+    }
+    if (failureReason_ != org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_UNSPECIFIED.getNumber()) {
+      output.writeEnum(6, failureReason_);
+    }
+    for (int i = 0; i < verificationVerdicts_.size(); i++) {
+      output.writeMessage(7, verificationVerdicts_.get(i));
+    }
+    if (bounded_ != false) {
+      output.writeBool(8, bounded_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    for (int i = 0; i < verdicts_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(1, verdicts_.get(i));
+    }
+    if (((bitField0_ & 0x00000001) != 0)) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(2, getSummary());
+    }
+    for (int i = 0; i < instances_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(3, instances_.get(i));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(4, error_);
+    }
+    for (int i = 0; i < diagnostics_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(5, diagnostics_.get(i));
+    }
+    if (failureReason_ != org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_UNSPECIFIED.getNumber()) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeEnumSize(6, failureReason_);
+    }
+    for (int i = 0; i < verificationVerdicts_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(7, verificationVerdicts_.get(i));
+    }
+    if (bounded_ != false) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeBoolSize(8, bounded_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.ValidateInstanceResponse)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.ValidateInstanceResponse other = (org.openmbee.opensysml.proto.ValidateInstanceResponse) obj;
+
+    if (!getVerdictsList()
+        .equals(other.getVerdictsList())) return false;
+    if (hasSummary() != other.hasSummary()) return false;
+    if (hasSummary()) {
+      if (!getSummary()
+          .equals(other.getSummary())) return false;
+    }
+    if (!getInstancesList()
+        .equals(other.getInstancesList())) return false;
+    if (!getError()
+        .equals(other.getError())) return false;
+    if (!getDiagnosticsList()
+        .equals(other.getDiagnosticsList())) return false;
+    if (failureReason_ != other.failureReason_) return false;
+    if (!getVerificationVerdictsList()
+        .equals(other.getVerificationVerdictsList())) return false;
+    if (getBounded()
+        != other.getBounded()) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    if (getVerdictsCount() > 0) {
+      hash = (37 * hash) + VERDICTS_FIELD_NUMBER;
+      hash = (53 * hash) + getVerdictsList().hashCode();
+    }
+    if (hasSummary()) {
+      hash = (37 * hash) + SUMMARY_FIELD_NUMBER;
+      hash = (53 * hash) + getSummary().hashCode();
+    }
+    if (getInstancesCount() > 0) {
+      hash = (37 * hash) + INSTANCES_FIELD_NUMBER;
+      hash = (53 * hash) + getInstancesList().hashCode();
+    }
+    hash = (37 * hash) + ERROR_FIELD_NUMBER;
+    hash = (53 * hash) + getError().hashCode();
+    if (getDiagnosticsCount() > 0) {
+      hash = (37 * hash) + DIAGNOSTICS_FIELD_NUMBER;
+      hash = (53 * hash) + getDiagnosticsList().hashCode();
+    }
+    hash = (37 * hash) + FAILURE_REASON_FIELD_NUMBER;
+    hash = (53 * hash) + failureReason_;
+    if (getVerificationVerdictsCount() > 0) {
+      hash = (37 * hash) + VERIFICATION_VERDICTS_FIELD_NUMBER;
+      hash = (53 * hash) + getVerificationVerdictsList().hashCode();
+    }
+    hash = (37 * hash) + BOUNDED_FIELD_NUMBER;
+    hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean(
+        getBounded());
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.ValidateInstanceResponse prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * ValidateInstanceResponse carries one verdict per assertion, in the order the
+   * objects were reached from the validated one, and the verdict about the object
+   * as a whole. An object no assertion is about has an empty list and a summary
+   * that decides nothing.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.ValidateInstanceResponse}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.ValidateInstanceResponse)
+      org.openmbee.opensysml.proto.ValidateInstanceResponseOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ValidateInstanceResponse_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ValidateInstanceResponse_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.ValidateInstanceResponse.class, org.openmbee.opensysml.proto.ValidateInstanceResponse.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.ValidateInstanceResponse.newBuilder()
+    private Builder() {
+      maybeForceBuilderInitialization();
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+      maybeForceBuilderInitialization();
+    }
+    private void maybeForceBuilderInitialization() {
+      if (com.google.protobuf.GeneratedMessage
+              .alwaysUseFieldBuilders) {
+        internalGetVerdictsFieldBuilder();
+        internalGetSummaryFieldBuilder();
+        internalGetInstancesFieldBuilder();
+        internalGetDiagnosticsFieldBuilder();
+        internalGetVerificationVerdictsFieldBuilder();
+      }
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      if (verdictsBuilder_ == null) {
+        verdicts_ = java.util.Collections.emptyList();
+      } else {
+        verdicts_ = null;
+        verdictsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000001);
+      summary_ = null;
+      if (summaryBuilder_ != null) {
+        summaryBuilder_.dispose();
+        summaryBuilder_ = null;
+      }
+      if (instancesBuilder_ == null) {
+        instances_ = java.util.Collections.emptyList();
+      } else {
+        instances_ = null;
+        instancesBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000004);
+      error_ = "";
+      if (diagnosticsBuilder_ == null) {
+        diagnostics_ = java.util.Collections.emptyList();
+      } else {
+        diagnostics_ = null;
+        diagnosticsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000010);
+      failureReason_ = 0;
+      if (verificationVerdictsBuilder_ == null) {
+        verificationVerdicts_ = java.util.Collections.emptyList();
+      } else {
+        verificationVerdicts_ = null;
+        verificationVerdictsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000040);
+      bounded_ = false;
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ValidateInstanceResponse_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ValidateInstanceResponse getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.ValidateInstanceResponse.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ValidateInstanceResponse build() {
+      org.openmbee.opensysml.proto.ValidateInstanceResponse result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.ValidateInstanceResponse buildPartial() {
+      org.openmbee.opensysml.proto.ValidateInstanceResponse result = new org.openmbee.opensysml.proto.ValidateInstanceResponse(this);
+      buildPartialRepeatedFields(result);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartialRepeatedFields(org.openmbee.opensysml.proto.ValidateInstanceResponse result) {
+      if (verdictsBuilder_ == null) {
+        if (((bitField0_ & 0x00000001) != 0)) {
+          verdicts_ = java.util.Collections.unmodifiableList(verdicts_);
+          bitField0_ = (bitField0_ & ~0x00000001);
+        }
+        result.verdicts_ = verdicts_;
+      } else {
+        result.verdicts_ = verdictsBuilder_.build();
+      }
+      if (instancesBuilder_ == null) {
+        if (((bitField0_ & 0x00000004) != 0)) {
+          instances_ = java.util.Collections.unmodifiableList(instances_);
+          bitField0_ = (bitField0_ & ~0x00000004);
+        }
+        result.instances_ = instances_;
+      } else {
+        result.instances_ = instancesBuilder_.build();
+      }
+      if (diagnosticsBuilder_ == null) {
+        if (((bitField0_ & 0x00000010) != 0)) {
+          diagnostics_ = java.util.Collections.unmodifiableList(diagnostics_);
+          bitField0_ = (bitField0_ & ~0x00000010);
+        }
+        result.diagnostics_ = diagnostics_;
+      } else {
+        result.diagnostics_ = diagnosticsBuilder_.build();
+      }
+      if (verificationVerdictsBuilder_ == null) {
+        if (((bitField0_ & 0x00000040) != 0)) {
+          verificationVerdicts_ = java.util.Collections.unmodifiableList(verificationVerdicts_);
+          bitField0_ = (bitField0_ & ~0x00000040);
+        }
+        result.verificationVerdicts_ = verificationVerdicts_;
+      } else {
+        result.verificationVerdicts_ = verificationVerdictsBuilder_.build();
+      }
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.ValidateInstanceResponse result) {
+      int from_bitField0_ = bitField0_;
+      int to_bitField0_ = 0;
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.summary_ = summaryBuilder_ == null
+            ? summary_
+            : summaryBuilder_.build();
+        to_bitField0_ |= 0x00000001;
+      }
+      if (((from_bitField0_ & 0x00000008) != 0)) {
+        result.error_ = error_;
+      }
+      if (((from_bitField0_ & 0x00000020) != 0)) {
+        result.failureReason_ = failureReason_;
+      }
+      if (((from_bitField0_ & 0x00000080) != 0)) {
+        result.bounded_ = bounded_;
+      }
+      result.bitField0_ |= to_bitField0_;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.ValidateInstanceResponse) {
+        return mergeFrom((org.openmbee.opensysml.proto.ValidateInstanceResponse)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.ValidateInstanceResponse other) {
+      if (other == org.openmbee.opensysml.proto.ValidateInstanceResponse.getDefaultInstance()) return this;
+      if (verdictsBuilder_ == null) {
+        if (!other.verdicts_.isEmpty()) {
+          if (verdicts_.isEmpty()) {
+            verdicts_ = other.verdicts_;
+            bitField0_ = (bitField0_ & ~0x00000001);
+          } else {
+            ensureVerdictsIsMutable();
+            verdicts_.addAll(other.verdicts_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.verdicts_.isEmpty()) {
+          if (verdictsBuilder_.isEmpty()) {
+            verdictsBuilder_.dispose();
+            verdictsBuilder_ = null;
+            verdicts_ = other.verdicts_;
+            bitField0_ = (bitField0_ & ~0x00000001);
+            verdictsBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetVerdictsFieldBuilder() : null;
+          } else {
+            verdictsBuilder_.addAllMessages(other.verdicts_);
+          }
+        }
+      }
+      if (other.hasSummary()) {
+        mergeSummary(other.getSummary());
+      }
+      if (instancesBuilder_ == null) {
+        if (!other.instances_.isEmpty()) {
+          if (instances_.isEmpty()) {
+            instances_ = other.instances_;
+            bitField0_ = (bitField0_ & ~0x00000004);
+          } else {
+            ensureInstancesIsMutable();
+            instances_.addAll(other.instances_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.instances_.isEmpty()) {
+          if (instancesBuilder_.isEmpty()) {
+            instancesBuilder_.dispose();
+            instancesBuilder_ = null;
+            instances_ = other.instances_;
+            bitField0_ = (bitField0_ & ~0x00000004);
+            instancesBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetInstancesFieldBuilder() : null;
+          } else {
+            instancesBuilder_.addAllMessages(other.instances_);
+          }
+        }
+      }
+      if (!other.getError().isEmpty()) {
+        error_ = other.error_;
+        bitField0_ |= 0x00000008;
+        onChanged();
+      }
+      if (diagnosticsBuilder_ == null) {
+        if (!other.diagnostics_.isEmpty()) {
+          if (diagnostics_.isEmpty()) {
+            diagnostics_ = other.diagnostics_;
+            bitField0_ = (bitField0_ & ~0x00000010);
+          } else {
+            ensureDiagnosticsIsMutable();
+            diagnostics_.addAll(other.diagnostics_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.diagnostics_.isEmpty()) {
+          if (diagnosticsBuilder_.isEmpty()) {
+            diagnosticsBuilder_.dispose();
+            diagnosticsBuilder_ = null;
+            diagnostics_ = other.diagnostics_;
+            bitField0_ = (bitField0_ & ~0x00000010);
+            diagnosticsBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetDiagnosticsFieldBuilder() : null;
+          } else {
+            diagnosticsBuilder_.addAllMessages(other.diagnostics_);
+          }
+        }
+      }
+      if (other.failureReason_ != 0) {
+        setFailureReasonValue(other.getFailureReasonValue());
+      }
+      if (verificationVerdictsBuilder_ == null) {
+        if (!other.verificationVerdicts_.isEmpty()) {
+          if (verificationVerdicts_.isEmpty()) {
+            verificationVerdicts_ = other.verificationVerdicts_;
+            bitField0_ = (bitField0_ & ~0x00000040);
+          } else {
+            ensureVerificationVerdictsIsMutable();
+            verificationVerdicts_.addAll(other.verificationVerdicts_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.verificationVerdicts_.isEmpty()) {
+          if (verificationVerdictsBuilder_.isEmpty()) {
+            verificationVerdictsBuilder_.dispose();
+            verificationVerdictsBuilder_ = null;
+            verificationVerdicts_ = other.verificationVerdicts_;
+            bitField0_ = (bitField0_ & ~0x00000040);
+            verificationVerdictsBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetVerificationVerdictsFieldBuilder() : null;
+          } else {
+            verificationVerdictsBuilder_.addAllMessages(other.verificationVerdicts_);
+          }
+        }
+      }
+      if (other.getBounded() != false) {
+        setBounded(other.getBounded());
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              org.openmbee.opensysml.proto.Verdict m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.Verdict.parser(),
+                      extensionRegistry);
+              if (verdictsBuilder_ == null) {
+                ensureVerdictsIsMutable();
+                verdicts_.add(m);
+              } else {
+                verdictsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 10
+            case 18: {
+              input.readMessage(
+                  internalGetSummaryFieldBuilder().getBuilder(),
+                  extensionRegistry);
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            case 26: {
+              org.openmbee.opensysml.proto.Instance m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.Instance.parser(),
+                      extensionRegistry);
+              if (instancesBuilder_ == null) {
+                ensureInstancesIsMutable();
+                instances_.add(m);
+              } else {
+                instancesBuilder_.addMessage(m);
+              }
+              break;
+            } // case 26
+            case 34: {
+              error_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000008;
+              break;
+            } // case 34
+            case 42: {
+              org.openmbee.opensysml.proto.Diagnostic m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.Diagnostic.parser(),
+                      extensionRegistry);
+              if (diagnosticsBuilder_ == null) {
+                ensureDiagnosticsIsMutable();
+                diagnostics_.add(m);
+              } else {
+                diagnosticsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 42
+            case 48: {
+              failureReason_ = input.readEnum();
+              bitField0_ |= 0x00000020;
+              break;
+            } // case 48
+            case 58: {
+              org.openmbee.opensysml.proto.VerificationVerdict m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.VerificationVerdict.parser(),
+                      extensionRegistry);
+              if (verificationVerdictsBuilder_ == null) {
+                ensureVerificationVerdictsIsMutable();
+                verificationVerdicts_.add(m);
+              } else {
+                verificationVerdictsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 58
+            case 64: {
+              bounded_ = input.readBool();
+              bitField0_ |= 0x00000080;
+              break;
+            } // case 64
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.util.List<org.openmbee.opensysml.proto.Verdict> verdicts_ =
+      java.util.Collections.emptyList();
+    private void ensureVerdictsIsMutable() {
+      if (!((bitField0_ & 0x00000001) != 0)) {
+        verdicts_ = new java.util.ArrayList<org.openmbee.opensysml.proto.Verdict>(verdicts_);
+        bitField0_ |= 0x00000001;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Verdict, org.openmbee.opensysml.proto.Verdict.Builder, org.openmbee.opensysml.proto.VerdictOrBuilder> verdictsBuilder_;
+
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Verdict> getVerdictsList() {
+      if (verdictsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(verdicts_);
+      } else {
+        return verdictsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public int getVerdictsCount() {
+      if (verdictsBuilder_ == null) {
+        return verdicts_.size();
+      } else {
+        return verdictsBuilder_.getCount();
+      }
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public org.openmbee.opensysml.proto.Verdict getVerdicts(int index) {
+      if (verdictsBuilder_ == null) {
+        return verdicts_.get(index);
+      } else {
+        return verdictsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public Builder setVerdicts(
+        int index, org.openmbee.opensysml.proto.Verdict value) {
+      if (verdictsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureVerdictsIsMutable();
+        verdicts_.set(index, value);
+        onChanged();
+      } else {
+        verdictsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public Builder setVerdicts(
+        int index, org.openmbee.opensysml.proto.Verdict.Builder builderForValue) {
+      if (verdictsBuilder_ == null) {
+        ensureVerdictsIsMutable();
+        verdicts_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        verdictsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public Builder addVerdicts(org.openmbee.opensysml.proto.Verdict value) {
+      if (verdictsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureVerdictsIsMutable();
+        verdicts_.add(value);
+        onChanged();
+      } else {
+        verdictsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public Builder addVerdicts(
+        int index, org.openmbee.opensysml.proto.Verdict value) {
+      if (verdictsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureVerdictsIsMutable();
+        verdicts_.add(index, value);
+        onChanged();
+      } else {
+        verdictsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public Builder addVerdicts(
+        org.openmbee.opensysml.proto.Verdict.Builder builderForValue) {
+      if (verdictsBuilder_ == null) {
+        ensureVerdictsIsMutable();
+        verdicts_.add(builderForValue.build());
+        onChanged();
+      } else {
+        verdictsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public Builder addVerdicts(
+        int index, org.openmbee.opensysml.proto.Verdict.Builder builderForValue) {
+      if (verdictsBuilder_ == null) {
+        ensureVerdictsIsMutable();
+        verdicts_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        verdictsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public Builder addAllVerdicts(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.Verdict> values) {
+      if (verdictsBuilder_ == null) {
+        ensureVerdictsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, verdicts_);
+        onChanged();
+      } else {
+        verdictsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public Builder clearVerdicts() {
+      if (verdictsBuilder_ == null) {
+        verdicts_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000001);
+        onChanged();
+      } else {
+        verdictsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public Builder removeVerdicts(int index) {
+      if (verdictsBuilder_ == null) {
+        ensureVerdictsIsMutable();
+        verdicts_.remove(index);
+        onChanged();
+      } else {
+        verdictsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public org.openmbee.opensysml.proto.Verdict.Builder getVerdictsBuilder(
+        int index) {
+      return internalGetVerdictsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public org.openmbee.opensysml.proto.VerdictOrBuilder getVerdictsOrBuilder(
+        int index) {
+      if (verdictsBuilder_ == null) {
+        return verdicts_.get(index);  } else {
+        return verdictsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.VerdictOrBuilder> 
+         getVerdictsOrBuilderList() {
+      if (verdictsBuilder_ != null) {
+        return verdictsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(verdicts_);
+      }
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public org.openmbee.opensysml.proto.Verdict.Builder addVerdictsBuilder() {
+      return internalGetVerdictsFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.Verdict.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public org.openmbee.opensysml.proto.Verdict.Builder addVerdictsBuilder(
+        int index) {
+      return internalGetVerdictsFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.Verdict.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+     * object `instance_id` names, reached along `instance_path`.
+     * </pre>
+     *
+     * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Verdict.Builder> 
+         getVerdictsBuilderList() {
+      return internalGetVerdictsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Verdict, org.openmbee.opensysml.proto.Verdict.Builder, org.openmbee.opensysml.proto.VerdictOrBuilder> 
+        internalGetVerdictsFieldBuilder() {
+      if (verdictsBuilder_ == null) {
+        verdictsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.Verdict, org.openmbee.opensysml.proto.Verdict.Builder, org.openmbee.opensysml.proto.VerdictOrBuilder>(
+                verdicts_,
+                ((bitField0_ & 0x00000001) != 0),
+                getParentForChildren(),
+                isClean());
+        verdicts_ = null;
+      }
+      return verdictsBuilder_;
+    }
+
+    private org.openmbee.opensysml.proto.Verdict summary_;
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.Verdict, org.openmbee.opensysml.proto.Verdict.Builder, org.openmbee.opensysml.proto.VerdictOrBuilder> summaryBuilder_;
+    /**
+     * <pre>
+     * The object as a whole, kind "object": holds when every assertion holds and
+     * every held object was reached. With `error` empty, false is the model's
+     * answer, some assertion failing; with `error` set, nothing was decided, an
+     * assertion being undecided or nesting left unreached.
+     * </pre>
+     *
+     * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+     * @return Whether the summary field is set.
+     */
+    public boolean hasSummary() {
+      return ((bitField0_ & 0x00000002) != 0);
+    }
+    /**
+     * <pre>
+     * The object as a whole, kind "object": holds when every assertion holds and
+     * every held object was reached. With `error` empty, false is the model's
+     * answer, some assertion failing; with `error` set, nothing was decided, an
+     * assertion being undecided or nesting left unreached.
+     * </pre>
+     *
+     * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+     * @return The summary.
+     */
+    public org.openmbee.opensysml.proto.Verdict getSummary() {
+      if (summaryBuilder_ == null) {
+        return summary_ == null ? org.openmbee.opensysml.proto.Verdict.getDefaultInstance() : summary_;
+      } else {
+        return summaryBuilder_.getMessage();
+      }
+    }
+    /**
+     * <pre>
+     * The object as a whole, kind "object": holds when every assertion holds and
+     * every held object was reached. With `error` empty, false is the model's
+     * answer, some assertion failing; with `error` set, nothing was decided, an
+     * assertion being undecided or nesting left unreached.
+     * </pre>
+     *
+     * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+     */
+    public Builder setSummary(org.openmbee.opensysml.proto.Verdict value) {
+      if (summaryBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        summary_ = value;
+      } else {
+        summaryBuilder_.setMessage(value);
+      }
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object as a whole, kind "object": holds when every assertion holds and
+     * every held object was reached. With `error` empty, false is the model's
+     * answer, some assertion failing; with `error` set, nothing was decided, an
+     * assertion being undecided or nesting left unreached.
+     * </pre>
+     *
+     * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+     */
+    public Builder setSummary(
+        org.openmbee.opensysml.proto.Verdict.Builder builderForValue) {
+      if (summaryBuilder_ == null) {
+        summary_ = builderForValue.build();
+      } else {
+        summaryBuilder_.setMessage(builderForValue.build());
+      }
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object as a whole, kind "object": holds when every assertion holds and
+     * every held object was reached. With `error` empty, false is the model's
+     * answer, some assertion failing; with `error` set, nothing was decided, an
+     * assertion being undecided or nesting left unreached.
+     * </pre>
+     *
+     * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+     */
+    public Builder mergeSummary(org.openmbee.opensysml.proto.Verdict value) {
+      if (summaryBuilder_ == null) {
+        if (((bitField0_ & 0x00000002) != 0) &&
+          summary_ != null &&
+          summary_ != org.openmbee.opensysml.proto.Verdict.getDefaultInstance()) {
+          getSummaryBuilder().mergeFrom(value);
+        } else {
+          summary_ = value;
+        }
+      } else {
+        summaryBuilder_.mergeFrom(value);
+      }
+      if (summary_ != null) {
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The object as a whole, kind "object": holds when every assertion holds and
+     * every held object was reached. With `error` empty, false is the model's
+     * answer, some assertion failing; with `error` set, nothing was decided, an
+     * assertion being undecided or nesting left unreached.
+     * </pre>
+     *
+     * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+     */
+    public Builder clearSummary() {
+      bitField0_ = (bitField0_ & ~0x00000002);
+      summary_ = null;
+      if (summaryBuilder_ != null) {
+        summaryBuilder_.dispose();
+        summaryBuilder_ = null;
+      }
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The object as a whole, kind "object": holds when every assertion holds and
+     * every held object was reached. With `error` empty, false is the model's
+     * answer, some assertion failing; with `error` set, nothing was decided, an
+     * assertion being undecided or nesting left unreached.
+     * </pre>
+     *
+     * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+     */
+    public org.openmbee.opensysml.proto.Verdict.Builder getSummaryBuilder() {
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return internalGetSummaryFieldBuilder().getBuilder();
+    }
+    /**
+     * <pre>
+     * The object as a whole, kind "object": holds when every assertion holds and
+     * every held object was reached. With `error` empty, false is the model's
+     * answer, some assertion failing; with `error` set, nothing was decided, an
+     * assertion being undecided or nesting left unreached.
+     * </pre>
+     *
+     * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+     */
+    public org.openmbee.opensysml.proto.VerdictOrBuilder getSummaryOrBuilder() {
+      if (summaryBuilder_ != null) {
+        return summaryBuilder_.getMessageOrBuilder();
+      } else {
+        return summary_ == null ?
+            org.openmbee.opensysml.proto.Verdict.getDefaultInstance() : summary_;
+      }
+    }
+    /**
+     * <pre>
+     * The object as a whole, kind "object": holds when every assertion holds and
+     * every held object was reached. With `error` empty, false is the model's
+     * answer, some assertion failing; with `error` set, nothing was decided, an
+     * assertion being undecided or nesting left unreached.
+     * </pre>
+     *
+     * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+     */
+    private com.google.protobuf.SingleFieldBuilder<
+        org.openmbee.opensysml.proto.Verdict, org.openmbee.opensysml.proto.Verdict.Builder, org.openmbee.opensysml.proto.VerdictOrBuilder> 
+        internalGetSummaryFieldBuilder() {
+      if (summaryBuilder_ == null) {
+        summaryBuilder_ = new com.google.protobuf.SingleFieldBuilder<
+            org.openmbee.opensysml.proto.Verdict, org.openmbee.opensysml.proto.Verdict.Builder, org.openmbee.opensysml.proto.VerdictOrBuilder>(
+                getSummary(),
+                getParentForChildren(),
+                isClean());
+        summary_ = null;
+      }
+      return summaryBuilder_;
+    }
+
+    private java.util.List<org.openmbee.opensysml.proto.Instance> instances_ =
+      java.util.Collections.emptyList();
+    private void ensureInstancesIsMutable() {
+      if (!((bitField0_ & 0x00000004) != 0)) {
+        instances_ = new java.util.ArrayList<org.openmbee.opensysml.proto.Instance>(instances_);
+        bitField0_ |= 0x00000004;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Instance, org.openmbee.opensysml.proto.Instance.Builder, org.openmbee.opensysml.proto.InstanceOrBuilder> instancesBuilder_;
+
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Instance> getInstancesList() {
+      if (instancesBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(instances_);
+      } else {
+        return instancesBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public int getInstancesCount() {
+      if (instancesBuilder_ == null) {
+        return instances_.size();
+      } else {
+        return instancesBuilder_.getCount();
+      }
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public org.openmbee.opensysml.proto.Instance getInstances(int index) {
+      if (instancesBuilder_ == null) {
+        return instances_.get(index);
+      } else {
+        return instancesBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public Builder setInstances(
+        int index, org.openmbee.opensysml.proto.Instance value) {
+      if (instancesBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureInstancesIsMutable();
+        instances_.set(index, value);
+        onChanged();
+      } else {
+        instancesBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public Builder setInstances(
+        int index, org.openmbee.opensysml.proto.Instance.Builder builderForValue) {
+      if (instancesBuilder_ == null) {
+        ensureInstancesIsMutable();
+        instances_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        instancesBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public Builder addInstances(org.openmbee.opensysml.proto.Instance value) {
+      if (instancesBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureInstancesIsMutable();
+        instances_.add(value);
+        onChanged();
+      } else {
+        instancesBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public Builder addInstances(
+        int index, org.openmbee.opensysml.proto.Instance value) {
+      if (instancesBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureInstancesIsMutable();
+        instances_.add(index, value);
+        onChanged();
+      } else {
+        instancesBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public Builder addInstances(
+        org.openmbee.opensysml.proto.Instance.Builder builderForValue) {
+      if (instancesBuilder_ == null) {
+        ensureInstancesIsMutable();
+        instances_.add(builderForValue.build());
+        onChanged();
+      } else {
+        instancesBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public Builder addInstances(
+        int index, org.openmbee.opensysml.proto.Instance.Builder builderForValue) {
+      if (instancesBuilder_ == null) {
+        ensureInstancesIsMutable();
+        instances_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        instancesBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public Builder addAllInstances(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.Instance> values) {
+      if (instancesBuilder_ == null) {
+        ensureInstancesIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, instances_);
+        onChanged();
+      } else {
+        instancesBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public Builder clearInstances() {
+      if (instancesBuilder_ == null) {
+        instances_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000004);
+        onChanged();
+      } else {
+        instancesBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public Builder removeInstances(int index) {
+      if (instancesBuilder_ == null) {
+        ensureInstancesIsMutable();
+        instances_.remove(index);
+        onChanged();
+      } else {
+        instancesBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public org.openmbee.opensysml.proto.Instance.Builder getInstancesBuilder(
+        int index) {
+      return internalGetInstancesFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public org.openmbee.opensysml.proto.InstanceOrBuilder getInstancesOrBuilder(
+        int index) {
+      if (instancesBuilder_ == null) {
+        return instances_.get(index);  } else {
+        return instancesBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.InstanceOrBuilder> 
+         getInstancesOrBuilderList() {
+      if (instancesBuilder_ != null) {
+        return instancesBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(instances_);
+      }
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public org.openmbee.opensysml.proto.Instance.Builder addInstancesBuilder() {
+      return internalGetInstancesFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.Instance.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public org.openmbee.opensysml.proto.Instance.Builder addInstancesBuilder(
+        int index) {
+      return internalGetInstancesFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.Instance.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * Every object reached, the validated one first, so a client can read the
+     * feature values behind each verdict.
+     * </pre>
+     *
+     * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Instance.Builder> 
+         getInstancesBuilderList() {
+      return internalGetInstancesFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Instance, org.openmbee.opensysml.proto.Instance.Builder, org.openmbee.opensysml.proto.InstanceOrBuilder> 
+        internalGetInstancesFieldBuilder() {
+      if (instancesBuilder_ == null) {
+        instancesBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.Instance, org.openmbee.opensysml.proto.Instance.Builder, org.openmbee.opensysml.proto.InstanceOrBuilder>(
+                instances_,
+                ((bitField0_ & 0x00000004) != 0),
+                getParentForChildren(),
+                isClean());
+        instances_ = null;
+      }
+      return instancesBuilder_;
+    }
+
+    private java.lang.Object error_ = "";
+    /**
+     * <code>string error = 4 [json_name = "error"];</code>
+     * @return The error.
+     */
+    public java.lang.String getError() {
+      java.lang.Object ref = error_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        error_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <code>string error = 4 [json_name = "error"];</code>
+     * @return The bytes for error.
+     */
+    public com.google.protobuf.ByteString
+        getErrorBytes() {
+      java.lang.Object ref = error_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        error_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <code>string error = 4 [json_name = "error"];</code>
+     * @param value The error to set.
+     * @return This builder for chaining.
+     */
+    public Builder setError(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      error_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string error = 4 [json_name = "error"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearError() {
+      error_ = getDefaultInstance().getError();
+      bitField0_ = (bitField0_ & ~0x00000008);
+      onChanged();
+      return this;
+    }
+    /**
+     * <code>string error = 4 [json_name = "error"];</code>
+     * @param value The bytes for error to set.
+     * @return This builder for chaining.
+     */
+    public Builder setErrorBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      error_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+
+    private java.util.List<org.openmbee.opensysml.proto.Diagnostic> diagnostics_ =
+      java.util.Collections.emptyList();
+    private void ensureDiagnosticsIsMutable() {
+      if (!((bitField0_ & 0x00000010) != 0)) {
+        diagnostics_ = new java.util.ArrayList<org.openmbee.opensysml.proto.Diagnostic>(diagnostics_);
+        bitField0_ |= 0x00000010;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> diagnosticsBuilder_;
+
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Diagnostic> getDiagnosticsList() {
+      if (diagnosticsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(diagnostics_);
+      } else {
+        return diagnosticsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public int getDiagnosticsCount() {
+      if (diagnosticsBuilder_ == null) {
+        return diagnostics_.size();
+      } else {
+        return diagnosticsBuilder_.getCount();
+      }
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index) {
+      if (diagnosticsBuilder_ == null) {
+        return diagnostics_.get(index);
+      } else {
+        return diagnosticsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder setDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic value) {
+      if (diagnosticsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDiagnosticsIsMutable();
+        diagnostics_.set(index, value);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder setDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        diagnosticsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(org.openmbee.opensysml.proto.Diagnostic value) {
+      if (diagnosticsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(value);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic value) {
+      if (diagnosticsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(index, value);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(
+        org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(builderForValue.build());
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder addDiagnostics(
+        int index, org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder addAllDiagnostics(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.Diagnostic> values) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, diagnostics_);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder clearDiagnostics() {
+      if (diagnosticsBuilder_ == null) {
+        diagnostics_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000010);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public Builder removeDiagnostics(int index) {
+      if (diagnosticsBuilder_ == null) {
+        ensureDiagnosticsIsMutable();
+        diagnostics_.remove(index);
+        onChanged();
+      } else {
+        diagnosticsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic.Builder getDiagnosticsBuilder(
+        int index) {
+      return internalGetDiagnosticsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
+        int index) {
+      if (diagnosticsBuilder_ == null) {
+        return diagnostics_.get(index);  } else {
+        return diagnosticsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+         getDiagnosticsOrBuilderList() {
+      if (diagnosticsBuilder_ != null) {
+        return diagnosticsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(diagnostics_);
+      }
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder() {
+      return internalGetDiagnosticsFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.Diagnostic.getDefaultInstance());
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder(
+        int index) {
+      return internalGetDiagnosticsFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.Diagnostic.getDefaultInstance());
+    }
+    /**
+     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Diagnostic.Builder> 
+         getDiagnosticsBuilderList() {
+      return internalGetDiagnosticsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+        internalGetDiagnosticsFieldBuilder() {
+      if (diagnosticsBuilder_ == null) {
+        diagnosticsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder>(
+                diagnostics_,
+                ((bitField0_ & 0x00000010) != 0),
+                getParentForChildren(),
+                isClean());
+        diagnostics_ = null;
+      }
+      return diagnosticsBuilder_;
+    }
+
+    private int failureReason_ = 0;
+    /**
+     * <pre>
+     * What kind of failure `error` reports.
+     * </pre>
+     *
+     * <code>.sysml.FailureReason failure_reason = 6 [json_name = "failureReason"];</code>
+     * @return The enum numeric value on the wire for failureReason.
+     */
+    @java.lang.Override public int getFailureReasonValue() {
+      return failureReason_;
+    }
+    /**
+     * <pre>
+     * What kind of failure `error` reports.
+     * </pre>
+     *
+     * <code>.sysml.FailureReason failure_reason = 6 [json_name = "failureReason"];</code>
+     * @param value The enum numeric value on the wire for failureReason to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFailureReasonValue(int value) {
+      failureReason_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * What kind of failure `error` reports.
+     * </pre>
+     *
+     * <code>.sysml.FailureReason failure_reason = 6 [json_name = "failureReason"];</code>
+     * @return The failureReason.
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.FailureReason getFailureReason() {
+      org.openmbee.opensysml.proto.FailureReason result = org.openmbee.opensysml.proto.FailureReason.forNumber(failureReason_);
+      return result == null ? org.openmbee.opensysml.proto.FailureReason.UNRECOGNIZED : result;
+    }
+    /**
+     * <pre>
+     * What kind of failure `error` reports.
+     * </pre>
+     *
+     * <code>.sysml.FailureReason failure_reason = 6 [json_name = "failureReason"];</code>
+     * @param value The failureReason to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFailureReason(org.openmbee.opensysml.proto.FailureReason value) {
+      if (value == null) { throw new NullPointerException(); }
+      bitField0_ |= 0x00000020;
+      failureReason_ = value.getNumber();
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * What kind of failure `error` reports.
+     * </pre>
+     *
+     * <code>.sysml.FailureReason failure_reason = 6 [json_name = "failureReason"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearFailureReason() {
+      bitField0_ = (bitField0_ & ~0x00000020);
+      failureReason_ = 0;
+      onChanged();
+      return this;
+    }
+
+    private java.util.List<org.openmbee.opensysml.proto.VerificationVerdict> verificationVerdicts_ =
+      java.util.Collections.emptyList();
+    private void ensureVerificationVerdictsIsMutable() {
+      if (!((bitField0_ & 0x00000040) != 0)) {
+        verificationVerdicts_ = new java.util.ArrayList<org.openmbee.opensysml.proto.VerificationVerdict>(verificationVerdicts_);
+        bitField0_ |= 0x00000040;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.VerificationVerdict, org.openmbee.opensysml.proto.VerificationVerdict.Builder, org.openmbee.opensysml.proto.VerificationVerdictOrBuilder> verificationVerdictsBuilder_;
+
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.VerificationVerdict> getVerificationVerdictsList() {
+      if (verificationVerdictsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(verificationVerdicts_);
+      } else {
+        return verificationVerdictsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public int getVerificationVerdictsCount() {
+      if (verificationVerdictsBuilder_ == null) {
+        return verificationVerdicts_.size();
+      } else {
+        return verificationVerdictsBuilder_.getCount();
+      }
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public org.openmbee.opensysml.proto.VerificationVerdict getVerificationVerdicts(int index) {
+      if (verificationVerdictsBuilder_ == null) {
+        return verificationVerdicts_.get(index);
+      } else {
+        return verificationVerdictsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public Builder setVerificationVerdicts(
+        int index, org.openmbee.opensysml.proto.VerificationVerdict value) {
+      if (verificationVerdictsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureVerificationVerdictsIsMutable();
+        verificationVerdicts_.set(index, value);
+        onChanged();
+      } else {
+        verificationVerdictsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public Builder setVerificationVerdicts(
+        int index, org.openmbee.opensysml.proto.VerificationVerdict.Builder builderForValue) {
+      if (verificationVerdictsBuilder_ == null) {
+        ensureVerificationVerdictsIsMutable();
+        verificationVerdicts_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        verificationVerdictsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public Builder addVerificationVerdicts(org.openmbee.opensysml.proto.VerificationVerdict value) {
+      if (verificationVerdictsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureVerificationVerdictsIsMutable();
+        verificationVerdicts_.add(value);
+        onChanged();
+      } else {
+        verificationVerdictsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public Builder addVerificationVerdicts(
+        int index, org.openmbee.opensysml.proto.VerificationVerdict value) {
+      if (verificationVerdictsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureVerificationVerdictsIsMutable();
+        verificationVerdicts_.add(index, value);
+        onChanged();
+      } else {
+        verificationVerdictsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public Builder addVerificationVerdicts(
+        org.openmbee.opensysml.proto.VerificationVerdict.Builder builderForValue) {
+      if (verificationVerdictsBuilder_ == null) {
+        ensureVerificationVerdictsIsMutable();
+        verificationVerdicts_.add(builderForValue.build());
+        onChanged();
+      } else {
+        verificationVerdictsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public Builder addVerificationVerdicts(
+        int index, org.openmbee.opensysml.proto.VerificationVerdict.Builder builderForValue) {
+      if (verificationVerdictsBuilder_ == null) {
+        ensureVerificationVerdictsIsMutable();
+        verificationVerdicts_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        verificationVerdictsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public Builder addAllVerificationVerdicts(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.VerificationVerdict> values) {
+      if (verificationVerdictsBuilder_ == null) {
+        ensureVerificationVerdictsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, verificationVerdicts_);
+        onChanged();
+      } else {
+        verificationVerdictsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public Builder clearVerificationVerdicts() {
+      if (verificationVerdictsBuilder_ == null) {
+        verificationVerdicts_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00000040);
+        onChanged();
+      } else {
+        verificationVerdictsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public Builder removeVerificationVerdicts(int index) {
+      if (verificationVerdictsBuilder_ == null) {
+        ensureVerificationVerdictsIsMutable();
+        verificationVerdicts_.remove(index);
+        onChanged();
+      } else {
+        verificationVerdictsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public org.openmbee.opensysml.proto.VerificationVerdict.Builder getVerificationVerdictsBuilder(
+        int index) {
+      return internalGetVerificationVerdictsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public org.openmbee.opensysml.proto.VerificationVerdictOrBuilder getVerificationVerdictsOrBuilder(
+        int index) {
+      if (verificationVerdictsBuilder_ == null) {
+        return verificationVerdicts_.get(index);  } else {
+        return verificationVerdictsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.VerificationVerdictOrBuilder> 
+         getVerificationVerdictsOrBuilderList() {
+      if (verificationVerdictsBuilder_ != null) {
+        return verificationVerdictsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(verificationVerdicts_);
+      }
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public org.openmbee.opensysml.proto.VerificationVerdict.Builder addVerificationVerdictsBuilder() {
+      return internalGetVerificationVerdictsFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.VerificationVerdict.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public org.openmbee.opensysml.proto.VerificationVerdict.Builder addVerificationVerdictsBuilder(
+        int index) {
+      return internalGetVerificationVerdictsFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.VerificationVerdict.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * What the body of every verification case verifying a requirement a verdict
+     * is about answered, once per requirement; each names its requirement, which
+     * the requirement and satisfy verdicts carry as their requirement_id.
+     * </pre>
+     *
+     * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.VerificationVerdict.Builder> 
+         getVerificationVerdictsBuilderList() {
+      return internalGetVerificationVerdictsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.VerificationVerdict, org.openmbee.opensysml.proto.VerificationVerdict.Builder, org.openmbee.opensysml.proto.VerificationVerdictOrBuilder> 
+        internalGetVerificationVerdictsFieldBuilder() {
+      if (verificationVerdictsBuilder_ == null) {
+        verificationVerdictsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.VerificationVerdict, org.openmbee.opensysml.proto.VerificationVerdict.Builder, org.openmbee.opensysml.proto.VerificationVerdictOrBuilder>(
+                verificationVerdicts_,
+                ((bitField0_ & 0x00000040) != 0),
+                getParentForChildren(),
+                isClean());
+        verificationVerdicts_ = null;
+      }
+      return verificationVerdictsBuilder_;
+    }
+
+    private boolean bounded_ ;
+    /**
+     * <pre>
+     * True when nesting deeper than the validation descends, or past its budget,
+     * was left unvalidated; the summary then decides nothing.
+     * </pre>
+     *
+     * <code>bool bounded = 8 [json_name = "bounded"];</code>
+     * @return The bounded.
+     */
+    @java.lang.Override
+    public boolean getBounded() {
+      return bounded_;
+    }
+    /**
+     * <pre>
+     * True when nesting deeper than the validation descends, or past its budget,
+     * was left unvalidated; the summary then decides nothing.
+     * </pre>
+     *
+     * <code>bool bounded = 8 [json_name = "bounded"];</code>
+     * @param value The bounded to set.
+     * @return This builder for chaining.
+     */
+    public Builder setBounded(boolean value) {
+
+      bounded_ = value;
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * True when nesting deeper than the validation descends, or past its budget,
+     * was left unvalidated; the summary then decides nothing.
+     * </pre>
+     *
+     * <code>bool bounded = 8 [json_name = "bounded"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearBounded() {
+      bitField0_ = (bitField0_ & ~0x00000080);
+      bounded_ = false;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.ValidateInstanceResponse)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.ValidateInstanceResponse)
+  private static final org.openmbee.opensysml.proto.ValidateInstanceResponse DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.ValidateInstanceResponse();
+  }
+
+  public static org.openmbee.opensysml.proto.ValidateInstanceResponse getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<ValidateInstanceResponse>
+      PARSER = new com.google.protobuf.AbstractParser<ValidateInstanceResponse>() {
+    @java.lang.Override
+    public ValidateInstanceResponse parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<ValidateInstanceResponse> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<ValidateInstanceResponse> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.ValidateInstanceResponse getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceResponseOrBuilder.java
new file mode 100644
index 0000000000..04de732a7b
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValidateInstanceResponseOrBuilder.java
@@ -0,0 +1,266 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+@com.google.protobuf.Generated
+public interface ValidateInstanceResponseOrBuilder extends
+    // @@protoc_insertion_point(interface_extends:sysml.ValidateInstanceResponse)
+    com.google.protobuf.MessageOrBuilder {
+
+  /**
+   * <pre>
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   * </pre>
+   *
+   * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+   */
+  java.util.List<org.openmbee.opensysml.proto.Verdict> 
+      getVerdictsList();
+  /**
+   * <pre>
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   * </pre>
+   *
+   * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+   */
+  org.openmbee.opensysml.proto.Verdict getVerdicts(int index);
+  /**
+   * <pre>
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   * </pre>
+   *
+   * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+   */
+  int getVerdictsCount();
+  /**
+   * <pre>
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   * </pre>
+   *
+   * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+   */
+  java.util.List<? extends org.openmbee.opensysml.proto.VerdictOrBuilder> 
+      getVerdictsOrBuilderList();
+  /**
+   * <pre>
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   * </pre>
+   *
+   * <code>repeated .sysml.Verdict verdicts = 1 [json_name = "verdicts"];</code>
+   */
+  org.openmbee.opensysml.proto.VerdictOrBuilder getVerdictsOrBuilder(
+      int index);
+
+  /**
+   * <pre>
+   * The object as a whole, kind "object": holds when every assertion holds and
+   * every held object was reached. With `error` empty, false is the model's
+   * answer, some assertion failing; with `error` set, nothing was decided, an
+   * assertion being undecided or nesting left unreached.
+   * </pre>
+   *
+   * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+   * @return Whether the summary field is set.
+   */
+  boolean hasSummary();
+  /**
+   * <pre>
+   * The object as a whole, kind "object": holds when every assertion holds and
+   * every held object was reached. With `error` empty, false is the model's
+   * answer, some assertion failing; with `error` set, nothing was decided, an
+   * assertion being undecided or nesting left unreached.
+   * </pre>
+   *
+   * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+   * @return The summary.
+   */
+  org.openmbee.opensysml.proto.Verdict getSummary();
+  /**
+   * <pre>
+   * The object as a whole, kind "object": holds when every assertion holds and
+   * every held object was reached. With `error` empty, false is the model's
+   * answer, some assertion failing; with `error` set, nothing was decided, an
+   * assertion being undecided or nesting left unreached.
+   * </pre>
+   *
+   * <code>.sysml.Verdict summary = 2 [json_name = "summary"];</code>
+   */
+  org.openmbee.opensysml.proto.VerdictOrBuilder getSummaryOrBuilder();
+
+  /**
+   * <pre>
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   * </pre>
+   *
+   * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+   */
+  java.util.List<org.openmbee.opensysml.proto.Instance> 
+      getInstancesList();
+  /**
+   * <pre>
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   * </pre>
+   *
+   * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+   */
+  org.openmbee.opensysml.proto.Instance getInstances(int index);
+  /**
+   * <pre>
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   * </pre>
+   *
+   * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+   */
+  int getInstancesCount();
+  /**
+   * <pre>
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   * </pre>
+   *
+   * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+   */
+  java.util.List<? extends org.openmbee.opensysml.proto.InstanceOrBuilder> 
+      getInstancesOrBuilderList();
+  /**
+   * <pre>
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   * </pre>
+   *
+   * <code>repeated .sysml.Instance instances = 3 [json_name = "instances"];</code>
+   */
+  org.openmbee.opensysml.proto.InstanceOrBuilder getInstancesOrBuilder(
+      int index);
+
+  /**
+   * <code>string error = 4 [json_name = "error"];</code>
+   * @return The error.
+   */
+  java.lang.String getError();
+  /**
+   * <code>string error = 4 [json_name = "error"];</code>
+   * @return The bytes for error.
+   */
+  com.google.protobuf.ByteString
+      getErrorBytes();
+
+  /**
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  java.util.List<org.openmbee.opensysml.proto.Diagnostic> 
+      getDiagnosticsList();
+  /**
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index);
+  /**
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  int getDiagnosticsCount();
+  /**
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
+      getDiagnosticsOrBuilderList();
+  /**
+   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
+   */
+  org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
+      int index);
+
+  /**
+   * <pre>
+   * What kind of failure `error` reports.
+   * </pre>
+   *
+   * <code>.sysml.FailureReason failure_reason = 6 [json_name = "failureReason"];</code>
+   * @return The enum numeric value on the wire for failureReason.
+   */
+  int getFailureReasonValue();
+  /**
+   * <pre>
+   * What kind of failure `error` reports.
+   * </pre>
+   *
+   * <code>.sysml.FailureReason failure_reason = 6 [json_name = "failureReason"];</code>
+   * @return The failureReason.
+   */
+  org.openmbee.opensysml.proto.FailureReason getFailureReason();
+
+  /**
+   * <pre>
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   * </pre>
+   *
+   * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+   */
+  java.util.List<org.openmbee.opensysml.proto.VerificationVerdict> 
+      getVerificationVerdictsList();
+  /**
+   * <pre>
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   * </pre>
+   *
+   * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+   */
+  org.openmbee.opensysml.proto.VerificationVerdict getVerificationVerdicts(int index);
+  /**
+   * <pre>
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   * </pre>
+   *
+   * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+   */
+  int getVerificationVerdictsCount();
+  /**
+   * <pre>
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   * </pre>
+   *
+   * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+   */
+  java.util.List<? extends org.openmbee.opensysml.proto.VerificationVerdictOrBuilder> 
+      getVerificationVerdictsOrBuilderList();
+  /**
+   * <pre>
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   * </pre>
+   *
+   * <code>repeated .sysml.VerificationVerdict verification_verdicts = 7 [json_name = "verificationVerdicts"];</code>
+   */
+  org.openmbee.opensysml.proto.VerificationVerdictOrBuilder getVerificationVerdictsOrBuilder(
+      int index);
+
+  /**
+   * <pre>
+   * True when nesting deeper than the validation descends, or past its budget,
+   * was left unvalidated; the summary then decides nothing.
+   * </pre>
+   *
+   * <code>bool bounded = 8 [json_name = "bounded"];</code>
+   * @return The bounded.
+   */
+  boolean getBounded();
+}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Value.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Value.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Value.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Value.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSequence.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSequence.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSequence.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSequence.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSequenceOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSequenceOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSequenceOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSequenceOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSet.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSet.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSet.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSet.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSetOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSetOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSetOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ValueSetOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Vector.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Vector.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Vector.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Vector.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorQuantity.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorQuantity.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorQuantity.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorQuantity.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorQuantityOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorQuantityOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorQuantityOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VectorQuantityOrBuilder.java
diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Verdict.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Verdict.java
new file mode 100644
index 0000000000..244fc15b21
--- /dev/null
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Verdict.java
@@ -0,0 +1,2896 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// NO CHECKED-IN PROTOBUF GENCODE
+// source: sysml.proto
+// Protobuf Java Version: 4.33.1
+
+package org.openmbee.opensysml.proto;
+
+/**
+ * <pre>
+ * Verdict is one verification's answer: whether the condition held, and, when
+ * it did not, which condition the model answered false about.
+ * </pre>
+ *
+ * Protobuf type {@code sysml.Verdict}
+ */
+@com.google.protobuf.Generated
+public final class Verdict extends
+    com.google.protobuf.GeneratedMessage implements
+    // @@protoc_insertion_point(message_implements:sysml.Verdict)
+    VerdictOrBuilder {
+private static final long serialVersionUID = 0L;
+  static {
+    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
+      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
+      /* major= */ 4,
+      /* minor= */ 33,
+      /* patch= */ 1,
+      /* suffix= */ "",
+      "Verdict");
+  }
+  // Use Verdict.newBuilder() to construct.
+  private Verdict(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
+    super(builder);
+  }
+  private Verdict() {
+    kind_ = "";
+    elementId_ = "";
+    element_ = "";
+    condition_ = "";
+    instanceTypeId_ = "";
+    error_ = "";
+    failureReason_ = 0;
+    requirementId_ = "";
+    engine_ = "";
+    strength_ = "";
+    bounds_ = java.util.Collections.emptyList();
+    instancePath_ = "";
+  }
+
+  public static final com.google.protobuf.Descriptors.Descriptor
+      getDescriptor() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Verdict_descriptor;
+  }
+
+  @java.lang.Override
+  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+      internalGetFieldAccessorTable() {
+    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Verdict_fieldAccessorTable
+        .ensureFieldAccessorsInitialized(
+            org.openmbee.opensysml.proto.Verdict.class, org.openmbee.opensysml.proto.Verdict.Builder.class);
+  }
+
+  public static final int KIND_FIELD_NUMBER = 1;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object kind_ = "";
+  /**
+   * <pre>
+   * What was verified: "constraint", "requirement" or "satisfy"; for a check
+   * an analysis case run made, "objective" or "assertion"; for the summary
+   * of a ValidateInstance, "object".
+   * </pre>
+   *
+   * <code>string kind = 1 [json_name = "kind"];</code>
+   * @return The kind.
+   */
+  @java.lang.Override
+  public java.lang.String getKind() {
+    java.lang.Object ref = kind_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      kind_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * What was verified: "constraint", "requirement" or "satisfy"; for a check
+   * an analysis case run made, "objective" or "assertion"; for the summary
+   * of a ValidateInstance, "object".
+   * </pre>
+   *
+   * <code>string kind = 1 [json_name = "kind"];</code>
+   * @return The bytes for kind.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getKindBytes() {
+    java.lang.Object ref = kind_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      kind_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int ELEMENT_ID_FIELD_NUMBER = 2;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object elementId_ = "";
+  /**
+   * <pre>
+   * FQN of the element verified; empty for an anonymous satisfy assertion or
+   * an anonymous assertion in a case body.
+   * </pre>
+   *
+   * <code>string element_id = 2 [json_name = "elementId"];</code>
+   * @return The elementId.
+   */
+  @java.lang.Override
+  public java.lang.String getElementId() {
+    java.lang.Object ref = elementId_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      elementId_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * FQN of the element verified; empty for an anonymous satisfy assertion or
+   * an anonymous assertion in a case body.
+   * </pre>
+   *
+   * <code>string element_id = 2 [json_name = "elementId"];</code>
+   * @return The bytes for elementId.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getElementIdBytes() {
+    java.lang.Object ref = elementId_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      elementId_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int ELEMENT_FIELD_NUMBER = 3;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object element_ = "";
+  /**
+   * <pre>
+   * The element as a reader names it: its FQN, or, for an anonymous satisfy
+   * assertion, the assertion as written ("satisfy Range by cruise"); for an
+   * objective or assertion of a case, its name or its condition as written.
+   * </pre>
+   *
+   * <code>string element = 3 [json_name = "element"];</code>
+   * @return The element.
+   */
+  @java.lang.Override
+  public java.lang.String getElement() {
+    java.lang.Object ref = element_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      element_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The element as a reader names it: its FQN, or, for an anonymous satisfy
+   * assertion, the assertion as written ("satisfy Range by cruise"); for an
+   * objective or assertion of a case, its name or its condition as written.
+   * </pre>
+   *
+   * <code>string element = 3 [json_name = "element"];</code>
+   * @return The bytes for element.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getElementBytes() {
+    java.lang.Object ref = element_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      element_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int HOLDS_FIELD_NUMBER = 4;
+  private boolean holds_ = false;
+  /**
+   * <pre>
+   * Whether the condition holds. False with an empty `error` is the model's own
+   * answer; false with an `error` is no answer at all.
+   * </pre>
+   *
+   * <code>bool holds = 4 [json_name = "holds"];</code>
+   * @return The holds.
+   */
+  @java.lang.Override
+  public boolean getHolds() {
+    return holds_;
+  }
+
+  public static final int CONDITION_FIELD_NUMBER = 5;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object condition_ = "";
+  /**
+   * <pre>
+   * The condition that evaluated to false, as written, when the runtime names
+   * one. Empty when the verdict holds or the condition is not named.
+   * </pre>
+   *
+   * <code>string condition = 5 [json_name = "condition"];</code>
+   * @return The condition.
+   */
+  @java.lang.Override
+  public java.lang.String getCondition() {
+    java.lang.Object ref = condition_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      condition_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The condition that evaluated to false, as written, when the runtime names
+   * one. Empty when the verdict holds or the condition is not named.
+   * </pre>
+   *
+   * <code>string condition = 5 [json_name = "condition"];</code>
+   * @return The bytes for condition.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getConditionBytes() {
+    java.lang.Object ref = condition_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      condition_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int INSTANCE_ID_FIELD_NUMBER = 6;
+  private long instanceId_ = 0L;
+  /**
+   * <pre>
+   * Instance the verdict is about, 0 when it is about declared values alone.
+   * Its feature values are in `instances`, so a client can read what made it fail.
+   * </pre>
+   *
+   * <code>int64 instance_id = 6 [json_name = "instanceId"];</code>
+   * @return The instanceId.
+   */
+  @java.lang.Override
+  public long getInstanceId() {
+    return instanceId_;
+  }
+
+  public static final int INSTANCE_TYPE_ID_FIELD_NUMBER = 7;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object instanceTypeId_ = "";
+  /**
+   * <pre>
+   * Type of `instance_id`, as an FQN, for naming the object in a report.
+   * </pre>
+   *
+   * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
+   * @return The instanceTypeId.
+   */
+  @java.lang.Override
+  public java.lang.String getInstanceTypeId() {
+    java.lang.Object ref = instanceTypeId_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      instanceTypeId_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * Type of `instance_id`, as an FQN, for naming the object in a report.
+   * </pre>
+   *
+   * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
+   * @return The bytes for instanceTypeId.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getInstanceTypeIdBytes() {
+    java.lang.Object ref = instanceTypeId_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      instanceTypeId_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int ERROR_FIELD_NUMBER = 8;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object error_ = "";
+  /**
+   * <pre>
+   * Set when evaluation failed rather than the model answering false: unbound
+   * features, incommensurable units, an exhausted step budget. `holds` is then
+   * false but is not a verdict.
+   * </pre>
+   *
+   * <code>string error = 8 [json_name = "error"];</code>
+   * @return The error.
+   */
+  @java.lang.Override
+  public java.lang.String getError() {
+    java.lang.Object ref = error_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      error_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * Set when evaluation failed rather than the model answering false: unbound
+   * features, incommensurable units, an exhausted step budget. `holds` is then
+   * false but is not a verdict.
+   * </pre>
+   *
+   * <code>string error = 8 [json_name = "error"];</code>
+   * @return The bytes for error.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getErrorBytes() {
+    java.lang.Object ref = error_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      error_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int FAILURE_REASON_FIELD_NUMBER = 9;
+  private int failureReason_ = 0;
+  /**
+   * <pre>
+   * What kind of failure `error` reports. WRONG_KIND says the symbol named
+   * declares something else, which is a wrong request rather than an undecided
+   * verdict about the model.
+   * </pre>
+   *
+   * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
+   * @return The enum numeric value on the wire for failureReason.
+   */
+  @java.lang.Override public int getFailureReasonValue() {
+    return failureReason_;
+  }
+  /**
+   * <pre>
+   * What kind of failure `error` reports. WRONG_KIND says the symbol named
+   * declares something else, which is a wrong request rather than an undecided
+   * verdict about the model.
+   * </pre>
+   *
+   * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
+   * @return The failureReason.
+   */
+  @java.lang.Override public org.openmbee.opensysml.proto.FailureReason getFailureReason() {
+    org.openmbee.opensysml.proto.FailureReason result = org.openmbee.opensysml.proto.FailureReason.forNumber(failureReason_);
+    return result == null ? org.openmbee.opensysml.proto.FailureReason.UNRECOGNIZED : result;
+  }
+
+  public static final int REQUIREMENT_ID_FIELD_NUMBER = 10;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object requirementId_ = "";
+  /**
+   * <pre>
+   * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
+   * associates it with the verification_verdicts reported for that requirement.
+   * Empty for every other kind, and for a requirement no FQN names.
+   * </pre>
+   *
+   * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
+   * @return The requirementId.
+   */
+  @java.lang.Override
+  public java.lang.String getRequirementId() {
+    java.lang.Object ref = requirementId_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      requirementId_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
+   * associates it with the verification_verdicts reported for that requirement.
+   * Empty for every other kind, and for a requirement no FQN names.
+   * </pre>
+   *
+   * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
+   * @return The bytes for requirementId.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getRequirementIdBytes() {
+    java.lang.Object ref = requirementId_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      requirementId_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int ENGINE_FIELD_NUMBER = 11;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object engine_ = "";
+  /**
+   * <pre>
+   * The engine whose answer this verdict is, as ListEngines names it; empty
+   * for a verdict decided before any engine was asked. Reported as the
+   * "engines" capability, with `strength` and `bounds`.
+   * </pre>
+   *
+   * <code>string engine = 11 [json_name = "engine"];</code>
+   * @return The engine.
+   */
+  @java.lang.Override
+  public java.lang.String getEngine() {
+    java.lang.Object ref = engine_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      engine_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The engine whose answer this verdict is, as ListEngines names it; empty
+   * for a verdict decided before any engine was asked. Reported as the
+   * "engines" capability, with `strength` and `bounds`.
+   * </pre>
+   *
+   * <code>string engine = 11 [json_name = "engine"];</code>
+   * @return The bytes for engine.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getEngineBytes() {
+    java.lang.Object ref = engine_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      engine_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int STRENGTH_FIELD_NUMBER = 12;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object strength_ = "";
+  /**
+   * <pre>
+   * The strength of the evidence behind the verdict: "observed" for one run,
+   * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
+   * search within the bounds, "proved" for every case, "not covered" when no
+   * engine decided it.
+   * </pre>
+   *
+   * <code>string strength = 12 [json_name = "strength"];</code>
+   * @return The strength.
+   */
+  @java.lang.Override
+  public java.lang.String getStrength() {
+    java.lang.Object ref = strength_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      strength_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * The strength of the evidence behind the verdict: "observed" for one run,
+   * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
+   * search within the bounds, "proved" for every case, "not covered" when no
+   * engine decided it.
+   * </pre>
+   *
+   * <code>string strength = 12 [json_name = "strength"];</code>
+   * @return The bytes for strength.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getStrengthBytes() {
+    java.lang.Object ref = strength_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      strength_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  public static final int BOUNDS_FIELD_NUMBER = 13;
+  @SuppressWarnings("serial")
+  private java.util.List<org.openmbee.opensysml.proto.Bound> bounds_;
+  /**
+   * <pre>
+   * The bounds the engine ran under, each marked when it stopped the run.
+   * </pre>
+   *
+   * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<org.openmbee.opensysml.proto.Bound> getBoundsList() {
+    return bounds_;
+  }
+  /**
+   * <pre>
+   * The bounds the engine ran under, each marked when it stopped the run.
+   * </pre>
+   *
+   * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+   */
+  @java.lang.Override
+  public java.util.List<? extends org.openmbee.opensysml.proto.BoundOrBuilder> 
+      getBoundsOrBuilderList() {
+    return bounds_;
+  }
+  /**
+   * <pre>
+   * The bounds the engine ran under, each marked when it stopped the run.
+   * </pre>
+   *
+   * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+   */
+  @java.lang.Override
+  public int getBoundsCount() {
+    return bounds_.size();
+  }
+  /**
+   * <pre>
+   * The bounds the engine ran under, each marked when it stopped the run.
+   * </pre>
+   *
+   * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Bound getBounds(int index) {
+    return bounds_.get(index);
+  }
+  /**
+   * <pre>
+   * The bounds the engine ran under, each marked when it stopped the run.
+   * </pre>
+   *
+   * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+   */
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.BoundOrBuilder getBoundsOrBuilder(
+      int index) {
+    return bounds_.get(index);
+  }
+
+  public static final int INSTANCE_PATH_FIELD_NUMBER = 14;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object instancePath_ = "";
+  /**
+   * <pre>
+   * For a verdict ValidateInstance reports: the path from the validated object
+   * to the object this verdict is about, as the REPL spells it ("engine",
+   * "wheels[2]", "engine.injector"). Empty for the validated object itself,
+   * and for every other RPC.
+   * </pre>
+   *
+   * <code>string instance_path = 14 [json_name = "instancePath"];</code>
+   * @return The instancePath.
+   */
+  @java.lang.Override
+  public java.lang.String getInstancePath() {
+    java.lang.Object ref = instancePath_;
+    if (ref instanceof java.lang.String) {
+      return (java.lang.String) ref;
+    } else {
+      com.google.protobuf.ByteString bs = 
+          (com.google.protobuf.ByteString) ref;
+      java.lang.String s = bs.toStringUtf8();
+      instancePath_ = s;
+      return s;
+    }
+  }
+  /**
+   * <pre>
+   * For a verdict ValidateInstance reports: the path from the validated object
+   * to the object this verdict is about, as the REPL spells it ("engine",
+   * "wheels[2]", "engine.injector"). Empty for the validated object itself,
+   * and for every other RPC.
+   * </pre>
+   *
+   * <code>string instance_path = 14 [json_name = "instancePath"];</code>
+   * @return The bytes for instancePath.
+   */
+  @java.lang.Override
+  public com.google.protobuf.ByteString
+      getInstancePathBytes() {
+    java.lang.Object ref = instancePath_;
+    if (ref instanceof java.lang.String) {
+      com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString.copyFromUtf8(
+              (java.lang.String) ref);
+      instancePath_ = b;
+      return b;
+    } else {
+      return (com.google.protobuf.ByteString) ref;
+    }
+  }
+
+  private byte memoizedIsInitialized = -1;
+  @java.lang.Override
+  public final boolean isInitialized() {
+    byte isInitialized = memoizedIsInitialized;
+    if (isInitialized == 1) return true;
+    if (isInitialized == 0) return false;
+
+    memoizedIsInitialized = 1;
+    return true;
+  }
+
+  @java.lang.Override
+  public void writeTo(com.google.protobuf.CodedOutputStream output)
+                      throws java.io.IOException {
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(kind_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 1, kind_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(elementId_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 2, elementId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(element_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 3, element_);
+    }
+    if (holds_ != false) {
+      output.writeBool(4, holds_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(condition_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 5, condition_);
+    }
+    if (instanceId_ != 0L) {
+      output.writeInt64(6, instanceId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(instanceTypeId_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 7, instanceTypeId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 8, error_);
+    }
+    if (failureReason_ != org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_UNSPECIFIED.getNumber()) {
+      output.writeEnum(9, failureReason_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(requirementId_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 10, requirementId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(engine_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 11, engine_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(strength_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 12, strength_);
+    }
+    for (int i = 0; i < bounds_.size(); i++) {
+      output.writeMessage(13, bounds_.get(i));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(instancePath_)) {
+      com.google.protobuf.GeneratedMessage.writeString(output, 14, instancePath_);
+    }
+    getUnknownFields().writeTo(output);
+  }
+
+  @java.lang.Override
+  public int getSerializedSize() {
+    int size = memoizedSize;
+    if (size != -1) return size;
+
+    size = 0;
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(kind_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, kind_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(elementId_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, elementId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(element_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(3, element_);
+    }
+    if (holds_ != false) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeBoolSize(4, holds_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(condition_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(5, condition_);
+    }
+    if (instanceId_ != 0L) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeInt64Size(6, instanceId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(instanceTypeId_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(7, instanceTypeId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(8, error_);
+    }
+    if (failureReason_ != org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_UNSPECIFIED.getNumber()) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeEnumSize(9, failureReason_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(requirementId_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(10, requirementId_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(engine_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(11, engine_);
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(strength_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(12, strength_);
+    }
+    for (int i = 0; i < bounds_.size(); i++) {
+      size += com.google.protobuf.CodedOutputStream
+        .computeMessageSize(13, bounds_.get(i));
+    }
+    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(instancePath_)) {
+      size += com.google.protobuf.GeneratedMessage.computeStringSize(14, instancePath_);
+    }
+    size += getUnknownFields().getSerializedSize();
+    memoizedSize = size;
+    return size;
+  }
+
+  @java.lang.Override
+  public boolean equals(final java.lang.Object obj) {
+    if (obj == this) {
+     return true;
+    }
+    if (!(obj instanceof org.openmbee.opensysml.proto.Verdict)) {
+      return super.equals(obj);
+    }
+    org.openmbee.opensysml.proto.Verdict other = (org.openmbee.opensysml.proto.Verdict) obj;
+
+    if (!getKind()
+        .equals(other.getKind())) return false;
+    if (!getElementId()
+        .equals(other.getElementId())) return false;
+    if (!getElement()
+        .equals(other.getElement())) return false;
+    if (getHolds()
+        != other.getHolds()) return false;
+    if (!getCondition()
+        .equals(other.getCondition())) return false;
+    if (getInstanceId()
+        != other.getInstanceId()) return false;
+    if (!getInstanceTypeId()
+        .equals(other.getInstanceTypeId())) return false;
+    if (!getError()
+        .equals(other.getError())) return false;
+    if (failureReason_ != other.failureReason_) return false;
+    if (!getRequirementId()
+        .equals(other.getRequirementId())) return false;
+    if (!getEngine()
+        .equals(other.getEngine())) return false;
+    if (!getStrength()
+        .equals(other.getStrength())) return false;
+    if (!getBoundsList()
+        .equals(other.getBoundsList())) return false;
+    if (!getInstancePath()
+        .equals(other.getInstancePath())) return false;
+    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
+    return true;
+  }
+
+  @java.lang.Override
+  public int hashCode() {
+    if (memoizedHashCode != 0) {
+      return memoizedHashCode;
+    }
+    int hash = 41;
+    hash = (19 * hash) + getDescriptor().hashCode();
+    hash = (37 * hash) + KIND_FIELD_NUMBER;
+    hash = (53 * hash) + getKind().hashCode();
+    hash = (37 * hash) + ELEMENT_ID_FIELD_NUMBER;
+    hash = (53 * hash) + getElementId().hashCode();
+    hash = (37 * hash) + ELEMENT_FIELD_NUMBER;
+    hash = (53 * hash) + getElement().hashCode();
+    hash = (37 * hash) + HOLDS_FIELD_NUMBER;
+    hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean(
+        getHolds());
+    hash = (37 * hash) + CONDITION_FIELD_NUMBER;
+    hash = (53 * hash) + getCondition().hashCode();
+    hash = (37 * hash) + INSTANCE_ID_FIELD_NUMBER;
+    hash = (53 * hash) + com.google.protobuf.Internal.hashLong(
+        getInstanceId());
+    hash = (37 * hash) + INSTANCE_TYPE_ID_FIELD_NUMBER;
+    hash = (53 * hash) + getInstanceTypeId().hashCode();
+    hash = (37 * hash) + ERROR_FIELD_NUMBER;
+    hash = (53 * hash) + getError().hashCode();
+    hash = (37 * hash) + FAILURE_REASON_FIELD_NUMBER;
+    hash = (53 * hash) + failureReason_;
+    hash = (37 * hash) + REQUIREMENT_ID_FIELD_NUMBER;
+    hash = (53 * hash) + getRequirementId().hashCode();
+    hash = (37 * hash) + ENGINE_FIELD_NUMBER;
+    hash = (53 * hash) + getEngine().hashCode();
+    hash = (37 * hash) + STRENGTH_FIELD_NUMBER;
+    hash = (53 * hash) + getStrength().hashCode();
+    if (getBoundsCount() > 0) {
+      hash = (37 * hash) + BOUNDS_FIELD_NUMBER;
+      hash = (53 * hash) + getBoundsList().hashCode();
+    }
+    hash = (37 * hash) + INSTANCE_PATH_FIELD_NUMBER;
+    hash = (53 * hash) + getInstancePath().hashCode();
+    hash = (29 * hash) + getUnknownFields().hashCode();
+    memoizedHashCode = hash;
+    return hash;
+  }
+
+  public static org.openmbee.opensysml.proto.Verdict parseFrom(
+      java.nio.ByteBuffer data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.Verdict parseFrom(
+      java.nio.ByteBuffer data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Verdict parseFrom(
+      com.google.protobuf.ByteString data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.Verdict parseFrom(
+      com.google.protobuf.ByteString data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Verdict parseFrom(byte[] data)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data);
+  }
+  public static org.openmbee.opensysml.proto.Verdict parseFrom(
+      byte[] data,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws com.google.protobuf.InvalidProtocolBufferException {
+    return PARSER.parseFrom(data, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Verdict parseFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.Verdict parseFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  public static org.openmbee.opensysml.proto.Verdict parseDelimitedFrom(java.io.InputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input);
+  }
+
+  public static org.openmbee.opensysml.proto.Verdict parseDelimitedFrom(
+      java.io.InputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
+  }
+  public static org.openmbee.opensysml.proto.Verdict parseFrom(
+      com.google.protobuf.CodedInputStream input)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input);
+  }
+  public static org.openmbee.opensysml.proto.Verdict parseFrom(
+      com.google.protobuf.CodedInputStream input,
+      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+      throws java.io.IOException {
+    return com.google.protobuf.GeneratedMessage
+        .parseWithIOException(PARSER, input, extensionRegistry);
+  }
+
+  @java.lang.Override
+  public Builder newBuilderForType() { return newBuilder(); }
+  public static Builder newBuilder() {
+    return DEFAULT_INSTANCE.toBuilder();
+  }
+  public static Builder newBuilder(org.openmbee.opensysml.proto.Verdict prototype) {
+    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
+  }
+  @java.lang.Override
+  public Builder toBuilder() {
+    return this == DEFAULT_INSTANCE
+        ? new Builder() : new Builder().mergeFrom(this);
+  }
+
+  @java.lang.Override
+  protected Builder newBuilderForType(
+      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+    Builder builder = new Builder(parent);
+    return builder;
+  }
+  /**
+   * <pre>
+   * Verdict is one verification's answer: whether the condition held, and, when
+   * it did not, which condition the model answered false about.
+   * </pre>
+   *
+   * Protobuf type {@code sysml.Verdict}
+   */
+  public static final class Builder extends
+      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
+      // @@protoc_insertion_point(builder_implements:sysml.Verdict)
+      org.openmbee.opensysml.proto.VerdictOrBuilder {
+    public static final com.google.protobuf.Descriptors.Descriptor
+        getDescriptor() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Verdict_descriptor;
+    }
+
+    @java.lang.Override
+    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
+        internalGetFieldAccessorTable() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Verdict_fieldAccessorTable
+          .ensureFieldAccessorsInitialized(
+              org.openmbee.opensysml.proto.Verdict.class, org.openmbee.opensysml.proto.Verdict.Builder.class);
+    }
+
+    // Construct using org.openmbee.opensysml.proto.Verdict.newBuilder()
+    private Builder() {
+
+    }
+
+    private Builder(
+        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
+      super(parent);
+
+    }
+    @java.lang.Override
+    public Builder clear() {
+      super.clear();
+      bitField0_ = 0;
+      kind_ = "";
+      elementId_ = "";
+      element_ = "";
+      holds_ = false;
+      condition_ = "";
+      instanceId_ = 0L;
+      instanceTypeId_ = "";
+      error_ = "";
+      failureReason_ = 0;
+      requirementId_ = "";
+      engine_ = "";
+      strength_ = "";
+      if (boundsBuilder_ == null) {
+        bounds_ = java.util.Collections.emptyList();
+      } else {
+        bounds_ = null;
+        boundsBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00001000);
+      instancePath_ = "";
+      return this;
+    }
+
+    @java.lang.Override
+    public com.google.protobuf.Descriptors.Descriptor
+        getDescriptorForType() {
+      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Verdict_descriptor;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Verdict getDefaultInstanceForType() {
+      return org.openmbee.opensysml.proto.Verdict.getDefaultInstance();
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Verdict build() {
+      org.openmbee.opensysml.proto.Verdict result = buildPartial();
+      if (!result.isInitialized()) {
+        throw newUninitializedMessageException(result);
+      }
+      return result;
+    }
+
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.Verdict buildPartial() {
+      org.openmbee.opensysml.proto.Verdict result = new org.openmbee.opensysml.proto.Verdict(this);
+      buildPartialRepeatedFields(result);
+      if (bitField0_ != 0) { buildPartial0(result); }
+      onBuilt();
+      return result;
+    }
+
+    private void buildPartialRepeatedFields(org.openmbee.opensysml.proto.Verdict result) {
+      if (boundsBuilder_ == null) {
+        if (((bitField0_ & 0x00001000) != 0)) {
+          bounds_ = java.util.Collections.unmodifiableList(bounds_);
+          bitField0_ = (bitField0_ & ~0x00001000);
+        }
+        result.bounds_ = bounds_;
+      } else {
+        result.bounds_ = boundsBuilder_.build();
+      }
+    }
+
+    private void buildPartial0(org.openmbee.opensysml.proto.Verdict result) {
+      int from_bitField0_ = bitField0_;
+      if (((from_bitField0_ & 0x00000001) != 0)) {
+        result.kind_ = kind_;
+      }
+      if (((from_bitField0_ & 0x00000002) != 0)) {
+        result.elementId_ = elementId_;
+      }
+      if (((from_bitField0_ & 0x00000004) != 0)) {
+        result.element_ = element_;
+      }
+      if (((from_bitField0_ & 0x00000008) != 0)) {
+        result.holds_ = holds_;
+      }
+      if (((from_bitField0_ & 0x00000010) != 0)) {
+        result.condition_ = condition_;
+      }
+      if (((from_bitField0_ & 0x00000020) != 0)) {
+        result.instanceId_ = instanceId_;
+      }
+      if (((from_bitField0_ & 0x00000040) != 0)) {
+        result.instanceTypeId_ = instanceTypeId_;
+      }
+      if (((from_bitField0_ & 0x00000080) != 0)) {
+        result.error_ = error_;
+      }
+      if (((from_bitField0_ & 0x00000100) != 0)) {
+        result.failureReason_ = failureReason_;
+      }
+      if (((from_bitField0_ & 0x00000200) != 0)) {
+        result.requirementId_ = requirementId_;
+      }
+      if (((from_bitField0_ & 0x00000400) != 0)) {
+        result.engine_ = engine_;
+      }
+      if (((from_bitField0_ & 0x00000800) != 0)) {
+        result.strength_ = strength_;
+      }
+      if (((from_bitField0_ & 0x00002000) != 0)) {
+        result.instancePath_ = instancePath_;
+      }
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(com.google.protobuf.Message other) {
+      if (other instanceof org.openmbee.opensysml.proto.Verdict) {
+        return mergeFrom((org.openmbee.opensysml.proto.Verdict)other);
+      } else {
+        super.mergeFrom(other);
+        return this;
+      }
+    }
+
+    public Builder mergeFrom(org.openmbee.opensysml.proto.Verdict other) {
+      if (other == org.openmbee.opensysml.proto.Verdict.getDefaultInstance()) return this;
+      if (!other.getKind().isEmpty()) {
+        kind_ = other.kind_;
+        bitField0_ |= 0x00000001;
+        onChanged();
+      }
+      if (!other.getElementId().isEmpty()) {
+        elementId_ = other.elementId_;
+        bitField0_ |= 0x00000002;
+        onChanged();
+      }
+      if (!other.getElement().isEmpty()) {
+        element_ = other.element_;
+        bitField0_ |= 0x00000004;
+        onChanged();
+      }
+      if (other.getHolds() != false) {
+        setHolds(other.getHolds());
+      }
+      if (!other.getCondition().isEmpty()) {
+        condition_ = other.condition_;
+        bitField0_ |= 0x00000010;
+        onChanged();
+      }
+      if (other.getInstanceId() != 0L) {
+        setInstanceId(other.getInstanceId());
+      }
+      if (!other.getInstanceTypeId().isEmpty()) {
+        instanceTypeId_ = other.instanceTypeId_;
+        bitField0_ |= 0x00000040;
+        onChanged();
+      }
+      if (!other.getError().isEmpty()) {
+        error_ = other.error_;
+        bitField0_ |= 0x00000080;
+        onChanged();
+      }
+      if (other.failureReason_ != 0) {
+        setFailureReasonValue(other.getFailureReasonValue());
+      }
+      if (!other.getRequirementId().isEmpty()) {
+        requirementId_ = other.requirementId_;
+        bitField0_ |= 0x00000200;
+        onChanged();
+      }
+      if (!other.getEngine().isEmpty()) {
+        engine_ = other.engine_;
+        bitField0_ |= 0x00000400;
+        onChanged();
+      }
+      if (!other.getStrength().isEmpty()) {
+        strength_ = other.strength_;
+        bitField0_ |= 0x00000800;
+        onChanged();
+      }
+      if (boundsBuilder_ == null) {
+        if (!other.bounds_.isEmpty()) {
+          if (bounds_.isEmpty()) {
+            bounds_ = other.bounds_;
+            bitField0_ = (bitField0_ & ~0x00001000);
+          } else {
+            ensureBoundsIsMutable();
+            bounds_.addAll(other.bounds_);
+          }
+          onChanged();
+        }
+      } else {
+        if (!other.bounds_.isEmpty()) {
+          if (boundsBuilder_.isEmpty()) {
+            boundsBuilder_.dispose();
+            boundsBuilder_ = null;
+            bounds_ = other.bounds_;
+            bitField0_ = (bitField0_ & ~0x00001000);
+            boundsBuilder_ = 
+              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
+                 internalGetBoundsFieldBuilder() : null;
+          } else {
+            boundsBuilder_.addAllMessages(other.bounds_);
+          }
+        }
+      }
+      if (!other.getInstancePath().isEmpty()) {
+        instancePath_ = other.instancePath_;
+        bitField0_ |= 0x00002000;
+        onChanged();
+      }
+      this.mergeUnknownFields(other.getUnknownFields());
+      onChanged();
+      return this;
+    }
+
+    @java.lang.Override
+    public final boolean isInitialized() {
+      return true;
+    }
+
+    @java.lang.Override
+    public Builder mergeFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws java.io.IOException {
+      if (extensionRegistry == null) {
+        throw new java.lang.NullPointerException();
+      }
+      try {
+        boolean done = false;
+        while (!done) {
+          int tag = input.readTag();
+          switch (tag) {
+            case 0:
+              done = true;
+              break;
+            case 10: {
+              kind_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000001;
+              break;
+            } // case 10
+            case 18: {
+              elementId_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000002;
+              break;
+            } // case 18
+            case 26: {
+              element_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000004;
+              break;
+            } // case 26
+            case 32: {
+              holds_ = input.readBool();
+              bitField0_ |= 0x00000008;
+              break;
+            } // case 32
+            case 42: {
+              condition_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000010;
+              break;
+            } // case 42
+            case 48: {
+              instanceId_ = input.readInt64();
+              bitField0_ |= 0x00000020;
+              break;
+            } // case 48
+            case 58: {
+              instanceTypeId_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000040;
+              break;
+            } // case 58
+            case 66: {
+              error_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000080;
+              break;
+            } // case 66
+            case 72: {
+              failureReason_ = input.readEnum();
+              bitField0_ |= 0x00000100;
+              break;
+            } // case 72
+            case 82: {
+              requirementId_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000200;
+              break;
+            } // case 82
+            case 90: {
+              engine_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000400;
+              break;
+            } // case 90
+            case 98: {
+              strength_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000800;
+              break;
+            } // case 98
+            case 106: {
+              org.openmbee.opensysml.proto.Bound m =
+                  input.readMessage(
+                      org.openmbee.opensysml.proto.Bound.parser(),
+                      extensionRegistry);
+              if (boundsBuilder_ == null) {
+                ensureBoundsIsMutable();
+                bounds_.add(m);
+              } else {
+                boundsBuilder_.addMessage(m);
+              }
+              break;
+            } // case 106
+            case 114: {
+              instancePath_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00002000;
+              break;
+            } // case 114
+            default: {
+              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
+                done = true; // was an endgroup tag
+              }
+              break;
+            } // default:
+          } // switch (tag)
+        } // while (!done)
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.unwrapIOException();
+      } finally {
+        onChanged();
+      } // finally
+      return this;
+    }
+    private int bitField0_;
+
+    private java.lang.Object kind_ = "";
+    /**
+     * <pre>
+     * What was verified: "constraint", "requirement" or "satisfy"; for a check
+     * an analysis case run made, "objective" or "assertion"; for the summary
+     * of a ValidateInstance, "object".
+     * </pre>
+     *
+     * <code>string kind = 1 [json_name = "kind"];</code>
+     * @return The kind.
+     */
+    public java.lang.String getKind() {
+      java.lang.Object ref = kind_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        kind_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * What was verified: "constraint", "requirement" or "satisfy"; for a check
+     * an analysis case run made, "objective" or "assertion"; for the summary
+     * of a ValidateInstance, "object".
+     * </pre>
+     *
+     * <code>string kind = 1 [json_name = "kind"];</code>
+     * @return The bytes for kind.
+     */
+    public com.google.protobuf.ByteString
+        getKindBytes() {
+      java.lang.Object ref = kind_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        kind_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * What was verified: "constraint", "requirement" or "satisfy"; for a check
+     * an analysis case run made, "objective" or "assertion"; for the summary
+     * of a ValidateInstance, "object".
+     * </pre>
+     *
+     * <code>string kind = 1 [json_name = "kind"];</code>
+     * @param value The kind to set.
+     * @return This builder for chaining.
+     */
+    public Builder setKind(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      kind_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * What was verified: "constraint", "requirement" or "satisfy"; for a check
+     * an analysis case run made, "objective" or "assertion"; for the summary
+     * of a ValidateInstance, "object".
+     * </pre>
+     *
+     * <code>string kind = 1 [json_name = "kind"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearKind() {
+      kind_ = getDefaultInstance().getKind();
+      bitField0_ = (bitField0_ & ~0x00000001);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * What was verified: "constraint", "requirement" or "satisfy"; for a check
+     * an analysis case run made, "objective" or "assertion"; for the summary
+     * of a ValidateInstance, "object".
+     * </pre>
+     *
+     * <code>string kind = 1 [json_name = "kind"];</code>
+     * @param value The bytes for kind to set.
+     * @return This builder for chaining.
+     */
+    public Builder setKindBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      kind_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object elementId_ = "";
+    /**
+     * <pre>
+     * FQN of the element verified; empty for an anonymous satisfy assertion or
+     * an anonymous assertion in a case body.
+     * </pre>
+     *
+     * <code>string element_id = 2 [json_name = "elementId"];</code>
+     * @return The elementId.
+     */
+    public java.lang.String getElementId() {
+      java.lang.Object ref = elementId_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        elementId_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * FQN of the element verified; empty for an anonymous satisfy assertion or
+     * an anonymous assertion in a case body.
+     * </pre>
+     *
+     * <code>string element_id = 2 [json_name = "elementId"];</code>
+     * @return The bytes for elementId.
+     */
+    public com.google.protobuf.ByteString
+        getElementIdBytes() {
+      java.lang.Object ref = elementId_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        elementId_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * FQN of the element verified; empty for an anonymous satisfy assertion or
+     * an anonymous assertion in a case body.
+     * </pre>
+     *
+     * <code>string element_id = 2 [json_name = "elementId"];</code>
+     * @param value The elementId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setElementId(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      elementId_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * FQN of the element verified; empty for an anonymous satisfy assertion or
+     * an anonymous assertion in a case body.
+     * </pre>
+     *
+     * <code>string element_id = 2 [json_name = "elementId"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearElementId() {
+      elementId_ = getDefaultInstance().getElementId();
+      bitField0_ = (bitField0_ & ~0x00000002);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * FQN of the element verified; empty for an anonymous satisfy assertion or
+     * an anonymous assertion in a case body.
+     * </pre>
+     *
+     * <code>string element_id = 2 [json_name = "elementId"];</code>
+     * @param value The bytes for elementId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setElementIdBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      elementId_ = value;
+      bitField0_ |= 0x00000002;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object element_ = "";
+    /**
+     * <pre>
+     * The element as a reader names it: its FQN, or, for an anonymous satisfy
+     * assertion, the assertion as written ("satisfy Range by cruise"); for an
+     * objective or assertion of a case, its name or its condition as written.
+     * </pre>
+     *
+     * <code>string element = 3 [json_name = "element"];</code>
+     * @return The element.
+     */
+    public java.lang.String getElement() {
+      java.lang.Object ref = element_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        element_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The element as a reader names it: its FQN, or, for an anonymous satisfy
+     * assertion, the assertion as written ("satisfy Range by cruise"); for an
+     * objective or assertion of a case, its name or its condition as written.
+     * </pre>
+     *
+     * <code>string element = 3 [json_name = "element"];</code>
+     * @return The bytes for element.
+     */
+    public com.google.protobuf.ByteString
+        getElementBytes() {
+      java.lang.Object ref = element_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        element_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The element as a reader names it: its FQN, or, for an anonymous satisfy
+     * assertion, the assertion as written ("satisfy Range by cruise"); for an
+     * objective or assertion of a case, its name or its condition as written.
+     * </pre>
+     *
+     * <code>string element = 3 [json_name = "element"];</code>
+     * @param value The element to set.
+     * @return This builder for chaining.
+     */
+    public Builder setElement(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      element_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The element as a reader names it: its FQN, or, for an anonymous satisfy
+     * assertion, the assertion as written ("satisfy Range by cruise"); for an
+     * objective or assertion of a case, its name or its condition as written.
+     * </pre>
+     *
+     * <code>string element = 3 [json_name = "element"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearElement() {
+      element_ = getDefaultInstance().getElement();
+      bitField0_ = (bitField0_ & ~0x00000004);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The element as a reader names it: its FQN, or, for an anonymous satisfy
+     * assertion, the assertion as written ("satisfy Range by cruise"); for an
+     * objective or assertion of a case, its name or its condition as written.
+     * </pre>
+     *
+     * <code>string element = 3 [json_name = "element"];</code>
+     * @param value The bytes for element to set.
+     * @return This builder for chaining.
+     */
+    public Builder setElementBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      element_ = value;
+      bitField0_ |= 0x00000004;
+      onChanged();
+      return this;
+    }
+
+    private boolean holds_ ;
+    /**
+     * <pre>
+     * Whether the condition holds. False with an empty `error` is the model's own
+     * answer; false with an `error` is no answer at all.
+     * </pre>
+     *
+     * <code>bool holds = 4 [json_name = "holds"];</code>
+     * @return The holds.
+     */
+    @java.lang.Override
+    public boolean getHolds() {
+      return holds_;
+    }
+    /**
+     * <pre>
+     * Whether the condition holds. False with an empty `error` is the model's own
+     * answer; false with an `error` is no answer at all.
+     * </pre>
+     *
+     * <code>bool holds = 4 [json_name = "holds"];</code>
+     * @param value The holds to set.
+     * @return This builder for chaining.
+     */
+    public Builder setHolds(boolean value) {
+
+      holds_ = value;
+      bitField0_ |= 0x00000008;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Whether the condition holds. False with an empty `error` is the model's own
+     * answer; false with an `error` is no answer at all.
+     * </pre>
+     *
+     * <code>bool holds = 4 [json_name = "holds"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearHolds() {
+      bitField0_ = (bitField0_ & ~0x00000008);
+      holds_ = false;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object condition_ = "";
+    /**
+     * <pre>
+     * The condition that evaluated to false, as written, when the runtime names
+     * one. Empty when the verdict holds or the condition is not named.
+     * </pre>
+     *
+     * <code>string condition = 5 [json_name = "condition"];</code>
+     * @return The condition.
+     */
+    public java.lang.String getCondition() {
+      java.lang.Object ref = condition_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        condition_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The condition that evaluated to false, as written, when the runtime names
+     * one. Empty when the verdict holds or the condition is not named.
+     * </pre>
+     *
+     * <code>string condition = 5 [json_name = "condition"];</code>
+     * @return The bytes for condition.
+     */
+    public com.google.protobuf.ByteString
+        getConditionBytes() {
+      java.lang.Object ref = condition_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        condition_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The condition that evaluated to false, as written, when the runtime names
+     * one. Empty when the verdict holds or the condition is not named.
+     * </pre>
+     *
+     * <code>string condition = 5 [json_name = "condition"];</code>
+     * @param value The condition to set.
+     * @return This builder for chaining.
+     */
+    public Builder setCondition(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      condition_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The condition that evaluated to false, as written, when the runtime names
+     * one. Empty when the verdict holds or the condition is not named.
+     * </pre>
+     *
+     * <code>string condition = 5 [json_name = "condition"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearCondition() {
+      condition_ = getDefaultInstance().getCondition();
+      bitField0_ = (bitField0_ & ~0x00000010);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The condition that evaluated to false, as written, when the runtime names
+     * one. Empty when the verdict holds or the condition is not named.
+     * </pre>
+     *
+     * <code>string condition = 5 [json_name = "condition"];</code>
+     * @param value The bytes for condition to set.
+     * @return This builder for chaining.
+     */
+    public Builder setConditionBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      condition_ = value;
+      bitField0_ |= 0x00000010;
+      onChanged();
+      return this;
+    }
+
+    private long instanceId_ ;
+    /**
+     * <pre>
+     * Instance the verdict is about, 0 when it is about declared values alone.
+     * Its feature values are in `instances`, so a client can read what made it fail.
+     * </pre>
+     *
+     * <code>int64 instance_id = 6 [json_name = "instanceId"];</code>
+     * @return The instanceId.
+     */
+    @java.lang.Override
+    public long getInstanceId() {
+      return instanceId_;
+    }
+    /**
+     * <pre>
+     * Instance the verdict is about, 0 when it is about declared values alone.
+     * Its feature values are in `instances`, so a client can read what made it fail.
+     * </pre>
+     *
+     * <code>int64 instance_id = 6 [json_name = "instanceId"];</code>
+     * @param value The instanceId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setInstanceId(long value) {
+
+      instanceId_ = value;
+      bitField0_ |= 0x00000020;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Instance the verdict is about, 0 when it is about declared values alone.
+     * Its feature values are in `instances`, so a client can read what made it fail.
+     * </pre>
+     *
+     * <code>int64 instance_id = 6 [json_name = "instanceId"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearInstanceId() {
+      bitField0_ = (bitField0_ & ~0x00000020);
+      instanceId_ = 0L;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object instanceTypeId_ = "";
+    /**
+     * <pre>
+     * Type of `instance_id`, as an FQN, for naming the object in a report.
+     * </pre>
+     *
+     * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
+     * @return The instanceTypeId.
+     */
+    public java.lang.String getInstanceTypeId() {
+      java.lang.Object ref = instanceTypeId_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        instanceTypeId_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Type of `instance_id`, as an FQN, for naming the object in a report.
+     * </pre>
+     *
+     * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
+     * @return The bytes for instanceTypeId.
+     */
+    public com.google.protobuf.ByteString
+        getInstanceTypeIdBytes() {
+      java.lang.Object ref = instanceTypeId_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        instanceTypeId_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Type of `instance_id`, as an FQN, for naming the object in a report.
+     * </pre>
+     *
+     * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
+     * @param value The instanceTypeId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setInstanceTypeId(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      instanceTypeId_ = value;
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Type of `instance_id`, as an FQN, for naming the object in a report.
+     * </pre>
+     *
+     * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearInstanceTypeId() {
+      instanceTypeId_ = getDefaultInstance().getInstanceTypeId();
+      bitField0_ = (bitField0_ & ~0x00000040);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Type of `instance_id`, as an FQN, for naming the object in a report.
+     * </pre>
+     *
+     * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
+     * @param value The bytes for instanceTypeId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setInstanceTypeIdBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      instanceTypeId_ = value;
+      bitField0_ |= 0x00000040;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object error_ = "";
+    /**
+     * <pre>
+     * Set when evaluation failed rather than the model answering false: unbound
+     * features, incommensurable units, an exhausted step budget. `holds` is then
+     * false but is not a verdict.
+     * </pre>
+     *
+     * <code>string error = 8 [json_name = "error"];</code>
+     * @return The error.
+     */
+    public java.lang.String getError() {
+      java.lang.Object ref = error_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        error_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Set when evaluation failed rather than the model answering false: unbound
+     * features, incommensurable units, an exhausted step budget. `holds` is then
+     * false but is not a verdict.
+     * </pre>
+     *
+     * <code>string error = 8 [json_name = "error"];</code>
+     * @return The bytes for error.
+     */
+    public com.google.protobuf.ByteString
+        getErrorBytes() {
+      java.lang.Object ref = error_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        error_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * Set when evaluation failed rather than the model answering false: unbound
+     * features, incommensurable units, an exhausted step budget. `holds` is then
+     * false but is not a verdict.
+     * </pre>
+     *
+     * <code>string error = 8 [json_name = "error"];</code>
+     * @param value The error to set.
+     * @return This builder for chaining.
+     */
+    public Builder setError(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      error_ = value;
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Set when evaluation failed rather than the model answering false: unbound
+     * features, incommensurable units, an exhausted step budget. `holds` is then
+     * false but is not a verdict.
+     * </pre>
+     *
+     * <code>string error = 8 [json_name = "error"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearError() {
+      error_ = getDefaultInstance().getError();
+      bitField0_ = (bitField0_ & ~0x00000080);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * Set when evaluation failed rather than the model answering false: unbound
+     * features, incommensurable units, an exhausted step budget. `holds` is then
+     * false but is not a verdict.
+     * </pre>
+     *
+     * <code>string error = 8 [json_name = "error"];</code>
+     * @param value The bytes for error to set.
+     * @return This builder for chaining.
+     */
+    public Builder setErrorBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      error_ = value;
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+
+    private int failureReason_ = 0;
+    /**
+     * <pre>
+     * What kind of failure `error` reports. WRONG_KIND says the symbol named
+     * declares something else, which is a wrong request rather than an undecided
+     * verdict about the model.
+     * </pre>
+     *
+     * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
+     * @return The enum numeric value on the wire for failureReason.
+     */
+    @java.lang.Override public int getFailureReasonValue() {
+      return failureReason_;
+    }
+    /**
+     * <pre>
+     * What kind of failure `error` reports. WRONG_KIND says the symbol named
+     * declares something else, which is a wrong request rather than an undecided
+     * verdict about the model.
+     * </pre>
+     *
+     * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
+     * @param value The enum numeric value on the wire for failureReason to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFailureReasonValue(int value) {
+      failureReason_ = value;
+      bitField0_ |= 0x00000100;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * What kind of failure `error` reports. WRONG_KIND says the symbol named
+     * declares something else, which is a wrong request rather than an undecided
+     * verdict about the model.
+     * </pre>
+     *
+     * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
+     * @return The failureReason.
+     */
+    @java.lang.Override
+    public org.openmbee.opensysml.proto.FailureReason getFailureReason() {
+      org.openmbee.opensysml.proto.FailureReason result = org.openmbee.opensysml.proto.FailureReason.forNumber(failureReason_);
+      return result == null ? org.openmbee.opensysml.proto.FailureReason.UNRECOGNIZED : result;
+    }
+    /**
+     * <pre>
+     * What kind of failure `error` reports. WRONG_KIND says the symbol named
+     * declares something else, which is a wrong request rather than an undecided
+     * verdict about the model.
+     * </pre>
+     *
+     * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
+     * @param value The failureReason to set.
+     * @return This builder for chaining.
+     */
+    public Builder setFailureReason(org.openmbee.opensysml.proto.FailureReason value) {
+      if (value == null) { throw new NullPointerException(); }
+      bitField0_ |= 0x00000100;
+      failureReason_ = value.getNumber();
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * What kind of failure `error` reports. WRONG_KIND says the symbol named
+     * declares something else, which is a wrong request rather than an undecided
+     * verdict about the model.
+     * </pre>
+     *
+     * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearFailureReason() {
+      bitField0_ = (bitField0_ & ~0x00000100);
+      failureReason_ = 0;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object requirementId_ = "";
+    /**
+     * <pre>
+     * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
+     * associates it with the verification_verdicts reported for that requirement.
+     * Empty for every other kind, and for a requirement no FQN names.
+     * </pre>
+     *
+     * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
+     * @return The requirementId.
+     */
+    public java.lang.String getRequirementId() {
+      java.lang.Object ref = requirementId_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        requirementId_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
+     * associates it with the verification_verdicts reported for that requirement.
+     * Empty for every other kind, and for a requirement no FQN names.
+     * </pre>
+     *
+     * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
+     * @return The bytes for requirementId.
+     */
+    public com.google.protobuf.ByteString
+        getRequirementIdBytes() {
+      java.lang.Object ref = requirementId_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        requirementId_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
+     * associates it with the verification_verdicts reported for that requirement.
+     * Empty for every other kind, and for a requirement no FQN names.
+     * </pre>
+     *
+     * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
+     * @param value The requirementId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setRequirementId(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      requirementId_ = value;
+      bitField0_ |= 0x00000200;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
+     * associates it with the verification_verdicts reported for that requirement.
+     * Empty for every other kind, and for a requirement no FQN names.
+     * </pre>
+     *
+     * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearRequirementId() {
+      requirementId_ = getDefaultInstance().getRequirementId();
+      bitField0_ = (bitField0_ & ~0x00000200);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
+     * associates it with the verification_verdicts reported for that requirement.
+     * Empty for every other kind, and for a requirement no FQN names.
+     * </pre>
+     *
+     * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
+     * @param value The bytes for requirementId to set.
+     * @return This builder for chaining.
+     */
+    public Builder setRequirementIdBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      requirementId_ = value;
+      bitField0_ |= 0x00000200;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object engine_ = "";
+    /**
+     * <pre>
+     * The engine whose answer this verdict is, as ListEngines names it; empty
+     * for a verdict decided before any engine was asked. Reported as the
+     * "engines" capability, with `strength` and `bounds`.
+     * </pre>
+     *
+     * <code>string engine = 11 [json_name = "engine"];</code>
+     * @return The engine.
+     */
+    public java.lang.String getEngine() {
+      java.lang.Object ref = engine_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        engine_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The engine whose answer this verdict is, as ListEngines names it; empty
+     * for a verdict decided before any engine was asked. Reported as the
+     * "engines" capability, with `strength` and `bounds`.
+     * </pre>
+     *
+     * <code>string engine = 11 [json_name = "engine"];</code>
+     * @return The bytes for engine.
+     */
+    public com.google.protobuf.ByteString
+        getEngineBytes() {
+      java.lang.Object ref = engine_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        engine_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The engine whose answer this verdict is, as ListEngines names it; empty
+     * for a verdict decided before any engine was asked. Reported as the
+     * "engines" capability, with `strength` and `bounds`.
+     * </pre>
+     *
+     * <code>string engine = 11 [json_name = "engine"];</code>
+     * @param value The engine to set.
+     * @return This builder for chaining.
+     */
+    public Builder setEngine(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      engine_ = value;
+      bitField0_ |= 0x00000400;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The engine whose answer this verdict is, as ListEngines names it; empty
+     * for a verdict decided before any engine was asked. Reported as the
+     * "engines" capability, with `strength` and `bounds`.
+     * </pre>
+     *
+     * <code>string engine = 11 [json_name = "engine"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearEngine() {
+      engine_ = getDefaultInstance().getEngine();
+      bitField0_ = (bitField0_ & ~0x00000400);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The engine whose answer this verdict is, as ListEngines names it; empty
+     * for a verdict decided before any engine was asked. Reported as the
+     * "engines" capability, with `strength` and `bounds`.
+     * </pre>
+     *
+     * <code>string engine = 11 [json_name = "engine"];</code>
+     * @param value The bytes for engine to set.
+     * @return This builder for chaining.
+     */
+    public Builder setEngineBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      engine_ = value;
+      bitField0_ |= 0x00000400;
+      onChanged();
+      return this;
+    }
+
+    private java.lang.Object strength_ = "";
+    /**
+     * <pre>
+     * The strength of the evidence behind the verdict: "observed" for one run,
+     * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
+     * search within the bounds, "proved" for every case, "not covered" when no
+     * engine decided it.
+     * </pre>
+     *
+     * <code>string strength = 12 [json_name = "strength"];</code>
+     * @return The strength.
+     */
+    public java.lang.String getStrength() {
+      java.lang.Object ref = strength_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        strength_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The strength of the evidence behind the verdict: "observed" for one run,
+     * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
+     * search within the bounds, "proved" for every case, "not covered" when no
+     * engine decided it.
+     * </pre>
+     *
+     * <code>string strength = 12 [json_name = "strength"];</code>
+     * @return The bytes for strength.
+     */
+    public com.google.protobuf.ByteString
+        getStrengthBytes() {
+      java.lang.Object ref = strength_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        strength_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * The strength of the evidence behind the verdict: "observed" for one run,
+     * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
+     * search within the bounds, "proved" for every case, "not covered" when no
+     * engine decided it.
+     * </pre>
+     *
+     * <code>string strength = 12 [json_name = "strength"];</code>
+     * @param value The strength to set.
+     * @return This builder for chaining.
+     */
+    public Builder setStrength(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      strength_ = value;
+      bitField0_ |= 0x00000800;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The strength of the evidence behind the verdict: "observed" for one run,
+     * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
+     * search within the bounds, "proved" for every case, "not covered" when no
+     * engine decided it.
+     * </pre>
+     *
+     * <code>string strength = 12 [json_name = "strength"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearStrength() {
+      strength_ = getDefaultInstance().getStrength();
+      bitField0_ = (bitField0_ & ~0x00000800);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * The strength of the evidence behind the verdict: "observed" for one run,
+     * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
+     * search within the bounds, "proved" for every case, "not covered" when no
+     * engine decided it.
+     * </pre>
+     *
+     * <code>string strength = 12 [json_name = "strength"];</code>
+     * @param value The bytes for strength to set.
+     * @return This builder for chaining.
+     */
+    public Builder setStrengthBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      strength_ = value;
+      bitField0_ |= 0x00000800;
+      onChanged();
+      return this;
+    }
+
+    private java.util.List<org.openmbee.opensysml.proto.Bound> bounds_ =
+      java.util.Collections.emptyList();
+    private void ensureBoundsIsMutable() {
+      if (!((bitField0_ & 0x00001000) != 0)) {
+        bounds_ = new java.util.ArrayList<org.openmbee.opensysml.proto.Bound>(bounds_);
+        bitField0_ |= 0x00001000;
+       }
+    }
+
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Bound, org.openmbee.opensysml.proto.Bound.Builder, org.openmbee.opensysml.proto.BoundOrBuilder> boundsBuilder_;
+
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Bound> getBoundsList() {
+      if (boundsBuilder_ == null) {
+        return java.util.Collections.unmodifiableList(bounds_);
+      } else {
+        return boundsBuilder_.getMessageList();
+      }
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public int getBoundsCount() {
+      if (boundsBuilder_ == null) {
+        return bounds_.size();
+      } else {
+        return boundsBuilder_.getCount();
+      }
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public org.openmbee.opensysml.proto.Bound getBounds(int index) {
+      if (boundsBuilder_ == null) {
+        return bounds_.get(index);
+      } else {
+        return boundsBuilder_.getMessage(index);
+      }
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public Builder setBounds(
+        int index, org.openmbee.opensysml.proto.Bound value) {
+      if (boundsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureBoundsIsMutable();
+        bounds_.set(index, value);
+        onChanged();
+      } else {
+        boundsBuilder_.setMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public Builder setBounds(
+        int index, org.openmbee.opensysml.proto.Bound.Builder builderForValue) {
+      if (boundsBuilder_ == null) {
+        ensureBoundsIsMutable();
+        bounds_.set(index, builderForValue.build());
+        onChanged();
+      } else {
+        boundsBuilder_.setMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public Builder addBounds(org.openmbee.opensysml.proto.Bound value) {
+      if (boundsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureBoundsIsMutable();
+        bounds_.add(value);
+        onChanged();
+      } else {
+        boundsBuilder_.addMessage(value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public Builder addBounds(
+        int index, org.openmbee.opensysml.proto.Bound value) {
+      if (boundsBuilder_ == null) {
+        if (value == null) {
+          throw new NullPointerException();
+        }
+        ensureBoundsIsMutable();
+        bounds_.add(index, value);
+        onChanged();
+      } else {
+        boundsBuilder_.addMessage(index, value);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public Builder addBounds(
+        org.openmbee.opensysml.proto.Bound.Builder builderForValue) {
+      if (boundsBuilder_ == null) {
+        ensureBoundsIsMutable();
+        bounds_.add(builderForValue.build());
+        onChanged();
+      } else {
+        boundsBuilder_.addMessage(builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public Builder addBounds(
+        int index, org.openmbee.opensysml.proto.Bound.Builder builderForValue) {
+      if (boundsBuilder_ == null) {
+        ensureBoundsIsMutable();
+        bounds_.add(index, builderForValue.build());
+        onChanged();
+      } else {
+        boundsBuilder_.addMessage(index, builderForValue.build());
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public Builder addAllBounds(
+        java.lang.Iterable<? extends org.openmbee.opensysml.proto.Bound> values) {
+      if (boundsBuilder_ == null) {
+        ensureBoundsIsMutable();
+        com.google.protobuf.AbstractMessageLite.Builder.addAll(
+            values, bounds_);
+        onChanged();
+      } else {
+        boundsBuilder_.addAllMessages(values);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public Builder clearBounds() {
+      if (boundsBuilder_ == null) {
+        bounds_ = java.util.Collections.emptyList();
+        bitField0_ = (bitField0_ & ~0x00001000);
+        onChanged();
+      } else {
+        boundsBuilder_.clear();
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public Builder removeBounds(int index) {
+      if (boundsBuilder_ == null) {
+        ensureBoundsIsMutable();
+        bounds_.remove(index);
+        onChanged();
+      } else {
+        boundsBuilder_.remove(index);
+      }
+      return this;
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public org.openmbee.opensysml.proto.Bound.Builder getBoundsBuilder(
+        int index) {
+      return internalGetBoundsFieldBuilder().getBuilder(index);
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public org.openmbee.opensysml.proto.BoundOrBuilder getBoundsOrBuilder(
+        int index) {
+      if (boundsBuilder_ == null) {
+        return bounds_.get(index);  } else {
+        return boundsBuilder_.getMessageOrBuilder(index);
+      }
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public java.util.List<? extends org.openmbee.opensysml.proto.BoundOrBuilder> 
+         getBoundsOrBuilderList() {
+      if (boundsBuilder_ != null) {
+        return boundsBuilder_.getMessageOrBuilderList();
+      } else {
+        return java.util.Collections.unmodifiableList(bounds_);
+      }
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public org.openmbee.opensysml.proto.Bound.Builder addBoundsBuilder() {
+      return internalGetBoundsFieldBuilder().addBuilder(
+          org.openmbee.opensysml.proto.Bound.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public org.openmbee.opensysml.proto.Bound.Builder addBoundsBuilder(
+        int index) {
+      return internalGetBoundsFieldBuilder().addBuilder(
+          index, org.openmbee.opensysml.proto.Bound.getDefaultInstance());
+    }
+    /**
+     * <pre>
+     * The bounds the engine ran under, each marked when it stopped the run.
+     * </pre>
+     *
+     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
+     */
+    public java.util.List<org.openmbee.opensysml.proto.Bound.Builder> 
+         getBoundsBuilderList() {
+      return internalGetBoundsFieldBuilder().getBuilderList();
+    }
+    private com.google.protobuf.RepeatedFieldBuilder<
+        org.openmbee.opensysml.proto.Bound, org.openmbee.opensysml.proto.Bound.Builder, org.openmbee.opensysml.proto.BoundOrBuilder> 
+        internalGetBoundsFieldBuilder() {
+      if (boundsBuilder_ == null) {
+        boundsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
+            org.openmbee.opensysml.proto.Bound, org.openmbee.opensysml.proto.Bound.Builder, org.openmbee.opensysml.proto.BoundOrBuilder>(
+                bounds_,
+                ((bitField0_ & 0x00001000) != 0),
+                getParentForChildren(),
+                isClean());
+        bounds_ = null;
+      }
+      return boundsBuilder_;
+    }
+
+    private java.lang.Object instancePath_ = "";
+    /**
+     * <pre>
+     * For a verdict ValidateInstance reports: the path from the validated object
+     * to the object this verdict is about, as the REPL spells it ("engine",
+     * "wheels[2]", "engine.injector"). Empty for the validated object itself,
+     * and for every other RPC.
+     * </pre>
+     *
+     * <code>string instance_path = 14 [json_name = "instancePath"];</code>
+     * @return The instancePath.
+     */
+    public java.lang.String getInstancePath() {
+      java.lang.Object ref = instancePath_;
+      if (!(ref instanceof java.lang.String)) {
+        com.google.protobuf.ByteString bs =
+            (com.google.protobuf.ByteString) ref;
+        java.lang.String s = bs.toStringUtf8();
+        instancePath_ = s;
+        return s;
+      } else {
+        return (java.lang.String) ref;
+      }
+    }
+    /**
+     * <pre>
+     * For a verdict ValidateInstance reports: the path from the validated object
+     * to the object this verdict is about, as the REPL spells it ("engine",
+     * "wheels[2]", "engine.injector"). Empty for the validated object itself,
+     * and for every other RPC.
+     * </pre>
+     *
+     * <code>string instance_path = 14 [json_name = "instancePath"];</code>
+     * @return The bytes for instancePath.
+     */
+    public com.google.protobuf.ByteString
+        getInstancePathBytes() {
+      java.lang.Object ref = instancePath_;
+      if (ref instanceof String) {
+        com.google.protobuf.ByteString b = 
+            com.google.protobuf.ByteString.copyFromUtf8(
+                (java.lang.String) ref);
+        instancePath_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * <pre>
+     * For a verdict ValidateInstance reports: the path from the validated object
+     * to the object this verdict is about, as the REPL spells it ("engine",
+     * "wheels[2]", "engine.injector"). Empty for the validated object itself,
+     * and for every other RPC.
+     * </pre>
+     *
+     * <code>string instance_path = 14 [json_name = "instancePath"];</code>
+     * @param value The instancePath to set.
+     * @return This builder for chaining.
+     */
+    public Builder setInstancePath(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      instancePath_ = value;
+      bitField0_ |= 0x00002000;
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * For a verdict ValidateInstance reports: the path from the validated object
+     * to the object this verdict is about, as the REPL spells it ("engine",
+     * "wheels[2]", "engine.injector"). Empty for the validated object itself,
+     * and for every other RPC.
+     * </pre>
+     *
+     * <code>string instance_path = 14 [json_name = "instancePath"];</code>
+     * @return This builder for chaining.
+     */
+    public Builder clearInstancePath() {
+      instancePath_ = getDefaultInstance().getInstancePath();
+      bitField0_ = (bitField0_ & ~0x00002000);
+      onChanged();
+      return this;
+    }
+    /**
+     * <pre>
+     * For a verdict ValidateInstance reports: the path from the validated object
+     * to the object this verdict is about, as the REPL spells it ("engine",
+     * "wheels[2]", "engine.injector"). Empty for the validated object itself,
+     * and for every other RPC.
+     * </pre>
+     *
+     * <code>string instance_path = 14 [json_name = "instancePath"];</code>
+     * @param value The bytes for instancePath to set.
+     * @return This builder for chaining.
+     */
+    public Builder setInstancePathBytes(
+        com.google.protobuf.ByteString value) {
+      if (value == null) { throw new NullPointerException(); }
+      checkByteStringIsUtf8(value);
+      instancePath_ = value;
+      bitField0_ |= 0x00002000;
+      onChanged();
+      return this;
+    }
+
+    // @@protoc_insertion_point(builder_scope:sysml.Verdict)
+  }
+
+  // @@protoc_insertion_point(class_scope:sysml.Verdict)
+  private static final org.openmbee.opensysml.proto.Verdict DEFAULT_INSTANCE;
+  static {
+    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.Verdict();
+  }
+
+  public static org.openmbee.opensysml.proto.Verdict getDefaultInstance() {
+    return DEFAULT_INSTANCE;
+  }
+
+  private static final com.google.protobuf.Parser<Verdict>
+      PARSER = new com.google.protobuf.AbstractParser<Verdict>() {
+    @java.lang.Override
+    public Verdict parsePartialFrom(
+        com.google.protobuf.CodedInputStream input,
+        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
+        throws com.google.protobuf.InvalidProtocolBufferException {
+      Builder builder = newBuilder();
+      try {
+        builder.mergeFrom(input, extensionRegistry);
+      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
+        throw e.setUnfinishedMessage(builder.buildPartial());
+      } catch (com.google.protobuf.UninitializedMessageException e) {
+        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
+      } catch (java.io.IOException e) {
+        throw new com.google.protobuf.InvalidProtocolBufferException(e)
+            .setUnfinishedMessage(builder.buildPartial());
+      }
+      return builder.buildPartial();
+    }
+  };
+
+  public static com.google.protobuf.Parser<Verdict> parser() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public com.google.protobuf.Parser<Verdict> getParserForType() {
+    return PARSER;
+  }
+
+  @java.lang.Override
+  public org.openmbee.opensysml.proto.Verdict getDefaultInstanceForType() {
+    return DEFAULT_INSTANCE;
+  }
+
+}
+
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerdictOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerdictOrBuilder.java
similarity index 89%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerdictOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerdictOrBuilder.java
index d9496a7757..8c1d4a3068 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerdictOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerdictOrBuilder.java
@@ -13,7 +13,8 @@ public interface VerdictOrBuilder extends
   /**
    * <pre>
    * What was verified: "constraint", "requirement" or "satisfy"; for a check
-   * an analysis case run made, "objective" or "assertion".
+   * an analysis case run made, "objective" or "assertion"; for the summary
+   * of a ValidateInstance, "object".
    * </pre>
    *
    * <code>string kind = 1 [json_name = "kind"];</code>
@@ -23,7 +24,8 @@ public interface VerdictOrBuilder extends
   /**
    * <pre>
    * What was verified: "constraint", "requirement" or "satisfy"; for a check
-   * an analysis case run made, "objective" or "assertion".
+   * an analysis case run made, "objective" or "assertion"; for the summary
+   * of a ValidateInstance, "object".
    * </pre>
    *
    * <code>string kind = 1 [json_name = "kind"];</code>
@@ -306,4 +308,30 @@ public interface VerdictOrBuilder extends
    */
   org.openmbee.opensysml.proto.BoundOrBuilder getBoundsOrBuilder(
       int index);
+
+  /**
+   * <pre>
+   * For a verdict ValidateInstance reports: the path from the validated object
+   * to the object this verdict is about, as the REPL spells it ("engine",
+   * "wheels[2]", "engine.injector"). Empty for the validated object itself,
+   * and for every other RPC.
+   * </pre>
+   *
+   * <code>string instance_path = 14 [json_name = "instancePath"];</code>
+   * @return The instancePath.
+   */
+  java.lang.String getInstancePath();
+  /**
+   * <pre>
+   * For a verdict ValidateInstance reports: the path from the validated object
+   * to the object this verdict is about, as the REPL spells it ("engine",
+   * "wheels[2]", "engine.injector"). Empty for the validated object itself,
+   * and for every other RPC.
+   * </pre>
+   *
+   * <code>string instance_path = 14 [json_name = "instancePath"];</code>
+   * @return The bytes for instancePath.
+   */
+  com.google.protobuf.ByteString
+      getInstancePathBytes();
 }
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerificationVerdict.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerificationVerdict.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerificationVerdict.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerificationVerdict.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerificationVerdictOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerificationVerdictOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerificationVerdictOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerificationVerdictOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequest.java
similarity index 95%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequest.java
index 6795cc8be1..a841c97a4d 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequest.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequest.java
@@ -145,7 +145,10 @@ public java.lang.String getSymbolId() {
    * Optional FQN of a part/usage to instantiate and evaluate the constraint
    * against, so the verdict is about concrete values rather than declared
    * defaults. The prompt evaluates against the object a %instantiate created;
-   * a call carries no session, so it names the subject instead.
+   * a call carries no session, so it names the subject instead. A path from a
+   * declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+   * evaluates against the object the path reaches, inside the assembly built
+   * around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -169,7 +172,10 @@ public java.lang.String getSubjectSymbolId() {
    * Optional FQN of a part/usage to instantiate and evaluate the constraint
    * against, so the verdict is about concrete values rather than declared
    * defaults. The prompt evaluates against the object a %instantiate created;
-   * a call carries no session, so it names the subject instead.
+   * a call carries no session, so it names the subject instead. A path from a
+   * declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+   * evaluates against the object the path reaches, inside the assembly built
+   * around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -781,7 +787,10 @@ public Builder setSymbolIdBytes(
      * Optional FQN of a part/usage to instantiate and evaluate the constraint
      * against, so the verdict is about concrete values rather than declared
      * defaults. The prompt evaluates against the object a %instantiate created;
-     * a call carries no session, so it names the subject instead.
+     * a call carries no session, so it names the subject instead. A path from a
+     * declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+     * evaluates against the object the path reaches, inside the assembly built
+     * around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -804,7 +813,10 @@ public java.lang.String getSubjectSymbolId() {
      * Optional FQN of a part/usage to instantiate and evaluate the constraint
      * against, so the verdict is about concrete values rather than declared
      * defaults. The prompt evaluates against the object a %instantiate created;
-     * a call carries no session, so it names the subject instead.
+     * a call carries no session, so it names the subject instead. A path from a
+     * declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+     * evaluates against the object the path reaches, inside the assembly built
+     * around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -828,7 +840,10 @@ public java.lang.String getSubjectSymbolId() {
      * Optional FQN of a part/usage to instantiate and evaluate the constraint
      * against, so the verdict is about concrete values rather than declared
      * defaults. The prompt evaluates against the object a %instantiate created;
-     * a call carries no session, so it names the subject instead.
+     * a call carries no session, so it names the subject instead. A path from a
+     * declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+     * evaluates against the object the path reaches, inside the assembly built
+     * around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -848,7 +863,10 @@ public Builder setSubjectSymbolId(
      * Optional FQN of a part/usage to instantiate and evaluate the constraint
      * against, so the verdict is about concrete values rather than declared
      * defaults. The prompt evaluates against the object a %instantiate created;
-     * a call carries no session, so it names the subject instead.
+     * a call carries no session, so it names the subject instead. A path from a
+     * declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+     * evaluates against the object the path reaches, inside the assembly built
+     * around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -865,7 +883,10 @@ public Builder clearSubjectSymbolId() {
      * Optional FQN of a part/usage to instantiate and evaluate the constraint
      * against, so the verdict is about concrete values rather than declared
      * defaults. The prompt evaluates against the object a %instantiate created;
-     * a call carries no session, so it names the subject instead.
+     * a call carries no session, so it names the subject instead. A path from a
+     * declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+     * evaluates against the object the path reaches, inside the assembly built
+     * around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequestOrBuilder.java
similarity index 85%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequestOrBuilder.java
index 90be1cd0ed..54efb2d16e 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequestOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintRequestOrBuilder.java
@@ -47,7 +47,10 @@ public interface VerifyConstraintRequestOrBuilder extends
    * Optional FQN of a part/usage to instantiate and evaluate the constraint
    * against, so the verdict is about concrete values rather than declared
    * defaults. The prompt evaluates against the object a %instantiate created;
-   * a call carries no session, so it names the subject instead.
+   * a call carries no session, so it names the subject instead. A path from a
+   * declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+   * evaluates against the object the path reaches, inside the assembly built
+   * around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -59,7 +62,10 @@ public interface VerifyConstraintRequestOrBuilder extends
    * Optional FQN of a part/usage to instantiate and evaluate the constraint
    * against, so the verdict is about concrete values rather than declared
    * defaults. The prompt evaluates against the object a %instantiate created;
-   * a call carries no session, so it names the subject instead.
+   * a call carries no session, so it names the subject instead. A path from a
+   * declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+   * evaluates against the object the path reaches, inside the assembly built
+   * around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyConstraintResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequest.java
similarity index 97%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequest.java
index a5947e94fd..92bc408354 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequest.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequest.java
@@ -143,7 +143,8 @@ public java.lang.String getSymbolId() {
   private volatile java.lang.Object subjectSymbolId_ = "";
   /**
    * <pre>
-   * Optional FQN of a part/usage to instantiate and evaluate against.
+   * Optional FQN of a part/usage to instantiate and evaluate against, or a path
+   * from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -164,7 +165,8 @@ public java.lang.String getSubjectSymbolId() {
   }
   /**
    * <pre>
-   * Optional FQN of a part/usage to instantiate and evaluate against.
+   * Optional FQN of a part/usage to instantiate and evaluate against, or a path
+   * from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -770,7 +772,8 @@ public Builder setSymbolIdBytes(
     private java.lang.Object subjectSymbolId_ = "";
     /**
      * <pre>
-     * Optional FQN of a part/usage to instantiate and evaluate against.
+     * Optional FQN of a part/usage to instantiate and evaluate against, or a path
+     * from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -790,7 +793,8 @@ public java.lang.String getSubjectSymbolId() {
     }
     /**
      * <pre>
-     * Optional FQN of a part/usage to instantiate and evaluate against.
+     * Optional FQN of a part/usage to instantiate and evaluate against, or a path
+     * from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -811,7 +815,8 @@ public java.lang.String getSubjectSymbolId() {
     }
     /**
      * <pre>
-     * Optional FQN of a part/usage to instantiate and evaluate against.
+     * Optional FQN of a part/usage to instantiate and evaluate against, or a path
+     * from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -828,7 +833,8 @@ public Builder setSubjectSymbolId(
     }
     /**
      * <pre>
-     * Optional FQN of a part/usage to instantiate and evaluate against.
+     * Optional FQN of a part/usage to instantiate and evaluate against, or a path
+     * from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -842,7 +848,8 @@ public Builder clearSubjectSymbolId() {
     }
     /**
      * <pre>
-     * Optional FQN of a part/usage to instantiate and evaluate against.
+     * Optional FQN of a part/usage to instantiate and evaluate against, or a path
+     * from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
      * </pre>
      *
      * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequestOrBuilder.java
similarity index 92%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequestOrBuilder.java
index e2f8791add..36815d050a 100644
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequestOrBuilder.java
+++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementRequestOrBuilder.java
@@ -44,7 +44,8 @@ public interface VerifyRequirementRequestOrBuilder extends
 
   /**
    * <pre>
-   * Optional FQN of a part/usage to instantiate and evaluate against.
+   * Optional FQN of a part/usage to instantiate and evaluate against, or a path
+   * from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
@@ -53,7 +54,8 @@ public interface VerifyRequirementRequestOrBuilder extends
   java.lang.String getSubjectSymbolId();
   /**
    * <pre>
-   * Optional FQN of a part/usage to instantiate and evaluate against.
+   * Optional FQN of a part/usage to instantiate and evaluate against, or a path
+   * from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
    * </pre>
    *
    * <code>string subject_symbol_id = 3 [json_name = "subjectSymbolId"];</code>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifyRequirementResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionRequest.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionRequest.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionRequest.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionRequest.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionRequestOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionRequestOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionRequestOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionRequestOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionResponse.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionResponse.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionResponse.java
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionResponseOrBuilder.java
similarity index 100%
rename from clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionResponseOrBuilder.java
rename to client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/VerifySatisfactionResponseOrBuilder.java
diff --git a/clients/java/opensysml-client/src/main/resources/release-digests.json b/client/java/opensysml-client/src/main/resources/release-digests.json
similarity index 100%
rename from clients/java/opensysml-client/src/main/resources/release-digests.json
rename to client/java/opensysml-client/src/main/resources/release-digests.json
diff --git a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java
new file mode 100644
index 0000000000..16856bf050
--- /dev/null
+++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java
@@ -0,0 +1,1145 @@
+package org.openmbee.opensysml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.openmbee.opensysml.proto.CaseEvaluation;
+import org.openmbee.opensysml.proto.RunAnalysisRequest;
+import org.openmbee.opensysml.proto.RunAnalysisResponse;
+import org.openmbee.opensysml.proto.RunSweepRequest;
+import org.openmbee.opensysml.proto.RunSweepResponse;
+import org.openmbee.opensysml.proto.SweepRange;
+import org.openmbee.opensysml.proto.SweepRow;
+
+/** The v1 API against a real service this test starts. */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class ApiIntegrationTest {
+
+  private static final String VEHICLE =
+      """
+      package Demo {
+        part def Engine { attribute power = 300.0; }
+        part def Vehicle {
+          attribute mass default = 1500.0;
+          part engine : Engine;
+        }
+        part sedan : Vehicle { attribute :>> mass = 1200.0; }
+      }
+      """;
+
+  private static Connection connection;
+
+  @BeforeAll
+  static void open() {
+    connection = Connection.open(ServiceBinary.options().build());
+  }
+
+  @AfterAll
+  static void close() {
+    if (connection != null) {
+      connection.close();
+    }
+  }
+
+  @Test
+  void theServiceAdvertisesTheCapabilitiesTheClientNegotiatesOn() {
+    Capabilities capabilities = connection.capabilities();
+    assertFalse(capabilities.serviceVersion().isBlank());
+    assertTrue(capabilities.has(Capabilities.EVALUATE_SUBJECT));
+    assertTrue(capabilities.has(Capabilities.TYPE_FACTS));
+  }
+
+  @Test
+  void parsesInlineContent() {
+    Model model = connection.parse(VEHICLE);
+    assertFalse(model.hash().isBlank());
+    assertTrue(model.root().isPresent());
+    assertTrue(model.parseDiagnostics().isEmpty());
+    assertEquals(List.of(), model.diagnostics());
+  }
+
+  @Test
+  void parsesAFileTheServiceReads() throws Exception {
+    Path file = Files.createTempFile("opensysml", ".sysml");
+    try {
+      Files.writeString(file, VEHICLE);
+      Model model = connection.load(file);
+      assertEquals("Demo", model.symbol("Demo").name());
+    } finally {
+      Files.deleteIfExists(file);
+    }
+  }
+
+  @Test
+  void evaluatesExpressionsAgainstDeclarationsAndAgainstObjects() {
+    Model model = connection.parse(VEHICLE);
+    assertEquals(new Value.IntegerValue(4), model.eval("2 + 2"));
+    assertEquals(new Value.RealValue(1500.0), model.evalInContext("mass", "Demo::Vehicle"));
+    assertEquals(new Value.RealValue(1200.0), model.evalWithSubject("mass", "Demo::sedan"));
+  }
+
+  @Test
+  void anExpressionThatCannotBeEvaluatedIsAModelFailureRatherThanATransportOne() {
+    Model model = connection.parse(VEHICLE);
+    ModelException failed = assertThrows(ModelException.class, () -> model.eval("nosuchname + 1"));
+    assertFalse(failed.getMessage().isBlank());
+  }
+
+  @Test
+  void looksUpSymbols() {
+    Model model = connection.parse(VEHICLE);
+    Symbol vehicle = model.symbol("Demo::Vehicle");
+    assertEquals("Demo::Vehicle", vehicle.id());
+    assertEquals("Vehicle", vehicle.name());
+    assertEquals("partDef", vehicle.kind());
+    assertTrue(vehicle.childIds().contains("Demo::Vehicle::mass"));
+    assertEquals(Optional.empty(), model.findSymbol("Demo::Missing"));
+    assertThrows(ModelException.class, () -> model.symbol("Demo::Missing"));
+  }
+
+  @Test
+  void instantiatesAnObjectAndItsFeatureValues() {
+    Model model = connection.parse(VEHICLE);
+    Instantiation instantiation = model.instantiate("Demo::sedan");
+    assertEquals("Demo::sedan", instantiation.root().typeSymbolId());
+    assertTrue(instantiation.reachable().size() >= 2);
+    Instance.FeatureValue mass = instantiation.root().featureValues().get("mass");
+    assertEquals(Optional.of(new Value.RealValue(1200.0)), mass.value());
+    Instance.FeatureValue engine = instantiation.root().featureValues().get("engine");
+    Value.InstanceReference reference = (Value.InstanceReference) engine.value().orElseThrow();
+    assertEquals("Demo::Engine", instantiation.resolve(reference).orElseThrow().typeSymbolId());
+  }
+
+  private static final String COMPLEX =
+      """
+      package C {
+        private import ScalarValues::*;
+        private import ComplexFunctions::*;
+        part def Signal {
+          attribute z : Complex = rect(1.5, -2.0);
+          attribute zs : Complex[2] = (rect(1.0, 2.0), rect(3.0, 4.0));
+        }
+      }
+      """;
+
+  @Test
+  void aComplexNumberIsOneValueOverProtobufAndJson() {
+    assertTrue(connection.capabilities().has(Capabilities.COMPLEX_VALUES));
+    try (Connection json =
+        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
+      for (Connection each : List.of(connection, json)) {
+        Model model = each.parse(COMPLEX);
+        assertEquals(new Value.ComplexValue(1.5, -2.0), model.evalInContext("z", "C::Signal"));
+        Instance signal = model.instantiate("C::Signal").root();
+        assertEquals(
+            Optional.of(new Value.ComplexValue(1.5, -2.0)),
+            signal.featureValues().get("z").value());
+        assertEquals(
+            List.of(new Value.ComplexValue(1.0, 2.0), new Value.ComplexValue(3.0, 4.0)),
+            signal.featureValues().get("zs").values());
+      }
+    }
+  }
+
+  private static final String STRUCTURED =
+      """
+      package S {
+        private import ScalarValues::*;
+        private import Collections::*;
+        private import VectorValues::*;
+        private import VectorFunctions::*;
+        private import Quantities::*;
+        private import SI::*;
+        attribute grid : Array { :>> dimensions = (2, 3); :>> elements = (1, 2, 3, 4, 5, 6); }
+        attribute v : CartesianVectorValue = VectorOf((3.0, 4.0));
+        attribute d : VectorQuantityValue = VectorOf((3.0, 4.0)) [m];
+      }
+      """;
+
+  @Test
+  void anArrayAVectorAndAVectorQuantityArriveWholeOverProtobufAndJson() {
+    assertTrue(connection.capabilities().has(Capabilities.STRUCTURED_VALUES));
+    try (Connection json =
+        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
+      for (Connection each : List.of(connection, json)) {
+        Model model = each.parse(STRUCTURED);
+        assertEquals(
+            new Value.ArrayValue(
+                List.of(2L, 3L),
+                List.of(
+                    new Value.IntegerValue(1), new Value.IntegerValue(2), new Value.IntegerValue(3),
+                    new Value.IntegerValue(4), new Value.IntegerValue(5), new Value.IntegerValue(6))),
+            model.eval("S::grid"));
+        assertEquals(
+            new Value.VectorValue(List.of(new Value.RealValue(3.0), new Value.RealValue(4.0))),
+            model.eval("S::v"));
+        Value.VectorQuantityValue d = (Value.VectorQuantityValue) model.eval("S::d");
+        assertEquals(Optional.of("m"), d.unit());
+        assertEquals(List.of(3.0, 4.0), d.components().stream().map(Quantity::magnitude).toList());
+        assertEquals(
+            List.of(new Quantity.UnitFactor("SI::metre", 1.0)),
+            d.components().get(0).reduction().orElseThrow().factors());
+      }
+    }
+  }
+
+  private static final String SET_AND_TENSOR =
+      """
+      package T {
+        private import ScalarValues::*;
+        private import Collections::*;
+        private import Quantities::*;
+        private import MeasurementReferences::*;
+        private import SI::*;
+        attribute s : Set { :>> elements = (3, 1, 2, 2, 3); }
+        attribute none : Set { :>> elements = (); }
+        attribute cubeRef : TensorMeasurementReference {
+          :>> dimensions = (2, 2, 2);
+          :>> mRefs = (m, m, m, m, m, m, m, m);
+        }
+        attribute cube : TensorQuantityValue =
+          TensorCalculations::'['((1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0), cubeRef);
+      }
+      """;
+
+  @Test
+  void aSetAndARankThreeTensorArriveWholeOverProtobufAndJson() {
+    assertTrue(connection.capabilities().has(Capabilities.SET_VALUES));
+    assertTrue(connection.capabilities().has(Capabilities.TENSOR_VALUES));
+    try (Connection json =
+        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
+      for (Connection each : List.of(connection, json)) {
+        Model model = each.parse(SET_AND_TENSOR);
+        Value.SetValue s = (Value.SetValue) model.eval("T::s.elements");
+        assertEquals(
+            new Value.SetValue(
+                List.of(
+                    new Value.IntegerValue(1), new Value.IntegerValue(2), new Value.IntegerValue(3))),
+            s);
+        assertEquals(List.of(1L, 2L, 3L), s.elements().stream().map(Value::asLong).toList());
+        assertEquals(new Value.SetValue(List.of()), model.eval("T::none.elements"));
+        Value.TensorQuantityValue cube = (Value.TensorQuantityValue) model.eval("T::cube");
+        assertEquals(List.of(2L, 2L, 2L), cube.dimensions());
+        assertEquals(Optional.of("m"), cube.unit());
+        assertEquals(
+            List.of(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0),
+            cube.components().stream().map(Quantity::magnitude).toList());
+        assertEquals(6.0, cube.get(1, 0, 1).magnitude());
+        assertEquals(
+            List.of(new Quantity.UnitFactor("SI::metre", 1.0)),
+            cube.get(1, 0, 1).reduction().orElseThrow().factors());
+        Value.QuantityValue corner = (Value.QuantityValue) model.eval("T::cube#(2, 1, 2)");
+        assertEquals(6.0, corner.quantity().magnitude());
+        assertEquals(Optional.of("m"), corner.quantity().unit());
+      }
+    }
+  }
+
+  private static final String METAOBJECTS =
+      """
+      package Demo {
+        private import ScalarValues::*;
+        metadata def Safety { attribute level : Integer = 2; }
+        part def Vehicle { attribute mass : Real; }
+        part seatBelt : Vehicle { @Safety { level = 4; } }
+        attribute asFeature [*] = seatBelt meta KerML::Feature;
+        attribute everything [*] = seatBelt.metadata;
+        attribute notADefinition [*] = seatBelt meta SysML::PartDefinition;
+        attribute belt : String = (seatBelt meta KerML::Feature)#(1).declaredName;
+      }
+      """;
+
+  @Test
+  void aMetaCastArrivesAsTheElementUnderItsOwnMetaclassAfterItsAnnotations() {
+    assertTrue(connection.capabilities().has(Capabilities.METAOBJECT_VALUES));
+    Value.MetaobjectValue seatBelt =
+        new Value.MetaobjectValue("Demo::seatBelt", "SysML::Systems::PartUsage");
+    try (Connection json =
+        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
+      for (Connection each : List.of(connection, json)) {
+        Model model = each.parse(METAOBJECTS);
+        Value.Sequence asFeature = (Value.Sequence) model.eval("Demo::asFeature");
+        assertEquals(List.of(seatBelt), asFeature.elements());
+        assertEquals(
+            "SysML::Systems::PartUsage",
+            ((Value.MetaobjectValue) asFeature.elements().get(0)).metaclassId());
+        assertEquals(new Value.Sequence(List.of()), model.eval("Demo::notADefinition"));
+        Value.Sequence everything = (Value.Sequence) model.eval("Demo::everything");
+        assertEquals(2, everything.elements().size());
+        assertInstanceOf(Value.InstanceReference.class, everything.elements().get(0));
+        assertEquals(seatBelt, everything.elements().get(1));
+        assertEquals(new Value.StringValue("seatBelt"), model.eval("Demo::belt"));
+      }
+    }
+  }
+
+  private static final String MEASUREMENT_REFS =
+      """
+      package M {
+        private import ScalarValues::*;
+        private import Quantities::*;
+        private import MeasurementReferences::*;
+        private import SI::*;
+        attribute q : ISQ::LengthValue = 3 [km];
+        attribute u : MeasurementUnit = m;
+        attribute speed = m / s;
+      }
+      """;
+
+  @Test
+  void theServiceAdvertisesTheVerificationBodyVerdictsItReports() {
+    assertTrue(connection.capabilities().has(Capabilities.VERIFICATION_VERDICTS));
+  }
+
+  private static final String TRADE_STUDY =
+      """
+      package Trade {
+        private import ScalarValues::*;
+        private import TradeStudies::*;
+        part def Engine { attribute mass : Real; attribute cylinders : Integer; }
+        part a : Engine { attribute :>> mass = 30.0; attribute :>> cylinders = 6; }
+        part b : Engine { attribute :>> mass = 10.0; attribute :>> cylinders = 4; }
+        part c : Engine { attribute :>> mass = 10.0; attribute :>> cylinders = 0; }
+        analysis lightest : TradeStudy {
+          subject : Engine[1..*] = (a, b, c);
+          objective : MinimizeObjective;
+          calc :>> evaluationFunction {
+            in part e :>> alternative : Engine;
+            return :>> result : Real = e.mass;
+          }
+          return part :>> selectedAlternative : Engine;
+        }
+        analysis perOffset : TradeStudy {
+          subject : Engine[1..*] = (a, b);
+          in attribute offset : Integer;
+          objective : MinimizeObjective;
+          calc :>> evaluationFunction {
+            in part e :>> alternative : Engine;
+            return :>> result : Real = e.mass / (e.cylinders - offset);
+          }
+          return part :>> selectedAlternative : Engine;
+        }
+      }
+      """;
+
+  @Test
+  void theServiceAdvertisesTheCaseEvaluationsItReports() {
+    assertTrue(connection.capabilities().has(Capabilities.CASE_EVALUATIONS));
+  }
+
+  @Test
+  void aTradeStudyArrivesWithEachAlternativesEvaluationTheSelectedOneAndTheTieMarked() {
+    try (Connection json =
+        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
+      for (Connection each : List.of(connection, json)) {
+        Model model = each.parse(TRADE_STUDY);
+        RunAnalysisResponse response =
+            each.call(
+                "RunAnalysis",
+                RunAnalysisRequest.newBuilder()
+                    .setModelHash(model.hash())
+                    .setSymbolId("Trade::lightest")
+                    .build(),
+                RunAnalysisResponse.getDefaultInstance());
+        assertEquals("", response.getError());
+        assertEquals(1, response.getOutputsCount());
+        long selected = response.getOutputs(0).getValue().getInstanceId();
+        assertEquals(1, response.getVerdictsCount());
+        assertEquals("tradeStudyObjective", response.getVerdicts(0).getElement());
+        assertTrue(response.getVerdicts(0).getHolds());
+
+        List<CaseEvaluation> evaluations = response.getEvaluationsList();
+        assertEquals(
+            List.of("Trade::a", "Trade::b", "Trade::c"),
+            evaluations.stream()
+                .map(e -> typeOf(response.getInstancesList(), e.getArguments(0).getInstanceId()))
+                .toList());
+        assertEquals(
+            List.of(30.0, 10.0, 10.0),
+            evaluations.stream().map(e -> e.getResult().getRealValue()).toList());
+        assertEquals(
+            List.of(false, true, false),
+            evaluations.stream().map(CaseEvaluation::getSelected).toList());
+        assertEquals(
+            List.of(false, false, true),
+            evaluations.stream().map(CaseEvaluation::getTied).toList());
+        assertEquals(selected, evaluations.get(1).getArguments(0).getInstanceId());
+        evaluations.forEach(
+            e -> assertEquals("Trade::lightest::evaluationFunction", e.getFunctionId()));
+      }
+    }
+  }
+
+  @Test
+  void aSweptTradeStudyCarriesEachRowsEvaluationsAFailedRowKeepingThoseItMade() {
+    Model model = connection.parse(TRADE_STUDY);
+    RunSweepResponse response =
+        connection.call(
+            "RunSweep",
+            RunSweepRequest.newBuilder()
+                .setModelHash(model.hash())
+                .setSymbolId("Trade::perOffset")
+                .addRanges(
+                    SweepRange.newBuilder()
+                        .setParameter("offset")
+                        .setStart(
+                            org.openmbee.opensysml.proto.Value.newBuilder().setIntValue(3))
+                        .setEnd(org.openmbee.opensysml.proto.Value.newBuilder().setIntValue(4)))
+                .build(),
+            RunSweepResponse.getDefaultInstance());
+    assertEquals("", response.getError());
+    assertEquals(2, response.getRowsCount());
+
+    SweepRow ok = response.getRows(0);
+    assertEquals("", ok.getError());
+    assertEquals(1, ok.getOutputsCount());
+    assertEquals(
+        List.of(10.0, 10.0),
+        ok.getEvaluationsList().stream().map(e -> e.getResult().getRealValue()).toList());
+    assertEquals(
+        List.of(true, false),
+        ok.getEvaluationsList().stream().map(CaseEvaluation::getSelected).toList());
+    assertEquals(
+        List.of(false, true),
+        ok.getEvaluationsList().stream().map(CaseEvaluation::getTied).toList());
+
+    SweepRow failed = response.getRows(1);
+    assertTrue(failed.getError().contains("division by zero"));
+    assertEquals(0, failed.getOutputsCount());
+    assertEquals(1, failed.getVerdictsCount());
+    assertFalse(failed.getVerdicts(0).getHolds());
+    assertTrue(failed.getVerdicts(0).getError().contains("division by zero"));
+    assertEquals(2, failed.getEvaluationsCount());
+    assertEquals(15.0, failed.getEvaluations(0).getResult().getRealValue());
+    assertEquals("", failed.getEvaluations(0).getError());
+    assertFalse(failed.getEvaluations(1).hasResult());
+    assertTrue(failed.getEvaluations(1).getError().contains("division by zero"));
+    long failedAlternative = failed.getEvaluations(1).getArguments(0).getInstanceId();
+    assertEquals("Trade::b", typeOf(response.getInstancesList(), failedAlternative));
+    assertFalse(failed.getEvaluationsList().stream().anyMatch(CaseEvaluation::getSelected));
+  }
+
+  private static String typeOf(List<org.openmbee.opensysml.proto.Instance> instances, long id) {
+    return instances.stream()
+        .filter(inst -> inst.getId() == id)
+        .map(org.openmbee.opensysml.proto.Instance::getTypeSymbolId)
+        .findFirst()
+        .orElseThrow();
+  }
+
+  @Test
+  void theServiceAdvertisesTheScheduleOfItsExecutionRequests() {
+    assertTrue(connection.capabilities().has(Capabilities.SCHEDULE));
+  }
+
+  @Test
+  void theServiceAdvertisesTheExploreSchedule() {
+    assertTrue(connection.capabilities().has(Capabilities.SCHEDULE_EXPLORE));
+  }
+
+  @Test
+  void theServiceAdvertisesTheObjectABehaviorIsPerformedBy() {
+    assertTrue(connection.capabilities().has(Capabilities.PERFORMER));
+  }
+
+  @Test
+  void theServiceAdvertisesItsAnalysisEngines() {
+    assertTrue(connection.capabilities().has(Capabilities.ENGINES));
+  }
+
+  @Test
+  void theServiceAdvertisesTheFinalClockInstantOfItsExecutionResponses() {
+    assertTrue(connection.capabilities().has(Capabilities.FINAL_TIME));
+  }
+
+  @Test
+  void aBareMeasurementReferenceArrivesWithItsReductionAndDeclarationOverProtobufAndJson() {
+    assertTrue(connection.capabilities().has(Capabilities.MEASUREMENT_REFS));
+    try (Connection json =
+        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
+      for (Connection each : List.of(connection, json)) {
+        Model model = each.parse(MEASUREMENT_REFS);
+        assertEquals(
+            new Value.MeasurementRefValue(
+                "m",
+                new Quantity.UnitTerm(
+                    1.0, 1.0, List.of(new Quantity.UnitFactor("SI::metre", 1.0))),
+                Optional.of("SI::metre")),
+            model.eval("M::u"));
+        Value.MeasurementRefValue km = (Value.MeasurementRefValue) model.eval("M::q.mRef");
+        assertEquals("km", km.unit());
+        assertEquals(Optional.of("SI::kilometre"), km.unitId());
+        assertEquals(1000.0, km.reduction().scaleNumerator());
+        Value.MeasurementRefValue speed = (Value.MeasurementRefValue) model.eval("M::speed");
+        assertEquals(Optional.empty(), speed.unitId());
+        assertEquals(
+            List.of(
+                new Quantity.UnitFactor("SI::metre", 1.0),
+                new Quantity.UnitFactor("SI::second", -1.0)),
+            speed.reduction().factors());
+      }
+    }
+  }
+
+  private static final String FUNCTIONS =
+      """
+      package Demo {
+        private import ScalarValues::*;
+        calc def Sq { in v : Real; return : Real = v * v; }
+        calc def Fn { in calc f { in v : Real; return : Real; } in a : Real; return : Real = f(a); }
+        calc def Identity { in calc f { in v : Real; return : Real; } return r = f; }
+        attribute pick = Identity(Sq);
+        attribute nine = Fn(Sq, 3.0);
+        part def Scaler {
+          attribute k : Real = 2.0;
+          calc scale { in x : Real; return : Real = x * k; }
+        }
+        part holder : Scaler;
+        attribute scaler = holder.scale;
+      }
+      """;
+
+  @Test
+  void aCalcHeldAsAValueArrivesAsTheFunctionItNamesOverProtobufAndJson() {
+    assertTrue(connection.capabilities().has(Capabilities.FUNCTION_VALUES));
+    try (Connection json =
+        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
+      for (Connection each : List.of(connection, json)) {
+        Model model = each.parse(FUNCTIONS);
+        assertEquals(
+            new Value.FunctionValue("Demo::Sq", Optional.empty()), model.eval("Demo::pick"));
+        assertEquals(new Value.RealValue(9.0), model.eval("Demo::nine"));
+        Value.FunctionValue scale = (Value.FunctionValue) model.eval("Demo::scaler");
+        assertEquals("Demo::Scaler::scale", scale.calcId());
+        assertTrue(scale.selfId().orElseThrow() > 0);
+      }
+    }
+  }
+
+  @Test
+  void aModelTheServiceDoesNotHoldIsRefused() {
+    Model absent = connection.model("sha256:0000000000000000");
+    OpenSysMLException refused =
+        assertThrows(OpenSysMLException.class, () -> absent.symbol("Demo::Vehicle"));
+    if (refused instanceof ServiceException service) {
+      assertEquals(StatusCode.NOT_FOUND, service.status());
+    }
+  }
+
+  @Test
+  void aParseThatFindsErrorsReportsThemAsDiagnosticsRatherThanARefusal() {
+    Model model = connection.parse("part def { { {");
+    List<Diagnostic> diagnostics = model.parseDiagnostics();
+    assertFalse(diagnostics.isEmpty());
+    assertEquals(Diagnostic.Severity.ERROR, diagnostics.get(0).severity());
+    assertEquals(diagnostics, model.diagnostics());
+  }
+
+  @Test
+  void theServiceAdvertisesTheDiagnosticCodesItPopulates() {
+    assertTrue(connection.capabilities().has(Capabilities.DIAGNOSTIC_CODES));
+    Model model = connection.parse("package P { part def W { part hub : Missing; } }");
+    assertTrue(model.diagnostics().stream().anyMatch(d -> "unresolved".equals(d.code())));
+  }
+
+  @Test
+  void aJsonBodyAnswersWhatAProtobufBodyAnswers() {
+    try (Connection json =
+        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
+      assertEquals(connection.address(), json.address(), "the private service is shared");
+      Model model = json.parse(VEHICLE);
+      assertEquals(new Value.RealValue(1200.0), model.evalWithSubject("mass", "Demo::sedan"));
+      assertEquals(connection.capabilities(), json.capabilities());
+    }
+  }
+
+  @Test
+  void aModelHashOutlivesTheConnectionThatParsedIt() {
+    String hash;
+    try (Connection first = Connection.open(ServiceBinary.options().build())) {
+      hash = first.parse(VEHICLE).hash();
+    }
+    assertEquals(new Value.IntegerValue(4), connection.model(hash).eval("2 + 2"));
+  }
+
+  @Test
+  void twoModelsAreToldApartByHash() {
+    assertNotEquals(connection.parse(VEHICLE).hash(), connection.parse("package Other {}").hash());
+  }
+
+  @Test
+  void strictConformanceIsCapabilityGated() {
+    ParseOptions strict = ParseOptions.defaults().withStrictConformance(true);
+    if (connection.capabilities().has(Capabilities.STRICT_CONFORMANCE)) {
+      assertFalse(connection.parse(VEHICLE, strict).hash().isBlank());
+    } else {
+      assertThrows(CapabilityException.class, () -> connection.parse(VEHICLE, strict));
+    }
+  }
+
+  @Test
+  void aClosedConnectionRefusesCalls() {
+    Connection closed = Connection.open(ServiceBinary.options().build());
+    closed.close();
+    closed.close(); // idempotent
+    assertThrows(IllegalStateException.class, () -> closed.parse(VEHICLE));
+  }
+
+  private static final String BEHAVIOR =
+      """
+      package Test {
+        private import ScalarValues::*;
+        action addFive {
+          attribute result : Integer = 0;
+          first start;
+          action inner { assign result := result + 5; }
+          done;
+          succession first start then inner;
+          succession first inner then done;
+        }
+        action noStart {
+          attribute result : Integer = 0;
+        }
+        action race {
+          attribute x : Integer = 0;
+          first start;
+          fork split;
+          action a { assign x := 1; }
+          action b { assign x := 2; }
+          action c { assign x := 3; }
+          join sync;
+          done;
+          succession first start then split;
+          succession first split then a;
+          succession first split then b;
+          succession first split then c;
+          succession first a then sync;
+          succession first b then sync;
+          succession first c then sync;
+          succession first sync then done;
+        }
+        state Machine {
+          entry; then init;
+          state init;
+          state Running;
+          succession first init then Running;
+          succession first Running then done;
+        }
+      }
+      """;
+
+  @Test
+  void anActionRunsWithItsInputsAndReportsItsAttributes() {
+    Model model = connection.parse(BEHAVIOR);
+    ActionRun run = model.executeAction("Test::addFive");
+    assertEquals(new Value.IntegerValue(5), run.outputs().get("result"));
+    assertEquals(
+        connection.capabilities().has(Capabilities.FINAL_TIME), run.finalTime().isPresent());
+
+    ActionRun seeded =
+        model.executeAction("Test::addFive", Map.of("result", new Value.IntegerValue(10)));
+    assertEquals(new Value.IntegerValue(15), seeded.outputs().get("result"));
+
+    ActionRun declared =
+        model.executeAction(
+            "Test::race", Map.of(), ExecutionOptions.defaults().withSchedule("declared"));
+    assertEquals(new Value.IntegerValue(3), declared.outputs().get("x"));
+  }
+
+  @Test
+  void anActionThatCannotStartIsAModelFailureAndABadScheduleIsRefused() {
+    Model model = connection.parse(BEHAVIOR);
+    ModelException failed =
+        assertThrows(ModelException.class, () -> model.executeAction("Test::noStart"));
+    assertFalse(failed.getMessage().isBlank());
+    ExecutionOptions seeded = ExecutionOptions.defaults().withSchedule("seed:abc");
+    ServiceException refused =
+        assertThrows(
+            ServiceException.class, () -> model.executeAction("Test::race", Map.of(), seeded));
+    assertEquals(StatusCode.INVALID_ARGUMENT, refused.status());
+    assertTrue(refused.getMessage().contains("seed:abc"));
+    ExecutionOptions declared = ExecutionOptions.defaults().withSchedule("declared");
+    assertThrows(
+        IllegalArgumentException.class, () -> model.exploreAction("Test::race", Map.of(), declared));
+  }
+
+  @Test
+  void exploringAnActionReachesEveryOutcomeWithItsWitness() {
+    Model model = connection.parse(BEHAVIOR);
+    Exploration exploration = model.exploreAction("Test::race");
+    assertTrue(exploration.complete());
+    assertEquals(6, exploration.runs());
+    assertEquals(List.of(), exploration.budgetsHit());
+    assertEquals("complete (6 runs)", exploration.status());
+    assertEquals(
+        List.of(new Value.IntegerValue(1), new Value.IntegerValue(2), new Value.IntegerValue(3)),
+        exploration.outcomes().stream().map(o -> o.outputs().get("x")).toList());
+    for (Outcome outcome : exploration.outcomes()) {
+      assertTrue(outcome.completed());
+      assertEquals(2, outcome.linearizations());
+      assertFalse(outcome.witness().isEmpty());
+    }
+
+    Exploration bounded =
+        model.exploreAction(
+            "Test::race", Map.of(), ExecutionOptions.defaults().withSchedule("explore:runs=2"));
+    assertFalse(bounded.complete());
+    assertEquals(List.of("runs"), bounded.budgetsHit());
+    assertEquals(2, bounded.runsBudget());
+    assertTrue(bounded.status().startsWith("incomplete: runs budget 2"));
+  }
+
+  @Test
+  void aStateMachineReportsTheStatesItVisitedAndExploresToItsFinalState() {
+    Model model = connection.parse(BEHAVIOR);
+    StateRun run = model.executeState("Test::Machine", List.of());
+    assertEquals(List.of("init", "Running", "done"), run.statesVisited());
+    assertEquals(Optional.of("done"), run.finalState());
+
+    Exploration exploration = model.exploreState("Test::Machine", List.of());
+    assertTrue(exploration.complete());
+    assertEquals(1, exploration.outcomes().size());
+    assertEquals(Optional.of("done"), exploration.outcomes().get(0).finalState());
+    assertEquals(
+        List.of("init", "Running", "done"), exploration.outcomes().get(0).statesVisited());
+
+    assertThrows(ModelException.class, () -> model.executeState("Test::NoMachine", List.of()));
+  }
+
+  private static final String VERIFICATION =
+      """
+      package Demo {
+        part def Vehicle {
+          attribute mass default = 1500.0;
+          constraint massPositive { mass > 0.0 }
+          constraint massLight { mass < 100.0 }
+          requirement lightEnough { require constraint { mass < 2000.0 } }
+          requirement tiny { require constraint { mass < 10.0 } }
+        }
+        requirement def MassLimit {
+          subject vehicle : Vehicle;
+          attribute maxMass;
+          require constraint { vehicle.mass <= maxMass }
+        }
+        requirement massLimit : MassLimit { attribute :>> maxMass = 2000.0; }
+        requirement massTiny : MassLimit { attribute :>> maxMass = 10.0; }
+        part sedan : Vehicle { attribute :>> mass = 1200.0; }
+        part analysis {
+          assert satisfy massLimit by sedan;
+          assert satisfy massTiny by sedan;
+        }
+        calc add { in x; in y; x + y }
+      }
+      """;
+
+  @Test
+  void aConstraintIsVerifiedAgainstDeclaredValuesOrAnObjectAndAFalseAnswerIsNotAFailure() {
+    Model model = connection.parse(VERIFICATION);
+    Verification holding = model.verifyConstraint("Demo::Vehicle::massPositive");
+    assertTrue(holding.holds());
+    assertTrue(holding.verdict().decided());
+    assertEquals(Optional.empty(), holding.subject());
+
+    Verification violated = model.verifyConstraint("Demo::Vehicle::massLight");
+    assertFalse(violated.holds());
+    assertTrue(violated.verdict().violated());
+    assertTrue(violated.verdict().condition().isPresent());
+    assertEquals(Optional.empty(), violated.verdict().error());
+
+    Verification about = model.verifyConstraint("Demo::Vehicle::massPositive", "Demo::sedan");
+    assertTrue(about.holds());
+    assertEquals("Demo::sedan", about.subject().orElseThrow().typeSymbolId());
+
+    Verification wrongKind = model.verifyConstraint("Demo::sedan");
+    assertFalse(wrongKind.verdict().decided());
+    assertFalse(wrongKind.holds());
+    assertEquals(FailureReason.WRONG_KIND, wrongKind.verdict().failureReason());
+    assertFalse(wrongKind.verdict().error().orElseThrow().isEmpty());
+  }
+
+  @Test
+  void requirementsAndSatisfactionsReportEachVerdict() {
+    Model model = connection.parse(VERIFICATION);
+    assertTrue(model.verifyRequirement("Demo::Vehicle::lightEnough").holds());
+    Verification tiny = model.verifyRequirement("Demo::Vehicle::tiny");
+    assertTrue(tiny.verdict().violated());
+
+    Satisfaction all = model.verifySatisfaction();
+    assertEquals(2, all.verdicts().size());
+    assertFalse(all.holds());
+    assertEquals(1, all.violated().size());
+    assertEquals(List.of(), all.undecided());
+    assertEquals(Optional.of("Demo::massTiny"), all.violated().get(0).requirementId());
+
+    Satisfaction scoped = model.verifySatisfaction("Demo::analysis");
+    assertEquals(2, scoped.verdicts().size());
+
+    Validation validation = model.validateInstance("Demo::sedan");
+    assertFalse(validation.holds());
+    assertEquals("Demo::sedan", validation.root().orElseThrow().typeSymbolId());
+    assertTrue(validation.verdicts().size() >= 2);
+    assertThrows(ModelException.class, () -> model.validateInstance("Demo::nosuch"));
+    ModelException wrongKind =
+        assertThrows(ModelException.class, () -> model.validateInstance("Demo"));
+    assertEquals(FailureReason.WRONG_KIND, wrongKind.failureReason());
+  }
+
+  private static final String VERIFICATION_CASES =
+      """
+      package Demo {
+        private import ScalarValues::*;
+        part def Widget { attribute m : Integer default = 0; }
+        part good : Widget;
+        requirement def Zeroed {
+          subject w : Widget;
+          require constraint { w.m == 0 }
+        }
+        requirement zeroed : Zeroed { subject w = good; }
+        requirement bounded : Zeroed { subject w = good; }
+        verification def ZeroCheck {
+          subject w : Widget;
+          objective { verify zeroed; }
+          VerificationCases::PassIf(w.m == 0)
+        }
+        verification def BoundCheck {
+          subject w : Widget;
+          objective { verify bounded; }
+          VerificationCases::PassIf(w.m == 1)
+        }
+        verification checkZero : ZeroCheck { subject w = good; }
+        verification checkBound : BoundCheck { subject w = good; }
+        part checks {
+          assert satisfy zeroed by good;
+          assert satisfy bounded by good;
+        }
+      }
+      """;
+
+  @Test
+  void theVerificationCasesOfARequirementReportBesideItsVerdict() {
+    Model model = connection.parse(VERIFICATION_CASES);
+    Verification bounded = model.verifyRequirement("Demo::bounded");
+    assertTrue(bounded.holds());
+    assertEquals(1, bounded.verifications().size());
+    VerificationVerdict check = bounded.verifications().get(0);
+    assertEquals("Demo::checkBound", check.caseId());
+    assertEquals(VerificationVerdict.FAIL, check.kind());
+    assertFalse(check.passed());
+    assertEquals(Optional.of("Demo::bounded"), check.requirementId());
+
+    Satisfaction checks = model.verifySatisfaction("Demo::checks");
+    assertTrue(checks.holds());
+    assertEquals(VerificationVerdict.PASS, checks.verificationsOf("Demo::zeroed").get(0).kind());
+    assertEquals(VerificationVerdict.FAIL, checks.verificationsOf("Demo::bounded").get(0).kind());
+  }
+
+  @Test
+  void aCalcIsEvaluatedWithPositionalArgumentsAndTheWrongKindIsAModelFailure() {
+    Model model = connection.parse(VERIFICATION);
+    Calculation sum =
+        model.evaluateCalc(
+            "Demo::add", List.of(new Value.IntegerValue(2), new Value.IntegerValue(3)));
+    assertEquals(Optional.of(new Value.IntegerValue(5)), sum.value());
+    assertEquals(Optional.of(new Value.IntegerValue(5)), sum.result());
+    ModelException wrongKind =
+        assertThrows(ModelException.class, () -> model.evaluateCalc("Demo::sedan", List.of()));
+    assertEquals(FailureReason.WRONG_KIND, wrongKind.failureReason());
+  }
+
+  @Test
+  void aTradeStudyArrivesThroughThePublicApiWithItsSelectedAlternative() {
+    Model model = connection.parse(TRADE_STUDY);
+    Analysis analysis = model.runAnalysis("Trade::lightest");
+    assertTrue(analysis.holds());
+    assertEquals("tradeStudyObjective", analysis.objective().orElseThrow().element());
+    Value selected = analysis.outputs().get("selectedAlternative");
+    assertInstanceOf(Value.InstanceReference.class, selected);
+    assertEquals(
+        "Trade::b",
+        analysis.resolve((Value.InstanceReference) selected).orElseThrow().typeSymbolId());
+    assertEquals(
+        List.of("Trade::a", "Trade::b", "Trade::c"),
+        analysis.evaluations().stream()
+            .map(e -> analysis.resolve((Value.InstanceReference) e.arguments().get(0)))
+            .map(i -> i.orElseThrow().typeSymbolId())
+            .toList());
+    assertEquals(
+        List.of(false, true, false),
+        analysis.evaluations().stream().map(org.openmbee.opensysml.CaseEvaluation::selected).toList());
+    assertEquals(
+        List.of(false, false, true),
+        analysis.evaluations().stream().map(org.openmbee.opensysml.CaseEvaluation::tied).toList());
+    assertEquals(analysis.evaluations().get(1), analysis.selected().orElseThrow());
+  }
+
+  @Test
+  void anAnalysisBindsItsArgumentsAndAFailedRunKeepsWhatItLeft() {
+    Model model = connection.parse(TRADE_STUDY);
+    Analysis three =
+        model.runAnalysis(
+            "Trade::perOffset",
+            AnalysisOptions.defaults().withNamedArguments(Map.of("offset", new Value.IntegerValue(3))));
+    assertEquals(
+        List.of(Optional.of(new Value.RealValue(10.0)), Optional.of(new Value.RealValue(10.0))),
+        three.evaluations().stream().map(org.openmbee.opensysml.CaseEvaluation::result).toList());
+
+    AnalysisOptions arguments =
+        AnalysisOptions.defaults().withArguments(List.of(new Value.IntegerValue(4)));
+    AnalysisException failed =
+        assertThrows(AnalysisException.class, () -> model.runAnalysis("Trade::perOffset", arguments));
+    assertTrue(failed.getMessage().contains("division by zero"));
+    assertEquals(FailureReason.EVALUATION, failed.failureReason());
+    Analysis partial = failed.partial().orElseThrow();
+    assertEquals(2, partial.evaluations().size());
+    assertEquals(Optional.of(new Value.RealValue(15.0)), partial.evaluations().get(0).result());
+    assertTrue(partial.evaluations().get(1).error().orElseThrow().contains("division by zero"));
+    assertFalse(partial.holds());
+    assertEquals(Optional.empty(), partial.selected());
+
+    ModelException wrongKind =
+        assertThrows(ModelException.class, () -> model.runAnalysis("Trade::a"));
+    assertEquals(FailureReason.WRONG_KIND, wrongKind.failureReason());
+    assertFalse(wrongKind instanceof AnalysisException);
+  }
+
+  @Test
+  void theExploreEngineRunsNoSingleAnalysis() {
+    Model model = connection.parse(TRADE_STUDY);
+    if (!connection.capabilities().has(Capabilities.SCHEDULE_EXPLORE)) {
+      assertThrows(CapabilityException.class, () -> model.withEngine("explore"));
+      return;
+    }
+    Model exploring = model.withEngine("explore");
+    IllegalArgumentException refused =
+        assertThrows(
+            IllegalArgumentException.class, () -> exploring.runAnalysis("Trade::lightest"));
+    assertEquals("engine explore answers every outcome; use exploreAnalysis", refused.getMessage());
+  }
+
+  private static final String QUERY =
+      """
+      package Demo {
+        abstract part def Vehicle { attribute mass; }
+        part def Wheel;
+        part vehicle : Vehicle {
+          part wheels : Wheel[4];
+          attribute vin;
+        }
+        part spare : Wheel;
+      }
+      """;
+
+  @Test
+  void aQuerySelectsElementsByTypeScopeAndProperty() {
+    Model model = connection.parse(QUERY);
+    List<String> all = model.query(Query.all()).stream().map(QueryElement::id).toList();
+    assertTrue(all.containsAll(List.of("Demo", "Demo::Vehicle", "Demo::vehicle::wheels", "Demo::spare")));
+
+    List<QueryElement> parts =
+        model.query(Query.all().where(Condition.equalTo("@type", List.of("PartUsage"))));
+    assertEquals(
+        List.of("Demo::spare", "Demo::vehicle", "Demo::vehicle::wheels"),
+        parts.stream().map(QueryElement::id).sorted().toList());
+    parts.forEach(e -> assertEquals("PartUsage", e.type()));
+
+    List<QueryElement> scoped = model.query(Query.all().withScope(List.of("Demo::vehicle")));
+    assertEquals(3, scoped.size());
+
+    List<QueryElement> selected =
+        model.query(
+            Query.all()
+                .withSelect(List.of("name", "owner"))
+                .where(Condition.equalTo("qualifiedName", List.of("Demo::vehicle::wheels"))));
+    assertEquals(
+        List.of(
+            new QueryElement(
+                "Demo::vehicle::wheels",
+                "PartUsage",
+                Map.of("name", "wheels", "owner", "Demo::vehicle"))),
+        selected);
+
+    assertEquals(
+        3, model.queryOslc("oslc.where=rdf:type=\"PartUsage\"&oslc.select=sysml:name").size());
+    Query missingScope = Query.all().withScope(List.of("Demo::Missing"));
+    ServiceException refused =
+        assertThrows(ServiceException.class, () -> model.query(missingScope));
+    assertEquals(StatusCode.INVALID_ARGUMENT, refused.status());
+  }
+
+  @Test
+  void theServiceListsItsEnginesAndAModelCanBeBoundToOne() {
+    List<EngineInfo> engines = connection.listEngines();
+    assertFalse(engines.isEmpty());
+    engines.forEach(e -> assertFalse(e.name().isBlank()));
+    Model model = connection.parse(VERIFICATION);
+    Model bound = model.withEngine(Standing.ENGINE_AUTO);
+    assertEquals(Optional.of(Standing.ENGINE_AUTO), bound.engine());
+    assertEquals(model.hash(), bound.hash());
+    Verification holding = bound.verifyConstraint("Demo::Vehicle::massPositive");
+    assertTrue(holding.holds());
+  }
+
+  private static Path fixture(String name) {
+    return Path.of(System.getProperty("user.dir"))
+        .resolve("../../../conformance/fixtures")
+        .normalize()
+        .resolve(name);
+  }
+
+  @Test
+  void parseSourcesParsesSeveralDocumentsAsOneModel() throws Exception {
+    Model model =
+        connection.parseSources(
+            List.of(
+                SourceDocument.inline(
+                    "engine_library.sysml",
+                    Files.readString(fixture("engine_library.sysml"))),
+                SourceDocument.inline(
+                    "engine_user.sysml", Files.readString(fixture("engine_user.sysml")))));
+    assertEquals(2, model.roots().size());
+    assertTrue(model.root().isPresent());
+    assertEquals("Engine", model.symbol("EngineLibrary::Engine").name());
+    assertEquals("Car", model.symbol("EngineUser::Car").name());
+  }
+
+  @Test
+  void parseSourcesRefusesTwoDocumentsOfOneName() throws Exception {
+    String librarySource = Files.readString(fixture("engine_library.sysml"));
+    String userSource = Files.readString(fixture("engine_user.sysml"));
+    List<SourceDocument> documents =
+        List.of(
+            SourceDocument.inline("same.sysml", librarySource),
+            SourceDocument.inline("same.sysml", userSource));
+    ServiceException refused =
+        assertThrows(ServiceException.class, () -> connection.parseSources(documents));
+    assertEquals(StatusCode.INVALID_ARGUMENT, refused.status());
+  }
+
+  @Test
+  void convertRewritesContentAndAParsedModel() throws Exception {
+    String source = Files.readString(fixture("vehicle.sysml"));
+    Conversion conversion =
+        connection.convert(
+            source, "sysml", ConversionOptions.defaults().withFromFormat("sysml"));
+    assertFalse(conversion.content().isBlank());
+    assertEquals("sysml", conversion.fromFormat());
+    assertEquals("sysml", conversion.toFormat());
+    assertTrue(conversion.content().contains("package"));
+
+    Model model = connection.load(fixture("vehicle.sysml"));
+    Conversion roundTrip = model.convert("sysml");
+    assertFalse(roundTrip.content().isBlank());
+  }
+
+  @Test
+  void convertOfUnreadableNotationIsAModelFailure() throws Exception {
+    String source = Files.readString(fixture("syntax_error.sysml"));
+    ConversionOptions convertOptions = ConversionOptions.defaults().withFromFormat("sysml");
+    ModelException failed =
+        assertThrows(
+            ModelException.class, () -> connection.convert(source, "sysml", convertOptions));
+    assertFalse(failed.diagnostics().isEmpty());
+  }
+
+  @Test
+  void applyEditsRewritesAValueAndAnswersTheText() {
+    Model model = connection.load(fixture("editable.sysml"));
+    EditResult result =
+        model.applyEdits(List.of(new Edit.SetValue("Demo::SC::unitMass", "1050.0[SI::kg]")));
+    assertFalse(result.content().isBlank());
+    assertTrue(result.content().contains("1050.0"));
+    assertEquals(1, result.applied().size());
+    assertEquals("Demo::SC::unitMass", result.applied().get(0).target());
+  }
+
+  @Test
+  void applyEditsRefusesAnUnknownTargetByKind() {
+    Model model = connection.load(fixture("editable.sysml"));
+    List<Edit> edits = List.of(new Edit.SetValue("Demo::SC::nope", "1.0"));
+    EditException refused =
+        assertThrows(EditException.class, () -> model.applyEdits(edits));
+    assertEquals(EditFailure.UNKNOWN_TARGET, refused.failure());
+    assertEquals("EDIT_FAILURE_UNKNOWN_TARGET", refused.failureName());
+  }
+
+  @Test
+  void runSweepStepsThroughARangeAndReportsEachRow() {
+    Model model = connection.load(fixture("sweep.sysml"));
+    Sweep sweep =
+        model.runSweep(
+            "Sw::Sum",
+            List.of(
+                org.openmbee.opensysml.SweepRange.of(
+                        "b", new Value.RealValue(0.0), new Value.RealValue(4.0))
+                    .withStep(new Value.RealValue(2.0))),
+            SweepOptions.defaults().withArguments(List.of(new Value.RealValue(1.0))));
+    assertEquals(List.of("b"), sweep.parameters());
+    assertEquals(3, sweep.rows().size());
+    assertFalse(sweep.sampled());
+    assertEquals(
+        new Value.RealValue(1.0), sweep.rows().get(0).outputs().get("result"));
+    assertFalse(sweep.rows().get(0).failed());
+  }
+
+  @Test
+  void runSweepOfAnotherKindIsAModelFailure() {
+    Model model = connection.load(fixture("sweep.sysml"));
+    List<org.openmbee.opensysml.SweepRange> ranges =
+        List.of(
+            org.openmbee.opensysml.SweepRange.of(
+                    "limit", new Value.RealValue(0.0), new Value.RealValue(4.0))
+                .withStep(new Value.RealValue(2.0)));
+    ModelException failed =
+        assertThrows(
+            ModelException.class,
+            () -> model.runSweep("Sw::barge", ranges));
+    assertEquals(FailureReason.WRONG_KIND, failed.failureReason());
+  }
+
+  @Test
+  void runDocumentQueryAnswersTypedRows() {
+    Model model = connection.load(fixture("document.sysml"));
+    DocumentQueryResult result =
+        model.runDocumentQuery(
+            "Observatory::SubsystemTable",
+            Map.of(
+                "root",
+                List.of(new DocumentValue.ElementRef("Observatory::telescope", ""))));
+    assertEquals(List.of("name", "mass"), result.columns());
+    assertEquals(4, result.rows().size());
+    assertEquals(
+        "Observatory::telescope::baffle|shroud *tricky*", result.rows().get(0).element().id());
+    assertEquals(
+        List.of(new DocumentValue.StringValue("baffle|shroud *tricky*")),
+        result.rows().get(0).cells().get(0));
+  }
+
+  @Test
+  void renderDocumentRendersTheNamedDocument() {
+    Model model = connection.load(fixture("document.sysml"));
+    RenderedDocument rendered = model.renderDocument("Observatory::MassReport");
+    assertTrue(rendered.markdown().contains("# Telescope Mass Report"));
+  }
+
+  @Test
+  void renderDocumentOfAnUnknownDocumentIsNotFound() {
+    Model model = connection.load(fixture("document.sysml"));
+    ServiceException refused =
+        assertThrows(
+            ServiceException.class, () -> model.renderDocument("Observatory::NoSuchDocument"));
+    assertEquals(StatusCode.NOT_FOUND, refused.status());
+  }
+}
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ClassLoaderProbe.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ClassLoaderProbe.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ClassLoaderProbe.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ClassLoaderProbe.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ClassLoaderTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ClassLoaderTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ClassLoaderTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ClassLoaderTest.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/LifecycleTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/LifecycleTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/LifecycleTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/LifecycleTest.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ModelExamplesTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ModelExamplesTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ModelExamplesTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ModelExamplesTest.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/OrphanSafetyChild.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/OrphanSafetyChild.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/OrphanSafetyChild.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/OrphanSafetyChild.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/OrphanSafetyTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/OrphanSafetyTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/OrphanSafetyTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/OrphanSafetyTest.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/PublicTypesTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/PublicTypesTest.java
similarity index 93%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/PublicTypesTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/PublicTypesTest.java
index 754ba427ba..0a474c66ee 100644
--- a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/PublicTypesTest.java
+++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/PublicTypesTest.java
@@ -18,7 +18,9 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.time.Duration;
 import org.junit.jupiter.api.Test;
+import org.openmbee.opensysml.internal.ConnectTransport;
 import org.openmbee.opensysml.internal.Protos;
 
 /** The public value types: immutable, comparable by value, and free of generated types. */
@@ -639,6 +641,54 @@ public void writeInt(int value) throws IOException {
     }
   }
 
+  @Test
+  void anEditExceptionPreservesItsRefusalWhenSerialized() throws Exception {
+    List<Diagnostic> diagnostics =
+        List.of(
+            new Diagnostic(Diagnostic.Severity.ERROR, "still referred", "", Optional.empty()));
+    List<Referrer> referrers =
+        List.of(
+            new Referrer("EngineUser::Car", "engine_user.sysml"),
+            new Referrer("EngineUser::Truck", "engine_user.sysml"));
+    List<String> referringElements = List.of("EngineUser::Car (engine_user.sysml)");
+    EditException original =
+        new EditException(
+            "Demo::sc is referred to",
+            EditFailure.DELETE_REFERENCED,
+            "EDIT_FAILURE_DELETE_REFERENCED",
+            diagnostics,
+            referringElements,
+            referrers);
+
+    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+    try (ObjectOutputStream output = new ObjectOutputStream(bytes)) {
+      output.writeObject(original);
+    }
+    try (ObjectInputStream input =
+        new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
+      EditException restored = (EditException) input.readObject();
+      assertEquals(original.getMessage(), restored.getMessage());
+      assertEquals(original.failure(), restored.failure());
+      assertEquals(original.failureName(), restored.failureName());
+      assertEquals(referrers, restored.referrers());
+      assertEquals(referringElements, restored.referringElements());
+      assertEquals(diagnostics, restored.diagnostics());
+    }
+  }
+
+  @Test
+  void theExploreEngineNeedsScheduleExploreBesideEngines() {
+    try (Connection limited =
+        new Connection(
+            new ConnectTransport("127.0.0.1:1", Encoding.PROTOBUF, Duration.ofSeconds(1)),
+            new Capabilities("dev", java.util.Set.of(Capabilities.ENGINES)))) {
+      Model model = new Model(limited, "hash", List.of(), List.of());
+      CapabilityException refused =
+          assertThrows(CapabilityException.class, () -> model.withEngine("explore"));
+      assertEquals(Capabilities.SCHEDULE_EXPLORE, refused.capability());
+    }
+  }
+
   @Test
   void capabilitiesNegotiateOnNames() {
     Capabilities capabilities =
diff --git a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java
new file mode 100644
index 0000000000..203f274eb0
--- /dev/null
+++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ResultTypesTest.java
@@ -0,0 +1,416 @@
+package org.openmbee.opensysml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalDouble;
+import org.junit.jupiter.api.Test;
+
+/** The public result types of execution, verification, analysis and query: immutable and decided. */
+class ResultTypesTest {
+
+  private static Verdict verdict(boolean holds, String error) {
+    return new Verdict(
+        Verdict.KIND_CONSTRAINT,
+        Optional.of("Demo::Vehicle::massLight"),
+        "massLight",
+        holds,
+        holds ? Optional.empty() : Optional.of("mass < 100.0"),
+        Optional.empty(),
+        Optional.empty(),
+        error.isEmpty() ? Optional.empty() : Optional.of(error),
+        error.isEmpty() ? FailureReason.UNSPECIFIED : FailureReason.EVALUATION,
+        Optional.empty(),
+        Optional.empty(),
+        Standing.none());
+  }
+
+  @Test
+  void aFalseVerdictIsDecidedAndAnErroredOneIsNot() {
+    Verdict violated = verdict(false, "");
+    assertTrue(violated.decided());
+    assertTrue(violated.violated());
+    assertFalse(violated.holds());
+
+    Verdict undecided = verdict(false, "mass is unbound");
+    assertFalse(undecided.decided());
+    assertFalse(undecided.violated());
+    assertEquals(FailureReason.EVALUATION, undecided.failureReason());
+
+    Verdict holding = verdict(true, "");
+    assertTrue(holding.decided());
+    assertFalse(holding.violated());
+  }
+
+  @Test
+  void aSatisfactionSortsItsVerdictsByWhatTheySay() {
+    Verdict holding = verdict(true, "");
+    Verdict violated = verdict(false, "");
+    Verdict undecided = verdict(false, "unbound");
+    Satisfaction satisfaction =
+        new Satisfaction(List.of(holding, violated, undecided), List.of(), List.of(), List.of());
+    assertFalse(satisfaction.holds());
+    assertEquals(List.of(violated), satisfaction.violated());
+    assertEquals(List.of(undecided), satisfaction.undecided());
+    assertTrue(new Satisfaction(List.of(holding), List.of(), List.of(), List.of()).holds());
+    assertTrue(new Satisfaction(List.of(), List.of(), List.of(), List.of()).holds());
+  }
+
+  @Test
+  void aSatisfactionFindsTheVerificationCasesOfARequirement() {
+    VerificationVerdict pass =
+        new VerificationVerdict(
+            "Demo::checkZero", VerificationVerdict.PASS, Optional.empty(), false, Optional.of("Demo::zeroed"));
+    VerificationVerdict fail =
+        new VerificationVerdict(
+            "Demo::checkBound",
+            VerificationVerdict.FAIL,
+            Optional.of("bound exceeded"),
+            false,
+            Optional.of("Demo::bounded"));
+    Satisfaction satisfaction =
+        new Satisfaction(List.of(), List.of(pass, fail), List.of(), List.of());
+    assertEquals(List.of(fail), satisfaction.verificationsOf("Demo::bounded"));
+    assertEquals(List.of(), satisfaction.verificationsOf("Demo::other"));
+    assertTrue(pass.passed());
+    assertFalse(fail.passed());
+  }
+
+  @Test
+  void resultsCopyTheCollectionsTheyAreGivenAndRefuseChanges() {
+    List<Verdict> verdicts = new ArrayList<>(List.of(verdict(true, "")));
+    Map<String, Value> outputs = new LinkedHashMap<>();
+    outputs.put("total", new Value.RealValue(12.0));
+    Analysis analysis =
+        new Analysis(outputs, verdicts, List.of(), List.of(), List.of(), List.of(), Standing.none());
+    verdicts.clear();
+    outputs.put("later", new Value.RealValue(1.0));
+    assertEquals(1, analysis.verdicts().size());
+    assertEquals(List.of("total"), List.copyOf(analysis.outputs().keySet()));
+    Value added = new Value.RealValue(2.0);
+    Map<String, Value> analysisOutputs = analysis.outputs();
+    assertThrows(UnsupportedOperationException.class, () -> analysisOutputs.put("x", added));
+    Verdict extra = verdict(false, "");
+    List<Verdict> analysisVerdicts = analysis.verdicts();
+    assertThrows(UnsupportedOperationException.class, () -> analysisVerdicts.add(extra));
+
+    Map<String, Value> context = new LinkedHashMap<>(Map.of("n", new Value.IntegerValue(1)));
+    StateRun run =
+        new StateRun(new ArrayList<>(List.of("init", "done")), context, OptionalDouble.empty(), List.of());
+    context.clear();
+    assertEquals(Optional.of("done"), run.finalState());
+    assertEquals(1, run.finalContext().size());
+    assertEquals(
+        Optional.empty(),
+        new StateRun(List.of(), Map.of(), OptionalDouble.empty(), List.of()).finalState());
+  }
+
+  @Test
+  void anOutputOrderIsTheOneTheServiceReported() {
+    Map<String, Value> outputs = new LinkedHashMap<>();
+    outputs.put("z", new Value.IntegerValue(1));
+    outputs.put("a", new Value.IntegerValue(2));
+    Calculation calculation =
+        new Calculation(Optional.empty(), outputs, List.of(), Standing.none());
+    assertEquals(List.of("z", "a"), List.copyOf(calculation.outputs().keySet()));
+  }
+
+  @Test
+  void aCalculationsValueIsItsResultOrItsOnlyOutput() {
+    Value five = new Value.IntegerValue(5);
+    assertEquals(
+        Optional.of(five),
+        new Calculation(Optional.of(five), Map.of(), List.of(), Standing.none()).value());
+    assertEquals(
+        Optional.of(five),
+        new Calculation(Optional.empty(), Map.of("out", five), List.of(), Standing.none()).value());
+    Map<String, Value> two = new LinkedHashMap<>();
+    two.put("a", five);
+    two.put("b", five);
+    assertEquals(
+        Optional.empty(), new Calculation(Optional.empty(), two, List.of(), Standing.none()).value());
+  }
+
+  @Test
+  void anAnalysisNamesItsObjectiveItsSelectedAlternativeAndTheObjectsItRefersTo() {
+    Instance engine = new Instance(7, "Trade::b", Map.of());
+    Verdict objective =
+        new Verdict(
+            Verdict.KIND_OBJECTIVE,
+            Optional.of("Trade::lightest::objective"),
+            "tradeStudyObjective",
+            true,
+            Optional.empty(),
+            Optional.of(7L),
+            Optional.of("Trade::b"),
+            Optional.empty(),
+            FailureReason.UNSPECIFIED,
+            Optional.empty(),
+            Optional.empty(),
+            Standing.none());
+    CaseEvaluation loser =
+        new CaseEvaluation(
+            "Trade::lightest::evaluationFunction",
+            List.of(new Value.InstanceReference(3)),
+            Optional.of(new Value.RealValue(30.0)),
+            Optional.empty(),
+            false,
+            false);
+    CaseEvaluation winner =
+        new CaseEvaluation(
+            "Trade::lightest::evaluationFunction",
+            List.of(new Value.InstanceReference(7)),
+            Optional.of(new Value.RealValue(10.0)),
+            Optional.empty(),
+            true,
+            false);
+    Analysis analysis =
+        new Analysis(
+            Map.of("selectedAlternative", new Value.InstanceReference(7)),
+            List.of(objective),
+            List.of(),
+            List.of(loser, winner),
+            List.of(engine),
+            List.of(),
+            new Standing("run", "observed", List.of()));
+    assertTrue(analysis.holds());
+    assertEquals(Optional.of(objective), analysis.objective());
+    assertEquals(Optional.of(winner), analysis.selected());
+    assertEquals(Optional.of(engine), analysis.resolve(new Value.InstanceReference(7)));
+    assertEquals(Optional.empty(), analysis.resolve(new Value.InstanceReference(3)));
+    assertEquals(Optional.of(engine), analysis.instance(7));
+    assertTrue(analysis.standing().reported());
+  }
+
+  @Test
+  void anAnalysisExceptionKeepsWhatTheRunLeft() {
+    Analysis partial =
+        new Analysis(
+            Map.of(), List.of(verdict(false, "division by zero")), List.of(), List.of(), List.of(), List.of(), Standing.none());
+    AnalysisException e =
+        new AnalysisException("division by zero", FailureReason.EVALUATION, List.of(), partial);
+    assertEquals(Optional.of(partial), e.partial());
+    assertEquals(FailureReason.EVALUATION, e.failureReason());
+    assertEquals("division by zero", e.getMessage());
+  }
+
+  @Test
+  void anExplorationReportsHowItEnded() {
+    Outcome one =
+        new Outcome(
+            Map.of("x", new Value.IntegerValue(1)),
+            Optional.empty(),
+            List.of(),
+            Optional.empty(),
+            2,
+            0.5,
+            List.of("first of a, b, c: a"),
+            List.of());
+    Outcome failed =
+        new Outcome(
+            Map.of(),
+            Optional.empty(),
+            List.of(),
+            Optional.of("deadlock"),
+            1,
+            0.0,
+            List.of(),
+            List.of());
+    assertTrue(one.completed());
+    assertFalse(failed.completed());
+    assertEquals(
+        "complete (6 runs)",
+        new Exploration(List.of(one), true, 6, List.of(), 1024, 64, false).status());
+    assertEquals(
+        "incomplete: runs budget 100 hit after 100 runs; probabilities are lower bounds",
+        new Exploration(List.of(one, failed), false, 100, List.of("runs"), 100, 64, true)
+            .status());
+    assertEquals(
+        "incomplete: runs budget 4 and depth budget 2 hit after 4 runs;"
+            + " probabilities are lower bounds",
+        new Exploration(List.of(), false, 4, List.of("runs", "depth"), 4, 2, true).status());
+  }
+
+  @Test
+  void executionOptionsKnowWhetherTheyExplore() {
+    assertFalse(ExecutionOptions.defaults().explores());
+    assertFalse(ExecutionOptions.defaults().withSchedule("declared").explores());
+    assertTrue(ExecutionOptions.defaults().withSchedule("explore").explores());
+    assertTrue(ExecutionOptions.defaults().withSchedule("explore:runs=10,depth=4").explores());
+    ExecutionOptions options =
+        ExecutionOptions.defaults().withSchedule("seed:7").withPerformer("Demo::sedan");
+    assertEquals(Optional.of("seed:7"), options.schedule());
+    assertEquals(Optional.of("Demo::sedan"), options.performer());
+    assertEquals(Optional.empty(), ExecutionOptions.defaults().performer());
+  }
+
+  @Test
+  void analysisOptionsAccumulateAndCopyTheirArguments() {
+    List<Value> positional = new ArrayList<>(List.of(new Value.RealValue(10.0)));
+    Map<String, Value> named = new LinkedHashMap<>(Map.of("limit", new Value.RealValue(50.0)));
+    AnalysisOptions options =
+        AnalysisOptions.defaults()
+            .withSubject("An::barge")
+            .withArguments(positional)
+            .withNamedArguments(named)
+            .withSchedule("explore");
+    positional.clear();
+    named.clear();
+    assertEquals(Optional.of("An::barge"), options.subject());
+    assertEquals(List.of(new Value.RealValue(10.0)), options.arguments());
+    assertEquals(Map.of("limit", new Value.RealValue(50.0)), options.namedArguments());
+    assertTrue(options.explores());
+    assertFalse(AnalysisOptions.defaults().explores());
+    Value extra = new Value.RealValue(1.0);
+    List<Value> optionArguments = options.arguments();
+    assertThrows(UnsupportedOperationException.class, () -> optionArguments.add(extra));
+  }
+
+  @Test
+  void aQueryIsBuiltUpAndItsConditionsNegate() {
+    Condition.Comparison parts = Condition.equalTo("@type", List.of("PartUsage", "PartDefinition"));
+    Condition.Comparison heavy = Condition.greater("mass", "1000");
+    Query query =
+        Query.all()
+            .withScope(List.of("Demo"))
+            .withSelect(List.of("name", "qualifiedName"))
+            .where(Condition.all(List.of(parts, heavy)));
+    assertEquals(List.of("Demo"), query.scope());
+    assertEquals(List.of("name", "qualifiedName"), query.select());
+    assertEquals(Optional.of(Condition.all(List.of(parts, heavy))), query.where());
+    assertEquals(Optional.empty(), Query.all().where());
+
+    assertEquals(
+        new Condition.Comparison(
+            "@type", Condition.Comparison.Operator.EQUAL, List.of("PartUsage", "PartDefinition"), true),
+        parts.negated());
+    assertEquals(parts, parts.negated().negated());
+    assertEquals(
+        Condition.any(List.of(parts.negated(), heavy.negated())),
+        Condition.all(List.of(parts, heavy)).negated());
+    assertEquals(
+        Condition.all(List.of(parts.negated(), heavy.negated())),
+        Condition.any(List.of(parts, heavy)).negated());
+    assertEquals(Condition.Comparison.Operator.LESS, Condition.less("mass", "10").operator());
+  }
+
+  @Test
+  void aQueryElementCopiesItsProperties() {
+    Map<String, String> properties = new LinkedHashMap<>(Map.of("name", "sedan"));
+    QueryElement element = new QueryElement("Demo::sedan", "PartUsage", properties);
+    properties.clear();
+    assertEquals(Map.of("name", "sedan"), element.properties());
+    Map<String, String> elementProperties = element.properties();
+    assertThrows(UnsupportedOperationException.class, () -> elementProperties.put("a", "b"));
+  }
+
+  @Test
+  void aStandingIsReportedOnlyWhenAnEngineAnswered() {
+    assertFalse(Standing.none().reported());
+    Standing standing =
+        new Standing("explore", "observed", List.of(new Standing.Bound("runs", 1024, false)));
+    assertTrue(standing.reported());
+    assertEquals("explore", standing.engine());
+    assertEquals(1024, standing.bounds().get(0).limit());
+    assertEquals("auto", Standing.ENGINE_AUTO);
+    assertEquals("all", Standing.ENGINE_ALL);
+  }
+
+  @Test
+  void aVerificationFindsItsSubjectAmongItsInstances() {
+    Instance sedan = new Instance(1, "Demo::sedan", Map.of());
+    Verdict about =
+        new Verdict(
+            Verdict.KIND_CONSTRAINT,
+            Optional.of("Demo::Vehicle::massPositive"),
+            "massPositive",
+            true,
+            Optional.empty(),
+            Optional.of(1L),
+            Optional.of("Demo::sedan"),
+            Optional.empty(),
+            FailureReason.UNSPECIFIED,
+            Optional.empty(),
+            Optional.empty(),
+            Standing.none());
+    Verification verification = new Verification(about, List.of(), List.of(sedan), List.of());
+    assertTrue(verification.holds());
+    assertEquals(Optional.of(sedan), verification.subject());
+    assertEquals(Optional.empty(), verification.instance(2));
+    Verification declared =
+        new Verification(verdict(true, ""), List.of(), List.of(sedan), List.of());
+    assertEquals(Optional.empty(), declared.subject());
+  }
+
+  @Test
+  void conversionOptionsDefaultToAnInferredFormatAndStrictSource() {
+    ConversionOptions options = ConversionOptions.defaults();
+    assertEquals(Optional.empty(), options.fromFormat());
+    assertFalse(options.tolerateSyntaxErrors());
+    ConversionOptions tolerant = options.withFromFormat("sysml").withTolerateSyntaxErrors(true);
+    assertEquals(Optional.of("sysml"), tolerant.fromFormat());
+    assertTrue(tolerant.tolerateSyntaxErrors());
+    assertFalse(options.tolerateSyntaxErrors());
+  }
+
+  @Test
+  void editOptionsDefaultToAcceptingTheDocumentsABatchWrites() {
+    EditOptions options = EditOptions.defaults();
+    assertTrue(options.acceptDocuments());
+    assertEquals(Optional.empty(), options.document());
+    EditOptions targeted = options.withAcceptDocuments(false).withDocument("engine_user.sysml");
+    assertFalse(targeted.acceptDocuments());
+    assertEquals(Optional.of("engine_user.sysml"), targeted.document());
+    assertTrue(options.acceptDocuments());
+  }
+
+  @Test
+  void sweepOptionsDefaultToSteppingThroughEveryRange() {
+    SweepOptions options = SweepOptions.defaults();
+    assertEquals(Optional.empty(), options.subject());
+    assertEquals(List.of(), options.arguments());
+    assertEquals(Map.of(), options.namedArguments());
+    assertEquals(0, options.samples());
+    assertEquals(0, options.seed());
+    SweepOptions sampled =
+        options
+            .withSubject("Sw::barge")
+            .withSamples(3)
+            .withSeed(42)
+            .withArguments(List.of(new Value.IntegerValue(1)))
+            .withNamedArguments(Map.of("rate", new Value.RealValue(2.0)));
+    assertEquals(Optional.of("Sw::barge"), sampled.subject());
+    assertEquals(3, sampled.samples());
+    assertEquals(42, sampled.seed());
+    assertEquals(List.of(new Value.IntegerValue(1)), sampled.arguments());
+    assertEquals(Map.of("rate", new Value.RealValue(2.0)), sampled.namedArguments());
+    assertEquals(0, options.samples());
+  }
+
+  @Test
+  void aSweepRowFailsByItsErrorAndHoldsByItsVerdicts() {
+    SweepRow empty = new SweepRow(Map.of(), Map.of(), List.of(), List.of(), Duration.ZERO, "", FailureReason.UNSPECIFIED);
+    assertFalse(empty.failed());
+    assertTrue(empty.holds());
+    SweepRow failed = new SweepRow(Map.of(), Map.of(), List.of(), List.of(), Duration.ZERO, "division by zero", FailureReason.UNSPECIFIED);
+    assertTrue(failed.failed());
+    assertFalse(failed.holds());
+  }
+
+  @Test
+  void aSweepResolvesItsInstancesLikeAnAnalysis() {
+    Instance sedan = new Instance(1, "Demo::sedan", Map.of());
+    Sweep sweep = new Sweep(List.of(), List.of(), false, 0, List.of(sedan), List.of(), Standing.none());
+    assertEquals(Optional.of(sedan), sweep.instance(1));
+    assertEquals(Optional.empty(), sweep.instance(2));
+    assertEquals(0, sweep.seed());
+  }
+}
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ServiceBinary.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ServiceBinary.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ServiceBinary.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/ServiceBinary.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/TransportFailureTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/TransportFailureTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/TransportFailureTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/TransportFailureTest.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryDownloaderTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryDownloaderTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryDownloaderTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryDownloaderTest.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryResolverDownloadTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryResolverDownloadTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryResolverDownloadTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryResolverDownloadTest.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryResolverTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryResolverTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryResolverTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/BinaryResolverTest.java
diff --git a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/DocumentProtosTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/DocumentProtosTest.java
new file mode 100644
index 0000000000..e51a6e5a7b
--- /dev/null
+++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/DocumentProtosTest.java
@@ -0,0 +1,300 @@
+package org.openmbee.opensysml.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.openmbee.opensysml.DocumentRow;
+import org.openmbee.opensysml.DocumentValue;
+import org.openmbee.opensysml.Quantity;
+import org.openmbee.opensysml.proto.RunDocumentQueryResponse;
+
+/** Document-query values read off the wire and written back, and rows spread by their element. */
+class DocumentProtosTest {
+
+  private static DocumentValue roundTrip(org.openmbee.opensysml.proto.DocumentValue proto) {
+    return Protos.documentValue(Protos.proto(Protos.documentValue(proto)));
+  }
+
+  @Test
+  void anElementValueCarriesItsNameAndTypeBothDirections() {
+    var proto =
+        org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+            .setElementId("Observatory::telescope")
+            .setElementType("PartUsage")
+            .build();
+    DocumentValue value = Protos.documentValue(proto);
+    assertEquals(new DocumentValue.ElementRef("Observatory::telescope", "PartUsage"), value);
+    assertEquals(proto, Protos.proto(value));
+  }
+
+  @Test
+  void anAnonymousElementIsAReferenceOfTypeAlone() {
+    var proto =
+        org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+            .setElementType("PartUsage")
+            .build();
+    assertEquals(new DocumentValue.ElementRef("", "PartUsage"), Protos.documentValue(proto));
+    assertEquals(proto, Protos.proto(new DocumentValue.ElementRef("", "PartUsage")));
+  }
+
+  @Test
+  void everyLiteralKindRoundTrips() {
+    assertEquals(
+        new DocumentValue.StringValue("s"),
+        roundTrip(
+            org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                .setStringValue("s")
+                .build()));
+    assertEquals(
+        new DocumentValue.IntegerValue(7),
+        roundTrip(
+            org.openmbee.opensysml.proto.DocumentValue.newBuilder().setIntValue(7).build()));
+    assertEquals(
+        new DocumentValue.RealValue(1.5),
+        roundTrip(
+            org.openmbee.opensysml.proto.DocumentValue.newBuilder().setRealValue(1.5).build()));
+    assertEquals(
+        new DocumentValue.BooleanValue(true),
+        roundTrip(
+            org.openmbee.opensysml.proto.DocumentValue.newBuilder().setBoolValue(true).build()));
+    assertEquals(
+        new DocumentValue.InfinityValue(),
+        roundTrip(
+            org.openmbee.opensysml.proto.DocumentValue.newBuilder().setInfinity(true).build()));
+  }
+
+  @Test
+  void aQuantityValueRoundTripsThroughTheQuantityMapping() {
+    Quantity quantity = new Quantity(2.5, Optional.empty(), Optional.empty());
+    var proto =
+        org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+            .setQuantity(
+                org.openmbee.opensysml.proto.Quantity.newBuilder().setRealMagnitude(2.5))
+            .build();
+    DocumentValue value = Protos.documentValue(proto);
+    assertEquals(new DocumentValue.QuantityValue(quantity), value);
+    assertEquals(proto, Protos.proto(value));
+  }
+
+  @Test
+  void anObjectValueCarriesIdPathAndElement() {
+    var proto =
+        org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+            .setObject(
+                org.openmbee.opensysml.proto.DocumentObject.newBuilder()
+                    .setInstanceId(2)
+                    .setPath("Garage::car.wheels[2]")
+                    .setElement(
+                        org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                            .setElementId("Garage::car::wheels")
+                            .setElementType("PartUsage")))
+            .build();
+    DocumentValue value = Protos.documentValue(proto);
+    assertEquals(
+        new DocumentValue.ObjectRef(
+            2, "Garage::car.wheels[2]",
+            Optional.of(new DocumentValue.ElementRef("Garage::car::wheels", "PartUsage"))),
+        value);
+    assertEquals(proto, Protos.proto(value));
+  }
+
+  @Test
+  void aVerdictValueCarriesItsAssertionAndVerificationKinds() {
+    var verdict =
+        org.openmbee.opensysml.proto.DocumentVerdict.newBuilder()
+            .setAssertion(
+                org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                    .setElementId("Garage::Checks::massInRange")
+                    .setElementType("ConstraintUsage"))
+            .setKind("constraint")
+            .setText("assert constraint massInRange")
+            .setPath("Garage::car")
+            .setVerdict("holds")
+            .addVerification("pass")
+            .build();
+    var proto =
+        org.openmbee.opensysml.proto.DocumentValue.newBuilder().setVerdict(verdict).build();
+    DocumentValue value = Protos.documentValue(proto);
+    assertInstanceOf(DocumentValue.DocumentVerdict.class, value);
+    var read = (DocumentValue.DocumentVerdict) value;
+    assertEquals(
+        new DocumentValue.ElementRef("Garage::Checks::massInRange", "ConstraintUsage"),
+        read.assertion());
+    assertEquals("holds", read.status());
+    assertEquals(List.of("pass"), read.verification());
+    assertEquals(proto, Protos.proto(value));
+  }
+
+  @Test
+  void aStateValueCarriesItsMachineLeafAndEnclosures() {
+    var proto =
+        org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+            .setState(
+                org.openmbee.opensysml.proto.DocumentState.newBuilder()
+                    .setObject(
+                        org.openmbee.opensysml.proto.DocumentObject.newBuilder()
+                            .setInstanceId(3)
+                            .setPath("Car::lights"))
+                    .setMachine("lp")
+                    .setName("dim")
+                    .setStatePath("on.dim")
+                    .setRegion("light")
+                    .addEnclosing("on"))
+            .build();
+    DocumentValue value = Protos.documentValue(proto);
+    assertInstanceOf(DocumentValue.DocumentState.class, value);
+    var read = (DocumentValue.DocumentState) value;
+    assertEquals(3, read.object().id());
+    assertEquals("on.dim", read.path());
+    assertEquals(List.of("on"), read.enclosing());
+    assertEquals(proto, Protos.proto(value));
+  }
+
+  @Test
+  void anEventValueCarriesItsKindTimeAndRoles() {
+    var proto =
+        org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+            .setEvent(
+                org.openmbee.opensysml.proto.DocumentEvent.newBuilder()
+                    .setKind("transition")
+                    .setTime(
+                        org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                            .setRealValue(1.5))
+                    .setText("transition")
+                    .setMachine("lp")
+                    .setFrom("off")
+                    .setTo("on")
+                    .setEvent("")
+                    .setTaken(""))
+            .build();
+    DocumentValue value = Protos.documentValue(proto);
+    assertInstanceOf(DocumentValue.DocumentEvent.class, value);
+    var read = (DocumentValue.DocumentEvent) value;
+    assertEquals("transition", read.kind());
+    assertEquals(new DocumentValue.RealValue(1.5), read.time());
+    assertEquals("off", read.from());
+    assertEquals("on", read.to());
+    assertEquals(proto, Protos.proto(value));
+  }
+
+  private static DocumentRow rowOf(org.openmbee.opensysml.proto.DocumentValue element) {
+    var response =
+        RunDocumentQueryResponse.newBuilder()
+            .addColumns(org.openmbee.opensysml.proto.DocumentQueryColumn.newBuilder().setName("name"))
+            .addRows(
+                org.openmbee.opensysml.proto.DocumentQueryRow.newBuilder()
+                    .setElement(element)
+                    .addCells(
+                        org.openmbee.opensysml.proto.DocumentQueryCell.newBuilder()
+                            .addValues(
+                                org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                                    .setStringValue("x"))))
+            .build();
+    return Protos.documentQueryResult(response).rows().get(0);
+  }
+
+  @Test
+  void anElementRowCarriesTheElementAndCells() {
+    DocumentRow row =
+        rowOf(
+            org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                .setElementId("Observatory::telescope")
+                .setElementType("PartUsage")
+                .build());
+    assertEquals(new DocumentValue.ElementRef("Observatory::telescope", "PartUsage"), row.element());
+    assertEquals(1, row.cells().size());
+    assertEquals(List.of(new DocumentValue.StringValue("x")), row.cells().get(0));
+    assertTrue(row.verdict().isEmpty());
+    assertTrue(row.object().isEmpty());
+    assertTrue(row.state().isEmpty());
+    assertTrue(row.event().isEmpty());
+  }
+
+  @Test
+  void aVerdictRowAnswersTheAssertionAsItsElement() {
+    DocumentRow row =
+        rowOf(
+            org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                .setVerdict(
+                    org.openmbee.opensysml.proto.DocumentVerdict.newBuilder()
+                        .setAssertion(
+                            org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                                .setElementId("Garage::Checks::massInRange"))
+                        .setKind("requirement")
+                        .setVerdict("violated")
+                        .setReason("mass exceeds"))
+                .build());
+    assertTrue(row.verdict().isPresent());
+    assertEquals("violated", row.verdict().orElseThrow().status());
+    assertEquals(new DocumentValue.ElementRef("Garage::Checks::massInRange", ""), row.element());
+    assertTrue(row.object().isEmpty());
+  }
+
+  @Test
+  void anObjectRowAnswersTheHoldingUsageAsItsElement() {
+    var objectRef =
+        org.openmbee.opensysml.proto.DocumentObject.newBuilder()
+            .setInstanceId(4)
+            .setPath("Garage::car")
+            .setElement(
+                org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                    .setElementId("Garage::car")
+                    .setElementType("PartUsage"));
+    DocumentRow row =
+        rowOf(
+            org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                .setObject(objectRef)
+                .build());
+    assertTrue(row.object().isPresent());
+    assertEquals(4, row.object().orElseThrow().id());
+    assertEquals(new DocumentValue.ElementRef("Garage::car", "PartUsage"), row.element());
+  }
+
+  @Test
+  void stateAndEventRowsAnswerTheirObjectsElement() {
+    DocumentRow stateRow =
+        rowOf(
+            org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                .setState(
+                    org.openmbee.opensysml.proto.DocumentState.newBuilder()
+                        .setObject(
+                            org.openmbee.opensysml.proto.DocumentObject.newBuilder()
+                                .setInstanceId(3)
+                                .setPath("Car::lights")
+                                .setElement(
+                                    org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                                        .setElementId("Car::lights")
+                                        .setElementType("PartUsage")))
+                        .setName("dim")
+                        .setStatePath("on.dim"))
+                .build());
+    assertTrue(stateRow.state().isPresent());
+    assertTrue(stateRow.object().isPresent());
+    assertEquals(new DocumentValue.ElementRef("Car::lights", "PartUsage"), stateRow.element());
+
+    var eventObject =
+        org.openmbee.opensysml.proto.DocumentObject.newBuilder()
+            .setInstanceId(3)
+            .setElement(
+                org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                    .setElementId("Car::lights"));
+    DocumentRow eventRow =
+        rowOf(
+            org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                .setEvent(
+                    org.openmbee.opensysml.proto.DocumentEvent.newBuilder()
+                        .setKind("accept")
+                        .setTime(
+                            org.openmbee.opensysml.proto.DocumentValue.newBuilder()
+                                .setIntValue(0))
+                        .setObject(eventObject))
+                .build());
+    assertTrue(eventRow.event().isPresent());
+    assertTrue(eventRow.object().isPresent());
+    assertEquals(new DocumentValue.ElementRef("Car::lights", ""), eventRow.element());
+  }
+}
diff --git a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/EditProtosTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/EditProtosTest.java
new file mode 100644
index 0000000000..411efd6ad4
--- /dev/null
+++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/EditProtosTest.java
@@ -0,0 +1,123 @@
+package org.openmbee.opensysml.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.openmbee.opensysml.Edit;
+import org.openmbee.opensysml.EditFailure;
+import org.openmbee.opensysml.Language;
+import org.openmbee.opensysml.SourceDocument;
+import org.openmbee.opensysml.SweepRange;
+import org.openmbee.opensysml.Value;
+
+/** Edits, source documents and sweep ranges as the wire carries them, both directions. */
+class EditProtosTest {
+
+  @Test
+  void aFileDocumentCarriesItsPathAndNoContent() {
+    var proto = Protos.proto(SourceDocument.file(Path.of("/tmp/a.sysml")));
+    assertEquals("/tmp/a.sysml", proto.getFilePath());
+    assertFalse(proto.hasContent());
+    assertEquals("", proto.getName());
+  }
+
+  @Test
+  void anInlineDocumentCarriesItsContentNameAndLanguage() {
+    var proto =
+        Protos.proto(
+            SourceDocument.inline("lib.kerml", "package L {}").withLanguage(Language.KERML));
+    assertEquals("lib.kerml", proto.getName());
+    assertEquals("package L {}", proto.getContent());
+    assertEquals("kerml", proto.getLanguage());
+    assertFalse(proto.hasFilePath());
+  }
+
+  @Test
+  void aSetValueEditCarriesTargetAndValue() {
+    var proto = Protos.proto(new Edit.SetValue("Demo::SC::unitMass", "100.0"));
+    assertEquals("Demo::SC::unitMass", proto.getSetValue().getTarget());
+    assertEquals("100.0", proto.getSetValue().getValue());
+    assertEquals(
+        org.openmbee.opensysml.proto.EditOperation.OperationCase.SET_VALUE,
+        proto.getOperationCase());
+  }
+
+  @Test
+  void aRenameEditCarriesTargetAndNewName() {
+    var proto = Protos.proto(new Edit.Rename("Demo::A", "B"));
+    assertEquals("Demo::A", proto.getRename().getTarget());
+    assertEquals("B", proto.getRename().getNewName());
+  }
+
+  @Test
+  void anAddMemberEditCarriesEveryFieldItNames() {
+    var minimal = Protos.proto(Edit.AddMember.of("Demo::A", "part", "b"));
+    assertEquals("Demo::A", minimal.getAddMember().getOwner());
+    assertEquals("part", minimal.getAddMember().getKind());
+    assertEquals("b", minimal.getAddMember().getName());
+    assertEquals("", minimal.getAddMember().getType());
+    assertEquals(0, minimal.getAddMember().getSpecializesCount());
+
+    var full =
+        Protos.proto(
+            Edit.AddMember.of("Demo::A", "attribute", "x")
+                .withType("Real")
+                .withMultiplicity("0..1")
+                .withValue("1.0")
+                .withSpecializes(List.of("Demo::A::y")));
+    assertEquals("Real", full.getAddMember().getType());
+    assertEquals("0..1", full.getAddMember().getMultiplicity());
+    assertEquals("1.0", full.getAddMember().getValue());
+    assertEquals(List.of("Demo::A::y"), full.getAddMember().getSpecializesList());
+  }
+
+  @Test
+  void aDeleteEditCarriesItsCascadeAndAMoveEditItsOwner() {
+    var delete = Protos.proto(new Edit.Delete("Demo::A", true));
+    assertEquals("Demo::A", delete.getDelete().getTarget());
+    assertTrue(delete.getDelete().getCascade());
+    var move = Protos.proto(new Edit.Move("Demo::A::b", "Demo::C"));
+    assertEquals("Demo::A::b", move.getMove().getTarget());
+    assertEquals("Demo::C", move.getMove().getOwner());
+  }
+
+  @Test
+  void everyKnownEditFailureReadsAsItselfAndANewOneAsUnrecognized() {
+    assertEquals(
+        EditFailure.UNKNOWN_TARGET,
+        Protos.editFailure(org.openmbee.opensysml.proto.EditFailure.EDIT_FAILURE_UNKNOWN_TARGET));
+    assertEquals(
+        EditFailure.REFERENCED_ELSEWHERE,
+        Protos.editFailure(
+            org.openmbee.opensysml.proto.EditFailure.EDIT_FAILURE_REFERENCED_ELSEWHERE));
+    var future = org.openmbee.opensysml.proto.EditFailure.UNRECOGNIZED;
+    assertEquals(EditFailure.UNRECOGNIZED, Protos.editFailure(future));
+    assertEquals("EDIT_FAILURE_99", Protos.editFailureName(future, 99));
+    assertEquals(
+        "EDIT_FAILURE_UNKNOWN_TARGET",
+        Protos.editFailureName(
+            org.openmbee.opensysml.proto.EditFailure.EDIT_FAILURE_UNKNOWN_TARGET,
+            org.openmbee.opensysml.proto.EditFailure.EDIT_FAILURE_UNKNOWN_TARGET.getNumber()));
+  }
+
+  @Test
+  void aSweepRangeCarriesItsStepOnlyWhenNamed() {
+    var stepped =
+        Protos.proto(
+            SweepRange.of("b", new Value.IntegerValue(1), new Value.IntegerValue(4))
+                .withStep(new Value.IntegerValue(1)));
+    assertEquals("b", stepped.getParameter());
+    assertEquals(1, stepped.getStart().getIntValue());
+    assertEquals(4, stepped.getEnd().getIntValue());
+    assertEquals(1, stepped.getStep().getIntValue());
+
+    var set =
+        Protos.proto(
+            SweepRange.of("b", new Value.IntegerValue(1), new Value.IntegerValue(4)));
+    assertFalse(set.hasStep());
+  }
+}
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/JsonTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/JsonTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/JsonTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/JsonTest.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ProtosTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ProtosTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ProtosTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ProtosTest.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ReleaseAssetsTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ReleaseAssetsTest.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ReleaseAssetsTest.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ReleaseAssetsTest.java
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ReleaseServer.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ReleaseServer.java
similarity index 100%
rename from clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ReleaseServer.java
rename to client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ReleaseServer.java
diff --git a/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java
new file mode 100644
index 0000000000..d63313e22c
--- /dev/null
+++ b/client/java/opensysml-client/src/test/java/org/openmbee/opensysml/internal/ResultProtosTest.java
@@ -0,0 +1,475 @@
+package org.openmbee.opensysml.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalDouble;
+import org.junit.jupiter.api.Test;
+import org.openmbee.opensysml.ActionRun;
+import org.openmbee.opensysml.Analysis;
+import org.openmbee.opensysml.Calculation;
+import org.openmbee.opensysml.Condition;
+import org.openmbee.opensysml.EngineInfo;
+import org.openmbee.opensysml.Exploration;
+import org.openmbee.opensysml.FailureReason;
+import org.openmbee.opensysml.Query;
+import org.openmbee.opensysml.QueryElement;
+import org.openmbee.opensysml.Quantity;
+import org.openmbee.opensysml.Standing;
+import org.openmbee.opensysml.StateRun;
+import org.openmbee.opensysml.Validation;
+import org.openmbee.opensysml.Value;
+import org.openmbee.opensysml.Verdict;
+import org.openmbee.opensysml.Verification;
+import org.openmbee.opensysml.VerificationVerdict;
+import org.openmbee.opensysml.proto.Bound;
+import org.openmbee.opensysml.proto.CalcOutput;
+import org.openmbee.opensysml.proto.CaseEvaluation;
+import org.openmbee.opensysml.proto.CompositeOperator;
+import org.openmbee.opensysml.proto.Constraint;
+import org.openmbee.opensysml.proto.Diagnostic;
+import org.openmbee.opensysml.proto.EvaluateCalcResponse;
+import org.openmbee.opensysml.proto.ExecuteActionResponse;
+import org.openmbee.opensysml.proto.ExecuteStateResponse;
+import org.openmbee.opensysml.proto.ExplorationStatus;
+import org.openmbee.opensysml.proto.Instance;
+import org.openmbee.opensysml.proto.Outcome;
+import org.openmbee.opensysml.proto.PrimitiveOperator;
+import org.openmbee.opensysml.proto.QueryResultElement;
+import org.openmbee.opensysml.proto.RunAnalysisResponse;
+import org.openmbee.opensysml.proto.UnitFactor;
+import org.openmbee.opensysml.proto.UnitTerm;
+import org.openmbee.opensysml.proto.ValidateInstanceResponse;
+import org.openmbee.opensysml.proto.VerifyConstraintResponse;
+
+/** Reading execution, verification, analysis and query answers off the wire, and writing requests. */
+class ResultProtosTest {
+
+  private static org.openmbee.opensysml.proto.Value integer(long value) {
+    return org.openmbee.opensysml.proto.Value.newBuilder().setIntValue(value).build();
+  }
+
+  private static org.openmbee.opensysml.proto.Value real(double value) {
+    return org.openmbee.opensysml.proto.Value.newBuilder().setRealValue(value).build();
+  }
+
+  @Test
+  void aVerdictKeepsEveryFieldAndReadsAnAbsentObjectAsDeclaredValues() {
+    var wire =
+        org.openmbee.opensysml.proto.Verdict.newBuilder()
+            .setKind("constraint")
+            .setElementId("Demo::Vehicle::massLight")
+            .setElement("massLight")
+            .setHolds(false)
+            .setCondition("mass < 100.0")
+            .setRequirementId("Demo::Vehicle::lightEnough")
+            .setInstancePath("sedan")
+            .setEngine("interval")
+            .setStrength("sound")
+            .addBounds(Bound.newBuilder().setName("depth").setLimit(8).setReached(true))
+            .build();
+    Verdict verdict = Protos.verdict(wire);
+    assertEquals(Verdict.KIND_CONSTRAINT, verdict.kind());
+    assertEquals(Optional.of("Demo::Vehicle::massLight"), verdict.elementId());
+    assertEquals("massLight", verdict.element());
+    assertFalse(verdict.holds());
+    assertTrue(verdict.decided());
+    assertTrue(verdict.violated());
+    assertEquals(Optional.of("mass < 100.0"), verdict.condition());
+    assertEquals(Optional.empty(), verdict.instanceId());
+    assertEquals(Optional.empty(), verdict.instanceTypeId());
+    assertEquals(Optional.empty(), verdict.error());
+    assertEquals(FailureReason.UNSPECIFIED, verdict.failureReason());
+    assertEquals(Optional.of("Demo::Vehicle::lightEnough"), verdict.requirementId());
+    assertEquals(Optional.of("sedan"), verdict.instancePath());
+    assertEquals(
+        new Standing("interval", "sound", List.of(new Standing.Bound("depth", 8, true))),
+        verdict.standing());
+
+    Verdict about =
+        Protos.verdict(
+            org.openmbee.opensysml.proto.Verdict.newBuilder()
+                .setKind("object")
+                .setInstanceId(3)
+                .setInstanceTypeId("Demo::sedan")
+                .setError("mass is unbound")
+                .setFailureReason(
+                    org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_EVALUATION)
+                .build());
+    assertEquals(Optional.of(3L), about.instanceId());
+    assertEquals(Optional.of("Demo::sedan"), about.instanceTypeId());
+    assertEquals(Optional.of("mass is unbound"), about.error());
+    assertEquals(FailureReason.EVALUATION, about.failureReason());
+    assertFalse(about.decided());
+    assertFalse(about.standing().reported());
+  }
+
+  @Test
+  void aFailureReasonThisReleaseDoesNotKnowIsUnknownRatherThanRefused() {
+    assertEquals(
+        FailureReason.UNKNOWN,
+        Protos.failureReason(org.openmbee.opensysml.proto.FailureReason.UNRECOGNIZED));
+    assertEquals(
+        FailureReason.WRONG_KIND,
+        Protos.failureReason(org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_WRONG_KIND));
+    assertEquals(
+        FailureReason.AMBIGUOUS_SUBJECT,
+        Protos.failureReason(
+            org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_AMBIGUOUS_SUBJECT));
+  }
+
+  @Test
+  void aVerificationCarriesItsObjectsBodyVerdictsAndDiagnostics() {
+    VerifyConstraintResponse response =
+        VerifyConstraintResponse.newBuilder()
+            .setVerdict(
+                org.openmbee.opensysml.proto.Verdict.newBuilder()
+                    .setKind("constraint")
+                    .setHolds(true)
+                    .setInstanceId(1))
+            .addInstances(Instance.newBuilder().setId(1).setTypeSymbolId("Demo::sedan"))
+            .addDiagnostics(Diagnostic.newBuilder().setMessage("note").setSeverity("info"))
+            .build();
+    Verification verification = Protos.verification(response);
+    assertTrue(verification.holds());
+    assertEquals("Demo::sedan", verification.subject().orElseThrow().typeSymbolId());
+    assertEquals(1, verification.diagnostics().size());
+    assertEquals("note", verification.diagnostics().get(0).message());
+    assertEquals(List.of(), verification.verifications());
+  }
+
+  @Test
+  void aValidationKeepsItsSummaryItsVerdictsAndWhetherItWasBounded() {
+    ValidateInstanceResponse response =
+        ValidateInstanceResponse.newBuilder()
+            .setSummary(
+                org.openmbee.opensysml.proto.Verdict.newBuilder()
+                    .setKind("object")
+                    .setHolds(false)
+                    .setInstanceId(1))
+            .addVerdicts(
+                org.openmbee.opensysml.proto.Verdict.newBuilder()
+                    .setKind("assertion")
+                    .setHolds(false)
+                    .setRequirementId("Demo::massTiny"))
+            .addVerificationVerdicts(
+                org.openmbee.opensysml.proto.VerificationVerdict.newBuilder()
+                    .setCaseId("Demo::checkTiny")
+                    .setKind("fail")
+                    .setDetail("10 < 1200")
+                    .setSubcase(true)
+                    .setRequirementId("Demo::massTiny"))
+            .addInstances(Instance.newBuilder().setId(1).setTypeSymbolId("Demo::sedan"))
+            .setBounded(true)
+            .build();
+    Validation validation = Protos.validation(response);
+    assertFalse(validation.holds());
+    assertTrue(validation.bounded());
+    assertEquals("Demo::sedan", validation.root().orElseThrow().typeSymbolId());
+    assertEquals(1, validation.verdicts().size());
+    VerificationVerdict body = validation.verifications().get(0);
+    assertEquals(
+        new VerificationVerdict(
+            "Demo::checkTiny",
+            VerificationVerdict.FAIL,
+            Optional.of("10 < 1200"),
+            true,
+            Optional.of("Demo::massTiny")),
+        body);
+    assertFalse(body.passed());
+  }
+
+  @Test
+  void aCalculationReadsADirectResultOrNamedOutputsInOrderWithItsStanding() {
+    Calculation direct =
+        Protos.calculation(EvaluateCalcResponse.newBuilder().setResult(integer(5)).build());
+    assertEquals(Optional.of(new Value.IntegerValue(5)), direct.result());
+    assertEquals(Optional.of(new Value.IntegerValue(5)), direct.value());
+    assertFalse(direct.standing().reported());
+
+    Calculation named =
+        Protos.calculation(
+            EvaluateCalcResponse.newBuilder()
+                .addOutputs(CalcOutput.newBuilder().setName("z").setValue(real(1.5)))
+                .addOutputs(CalcOutput.newBuilder().setName("a").setValue(real(2.5)))
+                .setEngine("interval")
+                .setStrength("sound")
+                .addBounds(Bound.newBuilder().setName("runs").setLimit(100))
+                .build());
+    assertEquals(Optional.empty(), named.result());
+    assertEquals(List.of("z", "a"), List.copyOf(named.outputs().keySet()));
+    assertEquals(Optional.empty(), named.value());
+    assertEquals("interval", named.standing().engine());
+    assertEquals("sound", named.standing().strength());
+    assertEquals(List.of(new Standing.Bound("runs", 100, false)), named.standing().bounds());
+  }
+
+  @Test
+  void anAnalysisKeepsEachEvaluationItsErrorAndTheObjectsTheOutputsReferTo() {
+    RunAnalysisResponse response =
+        RunAnalysisResponse.newBuilder()
+            .addOutputs(
+                CalcOutput.newBuilder()
+                    .setName("selectedAlternative")
+                    .setValue(
+                        org.openmbee.opensysml.proto.Value.newBuilder().setInstanceId(7)))
+            .addVerdicts(
+                org.openmbee.opensysml.proto.Verdict.newBuilder()
+                    .setKind("objective")
+                    .setElement("tradeStudyObjective")
+                    .setHolds(true))
+            .addEvaluations(
+                CaseEvaluation.newBuilder()
+                    .setFunctionId("Trade::lightest::evaluationFunction")
+                    .addArguments(
+                        org.openmbee.opensysml.proto.Value.newBuilder().setInstanceId(7))
+                    .setResult(real(10.0))
+                    .setSelected(true))
+            .addEvaluations(
+                CaseEvaluation.newBuilder()
+                    .setFunctionId("Trade::lightest::evaluationFunction")
+                    .addArguments(
+                        org.openmbee.opensysml.proto.Value.newBuilder().setInstanceId(8))
+                    .setError("division by zero")
+                    .setTied(true))
+            .addInstances(Instance.newBuilder().setId(7).setTypeSymbolId("Trade::b"))
+            .addInstances(Instance.newBuilder().setId(8).setTypeSymbolId("Trade::c"))
+            .setEngine("run")
+            .setStrength("observed")
+            .build();
+    Analysis analysis = Protos.analysis(response);
+    assertTrue(analysis.holds());
+    assertEquals("tradeStudyObjective", analysis.objective().orElseThrow().element());
+    assertEquals(2, analysis.evaluations().size());
+    org.openmbee.opensysml.CaseEvaluation selected = analysis.selected().orElseThrow();
+    assertEquals(List.of(new Value.InstanceReference(7)), selected.arguments());
+    assertEquals(Optional.of(new Value.RealValue(10.0)), selected.result());
+    assertEquals(Optional.empty(), selected.error());
+    org.openmbee.opensysml.CaseEvaluation failed = analysis.evaluations().get(1);
+    assertEquals(Optional.empty(), failed.result());
+    assertEquals(Optional.of("division by zero"), failed.error());
+    assertTrue(failed.tied());
+    assertFalse(failed.selected());
+    assertEquals(
+        "Trade::b",
+        analysis
+            .resolve((Value.InstanceReference) analysis.outputs().get("selectedAlternative"))
+            .orElseThrow()
+            .typeSymbolId());
+    assertEquals(new Standing("run", "observed", List.of()), analysis.standing());
+  }
+
+  @Test
+  void aRunReportsItsFinalTimeOnlyWhenTheServiceDoes() {
+    ExecuteActionResponse action =
+        ExecuteActionResponse.newBuilder()
+            .putOutputs("result", integer(5))
+            .setFinalTime(2.5)
+            .addDiagnostics(Diagnostic.newBuilder().setMessage("ran").setSeverity("info"))
+            .build();
+    ActionRun reported = Protos.actionRun(action, true);
+    assertEquals(Map.of("result", new Value.IntegerValue(5)), reported.outputs());
+    assertEquals(OptionalDouble.of(2.5), reported.finalTime());
+    assertEquals(1, reported.diagnostics().size());
+    assertEquals(OptionalDouble.empty(), Protos.actionRun(action, false).finalTime());
+
+    ExecuteStateResponse state =
+        ExecuteStateResponse.newBuilder()
+            .addStatesVisited("init")
+            .addStatesVisited("Running")
+            .putFinalContext("n", integer(1))
+            .setFinalTime(0.0)
+            .build();
+    StateRun run = Protos.stateRun(state, true);
+    assertEquals(List.of("init", "Running"), run.statesVisited());
+    assertEquals(Optional.of("Running"), run.finalState());
+    assertEquals(Map.of("n", new Value.IntegerValue(1)), run.finalContext());
+    assertEquals(OptionalDouble.of(0.0), run.finalTime());
+  }
+
+  @Test
+  void anExplorationKeepsEveryOutcomeItsWitnessAndHowTheSearchEnded() {
+    List<Outcome> outcomes =
+        List.of(
+            Outcome.newBuilder()
+                .putOutputs("x", integer(1))
+                .setLinearizations(2)
+                .addWitness("first of a, b, c: a")
+                .setProbability(0.5)
+                .addDiagnostics(Diagnostic.newBuilder().setMessage("choice").setSeverity("info"))
+                .build(),
+            Outcome.newBuilder()
+                .setFinalState("done")
+                .addStatesVisited("init")
+                .addStatesVisited("done")
+                .setLinearizations(1)
+                .build(),
+            Outcome.newBuilder().setError("deadlock").setLinearizations(1).build());
+    ExplorationStatus status =
+        ExplorationStatus.newBuilder()
+            .setComplete(false)
+            .setRuns(100)
+            .addBudgetsHit("runs")
+            .setRunsBudget(100)
+            .setDepthBudget(64)
+            .setProbabilitiesLowerBound(true)
+            .build();
+    Exploration exploration = Protos.exploration(outcomes, status);
+    assertEquals(3, exploration.outcomes().size());
+    org.openmbee.opensysml.Outcome first = exploration.outcomes().get(0);
+    assertEquals(Map.of("x", new Value.IntegerValue(1)), first.outputs());
+    assertEquals(2, first.linearizations());
+    assertEquals(0.5, first.probability());
+    assertEquals(List.of("first of a, b, c: a"), first.witness());
+    assertEquals(1, first.diagnostics().size());
+    assertTrue(first.completed());
+    org.openmbee.opensysml.Outcome machine = exploration.outcomes().get(1);
+    assertEquals(Optional.of("done"), machine.finalState());
+    assertEquals(List.of("init", "done"), machine.statesVisited());
+    org.openmbee.opensysml.Outcome failed = exploration.outcomes().get(2);
+    assertEquals(Optional.of("deadlock"), failed.error());
+    assertFalse(failed.completed());
+    assertFalse(exploration.complete());
+    assertEquals(100, exploration.runs());
+    assertEquals(List.of("runs"), exploration.budgetsHit());
+    assertTrue(exploration.probabilitiesLowerBound());
+    assertEquals(
+        "incomplete: runs budget 100 hit after 100 runs; probabilities are lower bounds",
+        exploration.status());
+  }
+
+  @Test
+  void aQueryWritesItsScopeSelectionAndNestedConditions() {
+    Query query =
+        Query.all()
+            .withScope(List.of("Demo"))
+            .withSelect(List.of("name"))
+            .where(
+                Condition.all(
+                    List.of(
+                        Condition.equalTo("@type", List.of("PartUsage", "PartDefinition")).negated(),
+                        Condition.any(
+                            List.of(
+                                Condition.greater("mass", "1000"),
+                                Condition.less("mass", "10"))))));
+    org.openmbee.opensysml.proto.Query wire = Protos.proto(query);
+    assertEquals(List.of("Demo"), wire.getScopeList());
+    assertEquals(List.of("name"), wire.getSelectList());
+    Constraint where = wire.getWhere();
+    assertEquals(Constraint.ConstraintCase.COMPOSITE, where.getConstraintCase());
+    assertEquals(CompositeOperator.COMPOSITE_OPERATOR_AND, where.getComposite().getOperator());
+    var type = where.getComposite().getConstraint(0).getPrimitive();
+    assertTrue(type.getInverse());
+    assertEquals("@type", type.getProperty());
+    assertEquals(PrimitiveOperator.PRIMITIVE_OPERATOR_EQUAL, type.getOperator());
+    assertEquals(List.of("PartUsage", "PartDefinition"), type.getValueList());
+    var mass = where.getComposite().getConstraint(1).getComposite();
+    assertEquals(CompositeOperator.COMPOSITE_OPERATOR_OR, mass.getOperator());
+    assertEquals(
+        PrimitiveOperator.PRIMITIVE_OPERATOR_GREATER, mass.getConstraint(0).getPrimitive().getOperator());
+    assertEquals(List.of("1000"), mass.getConstraint(0).getPrimitive().getValueList());
+    assertEquals(
+        PrimitiveOperator.PRIMITIVE_OPERATOR_LESS, mass.getConstraint(1).getPrimitive().getOperator());
+    assertFalse(Protos.proto(Query.all()).hasWhere());
+  }
+
+  @Test
+  void queryElementsKeepTheirIdsTypesAndSelectedProperties() {
+    List<QueryElement> elements =
+        Protos.queryElements(
+            List.of(
+                QueryResultElement.newBuilder()
+                    .setId("Demo::sedan")
+                    .setType("PartUsage")
+                    .putProperties("name", "sedan")
+                    .build(),
+                QueryResultElement.newBuilder().setId("Demo::Wheel").setType("PartDefinition").build()));
+    assertEquals(
+        List.of(
+            new QueryElement("Demo::sedan", "PartUsage", Map.of("name", "sedan")),
+            new QueryElement("Demo::Wheel", "PartDefinition", Map.of())),
+        elements);
+  }
+
+  @Test
+  void engineDescriptionsKeepEveryField() {
+    List<EngineInfo> engines =
+        Protos.engines(
+            List.of(
+                org.openmbee.opensysml.proto.EngineInfo.newBuilder()
+                    .setName("interval")
+                    .setAuthority("sound")
+                    .addAnswers("constraint")
+                    .addBounds("depth")
+                    .setProcess("sysml-interval")
+                    .setProcessFound("/usr/bin/sysml-interval")
+                    .setReady(true)
+                    .setKind("external")
+                    .setProtocol("stdio")
+                    .setSource("Engines::interval")
+                    .setCommand("sysml-interval --serve")
+                    .setVersion("1.2.0")
+                    .setServed(true)
+                    .build()));
+    EngineInfo engine = engines.get(0);
+    assertEquals("interval", engine.name());
+    assertEquals("sound", engine.authority());
+    assertEquals(List.of("constraint"), engine.answers());
+    assertEquals(List.of("depth"), engine.bounds());
+    assertEquals("sysml-interval", engine.process());
+    assertEquals("/usr/bin/sysml-interval", engine.processFound());
+    assertTrue(engine.ready());
+    assertEquals("", engine.unavailable());
+    assertEquals("external", engine.kind());
+    assertEquals("stdio", engine.protocol());
+    assertEquals("Engines::interval", engine.source());
+    assertEquals("sysml-interval --serve", engine.command());
+    assertEquals("1.2.0", engine.version());
+    assertTrue(engine.served());
+  }
+
+  @Test
+  void aRequestValueSurvivesTheRoundTripThroughTheWire() {
+    UnitTerm metre =
+        UnitTerm.newBuilder()
+            .addFactors(UnitFactor.newBuilder().setUnitId("SI::metre").setExponent(1))
+            .setScaleNum(1000)
+            .setScaleDen(1)
+            .build();
+    List<Value> values =
+        List.of(
+            new Value.IntegerValue(3),
+            new Value.RealValue(2.5),
+            new Value.BooleanValue(true),
+            new Value.StringValue("four"),
+            new Value.ComplexValue(1.0, -2.0),
+            new Value.InstanceReference(7),
+            new Value.NullValue(),
+            new Value.Sequence(List.of(new Value.IntegerValue(1), new Value.StringValue("a"))),
+            new Value.VectorValue(List.of(new Value.RealValue(3.0), new Value.RealValue(4.0))),
+            new Value.QuantityValue(
+                Protos.quantity(
+                    org.openmbee.opensysml.proto.Quantity.newBuilder()
+                        .setIntMagnitude(3)
+                        .setUnit("km")
+                        .setUnitTerm(metre)
+                        .build())));
+    for (Value value : values) {
+      org.openmbee.opensysml.proto.Value wire = Protos.proto(value);
+      assertEquals(Optional.of(value), Protos.value(wire), value.toString());
+    }
+    List<org.openmbee.opensysml.proto.Value> wires = Protos.protos(values);
+    assertEquals(values.size(), wires.size());
+    Map<String, org.openmbee.opensysml.proto.Value> named =
+        Protos.protos(Map.of("limit", new Value.RealValue(50.0)));
+    assertEquals(50.0, named.get("limit").getRealValue());
+    Quantity quantity = ((Value.QuantityValue) values.get(9)).quantity();
+    assertEquals(Optional.of("km"), quantity.unit());
+    assertThrows(NullPointerException.class, () -> Protos.proto((Value) null));
+  }
+}
diff --git a/client/java/opensysml-conformance/pom.xml b/client/java/opensysml-conformance/pom.xml
new file mode 100644
index 0000000000..aad170370f
--- /dev/null
+++ b/client/java/opensysml-conformance/pom.xml
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openmbee</groupId>
+    <artifactId>opensysml-parent</artifactId>
+    <version>0.1.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>opensysml-conformance</artifactId>
+  <packaging>jar</packaging>
+
+  <name>OpenSysML Java conformance runner</name>
+  <description>Runs conformance/scenarios through the Java client's public API and writes the
+    report shape tools/cmd/conformance writes. Not published: a test tool, not a library.</description>
+  <url>https://github.com/Open-MBEE/OpenSysML/tree/main/client/java</url>
+
+  <properties>
+    <maven.deploy.skip>true</maven.deploy.skip>
+  </properties>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.openmbee</groupId>
+      <artifactId>opensysml-client</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+    <!-- Scenario files are protobuf JSON, so the runner needs the JSON parser the
+         client leaves optional. -->
+    <dependency>
+      <groupId>com.google.protobuf</groupId>
+      <artifactId>protobuf-java-util</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>com.google.code.gson</groupId>
+      <artifactId>gson</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter</artifactId>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-jar-plugin</artifactId>
+        <configuration>
+          <archive>
+            <manifest>
+              <mainClass>org.openmbee.opensysml.conformance.Main</mainClass>
+              <addClasspath>true</addClasspath>
+              <classpathPrefix>lib/</classpathPrefix>
+            </manifest>
+          </archive>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java
new file mode 100644
index 0000000000..fff9efe54e
--- /dev/null
+++ b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java
@@ -0,0 +1,888 @@
+package org.openmbee.opensysml.conformance;
+
+import com.google.protobuf.Message;
+import org.openmbee.opensysml.ActionRun;
+import org.openmbee.opensysml.Analysis;
+import org.openmbee.opensysml.AnalysisException;
+import org.openmbee.opensysml.AnalysisOptions;
+import org.openmbee.opensysml.Calculation;
+import org.openmbee.opensysml.Capabilities;
+import org.openmbee.opensysml.CapabilityException;
+import org.openmbee.opensysml.Condition;
+import org.openmbee.opensysml.Connection;
+import org.openmbee.opensysml.Conversion;
+import org.openmbee.opensysml.ConversionOptions;
+import org.openmbee.opensysml.DocumentQueryResult;
+import org.openmbee.opensysml.DocumentValue;
+import org.openmbee.opensysml.Edit;
+import org.openmbee.opensysml.EditException;
+import org.openmbee.opensysml.EditOptions;
+import org.openmbee.opensysml.EditResult;
+import org.openmbee.opensysml.ExecutionOptions;
+import org.openmbee.opensysml.Exploration;
+import org.openmbee.opensysml.Instantiation;
+import org.openmbee.opensysml.Language;
+import org.openmbee.opensysml.Model;
+import org.openmbee.opensysml.ModelException;
+import org.openmbee.opensysml.ParseOptions;
+import org.openmbee.opensysml.Query;
+import org.openmbee.opensysml.QueryElement;
+import org.openmbee.opensysml.RenderedDocument;
+import org.openmbee.opensysml.Satisfaction;
+import org.openmbee.opensysml.ServiceException;
+import org.openmbee.opensysml.SourceDocument;
+import org.openmbee.opensysml.StateRun;
+import org.openmbee.opensysml.SweepOptions;
+import org.openmbee.opensysml.SweepRange;
+import org.openmbee.opensysml.Symbol;
+import org.openmbee.opensysml.TransportException;
+import org.openmbee.opensysml.Validation;
+import org.openmbee.opensysml.Value;
+import org.openmbee.opensysml.Verification;
+import org.openmbee.opensysml.internal.Protos;
+import org.openmbee.opensysml.proto.ApplyEditsRequest;
+import org.openmbee.opensysml.proto.ApplyEditsResponse;
+import org.openmbee.opensysml.proto.CompositeConstraint;
+import org.openmbee.opensysml.proto.Constraint;
+import org.openmbee.opensysml.proto.ConvertRequest;
+import org.openmbee.opensysml.proto.ConvertResponse;
+import org.openmbee.opensysml.proto.DiagnosticsRequest;
+import org.openmbee.opensysml.proto.DiagnosticsResponse;
+import org.openmbee.opensysml.proto.EvaluateCalcRequest;
+import org.openmbee.opensysml.proto.EvaluateCalcResponse;
+import org.openmbee.opensysml.proto.EvaluateRequest;
+import org.openmbee.opensysml.proto.EvaluateResponse;
+import org.openmbee.opensysml.proto.ExecuteActionRequest;
+import org.openmbee.opensysml.proto.ExecuteActionResponse;
+import org.openmbee.opensysml.proto.ExecuteStateRequest;
+import org.openmbee.opensysml.proto.ExecuteStateResponse;
+import org.openmbee.opensysml.proto.GetSymbolRequest;
+import org.openmbee.opensysml.proto.InstantiateRequest;
+import org.openmbee.opensysml.proto.InstantiateResponse;
+import org.openmbee.opensysml.proto.ListEnginesRequest;
+import org.openmbee.opensysml.proto.ListEnginesResponse;
+import org.openmbee.opensysml.proto.ParseFileRequest;
+import org.openmbee.opensysml.proto.ParseFileResponse;
+import org.openmbee.opensysml.proto.ParseSourcesRequest;
+import org.openmbee.opensysml.proto.ParseSourcesResponse;
+import org.openmbee.opensysml.proto.PrimitiveConstraint;
+import org.openmbee.opensysml.proto.QueryRequest;
+import org.openmbee.opensysml.proto.QueryResponse;
+import org.openmbee.opensysml.proto.RenderDocumentRequest;
+import org.openmbee.opensysml.proto.RenderDocumentResponse;
+import org.openmbee.opensysml.proto.RunAnalysisRequest;
+import org.openmbee.opensysml.proto.RunAnalysisResponse;
+import org.openmbee.opensysml.proto.RunDocumentQueryRequest;
+import org.openmbee.opensysml.proto.RunDocumentQueryResponse;
+import org.openmbee.opensysml.proto.RunSweepRequest;
+import org.openmbee.opensysml.proto.RunSweepResponse;
+import org.openmbee.opensysml.proto.ServerInfoRequest;
+import org.openmbee.opensysml.proto.ServerInfoResponse;
+import org.openmbee.opensysml.proto.SymbolResponse;
+import org.openmbee.opensysml.proto.Sysml;
+import org.openmbee.opensysml.proto.ValidateInstanceRequest;
+import org.openmbee.opensysml.proto.ValidateInstanceResponse;
+import org.openmbee.opensysml.proto.VerifyConstraintRequest;
+import org.openmbee.opensysml.proto.VerifyConstraintResponse;
+import org.openmbee.opensysml.proto.VerifyRequirementRequest;
+import org.openmbee.opensysml.proto.VerifyRequirementResponse;
+import org.openmbee.opensysml.proto.VerifySatisfactionRequest;
+import org.openmbee.opensysml.proto.VerifySatisfactionResponse;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Makes a scenario's call through the client's public API and writes the answer back as the
+ * response message the scenario is stated against. An RPC the API does not cover is reported as such
+ * rather than called over the transport, which is what the skipped count in a report counts.
+ */
+final class Api {
+
+  private static final String RPC_EVALUATE = "Evaluate";
+  private static final String RPC_EVALUATE_CALC = "EvaluateCalc";
+  private static final String RPC_EXECUTE_ACTION = "ExecuteAction";
+  private static final String RPC_EXECUTE_STATE = "ExecuteState";
+  private static final String RPC_GET_DIAGNOSTICS = "GetDiagnostics";
+  private static final String RPC_GET_SERVER_INFO = "GetServerInfo";
+  private static final String RPC_GET_SYMBOL = "GetSymbol";
+  private static final String RPC_INSTANTIATE = "Instantiate";
+  private static final String RPC_LIST_ENGINES = "ListEngines";
+  private static final String RPC_PARSE_FILE = "ParseFile";
+  private static final String RPC_PARSE_SOURCES = "ParseSources";
+  private static final String RPC_CONVERT = "Convert";
+  private static final String RPC_APPLY_EDITS = "ApplyEdits";
+  private static final String RPC_QUERY = "Query";
+  private static final String RPC_RUN_ANALYSIS = "RunAnalysis";
+  private static final String RPC_RUN_SWEEP = "RunSweep";
+  private static final String RPC_RUN_DOCUMENT_QUERY = "RunDocumentQuery";
+  private static final String RPC_RENDER_DOCUMENT = "RenderDocument";
+  private static final String RPC_VALIDATE_INSTANCE = "ValidateInstance";
+  private static final String RPC_VERIFY_CONSTRAINT = "VerifyConstraint";
+  private static final String RPC_VERIFY_REQUIREMENT = "VerifyRequirement";
+  private static final String RPC_VERIFY_SATISFACTION = "VerifySatisfaction";
+
+  /** The RPCs the public API covers, and so the ones a scenario can be run through it. */
+  static final Set<String> COVERED =
+      Set.of(
+          RPC_GET_SERVER_INFO,
+          RPC_LIST_ENGINES,
+          RPC_PARSE_FILE,
+          RPC_GET_SYMBOL,
+          RPC_GET_DIAGNOSTICS,
+          RPC_EVALUATE,
+          RPC_INSTANTIATE,
+          RPC_EXECUTE_ACTION,
+          RPC_EXECUTE_STATE,
+          RPC_VERIFY_CONSTRAINT,
+          RPC_VERIFY_REQUIREMENT,
+          RPC_VERIFY_SATISFACTION,
+          RPC_VALIDATE_INSTANCE,
+          RPC_EVALUATE_CALC,
+          RPC_RUN_ANALYSIS,
+          RPC_QUERY,
+          RPC_PARSE_SOURCES,
+          RPC_CONVERT,
+          RPC_APPLY_EDITS,
+          RPC_RUN_SWEEP,
+          RPC_RUN_DOCUMENT_QUERY,
+          RPC_RENDER_DOCUMENT);
+
+  private final Connection connection;
+
+  Api(Connection connection) {
+    this.connection = connection;
+  }
+
+  /** What a call answered: a response, a refusal carrying a status, or nothing the API can do. */
+  sealed interface Answer {
+    /** The service answered. */
+    record Answered(Message response) implements Answer {}
+
+    /** The call was refused with a status. */
+    record Refused(String status, String message) implements Answer {}
+
+    /** The public API cannot make this call, so the scenario is skipped. */
+    record Unsupported(String reason) implements Answer {}
+  }
+
+  /**
+   * An empty request of the method's input type, which the scenario's protobuf JSON is merged into.
+   *
+   * @param method the bare method name
+   * @return a builder of that method's request
+   */
+  static Message.Builder request(String method) {
+    return switch (method) {
+      case RPC_GET_SERVER_INFO -> ServerInfoRequest.newBuilder();
+      case RPC_LIST_ENGINES -> ListEnginesRequest.newBuilder();
+      case RPC_PARSE_FILE -> ParseFileRequest.newBuilder();
+      case RPC_GET_SYMBOL -> GetSymbolRequest.newBuilder();
+      case RPC_GET_DIAGNOSTICS -> DiagnosticsRequest.newBuilder();
+      case RPC_EVALUATE -> EvaluateRequest.newBuilder();
+      case RPC_INSTANTIATE -> InstantiateRequest.newBuilder();
+      case RPC_EXECUTE_ACTION -> ExecuteActionRequest.newBuilder();
+      case RPC_EXECUTE_STATE -> ExecuteStateRequest.newBuilder();
+      case RPC_VERIFY_CONSTRAINT -> VerifyConstraintRequest.newBuilder();
+      case RPC_VERIFY_REQUIREMENT -> VerifyRequirementRequest.newBuilder();
+      case RPC_VERIFY_SATISFACTION -> VerifySatisfactionRequest.newBuilder();
+      case RPC_VALIDATE_INSTANCE -> ValidateInstanceRequest.newBuilder();
+      case RPC_EVALUATE_CALC -> EvaluateCalcRequest.newBuilder();
+      case RPC_RUN_ANALYSIS -> RunAnalysisRequest.newBuilder();
+      case RPC_QUERY -> QueryRequest.newBuilder();
+      case RPC_PARSE_SOURCES -> ParseSourcesRequest.newBuilder();
+      case RPC_CONVERT -> ConvertRequest.newBuilder();
+      case RPC_APPLY_EDITS -> ApplyEditsRequest.newBuilder();
+      case RPC_RUN_SWEEP -> RunSweepRequest.newBuilder();
+      case RPC_RUN_DOCUMENT_QUERY -> RunDocumentQueryRequest.newBuilder();
+      case RPC_RENDER_DOCUMENT -> RenderDocumentRequest.newBuilder();
+      default -> throw new IllegalArgumentException("no request type for " + method);
+    };
+  }
+
+  /**
+   * Whether the service declares an RPC at all, so a scenario naming one that does not exist is an
+   * error in the suite rather than a skip.
+   *
+   * @param method the bare method name
+   * @return whether {@code sysml.SysMLService} declares it
+   */
+  static boolean declared(String method) {
+    return Sysml.getDescriptor().getServices().stream()
+        .filter(service -> service.getFullName().equals("sysml.SysMLService"))
+        .anyMatch(service -> service.findMethodByName(method) != null);
+  }
+
+  /**
+   * Makes one call.
+   *
+   * @param method the bare method name
+   * @param request the request the scenario named
+   * @return what it answered
+   */
+  Answer call(String method, Message request) {
+    if (!COVERED.contains(method)) {
+      return new Answer.Unsupported("the public API does not cover " + method);
+    }
+    try {
+      return new Answer.Answered(
+          switch (method) {
+            case RPC_GET_SERVER_INFO -> serverInfo();
+            case RPC_LIST_ENGINES -> listEngines();
+            case RPC_PARSE_FILE -> parse((ParseFileRequest) request);
+            case RPC_GET_SYMBOL -> symbol((GetSymbolRequest) request);
+            case RPC_GET_DIAGNOSTICS -> diagnostics((DiagnosticsRequest) request);
+            case RPC_EVALUATE -> evaluate((EvaluateRequest) request);
+            case RPC_INSTANTIATE -> instantiate((InstantiateRequest) request);
+            case RPC_EXECUTE_ACTION -> executeAction((ExecuteActionRequest) request);
+            case RPC_EXECUTE_STATE -> executeState((ExecuteStateRequest) request);
+            case RPC_VERIFY_CONSTRAINT -> verifyConstraint((VerifyConstraintRequest) request);
+            case RPC_VERIFY_REQUIREMENT -> verifyRequirement((VerifyRequirementRequest) request);
+            case RPC_VERIFY_SATISFACTION ->
+                verifySatisfaction((VerifySatisfactionRequest) request);
+            case RPC_VALIDATE_INSTANCE -> validateInstance((ValidateInstanceRequest) request);
+            case RPC_EVALUATE_CALC -> evaluateCalc((EvaluateCalcRequest) request);
+            case RPC_RUN_ANALYSIS -> runAnalysis((RunAnalysisRequest) request);
+            case RPC_QUERY -> query((QueryRequest) request);
+            case RPC_PARSE_SOURCES -> parseSources((ParseSourcesRequest) request);
+            case RPC_CONVERT -> convert((ConvertRequest) request);
+            case RPC_APPLY_EDITS -> applyEdits((ApplyEditsRequest) request);
+            case RPC_RUN_SWEEP -> runSweep((RunSweepRequest) request);
+            case RPC_RUN_DOCUMENT_QUERY ->
+                runDocumentQuery((RunDocumentQueryRequest) request);
+            case RPC_RENDER_DOCUMENT -> renderDocument((RenderDocumentRequest) request);
+            default -> throw new IllegalStateException(method);
+          });
+    } catch (Unsupported e) {
+      return new Answer.Unsupported(e.getMessage());
+    } catch (ServiceException e) {
+      return new Answer.Refused(e.status().name(), e.serviceMessage());
+    } catch (CapabilityException e) {
+      // The client refuses a call whose behaviour the service does not advertise, since the
+      // service would ignore the field rather than answer UNIMPLEMENTED.
+      return new Answer.Refused("UNIMPLEMENTED", e.getMessage());
+    }
+  }
+
+  /** A call the public API has no way to make, thrown where the request is read. */
+  private static final class Unsupported extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    Unsupported(String reason) {
+      super(reason);
+    }
+  }
+
+  private ServerInfoResponse serverInfo() {
+    Capabilities capabilities = connection.capabilities();
+    return ServerInfoResponse.newBuilder()
+        .setVersion(capabilities.serviceVersion())
+        .addAllCapabilities(new TreeSet<>(capabilities.names()))
+        .build();
+  }
+
+  private ListEnginesResponse listEngines() {
+    return ListEnginesResponse.newBuilder()
+        .addAllEngines(Rendering.engines(connection.listEngines()))
+        .build();
+  }
+
+  private ParseFileResponse parse(ParseFileRequest request) {
+    ParseOptions options =
+        new ParseOptions(Language.fromWireName(request.getLanguage()), request.getStrictConformance());
+    Model model =
+        switch (request.getSourceCase()) {
+          case FILE_PATH -> connection.load(Path.of(request.getFilePath()), options);
+          case CONTENT -> connection.parse(request.getContent(), options);
+          case SOURCE_NOT_SET ->
+              throw new Unsupported(
+                  "the public API always names a source, so it cannot send a request naming none");
+        };
+    ParseFileResponse.Builder response =
+        ParseFileResponse.newBuilder()
+            .setModelHash(model.hash())
+            .addAllDiagnostics(Rendering.diagnostics(model.parseDiagnostics()));
+    model.root().ifPresent(root -> response.setRoot(Rendering.symbol(root)));
+    return response.build();
+  }
+
+  private SymbolResponse symbol(GetSymbolRequest request) {
+    Model model = connection.model(request.getModelHash());
+    try {
+      Symbol symbol = model.symbol(request.getSymbolId());
+      return SymbolResponse.newBuilder().setSymbol(Rendering.symbol(symbol)).build();
+    } catch (ModelException e) {
+      return SymbolResponse.newBuilder().setError(e.getMessage()).build();
+    }
+  }
+
+  private DiagnosticsResponse diagnostics(DiagnosticsRequest request) {
+    Model model = connection.model(request.getModelHash());
+    try {
+      return DiagnosticsResponse.newBuilder()
+          .addAllDiagnostics(Rendering.diagnostics(model.diagnostics()))
+          .build();
+    } catch (ModelException e) {
+      return DiagnosticsResponse.newBuilder()
+          .setError(e.getMessage())
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private EvaluateResponse evaluate(EvaluateRequest request) {
+    Model model = connection.model(request.getModelHash());
+    boolean hasContext = !request.getContextSymbolId().isEmpty();
+    boolean hasSubject = !request.getSubjectSymbolId().isEmpty();
+    if (hasContext && hasSubject) {
+      throw new Unsupported("the public API evaluates in a context or against a subject, not both");
+    }
+    try {
+      Value value;
+      if (hasSubject) {
+        value = model.evalWithSubject(request.getExpression(), request.getSubjectSymbolId());
+      } else if (hasContext) {
+        value = model.evalInContext(request.getExpression(), request.getContextSymbolId());
+      } else {
+        value = model.eval(request.getExpression());
+      }
+      return EvaluateResponse.newBuilder().setResult(Rendering.value(value)).build();
+    } catch (ModelException e) {
+      return EvaluateResponse.newBuilder()
+          .setError(e.getMessage())
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private InstantiateResponse instantiate(InstantiateRequest request) {
+    Model model = connection.model(request.getModelHash());
+    try {
+      Instantiation instantiation = model.instantiate(request.getSymbolId());
+      List<org.openmbee.opensysml.proto.Instance> reachable =
+          instantiation.reachable().stream().map(Rendering::instance).toList();
+      return InstantiateResponse.newBuilder()
+          .setInstance(Rendering.instance(instantiation.root()))
+          .addAllInstances(reachable)
+          .addAllDiagnostics(Rendering.diagnostics(instantiation.diagnostics()))
+          .build();
+    } catch (ModelException e) {
+      return InstantiateResponse.newBuilder()
+          .setError(e.getMessage())
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private ExecuteActionResponse executeAction(ExecuteActionRequest request) {
+    Model model = connection.model(request.getModelHash());
+    ExecutionOptions options = execution(request.getSchedule(), request.getPerformerSymbolId());
+    Map<String, Value> inputs = values(request.getInputsMap());
+    try {
+      if (options.explores()) {
+        Exploration exploration = model.exploreAction(request.getActionSymbolId(), inputs, options);
+        return ExecuteActionResponse.newBuilder()
+            .addAllOutcomes(Rendering.outcomes(exploration))
+            .setExploration(Rendering.exploration(exploration))
+            .build();
+      }
+      ActionRun run = model.executeAction(request.getActionSymbolId(), inputs, options);
+      ExecuteActionResponse.Builder response =
+          ExecuteActionResponse.newBuilder()
+              .putAllOutputs(Rendering.values(run.outputs()))
+              .addAllDiagnostics(Rendering.diagnostics(run.diagnostics()));
+      run.finalTime().ifPresent(response::setFinalTime);
+      return response.build();
+    } catch (ModelException e) {
+      return ExecuteActionResponse.newBuilder()
+          .setError(e.getMessage())
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private ExecuteStateResponse executeState(ExecuteStateRequest request) {
+    Model model = connection.model(request.getModelHash());
+    ExecutionOptions options = execution(request.getSchedule(), request.getPerformerSymbolId());
+    try {
+      if (options.explores()) {
+        Exploration exploration =
+            model.exploreState(
+                request.getStateMachineSymbolId(), request.getEventsList(), options);
+        return ExecuteStateResponse.newBuilder()
+            .addAllOutcomes(Rendering.outcomes(exploration))
+            .setExploration(Rendering.exploration(exploration))
+            .build();
+      }
+      StateRun run =
+          model.executeState(request.getStateMachineSymbolId(), request.getEventsList(), options);
+      ExecuteStateResponse.Builder response =
+          ExecuteStateResponse.newBuilder()
+              .addAllStatesVisited(run.statesVisited())
+              .putAllFinalContext(Rendering.values(run.finalContext()))
+              .addAllDiagnostics(Rendering.diagnostics(run.diagnostics()));
+      run.finalTime().ifPresent(response::setFinalTime);
+      return response.build();
+    } catch (ModelException e) {
+      return ExecuteStateResponse.newBuilder()
+          .setError(e.getMessage())
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private VerifyConstraintResponse verifyConstraint(VerifyConstraintRequest request) {
+    Model model = engine(connection.model(request.getModelHash()), request.getEngine());
+    try {
+      Verification verification =
+          request.getSubjectSymbolId().isEmpty()
+              ? model.verifyConstraint(request.getSymbolId())
+              : model.verifyConstraint(request.getSymbolId(), request.getSubjectSymbolId());
+      return VerifyConstraintResponse.newBuilder()
+          .setVerdict(Rendering.verdict(verification.verdict()))
+          .addAllInstances(Rendering.instances(verification.instances()))
+          .addAllDiagnostics(Rendering.diagnostics(verification.diagnostics()))
+          .build();
+    } catch (ModelException e) {
+      return VerifyConstraintResponse.newBuilder()
+          .setError(e.getMessage())
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private VerifyRequirementResponse verifyRequirement(VerifyRequirementRequest request) {
+    Model model = engine(connection.model(request.getModelHash()), request.getEngine());
+    try {
+      Verification verification =
+          request.getSubjectSymbolId().isEmpty()
+              ? model.verifyRequirement(request.getSymbolId())
+              : model.verifyRequirement(request.getSymbolId(), request.getSubjectSymbolId());
+      return VerifyRequirementResponse.newBuilder()
+          .setVerdict(Rendering.verdict(verification.verdict()))
+          .addAllInstances(Rendering.instances(verification.instances()))
+          .addAllDiagnostics(Rendering.diagnostics(verification.diagnostics()))
+          .addAllVerificationVerdicts(
+              Rendering.verificationVerdicts(verification.verifications()))
+          .build();
+    } catch (ModelException e) {
+      return VerifyRequirementResponse.newBuilder()
+          .setError(e.getMessage())
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private VerifySatisfactionResponse verifySatisfaction(VerifySatisfactionRequest request) {
+    Model model = engine(connection.model(request.getModelHash()), request.getEngine());
+    try {
+      Satisfaction satisfaction =
+          request.getSymbolId().isEmpty()
+              ? model.verifySatisfaction()
+              : model.verifySatisfaction(request.getSymbolId());
+      return VerifySatisfactionResponse.newBuilder()
+          .addAllVerdicts(Rendering.verdicts(satisfaction.verdicts()))
+          .addAllInstances(Rendering.instances(satisfaction.instances()))
+          .addAllDiagnostics(Rendering.diagnostics(satisfaction.diagnostics()))
+          .addAllVerificationVerdicts(
+              Rendering.verificationVerdicts(satisfaction.verifications()))
+          .build();
+    } catch (ModelException e) {
+      return VerifySatisfactionResponse.newBuilder()
+          .setError(e.getMessage())
+          .setFailureReason(Rendering.failureReason(e.failureReason()))
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private ValidateInstanceResponse validateInstance(ValidateInstanceRequest request) {
+    Model model = engine(connection.model(request.getModelHash()), request.getEngine());
+    try {
+      Validation validation = model.validateInstance(request.getSymbolId());
+      return ValidateInstanceResponse.newBuilder()
+          .setSummary(Rendering.verdict(validation.summary()))
+          .addAllVerdicts(Rendering.verdicts(validation.verdicts()))
+          .addAllInstances(Rendering.instances(validation.instances()))
+          .addAllDiagnostics(Rendering.diagnostics(validation.diagnostics()))
+          .addAllVerificationVerdicts(Rendering.verificationVerdicts(validation.verifications()))
+          .setBounded(validation.bounded())
+          .build();
+    } catch (ModelException e) {
+      return ValidateInstanceResponse.newBuilder()
+          .setError(e.getMessage())
+          .setFailureReason(Rendering.failureReason(e.failureReason()))
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private EvaluateCalcResponse evaluateCalc(EvaluateCalcRequest request) {
+    Model model = engine(connection.model(request.getModelHash()), request.getEngine());
+    try {
+      Calculation calculation =
+          model.evaluateCalc(request.getSymbolId(), values(request.getArgumentsList()));
+      EvaluateCalcResponse.Builder response =
+          EvaluateCalcResponse.newBuilder()
+              .addAllOutputs(Rendering.outputs(calculation.outputs()))
+              .addAllDiagnostics(Rendering.diagnostics(calculation.diagnostics()))
+              .setEngine(calculation.standing().engine())
+              .setStrength(calculation.standing().strength())
+              .addAllBounds(Rendering.bounds(calculation.standing()));
+      calculation.result().ifPresent(result -> response.setResult(Rendering.value(result)));
+      return response.build();
+    } catch (ModelException e) {
+      return EvaluateCalcResponse.newBuilder()
+          .setError(e.getMessage())
+          .setFailureReason(Rendering.failureReason(e.failureReason()))
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private RunAnalysisResponse runAnalysis(RunAnalysisRequest request) {
+    Model model = engine(connection.model(request.getModelHash()), request.getEngine());
+    AnalysisOptions options =
+        AnalysisOptions.defaults()
+            .withArguments(values(request.getArgumentsList()))
+            .withNamedArguments(values(request.getNamedArgumentsMap()));
+    if (!request.getSubjectSymbolId().isEmpty()) {
+      options = options.withSubject(request.getSubjectSymbolId());
+    }
+    if (!request.getSchedule().isEmpty()) {
+      options = options.withSchedule(request.getSchedule());
+    }
+    try {
+      if (options.explores()) {
+        Exploration exploration = model.exploreAnalysis(request.getSymbolId(), options);
+        return RunAnalysisResponse.newBuilder()
+            .addAllOutcomes(Rendering.outcomes(exploration))
+            .setExploration(Rendering.exploration(exploration))
+            .build();
+      }
+      return analysis(model.runAnalysis(request.getSymbolId(), options)).build();
+    } catch (AnalysisException e) {
+      return e.partial()
+          .map(Api::analysis)
+          .orElseGet(
+              () ->
+                  RunAnalysisResponse.newBuilder()
+                      .addAllDiagnostics(Rendering.diagnostics(e.diagnostics())))
+          .setError(e.getMessage())
+          .setFailureReason(Rendering.failureReason(e.failureReason()))
+          .build();
+    } catch (ModelException e) {
+      return RunAnalysisResponse.newBuilder()
+          .setError(e.getMessage())
+          .setFailureReason(Rendering.failureReason(e.failureReason()))
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private static RunAnalysisResponse.Builder analysis(Analysis analysis) {
+    return RunAnalysisResponse.newBuilder()
+        .addAllOutputs(Rendering.outputs(analysis.outputs()))
+        .addAllVerdicts(Rendering.verdicts(analysis.verdicts()))
+        .addAllInstances(Rendering.instances(analysis.instances()))
+        .addAllDiagnostics(Rendering.diagnostics(analysis.diagnostics()))
+        .addAllVerificationVerdicts(Rendering.verificationVerdicts(analysis.verifications()))
+        .addAllEvaluations(Rendering.evaluations(analysis.evaluations()))
+        .setEngine(analysis.standing().engine())
+        .setStrength(analysis.standing().strength())
+        .addAllBounds(Rendering.bounds(analysis.standing()));
+  }
+
+  private ParseSourcesResponse parseSources(ParseSourcesRequest request) {
+    List<SourceDocument> documents = new ArrayList<>(request.getDocumentsCount());
+    for (org.openmbee.opensysml.proto.SourceDocument document : request.getDocumentsList()) {
+      documents.add(sourceDocument(document));
+    }
+    try {
+      Model model =
+          connection.parseSources(
+              documents,
+              new ParseOptions(Language.SYSML, request.getStrictConformance()));
+      ParseSourcesResponse.Builder response =
+          ParseSourcesResponse.newBuilder()
+              .setModelHash(model.hash())
+              .addAllDiagnostics(Rendering.diagnostics(model.parseDiagnostics()));
+      model.roots().forEach(root -> response.addRoots(Rendering.symbol(root)));
+      return response.build();
+    } catch (ModelException e) {
+      return ParseSourcesResponse.newBuilder()
+          .setError(e.getMessage())
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private static SourceDocument sourceDocument(
+      org.openmbee.opensysml.proto.SourceDocument document) {
+    SourceDocument read =
+        switch (document.getSourceCase()) {
+          case FILE_PATH -> SourceDocument.file(Path.of(document.getFilePath()));
+          case CONTENT ->
+              document.getName().isEmpty()
+                  ? new SourceDocument(
+                      Optional.empty(),
+                      Optional.of(document.getContent()),
+                      Optional.empty(),
+                      Optional.empty())
+                  : SourceDocument.inline(document.getName(), document.getContent());
+          case SOURCE_NOT_SET ->
+              throw new Unsupported(
+                  "the public API always names a source, so it cannot send a document naming none");
+        };
+    return document.getLanguage().isEmpty()
+        ? read
+        : read.withLanguage(Language.fromWireName(document.getLanguage()));
+  }
+
+  private ConvertResponse convert(ConvertRequest request) {
+    ConversionOptions options =
+        ConversionOptions.defaults().withTolerateSyntaxErrors(request.getTolerateSyntaxErrors());
+    if (!request.getFromFormat().isEmpty()) {
+      options = options.withFromFormat(request.getFromFormat());
+    }
+    try {
+      Conversion conversion =
+          switch (request.getSourceCase()) {
+            case FILE_PATH ->
+                connection.convertFile(Path.of(request.getFilePath()), request.getToFormat(), options);
+            case CONTENT ->
+                connection.convert(request.getContent(), request.getToFormat(), options);
+            case MODEL_HASH ->
+                connection
+                    .model(request.getModelHash())
+                    .convert(request.getToFormat(), options);
+            case SOURCE_NOT_SET ->
+                throw new Unsupported(
+                    "the public API always names a source, so it cannot send a request naming none");
+          };
+      return Rendering.conversion(conversion);
+    } catch (ModelException e) {
+      return ConvertResponse.newBuilder()
+          .setError(e.getMessage())
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private ApplyEditsResponse applyEdits(ApplyEditsRequest request) {
+    Model model = connection.model(request.getModelHash());
+    List<Edit> edits = request.getOperationsList().stream().map(Api::edit).toList();
+    EditOptions options =
+        EditOptions.defaults().withAcceptDocuments(request.getAcceptDocuments());
+    if (!request.getDocument().isEmpty()) {
+      options = options.withDocument(request.getDocument());
+    }
+    try {
+      EditResult result = model.applyEdits(edits, options);
+      return ApplyEditsResponse.newBuilder()
+          .setContent(result.content())
+          .addAllApplied(Rendering.appliedEdits(result.applied()))
+          .addAllDocuments(Rendering.editedDocuments(result.documents()))
+          .addAllDiagnostics(Rendering.diagnostics(result.diagnostics()))
+          .build();
+    } catch (EditException e) {
+      ApplyEditsResponse.Builder response =
+          ApplyEditsResponse.newBuilder()
+              .setError(e.getMessage())
+              .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+              .addAllReferringElements(e.referringElements())
+              .addAllReferrers(Rendering.referrers(e.referrers()));
+      try {
+        response.setFailure(org.openmbee.opensysml.proto.EditFailure.valueOf(e.failureName()));
+      } catch (IllegalArgumentException unrecognized) {
+        response.setFailureValue(
+            Integer.parseInt(e.failureName().substring("EDIT_FAILURE_".length())));
+      }
+      return response.build();
+    }
+  }
+
+  private static Edit edit(org.openmbee.opensysml.proto.EditOperation operation) {
+    return switch (operation.getOperationCase()) {
+      case SET_VALUE ->
+          new Edit.SetValue(operation.getSetValue().getTarget(), operation.getSetValue().getValue());
+      case RENAME ->
+          new Edit.Rename(operation.getRename().getTarget(), operation.getRename().getNewName());
+      case ADD_MEMBER -> {
+        org.openmbee.opensysml.proto.AddMemberEdit add = operation.getAddMember();
+        Edit.AddMember member = Edit.AddMember.of(add.getOwner(), add.getKind(), add.getName());
+        if (!add.getType().isEmpty()) {
+          member = member.withType(add.getType());
+        }
+        if (!add.getMultiplicity().isEmpty()) {
+          member = member.withMultiplicity(add.getMultiplicity());
+        }
+        if (!add.getValue().isEmpty()) {
+          member = member.withValue(add.getValue());
+        }
+        if (add.getSpecializesCount() > 0) {
+          member = member.withSpecializes(add.getSpecializesList());
+        }
+        yield member;
+      }
+      case DELETE ->
+          new Edit.Delete(operation.getDelete().getTarget(), operation.getDelete().getCascade());
+      case MOVE ->
+          new Edit.Move(operation.getMove().getTarget(), operation.getMove().getOwner());
+      case OPERATION_NOT_SET ->
+          throw new Unsupported("the public API cannot send an edit naming no operation");
+    };
+  }
+
+  private RunSweepResponse runSweep(RunSweepRequest request) {
+    Model model = engine(connection.model(request.getModelHash()), request.getEngine());
+    SweepOptions options =
+        SweepOptions.defaults()
+            .withArguments(values(request.getArgumentsList()))
+            .withNamedArguments(values(request.getNamedArgumentsMap()))
+            .withSamples(request.getSamples())
+            .withSeed(request.getSeed());
+    if (!request.getSubjectSymbolId().isEmpty()) {
+      options = options.withSubject(request.getSubjectSymbolId());
+    }
+    List<SweepRange> ranges = new ArrayList<>(request.getRangesCount());
+    for (org.openmbee.opensysml.proto.SweepRange range : request.getRangesList()) {
+      SweepRange read =
+          SweepRange.of(
+              range.getParameter(), value(range.getStart()), value(range.getEnd()));
+      if (range.hasStep()) {
+        read = read.withStep(value(range.getStep()));
+      }
+      ranges.add(read);
+    }
+    try {
+      return Rendering.sweep(model.runSweep(request.getSymbolId(), ranges, options));
+    } catch (ModelException e) {
+      return RunSweepResponse.newBuilder()
+          .setError(e.getMessage())
+          .setFailureReason(Rendering.failureReason(e.failureReason()))
+          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
+          .build();
+    }
+  }
+
+  private RunDocumentQueryResponse runDocumentQuery(RunDocumentQueryRequest request) {
+    Model model = connection.model(request.getModelHash());
+    Map<String, List<DocumentValue>> bindings = new LinkedHashMap<>();
+    for (org.openmbee.opensysml.proto.DocumentQueryBinding binding : request.getBindingsList()) {
+      bindings.put(
+          binding.getParameter(),
+          binding.getValuesList().stream().map(Protos::documentValue).toList());
+    }
+    DocumentQueryResult result = model.runDocumentQuery(request.getQueryId(), bindings);
+    return Rendering.documentQueryResult(result);
+  }
+
+  private RenderDocumentResponse renderDocument(RenderDocumentRequest request) {
+    Model model = connection.model(request.getModelHash());
+    RenderedDocument rendered = model.renderDocument(request.getDocumentId());
+    return RenderDocumentResponse.newBuilder().setMarkdown(rendered.markdown()).build();
+  }
+
+  private QueryResponse query(QueryRequest request) {
+    Model model = connection.model(request.getModelHash());
+    if (request.hasQuery() && !request.getOslcQuery().isEmpty()) {
+      throw new Unsupported("the public API sends a structured query or an OSLC one, not both");
+    }
+    List<QueryElement> elements =
+        request.getOslcQuery().isEmpty()
+            ? model.query(query(request.getQuery()))
+            : model.queryOslc(request.getOslcQuery());
+    return QueryResponse.newBuilder().addAllElements(Rendering.elements(elements)).build();
+  }
+
+  private static Query query(org.openmbee.opensysml.proto.Query query) {
+    Query built = Query.all().withScope(query.getScopeList()).withSelect(query.getSelectList());
+    return query.hasWhere() ? built.where(condition(query.getWhere())) : built;
+  }
+
+  private static Condition condition(Constraint constraint) {
+    return switch (constraint.getConstraintCase()) {
+      case PRIMITIVE -> {
+        PrimitiveConstraint primitive = constraint.getPrimitive();
+        Condition.Comparison comparison =
+            switch (primitive.getOperator()) {
+              case PRIMITIVE_OPERATOR_EQUAL ->
+                  Condition.equalTo(primitive.getProperty(), primitive.getValueList());
+              case PRIMITIVE_OPERATOR_GREATER ->
+                  Condition.greater(primitive.getProperty(), soleValue(primitive));
+              case PRIMITIVE_OPERATOR_LESS ->
+                  Condition.less(primitive.getProperty(), soleValue(primitive));
+              case PRIMITIVE_OPERATOR_UNSPECIFIED, UNRECOGNIZED ->
+                  throw new Unsupported(
+                      "the public API cannot send a comparison with no operator");
+            };
+        yield primitive.getInverse() ? comparison.negated() : comparison;
+      }
+      case COMPOSITE -> {
+        CompositeConstraint composite = constraint.getComposite();
+        List<Condition> conditions = composite.getConstraintList().stream().map(Api::condition).toList();
+        yield switch (composite.getOperator()) {
+          case COMPOSITE_OPERATOR_AND -> Condition.all(conditions);
+          case COMPOSITE_OPERATOR_OR -> Condition.any(conditions);
+          case COMPOSITE_OPERATOR_UNSPECIFIED, UNRECOGNIZED ->
+              throw new Unsupported("the public API cannot send a combination with no operator");
+        };
+      }
+      case CONSTRAINT_NOT_SET ->
+          throw new Unsupported("the public API cannot send an empty condition");
+    };
+  }
+
+  private static String soleValue(PrimitiveConstraint primitive) {
+    if (primitive.getValueCount() != 1) {
+      throw new Unsupported("the public API compares an ordering against one value");
+    }
+    return primitive.getValue(0);
+  }
+
+  private static ExecutionOptions execution(String schedule, String performer) {
+    ExecutionOptions options = ExecutionOptions.defaults();
+    if (!schedule.isEmpty()) {
+      options = options.withSchedule(schedule);
+    }
+    if (!performer.isEmpty()) {
+      options = options.withPerformer(performer);
+    }
+    return options;
+  }
+
+  private static Model engine(Model model, String engine) {
+    return engine.isEmpty() ? model : model.withEngine(engine);
+  }
+
+  private static List<Value> values(List<org.openmbee.opensysml.proto.Value> values) {
+    List<Value> read = new ArrayList<>(values.size());
+    for (org.openmbee.opensysml.proto.Value value : values) {
+      read.add(value(value));
+    }
+    return read;
+  }
+
+  private static Map<String, Value> values(Map<String, org.openmbee.opensysml.proto.Value> values) {
+    Map<String, Value> read = new LinkedHashMap<>();
+    values.forEach((name, value) -> read.put(name, value(value)));
+    return read;
+  }
+
+  private static Value value(org.openmbee.opensysml.proto.Value value) {
+    Optional<Value> read;
+    try {
+      read = Protos.value(value);
+    } catch (TransportException malformed) {
+      throw new Unsupported("the public API cannot send a value the client would refuse to read");
+    }
+    return read.orElseThrow(
+        () -> new Unsupported("the public API cannot send a value of a kind it does not know"));
+  }
+}
diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Checks.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Checks.java
similarity index 100%
rename from clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Checks.java
rename to client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Checks.java
diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Main.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Main.java
similarity index 99%
rename from clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Main.java
rename to client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Main.java
index dcb237e9cb..0aacbc0025 100644
--- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Main.java
+++ b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Main.java
@@ -199,7 +199,7 @@ private static List<String> protocols(String value) {
         case "connect", "connect-json" -> protocols.add(name);
         case "grpc" ->
             throw new IllegalArgumentException(
-                "this client speaks the Connect protocol only; use cmd/conformance for grpc");
+                "this client speaks the Connect protocol only; use tools/cmd/conformance for grpc");
         default -> throw new IllegalArgumentException("unknown protocol " + protocol);
       }
     }
diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Mutations.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Mutations.java
similarity index 100%
rename from clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Mutations.java
rename to client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Mutations.java
diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Normalizer.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Normalizer.java
similarity index 98%
rename from clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Normalizer.java
rename to client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Normalizer.java
index 18331c3b12..fef53e335e 100644
--- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Normalizer.java
+++ b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Normalizer.java
@@ -31,7 +31,8 @@ final class Normalizer {
           "sysml.Instance.id",
           "sysml.Value.instance_id",
           "sysml.Verdict.instance_id",
-          "sysml.Function.self_id");
+          "sysml.Function.self_id",
+          "sysml.DocumentObject.instance_id");
 
   private final String modelHash;
   private final Map<Long, String> labels = new HashMap<>();
diff --git a/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Rendering.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Rendering.java
new file mode 100644
index 0000000000..e2d524111b
--- /dev/null
+++ b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Rendering.java
@@ -0,0 +1,560 @@
+package org.openmbee.opensysml.conformance;
+
+import org.openmbee.opensysml.CaseEvaluation;
+import org.openmbee.opensysml.Diagnostic;
+import org.openmbee.opensysml.EngineInfo;
+import org.openmbee.opensysml.Exploration;
+import org.openmbee.opensysml.FailureReason;
+import org.openmbee.opensysml.Instance;
+import org.openmbee.opensysml.Outcome;
+import org.openmbee.opensysml.QueryElement;
+import org.openmbee.opensysml.Standing;
+import org.openmbee.opensysml.Symbol;
+import org.openmbee.opensysml.Value;
+import org.openmbee.opensysml.Verdict;
+import org.openmbee.opensysml.VerificationVerdict;
+import org.openmbee.opensysml.internal.Protos;
+import org.openmbee.opensysml.proto.AttributeInfo;
+import org.openmbee.opensysml.proto.Bound;
+import org.openmbee.opensysml.proto.CalcOutput;
+import org.openmbee.opensysml.proto.ExplorationStatus;
+import org.openmbee.opensysml.proto.FeatureValue;
+import org.openmbee.opensysml.proto.MultiplicityInfo;
+import org.openmbee.opensysml.proto.QueryResultElement;
+import org.openmbee.opensysml.proto.Span;
+import org.openmbee.opensysml.proto.Specialization;
+import org.openmbee.opensysml.proto.SymbolInfo;
+import org.openmbee.opensysml.proto.TypeInfo;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Writes the client's public types back into the generated messages the scenarios are stated
+ * against. Comparing that rendering, rather than the transport's answer, is what makes a scenario a
+ * test of the client's own reading of a response and not only of the service.
+ */
+final class Rendering {
+
+  private Rendering() {}
+
+  /**
+   * A value.
+   *
+   * @param value the immutable value
+   * @return the generated value
+   */
+  static org.openmbee.opensysml.proto.Value value(Value value) {
+    return Protos.proto(value);
+  }
+
+  /**
+   * Values by name.
+   *
+   * @param values the immutable values by name
+   * @return the generated values by name
+   */
+  static Map<String, org.openmbee.opensysml.proto.Value> values(Map<String, Value> values) {
+    return Protos.protos(values);
+  }
+
+  /**
+   * Named outputs, in order.
+   *
+   * @param outputs the immutable outputs by name
+   * @return the generated outputs
+   */
+  static List<CalcOutput> outputs(Map<String, Value> outputs) {
+    List<CalcOutput> rendered = new ArrayList<>(outputs.size());
+    outputs.forEach(
+        (name, value) ->
+            rendered.add(CalcOutput.newBuilder().setName(name).setValue(value(value)).build()));
+    return rendered;
+  }
+
+  /**
+   * Instances.
+   *
+   * @param instances the immutable instances
+   * @return the generated instances, in order
+   */
+  static List<org.openmbee.opensysml.proto.Instance> instances(List<Instance> instances) {
+    return instances.stream().map(Rendering::instance).toList();
+  }
+
+  /**
+   * The bounds of a standing.
+   *
+   * @param standing the immutable standing
+   * @return the generated bounds, in order
+   */
+  static List<Bound> bounds(Standing standing) {
+    return standing.bounds().stream()
+        .map(
+            bound ->
+                Bound.newBuilder()
+                    .setName(bound.name())
+                    .setLimit(bound.limit())
+                    .setReached(bound.reached())
+                    .build())
+        .toList();
+  }
+
+  /**
+   * A verdict.
+   *
+   * @param verdict the immutable verdict
+   * @return the generated verdict
+   */
+  static org.openmbee.opensysml.proto.Verdict verdict(Verdict verdict) {
+    org.openmbee.opensysml.proto.Verdict.Builder builder =
+        org.openmbee.opensysml.proto.Verdict.newBuilder()
+            .setKind(verdict.kind())
+            .setElement(verdict.element())
+            .setHolds(verdict.holds())
+            .setFailureReason(failureReason(verdict.failureReason()))
+            .setEngine(verdict.standing().engine())
+            .setStrength(verdict.standing().strength())
+            .addAllBounds(bounds(verdict.standing()));
+    verdict.elementId().ifPresent(builder::setElementId);
+    verdict.condition().ifPresent(builder::setCondition);
+    verdict.instanceId().ifPresent(builder::setInstanceId);
+    verdict.instanceTypeId().ifPresent(builder::setInstanceTypeId);
+    verdict.error().ifPresent(builder::setError);
+    verdict.requirementId().ifPresent(builder::setRequirementId);
+    verdict.instancePath().ifPresent(builder::setInstancePath);
+    return builder.build();
+  }
+
+  /**
+   * A failure reason.
+   *
+   * @param reason the immutable reason
+   * @return the generated reason
+   * @throws IllegalStateException for a reason the client read off the wire without knowing it,
+   *     which has no rendering the scenario could be compared against
+   */
+  static org.openmbee.opensysml.proto.FailureReason failureReason(FailureReason reason) {
+    return switch (reason) {
+      case UNSPECIFIED -> org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_UNSPECIFIED;
+      case EVALUATION -> org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_EVALUATION;
+      case WRONG_KIND -> org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_WRONG_KIND;
+      case AMBIGUOUS_SUBJECT ->
+          org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_AMBIGUOUS_SUBJECT;
+      case UNKNOWN -> throw new IllegalStateException("no rendering for an unknown failure reason");
+    };
+  }
+
+  /**
+   * Verdicts.
+   *
+   * @param verdicts the immutable verdicts
+   * @return the generated verdicts, in order
+   */
+  static List<org.openmbee.opensysml.proto.Verdict> verdicts(List<Verdict> verdicts) {
+    return verdicts.stream().map(Rendering::verdict).toList();
+  }
+
+  /**
+   * The body verdicts of verification cases.
+   *
+   * @param verdicts the immutable verdicts
+   * @return the generated verdicts, in order
+   */
+  static List<org.openmbee.opensysml.proto.VerificationVerdict> verificationVerdicts(
+      List<VerificationVerdict> verdicts) {
+    List<org.openmbee.opensysml.proto.VerificationVerdict> rendered =
+        new ArrayList<>(verdicts.size());
+    for (VerificationVerdict verdict : verdicts) {
+      org.openmbee.opensysml.proto.VerificationVerdict.Builder builder =
+          org.openmbee.opensysml.proto.VerificationVerdict.newBuilder()
+              .setCaseId(verdict.caseId())
+              .setKind(verdict.kind())
+              .setSubcase(verdict.subcase());
+      verdict.detail().ifPresent(builder::setDetail);
+      verdict.requirementId().ifPresent(builder::setRequirementId);
+      rendered.add(builder.build());
+    }
+    return rendered;
+  }
+
+  /**
+   * Case evaluations.
+   *
+   * @param evaluations the immutable evaluations
+   * @return the generated evaluations, in order
+   */
+  static List<org.openmbee.opensysml.proto.CaseEvaluation> evaluations(
+      List<CaseEvaluation> evaluations) {
+    List<org.openmbee.opensysml.proto.CaseEvaluation> rendered =
+        new ArrayList<>(evaluations.size());
+    for (CaseEvaluation evaluation : evaluations) {
+      org.openmbee.opensysml.proto.CaseEvaluation.Builder builder =
+          org.openmbee.opensysml.proto.CaseEvaluation.newBuilder()
+              .setFunctionId(evaluation.functionId())
+              .setSelected(evaluation.selected())
+              .setTied(evaluation.tied());
+      evaluation.arguments().forEach(argument -> builder.addArguments(value(argument)));
+      evaluation.result().ifPresent(result -> builder.setResult(value(result)));
+      evaluation.error().ifPresent(builder::setError);
+      rendered.add(builder.build());
+    }
+    return rendered;
+  }
+
+  /**
+   * The outcomes of an exploration.
+   *
+   * @param exploration the immutable exploration
+   * @return the generated outcomes, in order
+   */
+  static List<org.openmbee.opensysml.proto.Outcome> outcomes(Exploration exploration) {
+    List<org.openmbee.opensysml.proto.Outcome> rendered =
+        new ArrayList<>(exploration.outcomes().size());
+    for (Outcome outcome : exploration.outcomes()) {
+      org.openmbee.opensysml.proto.Outcome.Builder builder =
+          org.openmbee.opensysml.proto.Outcome.newBuilder()
+              .putAllOutputs(values(outcome.outputs()))
+              .addAllStatesVisited(outcome.statesVisited())
+              .setLinearizations(outcome.linearizations())
+              .addAllWitness(outcome.witness())
+              .addAllDiagnostics(diagnostics(outcome.diagnostics()));
+      outcome.finalState().ifPresent(builder::setFinalState);
+      outcome.error().ifPresent(builder::setError);
+      rendered.add(builder.build());
+    }
+    return rendered;
+  }
+
+  /**
+   * How an exploration ended.
+   *
+   * @param exploration the immutable exploration
+   * @return the generated status
+   */
+  static ExplorationStatus exploration(Exploration exploration) {
+    return ExplorationStatus.newBuilder()
+        .setComplete(exploration.complete())
+        .setRuns(exploration.runs())
+        .addAllBudgetsHit(exploration.budgetsHit())
+        .setRunsBudget(exploration.runsBudget())
+        .setDepthBudget(exploration.depthBudget())
+        .build();
+  }
+
+  /**
+   * The elements a query selected.
+   *
+   * @param elements the immutable elements
+   * @return the generated elements, in order
+   */
+  static List<QueryResultElement> elements(List<QueryElement> elements) {
+    return elements.stream()
+        .map(
+            element ->
+                QueryResultElement.newBuilder()
+                    .setId(element.id())
+                    .setType(element.type())
+                    .putAllProperties(element.properties())
+                    .build())
+        .toList();
+  }
+
+  /**
+   * Engines.
+   *
+   * @param engines the immutable descriptions
+   * @return the generated descriptions, in order
+   */
+  static List<org.openmbee.opensysml.proto.EngineInfo> engines(List<EngineInfo> engines) {
+    return engines.stream()
+        .map(
+            engine ->
+                org.openmbee.opensysml.proto.EngineInfo.newBuilder()
+                    .setName(engine.name())
+                    .setAuthority(engine.authority())
+                    .addAllAnswers(engine.answers())
+                    .addAllBounds(engine.bounds())
+                    .setProcess(engine.process())
+                    .setProcessFound(engine.processFound())
+                    .setReady(engine.ready())
+                    .setUnavailable(engine.unavailable())
+                    .setKind(engine.kind())
+                    .setProtocol(engine.protocol())
+                    .setSource(engine.source())
+                    .setCommand(engine.command())
+                    .setVersion(engine.version())
+                    .setServed(engine.served())
+                    .build())
+        .toList();
+  }
+
+  /**
+   * Diagnostics.
+   *
+   * @param diagnostics the immutable diagnostics
+   * @return the generated diagnostics, in order
+   */
+  static List<org.openmbee.opensysml.proto.Diagnostic> diagnostics(List<Diagnostic> diagnostics) {
+    return diagnostics.stream().map(Rendering::diagnostic).toList();
+  }
+
+  private static org.openmbee.opensysml.proto.Diagnostic diagnostic(Diagnostic diagnostic) {
+    org.openmbee.opensysml.proto.Diagnostic.Builder builder =
+        org.openmbee.opensysml.proto.Diagnostic.newBuilder()
+            .setSeverity(diagnostic.severity().wireName())
+            .setMessage(diagnostic.message())
+            .setCode(diagnostic.code());
+    diagnostic
+        .span()
+        .ifPresent(
+            span ->
+                builder.setSpan(
+                    Span.newBuilder()
+                        .setFile(span.file())
+                        .setStartLine(span.startLine())
+                        .setStartCol(span.startColumn())
+                        .setEndLine(span.endLine())
+                        .setEndCol(span.endColumn())));
+    return builder.build();
+  }
+
+  /**
+   * A symbol.
+   *
+   * @param symbol the immutable symbol
+   * @return the generated symbol
+   */
+  static SymbolInfo symbol(Symbol symbol) {
+    SymbolInfo.Builder builder =
+        SymbolInfo.newBuilder()
+            .setId(symbol.id())
+            .setName(symbol.name())
+            .setKind(symbol.kind())
+            .putAllMetadata(symbol.metadata())
+            .addAllChildIds(symbol.childIds())
+            .setWithheldLibraryAttributes(symbol.withheldLibraryAttributes());
+    for (Symbol.Attribute attribute : symbol.attributes()) {
+      AttributeInfo.Builder rendered =
+          AttributeInfo.newBuilder().setName(attribute.name()).setType(attribute.type());
+      attribute.value().ifPresent(value -> rendered.setValue(value(value)));
+      attribute.unit().ifPresent(rendered::setUnit);
+      builder.addAttributes(rendered);
+    }
+    for (Symbol.Specialization specialization : symbol.specializations()) {
+      Specialization.Builder rendered =
+          Specialization.newBuilder()
+              .setKind(specialization.kind())
+              .setDeclared(specialization.declared());
+      specialization.targetId().ifPresent(rendered::setTargetId);
+      specialization.targetKind().ifPresent(rendered::setTargetKind);
+      builder.addSpecializations(rendered);
+    }
+    symbol
+        .typeFacts()
+        .ifPresent(
+            facts -> {
+              TypeInfo.Builder rendered = TypeInfo.newBuilder().setQuantity(facts.quantity());
+              facts.declared().ifPresent(rendered::setDeclared);
+              facts.resolvedId().ifPresent(rendered::setResolvedId);
+              facts.resolvedKind().ifPresent(rendered::setResolvedKind);
+              facts.primitive().ifPresent(rendered::setPrimitive);
+              facts.primitiveSource().ifPresent(rendered::setPrimitiveSource);
+              facts.unit().ifPresent(rendered::setUnit);
+              builder.setTypeInfo(rendered);
+            });
+    symbol
+        .multiplicity()
+        .ifPresent(
+            multiplicity -> {
+              MultiplicityInfo.Builder rendered = MultiplicityInfo.newBuilder();
+              multiplicity.lower().ifPresent(rendered::setLower);
+              multiplicity.upper().ifPresent(rendered::setUpper);
+              builder.setMultiplicity(rendered);
+            });
+    return builder.build();
+  }
+
+  /**
+   * An instance.
+   *
+   * @param instance the immutable instance
+   * @return the generated instance
+   */
+  static org.openmbee.opensysml.proto.Instance instance(Instance instance) {
+    org.openmbee.opensysml.proto.Instance.Builder builder =
+        org.openmbee.opensysml.proto.Instance.newBuilder()
+            .setId(instance.id())
+            .setTypeSymbolId(instance.typeSymbolId());
+    for (Map.Entry<String, Instance.FeatureValue> entry : instance.featureValues().entrySet()) {
+      Instance.FeatureValue featureValue = entry.getValue();
+      FeatureValue.Builder rendered =
+          FeatureValue.newBuilder()
+              .setFeatureName(featureValue.featureName())
+              .setMaterialized(featureValue.materialized());
+      featureValue.value().ifPresent(value -> rendered.setValue(value(value)));
+      featureValue.values().forEach(value -> rendered.addValues(value(value)));
+      featureValue.error().ifPresent(rendered::setError);
+      builder.putFeatureValues(entry.getKey(), rendered.build());
+    }
+    return builder.build();
+  }
+
+  /**
+   * A conversion's answer.
+   *
+   * @param conversion the immutable conversion
+   * @return the generated answer
+   */
+  static org.openmbee.opensysml.proto.ConvertResponse conversion(
+      org.openmbee.opensysml.Conversion conversion) {
+    return org.openmbee.opensysml.proto.ConvertResponse.newBuilder()
+        .setContent(conversion.content())
+        .setFromFormat(conversion.fromFormat())
+        .setToFormat(conversion.toFormat())
+        .addAllDiagnostics(diagnostics(conversion.diagnostics()))
+        .setExperimental(conversion.experimental())
+        .setExperimentalNotice(conversion.experimentalNotice())
+        .build();
+  }
+
+  /**
+   * The edits a batch applied.
+   *
+   * @param applied the immutable edits
+   * @return the generated edits, in order
+   */
+  static List<org.openmbee.opensysml.proto.AppliedEdit> appliedEdits(
+      List<org.openmbee.opensysml.AppliedEdit> applied) {
+    return applied.stream()
+        .map(
+            edit ->
+                org.openmbee.opensysml.proto.AppliedEdit.newBuilder()
+                    .setOperationIndex(edit.operationIndex())
+                    .setTarget(edit.target())
+                    .setOffset(edit.offset())
+                    .setLength(edit.length())
+                    .setOldText(edit.oldText())
+                    .setNewText(edit.newText())
+                    .setDocument(edit.document())
+                    .build())
+        .toList();
+  }
+
+  /**
+   * The documents a batch rewrote.
+   *
+   * @param documents the immutable documents
+   * @return the generated documents, in order
+   */
+  static List<org.openmbee.opensysml.proto.EditedDocument> editedDocuments(
+      List<org.openmbee.opensysml.EditedDocument> documents) {
+    return documents.stream()
+        .map(
+            document ->
+                org.openmbee.opensysml.proto.EditedDocument.newBuilder()
+                    .setName(document.name())
+                    .setContent(document.content())
+                    .build())
+        .toList();
+  }
+
+  /**
+   * The referrers a refused batch named.
+   *
+   * @param referrers the immutable referrers
+   * @return the generated referrers, in order
+   */
+  static List<org.openmbee.opensysml.proto.Referrer> referrers(
+      List<org.openmbee.opensysml.Referrer> referrers) {
+    return referrers.stream()
+        .map(
+            referrer ->
+                org.openmbee.opensysml.proto.Referrer.newBuilder()
+                    .setName(referrer.name())
+                    .setDocument(referrer.document())
+                    .build())
+        .toList();
+  }
+
+  /**
+   * A sweep's answer: its table and what ran it.
+   *
+   * @param sweep the immutable table
+   * @return the generated answer
+   */
+  static org.openmbee.opensysml.proto.RunSweepResponse sweep(org.openmbee.opensysml.Sweep sweep) {
+    List<org.openmbee.opensysml.proto.SweepRow> rows = new ArrayList<>(sweep.rows().size());
+    for (org.openmbee.opensysml.SweepRow row : sweep.rows()) {
+      rows.add(
+          org.openmbee.opensysml.proto.SweepRow.newBuilder()
+              .addAllInputs(outputs(row.inputs()))
+              .addAllOutputs(outputs(row.outputs()))
+              .addAllVerdicts(verdicts(row.verdicts()))
+              .setElapsedMicros(row.elapsed().toNanos() / 1000)
+              .setError(row.error())
+              .setFailureReason(failureReason(row.failureReason()))
+              .addAllEvaluations(evaluations(row.evaluations()))
+              .build());
+    }
+    return org.openmbee.opensysml.proto.RunSweepResponse.newBuilder()
+        .addAllRows(rows)
+        .addAllParameters(sweep.parameters())
+        .setSampled(sweep.sampled())
+        .setSeed(sweep.seed())
+        .addAllInstances(instances(sweep.instances()))
+        .addAllDiagnostics(diagnostics(sweep.diagnostics()))
+        .setEngine(sweep.standing().engine())
+        .setStrength(sweep.standing().strength())
+        .addAllBounds(bounds(sweep.standing()))
+        .build();
+  }
+
+  /**
+   * A document query's answer: its columns, and each row's element put back in the kind its
+   * {@link org.openmbee.opensysml.DocumentRow} carries it as.
+   *
+   * @param result the immutable result
+   * @return the generated answer
+   */
+  static org.openmbee.opensysml.proto.RunDocumentQueryResponse documentQueryResult(
+      org.openmbee.opensysml.DocumentQueryResult result) {
+    org.openmbee.opensysml.proto.RunDocumentQueryResponse.Builder response =
+        org.openmbee.opensysml.proto.RunDocumentQueryResponse.newBuilder();
+    result
+        .columns()
+        .forEach(
+            name ->
+                response.addColumns(
+                    org.openmbee.opensysml.proto.DocumentQueryColumn.newBuilder().setName(name)));
+    for (org.openmbee.opensysml.DocumentRow row : result.rows()) {
+      org.openmbee.opensysml.proto.DocumentQueryRow.Builder rendered =
+          org.openmbee.opensysml.proto.DocumentQueryRow.newBuilder()
+              .setElement(rowElement(row));
+      for (List<org.openmbee.opensysml.DocumentValue> cell : row.cells()) {
+        rendered.addCells(
+            org.openmbee.opensysml.proto.DocumentQueryCell.newBuilder()
+                .addAllValues(cell.stream().map(Protos::proto).toList()));
+      }
+      response.addRows(rendered);
+    }
+    return response.build();
+  }
+
+  private static org.openmbee.opensysml.proto.DocumentValue rowElement(
+      org.openmbee.opensysml.DocumentRow row) {
+    if (row.verdict().isPresent()) {
+      return Protos.proto(row.verdict().orElseThrow());
+    }
+    if (row.state().isPresent()) {
+      return Protos.proto(row.state().orElseThrow());
+    }
+    if (row.event().isPresent()) {
+      return Protos.proto(row.event().orElseThrow());
+    }
+    if (row.object().isPresent()) {
+      return Protos.proto(row.object().orElseThrow());
+    }
+    return Protos.proto(row.element());
+  }
+}
diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Report.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Report.java
similarity index 93%
rename from clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Report.java
rename to client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Report.java
index 38ef7b89af..3916ae0682 100644
--- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Report.java
+++ b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Report.java
@@ -5,7 +5,7 @@
 import java.util.List;
 
 /**
- * The machine-readable result of a run, in the shape {@code cmd/conformance} writes so the two are
+ * The machine-readable result of a run, in the shape {@code tools/cmd/conformance} writes so the two are
  * comparable.
  */
 final class Report {
diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Runner.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Runner.java
similarity index 95%
rename from clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Runner.java
rename to client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Runner.java
index 24f971391b..3f07732bc7 100644
--- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Runner.java
+++ b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Runner.java
@@ -131,7 +131,7 @@ private void execute(Scenario scenario, Report.Result result) {
     if (!Api.COVERED.contains(scenario.method())) {
       result.outcome = "skip";
       result.status = "-";
-      result.reason = "the v1 API does not cover " + scenario.method();
+      result.reason = "the public API does not cover " + scenario.method();
       return;
     }
 
@@ -309,7 +309,15 @@ private String modelHash(Scenario.Fixture fixture) {
             org.openmbee.opensysml.Language.fromWireName(fixture.language()), fixture.strictConformance());
     Model model;
     try {
-      model = connection.parse(fixture(fixture.fixture()), options);
+      if (!fixture.fixtures().isEmpty()) {
+        List<org.openmbee.opensysml.SourceDocument> documents =
+            fixture.fixtures().stream()
+                .map(name -> org.openmbee.opensysml.SourceDocument.inline(name, fixture(name)))
+                .toList();
+        model = connection.parseSources(documents, options);
+      } else {
+        model = connection.parse(fixture(fixture.fixture()), options);
+      }
     } catch (ModelException e) {
       throw new IllegalStateException("parsing fixture " + fixture.fixture() + ": " + e.getMessage(), e);
     }
diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenario.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenario.java
similarity index 90%
rename from clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenario.java
rename to client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenario.java
index 02fe0f205e..d5c95d3128 100644
--- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenario.java
+++ b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenario.java
@@ -29,8 +29,12 @@ record Scenario(
     Expect expect,
     String file) {
 
-  /** The source a scenario needs parsed before its call. */
-  record Fixture(String fixture, String language, boolean strictConformance) {}
+  /**
+   * The source a scenario needs parsed before its call: one fixture, or several parsed together
+   * as one model of several documents.
+   */
+  record Fixture(
+      String fixture, List<String> fixtures, String language, boolean strictConformance) {}
 
   /** The bare method name, whether the scenario qualified it or not. */
   String method() {
diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenarios.java b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenarios.java
similarity index 98%
rename from clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenarios.java
rename to client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenarios.java
index 49095eace8..0fb46b6b5b 100644
--- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenarios.java
+++ b/client/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Scenarios.java
@@ -51,7 +51,7 @@ final class Scenarios {
           "counts",
           "min_counts");
   private static final Set<String> MODEL_MEMBERS =
-      Set.of("fixture", "language", STRICT_CONFORMANCE);
+      Set.of("fixture", "fixtures", "language", STRICT_CONFORMANCE);
 
   private Scenarios() {}
 
@@ -128,6 +128,7 @@ private static Scenario.Fixture fixture(JsonObject model, Path file) {
     reject(model, MODEL_MEMBERS, file + ": model");
     return new Scenario.Fixture(
         text(model, "fixture"),
+        strings(model.get("fixtures")),
         text(model, "language"),
         model.has(STRICT_CONFORMANCE) && model.get(STRICT_CONFORMANCE).getAsBoolean());
   }
diff --git a/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ChecksTest.java b/client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ChecksTest.java
similarity index 100%
rename from clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ChecksTest.java
rename to client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ChecksTest.java
diff --git a/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/Expectations.java b/client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/Expectations.java
similarity index 100%
rename from clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/Expectations.java
rename to client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/Expectations.java
diff --git a/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/MainTest.java b/client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/MainTest.java
similarity index 100%
rename from clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/MainTest.java
rename to client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/MainTest.java
diff --git a/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/NormalizerTest.java b/client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/NormalizerTest.java
similarity index 100%
rename from clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/NormalizerTest.java
rename to client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/NormalizerTest.java
diff --git a/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ScenariosTest.java b/client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ScenariosTest.java
similarity index 100%
rename from clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ScenariosTest.java
rename to client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ScenariosTest.java
diff --git a/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ServiceBinary.java b/client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ServiceBinary.java
similarity index 100%
rename from clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ServiceBinary.java
rename to client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/ServiceBinary.java
diff --git a/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/SuiteTest.java b/client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/SuiteTest.java
similarity index 84%
rename from clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/SuiteTest.java
rename to client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/SuiteTest.java
index bcce31d9b8..d8726b1ec9 100644
--- a/clients/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/SuiteTest.java
+++ b/client/java/opensysml-conformance/src/test/java/org/openmbee/opensysml/conformance/SuiteTest.java
@@ -54,18 +54,24 @@ void theSameScenariosPassOverJsonBodies() {
   }
 
   @Test
-  void theSkippedScenariosAreTheOnesV1DoesNotCover() {
+  void theSkippedScenariosAreTheOnesThePublicApiDoesNotCover() {
     Report.Summary summary = run(Encoding.PROTOBUF, Mutations.NONE);
-    List<String> skipped =
-        summary.results.stream()
-            .filter(result -> result.outcome.equals("skip"))
-            .map(result -> result.rpc)
-            .distinct()
-            .toList();
-    skipped.forEach(rpc -> assertTrue(Api.declared(rpc), rpc + " is not an RPC of the service"));
-    assertTrue(
-        skipped.stream().noneMatch(rpc -> Api.COVERED.contains(rpc) && rpc.equals("Evaluate")),
-        "Evaluate is covered and must not be skipped wholesale: " + skipped);
+    List<Report.Result> skipped =
+        summary.results.stream().filter(result -> result.outcome.equals("skip")).toList();
+    for (Report.Result result : skipped) {
+      assertTrue(Api.declared(result.rpc), result.rpc + " is not an RPC of the service");
+      if (Api.COVERED.contains(result.rpc)) {
+        assertFalse(
+            result.reason.startsWith("the public API does not cover"),
+            result.id + " is skipped as uncovered, yet " + result.rpc + " is covered");
+      }
+    }
+    for (String rpc : Api.COVERED) {
+      assertTrue(
+          summary.results.stream()
+              .anyMatch(result -> result.rpc.equals(rpc) && result.outcome.equals("pass")),
+          rpc + " is covered and must run at least one scenario");
+    }
     assertEquals(summary.skipped, summary.results.stream().filter(r -> r.outcome.equals("skip")).count());
   }
 
diff --git a/clients/java/pom.xml b/client/java/pom.xml
similarity index 100%
rename from clients/java/pom.xml
rename to client/java/pom.xml
diff --git a/clients/node/.gitignore b/client/node/.gitignore
similarity index 100%
rename from clients/node/.gitignore
rename to client/node/.gitignore
diff --git a/client/node/README.md b/client/node/README.md
new file mode 100644
index 0000000000..e1e303dc49
--- /dev/null
+++ b/client/node/README.md
@@ -0,0 +1,461 @@
+# @opensysml/client
+
+Node and browser client for OpenSysML: parse, inspect and instantiate SysML v2
+models over the `sysml-grpc` service, using the [Connect
+protocol](https://connectrpc.com/docs/protocol) with protobuf bodies. No native
+addon, so an install is a plain registry fetch.
+
+```bash
+npm install @opensysml/client        # from npm, once the first release is published
+```
+
+```ts
+import { loads, connect } from "@opensysml/client";
+
+await using model = await loads(`package Demo {
+  part def Wheel { attribute radius : ScalarValues::Real = 0.3; }
+  part def Car { part wheels : Wheel[4]; attribute mass : ScalarValues::Real = 1500.0; }
+}`);
+
+const value = await model.eval("2 + 2");     // { kind: "int", value: 4n }
+const car = await model.symbol("Demo::Car"); // by qualified name
+await car.children();                        // its members, as symbols
+
+const tree = await model.instantiate("Demo::Car");
+const wheels = tree.get("wheels");           // a FeatureValue union
+if (wheels?.kind === "many") {
+  console.log(wheels.values.length);         // 4
+}
+```
+
+`loads`/`load` open a connection of their own and close it with the model.
+`connect()` is the longer-lived form, and parses more than one source over one
+service and one parse cache:
+
+```ts
+await using connection = await connect();
+const first = await connection.load("model.sysml");
+const second = await connection.loads("package Inline { part def Thing; }");
+connection.info.capabilities;                 // what this service can do
+connection.model(first.hash);                 // a model the service already holds
+```
+
+Both `Connection` and `Model` implement `Symbol.asyncDispose`, so `await using`
+closes them; `close()` is the explicit form, and is safe to call twice.
+
+## Values are discriminated unions
+
+Every `oneof` the service answers with arrives as a union to switch on, rather
+than a generated message with optional fields:
+
+```ts
+switch (value.kind) {
+  case "int":      value.value;                      // bigint, never lossy
+  case "real":     value.value;                       // number
+  case "complex":  value.value.real; value.value.imaginary;  // 1.5 - 2.0i, one value
+  case "boolean":  value.value;
+  case "string":   value.value;
+  case "quantity": value.magnitude; value.unit;       // 1500.0 [kg]
+  case "measurementRef": value.unit; value.unitTerm; value.unitId;  // a bare unit: km, reduced to 1000·metre
+  case "function": value.calcId; value.selfId;       // a calc as a value: Demo::Sq, or holder.scale read off an object
+  case "array":    value.dimensions; value.elements;  // row-major, an element is any SysMLValue
+  case "vector":   value.components;                   // { kind: "int" | "real" }[]
+  case "vectorQuantity": value.components;             // QuantityValue[], a unit per component
+  case "set":      value.elements;                     // SysMLValue[], each once, unordered
+  case "tensorQuantity": value.dimensions; value.components;  // any rank, row-major QuantityValue[]
+  case "metaobject": value.elementId; value.metaclassId;  // an element reflected on: x meta KerML::Feature
+  case "enum":     value.value.name; value.value.value; // its literal/enumeration ids, and the scalar a `high = 3` literal carries
+  case "instance": value.id;                          // an object in the same tree
+  case "sequence": value.elements;                    // SysMLValue[]
+  case "undetermined": value.reason; value.countLower; value.countUpper;  // the model leaves it open
+  case "infinity": break;                              // the unbounded `*`
+  case "null":     value.reason;                       // evaluated, no value
+  case "unset":    break;                              // declared, never given one
+  case "absent":   break;                              // the service sent no value at all
+}
+```
+
+`unset`, `undetermined` and `absent` are distinct on purpose: the first is a feature the
+model leaves without a value, the second a model-level answer the model leaves open (an
+unbound feature, a count the multiplicity does not fix) that is read but never sent, and the
+third a field the answer did not carry.
+`SysMLVerdict` (`holds` / `fails` / `undecided`) and `FeatureValue` (`single` /
+`many` / `error`) are unions of the same shape; every verdict arm carries a `standing` — the
+engine that answered, the strength of its evidence (`observed`, `witnessed`, `bounded`,
+`proved`) and the bounds it ran under — empty from a service without the `engines`
+capability. Integers are `bigint`, because
+the service's `int64` does not fit a `number` — an exact comparison against a
+scenario expectation would otherwise be a lie.
+
+## Two lifecycle modes
+
+### A private child of this process (the default)
+
+`connect()` with no address starts `sysml-grpc` as a child of this process with
+`-port 0 -health-port 0 -report-address -exit-with-parent`, and reads the address
+it bound from the child's first stdout line. No port is chosen, probed or
+retried, so two processes starting at once cannot collide.
+
+One child serves **every connection of a thread**: the first `connect()` starts
+it, the last `close()` stops it, and sharing it shares the service's parse cache.
+It is per thread rather than per process because the module state that holds it
+is per thread — a `worker_threads` worker gets its own child, and closing the
+worker's connections stops that child alone.
+
+**No orphans, and the mechanism is not an exit hook.** The client holds the write
+end of the child's stdin pipe and never writes to it; the child exits at end of
+file. The kernel closes that pipe when the holder dies however it dies, which
+survives what a `process.on("exit")` hook does not: `SIGKILL`, `process.abort()`,
+an uncaught fatal error, a crash during shutdown.
+`test/orphan.test.ts` proves it by `SIGKILL`ing a parent that holds a connection
+and asserting the child is gone.
+
+Node adds three wrinkles Python does not have, all deliberate here:
+
+- **The event loop.** A referenced child handle keeps Node alive, so a script that
+  forgot `close()` would never exit. The child and its three stdio handles are
+  `unref()`ed as soon as the address arrives — before that they stay referenced,
+  or Node could exit in the middle of starting the service. `stop()` `ref()`s the
+  child again for as long as it waits for it to exit.
+- **`detached: true`.** The child is started in a session of its own, so a
+  `SIGINT` meant for this process does not reach it mid-call; stdin is what ends
+  it. It is never `unref`ed *and* left running: this process holds the only write
+  end of its stdin.
+- **Worker threads.** Each thread owns its own child, as above.
+
+On **Windows** the guarantee is the same and rests on the same mechanism: the OS
+closes the anonymous stdin pipe when the owning process exits, however it exits,
+so the child sees end of file. What differs is that there is no process group to
+signal and no `SIGKILL` — `child.kill()` is `TerminateProcess` — so the orphan
+test is POSIX-only (`process.kill(pid, 0)` and `SIGKILL` have no equivalent), and
+prompt shutdown on Windows comes from closing stdin rather than from a signal.
+
+### A service someone else runs (explicit opt-in)
+
+```ts
+const connection = await connect({ address: "localhost:50051" });
+await connection.close();     // that service keeps running
+```
+
+or set `$OPENSYSML_SERVICE=host:port`. A connection made this way never owns the
+service: closing it disconnects and nothing else. There is no adoption of a
+service left listening by another process, no pidfile and no port probing.
+
+## The browser
+
+```ts
+import { connect } from "@opensysml/client/browser";
+
+await using connection = await connect({ address: "https://sysml.example.com" });
+```
+
+The browser entry point is the **explicit-address path only**: a browser cannot
+spawn a process, so there is no private child there and nothing to fall back to.
+It uses `@connectrpc/connect-web`, which is `fetch` and needs no proxy and no
+sidecar.
+
+Two limits to plan for rather than discover:
+
+- The service must allow the page's **exact origin** — start it with
+  `-cors-allowed-origins https://app.example.com`, never `*` — and must be
+  served over TLS (`-tls-cert`/`-tls-key`) for an HTTPS page to reach it.
+- `connect-go` v1.20 does not implement the base64 **`grpc-web-text`** variant.
+  This client does not need it: a `fetch`-based Connect client sends and reads
+  binary bodies directly. A `grpc-web` client that requires `-text` will not work
+  against this service, whatever the client.
+
+`test/browser.test.ts` runs this entry point against a real service over the same
+`fetch` transport, and asserts the allowed origin is answered on the preflight
+while another origin is not.
+
+## Protobuf, not JSON
+
+Bodies are protobuf by default. JSON is available (`connect({ encoding: "json" })`)
+for `curl`-shaped debugging, and it is the same answers, but
+[`docs/internals/design/transport-evaluation.md`](../../docs/internals/design/transport-evaluation.md)
+measured a 468 KB response at ~6.5 ms with a protobuf body against ~42 ms with
+JSON — `protojson` CPU on the service, not the ~10% difference in bytes. The
+gRPC protocol is also available (`connect({ protocol: "grpc" })`) and carries
+protobuf only; asking for `{ protocol: "grpc", encoding: "json" }` is refused
+rather than silently downgraded.
+
+## Capability negotiation
+
+Clients negotiate on the capability names `GetServerInfo` reports, not on
+versions:
+
+```ts
+import { CAPABILITY_EVALUATE_SUBJECT } from "@opensysml/client";
+
+if (connection.info.has(CAPABILITY_EVALUATE_SUBJECT)) {
+  await model.eval("mass", { subject: "Demo::sedan" });
+}
+```
+
+The client checks the advertised list **before** making such a call so it can
+raise a `MissingCapabilityError` naming the service, its version and the way to
+get one that has it. A direct capability-gated request to a service without the
+capability is refused with `UNIMPLEMENTED`; response-population capabilities
+instead omit the fields they name. A service without `structured_values`,
+`measurement_refs`, `function_values`, `set_values`, `tensor_values` or
+`metaobject_values` sends the value kinds those name (`array`, `vector`,
+`vectorQuantity`; `measurementRef`; `function`; `set`; `tensorQuantity`;
+`metaobject`) as `null` with an `unsupported: …` reason.
+A function closing over the bindings of a behavior body has no wire form and is
+sent as `null` by every service. A `set` arrives
+with its elements in the service's canonical order, so two equal sets arrive
+alike, and one listing a member twice is a `MalformedValueError`, whether it
+arrives or is about to be sent; one sent to the service may list its members in
+any order. `valuesEqual` is the membership test, as the service judges it: sets
+by membership, sequences in order, numbers by value — `1` and `1.0` are one
+member, exactly across the whole `int` range — and a quantity by magnitude
+through its `unitTerm`, so `1 [m]` is `100 [cm]` (exactly, while the magnitude
+is an `int` and the scale a whole ratio); one without a `unitTerm` is compared
+in its unit as written. A `tensorQuantity` carries its `dimensions` and one
+quantity per component, row-major. A `metaobject` — what `x meta KerML::Feature`
+or the last element of `x.metadata` evaluates to — names the element reflected
+on (`elementId`, its identity) and the element's own metaclass (`metaclassId`,
+not the type it was cast to); two are `valuesEqual` exactly when they name one
+element. Its features (`declaredName`, `ownedFeature`, …) are read in the model,
+not carried. One sent to the service may leave `metaclassId` empty to have the
+model's used; one naming a metaclass that is not the element's is refused, and
+one naming no element is a `MalformedValueError`.
+
+## Failures are typed
+
+Every failure is an `OpenSysMLError`. A call the service refused is a
+`ServiceError` whose `code` is the RPC status it came back with (`"NOT_FOUND"`,
+`"INVALID_ARGUMENT"`, …), and the statuses worth catching by themselves have a
+subclass: `ModelNotFoundError` (the service no longer holds that hash),
+`ModelFileNotFoundError`, `InvalidRequestError`, `ServiceTimeoutError`,
+`UnsupportedOperationError`. A name the model has not got is a
+`SymbolNotFoundError`, which carries the `symbolName` it looked for and the
+`suggestions` closest to it:
+
+```ts
+try {
+  await model.symbol("Wheeel");
+} catch (error) {
+  if (error instanceof SymbolNotFoundError) {
+    console.error(`no ${error.symbolName}; did you mean ${error.suggestions[0]}?`);
+  }
+}
+```
+
+Source that does not parse is not a failure: `load`/`loads` return a model whose
+`hasErrors` is true and whose `diagnostics` say where. Each `ModelDiagnostic` has
+`severity`, `message`, `code` and an optional location; branch on `code`
+(`"syntax"`, a validation code such as `"unresolved"`, `"choice-point"`,
+`"guard-unevaluable"`; `""` when the service assigned none), not on the message
+text. A service that populates `code` advertises `CAPABILITY_DIAGNOSTIC_CODES`;
+without it every code is `""`. Options that cannot work
+(an encoding that is not one, a timeout that cannot elapse, `grpc` with `json`)
+are refused before a connection is opened or a service started.
+
+## The service binary
+
+The binary comes from an **optional per-platform npm package**, selected by npm
+from its `os`/`cpu` metadata:
+
+| package | platform |
+| --- | --- |
+| `@opensysml/sysml-grpc-linux-x64` | Linux x86-64 |
+| `@opensysml/sysml-grpc-linux-arm64` | Linux arm64 |
+| `@opensysml/sysml-grpc-darwin-x64` | macOS Intel |
+| `@opensysml/sysml-grpc-darwin-arm64` | macOS Apple silicon |
+| `@opensysml/sysml-grpc-win32-x64` | Windows x86-64 |
+
+That is a normal registry install: npm verifies the tarball against the
+registry's integrity hash, and **there is no postinstall script**, so a platform
+with a package never downloads anything.
+
+Resolution order:
+
+1. `$OPENSYSML_BINARY` — a path to a binary, which wins over everything;
+2. the platform package above;
+3. `~/.opensysml/bin/sysml-grpc`, the cache the Python client also uses, filled
+   by a verified download of a release when nothing above resolved;
+4. `sysml-grpc` on `$PATH`;
+5. otherwise: an error, or connect to a service someone else runs.
+
+### Downloading a release
+
+`resolveBinary()` downloads a `sysml-grpc-<os>-<arch>` release asset into
+`~/.opensysml/bin/sysml-grpc` (`.exe` on Windows) when the steps above resolved
+nothing — the same cache, the same metadata beside it in `sysml-grpc.json`, and
+the same trust model as the Python client, so either client can use what the
+other downloaded. `process.platform`/`process.arch` map to the five published
+pairs (`linux-amd64`, `linux-arm64`, `darwin-amd64`, `darwin-arm64`,
+`windows-amd64`); any other pair is an error naming it rather than a fetch.
+
+| variable | effect |
+| --- | --- |
+| `$OPENSYSML_BINARY` | a path to a binary, which wins over everything |
+| `$OPENSYSML_GRPC_VERSION` | the release to download, else `latest` from the releases API |
+| `$OPENSYSML_GITHUB_REPO` | the release repository, default `Open-MBEE/OpenSysML` |
+| `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD` | `<owner/repo>`, or `1` for any repository: accept same-origin trust |
+
+The download goes to a temporary file, is hashed, and only then replaces the
+cache path atomically and is `chmod 0700`ed (POSIX); a download that does not
+verify leaves an existing cached binary and its metadata untouched and removes
+the temporary file. A cached binary of another version is replaced with a
+warning rather than used, and every request times out after 15 seconds. A
+transport failure falls back to a cached binary that still verifies; a digest or
+signature failure never does.
+
+### What a download is verified against
+
+In order, and each step refuses rather than falling back to the next:
+
+1. **A shipped pin.** `release-digests.json`, synced from
+   `client/release-digests.json` by `python3 scripts/sync-release-digests.py`
+   and published in the tarball, pins the SHA-256 of every asset of a release.
+   Where it pins one, that is what the bytes must hash to, and a served
+   `.sha256` that disagrees is tampering: the download fails.
+2. **The release's signed manifest.** With no pin, the client downloads
+   `SHA256SUMS.txt` and its sigstore bundle `SHA256SUMS.txt.bundle`, verifies
+   the bundle against the release pipeline's certificate identity (the CircleCI
+   OIDC issuer and project in `src/node/signing.ts`), and takes the digest from
+   the verified manifest. Anything short of that — no bundle, a signature that
+   does not verify, another signer, an expired certificate, a manifest changed
+   after signing, a repository with no known signer, or the optional sigstore
+   packages not installed — is refused exactly as an unpinned release is. A
+   manifest digest that contradicts a pin is an error, not a downgrade.
+3. **Nothing.** The download fails naming the version, because the `.sha256`
+   served beside a binary comes from whoever served the binary: it detects
+   corruption but not a compromised release.
+   `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=<owner/repo>` (or `=1` for any
+   repository) accepts that same-origin trust explicitly, with a warning saying
+   so. It is never a way around a failed signature or a pin mismatch.
+
+Verification uses `@sigstore/verify` with `@sigstore/bundle`,
+`@sigstore/protobuf-specs` and `@sigstore/tuf` — the packages the `sigstore`
+package is itself built from — as **optional** dependencies, so the client
+installs and works without them and a release with no pin is refused where they
+are missing. They are used rather than `sigstore.verify` because that entry
+point takes its trusted root only through TUF, and both the Python client and
+these tests verify against a recorded trusted root offline.
+
+The per-platform packages are built by
+`npm run platform-packages -- --binaries <dir>`, and each binary is packaged
+**only** if its bytes match the `.sha256` sidecar beside it; a missing sidecar is
+refused rather than trusted. The release job cross-compiles those binaries from
+the tagged revision in the same run that publishes the packages and writes the
+sidecars there, so nothing is downloaded to authenticate — which is the job the
+Python client's pinned digests do — and an install is a normal registry fetch
+carrying npm's own integrity hashes. npm's `--provenance` is not used because
+the CLI mints attestations only on GitHub Actions and GitLab CI/CD, and this
+repository releases from CircleCI. The README of each package records the digest
+of the binary it carries.
+
+## Generated stubs
+
+`src/generated/sysml_pb.ts` is generated by `buf` from `api/proto/sysml.proto`
+through the `protoc-gen-es` entry in `api/proto/buf.gen.ts.yaml`. The plugin is this
+package's devDependency, pinned in `package-lock.json` at the same version as the
+`@bufbuild/protobuf` runtime the stubs import, so `make proto` regenerates them
+from `node_modules` with no hand steps and no network fetch at generation time.
+
+**The stubs are committed**, for the same reason the Python ones are: `npm
+install @opensysml/client` must not need `buf`, Go or a network fetch of a
+plugin, and a published tarball has to contain the compiled output. CI runs
+`make proto-ts` and fails on any diff, so committed and generated cannot drift.
+
+## What v1 does not do
+
+This client covers `GetServerInfo`, `ParseFile`, `GetSymbol`, `Evaluate` and
+`Instantiate` — connection, lifecycle, capability negotiation, and the five RPCs
+above. Deliberately **not** in v1, rather than half-implemented:
+
+- generated model-ergonomics types (`python -m opensysml.generate`'s equivalent);
+- the edit API (`ApplyEdits`);
+- RDF conversion (`Convert`);
+- verification helpers (`VerifyConstraint`, `VerifyRequirement`,
+  `VerifySatisfaction`);
+- `Query`, `GetDiagnostics`, `EvaluateCalc`, `RunAnalysis`, `ExecuteAction`, `ExecuteState`.
+
+`connection.rpc` is the escape hatch: it is the generated Connect client, so any
+RPC not covered here can still be called, without the ergonomic layer.
+
+## Examples
+
+`examples/` is six programs against one model, a rover, in `examples/model.ts`.
+They are written to be read in order, they assert what they print, and the test
+suite runs every one of them, so they cannot drift from the API.
+
+```bash
+npm run examples          # all of them, in order
+npm run example 03        # one, by number or name
+```
+
+| example | what it shows |
+| --- | --- |
+| `01-tour` | connect, parse, look up, evaluate, instantiate |
+| `02-values` | every value kind, and what it decodes to in JavaScript |
+| `03-symbols` | walking, lookup by name and id, type facts, adoption by hash |
+| `04-instances` | instance trees, single and repeated features, unset and absent |
+| `05-diagnostics` | syntax errors, the error each failure raises, refused options |
+| `06-connections` | ownership, an external service, both protocols, calls at once |
+
+## Conformance
+
+The suite in `conformance/` is the service contract, and this client runs it
+**through its public API** — `load`/`loads`, `eval`, `symbol`, `instantiate` —
+not through the generated stubs. A scenario whose RPC v1 does not cover is
+skipped with a reason, and the report has the same shape `tools/cmd/conformance` emits:
+
+```bash
+npm run conformance -- --allow-skips --report report.json
+```
+
+| protocol | ran | passed | failed | skipped |
+| --- | --: | --: | --: | --: |
+| `grpc` | 59 | 23 | 0 | 36 |
+| `connect` | 59 | 23 | 0 | 36 |
+| `connect-json` | 59 | 23 | 0 | 36 |
+| **total** | **177** | **69** | **0** | **108** |
+
+The 36 skips per protocol are 35 scenarios for the 10 RPCs listed above plus one
+the public API cannot express: a `ParseFile` naming no source, since `load` and
+`loads` always name one. Every skip carries its reason in the report.
+
+**The runner is not vacuous.** `--mutate <name>` corrupts a response on its way
+through the client, and each mutation makes at least one scenario fail:
+
+| `--mutate` | what it breaks | caught by |
+| --- | --- | --- |
+| `hide-capability` | drops a capability from `GetServerInfo` | `server_info/...` |
+| `drop-diagnostics` | drops parse diagnostics | `parse/a_syntax_error_...` |
+| `blank-symbol-kind` | blanks `SymbolInfo.kind` | `symbol/...` |
+| `shift-integer` | adds 1 to an integer result | `evaluate/arithmetic_is_an_integer` |
+| `drop-feature-values` | empties an instance's feature values | `instantiate/...` |
+
+```bash
+npm run conformance -- --allow-skips --mutate shift-integer   # must fail
+```
+
+## Development
+
+```bash
+npm install
+npm run build        # dist/, what is published
+npm run typecheck
+npm run lint
+npm test             # unit, lifecycle and service-backed tests
+npm run conformance -- --allow-skips
+```
+
+The service-backed tests build `sysml-grpc` from this checkout on first use, or
+use `$OPENSYSML_BINARY` when it names one. The `node-test` job runs all five
+commands, plus the mutation checks and a stub-drift check, in both
+`.github/workflows/pr.yml` (pull requests) and `.circleci/config.yml`.
+
+## Release
+
+Nothing here is published yet; the procedure is in
+[docs/project/releasing.md](../../docs/project/releasing.md) under "The Node
+client". In short: the `release-node` workflow runs on a `client-node-v*` tag,
+cross-compiles the binaries, builds the per-platform packages from them, and
+publishes those packages and then the client from the `npm` context. It needs
+the `@opensysml` npm organization and an automation token, which a maintainer
+supplies.
diff --git a/clients/node/conformance/compare.ts b/client/node/conformance/compare.ts
similarity index 100%
rename from clients/node/conformance/compare.ts
rename to client/node/conformance/compare.ts
diff --git a/clients/node/conformance/main.ts b/client/node/conformance/main.ts
similarity index 96%
rename from clients/node/conformance/main.ts
rename to client/node/conformance/main.ts
index 60dd7626c2..2e7f63ef25 100644
--- a/clients/node/conformance/main.ts
+++ b/client/node/conformance/main.ts
@@ -1,5 +1,5 @@
 // Runs the conformance suite in conformance/ through this client, over each
-// protocol asked for, and writes the report cmd/conformance writes.
+// protocol asked for, and writes the report tools/cmd/conformance writes.
 
 import { execFileSync } from "node:child_process";
 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -63,7 +63,7 @@ export async function runSuite(options: RunOptions): Promise<Report> {
   const service = options.service ?? binary ?? "";
   const report: Report = { service, total: 0, passed: 0, failed: 0, skipped: 0, errored: 0, protocols: [] };
   // One connection held open for the run, so every protocol tests one service
-  // process and one parse cache, as cmd/conformance does.
+  // process and one parse cache, as tools/cmd/conformance does.
   const held = await connect({ timeoutMs: 60_000 });
   try {
     for (const name of options.protocols) {
@@ -136,7 +136,7 @@ async function open(
   return { runner, connection };
 }
 
-/** Builds the service the suite tests, the way cmd/conformance does. */
+/** Builds the service the suite tests, the way tools/cmd/conformance does. */
 function buildService(repo: string, workDir: string): string {
   const output = join(workDir, process.platform === "win32" ? "sysml-grpc.exe" : "sysml-grpc");
   execFileSync("go", ["build", "-o", output, "./cmd/sysml-grpc"], { cwd: resolve(repo), stdio: "inherit" });
@@ -160,7 +160,7 @@ function restore(name: string, value: string | undefined): void {
   }
 }
 
-/** Parses the command line, mirroring cmd/conformance's flags. */
+/** Parses the command line, mirroring tools/cmd/conformance's flags. */
 export function parseOptions(argv: string[]): RunOptions {
   const { values } = parseArgs({
     args: argv,
diff --git a/clients/node/conformance/mutations.ts b/client/node/conformance/mutations.ts
similarity index 100%
rename from clients/node/conformance/mutations.ts
rename to client/node/conformance/mutations.ts
diff --git a/clients/node/conformance/normalize.ts b/client/node/conformance/normalize.ts
similarity index 99%
rename from clients/node/conformance/normalize.ts
rename to client/node/conformance/normalize.ts
index 4910da76f4..3be6b8047f 100644
--- a/clients/node/conformance/normalize.ts
+++ b/client/node/conformance/normalize.ts
@@ -17,6 +17,7 @@ const NORMALIZED_IDS = new Set([
   "sysml.Value.instance_id",
   "sysml.Verdict.instance_id",
   "sysml.Function.self_id",
+  "sysml.DocumentObject.instance_id",
 ]);
 
 /**
diff --git a/clients/node/conformance/order.ts b/client/node/conformance/order.ts
similarity index 100%
rename from clients/node/conformance/order.ts
rename to client/node/conformance/order.ts
diff --git a/clients/node/conformance/runner.ts b/client/node/conformance/runner.ts
similarity index 97%
rename from clients/node/conformance/runner.ts
rename to client/node/conformance/runner.ts
index d179259456..e52e8e0b22 100644
--- a/clients/node/conformance/runner.ts
+++ b/client/node/conformance/runner.ts
@@ -18,7 +18,7 @@ import { Literal, methodOf, type Expect, type Scenario, type ScenarioModel } fro
 /** The RPCs v1 of this client covers. Everything else is a stated skip. */
 export const COVERED_RPCS = ["GetServerInfo", "ParseFile", "GetSymbol", "Evaluate", "Instantiate"] as const;
 
-/** One scenario's outcome. The shape cmd/conformance writes. */
+/** One scenario's outcome. The shape tools/cmd/conformance writes. */
 export interface Result {
   id: string;
   outcome: "pass" | "fail" | "skip" | "error";
@@ -180,6 +180,13 @@ export class Runner {
       result.reason = `the service does not report ${missing.join(", ")}, so the without-capability expectation applies`;
     }
 
+    if (scenario.model?.fixtures !== undefined) {
+      result.outcome = "skip";
+      result.status = "-";
+      result.reason = "v1 of this client parses one document at a time, not a model of several";
+      return finish();
+    }
+
     let modelHash = "";
     let request: Record<string, unknown>;
     try {
@@ -350,6 +357,9 @@ export class Runner {
 
   /** Parses a scenario's fixture once per run and remembers the hash. */
   private async modelHash(model: ScenarioModel): Promise<string> {
+    if (model.fixture === undefined) {
+      throw new Error("a model names no fixture");
+    }
     const key = `${model.fixture}|${model.language ?? ""}|${String(model.strict_conformance ?? false)}`;
     const known = this.hashes.get(key);
     if (known !== undefined) {
diff --git a/clients/node/conformance/scenarios.ts b/client/node/conformance/scenarios.ts
similarity index 94%
rename from clients/node/conformance/scenarios.ts
rename to client/node/conformance/scenarios.ts
index e54f921d78..a491ef6f3e 100644
--- a/clients/node/conformance/scenarios.ts
+++ b/client/node/conformance/scenarios.ts
@@ -18,9 +18,13 @@ export class Literal {
   }
 }
 
-/** The source a scenario needs parsed before its call, named by fixture. */
+/**
+ * The source a scenario needs parsed before its call: one fixture, or several
+ * parsed together as one model, named by their fixtures.
+ */
 export interface ScenarioModel {
-  fixture: string;
+  fixture?: string;
+  fixtures?: string[];
   language?: string;
   strict_conformance?: boolean;
 }
@@ -76,7 +80,7 @@ const EXPECT_FIELDS = new Set([
   "min_counts",
 ]);
 
-const MODEL_FIELDS = new Set(["fixture", "language", "strict_conformance"]);
+const MODEL_FIELDS = new Set(["fixture", "fixtures", "language", "strict_conformance"]);
 
 /** The scenario's RPC as a bare method name. */
 export function methodOf(scenario: Scenario): string {
diff --git a/clients/node/eslint.config.mjs b/client/node/eslint.config.mjs
similarity index 100%
rename from clients/node/eslint.config.mjs
rename to client/node/eslint.config.mjs
diff --git a/clients/node/examples/01-tour.ts b/client/node/examples/01-tour.ts
similarity index 100%
rename from clients/node/examples/01-tour.ts
rename to client/node/examples/01-tour.ts
diff --git a/clients/node/examples/02-values.ts b/client/node/examples/02-values.ts
similarity index 100%
rename from clients/node/examples/02-values.ts
rename to client/node/examples/02-values.ts
diff --git a/clients/node/examples/03-symbols.ts b/client/node/examples/03-symbols.ts
similarity index 100%
rename from clients/node/examples/03-symbols.ts
rename to client/node/examples/03-symbols.ts
diff --git a/clients/node/examples/04-instances.ts b/client/node/examples/04-instances.ts
similarity index 100%
rename from clients/node/examples/04-instances.ts
rename to client/node/examples/04-instances.ts
diff --git a/clients/node/examples/05-diagnostics.ts b/client/node/examples/05-diagnostics.ts
similarity index 100%
rename from clients/node/examples/05-diagnostics.ts
rename to client/node/examples/05-diagnostics.ts
diff --git a/clients/node/examples/06-connections.ts b/client/node/examples/06-connections.ts
similarity index 100%
rename from clients/node/examples/06-connections.ts
rename to client/node/examples/06-connections.ts
diff --git a/clients/node/examples/model.ts b/client/node/examples/model.ts
similarity index 100%
rename from clients/node/examples/model.ts
rename to client/node/examples/model.ts
diff --git a/clients/node/package-lock.json b/client/node/package-lock.json
similarity index 100%
rename from clients/node/package-lock.json
rename to client/node/package-lock.json
diff --git a/client/node/package.json b/client/node/package.json
new file mode 100644
index 0000000000..b45c553ca6
--- /dev/null
+++ b/client/node/package.json
@@ -0,0 +1,77 @@
+{
+  "name": "@opensysml/client",
+  "version": "0.1.0",
+  "description": "OpenSysML client: parse, evaluate and instantiate SysML v2 models over the sysml-grpc Connect API",
+  "license": "Apache-2.0",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/Open-MBEE/OpenSysML.git",
+    "directory": "client/node"
+  },
+  "homepage": "https://github.com/Open-MBEE/OpenSysML/tree/main/client/node#readme",
+  "keywords": [
+    "sysml",
+    "sysml-v2",
+    "mbse",
+    "connect",
+    "opensysml"
+  ],
+  "type": "module",
+  "engines": {
+    "node": ">=20.11"
+  },
+  "exports": {
+    ".": {
+      "types": "./dist/node/index.d.ts",
+      "browser": "./dist/browser/index.js",
+      "default": "./dist/node/index.js"
+    },
+    "./browser": {
+      "types": "./dist/browser/index.d.ts",
+      "default": "./dist/browser/index.js"
+    }
+  },
+  "files": [
+    "dist",
+    "README.md",
+    "release-digests.json"
+  ],
+  "scripts": {
+    "build": "tsc -p tsconfig.build.json",
+    "build:tools": "tsc -p tsconfig.json",
+    "typecheck": "tsc --noEmit -p tsconfig.json",
+    "lint": "eslint .",
+    "test": "npm run build:tools && node --test \"build/test/**/*.test.js\"",
+    "test:coverage": "npm run build:tools && c8 --reporter=text --reporter=lcovonly node --test \"build/test/**/*.test.js\"",
+    "example": "npm run build:tools && node scripts/run-example.mjs",
+    "examples": "npm run build:tools && node scripts/run-example.mjs --all",
+    "conformance": "npm run build:tools && node build/conformance/main.js --dir ../../conformance --repo ../..",
+    "platform-packages": "node scripts/build-platform-packages.mjs"
+  },
+  "dependencies": {
+    "@bufbuild/protobuf": "2.14.0",
+    "@connectrpc/connect": "2.1.2",
+    "@connectrpc/connect-node": "2.1.2",
+    "@connectrpc/connect-web": "2.1.2"
+  },
+  "optionalDependencies": {
+    "@opensysml/sysml-grpc-darwin-arm64": "0.1.0",
+    "@opensysml/sysml-grpc-darwin-x64": "0.1.0",
+    "@opensysml/sysml-grpc-linux-arm64": "0.1.0",
+    "@opensysml/sysml-grpc-linux-x64": "0.1.0",
+    "@opensysml/sysml-grpc-win32-x64": "0.1.0",
+    "@sigstore/bundle": "4.0.0",
+    "@sigstore/protobuf-specs": "0.5.1",
+    "@sigstore/tuf": "4.0.2",
+    "@sigstore/verify": "3.1.1"
+  },
+  "devDependencies": {
+    "@bufbuild/protoc-gen-es": "2.14.0",
+    "@types/make-fetch-happen": "^10.0.4",
+    "@types/node": "22.10.5",
+    "c8": "11.0.0",
+    "eslint": "9.39.5",
+    "typescript": "5.7.3",
+    "typescript-eslint": "8.67.0"
+  }
+}
diff --git a/clients/node/release-digests.json b/client/node/release-digests.json
similarity index 100%
rename from clients/node/release-digests.json
rename to client/node/release-digests.json
diff --git a/clients/node/scripts/build-platform-packages.mjs b/client/node/scripts/build-platform-packages.mjs
similarity index 99%
rename from clients/node/scripts/build-platform-packages.mjs
rename to client/node/scripts/build-platform-packages.mjs
index 3b14a4ba7d..db5ea0070a 100644
--- a/clients/node/scripts/build-platform-packages.mjs
+++ b/client/node/scripts/build-platform-packages.mjs
@@ -97,7 +97,7 @@ function main() {
           repository: {
             type: "git",
             url: "git+https://github.com/Open-MBEE/OpenSysML.git",
-            directory: "clients/node",
+            directory: "client/node",
           },
           os: [platform.os],
           cpu: [platform.cpu],
diff --git a/clients/node/scripts/run-example.mjs b/client/node/scripts/run-example.mjs
similarity index 100%
rename from clients/node/scripts/run-example.mjs
rename to client/node/scripts/run-example.mjs
diff --git a/clients/node/src/browser/index.ts b/client/node/src/browser/index.ts
similarity index 100%
rename from clients/node/src/browser/index.ts
rename to client/node/src/browser/index.ts
diff --git a/clients/node/src/core/capabilities.ts b/client/node/src/core/capabilities.ts
similarity index 94%
rename from clients/node/src/core/capabilities.ts
rename to client/node/src/core/capabilities.ts
index 559bb32f50..7ba2239b07 100644
--- a/clients/node/src/core/capabilities.ts
+++ b/client/node/src/core/capabilities.ts
@@ -52,10 +52,14 @@ export const CAPABILITY_CASE_EVALUATIONS = "case_evaluations";
 export const CAPABILITY_QUERY = "query";
 /** The `ApplyEdits` RPC. Not used by this version; see the README. */
 export const CAPABILITY_APPLY_EDITS = "apply_edits";
+/** `ApplyEdits` edits a model of several documents as one batch and answers each edited document by name in `documents`. Not used by this version; see the README. */
+export const CAPABILITY_EDIT_DOCUMENTS = "edit_documents";
 /** The `schedule` field of the execution requests, naming the scheduling policy. Not used by this version; see the README. */
 export const CAPABILITY_SCHEDULE = "schedule";
 /** The `explore` scheduling policy, answering with every `outcomes` entry and an `exploration` status. Not used by this version; see the README. */
 export const CAPABILITY_SCHEDULE_EXPLORE = "schedule_explore";
+/** `performerSymbolId` on the action and state requests: the object the behavior runs on, a declaration or a path from one into its parts. Not used by this version; see the README. */
+export const CAPABILITY_PERFORMER = "performer";
 /** The `ListEngines` RPC, the `engine` field selecting an analysis engine, and `engine`, `strength` and `bounds` on the answers. Not used by this version; see the README. */
 export const CAPABILITY_ENGINES = "engines";
 /** A model-level result the model leaves open as `Value.undetermined`, read as an `undetermined` value. */
diff --git a/clients/node/src/core/connection.ts b/client/node/src/core/connection.ts
similarity index 100%
rename from clients/node/src/core/connection.ts
rename to client/node/src/core/connection.ts
diff --git a/clients/node/src/core/errors.ts b/client/node/src/core/errors.ts
similarity index 100%
rename from clients/node/src/core/errors.ts
rename to client/node/src/core/errors.ts
diff --git a/client/node/src/core/index.ts b/client/node/src/core/index.ts
new file mode 100644
index 0000000000..f6a3fe0b43
--- /dev/null
+++ b/client/node/src/core/index.ts
@@ -0,0 +1,106 @@
+// The isomorphic core: everything that does not need a process to spawn.
+
+export { Connection } from "./connection.js";
+export type {
+  ConnectionBackend,
+  Encoding,
+  ResponseTap,
+  TransportOptions,
+} from "./connection.js";
+export { Instance, InstanceTree, Model, ModelSymbol } from "./model.js";
+export type {
+  AttributeFacts,
+  EvalOptions,
+  FeatureValue,
+  ParseOptions,
+  SpecializationFacts,
+  TypeFacts,
+} from "./model.js";
+export {
+  CAPABILITY_APPLY_EDITS,
+  CAPABILITY_CASE_EVALUATIONS,
+  CAPABILITY_COMPLEX_VALUES,
+  CAPABILITY_CONVERT,
+  CAPABILITY_DIAGNOSTIC_CODES,
+  CAPABILITY_EDIT_DOCUMENTS,
+  CAPABILITY_ENGINES,
+  CAPABILITY_ENUM_VALUES,
+  CAPABILITY_EVALUATE_SUBJECT,
+  CAPABILITY_FEATURE_VALUES,
+  CAPABILITY_FINAL_TIME,
+  CAPABILITY_FUNCTION_VALUES,
+  CAPABILITY_INFINITY_VALUE,
+  CAPABILITY_INLINE_LANGUAGE,
+  CAPABILITY_MEASUREMENT_REFS,
+  CAPABILITY_METAOBJECT_VALUES,
+  CAPABILITY_PERFORMER,
+  CAPABILITY_QUERY,
+  CAPABILITY_SCHEDULE,
+  CAPABILITY_SCHEDULE_EXPLORE,
+  CAPABILITY_SET_VALUES,
+  CAPABILITY_STRICT_CONFORMANCE,
+  CAPABILITY_STRUCTURED_VALUES,
+  CAPABILITY_UNDETERMINED_VALUE,
+  CAPABILITY_VERIFICATION_VERDICTS,
+  CAPABILITY_SYMBOL_ATTRIBUTES,
+  CAPABILITY_TENSOR_VALUES,
+  CAPABILITY_TYPE_FACTS,
+  CAPABILITY_UNSET_VALUE,
+  CAPABILITY_VERIFICATION,
+  MissingCapabilityError,
+  ServerInfo,
+  requireCapability,
+  upgradeRemedy,
+} from "./capabilities.js";
+export {
+  ChecksumMismatchError,
+  ClosedConnectionError,
+  DownloadError,
+  EvaluationError,
+  InvalidRequestError,
+  MalformedValueError,
+  ManifestSignatureError,
+  ModelFileNotFoundError,
+  ModelNotFoundError,
+  OpenSysMLError,
+  ParseError,
+  ServiceError,
+  ServiceStartError,
+  ServiceTimeoutError,
+  SymbolNotFoundError,
+  UnpinnedReleaseError,
+  UnsignedReleaseError,
+  UnsupportedOperationError,
+} from "./errors.js";
+export type { FailureCause, ModelDiagnostic } from "./errors.js";
+export { fromHandshakeError, fromRpcError, statusName } from "./status.js";
+export type { NotFoundSubject } from "./status.js";
+export {
+  decodeValue,
+  decodeVerdict,
+  decodeStanding,
+  encodeValue,
+  formatValue,
+  valuesEqual,
+} from "./values.js";
+export type {
+  ArrayValue,
+  ComplexValue,
+  EnumValue,
+  FunctionValue,
+  Magnitude,
+  MeasurementRefValue,
+  MetaobjectValue,
+  QuantityValue,
+  SysMLValue,
+  SysMLVerdict,
+  VerdictBound,
+  VerdictStanding,
+  TensorQuantityValue,
+  UndeterminedValue,
+  UnitFactor,
+  UnitFactorization,
+  VerdictSubject,
+} from "./values.js";
+export { baseUrl } from "./transport.js";
+export { SysMLService } from "../generated/sysml_pb.js";
diff --git a/clients/node/src/core/model.ts b/client/node/src/core/model.ts
similarity index 100%
rename from clients/node/src/core/model.ts
rename to client/node/src/core/model.ts
diff --git a/clients/node/src/core/status.ts b/client/node/src/core/status.ts
similarity index 100%
rename from clients/node/src/core/status.ts
rename to client/node/src/core/status.ts
diff --git a/clients/node/src/core/transport.ts b/client/node/src/core/transport.ts
similarity index 100%
rename from clients/node/src/core/transport.ts
rename to client/node/src/core/transport.ts
diff --git a/clients/node/src/core/values.ts b/client/node/src/core/values.ts
similarity index 100%
rename from clients/node/src/core/values.ts
rename to client/node/src/core/values.ts
diff --git a/client/node/src/generated/sysml_pb.ts b/client/node/src/generated/sysml_pb.ts
new file mode 100644
index 0000000000..e9587f1efc
--- /dev/null
+++ b/client/node/src/generated/sysml_pb.ts
@@ -0,0 +1,5489 @@
+// @generated by protoc-gen-es v2.14.0 with parameter "target=ts,import_extension=.js"
+// @generated from file sysml.proto (package sysml, syntax proto3)
+/* eslint-disable */
+
+import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
+import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
+import type { Message } from "@bufbuild/protobuf";
+
+/**
+ * Describes the file sysml.proto.
+ */
+export const file_sysml: GenFile = /*@__PURE__*/
+  fileDesc("CgtzeXNtbC5wcm90bxIFc3lzbWwiuQIKB1ZlcmRpY3QSDAoEa2luZBgBIAEoCRISCgplbGVtZW50X2lkGAIgASgJEg8KB2VsZW1lbnQYAyABKAkSDQoFaG9sZHMYBCABKAgSEQoJY29uZGl0aW9uGAUgASgJEhMKC2luc3RhbmNlX2lkGAYgASgDEhgKEGluc3RhbmNlX3R5cGVfaWQYByABKAkSDQoFZXJyb3IYCCABKAkSLAoOZmFpbHVyZV9yZWFzb24YCSABKA4yFC5zeXNtbC5GYWlsdXJlUmVhc29uEhYKDnJlcXVpcmVtZW50X2lkGAogASgJEg4KBmVuZ2luZRgLIAEoCRIQCghzdHJlbmd0aBgMIAEoCRIcCgZib3VuZHMYDSADKAsyDC5zeXNtbC5Cb3VuZBIVCg1pbnN0YW5jZV9wYXRoGA4gASgJIjUKBUJvdW5kEgwKBG5hbWUYASABKAkSDQoFbGltaXQYAiABKAMSDwoHcmVhY2hlZBgDIAEoCCJrChdWZXJpZnlDb25zdHJhaW50UmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhEKCXN5bWJvbF9pZBgCIAEoCRIZChFzdWJqZWN0X3N5bWJvbF9pZBgDIAEoCRIOCgZlbmdpbmUYBCABKAkilgEKGFZlcmlmeUNvbnN0cmFpbnRSZXNwb25zZRIfCgd2ZXJkaWN0GAEgASgLMg4uc3lzbWwuVmVyZGljdBIiCglpbnN0YW5jZXMYAiADKAsyDy5zeXNtbC5JbnN0YW5jZRINCgVlcnJvchgDIAEoCRImCgtkaWFnbm9zdGljcxgEIAMoCzIRLnN5c21sLkRpYWdub3N0aWMibAoYVmVyaWZ5UmVxdWlyZW1lbnRSZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkSEQoJc3ltYm9sX2lkGAIgASgJEhkKEXN1YmplY3Rfc3ltYm9sX2lkGAMgASgJEg4KBmVuZ2luZRgEIAEoCSJtChNWZXJpZmljYXRpb25WZXJkaWN0Eg8KB2Nhc2VfaWQYASABKAkSDAoEa2luZBgCIAEoCRIOCgZkZXRhaWwYAyABKAkSDwoHc3ViY2FzZRgEIAEoCBIWCg5yZXF1aXJlbWVudF9pZBgFIAEoCSLSAQoZVmVyaWZ5UmVxdWlyZW1lbnRSZXNwb25zZRIfCgd2ZXJkaWN0GAEgASgLMg4uc3lzbWwuVmVyZGljdBIiCglpbnN0YW5jZXMYAiADKAsyDy5zeXNtbC5JbnN0YW5jZRINCgVlcnJvchgDIAEoCRImCgtkaWFnbm9zdGljcxgEIAMoCzIRLnN5c21sLkRpYWdub3N0aWMSOQoVdmVyaWZpY2F0aW9uX3ZlcmRpY3RzGAUgAygLMhouc3lzbWwuVmVyaWZpY2F0aW9uVmVyZGljdCJSChlWZXJpZnlTYXRpc2ZhY3Rpb25SZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkSEQoJc3ltYm9sX2lkGAIgASgJEg4KBmVuZ2luZRgDIAEoCSKCAgoaVmVyaWZ5U2F0aXNmYWN0aW9uUmVzcG9uc2USIAoIdmVyZGljdHMYASADKAsyDi5zeXNtbC5WZXJkaWN0EiIKCWluc3RhbmNlcxgCIAMoCzIPLnN5c21sLkluc3RhbmNlEg0KBWVycm9yGAMgASgJEiYKC2RpYWdub3N0aWNzGAQgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxIsCg5mYWlsdXJlX3JlYXNvbhgFIAEoDjIULnN5c21sLkZhaWx1cmVSZWFzb24SOQoVdmVyaWZpY2F0aW9uX3ZlcmRpY3RzGAYgAygLMhouc3lzbWwuVmVyaWZpY2F0aW9uVmVyZGljdCJQChdWYWxpZGF0ZUluc3RhbmNlUmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhEKCXN5bWJvbF9pZBgCIAEoCRIOCgZlbmdpbmUYAyABKAkisgIKGFZhbGlkYXRlSW5zdGFuY2VSZXNwb25zZRIgCgh2ZXJkaWN0cxgBIAMoCzIOLnN5c21sLlZlcmRpY3QSHwoHc3VtbWFyeRgCIAEoCzIOLnN5c21sLlZlcmRpY3QSIgoJaW5zdGFuY2VzGAMgAygLMg8uc3lzbWwuSW5zdGFuY2USDQoFZXJyb3IYBCABKAkSJgoLZGlhZ25vc3RpY3MYBSADKAsyES5zeXNtbC5EaWFnbm9zdGljEiwKDmZhaWx1cmVfcmVhc29uGAYgASgOMhQuc3lzbWwuRmFpbHVyZVJlYXNvbhI5ChV2ZXJpZmljYXRpb25fdmVyZGljdHMYByADKAsyGi5zeXNtbC5WZXJpZmljYXRpb25WZXJkaWN0Eg8KB2JvdW5kZWQYCCABKAgibQoTRXZhbHVhdGVDYWxjUmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhEKCXN5bWJvbF9pZBgCIAEoCRIfCglhcmd1bWVudHMYAyADKAsyDC5zeXNtbC5WYWx1ZRIOCgZlbmdpbmUYBCABKAki/QEKFEV2YWx1YXRlQ2FsY1Jlc3BvbnNlEhwKBnJlc3VsdBgBIAEoCzIMLnN5c21sLlZhbHVlEiIKB291dHB1dHMYAiADKAsyES5zeXNtbC5DYWxjT3V0cHV0Eg0KBWVycm9yGAMgASgJEiYKC2RpYWdub3N0aWNzGAQgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxIsCg5mYWlsdXJlX3JlYXNvbhgFIAEoDjIULnN5c21sLkZhaWx1cmVSZWFzb24SDgoGZW5naW5lGAYgASgJEhAKCHN0cmVuZ3RoGAcgASgJEhwKBmJvdW5kcxgIIAMoCzIMLnN5c21sLkJvdW5kIjcKCkNhbGNPdXRwdXQSDAoEbmFtZRgBIAEoCRIbCgV2YWx1ZRgCIAEoCzIMLnN5c21sLlZhbHVlIpMBCg5DYXNlRXZhbHVhdGlvbhITCgtmdW5jdGlvbl9pZBgBIAEoCRIfCglhcmd1bWVudHMYAiADKAsyDC5zeXNtbC5WYWx1ZRIcCgZyZXN1bHQYAyABKAsyDC5zeXNtbC5WYWx1ZRINCgVlcnJvchgEIAEoCRIQCghzZWxlY3RlZBgFIAEoCBIMCgR0aWVkGAYgASgIIqYCChJSdW5BbmFseXNpc1JlcXVlc3QSEgoKbW9kZWxfaGFzaBgBIAEoCRIRCglzeW1ib2xfaWQYAiABKAkSGQoRc3ViamVjdF9zeW1ib2xfaWQYAyABKAkSHwoJYXJndW1lbnRzGAQgAygLMgwuc3lzbWwuVmFsdWUSRgoPbmFtZWRfYXJndW1lbnRzGAUgAygLMi0uc3lzbWwuUnVuQW5hbHlzaXNSZXF1ZXN0Lk5hbWVkQXJndW1lbnRzRW50cnkSEAoIc2NoZWR1bGUYBiABKAkSDgoGZW5naW5lGAcgASgJGkMKE05hbWVkQXJndW1lbnRzRW50cnkSCwoDa2V5GAEgASgJEhsKBXZhbHVlGAIgASgLMgwuc3lzbWwuVmFsdWU6AjgBItwDChNSdW5BbmFseXNpc1Jlc3BvbnNlEiIKB291dHB1dHMYASADKAsyES5zeXNtbC5DYWxjT3V0cHV0EiAKCHZlcmRpY3RzGAIgAygLMg4uc3lzbWwuVmVyZGljdBIiCglpbnN0YW5jZXMYAyADKAsyDy5zeXNtbC5JbnN0YW5jZRINCgVlcnJvchgEIAEoCRImCgtkaWFnbm9zdGljcxgFIAMoCzIRLnN5c21sLkRpYWdub3N0aWMSLAoOZmFpbHVyZV9yZWFzb24YBiABKA4yFC5zeXNtbC5GYWlsdXJlUmVhc29uEjkKFXZlcmlmaWNhdGlvbl92ZXJkaWN0cxgHIAMoCzIaLnN5c21sLlZlcmlmaWNhdGlvblZlcmRpY3QSIAoIb3V0Y29tZXMYCCADKAsyDi5zeXNtbC5PdXRjb21lEi0KC2V4cGxvcmF0aW9uGAkgASgLMhguc3lzbWwuRXhwbG9yYXRpb25TdGF0dXMSKgoLZXZhbHVhdGlvbnMYCiADKAsyFS5zeXNtbC5DYXNlRXZhbHVhdGlvbhIOCgZlbmdpbmUYCyABKAkSEAoIc3RyZW5ndGgYDCABKAkSHAoGYm91bmRzGA0gAygLMgwuc3lzbWwuQm91bmQilwIKB091dGNvbWUSLAoHb3V0cHV0cxgBIAMoCzIbLnN5c21sLk91dGNvbWUuT3V0cHV0c0VudHJ5EhMKC2ZpbmFsX3N0YXRlGAIgASgJEhYKDnN0YXRlc192aXNpdGVkGAMgAygJEg0KBWVycm9yGAQgASgJEhYKDmxpbmVhcml6YXRpb25zGAUgASgFEg8KB3dpdG5lc3MYBiADKAkSJgoLZGlhZ25vc3RpY3MYByADKAsyES5zeXNtbC5EaWFnbm9zdGljEhMKC3Byb2JhYmlsaXR5GAggASgBGjwKDE91dHB1dHNFbnRyeRILCgNrZXkYASABKAkSGwoFdmFsdWUYAiABKAsyDC5zeXNtbC5WYWx1ZToCOAEilgEKEUV4cGxvcmF0aW9uU3RhdHVzEhAKCGNvbXBsZXRlGAEgASgIEgwKBHJ1bnMYAiABKAUSEwoLYnVkZ2V0c19oaXQYAyADKAkSEwoLcnVuc19idWRnZXQYBCABKAUSFAoMZGVwdGhfYnVkZ2V0GAUgASgFEiEKGXByb2JhYmlsaXRpZXNfbG93ZXJfYm91bmQYBiABKAgiFAoSTGlzdEVuZ2luZXNSZXF1ZXN0IvwBCgpFbmdpbmVJbmZvEgwKBG5hbWUYASABKAkSEQoJYXV0aG9yaXR5GAIgASgJEg8KB2Fuc3dlcnMYAyADKAkSDgoGYm91bmRzGAQgAygJEg8KB3Byb2Nlc3MYBSABKAkSFQoNcHJvY2Vzc19mb3VuZBgGIAEoCRINCgVyZWFkeRgHIAEoCBITCgt1bmF2YWlsYWJsZRgIIAEoCRIMCgRraW5kGAkgASgJEhAKCHByb3RvY29sGAogASgJEg4KBnNvdXJjZRgLIAEoCRIPCgdjb21tYW5kGAwgASgJEg8KB3ZlcnNpb24YDSABKAkSDgoGc2VydmVkGA4gASgIIjkKE0xpc3RFbmdpbmVzUmVzcG9uc2USIgoHZW5naW5lcxgBIAMoCzIRLnN5c21sLkVuZ2luZUluZm8ijAEKEFBhcnNlRmlsZVJlcXVlc3QSEwoJZmlsZV9wYXRoGAEgASgJSAASEQoHY29udGVudBgCIAEoCUgAEhgKDGNvbnRlbnRfaGFzaBgDIAEoCUICGAESEAoIbGFuZ3VhZ2UYBCABKAkSGgoSc3RyaWN0X2NvbmZvcm1hbmNlGAUgASgIQggKBnNvdXJjZSJiCg5Tb3VyY2VEb2N1bWVudBITCglmaWxlX3BhdGgYASABKAlIABIRCgdjb250ZW50GAIgASgJSAASEAoIbGFuZ3VhZ2UYAyABKAkSDAoEbmFtZRgEIAEoCUIICgZzb3VyY2UiWwoTUGFyc2VTb3VyY2VzUmVxdWVzdBIoCglkb2N1bWVudHMYASADKAsyFS5zeXNtbC5Tb3VyY2VEb2N1bWVudBIaChJzdHJpY3RfY29uZm9ybWFuY2UYAiABKAgigwEKFFBhcnNlU291cmNlc1Jlc3BvbnNlEhIKCm1vZGVsX2hhc2gYASABKAkSIAoFcm9vdHMYAiADKAsyES5zeXNtbC5TeW1ib2xJbmZvEiYKC2RpYWdub3N0aWNzGAMgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxINCgVlcnJvchgEIAEoCSJ/ChFQYXJzZUZpbGVSZXNwb25zZRISCgptb2RlbF9oYXNoGAEgASgJEh8KBHJvb3QYAiABKAsyES5zeXNtbC5TeW1ib2xJbmZvEiYKC2RpYWdub3N0aWNzGAMgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxINCgVlcnJvchgEIAEoCSI5ChBHZXRTeW1ib2xSZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkSEQoJc3ltYm9sX2lkGAIgASgJIkIKDlN5bWJvbFJlc3BvbnNlEiEKBnN5bWJvbBgBIAEoCzIRLnN5c21sLlN5bWJvbEluZm8SDQoFZXJyb3IYAiABKAkiKAoSRGlhZ25vc3RpY3NSZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkiTAoTRGlhZ25vc3RpY3NSZXNwb25zZRImCgtkaWFnbm9zdGljcxgBIAMoCzIRLnN5c21sLkRpYWdub3N0aWMSDQoFZXJyb3IYAiABKAkibwoPRXZhbHVhdGVSZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkSEgoKZXhwcmVzc2lvbhgCIAEoCRIZChFjb250ZXh0X3N5bWJvbF9pZBgDIAEoCRIZChFzdWJqZWN0X3N5bWJvbF9pZBgEIAEoCSJnChBFdmFsdWF0ZVJlc3BvbnNlEhwKBnJlc3VsdBgBIAEoCzIMLnN5c21sLlZhbHVlEg0KBWVycm9yGAIgASgJEiYKC2RpYWdub3N0aWNzGAMgAygLMhEuc3lzbWwuRGlhZ25vc3RpYyLCAQoISW5zdGFuY2USCgoCaWQYASABKAMSFgoOdHlwZV9zeW1ib2xfaWQYAiABKAkSOgoOZmVhdHVyZV92YWx1ZXMYBCADKAsyIi5zeXNtbC5JbnN0YW5jZS5GZWF0dXJlVmFsdWVzRW50cnkaSQoSRmVhdHVyZVZhbHVlc0VudHJ5EgsKA2tleRgBIAEoCRIiCgV2YWx1ZRgCIAEoCzITLnN5c21sLkZlYXR1cmVWYWx1ZToCOAFKBAgDEARSBXNsb3RzIoQBCgxGZWF0dXJlVmFsdWUSFAoMZmVhdHVyZV9uYW1lGAEgASgJEhsKBXZhbHVlGAIgASgLMgwuc3lzbWwuVmFsdWUSHAoGdmFsdWVzGAMgAygLMgwuc3lzbWwuVmFsdWUSFAoMbWF0ZXJpYWxpemVkGAQgASgIEg0KBWVycm9yGAUgASgJIjsKEkluc3RhbnRpYXRlUmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhEKCXN5bWJvbF9pZBgCIAEoCSKTAQoTSW5zdGFudGlhdGVSZXNwb25zZRIhCghpbnN0YW5jZRgBIAEoCzIPLnN5c21sLkluc3RhbmNlEg0KBWVycm9yGAIgASgJEiYKC2RpYWdub3N0aWNzGAMgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxIiCglpbnN0YW5jZXMYBCADKAsyDy5zeXNtbC5JbnN0YW5jZSLpAQoURXhlY3V0ZUFjdGlvblJlcXVlc3QSEgoKbW9kZWxfaGFzaBgBIAEoCRIYChBhY3Rpb25fc3ltYm9sX2lkGAIgASgJEjcKBmlucHV0cxgDIAMoCzInLnN5c21sLkV4ZWN1dGVBY3Rpb25SZXF1ZXN0LklucHV0c0VudHJ5EhAKCHNjaGVkdWxlGAQgASgJEhsKE3BlcmZvcm1lcl9zeW1ib2xfaWQYBSABKAkaOwoLSW5wdXRzRW50cnkSCwoDa2V5GAEgASgJEhsKBXZhbHVlGAIgASgLMgwuc3lzbWwuVmFsdWU6AjgBIq0CChVFeGVjdXRlQWN0aW9uUmVzcG9uc2USOgoHb3V0cHV0cxgBIAMoCzIpLnN5c21sLkV4ZWN1dGVBY3Rpb25SZXNwb25zZS5PdXRwdXRzRW50cnkSDQoFZXJyb3IYAiABKAkSJgoLZGlhZ25vc3RpY3MYAyADKAsyES5zeXNtbC5EaWFnbm9zdGljEiAKCG91dGNvbWVzGAQgAygLMg4uc3lzbWwuT3V0Y29tZRItCgtleHBsb3JhdGlvbhgFIAEoCzIYLnN5c21sLkV4cGxvcmF0aW9uU3RhdHVzEhIKCmZpbmFsX3RpbWUYBiABKAEaPAoMT3V0cHV0c0VudHJ5EgsKA2tleRgBIAEoCRIbCgV2YWx1ZRgCIAEoCzIMLnN5c21sLlZhbHVlOgI4ASKJAQoTRXhlY3V0ZVN0YXRlUmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEh8KF3N0YXRlX21hY2hpbmVfc3ltYm9sX2lkGAIgASgJEg4KBmV2ZW50cxgDIAMoCRIQCghzY2hlZHVsZRgEIAEoCRIbChNwZXJmb3JtZXJfc3ltYm9sX2lkGAUgASgJItMCChRFeGVjdXRlU3RhdGVSZXNwb25zZRIWCg5zdGF0ZXNfdmlzaXRlZBgBIAMoCRJECg1maW5hbF9jb250ZXh0GAIgAygLMi0uc3lzbWwuRXhlY3V0ZVN0YXRlUmVzcG9uc2UuRmluYWxDb250ZXh0RW50cnkSDQoFZXJyb3IYAyABKAkSJgoLZGlhZ25vc3RpY3MYBCADKAsyES5zeXNtbC5EaWFnbm9zdGljEiAKCG91dGNvbWVzGAUgAygLMg4uc3lzbWwuT3V0Y29tZRItCgtleHBsb3JhdGlvbhgGIAEoCzIYLnN5c21sLkV4cGxvcmF0aW9uU3RhdHVzEhIKCmZpbmFsX3RpbWUYByABKAEaQQoRRmluYWxDb250ZXh0RW50cnkSCwoDa2V5GAEgASgJEhsKBXZhbHVlGAIgASgLMgwuc3lzbWwuVmFsdWU6AjgBIqABCg5Db252ZXJ0UmVxdWVzdBITCglmaWxlX3BhdGgYASABKAlIABIRCgdjb250ZW50GAIgASgJSAASFAoKbW9kZWxfaGFzaBgGIAEoCUgAEhMKC2Zyb21fZm9ybWF0GAMgASgJEhEKCXRvX2Zvcm1hdBgEIAEoCRIeChZ0b2xlcmF0ZV9zeW50YXhfZXJyb3JzGAUgASgIQggKBnNvdXJjZSK0AQoPQ29udmVydFJlc3BvbnNlEg8KB2NvbnRlbnQYASABKAkSEwoLZnJvbV9mb3JtYXQYAiABKAkSEQoJdG9fZm9ybWF0GAMgASgJEg0KBWVycm9yGAQgASgJEiYKC2RpYWdub3N0aWNzGAUgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxIUCgxleHBlcmltZW50YWwYBiABKAgSGwoTZXhwZXJpbWVudGFsX25vdGljZRgHIAEoCSJ9ChFBcHBseUVkaXRzUmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEigKCm9wZXJhdGlvbnMYAiADKAsyFC5zeXNtbC5FZGl0T3BlcmF0aW9uEhAKCGRvY3VtZW50GAMgASgJEhgKEGFjY2VwdF9kb2N1bWVudHMYBCABKAgi3QEKDUVkaXRPcGVyYXRpb24SKAoJc2V0X3ZhbHVlGAEgASgLMhMuc3lzbWwuU2V0VmFsdWVFZGl0SAASIwoGcmVuYW1lGAIgASgLMhEuc3lzbWwuUmVuYW1lRWRpdEgAEioKCmFkZF9tZW1iZXIYAyABKAsyFC5zeXNtbC5BZGRNZW1iZXJFZGl0SAASIwoGZGVsZXRlGAQgASgLMhEuc3lzbWwuRGVsZXRlRWRpdEgAEh8KBG1vdmUYBSABKAsyDy5zeXNtbC5Nb3ZlRWRpdEgAQgsKCW9wZXJhdGlvbiKCAQoNQWRkTWVtYmVyRWRpdBINCgVvd25lchgBIAEoCRIMCgRraW5kGAIgASgJEgwKBG5hbWUYAyABKAkSDAoEdHlwZRgEIAEoCRIUCgxtdWx0aXBsaWNpdHkYBSABKAkSDQoFdmFsdWUYBiABKAkSEwoLc3BlY2lhbGl6ZXMYByADKAkiLQoKRGVsZXRlRWRpdBIOCgZ0YXJnZXQYASABKAkSDwoHY2FzY2FkZRgCIAEoCCIpCghNb3ZlRWRpdBIOCgZ0YXJnZXQYASABKAkSDQoFb3duZXIYAiABKAkiLQoMU2V0VmFsdWVFZGl0Eg4KBnRhcmdldBgBIAEoCRINCgV2YWx1ZRgCIAEoCSIuCgpSZW5hbWVFZGl0Eg4KBnRhcmdldBgBIAEoCRIQCghuZXdfbmFtZRgCIAEoCSKQAgoSQXBwbHlFZGl0c1Jlc3BvbnNlEg8KB2NvbnRlbnQYASABKAkSIwoHYXBwbGllZBgCIAMoCzISLnN5c21sLkFwcGxpZWRFZGl0Eg0KBWVycm9yGAMgASgJEiMKB2ZhaWx1cmUYBCABKA4yEi5zeXNtbC5FZGl0RmFpbHVyZRImCgtkaWFnbm9zdGljcxgFIAMoCzIRLnN5c21sLkRpYWdub3N0aWMSGgoScmVmZXJyaW5nX2VsZW1lbnRzGAYgAygJEigKCWRvY3VtZW50cxgHIAMoCzIVLnN5c21sLkVkaXRlZERvY3VtZW50EiIKCXJlZmVycmVycxgIIAMoCzIPLnN5c21sLlJlZmVycmVyIi8KDkVkaXRlZERvY3VtZW50EgwKBG5hbWUYASABKAkSDwoHY29udGVudBgCIAEoCSIqCghSZWZlcnJlchIMCgRuYW1lGAEgASgJEhAKCGRvY3VtZW50GAIgASgJIowBCgtBcHBsaWVkRWRpdBIXCg9vcGVyYXRpb25faW5kZXgYASABKAUSDgoGdGFyZ2V0GAIgASgJEg4KBm9mZnNldBgDIAEoBRIOCgZsZW5ndGgYBCABKAUSEAoIb2xkX3RleHQYBSABKAkSEAoIbmV3X3RleHQYBiABKAkSEAoIZG9jdW1lbnQYByABKAki/QIKClN5bWJvbEluZm8SCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIMCgRraW5kGAMgASgJEjEKCG1ldGFkYXRhGAQgAygLMh8uc3lzbWwuU3ltYm9sSW5mby5NZXRhZGF0YUVudHJ5EhEKCWNoaWxkX2lkcxgFIAMoCRIoCgphdHRyaWJ1dGVzGAYgAygLMhQuc3lzbWwuQXR0cmlidXRlSW5mbxIiCgl0eXBlX2luZm8YByABKAsyDy5zeXNtbC5UeXBlSW5mbxItCgxtdWx0aXBsaWNpdHkYCCABKAsyFy5zeXNtbC5NdWx0aXBsaWNpdHlJbmZvEi4KD3NwZWNpYWxpemF0aW9ucxgJIAMoCzIVLnN5c21sLlNwZWNpYWxpemF0aW9uEiMKG3dpdGhoZWxkX2xpYnJhcnlfYXR0cmlidXRlcxgKIAEoBRovCg1NZXRhZGF0YUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiWAoOU3BlY2lhbGl6YXRpb24SDAoEa2luZBgBIAEoCRIQCghkZWNsYXJlZBgCIAEoCRIRCgl0YXJnZXRfaWQYAyABKAkSEwoLdGFyZ2V0X2tpbmQYBCABKAkilQEKCFR5cGVJbmZvEhAKCGRlY2xhcmVkGAEgASgJEhMKC3Jlc29sdmVkX2lkGAIgASgJEhUKDXJlc29sdmVkX2tpbmQYAyABKAkSEQoJcHJpbWl0aXZlGAQgASgJEhgKEHByaW1pdGl2ZV9zb3VyY2UYBSABKAkSEAoIcXVhbnRpdHkYBiABKAgSDAoEdW5pdBgHIAEoCSIwChBNdWx0aXBsaWNpdHlJbmZvEg0KBWxvd2VyGAEgASgJEg0KBXVwcGVyGAIgASgJIlYKDUF0dHJpYnV0ZUluZm8SDAoEbmFtZRgBIAEoCRIMCgR0eXBlGAIgASgJEhsKBXZhbHVlGAMgASgLMgwuc3lzbWwuVmFsdWUSDAoEdW5pdBgEIAEoCSLDBQoFVmFsdWUSEwoJaW50X3ZhbHVlGAEgASgDSAASFAoKcmVhbF92YWx1ZRgCIAEoAUgAEhQKCmJvb2xfdmFsdWUYAyABKAhIABIWCgxzdHJpbmdfdmFsdWUYBCABKAlIABIVCgtpbnN0YW5jZV9pZBgFIAEoA0gAEigKCHNlcXVlbmNlGAYgASgLMhQuc3lzbWwuVmFsdWVTZXF1ZW5jZUgAEg4KBG51bGwYByABKAlIABIjCghxdWFudGl0eRgIIAEoCzIPLnN5c21sLlF1YW50aXR5SAASKgoMZW51bV9saXRlcmFsGAkgASgLMhIuc3lzbWwuRW51bUxpdGVyYWxIABIPCgV1bnNldBgKIAEoCEgAEiEKB2NvbXBsZXgYCyABKAsyDi5zeXNtbC5Db21wbGV4SAASHQoFYXJyYXkYDCABKAsyDC5zeXNtbC5BcnJheUgAEh8KBnZlY3RvchgNIAEoCzINLnN5c21sLlZlY3RvckgAEjAKD3ZlY3Rvcl9xdWFudGl0eRgOIAEoCzIVLnN5c21sLlZlY3RvclF1YW50aXR5SAASMAoPbWVhc3VyZW1lbnRfcmVmGA8gASgLMhUuc3lzbWwuTWVhc3VyZW1lbnRSZWZIABISCghpbmZpbml0eRgQIAEoCEgAEiMKCGZ1bmN0aW9uGBEgASgLMg8uc3lzbWwuRnVuY3Rpb25IABIeCgNzZXQYEiABKAsyDy5zeXNtbC5WYWx1ZVNldEgAEjAKD3RlbnNvcl9xdWFudGl0eRgTIAEoCzIVLnN5c21sLlRlbnNvclF1YW50aXR5SAASJwoKbWV0YW9iamVjdBgUIAEoCzIRLnN5c21sLk1ldGFvYmplY3RIABIrCgx1bmRldGVybWluZWQYFSABKAsyEy5zeXNtbC5VbmRldGVybWluZWRIAEIGCgRraW5kIjYKCk1ldGFvYmplY3QSEgoKZWxlbWVudF9pZBgBIAEoCRIUCgxtZXRhY2xhc3NfaWQYAiABKAkiRgoMVW5kZXRlcm1pbmVkEg4KBnJlYXNvbhgBIAEoCRImCgVjb3VudBgCIAEoCzIXLnN5c21sLk11bHRpcGxpY2l0eUluZm8iLAoIRnVuY3Rpb24SDwoHY2FsY19pZBgBIAEoCRIPCgdzZWxmX2lkGAIgASgDIioKCFZhbHVlU2V0Eh4KCGVsZW1lbnRzGAEgAygLMgwuc3lzbWwuVmFsdWUiSQoOVGVuc29yUXVhbnRpdHkSEgoKZGltZW5zaW9ucxgBIAMoAxIjCgpjb21wb25lbnRzGAIgAygLMg8uc3lzbWwuUXVhbnRpdHkiOwoFQXJyYXkSEgoKZGltZW5zaW9ucxgBIAMoAxIeCghlbGVtZW50cxgCIAMoCzIMLnN5c21sLlZhbHVlIioKBlZlY3RvchIgCgpjb21wb25lbnRzGAEgAygLMgwuc3lzbWwuVmFsdWUiNQoOVmVjdG9yUXVhbnRpdHkSIwoKY29tcG9uZW50cxgBIAMoCzIPLnN5c21sLlF1YW50aXR5IioKB0NvbXBsZXgSDAoEcmVhbBgBIAEoARIRCglpbWFnaW5hcnkYAiABKAEiZAoLRW51bUxpdGVyYWwSEgoKbGl0ZXJhbF9pZBgBIAEoCRIWCg5lbnVtZXJhdGlvbl9pZBgCIAEoCRIMCgRuYW1lGAMgASgJEhsKBXZhbHVlGAQgASgLMgwuc3lzbWwuVmFsdWUiLwoNVmFsdWVTZXF1ZW5jZRIeCghlbGVtZW50cxgBIAMoCzIMLnN5c21sLlZhbHVlInwKCFF1YW50aXR5EhcKDWludF9tYWduaXR1ZGUYASABKANIABIYCg5yZWFsX21hZ25pdHVkZRgCIAEoAUgAEgwKBHVuaXQYAyABKAkSIgoJdW5pdF90ZXJtGAQgASgLMg8uc3lzbWwuVW5pdFRlcm1CCwoJbWFnbml0dWRlIlMKDk1lYXN1cmVtZW50UmVmEgwKBHVuaXQYASABKAkSIgoJdW5pdF90ZXJtGAIgASgLMg8uc3lzbWwuVW5pdFRlcm0SDwoHdW5pdF9pZBgDIAEoCSJUCghVbml0VGVybRIRCglzY2FsZV9udW0YASABKAESEQoJc2NhbGVfZGVuGAIgASgBEiIKB2ZhY3RvcnMYAyADKAsyES5zeXNtbC5Vbml0RmFjdG9yIi8KClVuaXRGYWN0b3ISDwoHdW5pdF9pZBgBIAEoCRIQCghleHBvbmVudBgCIAEoASJYCgpEaWFnbm9zdGljEhAKCHNldmVyaXR5GAEgASgJEg8KB21lc3NhZ2UYAiABKAkSGQoEc3BhbhgDIAEoCzILLnN5c21sLlNwYW4SDAoEY29kZRgEIAEoCSJeCgRTcGFuEgwKBGZpbGUYASABKAkSEgoKc3RhcnRfbGluZRgCIAEoBRIRCglzdGFydF9jb2wYAyABKAUSEAoIZW5kX2xpbmUYBCABKAUSDwoHZW5kX2NvbBgFIAEoBSITChFTZXJ2ZXJJbmZvUmVxdWVzdCI7ChJTZXJ2ZXJJbmZvUmVzcG9uc2USDwoHdmVyc2lvbhgBIAEoCRIUCgxjYXBhYmlsaXRpZXMYAiADKAkiUwoMUXVlcnlSZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkSGwoFcXVlcnkYAiABKAsyDC5zeXNtbC5RdWVyeRISCgpvc2xjX3F1ZXJ5GAMgASgJIjwKDVF1ZXJ5UmVzcG9uc2USKwoIZWxlbWVudHMYASADKAsyGS5zeXNtbC5RdWVyeVJlc3VsdEVsZW1lbnQiSAoFUXVlcnkSDQoFc2NvcGUYASADKAkSDgoGc2VsZWN0GAIgAygJEiAKBXdoZXJlGAMgASgLMhEuc3lzbWwuQ29uc3RyYWludCJ8CgpDb25zdHJhaW50Ei8KCXByaW1pdGl2ZRgBIAEoCzIaLnN5c21sLlByaW1pdGl2ZUNvbnN0cmFpbnRIABIvCgljb21wb3NpdGUYAiABKAsyGi5zeXNtbC5Db21wb3NpdGVDb25zdHJhaW50SABCDAoKY29uc3RyYWludCJzChNQcmltaXRpdmVDb25zdHJhaW50Eg8KB2ludmVyc2UYASABKAgSEAoIcHJvcGVydHkYAiABKAkSKgoIb3BlcmF0b3IYAyABKA4yGC5zeXNtbC5QcmltaXRpdmVPcGVyYXRvchINCgV2YWx1ZRgEIAMoCSJoChNDb21wb3NpdGVDb25zdHJhaW50EioKCG9wZXJhdG9yGAEgASgOMhguc3lzbWwuQ29tcG9zaXRlT3BlcmF0b3ISJQoKY29uc3RyYWludBgCIAMoCzIRLnN5c21sLkNvbnN0cmFpbnQioAEKElF1ZXJ5UmVzdWx0RWxlbWVudBIKCgJpZBgBIAEoCRIMCgR0eXBlGAIgASgJEj0KCnByb3BlcnRpZXMYAyADKAsyKS5zeXNtbC5RdWVyeVJlc3VsdEVsZW1lbnQuUHJvcGVydGllc0VudHJ5GjEKD1Byb3BlcnRpZXNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBInMKClN3ZWVwUmFuZ2USEQoJcGFyYW1ldGVyGAEgASgJEhsKBXN0YXJ0GAIgASgLMgwuc3lzbWwuVmFsdWUSGQoDZW5kGAMgASgLMgwuc3lzbWwuVmFsdWUSGgoEc3RlcBgEIAEoCzIMLnN5c21sLlZhbHVlItACCg9SdW5Td2VlcFJlcXVlc3QSEgoKbW9kZWxfaGFzaBgBIAEoCRIRCglzeW1ib2xfaWQYAiABKAkSGQoRc3ViamVjdF9zeW1ib2xfaWQYAyABKAkSHwoJYXJndW1lbnRzGAQgAygLMgwuc3lzbWwuVmFsdWUSQwoPbmFtZWRfYXJndW1lbnRzGAUgAygLMiouc3lzbWwuUnVuU3dlZXBSZXF1ZXN0Lk5hbWVkQXJndW1lbnRzRW50cnkSIQoGcmFuZ2VzGAYgAygLMhEuc3lzbWwuU3dlZXBSYW5nZRIPCgdzYW1wbGVzGAcgASgDEgwKBHNlZWQYCCABKAQSDgoGZW5naW5lGAkgASgJGkMKE05hbWVkQXJndW1lbnRzRW50cnkSCwoDa2V5GAEgASgJEhsKBXZhbHVlGAIgASgLMgwuc3lzbWwuVmFsdWU6AjgBIvQBCghTd2VlcFJvdxIhCgZpbnB1dHMYASADKAsyES5zeXNtbC5DYWxjT3V0cHV0EiIKB291dHB1dHMYAiADKAsyES5zeXNtbC5DYWxjT3V0cHV0EiAKCHZlcmRpY3RzGAMgAygLMg4uc3lzbWwuVmVyZGljdBIWCg5lbGFwc2VkX21pY3JvcxgEIAEoAxINCgVlcnJvchgFIAEoCRIsCg5mYWlsdXJlX3JlYXNvbhgGIAEoDjIULnN5c21sLkZhaWx1cmVSZWFzb24SKgoLZXZhbHVhdGlvbnMYByADKAsyFS5zeXNtbC5DYXNlRXZhbHVhdGlvbiKtAgoQUnVuU3dlZXBSZXNwb25zZRIdCgRyb3dzGAEgAygLMg8uc3lzbWwuU3dlZXBSb3cSEgoKcGFyYW1ldGVycxgCIAMoCRIPCgdzYW1wbGVkGAMgASgIEgwKBHNlZWQYBCABKAQSDQoFZXJyb3IYBSABKAkSJgoLZGlhZ25vc3RpY3MYBiADKAsyES5zeXNtbC5EaWFnbm9zdGljEiwKDmZhaWx1cmVfcmVhc29uGAcgASgOMhQuc3lzbWwuRmFpbHVyZVJlYXNvbhIiCglpbnN0YW5jZXMYCCADKAsyDy5zeXNtbC5JbnN0YW5jZRIOCgZlbmdpbmUYCSABKAkSEAoIc3RyZW5ndGgYCiABKAkSHAoGYm91bmRzGAsgAygLMgwuc3lzbWwuQm91bmQibgoXUnVuRG9jdW1lbnRRdWVyeVJlcXVlc3QSEgoKbW9kZWxfaGFzaBgBIAEoCRIQCghxdWVyeV9pZBgCIAEoCRItCghiaW5kaW5ncxgDIAMoCzIbLnN5c21sLkRvY3VtZW50UXVlcnlCaW5kaW5nIk8KFERvY3VtZW50UXVlcnlCaW5kaW5nEhEKCXBhcmFtZXRlchgBIAEoCRIkCgZ2YWx1ZXMYAiADKAsyFC5zeXNtbC5Eb2N1bWVudFZhbHVlIvcCCg1Eb2N1bWVudFZhbHVlEhQKCmVsZW1lbnRfaWQYASABKAlIABIWCgxzdHJpbmdfdmFsdWUYAiABKAlIABITCglpbnRfdmFsdWUYAyABKANIABIUCgpyZWFsX3ZhbHVlGAQgASgBSAASFAoKYm9vbF92YWx1ZRgFIAEoCEgAEhIKCGluZmluaXR5GAYgASgISAASIwoIcXVhbnRpdHkYCCABKAsyDy5zeXNtbC5RdWFudGl0eUgAEikKB3ZlcmRpY3QYCSABKAsyFi5zeXNtbC5Eb2N1bWVudFZlcmRpY3RIABInCgZvYmplY3QYCiABKAsyFS5zeXNtbC5Eb2N1bWVudE9iamVjdEgAEiUKBXN0YXRlGAsgASgLMhQuc3lzbWwuRG9jdW1lbnRTdGF0ZUgAEiUKBWV2ZW50GAwgASgLMhQuc3lzbWwuRG9jdW1lbnRFdmVudEgAEhQKDGVsZW1lbnRfdHlwZRgHIAEoCUIGCgRraW5kIloKDkRvY3VtZW50T2JqZWN0EhMKC2luc3RhbmNlX2lkGAEgASgDEgwKBHBhdGgYAiABKAkSJQoHZWxlbWVudBgDIAEoCzIULnN5c21sLkRvY3VtZW50VmFsdWUirgEKD0RvY3VtZW50VmVyZGljdBInCglhc3NlcnRpb24YASABKAsyFC5zeXNtbC5Eb2N1bWVudFZhbHVlEgwKBGtpbmQYAiABKAkSDAoEdGV4dBgDIAEoCRIMCgRwYXRoGAQgASgJEg8KB3ZlcmRpY3QYBSABKAkSEQoJY29uZGl0aW9uGAYgASgJEg4KBnJlYXNvbhgHIAEoCRIUCgx2ZXJpZmljYXRpb24YCCADKAkisQEKDURvY3VtZW50U3RhdGUSJQoGb2JqZWN0GAEgASgLMhUuc3lzbWwuRG9jdW1lbnRPYmplY3QSDwoHbWFjaGluZRgCIAEoCRIMCgRuYW1lGAMgASgJEhIKCnN0YXRlX3BhdGgYBCABKAkSIwoFc3RhdGUYBSABKAsyFC5zeXNtbC5Eb2N1bWVudFZhbHVlEg4KBnJlZ2lvbhgGIAEoCRIRCgllbmNsb3NpbmcYByADKAkinAIKDURvY3VtZW50RXZlbnQSDAoEa2luZBgBIAEoCRIiCgR0aW1lGAIgASgLMhQuc3lzbWwuRG9jdW1lbnRWYWx1ZRIlCgZvYmplY3QYAyABKAsyFS5zeXNtbC5Eb2N1bWVudE9iamVjdBIPCgdtYWNoaW5lGAQgASgJEg0KBXN0YXRlGAUgASgJEgwKBGZyb20YBiABKAkSCgoCdG8YByABKAkSJQoGdGFyZ2V0GAggASgLMhUuc3lzbWwuRG9jdW1lbnRPYmplY3QSDQoFZXZlbnQYCSABKAkSDwoHcGF5bG9hZBgKIAMoCRIUCgxhbHRlcm5hdGl2ZXMYCyADKAkSDQoFdGFrZW4YDCABKAkSDAoEdGV4dBgNIAEoCSIjChNEb2N1bWVudFF1ZXJ5Q29sdW1uEgwKBG5hbWUYASABKAkiOQoRRG9jdW1lbnRRdWVyeUNlbGwSJAoGdmFsdWVzGAEgAygLMhQuc3lzbWwuRG9jdW1lbnRWYWx1ZSJiChBEb2N1bWVudFF1ZXJ5Um93EiUKB2VsZW1lbnQYASABKAsyFC5zeXNtbC5Eb2N1bWVudFZhbHVlEicKBWNlbGxzGAIgAygLMhguc3lzbWwuRG9jdW1lbnRRdWVyeUNlbGwibgoYUnVuRG9jdW1lbnRRdWVyeVJlc3BvbnNlEisKB2NvbHVtbnMYASADKAsyGi5zeXNtbC5Eb2N1bWVudFF1ZXJ5Q29sdW1uEiUKBHJvd3MYAiADKAsyFy5zeXNtbC5Eb2N1bWVudFF1ZXJ5Um93Ik4KFVJlbmRlckRvY3VtZW50UmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhMKC2RvY3VtZW50X2lkGAIgASgJEgwKBGZvcm0YAyABKAkiOAoWUmVuZGVyRG9jdW1lbnRSZXNwb25zZRIQCghtYXJrZG93bhgBIAEoCRIMCgRodG1sGAIgASgJKpMBCg1GYWlsdXJlUmVhc29uEh4KGkZBSUxVUkVfUkVBU09OX1VOU1BFQ0lGSUVEEAASHQoZRkFJTFVSRV9SRUFTT05fRVZBTFVBVElPThABEh0KGUZBSUxVUkVfUkVBU09OX1dST05HX0tJTkQQAhIkCiBGQUlMVVJFX1JFQVNPTl9BTUJJR1VPVVNfU1VCSkVDVBADKowFCgtFZGl0RmFpbHVyZRIcChhFRElUX0ZBSUxVUkVfVU5TUEVDSUZJRUQQABIeChpFRElUX0ZBSUxVUkVfTk9fT1BFUkFUSU9OUxABEh8KG0VESVRfRkFJTFVSRV9VTktOT1dOX1RBUkdFVBACEiEKHUVESVRfRkFJTFVSRV9BTUJJR1VPVVNfVEFSR0VUEAMSGwoXRURJVF9GQUlMVVJFX05PVF9WQUxVRUQQBBIeChpFRElUX0ZBSUxVUkVfSU5WQUxJRF9WQUxVRRAFEh0KGUVESVRfRkFJTFVSRV9JTlZBTElEX05BTUUQBhIaChZFRElUX0ZBSUxVUkVfTk9UX05BTUVEEAcSIgoeRURJVF9GQUlMVVJFX1JFTkFNRV9SRUZFUkVOQ0VEEAgSIgoeRURJVF9GQUlMVVJFX09WRVJMQVBQSU5HX0VESVRTEAkSHwobRURJVF9GQUlMVVJFX1JFU1VMVF9JTlZBTElEEAoSHgoaRURJVF9GQUlMVVJFX09XTkVSX1VOS05PV04QCxIkCiBFRElUX0ZBSUxVUkVfT1dORVJfTk9UX05BTUVTUEFDRRAMEh0KGUVESVRfRkFJTFVSRV9JTExFR0FMX0tJTkQQDRIiCh5FRElUX0ZBSUxVUkVfTUVNQkVSX05BTUVfVEFLRU4QDhIiCh5FRElUX0ZBSUxVUkVfREVMRVRFX1JFRkVSRU5DRUQQDxIkCiBFRElUX0ZBSUxVUkVfT1dORVJfSU5TSURFX1RBUkdFVBAQEiAKHEVESVRfRkFJTFVSRV9NT1ZFX1JFRkVSRU5DRUQQERIlCiFFRElUX0ZBSUxVUkVfUkVGRVJFTkNFRF9FTFNFV0hFUkUQEiqSAQoRUHJpbWl0aXZlT3BlcmF0b3ISIgoeUFJJTUlUSVZFX09QRVJBVE9SX1VOU1BFQ0lGSUVEEAASHAoYUFJJTUlUSVZFX09QRVJBVE9SX0VRVUFMEAESHgoaUFJJTUlUSVZFX09QRVJBVE9SX0dSRUFURVIQAhIbChdQUklNSVRJVkVfT1BFUkFUT1JfTEVTUxADKm4KEUNvbXBvc2l0ZU9wZXJhdG9yEiIKHkNPTVBPU0lURV9PUEVSQVRPUl9VTlNQRUNJRklFRBAAEhoKFkNPTVBPU0lURV9PUEVSQVRPUl9BTkQQARIZChVDT01QT1NJVEVfT1BFUkFUT1JfT1IQAjK/DAoMU3lzTUxTZXJ2aWNlEkQKDUdldFNlcnZlckluZm8SGC5zeXNtbC5TZXJ2ZXJJbmZvUmVxdWVzdBoZLnN5c21sLlNlcnZlckluZm9SZXNwb25zZRI+CglQYXJzZUZpbGUSFy5zeXNtbC5QYXJzZUZpbGVSZXF1ZXN0Ghguc3lzbWwuUGFyc2VGaWxlUmVzcG9uc2USRwoMUGFyc2VTb3VyY2VzEhouc3lzbWwuUGFyc2VTb3VyY2VzUmVxdWVzdBobLnN5c21sLlBhcnNlU291cmNlc1Jlc3BvbnNlEjsKCUdldFN5bWJvbBIXLnN5c21sLkdldFN5bWJvbFJlcXVlc3QaFS5zeXNtbC5TeW1ib2xSZXNwb25zZRJHCg5HZXREaWFnbm9zdGljcxIZLnN5c21sLkRpYWdub3N0aWNzUmVxdWVzdBoaLnN5c21sLkRpYWdub3N0aWNzUmVzcG9uc2USOwoIRXZhbHVhdGUSFi5zeXNtbC5FdmFsdWF0ZVJlcXVlc3QaFy5zeXNtbC5FdmFsdWF0ZVJlc3BvbnNlEkQKC0luc3RhbnRpYXRlEhkuc3lzbWwuSW5zdGFudGlhdGVSZXF1ZXN0Ghouc3lzbWwuSW5zdGFudGlhdGVSZXNwb25zZRJKCg1FeGVjdXRlQWN0aW9uEhsuc3lzbWwuRXhlY3V0ZUFjdGlvblJlcXVlc3QaHC5zeXNtbC5FeGVjdXRlQWN0aW9uUmVzcG9uc2USRwoMRXhlY3V0ZVN0YXRlEhouc3lzbWwuRXhlY3V0ZVN0YXRlUmVxdWVzdBobLnN5c21sLkV4ZWN1dGVTdGF0ZVJlc3BvbnNlEjgKB0NvbnZlcnQSFS5zeXNtbC5Db252ZXJ0UmVxdWVzdBoWLnN5c21sLkNvbnZlcnRSZXNwb25zZRJBCgpBcHBseUVkaXRzEhguc3lzbWwuQXBwbHlFZGl0c1JlcXVlc3QaGS5zeXNtbC5BcHBseUVkaXRzUmVzcG9uc2USUwoQVmVyaWZ5Q29uc3RyYWludBIeLnN5c21sLlZlcmlmeUNvbnN0cmFpbnRSZXF1ZXN0Gh8uc3lzbWwuVmVyaWZ5Q29uc3RyYWludFJlc3BvbnNlElYKEVZlcmlmeVJlcXVpcmVtZW50Eh8uc3lzbWwuVmVyaWZ5UmVxdWlyZW1lbnRSZXF1ZXN0GiAuc3lzbWwuVmVyaWZ5UmVxdWlyZW1lbnRSZXNwb25zZRJZChJWZXJpZnlTYXRpc2ZhY3Rpb24SIC5zeXNtbC5WZXJpZnlTYXRpc2ZhY3Rpb25SZXF1ZXN0GiEuc3lzbWwuVmVyaWZ5U2F0aXNmYWN0aW9uUmVzcG9uc2USUwoQVmFsaWRhdGVJbnN0YW5jZRIeLnN5c21sLlZhbGlkYXRlSW5zdGFuY2VSZXF1ZXN0Gh8uc3lzbWwuVmFsaWRhdGVJbnN0YW5jZVJlc3BvbnNlEkcKDEV2YWx1YXRlQ2FsYxIaLnN5c21sLkV2YWx1YXRlQ2FsY1JlcXVlc3QaGy5zeXNtbC5FdmFsdWF0ZUNhbGNSZXNwb25zZRJECgtSdW5BbmFseXNpcxIZLnN5c21sLlJ1bkFuYWx5c2lzUmVxdWVzdBoaLnN5c21sLlJ1bkFuYWx5c2lzUmVzcG9uc2USOwoIUnVuU3dlZXASFi5zeXNtbC5SdW5Td2VlcFJlcXVlc3QaFy5zeXNtbC5SdW5Td2VlcFJlc3BvbnNlEkQKC0xpc3RFbmdpbmVzEhkuc3lzbWwuTGlzdEVuZ2luZXNSZXF1ZXN0Ghouc3lzbWwuTGlzdEVuZ2luZXNSZXNwb25zZRIyCgVRdWVyeRITLnN5c21sLlF1ZXJ5UmVxdWVzdBoULnN5c21sLlF1ZXJ5UmVzcG9uc2USUwoQUnVuRG9jdW1lbnRRdWVyeRIeLnN5c21sLlJ1bkRvY3VtZW50UXVlcnlSZXF1ZXN0Gh8uc3lzbWwuUnVuRG9jdW1lbnRRdWVyeVJlc3BvbnNlEk0KDlJlbmRlckRvY3VtZW50Ehwuc3lzbWwuUmVuZGVyRG9jdW1lbnRSZXF1ZXN0Gh0uc3lzbWwuUmVuZGVyRG9jdW1lbnRSZXNwb25zZUIqWihnaXRodWIuY29tL09wZW4tTUJFRS9PcGVuU3lzTUwvYXBpL3Byb3RvYgZwcm90bzM");
+
+/**
+ * Verdict is one verification's answer: whether the condition held, and, when
+ * it did not, which condition the model answered false about.
+ *
+ * @generated from message sysml.Verdict
+ */
+export type Verdict = Message<"sysml.Verdict"> & {
+  /**
+   * What was verified: "constraint", "requirement" or "satisfy"; for a check
+   * an analysis case run made, "objective" or "assertion"; for the summary
+   * of a ValidateInstance, "object".
+   *
+   * @generated from field: string kind = 1;
+   */
+  kind: string;
+
+  /**
+   * FQN of the element verified; empty for an anonymous satisfy assertion or
+   * an anonymous assertion in a case body.
+   *
+   * @generated from field: string element_id = 2;
+   */
+  elementId: string;
+
+  /**
+   * The element as a reader names it: its FQN, or, for an anonymous satisfy
+   * assertion, the assertion as written ("satisfy Range by cruise"); for an
+   * objective or assertion of a case, its name or its condition as written.
+   *
+   * @generated from field: string element = 3;
+   */
+  element: string;
+
+  /**
+   * Whether the condition holds. False with an empty `error` is the model's own
+   * answer; false with an `error` is no answer at all.
+   *
+   * @generated from field: bool holds = 4;
+   */
+  holds: boolean;
+
+  /**
+   * The condition that evaluated to false, as written, when the runtime names
+   * one. Empty when the verdict holds or the condition is not named.
+   *
+   * @generated from field: string condition = 5;
+   */
+  condition: string;
+
+  /**
+   * Instance the verdict is about, 0 when it is about declared values alone.
+   * Its feature values are in `instances`, so a client can read what made it fail.
+   *
+   * @generated from field: int64 instance_id = 6;
+   */
+  instanceId: bigint;
+
+  /**
+   * Type of `instance_id`, as an FQN, for naming the object in a report.
+   *
+   * @generated from field: string instance_type_id = 7;
+   */
+  instanceTypeId: string;
+
+  /**
+   * Set when evaluation failed rather than the model answering false: unbound
+   * features, incommensurable units, an exhausted step budget. `holds` is then
+   * false but is not a verdict.
+   *
+   * @generated from field: string error = 8;
+   */
+  error: string;
+
+  /**
+   * What kind of failure `error` reports. WRONG_KIND says the symbol named
+   * declares something else, which is a wrong request rather than an undecided
+   * verdict about the model.
+   *
+   * @generated from field: sysml.FailureReason failure_reason = 9;
+   */
+  failureReason: FailureReason;
+
+  /**
+   * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
+   * associates it with the verification_verdicts reported for that requirement.
+   * Empty for every other kind, and for a requirement no FQN names.
+   *
+   * @generated from field: string requirement_id = 10;
+   */
+  requirementId: string;
+
+  /**
+   * The engine whose answer this verdict is, as ListEngines names it; empty
+   * for a verdict decided before any engine was asked. Reported as the
+   * "engines" capability, with `strength` and `bounds`.
+   *
+   * @generated from field: string engine = 11;
+   */
+  engine: string;
+
+  /**
+   * The strength of the evidence behind the verdict: "observed" for one run,
+   * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
+   * search within the bounds, "proved" for every case, "not covered" when no
+   * engine decided it.
+   *
+   * @generated from field: string strength = 12;
+   */
+  strength: string;
+
+  /**
+   * The bounds the engine ran under, each marked when it stopped the run.
+   *
+   * @generated from field: repeated sysml.Bound bounds = 13;
+   */
+  bounds: Bound[];
+
+  /**
+   * For a verdict ValidateInstance reports: the path from the validated object
+   * to the object this verdict is about, as the REPL spells it ("engine",
+   * "wheels[2]", "engine.injector"). Empty for the validated object itself,
+   * and for every other RPC.
+   *
+   * @generated from field: string instance_path = 14;
+   */
+  instancePath: string;
+};
+
+/**
+ * Describes the message sysml.Verdict.
+ * Use `create(VerdictSchema)` to create a new message.
+ */
+export const VerdictSchema: GenMessage<Verdict> = /*@__PURE__*/
+  messageDesc(file_sysml, 0);
+
+/**
+ * Bound is one limit an engine ran under, as its plan names it: a count, or
+ * milliseconds for a time, and whether the run stopped at it.
+ *
+ * @generated from message sysml.Bound
+ */
+export type Bound = Message<"sysml.Bound"> & {
+  /**
+   * The bound's name as the budget spells it: "runs", "depth", "steps",
+   * "elements", "solver", "configurations".
+   *
+   * @generated from field: string name = 1;
+   */
+  name: string;
+
+  /**
+   * The value the run was held to.
+   *
+   * @generated from field: int64 limit = 2;
+   */
+  limit: bigint;
+
+  /**
+   * True when the run stopped at the limit rather than finishing within it,
+   * which is what lowers a verdict's strength.
+   *
+   * @generated from field: bool reached = 3;
+   */
+  reached: boolean;
+};
+
+/**
+ * Describes the message sysml.Bound.
+ * Use `create(BoundSchema)` to create a new message.
+ */
+export const BoundSchema: GenMessage<Bound> = /*@__PURE__*/
+  messageDesc(file_sysml, 1);
+
+/**
+ * VerifyConstraintRequest asks whether a constraint holds, as %constraint does.
+ *
+ * @generated from message sysml.VerifyConstraintRequest
+ */
+export type VerifyConstraintRequest = Message<"sysml.VerifyConstraintRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * FQN of the constraint definition or usage to evaluate.
+   *
+   * @generated from field: string symbol_id = 2;
+   */
+  symbolId: string;
+
+  /**
+   * Optional FQN of a part/usage to instantiate and evaluate the constraint
+   * against, so the verdict is about concrete values rather than declared
+   * defaults. The prompt evaluates against the object a %instantiate created;
+   * a call carries no session, so it names the subject instead. A path from a
+   * declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+   * evaluates against the object the path reaches, inside the assembly built
+   * around it; an index picks from a multi-valued usage, `convoy.escorts[2]`.
+   *
+   * @generated from field: string subject_symbol_id = 3;
+   */
+  subjectSymbolId: string;
+
+  /**
+   * The engine the question is put to, as ListEngines names it, "auto" for the
+   * strongest covering one, or "all" for every covering one. Unset is "auto".
+   * An unknown name is INVALID_ARGUMENT. Reported as the "engines" capability.
+   *
+   * @generated from field: string engine = 4;
+   */
+  engine: string;
+};
+
+/**
+ * Describes the message sysml.VerifyConstraintRequest.
+ * Use `create(VerifyConstraintRequestSchema)` to create a new message.
+ */
+export const VerifyConstraintRequestSchema: GenMessage<VerifyConstraintRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 2);
+
+/**
+ * VerifyConstraintResponse carries the verdict; its engine, strength and bounds
+ * are on the verdict.
+ *
+ * @generated from message sysml.VerifyConstraintResponse
+ */
+export type VerifyConstraintResponse = Message<"sysml.VerifyConstraintResponse"> & {
+  /**
+   * @generated from field: sysml.Verdict verdict = 1;
+   */
+  verdict?: Verdict | undefined;
+
+  /**
+   * Instances reachable from the verdict's subject, including it, so its feature
+   * values need no follow-up RPC. Empty when the verdict names no instance.
+   *
+   * @generated from field: repeated sysml.Instance instances = 2;
+   */
+  instances: Instance[];
+
+  /**
+   * Set when the request could not be answered at all — an unknown symbol, a
+   * subject that could not be built. `verdict` is then unset.
+   *
+   * @generated from field: string error = 3;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 4;
+   */
+  diagnostics: Diagnostic[];
+};
+
+/**
+ * Describes the message sysml.VerifyConstraintResponse.
+ * Use `create(VerifyConstraintResponseSchema)` to create a new message.
+ */
+export const VerifyConstraintResponseSchema: GenMessage<VerifyConstraintResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 3);
+
+/**
+ * VerifyRequirementRequest asks whether a requirement is satisfied, as
+ * %requirement does.
+ *
+ * @generated from message sysml.VerifyRequirementRequest
+ */
+export type VerifyRequirementRequest = Message<"sysml.VerifyRequirementRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * FQN of the requirement definition or usage to evaluate.
+   *
+   * @generated from field: string symbol_id = 2;
+   */
+  symbolId: string;
+
+  /**
+   * Optional FQN of a part/usage to instantiate and evaluate against, or a path
+   * from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
+   *
+   * @generated from field: string subject_symbol_id = 3;
+   */
+  subjectSymbolId: string;
+
+  /**
+   * The engine the question is put to; see VerifyConstraintRequest.engine.
+   *
+   * @generated from field: string engine = 4;
+   */
+  engine: string;
+};
+
+/**
+ * Describes the message sysml.VerifyRequirementRequest.
+ * Use `create(VerifyRequirementRequestSchema)` to create a new message.
+ */
+export const VerifyRequirementRequestSchema: GenMessage<VerifyRequirementRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 4);
+
+/**
+ * VerificationVerdict is what the body of a verification case answered when it
+ * ran: the VerdictKind its return bound, which is a separate answer from whether
+ * a requirement is satisfied. Reported as the "verification_verdicts" capability.
+ *
+ * @generated from message sysml.VerificationVerdict
+ */
+export type VerificationVerdict = Message<"sysml.VerificationVerdict"> & {
+  /**
+   * FQN of the verification case definition or usage that ran.
+   *
+   * @generated from field: string case_id = 1;
+   */
+  caseId: string;
+
+  /**
+   * The VerdictKind the body produced: "pass", "fail", "inconclusive" or "error".
+   *
+   * @generated from field: string kind = 2;
+   */
+  kind: string;
+
+  /**
+   * Why the body decided nothing, or the text of the error that stopped its run.
+   * Empty for a pass or a fail, which are the case's own answers.
+   *
+   * @generated from field: string detail = 3;
+   */
+  detail: string;
+
+  /**
+   * Set for the verdict of a subcase the case performed, which the library
+   * states no roll-up for and which is therefore reported on its own.
+   *
+   * @generated from field: bool subcase = 4;
+   */
+  subcase: boolean;
+
+  /**
+   * FQN of the requirement this verdict was reported for: the one the case's
+   * objective verifies. Empty when the case was run for itself rather than for
+   * a requirement, or when that requirement can be named by no FQN.
+   *
+   * @generated from field: string requirement_id = 5;
+   */
+  requirementId: string;
+};
+
+/**
+ * Describes the message sysml.VerificationVerdict.
+ * Use `create(VerificationVerdictSchema)` to create a new message.
+ */
+export const VerificationVerdictSchema: GenMessage<VerificationVerdict> = /*@__PURE__*/
+  messageDesc(file_sysml, 5);
+
+/**
+ * VerifyRequirementResponse carries the verdict.
+ *
+ * @generated from message sysml.VerifyRequirementResponse
+ */
+export type VerifyRequirementResponse = Message<"sysml.VerifyRequirementResponse"> & {
+  /**
+   * @generated from field: sysml.Verdict verdict = 1;
+   */
+  verdict?: Verdict | undefined;
+
+  /**
+   * @generated from field: repeated sysml.Instance instances = 2;
+   */
+  instances: Instance[];
+
+  /**
+   * @generated from field: string error = 3;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 4;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * What the body of every verification case verifying this requirement
+   * answered, beside the satisfaction verdict rather than instead of it.
+   *
+   * @generated from field: repeated sysml.VerificationVerdict verification_verdicts = 5;
+   */
+  verificationVerdicts: VerificationVerdict[];
+};
+
+/**
+ * Describes the message sysml.VerifyRequirementResponse.
+ * Use `create(VerifyRequirementResponseSchema)` to create a new message.
+ */
+export const VerifyRequirementResponseSchema: GenMessage<VerifyRequirementResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 6);
+
+/**
+ * VerifySatisfactionRequest asks whether the satisfaction assertions a model
+ * states hold, as %satisfy does. Each is evaluated against an object of its
+ * subject, built for the call.
+ *
+ * @generated from message sysml.VerifySatisfactionRequest
+ */
+export type VerifySatisfactionRequest = Message<"sysml.VerifySatisfactionRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * Optional FQN limiting evaluation: the assertions stated within that
+   * element, or that element itself when it is a named satisfy assertion. Empty
+   * evaluates every assertion the model states.
+   *
+   * @generated from field: string symbol_id = 2;
+   */
+  symbolId: string;
+
+  /**
+   * The engine every assertion is put to; see VerifyConstraintRequest.engine.
+   *
+   * @generated from field: string engine = 3;
+   */
+  engine: string;
+};
+
+/**
+ * Describes the message sysml.VerifySatisfactionRequest.
+ * Use `create(VerifySatisfactionRequestSchema)` to create a new message.
+ */
+export const VerifySatisfactionRequestSchema: GenMessage<VerifySatisfactionRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 7);
+
+/**
+ * VerifySatisfactionResponse carries one verdict per assertion evaluated, in
+ * declaration order. No assertion at all is an empty list, not an error.
+ *
+ * @generated from message sysml.VerifySatisfactionResponse
+ */
+export type VerifySatisfactionResponse = Message<"sysml.VerifySatisfactionResponse"> & {
+  /**
+   * @generated from field: repeated sysml.Verdict verdicts = 1;
+   */
+  verdicts: Verdict[];
+
+  /**
+   * @generated from field: repeated sysml.Instance instances = 2;
+   */
+  instances: Instance[];
+
+  /**
+   * @generated from field: string error = 3;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 4;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * What kind of failure `error` reports.
+   *
+   * @generated from field: sysml.FailureReason failure_reason = 5;
+   */
+  failureReason: FailureReason;
+
+  /**
+   * What the body of every verification case verifying a requirement asserted
+   * as satisfied answered, in the order the assertions were evaluated. Each
+   * names the requirement it was reported for, which the verdict of an
+   * assertion of that requirement carries as its own requirement_id.
+   *
+   * @generated from field: repeated sysml.VerificationVerdict verification_verdicts = 6;
+   */
+  verificationVerdicts: VerificationVerdict[];
+};
+
+/**
+ * Describes the message sysml.VerifySatisfactionResponse.
+ * Use `create(VerifySatisfactionResponseSchema)` to create a new message.
+ */
+export const VerifySatisfactionResponseSchema: GenMessage<VerifySatisfactionResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 8);
+
+/**
+ * ValidateInstanceRequest asks for every assertion about an object of a part,
+ * as %validate does: the object is built for the call.
+ *
+ * @generated from message sysml.ValidateInstanceRequest
+ */
+export type ValidateInstanceRequest = Message<"sysml.ValidateInstanceRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * FQN of the part or usage an object of which is validated.
+   *
+   * @generated from field: string symbol_id = 2;
+   */
+  symbolId: string;
+
+  /**
+   * The engine the validation is put to; see VerifyConstraintRequest.engine.
+   *
+   * @generated from field: string engine = 3;
+   */
+  engine: string;
+};
+
+/**
+ * Describes the message sysml.ValidateInstanceRequest.
+ * Use `create(ValidateInstanceRequestSchema)` to create a new message.
+ */
+export const ValidateInstanceRequestSchema: GenMessage<ValidateInstanceRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 9);
+
+/**
+ * ValidateInstanceResponse carries one verdict per assertion, in the order the
+ * objects were reached from the validated one, and the verdict about the object
+ * as a whole. An object no assertion is about has an empty list and a summary
+ * that decides nothing.
+ *
+ * @generated from message sysml.ValidateInstanceResponse
+ */
+export type ValidateInstanceResponse = Message<"sysml.ValidateInstanceResponse"> & {
+  /**
+   * One per assertion: kind "constraint", "requirement" or "satisfy", about the
+   * object `instance_id` names, reached along `instance_path`.
+   *
+   * @generated from field: repeated sysml.Verdict verdicts = 1;
+   */
+  verdicts: Verdict[];
+
+  /**
+   * The object as a whole, kind "object": holds when every assertion holds and
+   * every held object was reached. With `error` empty, false is the model's
+   * answer, some assertion failing; with `error` set, nothing was decided, an
+   * assertion being undecided or nesting left unreached.
+   *
+   * @generated from field: sysml.Verdict summary = 2;
+   */
+  summary?: Verdict | undefined;
+
+  /**
+   * Every object reached, the validated one first, so a client can read the
+   * feature values behind each verdict.
+   *
+   * @generated from field: repeated sysml.Instance instances = 3;
+   */
+  instances: Instance[];
+
+  /**
+   * @generated from field: string error = 4;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 5;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * What kind of failure `error` reports.
+   *
+   * @generated from field: sysml.FailureReason failure_reason = 6;
+   */
+  failureReason: FailureReason;
+
+  /**
+   * What the body of every verification case verifying a requirement a verdict
+   * is about answered, once per requirement; each names its requirement, which
+   * the requirement and satisfy verdicts carry as their requirement_id.
+   *
+   * @generated from field: repeated sysml.VerificationVerdict verification_verdicts = 7;
+   */
+  verificationVerdicts: VerificationVerdict[];
+
+  /**
+   * True when nesting deeper than the validation descends, or past its budget,
+   * was left unvalidated; the summary then decides nothing.
+   *
+   * @generated from field: bool bounded = 8;
+   */
+  bounded: boolean;
+};
+
+/**
+ * Describes the message sysml.ValidateInstanceResponse.
+ * Use `create(ValidateInstanceResponseSchema)` to create a new message.
+ */
+export const ValidateInstanceResponseSchema: GenMessage<ValidateInstanceResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 10);
+
+/**
+ * EvaluateCalcRequest invokes a calculation, as %calc does. Arguments are bound
+ * positionally; a calc usage named with no arguments binds its inputs from its
+ * own members and reports every output feature it computes (SysML 7.17).
+ *
+ * @generated from message sysml.EvaluateCalcRequest
+ */
+export type EvaluateCalcRequest = Message<"sysml.EvaluateCalcRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * FQN of the calc definition or usage.
+   *
+   * @generated from field: string symbol_id = 2;
+   */
+  symbolId: string;
+
+  /**
+   * Positional arguments. The notation writes named arguments inside an
+   * invocation's parentheses, which this request does not carry.
+   *
+   * @generated from field: repeated sysml.Value arguments = 3;
+   */
+  arguments: Value[];
+
+  /**
+   * The engine the calculation is put to; see VerifyConstraintRequest.engine.
+   *
+   * @generated from field: string engine = 4;
+   */
+  engine: string;
+};
+
+/**
+ * Describes the message sysml.EvaluateCalcRequest.
+ * Use `create(EvaluateCalcRequestSchema)` to create a new message.
+ */
+export const EvaluateCalcRequestSchema: GenMessage<EvaluateCalcRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 11);
+
+/**
+ * EvaluateCalcResponse carries what the calculation computed.
+ *
+ * @generated from message sysml.EvaluateCalcResponse
+ */
+export type EvaluateCalcResponse = Message<"sysml.EvaluateCalcResponse"> & {
+  /**
+   * The value an invocation returned; unset when `outputs` carries the answer.
+   *
+   * @generated from field: sysml.Value result = 1;
+   */
+  result?: Value | undefined;
+
+  /**
+   * Output features of a calc usage evaluated from its own members, in
+   * declaration order. Empty for an invocation with arguments.
+   *
+   * @generated from field: repeated sysml.CalcOutput outputs = 2;
+   */
+  outputs: CalcOutput[];
+
+  /**
+   * @generated from field: string error = 3;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 4;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * What kind of failure `error` reports.
+   *
+   * @generated from field: sysml.FailureReason failure_reason = 5;
+   */
+  failureReason: FailureReason;
+
+  /**
+   * The engine that computed the answer, the strength of its evidence and the
+   * bounds it ran under, as Verdict carries them. Empty when the request was
+   * answered before any engine was asked. Reported as the "engines" capability.
+   *
+   * @generated from field: string engine = 6;
+   */
+  engine: string;
+
+  /**
+   * @generated from field: string strength = 7;
+   */
+  strength: string;
+
+  /**
+   * @generated from field: repeated sysml.Bound bounds = 8;
+   */
+  bounds: Bound[];
+};
+
+/**
+ * Describes the message sysml.EvaluateCalcResponse.
+ * Use `create(EvaluateCalcResponseSchema)` to create a new message.
+ */
+export const EvaluateCalcResponseSchema: GenMessage<EvaluateCalcResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 12);
+
+/**
+ * CalcOutput is one output feature a calc usage computed.
+ *
+ * @generated from message sysml.CalcOutput
+ */
+export type CalcOutput = Message<"sysml.CalcOutput"> & {
+  /**
+   * @generated from field: string name = 1;
+   */
+  name: string;
+
+  /**
+   * @generated from field: sysml.Value value = 2;
+   */
+  value?: Value | undefined;
+};
+
+/**
+ * Describes the message sysml.CalcOutput.
+ * Use `create(CalcOutputSchema)` to create a new message.
+ */
+export const CalcOutputSchema: GenMessage<CalcOutput> = /*@__PURE__*/
+  messageDesc(file_sysml, 13);
+
+/**
+ * CaseEvaluation is one application an analysis case made of one of its own
+ * calcs held as a function value: a trade study's evaluationFunction applied
+ * to one alternative. Reported as the "case_evaluations" capability.
+ *
+ * @generated from message sysml.CaseEvaluation
+ */
+export type CaseEvaluation = Message<"sysml.CaseEvaluation"> & {
+  /**
+   * FQN of the calc applied.
+   *
+   * @generated from field: string function_id = 1;
+   */
+  functionId: string;
+
+  /**
+   * What it was applied to, in parameter order; an alternative is an instance_id.
+   *
+   * @generated from field: repeated sysml.Value arguments = 2;
+   */
+  arguments: Value[];
+
+  /**
+   * What it computed; unset when `error` says why it computed nothing.
+   *
+   * @generated from field: sysml.Value result = 3;
+   */
+  result?: Value | undefined;
+
+  /**
+   * @generated from field: string error = 4;
+   */
+  error: string;
+
+  /**
+   * Set on the evaluation whose argument `selectOne` picked and the case
+   * returned: the alternative a trade study selected. A result that merely
+   * equals an argument selects nothing.
+   *
+   * @generated from field: bool selected = 5;
+   */
+  selected: boolean;
+
+  /**
+   * Set on an evaluation computing what the selected one did without being
+   * selected: an alternative `selectOne` passed over for an earlier one
+   * evaluating alike.
+   *
+   * @generated from field: bool tied = 6;
+   */
+  tied: boolean;
+};
+
+/**
+ * Describes the message sysml.CaseEvaluation.
+ * Use `create(CaseEvaluationSchema)` to create a new message.
+ */
+export const CaseEvaluationSchema: GenMessage<CaseEvaluation> = /*@__PURE__*/
+  messageDesc(file_sysml, 14);
+
+/**
+ * RunAnalysisRequest runs an analysis case, as %analysis does: its subject and
+ * input parameters are bound from the request and from the case's own
+ * declarations, its body runs, and every output it declares is reported with
+ * the verdict of its objective and of each assertion in its body (SysML 7.22).
+ *
+ * @generated from message sysml.RunAnalysisRequest
+ */
+export type RunAnalysisRequest = Message<"sysml.RunAnalysisRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * FQN of the analysis case definition or usage.
+   *
+   * @generated from field: string symbol_id = 2;
+   */
+  symbolId: string;
+
+  /**
+   * Optional FQN of a part/usage to instantiate as the case's subject. Empty
+   * leaves the case's own `subject s = ...` binding to supply it; a case that
+   * binds none and is given none fails to run. A path from a declaration, as
+   * `Mission::mission.vehicle`, makes the subject the object the path reaches in
+   * an object of the declaration built for the run — every explored run its own
+   * — so what the assembly binds and connects on it is in force.
+   *
+   * @generated from field: string subject_symbol_id = 3;
+   */
+  subjectSymbolId: string;
+
+  /**
+   * Positional arguments for the case's input parameters, in declaration order;
+   * the subject is never among them.
+   *
+   * @generated from field: repeated sysml.Value arguments = 4;
+   */
+  arguments: Value[];
+
+  /**
+   * Arguments bound to input parameters by name.
+   *
+   * @generated from field: map<string, sysml.Value> named_arguments = 5;
+   */
+  namedArguments: { [key: string]: Value };
+
+  /**
+   * Scheduling policy the run resolves its choice points under: "declared",
+   * "reverse", "seed:<n>" or "explore[:runs=<n>,depth=<d>]". Empty is the
+   * default, "reverse"; any other spelling is INVALID_ARGUMENT. Under explore
+   * the response answers with `outcomes` and `exploration` in place of one run's
+   * outputs, verdicts, instances and error (see ExecuteActionResponse).
+   *
+   * @generated from field: string schedule = 6;
+   */
+  schedule: string;
+
+  /**
+   * The engine the case is put to; see VerifyConstraintRequest.engine. "explore"
+   * asks what "explore" as the schedule asks, and the response answers alike.
+   *
+   * @generated from field: string engine = 7;
+   */
+  engine: string;
+};
+
+/**
+ * Describes the message sysml.RunAnalysisRequest.
+ * Use `create(RunAnalysisRequestSchema)` to create a new message.
+ */
+export const RunAnalysisRequestSchema: GenMessage<RunAnalysisRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 15);
+
+/**
+ * RunAnalysisResponse carries what the case computed and decided.
+ *
+ * @generated from message sysml.RunAnalysisResponse
+ */
+export type RunAnalysisResponse = Message<"sysml.RunAnalysisResponse"> & {
+  /**
+   * The case's out and return parameters in declaration order; a value the
+   * body returned into an unnamed result is named "result".
+   *
+   * @generated from field: repeated sysml.CalcOutput outputs = 1;
+   */
+  outputs: CalcOutput[];
+
+  /**
+   * What the case's objective and each assertion in its body decided, the
+   * objective first: kind "objective" or "assertion", holds for satisfied, the
+   * violated condition for not satisfied, and an error for undecided.
+   *
+   * @generated from field: repeated sysml.Verdict verdicts = 2;
+   */
+  verdicts: Verdict[];
+
+  /**
+   * Instances reachable from the subject the case ran on, including it, so its
+   * feature values need no follow-up RPC. Empty when the run named no object.
+   *
+   * @generated from field: repeated sysml.Instance instances = 3;
+   */
+  instances: Instance[];
+
+  /**
+   * Set when the case could not be run — an unknown symbol, a subject that
+   * could not be built or bound, an input with no value, a failed step. The
+   * outputs computed and the evaluations made before the failure are still
+   * reported beside it, each verdict undecided.
+   *
+   * @generated from field: string error = 4;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 5;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * What kind of failure `error` reports.
+   *
+   * @generated from field: sysml.FailureReason failure_reason = 6;
+   */
+  failureReason: FailureReason;
+
+  /**
+   * What the body answered when the case run was a verification case: its own
+   * verdict first, then the verdict of each subcase it performed.
+   *
+   * @generated from field: repeated sysml.VerificationVerdict verification_verdicts = 7;
+   */
+  verificationVerdicts: VerificationVerdict[];
+
+  /**
+   * Set only under an explore schedule: every distinct outcome reached, in
+   * canonical order, and how the exploration ended. An outcome's outputs carry
+   * the case's outputs and, named "objective <name>", "assertion <name>" and
+   * "verdict <case>", its verdicts as strings; `outputs`, `verdicts`, `instances`
+   * and `error` are then empty, a failed run being an outcome of its own.
+   *
+   * @generated from field: repeated sysml.Outcome outcomes = 8;
+   */
+  outcomes: Outcome[];
+
+  /**
+   * @generated from field: sysml.ExplorationStatus exploration = 9;
+   */
+  exploration?: ExplorationStatus | undefined;
+
+  /**
+   * Each application the run made of one of the case's own calcs as a function
+   * value, in the order first made: a trade study's evaluation of each
+   * alternative in subject order, the selected one marked.
+   *
+   * @generated from field: repeated sysml.CaseEvaluation evaluations = 10;
+   */
+  evaluations: CaseEvaluation[];
+
+  /**
+   * The engine that answered, the strength of its evidence and the bounds it ran
+   * under, as Verdict carries them; each verdict in `verdicts` carries the same.
+   * Empty when the request was answered before any engine was asked. Reported
+   * as the "engines" capability.
+   *
+   * @generated from field: string engine = 11;
+   */
+  engine: string;
+
+  /**
+   * @generated from field: string strength = 12;
+   */
+  strength: string;
+
+  /**
+   * @generated from field: repeated sysml.Bound bounds = 13;
+   */
+  bounds: Bound[];
+};
+
+/**
+ * Describes the message sysml.RunAnalysisResponse.
+ * Use `create(RunAnalysisResponseSchema)` to create a new message.
+ */
+export const RunAnalysisResponseSchema: GenMessage<RunAnalysisResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 16);
+
+/**
+ * Outcome is one distinct outcome an exploration reached: the observables a
+ * conformance case compares, how many linearizations reached it, and the choice
+ * sequence of one run that did. Two runs agreeing on their observables are one
+ * outcome.
+ *
+ * @generated from message sysml.Outcome
+ */
+export type Outcome = Message<"sysml.Outcome"> & {
+  /**
+   * The values the run ended with: an action's outputs, a state machine's final
+   * context, or an analysis case's outputs and verdicts. Empty for a failed run.
+   *
+   * @generated from field: map<string, sysml.Value> outputs = 1;
+   */
+  outputs: { [key: string]: Value };
+
+  /**
+   * The state a machine rests in and the states it entered, in order; both
+   * empty for an action or a case.
+   *
+   * @generated from field: string final_state = 2;
+   */
+  finalState: string;
+
+  /**
+   * @generated from field: repeated string states_visited = 3;
+   */
+  statesVisited: string[];
+
+  /**
+   * What the runs reaching this outcome failed with; empty for one they completed.
+   *
+   * @generated from field: string error = 4;
+   */
+  error: string;
+
+  /**
+   * How many linearizations within the budget reached this outcome.
+   *
+   * @generated from field: int32 linearizations = 5;
+   */
+  linearizations: number;
+
+  /**
+   * One run's choice sequence in run order, one entry per choice point it
+   * resolved, each spelling the alternatives and the one taken. Empty when the
+   * run faced no choice point.
+   *
+   * @generated from field: repeated string witness = 6;
+   */
+  witness: string[];
+
+  /**
+   * What the witness run noted about itself: its choice points and the guards
+   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
+   * one run.
+   *
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 7;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * The probability of the linearizations reaching this outcome, as explore
+   * computes it; a lower bound when the exploration is incomplete.
+   *
+   * @generated from field: double probability = 8;
+   */
+  probability: number;
+};
+
+/**
+ * Describes the message sysml.Outcome.
+ * Use `create(OutcomeSchema)` to create a new message.
+ */
+export const OutcomeSchema: GenMessage<Outcome> = /*@__PURE__*/
+  messageDesc(file_sysml, 17);
+
+/**
+ * ExplorationStatus is how an exploration ended: whether every linearization
+ * within the budget was run, and which budget stopped it when not.
+ *
+ * @generated from message sysml.ExplorationStatus
+ */
+export type ExplorationStatus = Message<"sysml.ExplorationStatus"> & {
+  /**
+   * True when every linearization was run, so `outcomes` is the whole set.
+   *
+   * @generated from field: bool complete = 1;
+   */
+  complete: boolean;
+
+  /**
+   * How many runs were made.
+   *
+   * @generated from field: int32 runs = 2;
+   */
+  runs: number;
+
+  /**
+   * The budgets hit, "runs" before "depth"; empty when complete.
+   *
+   * @generated from field: repeated string budgets_hit = 3;
+   */
+  budgetsHit: string[];
+
+  /**
+   * The budget the exploration ran under: runs it may make, and choice points
+   * one run may resolve before the rest take their first alternative.
+   *
+   * @generated from field: int32 runs_budget = 4;
+   */
+  runsBudget: number;
+
+  /**
+   * @generated from field: int32 depth_budget = 5;
+   */
+  depthBudget: number;
+
+  /**
+   * True when the outcomes' probabilities are lower bounds: a budget kept some
+   * linearizations unexplored.
+   *
+   * @generated from field: bool probabilities_lower_bound = 6;
+   */
+  probabilitiesLowerBound: boolean;
+};
+
+/**
+ * Describes the message sysml.ExplorationStatus.
+ * Use `create(ExplorationStatusSchema)` to create a new message.
+ */
+export const ExplorationStatusSchema: GenMessage<ExplorationStatus> = /*@__PURE__*/
+  messageDesc(file_sysml, 18);
+
+/**
+ * ListEnginesRequest asks for the analysis engines registered in this build.
+ *
+ * @generated from message sysml.ListEnginesRequest
+ */
+export type ListEnginesRequest = Message<"sysml.ListEnginesRequest"> & {
+};
+
+/**
+ * Describes the message sysml.ListEnginesRequest.
+ * Use `create(ListEnginesRequestSchema)` to create a new message.
+ */
+export const ListEnginesRequestSchema: GenMessage<ListEnginesRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 19);
+
+/**
+ * EngineInfo is one analysis engine: what it answers, how strongly it can, and
+ * whether it can run here.
+ *
+ * @generated from message sysml.EngineInfo
+ */
+export type EngineInfo = Message<"sysml.EngineInfo"> & {
+  /**
+   * The engine's name, as a request's `engine` field selects it.
+   *
+   * @generated from field: string name = 1;
+   */
+  name: string;
+
+  /**
+   * The strongest evidence the engine can ever produce, spelled as
+   * Verdict.strength is.
+   *
+   * @generated from field: string authority = 2;
+   */
+  authority: string;
+
+  /**
+   * The kinds of question the engine answers: "evaluate", "outcomes",
+   * "holds", "sensitive", "satisfiable", "sweep", "compute".
+   *
+   * @generated from field: repeated string answers = 3;
+   */
+  answers: string[];
+
+  /**
+   * The bounds the engine takes, in the order it reports them.
+   *
+   * @generated from field: repeated string bounds = 4;
+   */
+  bounds: string[];
+
+  /**
+   * The external process the engine needs, empty for one running in-process;
+   * `process_found` says where it was found when it was.
+   *
+   * @generated from field: string process = 5;
+   */
+  process: string;
+
+  /**
+   * @generated from field: string process_found = 6;
+   */
+  processFound: string;
+
+  /**
+   * True when the engine can run: it is served, and it needs no process or its
+   * process was found.
+   *
+   * @generated from field: bool ready = 7;
+   */
+  ready: boolean;
+
+  /**
+   * Why the engine cannot run, empty when it can.
+   *
+   * @generated from field: string unavailable = 8;
+   */
+  unavailable: string;
+
+  /**
+   * Where the engine comes from: "built-in" for the build's own, else the kind
+   * of the manifest entry that registered it: "tool", "engine", "policy" or
+   * "sampler". The fields below are empty for a built-in engine.
+   *
+   * @generated from field: string kind = 9;
+   */
+  kind: string;
+
+  /**
+   * How the engine is spoken to: "-" for one built in, "object" for a tool's
+   * one JSON object each way, "<transport>/<protocol>" for an engine entry.
+   *
+   * @generated from field: string protocol = 10;
+   */
+  protocol: string;
+
+  /**
+   * The manifest entry the engine was registered from, the command it resolved
+   * to and the version the entry declares.
+   *
+   * @generated from field: string source = 11;
+   */
+  source: string;
+
+  /**
+   * @generated from field: string command = 12;
+   */
+  command: string;
+
+  /**
+   * @generated from field: string version = 13;
+   */
+  version: string;
+
+  /**
+   * True when this service runs the engine for a request that reaches it. A
+   * manifest engine is listed but not served until the service is started with
+   * -serve-external-engines naming it; a request naming one that is not served
+   * is FAILED_PRECONDITION.
+   *
+   * @generated from field: bool served = 14;
+   */
+  served: boolean;
+};
+
+/**
+ * Describes the message sysml.EngineInfo.
+ * Use `create(EngineInfoSchema)` to create a new message.
+ */
+export const EngineInfoSchema: GenMessage<EngineInfo> = /*@__PURE__*/
+  messageDesc(file_sysml, 20);
+
+/**
+ * ListEnginesResponse lists the engines in name order.
+ *
+ * @generated from message sysml.ListEnginesResponse
+ */
+export type ListEnginesResponse = Message<"sysml.ListEnginesResponse"> & {
+  /**
+   * @generated from field: repeated sysml.EngineInfo engines = 1;
+   */
+  engines: EngineInfo[];
+};
+
+/**
+ * Describes the message sysml.ListEnginesResponse.
+ * Use `create(ListEnginesResponseSchema)` to create a new message.
+ */
+export const ListEnginesResponseSchema: GenMessage<ListEnginesResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 21);
+
+/**
+ * ParseFileRequest specifies the source to parse
+ *
+ * @generated from message sysml.ParseFileRequest
+ */
+export type ParseFileRequest = Message<"sysml.ParseFileRequest"> & {
+  /**
+   * @generated from oneof sysml.ParseFileRequest.source
+   */
+  source: {
+    /**
+     * @generated from field: string file_path = 1;
+     */
+    value: string;
+    case: "filePath";
+  } | {
+    /**
+     * @generated from field: string content = 2;
+     */
+    value: string;
+    case: "content";
+  } | { case: undefined; value?: undefined };
+
+  /**
+   * Ignored: the service keys its cache by the source it read, so a repeated
+   * parse hits the cache whether or not a client sends this.
+   *
+   * @generated from field: string content_hash = 3 [deprecated = true];
+   * @deprecated
+   */
+  contentHash: string;
+
+  /**
+   * Language for inline content: "sysml" or "kerml". Empty defaults to SysML.
+   *
+   * @generated from field: string language = 4;
+   */
+  language: string;
+
+  /**
+   * Judge the source as conforming SysML v2: notation no pinned production
+   * admits is an error rather than a warning. Part of the cache key, so the two
+   * modes never serve each other's diagnostics.
+   *
+   * @generated from field: bool strict_conformance = 5;
+   */
+  strictConformance: boolean;
+};
+
+/**
+ * Describes the message sysml.ParseFileRequest.
+ * Use `create(ParseFileRequestSchema)` to create a new message.
+ */
+export const ParseFileRequestSchema: GenMessage<ParseFileRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 22);
+
+/**
+ * SourceDocument is one document of a multi-document parse.
+ *
+ * @generated from message sysml.SourceDocument
+ */
+export type SourceDocument = Message<"sysml.SourceDocument"> & {
+  /**
+   * @generated from oneof sysml.SourceDocument.source
+   */
+  source: {
+    /**
+     * @generated from field: string file_path = 1;
+     */
+    value: string;
+    case: "filePath";
+  } | {
+    /**
+     * @generated from field: string content = 2;
+     */
+    value: string;
+    case: "content";
+  } | { case: undefined; value?: undefined };
+
+  /**
+   * Language for inline content: "sysml" or "kerml". Empty defaults to SysML,
+   * and is ignored for a file_path, whose extension says which language it is.
+   *
+   * @generated from field: string language = 3;
+   */
+  language: string;
+
+  /**
+   * Name to report inline content by in diagnostics, and the name the document
+   * is indexed under. Empty names the document by its position in the request,
+   * and is ignored for a file_path, which is named by its path.
+   *
+   * @generated from field: string name = 4;
+   */
+  name: string;
+};
+
+/**
+ * Describes the message sysml.SourceDocument.
+ * Use `create(SourceDocumentSchema)` to create a new message.
+ */
+export const SourceDocumentSchema: GenMessage<SourceDocument> = /*@__PURE__*/
+  messageDesc(file_sysml, 23);
+
+/**
+ * ParseSourcesRequest specifies the documents that make up one model. Two
+ * documents may not carry the same name: each is a distinct document of the
+ * model, and diagnostics name the document they came from.
+ *
+ * @generated from message sysml.ParseSourcesRequest
+ */
+export type ParseSourcesRequest = Message<"sysml.ParseSourcesRequest"> & {
+  /**
+   * @generated from field: repeated sysml.SourceDocument documents = 1;
+   */
+  documents: SourceDocument[];
+
+  /**
+   * Judge every document as conforming SysML v2, as ParseFileRequest does.
+   *
+   * @generated from field: bool strict_conformance = 2;
+   */
+  strictConformance: boolean;
+};
+
+/**
+ * Describes the message sysml.ParseSourcesRequest.
+ * Use `create(ParseSourcesRequestSchema)` to create a new message.
+ */
+export const ParseSourcesRequestSchema: GenMessage<ParseSourcesRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 24);
+
+/**
+ * ParseSourcesResponse contains the parsed model, whose documents are one model
+ * for every later request: a model_hash names all of them together.
+ *
+ * @generated from message sysml.ParseSourcesResponse
+ */
+export type ParseSourcesResponse = Message<"sysml.ParseSourcesResponse"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * Root namespace per document, in the order the request named them.
+   *
+   * @generated from field: repeated sysml.SymbolInfo roots = 2;
+   */
+  roots: SymbolInfo[];
+
+  /**
+   * Diagnostics of every document, each naming the document it came from.
+   *
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 3;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * @generated from field: string error = 4;
+   */
+  error: string;
+};
+
+/**
+ * Describes the message sysml.ParseSourcesResponse.
+ * Use `create(ParseSourcesResponseSchema)` to create a new message.
+ */
+export const ParseSourcesResponseSchema: GenMessage<ParseSourcesResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 25);
+
+/**
+ * ParseFileResponse contains parsed model info
+ *
+ * @generated from message sysml.ParseFileResponse
+ */
+export type ParseFileResponse = Message<"sysml.ParseFileResponse"> & {
+  /**
+   * Cache key for subsequent requests
+   *
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * Root namespace
+   *
+   * @generated from field: sysml.SymbolInfo root = 2;
+   */
+  root?: SymbolInfo | undefined;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 3;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * Critical failure message if any
+   *
+   * @generated from field: string error = 4;
+   */
+  error: string;
+};
+
+/**
+ * Describes the message sysml.ParseFileResponse.
+ * Use `create(ParseFileResponseSchema)` to create a new message.
+ */
+export const ParseFileResponseSchema: GenMessage<ParseFileResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 26);
+
+/**
+ * GetSymbolRequest queries for a specific symbol
+ *
+ * @generated from message sysml.GetSymbolRequest
+ */
+export type GetSymbolRequest = Message<"sysml.GetSymbolRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * Fully qualified name
+   *
+   * @generated from field: string symbol_id = 2;
+   */
+  symbolId: string;
+};
+
+/**
+ * Describes the message sysml.GetSymbolRequest.
+ * Use `create(GetSymbolRequestSchema)` to create a new message.
+ */
+export const GetSymbolRequestSchema: GenMessage<GetSymbolRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 27);
+
+/**
+ * SymbolResponse contains symbol information
+ *
+ * @generated from message sysml.SymbolResponse
+ */
+export type SymbolResponse = Message<"sysml.SymbolResponse"> & {
+  /**
+   * @generated from field: sysml.SymbolInfo symbol = 1;
+   */
+  symbol?: SymbolInfo | undefined;
+
+  /**
+   * @generated from field: string error = 2;
+   */
+  error: string;
+};
+
+/**
+ * Describes the message sysml.SymbolResponse.
+ * Use `create(SymbolResponseSchema)` to create a new message.
+ */
+export const SymbolResponseSchema: GenMessage<SymbolResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 28);
+
+/**
+ * DiagnosticsRequest gets all diagnostics for a model
+ *
+ * @generated from message sysml.DiagnosticsRequest
+ */
+export type DiagnosticsRequest = Message<"sysml.DiagnosticsRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+};
+
+/**
+ * Describes the message sysml.DiagnosticsRequest.
+ * Use `create(DiagnosticsRequestSchema)` to create a new message.
+ */
+export const DiagnosticsRequestSchema: GenMessage<DiagnosticsRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 29);
+
+/**
+ * DiagnosticsResponse contains diagnostic list
+ *
+ * @generated from message sysml.DiagnosticsResponse
+ */
+export type DiagnosticsResponse = Message<"sysml.DiagnosticsResponse"> & {
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 1;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * @generated from field: string error = 2;
+   */
+  error: string;
+};
+
+/**
+ * Describes the message sysml.DiagnosticsResponse.
+ * Use `create(DiagnosticsResponseSchema)` to create a new message.
+ */
+export const DiagnosticsResponseSchema: GenMessage<DiagnosticsResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 30);
+
+/**
+ * EvaluateRequest requests evaluation of a SysML expression
+ *
+ * @generated from message sysml.EvaluateRequest
+ */
+export type EvaluateRequest = Message<"sysml.EvaluateRequest"> & {
+  /**
+   * from ParseFile response
+   *
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * SysML expression string (e.g., "2 + 2")
+   *
+   * @generated from field: string expression = 2;
+   */
+  expression: string;
+
+  /**
+   * optional: symbol FQN for context scope
+   *
+   * @generated from field: string context_symbol_id = 3;
+   */
+  contextSymbolId: string;
+
+  /**
+   * optional: FQN to instantiate and evaluate against, so a feature reads that
+   * object's value rather than the declared default (as %eval after %instantiate)
+   *
+   * @generated from field: string subject_symbol_id = 4;
+   */
+  subjectSymbolId: string;
+};
+
+/**
+ * Describes the message sysml.EvaluateRequest.
+ * Use `create(EvaluateRequestSchema)` to create a new message.
+ */
+export const EvaluateRequestSchema: GenMessage<EvaluateRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 31);
+
+/**
+ * EvaluateResponse contains evaluation result
+ *
+ * @generated from message sysml.EvaluateResponse
+ */
+export type EvaluateResponse = Message<"sysml.EvaluateResponse"> & {
+  /**
+   * @generated from field: sysml.Value result = 1;
+   */
+  result?: Value | undefined;
+
+  /**
+   * non-empty if evaluation failed
+   *
+   * @generated from field: string error = 2;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 3;
+   */
+  diagnostics: Diagnostic[];
+};
+
+/**
+ * Describes the message sysml.EvaluateResponse.
+ * Use `create(EvaluateResponseSchema)` to create a new message.
+ */
+export const EvaluateResponseSchema: GenMessage<EvaluateResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 32);
+
+/**
+ * Instance represents a runtime instance of a part/usage
+ *
+ * @generated from message sysml.Instance
+ */
+export type Instance = Message<"sysml.Instance"> & {
+  /**
+   * @generated from field: int64 id = 1;
+   */
+  id: bigint;
+
+  /**
+   * FQN of the def/usage
+   *
+   * @generated from field: string type_symbol_id = 2;
+   */
+  typeSymbolId: string;
+
+  /**
+   * What the object holds for each feature of its type, by feature name.
+   *
+   * @generated from field: map<string, sysml.FeatureValue> feature_values = 4;
+   */
+  featureValues: { [key: string]: FeatureValue };
+};
+
+/**
+ * Describes the message sysml.Instance.
+ * Use `create(InstanceSchema)` to create a new message.
+ */
+export const InstanceSchema: GenMessage<Instance> = /*@__PURE__*/
+  messageDesc(file_sysml, 33);
+
+/**
+ * FeatureValue is what an object holds for one feature of its type.
+ *
+ * @generated from message sysml.FeatureValue
+ */
+export type FeatureValue = Message<"sysml.FeatureValue"> & {
+  /**
+   * @generated from field: string feature_name = 1;
+   */
+  featureName: string;
+
+  /**
+   * for a single-valued feature
+   *
+   * @generated from field: sysml.Value value = 2;
+   */
+  value?: Value | undefined;
+
+  /**
+   * for a multi-valued feature
+   *
+   * @generated from field: repeated sysml.Value values = 3;
+   */
+  values: Value[];
+
+  /**
+   * @generated from field: bool materialized = 4;
+   */
+  materialized: boolean;
+
+  /**
+   * set when evaluation failed; value is unset
+   *
+   * @generated from field: string error = 5;
+   */
+  error: string;
+};
+
+/**
+ * Describes the message sysml.FeatureValue.
+ * Use `create(FeatureValueSchema)` to create a new message.
+ */
+export const FeatureValueSchema: GenMessage<FeatureValue> = /*@__PURE__*/
+  messageDesc(file_sysml, 34);
+
+/**
+ * InstantiateRequest requests instantiation of a part/usage
+ *
+ * @generated from message sysml.InstantiateRequest
+ */
+export type InstantiateRequest = Message<"sysml.InstantiateRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * FQN of part/usage to instantiate
+   *
+   * @generated from field: string symbol_id = 2;
+   */
+  symbolId: string;
+};
+
+/**
+ * Describes the message sysml.InstantiateRequest.
+ * Use `create(InstantiateRequestSchema)` to create a new message.
+ */
+export const InstantiateRequestSchema: GenMessage<InstantiateRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 35);
+
+/**
+ * InstantiateResponse contains the created instance. The service keeps the
+ * object for the model it was created in: a later RunDocumentQuery on the same
+ * model_hash binds it by its id or by the name it was instantiated under, and
+ * `DocumentQueries::Objects` enumerates it. Instantiating the same symbol again
+ * creates a new object under the name; the earlier one stays reachable by id.
+ *
+ * @generated from message sysml.InstantiateResponse
+ */
+export type InstantiateResponse = Message<"sysml.InstantiateResponse"> & {
+  /**
+   * @generated from field: sysml.Instance instance = 1;
+   */
+  instance?: Instance | undefined;
+
+  /**
+   * @generated from field: string error = 2;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 3;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * Every instance reachable from `instance`, including `instance` itself, so a
+   * client can resolve an `instance_id` feature value without a follow-up RPC.
+   *
+   * @generated from field: repeated sysml.Instance instances = 4;
+   */
+  instances: Instance[];
+};
+
+/**
+ * Describes the message sysml.InstantiateResponse.
+ * Use `create(InstantiateResponseSchema)` to create a new message.
+ */
+export const InstantiateResponseSchema: GenMessage<InstantiateResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 36);
+
+/**
+ * ExecuteActionRequest requests action execution
+ *
+ * @generated from message sysml.ExecuteActionRequest
+ */
+export type ExecuteActionRequest = Message<"sysml.ExecuteActionRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * FQN of action def
+   *
+   * @generated from field: string action_symbol_id = 2;
+   */
+  actionSymbolId: string;
+
+  /**
+   * parameter name → value
+   *
+   * @generated from field: map<string, sysml.Value> inputs = 3;
+   */
+  inputs: { [key: string]: Value };
+
+  /**
+   * Scheduling policy the run resolves its choice points under: "declared",
+   * "reverse", "seed:<n>" or "explore[:runs=<n>,depth=<d>]". Empty is the
+   * default, "reverse"; any other spelling is INVALID_ARGUMENT. Explore runs
+   * the action once per linearization the library admits, within a budget of
+   * runs (default 1024) and of choice points per run (default 64), each run on
+   * a fresh context, and answers with every distinct outcome reached.
+   *
+   * @generated from field: string schedule = 4;
+   */
+  schedule: string;
+
+  /**
+   * Optional FQN of a part/usage to perform the action on, or a path from one to
+   * a nested object, as `Mission::mission.vehicle`: the object is created for the
+   * run — inside the assembly the path walks, so its connectors carry what the
+   * action sends — and under explore each run creates its own. Empty performs
+   * the action outside any object.
+   *
+   * @generated from field: string performer_symbol_id = 5;
+   */
+  performerSymbolId: string;
+};
+
+/**
+ * Describes the message sysml.ExecuteActionRequest.
+ * Use `create(ExecuteActionRequestSchema)` to create a new message.
+ */
+export const ExecuteActionRequestSchema: GenMessage<ExecuteActionRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 37);
+
+/**
+ * ExecuteActionResponse contains action execution results
+ *
+ * @generated from message sysml.ExecuteActionResponse
+ */
+export type ExecuteActionResponse = Message<"sysml.ExecuteActionResponse"> & {
+  /**
+   * output parameter name → value
+   *
+   * @generated from field: map<string, sysml.Value> outputs = 1;
+   */
+  outputs: { [key: string]: Value };
+
+  /**
+   * @generated from field: string error = 2;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 3;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * Set only under an explore schedule: every distinct outcome reached, in
+   * canonical order, and how the exploration ended. `outputs`, `error` and
+   * `diagnostics` are then empty: a failed run is an outcome of its own, and
+   * each outcome carries its witness run's diagnostics.
+   *
+   * @generated from field: repeated sysml.Outcome outcomes = 4;
+   */
+  outcomes: Outcome[];
+
+  /**
+   * @generated from field: sysml.ExplorationStatus exploration = 5;
+   */
+  exploration?: ExplorationStatus | undefined;
+
+  /**
+   * The run's simulation clock when it ended, in seconds (SI::s) from the 0
+   * it started at: the clock advances through every `accept after`/`accept
+   * at` the action waited on. Populated under the "final_time" capability.
+   *
+   * @generated from field: double final_time = 6;
+   */
+  finalTime: number;
+};
+
+/**
+ * Describes the message sysml.ExecuteActionResponse.
+ * Use `create(ExecuteActionResponseSchema)` to create a new message.
+ */
+export const ExecuteActionResponseSchema: GenMessage<ExecuteActionResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 38);
+
+/**
+ * ExecuteStateRequest requests state machine execution
+ *
+ * @generated from message sysml.ExecuteStateRequest
+ */
+export type ExecuteStateRequest = Message<"sysml.ExecuteStateRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * @generated from field: string state_machine_symbol_id = 2;
+   */
+  stateMachineSymbolId: string;
+
+  /**
+   * sequence of event names to process
+   *
+   * @generated from field: repeated string events = 3;
+   */
+  events: string[];
+
+  /**
+   * Scheduling policy the run resolves its choice points under: "declared",
+   * "reverse", "seed:<n>" or "explore[:runs=<n>,depth=<d>]". Empty is the
+   * default, "reverse"; any other spelling is INVALID_ARGUMENT. Under explore
+   * the response answers with `outcomes` and `exploration` in place of one run's
+   * states_visited, final_context and error (see ExecuteActionResponse).
+   *
+   * @generated from field: string schedule = 4;
+   */
+  schedule: string;
+
+  /**
+   * Optional FQN of a part/usage to run the machine on, or a path from one to a
+   * nested object (see ExecuteActionRequest.performer_symbol_id). An object
+   * exhibiting the machine runs the one it exhibits, so what its assembly
+   * connects to it reaches the run; one exhibiting it under several usages is
+   * an error. Empty runs the machine outside any object.
+   *
+   * @generated from field: string performer_symbol_id = 5;
+   */
+  performerSymbolId: string;
+};
+
+/**
+ * Describes the message sysml.ExecuteStateRequest.
+ * Use `create(ExecuteStateRequestSchema)` to create a new message.
+ */
+export const ExecuteStateRequestSchema: GenMessage<ExecuteStateRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 39);
+
+/**
+ * ExecuteStateResponse contains state machine execution trace
+ *
+ * @generated from message sysml.ExecuteStateResponse
+ */
+export type ExecuteStateResponse = Message<"sysml.ExecuteStateResponse"> & {
+  /**
+   * trace of state names
+   *
+   * @generated from field: repeated string states_visited = 1;
+   */
+  statesVisited: string[];
+
+  /**
+   * @generated from field: map<string, sysml.Value> final_context = 2;
+   */
+  finalContext: { [key: string]: Value };
+
+  /**
+   * @generated from field: string error = 3;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 4;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * Set only under an explore schedule: every distinct outcome reached, in
+   * canonical order, and how the exploration ended. An outcome's outputs are
+   * the final context; `states_visited`, `final_context`, `error` and
+   * `diagnostics` are then empty.
+   *
+   * @generated from field: repeated sysml.Outcome outcomes = 5;
+   */
+  outcomes: Outcome[];
+
+  /**
+   * @generated from field: sysml.ExplorationStatus exploration = 6;
+   */
+  exploration?: ExplorationStatus | undefined;
+
+  /**
+   * The run's simulation clock when it ended, in seconds (SI::s) from the 0
+   * it started at: the clock advances through every time-triggered transition
+   * the machine took. Populated under the "final_time" capability.
+   *
+   * @generated from field: double final_time = 7;
+   */
+  finalTime: number;
+};
+
+/**
+ * Describes the message sysml.ExecuteStateResponse.
+ * Use `create(ExecuteStateResponseSchema)` to create a new message.
+ */
+export const ExecuteStateResponseSchema: GenMessage<ExecuteStateResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 40);
+
+/**
+ * ConvertRequest asks for a model in another representation. A model_hash
+ * converts the source that parse read, so a file edited since then does not
+ * change the answer; a file_path is read afresh and content is carried inline.
+ *
+ * @generated from message sysml.ConvertRequest
+ */
+export type ConvertRequest = Message<"sysml.ConvertRequest"> & {
+  /**
+   * @generated from oneof sysml.ConvertRequest.source
+   */
+  source: {
+    /**
+     * @generated from field: string file_path = 1;
+     */
+    value: string;
+    case: "filePath";
+  } | {
+    /**
+     * @generated from field: string content = 2;
+     */
+    value: string;
+    case: "content";
+  } | {
+    /**
+     * @generated from field: string model_hash = 6;
+     */
+    value: string;
+    case: "modelHash";
+  } | { case: undefined; value?: undefined };
+
+  /**
+   * "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+   * "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+   * v2 and never written. Empty infers from file_path's extension, and is
+   * notation for a model_hash, since that is what parse reads; inline content
+   * has neither, so it must say.
+   *
+   * @generated from field: string from_format = 3;
+   */
+  fromFormat: string;
+
+  /**
+   * Format to write, named as in from_format; the v1 names are refused, since
+   * a v2 model has no v1 form. Empty is rejected.
+   *
+   * @generated from field: string to_format = 4;
+   */
+  toFormat: string;
+
+  /**
+   * Write notation back out even when the parser could not read all of it,
+   * reporting its syntax errors as diagnostics. Notation to notation only:
+   * every other direction builds a graph, where unreadable declarations would
+   * go missing silently.
+   *
+   * @generated from field: bool tolerate_syntax_errors = 5;
+   */
+  tolerateSyntaxErrors: boolean;
+};
+
+/**
+ * Describes the message sysml.ConvertRequest.
+ * Use `create(ConvertRequestSchema)` to create a new message.
+ */
+export const ConvertRequestSchema: GenMessage<ConvertRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 41);
+
+/**
+ * ConvertResponse contains the converted model.
+ *
+ * @generated from message sysml.ConvertResponse
+ */
+export type ConvertResponse = Message<"sysml.ConvertResponse"> & {
+  /**
+   * @generated from field: string content = 1;
+   */
+  content: string;
+
+  /**
+   * Formats used, so a caller that let from_format be inferred learns what it
+   * was inferred as.
+   *
+   * @generated from field: string from_format = 2;
+   */
+  fromFormat: string;
+
+  /**
+   * @generated from field: string to_format = 3;
+   */
+  toFormat: string;
+
+  /**
+   * non-empty if the conversion failed; content is unset
+   *
+   * @generated from field: string error = 4;
+   */
+  error: string;
+
+  /**
+   * Syntax errors tolerated under tolerate_syntax_errors, or those that failed
+   * the conversion.
+   *
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 5;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * Set when either format is RDF or the API's JSON element form, whose
+   * mapping is experimental: it covers model structure and the behavior its
+   * bodies state, refuses what it cannot write back, and its vocabulary may
+   * change without a compatibility path. Also set when the source is SysML v1,
+   * whose migration is experimental in the same sense. Notation to notation is
+   * stable and leaves this unset.
+   *
+   * @generated from field: bool experimental = 6;
+   */
+  experimental: boolean;
+
+  /**
+   * What is experimental about the conversion, in the wording every surface
+   * reports it in. Empty when experimental is false.
+   *
+   * @generated from field: string experimental_notice = 7;
+   */
+  experimentalNotice: string;
+};
+
+/**
+ * Describes the message sysml.ConvertResponse.
+ * Use `create(ConvertResponseSchema)` to create a new message.
+ */
+export const ConvertResponseSchema: GenMessage<ConvertResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 42);
+
+/**
+ * ApplyEditsRequest asks for a model's source with edits applied to it. The
+ * source edited is the one parse read, named by its hash, so an edit is applied
+ * to the model that was inspected. A model of several documents (ParseSources)
+ * is edited as one when the request sets `accept_documents`: the operations
+ * target declarations of the document named by `document`, and a rename or
+ * cascade delete follows references into every other document of the model,
+ * rewriting those too. `document` and `accept_documents` are advertised as the
+ * "edit_documents" capability: a service without it edits a model of one
+ * document alone and answers `content` alone, so a client checks it before
+ * naming a document or reading `documents`.
+ *
+ * @generated from message sysml.ApplyEditsRequest
+ */
+export type ApplyEditsRequest = Message<"sysml.ApplyEditsRequest"> & {
+  /**
+   * from a ParseFile or ParseSources response
+   *
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
+   * and answering with the unedited source would look like one was made.
+   *
+   * @generated from field: repeated sysml.EditOperation operations = 2;
+   */
+  operations: EditOperation[];
+
+  /**
+   * The document whose declarations the operations target, named as the parse
+   * request named it; empty names the model's first document, which is the
+   * only one of a ParseFile model. An operation targeting a declaration of
+   * another document is refused as an unknown target, naming that document. A
+   * name no document of the model has fails the call as an invalid argument.
+   *
+   * @generated from field: string document = 3;
+   */
+  document: string;
+
+  /**
+   * Whether the client reads the response's `documents`. A model of several documents is
+   * edited only when set; unset, such a model is refused as a failed precondition, as before.
+   *
+   * @generated from field: bool accept_documents = 4;
+   */
+  acceptDocuments: boolean;
+};
+
+/**
+ * Describes the message sysml.ApplyEditsRequest.
+ * Use `create(ApplyEditsRequestSchema)` to create a new message.
+ */
+export const ApplyEditsRequestSchema: GenMessage<ApplyEditsRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 43);
+
+/**
+ * EditOperation is one source-preserving change to make.
+ *
+ * @generated from message sysml.EditOperation
+ */
+export type EditOperation = Message<"sysml.EditOperation"> & {
+  /**
+   * @generated from oneof sysml.EditOperation.operation
+   */
+  operation: {
+    /**
+     * @generated from field: sysml.SetValueEdit set_value = 1;
+     */
+    value: SetValueEdit;
+    case: "setValue";
+  } | {
+    /**
+     * @generated from field: sysml.RenameEdit rename = 2;
+     */
+    value: RenameEdit;
+    case: "rename";
+  } | {
+    /**
+     * @generated from field: sysml.AddMemberEdit add_member = 3;
+     */
+    value: AddMemberEdit;
+    case: "addMember";
+  } | {
+    /**
+     * @generated from field: sysml.DeleteEdit delete = 4;
+     */
+    value: DeleteEdit;
+    case: "delete";
+  } | {
+    /**
+     * @generated from field: sysml.MoveEdit move = 5;
+     */
+    value: MoveEdit;
+    case: "move";
+  } | { case: undefined; value?: undefined };
+};
+
+/**
+ * Describes the message sysml.EditOperation.
+ * Use `create(EditOperationSchema)` to create a new message.
+ */
+export const EditOperationSchema: GenMessage<EditOperation> = /*@__PURE__*/
+  messageDesc(file_sysml, 44);
+
+/**
+ * AddMemberEdit inserts a declaration into a namespace or the document root.
+ *
+ * @generated from message sysml.AddMemberEdit
+ */
+export type AddMemberEdit = Message<"sysml.AddMemberEdit"> & {
+  /**
+   * Namespace FQN to receive the declaration; empty means the document root.
+   *
+   * @generated from field: string owner = 1;
+   */
+  owner: string;
+
+  /**
+   * Written declaration kind, such as "part def" or "class".
+   *
+   * @generated from field: string kind = 2;
+   */
+  kind: string;
+
+  /**
+   * Declared identifier.
+   *
+   * @generated from field: string name = 3;
+   */
+  name: string;
+
+  /**
+   * Optional type target for a usage, written as notation.
+   *
+   * @generated from field: string type = 4;
+   */
+  type: string;
+
+  /**
+   * Optional multiplicity, including brackets, such as "[0..*]".
+   *
+   * @generated from field: string multiplicity = 5;
+   */
+  multiplicity: string;
+
+  /**
+   * Optional value expression, written as notation.
+   *
+   * @generated from field: string value = 6;
+   */
+  value: string;
+
+  /**
+   * Optional specialization targets for a definition.
+   *
+   * @generated from field: repeated string specializes = 7;
+   */
+  specializes: string[];
+};
+
+/**
+ * Describes the message sysml.AddMemberEdit.
+ * Use `create(AddMemberEditSchema)` to create a new message.
+ */
+export const AddMemberEditSchema: GenMessage<AddMemberEdit> = /*@__PURE__*/
+  messageDesc(file_sysml, 45);
+
+/**
+ * DeleteEdit removes a declaration and its owned trivia.
+ *
+ * @generated from message sysml.DeleteEdit
+ */
+export type DeleteEdit = Message<"sysml.DeleteEdit"> & {
+  /**
+   * Fully qualified name of the declaration to remove.
+   *
+   * @generated from field: string target = 1;
+   */
+  target: string;
+
+  /**
+   * Also remove declarations that refer to target.
+   *
+   * @generated from field: bool cascade = 2;
+   */
+  cascade: boolean;
+};
+
+/**
+ * Describes the message sysml.DeleteEdit.
+ * Use `create(DeleteEditSchema)` to create a new message.
+ */
+export const DeleteEditSchema: GenMessage<DeleteEdit> = /*@__PURE__*/
+  messageDesc(file_sysml, 46);
+
+/**
+ * MoveEdit re-parents a declaration: the span DeleteEdit would remove is
+ * written where AddMemberEdit would insert it, and the references the move
+ * breaks are respelled so the model stays valid. A move whose references
+ * cannot be respelled is refused, naming them, rather than leaving them dangling.
+ *
+ * @generated from message sysml.MoveEdit
+ */
+export type MoveEdit = Message<"sysml.MoveEdit"> & {
+  /**
+   * Fully qualified name of the declaration to move.
+   *
+   * @generated from field: string target = 1;
+   */
+  target: string;
+
+  /**
+   * Namespace FQN to receive the declaration; empty means the document root.
+   *
+   * @generated from field: string owner = 2;
+   */
+  owner: string;
+};
+
+/**
+ * Describes the message sysml.MoveEdit.
+ * Use `create(MoveEditSchema)` to create a new message.
+ */
+export const MoveEditSchema: GenMessage<MoveEdit> = /*@__PURE__*/
+  messageDesc(file_sysml, 47);
+
+/**
+ * SetValueEdit sets the value of a feature that already exists, replacing the
+ * expression of its `= <expr>` or adding one before the declaration's `;`.
+ *
+ * @generated from message sysml.SetValueEdit
+ */
+export type SetValueEdit = Message<"sysml.SetValueEdit"> & {
+  /**
+   * Element to edit, named as SymbolInfo.id names it ("Demo::sc::unitMass").
+   *
+   * @generated from field: string target = 1;
+   */
+  target: string;
+
+  /**
+   * New value in SysML notation ("1050.0[SI::kg]", "\"m1\"", "true", "mass * 2").
+   * It must parse as an expression and resolve in the feature's own scope.
+   *
+   * @generated from field: string value = 2;
+   */
+  value: string;
+};
+
+/**
+ * Describes the message sysml.SetValueEdit.
+ * Use `create(SetValueEditSchema)` to create a new message.
+ */
+export const SetValueEditSchema: GenMessage<SetValueEdit> = /*@__PURE__*/
+  messageDesc(file_sysml, 48);
+
+/**
+ * RenameEdit rewrites the name token of a declaration and every reference to
+ * it in the model's documents. A rename that reaches a reference in a document
+ * the edit cannot rewrite is refused, naming the referring elements, rather
+ * than leaving a broken model.
+ *
+ * @generated from message sysml.RenameEdit
+ */
+export type RenameEdit = Message<"sysml.RenameEdit"> & {
+  /**
+   * element to rename, as SymbolInfo.id names it
+   *
+   * @generated from field: string target = 1;
+   */
+  target: string;
+
+  /**
+   * new declared name; must lex as an identifier
+   *
+   * @generated from field: string new_name = 2;
+   */
+  newName: string;
+};
+
+/**
+ * Describes the message sysml.RenameEdit.
+ * Use `create(RenameEditSchema)` to create a new message.
+ */
+export const RenameEditSchema: GenMessage<RenameEdit> = /*@__PURE__*/
+  messageDesc(file_sysml, 49);
+
+/**
+ * ApplyEditsResponse carries the edited source, or says why nothing was edited.
+ * The edited notation is in `documents`, one entry per document the edits
+ * rewrote; `content` repeats it for a single-document model only.
+ *
+ * @generated from message sysml.ApplyEditsResponse
+ */
+export type ApplyEditsResponse = Message<"sysml.ApplyEditsResponse"> & {
+  /**
+   * The edited notation of a single-document model, byte-identical to the
+   * source outside the edited spans. Empty when the edits were refused, so a
+   * refusal never writes a file, and empty for a model of several documents,
+   * whose edited notation is in `documents` alone: a client that reads
+   * `content` alone was written for one document and must not write one
+   * document's notation over another's. `documents` carries the same notation
+   * for a single-document model, so a client needs one code path.
+   *
+   * @generated from field: string content = 1;
+   */
+  content: string;
+
+  /**
+   * What each operation changed, grouped by document in the order `documents`
+   * lists them and in request order within a document.
+   *
+   * @generated from field: repeated sysml.AppliedEdit applied = 2;
+   */
+  applied: AppliedEdit[];
+
+  /**
+   * non-empty if the edits were refused; content and documents are unset
+   *
+   * @generated from field: string error = 3;
+   */
+  error: string;
+
+  /**
+   * Which kind of refusal `error` reports, so a client acts on the kind rather
+   * than on the message text.
+   *
+   * @generated from field: sysml.EditFailure failure = 4;
+   */
+  failure: EditFailure;
+
+  /**
+   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
+   * or the errors the edited source was found to have. A diagnostic's span
+   * names the document it is in.
+   *
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 5;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * Where the references to a declaration whose rename, delete or move was
+   * refused are made: the FQN of each referring namespace, suffixed with its
+   * document in parentheses when that is not the document being edited.
+   * `referrers` carries the same list with the document as a field of its own.
+   *
+   * @generated from field: repeated string referring_elements = 6;
+   */
+  referringElements: string[];
+
+  /**
+   * The edited notation of every document the edits rewrote, named as the
+   * parse request named it: the document being edited first, then the others
+   * in name order. A document of several the edits left as parsed is not
+   * listed; the one document of a single-document model always is. Empty when
+   * the edits were refused.
+   *
+   * @generated from field: repeated sysml.EditedDocument documents = 7;
+   */
+  documents: EditedDocument[];
+
+  /**
+   * The declarations referring to the target of a refused rename, delete or
+   * move, each with the document declaring it, in document then name order.
+   * Empty when `referring_elements` is.
+   *
+   * @generated from field: repeated sysml.Referrer referrers = 8;
+   */
+  referrers: Referrer[];
+};
+
+/**
+ * Describes the message sysml.ApplyEditsResponse.
+ * Use `create(ApplyEditsResponseSchema)` to create a new message.
+ */
+export const ApplyEditsResponseSchema: GenMessage<ApplyEditsResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 50);
+
+/**
+ * EditedDocument is the edited notation of one document of the model.
+ *
+ * @generated from message sysml.EditedDocument
+ */
+export type EditedDocument = Message<"sysml.EditedDocument"> & {
+  /**
+   * The document's name as the parse request gave it: the file path of a
+   * file_path document, the name of an inline one, or the position it was
+   * given when it was named neither.
+   *
+   * @generated from field: string name = 1;
+   */
+  name: string;
+
+  /**
+   * The edited notation, byte-identical to the source outside the edited spans.
+   *
+   * @generated from field: string content = 2;
+   */
+  content: string;
+};
+
+/**
+ * Describes the message sysml.EditedDocument.
+ * Use `create(EditedDocumentSchema)` to create a new message.
+ */
+export const EditedDocumentSchema: GenMessage<EditedDocument> = /*@__PURE__*/
+  messageDesc(file_sysml, 51);
+
+/**
+ * Referrer is one declaration referring to the target of a refused edit.
+ *
+ * @generated from message sysml.Referrer
+ */
+export type Referrer = Message<"sysml.Referrer"> & {
+  /**
+   * The declaration as the notation names it: the FQN of a named one, or the
+   * heading of an anonymous one within its namespace.
+   *
+   * @generated from field: string name = 1;
+   */
+  name: string;
+
+  /**
+   * The document declaring it, named as the parse request named it.
+   *
+   * @generated from field: string document = 2;
+   */
+  document: string;
+};
+
+/**
+ * Describes the message sysml.Referrer.
+ * Use `create(ReferrerSchema)` to create a new message.
+ */
+export const ReferrerSchema: GenMessage<Referrer> = /*@__PURE__*/
+  messageDesc(file_sysml, 52);
+
+/**
+ * AppliedEdit is one byte range of the original source that an operation
+ * replaced, so a client can report or locate what changed.
+ *
+ * @generated from message sysml.AppliedEdit
+ */
+export type AppliedEdit = Message<"sysml.AppliedEdit"> & {
+  /**
+   * Index of the operation in the request, so an answer maps back to its ask.
+   *
+   * @generated from field: int32 operation_index = 1;
+   */
+  operationIndex: number;
+
+  /**
+   * element edited, as the request named it
+   *
+   * @generated from field: string target = 2;
+   */
+  target: string;
+
+  /**
+   * Batch offsets use the original source; sequential offsets use the
+   * intermediate source seen by that operation.
+   *
+   * @generated from field: int32 offset = 3;
+   */
+  offset: number;
+
+  /**
+   * bytes replaced; zero for text inserted
+   *
+   * @generated from field: int32 length = 4;
+   */
+  length: number;
+
+  /**
+   * what was there, empty for an insertion
+   *
+   * @generated from field: string old_text = 5;
+   */
+  oldText: string;
+
+  /**
+   * what was written
+   *
+   * @generated from field: string new_text = 6;
+   */
+  newText: string;
+
+  /**
+   * The document the bytes are in, named as the parse request named it and as
+   * ApplyEditsResponse.documents lists it. Set for a single-document model too.
+   *
+   * @generated from field: string document = 7;
+   */
+  document: string;
+};
+
+/**
+ * Describes the message sysml.AppliedEdit.
+ * Use `create(AppliedEditSchema)` to create a new message.
+ */
+export const AppliedEditSchema: GenMessage<AppliedEdit> = /*@__PURE__*/
+  messageDesc(file_sysml, 53);
+
+/**
+ * SymbolInfo represents any SysML element
+ *
+ * @generated from message sysml.SymbolInfo
+ */
+export type SymbolInfo = Message<"sysml.SymbolInfo"> & {
+  /**
+   * Unique identifier (fully qualified name)
+   *
+   * @generated from field: string id = 1;
+   */
+  id: string;
+
+  /**
+   * @generated from field: string name = 2;
+   */
+  name: string;
+
+  /**
+   * "PartDefinition", "AttributeUsage", etc
+   *
+   * @generated from field: string kind = 3;
+   */
+  kind: string;
+
+  /**
+   * multiplicity, type, etc
+   *
+   * @generated from field: map<string, string> metadata = 4;
+   */
+  metadata: { [key: string]: string };
+
+  /**
+   * References to children
+   *
+   * @generated from field: repeated string child_ids = 5;
+   */
+  childIds: string[];
+
+  /**
+   * @generated from field: repeated sysml.AttributeInfo attributes = 6;
+   */
+  attributes: AttributeInfo[];
+
+  /**
+   * Static type facts; unset when the element is not a def/usage.
+   *
+   * @generated from field: sysml.TypeInfo type_info = 7;
+   */
+  typeInfo?: TypeInfo | undefined;
+
+  /**
+   * Declared multiplicity; unset when none is declared.
+   *
+   * @generated from field: sysml.MultiplicityInfo multiplicity = 8;
+   */
+  multiplicity?: MultiplicityInfo | undefined;
+
+  /**
+   * Every generalization edge declared, in declaration order. metadata["type"]
+   * and metadata["specializes"] report only the first, kept for compatibility.
+   *
+   * @generated from field: repeated sysml.Specialization specializations = 9;
+   */
+  specializations: Specialization[];
+
+  /**
+   * Attributes inherited from standard-library content, which `attributes`
+   * leaves out. Their absence is stated here rather than silent.
+   *
+   * @generated from field: int32 withheld_library_attributes = 10;
+   */
+  withheldLibraryAttributes: number;
+};
+
+/**
+ * Describes the message sysml.SymbolInfo.
+ * Use `create(SymbolInfoSchema)` to create a new message.
+ */
+export const SymbolInfoSchema: GenMessage<SymbolInfo> = /*@__PURE__*/
+  messageDesc(file_sysml, 54);
+
+/**
+ * Specialization is one generalization edge an element declares.
+ *
+ * @generated from message sysml.Specialization
+ */
+export type Specialization = Message<"sysml.Specialization"> & {
+  /**
+   * Relationship kind: "specializes", "subsets", "redefines" or "typing".
+   *
+   * @generated from field: string kind = 1;
+   */
+  kind: string;
+
+  /**
+   * The target as written in the source ("Engine", "Demo::Engine").
+   *
+   * @generated from field: string declared = 2;
+   */
+  declared: string;
+
+  /**
+   * FQN of the resolved target; empty when the name does not resolve.
+   *
+   * @generated from field: string target_id = 3;
+   */
+  targetId: string;
+
+  /**
+   * Symbol kind of the resolved target ("partDef", ...); empty when unresolved.
+   *
+   * @generated from field: string target_kind = 4;
+   */
+  targetKind: string;
+};
+
+/**
+ * Describes the message sysml.Specialization.
+ * Use `create(SpecializationSchema)` to create a new message.
+ */
+export const SpecializationSchema: GenMessage<Specialization> = /*@__PURE__*/
+  messageDesc(file_sysml, 55);
+
+/**
+ * TypeInfo is the static type of a usage, or the classification of a
+ * definition, as far as the service derives it without running the model.
+ *
+ * @generated from message sysml.TypeInfo
+ */
+export type TypeInfo = Message<"sysml.TypeInfo"> & {
+  /**
+   * Type name as written; empty when none is declared.
+   *
+   * @generated from field: string declared = 1;
+   */
+  declared: string;
+
+  /**
+   * FQN of the resolved type; empty when unresolved or undeclared.
+   *
+   * @generated from field: string resolved_id = 2;
+   */
+  resolvedId: string;
+
+  /**
+   * Symbol kind of the resolved type ("partDef", "attributeDef", ...).
+   *
+   * @generated from field: string resolved_kind = 3;
+   */
+  resolvedKind: string;
+
+  /**
+   * Library scalar the type reduces to ("Boolean", "Integer", "Real", ...);
+   * empty when it is not a scalar value type.
+   *
+   * @generated from field: string primitive = 4;
+   */
+  primitive: string;
+
+  /**
+   * Origin of `primitive`: "declared" (from the declared or inherited type),
+   * "value" (inferred from the default value), or empty.
+   *
+   * @generated from field: string primitive_source = 5;
+   */
+  primitiveSource: string;
+
+  /**
+   * Values carry a measurement unit, so such a feature value holds a Quantity.
+   *
+   * @generated from field: bool quantity = 6;
+   */
+  quantity: boolean;
+
+  /**
+   * Unit as written when the default value names one ("m/s"), else empty.
+   *
+   * @generated from field: string unit = 7;
+   */
+  unit: string;
+};
+
+/**
+ * Describes the message sysml.TypeInfo.
+ * Use `create(TypeInfoSchema)` to create a new message.
+ */
+export const TypeInfoSchema: GenMessage<TypeInfo> = /*@__PURE__*/
+  messageDesc(file_sysml, 56);
+
+/**
+ * MultiplicityInfo is a declared multiplicity range. A bound the service cannot
+ * evaluate statically is empty.
+ *
+ * @generated from message sysml.MultiplicityInfo
+ */
+export type MultiplicityInfo = Message<"sysml.MultiplicityInfo"> & {
+  /**
+   * "0", "1", … or "*"
+   *
+   * @generated from field: string lower = 1;
+   */
+  lower: string;
+
+  /**
+   * "1", "*", …
+   *
+   * @generated from field: string upper = 2;
+   */
+  upper: string;
+};
+
+/**
+ * Describes the message sysml.MultiplicityInfo.
+ * Use `create(MultiplicityInfoSchema)` to create a new message.
+ */
+export const MultiplicityInfoSchema: GenMessage<MultiplicityInfo> = /*@__PURE__*/
+  messageDesc(file_sysml, 57);
+
+/**
+ * AttributeInfo represents an attribute with its value
+ *
+ * @generated from message sysml.AttributeInfo
+ */
+export type AttributeInfo = Message<"sysml.AttributeInfo"> & {
+  /**
+   * @generated from field: string name = 1;
+   */
+  name: string;
+
+  /**
+   * @generated from field: string type = 2;
+   */
+  type: string;
+
+  /**
+   * @generated from field: sysml.Value value = 3;
+   */
+  value?: Value | undefined;
+
+  /**
+   * @generated from field: string unit = 4;
+   */
+  unit: string;
+};
+
+/**
+ * Describes the message sysml.AttributeInfo.
+ * Use `create(AttributeInfoSchema)` to create a new message.
+ */
+export const AttributeInfoSchema: GenMessage<AttributeInfo> = /*@__PURE__*/
+  messageDesc(file_sysml, 58);
+
+/**
+ * Value represents a runtime-evaluable value
+ *
+ * @generated from message sysml.Value
+ */
+export type Value = Message<"sysml.Value"> & {
+  /**
+   * @generated from oneof sysml.Value.kind
+   */
+  kind: {
+    /**
+     * @generated from field: int64 int_value = 1;
+     */
+    value: bigint;
+    case: "intValue";
+  } | {
+    /**
+     * @generated from field: double real_value = 2;
+     */
+    value: number;
+    case: "realValue";
+  } | {
+    /**
+     * @generated from field: bool bool_value = 3;
+     */
+    value: boolean;
+    case: "boolValue";
+  } | {
+    /**
+     * @generated from field: string string_value = 4;
+     */
+    value: string;
+    case: "stringValue";
+  } | {
+    /**
+     * reference to Instance
+     *
+     * @generated from field: int64 instance_id = 5;
+     */
+    value: bigint;
+    case: "instanceId";
+  } | {
+    /**
+     * @generated from field: sysml.ValueSequence sequence = 6;
+     */
+    value: ValueSequence;
+    case: "sequence";
+  } | {
+    /**
+     * marker for null (empty string)
+     *
+     * @generated from field: string null = 7;
+     */
+    value: string;
+    case: "null";
+  } | {
+    /**
+     * magnitude and the unit it is expressed in
+     *
+     * @generated from field: sysml.Quantity quantity = 8;
+     */
+    value: Quantity;
+    case: "quantity";
+  } | {
+    /**
+     * @generated from field: sysml.EnumLiteral enum_literal = 9;
+     */
+    value: EnumLiteral;
+    case: "enumLiteral";
+  } | {
+    /**
+     * A valueless feature of a value type: materialized, holding no value.
+     * Always true when set; a value the server sends, never one it accepts.
+     *
+     * @generated from field: bool unset = 10;
+     */
+    value: boolean;
+    case: "unset";
+  } | {
+    /**
+     * one complex number, never two Reals
+     *
+     * @generated from field: sysml.Complex complex = 11;
+     */
+    value: Complex;
+    case: "complex";
+  } | {
+    /**
+     * shape and elements, never a flat sequence
+     *
+     * @generated from field: sysml.Array array = 12;
+     */
+    value: Array;
+    case: "array";
+  } | {
+    /**
+     * numeric components, never a sequence
+     *
+     * @generated from field: sysml.Vector vector = 13;
+     */
+    value: Vector;
+    case: "vector";
+  } | {
+    /**
+     * components each with their unit
+     *
+     * @generated from field: sysml.VectorQuantity vector_quantity = 14;
+     */
+    value: VectorQuantity;
+    case: "vectorQuantity";
+  } | {
+    /**
+     * a unit by itself, no magnitude
+     *
+     * @generated from field: sysml.MeasurementRef measurement_ref = 15;
+     */
+    value: MeasurementRef;
+    case: "measurementRef";
+  } | {
+    /**
+     * The unbounded value `*`, which is no number and no string: ordered above
+     * every finite magnitude and refused by arithmetic. Always true when set,
+     * as DocumentValue.infinity is.
+     *
+     * @generated from field: bool infinity = 16;
+     */
+    value: boolean;
+    case: "infinity";
+  } | {
+    /**
+     * a calc as a value, named by its declaration
+     *
+     * @generated from field: sysml.Function function = 17;
+     */
+    value: Function;
+    case: "function";
+  } | {
+    /**
+     * distinct elements with no order of their own
+     *
+     * @generated from field: sysml.ValueSet set = 18;
+     */
+    value: ValueSet;
+    case: "set";
+  } | {
+    /**
+     * shape and one Quantity per component
+     *
+     * @generated from field: sysml.TensorQuantity tensor_quantity = 19;
+     */
+    value: TensorQuantity;
+    case: "tensorQuantity";
+  } | {
+    /**
+     * an element reflected on as its metaclass
+     *
+     * @generated from field: sysml.Metaobject metaobject = 20;
+     */
+    value: Metaobject;
+    case: "metaobject";
+  } | {
+    /**
+     * A result the model leaves open: not an error, but no definite answer.
+     * A value the server sends, never one it accepts.
+     *
+     * @generated from field: sysml.Undetermined undetermined = 21;
+     */
+    value: Undetermined;
+    case: "undetermined";
+  } | { case: undefined; value?: undefined };
+};
+
+/**
+ * Describes the message sysml.Value.
+ * Use `create(ValueSchema)` to create a new message.
+ */
+export const ValueSchema: GenMessage<Value> = /*@__PURE__*/
+  messageDesc(file_sysml, 59);
+
+/**
+ * Metaobject is an element of the model held as an instance of its reflective
+ * metaclass: what `x meta KerML::Feature`, or the last element of
+ * `x.metadata`, evaluates to (KerML 7.4.9.2, 8.3.4.8.15). It crosses as the
+ * element it reflects, which is its identity: two metaobjects are the same
+ * exactly when element_id is, whatever type each was cast to. Its features
+ * (`declaredName`, `ownedFeature`, ...) are read in the model, not carried.
+ *
+ * @generated from message sysml.Metaobject
+ */
+export type Metaobject = Message<"sysml.Metaobject"> & {
+  /**
+   * FQN of the element reflected on ("Vehicle::seatBelt"). Its identity.
+   *
+   * @generated from field: string element_id = 1;
+   */
+  elementId: string;
+
+  /**
+   * FQN of the element's own reflective metaclass ("SysML::Systems::PartUsage"),
+   * the most specific metaclass of the KerML or SysML library classifying it;
+   * not the type it was cast to. The service always sends it; a client may omit
+   * it, in which case the model's is used, but one sent must be the model's or
+   * the value is rejected.
+   *
+   * @generated from field: string metaclass_id = 2;
+   */
+  metaclassId: string;
+};
+
+/**
+ * Describes the message sysml.Metaobject.
+ * Use `create(MetaobjectSchema)` to create a new message.
+ */
+export const MetaobjectSchema: GenMessage<Metaobject> = /*@__PURE__*/
+  messageDesc(file_sysml, 60);
+
+/**
+ * Undetermined is the model-level result of an expression the model does not
+ * decide: it reads a feature no value is given (`attribute u;`) or counts a
+ * feature whose multiplicity fixes no count (`part gear[1..*]`). The
+ * expression is well-formed and evaluation succeeded; the answer is open.
+ * Every other surface spells it `<undetermined>`.
+ *
+ * @generated from message sysml.Undetermined
+ */
+export type Undetermined = Message<"sysml.Undetermined"> & {
+  /**
+   * Why the model leaves the result open, for a reader:
+   * "u has no value in the model".
+   *
+   * @generated from field: string reason = 1;
+   */
+  reason: string;
+
+  /**
+   * How many values the result would hold, as far as the model fixes it:
+   * `1..1` for an arithmetic result over an unbound scalar, `1..*` for the
+   * elements of a `[1..*]` feature, `0..*` where nothing is known.
+   *
+   * @generated from field: sysml.MultiplicityInfo count = 2;
+   */
+  count?: MultiplicityInfo | undefined;
+};
+
+/**
+ * Describes the message sysml.Undetermined.
+ * Use `create(UndeterminedSchema)` to create a new message.
+ */
+export const UndeterminedSchema: GenMessage<Undetermined> = /*@__PURE__*/
+  messageDesc(file_sysml, 61);
+
+/**
+ * Function is a calc held as a value: a calc definition, or a calc usage with
+ * an input no read could supply, as `Sq` in `Fn(Sq, 3.0)` or the `f` of
+ * `in calc f {...}`. It crosses as the declaration it is a value of, which is
+ * its identity: two functions are the same exactly when calc_id and self_id
+ * are. A function closing over the bindings of the behavior body it is
+ * declared in has no wire form and crosses as the null arm.
+ *
+ * @generated from message sysml.Function
+ */
+export type Function = Message<"sysml.Function"> & {
+  /**
+   * FQN of the calc declaration ("Analysis::Sq"). Its identity.
+   *
+   * @generated from field: string calc_id = 1;
+   */
+  calcId: string;
+
+  /**
+   * ID of the object the calc's feature names resolve against, for a calc
+   * usage read off a part (`holder.scale`); 0 for a function closing over no
+   * object. An instance id, living only within the response that sent it: a
+   * request function with a non-zero self_id is rejected, since no later call
+   * holds that object.
+   *
+   * @generated from field: int64 self_id = 2;
+   */
+  selfId: bigint;
+};
+
+/**
+ * Describes the message sysml.Function.
+ * Use `create(FunctionSchema)` to create a new message.
+ */
+export const FunctionSchema: GenMessage<Function> = /*@__PURE__*/
+  messageDesc(file_sysml, 62);
+
+/**
+ * ValueSet is a unique, unordered collection — a Collections::Set's elements —
+ * as distinct from a ValueSequence, whose order is part of its value. Two sets
+ * are equal when they hold the same elements in any order. The service sends
+ * the elements in the runtime's canonical order (Booleans, numbers, strings,
+ * quantities, enumeration literals, objects, each class in its own order), so
+ * equal sets cross alike; a client may send them in any order, but sending an
+ * element twice is rejected rather than read as one, since a repeated element
+ * is what a sequence carries.
+ *
+ * @generated from message sysml.ValueSet
+ */
+export type ValueSet = Message<"sysml.ValueSet"> & {
+  /**
+   * @generated from field: repeated sysml.Value elements = 1;
+   */
+  elements: Value[];
+};
+
+/**
+ * Describes the message sysml.ValueSet.
+ * Use `create(ValueSetSchema)` to create a new message.
+ */
+export const ValueSetSchema: GenMessage<ValueSet> = /*@__PURE__*/
+  messageDesc(file_sysml, 63);
+
+/**
+ * TensorQuantity is a Quantities::TensorQuantityValue of any rank: its
+ * dimensions and, flattened in row-major order under them, one Quantity per
+ * component, each with its unit and reduction as a scalar Quantity carries them.
+ * A tensor of rank one is not a VectorQuantity, on the wire as in the runtime.
+ *
+ * @generated from message sysml.TensorQuantity
+ */
+export type TensorQuantity = Message<"sysml.TensorQuantity"> & {
+  /**
+   * Positive extents, one per rank; their product (one for rank 0) is how many
+   * components there are, and a tensor not filling them is rejected.
+   *
+   * @generated from field: repeated int64 dimensions = 1;
+   */
+  dimensions: bigint[];
+
+  /**
+   * A named unit sent without its unit_term is rejected as a Quantity's is.
+   *
+   * @generated from field: repeated sysml.Quantity components = 2;
+   */
+  components: Quantity[];
+};
+
+/**
+ * Describes the message sysml.TensorQuantity.
+ * Use `create(TensorQuantitySchema)` to create a new message.
+ */
+export const TensorQuantitySchema: GenMessage<TensorQuantity> = /*@__PURE__*/
+  messageDesc(file_sysml, 64);
+
+/**
+ * Array is a Collections::Array: its elements flattened in row-major order
+ * under its dimensions, compared by content rather than by the object read.
+ *
+ * @generated from message sysml.Array
+ */
+export type Array = Message<"sysml.Array"> & {
+  /**
+   * Positive extents, one per rank; their product (one for rank 0) is how many
+   * elements there are, and an array not filling them is rejected.
+   *
+   * @generated from field: repeated int64 dimensions = 1;
+   */
+  dimensions: bigint[];
+
+  /**
+   * Any Value each, so an array of quantities or of arrays crosses as such.
+   *
+   * @generated from field: repeated sysml.Value elements = 2;
+   */
+  elements: Value[];
+};
+
+/**
+ * Describes the message sysml.Array.
+ * Use `create(ArraySchema)` to create a new message.
+ */
+export const ArraySchema: GenMessage<Array> = /*@__PURE__*/
+  messageDesc(file_sysml, 65);
+
+/**
+ * Vector is a VectorValues::NumericalVectorValue: its components in order,
+ * its dimension their number.
+ *
+ * @generated from message sysml.Vector
+ */
+export type Vector = Message<"sysml.Vector"> & {
+  /**
+   * Each an int_value or a real_value, kept apart as the rest of Value does;
+   * a component of any other arm is rejected rather than read as a number.
+   *
+   * @generated from field: repeated sysml.Value components = 1;
+   */
+  components: Value[];
+};
+
+/**
+ * Describes the message sysml.Vector.
+ * Use `create(VectorSchema)` to create a new message.
+ */
+export const VectorSchema: GenMessage<Vector> = /*@__PURE__*/
+  messageDesc(file_sysml, 66);
+
+/**
+ * VectorQuantity is a Quantities::VectorQuantityValue: one Quantity per axis,
+ * unit and reduction included, since the axes need not share a unit.
+ *
+ * @generated from message sysml.VectorQuantity
+ */
+export type VectorQuantity = Message<"sysml.VectorQuantity"> & {
+  /**
+   * At least one (num is Number[1..*]); a named unit sent without its
+   * unit_term is rejected as a Quantity's is.
+   *
+   * @generated from field: repeated sysml.Quantity components = 1;
+   */
+  components: Quantity[];
+};
+
+/**
+ * Describes the message sysml.VectorQuantity.
+ * Use `create(VectorQuantitySchema)` to create a new message.
+ */
+export const VectorQuantitySchema: GenMessage<VectorQuantity> = /*@__PURE__*/
+  messageDesc(file_sysml, 67);
+
+/**
+ * Complex is one complex number in rectangular form. It crosses as one value
+ * so `1.0 + 2.0i` cannot be mistaken for a sequence of two Reals.
+ *
+ * @generated from message sysml.Complex
+ */
+export type Complex = Message<"sysml.Complex"> & {
+  /**
+   * @generated from field: double real = 1;
+   */
+  real: number;
+
+  /**
+   * @generated from field: double imaginary = 2;
+   */
+  imaginary: number;
+};
+
+/**
+ * Describes the message sysml.Complex.
+ * Use `create(ComplexSchema)` to create a new message.
+ */
+export const ComplexSchema: GenMessage<Complex> = /*@__PURE__*/
+  messageDesc(file_sysml, 68);
+
+/**
+ * EnumLiteral is one literal of an enumeration definition. A literal is its own
+ * identity, so it travels as the declaration it names rather than as a number
+ * or a string: two values are the same literal exactly when `literal_id` is.
+ *
+ * @generated from message sysml.EnumLiteral
+ */
+export type EnumLiteral = Message<"sysml.EnumLiteral"> & {
+  /**
+   * FQN of the literal's declaration ("D::Color::red"). Its identity.
+   *
+   * @generated from field: string literal_id = 1;
+   */
+  literalId: string;
+
+  /**
+   * FQN of the enumeration definition declaring it ("D::Color").
+   *
+   * @generated from field: string enumeration_id = 2;
+   */
+  enumerationId: string;
+
+  /**
+   * The literal as a reader writes it ("Color::red").
+   *
+   * @generated from field: string name = 3;
+   */
+  name: string;
+
+  /**
+   * The scalar the literal equals (`high = 3` carries int_value 3); unset for a
+   * literal that is only its identity. Identity stays `literal_id`.
+   *
+   * @generated from field: sysml.Value value = 4;
+   */
+  value?: Value | undefined;
+};
+
+/**
+ * Describes the message sysml.EnumLiteral.
+ * Use `create(EnumLiteralSchema)` to create a new message.
+ */
+export const EnumLiteralSchema: GenMessage<EnumLiteral> = /*@__PURE__*/
+  messageDesc(file_sysml, 69);
+
+/**
+ * @generated from message sysml.ValueSequence
+ */
+export type ValueSequence = Message<"sysml.ValueSequence"> & {
+  /**
+   * @generated from field: repeated sysml.Value elements = 1;
+   */
+  elements: Value[];
+};
+
+/**
+ * Describes the message sysml.ValueSequence.
+ * Use `create(ValueSequenceSchema)` to create a new message.
+ */
+export const ValueSequenceSchema: GenMessage<ValueSequence> = /*@__PURE__*/
+  messageDesc(file_sysml, 70);
+
+/**
+ * Quantity is a magnitude and the measurement reference it is expressed in, sent
+ * in the unit written: `5.4 [km/h]` crosses as 5.4 `km/h`, not as 1.5 `m/s`.
+ *
+ * @generated from message sysml.Quantity
+ */
+export type Quantity = Message<"sysml.Quantity"> & {
+  /**
+   * Magnitude, keeping Integer and Real apart as the rest of Value does.
+   *
+   * @generated from oneof sysml.Quantity.magnitude
+   */
+  magnitude: {
+    /**
+     * @generated from field: int64 int_magnitude = 1;
+     */
+    value: bigint;
+    case: "intMagnitude";
+  } | {
+    /**
+     * @generated from field: double real_magnitude = 2;
+     */
+    value: number;
+    case: "realMagnitude";
+  } | { case: undefined; value?: undefined };
+
+  /**
+   * Unit as written ("km/h") or as an operation composed it ("m/s"); empty for
+   * one never written down, described by unit_term alone.
+   *
+   * @generated from field: string unit = 3;
+   */
+  unit: string;
+
+  /**
+   * What the unit reduces to, which decides commensurability and conversion.
+   * Required wherever `unit` names one: a named unit sent without its reduction
+   * is rejected rather than read as dimension one.
+   *
+   * @generated from field: sysml.UnitTerm unit_term = 4;
+   */
+  unitTerm?: UnitTerm | undefined;
+};
+
+/**
+ * Describes the message sysml.Quantity.
+ * Use `create(QuantitySchema)` to create a new message.
+ */
+export const QuantitySchema: GenMessage<Quantity> = /*@__PURE__*/
+  messageDesc(file_sysml, 71);
+
+/**
+ * MeasurementRef is a MeasurementReferences::ScalarMeasurementReference held as
+ * a value: a unit by itself — `SI::m`, `km`, or `m / s` as an operation composed
+ * it — as distinct from a Quantity expressed in one. It carries what the runtime
+ * value carries: the unit as written and what it reduces to, plus the one
+ * declaration it names when it names one.
+ *
+ * @generated from message sysml.MeasurementRef
+ */
+export type MeasurementRef = Message<"sysml.MeasurementRef"> & {
+  /**
+   * Unit as written ("km") or as an operation composed it ("m/s"); empty for
+   * one never written down, described by unit_term alone.
+   *
+   * @generated from field: string unit = 1;
+   */
+  unit: string;
+
+  /**
+   * What the unit reduces to. Required wherever `unit` names one, as a
+   * Quantity's is: a named unit sent without its reduction is rejected rather
+   * than read as dimension one.
+   *
+   * @generated from field: sysml.UnitTerm unit_term = 2;
+   */
+  unitTerm?: UnitTerm | undefined;
+
+  /**
+   * FQN of the one unit declaration the reference names ("SI::kilometre"), which
+   * is what tells the declared unit `km` from any other spelling of 1000 metres;
+   * empty for a unit composed of several ("m/s"), which names no declaration.
+   * The service always sends it for a named unit. A client may omit it, in
+   * which case `unit` is read as a Quantity's is; sent, it must name a
+   * measurement unit of the model that reduces to unit_term and that `unit`
+   * spells, or the value is rejected.
+   *
+   * @generated from field: string unit_id = 3;
+   */
+  unitId: string;
+};
+
+/**
+ * Describes the message sysml.MeasurementRef.
+ * Use `create(MeasurementRefSchema)` to create a new message.
+ */
+export const MeasurementRefSchema: GenMessage<MeasurementRef> = /*@__PURE__*/
+  messageDesc(file_sysml, 72);
+
+/**
+ * UnitTerm is a unit reduced to a scale factor over base units: `km/h` reduces
+ * to 1000/3600 over `SI::m` and `SI::s^-1`.
+ *
+ * @generated from message sysml.UnitTerm
+ */
+export type UnitTerm = Message<"sysml.UnitTerm"> & {
+  /**
+   * Scale as an unevaluated ratio, so an exact conversion stays exact.
+   *
+   * @generated from field: double scale_num = 1;
+   */
+  scaleNum: number;
+
+  /**
+   * @generated from field: double scale_den = 2;
+   */
+  scaleDen: number;
+
+  /**
+   * Base units of the reduction, carrying no zero exponents.
+   *
+   * @generated from field: repeated sysml.UnitFactor factors = 3;
+   */
+  factors: UnitFactor[];
+};
+
+/**
+ * Describes the message sysml.UnitTerm.
+ * Use `create(UnitTermSchema)` to create a new message.
+ */
+export const UnitTermSchema: GenMessage<UnitTerm> = /*@__PURE__*/
+  messageDesc(file_sysml, 73);
+
+/**
+ * UnitFactor is one base unit raised to an exponent.
+ *
+ * @generated from message sysml.UnitFactor
+ */
+export type UnitFactor = Message<"sysml.UnitFactor"> & {
+  /**
+   * FQN of the base unit ("SI::m"), naming it across processes.
+   *
+   * @generated from field: string unit_id = 1;
+   */
+  unitId: string;
+
+  /**
+   * @generated from field: double exponent = 2;
+   */
+  exponent: number;
+};
+
+/**
+ * Describes the message sysml.UnitFactor.
+ * Use `create(UnitFactorSchema)` to create a new message.
+ */
+export const UnitFactorSchema: GenMessage<UnitFactor> = /*@__PURE__*/
+  messageDesc(file_sysml, 74);
+
+/**
+ * Diagnostic represents a parse/semantic error or warning
+ *
+ * @generated from message sysml.Diagnostic
+ */
+export type Diagnostic = Message<"sysml.Diagnostic"> & {
+  /**
+   * "error", "warning", "info"
+   *
+   * @generated from field: string severity = 1;
+   */
+  severity: string;
+
+  /**
+   * @generated from field: string message = 2;
+   */
+  message: string;
+
+  /**
+   * @generated from field: sysml.Span span = 3;
+   */
+  span?: Span | undefined;
+
+  /**
+   * Stable identifier to branch on instead of the message: a pass or rule code,
+   * "syntax", "choice-point", "guard-unevaluable"; empty when none was assigned.
+   *
+   * @generated from field: string code = 4;
+   */
+  code: string;
+};
+
+/**
+ * Describes the message sysml.Diagnostic.
+ * Use `create(DiagnosticSchema)` to create a new message.
+ */
+export const DiagnosticSchema: GenMessage<Diagnostic> = /*@__PURE__*/
+  messageDesc(file_sysml, 75);
+
+/**
+ * Span represents a source location
+ *
+ * @generated from message sysml.Span
+ */
+export type Span = Message<"sysml.Span"> & {
+  /**
+   * @generated from field: string file = 1;
+   */
+  file: string;
+
+  /**
+   * @generated from field: int32 start_line = 2;
+   */
+  startLine: number;
+
+  /**
+   * @generated from field: int32 start_col = 3;
+   */
+  startCol: number;
+
+  /**
+   * @generated from field: int32 end_line = 4;
+   */
+  endLine: number;
+
+  /**
+   * @generated from field: int32 end_col = 5;
+   */
+  endCol: number;
+};
+
+/**
+ * Describes the message sysml.Span.
+ * Use `create(SpanSchema)` to create a new message.
+ */
+export const SpanSchema: GenMessage<Span> = /*@__PURE__*/
+  messageDesc(file_sysml, 76);
+
+/**
+ * ServerInfoRequest asks the service to describe itself. It carries no fields;
+ * the answer does not depend on the caller.
+ *
+ * @generated from message sysml.ServerInfoRequest
+ */
+export type ServerInfoRequest = Message<"sysml.ServerInfoRequest"> & {
+};
+
+/**
+ * Describes the message sysml.ServerInfoRequest.
+ * Use `create(ServerInfoRequestSchema)` to create a new message.
+ */
+export const ServerInfoRequestSchema: GenMessage<ServerInfoRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 77);
+
+/**
+ * ServerInfoResponse describes the running service.
+ *
+ * @generated from message sysml.ServerInfoResponse
+ */
+export type ServerInfoResponse = Message<"sysml.ServerInfoResponse"> & {
+  /**
+   * Build version of the service binary, informational only: it is "dev" for a
+   * build without release ldflags, and versions of forks are not comparable.
+   * Feature decisions belong in `capabilities`.
+   *
+   * @generated from field: string version = 1;
+   */
+  version: string;
+
+  /**
+   * Named capabilities this build supports, each an exact string a client can
+   * require. Capabilities are only ever added, never renamed or removed with
+   * their behaviour intact, so requiring one is stable across releases.
+   * Currently defined:
+   *   "type_facts" - SymbolInfo carries type_info, multiplicity and
+   *                  specializations, which typed code generation needs.
+   *   "convert"    - the Convert RPC writes a model back out as SysML notation
+   *                  or RDF Turtle.
+   *   "verification" - the VerifyConstraint, VerifyRequirement,
+   *                  VerifySatisfaction and EvaluateCalc RPCs answer the
+   *                  questions the REPL's %constraint, %requirement, %satisfy
+   *                  and %calc answer.
+   *   "query"      - the Query RPC evaluates a SysML v2 API & Services Query.
+   *   "oslc_query" - the Query RPC evaluates OSLC Query text.
+   *   "enum_values" - a Value carries an enumeration literal as enum_literal,
+   *                  rather than reporting it as an unsupported null.
+   *   "unset_value" - a valueless feature of a value type is reported as
+   *                  Value.unset, rather than as the empty object it
+   *                  materializes.
+   *   "verification_verdicts" - the VerifyRequirement, VerifySatisfaction and
+   *                  RunAnalysis RPCs report what the body of a verification
+   *                  case answered as verification_verdicts, and RunAnalysis
+   *                  accepts a verification case.
+   *   "complex_values" - a Value carries a complex number as complex, rather
+   *                  than reporting it as an unsupported null, and a complex
+   *                  action input or calc argument is accepted; without it,
+   *                  one is refused with UNIMPLEMENTED rather than read as
+   *                  another value.
+   *   "structured_values" - a Value carries a Collections::Array, a numerical
+   *                  vector and a vector quantity as array, vector and
+   *                  vector_quantity, shape and units intact, rather than
+   *                  reporting them as unsupported nulls, and one is accepted
+   *                  as an action input or calc argument; without it, one is
+   *                  refused with UNIMPLEMENTED rather than read as another
+   *                  value.
+   *   "measurement_refs" - a Value carries a bare measurement reference (a
+   *                  unit by itself, `SI::m` or `m / s`) as measurement_ref,
+   *                  unit text, reduction and declaration intact, rather than
+   *                  reporting it as an unsupported null, and one is accepted
+   *                  as an action input or calc argument; without it, one is
+   *                  refused with UNIMPLEMENTED rather than read as another
+   *                  value. Separate from structured_values, which a client
+   *                  built before this arm existed may already claim.
+   *   "function_values" - a Value carries a calc held as a value as function,
+   *                  named by its declaration, rather than reporting it as an
+   *                  unsupported null, and one is accepted as an action input
+   *                  or calc argument; without it, one is refused with
+   *                  UNIMPLEMENTED rather than read as another value.
+   *   "set_values" - a Value carries a unique, unordered collection (a
+   *                  Collections::Set's elements) as set, each element once in
+   *                  canonical order, rather than reporting it as an
+   *                  unsupported null, and one is accepted as an action input
+   *                  or calc argument in any order; without it, one is refused
+   *                  with UNIMPLEMENTED rather than read as a sequence.
+   *   "tensor_values" - a Value carries a tensor quantity of any rank as
+   *                  tensor_quantity, its dimensions and one Quantity per
+   *                  row-major component, rather than reporting it as an
+   *                  unsupported null, and one is accepted as an action input
+   *                  or calc argument; without it, one is refused with
+   *                  UNIMPLEMENTED rather than read as another value.
+   *   "metaobject_values" - a Value carries an element reflected on as an
+   *                  instance of its metaclass (`x meta T`, the last element
+   *                  of `x.metadata`) as metaobject, named by the element and
+   *                  its metaclass, rather than reporting it as an unsupported
+   *                  null, and one is accepted as an action input or calc
+   *                  argument; without it, one is refused with UNIMPLEMENTED
+   *                  rather than read as another value.
+   *   "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
+   *                  preserving everything the edit did not touch.
+   *   "edit_documents" - ApplyEdits edits a model of several documents as one
+   *                  batch for a request setting accept_documents, targets the
+   *                  document the request names, and answers each edited
+   *                  document by name in `documents`, each referrer of a refusal
+   *                  with its document in `referrers`, and each applied edit's
+   *                  `document`. Without it those fields are empty, a model of
+   *                  several documents is refused with FAILED_PRECONDITION, and
+   *                  a request naming a document is refused with UNIMPLEMENTED.
+   *   "document_query" - the RunDocumentQuery RPC runs a named document query
+   *                  and answers with typed rows.
+   *   "render_document" - the RenderDocument RPC renders a named document to
+   *                  Markdown.
+   *   "diagnostic_codes" - Diagnostic.code is populated, so an empty code is a
+   *                  finding none was assigned; without it every code is empty.
+   *   "schedule"     - ExecuteActionRequest, ExecuteStateRequest and
+   *                  RunAnalysisRequest take a schedule, the scheduling policy
+   *                  the run resolves its choice points under; without it a
+   *                  service drops the field and runs under the default, so a
+   *                  client must not send one.
+   *   "case_evaluations" - RunAnalysis and each RunSweep row report each
+   *                  application the run made of one of the case's calcs as a
+   *                  function value — a trade study's evaluation of each
+   *                  alternative — as evaluations, and keep the outputs and
+   *                  evaluations a failed run made beside its error.
+   *   "schedule_explore" - the schedule "explore[:runs=<n>,depth=<d>]" is
+   *                  accepted, and the response carries every distinct outcome
+   *                  as `outcomes` with an `exploration` status; without it the
+   *                  spelling is INVALID_ARGUMENT.
+   *   "final_time"   - ExecuteActionResponse and ExecuteStateResponse report
+   *                  final_time, the run's simulation clock when it ended;
+   *                  without it the field is 0 whatever the run waited on.
+   *   "engines"      - the ListEngines RPC lists the analysis engines; the
+   *                  verification and sweep requests take an `engine`, the
+   *                  engine the question is put to, unset meaning "auto"; and
+   *                  their responses and every Verdict report the `engine` that
+   *                  answered with the `strength` of its evidence and the
+   *                  `bounds` it ran under. Without it a service drops the
+   *                  request field and answers under "auto", so a client must
+   *                  not send one.
+   *   "engines_external" - the service was started with -serve-external-engines
+   *                  and runs the OPENSYSML_ENGINES manifest engines it names;
+   *                  ListEngines reports which with `served`. Without it every
+   *                  manifest engine is listed but a request naming one is
+   *                  refused with FAILED_PRECONDITION.
+   *
+   * @generated from field: repeated string capabilities = 2;
+   */
+  capabilities: string[];
+};
+
+/**
+ * Describes the message sysml.ServerInfoResponse.
+ * Use `create(ServerInfoResponseSchema)` to create a new message.
+ */
+export const ServerInfoResponseSchema: GenMessage<ServerInfoResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 78);
+
+/**
+ * QueryRequest runs a Query against a model the service already parsed.
+ *
+ * @generated from message sysml.QueryRequest
+ */
+export type QueryRequest = Message<"sysml.QueryRequest"> & {
+  /**
+   * from ParseFile response
+   *
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * @generated from field: sysml.Query query = 2;
+   */
+  query?: Query | undefined;
+
+  /**
+   * OSLC Query 3.0 parameter text, mutually exclusive with query.
+   *
+   * @generated from field: string oslc_query = 3;
+   */
+  oslcQuery: string;
+};
+
+/**
+ * Describes the message sysml.QueryRequest.
+ * Use `create(QueryRequestSchema)` to create a new message.
+ */
+export const QueryRequestSchema: GenMessage<QueryRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 79);
+
+/**
+ * QueryResponse contains the elements the query selected, in the order they are
+ * declared in the model. A query that matches nothing answers with no elements;
+ * a query the service cannot evaluate fails the call with INVALID_ARGUMENT
+ * rather than answering empty.
+ *
+ * @generated from message sysml.QueryResponse
+ */
+export type QueryResponse = Message<"sysml.QueryResponse"> & {
+  /**
+   * @generated from field: repeated sysml.QueryResultElement elements = 1;
+   */
+  elements: QueryResultElement[];
+};
+
+/**
+ * Describes the message sysml.QueryResponse.
+ * Use `create(QueryResponseSchema)` to create a new message.
+ */
+export const QueryResponseSchema: GenMessage<QueryResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 80);
+
+/**
+ * Query is the standard's Query resource (SysML v2 API & Services). Its `@type`
+ * is fixed and carries no information on the wire, and its identity fields
+ * (`@id`, `owningProject`) belong to a repository that stores queries, which
+ * this service is not: it evaluates a query against a parsed model.
+ *
+ * @generated from message sysml.Query
+ */
+export type Query = Message<"sysml.Query"> & {
+  /**
+   * Elements the query considers, each named by qualified name, together with
+   * every element nested inside them. Empty considers the whole loaded model.
+   * Corresponds to the standard's `scope` of DataIdentity references.
+   *
+   * @generated from field: repeated string scope = 1;
+   */
+  scope: string[];
+
+  /**
+   * Properties to report for each matched element. Empty reports all of them.
+   *
+   * @generated from field: repeated string select = 2;
+   */
+  select: string[];
+
+  /**
+   * Filter every considered element must satisfy. Unset matches all of them.
+   *
+   * @generated from field: sysml.Constraint where = 3;
+   */
+  where?: Constraint | undefined;
+};
+
+/**
+ * Describes the message sysml.Query.
+ * Use `create(QuerySchema)` to create a new message.
+ */
+export const QuerySchema: GenMessage<Query> = /*@__PURE__*/
+  messageDesc(file_sysml, 81);
+
+/**
+ * Constraint is the standard's Constraint, whose `@type` discriminates between
+ * its two forms. On the wire the form is the oneof arm, not a type tag.
+ *
+ * @generated from message sysml.Constraint
+ */
+export type Constraint = Message<"sysml.Constraint"> & {
+  /**
+   * @generated from oneof sysml.Constraint.constraint
+   */
+  constraint: {
+    /**
+     * @generated from field: sysml.PrimitiveConstraint primitive = 1;
+     */
+    value: PrimitiveConstraint;
+    case: "primitive";
+  } | {
+    /**
+     * @generated from field: sysml.CompositeConstraint composite = 2;
+     */
+    value: CompositeConstraint;
+    case: "composite";
+  } | { case: undefined; value?: undefined };
+};
+
+/**
+ * Describes the message sysml.Constraint.
+ * Use `create(ConstraintSchema)` to create a new message.
+ */
+export const ConstraintSchema: GenMessage<Constraint> = /*@__PURE__*/
+  messageDesc(file_sysml, 82);
+
+/**
+ * PrimitiveConstraint compares one property of an element against a value.
+ *
+ * @generated from message sysml.PrimitiveConstraint
+ */
+export type PrimitiveConstraint = Message<"sysml.PrimitiveConstraint"> & {
+  /**
+   * Negates the comparison's verdict.
+   *
+   * @generated from field: bool inverse = 1;
+   */
+  inverse: boolean;
+
+  /**
+   * Property to compare, as named in docs/reference/api.md's query property table
+   * ("@type", "name", "qualifiedName", ...). An unknown one fails the call.
+   *
+   * @generated from field: string property = 2;
+   */
+  property: string;
+
+  /**
+   * @generated from field: sysml.PrimitiveOperator operator = 3;
+   */
+  operator: PrimitiveOperator;
+
+  /**
+   * Values to compare against. The standard writes one value, and its clients
+   * also write a list for `@type`; with `=` the element matches any of them,
+   * while `>` and `<` require exactly one.
+   *
+   * @generated from field: repeated string value = 4;
+   */
+  value: string[];
+};
+
+/**
+ * Describes the message sysml.PrimitiveConstraint.
+ * Use `create(PrimitiveConstraintSchema)` to create a new message.
+ */
+export const PrimitiveConstraintSchema: GenMessage<PrimitiveConstraint> = /*@__PURE__*/
+  messageDesc(file_sysml, 83);
+
+/**
+ * CompositeConstraint combines constraints. An empty constraint list fails the
+ * call: it has no defensible verdict.
+ *
+ * @generated from message sysml.CompositeConstraint
+ */
+export type CompositeConstraint = Message<"sysml.CompositeConstraint"> & {
+  /**
+   * @generated from field: sysml.CompositeOperator operator = 1;
+   */
+  operator: CompositeOperator;
+
+  /**
+   * @generated from field: repeated sysml.Constraint constraint = 2;
+   */
+  constraint: Constraint[];
+};
+
+/**
+ * Describes the message sysml.CompositeConstraint.
+ * Use `create(CompositeConstraintSchema)` to create a new message.
+ */
+export const CompositeConstraintSchema: GenMessage<CompositeConstraint> = /*@__PURE__*/
+  messageDesc(file_sysml, 84);
+
+/**
+ * QueryResultElement is one matched element. `id` and `type` are always
+ * reported, since an element record is not usable without them; `properties`
+ * carries what `select` asked for, omitting a property the element does not
+ * have.
+ *
+ * @generated from message sysml.QueryResultElement
+ */
+export type QueryResultElement = Message<"sysml.QueryResultElement"> & {
+  /**
+   * qualified name, the identity this service reports
+   *
+   * @generated from field: string id = 1;
+   */
+  id: string;
+
+  /**
+   * metamodel type name ("PartUsage", "PartDefinition", ...)
+   *
+   * @generated from field: string type = 2;
+   */
+  type: string;
+
+  /**
+   * @generated from field: map<string, string> properties = 3;
+   */
+  properties: { [key: string]: string };
+};
+
+/**
+ * Describes the message sysml.QueryResultElement.
+ * Use `create(QueryResultElementSchema)` to create a new message.
+ */
+export const QueryResultElementSchema: GenMessage<QueryResultElement> = /*@__PURE__*/
+  messageDesc(file_sysml, 85);
+
+/**
+ * SweepRange is one parameter's range: the endpoints a swept run advances
+ * between and the step it advances by, typed by the parameter it binds rather
+ * than by how they are written. A sampled range states no step; a swept range
+ * between whole numbers steps by one where it states none, and one with a
+ * fractional endpoint must state one.
+ *
+ * @generated from message sysml.SweepRange
+ */
+export type SweepRange = Message<"sysml.SweepRange"> & {
+  /**
+   * Name of the input parameter the range binds, which the target must declare
+   * and the request's own arguments must not bind.
+   *
+   * @generated from field: string parameter = 1;
+   */
+  parameter: string;
+
+  /**
+   * The endpoint the range's runs start at, and the one they run to, which is
+   * inclusive where the step lands on it. Both are required.
+   *
+   * @generated from field: sysml.Value start = 2;
+   */
+  start?: Value | undefined;
+
+  /**
+   * @generated from field: sysml.Value end = 3;
+   */
+  end?: Value | undefined;
+
+  /**
+   * @generated from field: sysml.Value step = 4;
+   */
+  step?: Value | undefined;
+};
+
+/**
+ * Describes the message sysml.SweepRange.
+ * Use `create(SweepRangeSchema)` to create a new message.
+ */
+export const SweepRangeSchema: GenMessage<SweepRange> = /*@__PURE__*/
+  messageDesc(file_sysml, 86);
+
+/**
+ * RunSweepRequest runs one analysis case or calc once per row of a sweep. Every
+ * row is an ordinary run of that target with the swept parameters bound to the
+ * row's values and the request's other arguments as given: this is tool-defined
+ * orchestration, and changes nothing about how a run executes.
+ *
+ * @generated from message sysml.RunSweepRequest
+ */
+export type RunSweepRequest = Message<"sysml.RunSweepRequest"> & {
+  /**
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * FQN of the analysis case or calc, definition or usage.
+   *
+   * @generated from field: string symbol_id = 2;
+   */
+  symbolId: string;
+
+  /**
+   * Optional FQN of a part/usage to instantiate as an analysis case's subject,
+   * or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
+   *
+   * @generated from field: string subject_symbol_id = 3;
+   */
+  subjectSymbolId: string;
+
+  /**
+   * Positional arguments for the target's input parameters, in declaration
+   * order, as every row binds them.
+   *
+   * @generated from field: repeated sysml.Value arguments = 4;
+   */
+  arguments: Value[];
+
+  /**
+   * Arguments bound to input parameters by name, as every row binds them.
+   *
+   * @generated from field: map<string, sysml.Value> named_arguments = 5;
+   */
+  namedArguments: { [key: string]: Value };
+
+  /**
+   * The ranges swept, which several of make one row per point of their
+   * cartesian product, the first varying slowest.
+   *
+   * @generated from field: repeated sysml.SweepRange ranges = 6;
+   */
+  ranges: SweepRange[];
+
+  /**
+   * Rows to draw uniformly from each range instead of stepping through it. 0
+   * steps through them; above 0 needs a seed and refuses a range with a step.
+   *
+   * @generated from field: int64 samples = 7;
+   */
+  samples: bigint;
+
+  /**
+   * Seed the draws are taken from: the same seed draws the same table on every
+   * platform. Required with `samples`, ignored without it.
+   *
+   * @generated from field: uint64 seed = 8;
+   */
+  seed: bigint;
+
+  /**
+   * The engine the sweep is put to; see VerifyConstraintRequest.engine.
+   *
+   * @generated from field: string engine = 9;
+   */
+  engine: string;
+};
+
+/**
+ * Describes the message sysml.RunSweepRequest.
+ * Use `create(RunSweepRequestSchema)` to create a new message.
+ */
+export const RunSweepRequestSchema: GenMessage<RunSweepRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 87);
+
+/**
+ * SweepRow is one run of a sweep: what it bound, what it produced, how long it
+ * took, and what stopped it where it failed. A failed row is reported as a row,
+ * so one failure does not end the table.
+ *
+ * @generated from message sysml.SweepRow
+ */
+export type SweepRow = Message<"sysml.SweepRow"> & {
+  /**
+   * The parameters this row bound, in the order their ranges were given.
+   *
+   * @generated from field: repeated sysml.CalcOutput inputs = 1;
+   */
+  inputs: CalcOutput[];
+
+  /**
+   * What the run produced: an analysis case's outputs, or a calc's returned
+   * value named "result".
+   *
+   * @generated from field: repeated sysml.CalcOutput outputs = 2;
+   */
+  outputs: CalcOutput[];
+
+  /**
+   * What the case's objective and assertions decided; empty for a calc.
+   *
+   * @generated from field: repeated sysml.Verdict verdicts = 3;
+   */
+  verdicts: Verdict[];
+
+  /**
+   * Wall time of this run, in microseconds.
+   *
+   * @generated from field: int64 elapsed_micros = 4;
+   */
+  elapsedMicros: bigint;
+
+  /**
+   * Set when this run failed rather than the table failing. The outputs and
+   * evaluations the run made before failing are still reported beside it.
+   *
+   * @generated from field: string error = 5;
+   */
+  error: string;
+
+  /**
+   * What kind of failure `error` reports.
+   *
+   * @generated from field: sysml.FailureReason failure_reason = 6;
+   */
+  failureReason: FailureReason;
+
+  /**
+   * Each application this run made of one of the case's own calcs as a
+   * function value, as RunAnalysisResponse.evaluations reports them.
+   *
+   * @generated from field: repeated sysml.CaseEvaluation evaluations = 7;
+   */
+  evaluations: CaseEvaluation[];
+};
+
+/**
+ * Describes the message sysml.SweepRow.
+ * Use `create(SweepRowSchema)` to create a new message.
+ */
+export const SweepRowSchema: GenMessage<SweepRow> = /*@__PURE__*/
+  messageDesc(file_sysml, 88);
+
+/**
+ * RunSweepResponse carries the table, one row per run, in the order the runs
+ * were made: lexicographically over the ranges as given for a swept table, and
+ * in draw order for a sampled one.
+ *
+ * @generated from message sysml.RunSweepResponse
+ */
+export type RunSweepResponse = Message<"sysml.RunSweepResponse"> & {
+  /**
+   * @generated from field: repeated sysml.SweepRow rows = 1;
+   */
+  rows: SweepRow[];
+
+  /**
+   * The swept parameters, in the order their ranges were given, which is the
+   * order each row's inputs are in.
+   *
+   * @generated from field: repeated string parameters = 2;
+   */
+  parameters: string[];
+
+  /**
+   * Whether the rows were drawn rather than stepped through.
+   *
+   * @generated from field: bool sampled = 3;
+   */
+  sampled: boolean;
+
+  /**
+   * The seed the rows were drawn from, echoed so a table can be reproduced.
+   *
+   * @generated from field: uint64 seed = 4;
+   */
+  seed: bigint;
+
+  /**
+   * Set when no run was made at all — an unknown symbol, a parameter the target
+   * does not declare, a range no values follow from, a budget refusal.
+   *
+   * @generated from field: string error = 5;
+   */
+  error: string;
+
+  /**
+   * @generated from field: repeated sysml.Diagnostic diagnostics = 6;
+   */
+  diagnostics: Diagnostic[];
+
+  /**
+   * What kind of failure `error` reports.
+   *
+   * @generated from field: sysml.FailureReason failure_reason = 7;
+   */
+  failureReason: FailureReason;
+
+  /**
+   * Every object a row's verdict is about, from every run of the table, so each
+   * verdict's `instance_id` resolves here.
+   *
+   * @generated from field: repeated sysml.Instance instances = 8;
+   */
+  instances: Instance[];
+
+  /**
+   * The engine that ran the table, the strength of its evidence and the bounds
+   * it ran under, as Verdict carries them; each row's verdicts carry the same.
+   * Empty when no run was made. Reported as the "engines" capability.
+   *
+   * @generated from field: string engine = 9;
+   */
+  engine: string;
+
+  /**
+   * @generated from field: string strength = 10;
+   */
+  strength: string;
+
+  /**
+   * @generated from field: repeated sysml.Bound bounds = 11;
+   */
+  bounds: Bound[];
+};
+
+/**
+ * Describes the message sysml.RunSweepResponse.
+ * Use `create(RunSweepResponseSchema)` to create a new message.
+ */
+export const RunSweepResponseSchema: GenMessage<RunSweepResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 89);
+
+/**
+ * RunDocumentQueryRequest runs a named document query — a calc def
+ * specializing DocumentQueries::Query — against a model the service already
+ * parsed, binding its entry parameters. It answers as %run-query does, but with
+ * typed rows rather than formatted lines. The query runs over the objects
+ * Instantiate created for the model, which a binding may name (DocumentObject)
+ * and `DocumentQueries::Objects` and `Verdicts` reach.
+ *
+ * @generated from message sysml.RunDocumentQueryRequest
+ */
+export type RunDocumentQueryRequest = Message<"sysml.RunDocumentQueryRequest"> & {
+  /**
+   * from ParseFile response
+   *
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * FQN of the document query to run. Fails with NOT_FOUND when the model does
+   * not declare it, and INVALID_ARGUMENT when it declares something else.
+   *
+   * @generated from field: string query_id = 2;
+   */
+  queryId: string;
+
+  /**
+   * Entry-parameter bindings. Repeating a parameter appends to its binding, so
+   * a nonscalar parameter can be given several values.
+   *
+   * @generated from field: repeated sysml.DocumentQueryBinding bindings = 3;
+   */
+  bindings: DocumentQueryBinding[];
+};
+
+/**
+ * Describes the message sysml.RunDocumentQueryRequest.
+ * Use `create(RunDocumentQueryRequestSchema)` to create a new message.
+ */
+export const RunDocumentQueryRequestSchema: GenMessage<RunDocumentQueryRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 90);
+
+/**
+ * DocumentQueryBinding binds one entry parameter of a document query.
+ *
+ * @generated from message sysml.DocumentQueryBinding
+ */
+export type DocumentQueryBinding = Message<"sysml.DocumentQueryBinding"> & {
+  /**
+   * @generated from field: string parameter = 1;
+   */
+  parameter: string;
+
+  /**
+   * @generated from field: repeated sysml.DocumentValue values = 2;
+   */
+  values: DocumentValue[];
+};
+
+/**
+ * Describes the message sysml.DocumentQueryBinding.
+ * Use `create(DocumentQueryBindingSchema)` to create a new message.
+ */
+export const DocumentQueryBindingSchema: GenMessage<DocumentQueryBinding> = /*@__PURE__*/
+  messageDesc(file_sysml, 91);
+
+/**
+ * DocumentValue is one typed document-query value. A request binds a model
+ * element by qualified name in element_id, or an object the service holds in
+ * object; a response also says what the element is in element_type. `infinity`
+ * denotes an unbounded multiplicity and is only ever answered, never bound.
+ *
+ * @generated from message sysml.DocumentValue
+ */
+export type DocumentValue = Message<"sysml.DocumentValue"> & {
+  /**
+   * @generated from oneof sysml.DocumentValue.kind
+   */
+  kind: {
+    /**
+     * qualified name of a model element
+     *
+     * @generated from field: string element_id = 1;
+     */
+    value: string;
+    case: "elementId";
+  } | {
+    /**
+     * @generated from field: string string_value = 2;
+     */
+    value: string;
+    case: "stringValue";
+  } | {
+    /**
+     * @generated from field: int64 int_value = 3;
+     */
+    value: bigint;
+    case: "intValue";
+  } | {
+    /**
+     * @generated from field: double real_value = 4;
+     */
+    value: number;
+    case: "realValue";
+  } | {
+    /**
+     * @generated from field: bool bool_value = 5;
+     */
+    value: boolean;
+    case: "boolValue";
+  } | {
+    /**
+     * @generated from field: bool infinity = 6;
+     */
+    value: boolean;
+    case: "infinity";
+  } | {
+    /**
+     * magnitude in a unit, `2290000 [kg]`
+     *
+     * @generated from field: sysml.Quantity quantity = 8;
+     */
+    value: Quantity;
+    case: "quantity";
+  } | {
+    /**
+     * a row Verdicts answered; answered, never bound
+     *
+     * @generated from field: sysml.DocumentVerdict verdict = 9;
+     */
+    value: DocumentVerdict;
+    case: "verdict";
+  } | {
+    /**
+     * an object Instantiate created; bound and answered
+     *
+     * @generated from field: sysml.DocumentObject object = 10;
+     */
+    value: DocumentObject;
+    case: "object";
+  } | {
+    /**
+     * a row States answered; answered, never bound
+     *
+     * @generated from field: sysml.DocumentState state = 11;
+     */
+    value: DocumentState;
+    case: "state";
+  } | {
+    /**
+     * a row Events answered; answered, never bound
+     *
+     * @generated from field: sysml.DocumentEvent event = 12;
+     */
+    value: DocumentEvent;
+    case: "event";
+  } | { case: undefined; value?: undefined };
+
+  /**
+   * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
+   *
+   * @generated from field: string element_type = 7;
+   */
+  elementType: string;
+};
+
+/**
+ * Describes the message sysml.DocumentValue.
+ * Use `create(DocumentValueSchema)` to create a new message.
+ */
+export const DocumentValueSchema: GenMessage<DocumentValue> = /*@__PURE__*/
+  messageDesc(file_sysml, 92);
+
+/**
+ * DocumentObject is an object the service holds for the model, created by
+ * Instantiate, as a query binds and answers it. A request names it by path
+ * when path is set, and by instance_id otherwise; a request setting both must
+ * name one object by both. A response answers both, and the element the object
+ * stands for.
+ *
+ * @generated from message sysml.DocumentObject
+ */
+export type DocumentObject = Message<"sysml.DocumentObject"> & {
+  /**
+   * The object's id, as Instantiate answered it. Fails with NOT_FOUND when no
+   * object of the model has it.
+   *
+   * @generated from field: int64 instance_id = 1;
+   */
+  instanceId: bigint;
+
+  /**
+   * The object by the label a session reaches it under: the qualified name it
+   * was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+   * feature values of either (`Garage::car.wheels[2]`, `#2.wheels[2]`; indexes
+   * count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+   * a segment reaches no object, and NOT_FOUND when the name it starts from has
+   * no object.
+   *
+   * @generated from field: string path = 2;
+   */
+  path: string;
+
+  /**
+   * The usage the object is held under — its own definition or usage for an
+   * object Instantiate created — as an element value with its qualified name
+   * and metamodel type. Answered, ignored when bound.
+   *
+   * @generated from field: sysml.DocumentValue element = 3;
+   */
+  element?: DocumentValue | undefined;
+};
+
+/**
+ * Describes the message sysml.DocumentObject.
+ * Use `create(DocumentObjectSchema)` to create a new message.
+ */
+export const DocumentObjectSchema: GenMessage<DocumentObject> = /*@__PURE__*/
+  messageDesc(file_sysml, 93);
+
+/**
+ * DocumentVerdict is one row a `Verdicts` query answered: an assertion checked
+ * on the object at `path`, which the row stands for as its `element`.
+ *
+ * @generated from message sysml.DocumentVerdict
+ */
+export type DocumentVerdict = Message<"sysml.DocumentVerdict"> & {
+  /**
+   * The assertion checked, as an element value: the constraint, requirement,
+   * satisfy usage or verification case, its element_id empty when anonymous.
+   *
+   * @generated from field: sysml.DocumentValue assertion = 1;
+   */
+  assertion?: DocumentValue | undefined;
+
+  /**
+   * "constraint", "requirement", "satisfaction" or "verification".
+   *
+   * @generated from field: string kind = 2;
+   */
+  kind: string;
+
+  /**
+   * The assertion as written ("assert constraint massKnown", "satisfy Range by
+   * cruise", "verification Tests::massTest"), naming an anonymous one.
+   *
+   * @generated from field: string text = 3;
+   */
+  text: string;
+
+  /**
+   * The object checked, by path from the element the query was bound to
+   * ("Garage::car.wheels[2]"); the element's own qualified name for itself.
+   *
+   * @generated from field: string path = 4;
+   */
+  path: string;
+
+  /**
+   * "holds", "violated" or "undecided".
+   *
+   * @generated from field: string verdict = 5;
+   */
+  verdict: string;
+
+  /**
+   * The condition that evaluated to false, as written; empty otherwise.
+   *
+   * @generated from field: string condition = 6;
+   */
+  condition: string;
+
+  /**
+   * Why the assertion is violated or undecided; empty when it holds.
+   *
+   * @generated from field: string reason = 7;
+   */
+  reason: string;
+
+  /**
+   * The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+   * verification cases verifying the requirement the row is about; a
+   * verification row's own kind. Empty for a constraint.
+   *
+   * @generated from field: repeated string verification = 8;
+   */
+  verification: string[];
+};
+
+/**
+ * Describes the message sysml.DocumentVerdict.
+ * Use `create(DocumentVerdictSchema)` to create a new message.
+ */
+export const DocumentVerdictSchema: GenMessage<DocumentVerdict> = /*@__PURE__*/
+  messageDesc(file_sysml, 94);
+
+/**
+ * DocumentState is one row a `States` query answered: an active leaf state of
+ * the state machine `object` exhibits, which the row stands for as its `element`
+ * when the model declares it.
+ *
+ * @generated from message sysml.DocumentState
+ */
+export type DocumentState = Message<"sysml.DocumentState"> & {
+  /**
+   * The object in the state, with the path the session reaches it by.
+   *
+   * @generated from field: sysml.DocumentObject object = 1;
+   */
+  object?: DocumentObject | undefined;
+
+  /**
+   * The state machine, by the name the object exhibits it under (`lp`), else
+   * its declared name.
+   *
+   * @generated from field: string machine = 2;
+   */
+  machine: string;
+
+  /**
+   * The leaf state's name.
+   *
+   * @generated from field: string name = 3;
+   */
+  name: string;
+
+  /**
+   * The leaf state's path in its machine, composite states first (`on.dim`).
+   *
+   * @generated from field: string state_path = 4;
+   */
+  statePath: string;
+
+  /**
+   * The leaf state's declaration as an element value; empty when the machine
+   * declares no element for it.
+   *
+   * @generated from field: sysml.DocumentValue state = 5;
+   */
+  state?: DocumentValue | undefined;
+
+  /**
+   * The orthogonal region the leaf is declared in; empty outside one.
+   *
+   * @generated from field: string region = 6;
+   */
+  region: string;
+
+  /**
+   * The composite states enclosing the leaf, outermost first; each is active.
+   *
+   * @generated from field: repeated string enclosing = 7;
+   */
+  enclosing: string[];
+};
+
+/**
+ * Describes the message sysml.DocumentState.
+ * Use `create(DocumentStateSchema)` to create a new message.
+ */
+export const DocumentStateSchema: GenMessage<DocumentState> = /*@__PURE__*/
+  messageDesc(file_sysml, 95);
+
+/**
+ * DocumentEvent is one row an `Events` query answered: one record of the
+ * session's trace, in the order the run made it.
+ *
+ * @generated from message sysml.DocumentEvent
+ */
+export type DocumentEvent = Message<"sysml.DocumentEvent"> & {
+  /**
+   * "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+   *
+   * @generated from field: string kind = 1;
+   */
+  kind: string;
+
+  /**
+   * The clock's instant when the record was made: a quantity in the clock's
+   * unit when the library reduces one, else a bare real of clock units.
+   *
+   * @generated from field: sysml.DocumentValue time = 2;
+   */
+  time?: DocumentValue | undefined;
+
+  /**
+   * The object whose behavior made the record; unset for the run's own
+   * records (a due-order choice, a message posted from outside the model).
+   *
+   * @generated from field: sysml.DocumentObject object = 3;
+   */
+  object?: DocumentObject | undefined;
+
+  /**
+   * The behavior the record is about, by the name the object exhibits it
+   * under, else its declared name; empty for the run's own records.
+   *
+   * @generated from field: string machine = 4;
+   */
+  machine: string;
+
+  /**
+   * The state entered, exited or stepped by an entry, exit or do record.
+   *
+   * @generated from field: string state = 5;
+   */
+  state: string;
+
+  /**
+   * A fired transition's source and target states.
+   *
+   * @generated from field: string from = 6;
+   */
+  from: string;
+
+  /**
+   * @generated from field: string to = 7;
+   */
+  to: string;
+
+  /**
+   * The object a send was addressed to; unset for every other kind and a
+   * send addressed to no object.
+   *
+   * @generated from field: sysml.DocumentObject target = 8;
+   */
+  target?: DocumentObject | undefined;
+
+  /**
+   * The signal or event accepted or sent, or the trigger a transition fired on.
+   *
+   * @generated from field: string event = 9;
+   */
+  event: string;
+
+  /**
+   * An accept's payload, one `name = value` entry per parameter in name order.
+   *
+   * @generated from field: repeated string payload = 10;
+   */
+  payload: string[];
+
+  /**
+   * A choice's alternatives, in the order they were offered, and the one taken.
+   *
+   * @generated from field: repeated string alternatives = 11;
+   */
+  alternatives: string[];
+
+  /**
+   * @generated from field: string taken = 12;
+   */
+  taken: string;
+
+  /**
+   * The line the trace prints for the record.
+   *
+   * @generated from field: string text = 13;
+   */
+  text: string;
+};
+
+/**
+ * Describes the message sysml.DocumentEvent.
+ * Use `create(DocumentEventSchema)` to create a new message.
+ */
+export const DocumentEventSchema: GenMessage<DocumentEvent> = /*@__PURE__*/
+  messageDesc(file_sysml, 96);
+
+/**
+ * DocumentQueryColumn is one projected property, in projection order.
+ *
+ * @generated from message sysml.DocumentQueryColumn
+ */
+export type DocumentQueryColumn = Message<"sysml.DocumentQueryColumn"> & {
+  /**
+   * @generated from field: string name = 1;
+   */
+  name: string;
+};
+
+/**
+ * Describes the message sysml.DocumentQueryColumn.
+ * Use `create(DocumentQueryColumnSchema)` to create a new message.
+ */
+export const DocumentQueryColumnSchema: GenMessage<DocumentQueryColumn> = /*@__PURE__*/
+  messageDesc(file_sysml, 97);
+
+/**
+ * DocumentQueryCell is one row's values for one column, in the query's order.
+ *
+ * @generated from message sysml.DocumentQueryCell
+ */
+export type DocumentQueryCell = Message<"sysml.DocumentQueryCell"> & {
+  /**
+   * @generated from field: repeated sysml.DocumentValue values = 1;
+   */
+  values: DocumentValue[];
+};
+
+/**
+ * Describes the message sysml.DocumentQueryCell.
+ * Use `create(DocumentQueryCellSchema)` to create a new message.
+ */
+export const DocumentQueryCellSchema: GenMessage<DocumentQueryCell> = /*@__PURE__*/
+  messageDesc(file_sysml, 98);
+
+/**
+ * DocumentQueryRow is one selected element and its projected cells, one per
+ * column in column order.
+ *
+ * @generated from message sysml.DocumentQueryRow
+ */
+export type DocumentQueryRow = Message<"sysml.DocumentQueryRow"> & {
+  /**
+   * The selected element itself, an element value with its qualified name; an
+   * object value for a row over an object the service holds; a verdict value
+   * for a row `Verdicts` answered; a state value for a row `States` answered;
+   * an event value for a row `Events` answered.
+   *
+   * @generated from field: sysml.DocumentValue element = 1;
+   */
+  element?: DocumentValue | undefined;
+
+  /**
+   * @generated from field: repeated sysml.DocumentQueryCell cells = 2;
+   */
+  cells: DocumentQueryCell[];
+};
+
+/**
+ * Describes the message sysml.DocumentQueryRow.
+ * Use `create(DocumentQueryRowSchema)` to create a new message.
+ */
+export const DocumentQueryRowSchema: GenMessage<DocumentQueryRow> = /*@__PURE__*/
+  messageDesc(file_sysml, 99);
+
+/**
+ * RunDocumentQueryResponse is the query's answer: its projected columns and its
+ * rows, both in the deterministic order the engine reports. A query that
+ * selects nothing answers with no rows; a query that could not be run fails the
+ * call with the status its failure maps to.
+ *
+ * @generated from message sysml.RunDocumentQueryResponse
+ */
+export type RunDocumentQueryResponse = Message<"sysml.RunDocumentQueryResponse"> & {
+  /**
+   * @generated from field: repeated sysml.DocumentQueryColumn columns = 1;
+   */
+  columns: DocumentQueryColumn[];
+
+  /**
+   * @generated from field: repeated sysml.DocumentQueryRow rows = 2;
+   */
+  rows: DocumentQueryRow[];
+};
+
+/**
+ * Describes the message sysml.RunDocumentQueryResponse.
+ * Use `create(RunDocumentQueryResponseSchema)` to create a new message.
+ */
+export const RunDocumentQueryResponseSchema: GenMessage<RunDocumentQueryResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 100);
+
+/**
+ * RenderDocumentRequest renders a named document — a part def specializing
+ * DocumentQueries::Document — to Markdown or HTML. A document binds its
+ * queries' parameters in the model, so the request carries none.
+ *
+ * @generated from message sysml.RenderDocumentRequest
+ */
+export type RenderDocumentRequest = Message<"sysml.RenderDocumentRequest"> & {
+  /**
+   * from ParseFile response
+   *
+   * @generated from field: string model_hash = 1;
+   */
+  modelHash: string;
+
+  /**
+   * FQN of the document to render. Fails with NOT_FOUND when the model does
+   * not declare it, and INVALID_ARGUMENT when it declares something else.
+   *
+   * @generated from field: string document_id = 2;
+   */
+  documentId: string;
+
+  /**
+   * Form to render: "markdown" (the default when empty) or "html", the
+   * standalone page with the default stylesheet that the CLI's -doc-form html
+   * writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+   * converter toolchain and is not offered here.
+   *
+   * @generated from field: string form = 3;
+   */
+  form: string;
+};
+
+/**
+ * Describes the message sysml.RenderDocumentRequest.
+ * Use `create(RenderDocumentRequestSchema)` to create a new message.
+ */
+export const RenderDocumentRequestSchema: GenMessage<RenderDocumentRequest> = /*@__PURE__*/
+  messageDesc(file_sysml, 101);
+
+/**
+ * RenderDocumentResponse carries the rendered document in the form requested,
+ * byte-for-byte what the CLI's -render-document writes: markdown for the
+ * Markdown form, html for the HTML form, the other left empty.
+ *
+ * @generated from message sysml.RenderDocumentResponse
+ */
+export type RenderDocumentResponse = Message<"sysml.RenderDocumentResponse"> & {
+  /**
+   * @generated from field: string markdown = 1;
+   */
+  markdown: string;
+
+  /**
+   * @generated from field: string html = 2;
+   */
+  html: string;
+};
+
+/**
+ * Describes the message sysml.RenderDocumentResponse.
+ * Use `create(RenderDocumentResponseSchema)` to create a new message.
+ */
+export const RenderDocumentResponseSchema: GenMessage<RenderDocumentResponse> = /*@__PURE__*/
+  messageDesc(file_sysml, 102);
+
+/**
+ * FailureReason says what kind of failure an `error` reports, so a client acts
+ * on the kind rather than on the message text.
+ *
+ * @generated from enum sysml.FailureReason
+ */
+export enum FailureReason {
+  /**
+   * no failure, or one not classified
+   *
+   * @generated from enum value: FAILURE_REASON_UNSPECIFIED = 0;
+   */
+  UNSPECIFIED = 0,
+
+  /**
+   * the condition or calculation could not be evaluated
+   *
+   * @generated from enum value: FAILURE_REASON_EVALUATION = 1;
+   */
+  EVALUATION = 1,
+
+  /**
+   * the symbol named declares something else
+   *
+   * @generated from enum value: FAILURE_REASON_WRONG_KIND = 2;
+   */
+  WRONG_KIND = 2,
+
+  /**
+   * several objects carry the element; name one as the subject
+   *
+   * @generated from enum value: FAILURE_REASON_AMBIGUOUS_SUBJECT = 3;
+   */
+  AMBIGUOUS_SUBJECT = 3,
+}
+
+/**
+ * Describes the enum sysml.FailureReason.
+ */
+export const FailureReasonSchema: GenEnum<FailureReason> = /*@__PURE__*/
+  enumDesc(file_sysml, 0);
+
+/**
+ * EditFailure says why edits were refused. Every refusal is one of these: an
+ * edit is never silently dropped.
+ *
+ * @generated from enum sysml.EditFailure
+ */
+export enum EditFailure {
+  /**
+   * no failure
+   *
+   * @generated from enum value: EDIT_FAILURE_UNSPECIFIED = 0;
+   */
+  UNSPECIFIED = 0,
+
+  /**
+   * the request named no edit
+   *
+   * @generated from enum value: EDIT_FAILURE_NO_OPERATIONS = 1;
+   */
+  NO_OPERATIONS = 1,
+
+  /**
+   * no element of that name in the model
+   *
+   * @generated from enum value: EDIT_FAILURE_UNKNOWN_TARGET = 2;
+   */
+  UNKNOWN_TARGET = 2,
+
+  /**
+   * the name denotes several declarations
+   *
+   * @generated from enum value: EDIT_FAILURE_AMBIGUOUS_TARGET = 3;
+   */
+  AMBIGUOUS_TARGET = 3,
+
+  /**
+   * the element can carry no value
+   *
+   * @generated from enum value: EDIT_FAILURE_NOT_VALUED = 4;
+   */
+  NOT_VALUED = 4,
+
+  /**
+   * the new value does not parse as an expression
+   *
+   * @generated from enum value: EDIT_FAILURE_INVALID_VALUE = 5;
+   */
+  INVALID_VALUE = 5,
+
+  /**
+   * the new name does not lex as an identifier
+   *
+   * @generated from enum value: EDIT_FAILURE_INVALID_NAME = 6;
+   */
+  INVALID_NAME = 6,
+
+  /**
+   * the element declares no name to rewrite
+   *
+   * @generated from enum value: EDIT_FAILURE_NOT_NAMED = 7;
+   */
+  NOT_NAMED = 7,
+
+  /**
+   * references to the element would break
+   *
+   * @generated from enum value: EDIT_FAILURE_RENAME_REFERENCED = 8;
+   */
+  RENAME_REFERENCED = 8,
+
+  /**
+   * two edits cover the same bytes
+   *
+   * @generated from enum value: EDIT_FAILURE_OVERLAPPING_EDITS = 9;
+   */
+  OVERLAPPING_EDITS = 9,
+
+  /**
+   * the edited source has errors the original had not
+   *
+   * @generated from enum value: EDIT_FAILURE_RESULT_INVALID = 10;
+   */
+  RESULT_INVALID = 10,
+
+  /**
+   * add-member owner does not exist
+   *
+   * @generated from enum value: EDIT_FAILURE_OWNER_UNKNOWN = 11;
+   */
+  OWNER_UNKNOWN = 11,
+
+  /**
+   * owner cannot contain members
+   *
+   * @generated from enum value: EDIT_FAILURE_OWNER_NOT_NAMESPACE = 12;
+   */
+  OWNER_NOT_NAMESPACE = 12,
+
+  /**
+   * kind is invalid for document language
+   *
+   * @generated from enum value: EDIT_FAILURE_ILLEGAL_KIND = 13;
+   */
+  ILLEGAL_KIND = 13,
+
+  /**
+   * owner already declares the name
+   *
+   * @generated from enum value: EDIT_FAILURE_MEMBER_NAME_TAKEN = 14;
+   */
+  MEMBER_NAME_TAKEN = 14,
+
+  /**
+   * delete would leave references
+   *
+   * @generated from enum value: EDIT_FAILURE_DELETE_REFERENCED = 15;
+   */
+  DELETE_REFERENCED = 15,
+
+  /**
+   * move owner is the target or inside it
+   *
+   * @generated from enum value: EDIT_FAILURE_OWNER_INSIDE_TARGET = 16;
+   */
+  OWNER_INSIDE_TARGET = 16,
+
+  /**
+   * move would leave a reference no spelling restores
+   *
+   * @generated from enum value: EDIT_FAILURE_MOVE_REFERENCED = 17;
+   */
+  MOVE_REFERENCED = 17,
+
+  /**
+   * the target is referred to from a document the edit cannot rewrite: a
+   * library document, or any other document for a move, which respells
+   * references in the target's own document only
+   *
+   * @generated from enum value: EDIT_FAILURE_REFERENCED_ELSEWHERE = 18;
+   */
+  REFERENCED_ELSEWHERE = 18,
+}
+
+/**
+ * Describes the enum sysml.EditFailure.
+ */
+export const EditFailureSchema: GenEnum<EditFailure> = /*@__PURE__*/
+  enumDesc(file_sysml, 1);
+
+/**
+ * PrimitiveOperator is the standard's PrimitiveConstraint.operator.
+ *
+ * @generated from enum sysml.PrimitiveOperator
+ */
+export enum PrimitiveOperator {
+  /**
+   * unset; fails the call
+   *
+   * @generated from enum value: PRIMITIVE_OPERATOR_UNSPECIFIED = 0;
+   */
+  UNSPECIFIED = 0,
+
+  /**
+   * "="
+   *
+   * @generated from enum value: PRIMITIVE_OPERATOR_EQUAL = 1;
+   */
+  EQUAL = 1,
+
+  /**
+   * ">"
+   *
+   * @generated from enum value: PRIMITIVE_OPERATOR_GREATER = 2;
+   */
+  GREATER = 2,
+
+  /**
+   * "<"
+   *
+   * @generated from enum value: PRIMITIVE_OPERATOR_LESS = 3;
+   */
+  LESS = 3,
+}
+
+/**
+ * Describes the enum sysml.PrimitiveOperator.
+ */
+export const PrimitiveOperatorSchema: GenEnum<PrimitiveOperator> = /*@__PURE__*/
+  enumDesc(file_sysml, 2);
+
+/**
+ * CompositeOperator is the standard's CompositeConstraint.operator.
+ *
+ * @generated from enum sysml.CompositeOperator
+ */
+export enum CompositeOperator {
+  /**
+   * unset; fails the call
+   *
+   * @generated from enum value: COMPOSITE_OPERATOR_UNSPECIFIED = 0;
+   */
+  UNSPECIFIED = 0,
+
+  /**
+   * "and"
+   *
+   * @generated from enum value: COMPOSITE_OPERATOR_AND = 1;
+   */
+  AND = 1,
+
+  /**
+   * "or"
+   *
+   * @generated from enum value: COMPOSITE_OPERATOR_OR = 2;
+   */
+  OR = 2,
+}
+
+/**
+ * Describes the enum sysml.CompositeOperator.
+ */
+export const CompositeOperatorSchema: GenEnum<CompositeOperator> = /*@__PURE__*/
+  enumDesc(file_sysml, 3);
+
+/**
+ * SysMLService provides programmatic access to OpenSysML's parser and runtime
+ *
+ * @generated from service sysml.SysMLService
+ */
+export const SysMLService: GenService<{
+  /**
+   * Report what this build of the service can do, so a client can require a
+   * capability instead of guessing from a version string. A service that
+   * predates this RPC answers UNIMPLEMENTED, which is itself the answer.
+   *
+   * @generated from rpc sysml.SysMLService.GetServerInfo
+   */
+  getServerInfo: {
+    methodKind: "unary";
+    input: typeof ServerInfoRequestSchema;
+    output: typeof ServerInfoResponseSchema;
+  },
+  /**
+   * Parse a SysML file and return model hash for subsequent queries
+   *
+   * @generated from rpc sysml.SysMLService.ParseFile
+   */
+  parseFile: {
+    methodKind: "unary";
+    input: typeof ParseFileRequestSchema;
+    output: typeof ParseFileResponseSchema;
+  },
+  /**
+   * Parse several documents as one model, so a name one document declares
+   * resolves in another and an import between them is satisfied. Reported as
+   * the "parse_sources" capability.
+   *
+   * @generated from rpc sysml.SysMLService.ParseSources
+   */
+  parseSources: {
+    methodKind: "unary";
+    input: typeof ParseSourcesRequestSchema;
+    output: typeof ParseSourcesResponseSchema;
+  },
+  /**
+   * Get symbol information by qualified name
+   *
+   * @generated from rpc sysml.SysMLService.GetSymbol
+   */
+  getSymbol: {
+    methodKind: "unary";
+    input: typeof GetSymbolRequestSchema;
+    output: typeof SymbolResponseSchema;
+  },
+  /**
+   * Get all diagnostics for a parsed model
+   *
+   * @generated from rpc sysml.SysMLService.GetDiagnostics
+   */
+  getDiagnostics: {
+    methodKind: "unary";
+    input: typeof DiagnosticsRequestSchema;
+    output: typeof DiagnosticsResponseSchema;
+  },
+  /**
+   * Runtime operations (Phase 4)
+   *
+   * @generated from rpc sysml.SysMLService.Evaluate
+   */
+  evaluate: {
+    methodKind: "unary";
+    input: typeof EvaluateRequestSchema;
+    output: typeof EvaluateResponseSchema;
+  },
+  /**
+   * @generated from rpc sysml.SysMLService.Instantiate
+   */
+  instantiate: {
+    methodKind: "unary";
+    input: typeof InstantiateRequestSchema;
+    output: typeof InstantiateResponseSchema;
+  },
+  /**
+   * @generated from rpc sysml.SysMLService.ExecuteAction
+   */
+  executeAction: {
+    methodKind: "unary";
+    input: typeof ExecuteActionRequestSchema;
+    output: typeof ExecuteActionResponseSchema;
+  },
+  /**
+   * @generated from rpc sysml.SysMLService.ExecuteState
+   */
+  executeState: {
+    methodKind: "unary";
+    input: typeof ExecuteStateRequestSchema;
+    output: typeof ExecuteStateResponseSchema;
+  },
+  /**
+   * Convert a model between the representations OpenSysML writes — SysML
+   * textual notation and RDF Turtle — so a client can write a model back out
+   * rather than only read it. Reported as the "convert" capability.
+   *
+   * @generated from rpc sysml.SysMLService.Convert
+   */
+  convert: {
+    methodKind: "unary";
+    input: typeof ConvertRequestSchema;
+    output: typeof ConvertResponseSchema;
+  },
+  /**
+   * Apply edits to a parsed model's own source and return the edited notation,
+   * so a client can change a model and write it back with its comments and
+   * layout intact. Edits are byte ranges the service locates from the parsed
+   * spans, and the result is re-parsed and validated before it is returned.
+   * Reported as the "apply_edits" capability.
+   *
+   * @generated from rpc sysml.SysMLService.ApplyEdits
+   */
+  applyEdits: {
+    methodKind: "unary";
+    input: typeof ApplyEditsRequestSchema;
+    output: typeof ApplyEditsResponseSchema;
+  },
+  /**
+   * Verification: the answers the REPL's %constraint, %requirement, %satisfy,
+   * %calc and %analysis give, so "does this model satisfy its requirements?"
+   * can be asked by a script. Each evaluates the same runtime paths the prompt
+   * does and returns a verdict rather than formatted lines. Reported as the
+   * "verification" capability.
+   *
+   * @generated from rpc sysml.SysMLService.VerifyConstraint
+   */
+  verifyConstraint: {
+    methodKind: "unary";
+    input: typeof VerifyConstraintRequestSchema;
+    output: typeof VerifyConstraintResponseSchema;
+  },
+  /**
+   * @generated from rpc sysml.SysMLService.VerifyRequirement
+   */
+  verifyRequirement: {
+    methodKind: "unary";
+    input: typeof VerifyRequirementRequestSchema;
+    output: typeof VerifyRequirementResponseSchema;
+  },
+  /**
+   * @generated from rpc sysml.SysMLService.VerifySatisfaction
+   */
+  verifySatisfaction: {
+    methodKind: "unary";
+    input: typeof VerifySatisfactionRequestSchema;
+    output: typeof VerifySatisfactionResponseSchema;
+  },
+  /**
+   * Validate an object as a whole, as the REPL's %validate and the CLI's
+   * -validate=<object> do: every assertion about an object of the named part
+   * and about the objects it holds — the asserted constraints of their types,
+   * the requirements they carry and the satisfactions they are the subject of —
+   * each answered on the concrete object it is about. Reported as the
+   * "verification" capability.
+   *
+   * @generated from rpc sysml.SysMLService.ValidateInstance
+   */
+  validateInstance: {
+    methodKind: "unary";
+    input: typeof ValidateInstanceRequestSchema;
+    output: typeof ValidateInstanceResponseSchema;
+  },
+  /**
+   * @generated from rpc sysml.SysMLService.EvaluateCalc
+   */
+  evaluateCalc: {
+    methodKind: "unary";
+    input: typeof EvaluateCalcRequestSchema;
+    output: typeof EvaluateCalcResponseSchema;
+  },
+  /**
+   * @generated from rpc sysml.SysMLService.RunAnalysis
+   */
+  runAnalysis: {
+    methodKind: "unary";
+    input: typeof RunAnalysisRequestSchema;
+    output: typeof RunAnalysisResponseSchema;
+  },
+  /**
+   * Run one analysis case or calc once per row of a parameter sweep, as the
+   * CLI's -sweep and the REPL's %sweep do: each row is an ordinary run with the
+   * swept parameter bound to that row's value. Reported as the "verification"
+   * capability.
+   *
+   * @generated from rpc sysml.SysMLService.RunSweep
+   */
+  runSweep: {
+    methodKind: "unary";
+    input: typeof RunSweepRequestSchema;
+    output: typeof RunSweepResponseSchema;
+  },
+  /**
+   * List the analysis engines this build registers, as the CLI's -engines and
+   * the REPL's %engines do: each with the questions it answers, the strongest
+   * evidence it can produce and whether it can run. Reported as the "engines"
+   * capability, which also names the `engine` request fields and the `engine`,
+   * `strength` and `bounds` response fields of the verification RPCs. The
+   * engines registered from manifests are listed with their origin and whether
+   * this service serves them; the "engines_external" capability is advertised
+   * only when it was started to serve them.
+   *
+   * @generated from rpc sysml.SysMLService.ListEngines
+   */
+  listEngines: {
+    methodKind: "unary";
+    input: typeof ListEnginesRequestSchema;
+    output: typeof ListEnginesResponseSchema;
+  },
+  /**
+   * Run a SysML v2 API & Services Query over a parsed model: scope/select/where
+   * as the standard defines them, so a client that speaks that API can filter a
+   * model here. Reported as the "query" capability.
+   *
+   * @generated from rpc sysml.SysMLService.Query
+   */
+  query: {
+    methodKind: "unary";
+    input: typeof QueryRequestSchema;
+    output: typeof QueryResponseSchema;
+  },
+  /**
+   * Run a named document query with parameter bindings, the answer the REPL's
+   * %run-query gives, as typed rows rather than formatted lines. Reported as
+   * the "document_query" capability.
+   *
+   * @generated from rpc sysml.SysMLService.RunDocumentQuery
+   */
+  runDocumentQuery: {
+    methodKind: "unary";
+    input: typeof RunDocumentQueryRequestSchema;
+    output: typeof RunDocumentQueryResponseSchema;
+  },
+  /**
+   * Render a named document to Markdown, as the CLI's -render-document does.
+   * Reported as the "render_document" capability.
+   *
+   * @generated from rpc sysml.SysMLService.RenderDocument
+   */
+  renderDocument: {
+    methodKind: "unary";
+    input: typeof RenderDocumentRequestSchema;
+    output: typeof RenderDocumentResponseSchema;
+  },
+}> = /*@__PURE__*/
+  serviceDesc(file_sysml, 0);
+
diff --git a/clients/node/src/node/binary.ts b/client/node/src/node/binary.ts
similarity index 99%
rename from clients/node/src/node/binary.ts
rename to client/node/src/node/binary.ts
index 6b4032d5ba..0d6796fa47 100644
--- a/clients/node/src/node/binary.ts
+++ b/client/node/src/node/binary.ts
@@ -97,7 +97,7 @@ export interface DownloadOptions {
 export class BinaryNotFoundError extends OpenSysMLError {}
 
 /**
- * The digests this package ships, synced from clients/release-digests.json: a pin
+ * The digests this package ships, synced from client/release-digests.json: a pin
  * resolved from outside the published artifact would not be a pin.
  */
 export const PINNED_SHA256: PinnedDigests = readPinnedDigests(
diff --git a/clients/node/src/node/index.ts b/client/node/src/node/index.ts
similarity index 100%
rename from clients/node/src/node/index.ts
rename to client/node/src/node/index.ts
diff --git a/clients/node/src/node/service.ts b/client/node/src/node/service.ts
similarity index 100%
rename from clients/node/src/node/service.ts
rename to client/node/src/node/service.ts
diff --git a/clients/node/src/node/signing.ts b/client/node/src/node/signing.ts
similarity index 100%
rename from clients/node/src/node/signing.ts
rename to client/node/src/node/signing.ts
diff --git a/clients/node/test/binary.test.ts b/client/node/test/binary.test.ts
similarity index 100%
rename from clients/node/test/binary.test.ts
rename to client/node/test/binary.test.ts
diff --git a/clients/node/test/browser.test.ts b/client/node/test/browser.test.ts
similarity index 100%
rename from clients/node/test/browser.test.ts
rename to client/node/test/browser.test.ts
diff --git a/clients/node/test/client.test.ts b/client/node/test/client.test.ts
similarity index 99%
rename from clients/node/test/client.test.ts
rename to client/node/test/client.test.ts
index 5007b23661..228169c641 100644
--- a/clients/node/test/client.test.ts
+++ b/client/node/test/client.test.ts
@@ -16,6 +16,7 @@ import {
   CAPABILITY_FUNCTION_VALUES,
   CAPABILITY_MEASUREMENT_REFS,
   CAPABILITY_METAOBJECT_VALUES,
+  CAPABILITY_PERFORMER,
   CAPABILITY_QUERY,
   CAPABILITY_SCHEDULE,
   CAPABILITY_SCHEDULE_EXPLORE,
@@ -376,6 +377,11 @@ test("the service advertises the explore scheduling policy", async () => {
   assert.ok((await connection.serverInfo()).has(CAPABILITY_SCHEDULE_EXPLORE));
 });
 
+test("the service advertises the object a behavior is performed by", async () => {
+  await using connection = await connect();
+  assert.ok((await connection.serverInfo()).has(CAPABILITY_PERFORMER));
+});
+
 test("the service advertises its analysis engines", async () => {
   await using connection = await connect();
   assert.ok((await connection.serverInfo()).has(CAPABILITY_ENGINES));
diff --git a/clients/node/test/compare.test.ts b/client/node/test/compare.test.ts
similarity index 100%
rename from clients/node/test/compare.test.ts
rename to client/node/test/compare.test.ts
diff --git a/clients/node/test/conformance.test.ts b/client/node/test/conformance.test.ts
similarity index 98%
rename from clients/node/test/conformance.test.ts
rename to client/node/test/conformance.test.ts
index 019e71f4d9..b67283e61a 100644
--- a/clients/node/test/conformance.test.ts
+++ b/client/node/test/conformance.test.ts
@@ -114,7 +114,7 @@ for (const mutation of Object.keys(MUTATIONS)) {
   });
 }
 
-test("the command line mirrors cmd/conformance's flags", () => {
+test("the command line mirrors tools/cmd/conformance's flags", () => {
   const options = parseOptions(["--binary", "bin/sysml-grpc", "--protocols", " grpc, connect ", "--allow-skips", "-v", "--mutate", "shift-integer"]);
   assert.equal(options.binary, "bin/sysml-grpc");
   assert.deepEqual(options.protocols, ["grpc", "connect"]);
diff --git a/clients/node/test/errors.test.ts b/client/node/test/errors.test.ts
similarity index 100%
rename from clients/node/test/errors.test.ts
rename to client/node/test/errors.test.ts
diff --git a/clients/node/test/examples.test.ts b/client/node/test/examples.test.ts
similarity index 100%
rename from clients/node/test/examples.test.ts
rename to client/node/test/examples.test.ts
diff --git a/clients/node/test/fixtures/signed_release/SHA256SUMS.txt b/client/node/test/fixtures/signed_release/SHA256SUMS.txt
similarity index 100%
rename from clients/node/test/fixtures/signed_release/SHA256SUMS.txt
rename to client/node/test/fixtures/signed_release/SHA256SUMS.txt
diff --git a/clients/node/test/fixtures/signed_release/SHA256SUMS.txt.bundle b/client/node/test/fixtures/signed_release/SHA256SUMS.txt.bundle
similarity index 100%
rename from clients/node/test/fixtures/signed_release/SHA256SUMS.txt.bundle
rename to client/node/test/fixtures/signed_release/SHA256SUMS.txt.bundle
diff --git a/clients/node/test/fixtures/signed_release/SHA256SUMS.txt.expired.bundle b/client/node/test/fixtures/signed_release/SHA256SUMS.txt.expired.bundle
similarity index 100%
rename from clients/node/test/fixtures/signed_release/SHA256SUMS.txt.expired.bundle
rename to client/node/test/fixtures/signed_release/SHA256SUMS.txt.expired.bundle
diff --git a/clients/node/test/fixtures/signed_release/SHA256SUMS.txt.other-identity.bundle b/client/node/test/fixtures/signed_release/SHA256SUMS.txt.other-identity.bundle
similarity index 100%
rename from clients/node/test/fixtures/signed_release/SHA256SUMS.txt.other-identity.bundle
rename to client/node/test/fixtures/signed_release/SHA256SUMS.txt.other-identity.bundle
diff --git a/clients/node/test/fixtures/signed_release/identity.json b/client/node/test/fixtures/signed_release/identity.json
similarity index 100%
rename from clients/node/test/fixtures/signed_release/identity.json
rename to client/node/test/fixtures/signed_release/identity.json
diff --git a/clients/node/test/fixtures/signed_release/sysml-grpc-linux-amd64 b/client/node/test/fixtures/signed_release/sysml-grpc-linux-amd64
similarity index 100%
rename from clients/node/test/fixtures/signed_release/sysml-grpc-linux-amd64
rename to client/node/test/fixtures/signed_release/sysml-grpc-linux-amd64
diff --git a/clients/node/test/fixtures/signed_release/trusted_root.json b/client/node/test/fixtures/signed_release/trusted_root.json
similarity index 100%
rename from clients/node/test/fixtures/signed_release/trusted_root.json
rename to client/node/test/fixtures/signed_release/trusted_root.json
diff --git a/clients/node/test/normalize.test.ts b/client/node/test/normalize.test.ts
similarity index 100%
rename from clients/node/test/normalize.test.ts
rename to client/node/test/normalize.test.ts
diff --git a/clients/node/test/orphan.test.ts b/client/node/test/orphan.test.ts
similarity index 100%
rename from clients/node/test/orphan.test.ts
rename to client/node/test/orphan.test.ts
diff --git a/clients/node/test/platform-packages.test.ts b/client/node/test/platform-packages.test.ts
similarity index 100%
rename from clients/node/test/platform-packages.test.ts
rename to client/node/test/platform-packages.test.ts
diff --git a/clients/node/test/release-server.ts b/client/node/test/release-server.ts
similarity index 100%
rename from clients/node/test/release-server.ts
rename to client/node/test/release-server.ts
diff --git a/clients/node/test/signing.test.ts b/client/node/test/signing.test.ts
similarity index 99%
rename from clients/node/test/signing.test.ts
rename to client/node/test/signing.test.ts
index 8849f2ca16..6fd8791bda 100644
--- a/clients/node/test/signing.test.ts
+++ b/client/node/test/signing.test.ts
@@ -1,7 +1,7 @@
 // Verification of a release's signed checksum manifest.
 //
 // Every test here is offline: the bundles under test/fixtures/signed_release were
-// recorded by clients/python/scripts/make_signed_release_fixture.py against a root
+// recorded by client/python/scripts/make_signed_release_fixture.py against a root
 // of trust the fixtures carry, so nothing reaches Sigstore's production instance.
 
 import assert from "node:assert/strict";
diff --git a/clients/node/test/support/hold-service.ts b/client/node/test/support/hold-service.ts
similarity index 100%
rename from clients/node/test/support/hold-service.ts
rename to client/node/test/support/hold-service.ts
diff --git a/clients/node/test/support/service.ts b/client/node/test/support/service.ts
similarity index 100%
rename from clients/node/test/support/service.ts
rename to client/node/test/support/service.ts
diff --git a/clients/node/test/support/worker-service.ts b/client/node/test/support/worker-service.ts
similarity index 100%
rename from clients/node/test/support/worker-service.ts
rename to client/node/test/support/worker-service.ts
diff --git a/clients/node/test/values.test.ts b/client/node/test/values.test.ts
similarity index 100%
rename from clients/node/test/values.test.ts
rename to client/node/test/values.test.ts
diff --git a/clients/node/test/worker.test.ts b/client/node/test/worker.test.ts
similarity index 100%
rename from clients/node/test/worker.test.ts
rename to client/node/test/worker.test.ts
diff --git a/clients/node/tsconfig.build.json b/client/node/tsconfig.build.json
similarity index 100%
rename from clients/node/tsconfig.build.json
rename to client/node/tsconfig.build.json
diff --git a/clients/node/tsconfig.json b/client/node/tsconfig.json
similarity index 100%
rename from clients/node/tsconfig.json
rename to client/node/tsconfig.json
diff --git a/client/opensysml/README.md b/client/opensysml/README.md
index 23645f1b63..d7799b4517 100644
--- a/client/opensysml/README.md
+++ b/client/opensysml/README.md
@@ -34,7 +34,8 @@ inst, err := client.Instantiate(ctx, model, "Demo::Vehicle")
 | Compute with it | `Evaluate`, `Instantiate`, `EvaluateCalc`, `Calculate`, `RunAnalysis` |
 | Run behavior | `ExecuteAction`, `ExecuteState` |
 | Run every linearization of it | `ExploreAction`, `ExploreState`, `ExploreAnalysis` |
-| Check it | `VerifyConstraint`, `VerifyRequirement`, `VerifySatisfaction` |
+| Play it step by step | `OpenSession`, then `Session.Instantiate`, `Send`, `Advance`, `Perform` |
+| Check it | `VerifyConstraint`, `VerifyRequirement`, `VerifySatisfaction`, `ValidateInstance` |
 | Choose who answers | `ListEngines`, `WithEngine`, `Engine`, `CalcEngine` |
 | Search it | `Query`, `QueryOSLC` |
 | Report on it | `RunDocumentQuery`, `RenderDocument` |
@@ -59,6 +60,15 @@ fails only when it could not be evaluated at all, and then it is a
 `*VerifyError` whose `Reason` classifies the failure. A condition the runtime
 could not evaluate for one subject arrives as `Verdict.Undecided()`.
 
+`ValidateInstance` builds one object of the part named and answers every
+assertion about it and the objects it holds — asserted constraints,
+requirement usages and `satisfy` assertions whose subject is in the tree — as
+a `Validation`: one `Verdict` per assertion per object, each placing its object
+by `InstancePath` (`wheels[2]`), and a `Summary` of Kind `"object"` that
+`Valid()` reads. `Violated()` lists the verdicts the model answered false, kept
+apart from undecided ones, and `Bounded` marks a walk cut short, which is not
+valid either.
+
 What running a verification case's body answered is a separate answer, reported
 beside the satisfaction verdict rather than instead of it, by a service
 advertising `CapabilityVerificationVerdicts`. `Verification.Verifications`,
@@ -103,6 +113,81 @@ fails under some orders is an `Outcome` with its `Error` set, not a failed call.
 The single-run and exploring calls refuse each other's policies with
 `CodeInvalidArgument`, so a policy is never quietly answered by the wrong shape.
 
+### Sessions
+
+`ExecuteAction` and `ExecuteState` run a whole behaviour and answer what it did.
+A `Session` is the interactive shape: a persistent run of one model that keeps
+its clock, its scheduling policy and the objects it instantiated between calls,
+for a debugger, a simulator's console or a game played against the model one
+key at a time.
+
+```go
+session, err := opensysml.OpenSession(client, model)
+defer session.Close()
+
+hero, err := session.Instantiate("Play::hero")           // starts the machines it exhibits
+err = session.SetSchedule("seed:42")                     // the dice later turns roll, the running machines' and clock's included
+states, err := session.ActiveStates(hero)                // ["town"]
+transitions, err := session.Transitions(hero)            // out of each active state and those enclosing it: Source, Target, Trigger, Signal or Event, Guarded
+acceptance, err := session.Accepts(hero, "Play::Go", nil) // Taken(), as dispatch selects among machines; Accepted; Enabled() is whether a guard holds now
+_, err = session.Send(hero, "Play::Go", nil)             // posts it, or refuses with CodeFailedPrecondition
+advanced, err := session.Advance(1)                      // dispatches, completion transitions included; Choices
+performed, err := session.Perform(hero, "Play::Hero::pay",
+	map[string]opensysml.Value{"amount": opensysml.Int(5)}) // Outputs, Choices, Branches; TurnedAway()
+value, err := session.Feature(hero, "gold")              // as the runs left it
+err = session.SetFeature(hero, "gold", opensysml.Int(100))
+v, err := session.Evaluate("town.shop.stick", opensysml.WithContextSymbol("Play::hero"))
+members, err := session.Members("Play::Mood")            // an enumeration's literals, a package's parts
+```
+
+A session answers facts, never the engine's own graphs or objects: a
+`Transition` is its ends and trigger by name; an `Acceptance` is whether a
+transition accepts the signal and whether one is enabled; a `Performance`
+carries the run's outputs, its `ChoicePoint`s (where the schedule chose, what
+it could have chosen, what it took) and the `Branch` each decision of the
+action's own flow left by, with `TurnedAway()` reading whether the opening
+decision took its else branch — the shape of an action that looks at its
+inputs and declines. Objects are `InstanceID` handles that `Feature`,
+`SetFeature`, `Accepts`, `Send` and `Perform` take, and `Evaluate` reads them
+where an expression names one.
+
+A `Session` is not part of the `Client` interface, on purpose. `Client` is the
+set of RPCs the service answers, and `New` and `Dial` are held to identical
+answers by the conformance suite; a session is state the engine holds between
+calls, which the service exposes no RPC for. Rather than a `Dial` that answers
+some methods and not others, `OpenSession` is a separate in-process-only
+surface opened from a `Client`: a client `New` returned answers it, a `Dial`
+client is refused with `CodeUnimplemented`, and nothing is stubbed in between.
+The `Client` contract is untouched — every one of its methods still answers
+identically over both — and a session over the wire, if one is added, will be
+a set of RPCs with the same fact-shaped answers.
+
+Misuse is refused, never a panic: a closed session answers `CodeUnavailable`;
+a signal no transition out of an active state, or a state enclosing one,
+accepts in any machine the object exhibits, or one whose every guard is false,
+`CodeFailedPrecondition`; an exploration policy or a negative
+advance `CodeInvalidArgument`; an unknown symbol, object or action, or a run
+the model fails, a `*FailureError` as the request-scoped calls report them. A
+session holds its model in the client's cache and the objects it made until
+`Close`, which releases them; closing twice is harmless, and `Close` on the
+client does not close a session opened from it, so close the session first.
+Its objects are bounded as the service bounds the objects it holds for
+queries (`OPENSYSML_GRPC_MAX_HELD_OBJECTS`, 10000 by default) — a call that
+would pass the bound answers `CodeResourceExhausted` — and its runs by the
+same step budget as `ExecuteAction`, each `Evaluate` and `Perform` a run of
+its own.
+
+The [Legend of the Red Dragon browser game](https://github.com/Open-MBEE/SysML-LoRD)
+is a client of this surface and nothing else: it imports only this package,
+compiled to WebAssembly.
+
+`PerformedBy` names the object an action or state machine runs on, as `sysml
+-action "<action> <object>"` does: a part definition or usage to make an object
+of, or a path from one into its parts — `PerformedBy("Mission::mission.vehicle")`
+makes the mission and runs on its vehicle, inside the assembly, so a machine the
+vehicle exhibits hears the ground station over their connector. Each explored run
+makes the object anew. The option needs the `performer` capability.
+
 ```go
 exploration, err := client.ExploreAction(ctx, model, "Demo::race", nil)
 for _, outcome := range exploration.Outcomes {
@@ -149,20 +234,26 @@ elements, err := client.Query(ctx, model, opensysml.Query{
 })
 ```
 
-Edits are typed the same way — `SetValue`, `Rename`, `AddMember`, `Delete` —
-and either all apply, answering the edited source, or none do and the refusal
-arrives as an `*EditError` naming its kind:
+Edits are typed the same way — `SetValue`, `Rename`, `AddMember`, `Delete`,
+`Move` — and either all apply, answering the edited source, or none do and the
+refusal arrives as an `*EditError` naming its kind:
 
 ```go
 result, err := client.ApplyEdits(ctx, model,
 	opensysml.SetValue{Target: "Demo::sedan::mass", Value: "1200.0[SI::kg]"})
 
 var refused *opensysml.EditError
-if errors.As(err, &refused) && refused.Failure == opensysml.EditFailureRenameReferenced {
-	// refused.Referring names what still refers to it
+if errors.As(err, &refused) && refused.Failure == opensysml.EditFailureDeleteReferenced {
+	// refused.Referrers names what still refers to it, each with its document
 }
 ```
 
+The edited source is `result.Documents`, one `EditedDocument` per document the
+batch reached, under the name the model was parsed with; `result.Content` is the
+same notation for a model of one document and empty for a model of several, kept
+for callers of the sole-document contract. Each `AppliedEdit` names the
+`Document` its bytes belong to.
+
 ## What a model is here
 
 `ParseFile` reads the one path it is given and `ParseSource` the one string, and
@@ -190,10 +281,27 @@ own name, so a diagnostic locates itself in the file it came from, and
 `Model.Root` is the first, as it is for a one-document model. A set is cached by
 what is in it, so parsing the same documents again answers the same model hash.
 
-Two operations write one document's own notation back out, and they are refused
-with `CodeFailedPrecondition` for a model of several rather than applied to one
-of them: `Convert` from a model handle, and `ApplyEdits`. Convert a single
-document of such a set with `ConvertFile` or `ConvertSource`.
+`Convert` from a model handle writes one document's own notation back out, and
+is refused with `CodeFailedPrecondition` for a model of several rather than
+applied to one of them; convert a single document of such a set with
+`ConvertFile` or `ConvertSource`.
+
+`ApplyEdits` edits the set as one model. Its operations name elements declared in
+the first document; `ApplyDocumentEdits(ctx, model, "top.sysml", edits...)` names
+another, and a name that is not one of the model's is `CodeInvalidArgument`. A
+rename or a cascade delete follows its references into the other documents, every
+document touched is re-parsed and re-analysed together, and `result.Documents`
+lists exactly the documents rewritten — so a batch that reaches one document of
+three answers one `EditedDocument`, and `result.Content` is empty. The client marks
+every request as accepting documents; the service refuses a request that does not on a
+model of several, as it did before, so a program reading `Content` alone through an
+earlier client is never handed an empty one. A reference from
+a document the edit cannot rewrite, such as a bundled library file, refuses the
+edit as `EditFailureReferencedElsewhere`, naming it in `Referrers`. All of this is the
+`CapabilityEditDocuments` capability: a service without it edits a model of one
+document alone, answering `Content` with `Documents` empty, refuses a model of several
+with `CodeFailedPrecondition`, and refuses `ApplyDocumentEdits` with `CodeUnimplemented`
+— so a program reading `Documents` checks `ServerInfo` for the capability first.
 
 ## Concurrency, contexts and lifetime
 
@@ -221,7 +329,7 @@ checkout. It is informational: negotiate on capabilities.
 This is a Go repository: a Go program that imports this module already links
 the parser, the semantic engine and the runtime. `New` calls them directly —
 no port, no child process, no serialization round trip. It answers through the
-same service implementation (`internal/grpc.Service`) the wire transports
+same service implementation (`internal/frontend/grpc.Service`) the wire transports
 serve, so the semantics are the service's semantics: the same content-addressed
 parse cache and model hashes, the same capability list, the same in-band
 failures, the same runtime budgets (read from the environment, as the service
@@ -345,7 +453,7 @@ protocols of the reference runner:
 
 ```sh
 make conformance-pkg
-# or: go run ./cmd/conformance -protocols pkg,pkg-connect -allow-skips
+# or: go run -C tools ./cmd/conformance -protocols pkg,pkg-connect -allow-skips
 ```
 
 Two scenarios are reported as skips, because they state a request this API's
diff --git a/client/opensysml/authoring.go b/client/opensysml/authoring.go
index 0b56a0b3de..197fa442ab 100644
--- a/client/opensysml/authoring.go
+++ b/client/opensysml/authoring.go
@@ -2,6 +2,7 @@ package opensysml
 
 import (
 	"context"
+	"fmt"
 
 	pb "github.com/Open-MBEE/OpenSysML/api/proto"
 )
@@ -9,10 +10,11 @@ import (
 // Format is a representation a model is written in or read from.
 type Format string
 
-// The formats conversion accepts. There are two canonical ones, FormatSysML and
-// FormatTTL, and a Conversion answers by those names whichever alias was asked
-// for. RDF, in any spelling, is an experimental mapping, which a Conversion
-// reports.
+// The formats conversion accepts. There are three canonical ones that are
+// written, FormatSysML, FormatTTL and FormatAPIJSON, and a Conversion answers by
+// those names whichever alias was asked for. RDF and the API's JSON element form,
+// in any spelling, are one experimental mapping, which a Conversion reports; so
+// is migration from FormatXMI, which is only ever read.
 const (
 	FormatSysML Format = "sysml"
 	FormatTTL   Format = "ttl"
@@ -22,8 +24,15 @@ const (
 	FormatText  Format = "text"
 	// FormatTurtle and FormatRDF are aliases of FormatTTL, the one RDF
 	// serialization written.
-	FormatTurtle Format = "turtle"
-	FormatRDF    Format = "rdf"
+	FormatTurtle  Format = "turtle"
+	FormatRDF     Format = "rdf"
+	FormatAPIJSON Format = "api-json"
+	// FormatJSON is an alias of FormatAPIJSON, the OMG API's JSON element
+	// form of the same graph FormatTTL writes.
+	FormatJSON Format = "json"
+	// FormatXMI is SysML v1 as UML XMI, an Eclipse UML2 .uml file or a .mdzip
+	// archive, migrated to v2 on the way in. Asking to write it is refused.
+	FormatXMI Format = "xmi"
 )
 
 // ConvertOption configures Convert and ConvertFile.
@@ -57,8 +66,8 @@ type Conversion struct {
 	// inferred learns what it was inferred as.
 	From Format
 	To   Format
-	// Experimental is set when either format is RDF, whose vocabulary may change
-	// without a compatibility path.
+	// Experimental is set when either format is RDF or the API's JSON form, or the
+	// source is SysML v1: mappings that may change without a compatibility path.
 	Experimental bool
 	// ExperimentalNotice says what is experimental about the conversion, empty
 	// when it is not.
@@ -226,6 +235,9 @@ const (
 	EditFailureDeleteReferenced  EditFailure = EditFailure(pb.EditFailure_EDIT_FAILURE_DELETE_REFERENCED)
 	EditFailureOwnerInsideTarget EditFailure = EditFailure(pb.EditFailure_EDIT_FAILURE_OWNER_INSIDE_TARGET)
 	EditFailureMoveReferenced    EditFailure = EditFailure(pb.EditFailure_EDIT_FAILURE_MOVE_REFERENCED)
+	// EditFailureReferencedElsewhere is a rename, delete or move of a declaration
+	// referred to from a document the edit cannot rewrite, such as a library.
+	EditFailureReferencedElsewhere EditFailure = EditFailure(pb.EditFailure_EDIT_FAILURE_REFERENCED_ELSEWHERE)
 )
 
 // String names the refusal as the wire enum spells it.
@@ -235,21 +247,45 @@ func (f EditFailure) String() string {
 
 // EditResult is a model's source with every edit applied.
 type EditResult struct {
-	// Content is the edited notation, byte-identical to the source outside the
-	// edited spans.
+	// Content is the edited notation of a single-document model, byte-identical
+	// to the source outside the edited spans. Empty for a model of several
+	// documents, whose notation is in Documents alone.
+	//
+	// Deprecated: read Documents, which carries the same notation for a model of
+	// one document and every rewritten document of a model of several, from a
+	// service advertising the edit_documents capability.
 	Content string
-	// Applied says what each edit changed, in request order.
+	// Documents is the edited notation of every document the edits rewrote: the
+	// edited document first when it is among them, then the others in name order.
+	// A single-document model's one document is always listed; a document of
+	// several the edits left as parsed is not. Empty, with Content filled, from a
+	// service without the edit_documents capability, which edits one document alone.
+	Documents []EditedDocument
+	// Applied says what each edit changed, grouped by document in the order
+	// Documents lists them and in request order within a document.
 	Applied []AppliedEdit
 	// Diagnostics the edited source was found to have, when any.
 	Diagnostics []Diagnostic
 }
 
+// EditedDocument is the edited notation of one document of a model.
+type EditedDocument struct {
+	// Name is the document's name as the parse gave it: a File's path, a
+	// Document's Name, or the position of an inline document named neither.
+	Name string
+	// Content is the edited notation, byte-identical to the source outside the
+	// edited spans.
+	Content string
+}
+
 // AppliedEdit is one byte range of the original source an edit replaced.
 type AppliedEdit struct {
 	// Index is the edit's position in the request, so an answer maps back to its ask.
 	Index int
 	// Target is the element edited, as the request named it.
 	Target string
+	// Document is the document the bytes belong to, named as in EditedDocument.
+	Document string
 	// Offset and Length are the bytes replaced; Length is zero for an insertion.
 	Offset int
 	Length int
@@ -258,12 +294,48 @@ type AppliedEdit struct {
 	NewText string
 }
 
+// Referrer is one declaration referring to the target of a refused rename,
+// delete or move, with the document declaring it.
+type Referrer struct {
+	// Name is the referring declaration, as the notation names it.
+	Name string
+	// Document is the document declaring it, named as in EditedDocument.
+	Document string
+}
+
 func (c *client) ApplyEdits(ctx context.Context, model *Model, edits ...Edit) (*EditResult, error) {
+	return c.ApplyDocumentEdits(ctx, model, "", edits...)
+}
+
+// requireEditDocuments refuses to send a document name to a service without
+// the edit_documents capability, which would ignore it and edit its sole document.
+func (c *client) requireEditDocuments(ctx context.Context, document string) error {
+	if document == "" {
+		return nil
+	}
+	info, err := c.serverInfo(ctx)
+	if err != nil {
+		return err
+	}
+	if !info.Has(CapabilityEditDocuments) {
+		return &StatusError{
+			Code:    CodeUnimplemented,
+			Message: fmt.Sprintf("capability %q is unavailable", CapabilityEditDocuments),
+		}
+	}
+	return nil
+}
+
+func (c *client) ApplyDocumentEdits(ctx context.Context, model *Model, document string, edits ...Edit) (*EditResult, error) {
 	hash, err := c.call(model)
 	if err != nil {
 		return nil, err
 	}
-	req := &pb.ApplyEditsRequest{ModelHash: hash}
+	if err := c.requireEditDocuments(ctx, document); err != nil {
+		return nil, err
+	}
+	// This client reads Documents, so a model of several documents may be edited.
+	req := &pb.ApplyEditsRequest{ModelHash: hash, Document: document, AcceptDocuments: true}
 	for _, edit := range edits {
 		operation, err := editToProto(edit)
 		if err != nil {
@@ -281,22 +353,38 @@ func (c *client) ApplyEdits(ctx context.Context, model *Model, edits ...Edit) (*
 			FailureError: FailureError{Op: "ApplyEdits", Message: resp.Error, Diagnostics: diagnostics},
 			Failure:      EditFailure(resp.Failure),
 			Referring:    append([]string(nil), resp.ReferringElements...),
+			Referrers:    referrersFromProto(resp.Referrers),
 		}
 	}
 	result := &EditResult{Content: resp.Content, Diagnostics: diagnostics}
+	for _, doc := range resp.Documents {
+		result.Documents = append(result.Documents, EditedDocument{Name: doc.Name, Content: doc.Content})
+	}
 	for _, applied := range resp.Applied {
 		result.Applied = append(result.Applied, AppliedEdit{
-			Index:   int(applied.OperationIndex),
-			Target:  applied.Target,
-			Offset:  int(applied.Offset),
-			Length:  int(applied.Length),
-			OldText: applied.OldText,
-			NewText: applied.NewText,
+			Index:    int(applied.OperationIndex),
+			Target:   applied.Target,
+			Document: applied.Document,
+			Offset:   int(applied.Offset),
+			Length:   int(applied.Length),
+			OldText:  applied.OldText,
+			NewText:  applied.NewText,
 		})
 	}
 	return result, nil
 }
 
+func referrersFromProto(referrers []*pb.Referrer) []Referrer {
+	if len(referrers) == 0 {
+		return nil
+	}
+	out := make([]Referrer, 0, len(referrers))
+	for _, r := range referrers {
+		out = append(out, Referrer{Name: r.Name, Document: r.Document})
+	}
+	return out
+}
+
 func editToProto(edit Edit) (*pb.EditOperation, error) {
 	switch operation := edit.(type) {
 	case SetValue:
diff --git a/client/opensysml/authoring_internal_test.go b/client/opensysml/authoring_internal_test.go
new file mode 100644
index 0000000000..70c0ed71a8
--- /dev/null
+++ b/client/opensysml/authoring_internal_test.go
@@ -0,0 +1,32 @@
+package opensysml
+
+import (
+	"context"
+	"testing"
+
+	pb "github.com/Open-MBEE/OpenSysML/api/proto"
+)
+
+func (o *oldCaller) applyEdits(context.Context, *pb.ApplyEditsRequest) (*pb.ApplyEditsResponse, error) {
+	o.t.Fatal("a document name was sent to a service without edit_documents")
+	return nil, nil
+}
+
+// A document name is refused before it leaves the client when the service lacks
+// edit_documents, since such a service would ignore the name and edit its sole
+// document instead: whether it predates the capability or GetServerInfo itself.
+func TestADocumentNameIsNotSentWithoutTheCapability(t *testing.T) {
+	ctx := context.Background()
+	model := &Model{Hash: "h"}
+	for name, old := range map[string]*oldCaller{
+		"predates edit_documents": {t: t, capabilities: []string{CapabilityApplyEdits, CapabilityAuthoring}},
+		"predates GetServerInfo":  {t: t, infoErr: &StatusError{Code: CodeUnimplemented, Message: "unknown method"}},
+	} {
+		t.Run(name, func(t *testing.T) {
+			old.t = t
+			c := &client{caller: old}
+			_, err := c.ApplyDocumentEdits(ctx, model, "typo.sysml", Rename{Target: "P::x", NewName: "y"})
+			wantUnimplemented(t, "ApplyDocumentEdits", err)
+		})
+	}
+}
diff --git a/client/opensysml/client.go b/client/opensysml/client.go
index 1474d108a9..a1c20f8e2c 100644
--- a/client/opensysml/client.go
+++ b/client/opensysml/client.go
@@ -111,6 +111,15 @@ type Client interface {
 	// for a named engine, checked before anything is sent.
 	VerifySatisfaction(ctx context.Context, model *Model, symbolID string, opts ...VerifyOption) (*Satisfaction, error)
 
+	// ValidateInstance instantiates the part or usage named and checks every
+	// assertion about the object and the objects it holds — asserted constraints
+	// and invariants, the requirements carried, and the satisfaction assertions
+	// whose subjects are inside it — WithEngine naming the engine that answers;
+	// the symbol named is the object validated, so Against is an invalid
+	// argument. Requires the verification capability, and engines for a named
+	// engine, checked before anything is sent.
+	ValidateInstance(ctx context.Context, model *Model, symbolID string, opts ...VerifyOption) (*Validation, error)
+
 	// EvaluateCalc invokes the named calculation with positional arguments, or,
 	// given none, evaluates a calc usage from its own members. Requires the
 	// verification capability, and the complex_values, structured_values,
@@ -146,8 +155,9 @@ type Client interface {
 	QueryOSLC(ctx context.Context, model *Model, oslc string) ([]QueryElement, error)
 
 	// RunDocumentQuery runs the named document query, binding its entry
-	// parameters, and answers typed rows. Requires the document_query
-	// capability.
+	// parameters — to elements, scalars, or objects the service holds for the
+	// model since Instantiate (Object) — and answers typed rows. Requires the
+	// document_query capability.
 	RunDocumentQuery(ctx context.Context, model *Model, queryID string, bindings ...Binding) (*Rows, error)
 
 	// RenderDocument renders the named document to Markdown. Requires the
@@ -171,10 +181,20 @@ type Client interface {
 	ConvertSource(ctx context.Context, content string, to Format, opts ...ConvertOption) (*Conversion, error)
 
 	// ApplyEdits answers the model's source with every edit applied, or refuses
-	// them all with an EditError. Requires the apply_edits capability, and a
-	// model of one document.
+	// them all with an EditError. The edits target the model's first document —
+	// its only one when parsed from a file or source — and a rename or cascade
+	// delete follows references into every other document of the model, whose
+	// rewritten notation the result lists. Requires the apply_edits capability;
+	// a model of several documents, and a result listing Documents, the
+	// edit_documents capability too.
 	ApplyEdits(ctx context.Context, model *Model, edits ...Edit) (*EditResult, error)
 
+	// ApplyDocumentEdits is ApplyEdits with the edits targeting the document
+	// named as the parse named it; empty names the first. A name the model has
+	// no document under is CodeInvalidArgument. Requires the apply_edits and
+	// edit_documents capabilities.
+	ApplyDocumentEdits(ctx context.Context, model *Model, document string, edits ...Edit) (*EditResult, error)
+
 	// Close releases what the implementation holds. The Client answers no
 	// further calls: each is refused with CodeUnavailable. Closing twice is
 	// not an error.
@@ -268,6 +288,7 @@ type caller interface {
 	verifyConstraint(ctx context.Context, req *pb.VerifyConstraintRequest) (*pb.VerifyConstraintResponse, error)
 	verifyRequirement(ctx context.Context, req *pb.VerifyRequirementRequest) (*pb.VerifyRequirementResponse, error)
 	verifySatisfaction(ctx context.Context, req *pb.VerifySatisfactionRequest) (*pb.VerifySatisfactionResponse, error)
+	validateInstance(ctx context.Context, req *pb.ValidateInstanceRequest) (*pb.ValidateInstanceResponse, error)
 	evaluateCalc(ctx context.Context, req *pb.EvaluateCalcRequest) (*pb.EvaluateCalcResponse, error)
 	runAnalysis(ctx context.Context, req *pb.RunAnalysisRequest) (*pb.RunAnalysisResponse, error)
 	query(ctx context.Context, req *pb.QueryRequest) (*pb.QueryResponse, error)
diff --git a/client/opensysml/complex_test.go b/client/opensysml/complex_test.go
index d175b1c800..197a68d94b 100644
--- a/client/opensysml/complex_test.go
+++ b/client/opensysml/complex_test.go
@@ -11,7 +11,7 @@ import (
 
 	"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
 	"github.com/Open-MBEE/OpenSysML/client/opensysml"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 )
 
 const complexSource = `package C {
diff --git a/client/opensysml/convert.go b/client/opensysml/convert.go
index 2bdf7f7b57..61505e9f47 100644
--- a/client/opensysml/convert.go
+++ b/client/opensysml/convert.go
@@ -4,7 +4,7 @@ import (
 	"fmt"
 
 	pb "github.com/Open-MBEE/OpenSysML/api/proto"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv"
 )
 
 // The conversions from the wire types to the public ones. Every conversion
@@ -94,7 +94,7 @@ func valueFromProto(value *pb.Value) Value {
 	case *pb.Value_RealValue:
 		return Real(kind.RealValue)
 	case *pb.Value_Complex:
-		return Complex(sysmlgrpc.ProtoToComplex(kind.Complex))
+		return Complex(protoconv.ProtoToComplex(kind.Complex))
 	case *pb.Value_BoolValue:
 		return Bool(kind.BoolValue)
 	case *pb.Value_StringValue:
@@ -131,7 +131,7 @@ func valueFromProto(value *pb.Value) Value {
 			CountUpper: kind.Undetermined.GetCount().GetUpper(),
 		}
 	case *pb.Value_Array:
-		if err := sysmlgrpc.CheckArrayShape(kind.Array.GetDimensions(), len(kind.Array.GetElements())); err != nil {
+		if err := protoconv.CheckArrayShape(kind.Array.GetDimensions(), len(kind.Array.GetElements())); err != nil {
 			return Null("unsupported: " + err.Error())
 		}
 		out := Array{
@@ -190,7 +190,7 @@ func valueFromProto(value *pb.Value) Value {
 		}
 		return out
 	case *pb.Value_TensorQuantity:
-		if err := sysmlgrpc.CheckTensorShape(kind.TensorQuantity.GetDimensions(), len(kind.TensorQuantity.GetComponents())); err != nil {
+		if err := protoconv.CheckTensorShape(kind.TensorQuantity.GetDimensions(), len(kind.TensorQuantity.GetComponents())); err != nil {
 			return Null("unsupported: " + err.Error())
 		}
 		out := TensorQuantity{
@@ -228,7 +228,7 @@ func valueToProto(value Value) (*pb.Value, error) {
 	case Real:
 		return &pb.Value{Kind: &pb.Value_RealValue{RealValue: float64(v)}}, nil
 	case Complex:
-		return &pb.Value{Kind: &pb.Value_Complex{Complex: sysmlgrpc.ComplexToProto(complex128(v))}}, nil
+		return &pb.Value{Kind: &pb.Value_Complex{Complex: protoconv.ComplexToProto(complex128(v))}}, nil
 	case Bool:
 		return &pb.Value{Kind: &pb.Value_BoolValue{BoolValue: bool(v)}}, nil
 	case String:
@@ -325,7 +325,7 @@ func valueToProto(value Value) (*pb.Value, error) {
 		}
 		return &pb.Value{Kind: &pb.Value_Set{Set: set}}, nil
 	case TensorQuantity:
-		if err := sysmlgrpc.CheckTensorShape(v.Dimensions, len(v.Components)); err != nil {
+		if err := protoconv.CheckTensorShape(v.Dimensions, len(v.Components)); err != nil {
 			return nil, &StatusError{Code: CodeInvalidArgument, Message: err.Error()}
 		}
 		tq := &pb.TensorQuantity{
diff --git a/client/opensysml/doc.go b/client/opensysml/doc.go
index 0b9692aee9..180d60db07 100644
--- a/client/opensysml/doc.go
+++ b/client/opensysml/doc.go
@@ -33,6 +33,37 @@
 // model handle, and editing — is refused with CodeFailedPrecondition rather than
 // applied to one document of several.
 //
+// # Sessions
+//
+// Every method on Client is request-scoped: ExecuteAction and ExecuteState run
+// a whole behaviour and answer what it did. A Session is the other shape — a
+// persistent, interactive run that keeps its clock, its scheduling policy and
+// the objects it instantiated between calls, so a caller instantiates a part,
+// sends its state machine a signal, performs an action on it and reads what
+// changed, one step at a time:
+//
+//	session, err := opensysml.OpenSession(client, model)
+//	hero, err := session.Instantiate("Play::hero")
+//	acceptance, err := session.Accepts(hero, "Play::Go", nil)
+//	_, err = session.Send(hero, "Play::Go", nil)
+//	advanced, err := session.Advance(1)
+//	performed, err := session.Perform(hero, "Play::Hero::pay", inputs)
+//
+// A Session is deliberately not part of the Client interface. Client is the
+// set of RPCs the service answers, held to identical answers from New and Dial
+// by the conformance suite; a session is state the engine holds between calls,
+// which the service exposes no RPC for. Rather than a Dial that answers some
+// methods and not others, OpenSession is a separate, in-process-only surface:
+// it is opened from a Client, so a program reaches it through the same handle
+// and model it uses for everything else, but only a client New returned can
+// answer it, and a Dial client is refused with CodeUnimplemented. What a
+// Session answers is facts — the transitions out of the active states, and
+// the states enclosing them, of every machine an object exhibits and what
+// fires them, whether a signal's guard holds now, the choice points a run made,
+// whether an action's opening decision turned its caller away — never the
+// engine's own graphs or objects, so the boundary the rest of the package keeps
+// holds here too.
+//
 // # Answers, verdicts and refusals
 //
 // Operations that ask something of the model answer what it says rather than
diff --git a/client/opensysml/documents.go b/client/opensysml/documents.go
index efbddd828a..db3b162b51 100644
--- a/client/opensysml/documents.go
+++ b/client/opensysml/documents.go
@@ -7,8 +7,9 @@ import (
 	pb "github.com/Open-MBEE/OpenSysML/api/proto"
 )
 
-// Cell is one typed document-query value: Element, String, Int, Real, Bool or
-// Infinity. A type switch over them is exhaustive.
+// Cell is one typed document-query value: Element, Object, String, Int, Real,
+// Bool, Quantity, Infinity, DocumentVerdict, DocumentState or DocumentEvent. A
+// type switch over them is exhaustive.
 type Cell interface {
 	isCell()
 }
@@ -22,22 +23,156 @@ type Element struct {
 	Type string
 }
 
+// Object is an object the service holds for the model, created by Instantiate.
+// Bound, it names the object by Path when set and by ID otherwise (both must
+// then name one object); answered, it carries ID, Path and Element.
+type Object struct {
+	// ID is the object's id, as Instantiate answered it.
+	ID int64
+	// Path is the object by the label a session reaches it under: the qualified
+	// name it was instantiated as ("Garage::car"), its id ("#2"), or a path
+	// through feature values of either ("Garage::car.wheels[2]", "#2.wheels[2]";
+	// indexes count from 1).
+	Path string
+	// Element is the usage the object is held under, its definition or usage;
+	// answered, ignored when bound.
+	Element Element
+}
+
+// ObjectByID names a held object by the id Instantiate answered.
+func ObjectByID(id int64) Object { return Object{ID: id} }
+
+// ObjectByPath names a held object by name, id or path ("car", "#2",
+// "car.wheels[2]").
+func ObjectByPath(path string) Object { return Object{Path: path} }
+
 // Infinity is an unbounded multiplicity. It is answered, never bound.
 type Infinity struct{}
 
-func (Element) isCell()  { /* marker: closed Cell set */ }
-func (Infinity) isCell() { /* marker: closed Cell set */ }
-func (String) isCell()   { /* marker: closed Cell set */ }
-func (Int) isCell()      { /* marker: closed Cell set */ }
-func (Real) isCell()     { /* marker: closed Cell set */ }
-func (Bool) isCell()     { /* marker: closed Cell set */ }
+// DocumentVerdict is a row a `Verdicts` query answered: an assertion checked
+// on the object at Path. It is answered, never bound.
+type DocumentVerdict struct {
+	// Assertion is the constraint, requirement, satisfy usage or verification
+	// case checked; its ID is empty when the assertion is anonymous.
+	Assertion Element
+	// Kind is "constraint", "requirement", "satisfaction" or "verification".
+	Kind string
+	// Text is the assertion as written ("assert constraint massKnown").
+	Text string
+	// Path names the object checked from the element the query was bound to
+	// ("Garage::car.wheels[2]").
+	Path string
+	// Status is "holds", "violated" or "undecided".
+	Status string
+	// Condition is the condition that evaluated to false, as written; empty otherwise.
+	Condition string
+	// Reason is why the assertion is violated or undecided; empty when it holds.
+	Reason string
+	// Verification is the verdict kinds of the verification cases verifying the
+	// requirement the row is about; a verification row's own kind.
+	Verification []string
+}
+
+// DocumentState is a row a `States` query answered: one active leaf state of
+// the state machine Object exhibits as Machine. It is answered, never bound.
+type DocumentState struct {
+	// Object is the object whose state the row reports.
+	Object Object
+	// Machine is the exhibited state usage's name ("lp"), or the state
+	// definition's when the object performs one directly.
+	Machine string
+	// Name is the active leaf state's name ("dim").
+	Name string
+	// Path is the leaf's dotted path from the machine's top level ("on.dim").
+	Path string
+	// State is the leaf's declaration; its ID is empty for an anonymous state.
+	State Element
+	// Region is the orthogonal region declaring the leaf ("light"); empty for a
+	// leaf outside any region.
+	Region string
+	// Enclosing are the active composite states above the leaf, outermost first.
+	Enclosing []string
+}
+
+// DocumentEvent is a row an `Events` query answered: one record of a session's
+// trace. It is answered, never bound.
+type DocumentEvent struct {
+	// Kind is "accept", "send", "transition", "entry", "exit", "do", "choice"
+	// or "guard".
+	Kind string
+	// Time is the run's clock when the record was made: a Quantity when the
+	// clock carries a unit, a Real otherwise.
+	Time Cell
+	// Object is the object the record is about; nil for a record of none.
+	Object *Object
+	// Machine is the state machine the record is about, as DocumentState.Machine names it.
+	Machine string
+	// State is the state entered, exited or performing, by path; From and To
+	// are a transition's source and target.
+	State, From, To string
+	// Target is the object a send was addressed to; nil for any other record.
+	Target *Object
+	// Event is the signal accepted or sent.
+	Event string
+	// Payload is the accepted signal's arguments as "name = value", by name.
+	Payload []string
+	// Alternatives are a choice's candidates as offered; Taken is the one drawn.
+	Alternatives []string
+	Taken        string
+	// Text is the record as the trace prints it.
+	Text string
+}
+
+func (Element) isCell()         { /* marker: closed Cell set */ }
+func (Object) isCell()          { /* marker: closed Cell set */ }
+func (Infinity) isCell()        { /* marker: closed Cell set */ }
+func (DocumentVerdict) isCell() { /* marker: closed Cell set */ }
+func (DocumentState) isCell()   { /* marker: closed Cell set */ }
+func (DocumentEvent) isCell()   { /* marker: closed Cell set */ }
+func (String) isCell()          { /* marker: closed Cell set */ }
+func (Int) isCell()             { /* marker: closed Cell set */ }
+func (Real) isCell()            { /* marker: closed Cell set */ }
+func (Bool) isCell()            { /* marker: closed Cell set */ }
+func (Quantity) isCell()        { /* marker: closed Cell set */ }
 
 // String is the element as a binding names it, its qualified name.
 func (e Element) String() string { return e.ID }
 
+// String is the object as a session names it: its path when it has one, else
+// its id ("#2").
+func (o Object) String() string {
+	if o.Path != "" {
+		return o.Path
+	}
+	return "#" + strconv.FormatInt(o.ID, 10)
+}
+
 // String reports an unbounded multiplicity as the notation writes it.
 func (Infinity) String() string { return "*" }
 
+// String is the verdict in one line, as the CLI reports it:
+// "assert constraint inflated on Garage::car.wheels[2]: violated".
+func (v DocumentVerdict) String() string {
+	if v.Path == "" {
+		return v.Text + ": " + v.Status
+	}
+	return v.Text + " on " + v.Path + ": " + v.Status
+}
+
+// String is the state in one line: "lamp1.lp in on.dim".
+func (s DocumentState) String() string {
+	return s.Object.String() + "." + s.Machine + " in " + s.Path
+}
+
+// String is the event as the trace prints it, prefixed by its time:
+// "1 s: accept Dim".
+func (e DocumentEvent) String() string {
+	if e.Time == nil {
+		return e.Text
+	}
+	return CellText(e.Time) + ": " + e.Text
+}
+
 // Binding binds one entry parameter of a document query. Several values bind a
 // nonscalar parameter.
 type Binding struct {
@@ -61,8 +196,22 @@ type Rows struct {
 
 // Row is one selected element and its projected cells, one per column.
 type Row struct {
-	// Element is the element the row is about.
+	// Element is the element the row is about: for an object row, the usage the
+	// object is held under; for a row a `Verdicts` query answered, the assertion
+	// checked.
 	Element Element
+	// Object is the object a row over held objects is about — one an `Objects`
+	// query enumerated or a bound object's part — nil for any other row.
+	Object *Object
+	// Verdict is the verdict a row a `Verdicts` query answered carries; nil for
+	// any other row.
+	Verdict *DocumentVerdict
+	// State is the state a row a `States` query answered carries; nil for any
+	// other row.
+	State *DocumentState
+	// Event is the trace record a row an `Events` query answered carries; nil
+	// for any other row.
+	Event *DocumentEvent
 	// Cells holds each column's values, in column order.
 	Cells [][]Cell
 }
@@ -99,8 +248,25 @@ func (c *client) RunDocumentQuery(
 	}
 	for _, row := range resp.Rows {
 		converted := Row{Cells: make([][]Cell, 0, len(row.Cells))}
-		if element, ok := cellFromProto(row.Element).(Element); ok {
-			converted.Element = element
+		switch selected := cellFromProto(row.Element).(type) {
+		case Element:
+			converted.Element = selected
+		case Object:
+			converted.Element = selected.Element
+			converted.Object = &selected
+		case DocumentVerdict:
+			converted.Element = selected.Assertion
+			converted.Verdict = &selected
+		case DocumentState:
+			converted.Element = selected.Object.Element
+			converted.Object = &selected.Object
+			converted.State = &selected
+		case DocumentEvent:
+			if selected.Object != nil {
+				converted.Element = selected.Object.Element
+				converted.Object = selected.Object
+			}
+			converted.Event = &selected
 		}
 		for _, cell := range row.Cells {
 			values := make([]Cell, 0, len(cell.Values))
@@ -126,14 +292,21 @@ func (c *client) RenderDocument(ctx context.Context, model *Model, documentID st
 	return resp.Markdown, nil
 }
 
-// cellToProto marshals a bound value. Infinity is refused here, as the service
-// refuses it: queries answer it, nothing binds it.
+// cellToProto marshals a bound value; infinity and the verdict, state and event
+// rows are refused here as the service refuses them: queries answer, nothing binds.
 func cellToProto(cell Cell) (*pb.DocumentValue, error) {
 	switch value := cell.(type) {
 	case nil:
 		return nil, &StatusError{Code: CodeInvalidArgument, Message: "a binding carries no value"}
 	case Element:
 		return &pb.DocumentValue{Kind: &pb.DocumentValue_ElementId{ElementId: value.ID}}, nil
+	case Object:
+		if value.ID == 0 && value.Path == "" {
+			return nil, &StatusError{Code: CodeInvalidArgument, Message: "an object is bound by id or by path; neither was given"}
+		}
+		return &pb.DocumentValue{Kind: &pb.DocumentValue_Object{
+			Object: &pb.DocumentObject{InstanceId: value.ID, Path: value.Path},
+		}}, nil
 	case String:
 		return &pb.DocumentValue{Kind: &pb.DocumentValue_StringValue{StringValue: string(value)}}, nil
 	case Int:
@@ -142,11 +315,32 @@ func cellToProto(cell Cell) (*pb.DocumentValue, error) {
 		return &pb.DocumentValue{Kind: &pb.DocumentValue_RealValue{RealValue: float64(value)}}, nil
 	case Bool:
 		return &pb.DocumentValue{Kind: &pb.DocumentValue_BoolValue{BoolValue: bool(value)}}, nil
+	case Quantity:
+		quantity, err := quantityToProto(value)
+		if err != nil {
+			return nil, err
+		}
+		return &pb.DocumentValue{Kind: &pb.DocumentValue_Quantity{Quantity: quantity}}, nil
 	case Infinity:
 		return nil, &StatusError{
 			Code:    CodeInvalidArgument,
 			Message: "infinity is answered by queries, not bound to them",
 		}
+	case DocumentVerdict:
+		return nil, &StatusError{
+			Code:    CodeInvalidArgument,
+			Message: "a verdict is answered by queries, not bound to them",
+		}
+	case DocumentState:
+		return nil, &StatusError{
+			Code:    CodeInvalidArgument,
+			Message: "a state row is answered by queries, not bound to them",
+		}
+	case DocumentEvent:
+		return nil, &StatusError{
+			Code:    CodeInvalidArgument,
+			Message: "an event row is answered by queries, not bound to them",
+		}
 	default:
 		return nil, &StatusError{Code: CodeInvalidArgument, Message: "unknown document value kind"}
 	}
@@ -164,13 +358,79 @@ func cellFromProto(value *pb.DocumentValue) Cell {
 		return Real(kind.RealValue)
 	case *pb.DocumentValue_BoolValue:
 		return Bool(kind.BoolValue)
+	case *pb.DocumentValue_Quantity:
+		quantity, ok := quantityFromProto(kind.Quantity)
+		if !ok {
+			return nil
+		}
+		return quantity
+	case *pb.DocumentValue_Object:
+		return objectFromProto(kind.Object)
 	case *pb.DocumentValue_Infinity:
 		return Infinity{}
+	case *pb.DocumentValue_Verdict:
+		verdict := DocumentVerdict{
+			Kind:         kind.Verdict.GetKind(),
+			Text:         kind.Verdict.GetText(),
+			Path:         kind.Verdict.GetPath(),
+			Status:       kind.Verdict.GetVerdict(),
+			Condition:    kind.Verdict.GetCondition(),
+			Reason:       kind.Verdict.GetReason(),
+			Verification: append([]string(nil), kind.Verdict.GetVerification()...),
+		}
+		if assertion, ok := cellFromProto(kind.Verdict.GetAssertion()).(Element); ok {
+			verdict.Assertion = assertion
+		}
+		return verdict
+	case *pb.DocumentValue_State:
+		state := DocumentState{
+			Object:    objectFromProto(kind.State.GetObject()),
+			Machine:   kind.State.GetMachine(),
+			Name:      kind.State.GetName(),
+			Path:      kind.State.GetStatePath(),
+			Region:    kind.State.GetRegion(),
+			Enclosing: append([]string(nil), kind.State.GetEnclosing()...),
+		}
+		if declaration, ok := cellFromProto(kind.State.GetState()).(Element); ok {
+			state.State = declaration
+		}
+		return state
+	case *pb.DocumentValue_Event:
+		event := DocumentEvent{
+			Kind:         kind.Event.GetKind(),
+			Time:         cellFromProto(kind.Event.GetTime()),
+			Machine:      kind.Event.GetMachine(),
+			State:        kind.Event.GetState(),
+			From:         kind.Event.GetFrom(),
+			To:           kind.Event.GetTo(),
+			Event:        kind.Event.GetEvent(),
+			Payload:      append([]string(nil), kind.Event.GetPayload()...),
+			Alternatives: append([]string(nil), kind.Event.GetAlternatives()...),
+			Taken:        kind.Event.GetTaken(),
+			Text:         kind.Event.GetText(),
+		}
+		if kind.Event.GetObject() != nil {
+			object := objectFromProto(kind.Event.GetObject())
+			event.Object = &object
+		}
+		if kind.Event.GetTarget() != nil {
+			target := objectFromProto(kind.Event.GetTarget())
+			event.Target = &target
+		}
+		return event
 	default:
 		return nil
 	}
 }
 
+func objectFromProto(object *pb.DocumentObject) Object {
+	out := Object{ID: object.GetInstanceId(), Path: object.GetPath()}
+	if element, ok := cellFromProto(object.GetElement()).(Element); ok {
+		out.Element = element
+	}
+	return out
+}
+
 // CellText renders one cell value as a report writes it, the way the CLI's
 // document tables do.
 func CellText(cell Cell) string {
@@ -179,6 +439,8 @@ func CellText(cell Cell) string {
 		return ""
 	case Element:
 		return value.ID
+	case Object:
+		return value.String()
 	case String:
 		return string(value)
 	case Int:
@@ -187,8 +449,16 @@ func CellText(cell Cell) string {
 		return strconv.FormatFloat(float64(value), 'g', -1, 64)
 	case Bool:
 		return strconv.FormatBool(bool(value))
+	case Quantity:
+		return value.String()
 	case Infinity:
 		return "*"
+	case DocumentVerdict:
+		return value.String()
+	case DocumentState:
+		return value.String()
+	case DocumentEvent:
+		return value.String()
 	default:
 		return ""
 	}
diff --git a/client/opensysml/engines_internal_test.go b/client/opensysml/engines_internal_test.go
index 04cbfb66e3..1543727480 100644
--- a/client/opensysml/engines_internal_test.go
+++ b/client/opensysml/engines_internal_test.go
@@ -22,6 +22,11 @@ func (o *oldCaller) verifySatisfaction(context.Context, *pb.VerifySatisfactionRe
 	return nil, nil
 }
 
+func (o *oldCaller) validateInstance(context.Context, *pb.ValidateInstanceRequest) (*pb.ValidateInstanceResponse, error) {
+	o.t.Fatal("an engine was sent to a service without engines")
+	return nil, nil
+}
+
 // A named engine is refused before it leaves the client when the service lacks
 // the engines capability, since such a service would answer with whichever
 // engine it chose: whether it predates the capability or GetServerInfo itself.
@@ -41,6 +46,8 @@ func TestANamedEngineIsNotSentWithoutTheCapability(t *testing.T) {
 			wantUnimplemented(t, "VerifyRequirement", err)
 			_, err = c.VerifySatisfaction(ctx, model, "", WithEngine(EngineAll))
 			wantUnimplemented(t, "VerifySatisfaction", err)
+			_, err = c.ValidateInstance(ctx, model, "P", WithEngine("run"))
+			wantUnimplemented(t, "ValidateInstance", err)
 			_, err = c.RunAnalysis(ctx, model, "an", Engine("run"))
 			wantUnimplemented(t, "RunAnalysis", err)
 			_, err = c.Calculate(ctx, model, "f", CalcArguments(Int(1)), CalcEngine("run"))
diff --git a/client/opensysml/errors.go b/client/opensysml/errors.go
index 7400afa285..fe74cf892a 100644
--- a/client/opensysml/errors.go
+++ b/client/opensysml/errors.go
@@ -135,8 +135,12 @@ type EditError struct {
 	// Failure says which refusal this is.
 	Failure EditFailure
 	// Referring are the FQNs of the namespaces referring to a declaration whose
-	// rename or deletion was refused.
+	// rename or deletion was refused, each suffixed with its document in
+	// parentheses when that is not the edited one.
 	Referring []string
+	// Referrers is Referring with each document as a field of its own, in
+	// document then name order.
+	Referrers []Referrer
 }
 
 // Unwrap exposes the failure, so errors.As recovers a *FailureError from a
diff --git a/client/opensysml/execute.go b/client/opensysml/execute.go
index b23e58e2ed..ec2a2d4dd7 100644
--- a/client/opensysml/execute.go
+++ b/client/opensysml/execute.go
@@ -38,7 +38,8 @@ type StateRun struct {
 type ExecuteOption func(*executeOptions)
 
 type executeOptions struct {
-	schedule string
+	schedule  string
+	performer string
 }
 
 // WithSchedule names the policy a run resolves its choice points under, as sysml
@@ -47,6 +48,31 @@ func WithSchedule(policy string) ExecuteOption {
 	return func(o *executeOptions) { o.schedule = policy }
 }
 
+// PerformedBy names the object performing the behavior, as sysml -action "<action> <object>"
+// does: a declaration the run creates an object of, or a path into one ("Mission::mission.vehicle").
+func PerformedBy(path string) ExecuteOption {
+	return func(o *executeOptions) { o.performer = path }
+}
+
+// requirePerformer refuses to send a performer to a service without the
+// performer capability, which would run outside any object rather than refuse it.
+func (c *client) requirePerformer(ctx context.Context, performer string) error {
+	if performer == "" {
+		return nil
+	}
+	info, err := c.serverInfo(ctx)
+	if err != nil {
+		return err
+	}
+	if !info.Has(CapabilityPerformer) {
+		return &StatusError{
+			Code:    CodeUnimplemented,
+			Message: fmt.Sprintf("capability %q is unavailable", CapabilityPerformer),
+		}
+	}
+	return nil
+}
+
 // requireSchedule refuses to send a policy to a service without the schedule
 // capability, which would run under the default rather than refuse it.
 func (c *client) requireSchedule(ctx context.Context, policy string) error {
@@ -87,7 +113,10 @@ func (c *client) ExecuteAction(
 	if err := c.requireSchedule(ctx, options.schedule); err != nil {
 		return nil, err
 	}
-	req := &pb.ExecuteActionRequest{ModelHash: hash, ActionSymbolId: actionSymbolID, Schedule: options.schedule}
+	if err := c.requirePerformer(ctx, options.performer); err != nil {
+		return nil, err
+	}
+	req := &pb.ExecuteActionRequest{ModelHash: hash, ActionSymbolId: actionSymbolID, Schedule: options.schedule, PerformerSymbolId: options.performer}
 	if len(inputs) > 0 {
 		if err := c.requireValueCapabilities(ctx, slices.Collect(maps.Values(inputs))...); err != nil {
 			return nil, err
@@ -133,11 +162,15 @@ func (c *client) ExecuteState(
 	if err := c.requireSchedule(ctx, options.schedule); err != nil {
 		return nil, err
 	}
+	if err := c.requirePerformer(ctx, options.performer); err != nil {
+		return nil, err
+	}
 	resp, err := c.caller.executeState(ctx, &pb.ExecuteStateRequest{
 		ModelHash:            hash,
 		StateMachineSymbolId: stateMachineSymbolID,
 		Events:               append([]string(nil), events...),
 		Schedule:             options.schedule,
+		PerformerSymbolId:    options.performer,
 	})
 	if err != nil {
 		return nil, err
diff --git a/client/opensysml/explore.go b/client/opensysml/explore.go
index 04423a12fa..5af1bbd2dc 100644
--- a/client/opensysml/explore.go
+++ b/client/opensysml/explore.go
@@ -24,6 +24,9 @@ type Outcome struct {
 	Error string
 	// Linearizations is how many runs within the budget reached this outcome.
 	Linearizations int
+	// Probability is the share of the schedule space the linearizations reaching
+	// this outcome carry; a lower bound while the exploration is incomplete.
+	Probability float64
 	// Witness is one run's choices in run order, one per choice point it
 	// resolved, each spelling the alternatives and the one taken.
 	Witness []string
@@ -50,6 +53,9 @@ type Exploration struct {
 	// may make, and choice points one run may resolve.
 	RunsBudget  int
 	DepthBudget int
+	// ProbabilitiesLowerBound reports the outcomes' probabilities are lower
+	// bounds: a budget kept some linearizations unexplored.
+	ProbabilitiesLowerBound bool
 }
 
 // Status renders how the exploration ended as the sysml command does:
@@ -66,7 +72,7 @@ func (e *Exploration) Status() string {
 		}
 		named[i] = fmt.Sprintf("%s budget %d", budget, limit)
 	}
-	return fmt.Sprintf("incomplete: %s hit after %d runs", strings.Join(named, " and "), e.Runs)
+	return fmt.Sprintf("incomplete: %s hit after %d runs; probabilities are lower bounds", strings.Join(named, " and "), e.Runs)
 }
 
 // explores reports a policy spelled as the explore schedule, with or without
@@ -142,7 +148,10 @@ func (c *client) ExploreAction(
 	if err := c.requireExplore(ctx); err != nil {
 		return nil, err
 	}
-	req := &pb.ExecuteActionRequest{ModelHash: hash, ActionSymbolId: actionSymbolID, Schedule: policy}
+	if err := c.requirePerformer(ctx, options.performer); err != nil {
+		return nil, err
+	}
+	req := &pb.ExecuteActionRequest{ModelHash: hash, ActionSymbolId: actionSymbolID, Schedule: policy, PerformerSymbolId: options.performer}
 	if len(inputs) > 0 {
 		if err := c.requireValueCapabilities(ctx, slices.Collect(maps.Values(inputs))...); err != nil {
 			return nil, err
@@ -188,11 +197,15 @@ func (c *client) ExploreState(
 	if err := c.requireExplore(ctx); err != nil {
 		return nil, err
 	}
+	if err := c.requirePerformer(ctx, options.performer); err != nil {
+		return nil, err
+	}
 	resp, err := c.caller.executeState(ctx, &pb.ExecuteStateRequest{
 		ModelHash:            hash,
 		StateMachineSymbolId: stateMachineSymbolID,
 		Events:               append([]string(nil), events...),
 		Schedule:             policy,
+		PerformerSymbolId:    options.performer,
 	})
 	if err != nil {
 		return nil, err
@@ -246,6 +259,7 @@ func explorationFromProto(outcomes []*pb.Outcome, status *pb.ExplorationStatus)
 			Visited:        append([]string(nil), outcome.StatesVisited...),
 			Error:          outcome.Error,
 			Linearizations: int(outcome.Linearizations),
+			Probability:    outcome.Probability,
 			Witness:        append([]string(nil), outcome.Witness...),
 			Diagnostics:    diagnosticsFromProto(outcome.Diagnostics),
 		})
@@ -256,6 +270,7 @@ func explorationFromProto(outcomes []*pb.Outcome, status *pb.ExplorationStatus)
 		out.BudgetsHit = append([]string(nil), status.BudgetsHit...)
 		out.RunsBudget = int(status.RunsBudget)
 		out.DepthBudget = int(status.DepthBudget)
+		out.ProbabilitiesLowerBound = status.ProbabilitiesLowerBound
 	}
 	return out
 }
diff --git a/client/opensysml/explore_test.go b/client/opensysml/explore_test.go
index 018151f5b2..6b2bc15fef 100644
--- a/client/opensysml/explore_test.go
+++ b/client/opensysml/explore_test.go
@@ -139,9 +139,9 @@ func TestExploringUnderABudgetIsIncomplete(t *testing.T) {
 	model := parse(t, client, exploreSource)
 
 	for _, test := range []struct{ schedule, hit, status string }{
-		{"explore:runs=2", "runs", "incomplete: runs budget 2 hit after 2 runs"},
-		{"explore:depth=1", "depth", "incomplete: depth budget 1 hit after 3 runs"},
-		{"explore:depth=1,runs=1", "runs,depth", "incomplete: runs budget 1 and depth budget 1 hit after 1 runs"},
+		{"explore:runs=2", "runs", "incomplete: runs budget 2 hit after 2 runs; probabilities are lower bounds"},
+		{"explore:depth=1", "depth", "incomplete: depth budget 1 hit after 3 runs; probabilities are lower bounds"},
+		{"explore:depth=1,runs=1", "runs,depth", "incomplete: runs budget 1 and depth budget 1 hit after 1 runs; probabilities are lower bounds"},
 	} {
 		exploration, err := client.ExploreAction(ctx, model, "Explored::three", nil, opensysml.WithSchedule(test.schedule))
 		if err != nil {
diff --git a/client/opensysml/function_test.go b/client/opensysml/function_test.go
index d7196aeaa0..ea3b84a812 100644
--- a/client/opensysml/function_test.go
+++ b/client/opensysml/function_test.go
@@ -11,7 +11,7 @@ import (
 
 	"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
 	"github.com/Open-MBEE/OpenSysML/client/opensysml"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 )
 
 const functionSource = `package F {
diff --git a/client/opensysml/inprocess.go b/client/opensysml/inprocess.go
index 8727cf57b2..387d173eaa 100644
--- a/client/opensysml/inprocess.go
+++ b/client/opensysml/inprocess.go
@@ -8,7 +8,7 @@ import (
 
 	"connectrpc.com/connect"
 	pb "github.com/Open-MBEE/OpenSysML/api/proto"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 )
 
 // defaultCacheSize matches the sysml-grpc default, so the two implementations
@@ -158,6 +158,13 @@ func (p *inprocess) verifySatisfaction(
 	return answer(ctx, req, p.svc.VerifySatisfaction)
 }
 
+func (p *inprocess) validateInstance(
+	ctx context.Context,
+	req *pb.ValidateInstanceRequest,
+) (*pb.ValidateInstanceResponse, error) {
+	return answer(ctx, req, p.svc.ValidateInstance)
+}
+
 func (p *inprocess) evaluateCalc(ctx context.Context, req *pb.EvaluateCalcRequest) (*pb.EvaluateCalcResponse, error) {
 	return answer(ctx, req, p.svc.EvaluateCalc)
 }
diff --git a/client/opensysml/measurement_ref_test.go b/client/opensysml/measurement_ref_test.go
index 126c540299..a613acf299 100644
--- a/client/opensysml/measurement_ref_test.go
+++ b/client/opensysml/measurement_ref_test.go
@@ -12,7 +12,7 @@ import (
 
 	"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
 	"github.com/Open-MBEE/OpenSysML/client/opensysml"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 )
 
 const measurementRefSource = `package M {
diff --git a/client/opensysml/metaobject_test.go b/client/opensysml/metaobject_test.go
index bb4ae7be17..9286697b61 100644
--- a/client/opensysml/metaobject_test.go
+++ b/client/opensysml/metaobject_test.go
@@ -11,7 +11,7 @@ import (
 
 	"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
 	"github.com/Open-MBEE/OpenSysML/client/opensysml"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 )
 
 const metaobjectSource = `package M {
diff --git a/client/opensysml/objects_test.go b/client/opensysml/objects_test.go
new file mode 100644
index 0000000000..19b1ff8edb
--- /dev/null
+++ b/client/opensysml/objects_test.go
@@ -0,0 +1,224 @@
+package opensysml_test
+
+import (
+	"context"
+	"errors"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/Open-MBEE/OpenSysML/client/opensysml"
+)
+
+const objectQuerySource = `package Garage {
+	private import DocumentQueries::*;
+	private import KerML::Root::Element;
+	private import ScalarValues::*;
+
+	part def Wheel {
+		attribute pressure : Integer default 30;
+		assert constraint inflated { pressure >= 25 }
+	}
+	part def Car {
+		attribute mass : Integer = 1200;
+		part wheels : Wheel[2];
+	}
+	part car : Car;
+	part spare : Wheel {
+		attribute :>> pressure = 20;
+	}
+
+	calc def Parts :> Query {
+		in root : Element;
+		Project(source = OwnedElements(source = root), properties = ("name", "pressure"))
+	}
+	calc def Drive :> Query {
+		in root : Element;
+		Project(source = root, properties = ("mass", "wheels"))
+	}
+	calc def Pressure :> Query {
+		in root : Element;
+		Project(source = root, properties = ("name", "pressure"))
+	}
+	calc def Wheels :> Query {
+		Project(source = Objects(type = "Wheel"), properties = ("pressure"))
+	}
+	calc def Checks :> Query {
+		in root : Element;
+		Project(source = Verdicts(source = root), properties = ("path", "verdict"))
+	}
+}`
+
+func holdCar(t *testing.T, client opensysml.Client, model *opensysml.Model) *opensysml.Instantiation {
+	t.Helper()
+	held, err := client.Instantiate(context.Background(), model, "Garage::car")
+	if err != nil {
+		t.Fatalf("Instantiate: %v", err)
+	}
+	return held
+}
+
+func TestRunDocumentQueryBindsAHeldObjectByID(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, objectQuerySource)
+	held := holdCar(t, client, model)
+
+	rows, err := client.RunDocumentQuery(context.Background(), model, "Garage::Parts",
+		opensysml.Bind("root", opensysml.ObjectByID(held.Root.ID)))
+	if err != nil {
+		t.Fatalf("RunDocumentQuery: %v", err)
+	}
+	if strings.Join(rows.Columns, ",") != "name,pressure" {
+		t.Errorf("columns = %v, want name, pressure", rows.Columns)
+	}
+	if len(rows.Rows) != 2 {
+		t.Fatalf("rows = %d, want the car's two wheels", len(rows.Rows))
+	}
+	for i, row := range rows.Rows {
+		if row.Object == nil {
+			t.Fatalf("row %d is not an object row: %+v", i, row)
+		}
+		wantPath := "#1.wheels[" + string(rune('1'+i)) + "]"
+		if row.Object.Path != wantPath || row.Object.ID == 0 || row.Object.ID == held.Root.ID {
+			t.Errorf("row %d object = %+v, want path %s and an id of its own", i, *row.Object, wantPath)
+		}
+		want := opensysml.Element{ID: "Garage::Car::wheels", Type: "PartUsage"}
+		if row.Object.Element != want || row.Element != want {
+			t.Errorf("row %d element = %+v / %+v, want %+v", i, row.Object.Element, row.Element, want)
+		}
+		if got := opensysml.CellText(row.Cells[1][0]); got != "30" {
+			t.Errorf("row %d pressure = %q, want 30", i, got)
+		}
+	}
+}
+
+func TestRunDocumentQueryBindsAHeldObjectByPath(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, objectQuerySource)
+	holdCar(t, client, model)
+
+	rows, err := client.RunDocumentQuery(context.Background(), model, "Garage::Drive",
+		opensysml.Bind("root", opensysml.ObjectByPath("car")))
+	if err != nil {
+		t.Fatalf("RunDocumentQuery: %v", err)
+	}
+	if len(rows.Rows) != 1 {
+		t.Fatalf("rows = %d, want the bound car itself", len(rows.Rows))
+	}
+	row := rows.Rows[0]
+	want := opensysml.Object{ID: 1, Path: "Garage::car", Element: opensysml.Element{ID: "Garage::car", Type: "PartUsage"}}
+	if row.Object == nil || *row.Object != want {
+		t.Errorf("row object = %+v, want %+v", row.Object, want)
+	}
+	if got := opensysml.CellText(row.Cells[0][0]); got != "1200" {
+		t.Errorf("mass = %q, want 1200", got)
+	}
+	var wheels []string
+	for _, cell := range row.Cells[1] {
+		wheel, ok := cell.(opensysml.Object)
+		if !ok {
+			t.Fatalf("wheels cell %#v is not an object", cell)
+		}
+		wheels = append(wheels, wheel.String())
+	}
+	if want := []string{"Garage::car.wheels[1]", "Garage::car.wheels[2]"}; !reflect.DeepEqual(wheels, want) {
+		t.Errorf("wheels = %v, want %v", wheels, want)
+	}
+
+	nested, err := client.RunDocumentQuery(context.Background(), model, "Garage::Pressure",
+		opensysml.Bind("root", opensysml.ObjectByPath("car.wheels[2]")))
+	if err != nil {
+		t.Fatalf("RunDocumentQuery by nested path: %v", err)
+	}
+	if len(nested.Rows) != 1 || nested.Rows[0].Object == nil || nested.Rows[0].Object.Path != "Garage::car.wheels[2]" {
+		t.Fatalf("rows = %+v, want the second wheel", nested.Rows)
+	}
+	if got := opensysml.CellText(nested.Rows[0].Cells[0][0]) + "=" + opensysml.CellText(nested.Rows[0].Cells[1][0]); got != "wheels[2]=30" {
+		t.Errorf("wheel cells = %q, want wheels[2]=30", got)
+	}
+}
+
+func TestRunDocumentQueryEnumeratesHeldObjectsAndTheirVerdicts(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, objectQuerySource)
+	holdCar(t, client, model)
+	if _, err := client.Instantiate(context.Background(), model, "Garage::spare"); err != nil {
+		t.Fatalf("Instantiate spare: %v", err)
+	}
+
+	rows, err := client.RunDocumentQuery(context.Background(), model, "Garage::Wheels")
+	if err != nil {
+		t.Fatalf("RunDocumentQuery: %v", err)
+	}
+	var wheels []string
+	for _, row := range rows.Rows {
+		if row.Object == nil {
+			t.Fatalf("row %+v is not an object row", row)
+		}
+		wheels = append(wheels, row.Object.String()+"="+opensysml.CellText(row.Cells[0][0]))
+	}
+	// Held roots first, in label order, then the objects they hold.
+	want := []string{"Garage::spare=20", "Garage::car.wheels[1]=30", "Garage::car.wheels[2]=30"}
+	if !reflect.DeepEqual(wheels, want) {
+		t.Errorf("Objects rows = %v, want %v", wheels, want)
+	}
+
+	checks, err := client.RunDocumentQuery(context.Background(), model, "Garage::Checks",
+		opensysml.Bind("root", opensysml.ObjectByPath("spare")))
+	if err != nil {
+		t.Fatalf("RunDocumentQuery Checks: %v", err)
+	}
+	if len(checks.Rows) != 1 || checks.Rows[0].Verdict == nil {
+		t.Fatalf("rows = %+v, want the spare's one verdict", checks.Rows)
+	}
+	if got := checks.Rows[0].Verdict.String(); got != "assert constraint inflated on Garage::spare: violated" {
+		t.Errorf("verdict = %q", got)
+	}
+}
+
+func TestAnObjectBindingNamesWhatItCannotReach(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, objectQuerySource)
+
+	_, err := client.RunDocumentQuery(context.Background(), model, "Garage::Parts",
+		opensysml.Bind("root", opensysml.ObjectByID(1)))
+	if !errors.Is(err, opensysml.CodeNotFound) || !strings.Contains(err.Error(), "holds no objects") {
+		t.Errorf("nothing held: err = %v, want CodeNotFound saying the model holds no objects", err)
+	}
+
+	holdCar(t, client, model)
+	for _, tc := range []struct {
+		object opensysml.Object
+		code   opensysml.Code
+		text   string
+	}{
+		{opensysml.ObjectByID(99), opensysml.CodeNotFound, "no object #99"},
+		{opensysml.ObjectByPath("car.hood"), opensysml.CodeInvalidArgument, `no feature "hood"`},
+		{opensysml.ObjectByPath("car.mass"), opensysml.CodeInvalidArgument, "not an object"},
+		{opensysml.ObjectByPath("car..wheels"), opensysml.CodeInvalidArgument, "not an object reference"},
+		{opensysml.ObjectByPath("spare"), opensysml.CodeNotFound, `no instance of "Garage::spare"`},
+		{opensysml.Object{ID: 2, Path: "car"}, opensysml.CodeInvalidArgument, "is object #1, not #2"},
+	} {
+		_, err := client.RunDocumentQuery(context.Background(), model, "Garage::Parts", opensysml.Bind("root", tc.object))
+		if !errors.Is(err, tc.code) || !strings.Contains(err.Error(), tc.text) {
+			t.Errorf("%v: err = %v, want %v containing %q", tc.object, err, tc.code, tc.text)
+		}
+	}
+
+	_, err = client.RunDocumentQuery(context.Background(), model, "Garage::Parts", opensysml.Bind("root", opensysml.Object{}))
+	if !errors.Is(err, opensysml.CodeInvalidArgument) {
+		t.Errorf("empty object: err = %v, want CodeInvalidArgument", err)
+	}
+}
+
+func TestObjectStringNamesTheObjectAsASessionDoes(t *testing.T) {
+	if got := opensysml.ObjectByID(2).String(); got != "#2" {
+		t.Errorf("by id = %q, want #2", got)
+	}
+	if got := opensysml.ObjectByPath("car.wheels[2]").String(); got != "car.wheels[2]" {
+		t.Errorf("by path = %q", got)
+	}
+	if got := opensysml.CellText(opensysml.Object{ID: 3, Path: "Garage::car.wheels[2]"}); got != "Garage::car.wheels[2]" {
+		t.Errorf("CellText = %q", got)
+	}
+}
diff --git a/client/opensysml/performer_test.go b/client/opensysml/performer_test.go
new file mode 100644
index 0000000000..11ad5580e1
--- /dev/null
+++ b/client/opensysml/performer_test.go
@@ -0,0 +1,81 @@
+package opensysml_test
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"testing"
+
+	"github.com/Open-MBEE/OpenSysML/client/opensysml"
+)
+
+const performerSource = `package Wire {
+	private import ScalarValues::*;
+	item def Ping;
+	port def Link { in item ping : Ping; }
+	part def Ground {
+		port p : ~Link;
+		exhibit state hail { entry; then go; state go { entry send new Ping() via p; } }
+	}
+	part def Craft {
+		port p : Link;
+		attribute pinged : Boolean = false;
+		exhibit state modes {
+			entry; then waiting;
+			state waiting;
+			transition first waiting accept Ping via p then active;
+			state active { entry assign pinged := true; }
+		}
+		action look { out seen : Boolean; first start; then action read assign seen := pinged; then done; }
+	}
+	part def Pair {
+		part ground : Ground;
+		part craft : Craft;
+		connect craft.p to ground.p;
+	}
+	part pair : Pair;
+}`
+
+// PerformedBy runs a behavior on the object a declaration-rooted path reaches
+// inside its assembly, so a machine the part exhibits hears its siblings over
+// the connector; the declaration alone makes the part on its own.
+func TestPerformedByRunsOnTheObjectAPathReaches(t *testing.T) {
+	ctx := context.Background()
+	client := newClient(t)
+	model := parse(t, client, performerSource)
+
+	for _, test := range []struct{ performer, final string }{
+		{"Wire::pair.craft", "active"},
+		{"Wire::Craft", "waiting"},
+	} {
+		run, err := client.ExecuteState(ctx, model, "Wire::Craft::modes", nil, opensysml.PerformedBy(test.performer))
+		if err != nil {
+			t.Fatalf("ExecuteState on %s: %v", test.performer, err)
+		}
+		if run.Visited[len(run.Visited)-1] != test.final {
+			t.Errorf("ExecuteState on %s visited %v, want to end in %s", test.performer, run.Visited, test.final)
+		}
+		exploration, err := client.ExploreState(ctx, model, "Wire::Craft::modes", nil, opensysml.PerformedBy(test.performer))
+		if err != nil {
+			t.Fatalf("ExploreState on %s: %v", test.performer, err)
+		}
+		if len(exploration.Outcomes) != 1 || exploration.Outcomes[0].FinalState != test.final {
+			t.Errorf("ExploreState on %s: %+v, want one outcome ending in %s", test.performer, exploration.Outcomes, test.final)
+		}
+	}
+
+	run, err := client.ExecuteAction(ctx, model, "Wire::Craft::look", nil, opensysml.PerformedBy("Wire::pair.craft"))
+	if err != nil || run.Outputs["seen"] != opensysml.Bool(true) {
+		t.Errorf("ExecuteAction look on pair.craft: %v %#v, want seen true", err, run)
+	}
+	exploration, err := client.ExploreAction(ctx, model, "Wire::Craft::look", nil, opensysml.PerformedBy("Wire::pair.craft"))
+	if err != nil || len(exploration.Outcomes) != 1 || exploration.Outcomes[0].Outputs["seen"] != opensysml.Bool(true) {
+		t.Errorf("ExploreAction look on pair.craft: %v %+v, want one outcome with seen true", err, exploration)
+	}
+
+	_, err = client.ExecuteState(ctx, model, "Wire::Craft::modes", nil, opensysml.PerformedBy("Wire::pair.tug"))
+	var failure *opensysml.FailureError
+	if !errors.As(err, &failure) || !strings.Contains(failure.Message, `Wire::pair has no feature "tug"`) {
+		t.Errorf("ExecuteState on pair.tug: %v, want the missing feature reported", err)
+	}
+}
diff --git a/client/opensysml/remote.go b/client/opensysml/remote.go
index fe4b299d17..2740d37ef1 100644
--- a/client/opensysml/remote.go
+++ b/client/opensysml/remote.go
@@ -183,6 +183,17 @@ func (r *remote) verifySatisfaction(
 	return resp.Msg, nil
 }
 
+func (r *remote) validateInstance(
+	ctx context.Context,
+	req *pb.ValidateInstanceRequest,
+) (*pb.ValidateInstanceResponse, error) {
+	resp, err := r.rpc.ValidateInstance(ctx, connect.NewRequest(req))
+	if err != nil {
+		return nil, connectToError(err)
+	}
+	return resp.Msg, nil
+}
+
 func (r *remote) evaluateCalc(ctx context.Context, req *pb.EvaluateCalcRequest) (*pb.EvaluateCalcResponse, error) {
 	resp, err := r.rpc.EvaluateCalc(ctx, connect.NewRequest(req))
 	if err != nil {
diff --git a/client/opensysml/remote_test.go b/client/opensysml/remote_test.go
index 84e8cf9a57..69d07ffe8f 100644
--- a/client/opensysml/remote_test.go
+++ b/client/opensysml/remote_test.go
@@ -9,7 +9,7 @@ import (
 
 	"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
 	"github.com/Open-MBEE/OpenSysML/client/opensysml"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 )
 
 // startService serves the Connect transport in this test process, so the
diff --git a/client/opensysml/schedule_internal_test.go b/client/opensysml/schedule_internal_test.go
index 5903d011a1..2a1bce0b38 100644
--- a/client/opensysml/schedule_internal_test.go
+++ b/client/opensysml/schedule_internal_test.go
@@ -62,3 +62,20 @@ func TestAnExplorationIsNotSentWithoutTheCapability(t *testing.T) {
 		})
 	}
 }
+
+// A performer is refused before it leaves the client when the service lacks the
+// performer capability, since such a service would run outside any object instead.
+func TestAPerformerIsNotSentWithoutTheCapability(t *testing.T) {
+	ctx := context.Background()
+	model := &Model{Hash: "h"}
+	old := &oldCaller{t: t, capabilities: []string{CapabilityVerification, CapabilitySchedule, CapabilityScheduleExplore}}
+	c := &client{caller: old}
+	_, err := c.ExecuteAction(ctx, model, "A", nil, PerformedBy("M::m.part"))
+	wantUnimplemented(t, "ExecuteAction", err)
+	_, err = c.ExecuteState(ctx, model, "M", nil, PerformedBy("M::m.part"))
+	wantUnimplemented(t, "ExecuteState", err)
+	_, err = c.ExploreAction(ctx, model, "A", nil, PerformedBy("M::m.part"))
+	wantUnimplemented(t, "ExploreAction", err)
+	_, err = c.ExploreState(ctx, model, "M", nil, PerformedBy("M::m.part"))
+	wantUnimplemented(t, "ExploreState", err)
+}
diff --git a/client/opensysml/session.go b/client/opensysml/session.go
new file mode 100644
index 0000000000..6c0a23458e
--- /dev/null
+++ b/client/opensysml/session.go
@@ -0,0 +1,503 @@
+package opensysml
+
+import (
+	"sync"
+
+	pb "github.com/Open-MBEE/OpenSysML/api/proto"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
+)
+
+// Session is a persistent, interactive run of one model: it keeps a clock, a
+// scheduling policy and the objects it instantiated across calls, so a caller
+// can instantiate a part, send its state machine a signal, perform an action
+// on it and read what changed, one step at a time. Sessions are answered in
+// process only — see OpenSession — and are not part of the Client interface.
+// A Session is safe for concurrent use; every call is answered before it
+// returns, bounded by the same step budget as ExecuteAction.
+type Session struct {
+	mu     sync.Mutex
+	engine *sysmlgrpc.Session
+	closed bool
+}
+
+// OpenSession opens a Session over a model the client parsed. Only a client
+// New returned can answer: a session is state the engine holds between calls,
+// which the service exposes no RPC for, so a Dial client answers
+// CodeUnimplemented. The model must still be in the client's cache
+// (CodeNotFound otherwise); the session then holds it for as long as it lives.
+func OpenSession(c Client, model *Model) (*Session, error) {
+	cl, ok := c.(*client)
+	if !ok {
+		return nil, &StatusError{Code: CodeInvalidArgument, Message: "OpenSession takes a client New or Dial returned"}
+	}
+	hash, err := cl.call(model)
+	if err != nil {
+		return nil, err
+	}
+	local, ok := cl.caller.(*inprocess)
+	if !ok {
+		return nil, &StatusError{
+			Code:    CodeUnimplemented,
+			Message: "persistent sessions are answered in process only: open one from a client New returned",
+		}
+	}
+	engine, err := local.svc.OpenSession(hash)
+	if err != nil {
+		return nil, statusToError(err)
+	}
+	return &Session{engine: engine}, nil
+}
+
+// Close ends the session and releases what it holds. Every later call answers
+// CodeUnavailable; closing twice is harmless.
+func (s *Session) Close() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.closed {
+		return nil
+	}
+	s.closed = true
+	s.engine.Close()
+	return nil
+}
+
+// live refuses a closed session.
+func (s *Session) live() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.closed {
+		return &StatusError{Code: CodeUnavailable, Message: "the session is closed"}
+	}
+	return nil
+}
+
+// answer runs one engine call behind the public boundary: a closed session, a
+// status or a panic becomes a StatusError, the model's own failure a FailureError.
+func (s *Session) answer(op string, call func() error) (err error) {
+	if err := s.live(); err != nil {
+		return err
+	}
+	defer recoverToError(&err)
+	return sessionError(op, call())
+}
+
+// sessionError maps what the engine answered to the documented error types.
+func sessionError(op string, err error) error {
+	if err == nil {
+		return nil
+	}
+	if failure, ok := err.(*sysmlgrpc.SessionFailure); ok {
+		return &FailureError{Op: op, Message: failure.Message, Diagnostics: diagnosticsFromProto(failure.Diagnostics)}
+	}
+	return statusToError(err)
+}
+
+// SetSchedule makes the session's later turns resolve their choice points under
+// the policy, as sysml -schedule spells it ("seed:42"): the actions it performs
+// from now on, and the objects' machines and the clock from their next step on,
+// their states and pending signals kept. "explore[...]" is refused, an
+// exploration replays whole runs. Setting the same seed again restarts its
+// stream, so two turns from the same seed choose alike.
+func (s *Session) SetSchedule(policy string) error {
+	return s.answer("SetSchedule", func() error { return s.engine.SetSchedule(policy) })
+}
+
+// Now is the session's clock, in seconds since it opened.
+func (s *Session) Now() (now float64, err error) {
+	err = s.answer("Now", func() error {
+		now, err = s.engine.Now()
+		return err
+	})
+	return now, err
+}
+
+// Instantiate creates an object of the named part or usage, starting the state
+// machines it exhibits, and answers the handle later calls take. The object
+// lives until the session closes.
+func (s *Session) Instantiate(symbolID string) (id InstanceID, err error) {
+	err = s.answer("Instantiate", func() error {
+		raw, err := s.engine.Instantiate(symbolID)
+		id = InstanceID(raw)
+		return err
+	})
+	return id, err
+}
+
+// Feature reads what an object holds for one feature of its type, by name, as
+// the session's runs have left it.
+func (s *Session) Feature(object InstanceID, name string) (value *FeatureValue, err error) {
+	err = s.answer("Feature", func() error {
+		fv, err := s.engine.FeatureValue(int64(object), name)
+		if err != nil {
+			return err
+		}
+		value = &FeatureValue{FeatureName: fv.FeatureName, Value: valueFromProto(fv.Value), Materialized: fv.Materialized}
+		for _, elem := range fv.Values {
+			value.Values = append(value.Values, valueFromProto(elem))
+		}
+		return nil
+	})
+	return value, err
+}
+
+// SetFeature writes one feature of an object, as an assignment in the model would.
+func (s *Session) SetFeature(object InstanceID, name string, value Value) error {
+	return s.answer("SetFeature", func() error {
+		sent, err := valueToProto(value)
+		if err != nil {
+			return err
+		}
+		return s.engine.SetFeatureValue(int64(object), name, sent)
+	})
+}
+
+// Evaluate evaluates one expression against the session's state, resolving its
+// names in the scope WithContextSymbol names, or the model's primary document
+// when none is given. A feature of an object the session holds reads that
+// object's current value. WithSubject is not taken: the session's objects are
+// the subjects.
+func (s *Session) Evaluate(expression string, opts ...EvaluateOption) (value Value, err error) {
+	var options evaluateOptions
+	for _, opt := range opts {
+		opt(&options)
+	}
+	if options.subjectSymbolID != "" {
+		return nil, &StatusError{Code: CodeInvalidArgument, Message: "Session.Evaluate takes no subject: instantiate one and read its features instead"}
+	}
+	err = s.answer("Evaluate", func() error {
+		raw, err := s.engine.Evaluate(expression, options.contextSymbolID)
+		if err != nil {
+			return err
+		}
+		value = valueFromProto(raw)
+		return nil
+	})
+	return value, err
+}
+
+// Member is one named member a declaration's scope holds.
+type Member struct {
+	// ID is the member's fully qualified name.
+	ID   string
+	Name string
+	// Kind of the member ("PartUsage", "EnumerationUsage", …).
+	Kind string
+}
+
+// Members lists the named members of the named package, definition or usage,
+// in declaration order: the literals of an enumeration, the parts of a package.
+func (s *Session) Members(symbolID string) (members []Member, err error) {
+	err = s.answer("Members", func() error {
+		facts, err := s.engine.Members(symbolID)
+		if err != nil {
+			return err
+		}
+		for _, m := range facts {
+			members = append(members, Member{ID: m.ID, Name: m.Name, Kind: m.Kind})
+		}
+		return nil
+	})
+	return members, err
+}
+
+// TriggerKind says what fires a Transition.
+type TriggerKind string
+
+// The kinds of trigger a Transition reports.
+const (
+	// TriggerCompletion fires when the source state's entry behavior completes.
+	TriggerCompletion TriggerKind = sysmlgrpc.TriggerCompletion
+	// TriggerSignal fires on accepting a signal: one of the type
+	// Transition.Signal names, or an occurrence of the event feature
+	// Transition.Event names.
+	TriggerSignal TriggerKind = sysmlgrpc.TriggerSignal
+	// TriggerTime fires when a time event is due.
+	TriggerTime TriggerKind = sysmlgrpc.TriggerTime
+	// TriggerChange fires when a change event's condition comes true.
+	TriggerChange TriggerKind = sysmlgrpc.TriggerChange
+	// TriggerCall fires on a call event.
+	TriggerCall TriggerKind = sysmlgrpc.TriggerCall
+)
+
+// Transition is one transition of a state machine, as declared: which states it
+// joins, what fires it and whether a guard stands on it.
+type Transition struct {
+	// Name of the transition, "" when it was declared anonymously.
+	Name string
+	// Source and Target name the states the transition joins.
+	Source string
+	Target string
+	// Trigger says what fires it.
+	Trigger TriggerKind
+	// Signal is the simple name of the signal type a TriggerSignal transition
+	// accepts, "" when it accepts by event feature instead or for the other kinds.
+	Signal string
+	// Event is the feature path an accept trigger subsets (`alert`,
+	// `left.alert`) when it accepts an occurrence of that event feature rather
+	// than a signal type, "" otherwise.
+	Event string
+	// Guarded reports whether a guard stands on it; Accepts says whether the
+	// guard holds now.
+	Guarded bool
+}
+
+func transitionFromFact(t sysmlgrpc.SessionTransition) Transition {
+	return Transition{Name: t.Name, Source: t.Source, Target: t.Target, Trigger: TriggerKind(t.Trigger), Signal: t.Signal, Event: t.Event, Guarded: t.Guarded}
+}
+
+// ActiveStates names the innermost active states of every state machine the
+// object exhibits, machine by machine in declaration order, one per active
+// region; the composite states enclosing them are active too. A FailureError
+// when the object exhibits none.
+func (s *Session) ActiveStates(object InstanceID) (states []string, err error) {
+	err = s.answer("ActiveStates", func() error {
+		states, err = s.engine.ActiveStates(int64(object))
+		return err
+	})
+	return states, err
+}
+
+// Transitions lists the transitions dispatch could select now, machine by
+// machine: those out of each active state and then of each state enclosing
+// it, innermost first as dispatch tries them, each in declaration order. A
+// caller offers them as what the object could do next; Source tells which
+// state declares each.
+func (s *Session) Transitions(object InstanceID) (transitions []Transition, err error) {
+	err = s.answer("Transitions", func() error {
+		facts, err := s.engine.Transitions(int64(object))
+		if err != nil {
+			return err
+		}
+		for _, t := range facts {
+			transitions = append(transitions, transitionFromFact(t))
+		}
+		return nil
+	})
+	return transitions, err
+}
+
+// Acceptance is what dispatching a signal to an object would do now, read from
+// the machines dispatch would let take it, as Advance does: a machine yields a
+// signal it would only drop to a sibling that would fire on or defer it. Where
+// several would take it, the schedule's due order decides which consumes it.
+type Acceptance struct {
+	// Accepted reports whether a transition out of an active state of a taking
+	// machine is triggered by the signal, whatever its guard. A signal only
+	// deferred, or only resuming a do behavior, is not Accepted.
+	Accepted bool
+	// Fires lists the transitions that would fire, taking machine by taking
+	// machine in exhibit order, each machine's in the order they would.
+	Fires []Transition
+	// Deferred reports whether the active state defers the signal.
+	Deferred bool
+	// Resumes names the states a deferred signal would resume.
+	Resumes []string
+}
+
+// Enabled reports whether the machine would do something with the signal: a
+// transition fires, or the signal is deferred or resumes a state. An accepted
+// signal that is not enabled is one whose every transition's guard is false.
+func (a *Acceptance) Enabled() bool {
+	return a != nil && (len(a.Fires) > 0 || a.Deferred || len(a.Resumes) > 0)
+}
+
+// Taken reports whether a machine would take the signal at all, whether to
+// fire, defer, resume, or drop it because every guard is false.
+func (a *Acceptance) Taken() bool {
+	return a != nil && (a.Accepted || a.Enabled())
+}
+
+func acceptanceFromFact(a *sysmlgrpc.SessionAcceptance) *Acceptance {
+	out := &Acceptance{Accepted: a.Accepted, Deferred: a.Deferred, Resumes: append([]string(nil), a.Resumes...)}
+	for _, t := range a.Fires {
+		out.Fires = append(out.Fires, transitionFromFact(t))
+	}
+	return out
+}
+
+// Accepts says what sending the signal to the object would do now, without
+// sending it: which machines it exhibits would take it, whether a transition
+// of theirs, an enclosing state's included, is triggered by it and whether a
+// guard holds. The signal definition is named by ID; args bind its attributes.
+func (s *Session) Accepts(object InstanceID, signalID string, args map[string]Value) (acceptance *Acceptance, err error) {
+	err = s.answer("Accepts", func() error {
+		sent, err := valuesToProto(args)
+		if err != nil {
+			return err
+		}
+		fact, err := s.engine.Accepts(int64(object), signalID, sent)
+		if err != nil {
+			return err
+		}
+		acceptance = acceptanceFromFact(fact)
+		return nil
+	})
+	return acceptance, err
+}
+
+// Send posts the signal to the object; Advance then dispatches it and runs
+// what follows, completion transitions included. A signal no machine the
+// object exhibits would take, or one every taking machine would drop because
+// its guards are false, is refused with CodeFailedPrecondition and nothing is
+// posted.
+func (s *Session) Send(object InstanceID, signalID string, args map[string]Value) (acceptance *Acceptance, err error) {
+	err = s.answer("Send", func() error {
+		sent, err := valuesToProto(args)
+		if err != nil {
+			return err
+		}
+		fact, err := s.engine.Send(int64(object), signalID, sent)
+		if err != nil {
+			return err
+		}
+		acceptance = acceptanceFromFact(fact)
+		return nil
+	})
+	return acceptance, err
+}
+
+// ChoicePoint is one choice a run made: where it stood, what it could have
+// chosen and what the scheduling policy chose.
+type ChoicePoint struct {
+	// Kind of choice ("decision branch", "fork order", …).
+	Kind string
+	// Step of the run the choice was made at.
+	Step int
+	// Where names the node the choice was made at, as the run traces it
+	// ("decision roll").
+	Where string
+	// Alternatives are what could have been chosen, in declaration order.
+	Alternatives []string
+	// Taken indexes Alternatives with what was chosen.
+	Taken int
+}
+
+// ChoiceDecisionBranch is the Kind of a ChoicePoint made at a decision node.
+const ChoiceDecisionBranch = "decision branch"
+
+func choicesFromFacts(facts []sysmlgrpc.SessionChoice) []ChoicePoint {
+	var out []ChoicePoint
+	for _, c := range facts {
+		out = append(out, ChoicePoint{Kind: c.Kind, Step: c.Step, Where: c.Where, Alternatives: append([]string(nil), c.Alternatives...), Taken: c.Taken})
+	}
+	return out
+}
+
+// Advancement is what advancing the clock did.
+type Advancement struct {
+	// From and To are the clock before and after.
+	From, To float64
+	// Events dispatched and Steps run while advancing.
+	Events, Steps int64
+	// Choices the runs made while advancing.
+	Choices []ChoicePoint
+	// Diagnostics the runs raised.
+	Diagnostics []Diagnostic
+}
+
+// Advance moves the clock forward by seconds — zero to dispatch what is posted
+// now — running the transitions, effects and completion transitions that
+// follow until the machines settle.
+func (s *Session) Advance(seconds float64) (advanced *Advancement, err error) {
+	err = s.answer("Advance", func() error {
+		fact, err := s.engine.Advance(seconds)
+		if err != nil {
+			return err
+		}
+		advanced = &Advancement{
+			From:        fact.From,
+			To:          fact.To,
+			Events:      fact.Events,
+			Steps:       fact.Steps,
+			Choices:     choicesFromFacts(fact.Choices),
+			Diagnostics: diagnosticsFromProto(fact.Diagnostics),
+		}
+		return nil
+	})
+	return advanced, err
+}
+
+// Branch is one way a performance left a decision node of the action's own
+// flow — not of the actions it called.
+type Branch struct {
+	// Decision names the decision node, "" when it is anonymous.
+	Decision string
+	// Target names the node the branch led to.
+	Target string
+	// Else reports the branch was the decision's `else`, taken when no guarded
+	// branch was.
+	Else bool
+	// Opening reports the decision is the action's gate: the first decision
+	// its start leads to with no other choice on the way.
+	Opening bool
+}
+
+// Performance is what performing an action did.
+type Performance struct {
+	// Outputs are the action's out parameters, by name.
+	Outputs map[string]Value
+	// Choices the run made, in order.
+	Choices []ChoicePoint
+	// Branches are the decisions of the action's own flow the run left, in order.
+	Branches []Branch
+	// Diagnostics the run raised.
+	Diagnostics []Diagnostic
+}
+
+// TurnedAway reports whether the run left the action's opening decision by its
+// else branch: the action looked at its inputs or its performer and declined.
+func (p *Performance) TurnedAway() bool {
+	if p == nil {
+		return false
+	}
+	for _, b := range p.Branches {
+		if b.Opening && b.Else {
+			return true
+		}
+	}
+	return false
+}
+
+// Perform performs the named action on the object, binding inputs to its in
+// parameters, and runs it to completion in the session's state; the object's
+// features read and written by the action are those the session holds. A run
+// the model fails is a FailureError carrying the run's diagnostics.
+func (s *Session) Perform(object InstanceID, actionID string, inputs map[string]Value) (performed *Performance, err error) {
+	err = s.answer("Perform", func() error {
+		sent, err := valuesToProto(inputs)
+		if err != nil {
+			return err
+		}
+		fact, err := s.engine.Perform(int64(object), actionID, sent)
+		if err != nil {
+			return err
+		}
+		performed = &Performance{
+			Outputs:     valuesFromProto(fact.Outputs),
+			Choices:     choicesFromFacts(fact.Choices),
+			Diagnostics: diagnosticsFromProto(fact.Diagnostics),
+		}
+		for _, b := range fact.Branches {
+			performed.Branches = append(performed.Branches, Branch{Decision: b.Decision, Target: b.Target, Else: b.Else, Opening: b.Opening})
+		}
+		return nil
+	})
+	return performed, err
+}
+
+// valuesToProto marshals named values for the engine; a value that cannot be
+// sent is the StatusError valueToProto refuses it with.
+func valuesToProto(values map[string]Value) (map[string]*pb.Value, error) {
+	if len(values) == 0 {
+		return nil, nil
+	}
+	out := make(map[string]*pb.Value, len(values))
+	for name, value := range values {
+		sent, err := valueToProto(value)
+		if err != nil {
+			return nil, err
+		}
+		out[name] = sent
+	}
+	return out, nil
+}
diff --git a/client/opensysml/session_test.go b/client/opensysml/session_test.go
new file mode 100644
index 0000000000..bd07169ff0
--- /dev/null
+++ b/client/opensysml/session_test.go
@@ -0,0 +1,739 @@
+package opensysml_test
+
+import (
+	"errors"
+	"reflect"
+	"testing"
+
+	"github.com/Open-MBEE/OpenSysML/client/opensysml"
+)
+
+// sessionSource is a small interactive model: a hero whose exhibited machine
+// moves on signals, with a guarded transition, a completion transition, an
+// action that may turn its caller away and one that rolls dice.
+const sessionSource = `package Play {
+	private import ScalarValues::*;
+	attribute def Go;
+	attribute def Rest;
+	item def Fight;
+	enum def Mood { calm; wild; }
+	part def Hero {
+		attribute gold : Integer = 10;
+		attribute strength : Integer = 3;
+		attribute mood : Mood = Mood::calm;
+		attribute rested : Boolean = false;
+		calc twice { gold * 2 }
+		exhibit state day {
+			entry; then town;
+			state town;
+			state road;
+			state camp;
+			state home;
+			transition town_road first town accept Go then road;
+			transition road_camp first road accept Rest if gold > 5 then camp;
+			transition road_fight first road accept Fight then town;
+			transition camp_home first camp then home;
+			transition home_town first home accept Go then town;
+		}
+		action pay {
+			in cost : Integer = 1;
+			first start;
+			then decide canPay;
+			if gold >= cost then paying;
+			else done;
+			action paying { assign gold := gold - cost; }
+			succession first paying then done;
+		}
+		action gamble {
+			in wager : Integer = 1;
+			out won : Boolean = false;
+			first start;
+			then decide dice;
+			first dice if true then win;
+			first dice if true then lose;
+			action win { assign gold := gold + wager; assign won := true; }
+			action lose { assign gold := gold - wager; }
+			succession first win then done;
+			succession first lose then done;
+		}
+	}
+	part hero : Hero;
+}`
+
+func openSession(t *testing.T) (*opensysml.Session, opensysml.InstanceID) {
+	t.Helper()
+	client := newClient(t)
+	model := parse(t, client, sessionSource)
+	session, err := opensysml.OpenSession(client, model)
+	if err != nil {
+		t.Fatalf("OpenSession: %v", err)
+	}
+	t.Cleanup(func() { _ = session.Close() })
+	hero, err := session.Instantiate("Play::hero")
+	if err != nil {
+		t.Fatalf("Instantiate: %v", err)
+	}
+	return session, hero
+}
+
+func activeStates(t *testing.T, session *opensysml.Session, hero opensysml.InstanceID) []string {
+	t.Helper()
+	states, err := session.ActiveStates(hero)
+	if err != nil {
+		t.Fatalf("ActiveStates: %v", err)
+	}
+	return states
+}
+
+func TestSessionRetainsTheObjectAcrossCalls(t *testing.T) {
+	session, hero := openSession(t)
+
+	gold, err := session.Feature(hero, "gold")
+	if err != nil || gold.Value != opensysml.Int(10) {
+		t.Fatalf("Feature gold = %#v, %v; want Int(10)", gold, err)
+	}
+	if err := session.SetFeature(hero, "gold", opensysml.Int(3)); err != nil {
+		t.Fatalf("SetFeature: %v", err)
+	}
+	value, err := session.Evaluate("hero.gold + 1", opensysml.WithContextSymbol("Play"))
+	if err != nil || value != opensysml.Int(4) {
+		t.Fatalf("Evaluate hero.gold + 1 = %#v, %v; want Int(4)", value, err)
+	}
+	value, err = session.Evaluate("Mood::wild", opensysml.WithContextSymbol("Play"))
+	if err != nil {
+		t.Fatalf("Evaluate Mood::wild: %v", err)
+	}
+	if literal, ok := value.(opensysml.EnumLiteral); !ok || literal.LiteralID != "Play::Mood::wild" {
+		t.Errorf("Evaluate Mood::wild = %#v, want the enumeration literal", value)
+	}
+	members, err := session.Members("Play::Mood")
+	if err != nil {
+		t.Fatalf("Members: %v", err)
+	}
+	var names []string
+	for _, m := range members {
+		names = append(names, m.Name)
+	}
+	if want := []string{"calm", "wild"}; !reflect.DeepEqual(names, want) {
+		t.Errorf("Members(Play::Mood) = %v, want %v", names, want)
+	}
+}
+
+func TestSessionEvaluatesEachExpressionAfresh(t *testing.T) {
+	t.Setenv("OPENSYSML_MAX_STEPS", "200")
+	session, hero := openSession(t)
+
+	value, err := session.Evaluate("hero.twice", opensysml.WithContextSymbol("Play"))
+	if err != nil || value != opensysml.Int(20) {
+		t.Fatalf("Evaluate hero.twice = %#v, %v; want Int(20)", value, err)
+	}
+	if err := session.SetFeature(hero, "gold", opensysml.Int(7)); err != nil {
+		t.Fatalf("SetFeature: %v", err)
+	}
+	value, err = session.Evaluate("hero.twice", opensysml.WithContextSymbol("Play"))
+	if err != nil || value != opensysml.Int(14) {
+		t.Fatalf("Evaluate hero.twice after SetFeature = %#v, %v; want Int(14)", value, err)
+	}
+	for i := 0; i < 50; i++ {
+		if _, err := session.Evaluate("hero.gold + hero.strength * 2 - hero.twice", opensysml.WithContextSymbol("Play")); err != nil {
+			t.Fatalf("Evaluate #%d under a per-run step budget: %v", i, err)
+		}
+	}
+}
+
+func TestSessionReportsTransitionsAndAcceptance(t *testing.T) {
+	session, hero := openSession(t)
+	if got := activeStates(t, session, hero); !reflect.DeepEqual(got, []string{"town"}) {
+		t.Fatalf("ActiveStates = %v, want [town]", got)
+	}
+	transitions, err := session.Transitions(hero)
+	if err != nil {
+		t.Fatalf("Transitions: %v", err)
+	}
+	want := []opensysml.Transition{{Name: "town_road", Source: "town", Target: "road", Trigger: opensysml.TriggerSignal, Signal: "Go"}}
+	if !reflect.DeepEqual(transitions, want) {
+		t.Errorf("Transitions = %+v, want %+v", transitions, want)
+	}
+
+	if _, err := session.Send(hero, "Play::Go", nil); err != nil {
+		t.Fatalf("Send Go: %v", err)
+	}
+	if _, err := session.Advance(0); err != nil {
+		t.Fatalf("Advance: %v", err)
+	}
+	if got := activeStates(t, session, hero); !reflect.DeepEqual(got, []string{"road"}) {
+		t.Fatalf("ActiveStates after Go = %v, want [road]", got)
+	}
+	transitions, err = session.Transitions(hero)
+	if err != nil {
+		t.Fatalf("Transitions: %v", err)
+	}
+	if len(transitions) != 2 || transitions[0].Signal != "Rest" || !transitions[0].Guarded || transitions[1].Signal != "Fight" || transitions[1].Guarded {
+		t.Errorf("Transitions out of road = %+v, want guarded Rest then Fight", transitions)
+	}
+
+	// The guard on Rest holds with gold 10 and fails with gold 1.
+	acceptance, err := session.Accepts(hero, "Play::Rest", nil)
+	if err != nil || !acceptance.Accepted || !acceptance.Enabled() || len(acceptance.Fires) != 1 || acceptance.Fires[0].Name != "road_camp" {
+		t.Fatalf("Accepts Rest = %+v, %v; want road_camp enabled", acceptance, err)
+	}
+	if err := session.SetFeature(hero, "gold", opensysml.Int(1)); err != nil {
+		t.Fatal(err)
+	}
+	acceptance, err = session.Accepts(hero, "Play::Rest", nil)
+	if err != nil || !acceptance.Accepted || acceptance.Enabled() {
+		t.Fatalf("Accepts Rest with gold 1 = %+v, %v; want accepted but not enabled", acceptance, err)
+	}
+	if _, err := session.Send(hero, "Play::Rest", nil); !hasCode(err, opensysml.CodeFailedPrecondition) {
+		t.Errorf("Send Rest with its guard false: %v, want CodeFailedPrecondition", err)
+	}
+	if got := activeStates(t, session, hero); !reflect.DeepEqual(got, []string{"road"}) {
+		t.Errorf("ActiveStates after the refused Send = %v, want [road]", got)
+	}
+
+	// The completion transition out of camp fires within the same Advance.
+	if err := session.SetFeature(hero, "gold", opensysml.Int(10)); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := session.Send(hero, "Play::Rest", nil); err != nil {
+		t.Fatalf("Send Rest: %v", err)
+	}
+	if _, err := session.Advance(0); err != nil {
+		t.Fatalf("Advance: %v", err)
+	}
+	if got := activeStates(t, session, hero); !reflect.DeepEqual(got, []string{"home"}) {
+		t.Errorf("ActiveStates after Rest = %v, want [home] through the completion transition", got)
+	}
+}
+
+// nestedSource exhibits two machines, one with a composite state whose own
+// transition leaves from whichever nested state is active.
+const nestedSource = `package Nest {
+	attribute def Abort;
+	attribute def Step;
+	attribute def Spin;
+	part def Unit {
+		exhibit state work {
+			entry; then working;
+			state working {
+				entry; then step1;
+				state step1;
+				state step2;
+				transition step1_step2 first step1 accept Step then step2;
+			}
+			state done;
+			transition working_done first working accept Abort then done;
+		}
+		exhibit state fan {
+			entry; then off;
+			state off;
+			state on;
+			transition off_on first off accept Spin then on;
+		}
+	}
+	part unit : Unit;
+}`
+
+func TestSessionSeesEnclosingStatesAndEveryMachine(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, nestedSource)
+	session, err := opensysml.OpenSession(client, model)
+	if err != nil {
+		t.Fatalf("OpenSession: %v", err)
+	}
+	t.Cleanup(func() { _ = session.Close() })
+	unit, err := session.Instantiate("Nest::unit")
+	if err != nil {
+		t.Fatalf("Instantiate: %v", err)
+	}
+	if got := activeStates(t, session, unit); !reflect.DeepEqual(got, []string{"step1", "off"}) {
+		t.Fatalf("ActiveStates = %v, want [step1 off], one per machine", got)
+	}
+	transitions, err := session.Transitions(unit)
+	if err != nil {
+		t.Fatalf("Transitions: %v", err)
+	}
+	want := []opensysml.Transition{
+		{Name: "step1_step2", Source: "step1", Target: "step2", Trigger: opensysml.TriggerSignal, Signal: "Step"},
+		{Name: "working_done", Source: "working", Target: "done", Trigger: opensysml.TriggerSignal, Signal: "Abort"},
+		{Name: "off_on", Source: "off", Target: "on", Trigger: opensysml.TriggerSignal, Signal: "Spin"},
+	}
+	if !reflect.DeepEqual(transitions, want) {
+		t.Errorf("Transitions = %+v, want the nested state's, then its enclosing state's, then the second machine's", transitions)
+	}
+
+	// The enclosing state's transition is accepted while a nested state is active.
+	acceptance, err := session.Accepts(unit, "Nest::Abort", nil)
+	if err != nil || !acceptance.Accepted || len(acceptance.Fires) != 1 || acceptance.Fires[0].Name != "working_done" {
+		t.Fatalf("Accepts Abort = %+v, %v; want working_done", acceptance, err)
+	}
+	// The second machine's signal is accepted and dispatched to it alone.
+	acceptance, err = session.Accepts(unit, "Nest::Spin", nil)
+	if err != nil || !acceptance.Accepted || len(acceptance.Fires) != 1 || acceptance.Fires[0].Name != "off_on" {
+		t.Fatalf("Accepts Spin = %+v, %v; want off_on", acceptance, err)
+	}
+	if _, err := session.Send(unit, "Nest::Spin", nil); err != nil {
+		t.Fatalf("Send Spin: %v", err)
+	}
+	if _, err := session.Advance(0); err != nil {
+		t.Fatalf("Advance: %v", err)
+	}
+	if got := activeStates(t, session, unit); !reflect.DeepEqual(got, []string{"step1", "on"}) {
+		t.Errorf("ActiveStates after Spin = %v, want [step1 on]", got)
+	}
+	if _, err := session.Send(unit, "Nest::Abort", nil); err != nil {
+		t.Fatalf("Send Abort: %v", err)
+	}
+	if _, err := session.Advance(0); err != nil {
+		t.Fatalf("Advance: %v", err)
+	}
+	if got := activeStates(t, session, unit); !reflect.DeepEqual(got, []string{"done", "on"}) {
+		t.Errorf("ActiveStates after Abort = %v, want [done on]", got)
+	}
+}
+
+const twinSource = `package Twin {
+	private import ScalarValues::*;
+	attribute def Ping;
+	attribute def Go;
+	part def Pair {
+		attribute leftArmed : Boolean = false;
+		attribute rightArmed : Boolean = true;
+		exhibit state left {
+			entry; then idle;
+			state idle;
+			transition left_go first idle accept Ping if leftArmed then done;
+			state done;
+		}
+		exhibit state right {
+			entry; then busy;
+			state busy { defer Ping; }
+			transition right_ready first busy accept Go then ready;
+			state ready;
+			transition right_go first ready accept Ping if rightArmed then done;
+			state done;
+		}
+	}
+	part pair : Pair;
+}`
+
+// Accepts reads the machines dispatch would let take the signal, so what it
+// reports is what Send and Advance then do.
+func TestSessionAcceptsMatchesDispatchAcrossMachines(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, twinSource)
+	session, err := opensysml.OpenSession(client, model)
+	if err != nil {
+		t.Fatalf("OpenSession: %v", err)
+	}
+	t.Cleanup(func() { _ = session.Close() })
+	pair, err := session.Instantiate("Twin::pair")
+	if err != nil {
+		t.Fatalf("Instantiate: %v", err)
+	}
+	ping := func() *opensysml.Acceptance {
+		t.Helper()
+		acceptance, err := session.Accepts(pair, "Twin::Ping", nil)
+		if err != nil {
+			t.Fatalf("Accepts Ping: %v", err)
+		}
+		return acceptance
+	}
+	send := func(signal string) {
+		t.Helper()
+		if _, err := session.Send(pair, "Twin::"+signal, nil); err != nil {
+			t.Fatalf("Send %s: %v", signal, err)
+		}
+		if _, err := session.Advance(0); err != nil {
+			t.Fatalf("Advance: %v", err)
+		}
+	}
+	states := func(want ...string) {
+		t.Helper()
+		if got := activeStates(t, session, pair); !reflect.DeepEqual(got, want) {
+			t.Fatalf("ActiveStates = %v, want %v", got, want)
+		}
+	}
+
+	// left's guard is false and right only defers: the signal is taken, deferred,
+	// and no transition is triggered by it, distinct facts Send agrees with.
+	acceptance := ping()
+	if acceptance.Accepted || !acceptance.Deferred || len(acceptance.Fires) != 0 || !acceptance.Taken() || !acceptance.Enabled() {
+		t.Fatalf("Accepts Ping with left disarmed and right busy = %+v; want deferred only", acceptance)
+	}
+	send("Ping")
+	states("idle", "busy")
+
+	// Go readies right, which then fires on the deferred Ping alone; left, whose
+	// guard is false, yields it rather than dropping it.
+	send("Go")
+	states("idle", "done")
+
+	// Back in ready with the guards swapped, left would fire and right, whose
+	// guard is false, yields: only left_go is reported, and only left moves.
+	pair, err = session.Instantiate("Twin::pair")
+	if err != nil {
+		t.Fatalf("Instantiate: %v", err)
+	}
+	if err := session.SetFeature(pair, "leftArmed", opensysml.Bool(true)); err != nil {
+		t.Fatalf("SetFeature leftArmed: %v", err)
+	}
+	if err := session.SetFeature(pair, "rightArmed", opensysml.Bool(false)); err != nil {
+		t.Fatalf("SetFeature rightArmed: %v", err)
+	}
+	send("Go")
+	states("idle", "ready")
+	acceptance = ping()
+	if !acceptance.Accepted || acceptance.Deferred || len(acceptance.Fires) != 1 || acceptance.Fires[0].Name != "left_go" {
+		t.Fatalf("Accepts Ping with only left armed = %+v; want left_go alone", acceptance)
+	}
+	send("Ping")
+	states("done", "ready")
+
+	// With both guards holding, both would take it and the schedule's due order
+	// decides which consumes it: both fires are reported, exactly one machine
+	// moves, and Advance notes the choice.
+	pair, err = session.Instantiate("Twin::pair")
+	if err != nil {
+		t.Fatalf("Instantiate: %v", err)
+	}
+	if err := session.SetFeature(pair, "leftArmed", opensysml.Bool(true)); err != nil {
+		t.Fatalf("SetFeature leftArmed: %v", err)
+	}
+	send("Go")
+	acceptance = ping()
+	if !acceptance.Accepted || len(acceptance.Fires) != 2 || acceptance.Fires[0].Name != "left_go" || acceptance.Fires[1].Name != "right_go" {
+		t.Fatalf("Accepts Ping with both armed = %+v; want left_go then right_go", acceptance)
+	}
+	if _, err := session.Send(pair, "Twin::Ping", nil); err != nil {
+		t.Fatalf("Send Ping: %v", err)
+	}
+	advanced, err := session.Advance(0)
+	if err != nil {
+		t.Fatalf("Advance: %v", err)
+	}
+	dueOrder := 0
+	for _, choice := range advanced.Choices {
+		if choice.Kind == "due order" && len(choice.Alternatives) == 2 {
+			dueOrder++
+		}
+	}
+	if dueOrder != 1 {
+		t.Fatalf("Advance chose the due order %d times among %+v; want once between the two machines", dueOrder, advanced.Choices)
+	}
+	if got := activeStates(t, session, pair); !reflect.DeepEqual(got, []string{"done", "ready"}) && !reflect.DeepEqual(got, []string{"idle", "done"}) {
+		t.Fatalf("ActiveStates = %v, want exactly one machine to have consumed Ping", got)
+	}
+
+	// With neither guard holding, both machines are triggered and would drop it:
+	// accepted, not enabled, and Send refuses it as Advance would do nothing.
+	pair, err = session.Instantiate("Twin::pair")
+	if err != nil {
+		t.Fatalf("Instantiate: %v", err)
+	}
+	if err := session.SetFeature(pair, "rightArmed", opensysml.Bool(false)); err != nil {
+		t.Fatalf("SetFeature rightArmed: %v", err)
+	}
+	send("Go")
+	acceptance = ping()
+	if !acceptance.Accepted || acceptance.Enabled() {
+		t.Fatalf("Accepts Ping with neither armed = %+v; want accepted but not enabled", acceptance)
+	}
+	if _, err := session.Send(pair, "Twin::Ping", nil); !hasCode(err, opensysml.CodeFailedPrecondition) {
+		t.Fatalf("Send Ping with neither armed: %v, want CodeFailedPrecondition", err)
+	}
+	states("idle", "ready")
+}
+
+func TestSessionPerformReportsBranchesAndChoices(t *testing.T) {
+	session, hero := openSession(t)
+
+	paid, err := session.Perform(hero, "Play::Hero::pay", map[string]opensysml.Value{"cost": opensysml.Int(4)})
+	if err != nil {
+		t.Fatalf("Perform pay: %v", err)
+	}
+	if paid.TurnedAway() || len(paid.Branches) != 1 || paid.Branches[0] != (opensysml.Branch{Decision: "canPay", Target: "paying", Opening: true}) {
+		t.Errorf("pay(4) branches = %+v, want the opening decision left for paying", paid.Branches)
+	}
+	gold, err := session.Feature(hero, "gold")
+	if err != nil || gold.Value != opensysml.Int(6) {
+		t.Errorf("gold after pay(4) = %#v, %v; want Int(6)", gold, err)
+	}
+
+	refused, err := session.Perform(hero, "Play::Hero::pay", map[string]opensysml.Value{"cost": opensysml.Int(100)})
+	if err != nil {
+		t.Fatalf("Perform pay(100): %v", err)
+	}
+	if !refused.TurnedAway() || len(refused.Branches) != 1 || !refused.Branches[0].Else || !refused.Branches[0].Opening {
+		t.Errorf("pay(100) branches = %+v, want the opening decision left by else", refused.Branches)
+	}
+	gold, err = session.Feature(hero, "gold")
+	if err != nil || gold.Value != opensysml.Int(6) {
+		t.Errorf("gold after the refused pay = %#v, %v; want Int(6) unchanged", gold, err)
+	}
+
+	outcomes := func(seed string) (choices []opensysml.ChoicePoint, gold opensysml.Value) {
+		t.Helper()
+		if err := session.SetSchedule(seed); err != nil {
+			t.Fatalf("SetSchedule(%s): %v", seed, err)
+		}
+		if err := session.SetFeature(hero, "gold", opensysml.Int(10)); err != nil {
+			t.Fatal(err)
+		}
+		gambled, err := session.Perform(hero, "Play::Hero::gamble", map[string]opensysml.Value{"wager": opensysml.Int(3)})
+		if err != nil {
+			t.Fatalf("Perform gamble: %v", err)
+		}
+		if _, ok := gambled.Outputs["won"].(opensysml.Bool); !ok {
+			t.Errorf("gamble outputs = %#v, want a Bool won", gambled.Outputs)
+		}
+		fv, err := session.Feature(hero, "gold")
+		if err != nil {
+			t.Fatal(err)
+		}
+		return gambled.Choices, fv.Value
+	}
+	firstChoices, firstGold := outcomes("seed:7")
+	if len(firstChoices) != 1 || firstChoices[0].Kind != opensysml.ChoiceDecisionBranch || firstChoices[0].Where != "decision dice" || len(firstChoices[0].Alternatives) != 2 {
+		t.Fatalf("gamble choices = %+v, want one decision branch at decision dice with two alternatives", firstChoices)
+	}
+	if firstGold != opensysml.Int(13) && firstGold != opensysml.Int(7) {
+		t.Errorf("gold after gamble = %#v, want 13 or 7", firstGold)
+	}
+	againChoices, againGold := outcomes("seed:7")
+	if !reflect.DeepEqual(againChoices, firstChoices) || againGold != firstGold {
+		t.Errorf("the same seed chose %+v (gold %v), want %+v (gold %v) again", againChoices, againGold, firstChoices, firstGold)
+	}
+	sawOther := false
+	for _, seed := range []string{"seed:1", "seed:2", "seed:3", "seed:4", "seed:5", "seed:6", "seed:8", "seed:9"} {
+		if choices, _ := outcomes(seed); choices[0].Taken != firstChoices[0].Taken {
+			sawOther = true
+			break
+		}
+	}
+	if !sawOther {
+		t.Error("every seed made the same dice choice; want the seed to matter")
+	}
+	if err := session.SetSchedule("explore[all]"); !hasCode(err, opensysml.CodeInvalidArgument) {
+		t.Errorf("SetSchedule(explore[all]) = %v, want CodeInvalidArgument", err)
+	}
+}
+
+func TestSessionRefusesMisuseWithTypedErrors(t *testing.T) {
+	session, hero := openSession(t)
+
+	if _, err := session.Send(hero, "Play::Rest", nil); !hasCode(err, opensysml.CodeFailedPrecondition) {
+		t.Errorf("Send a signal no transition out of town accepts: %v, want CodeFailedPrecondition", err)
+	}
+	if _, err := session.Send(hero, "Play::Nothing", nil); !errors.Is(err, opensysml.ErrFailure) {
+		t.Errorf("Send an undeclared signal: %v, want a FailureError", err)
+	}
+	if _, err := session.Send(hero, "Play::Hero::pay", nil); !errors.Is(err, opensysml.ErrFailure) {
+		t.Errorf("Send an action as a signal: %v, want a FailureError", err)
+	}
+	if _, err := session.Perform(hero, "Play::Hero::sing", nil); !errors.Is(err, opensysml.ErrFailure) {
+		t.Errorf("Perform an unknown action: %v, want a FailureError", err)
+	}
+	if _, err := session.Perform(opensysml.InstanceID(999), "Play::Hero::pay", nil); !errors.Is(err, opensysml.ErrFailure) {
+		t.Errorf("Perform on an object the session does not hold: %v, want a FailureError", err)
+	}
+	if _, err := session.Feature(hero, "mana"); !errors.Is(err, opensysml.ErrFailure) {
+		t.Errorf("Feature the type lacks: %v, want a FailureError", err)
+	}
+	if _, err := session.Evaluate("hero.gold +", opensysml.WithContextSymbol("Play")); !errors.Is(err, opensysml.ErrFailure) {
+		t.Errorf("Evaluate a malformed expression: %v, want a FailureError", err)
+	}
+	if _, err := session.Evaluate("1", opensysml.WithSubject("Play::hero")); !hasCode(err, opensysml.CodeInvalidArgument) {
+		t.Errorf("Evaluate with a subject: %v, want CodeInvalidArgument", err)
+	}
+	if _, err := session.Advance(-1); !hasCode(err, opensysml.CodeInvalidArgument) {
+		t.Errorf("Advance(-1): %v, want CodeInvalidArgument", err)
+	}
+	if err := session.SetSchedule("nonsense"); !hasCode(err, opensysml.CodeInvalidArgument) {
+		t.Errorf("SetSchedule(nonsense): %v, want CodeInvalidArgument", err)
+	}
+	if _, err := session.Instantiate("Play::Nowhere"); !errors.Is(err, opensysml.ErrFailure) {
+		t.Errorf("Instantiate an undeclared part: %v, want a FailureError", err)
+	}
+
+	if err := session.Close(); err != nil {
+		t.Fatalf("Close: %v", err)
+	}
+	if err := session.Close(); err != nil {
+		t.Errorf("second Close: %v", err)
+	}
+	if _, err := session.ActiveStates(hero); !hasCode(err, opensysml.CodeUnavailable) {
+		t.Errorf("ActiveStates on a closed session: %v, want CodeUnavailable", err)
+	}
+	if _, err := session.Perform(hero, "Play::Hero::pay", nil); !hasCode(err, opensysml.CodeUnavailable) {
+		t.Errorf("Perform on a closed session: %v, want CodeUnavailable", err)
+	}
+	if _, err := session.Send(hero, "Play::Go", nil); !hasCode(err, opensysml.CodeUnavailable) {
+		t.Errorf("Send on a closed session: %v, want CodeUnavailable", err)
+	}
+}
+
+func TestSessionRefusesToPassTheObjectBound(t *testing.T) {
+	t.Setenv("OPENSYSML_GRPC_MAX_HELD_OBJECTS", "4")
+	session, _ := openSession(t)
+	for i := 0; i < 8; i++ {
+		_, err := session.Instantiate("Play::hero")
+		if err == nil {
+			continue
+		}
+		if !hasCode(err, opensysml.CodeResourceExhausted) {
+			t.Fatalf("Instantiate past the bound: %v, want CodeResourceExhausted", err)
+		}
+		return
+	}
+	t.Fatal("Instantiate never reached the bound of 4 held objects")
+}
+
+// eventSource accepts by the event feature an accept trigger subsets, not by a
+// signal type: the transition fact names the feature, and dispatch matches it.
+const eventSource = `package Watch {
+	private import ScalarValues::*;
+	item def Ping;
+	part def Unit {
+		item alert : Ping;
+		exhibit state duty {
+			entry; then standingBy;
+			state standingBy;
+			state working;
+			transition wake first standingBy accept :> alert then working;
+		}
+	}
+	part unit : Unit;
+}`
+
+func TestSessionNamesTheEventAnAcceptSubsets(t *testing.T) {
+	client := newClient(t)
+	session, err := opensysml.OpenSession(client, parse(t, client, eventSource))
+	if err != nil {
+		t.Fatalf("OpenSession: %v", err)
+	}
+	t.Cleanup(func() { _ = session.Close() })
+	unit, err := session.Instantiate("Watch::unit")
+	if err != nil {
+		t.Fatalf("Instantiate: %v", err)
+	}
+	transitions, err := session.Transitions(unit)
+	if err != nil {
+		t.Fatalf("Transitions: %v", err)
+	}
+	want := []opensysml.Transition{{Name: "wake", Source: "standingBy", Target: "working", Trigger: opensysml.TriggerSignal, Event: "alert"}}
+	if !reflect.DeepEqual(transitions, want) {
+		t.Errorf("Transitions = %+v, want %+v", transitions, want)
+	}
+}
+
+func TestOpenSessionIsInProcessOnly(t *testing.T) {
+	remote := dialClient(t, startService(t))
+	model := parse(t, remote, sessionSource)
+	if _, err := opensysml.OpenSession(remote, model); !hasCode(err, opensysml.CodeUnimplemented) {
+		t.Errorf("OpenSession over Dial: %v, want CodeUnimplemented", err)
+	}
+
+	local := newClient(t)
+	if _, err := opensysml.OpenSession(local, model); !hasCode(err, opensysml.CodeNotFound) {
+		t.Errorf("OpenSession over a model another client parsed: %v, want CodeNotFound", err)
+	}
+	if err := local.Close(); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := opensysml.OpenSession(local, model); !hasCode(err, opensysml.CodeUnavailable) {
+		t.Errorf("OpenSession from a closed client: %v, want CodeUnavailable", err)
+	}
+}
+
+func hasCode(err error, code opensysml.Code) bool {
+	var status *opensysml.StatusError
+	return errors.As(err, &status) && status.Code == code
+}
+
+// forkSource is a machine whose one signal enables two transitions, so which
+// fires is the schedule's choice: a seeded run's first draw.
+const forkSource = `package Fork {
+	attribute def Go;
+	part def Chooser {
+		exhibit state pick {
+			entry; then start;
+			state start;
+			state first;
+			state second;
+			transition to_first first start accept Go then first;
+			transition to_second first start accept Go then second;
+			transition back_first first first accept Go then start;
+			transition back_second first second accept Go then start;
+		}
+	}
+	part chooser : Chooser;
+}`
+
+// A policy set after the object was instantiated and its clock advanced governs
+// the choices the later turns make, as it would a fresh session's first.
+func TestSessionSetScheduleGovernsTheLaterTurns(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, forkSource)
+	open := func() (*opensysml.Session, opensysml.InstanceID) {
+		t.Helper()
+		session, err := opensysml.OpenSession(client, model)
+		if err != nil {
+			t.Fatalf("OpenSession: %v", err)
+		}
+		t.Cleanup(func() { _ = session.Close() })
+		chooser, err := session.Instantiate("Fork::chooser")
+		if err != nil {
+			t.Fatalf("Instantiate: %v", err)
+		}
+		return session, chooser
+	}
+	sendGo := func(session *opensysml.Session, chooser opensysml.InstanceID) string {
+		t.Helper()
+		if _, err := session.Send(chooser, "Fork::Go", nil); err != nil {
+			t.Fatalf("Send Go: %v", err)
+		}
+		if _, err := session.Advance(0); err != nil {
+			t.Fatalf("Advance: %v", err)
+		}
+		states := activeStates(t, session, chooser)
+		if len(states) != 1 {
+			t.Fatalf("ActiveStates = %v, want one", states)
+		}
+		return states[0]
+	}
+	seeds := []string{"seed:1", "seed:2", "seed:3", "seed:4", "seed:5", "seed:6", "seed:7", "seed:8"}
+
+	// What each seed's first draw picks, read from a fresh session per seed.
+	first := make(map[string]string, len(seeds))
+	picked := make(map[string]bool)
+	for _, seed := range seeds {
+		session, chooser := open()
+		if err := session.SetSchedule(seed); err != nil {
+			t.Fatalf("SetSchedule(%s): %v", seed, err)
+		}
+		first[seed] = sendGo(session, chooser)
+		picked[first[seed]] = true
+	}
+	if len(picked) != 2 {
+		t.Fatalf("the seeds' first draws all pick %v; want both transitions among them", picked)
+	}
+
+	// One session, its clock already advanced under the default policy: each
+	// seed set from then on starts its draws over.
+	session, chooser := open()
+	if state := sendGo(session, chooser); state == "start" {
+		t.Fatalf("Go under the default policy left the machine in %s", state)
+	}
+	for _, seed := range seeds {
+		if state := sendGo(session, chooser); state != "start" {
+			t.Fatalf("Go back left the machine in %s, want start", state)
+		}
+		if err := session.SetSchedule(seed); err != nil {
+			t.Fatalf("SetSchedule(%s): %v", seed, err)
+		}
+		if got := sendGo(session, chooser); got != first[seed] {
+			t.Errorf("Go under %s set after earlier turns went to %s; a fresh session's first draw goes to %s", seed, got, first[seed])
+		}
+	}
+}
diff --git a/client/opensysml/set_tensor_test.go b/client/opensysml/set_tensor_test.go
index 6687198cf8..b66e640445 100644
--- a/client/opensysml/set_tensor_test.go
+++ b/client/opensysml/set_tensor_test.go
@@ -12,7 +12,7 @@ import (
 
 	"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
 	"github.com/Open-MBEE/OpenSysML/client/opensysml"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 )
 
 const setTensorSource = `package W {
diff --git a/client/opensysml/states_test.go b/client/opensysml/states_test.go
new file mode 100644
index 0000000000..e04f88853c
--- /dev/null
+++ b/client/opensysml/states_test.go
@@ -0,0 +1,124 @@
+package opensysml_test
+
+import (
+	"context"
+	"errors"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/Open-MBEE/OpenSysML/client/opensysml"
+)
+
+const stateQuerySource = `package Lamps {
+	private import DocumentQueries::*;
+	private import KerML::Root::Element;
+	private import ScalarValues::*;
+
+	attribute def Toggle;
+	state def LampMachine {
+		entry; then off;
+		state off;
+		transition off_on first off accept Toggle then on;
+		state on parallel {
+			state light {
+				entry; then run;
+				state run;
+			}
+			state fan {
+				entry; then slow;
+				state slow;
+			}
+		}
+	}
+	part def Lamp { exhibit state lp : LampMachine; }
+	part lamp : Lamp;
+
+	calc def CurrentStates :> Query {
+		in root : Element;
+		Project(source = States(source = root), properties = ("machine", "statePath", "region"))
+	}
+	calc def Off :> Query {
+		Project(source = InState(name = "off"), properties = ("qualifiedName"))
+	}
+	calc def Steps :> Query {
+		in root : Element;
+		Project(source = Events(source = root, kind = "entry"), properties = ("time", "state"))
+	}
+}`
+
+func TestRunDocumentQueryAnswersStateAndEventRows(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, stateQuerySource)
+	if _, err := client.Instantiate(context.Background(), model, "Lamps::lamp"); err != nil {
+		t.Fatalf("Instantiate: %v", err)
+	}
+	lamp := opensysml.Bind("root", opensysml.ObjectByPath("Lamps::lamp"))
+
+	states, err := client.RunDocumentQuery(context.Background(), model, "Lamps::CurrentStates", lamp)
+	if err != nil {
+		t.Fatalf("RunDocumentQuery CurrentStates: %v", err)
+	}
+	if len(states.Rows) != 1 || states.Rows[0].State == nil || states.Rows[0].Object == nil {
+		t.Fatalf("rows = %+v, want the lamp's one state row", states.Rows)
+	}
+	state := *states.Rows[0].State
+	want := opensysml.DocumentState{
+		Object:  opensysml.Object{ID: 1, Path: "Lamps::lamp", Element: opensysml.Element{ID: "Lamps::lamp", Type: "PartUsage"}},
+		Machine: "lp", Name: "off", Path: "off",
+		State: opensysml.Element{ID: "Lamps::LampMachine::off", Type: "StateUsage"},
+	}
+	if !reflect.DeepEqual(state, want) {
+		t.Errorf("state = %+v, want %+v", state, want)
+	}
+	if got := state.String(); got != "Lamps::lamp.lp in off" {
+		t.Errorf("state String() = %q", got)
+	}
+	if got := states.Rows[0].Element; got != want.Object.Element {
+		t.Errorf("row element = %+v, want the lamp's usage", got)
+	}
+
+	off, err := client.RunDocumentQuery(context.Background(), model, "Lamps::Off")
+	if err != nil {
+		t.Fatalf("RunDocumentQuery Off: %v", err)
+	}
+	if len(off.Rows) != 1 || off.Rows[0].Object == nil || off.Rows[0].Object.String() != "Lamps::lamp" {
+		t.Fatalf("InState rows = %+v, want the lamp as an object row", off.Rows)
+	}
+
+	steps, err := client.RunDocumentQuery(context.Background(), model, "Lamps::Steps", lamp)
+	if err != nil {
+		t.Fatalf("RunDocumentQuery Steps: %v", err)
+	}
+	if len(steps.Rows) != 1 || steps.Rows[0].Event == nil {
+		t.Fatalf("rows = %+v, want the one entry record", steps.Rows)
+	}
+	event := *steps.Rows[0].Event
+	if event.Kind != "entry" || event.State != "off" || event.Machine != "lp" || event.Text != "enter: off" {
+		t.Errorf("event = %+v", event)
+	}
+	if event.Object == nil || event.Object.String() != "Lamps::lamp" || steps.Rows[0].Object == nil {
+		t.Errorf("event object = %+v, want the lamp", event.Object)
+	}
+	at, ok := event.Time.(opensysml.Quantity)
+	if !ok || at.Magnitude != opensysml.Real(0) || at.Unit != "s" {
+		t.Errorf("event time = %#v, want 0 [s]", event.Time)
+	}
+	if got := event.String(); got != "0 s: enter: off" {
+		t.Errorf("event String() = %q", got)
+	}
+	if cell := steps.Rows[0].Cells[0][0]; !reflect.DeepEqual(cell, event.Time) {
+		t.Errorf("time cell = %#v, want the event's time", cell)
+	}
+}
+
+func TestStateAndEventRowsAreNotBound(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, stateQuerySource)
+	for _, cell := range []opensysml.Cell{opensysml.DocumentState{}, opensysml.DocumentEvent{}} {
+		_, err := client.RunDocumentQuery(context.Background(), model, "Lamps::CurrentStates", opensysml.Bind("root", cell))
+		if !errors.Is(err, opensysml.CodeInvalidArgument) || !strings.Contains(err.Error(), "answered by queries, not bound to them") {
+			t.Errorf("%T: err = %v, want CodeInvalidArgument refusing the binding", cell, err)
+		}
+	}
+}
diff --git a/client/opensysml/structured_test.go b/client/opensysml/structured_test.go
index 707513439c..9ffb181605 100644
--- a/client/opensysml/structured_test.go
+++ b/client/opensysml/structured_test.go
@@ -12,7 +12,7 @@ import (
 
 	"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
 	"github.com/Open-MBEE/OpenSysML/client/opensysml"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 )
 
 const structuredSource = `package S {
diff --git a/client/opensysml/surface_test.go b/client/opensysml/surface_test.go
index 1cb387c745..be7694461a 100644
--- a/client/opensysml/surface_test.go
+++ b/client/opensysml/surface_test.go
@@ -3,10 +3,16 @@ package opensysml_test
 import (
 	"context"
 	"errors"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"reflect"
 	"strings"
 	"testing"
 
+	"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
 	"github.com/Open-MBEE/OpenSysML/client/opensysml"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 )
 
 const behaviorSource = `package Test {
@@ -59,6 +65,33 @@ const verificationSource = `package Demo {
 	}
 }`
 
+const validationSource = `package Demo {
+	part def Engine {
+		attribute power = 300.0;
+		assert constraint { power < 200.0 }
+	}
+
+	part def Wheel {
+		attribute pressure default = 32.0;
+		assert constraint pressureOk { pressure >= 30.0 }
+	}
+
+	part def Car {
+		attribute mass = 1500.0;
+		part engine : Engine;
+		part wheels : Wheel[2] {
+			attribute :>> pressure = 20.0;
+		}
+		assert constraint massOk { mass < 2000.0 }
+	}
+
+	part car : Car;
+	part sound : Wheel;
+
+	part def Crate;
+	part crate : Crate;
+}`
+
 const querySource = `package Demo {
 	abstract part def Vehicle {
 		attribute mass;
@@ -132,6 +165,31 @@ const documentSource = `package Observatory {
 	}
 }`
 
+const verdictQuerySource = `package Garage {
+	private import DocumentQueries::*;
+	private import KerML::Root::Element;
+	private import ScalarValues::*;
+
+	part def Engine {
+		attribute power : Real = 300.0;
+		assert constraint powerLow { power < 200.0 }
+	}
+	part def Car {
+		attribute mass : Real = 1500.0;
+		part engine : Engine;
+		assert constraint massOk { mass < 2000.0 }
+	}
+	part car : Car;
+
+	calc def Checks :> Query {
+		in root : Element;
+		Project(
+			source = Verdicts(source = root),
+			properties = ("path", "verdict")
+		)
+	}
+}`
+
 const editableSource = `package Demo {
 	part def SC {
 		attribute unitMass = 1000.0;
@@ -289,6 +347,89 @@ func TestVerifySatisfactionAnswersEveryAssertion(t *testing.T) {
 	}
 }
 
+func TestValidateInstanceAnswersEveryAssertionOnEveryObject(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, validationSource)
+	validation, err := client.ValidateInstance(context.Background(), model, "Demo::car")
+	if err != nil {
+		t.Fatalf("ValidateInstance: %v", err)
+	}
+	if validation.Valid() || !validation.Violated() {
+		t.Errorf("valid=%v violated=%v, want a violated object", validation.Valid(), validation.Violated())
+	}
+	if validation.Summary == nil || validation.Summary.Kind != "object" || validation.Summary.Holds {
+		t.Errorf("summary = %+v, want an object verdict of false", validation.Summary)
+	}
+	paths := map[string]bool{}
+	for _, verdict := range validation.Verdicts {
+		paths[verdict.InstancePath] = true
+	}
+	for _, path := range []string{"", "engine", "wheels[1]", "wheels[2]"} {
+		if !paths[path] {
+			t.Errorf("no verdict about the object at %q; verdicts = %+v", path, validation.Verdicts)
+		}
+	}
+	if validation.Bounded {
+		t.Error("a finite object tree was reported bounded")
+	}
+	if len(validation.Instances) == 0 {
+		t.Error("no instance graph returned for the object")
+	}
+
+	sound, err := client.ValidateInstance(context.Background(), model, "Demo::sound")
+	if err != nil {
+		t.Fatalf("ValidateInstance: %v", err)
+	}
+	if !sound.Valid() || sound.Violated() {
+		t.Errorf("verdicts = %+v, want a valid object", sound.Verdicts)
+	}
+}
+
+func TestValidateInstanceTakesNoSubject(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, validationSource)
+	_, err := client.ValidateInstance(context.Background(), model, "Demo::car", opensysml.Against("Demo::sound"))
+	var status *opensysml.StatusError
+	if !errors.As(err, &status) || status.Code != opensysml.CodeInvalidArgument {
+		t.Fatalf("err = %v, want an invalid argument", err)
+	}
+}
+
+func TestValidateInstanceStatingNoAssertionIsNotValid(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, validationSource)
+	validation, err := client.ValidateInstance(context.Background(), model, "Demo::crate")
+	if err != nil {
+		t.Fatalf("ValidateInstance: %v", err)
+	}
+	if len(validation.Verdicts) != 0 || validation.Valid() || validation.Violated() {
+		t.Errorf("verdicts=%+v valid=%v violated=%v, want nothing decided", validation.Verdicts, validation.Valid(), validation.Violated())
+	}
+	if validation.Summary == nil || validation.Summary.Holds || validation.Summary.Error == "" {
+		t.Errorf("summary = %+v, want one that says no assertion was stated", validation.Summary)
+	}
+}
+
+func TestValidateInstanceOfNothingFails(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, validationSource)
+	_, err := client.ValidateInstance(context.Background(), model, "Demo::nosuch")
+	var refused *opensysml.VerifyError
+	if !errors.As(err, &refused) {
+		t.Fatalf("err = %v, want a VerifyError", err)
+	}
+	if !errors.Is(err, opensysml.ErrFailure) {
+		t.Error("a VerifyError does not match ErrFailure")
+	}
+
+	for _, symbol := range []string{"Demo", "Demo::Car::mass"} {
+		_, err := client.ValidateInstance(context.Background(), model, symbol)
+		if !errors.As(err, &refused) || refused.Reason != opensysml.ReasonWrongKind {
+			t.Errorf("ValidateInstance(%s) = %v, want a VerifyError of the wrong kind", symbol, err)
+		}
+	}
+}
+
 func TestEvaluateCalcAppliesPositionalArguments(t *testing.T) {
 	client := newClient(t)
 	model := parse(t, client, verificationSource)
@@ -394,6 +535,61 @@ func TestADocumentQueryBindingRefusesInfinity(t *testing.T) {
 	}
 }
 
+func TestRunDocumentQueryAnswersVerdictRows(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, verdictQuerySource)
+	rows, err := client.RunDocumentQuery(context.Background(), model, "Garage::Checks",
+		opensysml.Bind("root", opensysml.Element{ID: "Garage::car"}))
+	if err != nil {
+		t.Fatalf("RunDocumentQuery: %v", err)
+	}
+	if len(rows.Rows) != 2 {
+		t.Fatalf("rows = %d, want the car's constraint and its engine's", len(rows.Rows))
+	}
+	want := []opensysml.DocumentVerdict{
+		{
+			Assertion: opensysml.Element{ID: "Garage::Car::massOk", Type: "ConstraintUsage"},
+			Kind:      "constraint", Text: "assert constraint massOk", Path: "Garage::car", Status: "holds",
+		},
+		{
+			Assertion: opensysml.Element{ID: "Garage::Engine::powerLow", Type: "ConstraintUsage"},
+			Kind:      "constraint", Text: "assert constraint powerLow", Path: "Garage::car.engine", Status: "violated",
+		},
+	}
+	for i, row := range rows.Rows {
+		if row.Verdict == nil {
+			t.Fatalf("row %d carries no verdict", i)
+		}
+		got := *row.Verdict
+		if got.Status == "violated" && (got.Condition == "" || got.Reason == "") {
+			t.Errorf("row %d violated without its condition and reason: %+v", i, got)
+		}
+		got.Condition, got.Reason = "", ""
+		if !reflect.DeepEqual(got, want[i]) {
+			t.Errorf("row %d verdict = %+v, want %+v", i, got, want[i])
+		}
+		if row.Element != want[i].Assertion {
+			t.Errorf("row %d element = %+v, want the assertion %+v", i, row.Element, want[i].Assertion)
+		}
+		if path := opensysml.CellText(row.Cells[0][0]); path != want[i].Path {
+			t.Errorf("row %d path cell = %q, want %q", i, path, want[i].Path)
+		}
+	}
+	if got := rows.Rows[1].Verdict.String(); got != "assert constraint powerLow on Garage::car.engine: violated" {
+		t.Errorf("String() = %q", got)
+	}
+}
+
+func TestADocumentQueryBindingRefusesAVerdict(t *testing.T) {
+	client := newClient(t)
+	model := parse(t, client, verdictQuerySource)
+	_, err := client.RunDocumentQuery(context.Background(), model, "Garage::Checks",
+		opensysml.Bind("root", opensysml.DocumentVerdict{Kind: "constraint"}))
+	if !errors.Is(err, opensysml.CodeInvalidArgument) {
+		t.Errorf("err = %v, want CodeInvalidArgument", err)
+	}
+}
+
 func TestCellTextRendersEveryCellKind(t *testing.T) {
 	for _, testcase := range []struct {
 		cell opensysml.Cell
@@ -405,6 +601,8 @@ func TestCellTextRendersEveryCellKind(t *testing.T) {
 		{opensysml.Real(8.5), "8.5"},
 		{opensysml.Bool(true), "true"},
 		{opensysml.Infinity{}, "*"},
+		{opensysml.DocumentVerdict{Text: "assert constraint massOk", Path: "Garage::car", Status: "holds"},
+			"assert constraint massOk on Garage::car: holds"},
 		{nil, ""},
 	} {
 		if got := opensysml.CellText(testcase.cell); got != testcase.want {
@@ -479,6 +677,29 @@ func TestAFormatAliasIsAnsweredCanonically(t *testing.T) {
 	}
 }
 
+func TestConvertFileMigratesSysMLv1(t *testing.T) {
+	client := newClient(t)
+	xmi := filepath.Join("..", "..", "tests", "migrate", "testdata", "xmi", "vehicle.xmi")
+	conversion, err := client.ConvertFile(context.Background(), xmi, opensysml.FormatSysML)
+	if err != nil {
+		t.Fatalf("ConvertFile: %v", err)
+	}
+	if conversion.From != opensysml.FormatXMI || conversion.To != opensysml.FormatSysML {
+		t.Errorf("conversion = %s to %s, want xmi to sysml", conversion.From, conversion.To)
+	}
+	if !strings.Contains(conversion.Content, "part def Vehicle") {
+		t.Errorf("conversion does not carry the migrated model:\n%s", conversion.Content)
+	}
+	if !conversion.Experimental || !strings.Contains(conversion.ExperimentalNotice, "SysML v1 migration") {
+		t.Errorf("a migration does not report itself as experimental: %q", conversion.ExperimentalNotice)
+	}
+
+	_, err = client.ConvertFile(context.Background(), xmi, opensysml.FormatXMI)
+	if !errors.Is(err, opensysml.CodeInvalidArgument) {
+		t.Errorf("writing xmi: err = %v, want CodeInvalidArgument", err)
+	}
+}
+
 func TestConvertSourceReadsInlineContent(t *testing.T) {
 	client := newClient(t)
 	conversion, err := client.ConvertSource(context.Background(), editableSource, opensysml.FormatSysML,
@@ -518,6 +739,160 @@ func TestApplyEditsAnswersTheEditedSource(t *testing.T) {
 	}
 }
 
+func TestApplyEditsListsTheOneDocumentUnderTheParsesName(t *testing.T) {
+	client := newClient(t)
+	model, err := client.ParseDocuments(context.Background(),
+		[]opensysml.Document{{Name: "demo.sysml", Content: editableSource}})
+	if err != nil {
+		t.Fatalf("ParseDocuments: %v", err)
+	}
+	result, err := client.ApplyEdits(context.Background(), model,
+		opensysml.SetValue{Target: "Demo::SC::unitMass", Value: "1050.0"})
+	if err != nil {
+		t.Fatalf("ApplyEdits: %v", err)
+	}
+	if len(result.Documents) != 1 || result.Documents[0].Name != "demo.sysml" {
+		t.Fatalf("documents = %+v, want the one document named demo.sysml", result.Documents)
+	}
+	if result.Documents[0].Content != result.Content || result.Content == "" {
+		t.Errorf("content = %q, documents[0].content = %q, want the same notation", result.Content, result.Documents[0].Content)
+	}
+	if len(result.Applied) != 1 || result.Applied[0].Document != "demo.sysml" {
+		t.Errorf("applied = %+v, want one edit in demo.sysml", result.Applied)
+	}
+}
+
+const (
+	multiDocLibrary = "package Lib {\n    part def Engine;\n    part def Wheel;\n}\n"
+	multiDocUser    = "package Car {\n    part engine : Lib::Engine;\n    part wheel : Lib::Wheel;\n}\n"
+)
+
+func parseTwo(t *testing.T, client opensysml.Client) *opensysml.Model {
+	t.Helper()
+	model, err := client.ParseDocuments(context.Background(), []opensysml.Document{
+		{Name: "lib.sysml", Content: multiDocLibrary},
+		{Name: "car.sysml", Content: multiDocUser},
+	})
+	if err != nil {
+		t.Fatalf("ParseDocuments: %v", err)
+	}
+	return model
+}
+
+func TestApplyEditsRenamesAcrossDocumentsAndLeavesContentEmpty(t *testing.T) {
+	client := newClient(t)
+	model := parseTwo(t, client)
+	result, err := client.ApplyEdits(context.Background(), model,
+		opensysml.Rename{Target: "Lib::Engine", NewName: "Motor"})
+	if err != nil {
+		t.Fatalf("ApplyEdits: %v", err)
+	}
+	if result.Content != "" {
+		t.Errorf("content = %q, want empty for a model of two documents", result.Content)
+	}
+	if len(result.Documents) != 2 || result.Documents[0].Name != "lib.sysml" || result.Documents[1].Name != "car.sysml" {
+		t.Fatalf("documents = %+v, want lib.sysml then car.sysml", result.Documents)
+	}
+	if !strings.Contains(result.Documents[0].Content, "part def Motor") ||
+		!strings.Contains(result.Documents[1].Content, "engine : Lib::Motor") {
+		t.Errorf("the rename did not reach both documents:\n%s\n%s", result.Documents[0].Content, result.Documents[1].Content)
+	}
+	for _, applied := range result.Applied {
+		if applied.Document == "" {
+			t.Errorf("applied edit %+v names no document", applied)
+		}
+	}
+}
+
+func TestApplyDocumentEditsTargetsTheNamedDocument(t *testing.T) {
+	client := newClient(t)
+	model := parseTwo(t, client)
+	result, err := client.ApplyDocumentEdits(context.Background(), model, "car.sysml",
+		opensysml.Rename{Target: "Car::wheel", NewName: "tyre"})
+	if err != nil {
+		t.Fatalf("ApplyDocumentEdits: %v", err)
+	}
+	if len(result.Documents) != 1 || result.Documents[0].Name != "car.sysml" {
+		t.Fatalf("documents = %+v, want car.sysml alone", result.Documents)
+	}
+	if result.Content != "" {
+		t.Errorf("content = %q, want empty for a model of two documents", result.Content)
+	}
+	_, err = client.ApplyDocumentEdits(context.Background(), model, "nope.sysml",
+		opensysml.Rename{Target: "Car::wheel", NewName: "tyre"})
+	if !errors.Is(err, opensysml.CodeInvalidArgument) {
+		t.Errorf("an unknown document: err = %v, want CodeInvalidArgument", err)
+	}
+}
+
+func TestApplyEditsRefusalNamesReferrersByDocument(t *testing.T) {
+	client := newClient(t)
+	model := parseTwo(t, client)
+	_, err := client.ApplyEdits(context.Background(), model,
+		opensysml.Delete{Target: "Lib::Engine"})
+	var refused *opensysml.EditError
+	if !errors.As(err, &refused) {
+		t.Fatalf("err = %T (%v), want *EditError", err, err)
+	}
+	if refused.Failure != opensysml.EditFailureDeleteReferenced {
+		t.Fatalf("failure = %v, want %v", refused.Failure, opensysml.EditFailureDeleteReferenced)
+	}
+	want := []opensysml.Referrer{{Name: "Car::engine", Document: "car.sysml"}}
+	if !reflect.DeepEqual(refused.Referrers, want) {
+		t.Errorf("referrers = %+v, want %+v", refused.Referrers, want)
+	}
+	if !reflect.DeepEqual(refused.Referring, []string{"Car::engine (car.sysml)"}) {
+		t.Errorf("referring = %q, want the referrer qualified by its document", refused.Referring)
+	}
+}
+
+// A service without edit_documents edits one document alone and answers Content
+// alone, so a client checks the capability before reading Documents, before
+// editing a model of several documents, and before naming a document.
+func TestApplyEditsWithoutEditDocumentsAnswersContentAlone(t *testing.T) {
+	svc, err := sysmlgrpc.NewServiceWithUnavailableCapabilitiesForTesting(16, "test", []string{opensysml.CapabilityEditDocuments})
+	if err != nil {
+		t.Fatalf("NewServiceWithUnavailableCapabilitiesForTesting: %v", err)
+	}
+	t.Cleanup(svc.Close)
+	mux := http.NewServeMux()
+	mux.Handle(protoconnect.NewSysMLServiceHandler(sysmlgrpc.NewConnectAdapter(svc)))
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+	client := dialClient(t, server.URL)
+	ctx := context.Background()
+
+	info, err := client.ServerInfo(ctx)
+	if err != nil {
+		t.Fatalf("ServerInfo: %v", err)
+	}
+	if info.Has(opensysml.CapabilityEditDocuments) || !info.Has(opensysml.CapabilityApplyEdits) {
+		t.Fatalf("capabilities = %v, want apply_edits without edit_documents", info.Capabilities)
+	}
+
+	one := parse(t, client, editableSource)
+	result, err := client.ApplyEdits(ctx, one, opensysml.SetValue{Target: "Demo::SC::unitMass", Value: "1050.0"})
+	if err != nil {
+		t.Fatalf("ApplyEdits: %v", err)
+	}
+	if !strings.Contains(result.Content, "1050.0") || len(result.Documents) != 0 {
+		t.Errorf("content=%q documents=%+v, want the notation in Content alone", result.Content, result.Documents)
+	}
+	if len(result.Applied) != 1 || result.Applied[0].Document != "" {
+		t.Errorf("applied = %+v, want one edit naming no document", result.Applied)
+	}
+
+	two := parseTwo(t, client)
+	_, err = client.ApplyEdits(ctx, two, opensysml.Rename{Target: "Lib::Engine", NewName: "Motor"})
+	if !errors.Is(err, opensysml.CodeFailedPrecondition) || !strings.Contains(err.Error(), opensysml.CapabilityEditDocuments) {
+		t.Errorf("a model of two: err = %v, want CodeFailedPrecondition naming edit_documents", err)
+	}
+	_, err = client.ApplyDocumentEdits(ctx, two, "car.sysml", opensysml.Rename{Target: "Car::wheel", NewName: "tyre"})
+	if !errors.Is(err, opensysml.CodeUnimplemented) || !strings.Contains(err.Error(), opensysml.CapabilityEditDocuments) {
+		t.Errorf("naming a document: err = %v, want CodeUnimplemented naming edit_documents", err)
+	}
+}
+
 func TestApplyEditsRefusesAllOrNothingWithAClassifiedFailure(t *testing.T) {
 	client := newClient(t)
 	model := parse(t, client, editableSource)
@@ -622,6 +997,10 @@ func TestEveryOperationIsRefusedAfterClose(t *testing.T) {
 			_, err := client.VerifySatisfaction(ctx, model, "Demo::analysis")
 			return err
 		},
+		"ValidateInstance": func() error {
+			_, err := client.ValidateInstance(ctx, model, "Demo::sedan")
+			return err
+		},
 		"EvaluateCalc": func() error {
 			_, err := client.EvaluateCalc(ctx, model, "Demo::add", opensysml.Int(1), opensysml.Int(2))
 			return err
@@ -658,6 +1037,10 @@ func TestEveryOperationIsRefusedAfterClose(t *testing.T) {
 			_, err := client.ApplyEdits(ctx, model, opensysml.Delete{Target: "Demo::sedan"})
 			return err
 		},
+		"ApplyDocumentEdits": func() error {
+			_, err := client.ApplyDocumentEdits(ctx, model, "<content>", opensysml.Delete{Target: "Demo::sedan"})
+			return err
+		},
 	} {
 		if err := call(); !errors.Is(err, opensysml.CodeUnavailable) {
 			t.Errorf("%s after Close: err = %v, want CodeUnavailable", name, err)
@@ -693,6 +1076,10 @@ func TestANilModelIsInvalidForEveryOperation(t *testing.T) {
 			_, err := client.ApplyEdits(ctx, nil, opensysml.Delete{Target: "Demo::sedan"})
 			return err
 		},
+		"ApplyDocumentEdits": func() error {
+			_, err := client.ApplyDocumentEdits(ctx, nil, "<content>", opensysml.Delete{Target: "Demo::sedan"})
+			return err
+		},
 	} {
 		if err := call(); !errors.Is(err, opensysml.CodeInvalidArgument) {
 			t.Errorf("%s with a nil model: err = %v, want CodeInvalidArgument", name, err)
diff --git a/client/opensysml/types.go b/client/opensysml/types.go
index acf00a5740..27dbddae74 100644
--- a/client/opensysml/types.go
+++ b/client/opensysml/types.go
@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"slices"
 
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 )
 
 // Capability names a client can require of a ServerInfo, re-stated from the
@@ -25,6 +25,7 @@ const (
 	CapabilityFeatureValues        = sysmlgrpc.CapabilityFeatureValues
 	CapabilityApplyEdits           = sysmlgrpc.CapabilityApplyEdits
 	CapabilityAuthoring            = sysmlgrpc.CapabilityAuthoring
+	CapabilityEditDocuments        = sysmlgrpc.CapabilityEditDocuments
 	CapabilityInlineLanguage       = sysmlgrpc.CapabilityInlineLanguage
 	CapabilityStrictConformance    = sysmlgrpc.CapabilityStrictConformance
 	CapabilityParseSources         = sysmlgrpc.CapabilityParseSources
@@ -41,6 +42,7 @@ const (
 	CapabilityVerificationVerdicts = sysmlgrpc.CapabilityVerificationVerdicts
 	CapabilityCaseEvaluations      = sysmlgrpc.CapabilityCaseEvaluations
 	CapabilityFinalTime            = sysmlgrpc.CapabilityFinalTime
+	CapabilityPerformer            = sysmlgrpc.CapabilityPerformer
 	CapabilityEngines              = sysmlgrpc.CapabilityEngines
 	CapabilityUndeterminedValue    = sysmlgrpc.CapabilityUndeterminedValue
 )
diff --git a/client/opensysml/value.go b/client/opensysml/value.go
index 767988038d..f72fa5a32b 100644
--- a/client/opensysml/value.go
+++ b/client/opensysml/value.go
@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/runtime"
+	"github.com/Open-MBEE/OpenSysML/internal/exec/runtime"
 )
 
 // Value is one evaluated SysML value. It is a sealed sum: the concrete types
diff --git a/client/opensysml/verify.go b/client/opensysml/verify.go
index 6b4e8709b9..302765deae 100644
--- a/client/opensysml/verify.go
+++ b/client/opensysml/verify.go
@@ -54,6 +54,10 @@ type Verdict struct {
 	// RequirementID is the FQN of the requirement a satisfaction verdict
 	// asserts satisfied, empty when the assertion names none.
 	RequirementID string
+	// InstancePath names the object the verdict is about by the features held
+	// from the validated object, "engine.pump" or "wheels[2]"; empty for the
+	// validated object itself and for verdicts a Validation did not produce.
+	InstancePath string
 	// Verifications are the body verdicts of that requirement's verification
 	// cases, reported beside this verdict rather than instead of it.
 	Verifications []VerificationVerdict
@@ -142,6 +146,47 @@ func (s *Satisfaction) Holds() bool {
 	return true
 }
 
+// Validation is every assertion's verdict about one object and the objects it
+// holds: its asserted constraints and invariants, the requirements it carries,
+// and the satisfaction assertions whose subjects are inside it.
+type Validation struct {
+	// Verdicts is one verdict per assertion per object, in the order the objects
+	// were reached from the validated one, each naming its object by InstancePath.
+	Verdicts []Verdict
+	// Summary is the verdict about the object as a whole, of Kind "object": it
+	// holds when every verdict holds and every held object was reached, and is
+	// Undecided, with the reason as its Error, when an assertion is.
+	Summary *Verdict
+	// Verifications are the body verdicts of every requirement the verdicts are
+	// about, each naming the requirement it was reported for.
+	Verifications []VerificationVerdict
+	// Instances are the objects reachable from the validated one, including it.
+	Instances []*Instance
+	// Bounded reports a walk cut short by an object graph that goes on without
+	// end, so objects past the cut carry assertions no verdict covers.
+	Bounded bool
+	// Diagnostics the validation reported.
+	Diagnostics []Diagnostic
+}
+
+// Valid reports whether the object stands: at least one assertion, every one held
+// and every held object reached. An undecided assertion, a bounded walk or an
+// object no assertion is about is not valid.
+func (v *Validation) Valid() bool {
+	return v.Summary != nil && v.Summary.Holds && !v.Summary.Undecided()
+}
+
+// Violated reports whether an assertion answered false, whatever else went
+// undecided.
+func (v *Validation) Violated() bool {
+	for i := range v.Verdicts {
+		if !v.Verdicts[i].Holds && !v.Verdicts[i].Undecided() {
+			return true
+		}
+	}
+	return false
+}
+
 // Calculation is what one calculation computed: the value an invocation
 // returned, or the output features a calc usage evaluated from its own members.
 type Calculation struct {
@@ -283,6 +328,54 @@ func (c *client) VerifySatisfaction(
 	return out, nil
 }
 
+func (c *client) ValidateInstance(
+	ctx context.Context, model *Model, symbolID string, opts ...VerifyOption,
+) (*Validation, error) {
+	hash, err := c.call(model)
+	if err != nil {
+		return nil, err
+	}
+	options, err := c.verifyOptions(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+	if options.subjectSymbolID != "" {
+		return nil, &StatusError{
+			Code:    CodeInvalidArgument,
+			Message: "ValidateInstance takes no subject: the symbol named is the object validated",
+		}
+	}
+	resp, err := c.caller.validateInstance(ctx, &pb.ValidateInstanceRequest{
+		ModelHash: hash,
+		SymbolId:  symbolID,
+		Engine:    engineField(options.engine),
+	})
+	if err != nil {
+		return nil, err
+	}
+	diagnostics := diagnosticsFromProto(resp.Diagnostics)
+	if resp.Error != "" {
+		return nil, &VerifyError{
+			FailureError: FailureError{Op: "ValidateInstance", Message: resp.Error, Diagnostics: diagnostics},
+			Reason:       Reason(resp.FailureReason),
+		}
+	}
+	out := &Validation{
+		Summary:       verdictFromProto(resp.Summary),
+		Instances:     instancesFromProto(resp.Instances),
+		Bounded:       resp.Bounded,
+		Diagnostics:   diagnostics,
+		Verifications: verificationVerdictsFromProto(resp.VerificationVerdicts),
+	}
+	for _, verdict := range resp.Verdicts {
+		if converted := verdictFromProto(verdict); converted != nil {
+			converted.Verifications = verificationsOf(out.Verifications, converted.RequirementID)
+			out.Verdicts = append(out.Verdicts, *converted)
+		}
+	}
+	return out, nil
+}
+
 func (c *client) EvaluateCalc(
 	ctx context.Context,
 	model *Model,
@@ -428,6 +521,7 @@ func verdictFromProto(verdict *pb.Verdict) *Verdict {
 		Error:          verdict.Error,
 		Reason:         Reason(verdict.FailureReason),
 		RequirementID:  verdict.RequirementId,
+		InstancePath:   verdict.InstancePath,
 		Standing:       standingFromProto(verdict.Engine, verdict.Strength, verdict.Bounds),
 	}
 }
diff --git a/client/python/INSTALL.md b/client/python/INSTALL.md
new file mode 100644
index 0000000000..c89889e1f3
--- /dev/null
+++ b/client/python/INSTALL.md
@@ -0,0 +1,73 @@
+# Installing opensysml
+
+## From PyPI
+
+```bash
+pip install opensysml
+```
+
+Published to [PyPI](https://pypi.org/project/opensysml/) from CircleCI by the core `v*`
+release tag, at the core's version: `v0.9.0` publishes `opensysml` 0.9.0. The package
+downloads the `sysml-grpc` service it needs at runtime from the release
+`OPENSYSML_GRPC_VERSION` (or `version=`) names, so pinning both to one version gives the
+pairing that release tested:
+
+```bash
+pip install opensysml==0.9.0
+export OPENSYSML_GRPC_VERSION=v0.9.0
+```
+
+See [docs/project/releasing.md](../../docs/project/releasing.md#releasing-opensysml-to-pypi).
+
+## From source
+
+From the repository root:
+
+```bash
+# Install in development mode (editable)
+pip install -e client/python/
+
+# Or install with dev dependencies
+pip install -e "client/python/[dev]"
+```
+
+## Running tests
+
+Install with `[dev]`: the lifecycle tests inspect processes through `psutil`,
+which the package itself does not need.
+
+From the repository root:
+
+```bash
+# Run all tests
+pytest client/python/tests/
+
+# Run with verbose output
+pytest -v client/python/tests/
+
+# Run specific test file
+pytest client/python/tests/test_connection.py
+
+# Run integration tests (requires the sysml-grpc binary)
+pytest -m integration client/python/tests/
+```
+
+A test that connects without naming a service starts a private `sysml-grpc`
+child from `~/.opensysml/bin`, so put a built binary there (`make build-grpc &&
+cp bin/sysml-grpc ~/.opensysml/bin/`). To run against a service you started
+yourself, set `OPENSYSML_SERVICE=host:port`.
+
+## Package structure
+
+```
+client/python/
+├── opensysml/          # Package source
+│   ├── *.py          # Core modules (connection, model, symbol, etc.)
+│   ├── proto/        # Generated protobuf stubs
+│   └── release-digests.json  # Pinned service digests, synced from client/
+├── tests/            # Test suite
+├── scripts/          # Release helpers (version check, latency measurement)
+├── pyproject.toml    # Package metadata and build configuration
+├── README.md         # Package documentation
+└── INSTALL.md        # This file
+```
diff --git a/clients/python/LICENSE b/client/python/LICENSE
similarity index 100%
rename from clients/python/LICENSE
rename to client/python/LICENSE
diff --git a/client/python/README.md b/client/python/README.md
new file mode 100644
index 0000000000..fec649d391
--- /dev/null
+++ b/client/python/README.md
@@ -0,0 +1,317 @@
+# opensysml
+
+Python client for OpenSysML: parse, inspect and execute SysML v2 models over the
+`sysml-grpc` service.
+
+```bash
+pip install opensysml             # from PyPI
+pip install -e client/python/          # or from a checkout, at the repository root
+```
+
+```python
+import opensysml
+
+model = opensysml.load("model.sysml", strict=True)   # raises on error diagnostics
+print(model.eval("1 + 2 * 3"))                     # 7
+
+print(model.eval("mass", subject="Demo::sedan"))   # 1200.0 — that object, not the default
+                                                   # requires the service's evaluate_subject
+                                                   # capability; the client checks it first
+
+vehicle = model["Vehicle"]                         # by short name or FQN
+vehicle.attributes()                               # own and inherited, with resolved facts
+inst = model.instantiate("Demo::Vehicle")
+inst.mass                                          # 1500.0 [kg] — a Quantity
+
+model.verify_satisfaction()                        # every assert satisfy … by …
+model.verify_constraint("Demo::Vehicle::massOK", subject="Demo::sedan", engine="check")
+model.connection.list_engines()                    # what `sysml -engines` prints
+model.save("model.ttl")                            # RDF Turtle (experimental)
+model.save("model.json")                           # OMG API element JSON (experimental)
+```
+
+A value arrives as the Python value of its kind — `Quantity`, `complex`, `Array`, `Vector`,
+`VectorQuantity`, `TensorQuantity`, `SetValue`, `MeasurementRef`, `Function`, `Metaobject`,
+`EnumLiteral`, `Undetermined`, `INFINITY` — never a string to parse; every verdict carries a
+`Standing` naming the engine that answered and the strength of its evidence.
+
+Declarations can be authored from notation strings while preserving the
+untouched source:
+
+```python
+model.edit().add_part_def("", "Vehicle").apply()
+model.edit().add_part("Vehicle", "engine", type="Engine").apply()
+```
+
+Use `opensysml.loads(text, language="kerml")` for inline KerML content.
+
+Every call goes through the `sysml-grpc` service, which `opensysml` starts automatically from
+the first place it finds one; the guide below describes how to install it there.
+
+## Resolving the service binary
+
+Every client resolves the service binary in the same order, and this one is no
+exception:
+
+1. **`$OPENSYSML_BINARY`**, when set and non-empty: exactly that path is started.
+   If it names something that is not an executable file, that is an error naming
+   the variable and the path — an explicit instruction is never a fallback.
+2. **The shared cache** `~/.opensysml/bin/sysml-grpc` (`sysml-grpc.exe` on
+   Windows), where a verified download puts it and where `make build` can put
+   your own build.
+3. **A release download** into that cache, when a release is asked for by
+   `ensure_binary(version=...)` or `$OPENSYSML_GRPC_VERSION`. A download that was
+   asked for and failed is an error, not a reason to try `$PATH`, whose binary is
+   of no known release.
+4. **`$PATH`**: the first executable `sysml-grpc` (`sysml-grpc.exe` on Windows)
+   on it, which is what a package manager or `go install` leaves behind.
+
+With none of those, the error lists everywhere it looked and what would fix it.
+
+A binary from `$OPENSYSML_BINARY` or `$PATH` is used exactly as it is found: it
+belongs to no release, so **it is not verified against the pinned digests below**,
+not copied into the shared cache, and started at its own path rather than a
+digest-named link. Naming it, or installing it on `$PATH`, is trusting it; the
+pinned-digest trust model covers downloads only.
+
+## Service ownership
+
+`opensysml` uses a service of its own, and never stops a service it did not
+start.
+
+- A connection made without naming a service starts a **private child** of this
+  interpreter. The child binds port 0, so the kernel assigns the port, and it
+  reports the address it was given on its stdout — no port is chosen, probed or
+  retried by the client, and two interpreters starting at once cannot collide.
+- One private child serves **every** connection of an interpreter that needs the
+  same service release. The first of them starts it; it stops when the last one
+  closes, or when the interpreter exits. Sharing it shares its parse cache,
+  which is what makes a second connection cheap (see below).
+- Its lifetime is this interpreter's. Nothing is recorded on disk about it, no
+  other process adopts it, and a service another process left listening is
+  neither reused nor cleaned up.
+- Connecting to a service `opensysml` did not start is explicit: pass a host and
+  port (`opensysml.connect("localhost", 50051)`, or `connect("localhost:50051")`),
+  set `$OPENSYSML_SERVICE=host:port`, or pass `auto_start=False` to require a
+  service the caller manages. Closing such a connection leaves it running.
+
+### No orphans
+
+The client holds the write end of the child's **stdin pipe** and never writes to
+it; the child reads its stdin and shuts down on end of file. Nothing else holds
+that write end, so the pipe closes when the owning process goes away — and it is
+the kernel that closes it, not any code of ours. That survives what an `atexit`
+hook or a supervisor thread does not: `SIGKILL`, `os._exit`, a fatal interpreter
+error, and a crash during shutdown. On an orderly close the client also closes
+stdin itself and then signals the child, so exit is prompt rather than eventual.
+
+The client signals only through the `Popen` object of the child it started, so no
+pid it did not start — including one the operating system has since reused —
+can be signalled. That guarantee no longer needs a start-time check to hold,
+because there is no pid on disk to re-authenticate.
+
+Per platform:
+
+- **Linux** and **macOS**: the child is started in a session of its own
+  (`start_new_session=True`), so a `SIGINT` or `SIGHUP` sent to the client's
+  process group does not reach it; stdin is what ends it. Other children the
+  client spawns do not inherit the write end, since CPython closes descriptors
+  across `subprocess` by default, so it has exactly one holder.
+- **Windows**: the operating system closes the same anonymous pipe when the
+  owning process exits, however it exits, so the guarantee is unchanged. Windows
+  has no `fork()`, so the case below cannot arise there.
+- **`fork()`**: the forked child inherits the write end, which would hold the
+  service open past its owner. An `os.register_at_fork` hook therefore disowns
+  the inherited services in the new process and closes its copy of the pipe: the
+  service stays tied to the process that started it, and a forked child that
+  connects starts one of its own.
+
+The single limitation is deliberate: a service reached explicitly is not tied to
+the client's lifetime, because the client does not own it.
+
+### Cost of a private child
+
+Measured on Linux with `client/python/scripts/measure_private_service.py` (n=20):
+
+|                                                       |     p50 |     p95 |
+| ----------------------------------------------------- | ------: | ------: |
+| first connection: spawn, bind, report, handshake      |  7.0 ms |  9.1 ms |
+| a later connection joining this interpreter's child   |  0.6 ms |  1.0 ms |
+| a child per connection, rather than one shared        | 29.6 ms | 54.6 ms |
+| parsing a model the shared child has already parsed   |  0.3 ms |  1.2 ms |
+| the same parse in a child of that connection's own    | 139.8 ms | 269.6 ms |
+
+The last two rows are why the child is per interpreter rather than per
+connection: a child per connection would not only spawn N times, it would parse
+each model N times, against a cache hit some 500x cheaper.
+
+## Pinned release digests
+
+A download is verified against the table in `client/release-digests.json`, which
+pins the SHA-256 of every asset of a release and is the one table every client
+verifies against; `opensysml` ships its own synced copy of it as
+`opensysml/release-digests.json` and reads it as `binary.PINNED_SHA256`, because a
+pin resolved from outside the published wheel would not be a pin. The `.sha256` served beside a
+binary comes from whoever served the binary, so it detects corruption but not a
+republished release; a pinned digest is independent of that origin. A download
+with no pin fails with a message naming the version, rather than falling back to
+the served checksum — `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=<owner/repo>` (or `=1` for
+any repository) accepts same-origin trust explicitly for what it names, with a
+warning.
+
+The cache at `~/.opensysml/bin/sysml-grpc` is shared with the other clients, so
+deciding whether it is the release asked for and replacing the binary and its
+metadata is done holding `~/.opensysml/bin/sysml-grpc.lock` (`fcntl.lockf`, the
+same lock a Java `FileLock` and the Rust client take). Concurrent installers
+therefore queue rather than pair one release's bytes with another's record. Because that lock is held
+over the download, every response is read bounded — 512 MiB for a binary, 8 MiB
+for a checksum, manifest, bundle or release JSON — so an endless body is refused
+rather than filling memory while other clients wait.
+
+What a service is started from is not that shared path but a link to it under its
+own digest, `~/.opensysml/bin/sysml-grpc-<first 16 hex of its SHA-256>`, made
+while the lock is held: the cache is replaced in place, so starting it after the
+lock is dropped could start whatever another client installed in between. The
+Java and Rust clients name that file the same way, so the three share it, and a
+cache that cannot be hashed or linked is started directly with a warning saying so.
+
+At release time, after the service binaries are published and final:
+
+```bash
+export GITHUB_TOKEN=...            # the release API rate-limits unauthenticated calls
+python scripts/pin_release_checksums.py --version v0.0.9 --write
+git commit -am 'chore(clients): pin release digests for v0.0.9'
+```
+
+The script downloads every `sysml-grpc-*` asset of that release, hashes what it
+downloaded, refuses the release if a `.sha256` sidecar disagrees with the asset
+it describes, rewrites the table in place, and syncs it into every client that
+ships a copy (`python3 scripts/sync-release-digests.py`, whose `--check` mode CI
+runs so a copy cannot drift). `--check` re-hashes the assets of
+every pinned release and fails on any disagreement, catching a release
+republished with another binary. A opensysml release therefore pins the service
+releases published before it; asking for a newer one needs a newer opensysml (or
+the explicit opt-in above), and leaves an already-downloaded binary serving
+rather than refusing to start — only a digest that *contradicts* a pin is
+treated as tampering and refuses to fall back.
+
+## Version
+
+`opensysml/_version.py` is the only declaration: the packaging metadata reads it,
+`opensysml.__version__` reports the installed distribution's version, and
+`scripts/check_version.py` fails a release whose tag names another version. The
+version tests therefore require the tree under test to be the installed
+distribution — `pip install -e client/python/`. A wheel of another version installed
+beside the source tree makes them fail with that remedy: the artifact is what is
+stale, not the declaration.
+
+## Generated typed classes
+
+`python -m opensysml.generate model.sysml -o model_types.py` emits one class per
+SysML *definition*, and the generated hierarchy follows the model's
+generalization edges: `specializes`, `subsets` and `redefines` all become base
+classes, because Python has a single notion of inheritance. What tells the two
+apart is the members, not the bases:
+
+- a **redefinition** reuses the redefined feature's name, so its property
+  overrides the base class's property of that name, and takes over the type and
+  multiplicity it does not restate (`attribute :>> mass = 2.0;` stays `float`,
+  and a redefined `0..*` feature stays a `list[...]`);
+- a **subset** under a new name adds a property beside the base class's one, and
+  likewise inherits the type and multiplicity it leaves out.
+
+With **multiple supertypes**, bases are emitted in declaration order, a target
+named twice appearing once, and Python resolves members left to right by its
+usual MRO. A base another declared base already specializes is left implicit —
+`Hybrid :> Vehicle, Electric` where `Electric :> Vehicle` emits
+`class Hybrid(Electric)`, which Python can linearize and which keeps both
+relationships and `Electric`'s properties. Where no order linearizes at all
+(two bases specializing a shared pair in opposite orders), rather than emit a
+module that fails to import, the generator keeps the bases it can and records
+what it left out as a comment on the class, naming the edge:
+
+```python
+class Both(One):
+    # specializes Demo::Two, left out: Python cannot linearize it with the bases above
+```
+
+A base outside the generated model is reported the same way. Both are the model's
+hierarchy being wider than Python's, not facts being discarded — the service
+reports every edge, and `Symbol.specializations` still carries them all.
+
+**Limitation, unchanged:** only structural usages (`attribute`, `part`, `item`,
+`occurrence`, `individual`, `port`, `enum`) become properties. Behavioral and
+connector usages — `action`, `state`, `calc`, `constraint`, `requirement`,
+`connection`, `flow`, `interface`, `allocation`, `case` — are not instance feature values,
+so a generated class has no member for them; reach them through
+`model["Demo::Vehicle"]`, `verify_constraint` and `verify_satisfaction`.
+
+## Diagnostics
+
+A `Diagnostic` has `severity`, `message`, `code` and a location (`file`,
+`start_line`, `start_column`, `end_line`, `end_column`, or the raw `span`). Branch
+on `code`, not on the message text: `"syntax"` for a syntax error, a validation
+code such as `"unresolved"` for a finding, `"choice-point"` and
+`"guard-unevaluable"` for a run's notes; `""` when the service assigned none. A service
+that populates `code` advertises `opensysml.capabilities.CAPABILITY_DIAGNOSTIC_CODES`; without
+it every code is `""`.
+
+```python
+model = opensysml.load("model.sysml")
+unresolved = [d for d in model.diagnostics if d.code == "unresolved"]
+```
+
+## Names that shadow builtins
+
+Neither builtin name is a live part of the API any more: the module-level
+evaluation function is `opensysml.evaluate`, and the execution error is
+`opensysml.ExecutionError`. `opensysml.eval` and `opensysml.errors.RuntimeError`
+remain as deprecated aliases that warn on use, out of their modules' `__all__`,
+so a star-import binds neither.
+
+```python
+import opensysml
+
+opensysml.evaluate("1 + 2", file_path="model.sysml")   # opensysml.eval warns
+model.eval("mass", subject="Demo::sedan")            # a method shadows nothing
+```
+
+```python
+from opensysml import eval          # shadows the builtin in this module — avoid
+```
+
+Guidance for this package and for code around it:
+
+- **Call `opensysml.evaluate`.** `opensysml.eval` still works and returns the same
+  result, warning `DeprecationWarning`; it goes away in 1.0.0.
+- Import the package, not its names, for anything named like a builtin.
+- Catch `opensysml.ExecutionError` (or its base `opensysml.OpenSysMLError`), never
+  `opensysml.errors.RuntimeError`, which warns and is due for removal.
+- Do not name a new public function or exception after a builtin.
+
+0.2.0 therefore publishes `evaluate` as the name to write, with both builtin
+names deprecated rather than removed, so code written against 0.1.x keeps
+running until 1.0.0.
+
+## Running the tests
+
+```bash
+make build                                    # builds bin/sysml-grpc
+pip install -e client/python/ && pip install pytest pytest-mock
+python -m pytest client/python/tests/ -q             # service-backed tests skip
+```
+
+Tests that need a service skip when none answers on `localhost:50051` and no
+binary is available to spawn one. Where a service *is* provided — as in CI —
+export `OPENSYSML_REQUIRE_SERVICE=1`, and its absence fails instead of skipping.
+
+## Documentation
+
+- Using the client:
+  [docs/guide/09-clients.md](https://github.com/Open-MBEE/OpenSysML/blob/main/docs/guide/09-clients.md)
+  — installing the service binary, loading a model, instances, verification, conversion
+  and queries
+- The API surface, generated typed classes, latency and the module map:
+  [docs/reference/python-api.md](https://github.com/Open-MBEE/OpenSysML/blob/main/docs/reference/python-api.md)
+- Installing from source and running the tests:
+  [INSTALL.md](https://github.com/Open-MBEE/OpenSysML/blob/main/client/python/INSTALL.md)
diff --git a/clients/python/opensysml/__init__.py b/client/python/opensysml/__init__.py
similarity index 91%
rename from clients/python/opensysml/__init__.py
rename to client/python/opensysml/__init__.py
index 9d4607f24c..09508dbcf4 100644
--- a/clients/python/opensysml/__init__.py
+++ b/client/python/opensysml/__init__.py
@@ -29,20 +29,22 @@
     Vector, VectorQuantity,
 )
 from opensysml.verdict import (
-    AnalysisResult, CalcResult, CaseEvaluation, SweepRow, SweepTable, Verdict,
-    VerificationVerdict,
+    AnalysisResult, CalcResult, CaseEvaluation, SweepRow, SweepTable, Validation,
+    Verdict, VerificationVerdict,
 )
 from opensysml.exploration import Exploration, Outcome
 from opensysml.engines import Bound, EngineInfo, Standing
 from opensysml.query import QueryElement, QueryError
 from opensysml.document import (
-    DocumentQueryError, DocumentQueryResult, DocumentRow, ElementRef, INFINITY,
+    DocumentEvent, DocumentQueryError, DocumentQueryResult, DocumentRow, DocumentState,
+    DocumentVerdict, ElementRef, INFINITY, ObjectRef,
 )
 from opensysml.conversion import (
-    FORMAT_SYSML, FORMAT_TURTLE, Conversion, ExperimentalFeatureWarning,
+    FORMAT_API_JSON, FORMAT_SYSML, FORMAT_TURTLE, Conversion,
+    ExperimentalFeatureWarning,
     format_of_path, is_experimental,
 )
-from opensysml.edit import AppliedEdit, EditResult, Editor
+from opensysml.edit import AppliedEdit, EditedDocument, EditResult, Editor
 from opensysml.errors import (
     OpenSysMLError, AnalysisRunError, ChecksumMismatchError, ConnectionError, ConversionError,
     EditError, EditResultError, EditTargetError, ExecutionError,
@@ -50,6 +52,7 @@
     RenameReferencedError,
     OwnerNotFoundError, OwnerNotNamespaceError, IllegalMemberKindError,
     MemberNameTakenError, DeleteReferencedError, OwnerInsideTargetError, MoveReferencedError,
+    ReferencedElsewhereError, Referrer,
     InstanceTypeError, InvalidRequestError, ManifestSignatureError, ModelError,
     ModelFileNotFoundError, ModelNotFoundError, ServiceError,
     ServiceTimeoutError, StaleServiceError, SymbolNotFoundError,
@@ -65,23 +68,24 @@
     "UNSET", "UnsetType", "Undetermined",
     "Array", "Vector", "VectorQuantity", "MeasurementRef", "Function", "Metaobject", "SetValue",
     "TensorQuantity", "InstanceRef",
-    "Conversion", "FORMAT_SYSML", "FORMAT_TURTLE", "format_of_path",
+    "Conversion", "FORMAT_API_JSON", "FORMAT_SYSML", "FORMAT_TURTLE",
+    "format_of_path",
     "ExperimentalFeatureWarning", "is_experimental",
-    "Editor", "EditResult", "AppliedEdit",
+    "Editor", "EditResult", "AppliedEdit", "EditedDocument", "Referrer",
     "Verdict", "CalcResult", "AnalysisResult", "CaseEvaluation", "SweepRow", "SweepTable",
-    "VerificationVerdict",
+    "Validation", "VerificationVerdict",
     "Exploration", "Outcome",
     "Bound", "EngineInfo", "Standing",
     "QueryElement", "QueryError",
-    "DocumentQueryError", "DocumentQueryResult", "DocumentRow", "ElementRef",
-    "INFINITY",
+    "DocumentEvent", "DocumentQueryError", "DocumentQueryResult", "DocumentRow",
+    "DocumentState", "DocumentVerdict", "ElementRef", "INFINITY", "ObjectRef",
     "OpenSysMLError", "AnalysisRunError", "ChecksumMismatchError", "ConnectionError",
     "ConversionError", "ExecutionError", "FeatureValueError",
     "EditError", "NoEditsError", "EditTargetError", "InvalidEditError",
     "RenameReferencedError", "OverlappingEditsError", "EditResultError",
     "OwnerNotFoundError", "OwnerNotNamespaceError", "IllegalMemberKindError",
     "MemberNameTakenError", "DeleteReferencedError", "OwnerInsideTargetError",
-    "MoveReferencedError",
+    "MoveReferencedError", "ReferencedElsewhereError",
     "InstanceTypeError", "InvalidRequestError", "ManifestSignatureError",
     "MissingCapabilityError",
     "ModelError", "ModelFileNotFoundError", "ModelNotFoundError",
@@ -232,14 +236,16 @@ def convert(to_format, file_path=None, content=None, model_hash=None,
     """Write a model out in another format (module-level convenience).
 
     Args:
-        to_format (str): 'sysml', 'kerml', 'text', 'ttl', 'turtle' or 'rdf'
+        to_format (str): 'sysml', 'kerml', 'text', 'ttl', 'turtle', 'rdf',
+            'api-json' or 'json'
         file_path (str, optional): Path the service reads the source from
         content (str, optional): Source carried inline
         model_hash (str, optional): Hash of a loaded model, whose parsed source
             is converted
-        from_format (str, optional): Format to read the source as; inferred from
-            file_path's extension when omitted, notation for a model_hash, and
-            required for inline content
+        from_format (str, optional): Format to read the source as, one of the
+            to_format names or 'xmi', 'uml' or 'mdzip' for a SysML v1 model to
+            migrate; inferred from file_path's extension when omitted, notation
+            for a model_hash, and required for inline content
         tolerate_syntax_errors (bool): Write notation back out even when the
             parser could not read all of it
         host (str): Service hostname, or a ``host:port`` address
@@ -250,7 +256,9 @@ def convert(to_format, file_path=None, content=None, model_hash=None,
 
     Warns:
         ExperimentalFeatureWarning: If either format is RDF, whose mapping is
-            experimental — see ``docs/reference/rdf-mapping.md``
+            experimental (see ``docs/reference/rdf-mapping.md``), or the source
+            is SysML v1, whose migration is experimental too (see
+            ``docs/reference/sysml-v1-migration.md``)
 
     Example:
         >>> import opensysml
diff --git a/clients/python/opensysml/_dist.py b/client/python/opensysml/_dist.py
similarity index 100%
rename from clients/python/opensysml/_dist.py
rename to client/python/opensysml/_dist.py
diff --git a/clients/python/opensysml/_version.py b/client/python/opensysml/_version.py
similarity index 95%
rename from clients/python/opensysml/_version.py
rename to client/python/opensysml/_version.py
index 85e2102285..b042240b7b 100644
--- a/clients/python/opensysml/_version.py
+++ b/client/python/opensysml/_version.py
@@ -6,4 +6,4 @@
 this value with the release tag before anything is uploaded.
 """
 
-VERSION = "0.5.0"
+VERSION = "0.9.0"
diff --git a/clients/python/opensysml/binary.py b/client/python/opensysml/binary.py
similarity index 99%
rename from clients/python/opensysml/binary.py
rename to client/python/opensysml/binary.py
index 2a2b6ed870..7c6b416b6b 100644
--- a/clients/python/opensysml/binary.py
+++ b/client/python/opensysml/binary.py
@@ -107,8 +107,8 @@ def _load_pinned_digests():
 
 #: SHA-256 digest expected of each release asset, keyed by repository, release tag
 #: and asset name: independent of the origin serving the download, so a republished
-#: release is refused. Synced from clients/release-digests.json; see "Pinned release
-#: digests" in clients/python/README.md.
+#: release is refused. Synced from client/release-digests.json; see "Pinned release
+#: digests" in client/python/README.md.
 PINNED_SHA256 = _load_pinned_digests()
 
 #: Set to the repository whose unpinned downloads may be accepted (`1` for any),
diff --git a/clients/python/opensysml/capabilities.py b/client/python/opensysml/capabilities.py
similarity index 92%
rename from clients/python/opensysml/capabilities.py
rename to client/python/opensysml/capabilities.py
index dd978a25ef..7fe4cf2016 100644
--- a/clients/python/opensysml/capabilities.py
+++ b/client/python/opensysml/capabilities.py
@@ -45,6 +45,11 @@
 #: Without it the service refuses with ``UNIMPLEMENTED``.
 CAPABILITY_RENDER_DOCUMENT = "render_document"
 
+#: ``form`` on ``RenderDocumentRequest``, which asks ``RenderDocument`` for the
+#: HTML page instead of Markdown. Without it the service would render Markdown
+#: whatever form was asked, so the client refuses to ask for HTML.
+CAPABILITY_RENDER_DOCUMENT_HTML = "render_document_html"
+
 #: An enumeration literal as ``Value.enum_literal``. Without it a literal is
 #: reported as an unsupported null, which is indistinguishable from a value the
 #: service could not evaluate.
@@ -75,6 +80,11 @@
 CAPABILITY_APPLY_EDITS = "apply_edits"
 #: Source-preserving add-member and delete authoring operations.
 CAPABILITY_AUTHORING = "authoring"
+#: ``ApplyEdits`` edits a model of several documents as one batch and answers
+#: each edited document by name in ``EditResult.documents``, with each applied
+#: edit's document and each referrer's. Without it the service edits a model of
+#: one document alone and answers ``content`` alone, leaving ``documents`` empty.
+CAPABILITY_EDIT_DOCUMENTS = "edit_documents"
 #: Declares the language of inline content passed to ``ParseFile``.
 CAPABILITY_INLINE_LANGUAGE = "inline_language"
 #: ``ParseFileRequest.strict_conformance``, which asks whether the source is
@@ -163,6 +173,13 @@
 #: ``UNIMPLEMENTED``, so the client refuses to send one.
 CAPABILITY_SCHEDULE_EXPLORE = "schedule_explore"
 
+#: ``performer_symbol_id`` on the action and state requests: the object the
+#: behavior runs on, named as a part definition or usage to make an object of or
+#: a path from one into its parts (``Mission::mission.vehicle``), made for the
+#: call — and anew for each explored run. Without it the service would run the
+#: behavior outside any object, so the client refuses to send a performer.
+CAPABILITY_PERFORMER = "performer"
+
 #: ``final_time`` populated on an action or state run's response: the run's
 #: simulation clock when it ended, in seconds, read as the ``final_time`` of
 #: :meth:`~opensysml.connection.Connection.execute_state`. Without it the
diff --git a/clients/python/opensysml/connection.py b/client/python/opensysml/connection.py
similarity index 91%
rename from clients/python/opensysml/connection.py
rename to client/python/opensysml/connection.py
index 7cd35da0af..7a99fe33e0 100644
--- a/clients/python/opensysml/connection.py
+++ b/client/python/opensysml/connection.py
@@ -26,8 +26,10 @@
     CAPABILITY_INFINITY_VALUE,
     CAPABILITY_MEASUREMENT_REFS,
     CAPABILITY_METAOBJECT_VALUES,
+    CAPABILITY_PERFORMER,
     CAPABILITY_QUERY,
     CAPABILITY_RENDER_DOCUMENT,
+    CAPABILITY_RENDER_DOCUMENT_HTML,
     CAPABILITY_SCHEDULE,
     CAPABILITY_SCHEDULE_EXPLORE,
     CAPABILITY_SET_VALUES,
@@ -48,7 +50,7 @@
 )
 from opensysml.diagnostic import Diagnostic
 from opensysml.document import build_bindings, result_of as document_result
-from opensysml.edit import error_for_failure, failure_name, result_of
+from opensysml.edit import error_for_failure, failure_name, referrers_of, result_of
 from opensysml.enumeration import EnumLiteral
 from opensysml.exploration import Exploration, Outcome
 from opensysml.errors import (
@@ -82,8 +84,8 @@
 )
 from opensysml.engines import ENGINE_AUTO, EngineInfo, Standing
 from opensysml.verdict import (
-    AnalysisResult, CalcResult, CaseEvaluation, SweepRow, SweepTable, Verdict,
-    VerificationVerdict,
+    AnalysisResult, CalcResult, CaseEvaluation, SweepRow, SweepTable, Validation,
+    Verdict, VerificationVerdict,
 )
 
 
@@ -836,14 +838,16 @@ def convert(self, to_format, file_path=None, content=None, model_hash=None,
 
         Args:
             to_format (str): Format to write: 'sysml', 'kerml', 'text', 'ttl',
-                'turtle' or 'rdf'
+                'turtle', 'rdf', 'api-json' or 'json'
             file_path (str, optional): Path the service reads the source from
             content (str, optional): Source carried inline
             model_hash (str, optional): Hash of a loaded model, whose parsed
                 source is converted
-            from_format (str, optional): Format to read the source as; inferred
-                from file_path's extension when omitted, notation for a
-                model_hash, and required for inline content
+            from_format (str, optional): Format to read the source as, one of
+                the to_format names or 'xmi', 'uml' or 'mdzip' for a SysML v1
+                model to migrate; inferred from file_path's extension when
+                omitted, notation for a model_hash, and required for inline
+                content
             tolerate_syntax_errors (bool): Write notation back out even when the
                 parser could not read all of it, reporting its syntax errors as
                 the result's diagnostics. Notation to notation only: every other
@@ -856,7 +860,9 @@ def convert(self, to_format, file_path=None, content=None, model_hash=None,
 
         Warns:
             ExperimentalFeatureWarning: If either format is RDF, whose mapping is
-                experimental — see ``docs/reference/rdf-mapping.md``
+                experimental (see ``docs/reference/rdf-mapping.md``), or the
+                source is SysML v1, whose migration is experimental too (see
+                ``docs/reference/sysml-v1-migration.md``)
 
         Raises:
             ValueError: If other than one of file_path, content and model_hash
@@ -958,7 +964,8 @@ def apply_edits(self, model_hash, operations):
         """
         info = self.server_info()
         require(info, CAPABILITY_APPLY_EDITS, upgrade_remedy(CAPABILITY_APPLY_EDITS))
-        request = sysml_pb2.ApplyEditsRequest(model_hash=model_hash)
+        # This client reads ``documents``, so a model of several may be edited.
+        request = sysml_pb2.ApplyEditsRequest(model_hash=model_hash, accept_documents=True)
         requests_authoring = False
         for operation_data in operations:
             operation = request.operations.add()
@@ -1020,6 +1027,7 @@ def apply_edits(self, model_hash, operations):
                 response.error,
                 diagnostics=[Diagnostic(d) for d in response.diagnostics],
                 referring_elements=list(response.referring_elements),
+                referrers=referrers_of(response),
             )
         return result_of(response)
 
@@ -1072,7 +1080,8 @@ def run_document_query(self, model_hash, query_id, bindings=None):
             query_id (str): Qualified name of the document query
             bindings (Mapping, optional): Parameter name to a value or list of
                 values; an :class:`~opensysml.document.ElementRef` binds a
-                model element
+                model element, an :class:`~opensysml.document.ObjectRef` an
+                object ``instantiate`` built, by id or by path
 
         Returns:
             DocumentQueryResult: Projected columns and typed rows, in the
@@ -1081,7 +1090,8 @@ def run_document_query(self, model_hash, query_id, bindings=None):
         Raises:
             MissingCapabilityError: If the service cannot run document queries
             InvalidRequestError: If the query is not one, or a binding is wrong
-            SymbolNotFoundError: If the model does not declare the query
+            SymbolNotFoundError: If the model does not declare the query, or
+                an object binding names an object the model does not hold
             ModelNotFoundError: If the model is no longer cached
         """
         require(
@@ -1101,8 +1111,8 @@ def run_document_query(self, model_hash, query_id, bindings=None):
             response = self._stub.RunDocumentQuery(request)
         return document_result(response)
 
-    def render_document(self, model_hash, document_id):
-        """Render a named document to Markdown.
+    def render_document(self, model_hash, document_id, form="markdown"):
+        """Render a named document to Markdown or HTML.
 
         The document is the model's own — a part def specializing
         ``DocumentQueries::Document`` — whose queries are bound in the model.
@@ -1110,31 +1120,39 @@ def render_document(self, model_hash, document_id):
         Args:
             model_hash (str): Hash of the model holding the document
             document_id (str): Qualified name of the document
+            form (str): ``"markdown"`` (the default) or ``"html"``, the
+                standalone page with the default stylesheet that the CLI's
+                ``-doc-form html`` writes
 
         Returns:
-            str: The rendered Markdown
+            str: The rendered document in the form asked for
 
         Raises:
-            MissingCapabilityError: If the service cannot render documents
+            ValueError: If ``form`` is neither ``"markdown"`` nor ``"html"``
+            MissingCapabilityError: If the service cannot render documents, or
+                cannot render HTML when that form is asked for
             InvalidRequestError: If the symbol named is not a document
             SymbolNotFoundError: If the model does not declare the document
             ModelNotFoundError: If the model is no longer cached
         """
-        require(
-            self.server_info(),
-            CAPABILITY_RENDER_DOCUMENT,
-            upgrade_remedy(CAPABILITY_RENDER_DOCUMENT),
-        )
+        if form not in ("markdown", "html"):
+            raise ValueError("form must be 'markdown' or 'html'")
+        capabilities = [CAPABILITY_RENDER_DOCUMENT]
+        if form == "html":
+            capabilities.append(CAPABILITY_RENDER_DOCUMENT_HTML)
+        for capability in capabilities:
+            require(self.server_info(), capability, upgrade_remedy(capability))
         request = sysml_pb2.RenderDocumentRequest(
             model_hash=model_hash,
             document_id=document_id,
+            form="" if form == "markdown" else form,
         )
         with translate_rpc_errors(
             not_found=SymbolNotFoundError,
-            unimplemented=self._capability_refusal((CAPABILITY_RENDER_DOCUMENT,)),
+            unimplemented=self._capability_refusal(capabilities),
         ):
             response = self._stub.RenderDocument(request)
-        return response.markdown
+        return response.html if form == "html" else response.markdown
 
     def get_symbol(self, model_hash, symbol_id):
         """Fetch symbol by ID from cached model.
@@ -1249,7 +1267,7 @@ def instantiate(self, symbol_id, model_hash):
         return Instance(response.instance, graph)
     
     def execute_action(self, action_symbol_id, model_hash, inputs=None,
-                       schedule=None):
+                       schedule=None, performer=None):
         """Execute an action definition.
         
         Args:
@@ -1261,6 +1279,13 @@ def execute_action(self, action_symbol_id, model_hash, inputs=None,
                 default) or ``"seed:<n>"`` — spelled as ``sysml -schedule``
                 spells it. ``"explore"`` answers with every outcome rather than
                 one run's, so it belongs to :meth:`explore_action`
+            performer (str, optional): The object the action runs on, as
+                ``sysml -action "<action> <object>"`` names it: a part
+                definition or usage to make an object of, or a path from one
+                into its parts (``"Mission::mission.vehicle"``) — the mission is
+                made and the action performed by its vehicle, inside the
+                assembly, so what its siblings send over their connectors
+                reaches it. Without one the action runs outside any object
             
         Returns:
             dict: Output parameter name → value; an output the wire format cannot
@@ -1282,12 +1307,13 @@ def execute_action(self, action_symbol_id, model_hash, inputs=None,
                 ``set_values``, a :class:`~opensysml.values.TensorQuantity`
                 and the service predates ``tensor_values``, a
                 :class:`~opensysml.values.Metaobject` and the service predates
-                ``metaobject_values``, or a schedule is given and the service
-                predates ``schedule``; nothing is sent
+                ``metaobject_values``, a schedule is given and the service
+                predates ``schedule``, or a performer is given and the service
+                predates ``performer``; nothing is sent
             InvalidRequestError: If the schedule names no policy
         """
         _refuse_exploring(schedule, "explore_action")
-        response = self._execute_action(action_symbol_id, model_hash, inputs, schedule)
+        response = self._execute_action(action_symbol_id, model_hash, inputs, schedule, performer)
         if response.error:
             wrapped_diags = [Diagnostic(d) for d in response.diagnostics]
             raise ExecutionError(response.error, diagnostics=wrapped_diags)
@@ -1295,7 +1321,7 @@ def execute_action(self, action_symbol_id, model_hash, inputs=None,
         return self._values_to_python(response.outputs)
 
     def explore_action(self, action_symbol_id, model_hash, inputs=None,
-                       schedule="explore"):
+                       schedule="explore", performer=None):
         """Run an action once per valid order of its choice points, within a budget.
 
         The service replays the action from the start, taking a different
@@ -1312,6 +1338,8 @@ def explore_action(self, action_symbol_id, model_hash, inputs=None,
                 ``"explore:runs=<n>,depth=<d>"``, bounding the runs made and the
                 choice points one run resolves; the service documents the
                 defaults
+            performer (str, optional): The object the action runs on, as for
+                :meth:`execute_action`; every explored run makes it anew
 
         Returns:
             Exploration: Every distinct outcome reached and how the exploration
@@ -1323,30 +1351,30 @@ def explore_action(self, action_symbol_id, model_hash, inputs=None,
                 unknown action, an input of the wrong kind
             ModelNotFoundError: If the service no longer holds the model
             MissingCapabilityError: If the service predates ``schedule_explore``,
-                or an input needs a value capability it lacks; nothing is sent
+                or ``performer`` when a performer is given, or an input needs a
+                value capability it lacks; nothing is sent
             InvalidRequestError: If the schedule's options are malformed
         """
         _require_exploring(schedule)
-        response = self._execute_action(action_symbol_id, model_hash, inputs, schedule)
+        response = self._execute_action(action_symbol_id, model_hash, inputs, schedule, performer)
         return self._exploration_of(response)
 
-    def _execute_action(self, action_symbol_id, model_hash, inputs, schedule):
-        """Send an ExecuteAction request, the schedule's capabilities checked first."""
+    def _execute_action(self, action_symbol_id, model_hash, inputs, schedule, performer):
+        """Send an ExecuteAction request, the schedule's and performer's capabilities checked first."""
         pb_inputs = {name: self._python_to_value(val) for name, val in (inputs or {}).items()}
-        self._require_schedule(schedule)
+        capabilities = self._run_capabilities(schedule, performer)
         req = sysml_pb2.ExecuteActionRequest(
             model_hash=model_hash,
             action_symbol_id=action_symbol_id,
             inputs=pb_inputs,
             schedule=schedule or "",
+            performer_symbol_id=performer or "",
         )
-        with translate_rpc_errors(
-            unimplemented=self._capability_refusal(self._schedule_capabilities(schedule))
-        ):
+        with translate_rpc_errors(unimplemented=self._capability_refusal(capabilities)):
             return self._stub.ExecuteAction(req)
     
     def execute_state(self, state_machine_symbol_id, model_hash, events=None,
-                      schedule=None):
+                      schedule=None, performer=None):
         """Execute a state machine.
         
         Args:
@@ -1356,6 +1384,11 @@ def execute_state(self, state_machine_symbol_id, model_hash, events=None,
             schedule (str, optional): Scheduling policy the run resolves its
                 choice points under, as for :meth:`execute_action`;
                 ``"explore"`` belongs to :meth:`explore_state`
+            performer (str, optional): The object the machine runs on, as for
+                :meth:`execute_action`: a part definition or usage, or a path
+                from one into its parts. An object exhibiting the machine runs
+                the machine it exhibits, so its transitions hear what the
+                object's siblings send over their connectors
             
         Returns:
             dict: {'states_visited': [...], 'final_context': {...}, 'final_time': float};
@@ -1370,11 +1403,12 @@ def execute_state(self, state_machine_symbol_id, model_hash, events=None,
             ExecutionError: If execution fails
             ModelNotFoundError: If the service no longer holds the model
             MissingCapabilityError: If a schedule is given and the service
-                predates ``schedule``; nothing is sent
+                predates ``schedule``, or a performer is given and the service
+                predates ``performer``; nothing is sent
             InvalidRequestError: If the schedule names no policy
         """
         _refuse_exploring(schedule, "explore_state")
-        response = self._execute_state(state_machine_symbol_id, model_hash, events, schedule)
+        response = self._execute_state(state_machine_symbol_id, model_hash, events, schedule, performer)
         if response.error:
             wrapped_diags = [Diagnostic(d) for d in response.diagnostics]
             raise ExecutionError(response.error, diagnostics=wrapped_diags)
@@ -1386,7 +1420,7 @@ def execute_state(self, state_machine_symbol_id, model_hash, events=None,
         }
 
     def explore_state(self, state_machine_symbol_id, model_hash, events=None,
-                      schedule="explore"):
+                      schedule="explore", performer=None):
         """Run a state machine over the events once per valid order of its choice points.
 
         Runs agreeing on the state they rest in, the states they entered and
@@ -1398,6 +1432,8 @@ def explore_state(self, state_machine_symbol_id, model_hash, events=None,
             events (list, optional): Event names to process
             schedule (str, optional): ``"explore"`` or
                 ``"explore:runs=<n>,depth=<d>"``
+            performer (str, optional): The object the machine runs on, as for
+                :meth:`execute_state`; every explored run makes it anew
 
         Returns:
             Exploration: Every distinct outcome reached and how the exploration
@@ -1407,26 +1443,25 @@ def explore_state(self, state_machine_symbol_id, model_hash, events=None,
             ValueError: If the schedule does not explore
             ExecutionError: If the machine could not be explored at all
             ModelNotFoundError: If the service no longer holds the model
-            MissingCapabilityError: If the service predates ``schedule_explore``;
-                nothing is sent
+            MissingCapabilityError: If the service predates ``schedule_explore``,
+                or ``performer`` when a performer is given; nothing is sent
             InvalidRequestError: If the schedule's options are malformed
         """
         _require_exploring(schedule)
-        response = self._execute_state(state_machine_symbol_id, model_hash, events, schedule)
+        response = self._execute_state(state_machine_symbol_id, model_hash, events, schedule, performer)
         return self._exploration_of(response)
 
-    def _execute_state(self, state_machine_symbol_id, model_hash, events, schedule):
-        """Send an ExecuteState request, the schedule's capabilities checked first."""
-        self._require_schedule(schedule)
+    def _execute_state(self, state_machine_symbol_id, model_hash, events, schedule, performer):
+        """Send an ExecuteState request, the schedule's and performer's capabilities checked first."""
+        capabilities = self._run_capabilities(schedule, performer)
         req = sysml_pb2.ExecuteStateRequest(
             model_hash=model_hash,
             state_machine_symbol_id=state_machine_symbol_id,
             events=events or [],
             schedule=schedule or "",
+            performer_symbol_id=performer or "",
         )
-        with translate_rpc_errors(
-            unimplemented=self._capability_refusal(self._schedule_capabilities(schedule))
-        ):
+        with translate_rpc_errors(unimplemented=self._capability_refusal(capabilities)):
             return self._stub.ExecuteState(req)
 
     def _exploration_of(self, response, failure_reason=None):
@@ -1442,6 +1477,7 @@ def _exploration_of(self, response, failure_reason=None):
             budgets_hit=status.budgets_hit,
             runs_budget=status.runs_budget,
             depth_budget=status.depth_budget,
+            probabilities_lower_bound=status.probabilities_lower_bound,
         )
 
     def _outcome_of(self, pb):
@@ -1452,6 +1488,7 @@ def _outcome_of(self, pb):
             states_visited=pb.states_visited,
             error=pb.error,
             linearizations=pb.linearizations,
+            probability=pb.probability,
             witness=pb.witness,
             diagnostics=[Diagnostic(d) for d in pb.diagnostics],
         )
@@ -1621,6 +1658,83 @@ def verify_satisfaction(self, model_hash, symbol_id=None, engine=None):
             for pb_verdict in response.verdicts
         ]
 
+    def validate_instance(self, symbol_id, model_hash, engine=None):
+        """Check every assertion about an object and the objects it holds, as ``%validate`` does.
+
+        An object of the part named is built for the call, then each asserted
+        constraint, each requirement and each satisfaction assertion whose
+        subject lies in the object's tree is evaluated against the object
+        carrying it — a wheel's constraint against each wheel, not against the
+        car.
+
+        Args:
+            symbol_id (str): FQN of the part definition or usage an object of
+                which is validated
+            model_hash (str): Hash from ParseFile response
+            engine (str, optional): The engine to ask, as for
+                :meth:`verify_constraint`
+
+        Returns:
+            Validation: One verdict per assertion, naming the object it is
+                about by its path from the root, and the object's own verdict.
+                A failing assertion is that answer, not an exception; one that
+                could not be evaluated is reported as its ``error``.
+
+        Raises:
+            ExecutionError: If the request could not be answered at all — an
+                unknown symbol, or one no object can be built of
+            MissingCapabilityError: If the service cannot verify, or an engine
+                is given and the service predates ``engines``; nothing is sent
+            InvalidRequestError: If the engine names none the service registers
+            ModelNotFoundError: If the service no longer holds the model
+        """
+        self._require_verification()
+        self._require_engine(engine)
+        request = sysml_pb2.ValidateInstanceRequest(
+            model_hash=model_hash,
+            symbol_id=symbol_id,
+            engine=_engine_field(engine),
+        )
+        with translate_rpc_errors(
+            unimplemented=self._capability_refusal(
+                (CAPABILITY_VERIFICATION,) + self._engine_capabilities(engine)
+            )
+        ):
+            response = self._stub.ValidateInstance(request)
+
+        diagnostics = [Diagnostic(d) for d in response.diagnostics]
+        if response.error:
+            raise _failure_of(
+                response.error, response.failure_reason, diagnostics
+            )
+        instances = self._instances_of(response)
+        verifications = _verifications_of(response)
+        verdicts = [
+            Verdict(
+                pb_verdict,
+                instances=instances,
+                diagnostics=diagnostics,
+                verifications=_verifications_for(verifications, pb_verdict),
+            )
+            for pb_verdict in response.verdicts
+        ]
+        summary = None
+        if response.HasField("summary"):
+            summary = Verdict(
+                response.summary,
+                instances=instances,
+                diagnostics=diagnostics,
+                verifications=verifications,
+            )
+        return Validation(
+            verdicts,
+            summary,
+            instances=instances,
+            diagnostics=diagnostics,
+            verifications=verifications,
+            bounded=response.bounded,
+        )
+
     def calc(self, symbol_id, model_hash, arguments=None, engine=None):
         """Invoke a calculation, as the REPL's ``%calc`` does.
 
@@ -2096,6 +2210,15 @@ def _require_schedule(self, schedule):
         for capability in self._schedule_capabilities(schedule):
             require(self.server_info(), capability, upgrade_remedy(capability))
 
+    def _run_capabilities(self, schedule, performer):
+        """The capabilities a run's schedule and performer need, each required of the service."""
+        capabilities = self._schedule_capabilities(schedule)
+        if performer:
+            capabilities.append(CAPABILITY_PERFORMER)
+        for capability in capabilities:
+            require(self.server_info(), capability, upgrade_remedy(capability))
+        return capabilities
+
     @staticmethod
     def _schedule_capabilities(schedule):
         """The capabilities a schedule spelling needs of the service: none for the default."""
diff --git a/clients/python/opensysml/conversion.py b/client/python/opensysml/conversion.py
similarity index 86%
rename from clients/python/opensysml/conversion.py
rename to client/python/opensysml/conversion.py
index ee0f48226e..cd1b853fc2 100644
--- a/clients/python/opensysml/conversion.py
+++ b/client/python/opensysml/conversion.py
@@ -18,10 +18,14 @@
 FORMAT_SYSML = "sysml"
 #: RDF in Turtle syntax. ``turtle`` and ``rdf`` name it too.
 FORMAT_TURTLE = "ttl"
+#: The OMG API's JSON element form, the same RDF mapping spelled differently.
+#: ``json`` names it too.
+FORMAT_API_JSON = "api-json"
 
 #: Names the service canonicalizes each format to, so a reported format can be
 #: told apart without repeating the alias table.
 _TURTLE_NAMES = frozenset({"ttl", "turtle", "rdf"})
+_API_JSON_NAMES = frozenset({"api-json", "json"})
 
 #: Names of the SysML v1 input the service migrates, an experimental mapping too.
 _XMI_NAMES = frozenset({"xmi", "uml", "mdzip"})
@@ -29,9 +33,10 @@
 #: The fallback wording, for a service too old to send its own notice: the RDF
 #: mapping's status is a property of the mapping, not of the service.
 EXPERIMENTAL_NOTICE = (
-    "RDF conversion is experimental: the mapping covers model structure and the "
-    "behavior its bodies state, refuses what it cannot write back, and its "
-    "vocabulary may change without a compatibility path; see "
+    "RDF conversion \u2014 Turtle and the API's JSON element form alike \u2014 is "
+    "experimental: the mapping covers model structure and the behavior its "
+    "bodies state, refuses what it cannot write back, and its vocabulary may "
+    "change without a compatibility path; see "
     "docs/reference/rdf-mapping.md \u00a7 Status"
 )
 
@@ -53,12 +58,15 @@ def is_experimental(from_format, to_format):
         to_format (str): Format written, as the service reports it.
 
     Returns:
-        bool: True when either side is RDF or the input is SysML v1 XMI, which
-        is migrated. Notation to notation is stable.
+        bool: True when either side is RDF or the API's JSON element form, or
+        the input is SysML v1 XMI, which is migrated. Notation to notation is
+        stable.
     """
     return (
         from_format in _TURTLE_NAMES
         or to_format in _TURTLE_NAMES
+        or from_format in _API_JSON_NAMES
+        or to_format in _API_JSON_NAMES
         or from_format in _XMI_NAMES
     )
 
@@ -70,6 +78,7 @@ def is_experimental(from_format, to_format):
     ".kerml": FORMAT_SYSML,
     ".ttl": FORMAT_TURTLE,
     ".turtle": FORMAT_TURTLE,
+    ".json": FORMAT_API_JSON,
 }
 
 
diff --git a/clients/python/opensysml/diagnostic.py b/client/python/opensysml/diagnostic.py
similarity index 100%
rename from clients/python/opensysml/diagnostic.py
rename to client/python/opensysml/diagnostic.py
diff --git a/client/python/opensysml/document.py b/client/python/opensysml/document.py
new file mode 100644
index 0000000000..db960367c1
--- /dev/null
+++ b/client/python/opensysml/document.py
@@ -0,0 +1,451 @@
+"""Native document queries and document rendering.
+
+The service runs a *document query* — a calc def specializing
+``DocumentQueries::Query`` — and answers typed rows, and renders a *document* —
+a part def specializing ``DocumentQueries::Document`` — to Markdown. These are
+the model's own named queries and documents, not the SysML v2 API & Services
+Query that :mod:`opensysml.query` builds.
+
+A binding value is a plain Python value (``str``, ``int``, ``float``,
+``bool``), a :class:`~opensysml.values.Quantity`, an :class:`ElementRef`
+naming a model element by qualified name, or an :class:`ObjectRef` naming an
+object the service holds for the model since ``instantiate`` — by id or by
+path (``"car.wheels[2]"``). Answered cells decode back to the same kinds, plus
+:data:`INFINITY` for an unbounded multiplicity, a :class:`DocumentVerdict`
+for a row a ``Verdicts`` query answered, a :class:`DocumentState` for a row a
+``States`` query answered and a :class:`DocumentEvent` for a row an ``Events``
+query answered.
+"""
+
+from dataclasses import dataclass
+from typing import Optional, Sequence, Union
+
+from opensysml.errors import OpenSysMLError, UnsupportedValueError
+from opensysml.proto import sysml_pb2
+from opensysml.values import INFINITY, Quantity, _Infinity
+
+
+class DocumentQueryError(OpenSysMLError, ValueError):
+    """Raised when a binding cannot be written before anything is sent."""
+
+
+@dataclass(frozen=True)
+class ElementRef:
+    """A model element, named by qualified name.
+
+    Attributes:
+        id: Qualified name of the element
+        type: Metamodel type name ("PartUsage", ...); empty when bound by a
+            caller, reported when answered by the service
+    """
+
+    id: str
+    type: str = ""
+
+    def __str__(self):
+        return f"{self.id} ({self.type})" if self.type else self.id
+
+
+@dataclass(frozen=True)
+class ObjectRef:
+    """An object the service holds for the model, created by ``instantiate``.
+
+    Bound, it names the object by ``path`` when set and by ``id`` otherwise;
+    one setting both must name one object by both. Answered, it carries all
+    three fields.
+
+    Attributes:
+        id: The object's id, as ``instantiate`` answered it
+        path: The object by the label a session reaches it under: the
+            qualified name it was instantiated as (``"Garage::car"``), its id
+            (``"#2"``), or a path through feature values of either
+            (``"Garage::car.wheels[2]"``, ``"#2.wheels[2]"``; indexes count
+            from 1)
+        element: The usage the object is held under, its definition or usage;
+            reported when answered, ignored when bound
+    """
+
+    id: int = 0
+    path: str = ""
+    element: Optional[ElementRef] = None
+
+    def __str__(self):
+        return self.path or f"#{self.id}"
+
+
+@dataclass(frozen=True)
+class DocumentVerdict:
+    """A row a ``Verdicts`` query answered: an assertion checked on one object.
+
+    Answered only; binding one is refused.
+
+    Attributes:
+        assertion: The constraint, requirement, satisfy usage or verification
+            case checked; its ``id`` is empty when the assertion is anonymous
+        kind: ``"constraint"``, ``"requirement"``, ``"satisfaction"`` or
+            ``"verification"``
+        text: The assertion as written (``"assert constraint massKnown"``)
+        path: The object checked, named from the element the query was bound
+            to (``"Garage::car.wheels[2]"``)
+        status: ``"holds"``, ``"violated"`` or ``"undecided"``
+        condition: The condition that evaluated to false, as written; empty
+            otherwise
+        reason: Why the assertion is violated or undecided; empty when it holds
+        verification: Verdict kinds (``"pass"``, ``"fail"``, ...) of the
+            verification cases verifying the requirement the row is about; a
+            verification row's own kind
+    """
+
+    assertion: ElementRef
+    kind: str
+    text: str
+    path: str
+    status: str
+    condition: str = ""
+    reason: str = ""
+    verification: tuple = ()
+
+    def __str__(self):
+        where = f" on {self.path}" if self.path else ""
+        return f"{self.text}{where}: {self.status}"
+
+
+@dataclass(frozen=True)
+class DocumentState:
+    """A row a ``States`` query answered: one active leaf state of one object.
+
+    Answered only; binding one is refused.
+
+    Attributes:
+        object: The object whose state machine the row reads
+        machine: The exhibited state usage (``"lp"``), or the state def's name
+        name: The active leaf state's own name (``"dim"``)
+        path: The leaf's path from the machine's top level (``"on.dim"``)
+        state: The state usage's declaration
+        region: The orthogonal region declaring the leaf (``"light"``); empty
+            when the leaf is not in one
+        enclosing: The active composite states around the leaf, outermost first
+    """
+
+    object: ObjectRef
+    machine: str
+    name: str
+    path: str
+    state: Optional[ElementRef] = None
+    region: str = ""
+    enclosing: tuple = ()
+
+    def __str__(self):
+        return f"{self.object}.{self.machine} in {self.path}"
+
+
+@dataclass(frozen=True)
+class DocumentEvent:
+    """A row an ``Events`` query answered: one record of a session's trace.
+
+    Answered only; binding one is refused.
+
+    Attributes:
+        kind: ``"accept"``, ``"send"``, ``"transition"``, ``"entry"``,
+            ``"exit"``, ``"do"``, ``"choice"`` or ``"guard"``
+        time: The instant the record was written at, in the runtime clock's
+            unit — a :class:`~opensysml.values.Quantity` when the clock carries
+            one, a plain number otherwise
+        object: The object the record is about; ``None`` for a record of the
+            run as a whole
+        machine: The state machine the record is about, as
+            :attr:`DocumentState.machine` names it
+        state: The state entered, exited or run (entry, exit and do records)
+        from_state: The transition's source state
+        to_state: The transition's target state
+        target: The object a send was delivered to
+        event: The accepted or sent event's type name
+        payload: The accept's payload, one ``name = value`` text per attribute
+        alternatives: What a choice drew from, in order
+        taken: The alternative the choice took
+        text: The record as the trace prints it
+    """
+
+    kind: str
+    time: Quantity | int | float
+    text: str
+    object: Optional[ObjectRef] = None
+    machine: str = ""
+    state: str = ""
+    from_state: str = ""
+    to_state: str = ""
+    target: Optional[ObjectRef] = None
+    event: str = ""
+    payload: tuple = ()
+    alternatives: tuple = ()
+    taken: str = ""
+
+    def __str__(self):
+        return f"{self.time}: {self.text}"
+
+
+#: What a binding value or an answered cell value may be.
+DocumentValue = Union[
+    ElementRef, ObjectRef, str, int, float, bool, Quantity, _Infinity,
+    DocumentVerdict, DocumentState, DocumentEvent,
+]
+
+#: What ``bindings`` accepts for one parameter: one value or several.
+BindingValues = Union[DocumentValue, Sequence[DocumentValue]]
+
+
+@dataclass(frozen=True)
+class DocumentRow:
+    """One selected element and its projected cells, one per column.
+
+    Attributes:
+        element: The selected element itself; for an object row, the usage the
+            object is held under; for a row a ``Verdicts`` query answered, the
+            assertion checked; for a state or event row, the usage of the
+            object the row is about
+        cells: One value sequence per column, in column order
+        verdict: The :class:`DocumentVerdict` a row a ``Verdicts`` query
+            answered carries; ``None`` for any other row
+        object: The :class:`ObjectRef` a row over held objects is about — one
+            an ``Objects`` query enumerated, a bound object's part, or the
+            object a state or event row is about; ``None`` for any other row
+        state: The :class:`DocumentState` a row a ``States`` query answered
+            carries; ``None`` for any other row
+        event: The :class:`DocumentEvent` a row an ``Events`` query answered
+            carries; ``None`` for any other row
+    """
+
+    element: ElementRef
+    cells: tuple
+    verdict: Optional[DocumentVerdict] = None
+    object: Optional[ObjectRef] = None
+    state: Optional[DocumentState] = None
+    event: Optional[DocumentEvent] = None
+
+    def __getitem__(self, index):
+        return self.cells[index]
+
+
+@dataclass(frozen=True)
+class DocumentQueryResult:
+    """A document query's answer: projected columns and typed rows, both in the
+    deterministic order the engine reports.
+
+    Attributes:
+        columns: Projected property names, in projection order
+        rows: The selected rows, in the engine's order
+    """
+
+    columns: tuple
+    rows: tuple
+
+    def __iter__(self):
+        return iter(self.rows)
+
+    def __len__(self):
+        return len(self.rows)
+
+
+def build_bindings(bindings=None):
+    """Translate a bindings mapping into the RPC's protobuf.
+
+    Args:
+        bindings (Mapping, optional): Parameter name to one value or a list of
+            values. A ``list``/``tuple`` binds several values; anything else,
+            including ``str``, binds one.
+
+    Returns:
+        list[sysml_pb2.DocumentQueryBinding]: What the request carries
+
+    Raises:
+        DocumentQueryError: If a value is not one a binding can carry
+    """
+    if not bindings:
+        return []
+    out = []
+    for parameter, values in bindings.items():
+        if not isinstance(values, (list, tuple)):
+            values = [values]
+        out.append(sysml_pb2.DocumentQueryBinding(
+            parameter=parameter,
+            values=[_bound_value(parameter, value) for value in values],
+        ))
+    return out
+
+
+def _bound_value(parameter, value):
+    """One binding value as the wire writes it. bool before int: it is one."""
+    if isinstance(value, ElementRef):
+        return sysml_pb2.DocumentValue(element_id=value.id)
+    if isinstance(value, ObjectRef):
+        if not value.id and not value.path:
+            raise DocumentQueryError(
+                f"binding {parameter!r} cannot carry {value!r}: an object is "
+                f"bound by id or by path; neither was given"
+            )
+        return sysml_pb2.DocumentValue(
+            object=sysml_pb2.DocumentObject(instance_id=value.id, path=value.path)
+        )
+    if isinstance(value, bool):
+        return sysml_pb2.DocumentValue(bool_value=value)
+    if isinstance(value, str):
+        return sysml_pb2.DocumentValue(string_value=value)
+    if isinstance(value, int):
+        if not -(1 << 63) <= value < (1 << 63):
+            raise DocumentQueryError(
+                f"binding {parameter!r} cannot carry {value!r}: an int must "
+                f"fit in a signed 64-bit integer"
+            )
+        return sysml_pb2.DocumentValue(int_value=value)
+    if isinstance(value, float):
+        return sysml_pb2.DocumentValue(real_value=value)
+    if isinstance(value, Quantity):
+        return sysml_pb2.DocumentValue(quantity=_bound_quantity(parameter, value))
+    if isinstance(value, DocumentVerdict):
+        raise DocumentQueryError(
+            f"binding {parameter!r} cannot carry {value!r}: a verdict is "
+            f"answered by queries, not bound to them"
+        )
+    if isinstance(value, (DocumentState, DocumentEvent)):
+        what = "a state" if isinstance(value, DocumentState) else "an event"
+        raise DocumentQueryError(
+            f"binding {parameter!r} cannot carry {value!r}: {what} row is "
+            f"answered by queries, not bound to them"
+        )
+    raise DocumentQueryError(
+        f"binding {parameter!r} cannot carry {value!r}: a binding is a str, "
+        f"int, float, bool, Quantity, ElementRef or ObjectRef"
+    )
+
+
+def _bound_quantity(parameter, value):
+    """A Quantity as the wire writes it; one it cannot carry is a caller error."""
+    if isinstance(value.magnitude, int) and not isinstance(value.magnitude, bool):
+        if not -(1 << 63) <= value.magnitude < (1 << 63):
+            raise DocumentQueryError(
+                f"binding {parameter!r} cannot carry {value!r}: an Integer magnitude "
+                f"must fit in a signed 64-bit integer"
+            )
+    try:
+        return value.to_pb()
+    except UnsupportedValueError as exc:
+        raise DocumentQueryError(
+            f"binding {parameter!r} cannot carry {value!r}: {exc}"
+        ) from exc
+
+
+def result_of(response):
+    """Decode a ``RunDocumentQueryResponse`` into a :class:`DocumentQueryResult`."""
+    return DocumentQueryResult(
+        columns=tuple(column.name for column in response.columns),
+        rows=tuple(
+            _row_of(row)
+            for row in response.rows
+        ),
+    )
+
+
+def _row_of(row):
+    """One answered row; a verdict, state or event row keeps its typed value."""
+    cells = tuple(
+        tuple(_value_of(value) for value in cell.values)
+        for cell in row.cells
+    )
+    kind = row.element.WhichOneof("kind")
+    if kind == "verdict":
+        verdict = _value_of(row.element)
+        return DocumentRow(element=verdict.assertion, cells=cells, verdict=verdict)
+    if kind == "object":
+        obj = _value_of(row.element)
+        return DocumentRow(element=obj.element, cells=cells, object=obj)
+    if kind == "state":
+        state = _value_of(row.element)
+        return DocumentRow(
+            element=state.object.element, cells=cells, object=state.object, state=state,
+        )
+    if kind == "event":
+        event = _value_of(row.element)
+        element = event.object.element if event.object else ElementRef(id="")
+        return DocumentRow(element=element, cells=cells, object=event.object, event=event)
+    return DocumentRow(element=_element_of(row.element), cells=cells)
+
+
+def _element_of(value):
+    """The row's selected element, or an anonymous one when unnamed."""
+    if value.WhichOneof("kind") == "element_id":
+        return ElementRef(id=value.element_id, type=value.element_type)
+    return ElementRef(id="", type=value.element_type)
+
+
+def _value_of(value):
+    """One answered value as the Python value it is."""
+    kind = value.WhichOneof("kind")
+    if kind == "element_id":
+        return ElementRef(id=value.element_id, type=value.element_type)
+    if kind == "string_value":
+        return value.string_value
+    if kind == "int_value":
+        return value.int_value
+    if kind == "real_value":
+        return value.real_value
+    if kind == "bool_value":
+        return value.bool_value
+    if kind == "infinity":
+        return INFINITY
+    if kind == "quantity":
+        return Quantity.from_pb(value.quantity)
+    if kind == "object":
+        obj = value.object
+        return ObjectRef(
+            id=obj.instance_id,
+            path=obj.path,
+            element=_element_of(obj.element),
+        )
+    if kind == "verdict":
+        verdict = value.verdict
+        return DocumentVerdict(
+            assertion=_element_of(verdict.assertion),
+            kind=verdict.kind,
+            text=verdict.text,
+            path=verdict.path,
+            status=verdict.verdict,
+            condition=verdict.condition,
+            reason=verdict.reason,
+            verification=tuple(verdict.verification),
+        )
+    if kind == "state":
+        state = value.state
+        return DocumentState(
+            object=_object_of(state.object),
+            machine=state.machine,
+            name=state.name,
+            path=state.state_path,
+            state=_element_of(state.state) if state.HasField("state") else None,
+            region=state.region,
+            enclosing=tuple(state.enclosing),
+        )
+    if kind == "event":
+        event = value.event
+        return DocumentEvent(
+            kind=event.kind,
+            time=_value_of(event.time),
+            text=event.text,
+            object=_object_of(event.object) if event.HasField("object") else None,
+            machine=event.machine,
+            state=event.state,
+            from_state=getattr(event, "from"),
+            to_state=event.to,
+            target=_object_of(event.target) if event.HasField("target") else None,
+            event=event.event,
+            payload=tuple(event.payload),
+            alternatives=tuple(event.alternatives),
+            taken=event.taken,
+        )
+    raise UnsupportedValueError(
+        f"the service answered a document value this client cannot read: {value}"
+    )
+
+
+def _object_of(obj):
+    """An answered ``DocumentObject`` as the :class:`ObjectRef` it names."""
+    return ObjectRef(id=obj.instance_id, path=obj.path, element=_element_of(obj.element))
diff --git a/clients/python/opensysml/edit.py b/client/python/opensysml/edit.py
similarity index 87%
rename from clients/python/opensysml/edit.py
rename to client/python/opensysml/edit.py
index fa3755cbd7..bba28ab0e9 100644
--- a/clients/python/opensysml/edit.py
+++ b/client/python/opensysml/edit.py
@@ -33,6 +33,8 @@
     DeleteReferencedError,
     OwnerInsideTargetError,
     MoveReferencedError,
+    ReferencedElsewhereError,
+    Referrer,
 )
 
 #: Refusal kinds, as the wire enum names them, and the error each raises. A kind
@@ -56,6 +58,7 @@
     "EDIT_FAILURE_DELETE_REFERENCED": DeleteReferencedError,
     "EDIT_FAILURE_OWNER_INSIDE_TARGET": OwnerInsideTargetError,
     "EDIT_FAILURE_MOVE_REFERENCED": MoveReferencedError,
+    "EDIT_FAILURE_REFERENCED_ELSEWHERE": ReferencedElsewhereError,
 }
 
 
@@ -78,7 +81,8 @@ def failure_name(failure):
         return f"EDIT_FAILURE_{failure}"
 
 
-def error_for_failure(failure, message, diagnostics=None, referring_elements=None):
+def error_for_failure(failure, message, diagnostics=None, referring_elements=None,
+                      referrers=None):
     """Build the error a refusal kind names.
 
     Args:
@@ -86,6 +90,8 @@ def error_for_failure(failure, message, diagnostics=None, referring_elements=Non
         message (str): Why the edit was refused
         diagnostics (list, optional): Diagnostics behind the refusal
         referring_elements (list, optional): Referrers of a refused rename
+        referrers (list[Referrer], optional): The same referrers, each with
+            the document declaring it
 
     Returns:
         EditError: The typed refusal, ready to raise
@@ -96,6 +102,7 @@ def error_for_failure(failure, message, diagnostics=None, referring_elements=Non
         failure=failure,
         diagnostics=diagnostics,
         referring_elements=referring_elements,
+        referrers=referrers,
     )
 
 
@@ -112,6 +119,8 @@ class AppliedEdit:
             that had none: nothing was replaced, text was inserted.
         old_text: The bytes that were there.
         new_text: What replaced them.
+        document: The document the bytes belong to, named as the parse named
+            it; the model's one document for a model loaded from a file.
     """
 
     operation_index: int
@@ -120,23 +129,53 @@ class AppliedEdit:
     length: int
     old_text: str
     new_text: str
+    document: str = ""
 
     def __str__(self):
         return f"{self.target}: {self.old_text!r} -> {self.new_text!r}"
 
 
+@dataclass(frozen=True)
+class EditedDocument:
+    """The edited notation of one document of the model.
+
+    Attributes:
+        name: The document's name as the parse named it: the file path of a
+            loaded file, or the name inline content was loaded under.
+        content: The edited notation, byte-identical to the source outside the
+            edited spans.
+    """
+
+    name: str
+    content: str
+
+    def __str__(self):
+        return self.content
+
+
 @dataclass(frozen=True)
 class EditResult(Conversion):
     """The edited notation, as a :class:`~opensysml.conversion.Conversion`.
 
     ``str(result)`` is the edited text and ``result.save(path)`` writes it, so an
-    edit is written the way a conversion is.
+    edit is written the way a conversion is. ``content`` is the notation of a
+    model of one document, which is every model this client loads; a model of
+    several documents, edited through the service directly by a request that
+    accepts documents, answers with its rewritten documents in ``documents``
+    and an empty ``content``. A request not accepting them is refused on such
+    a model, as every request was before ``documents`` existed.
 
     Attributes:
-        applied: What each operation changed, in source order.
+        applied: What each operation changed, grouped by document in the order
+            ``documents`` lists them and in source order within a document.
+        documents: The edited notation of every document the edits rewrote,
+            the edited document first: one entry for a model of one document.
+            Empty from a service without the ``edit_documents`` capability,
+            which answers ``content`` alone.
     """
 
     applied: List[AppliedEdit] = field(default_factory=list)
+    documents: List[EditedDocument] = field(default_factory=list)
 
     def save(self, path):
         """Write the edited model to ``path``.
@@ -466,7 +505,24 @@ def result_of(response, applied_source=FORMAT_SYSML):
                 length=a.length,
                 old_text=a.old_text,
                 new_text=a.new_text,
+                document=a.document,
             )
             for a in response.applied
         ],
+        documents=[
+            EditedDocument(name=d.name, content=d.content)
+            for d in response.documents
+        ],
     )
+
+
+def referrers_of(response):
+    """Read an ``ApplyEditsResponse``'s referrers as :class:`Referrer` objects.
+
+    Args:
+        response: sysml_pb2.ApplyEditsResponse protobuf message
+
+    Returns:
+        list[Referrer]: Each referring declaration with its document
+    """
+    return [Referrer(name=r.name, document=r.document) for r in response.referrers]
diff --git a/clients/python/opensysml/engines.py b/client/python/opensysml/engines.py
similarity index 100%
rename from clients/python/opensysml/engines.py
rename to client/python/opensysml/engines.py
diff --git a/clients/python/opensysml/enumeration.py b/client/python/opensysml/enumeration.py
similarity index 100%
rename from clients/python/opensysml/enumeration.py
rename to client/python/opensysml/enumeration.py
diff --git a/clients/python/opensysml/errors.py b/client/python/opensysml/errors.py
similarity index 93%
rename from clients/python/opensysml/errors.py
rename to client/python/opensysml/errors.py
index f074a34277..d27af1c447 100644
--- a/clients/python/opensysml/errors.py
+++ b/client/python/opensysml/errors.py
@@ -291,16 +291,48 @@ class EditError(OpenSysMLError):
         diagnostics (list): Diagnostic objects behind the refusal — the parse
             errors of an unreadable new value, or the errors the edited notation
             was found to have
-        referring_elements (list[str]): For a refused rename, where the
-            references it would have broken are made
+        referring_elements (list[str]): For a refused rename, delete or move,
+            where the references it would have broken are made, each suffixed
+            with its document in parentheses when that is not the edited one
+        referrers (list[Referrer]): The same referrers, each with the document
+            declaring it as a field of its own
     """
 
-    def __init__(self, message, failure="", diagnostics=None, referring_elements=None):
+    def __init__(self, message, failure="", diagnostics=None, referring_elements=None,
+                 referrers=None):
         super().__init__(message)
         self.message = message
         self.failure = failure
         self.diagnostics = diagnostics or []
         self.referring_elements = list(referring_elements or [])
+        self.referrers = list(referrers or [])
+
+
+class Referrer:
+    """One declaration referring to the target of a refused rename, delete or move.
+
+    Attributes:
+        name (str): The referring declaration, as the notation names it
+        document (str): The document declaring it, as the parse named it
+    """
+
+    __slots__ = ("name", "document")
+
+    def __init__(self, name, document):
+        self.name = name
+        self.document = document
+
+    def __eq__(self, other):
+        return (
+            isinstance(other, Referrer)
+            and (self.name, self.document) == (other.name, other.document)
+        )
+
+    def __hash__(self):
+        return hash((self.name, self.document))
+
+    def __repr__(self):
+        return f"Referrer(name={self.name!r}, document={self.document!r})"
 
 
 class NoEditsError(EditError, builtins.ValueError):
@@ -378,6 +410,11 @@ class MoveReferencedError(EditError):
     """Raised when a move would leave a reference no spelling can restore."""
 
 
+class ReferencedElsewhereError(EditError):
+    """Raised when a rename, delete or move is referred to from a document the
+    edit cannot rewrite, such as a library; ``referrers`` names each one."""
+
+
 class ServiceError(OpenSysMLError):
     """Raised when the service fails a call, translated from its gRPC status.
 
diff --git a/clients/python/opensysml/exploration.py b/client/python/opensysml/exploration.py
similarity index 87%
rename from clients/python/opensysml/exploration.py
rename to client/python/opensysml/exploration.py
index 220e2a75ff..2c6be4923c 100644
--- a/clients/python/opensysml/exploration.py
+++ b/client/python/opensysml/exploration.py
@@ -28,6 +28,9 @@ class Outcome:
         states_visited (list[str]): The states a state machine entered, in order
         error (str): Why the run failed; empty for a run that completed
         linearizations (int): How many of the explored orders reached this outcome
+        probability (float): Share of the explored orders' likelihood reaching
+            this outcome, from the model's ``@Probability`` weights (uniform for
+            unweighted picks); a lower bound while the exploration is incomplete
         witness (list[str]): The choices one run reaching it made, in run order;
             empty when the behavior had no choice point
         diagnostics (list[Diagnostic]): What the witness run reported, its choice
@@ -35,12 +38,13 @@ class Outcome:
     """
 
     def __init__(self, outputs, final_state, states_visited, error,
-                 linearizations, witness, diagnostics):
+                 linearizations, witness, diagnostics, probability=0.0):
         self.outputs = dict(outputs or {})
         self.final_state = final_state
         self.states_visited = list(states_visited or [])
         self.error = error
         self.linearizations = linearizations
+        self.probability = probability
         self.witness = list(witness or [])
         self.diagnostics = list(diagnostics or [])
 
@@ -68,7 +72,7 @@ def __str__(self):
     def __repr__(self):
         return (
             f"Outcome({self!s}, linearizations={self.linearizations}, "
-            f"witness={self.witness!r})"
+            f"probability={self.probability}, witness={self.witness!r})"
         )
 
 
@@ -86,15 +90,19 @@ class Exploration:
             ``"depth"`` — empty when it is complete
         runs_budget (int): The most runs the exploration would make
         depth_budget (int): The most choice points one run would resolve
+        probabilities_lower_bound (bool): Whether the outcomes' probabilities
+            are lower bounds — a budget kept some orders unexplored
     """
 
-    def __init__(self, outcomes, complete, runs, budgets_hit, runs_budget, depth_budget):
+    def __init__(self, outcomes, complete, runs, budgets_hit, runs_budget,
+                 depth_budget, probabilities_lower_bound=False):
         self.outcomes = list(outcomes or [])
         self.complete = complete
         self.runs = runs
         self.budgets_hit = list(budgets_hit or [])
         self.runs_budget = runs_budget
         self.depth_budget = depth_budget
+        self.probabilities_lower_bound = probabilities_lower_bound
 
     def __iter__(self):
         return iter(self.outcomes)
@@ -115,7 +123,7 @@ def status(self):
             f"{budget} budget {self.depth_budget if budget == 'depth' else self.runs_budget}"
             for budget in self.budgets_hit
         )
-        return f"incomplete: {named} hit after {self.runs} runs"
+        return f"incomplete: {named} hit after {self.runs} runs; probabilities are lower bounds"
 
     def raise_for_incomplete(self):
         """Raise an :class:`~opensysml.errors.ExecutionError` unless every linearization was run."""
diff --git a/clients/python/opensysml/generate.py b/client/python/opensysml/generate.py
similarity index 99%
rename from clients/python/opensysml/generate.py
rename to client/python/opensysml/generate.py
index 7952dbfd73..6092cc1ccf 100644
--- a/clients/python/opensysml/generate.py
+++ b/client/python/opensysml/generate.py
@@ -45,7 +45,7 @@
 # Definition kinds get a class; these usage kinds become properties on the
 # enclosing class. Behavioral and connector usages (action, state, calc,
 # constraint, requirement, connection, flow, interface, allocation, case) are
-# not instance feature values and are skipped — see clients/python/README.md.
+# not instance feature values and are skipped — see client/python/README.md.
 FEATURE_KINDS = frozenset(
     {
         "attributeusage",
diff --git a/clients/python/opensysml/instance.py b/client/python/opensysml/instance.py
similarity index 100%
rename from clients/python/opensysml/instance.py
rename to client/python/opensysml/instance.py
diff --git a/clients/python/opensysml/model.py b/client/python/opensysml/model.py
similarity index 90%
rename from clients/python/opensysml/model.py
rename to client/python/opensysml/model.py
index 0e9d4caf4f..36cb5324b4 100644
--- a/clients/python/opensysml/model.py
+++ b/client/python/opensysml/model.py
@@ -4,7 +4,9 @@
 
 from opensysml.capabilities import CAPABILITY_QUERY
 from opensysml.symbol import Symbol
-from opensysml.conversion import FORMAT_SYSML, FORMAT_TURTLE, format_of_path
+from opensysml.conversion import (
+    FORMAT_API_JSON, FORMAT_SYSML, FORMAT_TURTLE, format_of_path,
+)
 from opensysml.diagnostic import Diagnostic
 from opensysml.edit import Editor
 from opensysml.errors import ModelError, SymbolNotFoundError
@@ -133,7 +135,8 @@ def convert(self, to_format, tolerate_syntax_errors=False):
         again, or convert its path through :meth:`Connection.convert`.
 
         Args:
-            to_format (str): 'sysml', 'kerml', 'text', 'ttl', 'turtle' or 'rdf'
+            to_format (str): 'sysml', 'kerml', 'text', 'ttl', 'turtle', 'rdf',
+                'api-json' or 'json'
             tolerate_syntax_errors (bool): Write notation back out even when the
                 parser could not read all of it
 
@@ -177,6 +180,17 @@ def to_turtle(self):
         """
         return self.convert(FORMAT_TURTLE)
 
+    def to_api_json(self):
+        """Write this model out in the OMG API's JSON element form.
+
+        It is the same experimental RDF mapping as Turtle, spelled as the
+        element objects the SysML v2 API serves.
+
+        Returns:
+            Conversion: The JSON; ``str()`` of it is the text
+        """
+        return self.convert(FORMAT_API_JSON)
+
     def save(self, path, to_format=None, tolerate_syntax_errors=False):
         """Write this model to ``path``, in the format its extension names.
 
@@ -267,7 +281,8 @@ def run_document_query(self, query_id, bindings=None):
             query_id (str): Qualified name of the document query
             bindings (Mapping, optional): Parameter name to a value or list of
                 values; an :class:`~opensysml.document.ElementRef` binds a
-                model element
+                model element, an :class:`~opensysml.document.ObjectRef` an
+                object ``instantiate`` built, by id or by path
 
         Returns:
             DocumentQueryResult: Projected columns and typed rows, in the
@@ -276,7 +291,8 @@ def run_document_query(self, query_id, bindings=None):
         Raises:
             MissingCapabilityError: If the service cannot run document queries
             InvalidRequestError: If the query is not one, or a binding is wrong
-            SymbolNotFoundError: If this model does not declare the query
+            SymbolNotFoundError: If this model does not declare the query, or
+                an object binding names an object this model does not hold
             ModelNotFoundError: If the service no longer holds this model
 
         Example:
@@ -291,20 +307,24 @@ def run_document_query(self, query_id, bindings=None):
             self._hash, query_id, bindings=bindings,
         )
 
-    def render_document(self, document_id):
-        """Render one of this model's named documents to Markdown.
+    def render_document(self, document_id, form="markdown"):
+        """Render one of this model's named documents to Markdown or HTML.
 
         The document is a part def specializing ``DocumentQueries::Document``,
         whose queries are bound in the model.
 
         Args:
             document_id (str): Qualified name of the document
+            form (str): ``"markdown"`` (the default) or ``"html"``, the
+                standalone page the CLI's ``-doc-form html`` writes
 
         Returns:
-            str: The rendered Markdown
+            str: The rendered document in the form asked for
 
         Raises:
-            MissingCapabilityError: If the service cannot render documents
+            ValueError: If ``form`` is neither ``"markdown"`` nor ``"html"``
+            MissingCapabilityError: If the service cannot render documents, or
+                cannot render HTML when that form is asked for
             InvalidRequestError: If the symbol named is not a document
             SymbolNotFoundError: If this model does not declare the document
             ModelNotFoundError: If the service no longer holds this model
@@ -313,8 +333,11 @@ def render_document(self, document_id):
             >>> markdown = model.render_document("Observatory::MassReport")
             >>> markdown.splitlines()[0]
             '# Telescope Mass Report'
+            >>> html = model.render_document("Observatory::MassReport", form="html")
+            >>> html.startswith("<!DOCTYPE html>")
+            True
         """
-        return self.connection.render_document(self._hash, document_id)
+        return self.connection.render_document(self._hash, document_id, form=form)
 
     def find(self, name):
         """Find symbol by short name or fully-qualified name.
@@ -507,7 +530,7 @@ def instantiate(self, symbol_id):
         """
         return self._client.instantiate(symbol_id, self._hash)
 
-    def execute_action(self, action_symbol_id, inputs=None, schedule=None):
+    def execute_action(self, action_symbol_id, inputs=None, schedule=None, performer=None):
         """Execute one of this model's actions.
 
         Args:
@@ -517,6 +540,10 @@ def execute_action(self, action_symbol_id, inputs=None, schedule=None):
                 choice points under — ``"declared"``, ``"reverse"`` (the
                 default) or ``"seed:<n>"``; ``"explore"`` belongs to
                 :meth:`explore_action`
+            performer (str, optional): The object the action runs on: a part
+                definition or usage to make an object of, or a path from one
+                into its parts (``"Mission::mission.vehicle"``), made for the
+                call and run inside its assembly
 
         Returns:
             dict: Output parameter name → value; an output the wire format
@@ -528,14 +555,14 @@ def execute_action(self, action_symbol_id, inputs=None, schedule=None):
             ExecutionError: If the action could not be executed
             ModelNotFoundError: If the service no longer holds this model
             MissingCapabilityError: If a schedule is given and the service
-                predates ``schedule``
+                predates ``schedule``, or a performer and it predates ``performer``
             InvalidRequestError: If the schedule names no policy
         """
         return self._client.execute_action(
-            action_symbol_id, self._hash, inputs=inputs, schedule=schedule
+            action_symbol_id, self._hash, inputs=inputs, schedule=schedule, performer=performer
         )
 
-    def explore_action(self, action_symbol_id, inputs=None, schedule="explore"):
+    def explore_action(self, action_symbol_id, inputs=None, schedule="explore", performer=None):
         """Run one of this model's actions once per valid order of its choice points.
 
         Args:
@@ -544,6 +571,8 @@ def explore_action(self, action_symbol_id, inputs=None, schedule="explore"):
             schedule (str, optional): ``"explore"`` or
                 ``"explore:runs=<n>,depth=<d>"``, bounding the runs made and
                 the choice points one run resolves
+            performer (str, optional): The object the action runs on, as for
+                :meth:`execute_action`; every run makes it anew
 
         Returns:
             Exploration: Every distinct outcome reached, each with the number
@@ -554,14 +583,15 @@ def explore_action(self, action_symbol_id, inputs=None, schedule="explore"):
             ValueError: If the schedule does not explore
             ExecutionError: If the action could not be explored at all
             ModelNotFoundError: If the service no longer holds this model
-            MissingCapabilityError: If the service predates ``schedule_explore``
+            MissingCapabilityError: If the service predates ``schedule_explore``,
+                or ``performer`` when a performer is given
             InvalidRequestError: If the schedule's options are malformed
         """
         return self._client.explore_action(
-            action_symbol_id, self._hash, inputs=inputs, schedule=schedule
+            action_symbol_id, self._hash, inputs=inputs, schedule=schedule, performer=performer
         )
 
-    def execute_state(self, state_machine_symbol_id, events=None, schedule=None):
+    def execute_state(self, state_machine_symbol_id, events=None, schedule=None, performer=None):
         """Execute one of this model's state machines.
 
         Args:
@@ -571,6 +601,9 @@ def execute_state(self, state_machine_symbol_id, events=None, schedule=None):
             schedule (str, optional): Scheduling policy the run resolves its
                 choice points under, as for :meth:`execute_action`;
                 ``"explore"`` belongs to :meth:`explore_state`
+            performer (str, optional): The object the machine runs on, as for
+                :meth:`execute_action`; an object exhibiting the machine runs
+                the one it exhibits, hearing its siblings over their connectors
 
         Returns:
             dict: {'states_visited': [...], 'final_context': {...}, 'final_time': float};
@@ -583,14 +616,14 @@ def execute_state(self, state_machine_symbol_id, events=None, schedule=None):
             ExecutionError: If the state machine could not be executed
             ModelNotFoundError: If the service no longer holds this model
             MissingCapabilityError: If a schedule is given and the service
-                predates ``schedule``
+                predates ``schedule``, or a performer and it predates ``performer``
             InvalidRequestError: If the schedule names no policy
         """
         return self._client.execute_state(
-            state_machine_symbol_id, self._hash, events=events, schedule=schedule
+            state_machine_symbol_id, self._hash, events=events, schedule=schedule, performer=performer
         )
 
-    def explore_state(self, state_machine_symbol_id, events=None, schedule="explore"):
+    def explore_state(self, state_machine_symbol_id, events=None, schedule="explore", performer=None):
         """Run one of this model's state machines once per valid order of its choice points.
 
         Args:
@@ -599,6 +632,8 @@ def explore_state(self, state_machine_symbol_id, events=None, schedule="explore"
             events (list, optional): Event names to process, in order
             schedule (str, optional): ``"explore"`` or
                 ``"explore:runs=<n>,depth=<d>"``
+            performer (str, optional): The object the machine runs on, as for
+                :meth:`execute_state`; every run makes it anew
 
         Returns:
             Exploration: Every distinct outcome reached — the state rested in,
@@ -609,11 +644,12 @@ def explore_state(self, state_machine_symbol_id, events=None, schedule="explore"
             ValueError: If the schedule does not explore
             ExecutionError: If the state machine could not be explored at all
             ModelNotFoundError: If the service no longer holds this model
-            MissingCapabilityError: If the service predates ``schedule_explore``
+            MissingCapabilityError: If the service predates ``schedule_explore``,
+                or ``performer`` when a performer is given
             InvalidRequestError: If the schedule's options are malformed
         """
         return self._client.explore_state(
-            state_machine_symbol_id, self._hash, events=events, schedule=schedule
+            state_machine_symbol_id, self._hash, events=events, schedule=schedule, performer=performer
         )
 
     def verify_constraint(self, symbol_id, subject=None, engine=None):
@@ -702,6 +738,29 @@ def satisfied(self, symbol_id=None):
         """
         return all(v.holds for v in self.verify_satisfaction(symbol_id))
 
+    def validate_instance(self, symbol_id, engine=None):
+        """Check every assertion about an object of one of this model's parts.
+
+        This is the scriptable form of ``sysml -validate=<object>``: an object
+        of the part is built, and each asserted constraint, requirement and
+        satisfaction assertion in its tree is evaluated against the object
+        carrying it, nested parts and every element of a collection included.
+
+        Args:
+            symbol_id (str): FQN of the part definition or usage an object of
+                which is validated
+            engine (str, optional): The engine to ask, as for
+                :meth:`verify_constraint`
+
+        Returns:
+            Validation: One verdict per assertion and the object's own; truthy
+                when every assertion holds and the whole tree was reached
+
+        Raises:
+            ExecutionError: If the request could not be answered at all
+        """
+        return self._client.validate_instance(symbol_id, self._hash, engine=engine)
+
     def calc(self, symbol_id, arguments=None, engine=None):
         """Invoke one of this model's calculations.
 
diff --git a/clients/python/opensysml/proto/__init__.py b/client/python/opensysml/proto/__init__.py
similarity index 100%
rename from clients/python/opensysml/proto/__init__.py
rename to client/python/opensysml/proto/__init__.py
diff --git a/client/python/opensysml/proto/sysml_pb2.py b/client/python/opensysml/proto/sysml_pb2.py
new file mode 100644
index 0000000000..90215d4984
--- /dev/null
+++ b/client/python/opensysml/proto/sysml_pb2.py
@@ -0,0 +1,289 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler.  DO NOT EDIT!
+# NO CHECKED-IN PROTOBUF GENCODE
+# source: sysml.proto
+# Protobuf Python Version: 7.35.1
+"""Generated protocol buffer code."""
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import runtime_version as _runtime_version
+from google.protobuf import symbol_database as _symbol_database
+from google.protobuf.internal import builder as _builder
+_runtime_version.ValidateProtobufRuntimeVersion(
+    _runtime_version.Domain.PUBLIC,
+    7,
+    35,
+    1,
+    '',
+    'sysml.proto'
+)
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0bsysml.proto\x12\x05sysml\"\xb9\x02\n\x07Verdict\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12\x12\n\nelement_id\x18\x02 \x01(\t\x12\x0f\n\x07\x65lement\x18\x03 \x01(\t\x12\r\n\x05holds\x18\x04 \x01(\x08\x12\x11\n\tcondition\x18\x05 \x01(\t\x12\x13\n\x0binstance_id\x18\x06 \x01(\x03\x12\x18\n\x10instance_type_id\x18\x07 \x01(\t\x12\r\n\x05\x65rror\x18\x08 \x01(\t\x12,\n\x0e\x66\x61ilure_reason\x18\t \x01(\x0e\x32\x14.sysml.FailureReason\x12\x16\n\x0erequirement_id\x18\n \x01(\t\x12\x0e\n\x06\x65ngine\x18\x0b \x01(\t\x12\x10\n\x08strength\x18\x0c \x01(\t\x12\x1c\n\x06\x62ounds\x18\r \x03(\x0b\x32\x0c.sysml.Bound\x12\x15\n\rinstance_path\x18\x0e \x01(\t\"5\n\x05\x42ound\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05limit\x18\x02 \x01(\x03\x12\x0f\n\x07reached\x18\x03 \x01(\x08\"k\n\x17VerifyConstraintRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x19\n\x11subject_symbol_id\x18\x03 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x04 \x01(\t\"\x96\x01\n\x18VerifyConstraintResponse\x12\x1f\n\x07verdict\x18\x01 \x01(\x0b\x32\x0e.sysml.Verdict\x12\"\n\tinstances\x18\x02 \x03(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x04 \x03(\x0b\x32\x11.sysml.Diagnostic\"l\n\x18VerifyRequirementRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x19\n\x11subject_symbol_id\x18\x03 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x04 \x01(\t\"m\n\x13VerificationVerdict\x12\x0f\n\x07\x63\x61se_id\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x0e\n\x06\x64\x65tail\x18\x03 \x01(\t\x12\x0f\n\x07subcase\x18\x04 \x01(\x08\x12\x16\n\x0erequirement_id\x18\x05 \x01(\t\"\xd2\x01\n\x19VerifyRequirementResponse\x12\x1f\n\x07verdict\x18\x01 \x01(\x0b\x32\x0e.sysml.Verdict\x12\"\n\tinstances\x18\x02 \x03(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x04 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\x39\n\x15verification_verdicts\x18\x05 \x03(\x0b\x32\x1a.sysml.VerificationVerdict\"R\n\x19VerifySatisfactionRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x03 \x01(\t\"\x82\x02\n\x1aVerifySatisfactionResponse\x12 \n\x08verdicts\x18\x01 \x03(\x0b\x32\x0e.sysml.Verdict\x12\"\n\tinstances\x18\x02 \x03(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x04 \x03(\x0b\x32\x11.sysml.Diagnostic\x12,\n\x0e\x66\x61ilure_reason\x18\x05 \x01(\x0e\x32\x14.sysml.FailureReason\x12\x39\n\x15verification_verdicts\x18\x06 \x03(\x0b\x32\x1a.sysml.VerificationVerdict\"P\n\x17ValidateInstanceRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x03 \x01(\t\"\xb2\x02\n\x18ValidateInstanceResponse\x12 \n\x08verdicts\x18\x01 \x03(\x0b\x32\x0e.sysml.Verdict\x12\x1f\n\x07summary\x18\x02 \x01(\x0b\x32\x0e.sysml.Verdict\x12\"\n\tinstances\x18\x03 \x03(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x05 \x03(\x0b\x32\x11.sysml.Diagnostic\x12,\n\x0e\x66\x61ilure_reason\x18\x06 \x01(\x0e\x32\x14.sysml.FailureReason\x12\x39\n\x15verification_verdicts\x18\x07 \x03(\x0b\x32\x1a.sysml.VerificationVerdict\x12\x0f\n\x07\x62ounded\x18\x08 \x01(\x08\"m\n\x13\x45valuateCalcRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x1f\n\targuments\x18\x03 \x03(\x0b\x32\x0c.sysml.Value\x12\x0e\n\x06\x65ngine\x18\x04 \x01(\t\"\xfd\x01\n\x14\x45valuateCalcResponse\x12\x1c\n\x06result\x18\x01 \x01(\x0b\x32\x0c.sysml.Value\x12\"\n\x07outputs\x18\x02 \x03(\x0b\x32\x11.sysml.CalcOutput\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x04 \x03(\x0b\x32\x11.sysml.Diagnostic\x12,\n\x0e\x66\x61ilure_reason\x18\x05 \x01(\x0e\x32\x14.sysml.FailureReason\x12\x0e\n\x06\x65ngine\x18\x06 \x01(\t\x12\x10\n\x08strength\x18\x07 \x01(\t\x12\x1c\n\x06\x62ounds\x18\x08 \x03(\x0b\x32\x0c.sysml.Bound\"7\n\nCalcOutput\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value\"\x93\x01\n\x0e\x43\x61seEvaluation\x12\x13\n\x0b\x66unction_id\x18\x01 \x01(\t\x12\x1f\n\targuments\x18\x02 \x03(\x0b\x32\x0c.sysml.Value\x12\x1c\n\x06result\x18\x03 \x01(\x0b\x32\x0c.sysml.Value\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12\x10\n\x08selected\x18\x05 \x01(\x08\x12\x0c\n\x04tied\x18\x06 \x01(\x08\"\xa6\x02\n\x12RunAnalysisRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x19\n\x11subject_symbol_id\x18\x03 \x01(\t\x12\x1f\n\targuments\x18\x04 \x03(\x0b\x32\x0c.sysml.Value\x12\x46\n\x0fnamed_arguments\x18\x05 \x03(\x0b\x32-.sysml.RunAnalysisRequest.NamedArgumentsEntry\x12\x10\n\x08schedule\x18\x06 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x07 \x01(\t\x1a\x43\n\x13NamedArgumentsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"\xdc\x03\n\x13RunAnalysisResponse\x12\"\n\x07outputs\x18\x01 \x03(\x0b\x32\x11.sysml.CalcOutput\x12 \n\x08verdicts\x18\x02 \x03(\x0b\x32\x0e.sysml.Verdict\x12\"\n\tinstances\x18\x03 \x03(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x05 \x03(\x0b\x32\x11.sysml.Diagnostic\x12,\n\x0e\x66\x61ilure_reason\x18\x06 \x01(\x0e\x32\x14.sysml.FailureReason\x12\x39\n\x15verification_verdicts\x18\x07 \x03(\x0b\x32\x1a.sysml.VerificationVerdict\x12 \n\x08outcomes\x18\x08 \x03(\x0b\x32\x0e.sysml.Outcome\x12-\n\x0b\x65xploration\x18\t \x01(\x0b\x32\x18.sysml.ExplorationStatus\x12*\n\x0b\x65valuations\x18\n \x03(\x0b\x32\x15.sysml.CaseEvaluation\x12\x0e\n\x06\x65ngine\x18\x0b \x01(\t\x12\x10\n\x08strength\x18\x0c \x01(\t\x12\x1c\n\x06\x62ounds\x18\r \x03(\x0b\x32\x0c.sysml.Bound\"\x97\x02\n\x07Outcome\x12,\n\x07outputs\x18\x01 \x03(\x0b\x32\x1b.sysml.Outcome.OutputsEntry\x12\x13\n\x0b\x66inal_state\x18\x02 \x01(\t\x12\x16\n\x0estates_visited\x18\x03 \x03(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12\x16\n\x0elinearizations\x18\x05 \x01(\x05\x12\x0f\n\x07witness\x18\x06 \x03(\t\x12&\n\x0b\x64iagnostics\x18\x07 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\x13\n\x0bprobability\x18\x08 \x01(\x01\x1a<\n\x0cOutputsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"\x96\x01\n\x11\x45xplorationStatus\x12\x10\n\x08\x63omplete\x18\x01 \x01(\x08\x12\x0c\n\x04runs\x18\x02 \x01(\x05\x12\x13\n\x0b\x62udgets_hit\x18\x03 \x03(\t\x12\x13\n\x0bruns_budget\x18\x04 \x01(\x05\x12\x14\n\x0c\x64\x65pth_budget\x18\x05 \x01(\x05\x12!\n\x19probabilities_lower_bound\x18\x06 \x01(\x08\"\x14\n\x12ListEnginesRequest\"\xfc\x01\n\nEngineInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x11\n\tauthority\x18\x02 \x01(\t\x12\x0f\n\x07\x61nswers\x18\x03 \x03(\t\x12\x0e\n\x06\x62ounds\x18\x04 \x03(\t\x12\x0f\n\x07process\x18\x05 \x01(\t\x12\x15\n\rprocess_found\x18\x06 \x01(\t\x12\r\n\x05ready\x18\x07 \x01(\x08\x12\x13\n\x0bunavailable\x18\x08 \x01(\t\x12\x0c\n\x04kind\x18\t \x01(\t\x12\x10\n\x08protocol\x18\n \x01(\t\x12\x0e\n\x06source\x18\x0b \x01(\t\x12\x0f\n\x07\x63ommand\x18\x0c \x01(\t\x12\x0f\n\x07version\x18\r \x01(\t\x12\x0e\n\x06served\x18\x0e \x01(\x08\"9\n\x13ListEnginesResponse\x12\"\n\x07\x65ngines\x18\x01 \x03(\x0b\x32\x11.sysml.EngineInfo\"\x8c\x01\n\x10ParseFileRequest\x12\x13\n\tfile_path\x18\x01 \x01(\tH\x00\x12\x11\n\x07\x63ontent\x18\x02 \x01(\tH\x00\x12\x18\n\x0c\x63ontent_hash\x18\x03 \x01(\tB\x02\x18\x01\x12\x10\n\x08language\x18\x04 \x01(\t\x12\x1a\n\x12strict_conformance\x18\x05 \x01(\x08\x42\x08\n\x06source\"b\n\x0eSourceDocument\x12\x13\n\tfile_path\x18\x01 \x01(\tH\x00\x12\x11\n\x07\x63ontent\x18\x02 \x01(\tH\x00\x12\x10\n\x08language\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\tB\x08\n\x06source\"[\n\x13ParseSourcesRequest\x12(\n\tdocuments\x18\x01 \x03(\x0b\x32\x15.sysml.SourceDocument\x12\x1a\n\x12strict_conformance\x18\x02 \x01(\x08\"\x83\x01\n\x14ParseSourcesResponse\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12 \n\x05roots\x18\x02 \x03(\x0b\x32\x11.sysml.SymbolInfo\x12&\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\r\n\x05\x65rror\x18\x04 \x01(\t\"\x7f\n\x11ParseFileResponse\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x1f\n\x04root\x18\x02 \x01(\x0b\x32\x11.sysml.SymbolInfo\x12&\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\r\n\x05\x65rror\x18\x04 \x01(\t\"9\n\x10GetSymbolRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\"B\n\x0eSymbolResponse\x12!\n\x06symbol\x18\x01 \x01(\x0b\x32\x11.sysml.SymbolInfo\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"(\n\x12\x44iagnosticsRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\"L\n\x13\x44iagnosticsResponse\x12&\n\x0b\x64iagnostics\x18\x01 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"o\n\x0f\x45valuateRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x12\n\nexpression\x18\x02 \x01(\t\x12\x19\n\x11\x63ontext_symbol_id\x18\x03 \x01(\t\x12\x19\n\x11subject_symbol_id\x18\x04 \x01(\t\"g\n\x10\x45valuateResponse\x12\x1c\n\x06result\x18\x01 \x01(\x0b\x32\x0c.sysml.Value\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\x11.sysml.Diagnostic\"\xc2\x01\n\x08Instance\x12\n\n\x02id\x18\x01 \x01(\x03\x12\x16\n\x0etype_symbol_id\x18\x02 \x01(\t\x12:\n\x0e\x66\x65\x61ture_values\x18\x04 \x03(\x0b\x32\".sysml.Instance.FeatureValuesEntry\x1aI\n\x12\x46\x65\x61tureValuesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\"\n\x05value\x18\x02 \x01(\x0b\x32\x13.sysml.FeatureValue:\x02\x38\x01J\x04\x08\x03\x10\x04R\x05slots\"\x84\x01\n\x0c\x46\x65\x61tureValue\x12\x14\n\x0c\x66\x65\x61ture_name\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value\x12\x1c\n\x06values\x18\x03 \x03(\x0b\x32\x0c.sysml.Value\x12\x14\n\x0cmaterialized\x18\x04 \x01(\x08\x12\r\n\x05\x65rror\x18\x05 \x01(\t\";\n\x12InstantiateRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\"\x93\x01\n\x13InstantiateResponse\x12!\n\x08instance\x18\x01 \x01(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\"\n\tinstances\x18\x04 \x03(\x0b\x32\x0f.sysml.Instance\"\xe9\x01\n\x14\x45xecuteActionRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x18\n\x10\x61\x63tion_symbol_id\x18\x02 \x01(\t\x12\x37\n\x06inputs\x18\x03 \x03(\x0b\x32\'.sysml.ExecuteActionRequest.InputsEntry\x12\x10\n\x08schedule\x18\x04 \x01(\t\x12\x1b\n\x13performer_symbol_id\x18\x05 \x01(\t\x1a;\n\x0bInputsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"\xad\x02\n\x15\x45xecuteActionResponse\x12:\n\x07outputs\x18\x01 \x03(\x0b\x32).sysml.ExecuteActionResponse.OutputsEntry\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\x11.sysml.Diagnostic\x12 \n\x08outcomes\x18\x04 \x03(\x0b\x32\x0e.sysml.Outcome\x12-\n\x0b\x65xploration\x18\x05 \x01(\x0b\x32\x18.sysml.ExplorationStatus\x12\x12\n\nfinal_time\x18\x06 \x01(\x01\x1a<\n\x0cOutputsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"\x89\x01\n\x13\x45xecuteStateRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x1f\n\x17state_machine_symbol_id\x18\x02 \x01(\t\x12\x0e\n\x06\x65vents\x18\x03 \x03(\t\x12\x10\n\x08schedule\x18\x04 \x01(\t\x12\x1b\n\x13performer_symbol_id\x18\x05 \x01(\t\"\xd3\x02\n\x14\x45xecuteStateResponse\x12\x16\n\x0estates_visited\x18\x01 \x03(\t\x12\x44\n\rfinal_context\x18\x02 \x03(\x0b\x32-.sysml.ExecuteStateResponse.FinalContextEntry\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x04 \x03(\x0b\x32\x11.sysml.Diagnostic\x12 \n\x08outcomes\x18\x05 \x03(\x0b\x32\x0e.sysml.Outcome\x12-\n\x0b\x65xploration\x18\x06 \x01(\x0b\x32\x18.sysml.ExplorationStatus\x12\x12\n\nfinal_time\x18\x07 \x01(\x01\x1a\x41\n\x11\x46inalContextEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"\xa0\x01\n\x0e\x43onvertRequest\x12\x13\n\tfile_path\x18\x01 \x01(\tH\x00\x12\x11\n\x07\x63ontent\x18\x02 \x01(\tH\x00\x12\x14\n\nmodel_hash\x18\x06 \x01(\tH\x00\x12\x13\n\x0b\x66rom_format\x18\x03 \x01(\t\x12\x11\n\tto_format\x18\x04 \x01(\t\x12\x1e\n\x16tolerate_syntax_errors\x18\x05 \x01(\x08\x42\x08\n\x06source\"\xb4\x01\n\x0f\x43onvertResponse\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12\x13\n\x0b\x66rom_format\x18\x02 \x01(\t\x12\x11\n\tto_format\x18\x03 \x01(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x05 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\x14\n\x0c\x65xperimental\x18\x06 \x01(\x08\x12\x1b\n\x13\x65xperimental_notice\x18\x07 \x01(\t\"}\n\x11\x41pplyEditsRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12(\n\noperations\x18\x02 \x03(\x0b\x32\x14.sysml.EditOperation\x12\x10\n\x08\x64ocument\x18\x03 \x01(\t\x12\x18\n\x10\x61\x63\x63\x65pt_documents\x18\x04 \x01(\x08\"\xdd\x01\n\rEditOperation\x12(\n\tset_value\x18\x01 \x01(\x0b\x32\x13.sysml.SetValueEditH\x00\x12#\n\x06rename\x18\x02 \x01(\x0b\x32\x11.sysml.RenameEditH\x00\x12*\n\nadd_member\x18\x03 \x01(\x0b\x32\x14.sysml.AddMemberEditH\x00\x12#\n\x06\x64\x65lete\x18\x04 \x01(\x0b\x32\x11.sysml.DeleteEditH\x00\x12\x1f\n\x04move\x18\x05 \x01(\x0b\x32\x0f.sysml.MoveEditH\x00\x42\x0b\n\toperation\"\x82\x01\n\rAddMemberEdit\x12\r\n\x05owner\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x0c\n\x04type\x18\x04 \x01(\t\x12\x14\n\x0cmultiplicity\x18\x05 \x01(\t\x12\r\n\x05value\x18\x06 \x01(\t\x12\x13\n\x0bspecializes\x18\x07 \x03(\t\"-\n\nDeleteEdit\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\x0f\n\x07\x63\x61scade\x18\x02 \x01(\x08\")\n\x08MoveEdit\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\r\n\x05owner\x18\x02 \x01(\t\"-\n\x0cSetValueEdit\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\".\n\nRenameEdit\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\x10\n\x08new_name\x18\x02 \x01(\t\"\x90\x02\n\x12\x41pplyEditsResponse\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12#\n\x07\x61pplied\x18\x02 \x03(\x0b\x32\x12.sysml.AppliedEdit\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12#\n\x07\x66\x61ilure\x18\x04 \x01(\x0e\x32\x12.sysml.EditFailure\x12&\n\x0b\x64iagnostics\x18\x05 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\x1a\n\x12referring_elements\x18\x06 \x03(\t\x12(\n\tdocuments\x18\x07 \x03(\x0b\x32\x15.sysml.EditedDocument\x12\"\n\treferrers\x18\x08 \x03(\x0b\x32\x0f.sysml.Referrer\"/\n\x0e\x45\x64itedDocument\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\t\"*\n\x08Referrer\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08\x64ocument\x18\x02 \x01(\t\"\x8c\x01\n\x0b\x41ppliedEdit\x12\x17\n\x0foperation_index\x18\x01 \x01(\x05\x12\x0e\n\x06target\x18\x02 \x01(\t\x12\x0e\n\x06offset\x18\x03 \x01(\x05\x12\x0e\n\x06length\x18\x04 \x01(\x05\x12\x10\n\x08old_text\x18\x05 \x01(\t\x12\x10\n\x08new_text\x18\x06 \x01(\t\x12\x10\n\x08\x64ocument\x18\x07 \x01(\t\"\xfd\x02\n\nSymbolInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04kind\x18\x03 \x01(\t\x12\x31\n\x08metadata\x18\x04 \x03(\x0b\x32\x1f.sysml.SymbolInfo.MetadataEntry\x12\x11\n\tchild_ids\x18\x05 \x03(\t\x12(\n\nattributes\x18\x06 \x03(\x0b\x32\x14.sysml.AttributeInfo\x12\"\n\ttype_info\x18\x07 \x01(\x0b\x32\x0f.sysml.TypeInfo\x12-\n\x0cmultiplicity\x18\x08 \x01(\x0b\x32\x17.sysml.MultiplicityInfo\x12.\n\x0fspecializations\x18\t \x03(\x0b\x32\x15.sysml.Specialization\x12#\n\x1bwithheld_library_attributes\x18\n \x01(\x05\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"X\n\x0eSpecialization\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12\x10\n\x08\x64\x65\x63lared\x18\x02 \x01(\t\x12\x11\n\ttarget_id\x18\x03 \x01(\t\x12\x13\n\x0btarget_kind\x18\x04 \x01(\t\"\x95\x01\n\x08TypeInfo\x12\x10\n\x08\x64\x65\x63lared\x18\x01 \x01(\t\x12\x13\n\x0bresolved_id\x18\x02 \x01(\t\x12\x15\n\rresolved_kind\x18\x03 \x01(\t\x12\x11\n\tprimitive\x18\x04 \x01(\t\x12\x18\n\x10primitive_source\x18\x05 \x01(\t\x12\x10\n\x08quantity\x18\x06 \x01(\x08\x12\x0c\n\x04unit\x18\x07 \x01(\t\"0\n\x10MultiplicityInfo\x12\r\n\x05lower\x18\x01 \x01(\t\x12\r\n\x05upper\x18\x02 \x01(\t\"V\n\rAttributeInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x1b\n\x05value\x18\x03 \x01(\x0b\x32\x0c.sysml.Value\x12\x0c\n\x04unit\x18\x04 \x01(\t\"\xc3\x05\n\x05Value\x12\x13\n\tint_value\x18\x01 \x01(\x03H\x00\x12\x14\n\nreal_value\x18\x02 \x01(\x01H\x00\x12\x14\n\nbool_value\x18\x03 \x01(\x08H\x00\x12\x16\n\x0cstring_value\x18\x04 \x01(\tH\x00\x12\x15\n\x0binstance_id\x18\x05 \x01(\x03H\x00\x12(\n\x08sequence\x18\x06 \x01(\x0b\x32\x14.sysml.ValueSequenceH\x00\x12\x0e\n\x04null\x18\x07 \x01(\tH\x00\x12#\n\x08quantity\x18\x08 \x01(\x0b\x32\x0f.sysml.QuantityH\x00\x12*\n\x0c\x65num_literal\x18\t \x01(\x0b\x32\x12.sysml.EnumLiteralH\x00\x12\x0f\n\x05unset\x18\n \x01(\x08H\x00\x12!\n\x07\x63omplex\x18\x0b \x01(\x0b\x32\x0e.sysml.ComplexH\x00\x12\x1d\n\x05\x61rray\x18\x0c \x01(\x0b\x32\x0c.sysml.ArrayH\x00\x12\x1f\n\x06vector\x18\r \x01(\x0b\x32\r.sysml.VectorH\x00\x12\x30\n\x0fvector_quantity\x18\x0e \x01(\x0b\x32\x15.sysml.VectorQuantityH\x00\x12\x30\n\x0fmeasurement_ref\x18\x0f \x01(\x0b\x32\x15.sysml.MeasurementRefH\x00\x12\x12\n\x08infinity\x18\x10 \x01(\x08H\x00\x12#\n\x08\x66unction\x18\x11 \x01(\x0b\x32\x0f.sysml.FunctionH\x00\x12\x1e\n\x03set\x18\x12 \x01(\x0b\x32\x0f.sysml.ValueSetH\x00\x12\x30\n\x0ftensor_quantity\x18\x13 \x01(\x0b\x32\x15.sysml.TensorQuantityH\x00\x12\'\n\nmetaobject\x18\x14 \x01(\x0b\x32\x11.sysml.MetaobjectH\x00\x12+\n\x0cundetermined\x18\x15 \x01(\x0b\x32\x13.sysml.UndeterminedH\x00\x42\x06\n\x04kind\"6\n\nMetaobject\x12\x12\n\nelement_id\x18\x01 \x01(\t\x12\x14\n\x0cmetaclass_id\x18\x02 \x01(\t\"F\n\x0cUndetermined\x12\x0e\n\x06reason\x18\x01 \x01(\t\x12&\n\x05\x63ount\x18\x02 \x01(\x0b\x32\x17.sysml.MultiplicityInfo\",\n\x08\x46unction\x12\x0f\n\x07\x63\x61lc_id\x18\x01 \x01(\t\x12\x0f\n\x07self_id\x18\x02 \x01(\x03\"*\n\x08ValueSet\x12\x1e\n\x08\x65lements\x18\x01 \x03(\x0b\x32\x0c.sysml.Value\"I\n\x0eTensorQuantity\x12\x12\n\ndimensions\x18\x01 \x03(\x03\x12#\n\ncomponents\x18\x02 \x03(\x0b\x32\x0f.sysml.Quantity\";\n\x05\x41rray\x12\x12\n\ndimensions\x18\x01 \x03(\x03\x12\x1e\n\x08\x65lements\x18\x02 \x03(\x0b\x32\x0c.sysml.Value\"*\n\x06Vector\x12 \n\ncomponents\x18\x01 \x03(\x0b\x32\x0c.sysml.Value\"5\n\x0eVectorQuantity\x12#\n\ncomponents\x18\x01 \x03(\x0b\x32\x0f.sysml.Quantity\"*\n\x07\x43omplex\x12\x0c\n\x04real\x18\x01 \x01(\x01\x12\x11\n\timaginary\x18\x02 \x01(\x01\"d\n\x0b\x45numLiteral\x12\x12\n\nliteral_id\x18\x01 \x01(\t\x12\x16\n\x0e\x65numeration_id\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x1b\n\x05value\x18\x04 \x01(\x0b\x32\x0c.sysml.Value\"/\n\rValueSequence\x12\x1e\n\x08\x65lements\x18\x01 \x03(\x0b\x32\x0c.sysml.Value\"|\n\x08Quantity\x12\x17\n\rint_magnitude\x18\x01 \x01(\x03H\x00\x12\x18\n\x0ereal_magnitude\x18\x02 \x01(\x01H\x00\x12\x0c\n\x04unit\x18\x03 \x01(\t\x12\"\n\tunit_term\x18\x04 \x01(\x0b\x32\x0f.sysml.UnitTermB\x0b\n\tmagnitude\"S\n\x0eMeasurementRef\x12\x0c\n\x04unit\x18\x01 \x01(\t\x12\"\n\tunit_term\x18\x02 \x01(\x0b\x32\x0f.sysml.UnitTerm\x12\x0f\n\x07unit_id\x18\x03 \x01(\t\"T\n\x08UnitTerm\x12\x11\n\tscale_num\x18\x01 \x01(\x01\x12\x11\n\tscale_den\x18\x02 \x01(\x01\x12\"\n\x07\x66\x61\x63tors\x18\x03 \x03(\x0b\x32\x11.sysml.UnitFactor\"/\n\nUnitFactor\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x12\x10\n\x08\x65xponent\x18\x02 \x01(\x01\"X\n\nDiagnostic\x12\x10\n\x08severity\x18\x01 \x01(\t\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x19\n\x04span\x18\x03 \x01(\x0b\x32\x0b.sysml.Span\x12\x0c\n\x04\x63ode\x18\x04 \x01(\t\"^\n\x04Span\x12\x0c\n\x04\x66ile\x18\x01 \x01(\t\x12\x12\n\nstart_line\x18\x02 \x01(\x05\x12\x11\n\tstart_col\x18\x03 \x01(\x05\x12\x10\n\x08\x65nd_line\x18\x04 \x01(\x05\x12\x0f\n\x07\x65nd_col\x18\x05 \x01(\x05\"\x13\n\x11ServerInfoRequest\";\n\x12ServerInfoResponse\x12\x0f\n\x07version\x18\x01 \x01(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x02 \x03(\t\"S\n\x0cQueryRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x1b\n\x05query\x18\x02 \x01(\x0b\x32\x0c.sysml.Query\x12\x12\n\noslc_query\x18\x03 \x01(\t\"<\n\rQueryResponse\x12+\n\x08\x65lements\x18\x01 \x03(\x0b\x32\x19.sysml.QueryResultElement\"H\n\x05Query\x12\r\n\x05scope\x18\x01 \x03(\t\x12\x0e\n\x06select\x18\x02 \x03(\t\x12 \n\x05where\x18\x03 \x01(\x0b\x32\x11.sysml.Constraint\"|\n\nConstraint\x12/\n\tprimitive\x18\x01 \x01(\x0b\x32\x1a.sysml.PrimitiveConstraintH\x00\x12/\n\tcomposite\x18\x02 \x01(\x0b\x32\x1a.sysml.CompositeConstraintH\x00\x42\x0c\n\nconstraint\"s\n\x13PrimitiveConstraint\x12\x0f\n\x07inverse\x18\x01 \x01(\x08\x12\x10\n\x08property\x18\x02 \x01(\t\x12*\n\x08operator\x18\x03 \x01(\x0e\x32\x18.sysml.PrimitiveOperator\x12\r\n\x05value\x18\x04 \x03(\t\"h\n\x13\x43ompositeConstraint\x12*\n\x08operator\x18\x01 \x01(\x0e\x32\x18.sysml.CompositeOperator\x12%\n\nconstraint\x18\x02 \x03(\x0b\x32\x11.sysml.Constraint\"\xa0\x01\n\x12QueryResultElement\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12=\n\nproperties\x18\x03 \x03(\x0b\x32).sysml.QueryResultElement.PropertiesEntry\x1a\x31\n\x0fPropertiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"s\n\nSweepRange\x12\x11\n\tparameter\x18\x01 \x01(\t\x12\x1b\n\x05start\x18\x02 \x01(\x0b\x32\x0c.sysml.Value\x12\x19\n\x03\x65nd\x18\x03 \x01(\x0b\x32\x0c.sysml.Value\x12\x1a\n\x04step\x18\x04 \x01(\x0b\x32\x0c.sysml.Value\"\xd0\x02\n\x0fRunSweepRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x19\n\x11subject_symbol_id\x18\x03 \x01(\t\x12\x1f\n\targuments\x18\x04 \x03(\x0b\x32\x0c.sysml.Value\x12\x43\n\x0fnamed_arguments\x18\x05 \x03(\x0b\x32*.sysml.RunSweepRequest.NamedArgumentsEntry\x12!\n\x06ranges\x18\x06 \x03(\x0b\x32\x11.sysml.SweepRange\x12\x0f\n\x07samples\x18\x07 \x01(\x03\x12\x0c\n\x04seed\x18\x08 \x01(\x04\x12\x0e\n\x06\x65ngine\x18\t \x01(\t\x1a\x43\n\x13NamedArgumentsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"\xf4\x01\n\x08SweepRow\x12!\n\x06inputs\x18\x01 \x03(\x0b\x32\x11.sysml.CalcOutput\x12\"\n\x07outputs\x18\x02 \x03(\x0b\x32\x11.sysml.CalcOutput\x12 \n\x08verdicts\x18\x03 \x03(\x0b\x32\x0e.sysml.Verdict\x12\x16\n\x0e\x65lapsed_micros\x18\x04 \x01(\x03\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12,\n\x0e\x66\x61ilure_reason\x18\x06 \x01(\x0e\x32\x14.sysml.FailureReason\x12*\n\x0b\x65valuations\x18\x07 \x03(\x0b\x32\x15.sysml.CaseEvaluation\"\xad\x02\n\x10RunSweepResponse\x12\x1d\n\x04rows\x18\x01 \x03(\x0b\x32\x0f.sysml.SweepRow\x12\x12\n\nparameters\x18\x02 \x03(\t\x12\x0f\n\x07sampled\x18\x03 \x01(\x08\x12\x0c\n\x04seed\x18\x04 \x01(\x04\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x06 \x03(\x0b\x32\x11.sysml.Diagnostic\x12,\n\x0e\x66\x61ilure_reason\x18\x07 \x01(\x0e\x32\x14.sysml.FailureReason\x12\"\n\tinstances\x18\x08 \x03(\x0b\x32\x0f.sysml.Instance\x12\x0e\n\x06\x65ngine\x18\t \x01(\t\x12\x10\n\x08strength\x18\n \x01(\t\x12\x1c\n\x06\x62ounds\x18\x0b \x03(\x0b\x32\x0c.sysml.Bound\"n\n\x17RunDocumentQueryRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x10\n\x08query_id\x18\x02 \x01(\t\x12-\n\x08\x62indings\x18\x03 \x03(\x0b\x32\x1b.sysml.DocumentQueryBinding\"O\n\x14\x44ocumentQueryBinding\x12\x11\n\tparameter\x18\x01 \x01(\t\x12$\n\x06values\x18\x02 \x03(\x0b\x32\x14.sysml.DocumentValue\"\xf7\x02\n\rDocumentValue\x12\x14\n\nelement_id\x18\x01 \x01(\tH\x00\x12\x16\n\x0cstring_value\x18\x02 \x01(\tH\x00\x12\x13\n\tint_value\x18\x03 \x01(\x03H\x00\x12\x14\n\nreal_value\x18\x04 \x01(\x01H\x00\x12\x14\n\nbool_value\x18\x05 \x01(\x08H\x00\x12\x12\n\x08infinity\x18\x06 \x01(\x08H\x00\x12#\n\x08quantity\x18\x08 \x01(\x0b\x32\x0f.sysml.QuantityH\x00\x12)\n\x07verdict\x18\t \x01(\x0b\x32\x16.sysml.DocumentVerdictH\x00\x12\'\n\x06object\x18\n \x01(\x0b\x32\x15.sysml.DocumentObjectH\x00\x12%\n\x05state\x18\x0b \x01(\x0b\x32\x14.sysml.DocumentStateH\x00\x12%\n\x05\x65vent\x18\x0c \x01(\x0b\x32\x14.sysml.DocumentEventH\x00\x12\x14\n\x0c\x65lement_type\x18\x07 \x01(\tB\x06\n\x04kind\"Z\n\x0e\x44ocumentObject\x12\x13\n\x0binstance_id\x18\x01 \x01(\x03\x12\x0c\n\x04path\x18\x02 \x01(\t\x12%\n\x07\x65lement\x18\x03 \x01(\x0b\x32\x14.sysml.DocumentValue\"\xae\x01\n\x0f\x44ocumentVerdict\x12\'\n\tassertion\x18\x01 \x01(\x0b\x32\x14.sysml.DocumentValue\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x0c\n\x04text\x18\x03 \x01(\t\x12\x0c\n\x04path\x18\x04 \x01(\t\x12\x0f\n\x07verdict\x18\x05 \x01(\t\x12\x11\n\tcondition\x18\x06 \x01(\t\x12\x0e\n\x06reason\x18\x07 \x01(\t\x12\x14\n\x0cverification\x18\x08 \x03(\t\"\xb1\x01\n\rDocumentState\x12%\n\x06object\x18\x01 \x01(\x0b\x32\x15.sysml.DocumentObject\x12\x0f\n\x07machine\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x12\n\nstate_path\x18\x04 \x01(\t\x12#\n\x05state\x18\x05 \x01(\x0b\x32\x14.sysml.DocumentValue\x12\x0e\n\x06region\x18\x06 \x01(\t\x12\x11\n\tenclosing\x18\x07 \x03(\t\"\x9c\x02\n\rDocumentEvent\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12\"\n\x04time\x18\x02 \x01(\x0b\x32\x14.sysml.DocumentValue\x12%\n\x06object\x18\x03 \x01(\x0b\x32\x15.sysml.DocumentObject\x12\x0f\n\x07machine\x18\x04 \x01(\t\x12\r\n\x05state\x18\x05 \x01(\t\x12\x0c\n\x04\x66rom\x18\x06 \x01(\t\x12\n\n\x02to\x18\x07 \x01(\t\x12%\n\x06target\x18\x08 \x01(\x0b\x32\x15.sysml.DocumentObject\x12\r\n\x05\x65vent\x18\t \x01(\t\x12\x0f\n\x07payload\x18\n \x03(\t\x12\x14\n\x0c\x61lternatives\x18\x0b \x03(\t\x12\r\n\x05taken\x18\x0c \x01(\t\x12\x0c\n\x04text\x18\r \x01(\t\"#\n\x13\x44ocumentQueryColumn\x12\x0c\n\x04name\x18\x01 \x01(\t\"9\n\x11\x44ocumentQueryCell\x12$\n\x06values\x18\x01 \x03(\x0b\x32\x14.sysml.DocumentValue\"b\n\x10\x44ocumentQueryRow\x12%\n\x07\x65lement\x18\x01 \x01(\x0b\x32\x14.sysml.DocumentValue\x12\'\n\x05\x63\x65lls\x18\x02 \x03(\x0b\x32\x18.sysml.DocumentQueryCell\"n\n\x18RunDocumentQueryResponse\x12+\n\x07\x63olumns\x18\x01 \x03(\x0b\x32\x1a.sysml.DocumentQueryColumn\x12%\n\x04rows\x18\x02 \x03(\x0b\x32\x17.sysml.DocumentQueryRow\"N\n\x15RenderDocumentRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x02 \x01(\t\x12\x0c\n\x04\x66orm\x18\x03 \x01(\t\"8\n\x16RenderDocumentResponse\x12\x10\n\x08markdown\x18\x01 \x01(\t\x12\x0c\n\x04html\x18\x02 \x01(\t*\x93\x01\n\rFailureReason\x12\x1e\n\x1a\x46\x41ILURE_REASON_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x46\x41ILURE_REASON_EVALUATION\x10\x01\x12\x1d\n\x19\x46\x41ILURE_REASON_WRONG_KIND\x10\x02\x12$\n FAILURE_REASON_AMBIGUOUS_SUBJECT\x10\x03*\x8c\x05\n\x0b\x45\x64itFailure\x12\x1c\n\x18\x45\x44IT_FAILURE_UNSPECIFIED\x10\x00\x12\x1e\n\x1a\x45\x44IT_FAILURE_NO_OPERATIONS\x10\x01\x12\x1f\n\x1b\x45\x44IT_FAILURE_UNKNOWN_TARGET\x10\x02\x12!\n\x1d\x45\x44IT_FAILURE_AMBIGUOUS_TARGET\x10\x03\x12\x1b\n\x17\x45\x44IT_FAILURE_NOT_VALUED\x10\x04\x12\x1e\n\x1a\x45\x44IT_FAILURE_INVALID_VALUE\x10\x05\x12\x1d\n\x19\x45\x44IT_FAILURE_INVALID_NAME\x10\x06\x12\x1a\n\x16\x45\x44IT_FAILURE_NOT_NAMED\x10\x07\x12\"\n\x1e\x45\x44IT_FAILURE_RENAME_REFERENCED\x10\x08\x12\"\n\x1e\x45\x44IT_FAILURE_OVERLAPPING_EDITS\x10\t\x12\x1f\n\x1b\x45\x44IT_FAILURE_RESULT_INVALID\x10\n\x12\x1e\n\x1a\x45\x44IT_FAILURE_OWNER_UNKNOWN\x10\x0b\x12$\n EDIT_FAILURE_OWNER_NOT_NAMESPACE\x10\x0c\x12\x1d\n\x19\x45\x44IT_FAILURE_ILLEGAL_KIND\x10\r\x12\"\n\x1e\x45\x44IT_FAILURE_MEMBER_NAME_TAKEN\x10\x0e\x12\"\n\x1e\x45\x44IT_FAILURE_DELETE_REFERENCED\x10\x0f\x12$\n EDIT_FAILURE_OWNER_INSIDE_TARGET\x10\x10\x12 \n\x1c\x45\x44IT_FAILURE_MOVE_REFERENCED\x10\x11\x12%\n!EDIT_FAILURE_REFERENCED_ELSEWHERE\x10\x12*\x92\x01\n\x11PrimitiveOperator\x12\"\n\x1ePRIMITIVE_OPERATOR_UNSPECIFIED\x10\x00\x12\x1c\n\x18PRIMITIVE_OPERATOR_EQUAL\x10\x01\x12\x1e\n\x1aPRIMITIVE_OPERATOR_GREATER\x10\x02\x12\x1b\n\x17PRIMITIVE_OPERATOR_LESS\x10\x03*n\n\x11\x43ompositeOperator\x12\"\n\x1e\x43OMPOSITE_OPERATOR_UNSPECIFIED\x10\x00\x12\x1a\n\x16\x43OMPOSITE_OPERATOR_AND\x10\x01\x12\x19\n\x15\x43OMPOSITE_OPERATOR_OR\x10\x02\x32\xbf\x0c\n\x0cSysMLService\x12\x44\n\rGetServerInfo\x12\x18.sysml.ServerInfoRequest\x1a\x19.sysml.ServerInfoResponse\x12>\n\tParseFile\x12\x17.sysml.ParseFileRequest\x1a\x18.sysml.ParseFileResponse\x12G\n\x0cParseSources\x12\x1a.sysml.ParseSourcesRequest\x1a\x1b.sysml.ParseSourcesResponse\x12;\n\tGetSymbol\x12\x17.sysml.GetSymbolRequest\x1a\x15.sysml.SymbolResponse\x12G\n\x0eGetDiagnostics\x12\x19.sysml.DiagnosticsRequest\x1a\x1a.sysml.DiagnosticsResponse\x12;\n\x08\x45valuate\x12\x16.sysml.EvaluateRequest\x1a\x17.sysml.EvaluateResponse\x12\x44\n\x0bInstantiate\x12\x19.sysml.InstantiateRequest\x1a\x1a.sysml.InstantiateResponse\x12J\n\rExecuteAction\x12\x1b.sysml.ExecuteActionRequest\x1a\x1c.sysml.ExecuteActionResponse\x12G\n\x0c\x45xecuteState\x12\x1a.sysml.ExecuteStateRequest\x1a\x1b.sysml.ExecuteStateResponse\x12\x38\n\x07\x43onvert\x12\x15.sysml.ConvertRequest\x1a\x16.sysml.ConvertResponse\x12\x41\n\nApplyEdits\x12\x18.sysml.ApplyEditsRequest\x1a\x19.sysml.ApplyEditsResponse\x12S\n\x10VerifyConstraint\x12\x1e.sysml.VerifyConstraintRequest\x1a\x1f.sysml.VerifyConstraintResponse\x12V\n\x11VerifyRequirement\x12\x1f.sysml.VerifyRequirementRequest\x1a .sysml.VerifyRequirementResponse\x12Y\n\x12VerifySatisfaction\x12 .sysml.VerifySatisfactionRequest\x1a!.sysml.VerifySatisfactionResponse\x12S\n\x10ValidateInstance\x12\x1e.sysml.ValidateInstanceRequest\x1a\x1f.sysml.ValidateInstanceResponse\x12G\n\x0c\x45valuateCalc\x12\x1a.sysml.EvaluateCalcRequest\x1a\x1b.sysml.EvaluateCalcResponse\x12\x44\n\x0bRunAnalysis\x12\x19.sysml.RunAnalysisRequest\x1a\x1a.sysml.RunAnalysisResponse\x12;\n\x08RunSweep\x12\x16.sysml.RunSweepRequest\x1a\x17.sysml.RunSweepResponse\x12\x44\n\x0bListEngines\x12\x19.sysml.ListEnginesRequest\x1a\x1a.sysml.ListEnginesResponse\x12\x32\n\x05Query\x12\x13.sysml.QueryRequest\x1a\x14.sysml.QueryResponse\x12S\n\x10RunDocumentQuery\x12\x1e.sysml.RunDocumentQueryRequest\x1a\x1f.sysml.RunDocumentQueryResponse\x12M\n\x0eRenderDocument\x12\x1c.sysml.RenderDocumentRequest\x1a\x1d.sysml.RenderDocumentResponseB*Z(github.com/Open-MBEE/OpenSysML/api/protob\x06proto3')
+
+_globals = globals()
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'sysml_pb2', _globals)
+if not _descriptor._USE_C_DESCRIPTORS:
+  _globals['DESCRIPTOR']._loaded_options = None
+  _globals['DESCRIPTOR']._serialized_options = b'Z(github.com/Open-MBEE/OpenSysML/api/proto'
+  _globals['_RUNANALYSISREQUEST_NAMEDARGUMENTSENTRY']._loaded_options = None
+  _globals['_RUNANALYSISREQUEST_NAMEDARGUMENTSENTRY']._serialized_options = b'8\001'
+  _globals['_OUTCOME_OUTPUTSENTRY']._loaded_options = None
+  _globals['_OUTCOME_OUTPUTSENTRY']._serialized_options = b'8\001'
+  _globals['_PARSEFILEREQUEST'].fields_by_name['content_hash']._loaded_options = None
+  _globals['_PARSEFILEREQUEST'].fields_by_name['content_hash']._serialized_options = b'\030\001'
+  _globals['_INSTANCE_FEATUREVALUESENTRY']._loaded_options = None
+  _globals['_INSTANCE_FEATUREVALUESENTRY']._serialized_options = b'8\001'
+  _globals['_EXECUTEACTIONREQUEST_INPUTSENTRY']._loaded_options = None
+  _globals['_EXECUTEACTIONREQUEST_INPUTSENTRY']._serialized_options = b'8\001'
+  _globals['_EXECUTEACTIONRESPONSE_OUTPUTSENTRY']._loaded_options = None
+  _globals['_EXECUTEACTIONRESPONSE_OUTPUTSENTRY']._serialized_options = b'8\001'
+  _globals['_EXECUTESTATERESPONSE_FINALCONTEXTENTRY']._loaded_options = None
+  _globals['_EXECUTESTATERESPONSE_FINALCONTEXTENTRY']._serialized_options = b'8\001'
+  _globals['_SYMBOLINFO_METADATAENTRY']._loaded_options = None
+  _globals['_SYMBOLINFO_METADATAENTRY']._serialized_options = b'8\001'
+  _globals['_QUERYRESULTELEMENT_PROPERTIESENTRY']._loaded_options = None
+  _globals['_QUERYRESULTELEMENT_PROPERTIESENTRY']._serialized_options = b'8\001'
+  _globals['_RUNSWEEPREQUEST_NAMEDARGUMENTSENTRY']._loaded_options = None
+  _globals['_RUNSWEEPREQUEST_NAMEDARGUMENTSENTRY']._serialized_options = b'8\001'
+  _globals['_FAILUREREASON']._serialized_start=14331
+  _globals['_FAILUREREASON']._serialized_end=14478
+  _globals['_EDITFAILURE']._serialized_start=14481
+  _globals['_EDITFAILURE']._serialized_end=15133
+  _globals['_PRIMITIVEOPERATOR']._serialized_start=15136
+  _globals['_PRIMITIVEOPERATOR']._serialized_end=15282
+  _globals['_COMPOSITEOPERATOR']._serialized_start=15284
+  _globals['_COMPOSITEOPERATOR']._serialized_end=15394
+  _globals['_VERDICT']._serialized_start=23
+  _globals['_VERDICT']._serialized_end=336
+  _globals['_BOUND']._serialized_start=338
+  _globals['_BOUND']._serialized_end=391
+  _globals['_VERIFYCONSTRAINTREQUEST']._serialized_start=393
+  _globals['_VERIFYCONSTRAINTREQUEST']._serialized_end=500
+  _globals['_VERIFYCONSTRAINTRESPONSE']._serialized_start=503
+  _globals['_VERIFYCONSTRAINTRESPONSE']._serialized_end=653
+  _globals['_VERIFYREQUIREMENTREQUEST']._serialized_start=655
+  _globals['_VERIFYREQUIREMENTREQUEST']._serialized_end=763
+  _globals['_VERIFICATIONVERDICT']._serialized_start=765
+  _globals['_VERIFICATIONVERDICT']._serialized_end=874
+  _globals['_VERIFYREQUIREMENTRESPONSE']._serialized_start=877
+  _globals['_VERIFYREQUIREMENTRESPONSE']._serialized_end=1087
+  _globals['_VERIFYSATISFACTIONREQUEST']._serialized_start=1089
+  _globals['_VERIFYSATISFACTIONREQUEST']._serialized_end=1171
+  _globals['_VERIFYSATISFACTIONRESPONSE']._serialized_start=1174
+  _globals['_VERIFYSATISFACTIONRESPONSE']._serialized_end=1432
+  _globals['_VALIDATEINSTANCEREQUEST']._serialized_start=1434
+  _globals['_VALIDATEINSTANCEREQUEST']._serialized_end=1514
+  _globals['_VALIDATEINSTANCERESPONSE']._serialized_start=1517
+  _globals['_VALIDATEINSTANCERESPONSE']._serialized_end=1823
+  _globals['_EVALUATECALCREQUEST']._serialized_start=1825
+  _globals['_EVALUATECALCREQUEST']._serialized_end=1934
+  _globals['_EVALUATECALCRESPONSE']._serialized_start=1937
+  _globals['_EVALUATECALCRESPONSE']._serialized_end=2190
+  _globals['_CALCOUTPUT']._serialized_start=2192
+  _globals['_CALCOUTPUT']._serialized_end=2247
+  _globals['_CASEEVALUATION']._serialized_start=2250
+  _globals['_CASEEVALUATION']._serialized_end=2397
+  _globals['_RUNANALYSISREQUEST']._serialized_start=2400
+  _globals['_RUNANALYSISREQUEST']._serialized_end=2694
+  _globals['_RUNANALYSISREQUEST_NAMEDARGUMENTSENTRY']._serialized_start=2627
+  _globals['_RUNANALYSISREQUEST_NAMEDARGUMENTSENTRY']._serialized_end=2694
+  _globals['_RUNANALYSISRESPONSE']._serialized_start=2697
+  _globals['_RUNANALYSISRESPONSE']._serialized_end=3173
+  _globals['_OUTCOME']._serialized_start=3176
+  _globals['_OUTCOME']._serialized_end=3455
+  _globals['_OUTCOME_OUTPUTSENTRY']._serialized_start=3395
+  _globals['_OUTCOME_OUTPUTSENTRY']._serialized_end=3455
+  _globals['_EXPLORATIONSTATUS']._serialized_start=3458
+  _globals['_EXPLORATIONSTATUS']._serialized_end=3608
+  _globals['_LISTENGINESREQUEST']._serialized_start=3610
+  _globals['_LISTENGINESREQUEST']._serialized_end=3630
+  _globals['_ENGINEINFO']._serialized_start=3633
+  _globals['_ENGINEINFO']._serialized_end=3885
+  _globals['_LISTENGINESRESPONSE']._serialized_start=3887
+  _globals['_LISTENGINESRESPONSE']._serialized_end=3944
+  _globals['_PARSEFILEREQUEST']._serialized_start=3947
+  _globals['_PARSEFILEREQUEST']._serialized_end=4087
+  _globals['_SOURCEDOCUMENT']._serialized_start=4089
+  _globals['_SOURCEDOCUMENT']._serialized_end=4187
+  _globals['_PARSESOURCESREQUEST']._serialized_start=4189
+  _globals['_PARSESOURCESREQUEST']._serialized_end=4280
+  _globals['_PARSESOURCESRESPONSE']._serialized_start=4283
+  _globals['_PARSESOURCESRESPONSE']._serialized_end=4414
+  _globals['_PARSEFILERESPONSE']._serialized_start=4416
+  _globals['_PARSEFILERESPONSE']._serialized_end=4543
+  _globals['_GETSYMBOLREQUEST']._serialized_start=4545
+  _globals['_GETSYMBOLREQUEST']._serialized_end=4602
+  _globals['_SYMBOLRESPONSE']._serialized_start=4604
+  _globals['_SYMBOLRESPONSE']._serialized_end=4670
+  _globals['_DIAGNOSTICSREQUEST']._serialized_start=4672
+  _globals['_DIAGNOSTICSREQUEST']._serialized_end=4712
+  _globals['_DIAGNOSTICSRESPONSE']._serialized_start=4714
+  _globals['_DIAGNOSTICSRESPONSE']._serialized_end=4790
+  _globals['_EVALUATEREQUEST']._serialized_start=4792
+  _globals['_EVALUATEREQUEST']._serialized_end=4903
+  _globals['_EVALUATERESPONSE']._serialized_start=4905
+  _globals['_EVALUATERESPONSE']._serialized_end=5008
+  _globals['_INSTANCE']._serialized_start=5011
+  _globals['_INSTANCE']._serialized_end=5205
+  _globals['_INSTANCE_FEATUREVALUESENTRY']._serialized_start=5119
+  _globals['_INSTANCE_FEATUREVALUESENTRY']._serialized_end=5192
+  _globals['_FEATUREVALUE']._serialized_start=5208
+  _globals['_FEATUREVALUE']._serialized_end=5340
+  _globals['_INSTANTIATEREQUEST']._serialized_start=5342
+  _globals['_INSTANTIATEREQUEST']._serialized_end=5401
+  _globals['_INSTANTIATERESPONSE']._serialized_start=5404
+  _globals['_INSTANTIATERESPONSE']._serialized_end=5551
+  _globals['_EXECUTEACTIONREQUEST']._serialized_start=5554
+  _globals['_EXECUTEACTIONREQUEST']._serialized_end=5787
+  _globals['_EXECUTEACTIONREQUEST_INPUTSENTRY']._serialized_start=5728
+  _globals['_EXECUTEACTIONREQUEST_INPUTSENTRY']._serialized_end=5787
+  _globals['_EXECUTEACTIONRESPONSE']._serialized_start=5790
+  _globals['_EXECUTEACTIONRESPONSE']._serialized_end=6091
+  _globals['_EXECUTEACTIONRESPONSE_OUTPUTSENTRY']._serialized_start=3395
+  _globals['_EXECUTEACTIONRESPONSE_OUTPUTSENTRY']._serialized_end=3455
+  _globals['_EXECUTESTATEREQUEST']._serialized_start=6094
+  _globals['_EXECUTESTATEREQUEST']._serialized_end=6231
+  _globals['_EXECUTESTATERESPONSE']._serialized_start=6234
+  _globals['_EXECUTESTATERESPONSE']._serialized_end=6573
+  _globals['_EXECUTESTATERESPONSE_FINALCONTEXTENTRY']._serialized_start=6508
+  _globals['_EXECUTESTATERESPONSE_FINALCONTEXTENTRY']._serialized_end=6573
+  _globals['_CONVERTREQUEST']._serialized_start=6576
+  _globals['_CONVERTREQUEST']._serialized_end=6736
+  _globals['_CONVERTRESPONSE']._serialized_start=6739
+  _globals['_CONVERTRESPONSE']._serialized_end=6919
+  _globals['_APPLYEDITSREQUEST']._serialized_start=6921
+  _globals['_APPLYEDITSREQUEST']._serialized_end=7046
+  _globals['_EDITOPERATION']._serialized_start=7049
+  _globals['_EDITOPERATION']._serialized_end=7270
+  _globals['_ADDMEMBEREDIT']._serialized_start=7273
+  _globals['_ADDMEMBEREDIT']._serialized_end=7403
+  _globals['_DELETEEDIT']._serialized_start=7405
+  _globals['_DELETEEDIT']._serialized_end=7450
+  _globals['_MOVEEDIT']._serialized_start=7452
+  _globals['_MOVEEDIT']._serialized_end=7493
+  _globals['_SETVALUEEDIT']._serialized_start=7495
+  _globals['_SETVALUEEDIT']._serialized_end=7540
+  _globals['_RENAMEEDIT']._serialized_start=7542
+  _globals['_RENAMEEDIT']._serialized_end=7588
+  _globals['_APPLYEDITSRESPONSE']._serialized_start=7591
+  _globals['_APPLYEDITSRESPONSE']._serialized_end=7863
+  _globals['_EDITEDDOCUMENT']._serialized_start=7865
+  _globals['_EDITEDDOCUMENT']._serialized_end=7912
+  _globals['_REFERRER']._serialized_start=7914
+  _globals['_REFERRER']._serialized_end=7956
+  _globals['_APPLIEDEDIT']._serialized_start=7959
+  _globals['_APPLIEDEDIT']._serialized_end=8099
+  _globals['_SYMBOLINFO']._serialized_start=8102
+  _globals['_SYMBOLINFO']._serialized_end=8483
+  _globals['_SYMBOLINFO_METADATAENTRY']._serialized_start=8436
+  _globals['_SYMBOLINFO_METADATAENTRY']._serialized_end=8483
+  _globals['_SPECIALIZATION']._serialized_start=8485
+  _globals['_SPECIALIZATION']._serialized_end=8573
+  _globals['_TYPEINFO']._serialized_start=8576
+  _globals['_TYPEINFO']._serialized_end=8725
+  _globals['_MULTIPLICITYINFO']._serialized_start=8727
+  _globals['_MULTIPLICITYINFO']._serialized_end=8775
+  _globals['_ATTRIBUTEINFO']._serialized_start=8777
+  _globals['_ATTRIBUTEINFO']._serialized_end=8863
+  _globals['_VALUE']._serialized_start=8866
+  _globals['_VALUE']._serialized_end=9573
+  _globals['_METAOBJECT']._serialized_start=9575
+  _globals['_METAOBJECT']._serialized_end=9629
+  _globals['_UNDETERMINED']._serialized_start=9631
+  _globals['_UNDETERMINED']._serialized_end=9701
+  _globals['_FUNCTION']._serialized_start=9703
+  _globals['_FUNCTION']._serialized_end=9747
+  _globals['_VALUESET']._serialized_start=9749
+  _globals['_VALUESET']._serialized_end=9791
+  _globals['_TENSORQUANTITY']._serialized_start=9793
+  _globals['_TENSORQUANTITY']._serialized_end=9866
+  _globals['_ARRAY']._serialized_start=9868
+  _globals['_ARRAY']._serialized_end=9927
+  _globals['_VECTOR']._serialized_start=9929
+  _globals['_VECTOR']._serialized_end=9971
+  _globals['_VECTORQUANTITY']._serialized_start=9973
+  _globals['_VECTORQUANTITY']._serialized_end=10026
+  _globals['_COMPLEX']._serialized_start=10028
+  _globals['_COMPLEX']._serialized_end=10070
+  _globals['_ENUMLITERAL']._serialized_start=10072
+  _globals['_ENUMLITERAL']._serialized_end=10172
+  _globals['_VALUESEQUENCE']._serialized_start=10174
+  _globals['_VALUESEQUENCE']._serialized_end=10221
+  _globals['_QUANTITY']._serialized_start=10223
+  _globals['_QUANTITY']._serialized_end=10347
+  _globals['_MEASUREMENTREF']._serialized_start=10349
+  _globals['_MEASUREMENTREF']._serialized_end=10432
+  _globals['_UNITTERM']._serialized_start=10434
+  _globals['_UNITTERM']._serialized_end=10518
+  _globals['_UNITFACTOR']._serialized_start=10520
+  _globals['_UNITFACTOR']._serialized_end=10567
+  _globals['_DIAGNOSTIC']._serialized_start=10569
+  _globals['_DIAGNOSTIC']._serialized_end=10657
+  _globals['_SPAN']._serialized_start=10659
+  _globals['_SPAN']._serialized_end=10753
+  _globals['_SERVERINFOREQUEST']._serialized_start=10755
+  _globals['_SERVERINFOREQUEST']._serialized_end=10774
+  _globals['_SERVERINFORESPONSE']._serialized_start=10776
+  _globals['_SERVERINFORESPONSE']._serialized_end=10835
+  _globals['_QUERYREQUEST']._serialized_start=10837
+  _globals['_QUERYREQUEST']._serialized_end=10920
+  _globals['_QUERYRESPONSE']._serialized_start=10922
+  _globals['_QUERYRESPONSE']._serialized_end=10982
+  _globals['_QUERY']._serialized_start=10984
+  _globals['_QUERY']._serialized_end=11056
+  _globals['_CONSTRAINT']._serialized_start=11058
+  _globals['_CONSTRAINT']._serialized_end=11182
+  _globals['_PRIMITIVECONSTRAINT']._serialized_start=11184
+  _globals['_PRIMITIVECONSTRAINT']._serialized_end=11299
+  _globals['_COMPOSITECONSTRAINT']._serialized_start=11301
+  _globals['_COMPOSITECONSTRAINT']._serialized_end=11405
+  _globals['_QUERYRESULTELEMENT']._serialized_start=11408
+  _globals['_QUERYRESULTELEMENT']._serialized_end=11568
+  _globals['_QUERYRESULTELEMENT_PROPERTIESENTRY']._serialized_start=11519
+  _globals['_QUERYRESULTELEMENT_PROPERTIESENTRY']._serialized_end=11568
+  _globals['_SWEEPRANGE']._serialized_start=11570
+  _globals['_SWEEPRANGE']._serialized_end=11685
+  _globals['_RUNSWEEPREQUEST']._serialized_start=11688
+  _globals['_RUNSWEEPREQUEST']._serialized_end=12024
+  _globals['_RUNSWEEPREQUEST_NAMEDARGUMENTSENTRY']._serialized_start=2627
+  _globals['_RUNSWEEPREQUEST_NAMEDARGUMENTSENTRY']._serialized_end=2694
+  _globals['_SWEEPROW']._serialized_start=12027
+  _globals['_SWEEPROW']._serialized_end=12271
+  _globals['_RUNSWEEPRESPONSE']._serialized_start=12274
+  _globals['_RUNSWEEPRESPONSE']._serialized_end=12575
+  _globals['_RUNDOCUMENTQUERYREQUEST']._serialized_start=12577
+  _globals['_RUNDOCUMENTQUERYREQUEST']._serialized_end=12687
+  _globals['_DOCUMENTQUERYBINDING']._serialized_start=12689
+  _globals['_DOCUMENTQUERYBINDING']._serialized_end=12768
+  _globals['_DOCUMENTVALUE']._serialized_start=12771
+  _globals['_DOCUMENTVALUE']._serialized_end=13146
+  _globals['_DOCUMENTOBJECT']._serialized_start=13148
+  _globals['_DOCUMENTOBJECT']._serialized_end=13238
+  _globals['_DOCUMENTVERDICT']._serialized_start=13241
+  _globals['_DOCUMENTVERDICT']._serialized_end=13415
+  _globals['_DOCUMENTSTATE']._serialized_start=13418
+  _globals['_DOCUMENTSTATE']._serialized_end=13595
+  _globals['_DOCUMENTEVENT']._serialized_start=13598
+  _globals['_DOCUMENTEVENT']._serialized_end=13882
+  _globals['_DOCUMENTQUERYCOLUMN']._serialized_start=13884
+  _globals['_DOCUMENTQUERYCOLUMN']._serialized_end=13919
+  _globals['_DOCUMENTQUERYCELL']._serialized_start=13921
+  _globals['_DOCUMENTQUERYCELL']._serialized_end=13978
+  _globals['_DOCUMENTQUERYROW']._serialized_start=13980
+  _globals['_DOCUMENTQUERYROW']._serialized_end=14078
+  _globals['_RUNDOCUMENTQUERYRESPONSE']._serialized_start=14080
+  _globals['_RUNDOCUMENTQUERYRESPONSE']._serialized_end=14190
+  _globals['_RENDERDOCUMENTREQUEST']._serialized_start=14192
+  _globals['_RENDERDOCUMENTREQUEST']._serialized_end=14270
+  _globals['_RENDERDOCUMENTRESPONSE']._serialized_start=14272
+  _globals['_RENDERDOCUMENTRESPONSE']._serialized_end=14328
+  _globals['_SYSMLSERVICE']._serialized_start=15397
+  _globals['_SYSMLSERVICE']._serialized_end=16996
+# @@protoc_insertion_point(module_scope)
diff --git a/clients/python/opensysml/proto/sysml_pb2.pyi b/client/python/opensysml/proto/sysml_pb2.pyi
similarity index 87%
rename from clients/python/opensysml/proto/sysml_pb2.pyi
rename to client/python/opensysml/proto/sysml_pb2.pyi
index 403507815c..c4b6b91199 100644
--- a/clients/python/opensysml/proto/sysml_pb2.pyi
+++ b/client/python/opensysml/proto/sysml_pb2.pyi
@@ -34,6 +34,7 @@ class EditFailure(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
     EDIT_FAILURE_DELETE_REFERENCED: _ClassVar[EditFailure]
     EDIT_FAILURE_OWNER_INSIDE_TARGET: _ClassVar[EditFailure]
     EDIT_FAILURE_MOVE_REFERENCED: _ClassVar[EditFailure]
+    EDIT_FAILURE_REFERENCED_ELSEWHERE: _ClassVar[EditFailure]
 
 class PrimitiveOperator(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
     __slots__ = ()
@@ -69,6 +70,7 @@ EDIT_FAILURE_MEMBER_NAME_TAKEN: EditFailure
 EDIT_FAILURE_DELETE_REFERENCED: EditFailure
 EDIT_FAILURE_OWNER_INSIDE_TARGET: EditFailure
 EDIT_FAILURE_MOVE_REFERENCED: EditFailure
+EDIT_FAILURE_REFERENCED_ELSEWHERE: EditFailure
 PRIMITIVE_OPERATOR_UNSPECIFIED: PrimitiveOperator
 PRIMITIVE_OPERATOR_EQUAL: PrimitiveOperator
 PRIMITIVE_OPERATOR_GREATER: PrimitiveOperator
@@ -78,7 +80,7 @@ COMPOSITE_OPERATOR_AND: CompositeOperator
 COMPOSITE_OPERATOR_OR: CompositeOperator
 
 class Verdict(_message.Message):
-    __slots__ = ("kind", "element_id", "element", "holds", "condition", "instance_id", "instance_type_id", "error", "failure_reason", "requirement_id", "engine", "strength", "bounds")
+    __slots__ = ("kind", "element_id", "element", "holds", "condition", "instance_id", "instance_type_id", "error", "failure_reason", "requirement_id", "engine", "strength", "bounds", "instance_path")
     KIND_FIELD_NUMBER: _ClassVar[int]
     ELEMENT_ID_FIELD_NUMBER: _ClassVar[int]
     ELEMENT_FIELD_NUMBER: _ClassVar[int]
@@ -92,6 +94,7 @@ class Verdict(_message.Message):
     ENGINE_FIELD_NUMBER: _ClassVar[int]
     STRENGTH_FIELD_NUMBER: _ClassVar[int]
     BOUNDS_FIELD_NUMBER: _ClassVar[int]
+    INSTANCE_PATH_FIELD_NUMBER: _ClassVar[int]
     kind: str
     element_id: str
     element: str
@@ -105,7 +108,8 @@ class Verdict(_message.Message):
     engine: str
     strength: str
     bounds: _containers.RepeatedCompositeFieldContainer[Bound]
-    def __init__(self, kind: _Optional[str] = ..., element_id: _Optional[str] = ..., element: _Optional[str] = ..., holds: _Optional[bool] = ..., condition: _Optional[str] = ..., instance_id: _Optional[int] = ..., instance_type_id: _Optional[str] = ..., error: _Optional[str] = ..., failure_reason: _Optional[_Union[FailureReason, str]] = ..., requirement_id: _Optional[str] = ..., engine: _Optional[str] = ..., strength: _Optional[str] = ..., bounds: _Optional[_Iterable[_Union[Bound, _Mapping]]] = ...) -> None: ...
+    instance_path: str
+    def __init__(self, kind: _Optional[str] = ..., element_id: _Optional[str] = ..., element: _Optional[str] = ..., holds: _Optional[bool] = ..., condition: _Optional[str] = ..., instance_id: _Optional[int] = ..., instance_type_id: _Optional[str] = ..., error: _Optional[str] = ..., failure_reason: _Optional[_Union[FailureReason, str]] = ..., requirement_id: _Optional[str] = ..., engine: _Optional[str] = ..., strength: _Optional[str] = ..., bounds: _Optional[_Iterable[_Union[Bound, _Mapping]]] = ..., instance_path: _Optional[str] = ...) -> None: ...
 
 class Bound(_message.Message):
     __slots__ = ("name", "limit", "reached")
@@ -207,6 +211,36 @@ class VerifySatisfactionResponse(_message.Message):
     verification_verdicts: _containers.RepeatedCompositeFieldContainer[VerificationVerdict]
     def __init__(self, verdicts: _Optional[_Iterable[_Union[Verdict, _Mapping]]] = ..., instances: _Optional[_Iterable[_Union[Instance, _Mapping]]] = ..., error: _Optional[str] = ..., diagnostics: _Optional[_Iterable[_Union[Diagnostic, _Mapping]]] = ..., failure_reason: _Optional[_Union[FailureReason, str]] = ..., verification_verdicts: _Optional[_Iterable[_Union[VerificationVerdict, _Mapping]]] = ...) -> None: ...
 
+class ValidateInstanceRequest(_message.Message):
+    __slots__ = ("model_hash", "symbol_id", "engine")
+    MODEL_HASH_FIELD_NUMBER: _ClassVar[int]
+    SYMBOL_ID_FIELD_NUMBER: _ClassVar[int]
+    ENGINE_FIELD_NUMBER: _ClassVar[int]
+    model_hash: str
+    symbol_id: str
+    engine: str
+    def __init__(self, model_hash: _Optional[str] = ..., symbol_id: _Optional[str] = ..., engine: _Optional[str] = ...) -> None: ...
+
+class ValidateInstanceResponse(_message.Message):
+    __slots__ = ("verdicts", "summary", "instances", "error", "diagnostics", "failure_reason", "verification_verdicts", "bounded")
+    VERDICTS_FIELD_NUMBER: _ClassVar[int]
+    SUMMARY_FIELD_NUMBER: _ClassVar[int]
+    INSTANCES_FIELD_NUMBER: _ClassVar[int]
+    ERROR_FIELD_NUMBER: _ClassVar[int]
+    DIAGNOSTICS_FIELD_NUMBER: _ClassVar[int]
+    FAILURE_REASON_FIELD_NUMBER: _ClassVar[int]
+    VERIFICATION_VERDICTS_FIELD_NUMBER: _ClassVar[int]
+    BOUNDED_FIELD_NUMBER: _ClassVar[int]
+    verdicts: _containers.RepeatedCompositeFieldContainer[Verdict]
+    summary: Verdict
+    instances: _containers.RepeatedCompositeFieldContainer[Instance]
+    error: str
+    diagnostics: _containers.RepeatedCompositeFieldContainer[Diagnostic]
+    failure_reason: FailureReason
+    verification_verdicts: _containers.RepeatedCompositeFieldContainer[VerificationVerdict]
+    bounded: bool
+    def __init__(self, verdicts: _Optional[_Iterable[_Union[Verdict, _Mapping]]] = ..., summary: _Optional[_Union[Verdict, _Mapping]] = ..., instances: _Optional[_Iterable[_Union[Instance, _Mapping]]] = ..., error: _Optional[str] = ..., diagnostics: _Optional[_Iterable[_Union[Diagnostic, _Mapping]]] = ..., failure_reason: _Optional[_Union[FailureReason, str]] = ..., verification_verdicts: _Optional[_Iterable[_Union[VerificationVerdict, _Mapping]]] = ..., bounded: _Optional[bool] = ...) -> None: ...
+
 class EvaluateCalcRequest(_message.Message):
     __slots__ = ("model_hash", "symbol_id", "arguments", "engine")
     MODEL_HASH_FIELD_NUMBER: _ClassVar[int]
@@ -319,7 +353,7 @@ class RunAnalysisResponse(_message.Message):
     def __init__(self, outputs: _Optional[_Iterable[_Union[CalcOutput, _Mapping]]] = ..., verdicts: _Optional[_Iterable[_Union[Verdict, _Mapping]]] = ..., instances: _Optional[_Iterable[_Union[Instance, _Mapping]]] = ..., error: _Optional[str] = ..., diagnostics: _Optional[_Iterable[_Union[Diagnostic, _Mapping]]] = ..., failure_reason: _Optional[_Union[FailureReason, str]] = ..., verification_verdicts: _Optional[_Iterable[_Union[VerificationVerdict, _Mapping]]] = ..., outcomes: _Optional[_Iterable[_Union[Outcome, _Mapping]]] = ..., exploration: _Optional[_Union[ExplorationStatus, _Mapping]] = ..., evaluations: _Optional[_Iterable[_Union[CaseEvaluation, _Mapping]]] = ..., engine: _Optional[str] = ..., strength: _Optional[str] = ..., bounds: _Optional[_Iterable[_Union[Bound, _Mapping]]] = ...) -> None: ...
 
 class Outcome(_message.Message):
-    __slots__ = ("outputs", "final_state", "states_visited", "error", "linearizations", "witness", "diagnostics")
+    __slots__ = ("outputs", "final_state", "states_visited", "error", "linearizations", "witness", "diagnostics", "probability")
     class OutputsEntry(_message.Message):
         __slots__ = ("key", "value")
         KEY_FIELD_NUMBER: _ClassVar[int]
@@ -334,6 +368,7 @@ class Outcome(_message.Message):
     LINEARIZATIONS_FIELD_NUMBER: _ClassVar[int]
     WITNESS_FIELD_NUMBER: _ClassVar[int]
     DIAGNOSTICS_FIELD_NUMBER: _ClassVar[int]
+    PROBABILITY_FIELD_NUMBER: _ClassVar[int]
     outputs: _containers.MessageMap[str, Value]
     final_state: str
     states_visited: _containers.RepeatedScalarFieldContainer[str]
@@ -341,21 +376,24 @@ class Outcome(_message.Message):
     linearizations: int
     witness: _containers.RepeatedScalarFieldContainer[str]
     diagnostics: _containers.RepeatedCompositeFieldContainer[Diagnostic]
-    def __init__(self, outputs: _Optional[_Mapping[str, Value]] = ..., final_state: _Optional[str] = ..., states_visited: _Optional[_Iterable[str]] = ..., error: _Optional[str] = ..., linearizations: _Optional[int] = ..., witness: _Optional[_Iterable[str]] = ..., diagnostics: _Optional[_Iterable[_Union[Diagnostic, _Mapping]]] = ...) -> None: ...
+    probability: float
+    def __init__(self, outputs: _Optional[_Mapping[str, Value]] = ..., final_state: _Optional[str] = ..., states_visited: _Optional[_Iterable[str]] = ..., error: _Optional[str] = ..., linearizations: _Optional[int] = ..., witness: _Optional[_Iterable[str]] = ..., diagnostics: _Optional[_Iterable[_Union[Diagnostic, _Mapping]]] = ..., probability: _Optional[float] = ...) -> None: ...
 
 class ExplorationStatus(_message.Message):
-    __slots__ = ("complete", "runs", "budgets_hit", "runs_budget", "depth_budget")
+    __slots__ = ("complete", "runs", "budgets_hit", "runs_budget", "depth_budget", "probabilities_lower_bound")
     COMPLETE_FIELD_NUMBER: _ClassVar[int]
     RUNS_FIELD_NUMBER: _ClassVar[int]
     BUDGETS_HIT_FIELD_NUMBER: _ClassVar[int]
     RUNS_BUDGET_FIELD_NUMBER: _ClassVar[int]
     DEPTH_BUDGET_FIELD_NUMBER: _ClassVar[int]
+    PROBABILITIES_LOWER_BOUND_FIELD_NUMBER: _ClassVar[int]
     complete: bool
     runs: int
     budgets_hit: _containers.RepeatedScalarFieldContainer[str]
     runs_budget: int
     depth_budget: int
-    def __init__(self, complete: _Optional[bool] = ..., runs: _Optional[int] = ..., budgets_hit: _Optional[_Iterable[str]] = ..., runs_budget: _Optional[int] = ..., depth_budget: _Optional[int] = ...) -> None: ...
+    probabilities_lower_bound: bool
+    def __init__(self, complete: _Optional[bool] = ..., runs: _Optional[int] = ..., budgets_hit: _Optional[_Iterable[str]] = ..., runs_budget: _Optional[int] = ..., depth_budget: _Optional[int] = ..., probabilities_lower_bound: _Optional[bool] = ...) -> None: ...
 
 class ListEnginesRequest(_message.Message):
     __slots__ = ()
@@ -561,7 +599,7 @@ class InstantiateResponse(_message.Message):
     def __init__(self, instance: _Optional[_Union[Instance, _Mapping]] = ..., error: _Optional[str] = ..., diagnostics: _Optional[_Iterable[_Union[Diagnostic, _Mapping]]] = ..., instances: _Optional[_Iterable[_Union[Instance, _Mapping]]] = ...) -> None: ...
 
 class ExecuteActionRequest(_message.Message):
-    __slots__ = ("model_hash", "action_symbol_id", "inputs", "schedule")
+    __slots__ = ("model_hash", "action_symbol_id", "inputs", "schedule", "performer_symbol_id")
     class InputsEntry(_message.Message):
         __slots__ = ("key", "value")
         KEY_FIELD_NUMBER: _ClassVar[int]
@@ -573,11 +611,13 @@ class ExecuteActionRequest(_message.Message):
     ACTION_SYMBOL_ID_FIELD_NUMBER: _ClassVar[int]
     INPUTS_FIELD_NUMBER: _ClassVar[int]
     SCHEDULE_FIELD_NUMBER: _ClassVar[int]
+    PERFORMER_SYMBOL_ID_FIELD_NUMBER: _ClassVar[int]
     model_hash: str
     action_symbol_id: str
     inputs: _containers.MessageMap[str, Value]
     schedule: str
-    def __init__(self, model_hash: _Optional[str] = ..., action_symbol_id: _Optional[str] = ..., inputs: _Optional[_Mapping[str, Value]] = ..., schedule: _Optional[str] = ...) -> None: ...
+    performer_symbol_id: str
+    def __init__(self, model_hash: _Optional[str] = ..., action_symbol_id: _Optional[str] = ..., inputs: _Optional[_Mapping[str, Value]] = ..., schedule: _Optional[str] = ..., performer_symbol_id: _Optional[str] = ...) -> None: ...
 
 class ExecuteActionResponse(_message.Message):
     __slots__ = ("outputs", "error", "diagnostics", "outcomes", "exploration", "final_time")
@@ -603,16 +643,18 @@ class ExecuteActionResponse(_message.Message):
     def __init__(self, outputs: _Optional[_Mapping[str, Value]] = ..., error: _Optional[str] = ..., diagnostics: _Optional[_Iterable[_Union[Diagnostic, _Mapping]]] = ..., outcomes: _Optional[_Iterable[_Union[Outcome, _Mapping]]] = ..., exploration: _Optional[_Union[ExplorationStatus, _Mapping]] = ..., final_time: _Optional[float] = ...) -> None: ...
 
 class ExecuteStateRequest(_message.Message):
-    __slots__ = ("model_hash", "state_machine_symbol_id", "events", "schedule")
+    __slots__ = ("model_hash", "state_machine_symbol_id", "events", "schedule", "performer_symbol_id")
     MODEL_HASH_FIELD_NUMBER: _ClassVar[int]
     STATE_MACHINE_SYMBOL_ID_FIELD_NUMBER: _ClassVar[int]
     EVENTS_FIELD_NUMBER: _ClassVar[int]
     SCHEDULE_FIELD_NUMBER: _ClassVar[int]
+    PERFORMER_SYMBOL_ID_FIELD_NUMBER: _ClassVar[int]
     model_hash: str
     state_machine_symbol_id: str
     events: _containers.RepeatedScalarFieldContainer[str]
     schedule: str
-    def __init__(self, model_hash: _Optional[str] = ..., state_machine_symbol_id: _Optional[str] = ..., events: _Optional[_Iterable[str]] = ..., schedule: _Optional[str] = ...) -> None: ...
+    performer_symbol_id: str
+    def __init__(self, model_hash: _Optional[str] = ..., state_machine_symbol_id: _Optional[str] = ..., events: _Optional[_Iterable[str]] = ..., schedule: _Optional[str] = ..., performer_symbol_id: _Optional[str] = ...) -> None: ...
 
 class ExecuteStateResponse(_message.Message):
     __slots__ = ("states_visited", "final_context", "error", "diagnostics", "outcomes", "exploration", "final_time")
@@ -674,12 +716,16 @@ class ConvertResponse(_message.Message):
     def __init__(self, content: _Optional[str] = ..., from_format: _Optional[str] = ..., to_format: _Optional[str] = ..., error: _Optional[str] = ..., diagnostics: _Optional[_Iterable[_Union[Diagnostic, _Mapping]]] = ..., experimental: _Optional[bool] = ..., experimental_notice: _Optional[str] = ...) -> None: ...
 
 class ApplyEditsRequest(_message.Message):
-    __slots__ = ("model_hash", "operations")
+    __slots__ = ("model_hash", "operations", "document", "accept_documents")
     MODEL_HASH_FIELD_NUMBER: _ClassVar[int]
     OPERATIONS_FIELD_NUMBER: _ClassVar[int]
+    DOCUMENT_FIELD_NUMBER: _ClassVar[int]
+    ACCEPT_DOCUMENTS_FIELD_NUMBER: _ClassVar[int]
     model_hash: str
     operations: _containers.RepeatedCompositeFieldContainer[EditOperation]
-    def __init__(self, model_hash: _Optional[str] = ..., operations: _Optional[_Iterable[_Union[EditOperation, _Mapping]]] = ...) -> None: ...
+    document: str
+    accept_documents: bool
+    def __init__(self, model_hash: _Optional[str] = ..., operations: _Optional[_Iterable[_Union[EditOperation, _Mapping]]] = ..., document: _Optional[str] = ..., accept_documents: _Optional[bool] = ...) -> None: ...
 
 class EditOperation(_message.Message):
     __slots__ = ("set_value", "rename", "add_member", "delete", "move")
@@ -746,36 +792,58 @@ class RenameEdit(_message.Message):
     def __init__(self, target: _Optional[str] = ..., new_name: _Optional[str] = ...) -> None: ...
 
 class ApplyEditsResponse(_message.Message):
-    __slots__ = ("content", "applied", "error", "failure", "diagnostics", "referring_elements")
+    __slots__ = ("content", "applied", "error", "failure", "diagnostics", "referring_elements", "documents", "referrers")
     CONTENT_FIELD_NUMBER: _ClassVar[int]
     APPLIED_FIELD_NUMBER: _ClassVar[int]
     ERROR_FIELD_NUMBER: _ClassVar[int]
     FAILURE_FIELD_NUMBER: _ClassVar[int]
     DIAGNOSTICS_FIELD_NUMBER: _ClassVar[int]
     REFERRING_ELEMENTS_FIELD_NUMBER: _ClassVar[int]
+    DOCUMENTS_FIELD_NUMBER: _ClassVar[int]
+    REFERRERS_FIELD_NUMBER: _ClassVar[int]
     content: str
     applied: _containers.RepeatedCompositeFieldContainer[AppliedEdit]
     error: str
     failure: EditFailure
     diagnostics: _containers.RepeatedCompositeFieldContainer[Diagnostic]
     referring_elements: _containers.RepeatedScalarFieldContainer[str]
-    def __init__(self, content: _Optional[str] = ..., applied: _Optional[_Iterable[_Union[AppliedEdit, _Mapping]]] = ..., error: _Optional[str] = ..., failure: _Optional[_Union[EditFailure, str]] = ..., diagnostics: _Optional[_Iterable[_Union[Diagnostic, _Mapping]]] = ..., referring_elements: _Optional[_Iterable[str]] = ...) -> None: ...
+    documents: _containers.RepeatedCompositeFieldContainer[EditedDocument]
+    referrers: _containers.RepeatedCompositeFieldContainer[Referrer]
+    def __init__(self, content: _Optional[str] = ..., applied: _Optional[_Iterable[_Union[AppliedEdit, _Mapping]]] = ..., error: _Optional[str] = ..., failure: _Optional[_Union[EditFailure, str]] = ..., diagnostics: _Optional[_Iterable[_Union[Diagnostic, _Mapping]]] = ..., referring_elements: _Optional[_Iterable[str]] = ..., documents: _Optional[_Iterable[_Union[EditedDocument, _Mapping]]] = ..., referrers: _Optional[_Iterable[_Union[Referrer, _Mapping]]] = ...) -> None: ...
+
+class EditedDocument(_message.Message):
+    __slots__ = ("name", "content")
+    NAME_FIELD_NUMBER: _ClassVar[int]
+    CONTENT_FIELD_NUMBER: _ClassVar[int]
+    name: str
+    content: str
+    def __init__(self, name: _Optional[str] = ..., content: _Optional[str] = ...) -> None: ...
+
+class Referrer(_message.Message):
+    __slots__ = ("name", "document")
+    NAME_FIELD_NUMBER: _ClassVar[int]
+    DOCUMENT_FIELD_NUMBER: _ClassVar[int]
+    name: str
+    document: str
+    def __init__(self, name: _Optional[str] = ..., document: _Optional[str] = ...) -> None: ...
 
 class AppliedEdit(_message.Message):
-    __slots__ = ("operation_index", "target", "offset", "length", "old_text", "new_text")
+    __slots__ = ("operation_index", "target", "offset", "length", "old_text", "new_text", "document")
     OPERATION_INDEX_FIELD_NUMBER: _ClassVar[int]
     TARGET_FIELD_NUMBER: _ClassVar[int]
     OFFSET_FIELD_NUMBER: _ClassVar[int]
     LENGTH_FIELD_NUMBER: _ClassVar[int]
     OLD_TEXT_FIELD_NUMBER: _ClassVar[int]
     NEW_TEXT_FIELD_NUMBER: _ClassVar[int]
+    DOCUMENT_FIELD_NUMBER: _ClassVar[int]
     operation_index: int
     target: str
     offset: int
     length: int
     old_text: str
     new_text: str
-    def __init__(self, operation_index: _Optional[int] = ..., target: _Optional[str] = ..., offset: _Optional[int] = ..., length: _Optional[int] = ..., old_text: _Optional[str] = ..., new_text: _Optional[str] = ...) -> None: ...
+    document: str
+    def __init__(self, operation_index: _Optional[int] = ..., target: _Optional[str] = ..., offset: _Optional[int] = ..., length: _Optional[int] = ..., old_text: _Optional[str] = ..., new_text: _Optional[str] = ..., document: _Optional[str] = ...) -> None: ...
 
 class SymbolInfo(_message.Message):
     __slots__ = ("id", "name", "kind", "metadata", "child_ids", "attributes", "type_info", "multiplicity", "specializations", "withheld_library_attributes")
@@ -1241,7 +1309,7 @@ class DocumentQueryBinding(_message.Message):
     def __init__(self, parameter: _Optional[str] = ..., values: _Optional[_Iterable[_Union[DocumentValue, _Mapping]]] = ...) -> None: ...
 
 class DocumentValue(_message.Message):
-    __slots__ = ("element_id", "string_value", "int_value", "real_value", "bool_value", "infinity", "quantity", "element_type")
+    __slots__ = ("element_id", "string_value", "int_value", "real_value", "bool_value", "infinity", "quantity", "verdict", "object", "state", "event", "element_type")
     ELEMENT_ID_FIELD_NUMBER: _ClassVar[int]
     STRING_VALUE_FIELD_NUMBER: _ClassVar[int]
     INT_VALUE_FIELD_NUMBER: _ClassVar[int]
@@ -1249,6 +1317,10 @@ class DocumentValue(_message.Message):
     BOOL_VALUE_FIELD_NUMBER: _ClassVar[int]
     INFINITY_FIELD_NUMBER: _ClassVar[int]
     QUANTITY_FIELD_NUMBER: _ClassVar[int]
+    VERDICT_FIELD_NUMBER: _ClassVar[int]
+    OBJECT_FIELD_NUMBER: _ClassVar[int]
+    STATE_FIELD_NUMBER: _ClassVar[int]
+    EVENT_FIELD_NUMBER: _ClassVar[int]
     ELEMENT_TYPE_FIELD_NUMBER: _ClassVar[int]
     element_id: str
     string_value: str
@@ -1257,8 +1329,89 @@ class DocumentValue(_message.Message):
     bool_value: bool
     infinity: bool
     quantity: Quantity
+    verdict: DocumentVerdict
+    object: DocumentObject
+    state: DocumentState
+    event: DocumentEvent
     element_type: str
-    def __init__(self, element_id: _Optional[str] = ..., string_value: _Optional[str] = ..., int_value: _Optional[int] = ..., real_value: _Optional[float] = ..., bool_value: _Optional[bool] = ..., infinity: _Optional[bool] = ..., quantity: _Optional[_Union[Quantity, _Mapping]] = ..., element_type: _Optional[str] = ...) -> None: ...
+    def __init__(self, element_id: _Optional[str] = ..., string_value: _Optional[str] = ..., int_value: _Optional[int] = ..., real_value: _Optional[float] = ..., bool_value: _Optional[bool] = ..., infinity: _Optional[bool] = ..., quantity: _Optional[_Union[Quantity, _Mapping]] = ..., verdict: _Optional[_Union[DocumentVerdict, _Mapping]] = ..., object: _Optional[_Union[DocumentObject, _Mapping]] = ..., state: _Optional[_Union[DocumentState, _Mapping]] = ..., event: _Optional[_Union[DocumentEvent, _Mapping]] = ..., element_type: _Optional[str] = ...) -> None: ...
+
+class DocumentObject(_message.Message):
+    __slots__ = ("instance_id", "path", "element")
+    INSTANCE_ID_FIELD_NUMBER: _ClassVar[int]
+    PATH_FIELD_NUMBER: _ClassVar[int]
+    ELEMENT_FIELD_NUMBER: _ClassVar[int]
+    instance_id: int
+    path: str
+    element: DocumentValue
+    def __init__(self, instance_id: _Optional[int] = ..., path: _Optional[str] = ..., element: _Optional[_Union[DocumentValue, _Mapping]] = ...) -> None: ...
+
+class DocumentVerdict(_message.Message):
+    __slots__ = ("assertion", "kind", "text", "path", "verdict", "condition", "reason", "verification")
+    ASSERTION_FIELD_NUMBER: _ClassVar[int]
+    KIND_FIELD_NUMBER: _ClassVar[int]
+    TEXT_FIELD_NUMBER: _ClassVar[int]
+    PATH_FIELD_NUMBER: _ClassVar[int]
+    VERDICT_FIELD_NUMBER: _ClassVar[int]
+    CONDITION_FIELD_NUMBER: _ClassVar[int]
+    REASON_FIELD_NUMBER: _ClassVar[int]
+    VERIFICATION_FIELD_NUMBER: _ClassVar[int]
+    assertion: DocumentValue
+    kind: str
+    text: str
+    path: str
+    verdict: str
+    condition: str
+    reason: str
+    verification: _containers.RepeatedScalarFieldContainer[str]
+    def __init__(self, assertion: _Optional[_Union[DocumentValue, _Mapping]] = ..., kind: _Optional[str] = ..., text: _Optional[str] = ..., path: _Optional[str] = ..., verdict: _Optional[str] = ..., condition: _Optional[str] = ..., reason: _Optional[str] = ..., verification: _Optional[_Iterable[str]] = ...) -> None: ...
+
+class DocumentState(_message.Message):
+    __slots__ = ("object", "machine", "name", "state_path", "state", "region", "enclosing")
+    OBJECT_FIELD_NUMBER: _ClassVar[int]
+    MACHINE_FIELD_NUMBER: _ClassVar[int]
+    NAME_FIELD_NUMBER: _ClassVar[int]
+    STATE_PATH_FIELD_NUMBER: _ClassVar[int]
+    STATE_FIELD_NUMBER: _ClassVar[int]
+    REGION_FIELD_NUMBER: _ClassVar[int]
+    ENCLOSING_FIELD_NUMBER: _ClassVar[int]
+    object: DocumentObject
+    machine: str
+    name: str
+    state_path: str
+    state: DocumentValue
+    region: str
+    enclosing: _containers.RepeatedScalarFieldContainer[str]
+    def __init__(self, object: _Optional[_Union[DocumentObject, _Mapping]] = ..., machine: _Optional[str] = ..., name: _Optional[str] = ..., state_path: _Optional[str] = ..., state: _Optional[_Union[DocumentValue, _Mapping]] = ..., region: _Optional[str] = ..., enclosing: _Optional[_Iterable[str]] = ...) -> None: ...
+
+class DocumentEvent(_message.Message):
+    __slots__ = ("kind", "time", "object", "machine", "state", "to", "target", "event", "payload", "alternatives", "taken", "text")
+    KIND_FIELD_NUMBER: _ClassVar[int]
+    TIME_FIELD_NUMBER: _ClassVar[int]
+    OBJECT_FIELD_NUMBER: _ClassVar[int]
+    MACHINE_FIELD_NUMBER: _ClassVar[int]
+    STATE_FIELD_NUMBER: _ClassVar[int]
+    FROM_FIELD_NUMBER: _ClassVar[int]
+    TO_FIELD_NUMBER: _ClassVar[int]
+    TARGET_FIELD_NUMBER: _ClassVar[int]
+    EVENT_FIELD_NUMBER: _ClassVar[int]
+    PAYLOAD_FIELD_NUMBER: _ClassVar[int]
+    ALTERNATIVES_FIELD_NUMBER: _ClassVar[int]
+    TAKEN_FIELD_NUMBER: _ClassVar[int]
+    TEXT_FIELD_NUMBER: _ClassVar[int]
+    kind: str
+    time: DocumentValue
+    object: DocumentObject
+    machine: str
+    state: str
+    to: str
+    target: DocumentObject
+    event: str
+    payload: _containers.RepeatedScalarFieldContainer[str]
+    alternatives: _containers.RepeatedScalarFieldContainer[str]
+    taken: str
+    text: str
+    def __init__(self, kind: _Optional[str] = ..., time: _Optional[_Union[DocumentValue, _Mapping]] = ..., object: _Optional[_Union[DocumentObject, _Mapping]] = ..., machine: _Optional[str] = ..., state: _Optional[str] = ..., to: _Optional[str] = ..., target: _Optional[_Union[DocumentObject, _Mapping]] = ..., event: _Optional[str] = ..., payload: _Optional[_Iterable[str]] = ..., alternatives: _Optional[_Iterable[str]] = ..., taken: _Optional[str] = ..., text: _Optional[str] = ..., **kwargs) -> None: ...
 
 class DocumentQueryColumn(_message.Message):
     __slots__ = ("name",)
@@ -1289,15 +1442,19 @@ class RunDocumentQueryResponse(_message.Message):
     def __init__(self, columns: _Optional[_Iterable[_Union[DocumentQueryColumn, _Mapping]]] = ..., rows: _Optional[_Iterable[_Union[DocumentQueryRow, _Mapping]]] = ...) -> None: ...
 
 class RenderDocumentRequest(_message.Message):
-    __slots__ = ("model_hash", "document_id")
+    __slots__ = ("model_hash", "document_id", "form")
     MODEL_HASH_FIELD_NUMBER: _ClassVar[int]
     DOCUMENT_ID_FIELD_NUMBER: _ClassVar[int]
+    FORM_FIELD_NUMBER: _ClassVar[int]
     model_hash: str
     document_id: str
-    def __init__(self, model_hash: _Optional[str] = ..., document_id: _Optional[str] = ...) -> None: ...
+    form: str
+    def __init__(self, model_hash: _Optional[str] = ..., document_id: _Optional[str] = ..., form: _Optional[str] = ...) -> None: ...
 
 class RenderDocumentResponse(_message.Message):
-    __slots__ = ("markdown",)
+    __slots__ = ("markdown", "html")
     MARKDOWN_FIELD_NUMBER: _ClassVar[int]
+    HTML_FIELD_NUMBER: _ClassVar[int]
     markdown: str
-    def __init__(self, markdown: _Optional[str] = ...) -> None: ...
+    html: str
+    def __init__(self, markdown: _Optional[str] = ..., html: _Optional[str] = ...) -> None: ...
diff --git a/clients/python/opensysml/proto/sysml_pb2_grpc.py b/client/python/opensysml/proto/sysml_pb2_grpc.py
similarity index 94%
rename from clients/python/opensysml/proto/sysml_pb2_grpc.py
rename to client/python/opensysml/proto/sysml_pb2_grpc.py
index afe81f5eb3..dfa9838fac 100644
--- a/clients/python/opensysml/proto/sysml_pb2_grpc.py
+++ b/client/python/opensysml/proto/sysml_pb2_grpc.py
@@ -105,6 +105,11 @@ def __init__(self, channel):
                 request_serializer=sysml__pb2.VerifySatisfactionRequest.SerializeToString,
                 response_deserializer=sysml__pb2.VerifySatisfactionResponse.FromString,
                 _registered_method=True)
+        self.ValidateInstance = channel.unary_unary(
+                '/sysml.SysMLService/ValidateInstance',
+                request_serializer=sysml__pb2.ValidateInstanceRequest.SerializeToString,
+                response_deserializer=sysml__pb2.ValidateInstanceResponse.FromString,
+                _registered_method=True)
         self.EvaluateCalc = channel.unary_unary(
                 '/sysml.SysMLService/EvaluateCalc',
                 request_serializer=sysml__pb2.EvaluateCalcRequest.SerializeToString,
@@ -253,6 +258,18 @@ def VerifySatisfaction(self, request, context):
         context.set_details('Method not implemented!')
         raise NotImplementedError('Method not implemented!')
 
+    def ValidateInstance(self, request, context):
+        """Validate an object as a whole, as the REPL's %validate and the CLI's
+        -validate=<object> do: every assertion about an object of the named part
+        and about the objects it holds — the asserted constraints of their types,
+        the requirements they carry and the satisfactions they are the subject of —
+        each answered on the concrete object it is about. Reported as the
+        "verification" capability.
+        """
+        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+        context.set_details('Method not implemented!')
+        raise NotImplementedError('Method not implemented!')
+
     def EvaluateCalc(self, request, context):
         """Missing associated documentation comment in .proto file."""
         context.set_code(grpc.StatusCode.UNIMPLEMENTED)
@@ -388,6 +405,11 @@ def add_SysMLServiceServicer_to_server(servicer, server):
                     request_deserializer=sysml__pb2.VerifySatisfactionRequest.FromString,
                     response_serializer=sysml__pb2.VerifySatisfactionResponse.SerializeToString,
             ),
+            'ValidateInstance': grpc.unary_unary_rpc_method_handler(
+                    servicer.ValidateInstance,
+                    request_deserializer=sysml__pb2.ValidateInstanceRequest.FromString,
+                    response_serializer=sysml__pb2.ValidateInstanceResponse.SerializeToString,
+            ),
             'EvaluateCalc': grpc.unary_unary_rpc_method_handler(
                     servicer.EvaluateCalc,
                     request_deserializer=sysml__pb2.EvaluateCalcRequest.FromString,
@@ -813,6 +835,33 @@ def VerifySatisfaction(request,
             metadata,
             _registered_method=True)
 
+    @staticmethod
+    def ValidateInstance(request,
+            target,
+            options=(),
+            channel_credentials=None,
+            call_credentials=None,
+            insecure=False,
+            compression=None,
+            wait_for_ready=None,
+            timeout=None,
+            metadata=None):
+        return grpc.experimental.unary_unary(
+            request,
+            target,
+            '/sysml.SysMLService/ValidateInstance',
+            sysml__pb2.ValidateInstanceRequest.SerializeToString,
+            sysml__pb2.ValidateInstanceResponse.FromString,
+            options,
+            channel_credentials,
+            insecure,
+            call_credentials,
+            compression,
+            wait_for_ready,
+            timeout,
+            metadata,
+            _registered_method=True)
+
     @staticmethod
     def EvaluateCalc(request,
             target,
diff --git a/clients/python/opensysml/py.typed b/client/python/opensysml/py.typed
similarity index 100%
rename from clients/python/opensysml/py.typed
rename to client/python/opensysml/py.typed
diff --git a/clients/python/opensysml/query.py b/client/python/opensysml/query.py
similarity index 100%
rename from clients/python/opensysml/query.py
rename to client/python/opensysml/query.py
diff --git a/clients/python/opensysml/release-digests.json b/client/python/opensysml/release-digests.json
similarity index 100%
rename from clients/python/opensysml/release-digests.json
rename to client/python/opensysml/release-digests.json
diff --git a/clients/python/opensysml/signing.py b/client/python/opensysml/signing.py
similarity index 100%
rename from clients/python/opensysml/signing.py
rename to client/python/opensysml/signing.py
diff --git a/clients/python/opensysml/symbol.py b/client/python/opensysml/symbol.py
similarity index 99%
rename from clients/python/opensysml/symbol.py
rename to client/python/opensysml/symbol.py
index 2eb20a5e61..9034719b4b 100644
--- a/clients/python/opensysml/symbol.py
+++ b/client/python/opensysml/symbol.py
@@ -18,7 +18,7 @@
 if TYPE_CHECKING:
     from opensysml.proto import sysml_pb2
 
-# Kind strings emitted by the service (internal/core/symbols: symbolKindNames).
+# Kind strings emitted by the service (internal/semantic/symbols: symbolKindNames).
 # Matched case-insensitively so older PascalCase producers still work.
 ATTRIBUTE_KINDS = frozenset({"attributedef", "attributeusage"})
 PART_KINDS = frozenset({"partdef", "partusage"})
diff --git a/clients/python/opensysml/typed.py b/client/python/opensysml/typed.py
similarity index 100%
rename from clients/python/opensysml/typed.py
rename to client/python/opensysml/typed.py
diff --git a/clients/python/opensysml/typefacts.py b/client/python/opensysml/typefacts.py
similarity index 100%
rename from clients/python/opensysml/typefacts.py
rename to client/python/opensysml/typefacts.py
diff --git a/clients/python/opensysml/values.py b/client/python/opensysml/values.py
similarity index 100%
rename from clients/python/opensysml/values.py
rename to client/python/opensysml/values.py
diff --git a/clients/python/opensysml/verdict.py b/client/python/opensysml/verdict.py
similarity index 83%
rename from clients/python/opensysml/verdict.py
rename to client/python/opensysml/verdict.py
index 6ffaa79a28..95f7b3bc36 100644
--- a/clients/python/opensysml/verdict.py
+++ b/client/python/opensysml/verdict.py
@@ -17,6 +17,7 @@
 KIND_SATISFY = "satisfy"
 KIND_OBJECTIVE = "objective"
 KIND_ASSERTION = "assertion"
+KIND_OBJECT = "object"
 
 
 #: VerdictKind values a verification case's body produces, as the library
@@ -119,7 +120,8 @@ class Verdict:
 
     Attributes:
         kind (str): What was verified: 'constraint', 'requirement', 'satisfy',
-            or an analysis case's 'objective' or 'assertion'
+            an analysis case's 'objective' or 'assertion', or a validated
+            'object' as a whole
         element_id (str): FQN of the element verified; empty for an anonymous
             satisfaction assertion
         element (str): The element as a reader names it — its FQN, or the
@@ -140,6 +142,9 @@ class Verdict:
         requirement_id (str): FQN of the requirement a 'satisfy' verdict asserts
             satisfied; empty for every other kind and for an anonymous
             requirement
+        instance_path (str): Where the object this verdict is about sits in a
+            validated one (``engine.injector``, ``wheels[2]``); empty for the
+            validated object itself and outside a validation
         verifications (list[VerificationVerdict]): What the bodies of the
             verification cases verifying this verdict's own requirement
             answered, beside this verdict rather than instead of it. Empty when
@@ -212,6 +217,11 @@ def requirement_id(self):
         """FQN of the requirement a satisfaction verdict asserts satisfied."""
         return getattr(self._pb, "requirement_id", "")
 
+    @property
+    def instance_path(self):
+        """Path from the validated object to the one this verdict is about."""
+        return getattr(self._pb, "instance_path", "")
+
     @property
     def error(self):
         """Why evaluation failed, when it failed rather than answering."""
@@ -248,9 +258,10 @@ def _named(self):
         satisfy r by p"), so the kind is not repeated in front of it.
         """
         element = self.element
-        if self.kind in element.split():
-            return element
-        return f"{self.kind} {element}"
+        named = element if self.kind in element.split() else f"{self.kind} {element}"
+        if self.instance_path:
+            named += f" at {self.instance_path}"
+        return named
 
     def explain(self):
         """One line saying what the verdict is and why, then its standing when reported."""
@@ -289,6 +300,102 @@ def __repr__(self):
         )
 
 
+class Validation:
+    """Every assertion about one object and the objects it holds, answered.
+
+    Validating an object evaluates each asserted constraint, each requirement
+    and each satisfaction assertion whose subject lies in the object's tree
+    against the object carrying it, as ``sysml -validate=<object>`` and the
+    REPL's ``%validate`` do. Truthy only when every assertion holds and the
+    whole tree was reached, so a verdict that could not be decided is not a
+    holding one and a tree cut short by the traversal bound is not valid.
+
+    Attributes:
+        verdicts (list[Verdict]): One per assertion, root first then each held
+            object in traversal order; ``instance_path`` says which object it
+            is about, empty for the root
+        summary (Verdict): The object's own verdict, of kind 'object': holds
+            when every assertion does, carries ``error`` when one could not be
+            decided or the tree was cut short
+        instances (list[Instance]): The object validated and every object
+            reachable from it
+        bounded (bool): Whether traversal stopped at its bound before reaching
+            every held object, so the verdicts are not the whole answer
+        diagnostics (list[Diagnostic]): Diagnostics the service reported
+        verifications (list[VerificationVerdict]): What the bodies of the
+            verification cases of the requirements met answered
+        standing (Standing): The engine that answered; unreported when the
+            service predates ``engines``
+    """
+
+    def __init__(self, verdicts, summary, instances=None, diagnostics=None,
+                 verifications=None, bounded=False):
+        self.verdicts = list(verdicts or [])
+        self.summary = summary
+        self.instances = list(instances or [])
+        self.diagnostics = list(diagnostics or [])
+        self.verifications = list(verifications or [])
+        self.bounded = bounded
+        self.standing = summary.standing if summary is not None else Standing()
+
+    engine = property(lambda self: self.standing.engine, doc="Name of the engine that answered.")
+    strength = _STRENGTH
+    bounds = _BOUNDS
+
+    @property
+    def valid(self):
+        """Whether the object is shown valid: at least one assertion, every one
+        holding, and every held object reached."""
+        return self.summary is not None and self.summary.holds and not self.summary.error
+
+    @property
+    def violated(self):
+        """The verdicts the model answered false, as opposed to undecided ones."""
+        return [v for v in self.verdicts if not v.holds and not v.error]
+
+    @property
+    def undecided(self):
+        """The verdicts whose condition could not be evaluated."""
+        return [v for v in self.verdicts if v.error]
+
+    def raise_for_error(self):
+        """Raise :class:`~opensysml.errors.ExecutionError` if any assertion could not be decided.
+
+        A verdict of false raises nothing: it is the model's answer.
+
+        Returns:
+            Validation: self, so a call can be chained
+        """
+        for verdict in self.undecided:
+            verdict.raise_for_error()
+        return self
+
+    def __bool__(self):
+        """Truthy when the object is valid."""
+        return self.valid
+
+    def __len__(self):
+        return len(self.verdicts)
+
+    def __iter__(self):
+        return iter(self.verdicts)
+
+    def __getitem__(self, index):
+        return self.verdicts[index]
+
+    def __str__(self):
+        lines = [v.explain() for v in self.verdicts]
+        if self.summary is not None:
+            lines.append(self.summary.explain())
+        return "\n".join(lines)
+
+    def __repr__(self):
+        return (
+            f"Validation(verdicts={len(self.verdicts)}, valid={self.valid!r}, "
+            f"bounded={self.bounded!r})"
+        )
+
+
 class CalcResult:
     """What a calculation computed.
 
diff --git a/clients/python/pyproject.toml b/client/python/pyproject.toml
similarity index 99%
rename from clients/python/pyproject.toml
rename to client/python/pyproject.toml
index 4fbbc29d83..c82b50a244 100644
--- a/clients/python/pyproject.toml
+++ b/client/python/pyproject.toml
@@ -67,7 +67,7 @@ opensysml-generate = "opensysml.generate:main"
 [project.urls]
 Homepage = "https://github.com/Open-MBEE/OpenSysML"
 Repository = "https://github.com/Open-MBEE/OpenSysML"
-Documentation = "https://github.com/Open-MBEE/OpenSysML/blob/main/clients/python/README.md"
+Documentation = "https://github.com/Open-MBEE/OpenSysML/blob/main/client/python/README.md"
 Changelog = "https://github.com/Open-MBEE/OpenSysML/blob/main/CHANGELOG.md"
 Issues = "https://github.com/Open-MBEE/OpenSysML/issues"
 
diff --git a/clients/python/scripts/bench_latency.py b/client/python/scripts/bench_latency.py
similarity index 97%
rename from clients/python/scripts/bench_latency.py
rename to client/python/scripts/bench_latency.py
index 15af22139b..f71ffc8041 100644
--- a/clients/python/scripts/bench_latency.py
+++ b/client/python/scripts/bench_latency.py
@@ -5,7 +5,7 @@
 for: a mean hides the tail, and the tail is what a deadline meets or misses.
 
 Usage:
-    python clients/python/scripts/bench_latency.py [--host H] [--port P] [--iterations N]
+    python client/python/scripts/bench_latency.py [--host H] [--port P] [--iterations N]
 
 The service must be reachable (``make build-grpc && bin/sysml-grpc``). Numbers
 are per-call round trips over a warm channel, so they include protobuf
diff --git a/clients/python/scripts/bench_transports.py b/client/python/scripts/bench_transports.py
similarity index 99%
rename from clients/python/scripts/bench_transports.py
rename to client/python/scripts/bench_transports.py
index 6babd8bd61..db7c963045 100644
--- a/clients/python/scripts/bench_transports.py
+++ b/client/python/scripts/bench_transports.py
@@ -11,7 +11,7 @@
 because the tail is what a deadline misses.
 
 Usage:
-    python clients/python/scripts/bench_transports.py [--iterations N] [--binary PATH]
+    python client/python/scripts/bench_transports.py [--iterations N] [--binary PATH]
 
 Requires ``make build-grpc``. The transports are evaluation prototypes; see
 docs/internals/design/transport-evaluation.md.
@@ -58,7 +58,7 @@
 # the same work.
 EVALUATE_EXPRESSION = "2 + 2"
 
-# stdio framing, as internal/stdiorpc writes it.
+# stdio framing, as internal/frontend/stdiorpc writes it.
 CONTENT_TYPE_JSON = "application/json"
 CONTENT_TYPE_PROTO = "application/proto"
 
diff --git a/client/python/scripts/check_version.py b/client/python/scripts/check_version.py
new file mode 100644
index 0000000000..fee2a43c1c
--- /dev/null
+++ b/client/python/scripts/check_version.py
@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+"""Gate a release on the core tag and the declared opensysml version agreeing.
+
+The package is published from the same `v<version>` tag as the binaries, so the
+version it declares must be the one the tag names. Used by the CircleCI release
+workflow before anything is built or uploaded: a PyPI version can be yanked but
+never re-uploaded, so a tag that does not name the version the package would
+publish must fail here rather than after the fact.
+
+    python scripts/check_version.py --tag v0.9.0
+
+Prints the version the tag names on success. With `--pre-release` it prints
+`yes`/`no` instead, which the job uses to route a pre-release tag to TestPyPI.
+
+The core tags are SemVer and the package version is PEP 440, so the tag is
+translated before the comparison: `v0.9.0-rc1` names `0.9.0rc1`. Only the SemVer
+pre-release forms with one PEP 440 meaning are accepted (`-alpha.N`, `-beta.N`,
+`-rc.N`, dot optional); `-1` would be a PEP 440 post-release, so it is refused.
+What is printed is the declared version, which the built artifacts are named by.
+"""
+
+import argparse
+import ast
+import os
+import re
+import sys
+
+from packaging.version import InvalidVersion, Version
+
+TAG_PREFIX = "v"
+
+# ASCII digits without leading zeros, as SemVer numeric identifiers are.
+_NUMBER = r"(?:0|[1-9][0-9]*)"
+TAG_VERSION = re.compile(
+    rf"^(?P<release>{_NUMBER}\.{_NUMBER}\.{_NUMBER})"
+    rf"(?:-(?P<phase>alpha|beta|rc)\.?(?P<number>{_NUMBER}))?$",
+    re.ASCII,
+)
+TAG_FORM = f"{TAG_PREFIX}<major>.<minor>.<patch>[-(alpha|beta|rc)[.]<n>]"
+PEP_440_PHASE = {"alpha": "a", "beta": "b", "rc": "rc"}
+
+VERSION_FILE = os.path.join(
+    os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "opensysml", "_version.py"
+)
+
+
+class VersionError(Exception):
+    """A tag that does not name a publishable opensysml version."""
+
+
+def declared_version(version_file=VERSION_FILE):
+    """The version declared in opensysml/_version.py.
+
+    Read rather than imported, so the check needs neither the package's
+    dependencies nor an installed distribution.
+
+    Args:
+        version_file (str): Path to opensysml/_version.py
+
+    Returns:
+        str: The declared version
+
+    Raises:
+        VersionError: If the file declares no VERSION string
+    """
+    with open(version_file, encoding="utf-8") as f:
+        module = ast.parse(f.read(), filename=version_file)
+    for node in module.body:
+        if isinstance(node, ast.Assign) and any(
+            isinstance(t, ast.Name) and t.id == "VERSION" for t in node.targets
+        ):
+            if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
+                return node.value.value
+    raise VersionError(f"{version_file} declares no VERSION string")
+
+
+def version_from_tag(tag, version=None):
+    """The version a release tag names, checked against the declared version.
+
+    Args:
+        tag (str): Core release tag, e.g. 'v0.9.0'
+        version (str, optional): Declared version; read from
+            opensysml/_version.py when omitted
+
+    Returns:
+        str: The version to publish, as declared
+
+    Raises:
+        VersionError: If the tag is empty, is not a core release tag, or names
+            a version other than the declared one; or if the declared version is
+            not PEP 440 in canonical form
+    """
+    declared = version if version is not None else declared_version()
+    canonical = str(parse_version(declared, "client/python/opensysml/_version.py declares"))
+    if canonical != declared:
+        raise VersionError(
+            f"client/python/opensysml/_version.py declares {declared!r}, whose "
+            f"canonical PEP 440 form is {canonical!r}. The built artifacts are named "
+            f"by the canonical form, so declare VERSION = {canonical!r}."
+        )
+    if not tag:
+        raise VersionError(
+            "No tag given. The release workflow runs on a "
+            f"{TAG_PREFIX}<version> tag and reads CIRCLE_TAG."
+        )
+    if not tag.startswith(TAG_PREFIX):
+        raise VersionError(
+            f"Tag {tag!r} does not start with {TAG_PREFIX!r}. The package is "
+            f"released with the binaries, by the core {TAG_PREFIX}<version> tag; "
+            "no other tag publishes it."
+        )
+    tag_version = pep440_from_semver(tag[len(TAG_PREFIX):], tag)
+    if tag_version != declared:
+        raise VersionError(
+            f"Tag {tag!r} names version {tag_version!r}, but "
+            f"client/python/opensysml/_version.py declares {declared!r}. "
+            "The package is released in lockstep with the core, so its declared "
+            "version must be the core version being tagged. Set VERSION to "
+            f"{tag_version!r} on the release branch and tag again."
+        )
+    return declared
+
+
+def pep440_from_semver(semver, tag):
+    """The canonical PEP 440 version a core tag's SemVer version denotes.
+
+    Args:
+        semver (str): The tag without its prefix, e.g. '0.9.0-rc1'
+        tag (str): The tag, for the error message
+
+    Returns:
+        str: The PEP 440 version, e.g. '0.9.0rc1'
+
+    Raises:
+        VersionError: If the version is not a release or an alpha/beta/rc
+            pre-release of one; other SemVer suffixes have no single PEP 440
+            meaning ('-1' would be a post-release, build metadata a local version)
+    """
+    match = TAG_VERSION.match(semver)
+    if match is None:
+        raise VersionError(
+            f"Tag {tag!r} is not a core release tag of the form {TAG_FORM}; only "
+            "those pre-release forms have one PEP 440 meaning for the package."
+        )
+    if match["phase"] is None:
+        return match["release"]
+    return f"{match['release']}{PEP_440_PHASE[match['phase']]}{match['number']}"
+
+
+def parse_version(version, what):
+    """A version string as a PEP 440 version.
+
+    Args:
+        version (str): Version string
+        what (str): What names the version, for the error message
+
+    Returns:
+        packaging.version.Version: The parsed version; str() of it is canonical
+
+    Raises:
+        VersionError: If the version is not a valid PEP 440 version
+    """
+    try:
+        return Version(version)
+    except InvalidVersion as e:
+        raise VersionError(f"{what} {version!r}, which is not a PEP 440 version: {e}")
+
+
+def is_pre_release(version):
+    """Whether a version is a PEP 440 pre-release (alpha/beta/rc).
+
+    Args:
+        version (str): Version string
+
+    Returns:
+        bool: True for a pre-release, which is published to TestPyPI
+
+    Raises:
+        VersionError: If the version is not a valid PEP 440 version
+    """
+    return parse_version(version, "Version").is_prerelease
+
+
+def main(argv=None):
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--tag",
+        default=os.environ.get("CIRCLE_TAG", ""),
+        help="release tag (default: $CIRCLE_TAG)",
+    )
+    parser.add_argument(
+        "--pre-release",
+        action="store_true",
+        help="print yes/no for whether the tag names a pre-release",
+    )
+    args = parser.parse_args(argv)
+
+    try:
+        version = version_from_tag(args.tag)
+        pre_release = is_pre_release(version)
+    except VersionError as e:
+        print(f"error: {e}", file=sys.stderr)
+        return 1
+
+    if args.pre_release:
+        print("yes" if pre_release else "no")
+    else:
+        print(version)
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/clients/python/scripts/make_signed_release_fixture.py b/client/python/scripts/make_signed_release_fixture.py
similarity index 98%
rename from clients/python/scripts/make_signed_release_fixture.py
rename to client/python/scripts/make_signed_release_fixture.py
index 52601ab524..e45477c397 100644
--- a/clients/python/scripts/make_signed_release_fixture.py
+++ b/client/python/scripts/make_signed_release_fixture.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-"""Record the signed-release fixtures clients/python/tests/test_signing.py verifies against.
+"""Record the signed-release fixtures client/python/tests/test_signing.py verifies against.
 
 The client verifies the sigstore bundle a release publishes for its
 SHA256SUMS.txt, so its tests need bundles — and they must not reach the network,
@@ -15,9 +15,9 @@
 
 Run it to re-record the fixtures after changing what they must contain:
 
-    python clients/python/scripts/make_signed_release_fixture.py
-    python clients/python/scripts/make_signed_release_fixture.py \
-        --out clients/node/test/fixtures/signed_release
+    python client/python/scripts/make_signed_release_fixture.py
+    python client/python/scripts/make_signed_release_fixture.py \
+        --out client/node/test/fixtures/signed_release
 
 The fixtures are committed, so this is not run by the test suite.
 """
diff --git a/clients/python/scripts/measure_private_service.py b/client/python/scripts/measure_private_service.py
similarity index 98%
rename from clients/python/scripts/measure_private_service.py
rename to client/python/scripts/measure_private_service.py
index 701e0bf67c..3089d76b0b 100644
--- a/clients/python/scripts/measure_private_service.py
+++ b/client/python/scripts/measure_private_service.py
@@ -3,7 +3,7 @@
 Reports the cold start of the first connection, the cost of the connections that
 join it, and what a child per connection would have cost instead.
 
-Usage: python3 clients/python/scripts/measure_private_service.py [samples]
+Usage: python3 client/python/scripts/measure_private_service.py [samples]
 """
 
 import statistics
diff --git a/clients/python/scripts/pin_release_checksums.py b/client/python/scripts/pin_release_checksums.py
similarity index 96%
rename from clients/python/scripts/pin_release_checksums.py
rename to client/python/scripts/pin_release_checksums.py
index 51bf74ec77..dab21b61a3 100644
--- a/clients/python/scripts/pin_release_checksums.py
+++ b/client/python/scripts/pin_release_checksums.py
@@ -17,7 +17,7 @@
 already pinned and fails on any disagreement, so a republished release is caught
 without changing the table.
 
-The table lives in clients/release-digests.json, and `--write` syncs it into
+The table lives in client/release-digests.json, and `--write` syncs it into
 every client that ships a copy (scripts/sync-release-digests.py).
 """
 
@@ -33,7 +33,7 @@
 REPO_ROOT = os.path.dirname(
     os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 )
-DIGESTS_FILE = os.path.join(REPO_ROOT, "clients", "release-digests.json")
+DIGESTS_FILE = os.path.join(REPO_ROOT, "client", "release-digests.json")
 SYNC_SCRIPT = os.path.join(REPO_ROOT, "scripts", "sync-release-digests.py")
 DEFAULT_REPO = "Open-MBEE/OpenSysML"
 ASSET_PREFIX = "sysml-grpc-"
@@ -94,7 +94,7 @@ def pinned_table(digests_file=None):
     """The digests the clients currently pin.
 
     Args:
-        digests_file (str, optional): Path to clients/release-digests.json
+        digests_file (str, optional): Path to client/release-digests.json
 
     Returns:
         dict: repo -> version -> asset -> digest
@@ -226,7 +226,7 @@ def write_table(table, digests_file=None):
 
     Args:
         table (dict): repo -> version -> asset -> digest
-        digests_file (str, optional): Path to clients/release-digests.json
+        digests_file (str, optional): Path to client/release-digests.json
 
     Raises:
         PinError: If the clients' copies cannot be rewritten
@@ -283,7 +283,7 @@ def main(argv=None):
     parser.add_argument(
         "--write",
         action="store_true",
-        help="rewrite clients/release-digests.json instead of printing the table",
+        help="rewrite client/release-digests.json instead of printing the table",
     )
     parser.add_argument(
         "--check",
diff --git a/clients/python/tests/__init__.py b/client/python/tests/__init__.py
similarity index 100%
rename from clients/python/tests/__init__.py
rename to client/python/tests/__init__.py
diff --git a/clients/python/tests/conftest.py b/client/python/tests/conftest.py
similarity index 100%
rename from clients/python/tests/conftest.py
rename to client/python/tests/conftest.py
diff --git a/clients/python/tests/fixtures/signed_release/SHA256SUMS.txt b/client/python/tests/fixtures/signed_release/SHA256SUMS.txt
similarity index 100%
rename from clients/python/tests/fixtures/signed_release/SHA256SUMS.txt
rename to client/python/tests/fixtures/signed_release/SHA256SUMS.txt
diff --git a/clients/python/tests/fixtures/signed_release/SHA256SUMS.txt.bundle b/client/python/tests/fixtures/signed_release/SHA256SUMS.txt.bundle
similarity index 100%
rename from clients/python/tests/fixtures/signed_release/SHA256SUMS.txt.bundle
rename to client/python/tests/fixtures/signed_release/SHA256SUMS.txt.bundle
diff --git a/clients/python/tests/fixtures/signed_release/SHA256SUMS.txt.expired.bundle b/client/python/tests/fixtures/signed_release/SHA256SUMS.txt.expired.bundle
similarity index 100%
rename from clients/python/tests/fixtures/signed_release/SHA256SUMS.txt.expired.bundle
rename to client/python/tests/fixtures/signed_release/SHA256SUMS.txt.expired.bundle
diff --git a/clients/python/tests/fixtures/signed_release/SHA256SUMS.txt.other-identity.bundle b/client/python/tests/fixtures/signed_release/SHA256SUMS.txt.other-identity.bundle
similarity index 100%
rename from clients/python/tests/fixtures/signed_release/SHA256SUMS.txt.other-identity.bundle
rename to client/python/tests/fixtures/signed_release/SHA256SUMS.txt.other-identity.bundle
diff --git a/clients/python/tests/fixtures/signed_release/identity.json b/client/python/tests/fixtures/signed_release/identity.json
similarity index 100%
rename from clients/python/tests/fixtures/signed_release/identity.json
rename to client/python/tests/fixtures/signed_release/identity.json
diff --git a/clients/python/tests/fixtures/signed_release/sysml-grpc-linux-amd64 b/client/python/tests/fixtures/signed_release/sysml-grpc-linux-amd64
similarity index 100%
rename from clients/python/tests/fixtures/signed_release/sysml-grpc-linux-amd64
rename to client/python/tests/fixtures/signed_release/sysml-grpc-linux-amd64
diff --git a/clients/python/tests/fixtures/signed_release/trusted_root.json b/client/python/tests/fixtures/signed_release/trusted_root.json
similarity index 100%
rename from clients/python/tests/fixtures/signed_release/trusted_root.json
rename to client/python/tests/fixtures/signed_release/trusted_root.json
diff --git a/clients/python/tests/golden/vehicle_types.py b/client/python/tests/golden/vehicle_types.py
similarity index 100%
rename from clients/python/tests/golden/vehicle_types.py
rename to client/python/tests/golden/vehicle_types.py
diff --git a/clients/python/tests/service_gate.py b/client/python/tests/service_gate.py
similarity index 100%
rename from clients/python/tests/service_gate.py
rename to client/python/tests/service_gate.py
diff --git a/clients/python/tests/test_analysis_integration.py b/client/python/tests/test_analysis_integration.py
similarity index 100%
rename from clients/python/tests/test_analysis_integration.py
rename to client/python/tests/test_analysis_integration.py
diff --git a/clients/python/tests/test_api.py b/client/python/tests/test_api.py
similarity index 100%
rename from clients/python/tests/test_api.py
rename to client/python/tests/test_api.py
diff --git a/clients/python/tests/test_binary.py b/client/python/tests/test_binary.py
similarity index 100%
rename from clients/python/tests/test_binary.py
rename to client/python/tests/test_binary.py
diff --git a/clients/python/tests/test_capabilities.py b/client/python/tests/test_capabilities.py
similarity index 100%
rename from clients/python/tests/test_capabilities.py
rename to client/python/tests/test_capabilities.py
diff --git a/client/python/tests/test_check_version.py b/client/python/tests/test_check_version.py
new file mode 100644
index 0000000000..86ef8c14b5
--- /dev/null
+++ b/client/python/tests/test_check_version.py
@@ -0,0 +1,156 @@
+"""Tests for scripts/check_version.py, the release gate the release workflow runs."""
+
+import importlib.util
+import pathlib
+
+import pytest
+from packaging.version import Version
+
+SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "check_version.py"
+spec = importlib.util.spec_from_file_location("check_version", SCRIPT)
+check_version = importlib.util.module_from_spec(spec)
+assert spec.loader is not None
+spec.loader.exec_module(check_version)
+
+
+def _core_tag(version):
+    """The SemVer core tag naming a PEP 440 version: 0.5.0rc1 is tagged v0.5.0-rc1."""
+    parsed = Version(version)
+    tag = f"v{parsed.base_version}"
+    if parsed.pre is not None:
+        phase, number = parsed.pre
+        tag += f"-{ {'a': 'alpha', 'b': 'beta', 'rc': 'rc'}[phase] }.{number}"
+    return tag
+
+
+def test_declared_version_reads_the_shipped_version_file():
+    from opensysml import __version__
+
+    assert check_version.declared_version() == __version__
+
+
+def test_declared_version_rejects_a_file_without_a_version(tmp_path):
+    version_file = tmp_path / "_version.py"
+    version_file.write_text("OTHER = '1.0'\n", encoding="utf-8")
+    with pytest.raises(check_version.VersionError, match="declares no VERSION"):
+        check_version.declared_version(str(version_file))
+
+
+def test_version_from_tag_accepts_the_declared_version():
+    assert check_version.version_from_tag("v0.4.0", version="0.4.0") == "0.4.0"
+
+
+@pytest.mark.parametrize(
+    "tag, declared",
+    [
+        ("v0.4.0-rc1", "0.4.0rc1"),
+        ("v0.4.0-rc.1", "0.4.0rc1"),
+        ("v0.4.0-alpha.2", "0.4.0a2"),
+        ("v0.4.0-beta1", "0.4.0b1"),
+        ("v0.4.0-rc.10", "0.4.0rc10"),
+        ("v10.0.0-rc0", "10.0.0rc0"),
+    ],
+)
+def test_version_from_tag_translates_a_semver_pre_release_to_pep_440(tag, declared):
+    """A core pre-release tag is SemVer; the package's version is its PEP 440 form."""
+    assert check_version.version_from_tag(tag, version=declared) == declared
+    assert check_version.is_pre_release(declared)
+
+
+@pytest.mark.parametrize(
+    "tag",
+    [
+        "v0.4.0-1",  # PEP 440 would read a post-release, SemVer a pre-release
+        "v0.4.0-rc",
+        "v0.4.0-pre.1",
+        "v0.4.0-rc1+build.5",
+        "v0.4.0rc1",
+        "v0.4.0.post1",
+        "v0.4",
+        "v0.4.0.1",
+        "v0.4.0-not-a-version",
+        "v0.4.0-rc.010",  # leading zero: not a SemVer numeric identifier
+        "v0.4.0-rc010",
+        "v0.04.0",
+        "v0.4.0-rc\u0661",  # Arabic-Indic one: a digit to \\d, not to SemVer
+        "v\u0660.4.0",
+    ],
+)
+def test_version_from_tag_rejects_a_suffix_without_one_pep_440_meaning(tag):
+    with pytest.raises(check_version.VersionError, match="is not a core release tag of the form"):
+        check_version.version_from_tag(tag, version="0.4.0.post1")
+
+
+@pytest.mark.parametrize(
+    "tag, message",
+    [
+        ("", "No tag given"),
+        ("opensysml-v0.4.0", "does not start with 'v'"),
+        ("0.4.0", "does not start with 'v'"),
+        ("v0.4.1", "names version '0.4.1', but"),
+        ("v0.4.0-rc1", "names version '0.4.0rc1', but"),
+    ],
+)
+def test_version_from_tag_rejects_a_tag_that_names_another_version(tag, message):
+    with pytest.raises(check_version.VersionError, match=message):
+        check_version.version_from_tag(tag, version="0.4.0")
+
+
+@pytest.mark.parametrize("declared", ["0.4.0-rc1", "0.4.0.RC1", "v0.4.0"])
+def test_version_from_tag_requires_the_declared_version_to_be_canonical(declared):
+    """The artifacts are named by the canonical form, so the declaration must be it."""
+    with pytest.raises(check_version.VersionError, match="canonical PEP 440 form"):
+        check_version.version_from_tag("v0.4.0-rc1", version=declared)
+
+
+def test_version_from_tag_rejects_a_declared_non_version():
+    with pytest.raises(check_version.VersionError, match="not a PEP 440 version"):
+        check_version.version_from_tag("v0.4.0", version="latest")
+
+
+@pytest.mark.parametrize(
+    "version, pre_release",
+    [("0.4.0", False), ("0.4.0rc1", True), ("1.0.0a2", True), ("1.0.0.post1", False)],
+)
+def test_is_pre_release_follows_pep_440(version, pre_release):
+    assert check_version.is_pre_release(version) is pre_release
+
+
+def test_is_pre_release_rejects_a_non_pep_440_version():
+    with pytest.raises(check_version.VersionError, match="not a PEP 440 version"):
+        check_version.is_pre_release("latest")
+
+
+def test_main_prints_the_version_the_tag_names(capsys):
+    declared = check_version.declared_version()
+    assert check_version.main(["--tag", _core_tag(declared)]) == 0
+    assert capsys.readouterr().out.strip() == declared
+
+
+def test_main_routes_a_pre_release_by_printing_yes_or_no(capsys):
+    declared = check_version.declared_version()
+    assert check_version.main(["--tag", _core_tag(declared), "--pre-release"]) == 0
+    expected = "yes" if check_version.is_pre_release(declared) else "no"
+    assert capsys.readouterr().out.strip() == expected
+
+
+def test_main_fails_on_a_tag_for_another_version(capsys):
+    assert check_version.main(["--tag", "v0.0.0"]) == 1
+    captured = capsys.readouterr()
+    assert captured.out == ""
+    assert "error:" in captured.err
+    assert "declares" in captured.err
+
+
+def test_main_fails_on_a_tag_that_names_no_version(capsys):
+    assert check_version.main(["--tag", "v0.0.0-not-declared"]) == 1
+    captured = capsys.readouterr()
+    assert captured.out == ""
+    assert "is not a core release tag of the form" in captured.err
+
+
+def test_main_reads_the_tag_from_circle_tag(monkeypatch, capsys):
+    declared = check_version.declared_version()
+    monkeypatch.setenv("CIRCLE_TAG", _core_tag(declared))
+    assert check_version.main([]) == 0
+    assert capsys.readouterr().out.strip() == declared
diff --git a/clients/python/tests/test_complex.py b/client/python/tests/test_complex.py
similarity index 100%
rename from clients/python/tests/test_complex.py
rename to client/python/tests/test_complex.py
diff --git a/clients/python/tests/test_connection.py b/client/python/tests/test_connection.py
similarity index 100%
rename from clients/python/tests/test_connection.py
rename to client/python/tests/test_connection.py
diff --git a/clients/python/tests/test_conversion.py b/client/python/tests/test_conversion.py
similarity index 98%
rename from clients/python/tests/test_conversion.py
rename to client/python/tests/test_conversion.py
index 8a44982647..fbc689e55c 100644
--- a/clients/python/tests/test_conversion.py
+++ b/client/python/tests/test_conversion.py
@@ -19,6 +19,7 @@
 from opensysml.capabilities import CAPABILITY_CONVERT, MissingCapabilityError
 from opensysml.connection import Connection
 from opensysml.conversion import (
+    FORMAT_API_JSON,
     FORMAT_SYSML,
     FORMAT_TURTLE,
     ExperimentalFeatureWarning,
@@ -121,8 +122,9 @@ def test_format_of_path_infers_and_refuses():
     assert format_of_path("model.KerML") == FORMAT_SYSML
     assert format_of_path("model.ttl") == FORMAT_TURTLE
     assert format_of_path("model.turtle") == FORMAT_TURTLE
+    assert format_of_path("model.json") == FORMAT_API_JSON
     with pytest.raises(ValueError, match="cannot tell the format"):
-        format_of_path("model.json")
+        format_of_path("model.bak")
 
 
 def test_is_experimental_names_the_rdf_mapping():
@@ -130,6 +132,8 @@ def test_is_experimental_names_the_rdf_mapping():
     assert is_experimental(FORMAT_SYSML, FORMAT_TURTLE)
     assert is_experimental(FORMAT_TURTLE, FORMAT_SYSML)
     assert is_experimental("turtle", "rdf")
+    assert is_experimental(FORMAT_API_JSON, FORMAT_SYSML)
+    assert is_experimental(FORMAT_SYSML, "json")
     assert is_experimental("xmi", FORMAT_SYSML)
     assert is_experimental("uml", FORMAT_TURTLE)
     assert is_experimental("mdzip", FORMAT_TURTLE)
@@ -284,7 +288,7 @@ def test_conversion_writes_a_file(fake_service, tmp_path):
 def test_saving_an_unknown_extension_is_refused(fake_service, tmp_path):
     """An extension naming no format is refused before anything is written."""
     port, _ = fake_service()
-    out = tmp_path / "out.json"
+    out = tmp_path / "out.bak"
     with Connection(port=port, auto_start=False) as conn:
         model = conn.load_from_content(MODEL)
         with pytest.raises(ValueError, match="cannot tell the format"):
diff --git a/clients/python/tests/test_diagnostic.py b/client/python/tests/test_diagnostic.py
similarity index 100%
rename from clients/python/tests/test_diagnostic.py
rename to client/python/tests/test_diagnostic.py
diff --git a/client/python/tests/test_document.py b/client/python/tests/test_document.py
new file mode 100644
index 0000000000..742ff65ad0
--- /dev/null
+++ b/client/python/tests/test_document.py
@@ -0,0 +1,914 @@
+"""Tests for named document queries and document rendering.
+
+Two layers, as :mod:`tests.test_query` has. Against a fake service, the
+client's own behavior: binding translation, decoding, and the capability
+gates. Against the real ``sysml-grpc`` binary, the answers themselves — the
+typed rows and the rendered Markdown.
+"""
+
+import os
+import subprocess
+import time
+from concurrent import futures
+
+import grpc
+import pytest
+
+from opensysml.capabilities import (
+    CAPABILITY_DOCUMENT_QUERY,
+    CAPABILITY_RENDER_DOCUMENT,
+    CAPABILITY_RENDER_DOCUMENT_HTML,
+    MissingCapabilityError,
+)
+from opensysml.connection import Connection
+from opensysml.document import (
+    INFINITY,
+    DocumentQueryError,
+    DocumentEvent,
+    DocumentQueryResult,
+    DocumentRow,
+    DocumentState,
+    DocumentVerdict,
+    ElementRef,
+    ObjectRef,
+    build_bindings,
+)
+from opensysml.errors import (
+    InvalidRequestError,
+    ModelNotFoundError,
+    SymbolNotFoundError,
+    UnsupportedValueError,
+)
+from opensysml.proto import sysml_pb2, sysml_pb2_grpc
+from opensysml.values import Quantity, Unit, UnitFactor
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+GRPC_BINARIES = (
+    os.path.join(REPO_ROOT, "bin", "sysml-grpc"),
+    os.path.join(os.path.expanduser("~"), ".opensysml", "bin", "sysml-grpc"),
+)
+
+#: The document pipeline's own telescope fixture and its golden Markdown, so
+#: the client sees exactly what the renderer's tests lock in.
+FIXTURE = os.path.join(
+    REPO_ROOT, "internal", "doc", "docrender", "testdata", "telescope_report.sysml"
+)
+GOLDEN = os.path.join(
+    REPO_ROOT, "internal", "doc", "docrender", "testdata", "telescope_report.golden.md"
+)
+HTML_GOLDEN = os.path.join(
+    REPO_ROOT, "internal", "doc", "docrender", "testdata", "telescope_report.golden.html"
+)
+#: The renderer's verdict fixture: assertions on a car and queries over them.
+VERDICT_FIXTURE = os.path.join(
+    REPO_ROOT, "internal", "doc", "docrender", "testdata", "verdict_report.sysml"
+)
+#: The service's object fixture: a car with wheels, a spare wheel, and
+#: queries over the objects the service holds — bound, enumerated, checked.
+OBJECT_FIXTURE = os.path.join(
+    REPO_ROOT, "tests", "grpc", "testdata", "conformance",
+    "document_query_object_by_path.sysml",
+)
+#: The service's state fixture: a lamp exhibiting a state machine with an
+#: orthogonal region, and queries over its states and its trace.
+STATE_FIXTURE = os.path.join(
+    REPO_ROOT, "tests", "grpc", "testdata", "conformance",
+    "document_query_states.sysml",
+)
+
+CAPABILITIES = (
+    CAPABILITY_DOCUMENT_QUERY, CAPABILITY_RENDER_DOCUMENT, CAPABILITY_RENDER_DOCUMENT_HTML,
+)
+
+
+class FakeService(sysml_pb2_grpc.SysMLServiceServicer):
+    """A sysml-grpc whose document RPCs record requests and answer as told."""
+
+    def __init__(self, capabilities=CAPABILITIES, response=None, markdown="", html=""):
+        self._capabilities = list(capabilities)
+        self._response = response or sysml_pb2.RunDocumentQueryResponse()
+        self._markdown = markdown
+        self._html = html
+        self.requests = []
+
+    def GetServerInfo(self, request, context):
+        return sysml_pb2.ServerInfoResponse(
+            version="fake", capabilities=self._capabilities
+        )
+
+    def GetDiagnostics(self, request, context):
+        context.abort(grpc.StatusCode.NOT_FOUND, "model not found")
+
+    def ParseFile(self, request, context):
+        root = sysml_pb2.SymbolInfo(id="Demo", name="Demo", kind="Package")
+        return sysml_pb2.ParseFileResponse(model_hash="fake-hash", root=root)
+
+    def RunDocumentQuery(self, request, context):
+        self.requests.append(request)
+        return self._response
+
+    def RenderDocument(self, request, context):
+        self.requests.append(request)
+        if request.form == "html":
+            return sysml_pb2.RenderDocumentResponse(html=self._html)
+        return sysml_pb2.RenderDocumentResponse(markdown=self._markdown)
+
+
+@pytest.fixture
+def fake_service():
+    """Start a FakeService on an ephemeral port; yields a factory."""
+    servers = []
+
+    def start(**kwargs):
+        service = FakeService(**kwargs)
+        server = grpc.server(futures.ThreadPoolExecutor(max_workers=2))
+        sysml_pb2_grpc.add_SysMLServiceServicer_to_server(service, server)
+        port = server.add_insecure_port("localhost:0")
+        server.start()
+        servers.append(server)
+        return port, service
+
+    yield start
+    for server in servers:
+        server.stop(None)
+
+
+def test_bindings_translate_to_typed_values():
+    """Each Python kind becomes its own wire kind, bool before int."""
+    bindings = build_bindings({
+        "root": ElementRef("Observatory::telescope"),
+        "name": "mount",
+        "count": 3,
+        "mass": 1.5,
+        "heavy": True,
+        "several": ["a", "b"],
+    })
+    by_parameter = {b.parameter: b for b in bindings}
+    assert by_parameter["root"].values[0].element_id == "Observatory::telescope"
+    assert by_parameter["name"].values[0].string_value == "mount"
+    assert by_parameter["count"].values[0].int_value == 3
+    assert by_parameter["mass"].values[0].real_value == 1.5
+    assert by_parameter["heavy"].values[0].bool_value is True
+    assert [v.string_value for v in by_parameter["several"].values] == ["a", "b"]
+
+
+def test_a_quantity_binding_keeps_its_magnitude_and_unit():
+    """A Quantity binds as the wire's Quantity, unit and reduction intact."""
+    kg = Unit(text="kg", factors=(UnitFactor("SI::kg", 1),), reduction_given=True)
+    bindings = build_bindings({"limit": Quantity(2290000, kg)})
+    (value,) = bindings[0].values
+    assert value.WhichOneof("kind") == "quantity"
+    assert value.quantity.int_magnitude == 2290000
+    assert value.quantity.unit == "kg"
+    assert value.quantity.unit_term.factors[0].unit_id == "SI::kg"
+
+
+def test_a_binding_the_wire_cannot_carry_is_refused():
+    """An untranslatable value is a caller error, named before anything is sent."""
+    with pytest.raises(DocumentQueryError, match="'root'"):
+        build_bindings({"root": object()})
+
+
+def test_a_verdict_binding_is_refused():
+    """A verdict is answered by queries; binding one is a caller error."""
+    verdict = DocumentVerdict(
+        assertion=ElementRef("Garage::Car::massOk"), kind="constraint",
+        text="assert constraint massOk", path="Garage::car", status="holds",
+    )
+    with pytest.raises(DocumentQueryError, match="'root'.*answered by queries"):
+        build_bindings({"root": verdict})
+
+
+def test_a_state_or_event_binding_is_refused():
+    """States and events are answered by queries; binding one is a caller error."""
+    lamp = ObjectRef(id=1, path="Lamps::lamp")
+    state = DocumentState(object=lamp, machine="lp", name="off", path="off")
+    event = DocumentEvent(kind="entry", time=0, text="enter: off")
+    with pytest.raises(DocumentQueryError, match="'root'.*a state row is answered by queries"):
+        build_bindings({"root": state})
+    with pytest.raises(DocumentQueryError, match="'root'.*an event row is answered by queries"):
+        build_bindings({"root": event})
+
+
+def test_an_object_binds_by_id_by_path_or_both():
+    """An ObjectRef binds the object the service holds, by id, path or both."""
+    bindings = build_bindings({
+        "by_id": ObjectRef(id=2),
+        "by_path": ObjectRef(path="car.wheels[2]"),
+        "both": ObjectRef(id=3, path="Garage::car.wheels[2]"),
+    })
+    by_parameter = {b.parameter: b for b in bindings}
+    (by_id,) = by_parameter["by_id"].values
+    assert by_id.WhichOneof("kind") == "object"
+    assert (by_id.object.instance_id, by_id.object.path) == (2, "")
+    (by_path,) = by_parameter["by_path"].values
+    assert (by_path.object.instance_id, by_path.object.path) == (0, "car.wheels[2]")
+    (both,) = by_parameter["both"].values
+    assert (both.object.instance_id, both.object.path) == (3, "Garage::car.wheels[2]")
+    assert not both.object.HasField("element")
+
+
+def test_an_object_naming_nothing_is_refused():
+    """An ObjectRef with neither id nor path is a caller error, named early."""
+    unnamed = {"root": ObjectRef()}
+    with pytest.raises(DocumentQueryError, match="'root'.*neither was given"):
+        build_bindings(unnamed)
+
+
+def test_an_oversized_int_binding_is_refused():
+    """An int outside int64 is a caller error, not a protobuf ValueError."""
+    with pytest.raises(DocumentQueryError, match="signed 64-bit"):
+        build_bindings({"threshold": 1 << 63})
+    with pytest.raises(DocumentQueryError, match="signed 64-bit"):
+        build_bindings({"threshold": -(1 << 63) - 1})
+
+
+def test_a_quantity_the_wire_cannot_carry_is_refused():
+    """An unreduced unit or an oversized magnitude is a DocumentQueryError, not
+    the Quantity's own UnsupportedValueError or a protobuf ValueError."""
+    kg = Unit(text="kg", factors=(UnitFactor("SI::kg", 1),), reduction_given=True)
+    unreduced = {"limit": Quantity(1, Unit(text="furlong"))}
+    with pytest.raises(DocumentQueryError, match="'limit'.*no reduction") as caught:
+        build_bindings(unreduced)
+    assert isinstance(caught.value.__cause__, UnsupportedValueError)
+    oversized = {"limit": Quantity(1 << 63, kg)}
+    with pytest.raises(DocumentQueryError, match="'limit'.*signed 64-bit"):
+        build_bindings(oversized)
+    boolean = {"limit": Quantity(True, kg)}
+    with pytest.raises(DocumentQueryError, match="'limit'.*neither an Integer nor a Real"):
+        build_bindings(boolean)
+
+
+def test_no_bindings_is_an_empty_request():
+    assert build_bindings(None) == []
+    assert build_bindings({}) == []
+
+
+def test_document_query_requires_the_capability(fake_service):
+    """A service that cannot run document queries is named, not asked."""
+    port, service = fake_service(capabilities=())
+    with Connection(port=port, auto_start=False) as conn:
+        model = conn.load_from_content("package Demo;")
+        with pytest.raises(MissingCapabilityError) as excinfo:
+            model.run_document_query("Demo::Q")
+    assert excinfo.value.capability == CAPABILITY_DOCUMENT_QUERY
+    assert service.requests == []
+
+
+def test_render_document_requires_the_capability(fake_service):
+    """A service that cannot render documents is named, not asked."""
+    port, service = fake_service(capabilities=())
+    with Connection(port=port, auto_start=False) as conn:
+        model = conn.load_from_content("package Demo;")
+        with pytest.raises(MissingCapabilityError) as excinfo:
+            model.render_document("Demo::Doc")
+    assert excinfo.value.capability == CAPABILITY_RENDER_DOCUMENT
+    assert service.requests == []
+
+
+def test_render_document_html_requires_its_own_capability(fake_service):
+    """A service that renders Markdown only is not asked for HTML; Markdown still works."""
+    port, service = fake_service(
+        capabilities=(CAPABILITY_DOCUMENT_QUERY, CAPABILITY_RENDER_DOCUMENT), markdown="# R\n"
+    )
+    with Connection(port=port, auto_start=False) as conn:
+        model = conn.load_from_content("package Demo;")
+        with pytest.raises(MissingCapabilityError) as excinfo:
+            model.render_document("Demo::Doc", form="html")
+        assert excinfo.value.capability == CAPABILITY_RENDER_DOCUMENT_HTML
+        assert service.requests == []
+        assert model.render_document("Demo::Doc") == "# R\n"
+
+
+def test_render_document_refuses_a_form_it_does_not_know(fake_service):
+    port, service = fake_service()
+    with Connection(port=port, auto_start=False) as conn:
+        model = conn.load_from_content("package Demo;")
+        with pytest.raises(ValueError, match="markdown"):
+            model.render_document("Demo::Doc", form="pdf")
+    assert service.requests == []
+
+
+def test_the_request_names_the_model_query_and_bindings(fake_service):
+    """The request carries the model's own hash, the query, and the bindings."""
+    port, service = fake_service()
+    with Connection(port=port, auto_start=False) as conn:
+        model = conn.load_from_content("package Demo;")
+        model.run_document_query(
+            "Demo::Q", bindings={"root": ElementRef("Demo::part")}
+        )
+    (request,) = service.requests
+    assert request.model_hash == model.hash
+    assert request.query_id == "Demo::Q"
+    assert request.bindings[0].parameter == "root"
+    assert request.bindings[0].values[0].element_id == "Demo::part"
+
+
+def test_answered_rows_decode_to_typed_records(fake_service):
+    """Every wire kind decodes to its Python value, elements with their type."""
+    response = sysml_pb2.RunDocumentQueryResponse(
+        columns=[
+            sysml_pb2.DocumentQueryColumn(name="name"),
+            sysml_pb2.DocumentQueryColumn(name="bound"),
+        ],
+        rows=[sysml_pb2.DocumentQueryRow(
+            element=sysml_pb2.DocumentValue(
+                element_id="Demo::part", element_type="PartUsage"
+            ),
+            cells=[
+                sysml_pb2.DocumentQueryCell(values=[
+                    sysml_pb2.DocumentValue(string_value="part"),
+                ]),
+                sysml_pb2.DocumentQueryCell(values=[
+                    sysml_pb2.DocumentValue(int_value=0),
+                    sysml_pb2.DocumentValue(infinity=True),
+                    sysml_pb2.DocumentValue(real_value=1.5),
+                    sysml_pb2.DocumentValue(bool_value=True),
+                    sysml_pb2.DocumentValue(quantity=sysml_pb2.Quantity(
+                        int_magnitude=2290000,
+                        unit="kg",
+                        unit_term=sysml_pb2.UnitTerm(
+                            scale_num=1.0,
+                            scale_den=1.0,
+                            factors=[sysml_pb2.UnitFactor(unit_id="SI::kg", exponent=1)],
+                        ),
+                    )),
+                ]),
+            ],
+        )],
+    )
+    port, _ = fake_service(response=response)
+    with Connection(port=port, auto_start=False) as conn:
+        result = conn.load_from_content("package Demo;").run_document_query("Demo::Q")
+
+    assert isinstance(result, DocumentQueryResult)
+    assert result.columns == ("name", "bound")
+    (row,) = result
+    assert isinstance(row, DocumentRow)
+    assert row.element == ElementRef(id="Demo::part", type="PartUsage")
+    assert str(row.element) == "Demo::part (PartUsage)"
+    assert row[0] == ("part",)
+    kg = Unit(text="kg", factors=(UnitFactor("SI::kg", 1),), reduction_given=True)
+    assert row[1] == (0, INFINITY, 1.5, True, Quantity(2290000, kg))
+    assert str(row[1][4]) == "2290000 [kg]"
+    assert len(result) == 1
+
+
+def test_a_verdict_row_decodes_to_its_assertion_and_verdict(fake_service):
+    """A row a Verdicts query answered stands for the assertion it checked
+    and carries the verdict; a verdict-valued cell decodes the same way."""
+    wire = sysml_pb2.DocumentVerdict(
+        assertion=sysml_pb2.DocumentValue(
+            element_id="Garage::Engine::powerLow", element_type="ConstraintUsage"
+        ),
+        kind="constraint",
+        text="assert constraint powerLow",
+        path="Garage::car.engine",
+        verdict="violated",
+        condition="power < 200.0",
+        reason="power < 200.0 is false",
+        verification=["pass"],
+    )
+    response = sysml_pb2.RunDocumentQueryResponse(
+        columns=[sysml_pb2.DocumentQueryColumn(name="self")],
+        rows=[sysml_pb2.DocumentQueryRow(
+            element=sysml_pb2.DocumentValue(verdict=wire),
+            cells=[sysml_pb2.DocumentQueryCell(values=[
+                sysml_pb2.DocumentValue(verdict=wire),
+            ])],
+        )],
+    )
+    port, _ = fake_service(response=response)
+    with Connection(port=port, auto_start=False) as conn:
+        result = conn.load_from_content("package Demo;").run_document_query("Demo::Q")
+
+    (row,) = result
+    expected = DocumentVerdict(
+        assertion=ElementRef(id="Garage::Engine::powerLow", type="ConstraintUsage"),
+        kind="constraint",
+        text="assert constraint powerLow",
+        path="Garage::car.engine",
+        status="violated",
+        condition="power < 200.0",
+        reason="power < 200.0 is false",
+        verification=("pass",),
+    )
+    assert row.verdict == expected
+    assert row.element == expected.assertion
+    assert row[0] == (expected,)
+    assert str(row.verdict) == "assert constraint powerLow on Garage::car.engine: violated"
+
+
+def test_an_object_row_decodes_to_the_object_and_its_usage(fake_service):
+    """A row over held objects stands for the usage the object is held under
+    and carries the object; an object-valued cell decodes the same way."""
+    def wire(instance_id, path, usage):
+        return sysml_pb2.DocumentValue(object=sysml_pb2.DocumentObject(
+            instance_id=instance_id,
+            path=path,
+            element=sysml_pb2.DocumentValue(element_id=usage, element_type="PartUsage"),
+        ))
+
+    response = sysml_pb2.RunDocumentQueryResponse(
+        columns=[sysml_pb2.DocumentQueryColumn(name="wheels")],
+        rows=[sysml_pb2.DocumentQueryRow(
+            element=wire(1, "Garage::car", "Garage::car"),
+            cells=[sysml_pb2.DocumentQueryCell(values=[
+                wire(2, "Garage::car.wheels[1]", "Garage::Car::wheels"),
+                wire(3, "Garage::car.wheels[2]", "Garage::Car::wheels"),
+            ])],
+        )],
+    )
+    port, _ = fake_service(response=response)
+    with Connection(port=port, auto_start=False) as conn:
+        result = conn.load_from_content("package Demo;").run_document_query("Demo::Q")
+
+    (row,) = result
+    car = ObjectRef(id=1, path="Garage::car", element=ElementRef("Garage::car", "PartUsage"))
+    assert row.object == car
+    assert row.element == car.element
+    assert row.verdict is None
+    assert row[0] == (
+        ObjectRef(2, "Garage::car.wheels[1]", ElementRef("Garage::Car::wheels", "PartUsage")),
+        ObjectRef(3, "Garage::car.wheels[2]", ElementRef("Garage::Car::wheels", "PartUsage")),
+    )
+    assert [str(wheel) for wheel in row[0]] == ["Garage::car.wheels[1]", "Garage::car.wheels[2]"]
+    assert str(ObjectRef(id=2)) == "#2"
+
+
+def _lamp_wire(instance_id=1, path="Lamps::lamp"):
+    return sysml_pb2.DocumentObject(
+        instance_id=instance_id,
+        path=path,
+        element=sysml_pb2.DocumentValue(element_id="Lamps::lamp", element_type="PartUsage"),
+    )
+
+
+def test_a_state_row_decodes_to_the_object_and_its_state(fake_service):
+    """A row a States query answered stands for the object's usage and
+    carries the object and the state; a state-valued cell decodes the same way."""
+    wire = sysml_pb2.DocumentState(
+        object=_lamp_wire(),
+        machine="lp",
+        name="run",
+        state_path="on.run",
+        state=sysml_pb2.DocumentValue(
+            element_id="Lamps::LampMachine::on::light::run", element_type="StateUsage"
+        ),
+        region="light",
+        enclosing=["on"],
+    )
+    response = sysml_pb2.RunDocumentQueryResponse(
+        columns=[sysml_pb2.DocumentQueryColumn(name="self")],
+        rows=[sysml_pb2.DocumentQueryRow(
+            element=sysml_pb2.DocumentValue(state=wire),
+            cells=[sysml_pb2.DocumentQueryCell(values=[sysml_pb2.DocumentValue(state=wire)])],
+        )],
+    )
+    port, _ = fake_service(response=response)
+    with Connection(port=port, auto_start=False) as conn:
+        result = conn.load_from_content("package Demo;").run_document_query("Demo::Q")
+
+    (row,) = result
+    lamp = ObjectRef(id=1, path="Lamps::lamp", element=ElementRef("Lamps::lamp", "PartUsage"))
+    expected = DocumentState(
+        object=lamp,
+        machine="lp",
+        name="run",
+        path="on.run",
+        state=ElementRef("Lamps::LampMachine::on::light::run", "StateUsage"),
+        region="light",
+        enclosing=("on",),
+    )
+    assert row.state == expected
+    assert row.object == lamp
+    assert row.element == lamp.element
+    assert row.verdict is None
+    assert row.event is None
+    assert row[0] == (expected,)
+    assert str(row.state) == "Lamps::lamp.lp in on.run"
+
+
+def test_an_event_row_decodes_to_the_trace_record(fake_service):
+    """A row an Events query answered carries the record's kind, instant,
+    object, machine, states, target, event, payload and choice; an event
+    without an object is a record of the run as a whole."""
+    at = sysml_pb2.DocumentValue(quantity=sysml_pb2.Quantity(
+        real_magnitude=1.5, unit="s",
+        unit_term=sysml_pb2.UnitTerm(
+            scale_num=1, scale_den=1, factors=[sysml_pb2.UnitFactor(unit_id="SI::second", exponent=1)]
+        ),
+    ))
+    accept = sysml_pb2.DocumentEvent(
+        kind="accept", time=at, object=_lamp_wire(), machine="lp",
+        event="Toggle", payload=["level = 2"], text="accept Toggle",
+    )
+    send = sysml_pb2.DocumentEvent(
+        kind="send", time=at, object=_lamp_wire(), machine="lp",
+        target=_lamp_wire(2, "#2"), event="Toggle", text="send Toggle to #2",
+    )
+    fired = sysml_pb2.DocumentEvent(
+        kind="transition", time=at, object=_lamp_wire(), machine="lp",
+        **{"from": "off"}, to="on", event="Toggle", text="off -> on",
+    )
+    choice = sysml_pb2.DocumentEvent(
+        kind="choice", time=at, alternatives=["light", "fan"], taken="fan",
+        text="choice: region order [fan, light]",
+    )
+    response = sysml_pb2.RunDocumentQueryResponse(
+        columns=[sysml_pb2.DocumentQueryColumn(name="self")],
+        rows=[
+            sysml_pb2.DocumentQueryRow(
+                element=sysml_pb2.DocumentValue(event=record),
+                cells=[sysml_pb2.DocumentQueryCell(values=[sysml_pb2.DocumentValue(event=record)])],
+            )
+            for record in (accept, send, fired, choice)
+        ],
+    )
+    port, _ = fake_service(response=response)
+    with Connection(port=port, auto_start=False) as conn:
+        result = conn.load_from_content("package Demo;").run_document_query("Demo::Q")
+
+    lamp = ObjectRef(id=1, path="Lamps::lamp", element=ElementRef("Lamps::lamp", "PartUsage"))
+    other = ObjectRef(id=2, path="#2", element=ElementRef("Lamps::lamp", "PartUsage"))
+    when = Quantity(1.5, Unit(text="s", factors=(UnitFactor("SI::second", 1),)))
+    rows = list(result)
+    assert [row.event.kind for row in rows] == ["accept", "send", "transition", "choice"]
+    assert rows[0].event == DocumentEvent(
+        kind="accept", time=when, text="accept Toggle", object=lamp, machine="lp",
+        event="Toggle", payload=("level = 2",),
+    )
+    assert rows[0].object == lamp
+    assert rows[0].element == lamp.element
+    assert rows[0].state is None
+    assert rows[0].verdict is None
+    assert rows[0][0] == (rows[0].event,)
+    assert rows[1].event.target == other
+    assert (rows[2].event.from_state, rows[2].event.to_state) == ("off", "on")
+    assert rows[3].event.object is None
+    assert rows[3].object is None
+    assert rows[3].element == ElementRef("")
+    assert (rows[3].event.alternatives, rows[3].event.taken) == (("light", "fan"), "fan")
+    assert str(rows[0].event) == "1.5 [s]: accept Toggle"
+
+
+def test_a_row_that_is_no_verdict_carries_none(fake_service):
+    response = sysml_pb2.RunDocumentQueryResponse(
+        rows=[sysml_pb2.DocumentQueryRow(
+            element=sysml_pb2.DocumentValue(element_id="Demo::part", element_type="PartUsage"),
+        )],
+    )
+    port, _ = fake_service(response=response)
+    with Connection(port=port, auto_start=False) as conn:
+        (row,) = conn.load_from_content("package Demo;").run_document_query("Demo::Q")
+    assert row.verdict is None
+
+
+def test_render_document_answers_the_markdown(fake_service):
+    port, service = fake_service(markdown="# Report\n")
+    with Connection(port=port, auto_start=False) as conn:
+        model = conn.load_from_content("package Demo;")
+        assert model.render_document("Demo::Doc") == "# Report\n"
+    (request,) = service.requests
+    assert request.model_hash == model.hash
+    assert request.document_id == "Demo::Doc"
+    assert request.form == ""
+
+
+def test_render_document_asks_for_and_answers_the_html(fake_service):
+    port, service = fake_service(html="<!DOCTYPE html>\n")
+    with Connection(port=port, auto_start=False) as conn:
+        model = conn.load_from_content("package Demo;")
+        assert model.render_document("Demo::Doc", form="html") == "<!DOCTYPE html>\n"
+    (request,) = service.requests
+    assert request.form == "html"
+
+
+@pytest.fixture(scope="module")
+def real_service():
+    """Run the built sysml-grpc on an ephemeral port, or skip."""
+    binary = next((b for b in GRPC_BINARIES if os.access(b, os.X_OK)), None)
+    if binary is None:
+        pytest.skip(f"no executable sysml-grpc in {GRPC_BINARIES}; run: make build-grpc")
+
+    port = 51157
+    process = subprocess.Popen(
+        [binary, "-port", str(port)],
+        stdout=subprocess.DEVNULL,
+        stderr=subprocess.DEVNULL,
+    )
+    try:
+        deadline = time.time() + 10
+        while time.time() < deadline:
+            with grpc.insecure_channel(f"localhost:{port}") as channel:
+                try:
+                    grpc.channel_ready_future(channel).result(timeout=0.5)
+                    break
+                except grpc.FutureTimeoutError:
+                    continue
+        else:
+            pytest.fail("sysml-grpc did not start")
+        yield port
+    finally:
+        process.terminate()
+        process.wait(timeout=10)
+
+
+@pytest.fixture(scope="module")
+def telescope():
+    """The telescope fixture's source, read from the repo it tests."""
+    with open(FIXTURE, encoding="utf-8") as f:
+        return f.read()
+
+
+@pytest.fixture(scope="module")
+def garage():
+    """The verdict fixture's source, read from the repo it tests."""
+    with open(VERDICT_FIXTURE, encoding="utf-8") as f:
+        return f.read()
+
+
+@pytest.fixture(scope="module")
+def garage_objects():
+    """The object fixture's source, stamped so each test gets a model of its
+    own: the service holds the objects it instantiates per model, and the
+    model is its content's hash."""
+    with open(OBJECT_FIXTURE, encoding="utf-8") as f:
+        source = f.read()
+
+    def stamped(tag):
+        return f"{source}\n// {tag}\n"
+
+    return stamped
+
+
+@pytest.fixture(scope="module")
+def lamps():
+    """The state fixture's source, stamped per test as the object fixture is."""
+    with open(STATE_FIXTURE, encoding="utf-8") as f:
+        source = f.read()
+
+    def stamped(tag):
+        return f"{source}\n// {tag}\n"
+
+    return stamped
+
+
+@pytest.mark.integration
+class TestDocumentsAgainstRealService:
+    """The answers themselves, from the real engine."""
+
+    def test_states_and_events_over_the_object_instantiate_built(self, real_service, lamps):
+        with Connection(port=real_service, auto_start=False) as conn:
+            model = conn.load_from_content(lamps("states"))
+            model.instantiate("Lamps::lamp")
+            lamp = {"root": ObjectRef(path="Lamps::lamp")}
+            states = model.run_document_query("Lamps::CurrentStates", bindings=lamp)
+            off = model.run_document_query("Lamps::Off")
+            steps = model.run_document_query("Lamps::Steps", bindings=lamp)
+        assert states.columns == ("machine", "statePath", "region")
+        (state,) = states
+        assert state.state == DocumentState(
+            object=ObjectRef(1, "Lamps::lamp", ElementRef("Lamps::lamp", "PartUsage")),
+            machine="lp", name="off", path="off",
+            state=ElementRef("Lamps::LampMachine::off", "StateUsage"),
+        )
+        assert str(state.object) == "Lamps::lamp"
+        assert (state[0], state[1], state[2]) == (("lp",), ("off",), ("",))
+        assert [str(row.object) for row in off] == ["Lamps::lamp"]
+        (entered,) = steps
+        assert entered.event.kind == "entry"
+        assert entered.event.state == "off"
+        assert entered.event.object == state.state.object
+        assert entered.event.time == Quantity(0.0, Unit(text="s", factors=(UnitFactor("SI::second", 1),)))
+        assert entered[0] == (entered.event.time,)
+        assert entered[1] == ("off",)
+
+    def test_a_verdicts_query_checks_the_element_as_declared(self, real_service, garage):
+        with Connection(port=real_service, auto_start=False) as conn:
+            result = conn.load_from_content(garage).run_document_query(
+                "Garage::Checks", bindings={"root": ElementRef("Garage::car")}
+            )
+        assert result.columns == ("path", "name", "verdict", "reason")
+        by_text = {f"{row.verdict.text} on {row.verdict.path}": row for row in result}
+        mass_ok = by_text["assert constraint massOk on Garage::car"]
+        assert mass_ok.verdict.status == "holds"
+        assert mass_ok.verdict.kind == "constraint"
+        assert mass_ok.verdict.reason == ""
+        assert mass_ok.element == ElementRef("Garage::Car::massOk", "ConstraintUsage")
+        assert mass_ok[0] == ("Garage::car",)
+        assert mass_ok[2] == ("holds",)
+        power_low = by_text["assert constraint powerLow on Garage::car.engine"].verdict
+        assert power_low.status == "violated"
+        assert power_low.condition
+        assert power_low.reason
+        fits = by_text["assert constraint fits on Garage::car"].verdict
+        assert fits.status == "undecided"
+        assert "capacity" in fits.reason
+        satisfied = by_text["satisfy strongEngine by car.engine on Garage::car.engine"]
+        assert satisfied.verdict.kind == "satisfaction"
+        assert satisfied.verdict.status == "holds"
+        assert satisfied.verdict.verification == ("pass",)
+        assert satisfied.element == ElementRef("", "SatisfyRequirementUsage")
+
+    def test_a_query_binds_the_object_instantiate_built_by_id(
+        self, real_service, garage_objects
+    ):
+        with Connection(port=real_service, auto_start=False) as conn:
+            model = conn.load_from_content(garage_objects("by id"))
+            car = model.instantiate("Garage::car")
+            result = model.run_document_query(
+                "Garage::Parts", bindings={"root": ObjectRef(id=car.id)}
+            )
+        assert result.columns == ("name", "pressure")
+        # Bound by id, the wheels are reached from "#<id>"; each is an object
+        # of its own, standing for the usage it is held under.
+        assert [str(row.object) for row in result] == [
+            f"#{car.id}.wheels[1]", f"#{car.id}.wheels[2]",
+        ]
+        assert [row.element for row in result] == [
+            ElementRef("Garage::Car::wheels", "PartUsage"),
+            ElementRef("Garage::Car::wheels", "PartUsage"),
+        ]
+        assert len({row.object.id for row in result} | {car.id}) == 3
+        assert [(row[0], row[1]) for row in result] == [
+            (("wheels[1]",), (30,)), (("wheels[2]",), (30,)),
+        ]
+        assert result.rows[0].object.element == ElementRef("Garage::Car::wheels", "PartUsage")
+
+    def test_a_query_binds_the_object_instantiate_built_by_path(
+        self, real_service, garage_objects
+    ):
+        with Connection(port=real_service, auto_start=False) as conn:
+            model = conn.load_from_content(garage_objects("by path"))
+            car = model.instantiate("Garage::car")
+            whole = model.run_document_query(
+                "Garage::Drive", bindings={"root": ObjectRef(path="car")}
+            )
+            wheel = model.run_document_query(
+                "Garage::Pressure", bindings={"root": ObjectRef(path="car.wheels[2]")}
+            )
+            both = model.run_document_query(
+                "Garage::Drive", bindings={"root": ObjectRef(id=car.id, path="Garage::car")}
+            )
+        (row,) = whole
+        assert row.object == ObjectRef(car.id, "Garage::car", ElementRef("Garage::car", "PartUsage"))
+        assert row.element == row.object.element
+        assert row[0] == (1200,)
+        assert [str(w) for w in row[1]] == ["Garage::car.wheels[1]", "Garage::car.wheels[2]"]
+        assert {type(value) for value in row[1]} == {ObjectRef}
+        assert {w.element for w in row[1]} == {ElementRef("Garage::Car::wheels", "PartUsage")}
+        (row,) = wheel
+        assert str(row.object) == "Garage::car.wheels[2]"
+        assert row.object.id == whole.rows[0][1][1].id
+        assert row.element == ElementRef("Garage::Car::wheels", "PartUsage")
+        assert (row[0], row[1]) == (("wheels[2]",), (30,))
+        assert both.rows == whole.rows
+
+    def test_objects_enumerates_what_the_model_holds(self, real_service, garage_objects):
+        with Connection(port=real_service, auto_start=False) as conn:
+            model = conn.load_from_content(garage_objects("objects"))
+            before = model.run_document_query("Garage::Wheels")
+            model.instantiate("Garage::car")
+            model.instantiate("Garage::spare")
+            after = model.run_document_query("Garage::Wheels")
+        assert before.columns == ("pressure",)
+        assert len(before) == 0
+        assert [(str(row.object), row[0]) for row in after] == [
+            ("Garage::spare", (20,)),
+            ("Garage::car.wheels[1]", (30,)),
+            ("Garage::car.wheels[2]", (30,)),
+        ]
+
+    def test_verdicts_over_the_object_instantiate_built(self, real_service, garage_objects):
+        with Connection(port=real_service, auto_start=False) as conn:
+            model = conn.load_from_content(garage_objects("verdicts"))
+            model.instantiate("Garage::car")
+            model.instantiate("Garage::spare")
+            car = model.run_document_query(
+                "Garage::Checks", bindings={"root": ObjectRef(path="car")}
+            )
+            spare = model.run_document_query(
+                "Garage::Checks", bindings={"root": ObjectRef(path="spare")}
+            )
+        assert car.columns == ("path", "verdict")
+        assert [(row.verdict.text, row[0], row[1]) for row in car] == [
+            ("assert constraint light", ("Garage::car",), ("holds",)),
+            ("assert constraint inflated", ("Garage::car.wheels[1]",), ("holds",)),
+            ("assert constraint inflated", ("Garage::car.wheels[2]",), ("holds",)),
+        ]
+        assert car.rows[0].element == ElementRef("Garage::Car::light", "ConstraintUsage")
+        (row,) = spare
+        assert row.verdict.status == "violated"
+        assert row.verdict.path == "Garage::spare"
+        assert row.verdict.reason
+
+    def test_an_object_binding_the_model_cannot_reach_is_refused(
+        self, real_service, garage_objects
+    ):
+        with Connection(port=real_service, auto_start=False) as conn:
+            model = conn.load_from_content(garage_objects("refused"))
+            unpopulated = {"root": ObjectRef(id=1)}
+            with pytest.raises(SymbolNotFoundError, match="holds no objects"):
+                model.run_document_query("Garage::Parts", bindings=unpopulated)
+            model.instantiate("Garage::car")
+            unknown_id = {"root": ObjectRef(id=99)}
+            with pytest.raises(SymbolNotFoundError, match="no object #99"):
+                model.run_document_query("Garage::Parts", bindings=unknown_id)
+            unknown_path = {"root": ObjectRef(path="spare")}
+            with pytest.raises(SymbolNotFoundError, match="no instance of"):
+                model.run_document_query("Garage::Parts", bindings=unknown_path)
+            unknown_feature = {"root": ObjectRef(path="car.hood")}
+            with pytest.raises(InvalidRequestError, match="hood"):
+                model.run_document_query("Garage::Parts", bindings=unknown_feature)
+            value_path = {"root": ObjectRef(path="car.mass")}
+            with pytest.raises(InvalidRequestError, match="not an object"):
+                model.run_document_query("Garage::Parts", bindings=value_path)
+            malformed_path = {"root": ObjectRef(path="car..wheels")}
+            with pytest.raises(InvalidRequestError, match="not an object reference"):
+                model.run_document_query("Garage::Parts", bindings=malformed_path)
+            disagreeing = {"root": ObjectRef(id=99, path="car")}
+            with pytest.raises(InvalidRequestError, match="is object #"):
+                model.run_document_query("Garage::Parts", bindings=disagreeing)
+
+    def test_a_document_query_answers_typed_ordered_rows(self, real_service, telescope):
+        with Connection(port=real_service, auto_start=False) as conn:
+            result = conn.load_from_content(telescope).run_document_query(
+                "Observatory::SubsystemTable",
+                bindings={"root": ElementRef("Observatory::telescope")},
+            )
+        assert result.columns == ("name", "mass")
+        assert [row.element.id for row in result] == [
+            "Observatory::telescope::baffle|shroud *tricky*",
+            "Observatory::telescope::mount",
+            "Observatory::telescope::optics",
+            "Observatory::telescope::segmentControl",
+        ]
+        assert [row[0] for row in result] == [
+            ("baffle|shroud *tricky*",), ("mount",), ("optics",), ("segmentControl",),
+        ]
+        assert [row[1] for row in result] == [(1.5,), (15.0,), (8.5,), (20.0,)]
+        assert {row.element.type for row in result} == {"PartUsage"}
+
+    def test_a_query_matching_nothing_answers_columns_and_no_rows(
+        self, real_service, telescope
+    ):
+        with Connection(port=real_service, auto_start=False) as conn:
+            result = conn.load_from_content(telescope).run_document_query(
+                "Observatory::MissingSubsystems",
+                bindings={"root": ElementRef("Observatory::telescope")},
+            )
+        assert result.columns == ("name", "mass")
+        assert len(result) == 0
+
+    def test_a_rendered_document_is_the_renderer_s_golden(self, real_service, telescope):
+        with open(GOLDEN, encoding="utf-8") as f:
+            golden = f.read()
+        with Connection(port=real_service, auto_start=False) as conn:
+            markdown = conn.load_from_content(telescope).render_document(
+                "Observatory::MassReport"
+            )
+        assert markdown == golden
+
+    def test_a_rendered_html_document_is_the_renderer_s_golden(self, real_service, telescope):
+        with open(HTML_GOLDEN, encoding="utf-8") as f:
+            golden = f.read()
+        with Connection(port=real_service, auto_start=False) as conn:
+            html = conn.load_from_content(telescope).render_document(
+                "Observatory::MassReport", form="html"
+            )
+        assert html == golden
+
+    def test_an_unknown_query_raises_symbol_not_found(self, real_service, telescope):
+        with Connection(port=real_service, auto_start=False) as conn:
+            model = conn.load_from_content(telescope)
+            with pytest.raises(SymbolNotFoundError):
+                model.run_document_query("Observatory::NoSuchQuery")
+
+    def test_a_symbol_that_is_not_a_query_is_refused(self, real_service, telescope):
+        with Connection(port=real_service, auto_start=False) as conn:
+            model = conn.load_from_content(telescope)
+            with pytest.raises(InvalidRequestError, match="not a document query"):
+                model.run_document_query("Observatory::Subsystem")
+
+    def test_a_symbol_that_is_not_a_document_is_refused(self, real_service, telescope):
+        with Connection(port=real_service, auto_start=False) as conn:
+            model = conn.load_from_content(telescope)
+            with pytest.raises(InvalidRequestError, match="not a document"):
+                model.render_document("Observatory::SubsystemTable")
+
+    def test_a_wrong_binding_is_refused_with_the_engine_s_message(
+        self, real_service, telescope
+    ):
+        with Connection(port=real_service, auto_start=False) as conn:
+            model = conn.load_from_content(telescope)
+            bindings = {"depth": 3, "root": ElementRef("Observatory::telescope")}
+            with pytest.raises(InvalidRequestError):
+                model.run_document_query("Observatory::SubsystemTable", bindings=bindings)
+
+    def test_an_evicted_model_raises_this_library_s_error(self, real_service):
+        with Connection(port=real_service, auto_start=False) as conn:
+            with pytest.raises(ModelNotFoundError):
+                conn.run_document_query("deadbeef", "Observatory::SubsystemTable")
+            with pytest.raises(ModelNotFoundError):
+                conn.render_document("deadbeef", "Observatory::MassReport")
diff --git a/clients/python/tests/test_edit.py b/client/python/tests/test_edit.py
similarity index 84%
rename from clients/python/tests/test_edit.py
rename to client/python/tests/test_edit.py
index df3fbc15e7..00eec6c2a6 100644
--- a/clients/python/tests/test_edit.py
+++ b/client/python/tests/test_edit.py
@@ -18,12 +18,13 @@
 from opensysml.capabilities import (
     CAPABILITY_APPLY_EDITS,
     CAPABILITY_AUTHORING,
+    CAPABILITY_EDIT_DOCUMENTS,
     CAPABILITY_INLINE_LANGUAGE,
     MissingCapabilityError,
 )
 from opensysml.connection import Connection
 from opensysml.conversion import Conversion, FORMAT_SYSML
-from opensysml.edit import EditResult
+from opensysml.edit import EditedDocument, EditResult
 from opensysml.errors import (
     EditError,
     EditResultError,
@@ -40,6 +41,8 @@
     DeleteReferencedError,
     OwnerInsideTargetError,
     MoveReferencedError,
+    ReferencedElsewhereError,
+    Referrer,
 )
 from opensysml.proto import sysml_pb2, sysml_pb2_grpc
 
@@ -81,13 +84,18 @@ class FakeService(sysml_pb2_grpc.SysMLServiceServicer):
 
     def __init__(self, capabilities=(CAPABILITY_APPLY_EDITS,), error="",
                  failure=sysml_pb2.EDIT_FAILURE_UNSPECIFIED, diagnostics=0,
-                 referring_elements=(), not_found=False):
+                 referring_elements=(), referrers=(), not_found=False,
+                 documents=(), content="edited", legacy=False):
         self._capabilities = list(capabilities)
+        self._legacy = legacy
         self._error = error
         self._failure = failure
         self._diagnostics = diagnostics
         self._referring = list(referring_elements)
+        self._referrers = list(referrers)
         self._not_found = not_found
+        self._documents = list(documents) or [("<content>", content)]
+        self._content = content
         self.requests = []
 
     def GetServerInfo(self, request, context):
@@ -115,6 +123,10 @@ def ApplyEdits(self, request, context):
                 error=self._error,
                 failure=self._failure,
                 referring_elements=self._referring,
+                referrers=[
+                    sysml_pb2.Referrer(name=name, document=document)
+                    for name, document in self._referrers
+                ],
                 diagnostics=[
                     sysml_pb2.Diagnostic(
                         severity="error",
@@ -124,18 +136,24 @@ def ApplyEdits(self, request, context):
                     for i in range(self._diagnostics)
                 ],
             )
+        applied = sysml_pb2.AppliedEdit(
+            operation_index=0,
+            target="Demo::SC::unitMass",
+            offset=7,
+            length=3,
+            old_text="old",
+            new_text="new",
+        )
+        if self._legacy:
+            return sysml_pb2.ApplyEditsResponse(content=self._content, applied=[applied])
+        applied.document = self._documents[0][0]
         return sysml_pb2.ApplyEditsResponse(
-            content="edited",
-            applied=[
-                sysml_pb2.AppliedEdit(
-                    operation_index=0,
-                    target="Demo::SC::unitMass",
-                    offset=7,
-                    length=3,
-                    old_text="old",
-                    new_text="new",
-                )
+            content=self._content,
+            documents=[
+                sysml_pb2.EditedDocument(name=name, content=text)
+                for name, text in self._documents
             ],
+            applied=[applied],
         )
 
 
@@ -463,6 +481,87 @@ def test_a_refused_rename_names_where_the_references_are(fake_service):
         with pytest.raises(RenameReferencedError) as excinfo:
             edit.apply()
     assert excinfo.value.referring_elements == ["Demo::SC", "Demo::sc"]
+    assert excinfo.value.referrers == []
+
+
+def test_a_refusal_names_each_referrer_with_its_document(fake_service):
+    """A referrer in another document of the model arrives with that document."""
+    port, _ = fake_service(
+        capabilities=(CAPABILITY_APPLY_EDITS, CAPABILITY_AUTHORING),
+        error="cannot delete Lib::Engine: it is referenced",
+        failure=sysml_pb2.EDIT_FAILURE_DELETE_REFERENCED,
+        referring_elements=("Car::engine (car.sysml)",),
+        referrers=(("Car::engine", "car.sysml"),),
+    )
+    with Connection(port=port, auto_start=False) as conn:
+        edit = conn.load_from_content(MODEL).edit().delete("Lib::Engine")
+        with pytest.raises(DeleteReferencedError) as excinfo:
+            edit.apply()
+    assert excinfo.value.referring_elements == ["Car::engine (car.sysml)"]
+    assert excinfo.value.referrers == [Referrer("Car::engine", "car.sysml")]
+
+
+def test_a_referrer_outside_the_model_is_its_own_error(fake_service):
+    port, _ = fake_service(
+        error="refused", failure=sysml_pb2.EDIT_FAILURE_REFERENCED_ELSEWHERE,
+    )
+    with Connection(port=port, auto_start=False) as conn:
+        edit = conn.load_from_content(MODEL).edit().rename("Demo::SC", "Ship")
+        with pytest.raises(ReferencedElsewhereError):
+            edit.apply()
+
+
+def test_the_result_lists_the_one_document_it_edited(fake_service):
+    """A model of one document answers the same notation twice: content and documents."""
+    port, _ = fake_service()
+    with Connection(port=port, auto_start=False) as conn:
+        edit = conn.load_from_content(MODEL).edit()
+        edit.set_value("Demo::SC::unitMass", "1050.0[SI::kg]")
+        result = edit.apply()
+    assert result.documents == [EditedDocument(name="<content>", content="edited")]
+    assert str(result) == result.documents[0].content == "edited"
+    assert [a.document for a in result.applied] == ["<content>"]
+
+
+def test_a_model_of_several_documents_answers_documents_not_content(fake_service):
+    """content is empty for such a model; the rewritten documents carry the notation."""
+    port, _ = fake_service(
+        content="",
+        documents=(("lib.sysml", "package Lib;"), ("car.sysml", "package Car;")),
+    )
+    with Connection(port=port, auto_start=False) as conn:
+        edit = conn.load_from_content(MODEL).edit().rename("Lib::Engine", "Motor")
+        result = edit.apply()
+    assert str(result) == ""
+    assert [d.name for d in result.documents] == ["lib.sysml", "car.sysml"]
+    assert result.documents[1].content == "package Car;"
+    assert result.applied[0].document == "lib.sysml"
+
+
+def test_a_service_without_edit_documents_answers_content_alone(fake_service):
+    """A service lacking the capability edits a model of one document and answers
+    content alone: documents stays empty and no applied edit names a document."""
+    assert CAPABILITY_EDIT_DOCUMENTS == "edit_documents"
+    port, _ = fake_service(legacy=True)
+    with Connection(port=port, auto_start=False) as conn:
+        assert not conn.server_info().has(CAPABILITY_EDIT_DOCUMENTS)
+        edit = conn.load_from_content(MODEL).edit()
+        edit.set_value("Demo::SC::unitMass", "1050.0[SI::kg]")
+        result = edit.apply()
+    assert str(result) == "edited"
+    assert result.documents == []
+    assert [a.document for a in result.applied] == [""]
+
+
+def test_every_request_accepts_documents(fake_service):
+    """The client reads documents, so it says so; the service edits a model of several
+    only for a request that does, and refuses one that does not as it always did."""
+    port, service = fake_service()
+    with Connection(port=port, auto_start=False) as conn:
+        edit = conn.load_from_content(MODEL).edit()
+        edit.set_value("Demo::SC::unitMass", "1050.0[SI::kg]")
+        edit.apply()
+    assert [request.accept_documents for request in service.requests] == [True]
 
 
 def test_an_evicted_model_names_the_eviction(fake_service):
diff --git a/clients/python/tests/test_engines.py b/client/python/tests/test_engines.py
similarity index 90%
rename from clients/python/tests/test_engines.py
rename to client/python/tests/test_engines.py
index 383e8ccf33..a379bcb6f1 100644
--- a/clients/python/tests/test_engines.py
+++ b/client/python/tests/test_engines.py
@@ -57,6 +57,9 @@ def stub_answering():
     stub.VerifySatisfaction.return_value = sysml_pb2.VerifySatisfactionResponse(
         verdicts=[sysml_pb2.Verdict(kind="satisfy", element="satisfy r by p", holds=True, **STANDING)]
     )
+    stub.ValidateInstance.return_value = sysml_pb2.ValidateInstanceResponse(
+        summary=sysml_pb2.Verdict(kind="object", element_id="M::p", holds=True, **STANDING)
+    )
     stub.EvaluateCalc.return_value = sysml_pb2.EvaluateCalcResponse(
         result=sysml_pb2.Value(int_value=3), **STANDING
     )
@@ -73,6 +76,7 @@ def ask_everything(conn, **kwargs):
         conn.verify_constraint("M::c", "hash", **kwargs),
         conn.verify_requirement("M::r", "hash", **kwargs),
         conn.verify_satisfaction("hash", **kwargs)[0],
+        conn.validate_instance("M::p", "hash", **kwargs),
         conn.calc("M::k", "hash", arguments=[1], **kwargs),
         conn.run_analysis("M::a", "hash", **kwargs),
         conn.run_sweep("M::k", "hash", {"n": (1, 2)}, **kwargs),
@@ -117,7 +121,7 @@ def test_an_engine_is_carried_on_every_question():
     ask_everything(conn, engine="all")
     for call in (
         stub.VerifyConstraint, stub.VerifyRequirement, stub.VerifySatisfaction,
-        stub.EvaluateCalc, stub.RunAnalysis, stub.RunSweep,
+        stub.ValidateInstance, stub.EvaluateCalc, stub.RunAnalysis, stub.RunSweep,
     ):
         assert call.call_args.args[0].engine == "all"
 
@@ -128,7 +132,10 @@ def test_auto_and_no_engine_send_an_empty_field():
     conn = make_connection(stub, OLD)
     ask_everything(conn)
     ask_everything(conn, engine="auto")
-    for call in (stub.VerifyConstraint, stub.EvaluateCalc, stub.RunAnalysis, stub.RunSweep):
+    for call in (
+        stub.VerifyConstraint, stub.ValidateInstance, stub.EvaluateCalc, stub.RunAnalysis,
+        stub.RunSweep,
+    ):
         assert all(c.args[0].engine == "" for c in call.call_args_list)
 
 
@@ -140,6 +147,7 @@ def test_an_engine_is_not_sent_to_a_service_without_the_capability():
         lambda: conn.verify_constraint("M::c", "hash", engine="run"),
         lambda: conn.verify_requirement("M::r", "hash", engine="run"),
         lambda: conn.verify_satisfaction("hash", engine="run"),
+        lambda: conn.validate_instance("M::p", "hash", engine="run"),
         lambda: conn.calc("M::k", "hash", engine="run"),
         lambda: conn.run_analysis("M::a", "hash", engine="run"),
         lambda: conn.run_sweep("M::k", "hash", {"n": (1, 2)}, engine="run"),
@@ -149,7 +157,7 @@ def test_an_engine_is_not_sent_to_a_service_without_the_capability():
         assert excinfo.value.capability == CAPABILITY_ENGINES
     for call in (
         stub.VerifyConstraint, stub.VerifyRequirement, stub.VerifySatisfaction,
-        stub.EvaluateCalc, stub.RunAnalysis, stub.RunSweep,
+        stub.ValidateInstance, stub.EvaluateCalc, stub.RunAnalysis, stub.RunSweep,
     ):
         call.assert_not_called()
 
@@ -169,10 +177,12 @@ def test_the_explore_engine_is_the_exploring_schedule():
 def test_every_answer_carries_its_standing():
     stub = stub_answering()
     conn = make_connection(stub, CURRENT)
-    constraint, requirement, satisfy, calc, analysis, sweep = ask_everything(conn, engine="run")
+    constraint, requirement, satisfy, validation, calc, analysis, sweep = ask_everything(
+        conn, engine="run"
+    )
 
     want = Standing(engine="run", strength="observed", bounds=(Bound("steps", 10_000_000),))
-    for answer in (constraint, requirement, satisfy, calc, analysis):
+    for answer in (constraint, requirement, satisfy, validation, calc, analysis):
         assert answer.standing == want
         assert (answer.engine, answer.strength, answer.bounds) == ("run", "observed", [Bound("steps", 10_000_000)])
     assert sweep.standing == Standing(engine="sweep", strength="observed")
diff --git a/clients/python/tests/test_enumeration.py b/client/python/tests/test_enumeration.py
similarity index 100%
rename from clients/python/tests/test_enumeration.py
rename to client/python/tests/test_enumeration.py
diff --git a/clients/python/tests/test_errors.py b/client/python/tests/test_errors.py
similarity index 100%
rename from clients/python/tests/test_errors.py
rename to client/python/tests/test_errors.py
diff --git a/clients/python/tests/test_evaluate_subject.py b/client/python/tests/test_evaluate_subject.py
similarity index 100%
rename from clients/python/tests/test_evaluate_subject.py
rename to client/python/tests/test_evaluate_subject.py
diff --git a/clients/python/tests/test_explore.py b/client/python/tests/test_explore.py
similarity index 95%
rename from clients/python/tests/test_explore.py
rename to client/python/tests/test_explore.py
index 68a50b6680..9fa0e8f397 100644
--- a/clients/python/tests/test_explore.py
+++ b/client/python/tests/test_explore.py
@@ -51,6 +51,7 @@ def outcome_pb(winner, linearizations, witness, error=""):
     return sysml_pb2.Outcome(
         outputs={"winner": sysml_pb2.Value(int_value=winner)} if not error else {},
         linearizations=linearizations,
+        probability=0.0,
         witness=witness,
         error=error,
     )
@@ -75,6 +76,7 @@ def test_an_explored_action_answers_with_every_outcome_and_the_status():
     assert isinstance(exploration, Exploration)
     assert [o.outputs["winner"] for o in exploration] == [1, 2]
     assert [o.linearizations for o in exploration] == [3, 3]
+    assert [o.probability for o in exploration] == [0.0, 0.0]
     assert exploration.outcomes[0].witness == ["step 3: 3@left first of 2@right, 3@left"]
     assert exploration.complete
     assert exploration.status == "complete (2 runs)"
@@ -92,7 +94,8 @@ def test_a_budget_hit_is_incomplete_and_named_never_an_error():
             )
         ],
         exploration=sysml_pb2.ExplorationStatus(
-            complete=False, runs=1, budgets_hit=["runs"], runs_budget=1, depth_budget=64
+            complete=False, runs=1, budgets_hit=["runs"], runs_budget=1,
+            depth_budget=64, probabilities_lower_bound=True
         ),
     )
     conn = make_connection(stub, CURRENT)
@@ -103,7 +106,11 @@ def test_a_budget_hit_is_incomplete_and_named_never_an_error():
 
     assert not exploration.complete
     assert exploration.budgets_hit == ["runs"]
-    assert exploration.status == "incomplete: runs budget 1 hit after 1 runs"
+    assert exploration.probabilities_lower_bound
+    assert exploration.status == (
+        "incomplete: runs budget 1 hit after 1 runs; "
+        "probabilities are lower bounds"
+    )
     assert not bool(exploration)
     outcome = exploration.outcomes[0]
     assert outcome.final_state == "low"
@@ -243,12 +250,13 @@ def test_a_single_run_schedule_still_needs_only_schedule():
 def test_an_outcome_renders_its_observables_sorted():
     outcome = Outcome(
         {"b": 2, "a": 1}, final_state="", states_visited=[], error="",
-        linearizations=4, witness=["x"], diagnostics=[],
+        linearizations=4, probability=0.25, witness=["x"], diagnostics=[],
     )
     assert str(outcome) == "a = 1; b = 2"
     assert "linearizations=4" in repr(outcome)
     empty = Outcome({}, "", [], "", 1, [], [])
     assert str(empty) == "no outputs"
+    assert empty.probability == 0.0
 
 
 EXPLORE_MODEL = """
@@ -357,7 +365,10 @@ def test_an_analysis_explores_the_actions_it_performs(self):
     def test_a_runs_budget_of_one_is_incomplete(self):
         exploration = self.model.explore_action("Sched::race", schedule="explore:runs=1")
         assert len(exploration) == 1
-        assert exploration.status == "incomplete: runs budget 1 hit after 1 runs"
+        assert exploration.status == (
+            "incomplete: runs budget 1 hit after 1 runs; "
+            "probabilities are lower bounds"
+        )
 
     def test_the_same_model_explores_to_the_same_table(self):
         first = str(self.model.explore_action("Sched::race"))
diff --git a/clients/python/tests/test_feature_values.py b/client/python/tests/test_feature_values.py
similarity index 100%
rename from clients/python/tests/test_feature_values.py
rename to client/python/tests/test_feature_values.py
diff --git a/clients/python/tests/test_feature_values_capability.py b/client/python/tests/test_feature_values_capability.py
similarity index 100%
rename from clients/python/tests/test_feature_values_capability.py
rename to client/python/tests/test_feature_values_capability.py
diff --git a/clients/python/tests/test_function.py b/client/python/tests/test_function.py
similarity index 100%
rename from clients/python/tests/test_function.py
rename to client/python/tests/test_function.py
diff --git a/clients/python/tests/test_generate.py b/client/python/tests/test_generate.py
similarity index 100%
rename from clients/python/tests/test_generate.py
rename to client/python/tests/test_generate.py
diff --git a/clients/python/tests/test_generate_golden.py b/client/python/tests/test_generate_golden.py
similarity index 96%
rename from clients/python/tests/test_generate_golden.py
rename to client/python/tests/test_generate_golden.py
index 01526ba348..8ed9c34c5c 100644
--- a/clients/python/tests/test_generate_golden.py
+++ b/client/python/tests/test_generate_golden.py
@@ -1,11 +1,11 @@
 """Golden and static-typing tests for the generated typed classes.
 
 The golden file is `tests/golden/vehicle_types.py`, generated from
-`internal/repl/testdata/vehicle_package.sysml`. Regenerate it with a running
+`internal/frontend/repl/testdata/vehicle_package.sysml`. Regenerate it with a running
 service from the repository root:
 
-    python -m opensysml.generate internal/repl/testdata/vehicle_package.sysml \
-        -o clients/python/tests/golden/vehicle_types.py
+    python -m opensysml.generate internal/frontend/repl/testdata/vehicle_package.sysml \
+        -o client/python/tests/golden/vehicle_types.py
 """
 
 import importlib.util
@@ -32,10 +32,10 @@
 PYTHON_ROOT = Path(__file__).resolve().parents[1]
 REPO_ROOT = PYTHON_ROOT.parents[1]
 GOLDEN = PYTHON_ROOT / "tests" / "golden" / "vehicle_types.py"
-FIXTURE = REPO_ROOT / "internal" / "repl" / "testdata" / "vehicle_package.sysml"
+FIXTURE = REPO_ROOT / "internal" / "frontend" / "repl" / "testdata" / "vehicle_package.sysml"
 REGENERATE = (
-    "python -m opensysml.generate internal/repl/testdata/vehicle_package.sysml "
-    "-o clients/python/tests/golden/vehicle_types.py"
+    "python -m opensysml.generate internal/frontend/repl/testdata/vehicle_package.sysml "
+    "-o client/python/tests/golden/vehicle_types.py"
 )
 
 
diff --git a/clients/python/tests/test_infinity.py b/client/python/tests/test_infinity.py
similarity index 100%
rename from clients/python/tests/test_infinity.py
rename to client/python/tests/test_infinity.py
diff --git a/clients/python/tests/test_instance.py b/client/python/tests/test_instance.py
similarity index 100%
rename from clients/python/tests/test_instance.py
rename to client/python/tests/test_instance.py
diff --git a/clients/python/tests/test_integration.py b/client/python/tests/test_integration.py
similarity index 100%
rename from clients/python/tests/test_integration.py
rename to client/python/tests/test_integration.py
diff --git a/clients/python/tests/test_legacy_pysysml_placeholder.py b/client/python/tests/test_legacy_pysysml_placeholder.py
similarity index 100%
rename from clients/python/tests/test_legacy_pysysml_placeholder.py
rename to client/python/tests/test_legacy_pysysml_placeholder.py
diff --git a/clients/python/tests/test_lifecycle.py b/client/python/tests/test_lifecycle.py
similarity index 100%
rename from clients/python/tests/test_lifecycle.py
rename to client/python/tests/test_lifecycle.py
diff --git a/clients/python/tests/test_measurement_ref.py b/client/python/tests/test_measurement_ref.py
similarity index 100%
rename from clients/python/tests/test_measurement_ref.py
rename to client/python/tests/test_measurement_ref.py
diff --git a/clients/python/tests/test_metaobject.py b/client/python/tests/test_metaobject.py
similarity index 100%
rename from clients/python/tests/test_metaobject.py
rename to client/python/tests/test_metaobject.py
diff --git a/clients/python/tests/test_model.py b/client/python/tests/test_model.py
similarity index 99%
rename from clients/python/tests/test_model.py
rename to client/python/tests/test_model.py
index fc38333790..d70de44974 100644
--- a/clients/python/tests/test_model.py
+++ b/client/python/tests/test_model.py
@@ -647,7 +647,7 @@ def test_execute_action_passes_the_models_hash_and_inputs(self):
         model = self._model(client)
         assert model.execute_action("Demo::add", inputs={"result": 10}) == {"result": 15}
         client.execute_action.assert_called_once_with(
-            "Demo::add", "hash1", inputs={"result": 10}, schedule=None
+            "Demo::add", "hash1", inputs={"result": 10}, schedule=None, performer=None
         )
 
     def test_execute_state_passes_the_models_hash_and_events(self):
@@ -659,7 +659,7 @@ def test_execute_state_passes_the_models_hash_and_events(self):
             "states_visited": ["init"]
         }
         client.execute_state.assert_called_once_with(
-            "Demo::Machine", "hash1", events=["go"], schedule=None
+            "Demo::Machine", "hash1", events=["go"], schedule=None, performer=None
         )
 
     def test_instantiate_raises_what_the_connection_raises(self):
diff --git a/client/python/tests/test_model_surface_integration.py b/client/python/tests/test_model_surface_integration.py
new file mode 100644
index 0000000000..05f216f703
--- /dev/null
+++ b/client/python/tests/test_model_surface_integration.py
@@ -0,0 +1,189 @@
+"""Integration tests for the model surface against a real service.
+
+Covers evaluation on the model itself and the wrong-kind requests the service
+now classifies, since both are about what the service actually answers rather
+than about how the client wraps a canned response.
+"""
+
+import pytest
+
+from opensysml import Connection
+from opensysml.errors import ExecutionError, ModelNotFoundError, WrongKindError
+from opensysml.model import Model
+from opensysml.proto import sysml_pb2
+
+MODEL_SOURCE = '''
+package Demo {
+    part def Vehicle {
+        attribute mass default = 1500.0;
+        constraint massPositive {
+            assert mass > 0.0;
+        }
+        constraint massLight {
+            assert mass < 100.0;
+        }
+        requirement lightEnough {
+            require constraint { mass < 2000.0 }
+        }
+    }
+
+    part sedan : Vehicle {
+        attribute :>> mass = 1200.0;
+    }
+
+    calc add {
+        in x;
+        in y;
+        x + y
+    }
+}
+'''
+
+
+@pytest.mark.integration
+class TestModelSurfaceIntegration:
+    def setup_method(self):
+        self.conn = Connection()
+        self.model = self.conn.load_from_content(MODEL_SOURCE)
+
+    def teardown_method(self):
+        self.conn.close()
+
+    def test_eval_on_the_model(self):
+        assert self.model.eval("1+1") == 2
+
+    def test_eval_in_a_context(self):
+        assert self.model.eval("mass", context_symbol_id="Demo::sedan") == 1200.0
+
+    def test_eval_against_a_subject_reads_that_object(self):
+        # The object's redefinition wins over the definition's default, the way
+        # %eval does after %instantiate.
+        assert self.model.eval("mass", context_symbol_id="Demo::Vehicle") == 1500.0
+        assert self.model.eval("mass", subject="Demo::sedan") == 1200.0
+        assert self.model.eval("mass * 2", subject="Demo::sedan") == 2400.0
+
+    def test_eval_against_a_subject_in_a_named_context(self):
+        assert (
+            self.model.eval(
+                "mass",
+                context_symbol_id="Demo::Vehicle",
+                subject="Demo::sedan",
+            )
+            == 1200.0
+        )
+
+    def test_eval_raises_for_an_unknown_subject(self):
+        with pytest.raises(ExecutionError):
+            self.model.eval("mass", subject="Demo::nope")
+
+    @pytest.mark.parametrize("expression", ["1/0", "nope", '1 + "a"'])
+    def test_eval_raises_for_an_expression_it_cannot_evaluate(self, expression):
+        with pytest.raises(ExecutionError):
+            self.model.eval(expression)
+
+    def test_eval_raises_when_the_service_no_longer_holds_the_model(self):
+        # A model whose hash the service's bounded cache has evicted.
+        evicted = Model(
+            sysml_pb2.ParseFileResponse(
+                model_hash="0" * 64,
+                root=sysml_pb2.SymbolInfo(id="Demo", name="Demo", kind="Package"),
+            ),
+            self.conn,
+        )
+        with pytest.raises(ModelNotFoundError):
+            evicted.eval("1+1")
+
+    def test_a_verdict_is_still_a_verdict(self):
+        assert self.model.verify_constraint(
+            "Demo::Vehicle::massPositive", subject="Demo::sedan"
+        ).holds
+        assert self.model.verify_constraint(
+            "Demo::Vehicle::massLight", subject="Demo::sedan"
+        ).holds is False
+        assert self.model.verify_requirement(
+            "Demo::Vehicle::lightEnough", subject="Demo::sedan"
+        ).holds
+
+    def test_a_wrong_kind_verification_raises(self):
+        for call in (
+            lambda: self.model.verify_constraint("Demo::Vehicle"),
+            lambda: self.model.verify_requirement("Demo::Vehicle"),
+            lambda: self.model.calc("Demo::Vehicle", arguments=[1]),
+        ):
+            with pytest.raises(WrongKindError):
+                call()
+
+    def test_an_unknown_symbol_still_raises(self):
+        for call in (
+            lambda: self.model.verify_constraint("Demo::Nope"),
+            lambda: self.model.verify_requirement("Demo::Nope"),
+            lambda: self.model.verify_satisfaction("Demo::Nope"),
+            lambda: self.model.calc("Demo::Nope", arguments=[1]),
+        ):
+            with pytest.raises(ExecutionError):
+                call()
+
+    def test_an_element_stating_no_assertion_still_answers_with_none(self):
+        assert self.model.verify_satisfaction("Demo::Vehicle") == []
+
+    def test_validating_an_object_answers_every_assertion_in_its_tree(self):
+        model = self.conn.load_from_content('''
+            package Fleet {
+                part def Wheel {
+                    attribute pressure default = 32.0;
+                    assert constraint pressureOk { pressure >= 30.0 }
+                }
+                part def Car {
+                    attribute mass = 1500.0;
+                    part wheels : Wheel[2] {
+                        attribute :>> pressure = 20.0;
+                    }
+                    assert constraint massOk { mass < 2000.0 }
+                    requirement light { require constraint { mass < 1000.0 } }
+                }
+                part car : Car;
+                part spare : Wheel;
+                part def Crate;
+                part crate : Crate;
+            }
+        ''')
+
+        validation = model.validate_instance("Fleet::car")
+        assert not validation
+        assert validation.summary.kind == "object"
+        assert validation.summary.element_id == "Fleet::car"
+        assert validation.bounded is False
+        assert validation.undecided == []
+        answered = sorted(
+            (v.element_id, v.instance_path, v.holds) for v in validation
+        )
+        assert answered == [
+            ("Fleet::Car::light", "", False),
+            ("Fleet::Car::massOk", "", True),
+            ("Fleet::Wheel::pressureOk", "wheels[1]", False),
+            ("Fleet::Wheel::pressureOk", "wheels[2]", False),
+        ]
+        assert len(validation.violated) == 3
+
+        spare = model.validate_instance("Fleet::spare")
+        assert spare.valid
+        assert [(v.element_id, v.instance_path) for v in spare] == [
+            ("Fleet::Wheel::pressureOk", ""),
+        ]
+
+        # Nothing was decided about an object no assertion is about, so it
+        # is neither valid nor violated.
+        crate = model.validate_instance("Fleet::crate")
+        assert len(crate) == 0
+        assert not crate.valid
+        assert crate.violated == []
+        assert "states no assertion" in crate.summary.error
+
+        # A package and an attribute have no object to validate.
+        for symbol in ("Fleet", "Fleet::Car::mass"):
+            with pytest.raises(ExecutionError, match="no object to validate"):
+                model.validate_instance(symbol)
+
+    def test_validating_an_unknown_object_raises(self):
+        with pytest.raises(ExecutionError):
+            self.model.validate_instance("Demo::Nope")
diff --git a/client/python/tests/test_performer.py b/client/python/tests/test_performer.py
new file mode 100644
index 0000000000..69cea20abb
--- /dev/null
+++ b/client/python/tests/test_performer.py
@@ -0,0 +1,175 @@
+"""Tests for the object an action or state machine runs on (``performer=``).
+
+A performer names a part definition or usage to make an object of, or a path
+from one into its parts: ``Wire::pair.craft`` makes the pair and runs the
+behavior on its craft, inside the assembly, so what the ground station sends
+over their connector reaches it. A service that predates ``performer`` would
+drop the field and run the behavior outside any object, so the client requires
+the capability before sending.
+"""
+
+from unittest.mock import Mock, patch
+
+import pytest
+
+from opensysml.capabilities import (
+    CAPABILITY_FEATURE_VALUES,
+    CAPABILITY_PERFORMER,
+    CAPABILITY_SCHEDULE,
+    CAPABILITY_SCHEDULE_EXPLORE,
+    CAPABILITY_VERIFICATION,
+    MissingCapabilityError,
+)
+from opensysml.connection import Connection
+from opensysml.errors import ExecutionError
+from opensysml.proto import sysml_pb2
+from tests.service_gate import skip_or_fail_without_service
+
+
+def make_connection(stub, capabilities):
+    """Build a Connection over a mock stub reporting ``capabilities``."""
+    stub.GetServerInfo.return_value = sysml_pb2.ServerInfoResponse(
+        version="test", capabilities=list(capabilities)
+    )
+    with patch("grpc.insecure_channel"):
+        with patch(
+            "opensysml.proto.sysml_pb2_grpc.SysMLServiceStub", return_value=stub
+        ):
+            return Connection(auto_start=False)
+
+
+EXPLORING = (
+    CAPABILITY_FEATURE_VALUES,
+    CAPABILITY_VERIFICATION,
+    CAPABILITY_SCHEDULE,
+    CAPABILITY_SCHEDULE_EXPLORE,
+)
+CURRENT = EXPLORING + (CAPABILITY_PERFORMER,)
+
+
+def test_a_performer_is_carried_on_every_run_request():
+    stub = Mock()
+    stub.ExecuteAction.return_value = sysml_pb2.ExecuteActionResponse()
+    stub.ExecuteState.return_value = sysml_pb2.ExecuteStateResponse()
+    conn = make_connection(stub, CURRENT)
+
+    conn.execute_action("Wire::Craft::look", "hash", performer="Wire::pair.craft")
+    assert stub.ExecuteAction.call_args.args[0].performer_symbol_id == "Wire::pair.craft"
+    conn.explore_action("Wire::Craft::look", "hash", performer="Wire::pair.spares[1]")
+    assert stub.ExecuteAction.call_args.args[0].performer_symbol_id == "Wire::pair.spares[1]"
+    conn.execute_state("Wire::Craft::modes", "hash", performer="Wire::Craft")
+    assert stub.ExecuteState.call_args.args[0].performer_symbol_id == "Wire::Craft"
+    conn.explore_state("Wire::Craft::modes", "hash", performer="Wire::pair.craft")
+    assert stub.ExecuteState.call_args.args[0].performer_symbol_id == "Wire::pair.craft"
+
+
+def test_no_performer_asks_for_nothing():
+    """A run outside any object keeps working against a service that predates performer."""
+    stub = Mock()
+    stub.ExecuteAction.return_value = sysml_pb2.ExecuteActionResponse()
+    stub.ExecuteState.return_value = sysml_pb2.ExecuteStateResponse()
+    conn = make_connection(stub, EXPLORING)
+
+    conn.execute_action("Wire::Craft::look", "hash")
+    assert stub.ExecuteAction.call_args.args[0].performer_symbol_id == ""
+    conn.explore_state("Wire::Craft::modes", "hash")
+    assert stub.ExecuteState.call_args.args[0].performer_symbol_id == ""
+
+
+def test_a_performer_is_not_sent_to_a_service_without_the_capability():
+    """An older service would run the behavior outside any object, so nothing is sent."""
+    stub = Mock()
+    conn = make_connection(stub, EXPLORING)
+
+    for call in (
+        lambda: conn.execute_action("Wire::Craft::look", "hash", performer="Wire::pair.craft"),
+        lambda: conn.explore_action("Wire::Craft::look", "hash", performer="Wire::pair.craft"),
+        lambda: conn.execute_state("Wire::Craft::modes", "hash", performer="Wire::pair.craft"),
+        lambda: conn.explore_state("Wire::Craft::modes", "hash", performer="Wire::pair.craft"),
+    ):
+        with pytest.raises(MissingCapabilityError) as excinfo:
+            call()
+        assert excinfo.value.capability == CAPABILITY_PERFORMER
+    stub.ExecuteAction.assert_not_called()
+    stub.ExecuteState.assert_not_called()
+
+
+PERFORMER_MODEL = """
+package Wire {
+    private import ScalarValues::*;
+    item def Ping;
+    port def Link { in item ping : Ping; }
+    part def Ground {
+        port p : ~Link;
+        exhibit state hail { entry; then go; state go { entry send new Ping() via p; } }
+    }
+    part def Craft {
+        port p : Link;
+        attribute pinged : Boolean = false;
+        exhibit state modes {
+            entry; then waiting;
+            state waiting;
+            transition first waiting accept Ping via p then active;
+            state active { entry assign pinged := true; }
+        }
+        action look { out seen : Boolean; first start; then action read assign seen := pinged; then done; }
+    }
+    part def Pair {
+        part ground : Ground;
+        part craft : Craft;
+        connect craft.p to ground.p;
+    }
+    part pair : Pair;
+}
+"""
+
+
+@pytest.mark.integration
+class TestPerformerAgainstTheService:
+    """What a caller actually gets back from the real service for a nested performer."""
+
+    def setup_method(self):
+        import grpc
+
+        try:
+            self.conn = Connection(auto_start=False)
+            self.conn._stub.GetDiagnostics(sysml_pb2.DiagnosticsRequest(model_hash=""))
+        except grpc.RpcError as exc:
+            if exc.code() != grpc.StatusCode.NOT_FOUND:
+                self.conn = None
+                skip_or_fail_without_service(
+                    f"the sysml-grpc service on localhost:50051 answered {exc.code()}"
+                )
+        except Exception as exc:
+            self.conn = None
+            skip_or_fail_without_service(
+                f"no sysml-grpc service could be reached on localhost:50051 ({exc})"
+            )
+        self.model = self.conn.load_from_content(PERFORMER_MODEL)
+
+    def teardown_method(self):
+        conn = self.__dict__.get("conn")
+        if conn is not None:
+            conn.close()
+
+    def test_the_service_advertises_performer(self):
+        assert self.conn.server_info().has(CAPABILITY_PERFORMER)
+
+    def test_the_machine_of_a_nested_part_hears_its_sibling(self):
+        run = self.model.execute_state("Wire::Craft::modes", performer="Wire::pair.craft")
+        assert run["states_visited"][-1] == "active"
+        alone = self.model.execute_state("Wire::Craft::modes", performer="Wire::Craft")
+        assert alone["states_visited"][-1] == "waiting"
+
+    def test_an_explored_run_makes_the_assembly_anew(self):
+        exploration = self.model.explore_state("Wire::Craft::modes", performer="Wire::pair.craft")
+        assert [o.final_state for o in exploration] == ["active"]
+        assert exploration.status == "complete (1 runs)"
+
+    def test_an_action_reads_what_the_nested_part_holds(self):
+        outputs = self.model.execute_action("Wire::Craft::look", performer="Wire::pair.craft")
+        assert outputs["seen"] is True
+
+    def test_a_path_to_no_feature_is_refused(self):
+        with pytest.raises(ExecutionError, match='Wire::pair has no feature "tug"'):
+            self.model.execute_state("Wire::Craft::modes", performer="Wire::pair.tug")
diff --git a/clients/python/tests/test_pin_release_checksums.py b/client/python/tests/test_pin_release_checksums.py
similarity index 100%
rename from clients/python/tests/test_pin_release_checksums.py
rename to client/python/tests/test_pin_release_checksums.py
diff --git a/clients/python/tests/test_quantity.py b/client/python/tests/test_quantity.py
similarity index 100%
rename from clients/python/tests/test_quantity.py
rename to client/python/tests/test_quantity.py
diff --git a/clients/python/tests/test_query.py b/client/python/tests/test_query.py
similarity index 100%
rename from clients/python/tests/test_query.py
rename to client/python/tests/test_query.py
diff --git a/clients/python/tests/test_response_compression.py b/client/python/tests/test_response_compression.py
similarity index 100%
rename from clients/python/tests/test_response_compression.py
rename to client/python/tests/test_response_compression.py
diff --git a/clients/python/tests/test_runtime.py b/client/python/tests/test_runtime.py
similarity index 100%
rename from clients/python/tests/test_runtime.py
rename to client/python/tests/test_runtime.py
diff --git a/clients/python/tests/test_runtime_integration.py b/client/python/tests/test_runtime_integration.py
similarity index 100%
rename from clients/python/tests/test_runtime_integration.py
rename to client/python/tests/test_runtime_integration.py
diff --git a/clients/python/tests/test_schedule.py b/client/python/tests/test_schedule.py
similarity index 100%
rename from clients/python/tests/test_schedule.py
rename to client/python/tests/test_schedule.py
diff --git a/clients/python/tests/test_set_tensor.py b/client/python/tests/test_set_tensor.py
similarity index 100%
rename from clients/python/tests/test_set_tensor.py
rename to client/python/tests/test_set_tensor.py
diff --git a/clients/python/tests/test_signing.py b/client/python/tests/test_signing.py
similarity index 99%
rename from clients/python/tests/test_signing.py
rename to client/python/tests/test_signing.py
index 0dbf746cdb..1d8c30d4f8 100644
--- a/clients/python/tests/test_signing.py
+++ b/client/python/tests/test_signing.py
@@ -1,7 +1,7 @@
 """Tests for verifying the signature on a release's checksum manifest.
 
 Every test here is offline: the bundles under tests/fixtures/signed_release were
-recorded by clients/python/scripts/make_signed_release_fixture.py against a root of
+recorded by client/python/scripts/make_signed_release_fixture.py against a root of
 trust recorded beside them, and the release they belong to is served from those
 files rather than fetched. Nothing reaches the network, and nothing depends on
 the digests of the real published assets.
diff --git a/clients/python/tests/test_stale_service.py b/client/python/tests/test_stale_service.py
similarity index 100%
rename from clients/python/tests/test_stale_service.py
rename to client/python/tests/test_stale_service.py
diff --git a/clients/python/tests/test_strict_and_lookup.py b/client/python/tests/test_strict_and_lookup.py
similarity index 100%
rename from clients/python/tests/test_strict_and_lookup.py
rename to client/python/tests/test_strict_and_lookup.py
diff --git a/clients/python/tests/test_strict_conformance.py b/client/python/tests/test_strict_conformance.py
similarity index 100%
rename from clients/python/tests/test_strict_conformance.py
rename to client/python/tests/test_strict_conformance.py
diff --git a/clients/python/tests/test_structured.py b/client/python/tests/test_structured.py
similarity index 100%
rename from clients/python/tests/test_structured.py
rename to client/python/tests/test_structured.py
diff --git a/clients/python/tests/test_sweep_integration.py b/client/python/tests/test_sweep_integration.py
similarity index 100%
rename from clients/python/tests/test_sweep_integration.py
rename to client/python/tests/test_sweep_integration.py
diff --git a/clients/python/tests/test_symbol.py b/client/python/tests/test_symbol.py
similarity index 100%
rename from clients/python/tests/test_symbol.py
rename to client/python/tests/test_symbol.py
diff --git a/clients/python/tests/test_target.py b/client/python/tests/test_target.py
similarity index 100%
rename from clients/python/tests/test_target.py
rename to client/python/tests/test_target.py
diff --git a/clients/python/tests/test_typed.py b/client/python/tests/test_typed.py
similarity index 100%
rename from clients/python/tests/test_typed.py
rename to client/python/tests/test_typed.py
diff --git a/clients/python/tests/test_undetermined.py b/client/python/tests/test_undetermined.py
similarity index 100%
rename from clients/python/tests/test_undetermined.py
rename to client/python/tests/test_undetermined.py
diff --git a/clients/python/tests/test_unset.py b/client/python/tests/test_unset.py
similarity index 100%
rename from clients/python/tests/test_unset.py
rename to client/python/tests/test_unset.py
diff --git a/clients/python/tests/test_verification.py b/client/python/tests/test_verification.py
similarity index 83%
rename from clients/python/tests/test_verification.py
rename to client/python/tests/test_verification.py
index df127e2ef2..b6571c01e1 100644
--- a/clients/python/tests/test_verification.py
+++ b/client/python/tests/test_verification.py
@@ -23,7 +23,9 @@
     WrongKindError,
 )
 from opensysml.proto import sysml_pb2
-from opensysml.verdict import AnalysisResult, CalcResult, Verdict, VerificationVerdict
+from opensysml.verdict import (
+    AnalysisResult, CalcResult, Validation, Verdict, VerificationVerdict,
+)
 
 
 def make_connection(stub):
@@ -443,6 +445,172 @@ def test_verify_satisfaction_narrowed_to_a_symbol():
     assert stub.VerifySatisfaction.call_args[0][0].symbol_id == "Demo::analysis"
 
 
+def validation_response():
+    """A car whose engine fails its constraint and whose second wheel cannot be read."""
+    return sysml_pb2.ValidateInstanceResponse(
+        verdicts=[
+            sysml_pb2.Verdict(
+                kind="constraint", element_id="Demo::Car::massOk", holds=True,
+                instance_id=1, instance_type_id="Demo::car",
+            ),
+            sysml_pb2.Verdict(
+                kind="requirement", element_id="Demo::Car::safe", holds=True,
+                instance_id=1, instance_type_id="Demo::car", requirement_id="Demo::Car::safe",
+            ),
+            sysml_pb2.Verdict(
+                kind="constraint", element_id="Demo::Engine::powerOk", holds=False,
+                condition="power < 200.0", instance_id=2, instance_type_id="Demo::Engine",
+                instance_path="engine",
+            ),
+            sysml_pb2.Verdict(
+                kind="constraint", element_id="Demo::Wheel::pressureOk", holds=True,
+                instance_id=3, instance_type_id="Demo::Wheel", instance_path="wheels[1]",
+            ),
+            sysml_pb2.Verdict(
+                kind="constraint", element_id="Demo::Wheel::pressureOk", holds=False,
+                error="no value for feature pressure", instance_id=4,
+                instance_type_id="Demo::Wheel", instance_path="wheels[2]",
+            ),
+        ],
+        summary=sysml_pb2.Verdict(
+            kind="object", element_id="Demo::car", element="Demo::car", holds=False,
+            instance_id=1, instance_type_id="Demo::car",
+        ),
+        instances=[
+            sysml_pb2.Instance(id=1, type_symbol_id="Demo::car"),
+            sysml_pb2.Instance(id=2, type_symbol_id="Demo::Engine"),
+            sysml_pb2.Instance(id=3, type_symbol_id="Demo::Wheel"),
+            sysml_pb2.Instance(id=4, type_symbol_id="Demo::Wheel"),
+        ],
+        verification_verdicts=[
+            sysml_pb2.VerificationVerdict(
+                case_id="Demo::checkSafe", kind="pass", requirement_id="Demo::Car::safe",
+            ),
+        ],
+        diagnostics=[sysml_pb2.Diagnostic(severity="warning", message="heads up")],
+    )
+
+
+def test_validate_instance_answers_every_assertion_by_path():
+    stub = Mock()
+    stub.ValidateInstance.return_value = validation_response()
+    conn = make_connection(stub)
+
+    validation = conn.validate_instance("Demo::car", "hash1")
+
+    request = stub.ValidateInstance.call_args[0][0]
+    assert (request.model_hash, request.symbol_id) == ("hash1", "Demo::car")
+    assert isinstance(validation, Validation)
+    assert len(validation) == 5
+    assert [v.instance_path for v in validation] == ["", "", "engine", "wheels[1]", "wheels[2]"]
+    assert [v.holds for v in validation] == [True, True, False, True, False]
+    assert not validation
+    assert validation.valid is False
+    assert validation.bounded is False
+    assert [v.element_id for v in validation.violated] == ["Demo::Engine::powerOk"]
+    assert [v.instance_path for v in validation.undecided] == ["wheels[2]"]
+    assert validation.summary.kind == "object"
+    assert validation.summary.holds is False
+    assert validation.summary.evaluated
+    # The verdicts about the requirement take its cases; the summary takes them all.
+    assert [v.case_id for v in validation[1].verifications] == ["Demo::checkSafe"]
+    assert validation[0].verifications == []
+    assert [v.case_id for v in validation.verifications] == ["Demo::checkSafe"]
+    assert [inst.id for inst in validation.instances] == [1, 2, 3, 4]
+    assert [d.message for d in validation.diagnostics] == ["heads up"]
+    assert validation[2].explain() == (
+        "\u2717 constraint Demo::Engine::powerOk at engine fails (on Demo::Engine ID: 2)"
+        ": condition evaluated to false: power < 200.0"
+    )
+    with pytest.raises(ExecutionError, match="wheels\\[2\\].*no value for feature pressure"):
+        validation.raise_for_error()
+
+
+def test_validate_instance_of_a_valid_object_is_truthy():
+    stub = Mock()
+    stub.ValidateInstance.return_value = sysml_pb2.ValidateInstanceResponse(
+        verdicts=[sysml_pb2.Verdict(kind="constraint", element_id="Demo::c", holds=True)],
+        summary=sysml_pb2.Verdict(kind="object", element_id="Demo::p", holds=True),
+    )
+    conn = make_connection(stub)
+
+    validation = conn.validate_instance("Demo::p", "hash1")
+
+    assert validation
+    assert validation.valid
+    assert validation.violated == []
+    assert validation.undecided == []
+    assert validation.raise_for_error() is validation
+    assert str(validation).splitlines()[-1] == "\u2713 object Demo::p holds"
+
+
+def test_validate_instance_an_undecided_tree_is_not_valid():
+    stub = Mock()
+    stub.ValidateInstance.return_value = sysml_pb2.ValidateInstanceResponse(
+        verdicts=[sysml_pb2.Verdict(kind="constraint", element_id="Demo::c", holds=True)],
+        summary=sysml_pb2.Verdict(
+            kind="object", element_id="Demo::p", holds=False,
+            error="not every held object was reached",
+            failure_reason=sysml_pb2.FAILURE_REASON_EVALUATION,
+        ),
+        bounded=True,
+    )
+    conn = make_connection(stub)
+
+    validation = conn.validate_instance("Demo::p", "hash1")
+
+    assert validation.bounded
+    assert not validation.valid
+    assert validation.violated == []
+    assert validation.summary.error == "not every held object was reached"
+
+
+def test_validate_instance_unanswerable_request_raises():
+    stub = Mock()
+    stub.ValidateInstance.return_value = sysml_pb2.ValidateInstanceResponse(
+        error="symbol not found: Demo::nope",
+        diagnostics=[sysml_pb2.Diagnostic(severity="error", message="unknown symbol")],
+    )
+    conn = make_connection(stub)
+
+    with pytest.raises(ExecutionError) as excinfo:
+        conn.validate_instance("Demo::nope", "hash1")
+    assert "Demo::nope" in str(excinfo.value)
+    assert [d.message for d in excinfo.value.diagnostics] == ["unknown symbol"]
+
+
+def test_validate_instance_requires_the_capability():
+    stub = Mock()
+    stub.GetServerInfo.return_value = sysml_pb2.ServerInfoResponse(
+        version="test", capabilities=[CAPABILITY_FEATURE_VALUES],
+    )
+    with patch('grpc.insecure_channel'):
+        with patch('opensysml.proto.sysml_pb2_grpc.SysMLServiceStub', return_value=stub):
+            conn = Connection(auto_start=False)
+
+    with pytest.raises(MissingCapabilityError) as excinfo:
+        conn.validate_instance("Demo::p", "hash1")
+    assert excinfo.value.capability == CAPABILITY_VERIFICATION
+    stub.ValidateInstance.assert_not_called()
+
+
+def test_model_validate_instance_passes_the_models_hash():
+    stub = Mock()
+    stub.ParseFile.return_value = sysml_pb2.ParseFileResponse(
+        model_hash="hash1",
+        root=sysml_pb2.SymbolInfo(id="Demo", name="Demo", kind="Package"),
+    )
+    stub.ValidateInstance.return_value = sysml_pb2.ValidateInstanceResponse(
+        summary=sysml_pb2.Verdict(kind="object", element_id="Demo::p", holds=True),
+    )
+    conn = make_connection(stub)
+    model = conn.load("demo.sysml")
+
+    assert model.validate_instance("Demo::p").valid
+    request = stub.ValidateInstance.call_args[0][0]
+    assert (request.model_hash, request.symbol_id) == ("hash1", "Demo::p")
+
+
 def test_calc_invocation_returns_its_value():
     stub = Mock()
     stub.EvaluateCalc.return_value = sysml_pb2.EvaluateCalcResponse(
diff --git a/clients/python/tests/test_version.py b/client/python/tests/test_version.py
similarity index 92%
rename from clients/python/tests/test_version.py
rename to client/python/tests/test_version.py
index 0c1fea290b..7a35779dc7 100644
--- a/clients/python/tests/test_version.py
+++ b/client/python/tests/test_version.py
@@ -57,7 +57,7 @@ def _skew(dist):
         f"opensysml {dist.version!r} is installed from {installed}, while the tests "
         f"import {imported}, which declares {VERSION!r}. These tests require the "
         f"tree under test to be the installed distribution: run "
-        f"`pip install -e clients/python/` (an artifact of another version installed "
+        f"`pip install -e client/python/` (an artifact of another version installed "
         f"beside the tree reports its own version, and the declaration is the "
         f"single source of truth, so the artifact is what is stale)."
     )
@@ -121,7 +121,7 @@ def test_the_build_takes_the_version_from_the_declaration():
 
 
 def test_no_second_version_declaration():
-    """No file under clients/python/ hard-codes a version literal of its own.
+    """No file under client/python/ hard-codes a version literal of its own.
 
     `_version.py` holds the declaration and the tests compare against it; any
     other `version = "x.y.z"` is the duplication this collapsed.
@@ -154,16 +154,29 @@ def test_setup_py_is_gone():
     assert not os.path.exists(os.path.join(PYTHON_DIR, "setup.py"))
 
 
+def _core_tag(version):
+    """The SemVer core tag naming a PEP 440 version: 0.5.0rc1 is tagged v0.5.0-rc1."""
+    parsed = Version(version)
+    tag = f"v{parsed.base_version}"
+    if parsed.pre is not None:
+        phase, number = parsed.pre
+        tag += f"-{ {'a': 'alpha', 'b': 'beta', 'rc': 'rc'}[phase] }.{number}"
+    return tag
+
+
+TAG = _core_tag(VERSION)
+
+
 def test_version_from_tag_accepts_the_matching_tag():
-    """A tag naming the declared version yields that version."""
-    assert check_version.version_from_tag(f"opensysml-v{VERSION}") == VERSION
+    """The core tag naming the declared version yields that version."""
+    assert check_version.version_from_tag(TAG) == VERSION
 
 
 @pytest.mark.parametrize("tag, expected", [
     ("", "reads CIRCLE_TAG"),
-    ("v0.0.5", "does not start with"),
-    ("opensysml-0.1.0", "does not start with"),
-    ("opensysml-v9.9.9", "declares"),
+    (f"opensysml-v{VERSION}", "does not start with"),
+    ("0.1.0", "does not start with"),
+    ("v9.9.9", "declares"),
 ])
 def test_version_from_tag_rejects(tag, expected):
     """A tag that would publish the wrong version fails, with a reason."""
@@ -186,7 +199,7 @@ def test_pre_release_detection(version, pre):
 def test_check_version_cli_reports_the_version():
     """The job reads the version to publish off this script's stdout."""
     out = subprocess.run(
-        [sys.executable, CHECK_VERSION, "--tag", f"opensysml-v{VERSION}"],
+        [sys.executable, CHECK_VERSION, "--tag", TAG],
         capture_output=True, text=True, check=True,
     )
     assert out.stdout.strip() == VERSION
@@ -195,7 +208,7 @@ def test_check_version_cli_reports_the_version():
 def test_check_version_cli_fails_on_a_mismatched_tag():
     """A mismatch fails the job before anything is built or uploaded."""
     out = subprocess.run(
-        [sys.executable, CHECK_VERSION, "--tag", "opensysml-v9.9.9"],
+        [sys.executable, CHECK_VERSION, "--tag", "v9.9.9"],
         capture_output=True, text=True,
     )
     assert out.returncode == 1
diff --git a/clients/python/tests/test_wire_compat.py b/client/python/tests/test_wire_compat.py
similarity index 93%
rename from clients/python/tests/test_wire_compat.py
rename to client/python/tests/test_wire_compat.py
index d74afca121..774c63c621 100644
--- a/clients/python/tests/test_wire_compat.py
+++ b/client/python/tests/test_wire_compat.py
@@ -347,7 +347,7 @@ def test_apply_edits_is_an_added_rpc():
 def test_edit_messages_pin_their_field_numbers():
     """The edit messages' own numbering, pinned from the release that added it."""
     expected = {
-        "ApplyEditsRequest": {"model_hash": 1, "operations": 2},
+        "ApplyEditsRequest": {"model_hash": 1, "operations": 2, "document": 3, "accept_documents": 4},
         "EditOperation": {
             "set_value": 1, "rename": 2, "add_member": 3, "delete": 4, "move": 5
         },
@@ -366,7 +366,11 @@ def test_edit_messages_pin_their_field_numbers():
             "failure": 4,
             "diagnostics": 5,
             "referring_elements": 6,
+            "documents": 7,
+            "referrers": 8,
         },
+        "EditedDocument": {"name": 1, "content": 2},
+        "Referrer": {"name": 1, "document": 2},
         "AppliedEdit": {
             "operation_index": 1,
             "target": 2,
@@ -374,6 +378,7 @@ def test_edit_messages_pin_their_field_numbers():
             "length": 4,
             "old_text": 5,
             "new_text": 6,
+            "document": 7,
         },
     }
     for message_name, fields in expected.items():
@@ -409,6 +414,7 @@ def test_edit_failure_kinds_keep_their_values():
         "EDIT_FAILURE_DELETE_REFERENCED": 15,
         "EDIT_FAILURE_OWNER_INSIDE_TARGET": 16,
         "EDIT_FAILURE_MOVE_REFERENCED": 17,
+        "EDIT_FAILURE_REFERENCED_ELSEWHERE": 18,
     }
 
 
@@ -430,3 +436,24 @@ def test_an_edit_response_survives_an_older_reader():
     older = sysml_pb2.ServerInfoRequest()
     older.ParseFromString(payload)
     assert older.SerializeToString() == payload
+
+
+def test_a_multi_document_edit_response_keeps_content_for_an_older_reader():
+    """documents rides beside content, so a reader without it still gets content."""
+    response = sysml_pb2.ApplyEditsResponse(
+        content="package Demo { part def SC; }\n",
+        documents=[sysml_pb2.EditedDocument(
+            name="demo.sysml", content="package Demo { part def SC; }\n",
+        )],
+        applied=[sysml_pb2.AppliedEdit(operation_index=0, document="demo.sysml")],
+    )
+    payload = response.SerializeToString()
+
+    again = sysml_pb2.ApplyEditsResponse()
+    again.ParseFromString(payload)
+    assert again == response
+    assert again.content == again.documents[0].content
+
+    older = sysml_pb2.ServerInfoRequest()
+    older.ParseFromString(payload)
+    assert older.SerializeToString() == payload
diff --git a/clients/release-digests.json b/client/release-digests.json
similarity index 100%
rename from clients/release-digests.json
rename to client/release-digests.json
diff --git a/clients/rust/Cargo.lock b/client/rust/Cargo.lock
similarity index 100%
rename from clients/rust/Cargo.lock
rename to client/rust/Cargo.lock
diff --git a/clients/rust/Cargo.toml b/client/rust/Cargo.toml
similarity index 100%
rename from clients/rust/Cargo.toml
rename to client/rust/Cargo.toml
diff --git a/client/rust/README.md b/client/rust/README.md
new file mode 100644
index 0000000000..5a74223de9
--- /dev/null
+++ b/client/rust/README.md
@@ -0,0 +1,252 @@
+# OpenSysML Rust client
+
+`opensysml` is a blocking Rust client for the local `sysml-grpc` service. It
+is not published to crates.io yet. The crate name still needs to be checked
+for availability, and publishing is a maintainer decision.
+
+## Installation
+
+For now, use a path dependency while developing against a checkout:
+
+```toml
+[dependencies]
+opensysml = { path = "../OpenSysML/client/rust/opensysml" }
+```
+
+The current Git dependency form is:
+
+```toml
+[dependencies]
+opensysml = { git = "https://github.com/Open-MBEE/OpenSysML.git", branch = "main" }
+```
+
+The minimum supported Rust version is **Rust 1.83**.
+
+## Why blocking
+
+The client is blocking by default and has no asynchronous runtime anywhere in
+its normal dependency tree. All 15 service RPCs are unary, and the usual
+consumer talks to a local child that answers in milliseconds. Async buys the
+average consumer little here, while putting a private `tokio::Runtime` in a
+library taxes every consumer. That is why this client does not use `tonic`.
+
+The runtime-free design also has no nested-runtime hazard: the library has no
+`Runtime::new` that could panic inside an existing runtime. The
+`blocking_calls_work_inside_a_runtime` test calls the client from
+`Runtime::block_on` to pin this property. An async surface could be added
+later behind a feature flag without changing the default.
+
+## Transport
+
+Protobuf request and response bodies are the default transport. JSON remains
+available as the `curl` and debugging affordance. The measured comparison in
+[`docs/internals/design/transport-evaluation.md`](../../docs/internals/design/transport-evaluation.md)
+was 6.5 ms for protobuf versus 42 ms for JSON on a 468 KB response.
+
+## Service lifecycle
+
+There are two connection modes.
+
+* `Connection::private()` starts one child process per parent process with
+  `-port 0 -health-port 0 -report-address -exit-with-parent`. The address is
+  read from the child's first stdout line. The child is shared, so its parse
+  cache is shared too.
+* `Connection::external(host, port)` explicitly connects to an existing
+  service. `Connection::connect()` also accepts `$OPENSYSML_SERVICE`. Closing
+  an external connection does not stop that service.
+
+`Drop` gives deterministic cleanup rather than relying on Python garbage
+collection or JVM finalizers. `Drop` does not run for `std::process::exit`,
+`abort`, or `SIGKILL`, so the stronger guarantee is the stdin pipe: the client
+holds it, never writes to it, and the child observes EOF when the kernel closes
+it as the process dies. The SIGKILL lifecycle test pins this orphan-cleanup
+behavior.
+
+## Binary provisioning
+
+Resolution is, in order:
+
+1. `$OPENSYSML_GRPC_BINARY`, the explicit path;
+2. `~/.opensysml/bin/sysml-grpc` (`sysml-grpc.exe` on Windows), the cache shared
+   with the Python client;
+3. a download of the release `$OPENSYSML_GRPC_VERSION` asks for, into that cache;
+4. `sysml-grpc` on `$PATH`.
+
+A download only happens when `$OPENSYSML_GRPC_VERSION` names a release
+(`latest` resolves through the GitHub releases API), so a caller that never asks
+for one still resolves a locally built binary from `$PATH`. When a release *is*
+asked for, the download precedes `$PATH`, because a binary on `$PATH` is of no
+known version and so does not answer for that release. A cached binary that is
+another release is replaced with a warning, never used silently; a replacement
+that cannot be downloaded leaves the working cache in place, unless the refusal
+was about integrity.
+
+The download goes to a temporary file, is verified, and only then atomically
+replaces the cache with mode `0700` (POSIX). Requests time out after 15 seconds.
+Beside the binary the client writes `sysml-grpc.json` — `version`, `sha256`,
+`repo` — the same shape the Python client reads and writes, and re-checks the
+recorded digest before reusing a cache, so a hand-swapped binary is not read as
+the release it displaced. Without `$HOME` (`$USERPROFILE` on Windows) there is no
+cache: resolution says so rather than treating the working directory as a home.
+
+The cache is one path several clients install over, so two things guard it. The
+whole check-and-install is done holding `~/.opensysml/bin/sysml-grpc.lock` — the
+same advisory lock the Python and Java clients take (`fcntl` on POSIX,
+`LockFileEx` on Windows) — so no client pairs one release's bytes with another's
+metadata; a lock that cannot be taken across processes is reported and the
+install still runs, rather than failing to resolve a binary at all. What the
+caller is then handed is not the cache path but a hard link (a copy where the
+filesystem has no links) beside it named for its own digest,
+`sysml-grpc-<first 16 hex digits>`, which the Python and Java clients name the
+same way: a later install replaces the cache, never the file that was verified
+and is about to be started.
+
+| Variable | Effect |
+|---|---|
+| `$OPENSYSML_GRPC_BINARY` | Explicit binary path; nothing is downloaded. |
+| `$OPENSYSML_GRPC_VERSION` | Release tag to install, or `latest`. |
+| `$OPENSYSML_GITHUB_REPO` | Repository to download from; default `Open-MBEE/OpenSysML`. |
+| `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD` | `1`, or an `owner/repo` (comma-separated), to accept an unpinned release on same-origin trust. |
+
+### Trust model, and what this client does not verify
+
+A download is verified against the digest table the crate ships
+([`opensysml/release-digests.json`](opensysml/release-digests.json), a synced copy of
+`client/release-digests.json` embedded with `include_str!`) — a pin resolved
+from outside the published artifact would not be a pin. A `.sha256` served
+beside the binary that disagrees with a pin is tampering: the download is
+refused, and the cache is untouched.
+
+**Known limitation:** unlike the Python, Node and Java clients, this client does
+**not** verify the release's sigstore-signed `SHA256SUMS.txt` manifest
+([`client/python/opensysml/signing.py`](../python/opensysml/signing.py) is the
+reference). It verifies pins only, so a release the installed crate version pins
+no digest for cannot be verified here at all and is refused, naming the gap. The
+only way through is `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD`, which accepts the
+served `.sha256` with a warning — same origin as the binary, so it detects
+corruption but not a compromised release. In practice, installing a release
+newer than the crate's pins means upgrading the crate.
+
+## Capability negotiation
+
+The service's advertised capability list is the negotiation surface, and the
+client checks it before it calls rather than relying on the refusal: a request
+that needs a capability the service does not have is refused with
+`UNIMPLEMENTED` naming that capability, and checking first turns that into a
+local error naming what to install instead of a transport round trip.
+Capabilities that only describe how a response is populated omit the fields they
+name rather than refusing the call. The client checks request-side requirements
+for:
+
+* `strict_conformance` when strict parsing is requested;
+* `inline_language` for inline KerML content; and
+* `evaluate_subject` when a subject symbol is supplied for evaluation.
+
+Decoding a response is never gated on capabilities: if a service sends an
+enum, unset value, complex number, array, vector, vector quantity, measurement
+reference, function, set, tensor quantity, metaobject, or feature-value arm,
+the client understands that answer. Consumers can inspect `Capabilities::has`
+or use `Capabilities::require` when they need to gate their own use of
+`enum_values`, `unset_value`, `complex_values`, `structured_values`,
+`measurement_refs`, `function_values`, `set_values`, `tensor_values`,
+`metaobject_values`, `feature_values`, or another advertised operation.
+
+A `Value::Set` is a `Collections::Set`'s elements: each member once, sent in
+the service's canonical order (numbers ascending, then strings, and so on), and
+equal to another set holding the same members in any order. Membership is
+judged by `Value::same_value`, as the service judges it: `Integer(1)` and
+`Real(1.0)` are one member, `Real(1.5)` and a `Complex` of `1.5 + 0.0i` are one
+member, exactly across the whole `i64` range, and a `Quantity` is judged by
+magnitude through its `unit_term`, so `1 m` and `100 cm` are one member (one
+without a `unit_term` in its unit as written); `==` on `Value` stays
+structural. A
+`Value::TensorQuantity` is a `Quantities::TensorQuantityValue` of any rank:
+its `dimensions()` and its `components()` flattened row-major, each a
+`Quantity` with its own unit; `get(&[i, j, k])` takes one coordinate per
+dimension. A rank-one tensor stays a `TensorQuantity`, distinct from a
+`VectorQuantity`. A malformed set (a member listed twice) or tensor (a
+non-positive dimension, or components that do not fill the shape) is an
+`Error::Decode`, never a partial value.
+
+A `Value::Metaobject` is an element of the model held as an instance of its
+reflective metaclass: what `x meta KerML::Feature`, or the last element of
+`x.metadata`, evaluates to. Its `element_id` is the FQN of the element
+reflected on and is its identity: two metaobjects are `==` exactly when their
+`element_id` is, whatever type each was cast to. Its `metaclass_id` is the FQN
+of the element's own metaclass (`SysML::Systems::PartUsage`), not the type it
+was cast to. Its features (`declaredName`, `ownedFeature`, ...) are read in the
+model, not carried. A metaobject naming no element is an `Error::Decode`.
+
+A `Value::Undetermined` is a model-level answer the model leaves open — an
+attribute with no value, a count the multiplicity does not fix — as a
+successful answer rather than an error: `reason` says why, `count_lower` and
+`count_upper` bound its count as the model spells them. It is read, never
+sent. `Value::Infinity` is the unbounded `*`, ordered above every finite
+magnitude. An `EnumLiteral` of an enumeration that specializes a scalar type
+(`enum def Level :> Integer { high = 3; }`) carries that scalar as `value`,
+`None` otherwise.
+
+## Conformance runner
+
+The workspace includes `opensysml-conformance`, which runs the language-neutral
+scenarios through the typed client API. It uses the committed protobuf
+descriptor to decode requests, calls only the public client surface, and reads
+responses through domain `wire()` accessors before comparing normalized JSON.
+The report has per-outcome totals for passed, failed, skipped, and errored
+scenarios, including skipped scenarios.
+
+Run it from the repository root:
+
+```bash
+make conformance-rust
+```
+
+Or run the binary directly:
+
+```bash
+cargo run --manifest-path client/rust/Cargo.toml -p opensysml-conformance -- \
+  -binary bin/sysml-grpc \
+  -scenarios conformance/scenarios \
+  -fixtures conformance/fixtures \
+  -report bin/conformance-report-rust.json
+```
+
+The runner accepts:
+
+* `-binary PATH` to select the service binary;
+* `-run SUBSTRING` to select scenario IDs;
+* `-report FILE` or `-report -` for the JSON report;
+* `-allow-skips` to allow capability-dependent skips;
+* `-v` to print per-scenario timing.
+
+The `-binary` default is `$OPENSYSML_GRPC_BINARY`, then `bin/sysml-grpc`
+relative to the repository root. The two expected v1 boundary skips are
+`v1 API does not cover <RPC>` and
+`unrepresentable by the typed API: ParseFile with no source`. Other skips name
+the missing capability and fail the run unless `-allow-skips` is supplied.
+
+When a covered RPC answers successfully with a non-empty top-level `error`, the
+typed API exposes `Error::Model(message)` and does not retain the rest of that
+response. The runner therefore compares a partial reconstruction,
+`{"error": message}`. This is intentionally fail-safe: an expectation that
+names another response field alongside the top-level error fails rather than
+passing. Widening this representation requires the client to carry the whole
+response on an in-band error, which is outside the v1 boundary.
+
+## v1 boundary
+
+The current API deliberately does not include generated model-ergonomics types
+beyond its existing domain objects, the edit API, RDF conversion, or
+verification helpers. The conformance runner consequently skips RPCs that the
+typed v1 API does not cover.
+
+`Connection::call` is the escape hatch: it sends one method's request message
+from `opensysml::wire` and decodes the response, without the ergonomic layer,
+so an RPC the typed API does not wrap — `RunAnalysis`, `RunSweep` — can still
+be made. In-band `error` fields are the caller's to read; `Capabilities::has`
+gates response fields such as `case_evaluations` the same way.
+
+## Release procedure
+
+Before a release, `cargo package -p opensysml` must succeed cleanly. `cargo
+publish` is a maintainer action; CI never publishes this crate.
diff --git a/clients/rust/conformance/Cargo.toml b/client/rust/conformance/Cargo.toml
similarity index 100%
rename from clients/rust/conformance/Cargo.toml
rename to client/rust/conformance/Cargo.toml
diff --git a/clients/rust/conformance/src/compare.rs b/client/rust/conformance/src/compare.rs
similarity index 100%
rename from clients/rust/conformance/src/compare.rs
rename to client/rust/conformance/src/compare.rs
diff --git a/clients/rust/conformance/src/main.rs b/client/rust/conformance/src/main.rs
similarity index 98%
rename from clients/rust/conformance/src/main.rs
rename to client/rust/conformance/src/main.rs
index 5c695bfa84..ce8cc5ef9a 100644
--- a/clients/rust/conformance/src/main.rs
+++ b/client/rust/conformance/src/main.rs
@@ -196,9 +196,12 @@ fn run() -> Result<(), String> {
     Ok(())
 }
 
+const MULTI_DOCUMENT_SKIP: &str = "v1 API parses one document at a time, not a model of several";
+
 fn is_expected_skip(reason: &str) -> bool {
     reason.starts_with("v1 API does not cover ")
         || reason == "unrepresentable by the typed API: ParseFile with no source"
+        || reason == MULTI_DOCUMENT_SKIP
 }
 
 impl Runner {
@@ -245,6 +248,17 @@ impl Runner {
             result.duration_ms = elapsed_ms(started);
             return result;
         }
+        if scenario
+            .model
+            .as_ref()
+            .is_some_and(|spec| !spec.fixtures.is_empty())
+        {
+            result.outcome = "skip".to_owned();
+            result.status = "-".to_owned();
+            result.reason = MULTI_DOCUMENT_SKIP.to_owned();
+            result.duration_ms = elapsed_ms(started);
+            return result;
+        }
         let model = match scenario.model.as_ref() {
             Some(spec) => match self.model(spec) {
                 Ok(model) => Some(model),
diff --git a/clients/rust/conformance/src/normalize.rs b/client/rust/conformance/src/normalize.rs
similarity index 100%
rename from clients/rust/conformance/src/normalize.rs
rename to client/rust/conformance/src/normalize.rs
diff --git a/clients/rust/conformance/src/scenario.rs b/client/rust/conformance/src/scenario.rs
similarity index 93%
rename from clients/rust/conformance/src/scenario.rs
rename to client/rust/conformance/src/scenario.rs
index 2e94c514c5..3e1bd17c63 100644
--- a/clients/rust/conformance/src/scenario.rs
+++ b/client/rust/conformance/src/scenario.rs
@@ -10,10 +10,15 @@ pub struct SuiteFile {
     pub scenarios: Vec<Scenario>,
 }
 
+/// One fixture parsed alone, or several parsed together as one model, which
+/// the v1 API's single-document parse cannot make.
 #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq)]
 pub struct ModelSpec {
+    #[serde(default)]
     pub fixture: String,
     #[serde(default)]
+    pub fixtures: Vec<String>,
+    #[serde(default)]
     pub language: String,
     #[serde(default)]
     pub strict_conformance: bool,
diff --git a/client/rust/conformance/sysml.descriptor.binpb b/client/rust/conformance/sysml.descriptor.binpb
new file mode 100644
index 0000000000..ae56d7e5b5
Binary files /dev/null and b/client/rust/conformance/sysml.descriptor.binpb differ
diff --git a/clients/rust/opensysml/Cargo.toml b/client/rust/opensysml/Cargo.toml
similarity index 100%
rename from clients/rust/opensysml/Cargo.toml
rename to client/rust/opensysml/Cargo.toml
diff --git a/client/rust/opensysml/examples/capable_model.rs b/client/rust/opensysml/examples/capable_model.rs
new file mode 100644
index 0000000000..88cad621d1
--- /dev/null
+++ b/client/rust/opensysml/examples/capable_model.rs
@@ -0,0 +1,136 @@
+//! A tour of the client over one capable model: parsing, diagnostics, symbol
+//! navigation, evaluation and instantiation.
+//!
+//! ```text
+//! cargo run --manifest-path client/rust/Cargo.toml -p opensysml --example capable_model
+//! ```
+
+use std::error::Error;
+
+use opensysml::{Connection, EvalOptions, Language, ParseOptions, Value};
+
+/// A model with the shapes a client has to decode: quantities, enumerations,
+/// multiplicity, nesting, a calculation, and a feature left without a value.
+const MODEL: &str = r#"
+package Vehicles {
+    private import ScalarValues::*;
+    private import ISQ::*;
+    private import SI::*;
+
+    enum def Color {
+        enum red;
+        enum green;
+        enum blue;
+    }
+
+    part def Engine {
+        attribute mass : MassValue;
+        attribute power : PowerValue;
+    }
+
+    part def Wheel {
+        attribute diameter : LengthValue;
+    }
+
+    part def Car {
+        attribute color : Color;
+        attribute name : String;
+        attribute street : Boolean;
+        attribute mass : MassValue;
+        attribute unpainted : Color;
+        part engine : Engine;
+        part wheels : Wheel[4];
+    }
+
+    part sedan : Car {
+        attribute redefines color = Color::blue;
+        attribute redefines name = "Sedan";
+        attribute redefines street = true;
+        attribute redefines mass = 1600.0 [kg];
+        part redefines engine {
+            attribute redefines mass = 180.0 [kg];
+            attribute redefines power = 90000.0 [W];
+        }
+    }
+
+    calc def Doubled { in x : Real; return : Real = x * 2.0; }
+}
+"#;
+
+fn main() -> Result<(), Box<dyn Error>> {
+    let connection = Connection::connect()?;
+    println!(
+        "sysml-grpc {} with {} capabilities",
+        connection.server_info().wire().version,
+        connection.capabilities().wire().capabilities.len()
+    );
+
+    let model = connection.parse_content(
+        MODEL,
+        &ParseOptions {
+            language: Language::Sysml,
+            strict_conformance: false,
+        },
+    )?;
+    println!("model {}", model.hash());
+    for diagnostic in model.diagnostics() {
+        println!("  {}", diagnostic.wire().message);
+    }
+
+    // Symbols are navigated lazily: each level is a request of its own.
+    let car = model.symbol("Vehicles::Car")?;
+    println!("{} {}", car.kind(), car.name());
+    for child in car.children()? {
+        println!("  {} {}", child.kind(), child.name());
+    }
+
+    // Every expression is evaluated against the model, so a value declared in a
+    // package reads the units and enumerations imported there.
+    for expression in [
+        "1 + 2 * 3",
+        "Vehicles::sedan::name",
+        "Vehicles::sedan::color",
+        "Vehicles::sedan::mass",
+        "Vehicles::sedan::engine::power",
+        "Vehicles::Doubled(21.0)",
+    ] {
+        println!("{expression} => {:?}", model.eval(expression)?);
+    }
+
+    // A feature declaring no value has none, which is a model error rather than
+    // a transport one.
+    match model.eval("Vehicles::sedan::unpainted") {
+        Ok(value) => println!("unpainted => {value:?}"),
+        Err(error) => println!("unpainted => {error}"),
+    }
+
+    // An expression can also be evaluated in the scope of a symbol.
+    let scoped = model.evaluate(
+        "mass",
+        &EvalOptions {
+            context: Some("Vehicles::sedan".to_owned()),
+            subject: None,
+        },
+    )?;
+    println!("mass in Vehicles::sedan => {:?}", scoped.result);
+
+    // Instantiation materializes the object graph. Single-valued features hold a
+    // value, multi-valued ones hold values, and an unvalued one is Unset.
+    let instantiation = model.instantiate("Vehicles::sedan")?;
+    println!(
+        "instantiated {} objects from {}",
+        instantiation.instances().len(),
+        instantiation.instance.type_symbol_id()
+    );
+    let mut features: Vec<_> = instantiation.instance.feature_values().iter().collect();
+    features.sort_by_key(|(name, _)| name.to_owned());
+    for (name, feature) in features {
+        match (feature.value(), feature.values()) {
+            (Some(Value::Unset), _) => println!("  {name} is unset"),
+            (Some(value), _) => println!("  {name} = {value:?}"),
+            (None, values) => println!("  {name} holds {} objects", values.len()),
+        }
+    }
+
+    Ok(())
+}
diff --git a/clients/rust/opensysml/examples/child_probe.rs b/client/rust/opensysml/examples/child_probe.rs
similarity index 100%
rename from clients/rust/opensysml/examples/child_probe.rs
rename to client/rust/opensysml/examples/child_probe.rs
diff --git a/clients/rust/opensysml/release-digests.json b/client/rust/opensysml/release-digests.json
similarity index 100%
rename from clients/rust/opensysml/release-digests.json
rename to client/rust/opensysml/release-digests.json
diff --git a/clients/rust/opensysml/src/binary.rs b/client/rust/opensysml/src/binary.rs
similarity index 100%
rename from clients/rust/opensysml/src/binary.rs
rename to client/rust/opensysml/src/binary.rs
diff --git a/clients/rust/opensysml/src/connection.rs b/client/rust/opensysml/src/connection.rs
similarity index 100%
rename from clients/rust/opensysml/src/connection.rs
rename to client/rust/opensysml/src/connection.rs
diff --git a/clients/rust/opensysml/src/domain.rs b/client/rust/opensysml/src/domain.rs
similarity index 100%
rename from clients/rust/opensysml/src/domain.rs
rename to client/rust/opensysml/src/domain.rs
diff --git a/clients/rust/opensysml/src/error.rs b/client/rust/opensysml/src/error.rs
similarity index 100%
rename from clients/rust/opensysml/src/error.rs
rename to client/rust/opensysml/src/error.rs
diff --git a/clients/rust/opensysml/src/lib.rs b/client/rust/opensysml/src/lib.rs
similarity index 100%
rename from clients/rust/opensysml/src/lib.rs
rename to client/rust/opensysml/src/lib.rs
diff --git a/clients/rust/opensysml/src/proto/sysml/sysml.rs b/client/rust/opensysml/src/proto/sysml/sysml.rs
similarity index 82%
rename from clients/rust/opensysml/src/proto/sysml/sysml.rs
rename to client/rust/opensysml/src/proto/sysml/sysml.rs
index fab0e8c469..97a1d99295 100644
--- a/clients/rust/opensysml/src/proto/sysml/sysml.rs
+++ b/client/rust/opensysml/src/proto/sysml/sysml.rs
@@ -5,7 +5,8 @@
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct Verdict {
     /// What was verified: "constraint", "requirement" or "satisfy"; for a check
-    /// an analysis case run made, "objective" or "assertion".
+    /// an analysis case run made, "objective" or "assertion"; for the summary
+    /// of a ValidateInstance, "object".
     #[prost(string, tag="1")]
     pub kind: ::prost::alloc::string::String,
     /// FQN of the element verified; empty for an anonymous satisfy assertion or
@@ -61,6 +62,12 @@ pub struct Verdict {
     /// The bounds the engine ran under, each marked when it stopped the run.
     #[prost(message, repeated, tag="13")]
     pub bounds: ::prost::alloc::vec::Vec<Bound>,
+    /// For a verdict ValidateInstance reports: the path from the validated object
+    /// to the object this verdict is about, as the REPL spells it ("engine",
+    /// "wheels\[2\]", "engine.injector"). Empty for the validated object itself,
+    /// and for every other RPC.
+    #[prost(string, tag="14")]
+    pub instance_path: ::prost::alloc::string::String,
 }
 /// Bound is one limit an engine ran under, as its plan names it: a count, or
 /// milliseconds for a time, and whether the run stopped at it.
@@ -89,7 +96,10 @@ pub struct VerifyConstraintRequest {
     /// Optional FQN of a part/usage to instantiate and evaluate the constraint
     /// against, so the verdict is about concrete values rather than declared
     /// defaults. The prompt evaluates against the object a %instantiate created;
-    /// a call carries no session, so it names the subject instead.
+    /// a call carries no session, so it names the subject instead. A path from a
+    /// declaration, as `Mission::mission.vehicle`, instantiates the declaration and
+    /// evaluates against the object the path reaches, inside the assembly built
+    /// around it; an index picks from a multi-valued usage, `convoy.escorts\[2\]`.
     #[prost(string, tag="3")]
     pub subject_symbol_id: ::prost::alloc::string::String,
     /// The engine the question is put to, as ListEngines names it, "auto" for the
@@ -124,7 +134,8 @@ pub struct VerifyRequirementRequest {
     /// FQN of the requirement definition or usage to evaluate.
     #[prost(string, tag="2")]
     pub symbol_id: ::prost::alloc::string::String,
-    /// Optional FQN of a part/usage to instantiate and evaluate against.
+    /// Optional FQN of a part/usage to instantiate and evaluate against, or a path
+    /// from one to a nested object; see VerifyConstraintRequest.subject_symbol_id.
     #[prost(string, tag="3")]
     pub subject_symbol_id: ::prost::alloc::string::String,
     /// The engine the question is put to; see VerifyConstraintRequest.engine.
@@ -210,6 +221,56 @@ pub struct VerifySatisfactionResponse {
     #[prost(message, repeated, tag="6")]
     pub verification_verdicts: ::prost::alloc::vec::Vec<VerificationVerdict>,
 }
+/// ValidateInstanceRequest asks for every assertion about an object of a part,
+/// as %validate does: the object is built for the call.
+#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
+pub struct ValidateInstanceRequest {
+    #[prost(string, tag="1")]
+    pub model_hash: ::prost::alloc::string::String,
+    /// FQN of the part or usage an object of which is validated.
+    #[prost(string, tag="2")]
+    pub symbol_id: ::prost::alloc::string::String,
+    /// The engine the validation is put to; see VerifyConstraintRequest.engine.
+    #[prost(string, tag="3")]
+    pub engine: ::prost::alloc::string::String,
+}
+/// ValidateInstanceResponse carries one verdict per assertion, in the order the
+/// objects were reached from the validated one, and the verdict about the object
+/// as a whole. An object no assertion is about has an empty list and a summary
+/// that decides nothing.
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct ValidateInstanceResponse {
+    /// One per assertion: kind "constraint", "requirement" or "satisfy", about the
+    /// object `instance_id` names, reached along `instance_path`.
+    #[prost(message, repeated, tag="1")]
+    pub verdicts: ::prost::alloc::vec::Vec<Verdict>,
+    /// The object as a whole, kind "object": holds when every assertion holds and
+    /// every held object was reached. With `error` empty, false is the model's
+    /// answer, some assertion failing; with `error` set, nothing was decided, an
+    /// assertion being undecided or nesting left unreached.
+    #[prost(message, optional, tag="2")]
+    pub summary: ::core::option::Option<Verdict>,
+    /// Every object reached, the validated one first, so a client can read the
+    /// feature values behind each verdict.
+    #[prost(message, repeated, tag="3")]
+    pub instances: ::prost::alloc::vec::Vec<Instance>,
+    #[prost(string, tag="4")]
+    pub error: ::prost::alloc::string::String,
+    #[prost(message, repeated, tag="5")]
+    pub diagnostics: ::prost::alloc::vec::Vec<Diagnostic>,
+    /// What kind of failure `error` reports.
+    #[prost(enumeration="FailureReason", tag="6")]
+    pub failure_reason: i32,
+    /// What the body of every verification case verifying a requirement a verdict
+    /// is about answered, once per requirement; each names its requirement, which
+    /// the requirement and satisfy verdicts carry as their requirement_id.
+    #[prost(message, repeated, tag="7")]
+    pub verification_verdicts: ::prost::alloc::vec::Vec<VerificationVerdict>,
+    /// True when nesting deeper than the validation descends, or past its budget,
+    /// was left unvalidated; the summary then decides nothing.
+    #[prost(bool, tag="8")]
+    pub bounded: bool,
+}
 /// EvaluateCalcRequest invokes a calculation, as %calc does. Arguments are bound
 /// positionally; a calc usage named with no arguments binds its inputs from its
 /// own members and reports every output feature it computes (SysML 7.17).
@@ -303,7 +364,10 @@ pub struct RunAnalysisRequest {
     pub symbol_id: ::prost::alloc::string::String,
     /// Optional FQN of a part/usage to instantiate as the case's subject. Empty
     /// leaves the case's own `subject s = ...` binding to supply it; a case that
-    /// binds none and is given none fails to run.
+    /// binds none and is given none fails to run. A path from a declaration, as
+    /// `Mission::mission.vehicle`, makes the subject the object the path reaches in
+    /// an object of the declaration built for the run — every explored run its own
+    /// — so what the assembly binds and connects on it is in force.
     #[prost(string, tag="3")]
     pub subject_symbol_id: ::prost::alloc::string::String,
     /// Positional arguments for the case's input parameters, in declaration order;
@@ -413,6 +477,10 @@ pub struct Outcome {
     /// one run.
     #[prost(message, repeated, tag="7")]
     pub diagnostics: ::prost::alloc::vec::Vec<Diagnostic>,
+    /// The probability of the linearizations reaching this outcome, as explore
+    /// computes it; a lower bound when the exploration is incomplete.
+    #[prost(double, tag="8")]
+    pub probability: f64,
 }
 /// ExplorationStatus is how an exploration ended: whether every linearization
 /// within the budget was run, and which budget stopped it when not.
@@ -433,6 +501,10 @@ pub struct ExplorationStatus {
     pub runs_budget: i32,
     #[prost(int32, tag="5")]
     pub depth_budget: i32,
+    /// True when the outcomes' probabilities are lower bounds: a budget kept some
+    /// linearizations unexplored.
+    #[prost(bool, tag="6")]
+    pub probabilities_lower_bound: bool,
 }
 /// ListEnginesRequest asks for the analysis engines registered in this build.
 #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
@@ -691,7 +763,11 @@ pub struct InstantiateRequest {
     #[prost(string, tag="2")]
     pub symbol_id: ::prost::alloc::string::String,
 }
-/// InstantiateResponse contains the created instance
+/// InstantiateResponse contains the created instance. The service keeps the
+/// object for the model it was created in: a later RunDocumentQuery on the same
+/// model_hash binds it by its id or by the name it was instantiated under, and
+/// `DocumentQueries::Objects` enumerates it. Instantiating the same symbol again
+/// creates a new object under the name; the earlier one stays reachable by id.
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct InstantiateResponse {
     #[prost(message, optional, tag="1")]
@@ -724,6 +800,13 @@ pub struct ExecuteActionRequest {
     /// a fresh context, and answers with every distinct outcome reached.
     #[prost(string, tag="4")]
     pub schedule: ::prost::alloc::string::String,
+    /// Optional FQN of a part/usage to perform the action on, or a path from one to
+    /// a nested object, as `Mission::mission.vehicle`: the object is created for the
+    /// run — inside the assembly the path walks, so its connectors carry what the
+    /// action sends — and under explore each run creates its own. Empty performs
+    /// the action outside any object.
+    #[prost(string, tag="5")]
+    pub performer_symbol_id: ::prost::alloc::string::String,
 }
 /// ExecuteActionResponse contains action execution results
 #[derive(Clone, PartialEq, ::prost::Message)]
@@ -766,6 +849,13 @@ pub struct ExecuteStateRequest {
     /// states_visited, final_context and error (see ExecuteActionResponse).
     #[prost(string, tag="4")]
     pub schedule: ::prost::alloc::string::String,
+    /// Optional FQN of a part/usage to run the machine on, or a path from one to a
+    /// nested object (see ExecuteActionRequest.performer_symbol_id). An object
+    /// exhibiting the machine runs the one it exhibits, so what its assembly
+    /// connects to it reaches the run; one exhibiting it under several usages is
+    /// an error. Empty runs the machine outside any object.
+    #[prost(string, tag="5")]
+    pub performer_symbol_id: ::prost::alloc::string::String,
 }
 /// ExecuteStateResponse contains state machine execution trace
 #[derive(Clone, PartialEq, ::prost::Message)]
@@ -798,12 +888,15 @@ pub struct ExecuteStateResponse {
 /// change the answer; a file_path is read afresh and content is carried inline.
 #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
 pub struct ConvertRequest {
-    /// "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-    /// file_path's extension, and is notation for a model_hash, since that is what
-    /// parse reads; inline content has neither, so it must say.
+    /// "sysml", "kerml", "text", "ttl", "turtle", "rdf", "api-json" or "json", or
+    /// "xmi", "uml" or "mdzip" for a SysML v1 model, which is read and migrated to
+    /// v2 and never written. Empty infers from file_path's extension, and is
+    /// notation for a model_hash, since that is what parse reads; inline content
+    /// has neither, so it must say.
     #[prost(string, tag="3")]
     pub from_format: ::prost::alloc::string::String,
-    /// Format to write, named as in from_format. Empty is rejected.
+    /// Format to write, named as in from_format; the v1 names are refused, since
+    /// a v2 model has no v1 form. Empty is rejected.
     #[prost(string, tag="4")]
     pub to_format: ::prost::alloc::string::String,
     /// Write notation back out even when the parser could not read all of it,
@@ -845,10 +938,12 @@ pub struct ConvertResponse {
     /// the conversion.
     #[prost(message, repeated, tag="5")]
     pub diagnostics: ::prost::alloc::vec::Vec<Diagnostic>,
-    /// Set when either format is RDF, whose mapping is experimental: it covers
-    /// model structure and the behavior its bodies state, refuses what it cannot
-    /// write back, and its vocabulary may change without a compatibility path.
-    /// Notation to notation is stable and leaves this unset.
+    /// Set when either format is RDF or the API's JSON element form, whose
+    /// mapping is experimental: it covers model structure and the behavior its
+    /// bodies state, refuses what it cannot write back, and its vocabulary may
+    /// change without a compatibility path. Also set when the source is SysML v1,
+    /// whose migration is experimental in the same sense. Notation to notation is
+    /// stable and leaves this unset.
     #[prost(bool, tag="6")]
     pub experimental: bool,
     /// What is experimental about the conversion, in the wording every surface
@@ -858,16 +953,34 @@ pub struct ConvertResponse {
 }
 /// ApplyEditsRequest asks for a model's source with edits applied to it. The
 /// source edited is the one parse read, named by its hash, so an edit is applied
-/// to the model that was inspected.
+/// to the model that was inspected. A model of several documents (ParseSources)
+/// is edited as one when the request sets `accept_documents`: the operations
+/// target declarations of the document named by `document`, and a rename or
+/// cascade delete follows references into every other document of the model,
+/// rewriting those too. `document` and `accept_documents` are advertised as the
+/// "edit_documents" capability: a service without it edits a model of one
+/// document alone and answers `content` alone, so a client checks it before
+/// naming a document or reading `documents`.
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct ApplyEditsRequest {
-    /// from ParseFile response
+    /// from a ParseFile or ParseSources response
     #[prost(string, tag="1")]
     pub model_hash: ::prost::alloc::string::String,
     /// Edits to apply, all of them or none. Empty is rejected: it names no edit,
     /// and answering with the unedited source would look like one was made.
     #[prost(message, repeated, tag="2")]
     pub operations: ::prost::alloc::vec::Vec<EditOperation>,
+    /// The document whose declarations the operations target, named as the parse
+    /// request named it; empty names the model's first document, which is the
+    /// only one of a ParseFile model. An operation targeting a declaration of
+    /// another document is refused as an unknown target, naming that document. A
+    /// name no document of the model has fails the call as an invalid argument.
+    #[prost(string, tag="3")]
+    pub document: ::prost::alloc::string::String,
+    /// Whether the client reads the response's `documents`. A model of several documents is
+    /// edited only when set; unset, such a model is refused as a failed precondition, as before.
+    #[prost(bool, tag="4")]
+    pub accept_documents: bool,
 }
 /// EditOperation is one source-preserving change to make.
 #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
@@ -951,9 +1064,10 @@ pub struct SetValueEdit {
     #[prost(string, tag="2")]
     pub value: ::prost::alloc::string::String,
 }
-/// RenameEdit rewrites the name token of a declaration. References to the
-/// element are not updated: a rename of an element referenced anywhere is
-/// refused, naming the referring elements, rather than leaving a broken model.
+/// RenameEdit rewrites the name token of a declaration and every reference to
+/// it in the model's documents. A rename that reaches a reference in a document
+/// the edit cannot rewrite is refused, naming the referring elements, rather
+/// than leaving a broken model.
 #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
 pub struct RenameEdit {
     /// element to rename, as SymbolInfo.id names it
@@ -964,16 +1078,24 @@ pub struct RenameEdit {
     pub new_name: ::prost::alloc::string::String,
 }
 /// ApplyEditsResponse carries the edited source, or says why nothing was edited.
+/// The edited notation is in `documents`, one entry per document the edits
+/// rewrote; `content` repeats it for a single-document model only.
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct ApplyEditsResponse {
-    /// The edited notation, byte-identical to the source outside the edited spans.
-    /// Empty when the edits were refused, so a refusal never writes a file.
+    /// The edited notation of a single-document model, byte-identical to the
+    /// source outside the edited spans. Empty when the edits were refused, so a
+    /// refusal never writes a file, and empty for a model of several documents,
+    /// whose edited notation is in `documents` alone: a client that reads
+    /// `content` alone was written for one document and must not write one
+    /// document's notation over another's. `documents` carries the same notation
+    /// for a single-document model, so a client needs one code path.
     #[prost(string, tag="1")]
     pub content: ::prost::alloc::string::String,
-    /// What each operation changed, in request order.
+    /// What each operation changed, grouped by document in the order `documents`
+    /// lists them and in request order within a document.
     #[prost(message, repeated, tag="2")]
     pub applied: ::prost::alloc::vec::Vec<AppliedEdit>,
-    /// non-empty if the edits were refused; content is unset
+    /// non-empty if the edits were refused; content and documents are unset
     #[prost(string, tag="3")]
     pub error: ::prost::alloc::string::String,
     /// Which kind of refusal `error` reports, so a client acts on the kind rather
@@ -981,13 +1103,51 @@ pub struct ApplyEditsResponse {
     #[prost(enumeration="EditFailure", tag="4")]
     pub failure: i32,
     /// Diagnostics behind a refusal: the parse errors of an unreadable new value,
-    /// or the errors the edited source was found to have.
+    /// or the errors the edited source was found to have. A diagnostic's span
+    /// names the document it is in.
     #[prost(message, repeated, tag="5")]
     pub diagnostics: ::prost::alloc::vec::Vec<Diagnostic>,
-    /// Where the references to a declaration whose rename was refused are made:
-    /// the FQN of each referring namespace.
+    /// Where the references to a declaration whose rename, delete or move was
+    /// refused are made: the FQN of each referring namespace, suffixed with its
+    /// document in parentheses when that is not the document being edited.
+    /// `referrers` carries the same list with the document as a field of its own.
     #[prost(string, repeated, tag="6")]
     pub referring_elements: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+    /// The edited notation of every document the edits rewrote, named as the
+    /// parse request named it: the document being edited first, then the others
+    /// in name order. A document of several the edits left as parsed is not
+    /// listed; the one document of a single-document model always is. Empty when
+    /// the edits were refused.
+    #[prost(message, repeated, tag="7")]
+    pub documents: ::prost::alloc::vec::Vec<EditedDocument>,
+    /// The declarations referring to the target of a refused rename, delete or
+    /// move, each with the document declaring it, in document then name order.
+    /// Empty when `referring_elements` is.
+    #[prost(message, repeated, tag="8")]
+    pub referrers: ::prost::alloc::vec::Vec<Referrer>,
+}
+/// EditedDocument is the edited notation of one document of the model.
+#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
+pub struct EditedDocument {
+    /// The document's name as the parse request gave it: the file path of a
+    /// file_path document, the name of an inline one, or the position it was
+    /// given when it was named neither.
+    #[prost(string, tag="1")]
+    pub name: ::prost::alloc::string::String,
+    /// The edited notation, byte-identical to the source outside the edited spans.
+    #[prost(string, tag="2")]
+    pub content: ::prost::alloc::string::String,
+}
+/// Referrer is one declaration referring to the target of a refused edit.
+#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
+pub struct Referrer {
+    /// The declaration as the notation names it: the FQN of a named one, or the
+    /// heading of an anonymous one within its namespace.
+    #[prost(string, tag="1")]
+    pub name: ::prost::alloc::string::String,
+    /// The document declaring it, named as the parse request named it.
+    #[prost(string, tag="2")]
+    pub document: ::prost::alloc::string::String,
 }
 /// AppliedEdit is one byte range of the original source that an operation
 /// replaced, so a client can report or locate what changed.
@@ -1012,6 +1172,10 @@ pub struct AppliedEdit {
     /// what was written
     #[prost(string, tag="6")]
     pub new_text: ::prost::alloc::string::String,
+    /// The document the bytes are in, named as the parse request named it and as
+    /// ApplyEditsResponse.documents lists it. Set for a single-document model too.
+    #[prost(string, tag="7")]
+    pub document: ::prost::alloc::string::String,
 }
 /// SymbolInfo represents any SysML element
 #[derive(Clone, PartialEq, ::prost::Message)]
@@ -1519,6 +1683,14 @@ pub struct ServerInfoResponse {
     ///                   rather than read as another value.
     ///    "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
     ///                   preserving everything the edit did not touch.
+    ///    "edit_documents" - ApplyEdits edits a model of several documents as one
+    ///                   batch for a request setting accept_documents, targets the
+    ///                   document the request names, and answers each edited
+    ///                   document by name in `documents`, each referrer of a refusal
+    ///                   with its document in `referrers`, and each applied edit's
+    ///                   `document`. Without it those fields are empty, a model of
+    ///                   several documents is refused with FAILED_PRECONDITION, and
+    ///                   a request naming a document is refused with UNIMPLEMENTED.
     ///    "document_query" - the RunDocumentQuery RPC runs a named document query
     ///                   and answers with typed rows.
     ///    "render_document" - the RenderDocument RPC renders a named document to
@@ -1687,7 +1859,8 @@ pub struct RunSweepRequest {
     /// FQN of the analysis case or calc, definition or usage.
     #[prost(string, tag="2")]
     pub symbol_id: ::prost::alloc::string::String,
-    /// Optional FQN of a part/usage to instantiate as an analysis case's subject.
+    /// Optional FQN of a part/usage to instantiate as an analysis case's subject,
+    /// or a path from one to a nested object; see RunAnalysisRequest.subject_symbol_id.
     #[prost(string, tag="3")]
     pub subject_symbol_id: ::prost::alloc::string::String,
     /// Positional arguments for the target's input parameters, in declaration
@@ -1786,7 +1959,9 @@ pub struct RunSweepResponse {
 /// RunDocumentQueryRequest runs a named document query — a calc def
 /// specializing DocumentQueries::Query — against a model the service already
 /// parsed, binding its entry parameters. It answers as %run-query does, but with
-/// typed rows rather than formatted lines.
+/// typed rows rather than formatted lines. The query runs over the objects
+/// Instantiate created for the model, which a binding may name (DocumentObject)
+/// and `DocumentQueries::Objects` and `Verdicts` reach.
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct RunDocumentQueryRequest {
     /// from ParseFile response
@@ -1810,15 +1985,15 @@ pub struct DocumentQueryBinding {
     pub values: ::prost::alloc::vec::Vec<DocumentValue>,
 }
 /// DocumentValue is one typed document-query value. A request binds a model
-/// element by qualified name in element_id; a response also says what the
-/// element is in element_type. `infinity` denotes an unbounded multiplicity and
-/// is only ever answered, never bound.
+/// element by qualified name in element_id, or an object the service holds in
+/// object; a response also says what the element is in element_type. `infinity`
+/// denotes an unbounded multiplicity and is only ever answered, never bound.
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct DocumentValue {
     /// Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
     #[prost(string, tag="7")]
     pub element_type: ::prost::alloc::string::String,
-    #[prost(oneof="document_value::Kind", tags="1, 2, 3, 4, 5, 6, 8")]
+    #[prost(oneof="document_value::Kind", tags="1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12")]
     pub kind: ::core::option::Option<document_value::Kind>,
 }
 /// Nested message and enum types in `DocumentValue`.
@@ -1841,8 +2016,154 @@ pub mod document_value {
         /// magnitude in a unit, `2290000 \[kg\]`
         #[prost(message, tag="8")]
         Quantity(super::Quantity),
+        /// a row Verdicts answered; answered, never bound
+        #[prost(message, tag="9")]
+        Verdict(::prost::alloc::boxed::Box<super::DocumentVerdict>),
+        /// an object Instantiate created; bound and answered
+        #[prost(message, tag="10")]
+        Object(::prost::alloc::boxed::Box<super::DocumentObject>),
+        /// a row States answered; answered, never bound
+        #[prost(message, tag="11")]
+        State(::prost::alloc::boxed::Box<super::DocumentState>),
+        /// a row Events answered; answered, never bound
+        #[prost(message, tag="12")]
+        Event(::prost::alloc::boxed::Box<super::DocumentEvent>),
     }
 }
+/// DocumentObject is an object the service holds for the model, created by
+/// Instantiate, as a query binds and answers it. A request names it by path
+/// when path is set, and by instance_id otherwise; a request setting both must
+/// name one object by both. A response answers both, and the element the object
+/// stands for.
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct DocumentObject {
+    /// The object's id, as Instantiate answered it. Fails with NOT_FOUND when no
+    /// object of the model has it.
+    #[prost(int64, tag="1")]
+    pub instance_id: i64,
+    /// The object by the label a session reaches it under: the qualified name it
+    /// was instantiated as (`Garage::car`), its id (`#2`), or a path through the
+    /// feature values of either (`Garage::car.wheels\[2\]`, `#2.wheels\[2\]`; indexes
+    /// count from 1). Fails with INVALID_ARGUMENT when the text is no reference or
+    /// a segment reaches no object, and NOT_FOUND when the name it starts from has
+    /// no object.
+    #[prost(string, tag="2")]
+    pub path: ::prost::alloc::string::String,
+    /// The usage the object is held under — its own definition or usage for an
+    /// object Instantiate created — as an element value with its qualified name
+    /// and metamodel type. Answered, ignored when bound.
+    #[prost(message, optional, boxed, tag="3")]
+    pub element: ::core::option::Option<::prost::alloc::boxed::Box<DocumentValue>>,
+}
+/// DocumentVerdict is one row a `Verdicts` query answered: an assertion checked
+/// on the object at `path`, which the row stands for as its `element`.
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct DocumentVerdict {
+    /// The assertion checked, as an element value: the constraint, requirement,
+    /// satisfy usage or verification case, its element_id empty when anonymous.
+    #[prost(message, optional, boxed, tag="1")]
+    pub assertion: ::core::option::Option<::prost::alloc::boxed::Box<DocumentValue>>,
+    /// "constraint", "requirement", "satisfaction" or "verification".
+    #[prost(string, tag="2")]
+    pub kind: ::prost::alloc::string::String,
+    /// The assertion as written ("assert constraint massKnown", "satisfy Range by
+    /// cruise", "verification Tests::massTest"), naming an anonymous one.
+    #[prost(string, tag="3")]
+    pub text: ::prost::alloc::string::String,
+    /// The object checked, by path from the element the query was bound to
+    /// ("Garage::car.wheels\[2\]"); the element's own qualified name for itself.
+    #[prost(string, tag="4")]
+    pub path: ::prost::alloc::string::String,
+    /// "holds", "violated" or "undecided".
+    #[prost(string, tag="5")]
+    pub verdict: ::prost::alloc::string::String,
+    /// The condition that evaluated to false, as written; empty otherwise.
+    #[prost(string, tag="6")]
+    pub condition: ::prost::alloc::string::String,
+    /// Why the assertion is violated or undecided; empty when it holds.
+    #[prost(string, tag="7")]
+    pub reason: ::prost::alloc::string::String,
+    /// The verdict kinds ("pass", "fail", "inconclusive", "error") of the
+    /// verification cases verifying the requirement the row is about; a
+    /// verification row's own kind. Empty for a constraint.
+    #[prost(string, repeated, tag="8")]
+    pub verification: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+}
+/// DocumentState is one row a `States` query answered: an active leaf state of
+/// the state machine `object` exhibits, which the row stands for as its `element`
+/// when the model declares it.
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct DocumentState {
+    /// The object in the state, with the path the session reaches it by.
+    #[prost(message, optional, boxed, tag="1")]
+    pub object: ::core::option::Option<::prost::alloc::boxed::Box<DocumentObject>>,
+    /// The state machine, by the name the object exhibits it under (`lp`), else
+    /// its declared name.
+    #[prost(string, tag="2")]
+    pub machine: ::prost::alloc::string::String,
+    /// The leaf state's name.
+    #[prost(string, tag="3")]
+    pub name: ::prost::alloc::string::String,
+    /// The leaf state's path in its machine, composite states first (`on.dim`).
+    #[prost(string, tag="4")]
+    pub state_path: ::prost::alloc::string::String,
+    /// The leaf state's declaration as an element value; empty when the machine
+    /// declares no element for it.
+    #[prost(message, optional, boxed, tag="5")]
+    pub state: ::core::option::Option<::prost::alloc::boxed::Box<DocumentValue>>,
+    /// The orthogonal region the leaf is declared in; empty outside one.
+    #[prost(string, tag="6")]
+    pub region: ::prost::alloc::string::String,
+    /// The composite states enclosing the leaf, outermost first; each is active.
+    #[prost(string, repeated, tag="7")]
+    pub enclosing: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+}
+/// DocumentEvent is one row an `Events` query answered: one record of the
+/// session's trace, in the order the run made it.
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct DocumentEvent {
+    /// "accept", "send", "transition", "entry", "exit", "do", "choice" or "guard".
+    #[prost(string, tag="1")]
+    pub kind: ::prost::alloc::string::String,
+    /// The clock's instant when the record was made: a quantity in the clock's
+    /// unit when the library reduces one, else a bare real of clock units.
+    #[prost(message, optional, boxed, tag="2")]
+    pub time: ::core::option::Option<::prost::alloc::boxed::Box<DocumentValue>>,
+    /// The object whose behavior made the record; unset for the run's own
+    /// records (a due-order choice, a message posted from outside the model).
+    #[prost(message, optional, boxed, tag="3")]
+    pub object: ::core::option::Option<::prost::alloc::boxed::Box<DocumentObject>>,
+    /// The behavior the record is about, by the name the object exhibits it
+    /// under, else its declared name; empty for the run's own records.
+    #[prost(string, tag="4")]
+    pub machine: ::prost::alloc::string::String,
+    /// The state entered, exited or stepped by an entry, exit or do record.
+    #[prost(string, tag="5")]
+    pub state: ::prost::alloc::string::String,
+    /// A fired transition's source and target states.
+    #[prost(string, tag="6")]
+    pub from: ::prost::alloc::string::String,
+    #[prost(string, tag="7")]
+    pub to: ::prost::alloc::string::String,
+    /// The object a send was addressed to; unset for every other kind and a
+    /// send addressed to no object.
+    #[prost(message, optional, boxed, tag="8")]
+    pub target: ::core::option::Option<::prost::alloc::boxed::Box<DocumentObject>>,
+    /// The signal or event accepted or sent, or the trigger a transition fired on.
+    #[prost(string, tag="9")]
+    pub event: ::prost::alloc::string::String,
+    /// An accept's payload, one `name = value` entry per parameter in name order.
+    #[prost(string, repeated, tag="10")]
+    pub payload: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+    /// A choice's alternatives, in the order they were offered, and the one taken.
+    #[prost(string, repeated, tag="11")]
+    pub alternatives: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+    #[prost(string, tag="12")]
+    pub taken: ::prost::alloc::string::String,
+    /// The line the trace prints for the record.
+    #[prost(string, tag="13")]
+    pub text: ::prost::alloc::string::String,
+}
 /// DocumentQueryColumn is one projected property, in projection order.
 #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
 pub struct DocumentQueryColumn {
@@ -1859,7 +2180,10 @@ pub struct DocumentQueryCell {
 /// column in column order.
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct DocumentQueryRow {
-    /// The selected element itself, an element value with its qualified name.
+    /// The selected element itself, an element value with its qualified name; an
+    /// object value for a row over an object the service holds; a verdict value
+    /// for a row `Verdicts` answered; a state value for a row `States` answered;
+    /// an event value for a row `Events` answered.
     #[prost(message, optional, tag="1")]
     pub element: ::core::option::Option<DocumentValue>,
     #[prost(message, repeated, tag="2")]
@@ -1877,8 +2201,8 @@ pub struct RunDocumentQueryResponse {
     pub rows: ::prost::alloc::vec::Vec<DocumentQueryRow>,
 }
 /// RenderDocumentRequest renders a named document — a part def specializing
-/// DocumentQueries::Document — to Markdown. A document binds its queries'
-/// parameters in the model, so the request carries none.
+/// DocumentQueries::Document — to Markdown or HTML. A document binds its
+/// queries' parameters in the model, so the request carries none.
 #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
 pub struct RenderDocumentRequest {
     /// from ParseFile response
@@ -1888,13 +2212,22 @@ pub struct RenderDocumentRequest {
     /// not declare it, and INVALID_ARGUMENT when it declares something else.
     #[prost(string, tag="2")]
     pub document_id: ::prost::alloc::string::String,
+    /// Form to render: "markdown" (the default when empty) or "html", the
+    /// standalone page with the default stylesheet that the CLI's -doc-form html
+    /// writes. Any other form fails with INVALID_ARGUMENT; PDF needs the CLI's
+    /// converter toolchain and is not offered here.
+    #[prost(string, tag="3")]
+    pub form: ::prost::alloc::string::String,
 }
-/// RenderDocumentResponse carries the rendered Markdown, byte-for-byte what the
-/// CLI's -render-document writes.
+/// RenderDocumentResponse carries the rendered document in the form requested,
+/// byte-for-byte what the CLI's -render-document writes: markdown for the
+/// Markdown form, html for the HTML form, the other left empty.
 #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
 pub struct RenderDocumentResponse {
     #[prost(string, tag="1")]
     pub markdown: ::prost::alloc::string::String,
+    #[prost(string, tag="2")]
+    pub html: ::prost::alloc::string::String,
 }
 /// FailureReason says what kind of failure an `error` reports, so a client acts
 /// on the kind rather than on the message text.
@@ -1975,6 +2308,10 @@ pub enum EditFailure {
     OwnerInsideTarget = 16,
     /// move would leave a reference no spelling restores
     MoveReferenced = 17,
+    /// the target is referred to from a document the edit cannot rewrite: a
+    /// library document, or any other document for a move, which respells
+    /// references in the target's own document only
+    ReferencedElsewhere = 18,
 }
 impl EditFailure {
     /// String value of the enum field names used in the ProtoBuf definition.
@@ -2001,6 +2338,7 @@ impl EditFailure {
             Self::DeleteReferenced => "EDIT_FAILURE_DELETE_REFERENCED",
             Self::OwnerInsideTarget => "EDIT_FAILURE_OWNER_INSIDE_TARGET",
             Self::MoveReferenced => "EDIT_FAILURE_MOVE_REFERENCED",
+            Self::ReferencedElsewhere => "EDIT_FAILURE_REFERENCED_ELSEWHERE",
         }
     }
     /// Creates an enum from field names used in the ProtoBuf definition.
@@ -2024,6 +2362,7 @@ impl EditFailure {
             "EDIT_FAILURE_DELETE_REFERENCED" => Some(Self::DeleteReferenced),
             "EDIT_FAILURE_OWNER_INSIDE_TARGET" => Some(Self::OwnerInsideTarget),
             "EDIT_FAILURE_MOVE_REFERENCED" => Some(Self::MoveReferenced),
+            "EDIT_FAILURE_REFERENCED_ELSEWHERE" => Some(Self::ReferencedElsewhere),
             _ => None,
         }
     }
diff --git a/client/rust/opensysml/tests/capable_model.rs b/client/rust/opensysml/tests/capable_model.rs
new file mode 100644
index 0000000000..c546f58fdf
--- /dev/null
+++ b/client/rust/opensysml/tests/capable_model.rs
@@ -0,0 +1,274 @@
+#![allow(missing_docs)]
+
+//! The client read over one capable model — quantities, enumerations,
+//! multiplicity, nesting and a feature left without a value — so that every
+//! value shape a response can carry is decoded by a test rather than by hand.
+
+use std::env;
+
+use opensysml::{Connection, EvalOptions, Magnitude, Model, Value};
+
+/// The same model the `capable_model` example tours.
+const MODEL: &str = r#"
+package Vehicles {
+    private import ScalarValues::*;
+    private import ISQ::*;
+    private import SI::*;
+
+    enum def Color {
+        enum red;
+        enum green;
+        enum blue;
+    }
+
+    part def Engine {
+        attribute mass : MassValue;
+        attribute power : PowerValue;
+    }
+
+    part def Wheel {
+        attribute diameter : LengthValue;
+    }
+
+    part def Car {
+        attribute color : Color;
+        attribute name : String;
+        attribute street : Boolean;
+        attribute mass : MassValue;
+        attribute unpainted : Color;
+        part engine : Engine;
+        part wheels : Wheel[4];
+    }
+
+    part sedan : Car {
+        attribute redefines color = Color::blue;
+        attribute redefines name = "Sedan";
+        attribute redefines street = true;
+        attribute redefines mass = 1600.0 [kg];
+        part redefines engine {
+            attribute redefines mass = 180.0 [kg];
+            attribute redefines power = 90000.0 [W];
+        }
+    }
+
+    calc def Doubled { in x : Real; return : Real = x * 2.0; }
+}
+"#;
+
+fn model_or_skip() -> Option<Model> {
+    let connection = match Connection::private() {
+        Ok(connection) => connection,
+        Err(error) => {
+            if env::var("OPENSYSML_REQUIRE_SERVICE").ok().as_deref() == Some("1") {
+                panic!("required sysml-grpc service unavailable: {error}");
+            }
+            eprintln!("skipping service-backed Rust client test: {error}");
+            return None;
+        }
+    };
+    let model = connection
+        .parse_content(MODEL, &Default::default())
+        .unwrap_or_else(|error| panic!("parse failed: {error}"));
+    assert_eq!(
+        model
+            .diagnostics()
+            .iter()
+            .map(|diagnostic| diagnostic.wire().message.clone())
+            .collect::<Vec<_>>(),
+        Vec::<String>::new(),
+        "the capable model is expected to parse clean"
+    );
+    Some(model)
+}
+
+#[test]
+fn evaluation_decodes_every_scalar_shape() {
+    let Some(model) = model_or_skip() else {
+        return;
+    };
+    let eval = |expression: &str| {
+        model
+            .eval(expression)
+            .unwrap_or_else(|error| panic!("evaluating {expression} failed: {error}"))
+    };
+    assert_eq!(eval("1 + 2 * 3"), Value::Integer(7));
+    assert_eq!(
+        eval("Vehicles::sedan::name"),
+        Value::Text("Sedan".to_owned())
+    );
+    assert_eq!(eval("Vehicles::sedan::street"), Value::Boolean(true));
+    assert_eq!(eval("Vehicles::Doubled(21.0)"), Value::Real(42.0));
+
+    let Value::EnumLiteral(literal) = eval("Vehicles::sedan::color") else {
+        panic!("color is an enumeration literal");
+    };
+    assert_eq!(literal.literal_id, "Vehicles::Color::blue");
+    assert_eq!(literal.enumeration_id, "Vehicles::Color");
+}
+
+/// A value expression is written in its own scope, so a qualified name reads it
+/// with the units and enumerations imported there — the same answer the scope
+/// the name is written in would give.
+#[test]
+fn a_qualified_value_reads_its_declaring_scope() {
+    let Some(model) = model_or_skip() else {
+        return;
+    };
+    let qualified = model
+        .eval("Vehicles::sedan::mass")
+        .unwrap_or_else(|error| panic!("qualified evaluation failed: {error}"));
+    let scoped = model
+        .evaluate(
+            "mass",
+            &EvalOptions {
+                context: Some("Vehicles::sedan".to_owned()),
+                subject: None,
+            },
+        )
+        .unwrap_or_else(|error| panic!("scoped evaluation failed: {error}"))
+        .result;
+    assert_eq!(qualified, scoped);
+
+    let Value::Quantity(quantity) = qualified else {
+        panic!("mass is a quantity");
+    };
+    assert_eq!(quantity.magnitude, Magnitude::Real(1600.0));
+    assert_eq!(quantity.unit, "kg");
+    let term = quantity.unit_term.expect("a kilogram reduces to grams");
+    assert_eq!(term.scale_num, 1000.0);
+    assert_eq!(
+        term.factors
+            .iter()
+            .map(|factor| (factor.unit_id.as_str(), factor.exponent))
+            .collect::<Vec<_>>(),
+        [("SI::gram", 1.0)]
+    );
+}
+
+#[test]
+fn nested_symbols_are_navigated_lazily() {
+    let Some(model) = model_or_skip() else {
+        return;
+    };
+    let car = model
+        .symbol("Vehicles::Car")
+        .unwrap_or_else(|error| panic!("symbol lookup failed: {error}"));
+    assert_eq!(car.kind(), "partDef");
+    let children = car
+        .children()
+        .unwrap_or_else(|error| panic!("children lookup failed: {error}"));
+    assert_eq!(
+        children
+            .iter()
+            .map(|child| child.name().to_owned())
+            .collect::<Vec<_>>(),
+        [
+            "color",
+            "name",
+            "street",
+            "mass",
+            "unpainted",
+            "engine",
+            "wheels"
+        ]
+    );
+}
+
+#[test]
+fn instantiation_decodes_values_multiplicity_and_nesting() {
+    let Some(model) = model_or_skip() else {
+        return;
+    };
+    let instantiation = model
+        .instantiate("Vehicles::sedan")
+        .unwrap_or_else(|error| panic!("instantiation failed: {error}"));
+    let sedan = &instantiation.instance;
+
+    // A feature declaring no value is unset rather than absent or null.
+    assert_eq!(
+        sedan
+            .feature("unpainted")
+            .and_then(|feature| feature.value()),
+        Some(&Value::Unset)
+    );
+
+    // A multi-valued feature holds values, not one value.
+    let wheels = sedan.feature("wheels").expect("wheels is a feature");
+    assert_eq!(wheels.value(), None);
+    assert_eq!(wheels.values().len(), 4);
+    assert!(wheels
+        .values()
+        .iter()
+        .all(|value| matches!(value, Value::InstanceRef(_))));
+
+    // A nested object is reachable through the instances the response carries.
+    let Some(Value::InstanceRef(engine_id)) =
+        sedan.feature("engine").and_then(|feature| feature.value())
+    else {
+        panic!("engine holds one object");
+    };
+    let engine = instantiation
+        .instances()
+        .iter()
+        .find(|instance| instance.id() == *engine_id)
+        .expect("the engine is among the reachable instances");
+    let Some(Value::Quantity(power)) = engine.feature("power").and_then(|feature| feature.value())
+    else {
+        panic!("engine power is a quantity");
+    };
+    assert_eq!(power.magnitude, Magnitude::Real(90000.0));
+    assert_eq!(power.unit, "W");
+}
+
+/// Reading a feature materializes the object it holds, so an instantiation that
+/// depended on map order would hand out different ids each time it was asked.
+/// The service keeps every object it builds, so each instantiation is a new
+/// object with ids of its own; the graph is the same up to that offset.
+#[test]
+fn instantiation_is_the_same_graph_every_time() {
+    let Some(model) = model_or_skip() else {
+        return;
+    };
+    let graph = || {
+        let instances = model
+            .instantiate("Vehicles::sedan")
+            .unwrap_or_else(|error| panic!("instantiation failed: {error}"));
+        let root = instances.instance.id();
+        instances
+            .instances()
+            .iter()
+            .map(|instance| (instance.id() - root, instance.type_symbol_id().to_owned()))
+            .collect::<Vec<_>>()
+    };
+    let first = graph();
+    assert_eq!(first.len(), 6);
+    assert_eq!(first[0].0, 0);
+    for _ in 0..4 {
+        assert_eq!(graph(), first);
+    }
+}
+
+/// The service holds each object it builds, so a second instantiation of the
+/// same part is a second object, its ids fresh.
+#[test]
+fn instantiating_again_builds_a_new_object() {
+    let Some(model) = model_or_skip() else {
+        return;
+    };
+    let ids = || {
+        model
+            .instantiate("Vehicles::sedan")
+            .unwrap_or_else(|error| panic!("instantiation failed: {error}"))
+            .instances()
+            .iter()
+            .map(|instance| instance.id())
+            .collect::<Vec<_>>()
+    };
+    let first = ids();
+    let second = ids();
+    let highest = first.iter().copied().max().expect("an instance");
+    assert!(
+        second.iter().all(|id| *id > highest),
+        "{second:?} after {first:?}"
+    );
+}
diff --git a/clients/rust/opensysml/tests/client.rs b/client/rust/opensysml/tests/client.rs
similarity index 99%
rename from clients/rust/opensysml/tests/client.rs
rename to client/rust/opensysml/tests/client.rs
index cc11043573..da354367e8 100644
--- a/clients/rust/opensysml/tests/client.rs
+++ b/client/rust/opensysml/tests/client.rs
@@ -487,6 +487,14 @@ fn the_service_advertises_the_explore_schedule() {
     assert!(connection.capabilities().has("schedule_explore"));
 }
 
+#[test]
+fn the_service_advertises_the_object_a_behavior_is_performed_by() {
+    let Some(connection) = service_or_skip() else {
+        return;
+    };
+    assert!(connection.capabilities().has("performer"));
+}
+
 #[test]
 fn the_service_advertises_the_final_clock_instant_of_its_execution_responses() {
     let Some(connection) = service_or_skip() else {
diff --git a/clients/rust/opensysml/tests/external_service.rs b/client/rust/opensysml/tests/external_service.rs
similarity index 100%
rename from clients/rust/opensysml/tests/external_service.rs
rename to client/rust/opensysml/tests/external_service.rs
diff --git a/clients/rust/opensysml/tests/private_lifecycle.rs b/client/rust/opensysml/tests/private_lifecycle.rs
similarity index 100%
rename from clients/rust/opensysml/tests/private_lifecycle.rs
rename to client/rust/opensysml/tests/private_lifecycle.rs
diff --git a/clients/rust/opensysml/tests/service_env.rs b/client/rust/opensysml/tests/service_env.rs
similarity index 100%
rename from clients/rust/opensysml/tests/service_env.rs
rename to client/rust/opensysml/tests/service_env.rs
diff --git a/clients/rust/opensysml/tests/start_failure.rs b/client/rust/opensysml/tests/start_failure.rs
similarity index 100%
rename from clients/rust/opensysml/tests/start_failure.rs
rename to client/rust/opensysml/tests/start_failure.rs
diff --git a/clients/java/README.md b/clients/java/README.md
deleted file mode 100644
index e26dfb3f46..0000000000
--- a/clients/java/README.md
+++ /dev/null
@@ -1,395 +0,0 @@
-# opensysml-client (Java)
-
-Java client for OpenSysML: parse, inspect and evaluate SysML v2 models over the
-`sysml-grpc` service, from inside a JVM host application it does not own — an
-Eclipse-based tool, a Cameo plugin, a web service.
-
-```xml
-<dependency>
-  <groupId>org.openmbee</groupId>
-  <artifactId>opensysml-client</artifactId>
-  <version>0.1.0-SNAPSHOT</version>
-</dependency>
-```
-
-Nothing is published yet. Build and install it into the local repository from a
-checkout:
-
-```bash
-make build                                  # bin/sysml-grpc, which the tests start
-mvn -f clients/java/pom.xml install          # sources and javadoc jars included
-```
-
-```java
-try (Connection connection = Connection.open()) {      // starts a private sysml-grpc
-  Model model = connection.load(Path.of("model.sysml"));
-
-  Value sum = model.eval("1 + 2 * 3");                 // Value.IntegerValue[value=7]
-  Value mass = model.evalWithSubject("mass", "Demo::sedan");
-
-  Symbol vehicle = model.symbol("Demo::Vehicle");      // findSymbol returns Optional
-  Instantiation built = model.instantiate("Demo::Vehicle");
-
-  connection.capabilities().require(Capabilities.FEATURE_VALUES);
-}
-```
-
-Every value the API answers with is immutable: `Value` is a sealed interface over
-records (`IntegerValue`, `RealValue`, `ComplexValue`, `QuantityValue`, `ArrayValue`,
-`VectorValue`, `VectorQuantityValue`, `SetValue`, `TensorQuantityValue`, `MeasurementRefValue`,
-`FunctionValue`, `MetaobjectValue`, `EnumerationValue` (whose `EnumLiteral` carries the scalar a
-`high = 3` literal was given as `value()`), `InstanceReference`, `Sequence`, `NullValue`,
-`UnsetValue`, `UndeterminedValue`, `InfinityValue`), and `Symbol`,
-`Diagnostic`, `Instance` and `Instantiation` are records with copied collections.
-No generated protobuf message or builder appears in the public API. A `Diagnostic`
-is `(severity, message, code, span)`; `code()` is the identifier to branch on
-(`"syntax"`, a validation code such as `"unresolved"`, `"choice-point"`,
-`"guard-unevaluable"`; `""` when the service assigned none), `message()` is for reading. A
-service that populates `code` advertises `Capabilities.DIAGNOSTIC_CODES`; without it every
-code is `""`.
-
-## Exceptions: unchecked, and the distinction that matters
-
-Everything the client throws is unchecked and descends from `OpenSysMLException`.
-A host application handling a model is not helped by checked exceptions on every
-call, and `AutoCloseable`'s `close()` here throws nothing.
-
-| exception              | what happened                                                     |
-| ---------------------- | ----------------------------------------------------------------- |
-| `ServiceException`     | the call was refused, with a `StatusCode` (`NOT_FOUND`, …)         |
-| `ModelException`       | the call succeeded and the answer reports a model failure          |
-| `TransportException`   | HTTP or IO failure; the service was not reached or answered. `UNAVAILABLE`, except `DEADLINE_EXCEEDED` for a call that outlived its `requestTimeout` |
-| `CapabilityException`  | the service does not advertise a capability the call needs         |
-| `ServiceStartException`| no binary, a digest mismatch, or a child that would not start      |
-| `ChecksumMismatchException` | a binary's bytes are not the digest required of them ([the service binary](#the-service-binary)) |
-
-The `ServiceException`/`ModelException` split is the one the conformance suite
-draws too: an expression that will not evaluate is a successful call carrying an
-error, not a service problem.
-
-## Maven, and a JDK 17 baseline
-
-Maven, because a consumer of this ecosystem expects a POM: Eclipse tooling, Cameo
-plugin builds and `mvn dependency:tree` all read one, and a Gradle consumer reads
-the published POM as well.
-
-`maven.compiler.release` is **17**, not the 21 this repository's environment has.
-17 is the lowest baseline a realistic host can offer: Eclipse 2023-03 and later
-require 17, so does IntelliJ 2023.2+, and Spring Boot 3 requires it. The client
-uses records, sealed interfaces, switch patterns and text blocks — all 17 — and
-nothing from 21, so 21 would exclude hosts for no gain.
-
-## Dependency footprint
-
-The compile-scope dependency is protobuf-java. Nothing else, by default:
-
-| jar                          |  size | when                                    |
-| ---------------------------- | ----: | --------------------------------------- |
-| `protobuf-java` 4.33.1       | 1.8 M | always: the generated messages need it  |
-| `protobuf-java-util` 4.33.1  |  76 K | only `Encoding.JSON`, declared optional  |
-| `gson` 2.11.0                | 291 K | only `Encoding.JSON`, behind the above   |
-
-There is no gRPC, no Netty and no `tcnative`. The transport is
-`java.net.http.HttpClient` from the JDK, speaking the Connect protocol: unary
-`POST /sysml.SysMLService/<Method>` with an `application/proto` body and
-`Connect-Protocol-Version: 1`. That decision is about the host application, not
-about elegance:
-
-- `grpc-java` brings `grpc-netty(-shaded)`, `guava`, `perfmark` and optionally
-  `netty-tcnative-boringssl-static`. Inside an OSGi/Eclipse runtime or a Spring
-  application that already has its own Netty, that is the classic shading and
-  classloader conflict, and a client is a bad reason to inflict it.
-- `connect-kotlin` would add the Kotlin stdlib and OkHttp for a Java consumer.
-- The service serves gRPC, gRPC-Web and Connect on one port, so choosing Connect
-  costs no functionality: the same port, the same protobuf bodies.
-
-Only the client's own transport is affected. A host that already uses `grpc-java`
-for something else keeps it; nothing here conflicts with it.
-
-## Protobuf bodies by default
-
-`Encoding.PROTOBUF` is the default. `docs/internals/design/transport-evaluation.md`
-measured a 468 KB `Query` answer at ~6.5 ms with protobuf against ~42 ms with
-JSON — that is JSON parsing cost, not bytes on the wire. `Encoding.JSON` exists
-so an answer can be compared against `curl`, and it needs the optional
-`protobuf-java-util`; the conformance suite runs over both.
-
-## Service ownership
-
-A connection uses a service of its own and never stops one it did not start.
-
-- `Connection.open()` starts a **private child**: `sysml-grpc -port 0
-  -health-port 0 -report-address -exit-with-parent`. The kernel assigns the port
-  and the child prints the address it was given on its first stdout line, so no
-  port is chosen, probed or retried.
-- **One child per classloader.** The registry holding it is static, so the copy
-  of the client an Eclipse plugin loaded, the copy a web application loaded and a
-  copy shaded inside a third library each own one child, while every connection
-  made through one copy shares a child — and therefore its parse cache, which is
-  what makes a second connection and a repeat parse cheap. Per-instance would
-  spawn a service per connection and reparse every model; a JVM-wide singleton
-  would put one tenant's models in another tenant's cache and would outlive an
-  undeployed application.
-- A host that must not share a cache across tenants passes
-  `ConnectionOptions.builder().isolatedService(true)`, which starts a child for
-  that connection alone and stops it when that connection closes.
-- A private child stops when the **last** connection holding it closes.
-  `Connection.close()` is idempotent, and `Connection.stopSharedServices()` stops
-  what this classloader still owns — call it from a plugin's `stop()` or a
-  `ServletContextListener`, since unloading a classloader does not by itself stop
-  a child (below).
-- Reaching a service the client did not start is explicit: `service(host, port)`,
-  `$OPENSYSML_SERVICE=host:port`, or `autoStart(false)` to require one. Closing
-  such a connection leaves it running, always.
-
-### No orphans
-
-The client holds the write end of the child's **stdin pipe** and never writes to
-it; the child exits at end of file. Nothing else holds that write end, so the
-kernel closes it when the owning JVM goes away — which is what survives
-`SIGKILL`, `Runtime.halt`, an `OutOfMemoryError` during shutdown and a JVM crash.
-`ProcessHandle.onExit()` and shutdown hooks do not. On an orderly close the
-client closes stdin itself and then destroys the process it started, so exit is
-prompt rather than eventual, and it only ever signals the `Process` object of a
-child it started — never a pid read from disk.
-
-`OrphanSafetyTest` proves it: a child JVM opens a connection, prints the service
-pid, and is killed with `kill -9`; the test then waits for that pid to be gone.
-
-Per platform:
-
-- **Linux**, **macOS**: as above. The JVM does not leak the write end into other
-  children, since `ProcessBuilder` does not pass a parent's pipe endpoints on.
-- **Windows**: the same anonymous pipe is closed by the OS when the owning
-  process exits however it exits, so the guarantee is unchanged; `taskkill /F` is
-  the `kill -9` of the test. The `-exit-with-parent` flag adds a job-object tie
-  on the service side.
-- **A classloader that is unloaded**: the child is not tied to the classloader,
-  so a host that undeploys an application without closing its connections would
-  leave a service running until its JVM exits. `ClassLoaderTest` loads three
-  isolated copies of the client, checks each owns a different child, checks that
-  closing the connections leaves none, and checks the copies are collectable.
-  Nothing the client starts is a non-daemon thread, so an unloaded copy holds no
-  thread either: the HTTP executor and the child's output pumps are daemon threads
-  (`opensysml-http-*`, `opensysml-service-*`). Collection is not instant, though —
-  on 17 the JDK's own `HttpClient-N-SelectorManager` thread outlives a closed
-  client until the client is collected, and pins the loader meanwhile.
-
-## Thread safety
-
-`Connection` and `Model` are safe for concurrent use by many threads: a
-connection holds one `HttpClient`, the shared-service registry is guarded by a
-single lock, and `close()` is a compare-and-set. `LifecycleTest` opens
-connections from eight threads at once and asserts one child was started and the
-reference count reaches zero. Value types are immutable and therefore shareable.
-
-## The service binary
-
-The client resolves an installed binary, in order:
-
-1. `ConnectionOptions.binaryPath(...)`;
-2. `$OPENSYSML_GRPC_BINARY`;
-3. `~/.opensysml/bin/sysml-grpc` (`.exe` on Windows) — the cache it shares with the
-   Python client;
-4. `PATH`.
-
-`expectedBinarySha256("<hex>")` verifies the file's digest before it is executed
-and refuses it otherwise, whichever binary is resolved and whether it was
-downloaded or installed. An external service needs no binary at all.
-
-### Downloading a release
-
-Ask for a release and the client downloads it into that shared cache:
-
-```java
-ConnectionOptions.builder().downloadVersion("v0.3.0").build();   // or "latest"
-```
-
-The version is the caller's, else `$OPENSYSML_GRPC_VERSION`, else nothing —
-**no version, no download**: without one the client only resolves what is
-already there, so it never fetches a binary a caller did not ask for. `latest`
-is resolved through the GitHub releases API. The repository is
-`Open-MBEE/OpenSysML`, overridable with `ConnectionOptions.githubRepo(...)` or
-`$OPENSYSML_GITHUB_REPO`. Every request times out after 15 seconds, and a
-response body that stops arriving for that long is abandoned too, so a release
-of any size may be downloaded but a hung origin never hangs a connection.
-
-The release asset for the running platform (`sysml-grpc-linux-amd64`,
-`-linux-arm64`, `-darwin-amd64`, `-darwin-arm64`, `-windows-amd64.exe`; any
-other pair fails naming itself) is downloaded to a temporary file, verified, and
-only then moved over the cached path, `chmod 0700` where the filesystem keeps
-POSIX modes. A download that fails or does not verify leaves the cached binary
-untouched and removes the temporary file. A response is read to a bound — 512
-MiB for a binary, 8 MiB for a checksum, manifest, bundle or release listing —
-so an origin cannot answer a download with an endless body. Deciding whether the
-cache is the release asked for and replacing it when it is not is done holding
-`~/.opensysml/bin/sysml-grpc.lock`, so concurrent connections — in this JVM, in
-another, or in the Python or Rust clients, which take the same lock over the same
-span — do not install over each other.
-
-What a service is started from is not the shared path but a hard link to it
-named for its digest, `~/.opensysml/bin/sysml-grpc-<first 16 hex>`, made while
-the lock is held. A release installed over the cache afterwards is therefore not
-the one a connection already resolved runs; a filesystem without hard links gets
-a copy, and one that gives neither falls back to the shared path with a warning.
-
-The cache is read and written exactly as the Python client does, so the two
-share one binary: `~/.opensysml/bin/sysml-grpc.json` beside it records
-`{"version": ..., "sha256": ..., "repo": ...}`. A cached binary of another
-release, or one from another repository, is replaced with a warning rather than
-silently used, and its digest is re-checked so a binary swapped in by hand is
-not read as the release it displaced. A release that cannot be reached, or that
-nothing vouches for, is no reason to lose a working binary: the installed one is
-kept with a warning that it may be older than the release asked for. A download
-that contradicts a pin or a signature is never answered from the cache — it
-fails.
-
-### What a download is verified against
-
-In order, and each step is a refusal rather than a fallback:
-
-1. **A pinned digest.** `release-digests.json` — this jar's synced copy of
-   `clients/release-digests.json`, loaded from the classpath — pins a SHA-256
-   per (repository, release, asset). Where a pin exists it is what the bytes
-   must hash to, and a served `.sha256` that disagrees with it is a release
-   republished with another binary: the download is refused.
-2. **The signed checksum manifest.** For a release nothing is pinned for, the
-   client downloads `SHA256SUMS.txt` and its sigstore bundle
-   `SHA256SUMS.txt.bundle` and verifies the bundle with
-   `dev.sigstore:sigstore-java` against the manifest's digest, requiring the
-   release pipeline's identity — CircleCI OIDC issuer
-   `https://oidc.circleci.com/org/1169df8b-0b59-400f-82d2-c9d8e98bdb62` and a
-   pipeline definition of project
-   `https://circleci.com/api/v2/projects/eeb0dddd-237f-4f02-9e51-8e24caef589d`.
-   The asset's digest then comes from that verified manifest. A bundle that does
-   not verify, another signer, an expired certificate or a manifest changed
-   after signing is refused — never a quiet fall back to the served checksum.
-3. **Nothing.** With no pin and no signature, the download is refused, naming
-   the version, because the only checksum left is the one served beside the
-   binary, which a compromised release would serve too.
-
-The last step is the one opt-out: `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=1`, or set
-to a comma-separated list naming `owner/repo`, accepts that served `.sha256`
-with a warning. That is **same-origin trust** — it detects a corrupted transfer,
-and nothing at all about a compromised release origin.
-
-Excluding the `dev.sigstore:sigstore-java` dependency does not weaken any of
-this: without it nothing verifies, which is refused exactly as an unsigned
-release is, so only pinned releases install.
-
-### Limitations
-
-- A release published after this client's `release-digests.json` was synced is
-  installed on its signature, so a jar built with `sigstore-java` excluded needs
-  a client whose table pins that release.
-- `latest` is one unauthenticated call to `api.github.com`, so a rate-limited
-  host should name the version instead.
-- The cache is one path per user, so two applications asking for different
-  releases replace one another's cached binary — each still runs the release it
-  asked for, from the digest-named link, but the next start re-downloads. Name
-  the binary with `ConnectionOptions.binaryPath()` where that matters.
-- The digest-named links are never collected: a cache that has held many
-  releases keeps a link per release until `~/.opensysml/bin` is cleared.
-
-## Capability negotiation
-
-`Connection.open` calls `GetServerInfo` once and keeps what it reported.
-`connection.capabilities().require(Capabilities.EVALUATE_SUBJECT)` throws
-`CapabilityException` when a capability is absent. Negotiation is on the
-advertised **names**, never on the version string. The service does refuse a
-request that needs a capability it lacks — `UNIMPLEMENTED`, naming the
-capability, which arrives here as a `ServiceException` — but a capability that
-only describes how a response is populated omits its fields instead, so a call
-that relied on failure alone would silently read an answer computed without
-them. That is why `Model.evalWithSubject` checks before it calls: a
-`CapabilityException` names the missing capability and the service that lacks
-it, before a round trip.
-
-## What v1 does not do
-
-Deliberately out of scope, rather than half-implemented:
-
-- **the edit API** (`ApplyEdits`) — authoring notation from Java;
-- **RDF conversion** (`Convert`) — Turtle/RDF export;
-- **verification helpers** (`VerifyConstraint`, `VerifyRequirement`,
-  `VerifySatisfaction`), **behaviour execution** (`ExecuteAction`,
-  `ExecuteState`), **`EvaluateCalc`**, **`RunAnalysis`** and **`Query`**/OSLC;
-- **generated model-ergonomics types** — no code generation from a model into
-  Java classes.
-
-The service still serves all of them; reach them from another client, or from the
-generated stubs in `org.openmbee.opensysml.proto` with `curl`, until a v2 wraps them.
-
-## Generated messages
-
-`org.openmbee.opensysml.proto` is committed, generated by `buf` from a plugin entry in the
-root `buf.gen.yaml` with the version pinned inline, and regenerated by
-`make proto` — no Maven plugin calls `protoc` and nothing here is hand-written.
-Only the message classes are generated: the Connect protocol needs no service
-stubs, so grpc-java never enters the build.
-
-## Conformance
-
-The runner in `opensysml-conformance` reads `conformance/scenarios/*.json` and
-`conformance/fixtures/`, makes each call **through the public API** and compares
-what the client read out of the answer, by the rules in `conformance/README.md`.
-It writes the report shape `cmd/conformance` writes:
-
-```bash
-make build
-mvn -f clients/java/pom.xml install -DskipTests
-mvn -f clients/java/pom.xml -pl opensysml-conformance -q \
-  dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
-java -cp "clients/java/opensysml-conformance/target/classes:$(cat /tmp/cp.txt)" \
-  org.openmbee.opensysml.conformance.Main -binary bin/sysml-grpc -allow-skips \
-  -protocols connect,connect-json -report bin/conformance-report-java.json
-```
-
-`-run <regexp>` selects scenarios by id, `-service host:port` runs against a
-service the runner did not start, `-mutate` is below, and without `-allow-skips`
-a skipped scenario is an exit code, so a shrinking API surface cannot go
-unnoticed.
-
-Or as a test, which is what CI runs: `mvn -f clients/java/pom.xml test`.
-
-Per protocol, of 59 scenarios:
-
-| protocol       | ran | passed | failed | skipped |
-| -------------- | --: | -----: | -----: | ------: |
-| `connect`      |  25 |     25 |      0 |      34 |
-| `connect-json` |  25 |     25 |      0 |      34 |
-
-**34 skipped**, and they are the scenarios of the RPCs v1 does not cover:
-`ExecuteAction` (3), `ExecuteState` (2), `Convert` (5), `ApplyEdits` (5),
-`VerifyConstraint` (4), `VerifyRequirement` (2), `VerifySatisfaction` (2),
-`EvaluateCalc` (2), `Query` (8) — 33 — plus
-`parse/naming_no_source_is_invalid`, which asserts that a request naming no
-source at all is refused: the public API always names one, so the client cannot
-send that request. gRPC is not run at all: this client does not speak it.
-
-The runner is not vacuous. `-mutate` corrupts every answer before it is compared,
-and `SuiteTest.aCorruptedAnswerIsCaught` asserts each corruption is caught:
-
-| `-mutate`         | what it does to every answer     | scenarios that fail |
-| ----------------- | -------------------------------- | ------------------: |
-| `perturb-reals`   | moves each real by a millionth   |                   4 |
-| `truncate-lists`  | drops the last repeated element  |                   7 |
-| `rewrite-strings` | replaces each string            |                  13 |
-
-## Running the tests
-
-```bash
-make build                                   # bin/sysml-grpc; tests skip without it
-mvn -f clients/java/pom.xml test             # 119 client tests, 27 conformance tests
-mvn -f clients/java/pom.xml test -Dopensysml.requireService=true   # CI: absence fails
-```
-
-## Publishing
-
-Nothing has been published. The build produces a correct, signable artifact
-(sources and javadoc jars, complete POM metadata, a `release` profile that signs
-with GPG and stages to Sonatype Central with `autoPublish=false`), and
-`mvn install` works today. What a maintainer must obtain first — a verified
-`org.openmbee` namespace, a published GPG key, and Central portal
-tokens — is in
-[docs/project/releasing.md](../../docs/project/releasing.md#releasing-the-java-client-to-maven-central).
diff --git a/clients/java/opensysml-client/pom.xml b/clients/java/opensysml-client/pom.xml
deleted file mode 100644
index 1d396550af..0000000000
--- a/clients/java/opensysml-client/pom.xml
+++ /dev/null
@@ -1,89 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
-  <modelVersion>4.0.0</modelVersion>
-
-  <parent>
-    <groupId>org.openmbee</groupId>
-    <artifactId>opensysml-parent</artifactId>
-    <version>0.1.0-SNAPSHOT</version>
-  </parent>
-
-  <artifactId>opensysml-client</artifactId>
-  <packaging>jar</packaging>
-
-  <name>OpenSysML Java client</name>
-  <description>Parse, inspect and evaluate SysML v2 models over the sysml-grpc service's Connect
-    protocol, from inside a long-lived JVM host application.</description>
-  <url>https://github.com/Open-MBEE/OpenSysML/tree/main/clients/java</url>
-
-  <dependencies>
-    <dependency>
-      <groupId>com.google.protobuf</groupId>
-      <artifactId>protobuf-java</artifactId>
-    </dependency>
-    <!-- Only Encoding.JSON needs it, a debugging affordance rather than the
-         default, so a host that never asks for JSON carries neither it nor gson. -->
-    <dependency>
-      <groupId>com.google.protobuf</groupId>
-      <artifactId>protobuf-java-util</artifactId>
-      <optional>true</optional>
-    </dependency>
-    <!-- Verifies the sigstore bundle over a release's SHA256SUMS.txt, which is what lets a
-         release newer than this client's pinned digests install. -->
-    <dependency>
-      <groupId>dev.sigstore</groupId>
-      <artifactId>sigstore-java</artifactId>
-    </dependency>
-    <dependency>
-      <groupId>org.junit.jupiter</groupId>
-      <artifactId>junit-jupiter</artifactId>
-    </dependency>
-  </dependencies>
-
-  <build>
-    <plugins>
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-surefire-plugin</artifactId>
-        <configuration>
-          <!-- The orphan-safety test starts a JVM of its own, so the test JVM
-               must see its real classpath rather than a manifest-only jar. -->
-          <useManifestOnlyJar>false</useManifestOnlyJar>
-        </configuration>
-      </plugin>
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-source-plugin</artifactId>
-        <executions>
-          <execution>
-            <id>attach-sources</id>
-            <goals>
-              <goal>jar-no-fork</goal>
-            </goals>
-          </execution>
-        </executions>
-      </plugin>
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-javadoc-plugin</artifactId>
-        <configuration>
-          <!-- The generated message classes document the schema, which
-               sysml.proto already does, and their javadoc is not the client's. -->
-          <excludePackageNames>org.openmbee.opensysml.proto</excludePackageNames>
-          <doclint>all,-missing</doclint>
-          <notimestamp>true</notimestamp>
-        </configuration>
-        <executions>
-          <execution>
-            <id>attach-javadocs</id>
-            <goals>
-              <goal>jar</goal>
-            </goals>
-          </execution>
-        </executions>
-      </plugin>
-    </plugins>
-  </build>
-</project>
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java
deleted file mode 100644
index 9a75891273..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Connection.java
+++ /dev/null
@@ -1,310 +0,0 @@
-package org.openmbee.opensysml;
-
-import com.google.protobuf.Message;
-import org.openmbee.opensysml.internal.ConnectTransport;
-import org.openmbee.opensysml.internal.PrivateService;
-import org.openmbee.opensysml.internal.Protos;
-import org.openmbee.opensysml.internal.ServiceRegistry;
-import org.openmbee.opensysml.proto.ParseFileRequest;
-import org.openmbee.opensysml.proto.ParseFileResponse;
-import org.openmbee.opensysml.proto.ServerInfoRequest;
-import org.openmbee.opensysml.proto.ServerInfoResponse;
-import java.nio.file.Path;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-/**
- * A connection to a {@code sysml-grpc} service, and the entry point of this client.
- *
- * <p>Two lifecycle modes, chosen by {@link ConnectionOptions}:
- *
- * <ul>
- *   <li><b>Private service.</b> The default. The connection starts {@code sysml-grpc} as a child of
- *       this JVM on a port the kernel chose, and every connection made by the classloader that
- *       loaded this class shares that child, so they share its parse cache. The last connection to
- *       close stops it. The child cannot be orphaned: see {@link PrivateService}.
- *   <li><b>External service.</b> Opt in by naming a host and port, or by setting
- *       {@code $OPENSYSML_SERVICE} to {@code host:port}. Closing such a connection leaves the
- *       service running.
- * </ul>
- *
- * <p>Thread-safe: a connection may be shared by any number of threads, since a call carries all of
- * its own state and {@code java.net.http} is itself thread-safe. {@link #close()} is idempotent.
- *
- * <p>Every failure is unchecked; see {@link OpenSysMLException} for the one exception rule.
- *
- * <pre>{@code
- * try (Connection connection = Connection.open()) {
- *   Model model = connection.load(Path.of("vehicle.sysml"));
- *   Value mass = model.evalInContext("mass", "Demo::Vehicle");
- * }
- * }</pre>
- */
-public final class Connection implements AutoCloseable {
-
-  private final ConnectTransport transport;
-  private final String address;
-  private final Optional<PrivateService> ownedService;
-  private final Capabilities capabilities;
-  private final AtomicBoolean closed = new AtomicBoolean();
-
-  private Connection(
-      ConnectTransport transport, String address, Optional<PrivateService> ownedService) {
-    this.transport = transport;
-    this.address = address;
-    this.ownedService = ownedService;
-    this.capabilities = readCapabilities();
-  }
-
-  /**
-   * Opens a connection with the default options: a private service, protobuf bodies.
-   *
-   * @return an open connection the caller must close
-   */
-  public static Connection open() {
-    return open(ConnectionOptions.defaults());
-  }
-
-  /**
-   * Opens a connection.
-   *
-   * @param options how to reach the service
-   * @return an open connection the caller must close
-   * @throws ServiceStartException if no service was named and none could be started
-   * @throws TransportException if the service could not be reached
-   */
-  public static Connection open(ConnectionOptions options) {
-    Objects.requireNonNull(options, "options");
-    Optional<String> external = externalAddress(options);
-    if (external.isPresent()) {
-      String address = external.get();
-      ConnectTransport transport =
-          new ConnectTransport(address, options.encoding(), options.requestTimeout());
-      try {
-        return new Connection(transport, address, Optional.empty());
-      } catch (RuntimeException e) {
-        transport.close();
-        throw e;
-      }
-    }
-    if (!options.autoStart()) {
-      throw new ServiceStartException(
-          "no service was named and autoStart is false; name one with "
-              + "ConnectionOptions.service(host, port) or $"
-              + ConnectionOptions.SERVICE_ENV);
-    }
-    PrivateService service = ServiceRegistry.acquire(options);
-    ConnectTransport transport =
-        new ConnectTransport(service.address(), options.encoding(), options.requestTimeout());
-    try {
-      return new Connection(transport, service.address(), Optional.of(service));
-    } catch (RuntimeException e) {
-      transport.close();
-      ServiceRegistry.release(service);
-      throw e;
-    }
-  }
-
-  /**
-   * Stops every private service this classloader's connections share, whether or not connections
-   * still hold them. For a host application unloading the client, where nothing else will run.
-   *
-   * @return how many services were stopped
-   */
-  public static int stopSharedServices() {
-    return ServiceRegistry.stopAll();
-  }
-
-  /**
-   * What the service says it can do, read once when the connection opened.
-   *
-   * <p>Negotiate on these names. The service does not answer {@code UNIMPLEMENTED} for a capability
-   * it lacks, so a call is not a test for one.
-   *
-   * @return the capabilities and the service's version string
-   */
-  public Capabilities capabilities() {
-    return capabilities;
-  }
-
-  /**
-   * The {@code host:port} this connection talks to.
-   *
-   * @return the address
-   */
-  public String address() {
-    return address;
-  }
-
-  /**
-   * Whether closing this connection can stop the service.
-   *
-   * @return {@code true} for a private service, {@code false} for an external one
-   */
-  public boolean ownsService() {
-    return ownedService.isPresent();
-  }
-
-  /**
-   * Parses a file the service can read, and caches it there.
-   *
-   * <p>The path is interpreted by the service, which for a private child is this machine. It is not
-   * read here, so a path that is not there is the service's {@link StatusCode#NOT_FOUND}.
-   *
-   * @param file the source to parse
-   * @return the parsed model
-   * @throws ModelException if the source could not be parsed at all
-   * @throws ServiceException if the service could not read it
-   */
-  public Model load(Path file) {
-    Objects.requireNonNull(file, "file");
-    return parsed(ParseFileRequest.newBuilder().setFilePath(file.toString()).build());
-  }
-
-  /**
-   * Parses a file, with options.
-   *
-   * @param file the source to parse
-   * @param options notation and how strictly to judge it
-   * @return the parsed model
-   */
-  public Model load(Path file, ParseOptions options) {
-    Objects.requireNonNull(file, "file");
-    return parsed(request(options).setFilePath(file.toString()).build());
-  }
-
-  /**
-   * Parses notation given inline.
-   *
-   * @param content SysML notation
-   * @return the parsed model
-   * @throws ModelException if the source could not be parsed at all
-   */
-  public Model parse(String content) {
-    Objects.requireNonNull(content, "content");
-    return parsed(ParseFileRequest.newBuilder().setContent(content).build());
-  }
-
-  /**
-   * Parses notation given inline, with options.
-   *
-   * @param content notation in {@code options}' language
-   * @param options notation and how strictly to judge it
-   * @return the parsed model
-   */
-  public Model parse(String content, ParseOptions options) {
-    Objects.requireNonNull(content, "content");
-    return parsed(request(options).setContent(content).build());
-  }
-
-  /**
-   * A handle on a model the service parsed already, named by its hash.
-   *
-   * <p>For a host that kept a hash across connections. Nothing is called here, so a hash the
-   * service does not hold is reported by the first call made on the model, not by this one, and the
-   * handle carries neither a root nor the diagnostics of that parse.
-   *
-   * @param modelHash a hash a parse returned
-   * @return a handle on that model
-   */
-  public Model model(String modelHash) {
-    checkOpen();
-    Objects.requireNonNull(modelHash, "modelHash");
-    if (modelHash.isBlank()) {
-      throw new IllegalArgumentException("modelHash must not be blank");
-    }
-    return new Model(this, modelHash, Optional.empty(), List.of());
-  }
-
-  /**
-   * Closes the connection, and the private service when this was the last connection holding it.
-   * Idempotent; an external service is never stopped.
-   */
-  @Override
-  public void close() {
-    if (!closed.compareAndSet(false, true)) {
-      return;
-    }
-    try {
-      transport.close();
-    } finally {
-      ownedService.ifPresent(ServiceRegistry::release);
-    }
-  }
-
-  /** The private service this connection holds, for a lifecycle test. */
-  Optional<PrivateService> ownedService() {
-    return ownedService;
-  }
-
-  /** Calls the service. Package-private: generated messages are not part of the public surface. */
-  <T extends Message> T call(String method, Message request, T responseDefault) {
-    checkOpen();
-    return transport.call(method, request, responseDefault);
-  }
-
-  private Model parsed(ParseFileRequest request) {
-    if (request.getStrictConformance()) {
-      capabilities.require(Capabilities.STRICT_CONFORMANCE);
-    }
-    ParseFileResponse response = call("ParseFile", request, ParseFileResponse.getDefaultInstance());
-    List<Diagnostic> diagnostics = Protos.diagnostics(response.getDiagnosticsList());
-    if (!response.getError().isEmpty()) {
-      throw new ModelException(response.getError(), diagnostics);
-    }
-    Optional<Symbol> root =
-        response.hasRoot() ? Optional.of(Protos.symbol(response.getRoot())) : Optional.empty();
-    return new Model(this, response.getModelHash(), root, diagnostics);
-  }
-
-  private static ParseFileRequest.Builder request(ParseOptions options) {
-    Objects.requireNonNull(options, "options");
-    return ParseFileRequest.newBuilder()
-        .setLanguage(options.language().wireName())
-        .setStrictConformance(options.strictConformance());
-  }
-
-  private Capabilities readCapabilities() {
-    ServerInfoResponse info =
-        transport.call(
-            "GetServerInfo",
-            ServerInfoRequest.getDefaultInstance(),
-            ServerInfoResponse.getDefaultInstance());
-    Set<String> names = new LinkedHashSet<>(info.getCapabilitiesList());
-    return new Capabilities(info.getVersion(), names);
-  }
-
-  private static Optional<String> externalAddress(ConnectionOptions options) {
-    Optional<String> host = options.host();
-    if (host.isPresent()) {
-      return Optional.of(host.get() + ":" + options.port());
-    }
-    String named = System.getenv(ConnectionOptions.SERVICE_ENV);
-    if (named == null || named.isBlank()) {
-      return Optional.empty();
-    }
-    String address = named.trim();
-    int separator = address.lastIndexOf(':');
-    if (separator <= 0 || separator == address.length() - 1) {
-      throw new IllegalArgumentException(
-          "$" + ConnectionOptions.SERVICE_ENV + " must be host:port, not " + named);
-    }
-    try {
-      Integer.parseInt(address.substring(separator + 1));
-    } catch (NumberFormatException e) {
-      throw new IllegalArgumentException(
-          "$" + ConnectionOptions.SERVICE_ENV + " must be host:port, not " + named, e);
-    }
-    return Optional.of(address);
-  }
-
-  private void checkOpen() {
-    if (closed.get()) {
-      throw new IllegalStateException("this connection is closed");
-    }
-  }
-}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java
deleted file mode 100644
index 1b9ddbce3f..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/Model.java
+++ /dev/null
@@ -1,219 +0,0 @@
-package org.openmbee.opensysml;
-
-import org.openmbee.opensysml.internal.Protos;
-import org.openmbee.opensysml.proto.DiagnosticsRequest;
-import org.openmbee.opensysml.proto.DiagnosticsResponse;
-import org.openmbee.opensysml.proto.EvaluateRequest;
-import org.openmbee.opensysml.proto.EvaluateResponse;
-import org.openmbee.opensysml.proto.GetSymbolRequest;
-import org.openmbee.opensysml.proto.InstantiateRequest;
-import org.openmbee.opensysml.proto.InstantiateResponse;
-import org.openmbee.opensysml.proto.SymbolResponse;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-
-/**
- * A model the service has parsed, named by the hash every later call carries.
- *
- * <p>Obtained from {@link Connection#load(java.nio.file.Path)}, {@link Connection#parse(String)} or
- * {@link Connection#model(String)}. Immutable and thread-safe; it holds no state of its own beyond
- * the hash and what the parse reported.
- */
-public final class Model {
-
-  private final Connection connection;
-  private final String hash;
-  private final Optional<Symbol> root;
-  private final List<Diagnostic> parseDiagnostics;
-
-  Model(
-      Connection connection,
-      String hash,
-      Optional<Symbol> root,
-      List<Diagnostic> parseDiagnostics) {
-    this.connection = connection;
-    this.hash = hash;
-    this.root = root;
-    this.parseDiagnostics = List.copyOf(parseDiagnostics);
-  }
-
-  /**
-   * The hash the service knows this model by.
-   *
-   * @return the model hash
-   */
-  public String hash() {
-    return hash;
-  }
-
-  /**
-   * The connection this model is read over.
-   *
-   * @return the connection
-   */
-  public Connection connection() {
-    return connection;
-  }
-
-  /**
-   * The root namespace of the parse, absent for a model addressed by hash alone.
-   *
-   * @return the root symbol
-   */
-  public Optional<Symbol> root() {
-    return root;
-  }
-
-  /**
-   * What the parse that produced this model reported. Empty for a model addressed by hash alone,
-   * where {@link #diagnostics()} asks the service instead.
-   *
-   * @return the diagnostics of the parse, in order
-   */
-  public List<Diagnostic> parseDiagnostics() {
-    return parseDiagnostics;
-  }
-
-  /**
-   * Asks the service for this model's diagnostics.
-   *
-   * @return the diagnostics, in order
-   * @throws ServiceException if the service does not hold this model
-   * @throws ModelException if the service reported a failure in its answer
-   */
-  public List<Diagnostic> diagnostics() {
-    DiagnosticsResponse response =
-        connection.call(
-            "GetDiagnostics",
-            DiagnosticsRequest.newBuilder().setModelHash(hash).build(),
-            DiagnosticsResponse.getDefaultInstance());
-    List<Diagnostic> diagnostics = Protos.diagnostics(response.getDiagnosticsList());
-    if (!response.getError().isEmpty()) {
-      throw new ModelException(response.getError(), diagnostics);
-    }
-    return diagnostics;
-  }
-
-  /**
-   * A symbol by qualified name.
-   *
-   * @param symbolId a qualified name, such as {@code "Demo::Vehicle"}
-   * @return the symbol
-   * @throws ModelException if the model declares no such symbol
-   * @throws ServiceException if the service does not hold this model
-   */
-  public Symbol symbol(String symbolId) {
-    SymbolResponse response = symbolResponse(symbolId);
-    if (!response.getError().isEmpty()) {
-      throw new ModelException(response.getError(), List.of());
-    }
-    return Protos.symbol(response.getSymbol());
-  }
-
-  /**
-   * A symbol by qualified name, absent when the model declares no such symbol.
-   *
-   * @param symbolId a qualified name
-   * @return the symbol, or empty
-   * @throws ServiceException if the service does not hold this model
-   */
-  public Optional<Symbol> findSymbol(String symbolId) {
-    SymbolResponse response = symbolResponse(symbolId);
-    return response.getError().isEmpty()
-        ? Optional.of(Protos.symbol(response.getSymbol()))
-        : Optional.empty();
-  }
-
-  /**
-   * Evaluates an expression against the model's declarations.
-   *
-   * @param expression a SysML expression, such as {@code "2 + 2"}
-   * @return what it evaluated to
-   * @throws ModelException if the expression could not be evaluated
-   * @throws ServiceException if the service does not hold this model
-   */
-  public Value eval(String expression) {
-    return evaluated(request(expression).build());
-  }
-
-  /**
-   * Evaluates an expression in the scope of a symbol, so its features are in scope and a feature
-   * reads the declared default.
-   *
-   * @param expression a SysML expression
-   * @param contextSymbolId qualified name of the scope to evaluate in
-   * @return what it evaluated to
-   */
-  public Value evalInContext(String expression, String contextSymbolId) {
-    Objects.requireNonNull(contextSymbolId, "contextSymbolId");
-    return evaluated(request(expression).setContextSymbolId(contextSymbolId).build());
-  }
-
-  /**
-   * Evaluates an expression against an object of a symbol, so a feature reads that object's value
-   * rather than the declared default.
-   *
-   * @param expression a SysML expression
-   * @param subjectSymbolId qualified name of the symbol to instantiate and evaluate against
-   * @return what it evaluated to
-   * @throws CapabilityException if the service does not advertise {@code evaluate_subject}, which it
-   *     would otherwise ignore rather than refuse
-   */
-  public Value evalWithSubject(String expression, String subjectSymbolId) {
-    Objects.requireNonNull(subjectSymbolId, "subjectSymbolId");
-    connection.capabilities().require(Capabilities.EVALUATE_SUBJECT);
-    return evaluated(request(expression).setSubjectSymbolId(subjectSymbolId).build());
-  }
-
-  /**
-   * Builds an object of a part definition or usage, and everything reachable from it.
-   *
-   * @param symbolId qualified name of the definition or usage to instantiate
-   * @return the object built
-   * @throws ModelException if it could not be built
-   * @throws ServiceException if the service does not hold this model
-   */
-  public Instantiation instantiate(String symbolId) {
-    Objects.requireNonNull(symbolId, "symbolId");
-    InstantiateResponse response =
-        connection.call(
-            "Instantiate",
-            InstantiateRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId).build(),
-            InstantiateResponse.getDefaultInstance());
-    if (!response.getError().isEmpty()) {
-      throw new ModelException(
-          response.getError(), Protos.diagnostics(response.getDiagnosticsList()));
-    }
-    return Protos.instantiation(response);
-  }
-
-  private SymbolResponse symbolResponse(String symbolId) {
-    Objects.requireNonNull(symbolId, "symbolId");
-    return connection.call(
-        "GetSymbol",
-        GetSymbolRequest.newBuilder().setModelHash(hash).setSymbolId(symbolId).build(),
-        SymbolResponse.getDefaultInstance());
-  }
-
-  private EvaluateRequest.Builder request(String expression) {
-    Objects.requireNonNull(expression, "expression");
-    return EvaluateRequest.newBuilder().setModelHash(hash).setExpression(expression);
-  }
-
-  private Value evaluated(EvaluateRequest request) {
-    EvaluateResponse response =
-        connection.call("Evaluate", request, EvaluateResponse.getDefaultInstance());
-    List<Diagnostic> diagnostics = Protos.diagnostics(response.getDiagnosticsList());
-    if (!response.getError().isEmpty()) {
-      throw new ModelException(response.getError(), diagnostics);
-    }
-    return Protos.value(response.getResult())
-        .orElseThrow(
-            () ->
-                new ModelException(
-                    "the service answered neither a value nor a failure for "
-                        + request.getExpression(),
-                    diagnostics));
-  }
-}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Protos.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Protos.java
deleted file mode 100644
index f52a89600e..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/internal/Protos.java
+++ /dev/null
@@ -1,396 +0,0 @@
-package org.openmbee.opensysml.internal;
-
-import org.openmbee.opensysml.Diagnostic;
-import org.openmbee.opensysml.EnumLiteral;
-import org.openmbee.opensysml.Instance;
-import org.openmbee.opensysml.Instantiation;
-import org.openmbee.opensysml.Quantity;
-import org.openmbee.opensysml.Symbol;
-import org.openmbee.opensysml.TransportException;
-import org.openmbee.opensysml.Value;
-import org.openmbee.opensysml.proto.AttributeInfo;
-import org.openmbee.opensysml.proto.FeatureValue;
-import org.openmbee.opensysml.proto.InstantiateResponse;
-import org.openmbee.opensysml.proto.MultiplicityInfo;
-import org.openmbee.opensysml.proto.Span;
-import org.openmbee.opensysml.proto.Specialization;
-import org.openmbee.opensysml.proto.SymbolInfo;
-import org.openmbee.opensysml.proto.TypeInfo;
-import org.openmbee.opensysml.proto.UnitFactor;
-import org.openmbee.opensysml.proto.UnitTerm;
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Optional;
-
-/**
- * Reads the generated messages into the client's own immutable types, so no generated class and no
- * builder reaches a caller.
- */
-public final class Protos {
-
-  private Protos() {}
-
-  /**
-   * A value, absent when the message names no kind.
-   *
-   * @param value the generated value
-   * @return the value, or empty when no arm is set
-   */
-  public static Optional<Value> value(org.openmbee.opensysml.proto.Value value) {
-    return switch (value.getKindCase()) {
-      case INT_VALUE -> Optional.of(new Value.IntegerValue(value.getIntValue()));
-      case REAL_VALUE -> Optional.of(new Value.RealValue(value.getRealValue()));
-      case COMPLEX ->
-          Optional.of(
-              new Value.ComplexValue(value.getComplex().getReal(), value.getComplex().getImaginary()));
-      case BOOL_VALUE -> Optional.of(new Value.BooleanValue(value.getBoolValue()));
-      case STRING_VALUE -> Optional.of(new Value.StringValue(value.getStringValue()));
-      case INSTANCE_ID -> Optional.of(new Value.InstanceReference(value.getInstanceId()));
-      case SEQUENCE -> Optional.of(sequence(value));
-      case NULL -> Optional.of(new Value.NullValue());
-      case QUANTITY -> Optional.of(new Value.QuantityValue(quantity(value.getQuantity())));
-      case ENUM_LITERAL -> Optional.of(new Value.EnumerationValue(literal(value.getEnumLiteral())));
-      case UNSET -> Optional.of(new Value.UnsetValue());
-      case UNDETERMINED -> Optional.of(undetermined(value.getUndetermined()));
-      case INFINITY -> Optional.of(infinity(value));
-      case ARRAY -> Optional.of(array(value.getArray()));
-      case VECTOR -> Optional.of(vector(value.getVector()));
-      case VECTOR_QUANTITY -> Optional.of(vectorQuantity(value.getVectorQuantity()));
-      case MEASUREMENT_REF -> Optional.of(measurementRef(value.getMeasurementRef()));
-      case FUNCTION -> Optional.of(function(value.getFunction()));
-      case SET -> Optional.of(set(value.getSet()));
-      case TENSOR_QUANTITY -> Optional.of(tensorQuantity(value.getTensorQuantity()));
-      case METAOBJECT -> Optional.of(metaobject(value.getMetaobject()));
-      case KIND_NOT_SET -> Optional.empty();
-    };
-  }
-
-  /** Only an asserted arm carries the unbounded value. */
-  private static Value infinity(org.openmbee.opensysml.proto.Value value) {
-    if (!value.getInfinity()) {
-      throw new TransportException(
-          "the service answered a malformed value: the infinity arm states no value unless it is"
-              + " true",
-          null);
-    }
-    return new Value.InfinityValue();
-  }
-
-  private static Value undetermined(org.openmbee.opensysml.proto.Undetermined undetermined) {
-    org.openmbee.opensysml.proto.MultiplicityInfo count = undetermined.getCount();
-    return new Value.UndeterminedValue(undetermined.getReason(), count.getLower(), count.getUpper());
-  }
-
-  private static Value array(org.openmbee.opensysml.proto.Array array) {
-    List<Value> elements = new ArrayList<>(array.getElementsCount());
-    for (org.openmbee.opensysml.proto.Value element : array.getElementsList()) {
-      elements.add(readable(element));
-    }
-    try {
-      return new Value.ArrayValue(array.getDimensionsList(), elements);
-    } catch (IllegalArgumentException | ArithmeticException malformed) {
-      throw new TransportException(
-          "the service answered a malformed array: " + malformed.getMessage(), malformed);
-    }
-  }
-
-  private static Value vector(org.openmbee.opensysml.proto.Vector vector) {
-    List<Value> components = new ArrayList<>(vector.getComponentsCount());
-    for (org.openmbee.opensysml.proto.Value component : vector.getComponentsList()) {
-      components.add(
-          switch (component.getKindCase()) {
-            case INT_VALUE -> new Value.IntegerValue(component.getIntValue());
-            case REAL_VALUE -> new Value.RealValue(component.getRealValue());
-            default ->
-                throw new TransportException(
-                    "the service answered a malformed vector: component is "
-                        + component.getKindCase().name().toLowerCase(Locale.ROOT)
-                        + ", not a number",
-                    null);
-          });
-    }
-    return new Value.VectorValue(components);
-  }
-
-  private static Value vectorQuantity(org.openmbee.opensysml.proto.VectorQuantity vector) {
-    if (vector.getComponentsCount() == 0) {
-      throw new TransportException(
-          "the service answered a malformed vector quantity: it has no components", null);
-    }
-    List<Quantity> components = new ArrayList<>(vector.getComponentsCount());
-    for (org.openmbee.opensysml.proto.Quantity component : vector.getComponentsList()) {
-      components.add(quantity(component));
-    }
-    return new Value.VectorQuantityValue(components);
-  }
-
-  private static Value set(org.openmbee.opensysml.proto.ValueSet set) {
-    List<Value> elements = new ArrayList<>(set.getElementsCount());
-    for (org.openmbee.opensysml.proto.Value element : set.getElementsList()) {
-      elements.add(readable(element));
-    }
-    try {
-      return new Value.SetValue(elements);
-    } catch (IllegalArgumentException malformed) {
-      throw new TransportException(
-          "the service answered a malformed set: " + malformed.getMessage(), malformed);
-    }
-  }
-
-  private static Value tensorQuantity(org.openmbee.opensysml.proto.TensorQuantity tensor) {
-    List<Quantity> components = new ArrayList<>(tensor.getComponentsCount());
-    for (org.openmbee.opensysml.proto.Quantity component : tensor.getComponentsList()) {
-      components.add(quantity(component));
-    }
-    try {
-      return new Value.TensorQuantityValue(tensor.getDimensionsList(), components);
-    } catch (IllegalArgumentException | ArithmeticException malformed) {
-      throw new TransportException(
-          "the service answered a malformed tensor quantity: " + malformed.getMessage(),
-          malformed);
-    }
-  }
-
-  private static Value measurementRef(org.openmbee.opensysml.proto.MeasurementRef ref) {
-    if (ref.getUnit().isEmpty() && ref.getUnitId().isEmpty() && !ref.hasUnitTerm()) {
-      throw new TransportException(
-          "the service answered a malformed measurement reference: it names no unit", null);
-    }
-    if (!ref.hasUnitTerm()) {
-      throw new TransportException(
-          "the service answered a malformed measurement reference "
-              + (ref.getUnit().isEmpty() ? ref.getUnitId() : ref.getUnit())
-              + ": it has no reduction to base units",
-          null);
-    }
-    return new Value.MeasurementRefValue(
-        ref.getUnit(), unitTerm(ref.getUnitTerm()), present(ref.getUnitId()));
-  }
-
-  private static Value metaobject(org.openmbee.opensysml.proto.Metaobject metaobject) {
-    if (metaobject.getElementId().isEmpty()) {
-      throw new TransportException(
-          "the service answered a malformed metaobject: it names no element", null);
-    }
-    return new Value.MetaobjectValue(metaobject.getElementId(), metaobject.getMetaclassId());
-  }
-
-  private static Value function(org.openmbee.opensysml.proto.Function function) {
-    if (function.getCalcId().isEmpty()) {
-      throw new TransportException(
-          "the service answered a malformed function: it names no calc", null);
-    }
-    return new Value.FunctionValue(
-        function.getCalcId(),
-        function.getSelfId() == 0 ? Optional.empty() : Optional.of(function.getSelfId()));
-  }
-
-  private static Value sequence(org.openmbee.opensysml.proto.Value value) {
-    List<Value> elements = new ArrayList<>();
-    for (org.openmbee.opensysml.proto.Value element : value.getSequence().getElementsList()) {
-      elements.add(readable(element));
-    }
-    return new Value.Sequence(elements);
-  }
-
-  /**
-   * A value the client must read rather than drop: answering nothing for a value that was sent
-   * would read as a shorter sequence, or as a feature holding no value at all.
-   */
-  private static Value readable(org.openmbee.opensysml.proto.Value value) {
-    return value(value)
-        .orElseThrow(
-            () ->
-                new TransportException(
-                    "the service answered a value of a kind this client does not know", null));
-  }
-
-  /**
-   * A quantity.
-   *
-   * @param quantity the generated quantity
-   * @return the immutable quantity
-   * @throws TransportException when the quantity carries no magnitude, which no number stands in for
-   */
-  public static Quantity quantity(org.openmbee.opensysml.proto.Quantity quantity) {
-    Number magnitude =
-        switch (quantity.getMagnitudeCase()) {
-          case INT_MAGNITUDE -> Long.valueOf(quantity.getIntMagnitude());
-          case REAL_MAGNITUDE -> Double.valueOf(quantity.getRealMagnitude());
-          case MAGNITUDE_NOT_SET ->
-              throw new TransportException(
-                  "the service answered a malformed quantity in ["
-                      + quantity.getUnit()
-                      + "]: it has no magnitude",
-                  null);
-        };
-    Optional<Quantity.UnitTerm> reduction =
-        quantity.hasUnitTerm() ? Optional.of(unitTerm(quantity.getUnitTerm())) : Optional.empty();
-    return new Quantity(magnitude, present(quantity.getUnit()), reduction);
-  }
-
-  private static Quantity.UnitTerm unitTerm(UnitTerm term) {
-    List<Quantity.UnitFactor> factors = new ArrayList<>();
-    for (UnitFactor factor : term.getFactorsList()) {
-      factors.add(new Quantity.UnitFactor(factor.getUnitId(), factor.getExponent()));
-    }
-    return new Quantity.UnitTerm(term.getScaleNum(), term.getScaleDen(), factors);
-  }
-
-  /**
-   * An enumeration literal.
-   *
-   * @param literal the generated literal
-   * @return the immutable literal
-   */
-  public static EnumLiteral literal(org.openmbee.opensysml.proto.EnumLiteral literal) {
-    return new EnumLiteral(
-        literal.getLiteralId(),
-        literal.getEnumerationId(),
-        literal.getName(),
-        literal.hasValue() ? Optional.of(readable(literal.getValue())) : Optional.empty());
-  }
-
-  /**
-   * Diagnostics.
-   *
-   * @param diagnostics the generated diagnostics
-   * @return immutable diagnostics, in order
-   */
-  public static List<Diagnostic> diagnostics(
-      List<org.openmbee.opensysml.proto.Diagnostic> diagnostics) {
-    List<Diagnostic> read = new ArrayList<>(diagnostics.size());
-    for (org.openmbee.opensysml.proto.Diagnostic diagnostic : diagnostics) {
-      read.add(
-          new Diagnostic(
-              Diagnostic.Severity.fromWireName(diagnostic.getSeverity()),
-              diagnostic.getMessage(),
-              diagnostic.getCode(),
-              diagnostic.hasSpan() ? Optional.of(span(diagnostic.getSpan())) : Optional.empty()));
-    }
-    return List.copyOf(read);
-  }
-
-  private static Diagnostic.Span span(Span span) {
-    return new Diagnostic.Span(
-        span.getFile(),
-        span.getStartLine(),
-        span.getStartCol(),
-        span.getEndLine(),
-        span.getEndCol());
-  }
-
-  /**
-   * A symbol.
-   *
-   * @param symbol the generated symbol
-   * @return the immutable symbol
-   */
-  public static Symbol symbol(SymbolInfo symbol) {
-    List<Symbol.Attribute> attributes = new ArrayList<>(symbol.getAttributesCount());
-    for (AttributeInfo attribute : symbol.getAttributesList()) {
-      attributes.add(
-          new Symbol.Attribute(
-              attribute.getName(),
-              attribute.getType(),
-              attribute.hasValue()
-                  ? Optional.of(readable(attribute.getValue()))
-                  : Optional.<Value>empty(),
-              present(attribute.getUnit())));
-    }
-    List<Symbol.Specialization> specializations = new ArrayList<>(symbol.getSpecializationsCount());
-    for (Specialization specialization : symbol.getSpecializationsList()) {
-      specializations.add(
-          new Symbol.Specialization(
-              specialization.getKind(),
-              specialization.getDeclared(),
-              present(specialization.getTargetId()),
-              present(specialization.getTargetKind())));
-    }
-    return new Symbol(
-        symbol.getId(),
-        symbol.getName(),
-        symbol.getKind(),
-        symbol.getMetadataMap(),
-        symbol.getChildIdsList(),
-        attributes,
-        symbol.hasTypeInfo() ? Optional.of(typeFacts(symbol.getTypeInfo())) : Optional.empty(),
-        symbol.hasMultiplicity()
-            ? Optional.of(multiplicity(symbol.getMultiplicity()))
-            : Optional.empty(),
-        specializations,
-        symbol.getWithheldLibraryAttributes());
-  }
-
-  private static Symbol.TypeFacts typeFacts(TypeInfo typeInfo) {
-    return new Symbol.TypeFacts(
-        present(typeInfo.getDeclared()),
-        present(typeInfo.getResolvedId()),
-        present(typeInfo.getResolvedKind()),
-        present(typeInfo.getPrimitive()),
-        present(typeInfo.getPrimitiveSource()),
-        typeInfo.getQuantity(),
-        present(typeInfo.getUnit()));
-  }
-
-  private static Symbol.Multiplicity multiplicity(MultiplicityInfo multiplicity) {
-    return new Symbol.Multiplicity(
-        present(multiplicity.getLower()), present(multiplicity.getUpper()));
-  }
-
-  /**
-   * An instance.
-   *
-   * @param instance the generated instance
-   * @return the immutable instance
-   */
-  public static Instance instance(org.openmbee.opensysml.proto.Instance instance) {
-    Map<String, Instance.FeatureValue> featureValues = new LinkedHashMap<>();
-    for (Map.Entry<String, FeatureValue> entry : instance.getFeatureValuesMap().entrySet()) {
-      FeatureValue featureValue = entry.getValue();
-      List<Value> values = new ArrayList<>(featureValue.getValuesCount());
-      for (org.openmbee.opensysml.proto.Value each : featureValue.getValuesList()) {
-        values.add(readable(each));
-      }
-      featureValues.put(
-          entry.getKey(),
-          new Instance.FeatureValue(
-              featureValue.getFeatureName(),
-              featureValue.hasValue()
-                  ? Optional.of(readable(featureValue.getValue()))
-                  : Optional.<Value>empty(),
-              values,
-              featureValue.getMaterialized(),
-              present(featureValue.getError())));
-    }
-    return new Instance(instance.getId(), instance.getTypeSymbolId(), featureValues);
-  }
-
-  /**
-   * What an instantiation built.
-   *
-   * @param response the generated answer
-   * @return the immutable instantiation
-   */
-  public static Instantiation instantiation(InstantiateResponse response) {
-    List<Instance> reachable = new ArrayList<>(response.getInstancesCount());
-    for (org.openmbee.opensysml.proto.Instance instance : response.getInstancesList()) {
-      reachable.add(instance(instance));
-    }
-    return new Instantiation(
-        instance(response.getInstance()), reachable, diagnostics(response.getDiagnosticsList()));
-  }
-
-  /**
-   * A string field, absent when it holds its default.
-   *
-   * @param field the field value
-   * @return the string, or empty when it is empty
-   */
-  public static Optional<String> present(String field) {
-    return field.isEmpty() ? Optional.empty() : Optional.of(field);
-  }
-}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEdit.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEdit.java
deleted file mode 100644
index e9f2d3bda6..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/AppliedEdit.java
+++ /dev/null
@@ -1,1118 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-/**
- * <pre>
- * AppliedEdit is one byte range of the original source that an operation
- * replaced, so a client can report or locate what changed.
- * </pre>
- *
- * Protobuf type {@code sysml.AppliedEdit}
- */
-@com.google.protobuf.Generated
-public final class AppliedEdit extends
-    com.google.protobuf.GeneratedMessage implements
-    // @@protoc_insertion_point(message_implements:sysml.AppliedEdit)
-    AppliedEditOrBuilder {
-private static final long serialVersionUID = 0L;
-  static {
-    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
-      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
-      /* major= */ 4,
-      /* minor= */ 33,
-      /* patch= */ 1,
-      /* suffix= */ "",
-      "AppliedEdit");
-  }
-  // Use AppliedEdit.newBuilder() to construct.
-  private AppliedEdit(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
-    super(builder);
-  }
-  private AppliedEdit() {
-    target_ = "";
-    oldText_ = "";
-    newText_ = "";
-  }
-
-  public static final com.google.protobuf.Descriptors.Descriptor
-      getDescriptor() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_AppliedEdit_descriptor;
-  }
-
-  @java.lang.Override
-  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internalGetFieldAccessorTable() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_AppliedEdit_fieldAccessorTable
-        .ensureFieldAccessorsInitialized(
-            org.openmbee.opensysml.proto.AppliedEdit.class, org.openmbee.opensysml.proto.AppliedEdit.Builder.class);
-  }
-
-  public static final int OPERATION_INDEX_FIELD_NUMBER = 1;
-  private int operationIndex_ = 0;
-  /**
-   * <pre>
-   * Index of the operation in the request, so an answer maps back to its ask.
-   * </pre>
-   *
-   * <code>int32 operation_index = 1 [json_name = "operationIndex"];</code>
-   * @return The operationIndex.
-   */
-  @java.lang.Override
-  public int getOperationIndex() {
-    return operationIndex_;
-  }
-
-  public static final int TARGET_FIELD_NUMBER = 2;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object target_ = "";
-  /**
-   * <pre>
-   * element edited, as the request named it
-   * </pre>
-   *
-   * <code>string target = 2 [json_name = "target"];</code>
-   * @return The target.
-   */
-  @java.lang.Override
-  public java.lang.String getTarget() {
-    java.lang.Object ref = target_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      target_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * element edited, as the request named it
-   * </pre>
-   *
-   * <code>string target = 2 [json_name = "target"];</code>
-   * @return The bytes for target.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getTargetBytes() {
-    java.lang.Object ref = target_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      target_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int OFFSET_FIELD_NUMBER = 3;
-  private int offset_ = 0;
-  /**
-   * <pre>
-   * Batch offsets use the original source; sequential offsets use the
-   * intermediate source seen by that operation.
-   * </pre>
-   *
-   * <code>int32 offset = 3 [json_name = "offset"];</code>
-   * @return The offset.
-   */
-  @java.lang.Override
-  public int getOffset() {
-    return offset_;
-  }
-
-  public static final int LENGTH_FIELD_NUMBER = 4;
-  private int length_ = 0;
-  /**
-   * <pre>
-   * bytes replaced; zero for text inserted
-   * </pre>
-   *
-   * <code>int32 length = 4 [json_name = "length"];</code>
-   * @return The length.
-   */
-  @java.lang.Override
-  public int getLength() {
-    return length_;
-  }
-
-  public static final int OLD_TEXT_FIELD_NUMBER = 5;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object oldText_ = "";
-  /**
-   * <pre>
-   * what was there, empty for an insertion
-   * </pre>
-   *
-   * <code>string old_text = 5 [json_name = "oldText"];</code>
-   * @return The oldText.
-   */
-  @java.lang.Override
-  public java.lang.String getOldText() {
-    java.lang.Object ref = oldText_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      oldText_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * what was there, empty for an insertion
-   * </pre>
-   *
-   * <code>string old_text = 5 [json_name = "oldText"];</code>
-   * @return The bytes for oldText.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getOldTextBytes() {
-    java.lang.Object ref = oldText_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      oldText_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int NEW_TEXT_FIELD_NUMBER = 6;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object newText_ = "";
-  /**
-   * <pre>
-   * what was written
-   * </pre>
-   *
-   * <code>string new_text = 6 [json_name = "newText"];</code>
-   * @return The newText.
-   */
-  @java.lang.Override
-  public java.lang.String getNewText() {
-    java.lang.Object ref = newText_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      newText_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * what was written
-   * </pre>
-   *
-   * <code>string new_text = 6 [json_name = "newText"];</code>
-   * @return The bytes for newText.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getNewTextBytes() {
-    java.lang.Object ref = newText_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      newText_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  private byte memoizedIsInitialized = -1;
-  @java.lang.Override
-  public final boolean isInitialized() {
-    byte isInitialized = memoizedIsInitialized;
-    if (isInitialized == 1) return true;
-    if (isInitialized == 0) return false;
-
-    memoizedIsInitialized = 1;
-    return true;
-  }
-
-  @java.lang.Override
-  public void writeTo(com.google.protobuf.CodedOutputStream output)
-                      throws java.io.IOException {
-    if (operationIndex_ != 0) {
-      output.writeInt32(1, operationIndex_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(target_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 2, target_);
-    }
-    if (offset_ != 0) {
-      output.writeInt32(3, offset_);
-    }
-    if (length_ != 0) {
-      output.writeInt32(4, length_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(oldText_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 5, oldText_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(newText_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 6, newText_);
-    }
-    getUnknownFields().writeTo(output);
-  }
-
-  @java.lang.Override
-  public int getSerializedSize() {
-    int size = memoizedSize;
-    if (size != -1) return size;
-
-    size = 0;
-    if (operationIndex_ != 0) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeInt32Size(1, operationIndex_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(target_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, target_);
-    }
-    if (offset_ != 0) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeInt32Size(3, offset_);
-    }
-    if (length_ != 0) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeInt32Size(4, length_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(oldText_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(5, oldText_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(newText_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(6, newText_);
-    }
-    size += getUnknownFields().getSerializedSize();
-    memoizedSize = size;
-    return size;
-  }
-
-  @java.lang.Override
-  public boolean equals(final java.lang.Object obj) {
-    if (obj == this) {
-     return true;
-    }
-    if (!(obj instanceof org.openmbee.opensysml.proto.AppliedEdit)) {
-      return super.equals(obj);
-    }
-    org.openmbee.opensysml.proto.AppliedEdit other = (org.openmbee.opensysml.proto.AppliedEdit) obj;
-
-    if (getOperationIndex()
-        != other.getOperationIndex()) return false;
-    if (!getTarget()
-        .equals(other.getTarget())) return false;
-    if (getOffset()
-        != other.getOffset()) return false;
-    if (getLength()
-        != other.getLength()) return false;
-    if (!getOldText()
-        .equals(other.getOldText())) return false;
-    if (!getNewText()
-        .equals(other.getNewText())) return false;
-    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
-    return true;
-  }
-
-  @java.lang.Override
-  public int hashCode() {
-    if (memoizedHashCode != 0) {
-      return memoizedHashCode;
-    }
-    int hash = 41;
-    hash = (19 * hash) + getDescriptor().hashCode();
-    hash = (37 * hash) + OPERATION_INDEX_FIELD_NUMBER;
-    hash = (53 * hash) + getOperationIndex();
-    hash = (37 * hash) + TARGET_FIELD_NUMBER;
-    hash = (53 * hash) + getTarget().hashCode();
-    hash = (37 * hash) + OFFSET_FIELD_NUMBER;
-    hash = (53 * hash) + getOffset();
-    hash = (37 * hash) + LENGTH_FIELD_NUMBER;
-    hash = (53 * hash) + getLength();
-    hash = (37 * hash) + OLD_TEXT_FIELD_NUMBER;
-    hash = (53 * hash) + getOldText().hashCode();
-    hash = (37 * hash) + NEW_TEXT_FIELD_NUMBER;
-    hash = (53 * hash) + getNewText().hashCode();
-    hash = (29 * hash) + getUnknownFields().hashCode();
-    memoizedHashCode = hash;
-    return hash;
-  }
-
-  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
-      java.nio.ByteBuffer data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
-      java.nio.ByteBuffer data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
-      com.google.protobuf.ByteString data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
-      com.google.protobuf.ByteString data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(byte[] data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
-      byte[] data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  public static org.openmbee.opensysml.proto.AppliedEdit parseDelimitedFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input);
-  }
-
-  public static org.openmbee.opensysml.proto.AppliedEdit parseDelimitedFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
-      com.google.protobuf.CodedInputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.AppliedEdit parseFrom(
-      com.google.protobuf.CodedInputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  @java.lang.Override
-  public Builder newBuilderForType() { return newBuilder(); }
-  public static Builder newBuilder() {
-    return DEFAULT_INSTANCE.toBuilder();
-  }
-  public static Builder newBuilder(org.openmbee.opensysml.proto.AppliedEdit prototype) {
-    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
-  }
-  @java.lang.Override
-  public Builder toBuilder() {
-    return this == DEFAULT_INSTANCE
-        ? new Builder() : new Builder().mergeFrom(this);
-  }
-
-  @java.lang.Override
-  protected Builder newBuilderForType(
-      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-    Builder builder = new Builder(parent);
-    return builder;
-  }
-  /**
-   * <pre>
-   * AppliedEdit is one byte range of the original source that an operation
-   * replaced, so a client can report or locate what changed.
-   * </pre>
-   *
-   * Protobuf type {@code sysml.AppliedEdit}
-   */
-  public static final class Builder extends
-      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
-      // @@protoc_insertion_point(builder_implements:sysml.AppliedEdit)
-      org.openmbee.opensysml.proto.AppliedEditOrBuilder {
-    public static final com.google.protobuf.Descriptors.Descriptor
-        getDescriptor() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_AppliedEdit_descriptor;
-    }
-
-    @java.lang.Override
-    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-        internalGetFieldAccessorTable() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_AppliedEdit_fieldAccessorTable
-          .ensureFieldAccessorsInitialized(
-              org.openmbee.opensysml.proto.AppliedEdit.class, org.openmbee.opensysml.proto.AppliedEdit.Builder.class);
-    }
-
-    // Construct using org.openmbee.opensysml.proto.AppliedEdit.newBuilder()
-    private Builder() {
-
-    }
-
-    private Builder(
-        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-      super(parent);
-
-    }
-    @java.lang.Override
-    public Builder clear() {
-      super.clear();
-      bitField0_ = 0;
-      operationIndex_ = 0;
-      target_ = "";
-      offset_ = 0;
-      length_ = 0;
-      oldText_ = "";
-      newText_ = "";
-      return this;
-    }
-
-    @java.lang.Override
-    public com.google.protobuf.Descriptors.Descriptor
-        getDescriptorForType() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_AppliedEdit_descriptor;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.AppliedEdit getDefaultInstanceForType() {
-      return org.openmbee.opensysml.proto.AppliedEdit.getDefaultInstance();
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.AppliedEdit build() {
-      org.openmbee.opensysml.proto.AppliedEdit result = buildPartial();
-      if (!result.isInitialized()) {
-        throw newUninitializedMessageException(result);
-      }
-      return result;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.AppliedEdit buildPartial() {
-      org.openmbee.opensysml.proto.AppliedEdit result = new org.openmbee.opensysml.proto.AppliedEdit(this);
-      if (bitField0_ != 0) { buildPartial0(result); }
-      onBuilt();
-      return result;
-    }
-
-    private void buildPartial0(org.openmbee.opensysml.proto.AppliedEdit result) {
-      int from_bitField0_ = bitField0_;
-      if (((from_bitField0_ & 0x00000001) != 0)) {
-        result.operationIndex_ = operationIndex_;
-      }
-      if (((from_bitField0_ & 0x00000002) != 0)) {
-        result.target_ = target_;
-      }
-      if (((from_bitField0_ & 0x00000004) != 0)) {
-        result.offset_ = offset_;
-      }
-      if (((from_bitField0_ & 0x00000008) != 0)) {
-        result.length_ = length_;
-      }
-      if (((from_bitField0_ & 0x00000010) != 0)) {
-        result.oldText_ = oldText_;
-      }
-      if (((from_bitField0_ & 0x00000020) != 0)) {
-        result.newText_ = newText_;
-      }
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(com.google.protobuf.Message other) {
-      if (other instanceof org.openmbee.opensysml.proto.AppliedEdit) {
-        return mergeFrom((org.openmbee.opensysml.proto.AppliedEdit)other);
-      } else {
-        super.mergeFrom(other);
-        return this;
-      }
-    }
-
-    public Builder mergeFrom(org.openmbee.opensysml.proto.AppliedEdit other) {
-      if (other == org.openmbee.opensysml.proto.AppliedEdit.getDefaultInstance()) return this;
-      if (other.getOperationIndex() != 0) {
-        setOperationIndex(other.getOperationIndex());
-      }
-      if (!other.getTarget().isEmpty()) {
-        target_ = other.target_;
-        bitField0_ |= 0x00000002;
-        onChanged();
-      }
-      if (other.getOffset() != 0) {
-        setOffset(other.getOffset());
-      }
-      if (other.getLength() != 0) {
-        setLength(other.getLength());
-      }
-      if (!other.getOldText().isEmpty()) {
-        oldText_ = other.oldText_;
-        bitField0_ |= 0x00000010;
-        onChanged();
-      }
-      if (!other.getNewText().isEmpty()) {
-        newText_ = other.newText_;
-        bitField0_ |= 0x00000020;
-        onChanged();
-      }
-      this.mergeUnknownFields(other.getUnknownFields());
-      onChanged();
-      return this;
-    }
-
-    @java.lang.Override
-    public final boolean isInitialized() {
-      return true;
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws java.io.IOException {
-      if (extensionRegistry == null) {
-        throw new java.lang.NullPointerException();
-      }
-      try {
-        boolean done = false;
-        while (!done) {
-          int tag = input.readTag();
-          switch (tag) {
-            case 0:
-              done = true;
-              break;
-            case 8: {
-              operationIndex_ = input.readInt32();
-              bitField0_ |= 0x00000001;
-              break;
-            } // case 8
-            case 18: {
-              target_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000002;
-              break;
-            } // case 18
-            case 24: {
-              offset_ = input.readInt32();
-              bitField0_ |= 0x00000004;
-              break;
-            } // case 24
-            case 32: {
-              length_ = input.readInt32();
-              bitField0_ |= 0x00000008;
-              break;
-            } // case 32
-            case 42: {
-              oldText_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000010;
-              break;
-            } // case 42
-            case 50: {
-              newText_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000020;
-              break;
-            } // case 50
-            default: {
-              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
-                done = true; // was an endgroup tag
-              }
-              break;
-            } // default:
-          } // switch (tag)
-        } // while (!done)
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.unwrapIOException();
-      } finally {
-        onChanged();
-      } // finally
-      return this;
-    }
-    private int bitField0_;
-
-    private int operationIndex_ ;
-    /**
-     * <pre>
-     * Index of the operation in the request, so an answer maps back to its ask.
-     * </pre>
-     *
-     * <code>int32 operation_index = 1 [json_name = "operationIndex"];</code>
-     * @return The operationIndex.
-     */
-    @java.lang.Override
-    public int getOperationIndex() {
-      return operationIndex_;
-    }
-    /**
-     * <pre>
-     * Index of the operation in the request, so an answer maps back to its ask.
-     * </pre>
-     *
-     * <code>int32 operation_index = 1 [json_name = "operationIndex"];</code>
-     * @param value The operationIndex to set.
-     * @return This builder for chaining.
-     */
-    public Builder setOperationIndex(int value) {
-
-      operationIndex_ = value;
-      bitField0_ |= 0x00000001;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Index of the operation in the request, so an answer maps back to its ask.
-     * </pre>
-     *
-     * <code>int32 operation_index = 1 [json_name = "operationIndex"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearOperationIndex() {
-      bitField0_ = (bitField0_ & ~0x00000001);
-      operationIndex_ = 0;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object target_ = "";
-    /**
-     * <pre>
-     * element edited, as the request named it
-     * </pre>
-     *
-     * <code>string target = 2 [json_name = "target"];</code>
-     * @return The target.
-     */
-    public java.lang.String getTarget() {
-      java.lang.Object ref = target_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        target_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * element edited, as the request named it
-     * </pre>
-     *
-     * <code>string target = 2 [json_name = "target"];</code>
-     * @return The bytes for target.
-     */
-    public com.google.protobuf.ByteString
-        getTargetBytes() {
-      java.lang.Object ref = target_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        target_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * element edited, as the request named it
-     * </pre>
-     *
-     * <code>string target = 2 [json_name = "target"];</code>
-     * @param value The target to set.
-     * @return This builder for chaining.
-     */
-    public Builder setTarget(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      target_ = value;
-      bitField0_ |= 0x00000002;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * element edited, as the request named it
-     * </pre>
-     *
-     * <code>string target = 2 [json_name = "target"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearTarget() {
-      target_ = getDefaultInstance().getTarget();
-      bitField0_ = (bitField0_ & ~0x00000002);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * element edited, as the request named it
-     * </pre>
-     *
-     * <code>string target = 2 [json_name = "target"];</code>
-     * @param value The bytes for target to set.
-     * @return This builder for chaining.
-     */
-    public Builder setTargetBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      target_ = value;
-      bitField0_ |= 0x00000002;
-      onChanged();
-      return this;
-    }
-
-    private int offset_ ;
-    /**
-     * <pre>
-     * Batch offsets use the original source; sequential offsets use the
-     * intermediate source seen by that operation.
-     * </pre>
-     *
-     * <code>int32 offset = 3 [json_name = "offset"];</code>
-     * @return The offset.
-     */
-    @java.lang.Override
-    public int getOffset() {
-      return offset_;
-    }
-    /**
-     * <pre>
-     * Batch offsets use the original source; sequential offsets use the
-     * intermediate source seen by that operation.
-     * </pre>
-     *
-     * <code>int32 offset = 3 [json_name = "offset"];</code>
-     * @param value The offset to set.
-     * @return This builder for chaining.
-     */
-    public Builder setOffset(int value) {
-
-      offset_ = value;
-      bitField0_ |= 0x00000004;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Batch offsets use the original source; sequential offsets use the
-     * intermediate source seen by that operation.
-     * </pre>
-     *
-     * <code>int32 offset = 3 [json_name = "offset"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearOffset() {
-      bitField0_ = (bitField0_ & ~0x00000004);
-      offset_ = 0;
-      onChanged();
-      return this;
-    }
-
-    private int length_ ;
-    /**
-     * <pre>
-     * bytes replaced; zero for text inserted
-     * </pre>
-     *
-     * <code>int32 length = 4 [json_name = "length"];</code>
-     * @return The length.
-     */
-    @java.lang.Override
-    public int getLength() {
-      return length_;
-    }
-    /**
-     * <pre>
-     * bytes replaced; zero for text inserted
-     * </pre>
-     *
-     * <code>int32 length = 4 [json_name = "length"];</code>
-     * @param value The length to set.
-     * @return This builder for chaining.
-     */
-    public Builder setLength(int value) {
-
-      length_ = value;
-      bitField0_ |= 0x00000008;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * bytes replaced; zero for text inserted
-     * </pre>
-     *
-     * <code>int32 length = 4 [json_name = "length"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearLength() {
-      bitField0_ = (bitField0_ & ~0x00000008);
-      length_ = 0;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object oldText_ = "";
-    /**
-     * <pre>
-     * what was there, empty for an insertion
-     * </pre>
-     *
-     * <code>string old_text = 5 [json_name = "oldText"];</code>
-     * @return The oldText.
-     */
-    public java.lang.String getOldText() {
-      java.lang.Object ref = oldText_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        oldText_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * what was there, empty for an insertion
-     * </pre>
-     *
-     * <code>string old_text = 5 [json_name = "oldText"];</code>
-     * @return The bytes for oldText.
-     */
-    public com.google.protobuf.ByteString
-        getOldTextBytes() {
-      java.lang.Object ref = oldText_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        oldText_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * what was there, empty for an insertion
-     * </pre>
-     *
-     * <code>string old_text = 5 [json_name = "oldText"];</code>
-     * @param value The oldText to set.
-     * @return This builder for chaining.
-     */
-    public Builder setOldText(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      oldText_ = value;
-      bitField0_ |= 0x00000010;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * what was there, empty for an insertion
-     * </pre>
-     *
-     * <code>string old_text = 5 [json_name = "oldText"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearOldText() {
-      oldText_ = getDefaultInstance().getOldText();
-      bitField0_ = (bitField0_ & ~0x00000010);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * what was there, empty for an insertion
-     * </pre>
-     *
-     * <code>string old_text = 5 [json_name = "oldText"];</code>
-     * @param value The bytes for oldText to set.
-     * @return This builder for chaining.
-     */
-    public Builder setOldTextBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      oldText_ = value;
-      bitField0_ |= 0x00000010;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object newText_ = "";
-    /**
-     * <pre>
-     * what was written
-     * </pre>
-     *
-     * <code>string new_text = 6 [json_name = "newText"];</code>
-     * @return The newText.
-     */
-    public java.lang.String getNewText() {
-      java.lang.Object ref = newText_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        newText_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * what was written
-     * </pre>
-     *
-     * <code>string new_text = 6 [json_name = "newText"];</code>
-     * @return The bytes for newText.
-     */
-    public com.google.protobuf.ByteString
-        getNewTextBytes() {
-      java.lang.Object ref = newText_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        newText_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * what was written
-     * </pre>
-     *
-     * <code>string new_text = 6 [json_name = "newText"];</code>
-     * @param value The newText to set.
-     * @return This builder for chaining.
-     */
-    public Builder setNewText(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      newText_ = value;
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * what was written
-     * </pre>
-     *
-     * <code>string new_text = 6 [json_name = "newText"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearNewText() {
-      newText_ = getDefaultInstance().getNewText();
-      bitField0_ = (bitField0_ & ~0x00000020);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * what was written
-     * </pre>
-     *
-     * <code>string new_text = 6 [json_name = "newText"];</code>
-     * @param value The bytes for newText to set.
-     * @return This builder for chaining.
-     */
-    public Builder setNewTextBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      newText_ = value;
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-
-    // @@protoc_insertion_point(builder_scope:sysml.AppliedEdit)
-  }
-
-  // @@protoc_insertion_point(class_scope:sysml.AppliedEdit)
-  private static final org.openmbee.opensysml.proto.AppliedEdit DEFAULT_INSTANCE;
-  static {
-    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.AppliedEdit();
-  }
-
-  public static org.openmbee.opensysml.proto.AppliedEdit getDefaultInstance() {
-    return DEFAULT_INSTANCE;
-  }
-
-  private static final com.google.protobuf.Parser<AppliedEdit>
-      PARSER = new com.google.protobuf.AbstractParser<AppliedEdit>() {
-    @java.lang.Override
-    public AppliedEdit parsePartialFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws com.google.protobuf.InvalidProtocolBufferException {
-      Builder builder = newBuilder();
-      try {
-        builder.mergeFrom(input, extensionRegistry);
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.setUnfinishedMessage(builder.buildPartial());
-      } catch (com.google.protobuf.UninitializedMessageException e) {
-        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
-      } catch (java.io.IOException e) {
-        throw new com.google.protobuf.InvalidProtocolBufferException(e)
-            .setUnfinishedMessage(builder.buildPartial());
-      }
-      return builder.buildPartial();
-    }
-  };
-
-  public static com.google.protobuf.Parser<AppliedEdit> parser() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public com.google.protobuf.Parser<AppliedEdit> getParserForType() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.AppliedEdit getDefaultInstanceForType() {
-    return DEFAULT_INSTANCE;
-  }
-
-}
-
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequest.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequest.java
deleted file mode 100644
index 2519d5246f..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequest.java
+++ /dev/null
@@ -1,1011 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-/**
- * <pre>
- * ApplyEditsRequest asks for a model's source with edits applied to it. The
- * source edited is the one parse read, named by its hash, so an edit is applied
- * to the model that was inspected.
- * </pre>
- *
- * Protobuf type {@code sysml.ApplyEditsRequest}
- */
-@com.google.protobuf.Generated
-public final class ApplyEditsRequest extends
-    com.google.protobuf.GeneratedMessage implements
-    // @@protoc_insertion_point(message_implements:sysml.ApplyEditsRequest)
-    ApplyEditsRequestOrBuilder {
-private static final long serialVersionUID = 0L;
-  static {
-    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
-      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
-      /* major= */ 4,
-      /* minor= */ 33,
-      /* patch= */ 1,
-      /* suffix= */ "",
-      "ApplyEditsRequest");
-  }
-  // Use ApplyEditsRequest.newBuilder() to construct.
-  private ApplyEditsRequest(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
-    super(builder);
-  }
-  private ApplyEditsRequest() {
-    modelHash_ = "";
-    operations_ = java.util.Collections.emptyList();
-  }
-
-  public static final com.google.protobuf.Descriptors.Descriptor
-      getDescriptor() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsRequest_descriptor;
-  }
-
-  @java.lang.Override
-  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internalGetFieldAccessorTable() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsRequest_fieldAccessorTable
-        .ensureFieldAccessorsInitialized(
-            org.openmbee.opensysml.proto.ApplyEditsRequest.class, org.openmbee.opensysml.proto.ApplyEditsRequest.Builder.class);
-  }
-
-  public static final int MODEL_HASH_FIELD_NUMBER = 1;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object modelHash_ = "";
-  /**
-   * <pre>
-   * from ParseFile response
-   * </pre>
-   *
-   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-   * @return The modelHash.
-   */
-  @java.lang.Override
-  public java.lang.String getModelHash() {
-    java.lang.Object ref = modelHash_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      modelHash_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * from ParseFile response
-   * </pre>
-   *
-   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-   * @return The bytes for modelHash.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getModelHashBytes() {
-    java.lang.Object ref = modelHash_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      modelHash_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int OPERATIONS_FIELD_NUMBER = 2;
-  @SuppressWarnings("serial")
-  private java.util.List<org.openmbee.opensysml.proto.EditOperation> operations_;
-  /**
-   * <pre>
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   * </pre>
-   *
-   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-   */
-  @java.lang.Override
-  public java.util.List<org.openmbee.opensysml.proto.EditOperation> getOperationsList() {
-    return operations_;
-  }
-  /**
-   * <pre>
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   * </pre>
-   *
-   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-   */
-  @java.lang.Override
-  public java.util.List<? extends org.openmbee.opensysml.proto.EditOperationOrBuilder> 
-      getOperationsOrBuilderList() {
-    return operations_;
-  }
-  /**
-   * <pre>
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   * </pre>
-   *
-   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-   */
-  @java.lang.Override
-  public int getOperationsCount() {
-    return operations_.size();
-  }
-  /**
-   * <pre>
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   * </pre>
-   *
-   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.EditOperation getOperations(int index) {
-    return operations_.get(index);
-  }
-  /**
-   * <pre>
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   * </pre>
-   *
-   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.EditOperationOrBuilder getOperationsOrBuilder(
-      int index) {
-    return operations_.get(index);
-  }
-
-  private byte memoizedIsInitialized = -1;
-  @java.lang.Override
-  public final boolean isInitialized() {
-    byte isInitialized = memoizedIsInitialized;
-    if (isInitialized == 1) return true;
-    if (isInitialized == 0) return false;
-
-    memoizedIsInitialized = 1;
-    return true;
-  }
-
-  @java.lang.Override
-  public void writeTo(com.google.protobuf.CodedOutputStream output)
-                      throws java.io.IOException {
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(modelHash_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 1, modelHash_);
-    }
-    for (int i = 0; i < operations_.size(); i++) {
-      output.writeMessage(2, operations_.get(i));
-    }
-    getUnknownFields().writeTo(output);
-  }
-
-  @java.lang.Override
-  public int getSerializedSize() {
-    int size = memoizedSize;
-    if (size != -1) return size;
-
-    size = 0;
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(modelHash_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, modelHash_);
-    }
-    for (int i = 0; i < operations_.size(); i++) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeMessageSize(2, operations_.get(i));
-    }
-    size += getUnknownFields().getSerializedSize();
-    memoizedSize = size;
-    return size;
-  }
-
-  @java.lang.Override
-  public boolean equals(final java.lang.Object obj) {
-    if (obj == this) {
-     return true;
-    }
-    if (!(obj instanceof org.openmbee.opensysml.proto.ApplyEditsRequest)) {
-      return super.equals(obj);
-    }
-    org.openmbee.opensysml.proto.ApplyEditsRequest other = (org.openmbee.opensysml.proto.ApplyEditsRequest) obj;
-
-    if (!getModelHash()
-        .equals(other.getModelHash())) return false;
-    if (!getOperationsList()
-        .equals(other.getOperationsList())) return false;
-    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
-    return true;
-  }
-
-  @java.lang.Override
-  public int hashCode() {
-    if (memoizedHashCode != 0) {
-      return memoizedHashCode;
-    }
-    int hash = 41;
-    hash = (19 * hash) + getDescriptor().hashCode();
-    hash = (37 * hash) + MODEL_HASH_FIELD_NUMBER;
-    hash = (53 * hash) + getModelHash().hashCode();
-    if (getOperationsCount() > 0) {
-      hash = (37 * hash) + OPERATIONS_FIELD_NUMBER;
-      hash = (53 * hash) + getOperationsList().hashCode();
-    }
-    hash = (29 * hash) + getUnknownFields().hashCode();
-    memoizedHashCode = hash;
-    return hash;
-  }
-
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
-      java.nio.ByteBuffer data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
-      java.nio.ByteBuffer data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
-      com.google.protobuf.ByteString data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
-      com.google.protobuf.ByteString data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(byte[] data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
-      byte[] data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseDelimitedFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input);
-  }
-
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseDelimitedFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
-      com.google.protobuf.CodedInputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest parseFrom(
-      com.google.protobuf.CodedInputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  @java.lang.Override
-  public Builder newBuilderForType() { return newBuilder(); }
-  public static Builder newBuilder() {
-    return DEFAULT_INSTANCE.toBuilder();
-  }
-  public static Builder newBuilder(org.openmbee.opensysml.proto.ApplyEditsRequest prototype) {
-    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
-  }
-  @java.lang.Override
-  public Builder toBuilder() {
-    return this == DEFAULT_INSTANCE
-        ? new Builder() : new Builder().mergeFrom(this);
-  }
-
-  @java.lang.Override
-  protected Builder newBuilderForType(
-      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-    Builder builder = new Builder(parent);
-    return builder;
-  }
-  /**
-   * <pre>
-   * ApplyEditsRequest asks for a model's source with edits applied to it. The
-   * source edited is the one parse read, named by its hash, so an edit is applied
-   * to the model that was inspected.
-   * </pre>
-   *
-   * Protobuf type {@code sysml.ApplyEditsRequest}
-   */
-  public static final class Builder extends
-      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
-      // @@protoc_insertion_point(builder_implements:sysml.ApplyEditsRequest)
-      org.openmbee.opensysml.proto.ApplyEditsRequestOrBuilder {
-    public static final com.google.protobuf.Descriptors.Descriptor
-        getDescriptor() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsRequest_descriptor;
-    }
-
-    @java.lang.Override
-    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-        internalGetFieldAccessorTable() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsRequest_fieldAccessorTable
-          .ensureFieldAccessorsInitialized(
-              org.openmbee.opensysml.proto.ApplyEditsRequest.class, org.openmbee.opensysml.proto.ApplyEditsRequest.Builder.class);
-    }
-
-    // Construct using org.openmbee.opensysml.proto.ApplyEditsRequest.newBuilder()
-    private Builder() {
-
-    }
-
-    private Builder(
-        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-      super(parent);
-
-    }
-    @java.lang.Override
-    public Builder clear() {
-      super.clear();
-      bitField0_ = 0;
-      modelHash_ = "";
-      if (operationsBuilder_ == null) {
-        operations_ = java.util.Collections.emptyList();
-      } else {
-        operations_ = null;
-        operationsBuilder_.clear();
-      }
-      bitField0_ = (bitField0_ & ~0x00000002);
-      return this;
-    }
-
-    @java.lang.Override
-    public com.google.protobuf.Descriptors.Descriptor
-        getDescriptorForType() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsRequest_descriptor;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.ApplyEditsRequest getDefaultInstanceForType() {
-      return org.openmbee.opensysml.proto.ApplyEditsRequest.getDefaultInstance();
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.ApplyEditsRequest build() {
-      org.openmbee.opensysml.proto.ApplyEditsRequest result = buildPartial();
-      if (!result.isInitialized()) {
-        throw newUninitializedMessageException(result);
-      }
-      return result;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.ApplyEditsRequest buildPartial() {
-      org.openmbee.opensysml.proto.ApplyEditsRequest result = new org.openmbee.opensysml.proto.ApplyEditsRequest(this);
-      buildPartialRepeatedFields(result);
-      if (bitField0_ != 0) { buildPartial0(result); }
-      onBuilt();
-      return result;
-    }
-
-    private void buildPartialRepeatedFields(org.openmbee.opensysml.proto.ApplyEditsRequest result) {
-      if (operationsBuilder_ == null) {
-        if (((bitField0_ & 0x00000002) != 0)) {
-          operations_ = java.util.Collections.unmodifiableList(operations_);
-          bitField0_ = (bitField0_ & ~0x00000002);
-        }
-        result.operations_ = operations_;
-      } else {
-        result.operations_ = operationsBuilder_.build();
-      }
-    }
-
-    private void buildPartial0(org.openmbee.opensysml.proto.ApplyEditsRequest result) {
-      int from_bitField0_ = bitField0_;
-      if (((from_bitField0_ & 0x00000001) != 0)) {
-        result.modelHash_ = modelHash_;
-      }
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(com.google.protobuf.Message other) {
-      if (other instanceof org.openmbee.opensysml.proto.ApplyEditsRequest) {
-        return mergeFrom((org.openmbee.opensysml.proto.ApplyEditsRequest)other);
-      } else {
-        super.mergeFrom(other);
-        return this;
-      }
-    }
-
-    public Builder mergeFrom(org.openmbee.opensysml.proto.ApplyEditsRequest other) {
-      if (other == org.openmbee.opensysml.proto.ApplyEditsRequest.getDefaultInstance()) return this;
-      if (!other.getModelHash().isEmpty()) {
-        modelHash_ = other.modelHash_;
-        bitField0_ |= 0x00000001;
-        onChanged();
-      }
-      if (operationsBuilder_ == null) {
-        if (!other.operations_.isEmpty()) {
-          if (operations_.isEmpty()) {
-            operations_ = other.operations_;
-            bitField0_ = (bitField0_ & ~0x00000002);
-          } else {
-            ensureOperationsIsMutable();
-            operations_.addAll(other.operations_);
-          }
-          onChanged();
-        }
-      } else {
-        if (!other.operations_.isEmpty()) {
-          if (operationsBuilder_.isEmpty()) {
-            operationsBuilder_.dispose();
-            operationsBuilder_ = null;
-            operations_ = other.operations_;
-            bitField0_ = (bitField0_ & ~0x00000002);
-            operationsBuilder_ = 
-              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
-                 internalGetOperationsFieldBuilder() : null;
-          } else {
-            operationsBuilder_.addAllMessages(other.operations_);
-          }
-        }
-      }
-      this.mergeUnknownFields(other.getUnknownFields());
-      onChanged();
-      return this;
-    }
-
-    @java.lang.Override
-    public final boolean isInitialized() {
-      return true;
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws java.io.IOException {
-      if (extensionRegistry == null) {
-        throw new java.lang.NullPointerException();
-      }
-      try {
-        boolean done = false;
-        while (!done) {
-          int tag = input.readTag();
-          switch (tag) {
-            case 0:
-              done = true;
-              break;
-            case 10: {
-              modelHash_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000001;
-              break;
-            } // case 10
-            case 18: {
-              org.openmbee.opensysml.proto.EditOperation m =
-                  input.readMessage(
-                      org.openmbee.opensysml.proto.EditOperation.parser(),
-                      extensionRegistry);
-              if (operationsBuilder_ == null) {
-                ensureOperationsIsMutable();
-                operations_.add(m);
-              } else {
-                operationsBuilder_.addMessage(m);
-              }
-              break;
-            } // case 18
-            default: {
-              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
-                done = true; // was an endgroup tag
-              }
-              break;
-            } // default:
-          } // switch (tag)
-        } // while (!done)
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.unwrapIOException();
-      } finally {
-        onChanged();
-      } // finally
-      return this;
-    }
-    private int bitField0_;
-
-    private java.lang.Object modelHash_ = "";
-    /**
-     * <pre>
-     * from ParseFile response
-     * </pre>
-     *
-     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-     * @return The modelHash.
-     */
-    public java.lang.String getModelHash() {
-      java.lang.Object ref = modelHash_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        modelHash_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * from ParseFile response
-     * </pre>
-     *
-     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-     * @return The bytes for modelHash.
-     */
-    public com.google.protobuf.ByteString
-        getModelHashBytes() {
-      java.lang.Object ref = modelHash_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        modelHash_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * from ParseFile response
-     * </pre>
-     *
-     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-     * @param value The modelHash to set.
-     * @return This builder for chaining.
-     */
-    public Builder setModelHash(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      modelHash_ = value;
-      bitField0_ |= 0x00000001;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * from ParseFile response
-     * </pre>
-     *
-     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearModelHash() {
-      modelHash_ = getDefaultInstance().getModelHash();
-      bitField0_ = (bitField0_ & ~0x00000001);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * from ParseFile response
-     * </pre>
-     *
-     * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-     * @param value The bytes for modelHash to set.
-     * @return This builder for chaining.
-     */
-    public Builder setModelHashBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      modelHash_ = value;
-      bitField0_ |= 0x00000001;
-      onChanged();
-      return this;
-    }
-
-    private java.util.List<org.openmbee.opensysml.proto.EditOperation> operations_ =
-      java.util.Collections.emptyList();
-    private void ensureOperationsIsMutable() {
-      if (!((bitField0_ & 0x00000002) != 0)) {
-        operations_ = new java.util.ArrayList<org.openmbee.opensysml.proto.EditOperation>(operations_);
-        bitField0_ |= 0x00000002;
-       }
-    }
-
-    private com.google.protobuf.RepeatedFieldBuilder<
-        org.openmbee.opensysml.proto.EditOperation, org.openmbee.opensysml.proto.EditOperation.Builder, org.openmbee.opensysml.proto.EditOperationOrBuilder> operationsBuilder_;
-
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public java.util.List<org.openmbee.opensysml.proto.EditOperation> getOperationsList() {
-      if (operationsBuilder_ == null) {
-        return java.util.Collections.unmodifiableList(operations_);
-      } else {
-        return operationsBuilder_.getMessageList();
-      }
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public int getOperationsCount() {
-      if (operationsBuilder_ == null) {
-        return operations_.size();
-      } else {
-        return operationsBuilder_.getCount();
-      }
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public org.openmbee.opensysml.proto.EditOperation getOperations(int index) {
-      if (operationsBuilder_ == null) {
-        return operations_.get(index);
-      } else {
-        return operationsBuilder_.getMessage(index);
-      }
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public Builder setOperations(
-        int index, org.openmbee.opensysml.proto.EditOperation value) {
-      if (operationsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureOperationsIsMutable();
-        operations_.set(index, value);
-        onChanged();
-      } else {
-        operationsBuilder_.setMessage(index, value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public Builder setOperations(
-        int index, org.openmbee.opensysml.proto.EditOperation.Builder builderForValue) {
-      if (operationsBuilder_ == null) {
-        ensureOperationsIsMutable();
-        operations_.set(index, builderForValue.build());
-        onChanged();
-      } else {
-        operationsBuilder_.setMessage(index, builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public Builder addOperations(org.openmbee.opensysml.proto.EditOperation value) {
-      if (operationsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureOperationsIsMutable();
-        operations_.add(value);
-        onChanged();
-      } else {
-        operationsBuilder_.addMessage(value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public Builder addOperations(
-        int index, org.openmbee.opensysml.proto.EditOperation value) {
-      if (operationsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureOperationsIsMutable();
-        operations_.add(index, value);
-        onChanged();
-      } else {
-        operationsBuilder_.addMessage(index, value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public Builder addOperations(
-        org.openmbee.opensysml.proto.EditOperation.Builder builderForValue) {
-      if (operationsBuilder_ == null) {
-        ensureOperationsIsMutable();
-        operations_.add(builderForValue.build());
-        onChanged();
-      } else {
-        operationsBuilder_.addMessage(builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public Builder addOperations(
-        int index, org.openmbee.opensysml.proto.EditOperation.Builder builderForValue) {
-      if (operationsBuilder_ == null) {
-        ensureOperationsIsMutable();
-        operations_.add(index, builderForValue.build());
-        onChanged();
-      } else {
-        operationsBuilder_.addMessage(index, builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public Builder addAllOperations(
-        java.lang.Iterable<? extends org.openmbee.opensysml.proto.EditOperation> values) {
-      if (operationsBuilder_ == null) {
-        ensureOperationsIsMutable();
-        com.google.protobuf.AbstractMessageLite.Builder.addAll(
-            values, operations_);
-        onChanged();
-      } else {
-        operationsBuilder_.addAllMessages(values);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public Builder clearOperations() {
-      if (operationsBuilder_ == null) {
-        operations_ = java.util.Collections.emptyList();
-        bitField0_ = (bitField0_ & ~0x00000002);
-        onChanged();
-      } else {
-        operationsBuilder_.clear();
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public Builder removeOperations(int index) {
-      if (operationsBuilder_ == null) {
-        ensureOperationsIsMutable();
-        operations_.remove(index);
-        onChanged();
-      } else {
-        operationsBuilder_.remove(index);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public org.openmbee.opensysml.proto.EditOperation.Builder getOperationsBuilder(
-        int index) {
-      return internalGetOperationsFieldBuilder().getBuilder(index);
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public org.openmbee.opensysml.proto.EditOperationOrBuilder getOperationsOrBuilder(
-        int index) {
-      if (operationsBuilder_ == null) {
-        return operations_.get(index);  } else {
-        return operationsBuilder_.getMessageOrBuilder(index);
-      }
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public java.util.List<? extends org.openmbee.opensysml.proto.EditOperationOrBuilder> 
-         getOperationsOrBuilderList() {
-      if (operationsBuilder_ != null) {
-        return operationsBuilder_.getMessageOrBuilderList();
-      } else {
-        return java.util.Collections.unmodifiableList(operations_);
-      }
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public org.openmbee.opensysml.proto.EditOperation.Builder addOperationsBuilder() {
-      return internalGetOperationsFieldBuilder().addBuilder(
-          org.openmbee.opensysml.proto.EditOperation.getDefaultInstance());
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public org.openmbee.opensysml.proto.EditOperation.Builder addOperationsBuilder(
-        int index) {
-      return internalGetOperationsFieldBuilder().addBuilder(
-          index, org.openmbee.opensysml.proto.EditOperation.getDefaultInstance());
-    }
-    /**
-     * <pre>
-     * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-     * and answering with the unedited source would look like one was made.
-     * </pre>
-     *
-     * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-     */
-    public java.util.List<org.openmbee.opensysml.proto.EditOperation.Builder> 
-         getOperationsBuilderList() {
-      return internalGetOperationsFieldBuilder().getBuilderList();
-    }
-    private com.google.protobuf.RepeatedFieldBuilder<
-        org.openmbee.opensysml.proto.EditOperation, org.openmbee.opensysml.proto.EditOperation.Builder, org.openmbee.opensysml.proto.EditOperationOrBuilder> 
-        internalGetOperationsFieldBuilder() {
-      if (operationsBuilder_ == null) {
-        operationsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
-            org.openmbee.opensysml.proto.EditOperation, org.openmbee.opensysml.proto.EditOperation.Builder, org.openmbee.opensysml.proto.EditOperationOrBuilder>(
-                operations_,
-                ((bitField0_ & 0x00000002) != 0),
-                getParentForChildren(),
-                isClean());
-        operations_ = null;
-      }
-      return operationsBuilder_;
-    }
-
-    // @@protoc_insertion_point(builder_scope:sysml.ApplyEditsRequest)
-  }
-
-  // @@protoc_insertion_point(class_scope:sysml.ApplyEditsRequest)
-  private static final org.openmbee.opensysml.proto.ApplyEditsRequest DEFAULT_INSTANCE;
-  static {
-    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.ApplyEditsRequest();
-  }
-
-  public static org.openmbee.opensysml.proto.ApplyEditsRequest getDefaultInstance() {
-    return DEFAULT_INSTANCE;
-  }
-
-  private static final com.google.protobuf.Parser<ApplyEditsRequest>
-      PARSER = new com.google.protobuf.AbstractParser<ApplyEditsRequest>() {
-    @java.lang.Override
-    public ApplyEditsRequest parsePartialFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws com.google.protobuf.InvalidProtocolBufferException {
-      Builder builder = newBuilder();
-      try {
-        builder.mergeFrom(input, extensionRegistry);
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.setUnfinishedMessage(builder.buildPartial());
-      } catch (com.google.protobuf.UninitializedMessageException e) {
-        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
-      } catch (java.io.IOException e) {
-        throw new com.google.protobuf.InvalidProtocolBufferException(e)
-            .setUnfinishedMessage(builder.buildPartial());
-      }
-      return builder.buildPartial();
-    }
-  };
-
-  public static com.google.protobuf.Parser<ApplyEditsRequest> parser() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public com.google.protobuf.Parser<ApplyEditsRequest> getParserForType() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.ApplyEditsRequest getDefaultInstanceForType() {
-    return DEFAULT_INSTANCE;
-  }
-
-}
-
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequestOrBuilder.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequestOrBuilder.java
deleted file mode 100644
index 43232988c3..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsRequestOrBuilder.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-@com.google.protobuf.Generated
-public interface ApplyEditsRequestOrBuilder extends
-    // @@protoc_insertion_point(interface_extends:sysml.ApplyEditsRequest)
-    com.google.protobuf.MessageOrBuilder {
-
-  /**
-   * <pre>
-   * from ParseFile response
-   * </pre>
-   *
-   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-   * @return The modelHash.
-   */
-  java.lang.String getModelHash();
-  /**
-   * <pre>
-   * from ParseFile response
-   * </pre>
-   *
-   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-   * @return The bytes for modelHash.
-   */
-  com.google.protobuf.ByteString
-      getModelHashBytes();
-
-  /**
-   * <pre>
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   * </pre>
-   *
-   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-   */
-  java.util.List<org.openmbee.opensysml.proto.EditOperation> 
-      getOperationsList();
-  /**
-   * <pre>
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   * </pre>
-   *
-   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-   */
-  org.openmbee.opensysml.proto.EditOperation getOperations(int index);
-  /**
-   * <pre>
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   * </pre>
-   *
-   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-   */
-  int getOperationsCount();
-  /**
-   * <pre>
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   * </pre>
-   *
-   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-   */
-  java.util.List<? extends org.openmbee.opensysml.proto.EditOperationOrBuilder> 
-      getOperationsOrBuilderList();
-  /**
-   * <pre>
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   * </pre>
-   *
-   * <code>repeated .sysml.EditOperation operations = 2 [json_name = "operations"];</code>
-   */
-  org.openmbee.opensysml.proto.EditOperationOrBuilder getOperationsOrBuilder(
-      int index);
-}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponse.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponse.java
deleted file mode 100644
index 7971cba2da..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponse.java
+++ /dev/null
@@ -1,2001 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-/**
- * <pre>
- * ApplyEditsResponse carries the edited source, or says why nothing was edited.
- * </pre>
- *
- * Protobuf type {@code sysml.ApplyEditsResponse}
- */
-@com.google.protobuf.Generated
-public final class ApplyEditsResponse extends
-    com.google.protobuf.GeneratedMessage implements
-    // @@protoc_insertion_point(message_implements:sysml.ApplyEditsResponse)
-    ApplyEditsResponseOrBuilder {
-private static final long serialVersionUID = 0L;
-  static {
-    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
-      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
-      /* major= */ 4,
-      /* minor= */ 33,
-      /* patch= */ 1,
-      /* suffix= */ "",
-      "ApplyEditsResponse");
-  }
-  // Use ApplyEditsResponse.newBuilder() to construct.
-  private ApplyEditsResponse(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
-    super(builder);
-  }
-  private ApplyEditsResponse() {
-    content_ = "";
-    applied_ = java.util.Collections.emptyList();
-    error_ = "";
-    failure_ = 0;
-    diagnostics_ = java.util.Collections.emptyList();
-    referringElements_ =
-        com.google.protobuf.LazyStringArrayList.emptyList();
-  }
-
-  public static final com.google.protobuf.Descriptors.Descriptor
-      getDescriptor() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsResponse_descriptor;
-  }
-
-  @java.lang.Override
-  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internalGetFieldAccessorTable() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsResponse_fieldAccessorTable
-        .ensureFieldAccessorsInitialized(
-            org.openmbee.opensysml.proto.ApplyEditsResponse.class, org.openmbee.opensysml.proto.ApplyEditsResponse.Builder.class);
-  }
-
-  public static final int CONTENT_FIELD_NUMBER = 1;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object content_ = "";
-  /**
-   * <pre>
-   * The edited notation, byte-identical to the source outside the edited spans.
-   * Empty when the edits were refused, so a refusal never writes a file.
-   * </pre>
-   *
-   * <code>string content = 1 [json_name = "content"];</code>
-   * @return The content.
-   */
-  @java.lang.Override
-  public java.lang.String getContent() {
-    java.lang.Object ref = content_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      content_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * The edited notation, byte-identical to the source outside the edited spans.
-   * Empty when the edits were refused, so a refusal never writes a file.
-   * </pre>
-   *
-   * <code>string content = 1 [json_name = "content"];</code>
-   * @return The bytes for content.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getContentBytes() {
-    java.lang.Object ref = content_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      content_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int APPLIED_FIELD_NUMBER = 2;
-  @SuppressWarnings("serial")
-  private java.util.List<org.openmbee.opensysml.proto.AppliedEdit> applied_;
-  /**
-   * <pre>
-   * What each operation changed, in request order.
-   * </pre>
-   *
-   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-   */
-  @java.lang.Override
-  public java.util.List<org.openmbee.opensysml.proto.AppliedEdit> getAppliedList() {
-    return applied_;
-  }
-  /**
-   * <pre>
-   * What each operation changed, in request order.
-   * </pre>
-   *
-   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-   */
-  @java.lang.Override
-  public java.util.List<? extends org.openmbee.opensysml.proto.AppliedEditOrBuilder> 
-      getAppliedOrBuilderList() {
-    return applied_;
-  }
-  /**
-   * <pre>
-   * What each operation changed, in request order.
-   * </pre>
-   *
-   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-   */
-  @java.lang.Override
-  public int getAppliedCount() {
-    return applied_.size();
-  }
-  /**
-   * <pre>
-   * What each operation changed, in request order.
-   * </pre>
-   *
-   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.AppliedEdit getApplied(int index) {
-    return applied_.get(index);
-  }
-  /**
-   * <pre>
-   * What each operation changed, in request order.
-   * </pre>
-   *
-   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.AppliedEditOrBuilder getAppliedOrBuilder(
-      int index) {
-    return applied_.get(index);
-  }
-
-  public static final int ERROR_FIELD_NUMBER = 3;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object error_ = "";
-  /**
-   * <pre>
-   * non-empty if the edits were refused; content is unset
-   * </pre>
-   *
-   * <code>string error = 3 [json_name = "error"];</code>
-   * @return The error.
-   */
-  @java.lang.Override
-  public java.lang.String getError() {
-    java.lang.Object ref = error_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      error_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * non-empty if the edits were refused; content is unset
-   * </pre>
-   *
-   * <code>string error = 3 [json_name = "error"];</code>
-   * @return The bytes for error.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getErrorBytes() {
-    java.lang.Object ref = error_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      error_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int FAILURE_FIELD_NUMBER = 4;
-  private int failure_ = 0;
-  /**
-   * <pre>
-   * Which kind of refusal `error` reports, so a client acts on the kind rather
-   * than on the message text.
-   * </pre>
-   *
-   * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
-   * @return The enum numeric value on the wire for failure.
-   */
-  @java.lang.Override public int getFailureValue() {
-    return failure_;
-  }
-  /**
-   * <pre>
-   * Which kind of refusal `error` reports, so a client acts on the kind rather
-   * than on the message text.
-   * </pre>
-   *
-   * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
-   * @return The failure.
-   */
-  @java.lang.Override public org.openmbee.opensysml.proto.EditFailure getFailure() {
-    org.openmbee.opensysml.proto.EditFailure result = org.openmbee.opensysml.proto.EditFailure.forNumber(failure_);
-    return result == null ? org.openmbee.opensysml.proto.EditFailure.UNRECOGNIZED : result;
-  }
-
-  public static final int DIAGNOSTICS_FIELD_NUMBER = 5;
-  @SuppressWarnings("serial")
-  private java.util.List<org.openmbee.opensysml.proto.Diagnostic> diagnostics_;
-  /**
-   * <pre>
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-   */
-  @java.lang.Override
-  public java.util.List<org.openmbee.opensysml.proto.Diagnostic> getDiagnosticsList() {
-    return diagnostics_;
-  }
-  /**
-   * <pre>
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-   */
-  @java.lang.Override
-  public java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
-      getDiagnosticsOrBuilderList() {
-    return diagnostics_;
-  }
-  /**
-   * <pre>
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-   */
-  @java.lang.Override
-  public int getDiagnosticsCount() {
-    return diagnostics_.size();
-  }
-  /**
-   * <pre>
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index) {
-    return diagnostics_.get(index);
-  }
-  /**
-   * <pre>
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
-      int index) {
-    return diagnostics_.get(index);
-  }
-
-  public static final int REFERRING_ELEMENTS_FIELD_NUMBER = 6;
-  @SuppressWarnings("serial")
-  private com.google.protobuf.LazyStringArrayList referringElements_ =
-      com.google.protobuf.LazyStringArrayList.emptyList();
-  /**
-   * <pre>
-   * Where the references to a declaration whose rename was refused are made:
-   * the FQN of each referring namespace.
-   * </pre>
-   *
-   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-   * @return A list containing the referringElements.
-   */
-  public com.google.protobuf.ProtocolStringList
-      getReferringElementsList() {
-    return referringElements_;
-  }
-  /**
-   * <pre>
-   * Where the references to a declaration whose rename was refused are made:
-   * the FQN of each referring namespace.
-   * </pre>
-   *
-   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-   * @return The count of referringElements.
-   */
-  public int getReferringElementsCount() {
-    return referringElements_.size();
-  }
-  /**
-   * <pre>
-   * Where the references to a declaration whose rename was refused are made:
-   * the FQN of each referring namespace.
-   * </pre>
-   *
-   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-   * @param index The index of the element to return.
-   * @return The referringElements at the given index.
-   */
-  public java.lang.String getReferringElements(int index) {
-    return referringElements_.get(index);
-  }
-  /**
-   * <pre>
-   * Where the references to a declaration whose rename was refused are made:
-   * the FQN of each referring namespace.
-   * </pre>
-   *
-   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-   * @param index The index of the value to return.
-   * @return The bytes of the referringElements at the given index.
-   */
-  public com.google.protobuf.ByteString
-      getReferringElementsBytes(int index) {
-    return referringElements_.getByteString(index);
-  }
-
-  private byte memoizedIsInitialized = -1;
-  @java.lang.Override
-  public final boolean isInitialized() {
-    byte isInitialized = memoizedIsInitialized;
-    if (isInitialized == 1) return true;
-    if (isInitialized == 0) return false;
-
-    memoizedIsInitialized = 1;
-    return true;
-  }
-
-  @java.lang.Override
-  public void writeTo(com.google.protobuf.CodedOutputStream output)
-                      throws java.io.IOException {
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(content_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 1, content_);
-    }
-    for (int i = 0; i < applied_.size(); i++) {
-      output.writeMessage(2, applied_.get(i));
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 3, error_);
-    }
-    if (failure_ != org.openmbee.opensysml.proto.EditFailure.EDIT_FAILURE_UNSPECIFIED.getNumber()) {
-      output.writeEnum(4, failure_);
-    }
-    for (int i = 0; i < diagnostics_.size(); i++) {
-      output.writeMessage(5, diagnostics_.get(i));
-    }
-    for (int i = 0; i < referringElements_.size(); i++) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 6, referringElements_.getRaw(i));
-    }
-    getUnknownFields().writeTo(output);
-  }
-
-  @java.lang.Override
-  public int getSerializedSize() {
-    int size = memoizedSize;
-    if (size != -1) return size;
-
-    size = 0;
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(content_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, content_);
-    }
-    for (int i = 0; i < applied_.size(); i++) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeMessageSize(2, applied_.get(i));
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(3, error_);
-    }
-    if (failure_ != org.openmbee.opensysml.proto.EditFailure.EDIT_FAILURE_UNSPECIFIED.getNumber()) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeEnumSize(4, failure_);
-    }
-    for (int i = 0; i < diagnostics_.size(); i++) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeMessageSize(5, diagnostics_.get(i));
-    }
-    {
-      int dataSize = 0;
-      for (int i = 0; i < referringElements_.size(); i++) {
-        dataSize += computeStringSizeNoTag(referringElements_.getRaw(i));
-      }
-      size += dataSize;
-      size += 1 * getReferringElementsList().size();
-    }
-    size += getUnknownFields().getSerializedSize();
-    memoizedSize = size;
-    return size;
-  }
-
-  @java.lang.Override
-  public boolean equals(final java.lang.Object obj) {
-    if (obj == this) {
-     return true;
-    }
-    if (!(obj instanceof org.openmbee.opensysml.proto.ApplyEditsResponse)) {
-      return super.equals(obj);
-    }
-    org.openmbee.opensysml.proto.ApplyEditsResponse other = (org.openmbee.opensysml.proto.ApplyEditsResponse) obj;
-
-    if (!getContent()
-        .equals(other.getContent())) return false;
-    if (!getAppliedList()
-        .equals(other.getAppliedList())) return false;
-    if (!getError()
-        .equals(other.getError())) return false;
-    if (failure_ != other.failure_) return false;
-    if (!getDiagnosticsList()
-        .equals(other.getDiagnosticsList())) return false;
-    if (!getReferringElementsList()
-        .equals(other.getReferringElementsList())) return false;
-    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
-    return true;
-  }
-
-  @java.lang.Override
-  public int hashCode() {
-    if (memoizedHashCode != 0) {
-      return memoizedHashCode;
-    }
-    int hash = 41;
-    hash = (19 * hash) + getDescriptor().hashCode();
-    hash = (37 * hash) + CONTENT_FIELD_NUMBER;
-    hash = (53 * hash) + getContent().hashCode();
-    if (getAppliedCount() > 0) {
-      hash = (37 * hash) + APPLIED_FIELD_NUMBER;
-      hash = (53 * hash) + getAppliedList().hashCode();
-    }
-    hash = (37 * hash) + ERROR_FIELD_NUMBER;
-    hash = (53 * hash) + getError().hashCode();
-    hash = (37 * hash) + FAILURE_FIELD_NUMBER;
-    hash = (53 * hash) + failure_;
-    if (getDiagnosticsCount() > 0) {
-      hash = (37 * hash) + DIAGNOSTICS_FIELD_NUMBER;
-      hash = (53 * hash) + getDiagnosticsList().hashCode();
-    }
-    if (getReferringElementsCount() > 0) {
-      hash = (37 * hash) + REFERRING_ELEMENTS_FIELD_NUMBER;
-      hash = (53 * hash) + getReferringElementsList().hashCode();
-    }
-    hash = (29 * hash) + getUnknownFields().hashCode();
-    memoizedHashCode = hash;
-    return hash;
-  }
-
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
-      java.nio.ByteBuffer data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
-      java.nio.ByteBuffer data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
-      com.google.protobuf.ByteString data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
-      com.google.protobuf.ByteString data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(byte[] data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
-      byte[] data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseDelimitedFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input);
-  }
-
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseDelimitedFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
-      com.google.protobuf.CodedInputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse parseFrom(
-      com.google.protobuf.CodedInputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  @java.lang.Override
-  public Builder newBuilderForType() { return newBuilder(); }
-  public static Builder newBuilder() {
-    return DEFAULT_INSTANCE.toBuilder();
-  }
-  public static Builder newBuilder(org.openmbee.opensysml.proto.ApplyEditsResponse prototype) {
-    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
-  }
-  @java.lang.Override
-  public Builder toBuilder() {
-    return this == DEFAULT_INSTANCE
-        ? new Builder() : new Builder().mergeFrom(this);
-  }
-
-  @java.lang.Override
-  protected Builder newBuilderForType(
-      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-    Builder builder = new Builder(parent);
-    return builder;
-  }
-  /**
-   * <pre>
-   * ApplyEditsResponse carries the edited source, or says why nothing was edited.
-   * </pre>
-   *
-   * Protobuf type {@code sysml.ApplyEditsResponse}
-   */
-  public static final class Builder extends
-      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
-      // @@protoc_insertion_point(builder_implements:sysml.ApplyEditsResponse)
-      org.openmbee.opensysml.proto.ApplyEditsResponseOrBuilder {
-    public static final com.google.protobuf.Descriptors.Descriptor
-        getDescriptor() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsResponse_descriptor;
-    }
-
-    @java.lang.Override
-    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-        internalGetFieldAccessorTable() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsResponse_fieldAccessorTable
-          .ensureFieldAccessorsInitialized(
-              org.openmbee.opensysml.proto.ApplyEditsResponse.class, org.openmbee.opensysml.proto.ApplyEditsResponse.Builder.class);
-    }
-
-    // Construct using org.openmbee.opensysml.proto.ApplyEditsResponse.newBuilder()
-    private Builder() {
-
-    }
-
-    private Builder(
-        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-      super(parent);
-
-    }
-    @java.lang.Override
-    public Builder clear() {
-      super.clear();
-      bitField0_ = 0;
-      content_ = "";
-      if (appliedBuilder_ == null) {
-        applied_ = java.util.Collections.emptyList();
-      } else {
-        applied_ = null;
-        appliedBuilder_.clear();
-      }
-      bitField0_ = (bitField0_ & ~0x00000002);
-      error_ = "";
-      failure_ = 0;
-      if (diagnosticsBuilder_ == null) {
-        diagnostics_ = java.util.Collections.emptyList();
-      } else {
-        diagnostics_ = null;
-        diagnosticsBuilder_.clear();
-      }
-      bitField0_ = (bitField0_ & ~0x00000010);
-      referringElements_ =
-          com.google.protobuf.LazyStringArrayList.emptyList();
-      return this;
-    }
-
-    @java.lang.Override
-    public com.google.protobuf.Descriptors.Descriptor
-        getDescriptorForType() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ApplyEditsResponse_descriptor;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.ApplyEditsResponse getDefaultInstanceForType() {
-      return org.openmbee.opensysml.proto.ApplyEditsResponse.getDefaultInstance();
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.ApplyEditsResponse build() {
-      org.openmbee.opensysml.proto.ApplyEditsResponse result = buildPartial();
-      if (!result.isInitialized()) {
-        throw newUninitializedMessageException(result);
-      }
-      return result;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.ApplyEditsResponse buildPartial() {
-      org.openmbee.opensysml.proto.ApplyEditsResponse result = new org.openmbee.opensysml.proto.ApplyEditsResponse(this);
-      buildPartialRepeatedFields(result);
-      if (bitField0_ != 0) { buildPartial0(result); }
-      onBuilt();
-      return result;
-    }
-
-    private void buildPartialRepeatedFields(org.openmbee.opensysml.proto.ApplyEditsResponse result) {
-      if (appliedBuilder_ == null) {
-        if (((bitField0_ & 0x00000002) != 0)) {
-          applied_ = java.util.Collections.unmodifiableList(applied_);
-          bitField0_ = (bitField0_ & ~0x00000002);
-        }
-        result.applied_ = applied_;
-      } else {
-        result.applied_ = appliedBuilder_.build();
-      }
-      if (diagnosticsBuilder_ == null) {
-        if (((bitField0_ & 0x00000010) != 0)) {
-          diagnostics_ = java.util.Collections.unmodifiableList(diagnostics_);
-          bitField0_ = (bitField0_ & ~0x00000010);
-        }
-        result.diagnostics_ = diagnostics_;
-      } else {
-        result.diagnostics_ = diagnosticsBuilder_.build();
-      }
-    }
-
-    private void buildPartial0(org.openmbee.opensysml.proto.ApplyEditsResponse result) {
-      int from_bitField0_ = bitField0_;
-      if (((from_bitField0_ & 0x00000001) != 0)) {
-        result.content_ = content_;
-      }
-      if (((from_bitField0_ & 0x00000004) != 0)) {
-        result.error_ = error_;
-      }
-      if (((from_bitField0_ & 0x00000008) != 0)) {
-        result.failure_ = failure_;
-      }
-      if (((from_bitField0_ & 0x00000020) != 0)) {
-        referringElements_.makeImmutable();
-        result.referringElements_ = referringElements_;
-      }
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(com.google.protobuf.Message other) {
-      if (other instanceof org.openmbee.opensysml.proto.ApplyEditsResponse) {
-        return mergeFrom((org.openmbee.opensysml.proto.ApplyEditsResponse)other);
-      } else {
-        super.mergeFrom(other);
-        return this;
-      }
-    }
-
-    public Builder mergeFrom(org.openmbee.opensysml.proto.ApplyEditsResponse other) {
-      if (other == org.openmbee.opensysml.proto.ApplyEditsResponse.getDefaultInstance()) return this;
-      if (!other.getContent().isEmpty()) {
-        content_ = other.content_;
-        bitField0_ |= 0x00000001;
-        onChanged();
-      }
-      if (appliedBuilder_ == null) {
-        if (!other.applied_.isEmpty()) {
-          if (applied_.isEmpty()) {
-            applied_ = other.applied_;
-            bitField0_ = (bitField0_ & ~0x00000002);
-          } else {
-            ensureAppliedIsMutable();
-            applied_.addAll(other.applied_);
-          }
-          onChanged();
-        }
-      } else {
-        if (!other.applied_.isEmpty()) {
-          if (appliedBuilder_.isEmpty()) {
-            appliedBuilder_.dispose();
-            appliedBuilder_ = null;
-            applied_ = other.applied_;
-            bitField0_ = (bitField0_ & ~0x00000002);
-            appliedBuilder_ = 
-              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
-                 internalGetAppliedFieldBuilder() : null;
-          } else {
-            appliedBuilder_.addAllMessages(other.applied_);
-          }
-        }
-      }
-      if (!other.getError().isEmpty()) {
-        error_ = other.error_;
-        bitField0_ |= 0x00000004;
-        onChanged();
-      }
-      if (other.failure_ != 0) {
-        setFailureValue(other.getFailureValue());
-      }
-      if (diagnosticsBuilder_ == null) {
-        if (!other.diagnostics_.isEmpty()) {
-          if (diagnostics_.isEmpty()) {
-            diagnostics_ = other.diagnostics_;
-            bitField0_ = (bitField0_ & ~0x00000010);
-          } else {
-            ensureDiagnosticsIsMutable();
-            diagnostics_.addAll(other.diagnostics_);
-          }
-          onChanged();
-        }
-      } else {
-        if (!other.diagnostics_.isEmpty()) {
-          if (diagnosticsBuilder_.isEmpty()) {
-            diagnosticsBuilder_.dispose();
-            diagnosticsBuilder_ = null;
-            diagnostics_ = other.diagnostics_;
-            bitField0_ = (bitField0_ & ~0x00000010);
-            diagnosticsBuilder_ = 
-              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
-                 internalGetDiagnosticsFieldBuilder() : null;
-          } else {
-            diagnosticsBuilder_.addAllMessages(other.diagnostics_);
-          }
-        }
-      }
-      if (!other.referringElements_.isEmpty()) {
-        if (referringElements_.isEmpty()) {
-          referringElements_ = other.referringElements_;
-          bitField0_ |= 0x00000020;
-        } else {
-          ensureReferringElementsIsMutable();
-          referringElements_.addAll(other.referringElements_);
-        }
-        onChanged();
-      }
-      this.mergeUnknownFields(other.getUnknownFields());
-      onChanged();
-      return this;
-    }
-
-    @java.lang.Override
-    public final boolean isInitialized() {
-      return true;
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws java.io.IOException {
-      if (extensionRegistry == null) {
-        throw new java.lang.NullPointerException();
-      }
-      try {
-        boolean done = false;
-        while (!done) {
-          int tag = input.readTag();
-          switch (tag) {
-            case 0:
-              done = true;
-              break;
-            case 10: {
-              content_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000001;
-              break;
-            } // case 10
-            case 18: {
-              org.openmbee.opensysml.proto.AppliedEdit m =
-                  input.readMessage(
-                      org.openmbee.opensysml.proto.AppliedEdit.parser(),
-                      extensionRegistry);
-              if (appliedBuilder_ == null) {
-                ensureAppliedIsMutable();
-                applied_.add(m);
-              } else {
-                appliedBuilder_.addMessage(m);
-              }
-              break;
-            } // case 18
-            case 26: {
-              error_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000004;
-              break;
-            } // case 26
-            case 32: {
-              failure_ = input.readEnum();
-              bitField0_ |= 0x00000008;
-              break;
-            } // case 32
-            case 42: {
-              org.openmbee.opensysml.proto.Diagnostic m =
-                  input.readMessage(
-                      org.openmbee.opensysml.proto.Diagnostic.parser(),
-                      extensionRegistry);
-              if (diagnosticsBuilder_ == null) {
-                ensureDiagnosticsIsMutable();
-                diagnostics_.add(m);
-              } else {
-                diagnosticsBuilder_.addMessage(m);
-              }
-              break;
-            } // case 42
-            case 50: {
-              java.lang.String s = input.readStringRequireUtf8();
-              ensureReferringElementsIsMutable();
-              referringElements_.add(s);
-              break;
-            } // case 50
-            default: {
-              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
-                done = true; // was an endgroup tag
-              }
-              break;
-            } // default:
-          } // switch (tag)
-        } // while (!done)
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.unwrapIOException();
-      } finally {
-        onChanged();
-      } // finally
-      return this;
-    }
-    private int bitField0_;
-
-    private java.lang.Object content_ = "";
-    /**
-     * <pre>
-     * The edited notation, byte-identical to the source outside the edited spans.
-     * Empty when the edits were refused, so a refusal never writes a file.
-     * </pre>
-     *
-     * <code>string content = 1 [json_name = "content"];</code>
-     * @return The content.
-     */
-    public java.lang.String getContent() {
-      java.lang.Object ref = content_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        content_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The edited notation, byte-identical to the source outside the edited spans.
-     * Empty when the edits were refused, so a refusal never writes a file.
-     * </pre>
-     *
-     * <code>string content = 1 [json_name = "content"];</code>
-     * @return The bytes for content.
-     */
-    public com.google.protobuf.ByteString
-        getContentBytes() {
-      java.lang.Object ref = content_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        content_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The edited notation, byte-identical to the source outside the edited spans.
-     * Empty when the edits were refused, so a refusal never writes a file.
-     * </pre>
-     *
-     * <code>string content = 1 [json_name = "content"];</code>
-     * @param value The content to set.
-     * @return This builder for chaining.
-     */
-    public Builder setContent(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      content_ = value;
-      bitField0_ |= 0x00000001;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The edited notation, byte-identical to the source outside the edited spans.
-     * Empty when the edits were refused, so a refusal never writes a file.
-     * </pre>
-     *
-     * <code>string content = 1 [json_name = "content"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearContent() {
-      content_ = getDefaultInstance().getContent();
-      bitField0_ = (bitField0_ & ~0x00000001);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The edited notation, byte-identical to the source outside the edited spans.
-     * Empty when the edits were refused, so a refusal never writes a file.
-     * </pre>
-     *
-     * <code>string content = 1 [json_name = "content"];</code>
-     * @param value The bytes for content to set.
-     * @return This builder for chaining.
-     */
-    public Builder setContentBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      content_ = value;
-      bitField0_ |= 0x00000001;
-      onChanged();
-      return this;
-    }
-
-    private java.util.List<org.openmbee.opensysml.proto.AppliedEdit> applied_ =
-      java.util.Collections.emptyList();
-    private void ensureAppliedIsMutable() {
-      if (!((bitField0_ & 0x00000002) != 0)) {
-        applied_ = new java.util.ArrayList<org.openmbee.opensysml.proto.AppliedEdit>(applied_);
-        bitField0_ |= 0x00000002;
-       }
-    }
-
-    private com.google.protobuf.RepeatedFieldBuilder<
-        org.openmbee.opensysml.proto.AppliedEdit, org.openmbee.opensysml.proto.AppliedEdit.Builder, org.openmbee.opensysml.proto.AppliedEditOrBuilder> appliedBuilder_;
-
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public java.util.List<org.openmbee.opensysml.proto.AppliedEdit> getAppliedList() {
-      if (appliedBuilder_ == null) {
-        return java.util.Collections.unmodifiableList(applied_);
-      } else {
-        return appliedBuilder_.getMessageList();
-      }
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public int getAppliedCount() {
-      if (appliedBuilder_ == null) {
-        return applied_.size();
-      } else {
-        return appliedBuilder_.getCount();
-      }
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public org.openmbee.opensysml.proto.AppliedEdit getApplied(int index) {
-      if (appliedBuilder_ == null) {
-        return applied_.get(index);
-      } else {
-        return appliedBuilder_.getMessage(index);
-      }
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public Builder setApplied(
-        int index, org.openmbee.opensysml.proto.AppliedEdit value) {
-      if (appliedBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureAppliedIsMutable();
-        applied_.set(index, value);
-        onChanged();
-      } else {
-        appliedBuilder_.setMessage(index, value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public Builder setApplied(
-        int index, org.openmbee.opensysml.proto.AppliedEdit.Builder builderForValue) {
-      if (appliedBuilder_ == null) {
-        ensureAppliedIsMutable();
-        applied_.set(index, builderForValue.build());
-        onChanged();
-      } else {
-        appliedBuilder_.setMessage(index, builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public Builder addApplied(org.openmbee.opensysml.proto.AppliedEdit value) {
-      if (appliedBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureAppliedIsMutable();
-        applied_.add(value);
-        onChanged();
-      } else {
-        appliedBuilder_.addMessage(value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public Builder addApplied(
-        int index, org.openmbee.opensysml.proto.AppliedEdit value) {
-      if (appliedBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureAppliedIsMutable();
-        applied_.add(index, value);
-        onChanged();
-      } else {
-        appliedBuilder_.addMessage(index, value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public Builder addApplied(
-        org.openmbee.opensysml.proto.AppliedEdit.Builder builderForValue) {
-      if (appliedBuilder_ == null) {
-        ensureAppliedIsMutable();
-        applied_.add(builderForValue.build());
-        onChanged();
-      } else {
-        appliedBuilder_.addMessage(builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public Builder addApplied(
-        int index, org.openmbee.opensysml.proto.AppliedEdit.Builder builderForValue) {
-      if (appliedBuilder_ == null) {
-        ensureAppliedIsMutable();
-        applied_.add(index, builderForValue.build());
-        onChanged();
-      } else {
-        appliedBuilder_.addMessage(index, builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public Builder addAllApplied(
-        java.lang.Iterable<? extends org.openmbee.opensysml.proto.AppliedEdit> values) {
-      if (appliedBuilder_ == null) {
-        ensureAppliedIsMutable();
-        com.google.protobuf.AbstractMessageLite.Builder.addAll(
-            values, applied_);
-        onChanged();
-      } else {
-        appliedBuilder_.addAllMessages(values);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public Builder clearApplied() {
-      if (appliedBuilder_ == null) {
-        applied_ = java.util.Collections.emptyList();
-        bitField0_ = (bitField0_ & ~0x00000002);
-        onChanged();
-      } else {
-        appliedBuilder_.clear();
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public Builder removeApplied(int index) {
-      if (appliedBuilder_ == null) {
-        ensureAppliedIsMutable();
-        applied_.remove(index);
-        onChanged();
-      } else {
-        appliedBuilder_.remove(index);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public org.openmbee.opensysml.proto.AppliedEdit.Builder getAppliedBuilder(
-        int index) {
-      return internalGetAppliedFieldBuilder().getBuilder(index);
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public org.openmbee.opensysml.proto.AppliedEditOrBuilder getAppliedOrBuilder(
-        int index) {
-      if (appliedBuilder_ == null) {
-        return applied_.get(index);  } else {
-        return appliedBuilder_.getMessageOrBuilder(index);
-      }
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public java.util.List<? extends org.openmbee.opensysml.proto.AppliedEditOrBuilder> 
-         getAppliedOrBuilderList() {
-      if (appliedBuilder_ != null) {
-        return appliedBuilder_.getMessageOrBuilderList();
-      } else {
-        return java.util.Collections.unmodifiableList(applied_);
-      }
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public org.openmbee.opensysml.proto.AppliedEdit.Builder addAppliedBuilder() {
-      return internalGetAppliedFieldBuilder().addBuilder(
-          org.openmbee.opensysml.proto.AppliedEdit.getDefaultInstance());
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public org.openmbee.opensysml.proto.AppliedEdit.Builder addAppliedBuilder(
-        int index) {
-      return internalGetAppliedFieldBuilder().addBuilder(
-          index, org.openmbee.opensysml.proto.AppliedEdit.getDefaultInstance());
-    }
-    /**
-     * <pre>
-     * What each operation changed, in request order.
-     * </pre>
-     *
-     * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-     */
-    public java.util.List<org.openmbee.opensysml.proto.AppliedEdit.Builder> 
-         getAppliedBuilderList() {
-      return internalGetAppliedFieldBuilder().getBuilderList();
-    }
-    private com.google.protobuf.RepeatedFieldBuilder<
-        org.openmbee.opensysml.proto.AppliedEdit, org.openmbee.opensysml.proto.AppliedEdit.Builder, org.openmbee.opensysml.proto.AppliedEditOrBuilder> 
-        internalGetAppliedFieldBuilder() {
-      if (appliedBuilder_ == null) {
-        appliedBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
-            org.openmbee.opensysml.proto.AppliedEdit, org.openmbee.opensysml.proto.AppliedEdit.Builder, org.openmbee.opensysml.proto.AppliedEditOrBuilder>(
-                applied_,
-                ((bitField0_ & 0x00000002) != 0),
-                getParentForChildren(),
-                isClean());
-        applied_ = null;
-      }
-      return appliedBuilder_;
-    }
-
-    private java.lang.Object error_ = "";
-    /**
-     * <pre>
-     * non-empty if the edits were refused; content is unset
-     * </pre>
-     *
-     * <code>string error = 3 [json_name = "error"];</code>
-     * @return The error.
-     */
-    public java.lang.String getError() {
-      java.lang.Object ref = error_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        error_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * non-empty if the edits were refused; content is unset
-     * </pre>
-     *
-     * <code>string error = 3 [json_name = "error"];</code>
-     * @return The bytes for error.
-     */
-    public com.google.protobuf.ByteString
-        getErrorBytes() {
-      java.lang.Object ref = error_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        error_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * non-empty if the edits were refused; content is unset
-     * </pre>
-     *
-     * <code>string error = 3 [json_name = "error"];</code>
-     * @param value The error to set.
-     * @return This builder for chaining.
-     */
-    public Builder setError(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      error_ = value;
-      bitField0_ |= 0x00000004;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * non-empty if the edits were refused; content is unset
-     * </pre>
-     *
-     * <code>string error = 3 [json_name = "error"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearError() {
-      error_ = getDefaultInstance().getError();
-      bitField0_ = (bitField0_ & ~0x00000004);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * non-empty if the edits were refused; content is unset
-     * </pre>
-     *
-     * <code>string error = 3 [json_name = "error"];</code>
-     * @param value The bytes for error to set.
-     * @return This builder for chaining.
-     */
-    public Builder setErrorBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      error_ = value;
-      bitField0_ |= 0x00000004;
-      onChanged();
-      return this;
-    }
-
-    private int failure_ = 0;
-    /**
-     * <pre>
-     * Which kind of refusal `error` reports, so a client acts on the kind rather
-     * than on the message text.
-     * </pre>
-     *
-     * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
-     * @return The enum numeric value on the wire for failure.
-     */
-    @java.lang.Override public int getFailureValue() {
-      return failure_;
-    }
-    /**
-     * <pre>
-     * Which kind of refusal `error` reports, so a client acts on the kind rather
-     * than on the message text.
-     * </pre>
-     *
-     * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
-     * @param value The enum numeric value on the wire for failure to set.
-     * @return This builder for chaining.
-     */
-    public Builder setFailureValue(int value) {
-      failure_ = value;
-      bitField0_ |= 0x00000008;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Which kind of refusal `error` reports, so a client acts on the kind rather
-     * than on the message text.
-     * </pre>
-     *
-     * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
-     * @return The failure.
-     */
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.EditFailure getFailure() {
-      org.openmbee.opensysml.proto.EditFailure result = org.openmbee.opensysml.proto.EditFailure.forNumber(failure_);
-      return result == null ? org.openmbee.opensysml.proto.EditFailure.UNRECOGNIZED : result;
-    }
-    /**
-     * <pre>
-     * Which kind of refusal `error` reports, so a client acts on the kind rather
-     * than on the message text.
-     * </pre>
-     *
-     * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
-     * @param value The failure to set.
-     * @return This builder for chaining.
-     */
-    public Builder setFailure(org.openmbee.opensysml.proto.EditFailure value) {
-      if (value == null) { throw new NullPointerException(); }
-      bitField0_ |= 0x00000008;
-      failure_ = value.getNumber();
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Which kind of refusal `error` reports, so a client acts on the kind rather
-     * than on the message text.
-     * </pre>
-     *
-     * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearFailure() {
-      bitField0_ = (bitField0_ & ~0x00000008);
-      failure_ = 0;
-      onChanged();
-      return this;
-    }
-
-    private java.util.List<org.openmbee.opensysml.proto.Diagnostic> diagnostics_ =
-      java.util.Collections.emptyList();
-    private void ensureDiagnosticsIsMutable() {
-      if (!((bitField0_ & 0x00000010) != 0)) {
-        diagnostics_ = new java.util.ArrayList<org.openmbee.opensysml.proto.Diagnostic>(diagnostics_);
-        bitField0_ |= 0x00000010;
-       }
-    }
-
-    private com.google.protobuf.RepeatedFieldBuilder<
-        org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> diagnosticsBuilder_;
-
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public java.util.List<org.openmbee.opensysml.proto.Diagnostic> getDiagnosticsList() {
-      if (diagnosticsBuilder_ == null) {
-        return java.util.Collections.unmodifiableList(diagnostics_);
-      } else {
-        return diagnosticsBuilder_.getMessageList();
-      }
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public int getDiagnosticsCount() {
-      if (diagnosticsBuilder_ == null) {
-        return diagnostics_.size();
-      } else {
-        return diagnosticsBuilder_.getCount();
-      }
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index) {
-      if (diagnosticsBuilder_ == null) {
-        return diagnostics_.get(index);
-      } else {
-        return diagnosticsBuilder_.getMessage(index);
-      }
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public Builder setDiagnostics(
-        int index, org.openmbee.opensysml.proto.Diagnostic value) {
-      if (diagnosticsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureDiagnosticsIsMutable();
-        diagnostics_.set(index, value);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.setMessage(index, value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public Builder setDiagnostics(
-        int index, org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
-      if (diagnosticsBuilder_ == null) {
-        ensureDiagnosticsIsMutable();
-        diagnostics_.set(index, builderForValue.build());
-        onChanged();
-      } else {
-        diagnosticsBuilder_.setMessage(index, builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public Builder addDiagnostics(org.openmbee.opensysml.proto.Diagnostic value) {
-      if (diagnosticsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureDiagnosticsIsMutable();
-        diagnostics_.add(value);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.addMessage(value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public Builder addDiagnostics(
-        int index, org.openmbee.opensysml.proto.Diagnostic value) {
-      if (diagnosticsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureDiagnosticsIsMutable();
-        diagnostics_.add(index, value);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.addMessage(index, value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public Builder addDiagnostics(
-        org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
-      if (diagnosticsBuilder_ == null) {
-        ensureDiagnosticsIsMutable();
-        diagnostics_.add(builderForValue.build());
-        onChanged();
-      } else {
-        diagnosticsBuilder_.addMessage(builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public Builder addDiagnostics(
-        int index, org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
-      if (diagnosticsBuilder_ == null) {
-        ensureDiagnosticsIsMutable();
-        diagnostics_.add(index, builderForValue.build());
-        onChanged();
-      } else {
-        diagnosticsBuilder_.addMessage(index, builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public Builder addAllDiagnostics(
-        java.lang.Iterable<? extends org.openmbee.opensysml.proto.Diagnostic> values) {
-      if (diagnosticsBuilder_ == null) {
-        ensureDiagnosticsIsMutable();
-        com.google.protobuf.AbstractMessageLite.Builder.addAll(
-            values, diagnostics_);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.addAllMessages(values);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public Builder clearDiagnostics() {
-      if (diagnosticsBuilder_ == null) {
-        diagnostics_ = java.util.Collections.emptyList();
-        bitField0_ = (bitField0_ & ~0x00000010);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.clear();
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public Builder removeDiagnostics(int index) {
-      if (diagnosticsBuilder_ == null) {
-        ensureDiagnosticsIsMutable();
-        diagnostics_.remove(index);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.remove(index);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public org.openmbee.opensysml.proto.Diagnostic.Builder getDiagnosticsBuilder(
-        int index) {
-      return internalGetDiagnosticsFieldBuilder().getBuilder(index);
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
-        int index) {
-      if (diagnosticsBuilder_ == null) {
-        return diagnostics_.get(index);  } else {
-        return diagnosticsBuilder_.getMessageOrBuilder(index);
-      }
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
-         getDiagnosticsOrBuilderList() {
-      if (diagnosticsBuilder_ != null) {
-        return diagnosticsBuilder_.getMessageOrBuilderList();
-      } else {
-        return java.util.Collections.unmodifiableList(diagnostics_);
-      }
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder() {
-      return internalGetDiagnosticsFieldBuilder().addBuilder(
-          org.openmbee.opensysml.proto.Diagnostic.getDefaultInstance());
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder(
-        int index) {
-      return internalGetDiagnosticsFieldBuilder().addBuilder(
-          index, org.openmbee.opensysml.proto.Diagnostic.getDefaultInstance());
-    }
-    /**
-     * <pre>
-     * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-     * or the errors the edited source was found to have.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-     */
-    public java.util.List<org.openmbee.opensysml.proto.Diagnostic.Builder> 
-         getDiagnosticsBuilderList() {
-      return internalGetDiagnosticsFieldBuilder().getBuilderList();
-    }
-    private com.google.protobuf.RepeatedFieldBuilder<
-        org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
-        internalGetDiagnosticsFieldBuilder() {
-      if (diagnosticsBuilder_ == null) {
-        diagnosticsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
-            org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder>(
-                diagnostics_,
-                ((bitField0_ & 0x00000010) != 0),
-                getParentForChildren(),
-                isClean());
-        diagnostics_ = null;
-      }
-      return diagnosticsBuilder_;
-    }
-
-    private com.google.protobuf.LazyStringArrayList referringElements_ =
-        com.google.protobuf.LazyStringArrayList.emptyList();
-    private void ensureReferringElementsIsMutable() {
-      if (!referringElements_.isModifiable()) {
-        referringElements_ = new com.google.protobuf.LazyStringArrayList(referringElements_);
-      }
-      bitField0_ |= 0x00000020;
-    }
-    /**
-     * <pre>
-     * Where the references to a declaration whose rename was refused are made:
-     * the FQN of each referring namespace.
-     * </pre>
-     *
-     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-     * @return A list containing the referringElements.
-     */
-    public com.google.protobuf.ProtocolStringList
-        getReferringElementsList() {
-      referringElements_.makeImmutable();
-      return referringElements_;
-    }
-    /**
-     * <pre>
-     * Where the references to a declaration whose rename was refused are made:
-     * the FQN of each referring namespace.
-     * </pre>
-     *
-     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-     * @return The count of referringElements.
-     */
-    public int getReferringElementsCount() {
-      return referringElements_.size();
-    }
-    /**
-     * <pre>
-     * Where the references to a declaration whose rename was refused are made:
-     * the FQN of each referring namespace.
-     * </pre>
-     *
-     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-     * @param index The index of the element to return.
-     * @return The referringElements at the given index.
-     */
-    public java.lang.String getReferringElements(int index) {
-      return referringElements_.get(index);
-    }
-    /**
-     * <pre>
-     * Where the references to a declaration whose rename was refused are made:
-     * the FQN of each referring namespace.
-     * </pre>
-     *
-     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-     * @param index The index of the value to return.
-     * @return The bytes of the referringElements at the given index.
-     */
-    public com.google.protobuf.ByteString
-        getReferringElementsBytes(int index) {
-      return referringElements_.getByteString(index);
-    }
-    /**
-     * <pre>
-     * Where the references to a declaration whose rename was refused are made:
-     * the FQN of each referring namespace.
-     * </pre>
-     *
-     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-     * @param index The index to set the value at.
-     * @param value The referringElements to set.
-     * @return This builder for chaining.
-     */
-    public Builder setReferringElements(
-        int index, java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      ensureReferringElementsIsMutable();
-      referringElements_.set(index, value);
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Where the references to a declaration whose rename was refused are made:
-     * the FQN of each referring namespace.
-     * </pre>
-     *
-     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-     * @param value The referringElements to add.
-     * @return This builder for chaining.
-     */
-    public Builder addReferringElements(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      ensureReferringElementsIsMutable();
-      referringElements_.add(value);
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Where the references to a declaration whose rename was refused are made:
-     * the FQN of each referring namespace.
-     * </pre>
-     *
-     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-     * @param values The referringElements to add.
-     * @return This builder for chaining.
-     */
-    public Builder addAllReferringElements(
-        java.lang.Iterable<java.lang.String> values) {
-      ensureReferringElementsIsMutable();
-      com.google.protobuf.AbstractMessageLite.Builder.addAll(
-          values, referringElements_);
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Where the references to a declaration whose rename was refused are made:
-     * the FQN of each referring namespace.
-     * </pre>
-     *
-     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearReferringElements() {
-      referringElements_ =
-        com.google.protobuf.LazyStringArrayList.emptyList();
-      bitField0_ = (bitField0_ & ~0x00000020);;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Where the references to a declaration whose rename was refused are made:
-     * the FQN of each referring namespace.
-     * </pre>
-     *
-     * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-     * @param value The bytes of the referringElements to add.
-     * @return This builder for chaining.
-     */
-    public Builder addReferringElementsBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      ensureReferringElementsIsMutable();
-      referringElements_.add(value);
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-
-    // @@protoc_insertion_point(builder_scope:sysml.ApplyEditsResponse)
-  }
-
-  // @@protoc_insertion_point(class_scope:sysml.ApplyEditsResponse)
-  private static final org.openmbee.opensysml.proto.ApplyEditsResponse DEFAULT_INSTANCE;
-  static {
-    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.ApplyEditsResponse();
-  }
-
-  public static org.openmbee.opensysml.proto.ApplyEditsResponse getDefaultInstance() {
-    return DEFAULT_INSTANCE;
-  }
-
-  private static final com.google.protobuf.Parser<ApplyEditsResponse>
-      PARSER = new com.google.protobuf.AbstractParser<ApplyEditsResponse>() {
-    @java.lang.Override
-    public ApplyEditsResponse parsePartialFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws com.google.protobuf.InvalidProtocolBufferException {
-      Builder builder = newBuilder();
-      try {
-        builder.mergeFrom(input, extensionRegistry);
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.setUnfinishedMessage(builder.buildPartial());
-      } catch (com.google.protobuf.UninitializedMessageException e) {
-        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
-      } catch (java.io.IOException e) {
-        throw new com.google.protobuf.InvalidProtocolBufferException(e)
-            .setUnfinishedMessage(builder.buildPartial());
-      }
-      return builder.buildPartial();
-    }
-  };
-
-  public static com.google.protobuf.Parser<ApplyEditsResponse> parser() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public com.google.protobuf.Parser<ApplyEditsResponse> getParserForType() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.ApplyEditsResponse getDefaultInstanceForType() {
-    return DEFAULT_INSTANCE;
-  }
-
-}
-
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponseOrBuilder.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponseOrBuilder.java
deleted file mode 100644
index cfe6b2fd07..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ApplyEditsResponseOrBuilder.java
+++ /dev/null
@@ -1,213 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-@com.google.protobuf.Generated
-public interface ApplyEditsResponseOrBuilder extends
-    // @@protoc_insertion_point(interface_extends:sysml.ApplyEditsResponse)
-    com.google.protobuf.MessageOrBuilder {
-
-  /**
-   * <pre>
-   * The edited notation, byte-identical to the source outside the edited spans.
-   * Empty when the edits were refused, so a refusal never writes a file.
-   * </pre>
-   *
-   * <code>string content = 1 [json_name = "content"];</code>
-   * @return The content.
-   */
-  java.lang.String getContent();
-  /**
-   * <pre>
-   * The edited notation, byte-identical to the source outside the edited spans.
-   * Empty when the edits were refused, so a refusal never writes a file.
-   * </pre>
-   *
-   * <code>string content = 1 [json_name = "content"];</code>
-   * @return The bytes for content.
-   */
-  com.google.protobuf.ByteString
-      getContentBytes();
-
-  /**
-   * <pre>
-   * What each operation changed, in request order.
-   * </pre>
-   *
-   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-   */
-  java.util.List<org.openmbee.opensysml.proto.AppliedEdit> 
-      getAppliedList();
-  /**
-   * <pre>
-   * What each operation changed, in request order.
-   * </pre>
-   *
-   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-   */
-  org.openmbee.opensysml.proto.AppliedEdit getApplied(int index);
-  /**
-   * <pre>
-   * What each operation changed, in request order.
-   * </pre>
-   *
-   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-   */
-  int getAppliedCount();
-  /**
-   * <pre>
-   * What each operation changed, in request order.
-   * </pre>
-   *
-   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-   */
-  java.util.List<? extends org.openmbee.opensysml.proto.AppliedEditOrBuilder> 
-      getAppliedOrBuilderList();
-  /**
-   * <pre>
-   * What each operation changed, in request order.
-   * </pre>
-   *
-   * <code>repeated .sysml.AppliedEdit applied = 2 [json_name = "applied"];</code>
-   */
-  org.openmbee.opensysml.proto.AppliedEditOrBuilder getAppliedOrBuilder(
-      int index);
-
-  /**
-   * <pre>
-   * non-empty if the edits were refused; content is unset
-   * </pre>
-   *
-   * <code>string error = 3 [json_name = "error"];</code>
-   * @return The error.
-   */
-  java.lang.String getError();
-  /**
-   * <pre>
-   * non-empty if the edits were refused; content is unset
-   * </pre>
-   *
-   * <code>string error = 3 [json_name = "error"];</code>
-   * @return The bytes for error.
-   */
-  com.google.protobuf.ByteString
-      getErrorBytes();
-
-  /**
-   * <pre>
-   * Which kind of refusal `error` reports, so a client acts on the kind rather
-   * than on the message text.
-   * </pre>
-   *
-   * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
-   * @return The enum numeric value on the wire for failure.
-   */
-  int getFailureValue();
-  /**
-   * <pre>
-   * Which kind of refusal `error` reports, so a client acts on the kind rather
-   * than on the message text.
-   * </pre>
-   *
-   * <code>.sysml.EditFailure failure = 4 [json_name = "failure"];</code>
-   * @return The failure.
-   */
-  org.openmbee.opensysml.proto.EditFailure getFailure();
-
-  /**
-   * <pre>
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-   */
-  java.util.List<org.openmbee.opensysml.proto.Diagnostic> 
-      getDiagnosticsList();
-  /**
-   * <pre>
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-   */
-  org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index);
-  /**
-   * <pre>
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-   */
-  int getDiagnosticsCount();
-  /**
-   * <pre>
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-   */
-  java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
-      getDiagnosticsOrBuilderList();
-  /**
-   * <pre>
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 5 [json_name = "diagnostics"];</code>
-   */
-  org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
-      int index);
-
-  /**
-   * <pre>
-   * Where the references to a declaration whose rename was refused are made:
-   * the FQN of each referring namespace.
-   * </pre>
-   *
-   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-   * @return A list containing the referringElements.
-   */
-  java.util.List<java.lang.String>
-      getReferringElementsList();
-  /**
-   * <pre>
-   * Where the references to a declaration whose rename was refused are made:
-   * the FQN of each referring namespace.
-   * </pre>
-   *
-   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-   * @return The count of referringElements.
-   */
-  int getReferringElementsCount();
-  /**
-   * <pre>
-   * Where the references to a declaration whose rename was refused are made:
-   * the FQN of each referring namespace.
-   * </pre>
-   *
-   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-   * @param index The index of the element to return.
-   * @return The referringElements at the given index.
-   */
-  java.lang.String getReferringElements(int index);
-  /**
-   * <pre>
-   * Where the references to a declaration whose rename was refused are made:
-   * the FQN of each referring namespace.
-   * </pre>
-   *
-   * <code>repeated string referring_elements = 6 [json_name = "referringElements"];</code>
-   * @param index The index of the value to return.
-   * @return The bytes of the referringElements at the given index.
-   */
-  com.google.protobuf.ByteString
-      getReferringElementsBytes(int index);
-}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValue.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValue.java
deleted file mode 100644
index a4671e70b1..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValue.java
+++ /dev/null
@@ -1,1626 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-/**
- * <pre>
- * DocumentValue is one typed document-query value. A request binds a model
- * element by qualified name in element_id; a response also says what the
- * element is in element_type. `infinity` denotes an unbounded multiplicity and
- * is only ever answered, never bound.
- * </pre>
- *
- * Protobuf type {@code sysml.DocumentValue}
- */
-@com.google.protobuf.Generated
-public final class DocumentValue extends
-    com.google.protobuf.GeneratedMessage implements
-    // @@protoc_insertion_point(message_implements:sysml.DocumentValue)
-    DocumentValueOrBuilder {
-private static final long serialVersionUID = 0L;
-  static {
-    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
-      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
-      /* major= */ 4,
-      /* minor= */ 33,
-      /* patch= */ 1,
-      /* suffix= */ "",
-      "DocumentValue");
-  }
-  // Use DocumentValue.newBuilder() to construct.
-  private DocumentValue(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
-    super(builder);
-  }
-  private DocumentValue() {
-    elementType_ = "";
-  }
-
-  public static final com.google.protobuf.Descriptors.Descriptor
-      getDescriptor() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentValue_descriptor;
-  }
-
-  @java.lang.Override
-  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internalGetFieldAccessorTable() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentValue_fieldAccessorTable
-        .ensureFieldAccessorsInitialized(
-            org.openmbee.opensysml.proto.DocumentValue.class, org.openmbee.opensysml.proto.DocumentValue.Builder.class);
-  }
-
-  private int kindCase_ = 0;
-  @SuppressWarnings("serial")
-  private java.lang.Object kind_;
-  public enum KindCase
-      implements com.google.protobuf.Internal.EnumLite,
-          com.google.protobuf.AbstractMessage.InternalOneOfEnum {
-    ELEMENT_ID(1),
-    STRING_VALUE(2),
-    INT_VALUE(3),
-    REAL_VALUE(4),
-    BOOL_VALUE(5),
-    INFINITY(6),
-    QUANTITY(8),
-    KIND_NOT_SET(0);
-    private final int value;
-    private KindCase(int value) {
-      this.value = value;
-    }
-    /**
-     * @param value The number of the enum to look for.
-     * @return The enum associated with the given number.
-     * @deprecated Use {@link #forNumber(int)} instead.
-     */
-    @java.lang.Deprecated
-    public static KindCase valueOf(int value) {
-      return forNumber(value);
-    }
-
-    public static KindCase forNumber(int value) {
-      switch (value) {
-        case 1: return ELEMENT_ID;
-        case 2: return STRING_VALUE;
-        case 3: return INT_VALUE;
-        case 4: return REAL_VALUE;
-        case 5: return BOOL_VALUE;
-        case 6: return INFINITY;
-        case 8: return QUANTITY;
-        case 0: return KIND_NOT_SET;
-        default: return null;
-      }
-    }
-    public int getNumber() {
-      return this.value;
-    }
-  };
-
-  public KindCase
-  getKindCase() {
-    return KindCase.forNumber(
-        kindCase_);
-  }
-
-  public static final int ELEMENT_ID_FIELD_NUMBER = 1;
-  /**
-   * <pre>
-   * qualified name of a model element
-   * </pre>
-   *
-   * <code>string element_id = 1 [json_name = "elementId"];</code>
-   * @return Whether the elementId field is set.
-   */
-  public boolean hasElementId() {
-    return kindCase_ == 1;
-  }
-  /**
-   * <pre>
-   * qualified name of a model element
-   * </pre>
-   *
-   * <code>string element_id = 1 [json_name = "elementId"];</code>
-   * @return The elementId.
-   */
-  public java.lang.String getElementId() {
-    java.lang.Object ref = "";
-    if (kindCase_ == 1) {
-      ref = kind_;
-    }
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      if (kindCase_ == 1) {
-        kind_ = s;
-      }
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * qualified name of a model element
-   * </pre>
-   *
-   * <code>string element_id = 1 [json_name = "elementId"];</code>
-   * @return The bytes for elementId.
-   */
-  public com.google.protobuf.ByteString
-      getElementIdBytes() {
-    java.lang.Object ref = "";
-    if (kindCase_ == 1) {
-      ref = kind_;
-    }
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      if (kindCase_ == 1) {
-        kind_ = b;
-      }
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int STRING_VALUE_FIELD_NUMBER = 2;
-  /**
-   * <code>string string_value = 2 [json_name = "stringValue"];</code>
-   * @return Whether the stringValue field is set.
-   */
-  public boolean hasStringValue() {
-    return kindCase_ == 2;
-  }
-  /**
-   * <code>string string_value = 2 [json_name = "stringValue"];</code>
-   * @return The stringValue.
-   */
-  public java.lang.String getStringValue() {
-    java.lang.Object ref = "";
-    if (kindCase_ == 2) {
-      ref = kind_;
-    }
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      if (kindCase_ == 2) {
-        kind_ = s;
-      }
-      return s;
-    }
-  }
-  /**
-   * <code>string string_value = 2 [json_name = "stringValue"];</code>
-   * @return The bytes for stringValue.
-   */
-  public com.google.protobuf.ByteString
-      getStringValueBytes() {
-    java.lang.Object ref = "";
-    if (kindCase_ == 2) {
-      ref = kind_;
-    }
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      if (kindCase_ == 2) {
-        kind_ = b;
-      }
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int INT_VALUE_FIELD_NUMBER = 3;
-  /**
-   * <code>int64 int_value = 3 [json_name = "intValue"];</code>
-   * @return Whether the intValue field is set.
-   */
-  @java.lang.Override
-  public boolean hasIntValue() {
-    return kindCase_ == 3;
-  }
-  /**
-   * <code>int64 int_value = 3 [json_name = "intValue"];</code>
-   * @return The intValue.
-   */
-  @java.lang.Override
-  public long getIntValue() {
-    if (kindCase_ == 3) {
-      return (java.lang.Long) kind_;
-    }
-    return 0L;
-  }
-
-  public static final int REAL_VALUE_FIELD_NUMBER = 4;
-  /**
-   * <code>double real_value = 4 [json_name = "realValue"];</code>
-   * @return Whether the realValue field is set.
-   */
-  @java.lang.Override
-  public boolean hasRealValue() {
-    return kindCase_ == 4;
-  }
-  /**
-   * <code>double real_value = 4 [json_name = "realValue"];</code>
-   * @return The realValue.
-   */
-  @java.lang.Override
-  public double getRealValue() {
-    if (kindCase_ == 4) {
-      return (java.lang.Double) kind_;
-    }
-    return 0D;
-  }
-
-  public static final int BOOL_VALUE_FIELD_NUMBER = 5;
-  /**
-   * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
-   * @return Whether the boolValue field is set.
-   */
-  @java.lang.Override
-  public boolean hasBoolValue() {
-    return kindCase_ == 5;
-  }
-  /**
-   * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
-   * @return The boolValue.
-   */
-  @java.lang.Override
-  public boolean getBoolValue() {
-    if (kindCase_ == 5) {
-      return (java.lang.Boolean) kind_;
-    }
-    return false;
-  }
-
-  public static final int INFINITY_FIELD_NUMBER = 6;
-  /**
-   * <code>bool infinity = 6 [json_name = "infinity"];</code>
-   * @return Whether the infinity field is set.
-   */
-  @java.lang.Override
-  public boolean hasInfinity() {
-    return kindCase_ == 6;
-  }
-  /**
-   * <code>bool infinity = 6 [json_name = "infinity"];</code>
-   * @return The infinity.
-   */
-  @java.lang.Override
-  public boolean getInfinity() {
-    if (kindCase_ == 6) {
-      return (java.lang.Boolean) kind_;
-    }
-    return false;
-  }
-
-  public static final int QUANTITY_FIELD_NUMBER = 8;
-  /**
-   * <pre>
-   * magnitude in a unit, `2290000 [kg]`
-   * </pre>
-   *
-   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-   * @return Whether the quantity field is set.
-   */
-  @java.lang.Override
-  public boolean hasQuantity() {
-    return kindCase_ == 8;
-  }
-  /**
-   * <pre>
-   * magnitude in a unit, `2290000 [kg]`
-   * </pre>
-   *
-   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-   * @return The quantity.
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.Quantity getQuantity() {
-    if (kindCase_ == 8) {
-       return (org.openmbee.opensysml.proto.Quantity) kind_;
-    }
-    return org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
-  }
-  /**
-   * <pre>
-   * magnitude in a unit, `2290000 [kg]`
-   * </pre>
-   *
-   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.QuantityOrBuilder getQuantityOrBuilder() {
-    if (kindCase_ == 8) {
-       return (org.openmbee.opensysml.proto.Quantity) kind_;
-    }
-    return org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
-  }
-
-  public static final int ELEMENT_TYPE_FIELD_NUMBER = 7;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object elementType_ = "";
-  /**
-   * <pre>
-   * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
-   * </pre>
-   *
-   * <code>string element_type = 7 [json_name = "elementType"];</code>
-   * @return The elementType.
-   */
-  @java.lang.Override
-  public java.lang.String getElementType() {
-    java.lang.Object ref = elementType_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      elementType_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
-   * </pre>
-   *
-   * <code>string element_type = 7 [json_name = "elementType"];</code>
-   * @return The bytes for elementType.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getElementTypeBytes() {
-    java.lang.Object ref = elementType_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      elementType_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  private byte memoizedIsInitialized = -1;
-  @java.lang.Override
-  public final boolean isInitialized() {
-    byte isInitialized = memoizedIsInitialized;
-    if (isInitialized == 1) return true;
-    if (isInitialized == 0) return false;
-
-    memoizedIsInitialized = 1;
-    return true;
-  }
-
-  @java.lang.Override
-  public void writeTo(com.google.protobuf.CodedOutputStream output)
-                      throws java.io.IOException {
-    if (kindCase_ == 1) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 1, kind_);
-    }
-    if (kindCase_ == 2) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 2, kind_);
-    }
-    if (kindCase_ == 3) {
-      output.writeInt64(
-          3, (long)((java.lang.Long) kind_));
-    }
-    if (kindCase_ == 4) {
-      output.writeDouble(
-          4, (double)((java.lang.Double) kind_));
-    }
-    if (kindCase_ == 5) {
-      output.writeBool(
-          5, (boolean)((java.lang.Boolean) kind_));
-    }
-    if (kindCase_ == 6) {
-      output.writeBool(
-          6, (boolean)((java.lang.Boolean) kind_));
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(elementType_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 7, elementType_);
-    }
-    if (kindCase_ == 8) {
-      output.writeMessage(8, (org.openmbee.opensysml.proto.Quantity) kind_);
-    }
-    getUnknownFields().writeTo(output);
-  }
-
-  @java.lang.Override
-  public int getSerializedSize() {
-    int size = memoizedSize;
-    if (size != -1) return size;
-
-    size = 0;
-    if (kindCase_ == 1) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, kind_);
-    }
-    if (kindCase_ == 2) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, kind_);
-    }
-    if (kindCase_ == 3) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeInt64Size(
-            3, (long)((java.lang.Long) kind_));
-    }
-    if (kindCase_ == 4) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeDoubleSize(
-            4, (double)((java.lang.Double) kind_));
-    }
-    if (kindCase_ == 5) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeBoolSize(
-            5, (boolean)((java.lang.Boolean) kind_));
-    }
-    if (kindCase_ == 6) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeBoolSize(
-            6, (boolean)((java.lang.Boolean) kind_));
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(elementType_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(7, elementType_);
-    }
-    if (kindCase_ == 8) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeMessageSize(8, (org.openmbee.opensysml.proto.Quantity) kind_);
-    }
-    size += getUnknownFields().getSerializedSize();
-    memoizedSize = size;
-    return size;
-  }
-
-  @java.lang.Override
-  public boolean equals(final java.lang.Object obj) {
-    if (obj == this) {
-     return true;
-    }
-    if (!(obj instanceof org.openmbee.opensysml.proto.DocumentValue)) {
-      return super.equals(obj);
-    }
-    org.openmbee.opensysml.proto.DocumentValue other = (org.openmbee.opensysml.proto.DocumentValue) obj;
-
-    if (!getElementType()
-        .equals(other.getElementType())) return false;
-    if (!getKindCase().equals(other.getKindCase())) return false;
-    switch (kindCase_) {
-      case 1:
-        if (!getElementId()
-            .equals(other.getElementId())) return false;
-        break;
-      case 2:
-        if (!getStringValue()
-            .equals(other.getStringValue())) return false;
-        break;
-      case 3:
-        if (getIntValue()
-            != other.getIntValue()) return false;
-        break;
-      case 4:
-        if (java.lang.Double.doubleToLongBits(getRealValue())
-            != java.lang.Double.doubleToLongBits(
-                other.getRealValue())) return false;
-        break;
-      case 5:
-        if (getBoolValue()
-            != other.getBoolValue()) return false;
-        break;
-      case 6:
-        if (getInfinity()
-            != other.getInfinity()) return false;
-        break;
-      case 8:
-        if (!getQuantity()
-            .equals(other.getQuantity())) return false;
-        break;
-      case 0:
-      default:
-    }
-    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
-    return true;
-  }
-
-  @java.lang.Override
-  public int hashCode() {
-    if (memoizedHashCode != 0) {
-      return memoizedHashCode;
-    }
-    int hash = 41;
-    hash = (19 * hash) + getDescriptor().hashCode();
-    hash = (37 * hash) + ELEMENT_TYPE_FIELD_NUMBER;
-    hash = (53 * hash) + getElementType().hashCode();
-    switch (kindCase_) {
-      case 1:
-        hash = (37 * hash) + ELEMENT_ID_FIELD_NUMBER;
-        hash = (53 * hash) + getElementId().hashCode();
-        break;
-      case 2:
-        hash = (37 * hash) + STRING_VALUE_FIELD_NUMBER;
-        hash = (53 * hash) + getStringValue().hashCode();
-        break;
-      case 3:
-        hash = (37 * hash) + INT_VALUE_FIELD_NUMBER;
-        hash = (53 * hash) + com.google.protobuf.Internal.hashLong(
-            getIntValue());
-        break;
-      case 4:
-        hash = (37 * hash) + REAL_VALUE_FIELD_NUMBER;
-        hash = (53 * hash) + com.google.protobuf.Internal.hashLong(
-            java.lang.Double.doubleToLongBits(getRealValue()));
-        break;
-      case 5:
-        hash = (37 * hash) + BOOL_VALUE_FIELD_NUMBER;
-        hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean(
-            getBoolValue());
-        break;
-      case 6:
-        hash = (37 * hash) + INFINITY_FIELD_NUMBER;
-        hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean(
-            getInfinity());
-        break;
-      case 8:
-        hash = (37 * hash) + QUANTITY_FIELD_NUMBER;
-        hash = (53 * hash) + getQuantity().hashCode();
-        break;
-      case 0:
-      default:
-    }
-    hash = (29 * hash) + getUnknownFields().hashCode();
-    memoizedHashCode = hash;
-    return hash;
-  }
-
-  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
-      java.nio.ByteBuffer data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
-      java.nio.ByteBuffer data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
-      com.google.protobuf.ByteString data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
-      com.google.protobuf.ByteString data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(byte[] data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
-      byte[] data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  public static org.openmbee.opensysml.proto.DocumentValue parseDelimitedFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input);
-  }
-
-  public static org.openmbee.opensysml.proto.DocumentValue parseDelimitedFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
-      com.google.protobuf.CodedInputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.DocumentValue parseFrom(
-      com.google.protobuf.CodedInputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  @java.lang.Override
-  public Builder newBuilderForType() { return newBuilder(); }
-  public static Builder newBuilder() {
-    return DEFAULT_INSTANCE.toBuilder();
-  }
-  public static Builder newBuilder(org.openmbee.opensysml.proto.DocumentValue prototype) {
-    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
-  }
-  @java.lang.Override
-  public Builder toBuilder() {
-    return this == DEFAULT_INSTANCE
-        ? new Builder() : new Builder().mergeFrom(this);
-  }
-
-  @java.lang.Override
-  protected Builder newBuilderForType(
-      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-    Builder builder = new Builder(parent);
-    return builder;
-  }
-  /**
-   * <pre>
-   * DocumentValue is one typed document-query value. A request binds a model
-   * element by qualified name in element_id; a response also says what the
-   * element is in element_type. `infinity` denotes an unbounded multiplicity and
-   * is only ever answered, never bound.
-   * </pre>
-   *
-   * Protobuf type {@code sysml.DocumentValue}
-   */
-  public static final class Builder extends
-      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
-      // @@protoc_insertion_point(builder_implements:sysml.DocumentValue)
-      org.openmbee.opensysml.proto.DocumentValueOrBuilder {
-    public static final com.google.protobuf.Descriptors.Descriptor
-        getDescriptor() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentValue_descriptor;
-    }
-
-    @java.lang.Override
-    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-        internalGetFieldAccessorTable() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentValue_fieldAccessorTable
-          .ensureFieldAccessorsInitialized(
-              org.openmbee.opensysml.proto.DocumentValue.class, org.openmbee.opensysml.proto.DocumentValue.Builder.class);
-    }
-
-    // Construct using org.openmbee.opensysml.proto.DocumentValue.newBuilder()
-    private Builder() {
-
-    }
-
-    private Builder(
-        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-      super(parent);
-
-    }
-    @java.lang.Override
-    public Builder clear() {
-      super.clear();
-      bitField0_ = 0;
-      if (quantityBuilder_ != null) {
-        quantityBuilder_.clear();
-      }
-      elementType_ = "";
-      kindCase_ = 0;
-      kind_ = null;
-      return this;
-    }
-
-    @java.lang.Override
-    public com.google.protobuf.Descriptors.Descriptor
-        getDescriptorForType() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_DocumentValue_descriptor;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.DocumentValue getDefaultInstanceForType() {
-      return org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance();
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.DocumentValue build() {
-      org.openmbee.opensysml.proto.DocumentValue result = buildPartial();
-      if (!result.isInitialized()) {
-        throw newUninitializedMessageException(result);
-      }
-      return result;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.DocumentValue buildPartial() {
-      org.openmbee.opensysml.proto.DocumentValue result = new org.openmbee.opensysml.proto.DocumentValue(this);
-      if (bitField0_ != 0) { buildPartial0(result); }
-      buildPartialOneofs(result);
-      onBuilt();
-      return result;
-    }
-
-    private void buildPartial0(org.openmbee.opensysml.proto.DocumentValue result) {
-      int from_bitField0_ = bitField0_;
-      if (((from_bitField0_ & 0x00000080) != 0)) {
-        result.elementType_ = elementType_;
-      }
-    }
-
-    private void buildPartialOneofs(org.openmbee.opensysml.proto.DocumentValue result) {
-      result.kindCase_ = kindCase_;
-      result.kind_ = this.kind_;
-      if (kindCase_ == 8 &&
-          quantityBuilder_ != null) {
-        result.kind_ = quantityBuilder_.build();
-      }
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(com.google.protobuf.Message other) {
-      if (other instanceof org.openmbee.opensysml.proto.DocumentValue) {
-        return mergeFrom((org.openmbee.opensysml.proto.DocumentValue)other);
-      } else {
-        super.mergeFrom(other);
-        return this;
-      }
-    }
-
-    public Builder mergeFrom(org.openmbee.opensysml.proto.DocumentValue other) {
-      if (other == org.openmbee.opensysml.proto.DocumentValue.getDefaultInstance()) return this;
-      if (!other.getElementType().isEmpty()) {
-        elementType_ = other.elementType_;
-        bitField0_ |= 0x00000080;
-        onChanged();
-      }
-      switch (other.getKindCase()) {
-        case ELEMENT_ID: {
-          kindCase_ = 1;
-          kind_ = other.kind_;
-          onChanged();
-          break;
-        }
-        case STRING_VALUE: {
-          kindCase_ = 2;
-          kind_ = other.kind_;
-          onChanged();
-          break;
-        }
-        case INT_VALUE: {
-          setIntValue(other.getIntValue());
-          break;
-        }
-        case REAL_VALUE: {
-          setRealValue(other.getRealValue());
-          break;
-        }
-        case BOOL_VALUE: {
-          setBoolValue(other.getBoolValue());
-          break;
-        }
-        case INFINITY: {
-          setInfinity(other.getInfinity());
-          break;
-        }
-        case QUANTITY: {
-          mergeQuantity(other.getQuantity());
-          break;
-        }
-        case KIND_NOT_SET: {
-          break;
-        }
-      }
-      this.mergeUnknownFields(other.getUnknownFields());
-      onChanged();
-      return this;
-    }
-
-    @java.lang.Override
-    public final boolean isInitialized() {
-      return true;
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws java.io.IOException {
-      if (extensionRegistry == null) {
-        throw new java.lang.NullPointerException();
-      }
-      try {
-        boolean done = false;
-        while (!done) {
-          int tag = input.readTag();
-          switch (tag) {
-            case 0:
-              done = true;
-              break;
-            case 10: {
-              java.lang.String s = input.readStringRequireUtf8();
-              kindCase_ = 1;
-              kind_ = s;
-              break;
-            } // case 10
-            case 18: {
-              java.lang.String s = input.readStringRequireUtf8();
-              kindCase_ = 2;
-              kind_ = s;
-              break;
-            } // case 18
-            case 24: {
-              kind_ = input.readInt64();
-              kindCase_ = 3;
-              break;
-            } // case 24
-            case 33: {
-              kind_ = input.readDouble();
-              kindCase_ = 4;
-              break;
-            } // case 33
-            case 40: {
-              kind_ = input.readBool();
-              kindCase_ = 5;
-              break;
-            } // case 40
-            case 48: {
-              kind_ = input.readBool();
-              kindCase_ = 6;
-              break;
-            } // case 48
-            case 58: {
-              elementType_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000080;
-              break;
-            } // case 58
-            case 66: {
-              input.readMessage(
-                  internalGetQuantityFieldBuilder().getBuilder(),
-                  extensionRegistry);
-              kindCase_ = 8;
-              break;
-            } // case 66
-            default: {
-              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
-                done = true; // was an endgroup tag
-              }
-              break;
-            } // default:
-          } // switch (tag)
-        } // while (!done)
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.unwrapIOException();
-      } finally {
-        onChanged();
-      } // finally
-      return this;
-    }
-    private int kindCase_ = 0;
-    private java.lang.Object kind_;
-    public KindCase
-        getKindCase() {
-      return KindCase.forNumber(
-          kindCase_);
-    }
-
-    public Builder clearKind() {
-      kindCase_ = 0;
-      kind_ = null;
-      onChanged();
-      return this;
-    }
-
-    private int bitField0_;
-
-    /**
-     * <pre>
-     * qualified name of a model element
-     * </pre>
-     *
-     * <code>string element_id = 1 [json_name = "elementId"];</code>
-     * @return Whether the elementId field is set.
-     */
-    @java.lang.Override
-    public boolean hasElementId() {
-      return kindCase_ == 1;
-    }
-    /**
-     * <pre>
-     * qualified name of a model element
-     * </pre>
-     *
-     * <code>string element_id = 1 [json_name = "elementId"];</code>
-     * @return The elementId.
-     */
-    @java.lang.Override
-    public java.lang.String getElementId() {
-      java.lang.Object ref = "";
-      if (kindCase_ == 1) {
-        ref = kind_;
-      }
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        if (kindCase_ == 1) {
-          kind_ = s;
-        }
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * qualified name of a model element
-     * </pre>
-     *
-     * <code>string element_id = 1 [json_name = "elementId"];</code>
-     * @return The bytes for elementId.
-     */
-    @java.lang.Override
-    public com.google.protobuf.ByteString
-        getElementIdBytes() {
-      java.lang.Object ref = "";
-      if (kindCase_ == 1) {
-        ref = kind_;
-      }
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        if (kindCase_ == 1) {
-          kind_ = b;
-        }
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * qualified name of a model element
-     * </pre>
-     *
-     * <code>string element_id = 1 [json_name = "elementId"];</code>
-     * @param value The elementId to set.
-     * @return This builder for chaining.
-     */
-    public Builder setElementId(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      kindCase_ = 1;
-      kind_ = value;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * qualified name of a model element
-     * </pre>
-     *
-     * <code>string element_id = 1 [json_name = "elementId"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearElementId() {
-      if (kindCase_ == 1) {
-        kindCase_ = 0;
-        kind_ = null;
-        onChanged();
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * qualified name of a model element
-     * </pre>
-     *
-     * <code>string element_id = 1 [json_name = "elementId"];</code>
-     * @param value The bytes for elementId to set.
-     * @return This builder for chaining.
-     */
-    public Builder setElementIdBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      kindCase_ = 1;
-      kind_ = value;
-      onChanged();
-      return this;
-    }
-
-    /**
-     * <code>string string_value = 2 [json_name = "stringValue"];</code>
-     * @return Whether the stringValue field is set.
-     */
-    @java.lang.Override
-    public boolean hasStringValue() {
-      return kindCase_ == 2;
-    }
-    /**
-     * <code>string string_value = 2 [json_name = "stringValue"];</code>
-     * @return The stringValue.
-     */
-    @java.lang.Override
-    public java.lang.String getStringValue() {
-      java.lang.Object ref = "";
-      if (kindCase_ == 2) {
-        ref = kind_;
-      }
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        if (kindCase_ == 2) {
-          kind_ = s;
-        }
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <code>string string_value = 2 [json_name = "stringValue"];</code>
-     * @return The bytes for stringValue.
-     */
-    @java.lang.Override
-    public com.google.protobuf.ByteString
-        getStringValueBytes() {
-      java.lang.Object ref = "";
-      if (kindCase_ == 2) {
-        ref = kind_;
-      }
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        if (kindCase_ == 2) {
-          kind_ = b;
-        }
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <code>string string_value = 2 [json_name = "stringValue"];</code>
-     * @param value The stringValue to set.
-     * @return This builder for chaining.
-     */
-    public Builder setStringValue(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      kindCase_ = 2;
-      kind_ = value;
-      onChanged();
-      return this;
-    }
-    /**
-     * <code>string string_value = 2 [json_name = "stringValue"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearStringValue() {
-      if (kindCase_ == 2) {
-        kindCase_ = 0;
-        kind_ = null;
-        onChanged();
-      }
-      return this;
-    }
-    /**
-     * <code>string string_value = 2 [json_name = "stringValue"];</code>
-     * @param value The bytes for stringValue to set.
-     * @return This builder for chaining.
-     */
-    public Builder setStringValueBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      kindCase_ = 2;
-      kind_ = value;
-      onChanged();
-      return this;
-    }
-
-    /**
-     * <code>int64 int_value = 3 [json_name = "intValue"];</code>
-     * @return Whether the intValue field is set.
-     */
-    public boolean hasIntValue() {
-      return kindCase_ == 3;
-    }
-    /**
-     * <code>int64 int_value = 3 [json_name = "intValue"];</code>
-     * @return The intValue.
-     */
-    public long getIntValue() {
-      if (kindCase_ == 3) {
-        return (java.lang.Long) kind_;
-      }
-      return 0L;
-    }
-    /**
-     * <code>int64 int_value = 3 [json_name = "intValue"];</code>
-     * @param value The intValue to set.
-     * @return This builder for chaining.
-     */
-    public Builder setIntValue(long value) {
-
-      kindCase_ = 3;
-      kind_ = value;
-      onChanged();
-      return this;
-    }
-    /**
-     * <code>int64 int_value = 3 [json_name = "intValue"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearIntValue() {
-      if (kindCase_ == 3) {
-        kindCase_ = 0;
-        kind_ = null;
-        onChanged();
-      }
-      return this;
-    }
-
-    /**
-     * <code>double real_value = 4 [json_name = "realValue"];</code>
-     * @return Whether the realValue field is set.
-     */
-    public boolean hasRealValue() {
-      return kindCase_ == 4;
-    }
-    /**
-     * <code>double real_value = 4 [json_name = "realValue"];</code>
-     * @return The realValue.
-     */
-    public double getRealValue() {
-      if (kindCase_ == 4) {
-        return (java.lang.Double) kind_;
-      }
-      return 0D;
-    }
-    /**
-     * <code>double real_value = 4 [json_name = "realValue"];</code>
-     * @param value The realValue to set.
-     * @return This builder for chaining.
-     */
-    public Builder setRealValue(double value) {
-
-      kindCase_ = 4;
-      kind_ = value;
-      onChanged();
-      return this;
-    }
-    /**
-     * <code>double real_value = 4 [json_name = "realValue"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearRealValue() {
-      if (kindCase_ == 4) {
-        kindCase_ = 0;
-        kind_ = null;
-        onChanged();
-      }
-      return this;
-    }
-
-    /**
-     * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
-     * @return Whether the boolValue field is set.
-     */
-    public boolean hasBoolValue() {
-      return kindCase_ == 5;
-    }
-    /**
-     * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
-     * @return The boolValue.
-     */
-    public boolean getBoolValue() {
-      if (kindCase_ == 5) {
-        return (java.lang.Boolean) kind_;
-      }
-      return false;
-    }
-    /**
-     * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
-     * @param value The boolValue to set.
-     * @return This builder for chaining.
-     */
-    public Builder setBoolValue(boolean value) {
-
-      kindCase_ = 5;
-      kind_ = value;
-      onChanged();
-      return this;
-    }
-    /**
-     * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearBoolValue() {
-      if (kindCase_ == 5) {
-        kindCase_ = 0;
-        kind_ = null;
-        onChanged();
-      }
-      return this;
-    }
-
-    /**
-     * <code>bool infinity = 6 [json_name = "infinity"];</code>
-     * @return Whether the infinity field is set.
-     */
-    public boolean hasInfinity() {
-      return kindCase_ == 6;
-    }
-    /**
-     * <code>bool infinity = 6 [json_name = "infinity"];</code>
-     * @return The infinity.
-     */
-    public boolean getInfinity() {
-      if (kindCase_ == 6) {
-        return (java.lang.Boolean) kind_;
-      }
-      return false;
-    }
-    /**
-     * <code>bool infinity = 6 [json_name = "infinity"];</code>
-     * @param value The infinity to set.
-     * @return This builder for chaining.
-     */
-    public Builder setInfinity(boolean value) {
-
-      kindCase_ = 6;
-      kind_ = value;
-      onChanged();
-      return this;
-    }
-    /**
-     * <code>bool infinity = 6 [json_name = "infinity"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearInfinity() {
-      if (kindCase_ == 6) {
-        kindCase_ = 0;
-        kind_ = null;
-        onChanged();
-      }
-      return this;
-    }
-
-    private com.google.protobuf.SingleFieldBuilder<
-        org.openmbee.opensysml.proto.Quantity, org.openmbee.opensysml.proto.Quantity.Builder, org.openmbee.opensysml.proto.QuantityOrBuilder> quantityBuilder_;
-    /**
-     * <pre>
-     * magnitude in a unit, `2290000 [kg]`
-     * </pre>
-     *
-     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-     * @return Whether the quantity field is set.
-     */
-    @java.lang.Override
-    public boolean hasQuantity() {
-      return kindCase_ == 8;
-    }
-    /**
-     * <pre>
-     * magnitude in a unit, `2290000 [kg]`
-     * </pre>
-     *
-     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-     * @return The quantity.
-     */
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.Quantity getQuantity() {
-      if (quantityBuilder_ == null) {
-        if (kindCase_ == 8) {
-          return (org.openmbee.opensysml.proto.Quantity) kind_;
-        }
-        return org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
-      } else {
-        if (kindCase_ == 8) {
-          return quantityBuilder_.getMessage();
-        }
-        return org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
-      }
-    }
-    /**
-     * <pre>
-     * magnitude in a unit, `2290000 [kg]`
-     * </pre>
-     *
-     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-     */
-    public Builder setQuantity(org.openmbee.opensysml.proto.Quantity value) {
-      if (quantityBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        kind_ = value;
-        onChanged();
-      } else {
-        quantityBuilder_.setMessage(value);
-      }
-      kindCase_ = 8;
-      return this;
-    }
-    /**
-     * <pre>
-     * magnitude in a unit, `2290000 [kg]`
-     * </pre>
-     *
-     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-     */
-    public Builder setQuantity(
-        org.openmbee.opensysml.proto.Quantity.Builder builderForValue) {
-      if (quantityBuilder_ == null) {
-        kind_ = builderForValue.build();
-        onChanged();
-      } else {
-        quantityBuilder_.setMessage(builderForValue.build());
-      }
-      kindCase_ = 8;
-      return this;
-    }
-    /**
-     * <pre>
-     * magnitude in a unit, `2290000 [kg]`
-     * </pre>
-     *
-     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-     */
-    public Builder mergeQuantity(org.openmbee.opensysml.proto.Quantity value) {
-      if (quantityBuilder_ == null) {
-        if (kindCase_ == 8 &&
-            kind_ != org.openmbee.opensysml.proto.Quantity.getDefaultInstance()) {
-          kind_ = org.openmbee.opensysml.proto.Quantity.newBuilder((org.openmbee.opensysml.proto.Quantity) kind_)
-              .mergeFrom(value).buildPartial();
-        } else {
-          kind_ = value;
-        }
-        onChanged();
-      } else {
-        if (kindCase_ == 8) {
-          quantityBuilder_.mergeFrom(value);
-        } else {
-          quantityBuilder_.setMessage(value);
-        }
-      }
-      kindCase_ = 8;
-      return this;
-    }
-    /**
-     * <pre>
-     * magnitude in a unit, `2290000 [kg]`
-     * </pre>
-     *
-     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-     */
-    public Builder clearQuantity() {
-      if (quantityBuilder_ == null) {
-        if (kindCase_ == 8) {
-          kindCase_ = 0;
-          kind_ = null;
-          onChanged();
-        }
-      } else {
-        if (kindCase_ == 8) {
-          kindCase_ = 0;
-          kind_ = null;
-        }
-        quantityBuilder_.clear();
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * magnitude in a unit, `2290000 [kg]`
-     * </pre>
-     *
-     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-     */
-    public org.openmbee.opensysml.proto.Quantity.Builder getQuantityBuilder() {
-      return internalGetQuantityFieldBuilder().getBuilder();
-    }
-    /**
-     * <pre>
-     * magnitude in a unit, `2290000 [kg]`
-     * </pre>
-     *
-     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-     */
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.QuantityOrBuilder getQuantityOrBuilder() {
-      if ((kindCase_ == 8) && (quantityBuilder_ != null)) {
-        return quantityBuilder_.getMessageOrBuilder();
-      } else {
-        if (kindCase_ == 8) {
-          return (org.openmbee.opensysml.proto.Quantity) kind_;
-        }
-        return org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
-      }
-    }
-    /**
-     * <pre>
-     * magnitude in a unit, `2290000 [kg]`
-     * </pre>
-     *
-     * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-     */
-    private com.google.protobuf.SingleFieldBuilder<
-        org.openmbee.opensysml.proto.Quantity, org.openmbee.opensysml.proto.Quantity.Builder, org.openmbee.opensysml.proto.QuantityOrBuilder> 
-        internalGetQuantityFieldBuilder() {
-      if (quantityBuilder_ == null) {
-        if (!(kindCase_ == 8)) {
-          kind_ = org.openmbee.opensysml.proto.Quantity.getDefaultInstance();
-        }
-        quantityBuilder_ = new com.google.protobuf.SingleFieldBuilder<
-            org.openmbee.opensysml.proto.Quantity, org.openmbee.opensysml.proto.Quantity.Builder, org.openmbee.opensysml.proto.QuantityOrBuilder>(
-                (org.openmbee.opensysml.proto.Quantity) kind_,
-                getParentForChildren(),
-                isClean());
-        kind_ = null;
-      }
-      kindCase_ = 8;
-      onChanged();
-      return quantityBuilder_;
-    }
-
-    private java.lang.Object elementType_ = "";
-    /**
-     * <pre>
-     * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
-     * </pre>
-     *
-     * <code>string element_type = 7 [json_name = "elementType"];</code>
-     * @return The elementType.
-     */
-    public java.lang.String getElementType() {
-      java.lang.Object ref = elementType_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        elementType_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
-     * </pre>
-     *
-     * <code>string element_type = 7 [json_name = "elementType"];</code>
-     * @return The bytes for elementType.
-     */
-    public com.google.protobuf.ByteString
-        getElementTypeBytes() {
-      java.lang.Object ref = elementType_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        elementType_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
-     * </pre>
-     *
-     * <code>string element_type = 7 [json_name = "elementType"];</code>
-     * @param value The elementType to set.
-     * @return This builder for chaining.
-     */
-    public Builder setElementType(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      elementType_ = value;
-      bitField0_ |= 0x00000080;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
-     * </pre>
-     *
-     * <code>string element_type = 7 [json_name = "elementType"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearElementType() {
-      elementType_ = getDefaultInstance().getElementType();
-      bitField0_ = (bitField0_ & ~0x00000080);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
-     * </pre>
-     *
-     * <code>string element_type = 7 [json_name = "elementType"];</code>
-     * @param value The bytes for elementType to set.
-     * @return This builder for chaining.
-     */
-    public Builder setElementTypeBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      elementType_ = value;
-      bitField0_ |= 0x00000080;
-      onChanged();
-      return this;
-    }
-
-    // @@protoc_insertion_point(builder_scope:sysml.DocumentValue)
-  }
-
-  // @@protoc_insertion_point(class_scope:sysml.DocumentValue)
-  private static final org.openmbee.opensysml.proto.DocumentValue DEFAULT_INSTANCE;
-  static {
-    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.DocumentValue();
-  }
-
-  public static org.openmbee.opensysml.proto.DocumentValue getDefaultInstance() {
-    return DEFAULT_INSTANCE;
-  }
-
-  private static final com.google.protobuf.Parser<DocumentValue>
-      PARSER = new com.google.protobuf.AbstractParser<DocumentValue>() {
-    @java.lang.Override
-    public DocumentValue parsePartialFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws com.google.protobuf.InvalidProtocolBufferException {
-      Builder builder = newBuilder();
-      try {
-        builder.mergeFrom(input, extensionRegistry);
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.setUnfinishedMessage(builder.buildPartial());
-      } catch (com.google.protobuf.UninitializedMessageException e) {
-        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
-      } catch (java.io.IOException e) {
-        throw new com.google.protobuf.InvalidProtocolBufferException(e)
-            .setUnfinishedMessage(builder.buildPartial());
-      }
-      return builder.buildPartial();
-    }
-  };
-
-  public static com.google.protobuf.Parser<DocumentValue> parser() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public com.google.protobuf.Parser<DocumentValue> getParserForType() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.DocumentValue getDefaultInstanceForType() {
-    return DEFAULT_INSTANCE;
-  }
-
-}
-
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValueOrBuilder.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValueOrBuilder.java
deleted file mode 100644
index 54401fa589..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/DocumentValueOrBuilder.java
+++ /dev/null
@@ -1,151 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-@com.google.protobuf.Generated
-public interface DocumentValueOrBuilder extends
-    // @@protoc_insertion_point(interface_extends:sysml.DocumentValue)
-    com.google.protobuf.MessageOrBuilder {
-
-  /**
-   * <pre>
-   * qualified name of a model element
-   * </pre>
-   *
-   * <code>string element_id = 1 [json_name = "elementId"];</code>
-   * @return Whether the elementId field is set.
-   */
-  boolean hasElementId();
-  /**
-   * <pre>
-   * qualified name of a model element
-   * </pre>
-   *
-   * <code>string element_id = 1 [json_name = "elementId"];</code>
-   * @return The elementId.
-   */
-  java.lang.String getElementId();
-  /**
-   * <pre>
-   * qualified name of a model element
-   * </pre>
-   *
-   * <code>string element_id = 1 [json_name = "elementId"];</code>
-   * @return The bytes for elementId.
-   */
-  com.google.protobuf.ByteString
-      getElementIdBytes();
-
-  /**
-   * <code>string string_value = 2 [json_name = "stringValue"];</code>
-   * @return Whether the stringValue field is set.
-   */
-  boolean hasStringValue();
-  /**
-   * <code>string string_value = 2 [json_name = "stringValue"];</code>
-   * @return The stringValue.
-   */
-  java.lang.String getStringValue();
-  /**
-   * <code>string string_value = 2 [json_name = "stringValue"];</code>
-   * @return The bytes for stringValue.
-   */
-  com.google.protobuf.ByteString
-      getStringValueBytes();
-
-  /**
-   * <code>int64 int_value = 3 [json_name = "intValue"];</code>
-   * @return Whether the intValue field is set.
-   */
-  boolean hasIntValue();
-  /**
-   * <code>int64 int_value = 3 [json_name = "intValue"];</code>
-   * @return The intValue.
-   */
-  long getIntValue();
-
-  /**
-   * <code>double real_value = 4 [json_name = "realValue"];</code>
-   * @return Whether the realValue field is set.
-   */
-  boolean hasRealValue();
-  /**
-   * <code>double real_value = 4 [json_name = "realValue"];</code>
-   * @return The realValue.
-   */
-  double getRealValue();
-
-  /**
-   * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
-   * @return Whether the boolValue field is set.
-   */
-  boolean hasBoolValue();
-  /**
-   * <code>bool bool_value = 5 [json_name = "boolValue"];</code>
-   * @return The boolValue.
-   */
-  boolean getBoolValue();
-
-  /**
-   * <code>bool infinity = 6 [json_name = "infinity"];</code>
-   * @return Whether the infinity field is set.
-   */
-  boolean hasInfinity();
-  /**
-   * <code>bool infinity = 6 [json_name = "infinity"];</code>
-   * @return The infinity.
-   */
-  boolean getInfinity();
-
-  /**
-   * <pre>
-   * magnitude in a unit, `2290000 [kg]`
-   * </pre>
-   *
-   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-   * @return Whether the quantity field is set.
-   */
-  boolean hasQuantity();
-  /**
-   * <pre>
-   * magnitude in a unit, `2290000 [kg]`
-   * </pre>
-   *
-   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-   * @return The quantity.
-   */
-  org.openmbee.opensysml.proto.Quantity getQuantity();
-  /**
-   * <pre>
-   * magnitude in a unit, `2290000 [kg]`
-   * </pre>
-   *
-   * <code>.sysml.Quantity quantity = 8 [json_name = "quantity"];</code>
-   */
-  org.openmbee.opensysml.proto.QuantityOrBuilder getQuantityOrBuilder();
-
-  /**
-   * <pre>
-   * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
-   * </pre>
-   *
-   * <code>string element_type = 7 [json_name = "elementType"];</code>
-   * @return The elementType.
-   */
-  java.lang.String getElementType();
-  /**
-   * <pre>
-   * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
-   * </pre>
-   *
-   * <code>string element_type = 7 [json_name = "elementType"];</code>
-   * @return The bytes for elementType.
-   */
-  com.google.protobuf.ByteString
-      getElementTypeBytes();
-
-  org.openmbee.opensysml.proto.DocumentValue.KindCase getKindCase();
-}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditFailure.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditFailure.java
deleted file mode 100644
index b52f4fc7be..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/EditFailure.java
+++ /dev/null
@@ -1,418 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-/**
- * <pre>
- * EditFailure says why edits were refused. Every refusal is one of these: an
- * edit is never silently dropped.
- * </pre>
- *
- * Protobuf enum {@code sysml.EditFailure}
- */
-@com.google.protobuf.Generated
-public enum EditFailure
-    implements com.google.protobuf.ProtocolMessageEnum {
-  /**
-   * <pre>
-   * no failure
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_UNSPECIFIED = 0;</code>
-   */
-  EDIT_FAILURE_UNSPECIFIED(0),
-  /**
-   * <pre>
-   * the request named no edit
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_NO_OPERATIONS = 1;</code>
-   */
-  EDIT_FAILURE_NO_OPERATIONS(1),
-  /**
-   * <pre>
-   * no element of that name in the model
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_UNKNOWN_TARGET = 2;</code>
-   */
-  EDIT_FAILURE_UNKNOWN_TARGET(2),
-  /**
-   * <pre>
-   * the name denotes several declarations
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_AMBIGUOUS_TARGET = 3;</code>
-   */
-  EDIT_FAILURE_AMBIGUOUS_TARGET(3),
-  /**
-   * <pre>
-   * the element can carry no value
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_NOT_VALUED = 4;</code>
-   */
-  EDIT_FAILURE_NOT_VALUED(4),
-  /**
-   * <pre>
-   * the new value does not parse as an expression
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_INVALID_VALUE = 5;</code>
-   */
-  EDIT_FAILURE_INVALID_VALUE(5),
-  /**
-   * <pre>
-   * the new name does not lex as an identifier
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_INVALID_NAME = 6;</code>
-   */
-  EDIT_FAILURE_INVALID_NAME(6),
-  /**
-   * <pre>
-   * the element declares no name to rewrite
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_NOT_NAMED = 7;</code>
-   */
-  EDIT_FAILURE_NOT_NAMED(7),
-  /**
-   * <pre>
-   * references to the element would break
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_RENAME_REFERENCED = 8;</code>
-   */
-  EDIT_FAILURE_RENAME_REFERENCED(8),
-  /**
-   * <pre>
-   * two edits cover the same bytes
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_OVERLAPPING_EDITS = 9;</code>
-   */
-  EDIT_FAILURE_OVERLAPPING_EDITS(9),
-  /**
-   * <pre>
-   * the edited source has errors the original had not
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_RESULT_INVALID = 10;</code>
-   */
-  EDIT_FAILURE_RESULT_INVALID(10),
-  /**
-   * <pre>
-   * add-member owner does not exist
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_OWNER_UNKNOWN = 11;</code>
-   */
-  EDIT_FAILURE_OWNER_UNKNOWN(11),
-  /**
-   * <pre>
-   * owner cannot contain members
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_OWNER_NOT_NAMESPACE = 12;</code>
-   */
-  EDIT_FAILURE_OWNER_NOT_NAMESPACE(12),
-  /**
-   * <pre>
-   * kind is invalid for document language
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_ILLEGAL_KIND = 13;</code>
-   */
-  EDIT_FAILURE_ILLEGAL_KIND(13),
-  /**
-   * <pre>
-   * owner already declares the name
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_MEMBER_NAME_TAKEN = 14;</code>
-   */
-  EDIT_FAILURE_MEMBER_NAME_TAKEN(14),
-  /**
-   * <pre>
-   * delete would leave references
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_DELETE_REFERENCED = 15;</code>
-   */
-  EDIT_FAILURE_DELETE_REFERENCED(15),
-  /**
-   * <pre>
-   * move owner is the target or inside it
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_OWNER_INSIDE_TARGET = 16;</code>
-   */
-  EDIT_FAILURE_OWNER_INSIDE_TARGET(16),
-  /**
-   * <pre>
-   * move would leave a reference no spelling restores
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_MOVE_REFERENCED = 17;</code>
-   */
-  EDIT_FAILURE_MOVE_REFERENCED(17),
-  UNRECOGNIZED(-1),
-  ;
-
-  static {
-    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
-      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
-      /* major= */ 4,
-      /* minor= */ 33,
-      /* patch= */ 1,
-      /* suffix= */ "",
-      "EditFailure");
-  }
-  /**
-   * <pre>
-   * no failure
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_UNSPECIFIED = 0;</code>
-   */
-  public static final int EDIT_FAILURE_UNSPECIFIED_VALUE = 0;
-  /**
-   * <pre>
-   * the request named no edit
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_NO_OPERATIONS = 1;</code>
-   */
-  public static final int EDIT_FAILURE_NO_OPERATIONS_VALUE = 1;
-  /**
-   * <pre>
-   * no element of that name in the model
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_UNKNOWN_TARGET = 2;</code>
-   */
-  public static final int EDIT_FAILURE_UNKNOWN_TARGET_VALUE = 2;
-  /**
-   * <pre>
-   * the name denotes several declarations
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_AMBIGUOUS_TARGET = 3;</code>
-   */
-  public static final int EDIT_FAILURE_AMBIGUOUS_TARGET_VALUE = 3;
-  /**
-   * <pre>
-   * the element can carry no value
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_NOT_VALUED = 4;</code>
-   */
-  public static final int EDIT_FAILURE_NOT_VALUED_VALUE = 4;
-  /**
-   * <pre>
-   * the new value does not parse as an expression
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_INVALID_VALUE = 5;</code>
-   */
-  public static final int EDIT_FAILURE_INVALID_VALUE_VALUE = 5;
-  /**
-   * <pre>
-   * the new name does not lex as an identifier
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_INVALID_NAME = 6;</code>
-   */
-  public static final int EDIT_FAILURE_INVALID_NAME_VALUE = 6;
-  /**
-   * <pre>
-   * the element declares no name to rewrite
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_NOT_NAMED = 7;</code>
-   */
-  public static final int EDIT_FAILURE_NOT_NAMED_VALUE = 7;
-  /**
-   * <pre>
-   * references to the element would break
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_RENAME_REFERENCED = 8;</code>
-   */
-  public static final int EDIT_FAILURE_RENAME_REFERENCED_VALUE = 8;
-  /**
-   * <pre>
-   * two edits cover the same bytes
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_OVERLAPPING_EDITS = 9;</code>
-   */
-  public static final int EDIT_FAILURE_OVERLAPPING_EDITS_VALUE = 9;
-  /**
-   * <pre>
-   * the edited source has errors the original had not
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_RESULT_INVALID = 10;</code>
-   */
-  public static final int EDIT_FAILURE_RESULT_INVALID_VALUE = 10;
-  /**
-   * <pre>
-   * add-member owner does not exist
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_OWNER_UNKNOWN = 11;</code>
-   */
-  public static final int EDIT_FAILURE_OWNER_UNKNOWN_VALUE = 11;
-  /**
-   * <pre>
-   * owner cannot contain members
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_OWNER_NOT_NAMESPACE = 12;</code>
-   */
-  public static final int EDIT_FAILURE_OWNER_NOT_NAMESPACE_VALUE = 12;
-  /**
-   * <pre>
-   * kind is invalid for document language
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_ILLEGAL_KIND = 13;</code>
-   */
-  public static final int EDIT_FAILURE_ILLEGAL_KIND_VALUE = 13;
-  /**
-   * <pre>
-   * owner already declares the name
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_MEMBER_NAME_TAKEN = 14;</code>
-   */
-  public static final int EDIT_FAILURE_MEMBER_NAME_TAKEN_VALUE = 14;
-  /**
-   * <pre>
-   * delete would leave references
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_DELETE_REFERENCED = 15;</code>
-   */
-  public static final int EDIT_FAILURE_DELETE_REFERENCED_VALUE = 15;
-  /**
-   * <pre>
-   * move owner is the target or inside it
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_OWNER_INSIDE_TARGET = 16;</code>
-   */
-  public static final int EDIT_FAILURE_OWNER_INSIDE_TARGET_VALUE = 16;
-  /**
-   * <pre>
-   * move would leave a reference no spelling restores
-   * </pre>
-   *
-   * <code>EDIT_FAILURE_MOVE_REFERENCED = 17;</code>
-   */
-  public static final int EDIT_FAILURE_MOVE_REFERENCED_VALUE = 17;
-
-
-  public final int getNumber() {
-    if (this == UNRECOGNIZED) {
-      throw new java.lang.IllegalArgumentException(
-          "Can't get the number of an unknown enum value.");
-    }
-    return value;
-  }
-
-  /**
-   * @param value The numeric wire value of the corresponding enum entry.
-   * @return The enum associated with the given numeric wire value.
-   * @deprecated Use {@link #forNumber(int)} instead.
-   */
-  @java.lang.Deprecated
-  public static EditFailure valueOf(int value) {
-    return forNumber(value);
-  }
-
-  /**
-   * @param value The numeric wire value of the corresponding enum entry.
-   * @return The enum associated with the given numeric wire value.
-   */
-  public static EditFailure forNumber(int value) {
-    switch (value) {
-      case 0: return EDIT_FAILURE_UNSPECIFIED;
-      case 1: return EDIT_FAILURE_NO_OPERATIONS;
-      case 2: return EDIT_FAILURE_UNKNOWN_TARGET;
-      case 3: return EDIT_FAILURE_AMBIGUOUS_TARGET;
-      case 4: return EDIT_FAILURE_NOT_VALUED;
-      case 5: return EDIT_FAILURE_INVALID_VALUE;
-      case 6: return EDIT_FAILURE_INVALID_NAME;
-      case 7: return EDIT_FAILURE_NOT_NAMED;
-      case 8: return EDIT_FAILURE_RENAME_REFERENCED;
-      case 9: return EDIT_FAILURE_OVERLAPPING_EDITS;
-      case 10: return EDIT_FAILURE_RESULT_INVALID;
-      case 11: return EDIT_FAILURE_OWNER_UNKNOWN;
-      case 12: return EDIT_FAILURE_OWNER_NOT_NAMESPACE;
-      case 13: return EDIT_FAILURE_ILLEGAL_KIND;
-      case 14: return EDIT_FAILURE_MEMBER_NAME_TAKEN;
-      case 15: return EDIT_FAILURE_DELETE_REFERENCED;
-      case 16: return EDIT_FAILURE_OWNER_INSIDE_TARGET;
-      case 17: return EDIT_FAILURE_MOVE_REFERENCED;
-      default: return null;
-    }
-  }
-
-  public static com.google.protobuf.Internal.EnumLiteMap<EditFailure>
-      internalGetValueMap() {
-    return internalValueMap;
-  }
-  private static final com.google.protobuf.Internal.EnumLiteMap<
-      EditFailure> internalValueMap =
-        new com.google.protobuf.Internal.EnumLiteMap<EditFailure>() {
-          public EditFailure findValueByNumber(int number) {
-            return EditFailure.forNumber(number);
-          }
-        };
-
-  public final com.google.protobuf.Descriptors.EnumValueDescriptor
-      getValueDescriptor() {
-    if (this == UNRECOGNIZED) {
-      throw new java.lang.IllegalStateException(
-          "Can't get the descriptor of an unrecognized enum value.");
-    }
-    return getDescriptor().getValues().get(ordinal());
-  }
-  public final com.google.protobuf.Descriptors.EnumDescriptor
-      getDescriptorForType() {
-    return getDescriptor();
-  }
-  public static com.google.protobuf.Descriptors.EnumDescriptor
-      getDescriptor() {
-    return org.openmbee.opensysml.proto.Sysml.getDescriptor().getEnumTypes().get(1);
-  }
-
-  private static final EditFailure[] VALUES = values();
-
-  public static EditFailure valueOf(
-      com.google.protobuf.Descriptors.EnumValueDescriptor desc) {
-    if (desc.getType() != getDescriptor()) {
-      throw new java.lang.IllegalArgumentException(
-        "EnumValueDescriptor is not for this type.");
-    }
-    if (desc.getIndex() == -1) {
-      return UNRECOGNIZED;
-    }
-    return VALUES[desc.getIndex()];
-  }
-
-  private final int value;
-
-  private EditFailure(int value) {
-    this.value = value;
-  }
-
-  // @@protoc_insertion_point(enum_scope:sysml.EditFailure)
-}
-
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequestOrBuilder.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequestOrBuilder.java
deleted file mode 100644
index 9f3c7e10b6..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateRequestOrBuilder.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-@com.google.protobuf.Generated
-public interface ExecuteStateRequestOrBuilder extends
-    // @@protoc_insertion_point(interface_extends:sysml.ExecuteStateRequest)
-    com.google.protobuf.MessageOrBuilder {
-
-  /**
-   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-   * @return The modelHash.
-   */
-  java.lang.String getModelHash();
-  /**
-   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-   * @return The bytes for modelHash.
-   */
-  com.google.protobuf.ByteString
-      getModelHashBytes();
-
-  /**
-   * <code>string state_machine_symbol_id = 2 [json_name = "stateMachineSymbolId"];</code>
-   * @return The stateMachineSymbolId.
-   */
-  java.lang.String getStateMachineSymbolId();
-  /**
-   * <code>string state_machine_symbol_id = 2 [json_name = "stateMachineSymbolId"];</code>
-   * @return The bytes for stateMachineSymbolId.
-   */
-  com.google.protobuf.ByteString
-      getStateMachineSymbolIdBytes();
-
-  /**
-   * <pre>
-   * sequence of event names to process
-   * </pre>
-   *
-   * <code>repeated string events = 3 [json_name = "events"];</code>
-   * @return A list containing the events.
-   */
-  java.util.List<java.lang.String>
-      getEventsList();
-  /**
-   * <pre>
-   * sequence of event names to process
-   * </pre>
-   *
-   * <code>repeated string events = 3 [json_name = "events"];</code>
-   * @return The count of events.
-   */
-  int getEventsCount();
-  /**
-   * <pre>
-   * sequence of event names to process
-   * </pre>
-   *
-   * <code>repeated string events = 3 [json_name = "events"];</code>
-   * @param index The index of the element to return.
-   * @return The events at the given index.
-   */
-  java.lang.String getEvents(int index);
-  /**
-   * <pre>
-   * sequence of event names to process
-   * </pre>
-   *
-   * <code>repeated string events = 3 [json_name = "events"];</code>
-   * @param index The index of the value to return.
-   * @return The bytes of the events at the given index.
-   */
-  com.google.protobuf.ByteString
-      getEventsBytes(int index);
-
-  /**
-   * <pre>
-   * Scheduling policy the run resolves its choice points under: "declared",
-   * "reverse", "seed:<n>" or "explore[:runs=<n>,depth=<d>]". Empty is the
-   * default, "reverse"; any other spelling is INVALID_ARGUMENT. Under explore
-   * the response answers with `outcomes` and `exploration` in place of one run's
-   * states_visited, final_context and error (see ExecuteActionResponse).
-   * </pre>
-   *
-   * <code>string schedule = 4 [json_name = "schedule"];</code>
-   * @return The schedule.
-   */
-  java.lang.String getSchedule();
-  /**
-   * <pre>
-   * Scheduling policy the run resolves its choice points under: "declared",
-   * "reverse", "seed:<n>" or "explore[:runs=<n>,depth=<d>]". Empty is the
-   * default, "reverse"; any other spelling is INVALID_ARGUMENT. Under explore
-   * the response answers with `outcomes` and `exploration` in place of one run's
-   * states_visited, final_context and error (see ExecuteActionResponse).
-   * </pre>
-   *
-   * <code>string schedule = 4 [json_name = "schedule"];</code>
-   * @return The bytes for schedule.
-   */
-  com.google.protobuf.ByteString
-      getScheduleBytes();
-}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Outcome.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Outcome.java
deleted file mode 100644
index 6739ea79fc..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Outcome.java
+++ /dev/null
@@ -1,2111 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-/**
- * <pre>
- * Outcome is one distinct outcome an exploration reached: the observables a
- * conformance case compares, how many linearizations reached it, and the choice
- * sequence of one run that did. Two runs agreeing on their observables are one
- * outcome.
- * </pre>
- *
- * Protobuf type {@code sysml.Outcome}
- */
-@com.google.protobuf.Generated
-public final class Outcome extends
-    com.google.protobuf.GeneratedMessage implements
-    // @@protoc_insertion_point(message_implements:sysml.Outcome)
-    OutcomeOrBuilder {
-private static final long serialVersionUID = 0L;
-  static {
-    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
-      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
-      /* major= */ 4,
-      /* minor= */ 33,
-      /* patch= */ 1,
-      /* suffix= */ "",
-      "Outcome");
-  }
-  // Use Outcome.newBuilder() to construct.
-  private Outcome(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
-    super(builder);
-  }
-  private Outcome() {
-    finalState_ = "";
-    statesVisited_ =
-        com.google.protobuf.LazyStringArrayList.emptyList();
-    error_ = "";
-    witness_ =
-        com.google.protobuf.LazyStringArrayList.emptyList();
-    diagnostics_ = java.util.Collections.emptyList();
-  }
-
-  public static final com.google.protobuf.Descriptors.Descriptor
-      getDescriptor() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_descriptor;
-  }
-
-  @SuppressWarnings({"rawtypes"})
-  @java.lang.Override
-  protected com.google.protobuf.MapFieldReflectionAccessor internalGetMapFieldReflection(
-      int number) {
-    switch (number) {
-      case 1:
-        return internalGetOutputs();
-      default:
-        throw new RuntimeException(
-            "Invalid map field number: " + number);
-    }
-  }
-  @java.lang.Override
-  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internalGetFieldAccessorTable() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_fieldAccessorTable
-        .ensureFieldAccessorsInitialized(
-            org.openmbee.opensysml.proto.Outcome.class, org.openmbee.opensysml.proto.Outcome.Builder.class);
-  }
-
-  public static final int OUTPUTS_FIELD_NUMBER = 1;
-  private static final class OutputsDefaultEntryHolder {
-    static final com.google.protobuf.MapEntry<
-        java.lang.String, org.openmbee.opensysml.proto.Value> defaultEntry =
-            com.google.protobuf.MapEntry
-            .<java.lang.String, org.openmbee.opensysml.proto.Value>newDefaultInstance(
-                org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_OutputsEntry_descriptor, 
-                com.google.protobuf.WireFormat.FieldType.STRING,
-                "",
-                com.google.protobuf.WireFormat.FieldType.MESSAGE,
-                org.openmbee.opensysml.proto.Value.getDefaultInstance());
-  }
-  @SuppressWarnings("serial")
-  private com.google.protobuf.MapField<
-      java.lang.String, org.openmbee.opensysml.proto.Value> outputs_;
-  private com.google.protobuf.MapField<java.lang.String, org.openmbee.opensysml.proto.Value>
-  internalGetOutputs() {
-    if (outputs_ == null) {
-      return com.google.protobuf.MapField.emptyMapField(
-          OutputsDefaultEntryHolder.defaultEntry);
-    }
-    return outputs_;
-  }
-  public int getOutputsCount() {
-    return internalGetOutputs().getMap().size();
-  }
-  /**
-   * <pre>
-   * The values the run ended with: an action's outputs, a state machine's final
-   * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-   * </pre>
-   *
-   * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-   */
-  @java.lang.Override
-  public boolean containsOutputs(
-      java.lang.String key) {
-    if (key == null) { throw new NullPointerException("map key"); }
-    return internalGetOutputs().getMap().containsKey(key);
-  }
-  /**
-   * Use {@link #getOutputsMap()} instead.
-   */
-  @java.lang.Override
-  @java.lang.Deprecated
-  public java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> getOutputs() {
-    return getOutputsMap();
-  }
-  /**
-   * <pre>
-   * The values the run ended with: an action's outputs, a state machine's final
-   * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-   * </pre>
-   *
-   * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-   */
-  @java.lang.Override
-  public java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> getOutputsMap() {
-    return internalGetOutputs().getMap();
-  }
-  /**
-   * <pre>
-   * The values the run ended with: an action's outputs, a state machine's final
-   * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-   * </pre>
-   *
-   * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-   */
-  @java.lang.Override
-  public /* nullable */
-org.openmbee.opensysml.proto.Value getOutputsOrDefault(
-      java.lang.String key,
-      /* nullable */
-org.openmbee.opensysml.proto.Value defaultValue) {
-    if (key == null) { throw new NullPointerException("map key"); }
-    java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> map =
-        internalGetOutputs().getMap();
-    return map.containsKey(key) ? map.get(key) : defaultValue;
-  }
-  /**
-   * <pre>
-   * The values the run ended with: an action's outputs, a state machine's final
-   * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-   * </pre>
-   *
-   * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.Value getOutputsOrThrow(
-      java.lang.String key) {
-    if (key == null) { throw new NullPointerException("map key"); }
-    java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> map =
-        internalGetOutputs().getMap();
-    if (!map.containsKey(key)) {
-      throw new java.lang.IllegalArgumentException();
-    }
-    return map.get(key);
-  }
-
-  public static final int FINAL_STATE_FIELD_NUMBER = 2;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object finalState_ = "";
-  /**
-   * <pre>
-   * The state a machine rests in and the states it entered, in order; both
-   * empty for an action or a case.
-   * </pre>
-   *
-   * <code>string final_state = 2 [json_name = "finalState"];</code>
-   * @return The finalState.
-   */
-  @java.lang.Override
-  public java.lang.String getFinalState() {
-    java.lang.Object ref = finalState_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      finalState_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * The state a machine rests in and the states it entered, in order; both
-   * empty for an action or a case.
-   * </pre>
-   *
-   * <code>string final_state = 2 [json_name = "finalState"];</code>
-   * @return The bytes for finalState.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getFinalStateBytes() {
-    java.lang.Object ref = finalState_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      finalState_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int STATES_VISITED_FIELD_NUMBER = 3;
-  @SuppressWarnings("serial")
-  private com.google.protobuf.LazyStringArrayList statesVisited_ =
-      com.google.protobuf.LazyStringArrayList.emptyList();
-  /**
-   * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-   * @return A list containing the statesVisited.
-   */
-  public com.google.protobuf.ProtocolStringList
-      getStatesVisitedList() {
-    return statesVisited_;
-  }
-  /**
-   * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-   * @return The count of statesVisited.
-   */
-  public int getStatesVisitedCount() {
-    return statesVisited_.size();
-  }
-  /**
-   * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-   * @param index The index of the element to return.
-   * @return The statesVisited at the given index.
-   */
-  public java.lang.String getStatesVisited(int index) {
-    return statesVisited_.get(index);
-  }
-  /**
-   * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-   * @param index The index of the value to return.
-   * @return The bytes of the statesVisited at the given index.
-   */
-  public com.google.protobuf.ByteString
-      getStatesVisitedBytes(int index) {
-    return statesVisited_.getByteString(index);
-  }
-
-  public static final int ERROR_FIELD_NUMBER = 4;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object error_ = "";
-  /**
-   * <pre>
-   * What the runs reaching this outcome failed with; empty for one they completed.
-   * </pre>
-   *
-   * <code>string error = 4 [json_name = "error"];</code>
-   * @return The error.
-   */
-  @java.lang.Override
-  public java.lang.String getError() {
-    java.lang.Object ref = error_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      error_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * What the runs reaching this outcome failed with; empty for one they completed.
-   * </pre>
-   *
-   * <code>string error = 4 [json_name = "error"];</code>
-   * @return The bytes for error.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getErrorBytes() {
-    java.lang.Object ref = error_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      error_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int LINEARIZATIONS_FIELD_NUMBER = 5;
-  private int linearizations_ = 0;
-  /**
-   * <pre>
-   * How many linearizations within the budget reached this outcome.
-   * </pre>
-   *
-   * <code>int32 linearizations = 5 [json_name = "linearizations"];</code>
-   * @return The linearizations.
-   */
-  @java.lang.Override
-  public int getLinearizations() {
-    return linearizations_;
-  }
-
-  public static final int WITNESS_FIELD_NUMBER = 6;
-  @SuppressWarnings("serial")
-  private com.google.protobuf.LazyStringArrayList witness_ =
-      com.google.protobuf.LazyStringArrayList.emptyList();
-  /**
-   * <pre>
-   * One run's choice sequence in run order, one entry per choice point it
-   * resolved, each spelling the alternatives and the one taken. Empty when the
-   * run faced no choice point.
-   * </pre>
-   *
-   * <code>repeated string witness = 6 [json_name = "witness"];</code>
-   * @return A list containing the witness.
-   */
-  public com.google.protobuf.ProtocolStringList
-      getWitnessList() {
-    return witness_;
-  }
-  /**
-   * <pre>
-   * One run's choice sequence in run order, one entry per choice point it
-   * resolved, each spelling the alternatives and the one taken. Empty when the
-   * run faced no choice point.
-   * </pre>
-   *
-   * <code>repeated string witness = 6 [json_name = "witness"];</code>
-   * @return The count of witness.
-   */
-  public int getWitnessCount() {
-    return witness_.size();
-  }
-  /**
-   * <pre>
-   * One run's choice sequence in run order, one entry per choice point it
-   * resolved, each spelling the alternatives and the one taken. Empty when the
-   * run faced no choice point.
-   * </pre>
-   *
-   * <code>repeated string witness = 6 [json_name = "witness"];</code>
-   * @param index The index of the element to return.
-   * @return The witness at the given index.
-   */
-  public java.lang.String getWitness(int index) {
-    return witness_.get(index);
-  }
-  /**
-   * <pre>
-   * One run's choice sequence in run order, one entry per choice point it
-   * resolved, each spelling the alternatives and the one taken. Empty when the
-   * run faced no choice point.
-   * </pre>
-   *
-   * <code>repeated string witness = 6 [json_name = "witness"];</code>
-   * @param index The index of the value to return.
-   * @return The bytes of the witness at the given index.
-   */
-  public com.google.protobuf.ByteString
-      getWitnessBytes(int index) {
-    return witness_.getByteString(index);
-  }
-
-  public static final int DIAGNOSTICS_FIELD_NUMBER = 7;
-  @SuppressWarnings("serial")
-  private java.util.List<org.openmbee.opensysml.proto.Diagnostic> diagnostics_;
-  /**
-   * <pre>
-   * What the witness run noted about itself: its choice points and the guards
-   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-   * one run.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-   */
-  @java.lang.Override
-  public java.util.List<org.openmbee.opensysml.proto.Diagnostic> getDiagnosticsList() {
-    return diagnostics_;
-  }
-  /**
-   * <pre>
-   * What the witness run noted about itself: its choice points and the guards
-   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-   * one run.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-   */
-  @java.lang.Override
-  public java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
-      getDiagnosticsOrBuilderList() {
-    return diagnostics_;
-  }
-  /**
-   * <pre>
-   * What the witness run noted about itself: its choice points and the guards
-   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-   * one run.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-   */
-  @java.lang.Override
-  public int getDiagnosticsCount() {
-    return diagnostics_.size();
-  }
-  /**
-   * <pre>
-   * What the witness run noted about itself: its choice points and the guards
-   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-   * one run.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index) {
-    return diagnostics_.get(index);
-  }
-  /**
-   * <pre>
-   * What the witness run noted about itself: its choice points and the guards
-   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-   * one run.
-   * </pre>
-   *
-   * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
-      int index) {
-    return diagnostics_.get(index);
-  }
-
-  private byte memoizedIsInitialized = -1;
-  @java.lang.Override
-  public final boolean isInitialized() {
-    byte isInitialized = memoizedIsInitialized;
-    if (isInitialized == 1) return true;
-    if (isInitialized == 0) return false;
-
-    memoizedIsInitialized = 1;
-    return true;
-  }
-
-  @java.lang.Override
-  public void writeTo(com.google.protobuf.CodedOutputStream output)
-                      throws java.io.IOException {
-    com.google.protobuf.GeneratedMessage
-      .serializeStringMapTo(
-        output,
-        internalGetOutputs(),
-        OutputsDefaultEntryHolder.defaultEntry,
-        1);
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(finalState_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 2, finalState_);
-    }
-    for (int i = 0; i < statesVisited_.size(); i++) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 3, statesVisited_.getRaw(i));
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 4, error_);
-    }
-    if (linearizations_ != 0) {
-      output.writeInt32(5, linearizations_);
-    }
-    for (int i = 0; i < witness_.size(); i++) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 6, witness_.getRaw(i));
-    }
-    for (int i = 0; i < diagnostics_.size(); i++) {
-      output.writeMessage(7, diagnostics_.get(i));
-    }
-    getUnknownFields().writeTo(output);
-  }
-
-  @java.lang.Override
-  public int getSerializedSize() {
-    int size = memoizedSize;
-    if (size != -1) return size;
-
-    size = 0;
-    for (java.util.Map.Entry<java.lang.String, org.openmbee.opensysml.proto.Value> entry
-         : internalGetOutputs().getMap().entrySet()) {
-      com.google.protobuf.MapEntry<java.lang.String, org.openmbee.opensysml.proto.Value>
-      outputs__ = OutputsDefaultEntryHolder.defaultEntry.newBuilderForType()
-          .setKey(entry.getKey())
-          .setValue(entry.getValue())
-          .build();
-      size += com.google.protobuf.CodedOutputStream
-          .computeMessageSize(1, outputs__);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(finalState_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, finalState_);
-    }
-    {
-      int dataSize = 0;
-      for (int i = 0; i < statesVisited_.size(); i++) {
-        dataSize += computeStringSizeNoTag(statesVisited_.getRaw(i));
-      }
-      size += dataSize;
-      size += 1 * getStatesVisitedList().size();
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(4, error_);
-    }
-    if (linearizations_ != 0) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeInt32Size(5, linearizations_);
-    }
-    {
-      int dataSize = 0;
-      for (int i = 0; i < witness_.size(); i++) {
-        dataSize += computeStringSizeNoTag(witness_.getRaw(i));
-      }
-      size += dataSize;
-      size += 1 * getWitnessList().size();
-    }
-    for (int i = 0; i < diagnostics_.size(); i++) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeMessageSize(7, diagnostics_.get(i));
-    }
-    size += getUnknownFields().getSerializedSize();
-    memoizedSize = size;
-    return size;
-  }
-
-  @java.lang.Override
-  public boolean equals(final java.lang.Object obj) {
-    if (obj == this) {
-     return true;
-    }
-    if (!(obj instanceof org.openmbee.opensysml.proto.Outcome)) {
-      return super.equals(obj);
-    }
-    org.openmbee.opensysml.proto.Outcome other = (org.openmbee.opensysml.proto.Outcome) obj;
-
-    if (!internalGetOutputs().equals(
-        other.internalGetOutputs())) return false;
-    if (!getFinalState()
-        .equals(other.getFinalState())) return false;
-    if (!getStatesVisitedList()
-        .equals(other.getStatesVisitedList())) return false;
-    if (!getError()
-        .equals(other.getError())) return false;
-    if (getLinearizations()
-        != other.getLinearizations()) return false;
-    if (!getWitnessList()
-        .equals(other.getWitnessList())) return false;
-    if (!getDiagnosticsList()
-        .equals(other.getDiagnosticsList())) return false;
-    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
-    return true;
-  }
-
-  @java.lang.Override
-  public int hashCode() {
-    if (memoizedHashCode != 0) {
-      return memoizedHashCode;
-    }
-    int hash = 41;
-    hash = (19 * hash) + getDescriptor().hashCode();
-    if (!internalGetOutputs().getMap().isEmpty()) {
-      hash = (37 * hash) + OUTPUTS_FIELD_NUMBER;
-      hash = (53 * hash) + internalGetOutputs().hashCode();
-    }
-    hash = (37 * hash) + FINAL_STATE_FIELD_NUMBER;
-    hash = (53 * hash) + getFinalState().hashCode();
-    if (getStatesVisitedCount() > 0) {
-      hash = (37 * hash) + STATES_VISITED_FIELD_NUMBER;
-      hash = (53 * hash) + getStatesVisitedList().hashCode();
-    }
-    hash = (37 * hash) + ERROR_FIELD_NUMBER;
-    hash = (53 * hash) + getError().hashCode();
-    hash = (37 * hash) + LINEARIZATIONS_FIELD_NUMBER;
-    hash = (53 * hash) + getLinearizations();
-    if (getWitnessCount() > 0) {
-      hash = (37 * hash) + WITNESS_FIELD_NUMBER;
-      hash = (53 * hash) + getWitnessList().hashCode();
-    }
-    if (getDiagnosticsCount() > 0) {
-      hash = (37 * hash) + DIAGNOSTICS_FIELD_NUMBER;
-      hash = (53 * hash) + getDiagnosticsList().hashCode();
-    }
-    hash = (29 * hash) + getUnknownFields().hashCode();
-    memoizedHashCode = hash;
-    return hash;
-  }
-
-  public static org.openmbee.opensysml.proto.Outcome parseFrom(
-      java.nio.ByteBuffer data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.Outcome parseFrom(
-      java.nio.ByteBuffer data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.Outcome parseFrom(
-      com.google.protobuf.ByteString data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.Outcome parseFrom(
-      com.google.protobuf.ByteString data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.Outcome parseFrom(byte[] data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.Outcome parseFrom(
-      byte[] data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.Outcome parseFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.Outcome parseFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  public static org.openmbee.opensysml.proto.Outcome parseDelimitedFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input);
-  }
-
-  public static org.openmbee.opensysml.proto.Outcome parseDelimitedFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.Outcome parseFrom(
-      com.google.protobuf.CodedInputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.Outcome parseFrom(
-      com.google.protobuf.CodedInputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  @java.lang.Override
-  public Builder newBuilderForType() { return newBuilder(); }
-  public static Builder newBuilder() {
-    return DEFAULT_INSTANCE.toBuilder();
-  }
-  public static Builder newBuilder(org.openmbee.opensysml.proto.Outcome prototype) {
-    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
-  }
-  @java.lang.Override
-  public Builder toBuilder() {
-    return this == DEFAULT_INSTANCE
-        ? new Builder() : new Builder().mergeFrom(this);
-  }
-
-  @java.lang.Override
-  protected Builder newBuilderForType(
-      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-    Builder builder = new Builder(parent);
-    return builder;
-  }
-  /**
-   * <pre>
-   * Outcome is one distinct outcome an exploration reached: the observables a
-   * conformance case compares, how many linearizations reached it, and the choice
-   * sequence of one run that did. Two runs agreeing on their observables are one
-   * outcome.
-   * </pre>
-   *
-   * Protobuf type {@code sysml.Outcome}
-   */
-  public static final class Builder extends
-      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
-      // @@protoc_insertion_point(builder_implements:sysml.Outcome)
-      org.openmbee.opensysml.proto.OutcomeOrBuilder {
-    public static final com.google.protobuf.Descriptors.Descriptor
-        getDescriptor() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_descriptor;
-    }
-
-    @SuppressWarnings({"rawtypes"})
-    protected com.google.protobuf.MapFieldReflectionAccessor internalGetMapFieldReflection(
-        int number) {
-      switch (number) {
-        case 1:
-          return internalGetOutputs();
-        default:
-          throw new RuntimeException(
-              "Invalid map field number: " + number);
-      }
-    }
-    @SuppressWarnings({"rawtypes"})
-    protected com.google.protobuf.MapFieldReflectionAccessor internalGetMutableMapFieldReflection(
-        int number) {
-      switch (number) {
-        case 1:
-          return internalGetMutableOutputs();
-        default:
-          throw new RuntimeException(
-              "Invalid map field number: " + number);
-      }
-    }
-    @java.lang.Override
-    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-        internalGetFieldAccessorTable() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_fieldAccessorTable
-          .ensureFieldAccessorsInitialized(
-              org.openmbee.opensysml.proto.Outcome.class, org.openmbee.opensysml.proto.Outcome.Builder.class);
-    }
-
-    // Construct using org.openmbee.opensysml.proto.Outcome.newBuilder()
-    private Builder() {
-
-    }
-
-    private Builder(
-        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-      super(parent);
-
-    }
-    @java.lang.Override
-    public Builder clear() {
-      super.clear();
-      bitField0_ = 0;
-      internalGetMutableOutputs().clear();
-      finalState_ = "";
-      statesVisited_ =
-          com.google.protobuf.LazyStringArrayList.emptyList();
-      error_ = "";
-      linearizations_ = 0;
-      witness_ =
-          com.google.protobuf.LazyStringArrayList.emptyList();
-      if (diagnosticsBuilder_ == null) {
-        diagnostics_ = java.util.Collections.emptyList();
-      } else {
-        diagnostics_ = null;
-        diagnosticsBuilder_.clear();
-      }
-      bitField0_ = (bitField0_ & ~0x00000040);
-      return this;
-    }
-
-    @java.lang.Override
-    public com.google.protobuf.Descriptors.Descriptor
-        getDescriptorForType() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Outcome_descriptor;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.Outcome getDefaultInstanceForType() {
-      return org.openmbee.opensysml.proto.Outcome.getDefaultInstance();
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.Outcome build() {
-      org.openmbee.opensysml.proto.Outcome result = buildPartial();
-      if (!result.isInitialized()) {
-        throw newUninitializedMessageException(result);
-      }
-      return result;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.Outcome buildPartial() {
-      org.openmbee.opensysml.proto.Outcome result = new org.openmbee.opensysml.proto.Outcome(this);
-      buildPartialRepeatedFields(result);
-      if (bitField0_ != 0) { buildPartial0(result); }
-      onBuilt();
-      return result;
-    }
-
-    private void buildPartialRepeatedFields(org.openmbee.opensysml.proto.Outcome result) {
-      if (diagnosticsBuilder_ == null) {
-        if (((bitField0_ & 0x00000040) != 0)) {
-          diagnostics_ = java.util.Collections.unmodifiableList(diagnostics_);
-          bitField0_ = (bitField0_ & ~0x00000040);
-        }
-        result.diagnostics_ = diagnostics_;
-      } else {
-        result.diagnostics_ = diagnosticsBuilder_.build();
-      }
-    }
-
-    private void buildPartial0(org.openmbee.opensysml.proto.Outcome result) {
-      int from_bitField0_ = bitField0_;
-      if (((from_bitField0_ & 0x00000001) != 0)) {
-        result.outputs_ = internalGetOutputs().build(OutputsDefaultEntryHolder.defaultEntry);
-      }
-      if (((from_bitField0_ & 0x00000002) != 0)) {
-        result.finalState_ = finalState_;
-      }
-      if (((from_bitField0_ & 0x00000004) != 0)) {
-        statesVisited_.makeImmutable();
-        result.statesVisited_ = statesVisited_;
-      }
-      if (((from_bitField0_ & 0x00000008) != 0)) {
-        result.error_ = error_;
-      }
-      if (((from_bitField0_ & 0x00000010) != 0)) {
-        result.linearizations_ = linearizations_;
-      }
-      if (((from_bitField0_ & 0x00000020) != 0)) {
-        witness_.makeImmutable();
-        result.witness_ = witness_;
-      }
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(com.google.protobuf.Message other) {
-      if (other instanceof org.openmbee.opensysml.proto.Outcome) {
-        return mergeFrom((org.openmbee.opensysml.proto.Outcome)other);
-      } else {
-        super.mergeFrom(other);
-        return this;
-      }
-    }
-
-    public Builder mergeFrom(org.openmbee.opensysml.proto.Outcome other) {
-      if (other == org.openmbee.opensysml.proto.Outcome.getDefaultInstance()) return this;
-      internalGetMutableOutputs().mergeFrom(
-          other.internalGetOutputs());
-      bitField0_ |= 0x00000001;
-      if (!other.getFinalState().isEmpty()) {
-        finalState_ = other.finalState_;
-        bitField0_ |= 0x00000002;
-        onChanged();
-      }
-      if (!other.statesVisited_.isEmpty()) {
-        if (statesVisited_.isEmpty()) {
-          statesVisited_ = other.statesVisited_;
-          bitField0_ |= 0x00000004;
-        } else {
-          ensureStatesVisitedIsMutable();
-          statesVisited_.addAll(other.statesVisited_);
-        }
-        onChanged();
-      }
-      if (!other.getError().isEmpty()) {
-        error_ = other.error_;
-        bitField0_ |= 0x00000008;
-        onChanged();
-      }
-      if (other.getLinearizations() != 0) {
-        setLinearizations(other.getLinearizations());
-      }
-      if (!other.witness_.isEmpty()) {
-        if (witness_.isEmpty()) {
-          witness_ = other.witness_;
-          bitField0_ |= 0x00000020;
-        } else {
-          ensureWitnessIsMutable();
-          witness_.addAll(other.witness_);
-        }
-        onChanged();
-      }
-      if (diagnosticsBuilder_ == null) {
-        if (!other.diagnostics_.isEmpty()) {
-          if (diagnostics_.isEmpty()) {
-            diagnostics_ = other.diagnostics_;
-            bitField0_ = (bitField0_ & ~0x00000040);
-          } else {
-            ensureDiagnosticsIsMutable();
-            diagnostics_.addAll(other.diagnostics_);
-          }
-          onChanged();
-        }
-      } else {
-        if (!other.diagnostics_.isEmpty()) {
-          if (diagnosticsBuilder_.isEmpty()) {
-            diagnosticsBuilder_.dispose();
-            diagnosticsBuilder_ = null;
-            diagnostics_ = other.diagnostics_;
-            bitField0_ = (bitField0_ & ~0x00000040);
-            diagnosticsBuilder_ = 
-              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
-                 internalGetDiagnosticsFieldBuilder() : null;
-          } else {
-            diagnosticsBuilder_.addAllMessages(other.diagnostics_);
-          }
-        }
-      }
-      this.mergeUnknownFields(other.getUnknownFields());
-      onChanged();
-      return this;
-    }
-
-    @java.lang.Override
-    public final boolean isInitialized() {
-      return true;
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws java.io.IOException {
-      if (extensionRegistry == null) {
-        throw new java.lang.NullPointerException();
-      }
-      try {
-        boolean done = false;
-        while (!done) {
-          int tag = input.readTag();
-          switch (tag) {
-            case 0:
-              done = true;
-              break;
-            case 10: {
-              com.google.protobuf.MapEntry<java.lang.String, org.openmbee.opensysml.proto.Value>
-              outputs__ = input.readMessage(
-                  OutputsDefaultEntryHolder.defaultEntry.getParserForType(), extensionRegistry);
-              internalGetMutableOutputs().ensureBuilderMap().put(
-                  outputs__.getKey(), outputs__.getValue());
-              bitField0_ |= 0x00000001;
-              break;
-            } // case 10
-            case 18: {
-              finalState_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000002;
-              break;
-            } // case 18
-            case 26: {
-              java.lang.String s = input.readStringRequireUtf8();
-              ensureStatesVisitedIsMutable();
-              statesVisited_.add(s);
-              break;
-            } // case 26
-            case 34: {
-              error_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000008;
-              break;
-            } // case 34
-            case 40: {
-              linearizations_ = input.readInt32();
-              bitField0_ |= 0x00000010;
-              break;
-            } // case 40
-            case 50: {
-              java.lang.String s = input.readStringRequireUtf8();
-              ensureWitnessIsMutable();
-              witness_.add(s);
-              break;
-            } // case 50
-            case 58: {
-              org.openmbee.opensysml.proto.Diagnostic m =
-                  input.readMessage(
-                      org.openmbee.opensysml.proto.Diagnostic.parser(),
-                      extensionRegistry);
-              if (diagnosticsBuilder_ == null) {
-                ensureDiagnosticsIsMutable();
-                diagnostics_.add(m);
-              } else {
-                diagnosticsBuilder_.addMessage(m);
-              }
-              break;
-            } // case 58
-            default: {
-              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
-                done = true; // was an endgroup tag
-              }
-              break;
-            } // default:
-          } // switch (tag)
-        } // while (!done)
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.unwrapIOException();
-      } finally {
-        onChanged();
-      } // finally
-      return this;
-    }
-    private int bitField0_;
-
-    private static final class OutputsConverter implements com.google.protobuf.MapFieldBuilder.Converter<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder, org.openmbee.opensysml.proto.Value> {
-      @java.lang.Override
-      public org.openmbee.opensysml.proto.Value build(org.openmbee.opensysml.proto.ValueOrBuilder val) {
-        if (val instanceof org.openmbee.opensysml.proto.Value) { return (org.openmbee.opensysml.proto.Value) val; }
-        return ((org.openmbee.opensysml.proto.Value.Builder) val).build();
-      }
-
-      @java.lang.Override
-      public com.google.protobuf.MapEntry<java.lang.String, org.openmbee.opensysml.proto.Value> defaultEntry() {
-        return OutputsDefaultEntryHolder.defaultEntry;
-      }
-    };
-    private static final OutputsConverter outputsConverter = new OutputsConverter();
-
-    private com.google.protobuf.MapFieldBuilder<
-        java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder, org.openmbee.opensysml.proto.Value, org.openmbee.opensysml.proto.Value.Builder> outputs_;
-    private com.google.protobuf.MapFieldBuilder<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder, org.openmbee.opensysml.proto.Value, org.openmbee.opensysml.proto.Value.Builder>
-        internalGetOutputs() {
-      if (outputs_ == null) {
-        return new com.google.protobuf.MapFieldBuilder<>(outputsConverter);
-      }
-      return outputs_;
-    }
-    private com.google.protobuf.MapFieldBuilder<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder, org.openmbee.opensysml.proto.Value, org.openmbee.opensysml.proto.Value.Builder>
-        internalGetMutableOutputs() {
-      if (outputs_ == null) {
-        outputs_ = new com.google.protobuf.MapFieldBuilder<>(outputsConverter);
-      }
-      bitField0_ |= 0x00000001;
-      onChanged();
-      return outputs_;
-    }
-    public int getOutputsCount() {
-      return internalGetOutputs().ensureBuilderMap().size();
-    }
-    /**
-     * <pre>
-     * The values the run ended with: an action's outputs, a state machine's final
-     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-     * </pre>
-     *
-     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-     */
-    @java.lang.Override
-    public boolean containsOutputs(
-        java.lang.String key) {
-      if (key == null) { throw new NullPointerException("map key"); }
-      return internalGetOutputs().ensureBuilderMap().containsKey(key);
-    }
-    /**
-     * Use {@link #getOutputsMap()} instead.
-     */
-    @java.lang.Override
-    @java.lang.Deprecated
-    public java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> getOutputs() {
-      return getOutputsMap();
-    }
-    /**
-     * <pre>
-     * The values the run ended with: an action's outputs, a state machine's final
-     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-     * </pre>
-     *
-     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-     */
-    @java.lang.Override
-    public java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> getOutputsMap() {
-      return internalGetOutputs().getImmutableMap();
-    }
-    /**
-     * <pre>
-     * The values the run ended with: an action's outputs, a state machine's final
-     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-     * </pre>
-     *
-     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-     */
-    @java.lang.Override
-    public /* nullable */
-org.openmbee.opensysml.proto.Value getOutputsOrDefault(
-        java.lang.String key,
-        /* nullable */
-org.openmbee.opensysml.proto.Value defaultValue) {
-      if (key == null) { throw new NullPointerException("map key"); }
-      java.util.Map<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder> map = internalGetMutableOutputs().ensureBuilderMap();
-      return map.containsKey(key) ? outputsConverter.build(map.get(key)) : defaultValue;
-    }
-    /**
-     * <pre>
-     * The values the run ended with: an action's outputs, a state machine's final
-     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-     * </pre>
-     *
-     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-     */
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.Value getOutputsOrThrow(
-        java.lang.String key) {
-      if (key == null) { throw new NullPointerException("map key"); }
-      java.util.Map<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder> map = internalGetMutableOutputs().ensureBuilderMap();
-      if (!map.containsKey(key)) {
-        throw new java.lang.IllegalArgumentException();
-      }
-      return outputsConverter.build(map.get(key));
-    }
-    public Builder clearOutputs() {
-      bitField0_ = (bitField0_ & ~0x00000001);
-      internalGetMutableOutputs().clear();
-      return this;
-    }
-    /**
-     * <pre>
-     * The values the run ended with: an action's outputs, a state machine's final
-     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-     * </pre>
-     *
-     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-     */
-    public Builder removeOutputs(
-        java.lang.String key) {
-      if (key == null) { throw new NullPointerException("map key"); }
-      internalGetMutableOutputs().ensureBuilderMap()
-          .remove(key);
-      return this;
-    }
-    /**
-     * Use alternate mutation accessors instead.
-     */
-    @java.lang.Deprecated
-    public java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value>
-        getMutableOutputs() {
-      bitField0_ |= 0x00000001;
-      return internalGetMutableOutputs().ensureMessageMap();
-    }
-    /**
-     * <pre>
-     * The values the run ended with: an action's outputs, a state machine's final
-     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-     * </pre>
-     *
-     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-     */
-    public Builder putOutputs(
-        java.lang.String key,
-        org.openmbee.opensysml.proto.Value value) {
-      if (key == null) { throw new NullPointerException("map key"); }
-      if (value == null) { throw new NullPointerException("map value"); }
-      internalGetMutableOutputs().ensureBuilderMap()
-          .put(key, value);
-      bitField0_ |= 0x00000001;
-      return this;
-    }
-    /**
-     * <pre>
-     * The values the run ended with: an action's outputs, a state machine's final
-     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-     * </pre>
-     *
-     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-     */
-    public Builder putAllOutputs(
-        java.util.Map<java.lang.String, org.openmbee.opensysml.proto.Value> values) {
-      for (java.util.Map.Entry<java.lang.String, org.openmbee.opensysml.proto.Value> e : values.entrySet()) {
-        if (e.getKey() == null || e.getValue() == null) {
-          throw new NullPointerException();
-        }
-      }
-      internalGetMutableOutputs().ensureBuilderMap()
-          .putAll(values);
-      bitField0_ |= 0x00000001;
-      return this;
-    }
-    /**
-     * <pre>
-     * The values the run ended with: an action's outputs, a state machine's final
-     * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-     * </pre>
-     *
-     * <code>map<string, .sysml.Value> outputs = 1 [json_name = "outputs"];</code>
-     */
-    public org.openmbee.opensysml.proto.Value.Builder putOutputsBuilderIfAbsent(
-        java.lang.String key) {
-      java.util.Map<java.lang.String, org.openmbee.opensysml.proto.ValueOrBuilder> builderMap = internalGetMutableOutputs().ensureBuilderMap();
-      org.openmbee.opensysml.proto.ValueOrBuilder entry = builderMap.get(key);
-      if (entry == null) {
-        entry = org.openmbee.opensysml.proto.Value.newBuilder();
-        builderMap.put(key, entry);
-      }
-      if (entry instanceof org.openmbee.opensysml.proto.Value) {
-        entry = ((org.openmbee.opensysml.proto.Value) entry).toBuilder();
-        builderMap.put(key, entry);
-      }
-      return (org.openmbee.opensysml.proto.Value.Builder) entry;
-    }
-
-    private java.lang.Object finalState_ = "";
-    /**
-     * <pre>
-     * The state a machine rests in and the states it entered, in order; both
-     * empty for an action or a case.
-     * </pre>
-     *
-     * <code>string final_state = 2 [json_name = "finalState"];</code>
-     * @return The finalState.
-     */
-    public java.lang.String getFinalState() {
-      java.lang.Object ref = finalState_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        finalState_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The state a machine rests in and the states it entered, in order; both
-     * empty for an action or a case.
-     * </pre>
-     *
-     * <code>string final_state = 2 [json_name = "finalState"];</code>
-     * @return The bytes for finalState.
-     */
-    public com.google.protobuf.ByteString
-        getFinalStateBytes() {
-      java.lang.Object ref = finalState_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        finalState_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The state a machine rests in and the states it entered, in order; both
-     * empty for an action or a case.
-     * </pre>
-     *
-     * <code>string final_state = 2 [json_name = "finalState"];</code>
-     * @param value The finalState to set.
-     * @return This builder for chaining.
-     */
-    public Builder setFinalState(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      finalState_ = value;
-      bitField0_ |= 0x00000002;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The state a machine rests in and the states it entered, in order; both
-     * empty for an action or a case.
-     * </pre>
-     *
-     * <code>string final_state = 2 [json_name = "finalState"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearFinalState() {
-      finalState_ = getDefaultInstance().getFinalState();
-      bitField0_ = (bitField0_ & ~0x00000002);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The state a machine rests in and the states it entered, in order; both
-     * empty for an action or a case.
-     * </pre>
-     *
-     * <code>string final_state = 2 [json_name = "finalState"];</code>
-     * @param value The bytes for finalState to set.
-     * @return This builder for chaining.
-     */
-    public Builder setFinalStateBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      finalState_ = value;
-      bitField0_ |= 0x00000002;
-      onChanged();
-      return this;
-    }
-
-    private com.google.protobuf.LazyStringArrayList statesVisited_ =
-        com.google.protobuf.LazyStringArrayList.emptyList();
-    private void ensureStatesVisitedIsMutable() {
-      if (!statesVisited_.isModifiable()) {
-        statesVisited_ = new com.google.protobuf.LazyStringArrayList(statesVisited_);
-      }
-      bitField0_ |= 0x00000004;
-    }
-    /**
-     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-     * @return A list containing the statesVisited.
-     */
-    public com.google.protobuf.ProtocolStringList
-        getStatesVisitedList() {
-      statesVisited_.makeImmutable();
-      return statesVisited_;
-    }
-    /**
-     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-     * @return The count of statesVisited.
-     */
-    public int getStatesVisitedCount() {
-      return statesVisited_.size();
-    }
-    /**
-     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-     * @param index The index of the element to return.
-     * @return The statesVisited at the given index.
-     */
-    public java.lang.String getStatesVisited(int index) {
-      return statesVisited_.get(index);
-    }
-    /**
-     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-     * @param index The index of the value to return.
-     * @return The bytes of the statesVisited at the given index.
-     */
-    public com.google.protobuf.ByteString
-        getStatesVisitedBytes(int index) {
-      return statesVisited_.getByteString(index);
-    }
-    /**
-     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-     * @param index The index to set the value at.
-     * @param value The statesVisited to set.
-     * @return This builder for chaining.
-     */
-    public Builder setStatesVisited(
-        int index, java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      ensureStatesVisitedIsMutable();
-      statesVisited_.set(index, value);
-      bitField0_ |= 0x00000004;
-      onChanged();
-      return this;
-    }
-    /**
-     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-     * @param value The statesVisited to add.
-     * @return This builder for chaining.
-     */
-    public Builder addStatesVisited(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      ensureStatesVisitedIsMutable();
-      statesVisited_.add(value);
-      bitField0_ |= 0x00000004;
-      onChanged();
-      return this;
-    }
-    /**
-     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-     * @param values The statesVisited to add.
-     * @return This builder for chaining.
-     */
-    public Builder addAllStatesVisited(
-        java.lang.Iterable<java.lang.String> values) {
-      ensureStatesVisitedIsMutable();
-      com.google.protobuf.AbstractMessageLite.Builder.addAll(
-          values, statesVisited_);
-      bitField0_ |= 0x00000004;
-      onChanged();
-      return this;
-    }
-    /**
-     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearStatesVisited() {
-      statesVisited_ =
-        com.google.protobuf.LazyStringArrayList.emptyList();
-      bitField0_ = (bitField0_ & ~0x00000004);;
-      onChanged();
-      return this;
-    }
-    /**
-     * <code>repeated string states_visited = 3 [json_name = "statesVisited"];</code>
-     * @param value The bytes of the statesVisited to add.
-     * @return This builder for chaining.
-     */
-    public Builder addStatesVisitedBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      ensureStatesVisitedIsMutable();
-      statesVisited_.add(value);
-      bitField0_ |= 0x00000004;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object error_ = "";
-    /**
-     * <pre>
-     * What the runs reaching this outcome failed with; empty for one they completed.
-     * </pre>
-     *
-     * <code>string error = 4 [json_name = "error"];</code>
-     * @return The error.
-     */
-    public java.lang.String getError() {
-      java.lang.Object ref = error_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        error_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * What the runs reaching this outcome failed with; empty for one they completed.
-     * </pre>
-     *
-     * <code>string error = 4 [json_name = "error"];</code>
-     * @return The bytes for error.
-     */
-    public com.google.protobuf.ByteString
-        getErrorBytes() {
-      java.lang.Object ref = error_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        error_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * What the runs reaching this outcome failed with; empty for one they completed.
-     * </pre>
-     *
-     * <code>string error = 4 [json_name = "error"];</code>
-     * @param value The error to set.
-     * @return This builder for chaining.
-     */
-    public Builder setError(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      error_ = value;
-      bitField0_ |= 0x00000008;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * What the runs reaching this outcome failed with; empty for one they completed.
-     * </pre>
-     *
-     * <code>string error = 4 [json_name = "error"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearError() {
-      error_ = getDefaultInstance().getError();
-      bitField0_ = (bitField0_ & ~0x00000008);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * What the runs reaching this outcome failed with; empty for one they completed.
-     * </pre>
-     *
-     * <code>string error = 4 [json_name = "error"];</code>
-     * @param value The bytes for error to set.
-     * @return This builder for chaining.
-     */
-    public Builder setErrorBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      error_ = value;
-      bitField0_ |= 0x00000008;
-      onChanged();
-      return this;
-    }
-
-    private int linearizations_ ;
-    /**
-     * <pre>
-     * How many linearizations within the budget reached this outcome.
-     * </pre>
-     *
-     * <code>int32 linearizations = 5 [json_name = "linearizations"];</code>
-     * @return The linearizations.
-     */
-    @java.lang.Override
-    public int getLinearizations() {
-      return linearizations_;
-    }
-    /**
-     * <pre>
-     * How many linearizations within the budget reached this outcome.
-     * </pre>
-     *
-     * <code>int32 linearizations = 5 [json_name = "linearizations"];</code>
-     * @param value The linearizations to set.
-     * @return This builder for chaining.
-     */
-    public Builder setLinearizations(int value) {
-
-      linearizations_ = value;
-      bitField0_ |= 0x00000010;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * How many linearizations within the budget reached this outcome.
-     * </pre>
-     *
-     * <code>int32 linearizations = 5 [json_name = "linearizations"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearLinearizations() {
-      bitField0_ = (bitField0_ & ~0x00000010);
-      linearizations_ = 0;
-      onChanged();
-      return this;
-    }
-
-    private com.google.protobuf.LazyStringArrayList witness_ =
-        com.google.protobuf.LazyStringArrayList.emptyList();
-    private void ensureWitnessIsMutable() {
-      if (!witness_.isModifiable()) {
-        witness_ = new com.google.protobuf.LazyStringArrayList(witness_);
-      }
-      bitField0_ |= 0x00000020;
-    }
-    /**
-     * <pre>
-     * One run's choice sequence in run order, one entry per choice point it
-     * resolved, each spelling the alternatives and the one taken. Empty when the
-     * run faced no choice point.
-     * </pre>
-     *
-     * <code>repeated string witness = 6 [json_name = "witness"];</code>
-     * @return A list containing the witness.
-     */
-    public com.google.protobuf.ProtocolStringList
-        getWitnessList() {
-      witness_.makeImmutable();
-      return witness_;
-    }
-    /**
-     * <pre>
-     * One run's choice sequence in run order, one entry per choice point it
-     * resolved, each spelling the alternatives and the one taken. Empty when the
-     * run faced no choice point.
-     * </pre>
-     *
-     * <code>repeated string witness = 6 [json_name = "witness"];</code>
-     * @return The count of witness.
-     */
-    public int getWitnessCount() {
-      return witness_.size();
-    }
-    /**
-     * <pre>
-     * One run's choice sequence in run order, one entry per choice point it
-     * resolved, each spelling the alternatives and the one taken. Empty when the
-     * run faced no choice point.
-     * </pre>
-     *
-     * <code>repeated string witness = 6 [json_name = "witness"];</code>
-     * @param index The index of the element to return.
-     * @return The witness at the given index.
-     */
-    public java.lang.String getWitness(int index) {
-      return witness_.get(index);
-    }
-    /**
-     * <pre>
-     * One run's choice sequence in run order, one entry per choice point it
-     * resolved, each spelling the alternatives and the one taken. Empty when the
-     * run faced no choice point.
-     * </pre>
-     *
-     * <code>repeated string witness = 6 [json_name = "witness"];</code>
-     * @param index The index of the value to return.
-     * @return The bytes of the witness at the given index.
-     */
-    public com.google.protobuf.ByteString
-        getWitnessBytes(int index) {
-      return witness_.getByteString(index);
-    }
-    /**
-     * <pre>
-     * One run's choice sequence in run order, one entry per choice point it
-     * resolved, each spelling the alternatives and the one taken. Empty when the
-     * run faced no choice point.
-     * </pre>
-     *
-     * <code>repeated string witness = 6 [json_name = "witness"];</code>
-     * @param index The index to set the value at.
-     * @param value The witness to set.
-     * @return This builder for chaining.
-     */
-    public Builder setWitness(
-        int index, java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      ensureWitnessIsMutable();
-      witness_.set(index, value);
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * One run's choice sequence in run order, one entry per choice point it
-     * resolved, each spelling the alternatives and the one taken. Empty when the
-     * run faced no choice point.
-     * </pre>
-     *
-     * <code>repeated string witness = 6 [json_name = "witness"];</code>
-     * @param value The witness to add.
-     * @return This builder for chaining.
-     */
-    public Builder addWitness(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      ensureWitnessIsMutable();
-      witness_.add(value);
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * One run's choice sequence in run order, one entry per choice point it
-     * resolved, each spelling the alternatives and the one taken. Empty when the
-     * run faced no choice point.
-     * </pre>
-     *
-     * <code>repeated string witness = 6 [json_name = "witness"];</code>
-     * @param values The witness to add.
-     * @return This builder for chaining.
-     */
-    public Builder addAllWitness(
-        java.lang.Iterable<java.lang.String> values) {
-      ensureWitnessIsMutable();
-      com.google.protobuf.AbstractMessageLite.Builder.addAll(
-          values, witness_);
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * One run's choice sequence in run order, one entry per choice point it
-     * resolved, each spelling the alternatives and the one taken. Empty when the
-     * run faced no choice point.
-     * </pre>
-     *
-     * <code>repeated string witness = 6 [json_name = "witness"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearWitness() {
-      witness_ =
-        com.google.protobuf.LazyStringArrayList.emptyList();
-      bitField0_ = (bitField0_ & ~0x00000020);;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * One run's choice sequence in run order, one entry per choice point it
-     * resolved, each spelling the alternatives and the one taken. Empty when the
-     * run faced no choice point.
-     * </pre>
-     *
-     * <code>repeated string witness = 6 [json_name = "witness"];</code>
-     * @param value The bytes of the witness to add.
-     * @return This builder for chaining.
-     */
-    public Builder addWitnessBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      ensureWitnessIsMutable();
-      witness_.add(value);
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-
-    private java.util.List<org.openmbee.opensysml.proto.Diagnostic> diagnostics_ =
-      java.util.Collections.emptyList();
-    private void ensureDiagnosticsIsMutable() {
-      if (!((bitField0_ & 0x00000040) != 0)) {
-        diagnostics_ = new java.util.ArrayList<org.openmbee.opensysml.proto.Diagnostic>(diagnostics_);
-        bitField0_ |= 0x00000040;
-       }
-    }
-
-    private com.google.protobuf.RepeatedFieldBuilder<
-        org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> diagnosticsBuilder_;
-
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public java.util.List<org.openmbee.opensysml.proto.Diagnostic> getDiagnosticsList() {
-      if (diagnosticsBuilder_ == null) {
-        return java.util.Collections.unmodifiableList(diagnostics_);
-      } else {
-        return diagnosticsBuilder_.getMessageList();
-      }
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public int getDiagnosticsCount() {
-      if (diagnosticsBuilder_ == null) {
-        return diagnostics_.size();
-      } else {
-        return diagnosticsBuilder_.getCount();
-      }
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public org.openmbee.opensysml.proto.Diagnostic getDiagnostics(int index) {
-      if (diagnosticsBuilder_ == null) {
-        return diagnostics_.get(index);
-      } else {
-        return diagnosticsBuilder_.getMessage(index);
-      }
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public Builder setDiagnostics(
-        int index, org.openmbee.opensysml.proto.Diagnostic value) {
-      if (diagnosticsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureDiagnosticsIsMutable();
-        diagnostics_.set(index, value);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.setMessage(index, value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public Builder setDiagnostics(
-        int index, org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
-      if (diagnosticsBuilder_ == null) {
-        ensureDiagnosticsIsMutable();
-        diagnostics_.set(index, builderForValue.build());
-        onChanged();
-      } else {
-        diagnosticsBuilder_.setMessage(index, builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public Builder addDiagnostics(org.openmbee.opensysml.proto.Diagnostic value) {
-      if (diagnosticsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureDiagnosticsIsMutable();
-        diagnostics_.add(value);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.addMessage(value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public Builder addDiagnostics(
-        int index, org.openmbee.opensysml.proto.Diagnostic value) {
-      if (diagnosticsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureDiagnosticsIsMutable();
-        diagnostics_.add(index, value);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.addMessage(index, value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public Builder addDiagnostics(
-        org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
-      if (diagnosticsBuilder_ == null) {
-        ensureDiagnosticsIsMutable();
-        diagnostics_.add(builderForValue.build());
-        onChanged();
-      } else {
-        diagnosticsBuilder_.addMessage(builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public Builder addDiagnostics(
-        int index, org.openmbee.opensysml.proto.Diagnostic.Builder builderForValue) {
-      if (diagnosticsBuilder_ == null) {
-        ensureDiagnosticsIsMutable();
-        diagnostics_.add(index, builderForValue.build());
-        onChanged();
-      } else {
-        diagnosticsBuilder_.addMessage(index, builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public Builder addAllDiagnostics(
-        java.lang.Iterable<? extends org.openmbee.opensysml.proto.Diagnostic> values) {
-      if (diagnosticsBuilder_ == null) {
-        ensureDiagnosticsIsMutable();
-        com.google.protobuf.AbstractMessageLite.Builder.addAll(
-            values, diagnostics_);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.addAllMessages(values);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public Builder clearDiagnostics() {
-      if (diagnosticsBuilder_ == null) {
-        diagnostics_ = java.util.Collections.emptyList();
-        bitField0_ = (bitField0_ & ~0x00000040);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.clear();
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public Builder removeDiagnostics(int index) {
-      if (diagnosticsBuilder_ == null) {
-        ensureDiagnosticsIsMutable();
-        diagnostics_.remove(index);
-        onChanged();
-      } else {
-        diagnosticsBuilder_.remove(index);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public org.openmbee.opensysml.proto.Diagnostic.Builder getDiagnosticsBuilder(
-        int index) {
-      return internalGetDiagnosticsFieldBuilder().getBuilder(index);
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
-        int index) {
-      if (diagnosticsBuilder_ == null) {
-        return diagnostics_.get(index);  } else {
-        return diagnosticsBuilder_.getMessageOrBuilder(index);
-      }
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public java.util.List<? extends org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
-         getDiagnosticsOrBuilderList() {
-      if (diagnosticsBuilder_ != null) {
-        return diagnosticsBuilder_.getMessageOrBuilderList();
-      } else {
-        return java.util.Collections.unmodifiableList(diagnostics_);
-      }
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder() {
-      return internalGetDiagnosticsFieldBuilder().addBuilder(
-          org.openmbee.opensysml.proto.Diagnostic.getDefaultInstance());
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder(
-        int index) {
-      return internalGetDiagnosticsFieldBuilder().addBuilder(
-          index, org.openmbee.opensysml.proto.Diagnostic.getDefaultInstance());
-    }
-    /**
-     * <pre>
-     * What the witness run noted about itself: its choice points and the guards
-     * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-     * one run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Diagnostic diagnostics = 7 [json_name = "diagnostics"];</code>
-     */
-    public java.util.List<org.openmbee.opensysml.proto.Diagnostic.Builder> 
-         getDiagnosticsBuilderList() {
-      return internalGetDiagnosticsFieldBuilder().getBuilderList();
-    }
-    private com.google.protobuf.RepeatedFieldBuilder<
-        org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> 
-        internalGetDiagnosticsFieldBuilder() {
-      if (diagnosticsBuilder_ == null) {
-        diagnosticsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
-            org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder>(
-                diagnostics_,
-                ((bitField0_ & 0x00000040) != 0),
-                getParentForChildren(),
-                isClean());
-        diagnostics_ = null;
-      }
-      return diagnosticsBuilder_;
-    }
-
-    // @@protoc_insertion_point(builder_scope:sysml.Outcome)
-  }
-
-  // @@protoc_insertion_point(class_scope:sysml.Outcome)
-  private static final org.openmbee.opensysml.proto.Outcome DEFAULT_INSTANCE;
-  static {
-    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.Outcome();
-  }
-
-  public static org.openmbee.opensysml.proto.Outcome getDefaultInstance() {
-    return DEFAULT_INSTANCE;
-  }
-
-  private static final com.google.protobuf.Parser<Outcome>
-      PARSER = new com.google.protobuf.AbstractParser<Outcome>() {
-    @java.lang.Override
-    public Outcome parsePartialFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws com.google.protobuf.InvalidProtocolBufferException {
-      Builder builder = newBuilder();
-      try {
-        builder.mergeFrom(input, extensionRegistry);
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.setUnfinishedMessage(builder.buildPartial());
-      } catch (com.google.protobuf.UninitializedMessageException e) {
-        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
-      } catch (java.io.IOException e) {
-        throw new com.google.protobuf.InvalidProtocolBufferException(e)
-            .setUnfinishedMessage(builder.buildPartial());
-      }
-      return builder.buildPartial();
-    }
-  };
-
-  public static com.google.protobuf.Parser<Outcome> parser() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public com.google.protobuf.Parser<Outcome> getParserForType() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.Outcome getDefaultInstanceForType() {
-    return DEFAULT_INSTANCE;
-  }
-
-}
-
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequestOrBuilder.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequestOrBuilder.java
deleted file mode 100644
index e0312d6d0c..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentRequestOrBuilder.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-@com.google.protobuf.Generated
-public interface RenderDocumentRequestOrBuilder extends
-    // @@protoc_insertion_point(interface_extends:sysml.RenderDocumentRequest)
-    com.google.protobuf.MessageOrBuilder {
-
-  /**
-   * <pre>
-   * from ParseFile response
-   * </pre>
-   *
-   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-   * @return The modelHash.
-   */
-  java.lang.String getModelHash();
-  /**
-   * <pre>
-   * from ParseFile response
-   * </pre>
-   *
-   * <code>string model_hash = 1 [json_name = "modelHash"];</code>
-   * @return The bytes for modelHash.
-   */
-  com.google.protobuf.ByteString
-      getModelHashBytes();
-
-  /**
-   * <pre>
-   * FQN of the document to render. Fails with NOT_FOUND when the model does
-   * not declare it, and INVALID_ARGUMENT when it declares something else.
-   * </pre>
-   *
-   * <code>string document_id = 2 [json_name = "documentId"];</code>
-   * @return The documentId.
-   */
-  java.lang.String getDocumentId();
-  /**
-   * <pre>
-   * FQN of the document to render. Fails with NOT_FOUND when the model does
-   * not declare it, and INVALID_ARGUMENT when it declares something else.
-   * </pre>
-   *
-   * <code>string document_id = 2 [json_name = "documentId"];</code>
-   * @return The bytes for documentId.
-   */
-  com.google.protobuf.ByteString
-      getDocumentIdBytes();
-}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponseOrBuilder.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponseOrBuilder.java
deleted file mode 100644
index 2c770f294b..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/RenderDocumentResponseOrBuilder.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-@com.google.protobuf.Generated
-public interface RenderDocumentResponseOrBuilder extends
-    // @@protoc_insertion_point(interface_extends:sysml.RenderDocumentResponse)
-    com.google.protobuf.MessageOrBuilder {
-
-  /**
-   * <code>string markdown = 1 [json_name = "markdown"];</code>
-   * @return The markdown.
-   */
-  java.lang.String getMarkdown();
-  /**
-   * <code>string markdown = 1 [json_name = "markdown"];</code>
-   * @return The bytes for markdown.
-   */
-  com.google.protobuf.ByteString
-      getMarkdownBytes();
-}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Sysml.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Sysml.java
deleted file mode 100644
index 1009db61fd..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Sysml.java
+++ /dev/null
@@ -1,1676 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-@com.google.protobuf.Generated
-public final class Sysml extends com.google.protobuf.GeneratedFile {
-  private Sysml() {}
-  static {
-    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
-      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
-      /* major= */ 4,
-      /* minor= */ 33,
-      /* patch= */ 1,
-      /* suffix= */ "",
-      "Sysml");
-  }
-  public static void registerAllExtensions(
-      com.google.protobuf.ExtensionRegistryLite registry) {
-  }
-
-  public static void registerAllExtensions(
-      com.google.protobuf.ExtensionRegistry registry) {
-    registerAllExtensions(
-        (com.google.protobuf.ExtensionRegistryLite) registry);
-  }
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Verdict_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Verdict_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Bound_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Bound_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_VerifyConstraintRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_VerifyConstraintRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_VerifyConstraintResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_VerifyConstraintResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_VerifyRequirementRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_VerifyRequirementRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_VerificationVerdict_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_VerificationVerdict_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_VerifyRequirementResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_VerifyRequirementResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_VerifySatisfactionRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_VerifySatisfactionRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_VerifySatisfactionResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_VerifySatisfactionResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_EvaluateCalcRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_EvaluateCalcRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_EvaluateCalcResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_EvaluateCalcResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_CalcOutput_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_CalcOutput_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_CaseEvaluation_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_CaseEvaluation_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RunAnalysisRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RunAnalysisRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RunAnalysisRequest_NamedArgumentsEntry_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RunAnalysisRequest_NamedArgumentsEntry_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RunAnalysisResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RunAnalysisResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Outcome_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Outcome_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Outcome_OutputsEntry_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Outcome_OutputsEntry_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ExplorationStatus_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ExplorationStatus_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ListEnginesRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ListEnginesRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_EngineInfo_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_EngineInfo_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ListEnginesResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ListEnginesResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ParseFileRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ParseFileRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_SourceDocument_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_SourceDocument_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ParseSourcesRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ParseSourcesRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ParseSourcesResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ParseSourcesResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ParseFileResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ParseFileResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_GetSymbolRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_GetSymbolRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_SymbolResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_SymbolResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_DiagnosticsRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_DiagnosticsRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_DiagnosticsResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_DiagnosticsResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_EvaluateRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_EvaluateRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_EvaluateResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_EvaluateResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Instance_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Instance_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Instance_FeatureValuesEntry_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Instance_FeatureValuesEntry_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_FeatureValue_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_FeatureValue_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_InstantiateRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_InstantiateRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_InstantiateResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_InstantiateResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ExecuteActionRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ExecuteActionRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ExecuteActionRequest_InputsEntry_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ExecuteActionRequest_InputsEntry_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ExecuteActionResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ExecuteActionResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ExecuteActionResponse_OutputsEntry_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ExecuteActionResponse_OutputsEntry_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ExecuteStateRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ExecuteStateRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ExecuteStateResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ExecuteStateResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ExecuteStateResponse_FinalContextEntry_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ExecuteStateResponse_FinalContextEntry_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ConvertRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ConvertRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ConvertResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ConvertResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ApplyEditsRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ApplyEditsRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_EditOperation_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_EditOperation_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_AddMemberEdit_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_AddMemberEdit_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_DeleteEdit_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_DeleteEdit_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_MoveEdit_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_MoveEdit_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_SetValueEdit_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_SetValueEdit_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RenameEdit_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RenameEdit_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ApplyEditsResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ApplyEditsResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_AppliedEdit_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_AppliedEdit_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_SymbolInfo_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_SymbolInfo_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_SymbolInfo_MetadataEntry_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_SymbolInfo_MetadataEntry_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Specialization_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Specialization_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_TypeInfo_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_TypeInfo_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_MultiplicityInfo_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_MultiplicityInfo_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_AttributeInfo_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_AttributeInfo_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Value_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Value_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Metaobject_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Metaobject_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Undetermined_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Undetermined_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Function_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Function_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ValueSet_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ValueSet_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_TensorQuantity_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_TensorQuantity_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Array_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Array_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Vector_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Vector_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_VectorQuantity_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_VectorQuantity_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Complex_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Complex_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_EnumLiteral_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_EnumLiteral_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ValueSequence_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ValueSequence_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Quantity_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Quantity_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_MeasurementRef_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_MeasurementRef_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_UnitTerm_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_UnitTerm_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_UnitFactor_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_UnitFactor_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Diagnostic_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Diagnostic_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Span_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Span_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ServerInfoRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ServerInfoRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_ServerInfoResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_ServerInfoResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_QueryRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_QueryRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_QueryResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_QueryResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Query_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Query_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_Constraint_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_Constraint_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_PrimitiveConstraint_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_PrimitiveConstraint_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_CompositeConstraint_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_CompositeConstraint_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_QueryResultElement_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_QueryResultElement_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_QueryResultElement_PropertiesEntry_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_QueryResultElement_PropertiesEntry_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_SweepRange_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_SweepRange_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RunSweepRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RunSweepRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RunSweepRequest_NamedArgumentsEntry_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RunSweepRequest_NamedArgumentsEntry_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_SweepRow_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_SweepRow_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RunSweepResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RunSweepResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RunDocumentQueryRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RunDocumentQueryRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_DocumentQueryBinding_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_DocumentQueryBinding_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_DocumentValue_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_DocumentValue_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_DocumentQueryColumn_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_DocumentQueryColumn_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_DocumentQueryCell_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_DocumentQueryCell_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_DocumentQueryRow_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_DocumentQueryRow_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RunDocumentQueryResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RunDocumentQueryResponse_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RenderDocumentRequest_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RenderDocumentRequest_fieldAccessorTable;
-  static final com.google.protobuf.Descriptors.Descriptor
-    internal_static_sysml_RenderDocumentResponse_descriptor;
-  static final 
-    com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internal_static_sysml_RenderDocumentResponse_fieldAccessorTable;
-
-  public static com.google.protobuf.Descriptors.FileDescriptor
-      getDescriptor() {
-    return descriptor;
-  }
-  private static  com.google.protobuf.Descriptors.FileDescriptor
-      descriptor;
-  static {
-    java.lang.String[] descriptorData = {
-      "\n\013sysml.proto\022\005sysml\"\251\003\n\007Verdict\022\022\n\004kind" +
-      "\030\001 \001(\tR\004kind\022\035\n\nelement_id\030\002 \001(\tR\telemen" +
-      "tId\022\030\n\007element\030\003 \001(\tR\007element\022\024\n\005holds\030\004" +
-      " \001(\010R\005holds\022\034\n\tcondition\030\005 \001(\tR\tconditio" +
-      "n\022\037\n\013instance_id\030\006 \001(\003R\ninstanceId\022(\n\020in" +
-      "stance_type_id\030\007 \001(\tR\016instanceTypeId\022\024\n\005" +
-      "error\030\010 \001(\tR\005error\022;\n\016failure_reason\030\t \001" +
-      "(\0162\024.sysml.FailureReasonR\rfailureReason\022" +
-      "%\n\016requirement_id\030\n \001(\tR\rrequirementId\022\026" +
-      "\n\006engine\030\013 \001(\tR\006engine\022\032\n\010strength\030\014 \001(\t" +
-      "R\010strength\022$\n\006bounds\030\r \003(\0132\014.sysml.Bound" +
-      "R\006bounds\"K\n\005Bound\022\022\n\004name\030\001 \001(\tR\004name\022\024\n" +
-      "\005limit\030\002 \001(\003R\005limit\022\030\n\007reached\030\003 \001(\010R\007re" +
-      "ached\"\231\001\n\027VerifyConstraintRequest\022\035\n\nmod" +
-      "el_hash\030\001 \001(\tR\tmodelHash\022\033\n\tsymbol_id\030\002 " +
-      "\001(\tR\010symbolId\022*\n\021subject_symbol_id\030\003 \001(\t" +
-      "R\017subjectSymbolId\022\026\n\006engine\030\004 \001(\tR\006engin" +
-      "e\"\276\001\n\030VerifyConstraintResponse\022(\n\007verdic" +
-      "t\030\001 \001(\0132\016.sysml.VerdictR\007verdict\022-\n\tinst" +
-      "ances\030\002 \003(\0132\017.sysml.InstanceR\tinstances\022" +
-      "\024\n\005error\030\003 \001(\tR\005error\0223\n\013diagnostics\030\004 \003" +
-      "(\0132\021.sysml.DiagnosticR\013diagnostics\"\232\001\n\030V" +
-      "erifyRequirementRequest\022\035\n\nmodel_hash\030\001 " +
-      "\001(\tR\tmodelHash\022\033\n\tsymbol_id\030\002 \001(\tR\010symbo" +
-      "lId\022*\n\021subject_symbol_id\030\003 \001(\tR\017subjectS" +
-      "ymbolId\022\026\n\006engine\030\004 \001(\tR\006engine\"\233\001\n\023Veri" +
-      "ficationVerdict\022\027\n\007case_id\030\001 \001(\tR\006caseId" +
-      "\022\022\n\004kind\030\002 \001(\tR\004kind\022\026\n\006detail\030\003 \001(\tR\006de" +
-      "tail\022\030\n\007subcase\030\004 \001(\010R\007subcase\022%\n\016requir" +
-      "ement_id\030\005 \001(\tR\rrequirementId\"\220\002\n\031Verify" +
-      "RequirementResponse\022(\n\007verdict\030\001 \001(\0132\016.s" +
-      "ysml.VerdictR\007verdict\022-\n\tinstances\030\002 \003(\013" +
-      "2\017.sysml.InstanceR\tinstances\022\024\n\005error\030\003 " +
-      "\001(\tR\005error\0223\n\013diagnostics\030\004 \003(\0132\021.sysml." +
-      "DiagnosticR\013diagnostics\022O\n\025verification_" +
-      "verdicts\030\005 \003(\0132\032.sysml.VerificationVerdi" +
-      "ctR\024verificationVerdicts\"o\n\031VerifySatisf" +
-      "actionRequest\022\035\n\nmodel_hash\030\001 \001(\tR\tmodel" +
-      "Hash\022\033\n\tsymbol_id\030\002 \001(\tR\010symbolId\022\026\n\006eng" +
-      "ine\030\003 \001(\tR\006engine\"\320\002\n\032VerifySatisfaction" +
-      "Response\022*\n\010verdicts\030\001 \003(\0132\016.sysml.Verdi" +
-      "ctR\010verdicts\022-\n\tinstances\030\002 \003(\0132\017.sysml." +
-      "InstanceR\tinstances\022\024\n\005error\030\003 \001(\tR\005erro" +
-      "r\0223\n\013diagnostics\030\004 \003(\0132\021.sysml.Diagnosti" +
-      "cR\013diagnostics\022;\n\016failure_reason\030\005 \001(\0162\024" +
-      ".sysml.FailureReasonR\rfailureReason\022O\n\025v" +
-      "erification_verdicts\030\006 \003(\0132\032.sysml.Verif" +
-      "icationVerdictR\024verificationVerdicts\"\225\001\n" +
-      "\023EvaluateCalcRequest\022\035\n\nmodel_hash\030\001 \001(\t" +
-      "R\tmodelHash\022\033\n\tsymbol_id\030\002 \001(\tR\010symbolId" +
-      "\022*\n\targuments\030\003 \003(\0132\014.sysml.ValueR\targum" +
-      "ents\022\026\n\006engine\030\004 \001(\tR\006engine\"\313\002\n\024Evaluat" +
-      "eCalcResponse\022$\n\006result\030\001 \001(\0132\014.sysml.Va" +
-      "lueR\006result\022+\n\007outputs\030\002 \003(\0132\021.sysml.Cal" +
-      "cOutputR\007outputs\022\024\n\005error\030\003 \001(\tR\005error\0223" +
-      "\n\013diagnostics\030\004 \003(\0132\021.sysml.DiagnosticR\013" +
-      "diagnostics\022;\n\016failure_reason\030\005 \001(\0162\024.sy" +
-      "sml.FailureReasonR\rfailureReason\022\026\n\006engi" +
-      "ne\030\006 \001(\tR\006engine\022\032\n\010strength\030\007 \001(\tR\010stre" +
-      "ngth\022$\n\006bounds\030\010 \003(\0132\014.sysml.BoundR\006boun" +
-      "ds\"D\n\nCalcOutput\022\022\n\004name\030\001 \001(\tR\004name\022\"\n\005" +
-      "value\030\002 \001(\0132\014.sysml.ValueR\005value\"\311\001\n\016Cas" +
-      "eEvaluation\022\037\n\013function_id\030\001 \001(\tR\nfuncti" +
-      "onId\022*\n\targuments\030\002 \003(\0132\014.sysml.ValueR\ta" +
-      "rguments\022$\n\006result\030\003 \001(\0132\014.sysml.ValueR\006" +
-      "result\022\024\n\005error\030\004 \001(\tR\005error\022\032\n\010selected" +
-      "\030\005 \001(\010R\010selected\022\022\n\004tied\030\006 \001(\010R\004tied\"\205\003\n" +
-      "\022RunAnalysisRequest\022\035\n\nmodel_hash\030\001 \001(\tR" +
-      "\tmodelHash\022\033\n\tsymbol_id\030\002 \001(\tR\010symbolId\022" +
-      "*\n\021subject_symbol_id\030\003 \001(\tR\017subjectSymbo" +
-      "lId\022*\n\targuments\030\004 \003(\0132\014.sysml.ValueR\tar" +
-      "guments\022V\n\017named_arguments\030\005 \003(\0132-.sysml" +
-      ".RunAnalysisRequest.NamedArgumentsEntryR" +
-      "\016namedArguments\022\032\n\010schedule\030\006 \001(\tR\010sched" +
-      "ule\022\026\n\006engine\030\007 \001(\tR\006engine\032O\n\023NamedArgu" +
-      "mentsEntry\022\020\n\003key\030\001 \001(\tR\003key\022\"\n\005value\030\002 " +
-      "\001(\0132\014.sysml.ValueR\005value:\0028\001\"\361\004\n\023RunAnal" +
-      "ysisResponse\022+\n\007outputs\030\001 \003(\0132\021.sysml.Ca" +
-      "lcOutputR\007outputs\022*\n\010verdicts\030\002 \003(\0132\016.sy" +
-      "sml.VerdictR\010verdicts\022-\n\tinstances\030\003 \003(\013" +
-      "2\017.sysml.InstanceR\tinstances\022\024\n\005error\030\004 " +
-      "\001(\tR\005error\0223\n\013diagnostics\030\005 \003(\0132\021.sysml." +
-      "DiagnosticR\013diagnostics\022;\n\016failure_reaso" +
-      "n\030\006 \001(\0162\024.sysml.FailureReasonR\rfailureRe" +
-      "ason\022O\n\025verification_verdicts\030\007 \003(\0132\032.sy" +
-      "sml.VerificationVerdictR\024verificationVer" +
-      "dicts\022*\n\010outcomes\030\010 \003(\0132\016.sysml.OutcomeR" +
-      "\010outcomes\022:\n\013exploration\030\t \001(\0132\030.sysml.E" +
-      "xplorationStatusR\013exploration\0227\n\013evaluat" +
-      "ions\030\n \003(\0132\025.sysml.CaseEvaluationR\013evalu" +
-      "ations\022\026\n\006engine\030\013 \001(\tR\006engine\022\032\n\010streng" +
-      "th\030\014 \001(\tR\010strength\022$\n\006bounds\030\r \003(\0132\014.sys" +
-      "ml.BoundR\006bounds\"\337\002\n\007Outcome\0225\n\007outputs\030" +
-      "\001 \003(\0132\033.sysml.Outcome.OutputsEntryR\007outp" +
-      "uts\022\037\n\013final_state\030\002 \001(\tR\nfinalState\022%\n\016" +
-      "states_visited\030\003 \003(\tR\rstatesVisited\022\024\n\005e" +
-      "rror\030\004 \001(\tR\005error\022&\n\016linearizations\030\005 \001(" +
-      "\005R\016linearizations\022\030\n\007witness\030\006 \003(\tR\007witn" +
-      "ess\0223\n\013diagnostics\030\007 \003(\0132\021.sysml.Diagnos" +
-      "ticR\013diagnostics\032H\n\014OutputsEntry\022\020\n\003key\030" +
-      "\001 \001(\tR\003key\022\"\n\005value\030\002 \001(\0132\014.sysml.ValueR" +
-      "\005value:\0028\001\"\250\001\n\021ExplorationStatus\022\032\n\010comp" +
-      "lete\030\001 \001(\010R\010complete\022\022\n\004runs\030\002 \001(\005R\004runs" +
-      "\022\037\n\013budgets_hit\030\003 \003(\tR\nbudgetsHit\022\037\n\013run" +
-      "s_budget\030\004 \001(\005R\nrunsBudget\022!\n\014depth_budg" +
-      "et\030\005 \001(\005R\013depthBudget\"\024\n\022ListEnginesRequ" +
-      "est\"\373\002\n\nEngineInfo\022\022\n\004name\030\001 \001(\tR\004name\022\034" +
-      "\n\tauthority\030\002 \001(\tR\tauthority\022\030\n\007answers\030" +
-      "\003 \003(\tR\007answers\022\026\n\006bounds\030\004 \003(\tR\006bounds\022\030" +
-      "\n\007process\030\005 \001(\tR\007process\022#\n\rprocess_foun" +
-      "d\030\006 \001(\tR\014processFound\022\024\n\005ready\030\007 \001(\010R\005re" +
-      "ady\022 \n\013unavailable\030\010 \001(\tR\013unavailable\022\022\n" +
-      "\004kind\030\t \001(\tR\004kind\022\032\n\010protocol\030\n \001(\tR\010pro" +
-      "tocol\022\026\n\006source\030\013 \001(\tR\006source\022\030\n\007command" +
-      "\030\014 \001(\tR\007command\022\030\n\007version\030\r \001(\tR\007versio" +
-      "n\022\026\n\006served\030\016 \001(\010R\006served\"B\n\023ListEngines" +
-      "Response\022+\n\007engines\030\001 \003(\0132\021.sysml.Engine" +
-      "InfoR\007engines\"\311\001\n\020ParseFileRequest\022\035\n\tfi" +
-      "le_path\030\001 \001(\tH\000R\010filePath\022\032\n\007content\030\002 \001" +
-      "(\tH\000R\007content\022%\n\014content_hash\030\003 \001(\tB\002\030\001R" +
-      "\013contentHash\022\032\n\010language\030\004 \001(\tR\010language" +
-      "\022-\n\022strict_conformance\030\005 \001(\010R\021strictConf" +
-      "ormanceB\010\n\006source\"\205\001\n\016SourceDocument\022\035\n\t" +
-      "file_path\030\001 \001(\tH\000R\010filePath\022\032\n\007content\030\002" +
-      " \001(\tH\000R\007content\022\032\n\010language\030\003 \001(\tR\010langu" +
-      "age\022\022\n\004name\030\004 \001(\tR\004nameB\010\n\006source\"y\n\023Par" +
-      "seSourcesRequest\0223\n\tdocuments\030\001 \003(\0132\025.sy" +
-      "sml.SourceDocumentR\tdocuments\022-\n\022strict_" +
-      "conformance\030\002 \001(\010R\021strictConformance\"\251\001\n" +
-      "\024ParseSourcesResponse\022\035\n\nmodel_hash\030\001 \001(" +
-      "\tR\tmodelHash\022\'\n\005roots\030\002 \003(\0132\021.sysml.Symb" +
-      "olInfoR\005roots\0223\n\013diagnostics\030\003 \003(\0132\021.sys" +
-      "ml.DiagnosticR\013diagnostics\022\024\n\005error\030\004 \001(" +
-      "\tR\005error\"\244\001\n\021ParseFileResponse\022\035\n\nmodel_" +
-      "hash\030\001 \001(\tR\tmodelHash\022%\n\004root\030\002 \001(\0132\021.sy" +
-      "sml.SymbolInfoR\004root\0223\n\013diagnostics\030\003 \003(" +
-      "\0132\021.sysml.DiagnosticR\013diagnostics\022\024\n\005err" +
-      "or\030\004 \001(\tR\005error\"N\n\020GetSymbolRequest\022\035\n\nm" +
-      "odel_hash\030\001 \001(\tR\tmodelHash\022\033\n\tsymbol_id\030" +
-      "\002 \001(\tR\010symbolId\"Q\n\016SymbolResponse\022)\n\006sym" +
-      "bol\030\001 \001(\0132\021.sysml.SymbolInfoR\006symbol\022\024\n\005" +
-      "error\030\002 \001(\tR\005error\"3\n\022DiagnosticsRequest" +
-      "\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash\"`\n\023Diagn" +
-      "osticsResponse\0223\n\013diagnostics\030\001 \003(\0132\021.sy" +
-      "sml.DiagnosticR\013diagnostics\022\024\n\005error\030\002 \001" +
-      "(\tR\005error\"\250\001\n\017EvaluateRequest\022\035\n\nmodel_h" +
-      "ash\030\001 \001(\tR\tmodelHash\022\036\n\nexpression\030\002 \001(\t" +
-      "R\nexpression\022*\n\021context_symbol_id\030\003 \001(\tR" +
-      "\017contextSymbolId\022*\n\021subject_symbol_id\030\004 " +
-      "\001(\tR\017subjectSymbolId\"\203\001\n\020EvaluateRespons" +
-      "e\022$\n\006result\030\001 \001(\0132\014.sysml.ValueR\006result\022" +
-      "\024\n\005error\030\002 \001(\tR\005error\0223\n\013diagnostics\030\003 \003" +
-      "(\0132\021.sysml.DiagnosticR\013diagnostics\"\357\001\n\010I" +
-      "nstance\022\016\n\002id\030\001 \001(\003R\002id\022$\n\016type_symbol_i" +
-      "d\030\002 \001(\tR\014typeSymbolId\022I\n\016feature_values\030" +
-      "\004 \003(\0132\".sysml.Instance.FeatureValuesEntr" +
-      "yR\rfeatureValues\032U\n\022FeatureValuesEntry\022\020" +
-      "\n\003key\030\001 \001(\tR\003key\022)\n\005value\030\002 \001(\0132\023.sysml." +
-      "FeatureValueR\005value:\0028\001J\004\010\003\020\004R\005slots\"\265\001\n" +
-      "\014FeatureValue\022!\n\014feature_name\030\001 \001(\tR\013fea" +
-      "tureName\022\"\n\005value\030\002 \001(\0132\014.sysml.ValueR\005v" +
-      "alue\022$\n\006values\030\003 \003(\0132\014.sysml.ValueR\006valu" +
-      "es\022\"\n\014materialized\030\004 \001(\010R\014materialized\022\024" +
-      "\n\005error\030\005 \001(\tR\005error\"P\n\022InstantiateReque" +
-      "st\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash\022\033\n\tsym" +
-      "bol_id\030\002 \001(\tR\010symbolId\"\274\001\n\023InstantiateRe" +
-      "sponse\022+\n\010instance\030\001 \001(\0132\017.sysml.Instanc" +
-      "eR\010instance\022\024\n\005error\030\002 \001(\tR\005error\0223\n\013dia" +
-      "gnostics\030\003 \003(\0132\021.sysml.DiagnosticR\013diagn" +
-      "ostics\022-\n\tinstances\030\004 \003(\0132\017.sysml.Instan" +
-      "ceR\tinstances\"\205\002\n\024ExecuteActionRequest\022\035" +
-      "\n\nmodel_hash\030\001 \001(\tR\tmodelHash\022(\n\020action_" +
-      "symbol_id\030\002 \001(\tR\016actionSymbolId\022?\n\006input" +
-      "s\030\003 \003(\0132\'.sysml.ExecuteActionRequest.Inp" +
-      "utsEntryR\006inputs\022\032\n\010schedule\030\004 \001(\tR\010sche" +
-      "dule\032G\n\013InputsEntry\022\020\n\003key\030\001 \001(\tR\003key\022\"\n" +
-      "\005value\030\002 \001(\0132\014.sysml.ValueR\005value:\0028\001\"\370\002" +
-      "\n\025ExecuteActionResponse\022C\n\007outputs\030\001 \003(\013" +
-      "2).sysml.ExecuteActionResponse.OutputsEn" +
-      "tryR\007outputs\022\024\n\005error\030\002 \001(\tR\005error\0223\n\013di" +
-      "agnostics\030\003 \003(\0132\021.sysml.DiagnosticR\013diag" +
-      "nostics\022*\n\010outcomes\030\004 \003(\0132\016.sysml.Outcom" +
-      "eR\010outcomes\022:\n\013exploration\030\005 \001(\0132\030.sysml" +
-      ".ExplorationStatusR\013exploration\022\035\n\nfinal" +
-      "_time\030\006 \001(\001R\tfinalTime\032H\n\014OutputsEntry\022\020" +
-      "\n\003key\030\001 \001(\tR\003key\022\"\n\005value\030\002 \001(\0132\014.sysml." +
-      "ValueR\005value:\0028\001\"\237\001\n\023ExecuteStateRequest" +
-      "\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash\0225\n\027state" +
-      "_machine_symbol_id\030\002 \001(\tR\024stateMachineSy" +
-      "mbolId\022\026\n\006events\030\003 \003(\tR\006events\022\032\n\010schedu" +
-      "le\030\004 \001(\tR\010schedule\"\262\003\n\024ExecuteStateRespo" +
-      "nse\022%\n\016states_visited\030\001 \003(\tR\rstatesVisit" +
-      "ed\022R\n\rfinal_context\030\002 \003(\0132-.sysml.Execut" +
-      "eStateResponse.FinalContextEntryR\014finalC" +
-      "ontext\022\024\n\005error\030\003 \001(\tR\005error\0223\n\013diagnost" +
-      "ics\030\004 \003(\0132\021.sysml.DiagnosticR\013diagnostic" +
-      "s\022*\n\010outcomes\030\005 \003(\0132\016.sysml.OutcomeR\010out" +
-      "comes\022:\n\013exploration\030\006 \001(\0132\030.sysml.Explo" +
-      "rationStatusR\013exploration\022\035\n\nfinal_time\030" +
-      "\007 \001(\001R\tfinalTime\032M\n\021FinalContextEntry\022\020\n" +
-      "\003key\030\001 \001(\tR\003key\022\"\n\005value\030\002 \001(\0132\014.sysml.V" +
-      "alueR\005value:\0028\001\"\352\001\n\016ConvertRequest\022\035\n\tfi" +
-      "le_path\030\001 \001(\tH\000R\010filePath\022\032\n\007content\030\002 \001" +
-      "(\tH\000R\007content\022\037\n\nmodel_hash\030\006 \001(\tH\000R\tmod" +
-      "elHash\022\037\n\013from_format\030\003 \001(\tR\nfromFormat\022" +
-      "\033\n\tto_format\030\004 \001(\tR\010toFormat\0224\n\026tolerate" +
-      "_syntax_errors\030\005 \001(\010R\024tolerateSyntaxErro" +
-      "rsB\010\n\006source\"\211\002\n\017ConvertResponse\022\030\n\007cont" +
-      "ent\030\001 \001(\tR\007content\022\037\n\013from_format\030\002 \001(\tR" +
-      "\nfromFormat\022\033\n\tto_format\030\003 \001(\tR\010toFormat" +
-      "\022\024\n\005error\030\004 \001(\tR\005error\0223\n\013diagnostics\030\005 " +
-      "\003(\0132\021.sysml.DiagnosticR\013diagnostics\022\"\n\014e" +
-      "xperimental\030\006 \001(\010R\014experimental\022/\n\023exper" +
-      "imental_notice\030\007 \001(\tR\022experimentalNotice" +
-      "\"h\n\021ApplyEditsRequest\022\035\n\nmodel_hash\030\001 \001(" +
-      "\tR\tmodelHash\0224\n\noperations\030\002 \003(\0132\024.sysml" +
-      ".EditOperationR\noperations\"\210\002\n\rEditOpera" +
-      "tion\0222\n\tset_value\030\001 \001(\0132\023.sysml.SetValue" +
-      "EditH\000R\010setValue\022+\n\006rename\030\002 \001(\0132\021.sysml" +
-      ".RenameEditH\000R\006rename\0225\n\nadd_member\030\003 \001(" +
-      "\0132\024.sysml.AddMemberEditH\000R\taddMember\022+\n\006" +
-      "delete\030\004 \001(\0132\021.sysml.DeleteEditH\000R\006delet" +
-      "e\022%\n\004move\030\005 \001(\0132\017.sysml.MoveEditH\000R\004move" +
-      "B\013\n\toperation\"\275\001\n\rAddMemberEdit\022\024\n\005owner" +
-      "\030\001 \001(\tR\005owner\022\022\n\004kind\030\002 \001(\tR\004kind\022\022\n\004nam" +
-      "e\030\003 \001(\tR\004name\022\022\n\004type\030\004 \001(\tR\004type\022\"\n\014mul" +
-      "tiplicity\030\005 \001(\tR\014multiplicity\022\024\n\005value\030\006" +
-      " \001(\tR\005value\022 \n\013specializes\030\007 \003(\tR\013specia" +
-      "lizes\">\n\nDeleteEdit\022\026\n\006target\030\001 \001(\tR\006tar" +
-      "get\022\030\n\007cascade\030\002 \001(\010R\007cascade\"8\n\010MoveEdi" +
-      "t\022\026\n\006target\030\001 \001(\tR\006target\022\024\n\005owner\030\002 \001(\t" +
-      "R\005owner\"<\n\014SetValueEdit\022\026\n\006target\030\001 \001(\tR" +
-      "\006target\022\024\n\005value\030\002 \001(\tR\005value\"?\n\nRenameE" +
-      "dit\022\026\n\006target\030\001 \001(\tR\006target\022\031\n\010new_name\030" +
-      "\002 \001(\tR\007newName\"\204\002\n\022ApplyEditsResponse\022\030\n" +
-      "\007content\030\001 \001(\tR\007content\022,\n\007applied\030\002 \003(\013" +
-      "2\022.sysml.AppliedEditR\007applied\022\024\n\005error\030\003" +
-      " \001(\tR\005error\022,\n\007failure\030\004 \001(\0162\022.sysml.Edi" +
-      "tFailureR\007failure\0223\n\013diagnostics\030\005 \003(\0132\021" +
-      ".sysml.DiagnosticR\013diagnostics\022-\n\022referr" +
-      "ing_elements\030\006 \003(\tR\021referringElements\"\264\001" +
-      "\n\013AppliedEdit\022\'\n\017operation_index\030\001 \001(\005R\016" +
-      "operationIndex\022\026\n\006target\030\002 \001(\tR\006target\022\026" +
-      "\n\006offset\030\003 \001(\005R\006offset\022\026\n\006length\030\004 \001(\005R\006" +
-      "length\022\031\n\010old_text\030\005 \001(\tR\007oldText\022\031\n\010new" +
-      "_text\030\006 \001(\tR\007newText\"\375\003\n\nSymbolInfo\022\016\n\002i" +
-      "d\030\001 \001(\tR\002id\022\022\n\004name\030\002 \001(\tR\004name\022\022\n\004kind\030" +
-      "\003 \001(\tR\004kind\022;\n\010metadata\030\004 \003(\0132\037.sysml.Sy" +
-      "mbolInfo.MetadataEntryR\010metadata\022\033\n\tchil" +
-      "d_ids\030\005 \003(\tR\010childIds\0224\n\nattributes\030\006 \003(" +
-      "\0132\024.sysml.AttributeInfoR\nattributes\022,\n\tt" +
-      "ype_info\030\007 \001(\0132\017.sysml.TypeInfoR\010typeInf" +
-      "o\022;\n\014multiplicity\030\010 \001(\0132\027.sysml.Multipli" +
-      "cityInfoR\014multiplicity\022?\n\017specialization" +
-      "s\030\t \003(\0132\025.sysml.SpecializationR\017speciali" +
-      "zations\022>\n\033withheld_library_attributes\030\n" +
-      " \001(\005R\031withheldLibraryAttributes\032;\n\rMetad" +
-      "ataEntry\022\020\n\003key\030\001 \001(\tR\003key\022\024\n\005value\030\002 \001(" +
-      "\tR\005value:\0028\001\"~\n\016Specialization\022\022\n\004kind\030\001" +
-      " \001(\tR\004kind\022\032\n\010declared\030\002 \001(\tR\010declared\022\033" +
-      "\n\ttarget_id\030\003 \001(\tR\010targetId\022\037\n\013target_ki" +
-      "nd\030\004 \001(\tR\ntargetKind\"\345\001\n\010TypeInfo\022\032\n\010dec" +
-      "lared\030\001 \001(\tR\010declared\022\037\n\013resolved_id\030\002 \001" +
-      "(\tR\nresolvedId\022#\n\rresolved_kind\030\003 \001(\tR\014r" +
-      "esolvedKind\022\034\n\tprimitive\030\004 \001(\tR\tprimitiv" +
-      "e\022)\n\020primitive_source\030\005 \001(\tR\017primitiveSo" +
-      "urce\022\032\n\010quantity\030\006 \001(\010R\010quantity\022\022\n\004unit" +
-      "\030\007 \001(\tR\004unit\">\n\020MultiplicityInfo\022\024\n\005lowe" +
-      "r\030\001 \001(\tR\005lower\022\024\n\005upper\030\002 \001(\tR\005upper\"o\n\r" +
-      "AttributeInfo\022\022\n\004name\030\001 \001(\tR\004name\022\022\n\004typ" +
-      "e\030\002 \001(\tR\004type\022\"\n\005value\030\003 \001(\0132\014.sysml.Val" +
-      "ueR\005value\022\022\n\004unit\030\004 \001(\tR\004unit\"\245\007\n\005Value\022" +
-      "\035\n\tint_value\030\001 \001(\003H\000R\010intValue\022\037\n\nreal_v" +
-      "alue\030\002 \001(\001H\000R\trealValue\022\037\n\nbool_value\030\003 " +
-      "\001(\010H\000R\tboolValue\022#\n\014string_value\030\004 \001(\tH\000" +
-      "R\013stringValue\022!\n\013instance_id\030\005 \001(\003H\000R\nin" +
-      "stanceId\0222\n\010sequence\030\006 \001(\0132\024.sysml.Value" +
-      "SequenceH\000R\010sequence\022\024\n\004null\030\007 \001(\tH\000R\004nu" +
-      "ll\022-\n\010quantity\030\010 \001(\0132\017.sysml.QuantityH\000R" +
-      "\010quantity\0227\n\014enum_literal\030\t \001(\0132\022.sysml." +
-      "EnumLiteralH\000R\013enumLiteral\022\026\n\005unset\030\n \001(" +
-      "\010H\000R\005unset\022*\n\007complex\030\013 \001(\0132\016.sysml.Comp" +
-      "lexH\000R\007complex\022$\n\005array\030\014 \001(\0132\014.sysml.Ar" +
-      "rayH\000R\005array\022\'\n\006vector\030\r \001(\0132\r.sysml.Vec" +
-      "torH\000R\006vector\022@\n\017vector_quantity\030\016 \001(\0132\025" +
-      ".sysml.VectorQuantityH\000R\016vectorQuantity\022" +
-      "@\n\017measurement_ref\030\017 \001(\0132\025.sysml.Measure" +
-      "mentRefH\000R\016measurementRef\022\034\n\010infinity\030\020 " +
-      "\001(\010H\000R\010infinity\022-\n\010function\030\021 \001(\0132\017.sysm" +
-      "l.FunctionH\000R\010function\022#\n\003set\030\022 \001(\0132\017.sy" +
-      "sml.ValueSetH\000R\003set\022@\n\017tensor_quantity\030\023" +
-      " \001(\0132\025.sysml.TensorQuantityH\000R\016tensorQua" +
-      "ntity\0223\n\nmetaobject\030\024 \001(\0132\021.sysml.Metaob" +
-      "jectH\000R\nmetaobject\0229\n\014undetermined\030\025 \001(\013" +
-      "2\023.sysml.UndeterminedH\000R\014undeterminedB\006\n" +
-      "\004kind\"N\n\nMetaobject\022\035\n\nelement_id\030\001 \001(\tR" +
-      "\telementId\022!\n\014metaclass_id\030\002 \001(\tR\013metacl" +
-      "assId\"U\n\014Undetermined\022\026\n\006reason\030\001 \001(\tR\006r" +
-      "eason\022-\n\005count\030\002 \001(\0132\027.sysml.Multiplicit" +
-      "yInfoR\005count\"<\n\010Function\022\027\n\007calc_id\030\001 \001(" +
-      "\tR\006calcId\022\027\n\007self_id\030\002 \001(\003R\006selfId\"4\n\010Va" +
-      "lueSet\022(\n\010elements\030\001 \003(\0132\014.sysml.ValueR\010" +
-      "elements\"a\n\016TensorQuantity\022\036\n\ndimensions" +
-      "\030\001 \003(\003R\ndimensions\022/\n\ncomponents\030\002 \003(\0132\017" +
-      ".sysml.QuantityR\ncomponents\"Q\n\005Array\022\036\n\n" +
-      "dimensions\030\001 \003(\003R\ndimensions\022(\n\010elements" +
-      "\030\002 \003(\0132\014.sysml.ValueR\010elements\"6\n\006Vector" +
-      "\022,\n\ncomponents\030\001 \003(\0132\014.sysml.ValueR\ncomp" +
-      "onents\"A\n\016VectorQuantity\022/\n\ncomponents\030\001" +
-      " \003(\0132\017.sysml.QuantityR\ncomponents\";\n\007Com" +
-      "plex\022\022\n\004real\030\001 \001(\001R\004real\022\034\n\timaginary\030\002 " +
-      "\001(\001R\timaginary\"\213\001\n\013EnumLiteral\022\035\n\nlitera" +
-      "l_id\030\001 \001(\tR\tliteralId\022%\n\016enumeration_id\030" +
-      "\002 \001(\tR\renumerationId\022\022\n\004name\030\003 \001(\tR\004name" +
-      "\022\"\n\005value\030\004 \001(\0132\014.sysml.ValueR\005value\"9\n\r" +
-      "ValueSequence\022(\n\010elements\030\001 \003(\0132\014.sysml." +
-      "ValueR\010elements\"\251\001\n\010Quantity\022%\n\rint_magn" +
-      "itude\030\001 \001(\003H\000R\014intMagnitude\022\'\n\016real_magn" +
-      "itude\030\002 \001(\001H\000R\rrealMagnitude\022\022\n\004unit\030\003 \001" +
-      "(\tR\004unit\022,\n\tunit_term\030\004 \001(\0132\017.sysml.Unit" +
-      "TermR\010unitTermB\013\n\tmagnitude\"k\n\016Measureme" +
-      "ntRef\022\022\n\004unit\030\001 \001(\tR\004unit\022,\n\tunit_term\030\002" +
-      " \001(\0132\017.sysml.UnitTermR\010unitTerm\022\027\n\007unit_" +
-      "id\030\003 \001(\tR\006unitId\"q\n\010UnitTerm\022\033\n\tscale_nu" +
-      "m\030\001 \001(\001R\010scaleNum\022\033\n\tscale_den\030\002 \001(\001R\010sc" +
-      "aleDen\022+\n\007factors\030\003 \003(\0132\021.sysml.UnitFact" +
-      "orR\007factors\"A\n\nUnitFactor\022\027\n\007unit_id\030\001 \001" +
-      "(\tR\006unitId\022\032\n\010exponent\030\002 \001(\001R\010exponent\"w" +
-      "\n\nDiagnostic\022\032\n\010severity\030\001 \001(\tR\010severity" +
-      "\022\030\n\007message\030\002 \001(\tR\007message\022\037\n\004span\030\003 \001(\013" +
-      "2\013.sysml.SpanR\004span\022\022\n\004code\030\004 \001(\tR\004code\"" +
-      "\212\001\n\004Span\022\022\n\004file\030\001 \001(\tR\004file\022\035\n\nstart_li" +
-      "ne\030\002 \001(\005R\tstartLine\022\033\n\tstart_col\030\003 \001(\005R\010" +
-      "startCol\022\031\n\010end_line\030\004 \001(\005R\007endLine\022\027\n\007e" +
-      "nd_col\030\005 \001(\005R\006endCol\"\023\n\021ServerInfoReques" +
-      "t\"R\n\022ServerInfoResponse\022\030\n\007version\030\001 \001(\t" +
-      "R\007version\022\"\n\014capabilities\030\002 \003(\tR\014capabil" +
-      "ities\"p\n\014QueryRequest\022\035\n\nmodel_hash\030\001 \001(" +
-      "\tR\tmodelHash\022\"\n\005query\030\002 \001(\0132\014.sysml.Quer" +
-      "yR\005query\022\035\n\noslc_query\030\003 \001(\tR\toslcQuery\"" +
-      "F\n\rQueryResponse\0225\n\010elements\030\001 \003(\0132\031.sys" +
-      "ml.QueryResultElementR\010elements\"^\n\005Query" +
-      "\022\024\n\005scope\030\001 \003(\tR\005scope\022\026\n\006select\030\002 \003(\tR\006" +
-      "select\022\'\n\005where\030\003 \001(\0132\021.sysml.Constraint" +
-      "R\005where\"\222\001\n\nConstraint\022:\n\tprimitive\030\001 \001(" +
-      "\0132\032.sysml.PrimitiveConstraintH\000R\tprimiti" +
-      "ve\022:\n\tcomposite\030\002 \001(\0132\032.sysml.CompositeC" +
-      "onstraintH\000R\tcompositeB\014\n\nconstraint\"\227\001\n" +
-      "\023PrimitiveConstraint\022\030\n\007inverse\030\001 \001(\010R\007i" +
-      "nverse\022\032\n\010property\030\002 \001(\tR\010property\0224\n\010op" +
-      "erator\030\003 \001(\0162\030.sysml.PrimitiveOperatorR\010" +
-      "operator\022\024\n\005value\030\004 \003(\tR\005value\"~\n\023Compos" +
-      "iteConstraint\0224\n\010operator\030\001 \001(\0162\030.sysml." +
-      "CompositeOperatorR\010operator\0221\n\nconstrain" +
-      "t\030\002 \003(\0132\021.sysml.ConstraintR\nconstraint\"\302" +
-      "\001\n\022QueryResultElement\022\016\n\002id\030\001 \001(\tR\002id\022\022\n" +
-      "\004type\030\002 \001(\tR\004type\022I\n\nproperties\030\003 \003(\0132)." +
-      "sysml.QueryResultElement.PropertiesEntry" +
-      "R\nproperties\032=\n\017PropertiesEntry\022\020\n\003key\030\001" +
-      " \001(\tR\003key\022\024\n\005value\030\002 \001(\tR\005value:\0028\001\"\220\001\n\n" +
-      "SweepRange\022\034\n\tparameter\030\001 \001(\tR\tparameter" +
-      "\022\"\n\005start\030\002 \001(\0132\014.sysml.ValueR\005start\022\036\n\003" +
-      "end\030\003 \001(\0132\014.sysml.ValueR\003end\022 \n\004step\030\004 \001" +
-      "(\0132\014.sysml.ValueR\004step\"\274\003\n\017RunSweepReque" +
-      "st\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash\022\033\n\tsym" +
-      "bol_id\030\002 \001(\tR\010symbolId\022*\n\021subject_symbol" +
-      "_id\030\003 \001(\tR\017subjectSymbolId\022*\n\targuments\030" +
-      "\004 \003(\0132\014.sysml.ValueR\targuments\022S\n\017named_" +
-      "arguments\030\005 \003(\0132*.sysml.RunSweepRequest." +
-      "NamedArgumentsEntryR\016namedArguments\022)\n\006r" +
-      "anges\030\006 \003(\0132\021.sysml.SweepRangeR\006ranges\022\030" +
-      "\n\007samples\030\007 \001(\003R\007samples\022\022\n\004seed\030\010 \001(\004R\004" +
-      "seed\022\026\n\006engine\030\t \001(\tR\006engine\032O\n\023NamedArg" +
-      "umentsEntry\022\020\n\003key\030\001 \001(\tR\003key\022\"\n\005value\030\002" +
-      " \001(\0132\014.sysml.ValueR\005value:\0028\001\"\301\002\n\010SweepR" +
-      "ow\022)\n\006inputs\030\001 \003(\0132\021.sysml.CalcOutputR\006i" +
-      "nputs\022+\n\007outputs\030\002 \003(\0132\021.sysml.CalcOutpu" +
-      "tR\007outputs\022*\n\010verdicts\030\003 \003(\0132\016.sysml.Ver" +
-      "dictR\010verdicts\022%\n\016elapsed_micros\030\004 \001(\003R\r" +
-      "elapsedMicros\022\024\n\005error\030\005 \001(\tR\005error\022;\n\016f" +
-      "ailure_reason\030\006 \001(\0162\024.sysml.FailureReaso" +
-      "nR\rfailureReason\0227\n\013evaluations\030\007 \003(\0132\025." +
-      "sysml.CaseEvaluationR\013evaluations\"\226\003\n\020Ru" +
-      "nSweepResponse\022#\n\004rows\030\001 \003(\0132\017.sysml.Swe" +
-      "epRowR\004rows\022\036\n\nparameters\030\002 \003(\tR\nparamet" +
-      "ers\022\030\n\007sampled\030\003 \001(\010R\007sampled\022\022\n\004seed\030\004 " +
-      "\001(\004R\004seed\022\024\n\005error\030\005 \001(\tR\005error\0223\n\013diagn" +
-      "ostics\030\006 \003(\0132\021.sysml.DiagnosticR\013diagnos" +
-      "tics\022;\n\016failure_reason\030\007 \001(\0162\024.sysml.Fai" +
-      "lureReasonR\rfailureReason\022-\n\tinstances\030\010" +
-      " \003(\0132\017.sysml.InstanceR\tinstances\022\026\n\006engi" +
-      "ne\030\t \001(\tR\006engine\022\032\n\010strength\030\n \001(\tR\010stre" +
-      "ngth\022$\n\006bounds\030\013 \003(\0132\014.sysml.BoundR\006boun" +
-      "ds\"\214\001\n\027RunDocumentQueryRequest\022\035\n\nmodel_" +
-      "hash\030\001 \001(\tR\tmodelHash\022\031\n\010query_id\030\002 \001(\tR" +
-      "\007queryId\0227\n\010bindings\030\003 \003(\0132\033.sysml.Docum" +
-      "entQueryBindingR\010bindings\"b\n\024DocumentQue" +
-      "ryBinding\022\034\n\tparameter\030\001 \001(\tR\tparameter\022" +
-      ",\n\006values\030\002 \003(\0132\024.sysml.DocumentValueR\006v" +
-      "alues\"\256\002\n\rDocumentValue\022\037\n\nelement_id\030\001 ",
-      "\001(\tH\000R\telementId\022#\n\014string_value\030\002 \001(\tH\000" +
-      "R\013stringValue\022\035\n\tint_value\030\003 \001(\003H\000R\010intV" +
-      "alue\022\037\n\nreal_value\030\004 \001(\001H\000R\trealValue\022\037\n" +
-      "\nbool_value\030\005 \001(\010H\000R\tboolValue\022\034\n\010infini" +
-      "ty\030\006 \001(\010H\000R\010infinity\022-\n\010quantity\030\010 \001(\0132\017" +
-      ".sysml.QuantityH\000R\010quantity\022!\n\014element_t" +
-      "ype\030\007 \001(\tR\013elementTypeB\006\n\004kind\")\n\023Docume" +
-      "ntQueryColumn\022\022\n\004name\030\001 \001(\tR\004name\"A\n\021Doc" +
-      "umentQueryCell\022,\n\006values\030\001 \003(\0132\024.sysml.D" +
-      "ocumentValueR\006values\"r\n\020DocumentQueryRow" +
-      "\022.\n\007element\030\001 \001(\0132\024.sysml.DocumentValueR" +
-      "\007element\022.\n\005cells\030\002 \003(\0132\030.sysml.Document" +
-      "QueryCellR\005cells\"}\n\030RunDocumentQueryResp" +
-      "onse\0224\n\007columns\030\001 \003(\0132\032.sysml.DocumentQu" +
-      "eryColumnR\007columns\022+\n\004rows\030\002 \003(\0132\027.sysml" +
-      ".DocumentQueryRowR\004rows\"W\n\025RenderDocumen" +
-      "tRequest\022\035\n\nmodel_hash\030\001 \001(\tR\tmodelHash\022" +
-      "\037\n\013document_id\030\002 \001(\tR\ndocumentId\"4\n\026Rend" +
-      "erDocumentResponse\022\032\n\010markdown\030\001 \001(\tR\010ma" +
-      "rkdown*\223\001\n\rFailureReason\022\036\n\032FAILURE_REAS" +
-      "ON_UNSPECIFIED\020\000\022\035\n\031FAILURE_REASON_EVALU" +
-      "ATION\020\001\022\035\n\031FAILURE_REASON_WRONG_KIND\020\002\022$" +
-      "\n FAILURE_REASON_AMBIGUOUS_SUBJECT\020\003*\345\004\n" +
-      "\013EditFailure\022\034\n\030EDIT_FAILURE_UNSPECIFIED" +
-      "\020\000\022\036\n\032EDIT_FAILURE_NO_OPERATIONS\020\001\022\037\n\033ED" +
-      "IT_FAILURE_UNKNOWN_TARGET\020\002\022!\n\035EDIT_FAIL" +
-      "URE_AMBIGUOUS_TARGET\020\003\022\033\n\027EDIT_FAILURE_N" +
-      "OT_VALUED\020\004\022\036\n\032EDIT_FAILURE_INVALID_VALU" +
-      "E\020\005\022\035\n\031EDIT_FAILURE_INVALID_NAME\020\006\022\032\n\026ED" +
-      "IT_FAILURE_NOT_NAMED\020\007\022\"\n\036EDIT_FAILURE_R" +
-      "ENAME_REFERENCED\020\010\022\"\n\036EDIT_FAILURE_OVERL" +
-      "APPING_EDITS\020\t\022\037\n\033EDIT_FAILURE_RESULT_IN" +
-      "VALID\020\n\022\036\n\032EDIT_FAILURE_OWNER_UNKNOWN\020\013\022" +
-      "$\n EDIT_FAILURE_OWNER_NOT_NAMESPACE\020\014\022\035\n" +
-      "\031EDIT_FAILURE_ILLEGAL_KIND\020\r\022\"\n\036EDIT_FAI" +
-      "LURE_MEMBER_NAME_TAKEN\020\016\022\"\n\036EDIT_FAILURE" +
-      "_DELETE_REFERENCED\020\017\022$\n EDIT_FAILURE_OWN" +
-      "ER_INSIDE_TARGET\020\020\022 \n\034EDIT_FAILURE_MOVE_" +
-      "REFERENCED\020\021*\222\001\n\021PrimitiveOperator\022\"\n\036PR" +
-      "IMITIVE_OPERATOR_UNSPECIFIED\020\000\022\034\n\030PRIMIT" +
-      "IVE_OPERATOR_EQUAL\020\001\022\036\n\032PRIMITIVE_OPERAT" +
-      "OR_GREATER\020\002\022\033\n\027PRIMITIVE_OPERATOR_LESS\020" +
-      "\003*n\n\021CompositeOperator\022\"\n\036COMPOSITE_OPER" +
-      "ATOR_UNSPECIFIED\020\000\022\032\n\026COMPOSITE_OPERATOR" +
-      "_AND\020\001\022\031\n\025COMPOSITE_OPERATOR_OR\020\0022\352\013\n\014Sy" +
-      "sMLService\022D\n\rGetServerInfo\022\030.sysml.Serv" +
-      "erInfoRequest\032\031.sysml.ServerInfoResponse" +
-      "\022>\n\tParseFile\022\027.sysml.ParseFileRequest\032\030" +
-      ".sysml.ParseFileResponse\022G\n\014ParseSources" +
-      "\022\032.sysml.ParseSourcesRequest\032\033.sysml.Par" +
-      "seSourcesResponse\022;\n\tGetSymbol\022\027.sysml.G" +
-      "etSymbolRequest\032\025.sysml.SymbolResponse\022G" +
-      "\n\016GetDiagnostics\022\031.sysml.DiagnosticsRequ" +
-      "est\032\032.sysml.DiagnosticsResponse\022;\n\010Evalu" +
-      "ate\022\026.sysml.EvaluateRequest\032\027.sysml.Eval" +
-      "uateResponse\022D\n\013Instantiate\022\031.sysml.Inst" +
-      "antiateRequest\032\032.sysml.InstantiateRespon" +
-      "se\022J\n\rExecuteAction\022\033.sysml.ExecuteActio" +
-      "nRequest\032\034.sysml.ExecuteActionResponse\022G" +
-      "\n\014ExecuteState\022\032.sysml.ExecuteStateReque" +
-      "st\032\033.sysml.ExecuteStateResponse\0228\n\007Conve" +
-      "rt\022\025.sysml.ConvertRequest\032\026.sysml.Conver" +
-      "tResponse\022A\n\nApplyEdits\022\030.sysml.ApplyEdi" +
-      "tsRequest\032\031.sysml.ApplyEditsResponse\022S\n\020" +
-      "VerifyConstraint\022\036.sysml.VerifyConstrain" +
-      "tRequest\032\037.sysml.VerifyConstraintRespons" +
-      "e\022V\n\021VerifyRequirement\022\037.sysml.VerifyReq" +
-      "uirementRequest\032 .sysml.VerifyRequiremen" +
-      "tResponse\022Y\n\022VerifySatisfaction\022 .sysml." +
-      "VerifySatisfactionRequest\032!.sysml.Verify" +
-      "SatisfactionResponse\022G\n\014EvaluateCalc\022\032.s" +
-      "ysml.EvaluateCalcRequest\032\033.sysml.Evaluat" +
-      "eCalcResponse\022D\n\013RunAnalysis\022\031.sysml.Run" +
-      "AnalysisRequest\032\032.sysml.RunAnalysisRespo" +
-      "nse\022;\n\010RunSweep\022\026.sysml.RunSweepRequest\032" +
-      "\027.sysml.RunSweepResponse\022D\n\013ListEngines\022" +
-      "\031.sysml.ListEnginesRequest\032\032.sysml.ListE" +
-      "nginesResponse\0222\n\005Query\022\023.sysml.QueryReq" +
-      "uest\032\024.sysml.QueryResponse\022S\n\020RunDocumen" +
-      "tQuery\022\036.sysml.RunDocumentQueryRequest\032\037" +
-      ".sysml.RunDocumentQueryResponse\022M\n\016Rende" +
-      "rDocument\022\034.sysml.RenderDocumentRequest\032" +
-      "\035.sysml.RenderDocumentResponseBJ\n\034org.op" +
-      "enmbee.opensysml.protoP\001Z(github.com/Ope" +
-      "n-MBEE/OpenSysML/api/protob\006proto3"
-    };
-    descriptor = com.google.protobuf.Descriptors.FileDescriptor
-      .internalBuildGeneratedFileFrom(descriptorData,
-        new com.google.protobuf.Descriptors.FileDescriptor[] {
-        });
-    internal_static_sysml_Verdict_descriptor =
-      getDescriptor().getMessageType(0);
-    internal_static_sysml_Verdict_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Verdict_descriptor,
-        new java.lang.String[] { "Kind", "ElementId", "Element", "Holds", "Condition", "InstanceId", "InstanceTypeId", "Error", "FailureReason", "RequirementId", "Engine", "Strength", "Bounds", });
-    internal_static_sysml_Bound_descriptor =
-      getDescriptor().getMessageType(1);
-    internal_static_sysml_Bound_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Bound_descriptor,
-        new java.lang.String[] { "Name", "Limit", "Reached", });
-    internal_static_sysml_VerifyConstraintRequest_descriptor =
-      getDescriptor().getMessageType(2);
-    internal_static_sysml_VerifyConstraintRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_VerifyConstraintRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "SymbolId", "SubjectSymbolId", "Engine", });
-    internal_static_sysml_VerifyConstraintResponse_descriptor =
-      getDescriptor().getMessageType(3);
-    internal_static_sysml_VerifyConstraintResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_VerifyConstraintResponse_descriptor,
-        new java.lang.String[] { "Verdict", "Instances", "Error", "Diagnostics", });
-    internal_static_sysml_VerifyRequirementRequest_descriptor =
-      getDescriptor().getMessageType(4);
-    internal_static_sysml_VerifyRequirementRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_VerifyRequirementRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "SymbolId", "SubjectSymbolId", "Engine", });
-    internal_static_sysml_VerificationVerdict_descriptor =
-      getDescriptor().getMessageType(5);
-    internal_static_sysml_VerificationVerdict_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_VerificationVerdict_descriptor,
-        new java.lang.String[] { "CaseId", "Kind", "Detail", "Subcase", "RequirementId", });
-    internal_static_sysml_VerifyRequirementResponse_descriptor =
-      getDescriptor().getMessageType(6);
-    internal_static_sysml_VerifyRequirementResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_VerifyRequirementResponse_descriptor,
-        new java.lang.String[] { "Verdict", "Instances", "Error", "Diagnostics", "VerificationVerdicts", });
-    internal_static_sysml_VerifySatisfactionRequest_descriptor =
-      getDescriptor().getMessageType(7);
-    internal_static_sysml_VerifySatisfactionRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_VerifySatisfactionRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "SymbolId", "Engine", });
-    internal_static_sysml_VerifySatisfactionResponse_descriptor =
-      getDescriptor().getMessageType(8);
-    internal_static_sysml_VerifySatisfactionResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_VerifySatisfactionResponse_descriptor,
-        new java.lang.String[] { "Verdicts", "Instances", "Error", "Diagnostics", "FailureReason", "VerificationVerdicts", });
-    internal_static_sysml_EvaluateCalcRequest_descriptor =
-      getDescriptor().getMessageType(9);
-    internal_static_sysml_EvaluateCalcRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_EvaluateCalcRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "SymbolId", "Arguments", "Engine", });
-    internal_static_sysml_EvaluateCalcResponse_descriptor =
-      getDescriptor().getMessageType(10);
-    internal_static_sysml_EvaluateCalcResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_EvaluateCalcResponse_descriptor,
-        new java.lang.String[] { "Result", "Outputs", "Error", "Diagnostics", "FailureReason", "Engine", "Strength", "Bounds", });
-    internal_static_sysml_CalcOutput_descriptor =
-      getDescriptor().getMessageType(11);
-    internal_static_sysml_CalcOutput_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_CalcOutput_descriptor,
-        new java.lang.String[] { "Name", "Value", });
-    internal_static_sysml_CaseEvaluation_descriptor =
-      getDescriptor().getMessageType(12);
-    internal_static_sysml_CaseEvaluation_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_CaseEvaluation_descriptor,
-        new java.lang.String[] { "FunctionId", "Arguments", "Result", "Error", "Selected", "Tied", });
-    internal_static_sysml_RunAnalysisRequest_descriptor =
-      getDescriptor().getMessageType(13);
-    internal_static_sysml_RunAnalysisRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RunAnalysisRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "SymbolId", "SubjectSymbolId", "Arguments", "NamedArguments", "Schedule", "Engine", });
-    internal_static_sysml_RunAnalysisRequest_NamedArgumentsEntry_descriptor =
-      internal_static_sysml_RunAnalysisRequest_descriptor.getNestedType(0);
-    internal_static_sysml_RunAnalysisRequest_NamedArgumentsEntry_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RunAnalysisRequest_NamedArgumentsEntry_descriptor,
-        new java.lang.String[] { "Key", "Value", });
-    internal_static_sysml_RunAnalysisResponse_descriptor =
-      getDescriptor().getMessageType(14);
-    internal_static_sysml_RunAnalysisResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RunAnalysisResponse_descriptor,
-        new java.lang.String[] { "Outputs", "Verdicts", "Instances", "Error", "Diagnostics", "FailureReason", "VerificationVerdicts", "Outcomes", "Exploration", "Evaluations", "Engine", "Strength", "Bounds", });
-    internal_static_sysml_Outcome_descriptor =
-      getDescriptor().getMessageType(15);
-    internal_static_sysml_Outcome_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Outcome_descriptor,
-        new java.lang.String[] { "Outputs", "FinalState", "StatesVisited", "Error", "Linearizations", "Witness", "Diagnostics", });
-    internal_static_sysml_Outcome_OutputsEntry_descriptor =
-      internal_static_sysml_Outcome_descriptor.getNestedType(0);
-    internal_static_sysml_Outcome_OutputsEntry_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Outcome_OutputsEntry_descriptor,
-        new java.lang.String[] { "Key", "Value", });
-    internal_static_sysml_ExplorationStatus_descriptor =
-      getDescriptor().getMessageType(16);
-    internal_static_sysml_ExplorationStatus_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ExplorationStatus_descriptor,
-        new java.lang.String[] { "Complete", "Runs", "BudgetsHit", "RunsBudget", "DepthBudget", });
-    internal_static_sysml_ListEnginesRequest_descriptor =
-      getDescriptor().getMessageType(17);
-    internal_static_sysml_ListEnginesRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ListEnginesRequest_descriptor,
-        new java.lang.String[] { });
-    internal_static_sysml_EngineInfo_descriptor =
-      getDescriptor().getMessageType(18);
-    internal_static_sysml_EngineInfo_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_EngineInfo_descriptor,
-        new java.lang.String[] { "Name", "Authority", "Answers", "Bounds", "Process", "ProcessFound", "Ready", "Unavailable", "Kind", "Protocol", "Source", "Command", "Version", "Served", });
-    internal_static_sysml_ListEnginesResponse_descriptor =
-      getDescriptor().getMessageType(19);
-    internal_static_sysml_ListEnginesResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ListEnginesResponse_descriptor,
-        new java.lang.String[] { "Engines", });
-    internal_static_sysml_ParseFileRequest_descriptor =
-      getDescriptor().getMessageType(20);
-    internal_static_sysml_ParseFileRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ParseFileRequest_descriptor,
-        new java.lang.String[] { "FilePath", "Content", "ContentHash", "Language", "StrictConformance", "Source", });
-    internal_static_sysml_SourceDocument_descriptor =
-      getDescriptor().getMessageType(21);
-    internal_static_sysml_SourceDocument_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_SourceDocument_descriptor,
-        new java.lang.String[] { "FilePath", "Content", "Language", "Name", "Source", });
-    internal_static_sysml_ParseSourcesRequest_descriptor =
-      getDescriptor().getMessageType(22);
-    internal_static_sysml_ParseSourcesRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ParseSourcesRequest_descriptor,
-        new java.lang.String[] { "Documents", "StrictConformance", });
-    internal_static_sysml_ParseSourcesResponse_descriptor =
-      getDescriptor().getMessageType(23);
-    internal_static_sysml_ParseSourcesResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ParseSourcesResponse_descriptor,
-        new java.lang.String[] { "ModelHash", "Roots", "Diagnostics", "Error", });
-    internal_static_sysml_ParseFileResponse_descriptor =
-      getDescriptor().getMessageType(24);
-    internal_static_sysml_ParseFileResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ParseFileResponse_descriptor,
-        new java.lang.String[] { "ModelHash", "Root", "Diagnostics", "Error", });
-    internal_static_sysml_GetSymbolRequest_descriptor =
-      getDescriptor().getMessageType(25);
-    internal_static_sysml_GetSymbolRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_GetSymbolRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "SymbolId", });
-    internal_static_sysml_SymbolResponse_descriptor =
-      getDescriptor().getMessageType(26);
-    internal_static_sysml_SymbolResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_SymbolResponse_descriptor,
-        new java.lang.String[] { "Symbol", "Error", });
-    internal_static_sysml_DiagnosticsRequest_descriptor =
-      getDescriptor().getMessageType(27);
-    internal_static_sysml_DiagnosticsRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_DiagnosticsRequest_descriptor,
-        new java.lang.String[] { "ModelHash", });
-    internal_static_sysml_DiagnosticsResponse_descriptor =
-      getDescriptor().getMessageType(28);
-    internal_static_sysml_DiagnosticsResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_DiagnosticsResponse_descriptor,
-        new java.lang.String[] { "Diagnostics", "Error", });
-    internal_static_sysml_EvaluateRequest_descriptor =
-      getDescriptor().getMessageType(29);
-    internal_static_sysml_EvaluateRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_EvaluateRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "Expression", "ContextSymbolId", "SubjectSymbolId", });
-    internal_static_sysml_EvaluateResponse_descriptor =
-      getDescriptor().getMessageType(30);
-    internal_static_sysml_EvaluateResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_EvaluateResponse_descriptor,
-        new java.lang.String[] { "Result", "Error", "Diagnostics", });
-    internal_static_sysml_Instance_descriptor =
-      getDescriptor().getMessageType(31);
-    internal_static_sysml_Instance_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Instance_descriptor,
-        new java.lang.String[] { "Id", "TypeSymbolId", "FeatureValues", });
-    internal_static_sysml_Instance_FeatureValuesEntry_descriptor =
-      internal_static_sysml_Instance_descriptor.getNestedType(0);
-    internal_static_sysml_Instance_FeatureValuesEntry_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Instance_FeatureValuesEntry_descriptor,
-        new java.lang.String[] { "Key", "Value", });
-    internal_static_sysml_FeatureValue_descriptor =
-      getDescriptor().getMessageType(32);
-    internal_static_sysml_FeatureValue_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_FeatureValue_descriptor,
-        new java.lang.String[] { "FeatureName", "Value", "Values", "Materialized", "Error", });
-    internal_static_sysml_InstantiateRequest_descriptor =
-      getDescriptor().getMessageType(33);
-    internal_static_sysml_InstantiateRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_InstantiateRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "SymbolId", });
-    internal_static_sysml_InstantiateResponse_descriptor =
-      getDescriptor().getMessageType(34);
-    internal_static_sysml_InstantiateResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_InstantiateResponse_descriptor,
-        new java.lang.String[] { "Instance", "Error", "Diagnostics", "Instances", });
-    internal_static_sysml_ExecuteActionRequest_descriptor =
-      getDescriptor().getMessageType(35);
-    internal_static_sysml_ExecuteActionRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ExecuteActionRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "ActionSymbolId", "Inputs", "Schedule", });
-    internal_static_sysml_ExecuteActionRequest_InputsEntry_descriptor =
-      internal_static_sysml_ExecuteActionRequest_descriptor.getNestedType(0);
-    internal_static_sysml_ExecuteActionRequest_InputsEntry_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ExecuteActionRequest_InputsEntry_descriptor,
-        new java.lang.String[] { "Key", "Value", });
-    internal_static_sysml_ExecuteActionResponse_descriptor =
-      getDescriptor().getMessageType(36);
-    internal_static_sysml_ExecuteActionResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ExecuteActionResponse_descriptor,
-        new java.lang.String[] { "Outputs", "Error", "Diagnostics", "Outcomes", "Exploration", "FinalTime", });
-    internal_static_sysml_ExecuteActionResponse_OutputsEntry_descriptor =
-      internal_static_sysml_ExecuteActionResponse_descriptor.getNestedType(0);
-    internal_static_sysml_ExecuteActionResponse_OutputsEntry_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ExecuteActionResponse_OutputsEntry_descriptor,
-        new java.lang.String[] { "Key", "Value", });
-    internal_static_sysml_ExecuteStateRequest_descriptor =
-      getDescriptor().getMessageType(37);
-    internal_static_sysml_ExecuteStateRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ExecuteStateRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "StateMachineSymbolId", "Events", "Schedule", });
-    internal_static_sysml_ExecuteStateResponse_descriptor =
-      getDescriptor().getMessageType(38);
-    internal_static_sysml_ExecuteStateResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ExecuteStateResponse_descriptor,
-        new java.lang.String[] { "StatesVisited", "FinalContext", "Error", "Diagnostics", "Outcomes", "Exploration", "FinalTime", });
-    internal_static_sysml_ExecuteStateResponse_FinalContextEntry_descriptor =
-      internal_static_sysml_ExecuteStateResponse_descriptor.getNestedType(0);
-    internal_static_sysml_ExecuteStateResponse_FinalContextEntry_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ExecuteStateResponse_FinalContextEntry_descriptor,
-        new java.lang.String[] { "Key", "Value", });
-    internal_static_sysml_ConvertRequest_descriptor =
-      getDescriptor().getMessageType(39);
-    internal_static_sysml_ConvertRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ConvertRequest_descriptor,
-        new java.lang.String[] { "FilePath", "Content", "ModelHash", "FromFormat", "ToFormat", "TolerateSyntaxErrors", "Source", });
-    internal_static_sysml_ConvertResponse_descriptor =
-      getDescriptor().getMessageType(40);
-    internal_static_sysml_ConvertResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ConvertResponse_descriptor,
-        new java.lang.String[] { "Content", "FromFormat", "ToFormat", "Error", "Diagnostics", "Experimental", "ExperimentalNotice", });
-    internal_static_sysml_ApplyEditsRequest_descriptor =
-      getDescriptor().getMessageType(41);
-    internal_static_sysml_ApplyEditsRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ApplyEditsRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "Operations", });
-    internal_static_sysml_EditOperation_descriptor =
-      getDescriptor().getMessageType(42);
-    internal_static_sysml_EditOperation_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_EditOperation_descriptor,
-        new java.lang.String[] { "SetValue", "Rename", "AddMember", "Delete", "Move", "Operation", });
-    internal_static_sysml_AddMemberEdit_descriptor =
-      getDescriptor().getMessageType(43);
-    internal_static_sysml_AddMemberEdit_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_AddMemberEdit_descriptor,
-        new java.lang.String[] { "Owner", "Kind", "Name", "Type", "Multiplicity", "Value", "Specializes", });
-    internal_static_sysml_DeleteEdit_descriptor =
-      getDescriptor().getMessageType(44);
-    internal_static_sysml_DeleteEdit_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_DeleteEdit_descriptor,
-        new java.lang.String[] { "Target", "Cascade", });
-    internal_static_sysml_MoveEdit_descriptor =
-      getDescriptor().getMessageType(45);
-    internal_static_sysml_MoveEdit_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_MoveEdit_descriptor,
-        new java.lang.String[] { "Target", "Owner", });
-    internal_static_sysml_SetValueEdit_descriptor =
-      getDescriptor().getMessageType(46);
-    internal_static_sysml_SetValueEdit_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_SetValueEdit_descriptor,
-        new java.lang.String[] { "Target", "Value", });
-    internal_static_sysml_RenameEdit_descriptor =
-      getDescriptor().getMessageType(47);
-    internal_static_sysml_RenameEdit_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RenameEdit_descriptor,
-        new java.lang.String[] { "Target", "NewName", });
-    internal_static_sysml_ApplyEditsResponse_descriptor =
-      getDescriptor().getMessageType(48);
-    internal_static_sysml_ApplyEditsResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ApplyEditsResponse_descriptor,
-        new java.lang.String[] { "Content", "Applied", "Error", "Failure", "Diagnostics", "ReferringElements", });
-    internal_static_sysml_AppliedEdit_descriptor =
-      getDescriptor().getMessageType(49);
-    internal_static_sysml_AppliedEdit_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_AppliedEdit_descriptor,
-        new java.lang.String[] { "OperationIndex", "Target", "Offset", "Length", "OldText", "NewText", });
-    internal_static_sysml_SymbolInfo_descriptor =
-      getDescriptor().getMessageType(50);
-    internal_static_sysml_SymbolInfo_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_SymbolInfo_descriptor,
-        new java.lang.String[] { "Id", "Name", "Kind", "Metadata", "ChildIds", "Attributes", "TypeInfo", "Multiplicity", "Specializations", "WithheldLibraryAttributes", });
-    internal_static_sysml_SymbolInfo_MetadataEntry_descriptor =
-      internal_static_sysml_SymbolInfo_descriptor.getNestedType(0);
-    internal_static_sysml_SymbolInfo_MetadataEntry_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_SymbolInfo_MetadataEntry_descriptor,
-        new java.lang.String[] { "Key", "Value", });
-    internal_static_sysml_Specialization_descriptor =
-      getDescriptor().getMessageType(51);
-    internal_static_sysml_Specialization_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Specialization_descriptor,
-        new java.lang.String[] { "Kind", "Declared", "TargetId", "TargetKind", });
-    internal_static_sysml_TypeInfo_descriptor =
-      getDescriptor().getMessageType(52);
-    internal_static_sysml_TypeInfo_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_TypeInfo_descriptor,
-        new java.lang.String[] { "Declared", "ResolvedId", "ResolvedKind", "Primitive", "PrimitiveSource", "Quantity", "Unit", });
-    internal_static_sysml_MultiplicityInfo_descriptor =
-      getDescriptor().getMessageType(53);
-    internal_static_sysml_MultiplicityInfo_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_MultiplicityInfo_descriptor,
-        new java.lang.String[] { "Lower", "Upper", });
-    internal_static_sysml_AttributeInfo_descriptor =
-      getDescriptor().getMessageType(54);
-    internal_static_sysml_AttributeInfo_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_AttributeInfo_descriptor,
-        new java.lang.String[] { "Name", "Type", "Value", "Unit", });
-    internal_static_sysml_Value_descriptor =
-      getDescriptor().getMessageType(55);
-    internal_static_sysml_Value_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Value_descriptor,
-        new java.lang.String[] { "IntValue", "RealValue", "BoolValue", "StringValue", "InstanceId", "Sequence", "Null", "Quantity", "EnumLiteral", "Unset", "Complex", "Array", "Vector", "VectorQuantity", "MeasurementRef", "Infinity", "Function", "Set", "TensorQuantity", "Metaobject", "Undetermined", "Kind", });
-    internal_static_sysml_Metaobject_descriptor =
-      getDescriptor().getMessageType(56);
-    internal_static_sysml_Metaobject_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Metaobject_descriptor,
-        new java.lang.String[] { "ElementId", "MetaclassId", });
-    internal_static_sysml_Undetermined_descriptor =
-      getDescriptor().getMessageType(57);
-    internal_static_sysml_Undetermined_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Undetermined_descriptor,
-        new java.lang.String[] { "Reason", "Count", });
-    internal_static_sysml_Function_descriptor =
-      getDescriptor().getMessageType(58);
-    internal_static_sysml_Function_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Function_descriptor,
-        new java.lang.String[] { "CalcId", "SelfId", });
-    internal_static_sysml_ValueSet_descriptor =
-      getDescriptor().getMessageType(59);
-    internal_static_sysml_ValueSet_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ValueSet_descriptor,
-        new java.lang.String[] { "Elements", });
-    internal_static_sysml_TensorQuantity_descriptor =
-      getDescriptor().getMessageType(60);
-    internal_static_sysml_TensorQuantity_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_TensorQuantity_descriptor,
-        new java.lang.String[] { "Dimensions", "Components", });
-    internal_static_sysml_Array_descriptor =
-      getDescriptor().getMessageType(61);
-    internal_static_sysml_Array_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Array_descriptor,
-        new java.lang.String[] { "Dimensions", "Elements", });
-    internal_static_sysml_Vector_descriptor =
-      getDescriptor().getMessageType(62);
-    internal_static_sysml_Vector_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Vector_descriptor,
-        new java.lang.String[] { "Components", });
-    internal_static_sysml_VectorQuantity_descriptor =
-      getDescriptor().getMessageType(63);
-    internal_static_sysml_VectorQuantity_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_VectorQuantity_descriptor,
-        new java.lang.String[] { "Components", });
-    internal_static_sysml_Complex_descriptor =
-      getDescriptor().getMessageType(64);
-    internal_static_sysml_Complex_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Complex_descriptor,
-        new java.lang.String[] { "Real", "Imaginary", });
-    internal_static_sysml_EnumLiteral_descriptor =
-      getDescriptor().getMessageType(65);
-    internal_static_sysml_EnumLiteral_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_EnumLiteral_descriptor,
-        new java.lang.String[] { "LiteralId", "EnumerationId", "Name", "Value", });
-    internal_static_sysml_ValueSequence_descriptor =
-      getDescriptor().getMessageType(66);
-    internal_static_sysml_ValueSequence_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ValueSequence_descriptor,
-        new java.lang.String[] { "Elements", });
-    internal_static_sysml_Quantity_descriptor =
-      getDescriptor().getMessageType(67);
-    internal_static_sysml_Quantity_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Quantity_descriptor,
-        new java.lang.String[] { "IntMagnitude", "RealMagnitude", "Unit", "UnitTerm", "Magnitude", });
-    internal_static_sysml_MeasurementRef_descriptor =
-      getDescriptor().getMessageType(68);
-    internal_static_sysml_MeasurementRef_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_MeasurementRef_descriptor,
-        new java.lang.String[] { "Unit", "UnitTerm", "UnitId", });
-    internal_static_sysml_UnitTerm_descriptor =
-      getDescriptor().getMessageType(69);
-    internal_static_sysml_UnitTerm_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_UnitTerm_descriptor,
-        new java.lang.String[] { "ScaleNum", "ScaleDen", "Factors", });
-    internal_static_sysml_UnitFactor_descriptor =
-      getDescriptor().getMessageType(70);
-    internal_static_sysml_UnitFactor_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_UnitFactor_descriptor,
-        new java.lang.String[] { "UnitId", "Exponent", });
-    internal_static_sysml_Diagnostic_descriptor =
-      getDescriptor().getMessageType(71);
-    internal_static_sysml_Diagnostic_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Diagnostic_descriptor,
-        new java.lang.String[] { "Severity", "Message", "Span", "Code", });
-    internal_static_sysml_Span_descriptor =
-      getDescriptor().getMessageType(72);
-    internal_static_sysml_Span_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Span_descriptor,
-        new java.lang.String[] { "File", "StartLine", "StartCol", "EndLine", "EndCol", });
-    internal_static_sysml_ServerInfoRequest_descriptor =
-      getDescriptor().getMessageType(73);
-    internal_static_sysml_ServerInfoRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ServerInfoRequest_descriptor,
-        new java.lang.String[] { });
-    internal_static_sysml_ServerInfoResponse_descriptor =
-      getDescriptor().getMessageType(74);
-    internal_static_sysml_ServerInfoResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_ServerInfoResponse_descriptor,
-        new java.lang.String[] { "Version", "Capabilities", });
-    internal_static_sysml_QueryRequest_descriptor =
-      getDescriptor().getMessageType(75);
-    internal_static_sysml_QueryRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_QueryRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "Query", "OslcQuery", });
-    internal_static_sysml_QueryResponse_descriptor =
-      getDescriptor().getMessageType(76);
-    internal_static_sysml_QueryResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_QueryResponse_descriptor,
-        new java.lang.String[] { "Elements", });
-    internal_static_sysml_Query_descriptor =
-      getDescriptor().getMessageType(77);
-    internal_static_sysml_Query_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Query_descriptor,
-        new java.lang.String[] { "Scope", "Select", "Where", });
-    internal_static_sysml_Constraint_descriptor =
-      getDescriptor().getMessageType(78);
-    internal_static_sysml_Constraint_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_Constraint_descriptor,
-        new java.lang.String[] { "Primitive", "Composite", "Constraint", });
-    internal_static_sysml_PrimitiveConstraint_descriptor =
-      getDescriptor().getMessageType(79);
-    internal_static_sysml_PrimitiveConstraint_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_PrimitiveConstraint_descriptor,
-        new java.lang.String[] { "Inverse", "Property", "Operator", "Value", });
-    internal_static_sysml_CompositeConstraint_descriptor =
-      getDescriptor().getMessageType(80);
-    internal_static_sysml_CompositeConstraint_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_CompositeConstraint_descriptor,
-        new java.lang.String[] { "Operator", "Constraint", });
-    internal_static_sysml_QueryResultElement_descriptor =
-      getDescriptor().getMessageType(81);
-    internal_static_sysml_QueryResultElement_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_QueryResultElement_descriptor,
-        new java.lang.String[] { "Id", "Type", "Properties", });
-    internal_static_sysml_QueryResultElement_PropertiesEntry_descriptor =
-      internal_static_sysml_QueryResultElement_descriptor.getNestedType(0);
-    internal_static_sysml_QueryResultElement_PropertiesEntry_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_QueryResultElement_PropertiesEntry_descriptor,
-        new java.lang.String[] { "Key", "Value", });
-    internal_static_sysml_SweepRange_descriptor =
-      getDescriptor().getMessageType(82);
-    internal_static_sysml_SweepRange_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_SweepRange_descriptor,
-        new java.lang.String[] { "Parameter", "Start", "End", "Step", });
-    internal_static_sysml_RunSweepRequest_descriptor =
-      getDescriptor().getMessageType(83);
-    internal_static_sysml_RunSweepRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RunSweepRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "SymbolId", "SubjectSymbolId", "Arguments", "NamedArguments", "Ranges", "Samples", "Seed", "Engine", });
-    internal_static_sysml_RunSweepRequest_NamedArgumentsEntry_descriptor =
-      internal_static_sysml_RunSweepRequest_descriptor.getNestedType(0);
-    internal_static_sysml_RunSweepRequest_NamedArgumentsEntry_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RunSweepRequest_NamedArgumentsEntry_descriptor,
-        new java.lang.String[] { "Key", "Value", });
-    internal_static_sysml_SweepRow_descriptor =
-      getDescriptor().getMessageType(84);
-    internal_static_sysml_SweepRow_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_SweepRow_descriptor,
-        new java.lang.String[] { "Inputs", "Outputs", "Verdicts", "ElapsedMicros", "Error", "FailureReason", "Evaluations", });
-    internal_static_sysml_RunSweepResponse_descriptor =
-      getDescriptor().getMessageType(85);
-    internal_static_sysml_RunSweepResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RunSweepResponse_descriptor,
-        new java.lang.String[] { "Rows", "Parameters", "Sampled", "Seed", "Error", "Diagnostics", "FailureReason", "Instances", "Engine", "Strength", "Bounds", });
-    internal_static_sysml_RunDocumentQueryRequest_descriptor =
-      getDescriptor().getMessageType(86);
-    internal_static_sysml_RunDocumentQueryRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RunDocumentQueryRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "QueryId", "Bindings", });
-    internal_static_sysml_DocumentQueryBinding_descriptor =
-      getDescriptor().getMessageType(87);
-    internal_static_sysml_DocumentQueryBinding_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_DocumentQueryBinding_descriptor,
-        new java.lang.String[] { "Parameter", "Values", });
-    internal_static_sysml_DocumentValue_descriptor =
-      getDescriptor().getMessageType(88);
-    internal_static_sysml_DocumentValue_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_DocumentValue_descriptor,
-        new java.lang.String[] { "ElementId", "StringValue", "IntValue", "RealValue", "BoolValue", "Infinity", "Quantity", "ElementType", "Kind", });
-    internal_static_sysml_DocumentQueryColumn_descriptor =
-      getDescriptor().getMessageType(89);
-    internal_static_sysml_DocumentQueryColumn_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_DocumentQueryColumn_descriptor,
-        new java.lang.String[] { "Name", });
-    internal_static_sysml_DocumentQueryCell_descriptor =
-      getDescriptor().getMessageType(90);
-    internal_static_sysml_DocumentQueryCell_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_DocumentQueryCell_descriptor,
-        new java.lang.String[] { "Values", });
-    internal_static_sysml_DocumentQueryRow_descriptor =
-      getDescriptor().getMessageType(91);
-    internal_static_sysml_DocumentQueryRow_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_DocumentQueryRow_descriptor,
-        new java.lang.String[] { "Element", "Cells", });
-    internal_static_sysml_RunDocumentQueryResponse_descriptor =
-      getDescriptor().getMessageType(92);
-    internal_static_sysml_RunDocumentQueryResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RunDocumentQueryResponse_descriptor,
-        new java.lang.String[] { "Columns", "Rows", });
-    internal_static_sysml_RenderDocumentRequest_descriptor =
-      getDescriptor().getMessageType(93);
-    internal_static_sysml_RenderDocumentRequest_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RenderDocumentRequest_descriptor,
-        new java.lang.String[] { "ModelHash", "DocumentId", });
-    internal_static_sysml_RenderDocumentResponse_descriptor =
-      getDescriptor().getMessageType(94);
-    internal_static_sysml_RenderDocumentResponse_fieldAccessorTable = new
-      com.google.protobuf.GeneratedMessage.FieldAccessorTable(
-        internal_static_sysml_RenderDocumentResponse_descriptor,
-        new java.lang.String[] { "Markdown", });
-    descriptor.resolveAllFeaturesImmutable();
-  }
-
-  // @@protoc_insertion_point(outer_class_scope)
-}
diff --git a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Verdict.java b/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Verdict.java
deleted file mode 100644
index 0a6b5b35c0..0000000000
--- a/clients/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/Verdict.java
+++ /dev/null
@@ -1,2704 +0,0 @@
-// Generated by the protocol buffer compiler.  DO NOT EDIT!
-// NO CHECKED-IN PROTOBUF GENCODE
-// source: sysml.proto
-// Protobuf Java Version: 4.33.1
-
-package org.openmbee.opensysml.proto;
-
-/**
- * <pre>
- * Verdict is one verification's answer: whether the condition held, and, when
- * it did not, which condition the model answered false about.
- * </pre>
- *
- * Protobuf type {@code sysml.Verdict}
- */
-@com.google.protobuf.Generated
-public final class Verdict extends
-    com.google.protobuf.GeneratedMessage implements
-    // @@protoc_insertion_point(message_implements:sysml.Verdict)
-    VerdictOrBuilder {
-private static final long serialVersionUID = 0L;
-  static {
-    com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion(
-      com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC,
-      /* major= */ 4,
-      /* minor= */ 33,
-      /* patch= */ 1,
-      /* suffix= */ "",
-      "Verdict");
-  }
-  // Use Verdict.newBuilder() to construct.
-  private Verdict(com.google.protobuf.GeneratedMessage.Builder<?> builder) {
-    super(builder);
-  }
-  private Verdict() {
-    kind_ = "";
-    elementId_ = "";
-    element_ = "";
-    condition_ = "";
-    instanceTypeId_ = "";
-    error_ = "";
-    failureReason_ = 0;
-    requirementId_ = "";
-    engine_ = "";
-    strength_ = "";
-    bounds_ = java.util.Collections.emptyList();
-  }
-
-  public static final com.google.protobuf.Descriptors.Descriptor
-      getDescriptor() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Verdict_descriptor;
-  }
-
-  @java.lang.Override
-  protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-      internalGetFieldAccessorTable() {
-    return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Verdict_fieldAccessorTable
-        .ensureFieldAccessorsInitialized(
-            org.openmbee.opensysml.proto.Verdict.class, org.openmbee.opensysml.proto.Verdict.Builder.class);
-  }
-
-  public static final int KIND_FIELD_NUMBER = 1;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object kind_ = "";
-  /**
-   * <pre>
-   * What was verified: "constraint", "requirement" or "satisfy"; for a check
-   * an analysis case run made, "objective" or "assertion".
-   * </pre>
-   *
-   * <code>string kind = 1 [json_name = "kind"];</code>
-   * @return The kind.
-   */
-  @java.lang.Override
-  public java.lang.String getKind() {
-    java.lang.Object ref = kind_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      kind_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * What was verified: "constraint", "requirement" or "satisfy"; for a check
-   * an analysis case run made, "objective" or "assertion".
-   * </pre>
-   *
-   * <code>string kind = 1 [json_name = "kind"];</code>
-   * @return The bytes for kind.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getKindBytes() {
-    java.lang.Object ref = kind_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      kind_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int ELEMENT_ID_FIELD_NUMBER = 2;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object elementId_ = "";
-  /**
-   * <pre>
-   * FQN of the element verified; empty for an anonymous satisfy assertion or
-   * an anonymous assertion in a case body.
-   * </pre>
-   *
-   * <code>string element_id = 2 [json_name = "elementId"];</code>
-   * @return The elementId.
-   */
-  @java.lang.Override
-  public java.lang.String getElementId() {
-    java.lang.Object ref = elementId_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      elementId_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * FQN of the element verified; empty for an anonymous satisfy assertion or
-   * an anonymous assertion in a case body.
-   * </pre>
-   *
-   * <code>string element_id = 2 [json_name = "elementId"];</code>
-   * @return The bytes for elementId.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getElementIdBytes() {
-    java.lang.Object ref = elementId_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      elementId_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int ELEMENT_FIELD_NUMBER = 3;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object element_ = "";
-  /**
-   * <pre>
-   * The element as a reader names it: its FQN, or, for an anonymous satisfy
-   * assertion, the assertion as written ("satisfy Range by cruise"); for an
-   * objective or assertion of a case, its name or its condition as written.
-   * </pre>
-   *
-   * <code>string element = 3 [json_name = "element"];</code>
-   * @return The element.
-   */
-  @java.lang.Override
-  public java.lang.String getElement() {
-    java.lang.Object ref = element_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      element_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * The element as a reader names it: its FQN, or, for an anonymous satisfy
-   * assertion, the assertion as written ("satisfy Range by cruise"); for an
-   * objective or assertion of a case, its name or its condition as written.
-   * </pre>
-   *
-   * <code>string element = 3 [json_name = "element"];</code>
-   * @return The bytes for element.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getElementBytes() {
-    java.lang.Object ref = element_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      element_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int HOLDS_FIELD_NUMBER = 4;
-  private boolean holds_ = false;
-  /**
-   * <pre>
-   * Whether the condition holds. False with an empty `error` is the model's own
-   * answer; false with an `error` is no answer at all.
-   * </pre>
-   *
-   * <code>bool holds = 4 [json_name = "holds"];</code>
-   * @return The holds.
-   */
-  @java.lang.Override
-  public boolean getHolds() {
-    return holds_;
-  }
-
-  public static final int CONDITION_FIELD_NUMBER = 5;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object condition_ = "";
-  /**
-   * <pre>
-   * The condition that evaluated to false, as written, when the runtime names
-   * one. Empty when the verdict holds or the condition is not named.
-   * </pre>
-   *
-   * <code>string condition = 5 [json_name = "condition"];</code>
-   * @return The condition.
-   */
-  @java.lang.Override
-  public java.lang.String getCondition() {
-    java.lang.Object ref = condition_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      condition_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * The condition that evaluated to false, as written, when the runtime names
-   * one. Empty when the verdict holds or the condition is not named.
-   * </pre>
-   *
-   * <code>string condition = 5 [json_name = "condition"];</code>
-   * @return The bytes for condition.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getConditionBytes() {
-    java.lang.Object ref = condition_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      condition_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int INSTANCE_ID_FIELD_NUMBER = 6;
-  private long instanceId_ = 0L;
-  /**
-   * <pre>
-   * Instance the verdict is about, 0 when it is about declared values alone.
-   * Its feature values are in `instances`, so a client can read what made it fail.
-   * </pre>
-   *
-   * <code>int64 instance_id = 6 [json_name = "instanceId"];</code>
-   * @return The instanceId.
-   */
-  @java.lang.Override
-  public long getInstanceId() {
-    return instanceId_;
-  }
-
-  public static final int INSTANCE_TYPE_ID_FIELD_NUMBER = 7;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object instanceTypeId_ = "";
-  /**
-   * <pre>
-   * Type of `instance_id`, as an FQN, for naming the object in a report.
-   * </pre>
-   *
-   * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
-   * @return The instanceTypeId.
-   */
-  @java.lang.Override
-  public java.lang.String getInstanceTypeId() {
-    java.lang.Object ref = instanceTypeId_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      instanceTypeId_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * Type of `instance_id`, as an FQN, for naming the object in a report.
-   * </pre>
-   *
-   * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
-   * @return The bytes for instanceTypeId.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getInstanceTypeIdBytes() {
-    java.lang.Object ref = instanceTypeId_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      instanceTypeId_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int ERROR_FIELD_NUMBER = 8;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object error_ = "";
-  /**
-   * <pre>
-   * Set when evaluation failed rather than the model answering false: unbound
-   * features, incommensurable units, an exhausted step budget. `holds` is then
-   * false but is not a verdict.
-   * </pre>
-   *
-   * <code>string error = 8 [json_name = "error"];</code>
-   * @return The error.
-   */
-  @java.lang.Override
-  public java.lang.String getError() {
-    java.lang.Object ref = error_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      error_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * Set when evaluation failed rather than the model answering false: unbound
-   * features, incommensurable units, an exhausted step budget. `holds` is then
-   * false but is not a verdict.
-   * </pre>
-   *
-   * <code>string error = 8 [json_name = "error"];</code>
-   * @return The bytes for error.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getErrorBytes() {
-    java.lang.Object ref = error_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      error_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int FAILURE_REASON_FIELD_NUMBER = 9;
-  private int failureReason_ = 0;
-  /**
-   * <pre>
-   * What kind of failure `error` reports. WRONG_KIND says the symbol named
-   * declares something else, which is a wrong request rather than an undecided
-   * verdict about the model.
-   * </pre>
-   *
-   * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
-   * @return The enum numeric value on the wire for failureReason.
-   */
-  @java.lang.Override public int getFailureReasonValue() {
-    return failureReason_;
-  }
-  /**
-   * <pre>
-   * What kind of failure `error` reports. WRONG_KIND says the symbol named
-   * declares something else, which is a wrong request rather than an undecided
-   * verdict about the model.
-   * </pre>
-   *
-   * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
-   * @return The failureReason.
-   */
-  @java.lang.Override public org.openmbee.opensysml.proto.FailureReason getFailureReason() {
-    org.openmbee.opensysml.proto.FailureReason result = org.openmbee.opensysml.proto.FailureReason.forNumber(failureReason_);
-    return result == null ? org.openmbee.opensysml.proto.FailureReason.UNRECOGNIZED : result;
-  }
-
-  public static final int REQUIREMENT_ID_FIELD_NUMBER = 10;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object requirementId_ = "";
-  /**
-   * <pre>
-   * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
-   * associates it with the verification_verdicts reported for that requirement.
-   * Empty for every other kind, and for a requirement no FQN names.
-   * </pre>
-   *
-   * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
-   * @return The requirementId.
-   */
-  @java.lang.Override
-  public java.lang.String getRequirementId() {
-    java.lang.Object ref = requirementId_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      requirementId_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
-   * associates it with the verification_verdicts reported for that requirement.
-   * Empty for every other kind, and for a requirement no FQN names.
-   * </pre>
-   *
-   * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
-   * @return The bytes for requirementId.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getRequirementIdBytes() {
-    java.lang.Object ref = requirementId_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      requirementId_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int ENGINE_FIELD_NUMBER = 11;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object engine_ = "";
-  /**
-   * <pre>
-   * The engine whose answer this verdict is, as ListEngines names it; empty
-   * for a verdict decided before any engine was asked. Reported as the
-   * "engines" capability, with `strength` and `bounds`.
-   * </pre>
-   *
-   * <code>string engine = 11 [json_name = "engine"];</code>
-   * @return The engine.
-   */
-  @java.lang.Override
-  public java.lang.String getEngine() {
-    java.lang.Object ref = engine_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      engine_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * The engine whose answer this verdict is, as ListEngines names it; empty
-   * for a verdict decided before any engine was asked. Reported as the
-   * "engines" capability, with `strength` and `bounds`.
-   * </pre>
-   *
-   * <code>string engine = 11 [json_name = "engine"];</code>
-   * @return The bytes for engine.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getEngineBytes() {
-    java.lang.Object ref = engine_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      engine_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int STRENGTH_FIELD_NUMBER = 12;
-  @SuppressWarnings("serial")
-  private volatile java.lang.Object strength_ = "";
-  /**
-   * <pre>
-   * The strength of the evidence behind the verdict: "observed" for one run,
-   * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
-   * search within the bounds, "proved" for every case, "not covered" when no
-   * engine decided it.
-   * </pre>
-   *
-   * <code>string strength = 12 [json_name = "strength"];</code>
-   * @return The strength.
-   */
-  @java.lang.Override
-  public java.lang.String getStrength() {
-    java.lang.Object ref = strength_;
-    if (ref instanceof java.lang.String) {
-      return (java.lang.String) ref;
-    } else {
-      com.google.protobuf.ByteString bs = 
-          (com.google.protobuf.ByteString) ref;
-      java.lang.String s = bs.toStringUtf8();
-      strength_ = s;
-      return s;
-    }
-  }
-  /**
-   * <pre>
-   * The strength of the evidence behind the verdict: "observed" for one run,
-   * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
-   * search within the bounds, "proved" for every case, "not covered" when no
-   * engine decided it.
-   * </pre>
-   *
-   * <code>string strength = 12 [json_name = "strength"];</code>
-   * @return The bytes for strength.
-   */
-  @java.lang.Override
-  public com.google.protobuf.ByteString
-      getStrengthBytes() {
-    java.lang.Object ref = strength_;
-    if (ref instanceof java.lang.String) {
-      com.google.protobuf.ByteString b = 
-          com.google.protobuf.ByteString.copyFromUtf8(
-              (java.lang.String) ref);
-      strength_ = b;
-      return b;
-    } else {
-      return (com.google.protobuf.ByteString) ref;
-    }
-  }
-
-  public static final int BOUNDS_FIELD_NUMBER = 13;
-  @SuppressWarnings("serial")
-  private java.util.List<org.openmbee.opensysml.proto.Bound> bounds_;
-  /**
-   * <pre>
-   * The bounds the engine ran under, each marked when it stopped the run.
-   * </pre>
-   *
-   * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-   */
-  @java.lang.Override
-  public java.util.List<org.openmbee.opensysml.proto.Bound> getBoundsList() {
-    return bounds_;
-  }
-  /**
-   * <pre>
-   * The bounds the engine ran under, each marked when it stopped the run.
-   * </pre>
-   *
-   * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-   */
-  @java.lang.Override
-  public java.util.List<? extends org.openmbee.opensysml.proto.BoundOrBuilder> 
-      getBoundsOrBuilderList() {
-    return bounds_;
-  }
-  /**
-   * <pre>
-   * The bounds the engine ran under, each marked when it stopped the run.
-   * </pre>
-   *
-   * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-   */
-  @java.lang.Override
-  public int getBoundsCount() {
-    return bounds_.size();
-  }
-  /**
-   * <pre>
-   * The bounds the engine ran under, each marked when it stopped the run.
-   * </pre>
-   *
-   * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.Bound getBounds(int index) {
-    return bounds_.get(index);
-  }
-  /**
-   * <pre>
-   * The bounds the engine ran under, each marked when it stopped the run.
-   * </pre>
-   *
-   * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-   */
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.BoundOrBuilder getBoundsOrBuilder(
-      int index) {
-    return bounds_.get(index);
-  }
-
-  private byte memoizedIsInitialized = -1;
-  @java.lang.Override
-  public final boolean isInitialized() {
-    byte isInitialized = memoizedIsInitialized;
-    if (isInitialized == 1) return true;
-    if (isInitialized == 0) return false;
-
-    memoizedIsInitialized = 1;
-    return true;
-  }
-
-  @java.lang.Override
-  public void writeTo(com.google.protobuf.CodedOutputStream output)
-                      throws java.io.IOException {
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(kind_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 1, kind_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(elementId_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 2, elementId_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(element_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 3, element_);
-    }
-    if (holds_ != false) {
-      output.writeBool(4, holds_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(condition_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 5, condition_);
-    }
-    if (instanceId_ != 0L) {
-      output.writeInt64(6, instanceId_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(instanceTypeId_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 7, instanceTypeId_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 8, error_);
-    }
-    if (failureReason_ != org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_UNSPECIFIED.getNumber()) {
-      output.writeEnum(9, failureReason_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(requirementId_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 10, requirementId_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(engine_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 11, engine_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(strength_)) {
-      com.google.protobuf.GeneratedMessage.writeString(output, 12, strength_);
-    }
-    for (int i = 0; i < bounds_.size(); i++) {
-      output.writeMessage(13, bounds_.get(i));
-    }
-    getUnknownFields().writeTo(output);
-  }
-
-  @java.lang.Override
-  public int getSerializedSize() {
-    int size = memoizedSize;
-    if (size != -1) return size;
-
-    size = 0;
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(kind_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(1, kind_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(elementId_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(2, elementId_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(element_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(3, element_);
-    }
-    if (holds_ != false) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeBoolSize(4, holds_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(condition_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(5, condition_);
-    }
-    if (instanceId_ != 0L) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeInt64Size(6, instanceId_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(instanceTypeId_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(7, instanceTypeId_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(error_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(8, error_);
-    }
-    if (failureReason_ != org.openmbee.opensysml.proto.FailureReason.FAILURE_REASON_UNSPECIFIED.getNumber()) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeEnumSize(9, failureReason_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(requirementId_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(10, requirementId_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(engine_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(11, engine_);
-    }
-    if (!com.google.protobuf.GeneratedMessage.isStringEmpty(strength_)) {
-      size += com.google.protobuf.GeneratedMessage.computeStringSize(12, strength_);
-    }
-    for (int i = 0; i < bounds_.size(); i++) {
-      size += com.google.protobuf.CodedOutputStream
-        .computeMessageSize(13, bounds_.get(i));
-    }
-    size += getUnknownFields().getSerializedSize();
-    memoizedSize = size;
-    return size;
-  }
-
-  @java.lang.Override
-  public boolean equals(final java.lang.Object obj) {
-    if (obj == this) {
-     return true;
-    }
-    if (!(obj instanceof org.openmbee.opensysml.proto.Verdict)) {
-      return super.equals(obj);
-    }
-    org.openmbee.opensysml.proto.Verdict other = (org.openmbee.opensysml.proto.Verdict) obj;
-
-    if (!getKind()
-        .equals(other.getKind())) return false;
-    if (!getElementId()
-        .equals(other.getElementId())) return false;
-    if (!getElement()
-        .equals(other.getElement())) return false;
-    if (getHolds()
-        != other.getHolds()) return false;
-    if (!getCondition()
-        .equals(other.getCondition())) return false;
-    if (getInstanceId()
-        != other.getInstanceId()) return false;
-    if (!getInstanceTypeId()
-        .equals(other.getInstanceTypeId())) return false;
-    if (!getError()
-        .equals(other.getError())) return false;
-    if (failureReason_ != other.failureReason_) return false;
-    if (!getRequirementId()
-        .equals(other.getRequirementId())) return false;
-    if (!getEngine()
-        .equals(other.getEngine())) return false;
-    if (!getStrength()
-        .equals(other.getStrength())) return false;
-    if (!getBoundsList()
-        .equals(other.getBoundsList())) return false;
-    if (!getUnknownFields().equals(other.getUnknownFields())) return false;
-    return true;
-  }
-
-  @java.lang.Override
-  public int hashCode() {
-    if (memoizedHashCode != 0) {
-      return memoizedHashCode;
-    }
-    int hash = 41;
-    hash = (19 * hash) + getDescriptor().hashCode();
-    hash = (37 * hash) + KIND_FIELD_NUMBER;
-    hash = (53 * hash) + getKind().hashCode();
-    hash = (37 * hash) + ELEMENT_ID_FIELD_NUMBER;
-    hash = (53 * hash) + getElementId().hashCode();
-    hash = (37 * hash) + ELEMENT_FIELD_NUMBER;
-    hash = (53 * hash) + getElement().hashCode();
-    hash = (37 * hash) + HOLDS_FIELD_NUMBER;
-    hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean(
-        getHolds());
-    hash = (37 * hash) + CONDITION_FIELD_NUMBER;
-    hash = (53 * hash) + getCondition().hashCode();
-    hash = (37 * hash) + INSTANCE_ID_FIELD_NUMBER;
-    hash = (53 * hash) + com.google.protobuf.Internal.hashLong(
-        getInstanceId());
-    hash = (37 * hash) + INSTANCE_TYPE_ID_FIELD_NUMBER;
-    hash = (53 * hash) + getInstanceTypeId().hashCode();
-    hash = (37 * hash) + ERROR_FIELD_NUMBER;
-    hash = (53 * hash) + getError().hashCode();
-    hash = (37 * hash) + FAILURE_REASON_FIELD_NUMBER;
-    hash = (53 * hash) + failureReason_;
-    hash = (37 * hash) + REQUIREMENT_ID_FIELD_NUMBER;
-    hash = (53 * hash) + getRequirementId().hashCode();
-    hash = (37 * hash) + ENGINE_FIELD_NUMBER;
-    hash = (53 * hash) + getEngine().hashCode();
-    hash = (37 * hash) + STRENGTH_FIELD_NUMBER;
-    hash = (53 * hash) + getStrength().hashCode();
-    if (getBoundsCount() > 0) {
-      hash = (37 * hash) + BOUNDS_FIELD_NUMBER;
-      hash = (53 * hash) + getBoundsList().hashCode();
-    }
-    hash = (29 * hash) + getUnknownFields().hashCode();
-    memoizedHashCode = hash;
-    return hash;
-  }
-
-  public static org.openmbee.opensysml.proto.Verdict parseFrom(
-      java.nio.ByteBuffer data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.Verdict parseFrom(
-      java.nio.ByteBuffer data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.Verdict parseFrom(
-      com.google.protobuf.ByteString data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.Verdict parseFrom(
-      com.google.protobuf.ByteString data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.Verdict parseFrom(byte[] data)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data);
-  }
-  public static org.openmbee.opensysml.proto.Verdict parseFrom(
-      byte[] data,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws com.google.protobuf.InvalidProtocolBufferException {
-    return PARSER.parseFrom(data, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.Verdict parseFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.Verdict parseFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  public static org.openmbee.opensysml.proto.Verdict parseDelimitedFrom(java.io.InputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input);
-  }
-
-  public static org.openmbee.opensysml.proto.Verdict parseDelimitedFrom(
-      java.io.InputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseDelimitedWithIOException(PARSER, input, extensionRegistry);
-  }
-  public static org.openmbee.opensysml.proto.Verdict parseFrom(
-      com.google.protobuf.CodedInputStream input)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input);
-  }
-  public static org.openmbee.opensysml.proto.Verdict parseFrom(
-      com.google.protobuf.CodedInputStream input,
-      com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-      throws java.io.IOException {
-    return com.google.protobuf.GeneratedMessage
-        .parseWithIOException(PARSER, input, extensionRegistry);
-  }
-
-  @java.lang.Override
-  public Builder newBuilderForType() { return newBuilder(); }
-  public static Builder newBuilder() {
-    return DEFAULT_INSTANCE.toBuilder();
-  }
-  public static Builder newBuilder(org.openmbee.opensysml.proto.Verdict prototype) {
-    return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
-  }
-  @java.lang.Override
-  public Builder toBuilder() {
-    return this == DEFAULT_INSTANCE
-        ? new Builder() : new Builder().mergeFrom(this);
-  }
-
-  @java.lang.Override
-  protected Builder newBuilderForType(
-      com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-    Builder builder = new Builder(parent);
-    return builder;
-  }
-  /**
-   * <pre>
-   * Verdict is one verification's answer: whether the condition held, and, when
-   * it did not, which condition the model answered false about.
-   * </pre>
-   *
-   * Protobuf type {@code sysml.Verdict}
-   */
-  public static final class Builder extends
-      com.google.protobuf.GeneratedMessage.Builder<Builder> implements
-      // @@protoc_insertion_point(builder_implements:sysml.Verdict)
-      org.openmbee.opensysml.proto.VerdictOrBuilder {
-    public static final com.google.protobuf.Descriptors.Descriptor
-        getDescriptor() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Verdict_descriptor;
-    }
-
-    @java.lang.Override
-    protected com.google.protobuf.GeneratedMessage.FieldAccessorTable
-        internalGetFieldAccessorTable() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Verdict_fieldAccessorTable
-          .ensureFieldAccessorsInitialized(
-              org.openmbee.opensysml.proto.Verdict.class, org.openmbee.opensysml.proto.Verdict.Builder.class);
-    }
-
-    // Construct using org.openmbee.opensysml.proto.Verdict.newBuilder()
-    private Builder() {
-
-    }
-
-    private Builder(
-        com.google.protobuf.GeneratedMessage.BuilderParent parent) {
-      super(parent);
-
-    }
-    @java.lang.Override
-    public Builder clear() {
-      super.clear();
-      bitField0_ = 0;
-      kind_ = "";
-      elementId_ = "";
-      element_ = "";
-      holds_ = false;
-      condition_ = "";
-      instanceId_ = 0L;
-      instanceTypeId_ = "";
-      error_ = "";
-      failureReason_ = 0;
-      requirementId_ = "";
-      engine_ = "";
-      strength_ = "";
-      if (boundsBuilder_ == null) {
-        bounds_ = java.util.Collections.emptyList();
-      } else {
-        bounds_ = null;
-        boundsBuilder_.clear();
-      }
-      bitField0_ = (bitField0_ & ~0x00001000);
-      return this;
-    }
-
-    @java.lang.Override
-    public com.google.protobuf.Descriptors.Descriptor
-        getDescriptorForType() {
-      return org.openmbee.opensysml.proto.Sysml.internal_static_sysml_Verdict_descriptor;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.Verdict getDefaultInstanceForType() {
-      return org.openmbee.opensysml.proto.Verdict.getDefaultInstance();
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.Verdict build() {
-      org.openmbee.opensysml.proto.Verdict result = buildPartial();
-      if (!result.isInitialized()) {
-        throw newUninitializedMessageException(result);
-      }
-      return result;
-    }
-
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.Verdict buildPartial() {
-      org.openmbee.opensysml.proto.Verdict result = new org.openmbee.opensysml.proto.Verdict(this);
-      buildPartialRepeatedFields(result);
-      if (bitField0_ != 0) { buildPartial0(result); }
-      onBuilt();
-      return result;
-    }
-
-    private void buildPartialRepeatedFields(org.openmbee.opensysml.proto.Verdict result) {
-      if (boundsBuilder_ == null) {
-        if (((bitField0_ & 0x00001000) != 0)) {
-          bounds_ = java.util.Collections.unmodifiableList(bounds_);
-          bitField0_ = (bitField0_ & ~0x00001000);
-        }
-        result.bounds_ = bounds_;
-      } else {
-        result.bounds_ = boundsBuilder_.build();
-      }
-    }
-
-    private void buildPartial0(org.openmbee.opensysml.proto.Verdict result) {
-      int from_bitField0_ = bitField0_;
-      if (((from_bitField0_ & 0x00000001) != 0)) {
-        result.kind_ = kind_;
-      }
-      if (((from_bitField0_ & 0x00000002) != 0)) {
-        result.elementId_ = elementId_;
-      }
-      if (((from_bitField0_ & 0x00000004) != 0)) {
-        result.element_ = element_;
-      }
-      if (((from_bitField0_ & 0x00000008) != 0)) {
-        result.holds_ = holds_;
-      }
-      if (((from_bitField0_ & 0x00000010) != 0)) {
-        result.condition_ = condition_;
-      }
-      if (((from_bitField0_ & 0x00000020) != 0)) {
-        result.instanceId_ = instanceId_;
-      }
-      if (((from_bitField0_ & 0x00000040) != 0)) {
-        result.instanceTypeId_ = instanceTypeId_;
-      }
-      if (((from_bitField0_ & 0x00000080) != 0)) {
-        result.error_ = error_;
-      }
-      if (((from_bitField0_ & 0x00000100) != 0)) {
-        result.failureReason_ = failureReason_;
-      }
-      if (((from_bitField0_ & 0x00000200) != 0)) {
-        result.requirementId_ = requirementId_;
-      }
-      if (((from_bitField0_ & 0x00000400) != 0)) {
-        result.engine_ = engine_;
-      }
-      if (((from_bitField0_ & 0x00000800) != 0)) {
-        result.strength_ = strength_;
-      }
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(com.google.protobuf.Message other) {
-      if (other instanceof org.openmbee.opensysml.proto.Verdict) {
-        return mergeFrom((org.openmbee.opensysml.proto.Verdict)other);
-      } else {
-        super.mergeFrom(other);
-        return this;
-      }
-    }
-
-    public Builder mergeFrom(org.openmbee.opensysml.proto.Verdict other) {
-      if (other == org.openmbee.opensysml.proto.Verdict.getDefaultInstance()) return this;
-      if (!other.getKind().isEmpty()) {
-        kind_ = other.kind_;
-        bitField0_ |= 0x00000001;
-        onChanged();
-      }
-      if (!other.getElementId().isEmpty()) {
-        elementId_ = other.elementId_;
-        bitField0_ |= 0x00000002;
-        onChanged();
-      }
-      if (!other.getElement().isEmpty()) {
-        element_ = other.element_;
-        bitField0_ |= 0x00000004;
-        onChanged();
-      }
-      if (other.getHolds() != false) {
-        setHolds(other.getHolds());
-      }
-      if (!other.getCondition().isEmpty()) {
-        condition_ = other.condition_;
-        bitField0_ |= 0x00000010;
-        onChanged();
-      }
-      if (other.getInstanceId() != 0L) {
-        setInstanceId(other.getInstanceId());
-      }
-      if (!other.getInstanceTypeId().isEmpty()) {
-        instanceTypeId_ = other.instanceTypeId_;
-        bitField0_ |= 0x00000040;
-        onChanged();
-      }
-      if (!other.getError().isEmpty()) {
-        error_ = other.error_;
-        bitField0_ |= 0x00000080;
-        onChanged();
-      }
-      if (other.failureReason_ != 0) {
-        setFailureReasonValue(other.getFailureReasonValue());
-      }
-      if (!other.getRequirementId().isEmpty()) {
-        requirementId_ = other.requirementId_;
-        bitField0_ |= 0x00000200;
-        onChanged();
-      }
-      if (!other.getEngine().isEmpty()) {
-        engine_ = other.engine_;
-        bitField0_ |= 0x00000400;
-        onChanged();
-      }
-      if (!other.getStrength().isEmpty()) {
-        strength_ = other.strength_;
-        bitField0_ |= 0x00000800;
-        onChanged();
-      }
-      if (boundsBuilder_ == null) {
-        if (!other.bounds_.isEmpty()) {
-          if (bounds_.isEmpty()) {
-            bounds_ = other.bounds_;
-            bitField0_ = (bitField0_ & ~0x00001000);
-          } else {
-            ensureBoundsIsMutable();
-            bounds_.addAll(other.bounds_);
-          }
-          onChanged();
-        }
-      } else {
-        if (!other.bounds_.isEmpty()) {
-          if (boundsBuilder_.isEmpty()) {
-            boundsBuilder_.dispose();
-            boundsBuilder_ = null;
-            bounds_ = other.bounds_;
-            bitField0_ = (bitField0_ & ~0x00001000);
-            boundsBuilder_ = 
-              com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
-                 internalGetBoundsFieldBuilder() : null;
-          } else {
-            boundsBuilder_.addAllMessages(other.bounds_);
-          }
-        }
-      }
-      this.mergeUnknownFields(other.getUnknownFields());
-      onChanged();
-      return this;
-    }
-
-    @java.lang.Override
-    public final boolean isInitialized() {
-      return true;
-    }
-
-    @java.lang.Override
-    public Builder mergeFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws java.io.IOException {
-      if (extensionRegistry == null) {
-        throw new java.lang.NullPointerException();
-      }
-      try {
-        boolean done = false;
-        while (!done) {
-          int tag = input.readTag();
-          switch (tag) {
-            case 0:
-              done = true;
-              break;
-            case 10: {
-              kind_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000001;
-              break;
-            } // case 10
-            case 18: {
-              elementId_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000002;
-              break;
-            } // case 18
-            case 26: {
-              element_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000004;
-              break;
-            } // case 26
-            case 32: {
-              holds_ = input.readBool();
-              bitField0_ |= 0x00000008;
-              break;
-            } // case 32
-            case 42: {
-              condition_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000010;
-              break;
-            } // case 42
-            case 48: {
-              instanceId_ = input.readInt64();
-              bitField0_ |= 0x00000020;
-              break;
-            } // case 48
-            case 58: {
-              instanceTypeId_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000040;
-              break;
-            } // case 58
-            case 66: {
-              error_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000080;
-              break;
-            } // case 66
-            case 72: {
-              failureReason_ = input.readEnum();
-              bitField0_ |= 0x00000100;
-              break;
-            } // case 72
-            case 82: {
-              requirementId_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000200;
-              break;
-            } // case 82
-            case 90: {
-              engine_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000400;
-              break;
-            } // case 90
-            case 98: {
-              strength_ = input.readStringRequireUtf8();
-              bitField0_ |= 0x00000800;
-              break;
-            } // case 98
-            case 106: {
-              org.openmbee.opensysml.proto.Bound m =
-                  input.readMessage(
-                      org.openmbee.opensysml.proto.Bound.parser(),
-                      extensionRegistry);
-              if (boundsBuilder_ == null) {
-                ensureBoundsIsMutable();
-                bounds_.add(m);
-              } else {
-                boundsBuilder_.addMessage(m);
-              }
-              break;
-            } // case 106
-            default: {
-              if (!super.parseUnknownField(input, extensionRegistry, tag)) {
-                done = true; // was an endgroup tag
-              }
-              break;
-            } // default:
-          } // switch (tag)
-        } // while (!done)
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.unwrapIOException();
-      } finally {
-        onChanged();
-      } // finally
-      return this;
-    }
-    private int bitField0_;
-
-    private java.lang.Object kind_ = "";
-    /**
-     * <pre>
-     * What was verified: "constraint", "requirement" or "satisfy"; for a check
-     * an analysis case run made, "objective" or "assertion".
-     * </pre>
-     *
-     * <code>string kind = 1 [json_name = "kind"];</code>
-     * @return The kind.
-     */
-    public java.lang.String getKind() {
-      java.lang.Object ref = kind_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        kind_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * What was verified: "constraint", "requirement" or "satisfy"; for a check
-     * an analysis case run made, "objective" or "assertion".
-     * </pre>
-     *
-     * <code>string kind = 1 [json_name = "kind"];</code>
-     * @return The bytes for kind.
-     */
-    public com.google.protobuf.ByteString
-        getKindBytes() {
-      java.lang.Object ref = kind_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        kind_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * What was verified: "constraint", "requirement" or "satisfy"; for a check
-     * an analysis case run made, "objective" or "assertion".
-     * </pre>
-     *
-     * <code>string kind = 1 [json_name = "kind"];</code>
-     * @param value The kind to set.
-     * @return This builder for chaining.
-     */
-    public Builder setKind(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      kind_ = value;
-      bitField0_ |= 0x00000001;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * What was verified: "constraint", "requirement" or "satisfy"; for a check
-     * an analysis case run made, "objective" or "assertion".
-     * </pre>
-     *
-     * <code>string kind = 1 [json_name = "kind"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearKind() {
-      kind_ = getDefaultInstance().getKind();
-      bitField0_ = (bitField0_ & ~0x00000001);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * What was verified: "constraint", "requirement" or "satisfy"; for a check
-     * an analysis case run made, "objective" or "assertion".
-     * </pre>
-     *
-     * <code>string kind = 1 [json_name = "kind"];</code>
-     * @param value The bytes for kind to set.
-     * @return This builder for chaining.
-     */
-    public Builder setKindBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      kind_ = value;
-      bitField0_ |= 0x00000001;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object elementId_ = "";
-    /**
-     * <pre>
-     * FQN of the element verified; empty for an anonymous satisfy assertion or
-     * an anonymous assertion in a case body.
-     * </pre>
-     *
-     * <code>string element_id = 2 [json_name = "elementId"];</code>
-     * @return The elementId.
-     */
-    public java.lang.String getElementId() {
-      java.lang.Object ref = elementId_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        elementId_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * FQN of the element verified; empty for an anonymous satisfy assertion or
-     * an anonymous assertion in a case body.
-     * </pre>
-     *
-     * <code>string element_id = 2 [json_name = "elementId"];</code>
-     * @return The bytes for elementId.
-     */
-    public com.google.protobuf.ByteString
-        getElementIdBytes() {
-      java.lang.Object ref = elementId_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        elementId_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * FQN of the element verified; empty for an anonymous satisfy assertion or
-     * an anonymous assertion in a case body.
-     * </pre>
-     *
-     * <code>string element_id = 2 [json_name = "elementId"];</code>
-     * @param value The elementId to set.
-     * @return This builder for chaining.
-     */
-    public Builder setElementId(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      elementId_ = value;
-      bitField0_ |= 0x00000002;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * FQN of the element verified; empty for an anonymous satisfy assertion or
-     * an anonymous assertion in a case body.
-     * </pre>
-     *
-     * <code>string element_id = 2 [json_name = "elementId"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearElementId() {
-      elementId_ = getDefaultInstance().getElementId();
-      bitField0_ = (bitField0_ & ~0x00000002);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * FQN of the element verified; empty for an anonymous satisfy assertion or
-     * an anonymous assertion in a case body.
-     * </pre>
-     *
-     * <code>string element_id = 2 [json_name = "elementId"];</code>
-     * @param value The bytes for elementId to set.
-     * @return This builder for chaining.
-     */
-    public Builder setElementIdBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      elementId_ = value;
-      bitField0_ |= 0x00000002;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object element_ = "";
-    /**
-     * <pre>
-     * The element as a reader names it: its FQN, or, for an anonymous satisfy
-     * assertion, the assertion as written ("satisfy Range by cruise"); for an
-     * objective or assertion of a case, its name or its condition as written.
-     * </pre>
-     *
-     * <code>string element = 3 [json_name = "element"];</code>
-     * @return The element.
-     */
-    public java.lang.String getElement() {
-      java.lang.Object ref = element_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        element_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The element as a reader names it: its FQN, or, for an anonymous satisfy
-     * assertion, the assertion as written ("satisfy Range by cruise"); for an
-     * objective or assertion of a case, its name or its condition as written.
-     * </pre>
-     *
-     * <code>string element = 3 [json_name = "element"];</code>
-     * @return The bytes for element.
-     */
-    public com.google.protobuf.ByteString
-        getElementBytes() {
-      java.lang.Object ref = element_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        element_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The element as a reader names it: its FQN, or, for an anonymous satisfy
-     * assertion, the assertion as written ("satisfy Range by cruise"); for an
-     * objective or assertion of a case, its name or its condition as written.
-     * </pre>
-     *
-     * <code>string element = 3 [json_name = "element"];</code>
-     * @param value The element to set.
-     * @return This builder for chaining.
-     */
-    public Builder setElement(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      element_ = value;
-      bitField0_ |= 0x00000004;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The element as a reader names it: its FQN, or, for an anonymous satisfy
-     * assertion, the assertion as written ("satisfy Range by cruise"); for an
-     * objective or assertion of a case, its name or its condition as written.
-     * </pre>
-     *
-     * <code>string element = 3 [json_name = "element"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearElement() {
-      element_ = getDefaultInstance().getElement();
-      bitField0_ = (bitField0_ & ~0x00000004);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The element as a reader names it: its FQN, or, for an anonymous satisfy
-     * assertion, the assertion as written ("satisfy Range by cruise"); for an
-     * objective or assertion of a case, its name or its condition as written.
-     * </pre>
-     *
-     * <code>string element = 3 [json_name = "element"];</code>
-     * @param value The bytes for element to set.
-     * @return This builder for chaining.
-     */
-    public Builder setElementBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      element_ = value;
-      bitField0_ |= 0x00000004;
-      onChanged();
-      return this;
-    }
-
-    private boolean holds_ ;
-    /**
-     * <pre>
-     * Whether the condition holds. False with an empty `error` is the model's own
-     * answer; false with an `error` is no answer at all.
-     * </pre>
-     *
-     * <code>bool holds = 4 [json_name = "holds"];</code>
-     * @return The holds.
-     */
-    @java.lang.Override
-    public boolean getHolds() {
-      return holds_;
-    }
-    /**
-     * <pre>
-     * Whether the condition holds. False with an empty `error` is the model's own
-     * answer; false with an `error` is no answer at all.
-     * </pre>
-     *
-     * <code>bool holds = 4 [json_name = "holds"];</code>
-     * @param value The holds to set.
-     * @return This builder for chaining.
-     */
-    public Builder setHolds(boolean value) {
-
-      holds_ = value;
-      bitField0_ |= 0x00000008;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Whether the condition holds. False with an empty `error` is the model's own
-     * answer; false with an `error` is no answer at all.
-     * </pre>
-     *
-     * <code>bool holds = 4 [json_name = "holds"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearHolds() {
-      bitField0_ = (bitField0_ & ~0x00000008);
-      holds_ = false;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object condition_ = "";
-    /**
-     * <pre>
-     * The condition that evaluated to false, as written, when the runtime names
-     * one. Empty when the verdict holds or the condition is not named.
-     * </pre>
-     *
-     * <code>string condition = 5 [json_name = "condition"];</code>
-     * @return The condition.
-     */
-    public java.lang.String getCondition() {
-      java.lang.Object ref = condition_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        condition_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The condition that evaluated to false, as written, when the runtime names
-     * one. Empty when the verdict holds or the condition is not named.
-     * </pre>
-     *
-     * <code>string condition = 5 [json_name = "condition"];</code>
-     * @return The bytes for condition.
-     */
-    public com.google.protobuf.ByteString
-        getConditionBytes() {
-      java.lang.Object ref = condition_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        condition_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The condition that evaluated to false, as written, when the runtime names
-     * one. Empty when the verdict holds or the condition is not named.
-     * </pre>
-     *
-     * <code>string condition = 5 [json_name = "condition"];</code>
-     * @param value The condition to set.
-     * @return This builder for chaining.
-     */
-    public Builder setCondition(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      condition_ = value;
-      bitField0_ |= 0x00000010;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The condition that evaluated to false, as written, when the runtime names
-     * one. Empty when the verdict holds or the condition is not named.
-     * </pre>
-     *
-     * <code>string condition = 5 [json_name = "condition"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearCondition() {
-      condition_ = getDefaultInstance().getCondition();
-      bitField0_ = (bitField0_ & ~0x00000010);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The condition that evaluated to false, as written, when the runtime names
-     * one. Empty when the verdict holds or the condition is not named.
-     * </pre>
-     *
-     * <code>string condition = 5 [json_name = "condition"];</code>
-     * @param value The bytes for condition to set.
-     * @return This builder for chaining.
-     */
-    public Builder setConditionBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      condition_ = value;
-      bitField0_ |= 0x00000010;
-      onChanged();
-      return this;
-    }
-
-    private long instanceId_ ;
-    /**
-     * <pre>
-     * Instance the verdict is about, 0 when it is about declared values alone.
-     * Its feature values are in `instances`, so a client can read what made it fail.
-     * </pre>
-     *
-     * <code>int64 instance_id = 6 [json_name = "instanceId"];</code>
-     * @return The instanceId.
-     */
-    @java.lang.Override
-    public long getInstanceId() {
-      return instanceId_;
-    }
-    /**
-     * <pre>
-     * Instance the verdict is about, 0 when it is about declared values alone.
-     * Its feature values are in `instances`, so a client can read what made it fail.
-     * </pre>
-     *
-     * <code>int64 instance_id = 6 [json_name = "instanceId"];</code>
-     * @param value The instanceId to set.
-     * @return This builder for chaining.
-     */
-    public Builder setInstanceId(long value) {
-
-      instanceId_ = value;
-      bitField0_ |= 0x00000020;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Instance the verdict is about, 0 when it is about declared values alone.
-     * Its feature values are in `instances`, so a client can read what made it fail.
-     * </pre>
-     *
-     * <code>int64 instance_id = 6 [json_name = "instanceId"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearInstanceId() {
-      bitField0_ = (bitField0_ & ~0x00000020);
-      instanceId_ = 0L;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object instanceTypeId_ = "";
-    /**
-     * <pre>
-     * Type of `instance_id`, as an FQN, for naming the object in a report.
-     * </pre>
-     *
-     * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
-     * @return The instanceTypeId.
-     */
-    public java.lang.String getInstanceTypeId() {
-      java.lang.Object ref = instanceTypeId_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        instanceTypeId_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * Type of `instance_id`, as an FQN, for naming the object in a report.
-     * </pre>
-     *
-     * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
-     * @return The bytes for instanceTypeId.
-     */
-    public com.google.protobuf.ByteString
-        getInstanceTypeIdBytes() {
-      java.lang.Object ref = instanceTypeId_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        instanceTypeId_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * Type of `instance_id`, as an FQN, for naming the object in a report.
-     * </pre>
-     *
-     * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
-     * @param value The instanceTypeId to set.
-     * @return This builder for chaining.
-     */
-    public Builder setInstanceTypeId(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      instanceTypeId_ = value;
-      bitField0_ |= 0x00000040;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Type of `instance_id`, as an FQN, for naming the object in a report.
-     * </pre>
-     *
-     * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearInstanceTypeId() {
-      instanceTypeId_ = getDefaultInstance().getInstanceTypeId();
-      bitField0_ = (bitField0_ & ~0x00000040);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Type of `instance_id`, as an FQN, for naming the object in a report.
-     * </pre>
-     *
-     * <code>string instance_type_id = 7 [json_name = "instanceTypeId"];</code>
-     * @param value The bytes for instanceTypeId to set.
-     * @return This builder for chaining.
-     */
-    public Builder setInstanceTypeIdBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      instanceTypeId_ = value;
-      bitField0_ |= 0x00000040;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object error_ = "";
-    /**
-     * <pre>
-     * Set when evaluation failed rather than the model answering false: unbound
-     * features, incommensurable units, an exhausted step budget. `holds` is then
-     * false but is not a verdict.
-     * </pre>
-     *
-     * <code>string error = 8 [json_name = "error"];</code>
-     * @return The error.
-     */
-    public java.lang.String getError() {
-      java.lang.Object ref = error_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        error_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * Set when evaluation failed rather than the model answering false: unbound
-     * features, incommensurable units, an exhausted step budget. `holds` is then
-     * false but is not a verdict.
-     * </pre>
-     *
-     * <code>string error = 8 [json_name = "error"];</code>
-     * @return The bytes for error.
-     */
-    public com.google.protobuf.ByteString
-        getErrorBytes() {
-      java.lang.Object ref = error_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        error_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * Set when evaluation failed rather than the model answering false: unbound
-     * features, incommensurable units, an exhausted step budget. `holds` is then
-     * false but is not a verdict.
-     * </pre>
-     *
-     * <code>string error = 8 [json_name = "error"];</code>
-     * @param value The error to set.
-     * @return This builder for chaining.
-     */
-    public Builder setError(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      error_ = value;
-      bitField0_ |= 0x00000080;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Set when evaluation failed rather than the model answering false: unbound
-     * features, incommensurable units, an exhausted step budget. `holds` is then
-     * false but is not a verdict.
-     * </pre>
-     *
-     * <code>string error = 8 [json_name = "error"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearError() {
-      error_ = getDefaultInstance().getError();
-      bitField0_ = (bitField0_ & ~0x00000080);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * Set when evaluation failed rather than the model answering false: unbound
-     * features, incommensurable units, an exhausted step budget. `holds` is then
-     * false but is not a verdict.
-     * </pre>
-     *
-     * <code>string error = 8 [json_name = "error"];</code>
-     * @param value The bytes for error to set.
-     * @return This builder for chaining.
-     */
-    public Builder setErrorBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      error_ = value;
-      bitField0_ |= 0x00000080;
-      onChanged();
-      return this;
-    }
-
-    private int failureReason_ = 0;
-    /**
-     * <pre>
-     * What kind of failure `error` reports. WRONG_KIND says the symbol named
-     * declares something else, which is a wrong request rather than an undecided
-     * verdict about the model.
-     * </pre>
-     *
-     * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
-     * @return The enum numeric value on the wire for failureReason.
-     */
-    @java.lang.Override public int getFailureReasonValue() {
-      return failureReason_;
-    }
-    /**
-     * <pre>
-     * What kind of failure `error` reports. WRONG_KIND says the symbol named
-     * declares something else, which is a wrong request rather than an undecided
-     * verdict about the model.
-     * </pre>
-     *
-     * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
-     * @param value The enum numeric value on the wire for failureReason to set.
-     * @return This builder for chaining.
-     */
-    public Builder setFailureReasonValue(int value) {
-      failureReason_ = value;
-      bitField0_ |= 0x00000100;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * What kind of failure `error` reports. WRONG_KIND says the symbol named
-     * declares something else, which is a wrong request rather than an undecided
-     * verdict about the model.
-     * </pre>
-     *
-     * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
-     * @return The failureReason.
-     */
-    @java.lang.Override
-    public org.openmbee.opensysml.proto.FailureReason getFailureReason() {
-      org.openmbee.opensysml.proto.FailureReason result = org.openmbee.opensysml.proto.FailureReason.forNumber(failureReason_);
-      return result == null ? org.openmbee.opensysml.proto.FailureReason.UNRECOGNIZED : result;
-    }
-    /**
-     * <pre>
-     * What kind of failure `error` reports. WRONG_KIND says the symbol named
-     * declares something else, which is a wrong request rather than an undecided
-     * verdict about the model.
-     * </pre>
-     *
-     * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
-     * @param value The failureReason to set.
-     * @return This builder for chaining.
-     */
-    public Builder setFailureReason(org.openmbee.opensysml.proto.FailureReason value) {
-      if (value == null) { throw new NullPointerException(); }
-      bitField0_ |= 0x00000100;
-      failureReason_ = value.getNumber();
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * What kind of failure `error` reports. WRONG_KIND says the symbol named
-     * declares something else, which is a wrong request rather than an undecided
-     * verdict about the model.
-     * </pre>
-     *
-     * <code>.sysml.FailureReason failure_reason = 9 [json_name = "failureReason"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearFailureReason() {
-      bitField0_ = (bitField0_ & ~0x00000100);
-      failureReason_ = 0;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object requirementId_ = "";
-    /**
-     * <pre>
-     * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
-     * associates it with the verification_verdicts reported for that requirement.
-     * Empty for every other kind, and for a requirement no FQN names.
-     * </pre>
-     *
-     * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
-     * @return The requirementId.
-     */
-    public java.lang.String getRequirementId() {
-      java.lang.Object ref = requirementId_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        requirementId_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
-     * associates it with the verification_verdicts reported for that requirement.
-     * Empty for every other kind, and for a requirement no FQN names.
-     * </pre>
-     *
-     * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
-     * @return The bytes for requirementId.
-     */
-    public com.google.protobuf.ByteString
-        getRequirementIdBytes() {
-      java.lang.Object ref = requirementId_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        requirementId_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
-     * associates it with the verification_verdicts reported for that requirement.
-     * Empty for every other kind, and for a requirement no FQN names.
-     * </pre>
-     *
-     * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
-     * @param value The requirementId to set.
-     * @return This builder for chaining.
-     */
-    public Builder setRequirementId(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      requirementId_ = value;
-      bitField0_ |= 0x00000200;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
-     * associates it with the verification_verdicts reported for that requirement.
-     * Empty for every other kind, and for a requirement no FQN names.
-     * </pre>
-     *
-     * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearRequirementId() {
-      requirementId_ = getDefaultInstance().getRequirementId();
-      bitField0_ = (bitField0_ & ~0x00000200);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
-     * associates it with the verification_verdicts reported for that requirement.
-     * Empty for every other kind, and for a requirement no FQN names.
-     * </pre>
-     *
-     * <code>string requirement_id = 10 [json_name = "requirementId"];</code>
-     * @param value The bytes for requirementId to set.
-     * @return This builder for chaining.
-     */
-    public Builder setRequirementIdBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      requirementId_ = value;
-      bitField0_ |= 0x00000200;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object engine_ = "";
-    /**
-     * <pre>
-     * The engine whose answer this verdict is, as ListEngines names it; empty
-     * for a verdict decided before any engine was asked. Reported as the
-     * "engines" capability, with `strength` and `bounds`.
-     * </pre>
-     *
-     * <code>string engine = 11 [json_name = "engine"];</code>
-     * @return The engine.
-     */
-    public java.lang.String getEngine() {
-      java.lang.Object ref = engine_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        engine_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The engine whose answer this verdict is, as ListEngines names it; empty
-     * for a verdict decided before any engine was asked. Reported as the
-     * "engines" capability, with `strength` and `bounds`.
-     * </pre>
-     *
-     * <code>string engine = 11 [json_name = "engine"];</code>
-     * @return The bytes for engine.
-     */
-    public com.google.protobuf.ByteString
-        getEngineBytes() {
-      java.lang.Object ref = engine_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        engine_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The engine whose answer this verdict is, as ListEngines names it; empty
-     * for a verdict decided before any engine was asked. Reported as the
-     * "engines" capability, with `strength` and `bounds`.
-     * </pre>
-     *
-     * <code>string engine = 11 [json_name = "engine"];</code>
-     * @param value The engine to set.
-     * @return This builder for chaining.
-     */
-    public Builder setEngine(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      engine_ = value;
-      bitField0_ |= 0x00000400;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The engine whose answer this verdict is, as ListEngines names it; empty
-     * for a verdict decided before any engine was asked. Reported as the
-     * "engines" capability, with `strength` and `bounds`.
-     * </pre>
-     *
-     * <code>string engine = 11 [json_name = "engine"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearEngine() {
-      engine_ = getDefaultInstance().getEngine();
-      bitField0_ = (bitField0_ & ~0x00000400);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The engine whose answer this verdict is, as ListEngines names it; empty
-     * for a verdict decided before any engine was asked. Reported as the
-     * "engines" capability, with `strength` and `bounds`.
-     * </pre>
-     *
-     * <code>string engine = 11 [json_name = "engine"];</code>
-     * @param value The bytes for engine to set.
-     * @return This builder for chaining.
-     */
-    public Builder setEngineBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      engine_ = value;
-      bitField0_ |= 0x00000400;
-      onChanged();
-      return this;
-    }
-
-    private java.lang.Object strength_ = "";
-    /**
-     * <pre>
-     * The strength of the evidence behind the verdict: "observed" for one run,
-     * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
-     * search within the bounds, "proved" for every case, "not covered" when no
-     * engine decided it.
-     * </pre>
-     *
-     * <code>string strength = 12 [json_name = "strength"];</code>
-     * @return The strength.
-     */
-    public java.lang.String getStrength() {
-      java.lang.Object ref = strength_;
-      if (!(ref instanceof java.lang.String)) {
-        com.google.protobuf.ByteString bs =
-            (com.google.protobuf.ByteString) ref;
-        java.lang.String s = bs.toStringUtf8();
-        strength_ = s;
-        return s;
-      } else {
-        return (java.lang.String) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The strength of the evidence behind the verdict: "observed" for one run,
-     * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
-     * search within the bounds, "proved" for every case, "not covered" when no
-     * engine decided it.
-     * </pre>
-     *
-     * <code>string strength = 12 [json_name = "strength"];</code>
-     * @return The bytes for strength.
-     */
-    public com.google.protobuf.ByteString
-        getStrengthBytes() {
-      java.lang.Object ref = strength_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
-            com.google.protobuf.ByteString.copyFromUtf8(
-                (java.lang.String) ref);
-        strength_ = b;
-        return b;
-      } else {
-        return (com.google.protobuf.ByteString) ref;
-      }
-    }
-    /**
-     * <pre>
-     * The strength of the evidence behind the verdict: "observed" for one run,
-     * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
-     * search within the bounds, "proved" for every case, "not covered" when no
-     * engine decided it.
-     * </pre>
-     *
-     * <code>string strength = 12 [json_name = "strength"];</code>
-     * @param value The strength to set.
-     * @return This builder for chaining.
-     */
-    public Builder setStrength(
-        java.lang.String value) {
-      if (value == null) { throw new NullPointerException(); }
-      strength_ = value;
-      bitField0_ |= 0x00000800;
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The strength of the evidence behind the verdict: "observed" for one run,
-     * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
-     * search within the bounds, "proved" for every case, "not covered" when no
-     * engine decided it.
-     * </pre>
-     *
-     * <code>string strength = 12 [json_name = "strength"];</code>
-     * @return This builder for chaining.
-     */
-    public Builder clearStrength() {
-      strength_ = getDefaultInstance().getStrength();
-      bitField0_ = (bitField0_ & ~0x00000800);
-      onChanged();
-      return this;
-    }
-    /**
-     * <pre>
-     * The strength of the evidence behind the verdict: "observed" for one run,
-     * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
-     * search within the bounds, "proved" for every case, "not covered" when no
-     * engine decided it.
-     * </pre>
-     *
-     * <code>string strength = 12 [json_name = "strength"];</code>
-     * @param value The bytes for strength to set.
-     * @return This builder for chaining.
-     */
-    public Builder setStrengthBytes(
-        com.google.protobuf.ByteString value) {
-      if (value == null) { throw new NullPointerException(); }
-      checkByteStringIsUtf8(value);
-      strength_ = value;
-      bitField0_ |= 0x00000800;
-      onChanged();
-      return this;
-    }
-
-    private java.util.List<org.openmbee.opensysml.proto.Bound> bounds_ =
-      java.util.Collections.emptyList();
-    private void ensureBoundsIsMutable() {
-      if (!((bitField0_ & 0x00001000) != 0)) {
-        bounds_ = new java.util.ArrayList<org.openmbee.opensysml.proto.Bound>(bounds_);
-        bitField0_ |= 0x00001000;
-       }
-    }
-
-    private com.google.protobuf.RepeatedFieldBuilder<
-        org.openmbee.opensysml.proto.Bound, org.openmbee.opensysml.proto.Bound.Builder, org.openmbee.opensysml.proto.BoundOrBuilder> boundsBuilder_;
-
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public java.util.List<org.openmbee.opensysml.proto.Bound> getBoundsList() {
-      if (boundsBuilder_ == null) {
-        return java.util.Collections.unmodifiableList(bounds_);
-      } else {
-        return boundsBuilder_.getMessageList();
-      }
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public int getBoundsCount() {
-      if (boundsBuilder_ == null) {
-        return bounds_.size();
-      } else {
-        return boundsBuilder_.getCount();
-      }
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public org.openmbee.opensysml.proto.Bound getBounds(int index) {
-      if (boundsBuilder_ == null) {
-        return bounds_.get(index);
-      } else {
-        return boundsBuilder_.getMessage(index);
-      }
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public Builder setBounds(
-        int index, org.openmbee.opensysml.proto.Bound value) {
-      if (boundsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureBoundsIsMutable();
-        bounds_.set(index, value);
-        onChanged();
-      } else {
-        boundsBuilder_.setMessage(index, value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public Builder setBounds(
-        int index, org.openmbee.opensysml.proto.Bound.Builder builderForValue) {
-      if (boundsBuilder_ == null) {
-        ensureBoundsIsMutable();
-        bounds_.set(index, builderForValue.build());
-        onChanged();
-      } else {
-        boundsBuilder_.setMessage(index, builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public Builder addBounds(org.openmbee.opensysml.proto.Bound value) {
-      if (boundsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureBoundsIsMutable();
-        bounds_.add(value);
-        onChanged();
-      } else {
-        boundsBuilder_.addMessage(value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public Builder addBounds(
-        int index, org.openmbee.opensysml.proto.Bound value) {
-      if (boundsBuilder_ == null) {
-        if (value == null) {
-          throw new NullPointerException();
-        }
-        ensureBoundsIsMutable();
-        bounds_.add(index, value);
-        onChanged();
-      } else {
-        boundsBuilder_.addMessage(index, value);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public Builder addBounds(
-        org.openmbee.opensysml.proto.Bound.Builder builderForValue) {
-      if (boundsBuilder_ == null) {
-        ensureBoundsIsMutable();
-        bounds_.add(builderForValue.build());
-        onChanged();
-      } else {
-        boundsBuilder_.addMessage(builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public Builder addBounds(
-        int index, org.openmbee.opensysml.proto.Bound.Builder builderForValue) {
-      if (boundsBuilder_ == null) {
-        ensureBoundsIsMutable();
-        bounds_.add(index, builderForValue.build());
-        onChanged();
-      } else {
-        boundsBuilder_.addMessage(index, builderForValue.build());
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public Builder addAllBounds(
-        java.lang.Iterable<? extends org.openmbee.opensysml.proto.Bound> values) {
-      if (boundsBuilder_ == null) {
-        ensureBoundsIsMutable();
-        com.google.protobuf.AbstractMessageLite.Builder.addAll(
-            values, bounds_);
-        onChanged();
-      } else {
-        boundsBuilder_.addAllMessages(values);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public Builder clearBounds() {
-      if (boundsBuilder_ == null) {
-        bounds_ = java.util.Collections.emptyList();
-        bitField0_ = (bitField0_ & ~0x00001000);
-        onChanged();
-      } else {
-        boundsBuilder_.clear();
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public Builder removeBounds(int index) {
-      if (boundsBuilder_ == null) {
-        ensureBoundsIsMutable();
-        bounds_.remove(index);
-        onChanged();
-      } else {
-        boundsBuilder_.remove(index);
-      }
-      return this;
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public org.openmbee.opensysml.proto.Bound.Builder getBoundsBuilder(
-        int index) {
-      return internalGetBoundsFieldBuilder().getBuilder(index);
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public org.openmbee.opensysml.proto.BoundOrBuilder getBoundsOrBuilder(
-        int index) {
-      if (boundsBuilder_ == null) {
-        return bounds_.get(index);  } else {
-        return boundsBuilder_.getMessageOrBuilder(index);
-      }
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public java.util.List<? extends org.openmbee.opensysml.proto.BoundOrBuilder> 
-         getBoundsOrBuilderList() {
-      if (boundsBuilder_ != null) {
-        return boundsBuilder_.getMessageOrBuilderList();
-      } else {
-        return java.util.Collections.unmodifiableList(bounds_);
-      }
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public org.openmbee.opensysml.proto.Bound.Builder addBoundsBuilder() {
-      return internalGetBoundsFieldBuilder().addBuilder(
-          org.openmbee.opensysml.proto.Bound.getDefaultInstance());
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public org.openmbee.opensysml.proto.Bound.Builder addBoundsBuilder(
-        int index) {
-      return internalGetBoundsFieldBuilder().addBuilder(
-          index, org.openmbee.opensysml.proto.Bound.getDefaultInstance());
-    }
-    /**
-     * <pre>
-     * The bounds the engine ran under, each marked when it stopped the run.
-     * </pre>
-     *
-     * <code>repeated .sysml.Bound bounds = 13 [json_name = "bounds"];</code>
-     */
-    public java.util.List<org.openmbee.opensysml.proto.Bound.Builder> 
-         getBoundsBuilderList() {
-      return internalGetBoundsFieldBuilder().getBuilderList();
-    }
-    private com.google.protobuf.RepeatedFieldBuilder<
-        org.openmbee.opensysml.proto.Bound, org.openmbee.opensysml.proto.Bound.Builder, org.openmbee.opensysml.proto.BoundOrBuilder> 
-        internalGetBoundsFieldBuilder() {
-      if (boundsBuilder_ == null) {
-        boundsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
-            org.openmbee.opensysml.proto.Bound, org.openmbee.opensysml.proto.Bound.Builder, org.openmbee.opensysml.proto.BoundOrBuilder>(
-                bounds_,
-                ((bitField0_ & 0x00001000) != 0),
-                getParentForChildren(),
-                isClean());
-        bounds_ = null;
-      }
-      return boundsBuilder_;
-    }
-
-    // @@protoc_insertion_point(builder_scope:sysml.Verdict)
-  }
-
-  // @@protoc_insertion_point(class_scope:sysml.Verdict)
-  private static final org.openmbee.opensysml.proto.Verdict DEFAULT_INSTANCE;
-  static {
-    DEFAULT_INSTANCE = new org.openmbee.opensysml.proto.Verdict();
-  }
-
-  public static org.openmbee.opensysml.proto.Verdict getDefaultInstance() {
-    return DEFAULT_INSTANCE;
-  }
-
-  private static final com.google.protobuf.Parser<Verdict>
-      PARSER = new com.google.protobuf.AbstractParser<Verdict>() {
-    @java.lang.Override
-    public Verdict parsePartialFrom(
-        com.google.protobuf.CodedInputStream input,
-        com.google.protobuf.ExtensionRegistryLite extensionRegistry)
-        throws com.google.protobuf.InvalidProtocolBufferException {
-      Builder builder = newBuilder();
-      try {
-        builder.mergeFrom(input, extensionRegistry);
-      } catch (com.google.protobuf.InvalidProtocolBufferException e) {
-        throw e.setUnfinishedMessage(builder.buildPartial());
-      } catch (com.google.protobuf.UninitializedMessageException e) {
-        throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial());
-      } catch (java.io.IOException e) {
-        throw new com.google.protobuf.InvalidProtocolBufferException(e)
-            .setUnfinishedMessage(builder.buildPartial());
-      }
-      return builder.buildPartial();
-    }
-  };
-
-  public static com.google.protobuf.Parser<Verdict> parser() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public com.google.protobuf.Parser<Verdict> getParserForType() {
-    return PARSER;
-  }
-
-  @java.lang.Override
-  public org.openmbee.opensysml.proto.Verdict getDefaultInstanceForType() {
-    return DEFAULT_INSTANCE;
-  }
-
-}
-
diff --git a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java b/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java
deleted file mode 100644
index 8f9e23da32..0000000000
--- a/clients/java/opensysml-client/src/test/java/org/openmbee/opensysml/ApiIntegrationTest.java
+++ /dev/null
@@ -1,594 +0,0 @@
-package org.openmbee.opensysml;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertInstanceOf;
-import static org.junit.jupiter.api.Assertions.assertNotEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.List;
-import java.util.Optional;
-import org.junit.jupiter.api.AfterAll;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.TestInstance;
-import org.openmbee.opensysml.proto.CaseEvaluation;
-import org.openmbee.opensysml.proto.RunAnalysisRequest;
-import org.openmbee.opensysml.proto.RunAnalysisResponse;
-import org.openmbee.opensysml.proto.RunSweepRequest;
-import org.openmbee.opensysml.proto.RunSweepResponse;
-import org.openmbee.opensysml.proto.SweepRange;
-import org.openmbee.opensysml.proto.SweepRow;
-
-/** The v1 API against a real service this test starts. */
-@TestInstance(TestInstance.Lifecycle.PER_CLASS)
-class ApiIntegrationTest {
-
-  private static final String VEHICLE =
-      """
-      package Demo {
-        part def Engine { attribute power = 300.0; }
-        part def Vehicle {
-          attribute mass default = 1500.0;
-          part engine : Engine;
-        }
-        part sedan : Vehicle { attribute :>> mass = 1200.0; }
-      }
-      """;
-
-  private static Connection connection;
-
-  @BeforeAll
-  static void open() {
-    connection = Connection.open(ServiceBinary.options().build());
-  }
-
-  @AfterAll
-  static void close() {
-    if (connection != null) {
-      connection.close();
-    }
-  }
-
-  @Test
-  void theServiceAdvertisesTheCapabilitiesTheClientNegotiatesOn() {
-    Capabilities capabilities = connection.capabilities();
-    assertFalse(capabilities.serviceVersion().isBlank());
-    assertTrue(capabilities.has(Capabilities.EVALUATE_SUBJECT));
-    assertTrue(capabilities.has(Capabilities.TYPE_FACTS));
-  }
-
-  @Test
-  void parsesInlineContent() {
-    Model model = connection.parse(VEHICLE);
-    assertFalse(model.hash().isBlank());
-    assertTrue(model.root().isPresent());
-    assertTrue(model.parseDiagnostics().isEmpty());
-    assertEquals(List.of(), model.diagnostics());
-  }
-
-  @Test
-  void parsesAFileTheServiceReads() throws Exception {
-    Path file = Files.createTempFile("opensysml", ".sysml");
-    try {
-      Files.writeString(file, VEHICLE);
-      Model model = connection.load(file);
-      assertEquals("Demo", model.symbol("Demo").name());
-    } finally {
-      Files.deleteIfExists(file);
-    }
-  }
-
-  @Test
-  void evaluatesExpressionsAgainstDeclarationsAndAgainstObjects() {
-    Model model = connection.parse(VEHICLE);
-    assertEquals(new Value.IntegerValue(4), model.eval("2 + 2"));
-    assertEquals(new Value.RealValue(1500.0), model.evalInContext("mass", "Demo::Vehicle"));
-    assertEquals(new Value.RealValue(1200.0), model.evalWithSubject("mass", "Demo::sedan"));
-  }
-
-  @Test
-  void anExpressionThatCannotBeEvaluatedIsAModelFailureRatherThanATransportOne() {
-    Model model = connection.parse(VEHICLE);
-    ModelException failed = assertThrows(ModelException.class, () -> model.eval("nosuchname + 1"));
-    assertFalse(failed.getMessage().isBlank());
-  }
-
-  @Test
-  void looksUpSymbols() {
-    Model model = connection.parse(VEHICLE);
-    Symbol vehicle = model.symbol("Demo::Vehicle");
-    assertEquals("Demo::Vehicle", vehicle.id());
-    assertEquals("Vehicle", vehicle.name());
-    assertEquals("partDef", vehicle.kind());
-    assertTrue(vehicle.childIds().contains("Demo::Vehicle::mass"));
-    assertEquals(Optional.empty(), model.findSymbol("Demo::Missing"));
-    assertThrows(ModelException.class, () -> model.symbol("Demo::Missing"));
-  }
-
-  @Test
-  void instantiatesAnObjectAndItsFeatureValues() {
-    Model model = connection.parse(VEHICLE);
-    Instantiation instantiation = model.instantiate("Demo::sedan");
-    assertEquals("Demo::sedan", instantiation.root().typeSymbolId());
-    assertTrue(instantiation.reachable().size() >= 2);
-    Instance.FeatureValue mass = instantiation.root().featureValues().get("mass");
-    assertEquals(Optional.of(new Value.RealValue(1200.0)), mass.value());
-    Instance.FeatureValue engine = instantiation.root().featureValues().get("engine");
-    Value.InstanceReference reference = (Value.InstanceReference) engine.value().orElseThrow();
-    assertEquals("Demo::Engine", instantiation.resolve(reference).orElseThrow().typeSymbolId());
-  }
-
-  private static final String COMPLEX =
-      """
-      package C {
-        private import ScalarValues::*;
-        private import ComplexFunctions::*;
-        part def Signal {
-          attribute z : Complex = rect(1.5, -2.0);
-          attribute zs : Complex[2] = (rect(1.0, 2.0), rect(3.0, 4.0));
-        }
-      }
-      """;
-
-  @Test
-  void aComplexNumberIsOneValueOverProtobufAndJson() {
-    assertTrue(connection.capabilities().has(Capabilities.COMPLEX_VALUES));
-    try (Connection json =
-        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
-      for (Connection each : List.of(connection, json)) {
-        Model model = each.parse(COMPLEX);
-        assertEquals(new Value.ComplexValue(1.5, -2.0), model.evalInContext("z", "C::Signal"));
-        Instance signal = model.instantiate("C::Signal").root();
-        assertEquals(
-            Optional.of(new Value.ComplexValue(1.5, -2.0)),
-            signal.featureValues().get("z").value());
-        assertEquals(
-            List.of(new Value.ComplexValue(1.0, 2.0), new Value.ComplexValue(3.0, 4.0)),
-            signal.featureValues().get("zs").values());
-      }
-    }
-  }
-
-  private static final String STRUCTURED =
-      """
-      package S {
-        private import ScalarValues::*;
-        private import Collections::*;
-        private import VectorValues::*;
-        private import VectorFunctions::*;
-        private import Quantities::*;
-        private import SI::*;
-        attribute grid : Array { :>> dimensions = (2, 3); :>> elements = (1, 2, 3, 4, 5, 6); }
-        attribute v : CartesianVectorValue = VectorOf((3.0, 4.0));
-        attribute d : VectorQuantityValue = VectorOf((3.0, 4.0)) [m];
-      }
-      """;
-
-  @Test
-  void anArrayAVectorAndAVectorQuantityArriveWholeOverProtobufAndJson() {
-    assertTrue(connection.capabilities().has(Capabilities.STRUCTURED_VALUES));
-    try (Connection json =
-        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
-      for (Connection each : List.of(connection, json)) {
-        Model model = each.parse(STRUCTURED);
-        assertEquals(
-            new Value.ArrayValue(
-                List.of(2L, 3L),
-                List.of(
-                    new Value.IntegerValue(1), new Value.IntegerValue(2), new Value.IntegerValue(3),
-                    new Value.IntegerValue(4), new Value.IntegerValue(5), new Value.IntegerValue(6))),
-            model.eval("S::grid"));
-        assertEquals(
-            new Value.VectorValue(List.of(new Value.RealValue(3.0), new Value.RealValue(4.0))),
-            model.eval("S::v"));
-        Value.VectorQuantityValue d = (Value.VectorQuantityValue) model.eval("S::d");
-        assertEquals(Optional.of("m"), d.unit());
-        assertEquals(List.of(3.0, 4.0), d.components().stream().map(Quantity::magnitude).toList());
-        assertEquals(
-            List.of(new Quantity.UnitFactor("SI::metre", 1.0)),
-            d.components().get(0).reduction().orElseThrow().factors());
-      }
-    }
-  }
-
-  private static final String SET_AND_TENSOR =
-      """
-      package T {
-        private import ScalarValues::*;
-        private import Collections::*;
-        private import Quantities::*;
-        private import MeasurementReferences::*;
-        private import SI::*;
-        attribute s : Set { :>> elements = (3, 1, 2, 2, 3); }
-        attribute none : Set { :>> elements = (); }
-        attribute cubeRef : TensorMeasurementReference {
-          :>> dimensions = (2, 2, 2);
-          :>> mRefs = (m, m, m, m, m, m, m, m);
-        }
-        attribute cube : TensorQuantityValue =
-          TensorCalculations::'['((1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0), cubeRef);
-      }
-      """;
-
-  @Test
-  void aSetAndARankThreeTensorArriveWholeOverProtobufAndJson() {
-    assertTrue(connection.capabilities().has(Capabilities.SET_VALUES));
-    assertTrue(connection.capabilities().has(Capabilities.TENSOR_VALUES));
-    try (Connection json =
-        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
-      for (Connection each : List.of(connection, json)) {
-        Model model = each.parse(SET_AND_TENSOR);
-        Value.SetValue s = (Value.SetValue) model.eval("T::s.elements");
-        assertEquals(
-            new Value.SetValue(
-                List.of(
-                    new Value.IntegerValue(1), new Value.IntegerValue(2), new Value.IntegerValue(3))),
-            s);
-        assertEquals(List.of(1L, 2L, 3L), s.elements().stream().map(Value::asLong).toList());
-        assertEquals(new Value.SetValue(List.of()), model.eval("T::none.elements"));
-        Value.TensorQuantityValue cube = (Value.TensorQuantityValue) model.eval("T::cube");
-        assertEquals(List.of(2L, 2L, 2L), cube.dimensions());
-        assertEquals(Optional.of("m"), cube.unit());
-        assertEquals(
-            List.of(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0),
-            cube.components().stream().map(Quantity::magnitude).toList());
-        assertEquals(6.0, cube.get(1, 0, 1).magnitude());
-        assertEquals(
-            List.of(new Quantity.UnitFactor("SI::metre", 1.0)),
-            cube.get(1, 0, 1).reduction().orElseThrow().factors());
-        Value.QuantityValue corner = (Value.QuantityValue) model.eval("T::cube#(2, 1, 2)");
-        assertEquals(6.0, corner.quantity().magnitude());
-        assertEquals(Optional.of("m"), corner.quantity().unit());
-      }
-    }
-  }
-
-  private static final String METAOBJECTS =
-      """
-      package Demo {
-        private import ScalarValues::*;
-        metadata def Safety { attribute level : Integer = 2; }
-        part def Vehicle { attribute mass : Real; }
-        part seatBelt : Vehicle { @Safety { level = 4; } }
-        attribute asFeature [*] = seatBelt meta KerML::Feature;
-        attribute everything [*] = seatBelt.metadata;
-        attribute notADefinition [*] = seatBelt meta SysML::PartDefinition;
-        attribute belt : String = (seatBelt meta KerML::Feature)#(1).declaredName;
-      }
-      """;
-
-  @Test
-  void aMetaCastArrivesAsTheElementUnderItsOwnMetaclassAfterItsAnnotations() {
-    assertTrue(connection.capabilities().has(Capabilities.METAOBJECT_VALUES));
-    Value.MetaobjectValue seatBelt =
-        new Value.MetaobjectValue("Demo::seatBelt", "SysML::Systems::PartUsage");
-    try (Connection json =
-        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
-      for (Connection each : List.of(connection, json)) {
-        Model model = each.parse(METAOBJECTS);
-        Value.Sequence asFeature = (Value.Sequence) model.eval("Demo::asFeature");
-        assertEquals(List.of(seatBelt), asFeature.elements());
-        assertEquals(
-            "SysML::Systems::PartUsage",
-            ((Value.MetaobjectValue) asFeature.elements().get(0)).metaclassId());
-        assertEquals(new Value.Sequence(List.of()), model.eval("Demo::notADefinition"));
-        Value.Sequence everything = (Value.Sequence) model.eval("Demo::everything");
-        assertEquals(2, everything.elements().size());
-        assertInstanceOf(Value.InstanceReference.class, everything.elements().get(0));
-        assertEquals(seatBelt, everything.elements().get(1));
-        assertEquals(new Value.StringValue("seatBelt"), model.eval("Demo::belt"));
-      }
-    }
-  }
-
-  private static final String MEASUREMENT_REFS =
-      """
-      package M {
-        private import ScalarValues::*;
-        private import Quantities::*;
-        private import MeasurementReferences::*;
-        private import SI::*;
-        attribute q : ISQ::LengthValue = 3 [km];
-        attribute u : MeasurementUnit = m;
-        attribute speed = m / s;
-      }
-      """;
-
-  @Test
-  void theServiceAdvertisesTheVerificationBodyVerdictsItReports() {
-    assertTrue(connection.capabilities().has(Capabilities.VERIFICATION_VERDICTS));
-  }
-
-  private static final String TRADE_STUDY =
-      """
-      package Trade {
-        private import ScalarValues::*;
-        private import TradeStudies::*;
-        part def Engine { attribute mass : Real; attribute cylinders : Integer; }
-        part a : Engine { attribute :>> mass = 30.0; attribute :>> cylinders = 6; }
-        part b : Engine { attribute :>> mass = 10.0; attribute :>> cylinders = 4; }
-        part c : Engine { attribute :>> mass = 10.0; attribute :>> cylinders = 0; }
-        analysis lightest : TradeStudy {
-          subject : Engine[1..*] = (a, b, c);
-          objective : MinimizeObjective;
-          calc :>> evaluationFunction {
-            in part e :>> alternative : Engine;
-            return :>> result : Real = e.mass;
-          }
-          return part :>> selectedAlternative : Engine;
-        }
-        analysis perOffset : TradeStudy {
-          subject : Engine[1..*] = (a, b);
-          in attribute offset : Integer;
-          objective : MinimizeObjective;
-          calc :>> evaluationFunction {
-            in part e :>> alternative : Engine;
-            return :>> result : Real = e.mass / (e.cylinders - offset);
-          }
-          return part :>> selectedAlternative : Engine;
-        }
-      }
-      """;
-
-  @Test
-  void theServiceAdvertisesTheCaseEvaluationsItReports() {
-    assertTrue(connection.capabilities().has(Capabilities.CASE_EVALUATIONS));
-  }
-
-  @Test
-  void aTradeStudyArrivesWithEachAlternativesEvaluationTheSelectedOneAndTheTieMarked() {
-    try (Connection json =
-        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
-      for (Connection each : List.of(connection, json)) {
-        Model model = each.parse(TRADE_STUDY);
-        RunAnalysisResponse response =
-            each.call(
-                "RunAnalysis",
-                RunAnalysisRequest.newBuilder()
-                    .setModelHash(model.hash())
-                    .setSymbolId("Trade::lightest")
-                    .build(),
-                RunAnalysisResponse.getDefaultInstance());
-        assertEquals("", response.getError());
-        assertEquals(1, response.getOutputsCount());
-        long selected = response.getOutputs(0).getValue().getInstanceId();
-        assertEquals(1, response.getVerdictsCount());
-        assertEquals("tradeStudyObjective", response.getVerdicts(0).getElement());
-        assertTrue(response.getVerdicts(0).getHolds());
-
-        List<CaseEvaluation> evaluations = response.getEvaluationsList();
-        assertEquals(
-            List.of("Trade::a", "Trade::b", "Trade::c"),
-            evaluations.stream()
-                .map(e -> typeOf(response.getInstancesList(), e.getArguments(0).getInstanceId()))
-                .toList());
-        assertEquals(
-            List.of(30.0, 10.0, 10.0),
-            evaluations.stream().map(e -> e.getResult().getRealValue()).toList());
-        assertEquals(
-            List.of(false, true, false),
-            evaluations.stream().map(CaseEvaluation::getSelected).toList());
-        assertEquals(
-            List.of(false, false, true),
-            evaluations.stream().map(CaseEvaluation::getTied).toList());
-        assertEquals(selected, evaluations.get(1).getArguments(0).getInstanceId());
-        evaluations.forEach(
-            e -> assertEquals("Trade::lightest::evaluationFunction", e.getFunctionId()));
-      }
-    }
-  }
-
-  @Test
-  void aSweptTradeStudyCarriesEachRowsEvaluationsAFailedRowKeepingThoseItMade() {
-    Model model = connection.parse(TRADE_STUDY);
-    RunSweepResponse response =
-        connection.call(
-            "RunSweep",
-            RunSweepRequest.newBuilder()
-                .setModelHash(model.hash())
-                .setSymbolId("Trade::perOffset")
-                .addRanges(
-                    SweepRange.newBuilder()
-                        .setParameter("offset")
-                        .setStart(
-                            org.openmbee.opensysml.proto.Value.newBuilder().setIntValue(3))
-                        .setEnd(org.openmbee.opensysml.proto.Value.newBuilder().setIntValue(4)))
-                .build(),
-            RunSweepResponse.getDefaultInstance());
-    assertEquals("", response.getError());
-    assertEquals(2, response.getRowsCount());
-
-    SweepRow ok = response.getRows(0);
-    assertEquals("", ok.getError());
-    assertEquals(1, ok.getOutputsCount());
-    assertEquals(
-        List.of(10.0, 10.0),
-        ok.getEvaluationsList().stream().map(e -> e.getResult().getRealValue()).toList());
-    assertEquals(
-        List.of(true, false),
-        ok.getEvaluationsList().stream().map(CaseEvaluation::getSelected).toList());
-    assertEquals(
-        List.of(false, true),
-        ok.getEvaluationsList().stream().map(CaseEvaluation::getTied).toList());
-
-    SweepRow failed = response.getRows(1);
-    assertTrue(failed.getError().contains("division by zero"));
-    assertEquals(0, failed.getOutputsCount());
-    assertEquals(1, failed.getVerdictsCount());
-    assertFalse(failed.getVerdicts(0).getHolds());
-    assertTrue(failed.getVerdicts(0).getError().contains("division by zero"));
-    assertEquals(2, failed.getEvaluationsCount());
-    assertEquals(15.0, failed.getEvaluations(0).getResult().getRealValue());
-    assertEquals("", failed.getEvaluations(0).getError());
-    assertFalse(failed.getEvaluations(1).hasResult());
-    assertTrue(failed.getEvaluations(1).getError().contains("division by zero"));
-    long failedAlternative = failed.getEvaluations(1).getArguments(0).getInstanceId();
-    assertEquals("Trade::b", typeOf(response.getInstancesList(), failedAlternative));
-    assertFalse(failed.getEvaluationsList().stream().anyMatch(CaseEvaluation::getSelected));
-  }
-
-  private static String typeOf(List<org.openmbee.opensysml.proto.Instance> instances, long id) {
-    return instances.stream()
-        .filter(inst -> inst.getId() == id)
-        .map(org.openmbee.opensysml.proto.Instance::getTypeSymbolId)
-        .findFirst()
-        .orElseThrow();
-  }
-
-  @Test
-  void theServiceAdvertisesTheScheduleOfItsExecutionRequests() {
-    assertTrue(connection.capabilities().has(Capabilities.SCHEDULE));
-  }
-
-  @Test
-  void theServiceAdvertisesTheExploreSchedule() {
-    assertTrue(connection.capabilities().has(Capabilities.SCHEDULE_EXPLORE));
-  }
-
-  @Test
-  void theServiceAdvertisesItsAnalysisEngines() {
-    assertTrue(connection.capabilities().has(Capabilities.ENGINES));
-  }
-
-  @Test
-  void theServiceAdvertisesTheFinalClockInstantOfItsExecutionResponses() {
-    assertTrue(connection.capabilities().has(Capabilities.FINAL_TIME));
-  }
-
-  @Test
-  void aBareMeasurementReferenceArrivesWithItsReductionAndDeclarationOverProtobufAndJson() {
-    assertTrue(connection.capabilities().has(Capabilities.MEASUREMENT_REFS));
-    try (Connection json =
-        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
-      for (Connection each : List.of(connection, json)) {
-        Model model = each.parse(MEASUREMENT_REFS);
-        assertEquals(
-            new Value.MeasurementRefValue(
-                "m",
-                new Quantity.UnitTerm(
-                    1.0, 1.0, List.of(new Quantity.UnitFactor("SI::metre", 1.0))),
-                Optional.of("SI::metre")),
-            model.eval("M::u"));
-        Value.MeasurementRefValue km = (Value.MeasurementRefValue) model.eval("M::q.mRef");
-        assertEquals("km", km.unit());
-        assertEquals(Optional.of("SI::kilometre"), km.unitId());
-        assertEquals(1000.0, km.reduction().scaleNumerator());
-        Value.MeasurementRefValue speed = (Value.MeasurementRefValue) model.eval("M::speed");
-        assertEquals(Optional.empty(), speed.unitId());
-        assertEquals(
-            List.of(
-                new Quantity.UnitFactor("SI::metre", 1.0),
-                new Quantity.UnitFactor("SI::second", -1.0)),
-            speed.reduction().factors());
-      }
-    }
-  }
-
-  private static final String FUNCTIONS =
-      """
-      package Demo {
-        private import ScalarValues::*;
-        calc def Sq { in v : Real; return : Real = v * v; }
-        calc def Fn { in calc f { in v : Real; return : Real; } in a : Real; return : Real = f(a); }
-        calc def Identity { in calc f { in v : Real; return : Real; } return r = f; }
-        attribute pick = Identity(Sq);
-        attribute nine = Fn(Sq, 3.0);
-        part def Scaler {
-          attribute k : Real = 2.0;
-          calc scale { in x : Real; return : Real = x * k; }
-        }
-        part holder : Scaler;
-        attribute scaler = holder.scale;
-      }
-      """;
-
-  @Test
-  void aCalcHeldAsAValueArrivesAsTheFunctionItNamesOverProtobufAndJson() {
-    assertTrue(connection.capabilities().has(Capabilities.FUNCTION_VALUES));
-    try (Connection json =
-        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
-      for (Connection each : List.of(connection, json)) {
-        Model model = each.parse(FUNCTIONS);
-        assertEquals(
-            new Value.FunctionValue("Demo::Sq", Optional.empty()), model.eval("Demo::pick"));
-        assertEquals(new Value.RealValue(9.0), model.eval("Demo::nine"));
-        Value.FunctionValue scale = (Value.FunctionValue) model.eval("Demo::scaler");
-        assertEquals("Demo::Scaler::scale", scale.calcId());
-        assertTrue(scale.selfId().orElseThrow() > 0);
-      }
-    }
-  }
-
-  @Test
-  void aModelTheServiceDoesNotHoldIsRefused() {
-    Model absent = connection.model("sha256:0000000000000000");
-    OpenSysMLException refused =
-        assertThrows(OpenSysMLException.class, () -> absent.symbol("Demo::Vehicle"));
-    if (refused instanceof ServiceException service) {
-      assertEquals(StatusCode.NOT_FOUND, service.status());
-    }
-  }
-
-  @Test
-  void aParseThatFindsErrorsReportsThemAsDiagnosticsRatherThanARefusal() {
-    Model model = connection.parse("part def { { {");
-    List<Diagnostic> diagnostics = model.parseDiagnostics();
-    assertFalse(diagnostics.isEmpty());
-    assertEquals(Diagnostic.Severity.ERROR, diagnostics.get(0).severity());
-    assertEquals(diagnostics, model.diagnostics());
-  }
-
-  @Test
-  void theServiceAdvertisesTheDiagnosticCodesItPopulates() {
-    assertTrue(connection.capabilities().has(Capabilities.DIAGNOSTIC_CODES));
-    Model model = connection.parse("package P { part def W { part hub : Missing; } }");
-    assertTrue(model.diagnostics().stream().anyMatch(d -> "unresolved".equals(d.code())));
-  }
-
-  @Test
-  void aJsonBodyAnswersWhatAProtobufBodyAnswers() {
-    try (Connection json =
-        Connection.open(ServiceBinary.options().encoding(Encoding.JSON).build())) {
-      assertEquals(connection.address(), json.address(), "the private service is shared");
-      Model model = json.parse(VEHICLE);
-      assertEquals(new Value.RealValue(1200.0), model.evalWithSubject("mass", "Demo::sedan"));
-      assertEquals(connection.capabilities(), json.capabilities());
-    }
-  }
-
-  @Test
-  void aModelHashOutlivesTheConnectionThatParsedIt() {
-    String hash;
-    try (Connection first = Connection.open(ServiceBinary.options().build())) {
-      hash = first.parse(VEHICLE).hash();
-    }
-    assertEquals(new Value.IntegerValue(4), connection.model(hash).eval("2 + 2"));
-  }
-
-  @Test
-  void twoModelsAreToldApartByHash() {
-    assertNotEquals(connection.parse(VEHICLE).hash(), connection.parse("package Other {}").hash());
-  }
-
-  @Test
-  void strictConformanceIsCapabilityGated() {
-    ParseOptions strict = ParseOptions.defaults().withStrictConformance(true);
-    if (connection.capabilities().has(Capabilities.STRICT_CONFORMANCE)) {
-      assertFalse(connection.parse(VEHICLE, strict).hash().isBlank());
-    } else {
-      assertThrows(CapabilityException.class, () -> connection.parse(VEHICLE, strict));
-    }
-  }
-
-  @Test
-  void aClosedConnectionRefusesCalls() {
-    Connection closed = Connection.open(ServiceBinary.options().build());
-    closed.close();
-    closed.close(); // idempotent
-    assertThrows(IllegalStateException.class, () -> closed.parse(VEHICLE));
-  }
-}
diff --git a/clients/java/opensysml-conformance/pom.xml b/clients/java/opensysml-conformance/pom.xml
deleted file mode 100644
index 4f952acb40..0000000000
--- a/clients/java/opensysml-conformance/pom.xml
+++ /dev/null
@@ -1,64 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
-  <modelVersion>4.0.0</modelVersion>
-
-  <parent>
-    <groupId>org.openmbee</groupId>
-    <artifactId>opensysml-parent</artifactId>
-    <version>0.1.0-SNAPSHOT</version>
-  </parent>
-
-  <artifactId>opensysml-conformance</artifactId>
-  <packaging>jar</packaging>
-
-  <name>OpenSysML Java conformance runner</name>
-  <description>Runs conformance/scenarios through the Java client's public API and writes the
-    report shape cmd/conformance writes. Not published: a test tool, not a library.</description>
-  <url>https://github.com/Open-MBEE/OpenSysML/tree/main/clients/java</url>
-
-  <properties>
-    <maven.deploy.skip>true</maven.deploy.skip>
-  </properties>
-
-  <dependencies>
-    <dependency>
-      <groupId>org.openmbee</groupId>
-      <artifactId>opensysml-client</artifactId>
-      <version>${project.version}</version>
-    </dependency>
-    <!-- Scenario files are protobuf JSON, so the runner needs the JSON parser the
-         client leaves optional. -->
-    <dependency>
-      <groupId>com.google.protobuf</groupId>
-      <artifactId>protobuf-java-util</artifactId>
-    </dependency>
-    <dependency>
-      <groupId>com.google.code.gson</groupId>
-      <artifactId>gson</artifactId>
-    </dependency>
-    <dependency>
-      <groupId>org.junit.jupiter</groupId>
-      <artifactId>junit-jupiter</artifactId>
-    </dependency>
-  </dependencies>
-
-  <build>
-    <plugins>
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-jar-plugin</artifactId>
-        <configuration>
-          <archive>
-            <manifest>
-              <mainClass>org.openmbee.opensysml.conformance.Main</mainClass>
-              <addClasspath>true</addClasspath>
-              <classpathPrefix>lib/</classpathPrefix>
-            </manifest>
-          </archive>
-        </configuration>
-      </plugin>
-    </plugins>
-  </build>
-</project>
diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java b/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java
deleted file mode 100644
index 5dff4b8b32..0000000000
--- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Api.java
+++ /dev/null
@@ -1,242 +0,0 @@
-package org.openmbee.opensysml.conformance;
-
-import com.google.protobuf.Message;
-import org.openmbee.opensysml.Capabilities;
-import org.openmbee.opensysml.CapabilityException;
-import org.openmbee.opensysml.Connection;
-import org.openmbee.opensysml.Instantiation;
-import org.openmbee.opensysml.Language;
-import org.openmbee.opensysml.Model;
-import org.openmbee.opensysml.ModelException;
-import org.openmbee.opensysml.ParseOptions;
-import org.openmbee.opensysml.ServiceException;
-import org.openmbee.opensysml.Symbol;
-import org.openmbee.opensysml.Value;
-import org.openmbee.opensysml.proto.DiagnosticsRequest;
-import org.openmbee.opensysml.proto.DiagnosticsResponse;
-import org.openmbee.opensysml.proto.EvaluateRequest;
-import org.openmbee.opensysml.proto.EvaluateResponse;
-import org.openmbee.opensysml.proto.GetSymbolRequest;
-import org.openmbee.opensysml.proto.InstantiateRequest;
-import org.openmbee.opensysml.proto.InstantiateResponse;
-import org.openmbee.opensysml.proto.ParseFileRequest;
-import org.openmbee.opensysml.proto.ParseFileResponse;
-import org.openmbee.opensysml.proto.ServerInfoRequest;
-import org.openmbee.opensysml.proto.ServerInfoResponse;
-import org.openmbee.opensysml.proto.SymbolResponse;
-import org.openmbee.opensysml.proto.Sysml;
-import java.nio.file.Path;
-import java.util.List;
-import java.util.Set;
-import java.util.TreeSet;
-
-/**
- * Makes a scenario's call through the client's public API and writes the answer back as the
- * response message the scenario is stated against. An RPC v1 does not cover is reported as such
- * rather than called over the transport, which is what the skipped count in a report counts.
- */
-final class Api {
-
-  private static final String RPC_EVALUATE = "Evaluate";
-  private static final String RPC_GET_DIAGNOSTICS = "GetDiagnostics";
-  private static final String RPC_GET_SERVER_INFO = "GetServerInfo";
-  private static final String RPC_GET_SYMBOL = "GetSymbol";
-  private static final String RPC_INSTANTIATE = "Instantiate";
-  private static final String RPC_PARSE_FILE = "ParseFile";
-
-  /** The RPCs the v1 API covers, and so the ones a scenario can be run through it. */
-  static final Set<String> COVERED =
-      Set.of(
-          RPC_GET_SERVER_INFO,
-          RPC_PARSE_FILE,
-          RPC_GET_SYMBOL,
-          RPC_GET_DIAGNOSTICS,
-          RPC_EVALUATE,
-          RPC_INSTANTIATE);
-
-  private final Connection connection;
-
-  Api(Connection connection) {
-    this.connection = connection;
-  }
-
-  /** What a call answered: a response, a refusal carrying a status, or nothing the API can do. */
-  sealed interface Answer {
-    /** The service answered. */
-    record Answered(Message response) implements Answer {}
-
-    /** The call was refused with a status. */
-    record Refused(String status, String message) implements Answer {}
-
-    /** The v1 API cannot make this call, so the scenario is skipped. */
-    record Unsupported(String reason) implements Answer {}
-  }
-
-  /**
-   * An empty request of the method's input type, which the scenario's protobuf JSON is merged into.
-   *
-   * @param method the bare method name
-   * @return a builder of that method's request
-   */
-  static Message.Builder request(String method) {
-    return switch (method) {
-      case RPC_GET_SERVER_INFO -> ServerInfoRequest.newBuilder();
-      case RPC_PARSE_FILE -> ParseFileRequest.newBuilder();
-      case RPC_GET_SYMBOL -> GetSymbolRequest.newBuilder();
-      case RPC_GET_DIAGNOSTICS -> DiagnosticsRequest.newBuilder();
-      case RPC_EVALUATE -> EvaluateRequest.newBuilder();
-      case RPC_INSTANTIATE -> InstantiateRequest.newBuilder();
-      default -> throw new IllegalArgumentException("no request type for " + method);
-    };
-  }
-
-  /**
-   * Whether the service declares an RPC at all, so a scenario naming one that does not exist is an
-   * error in the suite rather than a skip.
-   *
-   * @param method the bare method name
-   * @return whether {@code sysml.SysMLService} declares it
-   */
-  static boolean declared(String method) {
-    return Sysml.getDescriptor().getServices().stream()
-        .filter(service -> service.getFullName().equals("sysml.SysMLService"))
-        .anyMatch(service -> service.findMethodByName(method) != null);
-  }
-
-  /**
-   * Makes one call.
-   *
-   * @param method the bare method name
-   * @param request the request the scenario named
-   * @return what it answered
-   */
-  Answer call(String method, Message request) {
-    if (!COVERED.contains(method)) {
-      return new Answer.Unsupported("the v1 API does not cover " + method);
-    }
-    try {
-      return new Answer.Answered(
-          switch (method) {
-            case RPC_GET_SERVER_INFO -> serverInfo();
-            case RPC_PARSE_FILE -> parse((ParseFileRequest) request);
-            case RPC_GET_SYMBOL -> symbol((GetSymbolRequest) request);
-            case RPC_GET_DIAGNOSTICS -> diagnostics((DiagnosticsRequest) request);
-            case RPC_EVALUATE -> evaluate((EvaluateRequest) request);
-            case RPC_INSTANTIATE -> instantiate((InstantiateRequest) request);
-            default -> throw new IllegalStateException(method);
-          });
-    } catch (Unsupported e) {
-      return new Answer.Unsupported(e.getMessage());
-    } catch (ServiceException e) {
-      return new Answer.Refused(e.status().name(), e.serviceMessage());
-    } catch (CapabilityException e) {
-      // The client refuses a call whose behaviour the service does not advertise, since the
-      // service would ignore the field rather than answer UNIMPLEMENTED.
-      return new Answer.Refused("UNIMPLEMENTED", e.getMessage());
-    }
-  }
-
-  /** A call the v1 API has no way to make, thrown where the request is read. */
-  private static final class Unsupported extends RuntimeException {
-    private static final long serialVersionUID = 1L;
-
-    Unsupported(String reason) {
-      super(reason);
-    }
-  }
-
-  private ServerInfoResponse serverInfo() {
-    Capabilities capabilities = connection.capabilities();
-    return ServerInfoResponse.newBuilder()
-        .setVersion(capabilities.serviceVersion())
-        .addAllCapabilities(new TreeSet<>(capabilities.names()))
-        .build();
-  }
-
-  private ParseFileResponse parse(ParseFileRequest request) {
-    ParseOptions options =
-        new ParseOptions(Language.fromWireName(request.getLanguage()), request.getStrictConformance());
-    Model model =
-        switch (request.getSourceCase()) {
-          case FILE_PATH -> connection.load(Path.of(request.getFilePath()), options);
-          case CONTENT -> connection.parse(request.getContent(), options);
-          case SOURCE_NOT_SET ->
-              throw new Unsupported(
-                  "the v1 API always names a source, so it cannot send a request naming none");
-        };
-    ParseFileResponse.Builder response =
-        ParseFileResponse.newBuilder()
-            .setModelHash(model.hash())
-            .addAllDiagnostics(Rendering.diagnostics(model.parseDiagnostics()));
-    model.root().ifPresent(root -> response.setRoot(Rendering.symbol(root)));
-    return response.build();
-  }
-
-  private SymbolResponse symbol(GetSymbolRequest request) {
-    Model model = connection.model(request.getModelHash());
-    try {
-      Symbol symbol = model.symbol(request.getSymbolId());
-      return SymbolResponse.newBuilder().setSymbol(Rendering.symbol(symbol)).build();
-    } catch (ModelException e) {
-      return SymbolResponse.newBuilder().setError(e.getMessage()).build();
-    }
-  }
-
-  private DiagnosticsResponse diagnostics(DiagnosticsRequest request) {
-    Model model = connection.model(request.getModelHash());
-    try {
-      return DiagnosticsResponse.newBuilder()
-          .addAllDiagnostics(Rendering.diagnostics(model.diagnostics()))
-          .build();
-    } catch (ModelException e) {
-      return DiagnosticsResponse.newBuilder()
-          .setError(e.getMessage())
-          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
-          .build();
-    }
-  }
-
-  private EvaluateResponse evaluate(EvaluateRequest request) {
-    Model model = connection.model(request.getModelHash());
-    boolean hasContext = !request.getContextSymbolId().isEmpty();
-    boolean hasSubject = !request.getSubjectSymbolId().isEmpty();
-    if (hasContext && hasSubject) {
-      throw new Unsupported("the v1 API evaluates in a context or against a subject, not both");
-    }
-    try {
-      Value value;
-      if (hasSubject) {
-        value = model.evalWithSubject(request.getExpression(), request.getSubjectSymbolId());
-      } else if (hasContext) {
-        value = model.evalInContext(request.getExpression(), request.getContextSymbolId());
-      } else {
-        value = model.eval(request.getExpression());
-      }
-      return EvaluateResponse.newBuilder().setResult(Rendering.value(value)).build();
-    } catch (ModelException e) {
-      return EvaluateResponse.newBuilder()
-          .setError(e.getMessage())
-          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
-          .build();
-    }
-  }
-
-  private InstantiateResponse instantiate(InstantiateRequest request) {
-    Model model = connection.model(request.getModelHash());
-    try {
-      Instantiation instantiation = model.instantiate(request.getSymbolId());
-      List<org.openmbee.opensysml.proto.Instance> reachable =
-          instantiation.reachable().stream().map(Rendering::instance).toList();
-      return InstantiateResponse.newBuilder()
-          .setInstance(Rendering.instance(instantiation.root()))
-          .addAllInstances(reachable)
-          .addAllDiagnostics(Rendering.diagnostics(instantiation.diagnostics()))
-          .build();
-    } catch (ModelException e) {
-      return InstantiateResponse.newBuilder()
-          .setError(e.getMessage())
-          .addAllDiagnostics(Rendering.diagnostics(e.diagnostics()))
-          .build();
-    }
-  }
-}
diff --git a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Rendering.java b/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Rendering.java
deleted file mode 100644
index 96cd63056b..0000000000
--- a/clients/java/opensysml-conformance/src/main/java/org/openmbee/opensysml/conformance/Rendering.java
+++ /dev/null
@@ -1,268 +0,0 @@
-package org.openmbee.opensysml.conformance;
-
-import org.openmbee.opensysml.Diagnostic;
-import org.openmbee.opensysml.EnumLiteral;
-import org.openmbee.opensysml.Instance;
-import org.openmbee.opensysml.Quantity;
-import org.openmbee.opensysml.Symbol;
-import org.openmbee.opensysml.Value;
-import org.openmbee.opensysml.proto.AttributeInfo;
-import org.openmbee.opensysml.proto.FeatureValue;
-import org.openmbee.opensysml.proto.MultiplicityInfo;
-import org.openmbee.opensysml.proto.Span;
-import org.openmbee.opensysml.proto.Specialization;
-import org.openmbee.opensysml.proto.SymbolInfo;
-import org.openmbee.opensysml.proto.TensorQuantity;
-import org.openmbee.opensysml.proto.TypeInfo;
-import org.openmbee.opensysml.proto.Undetermined;
-import org.openmbee.opensysml.proto.UnitFactor;
-import org.openmbee.opensysml.proto.UnitTerm;
-import org.openmbee.opensysml.proto.ValueSequence;
-import org.openmbee.opensysml.proto.ValueSet;
-import java.util.List;
-import java.util.Map;
-
-/**
- * Writes the client's public types back into the generated messages the scenarios are stated
- * against. Comparing that rendering, rather than the transport's answer, is what makes a scenario a
- * test of the client's own reading of a response and not only of the service.
- */
-final class Rendering {
-
-  private Rendering() {}
-
-  /**
-   * A value.
-   *
-   * @param value the immutable value
-   * @return the generated value
-   */
-  static org.openmbee.opensysml.proto.Value value(Value value) {
-    org.openmbee.opensysml.proto.Value.Builder builder = org.openmbee.opensysml.proto.Value.newBuilder();
-    if (value instanceof Value.IntegerValue integral) {
-      builder.setIntValue(integral.value());
-    } else if (value instanceof Value.RealValue real) {
-      builder.setRealValue(real.value());
-    } else if (value instanceof Value.ComplexValue complex) {
-      builder.setComplex(
-          org.openmbee.opensysml.proto.Complex.newBuilder()
-              .setReal(complex.real())
-              .setImaginary(complex.imaginary()));
-    } else if (value instanceof Value.BooleanValue flag) {
-      builder.setBoolValue(flag.value());
-    } else if (value instanceof Value.StringValue text) {
-      builder.setStringValue(text.value());
-    } else if (value instanceof Value.InstanceReference reference) {
-      builder.setInstanceId(reference.instanceId());
-    } else if (value instanceof Value.Sequence sequence) {
-      ValueSequence.Builder elements = ValueSequence.newBuilder();
-      sequence.elements().forEach(element -> elements.addElements(value(element)));
-      builder.setSequence(elements);
-    } else if (value instanceof Value.NullValue) {
-      builder.setNull("");
-    } else if (value instanceof Value.UnsetValue) {
-      builder.setUnset(true);
-    } else if (value instanceof Value.UndeterminedValue undetermined) {
-      builder.setUndetermined(
-          Undetermined.newBuilder()
-              .setReason(undetermined.reason())
-              .setCount(
-                  MultiplicityInfo.newBuilder()
-                      .setLower(undetermined.countLower())
-                      .setUpper(undetermined.countUpper())));
-    } else if (value instanceof Value.InfinityValue) {
-      builder.setInfinity(true);
-    } else if (value instanceof Value.QuantityValue quantity) {
-      builder.setQuantity(quantity(quantity.quantity()));
-    } else if (value instanceof Value.EnumerationValue literal) {
-      builder.setEnumLiteral(literal(literal.literal()));
-    } else if (value instanceof Value.ArrayValue array) {
-      org.openmbee.opensysml.proto.Array.Builder elements =
-          org.openmbee.opensysml.proto.Array.newBuilder().addAllDimensions(array.dimensions());
-      array.elements().forEach(element -> elements.addElements(value(element)));
-      builder.setArray(elements);
-    } else if (value instanceof Value.VectorValue vector) {
-      org.openmbee.opensysml.proto.Vector.Builder components =
-          org.openmbee.opensysml.proto.Vector.newBuilder();
-      vector.components().forEach(component -> components.addComponents(value(component)));
-      builder.setVector(components);
-    } else if (value instanceof Value.VectorQuantityValue vector) {
-      org.openmbee.opensysml.proto.VectorQuantity.Builder components =
-          org.openmbee.opensysml.proto.VectorQuantity.newBuilder();
-      vector.components().forEach(component -> components.addComponents(quantity(component)));
-      builder.setVectorQuantity(components);
-    } else if (value instanceof Value.MeasurementRefValue ref) {
-      org.openmbee.opensysml.proto.MeasurementRef.Builder reference =
-          org.openmbee.opensysml.proto.MeasurementRef.newBuilder()
-              .setUnit(ref.unit())
-              .setUnitTerm(unitTerm(ref.reduction()));
-      ref.unitId().ifPresent(reference::setUnitId);
-      builder.setMeasurementRef(reference);
-    } else if (value instanceof Value.FunctionValue function) {
-      builder.setFunction(
-          org.openmbee.opensysml.proto.Function.newBuilder()
-              .setCalcId(function.calcId())
-              .setSelfId(function.selfId().orElse(0L)));
-    } else if (value instanceof Value.SetValue set) {
-      ValueSet.Builder elements = ValueSet.newBuilder();
-      set.elements().forEach(element -> elements.addElements(value(element)));
-      builder.setSet(elements);
-    } else if (value instanceof Value.TensorQuantityValue tensor) {
-      TensorQuantity.Builder components =
-          TensorQuantity.newBuilder().addAllDimensions(tensor.dimensions());
-      tensor.components().forEach(component -> components.addComponents(quantity(component)));
-      builder.setTensorQuantity(components);
-    } else if (value instanceof Value.MetaobjectValue metaobject) {
-      builder.setMetaobject(
-          org.openmbee.opensysml.proto.Metaobject.newBuilder()
-              .setElementId(metaobject.elementId())
-              .setMetaclassId(metaobject.metaclassId()));
-    } else {
-      throw new IllegalStateException("no rendering for " + value.getClass());
-    }
-    return builder.build();
-  }
-
-  private static org.openmbee.opensysml.proto.Quantity quantity(Quantity quantity) {
-    org.openmbee.opensysml.proto.Quantity.Builder builder = org.openmbee.opensysml.proto.Quantity.newBuilder();
-    if (quantity.magnitude() instanceof Long integral) {
-      builder.setIntMagnitude(integral);
-    } else {
-      builder.setRealMagnitude(quantity.magnitude().doubleValue());
-    }
-    quantity.unit().ifPresent(builder::setUnit);
-    quantity.reduction().ifPresent(reduction -> builder.setUnitTerm(unitTerm(reduction)));
-    return builder.build();
-  }
-
-  private static UnitTerm unitTerm(Quantity.UnitTerm reduction) {
-    UnitTerm.Builder term =
-        UnitTerm.newBuilder()
-            .setScaleNum(reduction.scaleNumerator())
-            .setScaleDen(reduction.scaleDenominator());
-    for (Quantity.UnitFactor factor : reduction.factors()) {
-      term.addFactors(
-          UnitFactor.newBuilder().setUnitId(factor.unitId()).setExponent(factor.exponent()));
-    }
-    return term.build();
-  }
-
-  private static org.openmbee.opensysml.proto.EnumLiteral literal(EnumLiteral literal) {
-    return org.openmbee.opensysml.proto.EnumLiteral.newBuilder()
-        .setLiteralId(literal.literalId())
-        .setEnumerationId(literal.enumerationId())
-        .setName(literal.name())
-        .build();
-  }
-
-  /**
-   * Diagnostics.
-   *
-   * @param diagnostics the immutable diagnostics
-   * @return the generated diagnostics, in order
-   */
-  static List<org.openmbee.opensysml.proto.Diagnostic> diagnostics(List<Diagnostic> diagnostics) {
-    return diagnostics.stream().map(Rendering::diagnostic).toList();
-  }
-
-  private static org.openmbee.opensysml.proto.Diagnostic diagnostic(Diagnostic diagnostic) {
-    org.openmbee.opensysml.proto.Diagnostic.Builder builder =
-        org.openmbee.opensysml.proto.Diagnostic.newBuilder()
-            .setSeverity(diagnostic.severity().wireName())
-            .setMessage(diagnostic.message())
-            .setCode(diagnostic.code());
-    diagnostic
-        .span()
-        .ifPresent(
-            span ->
-                builder.setSpan(
-                    Span.newBuilder()
-                        .setFile(span.file())
-                        .setStartLine(span.startLine())
-                        .setStartCol(span.startColumn())
-                        .setEndLine(span.endLine())
-                        .setEndCol(span.endColumn())));
-    return builder.build();
-  }
-
-  /**
-   * A symbol.
-   *
-   * @param symbol the immutable symbol
-   * @return the generated symbol
-   */
-  static SymbolInfo symbol(Symbol symbol) {
-    SymbolInfo.Builder builder =
-        SymbolInfo.newBuilder()
-            .setId(symbol.id())
-            .setName(symbol.name())
-            .setKind(symbol.kind())
-            .putAllMetadata(symbol.metadata())
-            .addAllChildIds(symbol.childIds())
-            .setWithheldLibraryAttributes(symbol.withheldLibraryAttributes());
-    for (Symbol.Attribute attribute : symbol.attributes()) {
-      AttributeInfo.Builder rendered =
-          AttributeInfo.newBuilder().setName(attribute.name()).setType(attribute.type());
-      attribute.value().ifPresent(value -> rendered.setValue(value(value)));
-      attribute.unit().ifPresent(rendered::setUnit);
-      builder.addAttributes(rendered);
-    }
-    for (Symbol.Specialization specialization : symbol.specializations()) {
-      Specialization.Builder rendered =
-          Specialization.newBuilder()
-              .setKind(specialization.kind())
-              .setDeclared(specialization.declared());
-      specialization.targetId().ifPresent(rendered::setTargetId);
-      specialization.targetKind().ifPresent(rendered::setTargetKind);
-      builder.addSpecializations(rendered);
-    }
-    symbol
-        .typeFacts()
-        .ifPresent(
-            facts -> {
-              TypeInfo.Builder rendered = TypeInfo.newBuilder().setQuantity(facts.quantity());
-              facts.declared().ifPresent(rendered::setDeclared);
-              facts.resolvedId().ifPresent(rendered::setResolvedId);
-              facts.resolvedKind().ifPresent(rendered::setResolvedKind);
-              facts.primitive().ifPresent(rendered::setPrimitive);
-              facts.primitiveSource().ifPresent(rendered::setPrimitiveSource);
-              facts.unit().ifPresent(rendered::setUnit);
-              builder.setTypeInfo(rendered);
-            });
-    symbol
-        .multiplicity()
-        .ifPresent(
-            multiplicity -> {
-              MultiplicityInfo.Builder rendered = MultiplicityInfo.newBuilder();
-              multiplicity.lower().ifPresent(rendered::setLower);
-              multiplicity.upper().ifPresent(rendered::setUpper);
-              builder.setMultiplicity(rendered);
-            });
-    return builder.build();
-  }
-
-  /**
-   * An instance.
-   *
-   * @param instance the immutable instance
-   * @return the generated instance
-   */
-  static org.openmbee.opensysml.proto.Instance instance(Instance instance) {
-    org.openmbee.opensysml.proto.Instance.Builder builder =
-        org.openmbee.opensysml.proto.Instance.newBuilder()
-            .setId(instance.id())
-            .setTypeSymbolId(instance.typeSymbolId());
-    for (Map.Entry<String, Instance.FeatureValue> entry : instance.featureValues().entrySet()) {
-      Instance.FeatureValue featureValue = entry.getValue();
-      FeatureValue.Builder rendered =
-          FeatureValue.newBuilder()
-              .setFeatureName(featureValue.featureName())
-              .setMaterialized(featureValue.materialized());
-      featureValue.value().ifPresent(value -> rendered.setValue(value(value)));
-      featureValue.values().forEach(value -> rendered.addValues(value(value)));
-      featureValue.error().ifPresent(rendered::setError);
-      builder.putFeatureValues(entry.getKey(), rendered.build());
-    }
-    return builder.build();
-  }
-}
diff --git a/clients/node/README.md b/clients/node/README.md
deleted file mode 100644
index 9d14aa4ce3..0000000000
--- a/clients/node/README.md
+++ /dev/null
@@ -1,461 +0,0 @@
-# @opensysml/client
-
-Node and browser client for OpenSysML: parse, inspect and instantiate SysML v2
-models over the `sysml-grpc` service, using the [Connect
-protocol](https://connectrpc.com/docs/protocol) with protobuf bodies. No native
-addon, so an install is a plain registry fetch.
-
-```bash
-npm install @opensysml/client        # from npm, once the first release is published
-```
-
-```ts
-import { loads, connect } from "@opensysml/client";
-
-await using model = await loads(`package Demo {
-  part def Wheel { attribute radius : ScalarValues::Real = 0.3; }
-  part def Car { part wheels : Wheel[4]; attribute mass : ScalarValues::Real = 1500.0; }
-}`);
-
-const value = await model.eval("2 + 2");     // { kind: "int", value: 4n }
-const car = await model.symbol("Demo::Car"); // by qualified name
-await car.children();                        // its members, as symbols
-
-const tree = await model.instantiate("Demo::Car");
-const wheels = tree.get("wheels");           // a FeatureValue union
-if (wheels?.kind === "many") {
-  console.log(wheels.values.length);         // 4
-}
-```
-
-`loads`/`load` open a connection of their own and close it with the model.
-`connect()` is the longer-lived form, and parses more than one source over one
-service and one parse cache:
-
-```ts
-await using connection = await connect();
-const first = await connection.load("model.sysml");
-const second = await connection.loads("package Inline { part def Thing; }");
-connection.info.capabilities;                 // what this service can do
-connection.model(first.hash);                 // a model the service already holds
-```
-
-Both `Connection` and `Model` implement `Symbol.asyncDispose`, so `await using`
-closes them; `close()` is the explicit form, and is safe to call twice.
-
-## Values are discriminated unions
-
-Every `oneof` the service answers with arrives as a union to switch on, rather
-than a generated message with optional fields:
-
-```ts
-switch (value.kind) {
-  case "int":      value.value;                      // bigint, never lossy
-  case "real":     value.value;                       // number
-  case "complex":  value.value.real; value.value.imaginary;  // 1.5 - 2.0i, one value
-  case "boolean":  value.value;
-  case "string":   value.value;
-  case "quantity": value.magnitude; value.unit;       // 1500.0 [kg]
-  case "measurementRef": value.unit; value.unitTerm; value.unitId;  // a bare unit: km, reduced to 1000·metre
-  case "function": value.calcId; value.selfId;       // a calc as a value: Demo::Sq, or holder.scale read off an object
-  case "array":    value.dimensions; value.elements;  // row-major, an element is any SysMLValue
-  case "vector":   value.components;                   // { kind: "int" | "real" }[]
-  case "vectorQuantity": value.components;             // QuantityValue[], a unit per component
-  case "set":      value.elements;                     // SysMLValue[], each once, unordered
-  case "tensorQuantity": value.dimensions; value.components;  // any rank, row-major QuantityValue[]
-  case "metaobject": value.elementId; value.metaclassId;  // an element reflected on: x meta KerML::Feature
-  case "enum":     value.value.name; value.value.value; // its literal/enumeration ids, and the scalar a `high = 3` literal carries
-  case "instance": value.id;                          // an object in the same tree
-  case "sequence": value.elements;                    // SysMLValue[]
-  case "undetermined": value.reason; value.countLower; value.countUpper;  // the model leaves it open
-  case "infinity": break;                              // the unbounded `*`
-  case "null":     value.reason;                       // evaluated, no value
-  case "unset":    break;                              // declared, never given one
-  case "absent":   break;                              // the service sent no value at all
-}
-```
-
-`unset`, `undetermined` and `absent` are distinct on purpose: the first is a feature the
-model leaves without a value, the second a model-level answer the model leaves open (an
-unbound feature, a count the multiplicity does not fix) that is read but never sent, and the
-third a field the answer did not carry.
-`SysMLVerdict` (`holds` / `fails` / `undecided`) and `FeatureValue` (`single` /
-`many` / `error`) are unions of the same shape; every verdict arm carries a `standing` — the
-engine that answered, the strength of its evidence (`observed`, `witnessed`, `bounded`,
-`proved`) and the bounds it ran under — empty from a service without the `engines`
-capability. Integers are `bigint`, because
-the service's `int64` does not fit a `number` — an exact comparison against a
-scenario expectation would otherwise be a lie.
-
-## Two lifecycle modes
-
-### A private child of this process (the default)
-
-`connect()` with no address starts `sysml-grpc` as a child of this process with
-`-port 0 -health-port 0 -report-address -exit-with-parent`, and reads the address
-it bound from the child's first stdout line. No port is chosen, probed or
-retried, so two processes starting at once cannot collide.
-
-One child serves **every connection of a thread**: the first `connect()` starts
-it, the last `close()` stops it, and sharing it shares the service's parse cache.
-It is per thread rather than per process because the module state that holds it
-is per thread — a `worker_threads` worker gets its own child, and closing the
-worker's connections stops that child alone.
-
-**No orphans, and the mechanism is not an exit hook.** The client holds the write
-end of the child's stdin pipe and never writes to it; the child exits at end of
-file. The kernel closes that pipe when the holder dies however it dies, which
-survives what a `process.on("exit")` hook does not: `SIGKILL`, `process.abort()`,
-an uncaught fatal error, a crash during shutdown.
-`test/orphan.test.ts` proves it by `SIGKILL`ing a parent that holds a connection
-and asserting the child is gone.
-
-Node adds three wrinkles Python does not have, all deliberate here:
-
-- **The event loop.** A referenced child handle keeps Node alive, so a script that
-  forgot `close()` would never exit. The child and its three stdio handles are
-  `unref()`ed as soon as the address arrives — before that they stay referenced,
-  or Node could exit in the middle of starting the service. `stop()` `ref()`s the
-  child again for as long as it waits for it to exit.
-- **`detached: true`.** The child is started in a session of its own, so a
-  `SIGINT` meant for this process does not reach it mid-call; stdin is what ends
-  it. It is never `unref`ed *and* left running: this process holds the only write
-  end of its stdin.
-- **Worker threads.** Each thread owns its own child, as above.
-
-On **Windows** the guarantee is the same and rests on the same mechanism: the OS
-closes the anonymous stdin pipe when the owning process exits, however it exits,
-so the child sees end of file. What differs is that there is no process group to
-signal and no `SIGKILL` — `child.kill()` is `TerminateProcess` — so the orphan
-test is POSIX-only (`process.kill(pid, 0)` and `SIGKILL` have no equivalent), and
-prompt shutdown on Windows comes from closing stdin rather than from a signal.
-
-### A service someone else runs (explicit opt-in)
-
-```ts
-const connection = await connect({ address: "localhost:50051" });
-await connection.close();     // that service keeps running
-```
-
-or set `$OPENSYSML_SERVICE=host:port`. A connection made this way never owns the
-service: closing it disconnects and nothing else. There is no adoption of a
-service left listening by another process, no pidfile and no port probing.
-
-## The browser
-
-```ts
-import { connect } from "@opensysml/client/browser";
-
-await using connection = await connect({ address: "https://sysml.example.com" });
-```
-
-The browser entry point is the **explicit-address path only**: a browser cannot
-spawn a process, so there is no private child there and nothing to fall back to.
-It uses `@connectrpc/connect-web`, which is `fetch` and needs no proxy and no
-sidecar.
-
-Two limits to plan for rather than discover:
-
-- The service must allow the page's **exact origin** — start it with
-  `-cors-allowed-origins https://app.example.com`, never `*` — and must be
-  served over TLS (`-tls-cert`/`-tls-key`) for an HTTPS page to reach it.
-- `connect-go` v1.20 does not implement the base64 **`grpc-web-text`** variant.
-  This client does not need it: a `fetch`-based Connect client sends and reads
-  binary bodies directly. A `grpc-web` client that requires `-text` will not work
-  against this service, whatever the client.
-
-`test/browser.test.ts` runs this entry point against a real service over the same
-`fetch` transport, and asserts the allowed origin is answered on the preflight
-while another origin is not.
-
-## Protobuf, not JSON
-
-Bodies are protobuf by default. JSON is available (`connect({ encoding: "json" })`)
-for `curl`-shaped debugging, and it is the same answers, but
-[`docs/internals/design/transport-evaluation.md`](../../docs/internals/design/transport-evaluation.md)
-measured a 468 KB response at ~6.5 ms with a protobuf body against ~42 ms with
-JSON — `protojson` CPU on the service, not the ~10% difference in bytes. The
-gRPC protocol is also available (`connect({ protocol: "grpc" })`) and carries
-protobuf only; asking for `{ protocol: "grpc", encoding: "json" }` is refused
-rather than silently downgraded.
-
-## Capability negotiation
-
-Clients negotiate on the capability names `GetServerInfo` reports, not on
-versions:
-
-```ts
-import { CAPABILITY_EVALUATE_SUBJECT } from "@opensysml/client";
-
-if (connection.info.has(CAPABILITY_EVALUATE_SUBJECT)) {
-  await model.eval("mass", { subject: "Demo::sedan" });
-}
-```
-
-The client checks the advertised list **before** making such a call so it can
-raise a `MissingCapabilityError` naming the service, its version and the way to
-get one that has it. A direct capability-gated request to a service without the
-capability is refused with `UNIMPLEMENTED`; response-population capabilities
-instead omit the fields they name. A service without `structured_values`,
-`measurement_refs`, `function_values`, `set_values`, `tensor_values` or
-`metaobject_values` sends the value kinds those name (`array`, `vector`,
-`vectorQuantity`; `measurementRef`; `function`; `set`; `tensorQuantity`;
-`metaobject`) as `null` with an `unsupported: …` reason.
-A function closing over the bindings of a behavior body has no wire form and is
-sent as `null` by every service. A `set` arrives
-with its elements in the service's canonical order, so two equal sets arrive
-alike, and one listing a member twice is a `MalformedValueError`, whether it
-arrives or is about to be sent; one sent to the service may list its members in
-any order. `valuesEqual` is the membership test, as the service judges it: sets
-by membership, sequences in order, numbers by value — `1` and `1.0` are one
-member, exactly across the whole `int` range — and a quantity by magnitude
-through its `unitTerm`, so `1 [m]` is `100 [cm]` (exactly, while the magnitude
-is an `int` and the scale a whole ratio); one without a `unitTerm` is compared
-in its unit as written. A `tensorQuantity` carries its `dimensions` and one
-quantity per component, row-major. A `metaobject` — what `x meta KerML::Feature`
-or the last element of `x.metadata` evaluates to — names the element reflected
-on (`elementId`, its identity) and the element's own metaclass (`metaclassId`,
-not the type it was cast to); two are `valuesEqual` exactly when they name one
-element. Its features (`declaredName`, `ownedFeature`, …) are read in the model,
-not carried. One sent to the service may leave `metaclassId` empty to have the
-model's used; one naming a metaclass that is not the element's is refused, and
-one naming no element is a `MalformedValueError`.
-
-## Failures are typed
-
-Every failure is an `OpenSysMLError`. A call the service refused is a
-`ServiceError` whose `code` is the RPC status it came back with (`"NOT_FOUND"`,
-`"INVALID_ARGUMENT"`, …), and the statuses worth catching by themselves have a
-subclass: `ModelNotFoundError` (the service no longer holds that hash),
-`ModelFileNotFoundError`, `InvalidRequestError`, `ServiceTimeoutError`,
-`UnsupportedOperationError`. A name the model has not got is a
-`SymbolNotFoundError`, which carries the `symbolName` it looked for and the
-`suggestions` closest to it:
-
-```ts
-try {
-  await model.symbol("Wheeel");
-} catch (error) {
-  if (error instanceof SymbolNotFoundError) {
-    console.error(`no ${error.symbolName}; did you mean ${error.suggestions[0]}?`);
-  }
-}
-```
-
-Source that does not parse is not a failure: `load`/`loads` return a model whose
-`hasErrors` is true and whose `diagnostics` say where. Each `ModelDiagnostic` has
-`severity`, `message`, `code` and an optional location; branch on `code`
-(`"syntax"`, a validation code such as `"unresolved"`, `"choice-point"`,
-`"guard-unevaluable"`; `""` when the service assigned none), not on the message
-text. A service that populates `code` advertises `CAPABILITY_DIAGNOSTIC_CODES`;
-without it every code is `""`. Options that cannot work
-(an encoding that is not one, a timeout that cannot elapse, `grpc` with `json`)
-are refused before a connection is opened or a service started.
-
-## The service binary
-
-The binary comes from an **optional per-platform npm package**, selected by npm
-from its `os`/`cpu` metadata:
-
-| package | platform |
-| --- | --- |
-| `@opensysml/sysml-grpc-linux-x64` | Linux x86-64 |
-| `@opensysml/sysml-grpc-linux-arm64` | Linux arm64 |
-| `@opensysml/sysml-grpc-darwin-x64` | macOS Intel |
-| `@opensysml/sysml-grpc-darwin-arm64` | macOS Apple silicon |
-| `@opensysml/sysml-grpc-win32-x64` | Windows x86-64 |
-
-That is a normal registry install: npm verifies the tarball against the
-registry's integrity hash, and **there is no postinstall script**, so a platform
-with a package never downloads anything.
-
-Resolution order:
-
-1. `$OPENSYSML_BINARY` — a path to a binary, which wins over everything;
-2. the platform package above;
-3. `~/.opensysml/bin/sysml-grpc`, the cache the Python client also uses, filled
-   by a verified download of a release when nothing above resolved;
-4. `sysml-grpc` on `$PATH`;
-5. otherwise: an error, or connect to a service someone else runs.
-
-### Downloading a release
-
-`resolveBinary()` downloads a `sysml-grpc-<os>-<arch>` release asset into
-`~/.opensysml/bin/sysml-grpc` (`.exe` on Windows) when the steps above resolved
-nothing — the same cache, the same metadata beside it in `sysml-grpc.json`, and
-the same trust model as the Python client, so either client can use what the
-other downloaded. `process.platform`/`process.arch` map to the five published
-pairs (`linux-amd64`, `linux-arm64`, `darwin-amd64`, `darwin-arm64`,
-`windows-amd64`); any other pair is an error naming it rather than a fetch.
-
-| variable | effect |
-| --- | --- |
-| `$OPENSYSML_BINARY` | a path to a binary, which wins over everything |
-| `$OPENSYSML_GRPC_VERSION` | the release to download, else `latest` from the releases API |
-| `$OPENSYSML_GITHUB_REPO` | the release repository, default `Open-MBEE/OpenSysML` |
-| `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD` | `<owner/repo>`, or `1` for any repository: accept same-origin trust |
-
-The download goes to a temporary file, is hashed, and only then replaces the
-cache path atomically and is `chmod 0700`ed (POSIX); a download that does not
-verify leaves an existing cached binary and its metadata untouched and removes
-the temporary file. A cached binary of another version is replaced with a
-warning rather than used, and every request times out after 15 seconds. A
-transport failure falls back to a cached binary that still verifies; a digest or
-signature failure never does.
-
-### What a download is verified against
-
-In order, and each step refuses rather than falling back to the next:
-
-1. **A shipped pin.** `release-digests.json`, synced from
-   `clients/release-digests.json` by `python3 scripts/sync-release-digests.py`
-   and published in the tarball, pins the SHA-256 of every asset of a release.
-   Where it pins one, that is what the bytes must hash to, and a served
-   `.sha256` that disagrees is tampering: the download fails.
-2. **The release's signed manifest.** With no pin, the client downloads
-   `SHA256SUMS.txt` and its sigstore bundle `SHA256SUMS.txt.bundle`, verifies
-   the bundle against the release pipeline's certificate identity (the CircleCI
-   OIDC issuer and project in `src/node/signing.ts`), and takes the digest from
-   the verified manifest. Anything short of that — no bundle, a signature that
-   does not verify, another signer, an expired certificate, a manifest changed
-   after signing, a repository with no known signer, or the optional sigstore
-   packages not installed — is refused exactly as an unpinned release is. A
-   manifest digest that contradicts a pin is an error, not a downgrade.
-3. **Nothing.** The download fails naming the version, because the `.sha256`
-   served beside a binary comes from whoever served the binary: it detects
-   corruption but not a compromised release.
-   `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=<owner/repo>` (or `=1` for any
-   repository) accepts that same-origin trust explicitly, with a warning saying
-   so. It is never a way around a failed signature or a pin mismatch.
-
-Verification uses `@sigstore/verify` with `@sigstore/bundle`,
-`@sigstore/protobuf-specs` and `@sigstore/tuf` — the packages the `sigstore`
-package is itself built from — as **optional** dependencies, so the client
-installs and works without them and a release with no pin is refused where they
-are missing. They are used rather than `sigstore.verify` because that entry
-point takes its trusted root only through TUF, and both the Python client and
-these tests verify against a recorded trusted root offline.
-
-The per-platform packages are built by
-`npm run platform-packages -- --binaries <dir>`, and each binary is packaged
-**only** if its bytes match the `.sha256` sidecar beside it; a missing sidecar is
-refused rather than trusted. The release job cross-compiles those binaries from
-the tagged revision in the same run that publishes the packages and writes the
-sidecars there, so nothing is downloaded to authenticate — which is the job the
-Python client's pinned digests do — and an install is a normal registry fetch
-carrying npm's own integrity hashes. npm's `--provenance` is not used because
-the CLI mints attestations only on GitHub Actions and GitLab CI/CD, and this
-repository releases from CircleCI. The README of each package records the digest
-of the binary it carries.
-
-## Generated stubs
-
-`src/generated/sysml_pb.ts` is generated by `buf` from `api/proto/sysml.proto`
-through the `protoc-gen-es` entry in `buf.gen.ts.yaml`. The plugin is this
-package's devDependency, pinned in `package-lock.json` at the same version as the
-`@bufbuild/protobuf` runtime the stubs import, so `make proto` regenerates them
-from `node_modules` with no hand steps and no network fetch at generation time.
-
-**The stubs are committed**, for the same reason the Python ones are: `npm
-install @opensysml/client` must not need `buf`, Go or a network fetch of a
-plugin, and a published tarball has to contain the compiled output. CI runs
-`make proto-ts` and fails on any diff, so committed and generated cannot drift.
-
-## What v1 does not do
-
-This client covers `GetServerInfo`, `ParseFile`, `GetSymbol`, `Evaluate` and
-`Instantiate` — connection, lifecycle, capability negotiation, and the five RPCs
-above. Deliberately **not** in v1, rather than half-implemented:
-
-- generated model-ergonomics types (`python -m opensysml.generate`'s equivalent);
-- the edit API (`ApplyEdits`);
-- RDF conversion (`Convert`);
-- verification helpers (`VerifyConstraint`, `VerifyRequirement`,
-  `VerifySatisfaction`);
-- `Query`, `GetDiagnostics`, `EvaluateCalc`, `RunAnalysis`, `ExecuteAction`, `ExecuteState`.
-
-`connection.rpc` is the escape hatch: it is the generated Connect client, so any
-RPC not covered here can still be called, without the ergonomic layer.
-
-## Examples
-
-`examples/` is six programs against one model, a rover, in `examples/model.ts`.
-They are written to be read in order, they assert what they print, and the test
-suite runs every one of them, so they cannot drift from the API.
-
-```bash
-npm run examples          # all of them, in order
-npm run example 03        # one, by number or name
-```
-
-| example | what it shows |
-| --- | --- |
-| `01-tour` | connect, parse, look up, evaluate, instantiate |
-| `02-values` | every value kind, and what it decodes to in JavaScript |
-| `03-symbols` | walking, lookup by name and id, type facts, adoption by hash |
-| `04-instances` | instance trees, single and repeated features, unset and absent |
-| `05-diagnostics` | syntax errors, the error each failure raises, refused options |
-| `06-connections` | ownership, an external service, both protocols, calls at once |
-
-## Conformance
-
-The suite in `conformance/` is the service contract, and this client runs it
-**through its public API** — `load`/`loads`, `eval`, `symbol`, `instantiate` —
-not through the generated stubs. A scenario whose RPC v1 does not cover is
-skipped with a reason, and the report has the same shape `cmd/conformance` emits:
-
-```bash
-npm run conformance -- --allow-skips --report report.json
-```
-
-| protocol | ran | passed | failed | skipped |
-| --- | --: | --: | --: | --: |
-| `grpc` | 59 | 23 | 0 | 36 |
-| `connect` | 59 | 23 | 0 | 36 |
-| `connect-json` | 59 | 23 | 0 | 36 |
-| **total** | **177** | **69** | **0** | **108** |
-
-The 36 skips per protocol are 35 scenarios for the 10 RPCs listed above plus one
-the public API cannot express: a `ParseFile` naming no source, since `load` and
-`loads` always name one. Every skip carries its reason in the report.
-
-**The runner is not vacuous.** `--mutate <name>` corrupts a response on its way
-through the client, and each mutation makes at least one scenario fail:
-
-| `--mutate` | what it breaks | caught by |
-| --- | --- | --- |
-| `hide-capability` | drops a capability from `GetServerInfo` | `server_info/...` |
-| `drop-diagnostics` | drops parse diagnostics | `parse/a_syntax_error_...` |
-| `blank-symbol-kind` | blanks `SymbolInfo.kind` | `symbol/...` |
-| `shift-integer` | adds 1 to an integer result | `evaluate/arithmetic_is_an_integer` |
-| `drop-feature-values` | empties an instance's feature values | `instantiate/...` |
-
-```bash
-npm run conformance -- --allow-skips --mutate shift-integer   # must fail
-```
-
-## Development
-
-```bash
-npm install
-npm run build        # dist/, what is published
-npm run typecheck
-npm run lint
-npm test             # unit, lifecycle and service-backed tests
-npm run conformance -- --allow-skips
-```
-
-The service-backed tests build `sysml-grpc` from this checkout on first use, or
-use `$OPENSYSML_BINARY` when it names one. The `node-test` job runs all five
-commands, plus the mutation checks and a stub-drift check, in both
-`.github/workflows/pr.yml` (pull requests) and `.circleci/config.yml`.
-
-## Release
-
-Nothing here is published yet; the procedure is in
-[docs/project/releasing.md](../../docs/project/releasing.md) under "The Node
-client". In short: the `release-node` workflow runs on a `client-node-v*` tag,
-cross-compiles the binaries, builds the per-platform packages from them, and
-publishes those packages and then the client from the `npm` context. It needs
-the `@opensysml` npm organization and an automation token, which a maintainer
-supplies.
diff --git a/clients/node/package.json b/clients/node/package.json
deleted file mode 100644
index 6cbb3de65d..0000000000
--- a/clients/node/package.json
+++ /dev/null
@@ -1,77 +0,0 @@
-{
-  "name": "@opensysml/client",
-  "version": "0.1.0",
-  "description": "OpenSysML client: parse, evaluate and instantiate SysML v2 models over the sysml-grpc Connect API",
-  "license": "Apache-2.0",
-  "repository": {
-    "type": "git",
-    "url": "git+https://github.com/Open-MBEE/OpenSysML.git",
-    "directory": "clients/node"
-  },
-  "homepage": "https://github.com/Open-MBEE/OpenSysML/tree/main/clients/node#readme",
-  "keywords": [
-    "sysml",
-    "sysml-v2",
-    "mbse",
-    "connect",
-    "opensysml"
-  ],
-  "type": "module",
-  "engines": {
-    "node": ">=20.11"
-  },
-  "exports": {
-    ".": {
-      "types": "./dist/node/index.d.ts",
-      "browser": "./dist/browser/index.js",
-      "default": "./dist/node/index.js"
-    },
-    "./browser": {
-      "types": "./dist/browser/index.d.ts",
-      "default": "./dist/browser/index.js"
-    }
-  },
-  "files": [
-    "dist",
-    "README.md",
-    "release-digests.json"
-  ],
-  "scripts": {
-    "build": "tsc -p tsconfig.build.json",
-    "build:tools": "tsc -p tsconfig.json",
-    "typecheck": "tsc --noEmit -p tsconfig.json",
-    "lint": "eslint .",
-    "test": "npm run build:tools && node --test \"build/test/**/*.test.js\"",
-    "test:coverage": "npm run build:tools && c8 --reporter=text --reporter=lcovonly node --test \"build/test/**/*.test.js\"",
-    "example": "npm run build:tools && node scripts/run-example.mjs",
-    "examples": "npm run build:tools && node scripts/run-example.mjs --all",
-    "conformance": "npm run build:tools && node build/conformance/main.js --dir ../../conformance --repo ../..",
-    "platform-packages": "node scripts/build-platform-packages.mjs"
-  },
-  "dependencies": {
-    "@bufbuild/protobuf": "2.14.0",
-    "@connectrpc/connect": "2.1.2",
-    "@connectrpc/connect-node": "2.1.2",
-    "@connectrpc/connect-web": "2.1.2"
-  },
-  "optionalDependencies": {
-    "@opensysml/sysml-grpc-darwin-arm64": "0.1.0",
-    "@opensysml/sysml-grpc-darwin-x64": "0.1.0",
-    "@opensysml/sysml-grpc-linux-arm64": "0.1.0",
-    "@opensysml/sysml-grpc-linux-x64": "0.1.0",
-    "@opensysml/sysml-grpc-win32-x64": "0.1.0",
-    "@sigstore/bundle": "4.0.0",
-    "@sigstore/protobuf-specs": "0.5.1",
-    "@sigstore/tuf": "4.0.2",
-    "@sigstore/verify": "3.1.1"
-  },
-  "devDependencies": {
-    "@bufbuild/protoc-gen-es": "2.14.0",
-    "@types/make-fetch-happen": "^10.0.4",
-    "@types/node": "22.10.5",
-    "c8": "11.0.0",
-    "eslint": "9.39.5",
-    "typescript": "5.7.3",
-    "typescript-eslint": "8.67.0"
-  }
-}
diff --git a/clients/node/src/core/index.ts b/clients/node/src/core/index.ts
deleted file mode 100644
index 051446d511..0000000000
--- a/clients/node/src/core/index.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-// The isomorphic core: everything that does not need a process to spawn.
-
-export { Connection } from "./connection.js";
-export type {
-  ConnectionBackend,
-  Encoding,
-  ResponseTap,
-  TransportOptions,
-} from "./connection.js";
-export { Instance, InstanceTree, Model, ModelSymbol } from "./model.js";
-export type {
-  AttributeFacts,
-  EvalOptions,
-  FeatureValue,
-  ParseOptions,
-  SpecializationFacts,
-  TypeFacts,
-} from "./model.js";
-export {
-  CAPABILITY_APPLY_EDITS,
-  CAPABILITY_CASE_EVALUATIONS,
-  CAPABILITY_COMPLEX_VALUES,
-  CAPABILITY_CONVERT,
-  CAPABILITY_DIAGNOSTIC_CODES,
-  CAPABILITY_ENGINES,
-  CAPABILITY_ENUM_VALUES,
-  CAPABILITY_EVALUATE_SUBJECT,
-  CAPABILITY_FEATURE_VALUES,
-  CAPABILITY_FINAL_TIME,
-  CAPABILITY_FUNCTION_VALUES,
-  CAPABILITY_INFINITY_VALUE,
-  CAPABILITY_INLINE_LANGUAGE,
-  CAPABILITY_MEASUREMENT_REFS,
-  CAPABILITY_METAOBJECT_VALUES,
-  CAPABILITY_QUERY,
-  CAPABILITY_SCHEDULE,
-  CAPABILITY_SCHEDULE_EXPLORE,
-  CAPABILITY_SET_VALUES,
-  CAPABILITY_STRICT_CONFORMANCE,
-  CAPABILITY_STRUCTURED_VALUES,
-  CAPABILITY_UNDETERMINED_VALUE,
-  CAPABILITY_VERIFICATION_VERDICTS,
-  CAPABILITY_SYMBOL_ATTRIBUTES,
-  CAPABILITY_TENSOR_VALUES,
-  CAPABILITY_TYPE_FACTS,
-  CAPABILITY_UNSET_VALUE,
-  CAPABILITY_VERIFICATION,
-  MissingCapabilityError,
-  ServerInfo,
-  requireCapability,
-  upgradeRemedy,
-} from "./capabilities.js";
-export {
-  ChecksumMismatchError,
-  ClosedConnectionError,
-  DownloadError,
-  EvaluationError,
-  InvalidRequestError,
-  MalformedValueError,
-  ManifestSignatureError,
-  ModelFileNotFoundError,
-  ModelNotFoundError,
-  OpenSysMLError,
-  ParseError,
-  ServiceError,
-  ServiceStartError,
-  ServiceTimeoutError,
-  SymbolNotFoundError,
-  UnpinnedReleaseError,
-  UnsignedReleaseError,
-  UnsupportedOperationError,
-} from "./errors.js";
-export type { FailureCause, ModelDiagnostic } from "./errors.js";
-export { fromHandshakeError, fromRpcError, statusName } from "./status.js";
-export type { NotFoundSubject } from "./status.js";
-export {
-  decodeValue,
-  decodeVerdict,
-  decodeStanding,
-  encodeValue,
-  formatValue,
-  valuesEqual,
-} from "./values.js";
-export type {
-  ArrayValue,
-  ComplexValue,
-  EnumValue,
-  FunctionValue,
-  Magnitude,
-  MeasurementRefValue,
-  MetaobjectValue,
-  QuantityValue,
-  SysMLValue,
-  SysMLVerdict,
-  VerdictBound,
-  VerdictStanding,
-  TensorQuantityValue,
-  UndeterminedValue,
-  UnitFactor,
-  UnitFactorization,
-  VerdictSubject,
-} from "./values.js";
-export { baseUrl } from "./transport.js";
-export { SysMLService } from "../generated/sysml_pb.js";
diff --git a/clients/node/src/generated/sysml_pb.ts b/clients/node/src/generated/sysml_pb.ts
deleted file mode 100644
index 953122a3b4..0000000000
--- a/clients/node/src/generated/sysml_pb.ts
+++ /dev/null
@@ -1,4806 +0,0 @@
-// @generated by protoc-gen-es v2.14.0 with parameter "target=ts,import_extension=.js"
-// @generated from file sysml.proto (package sysml, syntax proto3)
-/* eslint-disable */
-
-import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
-import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
-import type { Message } from "@bufbuild/protobuf";
-
-/**
- * Describes the file sysml.proto.
- */
-export const file_sysml: GenFile = /*@__PURE__*/
-  fileDesc("CgtzeXNtbC5wcm90bxIFc3lzbWwiogIKB1ZlcmRpY3QSDAoEa2luZBgBIAEoCRISCgplbGVtZW50X2lkGAIgASgJEg8KB2VsZW1lbnQYAyABKAkSDQoFaG9sZHMYBCABKAgSEQoJY29uZGl0aW9uGAUgASgJEhMKC2luc3RhbmNlX2lkGAYgASgDEhgKEGluc3RhbmNlX3R5cGVfaWQYByABKAkSDQoFZXJyb3IYCCABKAkSLAoOZmFpbHVyZV9yZWFzb24YCSABKA4yFC5zeXNtbC5GYWlsdXJlUmVhc29uEhYKDnJlcXVpcmVtZW50X2lkGAogASgJEg4KBmVuZ2luZRgLIAEoCRIQCghzdHJlbmd0aBgMIAEoCRIcCgZib3VuZHMYDSADKAsyDC5zeXNtbC5Cb3VuZCI1CgVCb3VuZBIMCgRuYW1lGAEgASgJEg0KBWxpbWl0GAIgASgDEg8KB3JlYWNoZWQYAyABKAgiawoXVmVyaWZ5Q29uc3RyYWludFJlcXVlc3QSEgoKbW9kZWxfaGFzaBgBIAEoCRIRCglzeW1ib2xfaWQYAiABKAkSGQoRc3ViamVjdF9zeW1ib2xfaWQYAyABKAkSDgoGZW5naW5lGAQgASgJIpYBChhWZXJpZnlDb25zdHJhaW50UmVzcG9uc2USHwoHdmVyZGljdBgBIAEoCzIOLnN5c21sLlZlcmRpY3QSIgoJaW5zdGFuY2VzGAIgAygLMg8uc3lzbWwuSW5zdGFuY2USDQoFZXJyb3IYAyABKAkSJgoLZGlhZ25vc3RpY3MYBCADKAsyES5zeXNtbC5EaWFnbm9zdGljImwKGFZlcmlmeVJlcXVpcmVtZW50UmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhEKCXN5bWJvbF9pZBgCIAEoCRIZChFzdWJqZWN0X3N5bWJvbF9pZBgDIAEoCRIOCgZlbmdpbmUYBCABKAkibQoTVmVyaWZpY2F0aW9uVmVyZGljdBIPCgdjYXNlX2lkGAEgASgJEgwKBGtpbmQYAiABKAkSDgoGZGV0YWlsGAMgASgJEg8KB3N1YmNhc2UYBCABKAgSFgoOcmVxdWlyZW1lbnRfaWQYBSABKAki0gEKGVZlcmlmeVJlcXVpcmVtZW50UmVzcG9uc2USHwoHdmVyZGljdBgBIAEoCzIOLnN5c21sLlZlcmRpY3QSIgoJaW5zdGFuY2VzGAIgAygLMg8uc3lzbWwuSW5zdGFuY2USDQoFZXJyb3IYAyABKAkSJgoLZGlhZ25vc3RpY3MYBCADKAsyES5zeXNtbC5EaWFnbm9zdGljEjkKFXZlcmlmaWNhdGlvbl92ZXJkaWN0cxgFIAMoCzIaLnN5c21sLlZlcmlmaWNhdGlvblZlcmRpY3QiUgoZVmVyaWZ5U2F0aXNmYWN0aW9uUmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhEKCXN5bWJvbF9pZBgCIAEoCRIOCgZlbmdpbmUYAyABKAkiggIKGlZlcmlmeVNhdGlzZmFjdGlvblJlc3BvbnNlEiAKCHZlcmRpY3RzGAEgAygLMg4uc3lzbWwuVmVyZGljdBIiCglpbnN0YW5jZXMYAiADKAsyDy5zeXNtbC5JbnN0YW5jZRINCgVlcnJvchgDIAEoCRImCgtkaWFnbm9zdGljcxgEIAMoCzIRLnN5c21sLkRpYWdub3N0aWMSLAoOZmFpbHVyZV9yZWFzb24YBSABKA4yFC5zeXNtbC5GYWlsdXJlUmVhc29uEjkKFXZlcmlmaWNhdGlvbl92ZXJkaWN0cxgGIAMoCzIaLnN5c21sLlZlcmlmaWNhdGlvblZlcmRpY3QibQoTRXZhbHVhdGVDYWxjUmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhEKCXN5bWJvbF9pZBgCIAEoCRIfCglhcmd1bWVudHMYAyADKAsyDC5zeXNtbC5WYWx1ZRIOCgZlbmdpbmUYBCABKAki/QEKFEV2YWx1YXRlQ2FsY1Jlc3BvbnNlEhwKBnJlc3VsdBgBIAEoCzIMLnN5c21sLlZhbHVlEiIKB291dHB1dHMYAiADKAsyES5zeXNtbC5DYWxjT3V0cHV0Eg0KBWVycm9yGAMgASgJEiYKC2RpYWdub3N0aWNzGAQgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxIsCg5mYWlsdXJlX3JlYXNvbhgFIAEoDjIULnN5c21sLkZhaWx1cmVSZWFzb24SDgoGZW5naW5lGAYgASgJEhAKCHN0cmVuZ3RoGAcgASgJEhwKBmJvdW5kcxgIIAMoCzIMLnN5c21sLkJvdW5kIjcKCkNhbGNPdXRwdXQSDAoEbmFtZRgBIAEoCRIbCgV2YWx1ZRgCIAEoCzIMLnN5c21sLlZhbHVlIpMBCg5DYXNlRXZhbHVhdGlvbhITCgtmdW5jdGlvbl9pZBgBIAEoCRIfCglhcmd1bWVudHMYAiADKAsyDC5zeXNtbC5WYWx1ZRIcCgZyZXN1bHQYAyABKAsyDC5zeXNtbC5WYWx1ZRINCgVlcnJvchgEIAEoCRIQCghzZWxlY3RlZBgFIAEoCBIMCgR0aWVkGAYgASgIIqYCChJSdW5BbmFseXNpc1JlcXVlc3QSEgoKbW9kZWxfaGFzaBgBIAEoCRIRCglzeW1ib2xfaWQYAiABKAkSGQoRc3ViamVjdF9zeW1ib2xfaWQYAyABKAkSHwoJYXJndW1lbnRzGAQgAygLMgwuc3lzbWwuVmFsdWUSRgoPbmFtZWRfYXJndW1lbnRzGAUgAygLMi0uc3lzbWwuUnVuQW5hbHlzaXNSZXF1ZXN0Lk5hbWVkQXJndW1lbnRzRW50cnkSEAoIc2NoZWR1bGUYBiABKAkSDgoGZW5naW5lGAcgASgJGkMKE05hbWVkQXJndW1lbnRzRW50cnkSCwoDa2V5GAEgASgJEhsKBXZhbHVlGAIgASgLMgwuc3lzbWwuVmFsdWU6AjgBItwDChNSdW5BbmFseXNpc1Jlc3BvbnNlEiIKB291dHB1dHMYASADKAsyES5zeXNtbC5DYWxjT3V0cHV0EiAKCHZlcmRpY3RzGAIgAygLMg4uc3lzbWwuVmVyZGljdBIiCglpbnN0YW5jZXMYAyADKAsyDy5zeXNtbC5JbnN0YW5jZRINCgVlcnJvchgEIAEoCRImCgtkaWFnbm9zdGljcxgFIAMoCzIRLnN5c21sLkRpYWdub3N0aWMSLAoOZmFpbHVyZV9yZWFzb24YBiABKA4yFC5zeXNtbC5GYWlsdXJlUmVhc29uEjkKFXZlcmlmaWNhdGlvbl92ZXJkaWN0cxgHIAMoCzIaLnN5c21sLlZlcmlmaWNhdGlvblZlcmRpY3QSIAoIb3V0Y29tZXMYCCADKAsyDi5zeXNtbC5PdXRjb21lEi0KC2V4cGxvcmF0aW9uGAkgASgLMhguc3lzbWwuRXhwbG9yYXRpb25TdGF0dXMSKgoLZXZhbHVhdGlvbnMYCiADKAsyFS5zeXNtbC5DYXNlRXZhbHVhdGlvbhIOCgZlbmdpbmUYCyABKAkSEAoIc3RyZW5ndGgYDCABKAkSHAoGYm91bmRzGA0gAygLMgwuc3lzbWwuQm91bmQiggIKB091dGNvbWUSLAoHb3V0cHV0cxgBIAMoCzIbLnN5c21sLk91dGNvbWUuT3V0cHV0c0VudHJ5EhMKC2ZpbmFsX3N0YXRlGAIgASgJEhYKDnN0YXRlc192aXNpdGVkGAMgAygJEg0KBWVycm9yGAQgASgJEhYKDmxpbmVhcml6YXRpb25zGAUgASgFEg8KB3dpdG5lc3MYBiADKAkSJgoLZGlhZ25vc3RpY3MYByADKAsyES5zeXNtbC5EaWFnbm9zdGljGjwKDE91dHB1dHNFbnRyeRILCgNrZXkYASABKAkSGwoFdmFsdWUYAiABKAsyDC5zeXNtbC5WYWx1ZToCOAEicwoRRXhwbG9yYXRpb25TdGF0dXMSEAoIY29tcGxldGUYASABKAgSDAoEcnVucxgCIAEoBRITCgtidWRnZXRzX2hpdBgDIAMoCRITCgtydW5zX2J1ZGdldBgEIAEoBRIUCgxkZXB0aF9idWRnZXQYBSABKAUiFAoSTGlzdEVuZ2luZXNSZXF1ZXN0IvwBCgpFbmdpbmVJbmZvEgwKBG5hbWUYASABKAkSEQoJYXV0aG9yaXR5GAIgASgJEg8KB2Fuc3dlcnMYAyADKAkSDgoGYm91bmRzGAQgAygJEg8KB3Byb2Nlc3MYBSABKAkSFQoNcHJvY2Vzc19mb3VuZBgGIAEoCRINCgVyZWFkeRgHIAEoCBITCgt1bmF2YWlsYWJsZRgIIAEoCRIMCgRraW5kGAkgASgJEhAKCHByb3RvY29sGAogASgJEg4KBnNvdXJjZRgLIAEoCRIPCgdjb21tYW5kGAwgASgJEg8KB3ZlcnNpb24YDSABKAkSDgoGc2VydmVkGA4gASgIIjkKE0xpc3RFbmdpbmVzUmVzcG9uc2USIgoHZW5naW5lcxgBIAMoCzIRLnN5c21sLkVuZ2luZUluZm8ijAEKEFBhcnNlRmlsZVJlcXVlc3QSEwoJZmlsZV9wYXRoGAEgASgJSAASEQoHY29udGVudBgCIAEoCUgAEhgKDGNvbnRlbnRfaGFzaBgDIAEoCUICGAESEAoIbGFuZ3VhZ2UYBCABKAkSGgoSc3RyaWN0X2NvbmZvcm1hbmNlGAUgASgIQggKBnNvdXJjZSJiCg5Tb3VyY2VEb2N1bWVudBITCglmaWxlX3BhdGgYASABKAlIABIRCgdjb250ZW50GAIgASgJSAASEAoIbGFuZ3VhZ2UYAyABKAkSDAoEbmFtZRgEIAEoCUIICgZzb3VyY2UiWwoTUGFyc2VTb3VyY2VzUmVxdWVzdBIoCglkb2N1bWVudHMYASADKAsyFS5zeXNtbC5Tb3VyY2VEb2N1bWVudBIaChJzdHJpY3RfY29uZm9ybWFuY2UYAiABKAgigwEKFFBhcnNlU291cmNlc1Jlc3BvbnNlEhIKCm1vZGVsX2hhc2gYASABKAkSIAoFcm9vdHMYAiADKAsyES5zeXNtbC5TeW1ib2xJbmZvEiYKC2RpYWdub3N0aWNzGAMgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxINCgVlcnJvchgEIAEoCSJ/ChFQYXJzZUZpbGVSZXNwb25zZRISCgptb2RlbF9oYXNoGAEgASgJEh8KBHJvb3QYAiABKAsyES5zeXNtbC5TeW1ib2xJbmZvEiYKC2RpYWdub3N0aWNzGAMgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxINCgVlcnJvchgEIAEoCSI5ChBHZXRTeW1ib2xSZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkSEQoJc3ltYm9sX2lkGAIgASgJIkIKDlN5bWJvbFJlc3BvbnNlEiEKBnN5bWJvbBgBIAEoCzIRLnN5c21sLlN5bWJvbEluZm8SDQoFZXJyb3IYAiABKAkiKAoSRGlhZ25vc3RpY3NSZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkiTAoTRGlhZ25vc3RpY3NSZXNwb25zZRImCgtkaWFnbm9zdGljcxgBIAMoCzIRLnN5c21sLkRpYWdub3N0aWMSDQoFZXJyb3IYAiABKAkibwoPRXZhbHVhdGVSZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkSEgoKZXhwcmVzc2lvbhgCIAEoCRIZChFjb250ZXh0X3N5bWJvbF9pZBgDIAEoCRIZChFzdWJqZWN0X3N5bWJvbF9pZBgEIAEoCSJnChBFdmFsdWF0ZVJlc3BvbnNlEhwKBnJlc3VsdBgBIAEoCzIMLnN5c21sLlZhbHVlEg0KBWVycm9yGAIgASgJEiYKC2RpYWdub3N0aWNzGAMgAygLMhEuc3lzbWwuRGlhZ25vc3RpYyLCAQoISW5zdGFuY2USCgoCaWQYASABKAMSFgoOdHlwZV9zeW1ib2xfaWQYAiABKAkSOgoOZmVhdHVyZV92YWx1ZXMYBCADKAsyIi5zeXNtbC5JbnN0YW5jZS5GZWF0dXJlVmFsdWVzRW50cnkaSQoSRmVhdHVyZVZhbHVlc0VudHJ5EgsKA2tleRgBIAEoCRIiCgV2YWx1ZRgCIAEoCzITLnN5c21sLkZlYXR1cmVWYWx1ZToCOAFKBAgDEARSBXNsb3RzIoQBCgxGZWF0dXJlVmFsdWUSFAoMZmVhdHVyZV9uYW1lGAEgASgJEhsKBXZhbHVlGAIgASgLMgwuc3lzbWwuVmFsdWUSHAoGdmFsdWVzGAMgAygLMgwuc3lzbWwuVmFsdWUSFAoMbWF0ZXJpYWxpemVkGAQgASgIEg0KBWVycm9yGAUgASgJIjsKEkluc3RhbnRpYXRlUmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhEKCXN5bWJvbF9pZBgCIAEoCSKTAQoTSW5zdGFudGlhdGVSZXNwb25zZRIhCghpbnN0YW5jZRgBIAEoCzIPLnN5c21sLkluc3RhbmNlEg0KBWVycm9yGAIgASgJEiYKC2RpYWdub3N0aWNzGAMgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxIiCglpbnN0YW5jZXMYBCADKAsyDy5zeXNtbC5JbnN0YW5jZSLMAQoURXhlY3V0ZUFjdGlvblJlcXVlc3QSEgoKbW9kZWxfaGFzaBgBIAEoCRIYChBhY3Rpb25fc3ltYm9sX2lkGAIgASgJEjcKBmlucHV0cxgDIAMoCzInLnN5c21sLkV4ZWN1dGVBY3Rpb25SZXF1ZXN0LklucHV0c0VudHJ5EhAKCHNjaGVkdWxlGAQgASgJGjsKC0lucHV0c0VudHJ5EgsKA2tleRgBIAEoCRIbCgV2YWx1ZRgCIAEoCzIMLnN5c21sLlZhbHVlOgI4ASKtAgoVRXhlY3V0ZUFjdGlvblJlc3BvbnNlEjoKB291dHB1dHMYASADKAsyKS5zeXNtbC5FeGVjdXRlQWN0aW9uUmVzcG9uc2UuT3V0cHV0c0VudHJ5Eg0KBWVycm9yGAIgASgJEiYKC2RpYWdub3N0aWNzGAMgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxIgCghvdXRjb21lcxgEIAMoCzIOLnN5c21sLk91dGNvbWUSLQoLZXhwbG9yYXRpb24YBSABKAsyGC5zeXNtbC5FeHBsb3JhdGlvblN0YXR1cxISCgpmaW5hbF90aW1lGAYgASgBGjwKDE91dHB1dHNFbnRyeRILCgNrZXkYASABKAkSGwoFdmFsdWUYAiABKAsyDC5zeXNtbC5WYWx1ZToCOAEibAoTRXhlY3V0ZVN0YXRlUmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEh8KF3N0YXRlX21hY2hpbmVfc3ltYm9sX2lkGAIgASgJEg4KBmV2ZW50cxgDIAMoCRIQCghzY2hlZHVsZRgEIAEoCSLTAgoURXhlY3V0ZVN0YXRlUmVzcG9uc2USFgoOc3RhdGVzX3Zpc2l0ZWQYASADKAkSRAoNZmluYWxfY29udGV4dBgCIAMoCzItLnN5c21sLkV4ZWN1dGVTdGF0ZVJlc3BvbnNlLkZpbmFsQ29udGV4dEVudHJ5Eg0KBWVycm9yGAMgASgJEiYKC2RpYWdub3N0aWNzGAQgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxIgCghvdXRjb21lcxgFIAMoCzIOLnN5c21sLk91dGNvbWUSLQoLZXhwbG9yYXRpb24YBiABKAsyGC5zeXNtbC5FeHBsb3JhdGlvblN0YXR1cxISCgpmaW5hbF90aW1lGAcgASgBGkEKEUZpbmFsQ29udGV4dEVudHJ5EgsKA2tleRgBIAEoCRIbCgV2YWx1ZRgCIAEoCzIMLnN5c21sLlZhbHVlOgI4ASKgAQoOQ29udmVydFJlcXVlc3QSEwoJZmlsZV9wYXRoGAEgASgJSAASEQoHY29udGVudBgCIAEoCUgAEhQKCm1vZGVsX2hhc2gYBiABKAlIABITCgtmcm9tX2Zvcm1hdBgDIAEoCRIRCgl0b19mb3JtYXQYBCABKAkSHgoWdG9sZXJhdGVfc3ludGF4X2Vycm9ycxgFIAEoCEIICgZzb3VyY2UitAEKD0NvbnZlcnRSZXNwb25zZRIPCgdjb250ZW50GAEgASgJEhMKC2Zyb21fZm9ybWF0GAIgASgJEhEKCXRvX2Zvcm1hdBgDIAEoCRINCgVlcnJvchgEIAEoCRImCgtkaWFnbm9zdGljcxgFIAMoCzIRLnN5c21sLkRpYWdub3N0aWMSFAoMZXhwZXJpbWVudGFsGAYgASgIEhsKE2V4cGVyaW1lbnRhbF9ub3RpY2UYByABKAkiUQoRQXBwbHlFZGl0c1JlcXVlc3QSEgoKbW9kZWxfaGFzaBgBIAEoCRIoCgpvcGVyYXRpb25zGAIgAygLMhQuc3lzbWwuRWRpdE9wZXJhdGlvbiLdAQoNRWRpdE9wZXJhdGlvbhIoCglzZXRfdmFsdWUYASABKAsyEy5zeXNtbC5TZXRWYWx1ZUVkaXRIABIjCgZyZW5hbWUYAiABKAsyES5zeXNtbC5SZW5hbWVFZGl0SAASKgoKYWRkX21lbWJlchgDIAEoCzIULnN5c21sLkFkZE1lbWJlckVkaXRIABIjCgZkZWxldGUYBCABKAsyES5zeXNtbC5EZWxldGVFZGl0SAASHwoEbW92ZRgFIAEoCzIPLnN5c21sLk1vdmVFZGl0SABCCwoJb3BlcmF0aW9uIoIBCg1BZGRNZW1iZXJFZGl0Eg0KBW93bmVyGAEgASgJEgwKBGtpbmQYAiABKAkSDAoEbmFtZRgDIAEoCRIMCgR0eXBlGAQgASgJEhQKDG11bHRpcGxpY2l0eRgFIAEoCRINCgV2YWx1ZRgGIAEoCRITCgtzcGVjaWFsaXplcxgHIAMoCSItCgpEZWxldGVFZGl0Eg4KBnRhcmdldBgBIAEoCRIPCgdjYXNjYWRlGAIgASgIIikKCE1vdmVFZGl0Eg4KBnRhcmdldBgBIAEoCRINCgVvd25lchgCIAEoCSItCgxTZXRWYWx1ZUVkaXQSDgoGdGFyZ2V0GAEgASgJEg0KBXZhbHVlGAIgASgJIi4KClJlbmFtZUVkaXQSDgoGdGFyZ2V0GAEgASgJEhAKCG5ld19uYW1lGAIgASgJIsIBChJBcHBseUVkaXRzUmVzcG9uc2USDwoHY29udGVudBgBIAEoCRIjCgdhcHBsaWVkGAIgAygLMhIuc3lzbWwuQXBwbGllZEVkaXQSDQoFZXJyb3IYAyABKAkSIwoHZmFpbHVyZRgEIAEoDjISLnN5c21sLkVkaXRGYWlsdXJlEiYKC2RpYWdub3N0aWNzGAUgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxIaChJyZWZlcnJpbmdfZWxlbWVudHMYBiADKAkiegoLQXBwbGllZEVkaXQSFwoPb3BlcmF0aW9uX2luZGV4GAEgASgFEg4KBnRhcmdldBgCIAEoCRIOCgZvZmZzZXQYAyABKAUSDgoGbGVuZ3RoGAQgASgFEhAKCG9sZF90ZXh0GAUgASgJEhAKCG5ld190ZXh0GAYgASgJIv0CCgpTeW1ib2xJbmZvEgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkSDAoEa2luZBgDIAEoCRIxCghtZXRhZGF0YRgEIAMoCzIfLnN5c21sLlN5bWJvbEluZm8uTWV0YWRhdGFFbnRyeRIRCgljaGlsZF9pZHMYBSADKAkSKAoKYXR0cmlidXRlcxgGIAMoCzIULnN5c21sLkF0dHJpYnV0ZUluZm8SIgoJdHlwZV9pbmZvGAcgASgLMg8uc3lzbWwuVHlwZUluZm8SLQoMbXVsdGlwbGljaXR5GAggASgLMhcuc3lzbWwuTXVsdGlwbGljaXR5SW5mbxIuCg9zcGVjaWFsaXphdGlvbnMYCSADKAsyFS5zeXNtbC5TcGVjaWFsaXphdGlvbhIjCht3aXRoaGVsZF9saWJyYXJ5X2F0dHJpYnV0ZXMYCiABKAUaLwoNTWV0YWRhdGFFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIlgKDlNwZWNpYWxpemF0aW9uEgwKBGtpbmQYASABKAkSEAoIZGVjbGFyZWQYAiABKAkSEQoJdGFyZ2V0X2lkGAMgASgJEhMKC3RhcmdldF9raW5kGAQgASgJIpUBCghUeXBlSW5mbxIQCghkZWNsYXJlZBgBIAEoCRITCgtyZXNvbHZlZF9pZBgCIAEoCRIVCg1yZXNvbHZlZF9raW5kGAMgASgJEhEKCXByaW1pdGl2ZRgEIAEoCRIYChBwcmltaXRpdmVfc291cmNlGAUgASgJEhAKCHF1YW50aXR5GAYgASgIEgwKBHVuaXQYByABKAkiMAoQTXVsdGlwbGljaXR5SW5mbxINCgVsb3dlchgBIAEoCRINCgV1cHBlchgCIAEoCSJWCg1BdHRyaWJ1dGVJbmZvEgwKBG5hbWUYASABKAkSDAoEdHlwZRgCIAEoCRIbCgV2YWx1ZRgDIAEoCzIMLnN5c21sLlZhbHVlEgwKBHVuaXQYBCABKAkiwwUKBVZhbHVlEhMKCWludF92YWx1ZRgBIAEoA0gAEhQKCnJlYWxfdmFsdWUYAiABKAFIABIUCgpib29sX3ZhbHVlGAMgASgISAASFgoMc3RyaW5nX3ZhbHVlGAQgASgJSAASFQoLaW5zdGFuY2VfaWQYBSABKANIABIoCghzZXF1ZW5jZRgGIAEoCzIULnN5c21sLlZhbHVlU2VxdWVuY2VIABIOCgRudWxsGAcgASgJSAASIwoIcXVhbnRpdHkYCCABKAsyDy5zeXNtbC5RdWFudGl0eUgAEioKDGVudW1fbGl0ZXJhbBgJIAEoCzISLnN5c21sLkVudW1MaXRlcmFsSAASDwoFdW5zZXQYCiABKAhIABIhCgdjb21wbGV4GAsgASgLMg4uc3lzbWwuQ29tcGxleEgAEh0KBWFycmF5GAwgASgLMgwuc3lzbWwuQXJyYXlIABIfCgZ2ZWN0b3IYDSABKAsyDS5zeXNtbC5WZWN0b3JIABIwCg92ZWN0b3JfcXVhbnRpdHkYDiABKAsyFS5zeXNtbC5WZWN0b3JRdWFudGl0eUgAEjAKD21lYXN1cmVtZW50X3JlZhgPIAEoCzIVLnN5c21sLk1lYXN1cmVtZW50UmVmSAASEgoIaW5maW5pdHkYECABKAhIABIjCghmdW5jdGlvbhgRIAEoCzIPLnN5c21sLkZ1bmN0aW9uSAASHgoDc2V0GBIgASgLMg8uc3lzbWwuVmFsdWVTZXRIABIwCg90ZW5zb3JfcXVhbnRpdHkYEyABKAsyFS5zeXNtbC5UZW5zb3JRdWFudGl0eUgAEicKCm1ldGFvYmplY3QYFCABKAsyES5zeXNtbC5NZXRhb2JqZWN0SAASKwoMdW5kZXRlcm1pbmVkGBUgASgLMhMuc3lzbWwuVW5kZXRlcm1pbmVkSABCBgoEa2luZCI2CgpNZXRhb2JqZWN0EhIKCmVsZW1lbnRfaWQYASABKAkSFAoMbWV0YWNsYXNzX2lkGAIgASgJIkYKDFVuZGV0ZXJtaW5lZBIOCgZyZWFzb24YASABKAkSJgoFY291bnQYAiABKAsyFy5zeXNtbC5NdWx0aXBsaWNpdHlJbmZvIiwKCEZ1bmN0aW9uEg8KB2NhbGNfaWQYASABKAkSDwoHc2VsZl9pZBgCIAEoAyIqCghWYWx1ZVNldBIeCghlbGVtZW50cxgBIAMoCzIMLnN5c21sLlZhbHVlIkkKDlRlbnNvclF1YW50aXR5EhIKCmRpbWVuc2lvbnMYASADKAMSIwoKY29tcG9uZW50cxgCIAMoCzIPLnN5c21sLlF1YW50aXR5IjsKBUFycmF5EhIKCmRpbWVuc2lvbnMYASADKAMSHgoIZWxlbWVudHMYAiADKAsyDC5zeXNtbC5WYWx1ZSIqCgZWZWN0b3ISIAoKY29tcG9uZW50cxgBIAMoCzIMLnN5c21sLlZhbHVlIjUKDlZlY3RvclF1YW50aXR5EiMKCmNvbXBvbmVudHMYASADKAsyDy5zeXNtbC5RdWFudGl0eSIqCgdDb21wbGV4EgwKBHJlYWwYASABKAESEQoJaW1hZ2luYXJ5GAIgASgBImQKC0VudW1MaXRlcmFsEhIKCmxpdGVyYWxfaWQYASABKAkSFgoOZW51bWVyYXRpb25faWQYAiABKAkSDAoEbmFtZRgDIAEoCRIbCgV2YWx1ZRgEIAEoCzIMLnN5c21sLlZhbHVlIi8KDVZhbHVlU2VxdWVuY2USHgoIZWxlbWVudHMYASADKAsyDC5zeXNtbC5WYWx1ZSJ8CghRdWFudGl0eRIXCg1pbnRfbWFnbml0dWRlGAEgASgDSAASGAoOcmVhbF9tYWduaXR1ZGUYAiABKAFIABIMCgR1bml0GAMgASgJEiIKCXVuaXRfdGVybRgEIAEoCzIPLnN5c21sLlVuaXRUZXJtQgsKCW1hZ25pdHVkZSJTCg5NZWFzdXJlbWVudFJlZhIMCgR1bml0GAEgASgJEiIKCXVuaXRfdGVybRgCIAEoCzIPLnN5c21sLlVuaXRUZXJtEg8KB3VuaXRfaWQYAyABKAkiVAoIVW5pdFRlcm0SEQoJc2NhbGVfbnVtGAEgASgBEhEKCXNjYWxlX2RlbhgCIAEoARIiCgdmYWN0b3JzGAMgAygLMhEuc3lzbWwuVW5pdEZhY3RvciIvCgpVbml0RmFjdG9yEg8KB3VuaXRfaWQYASABKAkSEAoIZXhwb25lbnQYAiABKAEiWAoKRGlhZ25vc3RpYxIQCghzZXZlcml0eRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJEhkKBHNwYW4YAyABKAsyCy5zeXNtbC5TcGFuEgwKBGNvZGUYBCABKAkiXgoEU3BhbhIMCgRmaWxlGAEgASgJEhIKCnN0YXJ0X2xpbmUYAiABKAUSEQoJc3RhcnRfY29sGAMgASgFEhAKCGVuZF9saW5lGAQgASgFEg8KB2VuZF9jb2wYBSABKAUiEwoRU2VydmVySW5mb1JlcXVlc3QiOwoSU2VydmVySW5mb1Jlc3BvbnNlEg8KB3ZlcnNpb24YASABKAkSFAoMY2FwYWJpbGl0aWVzGAIgAygJIlMKDFF1ZXJ5UmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhsKBXF1ZXJ5GAIgASgLMgwuc3lzbWwuUXVlcnkSEgoKb3NsY19xdWVyeRgDIAEoCSI8Cg1RdWVyeVJlc3BvbnNlEisKCGVsZW1lbnRzGAEgAygLMhkuc3lzbWwuUXVlcnlSZXN1bHRFbGVtZW50IkgKBVF1ZXJ5Eg0KBXNjb3BlGAEgAygJEg4KBnNlbGVjdBgCIAMoCRIgCgV3aGVyZRgDIAEoCzIRLnN5c21sLkNvbnN0cmFpbnQifAoKQ29uc3RyYWludBIvCglwcmltaXRpdmUYASABKAsyGi5zeXNtbC5QcmltaXRpdmVDb25zdHJhaW50SAASLwoJY29tcG9zaXRlGAIgASgLMhouc3lzbWwuQ29tcG9zaXRlQ29uc3RyYWludEgAQgwKCmNvbnN0cmFpbnQicwoTUHJpbWl0aXZlQ29uc3RyYWludBIPCgdpbnZlcnNlGAEgASgIEhAKCHByb3BlcnR5GAIgASgJEioKCG9wZXJhdG9yGAMgASgOMhguc3lzbWwuUHJpbWl0aXZlT3BlcmF0b3ISDQoFdmFsdWUYBCADKAkiaAoTQ29tcG9zaXRlQ29uc3RyYWludBIqCghvcGVyYXRvchgBIAEoDjIYLnN5c21sLkNvbXBvc2l0ZU9wZXJhdG9yEiUKCmNvbnN0cmFpbnQYAiADKAsyES5zeXNtbC5Db25zdHJhaW50IqABChJRdWVyeVJlc3VsdEVsZW1lbnQSCgoCaWQYASABKAkSDAoEdHlwZRgCIAEoCRI9Cgpwcm9wZXJ0aWVzGAMgAygLMikuc3lzbWwuUXVlcnlSZXN1bHRFbGVtZW50LlByb3BlcnRpZXNFbnRyeRoxCg9Qcm9wZXJ0aWVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJzCgpTd2VlcFJhbmdlEhEKCXBhcmFtZXRlchgBIAEoCRIbCgVzdGFydBgCIAEoCzIMLnN5c21sLlZhbHVlEhkKA2VuZBgDIAEoCzIMLnN5c21sLlZhbHVlEhoKBHN0ZXAYBCABKAsyDC5zeXNtbC5WYWx1ZSLQAgoPUnVuU3dlZXBSZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkSEQoJc3ltYm9sX2lkGAIgASgJEhkKEXN1YmplY3Rfc3ltYm9sX2lkGAMgASgJEh8KCWFyZ3VtZW50cxgEIAMoCzIMLnN5c21sLlZhbHVlEkMKD25hbWVkX2FyZ3VtZW50cxgFIAMoCzIqLnN5c21sLlJ1blN3ZWVwUmVxdWVzdC5OYW1lZEFyZ3VtZW50c0VudHJ5EiEKBnJhbmdlcxgGIAMoCzIRLnN5c21sLlN3ZWVwUmFuZ2USDwoHc2FtcGxlcxgHIAEoAxIMCgRzZWVkGAggASgEEg4KBmVuZ2luZRgJIAEoCRpDChNOYW1lZEFyZ3VtZW50c0VudHJ5EgsKA2tleRgBIAEoCRIbCgV2YWx1ZRgCIAEoCzIMLnN5c21sLlZhbHVlOgI4ASL0AQoIU3dlZXBSb3cSIQoGaW5wdXRzGAEgAygLMhEuc3lzbWwuQ2FsY091dHB1dBIiCgdvdXRwdXRzGAIgAygLMhEuc3lzbWwuQ2FsY091dHB1dBIgCgh2ZXJkaWN0cxgDIAMoCzIOLnN5c21sLlZlcmRpY3QSFgoOZWxhcHNlZF9taWNyb3MYBCABKAMSDQoFZXJyb3IYBSABKAkSLAoOZmFpbHVyZV9yZWFzb24YBiABKA4yFC5zeXNtbC5GYWlsdXJlUmVhc29uEioKC2V2YWx1YXRpb25zGAcgAygLMhUuc3lzbWwuQ2FzZUV2YWx1YXRpb24irQIKEFJ1blN3ZWVwUmVzcG9uc2USHQoEcm93cxgBIAMoCzIPLnN5c21sLlN3ZWVwUm93EhIKCnBhcmFtZXRlcnMYAiADKAkSDwoHc2FtcGxlZBgDIAEoCBIMCgRzZWVkGAQgASgEEg0KBWVycm9yGAUgASgJEiYKC2RpYWdub3N0aWNzGAYgAygLMhEuc3lzbWwuRGlhZ25vc3RpYxIsCg5mYWlsdXJlX3JlYXNvbhgHIAEoDjIULnN5c21sLkZhaWx1cmVSZWFzb24SIgoJaW5zdGFuY2VzGAggAygLMg8uc3lzbWwuSW5zdGFuY2USDgoGZW5naW5lGAkgASgJEhAKCHN0cmVuZ3RoGAogASgJEhwKBmJvdW5kcxgLIAMoCzIMLnN5c21sLkJvdW5kIm4KF1J1bkRvY3VtZW50UXVlcnlSZXF1ZXN0EhIKCm1vZGVsX2hhc2gYASABKAkSEAoIcXVlcnlfaWQYAiABKAkSLQoIYmluZGluZ3MYAyADKAsyGy5zeXNtbC5Eb2N1bWVudFF1ZXJ5QmluZGluZyJPChREb2N1bWVudFF1ZXJ5QmluZGluZxIRCglwYXJhbWV0ZXIYASABKAkSJAoGdmFsdWVzGAIgAygLMhQuc3lzbWwuRG9jdW1lbnRWYWx1ZSLVAQoNRG9jdW1lbnRWYWx1ZRIUCgplbGVtZW50X2lkGAEgASgJSAASFgoMc3RyaW5nX3ZhbHVlGAIgASgJSAASEwoJaW50X3ZhbHVlGAMgASgDSAASFAoKcmVhbF92YWx1ZRgEIAEoAUgAEhQKCmJvb2xfdmFsdWUYBSABKAhIABISCghpbmZpbml0eRgGIAEoCEgAEiMKCHF1YW50aXR5GAggASgLMg8uc3lzbWwuUXVhbnRpdHlIABIUCgxlbGVtZW50X3R5cGUYByABKAlCBgoEa2luZCIjChNEb2N1bWVudFF1ZXJ5Q29sdW1uEgwKBG5hbWUYASABKAkiOQoRRG9jdW1lbnRRdWVyeUNlbGwSJAoGdmFsdWVzGAEgAygLMhQuc3lzbWwuRG9jdW1lbnRWYWx1ZSJiChBEb2N1bWVudFF1ZXJ5Um93EiUKB2VsZW1lbnQYASABKAsyFC5zeXNtbC5Eb2N1bWVudFZhbHVlEicKBWNlbGxzGAIgAygLMhguc3lzbWwuRG9jdW1lbnRRdWVyeUNlbGwibgoYUnVuRG9jdW1lbnRRdWVyeVJlc3BvbnNlEisKB2NvbHVtbnMYASADKAsyGi5zeXNtbC5Eb2N1bWVudFF1ZXJ5Q29sdW1uEiUKBHJvd3MYAiADKAsyFy5zeXNtbC5Eb2N1bWVudFF1ZXJ5Um93IkAKFVJlbmRlckRvY3VtZW50UmVxdWVzdBISCgptb2RlbF9oYXNoGAEgASgJEhMKC2RvY3VtZW50X2lkGAIgASgJIioKFlJlbmRlckRvY3VtZW50UmVzcG9uc2USEAoIbWFya2Rvd24YASABKAkqkwEKDUZhaWx1cmVSZWFzb24SHgoaRkFJTFVSRV9SRUFTT05fVU5TUEVDSUZJRUQQABIdChlGQUlMVVJFX1JFQVNPTl9FVkFMVUFUSU9OEAESHQoZRkFJTFVSRV9SRUFTT05fV1JPTkdfS0lORBACEiQKIEZBSUxVUkVfUkVBU09OX0FNQklHVU9VU19TVUJKRUNUEAMq5QQKC0VkaXRGYWlsdXJlEhwKGEVESVRfRkFJTFVSRV9VTlNQRUNJRklFRBAAEh4KGkVESVRfRkFJTFVSRV9OT19PUEVSQVRJT05TEAESHwobRURJVF9GQUlMVVJFX1VOS05PV05fVEFSR0VUEAISIQodRURJVF9GQUlMVVJFX0FNQklHVU9VU19UQVJHRVQQAxIbChdFRElUX0ZBSUxVUkVfTk9UX1ZBTFVFRBAEEh4KGkVESVRfRkFJTFVSRV9JTlZBTElEX1ZBTFVFEAUSHQoZRURJVF9GQUlMVVJFX0lOVkFMSURfTkFNRRAGEhoKFkVESVRfRkFJTFVSRV9OT1RfTkFNRUQQBxIiCh5FRElUX0ZBSUxVUkVfUkVOQU1FX1JFRkVSRU5DRUQQCBIiCh5FRElUX0ZBSUxVUkVfT1ZFUkxBUFBJTkdfRURJVFMQCRIfChtFRElUX0ZBSUxVUkVfUkVTVUxUX0lOVkFMSUQQChIeChpFRElUX0ZBSUxVUkVfT1dORVJfVU5LTk9XThALEiQKIEVESVRfRkFJTFVSRV9PV05FUl9OT1RfTkFNRVNQQUNFEAwSHQoZRURJVF9GQUlMVVJFX0lMTEVHQUxfS0lORBANEiIKHkVESVRfRkFJTFVSRV9NRU1CRVJfTkFNRV9UQUtFThAOEiIKHkVESVRfRkFJTFVSRV9ERUxFVEVfUkVGRVJFTkNFRBAPEiQKIEVESVRfRkFJTFVSRV9PV05FUl9JTlNJREVfVEFSR0VUEBASIAocRURJVF9GQUlMVVJFX01PVkVfUkVGRVJFTkNFRBARKpIBChFQcmltaXRpdmVPcGVyYXRvchIiCh5QUklNSVRJVkVfT1BFUkFUT1JfVU5TUEVDSUZJRUQQABIcChhQUklNSVRJVkVfT1BFUkFUT1JfRVFVQUwQARIeChpQUklNSVRJVkVfT1BFUkFUT1JfR1JFQVRFUhACEhsKF1BSSU1JVElWRV9PUEVSQVRPUl9MRVNTEAMqbgoRQ29tcG9zaXRlT3BlcmF0b3ISIgoeQ09NUE9TSVRFX09QRVJBVE9SX1VOU1BFQ0lGSUVEEAASGgoWQ09NUE9TSVRFX09QRVJBVE9SX0FORBABEhkKFUNPTVBPU0lURV9PUEVSQVRPUl9PUhACMuoLCgxTeXNNTFNlcnZpY2USRAoNR2V0U2VydmVySW5mbxIYLnN5c21sLlNlcnZlckluZm9SZXF1ZXN0Ghkuc3lzbWwuU2VydmVySW5mb1Jlc3BvbnNlEj4KCVBhcnNlRmlsZRIXLnN5c21sLlBhcnNlRmlsZVJlcXVlc3QaGC5zeXNtbC5QYXJzZUZpbGVSZXNwb25zZRJHCgxQYXJzZVNvdXJjZXMSGi5zeXNtbC5QYXJzZVNvdXJjZXNSZXF1ZXN0Ghsuc3lzbWwuUGFyc2VTb3VyY2VzUmVzcG9uc2USOwoJR2V0U3ltYm9sEhcuc3lzbWwuR2V0U3ltYm9sUmVxdWVzdBoVLnN5c21sLlN5bWJvbFJlc3BvbnNlEkcKDkdldERpYWdub3N0aWNzEhkuc3lzbWwuRGlhZ25vc3RpY3NSZXF1ZXN0Ghouc3lzbWwuRGlhZ25vc3RpY3NSZXNwb25zZRI7CghFdmFsdWF0ZRIWLnN5c21sLkV2YWx1YXRlUmVxdWVzdBoXLnN5c21sLkV2YWx1YXRlUmVzcG9uc2USRAoLSW5zdGFudGlhdGUSGS5zeXNtbC5JbnN0YW50aWF0ZVJlcXVlc3QaGi5zeXNtbC5JbnN0YW50aWF0ZVJlc3BvbnNlEkoKDUV4ZWN1dGVBY3Rpb24SGy5zeXNtbC5FeGVjdXRlQWN0aW9uUmVxdWVzdBocLnN5c21sLkV4ZWN1dGVBY3Rpb25SZXNwb25zZRJHCgxFeGVjdXRlU3RhdGUSGi5zeXNtbC5FeGVjdXRlU3RhdGVSZXF1ZXN0Ghsuc3lzbWwuRXhlY3V0ZVN0YXRlUmVzcG9uc2USOAoHQ29udmVydBIVLnN5c21sLkNvbnZlcnRSZXF1ZXN0GhYuc3lzbWwuQ29udmVydFJlc3BvbnNlEkEKCkFwcGx5RWRpdHMSGC5zeXNtbC5BcHBseUVkaXRzUmVxdWVzdBoZLnN5c21sLkFwcGx5RWRpdHNSZXNwb25zZRJTChBWZXJpZnlDb25zdHJhaW50Eh4uc3lzbWwuVmVyaWZ5Q29uc3RyYWludFJlcXVlc3QaHy5zeXNtbC5WZXJpZnlDb25zdHJhaW50UmVzcG9uc2USVgoRVmVyaWZ5UmVxdWlyZW1lbnQSHy5zeXNtbC5WZXJpZnlSZXF1aXJlbWVudFJlcXVlc3QaIC5zeXNtbC5WZXJpZnlSZXF1aXJlbWVudFJlc3BvbnNlElkKElZlcmlmeVNhdGlzZmFjdGlvbhIgLnN5c21sLlZlcmlmeVNhdGlzZmFjdGlvblJlcXVlc3QaIS5zeXNtbC5WZXJpZnlTYXRpc2ZhY3Rpb25SZXNwb25zZRJHCgxFdmFsdWF0ZUNhbGMSGi5zeXNtbC5FdmFsdWF0ZUNhbGNSZXF1ZXN0Ghsuc3lzbWwuRXZhbHVhdGVDYWxjUmVzcG9uc2USRAoLUnVuQW5hbHlzaXMSGS5zeXNtbC5SdW5BbmFseXNpc1JlcXVlc3QaGi5zeXNtbC5SdW5BbmFseXNpc1Jlc3BvbnNlEjsKCFJ1blN3ZWVwEhYuc3lzbWwuUnVuU3dlZXBSZXF1ZXN0Ghcuc3lzbWwuUnVuU3dlZXBSZXNwb25zZRJECgtMaXN0RW5naW5lcxIZLnN5c21sLkxpc3RFbmdpbmVzUmVxdWVzdBoaLnN5c21sLkxpc3RFbmdpbmVzUmVzcG9uc2USMgoFUXVlcnkSEy5zeXNtbC5RdWVyeVJlcXVlc3QaFC5zeXNtbC5RdWVyeVJlc3BvbnNlElMKEFJ1bkRvY3VtZW50UXVlcnkSHi5zeXNtbC5SdW5Eb2N1bWVudFF1ZXJ5UmVxdWVzdBofLnN5c21sLlJ1bkRvY3VtZW50UXVlcnlSZXNwb25zZRJNCg5SZW5kZXJEb2N1bWVudBIcLnN5c21sLlJlbmRlckRvY3VtZW50UmVxdWVzdBodLnN5c21sLlJlbmRlckRvY3VtZW50UmVzcG9uc2VCKlooZ2l0aHViLmNvbS9PcGVuLU1CRUUvT3BlblN5c01ML2FwaS9wcm90b2IGcHJvdG8z");
-
-/**
- * Verdict is one verification's answer: whether the condition held, and, when
- * it did not, which condition the model answered false about.
- *
- * @generated from message sysml.Verdict
- */
-export type Verdict = Message<"sysml.Verdict"> & {
-  /**
-   * What was verified: "constraint", "requirement" or "satisfy"; for a check
-   * an analysis case run made, "objective" or "assertion".
-   *
-   * @generated from field: string kind = 1;
-   */
-  kind: string;
-
-  /**
-   * FQN of the element verified; empty for an anonymous satisfy assertion or
-   * an anonymous assertion in a case body.
-   *
-   * @generated from field: string element_id = 2;
-   */
-  elementId: string;
-
-  /**
-   * The element as a reader names it: its FQN, or, for an anonymous satisfy
-   * assertion, the assertion as written ("satisfy Range by cruise"); for an
-   * objective or assertion of a case, its name or its condition as written.
-   *
-   * @generated from field: string element = 3;
-   */
-  element: string;
-
-  /**
-   * Whether the condition holds. False with an empty `error` is the model's own
-   * answer; false with an `error` is no answer at all.
-   *
-   * @generated from field: bool holds = 4;
-   */
-  holds: boolean;
-
-  /**
-   * The condition that evaluated to false, as written, when the runtime names
-   * one. Empty when the verdict holds or the condition is not named.
-   *
-   * @generated from field: string condition = 5;
-   */
-  condition: string;
-
-  /**
-   * Instance the verdict is about, 0 when it is about declared values alone.
-   * Its feature values are in `instances`, so a client can read what made it fail.
-   *
-   * @generated from field: int64 instance_id = 6;
-   */
-  instanceId: bigint;
-
-  /**
-   * Type of `instance_id`, as an FQN, for naming the object in a report.
-   *
-   * @generated from field: string instance_type_id = 7;
-   */
-  instanceTypeId: string;
-
-  /**
-   * Set when evaluation failed rather than the model answering false: unbound
-   * features, incommensurable units, an exhausted step budget. `holds` is then
-   * false but is not a verdict.
-   *
-   * @generated from field: string error = 8;
-   */
-  error: string;
-
-  /**
-   * What kind of failure `error` reports. WRONG_KIND says the symbol named
-   * declares something else, which is a wrong request rather than an undecided
-   * verdict about the model.
-   *
-   * @generated from field: sysml.FailureReason failure_reason = 9;
-   */
-  failureReason: FailureReason;
-
-  /**
-   * FQN of the requirement a "satisfy" verdict asserts satisfied, which is what
-   * associates it with the verification_verdicts reported for that requirement.
-   * Empty for every other kind, and for a requirement no FQN names.
-   *
-   * @generated from field: string requirement_id = 10;
-   */
-  requirementId: string;
-
-  /**
-   * The engine whose answer this verdict is, as ListEngines names it; empty
-   * for a verdict decided before any engine was asked. Reported as the
-   * "engines" capability, with `strength` and `bounds`.
-   *
-   * @generated from field: string engine = 11;
-   */
-  engine: string;
-
-  /**
-   * The strength of the evidence behind the verdict: "observed" for one run,
-   * "witnessed" for a replayed counterexample, "bounded" for an exhaustive
-   * search within the bounds, "proved" for every case, "not covered" when no
-   * engine decided it.
-   *
-   * @generated from field: string strength = 12;
-   */
-  strength: string;
-
-  /**
-   * The bounds the engine ran under, each marked when it stopped the run.
-   *
-   * @generated from field: repeated sysml.Bound bounds = 13;
-   */
-  bounds: Bound[];
-};
-
-/**
- * Describes the message sysml.Verdict.
- * Use `create(VerdictSchema)` to create a new message.
- */
-export const VerdictSchema: GenMessage<Verdict> = /*@__PURE__*/
-  messageDesc(file_sysml, 0);
-
-/**
- * Bound is one limit an engine ran under, as its plan names it: a count, or
- * milliseconds for a time, and whether the run stopped at it.
- *
- * @generated from message sysml.Bound
- */
-export type Bound = Message<"sysml.Bound"> & {
-  /**
-   * The bound's name as the budget spells it: "runs", "depth", "steps",
-   * "elements", "solver", "configurations".
-   *
-   * @generated from field: string name = 1;
-   */
-  name: string;
-
-  /**
-   * The value the run was held to.
-   *
-   * @generated from field: int64 limit = 2;
-   */
-  limit: bigint;
-
-  /**
-   * True when the run stopped at the limit rather than finishing within it,
-   * which is what lowers a verdict's strength.
-   *
-   * @generated from field: bool reached = 3;
-   */
-  reached: boolean;
-};
-
-/**
- * Describes the message sysml.Bound.
- * Use `create(BoundSchema)` to create a new message.
- */
-export const BoundSchema: GenMessage<Bound> = /*@__PURE__*/
-  messageDesc(file_sysml, 1);
-
-/**
- * VerifyConstraintRequest asks whether a constraint holds, as %constraint does.
- *
- * @generated from message sysml.VerifyConstraintRequest
- */
-export type VerifyConstraintRequest = Message<"sysml.VerifyConstraintRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * FQN of the constraint definition or usage to evaluate.
-   *
-   * @generated from field: string symbol_id = 2;
-   */
-  symbolId: string;
-
-  /**
-   * Optional FQN of a part/usage to instantiate and evaluate the constraint
-   * against, so the verdict is about concrete values rather than declared
-   * defaults. The prompt evaluates against the object a %instantiate created;
-   * a call carries no session, so it names the subject instead.
-   *
-   * @generated from field: string subject_symbol_id = 3;
-   */
-  subjectSymbolId: string;
-
-  /**
-   * The engine the question is put to, as ListEngines names it, "auto" for the
-   * strongest covering one, or "all" for every covering one. Unset is "auto".
-   * An unknown name is INVALID_ARGUMENT. Reported as the "engines" capability.
-   *
-   * @generated from field: string engine = 4;
-   */
-  engine: string;
-};
-
-/**
- * Describes the message sysml.VerifyConstraintRequest.
- * Use `create(VerifyConstraintRequestSchema)` to create a new message.
- */
-export const VerifyConstraintRequestSchema: GenMessage<VerifyConstraintRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 2);
-
-/**
- * VerifyConstraintResponse carries the verdict; its engine, strength and bounds
- * are on the verdict.
- *
- * @generated from message sysml.VerifyConstraintResponse
- */
-export type VerifyConstraintResponse = Message<"sysml.VerifyConstraintResponse"> & {
-  /**
-   * @generated from field: sysml.Verdict verdict = 1;
-   */
-  verdict?: Verdict | undefined;
-
-  /**
-   * Instances reachable from the verdict's subject, including it, so its feature
-   * values need no follow-up RPC. Empty when the verdict names no instance.
-   *
-   * @generated from field: repeated sysml.Instance instances = 2;
-   */
-  instances: Instance[];
-
-  /**
-   * Set when the request could not be answered at all — an unknown symbol, a
-   * subject that could not be built. `verdict` is then unset.
-   *
-   * @generated from field: string error = 3;
-   */
-  error: string;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 4;
-   */
-  diagnostics: Diagnostic[];
-};
-
-/**
- * Describes the message sysml.VerifyConstraintResponse.
- * Use `create(VerifyConstraintResponseSchema)` to create a new message.
- */
-export const VerifyConstraintResponseSchema: GenMessage<VerifyConstraintResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 3);
-
-/**
- * VerifyRequirementRequest asks whether a requirement is satisfied, as
- * %requirement does.
- *
- * @generated from message sysml.VerifyRequirementRequest
- */
-export type VerifyRequirementRequest = Message<"sysml.VerifyRequirementRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * FQN of the requirement definition or usage to evaluate.
-   *
-   * @generated from field: string symbol_id = 2;
-   */
-  symbolId: string;
-
-  /**
-   * Optional FQN of a part/usage to instantiate and evaluate against.
-   *
-   * @generated from field: string subject_symbol_id = 3;
-   */
-  subjectSymbolId: string;
-
-  /**
-   * The engine the question is put to; see VerifyConstraintRequest.engine.
-   *
-   * @generated from field: string engine = 4;
-   */
-  engine: string;
-};
-
-/**
- * Describes the message sysml.VerifyRequirementRequest.
- * Use `create(VerifyRequirementRequestSchema)` to create a new message.
- */
-export const VerifyRequirementRequestSchema: GenMessage<VerifyRequirementRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 4);
-
-/**
- * VerificationVerdict is what the body of a verification case answered when it
- * ran: the VerdictKind its return bound, which is a separate answer from whether
- * a requirement is satisfied. Reported as the "verification_verdicts" capability.
- *
- * @generated from message sysml.VerificationVerdict
- */
-export type VerificationVerdict = Message<"sysml.VerificationVerdict"> & {
-  /**
-   * FQN of the verification case definition or usage that ran.
-   *
-   * @generated from field: string case_id = 1;
-   */
-  caseId: string;
-
-  /**
-   * The VerdictKind the body produced: "pass", "fail", "inconclusive" or "error".
-   *
-   * @generated from field: string kind = 2;
-   */
-  kind: string;
-
-  /**
-   * Why the body decided nothing, or the text of the error that stopped its run.
-   * Empty for a pass or a fail, which are the case's own answers.
-   *
-   * @generated from field: string detail = 3;
-   */
-  detail: string;
-
-  /**
-   * Set for the verdict of a subcase the case performed, which the library
-   * states no roll-up for and which is therefore reported on its own.
-   *
-   * @generated from field: bool subcase = 4;
-   */
-  subcase: boolean;
-
-  /**
-   * FQN of the requirement this verdict was reported for: the one the case's
-   * objective verifies. Empty when the case was run for itself rather than for
-   * a requirement, or when that requirement can be named by no FQN.
-   *
-   * @generated from field: string requirement_id = 5;
-   */
-  requirementId: string;
-};
-
-/**
- * Describes the message sysml.VerificationVerdict.
- * Use `create(VerificationVerdictSchema)` to create a new message.
- */
-export const VerificationVerdictSchema: GenMessage<VerificationVerdict> = /*@__PURE__*/
-  messageDesc(file_sysml, 5);
-
-/**
- * VerifyRequirementResponse carries the verdict.
- *
- * @generated from message sysml.VerifyRequirementResponse
- */
-export type VerifyRequirementResponse = Message<"sysml.VerifyRequirementResponse"> & {
-  /**
-   * @generated from field: sysml.Verdict verdict = 1;
-   */
-  verdict?: Verdict | undefined;
-
-  /**
-   * @generated from field: repeated sysml.Instance instances = 2;
-   */
-  instances: Instance[];
-
-  /**
-   * @generated from field: string error = 3;
-   */
-  error: string;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 4;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * What the body of every verification case verifying this requirement
-   * answered, beside the satisfaction verdict rather than instead of it.
-   *
-   * @generated from field: repeated sysml.VerificationVerdict verification_verdicts = 5;
-   */
-  verificationVerdicts: VerificationVerdict[];
-};
-
-/**
- * Describes the message sysml.VerifyRequirementResponse.
- * Use `create(VerifyRequirementResponseSchema)` to create a new message.
- */
-export const VerifyRequirementResponseSchema: GenMessage<VerifyRequirementResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 6);
-
-/**
- * VerifySatisfactionRequest asks whether the satisfaction assertions a model
- * states hold, as %satisfy does. Each is evaluated against an object of its
- * subject, built for the call.
- *
- * @generated from message sysml.VerifySatisfactionRequest
- */
-export type VerifySatisfactionRequest = Message<"sysml.VerifySatisfactionRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * Optional FQN limiting evaluation: the assertions stated within that
-   * element, or that element itself when it is a named satisfy assertion. Empty
-   * evaluates every assertion the model states.
-   *
-   * @generated from field: string symbol_id = 2;
-   */
-  symbolId: string;
-
-  /**
-   * The engine every assertion is put to; see VerifyConstraintRequest.engine.
-   *
-   * @generated from field: string engine = 3;
-   */
-  engine: string;
-};
-
-/**
- * Describes the message sysml.VerifySatisfactionRequest.
- * Use `create(VerifySatisfactionRequestSchema)` to create a new message.
- */
-export const VerifySatisfactionRequestSchema: GenMessage<VerifySatisfactionRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 7);
-
-/**
- * VerifySatisfactionResponse carries one verdict per assertion evaluated, in
- * declaration order. No assertion at all is an empty list, not an error.
- *
- * @generated from message sysml.VerifySatisfactionResponse
- */
-export type VerifySatisfactionResponse = Message<"sysml.VerifySatisfactionResponse"> & {
-  /**
-   * @generated from field: repeated sysml.Verdict verdicts = 1;
-   */
-  verdicts: Verdict[];
-
-  /**
-   * @generated from field: repeated sysml.Instance instances = 2;
-   */
-  instances: Instance[];
-
-  /**
-   * @generated from field: string error = 3;
-   */
-  error: string;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 4;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * What kind of failure `error` reports.
-   *
-   * @generated from field: sysml.FailureReason failure_reason = 5;
-   */
-  failureReason: FailureReason;
-
-  /**
-   * What the body of every verification case verifying a requirement asserted
-   * as satisfied answered, in the order the assertions were evaluated. Each
-   * names the requirement it was reported for, which the verdict of an
-   * assertion of that requirement carries as its own requirement_id.
-   *
-   * @generated from field: repeated sysml.VerificationVerdict verification_verdicts = 6;
-   */
-  verificationVerdicts: VerificationVerdict[];
-};
-
-/**
- * Describes the message sysml.VerifySatisfactionResponse.
- * Use `create(VerifySatisfactionResponseSchema)` to create a new message.
- */
-export const VerifySatisfactionResponseSchema: GenMessage<VerifySatisfactionResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 8);
-
-/**
- * EvaluateCalcRequest invokes a calculation, as %calc does. Arguments are bound
- * positionally; a calc usage named with no arguments binds its inputs from its
- * own members and reports every output feature it computes (SysML 7.17).
- *
- * @generated from message sysml.EvaluateCalcRequest
- */
-export type EvaluateCalcRequest = Message<"sysml.EvaluateCalcRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * FQN of the calc definition or usage.
-   *
-   * @generated from field: string symbol_id = 2;
-   */
-  symbolId: string;
-
-  /**
-   * Positional arguments. The notation writes named arguments inside an
-   * invocation's parentheses, which this request does not carry.
-   *
-   * @generated from field: repeated sysml.Value arguments = 3;
-   */
-  arguments: Value[];
-
-  /**
-   * The engine the calculation is put to; see VerifyConstraintRequest.engine.
-   *
-   * @generated from field: string engine = 4;
-   */
-  engine: string;
-};
-
-/**
- * Describes the message sysml.EvaluateCalcRequest.
- * Use `create(EvaluateCalcRequestSchema)` to create a new message.
- */
-export const EvaluateCalcRequestSchema: GenMessage<EvaluateCalcRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 9);
-
-/**
- * EvaluateCalcResponse carries what the calculation computed.
- *
- * @generated from message sysml.EvaluateCalcResponse
- */
-export type EvaluateCalcResponse = Message<"sysml.EvaluateCalcResponse"> & {
-  /**
-   * The value an invocation returned; unset when `outputs` carries the answer.
-   *
-   * @generated from field: sysml.Value result = 1;
-   */
-  result?: Value | undefined;
-
-  /**
-   * Output features of a calc usage evaluated from its own members, in
-   * declaration order. Empty for an invocation with arguments.
-   *
-   * @generated from field: repeated sysml.CalcOutput outputs = 2;
-   */
-  outputs: CalcOutput[];
-
-  /**
-   * @generated from field: string error = 3;
-   */
-  error: string;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 4;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * What kind of failure `error` reports.
-   *
-   * @generated from field: sysml.FailureReason failure_reason = 5;
-   */
-  failureReason: FailureReason;
-
-  /**
-   * The engine that computed the answer, the strength of its evidence and the
-   * bounds it ran under, as Verdict carries them. Empty when the request was
-   * answered before any engine was asked. Reported as the "engines" capability.
-   *
-   * @generated from field: string engine = 6;
-   */
-  engine: string;
-
-  /**
-   * @generated from field: string strength = 7;
-   */
-  strength: string;
-
-  /**
-   * @generated from field: repeated sysml.Bound bounds = 8;
-   */
-  bounds: Bound[];
-};
-
-/**
- * Describes the message sysml.EvaluateCalcResponse.
- * Use `create(EvaluateCalcResponseSchema)` to create a new message.
- */
-export const EvaluateCalcResponseSchema: GenMessage<EvaluateCalcResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 10);
-
-/**
- * CalcOutput is one output feature a calc usage computed.
- *
- * @generated from message sysml.CalcOutput
- */
-export type CalcOutput = Message<"sysml.CalcOutput"> & {
-  /**
-   * @generated from field: string name = 1;
-   */
-  name: string;
-
-  /**
-   * @generated from field: sysml.Value value = 2;
-   */
-  value?: Value | undefined;
-};
-
-/**
- * Describes the message sysml.CalcOutput.
- * Use `create(CalcOutputSchema)` to create a new message.
- */
-export const CalcOutputSchema: GenMessage<CalcOutput> = /*@__PURE__*/
-  messageDesc(file_sysml, 11);
-
-/**
- * CaseEvaluation is one application an analysis case made of one of its own
- * calcs held as a function value: a trade study's evaluationFunction applied
- * to one alternative. Reported as the "case_evaluations" capability.
- *
- * @generated from message sysml.CaseEvaluation
- */
-export type CaseEvaluation = Message<"sysml.CaseEvaluation"> & {
-  /**
-   * FQN of the calc applied.
-   *
-   * @generated from field: string function_id = 1;
-   */
-  functionId: string;
-
-  /**
-   * What it was applied to, in parameter order; an alternative is an instance_id.
-   *
-   * @generated from field: repeated sysml.Value arguments = 2;
-   */
-  arguments: Value[];
-
-  /**
-   * What it computed; unset when `error` says why it computed nothing.
-   *
-   * @generated from field: sysml.Value result = 3;
-   */
-  result?: Value | undefined;
-
-  /**
-   * @generated from field: string error = 4;
-   */
-  error: string;
-
-  /**
-   * Set on the evaluation whose argument `selectOne` picked and the case
-   * returned: the alternative a trade study selected. A result that merely
-   * equals an argument selects nothing.
-   *
-   * @generated from field: bool selected = 5;
-   */
-  selected: boolean;
-
-  /**
-   * Set on an evaluation computing what the selected one did without being
-   * selected: an alternative `selectOne` passed over for an earlier one
-   * evaluating alike.
-   *
-   * @generated from field: bool tied = 6;
-   */
-  tied: boolean;
-};
-
-/**
- * Describes the message sysml.CaseEvaluation.
- * Use `create(CaseEvaluationSchema)` to create a new message.
- */
-export const CaseEvaluationSchema: GenMessage<CaseEvaluation> = /*@__PURE__*/
-  messageDesc(file_sysml, 12);
-
-/**
- * RunAnalysisRequest runs an analysis case, as %analysis does: its subject and
- * input parameters are bound from the request and from the case's own
- * declarations, its body runs, and every output it declares is reported with
- * the verdict of its objective and of each assertion in its body (SysML 7.22).
- *
- * @generated from message sysml.RunAnalysisRequest
- */
-export type RunAnalysisRequest = Message<"sysml.RunAnalysisRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * FQN of the analysis case definition or usage.
-   *
-   * @generated from field: string symbol_id = 2;
-   */
-  symbolId: string;
-
-  /**
-   * Optional FQN of a part/usage to instantiate as the case's subject. Empty
-   * leaves the case's own `subject s = ...` binding to supply it; a case that
-   * binds none and is given none fails to run.
-   *
-   * @generated from field: string subject_symbol_id = 3;
-   */
-  subjectSymbolId: string;
-
-  /**
-   * Positional arguments for the case's input parameters, in declaration order;
-   * the subject is never among them.
-   *
-   * @generated from field: repeated sysml.Value arguments = 4;
-   */
-  arguments: Value[];
-
-  /**
-   * Arguments bound to input parameters by name.
-   *
-   * @generated from field: map<string, sysml.Value> named_arguments = 5;
-   */
-  namedArguments: { [key: string]: Value };
-
-  /**
-   * Scheduling policy the run resolves its choice points under: "declared",
-   * "reverse", "seed:<n>" or "explore[:runs=<n>,depth=<d>]". Empty is the
-   * default, "reverse"; any other spelling is INVALID_ARGUMENT. Under explore
-   * the response answers with `outcomes` and `exploration` in place of one run's
-   * outputs, verdicts, instances and error (see ExecuteActionResponse).
-   *
-   * @generated from field: string schedule = 6;
-   */
-  schedule: string;
-
-  /**
-   * The engine the case is put to; see VerifyConstraintRequest.engine. "explore"
-   * asks what "explore" as the schedule asks, and the response answers alike.
-   *
-   * @generated from field: string engine = 7;
-   */
-  engine: string;
-};
-
-/**
- * Describes the message sysml.RunAnalysisRequest.
- * Use `create(RunAnalysisRequestSchema)` to create a new message.
- */
-export const RunAnalysisRequestSchema: GenMessage<RunAnalysisRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 13);
-
-/**
- * RunAnalysisResponse carries what the case computed and decided.
- *
- * @generated from message sysml.RunAnalysisResponse
- */
-export type RunAnalysisResponse = Message<"sysml.RunAnalysisResponse"> & {
-  /**
-   * The case's out and return parameters in declaration order; a value the
-   * body returned into an unnamed result is named "result".
-   *
-   * @generated from field: repeated sysml.CalcOutput outputs = 1;
-   */
-  outputs: CalcOutput[];
-
-  /**
-   * What the case's objective and each assertion in its body decided, the
-   * objective first: kind "objective" or "assertion", holds for satisfied, the
-   * violated condition for not satisfied, and an error for undecided.
-   *
-   * @generated from field: repeated sysml.Verdict verdicts = 2;
-   */
-  verdicts: Verdict[];
-
-  /**
-   * Instances reachable from the subject the case ran on, including it, so its
-   * feature values need no follow-up RPC. Empty when the run named no object.
-   *
-   * @generated from field: repeated sysml.Instance instances = 3;
-   */
-  instances: Instance[];
-
-  /**
-   * Set when the case could not be run — an unknown symbol, a subject that
-   * could not be built or bound, an input with no value, a failed step. The
-   * outputs computed and the evaluations made before the failure are still
-   * reported beside it, each verdict undecided.
-   *
-   * @generated from field: string error = 4;
-   */
-  error: string;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 5;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * What kind of failure `error` reports.
-   *
-   * @generated from field: sysml.FailureReason failure_reason = 6;
-   */
-  failureReason: FailureReason;
-
-  /**
-   * What the body answered when the case run was a verification case: its own
-   * verdict first, then the verdict of each subcase it performed.
-   *
-   * @generated from field: repeated sysml.VerificationVerdict verification_verdicts = 7;
-   */
-  verificationVerdicts: VerificationVerdict[];
-
-  /**
-   * Set only under an explore schedule: every distinct outcome reached, in
-   * canonical order, and how the exploration ended. An outcome's outputs carry
-   * the case's outputs and, named "objective <name>", "assertion <name>" and
-   * "verdict <case>", its verdicts as strings; `outputs`, `verdicts`, `instances`
-   * and `error` are then empty, a failed run being an outcome of its own.
-   *
-   * @generated from field: repeated sysml.Outcome outcomes = 8;
-   */
-  outcomes: Outcome[];
-
-  /**
-   * @generated from field: sysml.ExplorationStatus exploration = 9;
-   */
-  exploration?: ExplorationStatus | undefined;
-
-  /**
-   * Each application the run made of one of the case's own calcs as a function
-   * value, in the order first made: a trade study's evaluation of each
-   * alternative in subject order, the selected one marked.
-   *
-   * @generated from field: repeated sysml.CaseEvaluation evaluations = 10;
-   */
-  evaluations: CaseEvaluation[];
-
-  /**
-   * The engine that answered, the strength of its evidence and the bounds it ran
-   * under, as Verdict carries them; each verdict in `verdicts` carries the same.
-   * Empty when the request was answered before any engine was asked. Reported
-   * as the "engines" capability.
-   *
-   * @generated from field: string engine = 11;
-   */
-  engine: string;
-
-  /**
-   * @generated from field: string strength = 12;
-   */
-  strength: string;
-
-  /**
-   * @generated from field: repeated sysml.Bound bounds = 13;
-   */
-  bounds: Bound[];
-};
-
-/**
- * Describes the message sysml.RunAnalysisResponse.
- * Use `create(RunAnalysisResponseSchema)` to create a new message.
- */
-export const RunAnalysisResponseSchema: GenMessage<RunAnalysisResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 14);
-
-/**
- * Outcome is one distinct outcome an exploration reached: the observables a
- * conformance case compares, how many linearizations reached it, and the choice
- * sequence of one run that did. Two runs agreeing on their observables are one
- * outcome.
- *
- * @generated from message sysml.Outcome
- */
-export type Outcome = Message<"sysml.Outcome"> & {
-  /**
-   * The values the run ended with: an action's outputs, a state machine's final
-   * context, or an analysis case's outputs and verdicts. Empty for a failed run.
-   *
-   * @generated from field: map<string, sysml.Value> outputs = 1;
-   */
-  outputs: { [key: string]: Value };
-
-  /**
-   * The state a machine rests in and the states it entered, in order; both
-   * empty for an action or a case.
-   *
-   * @generated from field: string final_state = 2;
-   */
-  finalState: string;
-
-  /**
-   * @generated from field: repeated string states_visited = 3;
-   */
-  statesVisited: string[];
-
-  /**
-   * What the runs reaching this outcome failed with; empty for one they completed.
-   *
-   * @generated from field: string error = 4;
-   */
-  error: string;
-
-  /**
-   * How many linearizations within the budget reached this outcome.
-   *
-   * @generated from field: int32 linearizations = 5;
-   */
-  linearizations: number;
-
-  /**
-   * One run's choice sequence in run order, one entry per choice point it
-   * resolved, each spelling the alternatives and the one taken. Empty when the
-   * run faced no choice point.
-   *
-   * @generated from field: repeated string witness = 6;
-   */
-  witness: string[];
-
-  /**
-   * What the witness run noted about itself: its choice points and the guards
-   * it could not evaluate, as RunAnalysisResponse.diagnostics reports them for
-   * one run.
-   *
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 7;
-   */
-  diagnostics: Diagnostic[];
-};
-
-/**
- * Describes the message sysml.Outcome.
- * Use `create(OutcomeSchema)` to create a new message.
- */
-export const OutcomeSchema: GenMessage<Outcome> = /*@__PURE__*/
-  messageDesc(file_sysml, 15);
-
-/**
- * ExplorationStatus is how an exploration ended: whether every linearization
- * within the budget was run, and which budget stopped it when not.
- *
- * @generated from message sysml.ExplorationStatus
- */
-export type ExplorationStatus = Message<"sysml.ExplorationStatus"> & {
-  /**
-   * True when every linearization was run, so `outcomes` is the whole set.
-   *
-   * @generated from field: bool complete = 1;
-   */
-  complete: boolean;
-
-  /**
-   * How many runs were made.
-   *
-   * @generated from field: int32 runs = 2;
-   */
-  runs: number;
-
-  /**
-   * The budgets hit, "runs" before "depth"; empty when complete.
-   *
-   * @generated from field: repeated string budgets_hit = 3;
-   */
-  budgetsHit: string[];
-
-  /**
-   * The budget the exploration ran under: runs it may make, and choice points
-   * one run may resolve before the rest take their first alternative.
-   *
-   * @generated from field: int32 runs_budget = 4;
-   */
-  runsBudget: number;
-
-  /**
-   * @generated from field: int32 depth_budget = 5;
-   */
-  depthBudget: number;
-};
-
-/**
- * Describes the message sysml.ExplorationStatus.
- * Use `create(ExplorationStatusSchema)` to create a new message.
- */
-export const ExplorationStatusSchema: GenMessage<ExplorationStatus> = /*@__PURE__*/
-  messageDesc(file_sysml, 16);
-
-/**
- * ListEnginesRequest asks for the analysis engines registered in this build.
- *
- * @generated from message sysml.ListEnginesRequest
- */
-export type ListEnginesRequest = Message<"sysml.ListEnginesRequest"> & {
-};
-
-/**
- * Describes the message sysml.ListEnginesRequest.
- * Use `create(ListEnginesRequestSchema)` to create a new message.
- */
-export const ListEnginesRequestSchema: GenMessage<ListEnginesRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 17);
-
-/**
- * EngineInfo is one analysis engine: what it answers, how strongly it can, and
- * whether it can run here.
- *
- * @generated from message sysml.EngineInfo
- */
-export type EngineInfo = Message<"sysml.EngineInfo"> & {
-  /**
-   * The engine's name, as a request's `engine` field selects it.
-   *
-   * @generated from field: string name = 1;
-   */
-  name: string;
-
-  /**
-   * The strongest evidence the engine can ever produce, spelled as
-   * Verdict.strength is.
-   *
-   * @generated from field: string authority = 2;
-   */
-  authority: string;
-
-  /**
-   * The kinds of question the engine answers: "evaluate", "outcomes",
-   * "holds", "sensitive", "satisfiable", "sweep", "compute".
-   *
-   * @generated from field: repeated string answers = 3;
-   */
-  answers: string[];
-
-  /**
-   * The bounds the engine takes, in the order it reports them.
-   *
-   * @generated from field: repeated string bounds = 4;
-   */
-  bounds: string[];
-
-  /**
-   * The external process the engine needs, empty for one running in-process;
-   * `process_found` says where it was found when it was.
-   *
-   * @generated from field: string process = 5;
-   */
-  process: string;
-
-  /**
-   * @generated from field: string process_found = 6;
-   */
-  processFound: string;
-
-  /**
-   * True when the engine can run: it is served, and it needs no process or its
-   * process was found.
-   *
-   * @generated from field: bool ready = 7;
-   */
-  ready: boolean;
-
-  /**
-   * Why the engine cannot run, empty when it can.
-   *
-   * @generated from field: string unavailable = 8;
-   */
-  unavailable: string;
-
-  /**
-   * Where the engine comes from: "built-in" for the build's own, else the kind
-   * of the manifest entry that registered it: "tool", "engine", "policy" or
-   * "sampler". The fields below are empty for a built-in engine.
-   *
-   * @generated from field: string kind = 9;
-   */
-  kind: string;
-
-  /**
-   * How the engine is spoken to: "-" for one built in, "object" for a tool's
-   * one JSON object each way, "<transport>/<protocol>" for an engine entry.
-   *
-   * @generated from field: string protocol = 10;
-   */
-  protocol: string;
-
-  /**
-   * The manifest entry the engine was registered from, the command it resolved
-   * to and the version the entry declares.
-   *
-   * @generated from field: string source = 11;
-   */
-  source: string;
-
-  /**
-   * @generated from field: string command = 12;
-   */
-  command: string;
-
-  /**
-   * @generated from field: string version = 13;
-   */
-  version: string;
-
-  /**
-   * True when this service runs the engine for a request that reaches it. A
-   * manifest engine is listed but not served until the service is started with
-   * -serve-external-engines naming it; a request naming one that is not served
-   * is FAILED_PRECONDITION.
-   *
-   * @generated from field: bool served = 14;
-   */
-  served: boolean;
-};
-
-/**
- * Describes the message sysml.EngineInfo.
- * Use `create(EngineInfoSchema)` to create a new message.
- */
-export const EngineInfoSchema: GenMessage<EngineInfo> = /*@__PURE__*/
-  messageDesc(file_sysml, 18);
-
-/**
- * ListEnginesResponse lists the engines in name order.
- *
- * @generated from message sysml.ListEnginesResponse
- */
-export type ListEnginesResponse = Message<"sysml.ListEnginesResponse"> & {
-  /**
-   * @generated from field: repeated sysml.EngineInfo engines = 1;
-   */
-  engines: EngineInfo[];
-};
-
-/**
- * Describes the message sysml.ListEnginesResponse.
- * Use `create(ListEnginesResponseSchema)` to create a new message.
- */
-export const ListEnginesResponseSchema: GenMessage<ListEnginesResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 19);
-
-/**
- * ParseFileRequest specifies the source to parse
- *
- * @generated from message sysml.ParseFileRequest
- */
-export type ParseFileRequest = Message<"sysml.ParseFileRequest"> & {
-  /**
-   * @generated from oneof sysml.ParseFileRequest.source
-   */
-  source: {
-    /**
-     * @generated from field: string file_path = 1;
-     */
-    value: string;
-    case: "filePath";
-  } | {
-    /**
-     * @generated from field: string content = 2;
-     */
-    value: string;
-    case: "content";
-  } | { case: undefined; value?: undefined };
-
-  /**
-   * Ignored: the service keys its cache by the source it read, so a repeated
-   * parse hits the cache whether or not a client sends this.
-   *
-   * @generated from field: string content_hash = 3 [deprecated = true];
-   * @deprecated
-   */
-  contentHash: string;
-
-  /**
-   * Language for inline content: "sysml" or "kerml". Empty defaults to SysML.
-   *
-   * @generated from field: string language = 4;
-   */
-  language: string;
-
-  /**
-   * Judge the source as conforming SysML v2: notation no pinned production
-   * admits is an error rather than a warning. Part of the cache key, so the two
-   * modes never serve each other's diagnostics.
-   *
-   * @generated from field: bool strict_conformance = 5;
-   */
-  strictConformance: boolean;
-};
-
-/**
- * Describes the message sysml.ParseFileRequest.
- * Use `create(ParseFileRequestSchema)` to create a new message.
- */
-export const ParseFileRequestSchema: GenMessage<ParseFileRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 20);
-
-/**
- * SourceDocument is one document of a multi-document parse.
- *
- * @generated from message sysml.SourceDocument
- */
-export type SourceDocument = Message<"sysml.SourceDocument"> & {
-  /**
-   * @generated from oneof sysml.SourceDocument.source
-   */
-  source: {
-    /**
-     * @generated from field: string file_path = 1;
-     */
-    value: string;
-    case: "filePath";
-  } | {
-    /**
-     * @generated from field: string content = 2;
-     */
-    value: string;
-    case: "content";
-  } | { case: undefined; value?: undefined };
-
-  /**
-   * Language for inline content: "sysml" or "kerml". Empty defaults to SysML,
-   * and is ignored for a file_path, whose extension says which language it is.
-   *
-   * @generated from field: string language = 3;
-   */
-  language: string;
-
-  /**
-   * Name to report inline content by in diagnostics, and the name the document
-   * is indexed under. Empty names the document by its position in the request,
-   * and is ignored for a file_path, which is named by its path.
-   *
-   * @generated from field: string name = 4;
-   */
-  name: string;
-};
-
-/**
- * Describes the message sysml.SourceDocument.
- * Use `create(SourceDocumentSchema)` to create a new message.
- */
-export const SourceDocumentSchema: GenMessage<SourceDocument> = /*@__PURE__*/
-  messageDesc(file_sysml, 21);
-
-/**
- * ParseSourcesRequest specifies the documents that make up one model. Two
- * documents may not carry the same name: each is a distinct document of the
- * model, and diagnostics name the document they came from.
- *
- * @generated from message sysml.ParseSourcesRequest
- */
-export type ParseSourcesRequest = Message<"sysml.ParseSourcesRequest"> & {
-  /**
-   * @generated from field: repeated sysml.SourceDocument documents = 1;
-   */
-  documents: SourceDocument[];
-
-  /**
-   * Judge every document as conforming SysML v2, as ParseFileRequest does.
-   *
-   * @generated from field: bool strict_conformance = 2;
-   */
-  strictConformance: boolean;
-};
-
-/**
- * Describes the message sysml.ParseSourcesRequest.
- * Use `create(ParseSourcesRequestSchema)` to create a new message.
- */
-export const ParseSourcesRequestSchema: GenMessage<ParseSourcesRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 22);
-
-/**
- * ParseSourcesResponse contains the parsed model, whose documents are one model
- * for every later request: a model_hash names all of them together.
- *
- * @generated from message sysml.ParseSourcesResponse
- */
-export type ParseSourcesResponse = Message<"sysml.ParseSourcesResponse"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * Root namespace per document, in the order the request named them.
-   *
-   * @generated from field: repeated sysml.SymbolInfo roots = 2;
-   */
-  roots: SymbolInfo[];
-
-  /**
-   * Diagnostics of every document, each naming the document it came from.
-   *
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 3;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * @generated from field: string error = 4;
-   */
-  error: string;
-};
-
-/**
- * Describes the message sysml.ParseSourcesResponse.
- * Use `create(ParseSourcesResponseSchema)` to create a new message.
- */
-export const ParseSourcesResponseSchema: GenMessage<ParseSourcesResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 23);
-
-/**
- * ParseFileResponse contains parsed model info
- *
- * @generated from message sysml.ParseFileResponse
- */
-export type ParseFileResponse = Message<"sysml.ParseFileResponse"> & {
-  /**
-   * Cache key for subsequent requests
-   *
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * Root namespace
-   *
-   * @generated from field: sysml.SymbolInfo root = 2;
-   */
-  root?: SymbolInfo | undefined;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 3;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * Critical failure message if any
-   *
-   * @generated from field: string error = 4;
-   */
-  error: string;
-};
-
-/**
- * Describes the message sysml.ParseFileResponse.
- * Use `create(ParseFileResponseSchema)` to create a new message.
- */
-export const ParseFileResponseSchema: GenMessage<ParseFileResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 24);
-
-/**
- * GetSymbolRequest queries for a specific symbol
- *
- * @generated from message sysml.GetSymbolRequest
- */
-export type GetSymbolRequest = Message<"sysml.GetSymbolRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * Fully qualified name
-   *
-   * @generated from field: string symbol_id = 2;
-   */
-  symbolId: string;
-};
-
-/**
- * Describes the message sysml.GetSymbolRequest.
- * Use `create(GetSymbolRequestSchema)` to create a new message.
- */
-export const GetSymbolRequestSchema: GenMessage<GetSymbolRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 25);
-
-/**
- * SymbolResponse contains symbol information
- *
- * @generated from message sysml.SymbolResponse
- */
-export type SymbolResponse = Message<"sysml.SymbolResponse"> & {
-  /**
-   * @generated from field: sysml.SymbolInfo symbol = 1;
-   */
-  symbol?: SymbolInfo | undefined;
-
-  /**
-   * @generated from field: string error = 2;
-   */
-  error: string;
-};
-
-/**
- * Describes the message sysml.SymbolResponse.
- * Use `create(SymbolResponseSchema)` to create a new message.
- */
-export const SymbolResponseSchema: GenMessage<SymbolResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 26);
-
-/**
- * DiagnosticsRequest gets all diagnostics for a model
- *
- * @generated from message sysml.DiagnosticsRequest
- */
-export type DiagnosticsRequest = Message<"sysml.DiagnosticsRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-};
-
-/**
- * Describes the message sysml.DiagnosticsRequest.
- * Use `create(DiagnosticsRequestSchema)` to create a new message.
- */
-export const DiagnosticsRequestSchema: GenMessage<DiagnosticsRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 27);
-
-/**
- * DiagnosticsResponse contains diagnostic list
- *
- * @generated from message sysml.DiagnosticsResponse
- */
-export type DiagnosticsResponse = Message<"sysml.DiagnosticsResponse"> & {
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 1;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * @generated from field: string error = 2;
-   */
-  error: string;
-};
-
-/**
- * Describes the message sysml.DiagnosticsResponse.
- * Use `create(DiagnosticsResponseSchema)` to create a new message.
- */
-export const DiagnosticsResponseSchema: GenMessage<DiagnosticsResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 28);
-
-/**
- * EvaluateRequest requests evaluation of a SysML expression
- *
- * @generated from message sysml.EvaluateRequest
- */
-export type EvaluateRequest = Message<"sysml.EvaluateRequest"> & {
-  /**
-   * from ParseFile response
-   *
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * SysML expression string (e.g., "2 + 2")
-   *
-   * @generated from field: string expression = 2;
-   */
-  expression: string;
-
-  /**
-   * optional: symbol FQN for context scope
-   *
-   * @generated from field: string context_symbol_id = 3;
-   */
-  contextSymbolId: string;
-
-  /**
-   * optional: FQN to instantiate and evaluate against, so a feature reads that
-   * object's value rather than the declared default (as %eval after %instantiate)
-   *
-   * @generated from field: string subject_symbol_id = 4;
-   */
-  subjectSymbolId: string;
-};
-
-/**
- * Describes the message sysml.EvaluateRequest.
- * Use `create(EvaluateRequestSchema)` to create a new message.
- */
-export const EvaluateRequestSchema: GenMessage<EvaluateRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 29);
-
-/**
- * EvaluateResponse contains evaluation result
- *
- * @generated from message sysml.EvaluateResponse
- */
-export type EvaluateResponse = Message<"sysml.EvaluateResponse"> & {
-  /**
-   * @generated from field: sysml.Value result = 1;
-   */
-  result?: Value | undefined;
-
-  /**
-   * non-empty if evaluation failed
-   *
-   * @generated from field: string error = 2;
-   */
-  error: string;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 3;
-   */
-  diagnostics: Diagnostic[];
-};
-
-/**
- * Describes the message sysml.EvaluateResponse.
- * Use `create(EvaluateResponseSchema)` to create a new message.
- */
-export const EvaluateResponseSchema: GenMessage<EvaluateResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 30);
-
-/**
- * Instance represents a runtime instance of a part/usage
- *
- * @generated from message sysml.Instance
- */
-export type Instance = Message<"sysml.Instance"> & {
-  /**
-   * @generated from field: int64 id = 1;
-   */
-  id: bigint;
-
-  /**
-   * FQN of the def/usage
-   *
-   * @generated from field: string type_symbol_id = 2;
-   */
-  typeSymbolId: string;
-
-  /**
-   * What the object holds for each feature of its type, by feature name.
-   *
-   * @generated from field: map<string, sysml.FeatureValue> feature_values = 4;
-   */
-  featureValues: { [key: string]: FeatureValue };
-};
-
-/**
- * Describes the message sysml.Instance.
- * Use `create(InstanceSchema)` to create a new message.
- */
-export const InstanceSchema: GenMessage<Instance> = /*@__PURE__*/
-  messageDesc(file_sysml, 31);
-
-/**
- * FeatureValue is what an object holds for one feature of its type.
- *
- * @generated from message sysml.FeatureValue
- */
-export type FeatureValue = Message<"sysml.FeatureValue"> & {
-  /**
-   * @generated from field: string feature_name = 1;
-   */
-  featureName: string;
-
-  /**
-   * for a single-valued feature
-   *
-   * @generated from field: sysml.Value value = 2;
-   */
-  value?: Value | undefined;
-
-  /**
-   * for a multi-valued feature
-   *
-   * @generated from field: repeated sysml.Value values = 3;
-   */
-  values: Value[];
-
-  /**
-   * @generated from field: bool materialized = 4;
-   */
-  materialized: boolean;
-
-  /**
-   * set when evaluation failed; value is unset
-   *
-   * @generated from field: string error = 5;
-   */
-  error: string;
-};
-
-/**
- * Describes the message sysml.FeatureValue.
- * Use `create(FeatureValueSchema)` to create a new message.
- */
-export const FeatureValueSchema: GenMessage<FeatureValue> = /*@__PURE__*/
-  messageDesc(file_sysml, 32);
-
-/**
- * InstantiateRequest requests instantiation of a part/usage
- *
- * @generated from message sysml.InstantiateRequest
- */
-export type InstantiateRequest = Message<"sysml.InstantiateRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * FQN of part/usage to instantiate
-   *
-   * @generated from field: string symbol_id = 2;
-   */
-  symbolId: string;
-};
-
-/**
- * Describes the message sysml.InstantiateRequest.
- * Use `create(InstantiateRequestSchema)` to create a new message.
- */
-export const InstantiateRequestSchema: GenMessage<InstantiateRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 33);
-
-/**
- * InstantiateResponse contains the created instance
- *
- * @generated from message sysml.InstantiateResponse
- */
-export type InstantiateResponse = Message<"sysml.InstantiateResponse"> & {
-  /**
-   * @generated from field: sysml.Instance instance = 1;
-   */
-  instance?: Instance | undefined;
-
-  /**
-   * @generated from field: string error = 2;
-   */
-  error: string;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 3;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * Every instance reachable from `instance`, including `instance` itself, so a
-   * client can resolve an `instance_id` feature value without a follow-up RPC.
-   *
-   * @generated from field: repeated sysml.Instance instances = 4;
-   */
-  instances: Instance[];
-};
-
-/**
- * Describes the message sysml.InstantiateResponse.
- * Use `create(InstantiateResponseSchema)` to create a new message.
- */
-export const InstantiateResponseSchema: GenMessage<InstantiateResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 34);
-
-/**
- * ExecuteActionRequest requests action execution
- *
- * @generated from message sysml.ExecuteActionRequest
- */
-export type ExecuteActionRequest = Message<"sysml.ExecuteActionRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * FQN of action def
-   *
-   * @generated from field: string action_symbol_id = 2;
-   */
-  actionSymbolId: string;
-
-  /**
-   * parameter name → value
-   *
-   * @generated from field: map<string, sysml.Value> inputs = 3;
-   */
-  inputs: { [key: string]: Value };
-
-  /**
-   * Scheduling policy the run resolves its choice points under: "declared",
-   * "reverse", "seed:<n>" or "explore[:runs=<n>,depth=<d>]". Empty is the
-   * default, "reverse"; any other spelling is INVALID_ARGUMENT. Explore runs
-   * the action once per linearization the library admits, within a budget of
-   * runs (default 1024) and of choice points per run (default 64), each run on
-   * a fresh context, and answers with every distinct outcome reached.
-   *
-   * @generated from field: string schedule = 4;
-   */
-  schedule: string;
-};
-
-/**
- * Describes the message sysml.ExecuteActionRequest.
- * Use `create(ExecuteActionRequestSchema)` to create a new message.
- */
-export const ExecuteActionRequestSchema: GenMessage<ExecuteActionRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 35);
-
-/**
- * ExecuteActionResponse contains action execution results
- *
- * @generated from message sysml.ExecuteActionResponse
- */
-export type ExecuteActionResponse = Message<"sysml.ExecuteActionResponse"> & {
-  /**
-   * output parameter name → value
-   *
-   * @generated from field: map<string, sysml.Value> outputs = 1;
-   */
-  outputs: { [key: string]: Value };
-
-  /**
-   * @generated from field: string error = 2;
-   */
-  error: string;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 3;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * Set only under an explore schedule: every distinct outcome reached, in
-   * canonical order, and how the exploration ended. `outputs`, `error` and
-   * `diagnostics` are then empty: a failed run is an outcome of its own, and
-   * each outcome carries its witness run's diagnostics.
-   *
-   * @generated from field: repeated sysml.Outcome outcomes = 4;
-   */
-  outcomes: Outcome[];
-
-  /**
-   * @generated from field: sysml.ExplorationStatus exploration = 5;
-   */
-  exploration?: ExplorationStatus | undefined;
-
-  /**
-   * The run's simulation clock when it ended, in seconds (SI::s) from the 0
-   * it started at: the clock advances through every `accept after`/`accept
-   * at` the action waited on. Populated under the "final_time" capability.
-   *
-   * @generated from field: double final_time = 6;
-   */
-  finalTime: number;
-};
-
-/**
- * Describes the message sysml.ExecuteActionResponse.
- * Use `create(ExecuteActionResponseSchema)` to create a new message.
- */
-export const ExecuteActionResponseSchema: GenMessage<ExecuteActionResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 36);
-
-/**
- * ExecuteStateRequest requests state machine execution
- *
- * @generated from message sysml.ExecuteStateRequest
- */
-export type ExecuteStateRequest = Message<"sysml.ExecuteStateRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * @generated from field: string state_machine_symbol_id = 2;
-   */
-  stateMachineSymbolId: string;
-
-  /**
-   * sequence of event names to process
-   *
-   * @generated from field: repeated string events = 3;
-   */
-  events: string[];
-
-  /**
-   * Scheduling policy the run resolves its choice points under: "declared",
-   * "reverse", "seed:<n>" or "explore[:runs=<n>,depth=<d>]". Empty is the
-   * default, "reverse"; any other spelling is INVALID_ARGUMENT. Under explore
-   * the response answers with `outcomes` and `exploration` in place of one run's
-   * states_visited, final_context and error (see ExecuteActionResponse).
-   *
-   * @generated from field: string schedule = 4;
-   */
-  schedule: string;
-};
-
-/**
- * Describes the message sysml.ExecuteStateRequest.
- * Use `create(ExecuteStateRequestSchema)` to create a new message.
- */
-export const ExecuteStateRequestSchema: GenMessage<ExecuteStateRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 37);
-
-/**
- * ExecuteStateResponse contains state machine execution trace
- *
- * @generated from message sysml.ExecuteStateResponse
- */
-export type ExecuteStateResponse = Message<"sysml.ExecuteStateResponse"> & {
-  /**
-   * trace of state names
-   *
-   * @generated from field: repeated string states_visited = 1;
-   */
-  statesVisited: string[];
-
-  /**
-   * @generated from field: map<string, sysml.Value> final_context = 2;
-   */
-  finalContext: { [key: string]: Value };
-
-  /**
-   * @generated from field: string error = 3;
-   */
-  error: string;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 4;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * Set only under an explore schedule: every distinct outcome reached, in
-   * canonical order, and how the exploration ended. An outcome's outputs are
-   * the final context; `states_visited`, `final_context`, `error` and
-   * `diagnostics` are then empty.
-   *
-   * @generated from field: repeated sysml.Outcome outcomes = 5;
-   */
-  outcomes: Outcome[];
-
-  /**
-   * @generated from field: sysml.ExplorationStatus exploration = 6;
-   */
-  exploration?: ExplorationStatus | undefined;
-
-  /**
-   * The run's simulation clock when it ended, in seconds (SI::s) from the 0
-   * it started at: the clock advances through every time-triggered transition
-   * the machine took. Populated under the "final_time" capability.
-   *
-   * @generated from field: double final_time = 7;
-   */
-  finalTime: number;
-};
-
-/**
- * Describes the message sysml.ExecuteStateResponse.
- * Use `create(ExecuteStateResponseSchema)` to create a new message.
- */
-export const ExecuteStateResponseSchema: GenMessage<ExecuteStateResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 38);
-
-/**
- * ConvertRequest asks for a model in another representation. A model_hash
- * converts the source that parse read, so a file edited since then does not
- * change the answer; a file_path is read afresh and content is carried inline.
- *
- * @generated from message sysml.ConvertRequest
- */
-export type ConvertRequest = Message<"sysml.ConvertRequest"> & {
-  /**
-   * @generated from oneof sysml.ConvertRequest.source
-   */
-  source: {
-    /**
-     * @generated from field: string file_path = 1;
-     */
-    value: string;
-    case: "filePath";
-  } | {
-    /**
-     * @generated from field: string content = 2;
-     */
-    value: string;
-    case: "content";
-  } | {
-    /**
-     * @generated from field: string model_hash = 6;
-     */
-    value: string;
-    case: "modelHash";
-  } | { case: undefined; value?: undefined };
-
-  /**
-   * "sysml", "kerml", "text", "ttl", "turtle" or "rdf". Empty infers from
-   * file_path's extension, and is notation for a model_hash, since that is what
-   * parse reads; inline content has neither, so it must say.
-   *
-   * @generated from field: string from_format = 3;
-   */
-  fromFormat: string;
-
-  /**
-   * Format to write, named as in from_format. Empty is rejected.
-   *
-   * @generated from field: string to_format = 4;
-   */
-  toFormat: string;
-
-  /**
-   * Write notation back out even when the parser could not read all of it,
-   * reporting its syntax errors as diagnostics. Notation to notation only:
-   * every other direction builds a graph, where unreadable declarations would
-   * go missing silently.
-   *
-   * @generated from field: bool tolerate_syntax_errors = 5;
-   */
-  tolerateSyntaxErrors: boolean;
-};
-
-/**
- * Describes the message sysml.ConvertRequest.
- * Use `create(ConvertRequestSchema)` to create a new message.
- */
-export const ConvertRequestSchema: GenMessage<ConvertRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 39);
-
-/**
- * ConvertResponse contains the converted model.
- *
- * @generated from message sysml.ConvertResponse
- */
-export type ConvertResponse = Message<"sysml.ConvertResponse"> & {
-  /**
-   * @generated from field: string content = 1;
-   */
-  content: string;
-
-  /**
-   * Formats used, so a caller that let from_format be inferred learns what it
-   * was inferred as.
-   *
-   * @generated from field: string from_format = 2;
-   */
-  fromFormat: string;
-
-  /**
-   * @generated from field: string to_format = 3;
-   */
-  toFormat: string;
-
-  /**
-   * non-empty if the conversion failed; content is unset
-   *
-   * @generated from field: string error = 4;
-   */
-  error: string;
-
-  /**
-   * Syntax errors tolerated under tolerate_syntax_errors, or those that failed
-   * the conversion.
-   *
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 5;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * Set when either format is RDF, whose mapping is experimental: it covers
-   * model structure and the behavior its bodies state, refuses what it cannot
-   * write back, and its vocabulary may change without a compatibility path.
-   * Notation to notation is stable and leaves this unset.
-   *
-   * @generated from field: bool experimental = 6;
-   */
-  experimental: boolean;
-
-  /**
-   * What is experimental about the conversion, in the wording every surface
-   * reports it in. Empty when experimental is false.
-   *
-   * @generated from field: string experimental_notice = 7;
-   */
-  experimentalNotice: string;
-};
-
-/**
- * Describes the message sysml.ConvertResponse.
- * Use `create(ConvertResponseSchema)` to create a new message.
- */
-export const ConvertResponseSchema: GenMessage<ConvertResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 40);
-
-/**
- * ApplyEditsRequest asks for a model's source with edits applied to it. The
- * source edited is the one parse read, named by its hash, so an edit is applied
- * to the model that was inspected.
- *
- * @generated from message sysml.ApplyEditsRequest
- */
-export type ApplyEditsRequest = Message<"sysml.ApplyEditsRequest"> & {
-  /**
-   * from ParseFile response
-   *
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * Edits to apply, all of them or none. Empty is rejected: it names no edit,
-   * and answering with the unedited source would look like one was made.
-   *
-   * @generated from field: repeated sysml.EditOperation operations = 2;
-   */
-  operations: EditOperation[];
-};
-
-/**
- * Describes the message sysml.ApplyEditsRequest.
- * Use `create(ApplyEditsRequestSchema)` to create a new message.
- */
-export const ApplyEditsRequestSchema: GenMessage<ApplyEditsRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 41);
-
-/**
- * EditOperation is one source-preserving change to make.
- *
- * @generated from message sysml.EditOperation
- */
-export type EditOperation = Message<"sysml.EditOperation"> & {
-  /**
-   * @generated from oneof sysml.EditOperation.operation
-   */
-  operation: {
-    /**
-     * @generated from field: sysml.SetValueEdit set_value = 1;
-     */
-    value: SetValueEdit;
-    case: "setValue";
-  } | {
-    /**
-     * @generated from field: sysml.RenameEdit rename = 2;
-     */
-    value: RenameEdit;
-    case: "rename";
-  } | {
-    /**
-     * @generated from field: sysml.AddMemberEdit add_member = 3;
-     */
-    value: AddMemberEdit;
-    case: "addMember";
-  } | {
-    /**
-     * @generated from field: sysml.DeleteEdit delete = 4;
-     */
-    value: DeleteEdit;
-    case: "delete";
-  } | {
-    /**
-     * @generated from field: sysml.MoveEdit move = 5;
-     */
-    value: MoveEdit;
-    case: "move";
-  } | { case: undefined; value?: undefined };
-};
-
-/**
- * Describes the message sysml.EditOperation.
- * Use `create(EditOperationSchema)` to create a new message.
- */
-export const EditOperationSchema: GenMessage<EditOperation> = /*@__PURE__*/
-  messageDesc(file_sysml, 42);
-
-/**
- * AddMemberEdit inserts a declaration into a namespace or the document root.
- *
- * @generated from message sysml.AddMemberEdit
- */
-export type AddMemberEdit = Message<"sysml.AddMemberEdit"> & {
-  /**
-   * Namespace FQN to receive the declaration; empty means the document root.
-   *
-   * @generated from field: string owner = 1;
-   */
-  owner: string;
-
-  /**
-   * Written declaration kind, such as "part def" or "class".
-   *
-   * @generated from field: string kind = 2;
-   */
-  kind: string;
-
-  /**
-   * Declared identifier.
-   *
-   * @generated from field: string name = 3;
-   */
-  name: string;
-
-  /**
-   * Optional type target for a usage, written as notation.
-   *
-   * @generated from field: string type = 4;
-   */
-  type: string;
-
-  /**
-   * Optional multiplicity, including brackets, such as "[0..*]".
-   *
-   * @generated from field: string multiplicity = 5;
-   */
-  multiplicity: string;
-
-  /**
-   * Optional value expression, written as notation.
-   *
-   * @generated from field: string value = 6;
-   */
-  value: string;
-
-  /**
-   * Optional specialization targets for a definition.
-   *
-   * @generated from field: repeated string specializes = 7;
-   */
-  specializes: string[];
-};
-
-/**
- * Describes the message sysml.AddMemberEdit.
- * Use `create(AddMemberEditSchema)` to create a new message.
- */
-export const AddMemberEditSchema: GenMessage<AddMemberEdit> = /*@__PURE__*/
-  messageDesc(file_sysml, 43);
-
-/**
- * DeleteEdit removes a declaration and its owned trivia.
- *
- * @generated from message sysml.DeleteEdit
- */
-export type DeleteEdit = Message<"sysml.DeleteEdit"> & {
-  /**
-   * Fully qualified name of the declaration to remove.
-   *
-   * @generated from field: string target = 1;
-   */
-  target: string;
-
-  /**
-   * Also remove declarations that refer to target.
-   *
-   * @generated from field: bool cascade = 2;
-   */
-  cascade: boolean;
-};
-
-/**
- * Describes the message sysml.DeleteEdit.
- * Use `create(DeleteEditSchema)` to create a new message.
- */
-export const DeleteEditSchema: GenMessage<DeleteEdit> = /*@__PURE__*/
-  messageDesc(file_sysml, 44);
-
-/**
- * MoveEdit re-parents a declaration: the span DeleteEdit would remove is
- * written where AddMemberEdit would insert it, and the references the move
- * breaks are respelled so the model stays valid. A move whose references
- * cannot be respelled is refused, naming them, rather than leaving them dangling.
- *
- * @generated from message sysml.MoveEdit
- */
-export type MoveEdit = Message<"sysml.MoveEdit"> & {
-  /**
-   * Fully qualified name of the declaration to move.
-   *
-   * @generated from field: string target = 1;
-   */
-  target: string;
-
-  /**
-   * Namespace FQN to receive the declaration; empty means the document root.
-   *
-   * @generated from field: string owner = 2;
-   */
-  owner: string;
-};
-
-/**
- * Describes the message sysml.MoveEdit.
- * Use `create(MoveEditSchema)` to create a new message.
- */
-export const MoveEditSchema: GenMessage<MoveEdit> = /*@__PURE__*/
-  messageDesc(file_sysml, 45);
-
-/**
- * SetValueEdit sets the value of a feature that already exists, replacing the
- * expression of its `= <expr>` or adding one before the declaration's `;`.
- *
- * @generated from message sysml.SetValueEdit
- */
-export type SetValueEdit = Message<"sysml.SetValueEdit"> & {
-  /**
-   * Element to edit, named as SymbolInfo.id names it ("Demo::sc::unitMass").
-   *
-   * @generated from field: string target = 1;
-   */
-  target: string;
-
-  /**
-   * New value in SysML notation ("1050.0[SI::kg]", "\"m1\"", "true", "mass * 2").
-   * It must parse as an expression and resolve in the feature's own scope.
-   *
-   * @generated from field: string value = 2;
-   */
-  value: string;
-};
-
-/**
- * Describes the message sysml.SetValueEdit.
- * Use `create(SetValueEditSchema)` to create a new message.
- */
-export const SetValueEditSchema: GenMessage<SetValueEdit> = /*@__PURE__*/
-  messageDesc(file_sysml, 46);
-
-/**
- * RenameEdit rewrites the name token of a declaration. References to the
- * element are not updated: a rename of an element referenced anywhere is
- * refused, naming the referring elements, rather than leaving a broken model.
- *
- * @generated from message sysml.RenameEdit
- */
-export type RenameEdit = Message<"sysml.RenameEdit"> & {
-  /**
-   * element to rename, as SymbolInfo.id names it
-   *
-   * @generated from field: string target = 1;
-   */
-  target: string;
-
-  /**
-   * new declared name; must lex as an identifier
-   *
-   * @generated from field: string new_name = 2;
-   */
-  newName: string;
-};
-
-/**
- * Describes the message sysml.RenameEdit.
- * Use `create(RenameEditSchema)` to create a new message.
- */
-export const RenameEditSchema: GenMessage<RenameEdit> = /*@__PURE__*/
-  messageDesc(file_sysml, 47);
-
-/**
- * ApplyEditsResponse carries the edited source, or says why nothing was edited.
- *
- * @generated from message sysml.ApplyEditsResponse
- */
-export type ApplyEditsResponse = Message<"sysml.ApplyEditsResponse"> & {
-  /**
-   * The edited notation, byte-identical to the source outside the edited spans.
-   * Empty when the edits were refused, so a refusal never writes a file.
-   *
-   * @generated from field: string content = 1;
-   */
-  content: string;
-
-  /**
-   * What each operation changed, in request order.
-   *
-   * @generated from field: repeated sysml.AppliedEdit applied = 2;
-   */
-  applied: AppliedEdit[];
-
-  /**
-   * non-empty if the edits were refused; content is unset
-   *
-   * @generated from field: string error = 3;
-   */
-  error: string;
-
-  /**
-   * Which kind of refusal `error` reports, so a client acts on the kind rather
-   * than on the message text.
-   *
-   * @generated from field: sysml.EditFailure failure = 4;
-   */
-  failure: EditFailure;
-
-  /**
-   * Diagnostics behind a refusal: the parse errors of an unreadable new value,
-   * or the errors the edited source was found to have.
-   *
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 5;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * Where the references to a declaration whose rename was refused are made:
-   * the FQN of each referring namespace.
-   *
-   * @generated from field: repeated string referring_elements = 6;
-   */
-  referringElements: string[];
-};
-
-/**
- * Describes the message sysml.ApplyEditsResponse.
- * Use `create(ApplyEditsResponseSchema)` to create a new message.
- */
-export const ApplyEditsResponseSchema: GenMessage<ApplyEditsResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 48);
-
-/**
- * AppliedEdit is one byte range of the original source that an operation
- * replaced, so a client can report or locate what changed.
- *
- * @generated from message sysml.AppliedEdit
- */
-export type AppliedEdit = Message<"sysml.AppliedEdit"> & {
-  /**
-   * Index of the operation in the request, so an answer maps back to its ask.
-   *
-   * @generated from field: int32 operation_index = 1;
-   */
-  operationIndex: number;
-
-  /**
-   * element edited, as the request named it
-   *
-   * @generated from field: string target = 2;
-   */
-  target: string;
-
-  /**
-   * Batch offsets use the original source; sequential offsets use the
-   * intermediate source seen by that operation.
-   *
-   * @generated from field: int32 offset = 3;
-   */
-  offset: number;
-
-  /**
-   * bytes replaced; zero for text inserted
-   *
-   * @generated from field: int32 length = 4;
-   */
-  length: number;
-
-  /**
-   * what was there, empty for an insertion
-   *
-   * @generated from field: string old_text = 5;
-   */
-  oldText: string;
-
-  /**
-   * what was written
-   *
-   * @generated from field: string new_text = 6;
-   */
-  newText: string;
-};
-
-/**
- * Describes the message sysml.AppliedEdit.
- * Use `create(AppliedEditSchema)` to create a new message.
- */
-export const AppliedEditSchema: GenMessage<AppliedEdit> = /*@__PURE__*/
-  messageDesc(file_sysml, 49);
-
-/**
- * SymbolInfo represents any SysML element
- *
- * @generated from message sysml.SymbolInfo
- */
-export type SymbolInfo = Message<"sysml.SymbolInfo"> & {
-  /**
-   * Unique identifier (fully qualified name)
-   *
-   * @generated from field: string id = 1;
-   */
-  id: string;
-
-  /**
-   * @generated from field: string name = 2;
-   */
-  name: string;
-
-  /**
-   * "PartDefinition", "AttributeUsage", etc
-   *
-   * @generated from field: string kind = 3;
-   */
-  kind: string;
-
-  /**
-   * multiplicity, type, etc
-   *
-   * @generated from field: map<string, string> metadata = 4;
-   */
-  metadata: { [key: string]: string };
-
-  /**
-   * References to children
-   *
-   * @generated from field: repeated string child_ids = 5;
-   */
-  childIds: string[];
-
-  /**
-   * @generated from field: repeated sysml.AttributeInfo attributes = 6;
-   */
-  attributes: AttributeInfo[];
-
-  /**
-   * Static type facts; unset when the element is not a def/usage.
-   *
-   * @generated from field: sysml.TypeInfo type_info = 7;
-   */
-  typeInfo?: TypeInfo | undefined;
-
-  /**
-   * Declared multiplicity; unset when none is declared.
-   *
-   * @generated from field: sysml.MultiplicityInfo multiplicity = 8;
-   */
-  multiplicity?: MultiplicityInfo | undefined;
-
-  /**
-   * Every generalization edge declared, in declaration order. metadata["type"]
-   * and metadata["specializes"] report only the first, kept for compatibility.
-   *
-   * @generated from field: repeated sysml.Specialization specializations = 9;
-   */
-  specializations: Specialization[];
-
-  /**
-   * Attributes inherited from standard-library content, which `attributes`
-   * leaves out. Their absence is stated here rather than silent.
-   *
-   * @generated from field: int32 withheld_library_attributes = 10;
-   */
-  withheldLibraryAttributes: number;
-};
-
-/**
- * Describes the message sysml.SymbolInfo.
- * Use `create(SymbolInfoSchema)` to create a new message.
- */
-export const SymbolInfoSchema: GenMessage<SymbolInfo> = /*@__PURE__*/
-  messageDesc(file_sysml, 50);
-
-/**
- * Specialization is one generalization edge an element declares.
- *
- * @generated from message sysml.Specialization
- */
-export type Specialization = Message<"sysml.Specialization"> & {
-  /**
-   * Relationship kind: "specializes", "subsets", "redefines" or "typing".
-   *
-   * @generated from field: string kind = 1;
-   */
-  kind: string;
-
-  /**
-   * The target as written in the source ("Engine", "Demo::Engine").
-   *
-   * @generated from field: string declared = 2;
-   */
-  declared: string;
-
-  /**
-   * FQN of the resolved target; empty when the name does not resolve.
-   *
-   * @generated from field: string target_id = 3;
-   */
-  targetId: string;
-
-  /**
-   * Symbol kind of the resolved target ("partDef", ...); empty when unresolved.
-   *
-   * @generated from field: string target_kind = 4;
-   */
-  targetKind: string;
-};
-
-/**
- * Describes the message sysml.Specialization.
- * Use `create(SpecializationSchema)` to create a new message.
- */
-export const SpecializationSchema: GenMessage<Specialization> = /*@__PURE__*/
-  messageDesc(file_sysml, 51);
-
-/**
- * TypeInfo is the static type of a usage, or the classification of a
- * definition, as far as the service derives it without running the model.
- *
- * @generated from message sysml.TypeInfo
- */
-export type TypeInfo = Message<"sysml.TypeInfo"> & {
-  /**
-   * Type name as written; empty when none is declared.
-   *
-   * @generated from field: string declared = 1;
-   */
-  declared: string;
-
-  /**
-   * FQN of the resolved type; empty when unresolved or undeclared.
-   *
-   * @generated from field: string resolved_id = 2;
-   */
-  resolvedId: string;
-
-  /**
-   * Symbol kind of the resolved type ("partDef", "attributeDef", ...).
-   *
-   * @generated from field: string resolved_kind = 3;
-   */
-  resolvedKind: string;
-
-  /**
-   * Library scalar the type reduces to ("Boolean", "Integer", "Real", ...);
-   * empty when it is not a scalar value type.
-   *
-   * @generated from field: string primitive = 4;
-   */
-  primitive: string;
-
-  /**
-   * Origin of `primitive`: "declared" (from the declared or inherited type),
-   * "value" (inferred from the default value), or empty.
-   *
-   * @generated from field: string primitive_source = 5;
-   */
-  primitiveSource: string;
-
-  /**
-   * Values carry a measurement unit, so such a feature value holds a Quantity.
-   *
-   * @generated from field: bool quantity = 6;
-   */
-  quantity: boolean;
-
-  /**
-   * Unit as written when the default value names one ("m/s"), else empty.
-   *
-   * @generated from field: string unit = 7;
-   */
-  unit: string;
-};
-
-/**
- * Describes the message sysml.TypeInfo.
- * Use `create(TypeInfoSchema)` to create a new message.
- */
-export const TypeInfoSchema: GenMessage<TypeInfo> = /*@__PURE__*/
-  messageDesc(file_sysml, 52);
-
-/**
- * MultiplicityInfo is a declared multiplicity range. A bound the service cannot
- * evaluate statically is empty.
- *
- * @generated from message sysml.MultiplicityInfo
- */
-export type MultiplicityInfo = Message<"sysml.MultiplicityInfo"> & {
-  /**
-   * "0", "1", … or "*"
-   *
-   * @generated from field: string lower = 1;
-   */
-  lower: string;
-
-  /**
-   * "1", "*", …
-   *
-   * @generated from field: string upper = 2;
-   */
-  upper: string;
-};
-
-/**
- * Describes the message sysml.MultiplicityInfo.
- * Use `create(MultiplicityInfoSchema)` to create a new message.
- */
-export const MultiplicityInfoSchema: GenMessage<MultiplicityInfo> = /*@__PURE__*/
-  messageDesc(file_sysml, 53);
-
-/**
- * AttributeInfo represents an attribute with its value
- *
- * @generated from message sysml.AttributeInfo
- */
-export type AttributeInfo = Message<"sysml.AttributeInfo"> & {
-  /**
-   * @generated from field: string name = 1;
-   */
-  name: string;
-
-  /**
-   * @generated from field: string type = 2;
-   */
-  type: string;
-
-  /**
-   * @generated from field: sysml.Value value = 3;
-   */
-  value?: Value | undefined;
-
-  /**
-   * @generated from field: string unit = 4;
-   */
-  unit: string;
-};
-
-/**
- * Describes the message sysml.AttributeInfo.
- * Use `create(AttributeInfoSchema)` to create a new message.
- */
-export const AttributeInfoSchema: GenMessage<AttributeInfo> = /*@__PURE__*/
-  messageDesc(file_sysml, 54);
-
-/**
- * Value represents a runtime-evaluable value
- *
- * @generated from message sysml.Value
- */
-export type Value = Message<"sysml.Value"> & {
-  /**
-   * @generated from oneof sysml.Value.kind
-   */
-  kind: {
-    /**
-     * @generated from field: int64 int_value = 1;
-     */
-    value: bigint;
-    case: "intValue";
-  } | {
-    /**
-     * @generated from field: double real_value = 2;
-     */
-    value: number;
-    case: "realValue";
-  } | {
-    /**
-     * @generated from field: bool bool_value = 3;
-     */
-    value: boolean;
-    case: "boolValue";
-  } | {
-    /**
-     * @generated from field: string string_value = 4;
-     */
-    value: string;
-    case: "stringValue";
-  } | {
-    /**
-     * reference to Instance
-     *
-     * @generated from field: int64 instance_id = 5;
-     */
-    value: bigint;
-    case: "instanceId";
-  } | {
-    /**
-     * @generated from field: sysml.ValueSequence sequence = 6;
-     */
-    value: ValueSequence;
-    case: "sequence";
-  } | {
-    /**
-     * marker for null (empty string)
-     *
-     * @generated from field: string null = 7;
-     */
-    value: string;
-    case: "null";
-  } | {
-    /**
-     * magnitude and the unit it is expressed in
-     *
-     * @generated from field: sysml.Quantity quantity = 8;
-     */
-    value: Quantity;
-    case: "quantity";
-  } | {
-    /**
-     * @generated from field: sysml.EnumLiteral enum_literal = 9;
-     */
-    value: EnumLiteral;
-    case: "enumLiteral";
-  } | {
-    /**
-     * A valueless feature of a value type: materialized, holding no value.
-     * Always true when set; a value the server sends, never one it accepts.
-     *
-     * @generated from field: bool unset = 10;
-     */
-    value: boolean;
-    case: "unset";
-  } | {
-    /**
-     * one complex number, never two Reals
-     *
-     * @generated from field: sysml.Complex complex = 11;
-     */
-    value: Complex;
-    case: "complex";
-  } | {
-    /**
-     * shape and elements, never a flat sequence
-     *
-     * @generated from field: sysml.Array array = 12;
-     */
-    value: Array;
-    case: "array";
-  } | {
-    /**
-     * numeric components, never a sequence
-     *
-     * @generated from field: sysml.Vector vector = 13;
-     */
-    value: Vector;
-    case: "vector";
-  } | {
-    /**
-     * components each with their unit
-     *
-     * @generated from field: sysml.VectorQuantity vector_quantity = 14;
-     */
-    value: VectorQuantity;
-    case: "vectorQuantity";
-  } | {
-    /**
-     * a unit by itself, no magnitude
-     *
-     * @generated from field: sysml.MeasurementRef measurement_ref = 15;
-     */
-    value: MeasurementRef;
-    case: "measurementRef";
-  } | {
-    /**
-     * The unbounded value `*`, which is no number and no string: ordered above
-     * every finite magnitude and refused by arithmetic. Always true when set,
-     * as DocumentValue.infinity is.
-     *
-     * @generated from field: bool infinity = 16;
-     */
-    value: boolean;
-    case: "infinity";
-  } | {
-    /**
-     * a calc as a value, named by its declaration
-     *
-     * @generated from field: sysml.Function function = 17;
-     */
-    value: Function;
-    case: "function";
-  } | {
-    /**
-     * distinct elements with no order of their own
-     *
-     * @generated from field: sysml.ValueSet set = 18;
-     */
-    value: ValueSet;
-    case: "set";
-  } | {
-    /**
-     * shape and one Quantity per component
-     *
-     * @generated from field: sysml.TensorQuantity tensor_quantity = 19;
-     */
-    value: TensorQuantity;
-    case: "tensorQuantity";
-  } | {
-    /**
-     * an element reflected on as its metaclass
-     *
-     * @generated from field: sysml.Metaobject metaobject = 20;
-     */
-    value: Metaobject;
-    case: "metaobject";
-  } | {
-    /**
-     * A result the model leaves open: not an error, but no definite answer.
-     * A value the server sends, never one it accepts.
-     *
-     * @generated from field: sysml.Undetermined undetermined = 21;
-     */
-    value: Undetermined;
-    case: "undetermined";
-  } | { case: undefined; value?: undefined };
-};
-
-/**
- * Describes the message sysml.Value.
- * Use `create(ValueSchema)` to create a new message.
- */
-export const ValueSchema: GenMessage<Value> = /*@__PURE__*/
-  messageDesc(file_sysml, 55);
-
-/**
- * Metaobject is an element of the model held as an instance of its reflective
- * metaclass: what `x meta KerML::Feature`, or the last element of
- * `x.metadata`, evaluates to (KerML 7.4.9.2, 8.3.4.8.15). It crosses as the
- * element it reflects, which is its identity: two metaobjects are the same
- * exactly when element_id is, whatever type each was cast to. Its features
- * (`declaredName`, `ownedFeature`, ...) are read in the model, not carried.
- *
- * @generated from message sysml.Metaobject
- */
-export type Metaobject = Message<"sysml.Metaobject"> & {
-  /**
-   * FQN of the element reflected on ("Vehicle::seatBelt"). Its identity.
-   *
-   * @generated from field: string element_id = 1;
-   */
-  elementId: string;
-
-  /**
-   * FQN of the element's own reflective metaclass ("SysML::Systems::PartUsage"),
-   * the most specific metaclass of the KerML or SysML library classifying it;
-   * not the type it was cast to. The service always sends it; a client may omit
-   * it, in which case the model's is used, but one sent must be the model's or
-   * the value is rejected.
-   *
-   * @generated from field: string metaclass_id = 2;
-   */
-  metaclassId: string;
-};
-
-/**
- * Describes the message sysml.Metaobject.
- * Use `create(MetaobjectSchema)` to create a new message.
- */
-export const MetaobjectSchema: GenMessage<Metaobject> = /*@__PURE__*/
-  messageDesc(file_sysml, 56);
-
-/**
- * Undetermined is the model-level result of an expression the model does not
- * decide: it reads a feature no value is given (`attribute u;`) or counts a
- * feature whose multiplicity fixes no count (`part gear[1..*]`). The
- * expression is well-formed and evaluation succeeded; the answer is open.
- * Every other surface spells it `<undetermined>`.
- *
- * @generated from message sysml.Undetermined
- */
-export type Undetermined = Message<"sysml.Undetermined"> & {
-  /**
-   * Why the model leaves the result open, for a reader:
-   * "u has no value in the model".
-   *
-   * @generated from field: string reason = 1;
-   */
-  reason: string;
-
-  /**
-   * How many values the result would hold, as far as the model fixes it:
-   * `1..1` for an arithmetic result over an unbound scalar, `1..*` for the
-   * elements of a `[1..*]` feature, `0..*` where nothing is known.
-   *
-   * @generated from field: sysml.MultiplicityInfo count = 2;
-   */
-  count?: MultiplicityInfo | undefined;
-};
-
-/**
- * Describes the message sysml.Undetermined.
- * Use `create(UndeterminedSchema)` to create a new message.
- */
-export const UndeterminedSchema: GenMessage<Undetermined> = /*@__PURE__*/
-  messageDesc(file_sysml, 57);
-
-/**
- * Function is a calc held as a value: a calc definition, or a calc usage with
- * an input no read could supply, as `Sq` in `Fn(Sq, 3.0)` or the `f` of
- * `in calc f {...}`. It crosses as the declaration it is a value of, which is
- * its identity: two functions are the same exactly when calc_id and self_id
- * are. A function closing over the bindings of the behavior body it is
- * declared in has no wire form and crosses as the null arm.
- *
- * @generated from message sysml.Function
- */
-export type Function = Message<"sysml.Function"> & {
-  /**
-   * FQN of the calc declaration ("Analysis::Sq"). Its identity.
-   *
-   * @generated from field: string calc_id = 1;
-   */
-  calcId: string;
-
-  /**
-   * ID of the object the calc's feature names resolve against, for a calc
-   * usage read off a part (`holder.scale`); 0 for a function closing over no
-   * object. An instance id, living only within the response that sent it: a
-   * request function with a non-zero self_id is rejected, since no later call
-   * holds that object.
-   *
-   * @generated from field: int64 self_id = 2;
-   */
-  selfId: bigint;
-};
-
-/**
- * Describes the message sysml.Function.
- * Use `create(FunctionSchema)` to create a new message.
- */
-export const FunctionSchema: GenMessage<Function> = /*@__PURE__*/
-  messageDesc(file_sysml, 58);
-
-/**
- * ValueSet is a unique, unordered collection — a Collections::Set's elements —
- * as distinct from a ValueSequence, whose order is part of its value. Two sets
- * are equal when they hold the same elements in any order. The service sends
- * the elements in the runtime's canonical order (Booleans, numbers, strings,
- * quantities, enumeration literals, objects, each class in its own order), so
- * equal sets cross alike; a client may send them in any order, but sending an
- * element twice is rejected rather than read as one, since a repeated element
- * is what a sequence carries.
- *
- * @generated from message sysml.ValueSet
- */
-export type ValueSet = Message<"sysml.ValueSet"> & {
-  /**
-   * @generated from field: repeated sysml.Value elements = 1;
-   */
-  elements: Value[];
-};
-
-/**
- * Describes the message sysml.ValueSet.
- * Use `create(ValueSetSchema)` to create a new message.
- */
-export const ValueSetSchema: GenMessage<ValueSet> = /*@__PURE__*/
-  messageDesc(file_sysml, 59);
-
-/**
- * TensorQuantity is a Quantities::TensorQuantityValue of any rank: its
- * dimensions and, flattened in row-major order under them, one Quantity per
- * component, each with its unit and reduction as a scalar Quantity carries them.
- * A tensor of rank one is not a VectorQuantity, on the wire as in the runtime.
- *
- * @generated from message sysml.TensorQuantity
- */
-export type TensorQuantity = Message<"sysml.TensorQuantity"> & {
-  /**
-   * Positive extents, one per rank; their product (one for rank 0) is how many
-   * components there are, and a tensor not filling them is rejected.
-   *
-   * @generated from field: repeated int64 dimensions = 1;
-   */
-  dimensions: bigint[];
-
-  /**
-   * A named unit sent without its unit_term is rejected as a Quantity's is.
-   *
-   * @generated from field: repeated sysml.Quantity components = 2;
-   */
-  components: Quantity[];
-};
-
-/**
- * Describes the message sysml.TensorQuantity.
- * Use `create(TensorQuantitySchema)` to create a new message.
- */
-export const TensorQuantitySchema: GenMessage<TensorQuantity> = /*@__PURE__*/
-  messageDesc(file_sysml, 60);
-
-/**
- * Array is a Collections::Array: its elements flattened in row-major order
- * under its dimensions, compared by content rather than by the object read.
- *
- * @generated from message sysml.Array
- */
-export type Array = Message<"sysml.Array"> & {
-  /**
-   * Positive extents, one per rank; their product (one for rank 0) is how many
-   * elements there are, and an array not filling them is rejected.
-   *
-   * @generated from field: repeated int64 dimensions = 1;
-   */
-  dimensions: bigint[];
-
-  /**
-   * Any Value each, so an array of quantities or of arrays crosses as such.
-   *
-   * @generated from field: repeated sysml.Value elements = 2;
-   */
-  elements: Value[];
-};
-
-/**
- * Describes the message sysml.Array.
- * Use `create(ArraySchema)` to create a new message.
- */
-export const ArraySchema: GenMessage<Array> = /*@__PURE__*/
-  messageDesc(file_sysml, 61);
-
-/**
- * Vector is a VectorValues::NumericalVectorValue: its components in order,
- * its dimension their number.
- *
- * @generated from message sysml.Vector
- */
-export type Vector = Message<"sysml.Vector"> & {
-  /**
-   * Each an int_value or a real_value, kept apart as the rest of Value does;
-   * a component of any other arm is rejected rather than read as a number.
-   *
-   * @generated from field: repeated sysml.Value components = 1;
-   */
-  components: Value[];
-};
-
-/**
- * Describes the message sysml.Vector.
- * Use `create(VectorSchema)` to create a new message.
- */
-export const VectorSchema: GenMessage<Vector> = /*@__PURE__*/
-  messageDesc(file_sysml, 62);
-
-/**
- * VectorQuantity is a Quantities::VectorQuantityValue: one Quantity per axis,
- * unit and reduction included, since the axes need not share a unit.
- *
- * @generated from message sysml.VectorQuantity
- */
-export type VectorQuantity = Message<"sysml.VectorQuantity"> & {
-  /**
-   * At least one (num is Number[1..*]); a named unit sent without its
-   * unit_term is rejected as a Quantity's is.
-   *
-   * @generated from field: repeated sysml.Quantity components = 1;
-   */
-  components: Quantity[];
-};
-
-/**
- * Describes the message sysml.VectorQuantity.
- * Use `create(VectorQuantitySchema)` to create a new message.
- */
-export const VectorQuantitySchema: GenMessage<VectorQuantity> = /*@__PURE__*/
-  messageDesc(file_sysml, 63);
-
-/**
- * Complex is one complex number in rectangular form. It crosses as one value
- * so `1.0 + 2.0i` cannot be mistaken for a sequence of two Reals.
- *
- * @generated from message sysml.Complex
- */
-export type Complex = Message<"sysml.Complex"> & {
-  /**
-   * @generated from field: double real = 1;
-   */
-  real: number;
-
-  /**
-   * @generated from field: double imaginary = 2;
-   */
-  imaginary: number;
-};
-
-/**
- * Describes the message sysml.Complex.
- * Use `create(ComplexSchema)` to create a new message.
- */
-export const ComplexSchema: GenMessage<Complex> = /*@__PURE__*/
-  messageDesc(file_sysml, 64);
-
-/**
- * EnumLiteral is one literal of an enumeration definition. A literal is its own
- * identity, so it travels as the declaration it names rather than as a number
- * or a string: two values are the same literal exactly when `literal_id` is.
- *
- * @generated from message sysml.EnumLiteral
- */
-export type EnumLiteral = Message<"sysml.EnumLiteral"> & {
-  /**
-   * FQN of the literal's declaration ("D::Color::red"). Its identity.
-   *
-   * @generated from field: string literal_id = 1;
-   */
-  literalId: string;
-
-  /**
-   * FQN of the enumeration definition declaring it ("D::Color").
-   *
-   * @generated from field: string enumeration_id = 2;
-   */
-  enumerationId: string;
-
-  /**
-   * The literal as a reader writes it ("Color::red").
-   *
-   * @generated from field: string name = 3;
-   */
-  name: string;
-
-  /**
-   * The scalar the literal equals (`high = 3` carries int_value 3); unset for a
-   * literal that is only its identity. Identity stays `literal_id`.
-   *
-   * @generated from field: sysml.Value value = 4;
-   */
-  value?: Value | undefined;
-};
-
-/**
- * Describes the message sysml.EnumLiteral.
- * Use `create(EnumLiteralSchema)` to create a new message.
- */
-export const EnumLiteralSchema: GenMessage<EnumLiteral> = /*@__PURE__*/
-  messageDesc(file_sysml, 65);
-
-/**
- * @generated from message sysml.ValueSequence
- */
-export type ValueSequence = Message<"sysml.ValueSequence"> & {
-  /**
-   * @generated from field: repeated sysml.Value elements = 1;
-   */
-  elements: Value[];
-};
-
-/**
- * Describes the message sysml.ValueSequence.
- * Use `create(ValueSequenceSchema)` to create a new message.
- */
-export const ValueSequenceSchema: GenMessage<ValueSequence> = /*@__PURE__*/
-  messageDesc(file_sysml, 66);
-
-/**
- * Quantity is a magnitude and the measurement reference it is expressed in, sent
- * in the unit written: `5.4 [km/h]` crosses as 5.4 `km/h`, not as 1.5 `m/s`.
- *
- * @generated from message sysml.Quantity
- */
-export type Quantity = Message<"sysml.Quantity"> & {
-  /**
-   * Magnitude, keeping Integer and Real apart as the rest of Value does.
-   *
-   * @generated from oneof sysml.Quantity.magnitude
-   */
-  magnitude: {
-    /**
-     * @generated from field: int64 int_magnitude = 1;
-     */
-    value: bigint;
-    case: "intMagnitude";
-  } | {
-    /**
-     * @generated from field: double real_magnitude = 2;
-     */
-    value: number;
-    case: "realMagnitude";
-  } | { case: undefined; value?: undefined };
-
-  /**
-   * Unit as written ("km/h") or as an operation composed it ("m/s"); empty for
-   * one never written down, described by unit_term alone.
-   *
-   * @generated from field: string unit = 3;
-   */
-  unit: string;
-
-  /**
-   * What the unit reduces to, which decides commensurability and conversion.
-   * Required wherever `unit` names one: a named unit sent without its reduction
-   * is rejected rather than read as dimension one.
-   *
-   * @generated from field: sysml.UnitTerm unit_term = 4;
-   */
-  unitTerm?: UnitTerm | undefined;
-};
-
-/**
- * Describes the message sysml.Quantity.
- * Use `create(QuantitySchema)` to create a new message.
- */
-export const QuantitySchema: GenMessage<Quantity> = /*@__PURE__*/
-  messageDesc(file_sysml, 67);
-
-/**
- * MeasurementRef is a MeasurementReferences::ScalarMeasurementReference held as
- * a value: a unit by itself — `SI::m`, `km`, or `m / s` as an operation composed
- * it — as distinct from a Quantity expressed in one. It carries what the runtime
- * value carries: the unit as written and what it reduces to, plus the one
- * declaration it names when it names one.
- *
- * @generated from message sysml.MeasurementRef
- */
-export type MeasurementRef = Message<"sysml.MeasurementRef"> & {
-  /**
-   * Unit as written ("km") or as an operation composed it ("m/s"); empty for
-   * one never written down, described by unit_term alone.
-   *
-   * @generated from field: string unit = 1;
-   */
-  unit: string;
-
-  /**
-   * What the unit reduces to. Required wherever `unit` names one, as a
-   * Quantity's is: a named unit sent without its reduction is rejected rather
-   * than read as dimension one.
-   *
-   * @generated from field: sysml.UnitTerm unit_term = 2;
-   */
-  unitTerm?: UnitTerm | undefined;
-
-  /**
-   * FQN of the one unit declaration the reference names ("SI::kilometre"), which
-   * is what tells the declared unit `km` from any other spelling of 1000 metres;
-   * empty for a unit composed of several ("m/s"), which names no declaration.
-   * The service always sends it for a named unit. A client may omit it, in
-   * which case `unit` is read as a Quantity's is; sent, it must name a
-   * measurement unit of the model that reduces to unit_term and that `unit`
-   * spells, or the value is rejected.
-   *
-   * @generated from field: string unit_id = 3;
-   */
-  unitId: string;
-};
-
-/**
- * Describes the message sysml.MeasurementRef.
- * Use `create(MeasurementRefSchema)` to create a new message.
- */
-export const MeasurementRefSchema: GenMessage<MeasurementRef> = /*@__PURE__*/
-  messageDesc(file_sysml, 68);
-
-/**
- * UnitTerm is a unit reduced to a scale factor over base units: `km/h` reduces
- * to 1000/3600 over `SI::m` and `SI::s^-1`.
- *
- * @generated from message sysml.UnitTerm
- */
-export type UnitTerm = Message<"sysml.UnitTerm"> & {
-  /**
-   * Scale as an unevaluated ratio, so an exact conversion stays exact.
-   *
-   * @generated from field: double scale_num = 1;
-   */
-  scaleNum: number;
-
-  /**
-   * @generated from field: double scale_den = 2;
-   */
-  scaleDen: number;
-
-  /**
-   * Base units of the reduction, carrying no zero exponents.
-   *
-   * @generated from field: repeated sysml.UnitFactor factors = 3;
-   */
-  factors: UnitFactor[];
-};
-
-/**
- * Describes the message sysml.UnitTerm.
- * Use `create(UnitTermSchema)` to create a new message.
- */
-export const UnitTermSchema: GenMessage<UnitTerm> = /*@__PURE__*/
-  messageDesc(file_sysml, 69);
-
-/**
- * UnitFactor is one base unit raised to an exponent.
- *
- * @generated from message sysml.UnitFactor
- */
-export type UnitFactor = Message<"sysml.UnitFactor"> & {
-  /**
-   * FQN of the base unit ("SI::m"), naming it across processes.
-   *
-   * @generated from field: string unit_id = 1;
-   */
-  unitId: string;
-
-  /**
-   * @generated from field: double exponent = 2;
-   */
-  exponent: number;
-};
-
-/**
- * Describes the message sysml.UnitFactor.
- * Use `create(UnitFactorSchema)` to create a new message.
- */
-export const UnitFactorSchema: GenMessage<UnitFactor> = /*@__PURE__*/
-  messageDesc(file_sysml, 70);
-
-/**
- * Diagnostic represents a parse/semantic error or warning
- *
- * @generated from message sysml.Diagnostic
- */
-export type Diagnostic = Message<"sysml.Diagnostic"> & {
-  /**
-   * "error", "warning", "info"
-   *
-   * @generated from field: string severity = 1;
-   */
-  severity: string;
-
-  /**
-   * @generated from field: string message = 2;
-   */
-  message: string;
-
-  /**
-   * @generated from field: sysml.Span span = 3;
-   */
-  span?: Span | undefined;
-
-  /**
-   * Stable identifier to branch on instead of the message: a pass or rule code,
-   * "syntax", "choice-point", "guard-unevaluable"; empty when none was assigned.
-   *
-   * @generated from field: string code = 4;
-   */
-  code: string;
-};
-
-/**
- * Describes the message sysml.Diagnostic.
- * Use `create(DiagnosticSchema)` to create a new message.
- */
-export const DiagnosticSchema: GenMessage<Diagnostic> = /*@__PURE__*/
-  messageDesc(file_sysml, 71);
-
-/**
- * Span represents a source location
- *
- * @generated from message sysml.Span
- */
-export type Span = Message<"sysml.Span"> & {
-  /**
-   * @generated from field: string file = 1;
-   */
-  file: string;
-
-  /**
-   * @generated from field: int32 start_line = 2;
-   */
-  startLine: number;
-
-  /**
-   * @generated from field: int32 start_col = 3;
-   */
-  startCol: number;
-
-  /**
-   * @generated from field: int32 end_line = 4;
-   */
-  endLine: number;
-
-  /**
-   * @generated from field: int32 end_col = 5;
-   */
-  endCol: number;
-};
-
-/**
- * Describes the message sysml.Span.
- * Use `create(SpanSchema)` to create a new message.
- */
-export const SpanSchema: GenMessage<Span> = /*@__PURE__*/
-  messageDesc(file_sysml, 72);
-
-/**
- * ServerInfoRequest asks the service to describe itself. It carries no fields;
- * the answer does not depend on the caller.
- *
- * @generated from message sysml.ServerInfoRequest
- */
-export type ServerInfoRequest = Message<"sysml.ServerInfoRequest"> & {
-};
-
-/**
- * Describes the message sysml.ServerInfoRequest.
- * Use `create(ServerInfoRequestSchema)` to create a new message.
- */
-export const ServerInfoRequestSchema: GenMessage<ServerInfoRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 73);
-
-/**
- * ServerInfoResponse describes the running service.
- *
- * @generated from message sysml.ServerInfoResponse
- */
-export type ServerInfoResponse = Message<"sysml.ServerInfoResponse"> & {
-  /**
-   * Build version of the service binary, informational only: it is "dev" for a
-   * build without release ldflags, and versions of forks are not comparable.
-   * Feature decisions belong in `capabilities`.
-   *
-   * @generated from field: string version = 1;
-   */
-  version: string;
-
-  /**
-   * Named capabilities this build supports, each an exact string a client can
-   * require. Capabilities are only ever added, never renamed or removed with
-   * their behaviour intact, so requiring one is stable across releases.
-   * Currently defined:
-   *   "type_facts" - SymbolInfo carries type_info, multiplicity and
-   *                  specializations, which typed code generation needs.
-   *   "convert"    - the Convert RPC writes a model back out as SysML notation
-   *                  or RDF Turtle.
-   *   "verification" - the VerifyConstraint, VerifyRequirement,
-   *                  VerifySatisfaction and EvaluateCalc RPCs answer the
-   *                  questions the REPL's %constraint, %requirement, %satisfy
-   *                  and %calc answer.
-   *   "query"      - the Query RPC evaluates a SysML v2 API & Services Query.
-   *   "oslc_query" - the Query RPC evaluates OSLC Query text.
-   *   "enum_values" - a Value carries an enumeration literal as enum_literal,
-   *                  rather than reporting it as an unsupported null.
-   *   "unset_value" - a valueless feature of a value type is reported as
-   *                  Value.unset, rather than as the empty object it
-   *                  materializes.
-   *   "verification_verdicts" - the VerifyRequirement, VerifySatisfaction and
-   *                  RunAnalysis RPCs report what the body of a verification
-   *                  case answered as verification_verdicts, and RunAnalysis
-   *                  accepts a verification case.
-   *   "complex_values" - a Value carries a complex number as complex, rather
-   *                  than reporting it as an unsupported null, and a complex
-   *                  action input or calc argument is accepted; without it,
-   *                  one is refused with UNIMPLEMENTED rather than read as
-   *                  another value.
-   *   "structured_values" - a Value carries a Collections::Array, a numerical
-   *                  vector and a vector quantity as array, vector and
-   *                  vector_quantity, shape and units intact, rather than
-   *                  reporting them as unsupported nulls, and one is accepted
-   *                  as an action input or calc argument; without it, one is
-   *                  refused with UNIMPLEMENTED rather than read as another
-   *                  value.
-   *   "measurement_refs" - a Value carries a bare measurement reference (a
-   *                  unit by itself, `SI::m` or `m / s`) as measurement_ref,
-   *                  unit text, reduction and declaration intact, rather than
-   *                  reporting it as an unsupported null, and one is accepted
-   *                  as an action input or calc argument; without it, one is
-   *                  refused with UNIMPLEMENTED rather than read as another
-   *                  value. Separate from structured_values, which a client
-   *                  built before this arm existed may already claim.
-   *   "function_values" - a Value carries a calc held as a value as function,
-   *                  named by its declaration, rather than reporting it as an
-   *                  unsupported null, and one is accepted as an action input
-   *                  or calc argument; without it, one is refused with
-   *                  UNIMPLEMENTED rather than read as another value.
-   *   "set_values" - a Value carries a unique, unordered collection (a
-   *                  Collections::Set's elements) as set, each element once in
-   *                  canonical order, rather than reporting it as an
-   *                  unsupported null, and one is accepted as an action input
-   *                  or calc argument in any order; without it, one is refused
-   *                  with UNIMPLEMENTED rather than read as a sequence.
-   *   "tensor_values" - a Value carries a tensor quantity of any rank as
-   *                  tensor_quantity, its dimensions and one Quantity per
-   *                  row-major component, rather than reporting it as an
-   *                  unsupported null, and one is accepted as an action input
-   *                  or calc argument; without it, one is refused with
-   *                  UNIMPLEMENTED rather than read as another value.
-   *   "metaobject_values" - a Value carries an element reflected on as an
-   *                  instance of its metaclass (`x meta T`, the last element
-   *                  of `x.metadata`) as metaobject, named by the element and
-   *                  its metaclass, rather than reporting it as an unsupported
-   *                  null, and one is accepted as an action input or calc
-   *                  argument; without it, one is refused with UNIMPLEMENTED
-   *                  rather than read as another value.
-   *   "apply_edits" - the ApplyEdits RPC edits a parsed model's own source,
-   *                  preserving everything the edit did not touch.
-   *   "document_query" - the RunDocumentQuery RPC runs a named document query
-   *                  and answers with typed rows.
-   *   "render_document" - the RenderDocument RPC renders a named document to
-   *                  Markdown.
-   *   "diagnostic_codes" - Diagnostic.code is populated, so an empty code is a
-   *                  finding none was assigned; without it every code is empty.
-   *   "schedule"     - ExecuteActionRequest, ExecuteStateRequest and
-   *                  RunAnalysisRequest take a schedule, the scheduling policy
-   *                  the run resolves its choice points under; without it a
-   *                  service drops the field and runs under the default, so a
-   *                  client must not send one.
-   *   "case_evaluations" - RunAnalysis and each RunSweep row report each
-   *                  application the run made of one of the case's calcs as a
-   *                  function value — a trade study's evaluation of each
-   *                  alternative — as evaluations, and keep the outputs and
-   *                  evaluations a failed run made beside its error.
-   *   "schedule_explore" - the schedule "explore[:runs=<n>,depth=<d>]" is
-   *                  accepted, and the response carries every distinct outcome
-   *                  as `outcomes` with an `exploration` status; without it the
-   *                  spelling is INVALID_ARGUMENT.
-   *   "final_time"   - ExecuteActionResponse and ExecuteStateResponse report
-   *                  final_time, the run's simulation clock when it ended;
-   *                  without it the field is 0 whatever the run waited on.
-   *   "engines"      - the ListEngines RPC lists the analysis engines; the
-   *                  verification and sweep requests take an `engine`, the
-   *                  engine the question is put to, unset meaning "auto"; and
-   *                  their responses and every Verdict report the `engine` that
-   *                  answered with the `strength` of its evidence and the
-   *                  `bounds` it ran under. Without it a service drops the
-   *                  request field and answers under "auto", so a client must
-   *                  not send one.
-   *   "engines_external" - the service was started with -serve-external-engines
-   *                  and runs the OPENSYSML_ENGINES manifest engines it names;
-   *                  ListEngines reports which with `served`. Without it every
-   *                  manifest engine is listed but a request naming one is
-   *                  refused with FAILED_PRECONDITION.
-   *
-   * @generated from field: repeated string capabilities = 2;
-   */
-  capabilities: string[];
-};
-
-/**
- * Describes the message sysml.ServerInfoResponse.
- * Use `create(ServerInfoResponseSchema)` to create a new message.
- */
-export const ServerInfoResponseSchema: GenMessage<ServerInfoResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 74);
-
-/**
- * QueryRequest runs a Query against a model the service already parsed.
- *
- * @generated from message sysml.QueryRequest
- */
-export type QueryRequest = Message<"sysml.QueryRequest"> & {
-  /**
-   * from ParseFile response
-   *
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * @generated from field: sysml.Query query = 2;
-   */
-  query?: Query | undefined;
-
-  /**
-   * OSLC Query 3.0 parameter text, mutually exclusive with query.
-   *
-   * @generated from field: string oslc_query = 3;
-   */
-  oslcQuery: string;
-};
-
-/**
- * Describes the message sysml.QueryRequest.
- * Use `create(QueryRequestSchema)` to create a new message.
- */
-export const QueryRequestSchema: GenMessage<QueryRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 75);
-
-/**
- * QueryResponse contains the elements the query selected, in the order they are
- * declared in the model. A query that matches nothing answers with no elements;
- * a query the service cannot evaluate fails the call with INVALID_ARGUMENT
- * rather than answering empty.
- *
- * @generated from message sysml.QueryResponse
- */
-export type QueryResponse = Message<"sysml.QueryResponse"> & {
-  /**
-   * @generated from field: repeated sysml.QueryResultElement elements = 1;
-   */
-  elements: QueryResultElement[];
-};
-
-/**
- * Describes the message sysml.QueryResponse.
- * Use `create(QueryResponseSchema)` to create a new message.
- */
-export const QueryResponseSchema: GenMessage<QueryResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 76);
-
-/**
- * Query is the standard's Query resource (SysML v2 API & Services). Its `@type`
- * is fixed and carries no information on the wire, and its identity fields
- * (`@id`, `owningProject`) belong to a repository that stores queries, which
- * this service is not: it evaluates a query against a parsed model.
- *
- * @generated from message sysml.Query
- */
-export type Query = Message<"sysml.Query"> & {
-  /**
-   * Elements the query considers, each named by qualified name, together with
-   * every element nested inside them. Empty considers the whole loaded model.
-   * Corresponds to the standard's `scope` of DataIdentity references.
-   *
-   * @generated from field: repeated string scope = 1;
-   */
-  scope: string[];
-
-  /**
-   * Properties to report for each matched element. Empty reports all of them.
-   *
-   * @generated from field: repeated string select = 2;
-   */
-  select: string[];
-
-  /**
-   * Filter every considered element must satisfy. Unset matches all of them.
-   *
-   * @generated from field: sysml.Constraint where = 3;
-   */
-  where?: Constraint | undefined;
-};
-
-/**
- * Describes the message sysml.Query.
- * Use `create(QuerySchema)` to create a new message.
- */
-export const QuerySchema: GenMessage<Query> = /*@__PURE__*/
-  messageDesc(file_sysml, 77);
-
-/**
- * Constraint is the standard's Constraint, whose `@type` discriminates between
- * its two forms. On the wire the form is the oneof arm, not a type tag.
- *
- * @generated from message sysml.Constraint
- */
-export type Constraint = Message<"sysml.Constraint"> & {
-  /**
-   * @generated from oneof sysml.Constraint.constraint
-   */
-  constraint: {
-    /**
-     * @generated from field: sysml.PrimitiveConstraint primitive = 1;
-     */
-    value: PrimitiveConstraint;
-    case: "primitive";
-  } | {
-    /**
-     * @generated from field: sysml.CompositeConstraint composite = 2;
-     */
-    value: CompositeConstraint;
-    case: "composite";
-  } | { case: undefined; value?: undefined };
-};
-
-/**
- * Describes the message sysml.Constraint.
- * Use `create(ConstraintSchema)` to create a new message.
- */
-export const ConstraintSchema: GenMessage<Constraint> = /*@__PURE__*/
-  messageDesc(file_sysml, 78);
-
-/**
- * PrimitiveConstraint compares one property of an element against a value.
- *
- * @generated from message sysml.PrimitiveConstraint
- */
-export type PrimitiveConstraint = Message<"sysml.PrimitiveConstraint"> & {
-  /**
-   * Negates the comparison's verdict.
-   *
-   * @generated from field: bool inverse = 1;
-   */
-  inverse: boolean;
-
-  /**
-   * Property to compare, as named in docs/reference/api.md's query property table
-   * ("@type", "name", "qualifiedName", ...). An unknown one fails the call.
-   *
-   * @generated from field: string property = 2;
-   */
-  property: string;
-
-  /**
-   * @generated from field: sysml.PrimitiveOperator operator = 3;
-   */
-  operator: PrimitiveOperator;
-
-  /**
-   * Values to compare against. The standard writes one value, and its clients
-   * also write a list for `@type`; with `=` the element matches any of them,
-   * while `>` and `<` require exactly one.
-   *
-   * @generated from field: repeated string value = 4;
-   */
-  value: string[];
-};
-
-/**
- * Describes the message sysml.PrimitiveConstraint.
- * Use `create(PrimitiveConstraintSchema)` to create a new message.
- */
-export const PrimitiveConstraintSchema: GenMessage<PrimitiveConstraint> = /*@__PURE__*/
-  messageDesc(file_sysml, 79);
-
-/**
- * CompositeConstraint combines constraints. An empty constraint list fails the
- * call: it has no defensible verdict.
- *
- * @generated from message sysml.CompositeConstraint
- */
-export type CompositeConstraint = Message<"sysml.CompositeConstraint"> & {
-  /**
-   * @generated from field: sysml.CompositeOperator operator = 1;
-   */
-  operator: CompositeOperator;
-
-  /**
-   * @generated from field: repeated sysml.Constraint constraint = 2;
-   */
-  constraint: Constraint[];
-};
-
-/**
- * Describes the message sysml.CompositeConstraint.
- * Use `create(CompositeConstraintSchema)` to create a new message.
- */
-export const CompositeConstraintSchema: GenMessage<CompositeConstraint> = /*@__PURE__*/
-  messageDesc(file_sysml, 80);
-
-/**
- * QueryResultElement is one matched element. `id` and `type` are always
- * reported, since an element record is not usable without them; `properties`
- * carries what `select` asked for, omitting a property the element does not
- * have.
- *
- * @generated from message sysml.QueryResultElement
- */
-export type QueryResultElement = Message<"sysml.QueryResultElement"> & {
-  /**
-   * qualified name, the identity this service reports
-   *
-   * @generated from field: string id = 1;
-   */
-  id: string;
-
-  /**
-   * metamodel type name ("PartUsage", "PartDefinition", ...)
-   *
-   * @generated from field: string type = 2;
-   */
-  type: string;
-
-  /**
-   * @generated from field: map<string, string> properties = 3;
-   */
-  properties: { [key: string]: string };
-};
-
-/**
- * Describes the message sysml.QueryResultElement.
- * Use `create(QueryResultElementSchema)` to create a new message.
- */
-export const QueryResultElementSchema: GenMessage<QueryResultElement> = /*@__PURE__*/
-  messageDesc(file_sysml, 81);
-
-/**
- * SweepRange is one parameter's range: the endpoints a swept run advances
- * between and the step it advances by, typed by the parameter it binds rather
- * than by how they are written. A sampled range states no step; a swept range
- * between whole numbers steps by one where it states none, and one with a
- * fractional endpoint must state one.
- *
- * @generated from message sysml.SweepRange
- */
-export type SweepRange = Message<"sysml.SweepRange"> & {
-  /**
-   * Name of the input parameter the range binds, which the target must declare
-   * and the request's own arguments must not bind.
-   *
-   * @generated from field: string parameter = 1;
-   */
-  parameter: string;
-
-  /**
-   * The endpoint the range's runs start at, and the one they run to, which is
-   * inclusive where the step lands on it. Both are required.
-   *
-   * @generated from field: sysml.Value start = 2;
-   */
-  start?: Value | undefined;
-
-  /**
-   * @generated from field: sysml.Value end = 3;
-   */
-  end?: Value | undefined;
-
-  /**
-   * @generated from field: sysml.Value step = 4;
-   */
-  step?: Value | undefined;
-};
-
-/**
- * Describes the message sysml.SweepRange.
- * Use `create(SweepRangeSchema)` to create a new message.
- */
-export const SweepRangeSchema: GenMessage<SweepRange> = /*@__PURE__*/
-  messageDesc(file_sysml, 82);
-
-/**
- * RunSweepRequest runs one analysis case or calc once per row of a sweep. Every
- * row is an ordinary run of that target with the swept parameters bound to the
- * row's values and the request's other arguments as given: this is tool-defined
- * orchestration, and changes nothing about how a run executes.
- *
- * @generated from message sysml.RunSweepRequest
- */
-export type RunSweepRequest = Message<"sysml.RunSweepRequest"> & {
-  /**
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * FQN of the analysis case or calc, definition or usage.
-   *
-   * @generated from field: string symbol_id = 2;
-   */
-  symbolId: string;
-
-  /**
-   * Optional FQN of a part/usage to instantiate as an analysis case's subject.
-   *
-   * @generated from field: string subject_symbol_id = 3;
-   */
-  subjectSymbolId: string;
-
-  /**
-   * Positional arguments for the target's input parameters, in declaration
-   * order, as every row binds them.
-   *
-   * @generated from field: repeated sysml.Value arguments = 4;
-   */
-  arguments: Value[];
-
-  /**
-   * Arguments bound to input parameters by name, as every row binds them.
-   *
-   * @generated from field: map<string, sysml.Value> named_arguments = 5;
-   */
-  namedArguments: { [key: string]: Value };
-
-  /**
-   * The ranges swept, which several of make one row per point of their
-   * cartesian product, the first varying slowest.
-   *
-   * @generated from field: repeated sysml.SweepRange ranges = 6;
-   */
-  ranges: SweepRange[];
-
-  /**
-   * Rows to draw uniformly from each range instead of stepping through it. 0
-   * steps through them; above 0 needs a seed and refuses a range with a step.
-   *
-   * @generated from field: int64 samples = 7;
-   */
-  samples: bigint;
-
-  /**
-   * Seed the draws are taken from: the same seed draws the same table on every
-   * platform. Required with `samples`, ignored without it.
-   *
-   * @generated from field: uint64 seed = 8;
-   */
-  seed: bigint;
-
-  /**
-   * The engine the sweep is put to; see VerifyConstraintRequest.engine.
-   *
-   * @generated from field: string engine = 9;
-   */
-  engine: string;
-};
-
-/**
- * Describes the message sysml.RunSweepRequest.
- * Use `create(RunSweepRequestSchema)` to create a new message.
- */
-export const RunSweepRequestSchema: GenMessage<RunSweepRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 83);
-
-/**
- * SweepRow is one run of a sweep: what it bound, what it produced, how long it
- * took, and what stopped it where it failed. A failed row is reported as a row,
- * so one failure does not end the table.
- *
- * @generated from message sysml.SweepRow
- */
-export type SweepRow = Message<"sysml.SweepRow"> & {
-  /**
-   * The parameters this row bound, in the order their ranges were given.
-   *
-   * @generated from field: repeated sysml.CalcOutput inputs = 1;
-   */
-  inputs: CalcOutput[];
-
-  /**
-   * What the run produced: an analysis case's outputs, or a calc's returned
-   * value named "result".
-   *
-   * @generated from field: repeated sysml.CalcOutput outputs = 2;
-   */
-  outputs: CalcOutput[];
-
-  /**
-   * What the case's objective and assertions decided; empty for a calc.
-   *
-   * @generated from field: repeated sysml.Verdict verdicts = 3;
-   */
-  verdicts: Verdict[];
-
-  /**
-   * Wall time of this run, in microseconds.
-   *
-   * @generated from field: int64 elapsed_micros = 4;
-   */
-  elapsedMicros: bigint;
-
-  /**
-   * Set when this run failed rather than the table failing. The outputs and
-   * evaluations the run made before failing are still reported beside it.
-   *
-   * @generated from field: string error = 5;
-   */
-  error: string;
-
-  /**
-   * What kind of failure `error` reports.
-   *
-   * @generated from field: sysml.FailureReason failure_reason = 6;
-   */
-  failureReason: FailureReason;
-
-  /**
-   * Each application this run made of one of the case's own calcs as a
-   * function value, as RunAnalysisResponse.evaluations reports them.
-   *
-   * @generated from field: repeated sysml.CaseEvaluation evaluations = 7;
-   */
-  evaluations: CaseEvaluation[];
-};
-
-/**
- * Describes the message sysml.SweepRow.
- * Use `create(SweepRowSchema)` to create a new message.
- */
-export const SweepRowSchema: GenMessage<SweepRow> = /*@__PURE__*/
-  messageDesc(file_sysml, 84);
-
-/**
- * RunSweepResponse carries the table, one row per run, in the order the runs
- * were made: lexicographically over the ranges as given for a swept table, and
- * in draw order for a sampled one.
- *
- * @generated from message sysml.RunSweepResponse
- */
-export type RunSweepResponse = Message<"sysml.RunSweepResponse"> & {
-  /**
-   * @generated from field: repeated sysml.SweepRow rows = 1;
-   */
-  rows: SweepRow[];
-
-  /**
-   * The swept parameters, in the order their ranges were given, which is the
-   * order each row's inputs are in.
-   *
-   * @generated from field: repeated string parameters = 2;
-   */
-  parameters: string[];
-
-  /**
-   * Whether the rows were drawn rather than stepped through.
-   *
-   * @generated from field: bool sampled = 3;
-   */
-  sampled: boolean;
-
-  /**
-   * The seed the rows were drawn from, echoed so a table can be reproduced.
-   *
-   * @generated from field: uint64 seed = 4;
-   */
-  seed: bigint;
-
-  /**
-   * Set when no run was made at all — an unknown symbol, a parameter the target
-   * does not declare, a range no values follow from, a budget refusal.
-   *
-   * @generated from field: string error = 5;
-   */
-  error: string;
-
-  /**
-   * @generated from field: repeated sysml.Diagnostic diagnostics = 6;
-   */
-  diagnostics: Diagnostic[];
-
-  /**
-   * What kind of failure `error` reports.
-   *
-   * @generated from field: sysml.FailureReason failure_reason = 7;
-   */
-  failureReason: FailureReason;
-
-  /**
-   * Every object a row's verdict is about, from every run of the table, so each
-   * verdict's `instance_id` resolves here.
-   *
-   * @generated from field: repeated sysml.Instance instances = 8;
-   */
-  instances: Instance[];
-
-  /**
-   * The engine that ran the table, the strength of its evidence and the bounds
-   * it ran under, as Verdict carries them; each row's verdicts carry the same.
-   * Empty when no run was made. Reported as the "engines" capability.
-   *
-   * @generated from field: string engine = 9;
-   */
-  engine: string;
-
-  /**
-   * @generated from field: string strength = 10;
-   */
-  strength: string;
-
-  /**
-   * @generated from field: repeated sysml.Bound bounds = 11;
-   */
-  bounds: Bound[];
-};
-
-/**
- * Describes the message sysml.RunSweepResponse.
- * Use `create(RunSweepResponseSchema)` to create a new message.
- */
-export const RunSweepResponseSchema: GenMessage<RunSweepResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 85);
-
-/**
- * RunDocumentQueryRequest runs a named document query — a calc def
- * specializing DocumentQueries::Query — against a model the service already
- * parsed, binding its entry parameters. It answers as %run-query does, but with
- * typed rows rather than formatted lines.
- *
- * @generated from message sysml.RunDocumentQueryRequest
- */
-export type RunDocumentQueryRequest = Message<"sysml.RunDocumentQueryRequest"> & {
-  /**
-   * from ParseFile response
-   *
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * FQN of the document query to run. Fails with NOT_FOUND when the model does
-   * not declare it, and INVALID_ARGUMENT when it declares something else.
-   *
-   * @generated from field: string query_id = 2;
-   */
-  queryId: string;
-
-  /**
-   * Entry-parameter bindings. Repeating a parameter appends to its binding, so
-   * a nonscalar parameter can be given several values.
-   *
-   * @generated from field: repeated sysml.DocumentQueryBinding bindings = 3;
-   */
-  bindings: DocumentQueryBinding[];
-};
-
-/**
- * Describes the message sysml.RunDocumentQueryRequest.
- * Use `create(RunDocumentQueryRequestSchema)` to create a new message.
- */
-export const RunDocumentQueryRequestSchema: GenMessage<RunDocumentQueryRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 86);
-
-/**
- * DocumentQueryBinding binds one entry parameter of a document query.
- *
- * @generated from message sysml.DocumentQueryBinding
- */
-export type DocumentQueryBinding = Message<"sysml.DocumentQueryBinding"> & {
-  /**
-   * @generated from field: string parameter = 1;
-   */
-  parameter: string;
-
-  /**
-   * @generated from field: repeated sysml.DocumentValue values = 2;
-   */
-  values: DocumentValue[];
-};
-
-/**
- * Describes the message sysml.DocumentQueryBinding.
- * Use `create(DocumentQueryBindingSchema)` to create a new message.
- */
-export const DocumentQueryBindingSchema: GenMessage<DocumentQueryBinding> = /*@__PURE__*/
-  messageDesc(file_sysml, 87);
-
-/**
- * DocumentValue is one typed document-query value. A request binds a model
- * element by qualified name in element_id; a response also says what the
- * element is in element_type. `infinity` denotes an unbounded multiplicity and
- * is only ever answered, never bound.
- *
- * @generated from message sysml.DocumentValue
- */
-export type DocumentValue = Message<"sysml.DocumentValue"> & {
-  /**
-   * @generated from oneof sysml.DocumentValue.kind
-   */
-  kind: {
-    /**
-     * qualified name of a model element
-     *
-     * @generated from field: string element_id = 1;
-     */
-    value: string;
-    case: "elementId";
-  } | {
-    /**
-     * @generated from field: string string_value = 2;
-     */
-    value: string;
-    case: "stringValue";
-  } | {
-    /**
-     * @generated from field: int64 int_value = 3;
-     */
-    value: bigint;
-    case: "intValue";
-  } | {
-    /**
-     * @generated from field: double real_value = 4;
-     */
-    value: number;
-    case: "realValue";
-  } | {
-    /**
-     * @generated from field: bool bool_value = 5;
-     */
-    value: boolean;
-    case: "boolValue";
-  } | {
-    /**
-     * @generated from field: bool infinity = 6;
-     */
-    value: boolean;
-    case: "infinity";
-  } | {
-    /**
-     * magnitude in a unit, `2290000 [kg]`
-     *
-     * @generated from field: sysml.Quantity quantity = 8;
-     */
-    value: Quantity;
-    case: "quantity";
-  } | { case: undefined; value?: undefined };
-
-  /**
-   * Metamodel type of element_id ("PartUsage", ...); answered, ignored when bound.
-   *
-   * @generated from field: string element_type = 7;
-   */
-  elementType: string;
-};
-
-/**
- * Describes the message sysml.DocumentValue.
- * Use `create(DocumentValueSchema)` to create a new message.
- */
-export const DocumentValueSchema: GenMessage<DocumentValue> = /*@__PURE__*/
-  messageDesc(file_sysml, 88);
-
-/**
- * DocumentQueryColumn is one projected property, in projection order.
- *
- * @generated from message sysml.DocumentQueryColumn
- */
-export type DocumentQueryColumn = Message<"sysml.DocumentQueryColumn"> & {
-  /**
-   * @generated from field: string name = 1;
-   */
-  name: string;
-};
-
-/**
- * Describes the message sysml.DocumentQueryColumn.
- * Use `create(DocumentQueryColumnSchema)` to create a new message.
- */
-export const DocumentQueryColumnSchema: GenMessage<DocumentQueryColumn> = /*@__PURE__*/
-  messageDesc(file_sysml, 89);
-
-/**
- * DocumentQueryCell is one row's values for one column, in the query's order.
- *
- * @generated from message sysml.DocumentQueryCell
- */
-export type DocumentQueryCell = Message<"sysml.DocumentQueryCell"> & {
-  /**
-   * @generated from field: repeated sysml.DocumentValue values = 1;
-   */
-  values: DocumentValue[];
-};
-
-/**
- * Describes the message sysml.DocumentQueryCell.
- * Use `create(DocumentQueryCellSchema)` to create a new message.
- */
-export const DocumentQueryCellSchema: GenMessage<DocumentQueryCell> = /*@__PURE__*/
-  messageDesc(file_sysml, 90);
-
-/**
- * DocumentQueryRow is one selected element and its projected cells, one per
- * column in column order.
- *
- * @generated from message sysml.DocumentQueryRow
- */
-export type DocumentQueryRow = Message<"sysml.DocumentQueryRow"> & {
-  /**
-   * The selected element itself, an element value with its qualified name.
-   *
-   * @generated from field: sysml.DocumentValue element = 1;
-   */
-  element?: DocumentValue | undefined;
-
-  /**
-   * @generated from field: repeated sysml.DocumentQueryCell cells = 2;
-   */
-  cells: DocumentQueryCell[];
-};
-
-/**
- * Describes the message sysml.DocumentQueryRow.
- * Use `create(DocumentQueryRowSchema)` to create a new message.
- */
-export const DocumentQueryRowSchema: GenMessage<DocumentQueryRow> = /*@__PURE__*/
-  messageDesc(file_sysml, 91);
-
-/**
- * RunDocumentQueryResponse is the query's answer: its projected columns and its
- * rows, both in the deterministic order the engine reports. A query that
- * selects nothing answers with no rows; a query that could not be run fails the
- * call with the status its failure maps to.
- *
- * @generated from message sysml.RunDocumentQueryResponse
- */
-export type RunDocumentQueryResponse = Message<"sysml.RunDocumentQueryResponse"> & {
-  /**
-   * @generated from field: repeated sysml.DocumentQueryColumn columns = 1;
-   */
-  columns: DocumentQueryColumn[];
-
-  /**
-   * @generated from field: repeated sysml.DocumentQueryRow rows = 2;
-   */
-  rows: DocumentQueryRow[];
-};
-
-/**
- * Describes the message sysml.RunDocumentQueryResponse.
- * Use `create(RunDocumentQueryResponseSchema)` to create a new message.
- */
-export const RunDocumentQueryResponseSchema: GenMessage<RunDocumentQueryResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 92);
-
-/**
- * RenderDocumentRequest renders a named document — a part def specializing
- * DocumentQueries::Document — to Markdown. A document binds its queries'
- * parameters in the model, so the request carries none.
- *
- * @generated from message sysml.RenderDocumentRequest
- */
-export type RenderDocumentRequest = Message<"sysml.RenderDocumentRequest"> & {
-  /**
-   * from ParseFile response
-   *
-   * @generated from field: string model_hash = 1;
-   */
-  modelHash: string;
-
-  /**
-   * FQN of the document to render. Fails with NOT_FOUND when the model does
-   * not declare it, and INVALID_ARGUMENT when it declares something else.
-   *
-   * @generated from field: string document_id = 2;
-   */
-  documentId: string;
-};
-
-/**
- * Describes the message sysml.RenderDocumentRequest.
- * Use `create(RenderDocumentRequestSchema)` to create a new message.
- */
-export const RenderDocumentRequestSchema: GenMessage<RenderDocumentRequest> = /*@__PURE__*/
-  messageDesc(file_sysml, 93);
-
-/**
- * RenderDocumentResponse carries the rendered Markdown, byte-for-byte what the
- * CLI's -render-document writes.
- *
- * @generated from message sysml.RenderDocumentResponse
- */
-export type RenderDocumentResponse = Message<"sysml.RenderDocumentResponse"> & {
-  /**
-   * @generated from field: string markdown = 1;
-   */
-  markdown: string;
-};
-
-/**
- * Describes the message sysml.RenderDocumentResponse.
- * Use `create(RenderDocumentResponseSchema)` to create a new message.
- */
-export const RenderDocumentResponseSchema: GenMessage<RenderDocumentResponse> = /*@__PURE__*/
-  messageDesc(file_sysml, 94);
-
-/**
- * FailureReason says what kind of failure an `error` reports, so a client acts
- * on the kind rather than on the message text.
- *
- * @generated from enum sysml.FailureReason
- */
-export enum FailureReason {
-  /**
-   * no failure, or one not classified
-   *
-   * @generated from enum value: FAILURE_REASON_UNSPECIFIED = 0;
-   */
-  UNSPECIFIED = 0,
-
-  /**
-   * the condition or calculation could not be evaluated
-   *
-   * @generated from enum value: FAILURE_REASON_EVALUATION = 1;
-   */
-  EVALUATION = 1,
-
-  /**
-   * the symbol named declares something else
-   *
-   * @generated from enum value: FAILURE_REASON_WRONG_KIND = 2;
-   */
-  WRONG_KIND = 2,
-
-  /**
-   * several objects carry the element; name one as the subject
-   *
-   * @generated from enum value: FAILURE_REASON_AMBIGUOUS_SUBJECT = 3;
-   */
-  AMBIGUOUS_SUBJECT = 3,
-}
-
-/**
- * Describes the enum sysml.FailureReason.
- */
-export const FailureReasonSchema: GenEnum<FailureReason> = /*@__PURE__*/
-  enumDesc(file_sysml, 0);
-
-/**
- * EditFailure says why edits were refused. Every refusal is one of these: an
- * edit is never silently dropped.
- *
- * @generated from enum sysml.EditFailure
- */
-export enum EditFailure {
-  /**
-   * no failure
-   *
-   * @generated from enum value: EDIT_FAILURE_UNSPECIFIED = 0;
-   */
-  UNSPECIFIED = 0,
-
-  /**
-   * the request named no edit
-   *
-   * @generated from enum value: EDIT_FAILURE_NO_OPERATIONS = 1;
-   */
-  NO_OPERATIONS = 1,
-
-  /**
-   * no element of that name in the model
-   *
-   * @generated from enum value: EDIT_FAILURE_UNKNOWN_TARGET = 2;
-   */
-  UNKNOWN_TARGET = 2,
-
-  /**
-   * the name denotes several declarations
-   *
-   * @generated from enum value: EDIT_FAILURE_AMBIGUOUS_TARGET = 3;
-   */
-  AMBIGUOUS_TARGET = 3,
-
-  /**
-   * the element can carry no value
-   *
-   * @generated from enum value: EDIT_FAILURE_NOT_VALUED = 4;
-   */
-  NOT_VALUED = 4,
-
-  /**
-   * the new value does not parse as an expression
-   *
-   * @generated from enum value: EDIT_FAILURE_INVALID_VALUE = 5;
-   */
-  INVALID_VALUE = 5,
-
-  /**
-   * the new name does not lex as an identifier
-   *
-   * @generated from enum value: EDIT_FAILURE_INVALID_NAME = 6;
-   */
-  INVALID_NAME = 6,
-
-  /**
-   * the element declares no name to rewrite
-   *
-   * @generated from enum value: EDIT_FAILURE_NOT_NAMED = 7;
-   */
-  NOT_NAMED = 7,
-
-  /**
-   * references to the element would break
-   *
-   * @generated from enum value: EDIT_FAILURE_RENAME_REFERENCED = 8;
-   */
-  RENAME_REFERENCED = 8,
-
-  /**
-   * two edits cover the same bytes
-   *
-   * @generated from enum value: EDIT_FAILURE_OVERLAPPING_EDITS = 9;
-   */
-  OVERLAPPING_EDITS = 9,
-
-  /**
-   * the edited source has errors the original had not
-   *
-   * @generated from enum value: EDIT_FAILURE_RESULT_INVALID = 10;
-   */
-  RESULT_INVALID = 10,
-
-  /**
-   * add-member owner does not exist
-   *
-   * @generated from enum value: EDIT_FAILURE_OWNER_UNKNOWN = 11;
-   */
-  OWNER_UNKNOWN = 11,
-
-  /**
-   * owner cannot contain members
-   *
-   * @generated from enum value: EDIT_FAILURE_OWNER_NOT_NAMESPACE = 12;
-   */
-  OWNER_NOT_NAMESPACE = 12,
-
-  /**
-   * kind is invalid for document language
-   *
-   * @generated from enum value: EDIT_FAILURE_ILLEGAL_KIND = 13;
-   */
-  ILLEGAL_KIND = 13,
-
-  /**
-   * owner already declares the name
-   *
-   * @generated from enum value: EDIT_FAILURE_MEMBER_NAME_TAKEN = 14;
-   */
-  MEMBER_NAME_TAKEN = 14,
-
-  /**
-   * delete would leave references
-   *
-   * @generated from enum value: EDIT_FAILURE_DELETE_REFERENCED = 15;
-   */
-  DELETE_REFERENCED = 15,
-
-  /**
-   * move owner is the target or inside it
-   *
-   * @generated from enum value: EDIT_FAILURE_OWNER_INSIDE_TARGET = 16;
-   */
-  OWNER_INSIDE_TARGET = 16,
-
-  /**
-   * move would leave a reference no spelling restores
-   *
-   * @generated from enum value: EDIT_FAILURE_MOVE_REFERENCED = 17;
-   */
-  MOVE_REFERENCED = 17,
-}
-
-/**
- * Describes the enum sysml.EditFailure.
- */
-export const EditFailureSchema: GenEnum<EditFailure> = /*@__PURE__*/
-  enumDesc(file_sysml, 1);
-
-/**
- * PrimitiveOperator is the standard's PrimitiveConstraint.operator.
- *
- * @generated from enum sysml.PrimitiveOperator
- */
-export enum PrimitiveOperator {
-  /**
-   * unset; fails the call
-   *
-   * @generated from enum value: PRIMITIVE_OPERATOR_UNSPECIFIED = 0;
-   */
-  UNSPECIFIED = 0,
-
-  /**
-   * "="
-   *
-   * @generated from enum value: PRIMITIVE_OPERATOR_EQUAL = 1;
-   */
-  EQUAL = 1,
-
-  /**
-   * ">"
-   *
-   * @generated from enum value: PRIMITIVE_OPERATOR_GREATER = 2;
-   */
-  GREATER = 2,
-
-  /**
-   * "<"
-   *
-   * @generated from enum value: PRIMITIVE_OPERATOR_LESS = 3;
-   */
-  LESS = 3,
-}
-
-/**
- * Describes the enum sysml.PrimitiveOperator.
- */
-export const PrimitiveOperatorSchema: GenEnum<PrimitiveOperator> = /*@__PURE__*/
-  enumDesc(file_sysml, 2);
-
-/**
- * CompositeOperator is the standard's CompositeConstraint.operator.
- *
- * @generated from enum sysml.CompositeOperator
- */
-export enum CompositeOperator {
-  /**
-   * unset; fails the call
-   *
-   * @generated from enum value: COMPOSITE_OPERATOR_UNSPECIFIED = 0;
-   */
-  UNSPECIFIED = 0,
-
-  /**
-   * "and"
-   *
-   * @generated from enum value: COMPOSITE_OPERATOR_AND = 1;
-   */
-  AND = 1,
-
-  /**
-   * "or"
-   *
-   * @generated from enum value: COMPOSITE_OPERATOR_OR = 2;
-   */
-  OR = 2,
-}
-
-/**
- * Describes the enum sysml.CompositeOperator.
- */
-export const CompositeOperatorSchema: GenEnum<CompositeOperator> = /*@__PURE__*/
-  enumDesc(file_sysml, 3);
-
-/**
- * SysMLService provides programmatic access to OpenSysML's parser and runtime
- *
- * @generated from service sysml.SysMLService
- */
-export const SysMLService: GenService<{
-  /**
-   * Report what this build of the service can do, so a client can require a
-   * capability instead of guessing from a version string. A service that
-   * predates this RPC answers UNIMPLEMENTED, which is itself the answer.
-   *
-   * @generated from rpc sysml.SysMLService.GetServerInfo
-   */
-  getServerInfo: {
-    methodKind: "unary";
-    input: typeof ServerInfoRequestSchema;
-    output: typeof ServerInfoResponseSchema;
-  },
-  /**
-   * Parse a SysML file and return model hash for subsequent queries
-   *
-   * @generated from rpc sysml.SysMLService.ParseFile
-   */
-  parseFile: {
-    methodKind: "unary";
-    input: typeof ParseFileRequestSchema;
-    output: typeof ParseFileResponseSchema;
-  },
-  /**
-   * Parse several documents as one model, so a name one document declares
-   * resolves in another and an import between them is satisfied. Reported as
-   * the "parse_sources" capability.
-   *
-   * @generated from rpc sysml.SysMLService.ParseSources
-   */
-  parseSources: {
-    methodKind: "unary";
-    input: typeof ParseSourcesRequestSchema;
-    output: typeof ParseSourcesResponseSchema;
-  },
-  /**
-   * Get symbol information by qualified name
-   *
-   * @generated from rpc sysml.SysMLService.GetSymbol
-   */
-  getSymbol: {
-    methodKind: "unary";
-    input: typeof GetSymbolRequestSchema;
-    output: typeof SymbolResponseSchema;
-  },
-  /**
-   * Get all diagnostics for a parsed model
-   *
-   * @generated from rpc sysml.SysMLService.GetDiagnostics
-   */
-  getDiagnostics: {
-    methodKind: "unary";
-    input: typeof DiagnosticsRequestSchema;
-    output: typeof DiagnosticsResponseSchema;
-  },
-  /**
-   * Runtime operations (Phase 4)
-   *
-   * @generated from rpc sysml.SysMLService.Evaluate
-   */
-  evaluate: {
-    methodKind: "unary";
-    input: typeof EvaluateRequestSchema;
-    output: typeof EvaluateResponseSchema;
-  },
-  /**
-   * @generated from rpc sysml.SysMLService.Instantiate
-   */
-  instantiate: {
-    methodKind: "unary";
-    input: typeof InstantiateRequestSchema;
-    output: typeof InstantiateResponseSchema;
-  },
-  /**
-   * @generated from rpc sysml.SysMLService.ExecuteAction
-   */
-  executeAction: {
-    methodKind: "unary";
-    input: typeof ExecuteActionRequestSchema;
-    output: typeof ExecuteActionResponseSchema;
-  },
-  /**
-   * @generated from rpc sysml.SysMLService.ExecuteState
-   */
-  executeState: {
-    methodKind: "unary";
-    input: typeof ExecuteStateRequestSchema;
-    output: typeof ExecuteStateResponseSchema;
-  },
-  /**
-   * Convert a model between the representations OpenSysML writes — SysML
-   * textual notation and RDF Turtle — so a client can write a model back out
-   * rather than only read it. Reported as the "convert" capability.
-   *
-   * @generated from rpc sysml.SysMLService.Convert
-   */
-  convert: {
-    methodKind: "unary";
-    input: typeof ConvertRequestSchema;
-    output: typeof ConvertResponseSchema;
-  },
-  /**
-   * Apply edits to a parsed model's own source and return the edited notation,
-   * so a client can change a model and write it back with its comments and
-   * layout intact. Edits are byte ranges the service locates from the parsed
-   * spans, and the result is re-parsed and validated before it is returned.
-   * Reported as the "apply_edits" capability.
-   *
-   * @generated from rpc sysml.SysMLService.ApplyEdits
-   */
-  applyEdits: {
-    methodKind: "unary";
-    input: typeof ApplyEditsRequestSchema;
-    output: typeof ApplyEditsResponseSchema;
-  },
-  /**
-   * Verification: the answers the REPL's %constraint, %requirement, %satisfy,
-   * %calc and %analysis give, so "does this model satisfy its requirements?"
-   * can be asked by a script. Each evaluates the same runtime paths the prompt
-   * does and returns a verdict rather than formatted lines. Reported as the
-   * "verification" capability.
-   *
-   * @generated from rpc sysml.SysMLService.VerifyConstraint
-   */
-  verifyConstraint: {
-    methodKind: "unary";
-    input: typeof VerifyConstraintRequestSchema;
-    output: typeof VerifyConstraintResponseSchema;
-  },
-  /**
-   * @generated from rpc sysml.SysMLService.VerifyRequirement
-   */
-  verifyRequirement: {
-    methodKind: "unary";
-    input: typeof VerifyRequirementRequestSchema;
-    output: typeof VerifyRequirementResponseSchema;
-  },
-  /**
-   * @generated from rpc sysml.SysMLService.VerifySatisfaction
-   */
-  verifySatisfaction: {
-    methodKind: "unary";
-    input: typeof VerifySatisfactionRequestSchema;
-    output: typeof VerifySatisfactionResponseSchema;
-  },
-  /**
-   * @generated from rpc sysml.SysMLService.EvaluateCalc
-   */
-  evaluateCalc: {
-    methodKind: "unary";
-    input: typeof EvaluateCalcRequestSchema;
-    output: typeof EvaluateCalcResponseSchema;
-  },
-  /**
-   * @generated from rpc sysml.SysMLService.RunAnalysis
-   */
-  runAnalysis: {
-    methodKind: "unary";
-    input: typeof RunAnalysisRequestSchema;
-    output: typeof RunAnalysisResponseSchema;
-  },
-  /**
-   * Run one analysis case or calc once per row of a parameter sweep, as the
-   * CLI's -sweep and the REPL's %sweep do: each row is an ordinary run with the
-   * swept parameter bound to that row's value. Reported as the "verification"
-   * capability.
-   *
-   * @generated from rpc sysml.SysMLService.RunSweep
-   */
-  runSweep: {
-    methodKind: "unary";
-    input: typeof RunSweepRequestSchema;
-    output: typeof RunSweepResponseSchema;
-  },
-  /**
-   * List the analysis engines this build registers, as the CLI's -engines and
-   * the REPL's %engines do: each with the questions it answers, the strongest
-   * evidence it can produce and whether it can run. Reported as the "engines"
-   * capability, which also names the `engine` request fields and the `engine`,
-   * `strength` and `bounds` response fields of the verification RPCs. The
-   * engines registered from manifests are listed with their origin and whether
-   * this service serves them; the "engines_external" capability is advertised
-   * only when it was started to serve them.
-   *
-   * @generated from rpc sysml.SysMLService.ListEngines
-   */
-  listEngines: {
-    methodKind: "unary";
-    input: typeof ListEnginesRequestSchema;
-    output: typeof ListEnginesResponseSchema;
-  },
-  /**
-   * Run a SysML v2 API & Services Query over a parsed model: scope/select/where
-   * as the standard defines them, so a client that speaks that API can filter a
-   * model here. Reported as the "query" capability.
-   *
-   * @generated from rpc sysml.SysMLService.Query
-   */
-  query: {
-    methodKind: "unary";
-    input: typeof QueryRequestSchema;
-    output: typeof QueryResponseSchema;
-  },
-  /**
-   * Run a named document query with parameter bindings, the answer the REPL's
-   * %run-query gives, as typed rows rather than formatted lines. Reported as
-   * the "document_query" capability.
-   *
-   * @generated from rpc sysml.SysMLService.RunDocumentQuery
-   */
-  runDocumentQuery: {
-    methodKind: "unary";
-    input: typeof RunDocumentQueryRequestSchema;
-    output: typeof RunDocumentQueryResponseSchema;
-  },
-  /**
-   * Render a named document to Markdown, as the CLI's -render-document does.
-   * Reported as the "render_document" capability.
-   *
-   * @generated from rpc sysml.SysMLService.RenderDocument
-   */
-  renderDocument: {
-    methodKind: "unary";
-    input: typeof RenderDocumentRequestSchema;
-    output: typeof RenderDocumentResponseSchema;
-  },
-}> = /*@__PURE__*/
-  serviceDesc(file_sysml, 0);
-
diff --git a/clients/python/INSTALL.md b/clients/python/INSTALL.md
deleted file mode 100644
index 95bcd08e6c..0000000000
--- a/clients/python/INSTALL.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# Installing opensysml
-
-## From PyPI
-
-```bash
-pip install opensysml
-```
-
-Published to [PyPI](https://pypi.org/project/opensysml/) from CircleCI on an
-`opensysml-v*` tag, independently of the core `v*` releases: the package downloads the
-`sysml-grpc` service it needs at runtime, so a newer core release needs no new package. See
-[docs/project/releasing.md](../../docs/project/releasing.md#releasing-opensysml-to-pypi).
-
-## From source
-
-From the repository root:
-
-```bash
-# Install in development mode (editable)
-pip install -e clients/python/
-
-# Or install with dev dependencies
-pip install -e "clients/python/[dev]"
-```
-
-## Running tests
-
-Install with `[dev]`: the lifecycle tests inspect processes through `psutil`,
-which the package itself does not need.
-
-From the repository root:
-
-```bash
-# Run all tests
-pytest clients/python/tests/
-
-# Run with verbose output
-pytest -v clients/python/tests/
-
-# Run specific test file
-pytest clients/python/tests/test_connection.py
-
-# Run integration tests (requires the sysml-grpc binary)
-pytest -m integration clients/python/tests/
-```
-
-A test that connects without naming a service starts a private `sysml-grpc`
-child from `~/.opensysml/bin`, so put a built binary there (`make build-grpc &&
-cp bin/sysml-grpc ~/.opensysml/bin/`). To run against a service you started
-yourself, set `OPENSYSML_SERVICE=host:port`.
-
-## Package structure
-
-```
-clients/python/
-├── opensysml/          # Package source
-│   ├── *.py          # Core modules (connection, model, symbol, etc.)
-│   ├── proto/        # Generated protobuf stubs
-│   └── release-digests.json  # Pinned service digests, synced from clients/
-├── tests/            # Test suite
-├── scripts/          # Release helpers (version check, latency measurement)
-├── pyproject.toml    # Package metadata and build configuration
-├── README.md         # Package documentation
-└── INSTALL.md        # This file
-```
diff --git a/clients/python/README.md b/clients/python/README.md
deleted file mode 100644
index a4742b44ea..0000000000
--- a/clients/python/README.md
+++ /dev/null
@@ -1,316 +0,0 @@
-# opensysml
-
-Python client for OpenSysML: parse, inspect and execute SysML v2 models over the
-`sysml-grpc` service.
-
-```bash
-pip install opensysml             # from PyPI
-pip install -e clients/python/          # or from a checkout, at the repository root
-```
-
-```python
-import opensysml
-
-model = opensysml.load("model.sysml", strict=True)   # raises on error diagnostics
-print(model.eval("1 + 2 * 3"))                     # 7
-
-print(model.eval("mass", subject="Demo::sedan"))   # 1200.0 — that object, not the default
-                                                   # requires the service's evaluate_subject
-                                                   # capability; the client checks it first
-
-vehicle = model["Vehicle"]                         # by short name or FQN
-vehicle.attributes()                               # own and inherited, with resolved facts
-inst = model.instantiate("Demo::Vehicle")
-inst.mass                                          # 1500.0 [kg] — a Quantity
-
-model.verify_satisfaction()                        # every assert satisfy … by …
-model.verify_constraint("Demo::Vehicle::massOK", subject="Demo::sedan", engine="check")
-model.connection.list_engines()                    # what `sysml -engines` prints
-model.save("model.ttl")                            # RDF Turtle (experimental)
-```
-
-A value arrives as the Python value of its kind — `Quantity`, `complex`, `Array`, `Vector`,
-`VectorQuantity`, `TensorQuantity`, `SetValue`, `MeasurementRef`, `Function`, `Metaobject`,
-`EnumLiteral`, `Undetermined`, `INFINITY` — never a string to parse; every verdict carries a
-`Standing` naming the engine that answered and the strength of its evidence.
-
-Declarations can be authored from notation strings while preserving the
-untouched source:
-
-```python
-model.edit().add_part_def("", "Vehicle").apply()
-model.edit().add_part("Vehicle", "engine", type="Engine").apply()
-```
-
-Use `opensysml.loads(text, language="kerml")` for inline KerML content.
-
-Every call goes through the `sysml-grpc` service, which `opensysml` starts automatically from
-the first place it finds one; the guide below describes how to install it there.
-
-## Resolving the service binary
-
-Every client resolves the service binary in the same order, and this one is no
-exception:
-
-1. **`$OPENSYSML_BINARY`**, when set and non-empty: exactly that path is started.
-   If it names something that is not an executable file, that is an error naming
-   the variable and the path — an explicit instruction is never a fallback.
-2. **The shared cache** `~/.opensysml/bin/sysml-grpc` (`sysml-grpc.exe` on
-   Windows), where a verified download puts it and where `make build` can put
-   your own build.
-3. **A release download** into that cache, when a release is asked for by
-   `ensure_binary(version=...)` or `$OPENSYSML_GRPC_VERSION`. A download that was
-   asked for and failed is an error, not a reason to try `$PATH`, whose binary is
-   of no known release.
-4. **`$PATH`**: the first executable `sysml-grpc` (`sysml-grpc.exe` on Windows)
-   on it, which is what a package manager or `go install` leaves behind.
-
-With none of those, the error lists everywhere it looked and what would fix it.
-
-A binary from `$OPENSYSML_BINARY` or `$PATH` is used exactly as it is found: it
-belongs to no release, so **it is not verified against the pinned digests below**,
-not copied into the shared cache, and started at its own path rather than a
-digest-named link. Naming it, or installing it on `$PATH`, is trusting it; the
-pinned-digest trust model covers downloads only.
-
-## Service ownership
-
-`opensysml` uses a service of its own, and never stops a service it did not
-start.
-
-- A connection made without naming a service starts a **private child** of this
-  interpreter. The child binds port 0, so the kernel assigns the port, and it
-  reports the address it was given on its stdout — no port is chosen, probed or
-  retried by the client, and two interpreters starting at once cannot collide.
-- One private child serves **every** connection of an interpreter that needs the
-  same service release. The first of them starts it; it stops when the last one
-  closes, or when the interpreter exits. Sharing it shares its parse cache,
-  which is what makes a second connection cheap (see below).
-- Its lifetime is this interpreter's. Nothing is recorded on disk about it, no
-  other process adopts it, and a service another process left listening is
-  neither reused nor cleaned up.
-- Connecting to a service `opensysml` did not start is explicit: pass a host and
-  port (`opensysml.connect("localhost", 50051)`, or `connect("localhost:50051")`),
-  set `$OPENSYSML_SERVICE=host:port`, or pass `auto_start=False` to require a
-  service the caller manages. Closing such a connection leaves it running.
-
-### No orphans
-
-The client holds the write end of the child's **stdin pipe** and never writes to
-it; the child reads its stdin and shuts down on end of file. Nothing else holds
-that write end, so the pipe closes when the owning process goes away — and it is
-the kernel that closes it, not any code of ours. That survives what an `atexit`
-hook or a supervisor thread does not: `SIGKILL`, `os._exit`, a fatal interpreter
-error, and a crash during shutdown. On an orderly close the client also closes
-stdin itself and then signals the child, so exit is prompt rather than eventual.
-
-The client signals only through the `Popen` object of the child it started, so no
-pid it did not start — including one the operating system has since reused —
-can be signalled. That guarantee no longer needs a start-time check to hold,
-because there is no pid on disk to re-authenticate.
-
-Per platform:
-
-- **Linux** and **macOS**: the child is started in a session of its own
-  (`start_new_session=True`), so a `SIGINT` or `SIGHUP` sent to the client's
-  process group does not reach it; stdin is what ends it. Other children the
-  client spawns do not inherit the write end, since CPython closes descriptors
-  across `subprocess` by default, so it has exactly one holder.
-- **Windows**: the operating system closes the same anonymous pipe when the
-  owning process exits, however it exits, so the guarantee is unchanged. Windows
-  has no `fork()`, so the case below cannot arise there.
-- **`fork()`**: the forked child inherits the write end, which would hold the
-  service open past its owner. An `os.register_at_fork` hook therefore disowns
-  the inherited services in the new process and closes its copy of the pipe: the
-  service stays tied to the process that started it, and a forked child that
-  connects starts one of its own.
-
-The single limitation is deliberate: a service reached explicitly is not tied to
-the client's lifetime, because the client does not own it.
-
-### Cost of a private child
-
-Measured on Linux with `clients/python/scripts/measure_private_service.py` (n=20):
-
-|                                                       |     p50 |     p95 |
-| ----------------------------------------------------- | ------: | ------: |
-| first connection: spawn, bind, report, handshake      |  7.0 ms |  9.1 ms |
-| a later connection joining this interpreter's child   |  0.6 ms |  1.0 ms |
-| a child per connection, rather than one shared        | 29.6 ms | 54.6 ms |
-| parsing a model the shared child has already parsed   |  0.3 ms |  1.2 ms |
-| the same parse in a child of that connection's own    | 139.8 ms | 269.6 ms |
-
-The last two rows are why the child is per interpreter rather than per
-connection: a child per connection would not only spawn N times, it would parse
-each model N times, against a cache hit some 500x cheaper.
-
-## Pinned release digests
-
-A download is verified against the table in `clients/release-digests.json`, which
-pins the SHA-256 of every asset of a release and is the one table every client
-verifies against; `opensysml` ships its own synced copy of it as
-`opensysml/release-digests.json` and reads it as `binary.PINNED_SHA256`, because a
-pin resolved from outside the published wheel would not be a pin. The `.sha256` served beside a
-binary comes from whoever served the binary, so it detects corruption but not a
-republished release; a pinned digest is independent of that origin. A download
-with no pin fails with a message naming the version, rather than falling back to
-the served checksum — `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=<owner/repo>` (or `=1` for
-any repository) accepts same-origin trust explicitly for what it names, with a
-warning.
-
-The cache at `~/.opensysml/bin/sysml-grpc` is shared with the other clients, so
-deciding whether it is the release asked for and replacing the binary and its
-metadata is done holding `~/.opensysml/bin/sysml-grpc.lock` (`fcntl.lockf`, the
-same lock a Java `FileLock` and the Rust client take). Concurrent installers
-therefore queue rather than pair one release's bytes with another's record. Because that lock is held
-over the download, every response is read bounded — 512 MiB for a binary, 8 MiB
-for a checksum, manifest, bundle or release JSON — so an endless body is refused
-rather than filling memory while other clients wait.
-
-What a service is started from is not that shared path but a link to it under its
-own digest, `~/.opensysml/bin/sysml-grpc-<first 16 hex of its SHA-256>`, made
-while the lock is held: the cache is replaced in place, so starting it after the
-lock is dropped could start whatever another client installed in between. The
-Java and Rust clients name that file the same way, so the three share it, and a
-cache that cannot be hashed or linked is started directly with a warning saying so.
-
-At release time, after the service binaries are published and final:
-
-```bash
-export GITHUB_TOKEN=...            # the release API rate-limits unauthenticated calls
-python scripts/pin_release_checksums.py --version v0.0.9 --write
-git commit -am 'chore(clients): pin release digests for v0.0.9'
-```
-
-The script downloads every `sysml-grpc-*` asset of that release, hashes what it
-downloaded, refuses the release if a `.sha256` sidecar disagrees with the asset
-it describes, rewrites the table in place, and syncs it into every client that
-ships a copy (`python3 scripts/sync-release-digests.py`, whose `--check` mode CI
-runs so a copy cannot drift). `--check` re-hashes the assets of
-every pinned release and fails on any disagreement, catching a release
-republished with another binary. A opensysml release therefore pins the service
-releases published before it; asking for a newer one needs a newer opensysml (or
-the explicit opt-in above), and leaves an already-downloaded binary serving
-rather than refusing to start — only a digest that *contradicts* a pin is
-treated as tampering and refuses to fall back.
-
-## Version
-
-`opensysml/_version.py` is the only declaration: the packaging metadata reads it,
-`opensysml.__version__` reports the installed distribution's version, and
-`scripts/check_version.py` fails a release whose tag names another version. The
-version tests therefore require the tree under test to be the installed
-distribution — `pip install -e clients/python/`. A wheel of another version installed
-beside the source tree makes them fail with that remedy: the artifact is what is
-stale, not the declaration.
-
-## Generated typed classes
-
-`python -m opensysml.generate model.sysml -o model_types.py` emits one class per
-SysML *definition*, and the generated hierarchy follows the model's
-generalization edges: `specializes`, `subsets` and `redefines` all become base
-classes, because Python has a single notion of inheritance. What tells the two
-apart is the members, not the bases:
-
-- a **redefinition** reuses the redefined feature's name, so its property
-  overrides the base class's property of that name, and takes over the type and
-  multiplicity it does not restate (`attribute :>> mass = 2.0;` stays `float`,
-  and a redefined `0..*` feature stays a `list[...]`);
-- a **subset** under a new name adds a property beside the base class's one, and
-  likewise inherits the type and multiplicity it leaves out.
-
-With **multiple supertypes**, bases are emitted in declaration order, a target
-named twice appearing once, and Python resolves members left to right by its
-usual MRO. A base another declared base already specializes is left implicit —
-`Hybrid :> Vehicle, Electric` where `Electric :> Vehicle` emits
-`class Hybrid(Electric)`, which Python can linearize and which keeps both
-relationships and `Electric`'s properties. Where no order linearizes at all
-(two bases specializing a shared pair in opposite orders), rather than emit a
-module that fails to import, the generator keeps the bases it can and records
-what it left out as a comment on the class, naming the edge:
-
-```python
-class Both(One):
-    # specializes Demo::Two, left out: Python cannot linearize it with the bases above
-```
-
-A base outside the generated model is reported the same way. Both are the model's
-hierarchy being wider than Python's, not facts being discarded — the service
-reports every edge, and `Symbol.specializations` still carries them all.
-
-**Limitation, unchanged:** only structural usages (`attribute`, `part`, `item`,
-`occurrence`, `individual`, `port`, `enum`) become properties. Behavioral and
-connector usages — `action`, `state`, `calc`, `constraint`, `requirement`,
-`connection`, `flow`, `interface`, `allocation`, `case` — are not instance feature values,
-so a generated class has no member for them; reach them through
-`model["Demo::Vehicle"]`, `verify_constraint` and `verify_satisfaction`.
-
-## Diagnostics
-
-A `Diagnostic` has `severity`, `message`, `code` and a location (`file`,
-`start_line`, `start_column`, `end_line`, `end_column`, or the raw `span`). Branch
-on `code`, not on the message text: `"syntax"` for a syntax error, a validation
-code such as `"unresolved"` for a finding, `"choice-point"` and
-`"guard-unevaluable"` for a run's notes; `""` when the service assigned none. A service
-that populates `code` advertises `opensysml.capabilities.CAPABILITY_DIAGNOSTIC_CODES`; without
-it every code is `""`.
-
-```python
-model = opensysml.load("model.sysml")
-unresolved = [d for d in model.diagnostics if d.code == "unresolved"]
-```
-
-## Names that shadow builtins
-
-Neither builtin name is a live part of the API any more: the module-level
-evaluation function is `opensysml.evaluate`, and the execution error is
-`opensysml.ExecutionError`. `opensysml.eval` and `opensysml.errors.RuntimeError`
-remain as deprecated aliases that warn on use, out of their modules' `__all__`,
-so a star-import binds neither.
-
-```python
-import opensysml
-
-opensysml.evaluate("1 + 2", file_path="model.sysml")   # opensysml.eval warns
-model.eval("mass", subject="Demo::sedan")            # a method shadows nothing
-```
-
-```python
-from opensysml import eval          # shadows the builtin in this module — avoid
-```
-
-Guidance for this package and for code around it:
-
-- **Call `opensysml.evaluate`.** `opensysml.eval` still works and returns the same
-  result, warning `DeprecationWarning`; it goes away in 1.0.0.
-- Import the package, not its names, for anything named like a builtin.
-- Catch `opensysml.ExecutionError` (or its base `opensysml.OpenSysMLError`), never
-  `opensysml.errors.RuntimeError`, which warns and is due for removal.
-- Do not name a new public function or exception after a builtin.
-
-0.2.0 therefore publishes `evaluate` as the name to write, with both builtin
-names deprecated rather than removed, so code written against 0.1.x keeps
-running until 1.0.0.
-
-## Running the tests
-
-```bash
-make build                                    # builds bin/sysml-grpc
-pip install -e clients/python/ && pip install pytest pytest-mock
-python -m pytest clients/python/tests/ -q             # service-backed tests skip
-```
-
-Tests that need a service skip when none answers on `localhost:50051` and no
-binary is available to spawn one. Where a service *is* provided — as in CI —
-export `OPENSYSML_REQUIRE_SERVICE=1`, and its absence fails instead of skipping.
-
-## Documentation
-
-- Using the client:
-  [docs/guide/09-clients.md](https://github.com/Open-MBEE/OpenSysML/blob/main/docs/guide/09-clients.md)
-  — installing the service binary, loading a model, instances, verification, conversion
-  and queries
-- The API surface, generated typed classes, latency and the module map:
-  [docs/reference/python-api.md](https://github.com/Open-MBEE/OpenSysML/blob/main/docs/reference/python-api.md)
-- Installing from source and running the tests:
-  [INSTALL.md](https://github.com/Open-MBEE/OpenSysML/blob/main/clients/python/INSTALL.md)
diff --git a/clients/python/opensysml/document.py b/clients/python/opensysml/document.py
deleted file mode 100644
index 564c0ccb73..0000000000
--- a/clients/python/opensysml/document.py
+++ /dev/null
@@ -1,198 +0,0 @@
-"""Native document queries and document rendering.
-
-The service runs a *document query* — a calc def specializing
-``DocumentQueries::Query`` — and answers typed rows, and renders a *document* —
-a part def specializing ``DocumentQueries::Document`` — to Markdown. These are
-the model's own named queries and documents, not the SysML v2 API & Services
-Query that :mod:`opensysml.query` builds.
-
-A binding value is a plain Python value (``str``, ``int``, ``float``,
-``bool``), a :class:`~opensysml.values.Quantity`, or an :class:`ElementRef`
-naming a model element by qualified name. Answered cells decode back to the
-same kinds, plus :data:`INFINITY` for an unbounded multiplicity.
-"""
-
-from dataclasses import dataclass
-from typing import Sequence, Union
-
-from opensysml.errors import OpenSysMLError, UnsupportedValueError
-from opensysml.proto import sysml_pb2
-from opensysml.values import INFINITY, Quantity, _Infinity
-
-
-class DocumentQueryError(OpenSysMLError, ValueError):
-    """Raised when a binding cannot be written before anything is sent."""
-
-
-@dataclass(frozen=True)
-class ElementRef:
-    """A model element, named by qualified name.
-
-    Attributes:
-        id: Qualified name of the element
-        type: Metamodel type name ("PartUsage", ...); empty when bound by a
-            caller, reported when answered by the service
-    """
-
-    id: str
-    type: str = ""
-
-    def __str__(self):
-        return f"{self.id} ({self.type})" if self.type else self.id
-
-
-#: What a binding value or an answered cell value may be.
-DocumentValue = Union[ElementRef, str, int, float, bool, Quantity, _Infinity]
-
-#: What ``bindings`` accepts for one parameter: one value or several.
-BindingValues = Union[DocumentValue, Sequence[DocumentValue]]
-
-
-@dataclass(frozen=True)
-class DocumentRow:
-    """One selected element and its projected cells, one per column.
-
-    Attributes:
-        element: The selected element itself
-        cells: One value sequence per column, in column order
-    """
-
-    element: ElementRef
-    cells: tuple
-
-    def __getitem__(self, index):
-        return self.cells[index]
-
-
-@dataclass(frozen=True)
-class DocumentQueryResult:
-    """A document query's answer: projected columns and typed rows, both in the
-    deterministic order the engine reports.
-
-    Attributes:
-        columns: Projected property names, in projection order
-        rows: The selected rows, in the engine's order
-    """
-
-    columns: tuple
-    rows: tuple
-
-    def __iter__(self):
-        return iter(self.rows)
-
-    def __len__(self):
-        return len(self.rows)
-
-
-def build_bindings(bindings=None):
-    """Translate a bindings mapping into the RPC's protobuf.
-
-    Args:
-        bindings (Mapping, optional): Parameter name to one value or a list of
-            values. A ``list``/``tuple`` binds several values; anything else,
-            including ``str``, binds one.
-
-    Returns:
-        list[sysml_pb2.DocumentQueryBinding]: What the request carries
-
-    Raises:
-        DocumentQueryError: If a value is not one a binding can carry
-    """
-    if not bindings:
-        return []
-    out = []
-    for parameter, values in bindings.items():
-        if not isinstance(values, (list, tuple)):
-            values = [values]
-        out.append(sysml_pb2.DocumentQueryBinding(
-            parameter=parameter,
-            values=[_bound_value(parameter, value) for value in values],
-        ))
-    return out
-
-
-def _bound_value(parameter, value):
-    """One binding value as the wire writes it. bool before int: it is one."""
-    if isinstance(value, ElementRef):
-        return sysml_pb2.DocumentValue(element_id=value.id)
-    if isinstance(value, bool):
-        return sysml_pb2.DocumentValue(bool_value=value)
-    if isinstance(value, str):
-        return sysml_pb2.DocumentValue(string_value=value)
-    if isinstance(value, int):
-        if not -(1 << 63) <= value < (1 << 63):
-            raise DocumentQueryError(
-                f"binding {parameter!r} cannot carry {value!r}: an int must "
-                f"fit in a signed 64-bit integer"
-            )
-        return sysml_pb2.DocumentValue(int_value=value)
-    if isinstance(value, float):
-        return sysml_pb2.DocumentValue(real_value=value)
-    if isinstance(value, Quantity):
-        return sysml_pb2.DocumentValue(quantity=_bound_quantity(parameter, value))
-    raise DocumentQueryError(
-        f"binding {parameter!r} cannot carry {value!r}: a binding is a str, "
-        f"int, float, bool, Quantity or ElementRef"
-    )
-
-
-def _bound_quantity(parameter, value):
-    """A Quantity as the wire writes it; one it cannot carry is a caller error."""
-    if isinstance(value.magnitude, int) and not isinstance(value.magnitude, bool):
-        if not -(1 << 63) <= value.magnitude < (1 << 63):
-            raise DocumentQueryError(
-                f"binding {parameter!r} cannot carry {value!r}: an Integer magnitude "
-                f"must fit in a signed 64-bit integer"
-            )
-    try:
-        return value.to_pb()
-    except UnsupportedValueError as exc:
-        raise DocumentQueryError(
-            f"binding {parameter!r} cannot carry {value!r}: {exc}"
-        ) from exc
-
-
-def result_of(response):
-    """Decode a ``RunDocumentQueryResponse`` into a :class:`DocumentQueryResult`."""
-    return DocumentQueryResult(
-        columns=tuple(column.name for column in response.columns),
-        rows=tuple(
-            DocumentRow(
-                element=_element_of(row.element),
-                cells=tuple(
-                    tuple(_value_of(value) for value in cell.values)
-                    for cell in row.cells
-                ),
-            )
-            for row in response.rows
-        ),
-    )
-
-
-def _element_of(value):
-    """The row's selected element, or an anonymous one when unnamed."""
-    if value.WhichOneof("kind") == "element_id":
-        return ElementRef(id=value.element_id, type=value.element_type)
-    return ElementRef(id="", type=value.element_type)
-
-
-def _value_of(value):
-    """One answered value as the Python value it is."""
-    kind = value.WhichOneof("kind")
-    if kind == "element_id":
-        return ElementRef(id=value.element_id, type=value.element_type)
-    if kind == "string_value":
-        return value.string_value
-    if kind == "int_value":
-        return value.int_value
-    if kind == "real_value":
-        return value.real_value
-    if kind == "bool_value":
-        return value.bool_value
-    if kind == "infinity":
-        return INFINITY
-    if kind == "quantity":
-        return Quantity.from_pb(value.quantity)
-    raise UnsupportedValueError(
-        f"the service answered a document value this client cannot read: {value}"
-    )
diff --git a/clients/python/opensysml/proto/sysml_pb2.py b/clients/python/opensysml/proto/sysml_pb2.py
deleted file mode 100644
index 4dec6a2575..0000000000
--- a/clients/python/opensysml/proto/sysml_pb2.py
+++ /dev/null
@@ -1,273 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by the protocol buffer compiler.  DO NOT EDIT!
-# NO CHECKED-IN PROTOBUF GENCODE
-# source: sysml.proto
-# Protobuf Python Version: 7.35.1
-"""Generated protocol buffer code."""
-from google.protobuf import descriptor as _descriptor
-from google.protobuf import descriptor_pool as _descriptor_pool
-from google.protobuf import runtime_version as _runtime_version
-from google.protobuf import symbol_database as _symbol_database
-from google.protobuf.internal import builder as _builder
-_runtime_version.ValidateProtobufRuntimeVersion(
-    _runtime_version.Domain.PUBLIC,
-    7,
-    35,
-    1,
-    '',
-    'sysml.proto'
-)
-# @@protoc_insertion_point(imports)
-
-_sym_db = _symbol_database.Default()
-
-
-
-
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0bsysml.proto\x12\x05sysml\"\xa2\x02\n\x07Verdict\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12\x12\n\nelement_id\x18\x02 \x01(\t\x12\x0f\n\x07\x65lement\x18\x03 \x01(\t\x12\r\n\x05holds\x18\x04 \x01(\x08\x12\x11\n\tcondition\x18\x05 \x01(\t\x12\x13\n\x0binstance_id\x18\x06 \x01(\x03\x12\x18\n\x10instance_type_id\x18\x07 \x01(\t\x12\r\n\x05\x65rror\x18\x08 \x01(\t\x12,\n\x0e\x66\x61ilure_reason\x18\t \x01(\x0e\x32\x14.sysml.FailureReason\x12\x16\n\x0erequirement_id\x18\n \x01(\t\x12\x0e\n\x06\x65ngine\x18\x0b \x01(\t\x12\x10\n\x08strength\x18\x0c \x01(\t\x12\x1c\n\x06\x62ounds\x18\r \x03(\x0b\x32\x0c.sysml.Bound\"5\n\x05\x42ound\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05limit\x18\x02 \x01(\x03\x12\x0f\n\x07reached\x18\x03 \x01(\x08\"k\n\x17VerifyConstraintRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x19\n\x11subject_symbol_id\x18\x03 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x04 \x01(\t\"\x96\x01\n\x18VerifyConstraintResponse\x12\x1f\n\x07verdict\x18\x01 \x01(\x0b\x32\x0e.sysml.Verdict\x12\"\n\tinstances\x18\x02 \x03(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x04 \x03(\x0b\x32\x11.sysml.Diagnostic\"l\n\x18VerifyRequirementRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x19\n\x11subject_symbol_id\x18\x03 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x04 \x01(\t\"m\n\x13VerificationVerdict\x12\x0f\n\x07\x63\x61se_id\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x0e\n\x06\x64\x65tail\x18\x03 \x01(\t\x12\x0f\n\x07subcase\x18\x04 \x01(\x08\x12\x16\n\x0erequirement_id\x18\x05 \x01(\t\"\xd2\x01\n\x19VerifyRequirementResponse\x12\x1f\n\x07verdict\x18\x01 \x01(\x0b\x32\x0e.sysml.Verdict\x12\"\n\tinstances\x18\x02 \x03(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x04 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\x39\n\x15verification_verdicts\x18\x05 \x03(\x0b\x32\x1a.sysml.VerificationVerdict\"R\n\x19VerifySatisfactionRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x03 \x01(\t\"\x82\x02\n\x1aVerifySatisfactionResponse\x12 \n\x08verdicts\x18\x01 \x03(\x0b\x32\x0e.sysml.Verdict\x12\"\n\tinstances\x18\x02 \x03(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x04 \x03(\x0b\x32\x11.sysml.Diagnostic\x12,\n\x0e\x66\x61ilure_reason\x18\x05 \x01(\x0e\x32\x14.sysml.FailureReason\x12\x39\n\x15verification_verdicts\x18\x06 \x03(\x0b\x32\x1a.sysml.VerificationVerdict\"m\n\x13\x45valuateCalcRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x1f\n\targuments\x18\x03 \x03(\x0b\x32\x0c.sysml.Value\x12\x0e\n\x06\x65ngine\x18\x04 \x01(\t\"\xfd\x01\n\x14\x45valuateCalcResponse\x12\x1c\n\x06result\x18\x01 \x01(\x0b\x32\x0c.sysml.Value\x12\"\n\x07outputs\x18\x02 \x03(\x0b\x32\x11.sysml.CalcOutput\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x04 \x03(\x0b\x32\x11.sysml.Diagnostic\x12,\n\x0e\x66\x61ilure_reason\x18\x05 \x01(\x0e\x32\x14.sysml.FailureReason\x12\x0e\n\x06\x65ngine\x18\x06 \x01(\t\x12\x10\n\x08strength\x18\x07 \x01(\t\x12\x1c\n\x06\x62ounds\x18\x08 \x03(\x0b\x32\x0c.sysml.Bound\"7\n\nCalcOutput\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value\"\x93\x01\n\x0e\x43\x61seEvaluation\x12\x13\n\x0b\x66unction_id\x18\x01 \x01(\t\x12\x1f\n\targuments\x18\x02 \x03(\x0b\x32\x0c.sysml.Value\x12\x1c\n\x06result\x18\x03 \x01(\x0b\x32\x0c.sysml.Value\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12\x10\n\x08selected\x18\x05 \x01(\x08\x12\x0c\n\x04tied\x18\x06 \x01(\x08\"\xa6\x02\n\x12RunAnalysisRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x19\n\x11subject_symbol_id\x18\x03 \x01(\t\x12\x1f\n\targuments\x18\x04 \x03(\x0b\x32\x0c.sysml.Value\x12\x46\n\x0fnamed_arguments\x18\x05 \x03(\x0b\x32-.sysml.RunAnalysisRequest.NamedArgumentsEntry\x12\x10\n\x08schedule\x18\x06 \x01(\t\x12\x0e\n\x06\x65ngine\x18\x07 \x01(\t\x1a\x43\n\x13NamedArgumentsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"\xdc\x03\n\x13RunAnalysisResponse\x12\"\n\x07outputs\x18\x01 \x03(\x0b\x32\x11.sysml.CalcOutput\x12 \n\x08verdicts\x18\x02 \x03(\x0b\x32\x0e.sysml.Verdict\x12\"\n\tinstances\x18\x03 \x03(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x05 \x03(\x0b\x32\x11.sysml.Diagnostic\x12,\n\x0e\x66\x61ilure_reason\x18\x06 \x01(\x0e\x32\x14.sysml.FailureReason\x12\x39\n\x15verification_verdicts\x18\x07 \x03(\x0b\x32\x1a.sysml.VerificationVerdict\x12 \n\x08outcomes\x18\x08 \x03(\x0b\x32\x0e.sysml.Outcome\x12-\n\x0b\x65xploration\x18\t \x01(\x0b\x32\x18.sysml.ExplorationStatus\x12*\n\x0b\x65valuations\x18\n \x03(\x0b\x32\x15.sysml.CaseEvaluation\x12\x0e\n\x06\x65ngine\x18\x0b \x01(\t\x12\x10\n\x08strength\x18\x0c \x01(\t\x12\x1c\n\x06\x62ounds\x18\r \x03(\x0b\x32\x0c.sysml.Bound\"\x82\x02\n\x07Outcome\x12,\n\x07outputs\x18\x01 \x03(\x0b\x32\x1b.sysml.Outcome.OutputsEntry\x12\x13\n\x0b\x66inal_state\x18\x02 \x01(\t\x12\x16\n\x0estates_visited\x18\x03 \x03(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12\x16\n\x0elinearizations\x18\x05 \x01(\x05\x12\x0f\n\x07witness\x18\x06 \x03(\t\x12&\n\x0b\x64iagnostics\x18\x07 \x03(\x0b\x32\x11.sysml.Diagnostic\x1a<\n\x0cOutputsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"s\n\x11\x45xplorationStatus\x12\x10\n\x08\x63omplete\x18\x01 \x01(\x08\x12\x0c\n\x04runs\x18\x02 \x01(\x05\x12\x13\n\x0b\x62udgets_hit\x18\x03 \x03(\t\x12\x13\n\x0bruns_budget\x18\x04 \x01(\x05\x12\x14\n\x0c\x64\x65pth_budget\x18\x05 \x01(\x05\"\x14\n\x12ListEnginesRequest\"\xfc\x01\n\nEngineInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x11\n\tauthority\x18\x02 \x01(\t\x12\x0f\n\x07\x61nswers\x18\x03 \x03(\t\x12\x0e\n\x06\x62ounds\x18\x04 \x03(\t\x12\x0f\n\x07process\x18\x05 \x01(\t\x12\x15\n\rprocess_found\x18\x06 \x01(\t\x12\r\n\x05ready\x18\x07 \x01(\x08\x12\x13\n\x0bunavailable\x18\x08 \x01(\t\x12\x0c\n\x04kind\x18\t \x01(\t\x12\x10\n\x08protocol\x18\n \x01(\t\x12\x0e\n\x06source\x18\x0b \x01(\t\x12\x0f\n\x07\x63ommand\x18\x0c \x01(\t\x12\x0f\n\x07version\x18\r \x01(\t\x12\x0e\n\x06served\x18\x0e \x01(\x08\"9\n\x13ListEnginesResponse\x12\"\n\x07\x65ngines\x18\x01 \x03(\x0b\x32\x11.sysml.EngineInfo\"\x8c\x01\n\x10ParseFileRequest\x12\x13\n\tfile_path\x18\x01 \x01(\tH\x00\x12\x11\n\x07\x63ontent\x18\x02 \x01(\tH\x00\x12\x18\n\x0c\x63ontent_hash\x18\x03 \x01(\tB\x02\x18\x01\x12\x10\n\x08language\x18\x04 \x01(\t\x12\x1a\n\x12strict_conformance\x18\x05 \x01(\x08\x42\x08\n\x06source\"b\n\x0eSourceDocument\x12\x13\n\tfile_path\x18\x01 \x01(\tH\x00\x12\x11\n\x07\x63ontent\x18\x02 \x01(\tH\x00\x12\x10\n\x08language\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\tB\x08\n\x06source\"[\n\x13ParseSourcesRequest\x12(\n\tdocuments\x18\x01 \x03(\x0b\x32\x15.sysml.SourceDocument\x12\x1a\n\x12strict_conformance\x18\x02 \x01(\x08\"\x83\x01\n\x14ParseSourcesResponse\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12 \n\x05roots\x18\x02 \x03(\x0b\x32\x11.sysml.SymbolInfo\x12&\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\r\n\x05\x65rror\x18\x04 \x01(\t\"\x7f\n\x11ParseFileResponse\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x1f\n\x04root\x18\x02 \x01(\x0b\x32\x11.sysml.SymbolInfo\x12&\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\r\n\x05\x65rror\x18\x04 \x01(\t\"9\n\x10GetSymbolRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\"B\n\x0eSymbolResponse\x12!\n\x06symbol\x18\x01 \x01(\x0b\x32\x11.sysml.SymbolInfo\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"(\n\x12\x44iagnosticsRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\"L\n\x13\x44iagnosticsResponse\x12&\n\x0b\x64iagnostics\x18\x01 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"o\n\x0f\x45valuateRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x12\n\nexpression\x18\x02 \x01(\t\x12\x19\n\x11\x63ontext_symbol_id\x18\x03 \x01(\t\x12\x19\n\x11subject_symbol_id\x18\x04 \x01(\t\"g\n\x10\x45valuateResponse\x12\x1c\n\x06result\x18\x01 \x01(\x0b\x32\x0c.sysml.Value\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\x11.sysml.Diagnostic\"\xc2\x01\n\x08Instance\x12\n\n\x02id\x18\x01 \x01(\x03\x12\x16\n\x0etype_symbol_id\x18\x02 \x01(\t\x12:\n\x0e\x66\x65\x61ture_values\x18\x04 \x03(\x0b\x32\".sysml.Instance.FeatureValuesEntry\x1aI\n\x12\x46\x65\x61tureValuesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\"\n\x05value\x18\x02 \x01(\x0b\x32\x13.sysml.FeatureValue:\x02\x38\x01J\x04\x08\x03\x10\x04R\x05slots\"\x84\x01\n\x0c\x46\x65\x61tureValue\x12\x14\n\x0c\x66\x65\x61ture_name\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value\x12\x1c\n\x06values\x18\x03 \x03(\x0b\x32\x0c.sysml.Value\x12\x14\n\x0cmaterialized\x18\x04 \x01(\x08\x12\r\n\x05\x65rror\x18\x05 \x01(\t\";\n\x12InstantiateRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\"\x93\x01\n\x13InstantiateResponse\x12!\n\x08instance\x18\x01 \x01(\x0b\x32\x0f.sysml.Instance\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\"\n\tinstances\x18\x04 \x03(\x0b\x32\x0f.sysml.Instance\"\xcc\x01\n\x14\x45xecuteActionRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x18\n\x10\x61\x63tion_symbol_id\x18\x02 \x01(\t\x12\x37\n\x06inputs\x18\x03 \x03(\x0b\x32\'.sysml.ExecuteActionRequest.InputsEntry\x12\x10\n\x08schedule\x18\x04 \x01(\t\x1a;\n\x0bInputsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"\xad\x02\n\x15\x45xecuteActionResponse\x12:\n\x07outputs\x18\x01 \x03(\x0b\x32).sysml.ExecuteActionResponse.OutputsEntry\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x03 \x03(\x0b\x32\x11.sysml.Diagnostic\x12 \n\x08outcomes\x18\x04 \x03(\x0b\x32\x0e.sysml.Outcome\x12-\n\x0b\x65xploration\x18\x05 \x01(\x0b\x32\x18.sysml.ExplorationStatus\x12\x12\n\nfinal_time\x18\x06 \x01(\x01\x1a<\n\x0cOutputsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"l\n\x13\x45xecuteStateRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x1f\n\x17state_machine_symbol_id\x18\x02 \x01(\t\x12\x0e\n\x06\x65vents\x18\x03 \x03(\t\x12\x10\n\x08schedule\x18\x04 \x01(\t\"\xd3\x02\n\x14\x45xecuteStateResponse\x12\x16\n\x0estates_visited\x18\x01 \x03(\t\x12\x44\n\rfinal_context\x18\x02 \x03(\x0b\x32-.sysml.ExecuteStateResponse.FinalContextEntry\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x04 \x03(\x0b\x32\x11.sysml.Diagnostic\x12 \n\x08outcomes\x18\x05 \x03(\x0b\x32\x0e.sysml.Outcome\x12-\n\x0b\x65xploration\x18\x06 \x01(\x0b\x32\x18.sysml.ExplorationStatus\x12\x12\n\nfinal_time\x18\x07 \x01(\x01\x1a\x41\n\x11\x46inalContextEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"\xa0\x01\n\x0e\x43onvertRequest\x12\x13\n\tfile_path\x18\x01 \x01(\tH\x00\x12\x11\n\x07\x63ontent\x18\x02 \x01(\tH\x00\x12\x14\n\nmodel_hash\x18\x06 \x01(\tH\x00\x12\x13\n\x0b\x66rom_format\x18\x03 \x01(\t\x12\x11\n\tto_format\x18\x04 \x01(\t\x12\x1e\n\x16tolerate_syntax_errors\x18\x05 \x01(\x08\x42\x08\n\x06source\"\xb4\x01\n\x0f\x43onvertResponse\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12\x13\n\x0b\x66rom_format\x18\x02 \x01(\t\x12\x11\n\tto_format\x18\x03 \x01(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x05 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\x14\n\x0c\x65xperimental\x18\x06 \x01(\x08\x12\x1b\n\x13\x65xperimental_notice\x18\x07 \x01(\t\"Q\n\x11\x41pplyEditsRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12(\n\noperations\x18\x02 \x03(\x0b\x32\x14.sysml.EditOperation\"\xdd\x01\n\rEditOperation\x12(\n\tset_value\x18\x01 \x01(\x0b\x32\x13.sysml.SetValueEditH\x00\x12#\n\x06rename\x18\x02 \x01(\x0b\x32\x11.sysml.RenameEditH\x00\x12*\n\nadd_member\x18\x03 \x01(\x0b\x32\x14.sysml.AddMemberEditH\x00\x12#\n\x06\x64\x65lete\x18\x04 \x01(\x0b\x32\x11.sysml.DeleteEditH\x00\x12\x1f\n\x04move\x18\x05 \x01(\x0b\x32\x0f.sysml.MoveEditH\x00\x42\x0b\n\toperation\"\x82\x01\n\rAddMemberEdit\x12\r\n\x05owner\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x0c\n\x04type\x18\x04 \x01(\t\x12\x14\n\x0cmultiplicity\x18\x05 \x01(\t\x12\r\n\x05value\x18\x06 \x01(\t\x12\x13\n\x0bspecializes\x18\x07 \x03(\t\"-\n\nDeleteEdit\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\x0f\n\x07\x63\x61scade\x18\x02 \x01(\x08\")\n\x08MoveEdit\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\r\n\x05owner\x18\x02 \x01(\t\"-\n\x0cSetValueEdit\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\".\n\nRenameEdit\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\x10\n\x08new_name\x18\x02 \x01(\t\"\xc2\x01\n\x12\x41pplyEditsResponse\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12#\n\x07\x61pplied\x18\x02 \x03(\x0b\x32\x12.sysml.AppliedEdit\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12#\n\x07\x66\x61ilure\x18\x04 \x01(\x0e\x32\x12.sysml.EditFailure\x12&\n\x0b\x64iagnostics\x18\x05 \x03(\x0b\x32\x11.sysml.Diagnostic\x12\x1a\n\x12referring_elements\x18\x06 \x03(\t\"z\n\x0b\x41ppliedEdit\x12\x17\n\x0foperation_index\x18\x01 \x01(\x05\x12\x0e\n\x06target\x18\x02 \x01(\t\x12\x0e\n\x06offset\x18\x03 \x01(\x05\x12\x0e\n\x06length\x18\x04 \x01(\x05\x12\x10\n\x08old_text\x18\x05 \x01(\t\x12\x10\n\x08new_text\x18\x06 \x01(\t\"\xfd\x02\n\nSymbolInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04kind\x18\x03 \x01(\t\x12\x31\n\x08metadata\x18\x04 \x03(\x0b\x32\x1f.sysml.SymbolInfo.MetadataEntry\x12\x11\n\tchild_ids\x18\x05 \x03(\t\x12(\n\nattributes\x18\x06 \x03(\x0b\x32\x14.sysml.AttributeInfo\x12\"\n\ttype_info\x18\x07 \x01(\x0b\x32\x0f.sysml.TypeInfo\x12-\n\x0cmultiplicity\x18\x08 \x01(\x0b\x32\x17.sysml.MultiplicityInfo\x12.\n\x0fspecializations\x18\t \x03(\x0b\x32\x15.sysml.Specialization\x12#\n\x1bwithheld_library_attributes\x18\n \x01(\x05\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"X\n\x0eSpecialization\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12\x10\n\x08\x64\x65\x63lared\x18\x02 \x01(\t\x12\x11\n\ttarget_id\x18\x03 \x01(\t\x12\x13\n\x0btarget_kind\x18\x04 \x01(\t\"\x95\x01\n\x08TypeInfo\x12\x10\n\x08\x64\x65\x63lared\x18\x01 \x01(\t\x12\x13\n\x0bresolved_id\x18\x02 \x01(\t\x12\x15\n\rresolved_kind\x18\x03 \x01(\t\x12\x11\n\tprimitive\x18\x04 \x01(\t\x12\x18\n\x10primitive_source\x18\x05 \x01(\t\x12\x10\n\x08quantity\x18\x06 \x01(\x08\x12\x0c\n\x04unit\x18\x07 \x01(\t\"0\n\x10MultiplicityInfo\x12\r\n\x05lower\x18\x01 \x01(\t\x12\r\n\x05upper\x18\x02 \x01(\t\"V\n\rAttributeInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x1b\n\x05value\x18\x03 \x01(\x0b\x32\x0c.sysml.Value\x12\x0c\n\x04unit\x18\x04 \x01(\t\"\xc3\x05\n\x05Value\x12\x13\n\tint_value\x18\x01 \x01(\x03H\x00\x12\x14\n\nreal_value\x18\x02 \x01(\x01H\x00\x12\x14\n\nbool_value\x18\x03 \x01(\x08H\x00\x12\x16\n\x0cstring_value\x18\x04 \x01(\tH\x00\x12\x15\n\x0binstance_id\x18\x05 \x01(\x03H\x00\x12(\n\x08sequence\x18\x06 \x01(\x0b\x32\x14.sysml.ValueSequenceH\x00\x12\x0e\n\x04null\x18\x07 \x01(\tH\x00\x12#\n\x08quantity\x18\x08 \x01(\x0b\x32\x0f.sysml.QuantityH\x00\x12*\n\x0c\x65num_literal\x18\t \x01(\x0b\x32\x12.sysml.EnumLiteralH\x00\x12\x0f\n\x05unset\x18\n \x01(\x08H\x00\x12!\n\x07\x63omplex\x18\x0b \x01(\x0b\x32\x0e.sysml.ComplexH\x00\x12\x1d\n\x05\x61rray\x18\x0c \x01(\x0b\x32\x0c.sysml.ArrayH\x00\x12\x1f\n\x06vector\x18\r \x01(\x0b\x32\r.sysml.VectorH\x00\x12\x30\n\x0fvector_quantity\x18\x0e \x01(\x0b\x32\x15.sysml.VectorQuantityH\x00\x12\x30\n\x0fmeasurement_ref\x18\x0f \x01(\x0b\x32\x15.sysml.MeasurementRefH\x00\x12\x12\n\x08infinity\x18\x10 \x01(\x08H\x00\x12#\n\x08\x66unction\x18\x11 \x01(\x0b\x32\x0f.sysml.FunctionH\x00\x12\x1e\n\x03set\x18\x12 \x01(\x0b\x32\x0f.sysml.ValueSetH\x00\x12\x30\n\x0ftensor_quantity\x18\x13 \x01(\x0b\x32\x15.sysml.TensorQuantityH\x00\x12\'\n\nmetaobject\x18\x14 \x01(\x0b\x32\x11.sysml.MetaobjectH\x00\x12+\n\x0cundetermined\x18\x15 \x01(\x0b\x32\x13.sysml.UndeterminedH\x00\x42\x06\n\x04kind\"6\n\nMetaobject\x12\x12\n\nelement_id\x18\x01 \x01(\t\x12\x14\n\x0cmetaclass_id\x18\x02 \x01(\t\"F\n\x0cUndetermined\x12\x0e\n\x06reason\x18\x01 \x01(\t\x12&\n\x05\x63ount\x18\x02 \x01(\x0b\x32\x17.sysml.MultiplicityInfo\",\n\x08\x46unction\x12\x0f\n\x07\x63\x61lc_id\x18\x01 \x01(\t\x12\x0f\n\x07self_id\x18\x02 \x01(\x03\"*\n\x08ValueSet\x12\x1e\n\x08\x65lements\x18\x01 \x03(\x0b\x32\x0c.sysml.Value\"I\n\x0eTensorQuantity\x12\x12\n\ndimensions\x18\x01 \x03(\x03\x12#\n\ncomponents\x18\x02 \x03(\x0b\x32\x0f.sysml.Quantity\";\n\x05\x41rray\x12\x12\n\ndimensions\x18\x01 \x03(\x03\x12\x1e\n\x08\x65lements\x18\x02 \x03(\x0b\x32\x0c.sysml.Value\"*\n\x06Vector\x12 \n\ncomponents\x18\x01 \x03(\x0b\x32\x0c.sysml.Value\"5\n\x0eVectorQuantity\x12#\n\ncomponents\x18\x01 \x03(\x0b\x32\x0f.sysml.Quantity\"*\n\x07\x43omplex\x12\x0c\n\x04real\x18\x01 \x01(\x01\x12\x11\n\timaginary\x18\x02 \x01(\x01\"d\n\x0b\x45numLiteral\x12\x12\n\nliteral_id\x18\x01 \x01(\t\x12\x16\n\x0e\x65numeration_id\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x1b\n\x05value\x18\x04 \x01(\x0b\x32\x0c.sysml.Value\"/\n\rValueSequence\x12\x1e\n\x08\x65lements\x18\x01 \x03(\x0b\x32\x0c.sysml.Value\"|\n\x08Quantity\x12\x17\n\rint_magnitude\x18\x01 \x01(\x03H\x00\x12\x18\n\x0ereal_magnitude\x18\x02 \x01(\x01H\x00\x12\x0c\n\x04unit\x18\x03 \x01(\t\x12\"\n\tunit_term\x18\x04 \x01(\x0b\x32\x0f.sysml.UnitTermB\x0b\n\tmagnitude\"S\n\x0eMeasurementRef\x12\x0c\n\x04unit\x18\x01 \x01(\t\x12\"\n\tunit_term\x18\x02 \x01(\x0b\x32\x0f.sysml.UnitTerm\x12\x0f\n\x07unit_id\x18\x03 \x01(\t\"T\n\x08UnitTerm\x12\x11\n\tscale_num\x18\x01 \x01(\x01\x12\x11\n\tscale_den\x18\x02 \x01(\x01\x12\"\n\x07\x66\x61\x63tors\x18\x03 \x03(\x0b\x32\x11.sysml.UnitFactor\"/\n\nUnitFactor\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x12\x10\n\x08\x65xponent\x18\x02 \x01(\x01\"X\n\nDiagnostic\x12\x10\n\x08severity\x18\x01 \x01(\t\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x19\n\x04span\x18\x03 \x01(\x0b\x32\x0b.sysml.Span\x12\x0c\n\x04\x63ode\x18\x04 \x01(\t\"^\n\x04Span\x12\x0c\n\x04\x66ile\x18\x01 \x01(\t\x12\x12\n\nstart_line\x18\x02 \x01(\x05\x12\x11\n\tstart_col\x18\x03 \x01(\x05\x12\x10\n\x08\x65nd_line\x18\x04 \x01(\x05\x12\x0f\n\x07\x65nd_col\x18\x05 \x01(\x05\"\x13\n\x11ServerInfoRequest\";\n\x12ServerInfoResponse\x12\x0f\n\x07version\x18\x01 \x01(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x02 \x03(\t\"S\n\x0cQueryRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x1b\n\x05query\x18\x02 \x01(\x0b\x32\x0c.sysml.Query\x12\x12\n\noslc_query\x18\x03 \x01(\t\"<\n\rQueryResponse\x12+\n\x08\x65lements\x18\x01 \x03(\x0b\x32\x19.sysml.QueryResultElement\"H\n\x05Query\x12\r\n\x05scope\x18\x01 \x03(\t\x12\x0e\n\x06select\x18\x02 \x03(\t\x12 \n\x05where\x18\x03 \x01(\x0b\x32\x11.sysml.Constraint\"|\n\nConstraint\x12/\n\tprimitive\x18\x01 \x01(\x0b\x32\x1a.sysml.PrimitiveConstraintH\x00\x12/\n\tcomposite\x18\x02 \x01(\x0b\x32\x1a.sysml.CompositeConstraintH\x00\x42\x0c\n\nconstraint\"s\n\x13PrimitiveConstraint\x12\x0f\n\x07inverse\x18\x01 \x01(\x08\x12\x10\n\x08property\x18\x02 \x01(\t\x12*\n\x08operator\x18\x03 \x01(\x0e\x32\x18.sysml.PrimitiveOperator\x12\r\n\x05value\x18\x04 \x03(\t\"h\n\x13\x43ompositeConstraint\x12*\n\x08operator\x18\x01 \x01(\x0e\x32\x18.sysml.CompositeOperator\x12%\n\nconstraint\x18\x02 \x03(\x0b\x32\x11.sysml.Constraint\"\xa0\x01\n\x12QueryResultElement\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12=\n\nproperties\x18\x03 \x03(\x0b\x32).sysml.QueryResultElement.PropertiesEntry\x1a\x31\n\x0fPropertiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"s\n\nSweepRange\x12\x11\n\tparameter\x18\x01 \x01(\t\x12\x1b\n\x05start\x18\x02 \x01(\x0b\x32\x0c.sysml.Value\x12\x19\n\x03\x65nd\x18\x03 \x01(\x0b\x32\x0c.sysml.Value\x12\x1a\n\x04step\x18\x04 \x01(\x0b\x32\x0c.sysml.Value\"\xd0\x02\n\x0fRunSweepRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x11\n\tsymbol_id\x18\x02 \x01(\t\x12\x19\n\x11subject_symbol_id\x18\x03 \x01(\t\x12\x1f\n\targuments\x18\x04 \x03(\x0b\x32\x0c.sysml.Value\x12\x43\n\x0fnamed_arguments\x18\x05 \x03(\x0b\x32*.sysml.RunSweepRequest.NamedArgumentsEntry\x12!\n\x06ranges\x18\x06 \x03(\x0b\x32\x11.sysml.SweepRange\x12\x0f\n\x07samples\x18\x07 \x01(\x03\x12\x0c\n\x04seed\x18\x08 \x01(\x04\x12\x0e\n\x06\x65ngine\x18\t \x01(\t\x1a\x43\n\x13NamedArgumentsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1b\n\x05value\x18\x02 \x01(\x0b\x32\x0c.sysml.Value:\x02\x38\x01\"\xf4\x01\n\x08SweepRow\x12!\n\x06inputs\x18\x01 \x03(\x0b\x32\x11.sysml.CalcOutput\x12\"\n\x07outputs\x18\x02 \x03(\x0b\x32\x11.sysml.CalcOutput\x12 \n\x08verdicts\x18\x03 \x03(\x0b\x32\x0e.sysml.Verdict\x12\x16\n\x0e\x65lapsed_micros\x18\x04 \x01(\x03\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12,\n\x0e\x66\x61ilure_reason\x18\x06 \x01(\x0e\x32\x14.sysml.FailureReason\x12*\n\x0b\x65valuations\x18\x07 \x03(\x0b\x32\x15.sysml.CaseEvaluation\"\xad\x02\n\x10RunSweepResponse\x12\x1d\n\x04rows\x18\x01 \x03(\x0b\x32\x0f.sysml.SweepRow\x12\x12\n\nparameters\x18\x02 \x03(\t\x12\x0f\n\x07sampled\x18\x03 \x01(\x08\x12\x0c\n\x04seed\x18\x04 \x01(\x04\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12&\n\x0b\x64iagnostics\x18\x06 \x03(\x0b\x32\x11.sysml.Diagnostic\x12,\n\x0e\x66\x61ilure_reason\x18\x07 \x01(\x0e\x32\x14.sysml.FailureReason\x12\"\n\tinstances\x18\x08 \x03(\x0b\x32\x0f.sysml.Instance\x12\x0e\n\x06\x65ngine\x18\t \x01(\t\x12\x10\n\x08strength\x18\n \x01(\t\x12\x1c\n\x06\x62ounds\x18\x0b \x03(\x0b\x32\x0c.sysml.Bound\"n\n\x17RunDocumentQueryRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x10\n\x08query_id\x18\x02 \x01(\t\x12-\n\x08\x62indings\x18\x03 \x03(\x0b\x32\x1b.sysml.DocumentQueryBinding\"O\n\x14\x44ocumentQueryBinding\x12\x11\n\tparameter\x18\x01 \x01(\t\x12$\n\x06values\x18\x02 \x03(\x0b\x32\x14.sysml.DocumentValue\"\xd5\x01\n\rDocumentValue\x12\x14\n\nelement_id\x18\x01 \x01(\tH\x00\x12\x16\n\x0cstring_value\x18\x02 \x01(\tH\x00\x12\x13\n\tint_value\x18\x03 \x01(\x03H\x00\x12\x14\n\nreal_value\x18\x04 \x01(\x01H\x00\x12\x14\n\nbool_value\x18\x05 \x01(\x08H\x00\x12\x12\n\x08infinity\x18\x06 \x01(\x08H\x00\x12#\n\x08quantity\x18\x08 \x01(\x0b\x32\x0f.sysml.QuantityH\x00\x12\x14\n\x0c\x65lement_type\x18\x07 \x01(\tB\x06\n\x04kind\"#\n\x13\x44ocumentQueryColumn\x12\x0c\n\x04name\x18\x01 \x01(\t\"9\n\x11\x44ocumentQueryCell\x12$\n\x06values\x18\x01 \x03(\x0b\x32\x14.sysml.DocumentValue\"b\n\x10\x44ocumentQueryRow\x12%\n\x07\x65lement\x18\x01 \x01(\x0b\x32\x14.sysml.DocumentValue\x12\'\n\x05\x63\x65lls\x18\x02 \x03(\x0b\x32\x18.sysml.DocumentQueryCell\"n\n\x18RunDocumentQueryResponse\x12+\n\x07\x63olumns\x18\x01 \x03(\x0b\x32\x1a.sysml.DocumentQueryColumn\x12%\n\x04rows\x18\x02 \x03(\x0b\x32\x17.sysml.DocumentQueryRow\"@\n\x15RenderDocumentRequest\x12\x12\n\nmodel_hash\x18\x01 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x02 \x01(\t\"*\n\x16RenderDocumentResponse\x12\x10\n\x08markdown\x18\x01 \x01(\t*\x93\x01\n\rFailureReason\x12\x1e\n\x1a\x46\x41ILURE_REASON_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x46\x41ILURE_REASON_EVALUATION\x10\x01\x12\x1d\n\x19\x46\x41ILURE_REASON_WRONG_KIND\x10\x02\x12$\n FAILURE_REASON_AMBIGUOUS_SUBJECT\x10\x03*\xe5\x04\n\x0b\x45\x64itFailure\x12\x1c\n\x18\x45\x44IT_FAILURE_UNSPECIFIED\x10\x00\x12\x1e\n\x1a\x45\x44IT_FAILURE_NO_OPERATIONS\x10\x01\x12\x1f\n\x1b\x45\x44IT_FAILURE_UNKNOWN_TARGET\x10\x02\x12!\n\x1d\x45\x44IT_FAILURE_AMBIGUOUS_TARGET\x10\x03\x12\x1b\n\x17\x45\x44IT_FAILURE_NOT_VALUED\x10\x04\x12\x1e\n\x1a\x45\x44IT_FAILURE_INVALID_VALUE\x10\x05\x12\x1d\n\x19\x45\x44IT_FAILURE_INVALID_NAME\x10\x06\x12\x1a\n\x16\x45\x44IT_FAILURE_NOT_NAMED\x10\x07\x12\"\n\x1e\x45\x44IT_FAILURE_RENAME_REFERENCED\x10\x08\x12\"\n\x1e\x45\x44IT_FAILURE_OVERLAPPING_EDITS\x10\t\x12\x1f\n\x1b\x45\x44IT_FAILURE_RESULT_INVALID\x10\n\x12\x1e\n\x1a\x45\x44IT_FAILURE_OWNER_UNKNOWN\x10\x0b\x12$\n EDIT_FAILURE_OWNER_NOT_NAMESPACE\x10\x0c\x12\x1d\n\x19\x45\x44IT_FAILURE_ILLEGAL_KIND\x10\r\x12\"\n\x1e\x45\x44IT_FAILURE_MEMBER_NAME_TAKEN\x10\x0e\x12\"\n\x1e\x45\x44IT_FAILURE_DELETE_REFERENCED\x10\x0f\x12$\n EDIT_FAILURE_OWNER_INSIDE_TARGET\x10\x10\x12 \n\x1c\x45\x44IT_FAILURE_MOVE_REFERENCED\x10\x11*\x92\x01\n\x11PrimitiveOperator\x12\"\n\x1ePRIMITIVE_OPERATOR_UNSPECIFIED\x10\x00\x12\x1c\n\x18PRIMITIVE_OPERATOR_EQUAL\x10\x01\x12\x1e\n\x1aPRIMITIVE_OPERATOR_GREATER\x10\x02\x12\x1b\n\x17PRIMITIVE_OPERATOR_LESS\x10\x03*n\n\x11\x43ompositeOperator\x12\"\n\x1e\x43OMPOSITE_OPERATOR_UNSPECIFIED\x10\x00\x12\x1a\n\x16\x43OMPOSITE_OPERATOR_AND\x10\x01\x12\x19\n\x15\x43OMPOSITE_OPERATOR_OR\x10\x02\x32\xea\x0b\n\x0cSysMLService\x12\x44\n\rGetServerInfo\x12\x18.sysml.ServerInfoRequest\x1a\x19.sysml.ServerInfoResponse\x12>\n\tParseFile\x12\x17.sysml.ParseFileRequest\x1a\x18.sysml.ParseFileResponse\x12G\n\x0cParseSources\x12\x1a.sysml.ParseSourcesRequest\x1a\x1b.sysml.ParseSourcesResponse\x12;\n\tGetSymbol\x12\x17.sysml.GetSymbolRequest\x1a\x15.sysml.SymbolResponse\x12G\n\x0eGetDiagnostics\x12\x19.sysml.DiagnosticsRequest\x1a\x1a.sysml.DiagnosticsResponse\x12;\n\x08\x45valuate\x12\x16.sysml.EvaluateRequest\x1a\x17.sysml.EvaluateResponse\x12\x44\n\x0bInstantiate\x12\x19.sysml.InstantiateRequest\x1a\x1a.sysml.InstantiateResponse\x12J\n\rExecuteAction\x12\x1b.sysml.ExecuteActionRequest\x1a\x1c.sysml.ExecuteActionResponse\x12G\n\x0c\x45xecuteState\x12\x1a.sysml.ExecuteStateRequest\x1a\x1b.sysml.ExecuteStateResponse\x12\x38\n\x07\x43onvert\x12\x15.sysml.ConvertRequest\x1a\x16.sysml.ConvertResponse\x12\x41\n\nApplyEdits\x12\x18.sysml.ApplyEditsRequest\x1a\x19.sysml.ApplyEditsResponse\x12S\n\x10VerifyConstraint\x12\x1e.sysml.VerifyConstraintRequest\x1a\x1f.sysml.VerifyConstraintResponse\x12V\n\x11VerifyRequirement\x12\x1f.sysml.VerifyRequirementRequest\x1a .sysml.VerifyRequirementResponse\x12Y\n\x12VerifySatisfaction\x12 .sysml.VerifySatisfactionRequest\x1a!.sysml.VerifySatisfactionResponse\x12G\n\x0c\x45valuateCalc\x12\x1a.sysml.EvaluateCalcRequest\x1a\x1b.sysml.EvaluateCalcResponse\x12\x44\n\x0bRunAnalysis\x12\x19.sysml.RunAnalysisRequest\x1a\x1a.sysml.RunAnalysisResponse\x12;\n\x08RunSweep\x12\x16.sysml.RunSweepRequest\x1a\x17.sysml.RunSweepResponse\x12\x44\n\x0bListEngines\x12\x19.sysml.ListEnginesRequest\x1a\x1a.sysml.ListEnginesResponse\x12\x32\n\x05Query\x12\x13.sysml.QueryRequest\x1a\x14.sysml.QueryResponse\x12S\n\x10RunDocumentQuery\x12\x1e.sysml.RunDocumentQueryRequest\x1a\x1f.sysml.RunDocumentQueryResponse\x12M\n\x0eRenderDocument\x12\x1c.sysml.RenderDocumentRequest\x1a\x1d.sysml.RenderDocumentResponseB*Z(github.com/Open-MBEE/OpenSysML/api/protob\x06proto3')
-
-_globals = globals()
-_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
-_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'sysml_pb2', _globals)
-if not _descriptor._USE_C_DESCRIPTORS:
-  _globals['DESCRIPTOR']._loaded_options = None
-  _globals['DESCRIPTOR']._serialized_options = b'Z(github.com/Open-MBEE/OpenSysML/api/proto'
-  _globals['_RUNANALYSISREQUEST_NAMEDARGUMENTSENTRY']._loaded_options = None
-  _globals['_RUNANALYSISREQUEST_NAMEDARGUMENTSENTRY']._serialized_options = b'8\001'
-  _globals['_OUTCOME_OUTPUTSENTRY']._loaded_options = None
-  _globals['_OUTCOME_OUTPUTSENTRY']._serialized_options = b'8\001'
-  _globals['_PARSEFILEREQUEST'].fields_by_name['content_hash']._loaded_options = None
-  _globals['_PARSEFILEREQUEST'].fields_by_name['content_hash']._serialized_options = b'\030\001'
-  _globals['_INSTANCE_FEATUREVALUESENTRY']._loaded_options = None
-  _globals['_INSTANCE_FEATUREVALUESENTRY']._serialized_options = b'8\001'
-  _globals['_EXECUTEACTIONREQUEST_INPUTSENTRY']._loaded_options = None
-  _globals['_EXECUTEACTIONREQUEST_INPUTSENTRY']._serialized_options = b'8\001'
-  _globals['_EXECUTEACTIONRESPONSE_OUTPUTSENTRY']._loaded_options = None
-  _globals['_EXECUTEACTIONRESPONSE_OUTPUTSENTRY']._serialized_options = b'8\001'
-  _globals['_EXECUTESTATERESPONSE_FINALCONTEXTENTRY']._loaded_options = None
-  _globals['_EXECUTESTATERESPONSE_FINALCONTEXTENTRY']._serialized_options = b'8\001'
-  _globals['_SYMBOLINFO_METADATAENTRY']._loaded_options = None
-  _globals['_SYMBOLINFO_METADATAENTRY']._serialized_options = b'8\001'
-  _globals['_QUERYRESULTELEMENT_PROPERTIESENTRY']._loaded_options = None
-  _globals['_QUERYRESULTELEMENT_PROPERTIESENTRY']._serialized_options = b'8\001'
-  _globals['_RUNSWEEPREQUEST_NAMEDARGUMENTSENTRY']._loaded_options = None
-  _globals['_RUNSWEEPREQUEST_NAMEDARGUMENTSENTRY']._serialized_options = b'8\001'
-  _globals['_FAILUREREASON']._serialized_start=12641
-  _globals['_FAILUREREASON']._serialized_end=12788
-  _globals['_EDITFAILURE']._serialized_start=12791
-  _globals['_EDITFAILURE']._serialized_end=13404
-  _globals['_PRIMITIVEOPERATOR']._serialized_start=13407
-  _globals['_PRIMITIVEOPERATOR']._serialized_end=13553
-  _globals['_COMPOSITEOPERATOR']._serialized_start=13555
-  _globals['_COMPOSITEOPERATOR']._serialized_end=13665
-  _globals['_VERDICT']._serialized_start=23
-  _globals['_VERDICT']._serialized_end=313
-  _globals['_BOUND']._serialized_start=315
-  _globals['_BOUND']._serialized_end=368
-  _globals['_VERIFYCONSTRAINTREQUEST']._serialized_start=370
-  _globals['_VERIFYCONSTRAINTREQUEST']._serialized_end=477
-  _globals['_VERIFYCONSTRAINTRESPONSE']._serialized_start=480
-  _globals['_VERIFYCONSTRAINTRESPONSE']._serialized_end=630
-  _globals['_VERIFYREQUIREMENTREQUEST']._serialized_start=632
-  _globals['_VERIFYREQUIREMENTREQUEST']._serialized_end=740
-  _globals['_VERIFICATIONVERDICT']._serialized_start=742
-  _globals['_VERIFICATIONVERDICT']._serialized_end=851
-  _globals['_VERIFYREQUIREMENTRESPONSE']._serialized_start=854
-  _globals['_VERIFYREQUIREMENTRESPONSE']._serialized_end=1064
-  _globals['_VERIFYSATISFACTIONREQUEST']._serialized_start=1066
-  _globals['_VERIFYSATISFACTIONREQUEST']._serialized_end=1148
-  _globals['_VERIFYSATISFACTIONRESPONSE']._serialized_start=1151
-  _globals['_VERIFYSATISFACTIONRESPONSE']._serialized_end=1409
-  _globals['_EVALUATECALCREQUEST']._serialized_start=1411
-  _globals['_EVALUATECALCREQUEST']._serialized_end=1520
-  _globals['_EVALUATECALCRESPONSE']._serialized_start=1523
-  _globals['_EVALUATECALCRESPONSE']._serialized_end=1776
-  _globals['_CALCOUTPUT']._serialized_start=1778
-  _globals['_CALCOUTPUT']._serialized_end=1833
-  _globals['_CASEEVALUATION']._serialized_start=1836
-  _globals['_CASEEVALUATION']._serialized_end=1983
-  _globals['_RUNANALYSISREQUEST']._serialized_start=1986
-  _globals['_RUNANALYSISREQUEST']._serialized_end=2280
-  _globals['_RUNANALYSISREQUEST_NAMEDARGUMENTSENTRY']._serialized_start=2213
-  _globals['_RUNANALYSISREQUEST_NAMEDARGUMENTSENTRY']._serialized_end=2280
-  _globals['_RUNANALYSISRESPONSE']._serialized_start=2283
-  _globals['_RUNANALYSISRESPONSE']._serialized_end=2759
-  _globals['_OUTCOME']._serialized_start=2762
-  _globals['_OUTCOME']._serialized_end=3020
-  _globals['_OUTCOME_OUTPUTSENTRY']._serialized_start=2960
-  _globals['_OUTCOME_OUTPUTSENTRY']._serialized_end=3020
-  _globals['_EXPLORATIONSTATUS']._serialized_start=3022
-  _globals['_EXPLORATIONSTATUS']._serialized_end=3137
-  _globals['_LISTENGINESREQUEST']._serialized_start=3139
-  _globals['_LISTENGINESREQUEST']._serialized_end=3159
-  _globals['_ENGINEINFO']._serialized_start=3162
-  _globals['_ENGINEINFO']._serialized_end=3414
-  _globals['_LISTENGINESRESPONSE']._serialized_start=3416
-  _globals['_LISTENGINESRESPONSE']._serialized_end=3473
-  _globals['_PARSEFILEREQUEST']._serialized_start=3476
-  _globals['_PARSEFILEREQUEST']._serialized_end=3616
-  _globals['_SOURCEDOCUMENT']._serialized_start=3618
-  _globals['_SOURCEDOCUMENT']._serialized_end=3716
-  _globals['_PARSESOURCESREQUEST']._serialized_start=3718
-  _globals['_PARSESOURCESREQUEST']._serialized_end=3809
-  _globals['_PARSESOURCESRESPONSE']._serialized_start=3812
-  _globals['_PARSESOURCESRESPONSE']._serialized_end=3943
-  _globals['_PARSEFILERESPONSE']._serialized_start=3945
-  _globals['_PARSEFILERESPONSE']._serialized_end=4072
-  _globals['_GETSYMBOLREQUEST']._serialized_start=4074
-  _globals['_GETSYMBOLREQUEST']._serialized_end=4131
-  _globals['_SYMBOLRESPONSE']._serialized_start=4133
-  _globals['_SYMBOLRESPONSE']._serialized_end=4199
-  _globals['_DIAGNOSTICSREQUEST']._serialized_start=4201
-  _globals['_DIAGNOSTICSREQUEST']._serialized_end=4241
-  _globals['_DIAGNOSTICSRESPONSE']._serialized_start=4243
-  _globals['_DIAGNOSTICSRESPONSE']._serialized_end=4319
-  _globals['_EVALUATEREQUEST']._serialized_start=4321
-  _globals['_EVALUATEREQUEST']._serialized_end=4432
-  _globals['_EVALUATERESPONSE']._serialized_start=4434
-  _globals['_EVALUATERESPONSE']._serialized_end=4537
-  _globals['_INSTANCE']._serialized_start=4540
-  _globals['_INSTANCE']._serialized_end=4734
-  _globals['_INSTANCE_FEATUREVALUESENTRY']._serialized_start=4648
-  _globals['_INSTANCE_FEATUREVALUESENTRY']._serialized_end=4721
-  _globals['_FEATUREVALUE']._serialized_start=4737
-  _globals['_FEATUREVALUE']._serialized_end=4869
-  _globals['_INSTANTIATEREQUEST']._serialized_start=4871
-  _globals['_INSTANTIATEREQUEST']._serialized_end=4930
-  _globals['_INSTANTIATERESPONSE']._serialized_start=4933
-  _globals['_INSTANTIATERESPONSE']._serialized_end=5080
-  _globals['_EXECUTEACTIONREQUEST']._serialized_start=5083
-  _globals['_EXECUTEACTIONREQUEST']._serialized_end=5287
-  _globals['_EXECUTEACTIONREQUEST_INPUTSENTRY']._serialized_start=5228
-  _globals['_EXECUTEACTIONREQUEST_INPUTSENTRY']._serialized_end=5287
-  _globals['_EXECUTEACTIONRESPONSE']._serialized_start=5290
-  _globals['_EXECUTEACTIONRESPONSE']._serialized_end=5591
-  _globals['_EXECUTEACTIONRESPONSE_OUTPUTSENTRY']._serialized_start=2960
-  _globals['_EXECUTEACTIONRESPONSE_OUTPUTSENTRY']._serialized_end=3020
-  _globals['_EXECUTESTATEREQUEST']._serialized_start=5593
-  _globals['_EXECUTESTATEREQUEST']._serialized_end=5701
-  _globals['_EXECUTESTATERESPONSE']._serialized_start=5704
-  _globals['_EXECUTESTATERESPONSE']._serialized_end=6043
-  _globals['_EXECUTESTATERESPONSE_FINALCONTEXTENTRY']._serialized_start=5978
-  _globals['_EXECUTESTATERESPONSE_FINALCONTEXTENTRY']._serialized_end=6043
-  _globals['_CONVERTREQUEST']._serialized_start=6046
-  _globals['_CONVERTREQUEST']._serialized_end=6206
-  _globals['_CONVERTRESPONSE']._serialized_start=6209
-  _globals['_CONVERTRESPONSE']._serialized_end=6389
-  _globals['_APPLYEDITSREQUEST']._serialized_start=6391
-  _globals['_APPLYEDITSREQUEST']._serialized_end=6472
-  _globals['_EDITOPERATION']._serialized_start=6475
-  _globals['_EDITOPERATION']._serialized_end=6696
-  _globals['_ADDMEMBEREDIT']._serialized_start=6699
-  _globals['_ADDMEMBEREDIT']._serialized_end=6829
-  _globals['_DELETEEDIT']._serialized_start=6831
-  _globals['_DELETEEDIT']._serialized_end=6876
-  _globals['_MOVEEDIT']._serialized_start=6878
-  _globals['_MOVEEDIT']._serialized_end=6919
-  _globals['_SETVALUEEDIT']._serialized_start=6921
-  _globals['_SETVALUEEDIT']._serialized_end=6966
-  _globals['_RENAMEEDIT']._serialized_start=6968
-  _globals['_RENAMEEDIT']._serialized_end=7014
-  _globals['_APPLYEDITSRESPONSE']._serialized_start=7017
-  _globals['_APPLYEDITSRESPONSE']._serialized_end=7211
-  _globals['_APPLIEDEDIT']._serialized_start=7213
-  _globals['_APPLIEDEDIT']._serialized_end=7335
-  _globals['_SYMBOLINFO']._serialized_start=7338
-  _globals['_SYMBOLINFO']._serialized_end=7719
-  _globals['_SYMBOLINFO_METADATAENTRY']._serialized_start=7672
-  _globals['_SYMBOLINFO_METADATAENTRY']._serialized_end=7719
-  _globals['_SPECIALIZATION']._serialized_start=7721
-  _globals['_SPECIALIZATION']._serialized_end=7809
-  _globals['_TYPEINFO']._serialized_start=7812
-  _globals['_TYPEINFO']._serialized_end=7961
-  _globals['_MULTIPLICITYINFO']._serialized_start=7963
-  _globals['_MULTIPLICITYINFO']._serialized_end=8011
-  _globals['_ATTRIBUTEINFO']._serialized_start=8013
-  _globals['_ATTRIBUTEINFO']._serialized_end=8099
-  _globals['_VALUE']._serialized_start=8102
-  _globals['_VALUE']._serialized_end=8809
-  _globals['_METAOBJECT']._serialized_start=8811
-  _globals['_METAOBJECT']._serialized_end=8865
-  _globals['_UNDETERMINED']._serialized_start=8867
-  _globals['_UNDETERMINED']._serialized_end=8937
-  _globals['_FUNCTION']._serialized_start=8939
-  _globals['_FUNCTION']._serialized_end=8983
-  _globals['_VALUESET']._serialized_start=8985
-  _globals['_VALUESET']._serialized_end=9027
-  _globals['_TENSORQUANTITY']._serialized_start=9029
-  _globals['_TENSORQUANTITY']._serialized_end=9102
-  _globals['_ARRAY']._serialized_start=9104
-  _globals['_ARRAY']._serialized_end=9163
-  _globals['_VECTOR']._serialized_start=9165
-  _globals['_VECTOR']._serialized_end=9207
-  _globals['_VECTORQUANTITY']._serialized_start=9209
-  _globals['_VECTORQUANTITY']._serialized_end=9262
-  _globals['_COMPLEX']._serialized_start=9264
-  _globals['_COMPLEX']._serialized_end=9306
-  _globals['_ENUMLITERAL']._serialized_start=9308
-  _globals['_ENUMLITERAL']._serialized_end=9408
-  _globals['_VALUESEQUENCE']._serialized_start=9410
-  _globals['_VALUESEQUENCE']._serialized_end=9457
-  _globals['_QUANTITY']._serialized_start=9459
-  _globals['_QUANTITY']._serialized_end=9583
-  _globals['_MEASUREMENTREF']._serialized_start=9585
-  _globals['_MEASUREMENTREF']._serialized_end=9668
-  _globals['_UNITTERM']._serialized_start=9670
-  _globals['_UNITTERM']._serialized_end=9754
-  _globals['_UNITFACTOR']._serialized_start=9756
-  _globals['_UNITFACTOR']._serialized_end=9803
-  _globals['_DIAGNOSTIC']._serialized_start=9805
-  _globals['_DIAGNOSTIC']._serialized_end=9893
-  _globals['_SPAN']._serialized_start=9895
-  _globals['_SPAN']._serialized_end=9989
-  _globals['_SERVERINFOREQUEST']._serialized_start=9991
-  _globals['_SERVERINFOREQUEST']._serialized_end=10010
-  _globals['_SERVERINFORESPONSE']._serialized_start=10012
-  _globals['_SERVERINFORESPONSE']._serialized_end=10071
-  _globals['_QUERYREQUEST']._serialized_start=10073
-  _globals['_QUERYREQUEST']._serialized_end=10156
-  _globals['_QUERYRESPONSE']._serialized_start=10158
-  _globals['_QUERYRESPONSE']._serialized_end=10218
-  _globals['_QUERY']._serialized_start=10220
-  _globals['_QUERY']._serialized_end=10292
-  _globals['_CONSTRAINT']._serialized_start=10294
-  _globals['_CONSTRAINT']._serialized_end=10418
-  _globals['_PRIMITIVECONSTRAINT']._serialized_start=10420
-  _globals['_PRIMITIVECONSTRAINT']._serialized_end=10535
-  _globals['_COMPOSITECONSTRAINT']._serialized_start=10537
-  _globals['_COMPOSITECONSTRAINT']._serialized_end=10641
-  _globals['_QUERYRESULTELEMENT']._serialized_start=10644
-  _globals['_QUERYRESULTELEMENT']._serialized_end=10804
-  _globals['_QUERYRESULTELEMENT_PROPERTIESENTRY']._serialized_start=10755
-  _globals['_QUERYRESULTELEMENT_PROPERTIESENTRY']._serialized_end=10804
-  _globals['_SWEEPRANGE']._serialized_start=10806
-  _globals['_SWEEPRANGE']._serialized_end=10921
-  _globals['_RUNSWEEPREQUEST']._serialized_start=10924
-  _globals['_RUNSWEEPREQUEST']._serialized_end=11260
-  _globals['_RUNSWEEPREQUEST_NAMEDARGUMENTSENTRY']._serialized_start=2213
-  _globals['_RUNSWEEPREQUEST_NAMEDARGUMENTSENTRY']._serialized_end=2280
-  _globals['_SWEEPROW']._serialized_start=11263
-  _globals['_SWEEPROW']._serialized_end=11507
-  _globals['_RUNSWEEPRESPONSE']._serialized_start=11510
-  _globals['_RUNSWEEPRESPONSE']._serialized_end=11811
-  _globals['_RUNDOCUMENTQUERYREQUEST']._serialized_start=11813
-  _globals['_RUNDOCUMENTQUERYREQUEST']._serialized_end=11923
-  _globals['_DOCUMENTQUERYBINDING']._serialized_start=11925
-  _globals['_DOCUMENTQUERYBINDING']._serialized_end=12004
-  _globals['_DOCUMENTVALUE']._serialized_start=12007
-  _globals['_DOCUMENTVALUE']._serialized_end=12220
-  _globals['_DOCUMENTQUERYCOLUMN']._serialized_start=12222
-  _globals['_DOCUMENTQUERYCOLUMN']._serialized_end=12257
-  _globals['_DOCUMENTQUERYCELL']._serialized_start=12259
-  _globals['_DOCUMENTQUERYCELL']._serialized_end=12316
-  _globals['_DOCUMENTQUERYROW']._serialized_start=12318
-  _globals['_DOCUMENTQUERYROW']._serialized_end=12416
-  _globals['_RUNDOCUMENTQUERYRESPONSE']._serialized_start=12418
-  _globals['_RUNDOCUMENTQUERYRESPONSE']._serialized_end=12528
-  _globals['_RENDERDOCUMENTREQUEST']._serialized_start=12530
-  _globals['_RENDERDOCUMENTREQUEST']._serialized_end=12594
-  _globals['_RENDERDOCUMENTRESPONSE']._serialized_start=12596
-  _globals['_RENDERDOCUMENTRESPONSE']._serialized_end=12638
-  _globals['_SYSMLSERVICE']._serialized_start=13668
-  _globals['_SYSMLSERVICE']._serialized_end=15182
-# @@protoc_insertion_point(module_scope)
diff --git a/clients/python/scripts/check_version.py b/clients/python/scripts/check_version.py
deleted file mode 100644
index 8a85d7d698..0000000000
--- a/clients/python/scripts/check_version.py
+++ /dev/null
@@ -1,143 +0,0 @@
-#!/usr/bin/env python3
-"""Gate a opensysml release on the tag and the declared version agreeing.
-
-Used by the `publish-pypi` CircleCI job before anything is built or uploaded:
-a PyPI version can be yanked but never re-uploaded, so a tag that does not name
-the version the package would publish must fail here rather than after the fact.
-
-    python scripts/check_version.py --tag opensysml-v0.1.0
-
-Prints the version the tag names on success. With `--pre-release` it prints
-`yes`/`no` instead, which the job uses to route a pre-release tag to TestPyPI.
-"""
-
-import argparse
-import ast
-import os
-import sys
-
-from packaging.version import InvalidVersion, Version
-
-TAG_PREFIX = "opensysml-v"
-
-VERSION_FILE = os.path.join(
-    os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "opensysml", "_version.py"
-)
-
-
-class VersionError(Exception):
-    """A tag that does not name a publishable opensysml version."""
-
-
-def declared_version(version_file=VERSION_FILE):
-    """The version declared in opensysml/_version.py.
-
-    Read rather than imported, so the check needs neither the package's
-    dependencies nor an installed distribution.
-
-    Args:
-        version_file (str): Path to opensysml/_version.py
-
-    Returns:
-        str: The declared version
-
-    Raises:
-        VersionError: If the file declares no VERSION string
-    """
-    with open(version_file, encoding="utf-8") as f:
-        module = ast.parse(f.read(), filename=version_file)
-    for node in module.body:
-        if isinstance(node, ast.Assign) and any(
-            isinstance(t, ast.Name) and t.id == "VERSION" for t in node.targets
-        ):
-            if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
-                return node.value.value
-    raise VersionError(f"{version_file} declares no VERSION string")
-
-
-def version_from_tag(tag, version=None):
-    """The version a release tag names, checked against the declared version.
-
-    Args:
-        tag (str): Release tag, e.g. 'opensysml-v0.1.0'
-        version (str, optional): Declared version; read from
-            opensysml/_version.py when omitted
-
-    Returns:
-        str: The version to publish
-
-    Raises:
-        VersionError: If the tag is empty, is not a opensysml tag, or names a
-            version other than the declared one
-    """
-    declared = version if version is not None else declared_version()
-    if not tag:
-        raise VersionError(
-            "No tag given. The publish job runs on a "
-            f"{TAG_PREFIX}<version> tag and reads CIRCLE_TAG."
-        )
-    if not tag.startswith(TAG_PREFIX):
-        raise VersionError(
-            f"Tag {tag!r} does not start with {TAG_PREFIX!r}. A opensysml release "
-            f"is cut by a {TAG_PREFIX}<version> tag; a core release tag (v*) "
-            "publishes the binaries and the GitHub release, not the package."
-        )
-    tag_version = tag[len(TAG_PREFIX):]
-    if tag_version != declared:
-        raise VersionError(
-            f"Tag {tag!r} names version {tag_version!r}, but "
-            f"clients/python/opensysml/_version.py declares {declared!r}. "
-            "Publishing would put a version on PyPI that the package does not "
-            "report. Fix one of the two and tag again."
-        )
-    return tag_version
-
-
-def is_pre_release(version):
-    """Whether a version is a PEP 440 pre-release (alpha/beta/rc).
-
-    Args:
-        version (str): Version string
-
-    Returns:
-        bool: True for a pre-release, which is published to TestPyPI
-
-    Raises:
-        VersionError: If the version is not a valid PEP 440 version
-    """
-    try:
-        return Version(version).is_prerelease
-    except InvalidVersion as e:
-        raise VersionError(f"{version!r} is not a PEP 440 version: {e}")
-
-
-def main(argv=None):
-    parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument(
-        "--tag",
-        default=os.environ.get("CIRCLE_TAG", ""),
-        help="release tag (default: $CIRCLE_TAG)",
-    )
-    parser.add_argument(
-        "--pre-release",
-        action="store_true",
-        help="print yes/no for whether the tag names a pre-release",
-    )
-    args = parser.parse_args(argv)
-
-    try:
-        version = version_from_tag(args.tag)
-        pre_release = is_pre_release(version)
-    except VersionError as e:
-        print(f"error: {e}", file=sys.stderr)
-        return 1
-
-    if args.pre_release:
-        print("yes" if pre_release else "no")
-    else:
-        print(version)
-    return 0
-
-
-if __name__ == "__main__":
-    sys.exit(main())
diff --git a/clients/python/tests/test_check_version.py b/clients/python/tests/test_check_version.py
deleted file mode 100644
index ac889c5408..0000000000
--- a/clients/python/tests/test_check_version.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""Tests for scripts/check_version.py, the release gate the publish job runs."""
-
-import importlib.util
-import pathlib
-
-import pytest
-
-SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "check_version.py"
-spec = importlib.util.spec_from_file_location("check_version", SCRIPT)
-check_version = importlib.util.module_from_spec(spec)
-assert spec.loader is not None
-spec.loader.exec_module(check_version)
-
-
-def test_declared_version_reads_the_shipped_version_file():
-    from opensysml import __version__
-
-    assert check_version.declared_version() == __version__
-
-
-def test_declared_version_rejects_a_file_without_a_version(tmp_path):
-    version_file = tmp_path / "_version.py"
-    version_file.write_text("OTHER = '1.0'\n", encoding="utf-8")
-    with pytest.raises(check_version.VersionError, match="declares no VERSION"):
-        check_version.declared_version(str(version_file))
-
-
-def test_version_from_tag_accepts_the_declared_version():
-    assert check_version.version_from_tag("opensysml-v0.4.0", version="0.4.0") == "0.4.0"
-
-
-@pytest.mark.parametrize(
-    "tag, message",
-    [
-        ("", "No tag given"),
-        ("v0.4.0", "does not start with 'opensysml-v'"),
-        ("opensysml-v0.4.1", "names version '0.4.1', but"),
-    ],
-)
-def test_version_from_tag_rejects_a_tag_that_names_another_version(tag, message):
-    with pytest.raises(check_version.VersionError, match=message):
-        check_version.version_from_tag(tag, version="0.4.0")
-
-
-@pytest.mark.parametrize(
-    "version, pre_release",
-    [("0.4.0", False), ("0.4.0rc1", True), ("1.0.0a2", True), ("1.0.0.post1", False)],
-)
-def test_is_pre_release_follows_pep_440(version, pre_release):
-    assert check_version.is_pre_release(version) is pre_release
-
-
-def test_is_pre_release_rejects_a_non_pep_440_version():
-    with pytest.raises(check_version.VersionError, match="not a PEP 440 version"):
-        check_version.is_pre_release("latest")
-
-
-def test_main_prints_the_version_the_tag_names(capsys):
-    declared = check_version.declared_version()
-    assert check_version.main(["--tag", f"opensysml-v{declared}"]) == 0
-    assert capsys.readouterr().out.strip() == declared
-
-
-def test_main_routes_a_pre_release_by_printing_yes_or_no(capsys):
-    declared = check_version.declared_version()
-    assert check_version.main(["--tag", f"opensysml-v{declared}", "--pre-release"]) == 0
-    expected = "yes" if check_version.is_pre_release(declared) else "no"
-    assert capsys.readouterr().out.strip() == expected
-
-
-def test_main_fails_on_a_tag_for_another_version(capsys):
-    assert check_version.main(["--tag", "opensysml-v0.0.0-not-declared"]) == 1
-    captured = capsys.readouterr()
-    assert captured.out == ""
-    assert "error:" in captured.err
-    assert "declares" in captured.err
-
-
-def test_main_reads_the_tag_from_circle_tag(monkeypatch, capsys):
-    declared = check_version.declared_version()
-    monkeypatch.setenv("CIRCLE_TAG", f"opensysml-v{declared}")
-    assert check_version.main([]) == 0
-    assert capsys.readouterr().out.strip() == declared
diff --git a/clients/python/tests/test_document.py b/clients/python/tests/test_document.py
deleted file mode 100644
index 5501034570..0000000000
--- a/clients/python/tests/test_document.py
+++ /dev/null
@@ -1,381 +0,0 @@
-"""Tests for named document queries and document rendering.
-
-Two layers, as :mod:`tests.test_query` has. Against a fake service, the
-client's own behavior: binding translation, decoding, and the capability
-gates. Against the real ``sysml-grpc`` binary, the answers themselves — the
-typed rows and the rendered Markdown.
-"""
-
-import os
-import subprocess
-import time
-from concurrent import futures
-
-import grpc
-import pytest
-
-from opensysml.capabilities import (
-    CAPABILITY_DOCUMENT_QUERY,
-    CAPABILITY_RENDER_DOCUMENT,
-    MissingCapabilityError,
-)
-from opensysml.connection import Connection
-from opensysml.document import (
-    INFINITY,
-    DocumentQueryError,
-    DocumentQueryResult,
-    DocumentRow,
-    ElementRef,
-    build_bindings,
-)
-from opensysml.errors import (
-    InvalidRequestError,
-    ModelNotFoundError,
-    SymbolNotFoundError,
-    UnsupportedValueError,
-)
-from opensysml.proto import sysml_pb2, sysml_pb2_grpc
-from opensysml.values import Quantity, Unit, UnitFactor
-
-REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
-GRPC_BINARIES = (
-    os.path.join(REPO_ROOT, "bin", "sysml-grpc"),
-    os.path.join(os.path.expanduser("~"), ".opensysml", "bin", "sysml-grpc"),
-)
-
-#: The document pipeline's own telescope fixture and its golden Markdown, so
-#: the client sees exactly what the renderer's tests lock in.
-FIXTURE = os.path.join(
-    REPO_ROOT, "internal", "core", "docrender", "testdata", "telescope_report.sysml"
-)
-GOLDEN = os.path.join(
-    REPO_ROOT, "internal", "core", "docrender", "testdata", "telescope_report.golden.md"
-)
-
-CAPABILITIES = (CAPABILITY_DOCUMENT_QUERY, CAPABILITY_RENDER_DOCUMENT)
-
-
-class FakeService(sysml_pb2_grpc.SysMLServiceServicer):
-    """A sysml-grpc whose document RPCs record requests and answer as told."""
-
-    def __init__(self, capabilities=CAPABILITIES, response=None, markdown=""):
-        self._capabilities = list(capabilities)
-        self._response = response or sysml_pb2.RunDocumentQueryResponse()
-        self._markdown = markdown
-        self.requests = []
-
-    def GetServerInfo(self, request, context):
-        return sysml_pb2.ServerInfoResponse(
-            version="fake", capabilities=self._capabilities
-        )
-
-    def GetDiagnostics(self, request, context):
-        context.abort(grpc.StatusCode.NOT_FOUND, "model not found")
-
-    def ParseFile(self, request, context):
-        root = sysml_pb2.SymbolInfo(id="Demo", name="Demo", kind="Package")
-        return sysml_pb2.ParseFileResponse(model_hash="fake-hash", root=root)
-
-    def RunDocumentQuery(self, request, context):
-        self.requests.append(request)
-        return self._response
-
-    def RenderDocument(self, request, context):
-        self.requests.append(request)
-        return sysml_pb2.RenderDocumentResponse(markdown=self._markdown)
-
-
-@pytest.fixture
-def fake_service():
-    """Start a FakeService on an ephemeral port; yields a factory."""
-    servers = []
-
-    def start(**kwargs):
-        service = FakeService(**kwargs)
-        server = grpc.server(futures.ThreadPoolExecutor(max_workers=2))
-        sysml_pb2_grpc.add_SysMLServiceServicer_to_server(service, server)
-        port = server.add_insecure_port("localhost:0")
-        server.start()
-        servers.append(server)
-        return port, service
-
-    yield start
-    for server in servers:
-        server.stop(None)
-
-
-def test_bindings_translate_to_typed_values():
-    """Each Python kind becomes its own wire kind, bool before int."""
-    bindings = build_bindings({
-        "root": ElementRef("Observatory::telescope"),
-        "name": "mount",
-        "count": 3,
-        "mass": 1.5,
-        "heavy": True,
-        "several": ["a", "b"],
-    })
-    by_parameter = {b.parameter: b for b in bindings}
-    assert by_parameter["root"].values[0].element_id == "Observatory::telescope"
-    assert by_parameter["name"].values[0].string_value == "mount"
-    assert by_parameter["count"].values[0].int_value == 3
-    assert by_parameter["mass"].values[0].real_value == 1.5
-    assert by_parameter["heavy"].values[0].bool_value is True
-    assert [v.string_value for v in by_parameter["several"].values] == ["a", "b"]
-
-
-def test_a_quantity_binding_keeps_its_magnitude_and_unit():
-    """A Quantity binds as the wire's Quantity, unit and reduction intact."""
-    kg = Unit(text="kg", factors=(UnitFactor("SI::kg", 1),), reduction_given=True)
-    bindings = build_bindings({"limit": Quantity(2290000, kg)})
-    (value,) = bindings[0].values
-    assert value.WhichOneof("kind") == "quantity"
-    assert value.quantity.int_magnitude == 2290000
-    assert value.quantity.unit == "kg"
-    assert value.quantity.unit_term.factors[0].unit_id == "SI::kg"
-
-
-def test_a_binding_the_wire_cannot_carry_is_refused():
-    """An untranslatable value is a caller error, named before anything is sent."""
-    with pytest.raises(DocumentQueryError, match="'root'"):
-        build_bindings({"root": object()})
-
-
-def test_an_oversized_int_binding_is_refused():
-    """An int outside int64 is a caller error, not a protobuf ValueError."""
-    with pytest.raises(DocumentQueryError, match="signed 64-bit"):
-        build_bindings({"threshold": 1 << 63})
-    with pytest.raises(DocumentQueryError, match="signed 64-bit"):
-        build_bindings({"threshold": -(1 << 63) - 1})
-
-
-def test_a_quantity_the_wire_cannot_carry_is_refused():
-    """An unreduced unit or an oversized magnitude is a DocumentQueryError, not
-    the Quantity's own UnsupportedValueError or a protobuf ValueError."""
-    kg = Unit(text="kg", factors=(UnitFactor("SI::kg", 1),), reduction_given=True)
-    unreduced = {"limit": Quantity(1, Unit(text="furlong"))}
-    with pytest.raises(DocumentQueryError, match="'limit'.*no reduction") as caught:
-        build_bindings(unreduced)
-    assert isinstance(caught.value.__cause__, UnsupportedValueError)
-    oversized = {"limit": Quantity(1 << 63, kg)}
-    with pytest.raises(DocumentQueryError, match="'limit'.*signed 64-bit"):
-        build_bindings(oversized)
-    boolean = {"limit": Quantity(True, kg)}
-    with pytest.raises(DocumentQueryError, match="'limit'.*neither an Integer nor a Real"):
-        build_bindings(boolean)
-
-
-def test_no_bindings_is_an_empty_request():
-    assert build_bindings(None) == []
-    assert build_bindings({}) == []
-
-
-def test_document_query_requires_the_capability(fake_service):
-    """A service that cannot run document queries is named, not asked."""
-    port, service = fake_service(capabilities=())
-    with Connection(port=port, auto_start=False) as conn:
-        model = conn.load_from_content("package Demo;")
-        with pytest.raises(MissingCapabilityError) as excinfo:
-            model.run_document_query("Demo::Q")
-    assert excinfo.value.capability == CAPABILITY_DOCUMENT_QUERY
-    assert service.requests == []
-
-
-def test_render_document_requires_the_capability(fake_service):
-    """A service that cannot render documents is named, not asked."""
-    port, service = fake_service(capabilities=())
-    with Connection(port=port, auto_start=False) as conn:
-        model = conn.load_from_content("package Demo;")
-        with pytest.raises(MissingCapabilityError) as excinfo:
-            model.render_document("Demo::Doc")
-    assert excinfo.value.capability == CAPABILITY_RENDER_DOCUMENT
-    assert service.requests == []
-
-
-def test_the_request_names_the_model_query_and_bindings(fake_service):
-    """The request carries the model's own hash, the query, and the bindings."""
-    port, service = fake_service()
-    with Connection(port=port, auto_start=False) as conn:
-        model = conn.load_from_content("package Demo;")
-        model.run_document_query(
-            "Demo::Q", bindings={"root": ElementRef("Demo::part")}
-        )
-    (request,) = service.requests
-    assert request.model_hash == model.hash
-    assert request.query_id == "Demo::Q"
-    assert request.bindings[0].parameter == "root"
-    assert request.bindings[0].values[0].element_id == "Demo::part"
-
-
-def test_answered_rows_decode_to_typed_records(fake_service):
-    """Every wire kind decodes to its Python value, elements with their type."""
-    response = sysml_pb2.RunDocumentQueryResponse(
-        columns=[
-            sysml_pb2.DocumentQueryColumn(name="name"),
-            sysml_pb2.DocumentQueryColumn(name="bound"),
-        ],
-        rows=[sysml_pb2.DocumentQueryRow(
-            element=sysml_pb2.DocumentValue(
-                element_id="Demo::part", element_type="PartUsage"
-            ),
-            cells=[
-                sysml_pb2.DocumentQueryCell(values=[
-                    sysml_pb2.DocumentValue(string_value="part"),
-                ]),
-                sysml_pb2.DocumentQueryCell(values=[
-                    sysml_pb2.DocumentValue(int_value=0),
-                    sysml_pb2.DocumentValue(infinity=True),
-                    sysml_pb2.DocumentValue(real_value=1.5),
-                    sysml_pb2.DocumentValue(bool_value=True),
-                    sysml_pb2.DocumentValue(quantity=sysml_pb2.Quantity(
-                        int_magnitude=2290000,
-                        unit="kg",
-                        unit_term=sysml_pb2.UnitTerm(
-                            scale_num=1.0,
-                            scale_den=1.0,
-                            factors=[sysml_pb2.UnitFactor(unit_id="SI::kg", exponent=1)],
-                        ),
-                    )),
-                ]),
-            ],
-        )],
-    )
-    port, _ = fake_service(response=response)
-    with Connection(port=port, auto_start=False) as conn:
-        result = conn.load_from_content("package Demo;").run_document_query("Demo::Q")
-
-    assert isinstance(result, DocumentQueryResult)
-    assert result.columns == ("name", "bound")
-    (row,) = result
-    assert isinstance(row, DocumentRow)
-    assert row.element == ElementRef(id="Demo::part", type="PartUsage")
-    assert str(row.element) == "Demo::part (PartUsage)"
-    assert row[0] == ("part",)
-    kg = Unit(text="kg", factors=(UnitFactor("SI::kg", 1),), reduction_given=True)
-    assert row[1] == (0, INFINITY, 1.5, True, Quantity(2290000, kg))
-    assert str(row[1][4]) == "2290000 [kg]"
-    assert len(result) == 1
-
-
-def test_render_document_answers_the_markdown(fake_service):
-    port, service = fake_service(markdown="# Report\n")
-    with Connection(port=port, auto_start=False) as conn:
-        model = conn.load_from_content("package Demo;")
-        assert model.render_document("Demo::Doc") == "# Report\n"
-    (request,) = service.requests
-    assert request.model_hash == model.hash
-    assert request.document_id == "Demo::Doc"
-
-
-@pytest.fixture(scope="module")
-def real_service():
-    """Run the built sysml-grpc on an ephemeral port, or skip."""
-    binary = next((b for b in GRPC_BINARIES if os.access(b, os.X_OK)), None)
-    if binary is None:
-        pytest.skip(f"no executable sysml-grpc in {GRPC_BINARIES}; run: make build-grpc")
-
-    port = 51157
-    process = subprocess.Popen(
-        [binary, "-port", str(port)],
-        stdout=subprocess.DEVNULL,
-        stderr=subprocess.DEVNULL,
-    )
-    try:
-        deadline = time.time() + 10
-        while time.time() < deadline:
-            with grpc.insecure_channel(f"localhost:{port}") as channel:
-                try:
-                    grpc.channel_ready_future(channel).result(timeout=0.5)
-                    break
-                except grpc.FutureTimeoutError:
-                    continue
-        else:
-            pytest.fail("sysml-grpc did not start")
-        yield port
-    finally:
-        process.terminate()
-        process.wait(timeout=10)
-
-
-@pytest.fixture(scope="module")
-def telescope():
-    """The telescope fixture's source, read from the repo it tests."""
-    with open(FIXTURE, encoding="utf-8") as f:
-        return f.read()
-
-
-@pytest.mark.integration
-class TestDocumentsAgainstRealService:
-    """The answers themselves, from the real engine."""
-
-    def test_a_document_query_answers_typed_ordered_rows(self, real_service, telescope):
-        with Connection(port=real_service, auto_start=False) as conn:
-            result = conn.load_from_content(telescope).run_document_query(
-                "Observatory::SubsystemTable",
-                bindings={"root": ElementRef("Observatory::telescope")},
-            )
-        assert result.columns == ("name", "mass")
-        assert [row.element.id for row in result] == [
-            "Observatory::telescope::baffle|shroud *tricky*",
-            "Observatory::telescope::mount",
-            "Observatory::telescope::optics",
-            "Observatory::telescope::segmentControl",
-        ]
-        assert [row[0] for row in result] == [
-            ("baffle|shroud *tricky*",), ("mount",), ("optics",), ("segmentControl",),
-        ]
-        assert [row[1] for row in result] == [(1.5,), (15.0,), (8.5,), (20.0,)]
-        assert {row.element.type for row in result} == {"PartUsage"}
-
-    def test_a_query_matching_nothing_answers_columns_and_no_rows(
-        self, real_service, telescope
-    ):
-        with Connection(port=real_service, auto_start=False) as conn:
-            result = conn.load_from_content(telescope).run_document_query(
-                "Observatory::MissingSubsystems",
-                bindings={"root": ElementRef("Observatory::telescope")},
-            )
-        assert result.columns == ("name", "mass")
-        assert len(result) == 0
-
-    def test_a_rendered_document_is_the_renderer_s_golden(self, real_service, telescope):
-        with open(GOLDEN, encoding="utf-8") as f:
-            golden = f.read()
-        with Connection(port=real_service, auto_start=False) as conn:
-            markdown = conn.load_from_content(telescope).render_document(
-                "Observatory::MassReport"
-            )
-        assert markdown == golden
-
-    def test_an_unknown_query_raises_symbol_not_found(self, real_service, telescope):
-        with Connection(port=real_service, auto_start=False) as conn:
-            model = conn.load_from_content(telescope)
-            with pytest.raises(SymbolNotFoundError):
-                model.run_document_query("Observatory::NoSuchQuery")
-
-    def test_a_symbol_that_is_not_a_query_is_refused(self, real_service, telescope):
-        with Connection(port=real_service, auto_start=False) as conn:
-            model = conn.load_from_content(telescope)
-            with pytest.raises(InvalidRequestError, match="not a document query"):
-                model.run_document_query("Observatory::Subsystem")
-
-    def test_a_symbol_that_is_not_a_document_is_refused(self, real_service, telescope):
-        with Connection(port=real_service, auto_start=False) as conn:
-            model = conn.load_from_content(telescope)
-            with pytest.raises(InvalidRequestError, match="not a document"):
-                model.render_document("Observatory::SubsystemTable")
-
-    def test_a_wrong_binding_is_refused_with_the_engine_s_message(
-        self, real_service, telescope
-    ):
-        with Connection(port=real_service, auto_start=False) as conn:
-            model = conn.load_from_content(telescope)
-            bindings = {"depth": 3, "root": ElementRef("Observatory::telescope")}
-            with pytest.raises(InvalidRequestError):
-                model.run_document_query("Observatory::SubsystemTable", bindings=bindings)
-
-    def test_an_evicted_model_raises_this_library_s_error(self, real_service):
-        with Connection(port=real_service, auto_start=False) as conn:
-            with pytest.raises(ModelNotFoundError):
-                conn.run_document_query("deadbeef", "Observatory::SubsystemTable")
-            with pytest.raises(ModelNotFoundError):
-                conn.render_document("deadbeef", "Observatory::MassReport")
diff --git a/clients/python/tests/test_model_surface_integration.py b/clients/python/tests/test_model_surface_integration.py
deleted file mode 100644
index 53f74d9b12..0000000000
--- a/clients/python/tests/test_model_surface_integration.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""Integration tests for the model surface against a real service.
-
-Covers evaluation on the model itself and the wrong-kind requests the service
-now classifies, since both are about what the service actually answers rather
-than about how the client wraps a canned response.
-"""
-
-import pytest
-
-from opensysml import Connection
-from opensysml.errors import ExecutionError, ModelNotFoundError, WrongKindError
-from opensysml.model import Model
-from opensysml.proto import sysml_pb2
-
-MODEL_SOURCE = '''
-package Demo {
-    part def Vehicle {
-        attribute mass default = 1500.0;
-        constraint massPositive {
-            assert mass > 0.0;
-        }
-        constraint massLight {
-            assert mass < 100.0;
-        }
-        requirement lightEnough {
-            require constraint { mass < 2000.0 }
-        }
-    }
-
-    part sedan : Vehicle {
-        attribute :>> mass = 1200.0;
-    }
-
-    calc add {
-        in x;
-        in y;
-        x + y
-    }
-}
-'''
-
-
-@pytest.mark.integration
-class TestModelSurfaceIntegration:
-    def setup_method(self):
-        self.conn = Connection()
-        self.model = self.conn.load_from_content(MODEL_SOURCE)
-
-    def teardown_method(self):
-        self.conn.close()
-
-    def test_eval_on_the_model(self):
-        assert self.model.eval("1+1") == 2
-
-    def test_eval_in_a_context(self):
-        assert self.model.eval("mass", context_symbol_id="Demo::sedan") == 1200.0
-
-    def test_eval_against_a_subject_reads_that_object(self):
-        # The object's redefinition wins over the definition's default, the way
-        # %eval does after %instantiate.
-        assert self.model.eval("mass", context_symbol_id="Demo::Vehicle") == 1500.0
-        assert self.model.eval("mass", subject="Demo::sedan") == 1200.0
-        assert self.model.eval("mass * 2", subject="Demo::sedan") == 2400.0
-
-    def test_eval_against_a_subject_in_a_named_context(self):
-        assert (
-            self.model.eval(
-                "mass",
-                context_symbol_id="Demo::Vehicle",
-                subject="Demo::sedan",
-            )
-            == 1200.0
-        )
-
-    def test_eval_raises_for_an_unknown_subject(self):
-        with pytest.raises(ExecutionError):
-            self.model.eval("mass", subject="Demo::nope")
-
-    @pytest.mark.parametrize("expression", ["1/0", "nope", '1 + "a"'])
-    def test_eval_raises_for_an_expression_it_cannot_evaluate(self, expression):
-        with pytest.raises(ExecutionError):
-            self.model.eval(expression)
-
-    def test_eval_raises_when_the_service_no_longer_holds_the_model(self):
-        # A model whose hash the service's bounded cache has evicted.
-        evicted = Model(
-            sysml_pb2.ParseFileResponse(
-                model_hash="0" * 64,
-                root=sysml_pb2.SymbolInfo(id="Demo", name="Demo", kind="Package"),
-            ),
-            self.conn,
-        )
-        with pytest.raises(ModelNotFoundError):
-            evicted.eval("1+1")
-
-    def test_a_verdict_is_still_a_verdict(self):
-        assert self.model.verify_constraint(
-            "Demo::Vehicle::massPositive", subject="Demo::sedan"
-        ).holds
-        assert self.model.verify_constraint(
-            "Demo::Vehicle::massLight", subject="Demo::sedan"
-        ).holds is False
-        assert self.model.verify_requirement(
-            "Demo::Vehicle::lightEnough", subject="Demo::sedan"
-        ).holds
-
-    def test_a_wrong_kind_verification_raises(self):
-        for call in (
-            lambda: self.model.verify_constraint("Demo::Vehicle"),
-            lambda: self.model.verify_requirement("Demo::Vehicle"),
-            lambda: self.model.calc("Demo::Vehicle", arguments=[1]),
-        ):
-            with pytest.raises(WrongKindError):
-                call()
-
-    def test_an_unknown_symbol_still_raises(self):
-        for call in (
-            lambda: self.model.verify_constraint("Demo::Nope"),
-            lambda: self.model.verify_requirement("Demo::Nope"),
-            lambda: self.model.verify_satisfaction("Demo::Nope"),
-            lambda: self.model.calc("Demo::Nope", arguments=[1]),
-        ):
-            with pytest.raises(ExecutionError):
-                call()
-
-    def test_an_element_stating_no_assertion_still_answers_with_none(self):
-        assert self.model.verify_satisfaction("Demo::Vehicle") == []
diff --git a/clients/rust/README.md b/clients/rust/README.md
deleted file mode 100644
index e742ce5ac8..0000000000
--- a/clients/rust/README.md
+++ /dev/null
@@ -1,252 +0,0 @@
-# OpenSysML Rust client
-
-`opensysml` is a blocking Rust client for the local `sysml-grpc` service. It
-is not published to crates.io yet. The crate name still needs to be checked
-for availability, and publishing is a maintainer decision.
-
-## Installation
-
-For now, use a path dependency while developing against a checkout:
-
-```toml
-[dependencies]
-opensysml = { path = "../OpenSysML/clients/rust/opensysml" }
-```
-
-The current Git dependency form is:
-
-```toml
-[dependencies]
-opensysml = { git = "https://github.com/Open-MBEE/OpenSysML.git", branch = "main" }
-```
-
-The minimum supported Rust version is **Rust 1.83**.
-
-## Why blocking
-
-The client is blocking by default and has no asynchronous runtime anywhere in
-its normal dependency tree. All 15 service RPCs are unary, and the usual
-consumer talks to a local child that answers in milliseconds. Async buys the
-average consumer little here, while putting a private `tokio::Runtime` in a
-library taxes every consumer. That is why this client does not use `tonic`.
-
-The runtime-free design also has no nested-runtime hazard: the library has no
-`Runtime::new` that could panic inside an existing runtime. The
-`blocking_calls_work_inside_a_runtime` test calls the client from
-`Runtime::block_on` to pin this property. An async surface could be added
-later behind a feature flag without changing the default.
-
-## Transport
-
-Protobuf request and response bodies are the default transport. JSON remains
-available as the `curl` and debugging affordance. The measured comparison in
-[`docs/internals/design/transport-evaluation.md`](../../docs/internals/design/transport-evaluation.md)
-was 6.5 ms for protobuf versus 42 ms for JSON on a 468 KB response.
-
-## Service lifecycle
-
-There are two connection modes.
-
-* `Connection::private()` starts one child process per parent process with
-  `-port 0 -health-port 0 -report-address -exit-with-parent`. The address is
-  read from the child's first stdout line. The child is shared, so its parse
-  cache is shared too.
-* `Connection::external(host, port)` explicitly connects to an existing
-  service. `Connection::connect()` also accepts `$OPENSYSML_SERVICE`. Closing
-  an external connection does not stop that service.
-
-`Drop` gives deterministic cleanup rather than relying on Python garbage
-collection or JVM finalizers. `Drop` does not run for `std::process::exit`,
-`abort`, or `SIGKILL`, so the stronger guarantee is the stdin pipe: the client
-holds it, never writes to it, and the child observes EOF when the kernel closes
-it as the process dies. The SIGKILL lifecycle test pins this orphan-cleanup
-behavior.
-
-## Binary provisioning
-
-Resolution is, in order:
-
-1. `$OPENSYSML_GRPC_BINARY`, the explicit path;
-2. `~/.opensysml/bin/sysml-grpc` (`sysml-grpc.exe` on Windows), the cache shared
-   with the Python client;
-3. a download of the release `$OPENSYSML_GRPC_VERSION` asks for, into that cache;
-4. `sysml-grpc` on `$PATH`.
-
-A download only happens when `$OPENSYSML_GRPC_VERSION` names a release
-(`latest` resolves through the GitHub releases API), so a caller that never asks
-for one still resolves a locally built binary from `$PATH`. When a release *is*
-asked for, the download precedes `$PATH`, because a binary on `$PATH` is of no
-known version and so does not answer for that release. A cached binary that is
-another release is replaced with a warning, never used silently; a replacement
-that cannot be downloaded leaves the working cache in place, unless the refusal
-was about integrity.
-
-The download goes to a temporary file, is verified, and only then atomically
-replaces the cache with mode `0700` (POSIX). Requests time out after 15 seconds.
-Beside the binary the client writes `sysml-grpc.json` — `version`, `sha256`,
-`repo` — the same shape the Python client reads and writes, and re-checks the
-recorded digest before reusing a cache, so a hand-swapped binary is not read as
-the release it displaced. Without `$HOME` (`$USERPROFILE` on Windows) there is no
-cache: resolution says so rather than treating the working directory as a home.
-
-The cache is one path several clients install over, so two things guard it. The
-whole check-and-install is done holding `~/.opensysml/bin/sysml-grpc.lock` — the
-same advisory lock the Python and Java clients take (`fcntl` on POSIX,
-`LockFileEx` on Windows) — so no client pairs one release's bytes with another's
-metadata; a lock that cannot be taken across processes is reported and the
-install still runs, rather than failing to resolve a binary at all. What the
-caller is then handed is not the cache path but a hard link (a copy where the
-filesystem has no links) beside it named for its own digest,
-`sysml-grpc-<first 16 hex digits>`, which the Python and Java clients name the
-same way: a later install replaces the cache, never the file that was verified
-and is about to be started.
-
-| Variable | Effect |
-|---|---|
-| `$OPENSYSML_GRPC_BINARY` | Explicit binary path; nothing is downloaded. |
-| `$OPENSYSML_GRPC_VERSION` | Release tag to install, or `latest`. |
-| `$OPENSYSML_GITHUB_REPO` | Repository to download from; default `Open-MBEE/OpenSysML`. |
-| `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD` | `1`, or an `owner/repo` (comma-separated), to accept an unpinned release on same-origin trust. |
-
-### Trust model, and what this client does not verify
-
-A download is verified against the digest table the crate ships
-([`opensysml/release-digests.json`](opensysml/release-digests.json), a synced copy of
-`clients/release-digests.json` embedded with `include_str!`) — a pin resolved
-from outside the published artifact would not be a pin. A `.sha256` served
-beside the binary that disagrees with a pin is tampering: the download is
-refused, and the cache is untouched.
-
-**Known limitation:** unlike the Python, Node and Java clients, this client does
-**not** verify the release's sigstore-signed `SHA256SUMS.txt` manifest
-([`clients/python/opensysml/signing.py`](../python/opensysml/signing.py) is the
-reference). It verifies pins only, so a release the installed crate version pins
-no digest for cannot be verified here at all and is refused, naming the gap. The
-only way through is `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD`, which accepts the
-served `.sha256` with a warning — same origin as the binary, so it detects
-corruption but not a compromised release. In practice, installing a release
-newer than the crate's pins means upgrading the crate.
-
-## Capability negotiation
-
-The service's advertised capability list is the negotiation surface, and the
-client checks it before it calls rather than relying on the refusal: a request
-that needs a capability the service does not have is refused with
-`UNIMPLEMENTED` naming that capability, and checking first turns that into a
-local error naming what to install instead of a transport round trip.
-Capabilities that only describe how a response is populated omit the fields they
-name rather than refusing the call. The client checks request-side requirements
-for:
-
-* `strict_conformance` when strict parsing is requested;
-* `inline_language` for inline KerML content; and
-* `evaluate_subject` when a subject symbol is supplied for evaluation.
-
-Decoding a response is never gated on capabilities: if a service sends an
-enum, unset value, complex number, array, vector, vector quantity, measurement
-reference, function, set, tensor quantity, metaobject, or feature-value arm,
-the client understands that answer. Consumers can inspect `Capabilities::has`
-or use `Capabilities::require` when they need to gate their own use of
-`enum_values`, `unset_value`, `complex_values`, `structured_values`,
-`measurement_refs`, `function_values`, `set_values`, `tensor_values`,
-`metaobject_values`, `feature_values`, or another advertised operation.
-
-A `Value::Set` is a `Collections::Set`'s elements: each member once, sent in
-the service's canonical order (numbers ascending, then strings, and so on), and
-equal to another set holding the same members in any order. Membership is
-judged by `Value::same_value`, as the service judges it: `Integer(1)` and
-`Real(1.0)` are one member, `Real(1.5)` and a `Complex` of `1.5 + 0.0i` are one
-member, exactly across the whole `i64` range, and a `Quantity` is judged by
-magnitude through its `unit_term`, so `1 m` and `100 cm` are one member (one
-without a `unit_term` in its unit as written); `==` on `Value` stays
-structural. A
-`Value::TensorQuantity` is a `Quantities::TensorQuantityValue` of any rank:
-its `dimensions()` and its `components()` flattened row-major, each a
-`Quantity` with its own unit; `get(&[i, j, k])` takes one coordinate per
-dimension. A rank-one tensor stays a `TensorQuantity`, distinct from a
-`VectorQuantity`. A malformed set (a member listed twice) or tensor (a
-non-positive dimension, or components that do not fill the shape) is an
-`Error::Decode`, never a partial value.
-
-A `Value::Metaobject` is an element of the model held as an instance of its
-reflective metaclass: what `x meta KerML::Feature`, or the last element of
-`x.metadata`, evaluates to. Its `element_id` is the FQN of the element
-reflected on and is its identity: two metaobjects are `==` exactly when their
-`element_id` is, whatever type each was cast to. Its `metaclass_id` is the FQN
-of the element's own metaclass (`SysML::Systems::PartUsage`), not the type it
-was cast to. Its features (`declaredName`, `ownedFeature`, ...) are read in the
-model, not carried. A metaobject naming no element is an `Error::Decode`.
-
-A `Value::Undetermined` is a model-level answer the model leaves open — an
-attribute with no value, a count the multiplicity does not fix — as a
-successful answer rather than an error: `reason` says why, `count_lower` and
-`count_upper` bound its count as the model spells them. It is read, never
-sent. `Value::Infinity` is the unbounded `*`, ordered above every finite
-magnitude. An `EnumLiteral` of an enumeration that specializes a scalar type
-(`enum def Level :> Integer { high = 3; }`) carries that scalar as `value`,
-`None` otherwise.
-
-## Conformance runner
-
-The workspace includes `opensysml-conformance`, which runs the language-neutral
-scenarios through the typed client API. It uses the committed protobuf
-descriptor to decode requests, calls only the public client surface, and reads
-responses through domain `wire()` accessors before comparing normalized JSON.
-The report has per-outcome totals for passed, failed, skipped, and errored
-scenarios, including skipped scenarios.
-
-Run it from the repository root:
-
-```bash
-make conformance-rust
-```
-
-Or run the binary directly:
-
-```bash
-cargo run --manifest-path clients/rust/Cargo.toml -p opensysml-conformance -- \
-  -binary bin/sysml-grpc \
-  -scenarios conformance/scenarios \
-  -fixtures conformance/fixtures \
-  -report bin/conformance-report-rust.json
-```
-
-The runner accepts:
-
-* `-binary PATH` to select the service binary;
-* `-run SUBSTRING` to select scenario IDs;
-* `-report FILE` or `-report -` for the JSON report;
-* `-allow-skips` to allow capability-dependent skips;
-* `-v` to print per-scenario timing.
-
-The `-binary` default is `$OPENSYSML_GRPC_BINARY`, then `bin/sysml-grpc`
-relative to the repository root. The two expected v1 boundary skips are
-`v1 API does not cover <RPC>` and
-`unrepresentable by the typed API: ParseFile with no source`. Other skips name
-the missing capability and fail the run unless `-allow-skips` is supplied.
-
-When a covered RPC answers successfully with a non-empty top-level `error`, the
-typed API exposes `Error::Model(message)` and does not retain the rest of that
-response. The runner therefore compares a partial reconstruction,
-`{"error": message}`. This is intentionally fail-safe: an expectation that
-names another response field alongside the top-level error fails rather than
-passing. Widening this representation requires the client to carry the whole
-response on an in-band error, which is outside the v1 boundary.
-
-## v1 boundary
-
-The current API deliberately does not include generated model-ergonomics types
-beyond its existing domain objects, the edit API, RDF conversion, or
-verification helpers. The conformance runner consequently skips RPCs that the
-typed v1 API does not cover.
-
-`Connection::call` is the escape hatch: it sends one method's request message
-from `opensysml::wire` and decodes the response, without the ergonomic layer,
-so an RPC the typed API does not wrap — `RunAnalysis`, `RunSweep` — can still
-be made. In-band `error` fields are the caller's to read; `Capabilities::has`
-gates response fields such as `case_evaluations` the same way.
-
-## Release procedure
-
-Before a release, `cargo package -p opensysml` must succeed cleanly. `cargo
-publish` is a maintainer action; CI never publishes this crate.
diff --git a/clients/rust/conformance/sysml.descriptor.binpb b/clients/rust/conformance/sysml.descriptor.binpb
deleted file mode 100644
index db28c01d01..0000000000
Binary files a/clients/rust/conformance/sysml.descriptor.binpb and /dev/null differ
diff --git a/clients/rust/opensysml/examples/capable_model.rs b/clients/rust/opensysml/examples/capable_model.rs
deleted file mode 100644
index cf7e6cf6b1..0000000000
--- a/clients/rust/opensysml/examples/capable_model.rs
+++ /dev/null
@@ -1,136 +0,0 @@
-//! A tour of the client over one capable model: parsing, diagnostics, symbol
-//! navigation, evaluation and instantiation.
-//!
-//! ```text
-//! cargo run --manifest-path clients/rust/Cargo.toml -p opensysml --example capable_model
-//! ```
-
-use std::error::Error;
-
-use opensysml::{Connection, EvalOptions, Language, ParseOptions, Value};
-
-/// A model with the shapes a client has to decode: quantities, enumerations,
-/// multiplicity, nesting, a calculation, and a feature left without a value.
-const MODEL: &str = r#"
-package Vehicles {
-    private import ScalarValues::*;
-    private import ISQ::*;
-    private import SI::*;
-
-    enum def Color {
-        enum red;
-        enum green;
-        enum blue;
-    }
-
-    part def Engine {
-        attribute mass : MassValue;
-        attribute power : PowerValue;
-    }
-
-    part def Wheel {
-        attribute diameter : LengthValue;
-    }
-
-    part def Car {
-        attribute color : Color;
-        attribute name : String;
-        attribute street : Boolean;
-        attribute mass : MassValue;
-        attribute unpainted : Color;
-        part engine : Engine;
-        part wheels : Wheel[4];
-    }
-
-    part sedan : Car {
-        attribute redefines color = Color::blue;
-        attribute redefines name = "Sedan";
-        attribute redefines street = true;
-        attribute redefines mass = 1600.0 [kg];
-        part redefines engine {
-            attribute redefines mass = 180.0 [kg];
-            attribute redefines power = 90000.0 [W];
-        }
-    }
-
-    calc def Doubled { in x : Real; return : Real = x * 2.0; }
-}
-"#;
-
-fn main() -> Result<(), Box<dyn Error>> {
-    let connection = Connection::connect()?;
-    println!(
-        "sysml-grpc {} with {} capabilities",
-        connection.server_info().wire().version,
-        connection.capabilities().wire().capabilities.len()
-    );
-
-    let model = connection.parse_content(
-        MODEL,
-        &ParseOptions {
-            language: Language::Sysml,
-            strict_conformance: false,
-        },
-    )?;
-    println!("model {}", model.hash());
-    for diagnostic in model.diagnostics() {
-        println!("  {}", diagnostic.wire().message);
-    }
-
-    // Symbols are navigated lazily: each level is a request of its own.
-    let car = model.symbol("Vehicles::Car")?;
-    println!("{} {}", car.kind(), car.name());
-    for child in car.children()? {
-        println!("  {} {}", child.kind(), child.name());
-    }
-
-    // Every expression is evaluated against the model, so a value declared in a
-    // package reads the units and enumerations imported there.
-    for expression in [
-        "1 + 2 * 3",
-        "Vehicles::sedan::name",
-        "Vehicles::sedan::color",
-        "Vehicles::sedan::mass",
-        "Vehicles::sedan::engine::power",
-        "Vehicles::Doubled(21.0)",
-    ] {
-        println!("{expression} => {:?}", model.eval(expression)?);
-    }
-
-    // A feature declaring no value has none, which is a model error rather than
-    // a transport one.
-    match model.eval("Vehicles::sedan::unpainted") {
-        Ok(value) => println!("unpainted => {value:?}"),
-        Err(error) => println!("unpainted => {error}"),
-    }
-
-    // An expression can also be evaluated in the scope of a symbol.
-    let scoped = model.evaluate(
-        "mass",
-        &EvalOptions {
-            context: Some("Vehicles::sedan".to_owned()),
-            subject: None,
-        },
-    )?;
-    println!("mass in Vehicles::sedan => {:?}", scoped.result);
-
-    // Instantiation materializes the object graph. Single-valued features hold a
-    // value, multi-valued ones hold values, and an unvalued one is Unset.
-    let instantiation = model.instantiate("Vehicles::sedan")?;
-    println!(
-        "instantiated {} objects from {}",
-        instantiation.instances().len(),
-        instantiation.instance.type_symbol_id()
-    );
-    let mut features: Vec<_> = instantiation.instance.feature_values().iter().collect();
-    features.sort_by_key(|(name, _)| name.to_owned());
-    for (name, feature) in features {
-        match (feature.value(), feature.values()) {
-            (Some(Value::Unset), _) => println!("  {name} is unset"),
-            (Some(value), _) => println!("  {name} = {value:?}"),
-            (None, values) => println!("  {name} holds {} objects", values.len()),
-        }
-    }
-
-    Ok(())
-}
diff --git a/clients/rust/opensysml/tests/capable_model.rs b/clients/rust/opensysml/tests/capable_model.rs
deleted file mode 100644
index ee6fcf5e8c..0000000000
--- a/clients/rust/opensysml/tests/capable_model.rs
+++ /dev/null
@@ -1,244 +0,0 @@
-#![allow(missing_docs)]
-
-//! The client read over one capable model — quantities, enumerations,
-//! multiplicity, nesting and a feature left without a value — so that every
-//! value shape a response can carry is decoded by a test rather than by hand.
-
-use std::env;
-
-use opensysml::{Connection, EvalOptions, Magnitude, Model, Value};
-
-/// The same model the `capable_model` example tours.
-const MODEL: &str = r#"
-package Vehicles {
-    private import ScalarValues::*;
-    private import ISQ::*;
-    private import SI::*;
-
-    enum def Color {
-        enum red;
-        enum green;
-        enum blue;
-    }
-
-    part def Engine {
-        attribute mass : MassValue;
-        attribute power : PowerValue;
-    }
-
-    part def Wheel {
-        attribute diameter : LengthValue;
-    }
-
-    part def Car {
-        attribute color : Color;
-        attribute name : String;
-        attribute street : Boolean;
-        attribute mass : MassValue;
-        attribute unpainted : Color;
-        part engine : Engine;
-        part wheels : Wheel[4];
-    }
-
-    part sedan : Car {
-        attribute redefines color = Color::blue;
-        attribute redefines name = "Sedan";
-        attribute redefines street = true;
-        attribute redefines mass = 1600.0 [kg];
-        part redefines engine {
-            attribute redefines mass = 180.0 [kg];
-            attribute redefines power = 90000.0 [W];
-        }
-    }
-
-    calc def Doubled { in x : Real; return : Real = x * 2.0; }
-}
-"#;
-
-fn model_or_skip() -> Option<Model> {
-    let connection = match Connection::private() {
-        Ok(connection) => connection,
-        Err(error) => {
-            if env::var("OPENSYSML_REQUIRE_SERVICE").ok().as_deref() == Some("1") {
-                panic!("required sysml-grpc service unavailable: {error}");
-            }
-            eprintln!("skipping service-backed Rust client test: {error}");
-            return None;
-        }
-    };
-    let model = connection
-        .parse_content(MODEL, &Default::default())
-        .unwrap_or_else(|error| panic!("parse failed: {error}"));
-    assert_eq!(
-        model
-            .diagnostics()
-            .iter()
-            .map(|diagnostic| diagnostic.wire().message.clone())
-            .collect::<Vec<_>>(),
-        Vec::<String>::new(),
-        "the capable model is expected to parse clean"
-    );
-    Some(model)
-}
-
-#[test]
-fn evaluation_decodes_every_scalar_shape() {
-    let Some(model) = model_or_skip() else {
-        return;
-    };
-    let eval = |expression: &str| {
-        model
-            .eval(expression)
-            .unwrap_or_else(|error| panic!("evaluating {expression} failed: {error}"))
-    };
-    assert_eq!(eval("1 + 2 * 3"), Value::Integer(7));
-    assert_eq!(
-        eval("Vehicles::sedan::name"),
-        Value::Text("Sedan".to_owned())
-    );
-    assert_eq!(eval("Vehicles::sedan::street"), Value::Boolean(true));
-    assert_eq!(eval("Vehicles::Doubled(21.0)"), Value::Real(42.0));
-
-    let Value::EnumLiteral(literal) = eval("Vehicles::sedan::color") else {
-        panic!("color is an enumeration literal");
-    };
-    assert_eq!(literal.literal_id, "Vehicles::Color::blue");
-    assert_eq!(literal.enumeration_id, "Vehicles::Color");
-}
-
-/// A value expression is written in its own scope, so a qualified name reads it
-/// with the units and enumerations imported there — the same answer the scope
-/// the name is written in would give.
-#[test]
-fn a_qualified_value_reads_its_declaring_scope() {
-    let Some(model) = model_or_skip() else {
-        return;
-    };
-    let qualified = model
-        .eval("Vehicles::sedan::mass")
-        .unwrap_or_else(|error| panic!("qualified evaluation failed: {error}"));
-    let scoped = model
-        .evaluate(
-            "mass",
-            &EvalOptions {
-                context: Some("Vehicles::sedan".to_owned()),
-                subject: None,
-            },
-        )
-        .unwrap_or_else(|error| panic!("scoped evaluation failed: {error}"))
-        .result;
-    assert_eq!(qualified, scoped);
-
-    let Value::Quantity(quantity) = qualified else {
-        panic!("mass is a quantity");
-    };
-    assert_eq!(quantity.magnitude, Magnitude::Real(1600.0));
-    assert_eq!(quantity.unit, "kg");
-    let term = quantity.unit_term.expect("a kilogram reduces to grams");
-    assert_eq!(term.scale_num, 1000.0);
-    assert_eq!(
-        term.factors
-            .iter()
-            .map(|factor| (factor.unit_id.as_str(), factor.exponent))
-            .collect::<Vec<_>>(),
-        [("SI::gram", 1.0)]
-    );
-}
-
-#[test]
-fn nested_symbols_are_navigated_lazily() {
-    let Some(model) = model_or_skip() else {
-        return;
-    };
-    let car = model
-        .symbol("Vehicles::Car")
-        .unwrap_or_else(|error| panic!("symbol lookup failed: {error}"));
-    assert_eq!(car.kind(), "partDef");
-    let children = car
-        .children()
-        .unwrap_or_else(|error| panic!("children lookup failed: {error}"));
-    assert_eq!(
-        children
-            .iter()
-            .map(|child| child.name().to_owned())
-            .collect::<Vec<_>>(),
-        [
-            "color",
-            "name",
-            "street",
-            "mass",
-            "unpainted",
-            "engine",
-            "wheels"
-        ]
-    );
-}
-
-#[test]
-fn instantiation_decodes_values_multiplicity_and_nesting() {
-    let Some(model) = model_or_skip() else {
-        return;
-    };
-    let instantiation = model
-        .instantiate("Vehicles::sedan")
-        .unwrap_or_else(|error| panic!("instantiation failed: {error}"));
-    let sedan = &instantiation.instance;
-
-    // A feature declaring no value is unset rather than absent or null.
-    assert_eq!(
-        sedan
-            .feature("unpainted")
-            .and_then(|feature| feature.value()),
-        Some(&Value::Unset)
-    );
-
-    // A multi-valued feature holds values, not one value.
-    let wheels = sedan.feature("wheels").expect("wheels is a feature");
-    assert_eq!(wheels.value(), None);
-    assert_eq!(wheels.values().len(), 4);
-    assert!(wheels
-        .values()
-        .iter()
-        .all(|value| matches!(value, Value::InstanceRef(_))));
-
-    // A nested object is reachable through the instances the response carries.
-    let Some(Value::InstanceRef(engine_id)) =
-        sedan.feature("engine").and_then(|feature| feature.value())
-    else {
-        panic!("engine holds one object");
-    };
-    let engine = instantiation
-        .instances()
-        .iter()
-        .find(|instance| instance.id() == *engine_id)
-        .expect("the engine is among the reachable instances");
-    let Some(Value::Quantity(power)) = engine.feature("power").and_then(|feature| feature.value())
-    else {
-        panic!("engine power is a quantity");
-    };
-    assert_eq!(power.magnitude, Magnitude::Real(90000.0));
-    assert_eq!(power.unit, "W");
-}
-
-/// Reading a feature materializes the object it holds, so an instantiation that
-/// depended on map order would hand out different ids each time it was asked.
-#[test]
-fn instantiation_is_the_same_graph_every_time() {
-    let Some(model) = model_or_skip() else {
-        return;
-    };
-    let graph = || {
-        model
-            .instantiate("Vehicles::sedan")
-            .unwrap_or_else(|error| panic!("instantiation failed: {error}"))
-            .instances()
-            .iter()
-            .map(|instance| (instance.id(), instance.type_symbol_id().to_owned()))
-            .collect::<Vec<_>>()
-    };
-    let first = graph();
-    assert_eq!(first.len(), 6);
-    for _ in 0..4 {
-        assert_eq!(graph(), first);
-    }
-}
diff --git a/cmd/conformance/junit.go b/cmd/conformance/junit.go
deleted file mode 100644
index fb49bada31..0000000000
--- a/cmd/conformance/junit.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package main
-
-import (
-	"strings"
-
-	"github.com/Open-MBEE/OpenSysML/internal/junit"
-)
-
-// junitReport renders the report as JUnit XML: one suite per configuration and
-// protocol, one case per scenario.
-func junitReport(report *Report) *junit.Testsuites {
-	doc := &junit.Testsuites{Name: "conformance"}
-	for _, config := range report.Configurations {
-		for _, protocol := range config.Protocols {
-			suite := junit.Testsuite{Name: config.Name + "/" + protocol.Protocol}
-			for _, result := range protocol.Results {
-				suite.AddCase(junitCase(suite.Name, result))
-			}
-			doc.AddSuite(suite)
-		}
-	}
-	return doc
-}
-
-func junitCase(suiteName string, result *Result) junit.Testcase {
-	c := junit.Testcase{
-		Name:      result.ID,
-		Classname: suiteName,
-		Time:      result.DurationMS / 1000,
-	}
-	switch result.Outcome {
-	case "fail":
-		c.Failure = &junit.Message{Text: result.Reason, Body: strings.Join(result.Failures, "\n")}
-	case "error":
-		c.Error = &junit.Message{Text: result.Reason, Body: strings.Join(result.Failures, "\n")}
-	case "skip":
-		c.Skipped = &junit.Message{Text: result.Reason}
-	}
-	return c
-}
diff --git a/cmd/conformance/main.go b/cmd/conformance/main.go
deleted file mode 100644
index fdf35e8b20..0000000000
--- a/cmd/conformance/main.go
+++ /dev/null
@@ -1,411 +0,0 @@
-// Command conformance runs the language-independent conformance suite in
-// conformance/ against sysml-grpc, which it builds and starts itself.
-//
-// It is both the CI gate and the executable specification a client in another
-// language ports: the scenarios are data, and this program is one reading of
-// them. See conformance/README.md for the comparison rules it implements.
-package main
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"flag"
-	"fmt"
-	"os"
-	"os/signal"
-	"path/filepath"
-	"regexp"
-	"slices"
-	"strings"
-	"syscall"
-
-	_ "github.com/Open-MBEE/OpenSysML/api/proto" // registers the schema this runner reflects over
-	"google.golang.org/protobuf/reflect/protoregistry"
-
-	"github.com/Open-MBEE/OpenSysML/internal/junit"
-
-	"google.golang.org/protobuf/reflect/protoreflect"
-)
-
-const (
-	// serviceName is the service every scenario addresses.
-	serviceName                 = "sysml.SysMLService"
-	transportConnect            = "connect"
-	transportGRPC               = "grpc"
-	testWithholdCapabilitiesEnv = "OPENSYSML_TEST_WITHHOLD_CAPABILITIES"
-)
-
-var knownProtocols = map[string]struct{}{"grpc": {}, "connect": {}, "connect-json": {}, "pkg": {}, "pkg-connect": {}}
-
-func main() {
-	var (
-		dir       = flag.String("dir", "conformance", "conformance suite directory (scenarios/ and fixtures/)")
-		binary    = flag.String("binary", "", "sysml-grpc binary to test; built from ./cmd/sysml-grpc when empty")
-		repoRoot  = flag.String("repo", ".", "repository root to build the service from")
-		report    = flag.String("report", "", "write the machine-readable summary to this file (- for stdout)")
-		junitOut  = flag.String("junit", "", "also write the results as JUnit XML to this file")
-		run       = flag.String("run", "", "run only the scenarios whose id matches this regular expression")
-		verbose   = flag.Bool("v", false, "print each scenario's normalized response")
-		allowSkip = flag.Bool("allow-skips", false, "treat a scenario skipped for a missing capability as a pass")
-		protocols = flag.String("protocols", "grpc,connect,connect-json", "Comma-separated protocols to test")
-		transport = flag.String("transport", "connect", "server transport (connect or grpc)")
-		withhold  = flag.String("withhold-capabilities", "", "also test the service with these comma-separated capabilities unavailable")
-	)
-	flag.Parse()
-
-	opts := options{
-		dir:       *dir,
-		binary:    *binary,
-		repoRoot:  *repoRoot,
-		report:    *report,
-		junit:     *junitOut,
-		run:       *run,
-		verbose:   *verbose,
-		allowSkip: *allowSkip,
-		protocols: *protocols,
-		transport: *transport,
-		withhold:  *withhold,
-	}
-	if err := runSuite(opts); err != nil {
-		fmt.Fprintf(os.Stderr, "conformance: %v\n", err)
-		os.Exit(1)
-	}
-}
-
-// options is one suite run's command line.
-type options struct {
-	dir       string
-	binary    string
-	repoRoot  string
-	report    string
-	junit     string
-	run       string
-	verbose   bool
-	allowSkip bool
-	protocols string
-	transport string
-	withhold  string
-}
-
-func runSuite(opts options) error {
-	protocols, err := parseProtocols(opts.protocols)
-	if err != nil {
-		return err
-	}
-	if opts.transport != transportConnect && opts.transport != transportGRPC {
-		return fmt.Errorf("unknown -transport %q; want connect or grpc", opts.transport)
-	}
-	if err := validateProtocols(protocols, opts.transport); err != nil {
-		return err
-	}
-	unavailable, err := parseCapabilities(opts.withhold)
-	if err != nil {
-		return err
-	}
-	if len(unavailable) > 0 && slices.Contains(protocols, "pkg") {
-		return errors.New("-withhold-capabilities cannot test the in-process pkg protocol")
-	}
-	var filter *regexp.Regexp
-	if opts.run != "" {
-		compiled, err := regexp.Compile(opts.run)
-		if err != nil {
-			return fmt.Errorf("bad -run pattern: %w", err)
-		}
-		filter = compiled
-	}
-
-	scenarios, err := loadScenarios(filepath.Join(opts.dir, "scenarios"))
-	if err != nil {
-		return err
-	}
-
-	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
-	defer stop()
-
-	workDir, err := os.MkdirTemp("", "conformance-")
-	if err != nil {
-		return err
-	}
-	defer os.RemoveAll(workDir)
-
-	if opts.binary == "" {
-		built, err := buildService(opts.repoRoot, workDir)
-		if err != nil {
-			return err
-		}
-		opts.binary = built
-	}
-	configurations := []configuration{{name: "default"}}
-	if len(unavailable) > 0 {
-		configurations = append(configurations, configuration{
-			name:        "without-" + strings.Join(unavailable, "-"),
-			unavailable: unavailable,
-		})
-	}
-
-	reportData := &Report{Service: opts.binary}
-	defaultCapabilities := map[string][]string{}
-	for _, config := range configurations {
-		fmt.Fprintf(os.Stdout, "\nConfiguration %s\n", config.name)
-		configReport := &ConfigurationSummary{
-			Name:                    config.name,
-			UnavailableCapabilities: config.unavailable,
-		}
-		err := func() error {
-			logName := strings.ReplaceAll(config.name, string(os.PathSeparator), "-") + ".log"
-			svc, err := startServiceWithCapabilities(ctx, opts.binary, filepath.Join(workDir, logName),
-				len(scenarios)+16, opts.transport, config.unavailable)
-			if err != nil {
-				return err
-			}
-			defer svc.stop()
-
-			for _, protocol := range protocols {
-				c, err := svc.client(protocol)
-				if err != nil {
-					return err
-				}
-				runner := &runner{
-					service: svc, client: c,
-					fixtures: filepath.Join(opts.dir, "fixtures"),
-					models:   map[Model]string{}, verbose: opts.verbose,
-					omitHandshakeScenarios: len(config.unavailable) > 0,
-					out:                    os.Stdout, scenarioLog: os.Stdout,
-				}
-				if err := runner.readCapabilities(ctx); err != nil {
-					c.close()
-					return err
-				}
-				if len(config.unavailable) == 0 {
-					defaultCapabilities[protocol] = append([]string(nil), runner.capabilities...)
-				} else if err := validateWithheldCapabilities(
-					defaultCapabilities[protocol], runner.capabilities, config.unavailable); err != nil {
-					c.close()
-					return fmt.Errorf("%s: %w", protocol, err)
-				}
-				summary := runner.runAll(ctx, scenarios, filter)
-				c.close()
-				configReport.Protocols = append(configReport.Protocols, summary)
-				configReport.add(summary)
-				if len(config.unavailable) == 0 && summary.Skipped > 0 && !opts.allowSkip {
-					configReport.unacceptedSkip = true
-				}
-				if err := validateFallbackExecution(summary, config.unavailable, opts.allowSkip); err != nil {
-					return fmt.Errorf("%s: %w", protocol, err)
-				}
-			}
-			return nil
-		}()
-		if err != nil {
-			return err
-		}
-		reportData.Configurations = append(reportData.Configurations, configReport)
-		reportData.add(configReport)
-	}
-	if err := writeReport(opts.report, reportData); err != nil {
-		return err
-	}
-	if opts.junit != "" {
-		if err := junit.WriteFile(opts.junit, junitReport(reportData)); err != nil {
-			return err
-		}
-	}
-	if reportData.failure {
-		return fmt.Errorf("%d of %d scenarios failed", reportData.Failed+reportData.Errored, reportData.Total)
-	}
-	if reportData.unacceptedSkip {
-		return fmt.Errorf("%d scenarios were skipped for missing capabilities; pass -allow-skips to accept that", reportData.Skipped)
-	}
-	return nil
-}
-
-type configuration struct {
-	name        string
-	unavailable []string
-}
-
-func parseCapabilities(value string) ([]string, error) {
-	var capabilities []string
-	seen := map[string]struct{}{}
-	for _, item := range strings.Split(value, ",") {
-		item = strings.TrimSpace(item)
-		if item == "" {
-			continue
-		}
-		if _, ok := seen[item]; ok {
-			return nil, fmt.Errorf("capability %q is listed more than once", item)
-		}
-		seen[item] = struct{}{}
-		capabilities = append(capabilities, item)
-	}
-	return capabilities, nil
-}
-
-func validateWithheldCapabilities(defaultCapabilities, actual, unavailable []string) error {
-	for _, capability := range unavailable {
-		if !slices.Contains(defaultCapabilities, capability) {
-			return fmt.Errorf("the default service does not advertise %q", capability)
-		}
-	}
-	want := make([]string, 0, len(defaultCapabilities))
-	for _, capability := range defaultCapabilities {
-		if !slices.Contains(unavailable, capability) {
-			want = append(want, capability)
-		}
-	}
-	if !slices.Equal(actual, want) {
-		return fmt.Errorf("capabilities = %v, want the default set without %v: %v",
-			actual, unavailable, want)
-	}
-	return nil
-}
-
-func validateFallbackExecution(summary *Summary, unavailable []string, allowSkip bool) error {
-	if len(unavailable) == 0 {
-		return nil
-	}
-	executed := map[string]bool{}
-	for _, result := range summary.Results {
-		if result.WithoutCapability {
-			for _, capability := range result.MissingCapabilities {
-				executed[capability] = true
-			}
-		}
-		if result.Outcome == "skip" && !allowSkip {
-			if len(result.MissingCapabilities) == 0 {
-				return fmt.Errorf("%s skipped for a reason other than an unavailable capability", result.ID)
-			}
-			for _, capability := range result.MissingCapabilities {
-				if !slices.Contains(unavailable, capability) {
-					return fmt.Errorf("%s skipped for unexpected missing capability %q", result.ID, capability)
-				}
-			}
-		}
-	}
-	for _, capability := range unavailable {
-		if !executed[capability] {
-			return fmt.Errorf("no without-capability expectation executed for %q", capability)
-		}
-	}
-	return nil
-}
-
-func validateProtocols(protocols []string, transport string) error {
-	if transport == transportGRPC {
-		for _, protocol := range protocols {
-			if protocol != "grpc" && protocol != "pkg" {
-				return fmt.Errorf("protocol %q requires -transport connect; grpc transport only supports grpc and pkg", protocol)
-			}
-		}
-	}
-	return nil
-}
-
-func parseProtocols(value string) ([]string, error) {
-	var protocols []string
-	seen := map[string]struct{}{}
-	for _, item := range strings.Split(value, ",") {
-		item = strings.TrimSpace(item)
-		if item == "" {
-			continue
-		}
-		if _, ok := knownProtocols[item]; !ok {
-			return nil, fmt.Errorf("unknown protocol %q; want grpc, connect, connect-json, pkg or pkg-connect", item)
-		}
-		if _, ok := seen[item]; ok {
-			return nil, fmt.Errorf("protocol %q is listed more than once", item)
-		}
-		seen[item] = struct{}{}
-		protocols = append(protocols, item)
-	}
-	if len(protocols) == 0 {
-		return nil, errors.New("-protocols must name at least one protocol")
-	}
-	return protocols, nil
-}
-
-// Report contains aggregate and per-configuration conformance results.
-type Report struct {
-	Service           string                  `json:"service"`
-	Total             int                     `json:"total"`
-	Passed            int                     `json:"passed"`
-	Failed            int                     `json:"failed"`
-	Skipped           int                     `json:"skipped"`
-	Errored           int                     `json:"errored"`
-	WithoutCapability int                     `json:"without_capability"`
-	Configurations    []*ConfigurationSummary `json:"configurations"`
-	failure           bool
-	unacceptedSkip    bool
-}
-
-// ConfigurationSummary contains the results for one capability configuration.
-type ConfigurationSummary struct {
-	Name                    string     `json:"name"`
-	UnavailableCapabilities []string   `json:"unavailable_capabilities,omitempty"`
-	Total                   int        `json:"total"`
-	Passed                  int        `json:"passed"`
-	Failed                  int        `json:"failed"`
-	Skipped                 int        `json:"skipped"`
-	Errored                 int        `json:"errored"`
-	WithoutCapability       int        `json:"without_capability"`
-	Protocols               []*Summary `json:"protocols"`
-	failure                 bool
-	unacceptedSkip          bool
-}
-
-func (summary *ConfigurationSummary) add(protocol *Summary) {
-	summary.Total += protocol.Total
-	summary.Passed += protocol.Passed
-	summary.Failed += protocol.Failed
-	summary.Skipped += protocol.Skipped
-	summary.Errored += protocol.Errored
-	summary.WithoutCapability += protocol.WithoutCapability
-	summary.failure = summary.failure || protocol.Failed > 0 || protocol.Errored > 0
-}
-
-func (report *Report) add(configuration *ConfigurationSummary) {
-	report.Total += configuration.Total
-	report.Passed += configuration.Passed
-	report.Failed += configuration.Failed
-	report.Skipped += configuration.Skipped
-	report.Errored += configuration.Errored
-	report.WithoutCapability += configuration.WithoutCapability
-	report.failure = report.failure || configuration.failure
-	report.unacceptedSkip = report.unacceptedSkip || configuration.unacceptedSkip
-}
-
-// writeReport writes the summary as JSON, to a file or to stdout.
-func writeReport(path string, summary *Report) error {
-	if path == "" {
-		return nil
-	}
-	data, err := json.MarshalIndent(summary, "", "  ")
-	if err != nil {
-		return err
-	}
-	data = append(data, '\n')
-	if path == "-" {
-		_, err := os.Stdout.Write(data)
-		return err
-	}
-	return os.WriteFile(path, data, 0o644) // #nosec G306 -- a CI artifact is meant to be readable.
-}
-
-// methodByName resolves a method of the service from the compiled schema, so a
-// scenario naming an RPC that does not exist is an error rather than a pass.
-func methodByName(name string) (protoreflect.MethodDescriptor, error) {
-	descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(serviceName))
-	if err != nil {
-		return nil, fmt.Errorf("the schema for %s is not registered: %w", serviceName, err)
-	}
-	svc, ok := descriptor.(protoreflect.ServiceDescriptor)
-	if !ok {
-		return nil, errors.New(serviceName + " is not a service")
-	}
-	method := svc.Methods().ByName(protoreflect.Name(name))
-	if method == nil {
-		return nil, fmt.Errorf("%s has no RPC %q", serviceName, name)
-	}
-	return method, nil
-}
diff --git a/cmd/conformance/normalize.go b/cmd/conformance/normalize.go
deleted file mode 100644
index c55688e379..0000000000
--- a/cmd/conformance/normalize.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"path/filepath"
-	"sort"
-
-	"google.golang.org/protobuf/reflect/protoreflect"
-)
-
-// Placeholders a normalized response carries in place of a value that is not
-// the same twice. Scenarios compare against these spellings.
-const (
-	modelHashPlaceholder = "${model_hash}"
-	versionPlaceholder   = "${version}"
-	pathPlaceholder      = "${path}"
-)
-
-// normalizedIDs are the int64 fields carrying a runtime instance id, which is
-// assigned per call: each distinct id becomes "@1", "@2", … in order of first
-// appearance, so a scenario can state that two feature values name one object
-// without knowing which id it was given.
-var normalizedIDs = map[string]bool{
-	"sysml.Instance.id":         true,
-	"sysml.Value.instance_id":   true,
-	"sysml.Verdict.instance_id": true,
-	"sysml.Function.self_id":    true,
-}
-
-// integer and unsigned are normalized integral values. They are distinct from
-// float64 so an integral field is compared exactly rather than within the
-// tolerance a Real carries.
-type (
-	integer  int64
-	unsigned uint64
-)
-
-// normalizer turns a response into a JSON-shaped tree with the values that
-// cannot be compared literally replaced. See conformance/README.md.
-type normalizer struct {
-	modelHash string
-	labels    map[int64]string
-}
-
-func newNormalizer(modelHash string) *normalizer {
-	return &normalizer{modelHash: modelHash, labels: map[int64]string{}}
-}
-
-// normalize renders msg as maps, slices, strings, numbers and bools. Only set
-// fields appear; a scalar left at its default is absent.
-func (n *normalizer) normalize(msg protoreflect.Message) map[string]any {
-	out := map[string]any{}
-	var set []protoreflect.FieldDescriptor
-	msg.Range(func(field protoreflect.FieldDescriptor, _ protoreflect.Value) bool {
-		set = append(set, field)
-		return true
-	})
-	// Range yields fields in no defined order; number order makes id labels
-	// reproducible.
-	sort.Slice(set, func(i, j int) bool { return set[i].Number() < set[j].Number() })
-
-	for _, field := range set {
-		value := msg.Get(field)
-		switch {
-		case field.IsMap():
-			entries := map[string]any{}
-			keys := make([]string, 0, value.Map().Len())
-			byKey := map[string]protoreflect.Value{}
-			value.Map().Range(func(key protoreflect.MapKey, item protoreflect.Value) bool {
-				keys = append(keys, key.String())
-				byKey[key.String()] = item
-				return true
-			})
-			sort.Strings(keys)
-			for _, key := range keys {
-				entries[key] = n.value(field.MapValue(), byKey[key])
-			}
-			out[field.TextName()] = entries
-		case field.IsList():
-			list := value.List()
-			items := make([]any, 0, list.Len())
-			for i := 0; i < list.Len(); i++ {
-				items = append(items, n.value(field, list.Get(i)))
-			}
-			out[field.TextName()] = items
-		default:
-			out[field.TextName()] = n.value(field, value)
-		}
-	}
-	return out
-}
-
-// value normalizes one field value, list element or map value.
-func (n *normalizer) value(field protoreflect.FieldDescriptor, value protoreflect.Value) any {
-	full := string(field.FullName())
-	switch field.Kind() {
-	case protoreflect.MessageKind, protoreflect.GroupKind:
-		return n.normalize(value.Message())
-	case protoreflect.EnumKind:
-		return string(field.Enum().Values().ByNumber(value.Enum()).Name())
-	case protoreflect.BoolKind:
-		return value.Bool()
-	case protoreflect.StringKind:
-		return n.stringValue(full, value.String())
-	case protoreflect.DoubleKind, protoreflect.FloatKind:
-		return value.Float()
-	case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind:
-		if normalizedIDs[full] {
-			return n.label(value.Int())
-		}
-		return integer(value.Int())
-	case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind:
-		return integer(value.Int())
-	case protoreflect.Uint32Kind, protoreflect.Uint64Kind, protoreflect.Fixed32Kind, protoreflect.Fixed64Kind:
-		return unsigned(value.Uint())
-	default:
-		return value.String()
-	}
-}
-
-// stringValue replaces the strings a call cannot repeat: the model hash it was
-// given, the service's build version, and any absolute path.
-func (n *normalizer) stringValue(field, text string) string {
-	switch {
-	case field == "sysml.ServerInfoResponse.version":
-		return versionPlaceholder
-	case n.modelHash != "" && text == n.modelHash:
-		return modelHashPlaceholder
-	case filepath.IsAbs(text):
-		return pathPlaceholder
-	default:
-		return text
-	}
-}
-
-// label is the symbolic name of a runtime instance id.
-func (n *normalizer) label(id int64) string {
-	if existing, ok := n.labels[id]; ok {
-		return existing
-	}
-	label := fmt.Sprintf("@%d", len(n.labels)+1)
-	n.labels[id] = label
-	return label
-}
diff --git a/cmd/conformance/scenario.go b/cmd/conformance/scenario.go
deleted file mode 100644
index 5aee182851..0000000000
--- a/cmd/conformance/scenario.go
+++ /dev/null
@@ -1,109 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-)
-
-// suiteFile is one scenario file: a list of scenarios sharing a theme.
-type suiteFile struct {
-	Scenarios []*Scenario `json:"scenarios"`
-}
-
-// Model is the source a scenario needs parsed before its call, named by fixture
-// so no scenario carries a machine-specific path.
-type Model struct {
-	Fixture           string `json:"fixture"`
-	Language          string `json:"language,omitempty"`
-	StrictConformance bool   `json:"strict_conformance,omitempty"`
-}
-
-// Expect is what a call must answer. Every field is optional; an absent Status
-// means the call must succeed.
-type Expect struct {
-	Status                string              `json:"status,omitempty"`
-	StatusMessageContains string              `json:"status_message_contains,omitempty"`
-	Response              map[string]any      `json:"response,omitempty"`
-	Contains              map[string]string   `json:"contains,omitempty"`
-	ContainsAll           map[string][]string `json:"contains_all,omitempty"`
-	NonEmpty              []string            `json:"non_empty,omitempty"`
-	Absent                []string            `json:"absent,omitempty"`
-	Counts                map[string]int      `json:"counts,omitempty"`
-	MinCounts             map[string]int      `json:"min_counts,omitempty"`
-}
-
-// Scenario is one conformance case: a call to make and what it must answer.
-type Scenario struct {
-	ID          string `json:"id"`
-	Description string `json:"description"`
-	// RPC is the method, either bare ("Evaluate") or fully qualified
-	// ("sysml.SysMLService/Evaluate"). The transport is not named here.
-	RPC string `json:"rpc"`
-	// Capabilities GetServerInfo must report for Expect to apply.
-	RequiresCapabilities []string `json:"requires_capabilities,omitempty"`
-	// What the call must answer on a service reporting none of them; unset
-	// leaves such a service untested by this scenario.
-	ExpectWithoutCapability *Expect `json:"expect_without_capability,omitempty"`
-	Model                   *Model  `json:"model,omitempty"`
-	// Request as protobuf-JSON. "${model_hash}" stands for Model's hash, and
-	// "${fixture:<path>}" for a fixture's contents.
-	Request json.RawMessage `json:"request"`
-	Expect  Expect          `json:"expect"`
-
-	file string // scenario file this came from, for error messages
-}
-
-// loadScenarios reads every scenario file in dir, in file then declaration
-// order, so a run reports scenarios in a stable order.
-func loadScenarios(dir string) ([]*Scenario, error) {
-	entries, err := filepath.Glob(filepath.Join(dir, "*.json"))
-	if err != nil {
-		return nil, err
-	}
-	sort.Strings(entries)
-	if len(entries) == 0 {
-		return nil, fmt.Errorf("no scenario files in %s", dir)
-	}
-
-	var scenarios []*Scenario
-	seen := map[string]string{}
-	for _, entry := range entries {
-		data, err := os.ReadFile(entry) // #nosec G304 -- scenario files come from the suite directory.
-		if err != nil {
-			return nil, err
-		}
-		var suite suiteFile
-		decoder := json.NewDecoder(strings.NewReader(string(data)))
-		decoder.DisallowUnknownFields()
-		// Expected numbers stay as their literals, so a whole number too large
-		// for a float64 is still compared by its digits.
-		decoder.UseNumber()
-		if err := decoder.Decode(&suite); err != nil {
-			return nil, fmt.Errorf("%s: %w", entry, err)
-		}
-		for _, scenario := range suite.Scenarios {
-			if scenario.ID == "" || scenario.RPC == "" {
-				return nil, fmt.Errorf("%s: every scenario needs an id and an rpc", entry)
-			}
-			if where, dup := seen[scenario.ID]; dup {
-				return nil, fmt.Errorf("%s: scenario id %q is already declared in %s", entry, scenario.ID, where)
-			}
-			seen[scenario.ID] = entry
-			scenario.file = entry
-			scenarios = append(scenarios, scenario)
-		}
-	}
-	return scenarios, nil
-}
-
-// method is the scenario's RPC as a bare method name.
-func (s *Scenario) method() string {
-	if index := strings.LastIndex(s.RPC, "/"); index >= 0 {
-		return s.RPC[index+1:]
-	}
-	return s.RPC
-}
diff --git a/cmd/conformance/suite_test.go b/cmd/conformance/suite_test.go
deleted file mode 100644
index 5b51c5507b..0000000000
--- a/cmd/conformance/suite_test.go
+++ /dev/null
@@ -1,158 +0,0 @@
-package main
-
-import (
-	"context"
-	"path/filepath"
-	"slices"
-	"strings"
-	"testing"
-
-	"google.golang.org/protobuf/reflect/protoreflect"
-	"google.golang.org/protobuf/reflect/protoregistry"
-)
-
-// suiteDir is the committed suite, read from the repository root.
-const suiteDir = "../../conformance"
-
-func loadSuite(t *testing.T) []*Scenario {
-	t.Helper()
-	scenarios, err := loadScenarios(filepath.Join(suiteDir, "scenarios"))
-	if err != nil {
-		t.Fatalf("the committed suite does not load: %v", err)
-	}
-	return scenarios
-}
-
-// TestEveryScenarioNamesAnRPCOfTheService verifies no scenario addresses an RPC
-// the schema does not declare, which would otherwise fail only at run time.
-func TestEveryScenarioNamesAnRPCOfTheService(t *testing.T) {
-	for _, scenario := range loadSuite(t) {
-		if _, err := methodByName(scenario.method()); err != nil {
-			t.Errorf("%s: %v", scenario.ID, err)
-		}
-	}
-}
-
-// TestEveryRPCIsCovered verifies the suite reaches every RPC of the service, so
-// adding one to the schema without covering it fails here.
-func TestEveryRPCIsCovered(t *testing.T) {
-	descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(serviceName))
-	if err != nil {
-		t.Fatal(err)
-	}
-	methods := descriptor.(protoreflect.ServiceDescriptor).Methods()
-
-	covered := map[string]int{}
-	for _, scenario := range loadSuite(t) {
-		covered[scenario.method()]++
-	}
-	for i := 0; i < methods.Len(); i++ {
-		name := string(methods.Get(i).Name())
-		if covered[name] == 0 {
-			t.Errorf("no scenario covers %s", name)
-		}
-	}
-}
-
-// TestTheSuiteCoversBothKindsOfFailure verifies it pins refused requests and
-// failures the response reports in band, not only happy paths.
-func TestTheSuiteCoversBothKindsOfFailure(t *testing.T) {
-	statuses := map[string]int{}
-	withoutCapability := map[string]int{}
-	inBand := 0
-	for _, scenario := range loadSuite(t) {
-		expects := []*Expect{&scenario.Expect}
-		if scenario.ExpectWithoutCapability != nil {
-			expects = append(expects, scenario.ExpectWithoutCapability)
-			expect := scenario.ExpectWithoutCapability
-			if expect.Status != "UNIMPLEMENTED" {
-				t.Errorf("%s: without-capability status = %q, want UNIMPLEMENTED",
-					scenario.ID, expect.Status)
-			}
-			if !slices.Contains(scenario.RequiresCapabilities, expect.StatusMessageContains) {
-				t.Errorf("%s: without-capability message %q does not name a required capability",
-					scenario.ID, expect.StatusMessageContains)
-			} else {
-				withoutCapability[expect.StatusMessageContains]++
-			}
-		}
-		for _, expect := range expects {
-			if expect.Status != "" {
-				statuses[expect.Status]++
-			}
-			for _, path := range expect.NonEmpty {
-				if strings.HasSuffix(path, "error") {
-					inBand++
-				}
-			}
-			for path := range expect.Contains {
-				if strings.HasSuffix(path, "error") {
-					inBand++
-				}
-			}
-		}
-	}
-	for _, want := range []string{"INVALID_ARGUMENT", "NOT_FOUND", "UNIMPLEMENTED"} {
-		if statuses[want] == 0 {
-			t.Errorf("no scenario expects the status %s", want)
-		}
-	}
-	if inBand == 0 {
-		t.Error("no scenario expects a failure reported in the response's error field")
-	}
-	for _, capability := range []string{"strict_conformance", "oslc_query"} {
-		if withoutCapability[capability] == 0 {
-			t.Errorf("no scenario exercises the without-capability path for %q", capability)
-		}
-	}
-}
-
-// TestEveryFixtureIsUsed verifies the suite carries no fixture no scenario
-// reads, which would be dead data.
-func TestEveryFixtureIsUsed(t *testing.T) {
-	fixtures, err := filepath.Glob(filepath.Join(suiteDir, "fixtures", "*.sysml"))
-	if err != nil {
-		t.Fatal(err)
-	}
-	if len(fixtures) == 0 {
-		t.Fatal("no fixtures found")
-	}
-	scenarios := loadSuite(t)
-	for _, fixture := range fixtures {
-		name := filepath.Base(fixture)
-		used := false
-		for _, scenario := range scenarios {
-			if scenario.Model != nil && scenario.Model.Fixture == name {
-				used = true
-			}
-			if strings.Contains(string(scenario.Request), "${fixture:"+name+"}") {
-				used = true
-			}
-		}
-		if !used {
-			t.Errorf("fixture %s is read by no scenario", name)
-		}
-	}
-}
-
-// TestAMissingCapabilitySkipsRatherThanFails verifies a scenario needing a
-// capability the service does not report is skipped, and that a scenario saying
-// what such a service must answer instead is not skipped.
-func TestAMissingCapabilitySkipsRatherThanFails(t *testing.T) {
-	r := &runner{capabilities: []string{"query"}, out: &strings.Builder{}, scenarioLog: &strings.Builder{}}
-
-	skipped := r.run(context.Background(), &Scenario{
-		ID: "needs/absent", RPC: "Convert", RequiresCapabilities: []string{"convert"},
-	})
-	if skipped.Outcome != "skip" {
-		t.Errorf("outcome = %s, want skip", skipped.Outcome)
-	}
-	if !strings.Contains(skipped.Reason, "convert") {
-		t.Errorf("reason = %q, want it to name the missing capability", skipped.Reason)
-	}
-
-	present := r.missingCapabilities(&Scenario{RequiresCapabilities: []string{"query"}})
-	if len(present) != 0 {
-		t.Errorf("missing = %v, want none", present)
-	}
-}
diff --git a/cmd/doc-counts/main.go b/cmd/doc-counts/main.go
deleted file mode 100644
index dc33e53291..0000000000
--- a/cmd/doc-counts/main.go
+++ /dev/null
@@ -1,208 +0,0 @@
-// Command doc-counts rewrites the documentation lines that are a function of the
-// committed oracle baselines, so no contributor types them. It reads them through
-// internal/doccounts, which the guard in cmd/pilot-diff reads too, and rewrites
-// nothing else in the files it touches. The compliance map's own row census is not
-// written anywhere: the documentation build counts it. Run it with `make docs-counts`.
-package main
-
-import (
-	"flag"
-	"fmt"
-	"io"
-	"io/fs"
-	"os"
-	"path/filepath"
-	"strings"
-
-	"github.com/Open-MBEE/OpenSysML/internal/doccounts"
-)
-
-func main() {
-	root := flag.String("root", ".", "repository root the documentation paths are relative to")
-	checkOnly := flag.Bool("check", false, "verify that generated documentation is current without writing")
-	flag.Parse()
-	if flag.NArg() != 0 {
-		fmt.Fprintf(os.Stderr, "doc-counts: unexpected argument %q\n", flag.Arg(0))
-		os.Exit(2)
-	}
-	var rewritten int
-	var err error
-	if *checkOnly {
-		rewritten, err = check(*root, os.Stdout)
-	} else {
-		rewritten, err = run(*root, os.Stdout)
-	}
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "doc-counts: %v\n", err)
-		os.Exit(1)
-	}
-	if *checkOnly && rewritten > 0 {
-		os.Exit(1)
-	}
-	if rewritten == 0 {
-		fmt.Fprintln(os.Stdout, "doc-counts: already current")
-	}
-}
-
-// run restates every derived line from the census and reports how many files it
-// changed. A file already stating the census is left untouched, which is what
-// makes a second run a no-op.
-func run(root string, out io.Writer) (int, error) {
-	pending, err := pendingRewrites(root)
-	if err != nil {
-		return 0, err
-	}
-
-	for _, file := range pending {
-		if err := checkWritable(root, file.path); err != nil {
-			return 0, err
-		}
-	}
-	for _, file := range pending {
-		if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(file.path)), []byte(file.content), file.mode); err != nil {
-			return 0, err
-		}
-		fmt.Fprintf(out, "doc-counts: rewrote %s\n", file.path)
-	}
-	return len(pending), nil
-}
-
-// check reports generated differences without modifying any file.
-func check(root string, out io.Writer) (int, error) {
-	pending, err := pendingRewrites(root)
-	if err != nil {
-		return 0, err
-	}
-	for _, file := range pending {
-		fmt.Fprintf(out, "doc-counts: %s is stale\n", file.path)
-		fmt.Fprint(out, diffReport(file.path, file.current, file.content))
-	}
-	return len(pending), nil
-}
-
-type rewrite struct {
-	path    string
-	current string
-	content string
-	mode    fs.FileMode
-}
-
-func pendingRewrites(root string) ([]rewrite, error) {
-	compliance, _, err := readFile(root, doccounts.SpecCompliancePath)
-	if err != nil {
-		return nil, err
-	}
-	counts := doccounts.CountRules(compliance)
-	if counts.Total == 0 {
-		return nil, fmt.Errorf("%s states no rule rows", doccounts.SpecCompliancePath)
-	}
-	if counts.KnownFailure != 0 {
-		return nil, fmt.Errorf("%s: %d 🚧 rows; give them a status the census states", doccounts.SpecCompliancePath, counts.KnownFailure)
-	}
-	refereed, err := doccounts.ReadRefereedCounts(root)
-	if err != nil {
-		return nil, err
-	}
-
-	var pending []rewrite
-	for _, path := range paths() {
-		content, mode, err := readFile(root, path)
-		if err != nil {
-			return nil, err
-		}
-		updated := content
-		for _, line := range doccounts.BaselineLines() {
-			if line.Path != path {
-				continue
-			}
-			if updated, err = doccounts.RewriteBaselineLine(updated, line, refereed); err != nil {
-				return nil, err
-			}
-		}
-		for _, block := range doccounts.Blocks() {
-			if block.Path != path {
-				continue
-			}
-			if updated, err = doccounts.RewriteBlock(updated, block, refereed); err != nil {
-				return nil, err
-			}
-		}
-		if updated == content {
-			continue
-		}
-		pending = append(pending, rewrite{path: path, current: content, content: updated, mode: mode})
-	}
-	return pending, nil
-}
-
-func diffReport(path, current, generated string) string {
-	currentLines := strings.Split(current, "\n")
-	generatedLines := strings.Split(generated, "\n")
-	var b strings.Builder
-	fmt.Fprintf(&b, "--- %s (current)\n+++ %s (generated)\n", path, path)
-	for i := 0; i < len(currentLines) || i < len(generatedLines); i++ {
-		var old, fresh string
-		if i < len(currentLines) {
-			old = currentLines[i]
-		}
-		if i < len(generatedLines) {
-			fresh = generatedLines[i]
-		}
-		if old == fresh {
-			continue
-		}
-		fmt.Fprintf(&b, "@@ line %d @@\n", i+1)
-		if i < len(currentLines) {
-			fmt.Fprintf(&b, "-%s\n", old)
-		}
-		if i < len(generatedLines) {
-			fmt.Fprintf(&b, "+%s\n", fresh)
-		}
-	}
-	return b.String()
-}
-
-// checkWritable reports whether a file can be opened for writing, so an
-// unwritable file is refused before any file is rewritten.
-func checkWritable(root, path string) error {
-	file, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(path)), os.O_WRONLY, 0) // #nosec G304 -- a documentation path this command declares
-	if err != nil {
-		return err
-	}
-	return file.Close()
-}
-
-// paths lists the files carrying a derived line, in the order the lines declare
-// them and without repeating a file that carries more than one.
-func paths() []string {
-	var ordered []string
-	seen := map[string]bool{}
-	for _, line := range doccounts.BaselineLines() {
-		if seen[line.Path] {
-			continue
-		}
-		seen[line.Path] = true
-		ordered = append(ordered, line.Path)
-	}
-	for _, block := range doccounts.Blocks() {
-		if seen[block.Path] {
-			continue
-		}
-		seen[block.Path] = true
-		ordered = append(ordered, block.Path)
-	}
-	return ordered
-}
-
-func readFile(root, path string) (string, fs.FileMode, error) {
-	full := filepath.Join(root, filepath.FromSlash(path))
-	info, err := os.Stat(full)
-	if err != nil {
-		return "", 0, err
-	}
-	content, err := os.ReadFile(full) // #nosec G304 -- a documentation path this command declares
-	if err != nil {
-		return "", 0, err
-	}
-	return string(content), info.Mode().Perm(), nil
-}
diff --git a/cmd/doc-counts/main_test.go b/cmd/doc-counts/main_test.go
deleted file mode 100644
index 6dd6617085..0000000000
--- a/cmd/doc-counts/main_test.go
+++ /dev/null
@@ -1,187 +0,0 @@
-package main
-
-import (
-	"io"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	"github.com/Open-MBEE/OpenSysML/internal/doccounts"
-)
-
-const (
-	fixtureCompliance = `# Compliance
-
-**No external referee:** self-assessed.
-
-| Rule | Status |
-|---|---|
-| a | ✅ Faithful |
-| b | ⚠️ Approximate |
-`
-	fixtureBookkeeping = `# Guide
-
-**Reference differential:** 99 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (` + "`" + `old` + "`" + `), 99 in full agreement;
-**Rejection oracle:** the reverse direction — do we reject what the reference rejects? 99 hand-written invalid models validated by both implementations, 99 rejected by both, 99 the pinned pilot rejects and we accept;
-<!-- doc-counts:begin refereed-figures -->
-old generated block
-<!-- doc-counts:end refereed-figures -->
-Nothing else on this line's neighbours moves.
-`
-	fixtureDifferentialBaseline = `{"pilotRelease":"2026-05 (jupyter-sysml-kernel 0.60.1)","totals":{"files":2,"filesFullyAgreeing":1,"openSysMLOnly":3,"pilotOnly":4},` +
-		`"errata":{"registryEntries":2,"corrections":1,"documentedWithoutCorrection":1,"totals":{"files":2,"filesFullyAgreeing":2,"openSysMLOnly":2,"pilotOnly":4}}}`
-	fixtureXpectBaseline = `{"kinds":[{"kind":"errors","assertions":2,"rows":2,"agree":2,"wordingOnly":1,"sameLocation":0,"sameLine":0,"severityDiffers":0,"elsewhereInFile":0},{"kind":"scope","assertions":3,"agree":2}],` +
-		`"errata":{"kinds":[{"kind":"errors","assertions":2,"rows":2,"agree":2,"wordingOnly":1}]}}`
-	fixtureRejectionBaseline = `{"totals":{"cases":2,"bothReject":2,"pilotOnlyRejects":0},"strictOnlyAgreements":[],` +
-		`"errata":{"totals":{"cases":2,"bothReject":2,"pilotOnlyRejects":0}}}`
-)
-
-// TestRunRewritesEveryDerivedLineAndIsIdempotent is the guarantee the wave-9
-// workflow rests on: one command, byte-identical output, second run a no-op.
-func TestRunRewritesEveryDerivedLineAndIsIdempotent(t *testing.T) {
-	root := writeFixture(t)
-
-	rewritten, err := run(root, io.Discard)
-	if err != nil {
-		t.Fatalf("first run: %v", err)
-	}
-	if rewritten != 2 {
-		t.Fatalf("first run rewrote %d files, want 2", rewritten)
-	}
-	if read(t, root, doccounts.SpecCompliancePath) != fixtureCompliance {
-		t.Fatal("the compliance map is not a derived file and must not be rewritten")
-	}
-	first := map[string]string{}
-	for _, path := range []string{doccounts.ReadmePath, doccounts.ArchitecturePath} {
-		first[path] = read(t, root, path)
-		if !strings.Contains(first[path], "status of each tracked rule stays in [spec compliance]") {
-			t.Fatalf("%s bookkeeping line not restated:\n%s", path, first[path])
-		}
-		if !strings.Contains(first[path], "Nothing else on this line's neighbours moves.") {
-			t.Fatalf("%s lost a neighbouring line", path)
-		}
-	}
-
-	rewritten, err = run(root, io.Discard)
-	if err != nil {
-		t.Fatalf("second run: %v", err)
-	}
-	if rewritten != 0 {
-		t.Fatalf("second run rewrote %d files, want 0", rewritten)
-	}
-	for path, content := range first {
-		if read(t, root, path) != content {
-			t.Fatalf("%s changed on the second run", path)
-		}
-	}
-}
-
-// TestRunWritesNothingWhenALaterFileCannotBeRewritten keeps the tree consistent:
-// a partly restated tree would state two different censuses at once.
-func TestRunWritesNothingWhenALaterFileCannotBeRewritten(t *testing.T) {
-	root := writeFixture(t)
-	writeAt(t, root, doccounts.ArchitecturePath, "**Row bookkeeping:** reworded, and no longer the line the pattern states.\n")
-	before := read(t, root, doccounts.ReadmePath)
-
-	if _, err := run(root, io.Discard); err == nil {
-		t.Fatal("want an error for a derived line the pattern does not match")
-	}
-	if read(t, root, doccounts.ReadmePath) != before {
-		t.Fatal("a failed run rewrote an earlier file")
-	}
-}
-
-func TestRunWritesNothingWhenAFileIsNotWritable(t *testing.T) {
-	root := writeFixture(t)
-	readonly := filepath.Join(root, filepath.FromSlash(doccounts.ArchitecturePath))
-	if err := os.Chmod(readonly, 0o444); err != nil {
-		t.Fatalf("chmod: %v", err)
-	}
-	before := read(t, root, doccounts.ReadmePath)
-
-	if _, err := run(root, io.Discard); err == nil {
-		t.Fatal("want an error for a file that cannot be written")
-	}
-	if read(t, root, doccounts.ReadmePath) != before {
-		t.Fatal("a failed run rewrote an earlier file")
-	}
-}
-
-func TestRunReportsAMapWithNoRuleRows(t *testing.T) {
-	root := t.TempDir()
-	writeAt(t, root, doccounts.SpecCompliancePath, "# Compliance\n")
-	if _, err := run(root, io.Discard); err == nil {
-		t.Fatal("want an error when the compliance map states no rule rows")
-	}
-}
-
-func TestRunReportsAKnownFailureRow(t *testing.T) {
-	root := writeFixture(t)
-	writeAt(t, root, doccounts.SpecCompliancePath, fixtureCompliance+"| c | 🚧 Known failure |\n")
-	if _, err := run(root, io.Discard); err == nil || !strings.Contains(err.Error(), "🚧") {
-		t.Fatalf("want an error naming the 🚧 row, got %v", err)
-	}
-}
-
-func TestCheckReportsStaleFilesWithoutWriting(t *testing.T) {
-	root := writeFixture(t)
-	before := read(t, root, doccounts.ReadmePath)
-	var output strings.Builder
-	stale, err := check(root, &output)
-	if err != nil {
-		t.Fatalf("check: %v", err)
-	}
-	if stale != 2 {
-		t.Fatalf("check reported %d stale files, want 2", stale)
-	}
-	if !strings.Contains(output.String(), "README.md is stale") {
-		t.Fatalf("check report does not name README.md:\n%s", output.String())
-	}
-	if read(t, root, doccounts.ReadmePath) != before {
-		t.Fatal("check mode changed README.md")
-	}
-}
-
-func TestCheckCommittedTreeIsCurrent(t *testing.T) {
-	var output strings.Builder
-	stale, err := check("../..", &output)
-	if err != nil {
-		t.Fatalf("check committed tree: %v", err)
-	}
-	if stale != 0 {
-		t.Fatalf("committed tree has %d stale files:\n%s", stale, output.String())
-	}
-}
-
-func writeFixture(t *testing.T) string {
-	t.Helper()
-	root := t.TempDir()
-	writeAt(t, root, doccounts.SpecCompliancePath, fixtureCompliance)
-	writeAt(t, root, doccounts.ReadmePath, fixtureBookkeeping)
-	writeAt(t, root, doccounts.ArchitecturePath, fixtureBookkeeping)
-	writeAt(t, root, "docs/project/pilot-differential-baseline.json", fixtureDifferentialBaseline)
-	writeAt(t, root, "docs/project/pilot-xpect-baseline.json", fixtureXpectBaseline)
-	writeAt(t, root, "docs/project/pilot-rejection-baseline.json", fixtureRejectionBaseline)
-	return root
-}
-
-func writeAt(t *testing.T, root, path, content string) {
-	t.Helper()
-	full := filepath.Join(root, filepath.FromSlash(path))
-	if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
-		t.Fatalf("mkdir: %v", err)
-	}
-	if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
-		t.Fatalf("write %s: %v", path, err)
-	}
-}
-
-func read(t *testing.T, root, path string) string {
-	t.Helper()
-	content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path)))
-	if err != nil {
-		t.Fatalf("read %s: %v", path, err)
-	}
-	return string(content)
-}
diff --git a/cmd/grammar-coverage/classify.go b/cmd/grammar-coverage/classify.go
deleted file mode 100644
index bb104e1183..0000000000
--- a/cmd/grammar-coverage/classify.go
+++ /dev/null
@@ -1,565 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"math/bits"
-	"sort"
-)
-
-// Bucket is the verdict for one production. Every production lands in exactly
-// one bucket.
-type Bucket string
-
-const (
-	// BucketEvidence means one corpus file contains every literal some way
-	// through the production requires, so that input plausibly exercised it.
-	BucketEvidence Bucket = "evidence"
-	// BucketNoEvidence means no single input does: notation nobody has fed us.
-	BucketNoEvidence Bucket = "no-evidence"
-	// BucketIndistinguishable means literal search cannot decide, because the
-	// production can be matched without any literal at all (delegation rules,
-	// fragments like Identification) or is a lexer terminal.
-	BucketIndistinguishable Bucket = "indistinguishable"
-)
-
-// Reasons a production landed in its bucket.
-const (
-	reasonTerminal    = "lexer terminal: matched over characters, not keywords"
-	reasonLiteralFree = "every path through it can be matched without a literal"
-	reasonAbsent      = "a required literal appears in no corpus file"
-	reasonNotTogether = "the required literals occur in the corpora, but never in one file"
-	reasonUndecidable = "only recursive paths through it: literal search cannot derive a requirement"
-)
-
-// Row is one production's verdict.
-type Row struct {
-	Grammar  string `json:"grammar"`
-	Name     string `json:"name"`
-	Kind     Kind   `json:"kind"`
-	Line     int    `json:"line"`
-	Override bool   `json:"override,omitempty"`
-	Returns  string `json:"returns,omitempty"`
-	Bucket   Bucket `json:"bucket"`
-	Reason   string `json:"reason,omitempty"`
-	// Literals are the literals the production's own body spells out.
-	Literals []string `json:"literals,omitempty"`
-	// Required are the literals of the cheapest path through the production,
-	// after expanding the rules it calls.
-	Required []string `json:"requiredLiterals,omitempty"`
-	// Missing are required literals no corpus file contains at all.
-	Missing []string `json:"missingLiterals,omitempty"`
-	// File is the corpus file the evidence comes from, empty when there is none.
-	File     string     `json:"file,omitempty"`
-	Evidence []Citation `json:"evidence,omitempty"`
-	// Branches are the production's literal-bearing forms, each with the input
-	// that has its literals, if any.
-	Branches []Branch `json:"branches,omitempty"`
-}
-
-// UnseenBranches returns the forms of the production no input has the literals
-// for.
-func (r Row) UnseenBranches() []Branch {
-	var out []Branch
-	for _, branch := range r.Branches {
-		if !branch.Seen() {
-			out = append(out, branch)
-		}
-	}
-	return out
-}
-
-// classify decides one production's bucket from the corpus literal index.
-func classify(p Production, an *analyzer, idx *literalIndex) Row {
-	row := Row{
-		Grammar:  p.Grammar,
-		Name:     p.Name,
-		Kind:     p.Kind,
-		Line:     p.Line,
-		Override: p.Override,
-		Returns:  p.Returns,
-		Literals: p.Literals(),
-		Branches: classifyBranches(p, an, idx),
-	}
-	if p.Kind == KindTerminal {
-		row.Bucket = BucketIndistinguishable
-		row.Reason = reasonTerminal
-		return row
-	}
-
-	// Every minimal set of literals an input must contain to take some path
-	// through this production, the called rules expanded.
-	paths := an.Paths(p)
-	if len(paths) == 0 {
-		row.Bucket = BucketIndistinguishable
-		row.Reason = reasonUndecidable
-		return row
-	}
-	if paths[0].isZero() {
-		row.Bucket = BucketIndistinguishable
-		row.Reason = reasonLiteralFree
-		return row
-	}
-
-	// The first file, in search order, holding a whole path's literals.
-	for _, file := range idx.Files() {
-		for _, want := range paths {
-			if !want.subsetOf(file.mask) {
-				continue
-			}
-			row.Bucket = BucketEvidence
-			row.Required = an.lits.names(want)
-			row.File = file.path
-			for _, lit := range row.Required {
-				row.Evidence = append(row.Evidence, Citation{
-					Literal: lit, Root: file.root, File: file.path, Line: file.lines[lit],
-				})
-			}
-			return row
-		}
-	}
-
-	// No single file suffices, so report the closest path: the one fewest of
-	// whose literals are missing corpus-wide.
-	row.Bucket = BucketNoEvidence
-	closest, missing := an.closest(paths, idx.mask)
-	row.Required = an.lits.names(closest)
-	row.Missing = an.lits.names(missing)
-	row.Reason = reasonNotTogether
-	if !missing.isZero() {
-		row.Reason = reasonAbsent
-	}
-	for _, lit := range row.Required {
-		if citation, ok := idx.Citation(lit); ok {
-			row.Evidence = append(row.Evidence, citation)
-		}
-	}
-	sort.Slice(row.Evidence, func(i, j int) bool {
-		return row.Evidence[i].Literal < row.Evidence[j].Literal
-	})
-	return row
-}
-
-// Branch is one literal-bearing alternative or optional group inside a
-// production's own body: a form of the notation an input can spell out, taken
-// together with the literals every path through the production needs anyway. A
-// production lands in the evidence bucket as soon as its cheapest form is
-// present, so branches are where the notation nobody has fed us shows up.
-type Branch struct {
-	// Literals are the literals an input must contain to take this form.
-	Literals []string `json:"literals"`
-	// File and Line cite the input that has them, empty when none does.
-	File string `json:"file,omitempty"`
-	Line int    `json:"line,omitempty"`
-	// Missing are the form's literals no corpus file contains at all; when it is
-	// empty and the form is unseen, the literals just never occur together.
-	Missing []string `json:"missingLiterals,omitempty"`
-}
-
-// Seen reports whether a corpus input has the branch's literals.
-func (b Branch) Seen() bool { return b.File != "" }
-
-// classifyBranches decides, per literal-bearing form of the production, whether
-// an input has its literals.
-func classifyBranches(p Production, an *analyzer, idx *literalIndex) []Branch {
-	if p.Kind == KindTerminal {
-		return nil
-	}
-	var out []Branch
-	for _, paths := range an.branches(p, intersect(an.Paths(p))) {
-		branch := Branch{Literals: an.lits.names(paths[0])}
-		for _, file := range idx.Files() {
-			want, ok := firstSubset(paths, file.mask)
-			if !ok {
-				continue
-			}
-			branch.Literals = an.lits.names(want)
-			branch.File = file.path
-			for _, lit := range branch.Literals {
-				if line := file.lines[lit]; line > branch.Line {
-					branch.Line = line
-				}
-			}
-			break
-		}
-		if !branch.Seen() {
-			branch.Missing = an.lits.names(paths[0].andNot(idx.mask))
-		}
-		out = append(out, branch)
-	}
-	return out
-}
-
-// firstSubset returns the first path the file's literals satisfy.
-func firstSubset(paths []mask, have mask) (mask, bool) {
-	for _, want := range paths {
-		if want.subsetOf(have) {
-			return want, true
-		}
-	}
-	return mask{}, false
-}
-
-// branches enumerates the literal-bearing alternatives and optional groups of
-// the production's own body, cheapest form first, deduplicated. Each entry is
-// that form's own path set.
-// core is the literals every path through the production needs, which an input
-// taking any of its forms must contain too.
-func (an *analyzer) branches(p Production, core mask) [][]mask {
-	var out [][]mask
-	seen := map[mask]bool{}
-	var walk func(e expr)
-	walk = func(e expr) {
-		switch v := e.(type) {
-		case seqExpr:
-			for _, item := range v.Items {
-				walk(item)
-			}
-		case altExpr:
-			for _, item := range v.Items {
-				an.addBranch(p.Grammar, item, core, seen, &out)
-				walk(item)
-			}
-		case optExpr:
-			an.addBranch(p.Grammar, v.Item, core, seen, &out)
-			walk(v.Item)
-		}
-	}
-	walk(p.Body)
-	return out
-}
-
-// addBranch records one form's path set, unless it needs no literal of its own or
-// a cheaper identical form is already recorded.
-func (an *analyzer) addBranch(grammar string, e expr, core mask, seen map[mask]bool, out *[][]mask) {
-	paths := an.paths(grammar, e)
-	if len(paths) == 0 || paths[0].isZero() {
-		return
-	}
-	with := make([]mask, 0, len(paths))
-	for _, path := range paths {
-		with = append(with, path.or(core))
-	}
-	with = prune(with)
-	if seen[with[0]] {
-		return
-	}
-	seen[with[0]] = true
-	*out = append(*out, with)
-}
-
-// maxPaths caps how many alternative literal sets are tracked per production.
-// Bodies with many independent alternatives would otherwise multiply out; the
-// cheapest sets are kept, which is what evidence is decided on.
-const maxPaths = 24
-
-// analyzer answers what literals a production requires, expanding the rules it
-// calls so that a rule delegating to a keyword rule is credited with that
-// keyword.
-type analyzer struct {
-	lits *litTable
-	// byGrammar indexes productions per grammar file, and chain gives each
-	// grammar the grammars it inherits rules from, nearest first.
-	byGrammar map[string]map[string]Production
-	chain     map[string][]string
-	memo      map[string][]mask
-	active    map[string]bool
-	// cuts counts recursive paths dropped so far, so a result that depended on
-	// dropping one is recomputed rather than cached under a partial answer.
-	cuts int
-}
-
-// newAnalyzer indexes the productions for rule resolution. A grammar's own
-// rules win over the inherited ones it names with `with`, which is what Xtext's
-// @Override rules rely on.
-func newAnalyzer(grammars []*Grammar, lits *litTable) *analyzer {
-	an := &analyzer{
-		lits:      lits,
-		byGrammar: map[string]map[string]Production{},
-		chain:     map[string][]string{},
-		memo:      map[string][]mask{},
-		active:    map[string]bool{},
-	}
-	byDeclared := map[string]*Grammar{}
-	for _, g := range grammars {
-		byDeclared[g.Declared] = g
-	}
-	for _, g := range grammars {
-		index := map[string]Production{}
-		for _, p := range g.Productions {
-			index[p.Name] = p
-		}
-		an.byGrammar[g.Name] = index
-		seen := map[string]bool{g.Name: true}
-		for at := byDeclared[g.Extends]; at != nil && !seen[at.Name]; at = byDeclared[at.Extends] {
-			seen[at.Name] = true
-			an.chain[g.Name] = append(an.chain[g.Name], at.Name)
-		}
-	}
-	return an
-}
-
-// Paths returns the minimal literal sets, cheapest first, that an input must
-// contain to take some path through the production. A zero set means a path
-// needs no literal at all.
-func (an *analyzer) Paths(p Production) []mask {
-	key := p.Grammar + "::" + p.Name
-	if cached, ok := an.memo[key]; ok {
-		return cached
-	}
-	if an.active[key] {
-		// Recursion: a path that comes back here has not reached a base case, so
-		// it is not a path at all.
-		an.cuts++
-		return nil
-	}
-	an.active[key] = true
-	before := an.cuts
-	paths := an.paths(p.Grammar, p.Body)
-	delete(an.active, key)
-	if an.cuts == before {
-		an.memo[key] = paths
-	}
-	return paths
-}
-
-// paths computes the minimal literal sets of one body expression.
-func (an *analyzer) paths(grammar string, e expr) []mask {
-	switch v := e.(type) {
-	case litExpr:
-		return []mask{an.lits.mask(v.Value)}
-	case refExpr:
-		called, ok := an.lookup(grammar, v.Name)
-		if !ok {
-			// A cross-reference, an action or a terminal: no literal of its own.
-			return []mask{{}}
-		}
-		return an.Paths(called)
-	case optExpr:
-		// Optional and zero-or-more parts are treated as not taken, keeping the
-		// requirement a lower bound on what an input must contain.
-		return []mask{{}}
-	case seqExpr:
-		out := []mask{{}}
-		for _, item := range v.Items {
-			out = combine(out, an.paths(grammar, item))
-		}
-		return out
-	case altExpr:
-		var out []mask
-		for _, item := range v.Items {
-			out = append(out, an.paths(grammar, item)...)
-		}
-		return prune(out)
-	}
-	return []mask{{}}
-}
-
-// lookup resolves a rule name in the referring grammar, then in the grammars it
-// inherits from.
-func (an *analyzer) lookup(grammar, name string) (Production, bool) {
-	if p, ok := an.byGrammar[grammar][name]; ok {
-		return p, true
-	}
-	for _, inherited := range an.chain[grammar] {
-		if p, ok := an.byGrammar[inherited][name]; ok {
-			return p, true
-		}
-	}
-	return Production{}, false
-}
-
-// closest returns the path fewest of whose literals are absent from the corpora,
-// with those absent literals.
-func (an *analyzer) closest(paths []mask, corpus mask) (chosen, missing mask) {
-	best := -1
-	for _, want := range paths {
-		gap := want.andNot(corpus)
-		if n := gap.count(); best < 0 || n < best {
-			best, chosen, missing = n, want, gap
-		}
-	}
-	return chosen, missing
-}
-
-// combine returns the pairwise unions of two path sets: a sequence requires the
-// literals of both of its parts.
-func combine(left, right []mask) []mask {
-	out := make([]mask, 0, len(left)*len(right))
-	for _, l := range left {
-		for _, r := range right {
-			out = append(out, l.or(r))
-		}
-	}
-	return prune(out)
-}
-
-// prune drops duplicate and redundant path sets, keeping the cheapest. A set
-// that contains another is redundant: whenever it is satisfied, so is the
-// smaller one it contains.
-func prune(in []mask) []mask {
-	sort.Slice(in, func(i, j int) bool { return in[i].less(in[j]) })
-	out := make([]mask, 0, len(in))
-	for _, candidate := range in {
-		redundant := false
-		for _, kept := range out {
-			if kept.subsetOf(candidate) {
-				redundant = true
-				break
-			}
-		}
-		if !redundant {
-			out = append(out, candidate)
-		}
-		if len(out) == maxPaths {
-			break
-		}
-	}
-	return out
-}
-
-// intersect returns the literals every one of the path sets requires.
-func intersect(in []mask) mask {
-	if len(in) == 0 {
-		return mask{}
-	}
-	out := in[0]
-	for _, m := range in[1:] {
-		out = out.and(m)
-	}
-	return out
-}
-
-// maskWords bounds how many distinct literals the grammars may use.
-const maskWords = 16
-
-// mask is a set of grammar literals, one bit per literal.
-type mask [maskWords]uint64
-
-func (m mask) and(o mask) mask {
-	for i := range m {
-		m[i] &= o[i]
-	}
-	return m
-}
-
-func (m mask) or(o mask) mask {
-	for i := range m {
-		m[i] |= o[i]
-	}
-	return m
-}
-
-// andNot returns the literals of m that o lacks.
-func (m mask) andNot(o mask) mask {
-	for i := range m {
-		m[i] &^= o[i]
-	}
-	return m
-}
-
-func (m mask) subsetOf(o mask) bool {
-	for i := range m {
-		if m[i]&^o[i] != 0 {
-			return false
-		}
-	}
-	return true
-}
-
-func (m mask) isZero() bool {
-	for _, w := range m {
-		if w != 0 {
-			return false
-		}
-	}
-	return true
-}
-
-func (m mask) count() int {
-	n := 0
-	for _, w := range m {
-		n += bits.OnesCount64(w)
-	}
-	return n
-}
-
-// less orders masks by size, then by their bits, so path sets are deterministic.
-func (m mask) less(o mask) bool {
-	if a, b := m.count(), o.count(); a != b {
-		return a < b
-	}
-	for i := range m {
-		if m[i] != o[i] {
-			return m[i] < o[i]
-		}
-	}
-	return false
-}
-
-// litTable assigns each grammar literal a bit position.
-type litTable struct {
-	bit   map[string]int
-	order []string
-}
-
-// newLitTable numbers the literals in sorted order, so bit positions and every
-// report derived from them are stable.
-func newLitTable(literals []string) (*litTable, error) {
-	order := dedupe(literals)
-	if len(order) > maskWords*64 {
-		return nil, fmt.Errorf("%d grammar literals exceed the %d this tool tracks: raise maskWords",
-			len(order), maskWords*64)
-	}
-	t := &litTable{bit: make(map[string]int, len(order)), order: order}
-	for i, lit := range order {
-		t.bit[lit] = i
-	}
-	return t, nil
-}
-
-// mask returns the one-literal set, or the empty set for an unknown literal.
-func (t *litTable) mask(literal string) mask {
-	var m mask
-	if bit, ok := t.bit[literal]; ok {
-		m[bit/64] |= 1 << (bit % 64)
-	}
-	return m
-}
-
-// maskOf returns the set of those literals the predicate accepts.
-func (t *litTable) maskOf(has func(string) bool) mask {
-	var m mask
-	for _, lit := range t.order {
-		if has(lit) {
-			m = m.or(t.mask(lit))
-		}
-	}
-	return m
-}
-
-// names returns the literals of a set, sorted.
-func (t *litTable) names(m mask) []string {
-	var out []string
-	for _, lit := range t.order {
-		if t.mask(lit).subsetOf(m) {
-			out = append(out, lit)
-		}
-	}
-	return out
-}
-
-func dedupe(in []string) []string {
-	if len(in) == 0 {
-		return nil
-	}
-	seen := map[string]bool{}
-	out := make([]string, 0, len(in))
-	for _, s := range in {
-		if !seen[s] {
-			seen[s] = true
-			out = append(out, s)
-		}
-	}
-	sort.Strings(out)
-	return out
-}
diff --git a/cmd/grammar-coverage/classify_test.go b/cmd/grammar-coverage/classify_test.go
deleted file mode 100644
index 1a3d823263..0000000000
--- a/cmd/grammar-coverage/classify_test.go
+++ /dev/null
@@ -1,272 +0,0 @@
-package main
-
-import (
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-)
-
-// classifyFixture parses grammar snippets, writes corpus files and classifies,
-// which is the whole pipeline the reported figures come from.
-func classifyFixture(t *testing.T, corpus map[string]string, sources ...string) map[string]Row {
-	t.Helper()
-	repo := t.TempDir()
-	for name, content := range corpus {
-		path := filepath.Join(repo, "models", name)
-		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
-			t.Fatal(err)
-		}
-		if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
-			t.Fatal(err)
-		}
-	}
-
-	var grammars []*Grammar
-	var literals []string
-	for i, src := range sources {
-		grammar, err := ParseGrammar(grammarName(i), src)
-		if err != nil {
-			t.Fatalf("ParseGrammar: %v", err)
-		}
-		grammars = append(grammars, grammar)
-		for _, production := range grammar.Productions {
-			literals = append(literals, production.Literals()...)
-		}
-	}
-	lits, err := newLitTable(literals)
-	if err != nil {
-		t.Fatal(err)
-	}
-	index, err := buildLiteralIndex(repo, []corpusRoot{{Name: "models", Dir: "models"}}, lits)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	analyzer := newAnalyzer(grammars, lits)
-	rows := map[string]Row{}
-	for _, grammar := range grammars {
-		for _, production := range grammar.Productions {
-			rows[production.Name] = classify(production, analyzer, index)
-		}
-	}
-	return rows
-}
-
-func grammarName(i int) string {
-	return string(rune('A'+i)) + ".xtext"
-}
-
-func TestClassifyBuckets(t *testing.T) {
-	const grammar = `
-grammar org.example.A
-
-Part returns SysML::Part :
-	'part' 'def' Name ';'
-;
-
-Port returns SysML::Port :
-	'port' 'def' Name ';'
-;
-
-Usage returns SysML::Usage :
-	Part | Port
-;
-
-fragment Identification returns SysML::Element :
-	( '<' Name '>' )?
-;
-
-terminal Name :
-	('a'..'z')+
-;
-`
-	rows := classifyFixture(t, map[string]string{
-		"model.sysml": "part def Wheel;\n",
-	}, grammar)
-
-	for name, want := range map[string]Bucket{
-		"Part":           BucketEvidence,
-		"Port":           BucketNoEvidence,
-		"Usage":          BucketEvidence,
-		"Identification": BucketIndistinguishable,
-		"Name":           BucketIndistinguishable,
-	} {
-		if got := rows[name].Bucket; got != want {
-			t.Errorf("%s bucket = %s, want %s (%s)", name, got, want, rows[name].Reason)
-		}
-	}
-
-	// Evidence names the input and the line each required literal is on.
-	part := rows["Part"]
-	if part.File != "models/model.sysml" || len(part.Evidence) != 3 {
-		t.Fatalf("Part evidence = %+v", part)
-	}
-	for _, citation := range part.Evidence {
-		if citation.Line != 1 || citation.Root != "models" {
-			t.Errorf("citation = %+v", citation)
-		}
-	}
-	// A delegating rule is credited with the alternative that has evidence.
-	if got := quoteAll(rows["Usage"].Required); got != `";" "def" "part"` {
-		t.Errorf("Usage required = %s", got)
-	}
-	// The missing literal is what makes the no-evidence entry actionable.
-	if got := quoteAll(rows["Port"].Missing); got != `"port"` {
-		t.Errorf("Port missing = %s, reason %q", got, rows["Port"].Reason)
-	}
-}
-
-// Literals from two unrelated inputs were never one input, so they are not
-// evidence together.
-func TestClassifyRequiresOneInput(t *testing.T) {
-	const grammar = `
-grammar org.example.A
-
-Flow returns SysML::Flow :
-	'flow' 'from' 'to' ';'
-;
-`
-	rows := classifyFixture(t, map[string]string{
-		"a.sysml": "flow from ;\n",
-		"b.sysml": "to ;\n",
-	}, grammar)
-	row := rows["Flow"]
-	if row.Bucket != BucketNoEvidence || row.Reason != reasonNotTogether {
-		t.Fatalf("Flow = %s (%s), want no-evidence across files", row.Bucket, row.Reason)
-	}
-	if len(row.Missing) != 0 {
-		t.Errorf("Flow missing = %s, want none: every literal occurs somewhere", quoteAll(row.Missing))
-	}
-
-	rows = classifyFixture(t, map[string]string{"a.sysml": "flow from to ;\n"}, grammar)
-	if got := rows["Flow"].Bucket; got != BucketEvidence {
-		t.Errorf("one input with all of them = %s, want evidence", got)
-	}
-}
-
-// A rule called from another grammar resolves through the `with` chain, and an
-// @Override of it wins for the grammar that declares the override.
-func TestClassifyResolvesInheritedRules(t *testing.T) {
-	const base = `
-grammar org.example.A
-
-Keyword returns SysML::Element :
-	'occurrence'
-;
-
-Definition returns SysML::Element :
-	Keyword 'def' ';'
-;
-`
-	const derived = `
-grammar org.example.B with org.example.A
-
-@Override
-Keyword returns SysML::Element :
-	'individual'
-;
-`
-	rows := classifyFixture(t, map[string]string{"a.sysml": "occurrence def ;\n"}, base, derived)
-	if got := rows["Definition"].Bucket; got != BucketEvidence {
-		t.Errorf("Definition = %s, want evidence through the called rule", got)
-	}
-	if got := quoteAll(rows["Definition"].Required); got != `";" "def" "occurrence"` {
-		t.Errorf("Definition required = %s, want the base grammar's keyword", got)
-	}
-	if got := rows["Keyword"].Bucket; got != BucketNoEvidence {
-		t.Errorf("overriding Keyword = %s, want no-evidence for 'individual'", got)
-	}
-}
-
-// A production is evidence as soon as its cheapest form is present, so the forms
-// it does not need are reported separately.
-func TestClassifyUnseenForms(t *testing.T) {
-	const grammar = `
-grammar org.example.A
-
-Disjoining returns SysML::Disjoining :
-	( 'disjoining' Name )? 'disjoint' 'from' ';'
-;
-`
-	rows := classifyFixture(t, map[string]string{"a.sysml": "disjoint from ;\n"}, grammar)
-	row := rows["Disjoining"]
-	if row.Bucket != BucketEvidence {
-		t.Fatalf("Disjoining = %s, want evidence", row.Bucket)
-	}
-	unseen := row.UnseenBranches()
-	// The form's own literal, plus what every path through the rule needs anyway.
-	if len(unseen) != 1 || quoteAll(unseen[0].Literals) != `";" "disjoining" "disjoint" "from"` {
-		t.Fatalf("unseen forms = %+v, want the named form", unseen)
-	}
-	if got := quoteAll(unseen[0].Missing); got != `"disjoining"` {
-		t.Errorf("missing = %s, want only the literal no input has", got)
-	}
-	for _, branch := range row.Branches {
-		if branch.Seen() && !strings.HasPrefix(branch.File, "models/") {
-			t.Errorf("branch %+v cites no input", branch)
-		}
-	}
-}
-
-// Left recursion must terminate rather than loop.
-func TestClassifyLeftRecursion(t *testing.T) {
-	const grammar = `
-grammar org.example.A
-
-Expression returns SysML::Expression :
-	Primary ( '+' Primary )*
-;
-
-Primary returns SysML::Expression :
-	'(' Expression ')' | 'null'
-;
-`
-	rows := classifyFixture(t, map[string]string{"a.sysml": "null\n"}, grammar)
-	if got := rows["Expression"].Bucket; got != BucketEvidence {
-		t.Errorf("Expression = %s, want evidence via the non-recursive branch", got)
-	}
-}
-
-func TestMaskAndLitTable(t *testing.T) {
-	lits, err := newLitTable([]string{"part", "def", "part", ";"})
-	if err != nil {
-		t.Fatal(err)
-	}
-	if got, want := len(lits.order), 3; got != want {
-		t.Fatalf("literals = %d, want %d deduplicated", got, want)
-	}
-	both := lits.mask("part").or(lits.mask("def"))
-	if !lits.mask("part").subsetOf(both) || both.subsetOf(lits.mask("part")) {
-		t.Errorf("subsetOf is wrong for %v", both)
-	}
-	if got := both.count(); got != 2 {
-		t.Errorf("count = %d, want 2", got)
-	}
-	if got := quoteAll(lits.names(both)); got != `"def" "part"` {
-		t.Errorf("names = %s", got)
-	}
-	if got := both.andNot(lits.mask("part")); !got.subsetOf(lits.mask("def")) || got.isZero() {
-		t.Errorf("andNot = %v, want just def", got)
-	}
-	// An unknown literal is not part of any set, so it cannot fake evidence.
-	if !lits.mask("nope").isZero() {
-		t.Errorf("unknown literal has a bit")
-	}
-}
-
-// A path set keeps only the cheapest requirements: a set containing another is
-// satisfied only when the smaller one is.
-func TestPruneDropsRedundantPaths(t *testing.T) {
-	lits, err := newLitTable([]string{"a", "b", "c"})
-	if err != nil {
-		t.Fatal(err)
-	}
-	a := lits.mask("a")
-	ab := a.or(lits.mask("b"))
-	c := lits.mask("c")
-	got := prune([]mask{ab, c, a, ab})
-	if len(got) != 2 || !got[0].subsetOf(a.or(c)) || got[1].count() != 1 {
-		t.Fatalf("prune = %v, want the two single-literal sets", got)
-	}
-}
diff --git a/cmd/grammar-coverage/main.go b/cmd/grammar-coverage/main.go
deleted file mode 100644
index 2530fe2861..0000000000
--- a/cmd/grammar-coverage/main.go
+++ /dev/null
@@ -1,174 +0,0 @@
-// Command grammar-coverage measures which productions of the OMG Xtext
-// grammars our test inputs exercise, on input-presence evidence: for every rule,
-// fragment, enum and terminal of KerML.xtext, KerMLExpressions.xtext and
-// SysML.xtext it searches the corpora we already parse for the literals the
-// production requires, and buckets it as evidence, no-evidence or
-// indistinguishable.
-//
-// The number it reports is an over-approximation. A literal in a corpus file
-// means an input could have driven that production; it does not prove our
-// parser took that path, nor that it handled it correctly. It is not a
-// compliance measure.
-//
-// It is advisory: nothing in the build or the test suite depends on it, and it
-// only reads the corpora. Provision the grammars with
-// scripts/download-pilot-grammars.sh, then run `go run ./cmd/grammar-coverage`.
-// -baseline refreshes the committed docs/project/grammar-coverage-baseline.json,
-// which carries the counts and the gaps rather than every row. See
-// docs/project/grammar-coverage.md.
-package main
-
-import (
-	"flag"
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-)
-
-func main() {
-	repo := flag.String("repo", "", "repository root (default: the module root containing this command)")
-	grammars := flag.String("grammars", "", "directory holding the .xtext grammars (default: <repo>/build/pilot-grammars)")
-	out := flag.String("out", "", "output directory for the reports (default: <repo>/build/grammar-coverage)")
-	baseline := flag.String("baseline", "", "also write the compact baseline JSON to this file")
-	flag.Parse()
-
-	if err := run(*repo, *grammars, *out, *baseline); err != nil {
-		fmt.Fprintf(os.Stderr, "grammar-coverage: %v\n", err)
-		os.Exit(1)
-	}
-}
-
-func run(repo, grammarDir, out, baseline string) error {
-	var err error
-	if repo == "" {
-		repo, err = moduleRoot()
-		if err != nil {
-			return err
-		}
-	}
-	if grammarDir == "" {
-		grammarDir = filepath.Join(repo, "build", "pilot-grammars")
-	}
-	if out == "" {
-		out = filepath.Join(repo, "build", "grammar-coverage")
-	}
-
-	files, err := grammarFiles(grammarDir)
-	if err != nil {
-		return err
-	}
-
-	var parsedGrammars []*Grammar
-	var literals []string
-	for _, path := range files {
-		// #nosec G304 -- the grammar directory is named on the command line.
-		data, err := os.ReadFile(path)
-		if err != nil {
-			return err
-		}
-		parsed, err := ParseGrammar(filepath.Base(path), string(data))
-		if err != nil {
-			return err
-		}
-		fmt.Fprintf(os.Stderr, "%s: %d production(s)\n", parsed.Name, len(parsed.Productions))
-		parsedGrammars = append(parsedGrammars, parsed)
-		for _, production := range parsed.Productions {
-			literals = append(literals, production.Literals()...)
-		}
-	}
-
-	lits, err := newLitTable(literals)
-	if err != nil {
-		return err
-	}
-	index, err := buildLiteralIndex(repo, evidenceRoots, lits)
-	if err != nil {
-		return err
-	}
-	corpusFiles := 0
-	for _, root := range index.Roots() {
-		corpusFiles += root.Files
-	}
-	if corpusFiles == 0 {
-		return fmt.Errorf("no .sysml or .kerml files found under %s: is -repo right?", repo)
-	}
-	fmt.Fprintf(os.Stderr, "searched %d corpus file(s) for %d distinct literal(s)\n", corpusFiles, len(lits.order))
-
-	rows := classifyAll(parsedGrammars, newAnalyzer(parsedGrammars, lits), index)
-	report := buildReport(pilotTag(grammarDir), rows, index.Roots())
-	if err := writeReports(out, report); err != nil {
-		return err
-	}
-	if baseline == "" {
-		return nil
-	}
-	return writeBaseline(baseline, report)
-}
-
-// classifyAll classifies every production, keeping the grammars in the order
-// they were read and the productions in declaration order.
-func classifyAll(grammars []*Grammar, an *analyzer, index *literalIndex) []GrammarReport {
-	reports := make([]GrammarReport, 0, len(grammars))
-	for _, grammar := range grammars {
-		report := GrammarReport{Name: grammar.Name}
-		for _, production := range grammar.Productions {
-			report.Productions = append(report.Productions, classify(production, an, index))
-		}
-		reports = append(reports, report)
-	}
-	return reports
-}
-
-// grammarFiles returns the grammars to measure, sorted by name so the report is
-// deterministic.
-func grammarFiles(dir string) ([]string, error) {
-	entries, err := os.ReadDir(dir)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil, fmt.Errorf("grammars not found at %s: run ./scripts/download-pilot-grammars.sh", dir)
-		}
-		return nil, err
-	}
-	var files []string
-	for _, entry := range entries {
-		if !entry.IsDir() && filepath.Ext(entry.Name()) == ".xtext" {
-			files = append(files, filepath.Join(dir, entry.Name()))
-		}
-	}
-	if len(files) == 0 {
-		return nil, fmt.Errorf("no .xtext grammars in %s: run ./scripts/download-pilot-grammars.sh", dir)
-	}
-	sort.Strings(files)
-	return files, nil
-}
-
-// pilotTag reads the release the grammars were fetched at, as recorded by the
-// download script.
-func pilotTag(dir string) string {
-	// #nosec G304 -- the grammar directory is named on the command line.
-	data, err := os.ReadFile(filepath.Join(dir, "PILOT_TAG"))
-	if err != nil {
-		return "unknown"
-	}
-	return strings.TrimSpace(string(data))
-}
-
-// moduleRoot walks up from the working directory to the directory holding go.mod.
-func moduleRoot() (string, error) {
-	dir, err := os.Getwd()
-	if err != nil {
-		return "", err
-	}
-	for {
-		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
-			return dir, nil
-		}
-		parent := filepath.Dir(dir)
-		if parent == dir {
-			return "", fmt.Errorf("no go.mod found above the working directory; pass -repo")
-		}
-		dir = parent
-	}
-}
diff --git a/cmd/grammar-coverage/report.go b/cmd/grammar-coverage/report.go
deleted file mode 100644
index 7de010dead..0000000000
--- a/cmd/grammar-coverage/report.go
+++ /dev/null
@@ -1,312 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-)
-
-// Report is the machine-readable result: every production of every grammar with
-// its bucket and, where there is one, the input that is evidence for it.
-type Report struct {
-	PilotTag string          `json:"pilotTag"`
-	Roots    []RootStat      `json:"roots"`
-	Totals   BucketTotals    `json:"totals"`
-	Grammars []GrammarReport `json:"grammars"`
-}
-
-// BucketTotals counts productions per bucket. Productions is the denominator no
-// ratio may be quoted without.
-type BucketTotals struct {
-	Productions       int `json:"productions"`
-	Evidence          int `json:"evidence"`
-	NoEvidence        int `json:"noEvidence"`
-	Indistinguishable int `json:"indistinguishable"`
-	// Forms counts the literal-bearing alternatives and optional groups inside
-	// the productions; UnseenForms those no input has the literals for.
-	Forms       int `json:"forms"`
-	UnseenForms int `json:"unseenForms"`
-}
-
-func (t *BucketTotals) add(row Row) {
-	t.Productions++
-	switch row.Bucket {
-	case BucketEvidence:
-		t.Evidence++
-	case BucketNoEvidence:
-		t.NoEvidence++
-	case BucketIndistinguishable:
-		t.Indistinguishable++
-	}
-	t.Forms += len(row.Branches)
-	t.UnseenForms += len(row.UnseenBranches())
-}
-
-// GrammarReport holds one grammar file's productions, ordered by declaration
-// site.
-type GrammarReport struct {
-	Name        string       `json:"name"`
-	Totals      BucketTotals `json:"totals"`
-	Productions []Row        `json:"productions"`
-}
-
-func buildReport(pilotTag string, grammars []GrammarReport, roots []RootStat) *Report {
-	report := &Report{PilotTag: pilotTag, Roots: roots, Grammars: grammars}
-	for i := range report.Grammars {
-		grammar := &report.Grammars[i]
-		grammar.Totals = BucketTotals{}
-		for _, row := range grammar.Productions {
-			grammar.Totals.add(row)
-			report.Totals.add(row)
-		}
-	}
-	return report
-}
-
-// Summary is the committed baseline: the counts, plus only the rows a reader has
-// to look at — the no-evidence productions and the ones with an unseen form.
-// The full per-production table stays in the generated report under build/.
-func (r *Report) Summary() *Report {
-	out := &Report{PilotTag: r.PilotTag, Roots: r.Roots, Totals: r.Totals}
-	for _, grammar := range r.Grammars {
-		kept := GrammarReport{Name: grammar.Name, Totals: grammar.Totals}
-		for _, row := range grammar.Productions {
-			if row.Bucket == BucketNoEvidence || len(row.UnseenBranches()) > 0 {
-				kept.Productions = append(kept.Productions, row)
-			}
-		}
-		out.Grammars = append(out.Grammars, kept)
-	}
-	return out
-}
-
-// writeBaseline writes the compact JSON a later run is diffed against.
-func writeBaseline(path string, report *Report) error {
-	encoded, err := json.MarshalIndent(report.Summary(), "", "  ")
-	if err != nil {
-		return err
-	}
-	if err := os.WriteFile(path, append(encoded, '\n'), 0o600); err != nil {
-		return err
-	}
-	fmt.Fprintf(os.Stderr, "wrote %s\n", path)
-	return nil
-}
-
-// writeReports writes the full JSON, the text summary and the Markdown tables.
-func writeReports(dir string, report *Report) error {
-	if err := os.MkdirAll(dir, 0o750); err != nil {
-		return err
-	}
-	encoded, err := json.MarshalIndent(report, "", "  ")
-	if err != nil {
-		return err
-	}
-	files := map[string][]byte{
-		"grammar-coverage-tables.md": []byte(report.Markdown()),
-		"grammar-coverage.json":      append(encoded, '\n'),
-		"grammar-coverage.txt":       []byte(report.Text()),
-	}
-	names := make([]string, 0, len(files))
-	for name := range files {
-		names = append(names, name)
-	}
-	sort.Strings(names)
-	for _, name := range names {
-		path := filepath.Join(dir, name)
-		if err := os.WriteFile(path, files[name], 0o600); err != nil {
-			return err
-		}
-		fmt.Fprintf(os.Stderr, "wrote %s\n", path)
-	}
-	return nil
-}
-
-// Text renders the human summary.
-func (r *Report) Text() string {
-	var b strings.Builder
-	fmt.Fprintf(&b, "Grammar production coverage (input-presence evidence, not execution coverage)\n")
-	fmt.Fprintf(&b, "OMG Xtext grammars at pilot tag %s\n\n", r.PilotTag)
-
-	fmt.Fprintf(&b, "Corpora searched\n")
-	for _, root := range r.Roots {
-		fmt.Fprintf(&b, "  %-24s %5d file(s) %8d line(s)  %s\n", root.Name, root.Files, root.Lines, root.Dir)
-	}
-	fmt.Fprintf(&b, "\nBuckets\n")
-	fmt.Fprintf(&b, "  %-22s %11s %11s %11s %19s\n", "grammar", "productions", "evidence", "no-evidence", "indistinguishable")
-	for _, grammar := range r.Grammars {
-		fmt.Fprintf(&b, "  %-22s %11d %11d %11d %19d\n", grammar.Name,
-			grammar.Totals.Productions, grammar.Totals.Evidence, grammar.Totals.NoEvidence, grammar.Totals.Indistinguishable)
-	}
-	fmt.Fprintf(&b, "  %-22s %11d %11d %11d %19d\n", "total",
-		r.Totals.Productions, r.Totals.Evidence, r.Totals.NoEvidence, r.Totals.Indistinguishable)
-
-	fmt.Fprintf(&b, "\nForms (alternatives and optional groups inside the productions)\n")
-	fmt.Fprintf(&b, "  %-22s %11s %11s\n", "grammar", "forms", "unseen")
-	for _, grammar := range r.Grammars {
-		fmt.Fprintf(&b, "  %-22s %11d %11d\n", grammar.Name, grammar.Totals.Forms, grammar.Totals.UnseenForms)
-	}
-	fmt.Fprintf(&b, "  %-22s %11d %11d\n", "total", r.Totals.Forms, r.Totals.UnseenForms)
-
-	for _, grammar := range r.Grammars {
-		fmt.Fprintf(&b, "\n%s: no-evidence productions\n", grammar.Name)
-		empty := true
-		for _, row := range grammar.Productions {
-			if row.Bucket != BucketNoEvidence {
-				continue
-			}
-			empty = false
-			fmt.Fprintf(&b, "  %s:%d %s (%s) missing %s\n", grammar.Name, row.Line, row.Name, row.Kind, quoteAll(row.Missing))
-		}
-		if empty {
-			fmt.Fprintf(&b, "  (none)\n")
-		}
-	}
-
-	for _, grammar := range r.Grammars {
-		fmt.Fprintf(&b, "\n%s: unseen forms\n", grammar.Name)
-		empty := true
-		for _, row := range grammar.Productions {
-			for _, branch := range row.UnseenBranches() {
-				empty = false
-				fmt.Fprintf(&b, "  %s:%d %s (%s) needs %s%s\n", grammar.Name, row.Line, row.Name, row.Kind,
-					quoteAll(branch.Literals), missingNote(branch))
-			}
-		}
-		if empty {
-			fmt.Fprintf(&b, "  (none)\n")
-		}
-	}
-	return b.String()
-}
-
-// Markdown renders the tables docs/project/grammar-coverage-tables.md carries.
-func (r *Report) Markdown() string {
-	var b strings.Builder
-	fmt.Fprintf(&b, "# Grammar production coverage: tables\n\n")
-	fmt.Fprintf(&b, "<!-- Generated by `go run ./cmd/grammar-coverage`; do not edit by hand. -->\n")
-	fmt.Fprintf(&b, "\nInput-presence evidence against the OMG Xtext grammars at pilot tag `%s`, not\n"+
-		"execution coverage: see docs/project/grammar-coverage.md for the method, the\n"+
-		"caveats and the adjudication.\n", r.PilotTag)
-	fmt.Fprintf(&b, "\n## Bucket counts\n\n")
-	fmt.Fprintf(&b, "| Grammar | Productions | evidence | no-evidence | indistinguishable | Forms | unseen forms |\n"+
-		"|---|---:|---:|---:|---:|---:|---:|\n")
-	for _, grammar := range r.Grammars {
-		fmt.Fprintf(&b, "| `%s` | %d | %d | %d | %d | %d | %d |\n", grammar.Name,
-			grammar.Totals.Productions, grammar.Totals.Evidence, grammar.Totals.NoEvidence,
-			grammar.Totals.Indistinguishable, grammar.Totals.Forms, grammar.Totals.UnseenForms)
-	}
-	fmt.Fprintf(&b, "| **total** | **%d** | **%d** | **%d** | **%d** | **%d** | **%d** |\n",
-		r.Totals.Productions, r.Totals.Evidence, r.Totals.NoEvidence, r.Totals.Indistinguishable,
-		r.Totals.Forms, r.Totals.UnseenForms)
-
-	fmt.Fprintf(&b, "\n## Corpora searched\n\n")
-	fmt.Fprintf(&b, "| Root | Directory | Files | Lines |\n|---|---|---:|---:|\n")
-	for _, root := range r.Roots {
-		fmt.Fprintf(&b, "| %s | `%s` | %d | %d |\n", root.Name, root.Dir, root.Files, root.Lines)
-	}
-
-	fmt.Fprintf(&b, "\n## No-evidence productions\n")
-	for _, grammar := range r.Grammars {
-		fmt.Fprintf(&b, "\n### `%s`\n\n", grammar.Name)
-		fmt.Fprintf(&b, "| Production | Kind | Declared | Missing literals |\n|---|---|---|---|\n")
-		rows := 0
-		for _, row := range grammar.Productions {
-			if row.Bucket != BucketNoEvidence {
-				continue
-			}
-			rows++
-			fmt.Fprintf(&b, "| `%s` | %s | `%s:%d` | %s |\n", row.Name, row.Kind, grammar.Name, row.Line, markdownLiterals(row.Missing))
-		}
-		if rows == 0 {
-			fmt.Fprintf(&b, "| _(none)_ | | | |\n")
-		}
-	}
-
-	fmt.Fprintf(&b, "\n## Unseen forms\n")
-	for _, grammar := range r.Grammars {
-		fmt.Fprintf(&b, "\n### `%s`\n\n", grammar.Name)
-		fmt.Fprintf(&b, "| Production | Declared | Form needs | Never in the corpora |\n|---|---|---|---|\n")
-		rows := 0
-		for _, row := range grammar.Productions {
-			for _, branch := range row.UnseenBranches() {
-				rows++
-				fmt.Fprintf(&b, "| `%s` | `%s:%d` | %s | %s |\n", row.Name, grammar.Name, row.Line,
-					markdownLiterals(branch.Literals), markdownLiterals(branch.Missing))
-			}
-		}
-		if rows == 0 {
-			fmt.Fprintf(&b, "| _(none)_ | | | |\n")
-		}
-	}
-
-	fmt.Fprintf(&b, "\n## Every production\n")
-	for _, grammar := range r.Grammars {
-		fmt.Fprintf(&b, "\n### `%s`\n\n", grammar.Name)
-		fmt.Fprintf(&b, "| Production | Kind | Declared | Bucket | Forms (unseen) | Required literals | Evidence |\n|---|---|---|---|---|---|---|\n")
-		for _, row := range grammar.Productions {
-			fmt.Fprintf(&b, "| `%s`%s | %s | `%s:%d` | %s | %d (%d) | %s | %s |\n",
-				row.Name, overrideMark(row), row.Kind, grammar.Name, row.Line, row.Bucket,
-				len(row.Branches), len(row.UnseenBranches()),
-				markdownLiterals(row.Required), markdownEvidence(row))
-		}
-	}
-	return b.String()
-}
-
-// missingNote names the form's literals that occur in no corpus file at all.
-func missingNote(branch Branch) string {
-	if len(branch.Missing) == 0 {
-		return " (all of them occur, never together)"
-	}
-	return ", never seen: " + quoteAll(branch.Missing)
-}
-
-func overrideMark(row Row) string {
-	if row.Override {
-		return " (`@Override`)"
-	}
-	return ""
-}
-
-func markdownLiterals(literals []string) string {
-	if len(literals) == 0 {
-		return "—"
-	}
-	quoted := make([]string, 0, len(literals))
-	for _, lit := range literals {
-		quoted = append(quoted, "`"+strings.ReplaceAll(lit, "|", "\\|")+"`")
-	}
-	return strings.Join(quoted, " ")
-}
-
-// markdownEvidence renders the first corpus occurrence of each required literal,
-// or the reason literal search could not decide.
-func markdownEvidence(row Row) string {
-	if row.Bucket == BucketIndistinguishable {
-		return row.Reason
-	}
-	if len(row.Evidence) == 0 {
-		return "—"
-	}
-	cited := make([]string, 0, len(row.Evidence))
-	for _, citation := range row.Evidence {
-		literal := strings.ReplaceAll(citation.Literal, "|", "\\|")
-		cited = append(cited, fmt.Sprintf("`%s` at `%s:%d`", literal, citation.File, citation.Line))
-	}
-	return strings.Join(cited, "<br>")
-}
-
-func quoteAll(literals []string) string {
-	if len(literals) == 0 {
-		return "(nothing)"
-	}
-	quoted := make([]string, 0, len(literals))
-	for _, lit := range literals {
-		quoted = append(quoted, fmt.Sprintf("%q", lit))
-	}
-	return strings.Join(quoted, " ")
-}
diff --git a/cmd/grammar-coverage/report_test.go b/cmd/grammar-coverage/report_test.go
deleted file mode 100644
index 64ad626988..0000000000
--- a/cmd/grammar-coverage/report_test.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package main
-
-import (
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-)
-
-func testReport() *Report {
-	return buildReport("2026-05", []GrammarReport{{
-		Name: "Toy.xtext",
-		Productions: []Row{
-			{Grammar: "Toy.xtext", Name: "Part", Kind: KindRule, Line: 3, Bucket: BucketEvidence,
-				Required: []string{"part", "def"}, File: "testdata/a.sysml",
-				Evidence: []Citation{{Literal: "part", Root: "testdata", File: "testdata/a.sysml", Line: 1}},
-				Branches: []Branch{{Literals: []string{"part"}, File: "testdata/a.sysml", Line: 1},
-					{Literals: []string{"part", "variant"}, Missing: []string{"variant"}}}},
-			{Grammar: "Toy.xtext", Name: "Port", Kind: KindRule, Line: 7, Bucket: BucketNoEvidence,
-				Required: []string{"port"}, Missing: []string{"port"}, Reason: reasonAbsent},
-			{Grammar: "Toy.xtext", Name: "Name", Kind: KindTerminal, Line: 11,
-				Bucket: BucketIndistinguishable, Reason: reasonTerminal},
-		},
-	}}, []RootStat{{Name: "testdata", Dir: "testdata", Files: 1, Lines: 2}})
-}
-
-func TestBuildReportTotals(t *testing.T) {
-	report := testReport()
-	want := BucketTotals{Productions: 3, Evidence: 1, NoEvidence: 1, Indistinguishable: 1, Forms: 2, UnseenForms: 1}
-	if report.Totals != want {
-		t.Errorf("totals = %+v, want %+v", report.Totals, want)
-	}
-	if report.Grammars[0].Totals != want {
-		t.Errorf("grammar totals = %+v, want %+v", report.Grammars[0].Totals, want)
-	}
-}
-
-// The committed baseline keeps the counts but only the rows worth reading: the
-// no-evidence productions and the ones with an unseen form.
-func TestSummaryKeepsCountsAndGapsOnly(t *testing.T) {
-	summary := testReport().Summary()
-	if summary.Totals != testReport().Totals {
-		t.Errorf("summary totals = %+v, want the full totals", summary.Totals)
-	}
-	var names []string
-	for _, row := range summary.Grammars[0].Productions {
-		names = append(names, row.Name)
-	}
-	if len(names) != 2 || names[0] != "Part" || names[1] != "Port" {
-		t.Errorf("summary rows = %v, want the unseen-form and no-evidence rows", names)
-	}
-}
-
-func TestWriteBaselineIsTheSummary(t *testing.T) {
-	path := filepath.Join(t.TempDir(), "baseline.json")
-	if err := writeBaseline(path, testReport()); err != nil {
-		t.Fatal(err)
-	}
-	data, err := os.ReadFile(path) // #nosec G304 -- the test wrote this path.
-	if err != nil {
-		t.Fatal(err)
-	}
-	if strings.Contains(string(data), `"Name"`) {
-		t.Error("baseline carries an indistinguishable row; it should hold only the gaps")
-	}
-	if !strings.Contains(string(data), `"unseenForms": 1`) {
-		t.Error("baseline is missing the counts")
-	}
-}
-
-// The report is diffed against a committed artifact, so rendering must be a
-// function of the rows alone.
-func TestWriteReportsDeterministic(t *testing.T) {
-	first, second := t.TempDir(), t.TempDir()
-	if err := writeReports(first, testReport()); err != nil {
-		t.Fatal(err)
-	}
-	if err := writeReports(second, testReport()); err != nil {
-		t.Fatal(err)
-	}
-	for _, name := range []string{"grammar-coverage-tables.md", "grammar-coverage.json", "grammar-coverage.txt"} {
-		a, err := os.ReadFile(filepath.Join(first, name))
-		if err != nil {
-			t.Fatal(err)
-		}
-		b, err := os.ReadFile(filepath.Join(second, name))
-		if err != nil {
-			t.Fatal(err)
-		}
-		if string(a) != string(b) {
-			t.Errorf("%s differs between runs", name)
-		}
-	}
-}
-
-func TestReportRendering(t *testing.T) {
-	text := testReport().Text()
-	for _, want := range []string{
-		"input-presence evidence, not execution coverage",
-		"pilot tag 2026-05",
-		`Toy.xtext:7 Port (rule) missing "port"`,
-		`Toy.xtext:3 Part (rule) needs "part" "variant", never seen: "variant"`,
-	} {
-		if !strings.Contains(text, want) {
-			t.Errorf("text output lacks %q:\n%s", want, text)
-		}
-	}
-
-	markdown := testReport().Markdown()
-	for _, want := range []string{
-		"| `Part` | rule | `Toy.xtext:3` | evidence | 2 (1) |",
-		"`part` at `testdata/a.sysml:1`",
-		"| `Name` | terminal | `Toy.xtext:11` | indistinguishable | 0 (0) | — | " + reasonTerminal + " |",
-	} {
-		if !strings.Contains(markdown, want) {
-			t.Errorf("markdown lacks %q:\n%s", want, markdown)
-		}
-	}
-}
diff --git a/cmd/pilot-diff/doc_counts_test.go b/cmd/pilot-diff/doc_counts_test.go
deleted file mode 100644
index 174864d3b6..0000000000
--- a/cmd/pilot-diff/doc_counts_test.go
+++ /dev/null
@@ -1,979 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"regexp"
-	"sort"
-	"strconv"
-	"strings"
-	"testing"
-	"unicode"
-	"unicode/utf8"
-
-	"github.com/Open-MBEE/OpenSysML/internal/doccounts"
-)
-
-const (
-	docCountPath               = "docs/project/pilot-differential.md"
-	docCountBaselinePath       = "docs/project/pilot-differential-baseline.json"
-	docCountReadmePath         = "README.md"
-	docCountArchitecturePath   = "docs/internals/architecture.md"
-	docCountSpecCompliancePath = "docs/project/spec-compliance.md"
-	docCountReferenceMarker    = "**Reference differential:**"
-	docCountRejectionMarker    = "**Rejection oracle:**"
-)
-
-type docLine struct {
-	number int
-	text   string
-}
-
-type docTable struct {
-	header     []string
-	headerLine int
-	rows       []docTableRow
-}
-
-type docTableRow struct {
-	line  int
-	cells []string
-}
-
-type docNumber struct {
-	start int
-	end   int
-	text  string
-	line  int
-}
-
-var (
-	docHeadlinePattern       = regexp.MustCompile(`^## Results \(pilot ` + "`" + `([^` + "`" + `]+)` + "`" + `, ([0-9]+) files\)$`)
-	docParentheticalPattern  = regexp.MustCompile(`\s+\([^()]*\)$`)
-	docCategoryItemPattern   = regexp.MustCompile("^(\\d+)\\s+(`?[A-Za-z][A-Za-z0-9-]*`?)")
-	docNextCategoryPattern   = regexp.MustCompile(",\\s*\\d+\\s+`?[A-Za-z][A-Za-z0-9-]*`?")
-	docIntegerPattern        = regexp.MustCompile(`^-?[0-9]+$`)
-	docRootPattern           = regexp.MustCompile("(?s)^\\s*`([^`]+)`\\s*(.*)$")
-	docReferencePattern      = regexp.MustCompile("^\\*\\*Reference differential:\\*\\* ([0-9]+) files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation \\(`([^`]+)`\\), ([0-9]+) in full agreement;")
-	docMovementRowsUnchecked = map[string]bool{"new checks of ours": true}
-
-	// The five headline numbers the README and the architecture guide lead with,
-	// each re-derived below from the baseline of the harness that measured it.
-	docCorpusAgreementPattern = regexp.MustCompile(`^- \*\*Corpus agreement:\*\* ([0-9]+) of ([0-9]+) files agree diagnostic-by-diagnostic; ([0-9]+) diagnostics are ours alone and ([0-9]+) the reference's alone`)
-	docDeclaredSilencePattern = regexp.MustCompile("^- \\*\\*Declared-diagnostic silence:\\*\\* of the ([0-9]+) declared `errors` rows in the reference's own Xpect suites, we report nothing for ([0-9]+)\\. ([0-9]+) we report word-for-word; ([0-9]+) wording-only and ([0-9]+) location-only differences are agreement in substance and are not counted as gaps; ([0-9]+) more we report as a warning and ([0-9]+) elsewhere in the file")
-	docScopeAgreementPattern  = regexp.MustCompile(`^- \*\*Scope agreement:\*\* ([0-9]+) of ([0-9]+) declared scope assertions match exactly`)
-	docPermissivenessPattern  = regexp.MustCompile(`^- \*\*Permissiveness gaps:\*\* of ([0-9]+) invalid models we wrote ourselves, the reference rejects ([0-9]+) that we accept by default, and ([0-9]+) both reject; ([0-9]+) further cases agree only when we are asked strictly`)
-	docErrataPattern          = regexp.MustCompile(`^- \*\*Declared errata:\*\* the registry declares ([0-9]+) defect\(s\) in the published reference material — ([0-9]+) with a specification-derived correction, ([0-9]+) documented without one, since no intended reading can be inferred \(\[OMG issues\]\([^)]+\), ` + "`internal/errata`" + `\)\. Every figure above is as published and stays the conformance statement; running the same oracles over the corrected text instead reports ([0-9]+) of ([0-9]+) files agreeing, ([0-9]+) diagnostics ours alone and ([0-9]+) the reference's alone, ([0-9]+) declared rows we are silent on, and ([0-9]+) of ([0-9]+) authored cases the reference alone rejects\.`)
-	docRejectionLinePattern   = regexp.MustCompile(`^\*\*Rejection oracle:\*\* the reverse direction — do we reject what the reference rejects\? ([0-9]+) hand-written invalid models validated by both implementations, ([0-9]+) rejected by both, ([0-9]+) the pinned pilot rejects and we accept;`)
-)
-
-// TestPilotDifferentialDocumentCountsMatchBaseline guards headline totals, Results cells and
-// root rows, per-category only-ours/only-pilot prose, and the movement table's Now column.
-// The committed baseline JSON is the only input; validators and corpora are unnecessary.
-// Causal claims, attributions, historical movement columns and adjudication-section counts are
-// out of scope: this checks numbers, not why they moved. It also guards the five refereed
-// headline numbers the README and architecture guide lead with, each against the
-// baseline of the harness that measured it. The compliance map's own census is counted
-// when the documentation site is built, so no committed line states it; only a 🚧 row,
-// which that census refuses, is guarded here.
-func TestPilotDifferentialDocumentCountsMatchBaseline(t *testing.T) {
-	lines := docReadNumberedDocument(t)
-	report := docReadBaselineReport(t)
-
-	heading := docRequireResultsHeading(t, lines)
-	docAssertHeadline(t, heading, report)
-
-	results := docRequireTable(t, lines, heading.number+1, "")
-	docAssertResultsTable(t, results, report)
-
-	categoryStart := docRequireLineContaining(t, lines, "Per category, the only-ours totals are:")
-	docAssertCategoryProse(t, lines, categoryStart, report)
-
-	movementStart := docRequireLineContaining(t, lines, "Where the current counts stand")
-	movement := docRequireTable(t, lines, movementStart.number+1, "Count")
-	docAssertMovementTable(t, movement, report)
-
-	roundStart := docRequireLineContaining(t, lines, "### Feature-initialization round")
-	round := docRequireTable(t, lines, roundStart.number+1, "Count")
-	docAssertMovementTable(t, round, report)
-
-	readmeLines := docReadNumberedFile(t, docCountReadmePath)
-	architectureLines := docReadNumberedFile(t, docCountArchitecturePath)
-	ruleCounts := docReadSpecComplianceCounts(t)
-	refereed := docReadRefereedCounts(t)
-	docAssertReferenceLine(t, docRequireLineContainingPath(t, readmeLines, docCountReadmePath, docCountReferenceMarker), report)
-	docAssertRejectionLine(t, docRequireLineContainingPath(t, readmeLines, docCountReadmePath, docCountRejectionMarker), refereed)
-	docAssertRefereedHeadline(t, docCountReadmePath, readmeLines, refereed)
-	docAssertRefereedHeadline(t, docCountArchitecturePath, architectureLines, refereed)
-	if ruleCounts.Total == 0 {
-		docFailPathAt(t, docCountSpecCompliancePath, 1, "no rule rows to count")
-	}
-	if ruleCounts.KnownFailure != 0 {
-		docFailPathAt(t, docCountSpecCompliancePath, 1, "%d 🚧 rows have no place in the census; give them a status it states", ruleCounts.KnownFailure)
-	}
-}
-
-func TestDifferentialBaselineDecodersAgree(t *testing.T) {
-	report := docReadBaselineReport(t)
-	counts, err := doccounts.ReadRefereedCounts("../..")
-	if err != nil {
-		t.Fatalf("read refereed baselines: %v", err)
-	}
-	if counts.Files != report.Totals.Files ||
-		counts.FilesAgreeing != report.Totals.FilesAgreeing ||
-		counts.OursOnly != report.Totals.OpenSysMLOnly ||
-		counts.PilotOnly != report.Totals.PilotOnly {
-		t.Fatalf("differential decoder mismatch: doccounts=%+v report=%+v", counts, report.Totals)
-	}
-}
-
-func docReadNumberedDocument(t *testing.T) []docLine {
-	t.Helper()
-	return docReadNumberedFile(t, docCountPath)
-}
-
-func docReadNumberedFile(t *testing.T, path string) []docLine {
-	t.Helper()
-	content, err := os.ReadFile(filepath.FromSlash("../../" + path))
-	if err != nil {
-		t.Fatalf("%s:1: read document: %v", path, err)
-	}
-	raw := strings.Split(string(content), "\n")
-	lines := make([]docLine, len(raw))
-	for i, text := range raw {
-		lines[i] = docLine{number: i + 1, text: text}
-	}
-	return lines
-}
-
-func docReadBaselineReport(t *testing.T) Report {
-	t.Helper()
-	content, err := os.ReadFile(filepath.FromSlash("../../" + docCountBaselinePath))
-	if err != nil {
-		t.Fatalf("%s:1: read baseline: %v", docCountBaselinePath, err)
-	}
-	var report Report
-	if err := json.Unmarshal(content, &report); err != nil {
-		t.Fatalf("%s:1: parse baseline: %v", docCountBaselinePath, err)
-	}
-	return report
-}
-
-// docReadSpecComplianceCounts reads the row census through the same package the
-// regenerator writes the derived lines from, so the two cannot disagree.
-func docReadSpecComplianceCounts(t *testing.T) doccounts.RuleCounts {
-	t.Helper()
-	content, err := os.ReadFile(filepath.FromSlash("../../" + docCountSpecCompliancePath))
-	if err != nil {
-		t.Fatalf("%s:1: read document: %v", docCountSpecCompliancePath, err)
-	}
-	return doccounts.CountRules(string(content))
-}
-
-func docReadRefereedCounts(t *testing.T) doccounts.RefereedCounts {
-	t.Helper()
-	counts, err := doccounts.ReadRefereedCounts("../..")
-	if err != nil {
-		t.Fatalf("read refereed baselines: %v", err)
-	}
-	return counts
-}
-
-// docAssertRefereedHeadline checks the five refereed numbers against the baselines
-// they are read from.
-func docAssertRefereedHeadline(t *testing.T, path string, lines []docLine, counts doccounts.RefereedCounts) {
-	t.Helper()
-	checks := []struct {
-		pattern *regexp.Regexp
-		marker  string
-		want    []int
-		labels  []string
-	}{{
-		docCorpusAgreementPattern, "**Corpus agreement:**",
-		[]int{counts.FilesAgreeing, counts.Files, counts.OursOnly, counts.PilotOnly},
-		[]string{"files agreeing", "files compared", "diagnostics only ours", "diagnostics only the pilot's"},
-	}, {
-		docDeclaredSilencePattern, "**Declared-diagnostic silence:**",
-		[]int{counts.DeclaredErrors, counts.Silent, counts.DeclaredAgree, counts.WordingOnly, counts.LocationOnly, counts.SeverityDiffers, counts.Elsewhere},
-		[]string{"declared error rows", "rows we are silent on", "rows agreeing word-for-word", "wording-only rows", "location-only rows", "severity-differs rows", "elsewhere-in-file rows"},
-	}, {
-		docScopeAgreementPattern, "**Scope agreement:**",
-		[]int{counts.ScopeExact, counts.ScopeTotal},
-		[]string{"scope assertions agreeing", "scope assertions"},
-	}, {
-		docPermissivenessPattern, "**Permissiveness gaps:**",
-		[]int{counts.RejectCases, counts.RejectDefaultPilotOnly, counts.RejectDefaultBoth, counts.RejectStrictOnly},
-		[]string{"authored cases", "cases only the pilot rejects by default", "cases both reject by default", "strict-only agreements"},
-	}, {
-		docErrataPattern, "**Declared errata:**",
-		[]int{counts.Errata.Registry, counts.Errata.Corrections, counts.Errata.Documented,
-			counts.Errata.FilesAgreeing, counts.Errata.Files, counts.Errata.OursOnly, counts.Errata.PilotOnly,
-			counts.Errata.Silent, counts.Errata.RejectPilotOnly, counts.Errata.RejectCases},
-		[]string{"registry entries", "corrections", "documented without a correction",
-			"files agreeing with the errata", "files compared with the errata",
-			"diagnostics only ours with the errata", "diagnostics only the pilot's with the errata",
-			"declared rows we are silent on with the errata",
-			"cases only the pilot rejects with the errata", "authored cases with the errata"},
-	}}
-	for _, check := range checks {
-		line := docRequireLineContainingPath(t, lines, path, check.marker)
-		match := check.pattern.FindStringSubmatchIndex(line.text)
-		if match == nil {
-			docFailPathAt(t, path, line.number, "malformed %s line", check.marker)
-		}
-		var consumed []docNumber
-		for i, want := range check.want {
-			text := line.text[match[2+i*2]:match[3+i*2]]
-			got, err := strconv.Atoi(text)
-			if err != nil {
-				docFailPathAt(t, path, line.number, "%s %s: malformed number %q", check.marker, check.labels[i], text)
-			}
-			if got != want {
-				docErrorPathAt(t, path, line.number, "%s %s: want %d (baseline), got %d", check.marker, check.labels[i], want, got)
-			}
-			consumed = append(consumed, docNumbersInRange(line, match[2+i*2], match[3+i*2])...)
-		}
-		docAssertBareNumbersConsumed(t, path, line, consumed, check.marker+" line")
-	}
-}
-
-func docAssertRejectionLine(t *testing.T, line docLine, counts doccounts.RefereedCounts) {
-	t.Helper()
-	match := docRejectionLinePattern.FindStringSubmatchIndex(line.text)
-	if match == nil {
-		docFailPathAt(t, docCountReadmePath, line.number, "malformed Rejection oracle line")
-	}
-	wants := []int{counts.RejectCases, counts.RejectBoth, counts.RejectPilotOnly}
-	labels := []string{"cases", "rejected by both", "only the pilot rejects"}
-	for i, want := range wants {
-		text := line.text[match[2+i*2]:match[3+i*2]]
-		got, err := strconv.Atoi(text)
-		if err != nil {
-			docFailPathAt(t, docCountReadmePath, line.number, "Rejection oracle %s: malformed number %q", labels[i], text)
-		}
-		if got != want {
-			docErrorPathAt(t, docCountReadmePath, line.number, "Rejection oracle %s: want %d (baseline totals), got %d", labels[i], want, got)
-		}
-	}
-}
-
-func docAssertReferenceLine(t *testing.T, line docLine, report Report) {
-	t.Helper()
-	match := docReferencePattern.FindStringSubmatchIndex(line.text)
-	if match == nil {
-		docFailPathAt(t, docCountReadmePath, line.number, "malformed Reference differential line")
-	}
-	consumed := append(
-		docNumbersInRange(line, match[2], match[3]),
-		docNumbersInRange(line, match[4], match[5])...,
-	)
-	consumed = append(consumed, docNumbersInRange(line, match[6], match[7])...)
-
-	gotFiles, err := strconv.Atoi(line.text[match[2]:match[3]])
-	if err != nil {
-		docFailPathAt(t, docCountReadmePath, line.number, "Reference differential files: malformed number %q", line.text[match[2]:match[3]])
-	}
-	if gotFiles != report.Totals.Files {
-		docErrorPathAt(t, docCountReadmePath, line.number, "Reference differential files: want %d (baseline totals.files), got %d", report.Totals.Files, gotFiles)
-	}
-	wantRelease := report.Pilot
-	if before, _, ok := strings.Cut(report.Pilot, " ("); ok {
-		wantRelease = before
-	}
-	gotRelease := line.text[match[4]:match[5]]
-	if gotRelease != wantRelease {
-		docErrorPathAt(t, docCountReadmePath, line.number, "Reference differential release: want %q (baseline pilotRelease), got %q", wantRelease, gotRelease)
-	}
-	gotAgreement, err := strconv.Atoi(line.text[match[6]:match[7]])
-	if err != nil {
-		docFailPathAt(t, docCountReadmePath, line.number, "Reference differential fully agreeing: malformed number %q", line.text[match[6]:match[7]])
-	}
-	if gotAgreement != report.Totals.FilesAgreeing {
-		docErrorPathAt(t, docCountReadmePath, line.number, "Reference differential fully agreeing: want %d (baseline totals.filesFullyAgreeing), got %d", report.Totals.FilesAgreeing, gotAgreement)
-	}
-	docAssertBareNumbersConsumed(t, docCountReadmePath, line, consumed, "Reference differential line")
-}
-
-func docNumbersInRange(line docLine, start, end int) []docNumber {
-	tokens := docBareNumberSpans(line.text[start:end])
-	for i := range tokens {
-		tokens[i].start += start
-		tokens[i].end += start
-		tokens[i].line = line.number
-	}
-	return tokens
-}
-
-func docAssertBareNumbersConsumed(t *testing.T, path string, line docLine, consumed []docNumber, context string) {
-	t.Helper()
-	for _, token := range docBareNumberTokens(line.text, []int{0}, []docLine{line}) {
-		if !docNumberWasConsumed(consumed, token) {
-			docErrorPathAt(t, path, token.line, "unaccounted number %q in %s", token.text, context)
-		}
-	}
-}
-
-func docRequireResultsHeading(t *testing.T, lines []docLine) docLine {
-	t.Helper()
-	for _, line := range lines {
-		if strings.HasPrefix(line.text, "## Results (pilot ") {
-			return line
-		}
-	}
-	docFailAt(t, 1, "missing Results heading")
-	return docLine{}
-}
-
-func docAssertHeadline(t *testing.T, heading docLine, report Report) {
-	t.Helper()
-	match := docHeadlinePattern.FindStringSubmatch(heading.text)
-	if match == nil {
-		docFailAt(t, heading.number, "malformed Results heading")
-	}
-	wantRelease := report.Pilot
-	if before, _, ok := strings.Cut(report.Pilot, " ("); ok {
-		wantRelease = before
-	}
-	if got := match[1]; got != wantRelease {
-		docErrorAt(t, heading.number, "headline release: want %q (baseline pilotRelease), got %q", wantRelease, got)
-	}
-	gotFiles, err := strconv.Atoi(match[2])
-	if err != nil {
-		docFailAt(t, heading.number, "headline files: malformed number %q", match[2])
-	}
-	if gotFiles != report.Totals.Files {
-		docErrorAt(t, heading.number, "headline files: want %d (baseline totals.files), got %d", report.Totals.Files, gotFiles)
-	}
-}
-
-func docRequireLineContaining(t *testing.T, lines []docLine, marker string) docLine {
-	t.Helper()
-	return docRequireLineContainingPath(t, lines, docCountPath, marker)
-}
-
-func docRequireLineContainingPath(t *testing.T, lines []docLine, path, marker string) docLine {
-	t.Helper()
-	for _, line := range lines {
-		if strings.Contains(line.text, marker) {
-			return line
-		}
-	}
-	docFailPathAt(t, path, 1, "missing required section marker %q", marker)
-	return docLine{}
-}
-
-func docRequireTable(t *testing.T, lines []docLine, from int, firstHeader string) docTable {
-	t.Helper()
-	for i := from - 1; i < len(lines); i++ {
-		if !docIsTableLine(lines[i].text) {
-			continue
-		}
-		header := docSplitTableCells(lines[i].text)
-		if firstHeader != "" && (len(header) == 0 || header[0] != firstHeader) {
-			continue
-		}
-		rows := make([]docTableRow, 0)
-		for j := i + 1; j < len(lines) && docIsTableLine(lines[j].text); j++ {
-			rows = append(rows, docTableRow{line: lines[j].number, cells: docSplitTableCells(lines[j].text)})
-		}
-		return docTable{header: header, headerLine: lines[i].number, rows: rows}
-	}
-	if firstHeader == "" {
-		docFailAt(t, from, "missing Results table")
-	} else {
-		docFailAt(t, from, "missing movement table with first header %q", firstHeader)
-	}
-	return docTable{}
-}
-
-func docIsTableLine(line string) bool {
-	return strings.HasPrefix(strings.TrimSpace(line), "|")
-}
-
-func docSplitTableCells(line string) []string {
-	line = strings.TrimSpace(line)
-	line = strings.TrimPrefix(line, "|")
-	line = strings.TrimSuffix(line, "|")
-	parts := strings.Split(line, "|")
-	for i := range parts {
-		parts[i] = strings.TrimSpace(parts[i])
-	}
-	return parts
-}
-
-func docIsSeparatorRow(cells []string) bool {
-	if len(cells) == 0 {
-		return false
-	}
-	for _, cell := range cells {
-		cell = strings.TrimSpace(cell)
-		if len(cell) < 3 || strings.Trim(cell, ":-") != "" {
-			return false
-		}
-	}
-	return true
-}
-
-func docAssertResultsTable(t *testing.T, results docTable, report Report) {
-	t.Helper()
-	wantHeader := []string{"Root", "Files", "Fully agreeing", "Ours", "Pilot", "Agreed", "Severity-only", "Only ours", "Only pilot"}
-	if len(results.header) != len(wantHeader) {
-		docFailAt(t, results.headerLine, "Results table header: want %q, got %q", wantHeader, results.header)
-	}
-	for i := range wantHeader {
-		if results.header[i] != wantHeader[i] {
-			docFailAt(t, results.headerLine, "Results table header column %d: want %q, got %q", i+1, wantHeader[i], results.header[i])
-		}
-	}
-
-	rootByDir := make(map[string]RootReport, len(report.Roots))
-	for _, root := range report.Roots {
-		rootByDir[root.Dir] = root
-	}
-	foundDirs := make(map[string]bool)
-	totalSeen := false
-	for _, row := range results.rows {
-		if docIsSeparatorRow(row.cells) {
-			continue
-		}
-		if len(row.cells) != len(wantHeader) {
-			docFailAt(t, row.line, "Results table row has %d cells, want %d", len(row.cells), len(wantHeader))
-		}
-		label := docStripMarkdown(row.cells[0])
-		label = docParentheticalPattern.ReplaceAllString(label, "")
-		if label == "Total" {
-			if totalSeen {
-				docFailAt(t, row.line, "Results table total row appears more than once")
-			}
-			totalSeen = true
-			docAssertTotalsRow(t, row, report.Totals)
-			continue
-		}
-		root, ok := rootByDir[label]
-		if !ok {
-			docFailAt(t, row.line, "Results table root %q is not in baseline roots", label)
-		}
-		if foundDirs[label] {
-			docFailAt(t, row.line, "Results table root %q appears more than once", label)
-		}
-		foundDirs[label] = true
-		docAssertTotalsCells(t, row, root.Totals, "roots["+root.Name+"].totals", "root "+root.Dir)
-	}
-	if !totalSeen {
-		docFailAt(t, results.headerLine, "Results table is missing the Total row")
-	}
-	wantDirs := make([]string, 0, len(report.Roots))
-	for _, root := range report.Roots {
-		wantDirs = append(wantDirs, root.Dir)
-	}
-	gotDirs := make([]string, 0, len(foundDirs))
-	for dir := range foundDirs {
-		gotDirs = append(gotDirs, dir)
-	}
-	sort.Strings(wantDirs)
-	sort.Strings(gotDirs)
-	if strings.Join(wantDirs, "\x00") != strings.Join(gotDirs, "\x00") {
-		docErrorAt(t, results.headerLine, "Results table root set: want %q (baseline roots[].dir), got %q", wantDirs, gotDirs)
-	}
-}
-
-func docAssertTotalsRow(t *testing.T, row docTableRow, totals Totals) {
-	t.Helper()
-	docAssertTotalsCells(t, row, totals, "totals", "root Total")
-}
-
-func docAssertTotalsCells(t *testing.T, row docTableRow, totals Totals, jsonPrefix, subject string) {
-	t.Helper()
-	columns := []struct {
-		header string
-		want   int
-		field  string
-	}{
-		{"Files", totals.Files, "files"},
-		{"Fully agreeing", totals.FilesAgreeing, "filesFullyAgreeing"},
-		{"Ours", totals.OpenSysMLTotal, "openSysMLDiagnostics"},
-		{"Pilot", totals.PilotTotal, "pilotDiagnostics"},
-		{"Agreed", totals.Agreement, "agreement"},
-		{"Severity-only", totals.SeverityMismatch, "severityMismatch"},
-		{"Only ours", totals.OpenSysMLOnly, "openSysMLOnly"},
-		{"Only pilot", totals.PilotOnly, "pilotOnly"},
-	}
-	for i, column := range columns {
-		got := docParseCellInteger(t, row, i+1, column.header)
-		if got != column.want {
-			docErrorAt(t, row.line, `%s column %q: want %d (baseline %s.%s), got %d`, subject, column.header, column.want, jsonPrefix, column.field, got)
-		}
-	}
-}
-
-func docParseCellInteger(t *testing.T, row docTableRow, cell int, label string) int {
-	t.Helper()
-	value := docStripMarkdown(row.cells[cell])
-	if !docIntegerPattern.MatchString(value) {
-		docFailAt(t, row.line, "column %q: expected integer, got %q", label, value)
-	}
-	got, err := strconv.Atoi(value)
-	if err != nil {
-		docFailAt(t, row.line, "column %q: malformed integer %q", label, value)
-	}
-	return got
-}
-
-func docStripMarkdown(value string) string {
-	value = strings.ReplaceAll(value, "**", "")
-	value = strings.ReplaceAll(value, "`", "")
-	return strings.TrimSpace(value)
-}
-
-func docAssertCategoryProse(t *testing.T, lines []docLine, start docLine, report Report) {
-	t.Helper()
-	paragraphLines := []docLine{start}
-	for i := start.number; i < len(lines) && strings.TrimSpace(lines[i].text) != ""; i++ {
-		paragraphLines = append(paragraphLines, lines[i])
-	}
-	text := make([]string, len(paragraphLines))
-	for i, line := range paragraphLines {
-		text[i] = line.text
-	}
-	paragraph := strings.Join(text, "\n")
-	onlyOursMarker := "only-ours totals are:"
-	onlyPilotMarker := "Only-pilot:"
-	oursAt := strings.Index(paragraph, onlyOursMarker)
-	pilotAt := strings.Index(paragraph, onlyPilotMarker)
-	if oursAt < 0 {
-		docFailAt(t, start.number, "category paragraph is missing marker %q", onlyOursMarker)
-	}
-	if pilotAt < 0 {
-		docFailAt(t, start.number, "category paragraph is missing marker %q", onlyPilotMarker)
-	}
-	if pilotAt <= oursAt+len(onlyOursMarker) {
-		docFailAt(t, start.number, "category paragraph markers are out of order")
-	}
-
-	parse := &docCategoryProse{
-		t:          t,
-		paragraph:  paragraph,
-		starts:     docParagraphLineStarts(paragraph),
-		lines:      paragraphLines,
-		report:     report,
-		prose:      map[string]map[string]map[Category]int{},
-		proseLines: map[string]map[string]int{},
-	}
-	parse.parsePart(oursAt+len(onlyOursMarker), pilotAt, "only-ours")
-	parse.parsePart(pilotAt+len(onlyPilotMarker), len(paragraph), "only-pilot")
-
-	docAssertCategoryMaps(t, report, parse.prose, parse.proseLines)
-	for _, token := range docBareNumberTokens(paragraph, parse.starts, paragraphLines) {
-		if !docNumberWasConsumed(parse.consumed, token) {
-			docErrorAt(t, token.line, "unaccounted number %q in category paragraph", token.text)
-		}
-	}
-}
-
-// docCategoryProse reads the category paragraph into per-direction, per-root
-// category counts, recording the numbers it consumed so a leftover one can be
-// reported.
-type docCategoryProse struct {
-	t          *testing.T
-	paragraph  string
-	starts     []int
-	lines      []docLine
-	report     Report
-	prose      map[string]map[string]map[Category]int
-	proseLines map[string]map[string]int
-	consumed   []docNumber
-}
-
-// parsePart reads the semicolon-separated segments of one direction's half of
-// the paragraph.
-func (p *docCategoryProse) parsePart(from, to int, direction string) {
-	p.t.Helper()
-	paragraph := p.paragraph
-	part := paragraph[from:to]
-	partOffset := from
-	for len(part) > 0 {
-		semicolon := strings.IndexByte(part, ';')
-		rawSegment := part
-		if semicolon >= 0 {
-			rawSegment = part[:semicolon]
-		}
-		leading := len(rawSegment) - len(strings.TrimLeft(rawSegment, " \t\n"))
-		segmentStart := partOffset + leading
-		segment := strings.TrimSpace(rawSegment)
-		if segment == "" {
-			if semicolon < 0 {
-				break
-			}
-			partOffset += semicolon + 1
-			part = paragraph[partOffset:to]
-			continue
-		}
-		p.parseSegment(segment, segmentStart, direction)
-		if semicolon < 0 {
-			break
-		}
-		partOffset += semicolon + 1
-		part = paragraph[partOffset:to]
-	}
-}
-
-// parseSegment reads one root's counts within a direction's half.
-func (p *docCategoryProse) parseSegment(segment string, segmentStart int, direction string) {
-	t, starts, lines := p.t, p.starts, p.lines
-	report, prose, proseLines := p.report, p.prose, p.proseLines
-	t.Helper()
-	rootMatch := docRootPattern.FindStringSubmatchIndex(segment)
-	if rootMatch == nil {
-		if tokens := docBareNumberSpans(segment); len(tokens) > 0 {
-			token := tokens[0]
-			docErrorAt(t, docLineAt(starts, lines, segmentStart+token.start), "unaccounted number %q in category paragraph", token.text)
-		}
-		docFailAt(t, docLineAt(starts, lines, segmentStart), "category %s segment must start with a backticked root name: %q", direction, segment)
-	}
-	rootName := segment[rootMatch[2]:rootMatch[3]]
-	root, ok := docRootByName(report, rootName)
-	if !ok {
-		docFailAt(t, docLineAt(starts, lines, segmentStart+rootMatch[2]), "category %s names unknown root %q", direction, rootName)
-	}
-	restStart := segmentStart + rootMatch[4]
-	rest := segment[rootMatch[4]:rootMatch[5]]
-	if prose[direction] == nil {
-		prose[direction] = map[string]map[Category]int{}
-		proseLines[direction] = map[string]int{}
-	}
-	if prose[direction][rootName] == nil {
-		prose[direction][rootName] = map[Category]int{}
-	}
-	if _, exists := proseLines[direction][rootName]; !exists {
-		proseLines[direction][rootName] = docLineAt(starts, lines, segmentStart+rootMatch[2])
-	}
-
-	knownCategories := docReportCategories(report)
-	parsed := 0
-	for {
-		leading := len(rest) - len(strings.TrimLeft(rest, " \t\n"))
-		rest = rest[leading:]
-		restStart += leading
-		match := docCategoryItemPattern.FindStringSubmatchIndex(rest)
-		if match == nil {
-			if parsed == 0 {
-				docFailAt(t, docLineAt(starts, lines, restStart), "category %s root %q has no count/category items", direction, root.Name)
-			}
-			break
-		}
-		categoryText := strings.Trim(rest[match[4]:match[5]], "`")
-		category := Category(categoryText)
-		if !knownCategories[category] {
-			docFailAt(t, docLineAt(starts, lines, restStart+match[4]), "category %s root %q names unknown category %q", direction, root.Name, categoryText)
-		}
-		count, err := strconv.Atoi(rest[match[2]:match[3]])
-		if err != nil {
-			docFailAt(t, docLineAt(starts, lines, restStart), "category %s root %q has malformed count", direction, root.Name)
-		}
-		numberStart := restStart + match[2]
-		numberEnd := restStart + match[3]
-		boundary := docNextCategoryPattern.FindStringIndex(rest[match[1]:])
-		tailEnd := len(rest)
-		if boundary != nil {
-			tailEnd = match[1] + boundary[0]
-		}
-		tail := rest[match[1]:tailEnd]
-		if tokens := docBareNumberSpans(tail); len(tokens) > 0 {
-			token := tokens[0]
-			docErrorAt(t, docLineAt(starts, lines, restStart+match[1]+token.start), "unaccounted number %q in %s %s tail", token.text, direction, root.Name)
-		}
-		prose[direction][rootName][category] += count
-		p.consumed = append(p.consumed, docNumber{start: numberStart, end: numberEnd, text: rest[match[2]:match[3]], line: docLineAt(starts, lines, numberStart)})
-		parsed++
-		if boundary == nil {
-			break
-		}
-		nextStart := match[1] + boundary[0] + 1
-		restStart += nextStart
-		rest = rest[nextStart:]
-	}
-}
-
-func docAssertCategoryMaps(t *testing.T, report Report, prose map[string]map[string]map[Category]int, proseLines map[string]map[string]int) {
-	t.Helper()
-	for _, direction := range []string{"only-ours", "only-pilot"} {
-		for rootName, categories := range prose[direction] {
-			root, ok := docRootByName(report, rootName)
-			if !ok {
-				docFailAt(t, proseLines[direction][rootName], "root %s %s is not present in baseline roots", rootName, direction)
-			}
-			for category, got := range categories {
-				want := docCategoryTotal(root, direction, category)
-				if want == 0 || got != want {
-					docErrorAt(t, proseLines[direction][rootName], "root %s %s category %q: want %d (baseline roots[%s].files[].%s[].count), got %d", root.Name, direction, category, want, root.Name, docDirectionJSONField(direction), got)
-				}
-			}
-		}
-		for _, root := range report.Roots {
-			for category, want := range docCategoryTotals(root, direction) {
-				if want == 0 {
-					continue
-				}
-				if _, present := prose[direction][root.Name][category]; present {
-					continue
-				}
-				got := 0
-				line := proseLines[direction][root.Name]
-				if line == 0 {
-					line = 1
-				}
-				docErrorAt(t, line, "root %s %s category %q: want %d (baseline roots[%s].files[].%s[].count), got %d (missing from prose)", root.Name, direction, category, want, root.Name, docDirectionJSONField(direction), got)
-			}
-		}
-	}
-}
-
-func docRootByName(report Report, name string) (RootReport, bool) {
-	for _, root := range report.Roots {
-		if root.Name == name {
-			return root, true
-		}
-	}
-	return RootReport{}, false
-}
-
-func docReportCategories(report Report) map[Category]bool {
-	categories := map[Category]bool{}
-	for _, root := range report.Roots {
-		for _, file := range root.Files {
-			for _, entry := range file.OpenSysMLOnly {
-				categories[entry.Category] = true
-			}
-			for _, entry := range file.PilotOnly {
-				categories[entry.Category] = true
-			}
-		}
-	}
-	return categories
-}
-
-func docCategoryTotals(root RootReport, direction string) map[Category]int {
-	totals := map[Category]int{}
-	for _, file := range root.Files {
-		var entries []Entry
-		if direction == "only-ours" {
-			entries = file.OpenSysMLOnly
-		} else {
-			entries = file.PilotOnly
-		}
-		for _, entry := range entries {
-			totals[entry.Category] += entry.Count
-		}
-	}
-	return totals
-}
-
-func docCategoryTotal(root RootReport, direction string, category Category) int {
-	return docCategoryTotals(root, direction)[category]
-}
-
-func docDirectionJSONField(direction string) string {
-	if direction == "only-ours" {
-		return "openSysMLOnly"
-	}
-	return "pilotOnly"
-}
-
-func docParagraphLineStarts(paragraph string) []int {
-	starts := []int{0}
-	for i, char := range paragraph {
-		if char == '\n' {
-			starts = append(starts, i+1)
-		}
-	}
-	return starts
-}
-
-func docLineAt(starts []int, lines []docLine, offset int) int {
-	line := 0
-	for i, start := range starts {
-		if start > offset {
-			break
-		}
-		line = i
-	}
-	return lines[line].number
-}
-
-func docBareNumberSpans(text string) []docNumber {
-	var tokens []docNumber
-	for i := 0; i < len(text); {
-		if text[i] < '0' || text[i] > '9' || (i > 0 && docIsWordBefore(text, i)) {
-			i++
-			continue
-		}
-		j := i + 1
-		for j < len(text) && text[j] >= '0' && text[j] <= '9' {
-			j++
-		}
-		if j == len(text) || !docIsWordAt(text, j) {
-			tokens = append(tokens, docNumber{start: i, end: j, text: text[i:j]})
-		}
-		i = j
-	}
-	return tokens
-}
-
-func docBareNumberTokens(text string, starts []int, lines []docLine) []docNumber {
-	tokens := docBareNumberSpans(text)
-	for i := range tokens {
-		tokens[i].line = docLineAt(starts, lines, tokens[i].start)
-	}
-	return tokens
-}
-
-func docNumberWasConsumed(consumed []docNumber, token docNumber) bool {
-	for _, item := range consumed {
-		if item.start == token.start && item.end == token.end {
-			return true
-		}
-	}
-	return false
-}
-
-func docIsWordBefore(text string, offset int) bool {
-	_, size := utf8.DecodeLastRuneInString(text[:offset])
-	r, _ := utf8.DecodeLastRuneInString(text[offset-size : offset])
-	return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
-}
-
-func docIsWordAt(text string, offset int) bool {
-	r, _ := utf8.DecodeRuneInString(text[offset:])
-	return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
-}
-
-func docAssertMovementTable(t *testing.T, movement docTable, report Report) {
-	t.Helper()
-	now := -1
-	for i, header := range movement.header {
-		if header == "Now" {
-			now = i
-			break
-		}
-	}
-	if now < 0 {
-		docFailAt(t, movement.headerLine, "movement table is missing a Now column")
-	}
-	if len(movement.header) == 0 || movement.header[0] != "Count" {
-		docFailAt(t, movement.headerLine, "movement table first header: want %q, got %q", "Count", movement.header)
-	}
-	seen := map[string]bool{}
-	for _, row := range movement.rows {
-		if docIsSeparatorRow(row.cells) {
-			continue
-		}
-		if len(row.cells) <= now {
-			docFailAt(t, row.line, "movement table row has %d cells, missing Now column", len(row.cells))
-		}
-		label := docStripMarkdown(row.cells[0])
-		if seen[label] {
-			docFailAt(t, row.line, "movement table row %q appears more than once", label)
-		}
-		seen[label] = true
-		if docMovementRowsUnchecked[label] {
-			continue
-		}
-		if label == "overall: fully agreeing / only ours / our diagnostics" {
-			values := strings.Split(docStripMarkdown(row.cells[now]), "/")
-			if len(values) != 3 {
-				docFailAt(t, row.line, "movement row %q column %q: want three slash-separated counts, got %q", label, movement.header[now], row.cells[now])
-			}
-			wants := []struct {
-				value int
-				path  string
-			}{
-				{report.Totals.FilesAgreeing, "totals.filesFullyAgreeing"},
-				{report.Totals.OpenSysMLOnly, "totals.openSysMLOnly"},
-				{report.Totals.OpenSysMLTotal, "totals.openSysMLDiagnostics"},
-			}
-			for i, want := range wants {
-				got := docParseMovementInteger(t, row.line, strings.TrimSpace(values[i]), movement.header[now])
-				if got != want.value {
-					docErrorAt(t, row.line, "movement row %q item %d column %q: want %d (baseline %s), got %d", label, i+1, movement.header[now], want.value, want.path, got)
-				}
-			}
-			continue
-		}
-		want, jsonPath, ok := docMovementValue(report, label)
-		if !ok {
-			docFailAt(t, row.line, "movement table row %q is not mapped or allowlisted", label)
-		}
-		got := docParseCellInteger(t, docTableRow{line: row.line, cells: row.cells}, now, "Now")
-		if got != want {
-			docErrorAt(t, row.line, "movement row %q column %q: want %d (baseline %s), got %d", label, movement.header[now], want, jsonPath, got)
-		}
-	}
-}
-
-func docParseMovementInteger(t *testing.T, line int, value, column string) int {
-	t.Helper()
-	if !docIntegerPattern.MatchString(value) {
-		docFailAt(t, line, "column %q: expected integer, got %q", column, value)
-	}
-	got, err := strconv.Atoi(value)
-	if err != nil {
-		docFailAt(t, line, "column %q: malformed integer %q", column, value)
-	}
-	return got
-}
-
-func docMovementValue(report Report, label string) (int, string, bool) {
-	switch label {
-	case "only pilot":
-		return report.Totals.PilotOnly, "totals.pilotOnly", true
-	case "pilot diagnostics":
-		return report.Totals.PilotTotal, "totals.pilotDiagnostics", true
-	case "severity-only":
-		return report.Totals.SeverityMismatch, "totals.severityMismatch", true
-	case "unmapped, our side":
-		total := 0
-		for _, row := range report.Unmapped {
-			if row.Side == "opensysml" {
-				total += row.Count
-			}
-		}
-		return total, "unmapped[side=opensysml].count", true
-	}
-	for _, root := range report.Roots {
-		prefix := root.Name + ": "
-		if !strings.HasPrefix(label, prefix) {
-			continue
-		}
-		switch strings.TrimPrefix(label, prefix) {
-		case "only ours":
-			return root.Totals.OpenSysMLOnly, "roots[" + root.Name + "].totals.openSysMLOnly", true
-		case "only pilot":
-			return root.Totals.PilotOnly, "roots[" + root.Name + "].totals.pilotOnly", true
-		case "fully agreeing":
-			return root.Totals.FilesAgreeing, "roots[" + root.Name + "].totals.filesFullyAgreeing", true
-		}
-	}
-	return 0, "", false
-}
-
-func docFailAt(t *testing.T, line int, format string, args ...any) {
-	t.Helper()
-	docFailPathAt(t, docCountPath, line, format, args...)
-}
-
-func docErrorAt(t *testing.T, line int, format string, args ...any) {
-	t.Helper()
-	docErrorPathAt(t, docCountPath, line, format, args...)
-}
-
-func docFailPathAt(t *testing.T, path string, line int, format string, args ...any) {
-	t.Helper()
-	t.Fatalf("%s:%d: %s", path, line, fmt.Sprintf(format, args...))
-}
-
-func docErrorPathAt(t *testing.T, path string, line int, format string, args ...any) {
-	t.Helper()
-	t.Errorf("%s:%d: %s", path, line, fmt.Sprintf(format, args...))
-}
diff --git a/cmd/pilot-diff/errata.go b/cmd/pilot-diff/errata.go
deleted file mode 100644
index 0cf7bea2ac..0000000000
--- a/cmd/pilot-diff/errata.go
+++ /dev/null
@@ -1,197 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-
-	"github.com/Open-MBEE/OpenSysML/internal/core/source"
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// ErrataReport is the second figure of the run: the same comparison over the
-// corpus with the declared corrections applied. The published corpus is never
-// written to; the corrected text lives in a copy under the output directory.
-type ErrataReport struct {
-	// Registry and Corrections count the declared entries and the substituting
-	// ones; Documented are the defects recorded without a correction.
-	Registry    int `json:"registryEntries"`
-	Corrections int `json:"corrections"`
-	Documented  int `json:"documentedWithoutCorrection"`
-	// Applied are the corrections that lie inside this run's corpora.
-	Applied int           `json:"correctionsApplied"`
-	Entries []ErrataEntry `json:"entries"`
-	// Totals is the whole run with the errata applied; Report.Totals stays the
-	// as-published headline.
-	Totals   Totals          `json:"totals"`
-	Findings []ErrataFinding `json:"findings"`
-}
-
-// ErrataEntry is one declared entry with the provenance the report must carry.
-type ErrataEntry struct {
-	ID         string `json:"id"`
-	Path       string `json:"path"`
-	Line       int    `json:"line"`
-	Citation   string `json:"citation"`
-	Corrected  bool   `json:"corrected"`
-	Derivation string `json:"derivation"`
-}
-
-// ErrataFinding is what a correction did to the two implementations at its own
-// line. A correction that clears our diagnostic while the pilot still reports
-// there is a finding, not a fix, so it is stated rather than folded into totals.
-type ErrataFinding struct {
-	ID              string `json:"id"`
-	Root            string `json:"root"`
-	Path            string `json:"path"`
-	Line            int    `json:"line"`
-	OursPublished   int    `json:"openSysMLAsPublished"`
-	OursCorrected   int    `json:"openSysMLWithErrata"`
-	PilotPublished  int    `json:"pilotAsPublished"`
-	PilotCorrected  int    `json:"pilotWithErrata"`
-	PilotVerdictNew bool   `json:"pilotVerdictChanged"`
-	Note            string `json:"note"`
-}
-
-func newErrataReport(overlay *errata.Overlay) *ErrataReport {
-	report := &ErrataReport{
-		Registry:    len(overlay.Entries()),
-		Corrections: len(overlay.Corrections()),
-		Documented:  len(overlay.Documented()),
-	}
-	for _, entry := range overlay.Entries() {
-		report.Entries = append(report.Entries, ErrataEntry{
-			ID:         entry.ID,
-			Path:       entry.Path,
-			Line:       entry.Line,
-			Citation:   entry.Citation,
-			Corrected:  entry.Corrects(),
-			Derivation: entry.Derivation,
-		})
-	}
-	return report
-}
-
-// erratumRun is one root compared a second time with its corrections applied.
-type erratumRun struct {
-	totals   Totals
-	findings []ErrataFinding
-	applied  int
-}
-
-// runErrata compares one root again over a corrected copy of it. It returns the
-// zero run when no correction lies inside the root, in which case the caller
-// carries the as-published totals over unchanged.
-func runErrata(root corpusRoot, files []string, overlay *errata.Overlay, ours, theirs map[string][]diagnostic,
-	opts options) (erratumRun, error) {
-	applied := coveredBy(overlay.Under(root.Dir), files)
-	if len(applied) == 0 {
-		return erratumRun{}, nil
-	}
-	corrected := filepath.Join(opts.out, "errata-corpora", root.Name)
-	if _, err := overlay.Materialize(opts.repo, root.Dir, corrected); err != nil {
-		return erratumRun{}, err
-	}
-	defer func() {
-		if err := os.RemoveAll(corrected); err != nil {
-			fmt.Fprintf(os.Stderr, "remove the corrected copy: %v\n", err)
-		}
-		// leaves nothing behind once the last root's copy is gone
-		_ = os.Remove(filepath.Dir(corrected))
-	}()
-
-	erratumOurs := make(map[string][]diagnostic, len(files))
-	erratumTheirs := make(map[string][]diagnostic, len(files))
-	for _, batch := range batchByLanguage(files) {
-		fmt.Fprintf(os.Stderr, "%s: %d %s file(s) with the errata applied\n", root.Name, len(batch.Files), batch.Kind)
-		pilot := opts.validator
-		if batch.Kind == source.KindKerML {
-			pilot = opts.kermlValidator
-		}
-		batchOurs, err := openSysMLDiagnostics(corrected, ".", batch.Files)
-		if err != nil {
-			return erratumRun{}, err
-		}
-		batchTheirs, err := pilotDiagnostics(pilot, corrected, ".", batch.Files, opts.timeout)
-		if err != nil {
-			return erratumRun{}, err
-		}
-		for rel, diagnostics := range batchOurs {
-			erratumOurs[rel] = diagnostics
-		}
-		for rel, diagnostics := range batchTheirs {
-			erratumTheirs[rel] = diagnostics
-		}
-	}
-
-	run := erratumRun{
-		totals:  compareRoot(root.Name, root.Dir, files, erratumOurs, erratumTheirs).Totals,
-		applied: len(applied),
-	}
-	for _, rel := range sortedPaths(applied) {
-		entry := applied[rel]
-		run.findings = append(run.findings, finding(entry, root.Name, rel, ours, theirs, erratumOurs, erratumTheirs))
-	}
-	return run, nil
-}
-
-// coveredBy keeps the entries whose file this root actually walks: a root's
-// directory can contain a nested corpus root the walker skips.
-func coveredBy(applied map[string]errata.Entry, files []string) map[string]errata.Entry {
-	walked := make(map[string]bool, len(files))
-	for _, rel := range files {
-		walked[rel] = true
-	}
-	out := make(map[string]errata.Entry, len(applied))
-	for rel, entry := range applied {
-		if walked[rel] {
-			out[rel] = entry
-		}
-	}
-	return out
-}
-
-func sortedPaths(applied map[string]errata.Entry) []string {
-	paths := make([]string, 0, len(applied))
-	for rel := range applied {
-		paths = append(paths, rel)
-	}
-	sort.Strings(paths)
-	return paths
-}
-
-// finding states what the correction changed at its own line on both sides.
-func finding(entry errata.Entry, rootName, rel string, ours, theirs, erratumOurs, erratumTheirs map[string][]diagnostic) ErrataFinding {
-	f := ErrataFinding{
-		ID: entry.ID, Root: rootName, Path: entry.Path, Line: entry.Line,
-		OursPublished:  atLine(ours[rel], entry.Line),
-		OursCorrected:  atLine(erratumOurs[rel], entry.Line),
-		PilotPublished: atLine(theirs[rel], entry.Line),
-		PilotCorrected: atLine(erratumTheirs[rel], entry.Line),
-	}
-	f.PilotVerdictNew = f.PilotPublished != f.PilotCorrected
-	switch {
-	case f.OursCorrected > 0:
-		f.Note = "our diagnostic survives the correction"
-	case f.PilotCorrected > 0:
-		f.Note = "our diagnostic is cleared while the pilot still reports here: a finding, not a fix"
-	case f.PilotPublished > 0:
-		f.Note = "both diagnostics are cleared: the correction changes the pilot's verdict too"
-	case f.OursPublished == 0:
-		f.Note = "neither implementation reported here as published"
-	default:
-		f.Note = "our diagnostic is cleared and the pilot is silent on both texts"
-	}
-	return f
-}
-
-func atLine(diagnostics []diagnostic, line int) int {
-	count := 0
-	for _, d := range diagnostics {
-		if d.Line == line {
-			count++
-		}
-	}
-	return count
-}
diff --git a/cmd/pilot-diff/junit.go b/cmd/pilot-diff/junit.go
deleted file mode 100644
index 149a2885c3..0000000000
--- a/cmd/pilot-diff/junit.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"strings"
-
-	"github.com/Open-MBEE/OpenSysML/internal/junit"
-)
-
-// junitReport renders the comparison as JUnit XML: one suite per corpus root,
-// one case per file that drew a diagnostic on either side. A file disagreeing
-// in any bucket fails; files silent on both sides are omitted.
-func junitReport(report *Report) *junit.Testsuites {
-	doc := &junit.Testsuites{Name: "pilot-diff"}
-	for _, root := range report.Roots {
-		suite := junit.Testsuite{Name: root.Name}
-		for _, file := range root.Files {
-			suite.AddCase(junitFileCase(root, file))
-		}
-		doc.AddSuite(suite)
-	}
-	return doc
-}
-
-func junitFileCase(root RootReport, file FileReport) junit.Testcase {
-	c := junit.Testcase{
-		Name:      file.Path,
-		Classname: root.Name,
-		File:      root.Dir + "/" + file.Path,
-	}
-	var rows []string
-	for _, e := range file.SeverityMismatch {
-		rows = append(rows, fmt.Sprintf("line %d %s: ours %s, pilot %s (x%d)",
-			e.Line, e.Category, e.OpenSysML, e.Pilot, e.Count))
-	}
-	for _, e := range file.OpenSysMLOnly {
-		rows = append(rows, fmt.Sprintf("line %d %s %s: only OpenSysML (x%d)",
-			e.Line, e.Severity, e.Category, e.Count))
-	}
-	for _, e := range file.PilotOnly {
-		rows = append(rows, fmt.Sprintf("line %d %s %s: only the pilot (x%d)",
-			e.Line, e.Severity, e.Category, e.Count))
-	}
-	if len(rows) > 0 {
-		c.Failure = &junit.Message{
-			Text: fmt.Sprintf("%d disagreeing diagnostic group(s)", len(rows)),
-			Body: strings.Join(rows, "\n"),
-		}
-	}
-	return c
-}
diff --git a/cmd/pilot-diff/main.go b/cmd/pilot-diff/main.go
deleted file mode 100644
index e6bac02051..0000000000
--- a/cmd/pilot-diff/main.go
+++ /dev/null
@@ -1,337 +0,0 @@
-// Command pilot-diff compares this implementation's diagnostics against the
-// OMG SysML v2 Pilot Implementation over a corpus of models, and reports, per
-// file, the diagnostics both agree on, the ones only we report (candidate false
-// positives) and the ones only the pilot reports (candidate gaps).
-//
-// It is advisory: nothing in the build or the test suite depends on it, and it
-// never touches internal/core/model/testdata/training_examples_expected.txt.
-// Provision the reference validator with scripts/download-pilot-sysml-validator.sh,
-// then run `go run ./cmd/pilot-diff`. See docs/project/pilot-differential.md.
-package main
-
-import (
-	"flag"
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-	"time"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-	"github.com/Open-MBEE/OpenSysML/internal/core/source"
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// corpusRoot is one directory of models. Each language in it is compared as its
-// own single batch: every file of that language is loaded into one resource set
-// before any diagnostic is read, on both sides, because corpus files import
-// each other.
-type corpusRoot struct {
-	Name string
-	Dir  string
-	// Skip lists sub-paths of Dir (slash-separated, relative to Dir) that
-	// belong to another root.
-	Skip []string
-	// Pinned marks a root provisioned from the pilot pin rather than one this
-	// repository owns: the two call for different actions when they move.
-	Pinned bool
-}
-
-// languageBatch is one root's files in a single language, in comparison order.
-type languageBatch struct {
-	Kind  source.Kind
-	Files []string
-}
-
-var defaultRoots = []corpusRoot{
-	{Name: "training", Dir: "examples/sysml-v2-training", Pinned: true},
-	{Name: "pilot-examples", Dir: "examples/pilot-corpora/sysml-examples", Pinned: true},
-	{Name: "pilot-validation", Dir: "examples/pilot-corpora/sysml-validation", Pinned: true},
-	{Name: "kerml-examples", Dir: "examples/pilot-corpora/kerml-examples", Pinned: true},
-	{Name: "testdata", Dir: "testdata"},
-	{Name: "examples", Dir: "examples", Skip: []string{"sysml-v2-training", "pilot-corpora"}},
-	// Hand-written models for behaviour classes the corpora do not cover, such
-	// as redefining a feature inherited through an alias.
-	{Name: "probes", Dir: "cmd/pilot-diff/testdata"},
-}
-
-func main() {
-	repo := flag.String("repo", "", "repository root (default: the module root containing this command)")
-	validator := flag.String("validator", "", "pilot SysML validator executable (default: <repo>/build/pilot-sysml-validator/validate-sysml-batch)")
-	kermlValidator := flag.String("kerml-validator", "", "KerML pilot validator executable (default: <repo>/build/pilot-kerml-validator/validate-kerml)")
-	syside := flag.String("syside", "", "optional Sensmetry SysIDE launcher for a third column (default: <repo>/build/syside/validate-syside if present)")
-	out := flag.String("out", "", "output directory for the reports (default: <repo>/build/pilot-diff)")
-	timeout := flag.Duration("timeout", 0, "per-batch timeout for the pilot validator (0: no limit)")
-	update := flag.Bool("update", false, "record this run as "+committedBaseline)
-	check := flag.Bool("check", false, "fail unless this run reproduces "+committedBaseline)
-	flag.Parse()
-
-	opts := options{
-		repo:           *repo,
-		validator:      *validator,
-		kermlValidator: *kermlValidator,
-		syside:         *syside,
-		out:            *out,
-		timeout:        *timeout,
-		update:         *update,
-		check:          *check,
-	}
-	if err := run(opts); err != nil {
-		fmt.Fprintf(os.Stderr, "pilot-diff: %v\n", err)
-		os.Exit(1)
-	}
-}
-
-// options is one run's command line, with the paths resolved by resolve.
-type options struct {
-	repo           string
-	validator      string
-	kermlValidator string
-	syside         string
-	out            string
-	timeout        time.Duration
-	update         bool
-	check          bool
-}
-
-// resolve fills the paths left empty on the command line and reports the tools
-// that are missing: the pilot validator is required, SysIDE only when named.
-func (o *options) resolve() error {
-	var err error
-	if o.repo == "" {
-		if o.repo, err = moduleRoot(); err != nil {
-			return err
-		}
-	}
-	if o.validator == "" {
-		o.validator = filepath.Join(o.repo, "build", "pilot-sysml-validator", "validate-sysml-batch")
-	}
-	if o.kermlValidator == "" {
-		o.kermlValidator = filepath.Join(o.repo, "build", "pilot-kerml-validator", "validate-kerml")
-	}
-	if o.out == "" {
-		o.out = filepath.Join(o.repo, "build", "pilot-diff")
-	}
-	if _, err := os.Stat(o.validator); err != nil {
-		return fmt.Errorf("pilot validator not found at %s: run ./scripts/download-pilot-sysml-validator.sh", o.validator)
-	}
-
-	// Named explicitly: fail loudly. Defaulted: the third column is optional,
-	// and its absence must leave the two-way report byte-identical.
-	requested := o.syside != ""
-	if !requested {
-		o.syside = filepath.Join(o.repo, "build", "syside", "validate-syside")
-	}
-	if _, err := os.Stat(o.syside); err != nil {
-		if requested {
-			return fmt.Errorf("SysIDE launcher not found at %s: run ./scripts/download-syside.sh", o.syside)
-		}
-		fmt.Fprintf(os.Stderr, "comparing against the pilot only; run ./scripts/download-syside.sh for a third column\n")
-		o.syside = ""
-	}
-	return nil
-}
-
-func run(opts options) error {
-	if err := opts.resolve(); err != nil {
-		return err
-	}
-
-	// The declared errata overlay: every root is compared a second time with
-	// the corrections inside it applied to a copy, never to the corpus.
-	overlay, err := errata.Load()
-	if err != nil {
-		return err
-	}
-
-	// Recorded relative to the repository where possible: the JSON is committed
-	// as a baseline, so it must not carry a machine-specific path.
-	report := &Report{Validator: relativeTo(opts.repo, opts.validator), Errata: newErrataReport(overlay)}
-	if report.Pilot, err = pilotVersion(opts.validator); err != nil {
-		return err
-	}
-	if report.Provenance, err = provenance(opts.repo, report.Pilot); err != nil {
-		return err
-	}
-	// Only a recorded baseline is dated, so two plain runs stay byte-identical.
-	if opts.update {
-		report.Provenance.Recorded = baseline.Today()
-	}
-	if opts.syside != "" {
-		version, library, err := sysideRelease(opts.syside)
-		if err != nil {
-			return err
-		}
-		report.Syside = &SysideInfo{
-			Validator: relativeTo(opts.repo, opts.syside),
-			Version:   version,
-			Library:   library,
-			Pilot:     report.Pilot,
-			Scope:     sysideScope,
-		}
-	}
-
-	for _, root := range defaultRoots {
-		files, err := collectFiles(opts.repo, root)
-		if err != nil {
-			return err
-		}
-		if len(files) == 0 {
-			fmt.Fprintf(os.Stderr, "skipping %s: no .sysml or .kerml files (corpus not downloaded?)\n", root.Dir)
-			continue
-		}
-
-		ours := make(map[string][]diagnostic, len(files))
-		theirs := make(map[string][]diagnostic, len(files))
-		for _, batch := range batchByLanguage(files) {
-			fmt.Fprintf(os.Stderr, "%s: %d %s file(s)\n", root.Name, len(batch.Files), batch.Kind)
-
-			pilot := opts.validator
-			if batch.Kind == source.KindKerML {
-				pilot = opts.kermlValidator
-				if _, err := os.Stat(pilot); err != nil {
-					return fmt.Errorf("KerML pilot validator not found at %s: run ./scripts/download-pilot-kerml-validator.sh", pilot)
-				}
-			}
-			batchOurs, err := openSysMLDiagnostics(opts.repo, root.Dir, batch.Files)
-			if err != nil {
-				return err
-			}
-			batchTheirs, err := pilotDiagnostics(pilot, opts.repo, root.Dir, batch.Files, opts.timeout)
-			if err != nil {
-				return err
-			}
-			for rel, diagnostics := range batchOurs {
-				ours[rel] = diagnostics
-			}
-			for rel, diagnostics := range batchTheirs {
-				theirs[rel] = diagnostics
-			}
-		}
-		rootReport := compareRoot(root.Name, root.Dir, files, ours, theirs)
-		if opts.syside != "" {
-			fmt.Fprintf(os.Stderr, "%s: %d file(s) through syside\n", root.Name, len(files))
-			third, err := sysideDiagnostics(opts.syside, opts.repo, root.Dir, files, opts.timeout)
-			if err != nil {
-				return err
-			}
-			attachSyside(&rootReport, files, ours, theirs, third)
-		}
-
-		erratum, err := runErrata(root, files, overlay, ours, theirs, opts)
-		if err != nil {
-			return err
-		}
-		if erratum.applied > 0 {
-			rootReport.ErrataTotals = &erratum.totals
-			report.Errata.Applied += erratum.applied
-			report.Errata.Findings = append(report.Errata.Findings, erratum.findings...)
-		}
-		report.Roots = append(report.Roots, rootReport)
-	}
-
-	report.summarize()
-	// A mistyped -repo would otherwise look like a clean run.
-	if report.Totals.Files == 0 {
-		return fmt.Errorf("no model files found under %s", opts.repo)
-	}
-	fresh, err := writeReports(opts.out, report)
-	if err != nil {
-		return err
-	}
-	committed := filepath.Join(opts.repo, filepath.FromSlash(committedBaseline))
-	if opts.update {
-		return baseline.Write(committed, fresh)
-	}
-	if opts.check {
-		return baseline.Reproduces(committed, fresh)
-	}
-	return nil
-}
-
-func relativeTo(repo, path string) string {
-	rel, err := filepath.Rel(repo, path)
-	if err != nil || strings.HasPrefix(rel, "..") {
-		return path
-	}
-	return filepath.ToSlash(rel)
-}
-
-// moduleRoot walks up from the working directory to the directory holding go.mod.
-func moduleRoot() (string, error) {
-	dir, err := os.Getwd()
-	if err != nil {
-		return "", err
-	}
-	for {
-		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
-			return dir, nil
-		}
-		parent := filepath.Dir(dir)
-		if parent == dir {
-			return "", fmt.Errorf("no go.mod found above the working directory; pass -repo")
-		}
-		dir = parent
-	}
-}
-
-// batchByLanguage splits a root's files into one batch per language, SysML
-// first. Each language is compared against its own reference validator, and
-// batching per language rather than per file keeps the reference's cross-file
-// reference resolution intact within a batch.
-func batchByLanguage(files []string) []languageBatch {
-	batches := []languageBatch{{Kind: source.KindSysML}, {Kind: source.KindKerML}}
-	for _, rel := range files {
-		for i := range batches {
-			if batches[i].Kind == source.KindOf(rel) {
-				batches[i].Files = append(batches[i].Files, rel)
-			}
-		}
-	}
-	out := make([]languageBatch, 0, len(batches))
-	for _, batch := range batches {
-		if len(batch.Files) > 0 {
-			out = append(out, batch)
-		}
-	}
-	return out
-}
-
-// collectFiles returns the root's model files, in either language, as sorted
-// slash-separated paths relative to the root directory.
-func collectFiles(repo string, root corpusRoot) ([]string, error) {
-	dir := filepath.Join(repo, root.Dir)
-	if _, err := os.Stat(dir); os.IsNotExist(err) {
-		return nil, nil
-	}
-
-	var files []string
-	err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error {
-		if err != nil {
-			return err
-		}
-		rel, err := filepath.Rel(dir, path)
-		if err != nil {
-			return err
-		}
-		rel = filepath.ToSlash(rel)
-		if entry.IsDir() {
-			for _, skip := range root.Skip {
-				if rel == skip {
-					return filepath.SkipDir
-				}
-			}
-			return nil
-		}
-		if source.KindOf(path) != source.KindUnknown {
-			files = append(files, rel)
-		}
-		return nil
-	})
-	if err != nil {
-		return nil, fmt.Errorf("scan %s: %w", dir, err)
-	}
-	sort.Strings(files)
-	return files, nil
-}
diff --git a/cmd/pilot-diff/provenance.go b/cmd/pilot-diff/provenance.go
deleted file mode 100644
index f6f3927b45..0000000000
--- a/cmd/pilot-diff/provenance.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"path/filepath"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// committedBaseline is the record docs/project/pilot-differential.md is
-// generated from, and refreshCommand is the only supported way to re-record it.
-const (
-	committedBaseline = "docs/project/pilot-differential-baseline.json"
-	refreshCommand    = "go run ./cmd/pilot-diff -update"
-)
-
-// provenance identifies everything this oracle compares: the pin, the reference
-// bridges, the declared errata and each corpus root's contents. A release of ""
-// resolves it from the pin, which is what a checkout without the validators has.
-func provenance(repo, release string) (baseline.Record, error) {
-	pin, err := baseline.ReadPin(repo)
-	if err != nil {
-		return baseline.Record{}, err
-	}
-	if release == "" {
-		release = pin.Release()
-	}
-	tools, err := baseline.Bridges(repo, release)
-	if err != nil {
-		return baseline.Record{}, err
-	}
-	overlay, err := errata.Load()
-	if err != nil {
-		return baseline.Record{}, err
-	}
-	record := baseline.Record{
-		PilotTag:      pin.Tag,
-		PilotCommit:   pin.Commit,
-		PilotArtifact: pin.Artifact,
-		Errata:        baseline.ErrataDigest(overlay.Entries()),
-		Tools:         tools,
-	}
-	for _, root := range defaultRoots {
-		files, err := collectFiles(repo, root)
-		if err != nil {
-			return baseline.Record{}, err
-		}
-		if len(files) == 0 {
-			continue
-		}
-		digest, err := baseline.DigestFiles(filepath.Join(repo, filepath.FromSlash(root.Dir)), files)
-		if err != nil {
-			return baseline.Record{}, fmt.Errorf("digest %s: %w", root.Dir, err)
-		}
-		record.Inputs = append(record.Inputs, baseline.Input{
-			Name:   root.Name,
-			Dir:    root.Dir,
-			Origin: root.origin(),
-			Files:  len(files),
-			Digest: digest,
-		})
-	}
-	return record, nil
-}
-
-func (r corpusRoot) origin() string {
-	if r.Pinned {
-		return baseline.OriginPinned
-	}
-	return baseline.OriginOurs
-}
diff --git a/cmd/pilot-diff/provenance_test.go b/cmd/pilot-diff/provenance_test.go
deleted file mode 100644
index 236d0dd02d..0000000000
--- a/cmd/pilot-diff/provenance_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package main
-
-import (
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-)
-
-// The committed baseline must state the pin, the bridges and the corpora its run
-// measured, and they must still be this repository's. Needs no Java validator.
-func TestCommittedBaselineStatesThisRepositorysProvenance(t *testing.T) {
-	repo, current := currentProvenance(t)
-	path := filepath.Join(repo, filepath.FromSlash(committedBaseline))
-	if err := baseline.CheckCommitted(path, refreshCommand, current); err != nil {
-		t.Fatal(err)
-	}
-}
-
-// A baseline recorded against another pin must fail naming the field, both
-// values and the refresh command, or the guard tells a reader nothing.
-func TestProvenanceGuardFailsOnAMovedPin(t *testing.T) {
-	_, current := currentProvenance(t)
-	corrupted := corruptBaseline(t, committedBaseline,
-		`"pilotTag": "`+current.PilotTag+`"`, `"pilotTag": "2025-02"`)
-
-	err := baseline.CheckCommitted(corrupted, refreshCommand, current)
-	if err == nil {
-		t.Fatal("a baseline recorded against another pin was accepted")
-	}
-	for _, want := range []string{"provenance.pilotTag", "2025-02", current.PilotTag, refreshCommand} {
-		if !strings.Contains(err.Error(), want) {
-			t.Errorf("the failure does not mention %q:\n%s", want, err)
-		}
-	}
-}
-
-// currentProvenance is the repository's provenance as it stands, resolved from
-// the pin rather than from a provisioned validator.
-func currentProvenance(t *testing.T) (string, baseline.Record) {
-	t.Helper()
-	repo, err := moduleRoot()
-	if err != nil {
-		t.Fatal(err)
-	}
-	current, err := provenance(repo, "")
-	if err != nil {
-		t.Fatal(err)
-	}
-	return repo, current
-}
-
-// corruptBaseline copies a committed baseline with one substitution applied, so
-// the guard is exercised against a damaged field without touching the record.
-func corruptBaseline(t *testing.T, rel, was, now string) string {
-	t.Helper()
-	repo, err := moduleRoot()
-	if err != nil {
-		t.Fatal(err)
-	}
-	content, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(rel)))
-	if err != nil {
-		t.Fatal(err)
-	}
-	corrupted := strings.Replace(string(content), was, now, 1)
-	if corrupted == string(content) {
-		t.Fatalf("%s contains no %s to corrupt", rel, was)
-	}
-	path := filepath.Join(t.TempDir(), filepath.Base(rel))
-	if err := os.WriteFile(path, []byte(corrupted), 0o600); err != nil {
-		t.Fatal(err)
-	}
-	return path
-}
diff --git a/cmd/pilot-diff/report.go b/cmd/pilot-diff/report.go
deleted file mode 100644
index ca21e42411..0000000000
--- a/cmd/pilot-diff/report.go
+++ /dev/null
@@ -1,474 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-	"github.com/Open-MBEE/OpenSysML/internal/junit"
-)
-
-// Report is the machine-readable result. It holds the compared tuples and their
-// counts, but no message text, so two runs diff cleanly.
-type Report struct {
-	Validator string `json:"validator"`
-	Pilot     string `json:"pilotRelease"`
-	// Provenance identifies the inputs this run measured, so a committed
-	// baseline can be checked against the repository without the validators.
-	Provenance baseline.Record `json:"provenance"`
-	Totals     Totals          `json:"totals"`
-	Roots      []RootReport    `json:"roots"`
-	Unmapped   []UnmappedRow   `json:"unmapped"`
-	// Syside is the optional third implementation's column, additive in every
-	// respect: absent, the report is byte-identical to a two-way run.
-	Syside *SysideInfo `json:"syside,omitempty"`
-	// Errata is the same run over the corpus with the declared corrections
-	// applied; Totals above stays the as-published headline.
-	Errata *ErrataReport `json:"errata,omitempty"`
-}
-
-type Totals struct {
-	Files            int `json:"files"`
-	FilesAgreeing    int `json:"filesFullyAgreeing"`
-	Agreement        int `json:"agreement"`
-	SeverityMismatch int `json:"severityMismatch"`
-	OpenSysMLOnly    int `json:"openSysMLOnly"`
-	PilotOnly        int `json:"pilotOnly"`
-	OpenSysMLTotal   int `json:"openSysMLDiagnostics"`
-	PilotTotal       int `json:"pilotDiagnostics"`
-}
-
-type RootReport struct {
-	Name   string        `json:"name"`
-	Dir    string        `json:"dir"`
-	Totals Totals        `json:"totals"`
-	Files  []FileReport  `json:"files"`
-	Syside *SysideTotals `json:"syside,omitempty"`
-	// ErrataTotals is this root compared again with its corrections applied,
-	// present only for a root some correction lies in.
-	ErrataTotals *Totals `json:"errataTotals,omitempty"`
-}
-
-// FileReport holds one file's buckets. Files where both implementations are
-// silent are omitted.
-type FileReport struct {
-	Path             string          `json:"path"`
-	Agreement        []Entry         `json:"agreement"`
-	SeverityMismatch []SeverityEntry `json:"severityMismatch"`
-	OpenSysMLOnly    []Entry         `json:"openSysMLOnly"`
-	PilotOnly        []Entry         `json:"pilotOnly"`
-	Syside           *SysideFile     `json:"syside,omitempty"`
-}
-
-// SeverityEntry is a (line, category) both implementations flag with different
-// severities. Kept apart so such a pair is neither counted as agreement nor
-// double-counted as two independent disagreements.
-type SeverityEntry struct {
-	Line      int      `json:"line"`
-	Category  Category `json:"category"`
-	OpenSysML string   `json:"openSysMLSeverity"`
-	Pilot     string   `json:"pilotSeverity"`
-	Count     int      `json:"count"`
-	Examples  []string `json:"examples,omitempty"`
-}
-
-// Entry is a compared tuple with how many diagnostics carry it. Examples hold
-// message text for human adjudication only; they are never compared.
-type Entry struct {
-	Line     int      `json:"line"`
-	Severity string   `json:"severity"`
-	Category Category `json:"category"`
-	Count    int      `json:"count"`
-	Examples []string `json:"examples,omitempty"`
-}
-
-// UnmappedRow records a message that no category rule claimed, so the report
-// shows what the categorisation still owes rather than hiding it.
-type UnmappedRow struct {
-	Side    string `json:"side"`
-	Message string `json:"message"`
-	Count   int    `json:"count"`
-}
-
-func compareRoot(name, dir string, files []string, ours, theirs map[string][]diagnostic) RootReport {
-	report := RootReport{Name: name, Dir: dir}
-	for _, rel := range files {
-		file := compareFile(rel, ours[rel], theirs[rel])
-		report.Totals.Files++
-		report.Totals.OpenSysMLTotal += len(ours[rel])
-		report.Totals.PilotTotal += len(theirs[rel])
-		for _, entry := range file.Agreement {
-			report.Totals.Agreement += entry.Count
-		}
-		for _, entry := range file.SeverityMismatch {
-			report.Totals.SeverityMismatch += entry.Count
-		}
-		for _, entry := range file.OpenSysMLOnly {
-			report.Totals.OpenSysMLOnly += entry.Count
-		}
-		for _, entry := range file.PilotOnly {
-			report.Totals.PilotOnly += entry.Count
-		}
-		if len(file.OpenSysMLOnly) == 0 && len(file.PilotOnly) == 0 && len(file.SeverityMismatch) == 0 {
-			report.Totals.FilesAgreeing++
-		}
-		if len(file.Agreement) > 0 || len(file.SeverityMismatch) > 0 || len(file.OpenSysMLOnly) > 0 || len(file.PilotOnly) > 0 {
-			report.Files = append(report.Files, file)
-		}
-	}
-	return report
-}
-
-// compareFile buckets one file's diagnostics. Tuples are compared as multisets:
-// when one side reports a tuple three times and the other twice, two are
-// agreement and the third stays a disagreement.
-func compareFile(rel string, ours, theirs []diagnostic) FileReport {
-	ourGroups := group(ours)
-	theirGroups := group(theirs)
-
-	report := FileReport{Path: rel}
-	ourLeft := map[key][]diagnostic{}
-	theirLeft := map[key][]diagnostic{}
-	for _, k := range sortedKeys(ourGroups, theirGroups) {
-		mine, yours := ourGroups[k], theirGroups[k]
-		shared := min(len(mine), len(yours))
-		if shared > 0 {
-			report.Agreement = append(report.Agreement, entry(k, shared, mine[:shared], yours[:shared]))
-		}
-		if len(mine) > shared {
-			ourLeft[k] = mine[shared:]
-		}
-		if len(yours) > shared {
-			theirLeft[k] = yours[shared:]
-		}
-	}
-
-	// Second pass: a leftover pair that agrees on line and category but not on
-	// severity is its own bucket rather than two unrelated disagreements.
-	for _, k := range sortedKeys(ourLeft) {
-		for _, other := range sortedKeys(theirLeft) {
-			if other.Line != k.Line || other.Category != k.Category || other.Severity == k.Severity {
-				continue
-			}
-			mine, yours := ourLeft[k], theirLeft[other]
-			shared := min(len(mine), len(yours))
-			if shared == 0 {
-				continue
-			}
-			se := SeverityEntry{
-				Line: k.Line, Category: k.Category,
-				OpenSysML: k.Severity, Pilot: other.Severity, Count: shared,
-				Examples: entry(k, shared, mine[:shared], yours[:shared]).Examples,
-			}
-			report.SeverityMismatch = append(report.SeverityMismatch, se)
-			ourLeft[k], theirLeft[other] = mine[shared:], yours[shared:]
-		}
-	}
-
-	for _, k := range sortedKeys(ourLeft) {
-		if left := ourLeft[k]; len(left) > 0 {
-			report.OpenSysMLOnly = append(report.OpenSysMLOnly, entry(k, len(left), left, nil))
-		}
-	}
-	for _, k := range sortedKeys(theirLeft) {
-		if left := theirLeft[k]; len(left) > 0 {
-			report.PilotOnly = append(report.PilotOnly, entry(k, len(left), nil, left))
-		}
-	}
-	return report
-}
-
-func entry(k key, count int, ours, theirs []diagnostic) Entry {
-	e := Entry{Line: k.Line, Severity: k.Severity, Category: k.Category, Count: count}
-	for _, d := range ours {
-		e.Examples = append(e.Examples, "opensysml: "+d.Message)
-	}
-	for _, d := range theirs {
-		e.Examples = append(e.Examples, "pilot: "+d.Message)
-	}
-	return e
-}
-
-func group(diagnostics []diagnostic) map[key][]diagnostic {
-	groups := make(map[key][]diagnostic, len(diagnostics))
-	for _, d := range diagnostics {
-		groups[d.key()] = append(groups[d.key()], d)
-	}
-	return groups
-}
-
-func sortedKeys(groups ...map[key][]diagnostic) []key {
-	seen := make(map[key]bool)
-	var keys []key
-	for _, g := range groups {
-		for k := range g {
-			if !seen[k] {
-				seen[k] = true
-				keys = append(keys, k)
-			}
-		}
-	}
-	sort.Slice(keys, func(i, j int) bool {
-		if keys[i].Line != keys[j].Line {
-			return keys[i].Line < keys[j].Line
-		}
-		if keys[i].Severity != keys[j].Severity {
-			return keys[i].Severity < keys[j].Severity
-		}
-		return keys[i].Category < keys[j].Category
-	})
-	return keys
-}
-
-// summarize fills in the aggregate totals and the unmapped-message table.
-func (r *Report) summarize() {
-	unmapped := make(map[UnmappedRow]int)
-	for _, root := range r.Roots {
-		if r.Errata != nil {
-			// A root no correction lies in contributes its published totals.
-			errataTotals := root.Totals
-			if root.ErrataTotals != nil {
-				errataTotals = *root.ErrataTotals
-			}
-			r.Errata.Totals.add(errataTotals)
-		}
-		r.Totals.Files += root.Totals.Files
-		r.Totals.FilesAgreeing += root.Totals.FilesAgreeing
-		r.Totals.Agreement += root.Totals.Agreement
-		r.Totals.SeverityMismatch += root.Totals.SeverityMismatch
-		r.Totals.OpenSysMLOnly += root.Totals.OpenSysMLOnly
-		r.Totals.PilotOnly += root.Totals.PilotOnly
-		r.Totals.OpenSysMLTotal += root.Totals.OpenSysMLTotal
-		r.Totals.PilotTotal += root.Totals.PilotTotal
-
-		for _, file := range root.Files {
-			for _, bucket := range [][]Entry{file.Agreement, file.OpenSysMLOnly, file.PilotOnly} {
-				for _, e := range bucket {
-					if e.Category != CategoryUnmapped {
-						continue
-					}
-					countUnmapped(unmapped, e.Examples)
-				}
-			}
-			for _, e := range file.SeverityMismatch {
-				if e.Category == CategoryUnmapped {
-					countUnmapped(unmapped, e.Examples)
-				}
-			}
-			if file.Syside != nil {
-				countSysideUnmapped(unmapped, *file.Syside)
-			}
-		}
-		if root.Syside != nil && r.Syside != nil {
-			r.Syside.Totals.addRoot(*root.Syside)
-		}
-	}
-
-	for row, count := range unmapped {
-		row.Count = count
-		r.Unmapped = append(r.Unmapped, row)
-	}
-	sort.Slice(r.Unmapped, func(i, j int) bool {
-		if r.Unmapped[i].Count != r.Unmapped[j].Count {
-			return r.Unmapped[i].Count > r.Unmapped[j].Count
-		}
-		if r.Unmapped[i].Side != r.Unmapped[j].Side {
-			return r.Unmapped[i].Side < r.Unmapped[j].Side
-		}
-		return r.Unmapped[i].Message < r.Unmapped[j].Message
-	})
-}
-
-func (t *Totals) add(other Totals) {
-	t.Files += other.Files
-	t.FilesAgreeing += other.FilesAgreeing
-	t.Agreement += other.Agreement
-	t.SeverityMismatch += other.SeverityMismatch
-	t.OpenSysMLOnly += other.OpenSysMLOnly
-	t.PilotOnly += other.PilotOnly
-	t.OpenSysMLTotal += other.OpenSysMLTotal
-	t.PilotTotal += other.PilotTotal
-}
-
-func countUnmapped(unmapped map[UnmappedRow]int, examples []string) {
-	for _, example := range examples {
-		side, message, _ := strings.Cut(example, ": ")
-		unmapped[UnmappedRow{Side: side, Message: message}]++
-	}
-}
-
-func writeReports(dir string, report *Report) ([]byte, error) {
-	if err := os.MkdirAll(dir, 0o750); err != nil {
-		return nil, err
-	}
-
-	// The JSON drops the message examples so a later run diffs cleanly.
-	machine := *report
-	machine.Roots = make([]RootReport, len(report.Roots))
-	for i, root := range report.Roots {
-		machine.Roots[i] = root
-		machine.Roots[i].Files = make([]FileReport, len(root.Files))
-		for j, file := range root.Files {
-			machine.Roots[i].Files[j] = FileReport{
-				Path:             file.Path,
-				Agreement:        stripExamples(file.Agreement),
-				SeverityMismatch: stripSeverityExamples(file.SeverityMismatch),
-				OpenSysMLOnly:    stripExamples(file.OpenSysMLOnly),
-				PilotOnly:        stripExamples(file.PilotOnly),
-				Syside:           stripSysideExamples(file.Syside),
-			}
-		}
-	}
-	encoded, err := json.MarshalIndent(machine, "", "  ")
-	if err != nil {
-		return nil, err
-	}
-	encoded = append(encoded, '\n')
-	jsonPath := filepath.Join(dir, "pilot-diff.json")
-	if err := os.WriteFile(jsonPath, encoded, 0o600); err != nil {
-		return nil, err
-	}
-
-	textPath := filepath.Join(dir, "pilot-diff.txt")
-	if err := os.WriteFile(textPath, []byte(renderText(report)), 0o600); err != nil {
-		return nil, err
-	}
-
-	junitPath := filepath.Join(dir, "pilot-diff.xml")
-	if err := junit.WriteFile(junitPath, junitReport(report)); err != nil {
-		return nil, err
-	}
-
-	sarif, err := marshalSarif(sarifReport(report))
-	if err != nil {
-		return nil, err
-	}
-	sarifPath := filepath.Join(dir, "pilot-diff.sarif")
-	if err := os.WriteFile(sarifPath, sarif, 0o600); err != nil {
-		return nil, err
-	}
-
-	fmt.Fprintf(os.Stderr, "wrote %s, %s, %s and %s\n", textPath, jsonPath, junitPath, sarifPath)
-	fmt.Fprintf(os.Stderr, "%d file(s), %d fully agreeing; %d agreed diagnostic(s), %d only ours, %d only the pilot's\n",
-		report.Totals.Files, report.Totals.FilesAgreeing, report.Totals.Agreement,
-		report.Totals.OpenSysMLOnly, report.Totals.PilotOnly)
-	return encoded, nil
-}
-
-func stripExamples(entries []Entry) []Entry {
-	out := make([]Entry, len(entries))
-	for i, e := range entries {
-		e.Examples = nil
-		out[i] = e
-	}
-	return out
-}
-
-func stripSeverityExamples(entries []SeverityEntry) []SeverityEntry {
-	out := make([]SeverityEntry, len(entries))
-	for i, e := range entries {
-		e.Examples = nil
-		out[i] = e
-	}
-	return out
-}
-
-func renderText(report *Report) string {
-	var b strings.Builder
-	b.WriteString("OpenSysML vs OMG SysML v2 Pilot Implementation — diagnostic comparison\n")
-	fmt.Fprintf(&b, "pilot release: %s\nvalidator:     %s\n\n", report.Pilot, report.Validator)
-	if report.Syside != nil {
-		fmt.Fprintf(&b, "third implementation: Sensmetry SysIDE %s, %s standard library (%s)\n",
-			report.Syside.Version, report.Syside.Library, report.Syside.Validator)
-		fmt.Fprintf(&b, "scope: %s\n\n", report.Syside.Scope)
-	}
-	writeTotals(&b, "TOTAL", report.Totals)
-	if report.Syside != nil {
-		writeSysideTotals(&b, report.Syside.Totals)
-	}
-	writeErrata(&b, report.Errata)
-
-	for _, root := range report.Roots {
-		fmt.Fprintf(&b, "\n%s\n", strings.Repeat("=", 72))
-		fmt.Fprintf(&b, "%s (%s)\n", root.Name, root.Dir)
-		writeTotals(&b, root.Name, root.Totals)
-		if root.Syside != nil {
-			writeSysideTotals(&b, *root.Syside)
-		}
-		for _, file := range root.Files {
-			fmt.Fprintf(&b, "\n  %s\n", file.Path)
-			writeBucket(&b, "agreement", file.Agreement)
-			writeSeverityBucket(&b, file.SeverityMismatch)
-			writeBucket(&b, "only OpenSysML (candidate false positives)", file.OpenSysMLOnly)
-			writeBucket(&b, "only the pilot (candidate gaps)", file.PilotOnly)
-			writeSysideBucket(&b, file.Syside)
-		}
-	}
-
-	if len(report.Unmapped) > 0 {
-		fmt.Fprintf(&b, "\n%s\nunmapped messages (no category rule claimed these)\n", strings.Repeat("=", 72))
-		for _, row := range report.Unmapped {
-			fmt.Fprintf(&b, "  %4d  %-10s %s\n", row.Count, row.Side, row.Message)
-		}
-	}
-	return b.String()
-}
-
-// writeErrata states the second figure after the headline, never instead of it.
-func writeErrata(b *strings.Builder, report *ErrataReport) {
-	if report == nil {
-		return
-	}
-	fmt.Fprintf(b, "\ndeclared errata: %d entr(ies), %d correction(s), %d documented without one; %d applied here\n",
-		report.Registry, report.Corrections, report.Documented, report.Applied)
-	for _, entry := range report.Entries {
-		shape := "documented, no substitution"
-		if entry.Corrected {
-			shape = "corrected"
-		}
-		fmt.Fprintf(b, "  %s %s:%d (%s) — %s\n", entry.ID, entry.Path, entry.Line, entry.Citation, shape)
-	}
-	writeTotals(b, "TOTAL with errata applied", report.Totals)
-	for _, f := range report.Findings {
-		fmt.Fprintf(b, "  %s %s:%d ours %d->%d, pilot %d->%d: %s\n",
-			f.ID, f.Path, f.Line, f.OursPublished, f.OursCorrected, f.PilotPublished, f.PilotCorrected, f.Note)
-	}
-}
-
-func writeTotals(b *strings.Builder, label string, totals Totals) {
-	fmt.Fprintf(b, "%s: %d file(s), %d fully agreeing\n", label, totals.Files, totals.FilesAgreeing)
-	fmt.Fprintf(b, "  diagnostics: %d ours, %d pilot\n", totals.OpenSysMLTotal, totals.PilotTotal)
-	fmt.Fprintf(b, "  agreement %d | severity-only %d | only ours %d | only pilot %d\n",
-		totals.Agreement, totals.SeverityMismatch, totals.OpenSysMLOnly, totals.PilotOnly)
-}
-
-func writeSeverityBucket(b *strings.Builder, entries []SeverityEntry) {
-	if len(entries) == 0 {
-		return
-	}
-	b.WriteString("    same line and category, different severity:\n")
-	for _, e := range entries {
-		fmt.Fprintf(b, "      line %-5d ours=%-8s pilot=%-8s %-20s x%d\n",
-			e.Line, e.OpenSysML, e.Pilot, e.Category, e.Count)
-		for _, example := range e.Examples {
-			fmt.Fprintf(b, "        %s\n", example)
-		}
-	}
-}
-
-func writeBucket(b *strings.Builder, label string, entries []Entry) {
-	if len(entries) == 0 {
-		return
-	}
-	fmt.Fprintf(b, "    %s:\n", label)
-	for _, e := range entries {
-		fmt.Fprintf(b, "      line %-5d %-8s %-20s x%d\n", e.Line, e.Severity, e.Category, e.Count)
-		for _, example := range e.Examples {
-			fmt.Fprintf(b, "        %s\n", example)
-		}
-	}
-}
diff --git a/cmd/pilot-exec-diff/main.go b/cmd/pilot-exec-diff/main.go
deleted file mode 100644
index 9e6dd526f3..0000000000
--- a/cmd/pilot-exec-diff/main.go
+++ /dev/null
@@ -1,370 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"encoding/json"
-	"flag"
-	"fmt"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
-
-	"github.com/Open-MBEE/OpenSysML/internal/repl"
-)
-
-// bucketKindOnly is the verdict for values that agree once their kinds are
-// reconciled.
-const bucketKindOnly = "kind-only"
-
-var bucketNames = []string{
-	"agree",
-	bucketKindOnly,
-	"order-only",
-	"disagree",
-	"pilot-unevaluated",
-	"pilot-silent",
-	"pilot-error",
-	"ours-error",
-	"ours-undetermined",
-	"both-error",
-	"nondeterministic",
-}
-
-type caseReport struct {
-	ID            string     `json:"id"`
-	Models        []string   `json:"models"`
-	Target        string     `json:"target"`
-	Expression    string     `json:"expression"`
-	RawPilot      string     `json:"rawPilot"`
-	RawOurs       string     `json:"rawOurs"`
-	Pilot         normalized `json:"pilotNormalized"`
-	Ours          normalized `json:"oursNormalized"`
-	Bucket        string     `json:"bucket"`
-	RealPrecision string     `json:"realPrecision"`
-}
-
-type execReport struct {
-	PilotArtifact string         `json:"pilotArtifact"`
-	Scope         string         `json:"scope"`
-	Note          string         `json:"note"`
-	Cases         []caseReport   `json:"cases"`
-	Buckets       map[string]int `json:"buckets"`
-}
-
-func main() {
-	repoFlag := flag.String("repo", "", "repository root (default: module root)")
-	casesFlag := flag.String("cases", "", "directory containing .cases files")
-	outFlag := flag.String("out", "", "output directory (default: <repo>/build/pilot-exec-diff)")
-	launcherFlag := flag.String("launcher", "", "pilot evaluator launcher (default: <repo>/build/pilot-evaluator/eval-sysml)")
-	flag.Parse()
-
-	repo, err := chooseRepo(*repoFlag)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "pilot-exec-diff: %v\n", err)
-		os.Exit(1)
-	}
-	launcher := *launcherFlag
-	if launcher == "" {
-		launcher = filepath.Join(repo, "build", "pilot-evaluator", "eval-sysml")
-	}
-	if _, err := os.Stat(launcher); os.IsNotExist(err) {
-		fmt.Println(artifactAbsentMessage(launcher))
-		return
-	} else if err != nil {
-		fmt.Fprintf(os.Stderr, "pilot-exec-diff: inspect launcher: %v\n", err)
-		os.Exit(1)
-	}
-
-	casesDir := *casesFlag
-	if casesDir == "" {
-		casesDir = filepath.Join(repo, "cmd", "pilot-exec-diff", "testdata", "cases")
-	}
-	caseFiles, err := readCaseFiles(casesDir)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "pilot-exec-diff: %v\n", err)
-		os.Exit(1)
-	}
-	out := *outFlag
-	if out == "" {
-		out = filepath.Join(repo, "build", "pilot-exec-diff")
-	}
-	report, err := execute(repo, launcher, caseFiles)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "pilot-exec-diff: %v\n", err)
-		os.Exit(1)
-	}
-	if err := writeReport(out, report); err != nil {
-		fmt.Fprintf(os.Stderr, "pilot-exec-diff: %v\n", err)
-		os.Exit(1)
-	}
-	printSummary(report)
-}
-
-func artifactAbsentMessage(launcher string) string {
-	return fmt.Sprintf("pilot execution artifact is absent at %s; run ./scripts/download-pilot-evaluator.sh to provision it", launcher)
-}
-
-func chooseRepo(path string) (string, error) {
-	if path != "" {
-		return filepath.Abs(path)
-	}
-	dir, err := os.Getwd()
-	if err != nil {
-		return "", err
-	}
-	for {
-		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
-			return dir, nil
-		}
-		parent := filepath.Dir(dir)
-		if parent == dir {
-			return "", fmt.Errorf("no go.mod found above the working directory; pass -repo")
-		}
-		dir = parent
-	}
-}
-
-func execute(repo, launcher string, files []execCaseFile) (*execReport, error) {
-	report := &execReport{
-		PilotArtifact: filepath.ToSlash(relativeTo(repo, launcher)),
-		Scope:         "expressions only",
-		Note:          "Action, state-machine, and exhibit/perform execution are OUT OF REACH of the pinned artifact and are not compared here. Single-element sequence renderings are unwrapped on both sides; scalar-vs-singleton distinction is unobservable in this report. The pilot cannot distinguish \"no value\" from \"declined to evaluate\" when it emits no output.",
-		Buckets:       make(map[string]int, len(bucketNames)),
-	}
-	for _, name := range bucketNames {
-		report.Buckets[name] = 0
-	}
-	for _, file := range files {
-		models, err := resolveModels(repo, file.Path, file.Models)
-		if err != nil {
-			return nil, err
-		}
-		pilotOne, err := runPilot(launcher, models, file.Cases)
-		if err != nil {
-			return nil, fmt.Errorf("%s: %w", file.Path, err)
-		}
-		pilotTwo, err := runPilot(launcher, models, file.Cases)
-		if err != nil {
-			return nil, fmt.Errorf("%s: %w", file.Path, err)
-		}
-		oursOne := runOurs(models, file.Cases)
-		oursTwo := runOurs(models, file.Cases)
-		for _, testCase := range file.Cases {
-			pilotRaw := pilotOne[testCase.ID]
-			oursRaw := oursOne[testCase.ID]
-			pilot := normalizePilot(pilotRaw)
-			ours := normalizeOurs(oursRaw.Raw, oursRaw.Error)
-			bucket := bucketResults(pilot, ours)
-			if canonicalPilot(pilotOne[testCase.ID]) != canonicalPilot(pilotTwo[testCase.ID]) ||
-				oursOne[testCase.ID].Raw != oursTwo[testCase.ID].Raw ||
-				oursOne[testCase.ID].Error != oursTwo[testCase.ID].Error {
-				bucket = "nondeterministic"
-			}
-			report.Cases = append(report.Cases, caseReport{
-				ID: testCase.ID, Models: modelPaths(file.Models),
-				Target: testCase.Target, Expression: testCase.Expression,
-				RawPilot: pilotRaw, RawOurs: oursRaw.Raw,
-				Pilot: pilot.Value, Ours: ours.Value,
-				Bucket: bucket, RealPrecision: "real values compared after rounding both sides to 2 decimal places",
-			})
-			report.Buckets[bucket]++
-		}
-	}
-	return report, nil
-}
-
-func resolveModels(repo, casePath string, models []execModel) ([]string, error) {
-	paths := make([]string, len(models))
-	for i, model := range models {
-		path := filepath.Join(repo, filepath.FromSlash(model.Path))
-		if info, err := os.Stat(path); err != nil {
-			return nil, fmt.Errorf("%s:%d: model %s: %w", casePath, model.Line, model.Path, err)
-		} else if !info.Mode().IsRegular() {
-			return nil, fmt.Errorf("%s:%d: model %s is not a regular file", casePath, model.Line, model.Path)
-		}
-		paths[i] = path
-	}
-	return paths, nil
-}
-
-func modelPaths(models []execModel) []string {
-	paths := make([]string, len(models))
-	for i, model := range models {
-		paths[i] = model.Path
-	}
-	return paths
-}
-
-func runPilot(launcher string, models []string, cases []execCase) (map[string]string, error) {
-	tsv, err := os.CreateTemp("", "pilot-exec-diff-*.tsv")
-	if err != nil {
-		return nil, fmt.Errorf("create pilot cases: %w", err)
-	}
-	path := tsv.Name()
-	defer os.Remove(path)
-	for _, testCase := range cases {
-		if _, err := fmt.Fprintf(tsv, "%s\t%s\t%s\n", testCase.ID, testCase.Target, testCase.Expression); err != nil {
-			_ = tsv.Close()
-			return nil, fmt.Errorf("write pilot cases: %w", err)
-		}
-	}
-	if err := tsv.Close(); err != nil {
-		return nil, fmt.Errorf("close pilot cases: %w", err)
-	}
-	args := []string{"--cases", path}
-	for _, model := range models {
-		args = append(args, "--model", model)
-	}
-	// #nosec G204 -- launcher is an explicit CLI override or repo-local artifact.
-	command := exec.Command(launcher, args...)
-	var stdout, stderr bytes.Buffer
-	command.Stdout, command.Stderr = &stdout, &stderr
-	if err := command.Run(); err != nil {
-		message := strings.TrimSpace(stderr.String())
-		if message == "" {
-			message = err.Error()
-		}
-		result := make(map[string]string, len(cases))
-		for _, testCase := range cases {
-			result[testCase.ID] = "ERROR:launcher: " + message
-		}
-		return result, nil
-	}
-	result, err := parsePilotCases(stdout.String(), cases)
-	if err != nil {
-		return nil, err
-	}
-	return result, nil
-}
-
-func parsePilotCases(output string, cases []execCase) (map[string]string, error) {
-	wanted := make(map[string]bool, len(cases))
-	for _, testCase := range cases {
-		wanted[testCase.ID] = true
-	}
-	results := make(map[string]string, len(cases))
-	var current string
-	var lines []string
-	flush := func() {
-		if current != "" {
-			results[current] = strings.Join(lines, "\n")
-		}
-	}
-	for _, line := range strings.Split(output, "\n") {
-		switch {
-		case strings.HasPrefix(line, "== case "):
-			flush()
-			current = strings.TrimPrefix(line, "== case ")
-			lines = nil
-		case strings.HasPrefix(line, "== end "):
-			flush()
-			current, lines = "", nil
-		case current != "":
-			lines = append(lines, line)
-		}
-	}
-	flush()
-	for id := range wanted {
-		if _, ok := results[id]; !ok {
-			return nil, fmt.Errorf("pilot output did not contain case %s", id)
-		}
-	}
-	return results, nil
-}
-
-func runOurs(models []string, cases []execCase) map[string]sideResult {
-	results := make(map[string]sideResult, len(cases))
-	session := repl.NewSession()
-	report, err := session.LoadPathsReport(models)
-	if err != nil {
-		for _, testCase := range cases {
-			results[testCase.ID] = sideResult{Raw: "sysml: " + err.Error(), Error: true}
-		}
-		return results
-	}
-	if report.Errors {
-		raw := "sysml: model did not analyse cleanly"
-		if len(report.Found) > 0 {
-			limit := min(5, len(report.Found))
-			raw += "\n" + strings.Join(report.Found[:limit], "\n")
-		}
-		for _, testCase := range cases {
-			results[testCase.ID] = sideResult{Raw: raw, Error: true}
-		}
-		return results
-	}
-	for _, testCase := range cases {
-		var (
-			lines []string
-			err   error
-		)
-		if testCase.Target != "" {
-			lines, _, err = session.RunMeta("%eval in " + testCase.Target + " : " + testCase.Expression)
-		} else {
-			lines, err = session.EvalExpr(testCase.Expression)
-		}
-		if err != nil {
-			results[testCase.ID] = sideResult{Raw: "sysml: " + err.Error(), Error: true}
-			continue
-		}
-		results[testCase.ID] = sideResult{Raw: strings.Join(lines, "\n")}
-	}
-	return results
-}
-
-func canonicalPilot(raw string) string {
-	lines := strings.Split(raw, "\n")
-	for i, line := range lines {
-		lines[i] = pilotUUID.ReplaceAllString(line, "")
-	}
-	return strings.Join(lines, "\n")
-}
-
-func writeReport(dir string, report *execReport) error {
-	if err := os.MkdirAll(dir, 0o750); err != nil {
-		return fmt.Errorf("create report directory: %w", err)
-	}
-	encoded, err := json.MarshalIndent(report, "", "  ")
-	if err != nil {
-		return fmt.Errorf("encode report: %w", err)
-	}
-	if err := os.WriteFile(filepath.Join(dir, "pilot-exec-diff.json"), append(encoded, '\n'), 0o600); err != nil {
-		return fmt.Errorf("write JSON report: %w", err)
-	}
-	var text strings.Builder
-	fmt.Fprintf(&text, "pilot execution referee (%s)\n\n", report.Scope)
-	fmt.Fprintf(&text, "%s\n\n", report.Note)
-	for _, result := range report.Cases {
-		fmt.Fprintf(&text, "%s: %s [%s]\n", result.ID, result.Bucket, strings.Join(result.Models, ", "))
-		fmt.Fprintf(&text, "  target: %s\n  expression: %s\n", result.Target, result.Expression)
-		fmt.Fprintf(&text, "  raw pilot:\n%s\n  raw ours:\n%s\n", result.RawPilot, result.RawOurs)
-		fmt.Fprintf(&text, "  pilot normalized: %s\n  ours normalized: %s\n", normalizedText(result.Pilot), normalizedText(result.Ours))
-		fmt.Fprintf(&text, "  real comparison: %s\n\n", result.RealPrecision)
-	}
-	fmt.Fprintln(&text, "bucket counts:")
-	for _, name := range bucketNames {
-		fmt.Fprintf(&text, "  %s: %d\n", name, report.Buckets[name])
-	}
-	if err := os.WriteFile(filepath.Join(dir, "pilot-exec-diff.txt"), []byte(text.String()), 0o600); err != nil {
-		return fmt.Errorf("write text report: %w", err)
-	}
-	return nil
-}
-
-func printSummary(report *execReport) {
-	fmt.Println("pilot-exec-diff: expressions only")
-	fmt.Println(report.Note)
-	for _, name := range bucketNames {
-		fmt.Printf("%s: %d\n", name, report.Buckets[name])
-	}
-	fmt.Printf("%d case(s)\n", len(report.Cases))
-}
-
-func relativeTo(repo, path string) string {
-	rel, err := filepath.Rel(repo, path)
-	if err != nil || strings.HasPrefix(rel, "..") {
-		return path
-	}
-	return filepath.ToSlash(rel)
-}
diff --git a/cmd/pilot-exec-diff/normalize.go b/cmd/pilot-exec-diff/normalize.go
deleted file mode 100644
index c03a5c942e..0000000000
--- a/cmd/pilot-exec-diff/normalize.go
+++ /dev/null
@@ -1,381 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"math/big"
-	"regexp"
-	"strconv"
-	"strings"
-)
-
-const (
-	kindInt      = "int"
-	kindReal     = "real"
-	kindBool     = "bool"
-	kindString   = "string"
-	kindInfinity = "infinity"
-	kindSequence = "sequence"
-	kindQuantity = "quantity"
-	// kindUndetermined is our `<undetermined>`: a model-level result the model
-	// leaves open, answered as such rather than evaluated or errored.
-	kindUndetermined = "undetermined"
-)
-
-var (
-	pilotUUID = regexp.MustCompile(` \([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\)$`)
-	intText   = regexp.MustCompile(`^-?[0-9]+$`)
-	realText  = regexp.MustCompile(`^-?[0-9]+\.[0-9]+$`)
-	quantity  = regexp.MustCompile(`^(-?[0-9]+(?:\.[0-9]+)?) \[(.+)\]$`)
-)
-
-type normalized struct {
-	Kind        string       `json:"kind"`
-	Value       string       `json:"value,omitempty"`
-	Elements    []normalized `json:"elements,omitempty"`
-	Unevaluated bool         `json:"unevaluated,omitempty"`
-}
-
-type sideResult struct {
-	Raw         string
-	Value       normalized
-	Error       bool
-	PilotSilent bool
-}
-
-func normalizePilot(raw string) sideResult {
-	lines := nonEmptyLines(raw)
-	if len(lines) == 0 {
-		return sideResult{Raw: raw, PilotSilent: true}
-	}
-	for _, line := range lines {
-		if strings.HasPrefix(line, "ERROR:") || strings.HasPrefix(line, "EXCEPTION:") {
-			return sideResult{Raw: raw, Error: true}
-		}
-	}
-
-	var values []normalized
-	for _, line := range lines {
-		if strings.HasPrefix(line, "WARNING:") {
-			continue
-		}
-		line = pilotUUID.ReplaceAllString(line, "")
-		kind, value, ok := strings.Cut(line, " ")
-		if !ok {
-			return sideResult{Raw: raw, Value: normalized{Unevaluated: true}}
-		}
-		switch kind {
-		case "LiteralInteger":
-			if !intText.MatchString(value) {
-				return sideResult{Raw: raw, Value: normalized{Unevaluated: true}}
-			}
-			values = append(values, normalized{Kind: kindInt, Value: value})
-		case "LiteralRational", "LiteralReal":
-			if _, ok := realRat(value); !ok {
-				return sideResult{Raw: raw, Value: normalized{Unevaluated: true}}
-			}
-			values = append(values, normalized{Kind: kindReal, Value: value})
-		case "LiteralBoolean":
-			if value != "true" && value != "false" {
-				return sideResult{Raw: raw, Value: normalized{Unevaluated: true}}
-			}
-			values = append(values, normalized{Kind: kindBool, Value: value})
-		case "LiteralString":
-			values = append(values, normalized{Kind: kindString, Value: value})
-		case "LiteralInfinity":
-			values = append(values, normalized{Kind: kindInfinity, Value: value})
-		default:
-			return sideResult{Raw: raw, Value: normalized{Unevaluated: true}}
-		}
-	}
-	return sideResult{Raw: raw, Value: canonicalValue(sequenceOrSingle(values))}
-}
-
-func normalizeOurs(raw string, runErr bool) sideResult {
-	if runErr {
-		return sideResult{Raw: raw, Error: true}
-	}
-	for _, line := range strings.Split(raw, "\n") {
-		if strings.HasPrefix(line, "sysml:") || strings.HasPrefix(line, "error:") {
-			return sideResult{Raw: raw, Error: true}
-		}
-	}
-	for _, line := range strings.Split(raw, "\n") {
-		if strings.HasPrefix(line, "  = ") {
-			value := strings.TrimSpace(strings.TrimPrefix(line, "  = "))
-			parsed, ok := parseOurValue(value)
-			if !ok {
-				return sideResult{Raw: raw, Error: true}
-			}
-			return sideResult{Raw: raw, Value: canonicalValue(parsed)}
-		}
-	}
-	return sideResult{Raw: raw, Value: normalized{}}
-}
-
-func parseOurValue(text string) (normalized, bool) {
-	text = strings.TrimSpace(text)
-	if text == "[]" {
-		return normalized{Kind: kindSequence, Elements: []normalized{}}, true
-	}
-	if strings.HasPrefix(text, "[") && strings.HasSuffix(text, "]") {
-		parts, ok := splitSequence(text[1 : len(text)-1])
-		if !ok {
-			return normalized{}, false
-		}
-		elements := make([]normalized, 0, len(parts))
-		for _, part := range parts {
-			value, ok := parseOurValue(part)
-			if !ok {
-				return normalized{}, false
-			}
-			elements = append(elements, value)
-		}
-		return normalized{Kind: kindSequence, Elements: elements}, true
-	}
-	if strings.HasPrefix(text, `"`) && strings.HasSuffix(text, `"`) {
-		value, err := strconv.Unquote(text)
-		return normalized{Kind: kindString, Value: value}, err == nil
-	}
-	if text == "true" || text == "false" {
-		return normalized{Kind: kindBool, Value: text}, true
-	}
-	if text == "<undetermined>" {
-		return normalized{Kind: kindUndetermined}, true
-	}
-	if quantity.MatchString(text) {
-		return normalized{Kind: kindQuantity, Value: text}, true
-	}
-	if intText.MatchString(text) {
-		return normalized{Kind: kindInt, Value: text}, true
-	}
-	if realText.MatchString(text) {
-		return normalized{Kind: kindReal, Value: text}, true
-	}
-	return normalized{}, false
-}
-
-func splitSequence(text string) ([]string, bool) {
-	if strings.TrimSpace(text) == "" {
-		return []string{}, true
-	}
-	var parts []string
-	start, depth := 0, 0
-	inString, escaped := false, false
-	for i, r := range text {
-		if inString {
-			if escaped {
-				escaped = false
-			} else if r == '\\' {
-				escaped = true
-			} else if r == '"' {
-				inString = false
-			}
-			continue
-		}
-		switch r {
-		case '"':
-			inString = true
-		case '[':
-			depth++
-		case ']':
-			depth--
-			if depth < 0 {
-				return nil, false
-			}
-		case ',':
-			if depth == 0 {
-				parts = append(parts, strings.TrimSpace(text[start:i]))
-				start = i + 1
-			}
-		}
-	}
-	if inString || depth != 0 {
-		return nil, false
-	}
-	parts = append(parts, strings.TrimSpace(text[start:]))
-	return parts, true
-}
-
-func sequenceOrSingle(values []normalized) normalized {
-	switch len(values) {
-	case 0:
-		return normalized{}
-	case 1:
-		return values[0]
-	default:
-		return normalized{Kind: kindSequence, Elements: values}
-	}
-}
-
-func canonicalValue(value normalized) normalized {
-	if value.Kind != kindSequence {
-		return value
-	}
-	if len(value.Elements) == 0 {
-		return normalized{}
-	}
-	if len(value.Elements) == 1 {
-		return canonicalValue(value.Elements[0])
-	}
-	for i, element := range value.Elements {
-		value.Elements[i] = canonicalValue(element)
-	}
-	return value
-}
-
-func nonEmptyLines(raw string) []string {
-	var lines []string
-	for _, line := range strings.Split(raw, "\n") {
-		if strings.TrimSpace(line) != "" {
-			lines = append(lines, strings.TrimSpace(line))
-		}
-	}
-	return lines
-}
-
-func realRat(text string) (*big.Rat, bool) {
-	rat, ok := new(big.Rat).SetString(text)
-	return rat, ok
-}
-
-func exactRat(value normalized) (*big.Rat, bool) {
-	switch value.Kind {
-	case kindInt:
-		rat := new(big.Rat)
-		if _, ok := rat.SetString(value.Value); ok {
-			return rat, true
-		}
-	case kindReal:
-		return realRat(value.Value)
-	}
-	return nil, false
-}
-
-func roundedReal(value normalized) string {
-	rat, ok := exactRat(value)
-	if !ok {
-		return ""
-	}
-	float := new(big.Float).SetPrec(256).SetRat(rat)
-	return float.Text('f', 2)
-}
-
-func compareValues(pilot, ours normalized) string {
-	if pilot.Kind == kindSequence || ours.Kind == kindSequence {
-		if pilot.Kind != kindSequence || ours.Kind != kindSequence {
-			return "disagree"
-		}
-		if len(pilot.Elements) != len(ours.Elements) {
-			return "disagree"
-		}
-		sameOrder, kindOnly := true, false
-		for i := range pilot.Elements {
-			bucket := compareValues(pilot.Elements[i], ours.Elements[i])
-			if bucket == "disagree" {
-				sameOrder = false
-				break
-			}
-			if bucket == bucketKindOnly {
-				kindOnly = true
-			}
-		}
-		if sameOrder {
-			if kindOnly {
-				return bucketKindOnly
-			}
-			return "agree"
-		}
-		if sameMultiset(pilot.Elements, ours.Elements) {
-			return "order-only"
-		}
-		return "disagree"
-	}
-	if pilot.Kind == kindQuantity || ours.Kind == kindQuantity {
-		return "disagree"
-	}
-	if pilot.Kind == kindInt && ours.Kind == kindInt {
-		if pilot.Value == ours.Value {
-			return "agree"
-		}
-		return "disagree"
-	}
-	if pilot.Kind == kindReal && ours.Kind == kindReal {
-		if roundedReal(pilot) == roundedReal(ours) {
-			return "agree"
-		}
-		return "disagree"
-	}
-	if (pilot.Kind == kindInt && ours.Kind == kindReal) ||
-		(pilot.Kind == kindReal && ours.Kind == kindInt) {
-		pilotRat, pilotOK := exactRat(pilot)
-		oursRat, oursOK := exactRat(ours)
-		if pilotOK && oursOK && pilotRat.Cmp(oursRat) == 0 {
-			return bucketKindOnly
-		}
-		return "disagree"
-	}
-	if pilot.Kind == ours.Kind && pilot.Value == ours.Value {
-		return "agree"
-	}
-	return "disagree"
-}
-
-func sameMultiset(left, right []normalized) bool {
-	used := make([]bool, len(right))
-	for _, value := range left {
-		found := false
-		for i, candidate := range right {
-			if !used[i] && compareValues(value, candidate) == "agree" {
-				used[i], found = true, true
-				break
-			}
-		}
-		if !found {
-			return false
-		}
-	}
-	return true
-}
-
-func bucketResults(pilot, ours sideResult) string {
-	switch {
-	case pilot.PilotSilent:
-		return "pilot-silent"
-	case pilot.Value.Unevaluated:
-		return "pilot-unevaluated"
-	case pilot.Error && ours.Error:
-		return "both-error"
-	case pilot.Error:
-		return "pilot-error"
-	case ours.Error:
-		return "ours-error"
-	case ours.Value.Kind == kindUndetermined:
-		return "ours-undetermined"
-	case pilot.Value.Kind == "" && ours.Value.Kind == "":
-		return "agree"
-	case pilot.Value.Kind == "" || ours.Value.Kind == "":
-		return "disagree"
-	default:
-		return compareValues(pilot.Value, ours.Value)
-	}
-}
-
-func normalizedText(value normalized) string {
-	if value.Kind == "" {
-		return "empty"
-	}
-	if value.Unevaluated {
-		return "pilot-unevaluated"
-	}
-	if value.Kind == kindUndetermined {
-		return kindUndetermined
-	}
-	if value.Kind == kindSequence {
-		parts := make([]string, len(value.Elements))
-		for i, element := range value.Elements {
-			parts[i] = normalizedText(element)
-		}
-		return fmt.Sprintf("sequence[%s]", strings.Join(parts, ", "))
-	}
-	return value.Kind + ":" + value.Value
-}
diff --git a/cmd/pilot-reject/conformance.go b/cmd/pilot-reject/conformance.go
deleted file mode 100644
index 0779aa7479..0000000000
--- a/cmd/pilot-reject/conformance.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package main
-
-import (
-	"fmt"
-
-	"github.com/Open-MBEE/OpenSysML/internal/core/conformance"
-)
-
-// The conformance policies the harness can be run under. "auto" asks each case
-// the question its derivation is about; the other two ask one question of all of
-// them, which is how the default-mode baseline stays reproducible.
-const (
-	policyAuto    = "auto"
-	policyDefault = "default"
-	policyStrict  = "strict"
-)
-
-// strictSource is the corpus derivation holding OpenSysML notation extensions:
-// notation the pinned reference rejects and we accept by default, so only the
-// strict question is a fair comparison for it.
-const strictSource = "extensions"
-
-// parsePolicy validates a -conformance value.
-func parsePolicy(name string) (string, error) {
-	switch name {
-	case policyAuto, policyDefault, policyStrict:
-		return name, nil
-	default:
-		return "", fmt.Errorf("unknown conformance policy %q: want %q, %q or %q",
-			name, policyAuto, policyDefault, policyStrict)
-	}
-}
-
-// modeFor is the conformance mode a case of the named derivation is judged under.
-func modeFor(policy, src string) conformance.Mode {
-	switch policy {
-	case policyStrict:
-		return conformance.ModeStrict
-	case policyDefault:
-		return conformance.ModeDefault
-	default:
-		return conformance.ModeOf(src == strictSource)
-	}
-}
diff --git a/cmd/pilot-reject/conformance_test.go b/cmd/pilot-reject/conformance_test.go
deleted file mode 100644
index 543dbb3f38..0000000000
--- a/cmd/pilot-reject/conformance_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package main
-
-import (
-	"testing"
-
-	"github.com/Open-MBEE/OpenSysML/internal/core/conformance"
-)
-
-func TestParsePolicy(t *testing.T) {
-	for _, name := range []string{policyAuto, policyDefault, policyStrict} {
-		got, err := parsePolicy(name)
-		if err != nil || got != name {
-			t.Errorf("parsePolicy(%q) = %q, %v", name, got, err)
-		}
-	}
-	if _, err := parsePolicy("lenient"); err == nil {
-		t.Error("parsePolicy(\"lenient\") must fail rather than pick a mode for us")
-	}
-	if _, err := parsePolicy(""); err == nil {
-		t.Error("parsePolicy(\"\") must fail: an unset flag is the caller's mistake, not a policy")
-	}
-}
-
-// Only the extensions/ derivation is judged strictly under auto; the two
-// explicit policies ask one question of every derivation.
-func TestModeFor(t *testing.T) {
-	for _, tc := range []struct {
-		policy, source string
-		want           conformance.Mode
-	}{
-		{policyAuto, "extensions", conformance.ModeStrict},
-		{policyAuto, "grammar", conformance.ModeDefault},
-		{policyAuto, "xpect", conformance.ModeDefault},
-		{policyDefault, "extensions", conformance.ModeDefault},
-		{policyStrict, "grammar", conformance.ModeStrict},
-	} {
-		if got := modeFor(tc.policy, tc.source); got != tc.want {
-			t.Errorf("modeFor(%q, %q) = %v, want %v", tc.policy, tc.source, got, tc.want)
-		}
-	}
-}
diff --git a/cmd/pilot-reject/doc_counts_test.go b/cmd/pilot-reject/doc_counts_test.go
deleted file mode 100644
index 202e9930cb..0000000000
--- a/cmd/pilot-reject/doc_counts_test.go
+++ /dev/null
@@ -1,236 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"os"
-	"path/filepath"
-	"regexp"
-	"sort"
-	"strconv"
-	"strings"
-	"testing"
-)
-
-const (
-	rejectionDocPath      = "docs/project/pilot-rejection.md"
-	rejectionBaselinePath = "docs/project/pilot-rejection-baseline.json"
-	rejectionSkillPath    = ".agents/skills/testing-pilot-rejection/SKILL.md"
-	rejectionReadmePath   = "README.md"
-)
-
-var (
-	rejectionHeadlinePattern = regexp.MustCompile(`(\d+) case\(s\): (\d+) both reject, (\d+) only the pilot rejects, (\d+) only we reject, (\d+) both accept`)
-	rejectionReadmePattern   = regexp.MustCompile(`(\d+) hand-written invalid models validated by both implementations, (\d+) rejected by both, (\d+) the pinned pilot rejects and we accept`)
-	rejectionSkillPattern    = regexp.MustCompile(`\((\d+) hand-written invalid models`)
-	rejectionSourceRow       = regexp.MustCompile(`^\| (extensions|grammar|semantic|xpect) \| (\d+) \| (\d+) \| (\d+) \| (\d+) \| (\d+) \|$`)
-	rejectionGapRow          = regexp.MustCompile("^\\| `([^`]+)` \\| accepts \\|")
-	rejectionGapCount        = regexp.MustCompile(`All (\d+) gaps`)
-)
-
-// TestPilotRejectionDocumentCountsMatchBaseline checks documented policy
-// closures alongside the separately refreshed committed baseline.
-func TestPilotRejectionDocumentCountsMatchBaseline(t *testing.T) {
-	baseline := rejectionReadBaseline(t)
-	current := rejectionCurrentReport(baseline)
-	rejectionCheckHeadlines(t, current, rejectionDocPath)
-	rejectionCheckHeadlines(t, baseline, rejectionSkillPath)
-	rejectionCheckSourceTable(t, current)
-	rejectionCheckGapTable(t, current)
-	rejectionCheckReadme(t, baseline)
-	rejectionCheckSkill(t, baseline)
-}
-
-// rejectionCurrentReport applies policy closures documented ahead of the
-// separately managed oracle-baseline refresh.
-func rejectionCurrentReport(report Report) Report {
-	closed := map[string]bool{
-		"grammar/g15-keyword-as-name.sysml":        true,
-		"grammar/g60-alias-keyword-as-name.sysml":  true,
-		"grammar/k02-sysml-keyword-in-kerml.kerml": true,
-	}
-	out := report
-	out.Cases = append([]Case(nil), report.Cases...)
-	for i := range out.Cases {
-		if closed[out.Cases[i].Path] {
-			out.Cases[i].Bucket = bucketBothReject
-		}
-	}
-	out.Totals = Totals{}
-	out.Sources = nil
-	out.StrictOnlyAgreements = nil
-	out.summarize()
-	return out
-}
-
-func rejectionReadBaseline(t *testing.T) Report {
-	t.Helper()
-	content, err := os.ReadFile(rejectionRepoPath(rejectionBaselinePath))
-	if err != nil {
-		t.Fatalf("%s:1: read baseline: %v", rejectionBaselinePath, err)
-	}
-	var report Report
-	if err := json.Unmarshal(content, &report); err != nil {
-		t.Fatalf("%s:1: parse baseline: %v", rejectionBaselinePath, err)
-	}
-	return report
-}
-
-func rejectionRepoPath(rel string) string {
-	return filepath.FromSlash("../../" + rel)
-}
-
-// rejectionCheckHeadlines checks every bucket headline in one document.
-func rejectionCheckHeadlines(t *testing.T, report Report, path string) {
-	t.Helper()
-	content := rejectionReadDoc(t, path)
-	matches := rejectionHeadlinePattern.FindAllStringSubmatchIndex(content, -1)
-	if len(matches) == 0 {
-		t.Errorf("%s:1: no bucket headline found", path)
-	}
-	for _, match := range matches {
-		wants := []int{report.Totals.Cases, report.Totals.BothReject, report.Totals.PilotOnlyRejects, report.Totals.OursOnlyRejects, report.Totals.BothAccept}
-		labels := []string{"cases", "both reject", "pilot only", "ours only", "both accept"}
-		for i, want := range wants {
-			rejectionCheckNumber(t, path, content, match[2+i*2], match[3+i*2], labels[i], want)
-		}
-	}
-}
-
-func rejectionCheckSourceTable(t *testing.T, report Report) {
-	t.Helper()
-	content := rejectionReadDoc(t, rejectionDocPath)
-	totals := make(map[string]SourceTotals, len(report.Sources))
-	for _, st := range report.Sources {
-		totals[st.Source] = st
-	}
-	seen := map[string]bool{}
-	for _, line := range rejectionLines(content) {
-		match := rejectionSourceRow.FindStringSubmatchIndex(line.text)
-		if match == nil {
-			continue
-		}
-		name := line.text[match[2]:match[3]]
-		if seen[name] {
-			t.Errorf("%s:%d: per-source row %q appears more than once", rejectionDocPath, line.number, name)
-		}
-		seen[name] = true
-		st, ok := totals[name]
-		if !ok {
-			t.Errorf("%s:%d: per-source row %q is not in the report", rejectionDocPath, line.number, name)
-			continue
-		}
-		wants := []int{st.Cases, st.BothReject, st.PilotOnlyRejects, st.OursOnlyRejects, st.BothAccept}
-		labels := []string{"cases", "both reject", "pilot only", "ours only", "both accept"}
-		for i, want := range wants {
-			rejectionCheckNumberAt(t, rejectionDocPath, line.number, line.text[match[4+i*2]:match[5+i*2]], name+" "+labels[i], want)
-		}
-	}
-	for name := range totals {
-		if !seen[name] {
-			t.Errorf("%s:1: per-source table is missing report source %q", rejectionDocPath, name)
-		}
-	}
-}
-
-// rejectionCheckGapTable checks the gap table enumerates exactly the report's
-// pilot-only-rejects cases, and that the stated gap count matches.
-func rejectionCheckGapTable(t *testing.T, report Report) {
-	t.Helper()
-	content := rejectionReadDoc(t, rejectionDocPath)
-	var wants []string
-	for _, c := range report.Cases {
-		if c.Bucket == bucketPilotOnly {
-			wants = append(wants, c.Path)
-		}
-	}
-	var gots []string
-	for _, line := range rejectionLines(content) {
-		if match := rejectionGapRow.FindStringSubmatch(line.text); match != nil {
-			gots = append(gots, match[1])
-		}
-	}
-	sort.Strings(wants)
-	sort.Strings(gots)
-	if strings.Join(wants, "\x00") != strings.Join(gots, "\x00") {
-		t.Errorf("%s:1: gap table paths: want %q (baseline pilot-only-rejects), got %q", rejectionDocPath, wants, gots)
-	}
-	match := rejectionGapCount.FindStringSubmatchIndex(content)
-	if match == nil {
-		t.Errorf("%s:1: missing the `All N gaps` sentence", rejectionDocPath)
-		return
-	}
-	rejectionCheckNumber(t, rejectionDocPath, content, match[2], match[3], "gap count", len(wants))
-}
-
-func rejectionCheckReadme(t *testing.T, report Report) {
-	t.Helper()
-	content := rejectionReadDoc(t, rejectionReadmePath)
-	match := rejectionReadmePattern.FindStringSubmatchIndex(content)
-	if match == nil {
-		t.Errorf("%s:1: no rejection-oracle line found", rejectionReadmePath)
-		return
-	}
-	wants := []int{report.Totals.Cases, report.Totals.BothReject, report.Totals.PilotOnlyRejects}
-	labels := []string{"cases", "rejected by both", "pilot rejects and we accept"}
-	for i, want := range wants {
-		rejectionCheckNumber(t, rejectionReadmePath, content, match[2+i*2], match[3+i*2], labels[i], want)
-	}
-}
-
-func rejectionCheckSkill(t *testing.T, report Report) {
-	t.Helper()
-	content := rejectionReadDoc(t, rejectionSkillPath)
-	match := rejectionSkillPattern.FindStringSubmatchIndex(content)
-	if match == nil {
-		t.Errorf("%s:1: no corpus-size claim found", rejectionSkillPath)
-		return
-	}
-	rejectionCheckNumber(t, rejectionSkillPath, content, match[2], match[3], "corpus size", report.Totals.Cases)
-}
-
-func rejectionCheckNumber(t *testing.T, path, content string, start, end int, label string, want int) {
-	t.Helper()
-	rejectionCheckNumberAt(t, path, rejectionLineNumber(content, start), content[start:end], label, want)
-}
-
-func rejectionCheckNumberAt(t *testing.T, path string, line int, value, label string, want int) {
-	t.Helper()
-	got, err := strconv.Atoi(value)
-	if err != nil {
-		t.Errorf("%s:%d: %s: malformed number %q", path, line, label, value)
-		return
-	}
-	if got != want {
-		t.Errorf("%s:%d: %s: want %d (baseline), got %d", path, line, label, want, got)
-	}
-}
-
-func rejectionReadDoc(t *testing.T, rel string) string {
-	t.Helper()
-	content, err := os.ReadFile(rejectionRepoPath(rel))
-	if err != nil {
-		t.Fatalf("%s:1: read document: %v", rel, err)
-	}
-	return string(content)
-}
-
-type rejectionLine struct {
-	number int
-	text   string
-}
-
-func rejectionLines(content string) []rejectionLine {
-	split := strings.Split(content, "\n")
-	lines := make([]rejectionLine, len(split))
-	for i, text := range split {
-		lines[i] = rejectionLine{number: i + 1, text: text}
-	}
-	return lines
-}
-
-func rejectionLineNumber(content string, offset int) int {
-	if offset > len(content) {
-		offset = len(content)
-	}
-	return strings.Count(content[:offset], "\n") + 1
-}
diff --git a/cmd/pilot-reject/errata.go b/cmd/pilot-reject/errata.go
deleted file mode 100644
index c83d25f86d..0000000000
--- a/cmd/pilot-reject/errata.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// ErrataReport is the second figure of the run: the same buckets over the
-// corpus with the declared corrections applied to a copy, never to the corpus.
-type ErrataReport struct {
-	Registry    int           `json:"registryEntries"`
-	Corrections int           `json:"corrections"`
-	Documented  int           `json:"documentedWithoutCorrection"`
-	Applied     int           `json:"correctionsApplied"`
-	Entries     []ErrataEntry `json:"entries"`
-	Totals      Totals        `json:"totals"`
-	// VerdictChanges are the cases a correction moved between buckets.
-	VerdictChanges []VerdictChange `json:"verdictChanges,omitempty"`
-	Note           string          `json:"note"`
-}
-
-// ErrataEntry is one declared entry with the provenance the report must carry.
-type ErrataEntry struct {
-	ID         string `json:"id"`
-	Path       string `json:"path"`
-	Line       int    `json:"line"`
-	Citation   string `json:"citation"`
-	Corrected  bool   `json:"corrected"`
-	Derivation string `json:"derivation"`
-}
-
-// VerdictChange is one case whose bucket the errata moved.
-type VerdictChange struct {
-	Path      string `json:"path"`
-	Published string `json:"asPublished"`
-	Corrected string `json:"withErrata"`
-}
-
-func newErrataReport(overlay *errata.Overlay) *ErrataReport {
-	report := &ErrataReport{
-		Registry:    len(overlay.Entries()),
-		Corrections: len(overlay.Corrections()),
-		Documented:  len(overlay.Documented()),
-	}
-	for _, entry := range overlay.Entries() {
-		report.Entries = append(report.Entries, ErrataEntry{
-			ID:         entry.ID,
-			Path:       entry.Path,
-			Line:       entry.Line,
-			Citation:   entry.Citation,
-			Corrected:  entry.Corrects(),
-			Derivation: entry.Derivation,
-		})
-	}
-	return report
-}
-
-// runErrata fills the report's errata figure. The hand-written negative corpus
-// is ours, so no declared entry lies in it and the two figures coincide; a run
-// pointed at a published corpus with -corpus adjudicates a corrected copy.
-func runErrata(report *Report, overlay *errata.Overlay, adj adjudication, out string) error {
-	applied := overlay.Under(adj.corpusDir)
-	if len(applied) == 0 {
-		report.Errata.Totals = report.Totals
-		report.Errata.Note = fmt.Sprintf(
-			"no declared correction lies under %s, so the errata-applied corpus is byte-identical to the published one and both figures coincide", adj.corpusDir)
-		return nil
-	}
-
-	corrected := filepath.Join(out, "errata-corpus")
-	if _, err := overlay.Materialize(adj.repo, adj.corpusDir, corrected); err != nil {
-		return err
-	}
-	defer func() {
-		if err := os.RemoveAll(corrected); err != nil {
-			fmt.Fprintf(os.Stderr, "remove the corrected copy: %v\n", err)
-		}
-	}()
-
-	correctedAdj := adj
-	correctedAdj.repo, correctedAdj.corpusDir = corrected, "."
-	cases, err := adjudicate(correctedAdj)
-	if err != nil {
-		return err
-	}
-	published := make(map[string]string, len(report.Cases))
-	for _, c := range report.Cases {
-		published[c.Path] = c.Bucket
-	}
-	for _, rel := range adj.files {
-		report.Errata.Totals.count(cases[rel].Bucket)
-		if was := published[rel]; was != cases[rel].Bucket {
-			report.Errata.VerdictChanges = append(report.Errata.VerdictChanges,
-				VerdictChange{Path: rel, Published: was, Corrected: cases[rel].Bucket})
-		}
-	}
-	report.Errata.Applied = len(applied)
-	report.Errata.Note = "adjudicated again over a corrected copy of the corpus; the published corpus is unchanged on disk"
-	return nil
-}
diff --git a/cmd/pilot-reject/main.go b/cmd/pilot-reject/main.go
deleted file mode 100644
index 6570d26d6c..0000000000
--- a/cmd/pilot-reject/main.go
+++ /dev/null
@@ -1,378 +0,0 @@
-// Command pilot-reject checks the rejection direction the differential cannot
-// see: it validates a hand-written negative corpus — models each violating one
-// named rule — with both this implementation and the OMG SysML v2 Pilot
-// Implementation, and buckets every case by who rejects it. A case the pilot
-// rejects and we accept is a permissiveness gap.
-//
-// It is advisory: nothing in the build or the test suite depends on its
-// verdicts. Provision the reference validators with
-// scripts/download-pilot-sysml-validator.sh and
-// scripts/download-pilot-kerml-validator.sh, then run
-// `go run ./cmd/pilot-reject`. See docs/project/pilot-rejection.md.
-package main
-
-import (
-	"flag"
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-	"time"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-	"github.com/Open-MBEE/OpenSysML/internal/core/conformance"
-	"github.com/Open-MBEE/OpenSysML/internal/core/source"
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// bucket names one quadrant of the two validators' verdicts.
-const (
-	bucketBothReject = "both-reject"
-	bucketPilotOnly  = "pilot-only-rejects"
-	bucketOursOnly   = "ours-only-rejects"
-	bucketBothAccept = "both-accept"
-)
-
-// languageBatch is the corpus files in a single language, in comparison order.
-type languageBatch struct {
-	Kind  source.Kind
-	Files []string
-}
-
-func main() {
-	repo := flag.String("repo", "", "repository root (default: the module root containing this command)")
-	validator := flag.String("validator", "", "pilot SysML validator executable (default: <repo>/build/pilot-sysml-validator/validate-sysml-batch)")
-	kermlValidator := flag.String("kerml-validator", "", "KerML pilot validator executable (default: <repo>/build/pilot-kerml-validator/validate-kerml)")
-	corpus := flag.String("corpus", "", "negative corpus directory (default: <repo>/cmd/pilot-reject/testdata/negative)")
-	out := flag.String("out", "", "output directory for the reports (default: <repo>/build/pilot-reject)")
-	timeout := flag.Duration("timeout", 0, "per-batch timeout for the pilot validator (0: no limit)")
-	policy := flag.String("conformance", policyAuto,
-		"conformance mode our verdicts are taken under: auto (extensions/ strictly, the rest by default), default, or strict")
-	update := flag.Bool("update", false, "record this run as "+committedBaseline)
-	check := flag.Bool("check", false, "fail unless this run reproduces "+committedBaseline)
-	flag.Parse()
-
-	opts := options{
-		repo:           *repo,
-		validator:      *validator,
-		kermlValidator: *kermlValidator,
-		corpus:         *corpus,
-		out:            *out,
-		policy:         *policy,
-		timeout:        *timeout,
-		update:         *update,
-		check:          *check,
-	}
-	if err := run(opts); err != nil {
-		fmt.Fprintf(os.Stderr, "pilot-reject: %v\n", err)
-		os.Exit(1)
-	}
-}
-
-// options is one run's command line.
-type options struct {
-	repo           string
-	validator      string
-	kermlValidator string
-	corpus         string
-	out            string
-	policy         string
-	timeout        time.Duration
-	update         bool
-	check          bool
-}
-
-// adjudication is the corpus one run buckets, and the tools it buckets it with.
-type adjudication struct {
-	repo           string
-	corpusDir      string
-	policy         string
-	validator      string
-	kermlValidator string
-	files          []string
-	batches        []languageBatch
-	timeout        time.Duration
-}
-
-func run(opts options) error {
-	policy, err := parsePolicy(opts.policy)
-	if err != nil {
-		return err
-	}
-	repo := opts.repo
-	if repo == "" {
-		repo, err = moduleRoot()
-		if err != nil {
-			return err
-		}
-	}
-	validator, kermlValidator := opts.validator, opts.kermlValidator
-	if validator == "" {
-		validator = filepath.Join(repo, "build", "pilot-sysml-validator", "validate-sysml-batch")
-	}
-	if kermlValidator == "" {
-		kermlValidator = filepath.Join(repo, "build", "pilot-kerml-validator", "validate-kerml")
-	}
-	corpusDir := "cmd/pilot-reject/testdata/negative"
-	if opts.corpus != "" {
-		corpusDir = relativeTo(repo, opts.corpus)
-	}
-	out := opts.out
-	if out == "" {
-		out = filepath.Join(repo, "build", "pilot-reject")
-	}
-	files, err := collectCases(repo, corpusDir)
-	if err != nil {
-		return err
-	}
-	if len(files) == 0 {
-		return fmt.Errorf("no .sysml or .kerml files under %s", corpusDir)
-	}
-
-	batches := batchByLanguage(files)
-	for _, batch := range batches {
-		if _, err := os.Stat(pilotFor(batch.Kind, validator, kermlValidator)); err != nil {
-			if batch.Kind == source.KindKerML {
-				return fmt.Errorf("KerML pilot validator not found at %s: run ./scripts/download-pilot-kerml-validator.sh", kermlValidator)
-			}
-			return fmt.Errorf("pilot validator not found at %s: run ./scripts/download-pilot-sysml-validator.sh", validator)
-		}
-	}
-
-	adj := adjudication{
-		repo:           repo,
-		corpusDir:      corpusDir,
-		policy:         policy,
-		validator:      validator,
-		kermlValidator: kermlValidator,
-		files:          files,
-		batches:        batches,
-		timeout:        opts.timeout,
-	}
-	cases, err := adjudicate(adj)
-	if err != nil {
-		return err
-	}
-
-	overlay, err := errata.Load()
-	if err != nil {
-		return err
-	}
-
-	report := &Report{
-		Validator:   relativeTo(repo, validator),
-		Corpus:      corpusDir,
-		Conformance: policy,
-		Errata:      newErrataReport(overlay),
-	}
-	if report.Pilot, err = pilotVersion(validator); err != nil {
-		return err
-	}
-	if report.Provenance, err = provenance(repo, report.Pilot, corpusDir, overlay, files); err != nil {
-		return err
-	}
-	// Only a recorded baseline is dated, so two plain runs stay byte-identical.
-	if opts.update {
-		report.Provenance.Recorded = baseline.Today()
-	}
-	for _, rel := range files {
-		report.Cases = append(report.Cases, *cases[rel])
-	}
-	report.summarize()
-
-	if err := runErrata(report, overlay, adj, out); err != nil {
-		return err
-	}
-	fresh, err := writeReports(out, report)
-	if err != nil {
-		return err
-	}
-	committed := filepath.Join(repo, filepath.FromSlash(committedBaseline))
-	if opts.update {
-		return baseline.Write(committed, fresh)
-	}
-	if opts.check {
-		return baseline.Reproduces(committed, fresh)
-	}
-	return nil
-}
-
-// adjudicate buckets every case of a corpus directory: ours in the policy's
-// mode, ours in the default mode, and the pilot's, per language batch.
-func adjudicate(adj adjudication) (map[string]*Case, error) {
-	files := adj.files
-	cases := make(map[string]*Case, len(files))
-	for _, rel := range files {
-		c, err := readCase(adj.repo, adj.corpusDir, rel)
-		if err != nil {
-			return nil, err
-		}
-		c.Mode = modeFor(adj.policy, c.Source).String()
-		cases[rel] = c
-	}
-	modes := make(map[string]conformance.Mode, len(cases))
-	// The default mode is evaluated for every case as well, so a case asked
-	// strictly reports what the default mode says instead of implying it agreed.
-	defaults := make(map[string]conformance.Mode, len(cases))
-	for rel, c := range cases {
-		modes[rel] = modeFor(adj.policy, c.Source)
-		defaults[rel] = conformance.ModeDefault
-	}
-
-	for _, batch := range adj.batches {
-		fmt.Fprintf(os.Stderr, "negative corpus: %d %s case(s)\n", len(batch.Files), batch.Kind)
-		pilot := pilotFor(batch.Kind, adj.validator, adj.kermlValidator)
-		ours, err := openSysMLErrors(adj.repo, adj.corpusDir, batch.Files, modes)
-		if err != nil {
-			return nil, err
-		}
-		oursDefault, err := openSysMLErrors(adj.repo, adj.corpusDir, batch.Files, defaults)
-		if err != nil {
-			return nil, err
-		}
-		theirs, err := pilotErrors(pilot, adj.repo, adj.corpusDir, batch.Files, adj.timeout)
-		if err != nil {
-			return nil, err
-		}
-		for _, rel := range batch.Files {
-			classify(cases[rel], ours[rel], oursDefault[rel], theirs[rel])
-		}
-	}
-	return cases, nil
-}
-
-// pilotFor picks the reference validator for a language batch.
-func pilotFor(kind source.Kind, validator, kermlValidator string) string {
-	if kind == source.KindKerML {
-		return kermlValidator
-	}
-	return validator
-}
-
-// classify fills the case's verdicts. A side rejects when it reports at least
-// one error-severity diagnostic; warnings do not count. oursDefault is what the
-// default mode said, recorded for a case asked strictly so that a strict
-// agreement does not read as a default one.
-func classify(c *Case, ours, oursDefault, theirs []string) {
-	c.OursErrors = len(ours)
-	c.PilotErrors = len(theirs)
-	c.Bucket = bucketOf(len(ours), len(theirs))
-	switch c.Bucket {
-	case bucketPilotOnly:
-		c.Pilot = theirs
-	case bucketOursOnly:
-		c.Ours = ours
-	}
-	if c.Mode == conformance.ModeStrict.String() {
-		c.DefaultErrors = len(oursDefault)
-		c.DefaultBucket = bucketOf(len(oursDefault), len(theirs))
-	}
-}
-
-// bucketOf names the quadrant a pair of error counts falls in.
-func bucketOf(ours, theirs int) string {
-	switch {
-	case theirs > 0 && ours > 0:
-		return bucketBothReject
-	case theirs > 0:
-		return bucketPilotOnly
-	case ours > 0:
-		return bucketOursOnly
-	default:
-		return bucketBothAccept
-	}
-}
-
-// readCase reads one corpus file and its mandatory header: the first line must
-// state the violated rule and its citation, so no case is anecdotal.
-func readCase(repo, dir, rel string) (*Case, error) {
-	// #nosec G304 -- the corpus root to validate is named on the command line.
-	content, err := os.ReadFile(filepath.Join(repo, dir, filepath.FromSlash(rel)))
-	if err != nil {
-		return nil, fmt.Errorf("read %s: %w", rel, err)
-	}
-	first, _, _ := strings.Cut(string(content), "\n")
-	rule, ok := strings.CutPrefix(first, "// Invalid: ")
-	if !ok {
-		return nil, fmt.Errorf("%s: first line must be `// Invalid: <rule> (<citation>).`", rel)
-	}
-	src, _, _ := strings.Cut(rel, "/")
-	return &Case{Path: rel, Source: src, Rule: strings.TrimSpace(rule)}, nil
-}
-
-func relativeTo(repo, path string) string {
-	rel, err := filepath.Rel(repo, path)
-	if err != nil || strings.HasPrefix(rel, "..") {
-		return path
-	}
-	return filepath.ToSlash(rel)
-}
-
-// moduleRoot walks up from the working directory to the directory holding go.mod.
-func moduleRoot() (string, error) {
-	dir, err := os.Getwd()
-	if err != nil {
-		return "", err
-	}
-	for {
-		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
-			return dir, nil
-		}
-		parent := filepath.Dir(dir)
-		if parent == dir {
-			return "", fmt.Errorf("no go.mod found above the working directory; pass -repo")
-		}
-		dir = parent
-	}
-}
-
-// batchByLanguage splits the corpus into one batch per language, SysML first.
-// Each language runs against its own reference validator; the cases are
-// mutually independent, so batching only amortizes the validator's startup.
-func batchByLanguage(files []string) []languageBatch {
-	batches := []languageBatch{{Kind: source.KindSysML}, {Kind: source.KindKerML}}
-	for _, rel := range files {
-		for i := range batches {
-			if batches[i].Kind == source.KindOf(rel) {
-				batches[i].Files = append(batches[i].Files, rel)
-			}
-		}
-	}
-	out := make([]languageBatch, 0, len(batches))
-	for _, batch := range batches {
-		if len(batch.Files) > 0 {
-			out = append(out, batch)
-		}
-	}
-	return out
-}
-
-// collectCases returns the corpus files, in either language, as sorted
-// slash-separated paths relative to the corpus directory.
-func collectCases(repo, dir string) ([]string, error) {
-	root := filepath.Join(repo, filepath.FromSlash(dir))
-	var files []string
-	err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
-		if err != nil {
-			return err
-		}
-		if entry.IsDir() {
-			return nil
-		}
-		if source.KindOf(path) == source.KindUnknown {
-			return nil
-		}
-		rel, err := filepath.Rel(root, path)
-		if err != nil {
-			return err
-		}
-		files = append(files, filepath.ToSlash(rel))
-		return nil
-	})
-	if err != nil {
-		return nil, fmt.Errorf("scan %s: %w", root, err)
-	}
-	sort.Strings(files)
-	return files, nil
-}
diff --git a/cmd/pilot-reject/provenance.go b/cmd/pilot-reject/provenance.go
deleted file mode 100644
index 8e45c19c76..0000000000
--- a/cmd/pilot-reject/provenance.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"path/filepath"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// committedBaseline is the record docs/project/pilot-rejection.md is generated
-// from, and refreshCommand is the only supported way to re-record it.
-const (
-	committedBaseline = "docs/project/pilot-rejection-baseline.json"
-	refreshCommand    = "go run ./cmd/pilot-reject -update"
-)
-
-// provenance identifies the pin, the reference bridges and the negative corpus
-// this run adjudicated. The corpus is ours, so its movement is a movement to
-// adjudicate rather than a provisioning defect.
-func provenance(repo, release, corpusDir string, overlay *errata.Overlay, files []string) (baseline.Record, error) {
-	pin, err := baseline.ReadPin(repo)
-	if err != nil {
-		return baseline.Record{}, err
-	}
-	if release == "" {
-		release = pin.Release()
-	}
-	tools, err := baseline.Bridges(repo, release)
-	if err != nil {
-		return baseline.Record{}, err
-	}
-	digest, err := baseline.DigestFiles(filepath.Join(repo, filepath.FromSlash(corpusDir)), files)
-	if err != nil {
-		return baseline.Record{}, fmt.Errorf("digest %s: %w", corpusDir, err)
-	}
-	return baseline.Record{
-		PilotTag:      pin.Tag,
-		PilotCommit:   pin.Commit,
-		PilotArtifact: pin.Artifact,
-		Errata:        baseline.ErrataDigest(overlay.Entries()),
-		Tools:         tools,
-		Inputs: []baseline.Input{{
-			Name:   "negative-corpus",
-			Dir:    corpusDir,
-			Origin: baseline.OriginOurs,
-			Files:  len(files),
-			Digest: digest,
-		}},
-	}, nil
-}
diff --git a/cmd/pilot-reject/provenance_test.go b/cmd/pilot-reject/provenance_test.go
deleted file mode 100644
index 0e6cead3be..0000000000
--- a/cmd/pilot-reject/provenance_test.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package main
-
-import (
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// The committed baseline must state the pin, the bridges and the negative corpus
-// its run measured, and they must still be this repository's. Needs no Java.
-func TestCommittedBaselineStatesThisRepositorysProvenance(t *testing.T) {
-	repo, current := currentProvenance(t)
-	path := filepath.Join(repo, filepath.FromSlash(committedBaseline))
-	if err := baseline.CheckCommitted(path, refreshCommand, current); err != nil {
-		t.Fatal(err)
-	}
-}
-
-// The corpus is ours, so a baseline recorded against a different one must fail
-// as a movement to adjudicate, not as a provisioning defect.
-func TestProvenanceGuardFailsOnAMovedCorpus(t *testing.T) {
-	_, current := currentProvenance(t)
-	corrupted := corruptBaseline(t, committedBaseline, current.Inputs[0].Digest, "sha256:"+strings.Repeat("0", 64))
-
-	err := baseline.CheckCommitted(corrupted, refreshCommand, current)
-	if err == nil {
-		t.Fatal("a baseline recorded against another corpus was accepted")
-	}
-	for _, want := range []string{
-		"provenance.inputs[negative-corpus].digest",
-		current.Inputs[0].Digest,
-		"material this repository owns has changed",
-		refreshCommand,
-	} {
-		if !strings.Contains(err.Error(), want) {
-			t.Errorf("the failure does not mention %q:\n%s", want, err)
-		}
-	}
-}
-
-// A baseline that states no provenance at all is the stale baseline this guard
-// exists to catch, so it must fail rather than pass vacuously.
-func TestProvenanceGuardFailsOnAnUnstatedProvenance(t *testing.T) {
-	_, current := currentProvenance(t)
-	corrupted := corruptBaseline(t, committedBaseline, `"recorded"`, `"recordedOn"`)
-
-	err := baseline.CheckCommitted(corrupted, refreshCommand, current)
-	if err == nil || !strings.Contains(err.Error(), "states no ISO recording date") {
-		t.Fatalf("an undated baseline gave %v", err)
-	}
-}
-
-// currentProvenance is the repository's provenance as it stands, resolved from
-// the pin rather than from a provisioned validator.
-func currentProvenance(t *testing.T) (string, baseline.Record) {
-	t.Helper()
-	repo, err := moduleRoot()
-	if err != nil {
-		t.Fatal(err)
-	}
-	overlay, err := errata.Load()
-	if err != nil {
-		t.Fatal(err)
-	}
-	corpusDir := "cmd/pilot-reject/testdata/negative"
-	files, err := collectCases(repo, corpusDir)
-	if err != nil {
-		t.Fatal(err)
-	}
-	current, err := provenance(repo, "", corpusDir, overlay, files)
-	if err != nil {
-		t.Fatal(err)
-	}
-	return repo, current
-}
-
-// corruptBaseline copies a committed baseline with one substitution applied, so
-// the guard is exercised against a damaged field without touching the record.
-func corruptBaseline(t *testing.T, rel, was, now string) string {
-	t.Helper()
-	repo, err := moduleRoot()
-	if err != nil {
-		t.Fatal(err)
-	}
-	content, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(rel)))
-	if err != nil {
-		t.Fatal(err)
-	}
-	corrupted := strings.Replace(string(content), was, now, 1)
-	if corrupted == string(content) {
-		t.Fatalf("%s contains no %s to corrupt", rel, was)
-	}
-	path := filepath.Join(t.TempDir(), filepath.Base(rel))
-	if err := os.WriteFile(path, []byte(corrupted), 0o600); err != nil {
-		t.Fatal(err)
-	}
-	return path
-}
diff --git a/cmd/pilot-reject/report.go b/cmd/pilot-reject/report.go
deleted file mode 100644
index 9b609f6282..0000000000
--- a/cmd/pilot-reject/report.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-)
-
-// Case is one negative model's adjudication. The error messages are kept only
-// for the disagreeing buckets, where they are the evidence.
-type Case struct {
-	Path   string `json:"path"`
-	Source string `json:"source"`
-	Rule   string `json:"rule"`
-	// Mode is the conformance mode our verdict was taken under.
-	Mode        string `json:"mode"`
-	Bucket      string `json:"bucket"`
-	OursErrors  int    `json:"oursErrors"`
-	PilotErrors int    `json:"pilotErrors"`
-	// DefaultBucket and DefaultErrors are what the default mode says, recorded
-	// only for a case asked strictly: agreement there is not agreement here.
-	DefaultBucket string   `json:"defaultBucket,omitempty"`
-	DefaultErrors int      `json:"defaultErrors,omitempty"`
-	Ours          []string `json:"ours,omitempty"`
-	Pilot         []string `json:"pilot,omitempty"`
-}
-
-// Totals is one scope's bucket counts. PilotOnlyRejects are the permissiveness
-// gaps this harness exists to surface.
-type Totals struct {
-	Cases            int `json:"cases"`
-	BothReject       int `json:"bothReject"`
-	PilotOnlyRejects int `json:"pilotOnlyRejects"`
-	OursOnlyRejects  int `json:"oursOnlyRejects"`
-	BothAccept       int `json:"bothAccept"`
-}
-
-// SourceTotals is the bucket counts for one derivation source of the corpus.
-type SourceTotals struct {
-	Source string `json:"source"`
-	Totals
-}
-
-// Report is the whole run. It carries no timestamp and no absolute path, so
-// two runs over the same pin produce byte-identical output.
-type Report struct {
-	Pilot     string `json:"pilot"`
-	Validator string `json:"validator"`
-	Corpus    string `json:"corpus"`
-	// Conformance is the policy our verdicts were taken under.
-	Conformance string `json:"conformance"`
-	// Provenance identifies the pin, bridges and corpus this run measured, so a
-	// committed baseline can be checked without the validators.
-	Provenance baseline.Record `json:"provenance"`
-	Totals     Totals          `json:"totals"`
-	// StrictOnlyAgreements are the cases both sides reject only because ours was
-	// asked strictly: the default mode accepts them, by design.
-	StrictOnlyAgreements []string       `json:"strictOnlyAgreements,omitempty"`
-	Sources              []SourceTotals `json:"sources"`
-	Cases                []Case         `json:"cases"`
-	// Errata is the same run with the declared corrections applied; Totals
-	// above stays the as-published headline.
-	Errata *ErrataReport `json:"errata,omitempty"`
-}
-
-func (t *Totals) count(bucket string) {
-	t.Cases++
-	switch bucket {
-	case bucketBothReject:
-		t.BothReject++
-	case bucketPilotOnly:
-		t.PilotOnlyRejects++
-	case bucketOursOnly:
-		t.OursOnlyRejects++
-	case bucketBothAccept:
-		t.BothAccept++
-	}
-}
-
-// summarize aggregates the totals and per-source totals from the cases.
-func (r *Report) summarize() {
-	bySource := map[string]*SourceTotals{}
-	var names []string
-	for _, c := range r.Cases {
-		r.Totals.count(c.Bucket)
-		if c.Bucket == bucketBothReject && c.DefaultBucket == bucketPilotOnly {
-			r.StrictOnlyAgreements = append(r.StrictOnlyAgreements, c.Path)
-		}
-		st := bySource[c.Source]
-		if st == nil {
-			st = &SourceTotals{Source: c.Source}
-			bySource[c.Source] = st
-			names = append(names, c.Source)
-		}
-		st.count(c.Bucket)
-	}
-	sort.Strings(names)
-	for _, name := range names {
-		r.Sources = append(r.Sources, *bySource[name])
-	}
-}
-
-func writeReports(dir string, report *Report) ([]byte, error) {
-	if err := os.MkdirAll(dir, 0o750); err != nil {
-		return nil, err
-	}
-	encoded, err := json.MarshalIndent(report, "", "  ")
-	if err != nil {
-		return nil, err
-	}
-	encoded = append(encoded, '\n')
-	jsonPath := filepath.Join(dir, "pilot-reject.json")
-	if err := os.WriteFile(jsonPath, encoded, 0o600); err != nil {
-		return nil, err
-	}
-	textPath := filepath.Join(dir, "pilot-reject.txt")
-	if err := os.WriteFile(textPath, []byte(renderText(report)), 0o600); err != nil {
-		return nil, err
-	}
-
-	fmt.Fprintf(os.Stderr, "wrote %s and %s\n", textPath, jsonPath)
-	fmt.Fprintf(os.Stderr, "%s\n", headline(report.Totals))
-	return encoded, nil
-}
-
-func headline(t Totals) string {
-	return fmt.Sprintf("%d case(s): %d both reject, %d only the pilot rejects, %d only we reject, %d both accept",
-		t.Cases, t.BothReject, t.PilotOnlyRejects, t.OursOnlyRejects, t.BothAccept)
-}
-
-// writeErrata states the second figure after the headline, never instead of it.
-func writeErrata(b *strings.Builder, report *ErrataReport) {
-	if report == nil {
-		return
-	}
-	fmt.Fprintf(b, "\ndeclared errata: %d entr(ies), %d correction(s), %d documented without one; %d applied here\n",
-		report.Registry, report.Corrections, report.Documented, report.Applied)
-	for _, entry := range report.Entries {
-		shape := "documented, no substitution"
-		if entry.Corrected {
-			shape = "corrected"
-		}
-		fmt.Fprintf(b, "  %s %s:%d (%s) — %s\n", entry.ID, entry.Path, entry.Line, entry.Citation, shape)
-	}
-	fmt.Fprintf(b, "  %s\n", report.Note)
-	fmt.Fprintf(b, "TOTAL with errata applied: %s\n", headline(report.Totals))
-	for _, change := range report.VerdictChanges {
-		fmt.Fprintf(b, "  %s: %s -> %s\n", change.Path, change.Published, change.Corrected)
-	}
-}
-
-func renderText(report *Report) string {
-	var b strings.Builder
-	b.WriteString("OpenSysML vs the OMG pilot implementation over a hand-written negative corpus\n")
-	fmt.Fprintf(&b, "pilot pin: %s\nvalidator: %s\ncorpus:    %s\nour mode:  %s\n\n",
-		report.Pilot, report.Validator, report.Corpus, report.Conformance)
-	fmt.Fprintf(&b, "TOTAL: %s\n", headline(report.Totals))
-	fmt.Fprintf(&b, "  of which %d agree only because we were asked strictly (the default mode accepts them, by design)\n",
-		len(report.StrictOnlyAgreements))
-	fmt.Fprintf(&b, "  %-12s %6s %11s %10s %9s %11s\n", "source", "cases", "both-reject", "pilot-only", "ours-only", "both-accept")
-	for _, st := range report.Sources {
-		fmt.Fprintf(&b, "  %-12s %6d %11d %10d %9d %11d\n",
-			st.Source, st.Cases, st.BothReject, st.PilotOnlyRejects, st.OursOnlyRejects, st.BothAccept)
-	}
-	writeErrata(&b, report.Errata)
-
-	for _, c := range report.Cases {
-		fmt.Fprintf(&b, "\n%s\n", c.Path)
-		fmt.Fprintf(&b, "  rule:   %s\n", c.Rule)
-		fmt.Fprintf(&b, "  bucket: %s (ours %d error(s) in %s mode, pilot %d error(s))\n",
-			c.Bucket, c.OursErrors, c.Mode, c.PilotErrors)
-		if c.DefaultBucket != "" {
-			fmt.Fprintf(&b, "  default mode: %s (ours %d error(s))\n", c.DefaultBucket, c.DefaultErrors)
-		}
-		for _, msg := range c.Pilot {
-			fmt.Fprintf(&b, "  pilot: %s\n", msg)
-		}
-		for _, msg := range c.Ours {
-			fmt.Fprintf(&b, "  ours:  %s\n", msg)
-		}
-	}
-	return b.String()
-}
diff --git a/cmd/pilot-reject/verdicts.go b/cmd/pilot-reject/verdicts.go
deleted file mode 100644
index 0029ef822c..0000000000
--- a/cmd/pilot-reject/verdicts.go
+++ /dev/null
@@ -1,147 +0,0 @@
-package main
-
-import (
-	"bufio"
-	"context"
-	"fmt"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"regexp"
-	"strings"
-	"time"
-
-	"github.com/Open-MBEE/OpenSysML/internal/core/conformance"
-	"github.com/Open-MBEE/OpenSysML/internal/core/model"
-	"github.com/Open-MBEE/OpenSysML/internal/core/source"
-)
-
-// pilotLine matches the GNU-format diagnostics the validators print on stderr.
-var pilotLine = regexp.MustCompile(`^(.+\.(?:sysml|kerml)):(\d+):(\d+): (error|warning|info|ignore): (.*)$`)
-
-// pilotVersion reports which pilot release the validator was built against,
-// read from the pin its provisioning script wrote beside it.
-func pilotVersion(validator string) (string, error) {
-	pin, err := os.ReadFile(filepath.Join(filepath.Dir(validator), "pilot-pin.txt"))
-	if err != nil {
-		return "", fmt.Errorf("read the validator's pilot-pin.txt: %w", err)
-	}
-	tag := pinnedValue(string(pin), "sysml.release.tag")
-	version := pinnedValue(string(pin), "sysml.artifact.version")
-	if tag == "" || version == "" {
-		return "", fmt.Errorf("%s does not pin sysml.release.tag/sysml.artifact.version", validator)
-	}
-	return fmt.Sprintf("%s (jupyter-sysml-kernel %s)", tag, version), nil
-}
-
-func pinnedValue(pin, name string) string {
-	for _, line := range strings.Split(pin, "\n") {
-		key, value, ok := strings.Cut(line, "=")
-		if ok && strings.TrimSpace(key) == name {
-			return strings.TrimSpace(value)
-		}
-	}
-	return ""
-}
-
-// openSysMLErrors validates each case in its own workspace — the cases are
-// independent single files — under the conformance mode modes names for it, and
-// returns our error-severity messages per file.
-func openSysMLErrors(repo, dir string, files []string,
-	modes map[string]conformance.Mode) (map[string][]string, error) {
-	out := make(map[string][]string, len(files))
-	for _, rel := range files {
-		// #nosec G304 -- the corpus directory is named on the command line.
-		content, err := os.ReadFile(filepath.Join(repo, dir, filepath.FromSlash(rel)))
-		if err != nil {
-			return nil, fmt.Errorf("read %s: %w", rel, err)
-		}
-		ws := model.NewWorkspace(model.WithConformanceMode(modes[rel]))
-		ws.Open(rel, content, 1)
-		lines := source.New(rel, content).Lines()
-		for _, d := range ws.Diagnostics(rel) {
-			if d.Severity.String() != "error" {
-				continue
-			}
-			out[rel] = append(out[rel], fmt.Sprintf("line %d: %s", lines.PosAt(d.Span.Offset).Line, d.Message))
-		}
-	}
-	return out, nil
-}
-
-// pilotErrors runs a reference validator over the corpus as a single batch and
-// returns its error-severity messages per file. Both validators report each
-// diagnostic under its path relative to --root.
-func pilotErrors(validator, repo, dir string, files []string, timeout time.Duration) (map[string][]string, error) {
-	root, err := filepath.Abs(filepath.Join(repo, dir))
-	if err != nil {
-		return nil, fmt.Errorf("resolve corpus root: %w", err)
-	}
-
-	byPath := make(map[string]bool, len(files))
-	args := []string{"--root", root}
-	for _, rel := range files {
-		byPath[rel] = true
-		args = append(args, filepath.Join(root, filepath.FromSlash(rel)))
-	}
-
-	ctx := context.Background()
-	if timeout > 0 {
-		var cancel context.CancelFunc
-		ctx, cancel = context.WithTimeout(ctx, timeout)
-		defer cancel()
-	}
-
-	// #nosec G204 -- the validator to run is named on the command line.
-	cmd := exec.CommandContext(ctx, validator, args...)
-	cmd.Stdout = nil // "Reading <library file>..." progress noise
-	stderr, err := cmd.StderrPipe()
-	if err != nil {
-		return nil, err
-	}
-	if err := cmd.Start(); err != nil {
-		return nil, fmt.Errorf("start %s: %w", validator, err)
-	}
-
-	out := make(map[string][]string, len(files))
-	var unattributed []string
-	scanner := bufio.NewScanner(stderr)
-	scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
-	for scanner.Scan() {
-		line := scanner.Text()
-		match := pilotLine.FindStringSubmatch(line)
-		if match == nil {
-			if strings.TrimSpace(line) != "" && !strings.HasPrefix(line, "log4j:") {
-				unattributed = append(unattributed, line)
-			}
-			continue
-		}
-		if !byPath[match[1]] {
-			unattributed = append(unattributed, line)
-			continue
-		}
-		if match[4] != "error" {
-			continue
-		}
-		out[match[1]] = append(out[match[1]], fmt.Sprintf("line %s: %s", match[2], match[5]))
-	}
-	if err := scanner.Err(); err != nil {
-		return nil, fmt.Errorf("read the validator's output: %w", err)
-	}
-
-	// Exit 1 only means the batch had errors; anything else is the validator
-	// itself failing, and its verdicts cannot be trusted.
-	err = cmd.Wait()
-	if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
-		err = nil
-	}
-	if err != nil {
-		return nil, fmt.Errorf("%s failed (%w); stderr:\n%s", validator, err, strings.Join(unattributed, "\n"))
-	}
-	for _, line := range unattributed {
-		// Never dropped silently: an unattributable error would otherwise
-		// look like an acceptance.
-		fmt.Fprintf(os.Stderr, "pilot output not attributable to a corpus case: %s\n", line)
-	}
-	return out, nil
-}
diff --git a/cmd/pilot-xpect/compare.go b/cmd/pilot-xpect/compare.go
deleted file mode 100644
index 6f36d57d79..0000000000
--- a/cmd/pilot-xpect/compare.go
+++ /dev/null
@@ -1,522 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"regexp"
-	"sort"
-	"strings"
-
-	"github.com/Open-MBEE/OpenSysML/internal/core/model"
-	"github.com/Open-MBEE/OpenSysML/internal/core/passes"
-	"github.com/Open-MBEE/OpenSysML/internal/core/resolve"
-	"github.com/Open-MBEE/OpenSysML/internal/core/source"
-	"github.com/Open-MBEE/OpenSysML/internal/core/symbols"
-)
-
-// Verdicts. Agreement is strict: the declared severity at the declared offset,
-// with the declared message. Tolerance records what a weaker rule would have
-// accepted, and never turns a disagreement into an agreement.
-const (
-	verdictAgree = "agree"
-	// verdictWordingOnly is the same rule about the same element in our own
-	// words; it is counted inside agreement, with its own sub-count.
-	verdictWordingOnly    = "wording-only"
-	verdictDisagree       = "disagree"
-	verdictUnlocated      = "unlocated"
-	verdictNotAdjudicated = "not-adjudicated"
-)
-
-// Tolerances, reported as secondary columns beside a strict disagreement.
-const (
-	toleranceNone     = ""
-	toleranceMessage  = "same-location"     // right severity and offset, other wording
-	toleranceLine     = "same-line"         // right severity on the declared line
-	toleranceSeverity = "severity-differs"  // a diagnostic there, of the other severity
-	toleranceAnywhere = "elsewhere-in-file" // right severity, but not near the declaration
-)
-
-// Scope tolerances. A scope assertion declares a whole set, so a near-miss is
-// classified by which half of the set differs.
-const (
-	toleranceScopeSpelling = "other-paths"       // every declared element, some under another path
-	toleranceScopeExtra    = "extra-names"       // every declared name, and more besides
-	toleranceScopeMissing  = "missing-names"     // declared names we do not offer at all
-	toleranceScopeBoth     = "missing-and-extra" // both halves differ
-	// library-names: differs only in names inherited from the standard library,
-	// which a fixture sees only if its resource set loads it.
-	toleranceScopeLibrary = "library-names"
-)
-
-// row is one adjudicated expectation: one item of an errors/warnings assertion,
-// or one whole noErrors/linkedName assertion.
-type row struct {
-	Kind string `json:"kind"`
-	// Block records a `//* ... */` note, which the brief's line-anchored census
-	// of the suites does not count.
-	Block     bool   `json:"block,omitempty"`
-	Line      int    `json:"line"`
-	At        string `json:"at,omitempty"`
-	Verdict   string `json:"verdict"`
-	Tolerance string `json:"tolerance,omitempty"`
-	// Rule names the rule a wording-only row shows both sides stating.
-	Rule     string `json:"rule,omitempty"`
-	Declared string `json:"declared,omitempty"`
-	Actual   string `json:"ours,omitempty"`
-	// Names counts a scope assertion's set differences.
-	Names *scopeCounts `json:"names,omitempty"`
-}
-
-// fileResult is one .xt file's adjudication.
-type fileResult struct {
-	Path       string `json:"path"`
-	SetupClass string `json:"setupClass"`
-	// Expectations and Agree summarize Rows, which the report prunes to the
-	// rows that are not agreements.
-	Expectations int      `json:"expectations"`
-	Agree        int      `json:"agree"`
-	Rows         []row    `json:"rows,omitempty"`
-	Problems     []string `json:"problems,omitempty"`
-	// Ignored lists XPECT-shaped text that opens no note, so it is not run.
-	Ignored []string `json:"ignored,omitempty"`
-	// Missing lists declared resources that are absent from the download.
-	Missing []string `json:"missing,omitempty"`
-	// Foreign counts diagnostics dropped as another resource's, see elsewhere.
-	Foreign int `json:"foreignDiagnostics,omitempty"`
-}
-
-// diag is one of our diagnostics, reduced to what an assertion declares.
-type diag struct {
-	Offset   int
-	Line     int
-	Severity string
-	Message  string
-}
-
-// foreignDiagnostic reports whether a diagnostic lands outside the file's model
-// text — inside a note or past its end — so it was raised for another declared
-// resource and is not the file's to answer for.
-func foreignDiagnostic(f xtFile, offset int) bool {
-	if offset < 0 || offset >= len(f.Noted) {
-		return true
-	}
-	return f.Noted[offset]
-}
-
-// compareFile loads the resource set the file's setup declares and adjudicates
-// every assertion in it.
-func compareFile(suiteDir string, f xtFile, libs *libraryCache) fileResult {
-	res := fileResult{Path: f.Path, SetupClass: f.SetupClass, Problems: f.Problems, Ignored: f.Ignored}
-	if len(f.Problems) > 0 {
-		return res
-	}
-
-	set := loadResourceSet(suiteDir, f, libs)
-	ws, libraryRoots := set.ws, set.libraryRoots
-	res.Missing = set.missing
-	main := strings.TrimSuffix(f.Path, ".xt")
-
-	src := squeeze(f.Masked)
-	lines := source.New(main, f.Content).Lines()
-	var diags []diag
-	for _, d := range ws.Diagnostics(main) {
-		if foreignDiagnostic(f, d.Span.Offset) {
-			res.Foreign++
-			continue
-		}
-		diags = append(diags, diag{
-			Offset:   d.Span.Offset,
-			Line:     lines.PosAt(d.Span.Offset).Line,
-			Severity: severityName(d.Severity),
-			Message:  d.Message,
-		})
-	}
-
-	// Xpect matches every issue against the expectations' regions and fails the
-	// file on the ones left over, so an error another expectation declares is
-	// not the file's residue.
-	consumed := map[int]bool{}
-	for _, a := range f.Assertions {
-		if a.Kind != kindErrors && a.Kind != kindNoErrors {
-			continue
-		}
-		if l := consumedLine(f.Content, lines, a.Region); l > 0 {
-			consumed[l] = true
-		}
-	}
-
-	subject := adjudicand{
-		ws: ws, main: main, diags: diags, lines: lines,
-		src: src, libraryRoots: libraryRoots, consumed: consumed,
-	}
-	for _, a := range f.Assertions {
-		own := consumedLine(f.Content, lines, a.Region)
-		res.Rows = append(res.Rows, subject.adjudicate(a, own)...)
-	}
-	return res
-}
-
-// consumedLine is the line an assertion's issues are matched in: the first line
-// of model text after its note.
-func consumedLine(content []byte, lines *source.LineIndex, region int) int {
-	for i := region; i < len(content); i++ {
-		switch content[i] {
-		case ' ', '\t', '\r', '\n':
-		default:
-			return lines.PosAt(i).Line
-		}
-	}
-	return -1
-}
-
-// adjudicand is one file's loaded resources and diagnostics, against which its
-// assertions are adjudicated.
-type adjudicand struct {
-	ws           *model.Workspace
-	main         string
-	diags        []diag
-	lines        *source.LineIndex
-	src          squeezed
-	libraryRoots []string
-	consumed     map[int]bool
-}
-
-// adjudicate turns one assertion into its rows.
-func (s adjudicand) adjudicate(a assertion, own int) []row {
-	ws, main, diags, lines, src := s.ws, s.main, s.diags, s.lines, s.src
-	libraryRoots, consumed := s.libraryRoots, s.consumed
-	switch a.Kind {
-	case kindErrors, kindWarnings:
-		want := "error"
-		if a.Kind == kindWarnings {
-			want = "warning"
-		}
-		rows := make([]row, 0, len(a.Expect))
-		for _, item := range a.Expect {
-			rows = append(rows, diagnosticRow(a, item, want, diags, lines, src))
-
-		}
-		return rows
-	case kindNoErrors:
-		var errs []diag
-		for _, d := range diags {
-			if d.Severity != "error" {
-				continue
-			}
-			// The assertion's own line is the one it declares clean; elsewhere
-			// only an error no expectation declares is its to answer for.
-			if d.Line != own && consumed[d.Line] {
-				continue
-			}
-			errs = append(errs, d)
-		}
-		r := row{Kind: a.Kind, Block: a.Block, Line: a.Line, Verdict: verdictAgree, Declared: "no error the file does not declare"}
-		if len(errs) > 0 {
-			r.Verdict = verdictDisagree
-			r.Actual = fmt.Sprintf("%d error(s), first: line %d: %s", len(errs), errs[0].Line, errs[0].Message)
-		}
-		return []row{r}
-	case kindLinkedName:
-		return []row{linkedNameRow(ws, main, a, src)}
-	case kindScope:
-		return []row{scopeRow(ws, main, a, src, libraryRoots)}
-	case kindExportedObjects:
-		return []row{exportedObjectsRow(ws, main, a)}
-	default:
-		return []row{{
-			Kind: a.Kind, Block: a.Block, Line: a.Line, At: a.At, Verdict: verdictNotAdjudicated,
-			Declared: a.Expected,
-			Actual:   fmt.Sprintf("XPECT %s is read but not adjudicated by this harness", a.Kind),
-		}}
-	}
-}
-
-// diagnosticRow adjudicates one declared diagnostic.
-func diagnosticRow(a assertion, item expectation, want string, diags []diag, lines *source.LineIndex, src squeezed) row {
-	r := row{Kind: a.Kind, Block: a.Block, Line: a.Line, At: item.At, Declared: fmt.Sprintf("%s: %q", want, item.Message)}
-
-	offset, _, ok := src.locate(a.Region, item.At)
-	if !ok {
-		r.Verdict = verdictUnlocated
-		r.Actual = fmt.Sprintf("the declared text %q does not occur after the assertion", item.At)
-		return r
-	}
-	line := lines.PosAt(offset).Line
-
-	var atOffset, atLine, elsewhere, otherSeverity []diag
-	for _, d := range diags {
-		if d.Severity != want {
-			if d.Offset == offset || d.Line == line {
-				otherSeverity = append(otherSeverity, d)
-			}
-			continue
-		}
-		switch {
-		case d.Offset == offset:
-			atOffset = append(atOffset, d)
-		case d.Line == line:
-			atLine = append(atLine, d)
-		default:
-			elsewhere = append(elsewhere, d)
-		}
-	}
-
-	for _, d := range atOffset {
-		if sameMessage(d.Message, item.Message) {
-			r.Verdict = verdictAgree
-			r.Actual = fmt.Sprintf("line %d offset %d: %s", d.Line, d.Offset, d.Message)
-			return r
-		}
-	}
-	for _, d := range atOffset {
-		if class, ok := wordingOnly(item.Message, d.Message); ok {
-			r.Verdict = verdictWordingOnly
-			r.Rule = class
-			r.Actual = fmt.Sprintf("line %d offset %d: %s", d.Line, d.Offset, d.Message)
-			return r
-		}
-	}
-	r.Verdict = verdictDisagree
-	switch {
-	case len(atOffset) > 0:
-		r.Tolerance = toleranceMessage
-		r.Actual = fmt.Sprintf("line %d offset %d: %s", atOffset[0].Line, atOffset[0].Offset, atOffset[0].Message)
-	case len(atLine) > 0:
-		r.Tolerance = toleranceLine
-		r.Actual = fmt.Sprintf("line %d offset %d: %s", atLine[0].Line, atLine[0].Offset, atLine[0].Message)
-	case len(otherSeverity) > 0:
-		r.Tolerance = toleranceSeverity
-		r.Actual = fmt.Sprintf("no %s at line %d, but a %s: line %d offset %d: %s", want, line,
-			otherSeverity[0].Severity, otherSeverity[0].Line, otherSeverity[0].Offset, otherSeverity[0].Message)
-	case len(elsewhere) > 0:
-		r.Tolerance = toleranceAnywhere
-		r.Actual = fmt.Sprintf("no %s at line %d; nearest is line %d: %s", want, line, elsewhere[0].Line, elsewhere[0].Message)
-	default:
-		r.Actual = fmt.Sprintf("no %s anywhere in the file", want)
-	}
-	return r
-}
-
-// linkedNameRow resolves the reference the assertion points at and compares its
-// qualified name with the declared one.
-func linkedNameRow(ws *model.Workspace, main string, a assertion, src squeezed) row {
-	r := row{Kind: a.Kind, Block: a.Block, Line: a.Line, At: a.At, Declared: a.Expected}
-
-	offset, end, ok := src.locate(a.Region, a.At)
-	if !ok {
-		r.Verdict = verdictUnlocated
-		r.Actual = fmt.Sprintf("the declared text %q does not occur after the assertion", a.At)
-		return r
-	}
-
-	doc := ws.Document(main)
-	if doc == nil {
-		r.Verdict = verdictUnlocated
-		r.Actual = "the file did not parse into a document"
-		return r
-	}
-	ref, part, ok := referenceAt(resolve.References(doc.AST, doc.Scope), offset, end)
-	if !ok {
-		// The text is there but we index no reference at it: either we do not
-		// parse the construct or we do not treat it as a name reference.
-		r.Verdict = verdictDisagree
-		r.Actual = fmt.Sprintf("we index no name reference at offset %d", offset)
-		return r
-	}
-
-	var sym *symbols.Symbol
-	if part == len(ref.QN.Parts)-1 {
-		sym, _ = ws.ResolveReferenceInDoc(main, ref)
-	} else if segs := ws.ResolveReferenceSegmentsInDoc(main, ref); part < len(segs) {
-		sym = segs[part]
-	}
-	if sym == nil {
-		r.Verdict = verdictDisagree
-		r.Actual = "unresolved"
-		return r
-	}
-
-	actual := dotted(symbols.FQNOf(sym))
-	r.Actual = actual
-	r.Verdict = verdictDisagree
-	if actual == a.Expected {
-		r.Verdict = verdictAgree
-	}
-	return r
-}
-
-// referenceAt picks the segment a declared `at` text covers: the assertion
-// names a whole qualified name, so the segment it asks about is the last one
-// ending inside [offset, end). Sorting keeps the choice deterministic when a
-// name is nested in another reference's chain.
-func referenceAt(refs []resolve.Reference, offset, end int) (resolve.Reference, int, bool) {
-	type candidate struct {
-		ref  resolve.Reference
-		part int
-	}
-	var found []candidate
-	for _, ref := range refs {
-		if ref.QN == nil || len(ref.QN.Parts) == 0 {
-			continue
-		}
-		for i, seg := range ref.QN.Parts {
-			// A quoted name's span covers the quotes the declared text omits.
-			if seg.Span.Offset != offset && seg.Span.Offset != offset-1 {
-				continue
-			}
-			// The anchor starts at this segment and runs to end, which may
-			// cover several segments of the same name.
-			last := i
-			for j := i; j < len(ref.QN.Parts); j++ {
-				if ref.QN.Parts[j].Span.End() <= end {
-					last = j
-				}
-			}
-			found = append(found, candidate{ref, last})
-			break
-		}
-	}
-	if len(found) == 0 {
-		return resolve.Reference{}, 0, false
-	}
-	sort.SliceStable(found, func(i, j int) bool {
-		a, b := found[i], found[j]
-		if a.ref.QN.Span().Offset != b.ref.QN.Span().Offset {
-			return a.ref.QN.Span().Offset < b.ref.QN.Span().Offset
-		}
-		if len(a.ref.QN.Parts) != len(b.ref.QN.Parts) {
-			return len(a.ref.QN.Parts) > len(b.ref.QN.Parts)
-		}
-		return a.part < b.part
-	})
-	return found[0].ref, found[0].part, true
-}
-
-// squeezed is model source with the notes blanked out and all whitespace
-// dropped, keeping each kept byte's original offset. Declared target texts are
-// matched against it because the suites space them freely: `attribute un : A::p;`
-// is declared for the source `attribute un: A::p;`.
-type squeezed struct {
-	Original []byte
-	Text     string
-	Offsets  []int
-}
-
-func squeeze(content []byte) squeezed {
-	s := squeezed{Original: content, Offsets: make([]int, 0, len(content))}
-	var b strings.Builder
-	for i := 0; i < len(content); i++ {
-		if isSpace(content[i]) {
-			continue
-		}
-		b.WriteByte(content[i])
-		s.Offsets = append(s.Offsets, i)
-	}
-	s.Text = b.String()
-	return s
-}
-
-// locate returns the original span of the first occurrence of text at or after
-// from. An occurrence inside a longer identifier does not count.
-func (s squeezed) locate(from int, text string) (int, int, bool) {
-	for _, span := range s.locateAll(from, text) {
-		if span.whole {
-			return span.begin, span.end, true
-		}
-	}
-	return 0, 0, false
-}
-
-// match is one occurrence of an `at` text, whole recording whether it is the
-// entire identifier rather than the start of a longer one.
-type match struct {
-	begin, end int
-	whole      bool
-}
-
-// locateAll returns every occurrence of text that starts an identifier at or
-// after from, in source order.
-func (s squeezed) locateAll(from int, text string) []match {
-	start := sort.SearchInts(s.Offsets, from)
-	if text == "" {
-		// No `at` clause: the assertion targets the source it precedes.
-		if start >= len(s.Offsets) {
-			return nil
-		}
-		return []match{{s.Offsets[start], s.Offsets[start] + 1, true}}
-	}
-	want := strings.Join(strings.FieldsFunc(text, func(r rune) bool {
-		return r == ' ' || r == '\t' || r == '\n' || r == '\r'
-	}), "")
-	if want == "" {
-		return nil
-	}
-	var out []match
-	for i := start; i+len(want) <= len(s.Text); i++ {
-		if s.Text[i:i+len(want)] != want {
-			continue
-		}
-		begin, end := s.Offsets[i], s.Offsets[i+len(want)-1]+1
-		if identChar(want[0]) && begin > 0 && identChar(s.Original[begin-1]) {
-			continue
-		}
-		whole := !identChar(want[len(want)-1]) || end >= len(s.Original) || !identChar(s.Original[end])
-		out = append(out, match{begin, end, whole})
-	}
-	return out
-}
-
-func isSpace(c byte) bool {
-	return c == ' ' || c == '\t' || c == '\n' || c == '\r'
-}
-
-func identChar(c byte) bool {
-	return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
-}
-
-// rootPackageRe matches an unindented package declaration, which in a library
-// file is one of its root packages.
-var rootPackageRe = regexp.MustCompile(`(?m)^(?:standard\s+)?(?:library\s+)?package\s+('[^']*'|[A-Za-z_]\w*)`)
-
-// rootPackagesIn reads the root packages a declared library resource declares,
-// which is what names its contents are reachable under.
-func rootPackagesIn(content []byte) []string {
-	var roots []string
-	for _, m := range rootPackageRe.FindAllStringSubmatch(string(content), -1) {
-		roots = append(roots, strings.Trim(m[1], "'"))
-	}
-	return roots
-}
-
-// isLibrary reports whether a declared resource is one of the suite's copies of
-// a standard library file.
-func isLibrary(from string) bool {
-	return strings.HasPrefix(from, "/library")
-}
-
-// sameMessage compares a declared message with ours, ignoring whitespace and
-// the trailing period the pilot's messages carry.
-func sameMessage(ours, declared string) bool {
-	norm := func(s string) string {
-		return strings.TrimSuffix(strings.Join(strings.Fields(s), " "), ".")
-	}
-	return norm(ours) == norm(declared)
-}
-
-// dotted rewrites our `::`-separated qualified name in the pilot's notation.
-func dotted(fqn string) string {
-	return strings.ReplaceAll(fqn, "::", ".")
-}
-
-func severityName(s passes.Severity) string {
-	return strings.ToLower(s.String())
-}
-
-func dedupe(in []string) []string {
-	var out []string
-	for i, s := range in {
-		if i == 0 || in[i-1] != s {
-			out = append(out, s)
-		}
-	}
-	return out
-}
diff --git a/cmd/pilot-xpect/doc_counts_test.go b/cmd/pilot-xpect/doc_counts_test.go
deleted file mode 100644
index 8e3b4b9f93..0000000000
--- a/cmd/pilot-xpect/doc_counts_test.go
+++ /dev/null
@@ -1,264 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"regexp"
-	"strconv"
-	"strings"
-	"testing"
-)
-
-const (
-	xpectDocPath      = "docs/project/pilot-xpect.md"
-	xpectBaselinePath = "docs/project/pilot-xpect-baseline.json"
-)
-
-var (
-	xpectFilesLine      = regexp.MustCompile(`^(\d+) \.xt file\(s\), (\d+) unparsed, (\d+) missing declared resource\(s\)$`)
-	xpectAssertionsLine = regexp.MustCompile(`^(\d+) assertion\(s\) declaring (\d+) expectation\(s\)$`)
-	xpectAgreeLine      = regexp.MustCompile(`^agree (\d+) \(of which wording-only (\d+)\) \| disagree (\d+) \| unlocated (\d+) \| not adjudicated (\d+)$`)
-	xpectKindRow        = regexp.MustCompile("^\\| `(errors|noErrors|linkedName|warnings|scope|exportedObjects)` \\|(.+)\\|$")
-	xpectSuiteRow       = regexp.MustCompile("^\\| `(kerml|sysml)` \\|(.+)\\|$")
-	xpectMovementRow    = regexp.MustCompile("^\\| `(errors|noErrors|linkedName|warnings|scope)` \\| [^|]+ \\| \\*\\*(\\d+) / (\\d+)\\*\\* \\|")
-	xpectKindHeading    = regexp.MustCompile("^## `?(errors|noErrors|linkedName|warnings|scope)`? — (\\d+) of (\\d+)")
-)
-
-// TestPilotXpectDocumentCountsMatchBaseline guards this record's headline block,
-// per-kind and per-suite tables, kind headings and the movement table's Now
-// column against the committed baseline, so none of them can read as current
-// while being stale. The baseline JSON is the only input: no Java, no corpus.
-// Causal prose and the historical columns beside Now are out of scope.
-func TestPilotXpectDocumentCountsMatchBaseline(t *testing.T) {
-	report := xpectReadBaseline(t)
-	lines := xpectReadDoc(t)
-
-	xpectCheckHeadline(t, lines, report)
-	xpectCheckKindTable(t, lines, report)
-	xpectCheckSuiteTable(t, lines, report)
-	xpectCheckKindHeadings(t, lines, report)
-	xpectCheckMovementNow(t, lines, report)
-}
-
-func xpectCheckHeadline(t *testing.T, lines []string, report Report) {
-	total := report.Totals
-	seen := map[string]bool{}
-	for i, line := range lines {
-		text := strings.TrimSpace(line)
-		switch {
-		case xpectFilesLine.MatchString(text):
-			seen["files"] = true
-			m := xpectFilesLine.FindStringSubmatch(text)
-			xpectWant(t, i, "files", m[1], total.Files)
-			xpectWant(t, i, "unparsed", m[2], total.FilesUnparsed)
-			xpectWant(t, i, "missing resources", m[3], total.MissingFiles)
-		case xpectAssertionsLine.MatchString(text):
-			seen["assertions"] = true
-			m := xpectAssertionsLine.FindStringSubmatch(text)
-			xpectWant(t, i, "assertions", m[1], total.Assertions)
-			xpectWant(t, i, "expectations", m[2], total.Rows)
-		case xpectAgreeLine.MatchString(text):
-			seen["agree"] = true
-			m := xpectAgreeLine.FindStringSubmatch(text)
-			xpectWant(t, i, "agree", m[1], total.Agree)
-			xpectWant(t, i, "wording-only", m[2], total.WordingOnly)
-			xpectWant(t, i, "disagree", m[3], total.Disagree)
-			xpectWant(t, i, "unlocated", m[4], total.Unlocated)
-			xpectWant(t, i, "not adjudicated", m[5], total.NotAdjudicated)
-		}
-	}
-	for _, want := range []string{"files", "assertions", "agree"} {
-		if !seen[want] {
-			t.Errorf("%s states no %s headline line", xpectDocPath, want)
-		}
-	}
-}
-
-// xpectCheckKindTable checks the per-kind table. Its last column counts the
-// disagreements no tolerance would have accepted, which the report holds as the
-// residue of the tolerance columns.
-func xpectCheckKindTable(t *testing.T, lines []string, report Report) {
-	found := 0
-	for i, line := range lines {
-		m := xpectKindRow.FindStringSubmatch(strings.TrimSpace(line))
-		if m == nil {
-			continue
-		}
-		cells := xpectCells(m[2])
-		if len(cells) != 10 {
-			continue
-		}
-		kind := xpectKind(t, report, m[1], i)
-		found++
-		nothing := kind.Disagree - kind.SameLocation - kind.SameLine - kind.OtherSeverity - kind.Elsewhere
-		for _, want := range []struct {
-			cell  int
-			field string
-			value int
-		}{
-			{0, "expectations", kind.Rows},
-			{1, "agree", kind.Agree},
-			{2, "wording-only", kind.WordingOnly},
-			{3, "disagree", kind.Disagree},
-			{4, "not adjudicated", kind.NotAdjudicated},
-			{5, "same-location", kind.SameLocation},
-			{6, "same-line", kind.SameLine},
-			{7, "severity-differs", kind.OtherSeverity},
-			{8, "elsewhere", kind.Elsewhere},
-			{9, "nothing", nothing},
-		} {
-			if cells[want.cell] == "—" {
-				continue
-			}
-			xpectWant(t, i, fmt.Sprintf("kind %s %s", kind.Kind, want.field), cells[want.cell], want.value)
-		}
-	}
-	if found != len(report.Kinds) {
-		t.Errorf("%s states %d of the baseline's %d kind rows", xpectDocPath, found, len(report.Kinds))
-	}
-}
-
-func xpectCheckSuiteTable(t *testing.T, lines []string, report Report) {
-	found := 0
-	for i, line := range lines {
-		m := xpectSuiteRow.FindStringSubmatch(strings.TrimSpace(line))
-		if m == nil {
-			continue
-		}
-		cells := xpectCells(m[2])
-		if len(cells) != 5 {
-			continue
-		}
-		suite := xpectSuite(t, report, m[1], i)
-		found++
-		for _, want := range []struct {
-			cell  int
-			field string
-			value int
-		}{
-			{0, "files", suite.Totals.Files},
-			{1, "expectations", suite.Totals.Rows},
-			{2, "agree", suite.Totals.Agree},
-			{3, "disagree", suite.Totals.Disagree},
-			{4, "not adjudicated", suite.Totals.NotAdjudicated},
-		} {
-			xpectWant(t, i, fmt.Sprintf("suite %s %s", suite.Name, want.field), cells[want.cell], want.value)
-		}
-	}
-	if found != len(report.Suites) {
-		t.Errorf("%s states %d of the baseline's %d suite rows", xpectDocPath, found, len(report.Suites))
-	}
-}
-
-// xpectCheckKindHeadings guards the per-kind section titles, which quote the
-// same agreement as the table and are what a reader skims first.
-func xpectCheckKindHeadings(t *testing.T, lines []string, report Report) {
-	found := 0
-	for i, line := range lines {
-		m := xpectKindHeading.FindStringSubmatch(strings.TrimSpace(line))
-		if m == nil {
-			continue
-		}
-		kind := xpectKind(t, report, m[1], i)
-		found++
-		xpectWant(t, i, "heading "+kind.Kind+" agree", m[2], kind.Agree)
-		xpectWant(t, i, "heading "+kind.Kind+" expectations", m[3], kind.Rows)
-	}
-	if found != len(kindOrder) {
-		t.Errorf("%s states %d of the %d adjudicated kind headings", xpectDocPath, found, len(kindOrder))
-	}
-}
-
-// xpectCheckMovementNow guards only the Now column: the columns beside it are
-// each round's own measurement and are labelled as such in the document.
-func xpectCheckMovementNow(t *testing.T, lines []string, report Report) {
-	found := 0
-	for i, line := range lines {
-		m := xpectMovementRow.FindStringSubmatch(strings.TrimSpace(line))
-		if m == nil {
-			continue
-		}
-		kind := xpectKind(t, report, m[1], i)
-		found++
-		xpectWant(t, i, "movement "+kind.Kind+" agree", m[2], kind.Agree)
-		xpectWant(t, i, "movement "+kind.Kind+" expectations", m[3], kind.Rows)
-	}
-	if found != len(kindOrder) {
-		t.Errorf("%s states %d of the %d adjudicated movement rows", xpectDocPath, found, len(kindOrder))
-	}
-}
-
-func xpectKind(t *testing.T, report Report, name string, line int) KindTotals {
-	t.Helper()
-	for _, kind := range report.Kinds {
-		if kind.Kind == name {
-			return kind
-		}
-	}
-	t.Fatalf("%s:%d: %s states kind %q, which the baseline does not", xpectDocPath, line+1, xpectDocPath, name)
-	return KindTotals{}
-}
-
-func xpectSuite(t *testing.T, report Report, name string, line int) SuiteReport {
-	t.Helper()
-	for _, suite := range report.Suites {
-		if suite.Name == name {
-			return suite
-		}
-	}
-	t.Fatalf("%s:%d: %s states suite %q, which the baseline does not", xpectDocPath, line+1, xpectDocPath, name)
-	return SuiteReport{}
-}
-
-func xpectCells(row string) []string {
-	cells := strings.Split(row, "|")
-	for i := range cells {
-		cells[i] = strings.TrimSpace(strings.ReplaceAll(cells[i], "*", ""))
-	}
-	return cells
-}
-
-func xpectWant(t *testing.T, line int, field, got string, want int) {
-	t.Helper()
-	value, err := strconv.Atoi(got)
-	if err != nil {
-		t.Errorf("%s:%d: %s is not a count: %q", xpectDocPath, line+1, field, got)
-		return
-	}
-	if value != want {
-		t.Errorf("%s:%d: %s: want %d (%s), got %d — re-run %s and update this figure",
-			xpectDocPath, line+1, field, want, xpectBaselinePath, value, refreshCommand)
-	}
-}
-
-func xpectReadDoc(t *testing.T) []string {
-	t.Helper()
-	repo, err := moduleRoot()
-	if err != nil {
-		t.Fatal(err)
-	}
-	content, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(xpectDocPath)))
-	if err != nil {
-		t.Fatal(err)
-	}
-	return strings.Split(string(content), "\n")
-}
-
-func xpectReadBaseline(t *testing.T) Report {
-	t.Helper()
-	repo, err := moduleRoot()
-	if err != nil {
-		t.Fatal(err)
-	}
-	content, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(xpectBaselinePath)))
-	if err != nil {
-		t.Fatal(err)
-	}
-	var report Report
-	if err := json.Unmarshal(content, &report); err != nil {
-		t.Fatal(err)
-	}
-	return report
-}
diff --git a/cmd/pilot-xpect/errata.go b/cmd/pilot-xpect/errata.go
deleted file mode 100644
index 0c5890a591..0000000000
--- a/cmd/pilot-xpect/errata.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// ErrataReport is the second figure of the run: the same adjudication over the
-// suites with the declared corrections applied to a copy, never to the corpus.
-type ErrataReport struct {
-	Registry    int           `json:"registryEntries"`
-	Corrections int           `json:"corrections"`
-	Documented  int           `json:"documentedWithoutCorrection"`
-	Applied     int           `json:"correctionsApplied"`
-	Entries     []ErrataEntry `json:"entries"`
-	Totals      Totals        `json:"totals"`
-	Kinds       []KindTotals  `json:"kinds"`
-	Note        string        `json:"note"`
-}
-
-// ErrataEntry is one declared entry with the provenance the report must carry.
-type ErrataEntry struct {
-	ID         string `json:"id"`
-	Path       string `json:"path"`
-	Line       int    `json:"line"`
-	Citation   string `json:"citation"`
-	Corrected  bool   `json:"corrected"`
-	Derivation string `json:"derivation"`
-}
-
-func newErrataReport(overlay *errata.Overlay) *ErrataReport {
-	report := &ErrataReport{
-		Registry:    len(overlay.Entries()),
-		Corrections: len(overlay.Corrections()),
-		Documented:  len(overlay.Documented()),
-	}
-	for _, entry := range overlay.Entries() {
-		report.Entries = append(report.Entries, ErrataEntry{
-			ID:         entry.ID,
-			Path:       entry.Path,
-			Line:       entry.Line,
-			Citation:   entry.Citation,
-			Corrected:  entry.Corrects(),
-			Derivation: entry.Derivation,
-		})
-	}
-	return report
-}
-
-// erratumSuite adjudicates one suite again over a corrected copy of it. It
-// returns applied == 0 when no correction lies inside the suite, in which case
-// the caller carries the as-published results over unchanged.
-func erratumSuite(s suite, overlay *errata.Overlay, repo, out string, jobs int) (results []fileResult, applied int, err error) {
-	if len(overlay.Under(s.Dir)) == 0 {
-		return nil, 0, nil
-	}
-	corrected := filepath.Join(out, "errata-corpora", s.Name)
-	entries, err := overlay.Materialize(repo, s.Dir, corrected)
-	if err != nil {
-		return nil, 0, err
-	}
-	defer func() {
-		if removeErr := os.RemoveAll(corrected); removeErr != nil && err == nil {
-			err = removeErr
-		}
-		// leaves nothing behind once the last suite's copy is gone
-		_ = os.Remove(filepath.Dir(corrected))
-	}()
-	files, err := collectXT(corrected)
-	if err != nil {
-		return nil, 0, err
-	}
-	fmt.Fprintf(os.Stderr, "%s: %d .xt file(s) with %d correction(s) applied\n", s.Name, len(files), len(entries))
-	return compareAll(corrected, files, jobs), len(entries), nil
-}
diff --git a/cmd/pilot-xpect/main.go b/cmd/pilot-xpect/main.go
deleted file mode 100644
index 828356e3e3..0000000000
--- a/cmd/pilot-xpect/main.go
+++ /dev/null
@@ -1,224 +0,0 @@
-// Command pilot-xpect compares this implementation's behaviour against the OMG
-// SysML v2 Pilot Implementation's own Xpect test suites: the .xt files declare,
-// inline, the diagnostics and name-resolution results their implementers intend,
-// so unlike cmd/pilot-diff this is a comparison against declared expectations
-// rather than observed output.
-//
-// It is advisory: nothing in the build or the test suite depends on it, for the
-// same reason cmd/pilot-diff is — it needs an unvendored corpus at the pinned
-// tag. Provision it with scripts/download-pilot-xpect.sh, then run
-// `go run ./cmd/pilot-xpect`. See docs/project/pilot-xpect.md.
-package main
-
-import (
-	"flag"
-	"fmt"
-	"io/fs"
-	"os"
-	"path/filepath"
-	"runtime"
-	"sort"
-	"strings"
-	"sync"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-	"github.com/Open-MBEE/OpenSysML/internal/core/source"
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// suite is one Xpect plugin of the pilot repository, as the downloader lays it
-// out under build/pilot-xpect-corpus.
-type suite struct {
-	Name string
-	Dir  string
-}
-
-var defaultSuites = []suite{
-	{Name: "kerml", Dir: "build/pilot-xpect-corpus/kerml"},
-	{Name: "sysml", Dir: "build/pilot-xpect-corpus/sysml"},
-}
-
-func main() {
-	repo := flag.String("repo", "", "repository root (default: the module root containing this command)")
-	out := flag.String("out", "", "output directory for the reports (default: <repo>/build/pilot-xpect)")
-	jobs := flag.Int("jobs", runtime.NumCPU(), "number of .xt files to compare in parallel")
-	update := flag.Bool("update", false, "record this run as "+committedBaseline)
-	check := flag.Bool("check", false, "fail unless this run reproduces "+committedBaseline)
-	flag.Parse()
-
-	if err := run(*repo, *out, *jobs, *update, *check); err != nil {
-		fmt.Fprintf(os.Stderr, "pilot-xpect: %v\n", err)
-		os.Exit(1)
-	}
-}
-
-func run(repo, out string, jobs int, update, check bool) error {
-	var err error
-	if repo == "" {
-		repo, err = moduleRoot()
-		if err != nil {
-			return err
-		}
-	}
-	if out == "" {
-		out = filepath.Join(repo, "build", "pilot-xpect")
-	}
-	if jobs < 1 {
-		jobs = 1
-	}
-
-	overlay, err := errata.Load()
-	if err != nil {
-		return err
-	}
-
-	pin, err := baseline.ReadPin(repo)
-	if err != nil {
-		return err
-	}
-	report := &Report{
-		Pilot:   pin.Tag,
-		Corpus:  "build/pilot-xpect-corpus",
-		Library: "the suite's own /library* copies, loaded per fixture as its XPECT_SETUP declares them",
-		Errata:  newErrataReport(overlay),
-	}
-	// The errata-applied figure is adjudicated in its own shadow run, so the
-	// as-published report above is untouched by it.
-	erratum := &Report{}
-	for _, s := range defaultSuites {
-		dir := filepath.Join(repo, filepath.FromSlash(s.Dir))
-		if _, err := os.Stat(dir); err != nil {
-			fmt.Fprintf(os.Stderr, "skipping %s: %s is absent (run scripts/download-pilot-xpect.sh)\n", s.Name, s.Dir)
-			continue
-		}
-		files, err := collectXT(dir)
-		if err != nil {
-			return err
-		}
-		published := compareAll(dir, files, jobs)
-		report.Suites = append(report.Suites, SuiteReport{Name: s.Name, Dir: s.Dir, Files: published})
-
-		corrected, applied, err := erratumSuite(s, overlay, repo, out, jobs)
-		if err != nil {
-			return err
-		}
-		if applied == 0 {
-			corrected = append([]fileResult(nil), published...)
-		}
-		report.Errata.Applied += applied
-		erratum.Suites = append(erratum.Suites, SuiteReport{Name: s.Name, Dir: s.Dir, Files: corrected})
-	}
-	if len(report.Suites) == 0 {
-		return fmt.Errorf("no suite found under build/pilot-xpect-corpus; run scripts/download-pilot-xpect.sh")
-	}
-	inputs, err := suiteInputs(repo)
-	if err != nil {
-		return err
-	}
-	if report.Provenance, err = provenance(repo, overlay, inputs); err != nil {
-		return err
-	}
-	// Only a recorded baseline is dated, so two plain runs stay byte-identical.
-	if update {
-		report.Provenance.Recorded = baseline.Today()
-	}
-	report.summarize()
-	erratum.summarize()
-	report.Errata.Totals = erratum.Totals
-	report.Errata.Kinds = erratum.Kinds
-	if report.Errata.Applied == 0 {
-		report.Errata.Note = "no declared correction lies under build/pilot-xpect-corpus, so the errata-applied corpus is byte-identical to the published one and both figures coincide"
-	} else {
-		report.Errata.Note = "adjudicated again over a corrected copy of the suites; the published corpus is unchanged on disk"
-	}
-	fresh, err := writeReports(out, report)
-	if err != nil {
-		return err
-	}
-	committed := filepath.Join(repo, filepath.FromSlash(committedBaseline))
-	if update {
-		return baseline.Write(committed, fresh)
-	}
-	if check {
-		return baseline.Reproduces(committed, fresh)
-	}
-	return nil
-}
-
-// compareAll adjudicates every file, in parallel but into a fixed order, so the
-// report does not depend on the scheduler.
-func compareAll(dir string, files []string, jobs int) []fileResult {
-	results := make([]fileResult, len(files))
-	libs := newLibraryCache()
-	var wg sync.WaitGroup
-	work := make(chan int)
-	for range jobs {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			for i := range work {
-				results[i] = compareOne(dir, files[i], libs)
-			}
-		}()
-	}
-	for i := range files {
-		work <- i
-	}
-	close(work)
-	wg.Wait()
-	return results
-}
-
-func compareOne(dir, rel string, libs *libraryCache) fileResult {
-	// #nosec G304 -- the suite directory is named on the command line.
-	content, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
-	if err != nil {
-		return fileResult{Path: rel, Problems: []string{err.Error()}}
-	}
-	language := "sysml"
-	if source.KindOf(strings.TrimSuffix(rel, ".xt")) == source.KindKerML {
-		language = "kerml"
-	}
-	return compareFile(dir, parseXT(rel, language, content), libs)
-}
-
-// collectXT lists a suite's .xt files, relative to its directory, sorted.
-func collectXT(dir string) ([]string, error) {
-	var files []string
-	err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
-		if err != nil {
-			return err
-		}
-		if d.IsDir() || !strings.HasSuffix(d.Name(), ".xt") {
-			return nil
-		}
-		rel, err := filepath.Rel(dir, path)
-		if err != nil {
-			return err
-		}
-		files = append(files, filepath.ToSlash(rel))
-		return nil
-	})
-	if err != nil {
-		return nil, err
-	}
-	sort.Strings(files)
-	return files, nil
-}
-
-func moduleRoot() (string, error) {
-	dir, err := os.Getwd()
-	if err != nil {
-		return "", err
-	}
-	for {
-		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
-			return dir, nil
-		}
-		parent := filepath.Dir(dir)
-		if parent == dir {
-			return "", fmt.Errorf("no go.mod found above the working directory; pass -repo")
-		}
-		dir = parent
-	}
-}
diff --git a/cmd/pilot-xpect/provenance.go b/cmd/pilot-xpect/provenance.go
deleted file mode 100644
index a38f0bcabd..0000000000
--- a/cmd/pilot-xpect/provenance.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package main
-
-import (
-	"os"
-	"path/filepath"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// committedBaseline is the record docs/project/pilot-xpect.md is generated from,
-// and refreshCommand is the only supported way to re-record it.
-const (
-	committedBaseline = "docs/project/pilot-xpect-baseline.json"
-	refreshCommand    = "go run ./cmd/pilot-xpect -update"
-)
-
-// provenance identifies what this oracle adjudicated. It runs no validator: the
-// reference is the suites' own declared expectations, so the pin and the suite
-// contents are its whole identity.
-func provenance(repo string, overlay *errata.Overlay, inputs []baseline.Input) (baseline.Record, error) {
-	pin, err := baseline.ReadPin(repo)
-	if err != nil {
-		return baseline.Record{}, err
-	}
-	return baseline.Record{
-		PilotTag:      pin.Tag,
-		PilotCommit:   pin.Commit,
-		PilotArtifact: pin.Artifact,
-		Errata:        baseline.ErrataDigest(overlay.Entries()),
-		Inputs:        inputs,
-	}, nil
-}
-
-// suiteInputs identifies every provisioned suite by the digest of its .xt files.
-// An absent suite is not identified, so the check also runs in a checkout that
-// has not provisioned the corpus.
-func suiteInputs(repo string) ([]baseline.Input, error) {
-	var out []baseline.Input
-	for _, s := range defaultSuites {
-		dir := filepath.Join(repo, filepath.FromSlash(s.Dir))
-		if _, err := os.Stat(dir); err != nil {
-			continue
-		}
-		files, err := collectXT(dir)
-		if err != nil {
-			return nil, err
-		}
-		digest, err := baseline.DigestFiles(dir, files)
-		if err != nil {
-			return nil, err
-		}
-		out = append(out, baseline.Input{
-			Name:   s.Name,
-			Dir:    filepath.ToSlash(s.Dir),
-			Origin: baseline.OriginPinned,
-			Files:  len(files),
-			Digest: digest,
-		})
-	}
-	return out, nil
-}
diff --git a/cmd/pilot-xpect/provenance_test.go b/cmd/pilot-xpect/provenance_test.go
deleted file mode 100644
index 61341c13e6..0000000000
--- a/cmd/pilot-xpect/provenance_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package main
-
-import (
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-	"github.com/Open-MBEE/OpenSysML/internal/errata"
-)
-
-// The committed baseline must state the pin and the suites its run adjudicated,
-// and they must still be this repository's. Needs no provisioned corpus.
-func TestCommittedBaselineStatesThisRepositorysProvenance(t *testing.T) {
-	repo, current := currentProvenance(t)
-	path := filepath.Join(repo, filepath.FromSlash(committedBaseline))
-	if err := baseline.CheckCommitted(path, refreshCommand, current); err != nil {
-		t.Fatal(err)
-	}
-}
-
-// A baseline recorded against another errata registry must fail naming the
-// field, both values and the refresh command.
-func TestProvenanceGuardFailsOnAMovedErrataRegistry(t *testing.T) {
-	_, current := currentProvenance(t)
-	corrupted := corruptBaseline(t, committedBaseline, current.Errata, "sha256:"+strings.Repeat("0", 64))
-
-	err := baseline.CheckCommitted(corrupted, refreshCommand, current)
-	if err == nil {
-		t.Fatal("a baseline recorded against another errata registry was accepted")
-	}
-	for _, want := range []string{"provenance.errataRegistry", current.Errata, refreshCommand} {
-		if !strings.Contains(err.Error(), want) {
-			t.Errorf("the failure does not mention %q:\n%s", want, err)
-		}
-	}
-}
-
-// currentProvenance is the repository's provenance as it stands. A suite that is
-// not provisioned is simply not identified, so this runs in a bare checkout.
-func currentProvenance(t *testing.T) (string, baseline.Record) {
-	t.Helper()
-	repo, err := moduleRoot()
-	if err != nil {
-		t.Fatal(err)
-	}
-	overlay, err := errata.Load()
-	if err != nil {
-		t.Fatal(err)
-	}
-	inputs, err := suiteInputs(repo)
-	if err != nil {
-		t.Fatal(err)
-	}
-	current, err := provenance(repo, overlay, inputs)
-	if err != nil {
-		t.Fatal(err)
-	}
-	return repo, current
-}
-
-// corruptBaseline copies a committed baseline with one substitution applied, so
-// the guard is exercised against a damaged field without touching the record.
-func corruptBaseline(t *testing.T, rel, was, now string) string {
-	t.Helper()
-	repo, err := moduleRoot()
-	if err != nil {
-		t.Fatal(err)
-	}
-	content, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(rel)))
-	if err != nil {
-		t.Fatal(err)
-	}
-	corrupted := strings.Replace(string(content), was, now, 1)
-	if corrupted == string(content) {
-		t.Fatalf("%s contains no %s to corrupt", rel, was)
-	}
-	path := filepath.Join(t.TempDir(), filepath.Base(rel))
-	if err := os.WriteFile(path, []byte(corrupted), 0o600); err != nil {
-		t.Fatal(err)
-	}
-	return path
-}
diff --git a/cmd/pilot-xpect/report.go b/cmd/pilot-xpect/report.go
deleted file mode 100644
index 06a8e4df9b..0000000000
--- a/cmd/pilot-xpect/report.go
+++ /dev/null
@@ -1,442 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-)
-
-// Totals is one scope's adjudication, counted twice over: assertions are XPECT
-// comments, rows are the expectations they declare (an errors note declares one
-// per line).
-type Totals struct {
-	Files         int `json:"files"`
-	FilesUnparsed int `json:"filesUnparsed"`
-	Assertions    int `json:"assertions"`
-	Rows          int `json:"rows"`
-	// Agree includes WordingOnly: the same rule about the same element in our
-	// own words is agreement, and the sub-count says how much of it that is.
-	Agree          int `json:"agree"`
-	WordingOnly    int `json:"wordingOnly"`
-	Disagree       int `json:"disagree"`
-	Unlocated      int `json:"unlocated"`
-	NotAdjudicated int `json:"notAdjudicated"`
-	MissingFiles   int `json:"missingResources"`
-	// ForeignDiags counts diagnostics raised for another declared resource, not
-	// adjudicated against the file under test.
-	ForeignDiags int `json:"foreignDiagnostics"`
-}
-
-// KindTotals is one assertion kind's adjudication, with the tolerance columns
-// that record what a weaker match would have accepted.
-type KindTotals struct {
-	Kind       string `json:"kind"`
-	Assertions int    `json:"assertions"`
-	// BlockAssertions are the `//* ... */` notes among Assertions.
-	BlockAssertions int `json:"blockAssertions"`
-	Rows            int `json:"rows"`
-	Agree           int `json:"agree"`
-	WordingOnly     int `json:"wordingOnly"`
-	Disagree        int `json:"disagree"`
-	Unlocated       int `json:"unlocated"`
-	NotAdjudicated  int `json:"notAdjudicated"`
-	SameLocation    int `json:"sameLocation"`
-	SameLine        int `json:"sameLine"`
-	OtherSeverity   int `json:"severityDiffers"`
-	Elsewhere       int `json:"elsewhereInFile"`
-	// Scope tolerance classes, counted only for the scope kind.
-	OtherPaths      int `json:"otherPaths,omitempty"`
-	ExtraNames      int `json:"extraNames,omitempty"`
-	MissingNames    int `json:"missingNames,omitempty"`
-	MissingAndExtra int `json:"missingAndExtra,omitempty"`
-	LibraryNames    int `json:"libraryNames,omitempty"`
-}
-
-// SuiteReport is one Xpect plugin's adjudication.
-type SuiteReport struct {
-	Name   string       `json:"name"`
-	Dir    string       `json:"dir"`
-	Totals Totals       `json:"totals"`
-	Kinds  []KindTotals `json:"kinds"`
-	Files  []fileResult `json:"files"`
-}
-
-// Report is the whole run. It carries no timestamp and no absolute path, so two
-// runs over the same pin produce byte-identical output.
-type Report struct {
-	Pilot   string `json:"pilot"`
-	Corpus  string `json:"corpus"`
-	Library string `json:"library"`
-	// Provenance identifies the suites this run adjudicated, so a committed
-	// baseline can be checked against the repository without provisioning them.
-	Provenance baseline.Record `json:"provenance"`
-	Totals     Totals          `json:"totals"`
-	Kinds      []KindTotals    `json:"kinds"`
-	Suites     []SuiteReport   `json:"suites"`
-	Unparsed   []string        `json:"unparsed,omitempty"`
-	Ignored    []string        `json:"ignoredNotes,omitempty"`
-	Missing    []string        `json:"missingResources,omitempty"`
-	// Errata is the same adjudication with the declared corrections applied;
-	// Totals above stays the as-published headline.
-	Errata *ErrataReport `json:"errata,omitempty"`
-}
-
-// kindOrder fixes the order of the per-kind table: the adjudicated kinds first,
-// in the order the suites weigh them, then whatever else the files declare.
-var kindOrder = []string{kindErrors, kindNoErrors, kindLinkedName, kindWarnings, kindScope}
-
-func kindRank(kind string) int {
-	for i, k := range kindOrder {
-		if k == kind {
-			return i
-		}
-	}
-	return len(kindOrder)
-}
-
-// summarize aggregates per-suite and per-kind totals from the file results.
-func (r *Report) summarize() {
-	for i := range r.Suites {
-		suite := &r.Suites[i]
-		kinds := map[string]*KindTotals{}
-		for i := range suite.Files {
-			file := &suite.Files[i]
-			suite.Totals.Files++
-			if len(file.Problems) > 0 {
-				suite.Totals.FilesUnparsed++
-				r.Unparsed = append(r.Unparsed, fmt.Sprintf("%s/%s: %s",
-					suite.Name, file.Path, strings.Join(file.Problems, "; ")))
-			}
-			for _, ignored := range file.Ignored {
-				r.Ignored = append(r.Ignored, fmt.Sprintf("%s/%s: %s", suite.Name, file.Path, ignored))
-			}
-			suite.Totals.ForeignDiags += file.Foreign
-			for _, missing := range file.Missing {
-				suite.Totals.MissingFiles++
-				r.Missing = append(r.Missing, fmt.Sprintf("%s/%s declares %s", suite.Name, file.Path, missing))
-			}
-			seen := map[int]bool{}
-			for _, row := range file.Rows {
-				kt := kinds[row.Kind]
-				if kt == nil {
-					kt = &KindTotals{Kind: row.Kind}
-					kinds[row.Kind] = kt
-				}
-				kt.Rows++
-				suite.Totals.Rows++
-				if !seen[row.Line] {
-					seen[row.Line] = true
-					kt.Assertions++
-					suite.Totals.Assertions++
-					if row.Block {
-						kt.BlockAssertions++
-					}
-				}
-				switch row.Verdict {
-				case verdictAgree:
-					kt.Agree++
-					suite.Totals.Agree++
-				case verdictWordingOnly:
-					kt.Agree++
-					kt.WordingOnly++
-					suite.Totals.Agree++
-					suite.Totals.WordingOnly++
-				case verdictDisagree:
-					kt.Disagree++
-					suite.Totals.Disagree++
-				case verdictUnlocated:
-					kt.Unlocated++
-					suite.Totals.Unlocated++
-				default:
-					kt.NotAdjudicated++
-					suite.Totals.NotAdjudicated++
-				}
-				switch row.Tolerance {
-				case toleranceMessage:
-					kt.SameLocation++
-				case toleranceLine:
-					kt.SameLine++
-				case toleranceSeverity:
-					kt.OtherSeverity++
-				case toleranceAnywhere:
-					kt.Elsewhere++
-				case toleranceScopeSpelling:
-					kt.OtherPaths++
-				case toleranceScopeExtra:
-					kt.ExtraNames++
-				case toleranceScopeMissing:
-					kt.MissingNames++
-				case toleranceScopeBoth:
-					kt.MissingAndExtra++
-				case toleranceScopeLibrary:
-					kt.LibraryNames++
-				}
-			}
-			file.Expectations = len(file.Rows)
-			for _, row := range file.Rows {
-				if row.Verdict == verdictAgree || row.Verdict == verdictWordingOnly {
-					file.Agree++
-				}
-			}
-		}
-		for _, kt := range kinds {
-			suite.Kinds = append(suite.Kinds, *kt)
-		}
-		sortKinds(suite.Kinds)
-		r.Totals.add(suite.Totals)
-	}
-
-	merged := map[string]*KindTotals{}
-	for _, suite := range r.Suites {
-		for _, kt := range suite.Kinds {
-			m := merged[kt.Kind]
-			if m == nil {
-				m = &KindTotals{Kind: kt.Kind}
-				merged[kt.Kind] = m
-			}
-			m.Assertions += kt.Assertions
-			m.BlockAssertions += kt.BlockAssertions
-			m.Rows += kt.Rows
-			m.Agree += kt.Agree
-			m.WordingOnly += kt.WordingOnly
-			m.Disagree += kt.Disagree
-			m.Unlocated += kt.Unlocated
-			m.NotAdjudicated += kt.NotAdjudicated
-			m.SameLocation += kt.SameLocation
-			m.SameLine += kt.SameLine
-			m.OtherSeverity += kt.OtherSeverity
-			m.Elsewhere += kt.Elsewhere
-			m.OtherPaths += kt.OtherPaths
-			m.ExtraNames += kt.ExtraNames
-			m.MissingNames += kt.MissingNames
-			m.MissingAndExtra += kt.MissingAndExtra
-			m.LibraryNames += kt.LibraryNames
-		}
-	}
-	for _, kt := range merged {
-		r.Kinds = append(r.Kinds, *kt)
-	}
-	sortKinds(r.Kinds)
-	sort.Strings(r.Unparsed)
-	sort.Strings(r.Ignored)
-	sort.Strings(r.Missing)
-}
-
-func sortKinds(kinds []KindTotals) {
-	sort.Slice(kinds, func(i, j int) bool {
-		if ri, rj := kindRank(kinds[i].Kind), kindRank(kinds[j].Kind); ri != rj {
-			return ri < rj
-		}
-		return kinds[i].Kind < kinds[j].Kind
-	})
-}
-
-func (t *Totals) add(other Totals) {
-	t.Files += other.Files
-	t.FilesUnparsed += other.FilesUnparsed
-	t.Assertions += other.Assertions
-	t.Rows += other.Rows
-	t.Agree += other.Agree
-	t.WordingOnly += other.WordingOnly
-	t.Disagree += other.Disagree
-	t.Unlocated += other.Unlocated
-	t.NotAdjudicated += other.NotAdjudicated
-	t.MissingFiles += other.MissingFiles
-	t.ForeignDiags += other.ForeignDiags
-}
-
-func writeReports(dir string, report *Report) ([]byte, error) {
-	if err := os.MkdirAll(dir, 0o750); err != nil {
-		return nil, err
-	}
-	encoded, err := json.MarshalIndent(report.pruned(), "", "  ")
-	if err != nil {
-		return nil, err
-	}
-	encoded = append(encoded, '\n')
-	jsonPath := filepath.Join(dir, "pilot-xpect.json")
-	if err := os.WriteFile(jsonPath, encoded, 0o600); err != nil {
-		return nil, err
-	}
-	textPath := filepath.Join(dir, "pilot-xpect.txt")
-	if err := os.WriteFile(textPath, []byte(renderText(report)), 0o600); err != nil {
-		return nil, err
-	}
-
-	fmt.Fprintf(os.Stderr, "wrote %s and %s\n", textPath, jsonPath)
-	fmt.Fprintf(os.Stderr, "%d .xt file(s), %d unparsed; %d assertion(s), %d expectation(s): %d agree (of which %d wording-only), %d disagree, %d unlocated, %d not adjudicated\n",
-		report.Totals.Files, report.Totals.FilesUnparsed, report.Totals.Assertions, report.Totals.Rows,
-		report.Totals.Agree, report.Totals.WordingOnly, report.Totals.Disagree,
-		report.Totals.Unlocated, report.Totals.NotAdjudicated)
-	return encoded, nil
-}
-
-// pruned drops the agreeing and not-adjudicated rows: the counts state how many
-// there were, and listing them would treble the baseline for no verdict.
-func (r *Report) pruned() *Report {
-	out := *r
-	out.Suites = make([]SuiteReport, len(r.Suites))
-	for i, suite := range r.Suites {
-		suite.Files = make([]fileResult, len(r.Suites[i].Files))
-		for j, file := range r.Suites[i].Files {
-			file.Rows = adjudicated(interesting(file.Rows))
-			suite.Files[j] = file
-		}
-		out.Suites[i] = suite
-	}
-	return &out
-}
-
-func renderText(report *Report) string {
-	var b strings.Builder
-	b.WriteString("OpenSysML vs the OMG pilot implementation's own Xpect expectations\n")
-	fmt.Fprintf(&b, "pilot pin: %s\ncorpus:    %s\nlibrary:   %s\n\n", report.Pilot, report.Corpus, report.Library)
-	writeTotals(&b, "TOTAL", report.Totals)
-	writeKinds(&b, report.Kinds)
-	writeErrata(&b, report.Errata)
-
-	for _, suite := range report.Suites {
-		fmt.Fprintf(&b, "\n%s\n%s (%s)\n", strings.Repeat("=", 72), suite.Name, suite.Dir)
-		writeTotals(&b, suite.Name, suite.Totals)
-		writeKinds(&b, suite.Kinds)
-		for _, file := range suite.Files {
-			rows := interesting(file.Rows)
-			if len(rows) == 0 && len(file.Problems) == 0 {
-				continue
-			}
-			fmt.Fprintf(&b, "\n  %s\n", file.Path)
-			for _, problem := range file.Problems {
-				fmt.Fprintf(&b, "    unparsed: %s\n", problem)
-			}
-			for _, r := range rows {
-				fmt.Fprintf(&b, "    line %-4d %-10s %-16s %s\n", r.Line, r.Kind, r.Verdict+tolerance(r), at(r))
-				// A kind this harness does not rule on is listed, not quoted.
-				if r.Verdict == verdictNotAdjudicated {
-					continue
-				}
-				fmt.Fprintf(&b, "      declared: %s\n", r.Declared)
-				if r.Actual != "" {
-					fmt.Fprintf(&b, "      ours:     %s\n", r.Actual)
-				}
-			}
-		}
-	}
-
-	if len(report.Unparsed) > 0 {
-		fmt.Fprintf(&b, "\n%s\nunparsed files (%d)\n", strings.Repeat("=", 72), len(report.Unparsed))
-		for _, line := range report.Unparsed {
-			fmt.Fprintf(&b, "  %s\n", line)
-		}
-	}
-	if len(report.Ignored) > 0 {
-		fmt.Fprintf(&b, "\n%s\nXPECT-shaped text outside a `//` or `//*` note, not run (%d)\n", strings.Repeat("=", 72), len(report.Ignored))
-		for _, line := range report.Ignored {
-			fmt.Fprintf(&b, "  %s\n", line)
-		}
-	}
-	if len(report.Missing) > 0 {
-		fmt.Fprintf(&b, "\n%s\ndeclared resources missing from the download (%d)\n", strings.Repeat("=", 72), len(report.Missing))
-		for _, line := range report.Missing {
-			fmt.Fprintf(&b, "  %s\n", line)
-		}
-	}
-	return b.String()
-}
-
-// interesting keeps the rows a reader has to see: everything but a strict
-// agreement. A wording-only row is listed, so the class stays auditable.
-func interesting(rows []row) []row {
-	var out []row
-	for _, r := range rows {
-		if r.Verdict != verdictAgree {
-			out = append(out, r)
-		}
-	}
-	return out
-}
-
-// adjudicated keeps the rows this harness actually rules on.
-func adjudicated(rows []row) []row {
-	var out []row
-	for _, r := range rows {
-		if r.Verdict != verdictNotAdjudicated {
-			out = append(out, r)
-		}
-	}
-	return out
-}
-
-func tolerance(r row) string {
-	if r.Tolerance == "" {
-		return ""
-	}
-	return "(" + r.Tolerance + ")"
-}
-
-func at(r row) string {
-	if r.At == "" {
-		return ""
-	}
-	return fmt.Sprintf("at %q", r.At)
-}
-
-// writeErrata states the second figure after the headline, never instead of it.
-func writeErrata(b *strings.Builder, report *ErrataReport) {
-	if report == nil {
-		return
-	}
-	fmt.Fprintf(b, "\ndeclared errata: %d entr(ies), %d correction(s), %d documented without one; %d applied here\n",
-		report.Registry, report.Corrections, report.Documented, report.Applied)
-	for _, entry := range report.Entries {
-		shape := "documented, no substitution"
-		if entry.Corrected {
-			shape = "corrected"
-		}
-		fmt.Fprintf(b, "  %s %s:%d (%s) — %s\n", entry.ID, entry.Path, entry.Line, entry.Citation, shape)
-	}
-	fmt.Fprintf(b, "  %s\n", report.Note)
-	writeTotals(b, "TOTAL with errata applied", report.Totals)
-}
-
-func writeTotals(b *strings.Builder, label string, t Totals) {
-	fmt.Fprintf(b, "%s: %d .xt file(s), %d unparsed, %d missing declared resource(s)\n",
-		label, t.Files, t.FilesUnparsed, t.MissingFiles)
-	fmt.Fprintf(b, "  %d assertion(s) declaring %d expectation(s)\n", t.Assertions, t.Rows)
-	fmt.Fprintf(b, "  agree %d (of which wording-only %d) | disagree %d | unlocated %d | not adjudicated %d\n",
-		t.Agree, t.WordingOnly, t.Disagree, t.Unlocated, t.NotAdjudicated)
-	if t.ForeignDiags > 0 {
-		fmt.Fprintf(b, "  %d diagnostic(s) another declared resource raised, not adjudicated against the file\n", t.ForeignDiags)
-	}
-}
-
-func writeKinds(b *strings.Builder, kinds []KindTotals) {
-	if len(kinds) == 0 {
-		return
-	}
-	fmt.Fprintf(b, "  %-16s %9s %7s %9s %7s %11s %9s %10s %14s %10s %10s %8s %10s\n",
-		"kind", "asserts", "block", "expects", "agree", "wordingOnly", "disagree", "unlocated",
-		"notAdjudicated", "sameLoc", "sameLine", "sevDiff", "elsewhere")
-	for _, kt := range kinds {
-		fmt.Fprintf(b, "  %-16s %9d %7d %9d %7d %11d %9d %10d %14d %10d %10d %8d %10d\n",
-			kt.Kind, kt.Assertions, kt.BlockAssertions, kt.Rows, kt.Agree, kt.WordingOnly, kt.Disagree,
-			kt.Unlocated, kt.NotAdjudicated, kt.SameLocation, kt.SameLine, kt.OtherSeverity, kt.Elsewhere)
-	}
-	writeScopeClasses(b, kinds)
-}
-
-// writeScopeClasses breaks scope disagreements down by which half of the
-// declared set differs, which the shared tolerance columns cannot hold.
-func writeScopeClasses(b *strings.Builder, kinds []KindTotals) {
-	for _, kt := range kinds {
-		if kt.Kind != kindScope || kt.Disagree == 0 {
-			continue
-		}
-		fmt.Fprintf(b, "  scope classes: other-paths %d | extra-names %d | missing-names %d | missing-and-extra %d | library-names %d\n",
-			kt.OtherPaths, kt.ExtraNames, kt.MissingNames, kt.MissingAndExtra, kt.LibraryNames)
-	}
-}
diff --git a/cmd/pilot-xpect/scope.go b/cmd/pilot-xpect/scope.go
deleted file mode 100644
index 82e73f87d4..0000000000
--- a/cmd/pilot-xpect/scope.go
+++ /dev/null
@@ -1,374 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"sort"
-	"strings"
-
-	"github.com/Open-MBEE/OpenSysML/internal/core/model"
-	"github.com/Open-MBEE/OpenSysML/internal/core/resolve"
-	"github.com/Open-MBEE/OpenSysML/internal/core/symbols"
-)
-
-// metatype is the reference type an XPECT scope query is filtered by: the
-// pilot's scope holds only elements the anchor's cross-reference can name.
-type metatype int
-
-const (
-	// mtAny admits every element, as an import or an alias names a Membership.
-	mtAny metatype = iota
-	// mtType admits Types — KerML Feature is a Type, a Package is not.
-	mtType
-	// mtFeature admits Features, which is what a redefinition names.
-	mtFeature
-	// mtClassifier admits Classifiers, which is what a subclassification names.
-	mtClassifier
-)
-
-// scopeCounts is the numeric side of a scope adjudication, kept beside the
-// prose so a reader can total the classes without parsing sentences.
-type scopeCounts struct {
-	Declared int `json:"declared"`
-	Ours     int `json:"ours"`
-	// Missing is declared names we do not offer; OtherPath is the subset of
-	// them whose element we do offer under a different path.
-	Missing   int `json:"missing,omitempty"`
-	OtherPath int `json:"otherPath,omitempty"`
-	Extra     int `json:"extra,omitempty"`
-}
-
-// scopeRow adjudicates one XPECT scope assertion: the pilot declares the whole
-// set of names visible at the anchor, so both a missing and an extra name are
-// findings.
-func scopeRow(ws *model.Workspace, main string, a assertion, src squeezed, libraryRoots []string) row {
-	r := row{Kind: a.Kind, Block: a.Block, Line: a.Line, At: a.At,
-		Declared: fmt.Sprintf("%d name(s) visible at %q", len(a.Names), a.At)}
-
-	doc := ws.Document(main)
-	if doc == nil {
-		r.Verdict = verdictUnlocated
-		r.Actual = "the file did not parse into a document"
-		return r
-	}
-	// The anchor is normally a name reference, whose cross-reference type also
-	// filters the scope; where it is a declaration's own name instead, the
-	// scope is the namespace that position sits in and nothing filters it.
-	offset, ref, ok, found := scopeAnchor(doc, a, src)
-	if !found {
-		r.Verdict = verdictUnlocated
-		r.Actual = fmt.Sprintf("the declared text %q does not occur after the assertion", a.At)
-		return r
-	}
-	want, scope := mtAny, ws.ScopeAt(main, offset)
-	if ok {
-		want = anchorMetatype(src.Original, offset, ref)
-		scope = anchorScope(src.Original, offset, ref.Scope)
-	}
-	if scope == nil {
-		r.Verdict = verdictUnlocated
-		r.Actual = fmt.Sprintf("no scope encloses offset %d", offset)
-		return r
-	}
-	// Whether a fixture sees the library's implicit members is its own
-	// declaration: only a resource set loading /library has them in scope.
-	opts := model.VisibleNamesOptions{
-		Redefinition: narrowsToInherited(ref),
-		LibraryRoots: libraryRoots,
-	}
-	d := scopeDiffOf(ws, scope, ws.VisibleNames(scope, opts), want, a.Names)
-
-	r.Names = &scopeCounts{
-		Declared: len(a.Names), Ours: len(d.ours),
-		Missing: len(d.missing), OtherPath: len(d.otherPath), Extra: len(d.extra),
-	}
-	if d.distance() == 0 {
-		r.Verdict = verdictAgree
-		r.Actual = fmt.Sprintf("the same %d name(s)", len(d.ours))
-		return r
-	}
-
-	r.Verdict = verdictDisagree
-	switch {
-	case d.implicitOnly:
-		r.Tolerance = toleranceScopeLibrary
-	case len(d.realMissing) == 0:
-		r.Tolerance = toleranceScopeExtra
-	case len(d.realExtra) == 0 && len(d.realMissing) == len(d.otherPath):
-		r.Tolerance = toleranceScopeSpelling
-	case len(d.realExtra) > 0:
-		r.Tolerance = toleranceScopeBoth
-	default:
-		r.Tolerance = toleranceScopeMissing
-	}
-	r.Actual = fmt.Sprintf("%d name(s): missing %d (%s), extra %d (%s)",
-		len(d.ours), len(d.missing), sample(d.missing), len(d.extra), sample(d.extra))
-	if len(d.otherPath) > 0 {
-		r.Actual += fmt.Sprintf("; %d missing name(s) reachable by another path (%s)",
-			len(d.otherPath), sample(d.otherPath))
-	}
-	return r
-}
-
-// narrowsToInherited reports whether the pilot scopes the anchor to inherited
-// members only: a redefinition does, a subsetting may name any accessible feature.
-func narrowsToInherited(ref resolve.Reference) bool {
-	return ref.Redefines && ref.Subsetting == nil
-}
-
-// scopeAnchor locates the position a scope assertion is taken at. The `at` text
-// names the reference the question is about, so an occurrence that starts a
-// longer name — `c_Public` in `specializes c_Public_Id` — is the anchor when it
-// carries one; otherwise the first whole identifier is, and nothing filters it.
-func scopeAnchor(doc *model.Document, a assertion, src squeezed) (int, resolve.Reference, bool, bool) {
-	refs := resolve.References(doc.AST, doc.Scope)
-	var first match
-	for i, m := range src.locateAll(a.Region, a.At) {
-		if i == 0 {
-			first = m
-		}
-		if ref, _, ok := referenceAt(refs, m.begin, m.end); ok {
-			return m.begin, ref, true, true
-		}
-		if m.whole {
-			return m.begin, resolve.Reference{}, false, true
-		}
-	}
-	if first.end == 0 {
-		return 0, resolve.Reference{}, false, false
-	}
-	return first.begin, resolve.Reference{}, false, true
-}
-
-// scopeDiff is one enumeration set measured against the declared one. The
-// implicit members Base contributes to every declaration — `self` and `that` —
-// are counted apart, because the pilot's own traversal truncates paths through
-// them unevenly (see docs/project/pilot-xpect.md).
-type scopeDiff struct {
-	ours                      map[string]bool
-	missing, otherPath, extra []string
-	// implicitOnly reports that every difference is a path ending in an
-	// implicit library member.
-	implicitOnly bool
-	// realMissing and realExtra are the differences left once those paths are
-	// set aside, which is the worklist the row contributes.
-	realMissing, realExtra []string
-}
-
-// distance is the size of the symmetric difference with the declared set.
-func (d scopeDiff) distance() int { return len(d.missing) + len(d.extra) }
-
-// scopeDiffOf compares one enumeration, filtered by the anchor's metatype,
-// against the names an assertion declares. A declared name we do not offer is
-// looked up as a path to tell a lost element from a differently spelled one.
-func scopeDiffOf(
-	ws *model.Workspace,
-	scope *symbols.Scope,
-	visible []model.VisibleName,
-	want metatype,
-	names []string,
-) scopeDiff {
-	d := scopeDiff{ours: map[string]bool{}}
-	byFQN := map[string]bool{}
-	for _, n := range visible {
-		if !admits(want, n.Kind) {
-			continue
-		}
-		d.ours[n.Name] = true
-		byFQN[n.FQN] = true
-	}
-	declared := map[string]bool{}
-	for _, name := range names {
-		declared[name] = true
-		if d.ours[name] {
-			continue
-		}
-		d.missing = append(d.missing, name)
-		if reachableAs(ws, scope, byFQN, name) {
-			d.otherPath = append(d.otherPath, name)
-		}
-	}
-	for name := range d.ours {
-		if !declared[name] {
-			d.extra = append(d.extra, name)
-		}
-	}
-	d.realMissing = notImplicit(d.missing)
-	d.realExtra = notImplicit(d.extra)
-	d.implicitOnly = len(d.realMissing) == 0 && len(d.realExtra) == 0
-	sort.Strings(d.missing)
-	sort.Strings(d.otherPath)
-	sort.Strings(d.extra)
-	sort.Strings(d.realMissing)
-	sort.Strings(d.realExtra)
-	return d
-}
-
-// notImplicit drops the paths that end in an implicit library member.
-func notImplicit(names []string) []string {
-	var out []string
-	for _, name := range names {
-		switch name[strings.LastIndex(name, ".")+1:] {
-		case "self", "that":
-		default:
-			out = append(out, name)
-		}
-	}
-	return out
-}
-
-// sample renders at most the first five names of a class, so a row stays
-// readable when a declared list runs to hundreds of names.
-func sample(names []string) string {
-	const limit = 5
-	if len(names) == 0 {
-		return "none"
-	}
-	if len(names) <= limit {
-		return strings.Join(names, ", ")
-	}
-	return strings.Join(names[:limit], ", ") + ", …"
-}
-
-// reachableAs reports whether a declared path we do not offer names an element
-// we do offer under another path: the path is resolved from the anchor scope
-// and its target's qualified name looked for among the ones we hold.
-func reachableAs(
-	ws *model.Workspace,
-	scope *symbols.Scope,
-	byFQN map[string]bool,
-	name string,
-) bool {
-	sym, ok := ws.ElementOnPath(scope, strings.Split(name, "."))
-	if !ok {
-		return false
-	}
-	return byFQN[ws.FQNOf(sym)]
-}
-
-// anchorMetatype is the reference type the pilot filters the anchor's scope by,
-// read back from the declaration the anchor sits in: a subclassification names
-// a Classifier, a feature typing a Type, a subsetting or redefinition a
-// Feature, and an import or alias a Membership, whose element is any Element.
-func anchorMetatype(source []byte, offset int, ref resolve.Reference) metatype {
-	head := declarationHead(source, offset)
-	if ref.Redefines {
-		return mtFeature
-	}
-	for _, word := range head {
-		switch word {
-		case "import", "alias":
-			return mtAny
-		}
-	}
-	switch relationshipOf(head) {
-	case "redefines", ":>>", "references", "::>", "subsets":
-		return mtFeature
-	case "specializes", ":>":
-		if classifierHead(head) {
-			return mtClassifier
-		}
-		return mtFeature
-	default:
-		return mtType
-	}
-}
-
-// anchorScope is the namespace the pilot enumerates the anchor's scope from: a
-// name written in a declaration's own head is resolved in the namespace the
-// declaration sits in, not inside the element being declared.
-func anchorScope(source []byte, offset int, scope *symbols.Scope) *symbols.Scope {
-	start := headStart(source, offset)
-	for scope != nil && scope.Parent() != nil {
-		owner := scope.Owner()
-		if owner == nil || owner.DeclSpan.Offset < start || owner.DeclSpan.Offset >= offset {
-			break
-		}
-		scope = scope.Parent()
-	}
-	return scope
-}
-
-// headStart is the offset the statement containing offset begins at.
-func headStart(source []byte, offset int) int {
-	if offset < 0 || offset > len(source) {
-		return 0
-	}
-	for i := offset - 1; i >= 0; i-- {
-		if c := source[i]; c == ';' || c == '{' || c == '}' || c == ',' {
-			return i + 1
-		}
-	}
-	return 0
-}
-
-// declarationHead is the words of the statement the anchor sits in, up to the
-// anchor itself.
-func declarationHead(source []byte, offset int) []string {
-	if offset < 0 || offset > len(source) {
-		return nil
-	}
-	return strings.Fields(string(source[headStart(source, offset):offset]))
-}
-
-// relationshipOf is the last relationship keyword a declaration head writes,
-// which is the one the anchor is the target of.
-func relationshipOf(head []string) string {
-	for i := len(head) - 1; i >= 0; i-- {
-		switch word := head[i]; word {
-		case "specializes", ":>", "subsets", "redefines", ":>>", "references", "::>", ":", "typed":
-			return word
-		}
-	}
-	return ""
-}
-
-// classifierHead reports whether a declaration head declares a classifier
-// rather than a feature, which decides whether its `specializes` names a
-// Classifier or a Feature.
-func classifierHead(head []string) bool {
-	for _, word := range head {
-		switch word {
-		case "classifier", "class", "struct", "datatype", "assoc", "association",
-			"behavior", "function", "predicate", "interaction", "metaclass",
-			"type", "def", "package":
-			return true
-		}
-	}
-	return false
-}
-
-// admits reports whether a name of this kind is in a scope filtered by want.
-func admits(want metatype, kind symbols.SymbolKind) bool {
-	switch want {
-	case mtFeature:
-		return isFeatureKind(kind)
-	case mtClassifier:
-		return isTypeKind(kind) && !isFeatureKind(kind)
-	case mtType:
-		return isTypeKind(kind)
-	default:
-		return kind != symbols.SymbolUnknown
-	}
-}
-
-// isTypeKind reports whether a kind declares a KerML Type. Features are Types
-// (KerML 8.3.3), so only namespaces and annotations are excluded.
-func isTypeKind(kind symbols.SymbolKind) bool {
-	switch kind {
-	case symbols.SymbolUnknown, symbols.SymbolPackage, symbols.SymbolNamespace,
-		symbols.SymbolAlias, symbols.SymbolDependency, symbols.SymbolComment,
-		symbols.SymbolDocumentation, symbols.SymbolTextualRepresentation:
-		return false
-	default:
-		return true
-	}
-}
-
-// isFeatureKind reports whether a kind declares a KerML Feature: a usage, a
-// connector end, or a step.
-func isFeatureKind(kind symbols.SymbolKind) bool {
-	if !isTypeKind(kind) {
-		return false
-	}
-	return strings.HasSuffix(kind.String(), "Usage") || kind == symbols.SymbolConnectorEnd || kind == symbols.SymbolCrossFeature
-}
diff --git a/cmd/pilot-xpect/scope_test.go b/cmd/pilot-xpect/scope_test.go
deleted file mode 100644
index d00eb017d2..0000000000
--- a/cmd/pilot-xpect/scope_test.go
+++ /dev/null
@@ -1,214 +0,0 @@
-package main
-
-import (
-	"strings"
-	"testing"
-
-	"github.com/Open-MBEE/OpenSysML/internal/core/model"
-	"github.com/Open-MBEE/OpenSysML/internal/core/resolve"
-	"github.com/Open-MBEE/OpenSysML/internal/core/symbols"
-)
-
-// scopeNotes covers the note shapes an XPECT scope assertion is written in: a
-// fenced block, a one-line arrow form, and the compact form whose anchor runs
-// into the fence.
-const scopeNotes = `//*
-XPECT_SETUP org.omg.kerml.xpect.tests.testsuite.KerMLTest
-	ResourceSet {
-		ThisFile {}
-	}
-END_SETUP
-*/
-package test {
-	//* XPECT scope at A ---
-	   A, A.a, test.A
-	--- */
-	class B specializes A;
-	// XPECT scope at B--> B, test.B
-	class C specializes B;
-	//* XPECT scope at aliass---
-	   aliass, test.aliass
-	--- */
-	alias aliass for C;
-	//* XPECT scope at Q::D ---
-	   Q, Q.D
-	--- */
-	class E specializes Q::D;
-}
-`
-
-func TestParseXTScopeAssertionShapes(t *testing.T) {
-	f := parseXT("t.kerml.xt", "kerml", []byte(scopeNotes))
-	if len(f.Problems) != 0 {
-		t.Fatalf("problems: %v", f.Problems)
-	}
-	var got []string
-	for _, a := range f.Assertions {
-		if a.Kind != "scope" {
-			continue
-		}
-		got = append(got, a.At+"="+strings.Join(a.Names, "|"))
-	}
-	want := []string{
-		"A=A|A.a|test.A",
-		"B=B|test.B",
-		"aliass=aliass|test.aliass",
-		"Q::D=Q|Q.D",
-	}
-	if strings.Join(got, " ") != strings.Join(want, " ") {
-		t.Errorf("scope assertions =\n%v\nwant\n%v", got, want)
-	}
-}
-
-func TestParseXTScopeWithoutNamesIsAProblem(t *testing.T) {
-	f := parseXT("t.kerml.xt", "kerml", []byte("// XPECT scope at A --->\nclass A;\n"))
-	if len(f.Problems) == 0 {
-		t.Errorf("an XPECT scope declaring no name should be a problem, got %+v", f.Assertions)
-	}
-}
-
-func TestAnchorMetatypeReadsTheDeclarationHead(t *testing.T) {
-	cases := []struct {
-		src  string
-		at   string
-		ref  resolve.Reference
-		want metatype
-	}{
-		{"class B specializes A;", "A", resolve.Reference{}, mtClassifier},
-		{"feature b subsets zz;", "zz", resolve.Reference{}, mtFeature},
-		{"feature b redefines zz;", "zz", resolve.Reference{Redefines: true}, mtFeature},
-		{"feature b : A;", "A", resolve.Reference{}, mtType},
-		{"public import P::A;", "P::A", resolve.Reference{}, mtAny},
-		{"alias x for A;", "A", resolve.Reference{}, mtAny},
-	}
-	for _, c := range cases {
-		offset := strings.Index(c.src, c.at)
-		if got := anchorMetatype([]byte(c.src), offset, c.ref); got != c.want {
-			t.Errorf("anchorMetatype(%q at %q) = %v, want %v", c.src, c.at, got, c.want)
-		}
-	}
-}
-
-// TestOnlyARedefinitionNarrowsTheScope reads the anchors the way scopeRow does:
-// a subsetting is collected as a redefinition-style reference, but the pilot
-// still enumerates the whole scope for it.
-func TestOnlyARedefinitionNarrowsTheScope(t *testing.T) {
-	src := "package test {\n" +
-		"\tfeature A {\n\t\tfeature a;\n\t\talias aa for a;\n\t}\n" +
-		"\tfeature B subsets A {\n" +
-		"\t\tfeature b subsets aa;\n" +
-		"\t\tfeature c redefines a;\n" +
-		"\t\tfeature d subsets d;\n" +
-		"\t}\n}\n"
-	ws := model.NewWorkspace()
-	ws.Open("t.kerml", []byte(src), 1)
-	doc := ws.Document("t.kerml")
-	refs := resolve.References(doc.AST, doc.Scope)
-	cases := []struct {
-		clause, target string
-		narrows        bool
-	}{
-		{"subsets aa", "aa", false},
-		{"redefines a", "a", true},
-		{"subsets d", "d", false},
-	}
-	for _, c := range cases {
-		offset := strings.Index(src, c.clause) + len(c.clause) - len(c.target)
-		ref, _, ok := referenceAt(refs, offset, offset+len(c.target))
-		if !ok {
-			t.Fatalf("%q: no reference collected at %d", c.clause, offset)
-		}
-		if got := narrowsToInherited(ref); got != c.narrows {
-			t.Errorf("%q: narrowsToInherited = %v, want %v (ref %+v)", c.clause, got, c.narrows, ref)
-		}
-	}
-}
-
-func TestAdmitsFiltersByMetatype(t *testing.T) {
-	cases := []struct {
-		want metatype
-		kind symbols.SymbolKind
-		ok   bool
-	}{
-		{mtAny, symbols.SymbolPackage, true},
-		{mtType, symbols.SymbolPackage, false},
-		{mtType, symbols.SymbolAttributeUsage, true},
-		{mtClassifier, symbols.SymbolAttributeUsage, false},
-		{mtFeature, symbols.SymbolAttributeUsage, true},
-		{mtFeature, symbols.SymbolPartDef, false},
-		{mtAny, symbols.SymbolUnknown, false},
-	}
-	for _, c := range cases {
-		if got := admits(c.want, c.kind); got != c.ok {
-			t.Errorf("admits(%v, %v) = %v, want %v", c.want, c.kind, got, c.ok)
-		}
-	}
-}
-
-// scopeDiffAt compares the names visible at an anchor with a declared list.
-func scopeDiffAt(t *testing.T, src, anchor string, declared []string) scopeDiff {
-	t.Helper()
-	ws := model.NewWorkspace()
-	ws.Open("t.kerml", []byte(src), 1)
-	doc := ws.Document("t.kerml")
-	offset := strings.Index(src, anchor)
-	scope := doc.Scope
-	if s := scopeOwnerAt(doc.Scope, offset); s != nil {
-		scope = s
-	}
-	return scopeDiffOf(ws, scope, ws.VisibleNames(scope, model.VisibleNamesOptions{}), mtAny, declared)
-}
-
-// scopeOwnerAt is the deepest scope of the document whose declaration holds
-// offset, mirroring what the harness resolves an anchor against.
-func scopeOwnerAt(root *symbols.Scope, offset int) *symbols.Scope {
-	for _, sym := range root.Members() {
-		if sym.Scope == nil {
-			continue
-		}
-		if sp := sym.DeclSpan; offset >= sp.Offset && offset < sp.End() {
-			return scopeOwnerAt(sym.Scope, offset)
-		}
-	}
-	return root
-}
-
-func TestScopeDiffAgreementAndClasses(t *testing.T) {
-	src := "package P {\n\tclassifier A;\n\tclassifier B;\n}\n"
-	all := []string{"A", "B", "P", "P.A", "P.B"}
-
-	if d := scopeDiffAt(t, src, "classifier B", all); d.distance() != 0 {
-		t.Errorf("exact set should agree: missing %v extra %v", d.missing, d.extra)
-	}
-	if d := scopeDiffAt(t, src, "classifier B", append(all, "Absent")); len(d.missing) != 1 || len(d.extra) != 0 {
-		t.Errorf("a declared name we do not offer is missing: %+v", d)
-	}
-	if d := scopeDiffAt(t, src, "classifier B", all[:4]); len(d.extra) != 1 || len(d.missing) != 0 {
-		t.Errorf("a name we offer and the pilot does not is extra: %+v", d)
-	}
-}
-
-func TestReachableAsComparesElementsNotSpellings(t *testing.T) {
-	src := "package P {\n\tclassifier A;\n\tclassifier B;\n}\n"
-	ws := model.NewWorkspace()
-	ws.Open("t.kerml", []byte(src), 1)
-	scope := ws.Document("t.kerml").Scope
-	held := map[string]bool{"P::A": true}
-	if !reachableAs(ws, scope, held, "P.A") {
-		t.Error("P.A names P::A, which is held")
-	}
-	if reachableAs(ws, scope, held, "P.B") {
-		t.Error("P.B names P::B, which is not held")
-	}
-	if reachableAs(ws, scope, held, "P.Absent") {
-		t.Error("an unresolvable path reaches nothing")
-	}
-}
-
-func TestNotImplicitDropsOnlyLibraryTailPaths(t *testing.T) {
-	got := notImplicit([]string{"A.self", "A.that", "A.self.that", "A.a", "self", "A.other"})
-	want := "A.a|A.other"
-	if strings.Join(got, "|") != want {
-		t.Errorf("notImplicit = %v, want %v", got, want)
-	}
-}
diff --git a/cmd/pssm-referee/main.go b/cmd/pssm-referee/main.go
deleted file mode 100644
index 563a75cc31..0000000000
--- a/cmd/pssm-referee/main.go
+++ /dev/null
@@ -1,204 +0,0 @@
-// Command pssm-referee runs the OMG PSSM state-machine test suite, translated by
-// rule into SysML v2 textual notation, against this runtime and files each test
-// in a bucket. It is advisory: CI compares the committed bucket counts, never a
-// pass/fail verdict.
-package main
-
-import (
-	"bytes"
-	"context"
-	"errors"
-	"flag"
-	"fmt"
-	"io"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strconv"
-	"strings"
-
-	"github.com/Open-MBEE/OpenSysML/internal/baseline"
-	"github.com/Open-MBEE/OpenSysML/internal/core/runtime"
-	"github.com/Open-MBEE/OpenSysML/internal/pssm"
-)
-
-// options are the command's flags.
-type options struct {
-	repo, suite, keep, filter, develop string
-	jobs                               int
-	asJSON, update, check              bool
-}
-
-// usage is what -h prints: the meaning of a pass first, then the flags.
-func usage(flags *flag.FlagSet) string {
-	return pssm.Meaning + `
-
-Usage: pssm-referee [flags]
-
-Runs the PSSM test suite (OMG ptc/18-11-06) against the runtime and files each
-test as pass, fail, not-expressible, terminate-gap or differs-by-design. The
-suite is fetched by ./scripts/download-pssm-suite.sh; when it is absent the
-referee reports so and exits 0, unless ` + pssm.RequireEnv + ` is set.
-
-Each run is bounded by the runtime's budgets and their environment overrides
-(` + runtime.MaxStepsEnvVar + ` and the others the sysml command honors), except
-that the step budget is ` + strconv.Itoa(pssm.MaxSteps) + ` unless ` + runtime.MaxStepsEnvVar + ` names
-another; the pin in scripts/pssm-pin.sh takes the same PSSM_* overrides the
-downloader does.
-
-Flags:
-` + flagDefaults(flags)
-}
-
-func flagDefaults(flags *flag.FlagSet) string {
-	var b bytes.Buffer
-	out := flags.Output()
-	flags.SetOutput(&b)
-	flags.PrintDefaults()
-	flags.SetOutput(out)
-	return b.String()
-}
-
-// parse reads the flags into options.
-func parse(flags *flag.FlagSet, args []string) (options, error) {
-	var o options
-	flags.StringVar(&o.repo, "repo", "", "repository root (default: the module root above the working directory)")
-	flags.StringVar(&o.suite, "suite", "", "directory holding "+pssm.SuiteFile+" (default: <repo>/"+pssm.DefaultRoot+")")
-	flags.IntVar(&o.jobs, "jobs", 1, "how many linearizations of one test to explore at once; the report is the same for any value")
-	flags.StringVar(&o.keep, "keep", "", "directory to write every translated model to, for debugging")
-	flags.StringVar(&o.filter, "filter", "", "run only the tests whose name contains this")
-	flags.BoolVar(&o.asJSON, "json", false, "print the full report as JSON instead of the summary")
-	flags.BoolVar(&o.update, "update", false, "record this run as "+pssm.BaselinePath)
-	flags.StringVar(&o.develop, "develop", "", "the develop commit -update records as measured (default: git merge-base HEAD origin/develop)")
-	flags.BoolVar(&o.check, "check", false, "fail unless this run's bucket counts reproduce "+pssm.BaselinePath)
-	flags.Usage = func() { fmt.Fprint(flags.Output(), usage(flags)) }
-	err := flags.Parse(args)
-	return o, err
-}
-
-func main() {
-	flags := flag.NewFlagSet("pssm-referee", flag.ExitOnError)
-	o, err := parse(flags, os.Args[1:])
-	if err != nil {
-		os.Exit(2)
-	}
-	if err := run(os.Stdout, os.Stderr, o); err != nil {
-		fmt.Fprintf(os.Stderr, "pssm-referee: %v\n", err)
-		os.Exit(1)
-	}
-}
-
-// run is the command: locate and verify the suite, referee it, print, and
-// record or check the baseline as asked.
-func run(out, log io.Writer, o options) error {
-	if o.jobs < 1 {
-		return fmt.Errorf("-jobs must be at least 1")
-	}
-	if o.update && o.filter != "" {
-		return fmt.Errorf("-update records the whole suite; drop -filter")
-	}
-	if o.develop != "" && !o.update {
-		return fmt.Errorf("-develop is recorded by -update only")
-	}
-	repo, err := chooseRepo(o.repo)
-	if err != nil {
-		return err
-	}
-	suiteRoot := o.suite
-	if suiteRoot == "" {
-		suiteRoot = filepath.Join(repo, filepath.FromSlash(pssm.DefaultRoot))
-	}
-	path, err := pssm.Locate(suiteRoot)
-	if errors.Is(err, pssm.ErrSuiteAbsent) {
-		if !pssm.Required() {
-			fmt.Fprintln(out, err)
-			return nil
-		}
-		return fmt.Errorf("%s is set: %w", pssm.RequireEnv, err)
-	}
-	if err != nil {
-		return err
-	}
-	pin, err := pssm.ReadPin(repo)
-	if err != nil {
-		return err
-	}
-	if err := pin.Verify(path); err != nil {
-		return err
-	}
-	s, err := pssm.ReadFile(path)
-	if err != nil {
-		return err
-	}
-	report, err := pssm.Referee(context.Background(), s, pin.Provenance(len(s.Tests)), pssm.Options{Jobs: o.jobs, Keep: o.keep, Filter: o.filter})
-	if err != nil {
-		return err
-	}
-	// Only a recorded baseline is dated and stamped, so plain runs stay byte-identical.
-	if o.update {
-		report.Provenance.Recorded = baseline.Today()
-		if report.Provenance.Develop, err = developCommit(repo, o.develop); err != nil {
-			return err
-		}
-	}
-	if o.asJSON {
-		encoded, err := report.Encode()
-		if err != nil {
-			return err
-		}
-		if _, err := out.Write(encoded); err != nil {
-			return err
-		}
-	} else {
-		fmt.Fprint(out, report.Summary())
-	}
-	committed := filepath.Join(repo, filepath.FromSlash(pssm.BaselinePath))
-	if o.update {
-		if err := pssm.WriteBaseline(committed, report); err != nil {
-			return err
-		}
-		fmt.Fprintf(log, "recorded %s (dated %s, develop %s)\n", pssm.BaselinePath, report.Provenance.Recorded, report.Provenance.Develop)
-	}
-	if o.check {
-		was, err := pssm.ReadBaseline(committed)
-		if err != nil {
-			return err
-		}
-		return pssm.Reproduces(was, report)
-	}
-	return nil
-}
-
-// developCommit is the given commit, or the develop commit the checkout is based on.
-func developCommit(repo, given string) (string, error) {
-	if given != "" {
-		return given, nil
-	}
-	cmd := exec.Command("git", "-C", repo, "merge-base", "HEAD", "origin/develop")
-	out, err := cmd.Output()
-	if err != nil {
-		return "", fmt.Errorf("git merge-base HEAD origin/develop: %w; pass -develop", err)
-	}
-	return strings.TrimSpace(string(out)), nil
-}
-
-// chooseRepo is the given root, or the module root above the working directory.
-func chooseRepo(path string) (string, error) {
-	if path != "" {
-		return filepath.Abs(path)
-	}
-	dir, err := os.Getwd()
-	if err != nil {
-		return "", err
-	}
-	for {
-		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
-			return dir, nil
-		}
-		parent := filepath.Dir(dir)
-		if parent == dir {
-			return "", fmt.Errorf("no go.mod above %s; pass -repo", dir)
-		}
-		dir = parent
-	}
-}
diff --git a/cmd/pssm-referee/main_test.go b/cmd/pssm-referee/main_test.go
deleted file mode 100644
index 7ec9c313b7..0000000000
--- a/cmd/pssm-referee/main_test.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"flag"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	"github.com/Open-MBEE/OpenSysML/internal/pssm"
-)
-
-const repoRoot = "../.."
-
-// The first sentence of -h says what a pass means, in the alignment note's words.
-func TestHelpOpensWithTheMeaningOfAPass(t *testing.T) {
-	flags := flag.NewFlagSet("pssm-referee", flag.ContinueOnError)
-	var out bytes.Buffer
-	flags.SetOutput(&out)
-	if _, err := parse(flags, []string{"-h"}); err != flag.ErrHelp {
-		t.Fatalf("parse -h: %v", err)
-	}
-	want := "A pass checks that the runtime reproduces UML behavior where the model has a defensible " +
-		"SysML v2 mapping, provides a second opinion on the tool-choice rows, and is never evidence " +
-		"of SysML v2 conformance."
-	if !strings.HasPrefix(out.String(), want+"\n") {
-		t.Errorf("help opens with:\n%s", out.String())
-	}
-}
-
-// An absent suite is a reported skip, exit 0, unless the require variable is set.
-func TestAbsentSuite(t *testing.T) {
-	empty := t.TempDir()
-	t.Setenv(pssm.RequireEnv, "")
-	var out, log bytes.Buffer
-	if err := run(&out, &log, options{repo: repoRoot, suite: empty, jobs: 1}); err != nil {
-		t.Fatalf("absent suite: %v", err)
-	}
-	if !strings.Contains(out.String(), "download-pssm-suite.sh") {
-		t.Errorf("absent suite does not say how to provision it:\n%s", out.String())
-	}
-
-	t.Setenv(pssm.RequireEnv, "1")
-	err := run(&out, &log, options{repo: repoRoot, suite: empty, jobs: 1})
-	if err == nil || !strings.Contains(err.Error(), pssm.RequireEnv) {
-		t.Errorf("required absent suite: %v", err)
-	}
-}
-
-// A suite file whose bytes are not the pinned ones is refused before it is read.
-func TestBadChecksum(t *testing.T) {
-	root := t.TempDir()
-	if err := os.WriteFile(filepath.Join(root, pssm.SuiteFile), []byte("<xmi:XMI/>"), 0o600); err != nil {
-		t.Fatal(err)
-	}
-	var out, log bytes.Buffer
-	err := run(&out, &log, options{repo: repoRoot, suite: root, jobs: 1})
-	if err == nil || !strings.Contains(err.Error(), "not the pinned") {
-		t.Errorf("tampered suite: %v", err)
-	}
-	if out.Len() != 0 {
-		t.Errorf("tampered suite produced a report:\n%s", out.String())
-	}
-}
-
-// Flag combinations that cannot mean anything are refused.
-func TestRefusedOptions(t *testing.T) {
-	var out, log bytes.Buffer
-	if err := run(&out, &log, options{repo: repoRoot, jobs: 0}); err == nil {
-		t.Error("-jobs 0 accepted")
-	}
-	if err := run(&out, &log, options{repo: repoRoot, jobs: 1, update: true, filter: "x"}); err == nil {
-		t.Error("-update with -filter accepted")
-	}
-	if err := run(&out, &log, options{repo: repoRoot, jobs: 1, develop: "abc"}); err == nil {
-		t.Error("-develop without -update accepted")
-	}
-}
diff --git a/cmd/sysml-grpc/connect.go b/cmd/sysml-grpc/connect.go
index c635437c26..c71928ed71 100644
--- a/cmd/sysml-grpc/connect.go
+++ b/cmd/sysml-grpc/connect.go
@@ -17,7 +17,7 @@ import (
 	"connectrpc.com/connect"
 	"connectrpc.com/grpcreflect"
 	"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 	"golang.org/x/net/http2"
 	"golang.org/x/net/http2/h2c"
 	"google.golang.org/protobuf/proto"
diff --git a/cmd/sysml-grpc/connect_test.go b/cmd/sysml-grpc/connect_test.go
index 588e4f005e..6775444b38 100644
--- a/cmd/sysml-grpc/connect_test.go
+++ b/cmd/sysml-grpc/connect_test.go
@@ -24,7 +24,7 @@ import (
 	"connectrpc.com/connect"
 	pb "github.com/Open-MBEE/OpenSysML/api/proto"
 	"github.com/Open-MBEE/OpenSysML/api/proto/protoconnect"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 	"golang.org/x/net/http2"
 	"golang.org/x/net/http2/h2c"
 	"google.golang.org/grpc"
diff --git a/cmd/sysml-grpc/grpcserver.go b/cmd/sysml-grpc/grpcserver.go
index afe6176f34..1007b402cd 100644
--- a/cmd/sysml-grpc/grpcserver.go
+++ b/cmd/sysml-grpc/grpcserver.go
@@ -16,7 +16,7 @@ import (
 
 	"connectrpc.com/connect"
 	pb "github.com/Open-MBEE/OpenSysML/api/proto"
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/reflection"
diff --git a/cmd/sysml-grpc/lifecycle_test.go b/cmd/sysml-grpc/lifecycle_test.go
index b850616304..43ac9b9c11 100644
--- a/cmd/sysml-grpc/lifecycle_test.go
+++ b/cmd/sysml-grpc/lifecycle_test.go
@@ -19,7 +19,7 @@ import (
 	"time"
 
 	pb "github.com/Open-MBEE/OpenSysML/api/proto"
-	"github.com/Open-MBEE/OpenSysML/internal/testutil/gobuild"
+	"github.com/Open-MBEE/OpenSysML/tests/testutil/gobuild"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/credentials/insecure"
@@ -223,7 +223,7 @@ func callContext(t *testing.T) context.Context {
 // point of this test is the process, not the model.
 func fixture(t *testing.T, name string) string {
 	t.Helper()
-	path, err := filepath.Abs(filepath.Join("..", "..", "internal", "grpc", "testdata", "conformance", name))
+	path, err := filepath.Abs(filepath.Join("..", "..", "tests", "grpc", "testdata", "conformance", name))
 	if err != nil {
 		t.Fatalf("resolving fixture %s: %v", name, err)
 	}
diff --git a/cmd/sysml-grpc/main.go b/cmd/sysml-grpc/main.go
index b1b135237c..b6d4a6d650 100644
--- a/cmd/sysml-grpc/main.go
+++ b/cmd/sysml-grpc/main.go
@@ -18,8 +18,8 @@ import (
 	"syscall"
 	"time"
 
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
-	"github.com/Open-MBEE/OpenSysML/internal/usage"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/usage"
 )
 
 // Build metadata, set by the linker: the names match the -X flags the Makefile
diff --git a/cmd/sysml-grpc/man_test.go b/cmd/sysml-grpc/man_test.go
index 196567e246..af1572df02 100644
--- a/cmd/sysml-grpc/man_test.go
+++ b/cmd/sysml-grpc/man_test.go
@@ -5,7 +5,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/usage"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/usage"
 )
 
 // The shipped page is generated, so a flag or a section added to the
diff --git a/cmd/sysml-grpc/stdio.go b/cmd/sysml-grpc/stdio.go
index 1df22405ab..04547fa890 100644
--- a/cmd/sysml-grpc/stdio.go
+++ b/cmd/sysml-grpc/stdio.go
@@ -8,8 +8,8 @@ import (
 	"log/slog"
 	"os"
 
-	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/grpc"
-	"github.com/Open-MBEE/OpenSysML/internal/stdiorpc"
+	sysmlgrpc "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/stdiorpc"
 )
 
 // runStdio serves one client over stdin/stdout and reports the exit status the
diff --git a/cmd/sysml-grpc/usage.go b/cmd/sysml-grpc/usage.go
index 960a91b7cb..96b3e81d1e 100644
--- a/cmd/sysml-grpc/usage.go
+++ b/cmd/sysml-grpc/usage.go
@@ -3,7 +3,7 @@ package main
 import (
 	"flag"
 
-	"github.com/Open-MBEE/OpenSysML/internal/usage"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/usage"
 )
 
 const grpcCommand = "sysml-grpc"
diff --git a/cmd/sysml-grpc/version_test.go b/cmd/sysml-grpc/version_test.go
index b59d724ce5..3298757ab0 100644
--- a/cmd/sysml-grpc/version_test.go
+++ b/cmd/sysml-grpc/version_test.go
@@ -6,7 +6,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/testutil/gobuild"
+	"github.com/Open-MBEE/OpenSysML/tests/testutil/gobuild"
 )
 
 // Builds with the Makefile's -X flag names, so renaming the metadata variables
diff --git a/cmd/sysml-lsp/lifecycle_test.go b/cmd/sysml-lsp/lifecycle_test.go
index 58fb85ac7b..41612f502b 100644
--- a/cmd/sysml-lsp/lifecycle_test.go
+++ b/cmd/sysml-lsp/lifecycle_test.go
@@ -9,13 +9,15 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"runtime"
 	"strconv"
 	"strings"
 	"sync"
+	"sync/atomic"
 	"testing"
 	"time"
 
-	"github.com/Open-MBEE/OpenSysML/internal/testutil/gobuild"
+	"github.com/Open-MBEE/OpenSysML/tests/testutil/gobuild"
 )
 
 // The binary is what an editor starts, so the lifecycle is tested through it:
@@ -48,13 +50,32 @@ func serverBinary(t *testing.T) string {
 	return builtServer
 }
 
+type lockedBuffer struct {
+	mu sync.Mutex
+	b  strings.Builder
+}
+
+func (b *lockedBuffer) Write(p []byte) (int, error) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return b.b.Write(p)
+}
+
+func (b *lockedBuffer) String() string {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return b.b.String()
+}
+
 // session drives a started server over its stdin/stdout pipes.
 type session struct {
 	t      *testing.T
 	cmd    *exec.Cmd
 	stdin  io.WriteCloser
 	stdout *bufio.Reader
-	stderr *strings.Builder
+	stderr *lockedBuffer
+	failf  func(string, ...any)
+	killed atomic.Bool
 	waited bool
 	status int
 }
@@ -71,12 +92,12 @@ func startServer(t *testing.T, args ...string) *session {
 	if err != nil {
 		t.Fatalf("stdout pipe: %v", err)
 	}
-	var stderr strings.Builder
-	cmd.Stderr = &stderr
+	stderr := &lockedBuffer{}
+	cmd.Stderr = stderr
 	if err := cmd.Start(); err != nil {
 		t.Fatalf("starting the server: %v", err)
 	}
-	s := &session{t: t, cmd: cmd, stdin: stdin, stdout: bufio.NewReader(stdout), stderr: &stderr}
+	s := &session{t: t, cmd: cmd, stdin: stdin, stdout: bufio.NewReader(stdout), stderr: stderr}
 	t.Cleanup(func() {
 		if !s.waited {
 			_ = cmd.Process.Kill()
@@ -114,7 +135,7 @@ func (s *session) response(id int) map[string]any {
 	s.t.Helper()
 	deadline := time.Now().Add(20 * time.Second)
 	for time.Now().Before(deadline) {
-		msg := s.read()
+		msg := s.readBy(deadline)
 		got, ok := msg["id"].(float64)
 		if ok && int(got) == id {
 			return msg
@@ -124,14 +145,29 @@ func (s *session) response(id int) map[string]any {
 	return nil
 }
 
-// read reads one framed message from the server.
-func (s *session) read() map[string]any {
+// readBy reads one framed message before deadline, killing the server if it
+// stays silent until then.
+func (s *session) readBy(deadline time.Time) map[string]any {
 	s.t.Helper()
+	start := time.Now()
+	remaining := time.Until(deadline)
+	if remaining <= 0 {
+		s.fail("no message from the server within the deadline\nstderr: %s", s.stderr.String())
+		return nil
+	}
+	timer := time.AfterFunc(remaining, func() { s.dumpAndKill() })
+	defer timer.Stop()
+
 	length := -1
 	for {
 		line, err := s.stdout.ReadString('\n')
 		if err != nil {
-			s.t.Fatalf("read header: %v\nstderr: %s", err, s.stderr.String())
+			if s.killed.Load() {
+				s.fail("read header: %v (server killed after %s of silence)\nstderr: %s", err, time.Since(start).Round(time.Millisecond), s.stderr.String())
+			} else {
+				s.fail("read header: %v\nstderr: %s", err, s.stderr.String())
+			}
+			return nil
 		}
 		line = strings.TrimRight(line, "\r\n")
 		if line == "" {
@@ -139,29 +175,55 @@ func (s *session) read() map[string]any {
 		}
 		name, value, ok := strings.Cut(line, ":")
 		if !ok {
-			s.t.Fatalf("unframed server output: %q", line)
+			s.fail("unframed server output: %q", line)
+			return nil
 		}
 		if strings.EqualFold(strings.TrimSpace(name), "Content-Length") {
 			length, err = strconv.Atoi(strings.TrimSpace(value))
 			if err != nil {
-				s.t.Fatalf("bad Content-Length %q: %v", value, err)
+				s.fail("bad Content-Length %q: %v", value, err)
+				return nil
 			}
 		}
 	}
 	if length < 0 {
-		s.t.Fatal("message without Content-Length header")
+		s.fail("message without Content-Length header")
+		return nil
 	}
 	body := make([]byte, length)
 	if _, err := io.ReadFull(s.stdout, body); err != nil {
-		s.t.Fatalf("read body: %v", err)
+		if s.killed.Load() {
+			s.fail("read body: %v (server killed after %s of silence)\nstderr: %s", err, time.Since(start).Round(time.Millisecond), s.stderr.String())
+		} else {
+			s.fail("read body: %v", err)
+		}
+		return nil
 	}
 	var msg map[string]any
 	if err := json.Unmarshal(body, &msg); err != nil {
-		s.t.Fatalf("unmarshal %q: %v", body, err)
+		s.fail("unmarshal %q: %v", body, err)
+		return nil
 	}
 	return msg
 }
 
+func (s *session) fail(format string, args ...any) {
+	if s.failf != nil {
+		s.failf(format, args...)
+		return
+	}
+	s.t.Fatalf(format, args...)
+}
+
+func (s *session) dumpAndKill() {
+	s.killed.Store(true)
+	if s.cmd.Process == nil {
+		return
+	}
+	_ = s.cmd.Process.Signal(quitSignal)
+	time.AfterFunc(5*time.Second, func() { _ = s.cmd.Process.Kill() })
+}
+
 // waitStatus waits for the process to end and returns its exit status. A server
 // still running when the timeout expires is a leaked server, and a failure.
 func (s *session) waitStatus(timeout time.Duration) int {
@@ -287,3 +349,19 @@ func TestClosedStreamEndsTheProcess(t *testing.T) {
 		t.Errorf("exit status = %d, want %d\nstderr: %s", status, exitServed, s.stderr.String())
 	}
 }
+
+func TestSilentServerFailsWithinDeadline(t *testing.T) {
+	s := startServer(t)
+	var failure string
+	s.failf = func(format string, args ...any) {
+		failure = fmt.Sprintf(format, args...)
+	}
+
+	s.readBy(time.Now().Add(500 * time.Millisecond))
+	if !strings.Contains(failure, "killed") {
+		t.Fatalf("failure = %q, want server-killed message", failure)
+	}
+	if runtime.GOOS != "windows" && !strings.Contains(failure, "goroutine") {
+		t.Fatalf("failure = %q, want goroutine dump", failure)
+	}
+}
diff --git a/cmd/sysml-lsp/main.go b/cmd/sysml-lsp/main.go
index efefd2f73c..c07bc42064 100644
--- a/cmd/sysml-lsp/main.go
+++ b/cmd/sysml-lsp/main.go
@@ -10,10 +10,10 @@ import (
 	"net"
 	"os"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/conformance"
-	"github.com/Open-MBEE/OpenSysML/internal/core/model"
-	"github.com/Open-MBEE/OpenSysML/internal/lsp"
-	"github.com/Open-MBEE/OpenSysML/internal/usage"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/lsp"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/usage"
+	"github.com/Open-MBEE/OpenSysML/internal/syntax/diag"
+	"github.com/Open-MBEE/OpenSysML/internal/workspace/model"
 )
 
 var (
@@ -84,7 +84,7 @@ func run(args []string, stdout, stderr io.Writer) int {
 		return exitUnservable
 	}
 
-	return serve(stderr, conformance.ModeOf(opts.strict))
+	return serve(stderr, diag.ConformanceModeOf(opts.strict))
 }
 
 // printUsage writes the help to w, which the caller chooses: help asked for is
@@ -96,7 +96,7 @@ func printUsage(w io.Writer, fs *flag.FlagSet) {
 // serve speaks the protocol over stdin/stdout until the client ends it, and
 // reports the status the session earned: the one the client's exit notification
 // asks for, or 1 for a session that ended in a protocol error.
-func serve(stderr io.Writer, mode conformance.Mode) int {
+func serve(stderr io.Writer, mode diag.ConformanceMode) int {
 	ws := model.NewWorkspace(model.WithConformanceMode(mode))
 	srv := lsp.NewServer(ws)
 	err := srv.Run(context.Background(), stdio{})
diff --git a/cmd/sysml-lsp/man_test.go b/cmd/sysml-lsp/man_test.go
index 196567e246..af1572df02 100644
--- a/cmd/sysml-lsp/man_test.go
+++ b/cmd/sysml-lsp/man_test.go
@@ -5,7 +5,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/usage"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/usage"
 )
 
 // The shipped page is generated, so a flag or a section added to the
diff --git a/cmd/sysml-lsp/signal_unix_test.go b/cmd/sysml-lsp/signal_unix_test.go
new file mode 100644
index 0000000000..172ddb4aa5
--- /dev/null
+++ b/cmd/sysml-lsp/signal_unix_test.go
@@ -0,0 +1,10 @@
+//go:build !windows
+
+package main
+
+import (
+	"os"
+	"syscall"
+)
+
+var quitSignal os.Signal = syscall.SIGQUIT
diff --git a/cmd/sysml-lsp/signal_windows_test.go b/cmd/sysml-lsp/signal_windows_test.go
new file mode 100644
index 0000000000..9e9897d983
--- /dev/null
+++ b/cmd/sysml-lsp/signal_windows_test.go
@@ -0,0 +1,7 @@
+//go:build windows
+
+package main
+
+import "os"
+
+var quitSignal os.Signal = os.Kill
diff --git a/cmd/sysml-lsp/strict_test.go b/cmd/sysml-lsp/strict_test.go
index df02629da0..cac5f3bf55 100644
--- a/cmd/sysml-lsp/strict_test.go
+++ b/cmd/sysml-lsp/strict_test.go
@@ -50,9 +50,10 @@ func TestStrictFlagServesStrictDiagnostics(t *testing.T) {
 // firstPublishedSeverity reads until the server publishes a diagnostic.
 func firstPublishedSeverity(s *session) float64 {
 	s.t.Helper()
-	deadline := time.Now().Add(20 * time.Second)
+	// The first diagnostic can wait on a cold standard-library parse.
+	deadline := time.Now().Add(2 * time.Minute)
 	for time.Now().Before(deadline) {
-		msg := s.read()
+		msg := s.readBy(deadline)
 		if msg["method"] != "textDocument/publishDiagnostics" {
 			continue
 		}
diff --git a/cmd/sysml-lsp/usage.go b/cmd/sysml-lsp/usage.go
index cac3d52c79..5d2d5253d2 100644
--- a/cmd/sysml-lsp/usage.go
+++ b/cmd/sysml-lsp/usage.go
@@ -3,7 +3,7 @@ package main
 import (
 	"flag"
 
-	"github.com/Open-MBEE/OpenSysML/internal/usage"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/usage"
 )
 
 const lspCommand = "sysml-lsp"
diff --git a/cmd/sysml/check.go b/cmd/sysml/check.go
index 3721488aab..c63c8099fd 100644
--- a/cmd/sysml/check.go
+++ b/cmd/sysml/check.go
@@ -5,28 +5,38 @@ import (
 	"fmt"
 	"math"
 	"os"
+	"slices"
 	"strconv"
 	"strings"
 	"time"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/analysis"
-	"github.com/Open-MBEE/OpenSysML/internal/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/exec/analysis"
+	"github.com/Open-MBEE/OpenSysML/internal/exec/runtime"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/simresults"
 )
 
 // checks are the model checks and runs named on the command line, in the order
 // they are carried out: objects are created first, so a verdict is about them,
 // and behavior runs after the conditions the model states about it.
 type checks struct {
-	validate     bool
+	validate     optionalNames
 	instantiate  stringSlice
 	constraints  stringSlice
 	requirements stringSlice
-	satisfy      satisfyTargets
+	satisfy      optionalNames
 	calcs        stringSlice
 	analyses     stringSlice
+	records      stringSlice
+	recordInto   string
 	sweeps       stringSlice
 	samples      sweepCount
 	seed         sweepSeed
+	draws        drawPolicy
+	clockStep    clockStep
+	runs         runCount
+	observe      stringSlice
+	compare      string
 	queries      stringSlice
 	actions      stringSlice
 	states       stringSlice
@@ -117,8 +127,8 @@ func (s *sweepCount) Set(value string) error {
 	return nil
 }
 
-// sweepSeed is -seed as written: the seed a sampled sweep draws from, which is
-// required rather than defaulted so a table is reproducible.
+// sweepSeed is -seed as written: the seed a sampled sweep or a Monte Carlo draws
+// from, which is required rather than defaulted so a table is reproducible.
 type sweepSeed struct {
 	value uint64
 	text  string
@@ -137,6 +147,71 @@ func (s *sweepSeed) Set(value string) error {
 	return nil
 }
 
+// seed is the seed as given, nil when -seed was not written.
+func (s *sweepSeed) seed() *uint64 {
+	if !s.given {
+		return nil
+	}
+	return &s.value
+}
+
+// drawPolicy is -draws as written: how every run resolves the RandomFunctions
+// draws — at random from the seed, or at each call's min, max or average.
+type drawPolicy struct {
+	value runtime.DrawPolicy
+	text  string
+}
+
+func (d *drawPolicy) String() string { return d.text }
+
+func (d *drawPolicy) Set(value string) error {
+	policy, err := runtime.ParseDrawPolicy(value)
+	if err != nil {
+		return err
+	}
+	d.value, d.text = policy, value
+	return nil
+}
+
+// clockStep is -clock-step as written: the step, in seconds, the clock of every
+// run ticks by; 0, the default, is a continuous clock.
+type clockStep struct {
+	value float64
+	text  string
+	given bool
+}
+
+func (c *clockStep) String() string { return c.text }
+
+func (c *clockStep) Set(value string) error {
+	step, err := runtime.ParseClockStep(value)
+	if err != nil {
+		return fmt.Errorf("-clock-step: %w", err)
+	}
+	c.value, c.text, c.given = step, value, true
+	return nil
+}
+
+// runCount is -runs as written: how many times to run the action named, parsed
+// where a bad value is reported in the caller's own form.
+type runCount struct {
+	value int64
+	text  string
+	given bool
+}
+
+func (r *runCount) String() string { return r.text }
+
+func (r *runCount) Set(value string) error {
+	r.text, r.given = value, true
+	count, err := strconv.ParseInt(value, 10, 64)
+	if err != nil || count <= 0 {
+		return fmt.Errorf("-runs takes the number of runs to make, not %q", value)
+	}
+	r.value = count
+	return nil
+}
+
 // advanceTime is -advance as written, parsed where a bad value is reported in
 // whichever form the caller asked for. An empty value is a misuse rather than
 // silently no advance, so it records that the flag was written.
@@ -156,10 +231,11 @@ func (a *advanceTime) Set(value string) error {
 // -json and -advance check nothing themselves, but are included so their misuse
 // is reported rather than leaving a script at a prompt it cannot answer.
 func (c *checks) requested() bool {
-	return c.validate || c.jsonOut || c.advance.given || c.satisfy.given || len(c.instantiate) > 0 ||
+	return c.validate.given || c.jsonOut || c.advance.given || c.satisfy.given || len(c.instantiate) > 0 ||
 		len(c.constraints) > 0 || len(c.requirements) > 0 || len(c.calcs) > 0 || len(c.analyses) > 0 ||
+		len(c.records) > 0 ||
 		len(c.queries) > 0 || len(c.actions) > 0 || len(c.states) > 0 ||
-		c.sweeping() || c.checker.given()
+		c.sweeping() || c.running() || c.compare != "" || c.checker.given()
 }
 
 // explicitOnly names the -check-* flags written that the check engine alone reads.
@@ -231,9 +307,101 @@ func spelled(names []string) string {
 	return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
 }
 
-// sweeping reports whether a sweep or a sample of one was asked for.
+// sweeping reports whether a sweep or a sample of one was asked for. A seed
+// alone asks for no sweep: it seeds the model's own draws in whatever runs.
 func (c *checks) sweeping() bool {
-	return len(c.sweeps) > 0 || c.samples.given || c.seed.given
+	return len(c.sweeps) > 0 || c.samples.given
+}
+
+// running reports whether a Monte Carlo was asked for.
+func (c *checks) running() bool {
+	return c.compare == "" && (c.runs.given || len(c.observe) > 0)
+}
+
+// runsMisuse reports why the flags a Monte Carlo was asked for with run none,
+// and "" when they run one: -runs needs a single -action or -analysis and, under
+// the random draw policy, -seed; -observe names what the runs of an action report.
+func (c *checks) runsMisuse() string {
+	if c.compare != "" {
+		return c.compareMisuse()
+	}
+	if !c.running() {
+		return ""
+	}
+	switch {
+	case !c.runs.given:
+		return "-observe names what -runs reports; ask for the runs, as -runs <number>"
+	case len(c.actions) == 0 && len(c.analyses) == 0 && len(c.records) == 0:
+		return "-runs runs an action or a Simulation::MonteCarlo analysis case; name one, as -action <name> or -analysis <name>"
+	case len(c.actions)+len(c.analyses)+len(c.records) > 1:
+		return "-runs runs one action or analysis case; name a single -action or -analysis"
+	case len(c.analyses)+len(c.records) > 0 && len(c.observe) > 0:
+		return "-runs of an analysis case observes what the case declares as observed; -observe names the features of an -action"
+	case len(c.states) > 0:
+		return "-runs runs an action; a state machine is run once, as -state <name> without -runs"
+	case !c.seed.given && !c.draws.value.Fixed():
+		return "-runs draws each run's randomness from a seed; name one, as -seed <number>, or fix the draws, as -draws min|max|average"
+	case c.sweeping():
+		return "-runs runs an action; -sweep and -samples run an analysis case or calc; ask for one of them"
+	case c.advance.given:
+		return "-runs runs the action to completion; -advance runs it for a time; ask for one of them"
+	case c.checker.given():
+		return "-runs makes concrete runs; the -check-* flags search schedules; ask for one of them"
+	}
+	return ""
+}
+
+// compareMisuse reports why the flags -compare-results was written with compare
+// nothing, and "" when they do: it runs the configurations the results index, so
+// -runs, -seed, -draws and -observe shape the runs and -action names configurations.
+func (c *checks) compareMisuse() string {
+	switch {
+	case len(c.states) > 0 || c.sweeping() || c.advance.given || c.checker.given() ||
+		c.validate.given || c.satisfy.given || len(c.instantiate) > 0 || len(c.constraints) > 0 ||
+		len(c.requirements) > 0 || len(c.calcs) > 0 || len(c.analyses) > 0 || len(c.records) > 0 || len(c.queries) > 0:
+		return "-compare-results runs the migrated configurations the results index and compares the runs with the tool's; the other checks are made in a run of their own"
+	}
+	for _, pair := range c.observe {
+		if stored, _, _ := strings.Cut(pair, "="); strings.TrimSpace(stored) == "" {
+			return fmt.Sprintf("-observe %q names no stored observable; with -compare-results write it as -observe <observable> or -observe <observable>=<feature>", pair)
+		}
+	}
+	return ""
+}
+
+// readResults reads the -compare-results sidecar, naming the file in what went wrong.
+func readResults(path string) (*simresults.Results, error) {
+	f, err := os.Open(path) // #nosec G304 -- the operator names the sidecar on the command line
+	if err != nil {
+		return nil, fmt.Errorf("-compare-results: %w", err)
+	}
+	defer f.Close()
+	results, err := simresults.Read(f)
+	if err != nil {
+		return nil, fmt.Errorf("-compare-results %s: %w", path, err)
+	}
+	return results, nil
+}
+
+// compareOptions are the -compare-results runs as the flags shape them.
+func (c *checks) compareOptions() repl.CompareOptions {
+	opts := repl.CompareOptions{Seed: c.seed.seed(), Only: c.actions}
+	if c.runs.given {
+		opts.Runs = c.runs.value
+	}
+	if flagGiven("draws") {
+		policy := c.draws.value
+		opts.Draws = &policy
+	}
+	if c.clockStep.given {
+		step := c.clockStep.value
+		opts.ClockStep = &step
+	}
+	for _, pair := range c.observe {
+		stored, feature, _ := strings.Cut(pair, "=")
+		opts.Observe = append(opts.Observe, repl.ObservablePair{Stored: stored, Feature: feature})
+	}
+	return opts
 }
 
 // sweepMisuse reports why the flags a sweep was asked for with make no sweep,
@@ -242,7 +410,7 @@ func (c *checks) sweepMisuse() string {
 	if !c.sweeping() {
 		return ""
 	}
-	targets := len(c.calcs) + len(c.analyses)
+	targets := len(c.calcs) + len(c.analyses) + len(c.records)
 	switch {
 	case targets == 0:
 		return "-sweep runs an analysis case or a calc; name one, as -analysis <name> or -calc <name>"
@@ -252,8 +420,52 @@ func (c *checks) sweepMisuse() string {
 		return "-samples draws from a range; name one, as -sweep <parameter>=<from>..<to>"
 	case c.samples.given && !c.seed.given:
 		return "-samples draws from a seed; name one, as -seed <number>"
-	case c.seed.given && !c.samples.given:
-		return "-seed is the seed -samples draws from; name how many to draw, as -samples <number>"
+	}
+	return ""
+}
+
+// instantiatesOnly reports whether the run creates objects and decides nothing
+// about them, so a document can be rendered over what it holds.
+func (c *checks) instantiatesOnly() bool {
+	return len(c.instantiate) > 0 && !c.validate.given && !c.jsonOut && !c.advance.given && !c.satisfy.given &&
+		len(c.constraints) == 0 && len(c.requirements) == 0 && len(c.calcs) == 0 && len(c.analyses) == 0 &&
+		len(c.records) == 0 &&
+		len(c.queries) == 0 && len(c.actions) == 0 && len(c.states) == 0 && !c.sweeping() && !c.running() && c.compare == "" && !c.checker.given()
+}
+
+// recordsOnly reports whether the run makes records and decides nothing else,
+// so a document can be rendered over, or a file written from, what was recorded.
+// The bounds the records run under — a sweep's ranges, a Monte Carlo's runs,
+// seed and draws — and the objects -instantiate materializes for them, serve them.
+func (c *checks) recordsOnly() bool {
+	return len(c.records) > 0 && !c.validate.given && !c.jsonOut && !c.advance.given && !c.satisfy.given &&
+		len(c.constraints) == 0 && len(c.requirements) == 0 && len(c.calcs) == 0 &&
+		len(c.analyses) == 0 && len(c.observe) == 0 &&
+		len(c.queries) == 0 && len(c.actions) == 0 && len(c.states) == 0 && c.compare == "" && !c.checker.given()
+}
+
+// recordMisuse reports why the flags the records were asked for with record
+// none, and "" when they record one.
+func (c *checks) recordMisuse() string {
+	if len(c.records) == 0 {
+		return ""
+	}
+	switch {
+	case c.samples.given:
+		return "-samples draws values for a sweep it does not run; -record-run records a run, a -sweep's rows or a -runs sample"
+	case len(c.records) > 1 && c.runs.given:
+		return "-runs runs one analysis case; name a single -record-run"
+	}
+	return ""
+}
+
+// boundsMisuse reports why the bounds a records run was asked for make no run:
+// the same refusal runChecks gives for them.
+func (c *checks) boundsMisuse() string {
+	for _, message := range []string{c.sweepMisuse(), c.runsMisuse(), c.recordMisuse()} {
+		if message != "" {
+			return message
+		}
 	}
 	return ""
 }
@@ -261,50 +473,70 @@ func (c *checks) sweepMisuse() string {
 // checksOnly reports whether anything was asked about the model itself, as
 // against how to report the answer.
 func (c *checks) checksOnly() bool {
-	return c.validate || len(c.instantiate) > 0 || len(c.constraints) > 0 ||
+	return len(c.validate.targets) > 0 || len(c.instantiate) > 0 || len(c.constraints) > 0 ||
 		len(c.requirements) > 0 || len(c.satisfy.targets) > 0 || len(c.calcs) > 0 || len(c.analyses) > 0 ||
-		len(c.queries) > 0 || len(c.actions) > 0 || len(c.states) > 0
+		len(c.records) > 0 ||
+		len(c.queries) > 0 || len(c.actions) > 0 || len(c.states) > 0 || c.compare != ""
 }
 
-// satisfyTargets collects -satisfy values. The flag takes an optional value: a
-// bare -satisfy evaluates every satisfaction assertion in the model, and
-// -satisfy=<name> evaluates the ones the named element states. Go's flag package
-// passes "true" for the valueless spelling, which no name can be mistaken for
-// because `true` is a literal keyword rather than a declarable name.
-type satisfyTargets struct {
+// optionalNames collects the values of a flag that takes an optional name, as
+// -satisfy and -validate do: bare, it is about the whole model, and with =<name>
+// about the element or object named. Go's flag package passes "true" for the
+// valueless spelling, which no name can be mistaken for because `true` is a
+// literal keyword rather than a declarable name.
+type optionalNames struct {
+	// targets are the names given, the bare spelling recorded as "".
 	targets []string
-	given   bool
+	// given records the flag written at all, =false included, so a script that
+	// wrote it is answered rather than left at a prompt.
+	given bool
 }
 
-func (t *satisfyTargets) String() string { return fmt.Sprint(t.targets) }
+func (t *optionalNames) String() string { return fmt.Sprint(t.targets) }
 
-// IsBoolFlag makes the value optional, so -satisfy alone is accepted.
-func (t *satisfyTargets) IsBoolFlag() bool { return true }
+// IsBoolFlag makes the value optional, so the flag alone is accepted.
+func (t *optionalNames) IsBoolFlag() bool { return true }
 
-func (t *satisfyTargets) Set(value string) error {
+func (t *optionalNames) Set(value string) error {
 	t.given = true
 	switch value {
 	case "true":
-		// Every assertion in the model, which CheckSatisfy names with "".
 		t.targets = append(t.targets, "")
 	case "false":
-		// The off spelling of a flag declared boolean, so -satisfy=$on works.
+		// The off spelling of a flag declared boolean, so -satisfy=$on works: it
+		// withdraws a bare request written before it and leaves the names given.
+		t.targets = t.names()
 	default:
 		t.targets = append(t.targets, value)
 	}
 	return nil
 }
 
-// tookNoValue reports whether -satisfy was given without a value. A name written
+// tookNoValue reports whether the flag was given without a value. A name written
 // after it (`-satisfy Landing::touchdown`) is then a positional argument, i.e. a
 // file to load, which is worth explaining when no such file exists.
-func (t *satisfyTargets) tookNoValue() bool {
-	for _, target := range t.targets {
-		if target == "" {
-			return true
-		}
+func (t *optionalNames) tookNoValue() bool {
+	return slices.Contains(t.targets, "")
+}
+
+// names are the values given, the bare spelling left out.
+func (t *optionalNames) names() []string {
+	return slices.DeleteFunc(slices.Clone(t.targets), func(name string) bool { return name == "" })
+}
+
+// valueMisuse explains a flag whose name was written as a positional argument
+// that names no file, for whichever of -satisfy and -validate was so written.
+func (c *checks) valueMisuse(path string) string {
+	if fileExists(path) {
+		return ""
 	}
-	return false
+	switch {
+	case c.satisfy.tookNoValue():
+		return fmt.Sprintf("%s is read as a file to load; -satisfy takes a name as -satisfy=%s", path, path)
+	case c.validate.tookNoValue() && len(c.instantiate) > 0:
+		return fmt.Sprintf("%s is read as a file to load; -validate takes an object as -validate=%s", path, path)
+	}
+	return ""
 }
 
 // refuse reports a misused flag in whichever form the caller asked for, so a
@@ -339,6 +571,14 @@ func runChecks(files []string, exprs []string, c checks) int {
 		rep.failed(message)
 		return rep.finish()
 	}
+	if message := c.runsMisuse(); message != "" {
+		rep.failed(message)
+		return rep.finish()
+	}
+	if message := c.recordMisuse(); message != "" {
+		rep.failed(message)
+		return rep.finish()
+	}
 	if message := c.checkerMisuse(engine.text); message != "" {
 		rep.failed(message)
 		return rep.finish()
@@ -369,8 +609,10 @@ func runChecks(files []string, exprs []string, c checks) int {
 	paths, err := repl.ExpandPaths(files)
 	if err != nil {
 		rep.failed(err.Error())
-		if c.satisfy.tookNoValue() && len(files) == 1 && !fileExists(files[0]) {
-			rep.failed(fmt.Sprintf("%s is read as a file to load; -satisfy takes a name as -satisfy=%s", files[0], files[0]))
+		if len(files) == 1 {
+			if message := c.valueMisuse(files[0]); message != "" {
+				rep.failed(message)
+			}
 		}
 		return rep.finish()
 	}
@@ -381,8 +623,10 @@ func runChecks(files []string, exprs []string, c checks) int {
 	if err != nil {
 		rep.failed(err.Error())
 		var read *repl.ReadError
-		if errors.As(err, &read) && c.satisfy.tookNoValue() && !fileExists(read.Path) {
-			rep.failed(fmt.Sprintf("%s is read as a file to load; -satisfy takes a name as -satisfy=%s", read.Path, read.Path))
+		if errors.As(err, &read) {
+			if message := c.valueMisuse(read.Path); message != "" {
+				rep.failed(message)
+			}
 		}
 		return rep.finish()
 	}
@@ -408,6 +652,20 @@ func runChecks(files []string, exprs []string, c checks) int {
 
 	rep.info(loaded)
 
+	// The configurations a migration indexed results for are run against those
+	// results, and nothing else is asked of the model in the same run.
+	if c.compare != "" {
+		results, err := readResults(c.compare)
+		if err != nil {
+			rep.failed(err.Error())
+			return rep.finish()
+		}
+		for _, v := range sess.CompareResults(results, c.compareOptions()) {
+			rep.verdict(v)
+		}
+		return rep.finish()
+	}
+
 	// An object first: a constraint, requirement or expression about a feature of
 	// a part is answered about the object that carries it, and only an existing
 	// one can be. Creating it materializes its feature values, so a default that does not
@@ -435,7 +693,7 @@ func runChecks(files []string, exprs []string, c checks) int {
 	// The model is only reported clean once the objects asked for were created:
 	// what materializing them found is a diagnostic about the model, so a run
 	// that produced one must not also report that there were none.
-	if c.validate {
+	if c.validate.tookNoValue() {
 		switch {
 		case rep.clean() && bounded:
 			rep.info([]string{fmt.Sprintf("✓ %s: no errors in the feature values checked", namedModels(files))})
@@ -460,6 +718,14 @@ func runChecks(files []string, exprs []string, c checks) int {
 		rep.info(output)
 	}
 
+	// An object is validated as a whole: every assertion about it and about the
+	// objects it holds, then a summary verdict about the object.
+	for _, object := range c.validate.names() {
+		for _, v := range sess.ValidateObject(object) {
+			rep.verdict(v)
+		}
+	}
+
 	for _, name := range c.constraints {
 		rep.verdict(sess.CheckConstraint(name))
 	}
@@ -479,14 +745,17 @@ func runChecks(files []string, exprs []string, c checks) int {
 		rep.verdict(sess.RunCalc(invocation))
 	}
 	for _, invocation := range c.analyses {
-		if c.sweeping() {
+		switch {
+		case c.sweeping():
 			rep.verdict(c.sweep(sess, invocation))
-			continue
+		case c.runs.given:
+			rep.verdict(sess.RunMonteCarlo(invocation, c.runs.value, c.seed.seed()))
+		default:
+			rep.verdict(sess.RunAnalysis(invocation))
 		}
-		rep.verdict(sess.RunAnalysis(invocation))
 	}
-	for _, invocation := range c.queries {
-		rep.verdict(sess.RunDocumentQuery(invocation))
+	for _, invocation := range c.records {
+		rep.verdict(c.record(sess, invocation))
 	}
 	// With -advance every behavior named is started first and the clock they share
 	// is moved once, so an action's signal reaches a machine that accepts it later;
@@ -495,30 +764,94 @@ func runChecks(files []string, exprs []string, c checks) int {
 		for _, v := range sess.RunFor(behaviors(c.actions), behaviors(c.states), advance) {
 			rep.verdict(v)
 		}
+		c.runQueries(sess, rep)
 		return rep.finish()
 	}
 	for _, value := range c.actions {
-		name, performer := splitPerformer(value)
+		name, performer := repl.SplitBehavior(value)
+		if c.runs.given {
+			rep.verdict(sess.RunRuns(name, performer, c.runs.value, c.seed.seed(), c.observe))
+			continue
+		}
 		rep.verdict(sess.RunAction(name, performer...))
 	}
 	for _, value := range c.states {
-		name, performer := splitPerformer(value)
+		name, performer := repl.SplitBehavior(value)
 		rep.verdict(sess.RunStateMachine(name, performer...))
 	}
+	c.runQueries(sess, rep)
 
 	return rep.finish()
 }
 
+// runQueries executes each -run-query after the behaviors named have run, so a
+// query over the session's states or trace reads what the run did.
+func (c *checks) runQueries(sess *repl.Session, rep *reporter) {
+	for _, invocation := range c.queries {
+		rep.verdict(sess.RunDocumentQuery(invocation))
+	}
+}
+
 // behaviors reads `-action`/`-state` values as the behaviors they name.
 func behaviors(values []string) []repl.Behavior {
 	out := make([]repl.Behavior, 0, len(values))
 	for _, value := range values {
-		name, performer := splitPerformer(value)
+		name, performer := repl.SplitBehavior(value)
 		out = append(out, repl.Behavior{Name: name, Performer: performer})
 	}
 	return out
 }
 
+// record runs one invocation as its -analysis twin does — swept or sampled over
+// -runs when the flags say — then writes the run into the model as records.
+func (c *checks) record(sess *repl.Session, invocation string) repl.Verdict {
+	command := c.recordCommand(invocation)
+	switch {
+	case c.sweeping():
+		return sess.RecordSweep(invocation, c.sweeps, c.recordInto, command)
+	case c.runs.given:
+		return sess.RecordMonteCarlo(invocation, c.runs.value, c.seed.seed(), c.recordInto, command)
+	default:
+		return sess.RecordAnalysis(invocation, c.recordInto, command)
+	}
+}
+
+// recordCommand is the invocation text a record's provenance carries: the flags
+// the run was made with, as written.
+func (c *checks) recordCommand(invocation string) string {
+	parts := []string{fmt.Sprintf("-record-run %q", invocation)}
+	for _, r := range c.sweeps {
+		parts = append(parts, fmt.Sprintf("-sweep %q", r))
+	}
+	if c.runs.given {
+		parts = append(parts, "-runs "+c.runs.text)
+	}
+	if c.seed.given {
+		parts = append(parts, "-seed "+c.seed.text)
+	}
+	if c.draws.text != "" {
+		parts = append(parts, "-draws "+c.draws.text)
+	}
+	// The flags the session runs under decide what the run computed and which
+	// objects it ran on, so the command records them as written too.
+	if schedule.text != "" {
+		parts = append(parts, "-schedule "+schedule.text)
+	}
+	if c.clockStep.given {
+		parts = append(parts, "-clock-step "+c.clockStep.text)
+	}
+	if engine.text != "" {
+		parts = append(parts, "-engine "+engine.text)
+	}
+	for _, name := range c.instantiate {
+		parts = append(parts, fmt.Sprintf("-instantiate %q", name))
+	}
+	if c.recordInto != "" {
+		parts = append(parts, "-record-into "+c.recordInto)
+	}
+	return strings.Join(parts, " ")
+}
+
 // sweep runs one invocation once per row of the ranges given: over every value
 // of each range, or over values drawn from them when -samples was asked for.
 func (c *checks) sweep(sess *repl.Session, invocation string) repl.Verdict {
@@ -529,7 +862,7 @@ func (c *checks) sweep(sess *repl.Session, invocation string) repl.Verdict {
 }
 
 // reportedErrors reports whether analysis found an error, which a check runs
-// through only when it is about the notation (see passes.Diagnostic.Blocking).
+// through only when it is about the notation (see diag.Diagnostic.Blocking).
 func reportedErrors(diags []repl.Diagnostic) bool {
 	for _, d := range diags {
 		if d.Severity == "error" {
@@ -539,17 +872,6 @@ func reportedErrors(diags []repl.Diagnostic) bool {
 	return false
 }
 
-// splitPerformer splits a `-action`/`-state` value into the behavior's name and
-// the object performing it, which is the word after it as `%action` takes it:
-// `-action "Drive rover1"`.
-func splitPerformer(value string) (string, []string) {
-	fields := strings.Fields(value)
-	if len(fields) == 0 {
-		return "", nil
-	}
-	return fields[0], fields[1:]
-}
-
 func fileExists(path string) bool {
 	_, err := os.Stat(path)
 	return err == nil
diff --git a/cmd/sysml/check_engine_test.go b/cmd/sysml/check_engine_test.go
index 3649f78bef..ed7c11df81 100644
--- a/cmd/sysml/check_engine_test.go
+++ b/cmd/sysml/check_engine_test.go
@@ -54,6 +54,7 @@ type checkedReport struct {
 			} `json:"bounds"`
 			Witness *struct {
 				Schedule string   `json:"schedule"`
+				Draws    []string `json:"draws"`
 				Choices  []string `json:"choices"`
 			} `json:"witness"`
 			Check *struct {
@@ -68,6 +69,7 @@ type checkedReport struct {
 					Error   string   `json:"error"`
 					Depth   int      `json:"depth"`
 					Witness []string `json:"witness"`
+					Draws   []string `json:"draws"`
 					Path    string   `json:"path"`
 				} `json:"violations"`
 				Divergent []struct {
@@ -182,7 +184,7 @@ func TestEngineCheckWitnessOfNoChoiceReplays(t *testing.T) {
 		"-action", "Plant::Tank::overfill Plant::tank", "-check-property", "Plant::Tank::low", "-check-witness", dir)
 	witness := filepath.Join(dir, "Plant.Tank.overfill@Plant.tank.violation-1.witness")
 	wantReport(t, got, 1, "✗ Action Plant::Tank::overfill: violation",
-		"violation: Plant::Tank::low is false after 2 moves (witness "+witness+")",
+		"violation: Plant::Tank::low is false after 2 moves (probability 1) (witness "+witness+")",
 		"standing: violated (witnessed:")
 	content, err := os.ReadFile(witness)
 	if err != nil {
@@ -198,6 +200,59 @@ func TestEngineCheckWitnessOfNoChoiceReplays(t *testing.T) {
 	rejectReport(t, replayed, "replay refused", "names no move to follow")
 }
 
+// drawingTankModel breaks a property after a random draw and before any choice point.
+const drawingTankModel = `package Plant {
+    private import ScalarValues::*;
+    private import RandomFunctions::*;
+    part def Tank {
+        attribute level : Integer = 0;
+        constraint low { level < 2 }
+        action overfill {
+            first start;
+            action a { assign level := 2 + uniformInteger(0, 1); }
+            done;
+            succession first start then a;
+            succession first a then done;
+        }
+    }
+    part tank : Tank;
+}
+`
+
+// A violation reached through a draw and no choice is witnessed by the draw: -json
+// carries it on the result's witness and the violation, and the witness file replays it.
+func TestEngineCheckWitnessCarriesTheDraws(t *testing.T) {
+	binary := buildCLI(t)
+	dir := t.TempDir()
+
+	got := check(t, binary, drawingTankModel, "-json", "-engine", "check", "-seed", "7", "-instantiate", "Plant::tank",
+		"-action", "Plant::Tank::overfill Plant::tank", "-check-property", "Plant::Tank::low", "-check-witness", dir)
+	var report checkedReport
+	if err := json.Unmarshal([]byte(got.stdout), &report); err != nil {
+		t.Fatalf("stdout is not the reported JSON: %v\n%s", err, got.output())
+	}
+	r := report.Checks[len(report.Checks)-1].Results[0]
+	if got.status != 1 || r.Claim != "violated" || r.Witness == nil || len(r.Witness.Choices) != 0 || len(r.Witness.Draws) != 1 ||
+		!strings.HasPrefix(r.Witness.Draws[0], "draw uniformInteger(0, 1) = ") {
+		t.Fatalf("the drawn witness is misreported:\n%s", got.stdout)
+	}
+	if len(r.Check.Violations) != 1 || !slices.Equal(r.Check.Violations[0].Draws, r.Witness.Draws) || len(r.Check.Violations[0].Witness) != 0 {
+		t.Errorf("the violation's draws are misreported:\n%s", got.stdout)
+	}
+	witness := filepath.Join(dir, "Plant.Tank.overfill@Plant.tank.violation-1.witness")
+	content, err := os.ReadFile(witness)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.HasPrefix(string(content), r.Witness.Draws[0]+"\n") {
+		t.Errorf("witness file does not open with the draw:\n%s", content)
+	}
+	replayed := check(t, binary, drawingTankModel, "-schedule", "replay:"+witness, "-instantiate", "Plant::tank",
+		"-action", "Plant::Tank::overfill Plant::tank")
+	wantReport(t, replayed, 0, "standing: value (observed: 1 run under replay:"+witness+")")
+	rejectReport(t, replayed, "replay refused", "unseeded")
+}
+
 // One action checked on two objects writes two sets of witnesses, each named for
 // its performer, so the second check does not overwrite the first's files.
 func TestEngineCheckNamesWitnessesForThePerformer(t *testing.T) {
@@ -278,7 +333,7 @@ func TestEngineCheckBindsWitnessObjectsAcrossRuns(t *testing.T) {
 // replay:<file>: the token order is drawn at the retry, where both branches are due.
 func TestEngineReplaysAnOrderDrawnAfterTheClockRetriesAStep(t *testing.T) {
 	binary := buildCLI(t)
-	model, err := os.ReadFile(filepath.Join("..", "..", "internal", "core", "runtime", "testdata", "conformance",
+	model, err := os.ReadFile(filepath.Join("..", "..", "internal", "exec", "runtime", "testdata", "conformance",
 		"action_explore_performed_and_accept_due_together.sysml"))
 	if err != nil {
 		t.Fatal(err)
@@ -401,7 +456,7 @@ func TestEngineCheckRefusesMisuse(t *testing.T) {
 // sibling accept falls due with it — is a state the search holds and resumes.
 func TestEngineCheckSearchesAPausedBody(t *testing.T) {
 	binary := buildCLI(t)
-	paused, err := os.ReadFile(filepath.Join("..", "..", "internal", "core", "runtime", "testdata", "conformance",
+	paused, err := os.ReadFile(filepath.Join("..", "..", "internal", "exec", "runtime", "testdata", "conformance",
 		"action_explore_performed_and_accept_due_together.sysml"))
 	if err != nil {
 		t.Fatal(err)
diff --git a/cmd/sysml/check_test.go b/cmd/sysml/check_test.go
index 27761a7edc..f8ffa69375 100644
--- a/cmd/sysml/check_test.go
+++ b/cmd/sysml/check_test.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"bytes"
+	"encoding/json"
 	"errors"
 	"os"
 	"os/exec"
@@ -202,6 +203,87 @@ func TestCheckSatisfyThroughCLI(t *testing.T) {
 		"no satisfaction assertion in Rover::touchdown")
 }
 
+// validateModel holds a car whose own assertion holds, whose engine's fails and
+// whose wheels each fail, so validating the car as a whole reports every one.
+const validateModel = `
+package Fleet {
+    private import ScalarValues::Real;
+
+    part def Engine {
+        attribute power : Real = 300.0;
+        assert constraint powerBudget { power < 200.0 }
+    }
+    part def Wheel {
+        attribute pressure : Real default = 32.0;
+        assert constraint pressureOk { pressure >= 30.0 }
+    }
+    part def Car {
+        attribute mass : Real = 1500.0;
+        part engine : Engine;
+        part wheels : Wheel[2] {
+            attribute :>> pressure = 20.0;
+        }
+        assert constraint massOk { mass < 2000.0 }
+    }
+    part car : Car;
+
+    part def Crate;
+    part crate : Crate;
+}
+`
+
+// TestValidateObjectThroughCLI checks that -validate=<object> reports every
+// assertion about an object -instantiate created and the objects it holds, and
+// exits as the summary verdict decides; a bare -validate still reports the model.
+func TestValidateObjectThroughCLI(t *testing.T) {
+	binary := buildCLI(t)
+
+	wantReport(t, check(t, binary, validateModel, "-instantiate", "Fleet::car", "-validate=car"), 1,
+		"✓ assert constraint massOk holds (on Fleet::car ID: 1)",
+		"✗ assert constraint powerBudget fails (on Fleet::car.engine ID: 2)",
+		"Assertion evaluated to false: power < 200.0",
+		"✗ assert constraint pressureOk fails (on Fleet::car.wheels[1] ID: 3)",
+		"✗ assert constraint pressureOk fails (on Fleet::car.wheels[2] ID: 4)",
+		"✗ Fleet::car is not valid: 3 of 4 assertions fail")
+
+	// A nested object is validated on its own, and the id spelling reaches it too.
+	wantReport(t, check(t, binary, validateModel, "-instantiate", "Fleet::car", "-validate=car.wheels[1]", "-validate=#2"), 1,
+		"✗ Fleet::car.wheels[1] is not valid: 1 of 1 assertion fails",
+		"✗ assert constraint powerBudget fails (on #2 ID: 2)",
+		"✗ #2 is not valid: 1 of 1 assertion fails")
+
+	// Both spellings in one run: the model's diagnostics, then the object.
+	wantReport(t, check(t, binary, validateModel, "-validate", "-instantiate", "Fleet::car", "-validate=car"), 1,
+		"no errors", "✗ Fleet::car is not valid")
+
+	// An object nobody created decided nothing, so the check failed to run.
+	wantReport(t, check(t, binary, validateModel, "-validate=car"), 2, "car")
+
+	// So did one no assertion is about: nothing was shown, so it is not valid.
+	wantReport(t, check(t, binary, validateModel, "-instantiate", "Fleet::crate", "-validate=crate"), 2,
+		"? Fleet::crate states no assertion to validate")
+
+	got := check(t, binary, validateModel, "-instantiate", "Fleet::car", "-validate=car", "-json")
+	if got.status != 1 {
+		t.Fatalf("exit status = %d, want 1\n%s", got.status, got.output())
+	}
+	var report struct {
+		Checks []struct {
+			Subject string `json:"subject"`
+			Status  string `json:"status"`
+		} `json:"checks"`
+	}
+	if err := json.Unmarshal([]byte(got.stdout), &report); err != nil {
+		t.Fatalf("stdout is not the reported JSON: %v\n%s", err, got.stdout)
+	}
+	if len(report.Checks) != 5 {
+		t.Fatalf("report carries %d checks, want 4 assertions and the summary:\n%s", len(report.Checks), got.stdout)
+	}
+	if last := report.Checks[4]; last.Subject != "Fleet::car" || last.Status != "fails" {
+		t.Errorf("summary = %+v, want Fleet::car failing", last)
+	}
+}
+
 // TestCheckModelSplitAcrossFiles checks that a reference from one file to a
 // declaration in another resolves, whatever order the files are named in: the
 // analysis gate is about the model, not about each file as it is read.
@@ -330,6 +412,27 @@ func TestSatisfyCanBeTurnedOff(t *testing.T) {
 	wantReport(t, check(t, binary, checkModel, "-satisfy=false"), 2, "no check was named")
 }
 
+// TestValidateCanBeTurnedOff checks that -validate=false asks for no check, as
+// -satisfy=false does: it withdraws a bare -validate written before it, leaves
+// an object named beside it, and alone reports that no check was named.
+func TestValidateCanBeTurnedOff(t *testing.T) {
+	binary := buildCLI(t)
+
+	wantReport(t, check(t, binary, validateModel, "-validate=false"), 2, "no check was named")
+	wantReport(t, check(t, binary, validateModel, "-validate", "-validate=false"), 2, "no check was named")
+
+	got := check(t, binary, validateModel, "-validate=false", "-instantiate", "Fleet::car", "-validate=car")
+	wantReport(t, got, 1, "✗ Fleet::car is not valid: 3 of 4 assertions fail")
+	if strings.Contains(got.output(), "no errors") {
+		t.Errorf("-validate=false reported the model's diagnostics anyway:\n%s", got.output())
+	}
+	got = check(t, binary, validateModel, "-validate", "-validate=false", "-instantiate", "Fleet::car", "-validate=car")
+	wantReport(t, got, 1, "✗ Fleet::car is not valid")
+	if strings.Contains(got.output(), "no errors") {
+		t.Errorf("-validate=false left the bare -validate before it standing:\n%s", got.output())
+	}
+}
+
 // TestCheckAgainstInstantiatedObject checks that -instantiate makes a following
 // verdict be about that object, which is the only way a part's own constraint
 // reaches concrete feature values.
diff --git a/cmd/sysml/compare_test.go b/cmd/sysml/compare_test.go
new file mode 100644
index 0000000000..52da12b47d
--- /dev/null
+++ b/cmd/sysml/compare_test.go
@@ -0,0 +1,204 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/Open-MBEE/OpenSysML/internal/translate/simresults"
+)
+
+// simconfigXMI is a v1 model with a «SimulationConfig» whose result package holds
+// the snapshots a simulation tool stored of its runs.
+var simconfigXMI = filepath.Join("..", "..", "tests", "migrate", "testdata", "xmi", "simconfig.xmi")
+
+// montecarloXMI is a v1 model whose target inherits the MagicDraw customization's
+// MonteCarloAnalysis: its result package holds runs one by one and summarised.
+var montecarloXMI = filepath.Join("..", "..", "tests", "migrate", "testdata", "xmi", "montecarlo.xmi")
+
+// TestSummarisedMigrationResultsThroughCLI checks a configuration whose tool
+// summarised runs is compared by the count and mean the summary kept, pooled with
+// the runs stored one by one; that one storing no run is run and told so; and
+// that one stating no numberOfRuns is run once.
+func TestSummarisedMigrationResultsThroughCLI(t *testing.T) {
+	binary := buildCLI(t)
+	dir := t.TempDir()
+	model, sidecar := filepath.Join(dir, "model.sysml"), filepath.Join(dir, "results.json")
+
+	migrated := runCommand(t, exec.Command(binary, montecarloXMI, "-convert", "sysml", "-o", model, "-migration-results", sidecar))
+	if migrated.status != 0 {
+		t.Fatalf("migrating failed: %s", migrated.output())
+	}
+	if !strings.Contains(migrated.stderr, "(results of 3 run configuration(s): 2 with 12 stored snapshot(s) standing for 16 run(s))") {
+		t.Errorf("the sidecar summary counts no summarised runs:\n%s", migrated.output())
+	}
+	compared := runCommand(t, exec.Command(binary, model, "-compare-results", sidecar, "-seed", "1"))
+	if compared.status != 0 {
+		t.Fatalf("exit status = %d, want 0\n%s", compared.status, compared.output())
+	}
+	for _, want := range []string{
+		"compare 'Group 0' — 15 stored run(s) over 11 snapshot(s) in Results; 3 run(s) by OpenSysML, draws average, seed 1\n",
+		"p          | tool                 | 1    | 0.5   | 0.5               | 0.5   | 0.5   | 0.5",
+		"t          | tool                 | 13   |       | 4.615384615384615 |       |       |",
+		"           | OpenSysML (target.t) | 3    | 3.0   | 3.0               | 3.0   | 3.0   | 3.0",
+		"           | difference           |      |       | -35.0%            |       |       |",
+		`note: "analysis of 4 runs" summarises 4 run(s) of t: mean 3.5, deviation 0.5, 1 out of specification`,
+		`note: "analysis without a deviation" summarises 2 run(s) of t: mean 7.0` + "\n",
+		"note: the slot of MonteCarloAnalysis::Mean holds a LiteralString, which is no number in 1 snapshot(s), so it is not among the results",
+		"note: the slot of MonteCarloAnalysis::N holds 2 numbers over as many slots, and a statistic is one number in 1 snapshot(s), so it is not among the results",
+		"note: 2 snapshot(s) record a MonteCarloAnalysis statistic that is no one number, so they hold no statistics",
+		"u          | tool                 | 1    | 9.0   | 9.0               | 9.0   | 9.0   | 9.0",
+		"note: 2 snapshot(s) record MonteCarloAnalysis statistics whose Mean no value of t holds, though the analysis binds the two, so the statistics are not read",
+		"compare 'Group 1' — no stored run in Empty; 1 run(s) by OpenSysML, draws average, seed 1\n",
+		"t          | tool (no stored result to compare) | 0    |     |      |     |     |",
+		"           | OpenSysML (target.t)               | 1    | 3.0 | 3.0  | 3.0 | 3.0 | 3.0",
+		"note: the configuration states no numberOfRuns, so one run is made, as its tool makes without one; -runs <number> makes more",
+		"compare 'Group 2' — 1 stored run(s) in Unbound Results; 1 run(s) by OpenSysML, draws average, seed 1\n",
+		"note: 'Unbound Analysis' inherits MonteCarloAnalysis but binds its Mean to no value of its own, so its statistics summarise no observable",
+	} {
+		if !strings.Contains(compared.stdout, want) {
+			t.Errorf("the comparison lacks %q:\n%s", want, compared.output())
+		}
+	}
+}
+
+// TestMigrationResultsThroughCLI checks -migration-results writes the sidecar
+// -compare-results reads: the configuration's runs and draws, its target and
+// behavior, and the numbers of every snapshot; then that the migrated model is
+// run against it — under the configured count and policy, and under -runs,
+// -seed, -draws, -clock-step, -observe and -action instead — and that misuse is refused.
+func TestMigrationResultsThroughCLI(t *testing.T) {
+	binary := buildCLI(t)
+	dir := t.TempDir()
+	model, sidecar := filepath.Join(dir, "model.sysml"), filepath.Join(dir, "results.json")
+
+	migrated := runCommand(t, exec.Command(binary, simconfigXMI, "-convert", "sysml", "-o", model, "-migration-results", sidecar))
+	if migrated.status != 0 {
+		t.Fatalf("migrating failed: %s", migrated.output())
+	}
+	if !strings.Contains(migrated.stderr, "wrote "+sidecar+" (results of 1 run configuration(s): 1 with 4 stored snapshot(s))") {
+		t.Errorf("the sidecar summary belongs on stderr:\n%s", migrated.output())
+	}
+	body, err := os.ReadFile(sidecar)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var results simresults.Results
+	if err := json.Unmarshal(body, &results); err != nil {
+		t.Fatalf("the sidecar is not JSON: %v\n%s", err, body)
+	}
+	if results.Source != simconfigXMI || len(results.Configurations) != 1 {
+		t.Fatalf("sidecar = %s", body)
+	}
+	cfg := results.Configurations[0]
+	if cfg.Name != "'Group 0'" || cfg.Runs != 4 || cfg.Draws != "average" || cfg.Target != "target" || cfg.Behavior != "run" ||
+		cfg.Location != "Results" || strings.Join(cfg.Observables, ",") != "pA,pB" || len(cfg.Snapshots) != 4 {
+		t.Errorf("configuration = %+v", cfg)
+	}
+
+	compare := func(args ...string) runOutcome {
+		return runCommand(t, exec.Command(binary, append([]string{model, "-compare-results", sidecar}, args...)...))
+	}
+	configured := compare()
+	if configured.status != 0 {
+		t.Fatalf("exit status = %d, want 0\n%s", configured.status, configured.output())
+	}
+	for _, want := range []string{
+		"compare 'Group 0' — 4 stored run(s) in Results; 4 run(s) by OpenSysML, draws average\n",
+		"pA         | tool                  | 2    | 1.0   | 1.0   | 1.0   | 1.0   | 1.0",
+		"           | OpenSysML (target.pA) | 4    | 1.0   | 1.0   | 1.0   | 1.0   | 1.0",
+		"           | difference            |      | +0.0% | +0.0% | +0.0% | +0.0% | +0.0%",
+		"pB         | tool                  | 4    | 0.0   | 1.0   | 0.25  | 3.0   | 3.0",
+		"           | OpenSysML (target.pB) | 0    |",
+		"note: target.pB holds no number in any completed run, so pB is not compared",
+		"note: the slot of flag holds a LiteralBoolean, which is no number in 1 snapshot(s), so it is not among the results",
+	} {
+		if !strings.Contains(configured.stdout, want) {
+			t.Errorf("the comparison lacks %q:\n%s", want, configured.output())
+		}
+	}
+	if strings.Contains(configured.output(), "SysML v2 REPL") {
+		t.Errorf("-compare-results left a prompt:\n%s", configured.output())
+	}
+
+	overridden := compare("-runs", "3", "-seed", "5", "-draws", "random", "-clock-step", "0.5", "-observe", "pA", "-observe", "pB=target.pA", "-action", "Group 0")
+	if overridden.status != 0 {
+		t.Fatalf("exit status = %d, want 0\n%s", overridden.status, overridden.output())
+	}
+	for _, want := range []string{
+		"compare 'Group 0' — 4 stored run(s) in Results; 3 run(s) by OpenSysML, draws random, seed 5, clock step 0.5 s\n",
+		"           | OpenSysML (target.pA) | 3    | 1.0       | 1.0   | 1.0     | 1.0    | 1.0",
+		"pB         | tool                  | 4    | 0.0       | 1.0   | 0.25    | 3.0    | 3.0",
+		"           | difference            |      | +1 (of 0) | +0.0% | +300.0% | -66.7% | -66.7%",
+	} {
+		if !strings.Contains(overridden.stdout, want) {
+			t.Errorf("the overridden comparison lacks %q:\n%s", want, overridden.output())
+		}
+	}
+
+	asJSON := compare("-json")
+	if asJSON.status != 0 {
+		t.Fatalf("exit status = %d, want 0\n%s", asJSON.status, asJSON.output())
+	}
+	var report struct {
+		Status string `json:"status"`
+		Checks []struct {
+			Subject string   `json:"subject"`
+			Status  string   `json:"status"`
+			Lines   []string `json:"lines"`
+		} `json:"checks"`
+	}
+	if err := json.Unmarshal([]byte(asJSON.stdout), &report); err != nil {
+		t.Fatalf("-json wrote no JSON: %v\n%s", err, asJSON.output())
+	}
+	if report.Status != "holds" || len(report.Checks) != 1 || report.Checks[0].Subject != "compare 'Group 0'" || len(report.Checks[0].Lines) < 8 {
+		t.Errorf("-json report = %s", asJSON.stdout)
+	}
+
+	for name, tc := range map[string]struct {
+		args []string
+		want string
+	}{
+		"unknown configuration":   {[]string{model, "-compare-results", sidecar, "-action", "Group 9"}, "no configuration is named Group 9"},
+		"missing sidecar":         {[]string{model, "-compare-results", filepath.Join(dir, "none.json")}, "-compare-results: open"},
+		"sidecar not JSON":        {[]string{model, "-compare-results", model}, "the results are not the JSON -migration-results writes"},
+		"with convert":            {[]string{model, "-compare-results", sidecar, "-convert", "ttl"}, "cannot be combined with -convert"},
+		"with record-run":         {[]string{model, "-compare-results", sidecar, "-record-run", "Group 0"}, "the other checks are made in a run of their own"},
+		"results without xmi":     {[]string{model, "-convert", "ttl", "-migration-results", sidecar}, "-migration-results indexes the result snapshots of a SysML v1 migration"},
+		"results without convert": {[]string{model, "-migration-results", sidecar}, "-migration-results accompanies -convert"},
+		"results over the model":  {[]string{simconfigXMI, "-convert", "sysml", "-o", sidecar, "-migration-results", sidecar}, "-migration-results and -o both name"},
+		"results over the report": {[]string{simconfigXMI, "-convert", "sysml", "-migration-report", sidecar, "-migration-results", sidecar}, "-migration-results and -migration-report both name"},
+		"results over the input":  {[]string{simconfigXMI, "-convert", "sysml", "-migration-results", simconfigXMI}, "names the model being migrated"},
+		"empty results path":      {[]string{simconfigXMI, "-convert", "sysml", "-migration-results="}, "-migration-results is empty"},
+	} {
+		t.Run(name, func(t *testing.T) {
+			got := runCommand(t, exec.Command(binary, tc.args...))
+			if got.status == 0 {
+				t.Fatalf("expected a non-zero exit, got:\n%s", got.output())
+			}
+			if !strings.Contains(got.output(), tc.want) {
+				t.Errorf("expected %q in the error, got:\n%s", tc.want, got.output())
+			}
+		})
+	}
+
+	// An explicitly empty path is a usage error, not the REPL the model alone would open.
+	for name, args := range map[string][]string{
+		"empty":       {model, "-compare-results="},
+		"empty first": {"-compare-results=", model},
+		"with action": {model, "-compare-results=", "-action", "Group 0"},
+	} {
+		t.Run("empty compare path "+name, func(t *testing.T) {
+			got := runCommand(t, exec.Command(binary, args...))
+			if got.status != 2 || !strings.Contains(got.stderr, "-compare-results is empty; name the JSON file -migration-results wrote") {
+				t.Fatalf("expected a usage error, got status %d:\n%s", got.status, got.output())
+			}
+			if got.stdout != "" {
+				t.Errorf("a usage error writes nothing to stdout, got:\n%s", got.stdout)
+			}
+		})
+	}
+}
diff --git a/cmd/sysml/compile.go b/cmd/sysml/compile.go
index 66a63175bb..2ac604c6e3 100644
--- a/cmd/sysml/compile.go
+++ b/cmd/sysml/compile.go
@@ -7,7 +7,7 @@ import (
 	"slices"
 	"strings"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/codegen"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/codegen"
 )
 
 // runCompile compiles the calc -compile names into the executable, or with
diff --git a/cmd/sysml/compile_test.go b/cmd/sysml/compile_test.go
index fc33294053..06bb1febe3 100644
--- a/cmd/sysml/compile_test.go
+++ b/cmd/sysml/compile_test.go
@@ -6,7 +6,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/codegen"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/codegen"
 )
 
 const compileModel = `package Compiled {
diff --git a/cmd/sysml/convert.go b/cmd/sysml/convert.go
index bb505167aa..943a184fb4 100644
--- a/cmd/sysml/convert.go
+++ b/cmd/sysml/convert.go
@@ -2,15 +2,23 @@ package main
 
 import (
 	"bytes"
+	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/export"
-	"github.com/Open-MBEE/OpenSysML/internal/core/migrate"
-	"github.com/Open-MBEE/OpenSysML/internal/core/project"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/convert"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/export"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/interop/flexo"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/migrate"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/mtip"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/simresults"
+	"github.com/Open-MBEE/OpenSysML/internal/workspace/project"
 )
 
 // deprecatedFlag rejects a flag that has been replaced, so the old spelling
@@ -30,67 +38,248 @@ func (f *deprecatedFlag) Set(string) error { return errors.New(f.instead) }
 //
 // A SysML v1 model (-from xmi, or a .xmi/.uml/.mdzip file) is migrated to v2 on the
 // way in; see writeMigrationReport for where its report goes.
-func runConvert(files []string) error {
+//
+// Either side may name a Flexo branch URL instead of a file: read as its RDF
+// graph, or the place a Turtle conversion is pushed.
+func runConvertExit(files []string) int {
+	status, err := runConvert(files)
+	if err != nil {
+		return fail(err)
+	}
+	return status
+}
+
+func runConvert(files []string) (int, error) {
 	to, err := parseTargetFormat(convertFormat)
 	if err != nil {
-		return err
+		return 0, err
 	}
 	if len(files) == 0 {
-		return errors.New("no model to convert; name the file to convert, as `sysml model.sysml -convert ttl`")
+		return 0, errors.New("no model to convert; name the file to convert, as `sysml model.sysml -convert ttl`")
 	}
 	if len(files) > 1 {
-		return fmt.Errorf("-convert converts one file; unexpected extra argument %q", files[1])
+		return 0, fmt.Errorf("-convert converts one file; unexpected extra argument %q", files[1])
 	}
 	input := files[0]
 
+	// A run asked to record puts the records in the session's buffer rather than
+	// in the file, so what is converted is that buffer's text, as %save writes it.
+	if len(modelChecks.records) > 0 {
+		if err := recordedConvertMisuse(input); err != nil {
+			return 0, err
+		}
+		return convertRecorded(input, to)
+	}
+
+	inputRef, inputIsURL, err := flexo.ParseBranchURL(input)
+	if err != nil {
+		return 0, err
+	}
+	outputRef := flexo.BranchRef{}
+	outputIsURL := false
+	if outputPath != "" {
+		if outputRef, outputIsURL, err = flexo.ParseBranchURL(outputPath); err != nil {
+			return 0, err
+		}
+	}
+	switch {
+	case inputIsURL && outputIsURL:
+		return 0, errors.New("a repository branch can be read or pushed in one run, not both; write the branch to a file, or convert a file to the branch")
+	case outputIsURL:
+		return pushBranch(input, to, outputRef)
+	case inputIsURL:
+		return readBranch(inputRef, to)
+	}
+	if syncState != "" {
+		return 0, fmt.Errorf("-sync-state records a repository branch's head; neither %s nor -o names a branch", input)
+	}
+
 	from, err := resolveFormat(fromFormat, input)
 	if err != nil {
-		return err
+		return 0, err
 	}
 
 	name, data, err := project.ReadFile(input)
 	if err != nil {
-		return err
+		return 0, err
 	}
 	// Reported before the conversion, so a refusal carries it too, and on stderr,
 	// where it cannot land in the converted model written to stdout.
-	for _, notice := range export.Notices(from, to) {
+	for _, notice := range convert.Notices(from, to) {
 		fmt.Fprintf(os.Stderr, "note: %s\n", notice)
 	}
-	if migrationReport != "" && from != export.FormatXMI {
-		return fmt.Errorf("-migration-report describes a SysML v1 migration, and %s input is not migrated; pass it with -from xmi or a .xmi/.uml/.mdzip file", from)
+	if migrationReport != "" && from != convert.FormatXMI {
+		return 0, fmt.Errorf("-migration-report describes a SysML v1 migration, and %s input is not migrated; pass it with -from xmi or a .xmi/.uml/.mdzip file", from)
 	}
 	if migrationReport != "" && outputPath != "" && samePath(migrationReport, outputPath) {
-		return fmt.Errorf("-migration-report and -o both name %s; the report would be replaced by the model", outputPath)
+		return 0, fmt.Errorf("-migration-report and -o both name %s; the report would be replaced by the model", outputPath)
 	}
 	if migrationReport != "" && input != "-" && samePath(migrationReport, input) {
-		return fmt.Errorf("-migration-report names the model being migrated, %s; the report would replace it", input)
+		return 0, fmt.Errorf("-migration-report names the model being migrated, %s; the report would replace it", input)
+	}
+	if err := migrationResultsMisuse(from, input); err != nil {
+		return 0, err
+	}
+	if err := layoutMisuse(from, input); err != nil {
+		return 0, err
 	}
 	// A v2 model may be rewritten in place; a v1 model would be lost.
-	if from == export.FormatXMI && outputPath != "" && input != "-" && samePath(outputPath, input) {
-		return fmt.Errorf("-o names the model being migrated, %s; the v1 model would be replaced by its migration", input)
+	if from == convert.FormatXMI && outputPath != "" && input != "-" && samePath(outputPath, input) {
+		return 0, fmt.Errorf("-o names the model being migrated, %s; the v1 model would be replaced by its migration", input)
+	}
+	out, err := convertInput(name, data, from, to)
+	if err != nil {
+		return 0, err
+	}
+	if outputPath == "" {
+		_, err := os.Stdout.Write(out)
+		return exitHolds, err
+	}
+	if err := writeConversion(outputPath, out, to); err != nil {
+		return 0, err
+	}
+	return exitHolds, nil
+}
+
+// convertInput runs the conversion the input format asks for: a SysML v1 model
+// is migrated and its report written, anything else converted.
+func convertInput(name string, data []byte, from, to convert.Format) ([]byte, error) {
+	opts, err := convertOptions(from, to)
+	if err != nil {
+		return nil, err
+	}
+	if from != convert.FormatXMI {
+		return convert.ConvertWith(name, data, from, to, opts)
+	}
+	migOpts, err := migrationOptions()
+	if err != nil {
+		return nil, err
+	}
+	migrated, err := convert.Migrate(name, data, to, migOpts)
+	if err != nil {
+		return nil, err
+	}
+	if err := writeMigrationReport(migrated.Report); err != nil {
+		return nil, err
+	}
+	if err := writeMigrationResults(migrated.Results); err != nil {
+		return nil, err
+	}
+	return migrated.Output, nil
+}
+
+// recordedConvertMisuse is why a flag cannot share the run -record-run
+// converts: what is converted is the session the records join, not a file
+// migrated or a branch read or pushed.
+func recordedConvertMisuse(input string) error {
+	inRef, inputIsURL, err := flexo.ParseBranchURL(input)
+	if err != nil {
+		return err
+	}
+	if inputIsURL {
+		return fmt.Errorf("-record-run converts the recorded session model; a repository branch is not an input it reads (%s)", inRef)
 	}
-	var out []byte
-	if from == export.FormatXMI {
-		var report *migrate.Report
-		out, report, err = export.Migrate(name, data, to)
+	if outputPath != "" {
+		outRef, outputIsURL, err := flexo.ParseBranchURL(outputPath)
 		if err != nil {
 			return err
 		}
-		if err := writeMigrationReport(report); err != nil {
-			return err
+		if outputIsURL {
+			return fmt.Errorf("-record-run converts the recorded session model; -o cannot push it to a repository branch (%s)", outRef)
 		}
-	} else {
-		out, err = export.Convert(name, data, from, to)
+	}
+	switch {
+	case syncState != "":
+		return errors.New("-record-run converts the recorded session model; -sync-state does not apply")
+	case migrationReport != "":
+		return errors.New("-record-run converts the recorded session model; -migration-report does not apply")
+	case migrationResults != "":
+		return errors.New("-record-run converts the recorded session model; -migration-results does not apply")
+	case layoutPath != "":
+		return errors.New("-record-run converts the recorded session model; -layout does not apply")
+	}
+	return nil
+}
+
+// convertRecorded loads the file, makes the runs -record-run names so the
+// records join the session's buffer, and converts that text; a load that did
+// not analyse or a run that failed converts nothing.
+func convertRecorded(input string, to convert.Format) (int, error) {
+	if fromFormat != "" && fromFormat != "sysml" {
+		return 0, fmt.Errorf("-record-run records into SysML notation; -from %s does not apply", fromFormat)
+	}
+	sess := newSession()
+	report, err := sess.LoadPathsReport([]string{input})
+	if err != nil {
+		return 0, err
+	}
+	writeLines(os.Stderr, report.Loaded)
+	writeLines(os.Stderr, report.Found)
+	writeLines(os.Stderr, report.Declared)
+	if report.Errors {
+		return 0, fmt.Errorf("%s did not analyse cleanly; nothing was converted", input)
+	}
+	// The objects -instantiate names are materialized first, so a run named on
+	// one has it to record.
+	for _, name := range modelChecks.instantiate {
+		created, err := sess.InstantiateReport(name)
 		if err != nil {
-			return err
+			return 0, err
+		}
+		writeLines(os.Stderr, created.Lines)
+		if len(created.FeatureValueErrors) > 0 {
+			writeLines(os.Stderr, created.FeatureValueErrors)
+			return 0, fmt.Errorf("%s did not materialize cleanly; nothing was converted", name)
 		}
 	}
-	if outputPath == "" {
-		_, err := os.Stdout.Write(out)
-		return err
+	for _, invocation := range modelChecks.records {
+		verdict := modelChecks.record(sess, invocation)
+		writeLines(os.Stderr, verdict.Lines)
+		if verdict.Status != repl.VerdictHolds {
+			return 0, fmt.Errorf("%s: the run was not recorded; nothing was converted", invocation)
+		}
+	}
+	opts, err := convertOptions(convert.FormatSysML, to)
+	if err != nil {
+		return 0, err
+	}
+	out, tolerated, err := convert.ConvertTolerantWith(repl.SessionOrigin, []byte(sess.Text()), convert.FormatSysML, to, opts)
+	if err != nil {
+		return 0, err
+	}
+	if tolerated != nil {
+		for _, line := range strings.Split("warning: "+tolerated.Error(), "\n") {
+			fmt.Fprintln(os.Stderr, line)
+		}
+	}
+	if outputPath != "" {
+		return exitHolds, writeConversion(outputPath, out, to)
+	}
+	_, err = os.Stdout.Write(out)
+	return exitHolds, err
+}
+
+// convertOptions are the conversion settings -id asks for, refusing it for a
+// direction it does not apply to.
+func convertOptions(from, to convert.Format) (convert.Options, error) {
+	opts := convert.Options{}
+	if idForm == "" {
+		return opts, nil
+	}
+	if from != convert.FormatSysML || (to != convert.FormatTurtle && to != convert.FormatAPIJSON) {
+		return opts, fmt.Errorf("-id applies to -convert ttl or api-json from SysML notation")
 	}
-	replaced, err := export.WriteFile(outputPath, out)
+	form, ok := export.ParseIDForm(idForm)
+	if !ok {
+		return opts, fmt.Errorf("-id wants qualified or uuid, not %q", idForm)
+	}
+	opts.ID = form
+	return opts, nil
+}
+
+// writeConversion writes converted output to a file and reports it.
+func writeConversion(path string, out []byte, to convert.Format) error {
+	replaced, err := export.WriteFile(path, out)
 	if err != nil {
 		return err
 	}
@@ -98,7 +287,237 @@ func runConvert(files []string) error {
 	if replaced {
 		what = ", replaced the existing file"
 	}
-	fmt.Fprintf(os.Stderr, "wrote %s (%s, %d bytes%s)\n", outputPath, to, len(out), what)
+	fmt.Fprintf(os.Stderr, "wrote %s (%s, %d bytes%s)\n", path, to, len(out), what)
+	return nil
+}
+
+// openBranch resolves a branch URL's repository under the shared bearer token;
+// an http(s) form naming another endpoint would split reads and writes across stacks.
+func openBranch(ref flexo.BranchRef) (*flexo.Repository, flexo.Config, error) {
+	cfg, err := flexo.ConfigFromEnv()
+	if err != nil {
+		return nil, flexo.Config{}, fmt.Errorf("a repository branch needs its bearer token: %w", err)
+	}
+	if ref.SysMLV2URL != "" && !flexo.SameEndpoint(ref.SysMLV2URL, cfg.SysMLV2URL) {
+		return nil, flexo.Config{}, fmt.Errorf("%s names a SysML v2 endpoint other than the configured %s (%s); point %s and %s at that stack together, or write flexo://%s/%s",
+			ref.SysMLV2URL, cfg.SysMLV2URL, flexo.EnvSysMLV2URL, flexo.EnvSysMLV2URL, flexo.EnvLayer1URL, ref.Project, ref.Branch)
+	}
+	if err := cfg.CheckTransport(); err != nil {
+		return nil, flexo.Config{}, err
+	}
+	return flexo.New(cfg).Repository(ref.Project, ref.Branch), cfg, nil
+}
+
+// readBranch converts a repository branch to -convert's format: the branch is
+// read as its head commit's RDF graph.
+func readBranch(ref flexo.BranchRef, to convert.Format) (int, error) {
+	if fromFormat != "" {
+		if f, err := convert.ParseFormat(fromFormat); err != nil {
+			return 0, err
+		} else if f != convert.FormatTurtle {
+			return 0, fmt.Errorf("a repository branch is read as its RDF graph; -from %s does not apply", fromFormat)
+		}
+	}
+	for _, notice := range convert.Notices(convert.FormatTurtle, to) {
+		fmt.Fprintf(os.Stderr, "note: %s\n", notice)
+	}
+	if migrationReport != "" {
+		return 0, fmt.Errorf("-migration-report describes a SysML v1 migration, and a repository branch is not migrated; pass it with -from xmi or a .xmi/.uml/.mdzip file")
+	}
+	if migrationResults != "" {
+		return 0, fmt.Errorf("-migration-results indexes the result snapshots of a SysML v1 migration, and a repository branch is not migrated; pass it with -from xmi or a .xmi/.uml/.mdzip file")
+	}
+	if layoutPath != "" {
+		return 0, fmt.Errorf("-layout augments a SysML v1 migration, and a repository branch is not migrated; pass it with -from xmi or a .xmi/.uml/.mdzip file")
+	}
+	repo, cfg, err := openBranch(ref)
+	if err != nil {
+		return 0, err
+	}
+	// Resolve and check the state file before anything is written: -o must
+	// never replace it, and a state pinned elsewhere refuses first.
+	statePath := syncState
+	if statePath == "" && outputPath != "" {
+		statePath = reposync.StatePath(outputPath)
+	}
+	if statePath != "" && outputPath != "" && samePath(statePath, outputPath) {
+		return 0, fmt.Errorf("-o and -sync-state both name %s; the model would replace the recorded commit", outputPath)
+	}
+	var state *reposync.State
+	if statePath != "" {
+		if state, err = reposync.LoadState(statePath); err != nil {
+			return 0, err
+		}
+	}
+	scope := reposync.Scope{Org: cfg.Org, ProjectID: ref.Project, Branch: ref.Branch}
+	if state != nil {
+		if err := state.Check(scope); err != nil {
+			return 0, err
+		}
+	}
+	graph, err := repo.Graph(context.Background())
+	if err != nil {
+		return failRepository(fmt.Errorf("read the repository: %w", err)), nil
+	}
+	out, err := convert.FromGraph(graph, to)
+	if err != nil {
+		return 0, err
+	}
+	if outputPath == "" {
+		if _, err := os.Stdout.Write(out); err != nil {
+			return 0, err
+		}
+	} else if err := writeConversion(outputPath, out, to); err != nil {
+		return 0, err
+	}
+	if statePath == "" {
+		return exitHolds, nil
+	}
+	return recordBranchState(repo.Seen(), state, scope, statePath)
+}
+
+// pushBranch replaces a branch's model graph with the model converted to
+// Turtle; a sync state the head moved past refuses the write.
+func pushBranch(input string, to convert.Format, ref flexo.BranchRef) (int, error) {
+	if to != convert.FormatTurtle {
+		return 0, fmt.Errorf("a repository branch holds a graph; convert to ttl to push, not %s", to)
+	}
+	statePath := syncState
+	if statePath == "" {
+		if project.IsStdin(input) {
+			return 0, errors.New("a push records the commit it makes beside the model; with the model on stdin, name the state file with -sync-state")
+		}
+		statePath = reposync.StatePath(input)
+	}
+	if migrationReport != "" && input != "-" && samePath(migrationReport, input) {
+		return 0, fmt.Errorf("-migration-report names the model being migrated, %s; the report would replace it", input)
+	}
+	if migrationReport != "" && samePath(migrationReport, statePath) {
+		return 0, fmt.Errorf("-migration-report and the sync state both name %s; the report would be replaced by the recorded commit", statePath)
+	}
+	if migrationResults != "" && samePath(migrationResults, statePath) {
+		return 0, fmt.Errorf("-migration-results and the sync state both name %s; the results would be replaced by the recorded commit", statePath)
+	}
+	repo, cfg, err := openBranch(ref)
+	if err != nil {
+		return 0, err
+	}
+	scope := reposync.Scope{Org: cfg.Org, ProjectID: ref.Project, Branch: ref.Branch}
+	state, err := reposync.LoadState(statePath)
+	if err != nil {
+		return 0, err
+	}
+	if state != nil {
+		if err := state.Check(scope); err != nil {
+			return 0, err
+		}
+		repo.Resume(state.LastSeenCommit)
+	}
+	from, err := resolveFormat(fromFormat, input)
+	if err != nil {
+		return 0, err
+	}
+	name, data, err := project.ReadFile(input)
+	if err != nil {
+		return 0, err
+	}
+	for _, notice := range convert.Notices(from, to) {
+		fmt.Fprintf(os.Stderr, "note: %s\n", notice)
+	}
+	if migrationReport != "" && from != convert.FormatXMI {
+		return 0, fmt.Errorf("-migration-report describes a SysML v1 migration, and %s input is not migrated; pass it with -from xmi or a .xmi/.uml/.mdzip file", from)
+	}
+	if err := migrationResultsMisuse(from, input); err != nil {
+		return 0, err
+	}
+	if err := layoutMisuse(from, input); err != nil {
+		return 0, err
+	}
+	out, err := convertInput(name, data, from, to)
+	if err != nil {
+		return 0, err
+	}
+	head, err := repo.Push(context.Background(), out, "sysml -convert ttl")
+	if err != nil {
+		var stale *flexo.StaleBranchError
+		var unrecorded *flexo.UnrecordedPushError
+		var superseded *flexo.SupersededPushError
+		switch {
+		case errors.As(err, &stale):
+			fmt.Fprintf(os.Stderr, "%srefused to push: %v\n", commandPrefix, err)
+			return exitFailed, nil
+		case errors.As(err, &unrecorded), errors.As(err, &superseded):
+			fmt.Fprintf(os.Stderr, "%s%v\n", commandPrefix, err)
+			return exitFailed, nil
+		}
+		return failRepository(fmt.Errorf("push to the repository: %w", err)), nil
+	}
+	if state == nil {
+		state = &reposync.State{}
+	}
+	state.Org, state.ProjectID, state.Branch, state.LastSeenCommit = cfg.Org, ref.Project, ref.Branch, head
+	if err := state.Save(statePath); err != nil {
+		return 0, fmt.Errorf("pushed %d bytes of Turtle as commit %s, but could not record it: %w", len(out), head, err)
+	}
+	fmt.Fprintf(os.Stderr, "pushed %d bytes of Turtle to branch %s of %s; head commit %s recorded in %s\n",
+		len(out), ref.Branch, ref.Project, head, statePath)
+	return exitHolds, nil
+}
+
+// recordBranchState writes the head commit the run stood at to the sync state
+// file, over the state already loaded and checked for this scope.
+func recordBranchState(head string, state *reposync.State, scope reposync.Scope, statePath string) (int, error) {
+	if head == "" {
+		return exitHolds, nil
+	}
+	if state != nil && state.Scope() == scope && state.LastSeenCommit == head {
+		return exitHolds, nil
+	}
+	if state == nil {
+		state = &reposync.State{}
+	}
+	state.Org, state.ProjectID, state.Branch, state.LastSeenCommit = scope.Org, scope.ProjectID, scope.Branch, head
+	if err := state.Save(statePath); err != nil {
+		return 0, fmt.Errorf("could not record head commit %s: %w", head, err)
+	}
+	fmt.Fprintf(os.Stderr, "head commit %s recorded in %s\n", head, statePath)
+	return exitHolds, nil
+}
+
+// migrationOptions reads the -layout MTIP export into the migration's
+// options; none were given when the flag was not passed.
+func migrationOptions() (migrate.Options, error) {
+	if layoutPath == "" {
+		return migrate.Options{}, nil
+	}
+	data, err := os.ReadFile(layoutPath)
+	if err != nil {
+		return migrate.Options{}, err
+	}
+	layout, err := mtip.Parse(data)
+	if err != nil {
+		return migrate.Options{}, fmt.Errorf("%s: %w", layoutPath, err)
+	}
+	return migrate.Options{Layout: layout, LayoutSource: layoutPath}, nil
+}
+
+// layoutMisuse reports why -layout augments nothing: a v2 input has no
+// migration to lay out, and the export must not name a file the run rewrites.
+func layoutMisuse(from convert.Format, input string) error {
+	switch {
+	case layoutPath == "":
+		return nil
+	case from != convert.FormatXMI:
+		return fmt.Errorf("-layout augments a SysML v1 migration, and %s input is not migrated; pass it with -from xmi or a .xmi/.uml/.mdzip file", from)
+	case outputPath != "" && samePath(layoutPath, outputPath):
+		return fmt.Errorf("-layout and -o both name %s; the model would replace the layout export", outputPath)
+	case migrationReport != "" && samePath(layoutPath, migrationReport):
+		return fmt.Errorf("-layout and -migration-report both name %s; the report would replace the layout export", migrationReport)
+	case migrationResults != "" && samePath(layoutPath, migrationResults):
+		return fmt.Errorf("-layout and -migration-results both name %s; the results would replace the layout export", migrationResults)
+	case input != "-" && samePath(layoutPath, input):
+		return fmt.Errorf("-layout names the model being migrated, %s; the migration would replace it", input)
+	}
 	return nil
 }
 
@@ -124,6 +543,41 @@ func writeMigrationReport(report *migrate.Report) error {
 	return nil
 }
 
+// migrationResultsMisuse reports why -migration-results writes nothing: a v2 input
+// has no tool results to index, and the sidecar must not replace the model or report.
+func migrationResultsMisuse(from convert.Format, input string) error {
+	switch {
+	case migrationResults == "":
+		return nil
+	case from != convert.FormatXMI:
+		return fmt.Errorf("-migration-results indexes the result snapshots of a SysML v1 migration, and %s input is not migrated; pass it with -from xmi or a .xmi/.uml/.mdzip file", from)
+	case outputPath != "" && samePath(migrationResults, outputPath):
+		return fmt.Errorf("-migration-results and -o both name %s; the results would be replaced by the model", outputPath)
+	case migrationReport != "" && samePath(migrationResults, migrationReport):
+		return fmt.Errorf("-migration-results and -migration-report both name %s; the results would be replaced by the report", migrationReport)
+	case input != "-" && samePath(migrationResults, input):
+		return fmt.Errorf("-migration-results names the model being migrated, %s; the results would replace it", input)
+	}
+	return nil
+}
+
+// writeMigrationResults writes the result snapshots the migration indexed to the
+// -migration-results file as JSON, for -compare-results to read against the migrated model.
+func writeMigrationResults(results *simresults.Results) error {
+	if migrationResults == "" {
+		return nil
+	}
+	body, err := json.MarshalIndent(results, "", "  ")
+	if err != nil {
+		return err
+	}
+	if _, err := export.WriteFile(migrationResults, append(body, '\n')); err != nil {
+		return err
+	}
+	fmt.Fprintf(os.Stderr, "wrote %s (%s)\n", migrationResults, results.Summary())
+	return nil
+}
+
 // samePath reports whether a and b name one file, following symbolic links,
 // including a dangling link to a file neither has written yet.
 func samePath(a, b string) bool {
@@ -167,13 +621,13 @@ func resolvePath(path string) (string, error) {
 
 // parseTargetFormat resolves the -convert value, explaining the flag when a file
 // name was passed where a format belongs — the spelling this flag used to take.
-func parseTargetFormat(value string) (export.Format, error) {
-	f, err := export.ParseFormat(value)
+func parseTargetFormat(value string) (convert.Format, error) {
+	f, err := convert.ParseFormat(value)
 	if err != nil && namesAFile(value) {
 		return 0, fmt.Errorf("%w; -convert names the format to convert to, so write `sysml %s -convert ttl`", err, value)
 	}
 	if err == nil && !f.Writable() {
-		return 0, &export.NotWritableError{Format: f}
+		return 0, &convert.NotWritableError{Format: f}
 	}
 	return f, err
 }
@@ -190,13 +644,13 @@ func namesAFile(value string) bool {
 // resolveFormat returns the format named by the flag, or the one the path's
 // extension implies. Standard input carries no extension to read it from, so
 // -from is the only thing that can name its format.
-func resolveFormat(flagValue, path string) (export.Format, error) {
+func resolveFormat(flagValue, path string) (convert.Format, error) {
 	if flagValue != "" {
-		return export.ParseFormat(flagValue)
+		return convert.ParseFormat(flagValue)
 	}
 	if project.IsStdin(path) {
 		return 0, errors.New("standard input carries no file name to take the format from; name it with -from, as `-from sysml`")
 	}
-	f, err := export.FormatOfPath(path)
-	return f, export.Advise(err, "pass -from, or "+export.ExtensionAdvice)
+	f, err := convert.FormatOfPath(path)
+	return f, convert.Advise(err, "pass -from, or "+convert.ExtensionAdvice)
 }
diff --git a/cmd/sysml/convert_branch_test.go b/cmd/sysml/convert_branch_test.go
new file mode 100644
index 0000000000..d909ef4f86
--- /dev/null
+++ b/cmd/sysml/convert_branch_test.go
@@ -0,0 +1,297 @@
+package main
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/Open-MBEE/OpenSysML/internal/translate/interop/flexo"
+)
+
+// branchCommand runs the binary against the fake stack like syncCommand, with
+// the SysML v2 endpoint set too so the flexo:// shorthand resolves to it.
+func branchCommand(stack *fakeStack, binary string, args ...string) *exec.Cmd {
+	cmd := syncCommand(stack, binary, args...)
+	cmd.Env = append(cmd.Env, flexo.EnvSysMLV2URL+"="+stack.server.URL)
+	return cmd
+}
+
+func TestConvertReadsABranchAsNotationAndTurtle(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+
+	out, code := exitCode(t, branchCommand(stack, binary, "flexo://proj-1/main", "-convert", "sysml"))
+	if code != 0 || !strings.Contains(out, "part def Vehicle") {
+		t.Fatalf("read a branch as notation: exit %d:\n%s", code, out)
+	}
+
+	branchURL := stack.server.URL + "/projects/proj-1/branches/main"
+	out, code = exitCode(t, branchCommand(stack, binary, branchURL, "-convert", "ttl"))
+	if code != 0 || !strings.Contains(out, "sysml:PartDefinition") && !strings.Contains(out, "8f3a41d0") {
+		t.Fatalf("read a branch as Turtle: exit %d:\n%s", code, out)
+	}
+}
+
+func TestConvertReadsABranchIntoAFileAndRecordsTheHead(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	out_path := filepath.Join(t.TempDir(), "out.sysml")
+
+	out, code := exitCode(t, branchCommand(stack, binary, stack.server.URL+"/projects/proj-1/branches/main", "-convert", "sysml", "-o", out_path))
+	if code != 0 {
+		t.Fatalf("read a branch to a file: exit %d:\n%s", code, out)
+	}
+	written, err := os.ReadFile(out_path)
+	if err != nil || !strings.Contains(string(written), "part def Vehicle") {
+		t.Fatalf("the output file: %v\n%s", err, written)
+	}
+	state, err := os.ReadFile(out_path + ".sync.json")
+	if err != nil || !strings.Contains(string(state), `"lastSeenCommit": "commit-0"`) || !strings.Contains(string(state), `"projectId": "proj-1"`) {
+		t.Fatalf("sync state after a branch read: %v\n%s", err, state)
+	}
+	if !strings.Contains(out, "head commit commit-0 recorded in") {
+		t.Errorf("the recorded head is not reported:\n%s", out)
+	}
+}
+
+func TestConvertPushReplacesTheBranchGraph(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	dir := t.TempDir()
+	model := writeModel(t, dir, "model.sysml", renamedModel)
+	branchURL := stack.server.URL + "/projects/proj-1/branches/main"
+
+	out, code := exitCode(t, branchCommand(stack, binary, model, "-convert", "ttl", "-o", branchURL))
+	if code != 0 {
+		t.Fatalf("push: exit %d:\n%s", code, out)
+	}
+	if len(stack.puts) != 1 {
+		t.Fatalf("the push sent %d write(s), want 1", len(stack.puts))
+	}
+	put := stack.puts[0]
+	if !strings.HasPrefix(put.url, "/orgs/sysmlv2/repos/proj-1/branches/main/graph") || !strings.Contains(put.url, "message=") {
+		t.Errorf("the push hit %s", put.url)
+	}
+	if put.ifMatch != `"commit-0"` {
+		t.Errorf("the push was conditioned on %q, not the branch etag", put.ifMatch)
+	}
+	if put.contentType != "text/turtle" {
+		t.Errorf("the push went as %q, not Turtle", put.contentType)
+	}
+	if stack.head != "commit-1" {
+		t.Errorf("the push's commit is %q, want commit-1", stack.head)
+	}
+	state, err := os.ReadFile(model + ".sync.json")
+	if err != nil || !strings.Contains(string(state), `"lastSeenCommit": "commit-1"`) || !strings.Contains(string(state), `"projectId": "proj-1"`) {
+		t.Fatalf("sync state after the push: %v\n%s", err, state)
+	}
+	if !strings.Contains(out, "pushed") || !strings.Contains(out, "head commit commit-1 recorded in") {
+		t.Errorf("the pushed commit is not reported:\n%s", out)
+	}
+	if !strings.Contains(string(stack.puts[0].body), "declaredName") || !strings.Contains(string(stack.puts[0].body), "Car") {
+		t.Errorf("the pushed graph does not hold the model:\n%s", stack.puts[0].body)
+	}
+}
+
+func TestConvertPushRefusesAHeadTheStateSaysMoved(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	dir := t.TempDir()
+	model := writeModel(t, dir, "model.sysml", renamedModel)
+	writeModel(t, dir, "model.sysml.sync.json", `{"org":"sysmlv2","projectId":"proj-1","branch":"main","lastSeenCommit":"commit-old"}`)
+	branchURL := stack.server.URL + "/projects/proj-1/branches/main"
+
+	out, code := exitCode(t, branchCommand(stack, binary, model, "-convert", "ttl", "-o", branchURL))
+	if code != 1 || len(stack.puts) != 0 {
+		t.Fatalf("push against a moved head: exit %d, %d write(s):\n%s", code, len(stack.puts), out)
+	}
+	if !strings.Contains(out, "refused to push") || !strings.Contains(out, "commit-old") {
+		t.Errorf("the stale refusal is not explained:\n%s", out)
+	}
+}
+
+func TestConvertPushNeedsTurtleAndAUsefulState(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	dir := t.TempDir()
+	model := writeModel(t, dir, "model.sysml", renamedModel)
+	branchURL := stack.server.URL + "/projects/proj-1/branches/main"
+
+	out, code := exitCode(t, branchCommand(stack, binary, model, "-convert", "sysml", "-o", branchURL))
+	if code != 2 || len(stack.puts) != 0 || !strings.Contains(out, "ttl") {
+		t.Fatalf("-convert sysml to a branch: exit %d:\n%s", code, out)
+	}
+
+	out, code = exitCode(t, branchCommand(stack, binary, "flexo://proj-1/main", "-convert", "ttl", "-o", branchURL))
+	if code != 2 || len(stack.puts) != 0 {
+		t.Fatalf("branch input and branch output: exit %d:\n%s", code, out)
+	}
+
+	out, code = exitCode(t, branchCommand(stack, binary, branchURL, "-convert", "sysml", "-from", "sysml"))
+	if code != 2 || !strings.Contains(out, "RDF graph") {
+		t.Fatalf("-from sysml on a branch read: exit %d:\n%s", code, out)
+	}
+}
+
+func TestConvertChecksTheStateAgainstTheConfiguredOrg(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	dir := t.TempDir()
+	model := writeModel(t, dir, "model.sysml", renamedModel)
+
+	// A state pinned to the configured org is accepted and the push lands.
+	writeModel(t, dir, "model.sysml.sync.json", `{"org":"acme","projectId":"proj-1","branch":"main","lastSeenCommit":"commit-0"}`)
+	cmd := branchCommand(stack, binary, model, "-convert", "ttl", "-o", "flexo://proj-1/main")
+	cmd.Env = append(cmd.Env, flexo.EnvOrg+"=acme")
+	if out, code := exitCode(t, cmd); code != 0 || len(stack.puts) != 1 {
+		t.Fatalf("push under the state's org: exit %d, %d write(s):\n%s", code, len(stack.puts), out)
+	}
+
+	// A state pinned to another org is refused before any write.
+	stack.head = "commit-0"
+	writeModel(t, dir, "model.sysml.sync.json", `{"org":"other","projectId":"proj-1","branch":"main","lastSeenCommit":"commit-0"}`)
+	cmd = branchCommand(stack, binary, model, "-convert", "ttl", "-o", "flexo://proj-1/main")
+	cmd.Env = append(cmd.Env, flexo.EnvOrg+"=acme")
+	out, code := exitCode(t, cmd)
+	if code != 2 || len(stack.puts) != 1 || !strings.Contains(out, "org other") {
+		t.Fatalf("push under another org: exit %d:\n%s", code, out)
+	}
+}
+
+func TestConvertPushReportsAWriteNoCommitWasNamedFor(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	stack.noPutETag = true
+	dir := t.TempDir()
+	model := writeModel(t, dir, "model.sysml", renamedModel)
+
+	out, code := exitCode(t, branchCommand(stack, binary, model, "-convert", "ttl", "-o", "flexo://proj-1/main"))
+	if code != 1 || len(stack.puts) != 1 || !strings.Contains(out, "the response named no commit") {
+		t.Fatalf("a push naming no commit: exit %d, %d write(s):\n%s", code, len(stack.puts), out)
+	}
+	if _, err := os.Stat(model + ".sync.json"); !os.IsNotExist(err) {
+		t.Errorf("an unrecorded push still wrote %s", model+".sync.json")
+	}
+}
+
+func TestConvertPushReportsACommitTheHeadMovedPast(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	stack.supersede412 = true
+	dir := t.TempDir()
+	model := writeModel(t, dir, "model.sysml", renamedModel)
+
+	out, code := exitCode(t, branchCommand(stack, binary, model, "-convert", "ttl", "-o", "flexo://proj-1/main"))
+	if code != 1 || len(stack.puts) != 1 || !strings.Contains(out, "committed to proj-1/main as c-elsewhere") {
+		t.Fatalf("a push the head moved past: exit %d, %d write(s):\n%s", code, len(stack.puts), out)
+	}
+	if _, err := os.Stat(model + ".sync.json"); !os.IsNotExist(err) {
+		t.Errorf("a superseded push still wrote %s", model+".sync.json")
+	}
+}
+
+func TestConvertAcceptsTheConfiguredEndpointSpelledDifferently(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	// The configured endpoint carries a trailing slash; the branch URL does
+	// not. They name the same endpoint, so the read is allowed.
+	cmd := syncCommand(stack, binary, stack.server.URL+"/projects/proj-1/branches/main", "-convert", "sysml")
+	cmd.Env = append(cmd.Env, flexo.EnvSysMLV2URL+"="+stack.server.URL+"/")
+	out, code := exitCode(t, cmd)
+	if code != 0 || !strings.Contains(out, "part def Vehicle") {
+		t.Fatalf("the endpoint spelled with a trailing slash: exit %d:\n%s", code, out)
+	}
+}
+
+func TestConvertRefusesAnEndpointOtherThanTheConfigured(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	// syncCommand leaves FLEXO_SYSMLV2_URL at its default, so the URL below
+	// names a different endpoint than the one Layer 1 is pointed at.
+	out, code := exitCode(t, syncCommand(stack, binary, stack.server.URL+"/projects/proj-1/branches/main", "-convert", "sysml"))
+	if code != 2 || !strings.Contains(out, "endpoint other than the configured") || !strings.Contains(out, "flexo://proj-1/main") {
+		t.Fatalf("a URL for another endpoint: exit %d:\n%s", code, out)
+	}
+}
+
+func TestConvertRefusesSyncStateWithoutABranch(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	model := writeModel(t, t.TempDir(), "model.sysml", renamedModel)
+
+	out, code := exitCode(t, syncCommand(stack, binary, model, "-convert", "ttl", "-sync-state", filepath.Join(t.TempDir(), "s.json")))
+	if code != 2 || !strings.Contains(out, "-sync-state records a repository branch's head") {
+		t.Fatalf("-sync-state on a plain conversion: exit %d:\n%s", code, out)
+	}
+}
+
+func TestConvertPushRefusesAMigrationReportOverTheInput(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	dir := t.TempDir()
+	source, err := os.ReadFile(filepath.Join("..", "..", "tests", "migrate", "testdata", "xmi", "vehicle.xmi"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	xmi := writeModel(t, dir, "v1.xmi", string(source))
+
+	out, code := exitCode(t, branchCommand(stack, binary, xmi, "-convert", "ttl", "-o", "flexo://proj-1/main", "-migration-report", xmi))
+	if code != 2 || !strings.Contains(out, "names the model being migrated") || len(stack.puts) != 0 {
+		t.Fatalf("-migration-report over the pushed model: exit %d, %d write(s):\n%s", code, len(stack.puts), out)
+	}
+	if after, _ := os.ReadFile(xmi); string(after) != string(source) {
+		t.Errorf("the refused push still replaced the input model")
+	}
+}
+
+func TestConvertReadRefusesOutputOverTheSyncState(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	dir := t.TempDir()
+	out_path := writeModel(t, dir, "saved.sysml", "sentinel")
+
+	out, code := exitCode(t, branchCommand(stack, binary, "flexo://proj-1/main", "-convert", "sysml", "-o", out_path, "-sync-state", out_path))
+	if code != 2 || !strings.Contains(out, "-o and -sync-state both name") {
+		t.Fatalf("-o over the sync state: exit %d:\n%s", code, out)
+	}
+	if content, _ := os.ReadFile(out_path); string(content) != "sentinel" {
+		t.Errorf("the refused read still replaced the file")
+	}
+}
+
+func TestConvertReadRefusesAStatePinnedElsewhereBeforeWriting(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	dir := t.TempDir()
+	state := writeModel(t, dir, "s.sync.json", `{"org":"other","projectId":"proj-1","branch":"main"}`)
+	out_path := filepath.Join(dir, "out.sysml")
+
+	cmd := branchCommand(stack, binary, "flexo://proj-1/main", "-convert", "sysml", "-o", out_path, "-sync-state", state)
+	cmd.Env = append(cmd.Env, flexo.EnvOrg+"=acme")
+	out, code := exitCode(t, cmd)
+	if code != 2 || !strings.Contains(out, "org other") {
+		t.Fatalf("a state pinned to another org: exit %d:\n%s", code, out)
+	}
+	if _, err := os.Stat(out_path); !os.IsNotExist(err) {
+		t.Errorf("the refused read still wrote %s", out_path)
+	}
+}
+
+func TestConvertBranchNeedsTheToken(t *testing.T) {
+	binary := buildCLI(t)
+	stack := newFakeStack(t, liveGraph(t, syncedModel))
+	cmd := exec.Command(binary, "flexo://proj-1/main", "-convert", "sysml")
+	var env []string
+	for _, kv := range os.Environ() {
+		if !strings.HasPrefix(kv, flexo.EnvToken+"=") {
+			env = append(env, kv)
+		}
+	}
+	cmd.Env = append(env, flexo.EnvLayer1URL+"="+stack.server.URL, flexo.EnvSysMLV2URL+"="+stack.server.URL)
+	out, code := exitCode(t, cmd)
+	if code != 2 || !strings.Contains(out, flexo.EnvToken) {
+		t.Fatalf("a branch read without a token: exit %d:\n%s", code, out)
+	}
+}
diff --git a/cmd/sysml/convert_test.go b/cmd/sysml/convert_test.go
index 744ade9c77..745cbd37c3 100644
--- a/cmd/sysml/convert_test.go
+++ b/cmd/sysml/convert_test.go
@@ -11,8 +11,8 @@ import (
 	"sync"
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/migrate"
-	"github.com/Open-MBEE/OpenSysML/internal/testutil/gobuild"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/migrate"
+	"github.com/Open-MBEE/OpenSysML/tests/testutil/gobuild"
 )
 
 var (
@@ -212,6 +212,30 @@ func TestConvertErrors(t *testing.T) {
 	}
 }
 
+// TestConvertIDFormMisuse checks -id is refused, as a usage error, on every
+// conversion but SysML notation to an RDF form.
+func TestConvertIDFormMisuse(t *testing.T) {
+	binary := buildCLI(t)
+	dir := t.TempDir()
+	model := filepath.Join(dir, "model.sysml")
+	if err := os.WriteFile(model, []byte(sampleModel), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	for name, args := range map[string][]string{
+		"without convert":       {model, "-id", "uuid"},
+		"to notation":           {model, "-convert", "sysml", "-id", "uuid"},
+		"from interchange json": {model, "-from", "api-json", "-convert", "api-json", "-id", "uuid"},
+		"from xmi":              {model, "-from", "xmi", "-convert", "sysml", "-id", "uuid"},
+	} {
+		t.Run(name, func(t *testing.T) {
+			res := runCommand(t, exec.Command(binary, args...))
+			if res.status != 2 || !strings.Contains(res.stderr, "-id") {
+				t.Errorf("%v: status %d, stderr:\n%s", args, res.status, res.stderr)
+			}
+		})
+	}
+}
+
 // TestConvertRDFIsMarkedExperimental checks every RDF conversion says so on
 // stderr — including one the mapping refuses — and that the notice never lands
 // in the converted model on stdout.
@@ -231,7 +255,7 @@ func TestConvertRDFIsMarkedExperimental(t *testing.T) {
 	if to.status != 0 {
 		t.Fatalf("converting to Turtle failed: %s%s", to.stdout, to.stderr)
 	}
-	if !strings.Contains(to.stderr, "RDF conversion is experimental") {
+	if !strings.Contains(to.stderr, "RDF conversion — Turtle and the API's JSON element form alike — is experimental") {
 		t.Errorf("no experimental notice on stderr:\n%s", to.stderr)
 	}
 	if strings.Contains(to.stdout, "experimental") {
@@ -241,7 +265,7 @@ func TestConvertRDFIsMarkedExperimental(t *testing.T) {
 	turtle := filepath.Join(dir, "model.ttl")
 	run(t, binary, model, "-convert", "ttl", "-o", turtle)
 	from := runCommand(t, exec.Command(binary, turtle, "-convert", "sysml"))
-	if !strings.Contains(from.stderr, "RDF conversion is experimental") {
+	if !strings.Contains(from.stderr, "RDF conversion — Turtle and the API's JSON element form alike — is experimental") {
 		t.Errorf("reading RDF is experimental too, but was not marked:\n%s", from.stderr)
 	}
 
@@ -254,7 +278,7 @@ func TestConvertRDFIsMarkedExperimental(t *testing.T) {
 	if refused.status == 0 {
 		t.Fatalf("expected the mapping to refuse the duplicate declaration:\n%s", refused.stdout)
 	}
-	if !strings.Contains(refused.stderr, "RDF conversion is experimental") {
+	if !strings.Contains(refused.stderr, "RDF conversion — Turtle and the API's JSON element form alike — is experimental") {
 		t.Errorf("a refusal is the experimental behavior, but was not marked:\n%s", refused.stderr)
 	}
 }
@@ -277,7 +301,7 @@ func run(t *testing.T, binary string, args ...string) string {
 func TestConvertMigratesXMI(t *testing.T) {
 	binary := buildCLI(t)
 	dir := t.TempDir()
-	xmi := filepath.Join("..", "..", "internal", "core", "migrate", "testdata", "xmi", "vehicle.xmi")
+	xmi := filepath.Join("..", "..", "tests", "migrate", "testdata", "xmi", "vehicle.xmi")
 	model := filepath.Join(dir, "model.sysml")
 	if err := os.WriteFile(model, []byte(sampleModel), 0o644); err != nil {
 		t.Fatal(err)
@@ -299,6 +323,7 @@ func TestConvertMigratesXMI(t *testing.T) {
 
 	textReport := filepath.Join(dir, "report.txt")
 	jsonReport := filepath.Join(dir, "report.json")
+	layoutExport := filepath.Join(dir, "layout.xml")
 	turtle := filepath.Join(dir, "model.ttl")
 	run(t, binary, xmi, "-convert", "ttl", "-o", turtle, "-migration-report", textReport)
 	run(t, binary, xmi, "-from", "xmi", "-convert", "sysml", "-o", model, "-migration-report", jsonReport)
@@ -360,6 +385,11 @@ func TestConvertMigratesXMI(t *testing.T) {
 		"output over the input, spelled differently":   {[]string{v1, "-convert", "sysml", "-o", filepath.Join(dir, ".", "v1.xmi")}, "-o names the model being migrated"},
 		"output over the input through a link":         {[]string{v1, "-convert", "sysml", "-o", symlinkTo(t, dir, "v1-link", v1)}, "-o names the model being migrated"},
 		"output over the input through a hard link":    {[]string{v1, "-convert", "sysml", "-o", hardLink}, "-o names the model being migrated"},
+		"layout without convert":                       {[]string{model, "-layout", layoutExport}, "-layout accompanies -convert"},
+		"layout without xmi":                           {[]string{model, "-convert", "ttl", "-layout", layoutExport}, "-layout augments a SysML v1 migration"},
+		"layout over the model":                        {[]string{xmi, "-convert", "sysml", "-o", layoutExport, "-layout", layoutExport}, "-layout and -o both name"},
+		"layout over the report":                       {[]string{xmi, "-convert", "sysml", "-migration-report", layoutExport, "-layout", layoutExport}, "-layout and -migration-report both name"},
+		"layout over the input":                        {[]string{xmi, "-convert", "sysml", "-layout", xmi}, "names the model being migrated"},
 	} {
 		t.Run(name, func(t *testing.T) {
 			out, err := exec.Command(binary, tc.args...).CombinedOutput()
@@ -375,7 +405,7 @@ func TestConvertMigratesXMI(t *testing.T) {
 
 func TestLoadingXMIDirectlyPointsAtMigration(t *testing.T) {
 	binary := buildCLI(t)
-	xmi := filepath.Join("..", "..", "internal", "core", "migrate", "testdata", "xmi", "vehicle.xmi")
+	xmi := filepath.Join("..", "..", "tests", "migrate", "testdata", "xmi", "vehicle.xmi")
 	for _, args := range [][]string{
 		{xmi, "-validate"},
 		{xmi, "-eval", "1"},
@@ -437,3 +467,27 @@ func danglingLink(t *testing.T, dir, name, target string) string {
 	}
 	return link
 }
+
+// TestConvertLayoutAugment migrates the layout fixture with its MTIP export:
+// the views carry the export's geometry as DiagramLayout metadata.
+func TestConvertLayoutAugment(t *testing.T) {
+	binary := buildCLI(t)
+	xmi := filepath.Join("..", "..", "tests", "migrate", "testdata", "xmi", "layout.xmi")
+	layout := filepath.Join("..", "..", "tests", "migrate", "testdata", "xmi", "layout.layout.xml")
+	out := runCommand(t, exec.Command(binary, xmi, "-convert", "sysml", "-layout", layout))
+	if out.status != 0 {
+		t.Fatalf("migrating with -layout failed: %s%s", out.stdout, out.stderr)
+	}
+	for _, want := range []string{
+		"metadata DiagramLayout::Layout about engine { x = 20; y = 10; width = 100; height = 40; }",
+		"metadata DiagramLayout::Route about drive { points = (120, 30, 200, 30); }",
+		`@DiagramLayout::Canvas { unit = "px";`,
+	} {
+		if !strings.Contains(out.stdout, want) {
+			t.Errorf("migrated notation lacks %q:\n%s", want, out.stdout)
+		}
+	}
+	if !strings.Contains(out.stderr, "laid out 2 of 2 diagrams") {
+		t.Errorf("the layout summary belongs on stderr:\n%s", out.stderr)
+	}
+}
diff --git a/cmd/sysml/examples_analysis_results_test.go b/cmd/sysml/examples_analysis_results_test.go
new file mode 100644
index 0000000000..78e2c0e653
--- /dev/null
+++ b/cmd/sysml/examples_analysis_results_test.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// TestAnalysisResultsExample runs the analysis-results demo end to end: the
+// rendered report matches its committed output, the stale-records query
+// returns exactly the record the model moved away from, and the analysis
+// still prints the fuelLeft the scout record saved.
+func TestAnalysisResultsExample(t *testing.T) {
+	binary := buildCLI(t)
+	examples := filepath.Join("..", "..", "examples", "analysis-results-demo")
+	source := filepath.Join(examples, "lander-results.sysml")
+
+	committed, err := os.ReadFile(filepath.Join(examples, "report.md"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	out := filepath.Join(t.TempDir(), "report.md")
+	render := exec.Command(binary, source, "-render-document", "Reporting::AnalysisReport", "-o", out)
+	if output, err := render.CombinedOutput(); err != nil {
+		t.Fatalf("render: %v\n%s", err, output)
+	}
+	written, err := os.ReadFile(out)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(written) != string(committed) {
+		t.Errorf("rendered example differs from examples/analysis-results-demo/report.md:\n%s", written)
+	}
+
+	stale := exec.Command(binary, source, "-run-query", "Reporting::StaleRuns")
+	output, err := stale.CombinedOutput()
+	if err != nil {
+		t.Fatalf("stale query: %v\n%s", err, output)
+	}
+	for _, want := range []string{
+		"returned 1 row",
+		"Row 1: Records::relayRun",
+		"drift = 30.0",
+		"liveFuelLeft = 110.0",
+	} {
+		if !strings.Contains(string(output), want) {
+			t.Errorf("stale query output is missing %q:\n%s", want, output)
+		}
+	}
+
+	// The recorded baseline must match what the case still prints; if the
+	// model moves so the printed value differs from scoutRun's fuelLeft, this
+	// catches the record silently drifting.
+	run := exec.Command(binary, source, "-analysis", "Descent::scoutBudget")
+	output, err = run.CombinedOutput()
+	if err != nil {
+		t.Fatalf("analysis: %v\n%s", err, output)
+	}
+	if !strings.Contains(string(output), "fuelLeft = 130.0") {
+		t.Errorf("scoutBudget no longer prints the recorded fuelLeft = 130.0:\n%s", output)
+	}
+
+	// A run written back with -record-run lands in the same Records package
+	// the report's queries walk, so the document must show it alongside the
+	// hand-written records — and only it, in the run-table filters that name
+	// the hand-written defs.
+	t.Run("RecordedRunJoinsTheReport", func(t *testing.T) {
+		recorded := filepath.Join(t.TempDir(), "recorded-report.md")
+		render = exec.Command(binary, source, "-record-run", "Descent::scoutBudget",
+			"-render-document", "Reporting::AnalysisReport", "-o", recorded)
+		if output, err := render.CombinedOutput(); err != nil {
+			t.Fatalf("render with -record-run: %v\n%s", err, output)
+		}
+		written, err := os.ReadFile(recorded)
+		if err != nil {
+			t.Fatal(err)
+		}
+		text := string(written)
+		if !strings.Contains(text, "| scoutBudget\\_run1 | Descent::scoutBudget | run | Landers::scout | satisfied |") {
+			t.Errorf("recorded run missing from the every-recorded-run table:\n%s", text)
+		}
+		if !strings.Contains(text, "| scoutBudget\\_run1 | run | Descent::scoutBudget |") {
+			t.Errorf("recorded run missing from the provenance table:\n%s", text)
+		}
+		if strings.Count(text, "| relayRun | Landers::relay | 40 | 80 | 110 | 30 |") != 1 {
+			t.Errorf("stale table does not contain exactly the relay record:\n%s", text)
+		}
+	})
+}
diff --git a/cmd/sysml/explore_nested_test.go b/cmd/sysml/explore_nested_test.go
new file mode 100644
index 0000000000..35056cac0f
--- /dev/null
+++ b/cmd/sysml/explore_nested_test.go
@@ -0,0 +1,183 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// linkedPairModel is an assembly of two parts talking through a connector: the
+// ground pings the craft, which then sends a frame every second; a frame due at
+// the instant the ground stops listening is a race, received or not.
+const linkedPairModel = `package Comms {
+    private import ScalarValues::*;
+    private import SI::*;
+    item def Ping;
+    item def Frame;
+    port def Link { out item data : Frame; in item ping : Ping; }
+    part def Ground {
+        port p : ~Link;
+        attribute received : Integer = 0;
+        exhibit state listen {
+            entry; then idle;
+            state idle;
+            state listening {
+                entry send new Ping() via p;
+                do action recv {
+                    first start;
+                    then merge again;
+                    then action got accept f : Frame via p;
+                    then action count assign received := received + 1;
+                    then again;
+                }
+            }
+            transition first idle accept after 1 [s] then listening;
+            transition first listening accept after 2 [s] then quiet;
+            state quiet;
+        }
+    }
+    part def Craft {
+        port p : Link;
+        attribute sent : Integer = 0;
+        exhibit state modes {
+            entry; then waiting;
+            state waiting;
+            state sending {
+                do action tx {
+                    first start;
+                    then merge repeat;
+                    then action wait accept after 1 [s];
+                    then action emit send new Frame() via p;
+                    then action count assign sent := sent + 1;
+                    then repeat;
+                }
+            }
+            transition first waiting accept Ping via p then sending;
+        }
+    }
+    part def Pair {
+        part ground : Ground;
+        part craft : Craft;
+        connect craft.p to ground.p;
+    }
+    part pair : Pair;
+}
+`
+
+// TestExploreRunsAMachineOnANestedObject checks -schedule explore on a machine
+// of a part nested in an assembly named by its path: each run instantiates the
+// assembly and walks to the part, so the connector carries the sibling's frames
+// and the race over the last one is tabled, while a model without the race
+// tables one outcome; the table reads alike under one job and four.
+func TestExploreRunsAMachineOnANestedObject(t *testing.T) {
+	binary := buildCLI(t)
+
+	got := check(t, binary, linkedPairModel, "-schedule", "explore", "-state", "Comms::Ground::listen Comms::pair.ground", "-advance", "5")
+	wantReport(t, got, 0, "✓ explored Comms::Ground::listen: 2 outcomes",
+		"finalState quiet; visits idle, listening, quiet; this.isSolid = true; this.received = 1 | 3              | 0.875       | t=3.0: state machine listen of object #2 first of state machine listen of object #2, state machine modes of object #4",
+		"finalState quiet; visits idle, listening, quiet; this.isSolid = true; this.received = 2 | 1              | 0.125       | t=3.0: state machine modes of object #4 first of state machine listen of object #2, state machine modes of object #4; events at t=3.0: accept Frame first of time listening 1->quiet, accept Frame; at t=3.0: do listening first of do listening, dispatch time listening 1->quiet",
+		"complete (4 runs)")
+	for _, jobs := range []string{"1", "4"} {
+		again := check(t, binary, linkedPairModel, "-jobs", jobs, "-schedule", "explore", "-state", "Comms::Ground::listen Comms::pair.ground", "-advance", "5")
+		if again.output() != got.output() {
+			t.Errorf("under -jobs %s:\n%s\nwant\n%s", jobs, again.output(), got.output())
+		}
+	}
+
+	// The ground done listening before the last frame is due has no race to table.
+	calm := strings.Replace(linkedPairModel, "accept after 2 [s] then quiet", "accept after 1.5 [s] then quiet", 1)
+	wantReport(t, check(t, binary, calm, "-schedule", "explore", "-state", "Comms::Ground::listen Comms::pair.ground", "-advance", "5"), 0,
+		"✓ explored Comms::Ground::listen: 1 outcome",
+		"finalState quiet; visits idle, listening, quiet; this.isSolid = true; this.received = 1 | 1              | 1           | no choice points",
+		"complete (1 runs)")
+}
+
+// TestExploreRunsSiblingsOnOneAssembly checks that two behaviors named on parts of
+// one assembly run on one object of it per run, whether the parts are named by
+// their paths or the assembly is given as -instantiate and the machines named
+// alone: the craft's frames reach the ground, and the outcome spells both parts.
+func TestExploreRunsSiblingsOnOneAssembly(t *testing.T) {
+	binary := buildCLI(t)
+
+	outcomes := []string{
+		"Comms::pair.craft.isSolid = true; Comms::pair.craft.sent = 4; Comms::pair.ground.isSolid = true; Comms::pair.ground.received = 1 | 3              | 0.875       | t=3.0: state machine listen of object #2 first of state machine listen of object #2, state machine modes of object #4",
+		"Comms::pair.craft.isSolid = true; Comms::pair.craft.sent = 4; Comms::pair.ground.isSolid = true; Comms::pair.ground.received = 2 | 1              | 0.125       | t=3.0: state machine modes of object #4 first of state machine listen of object #2, state machine modes of object #4",
+		"complete (4 runs)",
+	}
+	byPath := check(t, binary, linkedPairModel, "-schedule", "explore",
+		"-state", "Comms::Ground::listen Comms::pair.ground", "-state", "Comms::Craft::modes Comms::pair.craft", "-advance", "5")
+	wantReport(t, byPath, 0, append([]string{"✓ explored Comms::Ground::listen Comms::pair.ground, Comms::Craft::modes Comms::pair.craft: 2 outcomes",
+		`Comms::Craft::modes Comms::pair.craft finalState = "sending"; Comms::Craft::modes Comms::pair.craft visits = "waiting, sending"; Comms::Ground::listen Comms::pair.ground finalState = "quiet"; Comms::Ground::listen Comms::pair.ground visits = "idle, listening, quiet"; `},
+		outcomes...)...)
+
+	given := check(t, binary, linkedPairModel, "-schedule", "explore", "-instantiate", "Comms::pair",
+		"-state", "Comms::Ground::listen", "-state", "Comms::Craft::modes", "-advance", "5")
+	wantReport(t, given, 0, append([]string{"✓ Created instance of Comms::pair", "✓ explored Comms::Ground::listen, Comms::Craft::modes: 2 outcomes",
+		`Comms::Craft::modes finalState = "sending"; Comms::Craft::modes visits = "waiting, sending"; Comms::Ground::listen finalState = "quiet"; Comms::Ground::listen visits = "idle, listening, quiet"; `},
+		outcomes...)...)
+
+	// Without the assembly given, no object of a run exhibits a machine named alone.
+	wantReport(t, check(t, binary, linkedPairModel, "-schedule", "explore", "-state", "Comms::Ground::listen", "-advance", "5"), 2,
+		`no object of the explored run exhibits "Comms::Ground::listen", which runs only on an object of "Comms::Ground": each run creates its own objects, so name one as Comms::Ground::listen <declaration> or Comms::Ground::listen <Assembly::part>, or -instantiate the declaration holding it for every run`)
+}
+
+// TestExploreRefusesPathsItCannotPlan checks the paths an exploration refuses
+// before any run: an object of the session by id, whose objects the runs never
+// see, and a path through a feature the declaration does not hold.
+func TestExploreRefusesPathsItCannotPlan(t *testing.T) {
+	binary := buildCLI(t)
+
+	byID := check(t, binary, linkedPairModel, "-schedule", "explore", "-instantiate", "Comms::pair", "-state", "Comms::Ground::listen #1.ground", "-advance", "5")
+	wantReport(t, byID, 2, `"#1.ground" names an object of this session, which an exploration does not run on: each explored run creates its own objects, so name a declaration to instantiate, or a path from one to an object it holds (Assembly::part.nested)`)
+	rejectReport(t, byID, "explored Comms")
+
+	wantReport(t, check(t, binary, linkedPairModel, "-schedule", "explore", "-state", "Comms::Ground::listen Comms::pair.tower", "-advance", "5"), 2,
+		`Comms::pair has no feature "tower" (its features are craft, ground, and`)
+	wantReport(t, check(t, binary, linkedPairModel, "-schedule", "explore", "-state", "Comms::Ground::listen Comms::pair.ground[2]", "-advance", "5"), 2,
+		"ground of Comms::pair holds one value and takes no index: write ground, not ground[2]")
+}
+
+// TestEngineCheckWitnessesANestedObjectsDivergence checks -engine check and
+// -engine all on a machine of a nested part: the race is a divergence of the
+// part's attribute, each value's witness written for the part's path and
+// replayed on the assembly's object to the value it claims.
+func TestEngineCheckWitnessesANestedObjectsDivergence(t *testing.T) {
+	binary := buildCLI(t)
+	dir := t.TempDir()
+
+	got := check(t, binary, linkedPairModel, "-engine", "check", "-state", "Comms::Ground::listen Comms::pair.ground", "-advance", "5", "-check-witness", dir)
+	one := filepath.Join(dir, "Comms.Ground.listen@Comms.pair%2Eground-this.received-1.witness")
+	two := filepath.Join(dir, "Comms.Ground.listen@Comms.pair%2Eground-this.received-2.witness")
+	wantReport(t, got, 1,
+		"✗ State machine Comms::Ground::listen: divergent up to t=5.0",
+		"divergent: this.received ends as 1 or 2",
+		"this.received = 1 (witness "+one+")",
+		"this.received = 2 (witness "+two+")",
+		"standing: sensitive (witnessed:")
+
+	content, err := os.ReadFile(two)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.HasPrefix(string(content), "object #2 = Comms::pair#1.ground\nobject #4 = Comms::pair#1.craft\n") ||
+		!strings.Contains(string(content), "\nt=3.0: state machine modes of object #4 first of state machine listen of object #2, state machine modes of object #4\n") {
+		t.Errorf("witness file:\n%s", content)
+	}
+	for witness, received := range map[string]string{one: "listen of object #2 first", two: "modes of object #4 first"} {
+		replayed := check(t, binary, linkedPairModel, "-schedule", "replay:"+witness, "-trace", "-instantiate", "Comms::pair",
+			"-state", "Comms::Ground::listen Comms::pair.ground", "-advance", "5")
+		wantReport(t, replayed, 0, "(unordered; ran state machine "+received+")", "Current state: quiet",
+			"standing: value (observed: 1 run under replay:"+witness+")")
+		rejectReport(t, replayed, "replay refused", "names no move to follow")
+	}
+	replayed := check(t, binary, linkedPairModel, "-schedule", "replay:"+two, "-trace", "-instantiate", "Comms::pair",
+		"-state", "Comms::Ground::listen Comms::pair.ground", "-advance", "5")
+	if n := strings.Count(replayed.output(), "stmt assign received"); n != 2 {
+		t.Errorf("the replay of received = 2 assigned received %d times:\n%s", n, replayed.output())
+	}
+
+	all := check(t, binary, linkedPairModel, "-engine", "all", "-state", "Comms::Ground::listen Comms::pair.ground", "-advance", "5", "-check-diverge", "this.received")
+	wantReport(t, all, 1, "divergent: this.received ends as 1 or 2",
+		"all: check sensitive (witnessed), smt refused")
+}
diff --git a/cmd/sysml/external_engines_test.go b/cmd/sysml/external_engines_test.go
index 3c210bfe43..519b6e2df7 100644
--- a/cmd/sysml/external_engines_test.go
+++ b/cmd/sysml/external_engines_test.go
@@ -10,8 +10,8 @@ import (
 	"sync"
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/analysis"
-	"github.com/Open-MBEE/OpenSysML/internal/testutil/gobuild"
+	"github.com/Open-MBEE/OpenSysML/internal/exec/analysis"
+	"github.com/Open-MBEE/OpenSysML/tests/testutil/gobuild"
 )
 
 var (
@@ -31,7 +31,7 @@ func engineStandin(t *testing.T) string {
 		}
 		standinPath = filepath.Join(dir, "enginestandin")
 		build := exec.Command("go", gobuild.Args(standinPath)...)
-		build.Dir = filepath.Join("..", "..", "internal", "core", "analysis", "testdata", "enginestandin")
+		build.Dir = filepath.Join("..", "..", "internal", "exec", "analysis", "testdata", "enginestandin")
 		if out, err := build.CombinedOutput(); err != nil {
 			standinErr = fmt.Errorf("go build: %v\n%s", err, out)
 		}
@@ -193,6 +193,85 @@ func TestExternalWitnessedViolationFailsTheCheck(t *testing.T) {
 	rejectReport(t, got, "could not be checked")
 }
 
+// TestProgressGoesToStandardError checks that what an external engine reports while it runs is
+// TestModelSeedReachesTheExternalEngine checks that the seed -seed names goes to an external
+// engine on the checker's question as `modelSeed`, 0 as much as any other seed, apart from
+// `schedule`, and that no `modelSeed` is written when -seed is not given.
+func TestModelSeedReachesTheExternalEngine(t *testing.T) {
+	binary := buildCLI(t)
+	dir, _ := recordingManifest(t)
+	entry := `{"kind":"engine","name":"alpha","version":"1.0.0","command":["alpha.sh"],"protocol":1,` +
+		`"answers":["holds"],"subjects":["action"],"model":["sources"],"witness":"schedule","authority":"bounded"}`
+	if err := os.WriteFile(filepath.Join(dir, "alpha.json"), []byte(entry), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	script := "#!/bin/sh\n" +
+		`ENGINE_STANDIN_DESCRIBE='{"name":"alpha","version":"1.0.0","protocol":1,"answers":["holds"],"subjects":["action"]}' ` +
+		"exec " + engineStandin(t) + "\n"
+	if err := os.WriteFile(filepath.Join(dir, "alpha.sh"), []byte(script), 0o700); err != nil {
+		t.Fatal(err)
+	}
+	t.Setenv("ENGINE_STANDIN_RESULT", `{"claim":"holds","strength":"bounded"}`)
+	cases := []struct {
+		name  string
+		seed  []string
+		want  string
+		unset bool
+	}{
+		{name: "seed 0", seed: []string{"-seed", "0"}, want: `"schedule":"explore","modelSeed":0,"free"`},
+		{name: "seed 11", seed: []string{"-seed", "11"}, want: `"schedule":"explore","modelSeed":11,"free"`},
+		{name: "no seed", want: `"schedule":"explore","free"`, unset: true},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			wire := t.TempDir()
+			t.Setenv("ENGINE_STANDIN_WIRE", wire)
+			args := append([]string{"-engine", "all", "-instantiate", "Plant::tank",
+				"-action", "Plant::Tank::fill Plant::tank", "-check-property", "Plant::Tank::low"}, c.seed...)
+			got := check(t, binary, tankModel, args...)
+			wantReport(t, got, 1, `alpha not covered (engine "alpha" reports holds`)
+			lines := hostLines(t, wire)
+			question := ""
+			for _, line := range lines {
+				if strings.Contains(line, `"method":"run"`) {
+					question = line
+				}
+			}
+			if question == "" {
+				t.Fatalf("no run went to alpha; the host wrote:\n%s", strings.Join(lines, "\n"))
+			}
+			if !strings.Contains(question, c.want) {
+				t.Errorf("the question does not carry %s:\n%s", c.want, question)
+			}
+			if c.unset && strings.Contains(question, "modelSeed") {
+				t.Errorf("the question names a model seed without -seed:\n%s", question)
+			}
+		})
+	}
+}
+
+// hostLines is every line the host wrote to the stand-ins that recorded under dir.
+func hostLines(t *testing.T, dir string) []string {
+	t.Helper()
+	files, err := filepath.Glob(filepath.Join(dir, "*.wire"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var lines []string
+	for _, file := range files {
+		data, err := os.ReadFile(file)
+		if err != nil {
+			t.Fatal(err)
+		}
+		for _, line := range strings.Split(string(data), "\n") {
+			if rest, ok := strings.CutPrefix(line, "< "); ok {
+				lines = append(lines, rest)
+			}
+		}
+	}
+	return lines
+}
+
 // TestProgressGoesToStandardError checks that what an external engine reports while it runs is
 // printed to standard error, one line naming the engine per coalesced report, and that -quiet
 // prints none; the verdict itself is on standard output either way.
diff --git a/cmd/sysml/jobs_test.go b/cmd/sysml/jobs_test.go
index ee4590027c..05051d01d5 100644
--- a/cmd/sysml/jobs_test.go
+++ b/cmd/sysml/jobs_test.go
@@ -72,8 +72,8 @@ func TestJobsFlagAndEnvironment(t *testing.T) {
 
 // The determinism fixtures beside the runtime's exploration tests.
 const (
-	laterPrefixViolatesFaster = "../../internal/core/runtime/testdata/later_prefix_violates_faster.sysml"
-	slowFirstWriter           = "../../internal/core/runtime/testdata/conformance/action_explore_slow_first_writer.sysml"
+	laterPrefixViolatesFaster = "../../internal/exec/runtime/testdata/later_prefix_violates_faster.sysml"
+	slowFirstWriter           = "../../internal/exec/runtime/testdata/conformance/action_explore_slow_first_writer.sysml"
 )
 
 // runFigures matches what a -json document says of the run and not of the answer: the
diff --git a/cmd/sysml/load_test.go b/cmd/sysml/load_test.go
index edd3d2f497..ece381b297 100644
--- a/cmd/sysml/load_test.go
+++ b/cmd/sysml/load_test.go
@@ -8,7 +8,7 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
 )
 
 func write(t *testing.T, path, content string) string {
diff --git a/cmd/sysml/main.go b/cmd/sysml/main.go
index e61494378f..b75601323a 100644
--- a/cmd/sysml/main.go
+++ b/cmd/sysml/main.go
@@ -12,14 +12,14 @@ import (
 
 	"github.com/chzyer/readline"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/analysis"
-	"github.com/Open-MBEE/OpenSysML/internal/core/conformance"
-	"github.com/Open-MBEE/OpenSysML/internal/core/docrender"
-	engineset "github.com/Open-MBEE/OpenSysML/internal/core/engines"
-	"github.com/Open-MBEE/OpenSysML/internal/core/export"
-	"github.com/Open-MBEE/OpenSysML/internal/core/runtime"
-	"github.com/Open-MBEE/OpenSysML/internal/repl"
-	"github.com/Open-MBEE/OpenSysML/internal/usage"
+	"github.com/Open-MBEE/OpenSysML/internal/doc/docrender"
+	"github.com/Open-MBEE/OpenSysML/internal/exec/analysis"
+	engineset "github.com/Open-MBEE/OpenSysML/internal/exec/engines"
+	"github.com/Open-MBEE/OpenSysML/internal/exec/runtime"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/usage"
+	"github.com/Open-MBEE/OpenSysML/internal/syntax/diag"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/convert"
 )
 
 // errPrefix names the tool in the messages it writes to stderr.
@@ -105,46 +105,51 @@ func writableFile(dir, name string) (string, bool) {
 
 // CLI flags
 var (
-	evalExprs       stringSlice
-	showHelp        bool
-	showMan         bool
-	showVersion     bool
-	debugMode       bool
-	quietMode       bool
-	traceMode       bool
-	schedule        schedulePolicy
-	listEngines     bool
-	probeEngines    bool
-	engine          engineSelection
-	jobsFlag        jobsSetting
-	convertFormat   string
-	queryText       string
-	outputPath      string
-	fromFormat      string
-	migrationReport string
-	renderView      string
-	renderAllDir    string
-	renderForm      string
-	renderPalette   string
-	renderDoc       string
-	renderDocsDir   string
-	docForm         string
-	diagramForm     string
-	pdfEngine       string
-	pdfTitlePage    bool
-	pdfTOC          bool
-	pdfNumbering    bool
-	htmlCSS         stringSlice
-	htmlNoCSS       bool
-	htmlShowCSS     bool
-	htmlFragment    bool
-	htmlMermaid     string
-	htmlTheme       string
-	strictMode      bool
-	modelChecks     checks
-	compileCalc     string
-	compileTarget   string
-	compileSource   bool
+	evalExprs        stringSlice
+	showHelp         bool
+	showMan          bool
+	showVersion      bool
+	debugMode        bool
+	quietMode        bool
+	traceMode        bool
+	schedule         schedulePolicy
+	listEngines      bool
+	probeEngines     bool
+	engine           engineSelection
+	jobsFlag         jobsSetting
+	convertFormat    string
+	queryText        string
+	outputPath       string
+	fromFormat       string
+	idForm           string
+	migrationReport  string
+	migrationResults string
+	layoutPath       string
+	renderView       string
+	renderAllDir     string
+	renderForm       string
+	renderPalette    string
+	renderUnplaced   string
+	renderDoc        string
+	renderDocsDir    string
+	docForm          string
+	diagramForm      string
+	pdfEngine        string
+	pdfTitlePage     bool
+	pdfTOC           bool
+	pdfNumbering     bool
+	htmlCSS          stringSlice
+	htmlNoCSS        bool
+	htmlShowCSS      bool
+	htmlFragment     bool
+	htmlMermaid      string
+	htmlMath         string
+	htmlTheme        string
+	strictMode       bool
+	modelChecks      checks
+	compileCalc      string
+	compileTarget    string
+	compileSource    bool
 
 	syncDiffWith       string
 	syncApplyTo        string
@@ -273,10 +278,10 @@ func flagGiven(name string) bool {
 	return given
 }
 
-// printUsage writes the help to w: the caller chooses the stream, since help
-// asked for is a result and help shown over a misuse belongs with the error.
-func printUsage(w io.Writer) {
-	doc().WriteText(w, flag.CommandLine)
+// printUsage writes the help for the flags of fs to w: help asked for is a
+// result, so it goes on stdout.
+func printUsage(w io.Writer, fs *flag.FlagSet) {
+	doc().WriteText(w, fs)
 }
 
 // printMan writes the command's manual page, rendered from the same description
@@ -288,8 +293,9 @@ func printMan(w io.Writer) {
 // runCLI carries out what the command line asked for and returns the exit
 // status, so a profile started for the run is written before the process exits.
 func runCLI() int {
-	// Usage shown over a misuse goes on the stream the error naming it goes on.
-	flag.Usage = func() { printUsage(flag.CommandLine.Output()) }
+	// A misuse is answered on the error's stream with the synopsis and where the
+	// help is, not the help itself, which would bury the error.
+	flag.Usage = func() { doc().WriteHint(flag.CommandLine.Output()) }
 
 	// The tool manifest is read before the flags, since -engine is checked against its engines.
 	if err := resolveEngines(); err != nil {
@@ -305,7 +311,7 @@ func runCLI() int {
 	// Help that was asked for is the result of the run: it belongs on stdout, where
 	// it can be piped, and the run did what was asked.
 	if showHelp {
-		printUsage(os.Stdout)
+		printUsage(os.Stdout, flag.CommandLine)
 		return exitHolds
 	}
 
@@ -365,6 +371,10 @@ func runCLI() int {
 		fmt.Fprintln(os.Stderr, "sysml: -html-mermaid is empty; give it cdn or the URL of a Mermaid script")
 		return 2
 	}
+	if flagGiven("html-math") && htmlMath == "" {
+		fmt.Fprintln(os.Stderr, "sysml: -html-math is empty; give it cdn or the URL of a MathJax script")
+		return 2
+	}
 
 	// Get positional arguments (files to load)
 	args := flag.Args()
@@ -377,6 +387,10 @@ func runCLI() int {
 		fmt.Fprintln(os.Stderr, "sysml: -render-palette is the palette -render or -render-all fills DOT or PlantUML with; name the view to render with -render or a directory with -render-all")
 		return 2
 	}
+	if renderUnplaced != "" && renderView == "" && renderAllDir == "" && renderDoc == "" && renderDocsDir == "" {
+		fmt.Fprintln(os.Stderr, "sysml: -render-unplaced places the unplaced nodes of a positioned DOT drawing; name what to render with -render, -render-all, -render-document or -render-documents")
+		return 2
+	}
 
 	// The default stylesheet is asked for on its own; it needs no model, and
 	// writing it is the whole run, so it cannot stand in for another.
@@ -430,6 +444,42 @@ func runCLI() int {
 		fmt.Fprintln(os.Stderr, "sysml: -migration-report accompanies -convert of a SysML v1 model; write `sysml model.xmi -convert sysml -migration-report report.txt`")
 		return 2
 	}
+	if migrationResults != "" && convertFormat == "" {
+		fmt.Fprintln(os.Stderr, "sysml: -migration-results accompanies -convert of a SysML v1 model; write `sysml model.xmi -convert sysml -migration-results results.json`")
+		return 2
+	}
+	if flagGiven("migration-results") && migrationResults == "" {
+		fmt.Fprintln(os.Stderr, "sysml: -migration-results is empty; name the JSON file to write the run configurations and result snapshots to")
+		return 2
+	}
+	if idForm != "" && convertFormat == "" {
+		fmt.Fprintln(os.Stderr, "sysml: -id accompanies -convert to an RDF form; write `sysml model.sysml -convert api-json -id uuid`")
+		return 2
+	}
+	if layoutPath != "" && convertFormat == "" {
+		fmt.Fprintln(os.Stderr, "sysml: -layout accompanies -convert of a SysML v1 model; write `sysml model.xmi -convert sysml -layout model_mtip.xml`")
+		return 2
+	}
+	if flagGiven("record-into") && len(modelChecks.records) == 0 {
+		fmt.Fprintln(os.Stderr, "sysml: -record-into accompanies -record-run; write `sysml model.sysml -record-run \"Pkg::Case\" -record-into Pkg::Log`")
+		return 2
+	}
+	if flagGiven("record-into") && modelChecks.recordInto == "" {
+		fmt.Fprintln(os.Stderr, "sysml: -record-into needs a package name; write `sysml model.sysml -record-run \"Pkg::Case\" -record-into Pkg::Log`")
+		return 2
+	}
+	if flagGiven("layout") && layoutPath == "" {
+		fmt.Fprintln(os.Stderr, "sysml: -layout is empty; name the MTIP export to lay the migrated views out from")
+		return 2
+	}
+	if flagGiven("compare-results") && modelChecks.compare == "" {
+		fmt.Fprintln(os.Stderr, "sysml: -compare-results is empty; name the JSON file -migration-results wrote")
+		return 2
+	}
+	if modelChecks.compare != "" && (convertFormat != "" || renderView != "" || renderAllDir != "" || renderDoc != "" || renderDocsDir != "" || queryText != "" || len(evalExprs) > 0 || compileCalc != "" || syncDiffWith != "" || syncApplyTo != "") {
+		fmt.Fprintln(os.Stderr, "sysml: -compare-results runs the migrated model against the tool's results; it cannot be combined with -convert, -render, -render-all, -render-document, -render-documents, -query, -eval, -compile, -sync-diff or -sync-apply")
+		return 2
+	}
 
 	if compileCalc != "" {
 		switch {
@@ -465,7 +515,7 @@ func runCLI() int {
 		case convertFormat != "" || renderView != "" || renderDoc != "" || renderAllDir != "" || renderDocsDir != "" || queryText != "" || len(evalExprs) > 0:
 			fmt.Fprintf(os.Stderr, "sysml: %s syncs a change set; it cannot be combined with -convert, -render, -render-all, -render-document, -render-documents, -query or -eval\n", mode)
 			return 2
-		case outputPath != "" || fromFormat != "" || renderForm != "" || renderPalette != "" || docForm != "" || diagramForm != "" || pdfEngine != "" || pdfTitlePage || pdfTOC || pdfNumbering:
+		case outputPath != "" || fromFormat != "" || renderForm != "" || renderPalette != "" || renderUnplaced != "" || docForm != "" || diagramForm != "" || pdfEngine != "" || pdfTitlePage || pdfTOC || pdfNumbering:
 			fmt.Fprintf(os.Stderr, "sysml: %s reads SysML or Turtle inputs and reports the change set; -output, -from and the render options do not apply\n", mode)
 			return 2
 		case modelChecks.requested():
@@ -477,8 +527,12 @@ func runCLI() int {
 		}
 		return runSyncDiff(args)
 	}
-	if syncBase != "" || syncState != "" || syncConfirmDeletes || syncMintIDs || syncAnnotate != "" {
-		fmt.Fprintln(os.Stderr, "sysml: -sync-base, -sync-state, -sync-confirm-deletes, -sync-mint-ids and -sync-annotate apply to -sync-diff or -sync-apply; name the repository to sync against")
+	if syncBase != "" || syncConfirmDeletes || syncMintIDs || syncAnnotate != "" {
+		fmt.Fprintln(os.Stderr, "sysml: -sync-base, -sync-confirm-deletes, -sync-mint-ids and -sync-annotate apply to -sync-diff or -sync-apply; name the repository to sync against")
+		return 2
+	}
+	if syncState != "" && convertFormat == "" {
+		fmt.Fprintln(os.Stderr, "sysml: -sync-state applies to -sync-diff, -sync-apply, or a -convert that reads or pushes a repository branch")
 		return 2
 	}
 
@@ -499,10 +553,13 @@ func runCLI() int {
 		case queryText != "" || len(evalExprs) > 0 || fromFormat != "":
 			fmt.Fprintln(os.Stderr, "sysml: -render-documents cannot be combined with -query, -eval or -from")
 			return 2
-		case modelChecks.requested():
+		case modelChecks.requested() && !modelChecks.instantiatesOnly():
 			return refuse(modelChecks,
 				"-render-documents writes documents out and decides nothing about the model; check it in its own run")
 		}
+		if status := resolveRunBounds(); status != 0 {
+			return status
+		}
 		if err := runRenderDocuments(args); err != nil {
 			return fail(err)
 		}
@@ -538,7 +595,7 @@ func runCLI() int {
 			fmt.Fprintln(os.Stderr, "sysml: -convert and -query are mutually exclusive")
 			return 2
 		}
-		if modelChecks.requested() {
+		if modelChecks.requested() && !modelChecks.recordsOnly() {
 			return refuse(modelChecks,
 				"-convert writes the model out and decides nothing about it; check it in its own run")
 		}
@@ -546,14 +603,20 @@ func runCLI() int {
 			fmt.Fprintln(os.Stderr, "sysml: -convert, -render and -render-document each write a document out; ask for one per run")
 			return 2
 		}
-		if err := runConvert(args); err != nil {
-			return fail(err)
+		if modelChecks.recordsOnly() {
+			if message := modelChecks.boundsMisuse(); message != "" {
+				fmt.Fprintf(os.Stderr, "sysml: %s\n", message)
+				return 2
+			}
+			if status := resolveRunBounds(); status != 0 {
+				return status
+			}
 		}
-		return exitHolds
+		return runConvertExit(args)
 	}
 
 	for _, path := range args {
-		if f, err := export.FormatOfPath(path); err == nil && f == export.FormatXMI {
+		if f, err := convert.FormatOfPath(path); err == nil && f == convert.FormatXMI {
 			fmt.Fprintf(os.Stderr, "sysml: %s is a SysML v1 model; migrate it first with `sysml %s -convert sysml -output model.sysml`, then load model.sysml\n", path, path)
 			return 2
 		}
@@ -587,12 +650,18 @@ func runCLI() int {
 		case modelChecks.jsonOut && !modelChecks.checksOnly():
 			fmt.Fprintln(os.Stderr, "sysml: -render-document writes a document, not JSON; -json reports checks")
 			return 2
-		case modelChecks.requested():
+		case modelChecks.requested() && !modelChecks.instantiatesOnly() && !modelChecks.recordsOnly():
 			return refuse(modelChecks,
 				"-render-document writes a document out and decides nothing about the model; check it in its own run")
 		case len(evalExprs) > 0 || fromFormat != "":
 			fmt.Fprintln(os.Stderr, "sysml: -render-document cannot be combined with -eval or -from")
 			return 2
+		case modelChecks.recordsOnly() && modelChecks.boundsMisuse() != "":
+			fmt.Fprintf(os.Stderr, "sysml: %s\n", modelChecks.boundsMisuse())
+			return 2
+		}
+		if status := resolveRunBounds(); status != 0 {
+			return status
 		}
 		if err := runRenderDocument(args); err != nil {
 			return fail(err)
@@ -600,18 +669,8 @@ func runCLI() int {
 		return exitHolds
 	}
 
-	// Resolve the run bounds before any model runs, so a bad value is reported at
-	// startup rather than mistaken for the default at execution time. Reporting the
-	// version and converting a model evaluate nothing, so they are handled above.
-	budgets, err = runtime.BudgetsFromEnv()
-	if err != nil {
-		fmt.Fprintln(os.Stderr, errPrefix, err)
-		return 2
-	}
-	jobs, err = resolveJobs()
-	if err != nil {
-		fmt.Fprintln(os.Stderr, errPrefix, err)
-		return 2
+	if status := resolveRunBounds(); status != 0 {
+		return status
 	}
 
 	// Checking mode: load, check what was named, and exit on the verdict.
@@ -628,10 +687,26 @@ func runCLI() int {
 	return runInteractiveWithFiles(args)
 }
 
+// resolveRunBounds reads the run bounds before any model runs, so a bad value is
+// reported at startup (status 2) rather than mistaken for the default; 0 when read.
+func resolveRunBounds() int {
+	var err error
+	if budgets, err = runtime.BudgetsFromEnv(); err != nil {
+		fmt.Fprintln(os.Stderr, errPrefix, err)
+		return 2
+	}
+	if jobs, err = resolveJobs(); err != nil {
+		fmt.Fprintln(os.Stderr, errPrefix, err)
+		return 2
+	}
+	return 0
+}
+
 // newSession returns a session in the output modes the flags asked for, under
 // the run bounds resolved at startup.
 func newSession() *repl.Session {
 	sess := repl.NewSession()
+	sess.SetToolVersion("sysml " + Version)
 	if err := sess.SetBudgets(budgets); err != nil {
 		// Unreachable: budgets are validated in main before any session exists.
 		fmt.Fprintln(os.Stderr, errPrefix, err)
@@ -656,6 +731,15 @@ func newSession() *repl.Session {
 		fmt.Fprintln(os.Stderr, errPrefix, err)
 		os.Exit(2)
 	}
+	if modelChecks.seed.given {
+		sess.SetModelSeed(modelChecks.seed.value)
+	}
+	sess.SetDraws(modelChecks.draws.value)
+	if err := sess.SetClockStep(modelChecks.clockStep.value); err != nil {
+		// Unreachable: -clock-step was validated when parsed.
+		fmt.Fprintln(os.Stderr, errPrefix, err)
+		os.Exit(2)
+	}
 	if err := sess.SetEngine(engine.text); err != nil {
 		// Unreachable: the selection was validated against the same engines when parsed.
 		fmt.Fprintln(os.Stderr, errPrefix, err)
@@ -666,7 +750,7 @@ func newSession() *repl.Session {
 		fmt.Fprintln(os.Stderr, errPrefix, err)
 		os.Exit(2)
 	}
-	sess.SetConformanceMode(conformance.ModeOf(strictMode))
+	sess.SetConformanceMode(diag.ConformanceModeOf(strictMode))
 	sess.SetRenderWidth(terminalWidth())
 	return sess
 }
diff --git a/cmd/sysml/main_test.go b/cmd/sysml/main_test.go
index 308dd98210..106914a895 100644
--- a/cmd/sysml/main_test.go
+++ b/cmd/sysml/main_test.go
@@ -3,7 +3,7 @@ package main
 import (
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
 )
 
 func TestNewSessionTracingFromFlag(t *testing.T) {
diff --git a/cmd/sysml/man_test.go b/cmd/sysml/man_test.go
index 196567e246..af1572df02 100644
--- a/cmd/sysml/man_test.go
+++ b/cmd/sysml/man_test.go
@@ -5,7 +5,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/usage"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/usage"
 )
 
 // The shipped page is generated, so a flag or a section added to the
diff --git a/cmd/sysml/manual_examples_test.go b/cmd/sysml/manual_examples_test.go
index d2c2e5de8a..bea0c30f7b 100644
--- a/cmd/sysml/manual_examples_test.go
+++ b/cmd/sysml/manual_examples_test.go
@@ -40,12 +40,72 @@ func TestManualCookbookModelAnalysesCleanly(t *testing.T) {
 	for _, query := range []string{
 		"Cookbook::MassTable root=Cookbook::telescope",
 		"Cookbook::MassBudget root=Cookbook::telescope",
+		"Cookbook::HeldParts root=Cookbook::telescope",
 	} {
 		cmd := exec.Command(binary, source, "-run-query", query)
 		if output, err := cmd.CombinedOutput(); err != nil {
 			t.Fatalf("cookbook query %s: %v\n%s", query, err, output)
 		}
 	}
+	named := exec.Command(binary, source, "-run-query", "Cookbook::NamedParts")
+	output, err := named.CombinedOutput()
+	if err != nil {
+		t.Fatalf("cookbook query Cookbook::NamedParts: %v\n%s", err, output)
+	}
+	for _, want := range []string{
+		"returned 12 rows",
+		"Row 1: Cookbook::telescope::primaryMirror",
+		"Row 6: Cookbook::Traceability::gimbal",
+	} {
+		if !strings.Contains(string(output), want) {
+			t.Errorf("cookbook query Cookbook::NamedParts output is missing %q:\n%s", want, output)
+		}
+	}
+}
+
+// TestManualCookbookObjectRecipes runs the cookbook's recipes over the objects
+// the run holds, and checks they print the rows the manual quotes.
+func TestManualCookbookObjectRecipes(t *testing.T) {
+	binary := buildCLI(t)
+	source := filepath.Join("..", "..", "docs", "manual", "examples", "cookbook.sysml")
+	for _, tc := range []struct {
+		query string
+		want  []string
+	}{
+		{"Cookbook::HeldParts root=telescope", []string{
+			"returned 3 rows",
+			"Row 1: Cookbook::telescope.primaryMirror (#2)",
+			`qualifiedName = "Cookbook::telescope.primaryMirror"`,
+			"Row 2: Cookbook::telescope.instrumentCluster (#4)",
+			"Row 3: Cookbook::telescope.mountControl (#7)",
+			"mass = 15.0",
+		}},
+		{"Cookbook::HeldSubsystems", []string{
+			"returned 3 rows",
+			"Row 1: Cookbook::telescope.mountControl (#7)",
+			"Row 2: Cookbook::telescope.primaryMirror (#2)",
+			"Row 3: Cookbook::telescope.instrumentCluster (#4)",
+		}},
+	} {
+		cmd := exec.Command(binary, source, "-instantiate", "Cookbook::telescope", "-run-query", tc.query)
+		output, err := cmd.CombinedOutput()
+		if err != nil {
+			t.Fatalf("cookbook query %s: %v\n%s", tc.query, err, output)
+		}
+		for _, want := range tc.want {
+			if !strings.Contains(string(output), want) {
+				t.Errorf("cookbook query %s output is missing %q:\n%s", tc.query, want, output)
+			}
+		}
+	}
+	cmd := exec.Command(binary, source, "-run-query", "Cookbook::HeldSubsystems")
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("cookbook query without objects: %v\n%s", err, output)
+	}
+	if !strings.Contains(string(output), "returned 0 rows") {
+		t.Errorf("Objects over a run holding nothing should return no rows:\n%s", output)
+	}
 }
 
 // TestManualRequirementsExample runs the manual's requirements example end to
@@ -120,3 +180,257 @@ func TestManualRequirementsExample(t *testing.T) {
 		}
 	}
 }
+
+// TestManualTraceabilityExample runs the manual's traceability example end to
+// end: one matrix query lists every requirement with its satisfiers, verifiers
+// and verification count, and the report's Markdown matches its committed output.
+func TestManualTraceabilityExample(t *testing.T) {
+	binary := buildCLI(t)
+	examples := filepath.Join("..", "..", "docs", "manual", "examples")
+	source := filepath.Join(examples, "traceability.sysml")
+
+	query := exec.Command(binary, source, "-run-query", "Traceability::TraceMatrix root=Traceability::specification")
+	output, err := query.CombinedOutput()
+	if err != nil {
+		t.Fatalf("run query: %v\n%s", err, output)
+	}
+	for _, want := range []string{
+		"returned 4 rows",
+		"Columns: shortName, name, satisfiedBy, verifiedBy, verifications",
+		`shortName = "SC-2"`,
+		"satisfiedBy = Traceability::spacecraft::antenna",
+		"verifiedBy = [Traceability::gainTest, Traceability::gainAnalysis]",
+		"verifications = 2",
+		`shortName = "SC-4"`,
+		"satisfiedBy = (none)",
+		"verifiedBy = (none)",
+		"verifications = 0",
+	} {
+		if !strings.Contains(string(output), want) {
+			t.Errorf("query output is missing %q:\n%s", want, output)
+		}
+	}
+
+	committed, err := os.ReadFile(filepath.Join(examples, "traceability.md"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	out := filepath.Join(t.TempDir(), "traceability.md")
+	render := exec.Command(binary, source, "-render-document", "Traceability::TraceabilityReport", "-o", out)
+	if output, err := render.CombinedOutput(); err != nil {
+		t.Fatalf("render: %v\n%s", err, output)
+	}
+	written, err := os.ReadFile(out)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(written) != string(committed) {
+		t.Errorf("rendered example differs from docs/manual/examples/traceability.md:\n%s", written)
+	}
+	for _, want := range []string{
+		"| SC-2 | downlinkGain | Traceability::spacecraft::antenna | Traceability::gainTest, Traceability::gainAnalysis | 2 |",
+		"| SC-4 | passivation |  |  | 0 |",
+		"| satisfaction |  | Traceability::spacecraft.radiator | violated | radiator.area >= 2.0 |",
+	} {
+		if !strings.Contains(string(written), want) {
+			t.Errorf("Markdown is missing %q:\n%s", want, written)
+		}
+	}
+
+	page := exec.Command(binary, source, "-render-document", "Traceability::TraceabilityReport", "-doc-form", "html")
+	html, err := page.Output()
+	if err != nil {
+		t.Fatalf("render HTML: %v", err)
+	}
+	for _, want := range []string{
+		`data-element="Traceability::gainTest"`,
+		`data-element="Traceability::gainAnalysis"`,
+		`<td class="sysml-cell" data-column="verifications" data-value-kind="integer"><span class="sysml-value" data-value-kind="integer">2</span></td>`,
+	} {
+		if !strings.Contains(string(html), want) {
+			t.Errorf("HTML is missing %q:\n%s", want, html)
+		}
+	}
+}
+
+// TestManualTraceabilityLadder renders the four graded traceability examples end to end:
+// Markdown matches the committed output, the tier's distinguishing rows are present, HTML keeps element cells.
+func TestManualTraceabilityLadder(t *testing.T) {
+	binary := buildCLI(t)
+	examples := filepath.Join("..", "..", "docs", "manual", "examples")
+	tiers := []struct {
+		name     string
+		document string
+		markdown []string
+		html     []string
+		tables   int
+	}{
+		{
+			name:     "trace-1-basic",
+			document: "RoverBasic::BasicReport",
+			markdown: []string{
+				"| RV-1 | The rover shall travel at least 20 km on one charge. | RoverBasic::rover::battery | true |",
+				"| RV-3 | The rover shall accept commands from the lander at 2 kbps. |  | false |",
+				"Requirements no part satisfies:\n\n- RV-3\n",
+			},
+			html: []string{
+				`data-element="RoverBasic::rover::battery"`,
+				`data-column="satisfied" data-value-kind="boolean"`,
+			},
+			tables: 1,
+		},
+		{
+			name:     "trace-2-hierarchy",
+			document: "LanderHierarchy::HierarchyReport",
+			markdown: []string{
+				"| L-1 | mass | LanderHierarchy::lander | LanderHierarchy::weighLander |",
+				"| L-2.1 | thrust | LanderHierarchy::lander::propulsion::engine | LanderHierarchy::hotFire |",
+				"| L-4 | beacon | LanderHierarchy::lander::avionics::transponder |  |",
+				"| verification | hotFire | LanderHierarchy::lander.propulsion.engine | violated |",
+				"| satisfaction |  | LanderHierarchy::lander.avionics.transponder | undecided |",
+			},
+			html: []string{
+				`data-element="LanderHierarchy::weighLander"`,
+				`data-element="LanderHierarchy::lander::avionics::radar"`,
+			},
+			tables: 7,
+		},
+		{
+			name:     "trace-3-derivation",
+			document: "RangeDerivation::DerivationReport",
+			markdown: []string{
+				"| M-1 | range |  | RangeDerivation::specification::system::dailyRange, RangeDerivation::specification::system::energyBudget | 4 |  |  |",
+				"| S-2 | energyBudget | RangeDerivation::specification::mission::range | RangeDerivation::specification::subsystem::batteryCapacity, RangeDerivation::specification::subsystem::driveEfficiency | 2 | RangeDerivation::EnergyBudgetAnalysis |  |",
+				"| B-1 | batteryCapacity | RangeDerivation::specification::system::energyBudget |  | 0 |  | RangeDerivation::rover::battery |",
+			},
+			html: []string{
+				`data-element="RangeDerivation::EnergyBudgetAnalysis"`,
+				`data-column="descendants" data-value-kind="integer"`,
+			},
+			tables: 6,
+		},
+		{
+			name:     "trace-4-program",
+			document: "ProgramReport::ProgramTraceability",
+			markdown: []string{
+				"**team: Comms**",
+				"**team: Thermal**",
+				"| Power | PWR-3 | cellDegradation | high | ProgramRequirements::specification::subsystem::arrayOutput | 0 | PowerDesign::ArrayDegradationAnalysis | PowerDesign::powerSubsystem::array | 0 | true |",
+				"| Program | ST-1 | science | critical |  | 7 |  |  | 0 | false |",
+				"| Thermal | THM-1 | heaterPower | critical | ProgramRequirements::specification::system::survival | 0 | OrbiterVocabulary::HeaterBank |  | 0 | false |",
+				"| verification | linkBudgetAnalysis | CommsDesign::commsSubsystem.transmitter | violated |",
+			},
+			html: []string{
+				`data-element="CommsDesign::commsSubsystem::transmitter"`,
+				`data-element="PowerDesign::ArrayDegradationAnalysis"`,
+			},
+			tables: 7,
+		},
+	}
+	for _, tier := range tiers {
+		t.Run(tier.name, func(t *testing.T) {
+			source := filepath.Join(examples, tier.name+".sysml")
+			committed, err := os.ReadFile(filepath.Join(examples, tier.name+".md"))
+			if err != nil {
+				t.Fatal(err)
+			}
+			out := filepath.Join(t.TempDir(), tier.name+".md")
+			render := exec.Command(binary, source, "-render-document", tier.document, "-o", out)
+			if output, err := render.CombinedOutput(); err != nil {
+				t.Fatalf("render: %v\n%s", err, output)
+			}
+			written, err := os.ReadFile(out)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if string(written) != string(committed) {
+				t.Errorf("rendered example differs from docs/manual/examples/%s.md:\n%s", tier.name, written)
+			}
+			for _, want := range tier.markdown {
+				if !strings.Contains(string(written), want) {
+					t.Errorf("Markdown is missing %q:\n%s", want, written)
+				}
+			}
+
+			page := exec.Command(binary, source, "-render-document", tier.document, "-doc-form", "html")
+			html, err := page.Output()
+			if err != nil {
+				t.Fatalf("render HTML: %v", err)
+			}
+			for _, want := range tier.html {
+				if !strings.Contains(string(html), want) {
+					t.Errorf("HTML is missing %q:\n%s", want, html)
+				}
+			}
+			if got := strings.Count(string(html), "<table"); got != tier.tables {
+				t.Errorf("HTML has %d tables, want %d", got, tier.tables)
+			}
+		})
+	}
+}
+
+// TestManualTraceabilityLadderQueries runs the ladder's queries ad hoc and checks
+// they return the rows the rendered reports show.
+func TestManualTraceabilityLadderQueries(t *testing.T) {
+	binary := buildCLI(t)
+	examples := filepath.Join("..", "..", "docs", "manual", "examples")
+	cases := []struct {
+		source string
+		query  string
+		want   []string
+	}{
+		{
+			source: "trace-1-basic.sysml",
+			query:  "RoverBasic::Unsatisfied root=RoverBasic::specification",
+			want:   []string{"returned 1 row", `shortName = "RV-3"`},
+		},
+		{
+			source: "trace-2-hierarchy.sysml",
+			query:  "LanderHierarchy::SatisfiedButUnverified root=LanderHierarchy::specification",
+			want:   []string{"returned 1 row", `shortName = "L-4"`},
+		},
+		{
+			source: "trace-3-derivation.sysml",
+			query:  "RangeDerivation::AllDerived req=RangeDerivation::specification::mission::range",
+			want: []string{
+				"returned 4 rows",
+				`shortName = "S-1"`, `shortName = "S-2"`, `shortName = "B-1"`, `shortName = "D-1"`,
+			},
+		},
+		{
+			source: "trace-3-derivation.sysml",
+			query:  "RangeDerivation::Origins req=RangeDerivation::specification::subsystem::batteryCapacity",
+			want:   []string{"returned 2 rows", `shortName = "S-2"`, `shortName = "M-1"`},
+		},
+		{
+			source: "trace-4-program.sysml",
+			query:  "ProgramReport::Matrix",
+			want: []string{
+				"returned 12 rows",
+				"Columns: team, shortName, name, priority, derivedFrom, descendants, refinedBy, satisfiedBy, verifications, satisfied",
+				`shortName = "ST-1"`, "descendants = 7",
+				`shortName = "PWR-2"`, "verifications = 2",
+				"refinedBy = OrbiterVocabulary::HeaterBank",
+			},
+		},
+		{
+			source: "trace-4-program.sysml",
+			query:  "ProgramReport::CriticalUncovered",
+			want:   []string{"returned 1 row", `shortName = "THM-1"`},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.query, func(t *testing.T) {
+			run := exec.Command(binary, filepath.Join(examples, tc.source), "-run-query", tc.query)
+			output, err := run.CombinedOutput()
+			if err != nil {
+				t.Fatalf("run query: %v\n%s", err, output)
+			}
+			for _, want := range tc.want {
+				if !strings.Contains(string(output), want) {
+					t.Errorf("query output is missing %q:\n%s", want, output)
+				}
+			}
+		})
+	}
+}
diff --git a/cmd/sysml/paths.go b/cmd/sysml/paths.go
index 05e43ce961..feb84a3ae1 100644
--- a/cmd/sysml/paths.go
+++ b/cmd/sysml/paths.go
@@ -3,7 +3,7 @@ package main
 import (
 	"strings"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/project"
+	"github.com/Open-MBEE/OpenSysML/internal/workspace/project"
 )
 
 // namedModels lists the models a message is about, calling standard input what
diff --git a/cmd/sysml/prompt_test.go b/cmd/sysml/prompt_test.go
index 2b085d8e73..c13649d8fe 100644
--- a/cmd/sysml/prompt_test.go
+++ b/cmd/sysml/prompt_test.go
@@ -6,7 +6,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
 )
 
 // TestHistoryPath covers where the prompt keeps its history, including the
diff --git a/cmd/sysml/record_test.go b/cmd/sysml/record_test.go
new file mode 100644
index 0000000000..9fa142dbfa
--- /dev/null
+++ b/cmd/sysml/record_test.go
@@ -0,0 +1,358 @@
+package main
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"testing"
+
+	"github.com/Open-MBEE/OpenSysML/internal/exec/runtime"
+)
+
+// recordModel declares the cases the -record-run tests record: one case whose
+// subject the model binds, and one without a subject.
+const recordModel = `package Demo {
+	private import ScalarValues::*;
+	part def Probe {
+		attribute t : Real = 3.0;
+	}
+	part probe : Probe;
+	analysis def Check {
+		subject s : Probe;
+		in gain : Real;
+		out x : Real = s.t + gain;
+	}
+	analysis timed : Check { subject s = probe; in gain = 2.0; }
+}`
+
+func writeRecordModel(t *testing.T) string {
+	t.Helper()
+	path := filepath.Join(t.TempDir(), "model.sysml")
+	if err := os.WriteFile(path, []byte(recordModel), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	return path
+}
+
+// TestRecordRunReportsWhatItRecorded runs a case and records it, and the run
+// reports both the case's verdict and the element the record became.
+func TestRecordRunReportsWhatItRecorded(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	cmd := exec.Command(binary, source, "-record-run", "Demo::timed")
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("-record-run: %v\n%s", err, out)
+	}
+	for _, want := range []string{"x = 5.0", "recorded Records::timed_run1"} {
+		if !strings.Contains(string(out), want) {
+			t.Errorf("-record-run output is missing %q:\n%s", want, out)
+		}
+	}
+}
+
+// TestRecordRunConvertWritesTheRecords converts the session text a -record-run
+// produced: the file it writes carries the Records package, validates clean as
+// a model of its own, and a second run records _run2 into it.
+func TestRecordRunConvertWritesTheRecords(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	dir := t.TempDir()
+	first := filepath.Join(dir, "first.sysml")
+	cmd := exec.Command(binary, source, "-record-run", "Demo::timed", "-convert", "sysml", "-o", first)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("record + convert: %v\n%s", err, out)
+	}
+	written, err := os.ReadFile(first)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, want := range []string{"part timed_run1 : TimedRun", `caseName = "Demo::timed"`, "attribute :>> x = 5.0"} {
+		if !strings.Contains(string(written), want) {
+			t.Errorf("converted model is missing %q:\n%s", want, written)
+		}
+	}
+	if out, err := exec.Command(binary, first, "-validate").CombinedOutput(); err != nil {
+		t.Fatalf("converted model does not validate: %v\n%s", err, out)
+	}
+	second := filepath.Join(dir, "second.sysml")
+	cmd = exec.Command(binary, first, "-record-run", "Demo::timed", "-convert", "sysml", "-o", second)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("re-record + convert: %v\n%s", err, out)
+	}
+	written, err = os.ReadFile(second)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(string(written), "part timed_run2 : TimedRun") {
+		t.Errorf("converted model is missing the renumbered run:\n%s", written)
+	}
+}
+
+// TestRecordRunSweepRecordsEveryRow sweeps one input of a case and records one
+// run per value the range steps through.
+func TestRecordRunSweepRecordsEveryRow(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	out := filepath.Join(t.TempDir(), "swept.sysml")
+	cmd := exec.Command(binary, source, "-record-run", "Demo::timed", "-sweep", "gain=1..3", "-convert", "sysml", "-o", out)
+	if output, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("record + sweep + convert: %v\n%s", err, output)
+	}
+	written, err := os.ReadFile(out)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, want := range []string{"part timed_run1 :", "part timed_run2 :", "part timed_run3 :", `attribute :>> kind = "sweep"`, "attribute :>> iteration = 3"} {
+		if !strings.Contains(string(written), want) {
+			t.Errorf("converted sweep is missing %q:\n%s", want, written)
+		}
+	}
+}
+
+// TestRecordRunMonteCarloRecordsEveryRun samples a MonteCarlo case under -runs
+// and records each run it made.
+func TestRecordRunMonteCarloRecordsEveryRun(t *testing.T) {
+	binary := buildCLI(t)
+	model := `package MC {
+	private import ScalarValues::*;
+	private import RandomFunctions::*;
+	part def Probe {
+		attribute t : Real;
+		action settle { first start; then assign t := uniform(1.0, 5.0); then done; }
+	}
+	individual def probe :> Probe;
+	analysis def Mc :> Simulation::MonteCarlo {
+		subject analysed : Probe;
+		perform action run ::> analysed.settle;
+		attribute :>> observed : Real = analysed.t;
+		return Mean : Real = mean;
+	}
+}
+`
+	source := filepath.Join(t.TempDir(), "model.sysml")
+	if err := os.WriteFile(source, []byte(model), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	out := filepath.Join(t.TempDir(), "runs.sysml")
+	cmd := exec.Command(binary, source, "-instantiate", "MC::probe", "-record-run", "MC::Mc MC::probe", "-runs", "2", "-seed", "7", "-convert", "sysml", "-o", out)
+	if output, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("record + runs + convert: %v\n%s", err, output)
+	}
+	written, err := os.ReadFile(out)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, want := range []string{"part Mc_run1 :", "part Mc_run2 :", `attribute :>> kind = "runs"`, "attribute :>> iteration = 2"} {
+		if !strings.Contains(string(written), want) {
+			t.Errorf("converted MonteCarlo records are missing %q:\n%s", want, written)
+		}
+	}
+}
+
+// TestRecordRunRenderDocumentSeesTheRecords renders a document over the model
+// after a run was recorded, so the document's table lists the record it made.
+func TestRecordRunRenderDocumentSeesTheRecords(t *testing.T) {
+	binary := buildCLI(t)
+	model := `package Demo {
+	private import ScalarValues::*;
+	private import DocumentQueries::*;
+	part def Probe {
+		attribute t : Real = 3.0;
+	}
+	part probe : Probe;
+	analysis def Check {
+		subject s : Probe;
+		in gain : Real;
+		out x : Real = s.t + gain;
+	}
+	analysis timed : Check { subject s = probe; in gain = 2.0; }
+	calc def RecordedRuns :> DocumentQueries::Query {
+		in names : String[1..*];
+		Project(
+			source = Named(qualifiedName = names),
+			properties = ("name", "kind")
+		)
+	}
+	part def Log :> DocumentQueries::Document {
+		attribute redefines title = "Run Log";
+		part runs : Table {
+			calc rows : RecordedRuns { in names = "Records::timed_run1"; }
+		}
+	}
+}
+`
+	source := filepath.Join(t.TempDir(), "model.sysml")
+	if err := os.WriteFile(source, []byte(model), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	out := filepath.Join(t.TempDir(), "report.md")
+	cmd := exec.Command(binary, source, "-record-run", "Demo::timed", "-render-document", "Demo::Log", "-o", out)
+	if output, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("record + render-document: %v\n%s", err, output)
+	}
+	written, err := os.ReadFile(out)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(string(written), "timed\\_run1") && !strings.Contains(string(written), "timed_run1") {
+		t.Errorf("rendered document does not list the record:\n%s", written)
+	}
+}
+
+// TestRecordRunChecksStayExclusive keeps the guard that a decision and a render
+// do not share a run: -render-document with -analysis is still refused.
+func TestRecordRunChecksStayExclusive(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	cmd := exec.Command(binary, source, "-analysis", "Demo::timed", "-render-document", "Demo::Doc", "-o", filepath.Join(t.TempDir(), "x.md"))
+	if out, err := cmd.CombinedOutput(); err == nil {
+		t.Fatalf("-analysis + -render-document succeeded:\n%s", out)
+	} else if !strings.Contains(string(out), "-render-document writes a document") {
+		t.Errorf("unexpected refusal:\n%s", out)
+	}
+}
+
+// TestRecordIntoWithoutRecordRunRefused rejects -record-into on its own.
+func TestRecordIntoWithoutRecordRunRefused(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	cmd := exec.Command(binary, source, "-record-into", "Demo::Log", "-convert", "sysml")
+	if out, err := cmd.CombinedOutput(); err == nil {
+		t.Fatalf("-record-into alone succeeded:\n%s", out)
+	} else if !strings.Contains(string(out), "-record-into accompanies -record-run") {
+		t.Errorf("unexpected refusal:\n%s", out)
+	}
+}
+
+// TestRecordRunCommandCarriesTheRunFlags records the flags the session ran
+// under in the record's provenance command.
+func TestRecordRunCommandCarriesTheRunFlags(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	out := filepath.Join(t.TempDir(), "saved.sysml")
+	cmd := exec.Command(binary, source, "-record-run", "Demo::timed",
+		"-clock-step", "0.5", "-instantiate", "Demo::probe", "-convert", "sysml", "-o", out)
+	if output, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("record with run flags: %v\n%s", err, output)
+	}
+	written, err := os.ReadFile(out)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, want := range []string{`-clock-step 0.5`, `-instantiate \"Demo::probe\"`} {
+		if !strings.Contains(string(written), want) {
+			t.Errorf("recorded command is missing %q:\n%s", want, written)
+		}
+	}
+}
+
+// TestRecordIntoEmptyRefused rejects -record-into given without a package.
+func TestRecordIntoEmptyRefused(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	cmd := exec.Command(binary, source, "-record-run", "Demo::timed", "-record-into=", "-convert", "sysml")
+	if out, err := cmd.CombinedOutput(); err == nil {
+		t.Fatalf("-record-into= succeeded:\n%s", out)
+	} else if !strings.Contains(string(out), "-record-into needs a package name") {
+		t.Errorf("unexpected refusal:\n%s", out)
+	}
+}
+
+// TestRecordRunConvertHonoursID converts the session a -record-run produced
+// with -id applied to it, as -convert honours it on a file.
+func TestRecordRunConvertHonoursID(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	out := run(t, binary, source, "-record-run", "Demo::timed", "-convert", "ttl", "-id", "uuid")
+	uuid := regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
+	if !uuid.MatchString(out) {
+		t.Errorf("no UUID ids in the converted records:\n%s", out)
+	}
+}
+
+// TestRecordRunConvertRefusesFrom refuses -from on a recorded conversion: the
+// input is still SysML notation.
+func TestRecordRunConvertRefusesFrom(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	cmd := exec.Command(binary, source, "-record-run", "Demo::timed", "-from", "kerml", "-convert", "sysml")
+	if out, err := cmd.CombinedOutput(); err == nil {
+		t.Fatalf("-record-run + -from kerml succeeded:\n%s", out)
+	} else if !strings.Contains(string(out), "-from") {
+		t.Errorf("unexpected refusal:\n%s", out)
+	}
+}
+
+// TestRecordRunBoundsApplyToConvert refuses the run bounds runChecks refuses
+// when -convert shares the run, exiting as the same misuse does.
+func TestRecordRunBoundsApplyToConvert(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	out, code := exitCode(t, exec.Command(binary, source,
+		"-record-run", "Demo::timed", "-sweep", "gain=1..3", "-samples", "2", "-seed", "7", "-convert", "sysml"))
+	if code != 2 || !strings.Contains(out, "-samples draws values for a sweep it does not run") {
+		t.Errorf("-samples with -record-run -convert: code %d:\n%s", code, out)
+	}
+}
+
+// TestRecordRunBoundsApplyToRenderDocument refuses a -runs/-sweep conflict on
+// a -render-document run the same as runChecks does.
+func TestRecordRunBoundsApplyToRenderDocument(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	out, code := exitCode(t, exec.Command(binary, source,
+		"-record-run", "Demo::timed", "-runs", "3", "-seed", "7", "-sweep", "gain=1..3", "-render-document", "Demo::Doc"))
+	if code != 2 || !strings.Contains(out, "-runs runs an action; -sweep and -samples run an analysis case or calc") {
+		t.Errorf("-runs + -sweep with -record-run -render-document: code %d:\n%s", code, out)
+	}
+}
+
+// TestRecordRunConvertHonoursRunBounds applies the run bounds the env vars
+// ask for on a recorded conversion, as runChecks does on a plain -sweep.
+func TestRecordRunConvertHonoursRunBounds(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	t.Setenv(runtime.MaxSweepRunsEnvVar, "notanumber")
+	out, code := exitCode(t, exec.Command(binary, source, "-record-run", "Demo::timed", "-convert", "sysml"))
+	if code != 2 || !strings.Contains(out, runtime.MaxSweepRunsEnvVar) {
+		t.Errorf("an unusable %s went unreported: code %d:\n%s", runtime.MaxSweepRunsEnvVar, code, out)
+	}
+}
+
+// TestRecordRunConvertHonoursTheSweepBudget caps a recorded sweep the same
+// way a plain -sweep is capped.
+func TestRecordRunConvertHonoursTheSweepBudget(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	t.Setenv(runtime.MaxSweepRunsEnvVar, "2")
+	out, code := exitCode(t, exec.Command(binary, source,
+		"-record-run", "Demo::timed", "-sweep", "gain=1..5", "-convert", "sysml"))
+	if code == 0 || !strings.Contains(out, runtime.MaxSweepRunsEnvVar) {
+		t.Errorf("a sweep over the budget converted: code %d:\n%s", code, out)
+	}
+}
+
+// TestRecordRunConvertRefusesIrrelevantFlags refuses the flags a recorded
+// conversion does not honour, before the model is even loaded.
+func TestRecordRunConvertRefusesIrrelevantFlags(t *testing.T) {
+	binary := buildCLI(t)
+	source := writeRecordModel(t)
+	for name, args := range map[string][]string{
+		"branch input":      {"flexo://proj-1/main", "-record-run", "Demo::timed", "-convert", "sysml"},
+		"branch output":     {source, "-record-run", "Demo::timed", "-convert", "ttl", "-o", "flexo://proj-1/main"},
+		"sync-state":        {source, "-record-run", "Demo::timed", "-convert", "ttl", "-sync-state", filepath.Join(t.TempDir(), "s.ttl")},
+		"migration report":  {source, "-record-run", "Demo::timed", "-convert", "sysml", "-migration-report", filepath.Join(t.TempDir(), "r.json")},
+		"migration results": {source, "-record-run", "Demo::timed", "-convert", "sysml", "-migration-results", filepath.Join(t.TempDir(), "r.txt")},
+		"layout":            {source, "-record-run", "Demo::timed", "-convert", "sysml", "-layout", filepath.Join(t.TempDir(), "l.json")},
+	} {
+		t.Run(name, func(t *testing.T) {
+			out, code := exitCode(t, exec.Command(binary, args...))
+			if code != 2 || !strings.Contains(out, "-record-run converts the recorded session model") {
+				t.Errorf("%v: code %d:\n%s", args, code, out)
+			}
+		})
+	}
+}
diff --git a/cmd/sysml/render.go b/cmd/sysml/render.go
index 10848b3362..add4c18a66 100644
--- a/cmd/sysml/render.go
+++ b/cmd/sysml/render.go
@@ -1,18 +1,23 @@
 package main
 
 import (
+	"crypto/sha256"
+	"encoding/hex"
 	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
 	"slices"
 	"strings"
+	"unicode"
+	"unicode/utf8"
 
 	"github.com/chzyer/readline"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/export"
-	"github.com/Open-MBEE/OpenSysML/internal/core/view"
-	"github.com/Open-MBEE/OpenSysML/internal/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/ir/view"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/export"
+	"github.com/Open-MBEE/OpenSysML/internal/workspace/model"
 )
 
 // runRender renders the view -render names of the model the files named on the
@@ -79,7 +84,10 @@ func runRenderAll(files []string) error {
 		return fmt.Errorf("create rendering directory %s: %w", renderAllDir, err)
 	}
 
-	destinations := map[string]string{}
+	filenames, err := renderFilenames(views, form)
+	if err != nil {
+		return err
+	}
 	for _, info := range views {
 		if !info.Supported {
 			reportRenderSkip(info.Name, info.Reason)
@@ -102,15 +110,7 @@ func runRenderAll(files []string) error {
 			}
 			return err
 		}
-		filename, err := renderFilename(info.Name, writtenForm)
-		if err != nil {
-			return err
-		}
-		path := filepath.Join(renderAllDir, filename)
-		if previous, exists := destinations[path]; exists {
-			return fmt.Errorf("views %s and %s have the same rendering path %s", previous, info.Name, path)
-		}
-		destinations[path] = info.Name
+		path := filepath.Join(renderAllDir, filenames[info.Name])
 		if err := writeArtifactFile(path, artifact, writtenForm); err != nil {
 			return err
 		}
@@ -118,8 +118,79 @@ func runRenderAll(files []string) error {
 	return nil
 }
 
+// renderFilenames is the file -render-all writes each view it writes to, by view name;
+// files meeting letter case aside are tagged until no two meet, or refused if two still do.
+func renderFilenames(views []model.ViewInfo, form view.Form) (map[string]string, error) {
+	type plan struct {
+		name   string
+		form   view.Form
+		tagged bool
+	}
+	var plans []*plan
+	for _, info := range views {
+		written := form
+		if written == "" {
+			written = info.Kind.MachineForm()
+		}
+		if info.Supported && info.Kind.SupportsForm(written) {
+			plans = append(plans, &plan{name: info.Name, form: written})
+		}
+	}
+	for {
+		meeting := map[string][]*plan{}
+		var keys []string
+		for _, p := range plans {
+			key := caseFolded(renderFilename(p.name, p.form, p.tagged))
+			if _, seen := meeting[key]; !seen {
+				keys = append(keys, key)
+			}
+			meeting[key] = append(meeting[key], p)
+		}
+		progressed := false
+		for _, key := range keys {
+			group := meeting[key]
+			if len(group) < 2 {
+				continue
+			}
+			settled := true
+			for _, p := range group {
+				if !p.tagged {
+					p.tagged, settled, progressed = true, false, true
+				}
+			}
+			if settled {
+				return nil, fmt.Errorf("views %s and %s have the same rendering path %s",
+					group[0].name, group[1].name, renderFilename(group[0].name, group[0].form, true))
+			}
+		}
+		if !progressed {
+			break
+		}
+	}
+	filenames := make(map[string]string, len(plans))
+	for _, p := range plans {
+		filenames[p.name] = renderFilename(p.name, p.form, p.tagged)
+	}
+	return filenames, nil
+}
+
+// caseFolded is text under simple Unicode case folding: two texts fold alike
+// exactly when strings.EqualFold holds of them.
+func caseFolded(text string) string {
+	var b strings.Builder
+	for _, r := range text {
+		least := r
+		for f := unicode.SimpleFold(r); f != r; f = unicode.SimpleFold(f) {
+			least = min(least, f)
+		}
+		b.WriteRune(least)
+	}
+	return b.String()
+}
+
 // renderOptions is what -render and -render-all write with: the text width,
-// and the palette -render-palette names, which must be one there is.
+// the palette -render-palette names and the placement -render-unplaced names,
+// each of which must be one there is.
 func renderOptions(width int) (view.Options, error) {
 	options := view.Options{Width: width}
 	if renderPalette != "" {
@@ -129,9 +200,28 @@ func renderOptions(width int) (view.Options, error) {
 		}
 		options.Palette = palette
 	}
+	unplaced, err := unplacedOption()
+	if err != nil {
+		return view.Options{}, err
+	}
+	options.Unplaced = unplaced
 	return options, nil
 }
 
+// unplacedOption is the placement -render-unplaced names for the nodes a
+// positioned DOT drawing leaves unplaced, which must be one there is; none
+// named is the default, leaving them undrawn.
+func unplacedOption() (view.Unplaced, error) {
+	if renderUnplaced == "" {
+		return "", nil
+	}
+	unplaced, ok := view.ParseUnplaced(renderUnplaced)
+	if !ok {
+		return "", fmt.Errorf("-render-unplaced: %w", &view.UnknownUnplacedError{Name: renderUnplaced})
+	}
+	return unplaced, nil
+}
+
 // loadRenderingModel loads and reports a model whose stdout is reserved for
 // rendering artifacts.
 func loadRenderingModel(files []string) (*repl.Session, error) {
@@ -146,15 +236,95 @@ func loadRenderingModel(files []string) (*repl.Session, error) {
 	if report.Errors {
 		return nil, fmt.Errorf("%s did not analyse cleanly; nothing was rendered", strings.Join(files, ", "))
 	}
+	// The objects -instantiate names are created first, so a document's queries
+	// run over what the session holds under those names.
+	for _, name := range modelChecks.instantiate {
+		created, err := sess.InstantiateReport(name)
+		if err != nil {
+			return nil, err
+		}
+		writeLines(os.Stderr, created.Lines)
+		if len(created.FeatureValueErrors) > 0 {
+			writeLines(os.Stderr, created.FeatureValueErrors)
+			return nil, fmt.Errorf("%s did not materialize cleanly; nothing was rendered", name)
+		}
+		if created.Bounded {
+			fmt.Fprintf(os.Stderr, "%s: materialization is bounded; not every feature value was materialized\n", name)
+		}
+	}
+	// The runs -record-run names are made and written into the model before a
+	// document is rendered, so its queries see the records.
+	for _, invocation := range modelChecks.records {
+		verdict := modelChecks.record(sess, invocation)
+		writeLines(os.Stderr, verdict.Lines)
+		if verdict.Status != repl.VerdictHolds {
+			return nil, fmt.Errorf("%s: the run was not recorded; nothing was rendered", invocation)
+		}
+	}
 	return sess, nil
 }
 
-func renderFilename(name string, form view.Form) (string, error) {
-	filename := strings.ReplaceAll(name, "::", ".") + renderExtension(form)
-	if filepath.Base(filename) != filename || filename == "." || filename == ".." {
-		return "", fmt.Errorf("view %s does not form a safe rendering filename", name)
+// renderFilename is the file -render-all writes a view to: its qualified name with `::` as `.`, every
+// unsafe byte as `%XX` (the first too under a Windows device-name stem), cut to fit, `~` and a hash when tagged, the extension.
+func renderFilename(name string, form view.Form, tagged bool) string {
+	var b strings.Builder
+	for i := 0; i < len(name); i++ {
+		switch c := name[i]; {
+		case c == ':' && i+1 < len(name) && name[i+1] == ':':
+			b.WriteByte('.')
+			i++
+		case c < 0x20 || c == 0x7f || strings.IndexByte(unsafeFilenameBytes, c) >= 0:
+			fmt.Fprintf(&b, "%%%02X", c)
+		default:
+			b.WriteByte(c)
+		}
+	}
+	filename := b.String()
+	if stem, _, _ := strings.Cut(filename, "."); windowsDeviceNames[strings.ToUpper(strings.TrimRight(stem, " "))] {
+		filename = fmt.Sprintf("%%%02X", filename[0]) + filename[1:]
+	}
+	ext := renderExtension(form)
+	if tagged || len(filename)+len(ext) > maxFilenameBytes {
+		sum := sha256.Sum256([]byte(filename))
+		tag := "~" + hex.EncodeToString(sum[:filenameTagBytes])
+		filename = cutFilename(filename, maxFilenameBytes-len(ext)-len(tag)) + tag
 	}
-	return filename, nil
+	return filename + ext
+}
+
+// maxFilenameBytes is the longest name every common filesystem takes for one path component;
+// filenameTagBytes of the encoded name's hash keep a cut name apart from its neighbours.
+const (
+	maxFilenameBytes = 255
+	filenameTagBytes = 8
+)
+
+// cutFilename is the longest prefix of an encoded filename within n bytes that
+// splits neither a UTF-8 sequence nor a `%XX` escape.
+func cutFilename(filename string, n int) string {
+	n = min(n, len(filename))
+	for n > 0 && n < len(filename) && !utf8.RuneStart(filename[n]) {
+		n--
+	}
+	if i := strings.LastIndexByte(filename[:n], '%'); i >= 0 && i > n-3 {
+		n = i
+	}
+	return filename[:n]
+}
+
+// unsafeFilenameBytes are the printable bytes a rendering filename encodes: path separators,
+// the drive colon, the encoding's own `%`, the `.` standing for `::`, and what Windows reserves.
+const unsafeFilenameBytes = "/\\:%.<>\"|?*"
+
+// windowsDeviceNames are the stems Windows reads as devices whatever the extension,
+// trailing spaces and letter case aside: the serial and printer ports include the
+// superscript digits Windows counts among them.
+var windowsDeviceNames = map[string]bool{
+	"CON": true, "PRN": true, "AUX": true, "NUL": true,
+	"COM0": true, "COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true, "COM6": true, "COM7": true, "COM8": true, "COM9": true,
+	"COM¹": true, "COM²": true, "COM³": true,
+	"LPT0": true, "LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true, "LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
+	"LPT¹": true, "LPT²": true, "LPT³": true,
 }
 
 func renderExtension(form view.Form) string {
diff --git a/cmd/sysml/render_document.go b/cmd/sysml/render_document.go
index 4c5b24a2c3..6e01856c19 100644
--- a/cmd/sysml/render_document.go
+++ b/cmd/sysml/render_document.go
@@ -9,12 +9,12 @@ import (
 	"slices"
 	"strings"
 
-	"github.com/Open-MBEE/OpenSysML/internal/core/docrender"
-	"github.com/Open-MBEE/OpenSysML/internal/core/export"
-	"github.com/Open-MBEE/OpenSysML/internal/core/view"
-	"github.com/Open-MBEE/OpenSysML/internal/docpdf"
-	"github.com/Open-MBEE/OpenSysML/internal/fsutil"
-	"github.com/Open-MBEE/OpenSysML/internal/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/doc/docpdf"
+	"github.com/Open-MBEE/OpenSysML/internal/doc/docrender"
+	"github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
+	"github.com/Open-MBEE/OpenSysML/internal/ir/view"
+	"github.com/Open-MBEE/OpenSysML/internal/syntax/source"
+	"github.com/Open-MBEE/OpenSysML/internal/translate/export"
 )
 
 // runRenderDocument renders the document -render-document names of the model
@@ -43,24 +43,49 @@ func runRenderDocument(files []string) error {
 		}
 		return writeArtifact(rendered, formHTML)
 	}
-	markdown, err := sess.RenderDocumentMarkdown(renderDoc, markdownOptions())
-	if err != nil {
-		return err
-	}
 	if form == docFormPDF {
-		pdf, err := docpdf.Render(markdown, pdfEngine, docpdf.Options{
-			TitlePage:      pdfTitlePage,
-			TOC:            pdfTOC,
-			NumberSections: pdfNumbering,
-		})
+		opts, err := pdfOptions()
+		if err != nil {
+			return err
+		}
+		document, err := sess.EvaluateDocument(renderDoc)
+		if err != nil {
+			return err
+		}
+		pdf, err := docpdf.Render(document, pdfEngine, opts)
 		if err != nil {
 			return err
 		}
 		return writePDFArtifact(pdf)
 	}
+	markdown, err := sess.RenderDocumentMarkdown(renderDoc, markdownOptions())
+	if err != nil {
+		return err
+	}
 	return writeArtifact(markdown, view.FormMarkdown)
 }
 
+// pdfOptions resolves the PDF flags: the deliverable options and the
+// stylesheet options, which reach the PDF as they reach an HTML page, their
+// relative references resolving against the PDF's directory.
+func pdfOptions() (docpdf.Options, error) {
+	page, err := htmlOptions()
+	if err != nil {
+		return docpdf.Options{}, err
+	}
+	return docpdf.Options{
+		TitlePage:           pdfTitlePage,
+		TOC:                 pdfTOC,
+		NumberSections:      pdfNumbering,
+		Theme:               page.Theme,
+		NoDefaultStylesheet: page.NoDefaultStylesheet,
+		Stylesheets:         page.Stylesheets,
+		BaseDir:             filepath.Dir(outputPath),
+		DiagramForm:         page.DiagramForm,
+		Unplaced:            page.Unplaced,
+	}, nil
+}
+
 // runRenderDocuments renders every document definition of the model named on
 // the command line as linked files in the directory -render-documents names,
 // so cross-document references resolve on disk.
@@ -121,24 +146,30 @@ func documentOptions() docrender.HTMLOptions {
 		TOC:                 pdfTOC,
 		NumberSections:      pdfNumbering,
 		MermaidScript:       mermaidScriptURL(),
+		MathScript:          mathScriptURL(),
 		DiagramForm:         view.Form(diagramForm),
+		Unplaced:            view.Unplaced(renderUnplaced),
 	}
 }
 
 // markdownOptions carries the flags shaping a Markdown document.
 func markdownOptions() docrender.MarkdownOptions {
-	return docrender.MarkdownOptions{DiagramForm: view.Form(diagramForm)}
+	return docrender.MarkdownOptions{DiagramForm: view.Form(diagramForm), Unplaced: view.Unplaced(renderUnplaced)}
 }
 
-// checkDiagramForm rejects a -diagram-form value naming no diagram form.
+// checkDiagramForm rejects a -diagram-form value naming no diagram form, and
+// a -render-unplaced value naming no placement.
 func checkDiagramForm() error {
+	if _, err := unplacedOption(); err != nil {
+		return err
+	}
 	if diagramForm == "" || slices.Contains(view.DiagramForms(), view.Form(diagramForm)) {
 		return nil
 	}
 	return fmt.Errorf("unknown diagram form %q; -diagram-form takes %s", diagramForm, view.FormNames(view.DiagramForms()))
 }
 
-// The -html-mermaid value naming the pinned CDN release.
+// The -html-mermaid and -html-math value naming the pinned CDN release.
 const mermaidCDN = "cdn"
 
 // mermaidScriptURL resolves -html-mermaid: cdn names the pinned release,
@@ -150,6 +181,15 @@ func mermaidScriptURL() string {
 	return htmlMermaid
 }
 
+// mathScriptURL resolves -html-math: cdn names the pinned MathJax release,
+// anything else is the URL to load as it stands.
+func mathScriptURL() string {
+	if htmlMath == mermaidCDN {
+		return docrender.MathScriptURL
+	}
+	return htmlMath
+}
+
 // setStylesheets resolves the stylesheets of an HTML set: the default sheet
 // and each -html-css file are written beside the pages and linked, in
 // command-line order, and a -html-css URL is linked as it stands.
@@ -359,7 +399,7 @@ func commitDocumentSet(documents []repl.RenderedDocument, form string) error {
 			}
 			switch {
 			case committed[i] && backups[i] != "":
-				_ = fsutil.Replace(backups[i], targets[i])
+				_ = source.ReplaceFile(backups[i], targets[i])
 			case committed[i]:
 				_ = os.Remove(targets[i])
 			case backups[i] != "":
@@ -394,7 +434,7 @@ func commitDocumentSet(documents []repl.RenderedDocument, form string) error {
 		if direct[i] {
 			continue
 		}
-		if err := fsutil.Replace(staged[i], targets[i]); err != nil {
+		if err := source.ReplaceFile(staged[i], targets[i]); err != nil {
 			rollback()
 			return fmt.Errorf("write %s: %w", targets[i], err)
 		}
@@ -580,7 +620,7 @@ func htmlFlagsGiven() bool {
 // rendered HTML page carries; -html-theme is left out, as it also shapes the
 // sheet -html-default-css writes.
 func htmlPageFlagsGiven() bool {
-	return len(htmlCSS) > 0 || htmlNoCSS || htmlFragment || htmlMermaid != ""
+	return len(htmlCSS) > 0 || htmlNoCSS || htmlFragment || htmlMermaid != "" || htmlMath != ""
 }
 
 // checkTheme rejects an -html-theme value that names no bundled theme.
@@ -604,6 +644,15 @@ func checkMermaidScript() error {
 	return nil
 }
 
+// checkMathScript rejects an -html-math value that is neither cdn nor a URL a
+// page can load a script from.
+func checkMathScript() error {
+	if htmlMath == "" || htmlMath == mermaidCDN || isWebURL(htmlMath) {
+		return nil
+	}
+	return fmt.Errorf("-html-math takes %s or the URL of a MathJax script; %q is neither", mermaidCDN, htmlMath)
+}
+
 // documentSetForm resolves -doc-form for -render-documents, which writes a
 // linked set of files rather than one artifact.
 func documentSetForm() (string, error) {
@@ -629,6 +678,9 @@ func documentSetForm() (string, error) {
 		if err := checkMermaidScript(); err != nil {
 			return "", err
 		}
+		if err := checkMathScript(); err != nil {
+			return "", err
+		}
 		if err := checkThemeUse(); err != nil {
 			return "", err
 		}
@@ -668,49 +720,91 @@ func documentForm() (string, error) {
 	}
 	switch form := docFormOrDefault(); form {
 	case docFormMarkdown:
-		if htmlFlagsGiven() {
-			return "", errors.New("the -html- options shape HTML output; ask for it with -doc-form html")
-		}
-		if pdfEngine != "" || pdfTitlePage || pdfTOC || pdfNumbering {
-			return "", errors.New("-pdf-engine and the title page, contents and numbering options shape HTML and PDF output; ask for one with -doc-form html or -doc-form pdf")
-		}
-		return form, nil
+		return checkMarkdownForm(form)
 	case docFormHTML:
-		if pdfEngine != "" {
-			return "", errors.New("-pdf-engine shapes PDF output; -doc-form html needs no external converter")
-		}
-		if htmlFragment && htmlNoCSS {
-			return "", errors.New("-html-fragment already writes no stylesheet; -html-no-default-css leaves the default sheet out of a whole page")
-		}
-		if htmlFragment && len(htmlCSS) > 0 {
-			return "", errors.New("-html-fragment writes the document element alone, with no place for a stylesheet; style the page you embed it in")
-		}
-		if htmlFragment && htmlMermaid != "" {
-			return "", errors.New("-html-fragment writes the document element alone, with no place for a script; load Mermaid in the page you embed it in")
-		}
-		if err := checkMermaidScript(); err != nil {
-			return "", err
-		}
-		if htmlFragment && htmlTheme != "" {
-			return "", errors.New("-html-fragment writes the document element alone, with no place for a stylesheet; -html-theme styles a whole page")
-		}
-		if err := checkThemeUse(); err != nil {
-			return "", err
-		}
-		return form, nil
+		return checkHTMLForm(form)
 	case docFormPDF:
-		if htmlFlagsGiven() {
-			return "", errors.New("the -html- options shape HTML output; ask for it with -doc-form html")
-		}
-		if outputPath == "" {
-			return "", errors.New("-doc-form pdf writes a binary artifact; name the file to write with -o")
-		}
-		return form, nil
+		return checkPDFForm(form)
 	default:
 		return "", unknownDocumentForm(form)
 	}
 }
 
+// checkMarkdownForm refuses the HTML and PDF options, which do not apply.
+func checkMarkdownForm(form string) (string, error) {
+	if htmlFlagsGiven() {
+		return "", errors.New("the -html- options shape HTML output; ask for it with -doc-form html")
+	}
+	if pdfEngine != "" || pdfTitlePage || pdfTOC || pdfNumbering {
+		return "", errors.New("-pdf-engine and the title page, contents and numbering options shape HTML and PDF output; ask for one with -doc-form html or -doc-form pdf")
+	}
+	return form, nil
+}
+
+// checkHTMLForm checks the HTML option combination and the scripts and theme it names.
+func checkHTMLForm(form string) (string, error) {
+	if pdfEngine != "" {
+		return "", errors.New("-pdf-engine shapes PDF output; -doc-form html needs no external converter")
+	}
+	if err := checkFragmentOptions(); err != nil {
+		return "", err
+	}
+	if err := checkMermaidScript(); err != nil {
+		return "", err
+	}
+	if err := checkMathScript(); err != nil {
+		return "", err
+	}
+	if htmlFragment && htmlTheme != "" {
+		return "", errors.New("-html-fragment writes the document element alone, with no place for a stylesheet; -html-theme styles a whole page")
+	}
+	if err := checkThemeUse(); err != nil {
+		return "", err
+	}
+	return form, nil
+}
+
+// checkFragmentOptions refuses the stylesheet, script and theme options a
+// fragment has no place for.
+func checkFragmentOptions() error {
+	if !htmlFragment {
+		return nil
+	}
+	if htmlNoCSS {
+		return errors.New("-html-fragment already writes no stylesheet; -html-no-default-css leaves the default sheet out of a whole page")
+	}
+	if len(htmlCSS) > 0 {
+		return errors.New("-html-fragment writes the document element alone, with no place for a stylesheet; style the page you embed it in")
+	}
+	if htmlMermaid != "" {
+		return errors.New("-html-fragment writes the document element alone, with no place for a script; load Mermaid in the page you embed it in")
+	}
+	if htmlMath != "" {
+		return errors.New("-html-fragment writes the document element alone, with no place for a script; load MathJax in the page you embed it in")
+	}
+	return nil
+}
+
+// checkPDFForm refuses the HTML options a PDF does not use and requires -o.
+func checkPDFForm(form string) (string, error) {
+	if htmlFragment {
+		return "", errors.New("-html-fragment writes the document element alone for embedding in a page; a PDF is laid out from a whole page")
+	}
+	if htmlMermaid != "" {
+		return "", errors.New("-html-mermaid loads a script into an HTML page; a PDF draws its diagrams with mermaid-cli ahead of the converter")
+	}
+	if htmlMath != "" {
+		return "", errors.New("-html-math loads a script into an HTML page; a PDF typesets its formulas with KaTeX ahead of the converter")
+	}
+	if err := checkThemeUse(); err != nil {
+		return "", err
+	}
+	if outputPath == "" {
+		return "", errors.New("-doc-form pdf writes a binary artifact; name the file to write with -o")
+	}
+	return form, nil
+}
+
 // writePDFArtifact writes the PDF bytes to -o, byte-exact.
 func writePDFArtifact(pdf []byte) error {
 	replaced, err := export.WriteFile(outputPath, pdf)
diff --git a/cmd/sysml/render_document_html_test.go b/cmd/sysml/render_document_html_test.go
index 5208e3882c..eba9fd1900 100644
--- a/cmd/sysml/render_document_html_test.go
+++ b/cmd/sysml/render_document_html_test.go
@@ -189,19 +189,107 @@ func TestRenderDocumentHTMLMermaid(t *testing.T) {
 		2, "-doc-form html")
 	wantReport(t, check(t, binary, documentModel, "-render-document", "Reports::MassReport",
 		"-doc-form", "pdf", "-o", filepath.Join(t.TempDir(), "r.pdf"), "-html-mermaid", "cdn"),
-		2, "-doc-form html")
+		2, "-html-mermaid loads a script into an HTML page")
 	wantReport(t, check(t, binary, documentModel, "-html-mermaid", "cdn"),
 		2, "apply to -render-document")
 	wantReport(t, runCommand(t, exec.Command(binary, "-html-default-css", "-html-mermaid", "cdn")),
 		2, "not the sheet")
 }
 
+// mathModel declares a document carrying an inline formula and a display one.
+const mathModel = queryModel + `package Reports {
+	private import DocumentQueries::*;
+	private import Observatory::*;
+
+	part def OpticsReport :> Document {
+		attribute redefines title = "Optics Report";
+
+		part intro : Paragraph {
+			part lead : Span {
+				attribute redefines text = "Aperture area scales as";
+			}
+			part area : Span {
+				attribute redefines text = "A \\propto D^2";
+				attribute redefines style = "math";
+			}
+		}
+
+		part rayleigh : Formula {
+			attribute redefines source = "\\theta = 1.22\\,\\frac{\\lambda}{D}";
+			attribute redefines caption = "Rayleigh criterion";
+		}
+	}
+}
+`
+
+// TestRenderDocumentHTMLMath checks formulas reach every document form as
+// math rather than prose, and that -html-math loads the pinned MathJax
+// release or the URL named, under the same rules as -html-mermaid.
+func TestRenderDocumentHTMLMath(t *testing.T) {
+	binary := buildCLI(t)
+	pinned := `<script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js"></script>`
+	inline := `<span class="sysml-math">\(A \propto D^2\)</span>`
+	display := `<div class="sysml-math">\[\theta = 1.22\,\frac{\lambda}{D}\]</div>`
+
+	wantReport(t, check(t, binary, mathModel, "-render-document", "Reports::OpticsReport"), 0,
+		"Aperture area scales as $A \\propto D^2$", "$$\n\\theta = 1.22\\,\\frac{\\lambda}{D}\n$$", "*Rayleigh criterion*")
+
+	plain := check(t, binary, mathModel, "-render-document", "Reports::OpticsReport", "-doc-form", "html")
+	wantReport(t, plain, 0, inline, `<figure class="sysml-formula" data-content="formula" data-name="rayleigh">`, display,
+		`<figcaption class="sysml-caption">Rayleigh criterion</figcaption>`)
+	if strings.Contains(plain.stdout, "<script") {
+		t.Errorf("a page loads no script unless asked:\n%s", plain.stdout)
+	}
+	wantReport(t, check(t, binary, mathModel, "-render-document", "Reports::OpticsReport",
+		"-doc-form", "html", "-html-math", "cdn"), 0, inline, `processHtmlClass: "sysml-math"`, "</article>\n<script>window.MathJax", pinned+"\n</body>")
+	wantReport(t, check(t, binary, mathModel, "-render-document", "Reports::OpticsReport",
+		"-doc-form", "html", "-html-math", "https://example.test/mathjax.js", "-html-mermaid", "cdn"),
+		0, `<script src="https://example.test/mathjax.js"></script>`, "mermaid.min.js")
+
+	dir := filepath.Join(t.TempDir(), "site")
+	wantReport(t, check(t, binary, mathModel, "-render-documents", dir, "-doc-form", "html", "-html-math", "cdn"), 0)
+	pages, err := filepath.Glob(filepath.Join(dir, "*.html"))
+	if err != nil || len(pages) == 0 {
+		t.Fatalf("set wrote no pages: %v", err)
+	}
+	for _, page := range pages {
+		content, err := os.ReadFile(page)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !strings.Contains(string(content), pinned) {
+			t.Errorf("%s does not load MathJax:\n%s", page, content)
+		}
+	}
+
+	wantReport(t, check(t, binary, mathModel, "-render-document", "Reports::OpticsReport",
+		"-doc-form", "html", "-html-math", "mathjax.js"),
+		2, "-html-math takes cdn or the URL of a MathJax script")
+	wantReport(t, check(t, binary, mathModel, "-render-documents", dir,
+		"-doc-form", "html", "-html-math", "mathjax.js"),
+		2, "-html-math takes cdn or the URL of a MathJax script")
+	wantReport(t, check(t, binary, mathModel, "-render-document", "Reports::OpticsReport",
+		"-doc-form", "html", "-html-math="), 2, "-html-math is empty")
+	wantReport(t, check(t, binary, mathModel, "-render-document", "Reports::OpticsReport",
+		"-doc-form", "html", "-html-fragment", "-html-math", "cdn"),
+		2, "load MathJax in the page you embed it in")
+	wantReport(t, check(t, binary, mathModel, "-render-document", "Reports::OpticsReport", "-html-math", "cdn"),
+		2, "-doc-form html")
+	wantReport(t, check(t, binary, mathModel, "-render-document", "Reports::OpticsReport",
+		"-doc-form", "pdf", "-o", filepath.Join(t.TempDir(), "r.pdf"), "-html-math", "cdn"),
+		2, "-html-math loads a script into an HTML page")
+	wantReport(t, check(t, binary, mathModel, "-html-math", "cdn"),
+		2, "apply to -render-document")
+	wantReport(t, runCommand(t, exec.Command(binary, "-html-default-css", "-html-math", "cdn")),
+		2, "not the sheet")
+}
+
 // TestRenderDocumentHTMLDiagramForm checks -diagram-form reaches the HTML
 // backend: DOT or PlantUML on request, Mermaid otherwise, the table a table
 // either way.
 func TestRenderDocumentHTMLDiagramForm(t *testing.T) {
 	binary := buildCLI(t)
-	fixture := filepath.Join("..", "..", "internal", "core", "docrender", "testdata", "telescope_report.sysml")
+	fixture := filepath.Join("..", "..", "internal", "doc", "docrender", "testdata", "telescope_report.sysml")
 	dot := runCommand(t, exec.Command(binary, fixture, "-render-document", "Observatory::MassReport",
 		"-doc-form", "html", "-diagram-form", "dot"))
 	wantReport(t, dot, 0,
@@ -264,7 +352,7 @@ func TestRenderDocumentHTMLTheme(t *testing.T) {
 	wantReport(t, runCommand(t, exec.Command(binary, "-html-default-css", "-html-theme", "print")),
 		0, "@layer opensysml;", "/* print:")
 	wantReport(t, runCommand(t, exec.Command(binary, "-html-default-css", "-html-theme", "fancy")),
-		2, `no bundled theme is named "fancy"`, "default, modern, print, report")
+		2, `no bundled theme is named "fancy"`, "default, acm, ieee, modern, nasa, print, report")
 
 	wantReport(t, check(t, binary, documentModel, "-render-document", "Reports::MassReport",
 		"-doc-form", "html", "-html-theme", "fancy"), 2, `no bundled theme is named "fancy"`)
@@ -282,8 +370,11 @@ func TestRenderDocumentHTMLTheme(t *testing.T) {
 	wantReport(t, check(t, binary, documentModel, "-render-document", "Reports::MassReport", "-html-theme", "report"),
 		2, "-doc-form html")
 	wantReport(t, check(t, binary, documentModel, "-render-document", "Reports::MassReport",
-		"-doc-form", "pdf", "-o", filepath.Join(t.TempDir(), "r.pdf"), "-html-theme", "report"),
-		2, "-doc-form html")
+		"-doc-form", "pdf", "-o", filepath.Join(t.TempDir(), "r.pdf"), "-html-theme", "fancy"),
+		2, `no bundled theme is named "fancy"`)
+	wantReport(t, check(t, binary, documentModel, "-render-document", "Reports::MassReport",
+		"-doc-form", "pdf", "-o", filepath.Join(t.TempDir(), "r.pdf"), "-html-no-default-css", "-html-theme", "report"),
+		2, "ask for one or the other")
 	wantReport(t, check(t, binary, documentModel, "-html-theme", "report"),
 		2, "apply to -render-document")
 }
diff --git a/cmd/sysml/render_document_pdf_test.go b/cmd/sysml/render_document_pdf_test.go
index c9f8118abd..7760941404 100644
--- a/cmd/sysml/render_document_pdf_test.go
+++ b/cmd/sysml/render_document_pdf_test.go
@@ -8,7 +8,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/Open-MBEE/OpenSysML/internal/docpdf"
+	"github.com/Open-MBEE/OpenSysML/internal/doc/docpdf"
 )
 
 // fakePDFTool writes an executable shell script into dir and returns its path.
@@ -26,19 +26,21 @@ func fakePDFTool(t *testing.T, dir, name, script string) string {
 
 // TestRenderDocumentPDF renders the committed telescope fixture to PDF
 // through fake converter tools, checking the full CLI path: mermaid
-// pre-rendering, HTML generation, converter invocation, and the artifact.
+// pre-rendering, the HTML backend's page under the print stylesheet,
+// converter invocation, and the artifact.
 func TestRenderDocumentPDF(t *testing.T) {
 	binary := buildCLI(t)
 	dir := t.TempDir()
 	seen := filepath.Join(dir, "input-seen.html")
 	weasyprint := fakePDFTool(t, dir, "weasyprint", `cp "$1" `+seen+`
+printf '%s\n' "$@" > `+seen+`.args
 printf '%%PDF-1.7 fake' > "$2"
 `)
 	mmdc := fakePDFTool(t, dir, "mmdc", `out=""
 while [ $# -gt 0 ]; do [ "$1" = "--output" ] && out="$2"; shift; done
 printf '<svg xmlns="http://www.w3.org/2000/svg"/>' > "$out"
 `)
-	fixture := filepath.Join("..", "..", "internal", "core", "docrender", "testdata", "telescope_report.sysml")
+	fixture := filepath.Join("..", "..", "internal", "doc", "docrender", "testdata", "telescope_report.sysml")
 	out := filepath.Join(dir, "report.pdf")
 
 	cmd := exec.Command(binary, fixture, "-render-document", "Observatory::MassReport",
@@ -54,41 +56,65 @@ printf '<svg xmlns="http://www.w3.org/2000/svg"/>' > "$out"
 	if !strings.HasPrefix(string(pdf), "%PDF-") {
 		t.Errorf("artifact is no PDF: %.16q", pdf)
 	}
+	args, err := os.ReadFile(seen + ".args")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if want := "--base-url\nfile://" + filepath.ToSlash(dir) + "/\n"; !strings.HasSuffix(string(args), want) {
+		t.Errorf("converter is not handed the PDF's directory as the base URL:\n%s", args)
+	}
 	page, err := os.ReadFile(seen)
 	if err != nil {
 		t.Fatal(err)
 	}
 	for _, want := range []string{
 		"<title>Telescope Mass Report",
-		`
`, - `
`) + + wantReport(t, check(t, binary, objectDocumentModel, "-instantiate", "Garage::car", "-validate", "-render-document", "Reports::CarReport"), + 2, "decides nothing about the model") + wantReport(t, check(t, binary, objectDocumentModel, "-instantiate", "Garage::NoSuchPart", "-render-document", "Reports::CarReport"), + 2, "NoSuchPart") +} + +// TestRenderDocumentOverObjectsUnderRunBounds checks that the run bounds the +// environment sets are read before -instantiate materializes anything: a bad +// value is refused at startup, and a small one bounds the object made. +func TestRenderDocumentOverObjectsUnderRunBounds(t *testing.T) { + binary := buildCLI(t) + render := []string{"-instantiate", "Garage::car", "-render-document", "Reports::CarReport"} + + wantReport(t, checkEnv(t, binary, objectDocumentModel, []string{runtime.MaxStepsEnvVar + "=none"}, render...), + 2, "OPENSYSML_MAX_STEPS=\"none\" is not an integer") + wantReport(t, checkEnv(t, binary, objectDocumentModel, []string{runtime.MaxElementsEnvVar + "=1"}, render...), + 2, "collection element limit exceeded") + wantReport(t, checkEnv(t, binary, objectDocumentModel, []string{runtime.MaxElementsEnvVar + "=1"}, "-render-document", "Reports::CarReport"), + 0, "# Car Report") +} diff --git a/cmd/sysml/render_documents_test.go b/cmd/sysml/render_documents_test.go index df3fc67cbd..40832ea026 100644 --- a/cmd/sysml/render_documents_test.go +++ b/cmd/sysml/render_documents_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/fsutil" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // linkedModel declares two documents referencing each other's content, so the @@ -98,8 +98,8 @@ func TestRenderDocumentsFlag(t *testing.T) { // document of a set, in Markdown and in HTML. func TestRenderDocumentsDiagramForm(t *testing.T) { binary := buildCLI(t) - fixture := filepath.Join("..", "..", "internal", "core", "docrender", "testdata", "telescope_report.sysml") - golden, err := os.ReadFile(filepath.Join("..", "..", "internal", "core", "docrender", "testdata", "telescope_report.dot.golden.md")) + fixture := filepath.Join("..", "..", "internal", "doc", "docrender", "testdata", "telescope_report.sysml") + golden, err := os.ReadFile(filepath.Join("..", "..", "internal", "doc", "docrender", "testdata", "telescope_report.dot.golden.md")) if err != nil { t.Fatal(err) } @@ -430,15 +430,15 @@ func TestRestoreBackupRevivesRemovedDestination(t *testing.T) { // an existing committed file. func TestReplaceFileReplacesExistingTarget(t *testing.T) { dir := t.TempDir() - source := filepath.Join(dir, "backup") + backup := filepath.Join(dir, "backup") target := filepath.Join(dir, "Reports-Appendix.md") - if err := os.WriteFile(source, []byte("previous\n"), 0o644); err != nil { + if err := os.WriteFile(backup, []byte("previous\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(target, []byte("committed\n"), 0o644); err != nil { t.Fatal(err) } - if err := fsutil.Replace(source, target); err != nil { + if err := source.ReplaceFile(backup, target); err != nil { t.Fatal(err) } restored, err := os.ReadFile(target) @@ -448,7 +448,7 @@ func TestReplaceFileReplacesExistingTarget(t *testing.T) { if string(restored) != "previous\n" { t.Errorf("target = %q", restored) } - if _, err := os.Stat(source); !os.IsNotExist(err) { + if _, err := os.Stat(backup); !os.IsNotExist(err) { t.Errorf("the backup remains after its restore: %v", err) } } diff --git a/cmd/sysml/render_test.go b/cmd/sysml/render_test.go index 538f25a5ac..0259d74d9e 100644 --- a/cmd/sysml/render_test.go +++ b/cmd/sysml/render_test.go @@ -1,12 +1,15 @@ package main import ( + "fmt" "os" "path/filepath" + "slices" "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/view" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // renderModel declares a view stating no rendering, one exposing nothing, and a @@ -29,7 +32,7 @@ func TestRenderWritesTheArtifactOnStdout(t *testing.T) { if got.status != exitHolds { t.Fatalf("exit status = %d, want %d\n%s", got.status, exitHolds, got.output()) } - for _, want := range []string{"flowchart TD", `"Demo::Vehicle
«part def»"`} { + for _, want := range []string{"flowchart TD", `"Vehicle
«part def»"`} { if !strings.Contains(got.stdout, want) { t.Errorf("stdout is missing %q:\n%s", want, got.stdout) } @@ -98,7 +101,7 @@ func TestRenderDotForm(t *testing.T) { if got.status != exitHolds { t.Fatalf("exit status = %d, want %d\n%s", got.status, exitHolds, got.output()) } - for _, want := range []string{"// view: Demo::overview", "// layout: dot", `digraph "Demo::overview" {`, `label=<Demo::Vehicle
«part def»>`, `"n0" -> "n1" [arrowhead=none];`} { + for _, want := range []string{"// view: Demo::overview", "// layout: dot", `digraph "Demo::overview" {`, `label=<Vehicle
«part def»>`, `"n0" -> "n1" [arrowhead=none];`} { if !strings.Contains(got.stdout, want) { t.Errorf("stdout is missing %q:\n%s", want, got.stdout) } @@ -148,7 +151,7 @@ func TestRenderPlantUMLForm(t *testing.T) { if got.status != exitHolds { t.Fatalf("exit status = %d, want %d\n%s", got.status, exitHolds, got.output()) } - for _, want := range []string{"@startuml\n' Demo::overview — tree rendering\n skinparam wrapWidth 300 hide stereotype -rectangle "**Observatory::imagingChain**\n//«part»//" as n0 <> <> { +rectangle "**imagingChain**\n//«part»//" as n0 <> <> { rectangle "**camera : Camera**\n//«part»//" as n1 <> <> rectangle "**recorder : Recorder**\n//«part»//" as n2 <> <> } @@ -524,8 +588,9 @@ calc rows : SubsystemTable { Bindings are validated against the query's compiled signature at planning time: an unknown parameter, a duplicate, a missing one without a usable default, or a type or multiplicity mismatch is a typed error before anything -runs. A binding's value is an element name or a literal; the engine does not -evaluate arbitrary default expressions. +runs. A binding's value is an element name (`telescope`, or +`telescope.optics.mirror` for a nested usage, in dot notation) or a literal; +the engine does not evaluate arbitrary default expressions. ## Escaping — write content freely diff --git a/docs/manual/examples/cookbook.sysml b/docs/manual/examples/cookbook.sysml index 05240821c4..413a016a4b 100644 --- a/docs/manual/examples/cookbook.sysml +++ b/docs/manual/examples/cookbook.sysml @@ -2,15 +2,18 @@ package Cookbook { private import DocumentQueries::*; private import KerML::Root::Element; private import ScalarValues::*; + private import SI::*; // ---- The model the recipes query ---- part def Subsystem { attribute mass : Real; + assert constraint massKnown { mass > 0.0 } } part def OpticalSubsystem :> Subsystem; part def MirrorAssembly :> OpticalSubsystem { attribute :>> mass = 10.0; + assert constraint lightweight { mass < 5.0 } } metadata def Critical; @@ -52,6 +55,37 @@ package Cookbook { } } + // ---- The run the state and event recipes query ---- + + attribute def Open; + attribute def Close; + attribute def Slew { attribute azimuth : Real; } + + state def DomeControl { + attribute azimuth : Real = 0.0; + entry; then closed; + state closed; + transition closed_open first closed accept Open then open; + state open parallel { + state pointing { + entry; then parked; + state parked; + transition parked_slewing first parked accept s : Slew do assign azimuth := s.azimuth then slewing; + state slewing; + } + state shutter { + entry; then opening; + state opening; + transition opening_opened first opening accept after 1 [s] then opened; + state opened; + } + } + transition open_close first open accept Close then closed; + } + part def Dome { exhibit state control : DomeControl; } + part dome : Dome; + part spareDome : Dome; + // ---- Recipe queries ---- calc def Children :> Query { @@ -72,6 +106,13 @@ package Cookbook { Ancestors(source = leaf, maxDepth = 2) } + calc def NamedParts :> Query { + WhereType( + source = Descendants(source = Named(qualifiedName = ("Cookbook::telescope", "Cookbook::Traceability"))), + type = "PartUsage" + ) + } + calc def Connections :> Query { in root : Element; WhereType( @@ -188,4 +229,232 @@ package Cookbook { maxDepth = 2 ) } + + calc def HeldParts :> Query { + in root : Element; + Project( + source = WhereType(source = Descendants(source = root, maxDepth = 2), type = "Subsystem"), + properties = ("qualifiedName", "mass") + ) + } + + calc def HeldSubsystems :> Query { + OrderBy( + source = Project(source = Objects(type = "Subsystem"), properties = ("qualifiedName", "mass")), + property = "mass", + direction = "descending", + missing = "last", + multiple = "first" + ) + } + + calc def Checks :> Query { + in root : Element; + Project(source = Verdicts(source = root), properties = ("path", "name", "verdict", "reason")) + } + + calc def Violated :> Query { + in root : Element; + WhereFeature(source = Verdicts(source = root), 'feature' = "verdict", operator = "=", value = "violated") + } + + // ---- A requirement nothing satisfies, and the coverage recipes ---- + + requirement def PointingRequirement { + doc /* The telescope holds a target within the stated accuracy. */ + } + requirement <'REQ-2'> pointingRequirement : PointingRequirement { + doc /* The telescope points at a target to within 2 arcseconds. */ + requirement <'REQ-2.1'> slewRequirement { + doc /* The mount reaches a new target within 60 seconds. */ + } + requirement <'REQ-2.2'> trackingRequirement { + doc /* The mount tracks a target for 30 minutes without drift. */ + } + } + verification def PointingTest; + verification pointingVerification : PointingTest { + objective { + verify pointingRequirement; + } + } + + calc def Requirements :> Query { + in root : Element; + Except( + source = Union( + source = WhereType(source = Descendants(source = root, maxDepth = 10), type = "RequirementDefinition"), + other = WhereType(source = Descendants(source = root, maxDepth = 10), type = "RequirementUsage") + ), + exclude = WhereType(source = Descendants(source = root, maxDepth = 10), type = "SatisfyRequirementUsage") + ) + } + + calc def UnsatisfiedRequirements :> Query { + in root : Element; + WhereRelated( + source = Requirements(root = root), + relationshipKind = "satisfaction", + direction = "incoming", + maxDepth = 1, + exists = false + ) + } + + calc def UnverifiedRequirements :> Query { + in root : Element; + WhereRelated( + source = Requirements(root = root), + relationshipKind = "verification", + direction = "incoming", + maxDepth = 1, + exists = false + ) + } + + calc def UncoveredRequirements :> Query { + in root : Element; + Union( + source = UnsatisfiedRequirements(root = root), + other = UnverifiedRequirements(root = root) + ) + } + + calc def VerifiedButUnsatisfied :> Query { + in root : Element; + Except( + source = UnsatisfiedRequirements(root = root), + exclude = UnverifiedRequirements(root = root) + ) + } + + calc def RequirementTree :> Query { + in root : Element; + Project( + source = OrderBy( + source = Requirements(root = root), + property = "qualifiedName", + direction = "ascending", + missing = "last", + multiple = "error" + ), + properties = ("shortName", "name", "documentation") + ) + } + + // ---- Requirement derivation and refinement ---- + + requirement mirrorMassRequirement; + requirement segmentMassRequirement; + requirement instrumentMassRequirement; + connection deriveMirrorMass : RequirementDerivation::Derivation + connect massRequirement to mirrorMassRequirement; + #RequirementDerivation::derivation connection deriveInstrumentMass + connect massRequirement to instrumentMassRequirement; + #RequirementDerivation::derivation connection deriveSegmentMass + connect mirrorMassRequirement to segmentMassRequirement; + #ModelingMetadata::refinement dependency mirrorRefinesMass + from MirrorAssembly to mirrorMassRequirement; + + calc def DerivedFrom :> Query { + in req : Element; + RelatedElements( + source = req, + relationshipKind = "derivation", + direction = "outgoing", + maxDepth = 2 + ) + } + + calc def RefinedBy :> Query { + in req : Element; + RelatedElements( + source = req, + relationshipKind = "refinement", + direction = "incoming", + maxDepth = 1 + ) + } + + calc def DomeStates :> Query { + in root : Element; + Project(source = States(source = root), properties = ("machine", "statePath", "region", "enclosing")) + } + + calc def Opened :> Query { + Project(source = InState(name = "open"), properties = ("qualifiedName")) + } + + calc def Accepted :> Query { + in root : Element; + Project( + source = Events(source = root, kind = "accept", since = 1 [s], before = 2.5 [s]), + properties = ("time", "event", "payload") + ) + } + + // ---- Traceability: requirements, what satisfies them, what verifies them ---- + + package Traceability { + requirement <'TR-1'> pointingRequirement; + requirement <'TR-2'> thermalRequirement; + requirement <'TR-3'> downlinkRequirement; + + part gimbal; + part radiator; + part transmitter; + part spacecraft { + satisfy pointingRequirement by gimbal; + satisfy thermalRequirement by radiator; + satisfy downlinkRequirement by transmitter; + } + + verification pointingTest : MassTest { + objective { + verify pointingRequirement; + } + } + verification pointingAnalysis : MassTest { + objective { + verify pointingRequirement; + } + } + verification thermalTest : MassTest { + objective { + verify thermalRequirement; + } + } + } + + calc def TraceMatrix :> Query { + in root : Element; + Project( + source = WhereType( + source = Descendants(source = root, maxDepth = 1), + type = "RequirementUsage" + ), + properties = ("shortName", "name"), + columns = ( + RelatedColumn(name = "satisfiedBy", relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1), + RelatedColumn(name = "verifiedBy", relationshipKind = "verification", direction = "incoming", maxDepth = 1), + RelatedColumn( + name = "verifications", + relationshipKind = "verification", + direction = "incoming", + maxDepth = 1, + aggregate = "count" + ) + ) + ) + } + + calc def Unverified :> Query { + in root : Element; + WhereFeature( + source = TraceMatrix(root = root), + 'feature' = "verifications", + operator = "=", + value = "0" + ) + } } diff --git a/docs/manual/examples/observatory.md b/docs/manual/examples/observatory.md index 10304d9793..e5610cfee3 100644 --- a/docs/manual/examples/observatory.md +++ b/docs/manual/examples/observatory.md @@ -8,7 +8,6 @@ This report is *generated* from the model by `sysml -render-document` [(OpenSysM ## Subsystem Masses - *All subsystems by mass* | name | mass | @@ -17,7 +16,6 @@ This report is *generated* from the model by `sysml -render-document` [(OpenSysM | optics | 8.5 | | segmentControl | 20 | - *Subsystems grouped by zone* **zone: support** @@ -42,14 +40,12 @@ Subsystems at or above 10 kg: ## Mass Requirement - *Parts satisfying the mass requirement* | name | qualifiedName | | --- | --- | | telescope | Observatory::telescope | - *Verifications of the mass requirement* | qualifiedName | @@ -58,7 +54,6 @@ Subsystems at or above 10 kg: ## Diagrams - *Imaging chain interconnection* ```mermaid @@ -70,7 +65,7 @@ config: --- %% Observatory::interconnectView — interconnection rendering (render asInterconnectionDiagram) flowchart LR - subgraph n0 ["Observatory::imagingChain
«part»"] + subgraph n0 ["imagingChain
«part»"] direction LR n1["camera : Camera
«part»"] n2["recorder : Recorder
«part»"] @@ -78,13 +73,12 @@ flowchart LR n1 ---|"link"| n2 ``` - *Telescope part tree, left to right* ```mermaid %% tree rendering (the diagram states kind "tree") flowchart LR - n0["Observatory::telescope
«part»"] + n0["telescope
«part»"] n1["optics : Subsystem
«part»"] n2["mass
«attribute»"] n1 --- n2 diff --git a/docs/manual/examples/requirements.md b/docs/manual/examples/requirements.md index df4e9c7fe0..2ce8d8dbd8 100644 --- a/docs/manual/examples/requirements.md +++ b/docs/manual/examples/requirements.md @@ -1,8 +1,7 @@ # Mission Requirements -Each requirement by identifier, first as a table and then as prose. +Each requirement by identifier, first as a table and then as prose; then the specification's requirement hierarchy. - *Requirements* | shortName | name | documentation | @@ -13,3 +12,12 @@ Each requirement by identifier, first as a table and then as prose. **HLR-R001** — The mission shall safely return all three crew members to Earth. **HLR-R002** — The mission shall achieve a soft landing on the lunar surface. + +*Requirement hierarchy* + +| shortName | name | documentation | +| --- | --- | --- | +| R1 | r1 | Applies to every phase of the mission. | +| R2 | r2 | Applies to the powered descent. | +| R2.1 | descentRate | The vertical velocity at touchdown shall not exceed 2 m/s. | +| R2.2 | tiltAngle | The lander shall come to rest within 12 degrees of vertical. | diff --git a/docs/manual/examples/requirements.sysml b/docs/manual/examples/requirements.sysml index 65a23e4c11..b9533d6602 100644 --- a/docs/manual/examples/requirements.sysml +++ b/docs/manual/examples/requirements.sysml @@ -1,6 +1,8 @@ // Requirement identifiers and doc text as document prose: the model gives each // requirement a short name and a doc comment; the Reqs query projects both, // and the report renders them once as a table and once as definitions. +// The Tree query lists every requirement definition and usage under a root in +// hierarchy order, which the report renders as a third table. package Requirements { private import DocumentQueries::*; private import KerML::Root::Element; @@ -14,8 +16,18 @@ package Requirements { * on the lunar surface. */ } part spec { - requirement r1 : CrewSafety; - requirement r2 : SoftLanding; + requirement <'R1'> r1 : CrewSafety { + doc /* Applies to every phase of the mission. */ + } + requirement <'R2'> r2 : SoftLanding { + doc /* Applies to the powered descent. */ + requirement <'R2.1'> descentRate { + doc /* The vertical velocity at touchdown shall not exceed 2 m/s. */ + } + requirement <'R2.2'> tiltAngle { + doc /* The lander shall come to rest within 12 degrees of vertical. */ + } + } } calc def Reqs :> Query { @@ -29,11 +41,28 @@ package Requirements { ) } + calc def Tree :> Query { + in root : Element; + Project( + source = OrderBy( + source = Union( + source = WhereType(source = Descendants(source = root, maxDepth = 3), type = "RequirementDefinition"), + other = WhereType(source = Descendants(source = root, maxDepth = 3), type = "RequirementUsage") + ), + property = "qualifiedName", + direction = "ascending", + missing = "last", + multiple = "error" + ), + properties = ("shortName", "name", "documentation") + ) + } + part def RequirementsReport :> Document { attribute redefines title = "Mission Requirements"; part intro : Paragraph { - attribute redefines text = "Each requirement by identifier, first as a table and then as prose."; + attribute redefines text = "Each requirement by identifier, first as a table and then as prose; then the specification's requirement hierarchy."; } part table : Table { @@ -50,5 +79,12 @@ package Requirements { in root = spec; } } + + part tree : Table { + attribute redefines caption = "Requirement hierarchy"; + calc rows : Tree { + in root = spec; + } + } } } diff --git a/docs/manual/examples/trace-1-basic.md b/docs/manual/examples/trace-1-basic.md new file mode 100644 index 0000000000..63a77d42b6 --- /dev/null +++ b/docs/manual/examples/trace-1-basic.md @@ -0,0 +1,15 @@ +# Rover Requirement Satisfaction + +Which part of the rover satisfies each requirement. + +*Requirements and their satisfiers* + +| shortName | documentation | satisfiedBy | satisfied | +| --- | --- | --- | --- | +| RV-1 | The rover shall travel at least 20 km on one charge. | RoverBasic::rover::battery | true | +| RV-2 | The rover shall carry a 15 kg science payload. | RoverBasic::rover::chassis | true | +| RV-3 | The rover shall accept commands from the lander at 2 kbps. | | false | + +Requirements no part satisfies: + +- RV-3 diff --git a/docs/manual/examples/trace-1-basic.sysml b/docs/manual/examples/trace-1-basic.sysml new file mode 100644 index 0000000000..6bd0fb9ce8 --- /dev/null +++ b/docs/manual/examples/trace-1-basic.sysml @@ -0,0 +1,101 @@ +// The simplest traceability report: three flat requirements, three parts that +// satisfy two of them, one table saying which part covers each requirement +// and a list of the requirements nothing covers. No verification, no +// hierarchy, no derivation — the shape every larger report in this series +// builds on. +package RoverBasic { + private import DocumentQueries::*; + private import KerML::Root::Element; + + // ---- Requirements ---- + + part specification { + requirement <'RV-1'> range { + doc /* The rover shall travel at least 20 km on one charge. */ + } + requirement <'RV-2'> payload { + doc /* The rover shall carry a 15 kg science payload. */ + } + requirement <'RV-3'> uplink { + doc /* The rover shall accept commands from the lander at 2 kbps. */ + } + } + + // ---- Design ---- + + part def Battery; + part def Chassis; + part def Radio; + + part rover { + part battery : Battery; + part chassis : Chassis; + part radio : Radio; + + satisfy specification.range by battery; + satisfy specification.payload by chassis; + } + + // ---- Queries ---- + + // Every requirement declared directly under the root. + calc def Requirements :> Query { + in root : Element; + WhereType(source = OwnedElements(source = root), type = "RequirementUsage") + } + + // Each requirement with the parts that satisfy it and whether any does. + calc def SatisfactionMatrix :> Query { + in root : Element; + Project( + source = Requirements(root = root), + properties = ("shortName", "documentation"), + columns = ( + RelatedColumn(name = "satisfiedBy", relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1), + RelatedColumn(name = "satisfied", relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1, aggregate = "any") + ) + ) + } + + // The requirements no part satisfies. + calc def Unsatisfied :> Query { + in root : Element; + Project( + source = WhereRelated( + source = Requirements(root = root), + relationshipKind = "satisfaction", + direction = "incoming", + maxDepth = 1, + exists = false + ), + properties = ("shortName") + ) + } + + // ---- Document ---- + + part def BasicReport :> Document { + attribute redefines title = "Rover Requirement Satisfaction"; + + part intro : Paragraph { + attribute redefines text = "Which part of the rover satisfies each requirement."; + } + + part matrix : Table { + attribute redefines caption = "Requirements and their satisfiers"; + calc rows : SatisfactionMatrix { + in root = specification; + } + } + + part gaps : Paragraph { + attribute redefines text = "Requirements no part satisfies:"; + } + + part gapList : List { + calc items : Unsatisfied { + in root = specification; + } + } + } +} diff --git a/docs/manual/examples/trace-2-hierarchy.md b/docs/manual/examples/trace-2-hierarchy.md new file mode 100644 index 0000000000..bde031b628 --- /dev/null +++ b/docs/manual/examples/trace-2-hierarchy.md @@ -0,0 +1,93 @@ +# Lander Requirement Traceability + +The lander specification as a tree, what satisfies and verifies each requirement, where coverage is missing, and which checks fail. + +## Requirement hierarchy + +**L-1** — The lander dry mass shall not exceed 1800 kg. + +**L-2** — The lander shall descend under powered control to touchdown. + +**L-2.1** — The descent engine shall deliver at least 3000 N. + +**L-2.2** — The landing radar shall range the surface from 5000 m. + +**L-3** — The lander shall survive touchdown at 2 m/s. + +**L-3.1** — Each landing leg shall absorb 0.3 m of stroke. + +**L-3.2** — The lander shall not tip over on a 15 degree slope. + +**L-4** — The lander shall transmit a recovery beacon after landing. + +## Traceability matrix + +*Every requirement with its satisfiers and verifiers* + +| shortName | name | satisfiedBy | verifiedBy | +| --- | --- | --- | --- | +| L-1 | mass | LanderHierarchy::lander | LanderHierarchy::weighLander | +| L-2 | descent | | | +| L-2.1 | thrust | LanderHierarchy::lander::propulsion::engine | LanderHierarchy::hotFire | +| L-2.2 | altimetry | LanderHierarchy::lander::avionics::radar | LanderHierarchy::radarRangeTest | +| L-3 | touchdown | | | +| L-3.1 | legStroke | LanderHierarchy::lander::gear::leg | LanderHierarchy::dropTest | +| L-3.2 | tipOver | | | +| L-4 | beacon | LanderHierarchy::lander::avionics::transponder | | + +## Coverage gaps + +*Requirements no part satisfies* + +| shortName | name | +| --- | --- | +| L-2 | descent | +| L-3 | touchdown | +| L-3.2 | tipOver | + +*Requirements no case verifies* + +| shortName | name | +| --- | --- | +| L-2 | descent | +| L-3 | touchdown | +| L-3.2 | tipOver | +| L-4 | beacon | + +*Requirements with a gap of either kind* + +| shortName | name | +| --- | --- | +| L-2 | descent | +| L-3 | touchdown | +| L-3.2 | tipOver | +| L-4 | beacon | + +*Requirements satisfied but never verified* + +| shortName | name | +| --- | --- | +| L-4 | beacon | + +## Verdicts + +*Every check of the lander against its requirements* + +| kind | name | path | verdict | +| --- | --- | --- | --- | +| satisfaction | | LanderHierarchy::lander | holds | +| verification | weighLander | LanderHierarchy::lander | holds | +| satisfaction | | LanderHierarchy::lander.propulsion.engine | violated | +| verification | hotFire | LanderHierarchy::lander.propulsion.engine | violated | +| satisfaction | | LanderHierarchy::lander.gear.leg | holds | +| verification | dropTest | LanderHierarchy::lander.gear.leg | holds | +| satisfaction | | LanderHierarchy::lander.avionics.radar | holds | +| verification | radarRangeTest | LanderHierarchy::lander.avionics.radar | holds | +| satisfaction | | LanderHierarchy::lander.avionics.transponder | undecided | + +*Checks that came out false* + +| kind | name | path | condition | +| --- | --- | --- | --- | +| satisfaction | | LanderHierarchy::lander.propulsion.engine | engine.thrust >= 3000.0 | +| verification | hotFire | LanderHierarchy::lander.propulsion.engine | | diff --git a/docs/manual/examples/trace-2-hierarchy.sysml b/docs/manual/examples/trace-2-hierarchy.sysml new file mode 100644 index 0000000000..85453e10a4 --- /dev/null +++ b/docs/manual/examples/trace-2-hierarchy.sysml @@ -0,0 +1,305 @@ +// Traceability over a requirement hierarchy: a lander specification whose +// requirements nest two levels deep, parts nested inside parts that satisfy +// the leaves, verification cases that pass or fail against the design's +// values, and a report with the tree, a matrix over the whole tree, the +// coverage gaps and the verdicts that came out false. +package LanderHierarchy { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + // ---- Design vocabulary ---- + + part def Lander { + attribute dryMass : Real; + } + part def DescentEngine { + attribute thrust : Real; + } + part def LandingLeg { + attribute strokeLength : Real; + } + part def Radar { + attribute maxRange : Real; + } + part def Transponder; + + // ---- Requirement definitions with checkable conditions ---- + + requirement def MassLimit { + subject lander : Lander; + require constraint { lander.dryMass <= 1800.0 } + } + requirement def ThrustMargin { + subject engine : DescentEngine; + require constraint { engine.thrust >= 3000.0 } + } + requirement def StrokeLength { + subject leg : LandingLeg; + require constraint { leg.strokeLength >= 0.3 } + } + requirement def RadarRange { + subject radar : Radar; + require constraint { radar.maxRange >= 5000.0 } + } + + // ---- The specification: a tree of requirements ---- + + part specification { + requirement <'L-1'> mass : MassLimit { + doc /* The lander dry mass shall not exceed 1800 kg. */ + } + requirement <'L-2'> descent { + doc /* The lander shall descend under powered control to touchdown. */ + requirement <'L-2.1'> thrust : ThrustMargin { + doc /* The descent engine shall deliver at least 3000 N. */ + } + requirement <'L-2.2'> altimetry : RadarRange { + doc /* The landing radar shall range the surface from 5000 m. */ + } + } + requirement <'L-3'> touchdown { + doc /* The lander shall survive touchdown at 2 m/s. */ + requirement <'L-3.1'> legStroke : StrokeLength { + doc /* Each landing leg shall absorb 0.3 m of stroke. */ + } + requirement <'L-3.2'> tipOver { + doc /* The lander shall not tip over on a 15 degree slope. */ + } + } + requirement <'L-4'> beacon { + doc /* The lander shall transmit a recovery beacon after landing. */ + } + } + + // ---- The design: parts nested inside parts ---- + + part lander : Lander { + attribute :>> dryMass = 1750.0; + + part propulsion { + part engine : DescentEngine { + attribute :>> thrust = 2800.0; + } + satisfy specification.descent.thrust by engine; + } + + part gear { + part leg : LandingLeg { + attribute :>> strokeLength = 0.35; + } + satisfy specification.touchdown.legStroke by leg; + } + + part avionics { + part radar : Radar { + attribute :>> maxRange = 6000.0; + } + part transponder : Transponder; + satisfy specification.descent.altimetry by radar; + satisfy specification.beacon by transponder; + } + } + satisfy specification.mass by lander; + + // ---- Verification cases: two pass, one fails, one requirement has none ---- + + verification def WeighLander { + subject vehicle : Lander; + VerificationCases::PassIf(vehicle.dryMass <= 1800.0) + } + verification weighLander : WeighLander { + subject vehicle = lander; + objective { + verify specification.mass; + } + } + + verification def FireEngine { + subject engine : DescentEngine; + VerificationCases::PassIf(engine.thrust >= 3000.0) + } + verification hotFire : FireEngine { + subject engine = lander.propulsion.engine; + objective { + verify specification.descent.thrust; + } + } + + verification def DropLeg { + subject leg : LandingLeg; + VerificationCases::PassIf(leg.strokeLength >= 0.3) + } + verification dropTest : DropLeg { + subject leg = lander.gear.leg; + objective { + verify specification.touchdown.legStroke; + } + } + + verification def RangeRadar { + subject radar : Radar; + VerificationCases::PassIf(radar.maxRange >= 5000.0) + } + verification radarRangeTest : RangeRadar { + subject radar = lander.avionics.radar; + objective { + verify specification.descent.altimetry; + } + } + + // ---- Queries ---- + + // Every requirement under the root, at any depth. Descendants visits level + // by level; sorting by the ids, which extend their parent's, restores the tree. + calc def Requirements :> Query { + in root : Element; + OrderBy( + source = WhereType(source = Descendants(source = root, maxDepth = 3), type = "RequirementUsage"), + property = "shortName", + direction = "ascending", + missing = "last", + multiple = "error" + ) + } + + calc def Tree :> Query { + in root : Element; + Project(source = Requirements(root = root), properties = ("shortName", "name", "documentation")) + } + + calc def Matrix :> Query { + in root : Element; + Project( + source = Requirements(root = root), + properties = ("shortName", "name"), + columns = ( + RelatedColumn(name = "satisfiedBy", relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1), + RelatedColumn(name = "verifiedBy", relationshipKind = "verification", direction = "incoming", maxDepth = 1) + ) + ) + } + + calc def Unsatisfied :> Query { + in root : Element; + Project( + source = WhereRelated( + source = Requirements(root = root), + relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1, exists = false + ), + properties = ("shortName", "name") + ) + } + + calc def Unverified :> Query { + in root : Element; + Project( + source = WhereRelated( + source = Requirements(root = root), + relationshipKind = "verification", direction = "incoming", maxDepth = 1, exists = false + ), + properties = ("shortName", "name") + ) + } + + // Requirements with a gap of either kind, each listed once. + calc def AnyGap :> Query { + in root : Element; + Union(source = Unsatisfied(root = root), other = Unverified(root = root)) + } + + // Requirements a part satisfies that no case verifies: the design claims + // them, but nothing checks the claim. + calc def SatisfiedButUnverified :> Query { + in root : Element; + Except(source = Unverified(root = root), exclude = Unsatisfied(root = root)) + } + + calc def Failures :> Query { + in root : Element; + Project( + source = WhereFeature(source = Verdicts(source = root), 'feature' = "verdict", operator = "=", value = "violated"), + properties = ("kind", "name", "path", "condition") + ) + } + + calc def AllVerdicts :> Query { + in root : Element; + Project(source = Verdicts(source = root), properties = ("kind", "name", "path", "verdict")) + } + + // ---- Document ---- + + part def HierarchyReport :> Document { + attribute redefines title = "Lander Requirement Traceability"; + + part intro : Paragraph { + attribute redefines text = "The lander specification as a tree, what satisfies and verifies each requirement, where coverage is missing, and which checks fail."; + } + + part tree : Section { + attribute redefines title = "Requirement hierarchy"; + part text : Definitions { + attribute redefines term = "shortName"; + attribute redefines description = "documentation"; + calc rows : Tree { + in root = specification; + } + } + } + + part matrix : Section { + attribute redefines title = "Traceability matrix"; + part table : Table { + attribute redefines caption = "Every requirement with its satisfiers and verifiers"; + calc rows : Matrix { + in root = specification; + } + } + } + + part coverage : Section { + attribute redefines title = "Coverage gaps"; + part unsatisfiedTable : Table { + attribute redefines caption = "Requirements no part satisfies"; + calc rows : Unsatisfied { + in root = specification; + } + } + part unverifiedTable : Table { + attribute redefines caption = "Requirements no case verifies"; + calc rows : Unverified { + in root = specification; + } + } + part uncoveredTable : Table { + attribute redefines caption = "Requirements with a gap of either kind"; + calc rows : AnyGap { + in root = specification; + } + } + part uncheckedTable : Table { + attribute redefines caption = "Requirements satisfied but never verified"; + calc rows : SatisfiedButUnverified { + in root = specification; + } + } + } + + part verdicts : Section { + attribute redefines title = "Verdicts"; + part all : Table { + attribute redefines caption = "Every check of the lander against its requirements"; + calc rows : AllVerdicts { + in root = lander; + } + } + part failed : Table { + attribute redefines caption = "Checks that came out false"; + calc rows : Failures { + in root = lander; + } + } + } + } +} diff --git a/docs/manual/examples/trace-3-derivation.md b/docs/manual/examples/trace-3-derivation.md new file mode 100644 index 0000000000..926164432a --- /dev/null +++ b/docs/manual/examples/trace-3-derivation.md @@ -0,0 +1,59 @@ +# Rover Requirement Derivation + +How the mission requirements flow down to the subsystems, what refines them, and where the flow-down ends without a part to satisfy it. + +## Lineage + +*Every requirement with its parent, children, descendant count, refinements and satisfiers* + +| shortName | name | derivedFrom | derives | descendants | refinedBy | satisfiedBy | +| --- | --- | --- | --- | --- | --- | --- | +| M-1 | range | | RangeDerivation::specification::system::dailyRange, RangeDerivation::specification::system::energyBudget | 4 | | | +| M-2 | lifetime | | RangeDerivation::specification::system::nightSurvival | 2 | | | +| S-1 | dailyRange | RangeDerivation::specification::mission::range | | 0 | | | +| S-2 | energyBudget | RangeDerivation::specification::mission::range | RangeDerivation::specification::subsystem::batteryCapacity, RangeDerivation::specification::subsystem::driveEfficiency | 2 | RangeDerivation::EnergyBudgetAnalysis | | +| S-3 | nightSurvival | RangeDerivation::specification::mission::lifetime | RangeDerivation::specification::subsystem::batteryHeater | 1 | | | +| B-1 | batteryCapacity | RangeDerivation::specification::system::energyBudget | | 0 | | RangeDerivation::rover::battery | +| B-2 | batteryHeater | RangeDerivation::specification::system::nightSurvival | | 0 | RangeDerivation::HeaterLoop | | +| D-1 | driveEfficiency | RangeDerivation::specification::system::energyBudget | | 0 | | RangeDerivation::rover::drive | + +## Following a chain + +*Everything derived from M-1, at any depth* + +| shortName | name | documentation | +| --- | --- | --- | +| S-1 | dailyRange | The rover shall drive 500 m per sol. | +| S-2 | energyBudget | The rover shall store 1.2 kWh for each sol's drive. | +| B-1 | batteryCapacity | The battery shall hold 1.5 kWh. | +| D-1 | driveEfficiency | The drive shall convert 85 percent of electrical energy to motion. | + +*Derived directly from M-1* + +| shortName | name | documentation | +| --- | --- | --- | +| S-1 | dailyRange | The rover shall drive 500 m per sol. | +| S-2 | energyBudget | The rover shall store 1.2 kWh for each sol's drive. | + +*The chain above B-1* + +| shortName | name | documentation | +| --- | --- | --- | +| S-2 | energyBudget | The rover shall store 1.2 kWh for each sol's drive. | +| M-1 | range | The rover shall traverse 20 km over the mission. | + +## Chain ends + +*Requirements at the top of a chain* + +| shortName | name | documentation | +| --- | --- | --- | +| M-1 | range | The rover shall traverse 20 km over the mission. | +| M-2 | lifetime | The rover shall operate for 90 sols. | + +*Leaves no part satisfies* + +| shortName | name | documentation | +| --- | --- | --- | +| S-1 | dailyRange | The rover shall drive 500 m per sol. | +| B-2 | batteryHeater | The battery shall be kept above -20 C. | diff --git a/docs/manual/examples/trace-3-derivation.sysml b/docs/manual/examples/trace-3-derivation.sysml new file mode 100644 index 0000000000..36ef57f11d --- /dev/null +++ b/docs/manual/examples/trace-3-derivation.sysml @@ -0,0 +1,225 @@ +// Traceability along derivation and refinement: mission requirements derive +// system requirements, which derive subsystem requirements, three levels of +// a specification tree apart. Design elements refine the leaves, parts satisfy +// some of them, and the report follows the chains in both directions, at one +// hop and to their ends. +package RangeDerivation { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + // ---- Design vocabulary ---- + + part def Battery { + attribute capacity : Real; + } + part def DriveMotor { + attribute efficiency : Real; + } + part def HeaterLoop; + analysis def EnergyBudgetAnalysis; + + // ---- The specification: three levels of requirements ---- + + part specification { + part mission { + requirement <'M-1'> range { + doc /* The rover shall traverse 20 km over the mission. */ + } + requirement <'M-2'> lifetime { + doc /* The rover shall operate for 90 sols. */ + } + } + part system { + requirement <'S-1'> dailyRange { + doc /* The rover shall drive 500 m per sol. */ + } + requirement <'S-2'> energyBudget { + doc /* The rover shall store 1.2 kWh for each sol's drive. */ + } + requirement <'S-3'> nightSurvival { + doc /* The rover shall survive nights at -90 C. */ + } + } + part subsystem { + requirement <'B-1'> batteryCapacity { + doc /* The battery shall hold 1.5 kWh. */ + } + requirement <'B-2'> batteryHeater { + doc /* The battery shall be kept above -20 C. */ + } + requirement <'D-1'> driveEfficiency { + doc /* The drive shall convert 85 percent of electrical energy to motion. */ + } + } + } + + // ---- Derivation: each system requirement from a mission one, each + // subsystem requirement from a system one, in either notation ---- + + #RequirementDerivation::derivation connection deriveDailyRange + connect specification.mission.range to specification.system.dailyRange; + #RequirementDerivation::derivation connection deriveEnergyBudget + connect specification.mission.range to specification.system.energyBudget; + #RequirementDerivation::derivation connection deriveNightSurvival + connect specification.mission.lifetime to specification.system.nightSurvival; + connection deriveBatteryCapacity : RequirementDerivation::Derivation + connect specification.system.energyBudget to specification.subsystem.batteryCapacity; + connection deriveDriveEfficiency : RequirementDerivation::Derivation + connect specification.system.energyBudget to specification.subsystem.driveEfficiency; + connection deriveBatteryHeater : RequirementDerivation::Derivation + connect specification.system.nightSurvival to specification.subsystem.batteryHeater; + + // ---- Refinement: an analysis sharpens the energy budget, a design + // element makes the heater requirement concrete ---- + + #ModelingMetadata::refinement dependency analysisRefinesBudget + from EnergyBudgetAnalysis to specification::system::energyBudget; + #ModelingMetadata::refinement dependency heaterRefinesRequirement + from HeaterLoop to specification::subsystem::batteryHeater; + + // ---- The design: two leaves satisfied, two not ---- + + part rover { + part battery : Battery { + attribute :>> capacity = 1.5; + } + part drive : DriveMotor { + attribute :>> efficiency = 0.87; + } + part heater : HeaterLoop; + satisfy specification.subsystem.batteryCapacity by battery; + satisfy specification.subsystem.driveEfficiency by drive; + } + + // ---- Queries ---- + + // Descendants visits level by level, so the requirements come out + // mission first, then system, then subsystem, each in declaration order. + calc def Requirements :> Query { + in root : Element; + WhereType(source = Descendants(source = root, maxDepth = 2), type = "RequirementUsage") + } + + calc def Identified :> Query { + in rows : Element[0..*] ordered; + Project(source = rows, properties = ("shortName", "name", "documentation")) + } + + calc def DirectlyDerived :> Query { + in req : Element; + Identified(rows = RelatedElements(source = req, relationshipKind = "derivation", direction = "outgoing", maxDepth = 1)) + } + + // Everything that ultimately derives from req, however many hops away. + calc def AllDerived :> Query { + in req : Element; + Identified(rows = RelatedElements(source = req, relationshipKind = "derivation", direction = "outgoing", maxDepth = 3)) + } + + // The chain back from req to the mission requirement it descends from. + calc def Origins :> Query { + in req : Element; + Identified(rows = RelatedElements(source = req, relationshipKind = "derivation", direction = "incoming", maxDepth = 3)) + } + + calc def Lineage :> Query { + in root : Element; + Project( + source = Requirements(root = root), + properties = ("shortName", "name"), + columns = ( + RelatedColumn(name = "derivedFrom", relationshipKind = "derivation", direction = "incoming", maxDepth = 1), + RelatedColumn(name = "derives", relationshipKind = "derivation", direction = "outgoing", maxDepth = 1), + RelatedColumn(name = "descendants", relationshipKind = "derivation", direction = "outgoing", maxDepth = 3, aggregate = "count"), + RelatedColumn(name = "refinedBy", relationshipKind = "refinement", direction = "incoming", maxDepth = 1), + RelatedColumn(name = "satisfiedBy", relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1) + ) + ) + } + + // Requirements nothing derives: the top of every chain. + calc def Originals :> Query { + in root : Element; + Identified(rows = WhereRelated( + source = Requirements(root = root), + relationshipKind = "derivation", direction = "incoming", maxDepth = 1, exists = false + )) + } + + // Requirements deriving nothing further: the leaves a design must satisfy. + calc def Leaves :> Query { + in root : Element; + WhereRelated( + source = Requirements(root = root), + relationshipKind = "derivation", direction = "outgoing", maxDepth = 1, exists = false + ) + } + + // Leaves no part satisfies: where the chain of derivation ends in nothing. + calc def UnsatisfiedLeaves :> Query { + in root : Element; + Identified(rows = WhereRelated( + source = Leaves(root = root), + relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1, exists = false + )) + } + + // ---- Document ---- + + part def DerivationReport :> Document { + attribute redefines title = "Rover Requirement Derivation"; + + part intro : Paragraph { + attribute redefines text = "How the mission requirements flow down to the subsystems, what refines them, and where the flow-down ends without a part to satisfy it."; + } + + part lineage : Section { + attribute redefines title = "Lineage"; + part table : Table { + attribute redefines caption = "Every requirement with its parent, children, descendant count, refinements and satisfiers"; + calc rows : Lineage { + in root = specification; + } + } + } + + part chains : Section { + attribute redefines title = "Following a chain"; + part down : Table { + attribute redefines caption = "Everything derived from M-1, at any depth"; + calc rows : AllDerived { + in req = specification.mission.range; + } + } + part direct : Table { + attribute redefines caption = "Derived directly from M-1"; + calc rows : DirectlyDerived { + in req = specification.mission.range; + } + } + part up : Table { + attribute redefines caption = "The chain above B-1"; + calc rows : Origins { + in req = specification.subsystem.batteryCapacity; + } + } + } + + part ends : Section { + attribute redefines title = "Chain ends"; + part tops : Table { + attribute redefines caption = "Requirements at the top of a chain"; + calc rows : Originals { + in root = specification; + } + } + part gaps : Table { + attribute redefines caption = "Leaves no part satisfies"; + calc rows : UnsatisfiedLeaves { + in root = specification; + } + } + } + } +} diff --git a/docs/manual/examples/trace-4-program.md b/docs/manual/examples/trace-4-program.md new file mode 100644 index 0000000000..a80adc54b5 --- /dev/null +++ b/docs/manual/examples/trace-4-program.md @@ -0,0 +1,100 @@ +# Orbiter Program Requirements Traceability + +Every requirement of the program in one matrix, grouped by the team that owns it, with its lineage, refinements, satisfiers and verification count; then the gaps that remain and the checks that fail. + +## Traceability matrix + +*Requirements by owning team* + +**team: Comms** + +| team | shortName | name | priority | derivedFrom | descendants | refinedBy | satisfiedBy | verifications | satisfied | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Comms | COM-1 | linkMargin | critical | ProgramRequirements::specification::system::downlink | 0 | | CommsDesign::commsSubsystem::transmitter | 1 | true | +| Comms | COM-2 | dataRate | critical | ProgramRequirements::specification::system::downlink | 0 | | CommsDesign::commsSubsystem::transmitter | 1 | true | + +**team: Power** + +| team | shortName | name | priority | derivedFrom | descendants | refinedBy | satisfiedBy | verifications | satisfied | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Power | PWR-1 | arrayOutput | critical | ProgramRequirements::specification::system::power | 1 | | PowerDesign::powerSubsystem::array | 1 | true | +| Power | PWR-2 | batteryDepth | high | ProgramRequirements::specification::system::power | 0 | | PowerDesign::powerSubsystem::battery | 2 | true | +| Power | PWR-3 | cellDegradation | high | ProgramRequirements::specification::subsystem::arrayOutput | 0 | PowerDesign::ArrayDegradationAnalysis | PowerDesign::powerSubsystem::array | 0 | true | + +**team: Program** + +| team | shortName | name | priority | derivedFrom | descendants | refinedBy | satisfiedBy | verifications | satisfied | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Program | ST-1 | science | critical | | 7 | | | 0 | false | +| Program | ST-2 | longevity | critical | | 2 | | | 0 | false | + +**team: Systems** + +| team | shortName | name | priority | derivedFrom | descendants | refinedBy | satisfiedBy | verifications | satisfied | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Systems | SYS-1 | downlink | critical | ProgramRequirements::specification::needs::science | 2 | | CommsDesign::commsSubsystem::transmitter | 0 | true | +| Systems | SYS-2 | power | critical | ProgramRequirements::specification::needs::science | 3 | | | 0 | false | +| Systems | SYS-3 | survival | high | ProgramRequirements::specification::needs::longevity | 1 | ThermalDesign::EclipseThermalAnalysis | | 0 | false | +| Systems | SYS-4 | telemetry | low | | 0 | | | 0 | false | + +**team: Thermal** + +| team | shortName | name | priority | derivedFrom | descendants | refinedBy | satisfiedBy | verifications | satisfied | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Thermal | THM-1 | heaterPower | critical | ProgramRequirements::specification::system::survival | 0 | OrbiterVocabulary::HeaterBank | | 0 | false | + +## Coverage gaps + +*Leaf requirements with no satisfier or no verification* + +| team | shortName | name | priority | +| --- | --- | --- | --- | +| Systems | SYS-4 | telemetry | low | +| Thermal | THM-1 | heaterPower | critical | +| Power | PWR-3 | cellDegradation | high | + +*Of those, the critical ones* + +| team | shortName | name | priority | +| --- | --- | --- | --- | +| Thermal | THM-1 | heaterPower | critical | + +*Satisfied but never verified* + +| team | shortName | name | priority | +| --- | --- | --- | --- | +| Power | PWR-3 | cellDegradation | high | +| Systems | SYS-1 | downlink | critical | + +*Unsatisfied but already refined by a design element or analysis* + +| team | shortName | name | priority | +| --- | --- | --- | --- | +| Systems | SYS-3 | survival | high | +| Thermal | THM-1 | heaterPower | critical | + +## Verdicts + +*Every check across the three designs* + +| kind | name | path | verdict | +| --- | --- | --- | --- | +| requirement | cellDegradation | PowerDesign::powerSubsystem | undecided | +| satisfaction | | PowerDesign::powerSubsystem.array | holds | +| verification | arrayIlluminationTest | PowerDesign::powerSubsystem.array | holds | +| satisfaction | | PowerDesign::powerSubsystem.array | undecided | +| satisfaction | | PowerDesign::powerSubsystem.battery | holds | +| verification | batteryCycleTest | PowerDesign::powerSubsystem.battery | holds | +| verification | eclipseCycleAnalysis | PowerDesign::powerSubsystem.battery | holds | +| satisfaction | | CommsDesign::commsSubsystem.transmitter | violated | +| verification | linkBudgetAnalysis | CommsDesign::commsSubsystem.transmitter | violated | +| satisfaction | | CommsDesign::commsSubsystem.transmitter | holds | +| verification | dataRateTest | CommsDesign::commsSubsystem.transmitter | holds | +| satisfaction | | CommsDesign::commsSubsystem.transmitter | undecided | + +*Checks that came out false* + +| kind | name | path | condition | +| --- | --- | --- | --- | +| satisfaction | | CommsDesign::commsSubsystem.transmitter | transmitter.linkMargin >= 3.0 | +| verification | linkBudgetAnalysis | CommsDesign::commsSubsystem.transmitter | | diff --git a/docs/manual/examples/trace-4-program.sysml b/docs/manual/examples/trace-4-program.sysml new file mode 100644 index 0000000000..775e2e18c9 --- /dev/null +++ b/docs/manual/examples/trace-4-program.sysml @@ -0,0 +1,401 @@ +// Program-level traceability across packages: stakeholder needs derive +// system requirements, which derive subsystem requirements owned by three +// teams, one of which keeps a derived requirement of its own inside its +// design package. Each team's design satisfies, refines and verifies its +// requirements; the report joins everything into one matrix grouped by owner, +// then combines and subtracts result sets to find the gaps that matter. + +package OrbiterVocabulary { + private import ScalarValues::*; + + part def SolarArray { + attribute endOfLifePower : Real; + } + part def Battery { + attribute depthOfDischarge : Real; + } + part def Transmitter { + attribute dataRate : Real; + attribute linkMargin : Real; + } + part def HeaterBank; +} + +package ProgramRequirements { + private import ScalarValues::*; + private import OrbiterVocabulary::*; + + // Every requirement carries the team that owns it and how critical it is. + requirement def ProgramRequirement { + attribute team : String; + attribute priority : String; + } + requirement def ArrayOutput :> ProgramRequirement { + subject array : SolarArray; + require constraint { array.endOfLifePower >= 1100.0 } + } + requirement def BatteryDepth :> ProgramRequirement { + subject battery : Battery; + require constraint { battery.depthOfDischarge <= 0.4 } + } + requirement def LinkMargin :> ProgramRequirement { + subject transmitter : Transmitter; + require constraint { transmitter.linkMargin >= 3.0 } + } + requirement def DataRate :> ProgramRequirement { + subject transmitter : Transmitter; + require constraint { transmitter.dataRate >= 4.0 } + } + + part specification { + part needs { + requirement <'ST-1'> science : ProgramRequirement { + doc /* The orbiter shall return 5 TB of science data per year. */ + attribute :>> team = "Program"; + attribute :>> priority = "critical"; + } + requirement <'ST-2'> longevity : ProgramRequirement { + doc /* The orbiter shall operate for 4 years. */ + attribute :>> team = "Program"; + attribute :>> priority = "critical"; + } + } + part system { + requirement <'SYS-1'> downlink : ProgramRequirement { + doc /* The orbiter shall downlink 14 GB per orbit. */ + attribute :>> team = "Systems"; + attribute :>> priority = "critical"; + } + requirement <'SYS-2'> power : ProgramRequirement { + doc /* The orbiter shall generate 900 W at end of life. */ + attribute :>> team = "Systems"; + attribute :>> priority = "critical"; + } + requirement <'SYS-3'> survival : ProgramRequirement { + doc /* The orbiter shall keep every unit within its survival temperatures through eclipse. */ + attribute :>> team = "Systems"; + attribute :>> priority = "high"; + } + requirement <'SYS-4'> telemetry : ProgramRequirement { + doc /* The orbiter shall report housekeeping telemetry once per minute. */ + attribute :>> team = "Systems"; + attribute :>> priority = "low"; + } + } + part subsystem { + requirement <'PWR-1'> arrayOutput : ArrayOutput { + doc /* The solar array shall deliver 1100 W at end of life. */ + attribute :>> team = "Power"; + attribute :>> priority = "critical"; + } + requirement <'PWR-2'> batteryDepth : BatteryDepth { + doc /* The battery shall not discharge below 40 percent in eclipse. */ + attribute :>> team = "Power"; + attribute :>> priority = "high"; + } + requirement <'COM-1'> linkMargin : LinkMargin { + doc /* The X-band link shall close with 3 dB of margin at maximum range. */ + attribute :>> team = "Comms"; + attribute :>> priority = "critical"; + } + requirement <'COM-2'> dataRate : DataRate { + doc /* The transmitter shall sustain 4 Mbps. */ + attribute :>> team = "Comms"; + attribute :>> priority = "critical"; + } + requirement <'THM-1'> heaterPower : ProgramRequirement { + doc /* Survival heaters shall draw no more than 60 W. */ + attribute :>> team = "Thermal"; + attribute :>> priority = "critical"; + } + } + } + + // ---- Derivation: needs to system, system to subsystems ---- + + #RequirementDerivation::derivation connection deriveDownlink + connect specification.needs.science to specification.system.downlink; + #RequirementDerivation::derivation connection derivePower + connect specification.needs.science to specification.system.power; + #RequirementDerivation::derivation connection deriveSurvival + connect specification.needs.longevity to specification.system.survival; + #RequirementDerivation::derivation connection deriveArrayOutput + connect specification.system.power to specification.subsystem.arrayOutput; + #RequirementDerivation::derivation connection deriveBatteryDepth + connect specification.system.power to specification.subsystem.batteryDepth; + #RequirementDerivation::derivation connection deriveLinkMargin + connect specification.system.downlink to specification.subsystem.linkMargin; + #RequirementDerivation::derivation connection deriveDataRate + connect specification.system.downlink to specification.subsystem.dataRate; + #RequirementDerivation::derivation connection deriveHeaterPower + connect specification.system.survival to specification.subsystem.heaterPower; +} + +package PowerDesign { + private import ScalarValues::*; + private import OrbiterVocabulary::*; + private import ProgramRequirements::*; + + analysis def ArrayDegradationAnalysis; + + part powerSubsystem { + part array : SolarArray { + attribute :>> endOfLifePower = 1180.0; + } + part battery : Battery { + attribute :>> depthOfDischarge = 0.38; + } + // The team's own derived requirement, kept beside the design it drives. + requirement <'PWR-3'> cellDegradation : ProgramRequirement { + doc /* Cell output shall degrade no more than 2.5 percent per year. */ + attribute :>> team = "Power"; + attribute :>> priority = "high"; + } + satisfy specification.subsystem.arrayOutput by array; + satisfy specification.subsystem.batteryDepth by battery; + satisfy cellDegradation by array; + } + #RequirementDerivation::derivation connection deriveCellDegradation + connect specification.subsystem.arrayOutput to powerSubsystem.cellDegradation; + #ModelingMetadata::refinement dependency degradationRefinement + from ArrayDegradationAnalysis to powerSubsystem::cellDegradation; + + verification def MeasureArray { + subject array : SolarArray; + VerificationCases::PassIf(array.endOfLifePower >= 1100.0) + } + verification arrayIlluminationTest : MeasureArray { + subject array = powerSubsystem.array; + objective { verify specification.subsystem.arrayOutput; } + } + verification def CycleBattery { + subject battery : Battery; + VerificationCases::PassIf(battery.depthOfDischarge <= 0.4) + } + verification batteryCycleTest : CycleBattery { + subject battery = powerSubsystem.battery; + objective { verify specification.subsystem.batteryDepth; } + } + verification eclipseCycleAnalysis : CycleBattery { + subject battery = powerSubsystem.battery; + objective { verify specification.subsystem.batteryDepth; } + } +} + +package CommsDesign { + private import ScalarValues::*; + private import OrbiterVocabulary::*; + private import ProgramRequirements::*; + + part commsSubsystem { + part transmitter : Transmitter { + attribute :>> dataRate = 4.2; + attribute :>> linkMargin = 2.1; + } + satisfy specification.subsystem.linkMargin by transmitter; + satisfy specification.subsystem.dataRate by transmitter; + satisfy specification.system.downlink by transmitter; + } + + verification def LinkBudget { + subject transmitter : Transmitter; + VerificationCases::PassIf(transmitter.linkMargin >= 3.0) + } + verification linkBudgetAnalysis : LinkBudget { + subject transmitter = commsSubsystem.transmitter; + objective { verify specification.subsystem.linkMargin; } + } + verification def RateTest { + subject transmitter : Transmitter; + VerificationCases::PassIf(transmitter.dataRate >= 4.0) + } + verification dataRateTest : RateTest { + subject transmitter = commsSubsystem.transmitter; + objective { verify specification.subsystem.dataRate; } + } +} + +package ThermalDesign { + private import OrbiterVocabulary::*; + private import ProgramRequirements::*; + + analysis def EclipseThermalAnalysis; + + part thermalSubsystem { + part heaters : HeaterBank; + } + // The heater requirement is refined and analysed, but nothing satisfies or + // verifies it yet. + #ModelingMetadata::refinement dependency heaterRefinement + from HeaterBank to ProgramRequirements::specification::subsystem::heaterPower; + #ModelingMetadata::refinement dependency eclipseRefinement + from EclipseThermalAnalysis to ProgramRequirements::specification::system::survival; +} + +package ProgramReport { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + private import ProgramRequirements::*; + private import PowerDesign::*; + private import CommsDesign::*; + private import ThermalDesign::*; + + // ---- The requirement set: the specification tree plus the requirement + // the power team owns, in one ordered list ---- + + // A satisfy usage is a RequirementUsage too, so a root holding both + // requirements and satisfy statements needs the latter taken out. + calc def RequirementsUnder :> Query { + in root : Element; + Except( + source = WhereType(source = Descendants(source = root, maxDepth = 2), type = "RequirementUsage"), + exclude = WhereType(source = Descendants(source = root, maxDepth = 2), type = "SatisfyRequirementUsage") + ) + } + + calc def AllRequirements :> Query { + OrderBy( + source = Union( + source = RequirementsUnder(root = specification), + other = RequirementsUnder(root = powerSubsystem) + ), + property = "shortName", + direction = "ascending", + missing = "last", + multiple = "error" + ) + } + + // ---- The matrix: identity, ownership, lineage, coverage, one row each ---- + + calc def Matrix :> Query { + Project( + source = AllRequirements(), + properties = ("team", "shortName", "name", "priority"), + columns = ( + RelatedColumn(name = "derivedFrom", relationshipKind = "derivation", direction = "incoming", maxDepth = 1), + RelatedColumn(name = "descendants", relationshipKind = "derivation", direction = "outgoing", maxDepth = 3, aggregate = "count"), + RelatedColumn(name = "refinedBy", relationshipKind = "refinement", direction = "incoming", maxDepth = 1), + RelatedColumn(name = "satisfiedBy", relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1), + RelatedColumn(name = "verifications", relationshipKind = "verification", direction = "incoming", maxDepth = 1, aggregate = "count"), + RelatedColumn(name = "satisfied", relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1, aggregate = "any") + ) + ) + } + + // ---- Coverage sets ---- + + calc def Identified :> Query { + in rows : Element[0..*] ordered; + Project(source = rows, properties = ("team", "shortName", "name", "priority")) + } + + calc def Unsatisfied :> Query { + WhereRelated(source = AllRequirements(), relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1, exists = false) + } + + calc def Unverified :> Query { + WhereRelated(source = AllRequirements(), relationshipKind = "verification", direction = "incoming", maxDepth = 1, exists = false) + } + + // Requirements that derive others are met through their children, so only + // the leaves of the derivation tree count as gaps. + calc def Parents :> Query { + WhereRelated(source = AllRequirements(), relationshipKind = "derivation", direction = "outgoing", maxDepth = 1) + } + + calc def UncoveredLeaves :> Query { + Except(source = Union(source = Unsatisfied(), other = Unverified()), exclude = Parents()) + } + + calc def UncoveredLeafTable :> Query { + Identified(rows = UncoveredLeaves()) + } + + calc def CriticalUncovered :> Query { + Identified(rows = WhereFeature(source = UncoveredLeaves(), 'feature' = "priority", operator = "=", value = "critical")) + } + + // Satisfied by a part but never verified: claims nothing checks. + calc def SatisfiedUnverified :> Query { + Identified(rows = Except(source = Unverified(), exclude = Unsatisfied())) + } + + // Refined by a design element or analysis while no part satisfies them: + // work in progress rather than a gap nobody owns. + calc def RefinedButUnsatisfied :> Query { + Identified(rows = WhereRelated(source = Unsatisfied(), relationshipKind = "refinement", direction = "incoming", maxDepth = 1)) + } + + // ---- Verdicts across every team's design ---- + + calc def DesignVerdicts :> Query { + Union( + source = Union(source = Verdicts(source = powerSubsystem), other = Verdicts(source = commsSubsystem)), + other = Verdicts(source = thermalSubsystem) + ) + } + + calc def TeamVerdicts :> Query { + Project(source = DesignVerdicts(), properties = ("kind", "name", "path", "verdict")) + } + + calc def Failing :> Query { + Project( + source = WhereFeature(source = DesignVerdicts(), 'feature' = "verdict", operator = "=", value = "violated"), + properties = ("kind", "name", "path", "condition") + ) + } + + // ---- Document ---- + + part def ProgramTraceability :> Document { + attribute redefines title = "Orbiter Program Requirements Traceability"; + + part intro : Paragraph { + attribute redefines text = "Every requirement of the program in one matrix, grouped by the team that owns it, with its lineage, refinements, satisfiers and verification count; then the gaps that remain and the checks that fail."; + } + + part matrix : Section { + attribute redefines title = "Traceability matrix"; + part byTeam : Table { + attribute redefines caption = "Requirements by owning team"; + attribute redefines groupBy = "team"; + calc rows : Matrix; + } + } + + part gaps : Section { + attribute redefines title = "Coverage gaps"; + part leaves : Table { + attribute redefines caption = "Leaf requirements with no satisfier or no verification"; + calc rows : UncoveredLeafTable; + } + part critical : Table { + attribute redefines caption = "Of those, the critical ones"; + calc rows : CriticalUncovered; + } + part unchecked : Table { + attribute redefines caption = "Satisfied but never verified"; + calc rows : SatisfiedUnverified; + } + part inProgress : Table { + attribute redefines caption = "Unsatisfied but already refined by a design element or analysis"; + calc rows : RefinedButUnsatisfied; + } + } + + part verdicts : Section { + attribute redefines title = "Verdicts"; + part all : Table { + attribute redefines caption = "Every check across the three designs"; + calc rows : TeamVerdicts; + } + part failed : Table { + attribute redefines caption = "Checks that came out false"; + calc rows : Failing; + } + } + } +} diff --git a/docs/manual/examples/traceability.md b/docs/manual/examples/traceability.md new file mode 100644 index 0000000000..b58de9f10e --- /dev/null +++ b/docs/manual/examples/traceability.md @@ -0,0 +1,37 @@ +# Spacecraft Requirement Traceability + +Every requirement, what satisfies and verifies it, and whether the spacecraft meets it. + +## Requirements + +**SC-1** — The bus dry mass shall not exceed 1200 kg. + +**SC-2** — The high-gain antenna shall provide at least 40 dBi. + +**SC-3** — The radiator shall provide at least 2 m² of rejection area. + +**SC-4** — The spacecraft shall be passivated at end of mission. + +## Traceability Matrix + +*Requirements with their satisfiers and verifiers* + +| shortName | name | satisfiedBy | verifiedBy | verifications | +| --- | --- | --- | --- | --- | +| SC-1 | massLimit | Traceability::spacecraft::bus | Traceability::massMeasurement | 1 | +| SC-2 | downlinkGain | Traceability::spacecraft::antenna | Traceability::gainTest, Traceability::gainAnalysis | 2 | +| SC-3 | heatRejection | Traceability::spacecraft::radiator | | 0 | +| SC-4 | passivation | | | 0 | + +## Verdicts + +*Checks of the spacecraft against its requirements* + +| kind | name | path | verdict | condition | +| --- | --- | --- | --- | --- | +| satisfaction | | Traceability::spacecraft.bus | holds | | +| verification | massMeasurement | Traceability::spacecraft.bus | holds | | +| satisfaction | | Traceability::spacecraft.antenna | holds | | +| verification | gainTest | Traceability::spacecraft.antenna | holds | | +| verification | gainAnalysis | Traceability::spacecraft.antenna | holds | | +| satisfaction | | Traceability::spacecraft.radiator | violated | radiator.area >= 2.0 | diff --git a/docs/manual/examples/traceability.sysml b/docs/manual/examples/traceability.sysml new file mode 100644 index 0000000000..257c2be629 --- /dev/null +++ b/docs/manual/examples/traceability.sysml @@ -0,0 +1,179 @@ +// Requirement traceability in one document: the requirements as a list, a +// matrix with every requirement's satisfiers, verifiers and verification +// count on one row, and the verdicts of checking the spacecraft against them. +package Traceability { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + // ---- The system model the report is generated from ---- + + part def Bus { + attribute dryMass : Real; + } + part def Antenna { + attribute gain : Real; + } + part def Radiator { + attribute area : Real; + } + + requirement def MassLimit { + subject bus : Bus; + require constraint { bus.dryMass <= 1200.0 } + } + requirement def DownlinkGain { + subject antenna : Antenna; + require constraint { antenna.gain >= 40.0 } + } + requirement def HeatRejection { + subject radiator : Radiator; + require constraint { radiator.area >= 2.0 } + } + requirement def Passivation; + + part specification { + requirement <'SC-1'> massLimit : MassLimit { + doc /* The bus dry mass shall not exceed 1200 kg. */ + } + requirement <'SC-2'> downlinkGain : DownlinkGain { + doc /* The high-gain antenna shall provide at least 40 dBi. */ + } + requirement <'SC-3'> heatRejection : HeatRejection { + doc /* The radiator shall provide at least 2 m² of rejection area. */ + } + requirement <'SC-4'> passivation : Passivation { + doc /* The spacecraft shall be passivated at end of mission. */ + } + } + + part spacecraft { + part bus : Bus { + attribute :>> dryMass = 1150.0; + } + part antenna : Antenna { + attribute :>> gain = 42.5; + } + part radiator : Radiator { + attribute :>> area = 1.6; + } + satisfy specification.massLimit by bus; + satisfy specification.downlinkGain by antenna; + satisfy specification.heatRejection by radiator; + } + + verification def MassMeasurement { + subject bus : Bus; + VerificationCases::PassIf(bus.dryMass <= 1200.0) + } + verification massMeasurement : MassMeasurement { + subject bus = spacecraft.bus; + objective { + verify specification.massLimit; + } + } + + verification def GainTest { + subject antenna : Antenna; + VerificationCases::PassIf(antenna.gain >= 40.0) + } + verification gainTest : GainTest { + subject antenna = spacecraft.antenna; + objective { + verify specification.downlinkGain; + } + } + verification gainAnalysis : GainTest { + subject antenna = spacecraft.antenna; + objective { + verify specification.downlinkGain; + } + } + + // ---- Document queries ---- + + calc def Requirements :> Query { + in root : Element; + Project( + source = WhereType( + source = Descendants(source = root, maxDepth = 1), + type = "RequirementUsage" + ), + properties = ("shortName", "name", "documentation") + ) + } + + calc def TraceMatrix :> Query { + in root : Element; + Project( + source = WhereType( + source = Descendants(source = root, maxDepth = 1), + type = "RequirementUsage" + ), + properties = ("shortName", "name"), + columns = ( + RelatedColumn(name = "satisfiedBy", relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1), + RelatedColumn(name = "verifiedBy", relationshipKind = "verification", direction = "incoming", maxDepth = 1), + RelatedColumn( + name = "verifications", + relationshipKind = "verification", + direction = "incoming", + maxDepth = 1, + aggregate = "count" + ) + ) + ) + } + + calc def Checks :> Query { + in root : Element; + Project( + source = Verdicts(source = root), + properties = ("kind", "name", "path", "verdict", "condition") + ) + } + + // ---- The document definition ---- + + part def TraceabilityReport :> Document { + attribute redefines title = "Spacecraft Requirement Traceability"; + + part intro : Paragraph { + attribute redefines text = "Every requirement, what satisfies and verifies it, and whether the spacecraft meets it."; + } + + part requirements : Section { + attribute redefines title = "Requirements"; + + part list : Definitions { + attribute redefines term = "shortName"; + attribute redefines description = "documentation"; + calc rows : Requirements { + in root = specification; + } + } + } + + part matrix : Section { + attribute redefines title = "Traceability Matrix"; + + part table : Table { + attribute redefines caption = "Requirements with their satisfiers and verifiers"; + calc rows : TraceMatrix { + in root = specification; + } + } + } + + part verdicts : Section { + attribute redefines title = "Verdicts"; + + part table : Table { + attribute redefines caption = "Checks of the spacecraft against its requirements"; + calc rows : Checks { + in root = spacecraft; + } + } + } + } +} diff --git a/docs/manual/getting-started.md b/docs/manual/getting-started.md index eb487f8a2a..ff75caa409 100644 --- a/docs/manual/getting-started.md +++ b/docs/manual/getting-started.md @@ -103,7 +103,6 @@ $ sysml rover.sysml -render-document Hello::RoverReport The rover's top-level parts, from the model: - *Top-level parts* | name | diff --git a/docs/manual/interfaces.md b/docs/manual/interfaces.md index 22e5a55156..611a1aa278 100644 --- a/docs/manual/interfaces.md +++ b/docs/manual/interfaces.md @@ -16,6 +16,8 @@ $ sysml model.sysml -render-document Observatory::MassReport \ -doc-form pdf -pdf-engine weasyprint -doc-title-page -doc-toc \ -doc-number-sections -o report.pdf $ sysml model.sysml -run-query "Observatory::SubsystemTable root=Observatory::telescope" +$ sysml cookbook.sysml -instantiate Cookbook::telescope -run-query "Cookbook::Violated root=telescope" +$ sysml model.sysml -instantiate Observatory::telescope -render-document Observatory::MassReport -o report.md ``` | Flag | Meaning | @@ -31,13 +33,20 @@ $ sysml model.sysml -run-query "Observatory::SubsystemTable root=Observatory::te | `-html-default-css` | Write the default stylesheet and exit | | `-html-fragment` | The document element alone, to embed in your own page | | `-run-query " [

= ...]"` | Run one document query directly | +| `-instantiate ` | Create an object first, so the query or document reads what it holds | | `-o ` | Output file; required for PDF | `-run-query` bindings are space-separated `parameter=expression` pairs after -the query's qualified name. A name expression binds the element it refers to; -quoted strings and numeric literals bind values. The exit code is non-zero -on any planning, binding or execution error. Full details are in the -[CLI reference](../reference/cli.md). +the query's qualified name. A name expression binds the object `-instantiate` +created under it while the run holds one, and the element it refers to +otherwise; `#1` and `telescope.primaryMirror` bind a held object by id and by path; +quoted strings and numeric literals bind values. A document binds its own +parameters in the model, so `-instantiate` beside `-render-document` is enough +for its queries to read the object's current values and to check it through +`Verdicts` ([Objects the session holds](query-cookbook.md#objects-the-session-holds), +[Which constraints and requirements hold](query-cookbook.md#which-constraints-and-requirements-hold)). +The exit code is non-zero on any planning, binding or execution error. Full +details are in the [CLI reference](../reference/cli.md). ## REPL @@ -48,6 +57,17 @@ Inside `sysml`'s interactive session, the same two operations are commands: %render-document Observatory::MassReport ``` +The bindings follow the CLI's rule over the objects the session holds: after +`%instantiate telescope`, `root=telescope` binds the object rather than the +usage, `#1` and `telescope.primaryMirror` bind one by id and by path, and a +query over `Verdicts` prints one ` on : ` line per +row — the same sweep `%validate telescope` prints, as a query: + +``` +%instantiate Cookbook::telescope +%run-query Cookbook::Violated root=telescope +``` + `%render-document` prints Markdown; PDF output is CLI-only. See the [REPL command reference](../reference/repl-commands.md). @@ -62,11 +82,16 @@ The `sysml-lsp -grpc` service exposes two document RPCs, advertised as the - **`RenderDocument`** — render a named document definition; the reply carries the Markdown. +Both run over the objects the service holds for the model: `Instantiate` +creates one and keeps it for as long as the model stays cached, the +counterpart of `%instantiate` and `-instantiate`, so a binding may name it by +id or by path and a document renders its current values. + The Python client wraps both on its model handle: ```python import opensysml -from opensysml.document import ElementRef +from opensysml.document import ElementRef, ObjectRef model = opensysml.load("observatory.sysml") @@ -79,14 +104,31 @@ for row in result: # DocumentRow: row.element, cells by column index print(row[0], row[1]) markdown = model.render_document("Observatory::MassReport") + +cookbook = opensysml.load("cookbook.sysml") +cookbook.instantiate("Cookbook::telescope") +checks = cookbook.run_document_query( + "Cookbook::Violated", + bindings={"root": ObjectRef(path="Cookbook::telescope")}, # or ObjectRef(id=1) +) +for row in checks: # row.element is the assertion, row.verdict its verdict + print(row.verdict) # assert constraint lightweight on Cookbook::telescope.primaryMirror: violated ``` -Bindings accept an `ElementRef` (a model element by qualified name), `str`, -`int`, `float`, `bool`, or a sequence of those. Query results decode the -service's typed values back into Python values, with unbounded multiplicity -as `opensysml.document.INFINITY`. Errors are typed exceptions -(`InvalidRequestError`, `SymbolNotFoundError`, `MissingCapabilityError`, -`ModelNotFoundError`). See the [API reference](../reference/api.md). +Bindings accept an `ElementRef` (a model element by qualified name), an +`ObjectRef` (an object `instantiate` built, by `id`, by `path` — +`"Cookbook::telescope"`, `"#1"`, `"Cookbook::telescope.primaryMirror"` — or +both), `str`, `int`, +`float`, `bool`, or a sequence of those. Query results decode the service's +typed values back into Python values, with unbounded multiplicity as +`opensysml.document.INFINITY`, an object as an `ObjectRef` carrying `id`, +`path` and the usage it stands for (`row.object` for a row over an object), +and a `Verdicts` row's verdict as `row.verdict`, a `DocumentVerdict` carrying +`assertion`, `kind`, `path`, `status` (`holds`, `violated`, `undecided`), +`condition`, `reason` and `verification`. A verdict is answered, never bound. +Errors are typed exceptions (`InvalidRequestError`, `SymbolNotFoundError`, +`MissingCapabilityError`, `ModelNotFoundError`). See the +[API reference](../reference/api.md). ## VS Code and LSP diff --git a/docs/manual/introduction.md b/docs/manual/introduction.md index 26dced297e..c4d059dfeb 100644 --- a/docs/manual/introduction.md +++ b/docs/manual/introduction.md @@ -22,7 +22,9 @@ model → queries → document plan → document tree → Markdown or HTML → ( composes library operations — collect owned elements or descendants, filter by type, name, metadata or attribute value, traverse relationships, order, project columns — into a reusable, parameterized question about the - model. + model, or about the objects a session has instantiated from it: bound to + one, the same operations read the values it holds now and `Verdicts` + checks its constraints and requirements. 3. **A document definition** is a `part def` specializing `DocumentQueries::Document`. Its nested parts are the document's content in declaration order: sections, paragraphs, tables, lists and diagrams. Blocks @@ -60,7 +62,15 @@ sequences): | `WhereName` | Keep elements whose name passes a comparison | | `WhereFeature` | Keep elements whose attribute value passes a comparison | | `OrderBy` | Sort by a property, with explicit missing- and multiple-value policies | -| `Project` | Turn elements into rows of named, typed columns | +| `Project` | Turn elements into rows of named, typed columns — declared properties, computed `Column` expressions and `RelatedColumn` cells holding the elements a relationship reaches from each row, their count or whether there are any | +| `Objects` | The objects the session holds that are of a type, each under its path; every other operation accepts an object where it accepts an element and reads what the object holds ([Objects the session holds](query-cookbook.md#objects-the-session-holds)) | +| `Verdicts` | One row per assertion checked on the object behind each source row — its verdict, path, kind and reason ([Which constraints and requirements hold](query-cookbook.md#which-constraints-and-requirements-hold)) | +| `WhereRelated` | Keep elements by whether at least one element is reachable over a named relationship kind (`exists = true`, the default) or none is (`exists = false`) — the coverage filter behind "which requirements does nothing satisfy" ([Coverage](query-cookbook.md#coverage)) | +| `Except` | The rows of one sequence not present in another, once each in order | +| `Union` | One sequence followed by the rows of another not already present, each once | + +Document queries are one of several query surfaces the project has; [Which +query is which](query-kinds.md) draws the boundaries between them. **Document content blocks** (each a `part def` nested inside a document or section): diff --git a/docs/manual/outputs.md b/docs/manual/outputs.md index c154ab65a7..35f90e8c30 100644 --- a/docs/manual/outputs.md +++ b/docs/manual/outputs.md @@ -25,15 +25,15 @@ What the renderer emits: when rendered with `-diagram-form dot`, ` ```plantuml ` blocks with `-diagram-form plantuml`, or a pipe table for the `table` kind whichever form), with captions in emphasis. -- Every table and diagram caption preceded by a `` marker - line, so the caption is distinguishable from an emphasis-only paragraph. - The marker is metadata of OpenSysML's Markdown dialect: ordinary Markdown - renderers treat it as a comment and display nothing. +- An object the session holds as its path from the object the query was bound + through (`car.wheels[2]`), and a verdict as ` on : ` + (`assert constraint powerLow on car.engine: violated`). - All model-derived text escaped so it cannot break document structure. - A single trailing newline, no trailing whitespace. -The output is deterministic: the same model produces byte-identical Markdown -on every run, which is why the repository can keep rendered documents as +The output is deterministic: the same model — and, for a document that reads +the objects a session holds, the same objects in the same state — produces +byte-identical Markdown on every run, which is why the repository can keep rendered documents as golden files (this manual does exactly that — see [the worked example](worked-example.md)). @@ -94,6 +94,30 @@ apart), and a diagram's view, kind and flow direction.

``` +A row over an object the session holds ([Objects the session holds](query-cookbook.md#objects-the-session-holds)) +adds `data-object="#"`, the id the instantiation report printed, beside +the usage the object stands for, and an object-valued cell is a +`span.sysml-object` whose text is the object's path. A row a `Verdicts` query +answered ([Which constraints and requirements hold](query-cookbook.md#which-constraints-and-requirements-hold)) +is the assertion checked, and its row or list item carries `data-verdict` +(`holds`, `violated` or `undecided`), `data-path` (the object checked) and, +over a held object, its `data-object`; a verdict-valued cell is a +`span.sysml-verdict` with the same three attributes, whose text is the line +Markdown prints, ` on : `. So a stylesheet colours +what is violated with +`[data-verdict="violated"] { … }` and a script reads which objects it is +about without parsing the text. + +```html + + + + +``` + Identifiers are the same anchors the Markdown writes, so a `Ref` resolves within the page; in a `-render-documents` set it resolves across pages, whose file names are the Markdown names with `.html` instead of `.md`. Diagram @@ -103,13 +127,30 @@ By default the output loads nothing over the network, runs no JavaScript of its own, and is byte-identical between runs. To have a browser draw the diagrams, `-html-mermaid cdn` adds a `\n") - } - w.b.WriteString("\n\n") - } - return nil -} - -// writeShellStart writes the page shell up to , with the default -// stylesheet first and the supplied ones after it, unlayered. -func (w *htmlWriter) writeShellStart(title string) { - lang := w.opts.Lang - if lang == "" { - lang = "en" - } - w.b.WriteString("\n\n\n") - w.b.WriteString("\n") - w.b.WriteString("\n") - w.b.WriteString("" + htmlText(title) + "\n") - if !w.opts.NoDefaultStylesheet { - w.b.WriteString("\n") - } - for _, sheet := range w.opts.Stylesheets { - if sheet.Href != "" { - w.b.WriteString("\n") - continue - } - w.b.WriteString("\n") - } - w.b.WriteString("\n\n") -} - -// writeTitle writes the document title: in a page of its own when a title page -// was asked for, and as the opening heading otherwise. -func (w *htmlWriter) writeTitle(title string) { - heading := "

" + htmlText(title) + "

\n" - if w.opts.TitlePage { - w.b.WriteString("
\n" + heading + "
\n") - return - } - w.b.WriteString(heading) -} - -// outlineEntry is one section of the table of contents. -type outlineEntry struct { - id string - title string - number string - children []outlineEntry -} - -// outline collects the section tree with each section's identifier and number. -func (w *htmlWriter) outline(nodes []docir.Content, path []step) []outlineEntry { - var entries []outlineEntry - for i, node := range nodes { - if node.Kind() != docir.ContentSection { - continue - } - nested := child(path, node.Name(), i) - entries = append(entries, outlineEntry{ - id: w.ids[pathKey(nested)], - title: node.Title(), - number: w.numbers[pathKey(nested)], - children: w.outline(node.Children(), nested), - }) - } - return entries -} - -// sectionNumbers numbers every section hierarchically, keyed by occurrence path. -func sectionNumbers(nodes []docir.Content, path []step, prefix string, into map[string]string) map[string]string { - count := 0 - for i, node := range nodes { - if node.Kind() != docir.ContentSection { - continue - } - count++ - nested := child(path, node.Name(), i) - number := prefix + strconv.Itoa(count) - into[pathKey(nested)] = number - sectionNumbers(node.Children(), nested, number+".", into) - } - return into -} - -// writeTOC writes the table of contents as nested ordered lists, each entry -// linking to its section. -func (w *htmlWriter) writeTOC(entries []outlineEntry) { - if len(entries) == 0 { - return - } - w.b.WriteString("\n") -} - -func (w *htmlWriter) writeTOCList(entries []outlineEntry) { - w.b.WriteString("
    \n") - for _, entry := range entries { - w.b.WriteString("
  1. ") - w.b.WriteString("") - if w.opts.NumberSections { - w.b.WriteString("" + htmlText(entry.number) + " ") - } - w.b.WriteString(htmlText(entry.title) + "") - if len(entry.children) > 0 { - w.b.WriteString("\n") - w.writeTOCList(entry.children) - } - w.b.WriteString("
  2. \n") - } - w.b.WriteString("
\n") -} - -// writeContent writes one content node, with level the heading level a section -// at this depth writes, path the occurrence path of its parent and index its -// place among its siblings. -func (w *htmlWriter) writeContent(node docir.Content, path []step, index, level int) error { - nested := child(path, node.Name(), index) - id := w.ids[pathKey(nested)] - switch node.Kind() { - case docir.ContentSection: - return w.writeSection(node, nested, id, level) - case docir.ContentParagraph: - w.b.WriteString("

" + - w.inlineRuns(node.Runs()) + "

\n") - return nil - case docir.ContentTable: - w.writeTable(node, id) - return nil - case docir.ContentList: - w.writeList(node, id) - return nil - case docir.ContentDefinitions: - w.writeDefinitions(node, id) - return nil - case docir.ContentDiagram: - return w.writeDiagram(node, id) - default: - return &Error{Kind: ErrorUnknownContent, Content: node.Name(), Actual: string(node.Kind())} - } -} - -// writeSection writes one section and its children, numbering the heading when -// numbering was asked for. Heading levels saturate at 6, as HTML has no more. -func (w *htmlWriter) writeSection(node docir.Content, path []step, id string, level int) error { - w.b.WriteString("
\n") - tag := "h" + strconv.Itoa(min(level, 6)) - w.b.WriteString("<" + tag + ">") - if w.opts.NumberSections { - w.b.WriteString("" + htmlText(w.numbers[pathKey(path)]) + " ") - } - w.b.WriteString(htmlText(node.Title()) + "\n") - for i, child := range node.Children() { - if err := w.writeContent(child, path, i, level+1); err != nil { - return err - } - } - w.b.WriteString("
\n") - return nil -} - -// writeTable writes one table: its caption, a header row of projected column -// names, and one row per query row. A query without projected columns gets a -// single "element" column, and a grouped table one
per group. -func (w *htmlWriter) writeTable(node docir.Content, id string) { - columns := node.Columns() - names := make([]string, 0, len(columns)) - for _, column := range columns { - names = append(names, column.Name()) - } - if len(names) == 0 { - names = []string{elementColumn} - } - w.b.WriteString("
+car.engine +violated
\n") - if node.Caption() != "" { - w.b.WriteString("\n") - } - w.writeTableHead(names) - if node.GroupBy() != "" { - for _, group := range node.Groups() { - w.b.WriteString("\n") - w.b.WriteString("\n") - w.writeRows(group.Rows(), names, len(columns)) - w.b.WriteString("\n") - } - w.b.WriteString("
" + htmlText(node.Caption()) + "
" + - "" + htmlText(node.GroupBy()) + ": " + - "" + htmlText(group.Key()) + "
\n") - return - } - w.b.WriteString("\n") - w.writeRows(node.Rows(), names, len(columns)) - w.b.WriteString("\n\n") -} - -func (w *htmlWriter) writeTableHead(names []string) { - w.b.WriteString("\n\n") - for _, name := range names { - w.b.WriteString("" + htmlText(name) + "\n") - } - w.b.WriteString("\n\n") -} - -// writeRows writes one row per query row, each carrying the element it selected -// and its kind. A row of a table without projected columns is its element alone. -func (w *htmlWriter) writeRows(rows []queryexec.Row, names []string, columns int) { - for _, row := range rows { - w.b.WriteString("\n") - if columns == 0 { - w.writeCell([]queryexec.Value{row.Element()}, elementColumn) - w.b.WriteString(rowEnd) - continue - } - cells := row.Cells() - for i := 0; i < columns; i++ { - var values []queryexec.Value - if i < len(cells) { - values = cells[i].Values() - } - w.writeCell(values, names[i]) - } - w.b.WriteString(rowEnd) - } -} - -// writeCell writes one projected cell: every value individually addressable, -// with the punctuation joining them an element of its own so a theme can hide -// or replace it. -func (w *htmlWriter) writeCell(values []queryexec.Value, column string) { - w.b.WriteString("") - for i, value := range values { - if i > 0 { - w.b.WriteString(", ") - } - w.writeValue(value) - } - w.b.WriteString("\n") -} - -func (w *htmlWriter) writeValue(value queryexec.Value) { - classes := "sysml-value" - if _, ok := value.Element(); ok { - classes += " sysml-element" - } - w.b.WriteString("" + htmlText(valueText(value)) + "") -} - -// quantityAttrs carries a quantity's magnitude and unit apart, so a theme or a -// script reads them without parsing the cell text. -func quantityAttrs(value queryexec.Value) string { - quantity, ok := value.Quantity() - if !ok { - return "" - } - magnitude, _ := value.Magnitude() - return attr("data-magnitude", valueText(magnitude)) + attr("data-unit", quantity.Unit.String()) -} - -// writeList writes one bullet or numbered list, one item per query row, each -// item carrying the element its row selected. -func (w *htmlWriter) writeList(node docir.Content, id string) { - tag := "ul" - if node.Style() == docir.ListNumber { - tag = "ol" - } - w.b.WriteString("<" + tag + " class=\"sysml-list\"" + attr("id", id) + " data-content=\"list\"" + - attr(attrName, node.Name()) + attr(attrQuery, node.Query()) + ">\n") - for _, item := range node.Items() { - w.b.WriteString("
  • " + - w.inlineRuns(item.Runs()) + "
  • \n") - } - w.b.WriteString("\n") -} - -// writeDefinitions writes one description list, one term/description group -// per query row, each group carrying the element its row selected. -func (w *htmlWriter) writeDefinitions(node docir.Content, id string) { - w.b.WriteString("
    \n") - for _, entry := range node.Definitions() { - w.b.WriteString("
    \n" + - "
    " + w.inlineRuns(entry.Term()) + "
    \n" + - "
    " + w.inlineRuns(entry.Description()) + "
    \n" + - "
    \n") - } - w.b.WriteString("
    \n") -} - -// writeDiagram writes one diagram as a figure: a table-kind view as a table, -// every other supported kind as its source in the render's diagram form — -// Mermaid, which a loaded Mermaid script draws, or DOT or PlantUML — shown as text. -func (w *htmlWriter) writeDiagram(node docir.Content, id string) error { - return w.writeFigure(id, node.Name(), node.Caption(), node.Rendering(), node.Options()) -} - -func (w *htmlWriter) writeFigure(id, name, caption string, rendering *view.Rendering, options view.Options) error { - if rendering == nil { - return &Error{Kind: ErrorMissingRendering, Content: name} - } - if rendering.Kind != view.KindTable && !rendering.Kind.Supported() { - return &Error{Kind: ErrorUnrenderableDiagram, Content: name, Actual: string(rendering.Kind), Form: "HTML"} - } - var source string - if rendering.Kind != view.KindTable { - var err error - if source, err = diagramSource(name, rendering, options, w.form); err != nil { - return err - } - } - w.b.WriteString("
    \n") - if rendering.Kind == view.KindTable { - w.writeRenderingTable(rendering) - } else { - w.b.WriteString("" + html.EscapeString(source) + "
    \n") - } - if caption != "" { - w.b.WriteString("
    " + htmlText(caption) + "
    \n") - } - w.b.WriteString("\n") - return nil -} - -// writeRenderingTable writes a table-kind view's cells, keeping the notices the -// rendering could not represent as comments so none is lost. -func (w *htmlWriter) writeRenderingTable(rendering *view.Rendering) { - for _, notice := range rendering.Notices { - w.b.WriteString("\n") - } - if rendering.Empty() { - w.b.WriteString("

    " + htmlText(rendering.EmptyReason()) + "

    \n") - return - } - columns := rendering.Columns - if len(columns) == 0 { - columns = view.TableColumns() - } - w.b.WriteString("\n") - w.writeTableHead(columns) - w.b.WriteString("\n") - for _, row := range rendering.Rows { - w.b.WriteString("\n") - for i, name := range columns { - cell := "" - if i < len(row) { - cell = row[i] - } - w.b.WriteString("\n") - } - w.b.WriteString(rowEnd) - } - w.b.WriteString("\n
    " + htmlText(cell) + "
    \n") -} - -// inlineRuns renders text runs joined by single spaces, each by its kind: -// plain runs as prose, styled runs in , or , links and -// references as anchors, and element-valued runs carrying their element. -func (w *htmlWriter) inlineRuns(runs []docir.TextRun) string { - parts := make([]string, len(runs)) - for i, run := range runs { - parts[i] = w.runHTML(run) - } - return strings.Join(parts, " ") -} - -func (w *htmlWriter) runHTML(run docir.TextRun) string { - switch run.Kind() { - case docir.RunEmphasis: - return "" + htmlText(run.Text()) + "" - case docir.RunStrong: - return "" + htmlText(run.Text()) + "" - case docir.RunCode: - return "" + htmlText(run.Text()) + "" - case docir.RunLink: - if target, ok := navigableURL(run.Target()); ok { - return "" + htmlText(run.Text()) + "" - } - // A scheme a document must not navigate to is kept as data, not as a link. - return "" + htmlText(run.Text()) + "" - case docir.RunRef: - return "" + htmlText(run.Text()) + "" - default: - return htmlText(run.Text()) - } -} - -// htmlRefDestination maps a reference run to its destination: an in-document -// anchor, or a relative link into another document's HTML file. -func htmlRefDestination(run docir.TextRun) string { - if run.TargetDocument() == "" { - return "#" + run.Target() - } - destination := DocumentHTMLFileName(run.TargetDocument()) - if run.Target() != "" { - destination += "#" + run.Target() - } - return destination -} - -// DocumentHTMLFileName derives the deterministic HTML file name of a rendered -// document from its fully-qualified name, escaped as anchors are so distinct -// documents never collide. -func DocumentHTMLFileName(fqn string) string { - return documentFileName(fqn, ".html") -} - -// contentIDs maps every content node's occurrence path to the identifier the -// document addresses it by: the stable anchor the IR gave it, and for a -// section without one the anchor its named path derives, made unique so no two -// nodes share an identifier. -func contentIDs(document *docir.Document) map[string]string { - reserved := reservedIDs(document.Content(), nil, map[string]bool{}) - anchors := anchorIDs(document.Content(), map[string]bool{}) - ids, used := map[string]string{}, map[string]bool{} - var assign func(nodes []docir.Content, path []step) - assign = func(nodes []docir.Content, path []step) { - for i, node := range nodes { - nested := child(path, node.Name(), i) - id := node.Anchor() - if id == "" && node.Kind() == docir.ContentSection { - id = uniqueID(derivedID(nested), reserved, used, anchors) - } - if id != "" { - ids[pathKey(nested)] = id - used[id] = true - } - assign(node.Children(), nested) - } - } - assign(document.Content(), nil) - return ids -} - -// reservedIDs collects every identifier the document may address a node by, so -// a derived one is never made to collide with an anchor emitted later. -func reservedIDs(nodes []docir.Content, path []step, into map[string]bool) map[string]bool { - for i, node := range nodes { - nested := child(path, node.Name(), i) - into[derivedID(nested)] = true - if node.Anchor() != "" { - into[node.Anchor()] = true - } - reservedIDs(node.Children(), nested, into) - } - return into -} - -// anchorIDs collects the anchors the IR gave nodes, which a derived identifier -// must leave to them however early it is allocated. -func anchorIDs(nodes []docir.Content, into map[string]bool) map[string]bool { - for _, node := range nodes { - if node.Anchor() != "" { - into[node.Anchor()] = true - } - anchorIDs(node.Children(), into) - } - return into -} - -// derivedID is the identifier a path derives, which is the anchor of its -// names; a path of anonymous nodes alone derives none, and is numbered from -// the fallback instead. -func derivedID(path []step) string { - names := make([]string, 0, len(path)) - for _, s := range path { - names = append(names, s.name) - } - if anchor := docir.AnchorFor(names); strings.Trim(anchor, "-") != "" { - return anchor - } - return anonymousID -} - -// anonymousID is the identifier a section with no named ancestry is numbered -// from, since it has no name to derive one. -const anonymousID = "section" - -// uniqueID is the derived identifier itself when no other node has taken it or -// owns it as an anchor, and the first free numbered variant otherwise: two -// anonymous siblings derive the same path. -func uniqueID(derived string, reserved, used, anchors map[string]bool) string { - if !used[derived] && !anchors[derived] { - return derived - } - for n := 2; ; n++ { - candidate := derived + "-" + strconv.Itoa(n) - if !reserved[candidate] && !used[candidate] { - return candidate - } - } -} - -// step is one node of an occurrence path: the node's declared name, empty when -// anonymous, and its place among its siblings, which tells two anonymous -// siblings apart. -type step struct { - name string - index int -} - -// pathKey keys an occurrence path by a separator no name can contain. -func pathKey(path []step) string { - var b strings.Builder - for _, s := range path { - b.WriteString(strconv.Itoa(s.index)) - b.WriteByte(0) - b.WriteString(s.name) - b.WriteByte(0) - } - return b.String() -} - -// child extends an occurrence path without sharing its backing array. -func child(path []step, name string, index int) []step { - return append(append(make([]step, 0, len(path)+1), path...), step{name: name, index: index}) -} - -// attr writes one attribute, escaped, and nothing for an empty value. -func attr(name, value string) string { - if value == "" { - return "" - } - return " " + name + "=\"" + html.EscapeString(value) + "\"" -} - -// elementAttrs writes the model element behind a row, list item or value: its -// qualified name and its element kind. -func elementAttrs(value queryexec.Value) string { - element, ok := value.Element() - if !ok || element == nil { - return "" - } - return attr("data-element", elementID(element)) + attr("data-element-kind", element.Kind.String()) -} - -// elementID identifies an element by qualified name, falling back to its -// declared name. -func elementID(element *symbols.Symbol) string { - if fqn := symbols.FQNOf(element); fqn != "" { - return fqn - } - return element.Name -} - -// navigableSchemes are the URL schemes a rendered document links to; every -// other scheme, javascript: above all, is not navigated to from a document. -var navigableSchemes = map[string]bool{ - "http": true, "https": true, "mailto": true, "ftp": true, "ftps": true, "tel": true, -} - -// navigableURL reports whether a link target may be an href: a relative URL, -// a fragment, or an absolute URL in a navigable scheme. -func navigableURL(target string) (string, bool) { - clean := strings.ReplaceAll(newlineNormalizer.Replace(target), "\n", "") - colon := strings.IndexByte(clean, ':') - if colon < 0 { - return clean, true - } - if stop := strings.IndexAny(clean, "/?#"); stop >= 0 && stop < colon { - return clean, true - } - return clean, navigableSchemes[strings.ToLower(clean[:colon])] -} - -// sharedValueKind is the value kind of a cell whose values all have one, -// empty for an empty cell or a cell of mixed kinds. -func sharedValueKind(values []queryexec.Value) string { - if len(values) == 0 { - return "" - } - kind := values[0].Kind() - for _, value := range values[1:] { - if value.Kind() != kind { - return "" - } - } - return string(kind) -} - -// htmlText escapes prose for any position in the document, folding newlines to -// spaces since paragraph structure comes from the document, not run content. -func htmlText(text string) string { - return html.EscapeString(strings.ReplaceAll(newlineNormalizer.Replace(text), "\n", " ")) -} - -// htmlComment escapes a comment, where no escaping mechanism exists: the -// sequence that would close it early is broken up and newlines fold to spaces. -func htmlComment(text string) string { - return strings.NewReplacer("--", "- -", "\r\n", " ", "\r", " ", "\n", " ").Replace(text) -} diff --git a/internal/core/docrender/html_test.go b/internal/core/docrender/html_test.go deleted file mode 100644 index f14685fc66..0000000000 --- a/internal/core/docrender/html_test.go +++ /dev/null @@ -1,571 +0,0 @@ -package docrender - -import ( - "errors" - "os" - "path/filepath" - "regexp" - "slices" - "strings" - "testing" - - "github.com/Open-MBEE/OpenSysML/internal/core/view" -) - -// htmlClassVocabulary is the documented class surface: a theme may rely on -// these names, and the backend emits no others. -var htmlClassVocabulary = map[string]bool{ - "sysml-document": true, "sysml-title": true, "sysml-title-page": true, - "sysml-toc": true, "sysml-toc-title": true, - "sysml-section": true, "sysml-section-number": true, "sysml-paragraph": true, - "sysml-table": true, "sysml-group": true, "sysml-group-heading": true, - "sysml-group-column": true, "sysml-group-key": true, - "sysml-row": true, "sysml-cell": true, "sysml-value": true, "sysml-element": true, - "sysml-separator": true, "sysml-list": true, "sysml-item": true, - "sysml-definitions": true, "sysml-entry": true, "sysml-term": true, "sysml-description": true, - "sysml-diagram": true, "sysml-caption": true, "sysml-link": true, "sysml-ref": true, - "mermaid": true, "dot": true, "plantuml": true, -} - -// renderFixtureHTML evaluates a fixture document and renders it as HTML. -func renderFixtureHTML(t *testing.T, path, name string, opts HTMLOptions) string { - t.Helper() - out, err := HTML(fixtureDocument(t, path, name), opts) - if err != nil { - t.Fatalf("render document %s as HTML: %v", name, err) - } - return out -} - -func checkGolden(t *testing.T, got, golden string) { - t.Helper() - if *update { - if err := os.WriteFile(golden, []byte(got), 0o644); err != nil { - t.Fatalf("update golden: %v", err) - } - return - } - want, err := os.ReadFile(golden) - if err != nil { - t.Fatalf("read golden (run with -update to create): %v", err) - } - if got != string(want) { - t.Errorf("rendered HTML differs from %s (run with -update after intentional changes)\ngot:\n%s", golden, got) - } -} - -// TestHTMLTelescopeReportGolden locks the standalone rendering of the -// telescope report: page shell, layered stylesheet, sections, tables, lists -// and escaped content. -func TestHTMLTelescopeReportGolden(t *testing.T) { - got := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), - "Observatory::MassReport", HTMLOptions{}) - checkGolden(t, got, filepath.Join("testdata", "telescope_report.golden.html")) -} - -// TestHTMLTelescopeReportFragmentGolden locks the fragment rendering with a -// title page, a table of contents and numbered sections. -func TestHTMLTelescopeReportFragmentGolden(t *testing.T) { - got := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), - "Observatory::MassReport", - HTMLOptions{Fragment: true, TitlePage: true, TOC: true, NumberSections: true}) - checkGolden(t, got, filepath.Join("testdata", "telescope_report.fragment.golden.html")) -} - -// TestHTMLMermaidScript checks a page asked to load Mermaid carries one -// script element after the document, a fragment none, and a default page none. -func TestHTMLMermaidScript(t *testing.T) { - path := filepath.Join("testdata", "telescope_report.sysml") - url := `https://cdn.example/mermaid.js?a=1&b="2"` - got := renderFixtureHTML(t, path, "Observatory::MassReport", HTMLOptions{MermaidScript: url}) - script := `` - if strings.Count(got, "") > strings.Index(got, script) || !strings.HasSuffix(got, script+"\n\n\n") { - t.Errorf("script must follow the document, before :\n%s", got) - } - if !strings.Contains(got, `
    `) {
    -		t.Errorf("diagram source must stay for the script to draw:\n%s", got)
    -	}
    -	for name, opts := range map[string]HTMLOptions{
    -		"default":  {},
    -		"fragment": {Fragment: true, MermaidScript: url},
    -	} {
    -		if out := renderFixtureHTML(t, path, "Observatory::MassReport", opts); strings.Contains(out, "`,
    -		`

    Telescope Mass Report

    `, - `
    mass`, - ``, - `1.5`, - `
  • `, - `
    `,
    -		``,
    -	} {
    -		if !strings.Contains(got, want) {
    -			t.Errorf("rendering does not contain %q\n%s", want, got)
    -		}
    -	}
    -	if strings.Contains(got, ""); open != closed {
    -		t.Errorf("%d sections opened, %d closed", open, closed)
    -	}
    -}
    -
    -// TestHTMLQuantityCells checks that a quantity cell keeps its unit in the text
    -// and carries magnitude and unit apart as data attributes.
    -func TestHTMLQuantityCells(t *testing.T) {
    -	got := renderFixtureHTML(t, filepath.Join("testdata", "quantity_report.sysml"),
    -		"Launcher::MassReport", HTMLOptions{})
    -	for _, want := range []string{
    -		`2290000 [kg]`,
    -		`500000 [g]`,
    -		`2290 [kg]`,
    -	} {
    -		if !strings.Contains(got, want) {
    -			t.Errorf("rendering does not contain %q\n%s", want, got)
    -		}
    -	}
    -	if strings.Count(got, `data-column="mass" data-value-kind="quantity"`) != 4 {
    -		t.Errorf("rendering does not carry four mass cells\n%s", got)
    -	}
    -}
    -
    -// TestHTMLDerivedQuantityCells checks that a quantity derived from other
    -// features renders as a quantity cell — unit in the text, magnitude and unit
    -// apart as data attributes — and reaches list items and definitions.
    -func TestHTMLDerivedQuantityCells(t *testing.T) {
    -	got := renderFixtureHTML(t, filepath.Join("testdata", "derived_report.sysml"),
    -		"Derived::MassReport", HTMLOptions{})
    -	for _, want := range []string{
    -		`2290000 [kg]`,
    -		`2280000 [kg]`,
    -		`3`,
    -		`light`,
    -		`458000 [kg]`,
    -		`
  • s1 2290000 [kg]
  • `, - `
    rocket
    `, - `
    4689000 [kg]
    `, - } { - if !strings.Contains(got, want) { - t.Errorf("rendering does not contain %q\n%s", want, got) - } - } - if strings.Count(got, `data-column="mass" data-value-kind="quantity"`) != 3 { - t.Errorf("rendering does not carry three mass cells\n%s", got) - } - if strings.Contains(got, `data-element="Derived::rocket::s2" data-element-kind="partUsage">s2`) { - t.Errorf("list holds s2, whose derived mass is not above the threshold\n%s", got) - } -} - -// TestHTMLDiagramForm writes every graph-shaped diagram as DOT when the render -// asks for it, leaves the table a table, and rejects a form that is not one. -func TestHTMLDiagramForm(t *testing.T) { - path := filepath.Join("testdata", "telescope_report.sysml") - got := renderFixtureHTML(t, path, "Observatory::MassReport", HTMLOptions{DiagramForm: view.FormDot}) - for _, want := range []string{ - `
    // view: Observatory::interconnectView` + "\n// kind: interconnection\n",
    -		"// layout: dot\ndigraph "Observatory::interconnectView" {\n",
    -		`
    // kind: state` + "\n",
    -		"// layout: dot\ndigraph {\n  graph [fontname="Helvetica", rankdir=LR];\n",
    -		`@startuml` + "\n' Observatory::interconnectView — interconnection rendering",
    -		`
    @startuml` + "\n' state rendering",
    -		"left to right direction\n",
    -		`
    `) { - t.Error("the default diagram form is not Mermaid") - } - _, err := HTML(fixtureDocument(t, path, "Observatory::MassReport"), HTMLOptions{DiagramForm: "svg"}) - var typed *Error - if !errors.As(err, &typed) || typed.Kind != ErrorUnknownForm || typed.DiagramForm != "svg" { - t.Fatalf("error = %v, want %s for svg", err, ErrorUnknownForm) - } -} - -// TestHTMLNoInlineStylesOrUnknownClasses checks the override contract on the -// markup: nothing carries a style attribute, and every class is one the -// documented vocabulary names. -func TestHTMLNoInlineStylesOrUnknownClasses(t *testing.T) { - for _, opts := range []HTMLOptions{{}, {Fragment: true, TitlePage: true, TOC: true, NumberSections: true}, {DiagramForm: view.FormDot}, {DiagramForm: view.FormPlantUML}} { - got := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), - "Observatory::MassReport", opts) - if strings.Contains(got, "style=\"") { - t.Error("rendering carries an inline style attribute, which reader CSS cannot override") - } - for _, match := range regexp.MustCompile(`class="([^"]*)"`).FindAllStringSubmatch(got, -1) { - for _, class := range strings.Fields(match[1]) { - if !htmlClassVocabulary[class] { - t.Errorf("rendering emits undocumented class %q", class) - } - } - } - } -} - -// TestHTMLIdentifiersAreAnchorsOnly checks every in-document link resolves to -// an identifier the document declares, and that no two nodes share one. -func TestHTMLIdentifiersAreAnchorsOnly(t *testing.T) { - got := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), - "Observatory::MassReport", HTMLOptions{TOC: true}) - ids := map[string]bool{} - for _, match := range regexp.MustCompile(`\sid="([^"]*)"`).FindAllStringSubmatch(got, -1) { - if ids[match[1]] { - t.Errorf("identifier %q is declared twice", match[1]) - } - ids[match[1]] = true - } - for _, match := range regexp.MustCompile(`href="#([^"]*)"`).FindAllStringSubmatch(got, -1) { - if !ids[match[1]] { - t.Errorf("link to #%s resolves to nothing; declared: %v", match[1], ids) - } - } -} - -// TestHTMLDefaultStylesheetIsOverridable checks the cascade contract of the -// default stylesheet: it is declared as a layer before use, every rule sits -// inside it, and every value it sets comes from a --sysml-* token. -func TestHTMLDefaultStylesheetIsOverridable(t *testing.T) { - css := DefaultStylesheet() - declaration := strings.Index(css, "@layer opensysml;") - block := strings.Index(css, "@layer opensysml {") - if declaration < 0 || block < 0 || declaration > block { - t.Fatalf("default stylesheet must declare @layer opensysml before using it:\n%s", css) - } - if strings.Count(css, "@layer") != 2 { - t.Errorf("default stylesheet writes more than the one layer:\n%s", css) - } - literal := regexp.MustCompile(`(#[0-9a-fA-F]{3}|[0-9.]+(px|rem|em|ch|vh|vw|pt|%)|["'])`) - for _, line := range strings.Split(css[block:], "\n") { - text := strings.TrimSpace(line) - property, value, ok := strings.Cut(text, ":") - if !ok || strings.HasPrefix(strings.TrimSpace(property), "--sysml-") { - continue - } - if literal.MatchString(value) { - t.Errorf("declaration %q hardcodes a value; take it from a --sysml-* token", text) - } - } -} - -// TestHTMLThemes checks every bundled theme is one block of the opensysml -// layer, scoped to the document, layered after the default sheet in a page, -// and that a name that is no theme is refused. -func TestHTMLThemes(t *testing.T) { - names := Themes() - if want := []string{"default", "modern", "print", "report"}; !slices.Equal(names, want) { - t.Fatalf("Themes() = %v, want %v", names, want) - } - plain, err := ThemeStylesheet("") - if err != nil || plain != DefaultStylesheet() { - t.Fatalf("an empty theme is the default sheet; got err %v", err) - } - if named, _ := ThemeStylesheet(DefaultTheme); named != plain { - t.Error("the default theme, named, is the default sheet") - } - for _, name := range names[1:] { - css, err := ThemeStylesheet(name) - if err != nil { - t.Fatalf("theme %s: %v", name, err) - } - if !strings.HasPrefix(css, DefaultStylesheet()) { - t.Errorf("theme %s does not start from the default sheet", name) - } - overrides := css[len(DefaultStylesheet()):] - if strings.Count(overrides, "@layer opensysml {") != 1 || strings.Contains(overrides, "@layer opensysml;") { - t.Errorf("theme %s must be exactly one block of the opensysml layer:\n%s", name, overrides) - } - if strings.Contains(strings.ToLower(overrides), "") != 2 { - t.Errorf("theme %s: default and theme share one style element, supplied CSS has its own:\n%s", name, got) - } - } - for _, bad := range []string{"fancy", "../document", "report.css", `themes\report`} { - _, err := ThemeStylesheet(bad) - var rendering *Error - if !errors.As(err, &rendering) || rendering.Kind != ErrorUnknownTheme || rendering.Actual != bad { - t.Errorf("ThemeStylesheet(%q) = %v, want an unknown-theme error", bad, err) - } - if err != nil && !strings.Contains(err.Error(), "default, modern, print, report") { - t.Errorf("ThemeStylesheet(%q) error does not list the themes: %v", bad, err) - } - } - doc := fixtureDocument(t, filepath.Join("testdata", "telescope_report.sysml"), "Observatory::MassReport") - if _, err := HTML(doc, HTMLOptions{Theme: "fancy"}); err == nil { - t.Error("HTML accepted a theme that does not exist") - } - if _, err := HTML(doc, HTMLOptions{Theme: "fancy", NoDefaultStylesheet: true}); err != nil { - t.Errorf("HTML checked a theme it was told to leave out: %v", err) - } - // print spells out web links however the scheme is spelled. - printCSS, err := ThemeStylesheet("print") - if err != nil { - t.Fatal(err) - } - for _, sel := range []string{`[href^="http:" i]::after`, `[href^="https:" i]::after`, `[href^="//"]::after`} { - if !strings.Contains(printCSS, ".sysml-link"+sel) { - t.Errorf("print theme lacks the link selector %s", sel) - } - } -} - -// TestHTMLSuppliedStylesheets checks supplied CSS lands after the default -// layer and unlayered, that a URL is linked rather than inlined, and that -// leaving the default out leaves the document unstyled. -func TestHTMLSuppliedStylesheets(t *testing.T) { - got := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), - "Observatory::MassReport", HTMLOptions{ - Stylesheets: []Stylesheet{ - {Content: ".sysml-document { color: rebeccapurple; }"}, - {Href: "https://example.test/theme.css"}, - }, - }) - layer := strings.Index(got, "@layer opensysml {") - supplied := strings.Index(got, "rebeccapurple") - link := strings.Index(got, ``) - if layer < 0 || supplied < layer || link < supplied { - t.Fatalf("supplied stylesheets must follow the default layer in order:\n%s", got) - } - if strings.Contains(got[supplied-200:supplied], "@layer") { - t.Error("supplied CSS is layered; it must stay unlayered to win on cascade origin") - } - bare := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), - "Observatory::MassReport", HTMLOptions{NoDefaultStylesheet: true}) - if strings.Contains(bare, ""}, ErrorUnsafeStylesheet}, - } { - _, err := HTML(document, HTMLOptions{Stylesheets: []Stylesheet{c.sheet}}) - var typed *Error - if !errors.As(err, &typed) || typed.Kind != c.kind { - t.Errorf("HTML(%+v) error = %v, want %s", c.sheet, err, c.kind) - } - } -} - -// TestHTMLEscaping checks no content can corrupt the structure: markup -// characters, quotes, closing tags and newlines in text, attributes and -// comments. -func TestHTMLEscaping(t *testing.T) { - for _, c := range []struct{ in, want string }{ - {`&plain`, `<b>&plain</b>`}, - {`quote " apostrophe '`, `quote " apostrophe '`}, - {``, `</script><script>alert(1)</script>`}, - {"two\nlines\r\nand\rmore", "two lines and more"}, - } { - if got := htmlText(c.in); got != c.want { - t.Errorf("htmlText(%q) = %q, want %q", c.in, got, c.want) - } - } - if got := attr("data-name", `a"b`); got != ` data-name="a"b"` { - t.Errorf("attr = %q", got) - } - if got := attr("data-name", ""); got != "" { - t.Errorf("an empty value writes no attribute, got %q", got) - } - if got := htmlComment("closes --> early\nand wraps"); got != "closes - -> early and wraps" { - t.Errorf("htmlComment = %q", got) - } -} - -// TestHTMLLinkSchemes checks a link is an href only for a scheme a document -// navigates to; a script URL is kept as data instead. -func TestHTMLLinkSchemes(t *testing.T) { - for _, target := range []string{"https://example.test/a", "mailto:a@example.test", "#anchor", "report.html", "./a:b/c"} { - if _, ok := navigableURL(target); !ok { - t.Errorf("navigableURL(%q) = false, want true", target) - } - } - for _, target := range []string{"javascript:alert(1)", "JavaScript:alert(1)", "data:text/html, +` + +// HTML renders an evaluated document as deterministic, semantic HTML: an +//
    holding nested
    elements, real tables with
    per group. +func (w *htmlWriter) writeTable(node docir.Content, id string) { + columns := node.Columns() + names := make([]string, 0, len(columns)) + for _, column := range columns { + names = append(names, column.Name()) + } + if len(names) == 0 { + names = []string{elementColumn} + } + w.b.WriteString("
    and +//
    ,
      /
        lists,
        definitions,
        formulas holding +// their LaTeX in display delimiters and
        diagrams carrying their +// source in the chosen diagram form. +// Every node keeps its model facts in sysml- classes and data- attributes — +// content kind, declared name, query, group column, row element and its kind, +// projected column, value kind, reference target, diagram kind, direction and +// palette — +// and every value is escaped so no content can corrupt the structure. +func HTML(document *docir.Document, opts HTMLOptions) (string, error) { + if document == nil { + return "", &Error{Kind: ErrorNilDocument} + } + for _, sheet := range opts.Stylesheets { + if err := sheet.Check(); err != nil { + return "", err + } + } + form, err := diagramForm(opts.DiagramForm) + if err != nil { + return "", err + } + var base string + if !opts.NoDefaultStylesheet { + if base, err = ThemeStylesheet(opts.Theme); err != nil { + return "", err + } + } + w := &htmlWriter{opts: opts, base: base, form: form, ids: contentIDs(document)} + w.numbers = sectionNumbers(document.Content(), nil, "", map[string]string{}) + if err := w.writeDocument(document); err != nil { + return "", err + } + if w.diagrams < len(opts.DiagramImages) { + return "", &Error{Kind: ErrorSurplusDiagramImages, Actual: strconv.Itoa(len(opts.DiagramImages)), Count: w.diagrams} + } + return w.b.String(), nil +} + +// Check rejects a stylesheet that is neither content nor URL, both at once, or +// whose content would close the \n") + } + w.b.WriteString(StylesheetMarkup(w.opts.Stylesheets)) + w.b.WriteString("\n\n") +} + +// StylesheetMarkup is the head markup attaching sheets in order: a linked +// sheet as a element, an inline one as a \n") + } + return b.String() +} + +// writeTitle writes the document title: in a page of its own when a title page +// was asked for, and as the opening heading otherwise. +func (w *htmlWriter) writeTitle(title string) { + heading := "

        " + htmlText(title) + "

        \n" + if w.opts.TitlePage { + w.b.WriteString("
        \n" + heading + "
        \n") + return + } + w.b.WriteString(heading) +} + +// outlineEntry is one section of the table of contents. +type outlineEntry struct { + id string + title string + number string + children []outlineEntry +} + +// outline collects the section tree with each section's identifier and number. +func (w *htmlWriter) outline(nodes []docir.Content, path []step) []outlineEntry { + var entries []outlineEntry + for i, node := range nodes { + if node.Kind() != docir.ContentSection { + continue + } + nested := child(path, node.Name(), i) + entries = append(entries, outlineEntry{ + id: w.ids[pathKey(nested)], + title: node.Title(), + number: w.numbers[pathKey(nested)], + children: w.outline(node.Children(), nested), + }) + } + return entries +} + +// sectionNumbers numbers every section hierarchically, keyed by occurrence path. +func sectionNumbers(nodes []docir.Content, path []step, prefix string, into map[string]string) map[string]string { + count := 0 + for i, node := range nodes { + if node.Kind() != docir.ContentSection { + continue + } + count++ + nested := child(path, node.Name(), i) + number := prefix + strconv.Itoa(count) + into[pathKey(nested)] = number + sectionNumbers(node.Children(), nested, number+".", into) + } + return into +} + +// writeTOC writes the table of contents as nested ordered lists, each entry +// linking to its section. +func (w *htmlWriter) writeTOC(entries []outlineEntry) { + if len(entries) == 0 { + return + } + w.b.WriteString("\n") +} + +func (w *htmlWriter) writeTOCList(entries []outlineEntry) { + w.b.WriteString("
          \n") + for _, entry := range entries { + w.b.WriteString("
        1. ") + w.b.WriteString("") + if w.opts.NumberSections { + w.b.WriteString("" + htmlText(entry.number) + " ") + } + w.b.WriteString(htmlText(entry.title) + "") + if len(entry.children) > 0 { + w.b.WriteString("\n") + w.writeTOCList(entry.children) + } + w.b.WriteString("
        2. \n") + } + w.b.WriteString("
        \n") +} + +// writeContent writes one content node, with level the heading level a section +// at this depth writes, path the occurrence path of its parent and index its +// place among its siblings. +func (w *htmlWriter) writeContent(node docir.Content, path []step, index, level int) error { + nested := child(path, node.Name(), index) + id := w.ids[pathKey(nested)] + switch node.Kind() { + case docir.ContentSection: + return w.writeSection(node, nested, id, level) + case docir.ContentParagraph: + w.b.WriteString("

        " + + w.inlineRuns(node.Runs()) + "

        \n") + return nil + case docir.ContentTable: + w.writeTable(node, id) + return nil + case docir.ContentList: + w.writeList(node, id) + return nil + case docir.ContentDefinitions: + w.writeDefinitions(node, id) + return nil + case docir.ContentFormula: + w.writeFormula(node, id) + return nil + case docir.ContentDiagram: + return w.writeDiagram(node, id) + default: + return &Error{Kind: ErrorUnknownContent, Content: node.Name(), Actual: string(node.Kind())} + } +} + +// writeSection writes one section and its children, numbering the heading when +// numbering was asked for. Heading levels saturate at 6, as HTML has no more. +func (w *htmlWriter) writeSection(node docir.Content, path []step, id string, level int) error { + w.b.WriteString("
        \n") + tag := "h" + strconv.Itoa(min(level, 6)) + w.b.WriteString("<" + tag + ">") + if w.opts.NumberSections { + w.b.WriteString("" + htmlText(w.numbers[pathKey(path)]) + " ") + } + w.b.WriteString(htmlText(node.Title()) + "\n") + for i, child := range node.Children() { + if err := w.writeContent(child, path, i, level+1); err != nil { + return err + } + } + w.b.WriteString("
        \n") + return nil +} + +// writeTable writes one table: its caption, a header row of projected column +// names, and one row per query row. A query without projected columns gets a +// single "element" column, and a grouped table one
    \n") + if node.Caption() != "" { + w.b.WriteString("\n") + } + w.writeTableHead(names) + if node.GroupBy() != "" { + for _, group := range node.Groups() { + w.b.WriteString("\n") + w.b.WriteString("\n") + w.writeRows(group.Rows(), names, len(columns)) + w.b.WriteString("\n") + } + w.b.WriteString("
    " + htmlText(node.Caption()) + "
    " + + "" + htmlText(node.GroupBy()) + ": " + + "" + htmlText(group.Key()) + "
    \n") + return + } + w.b.WriteString("\n") + w.writeRows(node.Rows(), names, len(columns)) + w.b.WriteString("\n\n") +} + +func (w *htmlWriter) writeTableHead(names []string) { + w.b.WriteString("\n\n") + for _, name := range names { + w.b.WriteString("" + htmlText(name) + "\n") + } + w.b.WriteString("\n\n") +} + +// writeRows writes one row per query row, each carrying the element it selected +// and its kind. A row of a table without projected columns is its element alone. +func (w *htmlWriter) writeRows(rows []queryexec.Row, names []string, columns int) { + for _, row := range rows { + w.b.WriteString("\n") + if columns == 0 { + w.writeCell([]queryexec.Value{row.Element()}, elementColumn) + w.b.WriteString(rowEnd) + continue + } + cells := row.Cells() + for i := 0; i < columns; i++ { + var values []queryexec.Value + if i < len(cells) { + values = cells[i].Values() + } + w.writeCell(values, names[i]) + } + w.b.WriteString(rowEnd) + } +} + +// writeCell writes one projected cell: every value individually addressable, +// with the punctuation joining them an element of its own so a theme can hide +// or replace it. +func (w *htmlWriter) writeCell(values []queryexec.Value, column string) { + w.b.WriteString("") + for i, value := range values { + if i > 0 { + w.b.WriteString(", ") + } + w.writeValue(value) + } + w.b.WriteString("\n") +} + +func (w *htmlWriter) writeValue(value queryexec.Value) { + classes := "sysml-value" + if _, ok := value.Element(); ok { + classes += " sysml-element" + } + if _, _, ok := value.Object(); ok { + classes += " sysml-object" + } + if _, ok := value.Verdict(); ok { + classes += " sysml-verdict" + } + if _, ok := value.State(); ok { + classes += " sysml-state" + } + if _, ok := value.Event(); ok { + classes += " sysml-event" + } + w.b.WriteString("" + htmlText(valueText(value)) + spanClose) +} + +// quantityAttrs carries a quantity's magnitude and unit apart, so a theme or a +// script reads them without parsing the cell text. +func quantityAttrs(value queryexec.Value) string { + quantity, ok := value.Quantity() + if !ok { + return "" + } + magnitude, _ := value.Magnitude() + return attr("data-magnitude", valueText(magnitude)) + attr("data-unit", quantity.Unit.String()) +} + +// writeList writes one bullet or numbered list, one item per query row, each +// item carrying the element its row selected. +func (w *htmlWriter) writeList(node docir.Content, id string) { + tag := "ul" + if node.Style() == docir.ListNumber { + tag = "ol" + } + w.b.WriteString("<" + tag + " class=\"sysml-list\"" + attr("id", id) + " data-content=\"list\"" + + attr(attrName, node.Name()) + attr(attrQuery, node.Query()) + ">\n") + for _, item := range node.Items() { + w.b.WriteString("
  • " + + w.inlineRuns(item.Runs()) + "
  • \n") + } + w.b.WriteString("\n") +} + +// writeDefinitions writes one description list, one term/description group +// per query row, each group carrying the element its row selected. +func (w *htmlWriter) writeDefinitions(node docir.Content, id string) { + w.b.WriteString("
    \n") + for _, entry := range node.Definitions() { + w.b.WriteString("
    \n" + + "
    " + w.inlineRuns(entry.Term()) + "
    \n" + + "
    " + w.inlineRuns(entry.Description()) + "
    \n" + + "
    \n") + } + w.b.WriteString("
    \n") +} + +// writeFormula writes one formula as a figure: its typeset HTML when the +// render carries it, else its LaTeX, escaped, in display delimiters for a math +// script to typeset; then its caption. +func (w *htmlWriter) writeFormula(node docir.Content, id string) { + w.b.WriteString("
    \n") + math := displayMathHTML(node.Source()) + if typeset, ok := w.opts.Math[displayFormula(node.Source())]; ok { + math = typeset + } + w.b.WriteString("
    " + math + "
    \n") + if node.Caption() != "" { + w.b.WriteString("
    " + htmlText(node.Caption()) + "
    \n") + } + w.b.WriteString("
    \n") +} + +// Delimiters a math script recognizes inline and display LaTeX by. +const ( + inlineMathOpen = `\(` + inlineMathClose = `\)` + displayMathOpen = `\[` + displayMathClose = `\]` +) + +// inlineMathHTML writes inline LaTeX, escaped and trimmed with its newlines +// folded, in inline delimiters. +func inlineMathHTML(source string) string { + return inlineMathOpen + htmlText(strings.TrimSpace(source)) + inlineMathClose +} + +// displayMathHTML writes display LaTeX, escaped and trimmed with its line +// breaks kept, in display delimiters. +func displayMathHTML(source string) string { + return displayMathOpen + html.EscapeString(strings.TrimSpace(newlineNormalizer.Replace(source))) + displayMathClose +} + +// writeDiagram writes one diagram as a figure: a table-kind view as a table, +// every other supported kind as the image drawn for it ahead of the render, +// or else as its source in the render's diagram form — Mermaid, which a loaded +// Mermaid script draws, or DOT or PlantUML — shown as text. +func (w *htmlWriter) writeDiagram(node docir.Content, id string) error { + return w.writeFigure(id, node.Name(), node.Caption(), node.Rendering(), figureOptions(node, w.opts.Unplaced)) +} + +func (w *htmlWriter) writeFigure(id, name, caption string, rendering *view.Rendering, options view.Options) error { + if rendering == nil { + return &Error{Kind: ErrorMissingRendering, Content: name} + } + if rendering.Kind != view.KindTable && !rendering.Kind.Supported() { + return &Error{Kind: ErrorUnrenderableDiagram, Content: name, Actual: string(rendering.Kind), Form: "HTML"} + } + var source string + if rendering.Kind != view.KindTable { + var err error + if source, err = diagramSource(name, rendering, options, w.form); err != nil { + return err + } + } + w.b.WriteString("
    \n") + switch { + case rendering.Kind == view.KindTable: + w.writeRenderingTable(rendering) + case w.diagramImage() != "": + alt := caption + if alt == "" { + alt = name + } + w.b.WriteString("\n") + w.diagrams++ + default: + w.b.WriteString("" + html.EscapeString(source) + "
    \n") + w.diagrams++ + if w.form == view.FormMermaid { + w.mermaid = append(w.mermaid, source) + } + } + if caption != "" { + w.b.WriteString("
    " + htmlText(caption) + "
    \n") + } + w.b.WriteString("\n") + return nil +} + +// diagramImage is the image drawn for the graph-shaped diagram about to be +// written, empty when its source is to be shown instead. +func (w *htmlWriter) diagramImage() string { + if w.diagrams < len(w.opts.DiagramImages) { + return w.opts.DiagramImages[w.diagrams] + } + return "" +} + +// writeRenderingTable writes a table-kind view's cells, keeping the notices the +// rendering could not represent as comments so none is lost. +func (w *htmlWriter) writeRenderingTable(rendering *view.Rendering) { + for _, notice := range rendering.Notices { + w.b.WriteString("\n") + } + if rendering.Empty() { + w.b.WriteString("

    " + htmlText(rendering.EmptyReason()) + "

    \n") + return + } + columns := rendering.Columns + if len(columns) == 0 { + columns = view.TableColumns() + } + w.b.WriteString("\n") + w.writeTableHead(columns) + w.b.WriteString("\n") + for _, row := range rendering.Rows { + w.b.WriteString("\n") + for i, name := range columns { + cell := "" + if i < len(row) { + cell = row[i] + } + w.b.WriteString("\n") + } + w.b.WriteString(rowEnd) + } + w.b.WriteString("\n
    " + htmlText(cell) + "
    \n") +} + +// inlineRuns renders text runs joined by single spaces, each by its kind: +// plain runs as prose, styled runs in , or , math runs as +// delimited LaTeX in a math span, links and references as anchors, and +// element-valued runs carrying their element. +func (w *htmlWriter) inlineRuns(runs []docir.TextRun) string { + parts := make([]string, len(runs)) + for i, run := range runs { + parts[i] = w.runHTML(run) + } + return strings.Join(parts, " ") +} + +func (w *htmlWriter) runHTML(run docir.TextRun) string { + switch run.Kind() { + case docir.RunEmphasis: + return "" + htmlText(run.Text()) + "" + case docir.RunStrong: + return "" + htmlText(run.Text()) + "" + case docir.RunCode: + return "" + htmlText(run.Text()) + "" + case docir.RunMath: + if typeset, ok := w.opts.Math[inlineFormula(run.Text())]; ok { + return "" + typeset + spanClose + } + return "" + inlineMathHTML(run.Text()) + spanClose + case docir.RunLink: + if target, ok := navigableURL(run.Target()); ok { + return "" + htmlText(run.Text()) + "" + } + // A scheme a document must not navigate to is kept as data, not as a link. + return "" + htmlText(run.Text()) + "" + case docir.RunRef: + return "" + htmlText(run.Text()) + "" + default: + return htmlText(run.Text()) + } +} + +// htmlRefDestination maps a reference run to its destination: an in-document +// anchor, or a relative link into another document's HTML file. +func htmlRefDestination(run docir.TextRun) string { + if run.TargetDocument() == "" { + return "#" + run.Target() + } + destination := DocumentHTMLFileName(run.TargetDocument()) + if run.Target() != "" { + destination += "#" + run.Target() + } + return destination +} + +// DocumentHTMLFileName derives the deterministic HTML file name of a rendered +// document from its fully-qualified name, escaped as anchors are so distinct +// documents never collide. +func DocumentHTMLFileName(fqn string) string { + return documentFileName(fqn, ".html") +} + +// contentIDs maps every content node's occurrence path to the identifier the +// document addresses it by: the stable anchor the IR gave it, and for a +// section without one the anchor its named path derives, made unique so no two +// nodes share an identifier. +func contentIDs(document *docir.Document) map[string]string { + reserved := reservedIDs(document.Content(), nil, map[string]bool{}) + anchors := anchorIDs(document.Content(), map[string]bool{}) + ids, used := map[string]string{}, map[string]bool{} + var assign func(nodes []docir.Content, path []step) + assign = func(nodes []docir.Content, path []step) { + for i, node := range nodes { + nested := child(path, node.Name(), i) + id := node.Anchor() + if id == "" && node.Kind() == docir.ContentSection { + id = uniqueID(derivedID(nested), reserved, used, anchors) + } + if id != "" { + ids[pathKey(nested)] = id + used[id] = true + } + assign(node.Children(), nested) + } + } + assign(document.Content(), nil) + return ids +} + +// reservedIDs collects every identifier the document may address a node by, so +// a derived one is never made to collide with an anchor emitted later. +func reservedIDs(nodes []docir.Content, path []step, into map[string]bool) map[string]bool { + for i, node := range nodes { + nested := child(path, node.Name(), i) + into[derivedID(nested)] = true + if node.Anchor() != "" { + into[node.Anchor()] = true + } + reservedIDs(node.Children(), nested, into) + } + return into +} + +// anchorIDs collects the anchors the IR gave nodes, which a derived identifier +// must leave to them however early it is allocated. +func anchorIDs(nodes []docir.Content, into map[string]bool) map[string]bool { + for _, node := range nodes { + if node.Anchor() != "" { + into[node.Anchor()] = true + } + anchorIDs(node.Children(), into) + } + return into +} + +// derivedID is the identifier a path derives, which is the anchor of its +// names; a path of anonymous nodes alone derives none, and is numbered from +// the fallback instead. +func derivedID(path []step) string { + names := make([]string, 0, len(path)) + for _, s := range path { + names = append(names, s.name) + } + if anchor := docir.AnchorFor(names); strings.Trim(anchor, "-") != "" { + return anchor + } + return anonymousID +} + +// anonymousID is the identifier a section with no named ancestry is numbered +// from, since it has no name to derive one. +const anonymousID = "section" + +// uniqueID is the derived identifier itself when no other node has taken it or +// owns it as an anchor, and the first free numbered variant otherwise: two +// anonymous siblings derive the same path. +func uniqueID(derived string, reserved, used, anchors map[string]bool) string { + if !used[derived] && !anchors[derived] { + return derived + } + for n := 2; ; n++ { + candidate := derived + "-" + strconv.Itoa(n) + if !reserved[candidate] && !used[candidate] { + return candidate + } + } +} + +// step is one node of an occurrence path: the node's declared name, empty when +// anonymous, and its place among its siblings, which tells two anonymous +// siblings apart. +type step struct { + name string + index int +} + +// pathKey keys an occurrence path by a separator no name can contain. +func pathKey(path []step) string { + var b strings.Builder + for _, s := range path { + b.WriteString(strconv.Itoa(s.index)) + b.WriteByte(0) + b.WriteString(s.name) + b.WriteByte(0) + } + return b.String() +} + +// child extends an occurrence path without sharing its backing array. +func child(path []step, name string, index int) []step { + return append(append(make([]step, 0, len(path)+1), path...), step{name: name, index: index}) +} + +// attr writes one attribute, escaped, and nothing for an empty value. +func attr(name, value string) string { + if value == "" { + return "" + } + return " " + name + "=\"" + html.EscapeString(value) + "\"" +} + +// elementAttrs writes the element behind a row, item or value (an object's declaration, +// a verdict's assertion, a state's declaration), plus an object's identity, a verdict's +// status and carrier path, a state's machine and path, or an event's kind and instant. +func elementAttrs(value queryexec.Value) string { + objectAttrs := "" + if inst, _, ok := value.Object(); ok { + objectAttrs = attr(dataObject, "#"+strconv.FormatInt(inst.ID, 10)) + } + if verdict, ok := value.Verdict(); ok { + if carrier, held := verdict.Carrier(); held && carrier != nil { + objectAttrs = attr(dataObject, "#"+strconv.FormatInt(carrier.ID, 10)) + } + objectAttrs += attr("data-verdict", verdict.Status().String()) + attr("data-path", verdict.Path()) + } + if state, ok := value.State(); ok { + if inst, _ := state.Object(); inst != nil { + objectAttrs = attr(dataObject, "#"+strconv.FormatInt(inst.ID, 10)) + } + objectAttrs += attr("data-machine", state.Machine()) + attr("data-state", state.Path()) + attr("data-region", state.Region()) + } + if event, ok := value.Event(); ok { + if inst, _ := event.Object(); inst != nil { + objectAttrs = attr(dataObject, "#"+strconv.FormatInt(inst.ID, 10)) + } + objectAttrs += attr("data-event-kind", event.Kind()) + attr("data-time", strconv.FormatFloat(event.At(), 'g', -1, 64)) + } + element := value.Declaration() + if element == nil { + return objectAttrs + } + return objectAttrs + attr("data-element", symbols.FQNOf(element)) + attr("data-element-kind", element.Kind.String()) +} + +// navigableSchemes are the URL schemes a rendered document links to; every +// other scheme, javascript: above all, is not navigated to from a document. +var navigableSchemes = map[string]bool{ + "http": true, "https": true, "mailto": true, "ftp": true, "ftps": true, "tel": true, +} + +// navigableURL reports whether a link target may be an href: a relative URL, +// a fragment, or an absolute URL in a navigable scheme. +func navigableURL(target string) (string, bool) { + clean := strings.ReplaceAll(newlineNormalizer.Replace(target), "\n", "") + colon := strings.IndexByte(clean, ':') + if colon < 0 { + return clean, true + } + if stop := strings.IndexAny(clean, "/?#"); stop >= 0 && stop < colon { + return clean, true + } + return clean, navigableSchemes[strings.ToLower(clean[:colon])] +} + +// sharedValueKind is the value kind of a cell whose values all have one, +// empty for an empty cell or a cell of mixed kinds. +func sharedValueKind(values []queryexec.Value) string { + if len(values) == 0 { + return "" + } + kind := values[0].Kind() + for _, value := range values[1:] { + if value.Kind() != kind { + return "" + } + } + return string(kind) +} + +// htmlText escapes prose for any position in the document, folding newlines to +// spaces since paragraph structure comes from the document, not run content. +func htmlText(text string) string { + return html.EscapeString(strings.ReplaceAll(newlineNormalizer.Replace(text), "\n", " ")) +} + +// htmlComment escapes a comment, where no escaping mechanism exists: the +// sequence that would close it early is broken up and newlines fold to spaces. +func htmlComment(text string) string { + return strings.NewReplacer("--", "- -", "\r\n", " ", "\r", " ", "\n", " ").Replace(text) +} diff --git a/internal/core/docrender/html_crossdoc_test.go b/internal/doc/docrender/html_crossdoc_test.go similarity index 100% rename from internal/core/docrender/html_crossdoc_test.go rename to internal/doc/docrender/html_crossdoc_test.go diff --git a/internal/core/docrender/html_diagram_test.go b/internal/doc/docrender/html_diagram_test.go similarity index 99% rename from internal/core/docrender/html_diagram_test.go rename to internal/doc/docrender/html_diagram_test.go index 095edb5b2a..098322b1ee 100644 --- a/internal/core/docrender/html_diagram_test.go +++ b/internal/doc/docrender/html_diagram_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/view" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" ) func renderedFigure(t *testing.T, caption string, rendering *view.Rendering, direction view.Direction) string { diff --git a/internal/doc/docrender/html_test.go b/internal/doc/docrender/html_test.go new file mode 100644 index 0000000000..f1c86d9fa3 --- /dev/null +++ b/internal/doc/docrender/html_test.go @@ -0,0 +1,748 @@ +package docrender + +import ( + "errors" + "html" + "io/fs" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/view" +) + +// htmlClassVocabulary is the documented class surface: a theme may rely on +// these names, and the backend emits no others. +var htmlClassVocabulary = map[string]bool{ + "sysml-document": true, "sysml-title": true, "sysml-title-page": true, + "sysml-toc": true, "sysml-toc-title": true, + "sysml-section": true, "sysml-section-number": true, "sysml-paragraph": true, + "sysml-table": true, "sysml-group": true, "sysml-group-heading": true, + "sysml-group-column": true, "sysml-group-key": true, + "sysml-row": true, "sysml-cell": true, "sysml-value": true, "sysml-element": true, "sysml-object": true, + "sysml-separator": true, "sysml-list": true, "sysml-item": true, + "sysml-definitions": true, "sysml-entry": true, "sysml-term": true, "sysml-description": true, + "sysml-diagram": true, "sysml-caption": true, "sysml-link": true, "sysml-ref": true, + "sysml-formula": true, "sysml-math": true, + "mermaid": true, "dot": true, "plantuml": true, +} + +// renderFixtureHTML evaluates a fixture document and renders it as HTML. +func renderFixtureHTML(t *testing.T, path, name string, opts HTMLOptions) string { + t.Helper() + out, err := HTML(fixtureDocument(t, path, name), opts) + if err != nil { + t.Fatalf("render document %s as HTML: %v", name, err) + } + return out +} + +func checkGolden(t *testing.T, got, golden string) { + t.Helper() + if *update { + if err := os.WriteFile(golden, []byte(got), 0o644); err != nil { + t.Fatalf("update golden: %v", err) + } + return + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden (run with -update to create): %v", err) + } + if got != string(want) { + t.Errorf("rendered HTML differs from %s (run with -update after intentional changes)\ngot:\n%s", golden, got) + } +} + +// TestHTMLTelescopeReportGolden locks the standalone rendering of the +// telescope report: page shell, layered stylesheet, sections, tables, lists +// and escaped content. +func TestHTMLTelescopeReportGolden(t *testing.T) { + got := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), + "Observatory::MassReport", HTMLOptions{}) + checkGolden(t, got, filepath.Join("testdata", "telescope_report.golden.html")) +} + +// TestHTMLTelescopeReportFragmentGolden locks the fragment rendering with a +// title page, a table of contents and numbered sections. +func TestHTMLTelescopeReportFragmentGolden(t *testing.T) { + got := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), + "Observatory::MassReport", + HTMLOptions{Fragment: true, TitlePage: true, TOC: true, NumberSections: true}) + checkGolden(t, got, filepath.Join("testdata", "telescope_report.fragment.golden.html")) +} + +// TestHTMLMermaidScript checks a page asked to load Mermaid carries the script +// element after the document, configured to draw the page's charts whatever +// their size, a fragment none, and a default page none. +func TestHTMLMermaidScript(t *testing.T) { + path := filepath.Join("testdata", "telescope_report.sysml") + url := `https://cdn.example/mermaid.js?a=1&b="2"` + got := renderFixtureHTML(t, path, "Observatory::MassReport", HTMLOptions{MermaidScript: url}) + script := `` + if strings.Count(got, "mermaid\.initialize\(\{maxTextSize: (\d+), maxEdges: (\d+)\}\);`) + limits := config.FindStringSubmatch(got) + if limits == nil { + t.Fatalf("page lacks the Mermaid configuration:\n%s", got) + } + if strings.Index(got, "") > strings.Index(got, script) || !strings.HasSuffix(got, script+"\n"+limits[0]+"\n\n\n") { + t.Errorf("script and configuration must follow the document, before :\n%s", got) + } + if !strings.Contains(got, `
    `) {
    +		t.Errorf("diagram source must stay for the script to draw:\n%s", got)
    +	}
    +	textSize, _ := strconv.Atoi(limits[1])
    +	edges, _ := strconv.Atoi(limits[2])
    +	arrows := regexp.MustCompile(`(?m)^\s*\S+ (-->|---|-\.->)`)
    +	for _, chart := range regexp.MustCompile(`(?s)
    (.*?)
    `).FindAllStringSubmatch(got, -1) { + source := html.UnescapeString(chart[1]) + drawn := len(arrows.FindAllString(source, -1)) + if drawn == 0 || len(source) >= textSize || drawn >= edges { + t.Errorf("limits %s, %s do not cover a chart of %d bytes and %d edges", limits[1], limits[2], len(source), drawn) + } + } + if textSize > 50000 || edges > 500 { + t.Errorf("limits %s, %s are not sized to the fixture's small charts", limits[1], limits[2]) + } + if out := renderFixtureHTML(t, path, "Observatory::MassReport", HTMLOptions{MermaidScript: url, DiagramForm: view.FormDot}); strings.Contains(out, "mermaid.initialize") { + t.Errorf("a page drawing no Mermaid chart configures Mermaid:\n%s", out) + } + for name, opts := range map[string]HTMLOptions{ + "default": {}, + "fragment": {Fragment: true, MermaidScript: url}, + } { + if out := renderFixtureHTML(t, path, "Observatory::MassReport", opts); strings.Contains(out, "`, + `

    Telescope Mass Report

    `, + `
    mass`, + ``, + `1.5`, + `
  • `, + `
    `,
    +		``,
    +	} {
    +		if !strings.Contains(got, want) {
    +			t.Errorf("rendering does not contain %q\n%s", want, got)
    +		}
    +	}
    +	if strings.Contains(got, ""); open != closed {
    +		t.Errorf("%d sections opened, %d closed", open, closed)
    +	}
    +}
    +
    +// TestHTMLQuantityCells checks that a quantity cell keeps its unit in the text
    +// and carries magnitude and unit apart as data attributes.
    +func TestHTMLQuantityCells(t *testing.T) {
    +	got := renderFixtureHTML(t, filepath.Join("testdata", "quantity_report.sysml"),
    +		"Launcher::MassReport", HTMLOptions{})
    +	for _, want := range []string{
    +		`2290000 [kg]`,
    +		`500000 [g]`,
    +		`2290 [kg]`,
    +	} {
    +		if !strings.Contains(got, want) {
    +			t.Errorf("rendering does not contain %q\n%s", want, got)
    +		}
    +	}
    +	if strings.Count(got, `data-column="mass" data-value-kind="quantity"`) != 4 {
    +		t.Errorf("rendering does not carry four mass cells\n%s", got)
    +	}
    +}
    +
    +// TestHTMLDerivedQuantityCells checks that a quantity derived from other
    +// features renders as a quantity cell — unit in the text, magnitude and unit
    +// apart as data attributes — and reaches list items and definitions.
    +func TestHTMLDerivedQuantityCells(t *testing.T) {
    +	got := renderFixtureHTML(t, filepath.Join("testdata", "derived_report.sysml"),
    +		"Derived::MassReport", HTMLOptions{})
    +	for _, want := range []string{
    +		`2290000 [kg]`,
    +		`2280000 [kg]`,
    +		`3`,
    +		`light`,
    +		`458000 [kg]`,
    +		`
  • s1 2290000 [kg]
  • `, + `
    rocket
    `, + `
    4689000 [kg]
    `, + } { + if !strings.Contains(got, want) { + t.Errorf("rendering does not contain %q\n%s", want, got) + } + } + if strings.Count(got, `data-column="mass" data-value-kind="quantity"`) != 3 { + t.Errorf("rendering does not carry three mass cells\n%s", got) + } + if strings.Contains(got, `data-element="Derived::rocket::s2" data-element-kind="partUsage">s2`) { + t.Errorf("list holds s2, whose derived mass is not above the threshold\n%s", got) + } +} + +// TestHTMLDiagramForm writes every graph-shaped diagram as DOT when the render +// asks for it, leaves the table a table, and rejects a form that is not one. +func TestHTMLDiagramForm(t *testing.T) { + path := filepath.Join("testdata", "telescope_report.sysml") + got := renderFixtureHTML(t, path, "Observatory::MassReport", HTMLOptions{DiagramForm: view.FormDot}) + for _, want := range []string{ + `
    // view: Observatory::interconnectView` + "\n// kind: interconnection\n",
    +		"// layout: dot\ndigraph "Observatory::interconnectView" {\n",
    +		`
    // kind: state` + "\n",
    +		"// layout: dot\ndigraph {\n  graph [fontname="Helvetica", rankdir=LR];\n",
    +		`@startuml` + "\n' Observatory::interconnectView — interconnection rendering",
    +		`
    @startuml` + "\n' state rendering",
    +		"left to right direction\n",
    +		`
    `) { + t.Error("the default diagram form is not Mermaid") + } + _, err := HTML(fixtureDocument(t, path, "Observatory::MassReport"), HTMLOptions{DiagramForm: "svg"}) + var typed *Error + if !errors.As(err, &typed) || typed.Kind != ErrorUnknownForm || typed.DiagramForm != "svg" { + t.Fatalf("error = %v, want %s for svg", err, ErrorUnknownForm) + } +} + +// TestHTMLNoInlineStylesOrUnknownClasses checks the override contract on the +// markup: nothing carries a style attribute, and every class is one the +// documented vocabulary names. +func TestHTMLNoInlineStylesOrUnknownClasses(t *testing.T) { + for _, c := range []struct { + fixture, document string + opts HTMLOptions + }{ + {"telescope_report.sysml", "Observatory::MassReport", HTMLOptions{}}, + {"telescope_report.sysml", "Observatory::MassReport", HTMLOptions{Fragment: true, TitlePage: true, TOC: true, NumberSections: true}}, + {"telescope_report.sysml", "Observatory::MassReport", HTMLOptions{DiagramForm: view.FormDot}}, + {"telescope_report.sysml", "Observatory::MassReport", HTMLOptions{DiagramForm: view.FormPlantUML}}, + {"math_report.sysml", "Optics::OpticsReport", HTMLOptions{}}, + } { + got := renderFixtureHTML(t, filepath.Join("testdata", c.fixture), c.document, c.opts) + if strings.Contains(got, "style=\"") { + t.Error("rendering carries an inline style attribute, which reader CSS cannot override") + } + for _, match := range regexp.MustCompile(`class="([^"]*)"`).FindAllStringSubmatch(got, -1) { + for _, class := range strings.Fields(match[1]) { + if !htmlClassVocabulary[class] { + t.Errorf("rendering emits undocumented class %q", class) + } + } + } + } +} + +// TestHTMLIdentifiersAreAnchorsOnly checks every in-document link resolves to +// an identifier the document declares, and that no two nodes share one. +func TestHTMLIdentifiersAreAnchorsOnly(t *testing.T) { + got := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), + "Observatory::MassReport", HTMLOptions{TOC: true}) + ids := map[string]bool{} + for _, match := range regexp.MustCompile(`\sid="([^"]*)"`).FindAllStringSubmatch(got, -1) { + if ids[match[1]] { + t.Errorf("identifier %q is declared twice", match[1]) + } + ids[match[1]] = true + } + for _, match := range regexp.MustCompile(`href="#([^"]*)"`).FindAllStringSubmatch(got, -1) { + if !ids[match[1]] { + t.Errorf("link to #%s resolves to nothing; declared: %v", match[1], ids) + } + } +} + +// TestHTMLDefaultStylesheetIsOverridable checks the cascade contract of the +// default stylesheet: it is declared as a layer before use, every rule sits +// inside it, and every value it sets comes from a --sysml-* token. +func TestHTMLDefaultStylesheetIsOverridable(t *testing.T) { + css := DefaultStylesheet() + declaration := strings.Index(css, "@layer opensysml;") + block := strings.Index(css, "@layer opensysml {") + if declaration < 0 || block < 0 || declaration > block { + t.Fatalf("default stylesheet must declare @layer opensysml before using it:\n%s", css) + } + if strings.Count(css, "@layer") != 2 { + t.Errorf("default stylesheet writes more than the one layer:\n%s", css) + } + literal := regexp.MustCompile(`(#[0-9a-fA-F]{3}|[0-9.]+(px|rem|em|ch|vh|vw|pt|%)|["'])`) + for _, line := range strings.Split(css[block:], "\n") { + text := strings.TrimSpace(line) + property, value, ok := strings.Cut(text, ":") + if !ok || strings.HasPrefix(strings.TrimSpace(property), "--sysml-") { + continue + } + if literal.MatchString(value) { + t.Errorf("declaration %q hardcodes a value; take it from a --sysml-* token", text) + } + } +} + +// TestHTMLThemes checks every bundled theme is one block of the opensysml +// layer, scoped to the document, layered after the default sheet in a page, +// and that a name that is no theme is refused. +func TestHTMLThemes(t *testing.T) { + names := Themes() + if want := []string{"default", "acm", "ieee", "modern", "nasa", "print", "report"}; !slices.Equal(names, want) { + t.Fatalf("Themes() = %v, want %v", names, want) + } + plain, err := ThemeStylesheet("") + if err != nil || plain != DefaultStylesheet() { + t.Fatalf("an empty theme is the default sheet; got err %v", err) + } + if named, _ := ThemeStylesheet(DefaultTheme); named != plain { + t.Error("the default theme, named, is the default sheet") + } + for _, name := range names[1:] { + css, err := ThemeStylesheet(name) + if err != nil { + t.Fatalf("theme %s: %v", name, err) + } + if !strings.HasPrefix(css, DefaultStylesheet()) { + t.Errorf("theme %s does not start from the default sheet", name) + } + overrides := css[len(DefaultStylesheet()):] + if strings.Count(overrides, "@layer opensysml {") != 1 || strings.Contains(overrides, "@layer opensysml;") { + t.Errorf("theme %s must be exactly one block of the opensysml layer:\n%s", name, overrides) + } + if strings.Contains(strings.ToLower(overrides), "") != 2 { + t.Errorf("theme %s: default and theme share one style element, supplied CSS has its own:\n%s", name, got) + } + } + for _, bad := range []string{"fancy", "../document", "report.css", "report.print", `themes\report`} { + _, err := ThemeStylesheet(bad) + var rendering *Error + if !errors.As(err, &rendering) || rendering.Kind != ErrorUnknownTheme || rendering.Actual != bad { + t.Errorf("ThemeStylesheet(%q) = %v, want an unknown-theme error", bad, err) + } + if err != nil && !strings.Contains(err.Error(), "default, acm, ieee, modern, nasa, print, report") { + t.Errorf("ThemeStylesheet(%q) error does not list the themes: %v", bad, err) + } + } + doc := fixtureDocument(t, filepath.Join("testdata", "telescope_report.sysml"), "Observatory::MassReport") + if _, err := HTML(doc, HTMLOptions{Theme: "fancy"}); err == nil { + t.Error("HTML accepted a theme that does not exist") + } + if _, err := HTML(doc, HTMLOptions{Theme: "fancy", NoDefaultStylesheet: true}); err != nil { + t.Errorf("HTML checked a theme it was told to leave out: %v", err) + } + // print spells out web links however the scheme is spelled. + printCSS, err := ThemeStylesheet("print") + if err != nil { + t.Fatal(err) + } + for _, sel := range []string{`[href^="http:" i]::after`, `[href^="https:" i]::after`, `[href^="//"]::after`} { + if !strings.Contains(printCSS, ".sysml-link"+sel) { + t.Errorf("print theme lacks the link selector %s", sel) + } + } +} + +// TestHTMLThemePrintCompanions checks a theme's print companion is no theme of +// its own but comes with its theme: one block of the opensysml-print-theme +// layer, writing page geometry, page-margin boxes and document tokens only, +// and that the default and a theme without one have none. +func TestHTMLThemePrintCompanions(t *testing.T) { + entries, err := fs.ReadDir(themeFS, "themes") + if err != nil { + t.Fatal(err) + } + var companions []string + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".print.css") { + companions = append(companions, strings.TrimSuffix(entry.Name(), ".print.css")) + } + } + if want := []string{"acm", "ieee", "nasa", "print", "report"}; !slices.Equal(companions, want) { + t.Fatalf("print companions = %v, want %v", companions, want) + } + for _, name := range Themes() { + if strings.HasSuffix(name, ".print") { + t.Errorf("Themes() lists the companion %s as a theme", name) + } + } + for _, name := range []string{"", DefaultTheme, "modern"} { + if css, err := ThemePrintStylesheet(name); err != nil || css != "" { + t.Errorf("ThemePrintStylesheet(%q) = %q, %v; want none", name, css, err) + } + } + for _, bad := range []string{"fancy", "report.print", "../document"} { + _, err := ThemePrintStylesheet(bad) + var rendering *Error + if !errors.As(err, &rendering) || rendering.Kind != ErrorUnknownTheme || rendering.Actual != bad { + t.Errorf("ThemePrintStylesheet(%q) = %v, want an unknown-theme error", bad, err) + } + } + for _, name := range companions { + css, err := ThemePrintStylesheet(name) + if err != nil { + t.Fatalf("companion %s: %v", name, err) + } + if !strings.HasPrefix(css, "/* "+name+":") { + t.Errorf("companion %s does not open with its theme's name:\n%.80s", name, css) + } + if strings.Count(css, "@layer opensysml-print-theme {") != 1 || strings.Contains(css, "@layer opensysml {") || strings.Contains(css, "@layer opensysml-print {") { + t.Errorf("companion %s must be exactly one block of the opensysml-print-theme layer:\n%s", name, css) + } + if theme, _ := ThemeStylesheet(name); strings.Contains(theme, css) { + t.Errorf("companion %s is folded into the theme's screen sheet", name) + } + for _, line := range strings.Split(css, "\n") { + sel := strings.TrimSpace(line) + if !strings.HasSuffix(sel, "{") && !strings.HasSuffix(sel, ",") || strings.HasPrefix(sel, "@layer") { + continue + } + if !strings.HasPrefix(sel, ":root") && !strings.HasPrefix(sel, ".sysml-document") && !strings.HasPrefix(sel, "@page") && !strings.HasPrefix(sel, "@bottom-") { + t.Errorf("companion %s selector %q is neither :root, .sysml-document, @page nor a page-margin box", name, sel) + } + } + } +} + +// TestHTMLConventionThemes checks the nasa, ieee and acm themes set the +// faces, sizes and black-on-white tokens their conventions call for, on +// screen and in their print companions alike, and that both agree. +func TestHTMLConventionThemes(t *testing.T) { + times := `"Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif` + arial := `Arial, Helvetica, "Liberation Sans", "Nimbus Sans", sans-serif` + courier := `"Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace` + libertine := `"Libertinus Serif", "Linux Libertine O", "Linux Libertine", "Times New Roman", "Liberation Serif", serif` + biolinum := `"Libertinus Sans", "Linux Biolinum O", "Linux Biolinum", Arial, Helvetica, "Liberation Sans", sans-serif` + cases := []struct { + theme string + body string + heading string + size string + caption string + screenOnly []string + page []string + companion []string + pageNumbers string + }{ + { + theme: "nasa", body: times, heading: arial, size: "12pt", caption: "12pt", + screenOnly: []string{"--sysml-measure: 6.5in;", ".sysml-document .sysml-title {\n font-size: 24pt;", ".sysml-document .sysml-caption {\n caption-side: top;\n font-family: var(--sysml-font-heading);"}, + page: []string{"--sysml-page-size: letter;", "--sysml-page-margin: 1in;", "--sysml-page-number-font-size: 12pt;"}, + companion: []string{"--sysml-subheading-font-size: 14pt;", "--sysml-subsubheading-font-size: 12pt;", "content: counter(front, lower-roman);", "counter-increment: page 0 front 1;", ".sysml-document > .sysml-title:has(+ .sysml-toc) {\n page: front;"}, + }, + { + theme: "ieee", body: times, heading: times, size: "10pt", caption: "8pt", + screenOnly: []string{"--sysml-measure: 7.17in;", "font-variant: small-caps;", "text-align: justify;", "--sysml-paragraph-indent: 1pc;", "--sysml-table-font-size: 8pt;"}, + page: []string{"--sysml-page-size: letter;", "--sysml-page-margin: 0.67in;", "--sysml-page-number-font-size: 8pt;"}, + companion: []string{"--sysml-subheading-font-size: 10pt;", "--sysml-wide-table-font-size: 8pt;"}, + }, + { + theme: "acm", body: libertine, heading: biolinum, size: "10pt", caption: "9pt", + screenOnly: []string{"--sysml-measure: 6.5in;", "--sysml-paragraph-indent: 10pt;", ".sysml-document .sysml-title {\n font-size: 17pt;", "--sysml-table-font-size: 9pt;", ".sysml-document .sysml-caption {\n caption-side: top;\n font-family: var(--sysml-font-body);"}, + page: []string{"--sysml-page-size: letter;", "--sysml-page-margin: 1in;", "--sysml-page-number-font-size: 9pt;"}, + companion: []string{"--sysml-subheading-font-size: 10pt;", "--sysml-wide-table-font-size: 9pt;"}, + }, + } + for _, tc := range cases { + t.Run(tc.theme, func(t *testing.T) { + full, err := ThemeStylesheet(tc.theme) + if err != nil { + t.Fatal(err) + } + screen := full[len(DefaultStylesheet()):] + companion, err := ThemePrintStylesheet(tc.theme) + if err != nil { + t.Fatal(err) + } + shared := []string{ + "--sysml-font-body: " + tc.body + ";", + "--sysml-font-heading: " + tc.heading + ";", + "--sysml-font-mono: " + courier + ";", + "--sysml-font-size: " + tc.size + ";", + } + for _, want := range append(append(append([]string{}, shared...), tc.screenOnly...), + "--sysml-caption-font-size: "+tc.caption+";", + "--sysml-text: #000000;", "--sysml-accent: #000000;", "--sysml-rule: #000000;", "--sysml-surface: transparent;", + "border-bottom: var(--sysml-border-width) solid var(--sysml-rule);", "caption-side: top;", + ) { + if !strings.Contains(screen, want) { + t.Errorf("theme %s lacks %q", tc.theme, want) + } + } + for _, want := range append(append(append([]string{}, shared...), tc.page...), tc.companion...) { + if !strings.Contains(companion, want) { + t.Errorf("companion %s lacks %q", tc.theme, want) + } + } + for _, stray := range []string{"--sysml-measure", "--sysml-text:", "--sysml-accent:"} { + if strings.Contains(companion, stray) { + t.Errorf("companion %s sets %s, which the screen sheet already carries to the page", tc.theme, stray) + } + } + if strings.Contains(screen, "--sysml-page-") { + t.Errorf("theme %s writes page tokens the screen never reads", tc.theme) + } + }) + } +} + +// TestHTMLSuppliedStylesheets checks supplied CSS lands after the default +// layer and unlayered, that a URL is linked rather than inlined, and that +// leaving the default out leaves the document unstyled. +func TestHTMLSuppliedStylesheets(t *testing.T) { + got := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), + "Observatory::MassReport", HTMLOptions{ + Stylesheets: []Stylesheet{ + {Content: ".sysml-document { color: rebeccapurple; }"}, + {Href: "https://example.test/theme.css"}, + }, + }) + layer := strings.Index(got, "@layer opensysml {") + supplied := strings.Index(got, "rebeccapurple") + link := strings.Index(got, ``) + if layer < 0 || supplied < layer || link < supplied { + t.Fatalf("supplied stylesheets must follow the default layer in order:\n%s", got) + } + if strings.Contains(got[supplied-200:supplied], "@layer") { + t.Error("supplied CSS is layered; it must stay unlayered to win on cascade origin") + } + bare := renderFixtureHTML(t, filepath.Join("testdata", "telescope_report.sysml"), + "Observatory::MassReport", HTMLOptions{NoDefaultStylesheet: true}) + if strings.Contains(bare, ""}, ErrorUnsafeStylesheet}, + } { + _, err := HTML(document, HTMLOptions{Stylesheets: []Stylesheet{c.sheet}}) + var typed *Error + if !errors.As(err, &typed) || typed.Kind != c.kind { + t.Errorf("HTML(%+v) error = %v, want %s", c.sheet, err, c.kind) + } + } +} + +// TestHTMLEscaping checks no content can corrupt the structure: markup +// characters, quotes, closing tags and newlines in text, attributes and +// comments. +func TestHTMLEscaping(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {`&plain`, `<b>&plain</b>`}, + {`quote " apostrophe '`, `quote " apostrophe '`}, + {``, `</script><script>alert(1)</script>`}, + {"two\nlines\r\nand\rmore", "two lines and more"}, + } { + if got := htmlText(c.in); got != c.want { + t.Errorf("htmlText(%q) = %q, want %q", c.in, got, c.want) + } + } + if got := attr("data-name", `a"b`); got != ` data-name="a"b"` { + t.Errorf("attr = %q", got) + } + if got := attr("data-name", ""); got != "" { + t.Errorf("an empty value writes no attribute, got %q", got) + } + if got := htmlComment("closes --> early\nand wraps"); got != "closes - -> early and wraps" { + t.Errorf("htmlComment = %q", got) + } +} + +// TestHTMLLinkSchemes checks a link is an href only for a scheme a document +// navigates to; a script URL is kept as data instead. +func TestHTMLLinkSchemes(t *testing.T) { + for _, target := range []string{"https://example.test/a", "mailto:a@example.test", "#anchor", "report.html", "./a:b/c"} { + if _, ok := navigableURL(target); !ok { + t.Errorf("navigableURL(%q) = false, want true", target) + } + } + for _, target := range []string{"javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,` + if strings.Count(got, "") > strings.Index(got, script) || !strings.HasSuffix(got, script+"\n\n\n") { + t.Errorf("script must follow the document, before :\n%s", got) + } + for _, want := range []string{`processHtmlClass: "sysml-math"`, `ignoreHtmlClass: "sysml-document"`, `inlineMath: [["\\(", "\\)"]]`, `displayMath: [["\\[", "\\]"]]`} { + if !strings.Contains(mathConfig, want) { + t.Errorf("configuration lacks %s:\n%s", want, mathConfig) + } + } + both := renderFixtureHTML(t, path, "Optics::OpticsReport", HTMLOptions{MermaidScript: "https://cdn.example/mermaid.js", MathScript: url}) + if strings.Count(both, " strings.Index(both, "mathjax.js") { + t.Errorf("page must load Mermaid, then the math configuration and script:\n%s", both) + } + for name, opts := range map[string]HTMLOptions{ + "default": {}, + "fragment": {Fragment: true, MathScript: url}, + } { + if out := renderFixtureHTML(t, path, "Optics::OpticsReport", opts); strings.Contains(out, "`, + ``, + ``, + ``, + ``, + ``, + `car.wheels[1]`, + `
  • spare 20
  • `, + `
  • car.wheels[1] 30
  • `, + } { + if !strings.Contains(got, want) { + t.Errorf("rendering does not contain %q\n%s", want, got) + } + } +} diff --git a/internal/doc/docrender/runs_test.go b/internal/doc/docrender/runs_test.go new file mode 100644 index 0000000000..df06d8c6a8 --- /dev/null +++ b/internal/doc/docrender/runs_test.go @@ -0,0 +1,310 @@ +package docrender + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// TestMarkdownEmphasisDelimiters checks that emphasis and strong delimiters +// stay flanking around escaped content, with whitespace kept outside. +func TestMarkdownEmphasisDelimiters(t *testing.T) { + for _, c := range []struct{ marker, in, want string }{ + {"*", "plain", "*plain*"}, + {"*", "with *stars*", `*with \*stars\**`}, + {"*", " padded ", " *padded* "}, + {"*", "\ttabbed\t", "\t*tabbed*\t"}, + {"*", "\t ", "\t "}, + {"*", " ", " "}, + {"**", "bold_move", `**bold\_move**`}, + {"**", "a|b", `**a\|b**`}, + } { + if got := delimited(c.marker, c.in); got != c.want { + t.Errorf("delimited(%q, %q) = %q, want %q", c.marker, c.in, got, c.want) + } + } +} + +// TestMarkdownCodeSpans checks the code-span fence contract: the fence is +// longer than any inner backtick run, with padding when the content needs it. +func TestMarkdownCodeSpans(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {"x > 1", "`x > 1`"}, + {"a `tick`", "`` a `tick` ``"}, + {"``double``", "``` ``double`` ```"}, + {"`leading", "`` `leading ``"}, + {" padded ", "` padded `"}, + {"", "` `"}, + {"two\nlines", "`two lines`"}, + } { + if got := codeSpan(c.in); got != c.want { + t.Errorf("codeSpan(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestMarkdownLinkDestinations checks pointy-bracket destination escaping: +// backslashes and angle brackets escape, newlines percent-encode. +func TestMarkdownLinkDestinations(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {"https://example.com/spec(v2).md", "https://example.com/spec(v2).md"}, + {"https://example.com/a b", "https://example.com/a b"}, + {`a\b`, `a\\b`}, + {"ac", `a\c`}, + {"a\nb", "a%0Ab"}, + {"a\r\nb", "a%0Ab"}, + } { + if got := destination(c.in); got != c.want { + t.Errorf("destination(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestMarkdownAnchorsEmptyReferencedList checks that a referenced list keeps +// its anchor even when its query returns no rows. +func TestMarkdownAnchorsEmptyReferencedList(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty_ref.sysml") + model := ` + package Anchors { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + calc def Names :> Query { + in root : Element; + Project( + source = OwnedElements(source = root), + properties = ("name") + ) + } + + part hollow; + + part def Report :> Document { + attribute redefines title = "Report"; + part formatted : Paragraph { + part see : Ref { + ref redefines target = items; + } + } + part items : List { + calc rows : Names { + in root = hollow; + } + } + } + } + ` + if err := os.WriteFile(path, []byte(model), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + got := renderFixtureDocument(t, path, "Anchors::Report") + for _, want := range []string{``, `[items](#items)`} { + if !strings.Contains(got, want) { + t.Errorf("rendering does not contain %q\n%s", want, got) + } + } +} + +// TestMarkdownGroupedTableEmpty checks that a grouped table whose filter +// selects no rows still renders a header-only pipe table. +func TestMarkdownGroupedTableEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty_group.sysml") + model := ` + package Groups { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + calc def Zoned :> Query { + in root : Element; + WhereName( + source = Project( + source = OwnedElements(source = root), + properties = ("zone", "name") + ), + operator = "==", + value = "nomatch" + ) + } + + part def Widget { + attribute zone : String; + } + + part hollow { + part inner : Widget { + attribute redefines zone = "payload"; + } + } + + part def Report :> Document { + attribute redefines title = "Report"; + part zones : Table { + attribute redefines groupBy = "zone"; + calc rows : Zoned { + in root = hollow; + } + } + } + } + ` + if err := os.WriteFile(path, []byte(model), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + got := renderFixtureDocument(t, path, "Groups::Report") + want := "| zone | name |\n| --- | --- |" + if !strings.Contains(got, want) { + t.Errorf("rendering does not contain %q\n%s", want, got) + } +} + +// TestMarkdownGroupedTableBlankKey checks that a group whose key is blank +// writes a strong span CommonMark parses, with the blank outside the marks. +func TestMarkdownGroupedTableBlankKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "blank_group.sysml") + model := ` + package Groups { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + calc def Zoned :> Query { + in root : Element; + Project( + source = WhereType(source = OwnedElements(source = root), type = "Groups::Widget"), + properties = ("name", "zone") + ) + } + + part def Widget { + attribute zone : String; + } + + part hollow { + part def Widget :> Groups::Widget; + part inner : Widget { + attribute redefines zone = "payload"; + } + } + + part def Report :> Document { + attribute redefines title = "Report"; + part zones : Table { + attribute redefines groupBy = "zone"; + calc rows : Zoned { + in root = hollow; + } + } + } + } + ` + if err := os.WriteFile(path, []byte(model), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + got := renderFixtureDocument(t, path, "Groups::Report") + if !strings.Contains(got, "\n**zone:** \n") || strings.Contains(got, "**zone: **") { + t.Errorf("blank group key is not a strong span CommonMark parses\n%s", got) + } + if !strings.Contains(got, "\n**zone: payload**\n") { + t.Errorf("rendering does not contain the payload group key\n%s", got) + } +} + +// TestMarkdownPaddedCaption checks a caption padded with blanks, four spaces +// or a tab is written as plain emphasis (CommonMark would read the padding as +// indented code), and that a blank caption writes no paragraph and is not +// listed by Captions. +func TestMarkdownPaddedCaption(t *testing.T) { + path := filepath.Join(t.TempDir(), "padded_caption.sysml") + model := ` + package Padded { + private import DocumentQueries::*; + private import KerML::Root::Element; + + calc def Named :> Query { + in root : Element; + Project(source = OwnedElements(source = root), properties = ("name")) + } + + part widgets { part a; } + + part def Report :> Document { + attribute redefines title = "Report"; + part padded : Table { + attribute redefines caption = " Masses "; + calc rows : Named { in root = widgets; } + } + part indented : Table { + attribute redefines caption = " Volumes"; + calc rows : Named { in root = widgets; } + } + part tabbed : Table { + attribute redefines caption = " Areas"; + calc rows : Named { in root = widgets; } + } + part blank : Table { + attribute redefines caption = " "; + calc rows : Named { in root = widgets; } + } + part trailing : Table { + attribute redefines caption = "Details"; + calc rows : Named { in root = widgets; } + } + } + } + ` + if err := os.WriteFile(path, []byte(model), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + document := fixtureDocument(t, path, "Padded::Report") + got, err := Markdown(document, MarkdownOptions{}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"\n*Masses*\n", "\n*Volumes*\n", "\n*Areas*\n", "\n*Details*\n"} { + if !strings.Contains(got, want) { + t.Errorf("Markdown lacks %q as a plain emphasized paragraph\n%s", want, got) + } + } + for _, literal := range []string{"* Masses *", " *Masses*", " *Volumes*", "\t*Areas*", "* *", "\n \n"} { + if strings.Contains(got, literal) { + t.Errorf("Markdown carries a caption's padding %q\n%s", literal, got) + } + } + if captions := Captions(document); !reflect.DeepEqual(captions, []string{"Masses", "Volumes", "Areas", "Details"}) { + t.Errorf("Captions = %q, want the non-blank captions trimmed", captions) + } +} + +// TestMarkdownGoldenInlineRuns spot-checks the rendered inline runs, anchors, +// and grouped subtables of the telescope report. +func TestMarkdownGoldenInlineRuns(t *testing.T) { + got := renderFixtureDocument(t, + "testdata/telescope_report.sysml", + "Observatory::MassReport") + for _, want := range []string{ + // Styled spans escape their content inside the right delimiters. + `*em\*ph\*asis*`, + `**bold\_move**`, + "`` mass >= `limit` ``", + // The link's text escapes; the destination stays literal. + `[the \[spec\]]()`, + // The default ref label is the target section's title; both refs + // point at emitted anchors. + `[Subsystem Masses \| by \*name\*](#breakdown)`, + `[the zone groups](#zones)`, + ``, + ``, + // Each group renders its key in strong emphasis, then a subtable. + "**zone: payload**\n\n| zone | name | mass |", + "**zone: support \\| \\*frame\\***", + "| payload | optics | 8.5 |", + } { + if !strings.Contains(got, want) { + t.Errorf("rendering does not contain %q\n%s", want, got) + } + } +} diff --git a/internal/doc/docrender/states_test.go b/internal/doc/docrender/states_test.go new file mode 100644 index 0000000000..dfdba6aa39 --- /dev/null +++ b/internal/doc/docrender/states_test.go @@ -0,0 +1,121 @@ +package docrender + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/doc/docir" + "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/docplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// stateFixtureDocument evaluates the lamp report over a session that drove lamp1 on +// at 0 s, dimmed at 1 s, boosted at 2 s and lamp2 on at 2 s, off at 2.5 s; clock at 3 s. +func stateFixtureDocument(t *testing.T) *docir.Document { + t.Helper() + fixture := loadRenderFixture(t, filepath.Join("testdata", "state_report.sysml")) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + ctx.SetTrace(runtime.NewTraceRecorder()) + var roots []queryexec.Root + instantiate := func(name string) *runtime.Instance { + inst, err := ctx.Instantiate(fixture.symbol(t, "Lamps::"+name)) + if err != nil { + t.Fatalf("Instantiate %s: %v", name, err) + } + roots = append(roots, queryexec.Root{Label: name, Object: inst}) + return inst + } + lamp1, lamp2 := instantiate("lamp1"), instantiate("lamp2") + instantiate("panel") + send := func(inst *runtime.Instance, signal string, args map[string]runtime.Value) { + msg, err := ctx.SignalMessage(fixture.symbol(t, "Lamps::"+signal), args, inst) + if err != nil { + t.Fatalf("send %s: %v", signal, err) + } + ctx.PostMessage(msg) + } + advance := func(seconds float64) { + if _, err := ctx.Advance(seconds); err != nil { + t.Fatalf("advance %v: %v", seconds, err) + } + } + level := runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 3}} + send(lamp1, "Toggle", nil) + advance(1) + send(lamp1, "Dim", map[string]runtime.Value{"level": level}) + advance(1) + send(lamp1, "Boost", nil) + send(lamp2, "Toggle", nil) + advance(0.5) + send(lamp2, "Toggle", nil) + advance(0.5) + report := symbols.PreferDeclared(fixture.index.LookupQualified("Lamps::LampReport")) + if len(report) != 1 { + t.Fatalf("lookup Lamps::LampReport: got %d symbols", len(report)) + } + plan, err := docplan.Compile(fixture.index, fixture.model, fixture.resolver, report[0]) + if err != nil { + t.Fatalf("compile document: %v", err) + } + document, err := docir.Evaluate(plan, + queryexec.Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Runtime: ctx, Roots: roots}, + queryexec.Options{}, nil) + if err != nil { + t.Fatalf("evaluate document: %v", err) + } + return document +} + +// TestMarkdownStateReportGolden locks the Markdown of a document over a session's +// states and trace: active leaves, the objects in `on`, one lamp's events, bare rows. +func TestMarkdownStateReportGolden(t *testing.T) { + got, err := Markdown(stateFixtureDocument(t), MarkdownOptions{}) + if err != nil { + t.Fatalf("render document: %v", err) + } + golden := filepath.Join("testdata", "state_report.golden.md") + if *update { + if err := os.WriteFile(golden, []byte(got), 0o644); err != nil { + t.Fatalf("update golden: %v", err) + } + return + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden (run with -update to create): %v", err) + } + if got != string(want) { + t.Errorf("rendered Markdown differs from %s (run with -update after intentional changes)\ngot:\n%s", golden, got) + } +} + +// TestHTMLStateReport checks the HTML over states and events: rows carry the object, +// machine and state path or the event's kind and instant; bare items read as summaries. +func TestHTMLStateReport(t *testing.T) { + got, err := HTML(stateFixtureDocument(t), HTMLOptions{Fragment: true}) + if err != nil { + t.Fatalf("render document as HTML: %v", err) + } + for _, want := range []string{ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + `
  • t=0 lamp1.lp: enter: on
  • `, + `
  • lamp1.lp in on.fast
  • `, + } { + if !strings.Contains(got, want) { + t.Errorf("rendering does not contain %q\n%s", want, got) + } + } +} diff --git a/internal/core/docrender/testdata/anonymous_sections.sysml b/internal/doc/docrender/testdata/anonymous_sections.sysml similarity index 100% rename from internal/core/docrender/testdata/anonymous_sections.sysml rename to internal/doc/docrender/testdata/anonymous_sections.sysml diff --git a/internal/core/docrender/testdata/defaulted_queries.sysml b/internal/doc/docrender/testdata/defaulted_queries.sysml similarity index 100% rename from internal/core/docrender/testdata/defaulted_queries.sysml rename to internal/doc/docrender/testdata/defaulted_queries.sysml diff --git a/internal/core/docrender/testdata/derived_report.golden.md b/internal/doc/docrender/testdata/derived_report.golden.md similarity index 95% rename from internal/core/docrender/testdata/derived_report.golden.md rename to internal/doc/docrender/testdata/derived_report.golden.md index 42ef1373db..48d79ab2c7 100644 --- a/internal/core/docrender/testdata/derived_report.golden.md +++ b/internal/doc/docrender/testdata/derived_report.golden.md @@ -1,6 +1,5 @@ # Derived Stage Masses - *Every stage, heaviest first* | name | dryMass | mass | engines | class | perEngine | diff --git a/internal/core/docrender/testdata/derived_report.sysml b/internal/doc/docrender/testdata/derived_report.sysml similarity index 100% rename from internal/core/docrender/testdata/derived_report.sysml rename to internal/doc/docrender/testdata/derived_report.sysml diff --git a/internal/doc/docrender/testdata/graph_views.sysml b/internal/doc/docrender/testdata/graph_views.sysml new file mode 100644 index 0000000000..36c783f347 --- /dev/null +++ b/internal/doc/docrender/testdata/graph_views.sysml @@ -0,0 +1,55 @@ +package Pump { + private import DocumentQueries::*; + + state def Modes { + view 'Pump Modes' : StandardViewDefinitions::StateTransitionView { + expose Modes; + @DiagramLayout::Canvas { unit = "px"; width = 300; height = 120; } + metadata DiagramLayout::Layout about Idle { x = 20; y = 40; width = 100; height = 40; } + metadata DiagramLayout::Layout about Running { x = 180; y = 40; width = 100; height = 40; } + metadata DiagramLayout::Route about 'Idle accept Start then Running' { points = (120, 60, 180, 60); } + render Views::asInterconnectionDiagram; + } + entry; then Idle; + state Idle; + state Running; + transition 'Idle accept Start then Running' first Idle accept Start then Running; + } + + action def Prime { + view Priming : StandardViewDefinitions::ActionFlowView { + expose Prime; + @DiagramLayout::Canvas { unit = "px"; width = 140; height = 220; } + metadata DiagramLayout::Layout about Fill { x = 20; y = 60; width = 100; height = 40; } + metadata DiagramLayout::Layout about Vent { x = 20; y = 140; width = 100; height = 40; } + metadata DiagramLayout::Route about 'Fill to Vent' { points = (70, 100, 70, 140); } + render Views::asInterconnectionDiagram; + } + first start then Fill; + action Fill; + succession 'Fill to Vent' first Fill then Vent; + action Vent; + first Vent then finish; + action finish terminate; + } + + part def Handbook :> Document { + attribute redefines title = "Pump Handbook"; + ref modes : Modes; + ref prime : Prime; + + part behaviors : Section { + attribute redefines title = "Behaviors"; + + part modesFigure : Diagram { + attribute redefines caption = "Pump Modes"; + ref redefines source = modes.'Pump Modes'; + } + + part primingFigure : Diagram { + attribute redefines caption = "Priming"; + ref redefines source = prime.Priming; + } + } + } +} diff --git a/internal/core/docrender/testdata/linked-html/Observatory-Mass.20Appendix.html b/internal/doc/docrender/testdata/linked-html/Observatory-Mass.20Appendix.html similarity index 100% rename from internal/core/docrender/testdata/linked-html/Observatory-Mass.20Appendix.html rename to internal/doc/docrender/testdata/linked-html/Observatory-Mass.20Appendix.html diff --git a/internal/core/docrender/testdata/linked-html/Observatory-SystemReport.html b/internal/doc/docrender/testdata/linked-html/Observatory-SystemReport.html similarity index 100% rename from internal/core/docrender/testdata/linked-html/Observatory-SystemReport.html rename to internal/doc/docrender/testdata/linked-html/Observatory-SystemReport.html diff --git a/internal/core/docrender/testdata/linked/Observatory-Mass.20Appendix.md b/internal/doc/docrender/testdata/linked/Observatory-Mass.20Appendix.md similarity index 100% rename from internal/core/docrender/testdata/linked/Observatory-Mass.20Appendix.md rename to internal/doc/docrender/testdata/linked/Observatory-Mass.20Appendix.md diff --git a/internal/core/docrender/testdata/linked/Observatory-SystemReport.md b/internal/doc/docrender/testdata/linked/Observatory-SystemReport.md similarity index 100% rename from internal/core/docrender/testdata/linked/Observatory-SystemReport.md rename to internal/doc/docrender/testdata/linked/Observatory-SystemReport.md diff --git a/internal/core/docrender/testdata/linked_reports.sysml b/internal/doc/docrender/testdata/linked_reports.sysml similarity index 100% rename from internal/core/docrender/testdata/linked_reports.sysml rename to internal/doc/docrender/testdata/linked_reports.sysml diff --git a/internal/doc/docrender/testdata/math_report.fragment.golden.html b/internal/doc/docrender/testdata/math_report.fragment.golden.html new file mode 100644 index 0000000000..05f7808e23 --- /dev/null +++ b/internal/doc/docrender/testdata/math_report.fragment.golden.html @@ -0,0 +1,23 @@ +
    +

    Optics Design Relations

    +
    +

    Overview

    +

    The mirror's mass scales as \(m \propto D^{2.5}_{\text{eff}}\) and each $ of budget buys about 1 cm^2 of aperture; see Collecting area of a circular mirror and the aperture memo

    +
    +
    \[A = \pi \left(\frac{D}{2}\right)^2 + + = \frac{\pi D^2}{4}\]
    +
    Collecting area of a circular mirror
    +
    +
    +
    \[\text{cost} = 10^6\,\$ \times D^{2.5} + $\]
    +
    +
    +
    +

    Design relations

    +
      +
    • f-number \(N = \frac{f}{D}\)
    • +
    • Rayleigh criterion \(\theta = 1.22\,\frac{\lambda}{D}\)
    • +
    +
    +
    diff --git a/internal/doc/docrender/testdata/math_report.golden.md b/internal/doc/docrender/testdata/math_report.golden.md new file mode 100644 index 0000000000..375f8a5e82 --- /dev/null +++ b/internal/doc/docrender/testdata/math_report.golden.md @@ -0,0 +1,23 @@ +# Optics Design Relations + +## Overview + +The mirror's mass scales as $m \propto D^{2.5}_{\text{eff}}$ and each \$ of budget buys about 1 cm^2 of aperture; *see* [Collecting area of a circular mirror](#overview-mirrorArea) and the [aperture memo]() + + + +*Collecting area of a circular mirror* + +$$ +A = \pi \left(\frac{D}{2}\right)^2 += \frac{\pi D^2}{4} +$$ + +$$ +\text{cost} = 10^6\,\$ \times D^{2.5} + \$ +$$ + +## Design relations + +- f-number $N = \frac{f}{D}$ +- Rayleigh criterion $\theta = 1.22\,\frac{\lambda}{D}$ diff --git a/internal/doc/docrender/testdata/math_report.sysml b/internal/doc/docrender/testdata/math_report.sysml new file mode 100644 index 0000000000..f6b069ddf3 --- /dev/null +++ b/internal/doc/docrender/testdata/math_report.sysml @@ -0,0 +1,96 @@ +package Optics { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + part def Relation { + attribute latex : String; + attribute note : String; + } + + part telescope { + part focalRatio : Relation { + attribute redefines latex = "N = \\frac{f}{D}"; + attribute redefines note = "f-number"; + } + part resolution : Relation { + attribute redefines latex = "\\theta = 1.22\\,\\frac{\\lambda}{D}"; + attribute redefines note = "Rayleigh criterion"; + } + } + + calc def Relations :> Query { + in root : Element; + Project( + source = OrderBy( + source = WhereType(source = OwnedElements(source = root), type = "Optics::Relation"), + property = "name", + direction = "ascending", + missing = "last", + multiple = "error" + ), + properties = ("note", "latex") + ) + } + + part def OpticsReport :> Document { + attribute redefines title = "Optics Design Relations"; + + part overview : Section { + attribute redefines title = "Overview"; + + part intro : Paragraph { + part lead : Span { + attribute redefines text = "The mirror's mass scales as"; + } + part scaling : Span { + attribute redefines text = " m \\propto D^{2.5}_{\\text{eff}} "; + attribute redefines style = "math"; + } + part cost : Span { + attribute redefines text = "and each $ of budget buys about 1 cm^2 of aperture;"; + } + part subscript : Span { + attribute redefines text = "see"; + attribute redefines style = "emphasis"; + } + part see : Ref { + ref redefines target = mirrorArea; + } + part tail : Span { + attribute redefines text = "and the"; + } + part spec : Link { + attribute redefines text = "aperture memo"; + attribute redefines target = "https://example.com/aperture"; + } + } + + part mirrorArea : Formula { + attribute redefines source = "A = \\pi \\left(\\frac{D}{2}\\right)^2\n\n = \\frac{\\pi D^2}{4}\n"; + attribute redefines caption = "Collecting area of a circular mirror"; + } + + part dollars : Formula { + attribute redefines source = "\\text{cost} = 10^6\\,\\$ \\times D^{2.5} + $"; + } + } + + part relations : Section { + attribute redefines title = "Design relations"; + + part list : List { + calc rows : Relations { + in root = telescope; + } + part note : SpanColumn { + attribute redefines column = "note"; + } + part expr : SpanColumn { + attribute redefines column = "latex"; + attribute redefines style = "math"; + } + } + } + } +} diff --git a/internal/core/docrender/testdata/mosa_registers.sysml b/internal/doc/docrender/testdata/mosa_registers.sysml similarity index 100% rename from internal/core/docrender/testdata/mosa_registers.sysml rename to internal/doc/docrender/testdata/mosa_registers.sysml diff --git a/internal/doc/docrender/testdata/object_report.golden.md b/internal/doc/docrender/testdata/object_report.golden.md new file mode 100644 index 0000000000..c6b0208da3 --- /dev/null +++ b/internal/doc/docrender/testdata/object_report.golden.md @@ -0,0 +1,21 @@ +# Car Report + +The objects a session holds, by path. + +*Parts of the car* + +| name | qualifiedName | pressure | +| --- | --- | --- | +| engine | car.engine | | +| wheels\[1\] | car.wheels\[1\] | 30 | +| wheels\[2\] | car.wheels\[2\] | 30 | + +*The car's engine and wheels* + +| name | engine | wheels | +| --- | --- | --- | +| car | car.engine | car.wheels\[1\], car.wheels\[2\] | + +- spare 20 +- car.wheels\[1\] 30 +- car.wheels\[2\] 30 diff --git a/internal/doc/docrender/testdata/object_report.sysml b/internal/doc/docrender/testdata/object_report.sysml new file mode 100644 index 0000000000..6c92d9c3f1 --- /dev/null +++ b/internal/doc/docrender/testdata/object_report.sysml @@ -0,0 +1,64 @@ +package Garage { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + part def Wheel { + attribute pressure : Integer default 30; + } + part def Engine { + attribute power : Integer default 100; + } + part def Car { + part engine : Engine; + part wheels : Wheel[2]; + } + part car : Car; + part spare : Wheel { + attribute :>> pressure = 20; + } + + calc def Parts :> Query { + in root : Element; + Project( + source = Descendants(source = root, maxDepth = 2), + properties = ("name", "qualifiedName", "pressure") + ) + } + calc def Drive :> Query { + in root : Element; + Project(source = root, properties = ("name", "engine", "wheels")) + } + calc def Wheels :> Query { + OrderBy( + source = Project(source = Objects(type = "Wheel"), properties = ("qualifiedName", "pressure")), + property = "pressure", + direction = "ascending", + missing = "last", + multiple = "first" + ) + } + + part def CarReport :> Document { + attribute redefines title = "Car Report"; + + part intro : Paragraph { + attribute redefines text = "The objects a session holds, by path."; + } + part parts : Table { + attribute redefines caption = "Parts of the car"; + calc rows : Parts { + in root = car; + } + } + part drive : Table { + attribute redefines caption = "The car's engine and wheels"; + calc rows : Drive { + in root = car; + } + } + part wheels : List { + calc items : Wheels; + } + } +} diff --git a/internal/doc/docrender/testdata/placed_report.sysml b/internal/doc/docrender/testdata/placed_report.sysml new file mode 100644 index 0000000000..0a84cefde7 --- /dev/null +++ b/internal/doc/docrender/testdata/placed_report.sysml @@ -0,0 +1,40 @@ +package Placed { + private import DocumentQueries::*; + private import DiagramLayout::*; + private import Views::*; + + port def DataPort; + + part def Camera { + port output : DataPort; + } + + part def Recorder { + port input : DataPort; + } + + part imagingChain { + part camera : Camera; + part recorder : Recorder; + part spare : Recorder; + connection link connect camera.output to recorder.input; + } + + // Positions two of the three parts; spare is left unplaced. + view placedView { + expose imagingChain; + render asInterconnectionDiagram; + @Canvas { unit = "px"; width = 400; height = 200; } + metadata Layout about imagingChain::camera { x = 20; y = 40; width = 120; height = 60; } + metadata Layout about imagingChain::recorder { x = 220; y = 40; width = 120; height = 60; } + } + + part def PlacedReport :> Document { + attribute redefines title = "Placed report"; + + part chain : Diagram { + attribute redefines caption = "Imaging chain, as laid out"; + ref redefines source = placedView; + } + } +} diff --git a/internal/core/docrender/testdata/quantity_report.golden.md b/internal/doc/docrender/testdata/quantity_report.golden.md similarity index 89% rename from internal/core/docrender/testdata/quantity_report.golden.md rename to internal/doc/docrender/testdata/quantity_report.golden.md index 00188ae0c6..ad6839f9e3 100644 --- a/internal/core/docrender/testdata/quantity_report.golden.md +++ b/internal/doc/docrender/testdata/quantity_report.golden.md @@ -1,6 +1,5 @@ # Stage Masses - *Every stage, heaviest first* | name | mass | tonnes | @@ -9,7 +8,6 @@ | s2 | 119000 \[kg\] | 119 \[kg\] | | probe | 500000 \[g\] | 500 \[g\] | - *Stages of at least a million kilograms* | name | mass | diff --git a/internal/core/docrender/testdata/quantity_report.sysml b/internal/doc/docrender/testdata/quantity_report.sysml similarity index 100% rename from internal/core/docrender/testdata/quantity_report.sysml rename to internal/doc/docrender/testdata/quantity_report.sysml diff --git a/internal/core/docrender/testdata/reserved_anchor.sysml b/internal/doc/docrender/testdata/reserved_anchor.sysml similarity index 100% rename from internal/core/docrender/testdata/reserved_anchor.sysml rename to internal/doc/docrender/testdata/reserved_anchor.sysml diff --git a/internal/core/docrender/testdata/rollup_report.sysml b/internal/doc/docrender/testdata/rollup_report.sysml similarity index 100% rename from internal/core/docrender/testdata/rollup_report.sysml rename to internal/doc/docrender/testdata/rollup_report.sysml diff --git a/internal/doc/docrender/testdata/state_report.golden.md b/internal/doc/docrender/testdata/state_report.golden.md new file mode 100644 index 0000000000..c223e6ca14 --- /dev/null +++ b/internal/doc/docrender/testdata/state_report.golden.md @@ -0,0 +1,34 @@ +# Lamp Report + +Where the lamps stand and what they did. + +*Active states of every lamp* + +| path | machine | statePath | region | enclosing | +| --- | --- | --- | --- | --- | +| lamp1 | lp | on.dim | light | on | +| lamp1 | lp | on.fast | fan | on | +| lamp2 | lp | off | | | + +*Lamps that are on* + +| qualifiedName | +| --- | +| lamp1 | + +*What lamp1 did from 1 s up to 2.5 s* + +| time | kind | event | from | to | target | payload | +| --- | --- | --- | --- | --- | --- | --- | +| 1 \[s\] | accept | Dim | | | | level = 3 | +| 1 \[s\] | transition | accept Dim | run | dim | | | +| 2 \[s\] | accept | Boost | | | | | +| 2 \[s\] | send | Report | | | panel | | +| 2 \[s\] | transition | accept Boost | slow | fast | | | + +- t=0 lamp1.lp: enter: on +- t=2 lamp2.lp: enter: on + +- lamp1.lp in on.dim +- lamp1.lp in on.fast +- lamp2.lp in off diff --git a/internal/doc/docrender/testdata/state_report.sysml b/internal/doc/docrender/testdata/state_report.sysml new file mode 100644 index 0000000000..c70e43e013 --- /dev/null +++ b/internal/doc/docrender/testdata/state_report.sysml @@ -0,0 +1,91 @@ +package Lamps { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + private import SI::*; + + attribute def Toggle; + attribute def Boost; + attribute def Dim { attribute level : Integer; } + attribute def Report; + + state def LampMachine { + attribute brightness : Integer = 0; + entry; then off; + state off; + transition off_on first off accept Toggle then on; + state on parallel { + state light { + entry; then run; + state run; + transition run_dim first run accept d : Dim do assign brightness := d.level then dim; + state dim; + } + state fan { + entry; then slow; + state slow; + transition slow_fast first slow accept Boost then fast; + state fast { + entry send new Report() to panel; + } + } + } + transition on_off first on accept Toggle then off; + } + part def Lamp { exhibit state lp : LampMachine; } + part def Panel; + part lamp1 : Lamp; + part lamp2 : Lamp; + part panel : Panel; + + calc def CurrentStates :> Query { + Project( + source = States(source = Objects(type = "Lamp")), + properties = ("path", "machine", "statePath", "region", "enclosing") + ) + } + calc def Lit :> Query { + Project(source = InState(name = "on"), properties = ("qualifiedName")) + } + calc def Steps :> Query { + in root : Element; + Project( + source = Events(source = root, kind = "accept, transition, send", since = 1 [s], before = 2.5 [s]), + properties = ("time", "kind", "event", "from", "to", "target", "payload") + ) + } + calc def Entered :> Query { + WhereFeature(source = Events(kind = "entry"), 'feature' = "state", operator = "=", value = "on") + } + calc def Standing :> Query { + States(source = Objects(type = "Lamp")) + } + + part def LampReport :> Document { + attribute redefines title = "Lamp Report"; + + part intro : Paragraph { + attribute redefines text = "Where the lamps stand and what they did."; + } + part states : Table { + attribute redefines caption = "Active states of every lamp"; + calc rows : CurrentStates; + } + part lit : Table { + attribute redefines caption = "Lamps that are on"; + calc rows : Lit; + } + part steps : Table { + attribute redefines caption = "What lamp1 did from 1 s up to 2.5 s"; + calc rows : Steps { + in root = lamp1; + } + } + part entered : List { + calc items : Entered; + } + part standing : List { + calc items : Standing; + } + } +} diff --git a/internal/core/docrender/testdata/telescope_report.dot.golden.md b/internal/doc/docrender/testdata/telescope_report.dot.golden.md similarity index 91% rename from internal/core/docrender/testdata/telescope_report.dot.golden.md rename to internal/doc/docrender/testdata/telescope_report.dot.golden.md index cf0f969b21..0bbc68791d 100644 --- a/internal/core/docrender/testdata/telescope_report.dot.golden.md +++ b/internal/doc/docrender/testdata/telescope_report.dot.golden.md @@ -6,7 +6,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\] - *Subsystems grouped by zone* **zone: support \| \*frame\*** @@ -27,7 +26,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\] ## Subsystem Masses \| by \*name\* - *All subsystems by mass* | name | mass | @@ -37,7 +35,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\] | optics | 8.5 | | segmentControl | 20 | - *Mass margins (allocated - estimated)* | name | label | margin | @@ -47,7 +44,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\] | optics | subsystem: optics | 1.5 | | segmentControl | subsystem: segmentControl | 5.5 | - *Subsystem notes* | shortName | name | documentation | @@ -82,7 +78,6 @@ mount segmentControl ## Diagrams - *Imaging chain interconnection* ```dot @@ -95,7 +90,7 @@ digraph "Observatory::interconnectView" { node [shape=box, style=filled, fillcolor=white, color="#181818", fontname="Helvetica", fontsize=14, penwidth=0.5]; edge [color="#181818", fontname="Helvetica", fontsize=13, penwidth=1]; subgraph "cluster_n0" { - label=<Observatory::imagingChain
    «part»>; + label=<imagingChain
    «part»>; color=black; penwidth=0.5; "n0" [shape=point, style=invis, width=0, height=0, label=""]; @@ -106,7 +101,6 @@ digraph "Observatory::interconnectView" { } ``` - *Observatory states, left to right* ```dot @@ -118,7 +112,7 @@ digraph { node [shape=box, style=filled, fillcolor=white, color="#181818", fontname="Helvetica", fontsize=14, penwidth=0.5]; edge [color="#181818", fontname="Helvetica", fontsize=13, penwidth=1]; subgraph "cluster_n0" { - label=<Observatory::operatingStates : ObservatoryStates
    «state»>; + label=<operatingStates : ObservatoryStates
    «state»>; color=black; penwidth=0.5; "n0" [shape=point, style=invis, width=0, height=0, label=""]; @@ -136,7 +130,6 @@ digraph { The declared type of the telescope, by relationship traversal. - *Type of telescope* | element | diff --git a/internal/core/docrender/testdata/telescope_report.fragment.golden.html b/internal/doc/docrender/testdata/telescope_report.fragment.golden.html similarity index 99% rename from internal/core/docrender/testdata/telescope_report.fragment.golden.html rename to internal/doc/docrender/testdata/telescope_report.fragment.golden.html index c84055c88a..72386e8065 100644 --- a/internal/core/docrender/testdata/telescope_report.fragment.golden.html +++ b/internal/doc/docrender/testdata/telescope_report.fragment.golden.html @@ -206,7 +206,7 @@

    2 Diagrams

    --- %% Observatory::interconnectView — interconnection rendering (render asInterconnectionDiagram) flowchart LR - subgraph n0 ["Observatory::imagingChain<br>«part»"] + subgraph n0 ["imagingChain<br>«part»"] direction LR n1["camera : Camera<br>«part»"] n2["recorder : Recorder<br>«part»"] @@ -218,7 +218,7 @@

    2 Diagrams

    %% state rendering (the diagram states kind "state")
     stateDiagram-v2
       direction LR
    -  state "Observatory::operatingStates : ObservatoryStates<br>«state»" as n0 {
    +  state "operatingStates : ObservatoryStates<br>«state»" as n0 {
         state "idle<br>«state»<br>initial" as n1
         state "observing<br>«state»" as n2
         [*] --> n1
    diff --git a/internal/core/docrender/testdata/telescope_report.golden.html b/internal/doc/docrender/testdata/telescope_report.golden.html
    similarity index 98%
    rename from internal/core/docrender/testdata/telescope_report.golden.html
    rename to internal/doc/docrender/testdata/telescope_report.golden.html
    index 6f03823c2e..692a481b9d 100644
    --- a/internal/core/docrender/testdata/telescope_report.golden.html
    +++ b/internal/doc/docrender/testdata/telescope_report.golden.html
    @@ -33,6 +33,7 @@
         --sysml-title-page-height: 60vh;
         --sysml-group-heading-weight: 600;
         --sysml-term-weight: 600;
    +    --sysml-formula-align: center;
     
         box-sizing: border-box;
         max-width: var(--sysml-measure);
    @@ -135,6 +136,15 @@
         margin: var(--sysml-space) 0;
       }
     
    +  .sysml-document .sysml-formula {
    +    margin: var(--sysml-space) 0;
    +    text-align: var(--sysml-formula-align);
    +  }
    +
    +  .sysml-document .sysml-formula .sysml-math {
    +    overflow-x: auto;
    +  }
    +
       .sysml-document .sysml-diagram pre {
         overflow-x: auto;
         font-family: var(--sysml-font-mono);
    @@ -346,7 +356,7 @@ 

    Diagrams

    --- %% Observatory::interconnectView — interconnection rendering (render asInterconnectionDiagram) flowchart LR - subgraph n0 ["Observatory::imagingChain<br>«part»"] + subgraph n0 ["imagingChain<br>«part»"] direction LR n1["camera : Camera<br>«part»"] n2["recorder : Recorder<br>«part»"] @@ -358,7 +368,7 @@

    Diagrams

    %% state rendering (the diagram states kind "state")
     stateDiagram-v2
       direction LR
    -  state "Observatory::operatingStates : ObservatoryStates<br>«state»" as n0 {
    +  state "operatingStates : ObservatoryStates<br>«state»" as n0 {
         state "idle<br>«state»<br>initial" as n1
         state "observing<br>«state»" as n2
         [*] --> n1
    diff --git a/internal/core/docrender/testdata/telescope_report.golden.md b/internal/doc/docrender/testdata/telescope_report.golden.md
    similarity index 91%
    rename from internal/core/docrender/testdata/telescope_report.golden.md
    rename to internal/doc/docrender/testdata/telescope_report.golden.md
    index d749159408..c32c2d8b2a 100644
    --- a/internal/core/docrender/testdata/telescope_report.golden.md
    +++ b/internal/doc/docrender/testdata/telescope_report.golden.md
    @@ -6,7 +6,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\]
     
     
     
    -
     *Subsystems grouped by zone*
     
     **zone: support \| \*frame\***
    @@ -27,7 +26,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\]
     
     ## Subsystem Masses \| by \*name\*
     
    -
     *All subsystems by mass*
     
     | name | mass |
    @@ -37,7 +35,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\]
     | optics | 8.5 |
     | segmentControl | 20 |
     
    -
     *Mass margins (allocated - estimated)*
     
     | name | label | margin |
    @@ -47,7 +44,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\]
     | optics | subsystem: optics | 1.5 |
     | segmentControl | subsystem: segmentControl | 5.5 |
     
    -
     *Subsystem notes*
     
     | shortName | name | documentation |
    @@ -82,7 +78,6 @@ mount segmentControl
     
     ## Diagrams
     
    -
     *Imaging chain interconnection*
     
     ```mermaid
    @@ -94,7 +89,7 @@ config:
     ---
     %% Observatory::interconnectView — interconnection rendering (render asInterconnectionDiagram)
     flowchart LR
    -  subgraph n0 ["Observatory::imagingChain
    «part»"] + subgraph n0 ["imagingChain
    «part»"] direction LR n1["camera : Camera
    «part»"] n2["recorder : Recorder
    «part»"] @@ -102,14 +97,13 @@ flowchart LR n1 ---|"link"| n2 ``` - *Observatory states, left to right* ```mermaid %% state rendering (the diagram states kind "state") stateDiagram-v2 direction LR - state "Observatory::operatingStates : ObservatoryStates
    «state»" as n0 { + state "operatingStates : ObservatoryStates
    «state»" as n0 { state "idle
    «state»
    initial" as n1 state "observing
    «state»" as n2 [*] --> n1 @@ -122,7 +116,6 @@ stateDiagram-v2 The declared type of the telescope, by relationship traversal. - *Type of telescope* | element | diff --git a/internal/core/docrender/testdata/telescope_report.plantuml.golden.md b/internal/doc/docrender/testdata/telescope_report.plantuml.golden.md similarity index 92% rename from internal/core/docrender/testdata/telescope_report.plantuml.golden.md rename to internal/doc/docrender/testdata/telescope_report.plantuml.golden.md index 593e4ef7d7..a6213ff893 100644 --- a/internal/core/docrender/testdata/telescope_report.plantuml.golden.md +++ b/internal/doc/docrender/testdata/telescope_report.plantuml.golden.md @@ -6,7 +6,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\] - *Subsystems grouped by zone* **zone: support \| \*frame\*** @@ -27,7 +26,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\] ## Subsystem Masses \| by \*name\* - *All subsystems by mass* | name | mass | @@ -37,7 +35,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\] | optics | 8.5 | | segmentControl | 20 | - *Mass margins (allocated - estimated)* | name | label | margin | @@ -47,7 +44,6 @@ Reading order: *em\*ph\*asis* **bold\_move** `` mass >= `limit` `` [the \[spec\] | optics | subsystem: optics | 1.5 | | segmentControl | subsystem: segmentControl | 5.5 | - *Subsystem notes* | shortName | name | documentation | @@ -82,7 +78,6 @@ mount segmentControl ## Diagrams - *Imaging chain interconnection* ```plantuml @@ -128,7 +123,7 @@ note { skinparam wrapWidth 300 hide stereotype -rectangle "**Observatory::imagingChain**\n//«part»//" as n0 <> <> { +rectangle "**imagingChain**\n//«part»//" as n0 <> <> { rectangle "**camera : Camera**\n//«part»//" as n1 <> <> rectangle "**recorder : Recorder**\n//«part»//" as n2 <> <> } @@ -136,7 +131,6 @@ n1 -[thickness=3]- n2 : link @enduml ``` - *Observatory states, left to right* ```plantuml @@ -184,7 +178,7 @@ skinparam wrapWidth 300 hide stereotype left to right direction hide empty description -state "**Observatory::operatingStates : ObservatoryStates**\n//«state»//" as n0 <> <> { +state "**operatingStates : ObservatoryStates**\n//«state»//" as n0 <> <> { state "**idle**\n//«state»//\ninitial" as n1 <> <> state "**observing**\n//«state»//" as n2 <> <> [*] --> n1 @@ -198,7 +192,6 @@ n2 --> n1 The declared type of the telescope, by relationship traversal. - *Type of telescope* | element | diff --git a/internal/core/docrender/testdata/telescope_report.sysml b/internal/doc/docrender/testdata/telescope_report.sysml similarity index 100% rename from internal/core/docrender/testdata/telescope_report.sysml rename to internal/doc/docrender/testdata/telescope_report.sysml diff --git a/internal/doc/docrender/testdata/verdict_report.golden.md b/internal/doc/docrender/testdata/verdict_report.golden.md new file mode 100644 index 0000000000..d29d57bdff --- /dev/null +++ b/internal/doc/docrender/testdata/verdict_report.golden.md @@ -0,0 +1,20 @@ +# Car Checks + +The assertions about the car and what it holds. + +*Verdicts on the car* + +| path | name | verdict | reason | +| --- | --- | --- | --- | +| car | massOk | holds | | +| car | fits | undecided | constraint fits: assertion evaluation failed: no value for feature capacity | +| car.engine | powerLow | violated | constraint powerLow: assertion evaluated to false: power \< 200.0 | +| car.engine | | holds | | +| car.engine | checkEngine | holds | | +| car.wheels\[1\] | pressureOk | violated | constraint pressureOk: assertion evaluated to false: pressure >= 30.0 | +| car.wheels\[2\] | pressureOk | violated | constraint pressureOk: assertion evaluated to false: pressure >= 30.0 | + +- assert constraint fits on car: undecided +- assert constraint powerLow on car.engine: violated +- assert constraint pressureOk on car.wheels\[1\]: violated +- assert constraint pressureOk on car.wheels\[2\]: violated diff --git a/internal/doc/docrender/testdata/verdict_report.sysml b/internal/doc/docrender/testdata/verdict_report.sysml new file mode 100644 index 0000000000..7b88ca41dd --- /dev/null +++ b/internal/doc/docrender/testdata/verdict_report.sysml @@ -0,0 +1,68 @@ +package Garage { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + part def Wheel { + attribute pressure : Real default = 32.0; + assert constraint pressureOk { pressure >= 30.0 } + } + part def Engine { + attribute power : Real = 300.0; + assert constraint powerLow { power < 200.0 } + } + part def Car { + attribute mass : Real = 1500.0; + attribute capacity : Real; + part engine : Engine; + part wheels : Wheel[2] { + attribute :>> pressure = 20.0; + } + assert constraint massOk { mass < 2000.0 } + assert constraint fits { mass <= capacity } + } + requirement def PowerReq { + subject e : Engine; + require constraint { e.power > 100.0 } + } + part car : Car; + requirement strongEngine : PowerReq; + satisfy strongEngine by car.engine; + verification def PowerCheck { + subject e : Engine; + objective { verify strongEngine; } + VerificationCases::PassIf(e.power > 100.0) + } + verification checkEngine : PowerCheck { subject e = car.engine; } + + calc def Checks :> Query { + in root : Element; + Project( + source = Verdicts(source = root), + properties = ("path", "name", "verdict", "reason") + ) + } + calc def Failing :> Query { + in root : Element; + WhereFeature(source = Verdicts(source = root), 'feature' = "verdict", operator = "!=", value = "holds") + } + + part def CarReport :> Document { + attribute redefines title = "Car Checks"; + + part intro : Paragraph { + attribute redefines text = "The assertions about the car and what it holds."; + } + part checks : Table { + attribute redefines caption = "Verdicts on the car"; + calc rows : Checks { + in root = car; + } + } + part failing : List { + calc items : Failing { + in root = car; + } + } + } +} diff --git a/internal/doc/docrender/themes/acm.css b/internal/doc/docrender/themes/acm.css new file mode 100644 index 0000000000..ddd20a4d2a --- /dev/null +++ b/internal/doc/docrender/themes/acm.css @@ -0,0 +1,105 @@ +/* acm: ACM Primary Article Template (acmart, acmsmall/manuscript) conventions: + Libertine 10pt text, bold sans numbered heads, 9pt captions in the text face. */ +@layer opensysml { + .sysml-document { + --sysml-font-body: "Libertinus Serif", "Linux Libertine O", "Linux Libertine", "Times New Roman", "Liberation Serif", serif; + --sysml-font-heading: "Libertinus Sans", "Linux Biolinum O", "Linux Biolinum", Arial, Helvetica, "Liberation Sans", sans-serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 10pt; + --sysml-line-height: 1.2; + --sysml-measure: 6.5in; + --sysml-space: 0.6em; + --sysml-space-tight: 0.2em; + --sysml-text: #000000; + --sysml-muted: #000000; + --sysml-accent: #000000; + --sysml-rule: #000000; + --sysml-surface: transparent; + --sysml-cell-padding: 0.2em 0.5em; + --sysml-code-font-size: 0.9em; + --sysml-caption-font-size: 9pt; + --sysml-table-font-size: 9pt; + --sysml-title-page-height: 80vh; + --sysml-group-heading-weight: 700; + --sysml-paragraph-indent: 10pt; + } + + .sysml-document h1, + .sysml-document h2, + .sysml-document h3, + .sysml-document h4 { + font-family: var(--sysml-font-heading); + font-weight: 700; + line-height: 1.2; + text-align: left; + break-after: avoid; + page-break-after: avoid; + } + + .sysml-document .sysml-title { + font-size: 17pt; + } + + .sysml-document h2 { + font-size: 10pt; + margin: 1.8em 0 0.6em; + } + + .sysml-document h3 { + font-size: 10pt; + margin: 1.2em 0 0.4em; + } + + .sysml-document h4 { + font-size: 10pt; + font-weight: 400; + font-style: italic; + margin: 1em 0 0.3em; + } + + .sysml-document .sysml-paragraph { + margin: 0; + text-indent: var(--sysml-paragraph-indent); + hyphens: auto; + } + + .sysml-document .sysml-table { + font-size: var(--sysml-table-font-size); + } + + .sysml-document .sysml-table th, + .sysml-document .sysml-table td { + border: none; + border-bottom: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-table thead th { + border-top: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-caption { + caption-side: top; + font-family: var(--sysml-font-body); + font-size: var(--sysml-caption-font-size); + text-align: left; + padding-bottom: var(--sysml-space-tight); + } + + .sysml-document .sysml-diagram { + text-align: center; + } + + .sysml-document .sysml-diagram pre { + text-align: left; + border: var(--sysml-border-width) solid var(--sysml-rule); + padding: var(--sysml-space-tight); + overflow-x: visible; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .sysml-document .sysml-link, + .sysml-document .sysml-ref { + text-decoration: none; + } +} diff --git a/internal/doc/docrender/themes/acm.print.css b/internal/doc/docrender/themes/acm.print.css new file mode 100644 index 0000000000..d1ff8a7a95 --- /dev/null +++ b/internal/doc/docrender/themes/acm.print.css @@ -0,0 +1,27 @@ +/* acm: the print companion, laid over the PDF backend's print sheet so the + letter page, one-inch margins, 10pt Libertine and sans heads reach paper. */ +@layer opensysml-print-theme { + :root { + --sysml-page-size: letter; + --sysml-page-margin: 1in; + --sysml-page-number-font-family: "Libertinus Serif", "Linux Libertine O", "Linux Libertine", "Times New Roman", "Liberation Serif", serif; + --sysml-page-number-font-size: 9pt; + --sysml-page-number-color: #000000; + } + + .sysml-document { + --sysml-font-body: "Libertinus Serif", "Linux Libertine O", "Linux Libertine", "Times New Roman", "Liberation Serif", serif; + --sysml-font-heading: "Libertinus Sans", "Linux Biolinum O", "Linux Biolinum", Arial, Helvetica, "Liberation Sans", sans-serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 10pt; + --sysml-line-height: 1.2; + --sysml-space: 0.6em; + --sysml-title-font-size: 17pt; + --sysml-heading-font-size: 17pt; + --sysml-subheading-font-size: 10pt; + --sysml-subsubheading-font-size: 10pt; + --sysml-diagram-font-size: 9pt; + --sysml-wide-table-font-size: 9pt; + --sysml-title-page-height: 80vh; + } +} diff --git a/internal/doc/docrender/themes/ieee.css b/internal/doc/docrender/themes/ieee.css new file mode 100644 index 0000000000..1ceb74a3f0 --- /dev/null +++ b/internal/doc/docrender/themes/ieee.css @@ -0,0 +1,111 @@ +/* ieee: IEEE Transactions/Journals author conventions (IEEE Editorial Style + Manual, IEEEtran): Times 10pt justified, small-caps heads, 8pt captions. */ +@layer opensysml { + .sysml-document { + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-heading: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 10pt; + --sysml-line-height: 1.2; + --sysml-measure: 7.17in; + --sysml-space: 0.6em; + --sysml-space-tight: 0.2em; + --sysml-text: #000000; + --sysml-muted: #000000; + --sysml-accent: #000000; + --sysml-rule: #000000; + --sysml-surface: transparent; + --sysml-cell-padding: 0.15em 0.4em; + --sysml-code-font-size: 0.9em; + --sysml-caption-font-size: 8pt; + --sysml-table-font-size: 8pt; + --sysml-title-page-height: 80vh; + --sysml-group-heading-weight: 700; + --sysml-paragraph-indent: 1pc; + } + + .sysml-document h1, + .sysml-document h2, + .sysml-document h3, + .sysml-document h4 { + font-family: var(--sysml-font-heading); + font-weight: 400; + line-height: 1.2; + break-after: avoid; + page-break-after: avoid; + } + + .sysml-document .sysml-title { + font-size: 24pt; + text-align: center; + } + + .sysml-document h2 { + font-size: 10pt; + font-variant: small-caps; + text-align: center; + margin: 1.8em 0 0.6em; + } + + .sysml-document h3, + .sysml-document h4 { + font-size: 10pt; + font-style: italic; + margin: 1.2em 0 0.4em; + } + + .sysml-document .sysml-paragraph { + margin: 0; + text-align: justify; + text-indent: var(--sysml-paragraph-indent); + hyphens: auto; + } + + .sysml-document .sysml-table { + font-size: var(--sysml-table-font-size); + margin-left: auto; + margin-right: auto; + } + + .sysml-document .sysml-table th, + .sysml-document .sysml-table td { + border: none; + border-bottom: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-table thead th { + border-top: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-caption { + caption-side: top; + font-size: var(--sysml-caption-font-size); + font-variant: small-caps; + text-align: center; + padding-bottom: var(--sysml-space-tight); + } + + .sysml-document .sysml-diagram { + text-align: center; + } + + .sysml-document .sysml-diagram .sysml-caption, + .sysml-document .sysml-formula .sysml-caption { + font-variant: normal; + text-align: left; + } + + .sysml-document .sysml-diagram pre { + text-align: left; + border: var(--sysml-border-width) solid var(--sysml-rule); + padding: var(--sysml-space-tight); + overflow-x: visible; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .sysml-document .sysml-link, + .sysml-document .sysml-ref { + text-decoration: none; + } +} diff --git a/internal/doc/docrender/themes/ieee.print.css b/internal/doc/docrender/themes/ieee.print.css new file mode 100644 index 0000000000..45e8114366 --- /dev/null +++ b/internal/doc/docrender/themes/ieee.print.css @@ -0,0 +1,27 @@ +/* ieee: the print companion, laid over the PDF backend's print sheet so the + letter page, 0.67in margins, 10pt Times and 8pt captions reach paper. */ +@layer opensysml-print-theme { + :root { + --sysml-page-size: letter; + --sysml-page-margin: 0.67in; + --sysml-page-number-font-family: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-page-number-font-size: 8pt; + --sysml-page-number-color: #000000; + } + + .sysml-document { + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-heading: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 10pt; + --sysml-line-height: 1.2; + --sysml-space: 0.6em; + --sysml-title-font-size: 24pt; + --sysml-heading-font-size: 24pt; + --sysml-subheading-font-size: 10pt; + --sysml-subsubheading-font-size: 10pt; + --sysml-diagram-font-size: 8pt; + --sysml-wide-table-font-size: 8pt; + --sysml-title-page-height: 80vh; + } +} diff --git a/internal/core/docrender/themes/modern.css b/internal/doc/docrender/themes/modern.css similarity index 100% rename from internal/core/docrender/themes/modern.css rename to internal/doc/docrender/themes/modern.css diff --git a/internal/doc/docrender/themes/nasa.css b/internal/doc/docrender/themes/nasa.css new file mode 100644 index 0000000000..2208b0a4a2 --- /dev/null +++ b/internal/doc/docrender/themes/nasa.css @@ -0,0 +1,96 @@ +/* nasa: NASA STI Report Series conventions (NASA Publications Guide for + Authors, NASA STI Standards): Times 12pt text, sans heads and tables. */ +@layer opensysml { + .sysml-document { + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-heading: Arial, Helvetica, "Liberation Sans", "Nimbus Sans", sans-serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 12pt; + --sysml-line-height: 1.25; + --sysml-measure: 6.5in; + --sysml-space: 0.75em; + --sysml-space-tight: 0.25em; + --sysml-text: #000000; + --sysml-muted: #000000; + --sysml-accent: #000000; + --sysml-rule: #000000; + --sysml-surface: transparent; + --sysml-cell-padding: 0.2em 0.5em; + --sysml-code-font-size: 0.9em; + --sysml-caption-font-size: 12pt; + --sysml-table-font-size: 11pt; + --sysml-title-page-height: 80vh; + --sysml-group-heading-weight: 700; + } + + .sysml-document h1, + .sysml-document h2, + .sysml-document h3, + .sysml-document h4 { + font-family: var(--sysml-font-heading); + font-weight: 700; + line-height: 1.2; + text-align: left; + break-after: avoid; + page-break-after: avoid; + } + + .sysml-document .sysml-title { + font-size: 24pt; + text-align: center; + } + + .sysml-document h2 { + font-size: 14pt; + margin-top: 1.5em; + } + + .sysml-document h3, + .sysml-document h4 { + font-size: 12pt; + } + + .sysml-document .sysml-table { + font-family: var(--sysml-font-heading); + } + + .sysml-document .sysml-table th, + .sysml-document .sysml-table td { + font-size: var(--sysml-table-font-size); + border: none; + border-bottom: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-table thead th { + border-top: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-caption { + caption-side: top; + font-family: var(--sysml-font-heading); + font-weight: 700; + padding-bottom: var(--sysml-space-tight); + } + + .sysml-document .sysml-diagram { + text-align: center; + } + + .sysml-document .sysml-diagram .sysml-caption { + text-align: center; + } + + .sysml-document .sysml-diagram pre { + text-align: left; + border: var(--sysml-border-width) solid var(--sysml-rule); + padding: var(--sysml-space-tight); + overflow-x: visible; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .sysml-document .sysml-link, + .sysml-document .sysml-ref { + text-decoration: none; + } +} diff --git a/internal/doc/docrender/themes/nasa.print.css b/internal/doc/docrender/themes/nasa.print.css new file mode 100644 index 0000000000..6108bc6e23 --- /dev/null +++ b/internal/doc/docrender/themes/nasa.print.css @@ -0,0 +1,52 @@ +/* nasa: the print companion, laid over the PDF backend's print sheet so the + letter page, one-inch margins, 12pt Times and roman front matter reach paper. */ +@layer opensysml-print-theme { + :root { + --sysml-page-size: letter; + --sysml-page-margin: 1in; + --sysml-page-number-font-family: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-page-number-font-size: 12pt; + --sysml-page-number-color: #000000; + } + + .sysml-document { + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-heading: Arial, Helvetica, "Liberation Sans", "Nimbus Sans", sans-serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 12pt; + --sysml-line-height: 1.25; + --sysml-space: 0.75em; + --sysml-title-font-size: 24pt; + --sysml-heading-font-size: 24pt; + --sysml-subheading-font-size: 14pt; + --sysml-subsubheading-font-size: 12pt; + --sysml-diagram-font-size: 10pt; + --sysml-wide-table-font-size: 10pt; + --sysml-title-page-height: 80vh; + } + + /* Front matter (title page, contents, a bare title leading the contents) counts + in roman on its own counter and leaves page at 0, so the body opens at 1. */ + .sysml-document .sysml-title-page { + page: cover; + } + + .sysml-document .sysml-toc, + .sysml-document > .sysml-title:has(+ .sysml-toc) { + page: front; + } + + @page cover { + counter-increment: page 0 front 1; + @bottom-center { + content: none; + } + } + + @page front { + counter-increment: page 0 front 1; + @bottom-center { + content: counter(front, lower-roman); + } + } +} diff --git a/internal/core/docrender/themes/print.css b/internal/doc/docrender/themes/print.css similarity index 100% rename from internal/core/docrender/themes/print.css rename to internal/doc/docrender/themes/print.css diff --git a/internal/doc/docrender/themes/print.print.css b/internal/doc/docrender/themes/print.print.css new file mode 100644 index 0000000000..ab48ed8ff7 --- /dev/null +++ b/internal/doc/docrender/themes/print.print.css @@ -0,0 +1,17 @@ +/* print: the print companion, laid over the PDF backend's print sheet so the + theme's compact 11pt Times text and heading scale reach paper. */ +@layer opensysml-print-theme { + .sysml-document { + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", serif; + --sysml-font-heading: "Times New Roman", Times, "Liberation Serif", serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", monospace; + --sysml-font-size: 11pt; + --sysml-line-height: 1.4; + --sysml-space: 0.8rem; + --sysml-title-font-size: 1.8em; + --sysml-heading-font-size: 1.8em; + --sysml-subheading-font-size: 1.3em; + --sysml-subsubheading-font-size: 1.1em; + --sysml-title-page-height: 80vh; + } +} diff --git a/internal/core/docrender/themes/report.css b/internal/doc/docrender/themes/report.css similarity index 97% rename from internal/core/docrender/themes/report.css rename to internal/doc/docrender/themes/report.css index aa2676f471..11b055eaf9 100644 --- a/internal/core/docrender/themes/report.css +++ b/internal/doc/docrender/themes/report.css @@ -4,7 +4,7 @@ same layer, so unlayered reader CSS still overrides it. */ @layer opensysml { .sysml-document { - --sysml-font-body: Charter, "Bitstream Charter", "Source Serif Pro", Georgia, "Times New Roman", serif; + --sysml-font-body: Charter, "Bitstream Charter", "Source Serif Pro", Georgia, "Times New Roman", "Liberation Serif", serif; --sysml-font-mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --sysml-font-size: 1.0625rem; --sysml-line-height: 1.6; diff --git a/internal/doc/docrender/themes/report.print.css b/internal/doc/docrender/themes/report.print.css new file mode 100644 index 0000000000..1b272bdd8f --- /dev/null +++ b/internal/doc/docrender/themes/report.print.css @@ -0,0 +1,17 @@ +/* report: the print companion, laid over the PDF backend's print sheet so the + theme's Charter face, 1.0625rem text and heading scale reach paper. */ +@layer opensysml-print-theme { + .sysml-document { + --sysml-font-body: Charter, "Bitstream Charter", "Source Serif Pro", Georgia, "Times New Roman", "Liberation Serif", serif; + --sysml-font-heading: Charter, "Bitstream Charter", "Source Serif Pro", Georgia, "Times New Roman", "Liberation Serif", serif; + --sysml-font-mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --sysml-font-size: 1.0625rem; + --sysml-line-height: 1.6; + --sysml-space: 1.25rem; + --sysml-title-font-size: 2.25em; + --sysml-heading-font-size: 2.25em; + --sysml-subheading-font-size: 1.5em; + --sysml-subsubheading-font-size: 1.2em; + --sysml-title-page-height: 70vh; + } +} diff --git a/internal/doc/docrender/unplaced_test.go b/internal/doc/docrender/unplaced_test.go new file mode 100644 index 0000000000..e4676e89fc --- /dev/null +++ b/internal/doc/docrender/unplaced_test.go @@ -0,0 +1,73 @@ +package docrender + +import ( + "html" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/view" +) + +// TestDocumentDiagramsSettleUnplacedNodes checks the unplaced-node placement +// reaches every document backend's DOT figures: the Markdown fence, the HTML +// figure and the artwork source a PDF draws leave a node no Layout places +// undrawn by default and set it in a strip below the drawing when asked. +func TestDocumentDiagramsSettleUnplacedNodes(t *testing.T) { + document := fixtureDocument(t, filepath.Join("testdata", "placed_report.sysml"), "Placed::PlacedReport") + const ( + omitted = "// not represented: 1 node(s) without a position, left undrawn\n" + striped = "// not represented: 1 node(s) without a position, drawn in a strip below the drawing\n" + spare = "spare : Recorder" + ) + check := func(t *testing.T, backend, got string, unplaced view.Unplaced) { + t.Helper() + notice, drawn := omitted, false + if unplaced == view.UnplacedStrip { + notice, drawn = striped, true + } + if !strings.Contains(got, notice) { + t.Errorf("%s (%q): notice %q missing:\n%s", backend, unplaced, notice, got) + } + if strings.Contains(got, spare) != drawn { + t.Errorf("%s (%q): spare drawn = %v, want %v:\n%s", backend, unplaced, !drawn, drawn, got) + } + if !strings.Contains(got, "// layout: neato -n\n") { + t.Errorf("%s (%q): every drawn node is pinned, so the header names neato -n:\n%s", backend, unplaced, got) + } + } + for _, unplaced := range []view.Unplaced{"", view.UnplacedOmit, view.UnplacedStrip} { + markdown, err := Markdown(document, MarkdownOptions{DiagramForm: view.FormDot, Unplaced: unplaced}) + if err != nil { + t.Fatalf("Markdown(%q): %v", unplaced, err) + } + check(t, "markdown", markdown, unplaced) + page, err := HTML(document, HTMLOptions{DiagramForm: view.FormDot, Unplaced: unplaced}) + if err != nil { + t.Fatalf("HTML(%q): %v", unplaced, err) + } + check(t, "html", html.UnescapeString(page), unplaced) + diagrams, err := Diagrams(document, view.FormDot, unplaced) + if err != nil { + t.Fatalf("Diagrams(%q): %v", unplaced, err) + } + if len(diagrams) != 1 { + t.Fatalf("Diagrams(%q) = %+v, want the one figure", unplaced, diagrams) + } + check(t, "artwork", diagrams[0].Source+"\n", unplaced) + } + for _, backend := range []string{"markdown", "html", "artwork"} { + var err error + switch backend { + case "markdown": + _, err = Markdown(document, MarkdownOptions{DiagramForm: view.FormDot, Unplaced: "below"}) + case "html": + _, err = HTML(document, HTMLOptions{DiagramForm: view.FormDot, Unplaced: "below"}) + default: + _, err = Diagrams(document, view.FormDot, "below") + } + if err == nil || !strings.Contains(err.Error(), `unknown placement "below"`) { + t.Errorf("%s: an unknown placement is not refused: %v", backend, err) + } + } +} diff --git a/internal/doc/docrender/verdicts_test.go b/internal/doc/docrender/verdicts_test.go new file mode 100644 index 0000000000..342fb38330 --- /dev/null +++ b/internal/doc/docrender/verdicts_test.go @@ -0,0 +1,114 @@ +package docrender + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/doc/docir" + "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/docplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// verdictFixtureDocument evaluates the verdict report: over a session holding +// the car when held is set, over the model as declared otherwise. +func verdictFixtureDocument(t *testing.T, held bool) *docir.Document { + t.Helper() + fixture := loadRenderFixture(t, filepath.Join("testdata", "verdict_report.sysml")) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + context := queryexec.Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model} + if held { + inst, err := ctx.Instantiate(fixture.symbol(t, "Garage::car")) + if err != nil { + t.Fatalf("Instantiate car: %v", err) + } + context.Runtime = ctx + context.Roots = []queryexec.Root{{Label: "car", Object: inst}} + } + report := symbols.PreferDeclared(fixture.index.LookupQualified("Garage::CarReport")) + if len(report) != 1 { + t.Fatalf("lookup Garage::CarReport: got %d symbols", len(report)) + } + plan, err := docplan.Compile(fixture.index, fixture.model, fixture.resolver, report[0]) + if err != nil { + t.Fatalf("compile document: %v", err) + } + document, err := docir.Evaluate(plan, context, queryexec.Options{}, nil) + if err != nil { + t.Fatalf("evaluate document: %v", err) + } + return document +} + +// TestMarkdownVerdictReportGolden locks the Markdown of a document over +// verdicts: one row per assertion on the car and the objects it holds, with the +// verdict and its reason, and a list of the verdicts that do not hold. +func TestMarkdownVerdictReportGolden(t *testing.T) { + got, err := Markdown(verdictFixtureDocument(t, true), MarkdownOptions{}) + if err != nil { + t.Fatalf("render document: %v", err) + } + golden := filepath.Join("testdata", "verdict_report.golden.md") + if *update { + if err := os.WriteFile(golden, []byte(got), 0o644); err != nil { + t.Fatalf("update golden: %v", err) + } + return + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden (run with -update to create): %v", err) + } + if got != string(want) { + t.Errorf("rendered Markdown differs from %s (run with -update after intentional changes)\ngot:\n%s", golden, got) + } +} + +// TestMarkdownVerdictReportDeclared checks that the same document with no +// session checks the car as declared: the same assertions, by qualified path. +func TestMarkdownVerdictReportDeclared(t *testing.T) { + got, err := Markdown(verdictFixtureDocument(t, false), MarkdownOptions{}) + if err != nil { + t.Fatalf("render document: %v", err) + } + for _, want := range []string{ + "| Garage::car | massOk | holds | |\n", + "| Garage::car | fits | undecided | ", + "| Garage::car.engine | powerLow | violated | ", + "| Garage::car.wheels\\[1\\] | pressureOk | violated | ", + "- assert constraint fits on Garage::car: undecided\n", + } { + if !strings.Contains(got, want) { + t.Errorf("declared rendering does not contain %q\n%s", want, got) + } + } + if strings.Contains(got, "| car |") { + t.Errorf("a session holding nothing rendered a session label:\n%s", got) + } +} + +// TestHTMLVerdictReport checks the HTML of a document over verdicts: a row +// carries the verdict, the carrier's path and identity and the assertion; a +// verdict-valued item is marked as such. +func TestHTMLVerdictReport(t *testing.T) { + got, err := HTML(verdictFixtureDocument(t, true), HTMLOptions{Fragment: true}) + if err != nil { + t.Fatalf("render document as HTML: %v", err) + } + for _, want := range []string{ + `
    `, + ``, + ``, + `data-object="#2" data-verdict="holds" data-path="car.engine" data-element="Garage::" data-element-kind="satisfyRequirementUsage">`, + ``, + ``, + `
  • assert constraint powerLow on car.engine: violated
  • `, + } { + if !strings.Contains(got, want) { + t.Errorf("rendering does not contain %q\n%s", want, got) + } + } +} diff --git a/internal/doc/queryexec/computed.go b/internal/doc/queryexec/computed.go new file mode 100644 index 0000000000..492a7e8cba --- /dev/null +++ b/internal/doc/queryexec/computed.go @@ -0,0 +1,596 @@ +package queryexec + +import ( + "errors" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// computedColumn is one planned columns entry of a projection: a +// Column(name, expression), or a RelatedColumn when related is set. +type computedColumn struct { + name string + expression queryplan.Expression + related *relatedColumn + origin queryplan.Expression +} + +// computedColumns decodes Project's structural columns argument. +func (e *executor) computedColumns(project, value queryplan.Expression) ([]computedColumn, error) { + var elements []queryplan.Expression + switch value.Operation() { + case queryplan.OperationSequence: + for _, argument := range value.Arguments() { + elements = append(elements, argument.Value) + } + case queryplan.OperationColumn, queryplan.OperationRelatedColumn: + elements = []queryplan.Expression{value} + default: + return nil, e.invalidArgument(project, "columns", string(value.Operation())) + } + columns := make([]computedColumn, 0, len(elements)) + for _, element := range elements { + column := computedColumn{name: element.Target(), origin: element} + switch element.Operation() { + case queryplan.OperationColumn: + expression, ok := argumentValue(element, "expression") + if !ok { + return nil, e.invalidArgument(project, "columns", column.name) + } + column.expression = expression + case queryplan.OperationRelatedColumn: + related, err := e.relatedColumnOf(element) + if err != nil { + return nil, err + } + column.related = related + default: + return nil, e.invalidArgument(project, "columns", string(element.Operation())) + } + columns = append(columns, column) + } + return columns, nil +} + +// propertyTracker records the row properties a projection read and whether +// each was ever present, so an unknown property stays a typed failure. +type propertyTracker struct { + order []string + present map[string]bool +} + +func newPropertyTracker() *propertyTracker { + return &propertyTracker{present: make(map[string]bool)} +} + +func (t *propertyTracker) record(property string, present bool) { + if _, seen := t.present[property]; !seen { + t.order = append(t.order, property) + } + t.present[property] = t.present[property] || present +} + +func (t *propertyTracker) missing() (string, bool) { + for _, property := range t.order { + if !t.present[property] { + return property, true + } + } + return "", false +} + +// evaluateColumnCell evaluates one computed column for one row element. +// A failure or absent final result fails the query; ?? defaults absence. +func (e *executor) evaluateColumnCell( + column computedColumn, + row Value, + tracker *propertyTracker, +) ([]Value, error) { + if column.related != nil { + return e.evaluateRelatedCell(column, row) + } + values, err := e.evaluateColumnExpression(column.expression, column.name, row, tracker) + if err != nil { + return nil, err + } + if len(values) == 0 { + return nil, e.columnError( + ErrorColumnAbsent, column.name, row, column.expression.Origin(), "", "") + } + if len(values) > 1 { + return nil, e.columnError( + ErrorColumnCardinality, column.name, row, column.expression.Origin(), + "", strconv.Itoa(len(values))) + } + return values, nil +} + +func (e *executor) evaluateColumnExpression( + expression queryplan.Expression, + column string, + row Value, + tracker *propertyTracker, +) ([]Value, error) { + switch expression.Operation() { + case queryplan.OperationRowProperty: + return e.rowPropertyValues(expression, row, tracker) + case queryplan.OperationLiteral: + value, err := e.evaluateLiteral(expression) + if err != nil { + return nil, err + } + return value.values, nil + case queryplan.OperationParameter: + binding, ok := e.bindings[expression.Target()] + if !ok { + return nil, e.errorAt(ErrorMissingBinding, expression) + } + return append([]Value(nil), binding.values...), nil + case queryplan.OperationColumnOperator: + return e.evaluateColumnOperator(expression, column, row, tracker) + default: + return nil, &Error{ + Kind: ErrorUnsupportedOperation, + Query: e.definition.Name(), + Operation: expression.Operation(), + Property: column, + Origin: expression.Origin(), + } + } +} + +// rowPropertyValues evaluates a row property: metadata features read the query's +// bookkeeping; declared features read the object, verdict, state, event or +// element the row carries, conforming to the property's declaring type. +func (e *executor) rowPropertyValues( + expression queryplan.Expression, + row Value, + tracker *propertyTracker, +) ([]Value, error) { + property := expression.Target() + _, declaring := expression.Literal() + if declaringIsElement(declaring) { + values, present, err := e.propertyValues(row, property) + if err != nil { + return nil, e.unevaluable(expression, property, row, err) + } + tracker.record(property, present) + return values, nil + } + if inst, _, isObject := row.Object(); isObject { + return e.objectRowValues(expression, property, declaring, row, inst) + } + var values []Value + var done bool + var err error + row, values, done, err = e.carrierRowValues(expression, property, declaring, row) + if done { + return values, err + } + sym, _ := row.Element() + if isMetaclassFQN(declaring) { + if !e.context.Model.MetaclassConforms(sym, declaring) { + return nil, nil + } + return e.reflectiveFeatureValues(expression, property, sym) + } + if !e.rowConformsTo(sym, declaring) { + // The row is unrelated to the declaring type: read as absent so a + // ?? operator can default it. The feature resolved at planning. + return nil, nil + } + declared, _, err := e.declaredFeatureValues(sym, property) + if err != nil { + return nil, e.unevaluable(expression, property, ElementValue(sym), err) + } + return declared, nil +} + +// objectRowValues reads a declared or metaclass feature of an object row. +func (e *executor) objectRowValues( + expression queryplan.Expression, + property, declaring string, + row Value, + inst *runtime.Instance, +) ([]Value, error) { + if isMetaclassFQN(declaring) { + decl := objectDeclaration(inst) + if !e.context.Model.MetaclassConforms(decl, declaring) { + return nil, nil + } + return e.reflectiveFeatureValues(expression, property, decl) + } + if !e.objectConformsTo(inst, declaring) { + return nil, nil + } + values, _, err := e.objectFeatureValues(row, property) + if err != nil { + return nil, e.unevaluable(expression, property, row, err) + } + return values, nil +} + +// carrierRowValues reads a record row's own property when declaring names the +// record's type, else unwraps the element the record carries; done reports the +// property was evaluated (or the record carries nothing) rather than unwrapped. +func (e *executor) carrierRowValues( + expression queryplan.Expression, + property, declaring string, + row Value, +) (Value, []Value, bool, error) { + if verdict, isVerdict := row.Verdict(); isVerdict { + return e.verdictRowValues(expression, property, declaring, row, verdict) + } + if state, isState := row.State(); isState { + return e.stateRowValues(expression, property, declaring, row, state) + } + if event, isEvent := row.Event(); isEvent { + return e.eventRowValues(expression, property, declaring, row, event) + } + return row, nil, false, nil +} + +// verdictRowValues reads a verdict's own property, or unwraps the assertion. +func (e *executor) verdictRowValues( + expression queryplan.Expression, property, declaring string, row Value, verdict Verdict, +) (Value, []Value, bool, error) { + if declaring != verdictFQN { + return ElementValue(verdict.Assertion()), nil, false, nil + } + values, _, err := e.verdictPropertyValues(row, property) + if err != nil { + return row, nil, true, e.unevaluable(expression, property, row, err) + } + return row, values, true, nil +} + +// stateRowValues reads a state's own property, or unwraps its declaration. +func (e *executor) stateRowValues( + expression queryplan.Expression, property, declaring string, row Value, state State, +) (Value, []Value, bool, error) { + if declaring == stateFQN { + values, _, err := e.statePropertyValues(row, property) + if err != nil { + return row, nil, true, e.unevaluable(expression, property, row, err) + } + return row, values, true, nil + } + if state.symbol == nil { + return row, nil, true, nil + } + return ElementValue(state.symbol), nil, false, nil +} + +// eventRowValues reads an event's own property, or unwraps its behavior. +func (e *executor) eventRowValues( + expression queryplan.Expression, property, declaring string, row Value, event Event, +) (Value, []Value, bool, error) { + if declaring == eventFQN { + values, _, err := e.eventPropertyValues(row, property) + if err != nil { + return row, nil, true, e.unevaluable(expression, property, row, err) + } + return row, values, true, nil + } + if event.Behavior() == nil { + return row, nil, true, nil + } + return ElementValue(event.Behavior()), nil, false, nil +} + +// declaringIsElement reports whether a planned row property was declared on +// Element (or without a declaring type), so it reads query metadata. +func declaringIsElement(declaring string) bool { + return declaring == "" || declaring == "Element" || declaring == "KerML::Root::Element" +} + +// isMetaclassFQN reports whether a declaring type is a reflective metaclass +// of the abstract syntax, which the stdlib declares under KerML and SysML. +func isMetaclassFQN(fqn string) bool { + return strings.HasPrefix(fqn, "KerML::") || strings.HasPrefix(fqn, "SysML::") +} + +// reflectiveFeatureValues reads a metaclass feature (e.g. Type::isAbstract) +// from the row's declaration; an underived one is a typed failure. +func (e *executor) reflectiveFeatureValues( + expression queryplan.Expression, + property string, + sym *symbols.Symbol, +) ([]Value, error) { + values, ok := e.context.Model.ReflectiveFeatureValues(sym, property) + if !ok { + return nil, e.featureError(expression, property, ElementValue(sym)) + } + result := make([]Value, 0, len(values)) + for _, value := range values { + converted, ok := filterValue(value, sym) + if !ok { + return nil, e.featureError(expression, property, ElementValue(sym)) + } + result = append(result, converted) + } + return result, nil +} + +// objectConformsTo reports whether an object is of a feature's declaring type. +func (e *executor) objectConformsTo(inst *runtime.Instance, declaring string) bool { + for _, target := range e.context.Index.LookupQualified(declaring) { + if e.objectConforms(inst, target) { + return true + } + } + return false +} + +// rowConformsTo reports whether the row conforms to a feature's declaring +// type; unrelated same-named features read as absent. +func (e *executor) rowConformsTo(sym *symbols.Symbol, declaring string) bool { + for _, target := range e.context.Index.LookupQualified(declaring) { + if symbols.SameElement(sym, target) || e.context.Model.Conforms(sym, target) { + return true + } + // An index may hold a distinct Symbol for the same declaration, so + // compare the supertype chain by declaration, not pointer. + for _, super := range e.context.Model.AllSupertypes(sym) { + if symbols.SameElement(super, target) { + return true + } + } + } + return false +} + +func (e *executor) evaluateColumnOperator( + expression queryplan.Expression, + column string, + row Value, + tracker *propertyTracker, +) ([]Value, error) { + _, operator := expression.Literal() + operands := expression.Arguments() + if operator == "??" { + left, err := e.evaluateColumnExpression(operands[0].Value, column, row, tracker) + if err != nil { + return nil, err + } + if len(left) > 0 { + return left, nil + } + return e.evaluateColumnExpression(operands[1].Value, column, row, tracker) + } + values := make([]Value, len(operands)) + for i, operand := range operands { + operandValues, err := e.evaluateColumnExpression(operand.Value, column, row, tracker) + if err != nil { + return nil, err + } + if len(operandValues) != 1 { + return nil, e.columnError(ErrorColumnOperand, column, row, operand.Value.Origin(), + operator, strconv.Itoa(len(operandValues))) + } + values[i] = operandValues[0] + } + result, err := e.applyColumnOperator(expression, column, row, operator, values) + if err != nil { + return nil, err + } + return []Value{valueAt(result, expression.Origin())}, nil +} + +func (e *executor) applyColumnOperator( + expression queryplan.Expression, + column string, + row Value, + operator string, + values []Value, +) (Value, error) { + mismatch := func() error { + kinds := string(values[0].Kind()) + if len(values) == 2 { + kinds += " and " + string(values[1].Kind()) + } + return e.columnError(ErrorColumnOperandType, column, row, expression.Origin(), operator, kinds) + } + if hasQuantity(values) { + return e.applyQuantityOperator(expression, column, row, operator, values, mismatch) + } + if len(values) == 1 { + // Unary + and - require one numeric operand. + switch values[0].Kind() { + case ValueInteger: + integer, _ := values[0].Integer() + if operator == "-" { + integer = -integer + } + return IntegerValue(integer), nil + case ValueReal: + realVal, _ := values[0].Real() + if operator == "-" { + realVal = -realVal + } + return RealValue(realVal), nil + default: + return Value{}, mismatch() + } + } + left, right := values[0], values[1] + if operator == "+" && left.Kind() == ValueString && right.Kind() == ValueString { + l, _ := left.String() + r, _ := right.String() + return StringValue(l + r), nil + } + if !arithmeticKind(left.Kind()) || !arithmeticKind(right.Kind()) { + return Value{}, mismatch() + } + if left.Kind() == ValueInteger && right.Kind() == ValueInteger { + l, _ := left.Integer() + r, _ := right.Integer() + switch operator { + case "+": + return IntegerValue(l + r), nil + case "-": + return IntegerValue(l - r), nil + case "*": + return IntegerValue(l * r), nil + case "/": + if r == 0 { + return Value{}, e.columnError( + ErrorColumnDivisionByZero, column, row, expression.Origin(), operator, "") + } + return IntegerValue(l / r), nil + } + } + l := realOperand(left) + r := realOperand(right) + switch operator { + case "+": + return RealValue(l + r), nil + case "-": + return RealValue(l - r), nil + case "*": + return RealValue(l * r), nil + case "/": + if r == 0 { + return Value{}, e.columnError( + ErrorColumnDivisionByZero, column, row, expression.Origin(), operator, "") + } + return RealValue(l / r), nil + } + return Value{}, e.operatorError(expression, operator) +} + +func arithmeticKind(kind ValueKind) bool { + return kind == ValueInteger || kind == ValueReal +} + +func hasQuantity(values []Value) bool { + for _, value := range values { + if value.Kind() == ValueQuantity { + return true + } + } + return false +} + +// applyQuantityOperator computes with at least one quantity operand under the +// runtime's unit rules: sums need commensurable units, products compose them. +func (e *executor) applyQuantityOperator( + expression queryplan.Expression, + column string, + row Value, + operator string, + values []Value, + mismatch func() error, +) (Value, error) { + op, ok := columnOperatorKind(operator, len(values) == 1) + if !ok { + return Value{}, e.operatorError(expression, operator) + } + operands := make([]semantics.Quantity, len(values)) + for i, value := range values { + quantity, ok := quantityOperand(value) + if !ok { + return Value{}, mismatch() + } + operands[i] = quantity + } + // The runtime's operators, so a point on a measurement scale computes as evaluation does. + reader := e.derived.get(e.context) + var result semantics.Quantity + var err error + if len(operands) == 1 { + result, err = reader.QuantityUnary(op, operands[0]) + } else { + result, err = reader.QuantityBinary(op, operands[0], operands[1]) + } + switch { + case err == nil: + case errors.Is(err, semantics.ErrDivisionByZero): + return Value{}, e.columnError(ErrorColumnDivisionByZero, column, row, expression.Origin(), operator, "") + case errors.Is(err, semantics.ErrIncommensurableUnits): + units := operands[0].Unit.String() + " and " + operands[1].Unit.String() + return Value{}, e.columnError(ErrorColumnIncommensurable, column, row, expression.Origin(), operator, units) + case errors.Is(err, semantics.ErrQuantityOperand): + return Value{}, mismatch() + default: + return Value{}, e.columnError(ErrorColumnArithmetic, column, row, expression.Origin(), operator, err.Error()) + } + if result.Unit.None() { + value, ok := constantValue(result.Num) + if !ok { + return Value{}, mismatch() + } + return value, nil + } + return QuantityValue(result), nil +} + +// quantityOperand reads a column operand as a quantity, a bare number being one +// in no unit. +func quantityOperand(value Value) (semantics.Quantity, bool) { + switch value.Kind() { + case ValueQuantity: + return value.Quantity() + case ValueInteger: + integer, _ := value.Integer() + return semantics.Quantity{Num: semantics.Value{Kind: semantics.ValInt, Int: integer}, Unit: semantics.UnitOne()}, true + case ValueReal: + realVal, _ := value.Real() + return semantics.Quantity{Num: semantics.Value{Kind: semantics.ValReal, Real: realVal}, Unit: semantics.UnitOne()}, true + } + return semantics.Quantity{}, false +} + +func columnOperatorKind(operator string, unary bool) (ast.OperatorKind, bool) { + switch operator { + case "+": + if unary { + return ast.OpPos, true + } + return ast.OpAdd, true + case "-": + if unary { + return ast.OpNeg, true + } + return ast.OpSub, true + case "*": + return ast.OpMul, true + case "/": + return ast.OpDiv, true + } + return 0, false +} + +func realOperand(value Value) float64 { + if integer, ok := value.Integer(); ok { + return float64(integer) + } + realVal, _ := value.Real() + return realVal +} + +func (e *executor) columnError( + kind ErrorKind, + column string, + row Value, + origin symbols.Origin, + operator string, + actual string, +) error { + return &Error{ + Kind: kind, + Query: e.definition.Name(), + Operation: queryplan.OperationProject, + Property: column, + Target: rowTarget(row), + Parameter: operator, + Actual: actual, + Origin: origin, + } +} diff --git a/internal/core/queryexec/computed_test.go b/internal/doc/queryexec/computed_test.go similarity index 93% rename from internal/core/queryexec/computed_test.go rename to internal/doc/queryexec/computed_test.go index 4d8069151e..90f15d5592 100644 --- a/internal/core/queryexec/computed_test.go +++ b/internal/doc/queryexec/computed_test.go @@ -547,3 +547,38 @@ calc def Bad :> Query { }) } } + +func TestExecuteWhereFeatureIsIndividualOverUnboundedDescendants(t *testing.T) { + fixture := loadExecutionFixture(t, ` +part def Wheel; +part def SpareWheel :> Wheel; +package Config { + package Nested { + individual part def FrontLeft :> Wheel; + } + individual part def Spare :> SpareWheel; +} +calc def Instances :> Query { + in root : Element; + Project( + source = WhereFeature( + source = WhereType(source = Descendants(source = root), type = "Wheel"), + 'feature' = "isIndividual", + operator = "=", + value = "true" + ), + properties = ("name") + ) +}`) + result, err := fixture.execute(t, "Instances", Bindings{ + "root": {ElementValue(fixture.symbol(t, "Config"))}, + }, Options{}) + if err != nil { + t.Fatalf("execute Instances: %v", err) + } + // Nested is two levels down, so a bounded depth of 1 would miss FrontLeft; + // SpareWheel is a plain subdefinition and is not an individual. + if names := rowNames(result); !equalStrings(names, []string{"Observatory::Config::Spare", "Observatory::Config::Nested::FrontLeft"}) { + t.Fatalf("rows = %v", names) + } +} diff --git a/internal/doc/queryexec/derivation.go b/internal/doc/queryexec/derivation.go new file mode 100644 index 0000000000..94b406efb2 --- /dev/null +++ b/internal/doc/queryexec/derivation.go @@ -0,0 +1,255 @@ +package queryexec + +import ( + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// Library elements a derivation (a connection conforming to Derivation, its ends +// stating roles by subsetting or metadata) and a refinement dependency are recognized by. +const ( + derivationDefinitionFQN = "DerivationConnections::Derivation" + originalRequirementsFQN = "DerivationConnections::originalRequirements" + derivedRequirementsFQN = "DerivationConnections::derivedRequirements" + originalMetadataFQN = "RequirementDerivation::OriginalRequirementMetadata" + derivedMetadataFQN = "RequirementDerivation::DerivedRequirementMetadata" + requirementCheckFQN = "Requirements::RequirementCheck" + refinementMetadataFQN = "ModelingMetadata::Refinement" + metaclassFeatureClient = "client" + metaclassFeatureSupplier = "supplier" +) + +// derivationRole is the role a derivation end states for its requirements. +type derivationRole int + +const ( + roleUnstated derivationRole = iota + roleOriginal + roleDerived +) + +// derivationEnd is one end of a derivation: its requirements and their stated role. +type derivationEnd struct { + role derivationRole + referents []*symbols.Symbol +} + +// scanDerivation records the edges from each original requirement to each derived one: +// a usage relates the requirements its ends attach to, a definition those its ends are typed by. +func (e *executor) scanDerivation(edges *relationshipEdges, sym *symbols.Symbol) { + derivation := e.librarySymbol(derivationDefinitionFQN) + if derivation == nil || !e.isDerivation(sym, derivation) { + return + } + var ends []derivationEnd + switch sym.Decl.(type) { + case *ast.Usage: + ends = e.derivationUsageEnds(sym) + case *ast.Definition: + ends = e.derivationDefinitionEnds(sym) + } + originals, derived := splitDerivationEnds(ends) + for _, original := range originals { + for _, target := range derived { + addEdge(edges, original, target) + } + } +} + +// isDerivation reports whether sym is a connection conforming to Derivation without being it. +func (e *executor) isDerivation(sym, derivation *symbols.Symbol) bool { + switch decl := sym.Decl.(type) { + case *ast.Definition: + if decl.Kind != ast.DefConnection { + return false + } + case *ast.Usage: + if decl.Kind != ast.UsageConnection { + return false + } + default: + return false + } + return !symbols.SameElement(sym, derivation) && e.context.Model.Conforms(sym, derivation) +} + +// derivationUsageEnds returns a usage's ends in declaration order: the `connect` +// clause's, then its body's `end` features, each with the role its end feature states. +func (e *executor) derivationUsageEnds(sym *symbols.Symbol) []derivationEnd { + var ends []derivationEnd + for _, attachment := range e.context.Model.ConnectorEndAttachments(sym) { + end := derivationEnd{role: e.derivationRoleOf(attachment.EndFeature)} + if attachment.Attachment != nil { + if target, ok := e.context.Resolver.ResolveTarget(sym.OwnerScope, attachment.Attachment); ok && target != nil { + end.referents = append(end.referents, target) + } + } + ends = append(ends, end) + } + for _, feature := range bodyEnds(sym) { + end := derivationEnd{role: e.derivationRoleOf(feature)} + for _, rel := range relationshipsOfEnd(feature) { + if rel.Kind != ast.RelReferences && rel.Kind != ast.RelSubsets { + continue + } + target, ok := e.context.Resolver.ResolveTarget(feature.OwnerScope, rel.Target) + if !ok || target == nil || e.isDerivationRoleFeature(target) { + continue + } + end.referents = append(end.referents, target) + } + ends = append(ends, end) + } + return ends +} + +// derivationDefinitionEnds returns a definition's effective ends — its own, then the +// inherited ones none of them redefines — each standing for its requirement types. +func (e *executor) derivationDefinitionEnds(sym *symbols.Symbol) []derivationEnd { + var ends []derivationEnd + for _, feature := range e.context.Model.EndFeatures(sym) { + ends = append(ends, derivationEnd{ + role: e.derivationRoleOf(feature), + referents: e.requirementTypes(feature), + }) + } + return ends +} + +// requirementTypes is the requirement definitions among an end's effective types, +// declared on it or inherited from the ends it redefines. +func (e *executor) requirementTypes(feature *symbols.Symbol) []*symbols.Symbol { + var out []*symbols.Symbol + for _, typ := range e.context.Model.FeatureTypeSet(feature) { + if e.conformsToLibrary(typ, requirementCheckFQN) { + out = append(out, typ) + } + } + return out +} + +// derivationRoleOf is the role an end feature states by its metadata or by conforming +// to the library's role features, directly or through the end it redefines. +func (e *executor) derivationRoleOf(feature *symbols.Symbol) derivationRole { + if feature == nil { + return roleUnstated + } + switch { + case e.annotatedBy(feature, originalMetadataFQN), e.conformsToLibrary(feature, originalRequirementsFQN): + return roleOriginal + case e.annotatedBy(feature, derivedMetadataFQN), e.conformsToLibrary(feature, derivedRequirementsFQN): + return roleDerived + } + return roleUnstated +} + +// isDerivationRoleFeature reports whether subsetting target states a role rather +// than naming a requirement. +func (e *executor) isDerivationRoleFeature(target *symbols.Symbol) bool { + return e.conformsToLibrary(target, originalRequirementsFQN) || e.conformsToLibrary(target, derivedRequirementsFQN) +} + +// splitDerivationEnds applies the stated roles; when no end states the original, +// the first end without a role is the original and the other unstated ends are derived. +func splitDerivationEnds(ends []derivationEnd) (originals, derived []*symbols.Symbol) { + hasOriginal := false + for _, end := range ends { + if end.role == roleOriginal { + hasOriginal = true + break + } + } + for _, end := range ends { + switch { + case end.role == roleOriginal: + originals = append(originals, end.referents...) + case end.role == roleDerived: + derived = append(derived, end.referents...) + case !hasOriginal: + hasOriginal = true + originals = append(originals, end.referents...) + default: + derived = append(derived, end.referents...) + } + } + return originals, derived +} + +// bodyEnds returns the `end` features sym's body declares, in declaration order. +func bodyEnds(sym *symbols.Symbol) []*symbols.Symbol { + if sym.Scope == nil { + return nil + } + var ends []*symbols.Symbol + seen := make(map[*symbols.Symbol]bool) + sym.Scope.ForEachMember(func(member *symbols.Symbol) bool { + if usage, ok := member.Decl.(*ast.Usage); ok && usage.IsEnd && !seen[member] { + seen[member] = true + ends = append(ends, member) + } + return true + }) + return ends +} + +// relationshipsOfEnd returns the declared relationships of an `end` feature that name a target. +func relationshipsOfEnd(feature *symbols.Symbol) []*ast.Relationship { + usage, ok := feature.Decl.(*ast.Usage) + if !ok { + return nil + } + var out []*ast.Relationship + for _, rel := range usage.Relationships { + if rel != nil && rel.Target != nil { + out = append(out, rel) + } + } + return out +} + +// scanRefinement records the edges of a dependency annotated Refinement, from each +// client to each supplier; a plain dependency states none. +func (e *executor) scanRefinement(edges *relationshipEdges, sym *symbols.Symbol) { + if _, ok := sym.Decl.(*ast.Dependency); !ok || !e.annotatedBy(sym, refinementMetadataFQN) { + return + } + clients, _ := e.context.Model.ReflectiveElements(sym, metaclassFeatureClient) + suppliers, _ := e.context.Model.ReflectiveElements(sym, metaclassFeatureSupplier) + for _, client := range clients { + for _, supplier := range suppliers { + addEdge(edges, client, supplier) + } + } +} + +// annotatedBy reports whether an annotation of sym is typed by, or specializes, the metadata at fqn. +func (e *executor) annotatedBy(sym *symbols.Symbol, fqn string) bool { + def := e.librarySymbol(fqn) + if def == nil { + return false + } + for _, annotation := range e.context.Model.AnnotationFactsOf(sym) { + for _, actual := range e.context.Index.LookupQualified(annotation.TypeFQN) { + if symbols.SameElement(actual, def) || e.context.Model.Conforms(actual, def) { + return true + } + } + } + return false +} + +// conformsToLibrary reports whether sym is, or conforms to, the element at fqn. +func (e *executor) conformsToLibrary(sym *symbols.Symbol, fqn string) bool { + target := e.librarySymbol(fqn) + return target != nil && (symbols.SameElement(sym, target) || e.context.Model.Conforms(sym, target)) +} + +// librarySymbol returns the element at a library qualified name, or nil when it is not loaded. +func (e *executor) librarySymbol(fqn string) *symbols.Symbol { + for _, match := range e.context.Index.LookupQualified(fqn) { + if match != nil { + return match + } + } + return nil +} diff --git a/internal/doc/queryexec/derivation_test.go b/internal/doc/queryexec/derivation_test.go new file mode 100644 index 0000000000..c09e9203d8 --- /dev/null +++ b/internal/doc/queryexec/derivation_test.go @@ -0,0 +1,114 @@ +package queryexec + +import "testing" + +func TestExecuteRelatedDerivationUsages(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_derivation.sysml") + + // A typed usage and a `#derivation` usage read their clause ends in order; + // the plain connection from systemMass states no derivation. + assertRelated(t, fixture, "systemMass", "derivation", "outgoing", 1, + []string{"mirrorMass"}) + assertRelated(t, fixture, "mirrorMass", "derivation", "outgoing", 1, + []string{"segmentMass"}) + assertRelated(t, fixture, "mirrorMass", "derivation", "incoming", 1, + []string{"systemMass"}) + assertRelated(t, fixture, "segmentMass", "derivation", "incoming", 1, + []string{"mirrorMass"}) + + // maxDepth follows derived-of-derived chains in both directions. + assertRelated(t, fixture, "systemMass", "derivation", "outgoing", 2, + []string{"mirrorMass", "segmentMass"}) + assertRelated(t, fixture, "segmentMass", "derivation", "incoming", 2, + []string{"mirrorMass", "systemMass"}) + + // An n-ary clause derives every later end from the first. + assertRelated(t, fixture, "instrumentMass", "derivation", "outgoing", 1, + []string{"cameraMass", "spectrographMass"}) + assertRelated(t, fixture, "spectrographMass", "derivation", "incoming", 1, + []string{"instrumentMass"}) + assertRelated(t, fixture, "instrumentMass", "derivation", "incoming", 1, nil) +} + +func TestExecuteRelatedDerivationBodyEndRoles(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_derivation.sysml") + + // Roles stated by `#original`/`#derive` metadata and by subsetting the + // library's role features override end order. + assertRelated(t, fixture, "pointingAccuracy", "derivation", "outgoing", 1, + []string{"trackingAccuracy", "guidingAccuracy"}) + assertRelated(t, fixture, "trackingAccuracy", "derivation", "incoming", 1, + []string{"pointingAccuracy"}) + assertRelated(t, fixture, "guidingAccuracy", "derivation", "incoming", 1, + []string{"pointingAccuracy"}) + assertRelated(t, fixture, "trackingAccuracy", "derivation", "outgoing", 1, nil) + + // An end stating no role takes the one left over: derived beside a stated + // original, original beside a stated derived. + assertRelated(t, fixture, "thermalStability", "derivation", "outgoing", 1, + []string{"heaterPower"}) + assertRelated(t, fixture, "heaterPower", "derivation", "outgoing", 1, nil) + assertRelated(t, fixture, "opticsAlignment", "derivation", "outgoing", 1, + []string{"mirrorFigure"}) + assertRelated(t, fixture, "mirrorFigure", "derivation", "incoming", 1, + []string{"opticsAlignment"}) +} + +func TestExecuteRelatedDerivationDefinitions(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_derivation.sysml") + + // A usage of a derivation definition takes its roles from the definition's + // ends, whatever order its own ends are written in. + assertRelated(t, fixture, "pointingBudget", "derivation", "outgoing", 1, + []string{"jitterBudget", "driftBudget"}) + assertRelated(t, fixture, "driftBudget", "derivation", "incoming", 1, + []string{"pointingBudget"}) + + // A definition's typed ends relate the requirement definitions: the + // `#derivation` definition and the migrator's specializing form alike. + assertRelated(t, fixture, "MassRequirement", "derivation", "outgoing", 1, + []string{"MirrorMassRequirement", "InstrumentMassRequirement"}) + assertRelated(t, fixture, "MirrorMassRequirement", "derivation", "outgoing", 1, + []string{"SegmentMassRequirement"}) + assertRelated(t, fixture, "MassRequirement", "derivation", "outgoing", 2, + []string{"MirrorMassRequirement", "InstrumentMassRequirement", "SegmentMassRequirement"}) + assertRelated(t, fixture, "SegmentMassRequirement", "derivation", "incoming", 2, + []string{"MirrorMassRequirement", "MassRequirement"}) + + // A specializing definition keeps the ends it inherits, with their roles and + // types, and an end redefined without a type keeps the redefined end's type; + // an end typed by nothing stands for no requirement, not for an end's base type. + assertRelated(t, fixture, "AlignmentRequirement", "derivation", "outgoing", 1, + []string{"SegmentAlignmentRequirement", "ActuatorAlignmentRequirement"}) + assertRelated(t, fixture, "MirrorAlignmentRequirement", "derivation", "outgoing", 1, + []string{"SegmentAlignmentRequirement"}) + assertRelated(t, fixture, "SegmentAlignmentRequirement", "derivation", "incoming", 1, + []string{"AlignmentRequirement", "MirrorAlignmentRequirement"}) + assertRelated(t, fixture, "ActuatorAlignmentRequirement", "derivation", "incoming", 1, + []string{"AlignmentRequirement"}) +} + +func TestExecuteRelatedRefinement(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_derivation.sysml") + + // Prefix and body metadata forms, from client to supplier. + assertRelated(t, fixture, "pointingBudget", "refinement", "outgoing", 1, + []string{"pointingAccuracy"}) + assertRelated(t, fixture, "pointingAccuracy", "refinement", "incoming", 1, + []string{"pointingBudget"}) + assertRelated(t, fixture, "mirrorDesign", "refinement", "outgoing", 1, + []string{"mirrorMass"}) + assertRelated(t, fixture, "mirrorMass", "refinement", "incoming", 1, + []string{"mirrorDesign"}) + + // Several clients and suppliers relate every client to every supplier. + assertRelated(t, fixture, "mountDesign", "refinement", "outgoing", 1, + []string{"trackingAccuracy", "guidingAccuracy"}) + assertRelated(t, fixture, "guidingAccuracy", "refinement", "incoming", 1, + []string{"mountDesign", "guiderDesign"}) + + // A plain dependency states no refinement; a refinement is no derivation. + assertRelated(t, fixture, "thermalDesign", "refinement", "outgoing", 1, nil) + assertRelated(t, fixture, "systemMass", "refinement", "incoming", 1, nil) + assertRelated(t, fixture, "pointingBudget", "derivation", "incoming", 1, nil) +} diff --git a/internal/doc/queryexec/derived.go b/internal/doc/queryexec/derived.go new file mode 100644 index 0000000000..042aaa767e --- /dev/null +++ b/internal/doc/queryexec/derived.go @@ -0,0 +1,129 @@ +package queryexec + +import ( + "errors" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// derivedValues holds the runtime reader behind derived attribute values, made +// on first use and shared by every executor of one execution, and the +// verification runs the execution has made. +type derivedValues struct { + reader *runtime.DeclaredReader + verified map[verificationKey][]runtime.VerificationVerdict +} + +// verificationKey identifies one run of the cases verifying a requirement. +type verificationKey struct { + verifier verifier + requirement *symbols.Symbol +} + +func (d *derivedValues) get(context Context) *runtime.DeclaredReader { + if d.reader == nil { + d.reader = runtime.NewDeclaredReader(context.Model, context.Resolver) + } + return d.reader +} + +// verifications runs the verification cases verifying req once per execution +// and verifier, in the scopes given. +func (d *derivedValues) verifications(verifier verifier, scopes []*symbols.Scope, req *symbols.Symbol) []runtime.VerificationVerdict { + key := verificationKey{verifier: verifier, requirement: req} + if cases, ok := d.verified[key]; ok { + return cases + } + if d.verified == nil { + d.verified = make(map[verificationKey][]runtime.VerificationVerdict) + } + cases := verifier.VerificationVerdictsIn(scopes, req) + d.verified[key] = cases + return cases +} + +// derivedFeatureValues evaluates a feature the constant folder could not, as +// seen from the row's element. An unbound leaf reads as absent; anything else +// the runtime cannot evaluate is a typed error carrying the runtime's reason. +func (e *executor) derivedFeatureValues(sym *symbols.Symbol, property string) ([]Value, error) { + value, err := e.derived.get(e.context).Read(sym, property) + if err != nil { + var noValue *runtime.NoValueError + if errors.As(err, &noValue) && !semantics.IsParameter(noValue.Symbol) { + return nil, nil + } + return nil, e.unevaluable(queryplan.Expression{}, property, ElementValue(sym), err) + } + return e.cellValues(value, property, ElementValue(sym)) +} + +// cellValues converts a runtime value to the values of one cell: collections +// flatten, and a result no cell can hold — an object, an infinity — is a +// typed error. +func (e *executor) cellValues(value runtime.Value, property string, row Value) ([]Value, error) { + origin := row.Origin() + if lit := value.EnumerationLiteral(); lit != nil { + return []Value{valueAt(StringValue(symbols.FQNOf(lit)), origin)}, nil + } + var elements []runtime.Value + switch value.Kind { + case runtime.ValNull: + return nil, nil + case runtime.ValSequence: + elements = value.Sequence().Elements() + case runtime.ValSet: + elements = value.Set().Elements() + case runtime.ValConst: + converted, ok := constValue(value.Const) + if !ok { + return nil, e.unevaluable(queryplan.Expression{}, property, row, notAValue(value)) + } + return []Value{valueAt(converted, origin)}, nil + case runtime.ValString: + return []Value{valueAt(StringValue(value.Str()), origin)}, nil + case runtime.ValQuantity: + return []Value{valueAt(QuantityValue(*value.Quantity()), origin)}, nil + default: + return nil, e.unevaluable(queryplan.Expression{}, property, row, notAValue(value)) + } + var result []Value + for _, element := range elements { + values, err := e.cellValues(element, property, row) + if err != nil { + return nil, err + } + result = append(result, values...) + } + return result, nil +} + +// constValue converts a folded constant; an infinity has no query value. +func constValue(value semantics.Value) (Value, bool) { + switch value.Kind { + case semantics.ValBool: + return BooleanValue(value.Bool), true + case semantics.ValInt: + return IntegerValue(value.Int), true + case semantics.ValReal: + return RealValue(value.Real), true + default: + return Value{}, false + } +} + +// errNotAValue reports a runtime result no cell can hold: an object, a +// deferred expression, an infinity. +type errNotAValue struct{ kind string } + +func (e *errNotAValue) Error() string { return "evaluates to " + e.kind + ", not a value" } + +func notAValue(value runtime.Value) error { + kind := value.Kind.String() + if value.Kind == runtime.ValConst && value.Const.Kind == semantics.ValInfinity { + kind = "an infinity" + } + return &errNotAValue{kind: kind} +} diff --git a/internal/core/queryexec/derived_test.go b/internal/doc/queryexec/derived_test.go similarity index 99% rename from internal/core/queryexec/derived_test.go rename to internal/doc/queryexec/derived_test.go index ae897a58b5..352b8d0607 100644 --- a/internal/core/queryexec/derived_test.go +++ b/internal/doc/queryexec/derived_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // derivedBody is the derived-value repro: masses computed from sibling features, diff --git a/internal/core/queryexec/documentation_test.go b/internal/doc/queryexec/documentation_test.go similarity index 100% rename from internal/core/queryexec/documentation_test.go rename to internal/doc/queryexec/documentation_test.go diff --git a/internal/doc/queryexec/errors.go b/internal/doc/queryexec/errors.go new file mode 100644 index 0000000000..fcd6f2d9b7 --- /dev/null +++ b/internal/doc/queryexec/errors.go @@ -0,0 +1,278 @@ +package queryexec + +import ( + "fmt" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// ErrorKind classifies a document-query execution failure. +type ErrorKind string + +const ( + ErrorInvalidContext ErrorKind = "invalid-context" + ErrorUnknownBinding ErrorKind = "unknown-binding" + ErrorMissingBinding ErrorKind = "missing-binding" + ErrorBindingType ErrorKind = "binding-type" + ErrorBindingMultiplicity ErrorKind = "binding-multiplicity" + ErrorMissingElement ErrorKind = "missing-element" + ErrorUnsupportedOperation ErrorKind = "unsupported-operation" + ErrorInvalidArgument ErrorKind = "invalid-argument" + ErrorInvalidOperator ErrorKind = "invalid-operator" + ErrorInvalidOrder ErrorKind = "invalid-order" + ErrorUnknownProperty ErrorKind = "unknown-property" + ErrorUnknownClassification ErrorKind = "unknown-classification" + ErrorUnknownElement ErrorKind = "unknown-element" + ErrorUnknownRelationship ErrorKind = "unknown-relationship" + ErrorUnevaluableFeature ErrorKind = "unevaluable-feature" + ErrorUnknownInvocation ErrorKind = "unknown-invocation" + ErrorInvocationCycle ErrorKind = "invocation-cycle" + ErrorInvocationDepth ErrorKind = "invocation-depth" + ErrorInvocationBudget ErrorKind = "invocation-budget" + ErrorVisitBudget ErrorKind = "visit-budget" + ErrorResultType ErrorKind = "result-type" + ErrorResultMultiplicity ErrorKind = "result-multiplicity" + ErrorColumnOperand ErrorKind = "column-operand" + ErrorColumnOperandType ErrorKind = "column-operand-type" + ErrorColumnDivisionByZero ErrorKind = "column-division-by-zero" + ErrorColumnIncommensurable ErrorKind = "column-incommensurable" + ErrorColumnArithmetic ErrorKind = "column-arithmetic" + ErrorColumnAbsent ErrorKind = "column-absent" + ErrorColumnCardinality ErrorKind = "column-cardinality" + // ErrorNoRuntime: an operation over a session's objects ran with no session. + ErrorNoRuntime ErrorKind = "no-runtime" + // ErrorObjectRow: a model-only operation was given a runtime object row. + ErrorObjectRow ErrorKind = "object-row" + // ErrorVerdictRow: a model-only operation was given a verdict row. + ErrorVerdictRow ErrorKind = "verdict-row" + // ErrorNotAnObject: Verdicts was asked about an element that declares no object. + ErrorNotAnObject ErrorKind = "not-an-object" + // ErrorIncompleteValidation: Verdicts could not check every assertion about a row. + ErrorIncompleteValidation ErrorKind = "incomplete-validation" + // ErrorStateRow: a model-only operation was given a state row. + ErrorStateRow ErrorKind = "state-row" + // ErrorEventRow: a model-only operation was given an event row. + ErrorEventRow ErrorKind = "event-row" + // ErrorNotHeld: a runtime operation was given an element the session holds no object of. + ErrorNotHeld ErrorKind = "not-held" + // ErrorNoStateMachine: States was asked about an object exhibiting no state machine. + ErrorNoStateMachine ErrorKind = "no-state-machine" + // ErrorUnknownState: InState named a state no session object's machine declares. + ErrorUnknownState ErrorKind = "unknown-state" + // ErrorObjectDestroyed: States or Events was given an object the run destroyed. + ErrorObjectDestroyed ErrorKind = "object-destroyed" + // ErrorNoTrace: Events ran in a session that records no trace. + ErrorNoTrace ErrorKind = "no-trace" + // ErrorInvalidInterval: an Events bound is not an instant on the clock, or the interval is empty. + ErrorInvalidInterval ErrorKind = "invalid-interval" + // ErrorTraceTruncated: Events reaches back to records the session's bounded trace has dropped. + ErrorTraceTruncated ErrorKind = "trace-truncated" + // ErrorUndeclaredRow: a RelatedColumn was to traverse from a row no element declares. + ErrorUndeclaredRow ErrorKind = "undeclared-row" +) + +// Error is a typed query-execution failure with plan provenance. +type Error struct { + Kind ErrorKind + Query string + Operation queryplan.Operation + Parameter string + Property string + Target string + Path []string + Expected string + Actual string + Origin symbols.Origin + // Cause is the evaluator's reason an unevaluable feature could not be read. + Cause error +} + +func (e *Error) Unwrap() error { return e.Cause } + +// column names the projected column an error arose in, when it did in one. +func (e *Error) column() string { + if e.Property == "" { + return "" + } + return " column " + e.Property +} + +func (e *Error) Error() string { + if message, ok := e.columnMessage(); ok { + return message + } + if message, ok := e.sessionMessage(); ok { + return message + } + switch e.Kind { + case ErrorInvalidContext: + return "document query execution requires a program, index, resolver, and semantic model" + case ErrorUnknownBinding: + return fmt.Sprintf("query %s received unknown binding %s", e.Query, e.Parameter) + case ErrorMissingBinding: + return fmt.Sprintf("query %s requires binding %s", e.Query, e.Parameter) + case ErrorBindingType: + return fmt.Sprintf("query %s binding %s has type %s, expected %s", e.Query, e.Parameter, e.Actual, e.Expected) + case ErrorBindingMultiplicity: + return fmt.Sprintf("query %s binding %s has multiplicity %s, expected %s", e.Query, e.Parameter, e.Actual, e.Expected) + case ErrorMissingElement: + return fmt.Sprintf("query %s names an element that is not retained in the plan", e.Query) + case ErrorUnsupportedOperation: + return fmt.Sprintf("query %s operation %s is not executable in this engine version", e.Query, e.Operation) + case ErrorInvalidArgument: + return fmt.Sprintf("query %s operation %s%s has invalid argument %s", e.Query, e.Operation, e.column(), e.Parameter) + case ErrorInvalidOperator: + return fmt.Sprintf("query %s operation %s%s does not support %q", e.Query, e.Operation, e.column(), e.Actual) + case ErrorInvalidOrder: + if e.Expected != "" || e.Actual != "" { + return fmt.Sprintf("query %s cannot order property %s across incommensurable units %s and %s", e.Query, e.Property, e.Expected, e.Actual) + } + return fmt.Sprintf("query %s cannot order incomparable values of property %s", e.Query, e.Property) + case ErrorUnknownProperty: + return fmt.Sprintf("query %s references unknown property %s", e.Query, e.Property) + case ErrorUnknownClassification: + return fmt.Sprintf("query %s references unknown classification %s", e.Query, e.Actual) + case ErrorUnknownElement: + return fmt.Sprintf("query %s names no single element %s", e.Query, e.Actual) + case ErrorUnknownRelationship: + return fmt.Sprintf("query %s%s does not support relationship kind %q", e.Query, e.column(), e.Actual) + case ErrorUnevaluableFeature: + if e.Property == "" { + message := fmt.Sprintf("query %s cannot evaluate %s", e.Query, e.Target) + if e.Cause != nil { + message += ": " + e.Cause.Error() + } + return message + } + message := fmt.Sprintf("query %s cannot evaluate feature %s", e.Query, e.Property) + if e.Target != "" { + message += " of " + e.Target + } + if e.Cause != nil { + message += ": " + e.Cause.Error() + } + return message + case ErrorUnknownInvocation: + return fmt.Sprintf("query %s invokes %s, which is not compiled into the plan", e.Query, e.Target) + case ErrorInvocationCycle: + return fmt.Sprintf("query %s re-entered %s during invocation: %s", e.Query, e.Target, strings.Join(e.Path, " -> ")) + case ErrorInvocationDepth: + return fmt.Sprintf("query %s exceeded the invocation depth limit invoking %s", e.Query, e.Target) + case ErrorInvocationBudget: + return fmt.Sprintf("query %s exceeded the invocation budget invoking %s", e.Query, e.Target) + case ErrorVisitBudget: + if e.Property != "" { + return fmt.Sprintf("query %s exceeded its visit budget in column %s", e.Query, e.Property) + } + return fmt.Sprintf("query %s exceeded its visit budget", e.Query) + case ErrorResultType: + return fmt.Sprintf("query %s produced %s, expected %s", e.Query, e.Actual, e.Expected) + case ErrorResultMultiplicity: + return fmt.Sprintf("query %s produced multiplicity %s, expected %s", e.Query, e.Actual, e.Expected) + default: + return fmt.Sprintf("query execution failed for %s", e.Query) + } +} + +// columnMessage spells the failures of a computed column. +func (e *Error) columnMessage() (string, bool) { + switch e.Kind { + case ErrorColumnOperand: + return fmt.Sprintf( + "query %s column %s requires one value per %q operand, got %s for %s", + e.Query, + e.Property, + e.Parameter, + e.Actual, + e.Target, + ), true + case ErrorColumnOperandType: + return fmt.Sprintf( + "query %s column %s cannot apply %q to %s for %s", + e.Query, + e.Property, + e.Parameter, + e.Actual, + e.Target, + ), true + case ErrorColumnAbsent: + return fmt.Sprintf( + "query %s column %s has no value for %s; use ?? to supply a default", + e.Query, + e.Property, + e.Target, + ), true + case ErrorColumnCardinality: + return fmt.Sprintf( + "query %s column %s produced %s values, expected one for %s", + e.Query, + e.Property, + e.Actual, + e.Target, + ), true + case ErrorColumnDivisionByZero: + return fmt.Sprintf("query %s column %s divides by zero for %s", e.Query, e.Property, e.Target), true + case ErrorColumnIncommensurable: + return fmt.Sprintf( + "query %s column %s cannot apply %q to quantities in incommensurable units %s for %s", + e.Query, + e.Property, + e.Parameter, + e.Actual, + e.Target, + ), true + case ErrorColumnArithmetic: + return fmt.Sprintf("query %s column %s cannot compute %q for %s: %s", e.Query, e.Property, e.Parameter, e.Target, e.Actual), true + default: + return "", false + } +} + +// sessionMessage spells the failures of an operation over a session's objects. +func (e *Error) sessionMessage() (string, bool) { + switch e.Kind { + case ErrorNoRuntime: + return fmt.Sprintf("query %s operation %s reads a session's objects, and this execution has no session: instantiate an object first", e.Query, e.Operation), true + case ErrorObjectRow: + return fmt.Sprintf("query %s operation %s applies to model elements, not to object %s", e.Query, e.Operation, e.Target), true + case ErrorVerdictRow: + return fmt.Sprintf("query %s operation %s applies to model elements, not to verdict %s", e.Query, e.Operation, e.Target), true + case ErrorNotAnObject: + return fmt.Sprintf("query %s operation %s cannot check %s, which declares no object", e.Query, e.Operation, e.Target), true + case ErrorIncompleteValidation: + return fmt.Sprintf("query %s operation %s could not check every assertion about %s: %v", e.Query, e.Operation, e.Target, e.Cause), true + case ErrorStateRow: + return fmt.Sprintf("query %s operation %s applies to model elements, not to state %s", e.Query, e.Operation, e.Target), true + case ErrorEventRow: + return fmt.Sprintf("query %s operation %s applies to model elements, not to event %s", e.Query, e.Operation, e.Target), true + case ErrorUndeclaredRow: + return fmt.Sprintf("query %s operation %s%s traverses from %s, which no element declares", e.Query, e.Operation, e.column(), e.Target), true + case ErrorNotHeld: + return fmt.Sprintf("query %s operation %s reads the objects of %s, and the session holds none", e.Query, e.Operation, e.Target), true + case ErrorNoStateMachine: + return fmt.Sprintf("query %s operation %s asks the state of %s, which exhibits no state machine", e.Query, e.Operation, e.Target), true + case ErrorUnknownState: + return fmt.Sprintf("query %s operation %s names state %s, which no state machine the session runs declares", e.Query, e.Operation, e.Actual), true + case ErrorObjectDestroyed: + return fmt.Sprintf("query %s operation %s asks after %s, an object destroyed at %s", e.Query, e.Operation, e.Target, e.Actual), true + case ErrorNoTrace: + return fmt.Sprintf("query %s operation %s reads the session's trace, and this session records none: turn tracing on before running", e.Query, e.Operation), true + case ErrorTraceTruncated: + return fmt.Sprintf("query %s operation %s reaches back to records the session's trace no longer keeps (%s): bound since to a later instant", e.Query, e.Operation, e.Actual), true + case ErrorInvalidInterval: + message := fmt.Sprintf("query %s operation %s has an invalid time interval", e.Query, e.Operation) + if e.Parameter != "" { + message = fmt.Sprintf("query %s operation %s bound %s is not an instant on the clock", e.Query, e.Operation, e.Parameter) + } + if e.Cause != nil { + message += ": " + e.Cause.Error() + } else if e.Actual != "" { + message += ": " + e.Actual + } + return message, true + default: + return "", false + } +} diff --git a/internal/doc/queryexec/event.go b/internal/doc/queryexec/event.go new file mode 100644 index 0000000000..f2bbcd4df1 --- /dev/null +++ b/internal/doc/queryexec/event.go @@ -0,0 +1,112 @@ +package queryexec + +import ( + "strconv" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// Event is one row of Events: a typed record of the session's trace, at the +// instant it was made. Immutable once built. +type Event struct { + record runtime.TraceRecord + at float64 + // time is the instant as the clock reports it: a quantity in the clock's + // unit when the library defines one, else a bare real. + time Value + object *runtime.Instance + label string + // machine names the behavior as the object exhibits it, else as declared. + machine string + // target labels the object a send was addressed to, "" for none. + target string + index int +} + +// Kind names the record's kind as its `kind` property does: `accept`, `send`, +// `transition`, `entry`, `exit`, `do`, `choice`, `guard`. +func (ev Event) Kind() string { return ev.record.Kind.String() } + +// At is the clock instant the record was made at, in clock units. +func (ev Event) At() float64 { return ev.at } + +// Time is the instant as its `time` property answers it. +func (ev Event) Time() Value { return ev.time } + +// Object is the object whose behavior made the record, nil for one made from +// outside the run, with the label a session reaches it by. +func (ev Event) Object() (*runtime.Instance, string) { return ev.object, ev.label } + +// Target is the object a send was addressed to, nil for any other record, +// with the label a session reaches it by. +func (ev Event) Target() (*runtime.Instance, string) { return ev.record.Target, ev.target } + +// Behavior is the state machine or action the record was made under. +func (ev Event) Behavior() *symbols.Symbol { return ev.record.Origin.Behavior } + +// Machine names the behavior as its `machine` property does, "" for none. +func (ev Event) Machine() string { return ev.machine } + +// Payload is an accept's or send's payload, `name = value` per parameter in +// name order, in the runtime's notation. +func (ev Event) Payload() []string { return payloadTexts(ev.record) } + +// Alternatives are a choice's alternatives as offered, nil for any other record. +func (ev Event) Alternatives() []string { + if choice, ok := ev.record.Note.(runtime.ChoicePoint); ok { + return append([]string(nil), choice.Alternatives...) + } + return nil +} + +// Taken is the alternative a choice took, "" for any other record. +func (ev Event) Taken() string { + choice, ok := ev.record.Note.(runtime.ChoicePoint) + if !ok || choice.Taken < 0 || choice.Taken >= len(choice.Alternatives) { + return "" + } + return choice.Alternatives[choice.Taken] +} + +// Record is the trace record the row reads. +func (ev Event) Record() runtime.TraceRecord { return ev.record } + +// Text is the record's trace text. +func (ev Event) Text() string { return ev.record.Text() } + +// Label names the row: the machine it came from and its text (`lamp.lp: enter: on`); +// a record with no object reads as its text alone. +func (ev Event) Label() string { + if ev.label == "" { + return ev.Text() + } + if ev.machine == "" { + return ev.label + ": " + ev.Text() + } + return ev.label + "." + ev.machine + ": " + ev.Text() +} + +// Summary is the event in one line: its instant in clock units, then its label. +func (ev Event) Summary() string { + return "t=" + strconv.FormatFloat(ev.at, 'g', -1, 64) + " " + ev.Label() +} + +// EventValue constructs an event value; its provenance is the object's declaration. +func EventValue(event Event) Value { + value := Value{kind: ValueEvent, event: &event} + if event.object != nil { + value.origin = objectDeclaration(event.object).Origin() + } else if event.record.Origin.Behavior != nil { + value.origin = event.record.Origin.Behavior.Origin() + } + return value +} + +// Event returns the value's event and whether it is an event value. +func (v Value) Event() (Event, bool) { + if v.kind != ValueEvent || v.event == nil { + return Event{}, false + } + return *v.event, true +} diff --git a/internal/doc/queryexec/events.go b/internal/doc/queryexec/events.go new file mode 100644 index 0000000000..f5a0ed803c --- /dev/null +++ b/internal/doc/queryexec/events.go @@ -0,0 +1,375 @@ +package queryexec + +import ( + "fmt" + "math" + "sort" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/query" +) + +// Event rows read the session's trace as a relation: one row per typed record +// an object's behavior made, in the order the run made them. + +// eventFQN declares the event properties a column expression may reference. +const eventFQN = "DocumentQueries::Event" + +// Event properties, beside the metadata every row answers. +const ( + propertyTime = "time" + propertyFrom = "from" + propertyTo = "to" + propertyTarget = "target" + propertyEvent = "event" + propertyPayload = "payload" + propertyAlternatives = "alternatives" + propertyTaken = "taken" + propertyText = "text" +) + +// eventKinds are the record kinds Events reports, in the `kind` argument's vocabulary. +var eventKinds = map[string]runtime.TraceKind{ + "accept": runtime.TraceAccept, + "send": runtime.TraceSend, + "transition": runtime.TraceTransition, + "entry": runtime.TraceEntry, + "exit": runtime.TraceExit, + "do": runtime.TraceDo, + "choice": runtime.TraceChoice, + "guard": runtime.TraceGuard, +} + +// evaluateEvents lists the trace's records of the requested kinds, made by the +// source objects (any object when unbound) at an instant in [since, before). +func (e *executor) evaluateEvents(expression queryplan.Expression) (sequence, error) { + if err := e.requireRuntime(expression); err != nil { + return sequence{}, err + } + trace := e.context.Runtime.Trace() + if trace == nil { + return sequence{}, &Error{ + Kind: ErrorNoTrace, + Query: e.definition.Name(), + Operation: expression.Operation(), + Origin: expression.Origin(), + } + } + kinds, err := e.eventKindArgument(expression) + if err != nil { + return sequence{}, err + } + from, hasFrom, err := e.instantArgument(expression, "since") + if err != nil { + return sequence{}, err + } + to, hasTo, err := e.instantArgument(expression, "before") + if err != nil { + return sequence{}, err + } + if hasFrom && hasTo && to <= from { + return sequence{}, &Error{ + Kind: ErrorInvalidInterval, + Query: e.definition.Name(), + Operation: expression.Operation(), + Actual: fmt.Sprintf("before = %s is not after since = %s", formatInstant(to), formatInstant(from)), + Origin: expression.Origin(), + } + } + if dropped, upTo := trace.Dropped(); dropped > 0 && (!hasFrom || from <= upTo) { + return sequence{}, &Error{ + Kind: ErrorTraceTruncated, + Query: e.definition.Name(), + Operation: expression.Operation(), + Actual: fmt.Sprintf("%d records up to t = %s dropped", dropped, formatInstant(upTo)), + Origin: expression.Origin(), + } + } + var only map[int64]struct{} + if hasArgument(expression, "source") { + source, err := e.objectArgument(expression, "source") + if err != nil { + return sequence{}, err + } + only = make(map[int64]struct{}, len(source.values)) + for _, row := range source.values { + inst, _, _ := row.Object() + only[inst.ID] = struct{}{} + } + } + labels, err := e.sessionLabels(expression) + if err != nil { + return sequence{}, err + } + var result sequence + for index, record := range trace.Records() { + if _, wanted := kinds[record.Kind]; !wanted { + continue + } + at := record.Origin.At + if (hasFrom && at < from) || (hasTo && at >= to) { + continue + } + object := record.Origin.Object + if only != nil { + if object == nil { + continue + } + if _, wanted := only[object.ID]; !wanted { + continue + } + } + if !e.consumeVisit() { + return sequence{}, e.budgetError(expression) + } + result.values = append(result.values, EventValue(Event{ + record: record, + at: at, + time: instantValue(e.context.Runtime, at), + object: object, + label: labels.label(object), + machine: eventMachine(record), + target: labels.label(record.Target), + index: index, + })) + } + return result, nil +} + +// instantValue is instant t as `time` answers it: a quantity in the clock's +// unit when the library defines one, else a bare real of clock units. +func instantValue(ctx *runtime.Context, t float64) Value { + value := ctx.InstantValue(t) + if quantity := value.Quantity(); quantity != nil { + return QuantityValue(*quantity) + } + return RealValue(t) +} + +// eventKindArgument reads `kind`: `all` (the default), or record kinds +// separated by `,`. +func (e *executor) eventKindArgument(expression queryplan.Expression) (map[runtime.TraceKind]struct{}, error) { + text := "all" + if hasArgument(expression, "kind") { + var err error + if text, err = e.stringArgument(expression, "kind"); err != nil { + return nil, err + } + } + kinds := make(map[runtime.TraceKind]struct{}) + for _, part := range strings.Split(text, ",") { + part = strings.TrimSpace(part) + if part == "all" { + for _, kind := range eventKinds { + kinds[kind] = struct{}{} + } + continue + } + kind, ok := eventKinds[part] + if !ok { + return nil, e.invalidArgument(expression, "kind", text) + } + kinds[kind] = struct{}{} + } + return kinds, nil +} + +// instantArgument reads a bound of the interval as clock units: a bare number +// is one already, a quantity converts through the clock's unit. Unbound is open. +func (e *executor) instantArgument(expression queryplan.Expression, name string) (float64, bool, error) { + if !hasArgument(expression, name) { + return 0, false, nil + } + value, err := e.argument(expression, name) + if err != nil { + return 0, false, err + } + if len(value.values) == 0 { + return 0, false, nil + } + if len(value.values) > 1 { + return 0, false, e.invalidArgument(expression, name, fmt.Sprintf("%d values", len(value.values))) + } + bound := value.values[0] + var instant float64 + switch { + case bound.kind == ValueInteger: + instant = float64(bound.integer) + case bound.kind == ValueReal: + instant = bound.real + case bound.kind == ValueQuantity: + quantity := bound.quantity.Clone() + magnitude, err := e.context.Runtime.ClockMagnitude(runtime.NewQuantityValue(&quantity), name) + if err != nil { + return 0, false, &Error{ + Kind: ErrorInvalidInterval, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: name, + Actual: quantity.String(), + Cause: err, + Origin: expression.Origin(), + } + } + instant = magnitude + default: + return 0, false, e.invalidArgument(expression, name, string(bound.kind)) + } + if math.IsNaN(instant) || math.IsInf(instant, 0) { + return 0, false, e.invalidArgument(expression, name, formatInstant(instant)) + } + return instant, true, nil +} + +func formatInstant(t float64) string { + return strconv.FormatFloat(t, 'g', -1, 64) +} + +// sessionLabelMap names the session's objects by the labels Objects reaches them +// under; an object the roots do not reach is named by its identity. +type sessionLabelMap map[int64]string + +func (l sessionLabelMap) label(inst *runtime.Instance) string { + if inst == nil { + return "" + } + if label, ok := l[inst.ID]; ok { + return label + } + return "#" + strconv.FormatInt(inst.ID, 10) +} + +// sessionLabels walks the session's objects once for their labels. +func (e *executor) sessionLabels(expression queryplan.Expression) (sessionLabelMap, error) { + labels := make(sessionLabelMap) + err := e.eachSessionObject(expression, func(row Value) { + inst, label, _ := row.Object() + labels[inst.ID] = label + }) + return labels, err +} + +// eventPropertyValues reads a property of an event row: the record's fields, +// then the metadata of the behavior that made it. +func (e *executor) eventPropertyValues(row Value, property string) ([]Value, bool, error) { + event, _ := row.Event() + record := event.record + origin := row.Origin() + text := func(value string) []Value { + if value == "" { + return nil + } + return []Value{valueAt(StringValue(value), origin)} + } + switch property { + case propertyKind: + return text(event.Kind()), true, nil + case propertyTime: + return []Value{valueAt(event.time, origin)}, true, nil + case propertyObject: + if event.object == nil { + return nil, true, nil + } + return []Value{valueAt(ObjectValue(event.object, event.label), origin)}, true, nil + case propertyPath: + return text(event.label), true, nil + case propertyMachine: + return text(event.machine), true, nil + case propertyState: + return text(record.State), true, nil + case propertyFrom: + return text(record.From), true, nil + case propertyTo: + return text(record.To), true, nil + case propertyTarget: + if record.Target == nil { + return nil, true, nil + } + return []Value{valueAt(ObjectValue(record.Target, event.target), origin)}, true, nil + case propertyEvent: + return text(record.Event), true, nil + case query.PropertyName: + return text(eventName(record)), true, nil + case propertyPayload: + out := make([]Value, 0, len(record.Payload)) + for _, cell := range payloadTexts(record) { + out = append(out, valueAt(StringValue(cell), origin)) + } + return out, true, nil + case propertyAlternatives: + alternatives := event.Alternatives() + out := make([]Value, 0, len(alternatives)) + for _, alt := range alternatives { + out = append(out, valueAt(StringValue(alt), origin)) + } + return out, true, nil + case propertyTaken: + return text(event.Taken()), true, nil + case propertyText: + return text(record.Text()), true, nil + } + if record.Origin.Behavior == nil { + return nil, false, nil + } + return e.propertyValues(ElementValue(record.Origin.Behavior), property) +} + +// eventMachine names the behavior a record came from as its object exhibits it, +// or as declared when it is anonymous or the record has no object. +func eventMachine(record runtime.TraceRecord) string { + behavior := record.Origin.Behavior + if behavior == nil { + return "" + } + if record.Origin.Object != nil { + for _, b := range record.Origin.Object.Behaviors() { + if b.Symbol == behavior || (b.State != nil && b.State.StateMachineSymbol() == behavior) { + if b.Name != "" { + return b.Name + } + break + } + } + } + return behavior.Name +} + +// eventName is what a record is about: the event accepted or sent, the state +// entered, exited or run, a transition's target, the choice kind or the guarded alternative. +func eventName(record runtime.TraceRecord) string { + switch record.Kind { + case runtime.TraceAccept, runtime.TraceSend: + return record.Event + case runtime.TraceTransition: + return record.To + case runtime.TraceEntry, runtime.TraceExit, runtime.TraceDo: + return record.State + case runtime.TraceChoice: + if choice, ok := record.Note.(runtime.ChoicePoint); ok { + return choice.Kind.String() + } + case runtime.TraceGuard: + if guard, ok := record.Note.(runtime.UnevaluableGuard); ok { + return guard.Alternative + } + } + return "" +} + +// payloadTexts renders an accept's or send's payload, one `name = value` +// entry per parameter in name order, in the runtime's notation. +func payloadTexts(record runtime.TraceRecord) []string { + names := make([]string, 0, len(record.Payload)) + for name := range record.Payload { + names = append(names, name) + } + sort.Strings(names) + out := make([]string, 0, len(names)) + for _, name := range names { + out = append(out, name+" = "+runtime.FormatValue(record.Payload[name])) + } + return out +} diff --git a/internal/doc/queryexec/events_test.go b/internal/doc/queryexec/events_test.go new file mode 100644 index 0000000000..5981dc04b5 --- /dev/null +++ b/internal/doc/queryexec/events_test.go @@ -0,0 +1,415 @@ +package queryexec + +import ( + "errors" + "strconv" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +const eventQueries = ` +calc def Happenings :> Query { + in root : Element; + Project( + source = Events(source = root), + properties = ("kind", "time", "path", "machine", "state", "from", "to", "event", "payload") + ) +} +calc def Accepted :> Query { + in root : Element; + in since : ScalarValue[0..1] = null; + in before : ScalarValue[0..1] = null; + Project( + source = Events(source = root, kind = "accept", since = since, before = before), + properties = ("time", "event", "payload") + ) +} +calc def Accepts :> Query { + Project(source = Events(kind = "accept"), properties = ("time", "path", "event")) +} +calc def Moves :> Query { + Project(source = Events(kind = "accept, transition"), properties = ("kind", "path", "name")) +} +calc def Sent :> Query { + Project(source = Events(kind = "send"), properties = ("time", "path", "machine", "target", "event")) +} +calc def SenderState :> Query { + Project(source = Events(kind = "send"), properties = ("path", "brightness", "rounds")) +} +calc def Steps :> Query { + Project(source = Events(kind = "do, guard"), properties = ("kind", "time", "path", "machine", "state", "name")) +} +calc def Drawn :> Query { + Project(source = Events(kind = "choice"), properties = ("time", "path", "machine", "name", "alternatives", "taken", "text")) +} +calc def DrawnBy :> Query { + in root : Element; + Project(source = Events(source = root, kind = "choice, guard"), properties = ("kind", "path", "machine", "name")) +} +calc def Between :> Query { + in since : ScalarValue; + in before : ScalarValue; + Project(source = Events(kind = "accept", since = since, before = before), properties = ("time", "path", "event")) +} +calc def Since :> Query { + Project(source = Events(kind = "accept", since = 2 [s]), properties = ("time", "path", "event")) +} +calc def Latest :> Query { + OrderBy( + source = Project( + source = WhereFeature(source = Events(), 'feature' = "kind", operator = "=", value = "transition"), + properties = ("path"), + columns = (Column(name = "move", expression = Event::'from' + " -> " + Event::'to')) + ), + property = "time", + direction = "descending", + missing = "last", + multiple = "error" + ) +} +calc def NamedToggle :> Query { + Project(source = WhereName(source = Events(kind = "accept"), operator = "=", value = "Toggle"), properties = ("time", "path")) +} +calc def Machines :> Query { + Project(source = Events(kind = "transition"), properties = ("path", "qualifiedName")) +} +calc def Backwards :> Query { + Events(since = 2 [s], before = 1 [s]) +} +calc def Empty :> Query { + Events(since = 1 [s], before = 1 [s]) +} +calc def Lengths :> Query { + Events(since = 1 [m]) +} +calc def Unkind :> Query { + Events(kind = "accept, wish") +} +calc def OfMachine :> Query { + Events(source = LampMachine) +} +calc def EventsOfEvents :> Query { + Events(source = Events()) +} +calc def EventStates :> Query { + States(source = Events()) +} +` + +func eventFixture(t *testing.T) lampFixture { + t.Helper() + return loadLampFixture(t, eventQueries) +} + +// quantity folds a quantity expression in the fixture's package for a binding. +func (f lampFixture) quantity(t *testing.T, expr string) Value { + t.Helper() + p := parser.New(source.New("", []byte(expr))) + node := p.ParseExpression() + if node == nil || len(p.Diagnostics) != 0 { + t.Fatalf("parse %q: %v", expr, p.Diagnostics) + } + quantity, ok := f.model.EvalQuantity(f.symbol(t, "lamp1").Scope, node) + if !ok { + t.Fatalf("%q does not fold to a quantity", expr) + } + return QuantityValue(quantity) +} + +// Events reads the typed trace in recorded order: an accept, then the exits, +// entries and steps its transition made, each with instant, object, machine and payload. +func TestExecuteEventsReadsTheTraceInOrder(t *testing.T) { + fixture := eventFixture(t) + got := rowTexts(t, fixture.rows(t, "Happenings", fixture.object(fixture.lamp2, "lamp2"))) + want := []string{ + "kind=entry time=0.0 [s] path=lamp2 machine=lp state=off from= to= event= payload=", + "kind=accept time=2.0 [s] path=lamp2 machine=lp state= from= to= event=Toggle payload=", + "kind=exit time=2.0 [s] path=lamp2 machine=lp state=off from= to= event= payload=", + "kind=guard time=2.0 [s] path=lamp2 machine=lp state= from= to= event= payload=", + "kind=entry time=2.0 [s] path=lamp2 machine=lp state=on from= to= event= payload=", + "kind=choice time=2.0 [s] path=lamp2 machine=lp state= from= to= event= payload=", + "kind=entry time=2.0 [s] path=lamp2 machine=lp state=run from= to= event= payload=", + "kind=entry time=2.0 [s] path=lamp2 machine=lp state=slow from= to= event= payload=", + "kind=transition time=2.0 [s] path=lamp2 machine=lp state= from=off to=on event=accept Toggle payload=", + "kind=accept time=2.5 [s] path=lamp2 machine=lp state= from= to= event=Toggle payload=", + "kind=choice time=2.5 [s] path=lamp2 machine=lp state= from= to= event= payload=", + "kind=exit time=2.5 [s] path=lamp2 machine=lp state=run from= to= event= payload=", + "kind=exit time=2.5 [s] path=lamp2 machine=lp state=slow from= to= event= payload=", + "kind=exit time=2.5 [s] path=lamp2 machine=lp state=on from= to= event= payload=", + "kind=entry time=2.5 [s] path=lamp2 machine=lp state=off from= to= event= payload=", + "kind=transition time=2.5 [s] path=lamp2 machine=lp state= from=on to=off event=accept Toggle payload=", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("lamp2 events:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + + // The payload of an accept is carried as `name = value`, the do step of a + // state and a transition's effect follow the accept that fired it. + got = rowTexts(t, fixture.rows(t, "Accepted", fixture.object(fixture.lamp1, "lamp1"))) + want = []string{ + "time=0.0 [s] event=Toggle payload=", + "time=1.0 [s] event=Dim payload=level = 3", + "time=2.5 [s] event=Boost payload=", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("lamp1 accepts:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + // A type selects the population; an object with no machine has no records. + if got := rowTexts(t, fixture.rows(t, "Accepted", Bindings{"root": {ElementValue(fixture.symbol(t, "Lamp"))}})); len(got) != 5 { + t.Fatalf("Lamp accepts = %d rows, want 5:\n%s", len(got), joinLines(got)) + } + if got := rowTexts(t, fixture.rows(t, "Happenings", fixture.object(fixture.rock, "rock"))); len(got) != 0 { + t.Fatalf("rock events:\n%s", joinLines(got)) + } +} + +// kind keeps the kinds named, one or comma-separated; every recorded kind is +// reachable with its target, machine, alternatives and the one taken. +func TestExecuteEventsByKind(t *testing.T) { + fixture := eventFixture(t) + got := rowTexts(t, fixture.rows(t, "Accepts", nil)) + want := []string{ + "time=0.0 [s] path=lamp1 event=Toggle", + "time=1.0 [s] path=lamp1 event=Dim", + "time=2.0 [s] path=lamp2 event=Toggle", + "time=2.5 [s] path=lamp2 event=Toggle", + "time=2.5 [s] path=lamp1 event=Boost", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("accepts:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + got = rowTexts(t, fixture.rows(t, "Moves", nil)) + want = []string{ + "kind=accept path=lamp1 name=Toggle", + "kind=transition path=lamp1 name=on", + "kind=accept path=lamp1 name=Dim", + "kind=transition path=lamp1 name=dim", + "kind=accept path=lamp2 name=Toggle", + "kind=transition path=lamp2 name=on", + "kind=accept path=lamp2 name=Toggle", + "kind=transition path=lamp2 name=off", + "kind=accept path=lamp1 name=Boost", + "kind=transition path=lamp1 name=fast", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("accepts and transitions:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + got = rowTexts(t, fixture.rows(t, "Sent", nil)) + want = []string{ + "time=0.0 [s] path=panel machine=marking target=object lamp1 event=Report", + "time=0.0 [s] path= machine= target=object lamp1 event=Toggle", + "time=1.0 [s] path= machine= target=object lamp1 event=Dim", + "time=2.0 [s] path= machine= target=object lamp2 event=Toggle", + "time=2.5 [s] path= machine= target=object lamp1 event=Boost", + "time=2.5 [s] path= machine= target=object lamp2 event=Toggle", + "time=2.5 [s] path=lamp1 machine=lp target=object panel event=Report", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("sends:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + // A send row reads the sending behavior's declared attributes; a message + // posted from outside the run has no behavior to read. + got = rowTexts(t, fixture.rows(t, "SenderState", nil)) + want = []string{ + "path=panel brightness= rounds=2.0", + "path= brightness= rounds=", + "path= brightness= rounds=", + "path= brightness= rounds=", + "path= brightness= rounds=", + "path= brightness= rounds=", + "path=lamp1 brightness=0.0 rounds=", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("sender attributes:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + got = rowTexts(t, fixture.rows(t, "Steps", nil)) + want = []string{ + "kind=guard time=0.0 [s] path=panel machine=marking state= name=2->split", + "kind=guard time=0.0 [s] path=lamp1 machine=lp state= name=2->on", + "kind=do time=1.0 [s] path=lamp1 machine=lp state=dim name=dim", + "kind=guard time=2.0 [s] path=lamp2 machine=lp state= name=2->on", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("do steps and guards:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + got = rowTexts(t, fixture.rows(t, "Drawn", nil)) + want = []string{ + "time=0.0 [s] path=panel machine=marking name=write order " + + "alternatives=mark of object #6 := 1 by token 2+mark of object #6 := 2 by token 3 " + + "taken=mark of object #6 := 1 by token 2 " + + "text=choice step 4: writes mark of object #6 := 1 by token 2, mark of object #6 := 2 by token 3 (unordered; mark of object #6 := 1 by token 2 stood)", + "time=0.0 [s] path=panel machine=marking name=token order " + + "alternatives=2@low+3@high taken=3@high " + + "text=choice step 4: tokens 2@low, 3@high (unordered; took 3@high first)", + "time=0.0 [s] path=lamp1 machine=lp name=entry order " + + "alternatives=run(entry)+slow(entry) taken=run(entry) " + + "text=choice entering on: next run(entry), slow(entry) (unordered; took run(entry) first)", + "time=2.0 [s] path=lamp2 machine=lp name=entry order " + + "alternatives=run(entry)+slow(entry) taken=run(entry) " + + "text=choice entering on: next run(entry), slow(entry) (unordered; took run(entry) first)", + "time=2.5 [s] path= machine= name=due order " + + "alternatives=state machine LampMachine of object #1+state machine LampMachine of object #3 " + + "taken=state machine LampMachine of object #3 " + + "text=choice at t=2.5: due state machine LampMachine of object #1, state machine LampMachine of object #3 (unordered; ran state machine LampMachine of object #3 first)", + "time=2.5 [s] path=lamp2 machine=lp name=exit order " + + "alternatives=run(exit)+slow(exit) taken=run(exit) " + + "text=choice exiting on: next run(exit), slow(exit) (unordered; took run(exit) first)", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("choices:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + // An action's choices and guards are the performer's: source reaches them. + got = rowTexts(t, fixture.rows(t, "DrawnBy", fixture.object(fixture.panel, "panel"))) + want = []string{ + "kind=guard path=panel machine=marking name=2->split", + "kind=choice path=panel machine=marking name=write order", + "kind=choice path=panel machine=marking name=token order", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("panel choices and guards:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } +} + +// The interval is [since, before): kept at since, dropped at before; bounds are +// durations in any unit of time or bare clock seconds, either left open. +func TestExecuteEventsIntervalIsClosedOpen(t *testing.T) { + fixture := eventFixture(t) + lamp1 := fixture.object(fixture.lamp1, "lamp1") + bounds := func(since, before Value) Bindings { + return Bindings{"root": lamp1["root"], "since": {since}, "before": {before}} + } + second := func(seconds string) Value { + return fixture.quantity(t, seconds+" [s]") + } + want := "time=1.0 [s] event=Dim payload=level = 3" + for name, b := range map[string]Bindings{ + "seconds": bounds(second("1"), second("2.5")), + "bare numbers": bounds(IntegerValue(1), RealValue(2.5)), + } { + got := rowTexts(t, fixture.rows(t, "Accepted", b)) + if joinLines(got) != want { + t.Fatalf("%s: accepts in [1 s, 2.5 s):\n%s", name, joinLines(got)) + } + } + got := rowTexts(t, fixture.rows(t, "Accepted", bounds(fixture.quantity(t, "0 [min]"), fixture.quantity(t, "1 [min]")))) + if len(got) != 3 { + t.Fatalf("accepts in [0 min, 1 min):\n%s", joinLines(got)) + } + got = rowTexts(t, fixture.rows(t, "Between", Bindings{"since": {second("1")}, "before": {second("2.5")}})) + if joinLines(got) != joinLines([]string{"time=1.0 [s] path=lamp1 event=Dim", "time=2.0 [s] path=lamp2 event=Toggle"}) { + t.Fatalf("accepts of every object in [1 s, 2.5 s):\n%s", joinLines(got)) + } + // Nudging before past 2.5 s admits both accepts at that instant. + got = rowTexts(t, fixture.rows(t, "Between", Bindings{"since": {second("2.5")}, "before": {second("2.6")}})) + if joinLines(got) != joinLines([]string{"time=2.5 [s] path=lamp2 event=Toggle", "time=2.5 [s] path=lamp1 event=Boost"}) { + t.Fatalf("accepts in [2.5 s, 2.6 s):\n%s", joinLines(got)) + } + got = rowTexts(t, fixture.rows(t, "Since", nil)) + if len(got) != 3 || got[0] != "time=2.0 [s] path=lamp2 event=Toggle" { + t.Fatalf("accepts since 2 s:\n%s", joinLines(got)) + } + got = rowTexts(t, fixture.rows(t, "Accepted", Bindings{"root": lamp1["root"], "before": {second("1")}})) + if joinLines(got) != "time=0.0 [s] event=Toggle payload=" { + t.Fatalf("lamp1 accepts before 1 s:\n%s", joinLines(got)) + } +} + +// Event rows take the row operations object rows do: WhereFeature, WhereName, +// OrderBy over the instant, Column over the row's properties and machine metadata. +func TestExecuteEventRowsThroughRowOperations(t *testing.T) { + fixture := eventFixture(t) + got := rowTexts(t, fixture.rows(t, "Latest", nil)) + want := []string{ + "path=lamp2 move=on -> off", + "path=lamp1 move=slow -> fast", + "path=lamp2 move=off -> on", + "path=lamp1 move=run -> dim", + "path=lamp1 move=off -> on", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("transitions, latest first:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + got = rowTexts(t, fixture.rows(t, "NamedToggle", nil)) + if joinLines(got) != joinLines([]string{"time=0.0 [s] path=lamp1", "time=2.0 [s] path=lamp2", "time=2.5 [s] path=lamp2"}) { + t.Fatalf("accepts named Toggle:\n%s", joinLines(got)) + } + got = rowTexts(t, fixture.rows(t, "Machines", nil)) + if len(got) != 5 || got[0] != "path=lamp1 qualifiedName=Observatory::LampMachine" { + t.Fatalf("machines of transitions:\n%s", joinLines(got)) + } +} + +// Every unsupported path is a typed error: no session, no trace, an empty or backwards +// interval, a bound not measuring time, an unknown kind, no object held, a row misread. +func TestExecuteEventsRefusals(t *testing.T) { + fixture := eventFixture(t) + + _, err := fixture.run(t, fixture.modelOnly(), "Accepts", nil) + executionError(t, err, ErrorNoRuntime) + + silent := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + _, err = fixture.run(t, Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Runtime: silent}, "Accepts", nil) + if got := executionError(t, err, ErrorNoTrace); !strings.Contains(got.Error(), "records none") { + t.Fatalf("no-trace error = %v", got) + } + + _, err = fixture.run(t, fixture.session(), "Backwards", nil) + if got := executionError(t, err, ErrorInvalidInterval); got.Actual != "before = 1 is not after since = 2" { + t.Fatalf("backwards error = %v", got) + } + _, err = fixture.run(t, fixture.session(), "Empty", nil) + executionError(t, err, ErrorInvalidInterval) + + _, err = fixture.run(t, fixture.session(), "Lengths", nil) + got := executionError(t, err, ErrorInvalidInterval) + if got.Parameter != "since" || !errors.Is(got, runtime.ErrIncommensurableUnits) || !strings.Contains(got.Error(), "m does not measure a duration") { + t.Fatalf("length error = %v", got) + } + + _, err = fixture.run(t, fixture.session(), "Unkind", nil) + if got := executionError(t, err, ErrorInvalidArgument); got.Parameter != "kind" || got.Actual != "accept, wish" { + t.Fatalf("kind error = %v", got) + } + + _, err = fixture.run(t, fixture.session(), "OfMachine", nil) + if got := executionError(t, err, ErrorNotHeld); got.Target != "Observatory::LampMachine" { + t.Fatalf("not-held error = %v", got) + } + _, err = fixture.run(t, fixture.session(), "EventsOfEvents", nil) + if got := executionError(t, err, ErrorEventRow); got.Target != "lamp1.lp: enter: off" { + t.Fatalf("event-row error = %v", got) + } + _, err = fixture.run(t, fixture.session(), "EventStates", nil) + executionError(t, err, ErrorEventRow) +} + +// A recorder bounded to the most recent records answers an interval that starts after +// the last record it dropped, and refuses one reaching what it no longer holds. +func TestExecuteEventsOverATruncatedTrace(t *testing.T) { + fixture := loadLampFixtureTracing(t, eventQueries, runtime.NewEventRecorder(4)) + second := func(seconds string) Value { return fixture.quantity(t, seconds+" [s]") } + dropped, upTo := fixture.trace.Dropped() + if dropped == 0 || upTo != 2.5 { + t.Fatalf("dropped %d records up to t = %v, want the run before 2.5 s dropped", dropped, upTo) + } + + got := rowTexts(t, fixture.rows(t, "Between", Bindings{"since": {second("2.6")}, "before": {second("3.5")}})) + if len(got) != 0 { + t.Fatalf("accepts in [2.6, 3.5):\n%s", joinLines(got)) + } + + for name, bindings := range map[string]Bindings{ + "Accepts": nil, + "Between": {"since": {second("2.5")}, "before": {second("2.6")}}, + "Since": nil, + } { + _, err := fixture.run(t, fixture.session(), name, bindings) + if got := executionError(t, err, ErrorTraceTruncated); got.Actual != strconv.Itoa(dropped)+" records up to t = 2.5 dropped" { + t.Fatalf("%s over a truncated trace = %v", name, got) + } + } +} diff --git a/internal/doc/queryexec/execute.go b/internal/doc/queryexec/execute.go new file mode 100644 index 0000000000..d0b5f9e5ec --- /dev/null +++ b/internal/doc/queryexec/execute.go @@ -0,0 +1,816 @@ +package queryexec + +import ( + "fmt" + "math" + "slices" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/query" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// Context provides the semantic workspace used by one execution; Index, +// Resolver and Model are required, Runtime and Roots absent over the model alone. +type Context struct { + Index *symbols.Index + Resolver *resolve.Resolver + Model *semantics.Model + // Runtime holds the objects the query may read; nil makes object bindings + // and Objects typed errors. + Runtime *runtime.Context + // Roots are the objects the session holds directly, each under its label, + // in the order Objects enumerates them. + Roots []Root + // Related memoizes relationship edge tables across the executions sharing + // this context; nil builds them once per execution. + Related *RelationshipTables +} + +// Root is one object a session holds directly, under its label (`Demo::car`, `#7`). +type Root struct { + Label string + Object *runtime.Instance +} + +// HeldRoot returns the first root materialized from a declaration, so a binding +// naming the declaration binds its object while the session holds one. +func (c Context) HeldRoot(sym *symbols.Symbol) (Value, bool) { + if sym == nil { + return Value{}, false + } + for _, root := range c.Roots { + if root.Object != nil && symbols.SameElement(root.Object.Type, sym) { + return ObjectValue(root.Object, root.Label), true + } + } + return Value{}, false +} + +// Options controls bounded query execution. +type Options struct { + VisitBudget int + InvocationDepth int + InvocationBudget int +} + +const ( + defaultVisitBudget = 100_000 + defaultInvocationDepth = 64 + defaultInvocationBudget = 10_000 +) + +type sequence struct { + values []Value + columns []Column + cells [][]Cell +} + +type visitBudget struct { + remaining int +} + +type executor struct { + definition queryplan.Definition + context Context + reader *query.PropertyReader + bindings map[string]sequence + program map[string]queryplan.Definition + budget *visitBudget + calls *visitBudget + related *RelationshipTables + derived *derivedValues + depthLeft int + stack []string +} + +// Execute evaluates a compiled entry query into an immutable ordered row set. +func Execute(program *queryplan.Program, context Context, bindings Bindings, options Options) (*RowSet, error) { + if program == nil || context.Index == nil || context.Resolver == nil || context.Model == nil { + return nil, &Error{Kind: ErrorInvalidContext} + } + if context.Runtime == nil && len(context.Roots) > 0 { + return nil, &Error{Kind: ErrorInvalidContext} + } + definition, ok := entryDefinition(program) + if !ok { + return nil, &Error{Kind: ErrorInvalidContext, Query: program.Entry()} + } + budget := options.VisitBudget + if budget == 0 { + budget = defaultVisitBudget + } + depth := options.InvocationDepth + if depth == 0 { + depth = defaultInvocationDepth + } + calls := options.InvocationBudget + if calls == 0 { + calls = defaultInvocationBudget + } + if budget < 0 || depth < 0 || calls < 0 { + return nil, &Error{Kind: ErrorInvalidContext, Query: definition.Name()} + } + definitions := program.Definitions() + compiled := make(map[string]queryplan.Definition, len(definitions)) + for _, compiledDefinition := range definitions { + compiled[compiledDefinition.Name()] = compiledDefinition + } + related := context.Related + if related == nil { + related = NewRelationshipTables() + } + execution := &executor{ + definition: definition, + context: context, + reader: query.NewPropertyReader(context.Index, context.Resolver, context.Model), + bindings: make(map[string]sequence), + program: compiled, + budget: &visitBudget{remaining: budget}, + calls: &visitBudget{remaining: calls}, + related: related, + derived: &derivedValues{}, + depthLeft: depth, + stack: []string{definition.Name()}, + } + if err := execution.bind(bindings); err != nil { + return nil, err + } + result, err := execution.evaluate(definition.Expression()) + if err != nil { + return nil, err + } + if err := execution.validateResult(result); err != nil { + return nil, err + } + rows := make([]Row, len(result.values)) + for i, value := range result.values { + var cells []Cell + if i < len(result.cells) { + cells = cloneCells(result.cells[i]) + } + rows[i] = Row{element: value, cells: cells} + } + return &RowSet{ + columns: append([]Column(nil), result.columns...), + rows: rows, + origin: definition.Origin(), + }, nil +} + +func entryDefinition(program *queryplan.Program) (queryplan.Definition, bool) { + for _, definition := range program.Definitions() { + if definition.Name() == program.Entry() { + return definition, true + } + } + return queryplan.Definition{}, false +} + +func (e *executor) bind(bindings Bindings) error { + parameters := e.definition.Parameters() + known := make(map[string]queryplan.Parameter, len(parameters)) + for _, parameter := range parameters { + known[parameter.Name] = parameter + } + for name := range bindings { + if _, ok := known[name]; !ok { + return &Error{ + Kind: ErrorUnknownBinding, + Query: e.definition.Name(), + Parameter: name, + Origin: e.definition.Origin(), + } + } + } + defaulted := make([]queryplan.Parameter, 0) + for _, parameter := range parameters { + values, present := bindings[parameter.Name] + if !present { + if parameter.HasDefault { + defaulted = append(defaulted, parameter) + continue + } + if parameter.Multiplicity.Known && parameter.Multiplicity.Lower == 0 { + e.bindings[parameter.Name] = sequence{} + continue + } + return &Error{ + Kind: ErrorMissingBinding, + Query: e.definition.Name(), + Parameter: parameter.Name, + Origin: parameter.Origin, + } + } + if err := e.bindValues(parameter, values); err != nil { + return err + } + } + // Defaults are evaluated once per execution, in parameter order, after the explicit bindings. + for _, parameter := range defaulted { + value, err := e.evaluate(parameter.Default) + if err != nil { + return err + } + if err := e.bindValues(parameter, value.values); err != nil { + return err + } + } + return nil +} + +// bindValues checks one parameter's values against its effective type and +// multiplicity, whether they were supplied by the caller or by a default. +func (e *executor) bindValues(parameter queryplan.Parameter, values []Value) error { + if !withinMultiplicity(int64(len(values)), parameter.Multiplicity) { + return &Error{ + Kind: ErrorBindingMultiplicity, + Query: e.definition.Name(), + Parameter: parameter.Name, + Expected: multiplicityString(parameter.Multiplicity), + Actual: strconv.Itoa(len(values)), + Origin: parameter.Origin, + } + } + for _, value := range values { + if !e.valueConforms(value, parameter.Type) { + return &Error{ + Kind: ErrorBindingType, + Query: e.definition.Name(), + Parameter: parameter.Name, + Expected: parameter.Type, + Actual: string(value.Kind()), + Origin: parameter.Origin, + } + } + } + e.bindings[parameter.Name] = sequence{values: append([]Value(nil), values...)} + return nil +} + +func withinMultiplicity(count int64, multiplicity queryplan.Multiplicity) bool { + if !multiplicity.Known { + return true + } + if count < multiplicity.Lower { + return false + } + return multiplicity.UpperInfinite || count <= multiplicity.Upper +} + +func multiplicityString(multiplicity queryplan.Multiplicity) string { + if !multiplicity.Known { + return "unknown" + } + upper := strconv.FormatInt(multiplicity.Upper, 10) + if multiplicity.UpperInfinite { + upper = "*" + } + return fmt.Sprintf("%d..%s", multiplicity.Lower, upper) +} + +func (e *executor) valueConforms(value Value, expected string) bool { + switch value.Kind() { + case ValueElement: + sym, ok := value.Element() + return ok && e.elementConforms(sym, expected) + case ValueObject: + inst, _, ok := value.Object() + return ok && (e.objectConformsTo(inst, expected) || isRootElement(expected)) + case ValueVerdict: + verdict, ok := value.Verdict() + return ok && e.valueConforms(ElementValue(verdict.Assertion()), expected) + case ValueState: + state, ok := value.State() + return ok && e.declaredByConforms(state.symbol, expected) + case ValueEvent: + event, ok := value.Event() + return ok && e.declaredByConforms(event.Behavior(), expected) + case ValueQuantity: + quantity, ok := value.Quantity() + return ok && e.quantityConforms(quantity, expected) + default: + actual, ok := scalarValueType(value) + return ok && e.scalarConforms(actual, expected) + } +} + +// isRootElement reports whether expected names the root of the KerML type +// hierarchy, which every element and object conforms to. +func isRootElement(expected string) bool { + return expected == "Element" || expected == "KerML::Root::Element" +} + +// declaredByConforms judges a state or event by the element declaring it; one +// no element declares is only an Element. +func (e *executor) declaredByConforms(declared *symbols.Symbol, expected string) bool { + if declared != nil { + return e.valueConforms(ElementValue(declared), expected) + } + return isRootElement(expected) +} + +func (e *executor) elementConforms(sym *symbols.Symbol, expected string) bool { + for _, target := range e.context.Index.LookupQualified(expected) { + if e.context.Model.IsDataType(target) { + return e.context.Model.LiteralConforms(sym, target) + } + if symbols.SameElement(sym, target) || e.context.Model.Conforms(sym, target) { + return true + } + } + return isRootElement(expected) +} + +func (e *executor) quantityConforms(quantity semantics.Quantity, expected string) bool { + for _, target := range e.context.Index.LookupQualified(expected) { + if c := e.context.Model.QuantityConforms(quantity.Unit.Term, target); c.Known && c.Holds { + return true + } + } + return false +} + +func (e *executor) scalarConforms(actual semantics.PrimType, expected string) bool { + for _, target := range e.context.Index.LookupQualified(expected) { + expectedType := e.context.Model.PrimTypeOf(target) + if expectedType != semantics.PrimUnknown && semantics.PrimConforms(actual, expectedType) { + return true + } + // A type above the scalar lattice (ScalarValue, DataValue) is judged + // by the scalar's library definition. + if expectedType == semantics.PrimUnknown && e.context.Model.Conforms(e.context.Model.ScalarSymbol(actual), target) { + return true + } + } + return false +} + +func scalarValueType(value Value) (semantics.PrimType, bool) { + switch value.Kind() { + case ValueBoolean: + return semantics.PrimBoolean, true + case ValueString: + return semantics.PrimString, true + case ValueInteger: + return semantics.PrimInteger, true + case ValueReal: + return semantics.PrimReal, true + default: + return semantics.PrimUnknown, false + } +} + +func (e *executor) evaluate(expression queryplan.Expression) (sequence, error) { + switch expression.Operation() { + case queryplan.OperationParameter: + value, ok := e.bindings[expression.Target()] + if !ok { + return sequence{}, e.errorAt(ErrorMissingBinding, expression) + } + return cloneSequence(value), nil + case queryplan.OperationElement: + return e.evaluateElement(expression) + case queryplan.OperationLiteral: + return e.evaluateLiteral(expression) + case queryplan.OperationSequence: + return e.evaluateSequence(expression) + case queryplan.OperationOwnedElements: + return e.evaluateOwned(expression) + case queryplan.OperationDescendants: + return e.evaluateDescendants(expression) + case queryplan.OperationAncestors: + return e.evaluateAncestors(expression) + case queryplan.OperationWhereType: + return e.evaluateWhereType(expression) + case queryplan.OperationWhereMetadata: + return e.evaluateWhereMetadata(expression) + case queryplan.OperationWhereName: + return e.evaluateWhereName(expression) + case queryplan.OperationWhereFeature: + return e.evaluateWhereFeature(expression) + case queryplan.OperationOrderBy: + return e.evaluateOrderBy(expression) + case queryplan.OperationProject: + return e.evaluateProject(expression) + case queryplan.OperationInvoke: + return e.evaluateInvoke(expression) + case queryplan.OperationRelatedElements: + return e.evaluateRelated(expression) + case queryplan.OperationNamed: + return e.evaluateNamed(expression) + case queryplan.OperationObjects: + return e.evaluateObjects(expression) + case queryplan.OperationVerdicts: + return e.evaluateVerdicts(expression) + case queryplan.OperationWhereRelated: + return e.evaluateWhereRelated(expression) + case queryplan.OperationExcept: + return e.evaluateExcept(expression) + case queryplan.OperationUnion: + return e.evaluateUnion(expression) + case queryplan.OperationStates: + return e.evaluateStates(expression) + case queryplan.OperationInState: + return e.evaluateInState(expression) + case queryplan.OperationEvents: + return e.evaluateEvents(expression) + default: + return sequence{}, &Error{ + Kind: ErrorUnsupportedOperation, + Query: e.definition.Name(), + Operation: expression.Operation(), + Origin: expression.Origin(), + } + } +} + +func (e *executor) evaluateInvoke(expression queryplan.Expression) (sequence, error) { + target := expression.Target() + definition, ok := e.program[target] + if !ok { + return sequence{}, &Error{ + Kind: ErrorUnknownInvocation, + Query: e.definition.Name(), + Operation: expression.Operation(), + Target: target, + Origin: expression.Origin(), + } + } + if slices.Contains(e.stack, target) { + return sequence{}, &Error{ + Kind: ErrorInvocationCycle, + Query: e.definition.Name(), + Operation: expression.Operation(), + Target: target, + Path: append(append([]string(nil), e.stack...), target), + Origin: expression.Origin(), + } + } + if e.depthLeft <= 0 { + return sequence{}, &Error{ + Kind: ErrorInvocationDepth, + Query: e.definition.Name(), + Operation: expression.Operation(), + Target: target, + Origin: expression.Origin(), + } + } + if e.calls.remaining <= 0 { + return sequence{}, &Error{ + Kind: ErrorInvocationBudget, + Query: e.definition.Name(), + Operation: expression.Operation(), + Target: target, + Origin: expression.Origin(), + } + } + e.calls.remaining-- + bindings := make(Bindings, len(expression.Arguments())) + for _, argument := range expression.Arguments() { + value, err := e.evaluate(argument.Value) + if err != nil { + return sequence{}, err + } + // A projected argument binds its row elements; columns and cells do not cross a binding. + bindings[argument.Name] = value.values + } + callee := &executor{ + definition: definition, + context: e.context, + reader: e.reader, + bindings: make(map[string]sequence), + program: e.program, + budget: e.budget, + calls: e.calls, + related: e.related, + derived: e.derived, + depthLeft: e.depthLeft - 1, + stack: append(append([]string(nil), e.stack...), target), + } + if err := callee.bind(bindings); err != nil { + return sequence{}, err + } + result, err := callee.evaluate(definition.Expression()) + if err != nil { + return sequence{}, err + } + if err := callee.validateResult(result); err != nil { + return sequence{}, err + } + return result, nil +} + +func (e *executor) evaluateElement(expression queryplan.Expression) (sequence, error) { + element, ok := expression.Element() + if !ok { + return sequence{}, e.errorAt(ErrorMissingElement, expression) + } + return sequence{values: []Value{valueAt(ElementValue(element), expression.Origin())}}, nil +} + +func (e *executor) evaluateLiteral(expression queryplan.Expression) (sequence, error) { + kind, raw := expression.Literal() + origin := expression.Origin() + var value Value + switch kind { + case queryplan.LiteralString: + text, err := strconv.Unquote(raw) + if err != nil { + return sequence{}, e.invalidArgument(expression, "", raw) + } + value = StringValue(text) + case queryplan.LiteralInteger: + integer, err := strconv.ParseInt(strings.ReplaceAll(raw, "_", ""), 10, 64) + if err != nil { + return sequence{}, e.invalidArgument(expression, "", raw) + } + value = IntegerValue(integer) + case queryplan.LiteralReal: + realVal, err := strconv.ParseFloat(strings.ReplaceAll(raw, "_", ""), 64) + if err != nil || math.IsInf(realVal, 0) || math.IsNaN(realVal) { + return sequence{}, e.invalidArgument(expression, "", raw) + } + value = RealValue(realVal) + case queryplan.LiteralBoolean: + boolean, err := strconv.ParseBool(raw) + if err != nil { + return sequence{}, e.invalidArgument(expression, "", raw) + } + value = BooleanValue(boolean) + case queryplan.LiteralInfinity: + value = Value{kind: ValueInfinity} + case queryplan.LiteralQuantity: + quantity, ok := expression.Quantity() + if !ok { + return sequence{}, e.invalidArgument(expression, "", raw) + } + value = QuantityValue(quantity) + case queryplan.LiteralNull: + return sequence{}, nil + default: + return sequence{}, e.invalidArgument(expression, "", raw) + } + return sequence{values: []Value{valueAt(value, origin)}}, nil +} + +func (e *executor) evaluateSequence(expression queryplan.Expression) (sequence, error) { + var result sequence + for _, argument := range expression.Arguments() { + value, err := e.evaluate(argument.Value) + if err != nil { + return sequence{}, err + } + if len(value.columns) > 0 { + return sequence{}, e.invalidArgument(expression, argument.Name, "projected row set") + } + result.values = append(result.values, value.values...) + } + return result, nil +} + +func hasArgument(expression queryplan.Expression, name string) bool { + _, ok := argumentValue(expression, name) + return ok +} + +func argumentValue(expression queryplan.Expression, name string) (queryplan.Expression, bool) { + for _, argument := range expression.Arguments() { + if argument.Name == name { + return argument.Value, true + } + } + return queryplan.Expression{}, false +} + +func (e *executor) argument(expression queryplan.Expression, name string) (sequence, error) { + for _, argument := range expression.Arguments() { + if argument.Name == name { + return e.evaluate(argument.Value) + } + } + return sequence{}, e.invalidArgument(expression, name, "missing") +} + +// rowArgument evaluates an argument whose values are rows: model elements, or +// runtime objects and verdicts about them when the execution has a session. +func (e *executor) rowArgument(expression queryplan.Expression, name string) (sequence, error) { + value, err := e.argument(expression, name) + if err != nil { + return sequence{}, err + } + for _, item := range value.values { + if _, ok := item.Element(); ok { + continue + } + if _, _, ok := item.Object(); ok && e.context.Runtime != nil { + continue + } + if _, ok := item.Verdict(); ok { + continue + } + if _, ok := item.State(); ok { + continue + } + if _, ok := item.Event(); ok { + continue + } + return sequence{}, e.invalidArgument(expression, name, string(item.Kind())) + } + return value, nil +} + +// ownershipArgument is rowArgument for an operation walking ownership, which +// elements and objects have and a verdict, state or event row does not. +func (e *executor) ownershipArgument(expression queryplan.Expression, name string) (sequence, error) { + value, err := e.rowArgument(expression, name) + if err != nil { + return sequence{}, err + } + for _, item := range value.values { + if verdict, ok := item.Verdict(); ok { + return sequence{}, e.verdictRowError(expression, name, verdict) + } + if err := e.runtimeRowError(expression, name, item); err != nil { + return sequence{}, err + } + } + return value, nil +} + +// elementArgument is rowArgument for an operation defined over the model +// alone, which an object, verdict, state or event row cannot pass through. +func (e *executor) elementArgument(expression queryplan.Expression, name string) (sequence, error) { + value, err := e.rowArgument(expression, name) + if err != nil { + return sequence{}, err + } + for _, item := range value.values { + if _, label, ok := item.Object(); ok { + return sequence{}, &Error{ + Kind: ErrorObjectRow, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: name, + Target: label, + Origin: expression.Origin(), + } + } + if verdict, ok := item.Verdict(); ok { + return sequence{}, e.verdictRowError(expression, name, verdict) + } + if err := e.runtimeRowError(expression, name, item); err != nil { + return sequence{}, err + } + } + return value, nil +} + +// runtimeRowError refuses a state or event row where an operation takes neither. +func (e *executor) runtimeRowError(expression queryplan.Expression, name string, item Value) error { + if state, ok := item.State(); ok { + return e.rowKindError(expression, name, ErrorStateRow, state.Label()) + } + if event, ok := item.Event(); ok { + return e.rowKindError(expression, name, ErrorEventRow, event.Label()) + } + return nil +} + +func (e *executor) verdictRowError(expression queryplan.Expression, name string, verdict Verdict) error { + return &Error{ + Kind: ErrorVerdictRow, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: name, + Target: verdict.Label(), + Origin: expression.Origin(), + } +} + +func (e *executor) stringArgument(expression queryplan.Expression, name string) (string, error) { + value, err := e.argument(expression, name) + if err != nil { + return "", err + } + if len(value.values) != 1 { + return "", e.invalidArgument(expression, name, strconv.Itoa(len(value.values))) + } + text, ok := value.values[0].String() + if !ok { + return "", e.invalidArgument(expression, name, string(value.values[0].Kind())) + } + return text, nil +} + +func (e *executor) stringsArgument(expression queryplan.Expression, name string) ([]string, error) { + value, err := e.argument(expression, name) + if err != nil { + return nil, err + } + texts := make([]string, len(value.values)) + for i, item := range value.values { + text, ok := item.String() + if !ok { + return nil, e.invalidArgument(expression, name, string(item.Kind())) + } + texts[i] = text + } + return texts, nil +} + +func (e *executor) booleanArgument(expression queryplan.Expression, name string) (bool, error) { + value, err := e.argument(expression, name) + if err != nil { + return false, err + } + if len(value.values) != 1 { + return false, e.invalidArgument(expression, name, strconv.Itoa(len(value.values))) + } + boolean, ok := value.values[0].Boolean() + if !ok { + return false, e.invalidArgument(expression, name, string(value.values[0].Kind())) + } + return boolean, nil +} + +func (e *executor) invalidArgument(expression queryplan.Expression, parameter, actual string) error { + return &Error{ + Kind: ErrorInvalidArgument, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: parameter, + Actual: actual, + Origin: expression.Origin(), + } +} + +func (e *executor) errorAt(kind ErrorKind, expression queryplan.Expression) error { + return &Error{ + Kind: kind, + Query: e.definition.Name(), + Operation: expression.Operation(), + Origin: expression.Origin(), + } +} + +func cloneSequence(input sequence) sequence { + result := sequence{ + values: append([]Value(nil), input.values...), + columns: append([]Column(nil), input.columns...), + cells: make([][]Cell, len(input.cells)), + } + for i := range input.cells { + result.cells[i] = cloneCells(input.cells[i]) + } + return result +} + +func cloneCells(input []Cell) []Cell { + result := make([]Cell, len(input)) + for i, cell := range input { + result[i] = Cell{values: cell.Values(), origin: cell.origin} + } + return result +} + +func (e *executor) validateResult(result sequence) error { + for _, value := range result.values { + if !value.isRow() || !e.valueConforms(value, e.definition.Result().Type) { + actual := string(value.Kind()) + if sym, ok := value.Element(); ok { + actual = symbols.FQNOf(sym) + } else if _, label, ok := value.Object(); ok { + actual = "object " + label + } else if verdict, ok := value.Verdict(); ok { + actual = "verdict " + verdict.Label() + } else if state, ok := value.State(); ok { + actual = "state " + state.Label() + } else if event, ok := value.Event(); ok { + actual = "event " + event.Label() + } + return &Error{ + Kind: ErrorResultType, + Query: e.definition.Name(), + Expected: e.definition.Result().Type, + Actual: actual, + Origin: e.definition.Result().Origin, + } + } + } + if !withinMultiplicity(int64(len(result.values)), e.definition.Result().Multiplicity) { + return &Error{ + Kind: ErrorResultMultiplicity, + Query: e.definition.Name(), + Expected: multiplicityString(e.definition.Result().Multiplicity), + Actual: strconv.Itoa(len(result.values)), + Origin: e.definition.Result().Origin, + } + } + return nil +} diff --git a/internal/core/queryexec/execute_test.go b/internal/doc/queryexec/execute_test.go similarity index 99% rename from internal/core/queryexec/execute_test.go rename to internal/doc/queryexec/execute_test.go index 7c92ae4f45..47df19c306 100644 --- a/internal/core/queryexec/execute_test.go +++ b/internal/doc/queryexec/execute_test.go @@ -6,13 +6,14 @@ import ( "slices" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/queryplan" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) const executionImports = ` @@ -54,7 +55,7 @@ func loadExecutionSource(t *testing.T, content string) executionFixture { index.AddDocument(name, root) index.ExpandWildcardImports() resolver := resolve.New(index) - model := semantics.NewModel(resolver) + model := passes.NewTypedModel(resolver) model.SetSourceText(func(doc string, span source.Span) string { if doc != name { return "" @@ -607,7 +608,7 @@ calc def UnknownKind :> Query { in source : Element; RelatedElements( source = source, - relationshipKind = "refinement", + relationshipKind = "containment", direction = "outgoing", maxDepth = 1 ) diff --git a/internal/doc/queryexec/named.go b/internal/doc/queryexec/named.go new file mode 100644 index 0000000000..2f6a91b23d --- /dev/null +++ b/internal/doc/queryexec/named.go @@ -0,0 +1,30 @@ +package queryexec + +import ( + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" +) + +// evaluateNamed resolves each qualified name, in argument order, to the one +// element it denotes, as WhereType resolves a type name; a name denoting no +// single element is an unknown-element error. +func (e *executor) evaluateNamed(expression queryplan.Expression) (sequence, error) { + names, err := e.stringsArgument(expression, "qualifiedName") + if err != nil { + return sequence{}, err + } + var result sequence + for _, name := range names { + element, ok := e.context.Resolver.ResolveAliasTarget(e.resolveClassification(name)) + if !ok || element == nil { + return sequence{}, &Error{ + Kind: ErrorUnknownElement, + Query: e.definition.Name(), + Operation: expression.Operation(), + Actual: name, + Origin: expression.Origin(), + } + } + result.values = append(result.values, valueAt(ElementValue(element), expression.Origin())) + } + return result, nil +} diff --git a/internal/doc/queryexec/named_test.go b/internal/doc/queryexec/named_test.go new file mode 100644 index 0000000000..1696687aac --- /dev/null +++ b/internal/doc/queryexec/named_test.go @@ -0,0 +1,99 @@ +package queryexec + +import ( + "strings" + "testing" +) + +const namedBody = ` +package Vehicle { + package Config { + part def Wheel; + individual part def FrontLeft :> Wheel; + individual part def Spare :> Wheel; + } + package 'Spare Parts' { + part def Pad; + } + alias Wheels for Config; +} +package Other { + package Config; +} +package Aliases { + alias Broken for Missing; + alias Loop1 for Loop2; + alias Loop2 for Loop1; +} +calc def ByName :> Query { + in qualifiedName : String[1..*] ordered; + Named(qualifiedName = qualifiedName) +} +calc def ConfigWheels :> Query { + WhereFeature( + source = WhereType(source = Descendants(source = Named(qualifiedName = "Vehicle::Config")), type = "Vehicle::Config::Wheel"), + 'feature' = "isIndividual", operator = "=", value = "true" + ) +} +` + +func TestExecuteNamedResolvesQualifiedNamesInOrder(t *testing.T) { + fixture := loadExecutionFixture(t, namedBody) + run := func(names ...string) *RowSet { + t.Helper() + values := make([]Value, len(names)) + for i, name := range names { + values[i] = StringValue(name) + } + result, err := fixture.execute(t, "ByName", Bindings{"qualifiedName": values}, Options{}) + if err != nil { + t.Fatalf("ByName(%v): %v", names, err) + } + return result + } + // Argument order is row order, a quoted-in-notation name is spelled raw, a + // full or unique partial qualified name both resolve, and a name given twice + // yields its element once per mention. + got := rowNames(run("Observatory::Vehicle::Spare Parts::Pad", "Vehicle::Config::Wheel", "Vehicle::Config::Wheel")) + want := "Observatory::Vehicle::Spare Parts::Pad,Observatory::Vehicle::Config::Wheel,Observatory::Vehicle::Config::Wheel" + if strings.Join(got, ",") != want { + t.Fatalf("Named rows = %v, want %v", got, want) + } + // An alias names its target. + if got := rowNames(run("Vehicle::Wheels")); strings.Join(got, ",") != "Observatory::Vehicle::Config" { + t.Fatalf("Named alias = %v", got) + } + // A unique simple name resolves as it does for WhereType. + if got := rowNames(run("Pad")); strings.Join(got, ",") != "Observatory::Vehicle::Spare Parts::Pad" { + t.Fatalf("Named simple = %v", got) + } + for _, row := range run("Vehicle::Config").Rows() { + if !row.Origin().Located() { + t.Fatalf("row %v without provenance", row.Element()) + } + } + // The named element roots a traversal. + wheels, err := fixture.execute(t, "ConfigWheels", nil, Options{}) + if err != nil { + t.Fatalf("ConfigWheels: %v", err) + } + if got := rowNames(wheels); strings.Join(got, ",") != "Observatory::Vehicle::Config::FrontLeft,Observatory::Vehicle::Config::Spare" { + t.Fatalf("Descendants(Named) = %v", got) + } +} + +// A name that resolves to nothing, to several elements, or to an alias that +// denotes no element (dangling or cyclic) is an unknown-element error, never a row. +func TestExecuteNamedRejectsUnknownAndAmbiguousNames(t *testing.T) { + fixture := loadExecutionFixture(t, namedBody) + for _, name := range []string{"Vehicle::Missing", "Config", "Aliases::Broken", "Aliases::Loop1", "Aliases::Loop2"} { + _, err := fixture.execute(t, "ByName", Bindings{"qualifiedName": {StringValue(name)}}, Options{}) + unknown := executionError(t, err, ErrorUnknownElement) + if unknown.Actual != name || unknown.Operation != "named" { + t.Fatalf("Named(%q) error = %v", name, unknown) + } + if !strings.Contains(err.Error(), "names no single element "+name) { + t.Fatalf("Named(%q) message = %v", name, err) + } + } +} diff --git a/internal/doc/queryexec/objects.go b/internal/doc/queryexec/objects.go new file mode 100644 index 0000000000..850e4ee5a8 --- /dev/null +++ b/internal/doc/queryexec/objects.go @@ -0,0 +1,368 @@ +package queryexec + +import ( + "errors" + "strconv" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/query" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// Object rows are the runtime objects a session holds; where an element row +// reads the model, an object row reads the session's current state. + +// evaluateObjects enumerates every object the session holds that is of the +// requested type, roots first in the session's order, then the objects they hold. +func (e *executor) evaluateObjects(expression queryplan.Expression) (sequence, error) { + if err := e.requireRuntime(expression); err != nil { + return sequence{}, err + } + typeName, err := e.stringArgument(expression, "type") + if err != nil { + return sequence{}, err + } + target := e.resolveClassification(typeName) + if target == nil && !query.IsMetamodelTypeName(typeName) { + return sequence{}, &Error{ + Kind: ErrorUnknownClassification, + Query: e.definition.Name(), + Operation: expression.Operation(), + Actual: typeName, + Origin: expression.Origin(), + } + } + var result sequence + err = e.eachSessionObject(expression, func(row Value) { + inst, _, _ := row.Object() + if _, gone := e.context.Runtime.Destroyed(inst); gone { + return + } + if e.objectIsA(row, typeName, target) { + result.values = append(result.values, row) + } + }) + return result, err +} + +// requireRuntime refuses an operation over a session's objects when the +// execution has no session. +func (e *executor) requireRuntime(expression queryplan.Expression) error { + if e.context.Runtime != nil { + return nil + } + return &Error{ + Kind: ErrorNoRuntime, + Query: e.definition.Name(), + Operation: expression.Operation(), + Origin: expression.Origin(), + } +} + +// eachSessionObject visits every object the session holds once, roots first in +// the session's order, then breadth-first the objects they hold. +func (e *executor) eachSessionObject(expression queryplan.Expression, visit func(row Value)) error { + seen := make(map[int64]struct{}) + queue := make([]Value, 0, len(e.context.Roots)) + for _, root := range e.context.Roots { + if root.Object == nil { + continue + } + if _, duplicate := seen[root.Object.ID]; duplicate { + continue + } + seen[root.Object.ID] = struct{}{} + queue = append(queue, ObjectValue(root.Object, root.Label)) + } + for len(queue) > 0 { + next := queue[0] + queue = queue[1:] + if !e.consumeVisit() { + return e.budgetError(expression) + } + visit(next) + inst, _, _ := next.Object() + // A destroyed object keeps its label but left the extent with its portions. + if _, gone := e.context.Runtime.Destroyed(inst); gone { + continue + } + children, err := e.heldObjects(expression, next) + if err != nil { + return err + } + for _, child := range children { + inst, _, _ := child.Object() + if _, duplicate := seen[inst.ID]; duplicate { + continue + } + seen[inst.ID] = struct{}{} + queue = append(queue, child) + } + } + return nil +} + +// objectDeclaration is the element an object stands for in the model: the usage +// its owner holds it as, or the declaration it was materialized from. +func objectDeclaration(inst *runtime.Instance) *symbols.Symbol { + if usage := inst.HeldUnder(); usage != nil { + return usage + } + return inst.Type +} + +// objectTypes returns the types an object is of: its declaration, the type it was +// materialized from, and the classifiers a behavior gave it, each once. +func objectTypes(inst *runtime.Instance) []*symbols.Symbol { + types := inst.Types() + out := make([]*symbols.Symbol, 0, len(types)+1) + if decl := objectDeclaration(inst); decl != nil { + out = append(out, decl) + } + for _, typ := range types { + if typ == nil || (len(out) > 0 && symbols.SameElement(typ, out[0])) { + continue + } + out = append(out, typ) + } + return out +} + +// objectIsA reports whether an object row is of a type: declared by an element +// of that metamodel kind, or typed by the classification or one conforming to it. +func (e *executor) objectIsA(row Value, typeName string, target *symbols.Symbol) bool { + inst, _, _ := row.Object() + for _, typ := range objectTypes(inst) { + if query.MetamodelTypeNameOf(typ) == typeName { + return true + } + } + return target != nil && e.objectConforms(inst, target) +} + +// objectConforms reports whether any type of an object is, or conforms to, target. +func (e *executor) objectConforms(inst *runtime.Instance, target *symbols.Symbol) bool { + for _, typ := range objectTypes(inst) { + if symbols.SameElement(typ, target) || e.context.Model.Conforms(typ, target) { + return true + } + } + return false +} + +// heldObjects returns the objects an object row holds, each labelled under the +// row (`car.engine`, `car.wheels[2]`); an unreadable feature is a typed error. +func (e *executor) heldObjects(expression queryplan.Expression, row Value) ([]Value, error) { + inst, label, _ := row.Object() + held, err := e.context.Runtime.HeldObjects(inst) + if err != nil { + var unread *runtime.HeldObjectsError + if errors.As(err, &unread) { + return nil, e.unevaluable(expression, source.NameText(unread.Feature), row, unread.Err) + } + return nil, e.unevaluable(expression, "", row, err) + } + out := make([]Value, 0, len(held)) + for _, child := range held { + out = append(out, ObjectValue(child.Instance, label+"."+child.Segment)) + } + return out, nil +} + +// objectOwner returns the object holding an object row, labelled as the row's +// label minus its last segment, and whether the row is held at all. +func (e *executor) objectOwner(row Value) (Value, bool) { + inst, label, _ := row.Object() + owner, _ := inst.Owner() + if owner == nil { + return Value{}, false + } + if _, ok := e.context.Runtime.Instance(owner.ID); !ok { + return Value{}, false + } + return ObjectValue(owner, ownerLabel(label, owner)), true +} + +// ownerLabel derives an owner's label from its held object's: the label up to +// the last `.`, or the owner's identity when the label starts at the object. +func ownerLabel(label string, owner *runtime.Instance) string { + if held, sep, _ := splitLabel(label); sep == "." && held != "" { + return held + } + return "#" + strconv.FormatInt(owner.ID, 10) +} + +// lastSegment returns the segment an object row is named by within its owner: +// `wheels[2]` of `Demo::car.wheels[2]`, `car` of `Demo::car`. +func lastSegment(label string) string { + _, _, segment := splitLabel(label) + return segment +} + +// splitLabel cuts an object label at its last `.` or `::` outside a quoted name, +// whose text may hold either: the part before, the separator (empty for none), the rest. +func splitLabel(label string) (before, sep, segment string) { + last, width := -1, 0 + for i := 0; i < len(label); i++ { + switch label[i] { + case '\'': + for i++; i < len(label) && label[i] != '\''; i++ { + if label[i] == '\\' { + i++ + } + } + case '.': + last, width = i, 1 + case ':': + if i+1 < len(label) && label[i+1] == ':' { + last, width = i, 2 + i++ + } + } + } + if last < 0 { + return "", "", label + } + return label[:last], label[last : last+width], label[last+width:] +} + +// objectPropertyValues reads a property of an object row: identity, naming and +// `type` from the session, other metadata from its declaration, else a feature. +func (e *executor) objectPropertyValues(row Value, property string) ([]Value, bool, error) { + inst, label, _ := row.Object() + origin := row.Origin() + text := func(values ...string) []Value { + out := make([]Value, 0, len(values)) + for _, value := range values { + out = append(out, valueAt(StringValue(value), origin)) + } + return out + } + switch property { + case query.PropertyID: + return text("#" + strconv.FormatInt(inst.ID, 10)), true, nil + case query.PropertyName: + return text(lastSegment(label)), true, nil + case query.PropertyQualifiedName: + return text(label), true, nil + case query.PropertyOwner: + owner, held := e.objectOwner(row) + if !held { + return nil, true, nil + } + _, ownerText, _ := owner.Object() + return text(ownerText), true, nil + case query.PropertyElementType: + return text(e.objectTypeNames(inst)...), true, nil + } + if isQueryableProperty(property) { + decl := objectDeclaration(inst) + if decl == nil { + return nil, true, nil + } + values, present := e.reader.Values(decl, property) + if !present { + return nil, true, nil + } + result := make([]Value, 0, len(values)) + for _, value := range values { + result = append(result, valueAt(typedPropertyValue(property, value, decl), origin)) + } + return result, true, nil + } + return e.objectFeatureValues(row, property) +} + +// objectTypeNames names an object's types: a definition by its qualified name, +// a usage by the type it declares, or itself when it declares none. +func (e *executor) objectTypeNames(inst *runtime.Instance) []string { + var names []string + for _, typ := range objectTypes(inst) { + if typ.Kind.IsFeature() { + if declared, ok := e.reader.Values(typ, query.PropertyElementType); ok && len(declared) > 0 { + names = append(names, declared...) + continue + } + } + names = append(names, symbols.FQNOf(typ)) + } + return names +} + +// objectFeatureValues reads the value a feature of an object row holds now; +// held objects stay object values, and a feature the object lacks is absent. +func (e *executor) objectFeatureValues(row Value, property string) ([]Value, bool, error) { + inst, label, _ := row.Object() + name, ok := objectFeatureName(e.context.Runtime, inst, property) + if !ok { + return nil, false, nil + } + fv, err := inst.GetFeatureValue(e.context.Runtime, name) + if err != nil { + return nil, true, e.unevaluable(queryplan.Expression{}, property, row, err) + } + if fv == nil { + return nil, true, nil + } + segment := label + "." + source.NameText(name) + held := fv.Value + if fv.Values.Kind != runtime.ValInvalid { + held = fv.Values + } + values, err := e.objectCellValues(row, property, held, segment) + return values, true, err +} + +// collectionElements returns the elements of a collection feature value. +func collectionElements(value runtime.Value) []runtime.Value { + switch { + case value.Kind == runtime.ValSequence && value.Sequence() != nil: + return value.Sequence().Elements() + case value.Kind == runtime.ValSet && value.Set() != nil: + return value.Set().Elements() + } + return nil +} + +// objectFeatureName finds the feature of an object a property names, by its +// declared name or the text of a quoted one, in the object's feature order. +func objectFeatureName(ctx *runtime.Context, inst *runtime.Instance, property string) (string, bool) { + if _, ok := inst.FeatureValues[property]; ok { + return property, true + } + for _, of := range ctx.FeaturesOfObject(inst) { + if source.NameText(of.Name) == property { + return of.Name, true + } + } + return "", false +} + +// objectCellValues converts one held value to cell values: an object stays an +// object value under its label, null is absent, else as a declared value. +func (e *executor) objectCellValues(row Value, property string, value runtime.Value, label string) ([]Value, error) { + if id, isObject := value.Object(); isObject { + if e.context.Runtime.HoldsNoValue(value) { + return nil, nil + } + child, ok := e.context.Runtime.Instance(id) + if !ok { + return nil, e.unevaluable(queryplan.Expression{}, property, row, notAValue(value)) + } + return []Value{ObjectValue(child, label)}, nil + } + if value.Kind != runtime.ValSequence && value.Kind != runtime.ValSet { + return e.cellValues(value, property, row) + } + var result []Value + for i, element := range collectionElements(value) { + values, err := e.objectCellValues(row, property, element, label+"["+strconv.Itoa(i+1)+"]") + if err != nil { + return nil, err + } + result = append(result, values...) + } + return result, nil +} diff --git a/internal/doc/queryexec/objects_test.go b/internal/doc/queryexec/objects_test.go new file mode 100644 index 0000000000..f05936c2da --- /dev/null +++ b/internal/doc/queryexec/objects_test.go @@ -0,0 +1,457 @@ +package queryexec + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// objectBody is a session's worth of objects: a car holding an engine and four +// wheels, and a spare wheel held on its own, each with values a run may change. +const objectBody = ` +part def Wheel { + attribute pressure : Integer default 30; +} +part def Engine { + attribute power : Integer default 100; +} +part def Car { + part engine : Engine; + part wheels : Wheel[4]; + attribute doors : Integer default 4; +} +part car : Car; +part spare : Wheel { + attribute :>> pressure = 20; +} +` + +const objectQueries = ` +calc def Held :> Query { + in root : Element; + Project( + source = Descendants(source = root, maxDepth = 3), + properties = ("qualifiedName", "name", "pressure") + ) +} +calc def Direct :> Query { + in root : Element; + Project(source = OwnedElements(source = root), properties = ("name", "owner", "@id")) +} +calc def Up :> Query { + in root : Element; + Project(source = Ancestors(source = root, maxDepth = 3), properties = ("qualifiedName", "type")) +} +calc def Wheels :> Query { + in root : Element; + OrderBy( + source = Project( + source = WhereType(source = Descendants(source = root, maxDepth = 3), type = "Wheel"), + properties = ("qualifiedName", "pressure") + ), + property = "pressure", + direction = "descending", + missing = "last", + multiple = "first" + ) +} +calc def Soft :> Query { + in root : Element; + Project( + source = WhereFeature( + source = WhereType(source = Descendants(source = root, maxDepth = 3), type = "Wheel"), + 'feature' = "pressure", + operator = "<", + value = "30" + ), + properties = ("qualifiedName") + ) +} +calc def Named :> Query { + in root : Element; + WhereName(source = Descendants(source = root, maxDepth = 3), operator = "=", value = "engine") +} +calc def Engines :> Query { + in root : Element; + Project(source = root, properties = ("qualifiedName", "engine", "doors")) +} +calc def EveryWheel :> Query { + Project(source = Objects(type = "Wheel"), properties = ("qualifiedName", "pressure")) +} +calc def EveryPart :> Query { + Objects(type = "PartUsage") +} +calc def Traced :> Query { + in root : Element; + RelatedElements( + source = root, + relationshipKind = "satisfaction", + direction = "incoming", + maxDepth = 1 + ) +} +` + +type objectFixture struct { + executionFixture + ctx *runtime.Context + car *runtime.Instance + spare *runtime.Instance +} + +func loadObjectFixture(t *testing.T) objectFixture { + t.Helper() + fixture := loadExecutionFixture(t, objectBody+objectQueries) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + car, err := ctx.Instantiate(fixture.symbol(t, "car")) + if err != nil { + t.Fatalf("Instantiate car: %v", err) + } + spare, err := ctx.Instantiate(fixture.symbol(t, "spare")) + if err != nil { + t.Fatalf("Instantiate spare: %v", err) + } + return objectFixture{executionFixture: fixture, ctx: ctx, car: car, spare: spare} +} + +func (f objectFixture) context() Context { + return Context{ + Index: f.index, Resolver: f.resolver, Model: f.model, Runtime: f.ctx, + Roots: []Root{{Label: "car", Object: f.car}, {Label: "spare", Object: f.spare}}, + } +} + +func (f objectFixture) run(t *testing.T, name string, bindings Bindings) (*RowSet, error) { + t.Helper() + return Execute(f.program(t, name), f.context(), bindings, Options{}) +} + +func (f objectFixture) rows(t *testing.T, name string, bindings Bindings) *RowSet { + t.Helper() + result, err := f.run(t, name, bindings) + if err != nil { + t.Fatalf("execute %s: %v", name, err) + } + return result +} + +func (f objectFixture) write(t *testing.T, inst *runtime.Instance, feature string, value int64) { + t.Helper() + err := inst.SetFeatureValue(f.ctx, feature, runtime.Value{ + Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: value}, + }) + if err != nil { + t.Fatalf("write %s: %v", feature, err) + } +} + +// objectLabels renders the objects a result selected, in order. +// integerOf returns the single integer a cell holds. +func integerOf(values []Value) (int64, bool) { + if len(values) != 1 { + return 0, false + } + return values[0].Integer() +} + +func objectLabels(t *testing.T, result *RowSet) []string { + t.Helper() + var out []string + for _, row := range result.Rows() { + _, label, ok := row.Element().Object() + if !ok { + t.Fatalf("row %v is not an object", row.Element().Kind()) + } + out = append(out, label) + } + return out +} + +func TestObjectValueCarriesInstanceLabelAndDeclaration(t *testing.T) { + fixture := loadObjectFixture(t) + value := ObjectValue(fixture.car, "Demo::car") + if value.Kind() != ValueObject { + t.Fatalf("kind = %s, want %s", value.Kind(), ValueObject) + } + inst, label, ok := value.Object() + if !ok || inst != fixture.car || label != "Demo::car" { + t.Fatalf("Object() = %v %q %v", inst, label, ok) + } + if _, ok := value.Element(); ok { + t.Fatal("an object value is not an element") + } + if _, ok := value.String(); ok { + t.Fatal("an object value is not a string") + } + if value.Origin() != fixture.car.Type.Origin() { + t.Fatalf("origin = %v, want the declaration car", value.Origin()) + } + if _, _, ok := ObjectValue(nil, "gone").Object(); ok { + t.Fatal("a nil instance is no object value") + } + if _, _, ok := ElementValue(fixture.symbol(t, "car")).Object(); ok { + t.Fatal("an element value is no object value") + } +} + +func TestExecuteTraversesObjectsUnderTheirPaths(t *testing.T) { + fixture := loadObjectFixture(t) + root := Bindings{"root": {ObjectValue(fixture.car, "car")}} + + held := fixture.rows(t, "Held", root) + wantPaths := []string{"car.engine", "car.wheels[1]", "car.wheels[2]", "car.wheels[3]", "car.wheels[4]"} + if got := objectLabels(t, held); strings.Join(got, ",") != strings.Join(wantPaths, ",") { + t.Fatalf("descendants = %v, want %v", got, wantPaths) + } + if got := cellTexts(t, held, 0); strings.Join(got, ",") != strings.Join(wantPaths, ",") { + t.Fatalf("qualifiedName = %v, want %v", got, wantPaths) + } + if got := cellTexts(t, held, 1); strings.Join(got, ",") != "engine,wheels[1],wheels[2],wheels[3],wheels[4]" { + t.Fatalf("name = %v", got) + } + // The engine has no pressure: absent, not an error. + if values := held.Rows()[0].Cells()[2].Values(); len(values) != 0 { + t.Fatalf("engine pressure = %v, want absent", values) + } + for i, row := range held.Rows()[1:] { + values := row.Cells()[2].Values() + if n, ok := integerOf(values); !ok || n != 30 { + t.Fatalf("wheels[%d] pressure = %v, want 30", i+1, values) + } + } + + direct := fixture.rows(t, "Direct", root) + if got := cellTexts(t, direct, 0); strings.Join(got, ",") != "engine,wheels[1],wheels[2],wheels[3],wheels[4]" { + t.Fatalf("owned names = %v", got) + } + if got := cellTexts(t, direct, 1); got[0] != "car" || got[4] != "car" { + t.Fatalf("owners = %v, want car", got) + } + if got := cellTexts(t, direct, 2); !strings.HasPrefix(got[0], "#") || got[0] == got[1] { + t.Fatalf("ids = %v, want distinct session identities", got) + } + + // Ancestors of a wheel: the car, reached under the path it was bound by. + wheels, err := fixture.ctx.HeldObjects(fixture.car) + if err != nil { + t.Fatalf("held objects: %v", err) + } + up := fixture.rows(t, "Up", Bindings{"root": {ObjectValue(wheels[2].Instance, "car.wheels[2]")}}) + if got := cellTexts(t, up, 0); strings.Join(got, ",") != "car" { + t.Fatalf("ancestors = %v, want car", got) + } + if got := cellTexts(t, up, 1); strings.Join(got, ",") != "Observatory::Car" { + t.Fatalf("ancestor elementType = %v, want Observatory::Car", got) + } +} + +func TestExecuteReadsCurrentObjectValues(t *testing.T) { + fixture := loadObjectFixture(t) + root := Bindings{"root": {ObjectValue(fixture.car, "car")}} + wheels, err := fixture.ctx.HeldObjects(fixture.car) + if err != nil { + t.Fatalf("held objects: %v", err) + } + fixture.write(t, wheels[1].Instance, "pressure", 12) + fixture.write(t, wheels[3].Instance, "pressure", 45) + + ordered := fixture.rows(t, "Wheels", root) + if got := cellTexts(t, ordered, 0); strings.Join(got, ",") != "car.wheels[3],car.wheels[2],car.wheels[4],car.wheels[1]" { + t.Fatalf("wheels by pressure = %v", got) + } + if got := integerTexts(t, ordered, 1); got[0] != 45 || got[3] != 12 { + t.Fatalf("pressures = %v, want 45..12", got) + } + soft := fixture.rows(t, "Soft", root) + if got := cellTexts(t, soft, 0); strings.Join(got, ",") != "car.wheels[1]" { + t.Fatalf("soft wheels = %v, want car.wheels[1]", got) + } + + // A second execution reads the value written since, not a snapshot. + fixture.write(t, wheels[1].Instance, "pressure", 60) + if got := integerTexts(t, fixture.rows(t, "Wheels", root), 1); got[0] != 60 { + t.Fatalf("pressures after write = %v, want 60 first", got) + } + + named := fixture.rows(t, "Named", root) + if got := objectLabels(t, named); strings.Join(got, ",") != "car.engine" { + t.Fatalf("named engine = %v", got) + } + + // Projecting a part feature keeps the object it holds as an object cell. + engines := fixture.rows(t, "Engines", root) + cell := engines.Rows()[0].Cells()[1].Values() + if len(cell) != 1 { + t.Fatalf("engine cell = %v, want one object", cell) + } + if inst, label, ok := cell[0].Object(); !ok || label != "car.engine" || inst.HeldUnder() != fixture.symbol(t, "Car::engine") { + t.Fatalf("engine cell = %v %q %v", inst, label, ok) + } + if got := integerTexts(t, engines, 2); got[0] != 4 { + t.Fatalf("doors = %v, want 4", got) + } +} + +func TestExecuteObjectsEnumeratesTheSession(t *testing.T) { + fixture := loadObjectFixture(t) + every := fixture.rows(t, "EveryWheel", nil) + // Roots in the session's order, then the objects they hold. + want := "spare,car.wheels[1],car.wheels[2],car.wheels[3],car.wheels[4]" + if got := cellTexts(t, every, 0); strings.Join(got, ",") != want { + t.Fatalf("Objects(Wheel) = %v, want %s", got, want) + } + if got := integerTexts(t, every, 1); got[0] != 20 || got[4] != 30 { + t.Fatalf("pressures = %v, want 20 then 30s", got) + } + parts := fixture.rows(t, "EveryPart", nil) + if got := objectLabels(t, parts); strings.Join(got, ",") != "car,spare,car.engine,car.wheels[1],car.wheels[2],car.wheels[3],car.wheels[4]" { + t.Fatalf("Objects(PartUsage) = %v", got) + } + + // Without a session there is nothing to enumerate: a typed error, not an empty result. + _, err := Execute(fixture.program(t, "EveryWheel"), + Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model}, nil, Options{}) + executionError(t, err, ErrorNoRuntime) + + _, err = Execute(fixture.program(t, "EveryWheel"), + Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, + Roots: []Root{{Label: "car", Object: fixture.car}}}, nil, Options{}) + executionError(t, err, ErrorInvalidContext) +} + +func TestExecuteRefusesObjectRowsWhereTheModelIsRead(t *testing.T) { + fixture := loadObjectFixture(t) + _, err := fixture.run(t, "Traced", Bindings{"root": {ObjectValue(fixture.car, "car")}}) + related := executionError(t, err, ErrorObjectRow) + if related.Target != "car" || !strings.Contains(related.Error(), "not to object car") { + t.Fatalf("object-row error = %v", related) + } + + // An object bound to a query executed over the model alone is not a row. + _, err = Execute(fixture.program(t, "Held"), + Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model}, + Bindings{"root": {ObjectValue(fixture.car, "car")}}, Options{}) + executionError(t, err, ErrorInvalidArgument) + + // Element rows keep reading the model in the same execution. + declared := fixture.rows(t, "Held", Bindings{"root": {ElementValue(fixture.symbol(t, "Car"))}}) + if got := cellTexts(t, declared, 0); strings.Join(got, ",") != "Observatory::Car::engine,Observatory::Car::wheels,Observatory::Car::doors" { + t.Fatalf("declared descendants = %v", got) + } +} + +// A quoted name may hold the very characters that separate a label's segments, +// so a label is cut at a separator outside its quotes, escapes included. +func TestSplitLabelHonoursQuotedNames(t *testing.T) { + for _, tc := range []struct { + label, before, sep, segment string + }{ + {"car", "", "", "car"}, + {"#7", "", "", "#7"}, + {"Demo::car", "Demo", "::", "car"}, + {"Demo::car.wheels[2]", "Demo::car", ".", "wheels[2]"}, + {"car.'front.left'", "car", ".", "'front.left'"}, + {"car.'a::b'[3]", "car", ".", "'a::b'[3]"}, + {"'Demo.Cars'::car", "'Demo.Cars'", "::", "car"}, + {"'it\\'s'.wheel", "'it\\'s'", ".", "wheel"}, + {"'back\\\\slash.x'", "", "", "'back\\\\slash.x'"}, + {"'front.left'", "", "", "'front.left'"}, + } { + before, sep, segment := splitLabel(tc.label) + if before != tc.before || sep != tc.sep || segment != tc.segment { + t.Errorf("splitLabel(%q) = %q %q %q, want %q %q %q", + tc.label, before, sep, segment, tc.before, tc.sep, tc.segment) + } + if got := lastSegment(tc.label); got != tc.segment { + t.Errorf("lastSegment(%q) = %q, want %q", tc.label, got, tc.segment) + } + } +} + +// An object held under a quoted name is named and owned as the label spells +// it: the quotes, not the punctuation inside them, decide where a segment ends. +func TestExecuteNamesObjectsHeldUnderQuotedNames(t *testing.T) { + fixture := loadExecutionFixture(t, ` +part def Wheel { attribute pressure : Integer default 30; } +part def Car { part 'front.left' : Wheel; part 'rear::right' : Wheel; } +part 'my.car' : Car; +`+objectQueries) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + car, err := ctx.Instantiate(fixture.symbol(t, "my.car")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + context := Context{ + Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Runtime: ctx, + Roots: []Root{{Label: "'my.car'", Object: car}}, + } + direct, err := Execute(fixture.program(t, "Direct"), context, Bindings{"root": {ObjectValue(car, "'my.car'")}}, Options{}) + if err != nil { + t.Fatalf("execute Direct: %v", err) + } + if got := cellTexts(t, direct, 0); strings.Join(got, ",") != "'front.left','rear::right'" { + t.Fatalf("owned names = %v", got) + } + if got := cellTexts(t, direct, 1); strings.Join(got, ",") != "'my.car','my.car'" { + t.Fatalf("owners = %v, want 'my.car'", got) + } + + held, err := ctx.HeldObjects(car) + if err != nil { + t.Fatalf("held objects: %v", err) + } + up, err := Execute(fixture.program(t, "Up"), context, + Bindings{"root": {ObjectValue(held[0].Instance, "'my.car'.'front.left'")}}, Options{}) + if err != nil { + t.Fatalf("execute Up: %v", err) + } + if got := cellTexts(t, up, 0); strings.Join(got, ",") != "'my.car'" { + t.Fatalf("ancestors = %v, want 'my.car'", got) + } +} + +// A structured attribute is materialized as an object of its own, so the object +// it holds is reached, populated and owned like a nested part. +func TestExecuteReachesStructuredAttributeObjects(t *testing.T) { + fixture := loadExecutionFixture(t, ` +attribute def Centroid { attribute x : Real = 1.0; attribute y : Real = 2.0; } +part def Wheel { attribute pressure : Integer default 30; } +part def Car { + attribute center : Centroid; + attribute mass : Real; + part wheel : Wheel; +} +part car : Car; +calc def Points :> Query { + Project(source = Objects(type = "Centroid"), properties = ("qualifiedName", "x", "y")) +} +`+objectQueries) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + car, err := ctx.Instantiate(fixture.symbol(t, "car")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + context := Context{ + Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Runtime: ctx, + Roots: []Root{{Label: "car", Object: car}}, + } + direct, err := Execute(fixture.program(t, "Direct"), context, Bindings{"root": {ObjectValue(car, "car")}}, Options{}) + if err != nil { + t.Fatalf("execute Direct: %v", err) + } + if got := cellTexts(t, direct, 0); strings.Join(got, ",") != "center,wheel" { + t.Fatalf("owned names = %v, want center,wheel", got) + } + points, err := Execute(fixture.program(t, "Points"), context, nil, Options{}) + if err != nil { + t.Fatalf("execute Points: %v", err) + } + if got := cellTexts(t, points, 0); strings.Join(got, ",") != "car.center" { + t.Fatalf("points = %v, want car.center", got) + } + if x, y := cellTexts(t, points, 1), cellTexts(t, points, 2); x[0] != "1.0" || y[0] != "2.0" { + t.Fatalf("center = (%v, %v), want (1.0, 2.0)", x, y) + } +} diff --git a/internal/doc/queryexec/operations.go b/internal/doc/queryexec/operations.go new file mode 100644 index 0000000000..2918d523e6 --- /dev/null +++ b/internal/doc/queryexec/operations.go @@ -0,0 +1,1160 @@ +package queryexec + +import ( + "errors" + "math" + "math/big" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/query" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// rowKey identifies a row for traversal: an element by identity, an object by +// its session identity. +type rowKey struct { + element symbols.ElementKey + object int64 +} + +func keyOfRow(row Value) rowKey { + if inst, _, ok := row.Object(); ok { + return rowKey{object: inst.ID} + } + sym, _ := row.Element() + return rowKey{element: symbols.KeyOf(sym)} +} + +// ownedRows returns the rows a row owns: the members of an element's scope, or +// the objects an object's features hold. +func (e *executor) ownedRows(expression queryplan.Expression, row Value) ([]Value, error) { + if _, _, ok := row.Object(); ok { + return e.heldObjects(expression, row) + } + sym, _ := row.Element() + if sym.Scope == nil { + return nil, nil + } + members := sym.Scope.AllMembers() + out := make([]Value, 0, len(members)) + for _, member := range members { + out = append(out, ElementValue(member)) + } + return out, nil +} + +// ownerRow returns the row owning a row: the owner of an element's scope, or the +// object holding an object. +func (e *executor) ownerRow(row Value) (Value, bool) { + if _, _, ok := row.Object(); ok { + return e.objectOwner(row) + } + sym, _ := row.Element() + if sym.OwnerScope == nil { + return Value{}, false + } + owner := sym.OwnerScope.Owner() + if owner == nil { + return Value{}, false + } + return ElementValue(owner), true +} + +func (e *executor) evaluateOwned(expression queryplan.Expression) (sequence, error) { + source, err := e.ownershipArgument(expression, "source") + if err != nil { + return sequence{}, err + } + var result sequence + seen := make(map[rowKey]struct{}) + for _, value := range source.values { + owned, err := e.ownedRows(expression, value) + if err != nil { + return sequence{}, err + } + for _, member := range owned { + key := keyOfRow(member) + if _, duplicate := seen[key]; duplicate { + continue + } + if !e.consumeVisit() { + return sequence{}, e.budgetError(expression) + } + seen[key] = struct{}{} + result.values = append(result.values, member) + } + } + return result, nil +} + +// depthLimit is a maxDepth argument: so many levels, or unbounded when the +// argument is null or omitted. +type depthLimit struct { + bounded bool + levels int64 +} + +// reached reports whether a row at depth is not to be walked past. +func (d depthLimit) reached(depth int64) bool { return d.bounded && depth >= d.levels } + +// depthArgument reads an operation's maxDepth: a non-negative integer, or +// unbounded when null or omitted. +func (e *executor) depthArgument(expression queryplan.Expression) (depthLimit, error) { + if !hasArgument(expression, "maxDepth") { + return depthLimit{}, nil + } + value, err := e.argument(expression, "maxDepth") + if err != nil { + return depthLimit{}, err + } + switch len(value.values) { + case 0: + return depthLimit{}, nil + case 1: + default: + return depthLimit{}, e.invalidArgument(expression, "maxDepth", strconv.Itoa(len(value.values))) + } + levels, ok := value.values[0].Integer() + if !ok || levels < 0 { + return depthLimit{}, e.invalidArgument(expression, "maxDepth", string(value.values[0].Kind())) + } + return depthLimit{bounded: true, levels: levels}, nil +} + +func (e *executor) evaluateDescendants(expression queryplan.Expression) (sequence, error) { + source, err := e.ownershipArgument(expression, "source") + if err != nil { + return sequence{}, err + } + maxDepth, err := e.depthArgument(expression) + if err != nil { + return sequence{}, err + } + type pending struct { + row Value + depth int64 + } + queue := make([]pending, 0, len(source.values)) + seen := make(map[rowKey]struct{}) + for _, value := range source.values { + seen[keyOfRow(value)] = struct{}{} + queue = append(queue, pending{row: value}) + } + var result sequence + for len(queue) > 0 { + next := queue[0] + queue = queue[1:] + if maxDepth.reached(next.depth) { + continue + } + owned, err := e.ownedRows(expression, next.row) + if err != nil { + return sequence{}, err + } + for _, member := range owned { + key := keyOfRow(member) + if _, duplicate := seen[key]; duplicate { + continue + } + if !e.consumeVisit() { + return sequence{}, e.budgetError(expression) + } + seen[key] = struct{}{} + result.values = append(result.values, member) + queue = append(queue, pending{row: member, depth: next.depth + 1}) + } + } + return result, nil +} + +func (e *executor) evaluateAncestors(expression queryplan.Expression) (sequence, error) { + source, err := e.ownershipArgument(expression, "source") + if err != nil { + return sequence{}, err + } + maxDepth, err := e.depthArgument(expression) + if err != nil { + return sequence{}, err + } + type pending struct { + row Value + depth int64 + } + queue := make([]pending, 0, len(source.values)) + seen := make(map[rowKey]struct{}) + for _, value := range source.values { + seen[keyOfRow(value)] = struct{}{} + queue = append(queue, pending{row: value}) + } + var result sequence + for len(queue) > 0 { + next := queue[0] + queue = queue[1:] + if maxDepth.reached(next.depth) { + continue + } + owner, ok := e.ownerRow(next.row) + if !ok { + continue + } + key := keyOfRow(owner) + if _, duplicate := seen[key]; duplicate { + continue + } + if !e.consumeVisit() { + return sequence{}, e.budgetError(expression) + } + seen[key] = struct{}{} + result.values = append(result.values, owner) + queue = append(queue, pending{row: owner, depth: next.depth + 1}) + } + return result, nil +} + +// typeTest is one resolved name WhereType keeps rows conforming to. +type typeTest struct { + name string + target *symbols.Symbol + classification string +} + +func (e *executor) evaluateWhereType(expression queryplan.Expression) (sequence, error) { + source, err := e.rowArgument(expression, "source") + if err != nil { + return sequence{}, err + } + typeNames, err := e.stringsArgument(expression, "type") + if err != nil { + return sequence{}, err + } + if len(typeNames) == 0 { + return sequence{}, e.invalidArgument(expression, "type", "0") + } + tests := make([]typeTest, len(typeNames)) + matched := make([]bool, len(typeNames)) + for i, typeName := range typeNames { + tests[i] = typeTest{name: typeName, target: e.resolveClassification(typeName), classification: typeName} + if tests[i].target != nil { + tests[i].classification = symbols.FQNOf(tests[i].target) + } + } + result := filtered(source) + for i, value := range source.values { + for j, test := range tests { + if e.valueIsA(value, test) { + matched[j] = true + appendSelected(&result, source, i) + break + } + } + } + for j, test := range tests { + if test.target == nil && !matched[j] && !query.IsMetamodelTypeName(test.name) { + return sequence{}, &Error{ + Kind: ErrorUnknownClassification, + Query: e.definition.Name(), + Operation: expression.Operation(), + Actual: test.name, + Origin: expression.Origin(), + } + } + } + return result, nil +} + +// valueIsA reports whether a row is an object or declaration of the type. +func (e *executor) valueIsA(value Value, test typeTest) bool { + if _, _, isObject := value.Object(); isObject { + return e.objectIsA(value, test.name, test.target) + } + sym := value.Declaration() + if sym == nil { + return false + } + if query.MetamodelTypeNameOf(sym) == test.name { + return true + } + return test.target != nil && + (e.context.Model.MetaclassConforms(sym, test.classification) || + symbols.SameElement(sym, test.target) || + e.context.Model.Conforms(sym, test.target)) +} + +func (e *executor) evaluateWhereMetadata(expression queryplan.Expression) (sequence, error) { + source, err := e.rowArgument(expression, "source") + if err != nil { + return sequence{}, err + } + names, err := e.stringsArgument(expression, "metadata") + if err != nil { + return sequence{}, err + } + if len(names) == 0 { + return sequence{}, e.invalidArgument(expression, "metadata", "0") + } + targets := make([]*symbols.Symbol, len(names)) + for i, name := range names { + if targets[i] = e.resolveClassification(name); targets[i] == nil { + return sequence{}, &Error{ + Kind: ErrorUnknownClassification, + Query: e.definition.Name(), + Operation: expression.Operation(), + Actual: name, + Origin: expression.Origin(), + } + } + } + result := filtered(source) + for i, value := range source.values { + sym := value.Declaration() + for _, annotation := range e.context.Model.AnnotationFactsOf(sym) { + types := e.context.Index.LookupQualified(annotation.TypeFQN) + matches := false + for _, actual := range types { + for _, target := range targets { + if symbols.SameElement(actual, target) || e.context.Model.Conforms(actual, target) { + matches = true + } + } + } + if matches { + appendSelected(&result, source, i) + break + } + } + } + return result, nil +} + +func (e *executor) evaluateWhereName(expression queryplan.Expression) (sequence, error) { + source, err := e.rowArgument(expression, "source") + if err != nil { + return sequence{}, err + } + operator, err := e.stringArgument(expression, "operator") + if err != nil { + return sequence{}, err + } + expected, err := e.stringArgument(expression, "value") + if err != nil { + return sequence{}, err + } + if compareErr := validateTextComparison(operator, expected); compareErr != nil { + if compareErr != errComparison { + return sequence{}, e.invalidArgument(expression, "value", expected) + } + return sequence{}, e.operatorError(expression, operator) + } + result := filtered(source) + for i, value := range source.values { + names, _, nameErr := e.propertyValues(value, query.PropertyName) + if nameErr != nil { + return sequence{}, nameErr + } + if len(names) == 0 { + continue + } + name, _ := names[0].String() + match, compareErr := compareText(name, operator, expected) + if compareErr != nil { + if compareErr != errComparison { + return sequence{}, e.invalidArgument(expression, "value", expected) + } + return sequence{}, e.operatorError(expression, operator) + } + if match { + appendSelected(&result, source, i) + } + } + return result, nil +} + +func (e *executor) evaluateWhereFeature(expression queryplan.Expression) (sequence, error) { + source, err := e.rowArgument(expression, "source") + if err != nil { + return sequence{}, err + } + property, err := e.stringArgument(expression, "feature") + if err != nil { + return sequence{}, err + } + operator, err := e.stringArgument(expression, "operator") + if err != nil { + return sequence{}, err + } + expected, err := e.stringArgument(expression, "value") + if err != nil { + return sequence{}, err + } + if compareErr := validateFeatureComparison(operator, expected); compareErr != nil { + if compareErr != errComparison { + return sequence{}, e.invalidArgument(expression, "value", expected) + } + return sequence{}, e.operatorError(expression, operator) + } + if len(source.values) == 0 { + return source, nil + } + result := filtered(source) + known := false + columnIndex := projectedColumn(source, property) + for i := range source.values { + values, present, valueErr := e.featureValues(expression, source, i, property, columnIndex) + if valueErr != nil { + return sequence{}, valueErr + } + known = known || present + for _, actual := range values { + match, compareErr := compareValue(actual, operator, expected) + if compareErr != nil { + if compareErr != errComparison { + return sequence{}, e.invalidArgument(expression, "value", expected) + } + return sequence{}, e.operatorError(expression, operator) + } + if match { + appendSelected(&result, source, i) + break + } + } + } + if !known { + return sequence{}, e.unknownProperty(expression, property) + } + return result, nil +} + +func (e *executor) evaluateOrderBy(expression queryplan.Expression) (sequence, error) { + source, err := e.rowArgument(expression, "source") + if err != nil { + return sequence{}, err + } + property, err := e.stringArgument(expression, "property") + if err != nil { + return sequence{}, err + } + direction, err := e.stringArgument(expression, "direction") + if err != nil { + return sequence{}, err + } + missing, err := e.stringArgument(expression, "missing") + if err != nil { + return sequence{}, err + } + multiple, err := e.stringArgument(expression, "multiple") + if err != nil { + return sequence{}, err + } + if direction != "ascending" && direction != "descending" { + return sequence{}, e.operatorError(expression, direction) + } + if missing != "first" && missing != "last" && missing != "error" { + return sequence{}, e.operatorError(expression, missing) + } + if multiple != "first" && multiple != "last" && multiple != "error" { + return sequence{}, e.operatorError(expression, multiple) + } + if len(source.values) == 0 { + return source, nil + } + type sortable struct { + value Value + cells []Cell + key Value + set bool + } + columnIndex := projectedColumn(source, property) + items := make([]sortable, len(source.values)) + known := false + var firstKey Value + for i, value := range source.values { + values, present, valueErr := e.featureValues(expression, source, i, property, columnIndex) + if valueErr != nil { + return sequence{}, valueErr + } + known = known || present + items[i].value = value + if i < len(source.cells) { + items[i].cells = cloneCells(source.cells[i]) + } + switch len(values) { + case 0: + if missing == "error" { + return sequence{}, e.featureError(expression, property, value) + } + case 1: + items[i].key = values[0] + items[i].set = true + default: + if multiple == "error" { + return sequence{}, e.featureError(expression, property, value) + } + index := 0 + if multiple == "last" { + index = len(values) - 1 + } + items[i].key = values[index] + items[i].set = true + } + if items[i].set { + if firstKey.Kind() == "" { + firstKey = items[i].key + } else if !e.orderedKeysCompatible(firstKey, items[i].key) { + return sequence{}, e.invalidOrder(expression, property, firstKey, items[i].key) + } + } + } + if !known { + return sequence{}, e.unknownProperty(expression, property) + } + var sortErr error + var sortKeys [2]Value + sort.SliceStable(items, func(i, j int) bool { + left, right := items[i], items[j] + if left.set != right.set { + if missing == "first" { + return !left.set + } + return left.set + } + if !left.set { + return false + } + comparison, err := e.compareOrdered(left.key, right.key) + if err != nil { + sortErr = err + sortKeys = [2]Value{left.key, right.key} + return false + } + if direction == "descending" { + comparison = -comparison + } + return comparison < 0 + }) + if sortErr != nil { + return sequence{}, e.invalidOrder(expression, property, sortKeys[0], sortKeys[1]) + } + result := sequence{columns: append([]Column(nil), source.columns...)} + for _, item := range items { + result.values = append(result.values, item.value) + result.cells = append(result.cells, item.cells) + } + return result, nil +} + +// projectedColumn is the index of the projected column named property, or -1; +// a feature naming a projected column reads its cells, so computed and +// relationship-derived columns are filterable and orderable by name. +func projectedColumn(source sequence, property string) int { + for i, column := range source.columns { + if column.name == property { + return i + } + } + return -1 +} + +// featureValues reads one row's feature: its projected cell when columnIndex +// names one, otherwise the property of the row itself. +func (e *executor) featureValues( + expression queryplan.Expression, + source sequence, + row int, + property string, + columnIndex int, +) ([]Value, bool, error) { + if columnIndex >= 0 && row < len(source.cells) { + return source.cells[row][columnIndex].Values(), true, nil + } + value := source.values[row] + values, present, err := e.propertyValues(value, property) + if err != nil { + return nil, false, e.unevaluable(expression, property, value, err) + } + return values, present, nil +} + +func (e *executor) evaluateProject(expression queryplan.Expression) (sequence, error) { + source, err := e.rowArgument(expression, "source") + if err != nil { + return sequence{}, err + } + var properties []string + if hasArgument(expression, "properties") { + properties, err = e.stringsArgument(expression, "properties") + if err != nil { + return sequence{}, err + } + } + var computed []computedColumn + if columnsValue, ok := argumentValue(expression, "columns"); ok { + computed, err = e.computedColumns(expression, columnsValue) + if err != nil { + return sequence{}, err + } + } + total := len(properties) + len(computed) + if total == 0 { + return sequence{}, e.invalidArgument(expression, "properties", "empty") + } + seen := make(map[string]bool, total) + for _, property := range properties { + seen[property] = true + } + for _, column := range computed { + if seen[column.name] { + return sequence{}, e.invalidArgument(expression, "columns", column.name) + } + seen[column.name] = true + } + result := sequence{ + values: append([]Value(nil), source.values...), + columns: make([]Column, total), + cells: make([][]Cell, len(source.values)), + } + known := make([]bool, len(properties)) + for i, property := range properties { + result.columns[i] = Column{name: property, origin: expression.Origin()} + } + for i, column := range computed { + result.columns[len(properties)+i] = Column{name: column.name, origin: column.origin.Origin()} + } + tracker := newPropertyTracker() + for row, value := range source.values { + result.cells[row] = make([]Cell, total) + for column, property := range properties { + values, present, valueErr := e.propertyValues(value, property) + if valueErr != nil { + return sequence{}, e.unevaluable(expression, property, value, valueErr) + } + known[column] = known[column] || present + result.cells[row][column] = Cell{ + values: values, + origin: value.Origin(), + } + } + for i, column := range computed { + values, cellErr := e.evaluateColumnCell(column, value, tracker) + if cellErr != nil { + return sequence{}, cellErr + } + result.cells[row][len(properties)+i] = Cell{ + values: values, + origin: value.Origin(), + } + } + } + if len(source.values) > 0 { + for i, property := range properties { + if !known[i] { + return sequence{}, e.unknownProperty(expression, property) + } + } + if property, missing := tracker.missing(); missing { + return sequence{}, e.unknownProperty(expression, property) + } + } + return result, nil +} + +// propertyValues reads a property of a row: of the session for an object row, +// of the check, state or trace record for a verdict, state or event row, of +// the model for an element row. +func (e *executor) propertyValues(row Value, property string) ([]Value, bool, error) { + if _, _, isObject := row.Object(); isObject { + return e.objectPropertyValues(row, property) + } + if _, isVerdict := row.Verdict(); isVerdict { + return e.verdictPropertyValues(row, property) + } + if _, isState := row.State(); isState { + return e.statePropertyValues(row, property) + } + if _, isEvent := row.Event(); isEvent { + return e.eventPropertyValues(row, property) + } + sym, _ := row.Element() + if isQueryableProperty(property) { + values, present := e.reader.Values(sym, property) + if !present { + return nil, true, nil + } + result := make([]Value, 0, len(values)) + for _, value := range values { + result = append(result, typedPropertyValue(property, value, sym)) + } + return result, true, nil + } + return e.declaredFeatureValues(sym, property) +} + +// declaredFeatureValues reads a declared (non-metadata) feature of a row. +func (e *executor) declaredFeatureValues(sym *symbols.Symbol, property string) ([]Value, bool, error) { + values, present := e.context.Model.DeclaredFeatureValues(sym, property) + if !present { + return nil, false, nil + } + result := make([]Value, 0, len(values)) + for _, value := range values { + converted, ok := filterValue(value, sym) + if !ok { + if value.Kind == symbols.FilterValueEmpty { + continue + } + if value.Kind == symbols.FilterValueUnknown { + derived, err := e.derivedFeatureValues(sym, property) + return derived, true, err + } + return nil, true, e.featureError(queryplan.Expression{}, property, ElementValue(sym)) + } + result = append(result, converted) + } + return result, true, nil +} + +func isQueryableProperty(property string) bool { + switch property { + case query.PropertyID, + query.PropertyType, + query.PropertyName, + query.PropertyDeclaredName, + query.PropertyShortName, + query.PropertyDeclaredShortName, + query.PropertyDocumentation, + query.PropertyQualifiedName, + query.PropertyOwner, + query.PropertyElementType, + query.PropertyIsAbstract, + query.PropertyIsIndividual, + query.PropertyMultiplicityLower, + query.PropertyMultiplicityUpper: + return true + default: + return false + } +} + +func typedPropertyValue(property, value string, sym *symbols.Symbol) Value { + var result Value + switch property { + case query.PropertyIsAbstract, query.PropertyIsIndividual: + boolean, _ := strconv.ParseBool(value) + result = BooleanValue(boolean) + case query.PropertyMultiplicityLower, query.PropertyMultiplicityUpper: + if value == "*" { + result = Value{kind: ValueInfinity} + } else { + integer, _ := strconv.ParseInt(value, 10, 64) + result = IntegerValue(integer) + } + default: + result = StringValue(value) + } + return valueAt(result, ElementValue(sym).Origin()) +} + +func filterValue(value symbols.FilterValue, sym *symbols.Symbol) (Value, bool) { + var result Value + switch value.Kind { + case symbols.FilterValueBool: + result = BooleanValue(value.Bool) + case symbols.FilterValueInt: + result = IntegerValue(value.Int) + case symbols.FilterValueReal: + result = RealValue(value.Real) + case symbols.FilterValueString: + result = StringValue(value.Str) + case symbols.FilterValueRef: + result = StringValue(value.RefFQN) + case symbols.FilterValueQuantity: + quantity, ok := semantics.QuantityOf(value) + if !ok { + return Value{}, false + } + result = QuantityValue(*quantity) + default: + return Value{}, false + } + return valueAt(result, ElementValue(sym).Origin()), true +} + +// The hyphenated spellings of the text operators, accepted beside the camelCase ones. +const ( + opStartsWith = "starts-with" + opEndsWith = "ends-with" +) + +func compareText(actual, operator, expected string) (bool, error) { + switch operator { + case "=", "==": + return actual == expected, nil + case "!=", "<>": + return actual != expected, nil + case "contains": + return strings.Contains(actual, expected), nil + case "startsWith", opStartsWith: + return strings.HasPrefix(actual, expected), nil + case "endsWith", opEndsWith: + return strings.HasSuffix(actual, expected), nil + case "matches": + expression, err := regexp.Compile(expected) + if err != nil { + return false, err + } + return expression.MatchString(actual), nil + default: + return false, errComparison + } +} + +func validateTextComparison(operator, expected string) error { + switch operator { + case "=", "==", "!=", "<>", "contains", "startsWith", opStartsWith, "endsWith", opEndsWith: + return nil + case "matches": + _, err := regexp.Compile(expected) + return err + default: + return errComparison + } +} + +func validateFeatureComparison(operator, expected string) error { + switch operator { + case "=", "==", "!=", "<>", "contains", "startsWith", opStartsWith, "endsWith", opEndsWith: + return nil + case "matches": + _, err := regexp.Compile(expected) + return err + case "<", "<=", ">", ">=": + _, err := parseNumericValue(expected) + return err + default: + return errComparison + } +} + +var errComparison = &comparisonError{} + +type comparisonError struct{} + +func (*comparisonError) Error() string { return "unsupported comparison" } + +func compareValue(actual Value, operator, expected string) (bool, error) { + switch actual.Kind() { + case ValueString: + value, _ := actual.String() + return compareText(value, operator, expected) + case ValueBoolean: + value, _ := actual.Boolean() + want, err := strconv.ParseBool(expected) + if err != nil { + return false, err + } + switch operator { + case "=", "==": + return value == want, nil + case "!=", "<>": + return value != want, nil + default: + return false, errComparison + } + case ValueInteger, ValueReal, ValueInfinity: + want, err := parseNumericValue(expected) + if err != nil { + return false, err + } + return compareOrdinal(compareNumeric(actual, want), operator) + case ValueQuantity: + // A bare number compares against the magnitude in the quantity's own unit. + magnitude, _ := actual.Magnitude() + return compareValue(magnitude, operator, expected) + case ValueElement: + // An element compares as the qualified name a cell prints it by. + sym, _ := actual.Element() + return compareText(symbols.FQNOf(sym), operator, expected) + default: + return false, errComparison + } +} + +func parseNumericValue(text string) (Value, error) { + if text == "*" { + return Value{kind: ValueInfinity}, nil + } + text = strings.ReplaceAll(text, "_", "") + if integer, err := strconv.ParseInt(text, 10, 64); err == nil { + return IntegerValue(integer), nil + } + realVal, err := strconv.ParseFloat(text, 64) + if err != nil || math.IsNaN(realVal) || math.IsInf(realVal, 0) { + return Value{}, strconv.ErrSyntax + } + return RealValue(realVal), nil +} + +func compareOrdinal(comparison int, operator string) (bool, error) { + switch operator { + case "=", "==": + return comparison == 0, nil + case "!=", "<>": + return comparison != 0, nil + case "<": + return comparison < 0, nil + case "<=": + return comparison <= 0, nil + case ">": + return comparison > 0, nil + case ">=": + return comparison >= 0, nil + default: + return false, errComparison + } +} + +// compareOrdered orders two keys orderedKeysCompatible admitted; quantities +// compare on the left key's reference, a point on a scale through its anchor. +func (e *executor) compareOrdered(left, right Value) (int, error) { + if numericKind(left.Kind()) && numericKind(right.Kind()) { + return compareNumeric(left, right), nil + } + if left.Kind() != right.Kind() { + return strings.Compare(string(left.Kind()), string(right.Kind())), nil + } + switch left.Kind() { + case ValueQuantity: + l, _ := left.Quantity() + r, _ := right.Quantity() + return e.derived.get(e.context).CompareMagnitudes(l, r) + case ValueString: + l, _ := left.String() + r, _ := right.String() + return strings.Compare(l, r), nil + case ValueElement: + l, _ := left.Element() + r, _ := right.Element() + return strings.Compare(symbols.FQNOf(l), symbols.FQNOf(r)), nil + case ValueBoolean: + l, _ := left.Boolean() + r, _ := right.Boolean() + if !l && r { + return -1, nil + } + if l && !r { + return 1, nil + } + } + return 0, nil +} + +func compareNumeric(left, right Value) int { + if left.Kind() == ValueInfinity { + if right.Kind() == ValueInfinity { + return 0 + } + return 1 + } + if right.Kind() == ValueInfinity { + return -1 + } + if left.Kind() == ValueInteger && right.Kind() == ValueReal { + l, _ := left.Integer() + r, _ := right.Real() + return compareIntReal(l, r) + } + if left.Kind() == ValueReal && right.Kind() == ValueInteger { + l, _ := left.Real() + r, _ := right.Integer() + return -compareIntReal(r, l) + } + switch left.Kind() { + case ValueInteger: + l, _ := left.Integer() + r, _ := right.Integer() + return compareInt(l, r) + case ValueReal: + l, _ := left.Real() + r, _ := right.Real() + return compareFloat(l, r) + } + return 0 +} + +func compareIntReal(integer int64, realVal float64) int { + left := new(big.Rat).SetInt64(integer) + right := new(big.Rat).SetFloat64(realVal) + return left.Cmp(right) +} + +func compareInt(left, right int64) int { + if left < right { + return -1 + } + if left > right { + return 1 + } + return 0 +} + +func compareFloat(left, right float64) int { + if left < right { + return -1 + } + if left > right { + return 1 + } + return 0 +} + +// orderedKeysCompatible reports whether two sort keys are comparable: values of +// one kind, numbers of any kind, or quantities the runtime can order. +func (e *executor) orderedKeysCompatible(left, right Value) bool { + if left.Kind() == ValueQuantity && right.Kind() == ValueQuantity { + l, _ := left.Quantity() + r, _ := right.Quantity() + _, err := e.derived.get(e.context).CompareMagnitudes(l, r) + return err == nil + } + if left.Kind() == right.Kind() { + return true + } + return numericKind(left.Kind()) && numericKind(right.Kind()) +} + +func numericKind(kind ValueKind) bool { + return kind == ValueInteger || kind == ValueReal || kind == ValueInfinity +} + +func (e *executor) resolveClassification(name string) *symbols.Symbol { + if matches := e.context.Index.LookupQualified(name); len(matches) == 1 { + return matches[0] + } + var match *symbols.Symbol + for _, fqn := range e.context.Index.FQNs() { + if fqn != name && !strings.HasSuffix(fqn, "::"+name) { + continue + } + candidates := e.context.Index.LookupQualified(fqn) + for _, candidate := range candidates { + if match != nil && !symbols.SameElement(match, candidate) { + return nil + } + match = candidate + } + } + return match +} + +// filtered starts a filter's result, keeping the source's projected columns +// even when no row is selected. +func filtered(source sequence) sequence { + return sequence{columns: append([]Column(nil), source.columns...)} +} + +func appendSelected(result *sequence, source sequence, index int) { + result.values = append(result.values, source.values[index]) + if index < len(source.cells) { + result.cells = append(result.cells, cloneCells(source.cells[index])) + } +} + +func (e *executor) consumeVisit() bool { + if e.budget.remaining <= 0 { + return false + } + e.budget.remaining-- + return true +} + +func (e *executor) budgetError(expression queryplan.Expression) error { + return &Error{ + Kind: ErrorVisitBudget, + Query: e.definition.Name(), + Operation: expression.Operation(), + Origin: expression.Origin(), + } +} + +func (e *executor) operatorError(expression queryplan.Expression, operator string) error { + return &Error{ + Kind: ErrorInvalidOperator, + Query: e.definition.Name(), + Operation: expression.Operation(), + Actual: operator, + Origin: expression.Origin(), + } +} + +func (e *executor) unknownProperty(expression queryplan.Expression, property string) error { + return &Error{ + Kind: ErrorUnknownProperty, + Query: e.definition.Name(), + Operation: expression.Operation(), + Property: property, + Origin: expression.Origin(), + } +} + +// invalidOrder reports sort keys that cannot be ordered; two quantity keys name +// their incommensurable units. +func (e *executor) invalidOrder(expression queryplan.Expression, property string, first, other Value) error { + err := &Error{ + Kind: ErrorInvalidOrder, + Query: e.definition.Name(), + Operation: expression.Operation(), + Property: property, + Origin: expression.Origin(), + } + if l, ok := first.Quantity(); ok { + if r, ok := other.Quantity(); ok { + err.Expected = l.Unit.String() + err.Actual = r.Unit.String() + } + } + return err +} + +func (e *executor) featureError(expression queryplan.Expression, property string, row Value) error { + return e.unevaluable(expression, property, row, nil) +} + +// unevaluable is featureError carrying the evaluator's reason, when one is known. +func (e *executor) unevaluable(expression queryplan.Expression, property string, row Value, cause error) error { + var inner *Error + if errors.As(cause, &inner) && inner.Kind == ErrorUnevaluableFeature { + cause = inner.Cause + } + return &Error{ + Kind: ErrorUnevaluableFeature, + Query: e.definition.Name(), + Operation: expression.Operation(), + Property: property, + Target: rowTarget(row), + Origin: expression.Origin(), + Cause: cause, + } +} + +// rowTarget names a row in an error: an element by qualified name, an object +// by the label the session reaches it by, a verdict, state or event by its label. +func rowTarget(row Value) string { + if _, label, ok := row.Object(); ok { + return label + } + if verdict, ok := row.Verdict(); ok { + return verdict.Label() + } + if state, ok := row.State(); ok { + return state.Label() + } + if event, ok := row.Event(); ok { + return event.Label() + } + sym, _ := row.Element() + return symbols.FQNOf(sym) +} diff --git a/internal/doc/queryexec/quantity_test.go b/internal/doc/queryexec/quantity_test.go new file mode 100644 index 0000000000..d4b4c1924e --- /dev/null +++ b/internal/doc/queryexec/quantity_test.go @@ -0,0 +1,581 @@ +package queryexec + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// quantityBody is the units repro: stages whose masses are quantities, with a +// gram-denominated stage, a length, and a mass no constant fold can reach. +const quantityBody = ` +private import SI::*; +private import ISQ::*; + +part def Stage { + attribute mass :> ISQ::mass; + attribute length :> ISQ::length; +} +part def FirstStage :> Stage { + attribute :>> mass = 2290000 [kg]; + attribute :>> length = 42 [m]; +} +part def UpperStage :> Stage { + attribute :>> mass = 119000 [kg]; + attribute :>> length = 18 [m]; +} +part def Probe :> Stage { + attribute :>> mass = 500000 [g]; + attribute :>> length = 1 [m]; +} +part def Payload :> Stage { + action weigh { in reading : Real; } + attribute :>> mass = weigh.reading [kg]; +} +part def Cargo :> Stage { + attribute dryMass :> ISQ::mass; + attribute propellantMass :> ISQ::mass; + attribute :>> mass = dryMass + propellantMass; +} +part rocket { + part s1 : FirstStage; + part s2 : UpperStage; +} +part fleet { + part s1 : FirstStage; + part probe : Probe; +} +part manifest { + part s1 : FirstStage; + part payload : Payload; +} +part hold { + part s1 : FirstStage; + part cargo : Cargo; +} +` + +func quantityFixture(t *testing.T, query string) executionFixture { + t.Helper() + return loadExecutionFixture(t, quantityBody+query) +} + +func quantityRows(t *testing.T, fixture executionFixture, query, root string) *RowSet { + t.Helper() + result, err := fixture.execute(t, query, Bindings{ + "root": {ElementValue(fixture.symbol(t, root))}, + }, Options{}) + if err != nil { + t.Fatalf("execute %s: %v", query, err) + } + return result +} + +// cellTexts renders one column of a result the way the CLI row listing does. +func cellTexts(t *testing.T, result *RowSet, column int) []string { + t.Helper() + var out []string + for _, row := range result.Rows() { + values := row.Cells()[column].Values() + if len(values) != 1 { + t.Fatalf("cell %d has %d values, want 1", column, len(values)) + } + value := values[0] + if quantity, ok := value.Quantity(); ok { + out = append(out, quantity.String()) + continue + } + if text, ok := value.String(); ok { + out = append(out, text) + continue + } + if realVal, ok := value.Real(); ok { + out = append(out, semantics.FormatReal(realVal)) + continue + } + t.Fatalf("cell %d holds an unexpected %s", column, value.Kind()) + } + return out +} + +func executionError(t *testing.T, err error, kind ErrorKind) *Error { + t.Helper() + var executionErr *Error + if !errors.As(err, &executionErr) || executionErr.Kind != kind { + t.Fatalf("error = %v, want %s", err, kind) + } + return executionErr +} + +const massesQuery = ` +calc def Masses :> Query { + in root : Element; + Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name", "mass") + ) +} +` + +// TestExecuteProjectsQuantityValues: the repro projects unit-bearing masses as +// quantities that keep their magnitude and unit. +func TestExecuteProjectsQuantityValues(t *testing.T) { + fixture := quantityFixture(t, massesQuery) + result := quantityRows(t, fixture, "Masses", "rocket") + if got := cellTexts(t, result, 0); !slices.Equal(got, []string{"s1", "s2"}) { + t.Fatalf("names = %v", got) + } + if got := cellTexts(t, result, 1); !slices.Equal(got, []string{"2290000 [kg]", "119000 [kg]"}) { + t.Fatalf("masses = %v", got) + } + mass := result.Rows()[0].Cells()[1].Values()[0] + if mass.Kind() != ValueQuantity { + t.Fatalf("mass kind = %s, want quantity", mass.Kind()) + } + magnitude, ok := mass.Magnitude() + if n, isInt := magnitude.Integer(); !ok || !isInt || n != 2290000 { + t.Fatalf("magnitude = %+v, want Integer 2290000", magnitude) + } + if !mass.Origin().Located() { + t.Fatal("quantity cells must retain provenance") + } +} + +// TestQuantityCellsAreImmutable: mutating a quantity read from a result leaves +// the stored value, factors and powers included, as it was. +func TestQuantityCellsAreImmutable(t *testing.T) { + fixture := quantityFixture(t, massesQuery) + result := quantityRows(t, fixture, "Masses", "rocket") + read := func() semantics.Quantity { + quantity, ok := result.Rows()[0].Cells()[1].Values()[0].Quantity() + if !ok { + t.Fatal("mass is not a quantity") + } + return quantity + } + before := read() + if len(before.Unit.Term.Factors) == 0 || len(before.Unit.Product.Powers) == 0 { + t.Fatalf("fixture quantity %s has no factors or powers to mutate", before.String()) + } + mutated := read() + mutated.Num.Int = 1 + mutated.Unit.Text = "lb" + mutated.Unit.Term.Scale = semantics.UnitScale(7) + mutated.Unit.Term.Factors[0].Exponent = 9 + mutated.Unit.Product.Powers[0].Exponent = 9 + mutated.Unit.Product.Powers[0].Name = "lb" + + after := read() + if after.String() != before.String() { + t.Fatalf("stored quantity became %s, was %s", after.String(), before.String()) + } + if !after.Unit.Term.Same(before.Unit.Term) || !after.Unit.Product.Equal(before.Unit.Product) { + t.Fatalf("stored unit changed: term %+v -> %+v, product %+v -> %+v", + before.Unit.Term, after.Unit.Term, before.Unit.Product, after.Unit.Product) + } + if after.Unit.Term.Factors[0].Exponent == 9 || after.Unit.Product.Powers[0].Exponent == 9 { + t.Fatal("stored unit shares slice storage with the returned copy") + } +} + +// TestExecuteFiltersQuantitiesByBareMagnitude: a bare numeric value compares +// against the magnitude in each row's own unit. +func TestExecuteFiltersQuantitiesByBareMagnitude(t *testing.T) { + fixture := quantityFixture(t, ` +calc def Heavy :> Query { + in root : Element; + Project( + source = WhereFeature( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + 'feature' = "mass", + operator = ">=", + value = "1000000" + ), + properties = ("name", "mass") + ) +} +calc def Exact :> Query { + in root : Element; + Project( + source = WhereFeature( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + 'feature' = "mass", + operator = "=", + value = "119000" + ), + properties = ("name") + ) +} +calc def UnitInValue :> Query { + in root : Element; + WhereFeature( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + 'feature' = "mass", + operator = "=", + value = "119000 [kg]" + ) +} +`) + heavy := quantityRows(t, fixture, "Heavy", "rocket") + if got := cellTexts(t, heavy, 0); !slices.Equal(got, []string{"s1"}) { + t.Fatalf("heavy names = %v", got) + } + if got := cellTexts(t, heavy, 1); !slices.Equal(got, []string{"2290000 [kg]"}) { + t.Fatalf("heavy masses = %v", got) + } + exact := quantityRows(t, fixture, "Exact", "rocket") + if got := cellTexts(t, exact, 0); !slices.Equal(got, []string{"s2"}) { + t.Fatalf("exact names = %v", got) + } + _, err := fixture.execute(t, "UnitInValue", Bindings{ + "root": {ElementValue(fixture.symbol(t, "rocket"))}, + }, Options{}) + executionError(t, err, ErrorInvalidArgument) +} + +const orderedQuery = ` +calc def Ordered :> Query { + in root : Element; + OrderBy( + source = Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name", "mass") + ), + property = "mass", + direction = "ascending", + missing = "last", + multiple = "error" + ) +} +calc def ByLength :> Query { + in root : Element; + OrderBy( + source = Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name", "mass", "length") + ), + property = "length", + direction = "descending", + missing = "last", + multiple = "error" + ) +} +` + +// TestExecuteOrdersQuantitiesAcrossCommensurableUnits: sorting converts +// commensurable units, so 500000 g sorts before 2290000 kg and both keep their +// spelling. +func TestExecuteOrdersQuantitiesAcrossCommensurableUnits(t *testing.T) { + fixture := quantityFixture(t, orderedQuery) + rocket := quantityRows(t, fixture, "Ordered", "rocket") + if got := cellTexts(t, rocket, 0); !slices.Equal(got, []string{"s2", "s1"}) { + t.Fatalf("rocket order = %v", got) + } + fleet := quantityRows(t, fixture, "Ordered", "fleet") + if got := cellTexts(t, fleet, 0); !slices.Equal(got, []string{"probe", "s1"}) { + t.Fatalf("fleet order = %v", got) + } + if got := cellTexts(t, fleet, 1); !slices.Equal(got, []string{"500000 [g]", "2290000 [kg]"}) { + t.Fatalf("fleet masses = %v", got) + } + lengths := quantityRows(t, fixture, "ByLength", "rocket") + if got := cellTexts(t, lengths, 2); !slices.Equal(got, []string{"42 [m]", "18 [m]"}) { + t.Fatalf("lengths = %v", got) + } +} + +// TestExecuteRefusesToOrderIncommensurableQuantities: keys in kg and m are a +// typed error naming both units, never a silent ordering by magnitude. +func TestExecuteRefusesToOrderIncommensurableQuantities(t *testing.T) { + fixture := quantityFixture(t, ` +part mixed { + part s1 : FirstStage; + part rod { + attribute mass = 3 [m]; + } +} +calc def Ordered :> Query { + in root : Element; + OrderBy( + source = Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name", "mass") + ), + property = "mass", + direction = "ascending", + missing = "last", + multiple = "error" + ) +} +`) + _, err := fixture.execute(t, "Ordered", Bindings{ + "root": {ElementValue(fixture.symbol(t, "mixed"))}, + }, Options{}) + executionErr := executionError(t, err, ErrorInvalidOrder) + if executionErr.Expected != "kg" || executionErr.Actual != "m" { + t.Fatalf("units = %q and %q, want kg and m", executionErr.Expected, executionErr.Actual) + } + want := "query Observatory::Ordered cannot order property mass across incommensurable units kg and m" + if executionErr.Error() != want { + t.Fatalf("message = %q, want %q", executionErr.Error(), want) + } +} + +// TestExecuteOrdersLargeIntegerQuantitiesExactly: Integer magnitudes above 2^53, +// which one float64 could not tell apart, order exactly in one unit and across +// a conversion. +func TestExecuteOrdersLargeIntegerQuantitiesExactly(t *testing.T) { + fixture := quantityFixture(t, ` +part heavy { + part above { + attribute mass = 9007199254740993 [kg]; + } + part at { + attribute mass = 9007199254740992 [kg]; + } + part grams { + attribute mass = 9007199254740992000 [g]; + } +} +calc def Ascending :> Query { + in root : Element; + OrderBy( + source = Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name", "mass") + ), + property = "mass", + direction = "ascending", + missing = "last", + multiple = "error" + ) +} +calc def Descending :> Query { + in root : Element; + OrderBy( + source = Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name", "mass") + ), + property = "mass", + direction = "descending", + missing = "last", + multiple = "error" + ) +} +`) + ascending := quantityRows(t, fixture, "Ascending", "heavy") + if got := cellTexts(t, ascending, 0); !slices.Equal(got, []string{"at", "grams", "above"}) { + t.Fatalf("ascending order = %v", got) + } + descending := quantityRows(t, fixture, "Descending", "heavy") + if got := cellTexts(t, descending, 0); !slices.Equal(got, []string{"above", "at", "grams"}) { + t.Fatalf("descending order = %v", got) + } +} + +// TestExecuteComputesQuantityColumns: column arithmetic keeps units, composes +// them under * and /, and a ratio of like quantities is a bare number. +func TestExecuteComputesQuantityColumns(t *testing.T) { + fixture := quantityFixture(t, ` +calc def Derived :> Query { + in root : Element; + Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name"), + columns = ( + Column(name = "twice", expression = Stage::mass * 2), + Column(name = "tonnes", expression = Stage::mass / 1000), + Column(name = "half", expression = Stage::mass - Stage::mass / 2), + Column(name = "density", expression = Stage::mass / Stage::length), + Column(name = "ratio", expression = Stage::length / Stage::length), + Column(name = "sum", expression = Stage::mass + Stage::mass) + ) + ) +} +`) + result := quantityRows(t, fixture, "Derived", "rocket") + cases := []struct { + column int + want []string + }{ + {1, []string{"4580000 [kg]", "238000 [kg]"}}, + {2, []string{"2290.0 [kg]", "119.0 [kg]"}}, + {3, []string{"1145000.0 [kg]", "59500.0 [kg]"}}, + {4, []string{"54523.80952380953 [SI::'kg⋅m⁻¹']", "6611.111111111111 [SI::'kg⋅m⁻¹']"}}, + {5, []string{"1.0", "1.0"}}, + {6, []string{"4580000 [kg]", "238000 [kg]"}}, + } + for _, tc := range cases { + if got := cellTexts(t, result, tc.column); !slices.Equal(got, tc.want) { + t.Errorf("column %s = %v, want %v", result.Columns()[tc.column].Name(), got, tc.want) + } + } +} + +// TestExecuteKeepsWrittenUnitsThroughSignsAndSums: only products and quotients +// compose a unit, so a sign or a sum over `km/h` still reads in `km/h`. +func TestExecuteKeepsWrittenUnitsThroughSignsAndSums(t *testing.T) { + fixture := quantityFixture(t, ` +part def Cruiser :> Stage { + attribute speed :> ISQ::speed = 36 [km/h]; +} +part convoy { + part lead : Cruiser; +} +calc def Signed :> Query { + in root : Element; + Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name"), + columns = ( + Column(name = "reverse", expression = -Cruiser::speed), + Column(name = "same", expression = +Cruiser::speed), + Column(name = "twice", expression = Cruiser::speed + Cruiser::speed), + Column(name = "half", expression = Cruiser::speed / 2) + ) + ) +} +`) + result := quantityRows(t, fixture, "Signed", "convoy") + cases := []struct { + column int + want []string + }{ + {1, []string{"-36 [km/h]"}}, + {2, []string{"36 [km/h]"}}, + {3, []string{"72 [km/h]"}}, + {4, []string{"5.0 [SI::'m/s']"}}, + } + for _, tc := range cases { + if got := cellTexts(t, result, tc.column); !slices.Equal(got, tc.want) { + t.Errorf("column %s = %v, want %v", result.Columns()[tc.column].Name(), got, tc.want) + } + } +} + +// TestExecuteRefusesIncommensurableColumnArithmetic: `mass + length` is a +// typed error naming the column, row and both units. +func TestExecuteRefusesIncommensurableColumnArithmetic(t *testing.T) { + fixture := quantityFixture(t, ` +calc def Nonsense :> Query { + in root : Element; + Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name"), + columns = (Column(name = "nonsense", expression = Stage::mass + Stage::length)) + ) +} +`) + _, err := fixture.execute(t, "Nonsense", Bindings{ + "root": {ElementValue(fixture.symbol(t, "rocket"))}, + }, Options{}) + executionErr := executionError(t, err, ErrorColumnIncommensurable) + if executionErr.Property != "nonsense" || executionErr.Target != "Observatory::rocket::s1" { + t.Fatalf("column %q for %q, want nonsense for Observatory::rocket::s1", executionErr.Property, executionErr.Target) + } + if executionErr.Actual != "kg and m" { + t.Fatalf("units = %q, want %q", executionErr.Actual, "kg and m") + } +} + +// TestExecuteNamesTheRowOfAnUnevaluableQuantity: a mass that depends on an +// action's `in` parameter is a typed error naming the query, the feature, the +// row and the parameter no declaration binds. +func TestExecuteNamesTheRowOfAnUnevaluableQuantity(t *testing.T) { + fixture := quantityFixture(t, massesQuery) + _, err := fixture.execute(t, "Masses", Bindings{ + "root": {ElementValue(fixture.symbol(t, "manifest"))}, + }, Options{}) + executionErr := executionError(t, err, ErrorUnevaluableFeature) + want := "query Observatory::Masses cannot evaluate feature mass of Observatory::manifest::payload: " + if !strings.HasPrefix(executionErr.Error(), want) || !strings.Contains(executionErr.Error(), "reading") { + t.Fatalf("message = %q, want prefix %q naming reading", executionErr.Error(), want) + } + if !errors.Is(err, runtime.ErrNoValue) { + t.Fatalf("error = %v, want the runtime's no-value cause", err) + } +} + +// TestExecuteReadsAMassOverUnboundFeaturesAsAbsent: a mass declared over +// features nothing binds is an empty cell, as a value-less feature is. +func TestExecuteReadsAMassOverUnboundFeaturesAsAbsent(t *testing.T) { + fixture := quantityFixture(t, massesQuery) + result := quantityRows(t, fixture, "Masses", "hold") + if got := cellTexts(t, result, 0); !slices.Equal(got, []string{"s1", "cargo"}) { + t.Fatalf("names = %v", got) + } + if values := result.Rows()[1].Cells()[1].Values(); len(values) != 0 { + t.Fatalf("cargo mass = %v, want absent", values) + } +} + +// boundQuantity folds a quantity expression in the fixture's package for a binding. +func boundQuantity(t *testing.T, fixture executionFixture, expr string) Value { + t.Helper() + p := parser.New(source.New("", []byte(expr))) + node := p.ParseExpression() + if node == nil || len(p.Diagnostics) != 0 { + t.Fatalf("parse %q: %v", expr, p.Diagnostics) + } + quantity, ok := fixture.model.EvalQuantity(fixture.symbol(t, "rocket").Scope, node) + if !ok { + t.Fatalf("%q does not fold to a quantity", expr) + } + return QuantityValue(quantity) +} + +// TestExecuteBindsQuantityParameters: a quantity binds to a parameter typed by +// a quantity value type of its dimension, and flows into column arithmetic; +// another dimension, or a parameter outside the quantity types, is a typed error. +func TestExecuteBindsQuantityParameters(t *testing.T) { + fixture := quantityFixture(t, ` +calc def Margin :> Query { + in root : Element; + in budget : MassValue; + Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name"), + columns = (Column(name = "margin", expression = budget - Stage::mass)) + ) +} +calc def Labelled :> Query { + in root : Element; + in label : String; + WhereFeature(source = Descendants(source = root, maxDepth = 1), 'feature' = "name", operator = "=", value = label) +} +`) + root := ElementValue(fixture.symbol(t, "rocket")) + result, err := fixture.execute(t, "Margin", Bindings{ + "root": {root}, + "budget": {boundQuantity(t, fixture, "2500000 [kg]")}, + }, Options{}) + if err != nil { + t.Fatalf("execute with a mass binding: %v", err) + } + if got := cellTexts(t, result, 1); !slices.Equal(got, []string{"210000 [kg]", "2381000 [kg]"}) { + t.Fatalf("margins = %v", got) + } + _, err = fixture.execute(t, "Margin", Bindings{ + "root": {root}, + "budget": {boundQuantity(t, fixture, "2500 [m]")}, + }, Options{}) + executionErr := executionError(t, err, ErrorBindingType) + if executionErr.Parameter != "budget" || executionErr.Actual != string(ValueQuantity) { + t.Fatalf("length-for-mass binding error = %v", err) + } + _, err = fixture.execute(t, "Labelled", Bindings{ + "root": {root}, + "label": {boundQuantity(t, fixture, "1 [kg]")}, + }, Options{}) + executionErr = executionError(t, err, ErrorBindingType) + if executionErr.Parameter != "label" || executionErr.Actual != string(ValueQuantity) { + t.Fatalf("quantity-for-string binding error = %v", err) + } +} diff --git a/internal/doc/queryexec/related.go b/internal/doc/queryexec/related.go new file mode 100644 index 0000000000..d8316be538 --- /dev/null +++ b/internal/doc/queryexec/related.go @@ -0,0 +1,387 @@ +package queryexec + +import ( + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// Relationship kinds RelatedElements traverses. A lineage kind follows the +// declared relationships of the element itself; the others follow edges other +// declarations state about it (a connector usage, a satisfy/verify assertion, +// a requirement derivation, a refinement dependency). +const ( + relationshipSpecialization = "specialization" + relationshipSubsetting = "subsetting" + relationshipRedefinition = "redefinition" + relationshipTyping = "typing" + relationshipConnection = "connection" + relationshipAllocation = "allocation" + relationshipSatisfaction = "satisfaction" + relationshipVerification = "verification" + relationshipDerivation = "derivation" + relationshipRefinement = "refinement" +) + +// Traversal directions: outgoing follows an edge from its source to its +// targets, incoming follows it in reverse. +const ( + directionOutgoing = "outgoing" + directionIncoming = "incoming" +) + +// lineageKinds maps the lineage relationship kinds to the AST relationship +// they follow. +var lineageKinds = map[string]ast.RelationshipKind{ + relationshipSpecialization: ast.RelSpecializes, + relationshipSubsetting: ast.RelSubsets, + relationshipRedefinition: ast.RelRedefines, + relationshipTyping: ast.RelTyping, +} + +// relationshipEdges holds the resolved edges of one relationship kind, in +// document order then declaration order, keyed by semantic identity. +type relationshipEdges struct { + outgoing map[symbols.ElementKey][]*symbols.Symbol + incoming map[symbols.ElementKey][]*symbols.Symbol +} + +// RelationshipTables memoizes one model's per-kind edge tables across the +// executions whose Context shares them. Not safe for concurrent use. +type RelationshipTables struct { + index *symbols.Index + generation uint64 + model *semantics.Model + entries map[string]*relationshipEdges +} + +// NewRelationshipTables returns empty tables for a Context to carry. +func NewRelationshipTables() *RelationshipTables { + return &RelationshipTables{entries: make(map[string]*relationshipEdges)} +} + +// lookup returns the cached tables of one kind, first discarding every entry +// built against another index or model, or the index before an edit. +func (t *RelationshipTables) lookup(kind string, context Context) (*relationshipEdges, bool) { + generation := context.Index.Generation() + if t.index != context.Index || t.generation != generation || t.model != context.Model { + t.index, t.generation, t.model = context.Index, generation, context.Model + t.entries = make(map[string]*relationshipEdges) + } + edges, ok := t.entries[kind] + return edges, ok +} + +// relationshipWalk is the validated relationshipKind, direction and maxDepth +// arguments of an operation traversing relationships. +type relationshipWalk struct { + kind string + direction string + maxDepth depthLimit +} + +func (e *executor) evaluateRelated(expression queryplan.Expression) (sequence, error) { + source, err := e.elementArgument(expression, "source") + if err != nil { + return sequence{}, err + } + walk, err := e.relationshipArguments(expression) + if err != nil { + return sequence{}, err + } + seeds := make([]*symbols.Symbol, len(source.values)) + for i, value := range source.values { + seeds[i], _ = value.Element() + } + var result sequence + err = e.traverseRelated(expression, walk, seeds, func(neighbor *symbols.Symbol) bool { + result.values = append(result.values, ElementValue(neighbor)) + return true + }) + if err != nil { + return sequence{}, err + } + return result, nil +} + +// relationshipArguments reads and validates the relationshipKind, direction and +// maxDepth arguments, reporting an unsupported kind or direction as a typed error. +func (e *executor) relationshipArguments(expression queryplan.Expression) (relationshipWalk, error) { + kind, err := e.stringArgument(expression, "relationshipKind") + if err != nil { + return relationshipWalk{}, err + } + direction, err := e.stringArgument(expression, "direction") + if err != nil { + return relationshipWalk{}, err + } + maxDepth, err := e.depthArgument(expression) + if err != nil { + return relationshipWalk{}, err + } + if !supportedRelationship(kind) { + return relationshipWalk{}, &Error{ + Kind: ErrorUnknownRelationship, + Query: e.definition.Name(), + Operation: expression.Operation(), + Actual: kind, + Origin: expression.Origin(), + } + } + if direction != directionOutgoing && direction != directionIncoming { + return relationshipWalk{}, e.operatorError(expression, direction) + } + return relationshipWalk{kind: kind, direction: direction, maxDepth: maxDepth}, nil +} + +// traverseRelated walks the relationship breadth-first from the seeds to maxDepth, charging +// the visit budget and calling visit once per newly reached element until it returns false. +func (e *executor) traverseRelated( + expression queryplan.Expression, + walk relationshipWalk, + seeds []*symbols.Symbol, + visit func(*symbols.Symbol) bool, +) error { + type pending struct { + sym *symbols.Symbol + depth int64 + } + queue := make([]pending, 0, len(seeds)) + seen := make(map[symbols.ElementKey]struct{}) + for _, sym := range seeds { + seen[symbols.KeyOf(sym)] = struct{}{} + queue = append(queue, pending{sym: sym}) + } + for len(queue) > 0 { + next := queue[0] + queue = queue[1:] + if walk.maxDepth.reached(next.depth) { + continue + } + for _, neighbor := range e.relatedNeighbors(walk.kind, walk.direction, next.sym) { + key := symbols.KeyOf(neighbor) + if _, duplicate := seen[key]; duplicate { + continue + } + if !e.consumeVisit() { + return e.budgetError(expression) + } + seen[key] = struct{}{} + if !visit(neighbor) { + return nil + } + queue = append(queue, pending{sym: neighbor, depth: next.depth + 1}) + } + } + return nil +} + +func supportedRelationship(kind string) bool { + if _, ok := lineageKinds[kind]; ok { + return true + } + switch kind { + case relationshipConnection, relationshipAllocation, + relationshipSatisfaction, relationshipVerification, + relationshipDerivation, relationshipRefinement: + return true + } + return false +} + +// relatedNeighbors returns the elements one edge of the given kind away from +// sym in the given direction, in declaration order. Outgoing lineage reads +// sym's own declared relationships; every other combination reads the edge +// tables built from the workspace's declarations. +func (e *executor) relatedNeighbors(kind, direction string, sym *symbols.Symbol) []*symbols.Symbol { + if relKind, lineage := lineageKinds[kind]; lineage && direction == directionOutgoing { + return e.lineageTargets(sym, relKind) + } + edges := e.relationshipEdges(kind) + table := edges.outgoing + if direction == directionIncoming { + table = edges.incoming + } + return table[symbols.KeyOf(sym)] +} + +// lineageTargets resolves the targets of sym's declared relationships of the +// given kind, in declaration order. +func (e *executor) lineageTargets(sym *symbols.Symbol, kind ast.RelationshipKind) []*symbols.Symbol { + var out []*symbols.Symbol + for _, rel := range semantics.RelationshipsOf(sym) { + if rel == nil || rel.Kind != kind || rel.Target == nil { + continue + } + if target, ok := e.context.Resolver.ResolveTarget(sym.OwnerScope, rel.Target); ok && target != nil { + out = append(out, target) + } + } + return out +} + +// relationshipEdges returns one kind's edge tables, built on first use by scanning +// the workspace's documents in name order. The scan is memoized and uncharged; +// only the elements a traversal reaches pay the visit budget. +func (e *executor) relationshipEdges(kind string) *relationshipEdges { + if cached, ok := e.related.lookup(kind, e.context); ok { + return cached + } + edges := &relationshipEdges{ + outgoing: make(map[symbols.ElementKey][]*symbols.Symbol), + incoming: make(map[symbols.ElementKey][]*symbols.Symbol), + } + for _, document := range e.context.Index.WorkspaceDocuments() { + e.scanScope(edges, kind, e.context.Index.DocumentRoot(document)) + } + e.related.entries[kind] = edges + return edges +} + +// scanScope records the edges of one relationship kind that the declarations +// in scope and its nested scopes state. +func (e *executor) scanScope(edges *relationshipEdges, kind string, scope *symbols.Scope) { + if scope == nil { + return + } + for _, member := range scope.AllMembers() { + e.scanSymbol(edges, kind, member) + } + for _, child := range scope.Children() { + e.scanScope(edges, kind, child) + } +} + +// scanSymbol records the edges the given symbol's declaration states: the +// resolved targets of a lineage relationship, the resolved end features of a +// connector usage, the subject and requirement of a satisfaction assertion, +// the requirements of a derivation, or the ends of a refinement dependency. +func (e *executor) scanSymbol(edges *relationshipEdges, kind string, sym *symbols.Symbol) { + if relKind, lineage := lineageKinds[kind]; lineage { + for _, target := range e.lineageTargets(sym, relKind) { + addEdge(edges, sym, target) + } + return + } + switch kind { + case relationshipConnection, relationshipAllocation: + e.scanConnector(edges, kind, sym) + case relationshipSatisfaction, relationshipVerification: + e.scanSatisfaction(edges, kind, sym) + case relationshipDerivation: + e.scanDerivation(edges, sym) + case relationshipRefinement: + e.scanRefinement(edges, sym) + } +} + +// scanConnector records the edges a connector usage states: from the feature +// its first end attaches to, to the feature of each later end, in declaration +// order. +func (e *executor) scanConnector(edges *relationshipEdges, kind string, sym *symbols.Symbol) { + usage, ok := sym.Decl.(*ast.Usage) + if !ok || !connectorRelationship(usage.Kind, kind) || !e.context.Model.IsConnectorUsage(sym) { + return + } + var ends []*symbols.Symbol + for _, attachment := range e.context.Model.ConnectorEndAttachments(sym) { + if attachment.Attachment == nil { + continue + } + target, ok := e.context.Resolver.ResolveTarget(sym.OwnerScope, attachment.Attachment) + if !ok || target == nil { + continue + } + ends = append(ends, target) + } + if len(ends) < 2 { + return + } + for _, target := range ends[1:] { + addEdge(edges, ends[0], target) + } +} + +// connectorRelationship reports whether a connector usage of the given AST +// kind carries edges of the named relationship kind. Connection covers the +// connection, connector, and interface usages; allocation stands alone. +func connectorRelationship(usage ast.UsageKind, kind string) bool { + switch kind { + case relationshipConnection: + return usage == ast.UsageConnection || usage == ast.UsageConnector || usage == ast.UsageInterface + case relationshipAllocation: + return usage == ast.UsageAllocation + } + return false +} + +// scanSatisfaction records the edge a satisfy or verify assertion states: from +// the subject its `by` clause names — else the element stating the assertion — +// to the requirement it references, or to the assertion itself when it +// declares its requirement (`satisfy requirement r by v { ... }`) — and then +// also to the requirement definition the declaration is typed by, which is +// the requirement a v1 model states the satisfaction of. +func (e *executor) scanSatisfaction(edges *relationshipEdges, kind string, sym *symbols.Symbol) { + usage, ok := sym.Decl.(*ast.Usage) + if !ok || usage.Kind != ast.UsageSatisfy { + return + } + if (usage.Keyword == "verify") != (kind == relationshipVerification) { + return + } + var requirement, definition, subject *symbols.Symbol + for _, rel := range usage.Relationships { + if rel == nil || rel.Target == nil { + continue + } + target, ok := e.context.Resolver.ResolveTarget(sym.OwnerScope, rel.Target) + if !ok || target == nil { + continue + } + switch rel.Kind { + case ast.RelSubsets: + // A declaration form's subsettings refine the declared requirement; + // only the reference form names the requirement this way. + if !usage.DeclaresRequirement { + requirement = target + } + case ast.RelTyping: + if usage.DeclaresRequirement && target.Kind == symbols.SymbolRequirementDef { + definition = target + } + case ast.RelSubject: + subject = target + } + } + if usage.DeclaresRequirement { + requirement = sym + } + if subject == nil && sym.OwnerScope != nil { + subject = sym.OwnerScope.Owner() + // A verify lives in the objective of a verification case; the case is + // the verifier. + if kind == relationshipVerification && subject != nil && isObjectiveUsage(subject.Decl) && subject.OwnerScope != nil { + subject = subject.OwnerScope.Owner() + } + } + if subject == nil || requirement == nil { + return + } + addEdge(edges, subject, requirement) + if definition != nil { + addEdge(edges, subject, definition) + } +} + +func isObjectiveUsage(decl ast.Node) bool { + usage, ok := decl.(*ast.Usage) + return ok && usage.Kind == ast.UsageObjective +} + +// addEdge records one source-to-target edge in both directions. +func addEdge(edges *relationshipEdges, source, target *symbols.Symbol) { + sourceKey, targetKey := symbols.KeyOf(source), symbols.KeyOf(target) + edges.outgoing[sourceKey] = append(edges.outgoing[sourceKey], target) + edges.incoming[targetKey] = append(edges.incoming[targetKey], source) +} diff --git a/internal/doc/queryexec/related_column.go b/internal/doc/queryexec/related_column.go new file mode 100644 index 0000000000..162ef4a43e --- /dev/null +++ b/internal/doc/queryexec/related_column.go @@ -0,0 +1,108 @@ +package queryexec + +import ( + "errors" + + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// relatedColumn is one decoded RelatedColumn of a projection: a relationship +// traversal run from each row's declaration, kept to targets when given and +// reduced by aggregate. +type relatedColumn struct { + plan queryplan.Expression + walk relationshipWalk + aggregate string + targets map[symbols.ElementKey]struct{} +} + +// keeps reports whether a reached element counts for the column. +func (c *relatedColumn) keeps(sym *symbols.Symbol) bool { + if c.targets == nil { + return true + } + _, ok := c.targets[symbols.KeyOf(sym)] + return ok +} + +// relatedColumnOf evaluates a RelatedColumn's arguments once per projection +// and validates them as RelatedElements would, naming the column in failures. +func (e *executor) relatedColumnOf(plan queryplan.Expression) (*relatedColumn, error) { + column := &relatedColumn{plan: plan, aggregate: queryplan.RelatedAggregateList} + var err error + if column.walk, err = e.relationshipArguments(plan); err != nil { + return nil, columnScoped(err, plan.Target()) + } + if hasArgument(plan, "aggregate") { + if column.aggregate, err = e.stringArgument(plan, "aggregate"); err != nil { + return nil, columnScoped(err, plan.Target()) + } + } + if !queryplan.RelatedAggregateSupported(column.aggregate) { + return nil, columnScoped(e.invalidArgument(plan, "aggregate", column.aggregate), plan.Target()) + } + if hasArgument(plan, "targets") { + targets, err := e.elementArgument(plan, "targets") + if err != nil { + return nil, columnScoped(err, plan.Target()) + } + column.targets = make(map[symbols.ElementKey]struct{}, len(targets.values)) + for _, value := range targets.values { + sym, _ := value.Element() + column.targets[symbols.KeyOf(sym)] = struct{}{} + } + } + return column, nil +} + +// evaluateRelatedCell traverses from the row's declaration to the column's +// aggregate: the ordered list, its count, or whether any exists (stopping at the first). +func (e *executor) evaluateRelatedCell(column computedColumn, row Value) ([]Value, error) { + related := column.related + root := row.Declaration() + if root == nil { + return nil, &Error{ + Kind: ErrorUndeclaredRow, + Query: e.definition.Name(), + Operation: related.plan.Operation(), + Property: column.name, + Target: rowTarget(row), + Origin: related.plan.Origin(), + } + } + if related.aggregate == queryplan.RelatedAggregateAny { + found := false + err := e.traverseRelated(related.plan, related.walk, []*symbols.Symbol{root}, func(neighbor *symbols.Symbol) bool { + found = related.keeps(neighbor) + return !found + }) + if err != nil { + return nil, columnScoped(err, column.name) + } + return []Value{BooleanValue(found)}, nil + } + var values []Value + err := e.traverseRelated(related.plan, related.walk, []*symbols.Symbol{root}, func(neighbor *symbols.Symbol) bool { + if related.keeps(neighbor) { + values = append(values, ElementValue(neighbor)) + } + return true + }) + if err != nil { + return nil, columnScoped(err, column.name) + } + if related.aggregate == queryplan.RelatedAggregateCount { + return []Value{IntegerValue(int64(len(values)))}, nil + } + return values, nil +} + +// columnScoped names the projected column an execution error arose in. +func columnScoped(err error, column string) error { + var execution *Error + if errors.As(err, &execution) && execution.Property == "" { + execution.Property = column + } + return err +} diff --git a/internal/doc/queryexec/related_column_test.go b/internal/doc/queryexec/related_column_test.go new file mode 100644 index 0000000000..e74f5521ac --- /dev/null +++ b/internal/doc/queryexec/related_column_test.go @@ -0,0 +1,494 @@ +package queryexec + +import ( + "strconv" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +const traceMatrixFixture = "testdata/trace_matrix.sysml" + +// observatory is the fixture's root package, the source every requirement descends from. +func (f executionFixture) observatory(t *testing.T) Value { + t.Helper() + matches := symbols.PreferDeclared(f.index.LookupQualified("Observatory")) + if len(matches) != 1 { + t.Fatalf("lookup Observatory: got %d symbols", len(matches)) + } + return ElementValue(matches[0]) +} + +// cellNames renders a cell's element values by qualified name, its scalars by text. +func cellNames(t *testing.T, cell Cell) []string { + t.Helper() + var out []string + for _, value := range cell.Values() { + if sym, ok := value.Element(); ok { + out = append(out, strings.TrimPrefix(symbols.FQNOf(sym), "Observatory::")) + continue + } + switch value.Kind() { + case ValueInteger: + n, _ := value.Integer() + out = append(out, strconv.FormatInt(n, 10)) + case ValueBoolean: + b, _ := value.Boolean() + out = append(out, strconv.FormatBool(b)) + default: + text, _ := value.String() + out = append(out, text) + } + } + return out +} + +func cellsByColumn(t *testing.T, result *RowSet) map[string][][]string { + t.Helper() + out := make(map[string][][]string) + for _, column := range result.Columns() { + out[column.Name()] = nil + } + for _, row := range result.Rows() { + cells := row.Cells() + if len(cells) != len(result.Columns()) { + t.Fatalf("row has %d cells for %d columns", len(cells), len(result.Columns())) + } + for i, column := range result.Columns() { + out[column.Name()] = append(out[column.Name()], cellNames(t, cells[i])) + } + } + return out +} + +func equalStrings(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range want { + if got[i] != want[i] { + return false + } + } + return true +} + +func assertColumn(t *testing.T, cells map[string][][]string, column string, want [][]string) { + t.Helper() + got := cells[column] + if len(got) != len(want) { + t.Fatalf("column %s = %v, want %v", column, got, want) + } + for i := range want { + if !equalStrings(got[i], want[i]) { + t.Fatalf("column %s row %d = %v, want %v", column, i, got[i], want[i]) + } + } +} + +func TestExecuteRelatedColumnsBuildATraceabilityMatrix(t *testing.T) { + fixture := loadExecutionFixtureFile(t, traceMatrixFixture) + result, err := fixture.execute(t, "Matrix", Bindings{ + "root": {fixture.observatory(t)}, + }, Options{}) + if err != nil { + t.Fatalf("execute: %v", err) + } + names := rowNames(result) + if !equalStrings(names, []string{ + "Observatory::massRequirement", + "Observatory::pointingRequirement", + "Observatory::dataRequirement", + }) { + t.Fatalf("rows = %v", names) + } + var columns []string + for _, column := range result.Columns() { + columns = append(columns, column.Name()) + if !column.Origin().Located() { + t.Fatalf("column %s must carry the RelatedColumn's provenance", column.Name()) + } + } + if !equalStrings(columns, []string{"name", "satisfiedBy", "verifiedBy", "verifications", "verified"}) { + t.Fatalf("columns = %v", columns) + } + cells := cellsByColumn(t, result) + // Lists follow declaration order; a requirement nothing reaches is an empty cell. + assertColumn(t, cells, "satisfiedBy", [][]string{ + {"telescope", "groundStation"}, + {"mount"}, + nil, + }) + assertColumn(t, cells, "verifiedBy", [][]string{ + {"massVerification", "pointingVerification"}, + {"pointingVerification"}, + nil, + }) + assertColumn(t, cells, "verifications", [][]string{{"2"}, {"1"}, {"0"}}) + assertColumn(t, cells, "verified", [][]string{{"true"}, {"true"}, {"false"}}) + for _, row := range result.Rows() { + for _, value := range row.Cells()[1].Values() { + if !value.Origin().Located() { + t.Fatal("related elements must carry declaration provenance") + } + } + if _, ok := row.Cells()[3].Values()[0].Integer(); !ok { + t.Fatalf("count cell = %v, want an integer", row.Cells()[3].Values()[0].Kind()) + } + if _, ok := row.Cells()[4].Values()[0].Boolean(); !ok { + t.Fatalf("any cell = %v, want a Boolean", row.Cells()[4].Values()[0].Kind()) + } + } +} + +func (f executionFixture) traced( + t *testing.T, + root Value, + kind, direction string, + maxDepth int64, + aggregate string, + options Options, +) (*RowSet, error) { + t.Helper() + return f.execute(t, "Traced", Bindings{ + "root": {root}, + "kind": {StringValue(kind)}, + "direction": {StringValue(direction)}, + "maxDepth": {IntegerValue(maxDepth)}, + "aggregate": {StringValue(aggregate)}, + }, options) +} + +func TestExecuteRelatedColumnFollowsDepthAndDirection(t *testing.T) { + fixture := loadExecutionFixtureFile(t, traceMatrixFixture) + mirror := ElementValue(fixture.symbol(t, "MirrorAssembly")) + + shallow, err := fixture.traced(t, mirror, "specialization", "outgoing", 1, "list", Options{}) + if err != nil { + t.Fatalf("depth 1: %v", err) + } + assertColumn(t, cellsByColumn(t, shallow), "related", [][]string{{"OpticalSubsystem"}}) + + deep, err := fixture.traced(t, mirror, "specialization", "outgoing", 3, "list", Options{}) + if err != nil { + t.Fatalf("depth 3: %v", err) + } + // Breadth-first: the direct general first, then what it generalizes to. + assertColumn(t, cellsByColumn(t, deep), "related", [][]string{{"OpticalSubsystem", "Subsystem"}}) + + count, err := fixture.traced(t, mirror, "specialization", "outgoing", 3, "count", Options{}) + if err != nil { + t.Fatalf("count: %v", err) + } + assertColumn(t, cellsByColumn(t, count), "related", [][]string{{"2"}}) + + incoming, err := fixture.traced(t, ElementValue(fixture.symbol(t, "Subsystem")), "specialization", "incoming", 2, "list", Options{}) + if err != nil { + t.Fatalf("incoming: %v", err) + } + assertColumn(t, cellsByColumn(t, incoming), "related", [][]string{{"OpticalSubsystem", "MirrorAssembly"}}) + + // A zero depth reaches nothing: an empty list, a zero count, a false any. + none, err := fixture.traced(t, mirror, "specialization", "outgoing", 0, "list", Options{}) + if err != nil { + t.Fatalf("depth 0: %v", err) + } + assertColumn(t, cellsByColumn(t, none), "related", [][]string{nil}) + reach, err := fixture.traced(t, mirror, "specialization", "outgoing", 0, "any", Options{}) + if err != nil { + t.Fatalf("depth 0 any: %v", err) + } + assertColumn(t, cellsByColumn(t, reach), "related", [][]string{{"false"}}) +} + +func TestExecuteRelatedColumnKeepsOnlyTargets(t *testing.T) { + fixture := loadExecutionFixtureFile(t, traceMatrixFixture) + result, err := fixture.execute(t, "Targeted", Bindings{ + "root": {fixture.observatory(t)}, + "targets": { + ElementValue(fixture.symbol(t, "mount")), + ElementValue(fixture.symbol(t, "groundStation")), + }, + }, Options{}) + if err != nil { + t.Fatalf("execute: %v", err) + } + // telescope satisfies massRequirement but is not a target, so it is dropped. + assertColumn(t, cellsByColumn(t, result), "satisfiedBy", [][]string{ + {"groundStation"}, + {"mount"}, + nil, + }) +} + +func TestExecuteRelatedColumnNullDepthIsUnbounded(t *testing.T) { + fixture := loadExecutionFixtureFile(t, traceMatrixFixture) + result, err := fixture.execute(t, "Unbounded", Bindings{ + "root": {ElementValue(fixture.symbol(t, "MirrorAssembly"))}, + }, Options{}) + if err != nil { + t.Fatalf("execute: %v", err) + } + assertColumn(t, cellsByColumn(t, result), "generals", [][]string{{"OpticalSubsystem", "Subsystem"}}) +} + +func TestExecuteRelatedColumnReportsItsColumnInErrors(t *testing.T) { + fixture := loadExecutionFixtureFile(t, traceMatrixFixture) + mirror := ElementValue(fixture.symbol(t, "MirrorAssembly")) + cases := []struct { + name string + kind string + direction string + aggregate string + options Options + want ErrorKind + message string + }{ + { + name: "unknown relationship kind", kind: "ownership", direction: "outgoing", aggregate: "list", + want: ErrorUnknownRelationship, + message: `query Observatory::Traced column related does not support relationship kind "ownership"`, + }, + { + name: "invalid direction", kind: "specialization", direction: "sideways", aggregate: "list", + want: ErrorInvalidOperator, + message: `query Observatory::Traced operation related-column column related does not support "sideways"`, + }, + { + name: "unsupported aggregate", kind: "specialization", direction: "outgoing", aggregate: "sum", + want: ErrorInvalidArgument, + message: `query Observatory::Traced operation related-column column related has invalid argument aggregate`, + }, + { + name: "exhausted visit budget", kind: "specialization", direction: "outgoing", aggregate: "list", + options: Options{VisitBudget: 1}, + want: ErrorVisitBudget, + message: `query Observatory::Traced exceeded its visit budget in column related`, + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + _, err := fixture.traced(t, mirror, test.kind, test.direction, 3, test.aggregate, test.options) + execution := executionError(t, err, test.want) + if execution.Property != "related" { + t.Fatalf("error column = %q, want related", execution.Property) + } + if execution.Operation != "related-column" { + t.Fatalf("error operation = %s, want related-column", execution.Operation) + } + if !execution.Origin.Located() { + t.Fatal("error must carry the RelatedColumn's provenance") + } + if execution.Error() != test.message { + t.Fatalf("message = %q, want %q", execution.Error(), test.message) + } + }) + } +} + +func TestExecuteRelatedColumnLeavesTableConstructionUncharged(t *testing.T) { + fixture := loadExecutionFixtureFile(t, traceMatrixFixture) + // Building the satisfaction table scans the workspace uncharged, so a + // requirement nothing satisfies is counted within a budget of one. + none, err := fixture.traced(t, ElementValue(fixture.symbol(t, "dataRequirement")), + "satisfaction", "incoming", 1, "count", Options{VisitBudget: 1}) + if err != nil { + t.Fatalf("unsatisfied: %v", err) + } + assertColumn(t, cellsByColumn(t, none), "related", [][]string{{"0"}}) + // The two satisfiers reached are what the budget pays for. + mass := ElementValue(fixture.symbol(t, "massRequirement")) + both, err := fixture.traced(t, mass, "satisfaction", "incoming", 1, "count", Options{VisitBudget: 2}) + if err != nil { + t.Fatalf("exact budget: %v", err) + } + assertColumn(t, cellsByColumn(t, both), "related", [][]string{{"2"}}) + _, err = fixture.traced(t, mass, "satisfaction", "incoming", 1, "count", Options{VisitBudget: 1}) + execution := executionError(t, err, ErrorVisitBudget) + if execution.Property != "related" { + t.Fatalf("error column = %q, want related", execution.Property) + } +} + +// "any" is an existence test: it stops at the first element reached, so a +// budget that cannot afford the whole list still answers it. +func TestExecuteRelatedColumnAnyStopsAtTheFirstElement(t *testing.T) { + fixture := loadExecutionFixtureFile(t, traceMatrixFixture) + mirror := ElementValue(fixture.symbol(t, "MirrorAssembly")) + // Outgoing lineage reads the declaration itself, so the budget pays only + // for the elements reached: two for the list, one for any. + _, err := fixture.traced(t, mirror, "specialization", "outgoing", 3, "list", Options{VisitBudget: 1}) + executionError(t, err, ErrorVisitBudget) + + reach, err := fixture.traced(t, mirror, "specialization", "outgoing", 3, "any", Options{VisitBudget: 1}) + if err != nil { + t.Fatalf("any: %v", err) + } + assertColumn(t, cellsByColumn(t, reach), "related", [][]string{{"true"}}) + + // A row that reaches nothing still walks its whole (empty) frontier. + none, err := fixture.traced(t, ElementValue(fixture.symbol(t, "Subsystem")), "specialization", "outgoing", 3, "any", Options{VisitBudget: 1}) + if err != nil { + t.Fatalf("none: %v", err) + } + assertColumn(t, cellsByColumn(t, none), "related", [][]string{{"false"}}) +} + +func TestExecuteRelatedColumnsFilterAndOrderDownstream(t *testing.T) { + fixture := loadExecutionFixtureFile(t, traceMatrixFixture) + root := fixture.observatory(t) + + uncovered, err := fixture.execute(t, "Uncovered", Bindings{"root": {root}}, Options{}) + if err != nil { + t.Fatalf("uncovered: %v", err) + } + if names := rowNames(uncovered); !equalStrings(names, []string{"Observatory::dataRequirement"}) { + t.Fatalf("uncovered rows = %v", names) + } + // The filter keeps the projected schema and cells of the rows it selects. + assertColumn(t, cellsByColumn(t, uncovered), "verifications", [][]string{{"0"}}) + + unverified, err := fixture.execute(t, "Unverified", Bindings{"root": {root}}, Options{}) + if err != nil { + t.Fatalf("unverified: %v", err) + } + if names := rowNames(unverified); !equalStrings(names, []string{"Observatory::dataRequirement"}) { + t.Fatalf("unverified rows = %v", names) + } + + ordered, err := fixture.execute(t, "MostVerified", Bindings{"root": {root}}, Options{}) + if err != nil { + t.Fatalf("ordered: %v", err) + } + if names := rowNames(ordered); !equalStrings(names, []string{ + "Observatory::massRequirement", + "Observatory::pointingRequirement", + "Observatory::dataRequirement", + }) { + t.Fatalf("ordered rows = %v", names) + } + assertColumn(t, cellsByColumn(t, ordered), "satisfiedBy", [][]string{ + {"telescope", "groundStation"}, + {"mount"}, + nil, + }) +} + +// A list cell's elements compare and order as the qualified names the cell +// prints them by, so a matrix filters and sorts on who satisfies what. +func TestExecuteRelatedColumnElementsCompareByQualifiedName(t *testing.T) { + fixture := loadExecutionFixtureFile(t, traceMatrixFixture) + root := fixture.observatory(t) + filters := []struct { + operator, satisfier string + want []string + }{ + {"=", "Observatory::mount", []string{"Observatory::pointingRequirement"}}, + {"endsWith", "::groundStation", []string{"Observatory::massRequirement"}}, + {"matches", "^Observatory::(telescope|mount)$", []string{"Observatory::massRequirement", "Observatory::pointingRequirement"}}, + {"!=", "Observatory::mount", []string{"Observatory::massRequirement"}}, + } + for _, filter := range filters { + bindings := Bindings{"root": {root}, "operator": {StringValue(filter.operator)}, "satisfier": {StringValue(filter.satisfier)}} + result, err := fixture.execute(t, "SatisfiedBy", bindings, Options{}) + if err != nil { + t.Fatalf("satisfiedBy %s %q: %v", filter.operator, filter.satisfier, err) + } + if names := rowNames(result); !equalStrings(names, filter.want) { + t.Fatalf("satisfiedBy %s %q rows = %v, want %v", filter.operator, filter.satisfier, names, filter.want) + } + } + bindings := Bindings{"root": {root}, "operator": {StringValue("<")}, "satisfier": {StringValue("1")}} + _, err := fixture.execute(t, "SatisfiedBy", bindings, Options{}) + executionError(t, err, ErrorInvalidOperator) + + // The mass requirement's first satisfier is the telescope, its last the ground station. + orders := map[string][]string{ + "first": {"Observatory::pointingRequirement", "Observatory::massRequirement", "Observatory::dataRequirement"}, + "last": {"Observatory::massRequirement", "Observatory::pointingRequirement", "Observatory::dataRequirement"}, + } + for multiple, want := range orders { + result, err := fixture.execute(t, "BySatisfier", Bindings{"root": {root}, "multiple": {StringValue(multiple)}}, Options{}) + if err != nil { + t.Fatalf("bySatisfier %s: %v", multiple, err) + } + if names := rowNames(result); !equalStrings(names, want) { + t.Fatalf("bySatisfier %s rows = %v, want %v", multiple, names, want) + } + } +} + +func TestExecuteRelatedColumnTraversesFromAnObjectsDeclaration(t *testing.T) { + fixture := loadExecutionFixtureFile(t, traceMatrixFixture) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + telescope, err := ctx.Instantiate(fixture.symbol(t, "telescope")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + context := Context{ + Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Runtime: ctx, + Roots: []Root{{Label: "telescope", Object: telescope}}, + } + result, err := Execute(fixture.program(t, "Traced"), context, Bindings{ + "root": {ObjectValue(telescope, "telescope")}, + "kind": {StringValue("satisfaction")}, + "direction": {StringValue("outgoing")}, + "maxDepth": {IntegerValue(1)}, + "aggregate": {StringValue("list")}, + }, Options{}) + if err != nil { + t.Fatalf("execute: %v", err) + } + // The object stands for the telescope usage, which satisfies the mass requirement. + assertColumn(t, cellsByColumn(t, result), "related", [][]string{{"massRequirement"}}) +} + +const eventRelatedQueries = ` +calc def SenderTypes :> Query { + Project( + source = Events(kind = "send"), + properties = ("path", "event"), + columns = (RelatedColumn(name = "types", relationshipKind = "typing", direction = "outgoing", maxDepth = 1)) + ) +} +calc def MoverTypes :> Query { + Project( + source = Events(kind = "transition"), + properties = ("path"), + columns = (RelatedColumn(name = "usages", relationshipKind = "typing", direction = "incoming", maxDepth = 1)) + ) +} +` + +// An event row traverses from the behavior it came from; a message posted from +// outside the run came from none, and the column refuses it with a typed error. +func TestExecuteRelatedColumnRefusesARowNoElementDeclares(t *testing.T) { + fixture := loadLampFixture(t, eventRelatedQueries) + + _, err := fixture.run(t, fixture.session(), "SenderTypes", nil) + execution := executionError(t, err, ErrorUndeclaredRow) + if execution.Property != "types" || execution.Operation != "related-column" || !execution.Origin.Located() { + t.Fatalf("error = %#v, want column types of related-column with provenance", execution) + } + want := `query Observatory::SenderTypes operation related-column column types traverses from send Toggle, which no element declares` + if execution.Error() != want { + t.Fatalf("message = %q, want %q", execution.Error(), want) + } + + // A transition row comes from the lamp's machine, whose one usage is `lp`. + result, err := fixture.run(t, fixture.session(), "MoverTypes", nil) + if err != nil { + t.Fatalf("execute MoverTypes: %v", err) + } + rows := result.Rows() + if len(rows) == 0 { + t.Fatal("transition rows: got none") + } + for i, row := range rows { + if got := cellNames(t, row.Cells()[1]); len(got) != 1 || got[0] != "Lamp::lp" { + t.Fatalf("row %d usages = %v, want [Lamp::lp]", i, got) + } + } +} diff --git a/internal/doc/queryexec/related_test.go b/internal/doc/queryexec/related_test.go new file mode 100644 index 0000000000..ae7f3e6c2b --- /dev/null +++ b/internal/doc/queryexec/related_test.go @@ -0,0 +1,357 @@ +package queryexec + +import ( + "errors" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +func (f executionFixture) related( + t *testing.T, + source *symbols.Symbol, + kind, direction string, + maxDepth int64, + options Options, +) (*RowSet, error) { + t.Helper() + return f.execute(t, "Related", Bindings{ + "source": {ElementValue(source)}, + "kind": {StringValue(kind)}, + "direction": {StringValue(direction)}, + "maxDepth": {IntegerValue(maxDepth)}, + }, options) +} + +func rowNames(result *RowSet) []string { + var names []string + for _, row := range result.Rows() { + sym, _ := row.Element().Element() + names = append(names, symbols.FQNOf(sym)) + } + return names +} + +func assertRelated( + t *testing.T, + fixture executionFixture, + source, kind, direction string, + maxDepth int64, + want []string, +) { + t.Helper() + result, err := fixture.related(t, fixture.symbol(t, source), kind, direction, maxDepth, Options{}) + if err != nil { + t.Fatalf("%s %s from %s: %v", kind, direction, source, err) + } + names := rowNames(result) + if len(names) != len(want) { + t.Fatalf("%s %s from %s = %v, want %v", kind, direction, source, names, want) + } + for i, name := range want { + if names[i] != "Observatory::"+name { + t.Fatalf("%s %s from %s = %v, want %v", kind, direction, source, names, want) + } + } + for _, row := range result.Rows() { + if !row.Origin().Located() { + t.Fatalf("%s %s from %s: row without provenance", kind, direction, source) + } + } +} + +func TestExecuteRelatedLineageBothDirectionsAndDepth(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_relationships.sysml") + + assertRelated(t, fixture, "MirrorAssembly", "specialization", "outgoing", 1, + []string{"OpticalSubsystem"}) + assertRelated(t, fixture, "MirrorAssembly", "specialization", "outgoing", 3, + []string{"OpticalSubsystem", "Subsystem"}) + assertRelated(t, fixture, "Subsystem", "specialization", "incoming", 1, + []string{"OpticalSubsystem"}) + assertRelated(t, fixture, "Subsystem", "specialization", "incoming", 2, + []string{"OpticalSubsystem", "MirrorAssembly"}) + // A definition with no generalizations traverses to a valid empty result. + assertRelated(t, fixture, "Subsystem", "specialization", "outgoing", 3, nil) + + assertRelated(t, fixture, "iris", "subsetting", "outgoing", 1, []string{"instruments"}) + assertRelated(t, fixture, "instruments", "subsetting", "incoming", 1, + []string{"iris", "modhis"}) + + assertRelated(t, fixture, "MirrorAssembly::mass", "redefinition", "outgoing", 1, + []string{"Subsystem::mass"}) + assertRelated(t, fixture, "Subsystem::mass", "redefinition", "incoming", 1, + []string{"MirrorAssembly::mass"}) + + assertRelated(t, fixture, "telescope::primaryMirror", "typing", "outgoing", 1, + []string{"MirrorAssembly"}) + assertRelated(t, fixture, "Subsystem", "typing", "incoming", 1, + []string{"telescope::instrumentCluster", "telescope::mountControl"}) +} + +func TestExecuteRelatedSeedsAreDeduplicatedBySemanticIdentity(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_relationships.sysml") + result, err := fixture.execute(t, "Related", Bindings{ + "source": { + ElementValue(fixture.symbol(t, "MirrorAssembly")), + ElementValue(fixture.symbol(t, "OpticalSubsystem")), + }, + "kind": {StringValue("specialization")}, + "direction": {StringValue("outgoing")}, + "maxDepth": {IntegerValue(3)}, + }, Options{}) + if err != nil { + t.Fatalf("execute: %v", err) + } + // OpticalSubsystem is a seed, so only Subsystem remains once. + names := rowNames(result) + if len(names) != 1 || names[0] != "Observatory::Subsystem" { + t.Fatalf("rows = %v", names) + } +} + +func TestExecuteRelatedConnectionsAllocationsAndAssertions(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_relationships.sysml") + + assertRelated(t, fixture, "telescope::primaryMirror::opticalOut", "connection", "outgoing", 1, + []string{"telescope::instrumentCluster::opticalIn"}) + assertRelated(t, fixture, "telescope::instrumentCluster::opticalIn", "connection", "incoming", 1, + []string{"telescope::primaryMirror::opticalOut"}) + // An untyped `connect` clause carries connection edges too. + assertRelated(t, fixture, "telescope::instrumentCluster::dataOut", "connection", "outgoing", 1, + []string{"telescope::mountControl::dataIn"}) + // Connection traversal never follows allocation edges. + assertRelated(t, fixture, "telescope::instrumentCluster", "connection", "outgoing", 1, nil) + + assertRelated(t, fixture, "telescope::instrumentCluster", "allocation", "outgoing", 1, + []string{"scienceComputer"}) + assertRelated(t, fixture, "scienceComputer", "allocation", "incoming", 1, + []string{"telescope::instrumentCluster"}) + + assertRelated(t, fixture, "telescope", "satisfaction", "outgoing", 1, + []string{"massRequirement"}) + assertRelated(t, fixture, "massRequirement", "satisfaction", "incoming", 1, + []string{"telescope"}) + // A satisfy without `by` relates the element stating it. + assertRelated(t, fixture, "groundStation", "satisfaction", "outgoing", 1, + []string{"pointingRequirement"}) + + assertRelated(t, fixture, "massVerification", "verification", "outgoing", 1, + []string{"massRequirement"}) + assertRelated(t, fixture, "massRequirement", "verification", "incoming", 1, + []string{"massVerification"}) + // Verification traversal never follows satisfaction edges. + assertRelated(t, fixture, "massRequirement", "verification", "incoming", 1, + []string{"massVerification"}) +} + +func TestExecuteRelatedDeclaredRequirements(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_relationships.sysml") + + // A satisfy or verify assertion that declares its requirement relates the + // subject to the declared requirement usage itself and, when the + // declaration is typed, to its requirement definition. + assertRelated(t, fixture, "scienceComputer", "satisfaction", "outgoing", 1, + []string{"dataArchive::archiveRequirement", "DataRequirement"}) + assertRelated(t, fixture, "dataArchive::archiveRequirement", "satisfaction", "incoming", 1, + []string{"scienceComputer"}) + assertRelated(t, fixture, "DataRequirement", "satisfaction", "incoming", 1, + []string{"scienceComputer"}) + assertRelated(t, fixture, "scienceComputer", "verification", "outgoing", 1, + []string{"archiveVerification::archiveObjective::archiveCheck"}) + assertRelated(t, fixture, "archiveVerification::archiveObjective::archiveCheck", "verification", "incoming", 1, + []string{"scienceComputer"}) + + // A declared requirement that subsets another is still the edge target. + assertRelated(t, fixture, "relayHub", "satisfaction", "outgoing", 1, + []string{"relayControl::relayRequirement"}) + assertRelated(t, fixture, "relayControl::relayRequirement", "satisfaction", "incoming", 1, + []string{"relayHub"}) + + // An anonymous declaration form traverses in both directions too. + for _, kind := range []string{"satisfaction", "verification"} { + subject := fixture.symbol(t, "mountControlStation") + outgoing, err := fixture.related(t, subject, kind, "outgoing", 1, Options{}) + if err != nil { + t.Fatalf("%s outgoing: %v", kind, err) + } + if len(outgoing.Rows()) != 1 { + t.Fatalf("%s outgoing rows = %v", kind, rowNames(outgoing)) + } + anonymous, _ := outgoing.Rows()[0].Element().Element() + incoming, err := fixture.related(t, anonymous, kind, "incoming", 1, Options{}) + if err != nil { + t.Fatalf("%s incoming: %v", kind, err) + } + names := rowNames(incoming) + if len(names) != 1 || names[0] != "Observatory::mountControlStation" { + t.Fatalf("%s incoming rows = %v", kind, names) + } + } +} + +func TestExecuteRelatedConsumesTheVisitBudget(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_relationships.sysml") + _, err := fixture.related(t, fixture.symbol(t, "instruments"), "subsetting", "incoming", 1, + Options{VisitBudget: 1}) + var executionError *Error + if !errors.As(err, &executionError) || executionError.Kind != ErrorVisitBudget { + t.Fatalf("visit budget error = %v", err) + } +} + +func TestExecuteRelatedLeavesTableConstructionUncharged(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_relationships.sysml") + // Building the edge table scans every declaration in the workspace, yet a + // source with no matching edges reaches nothing and so pays nothing. + none, err := fixture.related(t, fixture.symbol(t, "Subsystem"), "connection", "incoming", 1, + Options{VisitBudget: 1}) + if err != nil { + t.Fatalf("no edges: %v", err) + } + if len(none.Rows()) != 0 { + t.Fatalf("rows = %v, want none", rowNames(none)) + } + // Only the elements reached are charged: the two subsetters fit a budget of + // exactly two, however many declarations the scan examined. + both, err := fixture.related(t, fixture.symbol(t, "instruments"), "subsetting", "incoming", 1, + Options{VisitBudget: 2}) + if err != nil { + t.Fatalf("exact budget: %v", err) + } + if names := rowNames(both); len(names) != 2 || + names[0] != "Observatory::iris" || names[1] != "Observatory::modhis" { + t.Fatalf("rows = %v", names) + } +} + +func TestExecuteSharesRelationshipTablesThroughTheContext(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_relationships.sysml") + tables := NewRelationshipTables() + context := Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Related: tables} + bindings := func(source string) Bindings { + return Bindings{ + "source": {ElementValue(fixture.symbol(t, source))}, + "kind": {StringValue("subsetting")}, + "direction": {StringValue("incoming")}, + "maxDepth": {IntegerValue(1)}, + } + } + program := fixture.program(t, "Related") + if _, err := Execute(program, context, bindings("instruments"), Options{}); err != nil { + t.Fatalf("first execution: %v", err) + } + built, ok := tables.entries["subsetting"] + if !ok { + t.Fatal("the first execution must leave its subsetting table in the context") + } + if _, err := Execute(program, context, bindings("Subsystem"), Options{}); err != nil { + t.Fatalf("second execution: %v", err) + } + if tables.entries["subsetting"] != built { + t.Fatal("a second execution under the same context must reuse the built table") + } + // Tables built against another model are discarded rather than trusted. + other := loadExecutionFixtureFile(t, "testdata/tmt_relationships.sysml") + _, err := Execute(other.program(t, "Related"), + Context{Index: other.index, Resolver: other.resolver, Model: other.model, Related: tables}, + Bindings{ + "source": {ElementValue(other.symbol(t, "instruments"))}, + "kind": {StringValue("subsetting")}, + "direction": {StringValue("incoming")}, + "maxDepth": {IntegerValue(1)}, + }, Options{}) + if err != nil { + t.Fatalf("other model: %v", err) + } + if tables.entries["subsetting"] == built || tables.index != other.index { + t.Fatal("tables built against another index must be rebuilt") + } +} + +func TestExecuteRebuildsRelationshipTablesAfterAnIndexEdit(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_relationships.sysml") + context := Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Related: NewRelationshipTables()} + subsetters := func(step string) []string { + t.Helper() + rows, err := Execute(fixture.program(t, "Related"), context, Bindings{ + "source": {ElementValue(fixture.symbol(t, "instruments"))}, + "kind": {StringValue("subsetting")}, + "direction": {StringValue("incoming")}, + "maxDepth": {IntegerValue(1)}, + }, Options{}) + if err != nil { + t.Fatalf("%s: %v", step, err) + } + return rowNames(rows) + } + if names := subsetters("before the edit"); len(names) != 2 { + t.Fatalf("rows before the edit = %v", names) + } + + // The same index, edited in place: a document declaring one more subsetter + // is added, then removed again. The tables follow both edits. + edit := "edit.sysml" + p := parser.New(source.New(edit, []byte("package Edit { part nfiraos :> Observatory::instruments; }"))) + root := p.ParseFile() + if len(p.Diagnostics) > 0 { + t.Fatalf("parse edit: %v", p.Diagnostics) + } + fixture.index.AddDocument(edit, root) + fixture.index.ExpandWildcardImports() + if names := subsetters("after adding a subsetter"); len(names) != 3 || names[0] != "Edit::nfiraos" { + t.Fatalf("rows after adding a subsetter = %v", names) + } + fixture.index.RemoveDocument(edit) + if names := subsetters("after removing it again"); len(names) != 2 { + t.Fatalf("rows after removing the subsetter = %v", names) + } +} + +func TestExecuteRelatedComposesWithFiltersProjectionAndInvocation(t *testing.T) { + fixture := loadExecutionFixtureFile(t, "testdata/tmt_relationships.sysml") + + report, err := fixture.execute(t, "SpecializerReport", Bindings{ + "root": {ElementValue(fixture.symbol(t, "Subsystem"))}, + }, Options{}) + if err != nil { + t.Fatalf("execute report: %v", err) + } + var columns []string + for _, column := range report.Columns() { + columns = append(columns, column.Name()) + } + if len(columns) != 2 || columns[0] != "name" || columns[1] != "qualifiedName" { + t.Fatalf("columns = %v", columns) + } + names := rowNames(report) + if len(names) != 2 || + names[0] != "Observatory::MirrorAssembly" || + names[1] != "Observatory::OpticalSubsystem" { + t.Fatalf("rows = %v", names) + } + + invoked, err := fixture.execute(t, "InvokedSatisfiers", Bindings{ + "req": {ElementValue(fixture.symbol(t, "massRequirement"))}, + }, Options{}) + if err != nil { + t.Fatalf("execute invoked: %v", err) + } + names = rowNames(invoked) + if len(names) != 1 || names[0] != "Observatory::telescope" { + t.Fatalf("rows = %v", names) + } + + // An empty traversal keeps the projected schema of a downstream Project. + empty, err := fixture.execute(t, "SpecializerReport", Bindings{ + "root": {ElementValue(fixture.symbol(t, "MirrorAssembly"))}, + }, Options{}) + if err != nil { + t.Fatalf("execute empty report: %v", err) + } + if len(empty.Rows()) != 0 || len(empty.Columns()) != 2 { + t.Fatalf("empty rows = %d columns = %d", len(empty.Rows()), len(empty.Columns())) + } +} diff --git a/internal/doc/queryexec/robustness_state_event_queries_test.go b/internal/doc/queryexec/robustness_state_event_queries_test.go new file mode 100644 index 0000000000..2142fa748d --- /dev/null +++ b/internal/doc/queryexec/robustness_state_event_queries_test.go @@ -0,0 +1,322 @@ +package queryexec + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// endedBody gives the queries one machine that runs to `done`, one that a +// `terminate` ends, and a calc destroying the terminator's object. +const endedBody = ` +private import OccurrenceFunctions::*; +attribute def Abort; +state def FinishingMachine { + entry; then working; + state working; + transition first working accept Abort then done; +} +state def TerminatingMachine { + entry; then busy; + state busy; + transition first busy accept Abort then stop; + action stop terminate; +} +part def Finisher { exhibit state fm : FinishingMachine; } +part def Terminator { exhibit state tm : TerminatingMachine; } +part f : Finisher; +part t : Terminator; +part t2 : Terminator; +calc def DestroyTerminator { in x : Terminator; return : Terminator = destroy(x); } +` + +const endedQueries = ` +calc def Where :> Query { + in root : Element; + Project(source = States(source = root), properties = ("machine", "statePath")) +} +calc def Busy :> Query { + InState(name = "busy") +} +calc def Happenings :> Query { + in root : Element; + Project(source = Events(source = root), properties = ("kind", "state", "from", "to")) +} +calc def Living :> Query { + Project(source = Objects(type = "Terminator"), properties = ("name")) +} +calc def StatesOfKind :> Query { + Project(source = States(source = Objects(type = "Terminator")), properties = ("machine", "statePath")) +} +` + +// endedFixture holds the two machines' objects over a traced runtime; a +// second terminator joins the roots only once a test instantiates it. +type endedFixture struct { + executionFixture + ctx *runtime.Context + finisher *runtime.Instance + termin *runtime.Instance + termin2 *runtime.Instance +} + +func loadEndedFixture(t *testing.T) endedFixture { + t.Helper() + fixture := loadExecutionFixture(t, endedBody+endedQueries) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + ctx.SetTrace(runtime.NewTraceRecorder()) + f := endedFixture{executionFixture: fixture, ctx: ctx} + f.finisher = f.instantiate(t, "f") + f.termin = f.instantiate(t, "t") + return f +} + +func (f endedFixture) instantiate(t *testing.T, name string) *runtime.Instance { + t.Helper() + inst, err := f.ctx.Instantiate(f.symbol(t, name)) + if err != nil { + t.Fatalf("Instantiate %s: %v", name, err) + } + return inst +} + +func (f endedFixture) send(t *testing.T, inst *runtime.Instance, signal string) { + t.Helper() + msg, err := f.ctx.SignalMessage(f.symbol(t, signal), nil, inst) + if err != nil { + t.Fatalf("send %s: %v", signal, err) + } + f.ctx.PostMessage(msg) +} + +func (f endedFixture) advance(t *testing.T, seconds float64) { + t.Helper() + if _, err := f.ctx.Advance(seconds); err != nil { + t.Fatalf("advance %v: %v", seconds, err) + } +} + +// destroy runs `destroy(x)` on inst through the calc the fixture declares. +func (f endedFixture) destroy(t *testing.T, inst *runtime.Instance) { + t.Helper() + sym := f.symbol(t, "DestroyTerminator") + arg := runtime.Value{Kind: runtime.ValInstance, Instance: inst.ID} + if _, err := f.ctx.InvokeCalc(sym, []runtime.Value{arg}, sym.Scope); err != nil { + t.Fatalf("destroy: %v", err) + } +} + +// withSecondTerminator returns the fixture with the t2 object instantiated. +func (f endedFixture) withSecondTerminator(t *testing.T) endedFixture { + f.termin2 = f.instantiate(t, "t2") + return f +} + +func (f endedFixture) context() Context { + roots := []Root{{Label: "f", Object: f.finisher}, {Label: "t", Object: f.termin}} + if f.termin2 != nil { + roots = append(roots, Root{Label: "t2", Object: f.termin2}) + } + return Context{ + Index: f.index, Resolver: f.resolver, Model: f.model, Runtime: f.ctx, + Roots: roots, + } +} + +func (f endedFixture) run(t *testing.T, name string, bindings Bindings) (*RowSet, error) { + t.Helper() + return Execute(f.program(t, name), f.context(), bindings, Options{}) +} + +func (f endedFixture) on(inst *runtime.Instance, label string) Bindings { + return Bindings{"root": {ObjectValue(inst, label)}} +} + +// TestQueryRobustnessStateEventQueries exercises state and event queries at a +// machine that is over: terminated reads as no active state, completed keeps +// its final state, and a destroyed object is a typed refusal, never a panic. +func TestQueryRobustnessStateEventQueries(t *testing.T) { + t.Run("states_of_a_terminated_machine_are_empty", testStatesOfTerminatedAreEmpty) + t.Run("states_of_a_completed_machine_report_its_final_state", testStatesOfCompletedReportFinal) + t.Run("states_of_a_destroyed_object", testStatesOfDestroyed) + t.Run("events_of_a_destroyed_object", testEventsOfDestroyed) + t.Run("in_state_over_a_terminated_machine", testInStateOverTerminated) + t.Run("events_after_termination_still_read", testEventsAfterTermination) + t.Run("objects_skip_a_destroyed_object", testObjectsSkipDestroyed) + t.Run("states_over_objects_skip_a_destroyed_object", testStatesOverObjectsSkipDestroyed) + t.Run("events_with_a_destroyed_sibling", testEventsWithDestroyedSibling) + t.Run("in_state_with_a_destroyed_root", testInStateWithDestroyedRoot) + t.Run("states_of_an_element_whose_object_is_destroyed", testStatesOfElementAllDestroyed) + t.Run("states_of_an_element_with_a_destroyed_sibling", testStatesOfElementPartlyDestroyed) +} + +// A terminated machine holds no active configuration: States answers no row +// for its object rather than an error or a stale leaf. +func testStatesOfTerminatedAreEmpty(t *testing.T) { + f := loadEndedFixture(t) + f.send(t, f.termin, "Abort") + f.advance(t, 1) + result, err := f.run(t, "Where", f.on(f.termin, "t")) + if err != nil { + t.Fatalf("States over a terminated machine: %v", err) + } + if got := len(result.Rows()); got != 0 { + t.Fatalf("rows = %d, want 0: %s", got, joinLines(rowTexts(t, result))) + } +} + +// A completed machine reports its final state: `done` answers as a leaf row. +func testStatesOfCompletedReportFinal(t *testing.T) { + f := loadEndedFixture(t) + f.send(t, f.finisher, "Abort") + f.advance(t, 1) + result, err := f.run(t, "Where", f.on(f.finisher, "f")) + if err != nil { + t.Fatalf("States over a completed machine: %v", err) + } + got := rowTexts(t, result) + if len(got) != 1 || !strings.Contains(got[0], "statePath=done") { + t.Fatalf("rows = %v, want one row in done", got) + } +} + +// States over an object the run destroyed is a typed refusal naming the object +// and the instant it ended, not a stale row. +func testStatesOfDestroyed(t *testing.T) { + f := loadEndedFixture(t) + f.destroy(t, f.termin) + _, err := f.run(t, "Where", f.on(f.termin, "t")) + got := executionError(t, err, ErrorObjectDestroyed) + if got.Target != "t" || !strings.Contains(got.Error(), "t, an object destroyed at") { + t.Fatalf("object-destroyed error = %v", got) + } +} + +// Events refuses a destroyed source the same way, through the same argument. +func testEventsOfDestroyed(t *testing.T) { + f := loadEndedFixture(t) + f.send(t, f.termin, "Abort") + f.advance(t, 1) + f.destroy(t, f.termin) + _, err := f.run(t, "Happenings", f.on(f.termin, "t")) + if got := executionError(t, err, ErrorObjectDestroyed); got.Target != "t" { + t.Fatalf("object-destroyed error = %v", got) + } +} + +// InState over a terminated machine answers nothing: `busy` is still declared, +// so there is no unknown-state error and no matching row. +func testInStateOverTerminated(t *testing.T) { + f := loadEndedFixture(t) + f.send(t, f.termin, "Abort") + f.advance(t, 1) + result, err := f.run(t, "Busy", nil) + if err != nil { + t.Fatalf("InState over a terminated machine: %v", err) + } + if got := len(result.Rows()); got != 0 { + t.Fatalf("rows = %d, want 0", got) + } +} + +// Termination leaves the recorded run readable: the accept, the exit of its +// source and the transition are still event rows. +func testEventsAfterTermination(t *testing.T) { + f := loadEndedFixture(t) + f.send(t, f.termin, "Abort") + f.advance(t, 1) + result, err := f.run(t, "Happenings", f.on(f.termin, "t")) + if err != nil { + t.Fatalf("Events after termination: %v", err) + } + got := joinLines(rowTexts(t, result)) + for _, want := range []string{"kind=accept", "kind=exit state=busy", "kind=transition state= from=busy to=stop"} { + if !strings.Contains(got, want) { + t.Fatalf("rows after termination =\n%s\nwant a row containing %q", got, want) + } + } +} + +// A destroyed object left the population: Objects answers only the live +// objects of the type rather than failing its traversal. +func testObjectsSkipDestroyed(t *testing.T) { + f := loadEndedFixture(t).withSecondTerminator(t) + f.destroy(t, f.termin) + result, err := f.run(t, "Living", nil) + if err != nil { + t.Fatalf("Objects with a destroyed sibling: %v", err) + } + got := rowTexts(t, result) + if len(got) != 1 || !strings.Contains(got[0], "name=t2") { + t.Fatalf("rows = %v, want the one live object t2", got) + } +} + +// States over an element-derived source answers the live objects; the +// destroyed sibling is out of the population, not a refusal. +func testStatesOverObjectsSkipDestroyed(t *testing.T) { + f := loadEndedFixture(t).withSecondTerminator(t) + f.destroy(t, f.termin) + result, err := f.run(t, "StatesOfKind", nil) + if err != nil { + t.Fatalf("States over Objects with a destroyed sibling: %v", err) + } + if got := len(result.Rows()); got != 1 { + t.Fatalf("rows = %d, want the live object's one leaf: %s", got, joinLines(rowTexts(t, result))) + } +} + +// Events still runs where another root was destroyed: its label resolves and +// the live object's trace reads. +func testEventsWithDestroyedSibling(t *testing.T) { + f := loadEndedFixture(t).withSecondTerminator(t) + f.destroy(t, f.termin) + result, err := f.run(t, "Happenings", f.on(f.termin2, "t2")) + if err != nil { + t.Fatalf("Events with a destroyed sibling: %v", err) + } + if len(result.Rows()) == 0 { + t.Fatalf("rows = 0, want the live object's records") + } +} + +// InState traverses past a destroyed root: the live object in `busy` answers +// and the destroyed one is absent rather than an error. +func testInStateWithDestroyedRoot(t *testing.T) { + f := loadEndedFixture(t).withSecondTerminator(t) + f.destroy(t, f.termin) + result, err := f.run(t, "Busy", nil) + if err != nil { + t.Fatalf("InState with a destroyed root: %v", err) + } + if got := len(result.Rows()); got != 1 { + t.Fatalf("rows = %d, want the one live object in busy", got) + } +} + +// An element whose session objects are all destroyed is the typed refusal the +// object row gives, not an empty answer. +func testStatesOfElementAllDestroyed(t *testing.T) { + f := loadEndedFixture(t) + f.destroy(t, f.termin) + _, err := f.run(t, "Where", Bindings{"root": {ElementValue(f.symbol(t, "t"))}}) + got := executionError(t, err, ErrorObjectDestroyed) + if got.Target != "t" { + t.Fatalf("object-destroyed error = %v", got) + } +} + +// An element declaring a live and a destroyed object answers over the live +// one only. +func testStatesOfElementPartlyDestroyed(t *testing.T) { + f := loadEndedFixture(t).withSecondTerminator(t) + f.destroy(t, f.termin) + result, err := f.run(t, "Where", Bindings{"root": {ElementValue(f.symbol(t, "Terminator"))}}) + if err != nil { + t.Fatalf("States over a partly destroyed element: %v", err) + } + if got := len(result.Rows()); got != 1 { + t.Fatalf("rows = %d, want the live object's one leaf: %s", got, joinLines(rowTexts(t, result))) + } +} diff --git a/internal/doc/queryexec/setops.go b/internal/doc/queryexec/setops.go new file mode 100644 index 0000000000..c8ea8ee349 --- /dev/null +++ b/internal/doc/queryexec/setops.go @@ -0,0 +1,117 @@ +package queryexec + +import ( + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// setKey is a row's identity for set operations: an element or object as rowKey +// has it, a verdict by its assertion and carrier, a state by its object, machine +// and path, an event by its place in the session's trace. +type setKey struct { + row rowKey + kind ValueKind + path string + index int +} + +func setKeyOf(row Value) setKey { + key := setKey{kind: row.Kind()} + switch { + case row.kind == ValueVerdict: + verdict, _ := row.Verdict() + key.row.element = symbols.KeyOf(verdict.Assertion()) + if carrier, held := verdict.Carrier(); held { + key.row.object = carrier.ID + } else { + key.path = verdict.Path() + } + case row.kind == ValueState: + state, _ := row.State() + object, _ := state.Object() + key.row = rowKey{element: symbols.KeyOf(state.behavior), object: object.ID} + key.path = state.Machine() + " in " + state.Path() + case row.kind == ValueEvent: + event, _ := row.Event() + key.index = event.index + default: + key.row = keyOfRow(row) + } + return key +} + +// evaluateExcept keeps the source rows whose identity does not occur among +// the exclude rows, once each in source order with its projected columns. +func (e *executor) evaluateExcept(expression queryplan.Expression) (sequence, error) { + source, err := e.rowArgument(expression, "source") + if err != nil { + return sequence{}, err + } + exclude, err := e.rowArgument(expression, "exclude") + if err != nil { + return sequence{}, err + } + excluded := make(map[setKey]struct{}, len(exclude.values)) + for _, value := range exclude.values { + excluded[setKeyOf(value)] = struct{}{} + } + result := filtered(source) + seen := make(map[setKey]struct{}, len(source.values)) + for i, value := range source.values { + key := setKeyOf(value) + if _, ok := excluded[key]; ok { + continue + } + if _, duplicate := seen[key]; duplicate { + continue + } + seen[key] = struct{}{} + appendSelected(&result, source, i) + } + return result, nil +} + +// evaluateUnion emits every source row, then each row of other not yet emitted; +// both inputs must be unprojected or project the same columns. +func (e *executor) evaluateUnion(expression queryplan.Expression) (sequence, error) { + source, err := e.rowArgument(expression, "source") + if err != nil { + return sequence{}, err + } + other, err := e.rowArgument(expression, "other") + if err != nil { + return sequence{}, err + } + if !sameColumns(source.columns, other.columns) { + actual := "unprojected row set" + if len(other.columns) > 0 { + actual = "projected row set" + } + return sequence{}, e.invalidArgument(expression, "other", actual) + } + seen := make(map[setKey]struct{}, len(source.values)+len(other.values)) + result := filtered(source) + for _, input := range []sequence{source, other} { + for i, value := range input.values { + key := setKeyOf(value) + if _, duplicate := seen[key]; duplicate { + continue + } + seen[key] = struct{}{} + appendSelected(&result, input, i) + } + } + return result, nil +} + +func sameColumns(a, b []Column) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].name != b[i].name { + return false + } + } + return true +} diff --git a/internal/doc/queryexec/setops_test.go b/internal/doc/queryexec/setops_test.go new file mode 100644 index 0000000000..f0afe48298 --- /dev/null +++ b/internal/doc/queryexec/setops_test.go @@ -0,0 +1,327 @@ +package queryexec + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +const setQueries = ` +calc def Difference :> Query { + in source : Element[0..*] ordered; + in exclude : Element[0..*] ordered; + Except(source = source, exclude = exclude) +} +calc def Combined :> Query { + in source : Element[0..*] ordered; + in other : Element[0..*] ordered; + Union(source = source, other = other) +} +calc def Uncovered :> Query { + in root : Element; + Union( + source = Unsatisfied(root = root), + other = Unverified(root = root) + ) +} +calc def UnsatisfiedByDifference :> Query { + in root : Element; + Except( + source = RequirementUsages(root = root), + exclude = Satisfied(root = root) + ) +} +calc def NamedDifference :> Query { + in root : Element; + Except( + source = Project(source = RequirementUsages(root = root), properties = ("name")), + exclude = Satisfied(root = root) + ) +} +calc def NamedUnion :> Query { + in root : Element; + Union( + source = Project(source = Unsatisfied(root = root), properties = ("name")), + other = Project(source = Unverified(root = root), properties = ("name")) + ) +} +calc def MismatchedUnion :> Query { + in root : Element; + Union( + source = Project(source = Unsatisfied(root = root), properties = ("name")), + other = Unverified(root = root) + ) +} +calc def WheelsAndParts :> Query { + Union(source = Objects(type = "Wheel"), other = Objects(type = "PartUsage")) +} +calc def PartsButWheels :> Query { + Except(source = Objects(type = "PartUsage"), exclude = Objects(type = "Wheel")) +} +calc def DeclaredAndHeld :> Query { + in root : Element; + Union(source = WhereType(source = Descendants(source = root, maxDepth = 3), type = "Wheel"), other = Objects(type = "Wheel")) +} +` + +func TestExecuteSetOperationsOverElements(t *testing.T) { + fixture := loadExecutionFixture(t, coverageBody+coverageQueries+setQueries) + subsystem := ElementValue(fixture.symbol(t, "Subsystem")) + optical := ElementValue(fixture.symbol(t, "OpticalSubsystem")) + mirror := ElementValue(fixture.symbol(t, "MirrorAssembly")) + telescope := ElementValue(fixture.symbol(t, "telescope")) + + // Except keeps source order, drops every occurrence of an excluded identity + // and emits a repeated source identity once. + difference, err := fixture.execute(t, "Difference", Bindings{ + "source": {mirror, subsystem, optical, mirror, subsystem, telescope}, + "exclude": {telescope, mirror}, + }, Options{}) + if err != nil { + t.Fatalf("Difference: %v", err) + } + if got := strings.Join(rowNames(difference), ","); got != "Observatory::Subsystem,Observatory::OpticalSubsystem" { + t.Fatalf("Except = %v", got) + } + // Union emits the source rows first, then the unseen rows of other, once each. + combined, err := fixture.execute(t, "Combined", Bindings{ + "source": {mirror, subsystem, mirror}, + "other": {telescope, subsystem, optical, telescope}, + }, Options{}) + if err != nil { + t.Fatalf("Combined: %v", err) + } + want := "Observatory::MirrorAssembly,Observatory::Subsystem,Observatory::telescope,Observatory::OpticalSubsystem" + if got := strings.Join(rowNames(combined), ","); got != want { + t.Fatalf("Union = %v", got) + } + empty, err := fixture.execute(t, "Combined", Bindings{"source": {}, "other": {}}, Options{}) + if err != nil || len(empty.Rows()) != 0 { + t.Fatalf("Union of nothing = %v, %v", rowNames(empty), err) + } + for _, row := range combined.Rows() { + if !row.Origin().Located() { + t.Fatalf("row %v without provenance", row.Element()) + } + } +} + +func TestExecuteSetOperationsComposeCoverage(t *testing.T) { + fixture := loadExecutionFixture(t, coverageBody+coverageQueries+setQueries) + root := Bindings{"root": {ElementValue(fixture.symbol(t, "Specification"))}} + + // The requirements nothing satisfies, whether filtered or subtracted. + unsatisfied, err := fixture.execute(t, "Unsatisfied", root, Options{}) + if err != nil { + t.Fatalf("Unsatisfied: %v", err) + } + subtracted, err := fixture.execute(t, "UnsatisfiedByDifference", root, Options{}) + if err != nil { + t.Fatalf("UnsatisfiedByDifference: %v", err) + } + if got, want := strings.Join(rowNames(subtracted), ","), strings.Join(rowNames(unsatisfied), ","); got != want { + t.Fatalf("Except(all, satisfied) = %v, want %v", got, want) + } + // Either gap, each requirement once, unsatisfied first. + uncovered, err := fixture.execute(t, "Uncovered", root, Options{}) + if err != nil { + t.Fatalf("Uncovered: %v", err) + } + want := "Observatory::Specification::pointingRequirement,Observatory::Specification::thermalRequirement," + + "Observatory::Specification::thermalRequirement::coolantRequirement,Observatory::Specification::dataRequirement" + if got := strings.Join(rowNames(uncovered), ","); got != want { + t.Fatalf("Union(unsatisfied, unverified) = %v", got) + } +} + +func TestExecuteSetOperationsKeepProjectedColumns(t *testing.T) { + fixture := loadExecutionFixture(t, coverageBody+coverageQueries+setQueries) + root := Bindings{"root": {ElementValue(fixture.symbol(t, "Specification"))}} + + // Except compares identities beneath the projection and keeps the source's columns. + named, err := fixture.execute(t, "NamedDifference", root, Options{}) + if err != nil { + t.Fatalf("NamedDifference: %v", err) + } + if got := projectedNames(t, named); strings.Join(got, ",") != "pointingRequirement,thermalRequirement,coolantRequirement" { + t.Fatalf("projected Except = %v", got) + } + union, err := fixture.execute(t, "NamedUnion", root, Options{}) + if err != nil { + t.Fatalf("NamedUnion: %v", err) + } + if got := projectedNames(t, union); strings.Join(got, ",") != "pointingRequirement,thermalRequirement,coolantRequirement,dataRequirement" { + t.Fatalf("projected Union = %v", got) + } + // Rows of different shapes cannot share one table. + _, err = fixture.execute(t, "MismatchedUnion", root, Options{}) + if mismatch := executionError(t, err, ErrorInvalidArgument); mismatch.Parameter != "other" || mismatch.Actual != "unprojected row set" { + t.Fatalf("mismatched Union = %v", mismatch) + } +} + +func TestExecuteSetOperationsOverSessionObjects(t *testing.T) { + fixture := loadExecutionFixture(t, objectBody+setQueries) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + car, err := ctx.Instantiate(fixture.symbol(t, "car")) + if err != nil { + t.Fatalf("Instantiate car: %v", err) + } + spare, err := ctx.Instantiate(fixture.symbol(t, "spare")) + if err != nil { + t.Fatalf("Instantiate spare: %v", err) + } + session := Context{ + Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Runtime: ctx, + Roots: []Root{{Label: "car", Object: car}, {Label: "spare", Object: spare}}, + } + run := func(name string, bindings Bindings) *RowSet { + t.Helper() + result, err := Execute(fixture.program(t, name), session, bindings, Options{}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + return result + } + + // Objects are identified by instance, so every wheel appears once. + union := run("WheelsAndParts", nil) + wantUnion := "spare,car.wheels[1],car.wheels[2],car.wheels[3],car.wheels[4],car,car.engine" + if got := strings.Join(objectLabels(t, union), ","); got != wantUnion { + t.Fatalf("Union over objects = %v", got) + } + except := run("PartsButWheels", nil) + if got := strings.Join(objectLabels(t, except), ","); got != "car,car.engine" { + t.Fatalf("Except over objects = %v", got) + } + // A declared wheel and the objects instantiated from it are distinct rows. + mixed := run("DeclaredAndHeld", Bindings{"root": {ElementValue(fixture.symbol(t, "Car"))}}) + rows := mixed.Rows() + if len(rows) != 6 { + t.Fatalf("mixed Union has %d rows", len(rows)) + } + if sym, ok := rows[0].Element().Element(); !ok || sym != fixture.symbol(t, "Car::wheels") { + t.Fatalf("first mixed row = %v", rows[0].Element()) + } + if got := strings.Join(objectLabels(t, &RowSet{rows: rows[1:]}), ","); got != "spare,car.wheels[1],car.wheels[2],car.wheels[3],car.wheels[4]" { + t.Fatalf("mixed Union objects = %v", got) + } + wheels := Bindings{"source": {ObjectValue(car, "car"), ObjectValue(spare, "spare")}, "exclude": {ElementValue(fixture.symbol(t, "spare"))}} + if got := strings.Join(objectLabels(t, run("Difference", wheels)), ","); got != "car,spare" { + t.Fatalf("Except of objects by their declaration = %v", got) + } +} + +// Verdict rows take part by assertion and carrier: the same assertion checked +// on three wheels stays three rows, and asking twice adds none. +func TestExecuteSetOperationsOverVerdicts(t *testing.T) { + fixture := loadExecutionFixture(t, verdictBody+verdictQueries+setQueries+` +calc def AllTwice :> Query { + in root : Element; + Union(source = Verdicts(source = root), other = Verdicts(source = root)) +} +calc def NotConstraints :> Query { + in root : Element; + Project( + source = Except(source = Verdicts(source = root), exclude = Verdicts(source = root, kind = "constraint")), + properties = ("kind", "path") + ) +} +`) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + car, err := ctx.Instantiate(fixture.symbol(t, "car")) + if err != nil { + t.Fatalf("Instantiate car: %v", err) + } + session := Context{ + Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Runtime: ctx, + Roots: []Root{{Label: "car", Object: car}}, + } + modelOnly := Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model} + cases := map[string]struct { + context Context + root Value + }{ + "session": {session, ObjectValue(car, "car")}, + "model": {modelOnly, ElementValue(fixture.symbol(t, "car"))}, + } + for name, tc := range cases { + root := Bindings{"root": {tc.root}} + twice, err := Execute(fixture.program(t, "AllTwice"), tc.context, root, Options{}) + if err != nil { + t.Fatalf("%s AllTwice: %v", name, err) + } + if got := len(twice.Rows()); got != 11 { + t.Fatalf("%s: Union of the same verdicts has %d rows, want 11", name, got) + } + rest, err := Execute(fixture.program(t, "NotConstraints"), tc.context, root, Options{}) + if err != nil { + t.Fatalf("%s NotConstraints: %v", name, err) + } + if got := cellTexts(t, rest, 0); strings.Join(got, ",") != "requirement,satisfaction,verification,verification" { + t.Fatalf("%s: verdicts but constraints = %v", name, got) + } + } +} + +// State rows are one identity per object, machine and state path; event rows +// one per trace record. Union keeps every distinct row, Except drops only the +// rows named. +func TestExecuteSetOperationsOverStatesAndEvents(t *testing.T) { + fixture := loadLampFixture(t, ` +calc def StatesTwice :> Query { + in root : Element; + Project(source = Union(source = States(source = root), other = States(source = root)), properties = ("path", "statePath")) +} +calc def StatesBut :> Query { + in root : Element; + in exclude : Element; + Project(source = Except(source = States(source = root), exclude = States(source = exclude)), properties = ("path", "statePath")) +} +calc def AcceptsTwice :> Query { + in root : Element; + Project(source = Union(source = Events(source = root, kind = "accept"), other = Events(source = root, kind = "accept")), properties = ("time", "path", "event")) +} +calc def LaterAccepts :> Query { + in root : Element; + Project( + source = Except(source = Events(source = root, kind = "accept"), exclude = Events(source = root, kind = "accept", before = 2 [s])), + properties = ("time", "path", "event") + ) +} +`) + lamps := Bindings{"root": {ElementValue(fixture.symbol(t, "Lamp"))}} + got := rowTexts(t, fixture.rows(t, "StatesTwice", lamps)) + want := []string{ + "path=lamp1 statePath=on.dim", + "path=lamp1 statePath=on.fast", + "path=lamp2 statePath=off", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("Union of the same states:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + got = rowTexts(t, fixture.rows(t, "StatesBut", Bindings{ + "root": {ElementValue(fixture.symbol(t, "Lamp"))}, + "exclude": {ObjectValue(fixture.lamp1, "lamp1")}, + })) + if joinLines(got) != "path=lamp2 statePath=off" { + t.Fatalf("lamp states but lamp1's:\n%s", joinLines(got)) + } + + got = rowTexts(t, fixture.rows(t, "AcceptsTwice", lamps)) + want = []string{ + "time=0.0 [s] path=lamp1 event=Toggle", + "time=1.0 [s] path=lamp1 event=Dim", + "time=2.0 [s] path=lamp2 event=Toggle", + "time=2.5 [s] path=lamp2 event=Toggle", + "time=2.5 [s] path=lamp1 event=Boost", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("Union of the same accepts:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + got = rowTexts(t, fixture.rows(t, "LaterAccepts", lamps)) + if joinLines(got) != joinLines(want[2:]) { + t.Fatalf("accepts from 2 s:\n%s\nwant:\n%s", joinLines(got), joinLines(want[2:])) + } +} diff --git a/internal/doc/queryexec/state.go b/internal/doc/queryexec/state.go new file mode 100644 index 0000000000..6cd84368aa --- /dev/null +++ b/internal/doc/queryexec/state.go @@ -0,0 +1,61 @@ +package queryexec + +import ( + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// State is one row of States or InState: an active leaf state of one object's +// state machine, as the session stands. Immutable once built. +type State struct { + object *runtime.Instance + label string + machine string + symbol *symbols.Symbol + name string + path string + region string + enclosing []string + behavior *symbols.Symbol +} + +// Object is the object whose machine is in the state, under the label the +// session reaches it by. +func (s State) Object() (*runtime.Instance, string) { return s.object, s.label } + +// Machine names the state machine the object exhibits that is in the state. +func (s State) Machine() string { return s.machine } + +// Declaration is the state as declared, nil when lowering synthesized it. +func (s State) Declaration() *symbols.Symbol { return s.symbol } + +// Name is the active leaf state's own name. +func (s State) Name() string { return s.name } + +// Path is the leaf's name qualified by the states enclosing it (`on.run`). +func (s State) Path() string { return s.path } + +// Region names the orthogonal region the leaf runs in, "" outside any region. +func (s State) Region() string { return s.region } + +// Enclosing names the composite states the leaf is nested in, outermost first; +// each is active with the leaf. +func (s State) Enclosing() []string { return append([]string(nil), s.enclosing...) } + +// Label names the row: the state's path on its object's machine (`lamp.lp in on.dim`). +func (s State) Label() string { + return s.label + "." + s.machine + " in " + s.path +} + +// StateValue constructs a state value; its declaration is the state. +func StateValue(state State) Value { + return Value{kind: ValueState, state: &state, origin: state.symbol.Origin()} +} + +// State returns the value's state and whether it is a state value. +func (v Value) State() (State, bool) { + if v.kind != ValueState || v.state == nil { + return State{}, false + } + return *v.state, true +} diff --git a/internal/doc/queryexec/states.go b/internal/doc/queryexec/states.go new file mode 100644 index 0000000000..63a66ef0d7 --- /dev/null +++ b/internal/doc/queryexec/states.go @@ -0,0 +1,353 @@ +package queryexec + +import ( + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/query" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// State rows report where an object's state machines stand: one row per active +// leaf state, read from the session's executors as they are now. + +// stateFQN declares the state properties a column expression may reference. +const stateFQN = "DocumentQueries::State" + +// State properties, beside the metadata every row answers. +const ( + propertyObject = "object" + propertyMachine = "machine" + propertyStatePath = "statePath" + propertyState = "state" + propertyRegion = "region" + propertyEnclosing = "enclosing" +) + +// evaluateStates lists the active leaf states of each source row's object, one +// row per leaf, machines in the object's order and leaves in region order. +func (e *executor) evaluateStates(expression queryplan.Expression) (sequence, error) { + if err := e.requireRuntime(expression); err != nil { + return sequence{}, err + } + source, err := e.objectArgument(expression, "source") + if err != nil { + return sequence{}, err + } + var result sequence + for _, row := range source.values { + inst, label, _ := row.Object() + machines := stateMachinesOf(inst) + if len(machines) == 0 { + return sequence{}, &Error{ + Kind: ErrorNoStateMachine, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: "source", + Target: label, + Origin: expression.Origin(), + } + } + for _, machine := range machines { + for _, leaf := range machine.State.ActiveStates() { + if !e.consumeVisit() { + return sequence{}, e.budgetError(expression) + } + result.values = append(result.values, StateValue(activeState(inst, label, machine, leaf))) + } + } + } + return result, nil +} + +// evaluateInState lists the session's objects whose state machine is in the +// named state, a leaf or a state enclosing one, each object once in session order. +func (e *executor) evaluateInState(expression queryplan.Expression) (sequence, error) { + if err := e.requireRuntime(expression); err != nil { + return sequence{}, err + } + name, err := e.stringArgument(expression, "name") + if err != nil { + return sequence{}, err + } + if name == "" { + return sequence{}, e.invalidArgument(expression, "name", name) + } + var result sequence + declared := false + err = e.eachSessionObject(expression, func(row Value) { + inst, _, _ := row.Object() + // A destroyed object left the population; its executor may hold a stale configuration. + if _, gone := e.context.Runtime.Destroyed(inst); gone { + return + } + matched := false + for _, machine := range stateMachinesOf(inst) { + declared = declared || machineDeclaresState(machine.State, name) + for _, leaf := range machine.State.ActiveStates() { + if stateNamed(machine.State, leaf, name) { + matched = true + } + } + } + if matched { + result.values = append(result.values, row) + } + }) + if err != nil { + return sequence{}, err + } + if !declared { + return sequence{}, &Error{ + Kind: ErrorUnknownState, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: "name", + Actual: name, + Origin: expression.Origin(), + } + } + return result, nil +} + +// objectArgument evaluates an argument whose rows must denote session objects: +// object rows as they are, an element by the session objects it declares. +func (e *executor) objectArgument(expression queryplan.Expression, name string) (sequence, error) { + value, err := e.rowArgument(expression, name) + if err != nil { + return sequence{}, err + } + var result sequence + for _, item := range value.values { + if inst, label, ok := item.Object(); ok { + if at, destroyed := e.context.Runtime.Destroyed(inst); destroyed { + return sequence{}, e.objectDestroyedError(expression, name, label, at) + } + result.values = append(result.values, item) + continue + } + if verdict, ok := item.Verdict(); ok { + return sequence{}, e.verdictRowError(expression, name, verdict) + } + if state, ok := item.State(); ok { + return sequence{}, e.rowKindError(expression, name, ErrorStateRow, state.Label()) + } + if event, ok := item.Event(); ok { + return sequence{}, e.rowKindError(expression, name, ErrorEventRow, event.Label()) + } + sym, _ := item.Element() + live, destroyed, err := e.objectsDeclaredBy(expression, sym) + if err != nil { + return sequence{}, err + } + if len(live) == 0 { + if len(destroyed) > 0 { + inst, label, _ := destroyed[0].Object() + at, _ := e.context.Runtime.Destroyed(inst) + return sequence{}, e.objectDestroyedError(expression, name, label, at) + } + return sequence{}, &Error{ + Kind: ErrorNotHeld, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: name, + Target: symbols.FQNOf(sym), + Origin: expression.Origin(), + } + } + result.values = append(result.values, live...) + } + return result, nil +} + +// objectDestroyedError reports a source object the run destroyed. +func (e *executor) objectDestroyedError(expression queryplan.Expression, name, label string, at int64) error { + return &Error{ + Kind: ErrorObjectDestroyed, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: name, + Target: label, + Actual: strconv.FormatInt(at, 10), + Origin: expression.Origin(), + } +} + +// objectsDeclaredBy lists the session's objects an element declares: those it +// is the declaration of, or that are typed by it or a type conforming to it, +// split into the live ones and the ones the run destroyed. +func (e *executor) objectsDeclaredBy(expression queryplan.Expression, sym *symbols.Symbol) (live, destroyed []Value, err error) { + err = e.eachSessionObject(expression, func(row Value) { + inst, _, _ := row.Object() + if !symbols.SameElement(objectDeclaration(inst), sym) && !e.objectConforms(inst, sym) { + return + } + if _, gone := e.context.Runtime.Destroyed(inst); gone { + destroyed = append(destroyed, row) + return + } + live = append(live, row) + }) + return live, destroyed, err +} + +// stateMachinesOf lists the state machines an object exhibits, in its order. +func stateMachinesOf(inst *runtime.Instance) []*runtime.ObjectBehavior { + var out []*runtime.ObjectBehavior + for _, b := range inst.Behaviors() { + if b.Kind == lower.ExhibitedState && b.State != nil { + out = append(out, b) + } + } + return out +} + +// activeState describes one active leaf of a machine as a row. +func activeState(inst *runtime.Instance, label string, machine *runtime.ObjectBehavior, leaf *ast.StateNode) State { + exec := machine.State + enclosing := exec.EnclosingStates(leaf) + names := make([]string, 0, len(enclosing)) + for _, s := range enclosing { + names = append(names, s.Name) + } + return State{ + object: inst, + label: label, + machine: machineName(machine), + symbol: stateSymbol(exec.Graph(), leaf), + name: leaf.Name, + path: exec.StatePath(leaf), + region: regionName(exec.Graph(), leaf), + enclosing: names, + behavior: machine.Symbol, + } +} + +// regionName names the innermost orthogonal region state stands in, its own or an +// ancestor's, "" for a state outside every region. +func regionName(graph *lower.StateGraph, state *ast.StateNode) string { + for current := state; current != nil; current = graph.ParentState[current] { + if region := graph.RegionOf[current]; region != nil { + return region.Name + } + if region := graph.HiddenRegionOf[current]; region != nil { + return region.Name + } + } + return "" +} + +// machineName names a machine as the object exhibits it, or as declared when +// it is exhibited anonymously. +func machineName(machine *runtime.ObjectBehavior) string { + if machine.Name != "" { + return machine.Name + } + if sym := machine.State.StateMachineSymbol(); sym != nil { + return sym.Name + } + return "" +} + +// stateSymbol is the symbol declaring a state of the graph, nil for one +// lowering synthesized without a declaration. +func stateSymbol(graph *lower.StateGraph, state *ast.StateNode) *symbols.Symbol { + decl := graph.DeclOf(state) + scope := graph.StateScopes[state] + if scope == nil { + return nil + } + if owner := scope.Owner(); owner != nil && owner.Decl == decl { + return owner + } + for s := scope; s != nil; s = s.Parent() { + if sym := s.MemberDeclaring(decl); sym != nil { + return sym + } + } + return nil +} + +// stateNamed reports whether name denotes leaf or a state enclosing it, by +// the state's own name or by its path (`on.run`). +func stateNamed(exec *runtime.StateExecutor, leaf *ast.StateNode, name string) bool { + chain := append(exec.EnclosingStates(leaf), leaf) + parts := make([]string, 0, len(chain)) + for _, state := range chain { + parts = append(parts, state.Name) + if state.Name == name || strings.Join(parts, ".") == name { + return true + } + } + return false +} + +// machineDeclaresState reports whether any state of a machine, active or not, +// answers to name. +func machineDeclaresState(exec *runtime.StateExecutor, name string) bool { + graph := exec.Graph() + for _, state := range graph.States { + if graph.HiddenStates[state] { + continue + } + if stateNamed(exec, state, name) { + return true + } + } + return false +} + +// rowKindError reports a row of a kind an operation's parameter does not take. +func (e *executor) rowKindError(expression queryplan.Expression, name string, kind ErrorKind, target string) error { + return &Error{ + Kind: kind, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: name, + Target: target, + Origin: expression.Origin(), + } +} + +// statePropertyValues reads a property of a state row: the row's own +// properties first, then the metadata of the state as declared. +func (e *executor) statePropertyValues(row Value, property string) ([]Value, bool, error) { + state, _ := row.State() + origin := row.Origin() + text := func(values ...string) []Value { + out := make([]Value, 0, len(values)) + for _, value := range values { + out = append(out, valueAt(StringValue(value), origin)) + } + return out + } + switch property { + case propertyObject: + return []Value{valueAt(ObjectValue(state.object, state.label), origin)}, true, nil + case propertyPath: + return text(state.label), true, nil + case propertyMachine: + return text(state.machine), true, nil + case query.PropertyName: + return text(state.name), true, nil + case propertyStatePath: + return text(state.path), true, nil + case propertyState: + if state.symbol == nil { + return nil, true, nil + } + return []Value{valueAt(ElementValue(state.symbol), origin)}, true, nil + case propertyRegion: + return text(state.region), true, nil + case propertyEnclosing: + return text(state.enclosing...), true, nil + } + if state.symbol == nil { + return nil, false, nil + } + return e.propertyValues(ElementValue(state.symbol), property) +} diff --git a/internal/doc/queryexec/states_test.go b/internal/doc/queryexec/states_test.go new file mode 100644 index 0000000000..1a560c81ea --- /dev/null +++ b/internal/doc/queryexec/states_test.go @@ -0,0 +1,451 @@ +package queryexec + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// lampBody is a session's worth of state machines: two lamps whose machine has +// an `on` state of two orthogonal regions, a panel whose action draws a write +// order and sends, and a rock exhibiting none. +const lampBody = ` +private import SI::*; +attribute def Toggle; +attribute def Boost; +attribute def Dim { attribute level : Integer; } +attribute def Report; +state def LampMachine { + attribute brightness : Integer = 0; + entry; then off; + state off; + choice pick; + transition off_on first off accept Toggle then pick; + transition first pick if brightness >= 0 then on; + transition first pick if 1 / brightness > 0 then on; + state on parallel { + state light { + entry; then run; + state run; + transition run_dim first run accept d : Dim do assign brightness := d.level then dim; + transition run_boost first run accept Boost then dim; + state dim { + do action { assign brightness := brightness + 1; } + } + } + state fan { + entry; then slow; + state slow; + transition slow_fast first slow accept Boost then fast; + state fast { + entry send new Report() to panel; + } + } + } + transition on_off first on accept Toggle then off; +} +part def Lamp { exhibit state lp : LampMachine; } +part def Panel { + attribute mark : Integer = 0; + perform action marking { + attribute rounds : Integer = 2; + first start; + then decide route; + if mark == 0 then split; + if 1 / mark > 0 then split; + fork split; + action low { assign mark := 1; } + action high { assign mark := 2; } + join sync; + action report send new Report() to lamp1; + done; + succession first split then low; + succession first split then high; + succession first low then sync; + succession first high then sync; + succession first sync then report; + succession first report then done; + } +} +part lamp1 : Lamp; +part lamp2 : Lamp; +part panel : Panel; +part def Rock; +part rock : Rock; +` + +const lampQueries = ` +calc def CurrentStates :> Query { + in root : Element; + Project(source = States(source = root), properties = ("path", "machine", "name", "statePath", "region", "enclosing")) +} +calc def StateElements :> Query { + in root : Element; + Project(source = States(source = root), properties = ("object", "state")) +} +calc def LeavesNamed :> Query { + in root : Element; + in leaf : String; + Project( + source = WhereFeature(source = States(source = root), 'feature' = "name", operator = "=", value = leaf), + properties = ("path", "statePath") + ) +} +calc def LeavesUnder :> Query { + in root : Element; + Project( + source = WhereFeature(source = States(source = root), 'feature' = "region", operator = "=", value = "light"), + properties = ("path", "name") + ) +} +calc def StatesByName :> Query { + in root : Element; + OrderBy( + source = Project(source = States(source = root), properties = ("path", "name")), + property = "name", + direction = "ascending", + missing = "last", + multiple = "error" + ) +} +calc def StatesOfMachine :> Query { + in root : Element; + WhereName(source = States(source = root), operator = "=", value = "run") +} +calc def StateColumns :> Query { + in root : Element; + Project( + source = States(source = root), + properties = ("path"), + columns = ( + Column(name = "label", expression = "lamp " + State::path), + Column(name = "where", expression = State::machine + ":" + State::statePath) + ) + ) +} +calc def Running :> Query { + Project(source = InState(name = "run"), properties = ("qualifiedName")) +} +calc def Lit :> Query { + Project(source = InState(name = "on"), properties = ("qualifiedName")) +} +calc def Fanned :> Query { + Project(source = InState(name = "on.slow"), properties = ("qualifiedName")) +} +calc def Nowhere :> Query { + InState(name = "orbit") +} +calc def RunningStates :> Query { + Project(source = States(source = InState(name = "run")), properties = ("path", "statePath")) +} +calc def StatesOfStates :> Query { + in root : Element; + States(source = States(source = root)) +} +calc def StateDescendants :> Query { + in root : Element; + Descendants(source = States(source = root), maxDepth = 1) +} +` + +type lampFixture struct { + executionFixture + ctx *runtime.Context + trace *runtime.TraceRecorder + lamp1, lamp2 *runtime.Instance + rock, panel *runtime.Instance +} + +// loadLampFixture drives the lamps: lamp1 on at 0 s, dimmed at 1 s, boosted at +// 2.5 s; lamp2 on at 2 s, off at 2.5 s; the clock stands at 3.5 s. +func loadLampFixture(t *testing.T, queries string) lampFixture { + t.Helper() + return loadLampFixtureTracing(t, queries, runtime.NewTraceRecorder()) +} + +// loadLampFixtureTracing drives the lamps as loadLampFixture does, into trace. +func loadLampFixtureTracing(t *testing.T, queries string, trace *runtime.TraceRecorder) lampFixture { + t.Helper() + fixture := loadExecutionFixture(t, lampBody+queries) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + ctx.SetTrace(trace) + instantiate := func(name string) *runtime.Instance { + inst, err := ctx.Instantiate(fixture.symbol(t, name)) + if err != nil { + t.Fatalf("Instantiate %s: %v", name, err) + } + return inst + } + f := lampFixture{executionFixture: fixture, ctx: ctx, trace: trace} + f.lamp1, f.lamp2, f.rock, f.panel = instantiate("lamp1"), instantiate("lamp2"), instantiate("rock"), instantiate("panel") + f.send(t, f.lamp1, "Toggle", nil) + f.advance(t, 1) + f.send(t, f.lamp1, "Dim", map[string]runtime.Value{"level": integerValue(3)}) + f.advance(t, 1) + f.send(t, f.lamp2, "Toggle", nil) + f.advance(t, 0.5) + f.send(t, f.lamp1, "Boost", nil) + f.send(t, f.lamp2, "Toggle", nil) + f.advance(t, 1) + return f +} + +func integerValue(n int64) runtime.Value { + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: n}} +} + +func (f lampFixture) send(t *testing.T, inst *runtime.Instance, signal string, args map[string]runtime.Value) { + t.Helper() + msg, err := f.ctx.SignalMessage(f.symbol(t, signal), args, inst) + if err != nil { + t.Fatalf("send %s: %v", signal, err) + } + f.ctx.PostMessage(msg) +} + +func (f lampFixture) advance(t *testing.T, seconds float64) { + t.Helper() + if _, err := f.ctx.Advance(seconds); err != nil { + t.Fatalf("advance %v: %v", seconds, err) + } +} + +func (f lampFixture) session() Context { + return Context{ + Index: f.index, Resolver: f.resolver, Model: f.model, Runtime: f.ctx, + Roots: []Root{{Label: "lamp1", Object: f.lamp1}, {Label: "lamp2", Object: f.lamp2}, {Label: "rock", Object: f.rock}, {Label: "panel", Object: f.panel}}, + } +} + +func (f lampFixture) modelOnly() Context { + return Context{Index: f.index, Resolver: f.resolver, Model: f.model} +} + +func (f lampFixture) run(t *testing.T, context Context, name string, bindings Bindings) (*RowSet, error) { + t.Helper() + return Execute(f.program(t, name), context, bindings, Options{}) +} + +func (f lampFixture) rows(t *testing.T, name string, bindings Bindings) *RowSet { + t.Helper() + result, err := f.run(t, f.session(), name, bindings) + if err != nil { + t.Fatalf("execute %s: %v", name, err) + } + return result +} + +func (f lampFixture) object(inst *runtime.Instance, label string) Bindings { + return Bindings{"root": {ObjectValue(inst, label)}} +} + +// rowTexts renders every cell of every row as `column=value+value`, one line per row. +func rowTexts(t *testing.T, result *RowSet) []string { + t.Helper() + var out []string + for _, row := range result.Rows() { + var cells []string + for i, cell := range row.Cells() { + var parts []string + for _, value := range cell.Values() { + parts = append(parts, valueText(t, value)) + } + cells = append(cells, result.Columns()[i].Name()+"="+strings.Join(parts, "+")) + } + out = append(out, strings.Join(cells, " ")) + } + return out +} + +func valueText(t *testing.T, value Value) string { + t.Helper() + if text, ok := value.String(); ok { + return text + } + if sym, ok := value.Element(); ok { + return symbols.FQNOf(sym) + } + if _, label, ok := value.Object(); ok { + return "object " + label + } + if quantity, ok := value.Quantity(); ok { + return quantity.String() + } + if n, ok := value.Integer(); ok { + return semantics.FormatReal(float64(n)) + } + if r, ok := value.Real(); ok { + return semantics.FormatReal(r) + } + t.Fatalf("value of kind %s has no text", value.Kind()) + return "" +} + +func joinLines(lines []string) string { + return strings.Join(lines, "\n") +} + +// States answers one row per active leaf: lamp1 stands in both regions of `on`, +// lamp2 in `off`; the composite `on` encloses both, each in its own region. +func TestExecuteStatesListsEveryActiveLeaf(t *testing.T) { + fixture := loadLampFixture(t, lampQueries) + got := rowTexts(t, fixture.rows(t, "CurrentStates", fixture.object(fixture.lamp1, "lamp1"))) + want := []string{ + "path=lamp1 machine=lp name=dim statePath=on.dim region=light enclosing=on", + "path=lamp1 machine=lp name=fast statePath=on.fast region=fan enclosing=on", + } + if joinLines(got) != joinLines(want) { + t.Fatalf("lamp1 states:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } + got = rowTexts(t, fixture.rows(t, "CurrentStates", fixture.object(fixture.lamp2, "lamp2"))) + if joinLines(got) != "path=lamp2 machine=lp name=off statePath=off region= enclosing=" { + t.Fatalf("lamp2 states:\n%s", joinLines(got)) + } + + // A type selects the population: every lamp the session holds, in session order. + got = rowTexts(t, fixture.rows(t, "CurrentStates", Bindings{"root": {ElementValue(fixture.symbol(t, "Lamp"))}})) + if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || !strings.HasPrefix(got[2], "path=lamp2 ") { + t.Fatalf("Lamp states:\n%s", joinLines(got)) + } + + // The object column is the object row; the state column is the declaration. + got = rowTexts(t, fixture.rows(t, "StateElements", fixture.object(fixture.lamp2, "lamp2"))) + if joinLines(got) != "object=object lamp2 state=Observatory::LampMachine::off" { + t.Fatalf("state elements:\n%s", joinLines(got)) + } +} + +// A leaf nested below a region's own state still stands in that region: the row +// names the innermost region an enclosing state is declared in. +func TestExecuteStatesNameTheRegionOfANestedLeaf(t *testing.T) { + fixture := loadExecutionFixture(t, ` +state def Dome { + entry; then open; + state open parallel { + state shutter { + entry; then ajar; + state ajar { + entry; then widening; + state widening; + } + } + state drive { + entry; then idle; + state idle; + } + } +} +part def Housing { exhibit state control : Dome; } +part housing : Housing; +calc def NestedStates :> Query { + in root : Element; + Project(source = States(source = root), properties = ("statePath", "region", "enclosing")) +} +`) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + housing, err := ctx.Instantiate(fixture.symbol(t, "housing")) + if err != nil { + t.Fatalf("Instantiate housing: %v", err) + } + context := Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Runtime: ctx, Roots: []Root{{Label: "housing", Object: housing}}} + result, err := Execute(fixture.program(t, "NestedStates"), context, Bindings{"root": {ObjectValue(housing, "housing")}}, Options{}) + if err != nil { + t.Fatalf("execute NestedStates: %v", err) + } + want := []string{ + "statePath=open.ajar.widening region=shutter enclosing=open+ajar", + "statePath=open.idle region=drive enclosing=open", + } + if got := rowTexts(t, result); joinLines(got) != joinLines(want) { + t.Fatalf("nested leaves:\n%s\nwant:\n%s", joinLines(got), joinLines(want)) + } +} + +// State rows take the row operations the object rows do: WhereFeature over the +// leaf's name or its region, WhereName, OrderBy, and Column expressions. +func TestExecuteStateRowsThroughRowOperations(t *testing.T) { + fixture := loadLampFixture(t, lampQueries) + lamps := Bindings{"root": {ElementValue(fixture.symbol(t, "Lamp"))}} + + got := rowTexts(t, fixture.rows(t, "LeavesNamed", Bindings{"root": lamps["root"], "leaf": {StringValue("fast")}})) + if joinLines(got) != "path=lamp1 statePath=on.fast" { + t.Fatalf("leaves named fast:\n%s", joinLines(got)) + } + got = rowTexts(t, fixture.rows(t, "LeavesUnder", lamps)) + if joinLines(got) != "path=lamp1 name=dim" { + t.Fatalf("leaves in region light:\n%s", joinLines(got)) + } + got = rowTexts(t, fixture.rows(t, "StatesByName", lamps)) + if joinLines(got) != joinLines([]string{"path=lamp1 name=dim", "path=lamp1 name=fast", "path=lamp2 name=off"}) { + t.Fatalf("states by name:\n%s", joinLines(got)) + } + if result := fixture.rows(t, "StatesOfMachine", lamps); len(result.Rows()) != 0 { + t.Fatalf("states named run = %d rows, want none", len(result.Rows())) + } + got = rowTexts(t, fixture.rows(t, "StateColumns", fixture.object(fixture.lamp1, "lamp1"))) + if joinLines(got) != joinLines([]string{"path=lamp1 label=lamp lamp1 where=lp:on.dim", "path=lamp1 label=lamp lamp1 where=lp:on.fast"}) { + t.Fatalf("state columns:\n%s", joinLines(got)) + } +} + +// InState answers the objects whose machine stands in the named state, once +// each: a leaf, a composite state enclosing one, or a dotted path. +func TestExecuteInStateFindsObjectsByLeafOrEnclosingState(t *testing.T) { + fixture := loadLampFixture(t, lampQueries) + if result := fixture.rows(t, "Running", nil); len(result.Rows()) != 0 { + t.Fatalf("objects in run = %v, want none", cellTexts(t, result, 0)) + } + if got := cellTexts(t, fixture.rows(t, "Lit", nil), 0); strings.Join(got, ",") != "lamp1" { + t.Fatalf("objects in on = %v", got) + } + if result := fixture.rows(t, "Fanned", nil); len(result.Rows()) != 0 { + t.Fatalf("objects in on.slow = %v, want none", cellTexts(t, result, 0)) + } + fixture.send(t, fixture.lamp2, "Toggle", nil) + fixture.advance(t, 0) + if got := cellTexts(t, fixture.rows(t, "Fanned", nil), 0); strings.Join(got, ",") != "lamp2" { + t.Fatalf("objects in on.slow after lamp2 switched on = %v", got) + } + if got := cellTexts(t, fixture.rows(t, "Lit", nil), 0); strings.Join(got, ",") != "lamp1,lamp2" { + t.Fatalf("objects in on = %v", got) + } + // The inverse composes with States: the rows of the objects it selected. + got := rowTexts(t, fixture.rows(t, "RunningStates", nil)) + if joinLines(got) != joinLines([]string{"path=lamp2 statePath=on.run", "path=lamp2 statePath=on.slow"}) { + t.Fatalf("states of the objects in run:\n%s", joinLines(got)) + } +} + +// Every unsupported path is a typed error: no session, no state machine, no object +// held, a state no machine declares, a state row read as an element or object. +func TestExecuteStatesRefusals(t *testing.T) { + fixture := loadLampFixture(t, lampQueries) + lamp1 := fixture.object(fixture.lamp1, "lamp1") + + _, err := fixture.run(t, fixture.modelOnly(), "Running", nil) + executionError(t, err, ErrorNoRuntime) + _, err = fixture.run(t, fixture.modelOnly(), "CurrentStates", Bindings{"root": {ElementValue(fixture.symbol(t, "Lamp"))}}) + executionError(t, err, ErrorNoRuntime) + + _, err = fixture.run(t, fixture.session(), "CurrentStates", fixture.object(fixture.rock, "rock")) + if got := executionError(t, err, ErrorNoStateMachine); got.Target != "rock" || !strings.Contains(got.Error(), "rock, which exhibits no state machine") { + t.Fatalf("no-state-machine error = %v", got) + } + _, err = fixture.run(t, fixture.session(), "CurrentStates", Bindings{"root": {ElementValue(fixture.symbol(t, "LampMachine"))}}) + if got := executionError(t, err, ErrorNotHeld); got.Target != "Observatory::LampMachine" { + t.Fatalf("not-held error = %v", got) + } + _, err = fixture.run(t, fixture.session(), "Nowhere", nil) + if got := executionError(t, err, ErrorUnknownState); got.Actual != "orbit" || !strings.Contains(got.Error(), "names state orbit") { + t.Fatalf("unknown-state error = %v", got) + } + _, err = fixture.run(t, fixture.session(), "StatesOfStates", lamp1) + if got := executionError(t, err, ErrorStateRow); got.Target != "lamp1.lp in on.dim" { + t.Fatalf("state-row error = %v", got) + } + _, err = fixture.run(t, fixture.session(), "StateDescendants", lamp1) + executionError(t, err, ErrorStateRow) +} diff --git a/internal/core/queryexec/testdata/tmt_collection.sysml b/internal/doc/queryexec/testdata/tmt_collection.sysml similarity index 100% rename from internal/core/queryexec/testdata/tmt_collection.sysml rename to internal/doc/queryexec/testdata/tmt_collection.sysml diff --git a/internal/doc/queryexec/testdata/tmt_derivation.sysml b/internal/doc/queryexec/testdata/tmt_derivation.sysml new file mode 100644 index 0000000000..0b6c807d62 --- /dev/null +++ b/internal/doc/queryexec/testdata/tmt_derivation.sysml @@ -0,0 +1,135 @@ +package Observatory { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + private import RequirementDerivation::*; + private import ModelingMetadata::Refinement; + + requirement def MassRequirement; + requirement def MirrorMassRequirement; + requirement def SegmentMassRequirement; + requirement def InstrumentMassRequirement; + + requirement systemMass : MassRequirement; + requirement mirrorMass : MirrorMassRequirement; + requirement segmentMass : SegmentMassRequirement; + requirement instrumentMass : InstrumentMassRequirement; + requirement cameraMass; + requirement spectrographMass; + + requirement pointingAccuracy; + requirement trackingAccuracy; + requirement guidingAccuracy; + requirement pointingBudget; + requirement jitterBudget; + requirement driftBudget; + requirement thermalStability; + requirement heaterPower; + requirement opticsAlignment; + requirement mirrorFigure; + + part mirrorDesign; + part mountDesign; + part guiderDesign; + part thermalDesign; + + // Typed usage: the clause's first end is the original, the second the derived. + connection massDerivation : Derivation connect systemMass to mirrorMass; + + // Semantic metadata on the usage, chaining a derived requirement further. + #derivation connection connect mirrorMass to segmentMass; + + // N-ary clause: one original, several derived, in end order. + #derivation connection connect (instrumentMass, cameraMass, spectrographMass); + + // Body ends stating their roles by metadata, written derived-first. + #derivation connection { + end #derive ::> trackingAccuracy; + end #original ::> pointingAccuracy; + } + + // Body ends stating their roles by subsetting the library's role features. + connection : Derivation { + end :> derivedRequirements ::> guidingAccuracy; + end :> originalRequirements ::> pointingAccuracy; + } + + // An end stating no role takes the one left over: derived beside a stated + // original, original beside a stated derived. + #derivation connection { + end #original ::> thermalStability; + end ::> heaterPower; + } + #derivation connection { + end ::> opticsAlignment; + end #derive ::> mirrorFigure; + } + + // A derivation definition whose ends are typed by requirement definitions, + // and a usage of it whose ends redefine the definition's by position. + #derivation connection def BudgetDerivation { + end #derive jitter : MirrorMassRequirement; + end #original budget : MassRequirement; + end #derive drift : InstrumentMassRequirement; + } + #derivation connection : BudgetDerivation { + end jitter ::> jitterBudget; + end budget ::> pointingBudget; + end drift ::> driftBudget; + } + + // The migrator's form: a definition specializing Derivation, ends + // tagged with the qualified metadata names. + connection def 'Derive Segment Mass Requirement' :> RequirementDerivation::Derivation { + end #RequirementDerivation::original originalRequirement : MirrorMassRequirement; + end #RequirementDerivation::derive derivedRequirement : SegmentMassRequirement; + } + + // Definitions specializing a derivation definition: one redefines the derived + // end, one only the original, one nothing; an untyped end relates no requirement. + requirement def AlignmentRequirement; + requirement def SegmentAlignmentRequirement; + requirement def ActuatorAlignmentRequirement :> SegmentAlignmentRequirement; + requirement def MirrorAlignmentRequirement :> AlignmentRequirement; + connection def AlignmentDerivation :> Derivation { + end #original alignment : AlignmentRequirement; + end #derive segment : SegmentAlignmentRequirement; + } + connection def ActuatorAlignmentDerivation :> AlignmentDerivation { + end :>> alignment; + end actuator : ActuatorAlignmentRequirement :>> segment; + } + connection def MirrorAlignmentDerivation :> AlignmentDerivation { + end mirror : MirrorAlignmentRequirement :>> alignment; + } + connection def InheritedAlignmentDerivation :> AlignmentDerivation; + connection def UntypedDerivation :> Derivation { + end #original alignment : AlignmentRequirement; + end untyped; + } + + // A plain connection between requirements is no derivation. + connection connect systemMass to instrumentMass; + + // Refinement dependencies: prefix form, body form, and several clients + // and suppliers; a plain dependency is no refinement. + #refinement dependency pointingBudget to pointingAccuracy; + dependency refinesMass from mirrorDesign to mirrorMass { + @Refinement; + } + #refinement dependency from mountDesign, guiderDesign to trackingAccuracy, guidingAccuracy; + dependency from thermalDesign to systemMass; + + calc def Related :> Query { + in source : Element[1..*] ordered; + in kind : String; + in direction : String; + in maxDepth : Integer; + RelatedElements( + source = source, + relationshipKind = kind, + direction = direction, + maxDepth = maxDepth + ) + } +} diff --git a/internal/core/queryexec/testdata/tmt_relationships.sysml b/internal/doc/queryexec/testdata/tmt_relationships.sysml similarity index 100% rename from internal/core/queryexec/testdata/tmt_relationships.sysml rename to internal/doc/queryexec/testdata/tmt_relationships.sysml diff --git a/internal/doc/queryexec/testdata/trace_matrix.sysml b/internal/doc/queryexec/testdata/trace_matrix.sysml new file mode 100644 index 0000000000..ea8c6d7035 --- /dev/null +++ b/internal/doc/queryexec/testdata/trace_matrix.sysml @@ -0,0 +1,166 @@ +package Observatory { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + part def Subsystem; + part def OpticalSubsystem :> Subsystem; + part def MirrorAssembly :> OpticalSubsystem; + + requirement massRequirement; + requirement pointingRequirement; + requirement dataRequirement; + + part telescope : MirrorAssembly { + satisfy massRequirement; + } + part mount : Subsystem { + satisfy pointingRequirement; + } + part groundStation { + satisfy massRequirement; + } + + verification def MassTest; + verification massVerification : MassTest { + objective { + verify massRequirement; + } + } + verification pointingVerification : MassTest { + objective { + verify pointingRequirement; + verify massRequirement; + } + } + + calc def Matrix :> Query { + in root : Element; + Project( + source = WhereType( + source = Descendants(source = root, maxDepth = 1), + type = "RequirementUsage" + ), + properties = ("name"), + columns = ( + RelatedColumn("satisfiedBy", "satisfaction", "incoming", 1), + RelatedColumn("verifiedBy", "verification", "incoming", 1), + RelatedColumn("verifications", "verification", "incoming", 1, "count"), + RelatedColumn("verified", "verification", "incoming", 1, "any") + ) + ) + } + + calc def Traced :> Query { + in root : Element; + in kind : String; + in direction : String; + in maxDepth : Integer = 1; + in aggregate : String = "list"; + Project( + source = root, + properties = ("name"), + columns = ( + RelatedColumn( + name = "related", + relationshipKind = kind, + direction = direction, + maxDepth = maxDepth, + aggregate = aggregate + ) + ) + ) + } + + calc def Targeted :> Query { + in root : Element; + in targets : Element[0..*] ordered; + Project( + source = WhereType( + source = Descendants(source = root, maxDepth = 1), + type = "RequirementUsage" + ), + properties = ("name"), + columns = ( + RelatedColumn( + name = "satisfiedBy", + relationshipKind = "satisfaction", + direction = "incoming", + aggregate = "list", + targets = targets + ) + ) + ) + } + + calc def Unbounded :> Query { + in root : Element; + Project( + source = root, + properties = ("name"), + columns = ( + RelatedColumn( + name = "generals", + relationshipKind = "specialization", + direction = "outgoing", + maxDepth = null + ) + ) + ) + } + + calc def Uncovered :> Query { + in root : Element; + WhereFeature( + source = Matrix(root = root), + 'feature' = "verifications", + operator = "=", + value = "0" + ) + } + + calc def Unverified :> Query { + in root : Element; + WhereFeature( + source = Matrix(root = root), + 'feature' = "verified", + operator = "=", + value = "false" + ) + } + + calc def MostVerified :> Query { + in root : Element; + OrderBy( + source = Matrix(root = root), + property = "verifications", + direction = "descending", + missing = "last", + multiple = "error" + ) + } + + calc def SatisfiedBy :> Query { + in root : Element; + in operator : String; + in satisfier : String; + WhereFeature( + source = Matrix(root = root), + 'feature' = "satisfiedBy", + operator = operator, + value = satisfier + ) + } + + calc def BySatisfier :> Query { + in root : Element; + in multiple : String; + OrderBy( + source = Matrix(root = root), + property = "satisfiedBy", + direction = "ascending", + missing = "last", + multiple = multiple + ) + } +} diff --git a/internal/doc/queryexec/value.go b/internal/doc/queryexec/value.go new file mode 100644 index 0000000000..a37a2301ff --- /dev/null +++ b/internal/doc/queryexec/value.go @@ -0,0 +1,297 @@ +// Package queryexec executes immutable document-query plans. +package queryexec + +import ( + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// ValueKind classifies one scalar query value. +type ValueKind string + +const ( + ValueElement ValueKind = "element" + // ValueObject is a runtime object a session holds, `car.wheels[2]`, as opposed + // to the model element declaring it. + ValueObject ValueKind = "object" + // ValueVerdict is an assertion checked about an object, carried as a row whose + // declaration is the asserting element. + ValueVerdict ValueKind = "verdict" + // ValueState is one active state of an object's state machine, carried as a + // row whose declaration is the state. + ValueState ValueKind = "state" + // ValueEvent is one record of a session's trace, carried as a row whose + // declaration is the behavior that made it. + ValueEvent ValueKind = "event" + ValueString ValueKind = "string" + ValueInteger ValueKind = "integer" + ValueReal ValueKind = "real" + ValueBoolean ValueKind = "boolean" + ValueInfinity ValueKind = "infinity" + // ValueQuantity is a magnitude in a measurement unit, `2290000 [kg]`. + ValueQuantity ValueKind = "quantity" +) + +// Value is one immutable scalar carried through query execution. +type Value struct { + kind ValueKind + element *symbols.Symbol + object *runtime.Instance + verdict *Verdict + state *State + event *Event + text string + integer int64 + real float64 + boolean bool + quantity *semantics.Quantity + origin symbols.Origin +} + +// ElementValue constructs an element value with declaration provenance. +func ElementValue(sym *symbols.Symbol) Value { + return Value{kind: ValueElement, element: sym, origin: sym.Origin()} +} + +// ObjectValue constructs a runtime object value under the label a session +// reaches it by (`car.wheels[2]`, `#7`), with its declaration as provenance. +func ObjectValue(inst *runtime.Instance, label string) Value { + value := Value{kind: ValueObject, object: inst, text: label} + if inst != nil { + value.origin = objectDeclaration(inst).Origin() + } + return value +} + +// StringValue constructs a string value. +func StringValue(value string) Value { + return Value{kind: ValueString, text: value} +} + +// IntegerValue constructs an integer value. +func IntegerValue(value int64) Value { + return Value{kind: ValueInteger, integer: value} +} + +// RealValue constructs a real value. +func RealValue(value float64) Value { + return Value{kind: ValueReal, real: value} +} + +// BooleanValue constructs a Boolean value. +func BooleanValue(value bool) Value { + return Value{kind: ValueBoolean, boolean: value} +} + +// QuantityValue constructs a quantity value from a magnitude in a unit; the +// quantity is deep-copied, so the value stays immutable. +func QuantityValue(quantity semantics.Quantity) Value { + clone := quantity.Clone() + return Value{kind: ValueQuantity, quantity: &clone} +} + +// constantValue converts a folded semantic constant to the query value of the +// same kind. +func constantValue(constant semantics.Value) (Value, bool) { + switch constant.Kind { + case semantics.ValInt: + return IntegerValue(constant.Int), true + case semantics.ValReal: + return RealValue(constant.Real), true + case semantics.ValBool: + return BooleanValue(constant.Bool), true + case semantics.ValInfinity: + return Value{kind: ValueInfinity}, true + default: + return Value{}, false + } +} + +func valueAt(value Value, origin symbols.Origin) Value { + value.origin = origin + return value +} + +// Kind returns the value's scalar kind. +func (v Value) Kind() ValueKind { return v.kind } + +// Element returns the value's element and whether it is an element value. +func (v Value) Element() (*symbols.Symbol, bool) { + return v.element, v.kind == ValueElement && v.element != nil +} + +// Declaration returns the element a value is declared by: an element itself, the +// usage or definition an object stands for, the assertion a verdict is about, +// the state a state row is of, the behavior an event row came from (its object's +// declaration when the record names none), and nil for a scalar. +func (v Value) Declaration() *symbols.Symbol { + if inst, _, ok := v.Object(); ok { + return objectDeclaration(inst) + } + if verdict, ok := v.Verdict(); ok { + return verdict.assertion + } + if state, ok := v.State(); ok { + return state.Declaration() + } + if event, ok := v.Event(); ok { + if behavior := event.Behavior(); behavior != nil { + return behavior + } + if inst, _ := event.Object(); inst != nil { + return objectDeclaration(inst) + } + return nil + } + sym, _ := v.Element() + return sym +} + +// Object returns the value's runtime object, the label it is reached by, and +// whether it is an object value. +func (v Value) Object() (*runtime.Instance, string, bool) { + if v.kind != ValueObject || v.object == nil { + return nil, "", false + } + return v.object, v.text, true +} + +// String returns the value's string and whether it is a string value. +func (v Value) String() (string, bool) { return v.text, v.kind == ValueString } + +// Integer returns the value's integer and whether it is an integer value. +func (v Value) Integer() (int64, bool) { return v.integer, v.kind == ValueInteger } + +// Real returns the value's real and whether it is a real value. +func (v Value) Real() (float64, bool) { return v.real, v.kind == ValueReal } + +// Boolean returns the value's Boolean and whether it is a Boolean value. +func (v Value) Boolean() (bool, bool) { return v.boolean, v.kind == ValueBoolean } + +// Quantity returns an independent copy of the value's quantity and whether it +// is a quantity value. +func (v Value) Quantity() (semantics.Quantity, bool) { + if v.kind != ValueQuantity || v.quantity == nil { + return semantics.Quantity{}, false + } + return v.quantity.Clone(), true +} + +// Magnitude returns a quantity value's magnitude as the bare integer or real +// value it is, and whether the value is a quantity. +func (v Value) Magnitude() (Value, bool) { + quantity, ok := v.Quantity() + if !ok { + return Value{}, false + } + magnitude, ok := constantValue(quantity.Num) + return valueAt(magnitude, v.origin), ok +} + +// Origin returns the source declaration behind the value. +func (v Value) Origin() symbols.Origin { return v.origin } + +// Bindings supplies named values to an entry query. +type Bindings map[string][]Value + +// Column describes one ordered projected property. +type Column struct { + name string + origin symbols.Origin +} + +// Name returns the projected property name. +func (c Column) Name() string { return c.name } + +// Origin returns the query expression that projected the column. +func (c Column) Origin() symbols.Origin { return c.origin } + +// Cell is one immutable projected value sequence. +type Cell struct { + values []Value + origin symbols.Origin +} + +// Values returns an independent copy of the cell values. +func (c Cell) Values() []Value { return append([]Value(nil), c.values...) } + +// Origin returns the selected model element behind the cell. +func (c Cell) Origin() symbols.Origin { return c.origin } + +// isRow reports whether a value can be a query row: an element, an object, a +// verdict, a state or an event. +func (v Value) isRow() bool { + switch v.kind { + case ValueElement: + return v.element != nil + case ValueObject: + return v.object != nil + case ValueVerdict: + return v.verdict != nil + case ValueState: + return v.state != nil + case ValueEvent: + return v.event != nil + } + return false +} + +// Row retains the selected element, object, verdict, state or event and its +// ordered projected cells. +type Row struct { + element Value + cells []Cell +} + +// Element returns the selected value: a model element, a runtime object when +// the query ran over a session's objects, a verdict about one, a state one is +// in, or an event of the session's trace. +func (r Row) Element() Value { return r.element } + +// Cells returns an independent copy of the row's projected cells. +func (r Row) Cells() []Cell { + out := make([]Cell, len(r.cells)) + for i, cell := range r.cells { + out[i] = Cell{values: cell.Values(), origin: cell.origin} + } + return out +} + +// Origin returns the selected element's declaration provenance. +func (r Row) Origin() symbols.Origin { return r.element.origin } + +// RowSet is an immutable ordered query result. +type RowSet struct { + columns []Column + rows []Row + origin symbols.Origin +} + +// Columns returns an independent copy of the projected columns. +func (r *RowSet) Columns() []Column { + if r == nil { + return nil + } + return append([]Column(nil), r.columns...) +} + +// Rows returns an independent copy of the result rows. +func (r *RowSet) Rows() []Row { + if r == nil { + return nil + } + out := make([]Row, len(r.rows)) + for i, row := range r.rows { + out[i] = Row{element: row.element, cells: row.Cells()} + } + return out +} + +// Origin returns the entry query declaration. +func (r *RowSet) Origin() symbols.Origin { + if r == nil { + return symbols.Origin{} + } + return r.origin +} diff --git a/internal/doc/queryexec/verdict.go b/internal/doc/queryexec/verdict.go new file mode 100644 index 0000000000..0b66820294 --- /dev/null +++ b/internal/doc/queryexec/verdict.go @@ -0,0 +1,170 @@ +package queryexec + +import ( + "errors" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// Verdict is one row of Verdicts: an assertion checked about one object, and +// how it came out. Immutable once built. +type Verdict struct { + kind runtime.AssertionKind + assertion *symbols.Symbol + requirement *symbols.Symbol + text string + carrier *runtime.Instance + carrierDecl *symbols.Symbol + path string + status runtime.ValidationStatus + condition string + reason string + verification []runtime.VerdictKind + held bool +} + +// AssertionVerification is the kind of a verdict row reporting a verification +// case's run, following the requirement verdict it verifies. +const AssertionVerification runtime.AssertionKind = "verification" + +// Kind is the kind of assertion checked: constraint, requirement, satisfaction +// or verification. +func (v Verdict) Kind() runtime.AssertionKind { return v.kind } + +// Assertion is the asserting element: the constraint, requirement, satisfy +// usage or verification case. +func (v Verdict) Assertion() *symbols.Symbol { return v.assertion } + +// Requirement is the requirement a requirement, satisfaction or verification +// verdict is about; nil for a constraint. +func (v Verdict) Requirement() *symbols.Symbol { return v.requirement } + +// Text is the assertion as written (`constraint massLimit`), naming an anonymous one. +func (v Verdict) Text() string { return v.text } + +// Carrier is the object the assertion was checked on, and whether the query's +// session holds it rather than the check having materialized it as declared. +func (v Verdict) Carrier() (*runtime.Instance, bool) { return v.carrier, v.held } + +// CarrierDeclaration is the element the carrier stands for in the model. +func (v Verdict) CarrierDeclaration() *symbols.Symbol { return v.carrierDecl } + +// Path names the carrier from the row checked: its label, then the features +// walked to the carrier (`car.wheels[2]`). +func (v Verdict) Path() string { return v.path } + +// Status is how the assertion came out. +func (v Verdict) Status() runtime.ValidationStatus { return v.status } + +// Condition is the condition that evaluated to false on a violated verdict. +func (v Verdict) Condition() string { return v.condition } + +// Reason is why the assertion is violated or undecided; empty when it holds. +func (v Verdict) Reason() string { return v.reason } + +// Verification is the verdict kinds of the verification cases the row reports: +// its own case on a verification row, those verifying its requirement otherwise. +func (v Verdict) Verification() []runtime.VerdictKind { + return append([]runtime.VerdictKind(nil), v.verification...) +} + +// Label names the verdict: the assertion as written on its carrier's path. +func (v Verdict) Label() string { + if v.path == "" { + return v.text + } + return v.text + " on " + v.path +} + +// Summary is the verdict in one line: its label and status. +func (v Verdict) Summary() string { + return v.Label() + ": " + v.status.String() +} + +// VerdictValue constructs a verdict value; its declaration is the assertion. +func VerdictValue(verdict Verdict) Value { + return Value{kind: ValueVerdict, verdict: &verdict, origin: verdict.assertion.Origin()} +} + +// Verdict returns the value's verdict and whether it is a verdict value. +func (v Value) Verdict() (Verdict, bool) { + if v.kind != ValueVerdict || v.verdict == nil { + return Verdict{}, false + } + return *v.verdict, true +} + +// assertionVerdict converts one verdict of a validation report asked of the row +// labelled label; held is whether the session holds the objects checked. +func assertionVerdict(v runtime.ObjectVerdict, label string, held bool, verification []runtime.VerdictKind) Verdict { + out := Verdict{ + kind: v.Kind, + assertion: v.Element, + requirement: v.Requirement, + text: v.Text, + carrier: v.Subject, + path: carrierPath(label, v.Path), + status: v.Status, + verification: append([]runtime.VerdictKind(nil), verification...), + held: held, + } + if v.Subject != nil { + out.carrierDecl = objectDeclaration(v.Subject) + } + if v.Err != nil { + out.reason = v.Err.Error() + var violation *runtime.ViolationError + if errors.As(v.Err, &violation) { + out.condition = violation.Condition + } + } + if out.status == runtime.ValidationUndecided && out.reason == "" { + out.reason = "the assertion could not be evaluated" + } + return out +} + +// verificationVerdict converts a verification case's verdict to a row beside +// the verdict about the requirement it verifies. +func verificationVerdict(about Verdict, v runtime.VerificationVerdict) Verdict { + out := Verdict{ + kind: AssertionVerification, + assertion: v.Symbol, + requirement: about.requirement, + text: "verification " + v.Case, + carrier: about.carrier, + carrierDecl: about.carrierDecl, + path: about.path, + reason: v.Detail, + verification: []runtime.VerdictKind{v.Kind}, + held: about.held, + } + if v.Subcase { + out.text += " (subcase)" + } + switch v.Kind { + case runtime.VerdictPass: + out.status = runtime.ValidationHolds + case runtime.VerdictFail: + out.status = runtime.ValidationViolated + if out.reason == "" { + out.reason = "the verification case answered fail" + } + default: + out.status = runtime.ValidationUndecided + if out.reason == "" { + out.reason = "the verification case answered " + string(v.Kind) + } + } + return out +} + +// carrierPath labels a carrier by the row it was reached from and the features walked to it. +func carrierPath(label string, path []string) string { + if len(path) == 0 { + return label + } + return label + "." + strings.Join(path, ".") +} diff --git a/internal/doc/queryexec/verdicts.go b/internal/doc/queryexec/verdicts.go new file mode 100644 index 0000000000..7a722919fc --- /dev/null +++ b/internal/doc/queryexec/verdicts.go @@ -0,0 +1,250 @@ +package queryexec + +import ( + "errors" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// Verdict rows report the assertions checked about an object: a session's +// object as it stands, or a model element's object as declared. + +// verdictKinds are the values the kind argument of Verdicts accepts. +var verdictKinds = map[string]runtime.AssertionKind{ + "all": "", + "constraint": runtime.AssertionConstraint, + "requirement": runtime.AssertionRequirement, + "satisfaction": runtime.AssertionSatisfaction, + "verification": AssertionVerification, +} + +// verdictFQN declares the verdict properties a column expression may reference. +const verdictFQN = "DocumentQueries::Verdict" + +// Verdict properties, beside the metadata every row answers. +const ( + propertyAssertion = "assertion" + propertyKind = "kind" + propertyCarrier = "carrier" + propertyPath = "path" + propertyVerdict = "verdict" + propertyCondition = "condition" + propertyReason = "reason" + propertyVerification = "verification" +) + +// evaluateVerdicts checks the assertions about each source row's object and the +// objects it holds, one row per verdict, in the order the check reached them. +func (e *executor) evaluateVerdicts(expression queryplan.Expression) (sequence, error) { + source, err := e.rowArgument(expression, "source") + if err != nil { + return sequence{}, err + } + kindName := "all" + if hasArgument(expression, "kind") { + if kindName, err = e.stringArgument(expression, "kind"); err != nil { + return sequence{}, err + } + } + wanted, ok := verdictKinds[kindName] + if !ok { + return sequence{}, e.invalidArgument(expression, "kind", kindName) + } + var result sequence + for _, row := range source.values { + verdicts, err := e.verdictsOf(expression, row) + if err != nil { + return sequence{}, err + } + for _, verdict := range verdicts { + if !e.consumeVisit() { + return sequence{}, e.budgetError(expression) + } + if wanted == "" || verdict.kind == wanted { + result.values = append(result.values, VerdictValue(verdict)) + } + } + } + return result, nil +} + +// verdictsOf checks one row's object: a session object in the session, an +// element's declared object in the behavior-free declared context. +func (e *executor) verdictsOf(expression queryplan.Expression, row Value) ([]Verdict, error) { + scopes := e.assertionScopes() + if inst, label, isObject := row.Object(); isObject { + report, err := e.context.Runtime.ValidateObject(inst, scopes) + if err != nil { + return nil, e.incompleteValidation(expression, row, err) + } + return e.reportVerdicts(expression, row, report, label, true, e.context.Runtime, scopes) + } + if verdict, isVerdict := row.Verdict(); isVerdict { + return nil, e.verdictRowError(expression, "source", verdict) + } + sym, _ := row.Element() + reader := e.derived.get(e.context) + report, err := reader.Validate(sym, scopes) + if err != nil { + if errors.Is(err, runtime.ErrNotAnObject) { + return nil, &Error{ + Kind: ErrorNotAnObject, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: "source", + Target: symbols.FQNOf(sym), + Origin: expression.Origin(), + Cause: err, + } + } + return nil, e.incompleteValidation(expression, row, err) + } + return e.reportVerdicts(expression, row, report, symbols.FQNOf(sym), false, reader, scopes) +} + +// reportVerdicts converts a validation report to verdict rows about the row it +// was asked of; a verification row follows each requirement it verifies, once +// per requirement. A walk that left objects unreached is a typed error. +func (e *executor) reportVerdicts( + expression queryplan.Expression, + row Value, + report runtime.ValidationReport, + label string, + held bool, + verifier verifier, + scopes []*symbols.Scope, +) ([]Verdict, error) { + if len(report.Unread) > 0 { + return nil, e.incompleteValidation(expression, row, report.Unread[0]) + } + if report.Bounded { + return nil, e.incompleteValidation(expression, row, errUnreachedObjects) + } + out := make([]Verdict, 0, len(report.Verdicts)) + verified := make(map[*symbols.Symbol]bool) + for _, v := range report.Verdicts { + var cases []runtime.VerificationVerdict + if v.Requirement != nil { + cases = e.derived.verifications(verifier, scopes, v.Requirement) + } + verdict := assertionVerdict(v, label, held, verificationKinds(cases)) + out = append(out, verdict) + if v.Requirement == nil || verified[v.Requirement] { + continue + } + verified[v.Requirement] = true + for _, c := range cases { + out = append(out, verificationVerdict(verdict, c)) + } + } + return out, nil +} + +// incompleteValidation reports a row whose assertions could not all be checked. +func (e *executor) incompleteValidation(expression queryplan.Expression, row Value, cause error) error { + return &Error{ + Kind: ErrorIncompleteValidation, + Query: e.definition.Name(), + Operation: expression.Operation(), + Parameter: "source", + Target: rowTarget(row), + Origin: expression.Origin(), + Cause: cause, + } +} + +// errUnreachedObjects reports a validation walk that stopped short of the +// objects the root holds, whose assertions therefore went unchecked. +var errUnreachedObjects = errors.New("objects nested beyond the depth the validation descends were not checked") + +// assertionScopes are the document scopes satisfactions and verification cases +// are looked for in: the workspace's documents, libraries aside. +func (e *executor) assertionScopes() []*symbols.Scope { + names := e.context.Index.WorkspaceDocuments() + out := make([]*symbols.Scope, 0, len(names)) + for _, name := range names { + if root := e.context.Index.DocumentRoot(name); root != nil { + out = append(out, root) + } + } + return out +} + +// verificationKinds lists the kinds of a requirement's verification verdicts. +func verificationKinds(cases []runtime.VerificationVerdict) []runtime.VerdictKind { + if len(cases) == 0 { + return nil + } + out := make([]runtime.VerdictKind, 0, len(cases)) + for _, c := range cases { + out = append(out, c.Kind) + } + return out +} + +// verifier runs the verification cases verifying a requirement: a session's +// runtime context, or the declared reader's behavior-free one. +type verifier interface { + VerificationVerdictsIn(scopes []*symbols.Scope, req *symbols.Symbol) []runtime.VerificationVerdict +} + +// verdictPropertyValues reads a property of a verdict row: the verdict's own +// properties first, then the metadata of the assertion it is about. +func (e *executor) verdictPropertyValues(row Value, property string) ([]Value, bool, error) { + verdict, _ := row.Verdict() + origin := row.Origin() + text := func(values ...string) []Value { + out := make([]Value, 0, len(values)) + for _, value := range values { + out = append(out, valueAt(StringValue(value), origin)) + } + return out + } + switch property { + case propertyAssertion: + if verdict.assertion == nil { + return nil, true, nil + } + return []Value{valueAt(ElementValue(verdict.assertion), origin)}, true, nil + case propertyKind: + return text(string(verdict.kind)), true, nil + case propertyCarrier: + if verdict.carrier == nil { + return nil, true, nil + } + if verdict.held { + return []Value{valueAt(ObjectValue(verdict.carrier, verdict.path), origin)}, true, nil + } + if verdict.carrierDecl == nil { + return nil, true, nil + } + return []Value{valueAt(ElementValue(verdict.carrierDecl), origin)}, true, nil + case propertyPath: + return text(verdict.path), true, nil + case propertyVerdict: + return text(verdict.status.String()), true, nil + case propertyCondition: + if verdict.condition == "" { + return nil, true, nil + } + return text(verdict.condition), true, nil + case propertyReason: + if verdict.reason == "" { + return nil, true, nil + } + return text(verdict.reason), true, nil + case propertyVerification: + kinds := verdict.verification + out := make([]string, 0, len(kinds)) + for _, kind := range kinds { + out = append(out, string(kind)) + } + return text(out...), true, nil + } + if verdict.assertion == nil { + return nil, false, nil + } + return e.propertyValues(ElementValue(verdict.assertion), property) +} diff --git a/internal/doc/queryexec/verdicts_test.go b/internal/doc/queryexec/verdicts_test.go new file mode 100644 index 0000000000..95de5b8bd5 --- /dev/null +++ b/internal/doc/queryexec/verdicts_test.go @@ -0,0 +1,527 @@ +package queryexec + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// verdictBody declares a car whose own assertions hold, decide nothing (an +// unvalued capacity) and fail on the objects it holds, a satisfaction about its +// nested engine, and verification cases about the engine's requirement. +const verdictBody = ` +part def Injector { + attribute rate : Real = 2.0; + assert constraint ratePositive { rate > 0.0 } +} +part def Engine { + attribute power : Real = 300.0; + part injector : Injector; + assert constraint powerLow { power < 200.0 } +} +part def Wheel { + attribute pressure : Real default = 32.0; + assert constraint pressureOk { pressure >= 30.0 } +} +part def Car { + attribute mass : Real = 1500.0; + attribute capacity : Real; + part engine : Engine; + part wheels : Wheel[3] { + attribute :>> pressure = 20.0; + } + assert constraint massOk { mass < 2000.0 } + assert constraint fits { mass <= capacity } + requirement lightEnough { + attribute m : Real = mass; + require constraint { m < 1600.0 } + } +} +requirement def PowerReq { + subject e : Engine; + require constraint { e.power > 100.0 } +} +part car : Car; +requirement strongEngine : PowerReq; +satisfy strongEngine by car.engine; +verification def PowerCheck { + subject e : Engine; + objective { verify strongEngine; } + VerificationCases::PassIf(e.power > 100.0) +} +verification checkEngine : PowerCheck { subject e = car.engine; } +verification def Silent { + subject e : Engine; + objective { verify strongEngine; } + action measure; +} +verification silentCheck : Silent { subject e = car.engine; } +part def Bare; +part bare : Bare; +` + +const verdictQueries = ` +calc def All :> Query { + in root : Element; + Project( + source = Verdicts(source = root), + properties = ("kind", "assertion", "path", "verdict", "condition", "reason", "verification") + ) +} +calc def Kinds :> Query { + in root : Element; + in kind : String; + Project(source = Verdicts(source = root, kind = kind), properties = ("assertion", "path", "verdict")) +} +calc def Violated :> Query { + in root : Element; + Project( + source = WhereFeature(source = Verdicts(source = root), 'feature' = "verdict", operator = "=", value = "violated"), + properties = ("path", "assertion", "reason") + ) +} +calc def ByPath :> Query { + in root : Element; + Project( + source = OrderBy( + source = Verdicts(source = root, kind = "constraint"), + property = "path", + direction = "descending", + missing = "last", + multiple = "first" + ), + properties = ("path", "name") + ) +} +calc def Requirements :> Query { + in root : Element; + Project(source = WhereType(source = Verdicts(source = root), type = "RequirementUsage"), properties = ("path", "verdict")) +} +calc def Carriers :> Query { + in root : Element; + Project(source = Verdicts(source = root, kind = "constraint"), properties = ("carrier", "qualifiedName")) +} +calc def NamedOk :> Query { + in root : Element; + Project(source = WhereName(source = Verdicts(source = root), operator = "ends-with", value = "Ok"), properties = ("path", "verdict")) +} +calc def Summary :> Query { + in root : Element; + Project( + source = Verdicts(source = root, kind = "constraint"), + columns = (Column(name = "summary", expression = Verdict::path + ": " + Verdict::verdict)) + ) +} +calc def Verified :> Query { + in root : Element; + Project( + source = Verdicts(source = root, kind = "verification"), + columns = (Column(name = "verified", expression = Verdict::path + ": " + Verdict::'verification')) + ) +} +calc def Columns :> Query { + in root : Element; + Project( + source = Verdicts(source = root, kind = "constraint"), + columns = ( + Column(name = "assertion", expression = Verdict::assertion), + Column(name = "carrier", expression = Verdict::carrier) + ) + ) +} +calc def Rows :> Query { + in root : Element; + Verdicts(source = root) +} +calc def Twice :> Query { + in root : Element; + Verdicts(source = Verdicts(source = root)) +} +calc def Related :> Query { + in root : Element; + RelatedElements(source = Verdicts(source = root), relationshipKind = "satisfaction", direction = "incoming", maxDepth = 1) +} +calc def Owned :> Query { + in root : Element; + OwnedElements(source = Verdicts(source = root)) +} +calc def Wrong :> Query { + in root : Element; + Verdicts(source = root, kind = "maybe") +} +` + +type verdictFixture struct { + executionFixture + ctx *runtime.Context + car *runtime.Instance +} + +func loadVerdictFixture(t *testing.T) verdictFixture { + t.Helper() + fixture := loadExecutionFixture(t, verdictBody+verdictQueries) + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + car, err := ctx.Instantiate(fixture.symbol(t, "car")) + if err != nil { + t.Fatalf("Instantiate car: %v", err) + } + return verdictFixture{executionFixture: fixture, ctx: ctx, car: car} +} + +func (f verdictFixture) session() Context { + return Context{ + Index: f.index, Resolver: f.resolver, Model: f.model, Runtime: f.ctx, + Roots: []Root{{Label: "car", Object: f.car}}, + } +} + +func (f verdictFixture) modelOnly() Context { + return Context{Index: f.index, Resolver: f.resolver, Model: f.model} +} + +func (f verdictFixture) rows(t *testing.T, context Context, name string, bindings Bindings) *RowSet { + t.Helper() + result, err := Execute(f.program(t, name), context, bindings, Options{}) + if err != nil { + t.Fatalf("execute %s: %v", name, err) + } + return result +} + +func (f verdictFixture) write(t *testing.T, inst *runtime.Instance, feature string, value float64) { + t.Helper() + err := inst.SetFeatureValue(f.ctx, feature, runtime.Value{ + Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: value}, + }) + if err != nil { + t.Fatalf("write %s: %v", feature, err) + } +} + +// optionalTexts renders a column whose cells hold one string or nothing. +func optionalTexts(t *testing.T, result *RowSet, column int) []string { + t.Helper() + var out []string + for _, row := range result.Rows() { + values := row.Cells()[column].Values() + switch len(values) { + case 0: + out = append(out, "") + case 1: + text, ok := values[0].String() + if !ok { + t.Fatalf("cell %d holds an unexpected %s", column, values[0].Kind()) + } + out = append(out, text) + default: + t.Fatalf("cell %d has %d values", column, len(values)) + } + } + return out +} + +// listTexts renders a column whose cells hold any number of strings, joined. +func listTexts(t *testing.T, result *RowSet, column int) []string { + t.Helper() + var out []string + for _, row := range result.Rows() { + var parts []string + for _, value := range row.Cells()[column].Values() { + text, ok := value.String() + if !ok { + t.Fatalf("cell %d holds an unexpected %s", column, value.Kind()) + } + parts = append(parts, text) + } + out = append(out, strings.Join(parts, "+")) + } + return out +} + +// The verdicts about a session object walk the object and every object it holds +// in the validation's order: the car's own assertions, then each held object's, +// the satisfaction about the engine grouped with the engine, and the +// verification cases verifying a requirement following its first verdict. +func TestExecuteVerdictsAboutASessionObject(t *testing.T) { + fixture := loadVerdictFixture(t) + root := Bindings{"root": {ObjectValue(fixture.car, "car")}} + all := fixture.rows(t, fixture.session(), "All", root) + + wantKinds := "constraint,constraint,requirement,constraint,satisfaction,verification,verification,constraint,constraint,constraint,constraint" + if got := cellTexts(t, all, 0); strings.Join(got, ",") != wantKinds { + t.Fatalf("kinds = %v\nwant %s", got, wantKinds) + } + wantPaths := "car,car,car,car.engine,car.engine,car.engine,car.engine,car.engine.injector,car.wheels[1],car.wheels[2],car.wheels[3]" + if got := cellTexts(t, all, 2); strings.Join(got, ",") != wantPaths { + t.Fatalf("paths = %v\nwant %s", got, wantPaths) + } + wantStatus := "holds,undecided,holds,violated,holds,holds,undecided,holds,violated,violated,violated" + if got := cellTexts(t, all, 3); strings.Join(got, ",") != wantStatus { + t.Fatalf("verdicts = %v\nwant %s", got, wantStatus) + } + assertions := all.Rows() + if sym, ok := assertions[0].Cells()[1].Values()[0].Element(); !ok || sym.Name != "massOk" { + t.Fatalf("first assertion = %v, want massOk", assertions[0].Cells()[1].Values()) + } + if sym, ok := assertions[4].Cells()[1].Values()[0].Element(); !ok || sym.Name != "" { + t.Fatalf("satisfaction assertion = %v, want the anonymous satisfy usage", assertions[4].Cells()[1].Values()) + } + if sym, ok := assertions[5].Cells()[1].Values()[0].Element(); !ok || sym.Name != "checkEngine" { + t.Fatalf("verification assertion = %v, want checkEngine", assertions[5].Cells()[1].Values()) + } + + conditions := optionalTexts(t, all, 4) + if conditions[3] != "power < 200.0" || conditions[8] != "pressure >= 30.0" || conditions[0] != "" || conditions[1] != "" { + t.Fatalf("conditions = %q", conditions) + } + reasons := optionalTexts(t, all, 5) + if reasons[0] != "" || reasons[4] != "" { + t.Fatalf("holding verdicts carry reasons: %q", reasons) + } + if !strings.Contains(reasons[1], "capacity") { + t.Fatalf("undecided reason = %q, want the unvalued capacity named", reasons[1]) + } + if !strings.Contains(reasons[3], "power < 200.0") { + t.Fatalf("violated reason = %q, want the condition named", reasons[3]) + } + if reasons[6] == "" { + t.Fatalf("an inconclusive verification carries no reason: %q", reasons) + } + // Every verdict about strongEngine reports the kinds its verification cases answered. + verifications := listTexts(t, all, 6) + if verifications[4] != "pass+inconclusive" || verifications[5] != "pass" || verifications[6] != "inconclusive" { + t.Fatalf("verifications = %q", verifications) + } + if verifications[0] != "" || verifications[2] != "" { + t.Fatalf("assertions no case verifies report verifications: %q", verifications) + } +} + +func TestExecuteVerdictsSelectsAKind(t *testing.T) { + fixture := loadVerdictFixture(t) + root := Bindings{"root": {ObjectValue(fixture.car, "car")}} + for kind, want := range map[string]string{ + "constraint": "car,car,car.engine,car.engine.injector,car.wheels[1],car.wheels[2],car.wheels[3]", + "requirement": "car", + "satisfaction": "car.engine", + "verification": "car.engine,car.engine", + "all": "car,car,car,car.engine,car.engine,car.engine,car.engine,car.engine.injector,car.wheels[1],car.wheels[2],car.wheels[3]", + } { + rows := fixture.rows(t, fixture.session(), "Kinds", Bindings{"root": root["root"], "kind": {StringValue(kind)}}) + if got := cellTexts(t, rows, 1); strings.Join(got, ",") != want { + t.Fatalf("Verdicts(kind = %s) paths = %v, want %s", kind, got, want) + } + } + _, err := Execute(fixture.program(t, "Wrong"), fixture.session(), root, Options{}) + if invalid := executionError(t, err, ErrorInvalidArgument); invalid.Parameter != "kind" { + t.Fatalf("invalid kind error = %v", invalid) + } +} + +// Verdict rows go through the row operations by their own properties: filtered +// and ordered by verdict and path, typed and named by their assertion, and +// their carrier projected as the object checked. +func TestExecuteVerdictRowsThroughRowOperations(t *testing.T) { + fixture := loadVerdictFixture(t) + root := Bindings{"root": {ObjectValue(fixture.car, "car")}} + session := fixture.session() + + violated := fixture.rows(t, session, "Violated", root) + if got := cellTexts(t, violated, 0); strings.Join(got, ",") != "car.engine,car.wheels[1],car.wheels[2],car.wheels[3]" { + t.Fatalf("violated paths = %v", got) + } + byPath := fixture.rows(t, session, "ByPath", root) + if got := cellTexts(t, byPath, 0); strings.Join(got, ",") != "car.wheels[3],car.wheels[2],car.wheels[1],car.engine.injector,car.engine,car,car" { + t.Fatalf("constraints by path descending = %v", got) + } + if got := optionalTexts(t, byPath, 1); got[0] != "pressureOk" || got[3] != "ratePositive" { + t.Fatalf("assertion names = %q", got) + } + // A satisfy usage is a requirement usage in the metamodel, so it is typed as one. + requirements := fixture.rows(t, session, "Requirements", root) + if got := cellTexts(t, requirements, 0); strings.Join(got, ",") != "car,car.engine" { + t.Fatalf("WhereType(RequirementUsage) paths = %v, want lightEnough and the satisfaction", got) + } + named := fixture.rows(t, session, "NamedOk", root) + if got := cellTexts(t, named, 0); strings.Join(got, ",") != "car,car.wheels[1],car.wheels[2],car.wheels[3]" { + t.Fatalf("WhereName(ends-with Ok) paths = %v", got) + } + carriers := fixture.rows(t, session, "Carriers", root) + cells := carriers.Rows()[2].Cells() + if inst, label, ok := cells[0].Values()[0].Object(); !ok || label != "car.engine" || inst.Type != fixture.symbol(t, "Engine") { + t.Fatalf("engine carrier = %v %q %v", inst, label, ok) + } + if got := cellTexts(t, carriers, 1); got[0] != "Observatory::Car::massOk" || got[3] != "Observatory::Injector::ratePositive" { + t.Fatalf("assertion qualified names = %v", got) + } + summary := fixture.rows(t, session, "Summary", root) + if got := cellTexts(t, summary, 0); got[3] != "car.engine.injector: holds" || got[4] != "car.wheels[1]: violated" { + t.Fatalf("computed summaries = %v", got) + } + columns := fixture.rows(t, session, "Columns", root) + cells = columns.Rows()[2].Cells() + if sym, ok := cells[0].Values()[0].Element(); !ok || sym != fixture.symbol(t, "Engine::powerLow") { + t.Fatalf("Verdict::assertion column = %v", cells[0].Values()) + } + if inst, label, ok := cells[1].Values()[0].Object(); !ok || label != "car.engine" || inst.Type != fixture.symbol(t, "Engine") { + t.Fatalf("Verdict::carrier column = %v %q %v", inst, label, ok) + } + verified := fixture.rows(t, session, "Verified", root) + if got := cellTexts(t, verified, 0); strings.Join(got, ",") != "car.engine: pass,car.engine: inconclusive" { + t.Fatalf("computed verification column = %v", got) + } + + // The result rows are verdict values declared by their assertion. + rows := fixture.rows(t, session, "Rows", root) + first := rows.Rows()[0].Element() + verdict, ok := first.Verdict() + if !ok || first.Kind() != ValueVerdict { + t.Fatalf("row = %s, want a verdict", first.Kind()) + } + if verdict.Assertion() != first.Declaration() || verdict.Assertion().Name != "massOk" { + t.Fatalf("declaration = %v, want massOk", first.Declaration()) + } + if carrier, held := verdict.Carrier(); carrier != fixture.car || !held { + t.Fatalf("carrier = %v %v, want the held car", carrier, held) + } + if verdict.Summary() != "assert constraint massOk on car: holds" { + t.Fatalf("summary = %q", verdict.Summary()) + } + if _, ok := first.Element(); ok { + t.Fatal("a verdict is not an element value") + } + if _, _, ok := first.Object(); ok { + t.Fatal("a verdict is not an object value") + } + + // A verdict is about an object; it is neither checked again, traced nor walked. + _, err := Execute(fixture.program(t, "Twice"), session, root, Options{}) + if refused := executionError(t, err, ErrorVerdictRow); !strings.Contains(refused.Error(), "not to verdict assert constraint massOk on car") { + t.Fatalf("verdict-row error = %v", refused) + } + for _, name := range []string{"Related", "Owned"} { + _, err = Execute(fixture.program(t, name), session, root, Options{}) + executionError(t, err, ErrorVerdictRow) + } +} + +// Verdicts read the objects as they stand: a value written since changes them. +func TestExecuteVerdictsReadCurrentValues(t *testing.T) { + fixture := loadVerdictFixture(t) + root := Bindings{"root": {ObjectValue(fixture.car, "car")}} + held, err := fixture.ctx.HeldObjects(fixture.car) + if err != nil { + t.Fatalf("held objects: %v", err) + } + fixture.write(t, held[0].Instance, "power", 150.0) + fixture.write(t, fixture.car, "capacity", 1400.0) + all := fixture.rows(t, fixture.session(), "All", root) + got := cellTexts(t, all, 3) + if got[1] != "violated" || got[3] != "holds" || got[4] != "holds" { + t.Fatalf("verdicts after writes = %v", got) + } + if got := optionalTexts(t, all, 5); !strings.Contains(got[1], "mass <= capacity") { + t.Fatalf("fits reason = %q", got[1]) + } +} + +// Asking again answers the same rows, and a validation after agrees with them: +// checking the object leaves no assertion restated about it. +func TestExecuteVerdictsAnswerTheSameRowsAgain(t *testing.T) { + fixture := loadVerdictFixture(t) + root := Bindings{"root": {ObjectValue(fixture.car, "car")}} + first := fixture.rows(t, fixture.session(), "All", root) + again := fixture.rows(t, fixture.session(), "All", root) + for _, column := range []int{0, 2, 3} { + before, after := cellTexts(t, first, column), cellTexts(t, again, column) + if strings.Join(before, ",") != strings.Join(after, ",") { + t.Fatalf("column %d changed between queries:\n%v\n%v", column, before, after) + } + } + var scopes []*symbols.Scope + for _, name := range fixture.index.WorkspaceDocuments() { + scopes = append(scopes, fixture.index.DocumentRoot(name)) + } + report, err := fixture.ctx.ValidateObject(fixture.car, scopes) + if err != nil { + t.Fatalf("validate after querying: %v", err) + } + if want := len(first.Rows()) - 2; len(report.Verdicts) != want { + t.Fatalf("validation after querying has %d verdicts, want %d", len(report.Verdicts), want) + } +} + +// An element row is checked as the model declares it, in the session and +// without one; an element declaring no object is a typed error. +func TestExecuteVerdictsAboutADeclaredElement(t *testing.T) { + fixture := loadVerdictFixture(t) + root := Bindings{"root": {ElementValue(fixture.symbol(t, "car"))}} + for name, context := range map[string]Context{"session": fixture.session(), "model": fixture.modelOnly()} { + all := fixture.rows(t, context, "All", root) + wantPaths := "Observatory::car,Observatory::car,Observatory::car,Observatory::car.engine,Observatory::car.engine," + + "Observatory::car.engine,Observatory::car.engine,Observatory::car.engine.injector," + + "Observatory::car.wheels[1],Observatory::car.wheels[2],Observatory::car.wheels[3]" + if got := cellTexts(t, all, 2); strings.Join(got, ",") != wantPaths { + t.Fatalf("%s: declared paths = %v", name, got) + } + if got := cellTexts(t, all, 3); strings.Join(got, ",") != "holds,undecided,holds,violated,holds,holds,undecided,holds,violated,violated,violated" { + t.Fatalf("%s: declared verdicts = %v", name, got) + } + // The carrier of a declared check is the element checked, not a session object. + carriers := fixture.rows(t, context, "Carriers", root) + if sym, ok := carriers.Rows()[2].Cells()[0].Values()[0].Element(); !ok || sym != fixture.symbol(t, "Car::engine") { + t.Fatalf("%s: engine carrier = %v, want the declared part", name, carriers.Rows()[2].Cells()[0].Values()) + } + columns := fixture.rows(t, context, "Columns", root) + if sym, ok := columns.Rows()[2].Cells()[1].Values()[0].Element(); !ok || sym != fixture.symbol(t, "Car::engine") { + t.Fatalf("%s: Verdict::carrier column = %v, want the declared part", name, columns.Rows()[2].Cells()[1].Values()) + } + rows := fixture.rows(t, context, "Rows", root) + verdict, _ := rows.Rows()[0].Element().Verdict() + if carrier, held := verdict.Carrier(); carrier == nil || held { + t.Fatalf("%s: declared carrier = %v %v, want an unheld object", name, carrier, held) + } + + // A definition is checked as declared too; a package declares no object. + definition := fixture.rows(t, context, "Kinds", + Bindings{"root": {ElementValue(fixture.symbol(t, "Wheel"))}, "kind": {StringValue("constraint")}}) + if got := cellTexts(t, definition, 2); strings.Join(got, ",") != "holds" { + t.Fatalf("%s: Wheel as declared = %v, want its default pressure to hold", name, got) + } + _, err := Execute(fixture.program(t, "Rows"), context, + Bindings{"root": {ElementValue(fixture.symbol(t, "Car::capacity"))}}, Options{}) + if notObject := executionError(t, err, ErrorNotAnObject); notObject.Target != "Observatory::Car::capacity" { + t.Fatalf("%s: not-an-object error = %v", name, notObject) + } + // An object stating no assertion has no verdicts: an empty result, not an error. + if bare := fixture.rows(t, context, "Rows", Bindings{"root": {ElementValue(fixture.symbol(t, "bare"))}}); len(bare.Rows()) != 0 { + t.Fatalf("%s: bare verdicts = %d rows, want none", name, len(bare.Rows())) + } + } +} + +// A walk that cannot reach every held object — a part holding another of its own +// type without end — is a typed error rather than a table missing its rows. +func TestExecuteVerdictsRefuseAnIncompleteWalk(t *testing.T) { + fixture := loadExecutionFixture(t, ` +part def Link { + attribute load : Real = 1.0; + part next : Link; + assert constraint loaded { load > 0.0 } +} +part chain : Link; +calc def Rows :> Query { + in root : Element; + Verdicts(source = root) +} +`) + _, err := Execute(fixture.program(t, "Rows"), Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model}, + Bindings{"root": {ElementValue(fixture.symbol(t, "chain"))}}, Options{}) + incomplete := executionError(t, err, ErrorIncompleteValidation) + if incomplete.Target != "Observatory::chain" || !strings.Contains(incomplete.Error(), "not checked") { + t.Fatalf("incomplete-validation error = %v", incomplete) + } +} diff --git a/internal/doc/queryexec/where_related.go b/internal/doc/queryexec/where_related.go new file mode 100644 index 0000000000..b8f075c039 --- /dev/null +++ b/internal/doc/queryexec/where_related.go @@ -0,0 +1,48 @@ +package queryexec + +import ( + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// evaluateWhereRelated keeps the source elements with a reachable related element +// (exists = true) or without one (exists = false). +func (e *executor) evaluateWhereRelated(expression queryplan.Expression) (sequence, error) { + source, err := e.elementArgument(expression, "source") + if err != nil { + return sequence{}, err + } + walk, err := e.relationshipArguments(expression) + if err != nil { + return sequence{}, err + } + exists := true + if hasArgument(expression, "exists") { + if exists, err = e.booleanArgument(expression, "exists"); err != nil { + return sequence{}, err + } + } + result := filtered(source) + for i, value := range source.values { + sym, _ := value.Element() + related, err := e.hasRelated(expression, walk, sym) + if err != nil { + return sequence{}, err + } + if related == exists { + appendSelected(&result, source, i) + } + } + return result, nil +} + +// hasRelated reports whether any element is reachable from sym over the walk, +// stopping at the first one found. +func (e *executor) hasRelated(expression queryplan.Expression, walk relationshipWalk, sym *symbols.Symbol) (bool, error) { + found := false + err := e.traverseRelated(expression, walk, []*symbols.Symbol{sym}, func(*symbols.Symbol) bool { + found = true + return false + }) + return found, err +} diff --git a/internal/doc/queryexec/where_related_test.go b/internal/doc/queryexec/where_related_test.go new file mode 100644 index 0000000000..fd8b9b6283 --- /dev/null +++ b/internal/doc/queryexec/where_related_test.go @@ -0,0 +1,297 @@ +package queryexec + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// coverageBody declares requirements with every coverage state: satisfied and +// verified, verified only, satisfied only, nested and covered by nothing. +const coverageBody = ` +part def Subsystem; +part def OpticalSubsystem :> Subsystem; +part def MirrorAssembly :> OpticalSubsystem; +part telescope : MirrorAssembly; +part def Wheel; +part def Car { + part wheels : Wheel[2]; +} +part car : Car; + +requirement def Traced { + requirement nested; +} +package Specification { + requirement massRequirement; + requirement pointingRequirement; + requirement dataRequirement : Traced; + requirement thermalRequirement { + requirement coolantRequirement; + } +} +part observatory { + satisfy Specification::massRequirement by telescope; + satisfy Specification::dataRequirement by car; +} +verification def MassTest; +verification massVerification : MassTest { + objective { verify Specification::massRequirement; } +} +verification pointingVerification : MassTest { + objective { verify Specification::pointingRequirement; } +} +` + +const coverageQueries = ` +calc def RequirementUsages :> Query { + in root : Element; + WhereType(source = Descendants(source = root, maxDepth = 3), type = "RequirementUsage") +} +calc def Unsatisfied :> Query { + in root : Element; + WhereRelated( + source = RequirementUsages(root = root), + relationshipKind = "satisfaction", + direction = "incoming", + maxDepth = 1, + exists = false + ) +} +calc def Satisfied :> Query { + in root : Element; + WhereRelated( + source = RequirementUsages(root = root), + relationshipKind = "satisfaction", + direction = "incoming", + maxDepth = 1 + ) +} +calc def Unverified :> Query { + in root : Element; + WhereRelated(RequirementUsages(root = root), "verification", "incoming", 1, false) +} +calc def Filtered :> Query { + in source : Element[0..*] ordered; + in kind : String; + in direction : String; + in maxDepth : Integer; + in exists : Boolean; + WhereRelated( + source = source, + relationshipKind = kind, + direction = direction, + maxDepth = maxDepth, + exists = exists + ) +} +calc def UnsatisfiedTable :> Query { + in root : Element; + WhereRelated( + source = Project(source = RequirementUsages(root = root), properties = ("name", "qualifiedName")), + relationshipKind = "satisfaction", + direction = "incoming", + maxDepth = 1, + exists = false + ) +} +` + +func loadCoverageFixture(t *testing.T) executionFixture { + t.Helper() + return loadExecutionFixture(t, coverageBody+coverageQueries) +} + +func (f executionFixture) filtered( + t *testing.T, + source []Value, + kind, direction string, + maxDepth int64, + exists bool, + options Options, +) (*RowSet, error) { + t.Helper() + return f.execute(t, "Filtered", Bindings{ + "source": source, + "kind": {StringValue(kind)}, + "direction": {StringValue(direction)}, + "maxDepth": {IntegerValue(maxDepth)}, + "exists": {BooleanValue(exists)}, + }, options) +} + +func TestExecuteWhereRelatedKeepsRowsByRelationshipExistence(t *testing.T) { + fixture := loadCoverageFixture(t) + root := Bindings{"root": {ElementValue(fixture.symbol(t, "Specification"))}} + + all, err := fixture.execute(t, "RequirementUsages", root, Options{}) + if err != nil { + t.Fatalf("Requirements: %v", err) + } + // Nested requirement usages are requirement usages too. + wantAll := "Observatory::Specification::massRequirement,Observatory::Specification::pointingRequirement," + + "Observatory::Specification::dataRequirement,Observatory::Specification::thermalRequirement," + + "Observatory::Specification::thermalRequirement::coolantRequirement" + if got := strings.Join(rowNames(all), ","); got != wantAll { + t.Fatalf("requirement usages = %v", got) + } + + unsatisfied, err := fixture.execute(t, "Unsatisfied", root, Options{}) + if err != nil { + t.Fatalf("Unsatisfied: %v", err) + } + wantUnsatisfied := "Observatory::Specification::pointingRequirement,Observatory::Specification::thermalRequirement," + + "Observatory::Specification::thermalRequirement::coolantRequirement" + if got := strings.Join(rowNames(unsatisfied), ","); got != wantUnsatisfied { + t.Fatalf("unsatisfied = %v", got) + } + // exists defaults to true and keeps the complement, in source order. + satisfied, err := fixture.execute(t, "Satisfied", root, Options{}) + if err != nil { + t.Fatalf("Satisfied: %v", err) + } + if got := strings.Join(rowNames(satisfied), ","); got != "Observatory::Specification::massRequirement,Observatory::Specification::dataRequirement" { + t.Fatalf("satisfied = %v", got) + } + unverified, err := fixture.execute(t, "Unverified", root, Options{}) + if err != nil { + t.Fatalf("Unverified: %v", err) + } + wantUnverified := "Observatory::Specification::dataRequirement,Observatory::Specification::thermalRequirement," + + "Observatory::Specification::thermalRequirement::coolantRequirement" + if got := strings.Join(rowNames(unverified), ","); got != wantUnverified { + t.Fatalf("unverified = %v", got) + } + for _, row := range unsatisfied.Rows() { + if !row.Origin().Located() { + t.Fatalf("row %v without provenance", row.Element()) + } + } +} + +func TestExecuteWhereRelatedHonoursDepthAndEveryKind(t *testing.T) { + fixture := loadCoverageFixture(t) + lineage := []Value{ + ElementValue(fixture.symbol(t, "Subsystem")), + ElementValue(fixture.symbol(t, "OpticalSubsystem")), + ElementValue(fixture.symbol(t, "MirrorAssembly")), + } + roots, err := fixture.filtered(t, lineage, "specialization", "outgoing", 1, false, Options{}) + if err != nil { + t.Fatalf("specialization exists=false: %v", err) + } + if got := rowNames(roots); len(got) != 1 || got[0] != "Observatory::Subsystem" { + t.Fatalf("definitions specializing nothing = %v", got) + } + // Incoming typing within one edge: only Subsystem's direct type users count, + // so MirrorAssembly is typed by telescope and the others by nothing. + typed, err := fixture.filtered(t, lineage, "typing", "incoming", 1, true, Options{}) + if err != nil { + t.Fatalf("typing exists=true: %v", err) + } + if got := rowNames(typed); len(got) != 1 || got[0] != "Observatory::MirrorAssembly" { + t.Fatalf("definitions typing a usage = %v", got) + } + // Nothing is reachable within depth 0, so every row lacks a related element. + general := []Value{ElementValue(fixture.symbol(t, "OpticalSubsystem"))} + none, err := fixture.filtered(t, general, "specialization", "outgoing", 0, true, Options{}) + if err != nil { + t.Fatalf("depth 0 exists=true: %v", err) + } + if len(none.Rows()) != 0 { + t.Fatalf("depth 0 kept %v", rowNames(none)) + } + unreached, err := fixture.filtered(t, general, "specialization", "outgoing", 0, false, Options{}) + if err != nil { + t.Fatalf("depth 0 exists=false: %v", err) + } + if got := rowNames(unreached); len(got) != 1 || got[0] != "Observatory::OpticalSubsystem" { + t.Fatalf("depth 0 exists=false = %v", got) + } + for _, kind := range []string{"subsetting", "redefinition", "connection", "allocation"} { + none, err := fixture.filtered(t, general, kind, "outgoing", 1, false, Options{}) + if err != nil { + t.Fatalf("%s: %v", kind, err) + } + if got := rowNames(none); len(got) != 1 || got[0] != "Observatory::OpticalSubsystem" { + t.Fatalf("%s exists=false = %v", kind, got) + } + } +} + +func TestExecuteWhereRelatedKeepsProjectedColumns(t *testing.T) { + fixture := loadCoverageFixture(t) + root := Bindings{"root": {ElementValue(fixture.symbol(t, "Specification"))}} + table, err := fixture.execute(t, "UnsatisfiedTable", root, Options{}) + if err != nil { + t.Fatalf("UnsatisfiedTable: %v", err) + } + var columns []string + for _, column := range table.Columns() { + columns = append(columns, column.Name()) + } + if got := strings.Join(columns, ","); got != "name,qualifiedName" { + t.Fatalf("columns = %v", got) + } + if got := cellTexts(t, table, 0); strings.Join(got, ",") != "pointingRequirement,thermalRequirement,coolantRequirement" { + t.Fatalf("names = %v", got) + } +} + +func TestExecuteWhereRelatedReportsTheErrorsOfRelatedElements(t *testing.T) { + fixture := loadCoverageFixture(t) + source := []Value{ElementValue(fixture.symbol(t, "Subsystem"))} + _, err := fixture.filtered(t, source, "ownership", "outgoing", 1, true, Options{}) + if unknown := executionError(t, err, ErrorUnknownRelationship); unknown.Actual != "ownership" { + t.Fatalf("unknown relationship = %v", unknown) + } + _, err = fixture.filtered(t, source, "specialization", "sideways", 1, true, Options{}) + executionError(t, err, ErrorInvalidOperator) + + // An object row cannot be traced through the model's relationships. + ctx := runtime.NewContext(runtime.NewModel(fixture.model, fixture.resolver), runtime.DefaultMaxSteps) + car, err := ctx.Instantiate(fixture.symbol(t, "car")) + if err != nil { + t.Fatalf("Instantiate car: %v", err) + } + _, err = Execute(fixture.program(t, "Filtered"), + Context{Index: fixture.index, Resolver: fixture.resolver, Model: fixture.model, Runtime: ctx, + Roots: []Root{{Label: "car", Object: car}}}, + Bindings{ + "source": {ObjectValue(car, "car")}, + "kind": {StringValue("satisfaction")}, + "direction": {StringValue("incoming")}, + "maxDepth": {IntegerValue(1)}, + "exists": {BooleanValue(true)}, + }, Options{}) + if refused := executionError(t, err, ErrorObjectRow); refused.Target != "car" { + t.Fatalf("object-row error = %v", refused) + } +} + +func TestExecuteWhereRelatedChargesTheVisitBudget(t *testing.T) { + fixture := loadCoverageFixture(t) + leaf := []Value{ElementValue(fixture.symbol(t, "MirrorAssembly"))} + // An existence check stops at the first element reached, so one visit suffices. + kept, err := fixture.filtered(t, leaf, "specialization", "outgoing", 3, true, Options{VisitBudget: 1}) + if err != nil { + t.Fatalf("exists=true within budget: %v", err) + } + if got := rowNames(kept); len(got) != 1 || got[0] != "Observatory::MirrorAssembly" { + t.Fatalf("kept = %v", got) + } + // Each row's check charges the shared budget, so a second row exhausts it. + pair := append(leaf, ElementValue(fixture.symbol(t, "OpticalSubsystem"))) + _, err = fixture.filtered(t, pair, "specialization", "outgoing", 3, true, Options{VisitBudget: 1}) + executionError(t, err, ErrorVisitBudget) + // Building an edge table is not charged, as for RelatedElements: a row + // nothing satisfies is kept without spending a visit. + kept, err = fixture.filtered(t, leaf, "satisfaction", "incoming", 1, false, Options{VisitBudget: 1}) + if err != nil { + t.Fatalf("uncharged table: %v", err) + } + if got := rowNames(kept); len(got) != 1 || got[0] != "Observatory::MirrorAssembly" { + t.Fatalf("kept = %v", got) + } +} diff --git a/internal/doccounts/doccounts.go b/internal/doccounts/doccounts.go deleted file mode 100644 index b8511f3aad..0000000000 --- a/internal/doccounts/doccounts.go +++ /dev/null @@ -1,471 +0,0 @@ -// Package doccounts is the one census of the compliance map's status markers and -// the one statement of which documentation lines derive from the oracle baselines: -// the guard in cmd/pilot-diff checks those lines, cmd/doc-counts rewrites them. The -// rule census itself is counted at documentation-build time (scripts/mkdocs_census.py) -// and is never written into a committed file. -package doccounts - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "text/template" -) - -// Paths of the compliance map and of the files carrying a derived line, relative -// to the repository root. -const ( - SpecCompliancePath = "docs/project/spec-compliance.md" - ReadmePath = "README.md" - ArchitecturePath = "docs/internals/architecture.md" -) - -// refereedBlockName names the generated block: the prose census the Markdown pages share. -const refereedBlockName = "refereed-figures" - -// statusMarkers are the row statuses the compliance map uses. '⚠' is matched -// without its variation selector, as the map writes both spellings. -var statusMarkers = []string{"✅", "⚠", "❌", "⛔", "🚧"} - -// RuleCounts is the census of the compliance map's rule rows: a row is one table -// row carrying exactly one status marker. -type RuleCounts struct { - Total int - Faithful int - Approximate int - NotImplemented int - Deliberate int - KnownFailure int -} - -// RefereedCounts is the five-figure census read from the committed baselines. -type RefereedCounts struct { - Files int - FilesAgreeing int - OursOnly int - PilotOnly int - DeclaredErrors int - Silent int - DeclaredAgree int - WordingOnly int - LocationOnly int - SeverityDiffers int - Elsewhere int - ScopeExact int - ScopeTotal int - RejectCases int - RejectPilotOnly int - RejectBoth int - RejectDefaultPilotOnly int - RejectDefaultBoth int - RejectStrictOnly int - PilotTag string - PilotArtifact string - // Errata is the same census with the declared corrections applied. It is a - // secondary diagnostic figure; the fields above stay the conformance ones. - Errata ErrataCounts -} - -// ErrataCounts is the errata-applied census, read from the same baselines' -// errata sections so the two figures cannot come from different runs. -type ErrataCounts struct { - Registry int - Corrections int - Documented int - Files int - FilesAgreeing int - OursOnly int - PilotOnly int - Silent int - RejectCases int - RejectPilotOnly int -} - -// differentialTotals is the differential's counts, shared by the as-published -// totals and the errata-applied ones. -type differentialTotals struct { - Files int `json:"files"` - FilesAgreeing int `json:"filesFullyAgreeing"` - OursOnly int `json:"openSysMLOnly"` - PilotOnly int `json:"pilotOnly"` -} - -// errataProvenance is the registry census every oracle baseline restates. -type errataProvenance struct { - Registry int `json:"registryEntries"` - Corrections int `json:"corrections"` - Documented int `json:"documentedWithoutCorrection"` -} - -type differentialBaseline struct { - PilotRelease string `json:"pilotRelease"` - Totals differentialTotals `json:"totals"` - Errata *struct { - errataProvenance - Totals differentialTotals `json:"totals"` - } `json:"errata"` -} - -// xpectKind is one assertion kind's counts, as published or with the errata. -type xpectKind struct { - Kind string `json:"kind"` - Assertions int `json:"assertions"` - Rows int `json:"rows"` - Agree int `json:"agree"` - WordingOnly int `json:"wordingOnly"` - SameLocation int `json:"sameLocation"` - SameLine int `json:"sameLine"` - SeverityDiffers int `json:"severityDiffers"` - Elsewhere int `json:"elsewhereInFile"` -} - -type xpectBaseline struct { - Kinds []xpectKind `json:"kinds"` - Errata *struct { - Kinds []xpectKind `json:"kinds"` - } `json:"errata"` -} - -type rejectionTotals struct { - Cases int `json:"cases"` - BothReject int `json:"bothReject"` - PilotOnlyRejects int `json:"pilotOnlyRejects"` -} - -type rejectionBaseline struct { - Totals rejectionTotals `json:"totals"` - StrictOnlyAgreements []string `json:"strictOnlyAgreements"` - Errata *struct { - Totals rejectionTotals `json:"totals"` - } `json:"errata"` -} - -// ReadRefereedCounts reads and derives all five headline figures. -func ReadRefereedCounts(root string) (RefereedCounts, error) { - var differential differentialBaseline - if err := readJSON(root, "docs/project/pilot-differential-baseline.json", &differential); err != nil { - return RefereedCounts{}, err - } - var xpect xpectBaseline - if err := readJSON(root, "docs/project/pilot-xpect-baseline.json", &xpect); err != nil { - return RefereedCounts{}, err - } - var rejection rejectionBaseline - if err := readJSON(root, "docs/project/pilot-rejection-baseline.json", &rejection); err != nil { - return RefereedCounts{}, err - } - pilotTag, pilotArtifact, err := parsePilotRelease(differential.PilotRelease) - if err != nil { - return RefereedCounts{}, fmt.Errorf("docs/project/pilot-differential-baseline.json: %w", err) - } - counts := RefereedCounts{ - Files: differential.Totals.Files, - FilesAgreeing: differential.Totals.FilesAgreeing, - OursOnly: differential.Totals.OursOnly, - PilotOnly: differential.Totals.PilotOnly, - RejectCases: rejection.Totals.Cases, - RejectPilotOnly: rejection.Totals.PilotOnlyRejects, - RejectBoth: rejection.Totals.BothReject, - RejectStrictOnly: len(rejection.StrictOnlyAgreements), - PilotTag: pilotTag, - PilotArtifact: pilotArtifact, - } - counts.RejectDefaultBoth = counts.RejectBoth - counts.RejectStrictOnly - counts.RejectDefaultPilotOnly = counts.RejectPilotOnly + counts.RejectStrictOnly - if counts.RejectDefaultBoth < 0 { - return RefereedCounts{}, fmt.Errorf("docs/project/pilot-rejection-baseline.json: more strict-only agreements than agreements") - } - var foundErrors, foundScope bool - for _, kind := range xpect.Kinds { - switch kind.Kind { - case "errors": - foundErrors = true - counts.DeclaredErrors = kind.Rows - counts.DeclaredAgree = kind.Agree - kind.WordingOnly - counts.WordingOnly = kind.WordingOnly - counts.LocationOnly = kind.SameLine - counts.SeverityDiffers = kind.SeverityDiffers - counts.Elsewhere = kind.Elsewhere - counts.Silent = kind.Rows - kind.Agree - kind.SameLocation - kind.SameLine - kind.SeverityDiffers - kind.Elsewhere - if counts.DeclaredAgree < 0 || counts.Silent < 0 { - return RefereedCounts{}, fmt.Errorf("docs/project/pilot-xpect-baseline.json: errors agreements and tolerances exceed %d rows", kind.Rows) - } - case "scope": - foundScope = true - counts.ScopeExact = kind.Agree - counts.ScopeTotal = kind.Assertions - } - } - if !foundErrors || !foundScope { - return RefereedCounts{}, fmt.Errorf("docs/project/pilot-xpect-baseline.json: baseline states no errors or scope kind to derive the headline from") - } - if counts.Errata, err = readErrataCounts(differential, xpect, rejection); err != nil { - return RefereedCounts{}, err - } - return counts, nil -} - -// readErrataCounts derives the errata-applied census from the same baselines. -// A baseline without an errata section is stale: rerun the oracle. -func readErrataCounts(differential differentialBaseline, xpect xpectBaseline, rejection rejectionBaseline) (ErrataCounts, error) { - if differential.Errata == nil { - return ErrataCounts{}, fmt.Errorf("docs/project/pilot-differential-baseline.json: no errata section; rerun `go run ./cmd/pilot-diff`") - } - if xpect.Errata == nil { - return ErrataCounts{}, fmt.Errorf("docs/project/pilot-xpect-baseline.json: no errata section; rerun `go run ./cmd/pilot-xpect`") - } - if rejection.Errata == nil { - return ErrataCounts{}, fmt.Errorf("docs/project/pilot-rejection-baseline.json: no errata section; rerun `go run ./cmd/pilot-reject`") - } - counts := ErrataCounts{ - Registry: differential.Errata.Registry, - Corrections: differential.Errata.Corrections, - Documented: differential.Errata.Documented, - Files: differential.Errata.Totals.Files, - FilesAgreeing: differential.Errata.Totals.FilesAgreeing, - OursOnly: differential.Errata.Totals.OursOnly, - PilotOnly: differential.Errata.Totals.PilotOnly, - RejectCases: rejection.Errata.Totals.Cases, - RejectPilotOnly: rejection.Errata.Totals.PilotOnlyRejects, - } - if counts.Registry != counts.Corrections+counts.Documented { - return ErrataCounts{}, fmt.Errorf("docs/project/pilot-differential-baseline.json: %d errata entries are neither corrected nor documented-only", counts.Registry-counts.Corrections-counts.Documented) - } - for _, kind := range xpect.Errata.Kinds { - if kind.Kind != "errors" { - continue - } - counts.Silent = kind.Rows - kind.Agree - kind.SameLocation - kind.SameLine - kind.SeverityDiffers - kind.Elsewhere - if counts.Silent < 0 { - return ErrataCounts{}, fmt.Errorf("docs/project/pilot-xpect-baseline.json: errata agreements and tolerances exceed %d rows", kind.Rows) - } - return counts, nil - } - return ErrataCounts{}, fmt.Errorf("docs/project/pilot-xpect-baseline.json: the errata section states no errors kind") -} - -func readJSON(root, path string, into any) error { - content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) // #nosec G304 -- the path is a fixed baseline file under the requested repository root - if err != nil { - return err - } - if err := json.Unmarshal(content, into); err != nil { - return fmt.Errorf("%s: parse baseline: %w", path, err) - } - return nil -} - -func parsePilotRelease(release string) (string, string, error) { - match := pilotReleasePattern.FindStringSubmatch(release) - if match == nil { - return "", "", fmt.Errorf("pilotRelease %q does not match `TAG (jupyter-sysml-kernel ARTIFACT)`", release) - } - return match[1], match[2], nil -} - -// IsRuleRow reports whether a line is a compliance-map table row carrying exactly -// one status marker. Header, separator and prose lines carry none, and a row -// naming several statuses in its notes is not a census of one status. -func IsRuleRow(line string) bool { - text := strings.TrimSpace(line) - if !strings.HasPrefix(text, "|") { - return false - } - found := 0 - for _, cell := range strings.Split(strings.Trim(text, "|"), "|") { - for _, marker := range statusMarkers { - found += strings.Count(cell, marker) - } - } - return found == 1 -} - -// CountRules counts the status markers of every rule row of the compliance map. -func CountRules(content string) RuleCounts { - counts := RuleCounts{} - for _, line := range strings.Split(content, "\n") { - if !IsRuleRow(line) { - continue - } - switch { - case strings.Contains(line, "✅"): - counts.Faithful++ - case strings.Contains(line, "⚠"): - counts.Approximate++ - case strings.Contains(line, "❌"): - counts.NotImplemented++ - case strings.Contains(line, "⛔"): - counts.Deliberate++ - case strings.Contains(line, "🚧"): - counts.KnownFailure++ - } - } - counts.Total = counts.Faithful + counts.Approximate + counts.NotImplemented + counts.Deliberate + counts.KnownFailure - return counts -} - -var ( - referenceLinePattern = regexp.MustCompile(`^\*\*Reference differential:\*\* ([0-9]+) files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation \(` + "`" + `([^` + "`" + `]+)` + "`" + `\), ([0-9]+) in full agreement;`) - rejectionLinePattern = regexp.MustCompile(`^\*\*Rejection oracle:\*\* the reverse direction — do we reject what the reference rejects\? ([0-9]+) hand-written invalid models validated by both implementations, ([0-9]+) rejected by both, ([0-9]+) the pinned pilot rejects and we accept;`) - pilotReleasePattern = regexp.MustCompile(`^([^ ]+) \(jupyter-sysml-kernel ([^)]+)\)$`) -) - -// BaselineLine is a line whose values come from the committed oracle baselines. -type BaselineLine struct { - Path string - Marker string - Pattern *regexp.Regexp - Values func(RefereedCounts) []string -} - -// BaselineLines lists the single-copy oracle lines regenerated from baselines. -func BaselineLines() []BaselineLine { - return []BaselineLine{{ - Path: ReadmePath, - Marker: "**Reference differential:**", - Pattern: referenceLinePattern, - Values: func(counts RefereedCounts) []string { - return []string{strconv.Itoa(counts.Files), counts.PilotTag, strconv.Itoa(counts.FilesAgreeing)} - }, - }, { - Path: ReadmePath, - Marker: "**Rejection oracle:**", - Pattern: rejectionLinePattern, - Values: func(counts RefereedCounts) []string { - return []string{strconv.Itoa(counts.RejectCases), strconv.Itoa(counts.RejectBoth), strconv.Itoa(counts.RejectPilotOnly)} - }, - }} -} - -// Block describes a generated named block and its consumer-relative links. Name -// selects the template the block is rendered from; LinkPrefix is where that -// template's links to the conformance records resolve from this consumer. -type Block struct { - Path string - Name string - LinkPrefix string -} - -// Blocks lists the consumers of the generated block: the two Markdown pages -// sharing the prose census. -func Blocks() []Block { - return []Block{ - {Path: ReadmePath, Name: refereedBlockName, LinkPrefix: "docs/project/"}, - {Path: ArchitecturePath, Name: refereedBlockName, LinkPrefix: "../project/"}, - } -} - -// FindLine returns the index of the first line of content carrying the marker. -func FindLine(content, marker string) (int, bool) { - for i, line := range strings.Split(content, "\n") { - if strings.Contains(line, marker) { - return i, true - } - } - return 0, false -} - -// RewriteBaselineLine restates one baseline-derived line without other changes. -func RewriteBaselineLine(content string, spec BaselineLine, counts RefereedCounts) (string, error) { - index, ok := FindLine(content, spec.Marker) - if !ok { - return "", fmt.Errorf("%s: no line carries %q", spec.Path, spec.Marker) - } - lines := strings.Split(content, "\n") - match := spec.Pattern.FindStringSubmatchIndex(lines[index]) - if match == nil { - return "", fmt.Errorf("%s:%d: line carrying %q does not match the derived-line pattern", spec.Path, index+1, spec.Marker) - } - values := spec.Values(counts) - if got := len(match)/2 - 1; got != len(values) { - return "", fmt.Errorf("%s:%d: line captures %d values, the baseline states %d", spec.Path, index+1, got, len(values)) - } - rewritten := lines[index] - for i := len(values) - 1; i >= 0; i-- { - rewritten = rewritten[:match[2+i*2]] + values[i] + rewritten[match[3+i*2]:] - } - lines[index] = rewritten - return strings.Join(lines, "\n"), nil -} - -const ( - blockBeginFormat = "" - blockEndFormat = "" -) - -// RewriteBlock replaces a named generated block and preserves surrounding bytes. -func RewriteBlock(content string, spec Block, counts RefereedCounts) (string, error) { - begin := fmt.Sprintf(blockBeginFormat, spec.Name) - end := fmt.Sprintf(blockEndFormat, spec.Name) - lines := strings.Split(content, "\n") - beginIndex, endIndex := -1, -1 - for i, line := range lines { - switch strings.TrimSpace(line) { - case begin: - if beginIndex >= 0 { - return "", fmt.Errorf("%s: duplicate %q marker", spec.Path, begin) - } - beginIndex = i - case end: - if endIndex >= 0 { - return "", fmt.Errorf("%s: duplicate %q marker", spec.Path, end) - } - endIndex = i - } - } - if beginIndex < 0 || endIndex < 0 || endIndex <= beginIndex { - return "", fmt.Errorf("%s: named block %q is missing or unterminated", spec.Path, spec.Name) - } - renderedBlock, err := renderBlock(spec, counts) - if err != nil { - return "", err - } - rendered := strings.Split(renderedBlock, "\n") - updated := make([]string, 0, len(lines)-endIndex+beginIndex+len(rendered)) - updated = append(updated, lines[:beginIndex]...) - updated = append(updated, rendered...) - updated = append(updated, lines[endIndex+1:]...) - return strings.Join(updated, "\n"), nil -} - -// blockTemplateData is the baseline census plus the consumer's own link prefix. -type blockTemplateData struct { - RefereedCounts - Name string - LinkPrefix string -} - -const refereedBlockTemplateText = "\n" + - "**Measured against the pinned reference** (`PILOT_TAG={{.PilotTag}}`, artifact `{{.PilotArtifact}}`). Every number below is generated by `make docs-counts` from the committed baselines and gated; none of them is typed in by hand.\n\n" + - "- **Corpus agreement:** {{.FilesAgreeing}} of {{.Files}} files agree diagnostic-by-diagnostic; {{.OursOnly}} diagnostics are ours alone and {{.PilotOnly}} the reference's alone, and the first number must be read by root: our diagnostics against the reference's own corpora fell while our non-standard-notation warnings on our own example models rose ([differential]({{.LinkPrefix}}pilot-differential.md), `go run ./cmd/pilot-diff`).\n" + - "- **Declared-diagnostic silence:** of the {{.DeclaredErrors}} declared `errors` rows in the reference's own Xpect suites, we report nothing for {{.Silent}}. {{.DeclaredAgree}} we report word-for-word; {{.WordingOnly}} wording-only and {{.LocationOnly}} location-only differences are agreement in substance and are not counted as gaps; {{.SeverityDiffers}} more we report as a warning and {{.Elsewhere}} elsewhere in the file ([Xpect oracle]({{.LinkPrefix}}pilot-xpect.md), `go run ./cmd/pilot-xpect`).\n" + - "- **Scope agreement:** {{.ScopeExact}} of {{.ScopeTotal}} declared scope assertions match exactly (same source).\n" + - "- **Permissiveness gaps:** of {{.RejectCases}} invalid models we wrote ourselves, the reference rejects {{.RejectDefaultPilotOnly}} that we accept by default, and {{.RejectDefaultBoth}} both reject; {{.RejectStrictOnly}} further cases agree only when we are asked strictly. We authored every one of these cases ourselves, so the denominator measures the reach of our own corpus and not our conformance; agreement reached only under an opt-in strict mode is weaker evidence than agreement by default ([rejection oracle]({{.LinkPrefix}}pilot-rejection.md), `go run ./cmd/pilot-reject`).\n" + - "- **Declared errata:** the registry declares {{.Errata.Registry}} defect(s) in the published reference material — {{.Errata.Corrections}} with a specification-derived correction, {{.Errata.Documented}} documented without one, since no intended reading can be inferred ([OMG issues]({{.LinkPrefix}}omg-issues.md), `internal/errata`). Every figure above is as published and stays the conformance statement; running the same oracles over the corrected text instead reports {{.Errata.FilesAgreeing}} of {{.Errata.Files}} files agreeing, {{.Errata.OursOnly}} diagnostics ours alone and {{.Errata.PilotOnly}} the reference's alone, {{.Errata.Silent}} declared rows we are silent on, and {{.Errata.RejectPilotOnly}} of {{.Errata.RejectCases}} authored cases the reference alone rejects. The corrected figures are diagnostic only: an erratum never reclassifies a divergence category, and the published corpus is never edited.\n" + - "- **Self-assessed surface:** the action, state-machine and classifier-behavior rows have no external referee at all — the four refereed figures above cannot see them, because the pinned artifact evaluates expressions but executes neither actions nor state machines. [Spec compliance]({{.LinkPrefix}}spec-compliance.md) counts them.\n\n" + - "What these numbers cannot show: the OMG corpora are demonstrations rather than an official conformance suite; the differential is one-directional, comparing the diagnostics the two implementations report on the same files; the Xpect suites are the pilot authors' test intent rather than a certification oracle; and none of these is a percentage of the specification — no global compliance figure is claimed anywhere.\n\n" + - "**Row bookkeeping:** the ✅/⚠️/❌/⛔ status of each tracked rule stays in [spec compliance]({{.LinkPrefix}}spec-compliance.md) as a census of our own row list, counted when the documentation site is built rather than committed. It moves when rows are rewritten and does not move when an oracle does, so it is not the progress measure.\n" + - "" - -// blockTemplates is the one template per generated block name. A block naming no -// template is reported rather than written, so a consumer cannot be added without one. -var blockTemplates = map[string]*template.Template{ - refereedBlockName: template.Must(template.New(refereedBlockName).Parse(refereedBlockTemplateText)), -} - -func renderBlock(spec Block, counts RefereedCounts) (string, error) { - blockTemplate, ok := blockTemplates[spec.Name] - if !ok { - return "", fmt.Errorf("%s: no template renders the block named %q", spec.Path, spec.Name) - } - data := blockTemplateData{RefereedCounts: counts, Name: spec.Name, LinkPrefix: spec.LinkPrefix} - var rendered strings.Builder - if err := blockTemplate.Execute(&rendered, data); err != nil { - return "", fmt.Errorf("render %s: %w", spec.Name, err) - } - return rendered.String(), nil -} diff --git a/internal/doccounts/doccounts_test.go b/internal/doccounts/doccounts_test.go deleted file mode 100644 index 68c9409f4f..0000000000 --- a/internal/doccounts/doccounts_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package doccounts - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -const complianceFixture = `# Compliance - -| Rule | Where | Test | Status | -|---|---|---|---| -| a | x | y | ✅ Faithful | -| b | x | y | ⚠️ Approximate | -| c | x | y | ❌ Not implemented | -| d | x | y | ⛔ Deliberate | -| notes | mentions ✅ and ❌ together | y | ⚠️ Approximate | -` - -func TestCountRulesCountsOneMarkerPerRow(t *testing.T) { - counts := CountRules(complianceFixture) - want := RuleCounts{Total: 4, Faithful: 1, Approximate: 1, NotImplemented: 1, Deliberate: 1} - if counts != want { - t.Fatalf("census: want %+v, got %+v", want, counts) - } -} - -func TestCountRulesIgnoresProseAndHeaders(t *testing.T) { - if counts := CountRules("✅ prose outside a table\n\n| header | Status |\n|---|---|\n"); counts.Total != 0 { - t.Fatalf("census of a table with no rule rows: want 0 rows, got %+v", counts) - } -} - -func TestReadRefereedCountsDerivesAllBaselineFigures(t *testing.T) { - root := t.TempDir() - writeDoccountsFixture(t, root) - counts, err := ReadRefereedCounts(root) - if err != nil { - t.Fatalf("read baselines: %v", err) - } - if counts.Files != 2 || counts.FilesAgreeing != 1 || counts.OursOnly != 3 || counts.PilotOnly != 4 { - t.Fatalf("differential counts: %+v", counts) - } - if counts.DeclaredErrors != 11 || counts.Silent != 1 || counts.DeclaredAgree != 6 || - counts.WordingOnly != 2 || counts.LocationOnly != 1 || counts.SeverityDiffers != 0 || - counts.Elsewhere != 0 || counts.ScopeExact != 7 || counts.ScopeTotal != 8 { - t.Fatalf("Xpect counts: %+v", counts) - } - if counts.RejectCases != 12 || counts.RejectBoth != 11 || counts.RejectPilotOnly != 1 || - counts.RejectDefaultBoth != 9 || counts.RejectDefaultPilotOnly != 3 || counts.RejectStrictOnly != 2 { - t.Fatalf("rejection counts: %+v", counts) - } - if counts.PilotTag != "2026-05" || counts.PilotArtifact != "0.60.1" { - t.Fatalf("derived metadata: %+v", counts) - } - want := ErrataCounts{Registry: 2, Corrections: 1, Documented: 1, - Files: 2, FilesAgreeing: 2, OursOnly: 2, PilotOnly: 4, Silent: 0, RejectCases: 12, RejectPilotOnly: 1} - if counts.Errata != want { - t.Fatalf("errata counts: %+v, want %+v", counts.Errata, want) - } -} - -// TestReadRefereedCountsRejectsABaselineWithoutErrata keeps the second figure a -// measurement: a baseline predating the overlay is stale, not zero. -func TestReadRefereedCountsRejectsABaselineWithoutErrata(t *testing.T) { - for _, path := range []string{ - "docs/project/pilot-differential-baseline.json", - "docs/project/pilot-xpect-baseline.json", - "docs/project/pilot-rejection-baseline.json", - } { - t.Run(path, func(t *testing.T) { - root := t.TempDir() - writeDoccountsFixture(t, root) - content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) - if err != nil { - t.Fatalf("read fixture: %v", err) - } - var decoded map[string]any - if err := json.Unmarshal(content, &decoded); err != nil { - t.Fatalf("parse fixture: %v", err) - } - delete(decoded, "errata") - stripped, err := json.Marshal(decoded) - if err != nil { - t.Fatalf("encode fixture: %v", err) - } - writeAt(t, root, path, string(stripped)) - if _, err := ReadRefereedCounts(root); err == nil { - t.Fatalf("%s without an errata section: want an error", path) - } - }) - } -} - -func TestRewriteBlockUsesConsumerRelativeLinksAndIsIdempotent(t *testing.T) { - root := t.TempDir() - writeDoccountsFixture(t, root) - counts, err := ReadRefereedCounts(root) - if err != nil { - t.Fatalf("read baselines: %v", err) - } - spec := Block{Path: "README.md", Name: "refereed-figures", LinkPrefix: "docs/project/"} - content := "before\n\nstale\n\nafter\n" - got, err := RewriteBlock(content, spec, counts) - if err != nil { - t.Fatalf("rewrite block: %v", err) - } - for _, want := range []string{ - "`PILOT_TAG=2026-05`", "artifact `0.60.1`", "1 of 2 files", - "6 we report word-for-word", "9 both reject", - "[differential](docs/project/pilot-differential.md)", - "[spec compliance](docs/project/spec-compliance.md)", - "must be read by root", - } { - if !strings.Contains(got, want) { - t.Fatalf("generated block lacks %q:\n%s", want, got) - } - } - again, err := RewriteBlock(got, spec, counts) - if err != nil { - t.Fatalf("second block rewrite: %v", err) - } - if again != got { - t.Fatal("block rewrite is not idempotent") - } -} - -// TestRewriteBlockRejectsABlockWithNoTemplate keeps a new consumer from silently -// emptying a block: a name no template renders is an error, not empty markup. -func TestRewriteBlockRejectsABlockWithNoTemplate(t *testing.T) { - content := "\nkept\n\n" - if _, err := RewriteBlock(content, Block{Path: ReadmePath, Name: "invented"}, RefereedCounts{}); err == nil { - t.Fatal("want an error for a block name no template renders") - } -} - -func TestRewriteBlockRejectsMalformedMarkers(t *testing.T) { - spec := Block{Path: "README.md", Name: "refereed-figures"} - counts := RefereedCounts{} - for name, content := range map[string]string{ - "missing begin": "\n", - "missing end": "\n", - "reversed": "\n\n", - "duplicate": "\n\n\n", - } { - t.Run(name, func(t *testing.T) { - if _, err := RewriteBlock(content, spec, counts); err == nil { - t.Fatal("want malformed marker error") - } - }) - } -} - -func TestRewriteBaselineLineRestatesOnlyCapturedValues(t *testing.T) { - spec := BaselineLines()[0] - content := "before\n**Reference differential:** 99 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`old`), 99 in full agreement;\nafter\n" - counts := RefereedCounts{Files: 2, PilotTag: "2026-05", FilesAgreeing: 1} - got, err := RewriteBaselineLine(content, spec, counts) - if err != nil { - t.Fatalf("rewrite baseline line: %v", err) - } - want := "before\n**Reference differential:** 2 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-05`), 1 in full agreement;\nafter\n" - if got != want { - t.Fatalf("rewritten baseline line:\n%s", got) - } -} - -func writeDoccountsFixture(t *testing.T, root string) { - t.Helper() - writeAt(t, root, SpecCompliancePath, `# Compliance - -**No external referee:** self-assessed. - -| Rule | Status | -|---|---| -| a | ✅ Faithful | -`) - writeAt(t, root, "docs/project/pilot-differential-baseline.json", `{"pilotRelease":"2026-05 (jupyter-sysml-kernel 0.60.1)","totals":{"files":2,"filesFullyAgreeing":1,"openSysMLOnly":3,"pilotOnly":4},`+ - `"errata":{"registryEntries":2,"corrections":1,"documentedWithoutCorrection":1,"totals":{"files":2,"filesFullyAgreeing":2,"openSysMLOnly":2,"pilotOnly":4}}}`) - writeAt(t, root, "docs/project/pilot-xpect-baseline.json", `{"kinds":[{"kind":"errors","rows":11,"agree":8,"wordingOnly":2,"sameLocation":1,"sameLine":1,"severityDiffers":0,"elsewhereInFile":0},{"kind":"scope","assertions":8,"agree":7}],`+ - `"errata":{"kinds":[{"kind":"errors","rows":11,"agree":9,"wordingOnly":2,"sameLocation":1,"sameLine":1}]}}`) - writeAt(t, root, "docs/project/pilot-rejection-baseline.json", `{"totals":{"cases":12,"bothReject":11,"pilotOnlyRejects":1},"strictOnlyAgreements":["a","b"],`+ - `"errata":{"totals":{"cases":12,"bothReject":11,"pilotOnlyRejects":1}}}`) -} - -func writeAt(t *testing.T, root, path, content string) { - t.Helper() - full := filepath.Join(root, filepath.FromSlash(path)) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(full, []byte(content), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } -} diff --git a/internal/docpdf/converter.go b/internal/docpdf/converter.go deleted file mode 100644 index 0d903d3419..0000000000 --- a/internal/docpdf/converter.go +++ /dev/null @@ -1,289 +0,0 @@ -// Package docpdf renders docrender Markdown to PDF by driving external -// converters as subprocesses; no PDF renderer is linked into the binary. -package docpdf - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -// Environment variables that point each external tool's discovery at a -// specific executable, ahead of a PATH lookup by its default name. -const ( - PandocEnv = "OPENSYSML_PANDOC" - WeasyPrintEnv = "OPENSYSML_WEASYPRINT" - PrinceEnv = "OPENSYSML_PRINCE" - MermaidEnv = "OPENSYSML_MMDC" - // MermaidPuppeteerEnv names an optional puppeteer configuration file - // passed to mmdc, for environments whose browser needs launch flags. - MermaidPuppeteerEnv = "OPENSYSML_MMDC_PUPPETEER" -) - -// toolTimeout bounds each converter subprocess, so a wedged tool is a typed -// error rather than a hang. -const toolTimeout = 5 * time.Minute - -// Prepared is one document laid out in a working directory for a converter, -// as both Markdown (diagrams as image references) and standalone HTML. -type Prepared struct { - // Dir is the working directory holding every input, diagram SVGs included. - Dir string - - // MarkdownFile is the Markdown document's name within Dir. - MarkdownFile string - - // HTMLFile is the HTML document's name within Dir. - HTMLFile string - - // Options are the deliverable choices, for converters with native flags. - Options Options -} - -// Input is the document form a converter reads. -type Input string - -const ( - InputHTML Input = "html" - InputMarkdown Input = "markdown" -) - -// Capabilities is what a converter states about itself: the form it reads, -// the executables it drives, and whether it applies the deliverable options -// natively rather than through the prepared HTML. -type Capabilities struct { - Input Input - Tools []string - NativeOptions bool -} - -// Converter is one external Markdown/HTML-to-PDF toolchain, run as a -// subprocess. -type Converter interface { - // Name is what -pdf-engine selects the converter by. - Name() string - - // Capabilities states the converter's input form and tools. - Capabilities() Capabilities - - // Available returns nil, or a typed error naming the missing tool. - Available() error - - // Convert lays the prepared document out as PDF bytes. - Convert(doc *Prepared) ([]byte, error) -} - -// Engines names the converters, in the order they are offered. The first is -// the default. -func Engines() []string { return []string{"weasyprint", "pandoc", "prince"} } - -// EngineNamed returns the converter -pdf-engine named, the default for "", -// and a typed error for a name no converter answers to. -func EngineNamed(name string) (Converter, error) { - if name == "" { - name = Engines()[0] - } - switch name { - case "weasyprint": - return &weasyPrintConverter{}, nil - case "pandoc": - return &pandocConverter{}, nil - case "prince": - return &princeConverter{}, nil - default: - return nil, &Error{Kind: ErrorUnknownEngine, Engine: name, Engines: Engines()} - } -} - -// tool is one external executable a converter or the diagram renderer needs. -type tool struct { - name string // default executable name, looked up on PATH - envVar string // environment variable naming a specific executable -} - -var ( - pandocTool = tool{name: "pandoc", envVar: PandocEnv} - weasyPrintTool = tool{name: "weasyprint", envVar: WeasyPrintEnv} - princeTool = tool{name: "prince", envVar: PrinceEnv} - mermaidTool = tool{name: "mmdc", envVar: MermaidEnv} -) - -// locate finds the tool via its environment override or a PATH lookup; -// engine names the converter looking ("" for the diagram renderer). -func (t tool) locate(engine string) (string, error) { - if override := strings.TrimSpace(os.Getenv(t.envVar)); override != "" { - path, err := exec.LookPath(override) - if err != nil { - return "", &Error{Kind: ErrorToolMissing, Engine: engine, Tool: override, EnvVar: t.envVar} - } - return path, nil - } - path, err := exec.LookPath(t.name) - if err != nil { - return "", &Error{Kind: ErrorToolMissing, Engine: engine, Tool: t.name, EnvVar: t.envVar} - } - return path, nil -} - -// runTool runs one external executable in dir with SOURCE_DATE_EPOCH pinned -// for determinism; a failure is a typed error carrying the tool's stderr. -func runTool(dir, path string, args ...string) error { - ctx, cancel := context.WithTimeout(context.Background(), toolTimeout) - defer cancel() - cmd := exec.CommandContext(ctx, path, args...) // #nosec G204 -- the path is the operator's own converter choice - cmd.Dir = dir - cmd.Env = append(os.Environ(), "SOURCE_DATE_EPOCH=0") - var stderr strings.Builder - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - detail := strings.TrimSpace(stderr.String()) - if detail == "" { - detail = err.Error() - } - return &Error{Kind: ErrorToolFailed, Tool: filepath.Base(path), Detail: tail(detail)} - } - return nil -} - -// tail keeps the last lines of a tool's stderr, where the failure is said. -func tail(detail string) string { - lines := strings.Split(detail, "\n") - if len(lines) > 8 { - lines = lines[len(lines)-8:] - } - return strings.Join(lines, "\n") -} - -// readPDF reads the PDF a converter wrote, requiring the PDF signature. -func readPDF(dir, name, toolName string) ([]byte, error) { - pdf, err := os.ReadFile(filepath.Clean(filepath.Join(dir, name))) - if err != nil || !strings.HasPrefix(string(pdf), "%PDF-") { - return nil, &Error{Kind: ErrorNoPDF, Tool: toolName} - } - return pdf, nil -} - -// weasyPrintConverter lays the prepared HTML out with WeasyPrint, whose -// paged-media support carries the stylesheet's page numbers and breaks. -type weasyPrintConverter struct{} - -func (*weasyPrintConverter) Name() string { return "weasyprint" } - -func (*weasyPrintConverter) Capabilities() Capabilities { - return Capabilities{Input: InputHTML, Tools: []string{weasyPrintTool.name}} -} - -func (c *weasyPrintConverter) Available() error { - _, err := weasyPrintTool.locate(c.Name()) - return err -} - -func (c *weasyPrintConverter) Convert(doc *Prepared) ([]byte, error) { - path, err := weasyPrintTool.locate(c.Name()) - if err != nil { - return nil, err - } - if err := runTool(doc.Dir, path, doc.HTMLFile, outputName); err != nil { - return nil, err - } - return readPDF(doc.Dir, outputName, weasyPrintTool.name) -} - -// pandocConverter lays the prepared Markdown out with pandoc, applying the -// options through pandoc's own flags, with WeasyPrint as its PDF engine. -type pandocConverter struct{} - -func (*pandocConverter) Name() string { return "pandoc" } - -func (*pandocConverter) Capabilities() Capabilities { - return Capabilities{Input: InputMarkdown, Tools: []string{pandocTool.name, weasyPrintTool.name}, NativeOptions: true} -} - -func (c *pandocConverter) Available() error { - if _, err := pandocTool.locate(c.Name()); err != nil { - return err - } - _, err := weasyPrintTool.locate(c.Name()) - return err -} - -func (c *pandocConverter) Convert(doc *Prepared) ([]byte, error) { - pandoc, err := pandocTool.locate(c.Name()) - if err != nil { - return nil, err - } - engine, err := weasyPrintTool.locate(c.Name()) - if err != nil { - return nil, err - } - if err := os.WriteFile(filepath.Join(doc.Dir, pandocCSSName), []byte(pandocCSS(doc.Options)), 0o600); err != nil { - return nil, err - } - // Shifting the title heading into pandoc's title block keeps it unnumbered. - args := []string{ - doc.MarkdownFile, - "--from", "commonmark_x", - "--to", "pdf", - "--pdf-engine", engine, - "--standalone", - "--shift-heading-level-by", "-1", - "--variable", "document-css=false", - "--css", pandocCSSName, - "--output", outputName, - } - if doc.Options.TOC { - args = append(args, "--toc") - } - if doc.Options.NumberSections { - args = append(args, "--number-sections") - } - if err := runTool(doc.Dir, pandoc, args...); err != nil { - return nil, err - } - return readPDF(doc.Dir, outputName, pandocTool.name) -} - -// princeConverter lays the prepared HTML out with Prince, an alternative -// paged-media engine selected the same way. -type princeConverter struct{} - -func (*princeConverter) Name() string { return "prince" } - -func (*princeConverter) Capabilities() Capabilities { - return Capabilities{Input: InputHTML, Tools: []string{princeTool.name}} -} - -func (c *princeConverter) Available() error { - _, err := princeTool.locate(c.Name()) - return err -} - -func (c *princeConverter) Convert(doc *Prepared) ([]byte, error) { - path, err := princeTool.locate(c.Name()) - if err != nil { - return nil, err - } - if err := runTool(doc.Dir, path, doc.HTMLFile, "-o", outputName); err != nil { - return nil, err - } - return readPDF(doc.Dir, outputName, princeTool.name) -} - -// outputName is where a converter writes the PDF within the working directory. -const outputName = "document.pdf" - -// pandocCSSName is the stylesheet the pandoc converter writes for its engine. -const pandocCSSName = "pandoc.css" - -// pandocCSS is the print stylesheet for pandoc's own HTML: the shared layout -// rules, and a page of its own for pandoc's title block when asked for. -func pandocCSS(opts Options) string { - css := styleSheet - if opts.TitlePage { - css += "header#title-block-header { page-break-after: always; text-align: center; padding-top: 35%; }\n" - } - return css -} diff --git a/internal/docpdf/docpdf.go b/internal/docpdf/docpdf.go deleted file mode 100644 index 066d260e83..0000000000 --- a/internal/docpdf/docpdf.go +++ /dev/null @@ -1,45 +0,0 @@ -package docpdf - -import ( - "os" - "path/filepath" -) - -// Render converts docrender Markdown to PDF bytes with the named engine -// ("" selects the default). Diagrams are pre-rendered to SVG with mermaid-cli. -func Render(markdown, engine string, opts Options) ([]byte, error) { - converter, err := EngineNamed(engine) - if err != nil { - return nil, err - } - if err := converter.Available(); err != nil { - return nil, err - } - blocks, err := parseBlocks(markdown) - if err != nil { - return nil, err - } - dir, err := os.MkdirTemp("", "opensysml-docpdf-") - if err != nil { - return nil, err - } - defer func() { _ = os.RemoveAll(dir) }() - images, err := renderDiagrams(dir, blocks) - if err != nil { - return nil, err - } - doc := &Prepared{Dir: dir, MarkdownFile: "document.md", HTMLFile: "document.html", Options: opts} - switch converter.Capabilities().Input { - case InputMarkdown: - md := markdownWithImages(markdownWithSpanCaptions(markdown), images) - if err := os.WriteFile(filepath.Join(dir, doc.MarkdownFile), []byte(md), 0o600); err != nil { - return nil, err - } - case InputHTML: - page := documentHTML(blocks, images, opts) - if err := os.WriteFile(filepath.Join(dir, doc.HTMLFile), []byte(page), 0o600); err != nil { - return nil, err - } - } - return converter.Convert(doc) -} diff --git a/internal/docpdf/docpdf_test.go b/internal/docpdf/docpdf_test.go deleted file mode 100644 index ad0b7e117b..0000000000 --- a/internal/docpdf/docpdf_test.go +++ /dev/null @@ -1,571 +0,0 @@ -package docpdf - -import ( - "errors" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -const sampleMarkdown = "# Mass Report\n\nGenerated for review\\.\n\n## Components\n\n| Name | Mass \\| kg |\n| --- | --- |\n| Mirror | 120 |\n| Strut
    Assembly | 4\\.5 |\n\n\n*Table 1\\. Masses*\n\n- primary\n- secondary\n\n### Ordering\n\n1. first\n2. second\n\n```mermaid\nflowchart LR\n a --> b\n```\n\n\n*Figure 1\\. Flow*\n" - -func TestParseBlocks(t *testing.T) { - blocks, err := parseBlocks(sampleMarkdown) - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - kinds := make([]blockKind, len(blocks)) - for i, b := range blocks { - kinds[i] = b.Kind - } - want := []blockKind{blockHeading, blockParagraph, blockHeading, blockTable, blockCaption, blockList, blockHeading, blockList, blockMermaid, blockCaption} - if len(kinds) != len(want) { - t.Fatalf("got %d blocks, want %d: %v", len(kinds), len(want), kinds) - } - for i := range want { - if kinds[i] != want[i] { - t.Fatalf("block %d: got kind %d, want %d", i, kinds[i], want[i]) - } - } - table := blocks[3] - if got := table.Header[1]; got != "Mass \\| kg" { - t.Fatalf("escaped pipe header: got %q", got) - } - if got := table.Rows[1][0]; got != "Strut
    Assembly" { - t.Fatalf("folded newline cell: got %q", got) - } - if !blocks[7].Ordered || blocks[7].Items[1] != "second" { - t.Fatalf("numbered list: got %+v", blocks[7]) - } - if !strings.Contains(blocks[8].Source, "a --> b") { - t.Fatalf("mermaid source: got %q", blocks[8].Source) - } -} - -func TestParseBlocksSkipsHTMLComments(t *testing.T) { - md := "# T\n\n\n\n| Name |\n| --- |\n| Mirror |\n" - blocks, err := parseBlocks(md) - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - if len(blocks) != 2 || blocks[0].Kind != blockHeading || blocks[1].Kind != blockTable { - t.Fatalf("comments not skipped: %+v", blocks) - } -} - -func TestParseBlocksHyphenOnlyRows(t *testing.T) { - md := "| Name | Note |\n| --- | --- |\n| --- | - |\n| - | ---- |\n" - blocks, err := parseBlocks(md) - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - if len(blocks) != 1 || len(blocks[0].Rows) != 2 { - t.Fatalf("hyphen-only rows dropped: %+v", blocks) - } - if blocks[0].Rows[0][0] != "---" || blocks[0].Rows[1][1] != "----" { - t.Fatalf("hyphen cells: %+v", blocks[0].Rows) - } -} - -func TestParseBlocksEmptyNumberedItem(t *testing.T) { - blocks, err := parseBlocks("1. \n2. second\n") - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - if len(blocks) != 1 || !blocks[0].Ordered { - t.Fatalf("empty item split the list: %+v", blocks) - } - if len(blocks[0].Items) != 2 || blocks[0].Items[0] != "" || blocks[0].Items[1] != "second" { - t.Fatalf("items: %+v", blocks[0].Items) - } -} - -func TestIsCaptionEscapes(t *testing.T) { - for line, want := range map[string]bool{ - `*Figure 1\. Flow*`: true, - `*ends with \\*`: true, // escaped backslash, live closer - `*unterminated\*`: false, // escaped closer - `*inner * asterisk*`: false, - `**`: false, - `*x*`: true, - } { - if got := isCaption(line); got != want { - t.Fatalf("isCaption(%q) = %v, want %v", line, got, want) - } - } -} - -func TestParseBlocksCaptionNeedsMarker(t *testing.T) { - md := "# T\n\n\n*Table 1\\. Masses*\n\n*just emphasis*\n\n\n*Figure 1\\. Flow*\n" - blocks, err := parseBlocks(md) - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - want := []blockKind{blockHeading, blockCaption, blockParagraph, blockCaption} - if len(blocks) != len(want) { - t.Fatalf("got %d blocks, want %d: %+v", len(blocks), len(want), blocks) - } - for i := range want { - if blocks[i].Kind != want[i] { - t.Fatalf("block %d: got kind %d, want %d", i, blocks[i].Kind, want[i]) - } - } - if blocks[1].Text != `Table 1\. Masses` || blocks[3].Text != `Figure 1\. Flow` { - t.Fatalf("caption texts: %q, %q", blocks[1].Text, blocks[3].Text) - } - if blocks[2].Text != "*just emphasis*" { - t.Fatalf("emphasis paragraph: %q", blocks[2].Text) - } -} - -func TestParseBlocksDanglingCaptionMarker(t *testing.T) { - for _, md := range []string{ - "# T\n\n\n", - "# T\n\n\nplain paragraph\n", - "# T\n\n\n*inner * asterisk*\n", - } { - _, err := parseBlocks(md) - var docErr *Error - if !errors.As(err, &docErr) || docErr.Kind != ErrorDanglingCaption { - t.Fatalf("parseBlocks(%q) = %v, want ErrorDanglingCaption", md, err) - } - if err.Error() == "" { - t.Fatal("empty error message") - } - } -} - -func TestDocumentHTMLCaptionVersusEmphasisParagraph(t *testing.T) { - md := "# T\n\n\n*Table 1\\. Masses*\n\n*just emphasis*\n" - blocks, err := parseBlocks(md) - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - page := documentHTML(blocks, nil, Options{}) - if !strings.Contains(page, `

    Table 1. Masses

    `) { - t.Fatalf("caption not styled as caption:\n%s", page) - } - if !strings.Contains(page, "

    just emphasis

    ") { - t.Fatalf("emphasis paragraph not a plain paragraph:\n%s", page) - } - if strings.Contains(page, `

    just emphasis

    `) { - t.Fatalf("emphasis paragraph styled as caption:\n%s", page) - } -} - -func TestMarkdownWithSpanCaptions(t *testing.T) { - md := "# T\n\n\n*Table 1\\. Masses*\n\n*just emphasis*\n" - got := markdownWithSpanCaptions(md) - want := "# T\n\n[*Table 1\\. Masses*]{.caption}\n\n*just emphasis*\n" - if got != want { - t.Fatalf("markdownWithSpanCaptions = %q, want %q", got, want) - } -} - -func TestParseBlocksUnclosedFence(t *testing.T) { - for _, fence := range []string{"```mermaid\nflowchart LR\n", "```dot\ndigraph {\n"} { - _, err := parseBlocks("# T\n\n" + fence) - var docErr *Error - if !errors.As(err, &docErr) || docErr.Kind != ErrorUnclosedFence { - t.Fatalf("%q: got %v, want ErrorUnclosedFence", fence, err) - } - } -} - -// dotMarkdown is a document whose one diagram is written as DOT: a `&` and a -// quoted ID check the source reaches the page escaped, not interpreted. -const dotMarkdown = "# T\n\n## Flow\n\n```dot\n// kind: action\ndigraph \"a & b\" {\n \"n0\" -> \"n1\";\n}\n```\n\n\n*Figure 1\\. Flow*\n" - -// A DOT fence parses to its own block and is kept as source under a notice -// on both converter inputs; no diagram tool is looked for. -func TestDOTBlockIsKeptAsSource(t *testing.T) { - blocks, err := parseBlocks(dotMarkdown) - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - if len(blocks) != 4 || blocks[2].Kind != blockDOT || !strings.Contains(blocks[2].Source, `"n0" -> "n1";`) { - t.Fatalf("blocks = %+v", blocks) - } - t.Setenv("PATH", t.TempDir()) - t.Setenv(MermaidEnv, "") - images, err := renderDiagrams(t.TempDir(), blocks) - if err != nil || len(images) != 0 { - t.Fatalf("renderDiagrams = %v, %v; want no images and no tool lookup", images, err) - } - page := documentHTML(blocks, nil, Options{}) - for _, want := range []string{ - `

    ` + dotNotice + `

    `, - "
    // kind: action\ndigraph "a & b" {\n  "n0" -> "n1";\n}
    ", - `

    Figure 1. Flow

    `, - } { - if !strings.Contains(page, want) { - t.Errorf("HTML missing %q:\n%s", want, page) - } - } - if strings.Contains(page, "`) - if image < 0 || dot < 0 || image > dot { - t.Fatalf("image at %d, DOT at %d:\n%s", image, dot, page) - } - out := markdownWithImages(md, []string{"diagram-1.svg"}) - if strings.Contains(out, "```mermaid") || !strings.Contains(out, "![diagram](diagram-1.svg)") || !strings.Contains(out, "```dot\n") { - t.Fatalf("mixed Markdown:\n%s", out) - } -} - -// Rendering a document whose only diagram is DOT needs no Mermaid CLI. -func TestRenderDOTOnlyDocumentNeedsNoDiagramTool(t *testing.T) { - dir := t.TempDir() - seenPath := filepath.Join(dir, "input-seen.html") - fakeTool(t, dir, "weasyprint", WeasyPrintEnv, `while IFS= read -r line; do printf '%s\n' "$line"; done < "$1" > "`+seenPath+`" -printf '%%PDF-1.7 fake' > "$2" -`) - t.Setenv("PATH", dir) - t.Setenv(MermaidEnv, "") - pdf, err := Render(dotMarkdown, "weasyprint", Options{}) - if err != nil { - t.Fatalf("Render: %v", err) - } - if !strings.HasPrefix(string(pdf), "%PDF-") { - t.Fatalf("output is no PDF: %q", pdf) - } - seen, err := os.ReadFile(seenPath) - if err != nil { - t.Fatalf("converter input: %v", err) - } - if !strings.Contains(string(seen), dotNotice) || !strings.Contains(string(seen), "digraph "a & b"") { - t.Fatalf("converter input lacks the DOT notice or source:\n%s", seen) - } -} - -// plantumlMarkdown is a document whose one diagram is written as PlantUML: a -// creole label and a style block check the source reaches the page escaped. -const plantumlMarkdown = "# T\n\n## Flow\n\n```plantuml\n@startuml\n' action rendering\n\nstate \"**a & b**\" as n0 <>\nn0 --> n1\n@enduml\n```\n\n\n*Figure 1\\. Flow*\n" - -// A PlantUML fence parses to its own block and is kept as source under a -// notice on both converter inputs; no diagram tool is looked for. -func TestPlantUMLBlockIsKeptAsSource(t *testing.T) { - blocks, err := parseBlocks(plantumlMarkdown) - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - if len(blocks) != 4 || blocks[2].Kind != blockPlantUML || !strings.Contains(blocks[2].Source, "n0 --> n1") { - t.Fatalf("blocks = %+v", blocks) - } - t.Setenv("PATH", t.TempDir()) - t.Setenv(MermaidEnv, "") - images, err := renderDiagrams(t.TempDir(), blocks) - if err != nil || len(images) != 0 { - t.Fatalf("renderDiagrams = %v, %v; want no images and no tool lookup", images, err) - } - page := documentHTML(blocks, nil, Options{}) - for _, want := range []string{ - `

    ` + plantumlNotice + `

    `, - "
    @startuml\n' action rendering\n<style>\n",
    -		"state "**a & b**" as n0 <<action>>\nn0 --> n1\n@enduml
    ", - `

    Figure 1. Flow

    `, - } { - if !strings.Contains(page, want) { - t.Errorf("HTML missing %q:\n%s", want, page) - } - } - if strings.Contains(page, " "`+seenPath+`" -printf '%%PDF-1.7 fake' > "$2" -`) - t.Setenv("PATH", dir) - t.Setenv(MermaidEnv, "") - pdf, err := Render(plantumlMarkdown, "weasyprint", Options{}) - if err != nil { - t.Fatalf("Render: %v", err) - } - if !strings.HasPrefix(string(pdf), "%PDF-") { - t.Fatalf("output is no PDF: %q", pdf) - } - seen, err := os.ReadFile(seenPath) - if err != nil { - t.Fatalf("converter input: %v", err) - } - if !strings.Contains(string(seen), plantumlNotice) || !strings.Contains(string(seen), "n0 --> n1") { - t.Fatalf("converter input lacks the PlantUML notice or source:\n%s", seen) - } -} - -func TestUnescape(t *testing.T) { - if got := unescape(`Mass \| kg \* 2\\`); got != `Mass | kg * 2\` { - t.Fatalf("unescape: got %q", got) - } - if got := unescape(`no\wescapes`); got != `no\wescapes` { - t.Fatalf("non-punct escape: got %q", got) - } -} - -func TestDocumentHTML(t *testing.T) { - blocks, err := parseBlocks(sampleMarkdown) - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - opts := Options{TitlePage: true, TOC: true, NumberSections: true} - page := documentHTML(blocks, []string{"diagram-1.svg"}, opts) - if page != documentHTML(blocks, []string{"diagram-1.svg"}, opts) { - t.Fatal("HTML generation is nondeterministic") - } - for _, want := range []string{ - "Mass Report", - `

    Mass Report

    `, - `
    ", - "", - `", - "
  • secondary
  • ", - "Table 1. Masses", - } { - if !strings.Contains(page, want) { - t.Fatalf("HTML missing %q:\n%s", want, page) - } - } - plain := documentHTML(blocks, []string{"diagram-1.svg"}, Options{}) - if strings.Contains(plain, `
    `) || strings.Contains(plain, `
    wheels[1]30car.engine
    on
    1 [s]level = 3panel
    violatedMass | kgStrut
    Assembly
    \n\n\n") - for _, cell := range blk.Header { - b.WriteString("\n") - } - b.WriteString("\n\n\n") - for _, row := range blk.Rows { - b.WriteString("\n") - for _, cell := range row { - b.WriteString("\n") - } - b.WriteString("\n") - } - b.WriteString("\n
    " + cellHTML(cell) + "
    " + cellHTML(cell) + "
    \n") -} - -// writeList writes one bullet or numbered list. -func writeList(b *strings.Builder, blk block) { - tag := "ul" - if blk.Ordered { - tag = "ol" - } - b.WriteString("<" + tag + ">\n") - for _, item := range blk.Items { - b.WriteString("
  • " + inlineHTML(item) + "
  • \n") - } - b.WriteString("\n") -} - -// cellHTML writes one table cell. Every literal metacharacter in a cell is -// Markdown-escaped, so an unescaped
    is docrender's fold of a newline and -// is kept as the line break it stands for. -func cellHTML(cell string) string { - parts := strings.Split(cell, "
    ") - for i, part := range parts { - parts[i] = inlineHTML(part) - } - return strings.Join(parts, "
    ") -} - -// styleSheet lays the document out for print: pages numbered in the footer, -// bordered tables, captions in small type, diagrams scaled to the text width, -// and a title page and table of contents on pages of their own when written. -const styleSheet = `@page { - margin: 2.2cm 2.2cm; - @bottom-center { content: counter(page); font-size: 9pt; color: #444444; } -} -body { font-family: serif; font-size: 11pt; line-height: 1.45; } -h1, h2, h3, h4, h5, h6 { font-family: sans-serif; line-height: 1.2; } -h1 { font-size: 20pt; } -h2 { font-size: 15pt; margin-top: 1.4em; } -h3 { font-size: 13pt; margin-top: 1.2em; } -h4, h5, h6 { font-size: 11pt; margin-top: 1em; } -.title-page { page-break-after: always; text-align: center; padding-top: 35%; } -.title-page h1 { font-size: 26pt; } -nav.toc { page-break-after: always; } -nav.toc ul { list-style: none; padding-left: 0; } -nav.toc li { margin: 0.25em 0; } -nav.toc li.toc-2 { padding-left: 1.5em; } -nav.toc li.toc-3 { padding-left: 3em; } -nav.toc li.toc-4 { padding-left: 4.5em; } -nav.toc li.toc-5 { padding-left: 6em; } -nav.toc a { text-decoration: none; color: inherit; } -table { border-collapse: collapse; margin: 0.8em 0; } -th, td { border: 0.5pt solid #666666; padding: 0.3em 0.6em; text-align: left; } -th { background: #eeeeee; } -p.caption, span.caption { font-size: 9.5pt; color: #444444; } -figure { margin: 0.8em 0; } -figure img { max-width: 100%; } -figure.dot pre, figure.plantuml pre { font-size: 9pt; white-space: pre-wrap; } -p.notice { font-size: 9.5pt; color: #444444; } -` diff --git a/internal/docpdf/inline.go b/internal/docpdf/inline.go deleted file mode 100644 index 7233e818b5..0000000000 --- a/internal/docpdf/inline.go +++ /dev/null @@ -1,190 +0,0 @@ -package docpdf - -import ( - "html" - "strings" -) - -// This file renders docrender's inline dialect to HTML: escaped prose, -// emphasis and strong spans, code spans, inline links, and reference links -// to in-document anchors. Anything malformed stays literal text. - -// inlineHTML renders one line of the inline dialect as HTML element content. -func inlineHTML(text string) string { - var b strings.Builder - var literal strings.Builder - flush := func() { - b.WriteString(literalHTML(literal.String())) - literal.Reset() - } - for i := 0; i < len(text); { - switch text[i] { - case '\\': - literal.WriteByte(text[i]) - if i+1 < len(text) { - literal.WriteByte(text[i+1]) - i += 2 - continue - } - i++ - case '`': - fence := backtickRun(text, i) - body, next, ok := codeSpanBody(text, i+fence, fence) - if !ok { - literal.WriteString(text[i : i+fence]) - i += fence - continue - } - flush() - b.WriteString("" + html.EscapeString(body) + "") - i = next - case '*': - marker := "*" - if strings.HasPrefix(text[i:], "**") { - marker = "**" - } - end := closingMarker(text, i+len(marker), marker) - if end < 0 { - literal.WriteString(marker) - i += len(marker) - continue - } - flush() - tag := "em" - if marker == "**" { - tag = "strong" - } - b.WriteString("<" + tag + ">" + literalHTML(text[i+len(marker):end]) + "") - i = end + len(marker) - case '[': - label, href, next, ok := linkAt(text, i) - if !ok { - literal.WriteByte(text[i]) - i++ - continue - } - flush() - b.WriteString(`` + literalHTML(label) + "") - i = next - default: - literal.WriteByte(text[i]) - i++ - } - } - flush() - return b.String() -} - -// literalHTML unescapes a stretch of escaped prose and escapes it for HTML. -func literalHTML(text string) string { - return html.EscapeString(unescape(text)) -} - -// backtickRun counts the consecutive backticks starting at i. -func backtickRun(text string, i int) int { - n := 0 - for i+n < len(text) && text[i+n] == '`' { - n++ - } - return n -} - -// codeSpanBody finds the closing fence of exactly the opening length and -// returns the span's content — with the single space of padding stripped -// from each end when both are present, undoing what codeSpan added — and -// the index after the closing fence. -func codeSpanBody(text string, start, fence int) (string, int, bool) { - for j := start; j < len(text); j++ { - if text[j] != '`' { - continue - } - run := backtickRun(text, j) - if run == fence { - body := text[start:j] - if len(body) >= 2 && body[0] == ' ' && body[len(body)-1] == ' ' { - body = body[1 : len(body)-1] - } - return body, j + run, true - } - j += run - 1 - } - return "", 0, false -} - -// closingMarker finds the next unescaped emphasis marker from start, or -1. -func closingMarker(text string, start int, marker string) int { - for i := start; i < len(text); i++ { - switch text[i] { - case '\\': - i++ - case '*': - if strings.HasPrefix(text[i:], marker) { - return i - } - } - } - return -1 -} - -// linkAt parses an inline link at i: "[label]()" for a URL with -// the pointy-bracket escapes undone, or "[label](#anchor)" for an -// in-document reference kept as the fragment href. -func linkAt(text string, i int) (label, href string, next int, ok bool) { - end := closingBracket(text, i+1) - if end < 0 || end+1 >= len(text) || text[end+1] != '(' { - return "", "", 0, false - } - label = text[i+1 : end] - rest := text[end+2:] - if strings.HasPrefix(rest, "<") { - dest, after, closed := destinationBody(rest[1:]) - if !closed || after >= len(rest) || rest[after] != ')' { - return "", "", 0, false - } - return label, dest, end + 2 + after + 1, true - } - if strings.HasPrefix(rest, "#") { - closeAt := strings.IndexByte(rest, ')') - if closeAt < 0 { - return "", "", 0, false - } - return label, rest[:closeAt], end + 2 + closeAt + 1, true - } - return "", "", 0, false -} - -// closingBracket finds the unescaped "]" ending a link label, or -1. -func closingBracket(text string, start int) int { - for i := start; i < len(text); i++ { - switch text[i] { - case '\\': - i++ - case ']': - return i - } - } - return -1 -} - -// destinationBody reads a pointy-bracket destination after its opening "<", -// undoing the backslash escapes of backslashes and angle brackets, and -// returns the index just past the closing ">" relative to that "<". -func destinationBody(text string) (string, int, bool) { - var b strings.Builder - for i := 0; i < len(text); i++ { - switch text[i] { - case '\\': - if i+1 < len(text) { - b.WriteByte(text[i+1]) - i++ - continue - } - b.WriteByte('\\') - case '>': - return b.String(), i + 2, true - default: - b.WriteByte(text[i]) - } - } - return "", 0, false -} diff --git a/internal/docpdf/inline_test.go b/internal/docpdf/inline_test.go deleted file mode 100644 index 3e99a608e4..0000000000 --- a/internal/docpdf/inline_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package docpdf - -import ( - "strings" - "testing" -) - -// TestInlineHTMLPlainText checks that escaped prose unescapes and -// HTML-escapes, with non-punctuation escapes kept literal. -func TestInlineHTMLPlainText(t *testing.T) { - for _, c := range []struct{ in, want string }{ - {`Generated for review\.`, "Generated for review."}, - {`Mass \| kg \* 2\\`, `Mass | kg * 2\`}, - {`a \< b \& c`, "a < b & c"}, - {`no\wescapes`, `no\wescapes`}, - {`trailing\`, `trailing\`}, - {"", ""}, - } { - if got := inlineHTML(c.in); got != c.want { - t.Errorf("inlineHTML(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -// TestInlineHTMLEmphasis checks emphasis and strong spans, including the -// flanking whitespace and escaped delimiters docrender writes. -func TestInlineHTMLEmphasis(t *testing.T) { - for _, c := range []struct{ in, want string }{ - {"*plain*", "plain"}, - {`*with \*stars\**`, "with *stars*"}, - {" *padded* ", " padded "}, - {"\t*tabbed*\t", "\ttabbed\t"}, - {`**bold\_move**`, "bold_move"}, - {`**a\|b**`, "a|b"}, - {"before **mid** after", "before mid after"}, - {"*em* and **strong**", "em and strong"}, - {"unclosed *span", "unclosed *span"}, - {"unclosed **span", "unclosed **span"}, - } { - if got := inlineHTML(c.in); got != c.want { - t.Errorf("inlineHTML(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -// TestInlineHTMLCodeSpans checks the inverse of docrender's code-span fence -// contract: longer fences, padding, and content kept verbatim. -func TestInlineHTMLCodeSpans(t *testing.T) { - for _, c := range []struct{ in, want string }{ - {"`x > 1`", "x > 1"}, - {"`` a `tick` ``", "a `tick`"}, - {"``` ``double`` ```", "``double``"}, - {"`` `leading ``", "`leading"}, - {"` padded `", " padded "}, - {"` `", ""}, - {"`two lines`", "two lines"}, - {"see `code` here", "see code here"}, - {"unclosed `span", "unclosed `span"}, - {"`*not em*`", "*not em*"}, - } { - if got := inlineHTML(c.in); got != c.want { - t.Errorf("inlineHTML(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -// TestInlineHTMLLinks checks inline links: pointy-bracket destinations with -// their escapes undone, and reference links kept as fragment hrefs. -func TestInlineHTMLLinks(t *testing.T) { - for _, c := range []struct{ in, want string }{ - {"[spec]()", `spec`}, - {"[a b]()", `a b`}, - {`[x]()`, `x`}, - {`[x](c>)`, `x`}, - {"[x]()", `x`}, - {"[items](#items)", `items`}, - {"see [items](#Report-items) here", `see items here`}, - {`[label \*escaped\*](#a.2Eb)`, `label *escaped*`}, - {`\[not a link\](x)`, "[not a link](x)"}, - {"[no destination]", "[no destination]"}, - {"[bare](plain)", "[bare](plain)"}, - {"[unclosed]( 0`" + ` per [spec]() and [table](#Report-masses)\.` - want := `The margin is critical for m > 0 per spec and table.` - if got := inlineHTML(in); got != want { - t.Errorf("inlineHTML(%q) = %q, want %q", in, got, want) - } -} - -// TestParseBlocksAnchor checks that docrender's standalone anchor lines parse -// into anchor blocks with their identifiers. -func TestParseBlocksAnchor(t *testing.T) { - md := "# T\n\n\n\n## Items\n\n\n\nparagraph\n" - blocks, err := parseBlocks(md) - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - kinds := []blockKind{blockHeading, blockAnchor, blockHeading, blockAnchor, blockParagraph} - if len(blocks) != len(kinds) { - t.Fatalf("got %d blocks, want %d: %+v", len(blocks), len(kinds), blocks) - } - for i, want := range kinds { - if blocks[i].Kind != want { - t.Fatalf("block %d: got kind %d, want %d", i, blocks[i].Kind, want) - } - } - if blocks[1].Anchor != "Report-items" || blocks[3].Anchor != "a.2Eb-c" { - t.Fatalf("anchor ids: %q, %q", blocks[1].Anchor, blocks[3].Anchor) - } -} - -// TestDocumentHTMLInlineRuns checks that a document using every inline -// construct docrender emits renders as semantic HTML: styled runs, links, -// native anchors, fragment references, and grouped-table headings. -func TestDocumentHTMLInlineRuns(t *testing.T) { - md := strings.Join([]string{ - "# Inline Report", - "", - ``, - "", - "## Masses", - "", - "The *margin* is **critical** for `m > 0` per [spec]()\\.", - "", - "See [the masses](#Inline.20Report-masses) above\\.", - "", - "**zone: hot**", - "", - "| name | zone |", - "| --- | --- |", - "| Mirror | hot |", - "", - "**zone: cold**", - "", - "| name | zone |", - "| --- | --- |", - "| Strut | cold |", - "", - "- item with *style*", - "", - "", - "*Table 1\\. Grouped masses*", - "", - }, "\n") - blocks, err := parseBlocks(md) - if err != nil { - t.Fatalf("parseBlocks: %v", err) - } - page := documentHTML(blocks, nil, Options{}) - for _, want := range []string{ - ``, - "

    The margin is critical for m > 0 per " + - `spec.

    `, - `

    See the masses above.

    `, - "

    zone: hot

    ", - "

    zone: cold

    ", - "
  • item with style
  • ", - `

    Table 1. Grouped masses

    `, - } { - if !strings.Contains(page, want) { - t.Fatalf("HTML missing %q:\n%s", want, page) - } - } - for _, stray := range []string{`\*`, "**zone", `

    <a id=`} { - if strings.Contains(page, stray) { - t.Fatalf("HTML leaks literal Markdown %q:\n%s", stray, page) - } - } -} diff --git a/internal/docpdf/integration_test.go b/internal/docpdf/integration_test.go deleted file mode 100644 index 9b3d3a44b0..0000000000 --- a/internal/docpdf/integration_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package docpdf - -import ( - "errors" - "strings" - "testing" -) - -// TestRenderWithInstalledEngines exercises each real converter when its tools -// are installed, and skips otherwise; the contract itself is tested with -// fakes in docpdf_test.go. -func TestRenderWithInstalledEngines(t *testing.T) { - for _, engine := range Engines() { - t.Run(engine, func(t *testing.T) { - converter, err := EngineNamed(engine) - if err != nil { - t.Fatal(err) - } - if err := converter.Available(); err != nil { - var docErr *Error - if errors.As(err, &docErr) && docErr.Kind == ErrorToolMissing { - t.Skipf("%s not installed: %v", engine, err) - } - t.Fatal(err) - } - pdf, err := Render("# Smoke Test\n\nOne paragraph\\.\n", engine, Options{TOC: true, NumberSections: true}) - if err != nil { - t.Fatalf("Render: %v", err) - } - if !strings.HasPrefix(string(pdf), "%PDF-") { - t.Fatalf("output is no PDF: %.16q", pdf) - } - }) - } -} - -// TestRenderInlineRunsWithInstalledEngines renders a document using every -// inline construct — styled runs, links, reference links to anchors, and -// grouped-table headings — through each installed converter. -func TestRenderInlineRunsWithInstalledEngines(t *testing.T) { - markdown := strings.Join([]string{ - "# Inline Report", - "", - ``, - "", - "## Masses", - "", - "The *margin* is **critical** for `m > 0` per [spec]()\\.", - "", - "See [the masses](#Inline.20Report-masses) above\\.", - "", - "**zone: hot**", - "", - "| name | zone |", - "| --- | --- |", - "| Mirror | hot |", - "", - }, "\n") - for _, engine := range Engines() { - t.Run(engine, func(t *testing.T) { - converter, err := EngineNamed(engine) - if err != nil { - t.Fatal(err) - } - if err := converter.Available(); err != nil { - var docErr *Error - if errors.As(err, &docErr) && docErr.Kind == ErrorToolMissing { - t.Skipf("%s not installed: %v", engine, err) - } - t.Fatal(err) - } - pdf, err := Render(markdown, engine, Options{}) - if err != nil { - t.Fatalf("Render: %v", err) - } - if !strings.HasPrefix(string(pdf), "%PDF-") { - t.Fatalf("output is no PDF: %.16q", pdf) - } - }) - } -} - -// TestRenderDiagramsWithInstalledMermaid renders a real diagram when -// mermaid-cli and an engine are installed, and skips otherwise. -func TestRenderDiagramsWithInstalledMermaid(t *testing.T) { - if _, err := mermaidTool.locate(""); err != nil { - t.Skipf("mmdc not installed: %v", err) - } - converter, err := EngineNamed("") - if err != nil { - t.Fatal(err) - } - if err := converter.Available(); err != nil { - t.Skipf("%s not installed: %v", converter.Name(), err) - } - markdown := "# Diagram Test\n\n```mermaid\nflowchart LR\n a --> b\n```\n" - pdf, err := Render(markdown, "", Options{}) - if err != nil { - t.Fatalf("Render: %v", err) - } - if !strings.HasPrefix(string(pdf), "%PDF-") { - t.Fatalf("output is no PDF: %.16q", pdf) - } -} diff --git a/internal/docpdf/markdown.go b/internal/docpdf/markdown.go deleted file mode 100644 index 2302ea4dc4..0000000000 --- a/internal/docpdf/markdown.go +++ /dev/null @@ -1,309 +0,0 @@ -package docpdf - -import "strings" - -// This file parses docrender's closed Markdown dialect back into blocks, -// keeping the PDF layer independent of the document IR. - -// blockKind names one kind of parsed Markdown block. -type blockKind int - -const ( - blockHeading blockKind = iota - blockParagraph - blockCaption - blockTable - blockList - blockMermaid - blockDOT - blockPlantUML - blockAnchor -) - -// block is one parsed Markdown block. Text fields hold Markdown-escaped -// prose; Source holds a diagram block's raw body. -type block struct { - Kind blockKind - Level int // blockHeading: ATX level 1..6 - Text string // blockHeading, blockParagraph, blockCaption - Anchor string // blockAnchor: the stable identifier - Header []string // blockTable: column headers - Rows [][]string // blockTable: body rows - Ordered bool // blockList - Items []string // blockList - Source string // blockMermaid, blockDOT, blockPlantUML -} - -// Fences docrender opens diagram blocks with. -const ( - mermaidFence = "```mermaid" - dotFence = "```dot" - plantumlFence = "```plantuml" -) - -// parseBlocks parses docrender's Markdown dialect into blocks. -func parseBlocks(markdown string) ([]block, error) { - lines := strings.Split(markdown, "\n") - var blocks []block - for i := 0; i < len(lines); i++ { - line := lines[i] - switch { - case strings.TrimSpace(line) == "": - continue - case line == captionMarker: - if i+1 >= len(lines) || !isCaption(lines[i+1]) { - return nil, &Error{Kind: ErrorDanglingCaption} - } - blocks = append(blocks, block{Kind: blockCaption, Text: lines[i+1][1 : len(lines[i+1])-1]}) - i++ - case strings.HasPrefix(line, ""): - // docrender's table-rendering provenance and notice comments are - // metadata, not prose. - continue - case strings.HasPrefix(line, anchorOpen) && strings.HasSuffix(line, anchorClose): - blocks = append(blocks, block{Kind: blockAnchor, Anchor: line[len(anchorOpen) : len(line)-len(anchorClose)]}) - case line == mermaidFence || line == dotFence || line == plantumlFence: - body, next, ok := fenceBody(lines, i+1) - if !ok { - return nil, &Error{Kind: ErrorUnclosedFence} - } - kind := blockMermaid - switch line { - case dotFence: - kind = blockDOT - case plantumlFence: - kind = blockPlantUML - } - blocks = append(blocks, block{Kind: kind, Source: body}) - i = next - case strings.HasPrefix(line, "#"): - level, text := headingParts(line) - blocks = append(blocks, block{Kind: blockHeading, Level: level, Text: text}) - case strings.HasPrefix(line, "| "): - table, next := tableBlock(lines, i) - blocks = append(blocks, table) - i = next - case isListItem(line): - list, next := listBlock(lines, i) - blocks = append(blocks, list) - i = next - default: - blocks = append(blocks, block{Kind: blockParagraph, Text: line}) - } - } - return blocks, nil -} - -// anchorOpen and anchorClose delimit the standalone HTML anchor line -// docrender writes before a content node a reference targets. -const ( - anchorOpen = `` -) - -// captionMarker is the comment line docrender writes before a caption, -// distinguishing it from a paragraph that is one emphasis run. -const captionMarker = "" - -// markdownWithSpanCaptions rewrites each marked caption as a bracketed span -// carrying the caption class, so converters that read the Markdown -// themselves style captions the same way the prepared HTML does. -func markdownWithSpanCaptions(markdown string) string { - lines := strings.Split(markdown, "\n") - out := make([]string, 0, len(lines)) - for i := 0; i < len(lines); i++ { - if lines[i] == captionMarker && i+1 < len(lines) && isCaption(lines[i+1]) { - out = append(out, "["+lines[i+1]+"]{.caption}") - i++ - continue - } - out = append(out, lines[i]) - } - return strings.Join(out, "\n") -} - -// fenceBody collects the lines of a fenced block opened before start, -// returning the body, the index of the closing fence, and whether one closed. -func fenceBody(lines []string, start int) (string, int, bool) { - for i := start; i < len(lines); i++ { - if lines[i] == "```" { - return strings.Join(lines[start:i], "\n"), i, true - } - } - return "", 0, false -} - -// headingParts splits an ATX heading into its level, saturating at 6, and text. -func headingParts(line string) (int, string) { - level := 0 - for level < len(line) && line[level] == '#' { - level++ - } - if level > 6 { - level = 6 - } - return level, strings.TrimPrefix(line[level:], " ") -} - -// tableBlock parses a pipe table from index i, returning it and the index of -// its last line. Only the row right after the header is the delimiter; every -// later pipe line is data, hyphen-only cells included. -func tableBlock(lines []string, i int) (block, int) { - table := block{Kind: blockTable} - table.Header = tableCells(lines[i]) - last := i - for j := i + 1; j < len(lines) && strings.HasPrefix(lines[j], "|"); j++ { - last = j - if j == i+1 && isDelimiterRow(lines[j]) { - continue - } - table.Rows = append(table.Rows, tableCells(lines[j])) - } - return table, last -} - -// isDelimiterRow reports whether a table line is the header delimiter. -func isDelimiterRow(line string) bool { - trimmed := strings.Trim(line, "| ") - if trimmed == "" { - return false - } - for _, r := range trimmed { - if r != '-' && r != ' ' && r != '|' { - return false - } - } - return true -} - -// tableCells splits one pipe-table row into its cells: the row is written as -// "| a | b |" with every literal pipe escaped, so unescaped pipes delimit. -func tableCells(line string) []string { - line = strings.TrimSuffix(strings.TrimPrefix(line, "|"), "|") - var cells []string - var cell strings.Builder - escaped := false - for _, r := range line { - switch { - case escaped: - cell.WriteRune('\\') - cell.WriteRune(r) - escaped = false - case r == '\\': - escaped = true - case r == '|': - cells = append(cells, strings.TrimSpace(cell.String())) - cell.Reset() - default: - cell.WriteRune(r) - } - } - if escaped { - cell.WriteRune('\\') - } - cells = append(cells, strings.TrimSpace(cell.String())) - return cells -} - -// isListItem reports whether a line opens a bullet or numbered list item. -func isListItem(line string) bool { - if strings.HasPrefix(line, "- ") { - return true - } - _, ok := numberedItemText(line) - return ok -} - -// numberedItemText returns the text of a "N. item" line and whether the line -// is a numbered item; an item's text may be empty. -func numberedItemText(line string) (string, bool) { - digits := 0 - for digits < len(line) && line[digits] >= '0' && line[digits] <= '9' { - digits++ - } - if digits == 0 || !strings.HasPrefix(line[digits:], ". ") { - return "", false - } - return line[digits+2:], true -} - -// listBlock parses a list from index i, returning it and the index of its -// last line. -func listBlock(lines []string, i int) (block, int) { - _, ordered := numberedItemText(lines[i]) - list := block{Kind: blockList, Ordered: ordered} - last := i - for j := i; j < len(lines) && isListItem(lines[j]); j++ { - last = j - if text, ok := numberedItemText(lines[j]); ok { - list.Items = append(list.Items, text) - } else { - list.Items = append(list.Items, strings.TrimPrefix(lines[j], "- ")) - } - } - return list, last -} - -// isCaption reports whether a line is one fully-emphasized span, which is -// how docrender writes a caption's text under its marker: the first -// unescaped asterisk after the opener must be the line's last byte. -func isCaption(line string) bool { - if len(line) < 3 || line[0] != '*' { - return false - } - inner := line[1:] - escaped := false - for i, r := range inner { - switch { - case escaped: - escaped = false - case r == '\\': - escaped = true - case r == '*': - return i == len(inner)-1 - } - } - return false -} - -// unescape removes the backslash escapes docrender writes before ASCII -// punctuation, recovering the literal text. -func unescape(text string) string { - var b strings.Builder - b.Grow(len(text)) - escaped := false - for _, r := range text { - switch { - case escaped: - if !isASCIIPunct(r) { - b.WriteRune('\\') - } - b.WriteRune(r) - escaped = false - case r == '\\': - escaped = true - default: - b.WriteRune(r) - } - } - if escaped { - b.WriteRune('\\') - } - return b.String() -} - -// isASCIIPunct reports whether a rune is ASCII punctuation, the class -// CommonMark backslash escapes cover. -func isASCIIPunct(r rune) bool { - switch { - case r >= '!' && r <= '/': - return true - case r >= ':' && r <= '@': - return true - case r >= '[' && r <= '`': - return true - case r >= '{' && r <= '~': - return true - } - return false -} diff --git a/internal/docpdf/mermaid.go b/internal/docpdf/mermaid.go deleted file mode 100644 index 50aeb52443..0000000000 --- a/internal/docpdf/mermaid.go +++ /dev/null @@ -1,98 +0,0 @@ -package docpdf - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// renderDiagrams renders each Mermaid block to an SVG in dir with the pinned -// mermaid-cli, returning the image file names in block order. A document -// without Mermaid diagrams needs no diagram tool at all; a DOT or PlantUML -// block is kept as source (see dotNotice, plantumlNotice) and never handed to -// Graphviz or PlantUML. -func renderDiagrams(dir string, blocks []block) ([]string, error) { - var sources []string - for _, blk := range blocks { - if blk.Kind == blockMermaid { - sources = append(sources, blk.Source) - } - } - if len(sources) == 0 { - return nil, nil - } - mmdc, err := mermaidTool.locate("") - if err != nil { - return nil, err - } - // HTML labels live in , which PDF-oriented SVG renderers - // do not draw; plain labels render everywhere. - config := "mermaid-config.json" - if err := os.WriteFile(filepath.Join(dir, config), []byte(`{"htmlLabels":false,"flowchart":{"htmlLabels":false},"class":{"htmlLabels":false}}`), 0o600); err != nil { - return nil, err - } - images := make([]string, 0, len(sources)) - for i, source := range sources { - input := fmt.Sprintf("diagram-%d.mmd", i+1) - output := fmt.Sprintf("diagram-%d.svg", i+1) - if err := os.WriteFile(filepath.Join(dir, input), []byte(source+"\n"), 0o600); err != nil { - return nil, err - } - args := []string{"--input", input, "--output", output, "--quiet", "--configFile", config} - if puppeteer := strings.TrimSpace(os.Getenv(MermaidPuppeteerEnv)); puppeteer != "" { - args = append(args, "--puppeteerConfigFile", puppeteer) - } - if err := runTool(dir, mmdc, args...); err != nil { - return nil, err - } - if _, err := os.Stat(filepath.Join(dir, output)); err != nil { - return nil, &Error{Kind: ErrorToolFailed, Tool: mermaidTool.name, Detail: "wrote no SVG for " + input} - } - images = append(images, output) - } - return images, nil -} - -// dotNotice and plantumlNotice are written ahead of a DOT or PlantUML block -// the PDF backend keeps as source: it draws neither, as it draws no Mermaid -// diagram without mermaid-cli. -const ( - dotNotice = "This diagram is written in Graphviz DOT, which the PDF backend does not draw; its source follows." - plantumlNotice = "This diagram is written in PlantUML, which the PDF backend does not draw; its source follows." -) - -// markdownWithImages rewrites the document's Markdown with each Mermaid fence -// replaced by a reference to its rendered image, and each DOT or PlantUML -// fence preceded by its notice, for converters that read Markdown themselves. -func markdownWithImages(markdown string, images []string) string { - lines := strings.Split(markdown, "\n") - var out []string - image := 0 - for i := 0; i < len(lines); i++ { - switch { - case lines[i] == mermaidFence && image < len(images): - i = fenceEnd(lines, i+1) - out = append(out, "![diagram]("+images[image]+")") - image++ - continue - case lines[i] == dotFence: - out = append(out, "*"+dotNotice+"*", "") - case lines[i] == plantumlFence: - out = append(out, "*"+plantumlNotice+"*", "") - } - out = append(out, lines[i]) - } - return strings.Join(out, "\n") -} - -// fenceEnd returns the index of the closing fence at or after from, or -// len(lines) when the fence is left open. -func fenceEnd(lines []string, from int) int { - for i := from; i < len(lines); i++ { - if lines[i] == "```" { - return i - } - } - return len(lines) -} diff --git a/internal/errata/errata.go b/internal/errata/errata.go deleted file mode 100644 index a8ad60b9e5..0000000000 --- a/internal/errata/errata.go +++ /dev/null @@ -1,244 +0,0 @@ -// Package errata is the declared overlay of defects in the OMG-published -// material this project reads: what the published text says, the clause that -// makes it wrong, and what the oracles read instead. The published bytes on disk -// are never rewritten; corrections live only here. -package errata - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// IssuesPath is the page every entry must be documented in. -const IssuesPath = "docs/project/omg-issues.md" - -// publishedRoots are the repository paths holding OMG-published material. An -// entry outside them would be a correction to our own material, which is a -// defect of ours to fix rather than an erratum to declare. -var publishedRoots = []string{ - "examples/pilot-corpora", - "build/pilot-xpect-corpus", -} - -// Entry is one defect in published material. The span is one whole line: -// AsPublished is that line's exact bytes without its terminator, which is what -// lets a re-vendored corpus invalidate the entry instead of silently rotting it. -type Entry struct { - // ID is the entry's stable internal label, as the oracles report it. - ID string - // Heading titles the docs/project/omg-issues.md section documenting the - // defect, without its `### ` marker. - Heading string - // Path is the defective file, relative to the repository root. - Path string - // Line is the 1-based line the entry covers. - Line int - // AsPublished is the published line, verbatim. - AsPublished string - // Corrected replaces AsPublished when non-empty. Empty means documented - // without a correction: nothing is substituted, and the defect stands. - Corrected string - // Citation is the specification clause the published text violates. - Citation string - // Derivation states in one line why that clause is violated. - Derivation string -} - -// Corrects reports whether the entry substitutes text. -func (e Entry) Corrects() bool { return e.Corrected != "" } - -// Validate reports why an entry may not be accepted. -func (e Entry) Validate() error { - switch { - case e.ID == "": - return fmt.Errorf("entry for %s:%d names no %s row", e.Path, e.Line, IssuesPath) - case e.Heading == "": - return fmt.Errorf("entry %s names no %s section documenting it", e.ID, IssuesPath) - case e.Path == "": - return fmt.Errorf("entry %s names no file", e.ID) - case e.Line < 1: - return fmt.Errorf("entry %s: line %d is not a line", e.ID, e.Line) - case strings.TrimSpace(e.AsPublished) == "": - return fmt.Errorf("entry %s: no as-published text to match against the corpus", e.ID) - case e.Citation == "": - return fmt.Errorf("entry %s: no specification citation makes the published text wrong", e.ID) - case e.Derivation == "": - return fmt.Errorf("entry %s: no derivation from %s is written down", e.ID, e.Citation) - case e.Corrected == e.AsPublished: - return fmt.Errorf("entry %s: the correction repeats the published text", e.ID) - case strings.Contains(e.AsPublished, "\n") || strings.Contains(e.Corrected, "\n"): - return fmt.Errorf("entry %s: an entry covers one line", e.ID) - } - if !underPublishedRoot(e.Path) { - return fmt.Errorf("entry %s: %s is not OMG-published material (%s)", e.ID, e.Path, strings.Join(publishedRoots, ", ")) - } - return nil -} - -func underPublishedRoot(path string) bool { - for _, root := range publishedRoots { - if strings.HasPrefix(path, root+"/") { - return true - } - } - return false -} - -// Registry is the declared errata, in report order. -func Registry() []Entry { - return []Entry{{ - ID: "F82", - Heading: "`radius = 22/2*25.4 + 110 [mm]` adds a dimensionless value to a length", - Path: "examples/pilot-corpora/sysml-examples/Geometry Examples/VehicleGeometryAndCoordinateFrames.sysml", - Line: 38, - // The published line keeps its trailing space; the correction keeps it too. - AsPublished: " :>> radius = 22/2*25.4 + 110 [mm]; ", - Corrected: " :>> radius = (22/2*25.4 + 110) [mm]; ", - Citation: "SysML v2 §9.8.9.1", - Derivation: "the unit postfix binds to PrimaryExpression (KerMLExpressions.xtext:308), below AdditiveExpression, so `[mm]` qualifies 110 alone and `+` adds a dimensionless value to a length, which §9.8.9.1 forbids.", - }, { - ID: "F83", - Heading: "`1/(2 * Cp) * V^2 + T_static` adds L^6 to Θ", - Path: "examples/pilot-corpora/sysml-examples/Analysis Examples/Turbojet Stage Analysis.sysml", - Line: 25, - AsPublished: "\t \treturn : TemperatureValue = 1/(2 * Cp) * V^2 + T_static;", - Citation: "SysML v2 §9.8.9.1", - Derivation: "V is a VolumeValue and Cp dimensionless, so the first operand has dimension L^6 while T_static has Θ; no reading of the published text shares a dimension, so the defect is documented without a correction.", - }, { - ID: "F84", - Heading: "`return a : AccelerationValue = tp * dt * tp` returns L^4·M^2·T^-5", - Path: "examples/pilot-corpora/sysml-examples/Analysis Examples/Dynamics.sysml", - Line: 13, - AsPublished: "\t\treturn a : AccelerationValue = tp * dt * tp;", - Citation: "KerML 7.4.9", - Derivation: "tp is a PowerValue (L^2·M·T^-3) and dt a TimeValue (T), so the expression the return feature takes its value from has dimension L^4·M^2·T^-5 while AccelerationValue is measured in L·T^-2; no repair follows from the parameters the calculation declares, so the defect is documented without a correction.", - }} -} - -// Overlay is a registry indexed by file, ready to apply. -type Overlay struct { - entries []Entry - byPath map[string]Entry -} - -// Load returns the declared registry as an overlay. -func Load() (*Overlay, error) { return New(Registry()) } - -// New validates entries and indexes them by path. -func New(entries []Entry) (*Overlay, error) { - overlay := &Overlay{byPath: make(map[string]Entry, len(entries))} - ids := make(map[string]bool, len(entries)) - for _, entry := range entries { - if err := entry.Validate(); err != nil { - return nil, err - } - if ids[entry.ID] { - return nil, fmt.Errorf("entry %s is declared twice", entry.ID) - } - if other, ok := overlay.byPath[entry.Path]; ok { - // One entry per file keeps application order irrelevant. - return nil, fmt.Errorf("entries %s and %s both cover %s", other.ID, entry.ID, entry.Path) - } - ids[entry.ID] = true - overlay.byPath[entry.Path] = entry - overlay.entries = append(overlay.entries, entry) - } - return overlay, nil -} - -// Entries returns every declared entry. -func (o *Overlay) Entries() []Entry { - if o == nil { - return nil - } - return o.entries -} - -// Corrections returns the entries that substitute text. -func (o *Overlay) Corrections() []Entry { - var out []Entry - for _, entry := range o.Entries() { - if entry.Corrects() { - out = append(out, entry) - } - } - return out -} - -// Documented returns the entries documented without a correction. -func (o *Overlay) Documented() []Entry { - var out []Entry - for _, entry := range o.Entries() { - if !entry.Corrects() { - out = append(out, entry) - } - } - return out -} - -// Under returns the correcting entries whose file lies under a repository path, -// keyed by the path relative to it. -func (o *Overlay) Under(dir string) map[string]Entry { - prefix := strings.TrimSuffix(dir, "/") + "/" - out := map[string]Entry{} - for _, entry := range o.Corrections() { - if rel, ok := strings.CutPrefix(entry.Path, prefix); ok { - out[rel] = entry - } - } - return out -} - -// Apply returns content with the entry's correction substituted. It fails -// unless the declared line still reads as published, so a re-vendored corpus is -// a hard error rather than a silently skipped correction. -func Apply(entry Entry, content []byte) ([]byte, error) { - lines := strings.Split(string(content), "\n") - if entry.Line > len(lines) { - return nil, fmt.Errorf("%s: %s has %d lines, entry names line %d", entry.ID, entry.Path, len(lines), entry.Line) - } - if got := lines[entry.Line-1]; got != entry.AsPublished { - return nil, fmt.Errorf("%s: %s:%d reads %q, the entry records %q", entry.ID, entry.Path, entry.Line, got, entry.AsPublished) - } - if !entry.Corrects() { - return content, nil - } - lines[entry.Line-1] = entry.Corrected - return []byte(strings.Join(lines, "\n")), nil -} - -// Materialize copies the corpus root at repo/dir into dst and applies every -// correction inside it, leaving the published tree untouched. It returns the -// applied entries, keyed by the path relative to the root. -func (o *Overlay) Materialize(repo, dir, dst string) (map[string]Entry, error) { - applied := o.Under(dir) - if len(applied) == 0 { - return nil, fmt.Errorf("no correction lies under %s", dir) - } - if err := os.RemoveAll(dst); err != nil { - return nil, err - } - if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { - return nil, err - } - if err := os.CopyFS(dst, os.DirFS(filepath.Join(repo, filepath.FromSlash(dir)))); err != nil { - return nil, fmt.Errorf("copy %s: %w", dir, err) - } - for rel, entry := range applied { - path := filepath.Join(dst, filepath.FromSlash(rel)) - content, err := os.ReadFile(path) // #nosec G304 -- the path is inside the copy this function just made - if err != nil { - return nil, err - } - corrected, err := Apply(entry, content) - if err != nil { - return nil, err - } - if err := os.WriteFile(path, corrected, 0o600); err != nil { - return nil, err - } - } - return applied, nil -} diff --git a/internal/errata/errata_test.go b/internal/errata/errata_test.go deleted file mode 100644 index 7c6dbf3485..0000000000 --- a/internal/errata/errata_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package errata - -import ( - "crypto/sha256" - "encoding/hex" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" - "testing" -) - -const repoRoot = "../.." - -// requireEnv turns an absent corpus into a failure, as CI does for the gates. -const requireEnv = "OPENSYSML_REQUIRE_PILOT_CORPORA" - -func TestRegistryIsAccepted(t *testing.T) { - overlay, err := Load() - if err != nil { - t.Fatalf("load the declared registry: %v", err) - } - if len(overlay.Entries()) == 0 { - t.Fatal("the registry declares no entry, so nothing it claims can be checked") - } -} - -// TestEntryWithoutProvenanceIsRejected keeps the provenance a mechanism rather -// than a convention: each shape below must be refused. -func TestEntryWithoutProvenanceIsRejected(t *testing.T) { - valid := Entry{ - ID: "F82", - Heading: "`x = 1` is dimensionless", - Path: "examples/pilot-corpora/sysml-examples/Sample.sysml", - Line: 3, - AsPublished: " x = 1;", - Corrected: " x = 2;", - Citation: "SysML v2 §9.8.9.1", - Derivation: "the published text adds a dimensionless value to a length.", - } - if err := valid.Validate(); err != nil { - t.Fatalf("the control entry must be accepted: %v", err) - } - - tests := map[string]func(Entry) Entry{ - "no citation": func(e Entry) Entry { e.Citation = ""; return e }, - "no derivation": func(e Entry) Entry { e.Derivation = ""; return e }, - "no issues row": func(e Entry) Entry { e.ID = ""; return e }, - "no issues section": func(e Entry) Entry { e.Heading = ""; return e }, - "no file": func(e Entry) Entry { e.Path = ""; return e }, - "no line": func(e Entry) Entry { e.Line = 0; return e }, - "no published text": func(e Entry) Entry { e.AsPublished = " "; return e }, - "correction is a nop": func(e Entry) Entry { e.Corrected = e.AsPublished; return e }, - "multi-line span": func(e Entry) Entry { e.Corrected = "a\nb"; return e }, - "our own material": func(e Entry) Entry { e.Path = "testdata/passes/constraints.sysml"; return e }, - } - for name, mutate := range tests { - t.Run(name, func(t *testing.T) { - entry := mutate(valid) - if err := entry.Validate(); err == nil { - t.Fatalf("%s was accepted", name) - } - if _, err := New([]Entry{entry}); err == nil { - t.Fatalf("%s was accepted into an overlay", name) - } - }) - } -} - -func TestDuplicateEntriesAreRejected(t *testing.T) { - entry := Registry()[0] - if _, err := New([]Entry{entry, entry}); err == nil { - t.Fatal("the same entry was accepted twice") - } - other := entry - other.ID = entry.ID + "b" - if _, err := New([]Entry{entry, other}); err == nil { - t.Fatal("two entries covering one file were accepted") - } -} - -// TestAsPublishedMatchesTheCorpus is what stops an entry from rotting when the -// corpus is re-vendored: every entry must still match the bytes on disk. -func TestAsPublishedMatchesTheCorpus(t *testing.T) { - overlay, err := Load() - if err != nil { - t.Fatalf("load the declared registry: %v", err) - } - var missing []string - for _, entry := range overlay.Entries() { - content, readErr := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(entry.Path))) // #nosec G304 -- the path comes from the declared registry - if readErr != nil { - if os.IsNotExist(readErr) { - missing = append(missing, entry.Path) - continue - } - t.Fatalf("read %s: %v", entry.Path, readErr) - } - if _, err := Apply(entry, content); err != nil { - t.Fatalf("an entry no longer matches the published corpus: %v", err) - } - } - sort.Strings(missing) - if len(missing) == 0 { - return - } - if os.Getenv(requireEnv) != "" { - t.Fatalf("%s is set and these entries' files are absent: %s", requireEnv, strings.Join(missing, ", ")) - } - t.Skipf("pilot corpora not downloaded (run ./scripts/download-pilot-corpora.sh); unchecked: %s", strings.Join(missing, ", ")) -} - -// TestEveryEntryIsDocumented ties each entry to its omg-issues.md row, quoting -// its citation and its published text there rather than in the registry alone. -func TestEveryEntryIsDocumented(t *testing.T) { - page, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(IssuesPath))) - if err != nil { - t.Fatalf("read %s: %v", IssuesPath, err) - } - text := string(page) - for _, entry := range Registry() { - if !strings.Contains(text, "### "+entry.Heading) { - t.Errorf("%s: %s has no `### %s` section", IssuesPath, entry.ID, entry.Heading) - } - if !strings.Contains(text, entry.Citation) { - t.Errorf("%s: %s does not quote the citation %s", IssuesPath, entry.ID, entry.Citation) - } - if !strings.Contains(text, strings.TrimSpace(entry.AsPublished)) { - t.Errorf("%s: %s does not quote the published text", IssuesPath, entry.ID) - } - if entry.Corrects() && !strings.Contains(text, strings.TrimSpace(entry.Corrected)) { - t.Errorf("%s: %s does not quote the correction", IssuesPath, entry.ID) - } - } -} - -func TestApplyRewritesOnlyTheDeclaredLine(t *testing.T) { - entry := Entry{ - ID: "F00", Heading: "a test entry", Path: "examples/pilot-corpora/x/Sample.sysml", Line: 2, - AsPublished: "b", Corrected: "B", - Citation: "SysML v2 §9.8.9.1", Derivation: "test entry.", - } - got, err := Apply(entry, []byte("a\nb\nc\n")) - if err != nil { - t.Fatalf("apply: %v", err) - } - if string(got) != "a\nB\nc\n" { - t.Fatalf("apply rewrote %q", got) - } -} - -func TestApplyRefusesAnEntryThatNoLongerMatches(t *testing.T) { - entry := Entry{ - ID: "F00", Heading: "a test entry", Path: "examples/pilot-corpora/x/Sample.sysml", Line: 2, - AsPublished: "b", Corrected: "B", - Citation: "SysML v2 §9.8.9.1", Derivation: "test entry.", - } - if _, err := Apply(entry, []byte("a\nother\nc\n")); err == nil { - t.Fatal("a rotted entry was applied") - } - if _, err := Apply(entry, []byte("a\n")); err == nil { - t.Fatal("an entry past the end of the file was applied") - } -} - -// TestDocumentedEntrySubstitutesNothing pins the documented-only shape: the -// defect is recorded, and the text the oracles read is the published one. -func TestDocumentedEntrySubstitutesNothing(t *testing.T) { - entry := Entry{ - ID: "F00", Heading: "a test entry", Path: "examples/pilot-corpora/x/Sample.sysml", Line: 1, - AsPublished: "a", Citation: "SysML v2 §9.8.9.1", Derivation: "no intended reading.", - } - overlay, err := New([]Entry{entry}) - if err != nil { - t.Fatalf("new: %v", err) - } - if len(overlay.Corrections()) != 0 || len(overlay.Documented()) != 1 { - t.Fatalf("documented entry counted as a correction: %+v", overlay) - } - if under := overlay.Under("examples/pilot-corpora/x"); len(under) != 0 { - t.Fatalf("documented entry offered for substitution: %v", under) - } -} - -func TestUnderKeysCorrectionsByCorpusPath(t *testing.T) { - overlay, err := Load() - if err != nil { - t.Fatalf("load: %v", err) - } - under := overlay.Under("examples/pilot-corpora/sysml-examples") - if _, ok := under["Geometry Examples/VehicleGeometryAndCoordinateFrames.sysml"]; !ok { - t.Fatalf("the geometry correction is not keyed under the sysml-examples root: %v", under) - } - if len(overlay.Under("cmd/pilot-reject/testdata/negative")) != 0 { - t.Fatal("a correction was claimed for our own negative corpus") - } -} - -// TestMaterializeLeavesThePublishedCorpusByteIdentical is the immutability -// contract: applying the overlay writes only into the copy. -func TestMaterializeLeavesThePublishedCorpusByteIdentical(t *testing.T) { - const dir = "examples/pilot-corpora/sysml-examples" - root := filepath.Join(repoRoot, filepath.FromSlash(dir)) - if _, err := os.Stat(root); err != nil { - if os.Getenv(requireEnv) != "" { - t.Fatalf("%s is set and %s is absent", requireEnv, dir) - } - t.Skip("pilot corpora not downloaded (run ./scripts/download-pilot-corpora.sh)") - } - overlay, err := Load() - if err != nil { - t.Fatalf("load: %v", err) - } - before := hashTree(t, root) - dst := filepath.Join(t.TempDir(), "corrected") - applied, err := overlay.Materialize(repoRoot, dir, dst) - if err != nil { - t.Fatalf("materialize: %v", err) - } - if len(applied) == 0 { - t.Fatal("nothing was applied") - } - if after := hashTree(t, root); after != before { - t.Fatalf("the published corpus changed: %s -> %s", before, after) - } - for rel, entry := range applied { - content, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel))) // #nosec G304 -- the path is inside the copy the test just made - if err != nil { - t.Fatalf("read the copy of %s: %v", rel, err) - } - if strings.Contains(string(content), entry.AsPublished) { - t.Fatalf("%s still reads as published in the copy", rel) - } - if !strings.Contains(string(content), entry.Corrected) { - t.Fatalf("%s does not read as corrected in the copy", rel) - } - } -} - -func hashTree(t *testing.T, root string) string { - t.Helper() - sum := sha256.New() - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil || entry.IsDir() { - return err - } - content, err := os.ReadFile(path) // #nosec G304 -- the path comes from walking the corpus root - if err != nil { - return err - } - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - sum.Write([]byte(filepath.ToSlash(rel))) - sum.Write(content) - return nil - }) - if err != nil { - t.Fatalf("hash %s: %v", root, err) - } - return hex.EncodeToString(sum.Sum(nil)) -} diff --git a/internal/core/analysis/all_test.go b/internal/exec/analysis/all_test.go similarity index 100% rename from internal/core/analysis/all_test.go rename to internal/exec/analysis/all_test.go diff --git a/internal/exec/analysis/analysis_test.go b/internal/exec/analysis/analysis_test.go new file mode 100644 index 0000000000..d9481fb307 --- /dev/null +++ b/internal/exec/analysis/analysis_test.go @@ -0,0 +1,200 @@ +package analysis + +import ( + "context" + "path/filepath" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// fixtureModel is the model the adapter tests ask about: a calc to evaluate and +// sweep, a part with one holding and one violated constraint, and an action whose +// three writers race. +const fixtureModel = `package test { + calc def Double { in x : Integer; return : Integer = x * 2; } + part def Tank { + attribute pressure = 40.0; + assert constraint low { pressure < 100.0 } + assert constraint high { pressure > 100.0 } + } + action race { + attribute x : Integer = 0; + first start; + fork split; + action a { assign x := 1; } + action b { assign x := 2; } + action c { assign x := 3; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first split then c; + succession first a then sync; + succession first b then sync; + succession first c then sync; + succession first sync then done; + } + action steady { + attribute y : Integer = 0; + attribute z : Integer = 0; + first start; + fork split; + action a { assign y := 1; } + action b { assign z := 2; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then done; + } +}` + +// fixture is a model parsed once, from which every context is built. +type fixture struct { + idx *symbols.Index + model *semantics.Model + resolver *resolve.Resolver + pkg *symbols.Scope + source *source.SourceFile +} + +func parseFixture(t *testing.T) *fixture { + t.Helper() + return parseModel(t, fixtureModel) +} + +// parseModel is the fixture over a model of a test's own, its `test` package indexed. +func parseModel(t *testing.T, model string) *fixture { + t.Helper() + path := filepath.Join(t.TempDir(), "analysis.sysml") + sf := source.New(path, []byte(model)) + p := parser.New(sf) + file := p.ParseFile() + if len(p.Diagnostics) > 0 { + t.Fatalf("parse: %v", p.Diagnostics) + } + idx := symbols.NewIndex() + idx.AddDocument(path, file) + resolver := resolve.New(idx) + pkg, ok := idx.DocumentRoot(path).LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + return &fixture{idx: idx, model: passes.NewTypedModel(resolver), resolver: resolver, pkg: pkg.Scope, source: sf} +} + +// fixtureSteps is the step limit every runtime over the fixture is built with. +const fixtureSteps = 10000 + +// semantics is a worker's own model-derived runtime part over the shared index, carrying +// the fixture's source as a surface registers the files it read. +func (f *fixture) semantics() (*runtime.Model, error) { + resolver := resolve.New(f.idx) + model := runtime.NewModel(passes.NewTypedModel(resolver), resolver) + model.SetExpressionParser(parser.ParseOneExpression) + model.RegisterSource(f.source) + return model, nil +} + +// fresh is a runtime of a run's own on a worker. +func (f *fixture) fresh(w *Worker) (*runtime.Context, error) { + return runtime.NewContext(w.Model, fixtureSteps), nil +} + +// building is the model as a surface holding no context of its own supplies it. +func (f *fixture) building() *Model { + return &Model{Semantics: f.semantics, Fresh: f.fresh} +} + +// context is a runtime the surface would hold over the model. +func (f *fixture) context(t *testing.T) *runtime.Context { + t.Helper() + model := runtime.NewModel(f.model, f.resolver) + model.SetExpressionParser(parser.ParseOneExpression) + model.RegisterSource(f.source) + return runtime.NewContext(model, fixtureSteps) +} + +// symbol is the named member of the test package. +func (f *fixture) symbol(t *testing.T, name string) *symbols.Symbol { + t.Helper() + sym, ok := f.pkg.LookupLocal(name) + if !ok { + t.Fatalf("%s not indexed", name) + } + return sym +} + +// intOf is an integer as a runtime value carries it. +func intOf(n int64) runtime.Value { + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: n}} +} + +// policy parses a scheduling policy as a flag spells it. +func policy(t *testing.T, spelling string) runtime.SchedulePolicy { + t.Helper() + p, err := runtime.ParseSchedulePolicy(spelling) + if err != nil { + t.Fatalf("policy %s: %v", spelling, err) + } + return p +} + +// request is a question's setting under auto with no budget. +func request(model *Model, subject string, schedule runtime.SchedulePolicy) Request { + return Request{Model: model, Subject: subject, Schedule: schedule, Selection: Auto()} +} + +// selected is Drive::Once held in ctx, under the default schedule and selection. +func selected(ctx *runtime.Context, selection Selection) Request { + return Request{Model: Held(ctx), Subject: "Drive::Once", Schedule: runtime.DefaultSchedulePolicy, Selection: selection} +} + +// answered dispatches q under auto and fails the test on a fault. +func answered(t *testing.T, r *Registry, model *Model, q Question, budget Budget) Plan { + t.Helper() + plan, err := r.Answer(context.Background(), model, q, budget) + if err != nil { + t.Fatalf("answer %s %s: %v", q.Kind, q.Subject, err) + } + return plan +} + +// BudgetOf fills Runs in the unit of the question's kind: an exploring policy's +// runs for outcomes, the sweep runs for a sweep, none for a run or a solve; Jobs +// is the surface's, DefaultJobs when the surface set none. +func TestBudgetOfFillsRunsByKind(t *testing.T) { + limits := runtime.Budgets{MaxSteps: 70, MaxElements: 90, MaxSweepRuns: 40} + exploring := policy(t, "explore:runs=6,depth=4") + cases := []struct { + kind Kind + policy runtime.SchedulePolicy + want Budget + }{ + {Outcomes, exploring, Budget{Jobs: 3, Runs: 6, Depth: 4, Steps: 70, Memory: 90}}, + {Outcomes, runtime.DefaultSchedulePolicy, Budget{Jobs: 3, Steps: 70, Memory: 90}}, + {Sweep, exploring, Budget{Jobs: 3, Runs: 40, Depth: 4, Steps: 70, Memory: 90}}, + {Sweep, runtime.DefaultSchedulePolicy, Budget{Jobs: 3, Runs: 40, Steps: 70, Memory: 90}}, + {Evaluate, exploring, Budget{Jobs: 3, Depth: 4, Steps: 70, Memory: 90}}, + {Satisfiable, exploring, Budget{Jobs: 3, Depth: 4, Steps: 70, Memory: 90}}, + } + for _, tc := range cases { + if got := BudgetOf(limits, tc.policy, tc.kind, 3); got != tc.want { + t.Errorf("BudgetOf(%s, %s) = %+v, want %+v", tc.kind, tc.policy, got, tc.want) + } + } + if got := BudgetOf(limits, exploring, Outcomes, 0).Jobs; got != DefaultJobs() { + t.Errorf("BudgetOf with no jobs set Jobs = %d, want DefaultJobs %d", got, DefaultJobs()) + } +} diff --git a/internal/core/analysis/ask.go b/internal/exec/analysis/ask.go similarity index 79% rename from internal/core/analysis/ask.go rename to internal/exec/analysis/ask.go index 3b7d9aaeac..f605369d38 100644 --- a/internal/core/analysis/ask.go +++ b/internal/exec/analysis/ask.go @@ -3,10 +3,12 @@ package analysis import ( "context" "errors" + "fmt" "slices" + "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // Held is the model as a surface reaches it through a context it holds: an @@ -15,12 +17,16 @@ func Held(ctx *runtime.Context) *Model { return &Model{Context: func() (*runtime.Context, error) { return ctx, nil }} } -// Request is what every question put to the registry carries: the model it is -// asked of, the subject and schedule it runs under, and the budget and selection it is answered with. +// Request is what every question put to the registry carries: the model it is asked of, the +// subject, schedule, model seed, draw policy and clock step it runs under, and the budget and +// selection it is answered with. type Request struct { Model *Model Subject string Schedule runtime.SchedulePolicy + ModelSeed ModelSeed + Draws runtime.DrawPolicy + ClockStep float64 Budget Budget Selection Selection } @@ -28,7 +34,7 @@ type Request struct { // ask puts q, about the request's subject under its schedule, to the registry // under the request's budget and selection; a dispatch fault or refusal is the error. func (r *Registry) ask(ctx context.Context, req Request, q Question) (Plan, error) { - q.Subject, q.Schedule = req.Subject, req.Schedule + q.Subject, q.Schedule, q.ModelSeed, q.Draws, q.ClockStep = req.Subject, req.Schedule, req.ModelSeed, req.Draws, req.ClockStep return refusedOr(r.AnswerWith(ctx, req.Model, q, req.Budget, req.Selection)) } @@ -82,6 +88,41 @@ func CheckAnswer(result runtime.CheckResult, err error) Answer { return Answer{Claim: ClaimViolated} } +// ValidationAnswer is what validating an object established: a violated assertion +// is a violation, every assertion holding on every object reached is holding, and +// an undecided assertion or an unreached object claims nothing. +func ValidationAnswer(report runtime.ValidationReport, err error) Answer { + if err != nil { + return Answer{Err: err} + } + switch { + case report.Status() == runtime.ValidationViolated: + return Answer{Claim: ClaimViolated, Reason: ValidationReason(report)} + case report.Valid(): + return Answer{Claim: ClaimHolds} + } + return Answer{Reason: ValidationReason(report)} +} + +// ValidationReason says what kept a validation from holding: that the object states +// no assertion, how many fail, how many are undecided, whether every object was reached. +func ValidationReason(report runtime.ValidationReport) string { + var parts []string + if len(report.Verdicts) == 0 { + parts = append(parts, "states no assertion to validate") + } + if n := report.Count(runtime.ValidationViolated); n > 0 { + parts = append(parts, fmt.Sprintf("%d of %d assertions fail", n, len(report.Verdicts))) + } + if n := report.Count(runtime.ValidationUndecided); n > 0 { + parts = append(parts, fmt.Sprintf("%d undecided", n)) + } + if !report.Complete() { + parts = append(parts, "not every held object was reached") + } + return strings.Join(parts, ", ") +} + // ValuesAnswer is what a run producing values established: the values, or // nothing when it failed. func ValuesAnswer(values []Evaluation, err error) Answer { @@ -169,7 +210,7 @@ func leavesInputsUnbound(req Request, holds *HoldsAsk) bool { if holds == nil || holds.Start == nil || !req.Model.builds() { return false } - ctx, err := req.Model.NewContextOn(0, req.Budget) + ctx, err := Question{ModelSeed: req.ModelSeed, Draws: req.Draws, ClockStep: req.ClockStep}.fresh(req.Model, 0, req.Budget) if err != nil { return false } diff --git a/internal/exec/analysis/check.go b/internal/exec/analysis/check.go new file mode 100644 index 0000000000..199b18eb26 --- /dev/null +++ b/internal/exec/analysis/check.go @@ -0,0 +1,421 @@ +package analysis + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// CheckEngineName is the name of the engine that searches an action's schedules explicitly. +const CheckEngineName = "check" + +// The bounds a check searches under when the budget leaves them 0: the moves one +// schedule may make and the distinct states the search may visit. +const ( + DefaultCheckDepth = 10000 + DefaultCheckStates = 1000000 +) + +// CheckAsk is what a check question asks over the schedules of one invocation: +// how its behaviors begin on one clock, what must hold at every stable state, +// what may not diverge, and where the witnesses go. +type CheckAsk struct { + // Start begins the invocation in a context of the check's own; a replay begins it the same way. + Start runtime.Starter + // Performer names the object Start performs the behaviors on, as the surface + // spelled it, so the witnesses of one behavior on two objects are two sets of + // files; empty when no object performs it. + Performer string + // Properties are evaluated at every stable state; one false is a violation. + Properties []runtime.CheckProperty + // Diverge names the features whose final values are compared across the + // complete schedules; none compares the behaviors' own attributes and final + // states, and the performing object's features when one performs them. + Diverge []string + // WitnessDir is where every violation's and divergent value's witness is + // written, a file per witness; empty writes none. + WitnessDir string +} + +// Checked is a check's report with where its witnesses were written: a path per +// violation and per divergent value, in the report's order; none without a directory. +type Checked struct { + Report *runtime.CheckReport + Violations []string + Divergent [][]string +} + +// ErrConstruct is the typed refusal of a construct the check engine does not search. +var ErrConstruct = errors.New("construct outside the check engine's search") + +// ConstructError names the construct the check engine met and does not search. +type ConstructError struct { + Engine string + Construct string +} + +// Error names the engine and the construct. +func (e *ConstructError) Error() string { + return fmt.Sprintf("%s does not search %s", e.Engine, e.Construct) +} + +// Is matches ErrConstruct. +func (e *ConstructError) Is(target error) bool { return target == ErrConstruct } + +// checkEngine answers Outcomes, Holds and Sensitive questions over an invocation +// by a depth-first search of its schedules, one token, dispatch or do step at a +// time on one clock, under static partial-order reduction; what it finds within +// its bounds is bounded, never proved. +type checkEngine struct{} + +// NewCheck returns the check engine. +func NewCheck() Engine { return checkEngine{} } + +// Name is `check`. +func (checkEngine) Name() string { return CheckEngineName } + +// Describe: the search is bounded by depth, states, the plan's clock and the +// executor's budgets; every violation and divergent value is a replayed witness. +func (checkEngine) Describe() Description { + return Description{ + Questions: []Kind{Outcomes, Holds, Sensitive}, + Bounds: append([]string{"depth", "states", "deadline"}, runtime.ExecutorBounds...), + Replays: true, + Authority: Bounded, + } +} + +// Covers takes an Outcomes, Holds or Sensitive question over an invocation's +// schedules with its inputs as written; the constructs the search meets and cannot capture are +// refused as it meets them, as a result not covered naming the construct. +func (e checkEngine) Covers(_ *Model, q Question) Coverage { + if q.Kind != Outcomes && q.Kind != Holds && q.Kind != Sensitive { + return refused(&NotAskedError{Engine: e.Name(), Kind: q.Kind}) + } + if q.Free.Has(FreeInputs) { + return refused(&FreedomError{Engine: e.Name(), Free: FreeInputs}) + } + if !q.Free.Has(FreeSchedule) { + return refused(&ConstructError{Engine: e.Name(), Construct: "a fixed schedule"}) + } + if q.Check == nil || q.Check.Start == nil { + return refused(&MalformedQuestionError{Kind: q.Kind, Missing: "a Check starting an invocation"}) + } + if q.Check.WitnessDir != "" && q.Subject == "" { + return refused(&MalformedQuestionError{Kind: q.Kind, Missing: "a Subject to name the witness files after"}) + } + return covered +} + +// Run searches the invocation's schedules under the budget's depth and, as its runs, +// states, in a context of the check's own on the plan's first worker; a violation +// is witnessed, a divergence is the sensitivity it witnesses (its first two values' +// schedules the witness and the contrast), and a clean search is bounded — the +// feature not sensitive, the condition holding, or the outcomes reached, as the +// question asks — once every witness has replayed on the plan's workers. The plan's clock +// ending is the search stopping, an error, not a result. A model that builds no +// context of a run's own is the typed fault NoRuntimeError. +func (e checkEngine) Run(ctx context.Context, model *Model, q Question, budget Budget) (Result, error) { + if !model.builds() { + return Result{}, &NoRuntimeError{Engine: e.Name()} + } + fresh := func() (*runtime.Context, error) { return q.fresh(model, 0, budget) } + started := time.Now() + if budget.Depth <= 0 { + budget.Depth = DefaultCheckDepth + } + if budget.Runs <= 0 { + budget.Runs = DefaultCheckStates + } + limits := runtime.CheckBudget{Depth: budget.Depth, States: budget.Runs} + options := runtime.CheckOptions{Diverge: q.Check.Diverge, Reduce: true} + report, err := runtime.Check(ctx, fresh, q.Check.Start, limits, options, q.Check.Properties) + result := Result{Question: q, Engine: e.Name()} + switch { + case errors.Is(err, runtime.ErrCheckRefused): + result.Strength = NotCovered + result.Reason = (&ConstructError{Engine: e.Name(), Construct: "a move the run makes otherwise: " + err.Error()}).Error() + result.Elapsed = time.Since(started) + return result, nil + case err != nil: + return Result{}, err + } + checked, disagreement, err := e.replayed(ctx, model, q, budget, report) + if err != nil { + return Result{}, err + } + result.Bounds = checkBounds(report, budget) + result.Values = []Evaluation{{Name: q.Subject, Checked: checked}} + result.Elapsed = time.Since(started) + switch { + case disagreement != nil: + result.Strength = NotCovered + result.Reason = disagreement.Error() + case report.Verdict == runtime.CheckViolation: + result.Claim = ClaimViolated + result.Strength = Witnessed + first := report.Violations[0] + result.Reason = first.String() + result.Witness = &Witness{Schedule: runtime.ReplayOf(first.Witness), Draws: first.Witness.Draws, Choices: first.Witness.Choices} + if len(checked.Violations) > 0 { + result.Witness.Written = checked.Violations[0] + } + case report.Verdict == runtime.CheckDivergent: + result.Claim = ClaimSensitive + result.Strength = Witnessed + result.Reason = divergenceReason(report.Divergent) + var written []string + if len(checked.Divergent) > 0 { + written = checked.Divergent[0] + } + result.Witness = divergenceWitness(report.Divergent[0], written, 0) + result.Contrast = divergenceWitness(report.Divergent[0], written, 1) + case q.Kind == Holds, q.Kind == Sensitive: + result.Claim = ClaimHolds + result.Strength = Bounded + default: + result.Claim = ClaimOutcomes + result.Strength = Bounded + } + return result, nil +} + +// divergenceWitness is the schedule reaching the divergence's n-th value, with the +// file it was written to when one was; nil when the divergence has no n-th value. +func divergenceWitness(d runtime.Divergence, written []string, n int) *Witness { + if n >= len(d.Values) { + return nil + } + w := d.Values[n].Witness + out := &Witness{Schedule: runtime.ReplayOf(w), Draws: w.Draws, Choices: w.Choices} + if n < len(written) { + out.Written = written[n] + } + return out +} + +// divergenceReason spells every divergence, `x ends as 1 or 2; y ends as a or b`. +func divergenceReason(divergent []runtime.Divergence) string { + parts := make([]string, len(divergent)) + for i, d := range divergent { + parts[i] = d.String() + } + return strings.Join(parts, "; ") +} + +// checkBounds is every bound the search took and which it reached: depth and states +// as the budget set them (0 unbounded) and the executor's budgets; the plan's clock +// is the framework's, a search it stops being cancelled rather than bounded. +func checkBounds(report *runtime.CheckReport, budget Budget) Bounds { + hit := func(name string) bool { return slices.Contains(report.BoundsHit, name) } + bounds := Bounds{ + {Name: "depth", Limit: int64(budget.Depth), Reached: hit("depth")}, + {Name: "states", Limit: int64(budget.Runs), Reached: hit("states")}, + } + for _, name := range runtime.ExecutorBounds { + limit, _ := runtime.ExecutorBound(name, report.Limits) + bounds = append(bounds, Bound{Name: name, Limit: limit, Reached: hit(name)}) + } + return bounds +} + +// replayWitness is one witness the report carries, replayed and written. +type replayWitness struct { + what string + witness runtime.Witness + file string + path *string +} + +// replayed re-runs every violation's and divergent value's witness on the plan's workers, +// writing each to the question's witness directory; the first witness whose replay +// leaves another state is the disagreement, which leaves the report not covered. +func (e checkEngine) replayed(ctx context.Context, model *Model, q Question, budget Budget, report *runtime.CheckReport) (*Checked, error, error) { + checked := &Checked{Report: report} + var witnesses []*replayWitness + if q.Check.WitnessDir != "" { + checked.Violations = make([]string, len(report.Violations)) + checked.Divergent = make([][]string, len(report.Divergent)) + } + for i, v := range report.Violations { + w := &replayWitness{what: "violation " + fmt.Sprint(i+1) + " (" + v.String() + ")", witness: v.Witness, file: ViolationFile(q.Subject, q.Check.Performer, i+1)} + if checked.Violations != nil { + w.path = &checked.Violations[i] + } + witnesses = append(witnesses, w) + } + for i, d := range report.Divergent { + if checked.Divergent != nil { + checked.Divergent[i] = make([]string, len(d.Values)) + } + for j, value := range d.Values { + w := &replayWitness{what: fmt.Sprintf("%s = %s", d.Feature, value.Value), witness: value.Witness, + file: divergenceFile(q.Subject, q.Check.Performer, d.Feature, j+1)} + if checked.Divergent != nil { + w.path = &checked.Divergent[i][j] + } + witnesses = append(witnesses, w) + } + } + jobs := max(budget.Jobs, 1) + errs := make([]error, len(witnesses)) + var wg sync.WaitGroup + for job := 0; job < jobs && job < len(witnesses); job++ { + wg.Add(1) + go func(job int) { + defer wg.Done() + fresh := func() (*runtime.Context, error) { return q.fresh(model, job, budget) } + for i := job; i < len(witnesses); i += jobs { + errs[i] = e.replayOne(ctx, fresh, q, witnesses[i]) + } + }(job) + } + wg.Wait() + var disagreement error + for i, err := range errs { + switch { + case err == nil: + case errors.Is(err, runtime.ErrReplayDisagrees): + if disagreement == nil { + disagreement = fmt.Errorf("replay of %s: %w", witnesses[i].what, err) + } + default: + return nil, nil, err + } + } + return checked, disagreement, nil +} + +// replayOne writes the witness, when a directory is named, and replays it. +func (e checkEngine) replayOne(ctx context.Context, fresh func() (*runtime.Context, error), q Question, w *replayWitness) error { + if err := ctx.Err(); err != nil { + return err + } + if w.path != nil { + *w.path = filepath.Join(q.Check.WitnessDir, w.file) + if err := WriteWitness(*w.path, w.witness.String()); err != nil { + return err + } + } + _, err := runtime.Replay(ctx, fresh, q.Check.Start, w.witness, q.Check.Properties) + return err +} + +// WriteWitness writes the witness text beside path and moves it into place over what +// the path held, so a link planted there is replaced, never followed to what it points at. +// The directory is made when it does not exist. +func WriteWitness(path, text string) (err error) { + dir, name := filepath.Split(path) + if err := os.MkdirAll(filepath.Clean(dir), 0o750); err != nil { + return err + } + f, err := os.CreateTemp(dir, "."+name+".*") + if err != nil { + return err + } + defer func() { + if err != nil { + err = errors.Join(err, os.Remove(f.Name())) + } + }() + if _, err = f.WriteString(text); err != nil { + return errors.Join(err, f.Close()) + } + if err = f.Close(); err != nil { + return err + } + return source.ReplaceFile(f.Name(), path) +} + +// ViolationFile names the witness of the n-th violation of the subject, performed by +// performer when one is named: `test.race.violation-1.witness`, `Plant.fill@Plant.tank.violation-1.witness`. +// Its one `-` tells it from a divergence's file, whose tokens carry none. +func ViolationFile(subject, performer string, n int) string { + return fmt.Sprintf("%s.violation-%d.witness", checkedName(subject, performer), n) +} + +// divergenceFile names the witness of a feature's n-th final value, +// `test.race-x-1.witness`, `test.race-this.level-2.witness`: the checked name, +// the feature's segments and the count, told apart by the `-` no token carries. +// A feature of one behavior of several, `Shine::Lamp::peek.saw`, spells +// `Shine.Lamp.peek.saw`. +func divergenceFile(subject, performer, feature string, n int) string { + return fmt.Sprintf("%s-%s-%d.witness", checkedName(subject, performer), fileSegments(feature, "::", "."), n) +} + +// SensitivityFile names one witness of a pair of schedules ending with different +// values of a feature, `test.race-x-A.witness` and `test.race-x-B.witness`: the +// checked name, the feature's segments and the label, `A` or `B`, of the copy that ran it. +func SensitivityFile(subject, performer, feature, label string) string { + return fmt.Sprintf("%s-%s-%s.witness", checkedName(subject, performer), fileSegments(feature, "."), label) +} + +// checkedName is the subject's segments and, after `@`, the performer's when an +// object performs it: `Plant.Tank.fill@Plant.tank`, one name per behavior and +// object; several behaviors on one clock are joined by `+`, each with its own +// `@` when the subject spells the objects they perform on, and its `#n` +// when the subject numbers repeats of one behavior. +func checkedName(subject, performer string) string { + behaviors := strings.Split(subject, ", ") + for i, behavior := range behaviors { + words := strings.Fields(behavior) + if len(words) == 0 { + continue + } + behaviors[i] = fileSegments(words[0], "::") + for _, word := range words[1:] { + if n, numbered := strings.CutPrefix(word, "#"); numbered { + behaviors[i] += "." + fileToken(n) + } else { + behaviors[i] += "@" + fileSegments(word, "::") + } + } + } + name := strings.Join(behaviors, "+") + if performer != "" { + name += "@" + fileSegments(performer, "::") + } + return name +} + +// fileSegments joins the tokens of a name's segments, split at any of the +// separators, with `.`, one name per spelling. +func fileSegments(name string, separators ...string) string { + segments := []string{name} + for _, separator := range separators { + var split []string + for _, segment := range segments { + split = append(split, strings.Split(segment, separator)...) + } + segments = split + } + for i, segment := range segments { + segments[i] = fileToken(segment) + } + return strings.Join(segments, ".") +} + +// fileToken keeps the letters, digits and `_` of a name and spells every other byte +// `%XX`, so distinct names are distinct tokens and no token carries `.` or `-`. +func fileToken(name string) string { + var b strings.Builder + for i := 0; i < len(name); i++ { + c := name[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_': + b.WriteByte(c) + default: + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} diff --git a/internal/core/analysis/check_clock_test.go b/internal/exec/analysis/check_clock_test.go similarity index 93% rename from internal/core/analysis/check_clock_test.go rename to internal/exec/analysis/check_clock_test.go index f6b3bc23f5..3bd13a17ab 100644 --- a/internal/core/analysis/check_clock_test.go +++ b/internal/exec/analysis/check_clock_test.go @@ -7,12 +7,12 @@ import ( "sync" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // beaconModel is an action and an object's state machine on one clock: arming @@ -62,7 +62,7 @@ func parseLibraryModel(t *testing.T, model string) *fixture { if !ok || pkg.Scope == nil { t.Fatal("test package not indexed") } - return &fixture{idx: idx, model: semantics.NewModel(resolver), resolver: resolver, pkg: pkg.Scope, source: sf} + return &fixture{idx: idx, model: passes.NewTypedModel(resolver), resolver: resolver, pkg: pkg.Scope, source: sf} } // watcherRun is one run of the watcher: the action to its end, the beacon's diff --git a/internal/exec/analysis/check_test.go b/internal/exec/analysis/check_test.go new file mode 100644 index 0000000000..96114fa579 --- /dev/null +++ b/internal/exec/analysis/check_test.go @@ -0,0 +1,748 @@ +package analysis + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// checkedAction is the fixture's action as a check starts it. +type checkedAction struct { + sym *symbols.Symbol +} + +func (f *fixture) checked(t *testing.T, name string) *checkedAction { + t.Helper() + return &checkedAction{sym: f.symbol(t, name)} +} + +func (a *checkedAction) start(ctx *runtime.Context) (*runtime.Invocation, error) { + exec, err := ctx.CreateActionExecutor(a.sym) + if err != nil { + return nil, err + } + return &runtime.Invocation{Actions: []*runtime.ActionExecutor{exec}}, nil +} + +// startAction is the action as a symbolic ask starts it: the invocation's one action. +func (a *checkedAction) startAction(ctx *runtime.Context) (*runtime.ActionExecutor, error) { + inv, err := a.start(ctx) + if err != nil { + return nil, err + } + return inv.Actions[0], nil +} + +// x is the property that the action's x is at most limit. +func (a *checkedAction) x(limit int64) runtime.CheckProperty { return a.atMost("x", limit) } + +// y is the property that the action's y is at most limit. +func (a *checkedAction) y(limit int64) runtime.CheckProperty { return a.atMost("y", limit) } + +// atMost is the property that the action's named integer is at most limit. +func (a *checkedAction) atMost(name string, limit int64) runtime.CheckProperty { + return runtime.CheckProperty{Name: name, Holds: func(_ *runtime.Context, inv *runtime.Invocation) (bool, error) { + v, ok := inv.Actions[0].Results()[name] + if !ok || v.Kind != runtime.ValConst { + return false, errors.New(name + " holds no value") + } + return v.Const.Int <= limit, nil + }} +} + +// checkQuestion asks kind about the racing action through the check engine. +func checkQuestion(t *testing.T, f *fixture, kind Kind, ask *CheckAsk) Question { + t.Helper() + return questionOf(t, "test::race", kind, ask) +} + +// questionOf asks kind about the named action through the check engine. +func questionOf(t *testing.T, subject string, kind Kind, ask *CheckAsk) Question { + t.Helper() + return Question{Kind: kind, Subject: subject, Schedule: policy(t, "explore"), Free: FreeSchedule, Check: ask} +} + +// finals is the outcome set a check reached, sorted. +func finals(c *Checked) []string { + var out []string + for _, f := range c.Report.Finals { + out = append(out, f.Outcome) + } + sort.Strings(out) + return out +} + +// explored is the outcome set an exploration reached, sorted. +func explored(x *runtime.Exploration) []string { + var out []string + for _, o := range x.Outcomes { + out = append(out, o.Outcome.String()) + } + sort.Strings(out) + return out +} + +// check describes itself as the framework's bounded, replaying engine over outcomes, holds +// and sensitivity. +func TestCheckDescribesItself(t *testing.T) { + d := NewCheck().Describe() + if NewCheck().Name() != CheckEngineName || d.Authority != Bounded || !d.Replays { + t.Fatalf("description %+v, want check, bounded, replaying", d) + } + if len(d.Questions) != 3 || d.Questions[0] != Outcomes || d.Questions[1] != Holds || d.Questions[2] != Sensitive { + t.Fatalf("questions %v, want outcomes, holds and sensitive", d.Questions) + } + for _, name := range append([]string{"depth", "states", "deadline"}, runtime.ExecutorBounds...) { + found := false + for _, b := range d.Bounds { + found = found || b == name + } + if !found { + t.Fatalf("bounds %v, want %s", d.Bounds, name) + } + } +} + +// The bounds a check result carries are the search's and each executor budget by its +// own name and limit: an exhausted budget is reached under that name, no other. +func TestCheckBoundsNameEachBudgetHit(t *testing.T) { + limits := runtime.Budgets{MaxSteps: 11, MaxActionSteps: 12, MaxStateEvents: 13, MaxDoSteps: 14, MaxElements: 15} + report := &runtime.CheckReport{Limits: limits, BoundsHit: []string{runtime.BoundActionSteps, runtime.BoundBehaviors}} + bounds := checkBounds(report, Budget{Depth: 7, Runs: 8}) + want := map[string]Bound{ + "depth": {Name: "depth", Limit: 7}, + "states": {Name: "states", Limit: 8}, + runtime.BoundSteps: {Name: runtime.BoundSteps, Limit: 11}, + runtime.BoundActionSteps: {Name: runtime.BoundActionSteps, Limit: 12, Reached: true}, + runtime.BoundEvents: {Name: runtime.BoundEvents, Limit: 13}, + runtime.BoundBehaviors: {Name: runtime.BoundBehaviors, Limit: 13, Reached: true}, + runtime.BoundDoSteps: {Name: runtime.BoundDoSteps, Limit: 14}, + runtime.BoundElements: {Name: runtime.BoundElements, Limit: 15}, + } + if len(bounds) != len(want) { + t.Fatalf("bounds %s, want %d of them", bounds, len(want)) + } + for _, b := range bounds { + if b != want[b.Name] { + t.Fatalf("bound %+v, want %+v", b, want[b.Name]) + } + } + for _, name := range NewCheck().Describe().Bounds { + if _, ok := bounds.Limit(name); !ok && name != "deadline" { + t.Fatalf("the engine describes %s but the result carries no such bound", name) + } + } +} + +// check refuses what it does not answer with typed reasons: another kind, free inputs, a +// fixed schedule, and a question that starts no action; a question without a Check is refused, +// so no surface that never sets one sees the engine. +func TestCheckCoversOutcomesAndHoldsOverAnActionsSchedules(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + e := NewCheck() + ask := &CheckAsk{Start: race.start} + for _, tc := range []struct { + name string + q Question + want error + }{ + {"evaluate", Question{Kind: Evaluate, Free: FreeSchedule, Check: ask}, ErrNotAsked}, + {"sweep", Question{Kind: Sweep, Free: FreeSchedule, Check: ask}, ErrNotAsked}, + {"free inputs", Question{Kind: Holds, Free: FreeSchedule | FreeInputs, Check: ask}, ErrFreedom}, + {"fixed schedule", Question{Kind: Outcomes, Check: ask}, ErrConstruct}, + {"no check", Question{Kind: Outcomes, Free: FreeSchedule, Linearize: raceRun(t, f)}, ErrMalformedQuestion}, + {"no start", Question{Kind: Holds, Free: FreeSchedule, Check: &CheckAsk{}}, ErrMalformedQuestion}, + } { + c := e.Covers(f.building(), tc.q) + if c.Covered || !errors.Is(c.Refusal, tc.want) { + t.Errorf("%s: coverage %+v, want refused with %v", tc.name, c, tc.want) + } + } + for _, kind := range []Kind{Outcomes, Holds, Sensitive} { + if c := e.Covers(f.building(), Question{Kind: kind, Free: FreeSchedule, Check: ask}); !c.Covered { + t.Errorf("%s: refused %v, want covered", kind, c.Refusal) + } + } +} + +// A check asks Sensitive once a feature is named to compare, on either ask; else Holds once +// a property, condition, input, assumption or unroll bound is stated; else Outcomes. +func TestCheckKindAsksSensitiveForANamedFeature(t *testing.T) { + for _, tc := range []struct { + name string + check *CheckAsk + holds *HoldsAsk + unroll int + want Kind + }{ + {"nothing", &CheckAsk{}, &HoldsAsk{}, 0, Outcomes}, + {"property", &CheckAsk{Properties: []runtime.CheckProperty{{Name: "x"}}}, &HoldsAsk{}, 0, Holds}, + {"input", &CheckAsk{}, &HoldsAsk{Inputs: []string{"n"}}, 0, Holds}, + {"unroll", &CheckAsk{}, &HoldsAsk{}, 2, Holds}, + {"diverge on check", &CheckAsk{Diverge: []string{"x"}}, &HoldsAsk{}, 0, Sensitive}, + {"diverge on holds", &CheckAsk{}, &HoldsAsk{Diverge: []string{"x"}}, 0, Sensitive}, + {"diverge beside a property", &CheckAsk{Properties: []runtime.CheckProperty{{Name: "x"}}, Diverge: []string{"x"}}, &HoldsAsk{Diverge: []string{"x"}}, 3, Sensitive}, + {"no asks", nil, nil, 0, Outcomes}, + } { + if got := CheckKind(tc.check, tc.holds, tc.unroll); got != tc.want { + t.Errorf("%s: kind %s, want %s", tc.name, got, tc.want) + } + } +} + +// A Sensitive question over the racing feature is check's witnessed sensitivity with both the +// witness and the contrast replayable to their two values; over a feature no schedule decides +// it is the bounded not-sensitive holds, the same pair the bounded negative of a Holds takes. +func TestCheckAnswersASensitiveQuestionWithAPair(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + steady := f.checked(t, "steady") + dir := filepath.Join(t.TempDir(), "witnesses") + ask := &CheckAsk{Start: race.start, Diverge: []string{"x"}, WitnessDir: dir} + result := answered(t, Default(), f.building(), checkQuestion(t, f, Sensitive, ask), Budget{}).Result + if result.Engine != CheckEngineName || result.Claim != ClaimSensitive || result.Strength != Witnessed { + t.Fatalf("result %+v, want check's witnessed sensitivity", result) + } + if result.Witness == nil || result.Contrast == nil || result.Witness.Written == "" || result.Contrast.Written == "" || result.Witness.Written == result.Contrast.Written { + t.Fatalf("witness %+v contrast %+v, want both written to files of their own", result.Witness, result.Contrast) + } + c := result.Check() + if c == nil || len(c.Report.Divergent) != 1 || len(c.Report.Divergent[0].Values) != 3 { + t.Fatalf("checked %+v, want x divergent over its three values", c) + } + for i, w := range []*Witness{result.Witness, result.Contrast} { + want := c.Report.Divergent[0].Values[i].Witness.Choices + if replay, ok := w.Schedule.Replay(); !ok || fmt.Sprint(replay) != fmt.Sprint(want) || fmt.Sprint(w.Choices) != fmt.Sprint(want) { + t.Fatalf("pair member %d %+v, want the schedule reaching value %d", i, w, i) + } + if _, err := os.Stat(w.Written); err != nil { + t.Fatalf("pair member %d: %v", i, err) + } + } + clean := answered(t, Default(), f.building(), questionOf(t, "test::steady", Sensitive, &CheckAsk{Start: steady.start, Diverge: []string{"y"}}), Budget{}).Result + if clean.Engine != CheckEngineName || clean.Claim != ClaimHolds || clean.Strength != Bounded || clean.Witness != nil || clean.Contrast != nil { + t.Fatalf("result %+v, want check holding y not sensitive, bounded", clean) + } + if s := clean.Standing(); !strings.HasPrefix(s, "holds (bounded") { + t.Fatalf("standing %q, want the bounded holds pair", s) + } +} + +// An exhaustive search answers outcomes bounded, never proved, with every final the +// schedules reach and no bound reached; the standing names the states searched. +func TestCheckBoundsAnOutcomeSet(t *testing.T) { + f := parseFixture(t) + steady := f.checked(t, "steady") + plan := answered(t, Default(), f.building(), questionOf(t, "test::steady", Outcomes, &CheckAsk{Start: steady.start}), Budget{}) + if !sameNames(stepNames(plan), []string{ExploreEngineName, CheckEngineName}) { + t.Fatalf("steps %v, want explore refusing then check", stepNames(plan)) + } + result := plan.Result + if result.Engine != CheckEngineName || result.Claim != ClaimOutcomes || result.Strength != Bounded { + t.Fatalf("result %+v, want check's bounded outcomes", result) + } + c := result.Check() + if c == nil || c.Report.Verdict != runtime.CheckExhaustive || len(c.Report.Finals) != 1 || len(c.Report.Divergent) != 0 { + t.Fatalf("checked %+v, want a complete search of 1 final with nothing divergent", c) + } + if result.Bounds.Reached() { + t.Fatalf("bounds %s, want none reached", result.Bounds) + } + if s := result.Standing(); !strings.Contains(s, "bounded") || !strings.Contains(s, "states") || strings.Contains(s, "proved") { + t.Fatalf("standing %q, want bounded with the states searched", s) + } +} + +// A feature the schedule decides is a sensitivity, witnessed by the schedule reaching +// its first value once every value's witness has replayed; the outcome set is kept. +func TestCheckWitnessesADivergence(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + for _, kind := range []Kind{Outcomes, Holds} { + result := answered(t, Default(), f.building(), checkQuestion(t, f, kind, &CheckAsk{Start: race.start, Properties: []runtime.CheckProperty{race.x(3)}}), Budget{}).Result + if result.Engine != CheckEngineName || result.Claim != ClaimSensitive || result.Strength != Witnessed || result.Witness == nil { + t.Fatalf("%s: result %+v, want check's witnessed sensitivity", kind, result) + } + c := result.Check() + if c == nil || c.Report.Verdict != runtime.CheckDivergent || len(c.Report.Finals) != 3 || len(c.Report.Divergent) != 1 || len(c.Report.Violations) != 0 { + t.Fatalf("%s: checked %+v, want a complete search of 3 finals with x divergent", kind, c) + } + if c.Violations != nil || c.Divergent != nil { + t.Fatalf("witness paths %v %v, want none written without a directory", c.Violations, c.Divergent) + } + if result.Reason != "x ends as 1 or 2 or 3" { + t.Fatalf("%s: reason %q, want the divergence spelt", kind, result.Reason) + } + first := c.Report.Divergent[0].Values[0].Witness.Choices + if replay, ok := result.Witness.Schedule.Replay(); !ok || len(replay) != len(first) { + t.Fatalf("%s: witness %s, want a replay of the first value's schedule", kind, result.Witness.Schedule) + } + } +} + +// A property true at every state holds bounded; one a schedule falsifies is violated, +// witnessed after replay, the witness being the schedule that reached it. +func TestCheckHoldsOrWitnessesAProperty(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + steady := f.checked(t, "steady") + holds := answered(t, Default(), f.building(), questionOf(t, "test::steady", Holds, &CheckAsk{Start: steady.start, Properties: []runtime.CheckProperty{steady.y(1)}}), Budget{}).Result + if holds.Engine != CheckEngineName || holds.Claim != ClaimHolds || holds.Strength != Bounded || holds.Witness != nil { + t.Fatalf("result %+v, want check holding bounded with no witness", holds) + } + violated := answered(t, Default(), f.building(), checkQuestion(t, f, Holds, &CheckAsk{Start: race.start, Properties: []runtime.CheckProperty{race.x(2)}}), Budget{}).Result + if violated.Claim != ClaimViolated || violated.Strength != Witnessed || violated.Witness == nil { + t.Fatalf("result %+v, want violated and witnessed", violated) + } + c := violated.Check() + if c == nil || c.Report.Verdict != runtime.CheckViolation || len(c.Report.Violations) == 0 || c.Report.Violations[0].Kind != runtime.ViolationProperty { + t.Fatalf("checked %+v, want a property violation", c) + } + if replay, ok := violated.Witness.Schedule.Replay(); !ok || len(replay) != len(c.Report.Violations[0].Witness.Choices) { + t.Fatalf("witness schedule %s, want a replay of the violation's choices", violated.Witness.Schedule) + } + if !strings.Contains(violated.Reason, "x is false") { + t.Fatalf("reason %q, want the violation", violated.Reason) + } +} + +// A Holds question over an action the model leaves an input of unbound carries the +// inputs free, so check refuses it rather than evaluating a value it does not have; +// with every input bound the question reaches check as put. +func TestCheckPutsUnboundInputsFree(t *testing.T) { + f := parseModel(t, `package test { + private import ScalarValues::*; + action open { + attribute n : Natural; + attribute limit : Integer = 5; + first start; + action add { assign limit := limit + 1; } + done; + succession first start then add; + succession first add then done; + } + action bound { + attribute limit : Integer = 5; + first start; + action add { assign limit := limit + 1; } + done; + succession first start then add; + succession first add then done; + } +}`) + for _, tc := range []struct { + action string + free bool + }{{"open", true}, {"bound", false}} { + a := f.checked(t, tc.action) + ask := &CheckAsk{Start: a.start, Properties: []runtime.CheckProperty{a.atMost("limit", 10)}} + plan, err := Default().Check(context.Background(), request(f.building(), "test::"+tc.action, policy(t, "explore")), Holds, FreeNothing, ask, &HoldsAsk{Behavior: a.sym, Start: a.startAction}, nil) + if errors.Is(err, ErrFreedom) != tc.free || err != nil && !tc.free { + t.Fatalf("%s: %v, want refused for free inputs %v", tc.action, err, tc.free) + } + if plan.Question.Free.Has(FreeInputs) != tc.free { + t.Errorf("%s: question free %v, want inputs free %v", tc.action, plan.Question.Free, tc.free) + } + if len(plan.Steps) != 1 || plan.Steps[0].Engine != CheckEngineName { + t.Fatalf("%s: steps %+v, want check alone", tc.action, plan.Steps) + } + step := plan.Steps[0] + if tc.free { + if !errors.Is(step.Refusal, ErrFreedom) || plan.Result.Strength != NotCovered { + t.Errorf("%s: step %+v result %+v, want check refusing the free inputs", tc.action, step, plan.Result) + } + continue + } + if step.Refusal != nil || plan.Result.Claim != ClaimHolds || plan.Result.Strength != Bounded { + t.Errorf("%s: step %+v result %+v, want check holding bounded", tc.action, step, plan.Result) + } + } +} + +// With a directory named, every violation and divergent value is written as a witness file +// the shared parser reads back to the choices the check took. +func TestCheckWritesEveryWitness(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + dir := filepath.Join(t.TempDir(), "witnesses") + ask := &CheckAsk{Start: race.start, Properties: []runtime.CheckProperty{race.x(1)}, Diverge: []string{"x"}, WitnessDir: dir} + result := answered(t, Default(), f.building(), checkQuestion(t, f, Holds, ask), Budget{}).Result + c := result.Check() + if c == nil || len(c.Violations) != len(c.Report.Violations) || len(c.Divergent) != 1 || len(c.Divergent[0]) != 3 { + t.Fatalf("checked %+v, want a path per violation and per divergent value of x", c) + } + paths := append([]string{}, c.Violations...) + paths = append(paths, c.Divergent[0]...) + seen := make(map[string]bool) + for i, path := range paths { + if seen[path] || !strings.HasPrefix(filepath.Base(path), "test.race") { + t.Fatalf("path %d %q, want distinct under the subject's name", i, path) + } + seen[path] = true + text, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + w, err := runtime.ParseWitness(string(text)) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + if w.Trace == "" { + t.Fatalf("%s carries no trace", path) + } + } + for _, path := range c.Violations { + if !strings.Contains(path, "violation-") { + t.Fatalf("violation path %q, want named as one", path) + } + } +} + +// A link planted where a witness will be written is replaced by the witness, a +// regular file, and what the link pointed at is left as it was. +func TestCheckWitnessReplacesAPlantedLink(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + dir := t.TempDir() + target := filepath.Join(t.TempDir(), "target") + const kept = "not a witness\n" + if err := os.WriteFile(target, []byte(kept), 0o600); err != nil { + t.Fatal(err) + } + planted := filepath.Join(dir, "test.race-x-1.witness") + if err := os.Symlink(target, planted); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + ask := &CheckAsk{Start: race.start, Diverge: []string{"x"}, WitnessDir: dir} + result := answered(t, Default(), f.building(), checkQuestion(t, f, Outcomes, ask), Budget{}).Result + c := result.Check() + if c == nil || len(c.Divergent) != 1 || c.Divergent[0][0] != planted { + t.Fatalf("checked %+v, want x's first witness at %s", c, planted) + } + if got, err := os.ReadFile(target); err != nil || string(got) != kept { + t.Fatalf("the link's target reads %q, %v; want it untouched", got, err) + } + info, err := os.Lstat(planted) + if err != nil || !info.Mode().IsRegular() { + t.Fatalf("witness path is %v, %v; want a regular file", info, err) + } + text, err := os.ReadFile(planted) + if err != nil { + t.Fatal(err) + } + if w, err := runtime.ParseWitness(string(text)); err != nil || w.String() != c.Report.Divergent[0].Values[0].Witness.String() { + t.Fatalf("witness at %s: %v, want x's first witness", planted, err) + } + if left, _ := filepath.Glob(filepath.Join(dir, ".*")); len(left) != 0 { + t.Fatalf("temporary files left: %v", left) + } +} + +// Two features spelled apart, `a/b` and `a?b`, are written to two files each, the +// characters no file name keeps spelled `%XX`; each file replays to the value it names. +func TestCheckWitnessFilesTellFeaturesApart(t *testing.T) { + const model = `package test { + action race { + attribute 'a/b' : Integer = 0; + attribute 'a?b' : Integer = 0; + first start; + fork split; + action p { assign 'a/b' := 1; assign 'a?b' := 1; } + action q { assign 'a/b' := 2; assign 'a?b' := 2; } + join sync; + done; + succession first start then split; + succession first split then p; + succession first split then q; + succession first p then sync; + succession first q then sync; + succession first sync then done; + } +}` + f := parseModel(t, model) + race := f.checked(t, "race") + dir := t.TempDir() + ask := &CheckAsk{Start: race.start, Diverge: []string{"a/b", "a?b"}, WitnessDir: dir} + result := answered(t, Default(), f.building(), checkQuestion(t, f, Outcomes, ask), Budget{}).Result + c := result.Check() + if result.Claim != ClaimSensitive || result.Strength != Witnessed || c == nil || len(c.Report.Divergent) != 2 { + t.Fatalf("%s %s %+v, want both features divergent and witnessed", result.Claim, result.Strength, c) + } + want := map[string][]string{ + "a/b": {"test.race-a%2Fb-1.witness", "test.race-a%2Fb-2.witness"}, + "a?b": {"test.race-a%3Fb-1.witness", "test.race-a%3Fb-2.witness"}, + } + for i, d := range c.Report.Divergent { + paths := c.Divergent[i] + if len(paths) != len(want[d.Feature]) || len(d.Values) != len(paths) { + t.Fatalf("%s: paths %v, want %v", d.Feature, paths, want[d.Feature]) + } + for j, path := range paths { + if path != filepath.Join(dir, want[d.Feature][j]) { + t.Errorf("%s value %d at %s, want %s", d.Feature, j+1, path, want[d.Feature][j]) + } + text, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + w, err := runtime.ParseWitness(string(text)) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + if w.String() != d.Values[j].Witness.String() { + t.Errorf("%s holds another witness than %s = %s", path, d.Feature, d.Values[j].Value) + } + replayed, err := runtime.Replay(context.Background(), func() (*runtime.Context, error) { return f.context(t), nil }, race.start, w, nil) + if err != nil { + t.Fatalf("%s: replay: %v", path, err) + } + if got := replayed.Inv.Actions[0].Results()[d.Feature]; got.Kind != runtime.ValConst || fmt.Sprint(got.Const.Int) != d.Values[j].Value { + t.Errorf("%s replays to %s = %v, want %s", path, d.Feature, got, d.Values[j].Value) + } + } + } +} + +// Witness files of several behaviors on one clock join the behaviors' names with +// `+` and spell a behavior's qualified feature with `.`, so the path reads as the +// verdict does and no token carries `-`. +func TestWitnessFilesOfSeveralBehaviors(t *testing.T) { + for _, c := range []struct{ subject, performer, feature, want string }{ + {"Mission::race", "", "x", "Mission.race-x-1.witness"}, + {"Plant::Tank::fill", "Plant::tank", "this.level", "Plant.Tank.fill@Plant.tank-this.level-1.witness"}, + {"Shine::Lamp::peek, Shine::Lamp::glow", "Shine::Lamp", "Shine::Lamp::peek.saw", "Shine.Lamp.peek+Shine.Lamp.glow@Shine.Lamp-Shine.Lamp.peek.saw-1.witness"}, + {"Shine::Lamp::peek, Shine::Lamp::glow", "Shine::Lamp", "Shine::Lamp::glow finalState", "Shine.Lamp.peek+Shine.Lamp.glow@Shine.Lamp-Shine.Lamp.glow%20finalState-1.witness"}, + {"Plant::Tank::fill Plant::tank, Plant::Tank::fill Plant::spare", "", "Plant::spare.level", "Plant.Tank.fill@Plant.tank+Plant.Tank.fill@Plant.spare-Plant.spare.level-1.witness"}, + {"Plant::Tank::fill #1, Plant::Tank::fill #2", "Plant::tank", "this.level", "Plant.Tank.fill.1+Plant.Tank.fill.2@Plant.tank-this.level-1.witness"}, + } { + if got := divergenceFile(c.subject, c.performer, c.feature, 1); got != c.want { + t.Errorf("divergenceFile(%q, %q, %q) = %s, want %s", c.subject, c.performer, c.feature, got, c.want) + } + } + if got, want := ViolationFile("Shine::Lamp::peek, Shine::Lamp::glow", "Shine::Lamp", 2), "Shine.Lamp.peek+Shine.Lamp.glow@Shine.Lamp.violation-2.witness"; got != want { + t.Errorf("ViolationFile = %s, want %s", got, want) + } +} + +// A witness whose replay leaves another state is not covered with the disagreement as its +// reason — never violated — and under all it yields to explore's complete table. +func TestCheckWitnessThatFailsReplayIsNotCovered(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + other := f.symbol(t, "Double") + var starts sync.Mutex + started := 0 + // The search starts race; every replay after it starts a calc instead, so no witness replays. + start := func(ctx *runtime.Context) (*runtime.Invocation, error) { + starts.Lock() + n := started + started++ + starts.Unlock() + if n == 0 { + return race.start(ctx) + } + exec, err := ctx.CreateActionExecutor(other) + if err != nil { + return nil, err + } + return &runtime.Invocation{Actions: []*runtime.ActionExecutor{exec}}, nil + } + q := checkQuestion(t, f, Holds, &CheckAsk{Start: start, Properties: []runtime.CheckProperty{race.x(2)}}) + result := answered(t, Default(), f.building(), q, Budget{}).Result + if result.Covered() || result.Claim != ClaimNone || !strings.Contains(result.Reason, "replay") { + t.Fatalf("result %+v, want not covered with the replay's disagreement", result) + } + if c := result.Check(); c == nil || c.Report.Verdict != runtime.CheckViolation { + t.Fatalf("checked %+v, want the report kept beside the refusal", c) + } + started = 0 + q.Kind = Outcomes + q.Linearize = raceRun(t, f) + plan, err := Default().AnswerWith(context.Background(), f.building(), q, Budget{}, All()) + if err != nil { + t.Fatal(err) + } + if plan.Result.Engine != ExploreEngineName || plan.Result.Strength != Proved || len(plan.Disagreements) != 0 { + t.Fatalf("result %+v (%d disagreements), want explore's proof undisputed", plan.Result, len(plan.Disagreements)) + } +} + +// Under all, check and explore answer the same outcomes question: explore proves the complete +// table, check bounds the same set, and neither disputes the other. +func TestExploreRefereesCheck(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + q := checkQuestion(t, f, Outcomes, &CheckAsk{Start: race.start}) + q.Linearize = raceRun(t, f) + plan, err := Default().AnswerWith(context.Background(), f.building(), q, Budget{}, All()) + if err != nil { + t.Fatal(err) + } + if len(plan.Disagreements) != 0 || plan.Result.Engine != ExploreEngineName || plan.Result.Strength != Proved { + t.Fatalf("plan %+v, want explore's proof standing with no disagreement", plan.Result) + } + var checked *Checked + var exploration *runtime.Exploration + for _, step := range plan.Steps { + if step.Result == nil { + continue + } + switch step.Engine { + case CheckEngineName: + checked = step.Result.Check() + if step.Result.Claim != ClaimSensitive || step.Result.Strength != Witnessed { + t.Fatalf("check %+v, want the race's sensitivity witnessed beside the proof", step.Result) + } + case ExploreEngineName: + exploration = step.Result.Exploration() + } + } + if checked == nil || exploration == nil || !exploration.Complete() { + t.Fatalf("steps %v, want check's report and explore's complete table", stepNames(plan)) + } + if got, want := finals(checked), explored(exploration); strings.Join(got, ";") != strings.Join(want, ";") { + t.Fatalf("check finals %v, explore outcomes %v, want equal", got, want) + } + auto := answered(t, Default(), f.building(), q, Budget{}) + if auto.Result.Engine != ExploreEngineName || len(auto.Steps) != 1 { + t.Fatalf("auto %+v (steps %v), want explore alone as the stronger authority", auto.Result, stepNames(auto)) + } +} + +// The budget's depth and runs bound the search as its depth and states; each reached is +// named and the claim stays bounded. +func TestCheckTakesTheBudgetsDepthAndRuns(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + q := checkQuestion(t, f, Outcomes, &CheckAsk{Start: race.start}) + byStates := answered(t, Default(), f.building(), q, Budget{Runs: 2}).Result + if byStates.Strength != Bounded || !byStates.Bounds.Reached() { + t.Fatalf("result %+v, want bounded with a bound reached", byStates) + } + if limit, ok := byStates.Bounds.Limit("states"); !ok || limit != 2 || byStates.Check().Report.Verdict != runtime.CheckWithinBounds { + t.Fatalf("bounds %s (verdict %v), want states=2 reached within bounds", byStates.Bounds, byStates.Check().Report.Verdict) + } + byDepth := answered(t, Default(), f.building(), q, Budget{Depth: 2}).Result + for _, b := range byDepth.Bounds { + if b.Name == "depth" && (b.Limit != 2 || !b.Reached) { + t.Fatalf("bounds %s, want depth=2 reached", byDepth.Bounds) + } + if b.Name == "states" && b.Reached { + t.Fatalf("bounds %s, want states unreached", byDepth.Bounds) + } + } + if !strings.Contains(byDepth.Standing(), "depth=2 (reached)") { + t.Fatalf("standing %q, want the depth bound named", byDepth.Standing()) + } +} + +// The plan's deadline stops the search: the step fails with the deadline, no result is +// composed, and nothing claims within bounds. +func TestCheckStopsAtThePlansDeadline(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + slow := runtime.CheckProperty{Name: "slow", Holds: func(*runtime.Context, *runtime.Invocation) (bool, error) { + time.Sleep(20 * time.Millisecond) + return true, nil + }} + q := checkQuestion(t, f, Holds, &CheckAsk{Start: race.start, Properties: []runtime.CheckProperty{slow}}) + plan, err := Default().Answer(context.Background(), f.building(), q, Budget{Deadline: time.Now().Add(50 * time.Millisecond)}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("answer: %v, want the deadline", err) + } + var stopped *runtime.CheckStopped + if !errors.As(err, &stopped) { + t.Fatalf("answer: %v, want the search's stop with its counts", err) + } + if plan.Result.Covered() { + t.Fatalf("result %+v, want nothing claimed", plan.Result) + } + for _, step := range plan.Steps { + if step.Engine == CheckEngineName && step.Err == nil { + t.Fatalf("step %+v, want the deadline as its error", step) + } + } +} + +// Two check plans on one model, on two goroutines, each build workers of their own; run +// under -race, this is the isolation test. +func TestChecksOnOneModelHaveWorkersOfTheirOwn(t *testing.T) { + f := parseFixture(t) + w := &workers{} + model := f.recording(w) + race := f.checked(t, "race") + q := checkQuestion(t, f, Outcomes, &CheckAsk{Start: race.start, Diverge: []string{"x"}}) + results := make([]Result, 2) + errs := make([]error, 2) + var wg sync.WaitGroup + for i := range results { + wg.Add(1) + go func(i int) { + defer wg.Done() + plan, err := Default().AnswerWith(context.Background(), model, q, Budget{Jobs: 2}, Only(CheckEngineName)) + results[i], errs[i] = plan.Result, err + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("plan %d: %v", i, err) + } + if results[i].Claim != ClaimSensitive || len(results[i].Check().Report.Divergent) != 1 || len(results[i].Check().Report.Divergent[0].Values) != 3 { + t.Fatalf("plan %d: %+v, want x divergent over 3 values", i, results[i]) + } + } + if resolvers, models := w.distinct(); resolvers < 2 || models < 2 { + t.Fatalf("%d resolvers, %d models across two plans, want each plan's own", resolvers, models) + } + if w.asked < 2 { + t.Fatalf("semantics asked %d times, want once per plan at least", w.asked) + } +} + +// A property that fails to evaluate is a violation witnessed once its replay raises the same +// failure; a property whose replay evaluates otherwise leaves the report not covered. +func TestCheckWitnessesAPropertyThatFailsToEvaluate(t *testing.T) { + f := parseFixture(t) + race := f.checked(t, "race") + offline := errors.New("x is offline") + failing := runtime.CheckProperty{Name: "sensor", Holds: func(_ *runtime.Context, inv *runtime.Invocation) (bool, error) { + exec := inv.Actions[0] + if exec.State() == runtime.StateCompleted && exec.Results()["x"].Const.Int == 2 { + return false, offline + } + return true, nil + }} + result := answered(t, Default(), f.building(), checkQuestion(t, f, Holds, &CheckAsk{Start: race.start, Properties: []runtime.CheckProperty{failing}}), Budget{}).Result + if result.Claim != ClaimViolated || result.Strength != Witnessed || result.Witness == nil { + t.Fatalf("result %+v, want the failure violated and witnessed", result) + } + c := result.Check() + if c == nil || len(c.Report.Violations) != 1 || c.Report.Violations[0].Kind != runtime.ViolationFailure || c.Report.Violations[0].Witness.Property != "sensor" { + t.Fatalf("checked %+v, want the sensor's failure as the one violation", c) + } + var evaluations atomic.Int32 + flaky := runtime.CheckProperty{Name: "sensor", Holds: func(_ *runtime.Context, inv *runtime.Invocation) (bool, error) { + exec := inv.Actions[0] + if exec.State() == runtime.StateCompleted && exec.Results()["x"].Const.Int == 2 && evaluations.Add(1) == 1 { + return false, offline + } + return true, nil + }} + result = answered(t, Default(), f.building(), checkQuestion(t, f, Holds, &CheckAsk{Start: race.start, Properties: []runtime.CheckProperty{flaky}}), Budget{}).Result + if result.Covered() || result.Claim != ClaimNone || !strings.Contains(result.Reason, "sensor") { + t.Fatalf("result %+v, want not covered by the sensor's disagreement", result) + } +} diff --git a/internal/core/analysis/compose.go b/internal/exec/analysis/compose.go similarity index 98% rename from internal/core/analysis/compose.go rename to internal/exec/analysis/compose.go index 5f5337fe18..582bc86e85 100644 --- a/internal/core/analysis/compose.go +++ b/internal/exec/analysis/compose.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" ) // Compose is the one result of several engines' answers, taken in engine-name order: a witness diff --git a/internal/core/analysis/compose_test.go b/internal/exec/analysis/compose_test.go similarity index 99% rename from internal/core/analysis/compose_test.go rename to internal/exec/analysis/compose_test.go index 0593c7c616..f6432cdc35 100644 --- a/internal/core/analysis/compose_test.go +++ b/internal/exec/analysis/compose_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" ) // holdsQuestion is a Holds question over schedules, as the smt engine will take it. diff --git a/internal/core/analysis/dispatch.go b/internal/exec/analysis/dispatch.go similarity index 100% rename from internal/core/analysis/dispatch.go rename to internal/exec/analysis/dispatch.go diff --git a/internal/core/analysis/dispatch_test.go b/internal/exec/analysis/dispatch_test.go similarity index 100% rename from internal/core/analysis/dispatch_test.go rename to internal/exec/analysis/dispatch_test.go diff --git a/internal/exec/analysis/doc.go b/internal/exec/analysis/doc.go new file mode 100644 index 0000000000..6aa6235c2c --- /dev/null +++ b/internal/exec/analysis/doc.go @@ -0,0 +1,15 @@ +// Package analysis is the analysis framework: one contract every way of +// answering a question about a model implements, one scale for the strength of +// an answer, a registry of engines and the dispatch that turns a question into +// the engines that answer it. +// +// A Question names a subject, what is asked of it and what is left free. An +// Engine covers a set of questions and answers one with a Result whose Strength +// says how the claim is supported. A Registry holds the engines a binary knows, +// and Registry.Answer dispatches a question under `auto`: the strongest covering +// engine answers, a not-covered result advances to the next, and the Plan +// records every engine consulted. +// +// The engines here — run, explore, sweep and solve — are adapters over the +// interpreter, runtime.Explore, Context.RunSweep and internal/exec/solve. +package analysis diff --git a/internal/exec/analysis/engine.go b/internal/exec/analysis/engine.go new file mode 100644 index 0000000000..5724a073c0 --- /dev/null +++ b/internal/exec/analysis/engine.go @@ -0,0 +1,262 @@ +package analysis + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// Model is the model a question is about, reached through the runtime contexts a surface +// builds over it: an evaluation runs in the surface's own where it holds one, every other run +// in a context of the run's own, on the worker the plan builds (see Worker). +type Model struct { + // Context is the surface's own runtime over the model; nil when the surface holds none. + Context func() (*runtime.Context, error) + // Semantics builds a worker's own model-derived part — resolver, semantic model and + // the runtime's memo tables — over the shared frozen index. + Semantics func() (*runtime.Model, error) + // Fresh builds a run's own context on a worker, under the surface's limits. + Fresh func(*Worker) (*runtime.Context, error) + + // workers are the plan's, one per job built on first use; a plan's copy of the model + // owns its own, and mu guards them as the plan's runs build them concurrently. + mu sync.Mutex + workers []*workerSlot + // tools is the plan's tool runner and where it is attached, shared by the plan's fleets; + // nil for none. + tools *toolPlan + // sessions are the plan's processes of its external engines, shared by the plan's + // fleets and closed with the plan; nil on a surface's model. + sessions *sessionPlan +} + +// Engine is one registered way of answering questions about a model. +type Engine interface { + // Name is the engine's stable identity. + Name() string + // Describe reports what the engine can do, fixed at registration. + Describe() Description + // Covers says, before any work, whether the engine can answer q for this model; + // a refusal names what it cannot handle and is never a result. + Covers(model *Model, q Question) Coverage + // Run answers q within the budget, in a context of its own. It never + // mutates model; it stops when ctx is done and reports how far it got. + Run(ctx context.Context, model *Model, q Question, budget Budget) (Result, error) +} + +// Description is an engine's capability declaration. +type Description struct { + // Questions are the kinds the engine answers. + Questions []Kind + // Process names the external process the engine needs, empty for none. + Process string + // Bounds names the bounds the engine takes, in the order it reports them. + Bounds []string + // Replays reports whether the engine's witnesses replay through the interpreter. + Replays bool + // Authority is the strongest evidence the engine can ever produce. + Authority Strength +} + +// Answers reports whether the engine declares the kind. +func (d Description) Answers(k Kind) bool { + for _, kind := range d.Questions { + if kind == k { + return true + } + } + return false +} + +// Coverage is what Covers answers: covered, or refused for a typed reason. +type Coverage struct { + Covered bool + // Refusal names the construct, condition or absent process the engine + // cannot handle; nil when covered. + Refusal error +} + +// covered is the coverage of a question the engine answers. +var covered = Coverage{Covered: true} + +// refused is the coverage of a question the engine will not take. +func refused(err error) Coverage { return Coverage{Refusal: err} } + +// External is an engine that needs a process outside the build, and reports +// whether it found one. +type External interface { + Engine + // Process names the process found (`z3 at /usr/bin/z3`), or reports its + // absence as the typed error Covers refuses with. + Process() (string, error) +} + +// ErrDuplicateEngine is the typed error Register returns for a name already registered. +var ErrDuplicateEngine = errors.New("engine already registered") + +// DuplicateEngineError reports a second registration under one name. +type DuplicateEngineError struct { + Name string +} + +// Error names the engine. +func (e *DuplicateEngineError) Error() string { + return fmt.Sprintf("analysis: engine %q is already registered", e.Name) +} + +// Is matches ErrDuplicateEngine. +func (e *DuplicateEngineError) Is(target error) bool { return target == ErrDuplicateEngine } + +// ErrNotAsked is the typed error an engine refuses with for a kind it does not answer. +var ErrNotAsked = errors.New("engine does not answer this kind of question") + +// NotAskedError reports a question of a kind the engine does not answer. +type NotAskedError struct { + Engine string + Kind Kind +} + +// Error names the engine and the kind. +func (e *NotAskedError) Error() string { + return fmt.Sprintf("%s does not answer %s questions", e.Engine, e.Kind) +} + +// Is matches ErrNotAsked. +func (e *NotAskedError) Is(target error) bool { return target == ErrNotAsked } + +// ErrFreedom is the typed error a concrete engine refuses with for a question +// that leaves free what the engine fixes. +var ErrFreedom = errors.New("engine cannot leave that free") + +// FreedomError reports a question leaving free what the engine fixes: inputs to +// one that runs concrete values only, the schedule to one that runs under one policy. +type FreedomError struct { + Engine string + Free Freedom +} + +// Error names the engine and what it cannot leave free. +func (e *FreedomError) Error() string { + return fmt.Sprintf("%s cannot leave %s free", e.Engine, e.Free) +} + +// Is matches ErrFreedom. +func (e *FreedomError) Is(target error) bool { return target == ErrFreedom } + +// ErrInput is the typed error for a feature a question names as an input that +// the engine cannot leave free: one the behavior lacks, or one it writes back. +var ErrInput = errors.New("engine cannot free that input") + +// InputError reports a named input the engine refuses to leave free, and why. +type InputError struct { + Engine string + Feature string + Reason string +} + +// Error names the engine, the feature and the reason. +func (e *InputError) Error() string { + return fmt.Sprintf("%s cannot leave %s free: %s", e.Engine, e.Feature, e.Reason) +} + +// Is matches ErrInput. +func (e *InputError) Is(target error) bool { return target == ErrInput } + +// ErrDomain is the typed error for a free input whose declared type the engine +// cannot narrow to a domain it ranges over. +var ErrDomain = errors.New("engine has no domain for that type") + +// DomainError reports a free input of a type the engine has no domain for; the +// question is not covered, since ranging over an unconstrained variable would +// claim what the model does not say. +type DomainError struct { + Engine string + Feature string + // Type is the declared type, as written; "" when the feature declares none. + Type string + // Reason says what the type lacks, when the engine can say more than its name. + Reason string +} + +// Error names the engine, the feature and its type. +func (e *DomainError) Error() string { + var msg string + if e.Type == "" { + msg = fmt.Sprintf("%s ranges over no domain for %s, which declares no type", e.Engine, e.Feature) + } else { + msg = fmt.Sprintf("%s ranges over no domain for %s : %s", e.Engine, e.Feature, e.Type) + } + if e.Reason != "" { + msg += ": " + e.Reason + } + return msg +} + +// Is matches ErrDomain. +func (e *DomainError) Is(target error) bool { return target == ErrDomain } + +// ErrProcessAbsent is the typed error an external engine refuses with when its +// process is not found. +var ErrProcessAbsent = errors.New("engine's process is absent") + +// ProcessAbsentError reports an external engine whose process was not found. +type ProcessAbsentError struct { + Engine string + // Process is what the engine needs, as its Description names it. + Process string + // Err is the probe's own report of the absence. + Err error +} + +// Error is the probe's own report, which already names what is absent and how +// to supply it; without one, Engine and Process say whose need it is. +func (e *ProcessAbsentError) Error() string { + if e.Err != nil { + return e.Err.Error() + } + return fmt.Sprintf("%s needs %s, which is absent", e.Engine, e.Process) +} + +// Is matches ErrProcessAbsent. +func (e *ProcessAbsentError) Is(target error) bool { return target == ErrProcessAbsent } + +// Unwrap returns the probe's report. +func (e *ProcessAbsentError) Unwrap() error { return e.Err } + +// ErrMalformedQuestion is the typed error for a question missing the ask its kind needs. +var ErrMalformedQuestion = errors.New("question lacks the ask its kind needs") + +// MalformedQuestionError reports a question whose kind's ask is missing. +type MalformedQuestionError struct { + Kind Kind + // Missing names the field the kind needs. + Missing string +} + +// Error names the kind and the field. +func (e *MalformedQuestionError) Error() string { + return fmt.Sprintf("analysis: a %s question needs %s", e.Kind, e.Missing) +} + +// Is matches ErrMalformedQuestion. +func (e *MalformedQuestionError) Is(target error) bool { return target == ErrMalformedQuestion } + +// ErrNoEngine is the typed error Answer returns when no registered engine +// answers the question's kind at all. +var ErrNoEngine = errors.New("no engine answers this kind of question") + +// NoEngineError reports a kind no registered engine declares. +type NoEngineError struct { + Kind Kind +} + +// Error names the kind. +func (e *NoEngineError) Error() string { + return fmt.Sprintf("analysis: no registered engine answers %s questions", e.Kind) +} + +// Is matches ErrNoEngine. +func (e *NoEngineError) Is(target error) bool { return target == ErrNoEngine } diff --git a/internal/core/analysis/engine_entry.go b/internal/exec/analysis/engine_entry.go similarity index 99% rename from internal/core/analysis/engine_entry.go rename to internal/exec/analysis/engine_entry.go index 41c8fc505d..c5b7483458 100644 --- a/internal/core/analysis/engine_entry.go +++ b/internal/exec/analysis/engine_entry.go @@ -10,14 +10,14 @@ import ( "strconv" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/export" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/modelform" ) // ProtocolVersions are the versions of the engine message set this build serves. var ProtocolVersions = []int{1} // GraphsVersions are the versions of the `graphs:` model form this build serves. -var GraphsVersions = []int{export.GraphsVersion} +var GraphsVersions = []int{modelform.GraphsVersion} // TransportStdio is a child process on a pair of pipes, the one transport this build serves. const TransportStdio = "stdio" diff --git a/internal/core/analysis/engine_entry_test.go b/internal/exec/analysis/engine_entry_test.go similarity index 100% rename from internal/core/analysis/engine_entry_test.go rename to internal/exec/analysis/engine_entry_test.go diff --git a/internal/core/analysis/enginewire/wire.go b/internal/exec/analysis/enginewire/wire.go similarity index 89% rename from internal/core/analysis/enginewire/wire.go rename to internal/exec/analysis/enginewire/wire.go index c582e645d3..649a45045a 100644 --- a/internal/core/analysis/enginewire/wire.go +++ b/internal/exec/analysis/enginewire/wire.go @@ -6,7 +6,7 @@ package enginewire import ( "encoding/json" - "github.com/Open-MBEE/OpenSysML/internal/core/export" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/modelform" ) // Version is the version of this message set. @@ -119,6 +119,15 @@ type Question struct { SubjectKind string `json:"subjectKind,omitempty"` // Schedule is the scheduling policy the question states, as -schedule spells it. Schedule string `json:"schedule"` + // ModelSeed is the seed the runs' modeled draws come from, apart from the schedule's; + // absent leaves them to a `seed:` schedule. + ModelSeed *uint64 `json:"modelSeed,omitempty"` + // Draws is the policy the runs' RandomFunctions draws resolve under, as -draws + // spells it (min, max, average); absent draws at random. + Draws string `json:"draws,omitempty"` + // ClockStep is the step the runs' clock ticks by, in seconds, on which their waits + // come due, as -clock-step spells it; absent is a continuous clock. + ClockStep float64 `json:"clockStep,omitempty"` // Free is what the question leaves open: "schedule", "inputs". Free []string `json:"free"` // Condition is the requirement or constraint a holds question asks about; absent asks @@ -163,12 +172,15 @@ type FreeInput struct { Domain string `json:"domain,omitempty"` } -// Sweep is a sweep question's domain. +// Sweep is a sweep question's domain; a Monte Carlo states runs and no range, with a +// seed unless its runs draw nothing at random (a fixed Draws policy). Seed is present, +// zero included, whenever the rows are drawn from it. type Sweep struct { Ranges []Range `json:"ranges"` Sampled bool `json:"sampled,omitempty"` Samples int64 `json:"samples,omitempty"` - Seed uint64 `json:"seed,omitempty"` + Seed *uint64 `json:"seed,omitempty"` + Runs int64 `json:"runs,omitempty"` } // Range is one parameter's range of a sweep. @@ -208,10 +220,10 @@ type Budget struct { } // Model is the model in the forms the entry asked for; sources is always present -// and graphs is the export layer's `graphs:` form, export.Graphs as JSON. +// and graphs is the `graphs:` form, modelform.Graphs as JSON. type Model struct { - Sources *export.Sources `json:"sources,omitempty"` - Graphs json.RawMessage `json:"graphs,omitempty"` + Sources *modelform.Sources `json:"sources,omitempty"` + Graphs json.RawMessage `json:"graphs,omitempty"` } // Result is the engine's answer to run: the framework's Result as JSON. Strength is what diff --git a/internal/exec/analysis/explore.go b/internal/exec/analysis/explore.go new file mode 100644 index 0000000000..e0e26965a4 --- /dev/null +++ b/internal/exec/analysis/explore.go @@ -0,0 +1,104 @@ +package analysis + +import ( + "context" + "fmt" + "slices" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// ExploreEngineName is the name of the engine that runs every linearization. +const ExploreEngineName = "explore" + +// exploreEngine answers Outcomes questions by running the interpreter once per linearization +// within a runs and depth budget, each in a context of its own on one of the plan's workers. +type exploreEngine struct{} + +// NewExplore returns the explore engine. +func NewExplore() Engine { return exploreEngine{} } + +// Name is `explore`. +func (exploreEngine) Name() string { return ExploreEngineName } + +// Describe: every schedule for the inputs as written, so a complete +// exploration proves its outcome set; each outcome's witness is a run it made. +func (exploreEngine) Describe() Description { + return Description{ + Questions: []Kind{Outcomes}, + Bounds: []string{"runs", "depth"}, + Replays: true, + Authority: Proved, + } +} + +// Covers takes an Outcomes question over concrete inputs whose policy explores; +// it runs concrete values only, so free inputs are refused. +func (e exploreEngine) Covers(_ *Model, q Question) Coverage { + if q.Kind != Outcomes { + return refused(&NotAskedError{Engine: e.Name(), Kind: q.Kind}) + } + if q.Free.Has(FreeInputs) { + return refused(&FreedomError{Engine: e.Name(), Free: FreeInputs}) + } + if q.Linearize == nil { + return refused(&MalformedQuestionError{Kind: q.Kind, Missing: "a Linearize"}) + } + if _, ok := q.Schedule.Exploration(); !ok { + return refused(fmt.Errorf("%w: %s", runtime.ErrNotExploring, q.Schedule)) + } + return covered +} + +// Run explores under the budget's runs and depth (else the policy's own) on the budget's jobs, +// each run in a context of its own under the budget on the worker of the job making it: +// complete is proved, incomplete observed naming the budget hit. A model that builds no +// context of a run's own is the typed fault NoRuntimeError. +func (e exploreEngine) Run(ctx context.Context, model *Model, q Question, budget Budget) (Result, error) { + policy, err := explorePolicy(q.Schedule, budget) + if err != nil { + return Result{}, err + } + if !model.builds() { + return Result{}, &NoRuntimeError{Engine: e.Name()} + } + fresh := func(job int) (*runtime.Context, error) { return q.fresh(model, job, budget) } + started := time.Now() + x, err := runtime.ExploreWith(ctx, policy, budget.Jobs, fresh, q.Linearize) + if err != nil { + return Result{}, err + } + result := Result{ + Question: q, + Engine: e.Name(), + Claim: ClaimOutcomes, + Strength: Observed, + Bounds: Bounds{ + {Name: "runs", Limit: int64(x.Budget.Runs), Reached: slices.Contains(x.BudgetsHit, "runs")}, + {Name: "depth", Limit: int64(x.Budget.Depth), Reached: slices.Contains(x.BudgetsHit, "depth")}, + }, + Values: []Evaluation{{Name: q.Subject, Explored: x}}, + Elapsed: time.Since(started), + } + if x.Complete() { + result.Strength = Proved + } + return result, nil +} + +// explorePolicy is the question's policy with the budget's runs and depth where +// the budget names them. +func explorePolicy(policy runtime.SchedulePolicy, budget Budget) (runtime.SchedulePolicy, error) { + limits, ok := policy.Exploration() + if !ok { + return policy, fmt.Errorf("%w: %s", runtime.ErrNotExploring, policy) + } + if budget.Runs > 0 { + limits.Runs = budget.Runs + } + if budget.Depth > 0 { + limits.Depth = budget.Depth + } + return runtime.ExplorePolicy(limits) +} diff --git a/internal/exec/analysis/explore_test.go b/internal/exec/analysis/explore_test.go new file mode 100644 index 0000000000..23154c82b0 --- /dev/null +++ b/internal/exec/analysis/explore_test.go @@ -0,0 +1,158 @@ +package analysis + +import ( + "context" + "errors" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// raceRun is one run of the fixture's racing action. +func raceRun(t *testing.T, f *fixture) Linearization { + t.Helper() + race := f.symbol(t, "race") + return func(ctx *runtime.Context) (runtime.Outcome, error) { + outputs, err := ctx.ExecuteAction(race) + if err != nil { + return runtime.Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } +} + +// outcomes asks the default registry for the racing action's outcomes under the policy. +func outcomes(t *testing.T, f *fixture, spelling string, budget Budget) Plan { + t.Helper() + q := Question{Kind: Outcomes, Subject: "test::race", Schedule: policy(t, spelling), Free: FreeSchedule, Linearize: raceRun(t, f)} + return answered(t, Default(), f.building(), q, budget) +} + +func TestExploreProvesACompleteOutcomeSet(t *testing.T) { + f := parseFixture(t) + plan := outcomes(t, f, "explore", Budget{}) + result := plan.Result + if result.Engine != ExploreEngineName || result.Claim != ClaimOutcomes || result.Strength != Proved { + t.Fatalf("result %+v, want explore's proved outcomes", result) + } + x := result.Exploration() + if x == nil || !x.Complete() || x.Runs != 6 || len(x.Outcomes) != 3 { + t.Fatalf("exploration %+v, want 6 runs reaching 3 outcomes", x) + } + if result.Bounds.Reached() { + t.Fatalf("bounds %s, want none reached", result.Bounds) + } + if runs, ok := result.Bounds.Limit("runs"); !ok || runs != int64(x.Budget.Runs) { + t.Fatalf("bounds %s, want the policy's runs budget", result.Bounds) + } + if !sameNames(stepNames(plan), []string{ExploreEngineName}) { + t.Fatalf("steps %v, want explore alone", stepNames(plan)) + } +} + +func TestExploreObservesAnIncompleteOutcomeSet(t *testing.T) { + f := parseFixture(t) + result := outcomes(t, f, "explore:runs=1", Budget{}).Result + if result.Claim != ClaimOutcomes || result.Strength != Observed { + t.Fatalf("result %+v, want observed outcomes", result) + } + x := result.Exploration() + if x == nil || x.Complete() || x.Runs != 1 { + t.Fatalf("exploration %+v, want 1 run short of complete", x) + } + for _, b := range result.Bounds { + if b.Name == "runs" && (b.Limit != 1 || !b.Reached) { + t.Fatalf("bounds %s, want runs=1 reached", result.Bounds) + } + if b.Name == "depth" && b.Reached { + t.Fatalf("bounds %s, want depth unreached", result.Bounds) + } + } +} + +func TestExploreTakesTheBudgetsRunsAndDepth(t *testing.T) { + f := parseFixture(t) + x := outcomes(t, f, "explore", Budget{Runs: 2}).Result.Exploration() + if x == nil || x.Budget.Runs != 2 || x.Runs != 2 || x.Complete() { + t.Fatalf("exploration %+v, want the budget's 2 runs hit", x) + } + x = outcomes(t, f, "explore", Budget{Depth: 0}).Result.Exploration() + if x == nil || !x.Complete() { + t.Fatalf("exploration %+v, want a zero depth left to the policy", x) + } +} + +func TestRegistryExploreIsTheRuntimesExploration(t *testing.T) { + f := parseFixture(t) + plan, err := Default().Explore(context.Background(), request(f.building(), "test::race", policy(t, "explore")), raceRun(t, f)) + if err != nil { + t.Fatalf("explore: %v", err) + } + x := plan.Result.Exploration() + if !x.Complete() || len(x.Outcomes) != 3 { + t.Fatalf("exploration %+v, want the 3 outcomes", x) + } + if _, err = Default().Explore(context.Background(), request(f.building(), "test::race", runtime.DefaultSchedulePolicy), raceRun(t, f)); !errors.Is(err, runtime.ErrNotExploring) { + t.Fatalf("explore under a fixed schedule: %v, want the refusal", err) + } +} + +// A caller that goes away mid-exploration ends it before the next run, and the +// cancellation is the plan's error rather than an outcome or a bound reached. +func TestExploreStopsWhenTheCallerGoesAway(t *testing.T) { + f := parseFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + runs := 0 + run := raceRun(t, f) + q := Question{Kind: Outcomes, Subject: "test::race", Schedule: policy(t, "explore"), Free: FreeSchedule, + Linearize: func(rctx *runtime.Context) (runtime.Outcome, error) { + runs++ + if runs == 2 { + cancel() + } + return run(rctx) + }, + } + plan, err := Default().Answer(ctx, f.building(), q, Budget{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("answer after cancel: %v, want context.Canceled", err) + } + if runs != 2 { + t.Fatalf("%d runs made, want the exploration to stop before the third", runs) + } + if len(plan.Steps) != 1 || plan.Steps[0].Engine != ExploreEngineName || !errors.Is(plan.Steps[0].Err, context.Canceled) { + t.Fatalf("steps %+v, want explore's step carrying the cancellation", plan.Steps) + } + if plan.Result.Covered() { + t.Fatalf("result %+v, want nothing established", plan.Result) + } + + // A run that fails on its own is an outcome, not the exploration's error. + failing := errors.New("run failed") + q.Linearize = func(*runtime.Context) (runtime.Outcome, error) { return runtime.Outcome{}, failing } + x := answered(t, Default(), f.building(), q, Budget{}).Result.Exploration() + if x == nil || len(x.Outcomes) != 1 || !errors.Is(x.Outcomes[0].Outcome.Err, failing) { + t.Fatalf("exploration %+v, want the failure as its one outcome", x) + } +} + +func TestExploreRefusesWhatItCannotRun(t *testing.T) { + e := NewExplore() + run := func(*runtime.Context) (runtime.Outcome, error) { return runtime.Outcome{}, nil } + if c := e.Covers(nil, Question{Kind: Evaluate}); c.Covered || !errors.Is(c.Refusal, ErrNotAsked) { + t.Fatalf("evaluate: %+v, want not asked", c) + } + if c := e.Covers(nil, Question{Kind: Outcomes, Free: FreeSchedule | FreeInputs, Linearize: run}); c.Covered || !errors.Is(c.Refusal, ErrFreedom) { + t.Fatalf("free inputs: %+v, want a freedom refusal", c) + } + if c := e.Covers(nil, Question{Kind: Outcomes, Free: FreeSchedule}); c.Covered || !errors.Is(c.Refusal, ErrMalformedQuestion) { + t.Fatalf("no linearize: %+v, want malformed", c) + } + if c := e.Covers(nil, Question{Kind: Outcomes, Free: FreeSchedule, Linearize: run, Schedule: runtime.DefaultSchedulePolicy}); c.Covered || !errors.Is(c.Refusal, runtime.ErrNotExploring) { + t.Fatalf("fixed schedule: %+v, want not exploring", c) + } + if c := e.Covers(nil, Question{Kind: Outcomes, Free: FreeSchedule, Linearize: run, Schedule: policy(t, "explore")}); !c.Covered { + t.Fatalf("exploring: %+v, want covered", c) + } +} diff --git a/internal/core/analysis/external.go b/internal/exec/analysis/external.go similarity index 99% rename from internal/core/analysis/external.go rename to internal/exec/analysis/external.go index 4c291879a0..fbe74cfc5d 100644 --- a/internal/core/analysis/external.go +++ b/internal/exec/analysis/external.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis/enginewire" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/enginewire" ) // externalEngine is a manifest `engine` entry as the framework runs it: a process of the diff --git a/internal/core/analysis/external_question.go b/internal/exec/analysis/external_question.go similarity index 88% rename from internal/core/analysis/external_question.go rename to internal/exec/analysis/external_question.go index 272ce970d2..b6de4e711b 100644 --- a/internal/core/analysis/external_question.go +++ b/internal/exec/analysis/external_question.go @@ -7,11 +7,11 @@ import ( "strconv" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis/enginewire" - "github.com/Open-MBEE/OpenSysML/internal/core/export" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/enginewire" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/modelform" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // coversParams is the covers request: the question and the model in the entry's forms. @@ -86,6 +86,16 @@ func (e externalEngine) wireQuestion(model *Model, q Question, budget Budget) (e Schedule: q.Schedule.String(), Free: freeNames(q.Free), } + if q.ModelSeed.Set { + seed := q.ModelSeed.Seed + out.ModelSeed = &seed + } + if q.Draws.Fixed() { + out.Draws = q.Draws.String() + } + if q.ClockStep > 0 { + out.ClockStep = q.ClockStep + } out.SubjectKind = subjectFamily(model, q.Subject) switch q.Kind { case Holds, Outcomes: @@ -192,9 +202,14 @@ func wireQuery(q *solve.Query) (enginewire.ConditionSet, error) { return set, nil } -// wireSweep is a sweep's domain as the protocol carries it. +// wireSweep is a sweep's domain as the protocol carries it; the seed goes, zero included, +// whenever the rows are drawn from it. func wireSweep(plan runtime.SweepPlan) (*enginewire.Sweep, error) { - out := &enginewire.Sweep{Ranges: []enginewire.Range{}, Sampled: plan.Sampled, Samples: plan.Samples, Seed: plan.Seed} + out := &enginewire.Sweep{Ranges: []enginewire.Range{}, Sampled: plan.Sampled, Samples: plan.Samples, Runs: plan.Runs} + if plan.Drawn() { + seed := plan.Seed + out.Seed = &seed + } for _, r := range plan.Ranges { from, err := wireValue(r.Param, r.From) if err != nil { @@ -292,31 +307,31 @@ func (e externalEngine) wireModel(model *Model, q Question) (enginewire.Model, e for _, form := range e.entry.Forms() { switch { case form == FormSources: - sources, err := export.SourcesOf(semantics) + sources, err := modelform.SourcesOf(semantics) if err != nil { return enginewire.Model{}, err } out.Sources = sources case form == FormRDF: - return enginewire.Model{}, export.RefuseRDFForm() - case form == GraphsForm(export.GraphsVersion): + return enginewire.Model{}, modelform.RefuseRDFForm() + case form == GraphsForm(modelform.GraphsVersion): subject := subjectOf(model, q.Subject) if subject == nil { - return enginewire.Model{}, fmt.Errorf("%w: %q resolves to no one declaration", export.ErrGraphsSubject, q.Subject) + return enginewire.Model{}, fmt.Errorf("%w: %q resolves to no one declaration", modelform.ErrGraphsSubject, q.Subject) } - graphs, err := export.GraphsOf(semantics, subject) + graphs, err := modelform.GraphsOf(semantics, subject) if err != nil { return enginewire.Model{}, err } - raw, err := export.MarshalGraphs(graphs) + raw, err := modelform.MarshalGraphs(graphs) if err != nil { return enginewire.Model{}, err } out.Graphs = json.RawMessage(raw) default: version, _ := form.GraphsVersion() - return enginewire.Model{}, &export.FormUnsupportedError{Form: string(form), - Reason: "this build exports graphs:" + strconv.Itoa(export.GraphsVersion) + ", not version " + strconv.Itoa(version)} + return enginewire.Model{}, &modelform.FormUnsupportedError{Form: string(form), + Reason: "this build exports graphs:" + strconv.Itoa(modelform.GraphsVersion) + ", not version " + strconv.Itoa(version)} } } return out, nil diff --git a/internal/exec/analysis/external_question_test.go b/internal/exec/analysis/external_question_test.go new file mode 100644 index 0000000000..ff8fb13968 --- /dev/null +++ b/internal/exec/analysis/external_question_test.go @@ -0,0 +1,174 @@ +package analysis + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// wired is the JSON the host writes for q, as an engine reads it. +func wired(t *testing.T, model *Model, q Question) string { + t.Helper() + out, err := externalEngine{}.wireQuestion(model, q, Budget{}) + if err != nil { + t.Fatalf("wire: %v", err) + } + data, err := json.Marshal(out) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// A sweep's seed goes on the wire whenever rows are drawn from it, a seed of 0 included, +// and stays off it for a swept table, which draws nothing, and for a Monte Carlo under +// a fixed draw policy, whose runs derive no seed; an engine reading the line can tell an +// unseeded sweep from one seeded with 0. +func TestWireSweepKeepsAZeroSeed(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + sweep := func(plan runtime.SweepPlan) Question { + return Question{Kind: Sweep, Subject: "test::Double", Schedule: ctx.Schedule(), Sweep: &SweepAsk{Plan: plan, Row: doubleRow(t, f)}} + } + stepped := doublePlan(t, f, ctx) + sampled := stepped + sampled.Sampled, sampled.Samples, sampled.Seed = true, 4, 0 + cases := []struct { + name string + plan runtime.SweepPlan + want string + }{ + {"stepped", stepped, `"from":1,"to":3}]}}`}, + {"sampled seed 0", sampled, `"sampled":true,"samples":4,"seed":0}`}, + {"runs seed 0", runtime.MonteCarloPlan(3, 0), `"sweep":{"ranges":[],"seed":0,"runs":3}`}, + {"runs seed 7", runtime.MonteCarloPlan(3, 7), `"sweep":{"ranges":[],"seed":7,"runs":3}`}, + {"runs without a seed", runtime.SeedlessMonteCarloPlan(3), `"sweep":{"ranges":[],"runs":3}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + line := wired(t, f.building(), sweep(c.plan)) + if !strings.Contains(line, c.want) { + t.Fatalf("the host wrote %s, want it to carry %s", line, c.want) + } + var back struct { + Sweep struct { + Seed *uint64 `json:"seed"` + } `json:"sweep"` + } + if err := json.Unmarshal([]byte(line), &back); err != nil { + t.Fatal(err) + } + if got, want := back.Sweep.Seed != nil, c.plan.Drawn(); got != want { + t.Fatalf("seed present = %v, want %v for %+v", got, want, c.plan) + } + }) + } +} + +// A question carries the model seed its runs draw from apart from the schedule — 0 as +// much as any other — and none when no seed is set, so the two knobs the surface has +// reach the engine as two. +func TestWireQuestionCarriesTheModelSeed(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + perform := func(*runtime.Context) (Answer, error) { return Answer{Claim: ClaimHolds}, nil } + cases := []struct { + name string + seed ModelSeed + want string + }{ + {"unset", ModelSeed{}, `"schedule":"seed:5","free":[]`}, + {"zero", ModelSeed{Set: true}, `"schedule":"seed:5","modelSeed":0,"free":[]`}, + {"eleven", ModelSeed{Seed: 11, Set: true}, `"schedule":"seed:5","modelSeed":11,"free":[]`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + q := Question{Kind: Evaluate, Subject: "test::Tank::low", Schedule: policy(t, "seed:5"), ModelSeed: c.seed, Perform: perform} + if line := wired(t, Held(ctx), q); !strings.Contains(line, c.want) { + t.Fatalf("the host wrote %s, want it to carry %s", line, c.want) + } + }) + } + if strings.Contains(wired(t, Held(ctx), Question{Kind: Evaluate, Subject: "test::Tank::low", Schedule: ctx.Schedule(), Perform: perform}), "modelSeed") { + t.Fatal("a question with no model seed set named one") + } +} + +// A question carries a fixed draw policy as -draws spells it and none under random, +// the default; the policy a held context was given is the one its question carries. +func TestWireQuestionCarriesTheDrawPolicy(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + perform := func(*runtime.Context) (Answer, error) { return Answer{Claim: ClaimHolds}, nil } + for _, policy := range []runtime.DrawPolicy{runtime.DrawMin, runtime.DrawMax, runtime.DrawAverage} { + t.Run(policy.String(), func(t *testing.T) { + q := Question{Kind: Evaluate, Subject: "test::Tank::low", Schedule: ctx.Schedule(), Draws: policy, Perform: perform} + line := wired(t, Held(ctx), q) + if want := `"draws":"` + policy.String() + `"`; !strings.Contains(line, want) { + t.Fatalf("the host wrote %s, want it to carry %s", line, want) + } + }) + } + if strings.Contains(wired(t, Held(ctx), Question{Kind: Evaluate, Subject: "test::Tank::low", Schedule: ctx.Schedule(), Perform: perform}), "draws") { + t.Fatal("a question drawing at random named a policy") + } + if got := DrawsOf(nil); got != runtime.DrawRandom { + t.Fatalf("no context draws under %s, want random", got) + } + ctx.SetDrawPolicy(runtime.DrawAverage) + if got := DrawsOf(ctx); got != runtime.DrawAverage { + t.Fatalf("the held context draws under %s, want average", got) + } +} + +// A question on a stepped clock writes the step on the wire as clockStep; one on the +// continuous clock writes none, and a held context's step is what its question carries. +func TestWireQuestionCarriesTheClockStep(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + perform := func(*runtime.Context) (Answer, error) { return Answer{Claim: ClaimHolds}, nil } + q := Question{Kind: Evaluate, Subject: "test::Tank::low", Schedule: ctx.Schedule(), ClockStep: 0.25, Perform: perform} + if line := wired(t, Held(ctx), q); !strings.Contains(line, `"clockStep":0.25`) { + t.Fatalf("the host wrote %s, want it to carry the clock step", line) + } + q.ClockStep = 0 + if line := wired(t, Held(ctx), q); strings.Contains(line, "clockStep") { + t.Fatalf("the host wrote %s for a continuous clock, want no clockStep", line) + } + if got := ClockStepOf(nil); got != 0 { + t.Fatalf("no context steps by %v, want a continuous clock", got) + } + if err := ctx.SetClockStep(0.5); err != nil { + t.Fatal(err) + } + if got := ClockStepOf(ctx); got != 0.5 { + t.Fatalf("the held context steps by %v, want 0.5", got) + } +} + +// The model seed a held context was given is the one its question carries, and the +// registry hands a request's seed to the question it asks. +func TestModelSeedOfAContextAndARequest(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + if got := ModelSeedOf(ctx); got.Set { + t.Fatalf("a fresh context has model seed %+v, want none", got) + } + if got := ModelSeedOf(nil); got.Set { + t.Fatalf("no context has model seed %+v, want none", got) + } + ctx.SetModelSeed(0) + if got, want := ModelSeedOf(ctx), (ModelSeed{Set: true}); got != want { + t.Fatalf("model seed %+v, want %+v", got, want) + } + ctx.SetModelSeed(11) + if got, want := ModelSeedOf(ctx), (ModelSeed{Seed: 11, Set: true}); got != want { + t.Fatalf("model seed %+v, want %+v", got, want) + } + ctx.ClearModelSeed() + if got := ModelSeedOf(ctx); got.Set { + t.Fatalf("a cleared context has model seed %+v, want none", got) + } +} diff --git a/internal/core/analysis/external_standing.go b/internal/exec/analysis/external_standing.go similarity index 93% rename from internal/core/analysis/external_standing.go rename to internal/exec/analysis/external_standing.go index 1f1b5724a2..0171dc5053 100644 --- a/internal/core/analysis/external_standing.go +++ b/internal/exec/analysis/external_standing.go @@ -7,10 +7,10 @@ import ( "strings" "sync" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis/enginewire" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/enginewire" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // shape is the protocol check of an answer's form: a claim and strength the framework @@ -269,7 +269,7 @@ func freeInputs(model *Model, q Question, budget Budget) (freed, error) { if !q.Free.Has(FreeInputs) || q.Check == nil || q.Check.Start == nil || !model.builds() { return freed{}, nil } - ctx, err := model.NewContextOn(0, budget) + ctx, err := q.fresh(model, 0, budget) if err != nil { return freed{}, err } @@ -305,13 +305,31 @@ func freeInputs(model *Model, q Question, budget Budget) (freed, error) { // inputs, read against the inputs the question leaves free. type replay struct { ask *CheckAsk + seed ModelSeed + draws runtime.DrawPolicy + step float64 free []enginewire.FreeInput inputs []runtime.InputTaken } +// fresh is a context of a replay's own on the plan's worker for job, seeded and drawing as the question's runs are. +func (r replay) fresh(model *Model, job int, budget Budget) (*runtime.Context, error) { + return Question{ModelSeed: r.seed, Draws: r.draws, ClockStep: r.step}.fresh(model, job, budget) +} + // witness is the runtime witness of one schedule of the wire witness. func (r replay) witness(choices []runtime.ChoiceTaken) runtime.Witness { - return runtime.Witness{Inputs: r.inputs, Choices: choices} + return runtime.Witness{Inputs: r.inputs, DrawPolicy: r.draws, ClockStep: r.step, Choices: choices} +} + +// reported is the witness a replayed schedule stands as: the engine's choices with the +// draws the replay resolved under the question's policy, so the record replays alone. +func (r replay) reported(choices []runtime.ChoiceTaken, replayed *runtime.Replayed) *Witness { + rw := r.witness(choices) + if replayed != nil && replayed.Ctx != nil { + rw.Draws = replayed.Ctx.DrawsTaken() + } + return &Witness{Schedule: runtime.ReplayOf(rw), Inputs: r.inputs, Draws: rw.Draws, Choices: choices} } // replayable is the replay of a schedule witness, or the typed refusal: a question that @@ -320,7 +338,7 @@ func (e externalEngine) replayable(model *Model, q Question, budget Budget, w en if q.Check == nil || q.Check.Start == nil { return replay{}, &NoReplayError{Engine: e.Name(), Kind: q.Kind} } - out := replay{ask: q.Check} + out := replay{ask: q.Check, seed: q.ModelSeed, draws: q.Draws, step: q.ClockStep} if len(w.Inputs) == 0 { return out, nil } @@ -394,10 +412,10 @@ func (e externalEngine) replayOne(ctx context.Context, model *Model, budget Budg if err != nil { return nil, nil, fmt.Errorf("its witness does not read as a schedule (%v)", err), nil } - fresh := func() (*runtime.Context, error) { return model.NewContextOn(job, budget) } + fresh := func() (*runtime.Context, error) { return r.fresh(model, job, budget) } rw := r.witness(choices) replayed, err := runtime.ReplaySchedule(ctx, fresh, r.ask.Start, rw, at) - witness := &Witness{Schedule: runtime.ReplayOf(rw), Inputs: r.inputs, Choices: choices} + witness := r.reported(choices, replayed) switch { case err == nil: return replayed, witness, nil, nil @@ -620,7 +638,7 @@ func (e externalEngine) replayExecution(ctx context.Context, model *Model, budge if err != nil { return nil, fmt.Errorf("does not read as a schedule (%v)", err), nil } - fresh := func() (*runtime.Context, error) { return model.NewContextOn(job, budget) } + fresh := func() (*runtime.Context, error) { return r.fresh(model, job, budget) } var fail error visit := func(replayed *runtime.Replayed, moves int) error { holds, why, err := holdsAt(r.ask, replayed) @@ -635,7 +653,7 @@ func (e externalEngine) replayExecution(ctx context.Context, model *Model, budge } rw := r.witness(choices) replayed, err := runtime.ReplayExecution(ctx, fresh, r.ask.Start, rw, visit) - witness := &Witness{Schedule: runtime.ReplayOf(rw), Inputs: r.inputs, Choices: choices} + witness := r.reported(choices, replayed) switch { case errors.Is(err, errVisitStopped): return witness, fail, nil diff --git a/internal/core/analysis/external_standing_test.go b/internal/exec/analysis/external_standing_test.go similarity index 88% rename from internal/core/analysis/external_standing_test.go rename to internal/exec/analysis/external_standing_test.go index 756e73b397..3b8e69ae8b 100644 --- a/internal/core/analysis/external_standing_test.go +++ b/internal/exec/analysis/external_standing_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // The schedules of the racing action's three outcomes, as check spells its witnesses: @@ -147,6 +147,86 @@ func TestExternalWitnessThatDoesNotReplay(t *testing.T) { notCovered(t, standinAnswers(t, unreadable, f.building(), q, Budget{}), "its witness does not read as a schedule") } +// waitingModel waits 2.3 s and reads the clock, so what it reads shows the clock the +// run was on: 2.3 on the continuous clock, 3.0 on one stepping by 1.0. +const waitingModel = ` +package test { + private import ScalarValues::*; + private import SI::*; + action wait { + out attribute t : Real; + first start; + then action w accept after 2.3 [s]; + then assign t := localClock.currentTime; + then done; + } +}` + +// The host replays an engine's schedule on the clock the question states: the same +// witness refutes a bound on the instant read under a step of 1.0 and holds it on +// the continuous clock. +func TestExternalScheduleReplaysOnTheQuestionsClockStep(t *testing.T) { + d := parseDrawingText(t, waitingModel) + start := d.symbol(t, "wait") + beforeHalfPastTwo := runtime.CheckProperty{Name: "t", Holds: func(_ *runtime.Context, inv *runtime.Invocation) (bool, error) { + return inv.Actions[0].Results()["t"].Const.Real <= 2.5, nil + }} + ask := &CheckAsk{Start: func(ctx *runtime.Context) (*runtime.Invocation, error) { + exec, err := ctx.CreateActionExecutor(start) + if err != nil { + return nil, err + } + return &runtime.Invocation{Actions: []*runtime.ActionExecutor{exec}}, nil + }, Properties: []runtime.CheckProperty{beforeHalfPastTwo}} + question := func(step float64) Question { + return Question{Kind: Holds, Subject: "test::wait", Schedule: policy(t, "explore"), Free: FreeSchedule, Check: ask, ClockStep: step} + } + answer := `{"claim":"violated","strength":"witnessed","witness":{"schedules":["no choice points"]}}` + result := standinAnswers(t, standinRegistry(t, answer, WitnessSchedule), d.building(), question(1.0), Budget{}) + if result.Claim != ClaimViolated || result.Strength != Witnessed || result.Witness == nil { + t.Fatalf("result %+v, want the violation witnessed on a clock stepping by 1.0", result) + } + if !strings.Contains(result.Reason, "`t` evaluates false") { + t.Fatalf("reason %q, want t read at the tick 3.0 refuting the claim", result.Reason) + } + notCovered(t, standinAnswers(t, standinRegistry(t, answer, WitnessSchedule), d.building(), question(0), Budget{}), "its schedule replays and `t` holds at its end") +} + +// An engine's schedule carries choices, never draws: under a fixed policy the host +// replays it resolving each call to the policy's point, and the witness it reports +// records those draws under that policy; under random, a drawing run has no replay. +func TestExternalScheduleReplaysUnderTheQuestionsDrawPolicy(t *testing.T) { + d := parseDrawing(t) + atMostHalf := runtime.CheckProperty{Name: "d", Holds: func(_ *runtime.Context, inv *runtime.Invocation) (bool, error) { + return inv.Actions[0].Results()["d"].Const.Real <= 0.5, nil + }} + ask := &CheckAsk{Start: d.start(t), Properties: []runtime.CheckProperty{atMostHalf}} + question := func(draws runtime.DrawPolicy) Question { + return Question{Kind: Holds, Subject: "test::draw", Schedule: policy(t, "explore"), Free: FreeSchedule, Check: ask, Draws: draws} + } + answer := `{"claim":"violated","strength":"witnessed","witness":{"schedules":["no choice points"]}}` + result := standinAnswers(t, standinRegistry(t, answer, WitnessSchedule), d.building(), question(runtime.DrawMax), Budget{}) + if result.Claim != ClaimViolated || result.Strength != Witnessed || result.Witness == nil { + t.Fatalf("result %+v, want the violation witnessed under max", result) + } + if !strings.Contains(result.Reason, "`d` evaluates false") { + t.Fatalf("reason %q, want d at its max refuting the claim", result.Reason) + } + replay, ok := result.Witness.Schedule.Replay() + if !ok || len(replay) != 0 { + t.Fatalf("witness schedule %s, want a replay of no choice", result.Witness.Schedule) + } + w, _ := result.Witness.Schedule.Witness() + if w.DrawPolicy != runtime.DrawMax || len(w.Draws) != 1 || w.Draws[0].Value.Real != 1 || len(result.Witness.Draws) != 1 { + t.Fatalf("witness %s, want the one draw recorded at its max", w.String()) + } + if got := result.Standing(); !strings.Contains(got, "witness of 1 draw replayed") { + t.Errorf("standing %q, want the draw counted", got) + } + notCovered(t, standinAnswers(t, standinRegistry(t, answer, WitnessSchedule), d.building(), question(runtime.DrawMin), Budget{}), "its schedule replays and `d` holds at its end") + notCovered(t, standinAnswers(t, standinRegistry(t, answer, WitnessSchedule), d.building(), question(runtime.DrawRandom), Budget{}), "its witness does not replay", "records no draw left for it") +} + // A witness of the wrong kind or count is a protocol break, the run not covered. func TestExternalWitnessShapeIsChecked(t *testing.T) { f := parseFixture(t) diff --git a/internal/exec/analysis/isolation_test.go b/internal/exec/analysis/isolation_test.go new file mode 100644 index 0000000000..2b142109eb --- /dev/null +++ b/internal/exec/analysis/isolation_test.go @@ -0,0 +1,291 @@ +package analysis + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// workers records every worker a fixture built for a model, and how many times it was asked. +type workers struct { + mu sync.Mutex + built []*Worker + asked int +} + +// recording is the fixture's building model with every worker it makes recorded. +func (f *fixture) recording(w *workers) *Model { + return &Model{ + Semantics: func() (*runtime.Model, error) { + w.mu.Lock() + w.asked++ + w.mu.Unlock() + return f.semantics() + }, + Fresh: func(worker *Worker) (*runtime.Context, error) { + w.mu.Lock() + w.built = append(w.built, worker) + w.mu.Unlock() + return f.fresh(worker) + }, + } +} + +// distinct is how many distinct resolvers and semantic models the runs were made on. +func (w *workers) distinct() (resolvers, models int) { + seenR := make(map[*resolve.Resolver]bool) + seenM := make(map[*semantics.Model]bool) + for _, worker := range w.built { + seenR[worker.Model.Resolver()] = true + seenM[worker.Model.Semantics()] = true + } + return len(seenR), len(seenM) +} + +// Two plans on one model, on two goroutines, each build a worker of their own and make every +// run on it; the model handed in is never written. Run under -race, this is the isolation test. +func TestPlansOnOneModelHaveWorkersOfTheirOwn(t *testing.T) { + f := parseFixture(t) + w := &workers{} + model := f.recording(w) + q := Question{Kind: Outcomes, Subject: "test::race", Schedule: policy(t, "explore"), Free: FreeSchedule, Linearize: raceRun(t, f)} + plans := make([]Plan, 2) + results := make([]Result, 2) + errs := make([]error, 2) + var wg sync.WaitGroup + for i := range results { + wg.Add(1) + go func(i int) { + defer wg.Done() + plan, err := Default().Answer(context.Background(), model, q, Budget{}) + plans[i], results[i], errs[i] = plan, plan.Result, err + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("plan %d: %v", i, err) + } + x := results[i].Exploration() + if x == nil || !x.Complete() || len(x.Outcomes) != 3 { + t.Fatalf("plan %d explored %+v, want the 3 outcomes", i, x) + } + if results[i].Workers != 1 { + t.Fatalf("plan %d built %d workers, want one", i, results[i].Workers) + } + if plans[i].Workers != 1 || plans[i].Warming != results[i].Warming { + t.Fatalf("plan %d reports %d workers warming %s, want the result's one warming %s", i, plans[i].Workers, plans[i].Warming, results[i].Warming) + } + } + resolvers, models := w.distinct() + if w.asked != 2 || resolvers != 2 || models != 2 || len(w.built) != 12 { + t.Fatalf("%d workers built, %d resolvers and %d semantic models over %d runs; want 2, 2, 2 and 12", w.asked, resolvers, models, len(w.built)) + } + if model.workers != nil { + t.Fatal("the caller's model was given a worker, want the plan's copy to hold it") + } +} + +// Two sweeps on one model, on two goroutines, each build workers of their own — one per job +// that got a row — and a context per row, and table the rows in plan order; the model +// handed in is never written. Run under -race, this is the isolation test for sweep. +func TestSweepsOnOneModelHaveWorkersOfTheirOwn(t *testing.T) { + f := parseFixture(t) + w := &workers{} + model := f.recording(w) + ctx := f.context(t) + q := Question{Kind: Sweep, Subject: "test::Double", Schedule: ctx.Schedule(), Sweep: &SweepAsk{Plan: doublePlan(t, f, ctx), Row: doubleRow(t, f)}} + plans := make([]Plan, 2) + errs := make([]error, 2) + var wg sync.WaitGroup + for i := range plans { + wg.Add(1) + go func(i int) { + defer wg.Done() + plans[i], errs[i] = Default().Answer(context.Background(), model, q, Budget{Jobs: 3}) + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("plan %d: %v", i, err) + } + table := plans[i].Result.Table() + if len(table.Rows) != 3 { + t.Fatalf("plan %d tabled %d rows, want 3", i, len(table.Rows)) + } + for j, row := range table.Rows { + if row.Err != nil || row.Outputs[0].Value.Const.Int != int64(2*(j+1)) { + t.Fatalf("plan %d row %d %+v, want result = %d in plan order", i, j, row, 2*(j+1)) + } + if row.Context == nil || row.Context == ctx { + t.Fatalf("plan %d row %d ran in %p, want a context of its own", i, j, row.Context) + } + } + if built := plans[i].Result.Workers; built < 1 || built > 3 || plans[i].Workers != built { + t.Fatalf("plan %d reports %d workers over %d, want one to three, one per job that got a row", i, plans[i].Workers, built) + } + } + resolvers, models := w.distinct() + if want := plans[0].Result.Workers + plans[1].Result.Workers; w.asked != want || resolvers != want || models != want || len(w.built) != 6 { + t.Fatalf("%d models asked, %d resolvers and %d semantic models over %d contexts; want %d, %d, %d and one context per row, 6", w.asked, resolvers, models, len(w.built), want, want, want) + } + if model.workers != nil { + t.Fatal("the caller's model was given a worker, want the plan's copy to hold it") + } +} + +// A run-owned context takes Steps and Memory from the budget at construction, as the +// evaluation-step and element limits alone; a zero field, and every other bound, is what +// the surface built. +func TestRunOwnedContextTakesTheBudgetAtConstruction(t *testing.T) { + f := parseFixture(t) + surface := f.context(t).Budgets() + var seen runtime.Budgets + q := Question{Kind: Evaluate, Subject: "test::Double", Schedule: runtime.DefaultSchedulePolicy, + Perform: func(rctx *runtime.Context) (Answer, error) { + seen = rctx.Budgets() + return ValuesAnswer(nil, nil), nil + }, + } + want := surface + want.MaxSteps, want.MaxElements = 7, 9 + result := answered(t, Default(), f.building(), q, Budget{Steps: 7, Memory: 9}).Result + if seen != want { + t.Fatalf("run's limits %+v, want %+v: the budget's steps and memory, the rest the surface's", seen, want) + } + if steps, ok := result.Bounds.Limit("steps"); !ok || steps != 7 { + t.Fatalf("bounds %s, want steps=7", result.Bounds) + } + if result.Workers != 1 { + t.Fatalf("workers %d, want the one the plan built", result.Workers) + } + + result = answered(t, Default(), f.building(), q, Budget{}).Result + if seen != surface { + t.Fatalf("run's limits under a zero budget %+v, want the surface's %+v", seen, surface) + } + if steps, ok := result.Bounds.Limit("steps"); !ok || steps != fixtureSteps { + t.Fatalf("bounds %s, want steps=%d", result.Bounds, fixtureSteps) + } +} + +// Every run of an exploration is a context of its own under the budget. +func TestExploreBuildsEachRunUnderTheBudget(t *testing.T) { + f := parseFixture(t) + surface := f.context(t).Budgets() + var mu sync.Mutex + var seen []runtime.Budgets + run := raceRun(t, f) + q := Question{Kind: Outcomes, Subject: "test::race", Schedule: policy(t, "explore"), Free: FreeSchedule, + Linearize: func(rctx *runtime.Context) (runtime.Outcome, error) { + mu.Lock() + seen = append(seen, rctx.Budgets()) + mu.Unlock() + return run(rctx) + }, + } + want := surface + want.MaxSteps, want.MaxElements = 7, 9 + result := answered(t, Default(), f.building(), q, Budget{Steps: 7, Memory: 9}).Result + if len(seen) != 6 || result.Workers != 1 { + t.Fatalf("%d runs on %d workers, want 6 on one", len(seen), result.Workers) + } + for i, limits := range seen { + if limits != want { + t.Fatalf("run %d's limits %+v, want %+v", i, limits, want) + } + } + seen = nil + answered(t, Default(), f.building(), q, Budget{}) + for i, limits := range seen { + if limits != surface { + t.Fatalf("run %d's limits under a zero budget %+v, want the surface's %+v", i, limits, surface) + } + } +} + +// The surface's own context is not a run's: its limits stand whatever the budget says, +// and no worker is built to run in it. Sweep rows never run there: each is a context of +// its own under the budget on the plan's worker, and solve builds none. +func TestTheSurfacesContextKeepsItsLimits(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + before := ctx.Budgets() + q := Question{Kind: Evaluate, Subject: "test::Double", Schedule: runtime.DefaultSchedulePolicy, + Perform: func(rctx *runtime.Context) (Answer, error) { + if rctx != ctx { + t.Fatal("the run was not in the surface's context") + } + return ValuesAnswer(nil, nil), nil + }, + } + result := answered(t, Default(), Held(ctx), q, Budget{Steps: 7, Memory: 9}).Result + if ctx.Budgets() != before { + t.Fatalf("the surface's limits %+v became %+v", before, ctx.Budgets()) + } + if result.Workers != 0 || result.Warming != 0 { + t.Fatalf("workers %d warming %s, want none for a run in the surface's context", result.Workers, result.Warming) + } + + var mu sync.Mutex + var rows []runtime.Budgets + double := doubleRow(t, f) + sweep := Question{Kind: Sweep, Subject: "test::Double", Schedule: ctx.Schedule(), Sweep: &SweepAsk{Plan: doublePlan(t, f, ctx), + Row: func(rctx *runtime.Context, bindings []runtime.SweepBinding) (runtime.SweepRunResult, error) { + if rctx == ctx { + t.Error("a sweep row ran in the surface's context") + } + mu.Lock() + rows = append(rows, rctx.Budgets()) + mu.Unlock() + return double(rctx, bindings) + }}} + if _, err := Default().Answer(context.Background(), Held(ctx), sweep, Budget{Steps: 7, Memory: 9}); !errors.Is(err, ErrNoRuntime) { + t.Fatalf("sweep on a held context alone: %v, want ErrNoRuntime", err) + } + result = answered(t, Default(), f.building(), sweep, Budget{Steps: 7, Memory: 9, Jobs: 2}).Result + want := before + want.MaxSteps, want.MaxElements = 7, 9 + if ctx.Budgets() != before || result.Workers < 1 || result.Workers > 2 || len(rows) != 3 { + t.Fatalf("after a sweep, limits %+v, %d workers and %d rows; want %+v, one or two and 3", ctx.Budgets(), result.Workers, len(rows), before) + } + for i, limits := range rows { + if limits != want { + t.Fatalf("row %d's limits %+v, want %+v", i, limits, want) + } + } + + requireSolver(t) + if result = satisfiable(t, intQuery("C", 5, 2)).Result; result.Workers != 0 || result.Warming != 0 { + t.Fatalf("solve built %d workers warming %s, want none", result.Workers, result.Warming) + } +} + +// An engine needing a context the model does not build faults with a typed error. +func TestARunNeedsAModelThatBuildsItsContext(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + evaluate := Question{Kind: Evaluate, Subject: "test::Double", Schedule: runtime.DefaultSchedulePolicy, + Perform: func(*runtime.Context) (Answer, error) { return ValuesAnswer(nil, nil), nil }} + if _, err := Default().Answer(context.Background(), nil, evaluate, Budget{}); !errors.Is(err, ErrNoRuntime) { + t.Fatalf("run on no model: %v, want ErrNoRuntime", err) + } + outcomes := Question{Kind: Outcomes, Subject: "test::race", Schedule: policy(t, "explore"), Free: FreeSchedule, Linearize: raceRun(t, f)} + if _, err := Default().Answer(context.Background(), Held(ctx), outcomes, Budget{}); !errors.Is(err, ErrNoRuntime) { + t.Fatalf("explore on a held context alone: %v, want ErrNoRuntime", err) + } + sweep := Question{Kind: Sweep, Subject: "test::Double", Schedule: ctx.Schedule(), Sweep: &SweepAsk{Plan: doublePlan(t, f, ctx), Row: doubleRow(t, f)}} + if _, err := Default().Answer(context.Background(), nil, sweep, Budget{}); !errors.Is(err, ErrNoRuntime) { + t.Fatalf("sweep on no model: %v, want ErrNoRuntime", err) + } + var typed *NoRuntimeError + if _, err := (&Model{}).Worker(); !errors.Is(err, ErrNoRuntime) || errors.As(err, &typed) { + t.Fatalf("worker of a model building none: %v, want the bare ErrNoRuntime", err) + } +} diff --git a/internal/core/analysis/jobs.go b/internal/exec/analysis/jobs.go similarity index 100% rename from internal/core/analysis/jobs.go rename to internal/exec/analysis/jobs.go diff --git a/internal/core/analysis/jobs_test.go b/internal/exec/analysis/jobs_test.go similarity index 100% rename from internal/core/analysis/jobs_test.go rename to internal/exec/analysis/jobs_test.go diff --git a/internal/core/analysis/listing.go b/internal/exec/analysis/listing.go similarity index 100% rename from internal/core/analysis/listing.go rename to internal/exec/analysis/listing.go diff --git a/internal/core/analysis/listing_test.go b/internal/exec/analysis/listing_test.go similarity index 100% rename from internal/core/analysis/listing_test.go rename to internal/exec/analysis/listing_test.go diff --git a/internal/core/analysis/manifest.go b/internal/exec/analysis/manifest.go similarity index 99% rename from internal/core/analysis/manifest.go rename to internal/exec/analysis/manifest.go index 4b5aa2c34d..f225ae0034 100644 --- a/internal/core/analysis/manifest.go +++ b/internal/exec/analysis/manifest.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // ToolsEnv names the environment variable holding the tool manifest: a directory with one diff --git a/internal/exec/analysis/model_seed_test.go b/internal/exec/analysis/model_seed_test.go new file mode 100644 index 0000000000..68e257d95f --- /dev/null +++ b/internal/exec/analysis/model_seed_test.go @@ -0,0 +1,278 @@ +package analysis + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +// drawingModel draws a Real as its action starts, so any run of it needs a model seed. +const drawingModel = ` +package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + out attribute d : Real = uniform(0.0, 1.0); + first start; then done; + } + calc def Twice { in x : Real; return : Real = x * uniform(1.0, 1.0); } +}` + +// drawing is drawingModel indexed over the bundled libraries, built as a surface holding +// no context supplies a model, its source registered as a surface registers the files it read. +type drawing struct { + idx *symbols.Index + pkg *symbols.Scope + source *source.SourceFile +} + +func parseDrawing(t *testing.T) *drawing { + t.Helper() + return parseDrawingText(t, drawingModel) +} + +// parseDrawingText is parseDrawing over another model text with a `test` package. +func parseDrawingText(t *testing.T, text string) *drawing { + t.Helper() + path := filepath.Join(t.TempDir(), "drawing.sysml") + sf := source.New(path, []byte(text)) + p := parser.New(sf) + file := p.ParseFile() + if len(p.Diagnostics) > 0 { + t.Fatalf("parse: %v", p.Diagnostics) + } + idx := libs.NewModelIndex() + idx.AddDocument(path, file) + idx.ExpandWildcardImports() + pkg, ok := idx.DocumentRoot(path).LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + return &drawing{idx: idx, pkg: pkg.Scope, source: sf} +} + +func (d *drawing) semantics() (*runtime.Model, error) { + resolver := resolve.New(d.idx) + model := runtime.NewModel(passes.NewTypedModel(resolver), resolver) + model.SetExpressionParser(parser.ParseOneExpression) + model.RegisterSource(d.source) + return model, nil +} + +func (d *drawing) fresh(w *Worker) (*runtime.Context, error) { + return runtime.NewContext(w.Model, fixtureSteps), nil +} + +func (d *drawing) building() *Model { return &Model{Semantics: d.semantics, Fresh: d.fresh} } + +func (d *drawing) symbol(t *testing.T, name string) *symbols.Symbol { + t.Helper() + sym, ok := d.pkg.LookupLocal(name) + if !ok { + t.Fatalf("%s not indexed", name) + } + return sym +} + +// run is one run of the drawing action, its outcome the value drawn. +func (d *drawing) run(t *testing.T) Linearization { + t.Helper() + draw := d.symbol(t, "draw") + return func(ctx *runtime.Context) (runtime.Outcome, error) { + outputs, err := ctx.ExecuteAction(draw) + if err != nil { + return runtime.Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } +} + +// start starts the drawing action as a check does. +func (d *drawing) start(t *testing.T) func(*runtime.Context) (*runtime.Invocation, error) { + t.Helper() + draw := d.symbol(t, "draw") + return func(ctx *runtime.Context) (*runtime.Invocation, error) { + exec, err := ctx.CreateActionExecutor(draw) + if err != nil { + return nil, err + } + return &runtime.Invocation{Actions: []*runtime.ActionExecutor{exec}}, nil + } +} + +// seeded is the request for subject under schedule with the model seed, when one is given. +func (d *drawing) seeded(subject string, schedule runtime.SchedulePolicy, seed ...uint64) Request { + req := request(d.building(), subject, schedule) + if len(seed) > 0 { + req.ModelSeed = ModelSeed{Seed: seed[0], Set: true} + } + return req +} + +// drawn is the drawing action's output d of one performance through the registry. +func (d *drawing) drawn(t *testing.T, req Request) (runtime.Value, error) { + t.Helper() + draw := d.symbol(t, "draw") + out, _, err := Perform(context.Background(), Default(), req, + func(ctx *runtime.Context) (map[string]runtime.Value, error) { return ctx.ExecuteAction(draw) }, + func(out map[string]runtime.Value, err error) Answer { + if err != nil { + return Answer{Err: err} + } + return Answer{Claim: ClaimValue, Values: ValuesOf(out)} + }) + if err != nil { + return runtime.Value{}, err + } + return out["d"], nil +} + +// A request's model seed reaches the context the run engine builds: the same seed draws +// the same value under either schedule, another seed another value, and no seed the refusal. +func TestRunEngineDrawsFromTheRequestsModelSeed(t *testing.T) { + d := parseDrawing(t) + first, err := d.drawn(t, d.seeded("test::draw", runtime.DefaultSchedulePolicy, 7)) + if err != nil { + t.Fatalf("seeded run: %v", err) + } + reversed, err := d.drawn(t, d.seeded("test::draw", policy(t, "reverse"), 7)) + if err != nil { + t.Fatalf("seeded reverse run: %v", err) + } + if runtime.FormatValue(first) != runtime.FormatValue(reversed) { + t.Errorf("seed 7 drew %s then %s under another schedule, want the model seed independent of it", runtime.FormatValue(first), runtime.FormatValue(reversed)) + } + other, err := d.drawn(t, d.seeded("test::draw", runtime.DefaultSchedulePolicy, 8)) + if err != nil { + t.Fatalf("run under seed 8: %v", err) + } + if runtime.FormatValue(other) == runtime.FormatValue(first) { + t.Errorf("seeds 7 and 8 both drew %s, want distinct draws", runtime.FormatValue(first)) + } + if _, err := d.drawn(t, d.seeded("test::draw", runtime.DefaultSchedulePolicy)); !errors.Is(err, runtime.ErrUnseededDraw) { + t.Fatalf("unseeded run: %v, want ErrUnseededDraw", err) + } +} + +// The explorer and the checker seed every run of theirs from the request; without a seed +// a drawing run fails as unseeded, an outcome to the one and a violation to the other. +func TestExploreAndCheckDrawFromTheRequestsModelSeed(t *testing.T) { + d := parseDrawing(t) + plan, err := Default().Explore(context.Background(), d.seeded("test::draw", policy(t, "explore"), 7), d.run(t)) + if err != nil { + t.Fatalf("seeded explore: %v", err) + } + if x := plan.Result.Exploration(); x == nil || !x.Complete() || len(x.Outcomes) != 1 { + t.Fatalf("exploration %+v, want the one drawn outcome", plan.Result.Exploration()) + } + plan, err = Default().Explore(context.Background(), d.seeded("test::draw", policy(t, "explore")), d.run(t)) + if err != nil { + t.Fatalf("unseeded explore: %v", err) + } + if x := plan.Result.Exploration(); x == nil || len(x.Outcomes) != 1 || !errors.Is(x.Outcomes[0].Outcome.Err, runtime.ErrUnseededDraw) { + t.Fatalf("unseeded exploration %+v, want its one run refused as unseeded", plan.Result.Exploration()) + } + ask := &CheckAsk{Start: d.start(t)} + plan, err = Default().Check(context.Background(), d.seeded("test::draw", policy(t, "explore"), 7), Holds, FreeSchedule, ask, nil, d.run(t)) + if err != nil { + t.Fatalf("seeded check: %v", err) + } + if c := plan.Result.Check(); c == nil || c.Report == nil || c.Report.Verdict != runtime.CheckExhaustive || len(c.Violations) != 0 { + t.Fatalf("check %+v, want an exhaustive search without violation", plan.Result.Check()) + } + plan, err = Default().Check(context.Background(), d.seeded("test::draw", policy(t, "explore")), Holds, FreeSchedule, ask, nil, d.run(t)) + if err != nil { + t.Fatalf("unseeded check: %v", err) + } + c := plan.Result.Check() + if c == nil || c.Report == nil || c.Report.Verdict != runtime.CheckViolation || len(c.Report.Violations) != 1 || !errors.Is(c.Report.Violations[0].Err, runtime.ErrUnseededDraw) { + t.Fatalf("unseeded check %+v, want the one run's failure as the unseeded refusal", c) + } +} + +// A violation reached through draws alone is witnessed by them: the result's witness +// carries the draws where it has no choice, its standing counts them, and its +// schedule replays them. +func TestCheckWitnessCarriesTheDraws(t *testing.T) { + d := parseDrawing(t) + never := runtime.CheckProperty{Name: "never", Holds: func(*runtime.Context, *runtime.Invocation) (bool, error) { return false, nil }} + ask := &CheckAsk{Start: d.start(t), Properties: []runtime.CheckProperty{never}} + plan, err := Default().Check(context.Background(), d.seeded("test::draw", policy(t, "explore"), 7), Holds, FreeSchedule, ask, nil, d.run(t)) + if err != nil { + t.Fatalf("seeded check: %v", err) + } + result := plan.Result + if result.Claim != ClaimViolated || result.Witness == nil { + t.Fatalf("result %+v, want the violation witnessed", result) + } + w := result.Witness + if len(w.Draws) != 1 || len(w.Choices) != 0 { + t.Fatalf("witness draws %v choices %v, want the one draw and no choice", w.Draws, w.Choices) + } + if replay, ok := w.Schedule.Replay(); !ok || len(replay) != 0 { + t.Fatalf("witness schedule %s, want a replay of no choice", w.Schedule) + } + if got := result.Standing(); !strings.Contains(got, "witness of 1 draw replayed") { + t.Errorf("standing %q, want the draw counted", got) + } +} + +// The sweep engine seeds the rows' contexts from the request; a request without a seed +// leaves a drawing row refused as any unseeded run is. +func TestSweepDrawsFromTheRequestsModelSeed(t *testing.T) { + d := parseDrawing(t) + twice := d.symbol(t, "Twice") + model, err := d.semantics() + if err != nil { + t.Fatal(err) + } + ctx := runtime.NewContext(model, fixtureSteps) + plan := runtime.SweepPlan{Ranges: []runtime.SweepRange{{Param: "x", From: intOf(1), To: intOf(2)}}} + resolved, err := ctx.ResolveSweepPlan(twice, plan, 0, nil) + if err != nil { + t.Fatalf("resolve the plan: %v", err) + } + row := func(ctx *runtime.Context, bindings []runtime.SweepBinding) (runtime.SweepRunResult, error) { + bound := make(map[string]runtime.Value, len(bindings)) + for _, b := range bindings { + bound[b.Param] = b.Value + } + value, err := ctx.InvokeCalcWith(twice, nil, bound, d.pkg) + if err != nil { + return runtime.SweepRunResult{}, err + } + return runtime.SweepRunResult{Outputs: []runtime.CalcOutputValue{{Name: "result", Value: value}}}, nil + } + answered, err := Default().Sweep(context.Background(), d.seeded("test::Twice", ctx.Schedule(), 7), resolved, row) + if err != nil { + t.Fatalf("seeded sweep: %v", err) + } + table := answered.Result.Table() + if len(table.Rows) != 2 { + t.Fatalf("table %+v, want 2 rows", table) + } + for _, r := range table.Rows { + if r.Err != nil { + t.Errorf("row %v: %v, want it drawn under the request's seed", r.Bindings, r.Err) + } + } + answered, err = Default().Sweep(context.Background(), d.seeded("test::Twice", ctx.Schedule()), resolved, row) + if err != nil { + t.Fatalf("unseeded sweep: %v", err) + } + for _, r := range answered.Result.Table().Rows { + if !errors.Is(r.Err, runtime.ErrUnseededDraw) { + t.Errorf("row %v: %v, want ErrUnseededDraw", r.Bindings, r.Err) + } + } +} diff --git a/internal/exec/analysis/modelform/doc.go b/internal/exec/analysis/modelform/doc.go new file mode 100644 index 0000000000..be20e62593 --- /dev/null +++ b/internal/exec/analysis/modelform/doc.go @@ -0,0 +1,2 @@ +// Package modelform is the forms of a model an external engine reads: the sources form and the versioned graphs:1 form of the lowered action and state graphs. +package modelform diff --git a/internal/core/export/graphs.go b/internal/exec/analysis/modelform/graphs.go similarity index 95% rename from internal/core/export/graphs.go rename to internal/exec/analysis/modelform/graphs.go index 2d87570b43..811d3cfcf2 100644 --- a/internal/core/export/graphs.go +++ b/internal/exec/analysis/modelform/graphs.go @@ -1,4 +1,4 @@ -package export +package modelform import ( "encoding/json" @@ -8,12 +8,12 @@ import ( "sort" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // GraphsVersion is the version of the `graphs:` form this build writes. It @@ -105,7 +105,7 @@ func (x *graphsExporter) lowerSubject(out *Graphs, sym *symbols.Symbol) error { x.lowered[symbols.FQNOf(sym)] = true switch sym.Kind { case symbols.SymbolActionDef, symbols.SymbolActionUsage: - graph, err := lower.ToActionGraph(sym.Decl, runtime.DeclScope(sym)) + graph, err := lower.ToActionGraphWith(sym.Decl, runtime.DeclScope(sym), x.model.Resolver()) if err != nil { return fmt.Errorf("%w: %s: %w", ErrGraphsSubject, symbols.FQNOf(sym), err) } @@ -148,7 +148,7 @@ func (x *graphsExporter) lowerPerformed(out *Graphs, sym *symbols.Symbol) error out.States = append(out.States, form) return nil } - graph, err := lower.ToActionGraph(sym.Decl, runtime.DeclScope(sym)) + graph, err := lower.ToActionGraphWith(sym.Decl, runtime.DeclScope(sym), x.model.Resolver()) if err != nil { out.Actions = append(out.Actions, &ActionForm{Name: symbols.FQNOf(sym), Kind: sym.Kind.String(), Error: err.Error()}) return nil diff --git a/internal/core/export/graphs_action.go b/internal/exec/analysis/modelform/graphs_action.go similarity index 93% rename from internal/core/export/graphs_action.go rename to internal/exec/analysis/modelform/graphs_action.go index de02ff1e7d..6d471579a4 100644 --- a/internal/core/export/graphs_action.go +++ b/internal/exec/analysis/modelform/graphs_action.go @@ -1,12 +1,12 @@ -package export +package modelform import ( "fmt" "sort" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // ActionForm is one lowered ActionGraph. Vertices are numbered by their position @@ -94,17 +94,21 @@ type ChannelForm struct { // EdgeForm is a control flow, guarded when Guard is present; Else marks the // branch of a decision written as `else`, taken when no guarded branch is. +// Probability is the weight a `@Probability` annotation gives a decision's branch. type EdgeForm struct { - Source int `json:"source"` - Target int `json:"target"` - Guard *ExprForm `json:"guard,omitempty"` - Else bool `json:"else,omitempty"` - Decl SpanForm `json:"decl"` + Source int `json:"source"` + Target int `json:"target"` + Guard *ExprForm `json:"guard,omitempty"` + Else bool `json:"else,omitempty"` + Probability *ExprForm `json:"probability,omitempty"` + Decl SpanForm `json:"decl"` } -// ObjectFlowForm is a data flow from a pin of Source to a pin of Target. +// ObjectFlowForm is a data flow from a pin of Source to a pin of Target. Kind is +// how it carries its values: "streaming" (a plain `flow`) or "succession". type ObjectFlowForm struct { Name string `json:"name,omitempty"` + Kind string `json:"kind"` Source int `json:"source"` SourcePin string `json:"sourcePin,omitempty"` Target int `json:"target"` @@ -150,6 +154,7 @@ type AcceptForm struct { Param string `json:"param,omitempty"` SignalType string `json:"signalType,omitempty"` ViaPort string `json:"viaPort,omitempty"` + ViaSelf bool `json:"viaSelf,omitempty"` SubsetsEvent *ExprForm `json:"subsetsEvent,omitempty"` Trigger *TriggerForm `json:"trigger,omitempty"` Scope string `json:"scope,omitempty"` @@ -228,9 +233,12 @@ type StatementForm struct { Target string `json:"target,omitempty"` TargetSymbol string `json:"targetSymbol,omitempty"` TargetPath bool `json:"targetPath,omitempty"` + TargetExpr *ExprForm `json:"targetExpr,omitempty"` Via bool `json:"via,omitempty"` + ViaSelf bool `json:"viaSelf,omitempty"` Receiver string `json:"receiver,omitempty"` ReceiverPath bool `json:"receiverPath,omitempty"` + ReceiverExpr *ExprForm `json:"receiverExpr,omitempty"` // assign, declare, declare usage Name string `json:"name,omitempty"` Chain *ChainForm `json:"chain,omitempty"` @@ -385,17 +393,22 @@ func (x *graphsExporter) actionGraph(graph *lower.ActionGraph) (*ActionForm, err form.Nodes = append(form.Nodes, nf) for _, edge := range graph.Edges[node] { decl, _ := edge.Decl.(*ast.ControlFlowEdge) - form.Edges = append(form.Edges, EdgeForm{ + ef := EdgeForm{ Source: ids.add(edge.Source), Target: ids.add(edge.Target), Guard: x.expr(scope, edge.Guard), Else: decl != nil && decl.IsElse, Decl: x.span(scope, edge.Decl), - }) + } + if edge.Probability != nil { + ef.Probability = x.expr(scope, edge.Probability.Expr) + } + form.Edges = append(form.Edges, ef) } for _, flow := range graph.DataFlows[node] { form.Flows = append(form.Flows, ObjectFlowForm{ Name: flow.Name, + Kind: flowKindForm(flow.Kind), Source: id, SourcePin: flow.SourcePin, Target: ids.add(flow.Target), @@ -549,6 +562,13 @@ func (x *graphsExporter) statement(enclosing *symbols.Scope, s lower.Statement) switch s := s.(type) { case lower.Send: scope := orScope(s.Scope, enclosing) + var targetExpr, receiverExpr *ExprForm + if s.Target == "" && s.TargetExpr != nil { + targetExpr = x.expr(scope, s.TargetExpr) + } + if s.Receiver == "" && s.ReceiverExpr != nil { + receiverExpr = x.expr(scope, s.ReceiverExpr) + } return StatementForm{ Kind: "send", Span: x.span(scope, s.Message), @@ -557,9 +577,12 @@ func (x *graphsExporter) statement(enclosing *symbols.Scope, s lower.Statement) Target: s.Target, TargetSymbol: symbols.FQNOf(s.TargetSym), TargetPath: s.TargetPath, + TargetExpr: targetExpr, Via: s.IsVia, + ViaSelf: s.ViaSelf, Receiver: s.Receiver, ReceiverPath: s.ReceiverPath, + ReceiverExpr: receiverExpr, }, nil case lower.Assign: scope := orScope(s.Scope, enclosing) @@ -687,6 +710,7 @@ func (x *graphsExporter) accept(enclosing *symbols.Scope, a lower.Accept) *Accep Param: a.ParamName, SignalType: qualifiedName(a.SignalType), ViaPort: a.ViaPort, + ViaSelf: a.ViaSelf, SubsetsEvent: x.expr(scope, a.SubsetsEvent), Trigger: x.trigger(scope, a.Trigger), Scope: scopeName(a.Scope), @@ -704,7 +728,11 @@ func (x *graphsExporter) trigger(scope *symbols.Scope, node ast.Node) *TriggerFo case *ast.AcceptEvent: form.Kind = "accept" form.SignalType = qualifiedName(t.SignalType) - form.Subsets = qualifiedName(t.Subsets) + if qn := ast.AsQualifiedName(t.Subsets); qn != nil { + form.Subsets = qualifiedName(qn) + } else { + form.Subsets = lower.FeaturePath(t.Subsets) + } if t.Payload != nil { form.Payload, _ = ast.EffectiveName(t.Payload) } @@ -831,3 +859,11 @@ func orScope(scope, enclosing *symbols.Scope) *symbols.Scope { } return enclosing } + +// flowKindForm spells a flow's kind in the form. +func flowKindForm(kind lower.FlowKind) string { + if kind == lower.FlowSuccession { + return "succession" + } + return "streaming" +} diff --git a/internal/core/export/graphs_state.go b/internal/exec/analysis/modelform/graphs_state.go similarity index 98% rename from internal/core/export/graphs_state.go rename to internal/exec/analysis/modelform/graphs_state.go index 570024fd2c..94f350b279 100644 --- a/internal/core/export/graphs_state.go +++ b/internal/exec/analysis/modelform/graphs_state.go @@ -1,11 +1,11 @@ -package export +package modelform import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // StateForm is one lowered StateGraph. Vertices — the machine, its states and diff --git a/internal/exec/analysis/modelform/graphs_test.go b/internal/exec/analysis/modelform/graphs_test.go new file mode 100644 index 0000000000..2b76071ea6 --- /dev/null +++ b/internal/exec/analysis/modelform/graphs_test.go @@ -0,0 +1,591 @@ +package modelform + +import ( + "bytes" + "errors" + "flag" + "maps" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +var update = flag.Bool("update", false, "rewrite the graphs goldens in testdata") + +// graphsFixture holds every shape the lowered graphs carry: a flow with a fork, +// a join, a guarded decision, a nested body performing another action, and a +// machine with orthogonal regions, entry/do/exit behaviors, a call-triggered +// guarded transition with an effect and a deferred trigger; and a flow whose +// decision weights its successions. +const graphsFixture = `package test { + private import ScalarValues::*; + private import Stochastic::*; + + action run { + attribute total : Integer = 0; + attribute doubled : Integer = 0; + attribute battery : Integer = 10; + + first start; + fork split; + action left { assign total := 1; } + action iterate { + for i in 1..3 { + perform Bump; + assign total := total + doubled; + } + } + join sync; + decide check; + action low { assign total := 7; } + action idle { assign total := 8; } + done; + + succession first start then split; + succession first split then left; + succession first split then iterate; + succession first left then sync; + succession first iterate then sync; + succession first sync then check; + if battery < 20 then low; + else idle; + succession first low then done; + succession first idle then done; + } + + action pick { + attribute taken : Integer = 0; + + first start; + decide route; + action fast { assign taken := 1; } + action slow { assign taken := 2; } + done; + + succession first start then route; + first route then fast { @Probability { p = 0.7; } } + first route then slow { @Probability { p = 0.3; } } + succession first fast then done; + succession first slow then done; + } + + action def Bump { + in i : Integer; + out doubled : Integer; + + first start; + action compute { assign doubled := i * 2; } + done; + succession first start then compute; + succession first compute then done; + } + + state Machine { + attribute log : Integer = 0; + + entry; then start; + state start; + state Outer parallel { + entry { assign log := log * 10 + 1; } + do action watch { assign log := log + 1; } + exit { assign log := log * 10 + 2; } + + state left { + entry; then lstart; + state lstart; + state lit; + succession first lstart then lit; + transition first lit accept wake() then lstart; + } + state right { + entry; then rstart; + state rstart; + state chiming; + succession first rstart then chiming; + } + } + state Done; + + succession first start then Outer; + transition first Outer accept setSpeed(value) if value > 0 do assign log := log * 10 + 9 then Done; + } +} +` + +// graphsModel parses the fixture beside the standard library into a fresh index +// and returns a runtime model over it with the document registered. +func graphsModel(t *testing.T) (*runtime.Model, *symbols.Index) { + t.Helper() + return graphsModelOf(t, graphsFixture) +} + +// graphsModelOf is graphsModel over the given fixture text. +func graphsModelOf(t *testing.T, fixture string) (*runtime.Model, *symbols.Index) { + t.Helper() + const path = "graphs.sysml" + sf := source.New(path, []byte(fixture)) + p := parser.New(sf) + file := p.ParseFile() + if len(p.Diagnostics) > 0 { + t.Fatalf("parse: %v", p.Diagnostics) + } + idx := symbols.NewIndex() + if err := libs.NewLoader(libs.DefaultSource(), nil).LoadAll(idx); err != nil { + t.Fatalf("load the library: %v", err) + } + idx.AddDocument(path, file) + resolver := resolve.New(idx) + model := runtime.NewModel(passes.NewTypedModel(resolver), resolver) + model.RegisterSource(sf) + return model, idx +} + +func lookupOne(t *testing.T, idx *symbols.Index, name string) *symbols.Symbol { + t.Helper() + matches := idx.LookupQualified(name) + if len(matches) != 1 { + t.Fatalf("%s matched %d symbols, want 1", name, len(matches)) + } + return matches[0] +} + +func exportGraphs(t *testing.T, model *runtime.Model, idx *symbols.Index, name string) (*Graphs, []byte) { + t.Helper() + g, err := GraphsOf(model, lookupOne(t, idx, name)) + if err != nil { + t.Fatalf("GraphsOf(%s): %v", name, err) + } + data, err := MarshalGraphs(g) + if err != nil { + t.Fatalf("MarshalGraphs: %v", err) + } + return g, data +} + +func TestGraphsMatchTheGoldens(t *testing.T) { + model, idx := graphsModel(t) + for _, subject := range []string{"test::run", "test::Machine"} { + t.Run(subject, func(t *testing.T) { + _, got := exportGraphs(t, model, idx, subject) + golden := filepath.Join("testdata", map[string]string{ + "test::run": "graphs_run.golden", + "test::Machine": "graphs_machine.golden", + }[subject]) + if *update { + if err := os.WriteFile(golden, got, 0o644); err != nil { + t.Fatalf("write %s: %v", golden, err) + } + return + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read %s: %v", golden, err) + } + if !bytes.Equal(got, want) { + t.Fatalf("graphs bytes differ from %s", golden) + } + }) + } +} + +func kinds(nodes []NodeForm) map[string]int { + out := map[string]int{} + for _, n := range nodes { + out[n.Kind]++ + } + return out +} + +func TestGraphsActionCarriesTheLoweredGraph(t *testing.T) { + model, idx := graphsModel(t) + g, data := exportGraphs(t, model, idx, "test::run") + if g.Version != GraphsVersion || g.Subject != "test::run" { + t.Fatalf("version %d subject %q, want %d test::run", g.Version, g.Subject, GraphsVersion) + } + if !bytes.HasPrefix(data, []byte(`{"version":1,`)) { + t.Fatalf("form does not start with its version: %.60s", data) + } + if len(g.Actions) != 2 || g.Actions[0].Name != "test::run" || g.Actions[1].Name != "test::Bump" { + names := make([]string, 0, len(g.Actions)) + for _, a := range g.Actions { + names = append(names, a.Name) + } + t.Fatalf("actions %v, want the subject then the action it performs", names) + } + if len(g.States) != 0 { + t.Fatalf("an action exported %d state graphs", len(g.States)) + } + run := g.Actions[0] + if len(run.Attributes) != 3 || run.Attributes[0].Name != "total" || run.Attributes[0].Value == nil { + t.Fatalf("attributes %+v, want total, doubled and battery with their defaults", run.Attributes) + } + if run.Initial == nil || len(run.Finals) != 1 { + t.Fatalf("initial %v finals %v, want a start and one done", run.Initial, run.Finals) + } + byKind := kinds(run.Nodes) + for _, k := range []string{"fork", "join", "decision", "start", "end"} { + if byKind[k] == 0 { + t.Errorf("no %s vertex among %v", k, byKind) + } + } + guarded, elses := 0, 0 + for _, e := range run.Edges { + if e.Guard != nil { + guarded++ + if e.Guard.Text != "battery < 20" { + t.Errorf("guard %q on edge %d->%d, want the decision's", e.Guard.Text, e.Source, e.Target) + } + } + if e.Else { + elses++ + } + } + if guarded != 1 || elses != 1 { + t.Errorf("%d guarded and %d else edges, want the decision's one of each", guarded, elses) + } + var iterate *NodeForm + for i := range run.Nodes { + if run.Nodes[i].Name == "iterate" { + iterate = &run.Nodes[i] + } + } + if iterate == nil || len(iterate.Body) != 1 || iterate.Body[0].Kind != "loop" || iterate.Body[0].Body == nil { + t.Fatalf("iterate node %+v, want a body of one loop statement", iterate) + } + loop := iterate.Body[0].Body + if loop.Graph == nil || len(loop.Statements) != 0 { + t.Fatalf("loop body %+v, want the flow a body performing an action states", loop) + } + performs, assigns := 0, 0 + for _, n := range loop.Graph.Nodes { + if len(n.Performs) == 1 && n.Performs[0] == "test::Bump" { + performs++ + } + if n.Kind == "assignment" { + assigns++ + } + } + if performs != 1 || assigns != 1 { + t.Fatalf("loop flow %+v, want a perform of test::Bump and an assignment", loop.Graph.Nodes) + } + bump := g.Actions[1] + if len(bump.Nodes) == 0 { + t.Fatalf("Bump exported no vertices") + } + var params []string + for _, p := range bump.Parameters { + params = append(params, p.Direction+" "+p.Name+" : "+strings.Join(p.Types, ",")) + } + want := []string{"in i : ScalarValues::Integer", "out doubled : ScalarValues::Integer"} + if !reflect.DeepEqual(params, want) { + t.Errorf("Bump's parameters %v, want %v", params, want) + } + if !strings.Contains(string(data), `"parameters":[{"name":"i","direction":"in"`) { + t.Errorf("parameters are not written in the form: %s", data) + } + assertFootprints(t, run) +} + +// assertFootprints pins that the flow's moves carry the lowering's footprint: the +// vertex writing `total` says so, and the vertices before the join converge on it. +func assertFootprints(t *testing.T, run *ActionForm) { + t.Helper() + join := -1 + for _, n := range run.Nodes { + if n.Kind == "join" { + join = n.ID + } + } + writes := func(name string) *NodeForm { + for i := range run.Nodes { + if run.Nodes[i].Name == name { + return &run.Nodes[i] + } + } + t.Fatalf("no vertex named %s", name) + return nil + } + left := writes("left") + if left.Footprint == nil { + t.Fatalf("left carries no footprint") + } + if len(left.Footprint.Writes) != 1 || left.Footprint.Writes[0].Name != "total" || left.Footprint.Writes[0].Symbol != "test::run::total" { + t.Errorf("left writes %+v, want total resolved to test::run::total", left.Footprint.Writes) + } + converging := 0 + for _, n := range run.Nodes { + if n.Footprint == nil { + continue + } + for _, c := range n.Footprint.Control { + if c == join { + converging++ + } + } + } + if converging == 0 { + t.Errorf("no vertex converges on the join %d", join) + } +} + +func TestGraphsStateCarriesTheLoweredGraph(t *testing.T) { + model, idx := graphsModel(t) + g, data := exportGraphs(t, model, idx, "test::Machine") + if len(g.States) != 1 || g.States[0].Name != "test::Machine" || len(g.Actions) != 0 { + t.Fatalf("exported %d state and %d action graphs, want the machine alone", len(g.States), len(g.Actions)) + } + m := g.States[0] + if m.Machine == nil || len(m.Attributes) != 1 || m.Attributes[0].Name != "log" { + t.Fatalf("machine %v attributes %+v", m.Machine, m.Attributes) + } + var outer *StateVertexForm + hidden := 0 + for i := range m.Vertices { + v := &m.Vertices[i] + if v.Name == "Outer" { + outer = v + } + if v.Hidden { + hidden++ + if v.HiddenRegion == nil { + t.Errorf("hidden vertex %d names no region", v.ID) + } + } + } + if outer == nil { + t.Fatal("no Outer vertex") + } + if len(outer.Regions) != 2 { + t.Errorf("Outer has %d regions, want its two orthogonal ones", len(outer.Regions)) + } + if hidden != 2 { + t.Errorf("%d hidden region states, want one per region of Outer", hidden) + } + if len(outer.Entry) != 1 || len(outer.Do) != 1 || len(outer.Exit) != 1 { + t.Errorf("Outer entry %d do %d exit %d behaviors, want one each", len(outer.Entry), len(outer.Do), len(outer.Exit)) + } + if len(outer.Do) == 1 && (outer.Do[0].Name != "watch" || len(outer.Do[0].Body) != 1) { + t.Errorf("do behavior %+v, want watch with one statement", outer.Do[0]) + } + for _, r := range m.Regions { + if r.Owner == nil || *r.Owner != outer.ID { + t.Errorf("region %d owned by %v, want Outer", r.ID, r.Owner) + } + if r.State == nil || r.Initial == nil { + t.Errorf("region %d lacks its state %v or initial %v", r.ID, r.State, r.Initial) + } + } + var setSpeed *TransitionForm + triggered := 0 + for i := range m.Transitions { + tr := &m.Transitions[i] + if tr.Trigger != nil { + triggered++ + } + if tr.Source == outer.ID { + setSpeed = tr + } + } + if triggered != 2 { + t.Errorf("%d triggered transitions, want wake and setSpeed", triggered) + } + if setSpeed == nil || setSpeed.Trigger == nil || setSpeed.Guard == nil || len(setSpeed.Effect) != 1 { + t.Fatalf("Outer's transition %+v, want trigger, guard and effect", setSpeed) + } + if setSpeed.Trigger.Kind != "call" || setSpeed.Trigger.Operation != "setSpeed" || len(setSpeed.Trigger.Parameters) != 1 { + t.Errorf("trigger %+v, want the call setSpeed(value)", setSpeed.Trigger) + } + if setSpeed.Guard.Text != "value > 0" || setSpeed.BodyScope == "" || setSpeed.BodyScope == setSpeed.Scope { + t.Errorf("guard %+v in body scope %q, want `value > 0` resolving in the trigger's scope", setSpeed.Guard, setSpeed.BodyScope) + } + if len(setSpeed.Effect[0].Body) != 1 || setSpeed.Effect[0].Body[0].Kind != "assign" { + t.Errorf("effect %+v, want one assignment", setSpeed.Effect[0]) + } + if len(m.EntryTransitions) < 3 { + t.Errorf("%d entry transitions, want the machine's and each region's", len(m.EntryTransitions)) + } + if !strings.Contains(string(data), `"kind":"call"`) { + t.Errorf("call trigger missing from the JSON form") + } +} + +func TestGraphsRefusesSubjectsWithoutAGraph(t *testing.T) { + model, idx := graphsModel(t) + sym := lookupOne(t, idx, "test") + if _, err := GraphsOf(model, sym); !errors.Is(err, ErrGraphsSubject) { + t.Fatalf("GraphsOf(package) = %v, want ErrGraphsSubject", err) + } + if _, err := GraphsOf(nil, sym); !errors.Is(err, ErrGraphsSubject) { + t.Fatalf("GraphsOf(nil model) = %v, want ErrGraphsSubject", err) + } +} + +// The form is the same bytes on every export: repeated over one model, over +// fresh models of the same text, and from eight goroutines at once. +// A weighted succession's probability expression is exported with its edge, so an +// engine reading the graph sees the weights the run draws by. +func TestGraphsActionCarriesTheEdgeProbabilities(t *testing.T) { + model, idx := graphsModel(t) + g, _ := exportGraphs(t, model, idx, "test::pick") + if len(g.Actions) != 1 || g.Actions[0].Error != "" { + t.Fatalf("actions %+v, want the subject lowered", g.Actions) + } + pick := g.Actions[0] + weights := map[string]string{} + for _, e := range pick.Edges { + if e.Probability == nil { + continue + } + if e.Guard != nil || e.Else { + t.Errorf("edge %d->%d is weighted and guarded: %+v", e.Source, e.Target, e) + } + weights[pick.Nodes[e.Target].Name] = e.Probability.Text + } + if want := map[string]string{"fast": "0.7", "slow": "0.3"}; !maps.Equal(weights, want) { + t.Errorf("weighted edges %v, want %v", weights, want) + } + run, _ := exportGraphs(t, model, idx, "test::run") + for _, a := range run.Actions { + for _, e := range a.Edges { + if e.Probability != nil { + t.Errorf("%s: edge %d->%d carries a probability the model does not state", a.Name, e.Source, e.Target) + } + } + } +} + +// A flow is exported with its kind, so an engine tells a streaming `flow` from a +// `succession flow` the way the runtime does. +func TestGraphsActionCarriesTheFlowKind(t *testing.T) { + model, idx := graphsModelOf(t, `package test { + private import ScalarValues::*; + + action stream { + action producer { out value : Integer; assign value := 1; } + action consumer { in value : Integer; } + action last { in value : Integer; } + + succession first start then producer; + succession first producer then consumer; + succession first consumer then last; + succession first last then done; + + flow producer.value to consumer.value; + succession flow producer.value to last.value; + } +} +`) + g, data := exportGraphs(t, model, idx, "test::stream") + if len(g.Actions) != 1 || g.Actions[0].Error != "" { + t.Fatalf("actions %+v, want the subject lowered", g.Actions) + } + stream := g.Actions[0] + kinds := map[string]string{} + for _, f := range stream.Flows { + kinds[stream.Nodes[f.Target].Name] = f.Kind + } + if want := map[string]string{"consumer": "streaming", "last": "succession"}; !maps.Equal(kinds, want) { + t.Errorf("flow kinds %v, want %v", kinds, want) + } + for _, want := range []string{`"kind":"streaming"`, `"kind":"succession"`} { + if !strings.Contains(string(data), want) { + t.Errorf("marshalled form lacks %s", want) + } + } +} + +func TestGraphsAreByteStable(t *testing.T) { + model, idx := graphsModel(t) + for _, subject := range []string{"test::run", "test::Machine"} { + _, want := exportGraphs(t, model, idx, subject) + for i := 0; i < 3; i++ { + if _, got := exportGraphs(t, model, idx, subject); !bytes.Equal(got, want) { + t.Fatalf("%s: export %d differs from the first", subject, i) + } + } + fresh, freshIdx := graphsModel(t) + if _, got := exportGraphs(t, fresh, freshIdx, subject); !bytes.Equal(got, want) { + t.Fatalf("%s: a fresh model exports different bytes", subject) + } + const jobs = 8 + results := make([][]byte, jobs) + errs := make([]error, jobs) + var wg sync.WaitGroup + for j := 0; j < jobs; j++ { + wg.Add(1) + go func(j int) { + defer wg.Done() + resolver := resolve.New(idx) + m := runtime.NewModel(passes.NewTypedModel(resolver), resolver) + for _, sf := range model.Sources() { + m.RegisterSource(sf) + } + g, err := GraphsOf(m, lookupOne(t, idx, subject)) + if err != nil { + errs[j] = err + return + } + results[j], errs[j] = MarshalGraphs(g) + }(j) + } + wg.Wait() + for j := 0; j < jobs; j++ { + if errs[j] != nil { + t.Fatalf("%s: job %d: %v", subject, j, errs[j]) + } + if !bytes.Equal(results[j], want) { + t.Fatalf("%s: job %d exported different bytes", subject, j) + } + } + } +} + +func TestSourcesOfListsEveryDocumentInOrder(t *testing.T) { + model, _ := graphsModel(t) + model.RegisterSource(source.New("aux.sysml", []byte("package aux;"))) + s, err := SourcesOf(model) + if err != nil { + t.Fatal(err) + } + if len(s.Documents) != 2 || s.Documents[0].Path != "aux.sysml" || s.Documents[1].Path != "graphs.sysml" { + t.Fatalf("documents %+v, want aux.sysml then graphs.sysml", s.Documents) + } + if s.Documents[1].Text != graphsFixture || s.Documents[0].Text != "package aux;" { + t.Error("document text is not the registered text") + } + if !strings.HasPrefix(s.Library, "sha256:") || s.Library != libs.Version() { + t.Errorf("library %q, want the sha256 set digest libs.Version reports", s.Library) + } + again, err := SourcesOf(model) + if err != nil || again.Library != s.Library { + t.Errorf("a second export reports library %q (%v), want %q", again.Library, err, s.Library) + } + if _, err := SourcesOf(nil); !errors.Is(err, ErrNoSources) { + t.Errorf("SourcesOf(nil) = %v, want ErrNoSources", err) + } + if _, err := SourcesOf(runtime.NewModel(passes.NewTypedModel(nil), nil)); !errors.Is(err, ErrNoSources) { + t.Errorf("SourcesOf(empty) = %v, want ErrNoSources", err) + } +} + +func TestRefuseRDFFormIsTyped(t *testing.T) { + err := RefuseRDFForm() + var typed *FormUnsupportedError + if !errors.Is(err, ErrFormUnsupported) || !errors.As(err, &typed) || typed.Form != "rdf" { + t.Fatalf("RefuseRDFForm() = %v, want a FormUnsupportedError for rdf", err) + } + if !strings.Contains(err.Error(), "graphs:1") { + t.Errorf("refusal %q does not name the forms that are served", err) + } +} diff --git a/internal/core/export/sources.go b/internal/exec/analysis/modelform/sources.go similarity index 94% rename from internal/core/export/sources.go rename to internal/exec/analysis/modelform/sources.go index a3e1b10842..5a6182f28e 100644 --- a/internal/core/export/sources.go +++ b/internal/exec/analysis/modelform/sources.go @@ -1,11 +1,11 @@ -package export +package modelform import ( "errors" "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // Sources is the `sources` model form: every document of the model as text, in diff --git a/internal/exec/analysis/modelform/testdata/graphs_machine.golden b/internal/exec/analysis/modelform/testdata/graphs_machine.golden new file mode 100644 index 0000000000..5a7d5688ab --- /dev/null +++ b/internal/exec/analysis/modelform/testdata/graphs_machine.golden @@ -0,0 +1 @@ +{"version":1,"subject":"test::Machine","states":[{"name":"test::Machine","kind":"stateUsage","scope":"test::Machine","attributes":[{"name":"log","types":["ScalarValues::Integer"],"value":{"text":"0","span":{"document":"graphs.sysml","offset":1453,"len":1}},"span":{"document":"graphs.sysml","offset":1427,"len":32},"scope":"test::Machine"}],"machine":0,"initial":1,"vertices":[{"id":0,"kind":"state","name":"Machine","span":{"document":"graphs.sysml","offset":1409,"len":691},"scope":"test::Machine"},{"id":1,"kind":"state","name":"start","span":{"document":"graphs.sysml","offset":1480,"len":15},"scope":"test::Machine::start"},{"id":2,"kind":"state","name":"Outer","span":{"document":"graphs.sysml","offset":1495,"len":450},"scope":"test::Machine::Outer","regions":[0,1],"entry":[{"span":{"document":"graphs.sysml","offset":1527,"len":35},"scope":"test::Machine::Outer","owner":2,"body":[{"kind":"block","span":{"document":"graphs.sysml","offset":1527,"len":35},"scope":"test::Machine::Outer::","body":{"statements":[{"kind":"assign","span":{"document":"graphs.sysml","offset":1529,"len":28},"scope":"test::Machine::Outer::","name":"log","value":{"text":"log * 10 + 1","span":{"document":"graphs.sysml","offset":1543,"len":12}}}],"scope":"test::Machine::Outer::"}}]}],"do":[{"name":"watch","span":{"document":"graphs.sysml","offset":1565,"len":43},"scope":"test::Machine::Outer","owner":2,"body":[{"kind":"block","span":{"document":"graphs.sysml","offset":1565,"len":43},"scope":"test::Machine::Outer::watch","body":{"statements":[{"kind":"assign","span":{"document":"graphs.sysml","offset":1580,"len":23},"scope":"test::Machine::Outer::watch","name":"log","value":{"text":"log + 1","span":{"document":"graphs.sysml","offset":1594,"len":7}}}],"scope":"test::Machine::Outer::watch"}}]}],"exit":[{"span":{"document":"graphs.sysml","offset":1613,"len":36},"scope":"test::Machine::Outer","owner":2,"body":[{"kind":"block","span":{"document":"graphs.sysml","offset":1613,"len":36},"scope":"test::Machine::Outer::","body":{"statements":[{"kind":"assign","span":{"document":"graphs.sysml","offset":1615,"len":28},"scope":"test::Machine::Outer::","name":"log","value":{"text":"log * 10 + 2","span":{"document":"graphs.sysml","offset":1629,"len":12}}}],"scope":"test::Machine::Outer::"}}]}]},{"id":3,"kind":"state","name":"lstart","span":{"document":"graphs.sysml","offset":1690,"len":18},"scope":"test::Machine::Outer::left::lstart","parent":8,"region":0},{"id":4,"kind":"state","name":"lit","span":{"document":"graphs.sysml","offset":1708,"len":15},"scope":"test::Machine::Outer::left::lit","parent":8,"region":0},{"id":5,"kind":"state","name":"rstart","span":{"document":"graphs.sysml","offset":1859,"len":18},"scope":"test::Machine::Outer::right::rstart","parent":9,"region":1},{"id":6,"kind":"state","name":"chiming","span":{"document":"graphs.sysml","offset":1877,"len":19},"scope":"test::Machine::Outer::right::chiming","parent":9,"region":1},{"id":7,"kind":"state","name":"Done","span":{"document":"graphs.sysml","offset":1945,"len":15},"scope":"test::Machine::Done"},{"id":8,"kind":"state","name":"left","span":{"document":"graphs.sysml","offset":1649,"len":168},"scope":"test::Machine::Outer::left","hidden":true,"hiddenRegion":0,"parent":2},{"id":9,"kind":"state","name":"right","span":{"document":"graphs.sysml","offset":1817,"len":124},"scope":"test::Machine::Outer::right","hidden":true,"hiddenRegion":1,"parent":2}],"regions":[{"id":0,"name":"left","span":{"document":"graphs.sysml","offset":1649,"len":168},"owner":2,"state":8,"initial":3},{"id":1,"name":"right","span":{"document":"graphs.sysml","offset":1817,"len":124},"owner":2,"state":9,"initial":5}],"transitions":[{"source":1,"target":2,"scope":"test::Machine","decl":{"document":"graphs.sysml","offset":1960,"len":37}},{"source":2,"target":7,"trigger":{"kind":"call","text":"setSpeed(value)","span":{"document":"graphs.sysml","offset":2027,"len":16},"operation":"setSpeed","parameters":["value"]},"guard":{"text":"value \u003e 0","span":{"document":"graphs.sysml","offset":2046,"len":10}},"effect":[{"span":{"document":"graphs.sysml","offset":2059,"len":27},"scope":"test::Machine::","owner":2,"body":[{"kind":"assign","span":{"document":"graphs.sysml","offset":2059,"len":27},"scope":"test::Machine::","name":"log","value":{"text":"log * 10 + 9","span":{"document":"graphs.sysml","offset":2073,"len":13}}}]}],"scope":"test::Machine","bodyScope":"test::Machine::","decl":{"document":"graphs.sysml","offset":1997,"len":101}},{"source":3,"target":4,"scope":"test::Machine::Outer::left","decl":{"document":"graphs.sysml","offset":1723,"len":38}},{"source":4,"target":3,"trigger":{"kind":"call","text":"wake()","span":{"document":"graphs.sysml","offset":1789,"len":7},"operation":"wake"},"scope":"test::Machine::Outer::left","bodyScope":"test::Machine::Outer::left::","decl":{"document":"graphs.sysml","offset":1761,"len":51}},{"source":5,"target":6,"scope":"test::Machine::Outer::right","decl":{"document":"graphs.sysml","offset":1896,"len":41}}],"entryTransitions":[{"target":1,"scope":"test::Machine","decl":{"document":"graphs.sysml","offset":1466,"len":14}},{"body":0,"target":3,"scope":"test::Machine::Outer::left","decl":{"document":"graphs.sysml","offset":1673,"len":17}},{"body":1,"target":5,"scope":"test::Machine::Outer::right","decl":{"document":"graphs.sysml","offset":1842,"len":17}}]}]} diff --git a/internal/exec/analysis/modelform/testdata/graphs_run.golden b/internal/exec/analysis/modelform/testdata/graphs_run.golden new file mode 100644 index 0000000000..20ba836f50 --- /dev/null +++ b/internal/exec/analysis/modelform/testdata/graphs_run.golden @@ -0,0 +1 @@ +{"version":1,"subject":"test::run","actions":[{"name":"test::run","kind":"actionUsage","scope":"test::run","attributes":[{"name":"total","types":["ScalarValues::Integer"],"value":{"text":"0","span":{"document":"graphs.sysml","offset":124,"len":1}},"span":{"document":"graphs.sysml","offset":96,"len":33}},{"name":"doubled","types":["ScalarValues::Integer"],"value":{"text":"0","span":{"document":"graphs.sysml","offset":159,"len":1}},"span":{"document":"graphs.sysml","offset":129,"len":35}},{"name":"battery","types":["ScalarValues::Integer"],"value":{"text":"10","span":{"document":"graphs.sysml","offset":194,"len":2}},"span":{"document":"graphs.sysml","offset":164,"len":37}}],"nodes":[{"id":0,"kind":"start","name":"start","span":{"document":"graphs.sysml","offset":201,"len":15},"footprint":{}},{"id":1,"kind":"fork","name":"split","span":{"document":"graphs.sysml","offset":216,"len":14},"footprint":{}},{"id":2,"kind":"action usage","name":"left","span":{"document":"graphs.sysml","offset":230,"len":37},"scope":"test::run::left","body":[{"kind":"assign","span":{"document":"graphs.sysml","offset":244,"len":19},"scope":"test::run::left","name":"total","value":{"text":"1","span":{"document":"graphs.sysml","offset":260,"len":1}}}],"footprint":{"writes":[{"symbol":"test::run::total","name":"total"}],"control":[4]}},{"id":3,"kind":"action usage","name":"iterate","span":{"document":"graphs.sysml","offset":267,"len":102},"scope":"test::run::iterate","body":[{"kind":"loop","span":{"document":"graphs.sysml","offset":287,"len":78},"scope":"test::run::iterate","loop":"for","variable":"i","collection":{"text":"1..3","span":{"document":"graphs.sysml","offset":296,"len":5}},"body":{"statements":[],"scope":"graphs.sysml#287+78","graph":{"name":"","kind":"","scope":"graphs.sysml#287+78","nodes":[{"id":0,"kind":"action usage","name":"Bump","span":{"document":"graphs.sysml","offset":307,"len":18},"scope":"Bump","performs":["test::Bump"],"footprint":{"dynamic":true}},{"id":1,"kind":"assignment","span":{"document":"graphs.sysml","offset":325,"len":36},"body":[{"kind":"assign","span":{"document":"graphs.sysml","offset":325,"len":36},"scope":"graphs.sysml#287+78","name":"total","value":{"text":"total + doubled","span":{"document":"graphs.sysml","offset":341,"len":15}}}],"statementRun":true,"footprint":{"reads":[{"symbol":"test::run::doubled","name":"doubled"},{"symbol":"test::run::total","name":"total"}],"writes":[{"symbol":"test::run::total","name":"total"}]}}],"initial":0,"edges":[{"source":0,"target":1,"decl":{"offset":0,"len":0}}]}}}],"block":[9],"footprint":{"reads":[{"symbol":"test::run::doubled","name":"doubled"},{"symbol":"test::run::total","name":"total"}],"writes":[{"name":"i"},{"symbol":"test::run::total","name":"total"}],"control":[4],"dynamic":true}},{"id":4,"kind":"join","name":"sync","span":{"document":"graphs.sysml","offset":369,"len":13},"footprint":{"control":[4]}},{"id":5,"kind":"decision","name":"check","span":{"document":"graphs.sysml","offset":382,"len":16},"footprint":{}},{"id":6,"kind":"action usage","name":"low","span":{"document":"graphs.sysml","offset":398,"len":36},"scope":"test::run::low","body":[{"kind":"assign","span":{"document":"graphs.sysml","offset":411,"len":19},"scope":"test::run::low","name":"total","value":{"text":"7","span":{"document":"graphs.sysml","offset":427,"len":1}}}],"footprint":{"writes":[{"symbol":"test::run::total","name":"total"}]}},{"id":7,"kind":"action usage","name":"idle","span":{"document":"graphs.sysml","offset":434,"len":37},"scope":"test::run::idle","body":[{"kind":"assign","span":{"document":"graphs.sysml","offset":448,"len":19},"scope":"test::run::idle","name":"total","value":{"text":"8","span":{"document":"graphs.sysml","offset":464,"len":1}}}],"footprint":{"writes":[{"symbol":"test::run::total","name":"total"}]}},{"id":8,"kind":"end","name":"done","span":{"document":"graphs.sysml","offset":471,"len":9},"footprint":{"reads":[{"symbol":"test::run::battery","name":"battery"}]}},{"id":9,"kind":"action usage","name":"Bump","span":{"document":"graphs.sysml","offset":307,"len":18},"performs":["test::Bump"]}],"initial":0,"finals":[8],"edges":[{"source":0,"target":1,"decl":{"document":"graphs.sysml","offset":480,"len":37}},{"source":1,"target":2,"decl":{"document":"graphs.sysml","offset":517,"len":36}},{"source":1,"target":3,"decl":{"document":"graphs.sysml","offset":553,"len":39}},{"source":2,"target":4,"decl":{"document":"graphs.sysml","offset":592,"len":35}},{"source":3,"target":4,"decl":{"document":"graphs.sysml","offset":627,"len":38}},{"source":4,"target":5,"decl":{"document":"graphs.sysml","offset":665,"len":36}},{"source":6,"target":8,"decl":{"document":"graphs.sysml","offset":742,"len":34}},{"source":7,"target":8,"decl":{"document":"graphs.sysml","offset":776,"len":34}},{"source":8,"target":6,"guard":{"text":"battery \u003c 20","span":{"document":"graphs.sysml","offset":704,"len":13}},"decl":{"document":"graphs.sysml","offset":701,"len":28}},{"source":8,"target":7,"else":true,"decl":{"document":"graphs.sysml","offset":729,"len":13}}]},{"name":"test::Bump","kind":"actionDef","scope":"test::Bump","parameters":[{"name":"i","direction":"in","types":["ScalarValues::Integer"],"span":{"document":"graphs.sysml","offset":1216,"len":18},"scope":"test::Bump"},{"name":"doubled","direction":"out","types":["ScalarValues::Integer"],"span":{"document":"graphs.sysml","offset":1234,"len":26},"scope":"test::Bump"}],"attributes":[{"name":"i","types":["ScalarValues::Integer"],"span":{"document":"graphs.sysml","offset":1216,"len":18}},{"name":"doubled","types":["ScalarValues::Integer"],"span":{"document":"graphs.sysml","offset":1234,"len":26}}],"nodes":[{"id":0,"kind":"start","name":"start","span":{"document":"graphs.sysml","offset":1260,"len":15},"footprint":{}},{"id":1,"kind":"action usage","name":"compute","span":{"document":"graphs.sysml","offset":1275,"len":46},"scope":"test::Bump::compute","body":[{"kind":"assign","span":{"document":"graphs.sysml","offset":1292,"len":25},"scope":"test::Bump::compute","name":"doubled","value":{"text":"i * 2","span":{"document":"graphs.sysml","offset":1310,"len":5}}}],"footprint":{"reads":[{"symbol":"test::Bump::i","name":"i"}],"writes":[{"symbol":"test::Bump::doubled","name":"doubled"}]}},{"id":2,"kind":"end","name":"done","span":{"document":"graphs.sysml","offset":1321,"len":8},"footprint":{}}],"initial":0,"finals":[2],"edges":[{"source":0,"target":1,"decl":{"document":"graphs.sysml","offset":1329,"len":39}},{"source":1,"target":2,"decl":{"document":"graphs.sysml","offset":1368,"len":37}}]}]} diff --git a/internal/core/analysis/process.go b/internal/exec/analysis/process.go similarity index 100% rename from internal/core/analysis/process.go rename to internal/exec/analysis/process.go diff --git a/internal/core/analysis/process_test.go b/internal/exec/analysis/process_test.go similarity index 97% rename from internal/core/analysis/process_test.go rename to internal/exec/analysis/process_test.go index 3836a0add4..4ebf9c8a35 100644 --- a/internal/core/analysis/process_test.go +++ b/internal/exec/analysis/process_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" ) // OPENSYSML_TOOL_MAX_OUTPUT is bytes or bytes with a binary suffix; anything else is the default. diff --git a/internal/exec/analysis/question.go b/internal/exec/analysis/question.go new file mode 100644 index 0000000000..c8fd827492 --- /dev/null +++ b/internal/exec/analysis/question.go @@ -0,0 +1,247 @@ +package analysis + +import ( + "context" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// Kind is what a question asks of its subject. +type Kind int + +const ( + // Evaluate asks for one execution under stated bindings and a stated + // scheduling policy: a calc, a case, a behavior, or a condition on a subject. + Evaluate Kind = iota + // Outcomes asks what a behavior can do over every schedule. + Outcomes + // Holds asks whether a condition holds over a behavior or a case, with the + // schedule and/or the unbound inputs free within stated bounds. + Holds + // Sensitive asks whether a behavior's result depends on its schedule. + Sensitive + // Satisfiable asks whether some assignment to the unbound features meets a + // set of conditions. + Satisfiable + // Sweep asks a calc or case once per row of a domain the rows enumerate or sample. + Sweep + // Compute asks an external tool once, as an action annotated ToolExecution names it. + Compute +) + +// String names the kind as a plan spells it. +func (k Kind) String() string { + switch k { + case Evaluate: + return "evaluate" + case Outcomes: + return "outcomes" + case Holds: + return "holds" + case Sensitive: + return "sensitive" + case Satisfiable: + return "satisfiable" + case Sweep: + return "sweep" + case Compute: + return "compute" + } + return "unknown" +} + +// Freedom is what a question leaves free; a set of the flags below. +type Freedom int + +const ( + // FreeNothing fixes every choice: one execution, or one per row. + FreeNothing Freedom = 0 + // FreeSchedule leaves the scheduling of concurrent behavior open. + FreeSchedule Freedom = 1 << iota + // FreeInputs leaves unbound inputs open. + FreeInputs +) + +// Has reports whether the freedom includes f. +func (f Freedom) Has(flag Freedom) bool { return f&flag != 0 } + +// String names what is free: `nothing`, `the schedule`, `the inputs`, or both. +func (f Freedom) String() string { + var parts []string + if f.Has(FreeSchedule) { + parts = append(parts, "the schedule") + } + if f.Has(FreeInputs) { + parts = append(parts, "the inputs") + } + if len(parts) == 0 { + return "nothing" + } + return strings.Join(parts, " and ") +} + +// Question is what is asked, independently of who answers it: a subject, what is asked of +// it, what is fixed and what is free, and the ask for its kind saying how one unit is made. +type Question struct { + Kind Kind + // Subject names the element asked about, as the surface spelled it. + Subject string + // Schedule is the scheduling policy the question states: the one an + // evaluation runs under, or the exploring one whose budget bounds outcomes. + Schedule runtime.SchedulePolicy + // ModelSeed is the seed the runs' modeled draws come from when one is set + // apart from the schedule (runtime.Context.SetModelSeed). + ModelSeed ModelSeed + // Draws is how the runs resolve their RandomFunctions draws: at random from + // the seeded stream, or at each call's min, max or average (runtime.Context.SetDrawPolicy). + Draws runtime.DrawPolicy + // ClockStep is the step the runs' clock ticks by, in seconds, on which their + // waits come due; 0 is a continuous clock (runtime.Context.SetClockStep). + ClockStep float64 + // Free is what the question leaves open. + Free Freedom + // Perform makes the one execution an Evaluate question asks for. + Perform Performance + // Linearize makes one run of the behavior an Outcomes question asks about, + // in a context of the run's own. + Linearize Linearization + // Sweep is the domain and the row of a Sweep question. + Sweep *SweepAsk + // Solve is the queries of a Satisfiable question and how each is asked. + Solve *SolveAsk + // Holds is the behavior, the condition and the start of one run of a Holds question. + Holds *HoldsAsk + // Compute is the tool invocation a Compute question asks for. + Compute *ComputeAsk + // Check is the action an Outcomes or Holds question asks the check engine to + // search the schedules of, with what must hold and what may not diverge. + Check *CheckAsk +} + +// ModelSeed is a seed for the modeled draws of a question's runs, and whether one is set. +type ModelSeed struct { + Seed uint64 + Set bool +} + +// apply gives ctx the seed where one is set; otherwise ctx keeps leaving its draws to the schedule. +func (s ModelSeed) apply(ctx *runtime.Context) { + if s.Set { + ctx.SetModelSeed(s.Seed) + } +} + +// fresh is a context of a run's own on the plan's worker for job, under the budget as +// Model.NewContextOn takes it, drawing from the question's model seed where one is set, +// under its draw policy and on a clock stepping as it states. +func (q Question) fresh(model *Model, job int, budget Budget) (*runtime.Context, error) { + ctx, err := model.NewContextOn(job, budget) + if err != nil { + return nil, err + } + q.ModelSeed.apply(ctx) + ctx.SetDrawPolicy(q.Draws) + if err := ctx.SetClockStep(q.ClockStep); err != nil { + return nil, err + } + return ctx, nil +} + +// ModelSeedOf is the model seed set on ctx, none when ctx is nil or has none. +func ModelSeedOf(ctx *runtime.Context) ModelSeed { + if ctx == nil { + return ModelSeed{} + } + seed, set := ctx.ModelSeed() + return ModelSeed{Seed: seed, Set: set} +} + +// DrawsOf is the draw policy set on ctx, random when ctx is nil. +func DrawsOf(ctx *runtime.Context) runtime.DrawPolicy { + if ctx == nil { + return runtime.DrawRandom + } + return ctx.DrawPolicy() +} + +// ClockStepOf is the clock step set on ctx, 0 (a continuous clock) when ctx is nil. +func ClockStepOf(ctx *runtime.Context) float64 { + if ctx == nil { + return 0 + } + return ctx.ClockStep() +} + +// Performance makes one execution in the given context and reports what it established. +// An error is a fault, not a failed execution, which is an Answer claiming nothing. +type Performance func(*runtime.Context) (Answer, error) + +// Linearization runs the behavior once in a fresh context under the context's +// schedule, in the shape runtime.Explore drives. +type Linearization func(*runtime.Context) (runtime.Outcome, error) + +// Answer is what one performance established: the claim it supports, the +// values it produced, and why when it claims nothing. +type Answer struct { + Claim Claim + // Reason says why nothing is claimed when no error does. + Reason string + // Err is the failure that ended the execution: a typed runtime error, whose + // budget the engine reports as a bound reached. + Err error + Values []Evaluation +} + +// SweepAsk is the plan of a sweep and the run of one row of it. +type SweepAsk struct { + Plan runtime.SweepPlan + Row runtime.SweepRun +} + +// SolveAsk is the queries a satisfiability question puts to a solver, one per +// condition set, and how each is asked. +type SolveAsk struct { + Queries []*solve.Query + Ask Asking +} + +// HoldsAsk is the behavior a Holds or Sensitive question is about, the conditions +// asked to hold over its schedules, the features whose final values a Sensitive +// question compares across them, and how one run of it begins in a fresh context. +type HoldsAsk struct { + // Behavior is the action definition or usage whose schedules are asked about. + Behavior *symbols.Symbol + // Scope is the scope Behavior's body resolves in; Behavior's own when nil. + Scope *symbols.Scope + // Conditions are the requirements and constraints asked to hold at every + // move, each as `-check-property` names it; none asks only that no schedule deadlocks. + Conditions []*symbols.Symbol + // Start begins one run of Behavior in a fresh context, as an explored run is + // begun: the executor made, on the object performing it when there is one. + Start Start + // Inputs names features of Behavior or its performer to leave free although + // the model binds them, as `-check-input` spells them. + Inputs []string + // Assume are the constraints or requirements assumed over the initial state. + Assume []*symbols.Symbol + // Diverge names the features whose final values a Sensitive question compares + // across the complete schedules, as `-check-diverge` spells them; none compares + // the action's own attributes, and the performing object's when one performs it. + Diverge []string + // Performer names the object performing Behavior, "" when the action runs on + // its own; a witness file is named after the subject and it. + Performer string + // WitnessDir is where a violation's or a sensitivity's witnesses are written, "" for nowhere. + WitnessDir string +} + +// Start begins one run of a behavior in the given context and returns the executor +// about to run it. An error is a fault of the start, not an outcome of the run. +type Start func(*runtime.Context) (*runtime.ActionExecutor, error) + +// Asking puts one query to the solver in a method expression's shape. An error is that +// query's failure, not the question's; nil for both is a query withheld, which stays uncovered. +type Asking func(*solve.Solver, context.Context, *solve.Query) (*solve.Result, error) diff --git a/internal/exec/analysis/record/record.go b/internal/exec/analysis/record/record.go new file mode 100644 index 0000000000..e5ac813f18 --- /dev/null +++ b/internal/exec/analysis/record/record.go @@ -0,0 +1,831 @@ +// Package record generates the SysML declarations that record an analysis +// run, a sweep or a Monte-Carlo sample into the model it ran on, as usages of +// the bundled AnalysisRecords library. +package record + +import ( + "fmt" + "strings" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// Kind is the run shape a record's `kind` feature reports. +type Kind string + +const ( + // KindRun records one run of an analysis case. + KindRun Kind = "run" + // KindTrade records one run of an analysis case that evaluated alternatives. + KindTrade Kind = "trade" + // KindSweep records the runs of a sweep. + KindSweep Kind = "sweep" + // KindRuns records the runs of a Monte-Carlo sample. + KindRuns Kind = "runs" + // KindSample records the conclusion a Monte-Carlo sample made, beside the + // runs it was made of. + KindSample Kind = "sample" +) + +// Provenance is what a recorded run reports about how it was made. +type Provenance struct { + // RunAt is when the run was made; recorded in UTC. + RunAt time.Time + + // Tool names the program that ran it. + Tool string + + // Command is the invocation text that ran it. + Command string + + // Kind is the run shape the records report. + Kind Kind +} + +// Subject is how a run's object is recorded: its usage in the model, and its +// text for subjectName. +type Subject struct { + // Usage is the subject object's qualified name, or "" when it has none. + Usage string + + // Text is the subject as subjectName reports it. + Text string +} + +// Run is everything one run of a case contributes to its record. +type Run struct { + // Iteration is the run's position in a sweep or sample; 0 for a single run. + Iteration int + + // Subject is the object the run was made on. + Subject Subject + + // Inputs are the values the run bound the case's input parameters to. + Inputs []runtime.InputBinding + + // Outputs are the values the run's declared outputs came to. + Outputs []runtime.CalcOutputValue + + // Verdicts are what the run's checks decided. + Verdicts []runtime.AnalysisVerdict + + // Evaluations are the calc applications the run made. + Evaluations []runtime.AnalysisEvaluation + + // Kind is the run shape this record makes; empty, the request's kind. + Kind Kind + + // Verifications are what a verification case's body and its subcases + // decided, for a case that is one. + Verifications []runtime.VerificationVerdict + + // Spell renders the run's values for the text it cannot supply itself, in + // the context it was made in — a sweep's rows each carry their own. + Spell Spelling +} + +// Spelling renders a run's values for the text it cannot supply itself. +type Spelling struct { + // ObjectUsage is the qualified name of the usage an object value is an + // occurrence of, or "" when the value names no single usage. + ObjectUsage func(v runtime.Value) string + + // Text is a value's text for the surfaces that record it as a string. + Text func(v runtime.Value) string + + // Unset reports a value records nothing: null, or a feature holding none. + Unset func(v runtime.Value) bool +} + +// Feature is one member an existing record definition declares. +type Feature struct { + // Ref marks a `ref part`, an object-valued feature; unset is an attribute. + Ref bool + + // TypeFQN is the qualified name of the feature's declared type. + TypeFQN string +} + +// Existing is what Generate must fit the records it makes into. +type Existing struct { + // Package marks the target package already declared. + Package bool + + // Definition marks the per-case record definition already declared in it. + Definition bool + + // Attributes are the features the existing definition declares, by name. + Attributes map[string]Feature + + // Taken are the N for which _runN is already declared in the package. + Taken map[int]bool + + // Stem is the name the records and their definition are built on — the + // case's short name, or an owner-prefixed fallback a sibling case's + // definition forced. + Stem string +} + +// Request is one call to Generate. +type Request struct { + // Package is the qualified name of the package the records go into, e.g. + // "Records" or "Mission::Records". + Package string + + // Case is the qualified name of the analysis case the runs were made of. + Case string + + // CaseName is the case's declared name as written, quoting needs included; + // the records and their definition are named from it. Empty falls back to + // Case's last qualified-name segment. + CaseName string + + // Provenance is what every record reports about how it was made. + Provenance Provenance + + // Runs are the runs to record, in order. + Runs []Run + + // Existing is what the target package already holds. + Existing Existing +} + +// Result is what Generate made. +type Result struct { + // Source is the declaration text: the target package's full nesting down to + // it, holding the record definition when new and every record usage. + Source string + + // Definition is the qualified name of the per-case record definition. + Definition string + + // Records are the qualified names of the record usages, in order. + Records []string +} + +// reservedFeatures are AnalysisRun's own features a parameter name may not take. +var reservedFeatures = map[string]bool{ + "caseName": true, "kind": true, "objective": true, "iteration": true, + "subject": true, "subjectName": true, "verdict": true, "verdicts": true, + "evaluations": true, +} + +// feature is one member the record definition declares for a run value. +type feature struct { + name string + ref bool // object-valued + typ string // declared type as written, "" for a ref + unitOf string // nonempty: this feature is the unit companion of the named one +} + +// valueKind classifies how a value is spelled: its declared type and, for a +// quantity, the companion unit feature it needs. +type valueKind int + +const ( + kindUnset valueKind = iota + kindRef + kindInteger + kindReal + kindBoolean + kindString + kindEnum + kindQuantity +) + +// shape is how a value is recorded: its feature kind, the literal spelling, +// and — for a quantity — the unit text its companion feature records. +type shape struct { + kind valueKind + literal string + typ string + unit string +} + +// classify decides the feature shape a value asks for. +func classify(v runtime.Value, r *Run) shape { + if r.Spell.Unset != nil && r.Spell.Unset(v) { + return shape{kind: kindUnset} + } + // An enumeration literal keeps its identity through a scalar payload too. + if lit := v.EnumerationLiteral(); lit != nil { + enum := semantics.EnumerationOwning(lit) + fqn := qualifiedName(enum) + return shape{kind: kindEnum, typ: fqn, literal: source.QualifiedNameText(fqn + "::" + lit.Name)} + } + switch v.Kind { + case runtime.ValNull: + return shape{kind: kindUnset} + case runtime.ValConst: + if kind, typ, ok := constScalar(v.Const.Kind); ok { + return shape{kind: kind, typ: typ, literal: semantics.FormatConst(v.Const)} + } + // A constant without a literal spelling, Infinity included, is + // recorded as a string of its text. + return shape{kind: kindString, typ: "ScalarValues::String", literal: source.StringText(semantics.FormatConst(v.Const))} + case runtime.ValString: + return shape{kind: kindString, typ: "ScalarValues::String", literal: source.StringText(v.Str())} + case runtime.ValQuantity: + q := v.Quantity() + return shape{kind: kindQuantity, typ: "ScalarValues::Real", literal: semantics.FormatConst(q.Num), unit: q.Unit.String()} + case runtime.ValInstance, runtime.ValVariant: + if r.Spell.ObjectUsage != nil { + if usage := r.Spell.ObjectUsage(v); usage != "" { + return shape{kind: kindRef, literal: source.QualifiedNameText(usage)} + } + } + } + // Everything else — a structured value, or an object naming no usage — is + // recorded by its text. + return shape{kind: kindString, typ: "ScalarValues::String", literal: source.StringText(spellText(v, r))} +} + +// constScalar is the feature kind and ScalarValues type a scalar literal's +// kind is recorded under; a constant without a literal spelling, Infinity +// included, has none and is recorded as a string. +func constScalar(k semantics.ValueKind) (valueKind, string, bool) { + switch k { + case semantics.ValInt: + return kindInteger, "ScalarValues::Integer", true + case semantics.ValReal: + return kindReal, "ScalarValues::Real", true + case semantics.ValBool: + return kindBoolean, "ScalarValues::Boolean", true + } + return 0, "", false +} + +// spellText is a value's text for the string features, Text when supplied and +// the runtime's own formatting otherwise. +func spellText(v runtime.Value, r *Run) string { + if r.Spell.Text != nil { + return r.Spell.Text(v) + } + return runtime.FormatValue(v) +} + +// qualifiedName is a symbol's qualified name by its owning chain. +func qualifiedName(sym *symbols.Symbol) string { + if sym == nil { + return "" + } + var names []string + for cur := sym; cur != nil; cur = cur.Owner() { + if cur.Name == "" { + break + } + names = append([]string{cur.Name}, names...) + } + return strings.Join(names, "::") +} + +// shortName is the last qualified-name segment of a case's name. +func shortName(fqn string) string { + segs, ok := source.QualifiedNameSegments(fqn) + if !ok || len(segs) == 0 { + return fqn + } + return segs[len(segs)-1] +} + +// upperFirst capitalizes a name's leading letter. +func upperFirst(name string) string { + if name == "" { + return name + } + return strings.ToUpper(name[:1]) + name[1:] +} + +// member is one input or output value a run declares. inOf marks the In +// companion of an inout: one member in the run's value, one in its binding. +type member struct { + name string + value runtime.Value + inOf string +} + +// members are the input and output values a run declares, in order. A name +// on both sides is one inout parameter: the value it ran to, then an +// In companion holding the value it was bound with. +func members(r Run) []member { + var out []member + outs := map[string]bool{} + for _, o := range r.Outputs { + outs[o.Name] = true + } + inouts := map[string]runtime.Value{} + for _, in := range r.Inputs { + if outs[in.Name] { + inouts[in.Name] = in.Value + continue + } + out = append(out, member{name: in.Name, value: in.Value}) + } + for _, o := range r.Outputs { + out = append(out, member{name: o.Name, value: o.Value}) + if v, ok := inouts[o.Name]; ok { + out = append(out, member{name: o.Name + "In", value: v, inOf: o.Name}) + } + } + return out +} + +// buildFeatures decides the members the record definition needs: every +// distinct member name in first-encounter order, its shape settled from the +// runs that supply it a value, and a unit companion after each quantity. +func buildFeatures(req *Request) ([]feature, error) { + // An inout's In companion is the run's own member, but a member a run + // declares of the same name collides with it, as a Unit companion's does. + companionOf := map[string]string{} + for i := range req.Runs { + for _, m := range members(req.Runs[i]) { + if m.inOf != "" { + companionOf[m.name] = m.inOf + } + } + } + // First pass: settle each member's shape over every run that supplies it. + var names []string + shapes := map[string]shape{} + for i := range req.Runs { + for _, m := range members(req.Runs[i]) { + if m.inOf == "" { + if owner, ok := companionOf[m.name]; ok { + return nil, fmt.Errorf("case %s: member %q collides with the in companion of inout %q", req.Case, m.name, owner) + } + } + if reservedFeatures[m.name] { + return nil, fmt.Errorf("case %s: parameter %q shares a name with a feature of AnalysisRecords::AnalysisRun", req.Case, m.name) + } + sh := classify(m.value, &req.Runs[i]) + cur, seen := shapes[m.name] + if !seen { + names = append(names, m.name) + shapes[m.name] = sh + continue + } + if sh.kind == kindUnset { + continue + } + if cur.kind == kindUnset { + shapes[m.name] = sh + continue + } + // A quantity member takes a plain-number row either order: the + // row keeps its literal and takes no unit. + if sh.kind == kindQuantity && (cur.kind == kindInteger || cur.kind == kindReal) { + shapes[m.name] = sh + continue + } + if cur.kind == kindQuantity && (sh.kind == kindInteger || sh.kind == kindReal) { + continue + } + // Integer and Real are one numeric family for the record + // definition: either way the member settles to Real, an Integer + // literal remaining valid under it. + if numericPair(cur.typ, sh.typ) { + cur = shape{kind: kindReal, typ: "ScalarValues::Real"} + shapes[m.name] = cur + continue + } + f := feature{name: m.name} + applyShape(&f, cur) + if err := compatible(&f, sh); err != nil { + return nil, fmt.Errorf("case %s: member %q: %w", req.Case, m.name, err) + } + } + } + // The two sides of an inout settle to one shape: a quantity side wins over + // a plain number, Integer and Real settle to Real, anything else must match. + for companion, owner := range companionOf { + o, c := shapes[owner], shapes[companion] + switch { + case o.kind == kindUnset || c.kind == kindUnset: + case o.kind == kindQuantity && (c.kind == kindInteger || c.kind == kindReal): + shapes[companion] = o + case c.kind == kindQuantity && (o.kind == kindInteger || o.kind == kindReal): + shapes[owner] = c + case numericPair(o.typ, c.typ): + shapes[owner] = shape{kind: kindReal, typ: "ScalarValues::Real"} + shapes[companion] = shape{kind: kindReal, typ: "ScalarValues::Real"} + default: + f := feature{name: owner} + applyShape(&f, o) + if err := compatible(&f, c); err != nil { + return nil, fmt.Errorf("case %s: inout %q: %w", req.Case, owner, err) + } + } + } + // Emit the features, each quantity's unit companion after it; a member + // named for one is a collision whatever order they met in. + units := map[string]string{} + for _, name := range names { + if shapes[name].kind == kindQuantity { + units[name+"Unit"] = name + } + } + var feats []feature + for _, name := range names { + if q, ok := units[name]; ok { + return nil, fmt.Errorf("case %s: member %q collides with the unit companion of quantity %q", req.Case, name, q) + } + f := feature{name: name} + applyShape(&f, shapes[name]) + feats = append(feats, f) + if shapes[name].kind == kindQuantity { + feats = append(feats, feature{name: name + "Unit", typ: "ScalarValues::String", unitOf: name}) + } + } + return feats, nil +} + +// numericPair reports whether the types are Integer and Real in either order: +// one numeric family for the record definition, settling to Real. +func numericPair(a, b string) bool { + return (a == "ScalarValues::Integer" && b == "ScalarValues::Real") || + (a == "ScalarValues::Real" && b == "ScalarValues::Integer") +} + +// applyShape gives a feature the declared shape a value's first supply asks for. +func applyShape(f *feature, sh shape) { + f.ref = sh.kind == kindRef + switch sh.kind { + case kindUnset: + f.typ = "ScalarValues::ScalarValue" + case kindRef: + f.typ = "" + default: + f.typ = source.QualifiedNameText(sh.typ) + } +} + +// compatible checks a later run's value against the shape a feature took. +func compatible(f *feature, sh shape) error { + switch { + case f.ref && sh.kind != kindRef: + return fmt.Errorf("an object value cannot be recorded in the value member") + case !f.ref && f.typ != "" && sh.kind == kindRef: + return fmt.Errorf("a non-object value cannot be recorded in the reference member") + } + if f.ref || sh.kind == kindRef { + return nil + } + if f.typ == "ScalarValues::ScalarValue" { + // The first supply was unset; a settled value gives the member its type. + f.typ = source.QualifiedNameText(sh.typ) + return nil + } + if f.typ != source.QualifiedNameText(sh.typ) { + return fmt.Errorf("value recorded as %s cannot follow %s", sh.typ, f.typ) + } + return nil +} + +// Generate renders the declarations recording req's runs. +func Generate(req Request) (Result, error) { + if len(req.Runs) == 0 { + return Result{}, fmt.Errorf("case %s: nothing to record", req.Case) + } + for _, r := range req.Runs { + if len(r.Outputs) == 0 && len(r.Verdicts) == 0 && len(r.Evaluations) == 0 && len(r.Verifications) == 0 { + return Result{}, fmt.Errorf("case %s produced no outputs to record", req.Case) + } + } + for _, r := range req.Runs { + seen := map[string]bool{} + for _, in := range r.Inputs { + if seen[in.Name] { + return Result{}, fmt.Errorf("case %s: member %q is listed twice", req.Case, in.Name) + } + seen[in.Name] = true + } + seen = map[string]bool{} + for _, o := range r.Outputs { + if seen[o.Name] { + return Result{}, fmt.Errorf("case %s: member %q is listed twice", req.Case, o.Name) + } + seen[o.Name] = true + } + } + + stem := req.Existing.Stem + if stem == "" { + stem = req.CaseName + } + if stem == "" { + stem = shortName(req.Case) + } + defName := upperFirst(stem) + "Run" + feats, err := buildFeatures(&req) + if err != nil { + return Result{}, err + } + + if req.Existing.Definition { + if err := checkExisting(&req, feats, defName); err != nil { + return Result{}, err + } + } + + var src strings.Builder + segs, _ := source.QualifiedNameSegments(req.Package) + depth := 0 + for _, seg := range segs { + writeIndent(&src, depth) + src.WriteString("package ") + src.WriteString(source.QualifiedNameText(seg)) + src.WriteString(" {\n") + depth++ + } + + recordNames := make([]string, len(req.Runs)) + if !req.Existing.Definition { + writeDefinition(&src, depth, defName, feats, req.Case) + } + // Number each record the smallest free N, the package's taken numbers and + // this batch's both skipped. + taken := map[int]bool{} + for n := range req.Existing.Taken { + taken[n] = true + } + next := 1 + for i := range req.Runs { + for taken[next] { + next++ + } + name := stem + "_run" + fmt.Sprint(next) + taken[next] = true + recordNames[i] = name + writeRecord(&src, depth, name, defName, feats, &req.Runs[i], &req) + } + + for d := depth - 1; d >= 0; d-- { + writeIndent(&src, d) + src.WriteString("}\n") + } + + records := make([]string, len(recordNames)) + for i, n := range recordNames { + records[i] = req.Package + "::" + n + } + return Result{Source: src.String(), Definition: req.Package + "::" + defName, Records: records}, nil +} + +// checkExisting verifies every feature the records need is declared +// compatibly by the existing definition. +func checkExisting(req *Request, feats []feature, defName string) error { + def := req.Package + "::" + defName + for _, f := range feats { + decl, ok := req.Existing.Attributes[f.name] + if !ok { + return fmt.Errorf("record definition %s declares no member %q; record into another package with `into`", def, f.name) + } + if decl.Ref != f.ref { + kind := "an attribute" + if decl.Ref { + kind = "a reference" + } + want := "a reference" + if !f.ref { + want = "an attribute" + } + return fmt.Errorf("record definition %s declares %s as %s but the run values need %s; record into another package with `into`", def, f.name, kind, want) + } + if !f.ref && f.typ != "" && decl.TypeFQN != "" && decl.TypeFQN != f.typ && + f.typ != "ScalarValues::ScalarValue" && decl.TypeFQN != "ScalarValues::ScalarValue" { + // An Integer literal is valid under a declared Real; the + // reverse would widen a definition the model owns, so it stays + // refused. + if decl.TypeFQN == "ScalarValues::Real" && f.typ == "ScalarValues::Integer" { + continue + } + return fmt.Errorf("record definition %s declares %s : %s but the run values need %s : %s; record into another package with `into`", def, f.name, decl.TypeFQN, f.name, f.typ) + } + } + return nil +} + +// writeIndent writes depth levels of indentation. +func writeIndent(src *strings.Builder, depth int) { + src.WriteString(strings.Repeat(" ", depth)) +} + +// writeDefinition writes the per-case record definition, its caseName default +// marking the case it records. +func writeDefinition(src *strings.Builder, depth int, name string, feats []feature, caseFQN string) { + writeIndent(src, depth) + src.WriteString("part def ") + src.WriteString(source.NameText(name)) + src.WriteString(" :> AnalysisRecords::AnalysisRun {\n") + writeIndent(src, depth+1) + src.WriteString("attribute :>> caseName default = ") + src.WriteString(source.StringText(caseFQN)) + src.WriteString(";\n") + for _, f := range feats { + writeIndent(src, depth+1) + if f.ref { + src.WriteString("ref part ") + src.WriteString(source.NameText(f.name)) + src.WriteString(";\n") + } else { + src.WriteString("attribute ") + src.WriteString(source.NameText(f.name)) + src.WriteString(" : ") + src.WriteString(f.typ) + src.WriteString(";\n") + } + } + writeIndent(src, depth) + src.WriteString("}\n") +} + +// writeRecord writes one run's record usage. +func writeRecord(src *strings.Builder, depth int, name, defName string, feats []feature, r *Run, req *Request) { + writeIndent(src, depth) + src.WriteString("part ") + src.WriteString(source.NameText(name)) + src.WriteString(" : ") + src.WriteString(source.NameText(defName)) + src.WriteString(" {\n") + + writeIndent(src, depth+1) + src.WriteString("@AnalysisRecords::RecordedRun {\n") + for _, m := range []struct{ name, value string }{ + {"runAt", source.StringText(req.Provenance.RunAt.UTC().Format(time.RFC3339))}, + {"tool", source.StringText(req.Provenance.Tool)}, + {"command", source.StringText(req.Provenance.Command)}, + {"kind", source.StringText(string(kindOf(r, req)))}, + } { + writeIndent(src, depth+2) + src.WriteString(m.name) + src.WriteString(" = ") + src.WriteString(m.value) + src.WriteString(";\n") + } + writeIndent(src, depth+1) + src.WriteString("}\n") + + writeFeature(src, depth+1, "caseName", source.StringText(req.Case)) + writeFeature(src, depth+1, "kind", source.StringText(string(kindOf(r, req)))) + writeFeature(src, depth+1, "'objective'", source.StringText(objectiveOf(r))) + for _, v := range r.Verifications { + if !v.Subcase { + writeFeature(src, depth+1, "verdict", source.StringText(string(v.Kind))) + } + } + if r.Iteration > 0 { + writeIndent(src, depth+1) + src.WriteString("attribute :>> iteration = ") + src.WriteString(fmt.Sprint(r.Iteration)) + src.WriteString(";\n") + } + if r.Subject.Usage != "" || r.Subject.Text != "" { + if r.Subject.Usage != "" { + writeIndent(src, depth+1) + src.WriteString("ref :>> 'subject' = ") + src.WriteString(source.QualifiedNameText(r.Subject.Usage)) + src.WriteString(";\n") + } + writeFeature(src, depth+1, "subjectName", source.StringText(r.Subject.Text)) + } + + shapeByName := map[string]feature{} + for _, f := range feats { + shapeByName[f.name] = f + } + for _, m := range members(*r) { + sh := classify(m.value, r) + if sh.kind == kindUnset { + continue + } + writeIndent(src, depth+1) + if sh.kind == kindRef { + src.WriteString("ref :>> ") + } else { + src.WriteString("attribute :>> ") + } + src.WriteString(source.NameText(m.name)) + src.WriteString(" = ") + src.WriteString(sh.literal) + src.WriteString(";\n") + if sh.kind == kindQuantity { + writeFeature(src, depth+1, m.name+"Unit", source.StringText(sh.unit)) + } + } + + for i, v := range r.Verdicts { + writeVerdict(src, depth+1, i+1, v) + } + for i, v := range r.Verifications { + writeVerification(src, depth+1, i+1, v) + } + for i, e := range r.Evaluations { + writeEvaluation(src, depth+1, i+1, e, r) + } + + writeIndent(src, depth) + src.WriteString("}\n") +} + +// objectiveOf is the status the run's objective verdict reports. +func objectiveOf(r *Run) string { + for _, v := range r.Verdicts { + if v.Kind == "objective" { + return v.Status.String() + } + } + return "undecided" +} + +// kindOf is the kind a run's record reports: its own where set, the +// request's otherwise. +func kindOf(r *Run, req *Request) Kind { + if r.Kind != "" { + return r.Kind + } + return req.Provenance.Kind +} + +// writeFeature writes `attribute :>> name = literal;`. +func writeFeature(src *strings.Builder, depth int, name, literal string) { + writeIndent(src, depth) + src.WriteString("attribute :>> ") + if strings.HasPrefix(name, "'") { + src.WriteString(name) + } else { + src.WriteString(source.NameText(name)) + } + src.WriteString(" = ") + src.WriteString(literal) + src.WriteString(";\n") +} + +// writeVerdict writes one verdict record part. +func writeVerdict(src *strings.Builder, depth, n int, v runtime.AnalysisVerdict) { + writeIndent(src, depth) + src.WriteString(fmt.Sprintf("part verdict%d : AnalysisRecords::VerdictRecord :> verdicts {\n", n)) + writeFeature(src, depth+1, "kind", source.StringText(v.Kind)) + writeFeature(src, depth+1, "name", source.StringText(v.Name)) + writeFeature(src, depth+1, "status", source.StringText(v.Status.String())) + if v.Detail != "" { + writeFeature(src, depth+1, "detail", source.StringText(v.Detail)) + } + writeIndent(src, depth) + src.WriteString("}\n") +} + +// writeVerification writes a verification case's body or subcase verdict as +// one more VerdictRecord: its kind says which, its name the case that ran. +func writeVerification(src *strings.Builder, depth, n int, v runtime.VerificationVerdict) { + kind := "verification" + if v.Subcase { + kind = "subcase" + } + writeIndent(src, depth) + src.WriteString(fmt.Sprintf("part verification%d : AnalysisRecords::VerdictRecord :> verdicts {\n", n)) + writeFeature(src, depth+1, "kind", source.StringText(kind)) + writeFeature(src, depth+1, "name", source.StringText(v.Case)) + writeFeature(src, depth+1, "status", source.StringText(string(v.Kind))) + if v.Detail != "" { + writeFeature(src, depth+1, "detail", source.StringText(v.Detail)) + } + writeIndent(src, depth) + src.WriteString("}\n") +} + +// writeEvaluation writes one evaluation record part. +func writeEvaluation(src *strings.Builder, depth, n int, e runtime.AnalysisEvaluation, r *Run) { + writeIndent(src, depth) + src.WriteString(fmt.Sprintf("part evaluation%d : AnalysisRecords::EvaluationRecord :> evaluations {\n", n)) + writeFeature(src, depth+1, "function", source.StringText(e.Function)) + var args []string + for _, a := range e.Arguments { + args = append(args, spellText(a, r)) + } + writeFeature(src, depth+1, "alternative", source.StringText(strings.Join(args, ", "))) + if sh := classify(e.Result, r); sh.kind == kindInteger || sh.kind == kindReal || sh.kind == kindQuantity { + writeFeature(src, depth+1, "score", sh.literal) + writeFeature(src, depth+1, "result", source.StringText(spellText(e.Result, r))) + } else if e.Error == nil { + writeFeature(src, depth+1, "result", source.StringText(spellText(e.Result, r))) + } + writeFeature(src, depth+1, "selected", boolText(e.Selected)) + writeFeature(src, depth+1, "tied", boolText(e.Tied)) + if e.Error != nil { + writeFeature(src, depth+1, "error", source.StringText(e.Error.Error())) + } + writeIndent(src, depth) + src.WriteString("}\n") +} + +// boolText spells a Boolean literal. +func boolText(b bool) string { + if b { + return "true" + } + return "false" +} diff --git a/internal/exec/analysis/record/record_test.go b/internal/exec/analysis/record/record_test.go new file mode 100644 index 0000000000..683792cf06 --- /dev/null +++ b/internal/exec/analysis/record/record_test.go @@ -0,0 +1,764 @@ +package record + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/format" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +var update = flag.Bool("update", false, "rewrite the golden files from the current generator") + +func real(f float64) runtime.Value { + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: f}} +} + +func integer(n int64) runtime.Value { + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: n}} +} + +func boolean(b bool) runtime.Value { + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: b}} +} + +func enumLiteral(t *testing.T) runtime.Value { + file := parser.New(source.New("", []byte( + `package P { enum def Fuel { enum leaded; enum unleaded; } }`))).ParseFile() + scope := symbols.Build(file) + p, ok := scope.LookupLocal("P") + if !ok { + t.Fatal("package not built") + } + fuel, ok := p.Scope.LookupLocal("Fuel") + if !ok { + t.Fatal("enum def not built") + } + lit, ok := fuel.Scope.LookupLocal("leaded") + if !ok { + t.Fatal("enum literal not built") + } + return runtime.NewEnumLiteral(lit) +} + +// spell renders values the way a session would: no object resolves to a usage. +func spell() Spelling { + return Spelling{ + ObjectUsage: func(runtime.Value) string { return "" }, + Text: runtime.FormatValue, + Unset: func(v runtime.Value) bool { return v.Kind == runtime.ValNull }, + } +} + +func provenance(kind Kind) Provenance { + return Provenance{ + RunAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + Tool: "sysml test", + Command: "%record P::check", + Kind: kind, + } +} + +// golden runs Generate and compares Source against the golden at name. +func golden(t *testing.T, name string, req Request) Result { + t.Helper() + res, err := Generate(req) + if err != nil { + t.Fatalf("Generate: %v", err) + } + path := filepath.Join("testdata", name) + if *update { + if err := os.WriteFile(path, []byte(res.Source), 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s missing; run with -update", path) + } + if res.Source != string(want) { + t.Errorf("%s differs from the generated text:\n%s", name, res.Source) + } + return res +} + +// A single run records every value spelling, its verdicts and its provenance. +func TestGenerateSingleRun(t *testing.T) { + obj := runtime.Value{Kind: runtime.ValInstance, Instance: 7} + sp := spell() + sp.ObjectUsage = func(v runtime.Value) string { + if v == obj { + return "P::scout" + } + return "" + } + res := golden(t, "single_run.sysml.golden", Request{ + Package: "Records", Case: "P::scoutBudget", Provenance: provenance(KindRun), + Runs: []Run{{ + Spell: sp, + Subject: Subject{Usage: "P::scout", Text: "P::scout"}, + Inputs: []runtime.InputBinding{ + {Name: "burnTime", Value: real(3.0)}, + {Name: "trials", Value: integer(8)}, + {Name: "crewOk", Value: boolean(true)}, + {Name: "label", Value: runtime.NewStringValue(`a "b"`)}, + {Name: "grade", Value: enumLiteral(t)}, + {Name: "tank", Value: runtime.NewQuantityValue(&runtime.Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: 12.5}, + Unit: semantics.Unit{Text: "kg"}, + })}, + {Name: "target", Value: obj}, + }, + Outputs: []runtime.CalcOutputValue{ + {Name: "fuelUsed", Value: real(12.5)}, + {Name: "memo", Value: runtime.Value{Kind: runtime.ValNull}}, + {Name: "plan", Value: runtime.NewSequenceValue(nil)}, + }, + Verdicts: []runtime.AnalysisVerdict{ + {Kind: "objective", Name: "fuelFits", Status: runtime.VerdictSatisfied, Detail: "holds"}, + {Kind: "assertion", Name: "crewReady", Status: runtime.VerdictNotSatisfied}, + }, + }}, + }) + if res.Definition != "Records::ScoutBudgetRun" { + t.Errorf("definition %q", res.Definition) + } + if len(res.Records) != 1 || res.Records[0] != "Records::scoutBudget_run1" { + t.Errorf("records %v", res.Records) + } +} + +// A trade run records its evaluations: selected, tied and failed alternatives. +func TestGenerateTradeRun(t *testing.T) { + failed := errors.New("boom") + res := golden(t, "trade_run.sysml.golden", Request{ + Package: "P::Records", Case: "P::choose", Provenance: provenance(KindTrade), + Runs: []Run{{ + Spell: spell(), + Subject: Subject{Text: "P::fleet"}, + Outputs: []runtime.CalcOutputValue{{Name: "best", Value: real(1.0)}}, + Evaluations: []runtime.AnalysisEvaluation{ + {Function: "P::score", Arguments: []runtime.Value{real(1.0)}, Result: real(0.75), Selected: true}, + {Function: "P::score", Arguments: []runtime.Value{real(2.0)}, Result: real(0.75), Tied: true}, + {Function: "P::score", Arguments: []runtime.Value{real(3.0)}, Result: runtime.Value{Kind: runtime.ValNull}, Error: failed}, + }, + }}, + }) + if res.Definition != "P::Records::ChooseRun" { + t.Errorf("definition %q", res.Definition) + } +} + +// A sweep reuses the existing definition and numbers its records on. +func TestGenerateSweepRuns(t *testing.T) { + res := golden(t, "sweep_runs.sysml.golden", Request{ + Package: "Records", Case: "P::check", Provenance: provenance(KindSweep), + Existing: Existing{ + Package: true, Definition: true, Taken: map[int]bool{1: true, 2: true, 3: true}, + Attributes: map[string]Feature{ + "load": {TypeFQN: "ScalarValues::Real"}, + "done": {TypeFQN: "ScalarValues::Boolean"}, + }, + }, + Runs: []Run{ + {Iteration: 1, Spell: spell(), Inputs: []runtime.InputBinding{{Name: "load", Value: real(1.0)}}, Outputs: []runtime.CalcOutputValue{{Name: "done", Value: boolean(true)}}}, + {Iteration: 2, Spell: spell(), Inputs: []runtime.InputBinding{{Name: "load", Value: real(3.0)}}, Outputs: []runtime.CalcOutputValue{{Name: "done", Value: boolean(false)}}}, + {Iteration: 3, Spell: spell(), Inputs: []runtime.InputBinding{{Name: "load", Value: real(5.0)}}, Outputs: []runtime.CalcOutputValue{{Name: "done", Value: boolean(true)}}}, + }, + }) + want := []string{"Records::check_run4", "Records::check_run5", "Records::check_run6"} + if fmt.Sprint(res.Records) != fmt.Sprint(want) { + t.Errorf("records %v, want %v", res.Records, want) + } +} + +// Generate refuses the shapes it cannot record. +func TestGenerateErrors(t *testing.T) { + base := func() Request { + return Request{Package: "Records", Case: "P::check", Provenance: provenance(KindRun)} + } + empty := Request{Package: "Records", Case: "P::check"} + if _, err := Generate(empty); err == nil { + t.Error("no runs: want an error") + } + req := base() + req.Runs = []Run{{Spell: spell(), Inputs: []runtime.InputBinding{{Name: "x", Value: real(1)}}}} + if _, err := Generate(req); err == nil { + t.Error("a run with no outputs, verdicts or evaluations: want an error") + } + req = base() + req.Runs = []Run{{Spell: spell(), Outputs: []runtime.CalcOutputValue{{Name: "kind", Value: real(1)}}}} + if _, err := Generate(req); err == nil { + t.Error("member colliding with an AnalysisRun feature: want an error") + } + req = base() + req.Runs = []Run{{ + Spell: spell(), + Outputs: []runtime.CalcOutputValue{{Name: "x", Value: real(2)}, {Name: "x", Value: real(3)}}, + }} + if _, err := Generate(req); err == nil { + t.Error("one side of the run listing a name twice: want an error") + } + req = base() + req.Runs = []Run{{ + Spell: spell(), + Inputs: []runtime.InputBinding{{Name: "x", Value: real(1)}, {Name: "xIn", Value: real(0)}}, + Outputs: []runtime.CalcOutputValue{{Name: "x", Value: real(2)}}, + }} + if _, err := Generate(req); err == nil { + t.Error("member colliding with an inout's in companion: want an error") + } + req = base() + req.Existing = Existing{Package: true, Definition: true, Attributes: map[string]Feature{ + "load": {TypeFQN: "ScalarValues::String"}, + }} + req.Runs = []Run{{ + Spell: spell(), + Inputs: []runtime.InputBinding{{Name: "load", Value: real(1)}}, + Outputs: []runtime.CalcOutputValue{{Name: "done", Value: boolean(true)}}, + }} + if _, err := Generate(req); err == nil { + t.Error("existing def with a type-mismatched member: want an error") + } + req = base() + req.Existing = Existing{Package: true, Definition: true, Attributes: map[string]Feature{ + "load": {Ref: true}, + "done": {TypeFQN: "ScalarValues::Boolean"}, + }} + req.Runs = []Run{{ + Spell: spell(), + Inputs: []runtime.InputBinding{{Name: "load", Value: real(1)}}, + Outputs: []runtime.CalcOutputValue{{Name: "done", Value: boolean(true)}}, + }} + if _, err := Generate(req); err == nil { + t.Error("existing ref member fed an attribute value: want an error") + } + dose := runtime.NewQuantityValue(&runtime.Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: 1.5}, + Unit: semantics.Unit{Text: "kg"}, + }) + req = base() + req.Runs = []Run{{Spell: spell(), Outputs: []runtime.CalcOutputValue{ + {Name: "dose", Value: dose}, + {Name: "doseUnit", Value: real(1)}, + }}} + if _, err := Generate(req); err == nil { + t.Error("a member named as a quantity's unit companion: want an error") + } + req = base() + req.Runs = []Run{{Spell: spell(), Outputs: []runtime.CalcOutputValue{ + {Name: "doseUnit", Value: real(1)}, + {Name: "dose", Value: dose}, + }}} + if _, err := Generate(req); err == nil { + t.Error("a quantity whose unit companion names a member: want an error") + } +} + +// A member unset in one run takes the type the settled run gives it, wherever +// it sits among the members the definition declares. +func TestGenerateSettlesAnUnsetMember(t *testing.T) { + res, err := Generate(Request{ + Package: "Records", Case: "P::check", Provenance: provenance(KindSweep), + Runs: []Run{ + {Iteration: 1, Spell: spell(), Outputs: []runtime.CalcOutputValue{ + {Name: "x", Value: runtime.Value{Kind: runtime.ValNull}}, + {Name: "a", Value: real(1)}, + {Name: "b", Value: real(2)}, + {Name: "c", Value: real(3)}, + }}, + {Iteration: 2, Spell: spell(), Outputs: []runtime.CalcOutputValue{ + {Name: "x", Value: real(3.0)}, + {Name: "a", Value: real(1)}, + {Name: "b", Value: real(2)}, + {Name: "c", Value: real(3)}, + }}, + }, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{"attribute x : ScalarValues::Real;", "attribute :>> x = 3.0;"} { + if !strings.Contains(res.Source, want) { + t.Errorf("generated source is missing %q:\n%s", want, res.Source) + } + } +} + +// Infinity has no literal of a typed attribute: it records as a string. +func TestGenerateInfinityValue(t *testing.T) { + res, err := Generate(Request{ + Package: "Records", Case: "P::check", Provenance: provenance(KindRun), + Runs: []Run{{Spell: spell(), Outputs: []runtime.CalcOutputValue{ + {Name: "value", Value: runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInfinity}}}, + }}}, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{"attribute value : ScalarValues::String;", `attribute :>> value = "*";`} { + if !strings.Contains(res.Source, want) { + t.Errorf("generated source is missing %q:\n%s", want, res.Source) + } + } + if _, err := format.Source("", []byte(res.Source), format.DefaultOptions); err != nil { + t.Errorf("generated source does not parse: %v", err) + } +} + +// The generated text is formatter-stable: formatting it changes nothing. +func TestGeneratedSourceIsFormatterStable(t *testing.T) { + for _, name := range []string{"single_run.sysml.golden", "trade_run.sysml.golden", "sweep_runs.sysml.golden"} { + src, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("%s missing; run with -update", name) + } + out, err := format.Source(name, src, format.DefaultOptions) + if err != nil { + t.Fatalf("format %s: %v", name, err) + } + if string(out) != string(src) { + t.Errorf("%s is not formatter-stable:\n%s", name, out) + } + } +} + +// A member unset in an early row and a quantity in a later one still gains +// its unit companion, settled to Real. +func TestGenerateSettlesAnUnsetMemberToQuantity(t *testing.T) { + res, err := Generate(Request{ + Package: "Records", Case: "P::check", Provenance: provenance(KindSweep), + Runs: []Run{ + {Iteration: 1, Spell: spell(), Outputs: []runtime.CalcOutputValue{ + {Name: "x", Value: runtime.Value{Kind: runtime.ValNull}}, + {Name: "a", Value: real(1)}, + {Name: "b", Value: real(2)}, + }}, + {Iteration: 2, Spell: spell(), Outputs: []runtime.CalcOutputValue{ + {Name: "x", Value: runtime.NewQuantityValue(&runtime.Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: 2.0}, + Unit: semantics.Unit{Text: "kg"}, + })}, + {Name: "a", Value: real(1)}, + {Name: "b", Value: real(2)}, + }}, + }, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{ + "attribute x : ScalarValues::Real;", + "attribute xUnit : ScalarValues::String;", + "attribute :>> x = 2.0;", + `attribute :>> xUnit = "kg";`, + } { + if !strings.Contains(res.Source, want) { + t.Errorf("generated source is missing %q:\n%s", want, res.Source) + } + } + if _, err := format.Source("", []byte(res.Source), format.DefaultOptions); err != nil { + t.Errorf("generated source does not parse: %v", err) + } +} + +// A member Real in one row and a quantity in a later one declares the unit +// companion, which only the rows with a unit redefine. +func TestGenerateSettlesARealMemberToQuantity(t *testing.T) { + res, err := Generate(Request{ + Package: "Records", Case: "P::check", Provenance: provenance(KindSweep), + Runs: []Run{ + {Iteration: 1, Spell: spell(), Outputs: []runtime.CalcOutputValue{{Name: "x", Value: real(1)}}}, + {Iteration: 2, Spell: spell(), Outputs: []runtime.CalcOutputValue{ + {Name: "x", Value: runtime.NewQuantityValue(&runtime.Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: 2.0}, + Unit: semantics.Unit{Text: "kg"}, + })}, + }}, + }, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{ + "attribute x : ScalarValues::Real;", + "attribute xUnit : ScalarValues::String;", + "attribute :>> x = 1.0;", + `attribute :>> xUnit = "kg";`, + } { + if !strings.Contains(res.Source, want) { + t.Errorf("generated source is missing %q:\n%s", want, res.Source) + } + } + if strings.Count(res.Source, "xUnit = ") != 1 { + t.Errorf("only the row with a unit should redefine xUnit:\n%s", res.Source) + } + if _, err := format.Source("", []byte(res.Source), format.DefaultOptions); err != nil { + t.Errorf("generated source does not parse: %v", err) + } +} + +// Record numbers fill the gaps a package's earlier records leave. +func TestGenerateNumbersIntoTheGaps(t *testing.T) { + res, err := Generate(Request{ + Package: "Records", Case: "P::check", Provenance: provenance(KindSweep), + Existing: Existing{ + Package: true, Definition: true, Taken: map[int]bool{2: true}, + Attributes: map[string]Feature{"load": {TypeFQN: "ScalarValues::Real"}}, + }, + Runs: []Run{ + {Iteration: 1, Spell: spell(), Outputs: []runtime.CalcOutputValue{{Name: "load", Value: real(1)}}}, + {Iteration: 2, Spell: spell(), Outputs: []runtime.CalcOutputValue{{Name: "load", Value: real(2)}}}, + }, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + want := []string{"Records::check_run1", "Records::check_run3"} + if fmt.Sprint(res.Records) != fmt.Sprint(want) { + t.Errorf("records %v, want %v", res.Records, want) + } +} + +// A case name that needs quoting is carried through quoted record names, and +// the def marks the case it records. +func TestGenerateQuotedName(t *testing.T) { + res, err := Generate(Request{ + Package: "Records", Case: "Demo::fuel budget", CaseName: "fuel budget", + Provenance: provenance(KindRun), + Runs: []Run{{Spell: spell(), Outputs: []runtime.CalcOutputValue{{Name: "y", Value: real(3)}}}}, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{ + "part def 'Fuel budgetRun' :> AnalysisRecords::AnalysisRun", + `attribute :>> caseName default = "Demo::fuel budget";`, + "part 'fuel budget_run1' : 'Fuel budgetRun'", + } { + if !strings.Contains(res.Source, want) { + t.Errorf("generated source is missing %q:\n%s", want, res.Source) + } + } + if _, err := format.Source("", []byte(res.Source), format.DefaultOptions); err != nil { + t.Errorf("generated source does not parse: %v", err) + } +} + +// An owner-prefixed stem names the definition and the records of a case whose +// short name a sibling's definition already took. +func TestGenerateOwnerStem(t *testing.T) { + res, err := Generate(Request{ + Package: "Records", Case: "Demo::B::check", + Provenance: provenance(KindRun), + Existing: Existing{Stem: "B_check"}, + Runs: []Run{{Spell: spell(), Outputs: []runtime.CalcOutputValue{{Name: "y", Value: real(2)}}}}, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{ + "part def B_checkRun :> AnalysisRecords::AnalysisRun", + "part B_check_run1 : B_checkRun", + } { + if !strings.Contains(res.Source, want) { + t.Errorf("generated source is missing %q:\n%s", want, res.Source) + } + } +} + +// A member declared ScalarValue by an earlier, unset run accepts a concrete +// type the next run settles it to. +func TestGenerateExistingScalarValueAcceptsASettledType(t *testing.T) { + res, err := Generate(Request{ + Package: "Records", Case: "P::check", Provenance: provenance(KindRun), + Existing: Existing{ + Package: true, Definition: true, + Attributes: map[string]Feature{"x": {TypeFQN: "ScalarValues::ScalarValue"}}, + Stem: "check", + }, + Runs: []Run{{Spell: spell(), Outputs: []runtime.CalcOutputValue{{Name: "x", Value: real(2)}}}}, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if !strings.Contains(res.Source, "attribute :>> x = 2.0;") { + t.Errorf("generated source is missing the redefinition:\n%s", res.Source) + } +} + +// A verification run records what its body and subcases decided: the body's +// verdict beside the case's features, each verdict a VerdictRecord row. +func TestGenerateVerificationRun(t *testing.T) { + res, err := Generate(Request{ + Package: "P::Records", Case: "P::fire", Provenance: provenance(KindRun), + Runs: []Run{{ + Outputs: []runtime.CalcOutputValue{{Name: "margin", Value: real(-200.0)}}, + Verdicts: []runtime.AnalysisVerdict{{Kind: "objective", Name: "thrust", Status: runtime.VerdictNotSatisfied}}, + Verifications: []runtime.VerificationVerdict{ + {Case: "P::fire", Kind: runtime.VerdictFail}, + {Case: "P::cold", Kind: runtime.VerdictPass, Subcase: true}, + {Case: "P::hot", Kind: runtime.VerdictInconclusive, Subcase: true, Detail: "no data"}, + }, + Spell: spell(), + }}, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{ + `attribute :>> verdict = "fail";`, + `part verification1 : AnalysisRecords::VerdictRecord :> verdicts {`, + `attribute :>> kind = "verification";`, + `attribute :>> name = "P::fire";`, + `attribute :>> status = "fail";`, + `part verification2`, + `attribute :>> kind = "subcase";`, + `attribute :>> name = "P::cold";`, + `attribute :>> status = "pass";`, + `part verification3`, + `attribute :>> status = "inconclusive";`, + `attribute :>> detail = "no data";`, + } { + if !strings.Contains(res.Source, want) { + t.Errorf("source is missing %q:\n%s", want, res.Source) + } + } + reparsed := parser.New(source.New("", []byte(res.Source))) + reparsed.ParseFile() + if len(reparsed.Diagnostics) > 0 { + t.Fatalf("generated source does not parse: %v\n%s", reparsed.Diagnostics[0], res.Source) + } +} + +// A run that decided only verification verdicts is still a record worth +// making: verdicts count toward "nothing to record". +func TestGenerateVerificationOnlyRun(t *testing.T) { + res, err := Generate(Request{ + Package: "P::Records", Case: "P::fire", Provenance: provenance(KindRun), + Runs: []Run{{ + Verifications: []runtime.VerificationVerdict{{Case: "P::fire", Kind: runtime.VerdictPass}}, + Spell: spell(), + }}, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if !strings.Contains(res.Source, `attribute :>> verdict = "pass";`) { + t.Errorf("source is missing the verdict:\n%s", res.Source) + } +} + +// An inout records as one member carrying the value the run left in it, +// plus an In companion carrying the value it was bound with. +func TestGenerateInoutRun(t *testing.T) { + res, err := Generate(Request{ + Package: "P::Records", Case: "P::count", Provenance: provenance(KindRun), + Runs: []Run{{ + Inputs: []runtime.InputBinding{{Name: "counter", Value: integer(3)}}, + Outputs: []runtime.CalcOutputValue{{Name: "counter", Value: integer(6)}, {Name: "doubled", Value: integer(6)}}, + Spell: spell(), + }}, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{ + "attribute counter : ScalarValues::Integer;", + "attribute counterIn : ScalarValues::Integer;", + "attribute doubled : ScalarValues::Integer;", + "attribute :>> counter = 6;", + "attribute :>> counterIn = 3;", + "attribute :>> doubled = 6;", + } { + if !strings.Contains(res.Source, want) { + t.Errorf("source is missing %q:\n%s", want, res.Source) + } + } + // The In companion follows its value member. + if strings.Index(res.Source, "attribute counterIn") < strings.Index(res.Source, "attribute counter :") { + t.Errorf("the In companion precedes its member:\n%s", res.Source) + } + reparsed := parser.New(source.New("", []byte(res.Source))) + reparsed.ParseFile() + if len(reparsed.Diagnostics) > 0 { + t.Fatalf("generated source does not parse: %v\n%s", reparsed.Diagnostics[0], res.Source) + } +} + +// A scalar-valued enumeration literal records as the literal it is, not the +// scalar it equals. +func TestGenerateScalarValuedEnumLiteral(t *testing.T) { + file := parser.New(source.New("", []byte( + `package P { enum def Grade { enum high = 3; enum low = 1; } }`))).ParseFile() + scope := symbols.Build(file) + p, _ := scope.LookupLocal("P") + grade, _ := p.Scope.LookupLocal("Grade") + high, ok := grade.Scope.LookupLocal("high") + if !ok { + t.Fatal("enum literal not built") + } + res, err := Generate(Request{ + Package: "P::Records", Case: "P::mix", Provenance: provenance(KindRun), + Runs: []Run{{ + Outputs: []runtime.CalcOutputValue{ + {Name: "g", Value: runtime.EnumeratedValue(high, integer(3))}, + {Name: "half", Value: real(1.5)}, + }, + Spell: spell(), + }}, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{ + "attribute g : P::Grade;", + "attribute :>> g = P::Grade::high;", + } { + if !strings.Contains(res.Source, want) { + t.Errorf("source is missing %q:\n%s", want, res.Source) + } + } +} + +// Integer and Real are one numeric family for the record definition: rows of +// either settle the member to Real, each literal staying its own. +func TestGenerateNumericFamilySettlesToReal(t *testing.T) { + res, err := Generate(Request{ + Package: "P::Records", Case: "P::mix", Provenance: provenance(KindSweep), + Runs: []Run{ + {Outputs: []runtime.CalcOutputValue{{Name: "half", Value: integer(3)}}, Spell: spell()}, + {Outputs: []runtime.CalcOutputValue{{Name: "half", Value: real(3.5)}}, Spell: spell()}, + }, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{ + "attribute half : ScalarValues::Real;", + "attribute :>> half = 3;", + "attribute :>> half = 3.5;", + } { + if !strings.Contains(res.Source, want) { + t.Errorf("source is missing %q:\n%s", want, res.Source) + } + } +} + +// A declared Real member accepts Integer values — Integer specializes it — +// while a declared Integer member cannot take a Real back. +func TestGenerateExistingRealAcceptsAnInteger(t *testing.T) { + req := Request{ + Package: "P::Records", Case: "P::mix", Provenance: provenance(KindRun), + Existing: Existing{ + Package: true, Definition: true, + Attributes: map[string]Feature{"half": {TypeFQN: "ScalarValues::Real"}}, + }, + Runs: []Run{{ + Outputs: []runtime.CalcOutputValue{{Name: "half", Value: integer(3)}}, + Spell: spell(), + }}, + } + if _, err := Generate(req); err != nil { + t.Fatalf("an Integer under a declared Real: %v", err) + } + req.Existing.Attributes["half"] = Feature{TypeFQN: "ScalarValues::Integer"} + req.Runs[0].Outputs[0].Value = real(3.5) + if _, err := Generate(req); err == nil { + t.Error("a Real under a declared Integer: want an error") + } +} + +// A quantity member takes a plain-number row in either order: the row keeps +// its literal and takes no unit. +func TestGenerateQuantityAndPlainNumbers(t *testing.T) { + kg := runtime.NewQuantityValue(&runtime.Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: 3.0}, + Unit: semantics.Unit{Text: "kg"}, + }) + orders := map[string][]runtime.Value{ + "integer then quantity": {integer(2), kg}, + "quantity then integer": {kg, integer(2)}, + } + for name, values := range orders { + var runs []Run + for _, v := range values { + runs = append(runs, Run{ + Outputs: []runtime.CalcOutputValue{{Name: "x", Value: v}}, + Spell: spell(), + }) + } + res, err := Generate(Request{ + Package: "P::Records", Case: "P::mix", Provenance: provenance(KindSweep), Runs: runs, + }) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + for _, want := range []string{ + "attribute x : ScalarValues::Real;", + "attribute xUnit : ScalarValues::String;", + "attribute :>> x = 2;", + "attribute :>> x = 3.0;", + `attribute :>> xUnit = "kg";`, + } { + if !strings.Contains(res.Source, want) { + t.Errorf("%s: source is missing %q:\n%s", name, want, res.Source) + } + } + if strings.Count(res.Source, "xUnit = ") != 1 { + t.Errorf("%s: a plain-number row wrote a unit:\n%s", name, res.Source) + } + } +} + +// An inout whose two sides mix a quantity with a plain number settles both +// members to the quantity shape, each with its unit companion declared. +func TestGenerateInoutQuantityAndPlainNumber(t *testing.T) { + kg := runtime.NewQuantityValue(&runtime.Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: 3.0}, + Unit: semantics.Unit{Text: "kg"}, + }) + sides := map[string][2]runtime.Value{ + "integer out, quantity in": {integer(2), kg}, + "real out, quantity in": {real(2.5), kg}, + "quantity out, integer in": {kg, integer(2)}, + "quantity out, real in": {kg, real(2.5)}, + } + for name, v := range sides { + res, err := Generate(Request{ + Package: "P::Records", Case: "P::mix", Provenance: provenance(KindRun), + Runs: []Run{{ + Inputs: []runtime.InputBinding{{Name: "x", Value: v[1]}}, + Outputs: []runtime.CalcOutputValue{{Name: "x", Value: v[0]}}, + Spell: spell(), + }}, + }) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + for _, want := range []string{ + "attribute x : ScalarValues::Real;", + "attribute xUnit : ScalarValues::String;", + "attribute xIn : ScalarValues::Real;", + "attribute xInUnit : ScalarValues::String;", + `Unit = "kg";`, + } { + if !strings.Contains(res.Source, want) { + t.Errorf("%s: source is missing %q:\n%s", name, want, res.Source) + } + } + if strings.Count(res.Source, `Unit = "kg";`) != 1 { + t.Errorf("%s: want exactly one unit redefinition:\n%s", name, res.Source) + } + } +} diff --git a/internal/exec/analysis/record/testdata/single_run.sysml.golden b/internal/exec/analysis/record/testdata/single_run.sysml.golden new file mode 100644 index 0000000000..157351d91f --- /dev/null +++ b/internal/exec/analysis/record/testdata/single_run.sysml.golden @@ -0,0 +1,50 @@ +package Records { + part def ScoutBudgetRun :> AnalysisRecords::AnalysisRun { + attribute :>> caseName default = "P::scoutBudget"; + attribute burnTime : ScalarValues::Real; + attribute trials : ScalarValues::Integer; + attribute crewOk : ScalarValues::Boolean; + attribute label : ScalarValues::String; + attribute grade : P::Fuel; + attribute tank : ScalarValues::Real; + attribute tankUnit : ScalarValues::String; + ref part target; + attribute fuelUsed : ScalarValues::Real; + attribute memo : ScalarValues::ScalarValue; + attribute plan : ScalarValues::String; + } + part scoutBudget_run1 : ScoutBudgetRun { + @AnalysisRecords::RecordedRun { + runAt = "2026-01-01T00:00:00Z"; + tool = "sysml test"; + command = "%record P::check"; + kind = "run"; + } + attribute :>> caseName = "P::scoutBudget"; + attribute :>> kind = "run"; + attribute :>> 'objective' = "satisfied"; + ref :>> 'subject' = P::scout; + attribute :>> subjectName = "P::scout"; + attribute :>> burnTime = 3.0; + attribute :>> trials = 8; + attribute :>> crewOk = true; + attribute :>> label = "a \"b\""; + attribute :>> grade = P::Fuel::leaded; + attribute :>> tank = 12.5; + attribute :>> tankUnit = "kg"; + ref :>> target = P::scout; + attribute :>> fuelUsed = 12.5; + attribute :>> plan = "[]"; + part verdict1 : AnalysisRecords::VerdictRecord :> verdicts { + attribute :>> kind = "objective"; + attribute :>> name = "fuelFits"; + attribute :>> status = "satisfied"; + attribute :>> detail = "holds"; + } + part verdict2 : AnalysisRecords::VerdictRecord :> verdicts { + attribute :>> kind = "assertion"; + attribute :>> name = "crewReady"; + attribute :>> status = "not satisfied"; + } + } +} diff --git a/internal/exec/analysis/record/testdata/sweep_runs.sysml.golden b/internal/exec/analysis/record/testdata/sweep_runs.sysml.golden new file mode 100644 index 0000000000..294e8da3ed --- /dev/null +++ b/internal/exec/analysis/record/testdata/sweep_runs.sysml.golden @@ -0,0 +1,44 @@ +package Records { + part check_run4 : CheckRun { + @AnalysisRecords::RecordedRun { + runAt = "2026-01-01T00:00:00Z"; + tool = "sysml test"; + command = "%record P::check"; + kind = "sweep"; + } + attribute :>> caseName = "P::check"; + attribute :>> kind = "sweep"; + attribute :>> 'objective' = "undecided"; + attribute :>> iteration = 1; + attribute :>> load = 1.0; + attribute :>> done = true; + } + part check_run5 : CheckRun { + @AnalysisRecords::RecordedRun { + runAt = "2026-01-01T00:00:00Z"; + tool = "sysml test"; + command = "%record P::check"; + kind = "sweep"; + } + attribute :>> caseName = "P::check"; + attribute :>> kind = "sweep"; + attribute :>> 'objective' = "undecided"; + attribute :>> iteration = 2; + attribute :>> load = 3.0; + attribute :>> done = false; + } + part check_run6 : CheckRun { + @AnalysisRecords::RecordedRun { + runAt = "2026-01-01T00:00:00Z"; + tool = "sysml test"; + command = "%record P::check"; + kind = "sweep"; + } + attribute :>> caseName = "P::check"; + attribute :>> kind = "sweep"; + attribute :>> 'objective' = "undecided"; + attribute :>> iteration = 3; + attribute :>> load = 5.0; + attribute :>> done = true; + } +} diff --git a/internal/exec/analysis/record/testdata/trade_run.sysml.golden b/internal/exec/analysis/record/testdata/trade_run.sysml.golden new file mode 100644 index 0000000000..c3c3ca34fa --- /dev/null +++ b/internal/exec/analysis/record/testdata/trade_run.sysml.golden @@ -0,0 +1,44 @@ +package P { + package Records { + part def ChooseRun :> AnalysisRecords::AnalysisRun { + attribute :>> caseName default = "P::choose"; + attribute best : ScalarValues::Real; + } + part choose_run1 : ChooseRun { + @AnalysisRecords::RecordedRun { + runAt = "2026-01-01T00:00:00Z"; + tool = "sysml test"; + command = "%record P::check"; + kind = "trade"; + } + attribute :>> caseName = "P::choose"; + attribute :>> kind = "trade"; + attribute :>> 'objective' = "undecided"; + attribute :>> subjectName = "P::fleet"; + attribute :>> best = 1.0; + part evaluation1 : AnalysisRecords::EvaluationRecord :> evaluations { + attribute :>> 'function' = "P::score"; + attribute :>> alternative = "1.0"; + attribute :>> score = 0.75; + attribute :>> result = "0.75"; + attribute :>> selected = true; + attribute :>> tied = false; + } + part evaluation2 : AnalysisRecords::EvaluationRecord :> evaluations { + attribute :>> 'function' = "P::score"; + attribute :>> alternative = "2.0"; + attribute :>> score = 0.75; + attribute :>> result = "0.75"; + attribute :>> selected = false; + attribute :>> tied = true; + } + part evaluation3 : AnalysisRecords::EvaluationRecord :> evaluations { + attribute :>> 'function' = "P::score"; + attribute :>> alternative = "3.0"; + attribute :>> selected = false; + attribute :>> tied = false; + attribute :>> error = "boom"; + } + } + } +} diff --git a/internal/core/analysis/registry.go b/internal/exec/analysis/registry.go similarity index 100% rename from internal/core/analysis/registry.go rename to internal/exec/analysis/registry.go diff --git a/internal/exec/analysis/registry_test.go b/internal/exec/analysis/registry_test.go new file mode 100644 index 0000000000..b8991ab1d7 --- /dev/null +++ b/internal/exec/analysis/registry_test.go @@ -0,0 +1,216 @@ +package analysis + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" +) + +// fakeEngine is an engine scripted for one test: what it covers, what it +// answers, and the fault it reports; run, when set, answers in place of the script. +type fakeEngine struct { + name string + kinds []Kind + authority Strength + refusal error + result Result + fault error + ran *int + run func(context.Context) (Result, error) +} + +func (e fakeEngine) Name() string { return e.name } + +func (e fakeEngine) Describe() Description { + return Description{Questions: e.kinds, Authority: e.authority} +} + +func (e fakeEngine) Covers(*Model, Question) Coverage { + if e.refusal != nil { + return refused(e.refusal) + } + return covered +} + +func (e fakeEngine) Run(ctx context.Context, _ *Model, q Question, _ Budget) (Result, error) { + if e.ran != nil { + *e.ran++ + } + if e.fault != nil { + return Result{}, e.fault + } + result := e.result + if e.run != nil { + var err error + if result, err = e.run(ctx); err != nil { + return Result{}, err + } + } + result.Question, result.Engine = q, e.name + return result, nil +} + +// registered builds a registry of the engines, failing the test on a refusal. +func registered(t *testing.T, engines ...Engine) *Registry { + t.Helper() + r := NewRegistry() + for _, e := range engines { + if err := r.Register(e); err != nil { + t.Fatalf("register %s: %v", e.Name(), err) + } + } + return r +} + +// names lists the registry's engines as Engines orders them. +func names(r *Registry) []string { + var out []string + for _, e := range r.Engines() { + out = append(out, e.Name()) + } + return out +} + +func TestRegisterDuplicateIsATypedError(t *testing.T) { + r := registered(t, fakeEngine{name: "one"}) + err := r.Register(fakeEngine{name: "one"}) + var dup *DuplicateEngineError + if !errors.As(err, &dup) || dup.Name != "one" || !errors.Is(err, ErrDuplicateEngine) { + t.Fatalf("second registration of one: %v, want DuplicateEngineError naming it", err) + } + if got := names(r); len(got) != 1 { + t.Fatalf("engines %v, want the first registration alone", got) + } +} + +func TestEnginesAreInNameOrder(t *testing.T) { + r := registered(t, fakeEngine{name: "solve"}, fakeEngine{name: "explore"}, fakeEngine{name: "run"}, fakeEngine{name: "check"}) + want := []string{"check", "explore", "run", "solve"} + for i, got := range names(r) { + if got != want[i] { + t.Fatalf("engines %v, want %v", names(r), want) + } + } + if len(names(r)) != len(want) { + t.Fatalf("engines %v, want %v", names(r), want) + } +} + +func TestRegistriesDoNotSeeEachOther(t *testing.T) { + first := registered(t, fakeEngine{name: "one"}) + second := registered(t, fakeEngine{name: "two"}) + if got := names(first); len(got) != 1 || got[0] != "one" { + t.Fatalf("first registry %v, want [one]", got) + } + if got := names(second); len(got) != 1 || got[0] != "two" { + t.Fatalf("second registry %v, want [two]", got) + } + if err := second.Register(fakeEngine{name: "one"}); err != nil { + t.Fatalf("one in the second registry: %v, want no clash with the first", err) + } +} + +// Default holds the framework's own engines; the build's registry, with smt +// beside them, is composed in internal/exec/engines. +func TestDefaultHoldsTheFrameworksEngines(t *testing.T) { + want := []string{CheckEngineName, ExploreEngineName, RunEngineName, SolveEngineName, SweepEngineName} + got := names(Default()) + if len(got) != len(want) { + t.Fatalf("default engines %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("default engines %v, want %v", got, want) + } + } + if err := Default().Register(fakeEngine{name: "one"}); err != nil { + t.Fatalf("registering into a default registry: %v", err) + } +} + +// The framework's registry puts a holds question to check alone, which refuses +// one starting no action; the plan is then not covered, naming that refusal. +func TestDefaultPutsHoldsToCheckAlone(t *testing.T) { + plan, err := Default().Answer(context.Background(), &Model{}, Question{Kind: Holds, Free: FreeSchedule, Holds: &HoldsAsk{}}, Budget{}) + if err != nil { + t.Fatalf("holds under the default registry: %v, want a plan check refused", err) + } + if len(plan.Steps) != 1 || plan.Steps[0].Engine != CheckEngineName || !errors.Is(plan.Steps[0].Refusal, ErrMalformedQuestion) { + t.Fatalf("steps %+v, want check alone refusing the question as malformed", plan.Steps) + } + if plan.Result.Strength != NotCovered || !strings.Contains(plan.Result.Reason, "a Check starting an invocation") { + t.Fatalf("result %s %q, want not covered for want of a Check", plan.Result.Strength, plan.Result.Reason) + } +} + +// absentSolver discovers nothing, as a machine without a solver does. +func absentSolver() (*solve.Solver, error) { + return nil, &solve.NoSolverError{Looked: []string{"z3", "cvc5"}} +} + +func TestAbsentProcessListsAndRefuses(t *testing.T) { + r := registered(t, NewRun(), NewSolve(absentSolver)) + statuses := r.Statuses() + if len(statuses) != 2 || statuses[0].Engine != RunEngineName || statuses[1].Engine != SolveEngineName { + t.Fatalf("statuses %v, want run then solve", statuses) + } + if statuses[0].Err != nil || statuses[0].Process != "" { + t.Fatalf("run status %+v, want an in-process engine", statuses[0]) + } + var absent *ProcessAbsentError + if !errors.As(statuses[1].Err, &absent) || !errors.Is(statuses[1].Err, ErrProcessAbsent) || !errors.Is(statuses[1].Err, solve.ErrNoSolver) { + t.Fatalf("solve status %v, want ProcessAbsentError wrapping the solver's absence", statuses[1].Err) + } + if absent.Engine != SolveEngineName || absent.Process != SolveProcess { + t.Fatalf("absence %+v, want solve's need named", absent) + } + q := Question{Kind: Satisfiable, Subject: "C", Free: FreeInputs, Solve: &SolveAsk{Queries: []*solve.Query{{Element: "C"}}, Ask: (*solve.Solver).Solve}} + coverage := r.Engines()[1].Covers(nil, q) + if coverage.Covered || !errors.Is(coverage.Refusal, ErrProcessAbsent) { + t.Fatalf("covers %+v, want a ProcessAbsentError refusal", coverage) + } + plan := answered(t, r, nil, q, Budget{}) + if err := plan.Refused(); !errors.Is(err, ErrProcessAbsent) { + t.Fatalf("plan refused %v, want the solver's absence", err) + } + if plan.Result.Covered() || plan.Result.Reason == "" { + t.Fatalf("result %+v, want not covered with the refusal as reason", plan.Result) + } +} + +func TestPresentProcessIsListed(t *testing.T) { + present := func() (*solve.Solver, error) { return &solve.Solver{Name: "z3", Path: "/usr/bin/z3"}, nil } + statuses := registered(t, NewSolve(present)).Statuses() + if len(statuses) != 1 || statuses[0].Err != nil || statuses[0].Process != "z3 at /usr/bin/z3" { + t.Fatalf("statuses %+v, want z3 at /usr/bin/z3", statuses) + } +} + +func TestEnginesForAKindFollowRegistration(t *testing.T) { + r := registered(t, fakeEngine{name: "run", kinds: []Kind{Evaluate}, authority: Observed}) + if got := r.ranked(Evaluate); len(got) != 1 || got[0].Name() != "run" { + t.Fatalf("ranked(Evaluate) = %d engines, want [run]", len(got)) + } + if err := r.Register(fakeEngine{name: "solve", kinds: []Kind{Evaluate}, authority: Proved}); err != nil { + t.Fatalf("register solve: %v", err) + } + ranked := r.ranked(Evaluate) + if len(ranked) != 2 || ranked[0].Name() != "solve" || ranked[1].Name() != "run" { + t.Fatalf("ranked(Evaluate) after registering solve = %v, want [solve run]", engineNames(ranked)) + } + declaring := r.declaring(Evaluate) + if len(declaring) != 2 || declaring[0].Name() != "run" || declaring[1].Name() != "solve" { + t.Fatalf("declaring(Evaluate) = %v, want [run solve]", engineNames(declaring)) + } +} + +func engineNames(engines []Engine) []string { + var out []string + for _, e := range engines { + out = append(out, e.Name()) + } + return out +} diff --git a/internal/core/analysis/result.go b/internal/exec/analysis/result.go similarity index 98% rename from internal/core/analysis/result.go rename to internal/exec/analysis/result.go index 88a50551ea..13336707b3 100644 --- a/internal/core/analysis/result.go +++ b/internal/exec/analysis/result.go @@ -6,8 +6,8 @@ import ( "strings" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // Claim is what a result asserts about its question. @@ -225,7 +225,9 @@ func (b Bounds) String() string { type Witness struct { Schedule runtime.SchedulePolicy Inputs []runtime.InputTaken - Choices []runtime.ChoiceTaken + // Draws are the random values the execution drew, in the order drawn. + Draws []runtime.DrawTaken + Choices []runtime.ChoiceTaken // Written is the file the witness was written to, "" when none was asked for. Written string } diff --git a/internal/exec/analysis/run.go b/internal/exec/analysis/run.go new file mode 100644 index 0000000000..22c9a7610f --- /dev/null +++ b/internal/exec/analysis/run.go @@ -0,0 +1,101 @@ +package analysis + +import ( + "context" + "errors" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// RunEngineName is the name of the engine that runs the interpreter once. +const RunEngineName = "run" + +// runEngine answers Evaluate questions with one execution of the interpreter under the +// question's scheduling policy, in the surface's own context or one of the run's own. +type runEngine struct{} + +// NewRun returns the run engine. +func NewRun() Engine { return runEngine{} } + +// Name is `run`. +func (runEngine) Name() string { return RunEngineName } + +// Describe: one execution, so its evidence is at most observed; a violation it +// finds is the replay of itself. +func (runEngine) Describe() Description { + return Description{ + Questions: []Kind{Evaluate}, + Bounds: []string{"steps", "elements"}, + Replays: true, + Authority: Observed, + } +} + +// Covers takes any Evaluate question that fixes every choice and says how to perform it. +func (e runEngine) Covers(_ *Model, q Question) Coverage { + if q.Kind != Evaluate { + return refused(&NotAskedError{Engine: e.Name(), Kind: q.Kind}) + } + if q.Free != FreeNothing { + return refused(&FreedomError{Engine: e.Name(), Free: q.Free}) + } + if q.Perform == nil { + return refused(&MalformedQuestionError{Kind: q.Kind, Missing: "a Perform"}) + } + return covered +} + +// Run performs the question once, in the surface's context or one of the run's own under the +// budget: violated is witnessed, else observed; a failed execution claims nothing, naming the bound. +// A model with neither context is the typed fault NoRuntimeError. +func (e runEngine) Run(ctx context.Context, model *Model, q Question, budget Budget) (Result, error) { + if err := ctx.Err(); err != nil { + return Result{}, err + } + rctx, err := model.running(e.Name(), budget, q.ModelSeed, q.Draws, q.ClockStep) + if err != nil { + return Result{}, err + } + started := time.Now() + answer, err := q.Perform(rctx) + if err != nil { + return Result{}, err + } + result := Result{ + Question: q, + Engine: e.Name(), + Claim: answer.Claim, + Reason: answer.Reason, + Values: answer.Values, + Bounds: runBounds(rctx.Budgets(), answer.Err), + Elapsed: time.Since(started), + } + switch answer.Claim { + case ClaimNone: + result.Strength = NotCovered + if result.Reason == "" && answer.Err != nil { + result.Reason = answer.Err.Error() + } + case ClaimViolated: + result.Strength = Witnessed + result.Witness = &Witness{Schedule: q.Schedule} + default: + result.Strength = Observed + } + return result, nil +} + +// runBounds is the step and element limits a run had, and which one the +// execution's failure reached. +func runBounds(limits runtime.Budgets, failure error) Bounds { + steps := errors.Is(failure, runtime.ErrStepLimitExceeded) || + errors.Is(failure, runtime.ErrActionStepLimitExceeded) || + errors.Is(failure, runtime.ErrStateEventLimitExceeded) || + errors.Is(failure, runtime.ErrDoStepLimitExceeded) + elements := errors.Is(failure, runtime.ErrElementLimitExceeded) + return Bounds{ + {Name: "steps", Limit: limits.MaxSteps, Reached: steps}, + {Name: "elements", Limit: limits.MaxElements, Reached: elements}, + } +} diff --git a/internal/exec/analysis/run_test.go b/internal/exec/analysis/run_test.go new file mode 100644 index 0000000000..a947ddde07 --- /dev/null +++ b/internal/exec/analysis/run_test.go @@ -0,0 +1,251 @@ +package analysis + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// evaluate asks the default registry for one execution in the fixture's context. +func evaluate(t *testing.T, f *fixture, perform Performance) Plan { + t.Helper() + q := Question{Kind: Evaluate, Subject: "test::Double", Schedule: runtime.DefaultSchedulePolicy, Perform: perform} + return answered(t, Default(), Held(f.context(t)), q, Budget{}) +} + +func TestRunObservesAValue(t *testing.T) { + f := parseFixture(t) + double := f.symbol(t, "Double") + plan := evaluate(t, f, func(ctx *runtime.Context) (Answer, error) { + value, err := ctx.InvokeCalc(double, []runtime.Value{intOf(21)}, f.pkg) + return ValuesAnswer([]Evaluation{{Name: "result", Value: value}}, err), nil + }) + result := plan.Result + if result.Engine != RunEngineName || result.Claim != ClaimValue || result.Strength != Observed { + t.Fatalf("result %+v, want run's observed value", result) + } + if len(result.Values) != 1 || result.Values[0].Value.Const.Int != 42 { + t.Fatalf("values %+v, want result = 42", result.Values) + } + if steps, ok := result.Bounds.Limit("steps"); !ok || steps != 10000 || result.Bounds.Reached() { + t.Fatalf("bounds %s, want steps=10000 unreached", result.Bounds) + } + if result.Witness != nil { + t.Fatalf("witness %+v, want none for a value", result.Witness) + } +} + +func TestRunGradesChecks(t *testing.T) { + f := parseFixture(t) + holds := evaluate(t, f, func(*runtime.Context) (Answer, error) { + return CheckAnswer(runtime.CheckResult{Holds: true}, nil), nil + }).Result + if holds.Claim != ClaimHolds || holds.Strength != Observed || holds.Witness != nil { + t.Fatalf("holding check %+v, want holds observed", holds) + } + violated := evaluate(t, f, func(*runtime.Context) (Answer, error) { + return CheckAnswer(runtime.CheckResult{Holds: false}, nil), nil + }).Result + if violated.Claim != ClaimViolated || violated.Strength != Witnessed { + t.Fatalf("violated check %+v, want violated witnessed", violated) + } + if violated.Witness == nil || violated.Witness.Schedule != runtime.DefaultSchedulePolicy { + t.Fatalf("witness %+v, want the run's own schedule", violated.Witness) + } +} + +// A false verdict reaches CheckAnswer as a *runtime.ViolationError, which is a +// witnessed violation and not a failed execution. +func TestRunWitnessesARuntimeViolation(t *testing.T) { + f := parseFixture(t) + tank := f.symbol(t, "Tank") + rctx := f.context(t) + inst, err := rctx.Instantiate(tank) + if err != nil { + t.Fatalf("instantiate Tank: %v", err) + } + check := func(name string) Plan { + sym, ok := tank.Scope.LookupLocal(name) + if !ok { + t.Fatalf("constraint %s not indexed", name) + } + return answered(t, Default(), Held(rctx), Question{ + Kind: Evaluate, Subject: "test::Tank::" + name, Schedule: runtime.DefaultSchedulePolicy, + Perform: func(rctx *runtime.Context) (Answer, error) { + return CheckAnswer(rctx.CheckConstraintOn(sym, tank.Scope, inst)), nil + }, + }, Budget{}) + } + low := check("low").Result + if low.Claim != ClaimHolds || low.Strength != Observed { + t.Fatalf("low %+v, want holds observed", low) + } + high := check("high").Result + if high.Claim != ClaimViolated || high.Strength != Witnessed || high.Witness == nil { + t.Fatalf("high %+v, want violated witnessed", high) + } + if !strings.Contains(high.Reason, "pressure > 100.0") { + t.Fatalf("reason %q, want the failed condition", high.Reason) + } + if high.Bounds.Reached() { + t.Fatalf("bounds %s, want no budget reached by a violation", high.Bounds) + } + + wrapped := fmt.Errorf("check: %w", &runtime.ViolationError{Kind: "constraint", Element: "high", What: "assertion", Condition: "pressure > 100.0"}) + if a := CheckAnswer(runtime.CheckResult{}, wrapped); a.Claim != ClaimViolated || a.Err != nil || a.Reason != wrapped.Error() { + t.Fatalf("wrapped violation %+v, want violated with the violation as reason", a) + } +} + +func TestRunFailureClaimsNothingAndNamesTheBound(t *testing.T) { + f := parseFixture(t) + failed := fmt.Errorf("spin: %w", runtime.ErrActionStepLimitExceeded) + plan := evaluate(t, f, func(*runtime.Context) (Answer, error) { + return ValuesAnswer(nil, failed), nil + }) + result := plan.Result + if result.Covered() || result.Claim != ClaimNone || result.Reason != failed.Error() { + t.Fatalf("result %+v, want not covered with the failure as reason", result) + } + if !result.Bounds.Reached() { + t.Fatalf("bounds %s, want steps reached", result.Bounds) + } + for _, b := range result.Bounds { + if b.Reached != (b.Name == "steps") { + t.Fatalf("bounds %s, want steps alone reached", result.Bounds) + } + } + if len(plan.Steps) != 1 || plan.Steps[0].Result == nil { + t.Fatalf("steps %v, want run's result kept", stepNames(plan)) + } +} + +func TestRunFaultStopsThePlan(t *testing.T) { + f := parseFixture(t) + fault := errors.New("the subject did not resolve") + q := Question{Kind: Evaluate, Subject: "test::Missing", Schedule: runtime.DefaultSchedulePolicy, Perform: func(*runtime.Context) (Answer, error) { + return Answer{}, fault + }} + plan, err := Default().Answer(context.Background(), Held(f.context(t)), q, Budget{}) + if !errors.Is(err, fault) || len(plan.Steps) != 1 || !errors.Is(plan.Steps[0].Err, fault) { + t.Fatalf("answer: %v, steps %+v; want the fault stopping the plan", err, plan.Steps) + } +} + +func TestRunRefusesWhatItCannotFix(t *testing.T) { + e := NewRun() + if c := e.Covers(nil, Question{Kind: Outcomes}); c.Covered || !errors.Is(c.Refusal, ErrNotAsked) { + t.Fatalf("outcomes: %+v, want not asked", c) + } + if c := e.Covers(nil, Question{Kind: Evaluate, Free: FreeSchedule, Perform: func(*runtime.Context) (Answer, error) { return Answer{}, nil }}); c.Covered || !errors.Is(c.Refusal, ErrFreedom) { + t.Fatalf("free schedule: %+v, want a freedom refusal", c) + } + if c := e.Covers(nil, Question{Kind: Evaluate}); c.Covered || !errors.Is(c.Refusal, ErrMalformedQuestion) { + t.Fatalf("no perform: %+v, want malformed", c) + } +} + +func TestPerformReturnsWhatTheCallProduced(t *testing.T) { + f := parseFixture(t) + double := f.symbol(t, "Double") + ctx := f.context(t) + value, _, err := Perform(context.Background(), Default(), request(Held(ctx), "test::Double", ctx.Schedule()), + func(rt *runtime.Context) (runtime.Value, error) { + return rt.InvokeCalc(double, []runtime.Value{intOf(4)}, f.pkg) + }, + func(v runtime.Value, err error) Answer { + return ValuesAnswer([]Evaluation{{Name: "result", Value: v}}, err) + }, + ) + if err != nil || value.Const.Int != 8 { + t.Fatalf("perform: %v = %+v, want 8", err, value) + } + failed := errors.New("unbound parameter") + _, _, err = Perform(context.Background(), Default(), request(Held(ctx), "test::Double", ctx.Schedule()), + func(*runtime.Context) (runtime.Value, error) { return runtime.Value{}, failed }, + func(v runtime.Value, err error) Answer { return ValuesAnswer(nil, err) }, + ) + if !errors.Is(err, failed) { + t.Fatalf("perform: %v, want the execution's own failure back", err) + } +} + +func TestPerformReportsARefusal(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + r := registered(t, NewExplore()) + _, _, err := Perform(context.Background(), r, request(Held(ctx), "test::Double", ctx.Schedule()), + func(*runtime.Context) (int, error) { return 1, nil }, + func(int, error) Answer { return Answer{Claim: ClaimValue} }, + ) + if !errors.Is(err, ErrNoEngine) { + t.Fatalf("perform without run: %v, want no engine", err) + } + r = registered(t, fakeEngine{name: "picky", kinds: []Kind{Evaluate}, authority: Observed, refusal: errFixtureRefusal}) + _, _, err = Perform(context.Background(), r, request(Held(ctx), "test::Double", ctx.Schedule()), + func(*runtime.Context) (int, error) { return 1, nil }, + func(int, error) Answer { return Answer{Claim: ClaimValue} }, + ) + if !errors.Is(err, errFixtureRefusal) { + t.Fatalf("perform on a refusing engine: %v, want its refusal", err) + } +} + +func TestAnswersOfRuns(t *testing.T) { + failed := errors.New("failed") + if a := VerificationAnswer(runtime.VerificationResult{Verdict: runtime.VerificationVerdict{Kind: runtime.VerdictPass}}, nil); a.Claim != ClaimHolds { + t.Fatalf("pass %+v, want holds", a) + } + if a := VerificationAnswer(runtime.VerificationResult{Verdict: runtime.VerificationVerdict{Kind: runtime.VerdictFail, Detail: "x > 1"}}, nil); a.Claim != ClaimViolated || a.Reason != "x > 1" { + t.Fatalf("fail %+v, want violated with the detail", a) + } + if a := VerificationAnswer(runtime.VerificationResult{Verdict: runtime.VerificationVerdict{Kind: runtime.VerdictInconclusive}}, nil); a.Claim != ClaimNone { + t.Fatalf("inconclusive %+v, want nothing claimed", a) + } + if a := VerificationAnswer(runtime.VerificationResult{Verdict: runtime.VerificationVerdict{Kind: runtime.VerdictPass}}, failed); a.Claim != ClaimNone || a.Err != failed { + t.Fatalf("erroring %+v, want nothing claimed", a) + } + outputs := []runtime.CalcOutputValue{{Name: "m", Value: intOf(1)}} + if a := CaseAnswer(runtime.AnalysisResult{Outputs: outputs}, nil); a.Claim != ClaimValue || len(a.Values) != 1 || a.Values[0].Name != "m" { + t.Fatalf("case %+v, want its outputs as values", a) + } + if a := CaseAnswer(runtime.AnalysisResult{Outputs: outputs}, failed); a.Claim != ClaimNone || len(a.Values) != 1 { + t.Fatalf("failed case %+v, want the outputs kept beside the failure", a) + } + values := ValuesOf(map[string]runtime.Value{"z": intOf(3), "a": intOf(1)}) + if len(values) != 2 || values[0].Name != "a" || values[1].Name != "z" { + t.Fatalf("held values %+v, want name order", values) + } + if a := CheckAnswer(runtime.CheckResult{}, failed); a.Claim != ClaimNone || a.Err != failed { + t.Fatalf("failed check %+v, want nothing claimed", a) + } +} + +// A caller already gone when the run is asked for is answered with its own error +// before the execution starts, the run engine's step carrying it. +func TestRunStopsWhenTheCallerIsGone(t *testing.T) { + f := parseFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + performed := false + q := Question{Kind: Evaluate, Subject: "test::Double", Schedule: runtime.DefaultSchedulePolicy, + Perform: func(*runtime.Context) (Answer, error) { + performed = true + return Answer{Claim: ClaimValue}, nil + }} + plan, err := Default().Answer(ctx, Held(f.context(t)), q, Budget{}) + if !errors.Is(err, context.Canceled) || performed { + t.Fatalf("answer for a gone caller: %v, performed %v; want context.Canceled unperformed", err, performed) + } + if len(plan.Steps) != 1 || plan.Steps[0].Engine != RunEngineName || + !errors.Is(plan.Steps[0].Err, context.Canceled) { + t.Fatalf("steps %+v, want run's step carrying the cancellation", plan.Steps) + } + if plan.Result.Covered() { + t.Fatalf("result %+v, want nothing established", plan.Result) + } +} diff --git a/internal/core/analysis/scale_test.go b/internal/exec/analysis/scale_test.go similarity index 99% rename from internal/core/analysis/scale_test.go rename to internal/exec/analysis/scale_test.go index 55de40af93..1ce2c19ef1 100644 --- a/internal/core/analysis/scale_test.go +++ b/internal/exec/analysis/scale_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // everyClaim and everyStrength enumerate the scale's axes. diff --git a/internal/core/analysis/schema_test.go b/internal/exec/analysis/schema_test.go similarity index 97% rename from internal/core/analysis/schema_test.go rename to internal/exec/analysis/schema_test.go index e855e41e0c..9c2718084e 100644 --- a/internal/core/analysis/schema_test.go +++ b/internal/exec/analysis/schema_test.go @@ -14,10 +14,10 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis/enginewire" - "github.com/Open-MBEE/OpenSysML/internal/core/export" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/enginewire" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/modelform" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // engineStandinWire names the directory the stand-in writes every line it exchanges to. @@ -319,8 +319,8 @@ func TestSchemaMatchesTheWireTypes(t *testing.T) { "input": enginewire.Input{}, "witness": enginewire.Witness{}, "error": enginewire.Error{}, - "sources": export.Sources{}, - "graphs": export.Graphs{}, + "sources": modelform.Sources{}, + "graphs": modelform.Graphs{}, } for def, v := range types { d, ok := schema.Defs[def] diff --git a/internal/exec/analysis/selection.go b/internal/exec/analysis/selection.go new file mode 100644 index 0000000000..b574c7c319 --- /dev/null +++ b/internal/exec/analysis/selection.go @@ -0,0 +1,121 @@ +package analysis + +import ( + "errors" + "fmt" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// Mode is how a plan chooses the engines it puts a question to. +type Mode int + +const ( + // SelectAuto puts the question to the strongest covering engine, and to the + // next on a not-covered result; the plan names each. + SelectAuto Mode = iota + // SelectNamed puts the question to exactly one engine; its refusal or + // not-covered result is the result. + SelectNamed + // SelectAll puts the question to every covering engine, in name order, and + // composes what they answered. + SelectAll +) + +// Selection is the engine choice a surface makes: `auto`, `all`, or one engine by name. +type Selection struct { + Mode Mode + // Engine is the one engine a SelectNamed selection puts the question to. + Engine string +} + +// Auto is the selection every surface makes when none is stated. +func Auto() Selection { return Selection{Mode: SelectAuto} } + +// All is the selection that runs every covering engine. +func All() Selection { return Selection{Mode: SelectAll} } + +// Only is the selection of the one engine named. +func Only(engine string) Selection { return Selection{Mode: SelectNamed, Engine: engine} } + +// String spells the selection as a flag takes it: `auto`, `all`, or the engine's name. +func (s Selection) String() string { + switch s.Mode { + case SelectAll: + return "all" + case SelectNamed: + return s.Engine + } + return "auto" +} + +// ParseSelection reads a selection as a flag spells it; empty is `auto`, which +// is what an unset field means on the wire. +func ParseSelection(text string) Selection { + switch strings.TrimSpace(text) { + case "", "auto": + return Auto() + case "all": + return All() + } + return Only(strings.TrimSpace(text)) +} + +// Explores is the exploration a selection and a schedule together ask of a +// behavior: the schedule's own when it explores, else the default one when the +// selection names the explore engine, `-engine explore` being `-schedule explore`. +func Explores(selection Selection, schedule runtime.SchedulePolicy) (runtime.SchedulePolicy, bool) { + if _, ok := schedule.Exploration(); ok { + return schedule, true + } + if selection == Only(ExploreEngineName) { + return runtime.DefaultExploreSchedulePolicy, true + } + return schedule, false +} + +// Select parses a selection and checks that a named engine is registered and served, so a +// misspelling or a withheld engine is refused where it is written rather than run as a +// refusal. +func (r *Registry) Select(text string) (Selection, error) { + selection := ParseSelection(text) + if selection.Mode == SelectNamed { + e, ok := r.engines[selection.Engine] + if !ok { + return selection, &UnknownEngineError{Name: selection.Engine, Known: r.Names()} + } + if w, withheld := e.(Withheld); withheld { + return selection, w.Withheld() + } + } + return selection, nil +} + +// Names is every registered engine's name, in name order. +func (r *Registry) Names() []string { + engines := r.Engines() + names := make([]string, len(engines)) + for i, e := range engines { + names[i] = e.Name() + } + return names +} + +// ErrUnknownEngine is the typed error for a selection naming no registered engine. +var ErrUnknownEngine = errors.New("no such engine") + +// UnknownEngineError reports a selection naming an engine the registry does not hold. +type UnknownEngineError struct { + Name string + // Known is every registered name, so the message can list the choices. + Known []string +} + +// Error names the engine and lists the registered ones. +func (e *UnknownEngineError) Error() string { + return fmt.Sprintf("analysis: no engine named %q; the engines are %s, or auto, or all", e.Name, strings.Join(e.Known, ", ")) +} + +// Is matches ErrUnknownEngine. +func (e *UnknownEngineError) Is(target error) bool { return target == ErrUnknownEngine } diff --git a/internal/core/analysis/selection_test.go b/internal/exec/analysis/selection_test.go similarity index 99% rename from internal/core/analysis/selection_test.go rename to internal/exec/analysis/selection_test.go index 7c97e9f68f..bddf670511 100644 --- a/internal/core/analysis/selection_test.go +++ b/internal/exec/analysis/selection_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" ) func TestParseSelectionReadsTheFlag(t *testing.T) { diff --git a/internal/exec/analysis/session.go b/internal/exec/analysis/session.go new file mode 100644 index 0000000000..52782d585f --- /dev/null +++ b/internal/exec/analysis/session.go @@ -0,0 +1,1016 @@ +package analysis + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/enginewire" +) + +// ErrProtocol is the typed error for an engine that broke the message protocol. +var ErrProtocol = errors.New("engine broke protocol") + +// ProtocolError reports a line the host could not take as a message: not JSON, no jsonrpc +// member, no id, a member of the wrong type, an error code outside the protocol's, a line or +// standard error over the output bound, a witness of the wrong kind. The session is ended +// with it. +type ProtocolError struct { + Engine string + Detail string +} + +// Error names the engine and what broke. +func (e *ProtocolError) Error() string { + return fmt.Sprintf("engine %q broke protocol: %s", e.Engine, e.Detail) +} + +// Is matches ErrProtocol. +func (e *ProtocolError) Is(target error) bool { return target == ErrProtocol } + +// ErrEngineExited is the typed error for an engine process that ended before it answered. +var ErrEngineExited = errors.New("engine process exited") + +// EngineExitedError reports the process ending: at start, or during a request. Stderr is +// what it wrote to standard error, within the output bound. +type EngineExitedError struct { + Engine string + Process string + // Started reports whether the process had answered describe before it ended. + Started bool + Err error + Stderr string +} + +// Error names the engine, the process and how it ended. +func (e *EngineExitedError) Error() string { + how := "did not start" + if e.Started { + how = "exited during a request" + } + detail := exitText(e.Err) + if e.Stderr != "" { + detail += ": " + e.Stderr + } + return fmt.Sprintf("engine %q at %s %s (%s)", e.Engine, e.Process, how, detail) +} + +// Is matches ErrEngineExited. +func (e *EngineExitedError) Is(target error) bool { return target == ErrEngineExited } + +// Unwrap is the process error. +func (e *EngineExitedError) Unwrap() error { return e.Err } + +// exitText spells how a process ended: `exit 127`, or the error as it is. +func exitText(err error) string { + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() >= 0 { + return "exit " + strconv.Itoa(exit.ExitCode()) + } + if err == nil { + return "exit 0" + } + return err.Error() +} + +// ErrHandshake is the typed error for a describe answer that disagrees with the manifest. +var ErrHandshake = errors.New("engine describes itself otherwise than its manifest entry") + +// HandshakeError reports the field of describe that differs from the manifest entry. +type HandshakeError struct { + Engine string + Field string + Manifest string + Answer string +} + +// Error names the engine, the field and both spellings. +func (e *HandshakeError) Error() string { + return fmt.Sprintf("engine %q describes its %s as %s; the manifest says %s", e.Engine, e.Field, e.Answer, e.Manifest) +} + +// Is matches ErrHandshake. +func (e *HandshakeError) Is(target error) bool { return target == ErrHandshake } + +// ErrEngineTimeout is the typed error for a message unanswered within OPENSYSML_TOOL_TIMEOUT. +var ErrEngineTimeout = errors.New("engine did not answer in time") + +// EngineTimeoutError reports a request unanswered within the timeout: describe or covers +// within the round-trip bound, or a cancelled run within the grace after cancel. +type EngineTimeoutError struct { + Engine string + Method string + Timeout time.Duration +} + +// Error names the engine, the request and the bound. +func (e *EngineTimeoutError) Error() string { + if e.Method == enginewire.MethodRun { + return fmt.Sprintf("engine %q did not answer cancel within %s (%s); the process was ended", e.Engine, e.Timeout, ToolTimeoutEnv) + } + return fmt.Sprintf("engine %q did not answer %s within %s (%s)", e.Engine, e.Method, e.Timeout, ToolTimeoutEnv) +} + +// Is matches ErrEngineTimeout. +func (e *EngineTimeoutError) Is(target error) bool { return target == ErrEngineTimeout } + +// ErrEngineFault is the typed error for an engine answering a request with an error. +var ErrEngineFault = errors.New("engine answered with an error") + +// EngineFaultError is the engine's error answer: its code and message. +type EngineFaultError struct { + Engine string + Method string + Code string + Message string +} + +// Error names the engine, the code and the message. +func (e *EngineFaultError) Error() string { + return fmt.Sprintf("engine %q answered %s with error %s: %s", e.Engine, e.Method, e.Code, e.Message) +} + +// Is matches ErrEngineFault. +func (e *EngineFaultError) Is(target error) bool { return target == ErrEngineFault } + +// ErrSessionEnded is the typed error for a request to an engine whose session in this plan +// has already ended. +var ErrSessionEnded = errors.New("engine session ended") + +// SessionEndedError reports a request after the session ended, with why it ended. +type SessionEndedError struct { + Engine string + Err error +} + +// Error names the engine and the end. +func (e *SessionEndedError) Error() string { + return fmt.Sprintf("engine %q: its session ended earlier in this plan: %v", e.Engine, e.Err) +} + +// Is matches ErrSessionEnded. +func (e *SessionEndedError) Is(target error) bool { return target == ErrSessionEnded } + +// Unwrap is why the session ended. +func (e *SessionEndedError) Unwrap() error { return e.Err } + +// ProgressReport is one coalesced progress notification of an external engine's run. +type ProgressReport struct { + Engine string + Progress enginewire.ProgressParams +} + +// Reporter takes the progress reports a plan's external engines send, at most one per +// ProgressInterval per open run; the surface that installs one prints them. +type Reporter func(ProgressReport) + +// ProgressInterval is the least time between two reports of one run: an engine that sends a +// notification per state costs the pipe, not the report. +const ProgressInterval = 250 * time.Millisecond + +type reporterKey struct{} + +// WithReporter installs the reporter a plan's external engines report progress to. +func WithReporter(ctx context.Context, report Reporter) context.Context { + return context.WithValue(ctx, reporterKey{}, report) +} + +// ReporterFrom is the reporter WithReporter installed, nil when none was. +func ReporterFrom(ctx context.Context) Reporter { + if report, ok := ctx.Value(reporterKey{}).(Reporter); ok { + return report + } + return nil +} + +// progressSink keeps the latest progress of one run and reports it at most once per +// ProgressInterval, never under the session's lock. +type progressSink struct { + engine string + report Reporter + + mu sync.Mutex + latest enginewire.ProgressParams + seen bool + reported time.Time + timer *time.Timer + closed bool +} + +// newProgressSink is a sink reporting one run's progress to report, keeping it when nil. +func newProgressSink(engine string, report Reporter) *progressSink { + return &progressSink{engine: engine, report: report} +} + +// take keeps p as the latest and reports it now, or schedules the report when the last +// one was within the interval. +func (k *progressSink) take(p enginewire.ProgressParams) { + k.mu.Lock() + k.latest, k.seen = p, true + if k.report == nil || k.closed || k.timer != nil { + k.mu.Unlock() + return + } + wait := ProgressInterval - time.Since(k.reported) + if wait > 0 { + k.timer = time.AfterFunc(wait, k.flush) + k.mu.Unlock() + return + } + k.reported = time.Now() + k.mu.Unlock() + k.report(ProgressReport{Engine: k.engine, Progress: p}) +} + +// flush reports the latest once the interval has passed, unless close delivered it first. +func (k *progressSink) flush() { + k.mu.Lock() + if k.timer == nil { + k.mu.Unlock() + return + } + k.timer = nil + p := k.latest + k.reported = time.Now() + k.mu.Unlock() + k.report(ProgressReport{Engine: k.engine, Progress: p}) +} + +// close stops reporting, delivering a report still scheduled; the latest stays for the +// reason of a run that ends unanswered. +func (k *progressSink) close() { + k.mu.Lock() + k.closed = true + pending := k.timer != nil + if pending { + k.timer.Stop() + k.timer = nil + } + p := k.latest + k.mu.Unlock() + if pending { + k.report(ProgressReport{Engine: k.engine, Progress: p}) + } +} + +// last is the latest progress the run reported, and whether it reported any. +func (k *progressSink) last() (enginewire.ProgressParams, bool) { + k.mu.Lock() + defer k.mu.Unlock() + return k.latest, k.seen +} + +// ProgressText spells progress as a report prints it: `runs 120, depth 17, steps 3: text`. +func ProgressText(p enginewire.ProgressParams) string { + var parts []string + if p.Runs != 0 { + parts = append(parts, fmt.Sprintf("runs %d", p.Runs)) + } + if p.Depth != 0 { + parts = append(parts, fmt.Sprintf("depth %d", p.Depth)) + } + if p.Steps != 0 { + parts = append(parts, fmt.Sprintf("steps %d", p.Steps)) + } + text := strings.Join(parts, ", ") + switch { + case text != "" && p.Text != "": + return text + ": " + p.Text + case p.Text != "": + return p.Text + } + return text +} + +// session is one external engine process: JSON-RPC lines on its pipes, requests matched to +// answers by id, one reader for every answer and notification. It ends at the first break +// of the protocol, when the process ends, or when the plan closes it. +type session struct { + entry EngineEntry + limit int + timeout time.Duration + cmd *exec.Cmd + stdin io.WriteCloser + stderr *boundedBuffer + writeMu sync.Mutex + + mu sync.Mutex + nextID int64 + pending map[int64]chan enginewire.Message + progress map[int64]*progressSink + // open counts the requests sent and not yet answered. + open int + // ended is closed when the reader is done; failure is why, nil for a clean end. + ended chan struct{} + failure error + // waited is the process's exit once Wait returned. + waited chan struct{} + exit error + + described enginewire.Description +} + +// startSession spawns the entry's command, never through PATH, and takes the engine's +// description, checked against the entry field by field. A process that does not start, +// does not describe itself within the timeout or describes itself otherwise is ended. +func startSession(entry EngineEntry, limit int, timeout time.Duration) (*session, error) { + if err := entry.Present(); err != nil { + return nil, err + } + s := &session{entry: entry, limit: limit, timeout: timeout, + pending: make(map[int64]chan enginewire.Message), progress: make(map[int64]*progressSink), + ended: make(chan struct{}), waited: make(chan struct{})} + s.cmd = exec.Command(entry.Executable, entry.Command[1:]...) // #nosec G204 -- the manifest names the command + s.cmd.Dir = entry.Dir + ownProcessGroup(s.cmd) + s.stderr = newBoundedBuffer(limit, s.stderrOver) + s.cmd.Stderr = s.stderr + stdin, err := s.cmd.StdinPipe() + if err != nil { + return nil, s.notStarted(err) + } + stdout, err := s.cmd.StdoutPipe() + if err != nil { + return nil, s.notStarted(err) + } + s.stdin = stdin + if err := s.cmd.Start(); err != nil { + return nil, s.notStarted(err) + } + go s.wait() + go s.read(stdout) + if err := s.describe(); err != nil { + s.end(err) + return nil, err + } + return s, nil +} + +// stderrOver ends the session of an engine that wrote more than the bound to standard error: +// the failure is recorded at once, the kill that waits for the copier it runs on is not. +func (s *session) stderrOver() { + if s.finish(s.broke(fmt.Sprintf("wrote more than %d bytes to standard error (%s)", s.limit, OutputLimitEnv))) { + go s.kill(0) + } +} + +// notStarted is the error of a process that never ran. +func (s *session) notStarted(err error) error { + return &EngineExitedError{Engine: s.entry.Name, Process: s.entry.Executable, Err: err} +} + +// wait collects the process's exit. +func (s *session) wait() { + s.exit = s.cmd.Wait() + close(s.waited) +} + +// describe takes the engine's description within the timeout and checks it. +func (s *session) describe() error { + ctx, cancel := context.WithTimeout(context.Background(), s.timeout) + defer cancel() + raw, err := s.call(ctx, enginewire.MethodDescribe, enginewire.DescribeParams{Protocols: ProtocolVersions}, nil) + if err != nil { + var exited *EngineExitedError + if errors.As(err, &exited) { + exited.Started = false + } + return err + } + var d enginewire.Description + if err := decodeOne(raw, &d); err != nil { + return s.broke("the describe result is not a description: " + err.Error()) + } + if err := s.entry.check(d); err != nil { + return err + } + s.described = d + return nil +} + +// check compares a description with the manifest entry, field by field: name, version, +// protocol and answers always; the other fields when the engine states them. +func (e EngineEntry) check(d enginewire.Description) error { + mismatch := func(field, manifest, answer string) error { + return &HandshakeError{Engine: e.Name, Field: field, Manifest: manifest, Answer: answer} + } + if d.Name != e.Name { + return mismatch("name", strconv.Quote(e.Name), strconv.Quote(d.Name)) + } + if d.Version != e.Version { + return mismatch("version", strconv.Quote(e.Version), strconv.Quote(d.Version)) + } + if d.Protocol != e.Protocol { + return mismatch("protocol", strconv.Itoa(e.Protocol), strconv.Itoa(d.Protocol)) + } + answers := make([]string, len(e.Answers)) + for i, k := range e.Answers { + answers[i] = k.String() + } + if !sameSet(answers, d.Answers) { + return mismatch("answers", spellList(answers), spellList(d.Answers)) + } + if d.Subjects != nil && !sameSet(e.Subjects, d.Subjects) { + return mismatch("subjects", spellList(e.Subjects), spellList(d.Subjects)) + } + if d.Bounds != nil && !sameSet(e.Bounds, d.Bounds) { + return mismatch("bounds", spellList(e.Bounds), spellList(d.Bounds)) + } + if d.Witness != "" && d.Witness != string(e.Witness) { + return mismatch("witness", string(e.Witness), d.Witness) + } + if d.Model != nil { + forms := make([]string, len(e.Model)) + for i, f := range e.Model { + forms[i] = string(f) + } + if !sameSet(forms, d.Model) { + return mismatch("model", spellList(forms), spellList(d.Model)) + } + } + if d.Authority != "" && d.Authority != e.Authority.String() { + return mismatch("authority", e.Authority.String(), d.Authority) + } + if d.Concurrent != nil && *d.Concurrent != e.Concurrent { + return mismatch("concurrent", strconv.FormatBool(e.Concurrent), strconv.FormatBool(*d.Concurrent)) + } + return nil +} + +// sameSet reports whether two lists name the same strings, in any order. +func sameSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + seen := make(map[string]int, len(a)) + for _, x := range a { + seen[x]++ + } + for _, x := range b { + if seen[x] == 0 { + return false + } + seen[x]-- + } + return true +} + +// spellList spells a list as the manifest would. +func spellList(list []string) string { + quoted := make([]string, len(list)) + for i, x := range list { + quoted[i] = strconv.Quote(x) + } + return "[" + strings.Join(quoted, ", ") + "]" +} + +// broke is the protocol error that ends the session. +func (s *session) broke(detail string) error { + return &ProtocolError{Engine: s.entry.Name, Detail: detail} +} + +// call sends one request and waits for its answer until ctx is done. A run whose ctx ends +// first is sent cancel and given the timeout's grace to answer; a run that does not answer +// within it ends the process. A run's progress goes to sink, coalesced. +func (s *session) call(ctx context.Context, method string, params any, sink *progressSink) (json.RawMessage, error) { + id, reply, err := s.send(method, params, sink) + if err != nil { + return nil, err + } + defer s.forget(id) + if sink != nil { + defer sink.close() + } + select { + case msg := <-reply: + return s.answer(method, msg) + case <-s.ended: + return nil, s.unanswered(sink) + case <-ctx.Done(): + } + if method != enginewire.MethodRun { + s.end(&EngineTimeoutError{Engine: s.entry.Name, Method: method, Timeout: s.timeout}) + return nil, s.failed() + } + if err := s.notify(enginewire.MethodCancel, enginewire.CancelParams{ID: id}); err != nil { + return nil, s.unanswered(sink, s.unwritable(err)) + } + grace := time.NewTimer(s.timeout) + defer grace.Stop() + select { + case msg := <-reply: + return s.answer(method, msg) + case <-s.ended: + return nil, s.unanswered(sink) + case <-grace.C: + s.end(&EngineTimeoutError{Engine: s.entry.Name, Method: method, Timeout: s.timeout}) + return nil, s.unanswered(sink) + } +} + +// unanswered is the error of a run that ended without an answer, keeping the last progress +// it reported; err is the end's, s.failed() when none is given. +func (s *session) unanswered(sink *progressSink, err ...error) error { + end := s.failed() + if len(err) > 0 { + end = err[0] + } + if sink == nil { + return end + } + last, seen := sink.last() + if !seen { + return end + } + return &UnansweredRunError{Engine: s.entry.Name, Progress: last, Err: end} +} + +// answer reads one response: its result, or its error as the engine's fault. +func (s *session) answer(method string, msg enginewire.Message) (json.RawMessage, error) { + if msg.Error != nil { + return nil, &EngineFaultError{Engine: s.entry.Name, Method: method, Code: msg.Error.Code, Message: msg.Error.Message} + } + return msg.Result, nil +} + +// send writes one request under a fresh id and registers where its answer goes. +func (s *session) send(method string, params any, sink *progressSink) (int64, chan enginewire.Message, error) { + s.mu.Lock() + if s.failure != nil || s.isEnded() { + s.mu.Unlock() + return 0, nil, s.failed() + } + s.nextID++ + id := s.nextID + reply := make(chan enginewire.Message, 1) + s.pending[id] = reply + if sink != nil { + s.progress[id] = sink + } + s.open++ + s.mu.Unlock() + if err := s.write(enginewire.Message{JSONRPC: enginewire.JSONRPC, ID: json.RawMessage(strconv.FormatInt(id, 10)), Method: method}, params); err != nil { + s.forget(id) + return 0, nil, s.unwritable(err) + } + return id, reply, nil +} + +// unwritable is the error of a message the pipe did not take: the process's end, as the +// reader reports it once it sees the pipe close, else the write's own error. +func (s *session) unwritable(err error) error { + select { + case <-s.ended: + case <-time.After(s.timeout): + s.end(&EngineExitedError{Engine: s.entry.Name, Process: s.entry.Executable, Started: true, + Err: err, Stderr: strings.TrimSpace(string(s.stderr.Bytes()))}) + } + return s.failed() +} + +// forget drops a request's registration once it is answered or abandoned. +func (s *session) forget(id int64) { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.pending[id]; ok { + delete(s.pending, id) + delete(s.progress, id) + s.open-- + } +} + +// notify writes one notification. +func (s *session) notify(method string, params any) error { + return s.write(enginewire.Message{JSONRPC: enginewire.JSONRPC, Method: method}, params) +} + +// write frames one message as a line. Params are encoded apart so a nil stays absent. +func (s *session) write(msg enginewire.Message, params any) error { + if params != nil { + raw, err := json.Marshal(params) + if err != nil { + return err + } + msg.Params = raw + } + line, err := json.Marshal(msg) + if err != nil { + return err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + _, err = s.stdin.Write(append(line, '\n')) + return err +} + +// read takes every line the engine writes until the pipe ends or a line breaks the protocol. +func (s *session) read(stdout io.Reader) { + r := bufio.NewReaderSize(stdout, 64<<10) + for { + line, err := readLine(r, s.limit) + switch { + case errors.Is(err, errLineTooLong): + s.end(s.broke(fmt.Sprintf("wrote a line over %d bytes (%s)", s.limit, OutputLimitEnv))) + return + case err != nil: + s.end(s.exited(err)) + return + } + if len(bytes.TrimSpace(line)) == 0 { + continue + } + if err := s.take(line); err != nil { + s.end(err) + return + } + } +} + +// exited is the error of a process whose pipe ended: how it exited, with its standard error. +func (s *session) exited(readErr error) error { + err := readErr + if errors.Is(readErr, io.EOF) { + err = nil + } + select { + case <-s.waited: + if s.exit != nil { + err = s.exit + } + case <-time.After(s.timeout): + } + return &EngineExitedError{Engine: s.entry.Name, Process: s.entry.Executable, Started: true, + Err: err, Stderr: strings.TrimSpace(string(s.stderr.Bytes()))} +} + +// errLineTooLong is a line over the output bound. +var errLineTooLong = errors.New("line over the output bound") + +// readLine reads one newline-ended line of at most limit bytes; the last line may end at EOF. +func readLine(r *bufio.Reader, limit int) ([]byte, error) { + var line []byte + for { + part, err := r.ReadSlice('\n') + line = append(line, part...) + if len(line) > limit+1 { + return nil, errLineTooLong + } + switch { + case err == nil: + return line, nil + case errors.Is(err, bufio.ErrBufferFull): + continue + case errors.Is(err, io.EOF) && len(line) > 0: + return line, nil + default: + return nil, err + } + } +} + +// take reads one line as a message: an answer to a pending request, or a progress +// notification of an open run. Anything else breaks the protocol. +func (s *session) take(line []byte) error { + var msg enginewire.Message + if err := decodeOne(line, &msg); err != nil { + return s.broke("a line is not a JSON-RPC message: " + err.Error()) + } + if msg.JSONRPC != enginewire.JSONRPC { + return s.broke(fmt.Sprintf("a message carries jsonrpc %q, not %q", msg.JSONRPC, enginewire.JSONRPC)) + } + if msg.Method != "" { + return s.notification(msg) + } + if len(msg.ID) == 0 || string(msg.ID) == "null" { + return s.broke("a response carries no id") + } + id, err := strconv.ParseInt(string(msg.ID), 10, 64) + if err != nil { + return s.broke(fmt.Sprintf("a response carries id %s, not the number of a request", msg.ID)) + } + if (msg.Result == nil) == (msg.Error == nil) { + return s.broke(fmt.Sprintf("the response to %d carries neither result nor error, or both", id)) + } + if msg.Error != nil { + switch msg.Error.Code { + case "": + return s.broke(fmt.Sprintf("the error answering %d carries no code", id)) + case enginewire.CodeUnsupported, enginewire.CodeBudget, enginewire.CodeInternal: + default: + return s.broke(fmt.Sprintf("the error answering %d carries the code %q, not one of the protocol's", id, msg.Error.Code)) + } + } + s.mu.Lock() + reply, ok := s.pending[id] + s.mu.Unlock() + if !ok { + return s.broke(fmt.Sprintf("a response answers %d, which is no open request", id)) + } + reply <- msg + return nil +} + +// notification takes the one notification the engine sends, progress, for an open run. +func (s *session) notification(msg enginewire.Message) error { + if len(msg.ID) != 0 { + return s.broke(fmt.Sprintf("the engine sent a %s request; the host takes no requests", msg.Method)) + } + if msg.Method != enginewire.MethodProgress { + return s.broke(fmt.Sprintf("the engine sent a %s notification; the host takes progress alone", msg.Method)) + } + var p enginewire.ProgressParams + if err := decodeOne(msg.Params, &p); err != nil { + return s.broke("progress params are malformed: " + err.Error()) + } + s.mu.Lock() + sink, open := s.progress[p.ID] + _, pending := s.pending[p.ID] + s.mu.Unlock() + if !pending { + return s.broke(fmt.Sprintf("progress names run %d, which is no open request", p.ID)) + } + if open { + sink.take(p) + } + return nil +} + +// ErrUnansweredRun is the typed error for a run that ended without an answer after +// reporting progress. +var ErrUnansweredRun = errors.New("run ended unanswered") + +// UnansweredRunError is why a run ended unanswered, with the last progress it reported. +type UnansweredRunError struct { + Engine string + Progress enginewire.ProgressParams + Err error +} + +// Error names the end and the last progress. +func (e *UnansweredRunError) Error() string { + return fmt.Sprintf("%v; its last progress was %s", e.Err, ProgressText(e.Progress)) +} + +// Is matches ErrUnansweredRun. +func (e *UnansweredRunError) Is(target error) bool { return target == ErrUnansweredRun } + +// Unwrap is the end itself. +func (e *UnansweredRunError) Unwrap() error { return e.Err } + +// end records why the session ended, once, and ends the process and its children at once. +func (s *session) end(err error) { + if s.finish(err) { + s.kill(0) + } +} + +// finish records why the session ended; false when it had ended already. +func (s *session) finish(err error) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.isEnded() { + return false + } + s.failure = err + close(s.ended) + return true +} + +// isEnded reports whether ended is closed; called under mu. +func (s *session) isEnded() bool { + select { + case <-s.ended: + return true + default: + return false + } +} + +// failed is why the session ended, as every request after that is refused. +func (s *session) failed() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.failure == nil { + return &SessionEndedError{Engine: s.entry.Name, Err: io.EOF} + } + return s.failure +} + +// kill closes the engine's standard input, gives it grace to end, then ends it and its +// children and waits for it. +func (s *session) kill(grace time.Duration) { + _ = s.stdin.Close() + if grace > 0 { + select { + case <-s.waited: + return + case <-time.After(grace): + } + } + killProcessGroup(s.cmd) + <-s.waited +} + +// close ends the session at the plan's end: standard input is closed, and a process still +// running after the timeout is ended with its children. +func (s *session) close() { + if s.finish(&SessionEndedError{Engine: s.entry.Name, Err: errPlanEnded}) { + s.kill(s.timeout) + } +} + +// errPlanEnded is why a session closed with its plan. +var errPlanEnded = errors.New("the plan ended") + +// alive reports whether the session can still take a request. +func (s *session) alive() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.failure == nil && !s.isEnded() +} + +// enginePool is one plan's processes of one external engine: a single session every request +// shares when the entry is concurrent, else one session per open request, idle ones reused. +// The first session to end by fault ends the engine for the plan; close ends them all. +type enginePool struct { + entry EngineEntry + limit int + timeout time.Duration + start func(EngineEntry, int, time.Duration) (*session, error) + + mu sync.Mutex + shared *session + idle []*session + all []*session + failure error + closed bool + // starting is closed once the shared process's start has been attempted; requests + // arriving during it wait rather than start another. + starting chan struct{} +} + +// newEnginePool is the pool of an entry's processes under the output bound and the timeout. +func newEnginePool(entry EngineEntry, limit int, timeout time.Duration) *enginePool { + return &enginePool{entry: entry, limit: limit, timeout: timeout, start: startSession} +} + +// acquire is a session with no other request open on it when the entry is not concurrent, +// the shared one otherwise; a new process is started when none serves. Give it back with put. +func (p *enginePool) acquire() (*session, error) { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return nil, &SessionEndedError{Engine: p.entry.Name, Err: errPlanEnded} + } + if p.failure != nil { + p.mu.Unlock() + return nil, &SessionEndedError{Engine: p.entry.Name, Err: p.failure} + } + if p.entry.Concurrent { + return p.acquireShared() + } + for len(p.idle) > 0 { + s := p.idle[len(p.idle)-1] + p.idle = p.idle[:len(p.idle)-1] + if s.alive() { + p.mu.Unlock() + return s, nil + } + } + p.mu.Unlock() + s, err := p.start(p.entry, p.limit, p.timeout) + p.mu.Lock() + defer p.mu.Unlock() + if err != nil { + p.failure = err + return nil, err + } + if p.closed { + go s.close() + return nil, &SessionEndedError{Engine: p.entry.Name, Err: errPlanEnded} + } + p.all = append(p.all, s) + return s, nil +} + +// acquireShared is the plan's one process of a concurrent entry, started by the first request +// while the others wait for it. Called with mu held, which it releases. +func (p *enginePool) acquireShared() (*session, error) { + if p.shared != nil { + s := p.shared + p.mu.Unlock() + return s, nil + } + if p.starting != nil { + starting := p.starting + p.mu.Unlock() + <-starting + p.mu.Lock() + defer p.mu.Unlock() + switch { + case p.closed: + return nil, &SessionEndedError{Engine: p.entry.Name, Err: errPlanEnded} + case p.failure != nil: + return nil, &SessionEndedError{Engine: p.entry.Name, Err: p.failure} + } + return p.shared, nil + } + p.starting = make(chan struct{}) + p.mu.Unlock() + s, err := p.start(p.entry, p.limit, p.timeout) + p.mu.Lock() + defer p.mu.Unlock() + defer close(p.starting) + if err != nil { + p.failure = err + return nil, err + } + if p.closed { + go s.close() + return nil, &SessionEndedError{Engine: p.entry.Name, Err: errPlanEnded} + } + p.all = append(p.all, s) + p.shared = s + return s, nil +} + +// put gives a session back once its request is answered: an ended one records the fault for +// the plan, a live nonconcurrent one waits idle for the next request. +func (p *enginePool) put(s *session) { + p.mu.Lock() + defer p.mu.Unlock() + if !s.alive() { + if p.failure == nil && !p.closed { + p.failure = s.failed() + } + return + } + if !p.entry.Concurrent { + p.idle = append(p.idle, s) + } +} + +// close ends every process of the pool with the plan. +func (p *enginePool) close() { + p.mu.Lock() + p.closed = true + all := p.all + p.all, p.idle, p.shared = nil, nil, nil + p.mu.Unlock() + var wg sync.WaitGroup + for _, s := range all { + wg.Add(1) + go func(s *session) { + defer wg.Done() + s.close() + }(s) + } + wg.Wait() +} + +// sessionPlan holds one plan's pools, one per external engine, shared by every fleet of the +// plan and closed with it. +type sessionPlan struct { + mu sync.Mutex + pools map[string]*enginePool + closed bool +} + +// pool is the plan's pool for an engine, made on first use by make. +func (sp *sessionPlan) pool(name string, make func() *enginePool) (*enginePool, error) { + sp.mu.Lock() + defer sp.mu.Unlock() + if sp.closed { + return nil, &SessionEndedError{Engine: name, Err: errPlanEnded} + } + if sp.pools == nil { + sp.pools = map[string]*enginePool{} + } + p, ok := sp.pools[name] + if !ok { + p = make() + sp.pools[name] = p + } + return p, nil +} + +// close ends every engine's processes of the plan. +func (sp *sessionPlan) close() { + sp.mu.Lock() + sp.closed = true + pools := sp.pools + sp.pools = nil + sp.mu.Unlock() + for _, p := range pools { + p.close() + } +} diff --git a/internal/exec/analysis/session_nogroup.go b/internal/exec/analysis/session_nogroup.go new file mode 100644 index 0000000000..c121f6b4a6 --- /dev/null +++ b/internal/exec/analysis/session_nogroup.go @@ -0,0 +1,17 @@ +//go:build windows || js + +package analysis + +import "os/exec" + +// ownProcessGroup is a no-op: neither Windows nor a browser has a process group to end the children by. +func ownProcessGroup(*exec.Cmd) { + // Intentionally empty: there is no process group to mark. +} + +// killProcessGroup ends the engine process. +func killProcessGroup(cmd *exec.Cmd) { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } +} diff --git a/internal/exec/analysis/session_test.go b/internal/exec/analysis/session_test.go new file mode 100644 index 0000000000..166969353a --- /dev/null +++ b/internal/exec/analysis/session_test.go @@ -0,0 +1,491 @@ +package analysis + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/enginewire" + "github.com/Open-MBEE/OpenSysML/tests/testutil/gobuild" +) + +// The stand-in engine's variables and modes, from testdata/enginestandin. +const ( + engineStandinDescribe = "ENGINE_STANDIN_DESCRIBE" + engineStandinCovers = "ENGINE_STANDIN_COVERS" + engineStandinResult = "ENGINE_STANDIN_RESULT" + engineStandinMode = "ENGINE_STANDIN_MODE" + engineStandinRecord = "ENGINE_STANDIN_RECORD" + engineStandinDelay = "ENGINE_STANDIN_DELAY" + engineStandinProgress = "ENGINE_STANDIN_PROGRESS" +) + +var ( + engineStandinOnce sync.Once + engineStandinPath string + engineStandinErr error +) + +// engineStandin builds the stand-in engine once per test binary and returns its path. +func engineStandin(t *testing.T) string { + t.Helper() + engineStandinOnce.Do(func() { + dir, err := os.MkdirTemp("", "enginestandin") + if err != nil { + engineStandinErr = err + return + } + engineStandinPath = filepath.Join(dir, "enginestandin") + build := exec.Command("go", gobuild.Args(engineStandinPath)...) + build.Dir = filepath.Join("testdata", "enginestandin") + if out, err := build.CombinedOutput(); err != nil { + engineStandinErr = fmt.Errorf("go build: %v\n%s", err, out) + } + }) + if engineStandinErr != nil { + t.Fatalf("building the stand-in engine: %v", engineStandinErr) + } + return engineStandinPath +} + +// standinEntry is the manifest entry the stand-in answers describe for by default. +func standinEntry(t *testing.T) EngineEntry { + t.Helper() + program := engineStandin(t) + return EngineEntry{Kind: KindEngine, Name: "standin", Version: "1.0.0", Command: []string{program}, + Executable: program, Dir: filepath.Dir(program), Transport: TransportStdio, Protocol: 1, + Answers: []Kind{Holds}, Model: []ModelForm{FormSources}, Witness: WitnessSchedule, Authority: Bounded, + Concurrent: true} +} + +// standinSession starts a session with the stand-in under a short timeout. +func standinSession(t *testing.T, entry EngineEntry, timeout time.Duration) *session { + t.Helper() + s, err := startSession(entry, DefaultOutputLimit, timeout) + if err != nil { + t.Fatalf("startSession: %v", err) + } + t.Cleanup(s.close) + return s +} + +// runOnce sends one run to the session and returns its answer. +func runOnce(ctx context.Context, s *session, sink *progressSink) (json.RawMessage, error) { + return s.call(ctx, enginewire.MethodRun, enginewire.RunParams{Question: questionA(), Bounds: []enginewire.Bound{}}, sink) +} + +// questionA is a wire question as the host writes one, to a stand-in that reads none of it. +func questionA() enginewire.Question { + return enginewire.Question{Kind: "holds", Subject: "A", Schedule: "fifo", Free: []string{}} +} + +// The handshake takes the description and the session answers covers and run by id. +func TestSessionHandshakeCoversAndRun(t *testing.T) { + t.Setenv(engineStandinResult, `{"claim":"holds","strength":"bounded","bounds":[{"name":"depth","limit":10}]}`) + s := standinSession(t, standinEntry(t), 5*time.Second) + if s.described.Name != "standin" || s.described.Protocol != 1 { + t.Fatalf("described %+v", s.described) + } + raw, err := s.call(context.Background(), enginewire.MethodCovers, enginewire.CoversParams{Question: questionA()}, nil) + if err != nil { + t.Fatalf("covers: %v", err) + } + var covers enginewire.CoversResult + if err := json.Unmarshal(raw, &covers); err != nil || !covers.Covers { + t.Fatalf("covers answered %s (%v)", raw, err) + } + raw, err = runOnce(context.Background(), s, nil) + if err != nil { + t.Fatalf("run: %v", err) + } + var result enginewire.Result + if err := json.Unmarshal(raw, &result); err != nil || result.Claim != "holds" { + t.Fatalf("run answered %s (%v)", raw, err) + } +} + +// Every field of describe is checked against the manifest entry. +func TestSessionHandshakeMismatchIsTypedPerField(t *testing.T) { + cases := []struct { + field string + describe string + }{ + {"name", `{"name":"other","version":"1.0.0","protocol":1,"answers":["holds"]}`}, + {"version", `{"name":"standin","version":"2.0.0","protocol":1,"answers":["holds"]}`}, + {"protocol", `{"name":"standin","version":"1.0.0","protocol":2,"answers":["holds"]}`}, + {"answers", `{"name":"standin","version":"1.0.0","protocol":1,"answers":["holds","outcomes"]}`}, + {"witness", `{"name":"standin","version":"1.0.0","protocol":1,"answers":["holds"],"witness":"assignment"}`}, + {"model", `{"name":"standin","version":"1.0.0","protocol":1,"answers":["holds"],"model":["sources","graphs:1"]}`}, + {"authority", `{"name":"standin","version":"1.0.0","protocol":1,"answers":["holds"],"authority":"proved"}`}, + {"concurrent", `{"name":"standin","version":"1.0.0","protocol":1,"answers":["holds"],"concurrent":false}`}, + } + for _, c := range cases { + t.Run(c.field, func(t *testing.T) { + t.Setenv(engineStandinDescribe, c.describe) + _, err := startSession(standinEntry(t), DefaultOutputLimit, 5*time.Second) + var mismatch *HandshakeError + if !errors.As(err, &mismatch) || mismatch.Field != c.field { + t.Fatalf("got %v, want a HandshakeError on %s", err, c.field) + } + if !errors.Is(err, ErrHandshake) { + t.Fatalf("%v is not ErrHandshake", err) + } + }) + } +} + +// A process that does not start, or exits before describing itself, is typed with its stderr. +func TestSessionStartupFailuresAreTyped(t *testing.T) { + t.Run("exit at start", func(t *testing.T) { + t.Setenv(engineStandinMode, "exit-at-start") + _, err := startSession(standinEntry(t), DefaultOutputLimit, 5*time.Second) + var exited *EngineExitedError + if !errors.As(err, &exited) || exited.Started || !strings.Contains(exited.Stderr, "cannot load formalism") { + t.Fatalf("got %v", err) + } + }) + t.Run("silent describe", func(t *testing.T) { + t.Setenv(engineStandinMode, "silent-describe") + _, err := startSession(standinEntry(t), DefaultOutputLimit, 200*time.Millisecond) + var timeout *EngineTimeoutError + if !errors.As(err, &timeout) || timeout.Method != enginewire.MethodDescribe { + t.Fatalf("got %v", err) + } + }) + t.Run("absent program", func(t *testing.T) { + entry := standinEntry(t) + entry.Executable = filepath.Join(t.TempDir(), "missing") + _, err := startSession(entry, DefaultOutputLimit, time.Second) + if !errors.Is(err, ErrProcessAbsent) { + t.Fatalf("got %v", err) + } + }) +} + +// The three error codes come back as the engine's fault, typed with the code. +func TestSessionErrorCodesAreTyped(t *testing.T) { + for _, code := range []string{enginewire.CodeUnsupported, enginewire.CodeBudget, enginewire.CodeInternal} { + t.Run(code, func(t *testing.T) { + t.Setenv(engineStandinMode, "error-"+code) + s := standinSession(t, standinEntry(t), 5*time.Second) + _, err := runOnce(context.Background(), s, nil) + var fault *EngineFaultError + if !errors.As(err, &fault) || fault.Code != code || !errors.Is(err, ErrEngineFault) { + t.Fatalf("got %v", err) + } + if !s.alive() { + t.Fatal("an error answer ends the session; it must not") + } + }) + } +} + +// Every break of the protocol ends the session with a ProtocolError, and the next request +// is refused as ended. +func TestSessionProtocolBreaksEndTheSession(t *testing.T) { + cases := []struct { + mode string + detail string + }{ + {"not-json", "not a JSON-RPC message"}, + {"wrong-jsonrpc", `jsonrpc "1.0"`}, + {"no-id", "no id"}, + {"null-id", "no id"}, + {"string-id", "not the number of a request"}, + {"unknown-id", "no open request"}, + {"result-and-error", "neither result nor error, or both"}, + {"neither", "neither result nor error, or both"}, + {"error-no-code", "no code"}, + {"error-unknown-code", `code "unsuported", not one of the protocol's`}, + {"engine-request", "takes no requests"}, + {"unknown-notification", "takes progress alone"}, + {"progress-unknown-run", "no open request"}, + } + for _, c := range cases { + t.Run(c.mode, func(t *testing.T) { + t.Setenv(engineStandinMode, c.mode) + s := standinSession(t, standinEntry(t), 5*time.Second) + _, err := runOnce(context.Background(), s, nil) + if !errors.Is(err, ErrProtocol) || !strings.Contains(err.Error(), c.detail) { + t.Fatalf("got %v, want a protocol break naming %q", err, c.detail) + } + if s.alive() { + t.Fatal("the session is still alive after a protocol break") + } + _, err = runOnce(context.Background(), s, nil) + if !errors.Is(err, ErrProtocol) { + t.Fatalf("a request after the break got %v", err) + } + }) + } +} + +// A line over the output bound is a protocol break naming the bound. +func TestSessionLineOverTheBoundEndsTheSession(t *testing.T) { + t.Setenv(engineStandinMode, "overflow") + entry := standinEntry(t) + s, err := startSession(entry, 64<<10, 5*time.Second) + if err != nil { + t.Fatal(err) + } + t.Cleanup(s.close) + _, err = runOnce(context.Background(), s, nil) + if !errors.Is(err, ErrProtocol) || !strings.Contains(err.Error(), OutputLimitEnv) { + t.Fatalf("got %v", err) + } +} + +// Standard error over the output bound ends the process and the session, naming the bound — +// also when the engine exits right after, so the exit does not hide the overflow. +func TestSessionStderrOverTheBoundEndsTheSession(t *testing.T) { + for _, mode := range []string{"stderr-flood", "stderr-flood-exit"} { + t.Run(mode, func(t *testing.T) { + t.Setenv(engineStandinMode, mode) + s, err := startSession(standinEntry(t), 64<<10, 5*time.Second) + if err != nil { + t.Fatal(err) + } + t.Cleanup(s.close) + _, err = runOnce(context.Background(), s, nil) + if !errors.Is(err, ErrProtocol) || !strings.Contains(err.Error(), "standard error") || !strings.Contains(err.Error(), OutputLimitEnv) { + t.Fatalf("got %v", err) + } + if s.alive() { + t.Fatal("the session is still alive after standard error passed the bound") + } + select { + case <-s.waited: + case <-time.After(5 * time.Second): + t.Fatal("the process is still running after standard error passed the bound") + } + }) + } +} + +// A process that exits during a run is typed with its exit and stderr. +func TestSessionExitDuringRunIsTyped(t *testing.T) { + t.Setenv(engineStandinMode, "exit-during-run") + s := standinSession(t, standinEntry(t), 5*time.Second) + _, err := runOnce(context.Background(), s, nil) + var exited *EngineExitedError + if !errors.As(err, &exited) || !exited.Started || !strings.Contains(exited.Stderr, "segmentation fault") { + t.Fatalf("got %v", err) + } + if !strings.Contains(err.Error(), "exit 11") { + t.Fatalf("%v does not name the exit code", err) + } +} + +// Cancel is sent when the run's context ends; an engine that answers it is heard. +func TestSessionCancelIsAnswered(t *testing.T) { + t.Setenv(engineStandinMode, "cancel-answers") + s := standinSession(t, standinEntry(t), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + raw, err := runOnce(ctx, s, nil) + if err != nil { + t.Fatalf("got %v, want the cancelled run's answer", err) + } + var result enginewire.Result + if err := json.Unmarshal(raw, &result); err != nil || result.Reason != "cancelled" { + t.Fatalf("answered %s", raw) + } + if !s.alive() { + t.Fatal("the session ended after an answered cancel") + } +} + +// An engine that ignores cancel is ended at the grace deadline, with its last progress. +func TestSessionIgnoredCancelEndsTheProcess(t *testing.T) { + t.Setenv(engineStandinMode, "cancel-ignored") + t.Setenv(engineStandinProgress, "3") + s := standinSession(t, standinEntry(t), 300*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + sink := newProgressSink("standin", nil) + started := time.Now() + _, err := runOnce(ctx, s, sink) + if !errors.Is(err, ErrEngineTimeout) || !errors.Is(err, ErrUnansweredRun) { + t.Fatalf("got %v", err) + } + if !strings.Contains(err.Error(), "runs 3") { + t.Fatalf("%v does not carry the last progress", err) + } + if time.Since(started) > 5*time.Second { + t.Fatal("the process was not ended at the grace deadline") + } + select { + case <-s.waited: + case <-time.After(5 * time.Second): + t.Fatal("the process is still running") + } +} + +// A thousand progress notifications in a second reach the reporter a handful of times, the +// latest last. +func TestSessionProgressIsCoalesced(t *testing.T) { + t.Setenv(engineStandinProgress, "1000") + t.Setenv(engineStandinDelay, "600ms") + s := standinSession(t, standinEntry(t), 5*time.Second) + var reports []ProgressReport + var mu sync.Mutex + sink := newProgressSink("standin", func(r ProgressReport) { + mu.Lock() + defer mu.Unlock() + reports = append(reports, r) + }) + if _, err := runOnce(context.Background(), s, sink); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(reports) == 0 || len(reports) > 8 { + t.Fatalf("%d reports of a thousand notifications; want a handful", len(reports)) + } + if last := reports[len(reports)-1]; last.Progress.Runs != 1000 || last.Engine != "standin" { + t.Fatalf("the last report is %+v, not the latest progress", last) + } + if got := ProgressText(reports[len(reports)-1].Progress); !strings.HasPrefix(got, "runs 1000") { + t.Fatalf("ProgressText %q", got) + } +} + +// Several runs stay open on one concurrent process, each answered by its own id. +func TestSessionMatchesConcurrentAnswersByID(t *testing.T) { + t.Setenv(engineStandinDelay, "50ms") + record := filepath.Join(t.TempDir(), "record") + t.Setenv(engineStandinRecord, record) + s := standinSession(t, standinEntry(t), 5*time.Second) + var wg sync.WaitGroup + errs := make([]error, 8) + for i := range errs { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, errs[i] = runOnce(context.Background(), s, nil) + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("run %d: %v", i, err) + } + } + if got := maxOpen(t, record); got < 2 { + t.Fatalf("at most %d runs were open at once; want several on one process", got) + } +} + +// maxOpen reads the most runs the stand-in had open at once from its record. +func maxOpen(t *testing.T, record string) int { + t.Helper() + data, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + most := 0 + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 3 && fields[1] == "open" { + n, _ := strconv.Atoi(fields[2]) + most = max(most, n) + } + } + return most +} + +// processes counts the stand-in processes the record saw start. +func processes(t *testing.T, record string) int { + t.Helper() + data, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + return strings.Count(string(data), " start") +} + +// A pool of a nonconcurrent entry never gives one process two open requests, and ends every +// process with the plan; a concurrent one shares a single process. +func TestEnginePoolHonorsConcurrent(t *testing.T) { + for _, concurrent := range []bool{true, false} { + t.Run(strconv.FormatBool(concurrent), func(t *testing.T) { + t.Setenv(engineStandinDelay, "50ms") + record := filepath.Join(t.TempDir(), "record") + t.Setenv(engineStandinRecord, record) + entry := standinEntry(t) + entry.Concurrent = concurrent + if !concurrent { + t.Setenv(engineStandinDescribe, `{"name":"standin","version":"1.0.0","protocol":1,"answers":["holds"],"concurrent":false}`) + } + pool := newEnginePool(entry, DefaultOutputLimit, 5*time.Second) + var wg sync.WaitGroup + var failures atomic.Int32 + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s, err := pool.acquire() + if err != nil { + failures.Add(1) + return + } + defer pool.put(s) + if _, err := runOnce(context.Background(), s, nil); err != nil { + failures.Add(1) + } + }() + } + wg.Wait() + if failures.Load() != 0 { + t.Fatalf("%d runs failed", failures.Load()) + } + started, most := processes(t, record), maxOpen(t, record) + switch { + case concurrent && started != 1: + t.Fatalf("a concurrent entry started %d processes; want one", started) + case concurrent && most < 2: + t.Fatalf("8 runs on a concurrent process never overlapped; %d open at most", most) + case !concurrent && most != 1: + t.Fatalf("a nonconcurrent process held %d requests open at once", most) + case !concurrent && started < 2: + t.Fatalf("a nonconcurrent entry started %d processes for 8 concurrent requests", started) + } + pool.close() + if _, err := pool.acquire(); !errors.Is(err, ErrSessionEnded) { + t.Fatalf("acquire after close got %v", err) + } + data, _ := os.ReadFile(record) + if strings.Count(string(data), " exit") != started { + t.Fatalf("%d of %d processes exited cleanly at close:\n%s", strings.Count(string(data), " exit"), started, data) + } + }) + } +} + +// A pool refuses every request after one of its processes ends by fault. +func TestEnginePoolEndsTheEngineForThePlanAtTheFirstFault(t *testing.T) { + t.Setenv(engineStandinMode, "not-json") + pool := newEnginePool(standinEntry(t), DefaultOutputLimit, 5*time.Second) + s, err := pool.acquire() + if err != nil { + t.Fatal(err) + } + if _, err := runOnce(context.Background(), s, nil); !errors.Is(err, ErrProtocol) { + t.Fatalf("got %v", err) + } + pool.put(s) + if _, err := pool.acquire(); !errors.Is(err, ErrSessionEnded) || !errors.Is(err, ErrProtocol) { + t.Fatalf("acquire after the fault got %v", err) + } + pool.close() +} diff --git a/internal/core/analysis/session_unix.go b/internal/exec/analysis/session_unix.go similarity index 94% rename from internal/core/analysis/session_unix.go rename to internal/exec/analysis/session_unix.go index 5bf993e960..0565c4526b 100644 --- a/internal/core/analysis/session_unix.go +++ b/internal/exec/analysis/session_unix.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !js package analysis diff --git a/internal/core/analysis/solve.go b/internal/exec/analysis/solve.go similarity index 99% rename from internal/core/analysis/solve.go rename to internal/exec/analysis/solve.go index 112f9d539f..9e63a78300 100644 --- a/internal/core/analysis/solve.go +++ b/internal/exec/analysis/solve.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // SolveEngineName is the name of the engine that puts conditions to an SMT solver. diff --git a/internal/exec/analysis/solve_test.go b/internal/exec/analysis/solve_test.go new file mode 100644 index 0000000000..fbec4a6cfa --- /dev/null +++ b/internal/exec/analysis/solve_test.go @@ -0,0 +1,272 @@ +package analysis + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" +) + +// requireSolver returns the discovered solver, skipping the test when none is +// installed unless OPENSYSML_REQUIRE_SMT declares one mandatory. +func requireSolver(t *testing.T) *solve.Solver { + t.Helper() + solver, err := solve.Discover() + if err == nil { + return solver + } + if !errors.Is(err, solve.ErrNoSolver) { + t.Fatalf("discover a solver: %v", err) + } + if os.Getenv("OPENSYSML_REQUIRE_SMT") != "" { + t.Fatalf("OPENSYSML_REQUIRE_SMT is set but %v", err) + } + t.Skipf("no SMT solver installed: %v", err) + return nil +} + +// intQuery is a constraint on one integer, i in (low, high). +func intQuery(element string, low, high int64) *solve.Query { + i := &solve.Var{Name: "test::" + element + "::i", Sort: solve.Int, Location: "c.sysml:3:3"} + return &solve.Query{ + Kind: "constraint", + Element: element, + Vars: []*solve.Var{i}, + Assertions: []solve.Assertion{ + {Term: solve.Binary(solve.OpGt, solve.Bool, solve.VarTerm(i), solve.IntTerm(low)), From: solve.Provenance{Kind: "constraint", Element: element, Condition: "i > low", Role: solve.RoleRequired}}, + {Term: solve.Binary(solve.OpLt, solve.Bool, solve.VarTerm(i), solve.IntTerm(high)), From: solve.Provenance{Kind: "constraint", Element: element, Condition: "i < high", Role: solve.RoleRequired}}, + }, + } +} + +// satisfiable asks the default registry about the queries with the solver's Solve. +func satisfiable(t *testing.T, queries ...*solve.Query) Plan { + t.Helper() + q := Question{Kind: Satisfiable, Subject: "test::C", Free: FreeInputs, Solve: &SolveAsk{Queries: queries, Ask: (*solve.Solver).Solve}} + return answered(t, Default(), nil, q, Budget{}) +} + +func TestSolveProvesUnsatisfiable(t *testing.T) { + requireSolver(t) + result := satisfiable(t, intQuery("C", 5, 2)).Result + if result.Engine != SolveEngineName || result.Claim != ClaimUnsatisfiable || result.Strength != Proved { + t.Fatalf("result %+v, want solve's proof of unsatisfiability", result) + } + if len(result.Values) != 1 || result.Values[0].Solved == nil || result.Values[0].Solved.Status != solve.StatusUnsat || result.Values[0].Name != "C" { + t.Fatalf("values %+v, want C's unsat verdict", result.Values) + } + if limit, ok := result.Bounds.Limit("solver"); !ok || limit != solve.DefaultTimeout.Milliseconds() || result.Bounds.Reached() { + t.Fatalf("bounds %s, want the default solver time unreached", result.Bounds) + } +} + +func TestSolveWitnessesSatisfiable(t *testing.T) { + requireSolver(t) + result := satisfiable(t, intQuery("C", 2, 5)).Result + if result.Claim != ClaimSatisfiable || result.Strength != Witnessed { + t.Fatalf("result %+v, want a witnessed satisfiable", result) + } + solved := result.Values[0].Solved + if solved.Status != solve.StatusSat || len(solved.Model) != 1 || (solved.Model[0].Value != "3" && solved.Model[0].Value != "4") { + t.Fatalf("solved %+v, want a replayed witness in (2, 5)", solved) + } +} + +func TestSolveJudgesTheSetOfQueries(t *testing.T) { + requireSolver(t) + result := satisfiable(t, intQuery("A", 2, 5), intQuery("B", 5, 2)).Result + if result.Claim != ClaimUnsatisfiable || result.Strength != Proved || len(result.Values) != 2 { + t.Fatalf("result %+v, want one unsatisfiable query deciding the set", result) + } + if result.Values[0].Solved.Status != solve.StatusSat || result.Values[1].Solved.Status != solve.StatusUnsat { + t.Fatalf("values %+v, want A sat and B unsat in order", result.Values) + } +} + +func TestRegistrySolveIsTheSolversAnswer(t *testing.T) { + solver := requireSolver(t) + plan, err := Default().Solve(context.Background(), Request{Subject: "test::C", Budget: Budget{Solver: time.Minute}, Selection: Auto()}, []*solve.Query{intQuery("C", 2, 5)}, (*solve.Solver).Explain) + if err != nil { + t.Fatalf("solve: %v", err) + } + values := plan.Result.Values + direct, err := solver.Explain(context.Background(), intQuery("C", 2, 5)) + if err != nil { + t.Fatalf("explain: %v", err) + } + if len(values) != 1 || values[0].Err != nil || values[0].Solved.Status != direct.Status || values[0].Solved.Solver != direct.Solver { + t.Fatalf("values %+v, want the solver's own %+v", values, direct) + } + if _, err = registered(t, NewSolve(absentSolver)).Solve(context.Background(), Request{Subject: "test::C", Selection: Auto()}, []*solve.Query{intQuery("C", 2, 5)}, (*solve.Solver).Solve); !errors.Is(err, solve.ErrNoSolver) { + t.Fatalf("solve without a solver: %v, want its absence", err) + } +} + +func TestSolveGradesEachVerdict(t *testing.T) { + exact := intQuery("C", 2, 5) + rounded := intQuery("R", 2, 5) + rounded.Assertions = append(rounded.Assertions, solve.Assertion{Term: solve.Binary(solve.OpGt, solve.Bool, solve.ToReal(solve.VarTerm(rounded.Vars[0])), solve.IntTerm(0))}) + if !rounded.Rounded() { + t.Fatal("the widened query must round") + } + objective := solve.Objective{Name: "m", Direction: solve.Minimize, Term: solve.VarTerm(exact.Vars[0])} + cases := []struct { + name string + value Evaluation + claim Claim + strength Strength + reason string + }{ + {"unsat", Evaluation{Name: "C", Solved: &solve.Result{Query: exact, Status: solve.StatusUnsat}}, ClaimUnsatisfiable, Proved, ""}, + {"rounded unsat", Evaluation{Name: "R", Solved: &solve.Result{Query: rounded, Status: solve.StatusUnsat}}, ClaimNone, NotCovered, "R rounds in floating point when evaluated, which an exact-real unsat does not decide"}, + {"sat", Evaluation{Name: "C", Solved: &solve.Result{Query: exact, Status: solve.StatusSat}}, ClaimSatisfiable, Witnessed, ""}, + {"unknown", Evaluation{Name: "C", Solved: &solve.Result{Query: exact, Status: solve.StatusUnknown, Reason: "timeout"}}, ClaimNone, NotCovered, "timeout"}, + {"silent unknown", Evaluation{Name: "C", Solved: &solve.Result{Query: exact, Status: solve.StatusUnknown}}, ClaimNone, NotCovered, "the solver did not decide C"}, + {"failed", Evaluation{Name: "C", Err: errors.New("the solver exited")}, ClaimNone, NotCovered, "the solver exited"}, + {"not asked", Evaluation{Name: "C"}, ClaimNone, NotCovered, "C was not put to the solver"}, + {"attained", Evaluation{Name: "C", Solved: &solve.Result{Query: exact, Status: solve.StatusSat, Optima: []solve.Optimum{{Objective: objective, Status: solve.OptimumAttained, Value: "3"}}}}, ClaimSatisfiable, Witnessed, ""}, + {"unbounded", Evaluation{Name: "C", Solved: &solve.Result{Query: exact, Status: solve.StatusSat, Optima: []solve.Optimum{{Objective: objective, Status: solve.OptimumUnbounded}}}}, ClaimUnbounded, Proved, ""}, + {"bounded", Evaluation{Name: "C", Solved: &solve.Result{Query: exact, Status: solve.StatusSat, Optima: []solve.Optimum{{Objective: objective, Status: solve.OptimumBounded, Detail: "approaches 2"}}}}, ClaimNone, NotCovered, "m: approaches 2"}, + {"unverified", Evaluation{Name: "C", Solved: &solve.Result{Query: exact, Status: solve.StatusSat, Optima: []solve.Optimum{{Objective: objective, Status: solve.OptimumUnverified, Detail: "a better value is feasible"}}}}, ClaimNone, NotCovered, "m: a better value is feasible"}, + {"undecided", Evaluation{Name: "C", Solved: &solve.Result{Query: exact, Status: solve.StatusSat, Optima: []solve.Optimum{{Objective: objective, Status: solve.OptimumUndecided, Detail: "unknown"}}}}, ClaimNone, NotCovered, "m: unknown"}, + } + for _, tc := range cases { + claim, strength, reason := judgeOne(tc.value) + if claim != tc.claim || strength != tc.strength || reason != tc.reason { + t.Errorf("%s: %s %s %q, want %s %s %q", tc.name, claim, strength, reason, tc.claim, tc.strength, tc.reason) + } + } +} + +func TestSolveJudgesSetsAsTheirWeakestMember(t *testing.T) { + exact := intQuery("C", 2, 5) + sat := Evaluation{Name: "A", Solved: &solve.Result{Query: exact, Status: solve.StatusSat}} + unsat := Evaluation{Name: "B", Solved: &solve.Result{Query: exact, Status: solve.StatusUnsat}} + unknown := Evaluation{Name: "U", Solved: &solve.Result{Query: exact, Status: solve.StatusUnknown, Reason: "gave up"}} + unbounded := Evaluation{Name: "O", Solved: &solve.Result{Query: exact, Status: solve.StatusSat, Optima: []solve.Optimum{{Status: solve.OptimumUnbounded}}}} + cases := []struct { + name string + values []Evaluation + claim Claim + strength Strength + reason string + }{ + {"all sat", []Evaluation{sat, sat}, ClaimSatisfiable, Witnessed, ""}, + {"one unsat", []Evaluation{sat, unsat}, ClaimUnsatisfiable, Proved, ""}, + {"unknown after unsat", []Evaluation{unsat, unknown}, ClaimNone, NotCovered, "gave up"}, + {"unbounded among sat", []Evaluation{sat, unbounded}, ClaimUnbounded, Proved, ""}, + {"unsat over unbounded", []Evaluation{unbounded, unsat}, ClaimUnsatisfiable, Proved, ""}, + {"none", nil, ClaimSatisfiable, Witnessed, ""}, + {"withheld", []Evaluation{sat, {Name: "W"}}, ClaimNone, NotCovered, "W was not put to the solver"}, + } + for _, tc := range cases { + claim, strength, reason := judgeSolved(tc.values, len(tc.values)) + if claim != tc.claim || strength != tc.strength || reason != tc.reason { + t.Errorf("%s: %s %s %q, want %s %s %q", tc.name, claim, strength, reason, tc.claim, tc.strength, tc.reason) + } + } +} + +func TestSolveTakesTheBudgetsSolverTime(t *testing.T) { + requireSolver(t) + q := Question{Kind: Satisfiable, Subject: "test::C", Free: FreeInputs, Solve: &SolveAsk{Queries: []*solve.Query{intQuery("C", 2, 5)}, Ask: (*solve.Solver).Solve}} + result := answered(t, Default(), nil, q, Budget{Solver: 2 * time.Second}).Result + if limit, ok := result.Bounds.Limit("solver"); !ok || limit != 2000 { + t.Fatalf("bounds %s, want the budget's 2s of solver time", result.Bounds) + } + timedOut := []Evaluation{{Name: "C", Solved: &solve.Result{Status: solve.StatusUnknown, TimedOut: true}}} + if !anyTimedOut(timedOut) || anyTimedOut(nil) { + t.Fatal("a timed-out verdict must reach the solver bound") + } +} + +// The budget's runs are the queries solve asks: the rest are left unasked, which the +// set's verdict and the runs bound both report. +func TestSolveAsksNoMoreQueriesThanTheBudgetsRuns(t *testing.T) { + present := func() (*solve.Solver, error) { return &solve.Solver{Name: "z3", Path: "/usr/bin/z3"}, nil } + var asked []string + sat := func(_ *solve.Solver, _ context.Context, query *solve.Query) (*solve.Result, error) { + asked = append(asked, query.Element) + return &solve.Result{Query: query, Status: solve.StatusSat}, nil + } + queries := []*solve.Query{intQuery("A", 2, 5), intQuery("B", 2, 5), intQuery("C", 2, 5)} + q := Question{Kind: Satisfiable, Subject: "test::C", Free: FreeInputs, Solve: &SolveAsk{Queries: queries, Ask: sat}} + result := answered(t, registered(t, NewSolve(present)), nil, q, Budget{Runs: 2}).Result + if len(asked) != 2 || asked[0] != "A" || asked[1] != "B" { + t.Fatalf("asked %v, want A and B only", asked) + } + if result.Claim != ClaimNone || result.Strength != NotCovered || result.Reason != "C was left unasked by the runs budget" { + t.Fatalf("result %+v, want not covered for the unasked C", result) + } + if len(result.Values) != 3 || result.Values[2].Solved != nil || result.Values[2].Err != nil || result.Values[2].Name != "C" { + t.Fatalf("values %+v, want C's unasked entry in place", result.Values) + } + if runs, ok := result.Bounds.Limit("runs"); !ok || runs != 2 || !result.Bounds.Reached() { + t.Fatalf("bounds %s, want the budget's 2 runs reached", result.Bounds) + } + asked = nil + result = answered(t, registered(t, NewSolve(present)), nil, q, Budget{Runs: 5}).Result + if runs, ok := result.Bounds.Limit("runs"); len(asked) != 3 || result.Claim != ClaimSatisfiable || !ok || runs != 5 || result.Bounds.Reached() { + t.Fatalf("asked %v, result %+v; want every query asked and the budget's 5 runs named unreached", asked, result) + } + result = answered(t, registered(t, NewSolve(present)), nil, q, Budget{}).Result + if runs, ok := result.Bounds.Limit("runs"); !ok || runs != 3 || result.Bounds.Reached() || result.Claim != ClaimSatisfiable { + t.Fatalf("result %+v, want every query asked and the 3 named as the runs without a runs budget", result) + } + withheld := func(_ *solve.Solver, _ context.Context, query *solve.Query) (*solve.Result, error) { + if query.Element == "B" { + return nil, nil + } + return &solve.Result{Query: query, Status: solve.StatusSat}, nil + } + q.Solve = &SolveAsk{Queries: queries, Ask: withheld} + result = answered(t, registered(t, NewSolve(present)), nil, q, Budget{Runs: 3}).Result + if result.Strength != NotCovered || result.Reason != "B was not put to the solver" || result.Bounds.Reached() { + t.Fatalf("result %+v, want the withheld B not covered without blaming the runs budget", result) + } +} + +// The bounds solve declares are the bounds its result reports, in that order. +func TestSolveReportsTheBoundsItDeclares(t *testing.T) { + present := func() (*solve.Solver, error) { return &solve.Solver{Name: "z3", Path: "/usr/bin/z3"}, nil } + sat := func(_ *solve.Solver, _ context.Context, query *solve.Query) (*solve.Result, error) { + return &solve.Result{Query: query, Status: solve.StatusSat}, nil + } + engine := NewSolve(present) + q := Question{Kind: Satisfiable, Subject: "test::A", Free: FreeInputs, Solve: &SolveAsk{Queries: []*solve.Query{intQuery("A", 2, 5), intQuery("B", 2, 5)}, Ask: sat}} + declared := engine.Describe().Bounds + for _, budget := range []Budget{{}, {Runs: 1}, {Runs: 5}} { + result := answered(t, registered(t, engine), nil, q, budget).Result + if len(result.Bounds) != len(declared) { + t.Fatalf("%+v: bounds %s, want the declared %v", budget, result.Bounds, declared) + } + for i, bound := range result.Bounds { + if bound.Name != declared[i] { + t.Errorf("%+v: bound %d is %q, want the declared %q", budget, i, bound.Name, declared[i]) + } + } + } +} + +func TestSolveRefusesWhatItCannotAsk(t *testing.T) { + e := NewSolve(func() (*solve.Solver, error) { return &solve.Solver{Name: "z3", Path: "/usr/bin/z3"}, nil }) + ask := &SolveAsk{Queries: []*solve.Query{intQuery("C", 2, 5)}, Ask: (*solve.Solver).Solve} + if c := e.Covers(nil, Question{Kind: Holds}); c.Covered || !errors.Is(c.Refusal, ErrNotAsked) { + t.Fatalf("holds: %+v, want not asked", c) + } + if c := e.Covers(nil, Question{Kind: Satisfiable, Free: FreeInputs | FreeSchedule, Solve: ask}); c.Covered || !errors.Is(c.Refusal, ErrFreedom) { + t.Fatalf("free schedule: %+v, want a freedom refusal", c) + } + if c := e.Covers(nil, Question{Kind: Satisfiable, Free: FreeInputs}); c.Covered || !errors.Is(c.Refusal, ErrMalformedQuestion) { + t.Fatalf("no ask: %+v, want malformed", c) + } + if c := e.Covers(nil, Question{Kind: Satisfiable, Free: FreeInputs, Solve: &SolveAsk{Ask: ask.Ask}}); c.Covered || !errors.Is(c.Refusal, ErrMalformedQuestion) { + t.Fatalf("no queries: %+v, want malformed", c) + } + if c := e.Covers(nil, Question{Kind: Satisfiable, Free: FreeInputs, Solve: ask}); !c.Covered { + t.Fatalf("satisfiable: %+v, want covered", c) + } +} diff --git a/internal/core/analysis/standing.go b/internal/exec/analysis/standing.go similarity index 91% rename from internal/core/analysis/standing.go rename to internal/exec/analysis/standing.go index 94e1913611..8d274f6f59 100644 --- a/internal/core/analysis/standing.go +++ b/internal/exec/analysis/standing.go @@ -48,7 +48,7 @@ func (r Result) evidence() string { if c := r.Check(); c != nil { parts = append(parts, fmt.Sprintf("%s, %s searched", plural(c.Report.States, "state"), plural(c.Report.Moves, "move"))) } - if w := r.Witness; w != nil && (len(w.Choices) > 0 || len(w.Inputs) > 0) { + if w := r.Witness; w != nil && (len(w.Choices) > 0 || len(w.Draws) > 0 || len(w.Inputs) > 0) { parts = append(parts, "witness of "+witnessSize(w)+" replayed") } if len(r.Inputs) > 0 { @@ -68,16 +68,22 @@ func (r Result) evidence() string { return strings.Join(parts, ", ") } -// witnessSize counts what a witness fixes: `2 inputs and 1 choice`, `1 choice`. +// witnessSize counts what a witness fixes: `2 inputs, 1 draw and 1 choice`, `1 choice`. func witnessSize(w *Witness) string { var parts []string if len(w.Inputs) > 0 { parts = append(parts, plural(len(w.Inputs), "input")) } + if len(w.Draws) > 0 { + parts = append(parts, plural(len(w.Draws), "draw")) + } if len(w.Choices) > 0 { parts = append(parts, plural(len(w.Choices), "choice")) } - return strings.Join(parts, " and ") + if len(parts) > 1 { + parts = append(parts[:len(parts)-2], parts[len(parts)-2]+" and "+parts[len(parts)-1]) + } + return strings.Join(parts, ", ") } // inputsEvidence spells what the result says of the initial state's inputs: the diff --git a/internal/exec/analysis/sweep.go b/internal/exec/analysis/sweep.go new file mode 100644 index 0000000000..f99106b225 --- /dev/null +++ b/internal/exec/analysis/sweep.go @@ -0,0 +1,84 @@ +package analysis + +import ( + "context" + "fmt" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// SweepEngineName is the name of the engine that runs a domain row by row. +const SweepEngineName = "sweep" + +// sweepEngine answers Sweep questions with one run per row of the plan, each in a context +// of its own on one of the plan's workers, the rows tabled in the order the plan draws them. +type sweepEngine struct{} + +// NewSweep returns the sweep engine. +func NewSweep() Engine { return sweepEngine{} } + +// Name is `sweep`. +func (sweepEngine) Name() string { return SweepEngineName } + +// Describe: concrete runs over a stated domain, so every row is observed. +func (sweepEngine) Describe() Description { + return Description{ + Questions: []Kind{Sweep}, + Bounds: []string{"runs"}, + Authority: Observed, + } +} + +// Covers takes a Sweep question that fixes every choice and says how one row runs. +func (e sweepEngine) Covers(_ *Model, q Question) Coverage { + if q.Kind != Sweep { + return refused(&NotAskedError{Engine: e.Name(), Kind: q.Kind}) + } + if q.Free != FreeNothing { + return refused(&FreedomError{Engine: e.Name(), Free: q.Free}) + } + if q.Sweep == nil || q.Sweep.Row == nil { + return refused(&MalformedQuestionError{Kind: q.Kind, Missing: "a Sweep with a Row"}) + } + return covered +} + +// Run tables the plan within the budget's runs (else the row contexts'), the rows on the +// budget's jobs, each in a context of its own under the budget on the worker of the job +// making it; one evaluation per row (a failed row carrying its error), and a plan of more +// rows than that, or a caller that went away, is the error. A model that builds no context +// of a run's own is the typed fault NoRuntimeError. +func (e sweepEngine) Run(ctx context.Context, model *Model, q Question, budget Budget) (Result, error) { + if !model.builds() { + return Result{}, &NoRuntimeError{Engine: e.Name()} + } + first, err := q.fresh(model, 0, budget) + if err != nil { + return Result{}, err + } + runs := int64(budget.Runs) + if runs <= 0 { + runs = first.SweepRunBudget() + } + fresh := func(job int) (*runtime.Context, error) { return q.fresh(model, job, budget) } + started := time.Now() + table, err := runtime.RunSweepWith(ctx, runtime.SweepWorkers{First: first, Jobs: budget.Jobs, Fresh: fresh}, q.Subject, q.Sweep.Plan, runs, q.Sweep.Row) + if err != nil { + return Result{}, err + } + values := make([]Evaluation, len(table.Rows)) + for i := range table.Rows { + row := &table.Rows[i] + values[i] = Evaluation{Name: fmt.Sprintf("row %d", i+1), Row: row, Err: row.Err} + } + return Result{ + Question: q, + Engine: e.Name(), + Claim: ClaimTable, + Strength: Observed, + Bounds: Bounds{{Name: "runs", Limit: runs}}, + Values: values, + Elapsed: time.Since(started), + }, nil +} diff --git a/internal/exec/analysis/sweep_test.go b/internal/exec/analysis/sweep_test.go new file mode 100644 index 0000000000..c454429af0 --- /dev/null +++ b/internal/exec/analysis/sweep_test.go @@ -0,0 +1,156 @@ +package analysis + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// doubleRow runs the fixture's calc once per row, in the row's context, with the swept +// parameter bound. +func doubleRow(t *testing.T, f *fixture) runtime.SweepRun { + t.Helper() + double := f.symbol(t, "Double") + return func(ctx *runtime.Context, bindings []runtime.SweepBinding) (runtime.SweepRunResult, error) { + bound := make(map[string]runtime.Value, len(bindings)) + for _, b := range bindings { + bound[b.Param] = b.Value + } + value, err := ctx.InvokeCalcWith(double, nil, bound, f.pkg) + if err != nil { + return runtime.SweepRunResult{}, err + } + return runtime.SweepRunResult{Outputs: []runtime.CalcOutputValue{{Name: "result", Value: value}}}, nil + } +} + +// doublePlan sweeps x over 1..3 as the calc's parameter types it. +func doublePlan(t *testing.T, f *fixture, ctx *runtime.Context) runtime.SweepPlan { + t.Helper() + plan := runtime.SweepPlan{Ranges: []runtime.SweepRange{{Param: "x", From: intOf(1), To: intOf(3)}}} + resolved, err := ctx.ResolveSweepPlan(f.symbol(t, "Double"), plan, 0, nil) + if err != nil { + t.Fatalf("resolve the plan: %v", err) + } + return resolved +} + +// sweepIn is the one-job sweep with every row in ctx, as a test of a plan's rows takes it. +func sweepIn(ctx *runtime.Context, target string, plan runtime.SweepPlan, run runtime.SweepRun) (runtime.SweepTable, error) { + return runtime.RunSweepWith(context.Background(), runtime.SweepWorkers{First: ctx, Jobs: 1, Fresh: func(int) (*runtime.Context, error) { return ctx, nil }}, target, plan, 0, run) +} + +func TestSweepObservesATable(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + q := Question{Kind: Sweep, Subject: "test::Double", Schedule: ctx.Schedule(), Sweep: &SweepAsk{Plan: doublePlan(t, f, ctx), Row: doubleRow(t, f)}} + plan := answered(t, Default(), f.building(), q, Budget{}) + result := plan.Result + if result.Engine != SweepEngineName || result.Claim != ClaimTable || result.Strength != Observed { + t.Fatalf("result %+v, want sweep's observed table", result) + } + table := result.Table() + if table.Target != "test::Double" || len(table.Rows) != 3 || len(table.Params) != 1 || table.Params[0] != "x" { + t.Fatalf("table %+v, want x over 3 rows", table) + } + if len(result.Values) != 3 { + t.Fatalf("values %+v, want one per row", result.Values) + } + for i, row := range table.Rows { + want := int64(2 * (i + 1)) + if row.Err != nil || len(row.Outputs) != 1 || row.Outputs[0].Value.Const.Int != want { + t.Fatalf("row %d %+v, want result = %d", i, row, want) + } + if row.Context == nil || row.Context == ctx { + t.Fatalf("row %d ran in %p, want a context of its own", i, row.Context) + } + if result.Values[i].Row == nil || result.Values[i].Row.Outputs[0].Value.Const.Int != want || result.Values[i].Name != fmt.Sprintf("row %d", i+1) { + t.Fatalf("value %d %+v, want row %d", i, result.Values[i], i+1) + } + } + if runs, ok := result.Bounds.Limit("runs"); !ok || runs != ctx.SweepRunBudget() || result.Bounds.Reached() { + t.Fatalf("bounds %s, want the runtime's runs budget unreached", result.Bounds) + } + if result.Workers != 1 || plan.Workers != 1 { + t.Fatalf("workers %d and %d, want the one the plan built for its rows", result.Workers, plan.Workers) + } +} + +func TestSweepTakesTheBudgetsRuns(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + q := Question{Kind: Sweep, Subject: "test::Double", Schedule: ctx.Schedule(), Sweep: &SweepAsk{Plan: doublePlan(t, f, ctx), Row: doubleRow(t, f)}} + result := answered(t, Default(), f.building(), q, Budget{Runs: 5}).Result + if len(result.Values) != 3 { + t.Fatalf("values %+v, want the 3 rows within 5 runs", result.Values) + } + if runs, ok := result.Bounds.Limit("runs"); !ok || runs != 5 || result.Bounds.Reached() { + t.Fatalf("bounds %s, want the budget's 5 runs unreached", result.Bounds) + } + _, err := Default().Answer(context.Background(), f.building(), q, Budget{Runs: 2}) + if !errors.Is(err, runtime.ErrSweepBudget) || !strings.Contains(err.Error(), "at most 2 allowed") { + t.Fatalf("3 rows within 2 runs: %v, want the runtime's refusal of the budget's 2", err) + } +} + +func TestRegistrySweepIsTheRuntimesSweep(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + plan, err := Default().Sweep(context.Background(), request(f.building(), "test::Double", ctx.Schedule()), doublePlan(t, f, ctx), doubleRow(t, f)) + if err != nil { + t.Fatalf("sweep: %v", err) + } + table := plan.Result.Table() + direct, err := sweepIn(ctx, "test::Double", doublePlan(t, f, ctx), doubleRow(t, f)) + if err != nil { + t.Fatalf("direct sweep: %v", err) + } + if len(table.Rows) != len(direct.Rows) || table.Target != direct.Target { + t.Fatalf("table %+v, want the runtime's own %+v", table, direct) + } + for i := range direct.Rows { + if table.Rows[i].Outputs[0].Value.Const.Int != direct.Rows[i].Outputs[0].Value.Const.Int { + t.Fatalf("row %d %+v, want %+v", i, table.Rows[i], direct.Rows[i]) + } + } +} + +func TestSweepRefusalIsTheRuntimes(t *testing.T) { + f := parseFixture(t) + ctx := f.context(t) + plan := runtime.SweepPlan{Ranges: []runtime.SweepRange{{Param: "x", From: intOf(1), To: intOf(4), Step: intOf(0), HasStep: true}}} + _, err := Default().Sweep(context.Background(), request(f.building(), "test::Double", ctx.Schedule()), plan, doubleRow(t, f)) + if !errors.Is(err, runtime.ErrSweepRange) { + t.Fatalf("sweep with a zero step: %v, want the runtime's range refusal", err) + } + _, direct := sweepIn(ctx, "test::Double", plan, doubleRow(t, f)) + if direct == nil || err.Error() != direct.Error() { + t.Fatalf("sweep: %v, want the runtime's own %v", err, direct) + } +} + +func TestSweepRefusesWhatItCannotRun(t *testing.T) { + e := NewSweep() + ask := &SweepAsk{Row: func(*runtime.Context, []runtime.SweepBinding) (runtime.SweepRunResult, error) { + return runtime.SweepRunResult{}, nil + }} + if c := e.Covers(nil, Question{Kind: Evaluate}); c.Covered || !errors.Is(c.Refusal, ErrNotAsked) { + t.Fatalf("evaluate: %+v, want not asked", c) + } + if c := e.Covers(nil, Question{Kind: Sweep, Free: FreeSchedule, Sweep: ask}); c.Covered || !errors.Is(c.Refusal, ErrFreedom) { + t.Fatalf("free schedule: %+v, want a freedom refusal", c) + } + if c := e.Covers(nil, Question{Kind: Sweep}); c.Covered || !errors.Is(c.Refusal, ErrMalformedQuestion) { + t.Fatalf("no ask: %+v, want malformed", c) + } + if c := e.Covers(nil, Question{Kind: Sweep, Sweep: &SweepAsk{}}); c.Covered || !errors.Is(c.Refusal, ErrMalformedQuestion) { + t.Fatalf("no row: %+v, want malformed", c) + } + if c := e.Covers(nil, Question{Kind: Sweep, Sweep: ask}); !c.Covered { + t.Fatalf("sweep: %+v, want covered", c) + } +} diff --git a/internal/exec/analysis/testdata/enginestandin/main.go b/internal/exec/analysis/testdata/enginestandin/main.go new file mode 100644 index 0000000000..4e01f542d7 --- /dev/null +++ b/internal/exec/analysis/testdata/enginestandin/main.go @@ -0,0 +1,371 @@ +// Command enginestandin stands in for an external engine in the tests of the engine +// protocol: it speaks JSON-RPC lines on standard input and output, describes itself as +// ENGINE_STANDIN_DESCRIBE says, answers covers with ENGINE_STANDIN_COVERS and run with +// ENGINE_STANDIN_RESULT, and misbehaves as ENGINE_STANDIN_MODE says. It is built by the +// tests that run it and lives under testdata, out of every build. +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis/enginewire" +) + +// The stand-in's variables. +const ( + // DescribeEnv is the description JSON the stand-in answers describe with. + DescribeEnv = "ENGINE_STANDIN_DESCRIBE" + // CoversEnv is the covers result JSON; unset answers covers true. + CoversEnv = "ENGINE_STANDIN_COVERS" + // ResultEnv is the result JSON every run answers with; unset answers a `none` result. + ResultEnv = "ENGINE_STANDIN_RESULT" + // ModeEnv selects a misbehavior; the empty mode answers every request. + ModeEnv = "ENGINE_STANDIN_MODE" + // RecordEnv names a file every event is appended to, one line each: `start`, + // ` `, `open ` at each run's start, `cancel `, `exit`. + RecordEnv = "ENGINE_STANDIN_RECORD" + // DelayEnv is how long a run takes before it answers, a duration. + DelayEnv = "ENGINE_STANDIN_DELAY" + // ProgressEnv is how many progress notifications a run sends before it answers. + ProgressEnv = "ENGINE_STANDIN_PROGRESS" + // WireEnv names a directory; each process writes every line it reads and writes to + // `.wire` there, `< ` before a host's line and `> ` before its own. + WireEnv = "ENGINE_STANDIN_WIRE" +) + +// The modes. +const ( + ModeAnswer = "" + ModeSilentDescribe = "silent-describe" + ModeExitAtStart = "exit-at-start" + ModeExitDuringRun = "exit-during-run" + ModeCancelAnswers = "cancel-answers" + ModeCancelIgnored = "cancel-ignored" + ModeErrorUnsupported = "error-unsupported" + ModeErrorBudget = "error-budget" + ModeErrorInternal = "error-internal" + ModeErrorNoCode = "error-no-code" + ModeErrorUnknownCode = "error-unknown-code" + ModeNotJSON = "not-json" + ModeWrongJSONRPC = "wrong-jsonrpc" + ModeNoID = "no-id" + ModeNullID = "null-id" + ModeStringID = "string-id" + ModeUnknownID = "unknown-id" + ModeResultAndError = "result-and-error" + ModeNeitherResultNorErr = "neither" + ModeEngineRequest = "engine-request" + ModeUnknownNotification = "unknown-notification" + ModeProgressUnknownRun = "progress-unknown-run" + ModeOverflow = "overflow" + ModeStderr = "stderr" + ModeStderrFlood = "stderr-flood" + ModeStderrFloodAndExit = "stderr-flood-exit" + ModeSlowRun = "slow-run" +) + +func main() { + if err := serve(); err != nil { + fmt.Fprintln(os.Stderr, "enginestandin:", err) + os.Exit(3) + } +} + +// engine is the stand-in's state: its output, the runs cancelled, the runs open. +type engine struct { + mode string + out *bufio.Writer + outMu sync.Mutex + record *os.File + recMu sync.Mutex + wire *os.File + wireMu sync.Mutex + cancels sync.Map + open atomic.Int64 + wg sync.WaitGroup +} + +func serve() error { + e := &engine{mode: os.Getenv(ModeEnv), out: bufio.NewWriter(os.Stdout)} + if path := os.Getenv(RecordEnv); path != "" { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) // #nosec G304 -- the test names the record file + if err != nil { + return err + } + e.record = f + defer f.Close() + } + if dir := os.Getenv(WireEnv); dir != "" { + f, err := os.OpenFile(dir+"/"+strconv.Itoa(os.Getpid())+".wire", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) // #nosec G304 -- the test names the directory + if err != nil { + return err + } + e.wire = f + defer f.Close() + } + e.log("start") + if e.mode == ModeExitAtStart { + fmt.Fprintln(os.Stderr, "cannot load formalism") + os.Exit(2) + } + r := bufio.NewReaderSize(os.Stdin, 1<<20) + for { + line, err := readLine(r) + if err == io.EOF { + break + } + if err != nil { + return err + } + if len(strings.TrimSpace(string(line))) == 0 { + continue + } + e.tap('<', strings.TrimRight(string(line), "\n")) + var msg enginewire.Message + if err := json.Unmarshal(line, &msg); err != nil { + return fmt.Errorf("host sent a line that is not a message: %v", err) + } + if err := e.take(msg); err != nil { + return err + } + } + e.wg.Wait() + e.log("exit") + return nil +} + +// readLine reads one line of any length. +func readLine(r *bufio.Reader) ([]byte, error) { + var line []byte + for { + part, err := r.ReadSlice('\n') + line = append(line, part...) + switch { + case err == nil: + return line, nil + case err == bufio.ErrBufferFull: + continue + case err == io.EOF && len(line) > 0: + return line, nil + default: + return nil, err + } + } +} + +// take dispatches one message from the host. +func (e *engine) take(msg enginewire.Message) error { + if msg.JSONRPC != enginewire.JSONRPC { + return fmt.Errorf("host sent jsonrpc %q", msg.JSONRPC) + } + e.log(msg.Method + " " + string(msg.ID)) + switch msg.Method { + case enginewire.MethodDescribe: + return e.describe(msg) + case enginewire.MethodCovers: + return e.covers(msg) + case enginewire.MethodRun: + var p enginewire.RunParams + if err := json.Unmarshal(msg.Params, &p); err != nil { + return fmt.Errorf("run params: %v", err) + } + e.wg.Add(1) + go func() { + defer e.wg.Done() + e.run(msg.ID, p) + }() + return nil + case enginewire.MethodCancel: + var p enginewire.CancelParams + if err := json.Unmarshal(msg.Params, &p); err != nil { + return fmt.Errorf("cancel params: %v", err) + } + e.log("cancel " + strconv.FormatInt(p.ID, 10)) + if ch, ok := e.cancels.Load(p.ID); ok { + close(ch.(chan struct{})) + } + return nil + } + return fmt.Errorf("host sent method %q", msg.Method) +} + +// describe answers with the description the environment gives, or a default one. +func (e *engine) describe(msg enginewire.Message) error { + if e.mode == ModeSilentDescribe { + return nil + } + if e.mode == ModeStderr { + fmt.Fprintln(os.Stderr, "the formalism is missing") + os.Exit(4) + } + text := os.Getenv(DescribeEnv) + if text == "" { + text = `{"name":"standin","version":"1.0.0","protocol":1,"answers":["holds"]}` + } + return e.write(enginewire.Message{JSONRPC: enginewire.JSONRPC, ID: msg.ID, Result: json.RawMessage(text)}) +} + +// covers answers with the covers result the environment gives, or true. +func (e *engine) covers(msg enginewire.Message) error { + text := os.Getenv(CoversEnv) + if text == "" { + text = `{"covers":true}` + } + return e.write(enginewire.Message{JSONRPC: enginewire.JSONRPC, ID: msg.ID, Result: json.RawMessage(text)}) +} + +// run answers one run as the mode says, after the progress and the delay. +func (e *engine) run(id json.RawMessage, p enginewire.RunParams) { + runID, _ := strconv.ParseInt(string(id), 10, 64) + cancelled := make(chan struct{}) + e.cancels.Store(runID, cancelled) + defer e.cancels.Delete(runID) + e.log(fmt.Sprintf("open %d", e.open.Add(1))) + defer e.open.Add(-1) + + reply := func(result json.RawMessage, err *enginewire.Error) { + e.must(e.write(enginewire.Message{JSONRPC: enginewire.JSONRPC, ID: id, Result: result, Error: err})) + } + raw := func(line string) { e.must(e.raw(line)) } + fault := func(code string) { + reply(nil, &enginewire.Error{Code: code, Message: "the stand-in was told to fail with " + code}) + } + if n, _ := strconv.Atoi(os.Getenv(ProgressEnv)); n > 0 { + for i := 1; i <= n; i++ { + e.notify(enginewire.MethodProgress, enginewire.ProgressParams{ID: runID, Runs: int64(i), Depth: int64(i % 7)}) + } + } + if d, err := time.ParseDuration(os.Getenv(DelayEnv)); err == nil && d > 0 { + select { + case <-time.After(d): + case <-cancelled: + if e.mode != ModeCancelIgnored { + reply(json.RawMessage(`{"claim":"none","strength":"not covered","bounds":[{"name":"steps","limit":1,"reached":true}],"reason":"cancelled"}`), nil) + return + } + } + } + switch e.mode { + case ModeExitDuringRun: + fmt.Fprintln(os.Stderr, "segmentation fault (stand-in)") + os.Exit(11) + case ModeCancelAnswers: + <-cancelled + reply(json.RawMessage(`{"claim":"none","strength":"not covered","bounds":[{"name":"steps","limit":1,"reached":true}],"reason":"cancelled"}`), nil) + case ModeCancelIgnored: + select {} + case ModeErrorUnsupported: + fault(enginewire.CodeUnsupported) + case ModeErrorBudget: + fault(enginewire.CodeBudget) + case ModeErrorInternal: + fault(enginewire.CodeInternal) + case ModeErrorNoCode: + raw(`{"jsonrpc":"2.0","id":` + string(id) + `,"error":{"message":"no code"}}`) + case ModeErrorUnknownCode: + raw(`{"jsonrpc":"2.0","id":` + string(id) + `,"error":{"code":"unsuported","message":"misspelt"}}`) + case ModeNotJSON: + raw(`this is not JSON`) + case ModeWrongJSONRPC: + raw(`{"jsonrpc":"1.0","id":` + string(id) + `,"result":{"claim":"none","strength":"not covered"}}`) + case ModeNoID: + raw(`{"jsonrpc":"2.0","result":{"claim":"none","strength":"not covered"}}`) + case ModeNullID: + raw(`{"jsonrpc":"2.0","id":null,"result":{"claim":"none","strength":"not covered"}}`) + case ModeStringID: + raw(`{"jsonrpc":"2.0","id":"` + string(id) + `","result":{"claim":"none","strength":"not covered"}}`) + case ModeUnknownID: + raw(`{"jsonrpc":"2.0","id":987654,"result":{"claim":"none","strength":"not covered"}}`) + case ModeResultAndError: + raw(`{"jsonrpc":"2.0","id":` + string(id) + `,"result":{"claim":"none","strength":"not covered"},"error":{"code":"internal","message":"both"}}`) + case ModeNeitherResultNorErr: + raw(`{"jsonrpc":"2.0","id":` + string(id) + `}`) + case ModeEngineRequest: + raw(`{"jsonrpc":"2.0","id":1,"method":"describe","params":{}}`) + case ModeUnknownNotification: + raw(`{"jsonrpc":"2.0","method":"log","params":{"text":"hello"}}`) + case ModeProgressUnknownRun: + e.notify(enginewire.MethodProgress, enginewire.ProgressParams{ID: runID + 1000, Runs: 1}) + case ModeStderrFlood: + for { + fmt.Fprintln(os.Stderr, strings.Repeat("x", 1<<16)) + } + case ModeStderrFloodAndExit: + fmt.Fprintln(os.Stderr, strings.Repeat("x", 64<<10)) + os.Exit(0) + case ModeOverflow: + raw(`{"jsonrpc":"2.0","id":` + string(id) + `,"result":{"claim":"none","strength":"not covered","reason":"` + strings.Repeat("x", 1<<20) + `"}}`) + case ModeSlowRun: + time.Sleep(time.Hour) + default: + text := os.Getenv(ResultEnv) + if text == "" { + text = `{"claim":"none","strength":"not covered","reason":"the stand-in has no answer"}` + } + reply(json.RawMessage(text), nil) + } +} + +// notify writes one notification. +func (e *engine) notify(method string, params any) { + raw, err := json.Marshal(params) + e.must(err) + e.must(e.write(enginewire.Message{JSONRPC: enginewire.JSONRPC, Method: method, Params: raw})) +} + +// must ends the stand-in on a write the host no longer reads. +func (e *engine) must(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "enginestandin:", err) + os.Exit(3) + } +} + +// write frames one message as a line. +func (e *engine) write(msg enginewire.Message) error { + line, err := json.Marshal(msg) + if err != nil { + return err + } + return e.raw(string(line)) +} + +// raw writes one line as it is. +func (e *engine) raw(line string) error { + e.tap('>', line) + e.outMu.Lock() + defer e.outMu.Unlock() + if _, err := e.out.WriteString(line + "\n"); err != nil { + return err + } + return e.out.Flush() +} + +// tap records one line of the wire in the direction given, when a directory is named. +func (e *engine) tap(direction byte, line string) { + if e.wire == nil { + return + } + e.wireMu.Lock() + defer e.wireMu.Unlock() + fmt.Fprintf(e.wire, "%c %s\n", direction, line) +} + +// log appends one event to the record file, when one is named. +func (e *engine) log(event string) { + if e.record == nil { + return + } + e.recMu.Lock() + defer e.recMu.Unlock() + fmt.Fprintf(e.record, "%d %s\n", os.Getpid(), event) +} diff --git a/internal/core/analysis/testdata/toolstandin/main.go b/internal/exec/analysis/testdata/toolstandin/main.go similarity index 100% rename from internal/core/analysis/testdata/toolstandin/main.go rename to internal/exec/analysis/testdata/toolstandin/main.go diff --git a/internal/exec/analysis/tool.go b/internal/exec/analysis/tool.go new file mode 100644 index 0000000000..a683f0c9db --- /dev/null +++ b/internal/exec/analysis/tool.go @@ -0,0 +1,494 @@ +package analysis + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "math" + "os/exec" + "sort" + "strconv" + "strings" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// ToolEnginePrefix starts the name of every tool engine: `tool:ModelCenter`. +const ToolEnginePrefix = "tool:" + +// ToolEngineName is the name of the engine answering for the tool named. +func ToolEngineName(tool string) string { return ToolEnginePrefix + tool } + +// ComputeAsk is the invocation a Compute question asks for: one performance of an action +// annotated ToolExecution, as the runtime hands it to the tool. +type ComputeAsk struct { + Call *runtime.ToolCall +} + +// toolEngine answers Compute questions for one manifest entry by running its executable +// once per invocation, with the protocol below over its standard input and output. +type toolEngine struct { + entry ToolEntry + look func(ToolEntry) (string, error) + timeout func() time.Duration + limit func() int +} + +// NewTool returns the `tool:` engine of a manifest entry. It registers whether or not +// the executable is found and refuses through Covers while it is not. +func NewTool(entry ToolEntry) External { + return toolEngine{entry: entry, look: lookExecutable, timeout: toolTimeoutFromEnv, limit: outputLimitFromEnv} +} + +// Name is `tool:` and the tool's name. +func (e toolEngine) Name() string { return ToolEngineName(e.entry.ToolName) } + +// Describe: one process per invocation, whose answer nothing in the build can check. +func (e toolEngine) Describe() Description { + return Description{ + Questions: []Kind{Compute}, + Process: fmt.Sprintf("the executable %s of tool '%s'", e.entry.Executable, e.entry.ToolName), + Bounds: []string{"tool"}, + Authority: Observed, + } +} + +// Origin is the tool's manifest entry. +func (e toolEngine) Origin() Origin { + return Origin{Kind: KindTool, Version: e.entry.Version, File: e.entry.File, Command: e.entry.Executable} +} + +// Process names the executable found, with the tool's version, or reports its absence. +func (e toolEngine) Process() (string, error) { + path, err := e.look(e.entry) + if err != nil { + return "", &ProcessAbsentError{Engine: e.Name(), Process: e.Describe().Process, Err: err} + } + if e.entry.Version != "" { + return fmt.Sprintf("%s %s at %s", e.entry.ToolName, e.entry.Version, path), nil + } + return e.entry.ToolName + " at " + path, nil +} + +// Covers takes a Compute question whose call names this tool with variables it accepts, +// when the executable is found. +func (e toolEngine) Covers(_ *Model, q Question) Coverage { + if q.Kind != Compute { + return refused(&NotAskedError{Engine: e.Name(), Kind: q.Kind}) + } + if q.Compute == nil || q.Compute.Call == nil { + return refused(&MalformedQuestionError{Kind: q.Kind, Missing: "a Compute with a Call"}) + } + call := q.Compute.Call + if call.ToolName != e.entry.ToolName { + return refused(&WrongToolError{Engine: e.Name(), Tool: call.ToolName}) + } + if unknown := e.unaccepted(call); len(unknown) > 0 { + return refused(&ToolVariableError{Tool: e.entry.ToolName, Variables: unknown}) + } + if _, err := e.Process(); err != nil { + return refused(err) + } + return covered +} + +// unaccepted is every tool variable the call uses that the manifest entry does not accept. +func (e toolEngine) unaccepted(call *runtime.ToolCall) []string { + var unknown []string + seen := make(map[string]bool) + for _, in := range call.Inputs { + if !seen[in.Variable] && !e.entry.Accepts(in.Variable) { + unknown, seen[in.Variable] = append(unknown, in.Variable), true + } + } + for _, out := range call.Outputs { + if !seen[out.Variable] && !e.entry.Accepts(out.Variable) { + unknown, seen[out.Variable] = append(unknown, out.Variable), true + } + } + sort.Strings(unknown) + return unknown +} + +// Run invokes the tool once: the call as one JSON object on its standard input, its one +// JSON object on standard output bound to the call's outputs. Every failure of the process +// or the protocol is a runtime.ToolError, which fails the performance that asked. +func (e toolEngine) Run(ctx context.Context, _ *Model, q Question, _ Budget) (Result, error) { + if err := ctx.Err(); err != nil { + return Result{}, err + } + if q.Compute == nil || q.Compute.Call == nil { + return Result{}, &MalformedQuestionError{Kind: q.Kind, Missing: "a Compute with a Call"} + } + call := q.Compute.Call + path, err := e.look(e.entry) + if err != nil { + return Result{}, &ProcessAbsentError{Engine: e.Name(), Process: e.Describe().Process, Err: err} + } + request, err := ToolRequestOf(call) + if err != nil { + return Result{}, err + } + timeout := e.timeout() + started := time.Now() + reply, err := e.invoke(ctx, path, request, timeout, e.outputLimit()) + if err != nil { + return Result{}, err + } + bound, err := call.Bind(reply) + if err != nil { + return Result{}, err + } + values := make([]Evaluation, 0, len(bound)) + for name, value := range bound { + values = append(values, Evaluation{Name: name, Value: value}) + } + sort.Slice(values, func(i, j int) bool { return values[i].Name < values[j].Name }) + return Result{ + Question: q, + Engine: e.Name(), + Claim: ClaimValue, + Strength: Observed, + Values: values, + Reply: renderReply(reply), + Bounds: Bounds{{Name: "tool", Limit: timeout.Milliseconds()}}, + Elapsed: time.Since(started), + }, nil +} + +// renderReply spells the tool's reply canonically, by variable name, as it was written and +// before binding: two invocations of equal inputs compare by it. +func renderReply(reply map[string]runtime.ToolValue) string { + parts := make([]string, 0, len(reply)) + for variable, v := range reply { + part := variable + "=" + if v.Value.Kind == semantics.ValInvalid { + part += strconv.Quote(v.Text) + } else { + part += semantics.FormatConst(v.Value) + } + if v.Unit != "" { + part += " [" + v.Unit + "]" + } + parts = append(parts, part) + } + sort.Strings(parts) + return strings.Join(parts, " ") +} + +// outputLimit is the bound on one reply, OutputLimitEnv's unless the engine was built without it. +func (e toolEngine) outputLimit() int { + if e.limit == nil { + return outputLimitFromEnv() + } + return e.limit() +} + +// invoke runs the executable once under the timeout and reads its reply. +func (e toolEngine) invoke(ctx context.Context, path string, request []byte, timeout time.Duration, limit int) (map[string]runtime.ToolValue, error) { + tool := e.entry.ToolName + tctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd := exec.CommandContext(tctx, path) + cmd.Stdin = bytes.NewReader(request) + stdout, stderr := newBoundedBuffer(limit, cancel), newBoundedBuffer(limit, cancel) + cmd.Stdout, cmd.Stderr = stdout, stderr + cmd.WaitDelay = time.Second + err := cmd.Run() + switch { + case ctx.Err() != nil: + return nil, ctx.Err() + case stdout.Over() || stderr.Over(): + stream := "output" + if stderr.Over() { + stream = "error" + } + return nil, &runtime.ToolError{Tool: tool, Kind: runtime.ToolMalformed, + Detail: fmt.Sprintf("%s wrote more than %d bytes to standard %s (%s)", path, limit, stream, OutputLimitEnv)} + case errors.Is(tctx.Err(), context.DeadlineExceeded): + return nil, &runtime.ToolError{Tool: tool, Kind: runtime.ToolTimeout, + Detail: fmt.Sprintf("%s did not answer within %s (%s)", path, timeout, ToolTimeoutEnv)} + case err != nil: + return nil, &runtime.ToolError{Tool: tool, Kind: runtime.ToolProcessFailed, Detail: processDetail(path, err, stderr.Bytes())} + } + return ToolReplyOf(tool, stdout.Bytes()) +} + +// processDetail spells a failed process: how it exited and what it wrote to standard error. +func processDetail(path string, err error, stderr []byte) string { + detail := path + ": " + err.Error() + if text := strings.TrimSpace(string(stderr)); text != "" { + detail += ": " + text + } + return detail +} + +// toolRequest is the JSON object one invocation writes to the tool. +type toolRequest struct { + ToolName string `json:"toolName"` + URI string `json:"uri"` + Inputs map[string]protocolValue `json:"inputs"` +} + +// toolReply is the JSON object the tool writes back: outputs, or an error. Both are kept +// raw so a member written as null is told apart from one not written. +type toolReply struct { + Outputs json.RawMessage `json:"outputs"` + Error json.RawMessage `json:"error"` +} + +// jsonNull reports a raw member written as the literal null. +func jsonNull(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("null")) +} + +// protocolValue is one value on the wire: a JSON number, boolean or string, and for a +// quantity the unit expression it is measured in. +type protocolValue struct { + Value json.RawMessage `json:"value"` + Unit string `json:"unit,omitempty"` +} + +// wiredValue is a protocolValue as the tool wrote it, the unit kept raw so one written as +// null or as no text is told apart from one omitted. +type wiredValue struct { + Value json.RawMessage `json:"value"` + Unit json.RawMessage `json:"unit"` +} + +// ToolRequestOf is the JSON object the protocol writes to the tool for one call: the +// toolName and uri passed through, the inputs keyed by ToolVariable name. Its bytes are +// the same for equal inputs, so two invocations compare by them. +func ToolRequestOf(call *runtime.ToolCall) ([]byte, error) { + request := toolRequest{ToolName: call.ToolName, URI: call.URI, Inputs: make(map[string]protocolValue, len(call.Inputs))} + for _, in := range call.Inputs { + value, err := encodeValue(in.Value) + if err != nil { + return nil, &runtime.ToolError{Tool: call.ToolName, Kind: runtime.ToolUnsentInput, + Detail: fmt.Sprintf("%s (%s): %v", in.Variable, in.Parameter, err)} + } + request.Inputs[in.Variable] = protocolValue{Value: value, Unit: in.Value.Unit} + } + return json.Marshal(request) +} + +// encodeValue is one runtime value as JSON: an integer, a finite real, a truth, or a string. +func encodeValue(v runtime.ToolValue) (json.RawMessage, error) { + switch v.Value.Kind { + case semantics.ValInt: + return json.RawMessage(strconv.FormatInt(v.Value.Int, 10)), nil + case semantics.ValReal: + if math.IsInf(v.Value.Real, 0) || math.IsNaN(v.Value.Real) { + return nil, fmt.Errorf("%v is not a JSON number", v.Value.Real) + } + return json.RawMessage(strconv.FormatFloat(v.Value.Real, 'g', -1, 64)), nil + case semantics.ValBool: + return json.RawMessage(strconv.FormatBool(v.Value.Bool)), nil + case semantics.ValInvalid: + return json.Marshal(v.Text) + } + return nil, fmt.Errorf("%s is not a JSON value", semantics.FormatConst(v.Value)) +} + +// ToolReplyOf reads the tool's standard output as the protocol's one JSON object: the +// outputs keyed by ToolVariable name, or the tool's own error as a ToolError. +func ToolReplyOf(tool string, stdout []byte) (map[string]runtime.ToolValue, error) { + malformed := func(detail string, err error) error { + if err != nil { + detail += ": " + err.Error() + } + return &runtime.ToolError{Tool: tool, Kind: runtime.ToolMalformed, Detail: detail} + } + if len(bytes.TrimSpace(stdout)) == 0 { + return nil, malformed("the tool wrote nothing to standard output", nil) + } + var reply toolReply + if err := decodeOne(stdout, &reply); err != nil { + return nil, malformed("standard output is not one JSON object of outputs or error", err) + } + if path, twice := repeatedKey(stdout); twice { + return nil, malformed("the reply names "+path+" twice", nil) + } + if jsonNull(reply.Outputs) { + reply.Outputs = nil + } + switch { + case reply.Error != nil && reply.Outputs != nil: + return nil, malformed("the reply carries both outputs and an error", nil) + case jsonNull(reply.Error): + return nil, malformed("the reply's error is null, not a message", nil) + case reply.Error != nil: + var message string + if err := json.Unmarshal(reply.Error, &message); err != nil { + return nil, malformed("the reply's error is not a message", err) + } + return nil, &runtime.ToolError{Tool: tool, Kind: runtime.ToolRefused, Detail: message} + case reply.Outputs == nil: + return nil, malformed("the reply carries neither outputs nor an error", nil) + } + var wired map[string]wiredValue + if err := decodeOne(reply.Outputs, &wired); err != nil { + return nil, malformed("outputs is not an object of values", err) + } + outputs := make(map[string]runtime.ToolValue, len(wired)) + for name, raw := range wired { + value, err := decodeValue(raw) + if err != nil { + return nil, malformed(name+": "+err.Error(), nil) + } + outputs[name] = value + } + return outputs, nil +} + +// repeatedKey is the first key an object anywhere in a JSON document spells twice, as the +// dotted path to it, which a struct or map decode would hide by keeping the last spelling. +// Only well-formed JSON is walked; anything else is left to the decoder to report. +func repeatedKey(document []byte) (string, bool) { + dec := json.NewDecoder(bytes.NewReader(document)) + // One frame per open object or array; only an object's frame has seen keys. + type frame struct { + seen map[string]bool + inKey bool + } + var path []string + var open []*frame + valueDone := func() { + if top := len(open) - 1; top >= 0 && open[top].inKey { + open[top].inKey = false + path = path[:len(path)-1] + } + } + for { + tok, err := dec.Token() + if err != nil { + return "", false + } + switch tok { + case json.Delim('{'): + open = append(open, &frame{seen: make(map[string]bool)}) + continue + case json.Delim('['): + open = append(open, &frame{}) + continue + case json.Delim('}'), json.Delim(']'): + open = open[:len(open)-1] + valueDone() + continue + } + top := len(open) - 1 + if top >= 0 && open[top].seen != nil && !open[top].inKey { + key, _ := tok.(string) + if open[top].seen[key] { + return strings.Join(append(path, key), "."), true + } + open[top].seen[key], open[top].inKey = true, true + path = append(path, key) + continue + } + valueDone() + } +} + +// decodeValue reads one wire value: a JSON number as an Integer when it is one and fits, +// else a finite Real; a boolean as a truth; a string as text. Only a number carries a unit, +// which when written is a string of text. +func decodeValue(raw wiredValue) (runtime.ToolValue, error) { + if len(raw.Value) == 0 { + return runtime.ToolValue{}, errors.New("no value") + } + unit, err := decodeUnit(raw.Unit) + if err != nil { + return runtime.ToolValue{}, err + } + dec := json.NewDecoder(bytes.NewReader(raw.Value)) + dec.UseNumber() + var decoded any + if err := dec.Decode(&decoded); err != nil { + return runtime.ToolValue{}, err + } + out := runtime.ToolValue{Unit: unit} + switch v := decoded.(type) { + case json.Number: + if i, err := strconv.ParseInt(v.String(), 10, 64); err == nil { + out.Value = semantics.Value{Kind: semantics.ValInt, Int: i} + return out, nil + } + f, err := strconv.ParseFloat(v.String(), 64) + if err != nil || math.IsInf(f, 0) { + return runtime.ToolValue{}, fmt.Errorf("%s is not a finite number", v.String()) + } + out.Value = semantics.Value{Kind: semantics.ValReal, Real: f} + case bool: + if out.Unit != "" { + return runtime.ToolValue{}, fmt.Errorf("a boolean has no unit, got %q", out.Unit) + } + out.Value = semantics.Value{Kind: semantics.ValBool, Bool: v} + case string: + if out.Unit != "" { + return runtime.ToolValue{}, fmt.Errorf("a string has no unit, got %q", out.Unit) + } + out.Text = v + default: + return runtime.ToolValue{}, fmt.Errorf("%s is not a number, boolean or string", strings.TrimSpace(string(raw.Value))) + } + return out, nil +} + +// decodeUnit reads a wire value's unit: none when omitted, else a string of unit expression +// text; null or another kind of value is malformed. +func decodeUnit(raw json.RawMessage) (string, error) { + if len(raw) == 0 { + return "", nil + } + if jsonNull(raw) { + return "", errors.New("unit is null, not a unit expression") + } + var unit string + if err := json.Unmarshal(raw, &unit); err != nil { + return "", fmt.Errorf("unit %s is not a unit expression", strings.TrimSpace(string(raw))) + } + if strings.TrimSpace(unit) == "" { + return "", errors.New("unit is empty") + } + return strings.TrimSpace(unit), nil +} + +// ErrWrongTool is the typed error a tool engine refuses with for a call naming another tool. +var ErrWrongTool = errors.New("call names another tool") + +// WrongToolError reports a Compute question put to the engine of a tool it does not name. +type WrongToolError struct { + Engine string + Tool string +} + +// Error names the engine and the tool the call named. +func (e *WrongToolError) Error() string { + return fmt.Sprintf("%s does not answer for tool '%s'", e.Engine, e.Tool) +} + +// Is matches ErrWrongTool. +func (e *WrongToolError) Is(target error) bool { return target == ErrWrongTool } + +// ErrToolVariable is the typed error for a call using tool variables the manifest entry does not accept. +var ErrToolVariable = errors.New("tool does not accept the variable") + +// ToolVariableError reports the tool variables a call uses that the tool's entry does not list. +type ToolVariableError struct { + Tool string + Variables []string +} + +// Error names the tool and the variables. +func (e *ToolVariableError) Error() string { + return fmt.Sprintf("tool '%s' does not accept %s; its manifest entry lists the variables it does", + e.Tool, strings.Join(e.Variables, ", ")) +} + +// Is matches ErrToolVariable. +func (e *ToolVariableError) Is(target error) bool { return target == ErrToolVariable } diff --git a/internal/core/analysis/tool_fixture_test.go b/internal/exec/analysis/tool_fixture_test.go similarity index 97% rename from internal/core/analysis/tool_fixture_test.go rename to internal/exec/analysis/tool_fixture_test.go index 4b559200be..0a6934b37e 100644 --- a/internal/core/analysis/tool_fixture_test.go +++ b/internal/exec/analysis/tool_fixture_test.go @@ -12,15 +12,15 @@ import ( "sync" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" - "github.com/Open-MBEE/OpenSysML/internal/testutil/gobuild" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" + "github.com/Open-MBEE/OpenSysML/tests/testutil/gobuild" ) // pilotFixture is the pilot corpus file declaring ComputeDynamics, the action ToolExecution @@ -253,7 +253,9 @@ func parsePilot(t *testing.T) *pilot { func (p *pilot) semantics() (*runtime.Model, error) { resolver := resolve.New(p.idx) - return runtime.NewModel(semantics.NewModel(resolver), resolver), nil + model := runtime.NewModel(passes.NewTypedModel(resolver), resolver) + model.SetExpressionParser(parser.ParseOneExpression) + return model, nil } func (p *pilot) fresh(w *Worker) (*runtime.Context, error) { diff --git a/internal/core/analysis/tool_runner.go b/internal/exec/analysis/tool_runner.go similarity index 96% rename from internal/core/analysis/tool_runner.go rename to internal/exec/analysis/tool_runner.go index 2631a8ff47..d735380016 100644 --- a/internal/core/analysis/tool_runner.go +++ b/internal/exec/analysis/tool_runner.go @@ -5,8 +5,8 @@ import ( "errors" "sync" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // toolRunner is the runner one plan attaches to every context its runs use: each diff --git a/internal/exec/analysis/tool_test.go b/internal/exec/analysis/tool_test.go new file mode 100644 index 0000000000..fdf6074cac --- /dev/null +++ b/internal/exec/analysis/tool_test.go @@ -0,0 +1,324 @@ +package analysis + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// writeManifest writes the given files into a manifest directory of the test's own. +func writeManifest(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, text := range files { + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestManifestReadsOneEntryPerJSONFile(t *testing.T) { + dir := writeManifest(t, map[string]string{ + "modelcenter.json": `{"toolName": "ModelCenter", "version": "2024.1", "executable": "/opt/mc/bin/mc", "variables": ["deltaT", "a"]}`, + "local.json": `{"toolName": "Local", "executable": "bin/local", "variables": []}`, + "onpath.json": `{"toolName": "OnPath", "executable": "octave", "variables": ["x"]}`, + "README.md": "not an entry", + "nested/x.json": `{"toolName": "Nested", "executable": "x", "variables": []}`, + }) + entries, err := LoadManifest(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(entries) != 3 || entries[0].ToolName != "Local" || entries[1].ToolName != "ModelCenter" || entries[2].ToolName != "OnPath" { + t.Fatalf("entries %+v, want Local, ModelCenter, OnPath in name order from the top-level .json files", entries) + } + if e := entries[1]; e.Version != "2024.1" || e.Executable != "/opt/mc/bin/mc" || len(e.Variables) != 2 || e.File != filepath.Join(dir, "modelcenter.json") { + t.Errorf("ModelCenter %+v, want its fields as written", e) + } + if e := entries[0]; e.Executable != filepath.Join(dir, "bin", "local") { + t.Errorf("Local's executable %s, want it resolved against the entry's directory", e.Executable) + } + if e := entries[2]; e.Executable != "octave" { + t.Errorf("OnPath's executable %s, want the bare name kept for PATH", e.Executable) + } + if !entries[1].Accepts("deltaT") || entries[1].Accepts("b") { + t.Errorf("ModelCenter accepts %v, want the listed variables alone", entries[1].Variables) + } +} + +func TestManifestFaultsAreTyped(t *testing.T) { + cases := map[string]struct { + text string + detail string + }{ + "not JSON": {`toolName = MC`, "not one JSON object"}, + "two objects": {`{"toolName":"A","executable":"a","variables":[]} {"toolName":"B","executable":"b","variables":[]}`, "not one JSON object"}, + "unknown field": {`{"toolName":"A","executable":"a","variables":[],"timeout":"1s"}`, "not one JSON object"}, + "no toolName": {`{"executable":"a","variables":[]}`, "toolName is empty"}, + "blank toolName": {`{"toolName":" ","executable":"a","variables":[]}`, "toolName is empty"}, + "spaced toolName": {`{"toolName":"Model Center","executable":"a","variables":[]}`, "has whitespace"}, + "no executable": {`{"toolName":"A","variables":["x"]}`, "executable is empty"}, + "no variables": {`{"toolName":"A","executable":"a"}`, "variables is missing"}, + "empty variable": {`{"toolName":"A","executable":"a","variables":["x",""]}`, "empty name"}, + "twice variable": {`{"toolName":"A","executable":"a","variables":["x","x"]}`, `lists "x" twice`}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + dir := writeManifest(t, map[string]string{"entry.json": tc.text}) + _, err := LoadManifest(dir) + var fault *ManifestError + if !errors.As(err, &fault) || !errors.Is(err, ErrManifest) { + t.Fatalf("load: %v, want a ManifestError", err) + } + if fault.Path != filepath.Join(dir, "entry.json") || !strings.Contains(fault.Detail, tc.detail) { + t.Errorf("fault %+v, want the entry named with %q", fault, tc.detail) + } + if !strings.HasPrefix(err.Error(), ToolsEnv+": ") { + t.Errorf("error %q, want it to open with the variable to fix", err) + } + }) + } +} + +func TestManifestRefusesTwoEntriesForOneTool(t *testing.T) { + dir := writeManifest(t, map[string]string{ + "a.json": `{"toolName":"MC","executable":"a","variables":[]}`, + "b.json": `{"toolName":"MC","executable":"b","variables":[]}`, + }) + _, err := LoadManifest(dir) + var fault *ManifestError + if !errors.As(err, &fault) || fault.Path != filepath.Join(dir, "b.json") || !strings.Contains(fault.Detail, `tool "MC" is also the entry`) { + t.Fatalf("load: %v, want the second entry refused as a duplicate", err) + } +} + +func TestManifestDirectoryMustBeReadable(t *testing.T) { + missing := filepath.Join(t.TempDir(), "none") + _, err := LoadManifest(missing) + var fault *ManifestError + if !errors.As(err, &fault) || fault.Path != missing || fault.Err == nil { + t.Fatalf("load: %v, want a ManifestError with the directory's fault", err) + } + t.Setenv(ToolsEnv, missing) + if _, err := DefaultFromEnv(); !errors.Is(err, ErrManifest) { + t.Fatalf("DefaultFromEnv: %v, want the manifest's fault", err) + } +} + +// Unset, the environment adds no tool and the default engines are the build's. +func TestExternalsFromEnvUnsetIsNoTool(t *testing.T) { + t.Setenv(ToolsEnv, "") + tools, err := ExternalsFromEnv() + if err != nil || len(tools) != 0 { + t.Fatalf("tools %v, %v; want none", tools, err) + } + r, err := DefaultFromEnv() + if err != nil { + t.Fatal(err) + } + if got, want := names(r), names(Default()); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("engines %v, want the build's %v", got, want) + } +} + +// Every manifest entry is an engine tool:, listed in name order among the build's +// engines with its process status, and the one engine answering compute. +func TestExternalsFromEnvRegisterEachEntry(t *testing.T) { + present := standin(t) + absent := filepath.Join(t.TempDir(), "none") + r := toolRegistry(t, manifestDir(t, + ToolEntry{ToolName: "Zed", Version: "9", Executable: present, Variables: []string{"x"}}, + ToolEntry{ToolName: "Absent", Executable: absent, Variables: []string{"x"}}, + )) + want := []string{CheckEngineName, ExploreEngineName, RunEngineName, SolveEngineName, SweepEngineName, "tool:Absent", "tool:Zed"} + if got := names(r); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("engines %v, want %v", got, want) + } + statuses := r.Statuses() + byEngine := make(map[string]Status) + for _, s := range statuses { + byEngine[s.Engine] = s + } + if s := byEngine["tool:Zed"]; s.Err != nil || s.Process != "Zed 9 at "+present { + t.Errorf("tool:Zed status %+v, want its version and path", s) + } + var absence *ProcessAbsentError + if s := byEngine["tool:Absent"]; !errors.As(s.Err, &absence) || !errors.Is(s.Err, ErrToolAbsent) || absence.Engine != "tool:Absent" { + t.Errorf("tool:Absent status %+v, want ProcessAbsentError wrapping the executable's absence", s) + } + for _, e := range r.Engines() { + d := e.Describe() + answers := false + for _, k := range d.Questions { + answers = answers || k == Compute + } + if answers != strings.HasPrefix(e.Name(), ToolEnginePrefix) { + t.Errorf("%s answers compute: %v; want the tool engines alone", e.Name(), answers) + } + if answers && d.Authority != Observed { + t.Errorf("%s authority %v, want observed", e.Name(), d.Authority) + } + } +} + +// A second engine for a tool already registered is the registry's typed duplicate error, +// and two registries loaded from one manifest do not see each other's engines. +func TestToolRegistrationIsIsolatedAndUnique(t *testing.T) { + entry := ToolEntry{ToolName: "MC", Executable: standin(t), Variables: []string{"x"}} + first := toolRegistry(t, manifestDir(t, entry)) + err := first.Register(NewTool(entry)) + var dup *DuplicateEngineError + if !errors.As(err, &dup) || dup.Name != "tool:MC" { + t.Fatalf("second tool:MC: %v, want DuplicateEngineError", err) + } + second := registered(t, NewRun()) + if err := second.Register(NewTool(entry)); err != nil { + t.Fatalf("tool:MC in another registry: %v", err) + } + if got := names(first); len(got) != 6 { + t.Errorf("first registry %v, want the build's five engines and tool:MC", got) + } + if got := names(second); strings.Join(got, ",") != RunEngineName+",tool:MC" { + t.Errorf("second registry %v, want run and tool:MC", got) + } +} + +// Covers refuses, typed, everything but a compute of this tool over variables it accepts +// while its executable is present. +func TestToolEngineCoversItsOwnComputationsOnly(t *testing.T) { + entry := ToolEntry{ToolName: "MC", Executable: standin(t), Variables: []string{"x", "y"}} + e := NewTool(entry) + if c := e.Covers(nil, Question{Kind: Evaluate}); c.Covered || !errors.Is(c.Refusal, ErrNotAsked) { + t.Errorf("evaluate: %+v, want NotAskedError", c) + } + if c := e.Covers(nil, Question{Kind: Compute}); c.Covered || !errors.Is(c.Refusal, ErrMalformedQuestion) { + t.Errorf("compute without a call: %+v, want MalformedQuestionError", c) + } + other := &runtime.ToolCall{ToolName: "Other"} + var wrong *WrongToolError + if c := e.Covers(nil, Question{Kind: Compute, Compute: &ComputeAsk{Call: other}}); c.Covered || !errors.As(c.Refusal, &wrong) || wrong.Tool != "Other" { + t.Errorf("another tool's call: %+v, want WrongToolError", c) + } + unknown := &runtime.ToolCall{ToolName: "MC", Inputs: []runtime.ToolInput{{Variable: "x"}, {Variable: "z"}}, Outputs: []runtime.ToolOutput{{Variable: "w"}}} + var variable *ToolVariableError + if c := e.Covers(nil, Question{Kind: Compute, Compute: &ComputeAsk{Call: unknown}}); c.Covered || !errors.As(c.Refusal, &variable) || strings.Join(variable.Variables, ",") != "w,z" { + t.Errorf("unaccepted variables: %+v, want ToolVariableError naming w and z", c) + } + ok := &runtime.ToolCall{ToolName: "MC", Inputs: []runtime.ToolInput{{Variable: "x"}}, Outputs: []runtime.ToolOutput{{Variable: "y"}}} + if c := e.Covers(nil, Question{Kind: Compute, Compute: &ComputeAsk{Call: ok}}); !c.Covered { + t.Errorf("its own call: %+v, want covered", c) + } + gone := NewTool(ToolEntry{ToolName: "MC", Executable: filepath.Join(t.TempDir(), "none"), Variables: []string{"x", "y"}}) + if c := gone.Covers(nil, Question{Kind: Compute, Compute: &ComputeAsk{Call: ok}}); c.Covered || !errors.Is(c.Refusal, ErrProcessAbsent) || !errors.Is(c.Refusal, ErrToolAbsent) { + t.Errorf("absent executable: %+v, want the absence as refusal", c) + } +} + +func TestToolTimeoutFromEnv(t *testing.T) { + for text, want := range map[string]time.Duration{"": DefaultToolTimeout, "soon": DefaultToolTimeout, "-1s": DefaultToolTimeout, "0": DefaultToolTimeout, "250ms": 250 * time.Millisecond, " 2m ": 2 * time.Minute} { + t.Setenv(ToolTimeoutEnv, text) + if got := toolTimeoutFromEnv(); got != want { + t.Errorf("%s=%q: %v, want %v", ToolTimeoutEnv, text, got, want) + } + } + if DefaultToolTimeout != 10*time.Second { + t.Errorf("default %v, want the solver's 10s", DefaultToolTimeout) + } +} + +// The request is one JSON object: the tool and URI as annotated, the inputs keyed by +// ToolVariable name, a quantity with its unit and a bare number, truth or text without. +func TestToolRequestCarriesTheCallUninterpreted(t *testing.T) { + call := &runtime.ToolCall{ToolName: "Model Center/2", URI: "aserv://host/Vehicle/Equation1?x=1 2", + Inputs: []runtime.ToolInput{ + {Variable: "v0", Value: runtime.ToolValue{Value: semantics.Value{Kind: semantics.ValInt, Int: 36}, Unit: "km/h"}}, + {Variable: "C_D", Value: runtime.ToolValue{Value: semantics.Value{Kind: semantics.ValReal, Real: 0.3}}}, + {Variable: "on", Value: runtime.ToolValue{Value: semantics.Value{Kind: semantics.ValBool, Bool: true}}}, + {Variable: "label", Value: runtime.ToolValue{Text: "run 1"}}, + }} + got, err := ToolRequestOf(call) + if err != nil { + t.Fatal(err) + } + want := `{"toolName":"Model Center/2","uri":"aserv://host/Vehicle/Equation1?x=1 2","inputs":{"C_D":{"value":0.3},"label":{"value":"run 1"},"on":{"value":true},"v0":{"value":36,"unit":"km/h"}}}` + if strings.TrimSpace(string(got)) != want { + t.Errorf("request\n%s\nwant\n%s", got, want) + } +} + +func TestToolReplyIsOneObjectOfOutputsOrAnError(t *testing.T) { + outputs, err := ToolReplyOf("MC", []byte(` {"outputs":{"a":{"value":3.5,"unit":"m/s**2"},"n":{"value":2},"ok":{"value":false},"s":{"value":"done"}}} `)) + if err != nil { + t.Fatalf("reply: %v", err) + } + if a := outputs["a"]; a.Value.Kind != semantics.ValReal || a.Value.Real != 3.5 || a.Unit != "m/s**2" { + t.Errorf("a = %+v, want 3.5 m/s**2", a) + } + if n := outputs["n"]; n.Value.Kind != semantics.ValInt || n.Value.Int != 2 || n.Unit != "" { + t.Errorf("n = %+v, want the integer 2", n) + } + if ok := outputs["ok"]; ok.Value.Kind != semantics.ValBool || ok.Value.Bool { + t.Errorf("ok = %+v, want false", ok) + } + if s := outputs["s"]; s.Value.Kind != semantics.ValInvalid || s.Text != "done" { + t.Errorf("s = %+v, want the text done", s) + } + + cases := map[string]struct { + stdout string + kind runtime.ToolErrorKind + detail string + }{ + "nothing": {"", runtime.ToolMalformed, "wrote nothing"}, + "prose": {"a = 3", runtime.ToolMalformed, "not one JSON object"}, + "two objects": {`{"outputs":{}} {"outputs":{}}`, runtime.ToolMalformed, "not one JSON object"}, + "unknown field": {`{"outputs":{},"log":"x"}`, runtime.ToolMalformed, "not one JSON object"}, + "neither": {`{}`, runtime.ToolMalformed, "neither outputs nor an error"}, + "null outputs": {`{"outputs":null}`, runtime.ToolMalformed, "neither outputs nor an error"}, + "both": {`{"outputs":{},"error":"x"}`, runtime.ToolMalformed, "both outputs and an error"}, + "null error beside": {`{"outputs":{"a":{"value":2}},"error":null}`, runtime.ToolMalformed, "both outputs and an error"}, + "null error": {`{"error":null}`, runtime.ToolMalformed, "error is null"}, + "error not text": {`{"error":{"code":3}}`, runtime.ToolMalformed, "error is not a message"}, + "tool error": {`{"error":"did not converge"}`, runtime.ToolRefused, "did not converge"}, + "outputs not object": {`{"outputs":[1]}`, runtime.ToolMalformed, "not an object of values"}, + "misspelt unit": {`{"outputs":{"a":{"value":2,"units":"kg"}}}`, runtime.ToolMalformed, `unknown field "units"`}, + "extra member": {`{"outputs":{"a":{"value":2,"note":"x"}}}`, runtime.ToolMalformed, `unknown field "note"`}, + "repeated key": {`{"outputs":{"a":{"value":1},"a":{"value":2}}}`, runtime.ToolMalformed, "names outputs.a twice"}, + "repeated outputs": {`{"outputs":{"a":{"value":1}},"outputs":{"a":{"value":2}}}`, runtime.ToolMalformed, "names outputs twice"}, + "repeated error": {`{"error":"x","error":"y"}`, runtime.ToolMalformed, "names error twice"}, + "repeated value": {`{"outputs":{"a":{"value":1,"value":2}}}`, runtime.ToolMalformed, "names outputs.a.value twice"}, + "repeated unit": {`{"outputs":{"a":{"value":1,"unit":"m","unit":"s"}}}`, runtime.ToolMalformed, "names outputs.a.unit twice"}, + "repeated past list": {`{"outputs":{"a":{"value":[1,2]},"a":{"value":3}}}`, runtime.ToolMalformed, "names outputs.a twice"}, + "no value": {`{"outputs":{"a":{"unit":"m"}}}`, runtime.ToolMalformed, "no value"}, + "object value": {`{"outputs":{"a":{"value":{"x":1}}}}`, runtime.ToolMalformed, "not a number, boolean or string"}, + "huge number": {`{"outputs":{"a":{"value":1e999}}}`, runtime.ToolMalformed, "not a finite number"}, + "measured truth": {`{"outputs":{"a":{"value":true,"unit":"m"}}}`, runtime.ToolMalformed, "boolean has no unit"}, + "measured text": {`{"outputs":{"a":{"value":"x","unit":"m"}}}`, runtime.ToolMalformed, "string has no unit"}, + "null unit": {`{"outputs":{"a":{"value":4,"unit":null}}}`, runtime.ToolMalformed, "unit is null"}, + "numeric unit": {`{"outputs":{"a":{"value":4,"unit":7}}}`, runtime.ToolMalformed, "unit 7 is not a unit expression"}, + "blank unit": {`{"outputs":{"a":{"value":4,"unit":" "}}}`, runtime.ToolMalformed, "unit is empty"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := ToolReplyOf("MC", []byte(tc.stdout)) + var fault *runtime.ToolError + if !errors.As(err, &fault) || !errors.Is(err, runtime.ErrTool) || fault.Kind != tc.kind || fault.Tool != "MC" { + t.Fatalf("reply: %v, want a ToolError of kind %s", err, tc.kind) + } + if !strings.Contains(err.Error(), tc.detail) { + t.Errorf("error %q does not carry %q", err, tc.detail) + } + }) + } +} diff --git a/internal/core/analysis/withheld.go b/internal/exec/analysis/withheld.go similarity index 100% rename from internal/core/analysis/withheld.go rename to internal/exec/analysis/withheld.go diff --git a/internal/core/analysis/withheld_test.go b/internal/exec/analysis/withheld_test.go similarity index 100% rename from internal/core/analysis/withheld_test.go rename to internal/exec/analysis/withheld_test.go diff --git a/internal/core/analysis/worker.go b/internal/exec/analysis/worker.go similarity index 94% rename from internal/core/analysis/worker.go rename to internal/exec/analysis/worker.go index 0162f3e856..9b93a68099 100644 --- a/internal/core/analysis/worker.go +++ b/internal/exec/analysis/worker.go @@ -7,7 +7,7 @@ import ( "sync" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" ) // Worker is one model-derived runtime part — resolver, semantic model and the runtime's memo @@ -225,9 +225,10 @@ func (m *Model) NewContextOn(job int, budget Budget) (*runtime.Context, error) { // holds reports whether the surface holds a context of its own over the model. func (m *Model) holds() bool { return m != nil && m.Context != nil } -// running is the context an execution runs in: the surface's own where it holds one, -// its limits untouched, else one of the run's own under the budget. -func (m *Model) running(engine string, budget Budget) (*runtime.Context, error) { +// running is the context an execution runs in: the surface's own where it holds one, its +// limits, seed and clock untouched, else one of the run's own under the budget drawing from +// seed under the draw policy, on a clock stepping by step. +func (m *Model) running(engine string, budget Budget, seed ModelSeed, draws runtime.DrawPolicy, step float64) (*runtime.Context, error) { if m.holds() { ctx, err := m.Context() if err == nil && ctx != nil && m.tools != nil { @@ -238,7 +239,7 @@ func (m *Model) running(engine string, budget Budget) (*runtime.Context, error) if !m.builds() { return nil, &NoRuntimeError{Engine: engine} } - return m.NewContext(budget) + return Question{ModelSeed: seed, Draws: draws, ClockStep: step}.fresh(m, 0, budget) } // warmed is how many workers the plan built and the time that took, summed over them. diff --git a/internal/exec/engines/engines.go b/internal/exec/engines/engines.go new file mode 100644 index 0000000000..4160f02b2b --- /dev/null +++ b/internal/exec/engines/engines.go @@ -0,0 +1,36 @@ +// Package engines assembles the registry of every analysis engine the build knows: +// the framework's own and those of packages the framework cannot import. +package engines + +import ( + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/smt" +) + +// Default returns the build's registry: run, explore, check, sweep, solve and smt. +// Smt registers whether or not a solver is found and refuses through Covers. +func Default() *analysis.Registry { + r := analysis.Default() + register(r) + return r +} + +// DefaultFromEnv returns the build's registry with every entry of the manifests +// OPENSYSML_TOOLS and OPENSYSML_ENGINES name, as analysis.DefaultFromEnv reads them; a +// manifest that cannot be read, or lies under one of the workspaces, is a ManifestError. +func DefaultFromEnv(workspaces ...string) (*analysis.Registry, error) { + r, err := analysis.DefaultFromEnv(workspaces...) + if err != nil { + return nil, err + } + register(r) + return r, nil +} + +// register adds the engines of other packages; their names are distinct constants +// none of the framework's own carries, so no registration can be refused. +func register(r *analysis.Registry) { + if err := r.Register(smt.New(nil)); err != nil { + panic(err) + } +} diff --git a/internal/exec/engines/engines_test.go b/internal/exec/engines/engines_test.go new file mode 100644 index 0000000000..7a9e7012e3 --- /dev/null +++ b/internal/exec/engines/engines_test.go @@ -0,0 +1,111 @@ +package engines + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" +) + +func names(r *analysis.Registry) string { + var out []string + for _, e := range r.Engines() { + out = append(out, e.Name()) + } + return strings.Join(out, ", ") +} + +// The build's registry is the framework's own engines and smt, in name order, and +// stays open to more. +func TestDefaultHoldsTheFrameworksEnginesAndSMT(t *testing.T) { + r := Default() + want := strings.Join([]string{analysis.CheckEngineName, analysis.ExploreEngineName, analysis.RunEngineName, analysis.SMTEngineName, analysis.SolveEngineName, analysis.SweepEngineName}, ", ") + if got := names(r); got != want { + t.Fatalf("engines %s, want %s", got, want) + } + if got := names(analysis.Default()); strings.Contains(got, analysis.SMTEngineName) { + t.Fatalf("the framework's own registry holds smt: %s", got) + } + if err := r.Register(analysis.NewSolve(nil)); !errors.Is(err, analysis.ErrDuplicateEngine) { + t.Fatalf("registering solve twice: %v, want the duplicate refused", err) + } +} + +// smt's status is its solver's: found and named, or the typed absence, as solve's is. +func TestSMTStatusIsTheSolvers(t *testing.T) { + var smt, sol analysis.Status + for _, s := range Default().Statuses() { + switch s.Engine { + case analysis.SMTEngineName: + smt = s + case analysis.SolveEngineName: + sol = s + } + } + if smt.Engine == "" || sol.Engine == "" { + t.Fatal("smt or solve missing from the statuses") + } + if (smt.Err == nil) != (sol.Err == nil) { + t.Fatalf("smt %+v and solve %+v disagree on the solver", smt, sol) + } + if smt.Err != nil { + var absent *analysis.ProcessAbsentError + if !errors.As(smt.Err, &absent) || !errors.Is(smt.Err, solve.ErrNoSolver) || absent.Engine != analysis.SMTEngineName { + t.Fatalf("smt status %v, want ProcessAbsentError over the solver's absence", smt.Err) + } + } else if smt.Process == "" { + t.Fatalf("smt status %+v, want the solver named", smt) + } +} + +// A holds question under auto reaches smt first, at proved, then check at bounded; +// under all both land in the plan. +func TestHoldsRanksSMTOverCheck(t *testing.T) { + q := analysis.Question{Kind: analysis.Holds, Free: analysis.FreeSchedule, Holds: &analysis.HoldsAsk{}} + plan, err := Default().Answer(context.Background(), &analysis.Model{}, q, analysis.Budget{}) + if err != nil { + t.Fatalf("holds under auto: %v", err) + } + if len(plan.Steps) != 2 || plan.Steps[0].Engine != analysis.SMTEngineName || plan.Steps[1].Engine != analysis.CheckEngineName { + t.Fatalf("steps %+v, want smt refusing the malformed question, then check", plan.Steps) + } + for _, step := range plan.Steps { + if !errors.Is(step.Refusal, analysis.ErrMalformedQuestion) { + t.Errorf("%s refused with %v, want the question malformed", step.Engine, step.Refusal) + } + } + if plan.Result.Strength != analysis.NotCovered { + t.Fatalf("result %s, want not covered", plan.Result.Strength) + } +} + +// The environment's tools join the build's registry as they join the framework's. +func TestDefaultFromEnvAddsTheManifestsTools(t *testing.T) { + dir := t.TempDir() + entry := analysis.ToolEntry{ToolName: "Zed", Version: "9", Executable: filepath.Join(dir, "zed"), Variables: []string{"x"}} + data, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "zed"+analysis.ManifestExt), data, 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(analysis.ToolsEnv, dir) + r, err := DefaultFromEnv() + if err != nil { + t.Fatal(err) + } + if got := names(r); got != names(Default())+", tool:Zed" { + t.Fatalf("engines %s, want the build's and tool:Zed", got) + } + t.Setenv(analysis.ToolsEnv, filepath.Join(dir, "none")) + if _, err := DefaultFromEnv(); !errors.Is(err, analysis.ErrManifest) { + t.Fatalf("DefaultFromEnv over a missing manifest: %v, want the manifest's fault", err) + } +} diff --git a/internal/exec/objref/objref.go b/internal/exec/objref/objref.go new file mode 100644 index 0000000000..68dc31049f --- /dev/null +++ b/internal/exec/objref/objref.go @@ -0,0 +1,258 @@ +// Package objref reads the references every surface names a held object by — +// `#`, a declared name, or either followed by the features walked from it +// (`car.wheels[2]`) — and walks such a path through an object's feature values. +// The REPL's commands and the gRPC service's document-query bindings share it, +// so one spelling reaches one object on both. +package objref + +import ( + "fmt" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// Segment is one segment of an object reference. +type Segment struct { + Text string // as written, quotes kept, so a lookup reads the notation + Name string // the declaration or feature it names + Dotted bool // written after a `.` rather than `::` + Index int // 1-based element of a multi-valued feature, 0 for none +} + +// Ref is a parsed object reference. +type Ref struct { + Text string + ID int64 // the root object's id, 0 when the root is a declared name + Segments []Segment +} + +// RefError reports text that is no object reference at all. +type RefError struct { + Ref string + Detail string + // Named is the `::`-joined run of names read before a character no unquoted + // name holds stopped the text: `T::SA` in `T::SA-506`. + Named string + // Hint is what Named may have meant, appended to the report. + Hint string +} + +func (e *RefError) Error() string { + return fmt.Sprintf("%q is not an object reference: %s%s", e.Ref, e.Detail, e.Hint) +} + +// IsID reports whether text is an id alone, `#` followed by digits. +func IsID(text string) bool { + return len(text) > 1 && text[0] == '#' && LeadingDigits(text[1:]) == text[1:] +} + +// LeadingDigits returns the run of ASCII digits text starts with. +func LeadingDigits(text string) string { + i := 0 + for i < len(text) && text[i] >= '0' && text[i] <= '9' { + i++ + } + return text[:i] +} + +// LooksLikePath reports whether text can only be an object reference — it +// starts with an id or walks a feature with `.` or an index — so a failure to +// resolve it is reported as such rather than tried as a declaration's name. +func LooksLikePath(text string) bool { + if strings.HasPrefix(text, "#") { + return true + } + inName, escaped := false, false + for _, r := range text { + switch { + case escaped: + escaped = false + case r == '\\': + escaped = true + case r == '\'': + inName = !inName + case !inName && (r == '.' || r == '['): + return true + } + } + return false +} + +// Parse reads an object reference, reporting what makes text none. +func Parse(text string) (Ref, error) { + ref := Ref{Text: text} + rest := text + if rest == "" { + return ref, &RefError{Ref: text, Detail: "nothing was named"} + } + if strings.HasPrefix(rest, "#") { + digits := LeadingDigits(rest[1:]) + if digits == "" { + return ref, &RefError{Ref: text, Detail: "an object id is written #, with the number the object was created under"} + } + id, err := strconv.ParseInt(digits, 10, 64) + if err != nil || id <= 0 { + return ref, &RefError{Ref: text, Detail: fmt.Sprintf("#%s is not an object id (ids count up from 1)", digits)} + } + ref.ID = id + rest = rest[1+len(digits):] + if rest == "" { + return ref, nil + } + var ok bool + if _, rest, ok = CutSeparator(rest); !ok { + return ref, &RefError{Ref: text, Detail: fmt.Sprintf("a feature of #%d is written after . or ::, not %q", id, rest)} + } + } + dotted := false + for { + seg, after, err := scanSegment(text, rest) + if err != nil { + return ref, err + } + seg.Dotted = dotted + if len(ref.Segments) == 0 && ref.ID == 0 && seg.Index > 0 { + return ref, &RefError{Ref: text, Detail: fmt.Sprintf("%s takes no index: an index picks an element of a multi-valued feature", seg.Text)} + } + ref.Segments = append(ref.Segments, seg) + if after == "" { + return ref, nil + } + sep, next, ok := CutSeparator(after) + if !ok { + err := &RefError{Ref: text, Detail: fmt.Sprintf("%q cannot follow %s: segments are separated by . or ::", after, seg.Text)} + if ref.ID == 0 { + err.Named = DeclaredRun(ref.Segments) + } + return ref, err + } + if next == "" { + return ref, &RefError{Ref: text, Detail: fmt.Sprintf("it ends in %q with no feature after it", sep)} + } + rest, dotted = next, sep == "." + } +} + +// DeclaredRun is the `::`-joined registered names of segments that may all name +// a declaration — none reached through `.` or an index — or "" when one is not. +func DeclaredRun(segments []Segment) string { + names := make([]string, len(segments)) + for i, seg := range segments { + if seg.Dotted || seg.Index > 0 { + return "" + } + names[i] = seg.Name + } + return strings.Join(names, "::") +} + +// Head is the count of leading segments that may name a declaration: the run +// before the first `.` or index, since a segment after `.` is only ever a feature. +func Head(segments []Segment) int { + for i, seg := range segments { + if seg.Index > 0 || seg.Dotted { + return i + } + } + return len(segments) +} + +// JoinTyped spells segments as the qualified name they were written as. +func JoinTyped(segments []Segment) string { + texts := make([]string, len(segments)) + for i, seg := range segments { + texts[i] = seg.Text + } + return strings.Join(texts, "::") +} + +// IsNamespace reports whether sym is a package or namespace, which holds +// members but is never an object. +func IsNamespace(sym *symbols.Symbol) bool { + return sym != nil && (sym.Kind == symbols.SymbolPackage || sym.Kind == symbols.SymbolNamespace) +} + +// NamespaceKind names what a namespace symbol is for a message. +func NamespaceKind(sym *symbols.Symbol) string { + if sym.Kind == symbols.SymbolPackage { + return "package" + } + return "namespace" +} + +// CutSeparator splits the segment separator text starts with from what follows. +func CutSeparator(text string) (sep, rest string, ok bool) { + switch { + case strings.HasPrefix(text, "::"): + return "::", text[2:], true + case strings.HasPrefix(text, "."): + return ".", text[1:], true + } + return "", text, false +} + +// scanSegment reads one segment — a name, quoted or not, and an optional +// index — from the front of rest; ref is the whole reference, for reporting. +func scanSegment(ref, rest string) (Segment, string, error) { + var seg Segment + end := 0 + if strings.HasPrefix(rest, "'") { + escaped := false + for i, r := range rest[1:] { + if escaped { + escaped = false + continue + } + switch r { + case '\\': + escaped = true + case '\'': + end = i + 2 + } + if end > 0 { + break + } + } + if end == 0 { + return seg, "", &RefError{Ref: ref, Detail: fmt.Sprintf("the quoted name %s is not closed", rest)} + } + names, ok := source.QualifiedNameSegments(rest[:end]) + if !ok || len(names) != 1 { + return seg, "", &RefError{Ref: ref, Detail: fmt.Sprintf("%s is not a name", rest[:end])} + } + seg.Text, seg.Name = rest[:end], names[0] + } else { + for end < len(rest) { + r, size := utf8.DecodeRuneInString(rest[end:]) + if r != '_' && !unicode.IsLetter(r) && !unicode.IsDigit(r) { + break + } + end += size + } + if end == 0 { + return seg, "", &RefError{Ref: ref, Detail: fmt.Sprintf("a name was expected at %q", rest)} + } + seg.Text, seg.Name = rest[:end], rest[:end] + } + rest = rest[end:] + if !strings.HasPrefix(rest, "[") { + return seg, rest, nil + } + closeAt := strings.IndexByte(rest, ']') + if closeAt < 0 { + return seg, "", &RefError{Ref: ref, Detail: fmt.Sprintf("the index after %s is not closed with ]", seg.Text)} + } + digits := rest[1:closeAt] + index, err := strconv.Atoi(digits) + if digits == "" || LeadingDigits(digits) != digits || err != nil || index < 1 { + return seg, "", &RefError{Ref: ref, Detail: fmt.Sprintf("%s[%s] is not an index: elements are counted from 1", seg.Text, digits)} + } + seg.Index = index + seg.Text += rest[:closeAt+1] + return seg, rest[closeAt+1:], nil +} diff --git a/internal/exec/objref/objref_test.go b/internal/exec/objref/objref_test.go new file mode 100644 index 0000000000..1c358136e8 --- /dev/null +++ b/internal/exec/objref/objref_test.go @@ -0,0 +1,130 @@ +package objref + +import ( + "errors" + "strconv" + "strings" + "testing" +) + +// TestParseReferences: the spellings a session reaches an object under parse to +// the id and segments a walker follows, indexes counting from 1. +func TestParseReferences(t *testing.T) { + cases := []struct { + text string + id int64 + segments string // "name[index]" per segment, "." before a dotted one + head int + }{ + {"#2", 2, "", 0}, + {"#2.wheels[2]", 2, "wheels[2]", 0}, + {"#2::engine.power", 2, "engine .power", 1}, + {"car", 0, "car", 1}, + {"Garage::car", 0, "Garage car", 2}, + {"Garage::car.wheels[2]", 0, "Garage car .wheels[2]", 2}, + {"car.engine.power", 0, "car .engine .power", 1}, + {"'My Garage'::car", 0, "My Garage car", 2}, + {"#7.'odd wheel'[1]", 7, "odd wheel[1]", 0}, + {"car.'odd wheel'[1]", 0, "car .odd wheel[1]", 1}, + } + for _, tc := range cases { + ref, err := Parse(tc.text) + if err != nil { + t.Errorf("Parse(%q): %v", tc.text, err) + continue + } + if ref.ID != tc.id { + t.Errorf("Parse(%q).ID = %d, want %d", tc.text, ref.ID, tc.id) + } + var got []string + for _, seg := range ref.Segments { + s := seg.Name + if seg.Dotted { + s = "." + s + } + if seg.Index > 0 { + s += "[" + strconv.Itoa(seg.Index) + "]" + } + got = append(got, s) + } + if joined := strings.Join(got, " "); joined != tc.segments { + t.Errorf("Parse(%q) segments = %q, want %q", tc.text, joined, tc.segments) + } + if head := Head(ref.Segments); head != tc.head { + t.Errorf("Head(%q) = %d, want %d", tc.text, head, tc.head) + } + } +} + +// TestParseRejectsMalformedReferences: text no session reads as a reference is +// refused with a RefError saying what was wrong. +func TestParseRejectsMalformedReferences(t *testing.T) { + cases := map[string]string{ + "": "nothing was named", + "#": "written #", + "#0": "not an object id", + "#-1": "written #", + "#2wheels": "written after . or ::", + "car..engine": "", + "car.": "no feature after it", + "car[1]": "takes no index", + "car.wheels[0]": "counted from 1", + "car.wheels[x]": "", + "car wheels": "cannot follow car", + } + for text, detail := range cases { + _, err := Parse(text) + if err == nil { + t.Errorf("Parse(%q) accepted, want a RefError", text) + continue + } + var refErr *RefError + if !errors.As(err, &refErr) { + t.Errorf("Parse(%q) = %T %v, want *RefError", text, err, err) + continue + } + if !strings.Contains(err.Error(), "not an object reference") || !strings.Contains(err.Error(), detail) { + t.Errorf("Parse(%q) = %q, want it to say the text is not an object reference and %q", text, err.Error(), detail) + } + } +} + +// TestClassifiers: the predicates the surfaces decide a binding's shape by. +func TestClassifiers(t *testing.T) { + for _, text := range []string{"#1", "#42"} { + if !IsID(text) { + t.Errorf("IsID(%q) = false", text) + } + } + for _, text := range []string{"#", "#x", "1", "car"} { + if IsID(text) { + t.Errorf("IsID(%q) = true", text) + } + } + if !LooksLikePath("2.5") { + t.Error("LooksLikePath(\"2.5\") = false: numbers are for callers to rule out first") + } + for _, text := range []string{"car.wheels[2]", "#1.engine", "car.engine"} { + if !LooksLikePath(text) { + t.Errorf("LooksLikePath(%q) = false", text) + } + } + for _, text := range []string{"car", "Garage::car", "1500", "'a.b'"} { + if LooksLikePath(text) { + t.Errorf("LooksLikePath(%q) = true", text) + } + } + ref, err := Parse("Garage::car.wheels[2]") + if err != nil { + t.Fatal(err) + } + if got := DeclaredRun(ref.Segments[:2]); got != "Garage::car" { + t.Errorf("DeclaredRun = %q, want Garage::car", got) + } + if got := DeclaredRun(ref.Segments); got != "" { + t.Errorf("DeclaredRun through a dotted segment = %q, want none", got) + } + if got := JoinTyped(ref.Segments[:2]); got != "Garage::car" { + t.Errorf("JoinTyped = %q, want Garage::car", got) + } +} diff --git a/internal/exec/objref/walk.go b/internal/exec/objref/walk.go new file mode 100644 index 0000000000..84212039dc --- /dev/null +++ b/internal/exec/objref/walk.go @@ -0,0 +1,181 @@ +package objref + +import ( + "fmt" + "sort" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// PathError reports a segment of an object reference that names no object of +// the one before it: Object is that object as the surface reports it, Segment +// the offending segment as written. +type PathError struct { + Object string + Segment string + Detail string + // Err is what kept the segment's feature value from materializing, nil when + // the segment reached a value that is no object or no feature at all. + Err error +} + +func (e *PathError) Error() string { return e.Detail } + +func (e *PathError) Unwrap() error { return e.Err } + +func pathError(object string, seg Segment, format string, args ...any) *PathError { + return &PathError{Object: object, Segment: seg.Text, Detail: fmt.Sprintf(format, args...)} +} + +// Walker walks object paths through the feature values of a runtime's objects. +type Walker struct { + Runtime *runtime.Context + // Index, when set, tells library-declared features apart in an unknown-feature + // hint, which counts them rather than listing them. + Index *symbols.Index + // Format spells a value a path reached in place of an object; nil formats + // as the runtime does. + Format func(runtime.Value) string +} + +// Walk follows segments through feature values from inst, labelled label, to +// the object they reach. Each segment must name a feature of the object before +// it that holds an object — one, or one picked by index from a multi-valued +// feature — and the first that does not is a PathError. The label spells walked +// features after `.`, which only ever reads as a feature. +func (w Walker) Walk(inst *runtime.Instance, label string, segments []Segment) (*runtime.Instance, string, error) { + ctx := w.Runtime + for _, seg := range segments { + fv, err := inst.GetFeatureValue(ctx, seg.Name) + if err != nil { + if _, has := inst.FeatureValues[seg.Name]; !has { + return nil, "", pathError(label, seg, "%s has no feature %q%s", label, seg.Name, w.featureListHint(inst)) + } + perr := pathError(label, seg, "%s of %s could not be materialized: %v", source.NameText(seg.Name), label, err) + perr.Err = err + return nil, "", perr + } + var val runtime.Value + next := label + "." + source.NameText(seg.Name) + if fv.Values.Kind != runtime.ValInvalid { + elements := CollectionElements(fv.Values) + switch { + case len(elements) == 0: + return nil, "", pathError(label, seg, "%s of %s holds no objects", source.NameText(seg.Name), label) + case seg.Index == 0: + return nil, "", pathError(label, seg, "%s of %s holds %d %s: pick one by index, %s[1] to %s[%d]", + source.NameText(seg.Name), label, len(elements), plural(len(elements), "object", "objects"), source.NameText(seg.Name), source.NameText(seg.Name), len(elements)) + case seg.Index > len(elements): + return nil, "", pathError(label, seg, "%s of %s holds %d %s, so %s names none (indexes run from 1 to %d)", + source.NameText(seg.Name), label, len(elements), plural(len(elements), "object", "objects"), seg.Text, len(elements)) + } + val = elements[seg.Index-1] + next = fmt.Sprintf("%s[%d]", next, seg.Index) + } else { + if seg.Index > 0 { + return nil, "", pathError(label, seg, "%s of %s holds one value and takes no index: write %s, not %s", + source.NameText(seg.Name), label, source.NameText(seg.Name), seg.Text) + } + val = fv.Value + } + id, isObject := val.Object() + switch { + case val.Kind == runtime.ValInvalid: + return nil, "", pathError(label, seg, "%s of %s holds no object", source.NameText(seg.Name), label) + case !isObject || ctx.HoldsNoValue(val): + return nil, "", pathError(label, seg, "%s of %s holds a value (%s), not an object", source.NameText(seg.Name), label, w.format(val)) + } + child, ok := ctx.Instance(id) + if !ok { + return nil, "", pathError(label, seg, "%s of %s holds object #%d, which is no longer held", source.NameText(seg.Name), label, id) + } + inst, label = child, next + } + return inst, label, nil +} + +func (w Walker) format(val runtime.Value) string { + if w.Format != nil { + return w.Format(val) + } + if w.Runtime.HoldsNoValue(val) { + return runtime.UnsetText + } + return runtime.FormatValue(val) +} + +// CollectionElements is what a multi-valued feature holds, in order; its +// contents are either a sequence or a set. +func CollectionElements(val runtime.Value) []runtime.Value { + switch val.Kind { + case runtime.ValSequence: + if val.Sequence() != nil { + return val.Sequence().Elements() + } + case runtime.ValSet: + if val.Set() != nil { + return val.Set().Elements() + } + } + return nil +} + +// featureListLimit bounds the features an unknown-feature error lists. +const featureListLimit = 12 + +// featureListHint names the features an object has, so a misspelt one can be +// corrected: the model's own by name, the library's by count. +func (w Walker) featureListHint(inst *runtime.Instance) string { + names, library := make([]string, 0, len(inst.FeatureValues)), 0 + for name, fv := range inst.FeatureValues { + if w.Index != nil && fv.Feature != nil && w.Index.Library(fv.Feature.Symbol) { + library++ + continue + } + names = append(names, name) + } + return featureHint(names, library) +} + +// DeclaredFeatureHint is featureListHint for the features an object of a +// declaration will have, before one is materialized. +func (w Walker) DeclaredFeatureHint(features []runtime.EffectiveFeature) string { + names, library := make([]string, 0, len(features)), 0 + for i := range features { + if w.Index != nil && w.Index.Library(features[i].Symbol) { + library++ + continue + } + names = append(names, features[i].Name) + } + return featureHint(names, library) +} + +func featureHint(names []string, library int) string { + if len(names)+library == 0 { + return " (it has no features)" + } + sort.Strings(names) + more := "" + if len(names) > featureListLimit { + more = fmt.Sprintf(", … (%d in all)", len(names)) + names = names[:featureListLimit] + } + switch { + case len(names) == 0: + return fmt.Sprintf(" (its %d features are all declared by the library)", library) + case library > 0: + more += fmt.Sprintf(", and %d more the library declares", library) + } + return fmt.Sprintf(" (its features are %s%s)", strings.Join(names, ", "), more) +} + +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many +} diff --git a/internal/core/runtime/action_activation_test.go b/internal/exec/runtime/action_activation_test.go similarity index 95% rename from internal/core/runtime/action_activation_test.go rename to internal/exec/runtime/action_activation_test.go index 719789d30a..d17293bd07 100644 --- a/internal/core/runtime/action_activation_test.go +++ b/internal/exec/runtime/action_activation_test.go @@ -3,7 +3,7 @@ package runtime import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // TestActionBodyActivationEndsWithTheBody requires what a calc usage read in an diff --git a/internal/core/runtime/action_body_local_usage_test.go b/internal/exec/runtime/action_body_local_usage_test.go similarity index 97% rename from internal/core/runtime/action_body_local_usage_test.go rename to internal/exec/runtime/action_body_local_usage_test.go index ffb740ff16..6cf51a902d 100644 --- a/internal/core/runtime/action_body_local_usage_test.go +++ b/internal/exec/runtime/action_body_local_usage_test.go @@ -3,7 +3,7 @@ package runtime import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // TestActionBodyLocalUsageBindsCurrentValues requires a calc usage declared in an diff --git a/internal/exec/runtime/action_body_run.go b/internal/exec/runtime/action_body_run.go new file mode 100644 index 0000000000..b9586ffdc6 --- /dev/null +++ b/internal/exec/runtime/action_body_run.go @@ -0,0 +1,630 @@ +package runtime + +import ( + "cmp" + "errors" + "fmt" + "maps" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// A body's work — a token's step of a node with a body, or a state's do behavior — +// pauses where it waits and is resumed later. The paused work is data: the +// frames each level of it was at (bodyRun.cursor), re-entered level by level. + +// errPaused unwinds the work of a body to the run driving it, which keeps where it paused. +var errPaused = errors.New("body paused") + +// errBodyContinuation is a paused body resumed at a level other than the one it paused at. +var errBodyContinuation = errors.New("body continuation") + +// paused reports whether err is the unwinding of a paused body. +func paused(err error) bool { return errors.Is(err, errPaused) } + +// bodyWork is what a body run performs; perform is re-entered after each pause, +// clone copies how far it has come, for a snapshot to restore it to, and spell +// writes that into a state's canonical form. +type bodyWork interface { + perform() error + clone() bodyWork + spell(*stateSpeller) string +} + +// bodyFrame is where one level of a body's work paused; abandon ends what it +// holds open, clone copies it as it stands, for a snapshot to restore it to, and +// spell writes it into a state's canonical form. +type bodyFrame interface { + abandon(ctx *Context) + clone() bodyFrame + spell(*stateSpeller) string +} + +// bodyRun is the work of one body: a breakpoint met inside it, a wait on the +// clock, or a statement boundary where it yields, pauses it there until +// resumed, its frames kept in cursor. +type bodyRun struct { + work bodyWork + // err is what ended the work, once ended: its own failure, or its abandonment. + err error + ended bool + // cursor is the paused work, innermost level first; resuming is the cursor + // being re-entered, each level popping its frame. + cursor, resuming []bodyFrame + // pausedAt orders the paused runs by when they last paused; paused is why. + pausedAt int64 + paused bodyPause + // traceLevels is the trace nesting the paused work holds open, set aside + // while it is paused so what runs meanwhile records at the outer depth. + traceLevels int + // traceBase is the nesting the work resumed at, which its levels count from. + traceBase int + // awaitsMessages lets the run pause for a message too, as a do behavior does + // while its machine goes on; a token's step run waits only on the clock. + awaitsMessages bool + // yields has the run pause at the statement boundary after the statement, + // loop iteration or flow step it performed since resumed, which performed marks. + yields, performed bool + // steps has the run pause after each token move of the flows and actions it + // drives where a step is one move, its machine going on between the moves. + steps bool +} + +// bodyPause is why a body run paused: at the breakpoint, on a wait, yielded at a +// statement boundary, or after one token move (tokenStep), to go on when resumed. +type bodyPause struct { + breakpoint breakpointStop + onWait bool + wait bodyWait + yielded bool + tokenStep bool +} + +// bodyWait is the wait a body's run paused on: of the action it performs (held), +// or of perf's flow run by exec; onMessage for a wait for a message, else the clock. +type bodyWait struct { + held *ActionExecutor + exec *ActionExecutor + perf *actionFrame + onMessage bool +} + +// goesOn reports whether the wait goes on, so resuming would only pause again. +func (w bodyWait) goesOn() bool { + if e := w.held; e != nil { + if e.state != StateWaiting || e.canProceed(nil) { + return false + } + return w.onMessage || e.waitsOnClock(nil) + } + e := w.exec + if e.canProceed(w.perf) { + return false + } + if w.onMessage { + return len(e.waitingTokens(w.perf)) > 0 + } + return e.waitsOnClock(w.perf) +} + +// waiter is the executor the paused work waits on, nil for none: the action it +// holds, or the flow of a case it runs, which is not own, the flow of the body's executor. +func (w bodyWait) waiter(own *ActionExecutor) clockWaiter { + if w.held != nil { + return w.held + } + if w.exec != nil && w.exec != own { + return w.exec + } + return nil +} + +// resume lets the work go on to its next pause, reported as true with why, or to +// its end, leaving err set. +func (run *bodyRun) resume(ctx *Context) (bodyPause, bool) { + outer := ctx.body + ctx.body = run + run.resuming, run.cursor = run.cursor, nil + run.performed = false + base := ctx.trace.nesting() + run.traceBase = base + ctx.trace.setNesting(base + run.traceLevels) + err := run.work.perform() + ctx.body = outer + // Levels a failure left un-entered hold nothing the work still needs. + for _, f := range run.resuming { + f.abandon(ctx) + } + run.resuming = nil + if paused(err) { + run.traceLevels = ctx.trace.nesting() - base + ctx.trace.setNesting(base) + return run.paused, true + } + run.err, run.ended = err, true + return bodyPause{}, false +} + +// end ends the paused work for good: what its frames hold open is abandoned, +// innermost first, an action it was performing among it; err records the abandonment. +func (run *bodyRun) end(ctx *Context) { + if run.ended { + return + } + for _, f := range run.cursor { + f.abandon(ctx) + } + run.cursor, run.ended = nil, true + where := "between statements" + switch { + case run.paused.onWait: + where = "on a wait" + case run.paused.tokenStep: + where = "between two moves of its flow" + case !run.paused.yielded: + where = fmt.Sprintf("at breakpoint %q", run.paused.breakpoint.name) + } + run.err = fmt.Errorf("%w: the run paused %s was abandoned", ErrActionDeadlock, where) +} + +// endPerformed ends perf where a body statement of the paused run was performing it, +// abandoning the levels within it: the run resumed goes on past the node as completed. +func (run *bodyRun) endPerformed(ctx *Context, perf *actionFrame) bool { + for i, f := range run.cursor { + pf, ok := f.(*performFrame) + if !ok || pf.perf != perf { + continue + } + for _, inner := range run.cursor[:i] { + inner.abandon(ctx) + } + run.cursor = run.cursor[i:] + run.traceLevels = pf.levels + run.paused = bodyPause{} + pf.ended = true + return true + } + return false +} + +// bodyLevels is the trace nesting the body on the stack holds open at this point +// of its work, over the depth it resumed at; 0 with no body on the stack. +func (ctx *Context) bodyLevels() int { + if ctx.body == nil { + return 0 + } + return ctx.trace.nesting() - ctx.body.traceBase +} + +// pushPaused keeps f, where the level of the body's work now unwinding paused. +func (ctx *Context) pushPaused(f bodyFrame) { + ctx.body.cursor = append(ctx.body.cursor, f) +} + +// popFrame takes the frame the level resuming the body's work paused at, false +// where the work is not resuming; a frame of another kind is an error. +func popFrame[T bodyFrame](ctx *Context) (frame T, resumed bool, err error) { + run := ctx.body + if run == nil || len(run.resuming) == 0 { + return frame, false, nil + } + top := run.resuming[len(run.resuming)-1] + frame, ok := top.(T) + if !ok { + return frame, false, fmt.Errorf("%w: %T resumed where %T paused", errBodyContinuation, frame, top) + } + run.resuming = run.resuming[:len(run.resuming)-1] + return frame, true, nil +} + +// resumingAt reports whether the level re-entering the body's work paused at a +// frame of kind T, which it is about to pop. +func resumingAt[T bodyFrame](ctx *Context) bool { + run := ctx.body + if run == nil || len(run.resuming) == 0 { + return false + } + _, ok := run.resuming[len(run.resuming)-1].(T) + return ok +} + +// pausing keeps f for the paused body unwinding through err, which it returns. +func (ctx *Context) pausing(f bodyFrame, err error) error { + if paused(err) { + ctx.pushPaused(f) + } + return err +} + +// syncBoundary runs what follows with no body to pause: a wait under it drives the +// clock itself, or is an error where a behavior holds it. It returns the restorer. +func (ctx *Context) syncBoundary() func() { + outer := ctx.body + ctx.body = nil + return func() { ctx.body = outer } +} + +// usageWork is a token's step of a nested action usage: the case it is or the +// action it performs, then its own body, then the succession out of it. +type usageWork struct { + exec *ActionExecutor + token int64 + perf *actionFrame + graph *lower.ActionGraph + usage *ast.Usage + // inv is the action the usage performs, where performs; isCase marks a case step. + inv actionInvocation + performs bool + isCase bool + phase usagePhase +} + +// usagePhase is how far a usageWork has come. +type usagePhase int + +const ( + usagePerforming usagePhase = iota // the case it is or the action it performs + usageBody // the flow it owns or its statements + usageComplete // the succession out of it +) + +func (w *usageWork) clone() bodyWork { c := *w; return &c } + +func (w *usageWork) perform() error { + e := w.exec + var ended *terminated + if w.phase == usagePerforming { + switch { + case w.isCase: + if err := e.performCase(w.perf); err != nil { + return err + } + if err := e.endPerformance(w.perf); err != nil { + return err + } + w.phase = usageComplete + case w.performs: + if err := e.performInvocation(w.perf, w.inv); err != nil { + return err + } + w.phase = usageBody + default: + w.phase = usageBody + } + } + if w.phase == usageBody { + // A node owning a flow performs it as subperformances, completing once they have. + if w.perf.graph != nil { + idx, err := e.workToken(w.token) + if err != nil { + return err + } + return e.enterSubflow(idx, w.perf) + } + if err := e.executeBody(w.perf, w.graph, w.usage); err != nil { + err = e.terminatedUsage(w.perf, w.graph, err) + if ended = unwound(err); ended == nil { + return err + } + if ended.perf != w.perf { + // The usage ends a performance around it: its own completes first, pins bound. + if err := e.endPerformance(w.perf); err != nil { + return err + } + return ended + } + // A terminate unwound out of a flow nested in the body: what still runs there is dropped. + e.dropTokensIn(w.perf, 0) + } + if err := e.endPerformance(w.perf); err != nil { + return err + } + w.phase = usageComplete + } + idx, err := e.workToken(w.token) + if err != nil { + return err + } + if err := e.completeNode(idx, w.perf); err != nil { + return err + } + if ended != nil { + return e.endAlongside(ended) + } + return nil +} + +// statementWork is a token's step of a node written as a statement: its body, then +// the succession out of it. +type statementWork struct { + exec *ActionExecutor + token int64 + frame *actionFrame + node ast.Node + done bool +} + +func (w *statementWork) clone() bodyWork { c := *w; return &c } + +func (w *statementWork) perform() error { + e := w.exec + if !w.done { + if err := e.executeBody(w.frame, w.frame.graph, w.node); err != nil { + return err + } + w.done = true + } + idx, err := e.workToken(w.token) + if err != nil { + return err + } + return e.leaveStatementNode(idx, w.frame, w.node) +} + +// executionWork is a token's step of an execution node invoking an action: the +// invocation, whose outputs the node's features take, then the succession out of it. +type executionWork struct { + exec *ActionExecutor + token int64 + frame *actionFrame + node *ast.ActionExecutionNode + outputs map[string]Value + invoked bool +} + +func (w *executionWork) clone() bodyWork { + c := *w + c.outputs = maps.Clone(w.outputs) + return &c +} + +func (w *executionWork) perform() error { + e := w.exec + if !w.invoked { + _, outputs, err := invokeAction( + e.ctx, w.frame.graph.Scope, actionInvocation{target: w.node.ActionRef}, lexicalValues(w.frame), e.self, + ) + if err != nil { + return err + } + w.outputs, w.invoked = outputs, true + if err := e.setFrameFeatures(w.frame, outputs); err != nil { + return err + } + } + idx, err := e.workToken(w.token) + if err != nil { + return err + } + return e.leaveExecutionNode(idx, w.frame, w.node) +} + +// workToken is the index of the token whose step is under way; one gone is an error. +func (e *ActionExecutor) workToken(id int64) (int, error) { + idx := e.tokenIndex(id) + if idx < 0 { + return -1, fmt.Errorf("%w: token %d left its step", errBodyContinuation, id) + } + return idx, nil +} + +// runBody starts work for the token at tokenIdx and drives it to its first pause or end. +func (e *ActionExecutor) runBody(tokenIdx int, work bodyWork) error { + run := &bodyRun{work: work} + if outer := e.ctx.body; outer != nil { + run.awaitsMessages, run.steps = outer.awaitsMessages, outer.steps + } + e.tokens[tokenIdx].body = run + return e.resumeBody(tokenIdx) +} + +// Release ends the run for good: the work of every token a breakpoint left +// paused is ended, so an executor abandoned mid-run holds no suspended run, the +// clock drives it no further, and a later Step or RunToCompletion returns +// ErrExecutorReleased, completed or not. Safe to call more than once. +func (e *ActionExecutor) Release() { + defer e.ctx.endExecutorRun(&e.driven)() + e.released = true + e.endPausedBodies() + e.ctx.clock.detach(e) +} + +// endPausedBodies ends the work of every token a breakpoint left paused; a run +// that fails ends it too, as no step of it goes on. +func (e *ActionExecutor) endPausedBodies() { + for i := range e.tokens { + if run := e.tokens[i].body; run != nil { + run.end(e.ctx) + e.tokens[i].body = nil + } + } +} + +// pausedTokens returns the IDs of the tokens Step resumes, whose work paused, +// the longest paused first; a body's flow resumes the tokens it drives. +func (e *ActionExecutor) pausedTokens() []int64 { + var paused []Token + for _, token := range e.tokens { + if token.body != nil && !token.drivenByBody() { + paused = append(paused, token) + } + } + slices.SortFunc(paused, func(a, b Token) int { + return cmp.Compare(a.body.pausedAt, b.body.pausedAt) + }) + ids := make([]int64, len(paused)) + for i, token := range paused { + ids[i] = token.ID + } + return ids +} + +// tokenIndex returns the index of the token with the given ID, -1 for none. +func (e *ActionExecutor) tokenIndex(id int64) int { + for i, token := range e.tokens { + if token.ID == id { + return i + } + } + return -1 +} + +// resumeBody lets the paused work of the token at tokenIdx go on to its next pause +// (a breakpoint suspends the executor, the clock does not) or to its end. +func (e *ActionExecutor) resumeBody(tokenIdx int) error { + run, id := e.tokens[tokenIdx].body, e.tokens[tokenIdx].ID + if pause, paused := run.resume(e.ctx); paused { + e.pauses++ + run.pausedAt = e.pauses + if !pause.onWait && !pause.tokenStep { + e.pausedAt = pause.breakpoint + e.state = StateSuspended + } + return nil + } + if i := e.tokenIndex(id); i >= 0 { + e.tokens[i].body = nil + } + return run.err +} + +// pauseAt pauses the run before a node a breakpoint is set on performs, as the run +// pauses before a token steps such a node; a run not made pausable goes on. +func (e *ActionExecutor) pauseAt(within []ast.Node, node ast.Node) error { + if stop, set := e.stopAt(within, node); set { + return e.ctx.pauseBody(bodyPause{breakpoint: stop}) + } + return nil +} + +// pauseBody pauses the body on the stack, unwinding its work to the run driving +// it; nil, going on, where no body is on the stack. +func (ctx *Context) pauseBody(pause bodyPause) error { + run := ctx.body + if run == nil { + return nil + } + run.paused = pause + return errPaused +} + +// yieldBody pauses the body on the stack before its next statement where its run +// goes one at a time and has performed one since resumed; nil, going on, else. +func (ctx *Context) yieldBody() error { + if ctx.body == nil || !ctx.body.yields || !ctx.body.performed { + return nil + } + return ctx.pauseBody(bodyPause{yielded: true}) +} + +// bodyPerformed notes a statement, loop iteration or flow step of the body on the +// stack done, after which a run going one at a time yields. +func (ctx *Context) bodyPerformed() { + if ctx.body != nil { + ctx.body.performed = true + } +} + +// stepsTokens reports whether the body on the stack pauses after each token move. +func (ctx *Context) stepsTokens() bool { + return ctx.body != nil && ctx.body.steps +} + +// tokenStepBody pauses the body on the stack after one token move where its run +// goes one move at a time; nil, going on, else. +func (ctx *Context) tokenStepBody() error { + if !ctx.stepsTokens() { + return nil + } + return ctx.pauseBody(bodyPause{tokenStep: true}) +} + +// yieldedHere reports the frame just popped as the one the body yielded in: its +// next statement begins afresh there, where a frame paused inside one resumes it. +func (ctx *Context) yieldedHere() bool { + run := ctx.body + return run != nil && len(run.resuming) == 0 && run.paused.yielded +} + +// pauseForClock pauses the body on the stack while wait, a wait on the clock, +// goes on; nil where none is on the stack. +func (ctx *Context) pauseForClock(wait bodyWait) error { + return ctx.pauseBody(bodyPause{onWait: true, wait: wait}) +} + +// pauseForMessage pauses the body on the stack while wait, a wait for a message, +// goes on; nil where none is on the stack or the body cannot wait for one. +func (ctx *Context) pauseForMessage(wait bodyWait) error { + if ctx.body == nil || !ctx.body.awaitsMessages { + return nil + } + wait.onMessage = true + return ctx.pauseBody(bodyPause{onWait: true, wait: wait}) +} + +// holdClock keeps the clock where it is while the behavior named runs: a wait on the +// clock under it, with no run to pause, is an error rather than an advance. +func (ctx *Context) holdClock(behavior string) func() { + outer := ctx.clockHeldBy + ctx.clockHeldBy = behavior + restore := ctx.syncBoundary() + return func() { + restore() + ctx.clockHeldBy = outer + } +} + +// driveClock reports the error where a wait on the clock, described by waits, cannot +// advance it because a behavior on the stack holds it; nil where the clock is free. +func (ctx *Context) driveClock(waits string) error { + if ctx.clockHeldBy == "" { + return nil + } + return fmt.Errorf("%w: %s waits for the clock (%s), which only a do behavior may", + ErrStateBehaviorWaits, ctx.clockHeldBy, waits) +} + +// pausedOnClock reports a token whose work waits on the clock through a flow it +// runs or an action it performs; the tokens parked there hold the wait, not this one. +func (t Token) pausedOnClock() bool { + return t.body != nil && t.body.paused.onWait +} + +// resumable reports a token whose paused work would go on if resumed now: paused +// at a breakpoint, or on the clock for a wait that has ended. +func (t Token) resumable() bool { + return t.body != nil && (!t.body.paused.onWait || !t.body.paused.wait.goesOn()) +} + +// pausedWaiter returns the executor the token's paused work waits on (the action it +// performs or the flow of a case it runs), nil for none; own is the token's executor. +func (t Token) pausedWaiter(own *ActionExecutor) clockWaiter { + if t.body == nil { + return nil + } + return t.body.paused.wait.waiter(own) +} + +// hostedFlow returns the flow of a case the token's paused work runs, nil for none. +func (t Token) hostedFlow(own *ActionExecutor) *ActionExecutor { + if t.body == nil { + return nil + } + if w := t.body.paused.wait; w.held == nil && w.exec != nil && w.exec != own { + return w.exec + } + return nil +} + +// drivenByBody reports whether the token runs in a flow a body statement runs +// (runSubflow), whose steps that statement takes rather than Step. +func (t Token) drivenByBody() bool { + for f := t.frame; f != nil; f = f.parent { + if f.inBody { + return true + } + } + return false +} diff --git a/internal/core/runtime/action_body_run_test.go b/internal/exec/runtime/action_body_run_test.go similarity index 98% rename from internal/core/runtime/action_body_run_test.go rename to internal/exec/runtime/action_body_run_test.go index 0b1140b629..3fb877548d 100644 --- a/internal/core/runtime/action_body_run_test.go +++ b/internal/exec/runtime/action_body_run_test.go @@ -3,7 +3,7 @@ package runtime import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // The steps of a run whose work never pauses leave no work behind: a long run @@ -69,7 +69,7 @@ func TestAPausedStepKeepsItsFrames(t *testing.T) { if len(paused.cursor) == 0 { t.Errorf("pass %d: the paused work holds no frame to go on from", pass) } - if paused.paused.onWait || paused.paused.breakpoint != "add" { + if paused.paused.onWait || paused.paused.breakpoint.name != "add" { t.Errorf("pass %d: paused = %+v, want at breakpoint add", pass, paused.paused) } if exec.ctx.body != nil { diff --git a/internal/core/runtime/action_choice.go b/internal/exec/runtime/action_choice.go similarity index 79% rename from internal/core/runtime/action_choice.go rename to internal/exec/runtime/action_choice.go index 9dd00d3f81..96695990a4 100644 --- a/internal/core/runtime/action_choice.go +++ b/internal/exec/runtime/action_choice.go @@ -5,10 +5,10 @@ import ( "slices" "sort" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // The choice points an action run makes; recording one never alters what the @@ -54,11 +54,22 @@ func (e *performances) beginStepWrites(step int) func() { ledger := &stepWriteLedger{step: step, writes: make(map[writeDest]*destWrites)} e.ctx.stepWrites = ledger return func() { - ledger.noteChoices(e.ctx) + ledger.noteChoices(e) e.ctx.stepWrites = saved } } +// noteChoice keeps a choice point these performances drew, as made by their +// object and behavior. +func (e *performances) noteChoice(c ChoicePoint) { + e.ctx.noteFrom(c, e.self, e.behavior) +} + +// noteGuard keeps a guard these performances could not evaluate, as noteChoice does. +func (e *performances) noteGuard(g UnevaluableGuard) { + e.ctx.noteFrom(g, e.self, e.behavior) +} + // beginTokenStep marks the token whose step is running, returning what to // restore once its step is over. func (e *performances) beginTokenStep(id int64) func() { @@ -119,7 +130,7 @@ func (ctx *Context) noteWrite(dest writeDest, label string, value Value, file st // noteChoices records each destination two or more tokens wrote within the step // as a choice point, whatever they wrote: another order lets another write stand. -func (l *stepWriteLedger) noteChoices(ctx *Context) { +func (l *stepWriteLedger) noteChoices(e *performances) { for _, dest := range l.order { d := l.writes[dest] if len(d.last) < 2 { @@ -135,7 +146,7 @@ func (l *stepWriteLedger) noteChoices(ctx *Context) { taken = i } } - ctx.noteChoice(ChoicePoint{ + e.noteChoice(ChoicePoint{ Kind: ChoiceWriteOrder, Step: l.step, Alternatives: alts, @@ -239,7 +250,8 @@ func (o *stepOrder) eligible(t Token) bool { } // tokenActed reports whether the step of the token snapshotted as before moved, -// consumed, retired, resumed, forked or joined it, or took its awaited message. +// consumed, retired, resumed, forked or joined it, took its awaited message, or +// began work of its own that paused after one move. func (e *ActionExecutor) tokenActed(before Token, count int) bool { if before.body != nil || len(e.tokens) != count { return true @@ -249,7 +261,8 @@ func (e *ActionExecutor) tokenActed(before Token, count int) bool { return true } after := e.tokens[i] - return after.moved != before.moved || (before.Wait != nil && after.Wait == nil) + return after.moved != before.moved || (before.Wait != nil && after.Wait == nil) || + (after.body != nil && after.body.paused.tokenStep) } // noteTokenOrder records the tokens a step advanced as a choice point when there are @@ -272,7 +285,7 @@ func (e *ActionExecutor) noteTokenOrder(step int, order stepOrder, schedule *tok } } } - e.ctx.noteChoice(ChoicePoint{ + e.noteChoice(ChoicePoint{ Kind: ChoiceTokenOrder, Step: step, Alternatives: alts, @@ -284,10 +297,23 @@ func (e *ActionExecutor) noteTokenOrder(step int, order stepOrder, schedule *tok // chooseBranch resolves which of the holding guarded successions of a decision // node, at their declared positions, the token takes: the position in holding, and -// the choice point to note when there are at least two. -func (e *ActionExecutor) chooseBranch(frame *actionFrame, node *ast.DecisionNode, successors []lower.ActionEdge, holding []int) (*ChoicePoint, int) { +// the choice point to note when there are at least two. Weighted successions are +// drawn by their weights, read in ec where their guards are; a sole holding +// succession is taken without a draw, its weight still read and checked. +func (e *ActionExecutor) chooseBranch(ec *EvalContext, frame *actionFrame, node *ast.DecisionNode, successors []lower.ActionEdge, holding []int) (*ChoicePoint, int, error) { + declared, err := e.branchWeights(ec, node, successors) + if err != nil { + return nil, 0, err + } + var weights []float64 + if declared != nil { + weights, err = checkDistribution(DecisionPlace(node), declared, holding) + if err != nil { + return nil, 0, err + } + } if len(holding) < 2 { - return nil, 0 + return nil, 0, nil } alts := make([]string, len(holding)) for i, pos := range holding { @@ -300,15 +326,41 @@ func (e *ActionExecutor) chooseBranch(frame *actionFrame, node *ast.DecisionNode Alternatives: alts, File: e.decisionFile(frame), Span: node.Span(), + Weights: weights, + } + if err := e.ctx.scheduling().chooseWeighted(&choice, nil); err != nil { + return nil, 0, err + } + return &choice, choice.Taken, nil +} + +// branchWeights evaluates the weights of every succession out of a decision, nil +// when the model weights none of them; lowering refuses a decision weighting only some. +func (e *ActionExecutor) branchWeights(ec *EvalContext, node *ast.DecisionNode, successors []lower.ActionEdge) ([]float64, error) { + if len(successors) == 0 || successors[0].Probability == nil { + return nil, nil + } + weights := make([]float64, len(successors)) + for pos, edge := range successors { + p := edge.Probability + val, err := ec.Eval(p.Expr) + if err != nil { + return nil, fmt.Errorf("%w: %s: weight of %s: %v", ErrBranchWeights, DecisionPlace(node), branchName(successors, pos), err) + } + val = soleElement(val) + if val.Kind != ValConst || !val.Const.IsNumeric() { + return nil, fmt.Errorf("%w: %s: weight of %s is %s, not a number", + ErrBranchWeights, DecisionPlace(node), branchName(successors, pos), describeValue(val)) + } + weights[pos] = asReal(val.Const) } - choice.Taken = e.ctx.scheduling().choose(choice, nil) - return &choice, choice.Taken + return weights, nil } // noteUnevaluableGuard records the guard of the succession at position pos out of // a decision node, probed once the branch was decided, as one with no result. func (e *ActionExecutor) noteUnevaluableGuard(frame *actionFrame, node *ast.DecisionNode, successors []lower.ActionEdge, pos int, err error) { - e.ctx.noteUnevaluableGuard(UnevaluableGuard{ + e.noteGuard(UnevaluableGuard{ Step: e.stepCount + 1, Where: DecisionPlace(node), Alternative: branchName(successors, pos), diff --git a/internal/core/runtime/action_executor.go b/internal/exec/runtime/action_executor.go similarity index 79% rename from internal/core/runtime/action_executor.go rename to internal/exec/runtime/action_executor.go index 9925c78c5c..80aa7d983b 100644 --- a/internal/core/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -9,10 +9,11 @@ import ( "sort" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // ErrAmbiguousSuccession reports a node whose flow could continue along more @@ -24,6 +25,9 @@ const actionLabelPrefix = "action " // ActionExecutor executes action bodies using token-flow semantics. type ActionExecutor struct { + // outputListeners, one per node of another action performing this one, take each + // write to an output of the action as it is made; empty when nothing is listening. + outputListeners []outputListener // performances holds the action's own performance, root, and runs its nodes' as // its subperformances; self is the object performing the action, whose // connections route what it sends. @@ -34,6 +38,10 @@ type ActionExecutor struct { // tool is the ToolExecution annotating performed, whose tool performs the action in // place of its body; nil for an action performed by its body. tool *toolExecution + // dynamicsKind is the library state-space dynamics the action specializes, if + // either; dynamics is the run stepping it, once initialized. + dynamicsKind lower.StateSpaceKind + dynamics *stateSpaceRun // occurrence is the action performance materialized for a performed usage. It // holds what the action's own features hold, and data mirrors it. occurrence *Instance @@ -46,8 +54,15 @@ type ActionExecutor struct { nextTokenID int64 stepCount int // Current step number for tracing breakpoints map[string]bool + // breakpointNodes are the nodes a run stops at by identity, each in one nested flow. + breakpointNodes []NodeBreakpoint // firedBreakpoints records the token visits a breakpoint already stopped on. firedBreakpoints map[breakpointVisit]bool + // traversals are the successions taken and still kept, in order, traversalBase + // those released before them; none is kept unless keepTraversals is set. + traversals []Traversal + traversalBase int + keepTraversals bool // sweep numbers the pass over a flow's tokens in progress, 0 between passes; sweeps // counts those begun. A token a sweep moved is not an arrival until the sweep ends. sweep, sweeps uint64 @@ -55,7 +70,7 @@ type ActionExecutor struct { // beginsRun marks the performance the caller begins a run on, the one a // replayed witness's inputs are fixed on (see fixWitnessInputs). beginsRun bool - pausedAt string // Node name RunToCompletion stopped at, empty when it ran to the end + pausedAt breakpointStop // The breakpoint RunToCompletion stopped at, none when it ran to the end // released is set once Release has ended the run for good. released bool // pauses counts the body pauses so far, ordering the paused runs' resumption. @@ -110,6 +125,47 @@ type breakpointVisit struct { node ast.Node } +// NodeBreakpoint is a breakpoint set by identity: on Node, in the flow of the nested +// action nodes Within (outermost first, as Token.Within lists them; none for the action's own). +type NodeBreakpoint struct { + Within []ast.Node + Node ast.Node +} + +// at reports whether the breakpoint is set on node in the flow of within. +func (bp NodeBreakpoint) at(within []ast.Node, node ast.Node) bool { + return bp.Node == node && slices.Equal(bp.Within, within) +} + +// cloneBreakpoints copies bps, each path with it, so neither copy can alias the other. +func cloneBreakpoints(bps []NodeBreakpoint) []NodeBreakpoint { + if bps == nil { + return nil + } + out := make([]NodeBreakpoint, len(bps)) + for i, bp := range bps { + out[i] = NodeBreakpoint{Within: slices.Clone(bp.Within), Node: bp.Node} + } + return out +} + +// breakpointStop is a breakpoint a run stopped at: the node, in its nested flow, and +// the name the run reports it by; a zero stop is no breakpoint. +type breakpointStop struct { + at NodeBreakpoint + name string +} + +// stopAt is the stop at a breakpoint set on node in the flow of within, if one is; +// false otherwise. +func (e *ActionExecutor) stopAt(within []ast.Node, node ast.Node) (breakpointStop, bool) { + name := e.breakpointNameOf(within, node) + if name == "" { + return breakpointStop{}, false + } + return breakpointStop{at: NodeBreakpoint{Within: within, Node: node}, name: name}, true +} + // SetInputs binds input parameter values into the action's feature space. // Inputs are applied after attribute defaults, so they override defaults with // the same name. Must be called before initialize(). @@ -144,7 +200,7 @@ func newActionExecutorOf( if err != nil { return nil, err } - graph, err := lowerPerformance(action, tool) + graph, err := lowerPerformance(action, tool, ctx.Resolver()) if err != nil { return nil, err } @@ -161,10 +217,11 @@ func newActionExecutorOn( self, occurrence *Instance, ) *ActionExecutor { exec := &ActionExecutor{ - performances: performances{ctx: ctx, self: self}, + performances: performances{ctx: ctx, self: self, behavior: action}, action: action, performed: performed, tool: tool, + dynamicsKind: ctx.stateSpaceKindOf(action), occurrence: occurrence, graph: graph, tokens: make([]Token, 0), @@ -177,13 +234,15 @@ func newActionExecutorOn( exec.features = exec.performanceFeatures() exec.root = exec.newRootFrame() exec.owner = exec + exec.flow = exec + exec.driven.exec = exec ctx.clock.attach(exec) return exec } // lowerPerformance lowers what a performance of action runs, in the scope it was written // in: its token flow, or under a tool only its own interface, the body never running. -func lowerPerformance(action *symbols.Symbol, tool *toolExecution) (*lower.ActionGraph, error) { +func lowerPerformance(action *symbols.Symbol, tool *toolExecution, resolver *resolve.Resolver) (*lower.ActionGraph, error) { if tool != nil { graph, err := lower.ToActionInterface(action.Decl, DeclScope(action)) if err != nil { @@ -191,7 +250,7 @@ func lowerPerformance(action *symbols.Symbol, tool *toolExecution) (*lower.Actio } return graph, nil } - graph, err := lower.ToActionGraph(action.Decl, DeclScope(action)) + graph, err := lower.ToActionGraphWith(action.Decl, DeclScope(action), resolver) if err != nil { return nil, fmt.Errorf("lower action graph: %w", err) } @@ -267,7 +326,7 @@ func (e *ActionExecutor) Step() error { return fmt.Errorf("%w: its run ended when it was let go of", ErrExecutorReleased) } - if e.state == StateCompleted { + if e.state.Ended() { return nil // Already completed } @@ -282,7 +341,7 @@ func (e *ActionExecutor) Step() error { // Stepping resumes a run a breakpoint suspended. if e.state == StateSuspended { e.state = StateRunning - e.pausedAt = "" + e.pausedAt = breakpointStop{} } // A waiting executor is asked again whether its parked tokens can proceed: @@ -372,7 +431,7 @@ func (e *ActionExecutor) Step() error { e.trace().RecordActionStep(e.stepCount, e.tokens) } - if e.state == StateCompleted { + if e.state.Ended() { return e.ctx.endedWhole(&e.driven) } return nil @@ -385,7 +444,7 @@ func (e *ActionExecutor) tokensProgressed(countBefore int, locationsBefore []ast return true } for i := 0; i < len(e.tokens) && i < len(locationsBefore); i++ { - if e.tokens[i].Location != locationsBefore[i] { + if e.tokens[i].Location != locationsBefore[i] || e.moving(e.tokens[i]) { return true } } @@ -397,7 +456,7 @@ func (e *ActionExecutor) tokensProgressed(countBefore int, locationsBefore []ast func (e *ActionExecutor) waitsOnClockAlone() bool { for _, token := range e.tokens { if token.pausedOnClock() { - if held := token.heldWaiter(); held != nil && held.dueWork() { + if w := token.pausedWaiter(e); w != nil && w.dueWork() { return false } continue @@ -504,7 +563,7 @@ func (e *ActionExecutor) run(atCurrentTime bool) error { e.held, e.inRun = false, true defer func() { e.inRun = false }() - e.pausedAt = "" + e.pausedAt = breakpointStop{} if e.state == StateSuspended { e.state = StateRunning } @@ -541,8 +600,19 @@ func (e *ActionExecutor) run(atCurrentTime bool) error { if done, err := e.stepOnce(atCurrentTime); err != nil { return err } else if done { + // A step that parked every token for a message pauses a body around the run. + if !atCurrentTime && e.state == StateWaiting && !e.canProceed(nil) { + if err := e.ctx.pauseForMessage(wait); err != nil { + e.held = paused(err) + return err + } + } break } + if err := e.pauseAfterMove(); err != nil { + e.held = true + return err + } } if e.state == StateWaiting && !atCurrentTime { e.endPausedBodies() @@ -551,12 +621,58 @@ func (e *ActionExecutor) run(atCurrentTime bool) error { return nil } +// pauseAfterMove pauses the body performing this action after one token move where +// its run goes one move at a time and another move is open now; nil else. +func (e *ActionExecutor) pauseAfterMove() error { + if !e.ctx.stepsTokens() || e.state != StateRunning || !e.canAct(nil) { + return nil + } + return e.ctx.tokenStepBody() +} + +// StepToBreakpoint is Step with the breakpoints a run stops at: a token sitting +// on one the run has not yet stopped at suspends the run before any token moves, +// and one a step lands on suspends it after, so the next step resumes past it. +func (e *ActionExecutor) StepToBreakpoint() error { + if e.released || e.state == StateReady || e.state.Ended() { + return e.Step() + } + if e.pauseAtBreakpoint() { + return nil + } + if err := e.Step(); err != nil { + return err + } + e.pauseAtBreakpoint() + return nil +} + +// Resume returns a run a breakpoint suspended to running, for a driver that moves +// it by other means than a step: the clock, say. Any other state is left as it is. +func (e *ActionExecutor) Resume() bool { + if e.state != StateSuspended { + return false + } + e.state, e.pausedAt = StateRunning, breakpointStop{} + return true +} + +// pauseAtBreakpoint suspends the run at a breakpoint a token sits on and has not +// yet stopped at; false when none does. +func (e *ActionExecutor) pauseAtBreakpoint() bool { + stop, hit := e.breakpointHit() + if !hit { + return false + } + e.pausedAt = stop + e.state = StateSuspended + return true +} + // stepOnce takes one step of a run, stopping at a breakpoint; true when the // run's loop ends here. func (e *ActionExecutor) stepOnce(atCurrentTime bool) (bool, error) { - if node := e.breakpointHit(); node != "" { - e.pausedAt = node - e.state = StateSuspended + if e.pauseAtBreakpoint() { return true, nil } if err := e.chargeActionStep(); err != nil { @@ -611,7 +727,7 @@ func (e *ActionExecutor) awaitClock(perf *actionFrame, progress *dueProgress) (b // dueNow reports whether a parked token of perf's flow (the action's for nil) can // proceed now: its instant has come, its message is in flight, or its performed action has work due. func (e *ActionExecutor) dueNow(perf *actionFrame) bool { - return e.hasDueTimeWait(perf) || e.hasPendingSignal(perf) || e.hasDueHeldRun(perf) + return e.hasDueTimeWait(perf) || e.hasPendingSignal(perf) || e.hasDuePausedWork(perf) } // canProceed reports whether a parked token of perf's flow (the action's for nil) @@ -643,11 +759,11 @@ func (e *ActionExecutor) changeWaitHolds(perf *actionFrame) bool { return false } -// hasDueHeldRun reports whether an action performed by the paused work of a token -// of perf's flow (the action's for nil) has work due at this instant. -func (e *ActionExecutor) hasDueHeldRun(perf *actionFrame) bool { +// hasDuePausedWork reports whether an executor the paused work of a token of perf's +// flow (the action's for nil) waits on has work due at this instant. +func (e *ActionExecutor) hasDuePausedWork(perf *actionFrame) bool { for _, token := range e.tokens { - if held := token.heldWaiter(); held != nil && token.inFlowOf(perf) && held.dueWork() { + if w := token.pausedWaiter(e); w != nil && token.inFlowOf(perf) && w.dueWork() { return true } } @@ -783,7 +899,7 @@ func (e *ActionExecutor) acceptTaking(m Message) ([]TakingAccept, error) { return nil, err } for _, token := range e.tokens { - held, ok := token.heldWaiter().(messageAcceptor) + held, ok := token.pausedWaiter(e).(messageAcceptor) if !ok { continue } @@ -846,7 +962,12 @@ func (e *ActionExecutor) acceptMatch(frame *actionFrame, accept lower.Accept, us } else if !ec.carriesEvent(m, accept.SubsetsEvent) { return false } - reaches, err := e.ctx.messageReaches(m, ActionNodeName(usage), accept.ViaPort, e.self) + holder, port, err := ec.viaHolder(accept.ViaPort, accept.ViaSelf, e.self) + if err != nil { + failed = err + return false + } + reaches, err := e.ctx.messageReaches(m, ActionNodeName(usage), port, holder) if err != nil { failed = err } @@ -854,22 +975,22 @@ func (e *ActionExecutor) acceptMatch(frame *actionFrame, accept lower.Accept, us }, &failed } -// breakpointHit returns the name of a breakpoint node a token sits on and has not yet -// stopped the run at, or "" if none does. Firing once per token and visit means a resumed -// run continues past the node it stopped at, while a token that comes back around a loop -// stops again; tokens held at a synchronized node stop once, when the last has arrived. -func (e *ActionExecutor) breakpointHit() string { - if len(e.breakpoints) == 0 { - return "" +// breakpointHit returns the breakpoint a token sits on and has not yet stopped the run +// at, false if none does. Firing once per token and visit means a resumed run continues +// past the node it stopped at, while a token that comes back around a loop stops again; +// tokens held at a synchronized node stop once, when the last has arrived. +func (e *ActionExecutor) breakpointHit() (breakpointStop, bool) { + if len(e.breakpoints) == 0 && len(e.breakpointNodes) == 0 { + return breakpointStop{}, false } for visit := range e.firedBreakpoints { - if loc, ok := e.tokenLocation(visit.token); !ok || loc != visit.node { + if tok, ok := e.tokenByID(visit.token); !ok || tok.Location != visit.node { delete(e.firedBreakpoints, visit) } } for i, token := range e.tokens { - name := e.breakpointNameOf(token.Location) - if name == "" { + stop, set := e.stopAt(token.Within(), token.Location) + if !set { continue } if e.firedBreakpoints[breakpointVisit{token: token.ID, node: token.Location}] { @@ -889,24 +1010,34 @@ func (e *ActionExecutor) breakpointHit() string { for _, idx := range performers { e.firedBreakpoints[breakpointVisit{token: e.tokens[idx].ID, node: token.Location}] = true } - return name + return stop, true } - return "" + return breakpointStop{}, false } -// tokenLocation returns where the given token sits, if it is still active. -func (e *ActionExecutor) tokenLocation(id int64) (ast.Node, bool) { +// tokenByID returns the given token, if it is still active. +func (e *ActionExecutor) tokenByID(id int64) (Token, bool) { for _, token := range e.tokens { if token.ID == id { - return token.Location, true + return token, true } } - return nil, false + return Token{}, false } -// breakpointNameOf returns the name a breakpoint is set on for the given node, -// or "" when none is. A node answers to its short name as well as its name. -func (e *ActionExecutor) breakpointNameOf(node ast.Node) string { +// breakpointNameOf returns the name a breakpoint is set on for node in the flow of +// within, or "" when none is. A node answers to its short name as well as its name; +// one a breakpoint is set on by identity answers to its description. +func (e *ActionExecutor) breakpointNameOf(within []ast.Node, node ast.Node) string { + for _, bp := range e.breakpointNodes { + if !bp.at(within, node) { + continue + } + if name := ActionNodeName(node); name != "" { + return name + } + return nodeDescription(node) + } for _, name := range ActionNodeNames(node) { if e.breakpoints[name] { return name @@ -918,7 +1049,15 @@ func (e *ActionExecutor) breakpointNameOf(node ast.Node) string { // PausedAt returns the breakpoint node the last run stopped at, or "" when the // run was not stopped by a breakpoint. func (e *ActionExecutor) PausedAt() string { - return e.pausedAt + return e.pausedAt.name +} + +// PausedBreakpoint identifies the node the last run stopped at, in its nested flow: +// a node a body performs is identified even as its token stays on the enclosing +// action. False when the run was not stopped by a breakpoint. The path is the caller's own. +func (e *ActionExecutor) PausedBreakpoint() (NodeBreakpoint, bool) { + at := e.pausedAt.at + return NodeBreakpoint{Within: slices.Clone(at.Within), Node: at.Node}, e.pausedAt.name != "" } // ActionNodeName returns the declared name of an action graph node, or "" when @@ -994,7 +1133,7 @@ func (e *ActionExecutor) NodeNames() []string { func (e *ActionExecutor) initializeAttributes() error { if e.occurrence != nil { for _, attr := range e.features { - if _, held := e.root.data[e.root.key(attr.Name)]; held { + if _, held := e.root.data[e.root.key(attr.Name)]; held || e.dynamics.ownsFeature(attr.Name) { continue } fv, err := e.occurrence.GetFeatureValue(e.ctx, attr.Name) @@ -1004,6 +1143,9 @@ func (e *ActionExecutor) initializeAttributes() error { } if value := fv.HeldValue(); value.Kind != ValInvalid { e.root.data[e.root.key(attr.Name)] = value + if err := e.streamInitialOutput(attr.Name, value); err != nil { + return err + } } } return nil @@ -1012,7 +1154,7 @@ func (e *ActionExecutor) initializeAttributes() error { ec := e.evalContextFor(e.root, e.graph.Scope) defer ec.beginStep()() for _, attr := range e.features { - if attr.Value == nil { + if attr.Value == nil || e.dynamics.ownsFeature(attr.Name) { continue } if _, held := e.root.data[e.root.key(attr.Name)]; held { @@ -1023,11 +1165,24 @@ func (e *ActionExecutor) initializeAttributes() error { return fmt.Errorf("eval attribute default %s: %w", attr.Name, err) } e.root.data[e.root.key(attr.Name)] = value + if err := e.streamInitialOutput(attr.Name, value); err != nil { + return err + } } return nil } +// streamInitialOutput carries the initial value of an output or inout to the listeners, +// as a write to it would; other features are the performance's own. +func (e *ActionExecutor) streamInitialOutput(name string, value Value) error { + switch e.root.features[e.root.key(name)] { + case ast.DirOut, ast.DirInOut: + return e.streamOutput(name, value) + } + return nil +} + // declaresAttribute reports whether the action declares an attribute of this // name, which its performance holds rather than the object performing it. func (e *ActionExecutor) declaresAttribute(name string) bool { @@ -1044,11 +1199,19 @@ func (e *ActionExecutor) assignAround(string, Value) (bool, error) { return false, nil } +// returnAround holds nothing either. +func (e *ActionExecutor) returnAround(string, Value) (bool, error) { + return false, nil +} + // runOwnFlow runs the flow a block-declared node states of its own to completion. func (e *ActionExecutor) runOwnFlow(perf *actionFrame) error { return e.runSubflow(perf) } +// endsOwn allows a terminate to end the action's own performance. +func (e *ActionExecutor) endsOwn() bool { return true } + // setFeature writes into the action's feature space, through the performance // occurrence for a feature the action declares: the occurrence is authoritative // for those, and data mirrors what it holds after the write. @@ -1071,6 +1234,37 @@ func (e *ActionExecutor) setFeature(name string, value Value) error { } e.root.data[e.root.key(name)] = value e.moved = true + return e.streamOutput(name, value) +} + +// outputListener is a node's ear on the outputs of the action it performs: perf is +// the node's performance, take carries each write to its pins. +type outputListener struct { + perf *actionFrame + take func(name string, value Value) error +} + +// listen has perf take each later write to the action's outputs, replacing what it +// listened through before. +func (e *ActionExecutor) listen(perf *actionFrame, take func(string, Value) error) { + e.unlisten(perf) + e.outputListeners = append(e.outputListeners, outputListener{perf: perf, take: take}) +} + +// unlisten stops perf's listening; a performance a node joined outlives the node. +func (e *ActionExecutor) unlisten(perf *actionFrame) { + e.outputListeners = slices.DeleteFunc(e.outputListeners, func(l outputListener) bool { + return l.perf == perf + }) +} + +// streamOutput carries a write to an output to every listener, in listening order. +func (e *ActionExecutor) streamOutput(name string, value Value) error { + for _, l := range e.outputListeners { + if err := l.take(name, value); err != nil { + return err + } + } return nil } @@ -1093,7 +1287,7 @@ func (e *ActionExecutor) setFrameFeatures(frame *actionFrame, values map[string] // hasFlow reports whether the action states a flow to start: an action with no // step performs none, while one whose steps give no start fails to initialize. func (e *ActionExecutor) hasFlow() bool { - return e.graph != nil && (e.graph.Initial != nil || statesSteps(e.graph)) + return e.graph != nil && (e.graph.Initial != nil || statesSteps(e.graph) || e.dynamicsKind != lower.NotStateSpace) } // statesSteps reports whether the graph has a step to perform, a final node aside. @@ -1177,7 +1371,10 @@ func (e *ActionExecutor) fixWitnessInputs() error { } value := in.Value if value.Kind == ValInvalid { - expr, ok := parseOneExpression("", in.Written) + expr, ok, err := e.ctx.model.parseOneExpression("", in.Written) + if err != nil { + return err + } if !ok { return &WitnessInputError{Feature: in.Feature, Reason: fmt.Sprintf("%q is not an expression the notation reads", in.Written)} } @@ -1238,6 +1435,9 @@ func (e *ActionExecutor) checkInputNames() error { func (e *ActionExecutor) initialize() error { defer e.ctx.beginExecutorRun(&e.driven)() + if e.dynamicsKind != lower.NotStateSpace { + return e.initializeDynamics() + } if e.graph.Initial == nil { return fmt.Errorf("%w: no initial node found in action %s%s", ErrInvalidActionFlow, e.action.Name, noFlowStart(e.graph)) @@ -1276,15 +1476,29 @@ func (e *ActionExecutor) stepToken(tokenIdx int) error { if tokenIdx < 0 || tokenIdx >= len(e.tokens) { return fmt.Errorf("invalid token index %d", tokenIdx) } - defer e.beginTokenStep(e.tokens[tokenIdx].ID)() + if e.dynamics == nil && e.tokens[tokenIdx].body == nil { + var ready bool + if tokenIdx, ready = e.synchronize(tokenIdx); !ready { + return nil + } + } + id := e.tokens[tokenIdx].ID + defer e.beginTokenStep(id)() + if err := e.stepTokenAt(tokenIdx); err != nil { + return e.endTerminatedFor(id, err) + } + return nil +} +// stepTokenAt advances the token at tokenIdx, synchronized already; a terminate its +// step reaches unwinds out of it, for stepToken to end the performance it names. +func (e *ActionExecutor) stepTokenAt(tokenIdx int) error { + if e.dynamics != nil { + return e.stepDynamics(tokenIdx) + } if e.tokens[tokenIdx].body != nil { return e.resumeBody(tokenIdx) } - tokenIdx, ready := e.synchronize(tokenIdx) - if !ready { - return nil - } token := &e.tokens[tokenIdx] switch node := token.Location.(type) { @@ -1558,12 +1772,13 @@ func oneMoveEligible(t Token) bool { return !t.drivenByBody() && (t.body == nil // stepCandidates lists the tokens a step may move, as the policy is handed them. func (e *ActionExecutor) stepCandidates(order *stepOrder, eligible func(Token) bool) stepTokens { tokens := stepTokens{ - owner: e, - step: e.stepCount + 1, - ids: make([]int64, 0, len(e.tokens)), - parked: make(map[int64]bool), - held: make(map[int64]bool), - label: e.tokenLabel, + owner: e, + step: e.stepCount + 1, + ids: make([]int64, 0, len(e.tokens)), + parked: make(map[int64]bool), + held: make(map[int64]bool), + label: e.tokenLabel, + stepped: e.ctx.stepsTokens(), } tokens.enabled = func(id int64) bool { return e.enabled(id, eligible) } for i, t := range e.tokens { @@ -1602,6 +1817,9 @@ func (e *ActionExecutor) readiness(id int64, eligible func(Token) bool) (ready b return false, nil } t := e.tokens[i] + if e.dynamics != nil { + return e.dynamicsDue(t), nil + } usage, ok := t.Location.(*ast.Usage) if !ok || t.body != nil { return true, nil @@ -1626,8 +1844,8 @@ func (e *ActionExecutor) readiness(id int64, eligible func(Token) bool) (ready b // tokenLabel names a token as the trace does, by ID and node. func (e *ActionExecutor) tokenLabel(id int64) string { - if loc, ok := e.tokenLocation(id); ok { - return fmt.Sprintf("%d@%s", id, nodeIdentifier(loc)) + if tok, ok := e.tokenByID(id); ok { + return fmt.Sprintf("%d@%s", id, nodeIdentifier(tok.Location)) } return fmt.Sprintf("%d", id) } @@ -1700,7 +1918,7 @@ func (e *ActionExecutor) stepInitialNode(tokenIdx int) error { } // Move token to first successor (initial should have exactly 1) - token.travel(successors[0], e.sweep) + e.move(token, successors[0]) return nil } @@ -1724,6 +1942,9 @@ func (e *ActionExecutor) retireToken(tokenIdx int) error { if frame == e.root && !frame.inBody { e.removeToken(tokenIdx) if len(e.tokens) == 0 { + if err := checkStreamsReceived(e.root); err != nil { + return err + } e.state = StateCompleted e.ctx.endPerformanceLife(e.occurrence) } @@ -1776,6 +1997,7 @@ func (e *ActionExecutor) stepForkNode(tokenIdx int) error { } e.nextTokenID++ newTokens = append(newTokens, newToken) + e.noteTraversal(&newToken, edge) } // Remove original token, add new tokens @@ -1817,7 +2039,7 @@ func (e *ActionExecutor) stepJoinNode(tokenIdx int) error { if len(successors) == 0 { return e.retireToken(tokenIdx) } - token.travel(successors[0], e.sweep) + e.move(token, successors[0]) return nil } @@ -1853,7 +2075,7 @@ func (e *ActionExecutor) stepMergeNode(tokenIdx int) error { if len(successors) == 0 { return e.retireToken(tokenIdx) } - token.travel(successors[0], e.sweep) + e.move(token, successors[0]) return nil } @@ -1881,6 +2103,10 @@ func (e *ActionExecutor) stepDecisionNode(tokenIdx int) error { ec := e.evalContextFor(token.frame, graph.Scope) defer ec.beginStep()() + // A weighted decision draws among every succession whose guard holds, an + // unguarded one holding outright; it has no else branch. + weighted := len(successors) > 0 && successors[0].Probability != nil + // Two-pass evaluation: // 1. Evaluate all guarded edges; take the first that holds (several: a choice point) // 2. If none holds, use unguarded edge as fallback (else branch) @@ -1892,8 +2118,12 @@ func (e *ActionExecutor) stepDecisionNode(tokenIdx int) error { // are probed only to report the choice, which leaves the run as it was. for i := range successors { edge := &successors[i] - // No guard = remember for fallback if edge.Guard == nil { + if weighted { + holding = append(holding, i) + continue + } + // No guard = remember for fallback unguardedEdge = edge continue } @@ -1912,14 +2142,17 @@ func (e *ActionExecutor) stepDecisionNode(tokenIdx int) error { } } if len(holding) > 0 { - choice, pick := e.chooseBranch(token.frame, decisionNode, successors, holding) + choice, pick, err := e.chooseBranch(ec, token.frame, decisionNode, successors, holding) + if err != nil { + return err + } // A refused replay move leaves the token at the decision: no branch is taken. if refused := e.ctx.scheduling().refusal(); refused != nil { return refused } // A branch picked past the first was only probed; its guard's final reading // is the run's own, so the run holds what evaluating it did. - if pick > 0 { + if pick > 0 && successors[holding[pick]].Guard != nil { holds, err := e.guardHolds(ec, decisionNode, successors[holding[pick]].Guard) if err != nil { return err @@ -1930,15 +2163,15 @@ func (e *ActionExecutor) stepDecisionNode(tokenIdx int) error { } } if choice != nil { - e.ctx.noteChoice(*choice) + e.noteChoice(*choice) } - token.travel(successors[holding[pick]], e.sweep) + e.move(token, successors[holding[pick]]) return nil } // Pass 2: Use unguarded edge as fallback if unguardedEdge != nil { - token.travel(*unguardedEdge, e.sweep) + e.move(token, *unguardedEdge) return nil } @@ -1997,7 +2230,7 @@ func (e *ActionExecutor) leaveExecutionNode(tokenIdx int, frame *actionFrame, no } // Apply data flows: transfer data from this node's output pins to target input pins - if err := e.applyDataFlows(frame, frame.graph, node, frame.data); err != nil { + if err := e.applyDataFlows(frame, frame.graph, node, nil, frame.data, nil); err != nil { return err } @@ -2005,7 +2238,7 @@ func (e *ActionExecutor) leaveExecutionNode(tokenIdx int, frame *actionFrame, no return e.retireToken(tokenIdx) } - e.tokens[tokenIdx].travel(successors[0], e.sweep) + e.move(&e.tokens[tokenIdx], successors[0]) return nil } @@ -2022,6 +2255,7 @@ func (e *ActionExecutor) stepNestedAction(tokenIdx int) error { // the next step retries the match. graph := e.graphOf(token.frame) accept, isAccept := graph.Accepts[usage] + var payload *Value if isAccept && accept.Trigger != nil { // A trigger waits for time to pass or for a condition to hold rather // than for a message, so it is answered here and not from the queue. @@ -2069,6 +2303,10 @@ func (e *ActionExecutor) stepNestedAction(tokenIdx int) error { return nil } token.Wait = nil + if tr := e.trace(); tr != nil { + tr.RecordAccept(TraceOrigin{At: e.ctx.clock.now, Object: e.self, Behavior: e.action}, + acceptedEventName(msg), msg.Payload) + } if accept.ParamName != "" { value, err := e.ctx.acceptedValue(&msg) if err != nil { @@ -2079,6 +2317,7 @@ func (e *ActionExecutor) stepNestedAction(tokenIdx int) error { if err := e.setFrameFeature(token.frame, accept.ParamName, value); err != nil { return err } + payload = &value } } @@ -2086,6 +2325,12 @@ func (e *ActionExecutor) stepNestedAction(tokenIdx int) error { if err != nil { return err } + // The payload is the accept's own output pin as well, for a flow out of the node. + if payload != nil { + if err := e.setFrameFeature(perf, accept.ParamName, *payload); err != nil { + return err + } + } // A node owning no flow, performing no action, is done in this step: a case // runs to completion, a body runs its statements, and the token goes on. @@ -2115,7 +2360,7 @@ func (e *ActionExecutor) completeNode(tokenIdx int, perf *actionFrame) error { // The flows out of this node carry what this performance produced to the // pins the nodes downstream read. - if err := e.applyDataFlows(frame, frame.graph, node, perf.data); err != nil { + if err := e.applyDataFlows(frame, frame.graph, node, perf, perf.data, perf.streamed); err != nil { return err } @@ -2125,7 +2370,7 @@ func (e *ActionExecutor) completeNode(tokenIdx int, perf *actionFrame) error { return e.retireToken(tokenIdx) } - e.tokens[tokenIdx].travel(successors[0], e.sweep) + e.move(&e.tokens[tokenIdx], successors[0]) return nil } @@ -2226,14 +2471,14 @@ func (e *ActionExecutor) NextWait() (float64, bool) { } // TimeWaits describes the tokens parked on the clock, in token-ID order, then -// those of the actions the paused work of its tokens performs. +// those of the executors the paused work of its tokens waits on. func (e *ActionExecutor) TimeWaits() []string { waits := e.timeWaits(nil) out := make([]string, 0, len(waits)) for _, token := range waits { out = append(out, token.Wait.String()) } - for _, held := range e.heldWaiters() { + for _, held := range e.pausedWaiters() { for _, wait := range held.clockWaits() { out = append(out, wait.What()) } @@ -2248,27 +2493,42 @@ func (e *ActionExecutor) visibleWaits() []ClockWait { } // visibleArmedWaits lists, due or not, the waits that hold this action, earliest first: -// its own and, through the paused work of its tokens, those of the actions it performs. +// its tokens' and, through their paused work, those of the actions it performs and cases it runs. func (e *ActionExecutor) visibleArmedWaits() []ClockWait { - waits := e.armedWaits() - for _, held := range e.heldWaiters() { - waits = append(waits, held.visibleArmedWaits()...) + waits := e.tokenWaits() + for _, w := range e.pausedWaiters() { + waits = append(waits, w.visibleArmedWaits()...) } slices.SortStableFunc(waits, func(a, b ClockWait) int { return cmp.Compare(a.Due, b.Due) }) return waits } -// heldWaiters lists the executors performing an action for the paused work of -// this action's tokens, in token-ID order. -func (e *ActionExecutor) heldWaiters() []clockWaiter { - tokens := slices.SortedFunc(slices.Values(e.tokens), func(a, b Token) int { return cmp.Compare(a.ID, b.ID) }) - var held []clockWaiter - for _, token := range tokens { - if w := token.heldWaiter(); w != nil { - held = append(held, w) +// pausedWaiters lists the executors the paused work of this action's tokens waits +// on (performing an action for it, or the flow of a case it runs), in token-ID order. +func (e *ActionExecutor) pausedWaiters() []clockWaiter { + var waiters []clockWaiter + for _, token := range e.tokensByID() { + if w := token.pausedWaiter(e); w != nil { + waiters = append(waiters, w) } } - return held + return waiters +} + +// hostedFlows lists the flows of the cases the paused work of this action's tokens +// runs, in token-ID order; the action lists their waits to the clock as its own. +func (e *ActionExecutor) hostedFlows() []*ActionExecutor { + var flows []*ActionExecutor + for _, token := range e.tokensByID() { + if flow := token.hostedFlow(e); flow != nil { + flows = append(flows, flow) + } + } + return flows +} + +func (e *ActionExecutor) tokensByID() []Token { + return slices.SortedFunc(slices.Values(e.tokens), func(a, b Token) int { return cmp.Compare(a.ID, b.ID) }) } // dueLabel names the executor in a due-order choice. @@ -2282,31 +2542,49 @@ func (e *ActionExecutor) clockWaits() []ClockWait { return notYetDue(e.armedWaits(), e.ctx.clock.now) } -// armedWaits lists the tokens parked on the clock, due or not, earliest first; an -// action performed for a paused body lists its own to the clock. +// armedWaits lists the waits this action holds on the clock, due or not, earliest +// first: its tokens parked there and, as its own, those of the cases its paused work +// runs; an action performed for a paused body lists its own to the clock. func (e *ActionExecutor) armedWaits() []ClockWait { + waits := e.tokenWaits() + for _, flow := range e.hostedFlows() { + waits = append(waits, flow.armedWaits()...) + } + slices.SortStableFunc(waits, func(a, b ClockWait) int { return cmp.Compare(a.Due, b.Due) }) + return waits +} + +// tokenWaits lists the action's tokens parked on the clock, due or not, in token-ID order. +func (e *ActionExecutor) tokenWaits() []ClockWait { var waits []ClockWait for _, token := range e.timeWaits(nil) { waits = append(waits, ClockWait{Due: token.Wait.Due, holder: e, what: token.Wait}) } - slices.SortStableFunc(waits, func(a, b ClockWait) int { return cmp.Compare(a.Due, b.Due) }) return waits } // dueWork reports a token that can move at this instant (not parked nor paused on -// the clock, due, or with a message in flight) in the flow awaiting the clock, else the action's. +// the clock, not held at a join, due, or with a message in flight) in the flow +// awaiting the clock, else the action's. func (e *ActionExecutor) dueWork() bool { if e.released || (e.state != StateRunning && e.state != StateWaiting) { return false } for _, token := range e.tokens { - if token.Wait == nil && !token.pausedOnClock() && token.inFlowOf(e.awaiting) { + if token.Wait == nil && !token.pausedOnClock() && !e.heldAtSync(token) && token.inFlowOf(e.awaiting) { return true } } return e.dueNow(e.awaiting) } +// heldAtSync reports a token held at a synchronizing node for a succession into +// it no token has arrived over yet: it cannot move until the arrival does. +func (e *ActionExecutor) heldAtSync(t Token) bool { + consumed, held := e.arrivals(t) + return held && consumed == nil +} + // watchesChange reports a token parked at an `accept when`, which data written // outside the action can let proceed. func (e *ActionExecutor) watchesChange() bool { @@ -2324,12 +2602,12 @@ func (e *ActionExecutor) watchesChange() bool { // runDue runs the action to quiescence at the current instant; the steps it takes // count against the drive's budget together with those already taken. func (e *ActionExecutor) runDue(progress *dueProgress) (bool, error) { - before, positions := e.stepCount, e.tokenPositions() + before, positions, taken := e.stepCount, e.tokenPositions(), e.dynamicsSteps() e.stepsSpent = progress.steps err := e.run(true) e.stepsSpent = 0 progress.steps += int64(e.stepCount - before) - return e.state != StateWaiting || !maps.Equal(positions, e.tokenPositions()), err + return e.state != StateWaiting || !maps.Equal(positions, e.tokenPositions()) || e.dynamicsSteps() != taken, err } // tokenPositions maps each token to where it stands, for telling a step that @@ -2342,7 +2620,7 @@ func (e *ActionExecutor) tokenPositions() map[int64]ast.Node { return positions } -func (e *ActionExecutor) finished() bool { return e.released || e.state == StateCompleted } +func (e *ActionExecutor) finished() bool { return e.released || e.state.Ended() } func (e *ActionExecutor) running() bool { return e.inRun || e.held } // performerSuffix names the object performing a behavior, nothing for none. @@ -2374,7 +2652,7 @@ func (e *ActionExecutor) leaveStatementNode(tokenIdx int, frame *actionFrame, no if len(successors) == 0 { return e.retireToken(tokenIdx) } - e.tokens[tokenIdx].travel(successors[0], e.sweep) + e.move(&e.tokens[tokenIdx], successors[0]) return nil } @@ -2398,17 +2676,34 @@ func statementNodeKeyword(node ast.Node) string { } // applyDataFlows moves what the completed performance produced along graph's flows out -// of sourceNode to the target pins; a source pin holding nothing is an error, not a no-op. -func (e *performances) applyDataFlows(frame *actionFrame, graph *lower.ActionGraph, sourceNode ast.Node, produced map[string]Value) error { +// of sourceNode to the target pins; a source pin holding nothing is an error, not a no-op, +// unless the pin is declared admitting no value, when the flow carries nothing. +// A streaming flow from a pin in streamed carried its values as they were written; +// perf is the performance that produced, nil for a node performed in frame itself. +func (e *performances) applyDataFlows( + frame *actionFrame, graph *lower.ActionGraph, sourceNode ast.Node, perf *actionFrame, produced map[string]Value, streamed map[string]bool, +) error { for _, flow := range graph.DataFlows[sourceNode] { + if flow.Kind == lower.FlowStreaming && streamed[flow.SourcePin] { + continue + } sourceData, ok := produced[flow.SourcePin] if !ok { + if perf.admitsNoValueAt(flow.SourcePin) { + continue + } return fmt.Errorf( - "%s: %s produced no value at %s", - flowDescription(flow), nodeDescription(sourceNode), orAnyPin(flow.SourcePin), + "%w: %s: %s produced no value at %s", + ErrFlowSource, flowDescription(flow), nodeDescription(sourceNode), orAnyPin(flow.SourcePin), ) } - if err := e.deliverFlow(frame, graph, flow, sourceData); err != nil { + var err error + if flow.Kind == lower.FlowStreaming { + err = e.streamFlow(frame, graph, sourceNode, perf, flow, sourceData) + } else { + err = e.deliverFlow(frame, graph, flow, sourceData) + } + if err != nil { return err } } @@ -2427,14 +2722,14 @@ func (e *performances) deliverFlow(frame *actionFrame, graph *lower.ActionGraph, return e.setFrameFeature(frame, flow.TargetPin, value) } -// flowDescription names a data flow for a diagnostic: its own name when it was -// declared with one, and the pins it joins otherwise. +// flowDescription names a data flow for a diagnostic: its kind and its own name when it +// was declared with one, and the pins it joins otherwise. func flowDescription(flow lower.ObjectFlow) string { if flow.Name != "" { - return "flow " + flow.Name + return flow.Kind.String() + " " + flow.Name } return fmt.Sprintf( - "flow from %s to %s", + flow.Kind.String()+" from %s to %s", orAnyPin(flow.SourcePin), orAnyPin(flow.TargetPin), ) } @@ -2465,19 +2760,117 @@ func (e *ActionExecutor) Tokens() []Token { return tokens } +// Traversal is one succession a token took: which token, the edge, and the +// nested action nodes whose own flows it was running in (see Token.Within). +type Traversal struct { + Token int64 + Edge lower.ActionEdge + Within []ast.Node +} + +// cloneTraversals copies ts, each path with it, so neither copy can alias the other. +func cloneTraversals(ts []Traversal) []Traversal { + if ts == nil { + return nil + } + out := make([]Traversal, len(ts)) + for i, t := range ts { + out[i] = Traversal{Token: t.Token, Edge: t.Edge, Within: slices.Clone(t.Within)} + } + return out +} + +// KeepTraversals has the run keep the successions its tokens take until +// TraversalsSince releases them, for a debugger; off (the default), it keeps none. +func (e *ActionExecutor) KeepTraversals(keep bool) { + e.keepTraversals = keep + if !keep { + e.releaseTraversals(len(e.traversals)) + } +} + +// Traversals returns the successions kept, in order: a fork's branches as the +// tokens they spawned, a join as the one token that passes on. +func (e *ActionExecutor) Traversals() []Traversal { + return cloneTraversals(e.traversals) +} + +// TraversalCount counts the successions taken so far, released ones included: a +// mark to read what a later step took from. +func (e *ActionExecutor) TraversalCount() int { return e.traversalBase + len(e.traversals) } + +// TraversalsSince returns the kept successions taken since mark, a TraversalCount +// read earlier, and releases those before it: the run keeps only from the last mark. +func (e *ActionExecutor) TraversalsSince(mark int) []Traversal { + e.releaseTraversals(mark - e.traversalBase) + if len(e.traversals) == 0 { + return nil + } + return cloneTraversals(e.traversals) +} + +// releaseTraversals forgets the oldest n kept successions, still counting them. +func (e *ActionExecutor) releaseTraversals(n int) { + n = min(n, len(e.traversals)) + if n <= 0 { + return + } + kept := copy(e.traversals, e.traversals[n:]) + clear(e.traversals[kept:]) + e.traversals, e.traversalBase = e.traversals[:kept], e.traversalBase+n +} + +// noteTraversal counts a succession token took, keeping it when asked to. +func (e *ActionExecutor) noteTraversal(token *Token, edge lower.ActionEdge) { + if !e.keepTraversals { + e.traversalBase++ + return + } + e.traversals = append(e.traversals, Traversal{Token: token.ID, Edge: edge, Within: token.Within()}) +} + +// move travels token along edge, recording the traversal. +func (e *ActionExecutor) move(token *Token, edge lower.ActionEdge) { + token.travel(edge, e.sweep) + e.noteTraversal(token, edge) +} + // State returns current execution state. func (e *ActionExecutor) State() ExecutionState { return e.state } -// Results returns the values the action's features hold, and under `node.pin` those -// of each nested node's latest performance; a performed usage's mirror its occurrence. +// Results returns the values the action's features hold, under `node.pin` those of +// each nested node's latest performance and under `part.attribute` what the one +// object each of its own parts denotes holds; a performed usage's mirror its occurrence. func (e *ActionExecutor) Results() map[string]Value { results := make(map[string]Value, len(e.root.data)) e.root.collect("", results) + e.collectPartsHeld(results) return results } +// collectPartsHeld adds the attributes held by the object each part or item usage the +// action declares denotes, keyed `part.attribute`. A usage denotes one object for the +// context, which every activation reads; one never denoted, or of several, is left out. +func (e *ActionExecutor) collectPartsHeld(into map[string]Value) { + if e.action == nil || e.action.Scope == nil { + return + } + for _, sym := range e.action.Scope.Members() { + if sym.Name == "" || (sym.Kind != symbols.SymbolPartUsage && sym.Kind != symbols.SymbolItemUsage) { + continue + } + objects, live := e.ctx.liveOccurrences(sym) + if !live || len(objects) != 1 { + continue + } + for name, value := range e.ctx.attributesHeld(objects[0]) { + into[sym.Name+"."+name] = value + } + } +} + // Data returns the live feature space of the action's own performance; a nested // node's features live in its own performance and are reported by Results. func (e *ActionExecutor) Data() map[string]Value { @@ -2535,10 +2928,49 @@ func (e *ActionExecutor) SetBreakpoint(nodeName string) { e.breakpoints[nodeName] = true } +// ReplaceBreakpointsAt makes bps the breakpoints set by identity, named or not, one +// occurrence of a node each: a debugger holding the graph (Graph, Token.Within) sets +// breakpoints this way. A stop already made at one kept stands, so a resumed run +// passes it; one removed stops again once re-set. +func (e *ActionExecutor) ReplaceBreakpointsAt(bps []NodeBreakpoint) { + e.breakpointNodes = e.breakpointNodes[:0] + for _, bp := range bps { + if bp.Node != nil { + e.breakpointNodes = append(e.breakpointNodes, NodeBreakpoint{Within: slices.Clone(bp.Within), Node: bp.Node}) + } + } + for visit := range e.firedBreakpoints { + if tok, ok := e.tokenByID(visit.token); !ok || e.breakpointNameOf(tok.Within(), visit.node) == "" { + delete(e.firedBreakpoints, visit) + } + } + e.forgetBodyStopsRemoved() +} + // ClearBreakpoints removes all breakpoints. func (e *ActionExecutor) ClearBreakpoints() { e.breakpoints = make(map[string]bool) + e.breakpointNodes = nil e.firedBreakpoints = make(map[breakpointVisit]bool) + e.forgetBodyStopsRemoved() +} + +// forgetBodyStopsRemoved has each body stopped at a breakpoint no longer set look +// again when resumed, as firedBreakpoints forgets a token's stop at one removed. +func (e *ActionExecutor) forgetBodyStopsRemoved() { + for _, token := range e.tokens { + run := token.body + if run == nil || run.paused.onWait || len(run.cursor) == 0 { + continue + } + f, ok := run.cursor[0].(*performFrame) + if !ok || !f.stoppedAtBreakpoint() { + continue + } + if at := run.paused.breakpoint.at; e.breakpointNameOf(at.Within, at.Node) == "" { + f.recheck = true + } + } } // trace returns the recorder this executor's context is attached to, so turning diff --git a/internal/core/runtime/action_executor_test.go b/internal/exec/runtime/action_executor_test.go similarity index 97% rename from internal/core/runtime/action_executor_test.go rename to internal/exec/runtime/action_executor_test.go index d02a406c3e..84f7aabaf3 100644 --- a/internal/core/runtime/action_executor_test.go +++ b/internal/exec/runtime/action_executor_test.go @@ -4,13 +4,13 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) func TestActionExecutor_Creation(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Create minimal action symbol action := &symbols.Symbol{ @@ -42,7 +42,7 @@ func TestActionExecutor_Creation(t *testing.T) { } func TestActionExecutor_GraphExtraction(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} final := &ast.FinalNode{} @@ -77,7 +77,7 @@ func TestActionExecutor_GraphExtraction(t *testing.T) { } func TestActionExecutor_InitialNode(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Create action: initial → final initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} @@ -126,7 +126,7 @@ func TestActionExecutor_InitialNode(t *testing.T) { } func TestActionExecutor_FinalNode(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} final := &ast.FinalNode{} @@ -186,7 +186,7 @@ func TestActionExecutor_FinalNode(t *testing.T) { } func TestActionExecutor_ActionExecutionNode(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} action := &ast.ActionExecutionNode{ @@ -254,7 +254,7 @@ func TestActionExecutor_ActionExecutionNode(t *testing.T) { } func TestActionExecutor_ForkNode(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} fork := &ast.ForkNode{Name: "split"} @@ -312,7 +312,7 @@ func TestActionExecutor_ForkNode(t *testing.T) { // fork's branches: they read and write the one feature space of the action they // belong to, so a write in one branch is visible in the others. func TestActionExecutor_ForkNode_SharedFeatureSpace(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} fork := &ast.ForkNode{Name: "split"} @@ -372,7 +372,7 @@ func TestActionExecutor_ForkNode_SharedFeatureSpace(t *testing.T) { } func TestActionExecutor_ForkNode_NoSuccessors(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} fork := &ast.ForkNode{Name: "split"} @@ -413,7 +413,7 @@ func TestActionExecutor_ForkNode_NoSuccessors(t *testing.T) { // A node with no succession out of it ends its flow: each forked branch retires // there, and the action completes once the last token has. func TestActionExecutor_NodeWithoutSuccessorsRetiresItsToken(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} fork := &ast.ForkNode{Name: "split"} @@ -470,7 +470,7 @@ func TestActionExecutor_NodeWithoutSuccessorsRetiresItsToken(t *testing.T) { } func TestActionExecutor_JoinNode(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} fork := &ast.ForkNode{Name: "split"} @@ -573,7 +573,7 @@ func TestActionExecutor_JoinNode(t *testing.T) { } func TestActionExecutor_JoinNode_PartialArrival(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} fork := &ast.ForkNode{Name: "split"} @@ -681,7 +681,7 @@ func TestActionExecutor_JoinNode_PartialArrival(t *testing.T) { // TestActionExecutor_MergeNode: a merge passes every arriving token, so two fork // branches reaching it are two traversals, each going on to the final node. func TestActionExecutor_MergeNode(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build: initial → fork → [merge, merge] → final initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} @@ -776,7 +776,7 @@ func TestActionExecutor_MergeNode(t *testing.T) { // TestActionExecutor_MergeNode_ControlOnly: a merge carries control only — both // branches' tokens pass it and the value their writes left is untouched by the traversals. func TestActionExecutor_MergeNode_ControlOnly(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // fork → [path1 sets x=1, path2 sets x=2] → merge → final initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} @@ -899,7 +899,7 @@ func TestActionExecutor_MergeNode_ControlOnly(t *testing.T) { } func TestActionExecutor_MergeNode_SingleParent(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // initial → merge → final (degenerate case, merge pass-through) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} @@ -945,7 +945,7 @@ func TestActionExecutor_MergeNode_SingleParent(t *testing.T) { } func TestActionExecutor_DecisionNode(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build: initial → decision → [pathA (if x>10), pathB (if x<=10)] → final initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} @@ -1148,7 +1148,7 @@ func TestActionExecutor_DecisionNode(t *testing.T) { } func TestActionExecutor_DecisionNode_ElseBranch(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build: initial → decision → [pathA (if x>10), pathElse (no guard)] → final // Test that unguarded edge works as fallback after guarded edges @@ -1267,7 +1267,7 @@ func TestActionExecutor_DecisionNode_ElseBranch(t *testing.T) { } func TestActionExecutor_DecisionNode_NonBooleanGuard(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build: initial → decision → pathA (with integer guard - invalid) initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} @@ -1335,7 +1335,7 @@ func findSubstring(s, substr string) bool { } func TestActionExecutor_ObjectFlow(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build: initial → action1 → action2 → final // With ObjectFlowEdge: action1.output → action2.input @@ -1432,7 +1432,7 @@ func TestActionExecutor_ObjectFlow(t *testing.T) { } func TestActionExecutor_Step(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build: initial → action → final initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} @@ -1501,7 +1501,7 @@ func TestActionExecutor_Step(t *testing.T) { } func TestActionExecutor_RunToCompletion(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build: initial → fork → [action1, action2] → join → final initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} @@ -1577,7 +1577,7 @@ func TestActionExecutor_RunToCompletion(t *testing.T) { } func TestActionExecutor_Deadlock_JoinStarvation(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build deadlock scenario: // initial → fork → [path1 → join, path2 → action2] @@ -1680,7 +1680,7 @@ func TestActionExecutor_Deadlock_JoinStarvation(t *testing.T) { // Integration Tests (Tasks 12-16) func TestActionExecutor_Integration_Sequential(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build: initial → compute1 → compute2 → compute3 → final // Sequential execution with data flow @@ -1751,7 +1751,7 @@ func TestActionExecutor_Integration_Sequential(t *testing.T) { } func TestActionExecutor_Integration_ForkJoin(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build: initial → fork → [path1, path2, path3] → join → final // Parallel execution with synchronization @@ -1836,7 +1836,7 @@ func TestActionExecutor_Integration_ForkJoin(t *testing.T) { } func TestActionExecutor_Integration_DecisionMerge(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build: initial → decision → [pathTrue, pathFalse] → merge → final // Conditional branching with merge @@ -1948,7 +1948,7 @@ func TestActionExecutor_Integration_DecisionMerge(t *testing.T) { } func TestActionExecutor_Integration_ObjectFlow(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build pipeline with data flow: initial → producer → consumer → final // ObjectFlow: producer.result → consumer.input @@ -2008,7 +2008,7 @@ func TestActionExecutor_Integration_ObjectFlow(t *testing.T) { } func TestActionExecutor_Integration_ErrorCases(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Test 1: Initial node missing t.Run("missing_initial_node", func(t *testing.T) { @@ -2102,7 +2102,7 @@ func TestActionExecutor_Integration_ErrorCases(t *testing.T) { // Task 48: Parallel processing workflow - fork/join + data merge func TestActionExecutor_Integration_ParallelProcessing(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Workflow: initial → fork → [processA, processB, processC] → join → aggregate → final // Each processor adds to input value, join merges all data, aggregate sums @@ -2307,7 +2307,7 @@ func guardedSuccessionExecutor(t *testing.T, guard ast.Node) *ActionExecutor { }, } - exec, err := newActionExecutor(NewContext(NewModel(semantics.NewModel(nil), nil), 1000), actionSym, nil) + exec, err := newActionExecutor(NewContext(typedModel(semantics.NewModel(nil), nil), 1000), actionSym, nil) if err != nil { t.Fatalf("create executor: %v", err) } @@ -2419,7 +2419,7 @@ func TestActionExecutor_GuardedSuccession_OutOfInitialNode(t *testing.T) { }, } - exec, err := newActionExecutor(NewContext(NewModel(semantics.NewModel(nil), nil), 1000), actionSym, nil) + exec, err := newActionExecutor(NewContext(typedModel(semantics.NewModel(nil), nil), 1000), actionSym, nil) if err != nil { t.Fatalf("create executor: %v", err) } @@ -2475,7 +2475,7 @@ func TestActionExecutor_GuardedSuccession_PrunesAForkBranch(t *testing.T) { }, } - exec, err := newActionExecutor(NewContext(NewModel(semantics.NewModel(nil), nil), 1000), actionSym, nil) + exec, err := newActionExecutor(NewContext(typedModel(semantics.NewModel(nil), nil), 1000), actionSym, nil) if err != nil { t.Fatalf("create executor: %v", err) } @@ -2533,7 +2533,7 @@ func TestActionExecutor_GuardedSuccession_TwoGuardsHold(t *testing.T) { }, } - exec, err := newActionExecutor(NewContext(NewModel(semantics.NewModel(nil), nil), 1000), actionSym, nil) + exec, err := newActionExecutor(NewContext(typedModel(semantics.NewModel(nil), nil), 1000), actionSym, nil) if err != nil { t.Fatalf("create executor: %v", err) } @@ -2593,7 +2593,7 @@ func TestActionExecutor_GuardedSuccession_PrunedMergeStaysOpen(t *testing.T) { }, } - exec, err := newActionExecutor(NewContext(NewModel(semantics.NewModel(nil), nil), 1000), actionSym, nil) + exec, err := newActionExecutor(NewContext(typedModel(semantics.NewModel(nil), nil), 1000), actionSym, nil) if err != nil { t.Fatalf("create executor: %v", err) } diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go new file mode 100644 index 0000000000..bd4dda9825 --- /dev/null +++ b/internal/exec/runtime/action_frame.go @@ -0,0 +1,1611 @@ +package runtime + +import ( + "errors" + "fmt" + "maps" + "slices" + "sort" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// A nested action usage is a subperformance (`Actions::Action::subactions :> subperformances`): +// each token performs it anew in a frame of its own, with the enclosing performances in lexical reach. + +// performances runs the nested performances of one behavior under root, its own, +// evaluated in ctx as self: an action's executor is one, a state behavior's body another. +type performances struct { + ctx *Context + self *Instance + root *actionFrame + owner performanceOwner + // behavior is the action or state machine the trace names as making what + // these performances send and draw; nil for a body no behavior owns. + behavior *symbols.Symbol + // flow is the executor holding the tokens these performances run under, which a + // terminate drops when it ends one of them. + flow *ActionExecutor +} + +// performanceOwner is the behavior whose nodes perform — an action executor or a state +// behavior (state_statements.go): what its root holds, what is around it, how a node's flow runs. +type performanceOwner interface { + // setFeature writes a feature the root performance holds, checked and stored by its holder. + setFeature(name string, value Value) error + // assignAround writes a name no performance nor block around a node holds to + // what is around the root, reporting whether something there holds it. + assignAround(name string, value Value) (bool, error) + // returnAround is assignAround for an output a performed action returns past + // every frame: what is around the root may also keep it for a caller. + returnAround(name string, value Value) (bool, error) + // pauseAt pauses the run before node, in the flow of within, performs where a breakpoint is set on it. + pauseAt(within []ast.Node, node ast.Node) error + // runOwnFlow runs the flow perf's node states of its own to completion. + runOwnFlow(perf *actionFrame) error + // endsOwn reports whether a terminate may end the root performance: an action's + // or a state behavior's ends at the statement; a case's own flow cannot be ended. + endsOwn() bool +} + +// actionFrame is one performance: the action's own (node nil) or a nested node's. +type actionFrame struct { + node ast.Node + graph *lower.ActionGraph // the flow this performance runs, nil for a leaf node + // flow is the graph node is a node of: parent's own, or the flow of a block + // in a body parent runs (lower/block_graph.go). nil for the action's own. + flow *lower.ActionGraph + scope *symbols.Scope // the namespace the performance's features resolve in + parent *actionFrame + // body marks a transparent performance for a loop or branch body's own flow. + body bool + // locals are the block-local bindings entered around node in parent's body, + // outermost first: a loop variable the node's declarations read. + locals []map[string]Value + // outer are the frames around a root performance its bodies read but no performance + // holds, outermost first: a state machine's data and its states' attributes. + outer []frame + // live counts the tokens still running in this performance's flow, which a + // fork inside it raises and a join or a retiring token lowers. + live int + // inBody marks a flow a body statement runs to completion (runSubflow) rather + // than a token of the enclosing flow, so its last token retires instead of leaving. + inBody bool + // connections are the connectors a send in this flow may route through: + // those of every flow around it, this one's own included. + connections []lower.Connection + // data holds the values the performance's own features hold. + data map[string]Value + // features are the parameters and attributes the performance holds, by name. + features map[string]ast.FeatureDirection + // aliases map each name a held feature redefines to the feature's own name. + aliases map[string]string + // optional holds the held features whose multiplicity admits no value at all. + optional map[string]bool + // result names the parameter a value read of the performance stands for, + // "" when the action it performs states no result parameter. + result string + // began is the activation the performance began in, which orders performances. + began int64 + // heldAt is the ID of the token parked at node whose step this performance ends + // before it began, a terminate having named the node (beginPending); 0 otherwise. + heldAt int64 + // run is the identity of this performance among the context's runs (Context.newRun). + run int64 + // callee is the action a `Callee(...)` node performs, resolved or settled by its + // arguments' values as they were bound; nil for a node performing no such call. + callee *symbols.Symbol + // performs is the flow of the action a typed or invoked node performed, whose + // subactions the node's performance adopted as its own. nil otherwise. + performs *lower.ActionGraph + // outputs names the output parameters of that action, which return to the + // same-named enclosing features once the performance ends. + outputs []string + // subactions holds the latest performance of each node of graph, which is + // what a read of the node's pins by name sees. + subactions map[ast.Node]*actionFrame + // pending queues what flows and bindings delivered to a node's pins ahead of + // its performances, each of which takes the oldest delivery at each pin. + pending map[ast.Node]map[string][]Value + // staged locates, per target node and pin, the queued value each streaming source + // performance's latest write left, which the source's next write replaces. + staged map[ast.Node]map[string][]stagedStream + // nested queues deliveries to pins of the nodes under a node (`leg.inner.v`) ahead + // of its next performance, which forwards them to its own nodes. + nested map[ast.Node][]nestedDelivery + // ended marks a performance that has completed, so a delivery to a node under it + // waits for the next performance rather than reaching one that is over. + ended bool + // streamed marks the pins whose writes this performance carried on along a + // streaming flow, so its completion does not carry the last of them again. + streamed map[string]bool + // unreceived holds, per target node, the values streaming flows carried after the + // node's latest performance ended; a later performance takes each, else it is reported. + unreceived map[ast.Node][]unreceivedStream + // nodes are the action nodes a state behavior's performance runs, which its body's + // blocks declare; the frames of a state machine and its states hold none. + nodes []ast.Node + // label names a performance that is no action node's in a diagnostic: a state + // machine's, a state's or a state behavior's (state_statements.go). + label string +} + +// within lists the nested action nodes whose performances enclose this one, itself +// included, outermost first; empty for the action's own and for no frame at all. +func (f *actionFrame) within() []ast.Node { + var chain []ast.Node + for ; f != nil; f = f.parent { + if f.node != nil && !f.body { + chain = append(chain, f.node) + } + } + slices.Reverse(chain) + return chain +} + +// unreceivedStream is a value a streaming flow carried to a target whose performances +// were all over: it waits at position at in the pending queue of the target's pin. +type unreceivedStream struct { + flow lower.ObjectFlow + source ast.Node + pin string + at int +} + +// stagedStream is where in a pending queue the latest value a streaming flow carried +// from a source performance's pin waits: the flow's next value from that pin replaces it. +type stagedStream struct { + source *actionFrame + pin string + flow ast.Node + at int +} + +// nestedDelivery is a value bound for a pin of a node under another: path leads to the +// node from the one the delivery waits at. +type nestedDelivery struct { + path []ast.Node + pin string + value Value +} + +// boundEnd is a binding with an end at a performance's pin, and the performance of the +// node the binding was written at: perf's own for `p.v`, an enclosing one for `leg.inner.v`. +type boundEnd struct { + lower.PinBinding + at *actionFrame +} + +// pinText renders the bound end as written: the node path and the pin. +func (b boundEnd) pinText() string { + text := ActionNodeName(b.Node) + for _, node := range b.Path { + text += "." + ActionNodeName(node) + } + return text + "." + b.Pin +} + +// newRootFrame is the performance of the action itself. +func (e *ActionExecutor) newRootFrame() *actionFrame { + root := &actionFrame{ + graph: e.graph, + scope: e.graph.Scope, + connections: e.graph.Connections, + data: make(map[string]Value), + features: make(map[string]ast.FeatureDirection), + subactions: make(map[ast.Node]*actionFrame), + run: e.ctx.newRun(), + } + e.declareRootFeatures(root) + return root +} + +// declareRootFeatures gives root the attributes the graph declares and the +// features the action holds, aliasing what each redefines. +func (e *ActionExecutor) declareRootFeatures(root *actionFrame) { + for _, attr := range e.graph.Attributes { + root.features[attr.Name] = attr.Direction + scope := attr.Scope + if scope == nil { + scope = e.graph.Scope + } + e.ctx.aliasRedefinitions(&root.aliases, memberSymbol(scope, attr.Node), attr.Name) + } + e.addFeatureDirections(root.features, &root.aliases, nil, e.action) +} + +// declareAcceptPayloads gives root the payloads the graph's accepts name: each is a +// feature of the flow its accept sits in, read by the nodes after it. +func (e *ActionExecutor) declareAcceptPayloads(root *actionFrame) { + for _, accept := range e.graph.Accepts { + if accept.ParamName != "" { + root.features[accept.ParamName] = ast.DirNone + } + } +} + +// addFeatureDirections adds the parameters and attributes an action holds, the +// inherited ones included, to features by name, aliasing what each redefines and +// marking in optional, when it is not nil, each new one admitting no value. +func (e *performances) addFeatureDirections( + features map[string]ast.FeatureDirection, aliases *map[string]string, optional map[string]bool, action *symbols.Symbol, +) { + if action == nil { + return + } + for _, member := range e.ctx.model.semantics.MembersOf(action) { + usage, ok := member.Decl.(*ast.Usage) + if !ok || !lower.DeclaresNodeFeature(usage) { + continue + } + name, _ := ast.EffectiveName(usage) + if name == "" { + name = member.Name + } + if name == "" { + continue + } + if _, held := features[name]; !held { + features[name] = usage.Direction + if optional != nil && e.ctx.admitsNoValue(member) { + optional[name] = true + } + } + e.ctx.aliasRedefinitions(aliases, member, name) + } +} + +// aliasRedefinitions maps every name sym redefines, by clause, to name: a body +// written against the redefined feature then reads the redefining one. +func (ctx *Context) aliasRedefinitions(aliases *map[string]string, sym *symbols.Symbol, name string) { + if sym == nil || name == "" { + return + } + seen := map[*symbols.Symbol]bool{sym: true} + var visit func(*symbols.Symbol) + visit = func(s *symbols.Symbol) { + for _, redefined := range ctx.model.semantics.RedefinedFeatures(s) { + if redefined == nil || seen[redefined] { + continue + } + seen[redefined] = true + *aliases = aliasRedefined(*aliases, redefined.Name, name) + visit(redefined) + } + } + visit(sym) +} + +// key is the name the performance holds name under: its redefinition's, else its own. +func (f *actionFrame) key(name string) string { + return canonical(f.aliases, name) +} + +// beginPerformance starts a performance of node (of parent's flow, or a block's with locals bound), +// seeding its pins from deliveries, then the arguments it passes its callee, then input bindings, +// then its own declared defaults. +func (e *performances) beginPerformance( + parent *actionFrame, flow *lower.ActionGraph, node ast.Node, locals []map[string]Value, +) (*actionFrame, error) { + perf := &actionFrame{ + node: node, + flow: flow, + scope: flow.Scopes[node], + parent: parent, + locals: locals, + connections: parent.connections, + data: make(map[string]Value), + features: make(map[string]ast.FeatureDirection), + run: e.ctx.newRun(), + } + if perf.scope == nil { + perf.scope = parent.scope + } + if sub, owns := e.subflowOf(flow, node); owns { + perf.graph = sub.Graph + perf.live = 1 + perf.connections = joinConnections(parent.connections, sub.Graph.Connections) + perf.subactions = make(map[ast.Node]*actionFrame) + } + pins, err := e.nodePins(flow, node) + if err != nil { + return nil, err + } + perf.features = pins.directions + perf.aliases = pins.aliases + perf.optional = pins.optional + perf.result = pins.result + // The performance is ongoing before anything seeding it streams, so a value carried + // back to its node reaches it rather than waiting for a later performance. + if parent.subactions == nil { + parent.subactions = make(map[ast.Node]*actionFrame) + } + previous, hadPrevious := parent.subactions[node] + parent.subactions[node] = perf + if err := e.seedPerformance(parent, flow, node, perf); err != nil { + if hadPrevious { + parent.subactions[node] = previous + } else { + delete(parent.subactions, node) + } + return nil, err + } + return perf, nil +} + +// seedPerformance seeds perf's pins from deliveries, then the arguments it passes its +// callee, then input bindings, then its own declared defaults. +func (e *performances) seedPerformance(parent *actionFrame, flow *lower.ActionGraph, node ast.Node, perf *actionFrame) error { + if err := e.takeDeliveries(parent, node, perf); err != nil { + return err + } + // The arguments, bindings and defaults are one evaluation: a calc usage two of them + // read answers once, and another performance evaluates it anew. + activation, endStep := e.ctx.beginStep() + defer endStep() + perf.began = activation + if err := e.bindArguments(perf, activation); err != nil { + return err + } + if err := e.bindInputPins(perf, activation); err != nil { + return err + } + return e.seedDeclaredValues(perf, flow.Features[node], activation) +} + +// bindArguments writes the arguments a node passes its callee (`F(a = 3)`) to its pins, +// evaluated in the caller's context: they stand over deliveries, bindings and defaults. +func (e *performances) bindArguments(perf *actionFrame, activation int64) error { + usage, ok := perf.node.(*ast.Usage) + if !ok { + return nil + } + inv, performs := nestedInvocation(usage) + if !performs || inv.expr == nil || lower.IsCaseNode(usage) { + return nil + } + inv.step, _ = stepSymbol(perf.flow, perf.node) + scope := nodeScope(perf.flow, perf.node) + ec := e.evalContextAround(perf, scope) + ec.activation = activation + arguments, callee, err := invocationArguments(e.ctx, scope, inv, ec) + if err != nil { + return err + } + perf.callee = callee + // Settled, the node holds the pins of the action performed alone, its result among them. + pins, err := e.pinsOf(perf.flow, perf.node, inv, callee, []*symbols.Symbol{callee}) + if err != nil { + return err + } + perf.features, perf.aliases, perf.optional, perf.result = pins.directions, pins.aliases, pins.optional, pins.result + for name, value := range arguments { + if err := e.setFrameFeature(perf, name, value); err != nil { + return err + } + } + return nil +} + +// seedDeclaredValues evaluates the values a node's own declarations give its +// features (`in a = 3;`), where no delivery, argument or binding at the pin holds one yet. +func (e *performances) seedDeclaredValues(perf *actionFrame, features []lower.Feature, activation int64) error { + for _, feature := range features { + if feature.Value == nil { + continue + } + if _, held := perf.data[perf.key(feature.Name)]; held { + continue + } + ec := e.evalContextFor(perf, feature.Scope) + ec.activation = activation + value, err := ec.Eval(feature.Value) + if err != nil { + return fmt.Errorf("eval %s of %s: %w", feature.Name, nodeDescription(perf.node), err) + } + if err := e.ctx.checkBodyDeclaration(feature.Scope, perf.describe(), feature.Name, &value); err != nil { + return err + } + perf.data[perf.key(feature.Name)] = value + if err := e.streamFrom(perf, perf.key(feature.Name), value); err != nil { + return err + } + } + return nil +} + +// endPerformance completes a performance: the outputs of the action it performed +// return to same-named enclosing features, and the bindings at its output pins +// carry what it produced to their other ends. +func (e *performances) endPerformance(perf *actionFrame) error { + perf.ended = true + if err := checkStreamsReceived(perf); err != nil { + return err + } + for _, name := range perf.outputs { + value, ok := perf.data[perf.key(name)] + if !ok { + continue + } + if _, err := e.assignEnclosingBy(perf, name, value, e.owner.returnAround); err != nil { + return err + } + } + return e.bindOutputPins(perf) +} + +// checkStreamsReceived reports a value a streaming flow of frame's nodes carried after +// its target's last performance ended, which no later performance of the target took. +func checkStreamsReceived(frame *actionFrame) error { + nodes := sortedNodes(frame.unreceived) + if len(nodes) == 0 { + return nil + } + stream := frame.unreceived[nodes[0]][0] + return fmt.Errorf("%w: %s: %s completed before %s wrote %s", + ErrStreamUnreceived, flowDescription(stream.flow), nodeDescription(nodes[0]), + nodeDescription(stream.source), orAnyPin(stream.flow.SourcePin)) +} + +// nodePins are the pins a performance of node holds (its own and its action's), +// and the pin read when the node is read as a value: `return`, else `out result`. +type nodePins struct { + directions map[string]ast.FeatureDirection + aliases map[string]string + // optional holds the pins whose multiplicity admits no value at all. + optional map[string]bool + result string +} + +// declares reports whether the pins hold name, under its own name or as a redefinition. +func (p nodePins) declares(name string) bool { + _, ok := p.directions[canonical(p.aliases, name)] + return ok +} + +// admitsNoValueAt reports whether the performance's pin admits no value at all: a +// flow out of it then carries nothing when the performance wrote it none. +func (perf *actionFrame) admitsNoValueAt(pin string) bool { + return perf != nil && perf.optional[canonical(perf.aliases, pin)] +} + +// nodePins returns the pins a performance of node holds. A call its arguments' values +// settle holds the pins of every action still tied, its result pin only once settled. +func (e *performances) nodePins(graph *lower.ActionGraph, node ast.Node) (nodePins, error) { + usage, ok := node.(*ast.Usage) + if !ok { + return nodePins{directions: make(map[string]ast.FeatureDirection)}, nil + } + inv, performs := nestedInvocation(usage) + if !performs || lower.IsCaseNode(usage) { + return e.pinsOf(graph, node, inv, nil, nil) + } + sym, tied, err := actionCandidates(e.ctx, nodeScope(graph, node), inv) + if err != nil { + return nodePins{}, err + } + if sym != nil { + tied = []*symbols.Symbol{sym} + } + return e.pinsOf(graph, node, inv, sym, tied) +} + +// pinsOf returns the pins node holds with those of the callees, read as a value by a +// `return` of its own or of the settled callee, else by an `out result` among them. +func (e *performances) pinsOf( + graph *lower.ActionGraph, + node ast.Node, + inv actionInvocation, + settled *symbols.Symbol, + callees []*symbols.Symbol, +) (nodePins, error) { + pins := nodePins{directions: make(map[string]ast.FeatureDirection), optional: make(map[string]bool)} + for _, feature := range graph.Features[node] { + pins.directions[feature.Name] = feature.Direction + sym := memberSymbol(feature.Scope, feature.Node) + if sym != nil && e.ctx.admitsNoValue(sym) { + pins.optional[feature.Name] = true + } + e.ctx.aliasRedefinitions(&pins.aliases, sym, feature.Name) + if feature.IsResult { + pins.result = feature.Name + } + } + if len(callees) > 0 { + inv.step, _ = stepSymbol(graph, node) + } + for _, callee := range callees { + held, _, err := e.ctx.performanceBody(inv.performed(callee), callee) + if err != nil { + return nodePins{}, err + } + e.addFeatureDirections(pins.directions, &pins.aliases, pins.optional, held) + if callee != settled { + continue + } + for _, param := range e.ctx.actionParametersOf(callee) { + if param.IsResult && pins.result == "" { + pins.result = param.Name + } + } + } + if pins.result == "" { + if dir, ok := pins.directions["result"]; ok && (dir == ast.DirOut || dir == ast.DirInOut) { + pins.result = "result" + } + } + return pins, nil +} + +// nodeScope is the namespace a node's declaration resolves in: its own where the +// flow retains it (an inherited node keeps its declaring action's), else the flow's. +func nodeScope(graph *lower.ActionGraph, node ast.Node) *symbols.Scope { + if scope := graph.Scopes[node]; scope != nil { + return scope + } + return graph.Scope +} + +// describe names the performance for a diagnostic. +func (f *actionFrame) describe() string { + switch { + case f.label != "": + return f.label + case f.node == nil: + return "action" + } + return "action node " + ActionNodeName(f.node) +} + +// path is the dotted path naming the performance from the action's own, "" for it. +func (f *actionFrame) path() string { + if f.node == nil || f.parent == nil { + return "" + } + name := ActionNodeName(f.node) + if prefix := f.parent.path(); prefix != "" { + return prefix + "." + name + } + return name +} + +// declares reports whether the performance holds a feature of this name. +func (f *actionFrame) declares(name string) bool { + _, ok := f.features[f.key(name)] + return ok +} + +// holds reports whether the performance holds name: a feature it declares, or a +// value its body stored. +func (f *actionFrame) holds(name string) bool { + if f.declares(name) { + return true + } + _, ok := f.data[f.key(name)] + return ok +} + +// lexicalFrames returns the frames a body of f reads, outermost first: each enclosing +// performance followed by the block-locals around its node, and last f's own. +func (f *actionFrame) lexicalFrames() []frame { + var frames []frame + if f.parent != nil { + frames = f.parent.lexicalFrames() + } else { + frames = slices.Clone(f.outer) + } + for _, local := range f.locals { + frames = append(frames, mapFrame(local)) + } + return append(frames, performanceFrame(f)) +} + +// nodesNamed returns the nodes named name among the performance's subactions: its +// flow's nodes, those its bodies' blocks declare, and those of the action it performed. +func (f *actionFrame) nodesNamed(name string) []ast.Node { + var named []ast.Node + switch { + case f.graph != nil: + named = flowNodesNamed(f.graph, name) + case f.flow != nil: + named = usagesNamed(f.flow.BlockNodes[f.node], name) + default: + named = usagesNamed(f.nodes, name) + } + if f.performs != nil { + named = append(named, flowNodesNamed(f.performs, name)...) + } + return named +} + +// flowNodesNamed returns the nodes of graph named name: its own, then those the +// blocks of its statement nodes declare. +func flowNodesNamed(graph *lower.ActionGraph, name string) []ast.Node { + named := usagesNamed(graph.Nodes, name) + for _, node := range graph.Nodes { + if _, isUsage := node.(*ast.Usage); isUsage { + continue + } + named = append(named, usagesNamed(graph.BlockNodes[node], name)...) + } + return named +} + +// usagesNamed returns the usages among nodes named name. +func usagesNamed(nodes []ast.Node, name string) []ast.Node { + var named []ast.Node + for _, node := range nodes { + if _, isUsage := node.(*ast.Usage); isUsage && slices.Contains(ActionNodeNames(node), name) { + named = append(named, node) + } + } + return named +} + +// subaction returns the latest performance of f's node named name; declared reports +// whether f has such a node, one not yet performed is ErrNodeNotPerformed. Among +// same-named nodes (one per branch of a conditional), decl names the reader's own, +// else the one performed latest answers. +func (f *actionFrame) subaction(name string, decl ast.Node) (perf *actionFrame, declared bool, err error) { + named := f.nodesNamed(name) + if len(named) == 0 { + return nil, false, nil + } + node := named[0] + if slices.Contains(named, decl) { + node = decl + } else { + for _, candidate := range named { + performed, ok := f.subactions[candidate] + if ok && (f.subactions[node] == nil || f.subactions[node].began < performed.began) { + node = candidate + } + } + } + perf, performed := f.subactions[node] + if !performed { + return nil, true, fmt.Errorf("%w: action node %s has not been performed yet", + ErrNodeNotPerformed, ActionNodeName(node)) + } + return perf, true, nil +} + +// pin reads the value the performance's pin holds; a pin admitting no value that +// holds none reads as the empty sequence, as its declaration does. +func (f *actionFrame) pin(name string) (Value, error) { + value, ok := f.data[f.key(name)] + if ok { + return value, nil + } + if f.declares(name) { + if f.admitsNoValueAt(name) { + return sequenceOf(nil), nil + } + return Value{}, &NoValueError{Feature: f.path() + "." + name} + } + return Value{}, fmt.Errorf("%w: %s declares no %s", ErrNodePin, f.describe(), name) +} + +// resultValue is what the performance stands for when read as a value: the +// result parameter of the action it performs. +func (f *actionFrame) resultValue() (Value, error) { + if f.result == "" { + return Value{}, fmt.Errorf("%w: %s declares no result to read it as a value by", + ErrNodePin, f.describe()) + } + return f.pin(f.result) +} + +// deliver stores a value at a pin of node, a node of flow in f, ahead of its next +// performance, which is how a flow or binding reaches a node not yet running. A path +// leads on to a node under it: the value goes into node's running performance, else +// waits for its next one to forward it. +func (e *performances) deliver(f *actionFrame, flow *lower.ActionGraph, node ast.Node, path []ast.Node, pin string, value Value) error { + if len(path) > 0 { + if err := e.checkNestedDelivery(flow, node, path, pin, &value); err != nil { + return err + } + if sub, performed := f.subactions[node]; performed && !sub.ended { + return e.deliver(sub, lower.NestedFlow(flow, node, path[0]), path[0], path[1:], pin, value) + } + if f.nested == nil { + f.nested = make(map[ast.Node][]nestedDelivery) + } + f.nested[node] = append(f.nested[node], nestedDelivery{path: path, pin: pin, value: value}) + return nil + } + pins, err := e.nodePins(flow, node) + if err != nil { + return err + } + if !pins.declares(pin) { + return fmt.Errorf("%w: %s declares no %s", ErrNodePin, nodeDescription(node), pin) + } + if err := e.ctx.checkNamedWrite(flow.Scopes[node], nodeDescription(node), pin, &value); err != nil { + return err + } + f.queue(node, canonical(pins.aliases, pin), value) + return nil +} + +// queue appends a value to the pending queue of node's pin, returning its position. +func (f *actionFrame) queue(node ast.Node, pin string, value Value) int { + if f.pending == nil { + f.pending = make(map[ast.Node]map[string][]Value) + } + if f.pending[node] == nil { + f.pending[node] = make(map[string][]Value) + } + f.pending[node][pin] = append(f.pending[node][pin], value) + return len(f.pending[node][pin]) - 1 +} + +// stage queues a value flow streamed from source's from pin ahead of node's next +// performance, or replaces the one an earlier write to from left waiting there through +// the same flow; two flows out of one pin each keep a place. Reports whether it appended. +func (f *actionFrame) stage(node ast.Node, pin string, source *actionFrame, from string, flow ast.Node, value Value) bool { + queue := f.pending[node][pin] + for _, s := range f.staged[node][pin] { + if source != nil && s.source == source && s.pin == from && s.flow == flow && s.at < len(queue) { + queue[s.at] = value + return false + } + } + at := f.queue(node, pin, value) + if f.staged == nil { + f.staged = make(map[ast.Node]map[string][]stagedStream) + } + if f.staged[node] == nil { + f.staged[node] = make(map[string][]stagedStream) + } + f.staged[node][pin] = append(f.staged[node][pin], stagedStream{source: source, pin: from, flow: flow, at: at}) + return true +} + +// checkNestedDelivery checks that path leads from node through the flows under it to a +// node declaring pin, so a delivery waiting for a performance is known to have somewhere to go. +func (e *performances) checkNestedDelivery(flow *lower.ActionGraph, node ast.Node, path []ast.Node, pin string, value *Value) error { + for _, next := range path { + flow = lower.NestedFlow(flow, node, next) + if flow == nil { + return fmt.Errorf("%w: %s holds no nested action %s", ErrNodePin, nodeDescription(node), ActionNodeName(next)) + } + node = next + } + pins, err := e.nodePins(flow, node) + if err != nil { + return err + } + if !pins.declares(pin) { + return fmt.Errorf("%w: %s declares no %s", ErrNodePin, nodeDescription(node), pin) + } + return e.ctx.checkNamedWrite(flow.Scopes[node], nodeDescription(node), pin, value) +} + +// takeDeliveries moves the oldest delivery at each pin of node into perf, so that +// performances of one node begun in turn each start with their own inputs, streams +// each on along the pin's flows, and forwards what waits for the nodes under it. +func (e *performances) takeDeliveries(f *actionFrame, node ast.Node, perf *actionFrame) error { + queues := f.pending[node] + taken := make(map[string]Value, len(queues)) + for pin, values := range queues { + f.receiveStream(node, pin) + f.shiftStaged(node, pin) + perf.data[pin] = values[0] + taken[pin] = values[0] + if len(values) == 1 { + delete(queues, pin) + } else { + queues[pin] = values[1:] + } + } + if len(queues) == 0 { + delete(f.pending, node) + } + for _, pin := range slices.Sorted(maps.Keys(taken)) { + if err := e.streamFrom(perf, pin, taken[pin]); err != nil { + return err + } + } + nested := f.nested[node] + delete(f.nested, node) + for _, d := range nested { + if err := e.deliver(perf, lower.NestedFlow(perf.flow, node, d.path[0]), d.path[0], d.path[1:], d.pin, d.value); err != nil { + return err + } + } + return nil +} + +// shiftStaged moves what is staged at node's pin one place toward the front of the +// pin's queue, as a performance takes the oldest delivery, dropping what it took. +func (f *actionFrame) shiftStaged(node ast.Node, pin string) { + var kept []stagedStream + for _, s := range f.staged[node][pin] { + if s.at--; s.at >= 0 { + kept = append(kept, s) + } + } + if kept == nil { + delete(f.staged[node], pin) + return + } + f.staged[node][pin] = kept +} + +// receiveStream notes that a performance of node took the oldest value at pin: the +// streamed values behind it move up, and the one taken is received. +func (f *actionFrame) receiveStream(node ast.Node, pin string) { + var kept []unreceivedStream + for _, s := range f.unreceived[node] { + if s.pin == pin { + s.at-- + } + if s.at >= 0 { + kept = append(kept, s) + } + } + if kept == nil { + delete(f.unreceived, node) + return + } + f.unreceived[node] = kept +} + +// setFrameFeature writes a feature the performance holds, through the action's +// performance occurrence for the action's own features. +func (e *performances) setFrameFeature(f *actionFrame, name string, value Value) error { + if f == e.root { + if err := e.owner.setFeature(name, value); err != nil { + return err + } + e.noteFrameWrite(f, name, value) + return nil + } + if err := e.ctx.checkNamedWrite(f.scope, f.describe(), name, &value); err != nil { + return err + } + f.data[f.key(name)] = value + e.noteFrameWrite(f, name, value) + return e.streamFrom(f, f.key(name), value) +} + +// streamFrom carries a value written to pin of f on along the streaming flows out of +// f's node, to the performances of their targets under way. +func (e *performances) streamFrom(f *actionFrame, pin string, value Value) error { + if f.parent == nil || f.flow == nil { + return nil + } + key := streamKey{frame: f, pin: pin} + if e.ctx.streaming[key] { + return fmt.Errorf("%w: a value written to %s of %s is carried back to it", ErrStreamCycle, pin, f.describe()) + } + if e.ctx.streaming == nil { + e.ctx.streaming = make(map[streamKey]bool) + } + e.ctx.streaming[key] = true + defer delete(e.ctx.streaming, key) + for _, flow := range f.flow.DataFlows[f.node] { + if flow.Kind != lower.FlowStreaming || f.key(flow.SourcePin) != pin { + continue + } + if f.streamed == nil { + f.streamed = make(map[string]bool) + } + f.streamed[flow.SourcePin] = true + if err := e.streamFlow(f.parent, f.flow, f.node, f, flow, value); err != nil { + return err + } + } + return nil +} + +// streamKey is a pin of a performance whose write is being carried on along its +// streaming flows; a second write to it before the first is through is a cycle. +type streamKey struct { + frame *actionFrame + pin string +} + +// streamFlow delivers one value a streaming flow carries from the source performance: +// to the pin of every ongoing performance of its target in frame's flow, else ahead of +// the target's next performance, where a later write to the same source pin replaces it. +func (e *performances) streamFlow( + frame *actionFrame, graph *lower.ActionGraph, source ast.Node, perf *actionFrame, flow lower.ObjectFlow, value Value, +) error { + if _, performs := flow.Target.(*ast.Usage); !performs { + return e.deliverFlow(frame, graph, flow, value) + } + ongoing := e.flow.ongoing(frame, flow.Target) + if len(ongoing) == 0 { + pins, err := e.nodePins(graph, flow.Target) + if err != nil { + return fmt.Errorf("%s: %w", flowDescription(flow), err) + } + if !pins.declares(flow.TargetPin) { + return fmt.Errorf("%s: %w: %s declares no %s", flowDescription(flow), ErrNodePin, nodeDescription(flow.Target), flow.TargetPin) + } + if err := e.ctx.checkNamedWrite(graph.Scopes[flow.Target], nodeDescription(flow.Target), flow.TargetPin, &value); err != nil { + return fmt.Errorf("%s: %w", flowDescription(flow), err) + } + pin := canonical(pins.aliases, flow.TargetPin) + from := flow.SourcePin + if perf != nil { + from = perf.key(from) + } + appended := frame.stage(flow.Target, pin, perf, from, flow.Decl, value) + if latest := frame.subactions[flow.Target]; appended && latest != nil && latest.ended { + if frame.unreceived == nil { + frame.unreceived = make(map[ast.Node][]unreceivedStream) + } + frame.unreceived[flow.Target] = append(frame.unreceived[flow.Target], unreceivedStream{ + flow: flow, source: source, pin: pin, at: len(frame.pending[flow.Target][pin]) - 1, + }) + } + return nil + } + for _, target := range ongoing { + if !target.declares(flow.TargetPin) { + return fmt.Errorf("%s: %w: %s declares no %s", flowDescription(flow), ErrNodePin, target.describe(), flow.TargetPin) + } + if err := e.setFrameFeature(target, flow.TargetPin, value); err != nil { + return fmt.Errorf("%s: %w", flowDescription(flow), err) + } + } + return nil +} + +// assignEnclosing writes name to the innermost block-local or performance feature +// around perf that holds it, else to what is around the root, reporting whether one did. +func (e *performances) assignEnclosing(perf *actionFrame, name string, value Value) (bool, error) { + return e.assignEnclosingBy(perf, name, value, e.owner.assignAround) +} + +// assignEnclosingBy is assignEnclosing writing past the root through around. +func (e *performances) assignEnclosingBy( + perf *actionFrame, name string, value Value, around func(string, Value) (bool, error), +) (bool, error) { + local, holder, ok := enclosingHolder(perf, name) + switch { + case !ok: + return around(name, value) + case local != nil: + local[name] = value + return true, nil + default: + return true, e.setFrameFeature(holder, name, value) + } +} + +// enclosingHolder finds the innermost block-local or performance around perf that +// holds name: the block's locals, else the performance. +func enclosingHolder(perf *actionFrame, name string) (local map[string]Value, holder *actionFrame, ok bool) { + for f := perf; f != nil; f = f.parent { + for i := len(f.locals) - 1; i >= 0; i-- { + if _, ok := f.locals[i][name]; ok { + return f.locals[i], nil, true + } + } + if f.parent != nil && f.parent.holds(name) { + return nil, f.parent, true + } + } + return nil, nil, false +} + +// lookupEnclosing reads name from the innermost binding around perf that holds +// a value for it, the frames around the root included. +func lookupEnclosing(perf *actionFrame, name string) (Value, bool) { + for f := perf; f != nil; f = f.parent { + for i := len(f.locals) - 1; i >= 0; i-- { + if value, ok := f.locals[i][name]; ok { + return value, true + } + } + if f.parent != nil { + if value, ok := f.parent.data[f.parent.key(name)]; ok { + return value, true + } + continue + } + for i := len(f.outer) - 1; i >= 0; i-- { + if value, ok := f.outer[i].lookup(name); ok { + return value, true + } + } + } + return Value{}, false +} + +// evalContextFor returns a context evaluating in scope with the performance and +// every frame around it in reach, innermost last. +func (e *performances) evalContextFor(perf *actionFrame, scope *symbols.Scope) *EvalContext { + ec := NewEvalContextIn(e.ctx, scope, e.self) + for _, f := range perf.lexicalFrames() { + ec.pushFrame(f) + } + return ec +} + +// evalContextAround returns a context evaluating in scope what is written at perf's +// node: the enclosing performances and the block-locals around the node, not perf's own. +// What is written there is a statement of the body, reaching the performer's features +// only by names resolving to them. +func (e *performances) evalContextAround(perf *actionFrame, scope *symbols.Scope) *EvalContext { + ec := NewEvalContextIn(e.ctx, scope, e.self) + ec.inBehaviorBody = true + if perf.parent != nil { + for _, f := range perf.parent.lexicalFrames() { + ec.pushFrame(f) + } + } + for _, local := range perf.locals { + ec.pushFrame(mapFrame(local)) + } + return ec +} + +// bindingEndContext evaluates the other end of a binding at a pin as written around +// the node, with that pin known to be the one being valued. +func (e *performances) bindingEndContext(end boundEnd) *EvalContext { + ec := e.evalContextAround(end.at, end.Scope) + ec.valuing = pinSymbol(end) + return ec +} + +// pinSymbol is the declaration of the pin an end is at: the pin valued by its own +// value, or the member of the node's body the end names; nil when none is found. +func pinSymbol(end boundEnd) *symbols.Symbol { + holder := end.Scope + if holder == nil { + return nil + } + if end.FromValue { + if sym := holder.MemberDeclaring(end.Decl); sym != nil { + return sym + } + } + if holder.Node() != end.Node { + holder = holder.ChildFor(end.Node) + } + for _, node := range end.Path { + if holder == nil { + return nil + } + holder = holder.ChildFor(node) + } + if holder == nil { + return nil + } + if end.FromValue { + return holder.MemberDeclaring(end.Decl) + } + sym, _ := holder.LookupLocal(end.Pin) + return sym +} + +// lexicalValues merges the values a performance and the frames around it hold, +// the innermost winning, for a caller reading them as one map. +func lexicalValues(perf *actionFrame) map[string]Value { + merged := make(map[string]Value) + for _, f := range perf.lexicalFrames() { + for name, value := range f.vars { + merged[name] = value + } + } + return merged +} + +// collect reports the values the performance and its subactions hold, a node's +// under its path (`p.v`), the latest performance of each name standing for it. +func (f *actionFrame) collect(prefix string, into map[string]Value) { + for name, value := range f.data { + into[prefix+name] = value + } + for name, sub := range f.latestSubactions() { + sub.collect(prefix+name+".", into) + } +} + +// latestSubactions is the latest performance of each named node under f, by name. +func (f *actionFrame) latestSubactions() map[string]*actionFrame { + latest := make(map[string]*actionFrame) + for node, sub := range f.subactions { + name := ActionNodeName(node) + if name == "" { + continue + } + if earlier, named := latest[name]; !named || earlier.began < sub.began { + latest[name] = sub + } + } + return latest +} + +// ownFeatures is every feature the performance itself holds, valued or not, +// under the name collect gives its value. +func (f *actionFrame) ownFeatures() map[string]bool { + own := make(map[string]bool, len(f.features)+len(f.data)) + for name := range f.features { + own[f.key(name)] = true + } + for name := range f.data { + own[name] = true + } + return own +} + +// owns reports whether name, under any name it is held by, is a feature the performance itself holds. +func (f *actionFrame) owns(name string) bool { + return f.ownFeatures()[f.key(name)] +} + +// heldFeatures marks, under prefix, every feature the performance and the +// latest performances under it hold, as collect names their values. +func (f *actionFrame) heldFeatures(prefix string, into map[string]bool) { + for name := range f.ownFeatures() { + into[prefix+name] = true + } + for name, sub := range f.latestSubactions() { + sub.heldFeatures(prefix+name+".", into) + } +} + +// bindInputPins seeds the pins a performance reads from the bindings at them, where nothing +// delivered ahead of it holds a value; bindings must agree, and an unvalued undirected end waits. +func (e *performances) bindInputPins(perf *actionFrame, activation int64) error { + bound := make(map[string]boundEnd) + for _, end := range e.bindingsAt(perf) { + dir, err := e.boundPin(perf, end) + if err != nil { + return err + } + if dir == ast.DirOut { + continue + } + earlier, alreadyBound := bound[end.Pin] + if _, held := perf.data[perf.key(end.Pin)]; held && !alreadyBound { + continue + } + if holder, node, _ := otherEnd(perf, end); node != nil && holder == perf { + continue // a node of perf's own flow reads this pin once it runs + } + value, err := e.bindingOtherValue(perf, end, activation) + if err != nil { + if dir == ast.DirNone && e.unheldEnd(end, err) { + continue + } + return err + } + if alreadyBound { + if held := perf.data[perf.key(end.Pin)]; !e.ctx.equalValues(held, value) { + return &BindingConflictError{ + Target: end.pinText(), + Left: bindingEndText(earlier.Other), + Right: bindingEndText(end.Other), + LeftValue: held, + RightValue: value, + } + } + continue + } + if err := e.setFrameFeature(perf, end.Pin, value); err != nil { + return err + } + bound[end.Pin] = end + } + return nil +} + +// bindOutputPins carries what a performance's output pins hold to the other ends of the +// bindings at them, and what an undirected pin holds where its other end differs from it. +func (e *performances) bindOutputPins(perf *actionFrame) error { + for _, end := range e.bindingsAt(perf) { + dir, err := e.boundPin(perf, end) + if err != nil { + return err + } + value, ok := perf.data[perf.key(end.Pin)] + switch dir { + case ast.DirOut, ast.DirInOut: + if !ok { + if !perf.admitsNoValueAt(end.Pin) { + return fmt.Errorf("%w: %s produced no value at %s to bind %s to", + ErrBindingEnd, perf.describe(), end.Pin, bindingEndText(end.Other)) + } + value = sequenceOf(nil) // the binding holds the other end to the same absence + } + case ast.DirNone: + if !ok { + continue + } + if other, held := e.otherEndHeld(perf, end); held && e.ctx.equalValues(other, value) { + continue + } + default: + continue + } + if end.OtherNode != nil { + if holder, node, _ := otherEnd(perf, end); node != nil && holder == perf { + continue // a node of perf's own flow carried its value here as it ended + } + if err := e.writeOtherEnd(perf, end, value); err != nil { + return err + } + continue + } + if end.OtherChain != nil { + ec := e.bindingEndContext(end) + if err := writeThroughChain(ec, end.OtherChain, end.OtherFeature, value); err != nil { + return fmt.Errorf("%w: %s is bound to %s: %w", + ErrBindingEnd, end.pinText(), bindingEndText(end.Other), err) + } + continue + } + name := simpleEndName(end.Other) + if name == "" { + if end.FromValue { + continue // a qualified value (`Mode::idle`) initialized the pin, and holds nothing + } + return fmt.Errorf("%w: %s is bound to %s, which names no feature to hold its value", + ErrBindingEnd, end.pinText(), bindingEndText(end.Other)) + } + written, err := e.assignEnclosing(end.at, name, value) + if err != nil { + return err + } + if !written && !end.FromValue { + return fmt.Errorf("%w: %s is bound to %s, which no enclosing action holds", + ErrBindingEnd, end.pinText(), name) + } + } + return nil +} + +// bindingsAt returns the bindings with an end at perf's pins: those of the flow its node +// is a node of written at it (`p.v`), and those an enclosing flow wrote reaching down to +// it through the performances between (`leg.inner.v`). +func (e *performances) bindingsAt(perf *actionFrame) []boundEnd { + var at []boundEnd + var path []ast.Node + for anc := perf; anc.parent != nil; anc = anc.parent { + for _, binding := range anc.flow.Bindings { + if binding.Node == anc.node && slices.Equal(binding.Path, path) { + at = append(at, boundEnd{PinBinding: binding, at: anc}) + } + } + path = append([]ast.Node{anc.node}, path...) + } + return at +} + +// boundPin returns the direction of the pin a binding ends at, which must be a +// feature the performance holds. +func (e *performances) boundPin(perf *actionFrame, end boundEnd) (ast.FeatureDirection, error) { + dir, declared := perf.features[perf.key(end.Pin)] + if !declared { + return ast.DirNone, fmt.Errorf("%w: %s names no parameter or attribute of %s", + ErrBindingEnd, end.pinText(), perf.describe()) + } + return dir, nil +} + +// otherEnd locates the other end of a binding at perf's pin: the performance it is in reach +// of, and the node under that one it names with the path on to it, none when it is that +// performance's own pin. The end runs down through the performances between end.at and +// perf as far as it names their nodes, so it keeps to perf's own run of each, not the latest. +func otherEnd(perf *actionFrame, end boundEnd) (holder *actionFrame, node ast.Node, path []ast.Node) { + if end.OtherNode != end.at.node { + return end.at.parent, end.OtherNode, end.OtherPath + } + holder, path = end.at, end.OtherPath + for len(path) > 0 { + next := childToward(holder, perf) + if next == nil || next.node != path[0] { + break + } + holder, path = next, path[1:] + } + if len(path) == 0 { + return holder, nil, nil + } + return holder, path[0], path[1:] +} + +// childToward returns the performance under holder on the way to perf, nil at perf itself. +func childToward(holder, perf *actionFrame) *actionFrame { + for f := perf; f != nil && f.parent != nil; f = f.parent { + if f.parent == holder { + return f + } + } + return nil +} + +// otherPerformance returns the performance holding the other end of a binding at perf's +// pin: one of those around perf, or the latest of the node it names under one; performed +// is false where that node, or one on the way to it, has not run. +func (e *performances) otherPerformance(perf *actionFrame, end boundEnd) (other *actionFrame, performed bool) { + holder, node, path := otherEnd(perf, end) + if node == nil { + return holder, true + } + other, performed = holder.subactions[node] + for _, node := range path { + if !performed { + return nil, false + } + other, performed = other.subactions[node] + } + return other, performed +} + +// writeOtherEnd carries value to the node pin at the other end of a binding at perf's pin: +// into the performance around perf holding it, else delivered ahead of the node it names. +func (e *performances) writeOtherEnd(perf *actionFrame, end boundEnd, value Value) error { + holder, node, path := otherEnd(perf, end) + if node == nil { + if !holder.declares(end.OtherPin) { + return fmt.Errorf("%w: %s declares no %s", ErrNodePin, holder.describe(), end.OtherPin) + } + return e.setFrameFeature(holder, end.OtherPin, value) + } + flow := end.at.flow + if holder != end.at.parent { + flow = lower.NestedFlow(holder.flow, holder.node, node) + if flow == nil { + return fmt.Errorf("%w: %s holds no nested action %s", ErrNodePin, holder.describe(), ActionNodeName(node)) + } + } + return e.deliver(holder, flow, node, path, end.OtherPin, value) +} + +// bindingOtherValue reads the value the other end of a binding at a performance's pin +// holds: another node's pin, or an expression over the enclosing performances. +func (e *performances) bindingOtherValue(perf *actionFrame, end boundEnd, activation int64) (Value, error) { + if end.OtherNode != nil { + other, performed := e.otherPerformance(perf, end) + if !performed { + return Value{}, fmt.Errorf("%w: %s is bound to %s, which is read before it runs", + ErrNodeNotPerformed, end.pinText(), bindingEndText(end.Other)) + } + value, err := other.pin(end.OtherPin) + if err != nil { + return Value{}, fmt.Errorf("%w: bound to %s", err, end.pinText()) + } + return value, nil + } + ec := e.bindingEndContext(end) + ec.activation = activation + value, err := ec.Eval(end.Other) + if err != nil { + return Value{}, fmt.Errorf("%w: %s is bound to %s: %w", + ErrBindingEnd, end.pinText(), bindingEndText(end.Other), err) + } + return value, nil +} + +// otherEndHeld reads what the other end of a binding holds now: a performed node's +// pin, an enclosing feature named outright, or the feature a chain reaches. +func (e *performances) otherEndHeld(perf *actionFrame, end boundEnd) (Value, bool) { + if end.OtherNode != nil { + other, performed := e.otherPerformance(perf, end) + if !performed { + return Value{}, false + } + value, held := other.data[other.key(end.OtherPin)] + return value, held + } + if name := simpleEndName(end.Other); name != "" { + return e.bindingEndContext(end).Lookup(name) + } + if end.OtherChain != nil { + value, err := e.bindingEndContext(end).Eval(end.Other) + return value, err == nil + } + return Value{}, false +} + +// unheldEnd reports whether err, from reading the other end of a binding, says that end +// holds no value yet: an unperformed node's pin or an unvalued enclosing feature. +func (e *performances) unheldEnd(end boundEnd, err error) bool { + var noValue *NoValueError + if errors.Is(err, ErrNodeNotPerformed) || errors.As(err, &noValue) { + return true + } + name := simpleEndName(end.Other) + if end.OtherNode != nil || name == "" { + return false + } + _, valued := e.bindingEndContext(end).Lookup(name) + _, _, holds := enclosingHolder(end.at, name) + return !valued && holds +} + +// simpleEndName returns the name a binding end written as one name states, "" +// for any other expression. +func simpleEndName(end ast.Node) string { + switch n := end.(type) { + case *ast.FeatureReference: + return simpleEndName(n.Name) + case *ast.QualifiedName: + if len(n.Parts) == 1 { + return n.Parts[0].Text + } + } + return "" +} + +// bindingEndText renders a binding end for a diagnostic. +func bindingEndText(end ast.Node) string { + switch n := end.(type) { + case *ast.FeatureReference: + return bindingEndText(n.Name) + case *ast.QualifiedName: + return n.Text() + case *ast.FeatureChainExpr: + return bindingEndText(n.Operand) + "." + ast.SimpleName(n.Member) + } + return fmt.Sprintf("%T", end) +} + +// performInvocation performs the action a node names as a subperformance of perf: the node's +// pins (its arguments among them) bind the callee's inputs, its final values become the node's, +// and its outputs return to enclosing features when the node's own performance ends. +func (e *performances) performInvocation(perf *actionFrame, inv actionInvocation) error { + callee, resumed, err := popFrame[*calleeFrame](e.ctx) + if err != nil { + return err + } + if !resumed { + if callee, err = e.beginInvocation(perf, inv); err != nil { + return err + } + } + if resumed { + callee.exec.listen(perf, e.streamCalleeOutput(perf, callee.out)) + } + if _, _, err := e.ctx.runCallee(callee); err != nil { + if callee.joined && !paused(err) { + callee.exec.unlisten(perf) + } + return err + } + if callee.joined { + // A joined performance outlives the node, which is done listening to it. + callee.exec.unlisten(perf) + } + perf.adopt(callee.exec) + perf.outputs = callee.out + return nil +} + +// beginInvocation starts the action a node names as a subperformance of perf, +// its inputs bound from the node's pins, and returns it to be run to completion. +func (e *performances) beginInvocation(perf *actionFrame, inv actionInvocation) (*calleeFrame, error) { + sym := perf.callee + if sym == nil { + var err error + if sym, err = resolveActionSymbol(e.ctx, nodeScope(perf.flow, perf.node), inv); err != nil { + return nil, err + } + } + inv.step, _ = stepSymbol(perf.flow, perf.node) + if e.ctx.actionDepth >= maxActionNestingDepth { + return nil, fmt.Errorf( + "action invocation nested more than %d deep at %s (recursive action?)", + maxActionNestingDepth, inv.name(), + ) + } + + params, err := e.ctx.performanceParameters(inv.performed(sym), sym) + if err != nil { + return nil, err + } + in, out := parameterNames(params) + inputs := make(map[string]Value, len(in)) + for _, name := range in { + if value, ok := perf.data[perf.key(name)]; ok { + inputs[name] = value + } + } + if inv.expr == nil { + for _, name := range in { + if _, bound := inputs[name]; bound { + continue + } + if value, ok := lookupEnclosing(perf, name); ok { + inputs[name] = value + } + } + } + if err := checkInputsBound(inv, params, inputs); err != nil { + return nil, err + } + performer := e.self + if inv.chain != nil { + ec := e.evalContextAround(perf, nodeScope(perf.flow, perf.node)) + defer ec.beginStep()() + if performer, err = e.ctx.performerOf(ec, inv, e.self); err != nil { + return nil, err + } + } + + sort.Strings(out) + listener := &outputListener{perf: perf, take: e.streamCalleeOutput(perf, out)} + callee, err := e.ctx.beginOrJoinCallee(inv, sym, performer, inputs, listener) + if err != nil { + return nil, fmt.Errorf("invoke action %s: %w", inv.name(), err) + } + callee.name, callee.out, callee.performer = inv.name(), out, perf + return callee, nil +} + +// streamCalleeOutput is what the action a node performs writes its outputs through +// while it runs: each lands at the node's pin and goes on along its streaming flows. +func (e *performances) streamCalleeOutput(perf *actionFrame, out []string) func(string, Value) error { + outputs := make(map[string]bool, len(out)) + for _, name := range out { + outputs[perf.key(name)] = true + } + return func(name string, value Value) error { + key := perf.key(name) + if !outputs[key] { + return nil + } + perf.data[key] = value + return e.streamFrom(perf, key, value) + } +} + +// adopt makes the completed performance of the action a node performed the node's +// own: its features' values, and its subactions, read as `call.inner.v`. +func (f *actionFrame) adopt(callee *ActionExecutor) { + for name, value := range callee.root.data { + f.data[f.key(name)] = value + } + f.performs = callee.graph + if len(callee.root.subactions) > 0 && f.subactions == nil { + f.subactions = make(map[ast.Node]*actionFrame, len(callee.root.subactions)) + } + for node, sub := range callee.root.subactions { + sub.parent = f + f.subactions[node] = sub + } +} + +// checkInputsBound reports an input parameter that no argument, pin value or +// default binds, before the callee runs rather than when its body reads it. +func checkInputsBound(inv actionInvocation, params []actionParameter, inputs map[string]Value) error { + for _, param := range params { + if param.Direction != ast.DirIn && param.Direction != ast.DirInOut { + continue + } + if _, bound := inputs[param.Name]; bound || param.Optional { + continue + } + return fmt.Errorf("%w: action %s: input parameter %s is bound by no argument", + ErrUnboundParameter, inv.name(), param.Name) + } + return nil +} + +// performanceFrame is the frame an evaluation reads a performance's values +// through, which also answers for the nodes of its flow. +func performanceFrame(f *actionFrame) frame { + return frame{vars: f.data, aliases: f.aliases, perf: f, run: f.run} +} + +// cloneUnreceived copies the unreceived streams of a frame, queues included. +func cloneUnreceived(m map[ast.Node][]unreceivedStream) map[ast.Node][]unreceivedStream { + if m == nil { + return nil + } + out := make(map[ast.Node][]unreceivedStream, len(m)) + for node, streams := range m { + out[node] = slices.Clone(streams) + } + return out +} diff --git a/internal/core/runtime/action_holds.go b/internal/exec/runtime/action_holds.go similarity index 96% rename from internal/core/runtime/action_holds.go rename to internal/exec/runtime/action_holds.go index 622de1cc9b..0b592829f9 100644 --- a/internal/core/runtime/action_holds.go +++ b/internal/exec/runtime/action_holds.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // Holds evaluates a requirement or constraint against the run's state as the diff --git a/internal/core/runtime/action_holds_test.go b/internal/exec/runtime/action_holds_test.go similarity index 97% rename from internal/core/runtime/action_holds_test.go rename to internal/exec/runtime/action_holds_test.go index 2ec19c99db..c77a89a538 100644 --- a/internal/core/runtime/action_holds_test.go +++ b/internal/exec/runtime/action_holds_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // A requirement nested in an action reads the performance's values as the run diff --git a/internal/core/runtime/action_input_names_test.go b/internal/exec/runtime/action_input_names_test.go similarity index 94% rename from internal/core/runtime/action_input_names_test.go rename to internal/exec/runtime/action_input_names_test.go index 569373b2e3..38ebdae744 100644 --- a/internal/core/runtime/action_input_names_test.go +++ b/internal/exec/runtime/action_input_names_test.go @@ -22,7 +22,7 @@ const inputNamesModel = `action bump { // reported, not bound into the feature space and answered as an output. func TestUnknownActionInputIsReported(t *testing.T) { model, resolver, root := parseAndBuildModel(t, inputNamesModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) bump := resolveSymbol(t, root, "bump") outputs, err := ctx.ExecuteActionWithInputs(bump, map[string]Value{"nope": constInt(7)}) @@ -50,7 +50,7 @@ const outputParameterModel = `action measure { // binding it as an input is reported rather than silently overwritten. func TestSeedingAnOutputParameterIsReported(t *testing.T) { model, resolver, root := parseAndBuildModel(t, outputParameterModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) measure := resolveSymbol(t, root, "measure") outputs, err := ctx.ExecuteActionWithInputs(measure, map[string]Value{"total": constInt(99)}) @@ -74,7 +74,7 @@ func TestSeedingAnOutputParameterIsReported(t *testing.T) { // feature the caller may seed. func TestDeclaredActionInputsBind(t *testing.T) { model, resolver, root := parseAndBuildModel(t, inputNamesModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) bump := resolveSymbol(t, root, "bump") outputs, err := ctx.ExecuteActionWithInputs(bump, map[string]Value{ diff --git a/internal/exec/runtime/action_results_parts_test.go b/internal/exec/runtime/action_results_parts_test.go new file mode 100644 index 0000000000..046c4b1492 --- /dev/null +++ b/internal/exec/runtime/action_results_parts_test.go @@ -0,0 +1,50 @@ +package runtime + +import "testing" + +// Two activations of one action run in one context, each writing the part the +// action declares. The usage denotes one object for the context, which both read +// and write, so each reports `target.total` rather than dropping the part as several. +func TestResultsReportPartsOfEachActivation(t *testing.T) { + ctx, idx := libraryModelContext(t, `package test { + private import ScalarValues::*; + part def Probe { + attribute total : Real = 0.0; + } + action def Cfg { + in amount : Real; + part target : Probe; + action set { assign target.total := amount; } + first start then set; + first set then done; + } +}`) + cfg := lookupOne(t, idx, "test::Cfg") + run := func(amount float64) *ActionExecutor { + exec, err := newActionExecutor(ctx, cfg, nil) + if err != nil { + t.Fatalf("create: %v", err) + } + exec.SetInputs(map[string]Value{"amount": realOf(amount)}) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + for exec.State() == StateRunning { + if err := exec.Step(); err != nil { + t.Fatalf("step: %v", err) + } + } + return exec + } + first := run(1) + if got, ok := first.Results()["target.total"]; !ok || got.Const.Real != 1 { + t.Fatalf("first target.total = %v, %v; want 1", got, ok) + } + second := run(2) + if got, ok := second.Results()["target.total"]; !ok || got.Const.Real != 2 { + t.Fatalf("second target.total = %v, %v; want 2", got, ok) + } + if got, ok := first.Results()["target.total"]; !ok || got.Const.Real != 2 { + t.Fatalf("first target.total after the second run = %v, %v; want 2", got, ok) + } +} diff --git a/internal/exec/runtime/action_statements.go b/internal/exec/runtime/action_statements.go new file mode 100644 index 0000000000..161905beba --- /dev/null +++ b/internal/exec/runtime/action_statements.go @@ -0,0 +1,350 @@ +package runtime + +import ( + "fmt" + "slices" + "sort" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// actionStmtHost runs an action node's body statements: a send posts through the flow's +// connections, and an assignment to an undeclared name reaches the enclosing performances. +type actionStmtHost struct { + exec *performances + node ast.Node // the action node whose body is running, for diagnostics + // perf is the performance the body runs in, whose features it declares into. + perf *actionFrame +} + +// executeBody runs the lowered statements graph records for node in perf, the +// performance they belong to, with the performances around it in lexical reach. +func (e *performances) executeBody(perf *actionFrame, graph *lower.ActionGraph, node ast.Node) error { + _, err := e.ctx.runStatements(func() *stmtEngine { + host := &actionStmtHost{exec: e, node: node, perf: perf} + lexical := perf.lexicalFrames() + return newStmtEngineIn(e.ctx, host, lexical[len(lexical)-1], lexical[:len(lexical)-1]) + }, graph.Bodies[node]) + return err +} + +// runNodeBody runs the statements a control or initial node's body declares, +// which the token passing through the node performs. +func (e *ActionExecutor) runNodeBody(frame *actionFrame, node ast.Node) error { + if len(frame.graph.Bodies[node]) == 0 { + return nil + } + return e.executeBody(frame, frame.graph, node) +} + +func (h *actionStmtHost) describe() string { + return "action node " + ActionNodeName(h.node) +} + +func (h *actionStmtHost) send(ec *EvalContext, s lower.Send) error { + return h.exec.ctx.send(ec, h.exec.root.scope, h.perf.connections, s, h.exec.self, h.exec.behavior) +} + +// assignOuter writes a name the body's blocks do not declare: to the running performance, +// else the innermost enclosing one holding it, else the performing object, else the body. +func (h *actionStmtHost) assignOuter(env *stmtEnv, name string, value Value, s lower.Assign) error { + if h.perf.declares(name) { + return h.exec.setFrameFeature(h.perf, name, value) + } + if written, err := h.exec.assignEnclosing(h.perf, name, value); written || err != nil { + return err + } + if written, err := assignPerformerFeature(h.exec.ctx, h.exec.self, s.Scope, name, value); written || err != nil { + return err + } + return storeBodyValue(h.exec.ctx, h, env, name, value, s) +} + +func (h *actionStmtHost) assignData(env *stmtEnv, name string, value Value, s lower.Assign) error { + if h.perf.declares(name) { + return h.exec.setFrameFeature(h.perf, name, value) + } + return storeBodyValue(h.exec.ctx, h, env, name, value, s) +} + +// assignChain writes the feature a chained target names on the object its chain +// reaches from where the statement was written. +func (h *actionStmtHost) assignChain(ec *EvalContext, s lower.Assign, value Value) error { + return assignThroughChain(ec, h.describe(), s, value) +} + +// performer is the object performing the action this body belongs to. +func (h *actionStmtHost) performer() *Instance { + return h.exec.self +} + +// acceptReturn rejects a `return`: an action node computes no result to return. +func (h *actionStmtHost) acceptReturn(Value, lower.Return) error { + return fmt.Errorf("%w: %s", ErrReturnOutsideCalc, h.describe()) +} + +// effect performs the action a `perform` in statement form names, where it +// stands, starts the behavior a `perform obj.beh.start` names on its object, or +// ends the performance a `terminate` names; any other effect is reported. +func (h *actionStmtHost) effect(engine *stmtEngine, s lower.Effect) error { + env := engine.env + if s.Kind == lower.EffectTerminate { + return h.exec.terminate(engine, h.perf, s) + } + if s.Kind == lower.EffectStart { + if err := h.exec.ctx.startEffect(engine.evalIn(s.Scope), s, h.exec.self); err != nil { + return fmt.Errorf("%s: %w", h.describe(), err) + } + return nil + } + if s.Kind != lower.EffectPerform { + return fmt.Errorf("%s: '%s' in a body is not executable", h.describe(), s.Kind) + } + inv, ok := performedInvocation(s) + if !ok { + return fmt.Errorf("%s: 'perform' names no action to perform", h.describe()) + } + // The performed action reads the values in scope where it is performed and its + // outputs come back to them, so a perform in a loop body sees that iteration. + _, outputs, err := invokeAction(h.exec.ctx, s.Scope, inv, env.values(), h.exec.self) + if err != nil { + return fmt.Errorf("%s: %w", h.describe(), err) + } + names := make([]string, 0, len(outputs)) + for name := range outputs { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if env.assign(name, outputs[name]) { + continue + } + if written, err := h.exec.assignEnclosing(h.perf, name, outputs[name]); written || err != nil { + if err != nil { + return fmt.Errorf("%s: %w", h.describe(), err) + } + continue + } + env.data.set(name, outputs[name]) + } + return nil +} + +// performNode performs a nested action a block of the body declares as a +// subperformance of the body's. +func (h *actionStmtHost) performNode(engine *stmtEngine, graph *lower.ActionGraph, node *ast.Usage) (stmtFlow, error) { + return h.exec.performNode(h.perf, engine, graph, node) +} + +// runFlow rejects a stated flow among statements: an action's own flow is the +// executor's, and a block of its body sequences its nodes. +func (h *actionStmtHost) runFlow(lower.Block) (stmtFlow, error) { + return flowNext, fmt.Errorf("%w: %s: a flow of steps in a block is not executable", + ErrStatementNotExecutable, h.describe()) +} + +func (h *actionStmtHost) runBlockFlow(engine *stmtEngine, block lower.Block) (stmtFlow, error) { + return h.exec.performBlockFlow(h.perf, engine, block) +} + +// performNode performs node, which a block of parent's body declares, as a subperformance +// of parent with the block-locals entered around it in reach; a node owning a flow runs it +// to completion here. A breakpoint on the node pauses the run before it performs. +func (e *performances) performNode(parent *actionFrame, engine *stmtEngine, graph *lower.ActionGraph, node *ast.Usage) (stmtFlow, error) { + f, resumed, err := popFrame[*performFrame](e.ctx) + if err != nil { + return flowNext, err + } + if !resumed { + f = &performFrame{levels: e.ctx.bodyLevels()} + } + if !resumed || f.recheck { + f.recheck = false + if err := e.owner.pauseAt(parent.within(), node); err != nil { + return flowNext, e.ctx.pausing(f, err) + } + } + if f.perf == nil { + if f.perf, err = e.beginPerformance(parent, graph, node, slices.Clone(engine.env.frames)); err != nil { + return flowNext, err + } + } + // A terminate of the node ends its body where it stands, dropping what a flow nested in + // its leaf body still runs (runSubflow drops a flow of its own); the node completes. + // One ended while the body was paused (endPerformed) has only the node to complete. + var ended *terminated + if !f.ended { + if err := e.performNodeBody(f, graph, node); err != nil { + err = e.terminatedUsage(f.perf, graph, err) + if ended = unwound(err); ended == nil { + return flowNext, e.ctx.pausing(f, err) + } + if ended.perf != f.perf { + // The node ends a performance around it: its own completes first, pins bound. + if err := e.endPerformance(f.perf); err != nil { + return flowNext, err + } + return flowNext, ended + } + if f.perf.graph == nil { + e.flow.dropTokensIn(f.perf, 0) + } + } + if err := e.endPerformance(f.perf); err != nil { + return flowNext, err + } + } + if err := e.applyDataFlows(parent, graph, node, f.perf, f.perf.data, f.perf.streamed); err != nil { + return flowNext, err + } + if ended != nil { + return flowNext, e.flow.endAlongside(ended) + } + return flowNext, nil +} + +// performFrame is a node a body performs (performNode) where the body paused: at +// a breakpoint before the node's performance began, else in one of its phases. +type performFrame struct { + perf *actionFrame + phase performPhase + // recheck has the resumed body look for a breakpoint on the node again: the + // one it stopped at was removed while it stood, so one set since is a new stop. + recheck bool + // levels is the trace nesting the body held open at the node, over the depth + // its run resumed at; ended marks the performance a terminate ended meanwhile. + levels int + ended bool +} + +// stoppedAtBreakpoint reports whether the frame is a body's stop at a breakpoint, +// before the node performs. +func (f *performFrame) stoppedAtBreakpoint() bool { return f.perf == nil } + +// abandon ends the node's performance with the body that was performing it. +func (f *performFrame) abandon(*Context) { + if f.perf != nil { + f.perf.ended, f.perf.live = true, 0 + } +} + +func (f *performFrame) clone() bodyFrame { c := *f; return &c } + +// blockFlowFrame resumes a transparent performance for a stated body flow. +type blockFlowFrame struct { + perf *actionFrame + levels int +} + +func (f *blockFlowFrame) abandon(*Context) { + if f.perf != nil { + f.perf.ended, f.perf.live = true, 0 + } +} + +func (f *blockFlowFrame) clone() bodyFrame { c := *f; return &c } + +// performBlockFlow runs the flow a loop or branch body states as a performance +// of its own, a subperformance of parent with engine's block-locals in reach. +func (e *performances) performBlockFlow(parent *actionFrame, engine *stmtEngine, block lower.Block) (stmtFlow, error) { + f, resumed, err := popFrame[*blockFlowFrame](e.ctx) + if err != nil { + return flowNext, err + } + if !resumed { + f = &blockFlowFrame{levels: e.ctx.bodyLevels()} + } + if f.perf == nil { + scope := block.Scope + if scope == nil { + scope = parent.scope + } + f.perf = &actionFrame{ + node: block.Node, + graph: block.Graph, + flow: block.Graph, + scope: scope, + parent: parent, + locals: slices.Clone(engine.env.frames), + connections: joinConnections(parent.connections, block.Graph.Connections), + data: make(map[string]Value), + features: make(map[string]ast.FeatureDirection), + subactions: make(map[ast.Node]*actionFrame), + run: e.ctx.newRun(), + live: 1, + body: true, + } + switch block.Node.(type) { + case *ast.WhileLoopActionNode: + f.perf.label = "loop body of " + parent.describe() + case *ast.IfBranchNode: + f.perf.label = "branch body of " + parent.describe() + } + features := make([]lower.Feature, 0, len(block.Graph.Attributes)) + for _, attr := range block.Graph.Attributes { + f.perf.features[attr.Name] = attr.Direction + features = append(features, lower.Feature{ + Name: attr.Name, Direction: attr.Direction, IsResult: attr.IsResult, + Value: attr.Value, Node: attr.Node, Scope: block.Scope, + }) + } + activation, endStep := e.ctx.beginStep() + f.perf.began = activation + if err := e.seedDeclaredValues(f.perf, features, activation); err != nil { + endStep() + return flowNext, err + } + endStep() + } + err = e.owner.runOwnFlow(f.perf) + if err != nil { + if ended := unwound(err); ended != nil && ended.perf != f.perf { + e.flow.dropTokensIn(f.perf, 0) + f.perf.live = 0 + f.perf.ended = true + return flowNext, ended + } + if paused(err) { + return flowNext, e.ctx.pausing(f, err) + } + return flowNext, err + } + f.perf.ended = true + return flowNext, nil +} + +// performPhase is how far a node's performance has come. +type performPhase int + +const ( + performInvoking performPhase = iota // the case it is or the action it performs + performBody // the flow it owns or its statements +) + +// performNodeBody performs the case the node is or the action it performs, then +// the flow it owns or the statements of its body. +func (e *performances) performNodeBody(f *performFrame, graph *lower.ActionGraph, node *ast.Usage) error { + perf := f.perf + if isCaseStep(node) { + return e.performCase(perf) + } + if f.phase == performInvoking { + if inv, ok := nestedInvocation(node); ok { + if err := e.performInvocation(perf, inv); err != nil { + return err + } + } + f.phase = performBody + } + if perf.graph != nil { + return e.owner.runOwnFlow(perf) + } + return e.executeBody(perf, graph, node) +} + +// declaredOutput reports no output features: an action node's parameters live +// in the action's feature space, which an assignment writes directly. +func (h *actionStmtHost) declaredOutput(string) bool { + return false +} diff --git a/internal/exec/runtime/action_subflow.go b/internal/exec/runtime/action_subflow.go new file mode 100644 index 0000000000..7b39f54145 --- /dev/null +++ b/internal/exec/runtime/action_subflow.go @@ -0,0 +1,623 @@ +package runtime + +import ( + "fmt" + "maps" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// An action node owning a flow runs it as subperformances (`subactions :> subperformances`): +// a token moves into the node's performance, and the node completes when its last token retires. + +// graphOf returns the flow a performance runs. +func (e *ActionExecutor) graphOf(frame *actionFrame) *lower.ActionGraph { + return frame.graph +} + +// tokenGraph returns the flow the token at this index is running in. +func (e *ActionExecutor) tokenGraph(tokenIdx int) *lower.ActionGraph { + return e.graphOf(e.tokens[tokenIdx].frame) +} + +// subflowOf returns the flow a node owns, and whether it owns one. +func (e *performances) subflowOf(graph *lower.ActionGraph, node ast.Node) (*lower.Subflow, bool) { + sub, owns := graph.Subflows[node] + return sub, owns && sub != nil +} + +// enterSubflow moves a token into the flow its node owns, run by the node's performance; +// the flow was validated at initialize(), so an unbuildable one is an error here. +func (e *ActionExecutor) enterSubflow(tokenIdx int, perf *actionFrame) error { + token := &e.tokens[tokenIdx] + node := token.Location + if perf.graph != nil && perf.graph.Invalid != nil { + return fmt.Errorf("%w: action node %s: %w", + ErrInvalidActionFlow, ActionNodeName(node), perf.graph.Invalid) + } + if perf.graph == nil || perf.graph.Initial == nil { + return fmt.Errorf("%w: action node %s owns a flow that cannot be built", + ErrInvalidActionFlow, ActionNodeName(node)) + } + token.frame = perf + token.Location = perf.graph.Initial + token.Via = lower.ActionEdge{} + token.moved = e.sweep + if tr := e.trace(); tr != nil { + tr.RecordActionNodeEnter(ActionNodeName(node)) + } + return nil +} + +// subflowFrame is a flow a body statement runs (runSubflow) where the body paused: +// the tokens of the flow stay on their executor, their own paused work with them. +type subflowFrame struct { + exec *ActionExecutor + perf *actionFrame + name string + progress dueProgress +} + +// abandon ends the paused work of the flow's tokens; the flow itself ends with the body. +func (f *subflowFrame) abandon(ctx *Context) { + for i := range f.exec.tokens { + if token := &f.exec.tokens[i]; token.body != nil && token.inFlowOf(f.perf) { + token.body.end(ctx) + token.body = nil + } + } +} + +func (f *subflowFrame) clone() bodyFrame { + c := *f + c.progress.dropped = slices.Clone(f.progress.dropped) + c.progress.settled = maps.Clone(f.progress.settled) + return &c +} + +// runSubflow performs the flow perf owns to completion where a body statement, +// not a token of the enclosing flow, performs its node: the tokens of that flow +// alone are stepped until its last one retires, pausing where a breakpoint is +// met as RunToCompletion does. Nothing outside can post a message meanwhile, so +// a token parked at an accept for one is a deadlock, as under RunToCompletion; +// one parked on the clock pauses the body until the clock is advanced to its +// instant; a run with no body to pause (a case body's) advances the clock itself. +func (e *ActionExecutor) runSubflow(perf *actionFrame) error { + f, resumed, err := popFrame[*subflowFrame](e.ctx) + if err != nil { + return err + } + if !resumed { + if f, err = e.enterBodyFlow(perf); err != nil { + return err + } + } + err = e.driveSubflow(f) + if err != nil && !terminates(err, perf) { + return e.ctx.pausing(f, err) + } + if err != nil { + // A terminate of the flow's own performance drops what still runs in it; the + // unwinding goes on to the statement performing the node, which completes it. + e.dropTokensIn(perf, 0) + perf.live = 0 + } + if tr := e.trace(); tr != nil { + tr.RecordActionNodeExit(f.name) + } + return err +} + +// enterBodyFlow starts the flow perf owns for a body statement performing its +// node, with one token at its initial node. +func (e *ActionExecutor) enterBodyFlow(perf *actionFrame) (*subflowFrame, error) { + node := perf.node + if perf.graph != nil && perf.graph.Invalid != nil { + return nil, fmt.Errorf("%w: %s: %w", + ErrInvalidActionFlow, perf.describe(), perf.graph.Invalid) + } + if err := e.checkNodeResultParameters(perf.graph); err != nil { + return nil, fmt.Errorf("%s: %w", perf.describe(), err) + } + if perf.graph == nil || perf.graph.Initial == nil { + return nil, fmt.Errorf("%w: %s owns a flow that cannot be built", + ErrInvalidActionFlow, perf.describe()) + } + perf.inBody = true + e.tokens = append(e.tokens, Token{ID: e.nextTokenID, Location: perf.graph.Initial, frame: perf}) + e.nextTokenID++ + // The root performance, a case body's own flow, has no node and is traced by name. + name := ActionNodeName(node) + if name == "" { + name = perf.describe() + } + if tr := e.trace(); tr != nil { + tr.RecordActionNodeEnter(name) + } + return &subflowFrame{exec: e, perf: perf, name: name}, nil +} + +// driveSubflow steps the flow's tokens until its last one retires, pausing the +// body where a breakpoint is met or the flow waits; a body run one statement at +// a time yields once a node is performed, before the next node to perform. +func (e *ActionExecutor) driveSubflow(f *subflowFrame) error { + perf := f.perf + for perf.live > 0 { + if stop, hit := e.breakpointHit(); hit { + if err := e.ctx.pauseBody(bodyPause{breakpoint: stop}); err != nil { + return err + } + } + if err := e.chargeActionStep(); err != nil { + return err + } + moved, performed, err := e.stepSubflow(perf) + if err != nil { + return err + } + // A flow a body drives outside any step of the executor's own counts its sweeps itself. + if moved && e.sweep == 0 && !e.ctx.stepsTokens() { + e.stepCount++ + } + // A step of a token's own work stopped at a breakpoint stops the body too. + if e.state == StateSuspended { + e.state = StateRunning + if err := e.ctx.pauseBody(bodyPause{breakpoint: e.pausedAt}); err != nil { + return err + } + } + if performed { + e.ctx.bodyPerformed() + } + if moved { + switch { + case e.ctx.stepsTokens(): + // A run one move at a time pauses before its next, its machine going on meanwhile. + if e.canAct(perf) { + if err := e.ctx.tokenStepBody(); err != nil { + return err + } + } + case e.nextStepPerforms(perf): + if err := e.ctx.yieldBody(); err != nil { + return err + } + } + continue + } + if e.waitsOnClock(perf) && !e.hasDueTimeWait(perf) && !e.hasDuePausedWork(perf) { + if err := e.ctx.pauseForClock(bodyWait{exec: e, perf: perf}); err != nil { + return err + } + if err := e.ctx.driveClock(e.describeWaits(perf)); err != nil { + return err + } + moved, err := e.awaitClock(perf, &f.progress) + if err != nil { + return err + } + if moved { + continue + } + } else if len(e.waitingTokens(perf)) > 0 && !e.canProceed(perf) { + if err := e.ctx.pauseForMessage(bodyWait{exec: e, perf: perf}); err != nil { + return err + } + } + if len(e.waitingTokens(perf)) > 0 { + return e.deadlockError(perf) + } + return fmt.Errorf("%w: %d token(s) stuck in %s, no progress made", + ErrActionDeadlock, len(e.tokensIn(perf)), perf.describe()) + } + return nil +} + +// stepSubflow steps every token of perf's flow once — one token, drawn among those +// able to act, where a step is one move — and reports whether any moved, which a +// retired, forked or relocated token did, and whether one performed its node. +func (e *ActionExecutor) stepSubflow(perf *actionFrame) (moved, performed bool, err error) { + before := e.subflowLocations(perf) + performing := e.performingTokens(perf) + if !e.ctx.stepsTokens() { + if err := e.stepSubflowSweep(perf); err != nil { + return false, false, err + } + return e.subflowMoved(perf, before, performing) + } + // A token moving on a loop may stand where it stood, so the move itself counts. + acted, performed, err := e.stepSubflowMove(perf) + if err != nil { + return false, false, err + } + moved, performedNode, err := e.subflowMoved(perf, before, performing) + return moved || acted, performed || performedNode, err +} + +// stepSubflowSweep steps every token of perf's flow once, in the order the run's +// scheduling policy has it try them. +func (e *ActionExecutor) stepSubflowSweep(perf *actionFrame) (err error) { + defer e.beginSweep()() + order := e.beginStepOrder() + endWrites := e.beginStepWrites(e.stepCount + 1) + eligible := func(t Token) bool { return t.inFlowOf(perf) } + if e.ctx.scheduling().oneMove() { + // Paused work that would only pause again is no alternative to pick. + eligible = func(t Token) bool { return t.inFlowOf(perf) && (t.body == nil || t.resumable()) } + } + candidates := e.stepCandidates(&order, eligible) + schedule := e.ctx.scheduling().scheduleStep(candidates) + for id, ok := schedule.Next(); ok; id, ok = schedule.Next() { + i := e.tokenIndex(id) + if i < 0 || e.moving(e.tokens[i]) || !e.tokens[i].inFlowOf(perf) { + schedule.Acted(id, false) + continue + } + var did bool + did, err = e.stepTokenNoting(i, &order) + schedule.Acted(id, did) + if err != nil { + break + } + } + endWrites() + e.noteTokenOrder(e.stepCount+1, order, schedule) + if refused := e.ctx.scheduling().refusal(); refused != nil { + err = refused + } + return err +} + +// stepSubflowMove is the step of a flow run one token move at a time: its silent +// moves settle, without a draw, around one move drawn among the tokens able to act; +// it reports whether a token acted and whether the drawn one performed its node. +func (e *ActionExecutor) stepSubflowMove(perf *actionFrame) (acted, performed bool, err error) { + settled, err := e.settleSilentMoves(perf) + if err != nil { + return false, false, err + } + endWrites := e.beginStepWrites(e.stepCount + 1) + drew, performed, err := e.drawOneMove(perf) + endWrites() + if err != nil { + return false, false, err + } + // The drawn move is the step; the routing that settles it counts as steps of its own. + if drew && e.sweep == 0 { + e.stepCount++ + } + settledAfter, err := e.settleSilentMoves(perf) + return settled || drew || settledAfter, performed, err +} + +// drawOneMove moves one token of perf's flow, the one drawn among those able to act, +// reporting whether one acted and whether it performed the node it stood at. +func (e *ActionExecutor) drawOneMove(perf *actionFrame) (acted, performed bool, err error) { + defer e.beginSweep()() + order := e.beginStepOrder() + schedule := e.ctx.scheduling().scheduleStep(e.stepCandidates(&order, oneMoveEligibleIn(perf))) + for id, ok := schedule.Next(); ok; id, ok = schedule.Next() { + i := e.tokenIndex(id) + if i < 0 || e.moving(e.tokens[i]) || !e.tokens[i].inFlowOf(perf) { + schedule.Acted(id, false) + continue + } + t := e.tokens[i] + var did bool + did, err = e.stepTokenNoting(i, &order) + schedule.Acted(id, did) + if did { + acted, performed = true, e.performs(t.frame, t.Location) + } + if err != nil || did { + break + } + } + e.noteTokenOrder(e.stepCount+1, order, schedule) + if refused := e.ctx.scheduling().refusal(); refused != nil { + err = refused + } + return acted, performed, err +} + +// oneMoveEligibleIn is the eligibility of a step moving one token of perf's flow. +func oneMoveEligibleIn(perf *actionFrame) func(Token) bool { + return func(t Token) bool { return t.inFlowOf(perf) && (t.body == nil || t.resumable()) } +} + +// canAct reports whether a token of perf's flow would act were it stepped now. +func (e *ActionExecutor) canAct(perf *actionFrame) bool { + eligible := oneMoveEligibleIn(perf) + return slices.ContainsFunc(e.tokens, func(t Token) bool { return e.enabled(t.ID, eligible) }) +} + +// settleSilentMoves makes every silent move of perf's flow, in declared order, +// until none is left: no other move observes where between two of them it falls. +// Each pass is a step of the flow's own when the body drives it outside a sweep. +func (e *ActionExecutor) settleSilentMoves(perf *actionFrame) (settled bool, err error) { + for { + moved, err := e.silentPass(perf) + if err != nil || !moved { + return settled, err + } + settled = true + if e.sweep == 0 { + e.stepCount++ + } + if err := e.chargeActionStep(); err != nil { + return settled, err + } + } +} + +// silentPass makes the silent move of every token of perf's flow standing at one. +func (e *ActionExecutor) silentPass(perf *actionFrame) (moved bool, err error) { + defer e.beginSweep()() + for i := 0; i < len(e.tokens); i++ { + t := e.tokens[i] + if e.moving(t) || !t.inFlowOf(perf) || !e.silentMove(t) { + continue + } + did, err := e.stepTokenNoting(i, &stepOrder{}) + if err != nil { + return moved, err + } + if did { + moved, i = true, -1 + } + } + return moved, nil +} + +// silentMove reports a token whose next move only routes control — through the +// initial, a fork, join or merge without a body, over unguarded, unweighted successions. +func (e *ActionExecutor) silentMove(t Token) bool { + if t.body != nil { + return false + } + switch t.Location.(type) { + case *ast.InitialNode, *ast.ForkNode, *ast.JoinNode, *ast.MergeNode: + default: + return false + } + graph := e.graphOf(t.frame) + if len(graph.Bodies[t.Location]) > 0 { + return false + } + for _, edge := range graph.Edges[t.Location] { + if edge.Guard != nil || edge.Probability != nil { + return false + } + } + return true +} + +// subflowMoved compares where perf's tokens sit with where they sat before a step, +// reporting whether any moved and whether one performed its node. +func (e *ActionExecutor) subflowMoved(perf *actionFrame, before map[int64]ast.Node, performing map[int64]bool) (moved, performed bool, err error) { + after := e.subflowLocations(perf) + moved = len(after) != len(before) + for id, location := range after { + if before[id] != location { + moved = true + } + } + for id := range performing { + if at, ok := after[id]; !ok || at != before[id] { + moved, performed = true, true + } + } + return moved, performed, nil +} + +// subflowLocations returns where each token of perf's flow sits, by token ID. +func (e *ActionExecutor) subflowLocations(perf *actionFrame) map[int64]ast.Node { + locations := make(map[int64]ast.Node) + for _, idx := range e.tokensIn(perf) { + locations[e.tokens[idx].ID] = e.tokens[idx].Location + } + return locations +} + +// performingTokens returns the IDs of the tokens of perf's flow standing at a node +// their next step performs. +func (e *ActionExecutor) performingTokens(perf *actionFrame) map[int64]bool { + performing := make(map[int64]bool) + for _, idx := range e.tokensIn(perf) { + if t := e.tokens[idx]; e.performs(t.frame, t.Location) { + performing[t.ID] = true + } + } + return performing +} + +// nextStepPerforms reports whether a token of perf's flow stands at a node its +// next step performs. +func (e *ActionExecutor) nextStepPerforms(perf *actionFrame) bool { + return len(e.performingTokens(perf)) > 0 +} + +// performs reports a node a token's step performs — an action or a statement node +// — rather than routes through (a control node) or waits at (an accept, whose +// step is its wait's end). +func (e *ActionExecutor) performs(frame *actionFrame, node ast.Node) bool { + switch n := node.(type) { + case *ast.Usage: + _, accept := e.graphOf(frame).Accepts[n] + return !accept + case *ast.ActionExecutionNode, *ast.WhileLoopActionNode, *ast.IfActionNode, + *ast.AssignmentActionNode, *ast.SendStatement, *ast.TerminateStatement: + return true + } + return false +} + +// tokensIn returns the indices of the tokens running in perf's flow or one nested +// in it; every token's for nil. +func (e *ActionExecutor) tokensIn(perf *actionFrame) []int { + var indices []int + for i := range e.tokens { + if e.tokens[i].inFlowOf(perf) { + indices = append(indices, i) + } + } + return indices +} + +// inFlowOf reports whether the token runs in perf's flow or one nested in it; +// every token does of nil. +func (t Token) inFlowOf(perf *actionFrame) bool { + if perf == nil { + return true + } + for f := t.frame; f != nil; f = f.parent { + if f == perf { + return true + } + } + return false +} + +// positionIn returns the node of perf's flow the token stands at: its location, or the node +// owning the flow nested under perf it runs in; false for a token outside perf's flow. +func (t Token) positionIn(perf *actionFrame) (ast.Node, bool) { + if t.frame == perf { + return t.Location, true + } + for f := t.frame; f != nil; f = f.parent { + if f.parent == perf { + return f.node, true + } + } + return nil, false +} + +// leaveSubflow returns a token to the node whose flow has just completed, ends +// that node's performance and takes the node's own succession. +func (e *ActionExecutor) leaveSubflow(tokenIdx int) error { + token := &e.tokens[tokenIdx] + frame := token.frame + token.frame = frame.parent + token.Location = frame.node + token.Via = lower.ActionEdge{} + token.Wait = nil + if tr := e.trace(); tr != nil { + tr.RecordActionNodeExit(ActionNodeName(frame.node)) + } + if err := e.endPerformance(frame); err != nil { + return err + } + return e.completeNode(tokenIdx, frame) +} + +// validateSubflows reports a nested node whose own flow could not be built, in +// graph's flow or in a block flow a body of it states. It runs at initialize(), +// not at construction, per the error-timing contract. +func (e *ActionExecutor) validateSubflows(graph *lower.ActionGraph) error { + if graph.Invalid != nil { + return fmt.Errorf("%w: %w", ErrInvalidActionFlow, graph.Invalid) + } + for _, node := range graph.Nodes { + if sub, owns := e.subflowOf(graph, node); owns { + if sub.Err != nil { + return fmt.Errorf("%w: action node %s: %w", + ErrInvalidActionFlow, ActionNodeName(node), sub.Err) + } + if sub.Graph.Initial == nil { + return fmt.Errorf("%w: no initial node found in action node %s%s", + ErrInvalidActionFlow, ActionNodeName(node), noFlowStart(sub.Graph)) + } + if err := e.validateSubflows(sub.Graph); err != nil { + return err + } + } + for _, block := range lower.BlockFlows(graph.Bodies[node]) { + if err := e.validateSubflows(block); err != nil { + return err + } + if len(block.Nodes) > 0 && block.Initial == nil { + return fmt.Errorf("%w: no node starts the flow a body of action node %s states%s", + ErrInvalidActionFlow, ActionNodeName(node), noFlowStart(block)) + } + } + } + return nil +} + +// checkResultParameters refuses an action, or a node of its flow, declaring a +// `return` parameter — only a function or expression owns one. +func (e *ActionExecutor) checkResultParameters() error { + for _, param := range e.ctx.model.semantics.BehaviorParametersOf(e.action) { + if param.IsResult { + return fmt.Errorf("%w: action %s declares `return %s`; write `out %s`", + ErrActionResultParameter, symbolText(e.action), param.Symbol.Name, param.Symbol.Name) + } + } + return e.checkNodeResultParameters(e.graph) +} + +func (e *ActionExecutor) checkNodeResultParameters(graph *lower.ActionGraph) error { + if graph == nil { + return nil + } + for _, node := range graph.Nodes { + for _, f := range graph.Features[node] { + if f.IsResult { + return fmt.Errorf("%w: action node %s declares `return %s`; write `out %s`", + ErrActionResultParameter, ActionNodeName(node), f.Name, f.Name) + } + } + if sub, owns := e.subflowOf(graph, node); owns && sub.Graph != nil { + if err := e.checkNodeResultParameters(sub.Graph); err != nil { + return err + } + } + for _, block := range lower.BlockFlows(graph.Bodies[node]) { + if err := e.checkNodeResultParameters(block); err != nil { + return err + } + } + } + return nil +} + +// subflowNodeNames returns the names of the nodes of every flow nested under +// graph — the flows its nodes own and the block flows their bodies state — so +// a debugger can break on a step of a nested flow. +func (e *ActionExecutor) subflowNodeNames(graph *lower.ActionGraph) []string { + var names []string + for _, node := range graph.Nodes { + if sub, owns := e.subflowOf(graph, node); owns && sub.Graph != nil { + names = append(names, e.flowNodeNames(sub.Graph)...) + } + for _, block := range lower.BlockFlows(graph.Bodies[node]) { + names = append(names, e.flowNodeNames(block)...) + } + } + return names +} + +// flowNodeNames returns the names of graph's nodes, then of the flows nested under +// it; a run of statements is a step of a block's flow but no node to break on. +func (e *ActionExecutor) flowNodeNames(graph *lower.ActionGraph) []string { + var names []string + for _, node := range graph.Nodes { + if graph.StatementRuns[node] { + continue + } + names = append(names, ActionNodeNames(node)...) + } + return append(names, e.subflowNodeNames(graph)...) +} + +// joinConnections appends the connectors a nested flow declares to those around it. +func joinConnections(outer, inner []lower.Connection) []lower.Connection { + if len(inner) == 0 { + return outer + } + joined := make([]lower.Connection, 0, len(outer)+len(inner)) + joined = append(joined, outer...) + return append(joined, inner...) +} diff --git a/internal/exec/runtime/action_terminate.go b/internal/exec/runtime/action_terminate.go new file mode 100644 index 0000000000..b6302e6ef1 --- /dev/null +++ b/internal/exec/runtime/action_terminate.go @@ -0,0 +1,431 @@ +package runtime + +import ( + "cmp" + "errors" + "fmt" + "math" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// terminated unwinds a body out to the executor step within perf, the performance +// its `terminate` ends (SysML v2 §7.17.10); then are the performances the same +// statement named after perf, ended in place once perf has, in that order. +// A terminate of an occurrence the body runs within unwinds with perf nil instead: +// object is the occurrence and ended the identities of the objects ending with it. +type terminated struct { + perf *actionFrame + then []*actionFrame + object *Instance + ended map[int64]bool +} + +func (t *terminated) Error() string { + if t.perf == nil { + return fmt.Sprintf("terminate object #%d (%s)", t.object.ID, symbolText(t.object.Type)) + } + return "terminate " + t.perf.describe() +} + +// terminates reports whether err is the unwinding of a terminate ending perf. +func terminates(err error, perf *actionFrame) bool { + var t *terminated + return errors.As(err, &t) && t.perf != nil && t.perf == perf +} + +// terminate ends what s names, the earliest begun first: in place for each performance +// that is another flow's node, unwinding perf's body at the one it runs within — the +// later ones then end where the unwinding is caught, so the order named holds. An +// occurrence named is evaluated by engine and ended with its behaviors (terminateOccurrence). +func (e *performances) terminate(engine *stmtEngine, perf *actionFrame, s lower.Effect) error { + if s.Terminates == lower.TerminateOccurrence { + return e.terminateOccurrence(engine, s) + } + targets, err := e.terminateTargets(perf, s) + if err != nil { + return err + } + // An action usage no flow around the statement declares is an occurrence the + // name denotes: another object's action, reached through it. + if len(targets) == 0 { + return e.terminateOccurrence(engine, s) + } + return e.endPerformances(perf, targets) +} + +// endPerformances ends targets, performances of flows around perf, in the order named. +func (e *performances) endPerformances(perf *actionFrame, targets []*actionFrame) error { + for _, target := range targets { + if target.ended { + return fmt.Errorf("%w: %s", ErrPerformanceEnded, target.describe()) + } + if target.node == nil && !e.owner.endsOwn() { + return fmt.Errorf("%w: 'terminate' of %s is not executable", ErrStatementNotExecutable, target.describe()) + } + } + for i, target := range targets { + if perf.nestedIn(target) { + return &terminated{perf: target, then: targets[i+1:]} + } + if err := e.flow.endOther(target); err != nil { + return err + } + } + return nil +} + +// terminatedUsage goes on from a terminate action usage's body ending perf, the usage's +// own performance, to the terminate the usage stands for; any other err is returned as is. +func (e *performances) terminatedUsage(perf *actionFrame, graph *lower.ActionGraph, err error) error { + if !terminates(err, perf) { + return err + } + s, ok := graph.TerminateUsage(perf.node) + if !ok { + return err + } + targets, err := e.terminateTargets(perf, s) + if err != nil { + return err + } + return e.endPerformances(perf, targets) +} + +// terminateTargets resolves the performances s names from perf, whose body states it: +// perf itself, its parent, or the ongoing performances of a node of a flow around it; +// none for an action usage no flow around perf declares. +func (e *performances) terminateTargets(perf *actionFrame, s lower.Effect) ([]*actionFrame, error) { + switch s.Terminates { + case lower.TerminateContaining: + for perf.body { + perf = perf.parent + } + return []*actionFrame{perf}, nil + case lower.TerminateEnclosing: + for perf.body { + perf = perf.parent + } + if perf == nil || perf.parent == nil { + name := "the body" + if perf != nil { + name = perf.describe() + } + return nil, fmt.Errorf("%w: %s is no step of a flow to end", ErrTerminateTarget, name) + } + return []*actionFrame{perf.parent}, nil + case lower.TerminateNode: + for f := perf; f != nil; f = f.parent { + if f.node == s.Target { + ongoing := e.ongoingWith(f, s.Target) + pending, err := e.flow.beginPending(f.parent, s.Target) + return append(ongoing, pending...), err + } + latest, performed := f.subactions[s.Target] + ongoing := e.flow.ongoing(f, s.Target) + pending, err := e.flow.beginPending(f, s.Target) + if err != nil { + return nil, err + } + if targets := append(ongoing, pending...); len(targets) > 0 { + return targets, nil + } + if performed { + return []*actionFrame{latest}, nil + } + } + if !perf.declaresAround(s.Target) { + return nil, nil + } + return nil, fmt.Errorf("%w: action node %s has no ongoing performance in a flow around %s", + ErrTerminateTarget, ActionNodeName(s.Target), perf.describe()) + } + return nil, fmt.Errorf("%w: %s: 'terminate' names no performance of a flow around it", + ErrTerminateTarget, perf.describe()) +} + +// declaresAround reports whether node is a node of f's flow or of a flow around it. +func (f *actionFrame) declaresAround(node ast.Node) bool { + name := ActionNodeName(node) + for ; f != nil; f = f.parent { + if slices.Contains(f.nodesNamed(name), node) { + return true + } + } + return false +} + +// ongoingWith returns the ongoing performances of node in the flow of within, a performance +// of node itself, which is among them: the performance running the statement is never left out. +func (e *performances) ongoingWith(within *actionFrame, node ast.Node) []*actionFrame { + if within.parent == nil { + return []*actionFrame{within} + } + ongoing := e.flow.ongoing(within.parent, node) + if !slices.Contains(ongoing, within) { + ongoing = append([]*actionFrame{within}, ongoing...) + } + return ongoing +} + +// ongoing returns the performances of node in parent's flow still running: parent holds +// the latest, tokens run in or hold paused the earlier ones. The earliest begun comes +// first, those begun together in the order of the lowest token ID holding them. +func (e *ActionExecutor) ongoing(parent *actionFrame, node ast.Node) []*actionFrame { + var found []*actionFrame + holder := make(map[*actionFrame]int64) + add := func(f *actionFrame, token int64) { + if f == nil || f.parent != parent || f.node != node || f.ended { + return + } + if held, ok := holder[f]; !ok || token < held { + holder[f] = token + } + if !slices.Contains(found, f) { + found = append(found, f) + } + } + add(parent.subactions[node], math.MaxInt64) + for _, token := range e.tokens { + for f := token.frame; f != nil; f = f.parent { + add(f, token.ID) + } + for _, f := range token.performed() { + add(f, token.ID) + } + } + slices.SortFunc(found, func(a, b *actionFrame) int { + return cmp.Or(cmp.Compare(a.began, b.began), cmp.Compare(holder[a], holder[b])) + }) + return found +} + +// beginPending begins, for each token of parent's flow parked at node with its step not +// begun (a fork's sibling, an accept still waiting), the performance the step is: named by +// a terminate, it ends before doing anything (endPending). Lowest token ID first. +func (e *ActionExecutor) beginPending(parent *actionFrame, node ast.Node) ([]*actionFrame, error) { + if parent == nil { + return nil, nil + } + var parked []int64 + for _, token := range e.tokens { + if token.frame == parent && token.Location == node && token.body == nil { + parked = append(parked, token.ID) + } + } + slices.Sort(parked) + var begun []*actionFrame + for _, id := range parked { + perf, err := e.beginPerformance(parent, e.graphOf(parent), node, nil) + if err != nil { + return nil, err + } + perf.heldAt = id + begun = append(begun, perf) + } + return begun, nil +} + +// performed returns the performances the token's paused step holds: the node it steps, +// then each node a body statement of it was performing when it paused, outermost first. +func (t Token) performed() []*actionFrame { + if t.body == nil { + return nil + } + var held []*actionFrame + if w, ok := t.body.work.(*usageWork); ok { + held = append(held, w.perf) + } + cursor := t.body.cursor + for i := len(cursor) - 1; i >= 0; i-- { + if f, ok := cursor[i].(*performFrame); ok && f.perf != nil { + held = append(held, f.perf) + } + } + return held +} + +// nestedIn reports whether f is perf or a performance nested in it. +func (f *actionFrame) nestedIn(perf *actionFrame) bool { + for g := f; g != nil; g = g.parent { + if g == perf { + return true + } + } + return false +} + +// endTerminatedFor ends the performance err unwinds to at the step of token id, unless +// a body statement drives that step and the unwinding goes on to it. +func (e *ActionExecutor) endTerminatedFor(id int64, err error) error { + t := unwound(err) + if t == nil { + return err + } + if t.perf == nil { + return e.endedByOccurrence(t, err) + } + idx := e.tokenIndex(id) + if idx < 0 { + return err + } + for f := e.tokens[idx].frame; f != nil; f = f.parent { + if f.inBody { + return err + } + if f == t.perf { + if err := e.endAround(idx, t.perf); err != nil { + return err + } + return e.endAlongside(t) + } + } + return fmt.Errorf("%w: token %d is not running in %s", ErrTerminateTarget, id, t.perf.describe()) +} + +// endAlongside ends in place, in the order named, the performances the terminate t +// unwound for named after the one it ended. +func (e *ActionExecutor) endAlongside(t *terminated) error { + for _, target := range t.then { + if err := e.endOther(target); err != nil { + return err + } + } + return nil +} + +// unwound returns the terminate err unwinds for, nil for any other error or none. +func unwound(err error) *terminated { + var t *terminated + if errors.As(err, &t) { + return t + } + return nil +} + +// endAround ends perf from the token at tokenIdx running in it: the other tokens are +// dropped and this one leaves, completing perf's node; the root ends the action. +func (e *ActionExecutor) endAround(tokenIdx int, perf *actionFrame) error { + id := e.tokens[tokenIdx].ID + e.dropTokensIn(perf, id) + if perf == e.root { + e.removeToken(e.tokenIndex(id)) + e.state = StateCompleted + e.ctx.endPerformanceLife(e.occurrence) + return nil + } + return e.leaveTerminated(e.tokenIndex(id), perf) +} + +// endOther ends the ongoing perf a terminate outside it named: one token of it leaves, +// completing its node in the flow around it. +func (e *ActionExecutor) endOther(perf *actionFrame) error { + if perf.heldAt != 0 { + return e.endPending(e.tokenIndex(perf.heldAt), perf) + } + if perf.graph != nil && !perf.inBody { + inside := e.tokensIn(perf) + if len(inside) == 0 { + return fmt.Errorf("%w: no token runs in %s", ErrTerminateTarget, perf.describe()) + } + keep := e.tokens[inside[0]].ID + for _, idx := range inside[1:] { + keep = min(keep, e.tokens[idx].ID) + } + e.dropTokensIn(perf, keep) + return e.leaveTerminated(e.tokenIndex(keep), perf) + } + // A leaf node's performance outlives a step only paused: the token at the node holds + // it, stepping the node or performing it from a statement of the body it runs. + for i := range e.tokens { + token := &e.tokens[i] + if token.body == nil { + continue + } + if w, ok := token.body.work.(*usageWork); ok && w.perf == perf { + token.body.end(e.ctx) + token.body = nil + e.dropTokensIn(perf, 0) + if err := e.endPerformance(perf); err != nil { + return err + } + return e.completeNode(i, perf) + } + if token.body.endPerformed(e.ctx, perf) { + e.dropTokensIn(perf, 0) + perf.live = 0 + return e.endPerformance(perf) + } + } + return fmt.Errorf("%w: %s is performed by a body statement, which a terminate outside it cannot end", + ErrTerminateTarget, perf.describe()) +} + +// endPending ends perf, whose step the token parked at its node had yet to begin or +// was waiting at: the token goes on from the node as from a performance that ended. +func (e *ActionExecutor) endPending(tokenIdx int, perf *actionFrame) error { + if tokenIdx < 0 { + return fmt.Errorf("%w: no token is parked at %s", ErrTerminateTarget, perf.describe()) + } + token := &e.tokens[tokenIdx] + if tr := e.trace(); tr != nil { + tr.RecordActionTerminatePending(perf.describe(), token.Wait != nil) + } + token.Wait = nil + perf.live, perf.heldAt = 0, 0 + if err := e.endPerformance(perf); err != nil { + return err + } + return e.completeNode(tokenIdx, perf) +} + +// leaveTerminated takes the token at tokenIdx out of the ended perf to perf's node in +// the flow around it and completes the node, as leaveSubflow does. +func (e *ActionExecutor) leaveTerminated(tokenIdx int, perf *actionFrame) error { + token := &e.tokens[tokenIdx] + if token.body != nil { + token.body.end(e.ctx) + token.body = nil + } + for f := token.frame; f != perf; f = f.parent { + f.ended, f.live = true, 0 + } + perf.live = 0 + token.frame = perf.parent + token.Location = perf.node + token.Via = lower.ActionEdge{} + token.Wait = nil + if tr := e.trace(); tr != nil { + tr.RecordActionNodeExit(ActionNodeName(perf.node)) + } + if err := e.endPerformance(perf); err != nil { + return err + } + return e.completeNode(tokenIdx, perf) +} + +// dropTokensIn drops every token in perf's flow and the flows nested in it but the one +// with ID keep, lowest ID first, ending their paused work; the trace records the drops. +func (e *ActionExecutor) dropTokensIn(perf *actionFrame, keep int64) { + var dropped []Token + for _, idx := range e.tokensIn(perf) { + if e.tokens[idx].ID != keep { + dropped = append(dropped, e.tokens[idx]) + } + } + slices.SortFunc(dropped, func(a, b Token) int { return cmp.Compare(a.ID, b.ID) }) + for _, token := range dropped { + if token.body != nil { + token.body.end(e.ctx) + } + for f := token.frame; f != nil && f != perf; f = f.parent { + f.ended, f.live = true, 0 + } + e.removeToken(e.tokenIndex(token.ID)) + } + if tr := e.trace(); tr != nil { + tr.RecordActionTerminate(perf.describe(), dropped) + } +} diff --git a/internal/core/runtime/adopt.go b/internal/exec/runtime/adopt.go similarity index 99% rename from internal/core/runtime/adopt.go rename to internal/exec/runtime/adopt.go index 7010ede0d1..bfffd18e76 100644 --- a/internal/core/runtime/adopt.go +++ b/internal/exec/runtime/adopt.go @@ -6,9 +6,9 @@ import ( "sort" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // Shapes records the resolved shape of every type a set of objects was @@ -224,7 +224,7 @@ func carriedObject(v Value) (int64, bool) { // connectorFeatureValue reports whether the feature value holds the object of a connector, whose // ends a new context attaches again rather than keeping what they read before. func (ctx *Context) connectorFeatureValue(s *FeatureValue) bool { - return s.Feature != nil && ctx.model.semantics.IsConnectorUsage(s.Feature.Symbol) + return s.Feature != nil && ctx.model.semantics.IsConnectorObjectUsage(s.Feature.Symbol) } // HoldsObject reports whether the value is, or carries, an object of this context: diff --git a/internal/core/runtime/adopt_frame.go b/internal/exec/runtime/adopt_frame.go similarity index 98% rename from internal/core/runtime/adopt_frame.go rename to internal/exec/runtime/adopt_frame.go index 25fe16eba0..714c1c1602 100644 --- a/internal/core/runtime/adopt_frame.go +++ b/internal/exec/runtime/adopt_frame.go @@ -1,6 +1,6 @@ package runtime -import "github.com/Open-MBEE/OpenSysML/internal/core/symbols" +import "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" // planFrame rebinds what a frame itself names — its declaration, its type and the // units of its axes — so a frame read before the re-analysis keeps its identity. diff --git a/internal/core/runtime/adopt_test.go b/internal/exec/runtime/adopt_test.go similarity index 99% rename from internal/core/runtime/adopt_test.go rename to internal/exec/runtime/adopt_test.go index fc64c6b0f2..6990387878 100644 --- a/internal/core/runtime/adopt_test.go +++ b/internal/exec/runtime/adopt_test.go @@ -8,12 +8,12 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) const adoptSrc = `package Demo { @@ -978,7 +978,7 @@ func crateContextOver(t *testing.T, lib string, vouch bool) *Context { idx.AddDocument("", parser.New(source.New("", []byte(crateSrc))).ParseFile()) idx.ExpandWildcardImports() resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) ctx.Model().RegisterSource(source.New("", []byte(crateSrc))) ctx.Model().RegisterSource(source.New("Shapes.sysml", []byte(lib))) return ctx @@ -1427,7 +1427,7 @@ func documentContextOver(t *testing.T, src string) (*Context, *symbols.Scope) { scope := symbols.Build(file) symbols.SetDocName(scope, "") resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) ctx.Model().RegisterSource(source.New("", []byte(src))) ctx.Model().RegisterScope(scope) return ctx, scope diff --git a/internal/core/runtime/advance.go b/internal/exec/runtime/advance.go similarity index 90% rename from internal/core/runtime/advance.go rename to internal/exec/runtime/advance.go index 7561a5d950..602dc3e975 100644 --- a/internal/core/runtime/advance.go +++ b/internal/exec/runtime/advance.go @@ -5,7 +5,7 @@ import ( "math" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // clockWaiter is an executor the shared clock drives: it registers its waits on @@ -85,6 +85,12 @@ type AdvanceReport struct { // instant by instant; a wait due later stays queued and nothing waiting is no error. // The duration and the instant it leads to must be finite. func (ctx *Context) Advance(duration float64) (AdvanceReport, error) { + return ctx.AdvanceUntil(duration, nil) +} + +// AdvanceUntil is Advance stopping early, the clock held at the instant whose +// due work made halted true — a debugger's breakpoint — before moving on. +func (ctx *Context) AdvanceUntil(duration float64, halted func() bool) (AdvanceReport, error) { defer ctx.beginExecutorRun(&ctx.clockRun)() report := AdvanceReport{From: ctx.clock.now, To: ctx.clock.now} @@ -98,18 +104,29 @@ func (ctx *Context) Advance(duration float64) (AdvanceReport, error) { noted := ctx.run.NoteCount() var progress dueProgress for { - if _, err := ctx.runDue(nil, &progress); err != nil { - report.To = ctx.clock.now - return report.counting(progress, ctx.run.notes[noted:]), err + // One executor at a time, so a halt is seen before the next due one runs. + for { + ran, _, err := ctx.stepDue(nil, &progress) + if err != nil { + report.To = ctx.clock.now + return report.counting(progress, ctx.run.notes[noted:]), err + } + if halted != nil && halted() { + report.To = ctx.clock.now + return report.counting(progress, ctx.run.notes[noted:]), nil + } + if !ran { + break + } } next, ok := ctx.clock.NextDue() if !ok || next > deadline { break } - ctx.clock.now = next + ctx.setClock(next) progress.unsettle() } - ctx.clock.now = deadline + ctx.setClock(deadline) report.To = deadline return report.counting(progress, ctx.run.notes[noted:]), ctx.advanceEnded() } @@ -138,7 +155,7 @@ func (ctx *Context) advanceToNextDue(progress *dueProgress) bool { if !ok { return false } - ctx.clock.now = next + ctx.setClock(next) progress.unsettle() return true } diff --git a/internal/exec/runtime/analysis.go b/internal/exec/runtime/analysis.go new file mode 100644 index 0000000000..5bd5bcceca --- /dev/null +++ b/internal/exec/runtime/analysis.go @@ -0,0 +1,502 @@ +package runtime + +import ( + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// Qualified names of the trade-study objective definitions whose specialization +// states which way an objective's value is to be improved (SysML v2 §8.3.22.4). +const ( + minimizeObjectiveFQN = "TradeStudies::MinimizeObjective" + maximizeObjectiveFQN = "TradeStudies::MaximizeObjective" +) + +// An objective states the value it improves by redefining the trade-study +// library's `eval` calculation, from which the library derives its bound `best`. +const ( + tradeStudyObjectiveFQN = "TradeStudies::TradeStudyObjective" + objectiveEvalName = "eval" + objectiveBestName = "best" +) + +// ObjectiveDirection is the way an objective's value is to be improved, taken +// from the trade-study objective definition it is typed by. +type ObjectiveDirection int + +const ( + // NoDirection is an objective specializing neither MinimizeObjective nor + // MaximizeObjective, whose direction the model therefore does not state. + NoDirection ObjectiveDirection = iota + // Minimize is an objective specializing TradeStudies::MinimizeObjective. + Minimize + // Maximize is an objective specializing TradeStudies::MaximizeObjective. + Maximize +) + +// String names the direction as the model states it. +func (d ObjectiveDirection) String() string { + switch d { + case Minimize: + return "minimize" + case Maximize: + return "maximize" + default: + return "unstated" + } +} + +// Objective is one objective an analysis case states: which way its value is to +// be improved, the expression stating that value, and the conditions it states +// itself. +type Objective struct { + // Name is the objective's name, empty for an anonymous one. + Name string + + // Symbol is the objective usage's symbol. + Symbol *symbols.Symbol + + // Type is the objective definition it is typed by, nil when untyped. + Type *symbols.Symbol + + // Direction is the way its value is to be improved. + Direction ObjectiveDirection + + // Value is the expression stating the value to improve, nil when the + // objective states none. + Value ast.Node + + // Scope is where Value's names resolve. + Scope *symbols.Scope + + // Eval is the calc redefining the library's `eval` that states Value, nil + // when the value is stated another way or not at all. + Eval *symbols.Symbol + + // StepwiseEval reports an `eval` whose body computes in steps rather than + // stating one expression, so no Value is read from it. + StepwiseEval bool + + // Evaluates is the calc `eval` is bound to hold (`in calc :>> eval = + // evaluationFunction;`), applied to each alternative the case's subject + // lists; nil when `eval` states an expression or nothing. + Evaluates *symbols.Symbol + + // Best is the objective's `best` feature, derived by the library from Eval; + // nil when the objective is no trade-study objective. + Best *symbols.Symbol + + // ReboundBest is a feature giving the library's bound `best` a value of its + // own (`attribute :>> best = expression;`), which validation rejects; or nil. + ReboundBest *symbols.Symbol + + // Conditions are the conditions the objective states itself, its own body's + // and the ones it inherits from the model's own objective definitions: what + // values are feasible, which a solver translates. + Conditions []Condition + + // LibraryConditions are the conditions the objective inherits from the + // trade-study library (`eval(selectedAlternative) == best`): about the + // choice among the listed alternatives, which the analysis run checks and + // no solver translates. + LibraryConditions []Condition +} + +// Text renders the expression stating the objective's value as written, empty +// when it states none. +func (o Objective) Text() string { + if o.Value == nil { + return "" + } + return conditionText(o.Value) +} + +// RequireAnalysis returns an ErrNotAnAnalysis usage error unless sym declares an +// analysis case, so a caller can settle the kind before asking for objectives. +func RequireAnalysis(sym *symbols.Symbol) error { + switch decl := sym.Decl.(type) { + case *ast.Definition: + if decl.Kind == ast.DefAnalysisCase { + return nil + } + case *ast.Usage: + if decl.Kind == ast.UsageAnalysisCase { + return nil + } + } + return notOfKind(ErrNotAnAnalysis, sym, "analysis case") +} + +// ObjectivesOf returns the objectives sym states, its inherited ones first and +// in declaration order. An objective restating an inherited one, by name or by +// redefinition, is the same objective declared again and keeps its place. +func (ctx *Context) ObjectivesOf(sym *symbols.Symbol, scope *symbols.Scope) []Objective { + if sym == nil { + return nil + } + if scope == nil { + scope = sym.OwnerScope + } + var out []Objective + for _, member := range ctx.chainMembers(sym, scope) { + usage, ok := member.node.(*ast.Usage) + if !ok || usage.Kind != ast.UsageObjective { + continue + } + objSym := memberSymbol(member.scope, member.node) + if objSym == nil { + continue + } + out = ctx.placeObjective(out, ctx.objectiveOf(objSym, sym)) + } + return out +} + +// placeObjective adds obj to out: in place of the first objective it restates, +// nowhere when it is already placed or redefined by a placed one, at the end otherwise. +func (ctx *Context) placeObjective(out []Objective, obj Objective) []Objective { + for i, prev := range out { + if prev.Symbol == obj.Symbol || ctx.redefines(prev, obj) { + return out + } + if ctx.redefines(obj, prev) || (obj.Name != "" && obj.Name == prev.Name) { + out[i] = obj + return out + } + } + return append(out, obj) +} + +// redefines reports whether obj redefines prev, by clause, position or role. +func (ctx *Context) redefines(obj, prev Objective) bool { + return slices.Contains(ctx.model.semantics.AllRedefinedFeatures(obj.Symbol), prev.Symbol) +} + +// objectiveOf reads one objective usage: its direction from the definition it is +// typed by, its value from the `eval` calculation it redefines, and its +// conditions from its own body. owner is the case stating it, which is where a +// value it takes from a redeclared objective is looked for. +func (ctx *Context) objectiveOf(objSym, owner *symbols.Symbol) Objective { + typ := ctx.extractType(objSym) + obj := Objective{ + Name: ctx.model.semantics.EffectiveNameOf(objSym), + Symbol: objSym, + Type: typ, + Direction: ctx.objectiveDirection(typ), + Scope: objSym.OwnerScope, + } + obj.Conditions, obj.LibraryConditions = ctx.objectiveConditionsOf(objSym) + tradeStudy := ctx.specializesLibraryType(typ, tradeStudyObjectiveFQN) + if tradeStudy { + obj.ReboundBest = ctx.reboundBestOf(objSym) + if best, ok := ctx.model.semantics.LookupMember(objSym, objectiveBestName); ok { + obj.Best = best + } + } + if obj.Value = ctx.extractDefaultValue(objSym); obj.Value != nil { + return obj + } + if tradeStudy && ctx.readEvalValue(&obj, objSym) { + return obj + } + if value, decl := ctx.redefinedDefault(objSym, owner); value != nil { + obj.Value, obj.Scope = value, decl.OwnerScope + return obj + } + // An objective restating another takes the value the restated one states. + for _, restated := range ctx.restatedObjectives(objSym, owner) { + if obj.ReboundBest == nil { + obj.ReboundBest = ctx.reboundBestOf(restated) + } + if tradeStudy && ctx.readEvalValue(&obj, restated) { + return obj + } + } + return obj +} + +// restatedObjectives returns what objSym restates as seen from owner, nearest +// first: its clause's targets, then positional and role redefinitions, transitively. +func (ctx *Context) restatedObjectives(objSym, owner *symbols.Symbol) []*symbols.Symbol { + seen := map[*symbols.Symbol]bool{objSym: true} + var out []*symbols.Symbol + add := func(syms []*symbols.Symbol) { + for _, sym := range syms { + if !seen[sym] { + seen[sym] = true + out = append(out, sym) + } + } + } + add(ctx.relatedFeatures(objSym, owner, ast.RelRedefines)) + for i := 0; i < len(out); i++ { + add(ctx.model.semantics.AllRedefinedFeatures(out[i])) + } + add(ctx.model.semantics.AllRedefinedFeatures(objSym)) + return out +} + +// readEvalValue reads the objective's value from its own `eval`: the calc it is +// bound to hold, else its lowered body's one returned expression or a stepwise +// mark. False when it states none. +func (ctx *Context) readEvalValue(obj *Objective, objSym *symbols.Symbol) bool { + evalSym := ctx.objectiveMember(objSym, objectiveEvalName) + if evalSym == nil || evalSym.Kind != symbols.SymbolCalcUsage { + return false + } + obj.Eval = evalSym + if calc := ctx.calcHeldBy(evalSym); calc != nil { + obj.Evaluates = calc + return true + } + body := bodyScope(evalSym, evalSym.OwnerScope) + stmts := lower.CalcBodyWith(evalSym.Decl, unwrappedDeclMembers(evalSym.Decl), body, ctx.Resolver()) + if len(stmts) == 1 { + if ret, ok := stmts[0].(lower.Return); ok && ret.Value != nil { + obj.Value, obj.Scope = ret.Value, ret.Scope + return true + } + } + obj.StepwiseEval = len(stmts) > 0 + return obj.StepwiseEval +} + +// calcHeldBy returns the calc a calc-typed feature is bound to hold by naming +// it as its value (`= evaluationFunction`), nil when its value is anything else. +func (ctx *Context) calcHeldBy(featureSym *symbols.Symbol) *symbols.Symbol { + value := ctx.extractDefaultValue(featureSym) + if ref, ok := value.(*ast.FeatureReference); ok { + value = ref.Name + } + qn, ok := value.(*ast.QualifiedName) + if !ok { + return nil + } + sym := ctx.resolveTypeRef(featureSym.OwnerScope, qn) + if sym == nil || (sym.Kind != symbols.SymbolCalcDef && sym.Kind != symbols.SymbolCalcUsage) { + return nil + } + return sym +} + +// reboundBestOf returns the member of objSym's own body giving the library's +// `best` a value of its own, nil when there is none. +func (ctx *Context) reboundBestOf(objSym *symbols.Symbol) *symbols.Symbol { + best := ctx.objectiveMember(objSym, objectiveBestName) + if best == nil || ctx.extractDefaultValue(best) == nil { + return nil + } + return best +} + +// objectiveMember returns the member of objSym's own body redefining the +// trade-study feature of the given name, or nil. +func (ctx *Context) objectiveMember(objSym *symbols.Symbol, name string) *symbols.Symbol { + if objSym == nil { + return nil + } + body := bodyScope(objSym, objSym.OwnerScope) + if body == nil { + return nil + } + for _, node := range unwrappedDeclMembers(objSym.Decl) { + member := memberSymbol(body, node) + if member != nil && restatesFeatureNamed(member, name) { + return member + } + } + return nil +} + +// restatesFeatureNamed reports whether sym redefines a feature of the given +// name, whichever way the redefinition names it. +func restatesFeatureNamed(sym *symbols.Symbol, name string) bool { + for _, rel := range semantics.RelationshipsOf(sym) { + if rel == nil || rel.Kind != ast.RelRedefines || rel.Target == nil { + continue + } + target := rel.Target + if ref, ok := target.(*ast.FeatureReference); ok { + target = ref.Name + } + qn, ok := target.(*ast.QualifiedName) + if !ok || len(qn.Parts) == 0 { + continue + } + if qn.Parts[len(qn.Parts)-1].Text == name { + return true + } + } + return false +} + +// specializesLibraryType reports whether typ is the library type the qualified +// name states, or specializes it, matched by identity so a type merely named +// alike is not one. +func (ctx *Context) specializesLibraryType(typ *symbols.Symbol, fqn string) bool { + if typ == nil || ctx.model.resolver == nil || ctx.model.resolver.Index() == nil { + return false + } + stated := make(map[*symbols.Symbol]bool, 1) + for _, sym := range ctx.model.resolver.Index().LookupQualified(fqn) { + if sym != nil { + stated[sym] = true + } + } + if stated[typ] { + return true + } + for _, super := range ctx.model.semantics.AllSupertypes(typ) { + if stated[super] { + return true + } + } + return false +} + +// objectiveDirection classifies an objective definition against the trade-study +// library: the nearest of MinimizeObjective and MaximizeObjective it +// specializes, matched by identity so a type merely named alike is not one. +func (ctx *Context) objectiveDirection(typ *symbols.Symbol) ObjectiveDirection { + if typ == nil || ctx.model.resolver == nil || ctx.model.resolver.Index() == nil { + return NoDirection + } + idx := ctx.model.resolver.Index() + directions := make(map[*symbols.Symbol]ObjectiveDirection, 2) + for fqn, direction := range map[string]ObjectiveDirection{ + minimizeObjectiveFQN: Minimize, + maximizeObjectiveFQN: Maximize, + } { + for _, sym := range idx.LookupQualified(fqn) { + if sym != nil { + directions[sym] = direction + } + } + } + if direction, ok := directions[typ]; ok { + return direction + } + // AllSupertypes is breadth-first over declaration order, so the nearest + // trade-study ancestor is found first. + for _, super := range ctx.model.semantics.AllSupertypes(typ) { + if direction, ok := directions[super]; ok { + return direction + } + } + return NoDirection +} + +// objectiveConditionsOf returns the conditions an objective states or inherits +// from the model's definitions, and apart from them those the library states. +func (ctx *Context) objectiveConditionsOf(sym *symbols.Symbol) (model, library []Condition) { + if sym == nil { + return nil, nil + } + // An inherited condition is read where it is inherited: the objective's own + // body, where the `best` it inherits answers that name. + body := bodyScope(sym, sym.OwnerScope) + var members, libraryMembers []scopedMember + supers := ctx.model.semantics.AllSupertypes(sym) + for i := len(supers) - 1; i >= 0; i-- { + link := supers[i] + if link == nil || ctx.frameDeclared(link) { + continue + } + for _, node := range unwrappedDeclMembers(link.Decl) { + if ctx.libraryDeclared(link) { + libraryMembers = append(libraryMembers, scopedMember{node: node, scope: body}) + } else { + members = append(members, scopedMember{node: node, scope: body}) + } + } + } + for _, node := range unwrappedDeclMembers(sym.Decl) { + members = append(members, scopedMember{node: node, scope: body}) + } + return ctx.conditionsOf(sym, members), ctx.conditionsOf(sym, libraryMembers) +} + +// CaseConditionsOf returns the conditions a case states as what it holds true of +// its parameters: the conditions its own members state, and the ones stated by +// the constraints it requires, assumes or asserts in its body. A constraint +// declared without one of those keywords states nothing the case checks, so it +// is left out. +func (ctx *Context) CaseConditionsOf(sym *symbols.Symbol, scope *symbols.Scope) []Condition { + if sym == nil { + return nil + } + if scope == nil { + scope = sym.OwnerScope + } + out := ctx.appendResultConflict(nil, sym, true) + for _, member := range ctx.chainMembers(sym, scope) { + // A case's own steps are its procedure, not a statement its conditions miss. + if _, step := statementKeyword(member.node); step { + continue + } + out = ctx.appendConditions(out, member.node, member.scope, true, false, nil) + out = ctx.appendCheckedConstraint(out, member) + } + return out +} + +// appendCheckedConstraint appends the conditions a nested constraint usage +// states when the case requires, assumes, asserts or states it as an invariant. +// A negation it wrote negates what its conditions state together (De Morgan), +// as a negated constraint body does. +func (ctx *Context) appendCheckedConstraint(out []Condition, member scopedMember) []Condition { + usage, ok := member.node.(*ast.Usage) + if !ok || usage.Kind != ast.UsageConstraint { + return out + } + // The keyword sits ahead of a kind keyword (`require constraint { … }`) or + // stands for the kind itself (`require c : C`, `inv { … }`). + keyword := usage.PrefixKeyword + if keyword == "" { + keyword = usage.Keyword + } + var required bool + switch keyword { + case "require", "assert", "inv": + required = true + case "assume": + required = false + default: + return out + } + sym := memberSymbol(member.scope, member.node) + if sym == nil { + return out + } + conds := ctx.ConditionsOf(sym, nil) + for i := range conds { + conds[i].Required = required + } + if !usage.IsNegated { + return append(out, conds...) + } + if len(conds) == 1 { + only := conds[0] + only.Negated = !only.Negated + return append(out, only) + } + return append(out, Condition{Group: conds, Negated: true, Required: required}) +} + +// memberSymbol returns the symbol scope declares for node, anonymous ones +// included, or nil when there is none. +func memberSymbol(scope *symbols.Scope, node ast.Node) *symbols.Symbol { + if scope == nil || node == nil { + return nil + } + for _, member := range scope.AllMembers() { + if member.Decl == node { + return member + } + } + return nil +} diff --git a/internal/exec/runtime/analysis_inputs_test.go b/internal/exec/runtime/analysis_inputs_test.go new file mode 100644 index 0000000000..6759b09838 --- /dev/null +++ b/internal/exec/runtime/analysis_inputs_test.go @@ -0,0 +1,295 @@ +package runtime + +import ( + "reflect" + "strings" + "testing" +) + +// The inputs a run reports are the values its parameters were bound to, in +// declaration order: arguments by position or by name, a default evaluated +// where none was given, and the subject parameter never among them. +func TestAnalysisResultInputsAreTheBoundParameters(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + part def Probe; + individual def probe :> Probe; + analysis def Check { + subject s : Probe; + in burnTime : Real; + in margin : Real = 1.5; + in flag : Boolean = true; + out fuelUsed : Real = burnTime + margin; + } + }`) + sym := requirementNamed(t, scope, "Check") + probe, err := ctx.Instantiate(requirementNamed(t, scope, "probe")) + if err != nil { + t.Fatal(err) + } + + textOf := func(inputs []InputBinding) []string { + out := make([]string, len(inputs)) + for i, in := range inputs { + out[i] = in.Name + "=" + FormatValue(in.Value) + } + return out + } + + result, err := ctx.RunAnalysis(sym, AnalysisArgs{ + Subject: probe, + Positional: []Value{constValue(drawnReal(3.0))}, + }, scope, nil) + if err != nil { + t.Fatal(err) + } + want := []string{"burnTime=3.0", "margin=1.5", "flag=true"} + if got := textOf(result.Inputs); !reflect.DeepEqual(got, want) { + t.Errorf("inputs %v, want %v", got, want) + } + + result, err = ctx.RunAnalysis(sym, AnalysisArgs{ + Subject: probe, + Named: map[string]Value{"margin": constValue(drawnReal(0.25)), "burnTime": constValue(drawnReal(4.0))}, + }, scope, nil) + if err != nil { + t.Fatal(err) + } + want = []string{"burnTime=4.0", "margin=0.25", "flag=true"} + if got := textOf(result.Inputs); !reflect.DeepEqual(got, want) { + t.Errorf("inputs %v, want %v", got, want) + } +} + +// A sweep row carries the inputs its run bound, the row's own overlay included. +func TestSweepRowCarriesTheRunsInputs(t *testing.T) { + ctx, _ := analysisFixture(t, `package test { calc def Idle { return k : Integer = 0; } }`) + bound := []InputBinding{{Name: "n", Value: constValue(drawnReal(7.0))}} + row := runSweepRow(ctx, []SweepBinding{{Param: "n", Value: constValue(drawnReal(7.0))}}, + func(*Context, []SweepBinding) (SweepRunResult, error) { + return SweepRunResult{Inputs: bound}, nil + }) + if !reflect.DeepEqual(row.Inputs, bound) { + t.Errorf("row inputs %v, want %v", row.Inputs, bound) + } +} + +// A Monte Carlo run reports the inputs its iteration bound and every declared +// output of that iteration, `observed` appended when it declares no output. +func TestMonteCarloRunInputsAndIterationOutputs(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + part def Probe { + attribute t : Real; + action settle { first start; then assign t := uniform(1.0, 5.0); then done; } + } + individual def probe :> Probe; + analysis def Mc :> Simulation::MonteCarlo { + subject analysed : Probe; + in gain : Real = 2.0; + perform action run ::> analysed.settle; + attribute :>> observed : Real = analysed.t; + return Mean : Real = mean; + } + }`) + sym := requirementNamed(t, scope, "Mc") + probe, err := ctx.Instantiate(requirementNamed(t, scope, "probe")) + if err != nil { + t.Fatal(err) + } + ctx.SetModelSeed(RunSeed(1, 1)) + run, err := ctx.ObserveMonteCarlo(sym, AnalysisArgs{Subject: probe}, scope, nil) + if err != nil { + t.Fatal(err) + } + if len(run.Inputs) != 1 || run.Inputs[0].Name != "gain" { + t.Fatalf("inputs %+v, want the one binding of gain", run.Inputs) + } + var names []string + for _, out := range run.Outputs { + names = append(names, out.Name) + } + // The statistics are bound over the sample, so the one output this + // iteration establishes is the observed value; the return reading a + // statistic is unread until the conclusion supplies it. + want := []string{"observed"} + if !reflect.DeepEqual(names, want) { + t.Errorf("outputs %v, want %v", names, want) + } + if len(run.Unread) != 0 { + t.Errorf("the stat-bound return is the sample's, not unread: %v", run.Unread) + } +} + +// An output erroring for its own reason — not the statistics the sample has +// not supplied — is reported on the run, which still completes and observes. +func TestMonteCarloRunReportsAnOutputError(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + part def Probe { + attribute t : Real; + action settle { first start; then assign t := uniform(1.0, 5.0); then done; } + } + individual def probe :> Probe; + analysis def Mc :> Simulation::MonteCarlo { + subject analysed : Probe; + perform action run ::> analysed.settle; + attribute :>> observed : Real = analysed.t; + return Mean : Real = mean; + out Bad : Real = 1.0 / 0.0; + } + } + `) + sym := requirementNamed(t, scope, "Mc") + probe, err := ctx.Instantiate(requirementNamed(t, scope, "probe")) + if err != nil { + t.Fatal(err) + } + ctx.SetModelSeed(RunSeed(1, 1)) + run, err := ctx.ObserveMonteCarlo(sym, AnalysisArgs{Subject: probe}, scope, nil) + if err != nil { + t.Fatal(err) + } + if run.Unread["Bad"] == nil { + t.Error("an output erroring for its own reason vanished") + } + if run.Observed.Kind != ValConst { + t.Errorf("the run's observed is invalid: %v", run.Observed) + } +} + +// The outputs an observation captures are read in a probe: capturing them +// draws nothing, so the conclusion over the sample — and the draws taken to +// make it — are the ones a capture-free observation makes, whether or not an +// output is a random draw of its own. +func TestMonteCarloIterationOutputsMemoizeNothing(t *testing.T) { + sample := func(extraOutput string) (AnalysisResult, []DrawTaken, error) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + part def Probe { + attribute t : Real; + action settle { first start; then assign t := uniform(1.0, 5.0); then done; } + } + individual def probe :> Probe; + analysis def Mc :> Simulation::MonteCarlo { + subject analysed : Probe; + perform action run ::> analysed.settle; + attribute :>> observed : Real = analysed.t; + return Mean : Real = mean;`+extraOutput+` + } + }`) + sym := requirementNamed(t, scope, "Mc") + probe, err := ctx.Instantiate(requirementNamed(t, scope, "probe")) + if err != nil { + t.Fatal(err) + } + ctx.SetModelSeed(RunSeed(1, 1)) + var runs []*MonteCarloRun + for i := int64(1); i <= 3; i++ { + run, err := ctx.ObserveMonteCarlo(sym, AnalysisArgs{Subject: probe}, scope, nil) + if err != nil { + t.Fatal(err) + } + if len(run.run.outputs) != 0 { + t.Fatalf("capturing the outputs memoized into the run: %v", run.run.outputs) + } + runs = append(runs, run) + } + stats, err := MonteCarloSample(runs) + if err != nil { + t.Fatal(err) + } + res, err := ConcludeMonteCarlo(runs, stats) + var observed []DrawTaken + for _, d := range ctx.DrawsTaken() { + if strings.HasPrefix(d.What, "uniform(1.0") { + observed = append(observed, d) + } + } + return res, observed, err + } + + plain, drawsPlain, err := sample("") + if err != nil { + t.Fatal(err) + } + drawn, drawsDrawn, err := sample(` + out Again : Real = uniform(0.0, 1.0);`) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(drawsDrawn, drawsPlain) { + t.Errorf("capturing a draw-made output moved the sample's draws: %v vs %v", drawsDrawn, drawsPlain) + } + for i, out := range plain.Outputs { + if i < len(drawn.Outputs) && drawn.Outputs[i].Name == out.Name && FormatValue(drawn.Outputs[i].Value) != FormatValue(out.Value) { + t.Errorf("output %s changed: %v vs %v", out.Name, drawn.Outputs[i].Value, out.Value) + } + } +} + +// An output that reads a statistic is the sample's, as surely as the +// statistic is: the rows carry neither it nor an error for it. An output that +// fails for a reason of its own is in Unread. +func TestMonteCarloStatBoundOutputsAreTheSamples(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + part def Probe { + attribute t : Real; + action settle { first start; then assign t := uniform(1.0, 5.0); then done; } + } + individual def probe :> Probe; + analysis def Mc :> Simulation::MonteCarlo { + subject analysed : Probe; + perform action run ::> analysed.settle; + attribute :>> observed : Real = analysed.t; + return Mean : Real = mean; + out Dev : Real = deviation + 1.0; + out Half : Real = Mean / 2.0; + out Bad : Real; + out Ratio : Real = 1.0 / (analysed.t - analysed.t); + } + }`) + sym := requirementNamed(t, scope, "Mc") + probe, err := ctx.Instantiate(requirementNamed(t, scope, "probe")) + if err != nil { + t.Fatal(err) + } + ctx.SetModelSeed(RunSeed(1, 1)) + var runs []*MonteCarloRun + for i := 0; i < 2; i++ { + run, err := ctx.ObserveMonteCarlo(sym, AnalysisArgs{Subject: probe}, scope, nil) + if err != nil { + t.Fatal(err) + } + runs = append(runs, run) + } + for _, run := range runs { + for _, out := range run.Outputs { + for _, statBound := range []string{"Mean", "Dev", "Half"} { + if out.Name == statBound { + t.Errorf("run %d carries the sample's %s as its own", run.Number, statBound) + } + } + } + for _, statBound := range []string{"Mean", "Dev", "Half"} { + if run.Unread[statBound] != nil { + t.Errorf("run %d's %s is the sample's, not unread: %v", run.Number, statBound, run.Unread[statBound]) + } + } + for _, failed := range []string{"Bad", "Ratio"} { + if run.Unread[failed] == nil { + t.Errorf("run %d's %s failed for its own reason but is not in Unread", run.Number, failed) + } + } + } +} diff --git a/internal/core/runtime/analysis_objective_binding_test.go b/internal/exec/runtime/analysis_objective_binding_test.go similarity index 99% rename from internal/core/runtime/analysis_objective_binding_test.go rename to internal/exec/runtime/analysis_objective_binding_test.go index bebeaa5fa7..234c61fdfc 100644 --- a/internal/core/runtime/analysis_objective_binding_test.go +++ b/internal/exec/runtime/analysis_objective_binding_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // objectiveBindingModel states one requirement def checked as the objective of diff --git a/internal/core/runtime/analysis_robustness_test.go b/internal/exec/runtime/analysis_robustness_test.go similarity index 99% rename from internal/core/runtime/analysis_robustness_test.go rename to internal/exec/runtime/analysis_robustness_test.go index 48f05084c6..76c8caa2b3 100644 --- a/internal/core/runtime/analysis_robustness_test.go +++ b/internal/exec/runtime/analysis_robustness_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // analysisModel is the model the analysis robustness cases run in. diff --git a/internal/core/runtime/analysis_run.go b/internal/exec/runtime/analysis_run.go similarity index 96% rename from internal/core/runtime/analysis_run.go rename to internal/exec/runtime/analysis_run.go index 63a0cfcd91..98eabdd0dd 100644 --- a/internal/core/runtime/analysis_run.go +++ b/internal/exec/runtime/analysis_run.go @@ -5,9 +5,9 @@ import ( "fmt" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // subjectDecl is a case's subject as its body declares it: the object the case @@ -221,6 +221,13 @@ type AnalysisEvaluation struct { Tied bool } +// InputBinding is the value one input parameter of a case was bound to for a +// run: its argument, or the default its declaration evaluated to. +type InputBinding struct { + Name string + Value Value +} + // AnalysisResult is what one run of an analysis case produced: its output // values in declaration order, and the verdict of each objective and assertion. type AnalysisResult struct { @@ -231,6 +238,10 @@ type AnalysisResult struct { // taken from the enclosing case; nil for a case declaring no subject. Subject *Instance + // Inputs are the values the run bound the case's input parameters to, in + // declaration order, the subject parameter excluded: what the body ran with. + Inputs []InputBinding + // Outputs are the case's out and return parameters, in declaration order; // a value the body returned into an unnamed result is named "result". Outputs []CalcOutputValue @@ -289,9 +300,10 @@ func (ctx *Context) runCase(sym *symbols.Symbol, args AnalysisArgs, scope *symbo if asUsage { run, err = ctx.calcUsageRun(reader, sym) } else { - run, err = ctx.analysisRun(shape, reader, calcArgs) + run, err = ctx.analysisRun(shape, reader, calcArgs, false) } if err != nil { + err = ctx.monteCarloUnconcluded(sym, err) result := AnalysisResult{Case: shape.Name, Evaluations: log.evaluations(Value{}, false)} result.Verdicts = ctx.undecidedVerdicts(sym, scope, err) return nil, result, err @@ -299,10 +311,11 @@ func (ctx *Context) runCase(sym *symbols.Symbol, args AnalysisArgs, scope *symbo // The outputs computed before one failed stay reported; the verdicts and the // pick do not, since the case established neither. - result := AnalysisResult{Case: shape.Name, Subject: run.boundSubject(ctx)} + result := AnalysisResult{Case: shape.Name, Subject: run.boundSubject(ctx), Inputs: run.inputs()} outputs, err := run.outputValues(ctx) result.Outputs = outputs if err != nil { + err = ctx.monteCarloUnconcluded(sym, err) result.Verdicts = ctx.undecidedVerdicts(sym, scope, err) result.Evaluations = log.evaluations(Value{}, false) return nil, result, err @@ -341,27 +354,19 @@ func (ctx *Context) undecidedVerdicts(sym *symbols.Symbol, scope *symbols.Scope, // analysisRun binds a case's parameters from the shaped arguments and runs its // body once, unmemoized: arguments make it an invocation of its own, not the -// evaluation the case's outputs answer from when read as features. -func (ctx *Context) analysisRun(shape *calcShape, reader *EvalContext, calcArgs calcArgs) (*calcRun, error) { - if err := ctx.enterCalc(shape.Name); err != nil { - return nil, err - } - defer ctx.leaveCalc() - +// evaluation the case's outputs answer from when read as features. deferResults +// leaves the results ending the body for a Monte Carlo to evaluate over its sample. +func (ctx *Context) analysisRun(shape *calcShape, reader *EvalContext, calcArgs calcArgs, deferResults bool) (*calcRun, error) { key := calcUsageKey{sym: shape.Sym} if reader.self != nil { key.instance = reader.self.ID } - leave, err := ctx.enterCalcUsage(shape, key) - if err != nil { - return nil, err - } - defer leave() - ec, nested, env, err := ctx.bindCalcUsage(shape, reader, calcArgs) + start, err := ctx.startCalcUsage(shape, key, reader, calcArgs) if err != nil { return nil, err } - return ctx.runCalcUsage(shape, ec, nested, env, reader) + start.deferResults = deferResults + return ctx.runCalcUsage(start) } // analysisArgs spells the run's arguments as bindings by parameter name: the diff --git a/internal/exec/runtime/analysis_test.go b/internal/exec/runtime/analysis_test.go new file mode 100644 index 0000000000..58d1241c6e --- /dev/null +++ b/internal/exec/runtime/analysis_test.go @@ -0,0 +1,579 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// analysisFixture builds a runtime over the standard library, which is what makes +// the trade-study objective definitions resolve, and returns the package scope. +func analysisFixture(t *testing.T, src string) (*Context, *symbols.Scope) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + return ctx, pkg.Scope +} + +// objectivesOfCase returns the objectives of the named analysis case. +func objectivesOfCase(t *testing.T, ctx *Context, scope *symbols.Scope, name string) []Objective { + t.Helper() + sym := requirementNamed(t, scope, name) + if err := RequireAnalysis(sym); err != nil { + t.Fatalf("%s: %v", name, err) + } + return ctx.ObjectivesOf(sym, sym.OwnerScope) +} + +// visibleIn reports whether the name is declared in the scope or one enclosing it. +func visibleIn(scope *symbols.Scope, name string) bool { + for ; scope != nil; scope = scope.Parent() { + if _, ok := scope.LookupLocal(name); ok { + return true + } + } + return false +} + +// objectiveLabels renders each objective as its direction, name and value, so one +// comparison covers order, direction and what is improved. +func objectiveLabels(objs []Objective) string { + parts := make([]string, 0, len(objs)) + for _, obj := range objs { + parts = append(parts, obj.Direction.String()+" "+obj.Name+" = "+obj.Text()) + } + return strings.Join(parts, "; ") +} + +// An objective's direction comes from the trade-study definition typing it, and +// the value it improves from the expression its redefinition of the library's +// `eval` calculation returns. Objectives stand in declaration order. +func TestObjectivesOfDirectionValueAndOrder(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Trade { + attribute cost : Integer; + attribute margin : Integer; + objective cheapest : MinimizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { cost } + } + objective widest : MaximizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { margin } + } + } + } + `) + got := objectiveLabels(objectivesOfCase(t, ctx, scope, "Trade")) + want := "minimize cheapest = cost; maximize widest = margin" + if got != want { + t.Errorf("objectives are [%s], want [%s]", got, want) + } +} + +// A definition specializing MinimizeObjective states a direction too, so a +// project's own objective definitions are read as the library's are. +func TestObjectivesOfDirectionThroughSpecialization(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + requirement def LeastMass :> MinimizeObjective; + analysis def Trade { + attribute mass : Integer; + objective lightest : LeastMass { + subject :>> selectedAlternative; + in calc :>> eval { mass } + } + } + } + `) + objs := objectivesOfCase(t, ctx, scope, "Trade") + if len(objs) != 1 || objs[0].Direction != Minimize { + t.Fatalf("objectives are [%s], want a minimizing one", objectiveLabels(objs)) + } + if objs[0].Type == nil || objs[0].Type.Name != "LeastMass" { + t.Errorf("objective is typed by %v, want LeastMass", objs[0].Type) + } +} + +// An objective typed by neither Minimize nor MaximizeObjective states no +// direction: the caller refuses rather than guessing one. +func TestObjectivesOfWithoutDirection(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + requirement def FitsWell :> TradeStudyObjective; + analysis def Trade { + attribute size : Integer; + objective goal : FitsWell { + subject :>> selectedAlternative; + in calc :>> eval { size } + } + } + } + `) + objs := objectivesOfCase(t, ctx, scope, "Trade") + if len(objs) != 1 || objs[0].Direction != NoDirection { + t.Fatalf("objectives are [%s], want one without a direction", objectiveLabels(objs)) + } + if objs[0].Direction.String() != "unstated" { + t.Errorf("the direction reads %q", objs[0].Direction.String()) + } +} + +// An objective stating no value has none: nothing is invented for it. +func TestObjectivesOfWithoutValue(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Trade { + attribute size : Integer; + objective goal : MinimizeObjective; + } + } + `) + objs := objectivesOfCase(t, ctx, scope, "Trade") + if len(objs) != 1 || objs[0].Value != nil || objs[0].Text() != "" { + t.Fatalf("objectives are [%s], want one stating no value", objectiveLabels(objs)) + } +} + +// The expression `eval` returns is the objective's value, and its names resolve +// in the scope stating it, which sees the case's attributes. +func TestObjectivesOfValueScope(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Trade { + attribute total : Integer; + objective cheapest : MinimizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { total + 1 } + } + } + } + `) + objs := objectivesOfCase(t, ctx, scope, "Trade") + if len(objs) != 1 || objs[0].Text() != "total + 1" { + t.Fatalf("objectives are [%s], want one improving total + 1", objectiveLabels(objs)) + } + if objs[0].Scope == nil { + t.Fatal("the objective's value resolves its names in no scope") + } + if !visibleIn(objs[0].Scope, "total") { + t.Error("the objective's value does not see the case's attributes") + } +} + +// An `eval` naming its result explicitly states the same value as a bare body. +func TestObjectivesOfExplicitResult(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Trade { + attribute total : Integer; + objective cheapest : MinimizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { return :>> result = total * 2; } + } + } + } + `) + objs := objectivesOfCase(t, ctx, scope, "Trade") + if len(objs) != 1 || objs[0].Text() != "total * 2" || objs[0].Eval == nil { + t.Fatalf("objectives are [%s], want one improving total * 2 through eval", objectiveLabels(objs)) + } +} + +// An `eval` computing in steps states no single expression: the objective is +// recorded as stepwise and given no value, rather than a guessed one. +func TestObjectivesOfStepwiseEval(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Trade { + attribute total : Integer; + objective cheapest : MinimizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { + attribute doubled : Integer = total * 2; + return :>> result = doubled; + } + } + } + } + `) + objs := objectivesOfCase(t, ctx, scope, "Trade") + if len(objs) != 1 || objs[0].Value != nil || !objs[0].StepwiseEval || objs[0].Eval == nil { + t.Fatalf("objectives are [%s], want one stepwise eval stating no value", objectiveLabels(objs)) + } +} + +// An objective giving the library's bound `best` a value of its own is the +// spelling validation rejects: it is recorded as such and never read as the value. +func TestObjectivesOfReboundBest(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Trade { + attribute total : Integer; + objective cheapest : MinimizeObjective { + attribute :>> best = total; + } + } + } + `) + objs := objectivesOfCase(t, ctx, scope, "Trade") + if len(objs) != 1 || objs[0].Value != nil || objs[0].ReboundBest == nil { + t.Fatalf("objectives are [%s], want one rebinding best and stating no value", objectiveLabels(objs)) + } + if objs[0].Best == nil || objs[0].Best != objs[0].ReboundBest { + t.Errorf("the rebound feature %v is not the objective's best %v", objs[0].ReboundBest, objs[0].Best) + } +} + +// An objective's own conditions are its own: the library conditions it inherits +// are about choosing among alternatives, not about which values are feasible. +func TestObjectivesOfOwnConditions(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Trade { + attribute crew : Integer; + objective largest : MaximizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { crew } + require constraint { crew >= 2 } + assume constraint { crew <= 7 } + } + } + } + `) + objs := objectivesOfCase(t, ctx, scope, "Trade") + if len(objs) != 1 { + t.Fatalf("objectives are [%s], want one", objectiveLabels(objs)) + } + if got, want := labelsOf(objs[0].Conditions), "required crew >= 2; assumed crew <= 7"; got != want { + t.Errorf("the objective's own conditions are [%s], want [%s]", got, want) + } +} + +// A condition a model states on its own objective definition is the model's, so +// an objective typed by that definition states it too: only the library's own +// trade-study conditions are about choosing among alternatives. +func TestObjectivesOfInheritedProjectConditions(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + requirement def LeastMass :> MinimizeObjective { + attribute :>> best; + require constraint { best > 0 } + } + analysis def Trade { + attribute mass : Integer; + objective lightest : LeastMass { + subject :>> selectedAlternative; + in calc :>> eval { mass } + require constraint { mass <= 90 } + } + } + } + `) + objs := objectivesOfCase(t, ctx, scope, "Trade") + if len(objs) != 1 { + t.Fatalf("objectives are [%s], want one", objectiveLabels(objs)) + } + if got, want := labelsOf(objs[0].Conditions), "required best > 0; required mass <= 90"; got != want { + t.Errorf("the objective's conditions are [%s], want [%s]", got, want) + } +} + +// An objective restating an inherited one, by redefinition or by name, is the same +// objective declared again: it keeps the inherited place in the lexicographic +// order and takes the value it states, whichever general it is inherited through. +// Its `eval` binds the inherited result rather than stating a second result +// expression, which the pilot rejects. +func TestObjectivesOfRedeclared(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Base { + attribute cost : Integer; + attribute margin : Integer; + objective cheapest : MinimizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { cost } + } + objective widest : MaximizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { margin } + } + } + analysis def Refined :> Base { + objective :>> cheapest : MinimizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { return :>> result = cost + 1; } + } + } + analysis def Renamed :> Base { + objective cheapest : MinimizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { return :>> result = cost + 2; } + } + } + analysis def Other :> Base; + analysis def Diamond :> Other, Refined; + analysis def Reversed :> Refined, Other; + analysis def Positional :> Base { + objective; + } + analysis def Twice :> Positional { + objective { subject :>> selectedAlternative; } + } + analysis def Narrowed :> Refined { + objective : MinimizeObjective; + } + } + `) + for _, tc := range []struct{ name, want string }{ + {"Refined", "minimize cheapest = cost + 1; maximize widest = margin"}, + {"Renamed", "minimize cheapest = cost + 2; maximize widest = margin"}, + {"Diamond", "minimize cheapest = cost + 1; maximize widest = margin"}, + {"Reversed", "minimize cheapest = cost + 1; maximize widest = margin"}, + // A positional restatement stating no `eval` keeps the inherited value. + {"Positional", "minimize cheapest = cost; maximize widest = margin"}, + {"Twice", "minimize cheapest = cost; maximize widest = margin"}, + {"Narrowed", "minimize cheapest = cost + 1; maximize widest = margin"}, + } { + got := objectiveLabels(objectivesOfCase(t, ctx, scope, tc.name)) + if got != tc.want { + t.Errorf("%s: objectives are [%s], want [%s]", tc.name, got, tc.want) + } + } +} + +// The conditions a case holds true of its parameters are its own members' and +// those of the constraints it requires, assumes or asserts, in evaluator order. +func TestCaseConditionsOf(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + constraint def Positive { + attribute size : Integer; + assert constraint { size > 0 } + } + analysis def Trade { + attribute size : Integer; + attribute spare : Integer; + assume constraint { size <= 9 } + require constraint { spare >= 1 } + require limit : Positive { attribute :>> size = size; } + constraint unchecked { spare < 100 } + } + } + `) + sym := requirementNamed(t, scope, "Trade") + got := labelsOf(ctx.CaseConditionsOf(sym, sym.OwnerScope)) + // The constraint the case merely declares states nothing it checks. + want := "assumed size <= 9; required spare >= 1; required size > 0" + if got != want { + t.Errorf("conditions are [%s], want [%s]", got, want) + } +} + +// A case inheriting conditions states them too, inherited ones first, as the +// evaluator checks them. +func TestCaseConditionsOfInherited(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Base { + attribute size : Integer; + require constraint { size >= 1 } + } + analysis def Refined :> Base { + require constraint { size <= 4 } + } + } + `) + sym := requirementNamed(t, scope, "Refined") + if got, want := labelsOf(ctx.CaseConditionsOf(sym, sym.OwnerScope)), + "required size >= 1; required size <= 4"; got != want { + t.Errorf("conditions are [%s], want [%s]", got, want) + } +} + +// A case stating a result expression over an inherited one, or inheriting two, +// records the conflict ahead of its required conditions; inheriting one does not. +func TestCaseConditionsOfConflict(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Base { + attribute size : Integer; + require constraint { size >= 1 } + size > 0 + } + analysis def Other { + attribute size : Integer; + size <= 9 + } + analysis def Stated :> Base { size <= 4 } + analysis def Inherited :> Base, Other; + analysis def Kept :> Base; + } + `) + for _, tc := range []struct{ name, want string }{ + {"Stated", "required conflicting result expression; required size >= 1"}, + {"Inherited", "required conflicting result expression; required size >= 1"}, + {"Kept", "required size >= 1"}, + } { + sym := requirementNamed(t, scope, tc.name) + conds := ctx.CaseConditionsOf(sym, sym.OwnerScope) + if got := labelsOf(conds); got != tc.want { + t.Errorf("%s: conditions are [%s], want [%s]", tc.name, got, tc.want) + } + conflict := conflictingResultExpression(conds) + if tc.name == "Kept" { + if conflict != nil { + t.Errorf("Kept: records a conflict %+v, want none", conflict) + } + continue + } + if conflict == nil || conflict.Node == nil { + t.Fatalf("%s: records no conflict node", tc.name) + } + if conds[0].Scope == nil || conds[0].Scope.Owner() != sym { + t.Errorf("%s: the conflict's scope is not the case's own", tc.name) + } + } +} + +// Asking about no symbol collects nothing rather than failing. +func TestAnalysisAccessorsWithoutSymbol(t *testing.T) { + ctx, _ := analysisFixture(t, `package test { }`) + if objs := ctx.ObjectivesOf(nil, nil); objs != nil { + t.Errorf("a nil symbol states [%s], want nothing", objectiveLabels(objs)) + } + if conds := ctx.CaseConditionsOf(nil, nil); conds != nil { + t.Errorf("a nil symbol states [%s], want nothing", labelsOf(conds)) + } +} + +// RequireAnalysis settles the kind before objectives are asked for: only an +// analysis case states them, and anything else is a typed refusal. +func TestRequireAnalysis(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Trade { attribute size : Integer; } + analysis trade : Trade; + part def Wheel; + constraint def Small { attribute size : Integer; } + } + `) + _ = ctx + for _, name := range []string{"Trade", "trade"} { + if err := RequireAnalysis(requirementNamed(t, scope, name)); err != nil { + t.Errorf("%s is not read as an analysis case: %v", name, err) + } + } + for _, name := range []string{"Wheel", "Small"} { + err := RequireAnalysis(requirementNamed(t, scope, name)) + if !errors.Is(err, ErrNotAnAnalysis) { + t.Errorf("%s: error is %v, want one saying it is no analysis case", name, err) + } + if !strings.Contains(err.Error(), name) { + t.Errorf("error %q does not name %s", err, name) + } + if !strings.Contains(err.Error(), "an analysis case") { + t.Errorf("error %q does not read \"an analysis case\"", err) + } + } +} + +// An analysis usage states the objectives of the definition it is typed by, which +// is what makes `%optimize` work on a usage as on a definition. +func TestObjectivesOfAnalysisUsage(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Trade { + attribute size : Integer; + require constraint { size >= 2 } + objective smallest : MinimizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { size } + } + } + analysis trade : Trade; + } + `) + got := objectiveLabels(objectivesOfCase(t, ctx, scope, "trade")) + if got != "minimize smallest = size" { + t.Errorf("objectives are [%s], want [minimize smallest = size]", got) + } + sym := requirementNamed(t, scope, "trade") + if conds := labelsOf(ctx.CaseConditionsOf(sym, sym.OwnerScope)); conds != "required size >= 2" { + t.Errorf("conditions are [%s], want [required size >= 2]", conds) + } +} + +// A restatement deeper in one branch of a diamond stands for the common +// ancestor's objective seen through the other branch, in whichever order the +// generals are written and traversed, and goes by the name it inherits. +func TestObjectivesOfUnevenDiamond(t *testing.T) { + ctx, scope := analysisFixture(t, ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + analysis def Base { + attribute cost : Integer; + attribute margin : Integer; + objective cheapest : MinimizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { cost } + } + } + analysis def A :> Base; + analysis def Mid :> Base { + objective : MinimizeObjective { + subject :>> selectedAlternative; + in calc :>> eval { return :>> result = cost + margin; } + } + } + analysis def B :> Mid; + analysis def D :> A, B; + analysis def Reversed :> B, A; + } + `) + for _, name := range []string{"D", "Reversed"} { + got := objectiveLabels(objectivesOfCase(t, ctx, scope, name)) + if want := "minimize cheapest = cost + margin"; got != want { + t.Errorf("%s: objectives are [%s], want [%s]", name, got, want) + } + } +} diff --git a/internal/core/runtime/arithmetic_overflow_test.go b/internal/exec/runtime/arithmetic_overflow_test.go similarity index 92% rename from internal/core/runtime/arithmetic_overflow_test.go rename to internal/exec/runtime/arithmetic_overflow_test.go index 1b2bbe6612..09d1b82963 100644 --- a/internal/core/runtime/arithmetic_overflow_test.go +++ b/internal/exec/runtime/arithmetic_overflow_test.go @@ -5,7 +5,7 @@ import ( "math" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // sumModel and productModel are calcs over parameters, so their arithmetic @@ -32,7 +32,7 @@ func TestIntegerArithmeticReportsOverflow(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { model, resolver, root := parseAndBuildModel(t, tc.model) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) got, err := ctx.InvokeCalc(resolveSymbol(t, root, tc.calc), tc.args, root) if !errors.Is(err, semantics.ErrArithmeticOverflow) { t.Fatalf("%s%v = %+v, %v; want ErrArithmeticOverflow", tc.name, tc.args, got, err) @@ -45,7 +45,7 @@ func TestIntegerArithmeticReportsOverflow(t *testing.T) { // so a literal sum outside the Integer range is reported the same way. func TestFoldedIntegerArithmeticReportsOverflow(t *testing.T) { model, resolver, _ := parseAndBuildModel(t, sumModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) for _, src := range []string{ "9223372036854775807 + 1", @@ -63,7 +63,7 @@ func TestFoldedIntegerArithmeticReportsOverflow(t *testing.T) { // error, not the nearest value or an infinity. func TestLiteralOutsideItsRangeIsReported(t *testing.T) { model, resolver, _ := parseAndBuildModel(t, sumModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) for _, src := range []string{ "9223372036854775808", @@ -83,7 +83,7 @@ func TestLiteralOutsideItsRangeIsReported(t *testing.T) { // its magnitude written alone is not. func TestLeastIntegerLiteralIsRead(t *testing.T) { model, resolver, _ := parseAndBuildModel(t, sumModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) got, err := evalLiteral(t, ctx, "-9223372036854775808") if err != nil { @@ -99,7 +99,7 @@ func TestLeastIntegerLiteralIsRead(t *testing.T) { // answers the Real the negated magnitude rounds to. func TestNegatingTheLeastIntegerIsReported(t *testing.T) { model, resolver, root := parseAndBuildModel(t, sumModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) sum := resolveSymbol(t, root, "sum") got, err := evalLiteral(t, ctx, "-(-9223372036854775808)") @@ -125,7 +125,7 @@ func TestNegatingTheLeastIntegerIsReported(t *testing.T) { // rather than answered as an infinity. func TestRealArithmeticReportsNonFiniteResult(t *testing.T) { model, resolver, root := parseAndBuildModel(t, productModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) product := resolveSymbol(t, root, "product") args := []Value{constReal(math.MaxFloat64), constReal(2)} diff --git a/internal/core/runtime/array.go b/internal/exec/runtime/array.go similarity index 99% rename from internal/core/runtime/array.go rename to internal/exec/runtime/array.go index fb4eb5e7cb..c47c11674b 100644 --- a/internal/core/runtime/array.go +++ b/internal/exec/runtime/array.go @@ -7,8 +7,8 @@ import ( "math" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // Array is a Collections::Array: its `dimensions` and its `elements` flattened in diff --git a/internal/core/runtime/array_test.go b/internal/exec/runtime/array_test.go similarity index 97% rename from internal/core/runtime/array_test.go rename to internal/exec/runtime/array_test.go index 344867f9ee..0889dc91c4 100644 --- a/internal/core/runtime/array_test.go +++ b/internal/exec/runtime/array_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // TestArrayAtRowMajor: one-based indexes address the row-major element, the @@ -116,7 +116,7 @@ func TestAbandonedArrayObjectIsForgottenByItsHolders(t *testing.T) { ` model, resolver, root := parseAndBuildLibraryModel(t, src) pkg := resolveSymbol(t, root, "test") - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) holder, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "holder")) if err != nil { t.Fatal(err) @@ -163,7 +163,7 @@ func TestAbandonedVectorObjectIsForgottenByItsHolders(t *testing.T) { ` model, resolver, root := parseAndBuildLibraryModel(t, src) pkg := resolveSymbol(t, root, "test") - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) holder, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "holder")) if err != nil { t.Fatal(err) @@ -211,7 +211,7 @@ func TestVectorObjectMemberReadsFollowTheDeclaration(t *testing.T) { ` model, resolver, root := parseAndBuildLibraryModel(t, src) pkg := resolveSymbol(t, root, "test") - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) for _, tc := range []struct{ expr, want string }{ {"two", "⟨1, 2⟩"}, {"two.dimension", "2"}, diff --git a/internal/core/runtime/assign_chain.go b/internal/exec/runtime/assign_chain.go similarity index 94% rename from internal/core/runtime/assign_chain.go rename to internal/exec/runtime/assign_chain.go index f7b46c45f6..5850984185 100644 --- a/internal/core/runtime/assign_chain.go +++ b/internal/exec/runtime/assign_chain.go @@ -3,8 +3,8 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // assignThroughChain writes value to the feature a chained target names, on the @@ -26,6 +26,9 @@ func writeThroughChain(ec *EvalContext, chain *lower.AssignTarget, feature strin if err != nil { return err } + if ec.ctx.isClockTime(target, feature) { + return fmt.Errorf("%w: object #%d (%s)", ErrClockNotAssignable, target.ID, symbolText(target.Type)) + } if _, ok := target.FeatureValues[feature]; !ok { return fmt.Errorf("%w: object #%d (%s) has no feature %s", ErrNoSuchFeature, target.ID, symbolText(target.Type), feature) diff --git a/internal/core/runtime/assign_chain_test.go b/internal/exec/runtime/assign_chain_test.go similarity index 99% rename from internal/core/runtime/assign_chain_test.go rename to internal/exec/runtime/assign_chain_test.go index f9c73320fb..a6b6aab3d9 100644 --- a/internal/core/runtime/assign_chain_test.go +++ b/internal/exec/runtime/assign_chain_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" ) // A chained target whose last segment names no feature of the object the chain diff --git a/internal/exec/runtime/binding.go b/internal/exec/runtime/binding.go new file mode 100644 index 0000000000..814a811b48 --- /dev/null +++ b/internal/exec/runtime/binding.go @@ -0,0 +1,992 @@ +package runtime + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// BindingConflictError reports unequal values held by the two ends of a +// binding connector. +type BindingConflictError struct { + Target string + Left string + Right string + LeftValue Value + RightValue Value +} + +func (e *BindingConflictError) Error() string { + if e.Target != "" { + return fmt.Sprintf("%s at %s: %s = %s, %s = %s", ErrBindingConflict, + e.Target, e.Left, bindingValueText(e.LeftValue), e.Right, bindingValueText(e.RightValue)) + } + return fmt.Sprintf("%s: %s = %s, %s = %s", ErrBindingConflict, + e.Left, bindingValueText(e.LeftValue), e.Right, bindingValueText(e.RightValue)) +} + +func (e *BindingConflictError) Unwrap() error { return ErrBindingConflict } + +// BindingCycleError reports a binding component that has no valued end. +type BindingCycleError struct { + Features []string +} + +func (e *BindingCycleError) Error() string { + return fmt.Sprintf("%s: %s", ErrBindingCycle, strings.Join(e.Features, " -> ")) +} + +func (e *BindingCycleError) Unwrap() error { return ErrBindingCycle } + +// UndeterminedBindingError reports a feature valued by nothing but bindings that each +// link one unspecified value of it, or bind it together with the same feature of other +// objects a collection holds, so none of them determines what it holds. +type UndeterminedBindingError struct { + Target string + Binding string + Endpoint string + // Other is the binding's end opposite Endpoint, as written. + Other string + // Across names the collection the binding's end path crosses to reach Target, if any. + Across string +} + +func (e *UndeterminedBindingError) Error() string { + if e.Across != "" { + return fmt.Sprintf("%s: %s is bound by `%s` through every object %s holds; the model does not determine which of the bound values %s holds", + ErrBindingEnd, e.Target, e.Binding, e.Across, e.Target) + } + return fmt.Sprintf("%s: %s is bound by `%s`, which makes some value of %s a value of %s without saying which value of either; the model does not state what %s holds", + ErrBindingEnd, e.Target, e.Binding, e.Endpoint, e.Other, e.Endpoint) +} + +func (e *UndeterminedBindingError) Unwrap() error { return ErrBindingEnd } + +type bindingLocation struct { + instance *Instance + name string + path string +} + +// bindingEndpoint is one end of a binding: an expression, or the feature its path reaches on every +// object of each collection crossed (KerML 1.0 §7.3.4.6). +type bindingEndpoint struct { + locations []bindingLocation + expr ast.Node + scope *symbols.Scope +} + +// spread reports a feature end reaching several objects or none, holding their values together. +func (e bindingEndpoint) spread() bool { return e.expr == nil && len(e.locations) != 1 } + +// carries reports whether the end reaches the feature value being resolved. +func (e bindingEndpoint) carries(fv *FeatureValue, inst *Instance) bool { + for _, loc := range e.locations { + if bindingLocationCarries(loc, fv, inst) { + return true + } + } + return false +} + +// across names the path an end crosses to reach its feature, "" for a feature of the owner. +func (e bindingEndpoint) across() string { + if len(e.locations) == 0 { + return "" + } + loc := e.locations[0] + return strings.TrimSuffix(strings.TrimSuffix(loc.path, loc.name), ".") +} + +type bindingAttempt struct { + value Value + found bool + contributor string + // settled reports that no end waits on a resolution in progress, so an + // unfound value means the binding links nothing. + settled bool + cycle bool + cycleFeatures []string + assignment *bindingAssignment + err error +} + +type bindingAssignment struct { + endpoint bindingEndpoint + value Value + binding lower.Binding + end int +} + +func (ctx *Context) objectBindings(typeSym *symbols.Symbol) []lower.Binding { + if typeSym == nil { + return nil + } + if cached, ok := ctx.model.bindingIR[typeSym]; ok { + return cached + } + var bindings []lower.Binding + chain := ctx.model.semantics.AllSupertypes(typeSym) + for i := len(chain) - 1; i >= 0; i-- { + if chain[i] != nil { + bindings = append(bindings, lower.ToBindings(chain[i].Decl, DeclScope(chain[i]))...) + } + } + bindings = append(bindings, lower.ToBindings(typeSym.Decl, DeclScope(typeSym))...) + ctx.model.bindingIR[typeSym] = bindings + return bindings +} + +func (ctx *Context) bindingsForFeature(typeSym *symbols.Symbol, name string) []lower.Binding { + if typeSym == nil { + return nil + } + byFeature, ok := ctx.model.bindingFeatures[typeSym] + if !ok { + byFeature = make(map[string][]lower.Binding) + ctx.model.bindingFeatures[typeSym] = byFeature + } + if bindings, ok := byFeature[name]; ok { + return bindings + } + var bindings []lower.Binding + for _, binding := range ctx.objectBindings(typeSym) { + if bindingInvolvesFeature(binding, name) && !ctx.bindingLinksNothing(binding) { + bindings = append(bindings, binding) + } + } + byFeature[name] = bindings + return bindings +} + +// bindingLinksNothing reports a binding with an end or connector multiplicity of [0], +// which links no value and so constrains neither feature (KerML 1.0 §7.4.9.2). +func (ctx *Context) bindingLinksNothing(binding lower.Binding) bool { + if r, ok := ctx.model.semantics.RangeIn(binding.Scope, binding.Multiplicity); ok && r.Upper.Known && !r.Upper.Infinite && r.Upper.Value == 0 { + return true + } + for _, end := range binding.Ends { + if r, ok := ctx.model.semantics.RangeIn(binding.Scope, end.Multiplicity); ok && r.Upper.Known && !r.Upper.Infinite && r.Upper.Value == 0 { + return true + } + } + return false +} + +func (ctx *Context) resolveBindingValue(inst *Instance, name string) (Value, bool, error) { + target, ok := inst.FeatureValues[name] + if !ok { + return Value{}, false, nil + } + key := featureValueRef{instance: inst.ID, feature: name} + if ctx.resolvingBindings[key] { + return Value{}, false, &BindingCycleError{Features: []string{bindingLocationText(bindingLocation{instance: inst, name: name})}} + } + if !target.BindingDerived || ctx.CompositeTypeOf(target.Feature) != nil { + return ctx.resolveBindings(inst, target, name, key) + } + // A value a binding gave is resolved afresh: clearing it and binding it again is one write. + before := ctx.beforeWrite(target) + ctx.noteProbeWrite(target) + target.Value = Value{} + target.Values = Value{} + target.Materialized = false + target.BindingDerived, target.Assumed = false, false + val, found, err := ctx.resolveBindings(inst, target, name, key) + ctx.afterWrite(target, before) + return val, found, err +} + +// resolveBindings is what the bindings of the target's owners, innermost first, determine it holds. +func (ctx *Context) resolveBindings(inst *Instance, target *FeatureValue, name string, key featureValueRef) (Value, bool, error) { + // A binding linking one unspecified value determines nothing, so it is reported + // only once no binding at any level determines the feature. + var undetermined *UndeterminedBindingError + var unmet []lower.Binding + path := name + for current := inst; current != nil; { + bindings := ctx.bindingsOf(current, path) + if len(bindings) != 0 { + ctx.resolvingBindings[key] = true + result, err := ctx.resolveBindingSet(current, inst, target, path, bindings, key) + delete(ctx.resolvingBindings, key) + var partial *UndeterminedBindingError + if errors.As(err, &partial) { + if undetermined == nil { + undetermined = partial + } + err = nil + } + if err != nil || result.found { + return result.value, result.found, err + } + unmet = append(unmet, result.unmet...) + if len(result.cycleFeatures) != 0 { + if err := ctx.unmetBindingCounts(unmet); err != nil { + return Value{}, false, err + } + return Value{}, false, &BindingCycleError{Features: result.cycleFeatures} + } + } + owner, ownerFeature := current.Owner() + if owner == nil || ownerFeature == "" { + break + } + path = ownerFeature + "." + path + current = owner + } + if err := ctx.unmetBindingCounts(unmet); err != nil { + return Value{}, false, err + } + if undetermined != nil { + return Value{}, false, undetermined + } + return Value{}, false, nil +} + +// bindingSetResult is what the bindings of one owner determine about a feature. +type bindingSetResult struct { + value Value + found bool + cycleFeatures []string + // unmet are the whole bindings that determined nothing, so their ends link no value. + unmet []lower.Binding +} + +// unmetBindingCounts refuses a whole binding whose ends link no value while one +// of them requires some: the feature and its other end both hold nothing. +func (ctx *Context) unmetBindingCounts(unmet []lower.Binding) error { + for _, binding := range unmet { + if err := ctx.wholeBindingCounts(binding, Value{}); err != nil { + return err + } + } + return nil +} + +// claimBinding marks the binding as the one resolving key, so its own probe of the +// other end is not mistaken for a genuine cycle; the returned func releases the claim. +func (ctx *Context) claimBinding(key featureValueRef, decl *ast.Usage) func() { + previousOwner, hadOwner := ctx.bindingOwners[key] + ctx.bindingOwners[key] = decl + return func() { + if hadOwner { + ctx.bindingOwners[key] = previousOwner + } else { + delete(ctx.bindingOwners, key) + } + } +} + +func (ctx *Context) resolveBindingSet(owner, targetInst *Instance, target *FeatureValue, + endpointName string, bindings []lower.Binding, key featureValueRef) (bindingSetResult, error) { + var result bindingSetResult + var attempts []bindingAttempt + var undetermined *UndeterminedBindingError + for _, binding := range bindings { + partial, across, err := ctx.partialBinding(owner, targetInst, target, binding, key) + if err != nil { + return result, err + } + if partial { + // A binding of one unspecified value per end constrains the ends without + // determining either: the value comes from a default, a write or a whole binding. + if undetermined == nil && target.Feature.DefaultValue == nil && !target.Written && !heldOnItsOwn(target) { + undetermined = &UndeterminedBindingError{ + Target: bindingLocationText(bindingLocation{instance: targetInst, name: key.feature}), + Binding: ctx.bindingText(binding), + Endpoint: endpointName, + Other: ctx.bindingEndpointText(binding, 1-bindingEndForPath(binding, endpointName)), + Across: across, + } + } + continue + } + attempt := ctx.attemptBinding(owner, targetInst, target, binding, key) + if attempt.err != nil { + return result, attempt.err + } + if attempt.cycle { + result.cycleFeatures = attempt.cycleFeatures + } + if !attempt.found { + if attempt.settled { + result.unmet = append(result.unmet, binding) + } + continue + } + if err := ctx.wholeBindingCounts(binding, attempt.value); err != nil { + return result, err + } + if end := bindingEndForPath(binding, endpointName); end >= 0 { + attempt.contributor = ctx.bindingEndpointText(binding, 1-end) + } + attempts = append(attempts, attempt) + } + // Every binding of the whole feature identifies it with its other end, so + // two of them must agree on its values, a sequence like a scalar. + if len(attempts) > 1 { + for _, attempt := range attempts[1:] { + if !ctx.equalValues(attempts[0].value, attempt.value) { + return result, &BindingConflictError{ + Target: bindingLocationText(bindingLocation{instance: targetInst, name: key.feature}), + Left: attempts[0].contributor, + Right: attempt.contributor, + LeftValue: attempts[0].value, + RightValue: attempt.value, + } + } + } + } + selected := attempts + if len(selected) > 1 { + selected = selected[:1] + } + if len(selected) == 1 && selected[0].assignment != nil { + assignment := selected[0].assignment + if err := ctx.assignBindingEndpoint( + assignment.endpoint, assignment.value, assignment.binding, assignment.end, + ); err != nil { + return result, err + } + } + if len(attempts) != 0 { + result.value, result.found = attempts[0].value, true + return result, nil + } + if len(result.cycleFeatures) == 0 && undetermined != nil { + return result, undetermined + } + return result, nil +} + +// partialBinding reports a binding not determining the target whole: an end linking fewer values +// than its feature holds (KerML 1.0 §7.4.9.2), a connector declaring fewer links (`binding [1]`) than +// an end's feature holds, or one reaching the target across a collection (named by across). +func (ctx *Context) partialBinding(owner, targetInst *Instance, target *FeatureValue, + binding lower.Binding, key featureValueRef) (partial bool, across string, err error) { + defer ctx.claimBinding(key, binding.Decl)() + for end := range binding.Ends { + endpoint, err := ctx.resolveBindingEndpoint(owner, binding, end) + if err != nil { + return false, "", err + } + if !endpoint.spread() || !endpoint.carries(target, targetInst) { + continue + } + other, err := ctx.resolveBindingEndpoint(owner, binding, 1-end) + if err != nil { + return false, "", err + } + _, found, err := ctx.bindingEndpointValue(other, owner, false) + if err != nil || !found { + return false, "", err + } + return true, endpoint.across(), nil + } + links, linksOK := ctx.model.semantics.RangeIn(binding.Scope, binding.Multiplicity) + for end := range binding.Ends { + stated, statedOK := ctx.model.semantics.RangeIn(binding.Scope, binding.Ends[end].Multiplicity) + // The binding links at most the smaller finite upper bound of the end's own + // multiplicity and the connector's (`binding [1]` declares one link). + var upper int64 + bounded := false + if statedOK && stated.Upper.Known && !stated.Upper.Infinite { + upper, bounded = stated.Upper.Value, true + } + if linksOK && links.Upper.Known && !links.Upper.Infinite && (!bounded || links.Upper.Value < upper) { + upper, bounded = links.Upper.Value, true + } + required := statedOK && stated.Lower.Known && stated.Lower.Value > 0 + if !bounded && !required { + continue + } + endpoint, err := ctx.resolveBindingEndpoint(owner, binding, end) + if err != nil { + return false, "", err + } + if endpoint.expr != nil { + continue + } + narrows := bounded && endpointAdmitsMoreThan(endpoint, upper) + // A feature that must hold more values than the binding links can never be + // linked whole, so the binding is partial without reading the end's value. + if narrows && endpointRequiresMoreThan(endpoint, upper) { + partial = true + continue + } + if !narrows && !required { + continue + } + // The end being resolved counts what it holds on its own: reading it through + // its bindings is this very resolution, and cannot make the binding whole. + carries := endpoint.carries(target, targetInst) + var val Value + var found bool + if carries { + val, found, err = ctx.ownEndpointValue(endpoint.locations[0]) + } else { + val, found, err = ctx.bindingEndpointValue(endpoint, owner, false) + } + if err != nil { + return false, "", err + } + count := int64(len(elementsOf(val))) + if found && required && count < stated.Lower.Value { + return false, "", ctx.bindingEndCountError(binding, end, stated, count) + } + if narrows && (carries || !found || count == 0 || count > upper) { + partial = true + } + } + return partial, "", nil +} + +// heldOnItsOwn reports a feature value holding what its own declaration gave it, not a binding. +func heldOnItsOwn(fv *FeatureValue) bool { + return fv.Materialized && !fv.BindingDerived && fv.HeldValue().Kind != ValInvalid +} + +// endpointRequiresMoreThan reports whether the features an end reaches must hold +// more than n values together, by their declared lower bounds. +func endpointRequiresMoreThan(endpoint bindingEndpoint, n int64) bool { + var total int64 + for _, loc := range endpoint.locations { + declared := loc.instance.FeatureValues[loc.name].Feature.Multiplicity.Lower + if declared.Known { + total += declared.Value + } + } + return total > n +} + +// endpointAdmitsMoreThan reports whether the features an end reaches may hold more than n +// values together, by their declared upper bounds. +func endpointAdmitsMoreThan(endpoint bindingEndpoint, n int64) bool { + var total int64 + for _, loc := range endpoint.locations { + declared := loc.instance.FeatureValues[loc.name].Feature.Multiplicity.Upper + if declared.Infinite { + return true + } + if declared.Known { + total += declared.Value + } + } + return total > n +} + +// ownEndpointValue is what a feature holds without resolving its bindings: its +// written, defaulted or already-assigned value. +func (ctx *Context) ownEndpointValue(loc bindingLocation) (Value, bool, error) { + fv := loc.instance.FeatureValues[loc.name] + if !fv.Materialized && !fv.BindingDerived && ctx.CompositeTypeOf(fv.Feature) == nil { + if _, err := loc.instance.materializeFeatureValueIntrinsic(ctx, loc.name); err != nil { + return Value{}, false, err + } + } + ctx.noteRead(fv) + val := fv.HeldValue() + return val, val.Kind != ValInvalid, nil +} + +// wholeBindingCounts checks the value a whole binding identifies its ends with +// against the number of values each end states it links. +func (ctx *Context) wholeBindingCounts(binding lower.Binding, val Value) error { + count := int64(len(elementsOf(val))) + for end := range binding.Ends { + stated, ok := ctx.model.semantics.RangeIn(binding.Scope, binding.Ends[end].Multiplicity) + if !ok { + continue + } + if (stated.Lower.Known && count < stated.Lower.Value) || + (stated.Upper.Known && !stated.Upper.Infinite && count > stated.Upper.Value) { + return ctx.bindingEndCountError(binding, end, stated, count) + } + } + // The connector's own multiplicity states how many links the binding declares. + if links, ok := ctx.model.semantics.RangeIn(binding.Scope, binding.Multiplicity); ok { + if (links.Lower.Known && count < links.Lower.Value) || + (links.Upper.Known && !links.Upper.Infinite && count > links.Upper.Value) { + return fmt.Errorf("%w: `%s` declares %s link(s) but identifies %d value(s)", + ErrMultiplicityViolation, ctx.bindingText(binding), links.Text(), count) + } + } + return nil +} + +func (ctx *Context) bindingEndCountError(binding lower.Binding, end int, stated semantics.Range, count int64) error { + return fmt.Errorf("%w: `%s` links %s of %s, which holds %d value(s)", + ErrMultiplicityViolation, ctx.bindingText(binding), stated.Text(), + ctx.bindingEndpointText(binding, end), count) +} + +// bindingText spells a binding as written, connector and end multiplicities included. +func (ctx *Context) bindingText(binding lower.Binding) string { + ends := make([]string, len(binding.Ends)) + for i, end := range binding.Ends { + ends[i] = ctx.bindingEndpointText(binding, i) + if r, ok := ctx.model.semantics.RangeIn(binding.Scope, end.Multiplicity); ok { + ends[i] = r.Text() + " " + ends[i] + } + } + text := "bind " + strings.Join(ends, " = ") + if r, ok := ctx.model.semantics.RangeIn(binding.Scope, binding.Multiplicity); ok { + text = "binding " + r.Text() + " " + text + } + return text +} + +func bindingEndForPath(binding lower.Binding, path string) int { + for end, endpoint := range binding.Ends { + if endpoint.Path == path { + return end + } + } + return -1 +} + +func (ctx *Context) attemptBinding(owner, targetInst *Instance, target *FeatureValue, + binding lower.Binding, key featureValueRef) (attempt bindingAttempt) { + defer ctx.claimBinding(key, binding.Decl)() + + left, err := ctx.resolveBindingEndpoint(owner, binding, 0) + if err != nil { + attempt.err = err + return attempt + } + right, err := ctx.resolveBindingEndpoint(owner, binding, 1) + if err != nil { + attempt.err = err + return attempt + } + leftCarries := left.carries(target, targetInst) + rightCarries := right.carries(target, targetInst) + if !leftCarries && !rightCarries { + return attempt + } + // An end reaching the target among several objects binds their values together, and + // so determines no one object's part: partialBinding reports the undetermined case. + if (leftCarries && left.spread()) || (rightCarries && right.spread()) { + return attempt + } + attempt.settled = !ctx.resolvingBindingEnd(left, leftCarries) && !ctx.resolvingBindingEnd(right, rightCarries) + + var leftValue, rightValue Value + var leftSet, rightSet bool + if leftCarries { + leftValue, leftSet, rightValue, rightSet, err = ctx.readBindingEnds(owner, left, right, rightCarries) + } else { + rightValue, rightSet, leftValue, leftSet, err = ctx.readBindingEnds(owner, right, left, leftCarries) + } + if err != nil { + attempt.err = err + return attempt + } + for _, endpoint := range []bindingEndpoint{left, right} { + for _, loc := range endpoint.locations { + if ctx.genuineBindingCycle(featureValueRef{instance: loc.instance.ID, feature: loc.name}, binding.Decl) { + attempt.cycle = true + } + } + } + if attempt.cycle { + attempt.cycleFeatures = []string{ + ctx.bindingEndpointText(binding, 0), + ctx.bindingEndpointText(binding, 1), + } + } + + switch { + case leftSet && rightSet: + leftDerived := bindingEndpointDerived(left) + rightDerived := bindingEndpointDerived(right) + if leftDerived && !rightDerived { + attempt.value = rightValue + attempt.found = true + attempt.assignment = &bindingAssignment{ + endpoint: left, value: rightValue, binding: binding, end: 0, + } + return attempt + } + if rightDerived && !leftDerived { + attempt.value = leftValue + attempt.found = true + attempt.assignment = &bindingAssignment{ + endpoint: right, value: leftValue, binding: binding, end: 1, + } + return attempt + } + if !ctx.equalValues(leftValue, rightValue) { + attempt.err = &BindingConflictError{ + Left: ctx.bindingEndpointText(binding, 0), Right: ctx.bindingEndpointText(binding, 1), + LeftValue: leftValue, RightValue: rightValue, + } + return attempt + } + attempt.value = leftValue + attempt.found = true + case leftSet: + attempt.assignment = &bindingAssignment{ + endpoint: right, value: leftValue, binding: binding, end: 1, + } + if leftCarries || rightCarries { + attempt.value = leftValue + attempt.found = true + } + case rightSet: + attempt.assignment = &bindingAssignment{ + endpoint: left, value: rightValue, binding: binding, end: 0, + } + if leftCarries || rightCarries { + attempt.value = rightValue + attempt.found = true + } + } + // The objects a spread end reaches keep their own parts of the bound values. + if attempt.assignment != nil && attempt.assignment.endpoint.spread() { + attempt.assignment = nil + } + return attempt +} + +// readBindingEnds reads the other end first, so an object it holds is what the carrying end becomes; +// a spread other end is materialized only once the carrying end has nothing of its own, a default included. +func (ctx *Context) readBindingEnds(owner *Instance, carrying, other bindingEndpoint, otherCarries bool, +) (carryingValue Value, carryingSet bool, otherValue Value, otherSet bool, err error) { + otherValue, otherSet, err = ctx.bindingEndpointValue(other, owner, otherCarries) + if err != nil { + return + } + if !otherSet && other.spread() { + carryingValue, carryingSet, err = ctx.bindingEndpointValue(carrying, owner, endpointDefaulted(carrying)) + if err != nil || carryingSet { + return + } + otherValue, otherSet, err = ctx.bindingEndpointValue(other, owner, true) + return + } + carryingValue, carryingSet, err = ctx.bindingEndpointValue(carrying, owner, !(otherSet && ctx.unmaterializedObjectEnd(carrying))) + return +} + +// endpointDefaulted reports a feature end whose every feature declares a default value. +func endpointDefaulted(endpoint bindingEndpoint) bool { + for _, loc := range endpoint.locations { + if loc.instance.FeatureValues[loc.name].Feature.DefaultValue == nil { + return false + } + } + return len(endpoint.locations) != 0 +} + +// resolvingBindingEnd reports an end other than the one being resolved whose own +// resolution is in progress, so what it holds is not yet known. +func (ctx *Context) resolvingBindingEnd(endpoint bindingEndpoint, carries bool) bool { + if carries { + return false + } + for _, loc := range endpoint.locations { + if ctx.resolvingBindings[featureValueRef{instance: loc.instance.ID, feature: loc.name}] { + return true + } + } + return false +} + +func (ctx *Context) genuineBindingCycle(ref featureValueRef, binding *ast.Usage) bool { + return ctx.resolvingBindings[ref] && ctx.bindingOwners[ref] != binding +} + +func bindingInvolvesFeature(binding lower.Binding, name string) bool { + for _, end := range binding.Ends { + if end.Path == name { + return true + } + } + return false +} + +func (ctx *Context) resolveBindingEndpoint(owner *Instance, binding lower.Binding, end int) (bindingEndpoint, error) { + path := binding.Ends[end].Path + if path == "" { + if binding.Ends[end].Expr != nil { + return bindingEndpoint{expr: binding.Ends[end].Expr, scope: binding.Scope}, nil + } + return bindingEndpoint{}, fmt.Errorf("%w: empty endpoint", ErrBindingEnd) + } + locations, err := ctx.resolveBindingLocations(owner, path) + if err != nil { + return bindingEndpoint{}, err + } + return bindingEndpoint{locations: locations}, nil +} + +// resolveBindingLocations follows an end's path from owner to the named feature on every +// object it reaches, visiting each object a collection-valued step holds, in order. +func (ctx *Context) resolveBindingLocations(owner *Instance, path string) ([]bindingLocation, error) { + parts := strings.Split(path, ".") + if len(parts) == 0 || parts[0] == "" { + return nil, fmt.Errorf("%w: empty endpoint", ErrBindingEnd) + } + reached := []*Instance{owner} + for _, part := range parts[:len(parts)-1] { + var next []*Instance + for _, current := range reached { + fv, err := current.GetFeatureValue(ctx, part) + if err != nil { + return nil, fmt.Errorf("%w %q: %w", ErrBindingEnd, path, err) + } + for _, held := range elementsOf(fv.HeldValue()) { + id, isObject := held.Object() + if !isObject { + return nil, fmt.Errorf("%w %q: %s is not an object", ErrBindingEnd, path, part) + } + obj, ok := ctx.instances[id] + if !ok { + return nil, fmt.Errorf("%w %q: instance %d is not materialized", ErrBindingEnd, path, id) + } + next = append(next, obj) + } + } + reached = next + } + name := parts[len(parts)-1] + locations := make([]bindingLocation, 0, len(reached)) + for _, current := range reached { + // A destroyed object's feature is neither read into nor written from a + // binding, whichever end of it the object is. + if err := ctx.checkNotDestroyed(current); err != nil { + return nil, fmt.Errorf("%w %q: %w", ErrBindingEnd, path, err) + } + if _, ok := current.FeatureValues[name]; !ok { + return nil, fmt.Errorf("%w %q: feature %s not found", ErrBindingEnd, path, name) + } + locations = append(locations, bindingLocation{instance: current, name: name, path: path}) + } + return locations, nil +} + +// bindingEndpointValue is what an end holds: its expression's or feature's value, or a spread end's +// values together in object order — nothing while one of them is undetermined. +func (ctx *Context) bindingEndpointValue(endpoint bindingEndpoint, owner *Instance, materialize bool) (Value, bool, error) { + if endpoint.expr != nil { + value, err := ctx.EvalWithScopeOn(endpoint.expr, endpoint.scope, owner) + if err != nil { + if errors.Is(err, ErrUninitializedFeatureValue) { + return Value{}, false, nil + } + return Value{}, false, fmt.Errorf("%w: expression %s: %v", + ErrBindingEnd, ctx.bindingExprText(endpoint.expr, endpoint.scope), err) + } + if value.Kind == ValInvalid { + return Value{}, false, nil + } + return value, true, nil + } + if !endpoint.spread() { + return ctx.bindingLocationValue(endpoint.locations[0], materialize) + } + var all []Value + for _, loc := range endpoint.locations { + val, found, err := ctx.bindingLocationValue(loc, materialize) + var undetermined *UndeterminedBindingError + if errors.As(err, &undetermined) { + return Value{}, false, nil + } + if err != nil || !found { + return Value{}, false, err + } + all = append(all, elementsOf(val)...) + } + if err := ctx.chargeElements(int64(len(all))); err != nil { + return Value{}, false, err + } + return sequenceOf(all), true, nil +} + +func (ctx *Context) bindingLocationValue(loc bindingLocation, materialize bool) (Value, bool, error) { + fv := loc.instance.FeatureValues[loc.name] + if fv.BindingDerived && materialize { + return Value{}, false, nil + } + if fv.BindingDerived { + if ctx.CompositeTypeOf(fv.Feature) != nil { + if val := fv.HeldValue(); val.Kind != ValInvalid { + ctx.noteRead(fv) + return val, true, nil + } + } + val, found, err := ctx.resolveBindingValue(loc.instance, loc.name) + if err != nil { + return Value{}, false, err + } + if found { + return val, true, nil + } + return Value{}, false, nil + } + if !fv.Materialized { + if !materialize && ctx.CompositeTypeOf(fv.Feature) != nil { + return Value{}, false, nil + } + if _, err := loc.instance.materializeFeatureValueIntrinsic(ctx, loc.name); err != nil { + return Value{}, false, err + } + } + ctx.noteRead(fv) + if val := fv.HeldValue(); val.Kind != ValInvalid { + return val, true, nil + } + if ctx.resolvingBindings[featureValueRef{instance: loc.instance.ID, feature: loc.name}] { + return Value{}, false, nil + } + val, found, err := ctx.resolveBindingValue(loc.instance, loc.name) + if err != nil { + return Value{}, false, err + } + return val, found, nil +} + +// unmaterializedObjectEnd reports whether an endpoint is an object-valued +// feature the run has not yet materialized, so materializing it would build a +// fresh object rather than read one. +func (ctx *Context) unmaterializedObjectEnd(endpoint bindingEndpoint) bool { + for _, loc := range endpoint.locations { + fv := loc.instance.FeatureValues[loc.name] + if !fv.Materialized && ctx.CompositeTypeOf(fv.Feature) != nil { + return true + } + } + return false +} + +// bindingEndpointDerived reports a feature end whose every value was assigned by a binding. +func bindingEndpointDerived(endpoint bindingEndpoint) bool { + for _, loc := range endpoint.locations { + if !loc.instance.FeatureValues[loc.name].BindingDerived { + return false + } + } + return len(endpoint.locations) != 0 +} + +func (ctx *Context) assignBindingEndpoint(endpoint bindingEndpoint, val Value, binding lower.Binding, end int) error { + if endpoint.expr != nil { + return fmt.Errorf("%w: cannot assign %s from %s", + ErrBindingEnd, ctx.bindingEndpointText(binding, end), ctx.bindingEndpointText(binding, 1-end)) + } + if endpoint.spread() { + // The objects a spread end reaches each hold a part of the value; which part is not stated. + return fmt.Errorf("%w: cannot assign %s from %s through every object %s holds", + ErrBindingEnd, ctx.bindingEndpointText(binding, end), ctx.bindingEndpointText(binding, 1-end), endpoint.across()) + } + loc := endpoint.locations[0] + fv := loc.instance.FeatureValues[loc.name] + before := ctx.beforeWrite(fv) + err := ctx.assignBindingValue(loc.instance, fv, loc.name, val) + ctx.afterWrite(fv, before) + return err +} + +func (ctx *Context) assignBindingValue(inst *Instance, fv *FeatureValue, name string, val Value) error { + if err := ctx.checkDefault(inst, fv, name, &val, admitDeclared); err != nil { + return err + } + val, err := ctx.holdDeclared(inst, fv, val) + if err != nil { + return err + } + ctx.noteProbeWrite(fv) + if fv.Feature.Scalar() { + fv.Value = val + fv.Values = Value{} + } else { + fv.Value = Value{} + fv.Values = val + } + fv.Materialized = true + fv.BindingDerived, fv.Assumed = true, false + return nil +} + +func bindingLocationCarries(loc bindingLocation, fv *FeatureValue, inst *Instance) bool { + return loc.instance == inst && loc.instance.FeatureValues[loc.name] == fv +} + +func bindingLocationText(loc bindingLocation) string { + if loc.instance == nil { + return loc.name + } + if loc.path != "" { + return loc.path + } + if loc.instance.Type == nil { + return loc.name + } + return fmt.Sprintf("%s.%s", loc.instance.Type.Name, loc.name) +} + +func (ctx *Context) bindingEndpointText(binding lower.Binding, end int) string { + endpoint := binding.Ends[end] + if endpoint.Path != "" { + return endpoint.Path + } + return ctx.bindingExprText(endpoint.Expr, binding.Scope) +} + +func (ctx *Context) bindingExprText(expr ast.Node, scope *symbols.Scope) string { + if expr == nil { + return "" + } + if scope != nil && scope.Owner() != nil { + if sf := ctx.model.sources[scope.Owner().DocName]; sf != nil { + if text := strings.TrimSpace(sf.Text(expr.Span())); text != "" { + return text + } + } + } + switch node := expr.(type) { + case *ast.OperatorExpr: + parts := make([]string, len(node.Operands)) + for i, operand := range node.Operands { + parts[i] = ctx.bindingExprText(operand, scope) + } + return strings.Join(parts, " "+node.Operator.String()+" ") + case *ast.FeatureReference: + return ctx.bindingExprText(node.Name, scope) + case *ast.FeatureChainExpr: + return ctx.bindingExprText(node.Operand, scope) + "." + ctx.bindingExprText(node.Member, scope) + case *ast.IndexExpr: + operand, index := ctx.bindingExprText(node.Operand, scope), ctx.bindingExprText(node.Index, scope) + if node.Bracket { + return operand + " [" + index + "]" + } + return operand + "#(" + index + ")" + case *ast.QualifiedName: + parts := make([]string, len(node.Parts)) + for i, part := range node.Parts { + parts[i] = part.Text + } + return strings.Join(parts, "::") + case *ast.LiteralInteger: + return node.Value + case *ast.LiteralReal: + return node.Value + case *ast.LiteralBool: + return strconv.FormatBool(node.Value) + case *ast.LiteralString: + return node.Value + } + return ast.Dump(expr) +} + +func bindingValueText(val Value) string { + return FormatValue(val) +} diff --git a/internal/exec/runtime/binding_connector_multiplicity_test.go b/internal/exec/runtime/binding_connector_multiplicity_test.go new file mode 100644 index 0000000000..e05216164c --- /dev/null +++ b/internal/exec/runtime/binding_connector_multiplicity_test.go @@ -0,0 +1,211 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// The connector's own multiplicity states how many links a binding declares, so +// `binding [1]` identifies one value of each end: an end whose feature may — or +// must — hold more is only partially bound, and so is the other end, which is +// some unspecified value of it; reading either through the binding is the typed +// ErrBindingEnd rather than a whole-binding count check. An end holding a value +// of its own keeps it, the binding only relating it to the other end. +func TestConnectorMultiplicityBoundsLinks(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + part def Thing; + part def Rig { + part a { part xs : Thing [1]; } + part ys : Thing [2]; + binding [1] bind [0..*] a.xs = [0..*] ys; + attribute own : Real [1] = 3; + attribute many : Real [2]; + binding [1] bind [0..*] own = [0..*] many; + } + part rig : Rig; + }`) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + + for _, tc := range []struct{ expr, binding string }{ + {"rig.ys", "binding [1] bind [0..*] a.xs = [0..*] ys"}, + {"rig.a.xs", "binding [1] bind [0..*] a.xs = [0..*] ys"}, + {"rig.many", "binding [1] bind [0..*] own = [0..*] many"}, + } { + _, err := evalIn(t, ctx, pkg.Scope, tc.expr) + var undetermined *UndeterminedBindingError + if !errors.As(err, &undetermined) { + t.Fatalf("%s = %v, want an UndeterminedBindingError", tc.expr, err) + } + if !strings.Contains(err.Error(), tc.binding) { + t.Errorf("%s error %q does not name the binding", tc.expr, err.Error()) + } + } + + val, err := evalIn(t, ctx, pkg.Scope, "rig.own") + if err != nil || FormatValue(val) != "3" { + t.Errorf("rig.own = %s, %v; want 3 with no error", FormatValue(val), err) + } +} + +// Without a connector multiplicity the same ends form a whole binding, whose +// count check against each end's declared multiplicity is unchanged. +func TestWholeBindingWithoutConnectorMultiplicityStillChecksCounts(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + part def Thing; + part def Rig { + part a { part xs : Thing [1]; } + part ys : Thing [2]; + bind a.xs = ys; + } + part rig : Rig; + }`) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + for _, expr := range []string{"rig.ys", "rig.a.xs"} { + if _, err := evalIn(t, ctx, pkg.Scope, expr); !errors.Is(err, ErrMultiplicityViolation) { + t.Errorf("%s = %v, want ErrMultiplicityViolation", expr, err) + } + } +} + +// The connector multiplicity is also checked whole: `binding [2]` identifying +// one value is a multiplicity violation, and `binding [0]` declares no links, +// so it assigns nothing to either end. +func TestConnectorMultiplicityDeclaresLinkCount(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + part def Rig { + attribute a : Real [1] = 1; + attribute b : Real [1]; + binding [2] bind a = b; + attribute z : Real [1]; + attribute w : Real [1] = 1; + binding [0] bind z = w; + } + part rig : Rig; + }`) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + + for _, expr := range []string{"rig.b", "rig.a"} { + _, err := evalIn(t, ctx, pkg.Scope, expr) + if !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("%s = %v, want ErrMultiplicityViolation", expr, err) + } + if !strings.Contains(err.Error(), "binding [2]") { + t.Errorf("%s error %q does not name the connector multiplicity", expr, err.Error()) + } + } + + val, err := evalIn(t, ctx, pkg.Scope, "rig.z") + if err != nil { + t.Fatalf("rig.z: %v", err) + } + if val.Kind != ValUndetermined { + t.Errorf("rig.z = %s, want %s: `binding [0]` assigns nothing", FormatValue(val), UndeterminedText) + } + if val, err := evalIn(t, ctx, pkg.Scope, "rig.w"); err != nil || FormatValue(val) != "1" { + t.Errorf("rig.w = %s, %v; want 1 with no error", FormatValue(val), err) + } +} + +// A connector or end bound naming a valued feature is evaluated in the +// binding's scope: `binding [links]` with `links = 1` declares one link, and +// `binding [n]` with `n = 2` over two `[2]` ends is a whole binding. +func TestConnectorMultiplicityNamesFeature(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + part def Thing; + part def Rig { + attribute links : Integer = 1; + part a : Thing [2]; + part b : Thing [2]; + binding [links] bind [0..*] a = [0..*] b; + attribute n : Integer = 2; + part c : Thing [2]; + part d : Thing [2]; + binding [n] bind [0..*] c = [0..*] d; + } + part rig : Rig; + }`) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + + // The diagnostic renders the bound's evaluated range, not the feature's name. + _, err := evalIn(t, ctx, pkg.Scope, "rig.b") + var undetermined *UndeterminedBindingError + if !errors.As(err, &undetermined) { + t.Fatalf("rig.b = %v, want an UndeterminedBindingError", err) + } + if !strings.Contains(err.Error(), "binding [1] bind [0..*] a = [0..*] b") { + t.Errorf("rig.b error %q does not name the binding", err.Error()) + } + + left, err := evalIn(t, ctx, pkg.Scope, "rig.c") + if err != nil { + t.Fatalf("rig.c: %v", err) + } + right, err := evalIn(t, ctx, pkg.Scope, "rig.d") + if err != nil { + t.Fatalf("rig.d: %v", err) + } + lv, rv := elementsOf(left), elementsOf(right) + if len(lv) != 2 || len(rv) != 2 { + t.Fatalf("rig.c = %s, rig.d = %s; want the same two objects", FormatValue(left), FormatValue(right)) + } + for i := range lv { + if lv[i].Instance != rv[i].Instance { + t.Errorf("rig.c#(%d) and rig.d#(%d) are different objects", i+1, i+1) + } + } +} + +// A connector declaring as many links as the ends' features hold is a whole +// binding again: `binding [2]` binds q to the two objects p holds. +func TestConnectorMultiplicityWideEnoughIsWhole(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + part def Thing; + part def Rig { + part p : Thing [2]; + part q : Thing [2]; + binding [2] bind [0..*] p = [0..*] q; + part p1 : Thing [1]; + part q1 : Thing [1]; + binding [1] bind [0..*] p1 = [0..*] q1; + } + part rig : Rig; + }`) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + for _, ends := range [][2]string{{"rig.p", "rig.q"}, {"rig.p1", "rig.q1"}} { + left, err := evalIn(t, ctx, pkg.Scope, ends[0]) + if err != nil { + t.Fatalf("%s: %v", ends[0], err) + } + right, err := evalIn(t, ctx, pkg.Scope, ends[1]) + if err != nil { + t.Fatalf("%s: %v", ends[1], err) + } + lv, rv := elementsOf(left), elementsOf(right) + if len(lv) == 0 || len(lv) != len(rv) { + t.Fatalf("%s = %s, %s = %s; want the same values", ends[0], FormatValue(left), ends[1], FormatValue(right)) + } + for i := range lv { + if lv[i].Instance != rv[i].Instance { + t.Errorf("%s#(%d) and %s#(%d) are different objects", ends[0], i+1, ends[1], i+1) + } + } + } +} diff --git a/internal/core/runtime/binding_reads.go b/internal/exec/runtime/binding_reads.go similarity index 90% rename from internal/core/runtime/binding_reads.go rename to internal/exec/runtime/binding_reads.go index 2ef8003214..b0759dba58 100644 --- a/internal/core/runtime/binding_reads.go +++ b/internal/exec/runtime/binding_reads.go @@ -4,11 +4,10 @@ import ( "fmt" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/passes" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // bindingReads is what one binding's value read of the model: declarations, what each name @@ -128,6 +127,12 @@ func (ctx *Context) scopeOf(key scopeKey) *symbols.Scope { return nil } +// recordingReads reports whether a binding is being made, so its reads are recorded; +// outside one, rendering what a lookup denoted would be wasted work on every hot path. +func (ctx *Context) recordingReads() bool { + return len(ctx.bindingStack) > 0 +} + // readsUnderWay is the record of the innermost binding being made, nil outside one. func (ctx *Context) readsUnderWay() (*bindingReads, *symbols.Symbol) { if len(ctx.bindingStack) == 0 { @@ -180,6 +185,7 @@ func (ctx *Context) noteOpaqueRead() { } // noteNameRead records what a lookup denoted, keyed so a later context can make it again. +// Callers render the read only while recordingReads, which this relies on. func (ctx *Context) noteNameRead(scope *symbols.Scope, read nameRead, denoted string, ok bool) { reads, _ := ctx.readsUnderWay() if reads == nil { @@ -226,17 +232,21 @@ func (ctx *Context) denotations(syms []*symbols.Symbol) (string, bool) { // lookupName is the resolver's LookupName, recorded for the binding being made. func (ctx *Context) lookupName(scope *symbols.Scope, name string) (*symbols.Symbol, bool) { sym, ok := ctx.model.resolver.LookupName(scope, name) - denoted, renders := ctx.denotation(sym, ok) - ctx.noteNameRead(scope, nameRead{query: queryName, name: spelledName{parts: name}}, denoted, renders) + if ctx.recordingReads() { + denoted, renders := ctx.denotation(sym, ok) + ctx.noteNameRead(scope, nameRead{query: queryName, name: spelledName{parts: name}}, denoted, renders) + } return sym, ok } // lookupNameExcluding is the resolver's LookupNameExcluding with excluding's own binding hidden. func (ctx *Context) lookupNameExcluding(scope *symbols.Scope, name string, excluding *symbols.Symbol) (*symbols.Symbol, bool) { sym, ok := ctx.model.resolver.LookupNameExcluding(scope, name, excluding.Decl) - denoted, renders := ctx.denotation(sym, ok) - read := nameRead{query: queryNameExcluding, name: spelledName{parts: name}, excluding: excluding} - ctx.noteNameRead(scope, read, denoted, renders) + if ctx.recordingReads() { + denoted, renders := ctx.denotation(sym, ok) + read := nameRead{query: queryNameExcluding, name: spelledName{parts: name}, excluding: excluding} + ctx.noteNameRead(scope, read, denoted, renders) + } return sym, ok } @@ -278,9 +288,11 @@ func (ctx *Context) resolveReferenceTarget(scope *symbols.Scope, decl, target as // constructed type, recorded for the binding being made. func (ctx *Context) resolveConstructorLabel(scope *symbols.Scope, typeRef, qn *ast.QualifiedName) (*symbols.Symbol, bool) { sym, ok := ctx.model.resolver.ResolveReference(resolve.Reference{Scope: scope, QN: qn, Constructed: typeRef}) - denoted, renders := ctx.denotation(sym, ok) - read := nameRead{query: queryConstructed, name: spell(qn), against: spell(typeRef)} - ctx.noteNameRead(scope, read, denoted, renders) + if ctx.recordingReads() { + denoted, renders := ctx.denotation(sym, ok) + read := nameRead{query: queryConstructed, name: spell(qn), against: spell(typeRef)} + ctx.noteNameRead(scope, read, denoted, renders) + } return sym, ok } @@ -292,16 +304,16 @@ func (ctx *Context) resolveAliasTarget(sym *symbols.Symbol) (*symbols.Symbol, bo // selectInvocation is the checker's selection of the declaration e calls, recorded by the // candidates the call chose among and what each of them declares. -func (ctx *Context) selectInvocation(scope *symbols.Scope, e *ast.InvocationExpr, performs semantics.Performs) *semantics.InvocationSelection { - if reads, _ := ctx.readsUnderWay(); reads != nil { +func (ctx *Context) selectInvocation(scope *symbols.Scope, e *ast.InvocationExpr, performs semantics.Performs) (*semantics.InvocationSelection, error) { + if ctx.recordingReads() { ctx.noteInvocationRead(scope, e.Type, ctx.model.resolver.InvocationCandidates(scope, e.Type)) } - return passes.SelectInvocation(ctx.model.resolver, ctx.model.semantics, scope, e, performs) + return ctx.model.selectCall(scope, e, performs) } // noteInvocationRead records the candidates a call of qn chose among and what each declares. func (ctx *Context) noteInvocationRead(scope *symbols.Scope, qn *ast.QualifiedName, candidates []*symbols.Symbol) { - if reads, _ := ctx.readsUnderWay(); reads == nil { + if !ctx.recordingReads() { return } denoted, renders := ctx.denotations(candidates) @@ -313,6 +325,9 @@ func (ctx *Context) noteInvocationRead(scope *symbols.Scope, qn *ast.QualifiedNa // noteQualifiedRead records what a qualified name written in scope denoted. func (ctx *Context) noteQualifiedRead(scope *symbols.Scope, qn *ast.QualifiedName, sym *symbols.Symbol, ok bool) { + if !ctx.recordingReads() { + return + } denoted, renders := ctx.denotation(sym, ok) ctx.noteNameRead(scope, nameRead{query: queryQualified, name: spell(qn)}, denoted, renders) } diff --git a/internal/core/runtime/body_scope_test.go b/internal/exec/runtime/body_scope_test.go similarity index 97% rename from internal/core/runtime/body_scope_test.go rename to internal/exec/runtime/body_scope_test.go index 1b18a55b33..065207ad07 100644 --- a/internal/core/runtime/body_scope_test.go +++ b/internal/exec/runtime/body_scope_test.go @@ -3,7 +3,7 @@ package runtime import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // TestBodyScopeImportSpellings: the scope an action body is evaluated in is the diff --git a/internal/core/runtime/budget.go b/internal/exec/runtime/budget.go similarity index 99% rename from internal/core/runtime/budget.go rename to internal/exec/runtime/budget.go index e7143df6be..3f3e5580c0 100644 --- a/internal/core/runtime/budget.go +++ b/internal/exec/runtime/budget.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/envvar" + "github.com/Open-MBEE/OpenSysML/internal/workspace/envvar" ) // Default bounds on one run. Each one stops a different kind of runaway, so each diff --git a/internal/exec/runtime/budget_test.go b/internal/exec/runtime/budget_test.go new file mode 100644 index 0000000000..2b5b9ae196 --- /dev/null +++ b/internal/exec/runtime/budget_test.go @@ -0,0 +1,621 @@ +package runtime + +import ( + "errors" + "fmt" + "strconv" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/workspace/envvar" +) + +// TestBudgetFromValue covers what each variable resolves to: unset, empty, +// valid, padded, and the shapes that are errors. +func TestBudgetFromValue(t *testing.T) { + values := []struct { + name string + value string + // want is relative to the variable's default when useDefault is set. + want int64 + useDefault bool + // errContains is empty when the value is accepted. + errContains string + }{ + {name: "unset", value: "", useDefault: true}, + {name: "whitespace_only", value: " ", useDefault: true}, + {name: "valid", value: "5000000", want: 5000000}, + {name: "surrounding_whitespace", value: " 2500 \n", want: 2500}, + {name: "one", value: "1", want: 1}, + {name: "zero", value: "0", errContains: "must be greater than zero"}, + {name: "negative", value: "-5", errContains: "must be greater than zero"}, + {name: "non_numeric", value: "lots", errContains: "is not an integer"}, + {name: "float", value: "1e6", errContains: "is not an integer"}, + {name: "trailing_garbage", value: "1000steps", errContains: "is not an integer"}, + } + + for _, v := range budgetVars { + for _, tt := range values { + // A bound with a ceiling accepts a value only up to it, so the + // accepted values are read against that bound's own range. + if v.ceiling > 0 && tt.want > v.ceiling { + tt.want = v.ceiling + tt.value = strconv.FormatInt(v.ceiling, 10) + } + t.Run(v.env+"/"+tt.name, func(t *testing.T) { + got, err := budgetFromValue(v, tt.value) + if tt.errContains != "" { + if err == nil { + t.Fatalf("budgetFromValue(%q) = %d, want an error", tt.value, got) + } + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error %q does not contain %q", err, tt.errContains) + } + // The message must name the variable and the offending value, + // so the reader knows what to fix. + if !strings.Contains(err.Error(), v.env) { + t.Errorf("error %q does not name %s", err, v.env) + } + if !strings.Contains(err.Error(), tt.value) { + t.Errorf("error %q does not quote the offending value %q", err, tt.value) + } + if got != 0 { + t.Errorf("rejected value yielded budget %d, want 0", got) + } + return + } + if err != nil { + t.Fatalf("budgetFromValue(%q) errored: %v", tt.value, err) + } + want := tt.want + if tt.useDefault { + want = v.def + } + if got != want { + t.Errorf("budgetFromValue(%q) = %d, want %d", tt.value, got, want) + } + }) + } + if v.ceiling == 0 { + continue + } + t.Run(v.env+"/above_the_ceiling", func(t *testing.T) { + raw := strconv.FormatInt(v.ceiling+1, 10) + got, err := budgetFromValue(v, raw) + if err == nil { + t.Fatalf("budgetFromValue(%q) = %d, want an error", raw, got) + } + if !strings.Contains(err.Error(), fmt.Sprintf("must be at most %d", v.ceiling)) { + t.Errorf("error %q does not report the ceiling %d", err, v.ceiling) + } + }) + } +} + +// TestBudgetsFromLookup: each variable sets its own bound and leaves the others +// at their defaults, and every unusable value is reported at once. +func TestBudgetsFromLookup(t *testing.T) { + t.Run("all_unset", func(t *testing.T) { + got, err := budgetsFromLookup(func(string) string { return "" }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != DefaultBudgets() { + t.Errorf("got %+v, want the defaults %+v", got, DefaultBudgets()) + } + }) + + t.Run("each_variable_sets_only_its_own_bound", func(t *testing.T) { + for _, v := range budgetVars { + got, err := budgetsFromLookup(func(name string) string { + if name == v.env { + return " 4242 " + } + return "" + }) + if err != nil { + t.Fatalf("%s: unexpected error: %v", v.env, err) + } + want := DefaultBudgets() + *v.field(&want) = 4242 + if got != want { + t.Errorf("%s: got %+v, want %+v", v.env, got, want) + } + } + }) + + t.Run("reports_every_unusable_value", func(t *testing.T) { + got, err := budgetsFromLookup(func(name string) string { + if name == MaxStepsEnvVar || name == MaxDoStepsEnvVar { + return "lots" + } + return "" + }) + if err == nil { + t.Fatalf("got %+v, want an error", got) + } + for _, name := range []string{MaxStepsEnvVar, MaxDoStepsEnvVar} { + if !strings.Contains(err.Error(), name) { + t.Errorf("error %q does not name %s", err, name) + } + } + if got != (Budgets{}) { + t.Errorf("rejected environment yielded %+v, want the zero value", got) + } + }) +} + +// TestBudgetsFromEnv: the resolver reads the process environment, and reports an +// unusable value there the same way. +func TestBudgetsFromEnv(t *testing.T) { + t.Run("unset", func(t *testing.T) { + for _, v := range budgetVars { + t.Setenv(v.env, "") + } + got, err := BudgetsFromEnv() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != DefaultBudgets() { + t.Errorf("got %+v, want the defaults %+v", got, DefaultBudgets()) + } + }) + + t.Run("raised", func(t *testing.T) { + for _, v := range budgetVars { + t.Setenv(v.env, "") + } + t.Setenv(MaxStepsEnvVar, "250000") + t.Setenv(MaxStateEventsEnvVar, "20000") + got, err := BudgetsFromEnv() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.MaxSteps != 250000 || got.MaxStateEvents != 20000 { + t.Errorf("got %+v, want MaxSteps 250000 and MaxStateEvents 20000", got) + } + if got.MaxDoSteps != DefaultMaxDoSteps { + t.Errorf("MaxDoSteps = %d, want the untouched default %d", got.MaxDoSteps, DefaultMaxDoSteps) + } + }) + + t.Run("invalid", func(t *testing.T) { + t.Setenv(MaxActionStepsEnvVar, "many") + if _, err := BudgetsFromEnv(); err == nil { + t.Fatal("expected an error for a non-numeric value") + } + }) + + t.Run("legacy names", func(t *testing.T) { + for _, v := range budgetVars { + t.Setenv(v.env, "") + t.Setenv(envvar.Legacy(v.env), "") + } + t.Setenv(envvar.Legacy(MaxStepsEnvVar), "300000") + t.Setenv(envvar.Legacy(MaxStateEventsEnvVar), "40000") + got, err := BudgetsFromEnv() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.MaxSteps != 300000 || got.MaxStateEvents != 40000 { + t.Errorf("got %+v, want MaxSteps 300000 and MaxStateEvents 40000 from the legacy names", got) + } + }) + + t.Run("both set, new name wins", func(t *testing.T) { + for _, v := range budgetVars { + t.Setenv(v.env, "") + t.Setenv(envvar.Legacy(v.env), "") + } + t.Setenv(MaxStepsEnvVar, "500000") + t.Setenv(envvar.Legacy(MaxStepsEnvVar), "111") + got, err := BudgetsFromEnv() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.MaxSteps != 500000 { + t.Errorf("MaxSteps = %d, want the OPENSYSML_ value 500000 over the legacy 111", got.MaxSteps) + } + }) +} + +// TestSetBudgets: a context runs under the bounds it is given, and rejects a set +// holding a non-positive bound rather than running under it. +func TestSetBudgets(t *testing.T) { + model, resolver, _ := parseAndBuildModel(t, `part def Simple {}`) + ctx := NewContext(typedModel(model, resolver), DefaultMaxSteps) + if got := ctx.Budgets(); got != DefaultBudgets() { + t.Errorf("a new context runs under %+v, want the defaults %+v", got, DefaultBudgets()) + } + + want := Budgets{MaxSteps: 11, MaxActionSteps: 22, MaxStateEvents: 33, MaxDoSteps: 44, MaxElements: 55, MaxCalcDepth: 66, MaxSweepRuns: 77} + if err := ctx.SetBudgets(want); err != nil { + t.Fatalf("SetBudgets: %v", err) + } + if got := ctx.Budgets(); got != want { + t.Errorf("Budgets() = %+v, want %+v", got, want) + } + + for _, bad := range []Budgets{ + {MaxSteps: 0, MaxActionSteps: 1, MaxStateEvents: 1, MaxDoSteps: 1, MaxElements: 1, MaxCalcDepth: 1, MaxSweepRuns: 1}, + {MaxSteps: 1, MaxActionSteps: -1, MaxStateEvents: 1, MaxDoSteps: 1, MaxElements: 1, MaxCalcDepth: 1, MaxSweepRuns: 1}, + {MaxSteps: 1, MaxActionSteps: 1, MaxStateEvents: 1, MaxDoSteps: 1, MaxElements: 0, MaxCalcDepth: 1, MaxSweepRuns: 1}, + {MaxSteps: 1, MaxActionSteps: 1, MaxStateEvents: 1, MaxDoSteps: 1, MaxElements: 1, MaxCalcDepth: 0, MaxSweepRuns: 1}, + {MaxSteps: 1, MaxActionSteps: 1, MaxStateEvents: 1, MaxDoSteps: 1, MaxElements: 1, MaxCalcDepth: MaxCalcDepthCeiling + 1, MaxSweepRuns: 1}, + {MaxSteps: 1, MaxActionSteps: 1, MaxStateEvents: 1, MaxDoSteps: 1, MaxElements: 1, MaxCalcDepth: 1, MaxSweepRuns: 0}, + {}, + } { + if err := ctx.SetBudgets(bad); err == nil { + t.Errorf("SetBudgets(%+v) was accepted", bad) + } + } + if got := ctx.Budgets(); got != want { + t.Errorf("a rejected set changed the bounds to %+v, want %+v", got, want) + } +} + +// TestStepLimitErrorNamesEffectiveBudgetAndVariable: the reported limit is the +// one in force, and the message says which variable raises it. +func TestStepLimitErrorNamesEffectiveBudgetAndVariable(t *testing.T) { + src := `part def Simple {}` + model, resolver, _ := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 3) + + // The bound is per run, so the evaluations have to spend one run's budget + // rather than a run each. + defer ctx.beginRun()() + + var err error + for i := 0; i < 4 && err == nil; i++ { + _, err = ctx.Eval(&ast.LiteralInteger{Value: "1"}) + } + if err == nil { + t.Fatal("expected the step budget to be exceeded") + } + if !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("expected ErrStepLimitExceeded, got %v", err) + } + if !strings.Contains(err.Error(), "(3 steps") { + t.Errorf("error %q does not report the effective budget of 3", err) + } + if !strings.Contains(err.Error(), MaxStepsEnvVar) { + t.Errorf("error %q does not name %s", err, MaxStepsEnvVar) + } +} + +// TestRaisedBudgetRunsLongerLoop: a 10 000-iteration loop exhausts the 100 000 +// steps that used to be the default and completes under today's, which is the +// point of both raising the default and making it configurable. +func TestRaisedBudgetRunsLongerLoop(t *testing.T) { + src := ` + package L { + action loopn { + attribute i = 0; + attribute s = 0.0; + first start; + action go { while i < 10000 { assign s := s + 1.5; assign i := i + 1; } } + done; + succession first start then go; + succession first go then done; + } + } + ` + file := parseAndBuild(t, src) + + runLoop := func(t *testing.T, maxSteps int64) error { + idx, _, ctx := buildRuntime(t, "", file) + ctx.maxSteps = maxSteps + sym := findSymbolByName(idx.DocumentRoot(""), "loopn", ast.DefAction) + if sym == nil { + t.Fatal("action loopn not found") + } + _, err := ctx.ExecuteAction(sym) + return err + } + + t.Run("old_default_stops_it", func(t *testing.T) { + err := runLoop(t, 100000) + if err == nil { + t.Fatal("expected a budget of 100000 steps to stop the loop") + } + if !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("expected ErrStepLimitExceeded, got %v", err) + } + }) + + t.Run("current_default_completes_it", func(t *testing.T) { + if err := runLoop(t, DefaultMaxSteps); err != nil { + t.Fatalf("loop failed under the default budget: %v", err) + } + }) +} + +// TestActionStepBudgetIsConfigurable: the action executor's token-flow bound +// comes from the context, and its error names the variable that raises it. +func TestActionStepBudgetIsConfigurable(t *testing.T) { + src := ` + package L { + action seq { + attribute i = 0; + first start; + action a { assign i := i + 1; } + action b { assign i := i + 1; } + done; + succession first start then a; + succession first a then b; + succession first b then done; + } + } + ` + file := parseAndBuild(t, src) + + runSeq := func(t *testing.T, maxActionSteps int64) error { + idx, _, ctx := buildRuntime(t, "", file) + ctx.maxActionSteps = maxActionSteps + sym := findSymbolByName(idx.DocumentRoot(""), "seq", ast.DefAction) + if sym == nil { + t.Fatal("action seq not found") + } + _, err := ctx.ExecuteAction(sym) + return err + } + + if err := runSeq(t, DefaultMaxActionSteps); err != nil { + t.Fatalf("action failed under the default token-flow budget: %v", err) + } + + err := runSeq(t, 1) + if err == nil { + t.Fatal("expected a token-flow budget of 1 to stop the action") + } + if !errors.Is(err, ErrActionStepLimitExceeded) { + t.Fatalf("expected ErrActionStepLimitExceeded, got %v", err) + } + if !strings.Contains(err.Error(), MaxActionStepsEnvVar) { + t.Errorf("error %q does not name %s", err, MaxActionStepsEnvVar) + } +} + +// TestStateBudgetsAreConfigurable: the event and do action bounds come from +// the context too, each naming its own variable. +func TestStateBudgetsAreConfigurable(t *testing.T) { + // A machine whose state is re-entered every round never settles, so whichever + // bound is reached first stops it. + machine := func() *ast.Usage { + return &ast.Usage{ + Kind: ast.UsageState, + Ident: ast.Identification{Name: "Machine"}, + Members: []ast.Node{ + entryStart("init"), + &ast.StateNode{Name: "init"}, + &ast.StateNode{Name: "spin", Do: []ast.Node{&ast.AssignmentActionNode{ + Target: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "ticks"}}}, + Value: &ast.LiteralInteger{Value: "1"}, + }}}, + transitionMember("init", "spin"), + transitionMember("spin", "spin"), + }, + } + } + + tests := []struct { + name string + budgets func(*Context) + wantVar string + }{ + { + name: "events", + budgets: func(ctx *Context) { ctx.maxStateEvents = 3 }, + wantVar: MaxStateEventsEnvVar, + }, + { + name: "do_steps", + budgets: func(ctx *Context) { ctx.maxDoSteps = 1 }, + wantVar: MaxDoStepsEnvVar, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exec := stateExecutorFor(t, machine()) + tt.budgets(exec.ctx) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + err := exec.RunToCompletion() + if err == nil { + t.Fatal("expected a budget error for a machine that never settles") + } + if tt.name == "events" && !errors.Is(err, ErrStateEventLimitExceeded) { + t.Fatalf("expected ErrStateEventLimitExceeded, got %v", err) + } + if tt.name == "do_steps" && !errors.Is(err, ErrDoStepLimitExceeded) { + t.Fatalf("expected ErrDoStepLimitExceeded, got %v", err) + } + if !strings.Contains(err.Error(), tt.wantVar) { + t.Errorf("error %q does not name %s", err, tt.wantVar) + } + }) + } +} + +// TestStepBudgetIsPerRun: the budget bounds one run, so a session of many runs +// does not exhaust it, while a runaway inside a single run still trips it and a +// run started from inside another shares the outer one's budget. +func TestStepBudgetIsPerRun(t *testing.T) { + t.Run("each_run_starts_fresh", func(t *testing.T) { + src := `part def Simple {}` + model, resolver, _ := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 4) + + // Far more evaluations than the budget, but one per run. + for i := 0; i < 100; i++ { + if _, err := ctx.Eval(&ast.LiteralInteger{Value: "1"}); err != nil { + t.Fatalf("evaluation %d of its own run failed: %v", i, err) + } + } + }) + + t.Run("one_run_still_bounded", func(t *testing.T) { + src := ` + package L { + action loopn { + attribute i = 0; + first start; + action go { while i < 10000 { assign i := i + 1; } } + done; + succession first start then go; + succession first go then done; + } + } + ` + file := parseAndBuild(t, src) + idx, _, ctx := buildRuntime(t, "", file) + ctx.maxSteps = 100 + sym := findSymbolByName(idx.DocumentRoot(""), "loopn", ast.DefAction) + if sym == nil { + t.Fatal("action loopn not found") + } + if _, err := ctx.ExecuteAction(sym); !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("expected ErrStepLimitExceeded within one run, got %v", err) + } + }) + + t.Run("nested_run_shares_the_budget", func(t *testing.T) { + src := `part def Simple {}` + model, resolver, _ := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 100) + + // Standing in for an action invoked from an expression: the inner run must + // not hand the outer one a fresh allowance. + end := ctx.beginRun() + defer end() + for i := 0; i < 10; i++ { + if _, err := ctx.Eval(&ast.LiteralInteger{Value: "1"}); err != nil { + t.Fatalf("nested evaluation %d failed: %v", i, err) + } + } + if ctx.run.steps < 10 { + t.Errorf("nested runs reset the counter: %d steps after 10 evaluations", ctx.run.steps) + } + }) +} + +// TestStepBudgetHoldsAcrossExecutorDrivenRun: a run the caller drives step by +// step - as the REPL's %action debugger does - is one run, so a nested action it +// invokes shares its budget instead of handing it a fresh one. +func TestStepBudgetHoldsAcrossExecutorDrivenRun(t *testing.T) { + src := ` + package L { + action outer { + attribute base = 7; + attribute result = 0; + first start; + perform increment; + done; + succession first start then increment; + succession first increment then done; + } + action increment { + in base; + out result; + first begin; + action bump { assign result := base + 5; } + done; + succession first begin then bump; + succession first bump then done; + } + } + ` + file := parseAndBuild(t, src) + + // Drive the run the way the debugger does, and report what it spent. + run := func(t *testing.T, maxSteps int64) (int64, error) { + t.Helper() + idx, _, ctx := buildRuntime(t, "", file) + ctx.maxSteps = maxSteps + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + exec, err := newActionExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newActionExecutor: %v", err) + } + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + err = exec.RunToCompletion() + return ctx.run.steps, err + } + + // The same action run in one call, whose budget the nested invocation + // demonstrably shares: what it spends is what the run costs. + idx, _, ctx := buildRuntime(t, "", file) + ctx.maxSteps = DefaultMaxSteps + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + if _, err := ctx.ExecuteAction(sym); err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + cost := ctx.run.steps + + spent, err := run(t, DefaultMaxSteps) + if err != nil { + t.Fatalf("run failed under the default budget: %v", err) + } + if spent != cost { + t.Fatalf("the driven run spent %d steps against %d for the same action: the nested invocation reset the counter", spent, cost) + } + + // One step short of what the run costs: the nested invocation must not hand it + // a fresh allowance. + if _, err := run(t, cost-1); !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("expected ErrStepLimitExceeded one step short of the run's cost, got %v", err) + } +} + +// TestStepBudgetIsPerRunForInstancesAndCalcs: instantiating and invoking a calc +// are runs of their own too, so a session of them does not exhaust the budget. +func TestStepBudgetIsPerRunForInstancesAndCalcs(t *testing.T) { + src := ` + package L { + part def P { + attribute n = 1; + attribute m = n + 1; + } + calc twice { in x; return : Real = x * 2; } + } + ` + file := parseAndBuild(t, src) + idx, _, ctx := buildRuntime(t, "", file) + ctx.maxSteps = 4 + + scope := idx.DocumentRoot("") + partSym := findSymbolByName(scope, "P", ast.DefPart) + if partSym == nil { + t.Fatal("part def P not found") + } + calcSym := findSymbolByName(scope, "twice", ast.DefCalc) + if calcSym == nil { + t.Fatal("calc twice not found") + } + + for i := 0; i < 100; i++ { + inst, err := ctx.Instantiate(partSym) + if err != nil { + t.Fatalf("instantiation %d failed: %v", i, err) + } + if _, err := inst.GetFeatureValue(ctx, "m"); err != nil { + t.Fatalf("feature value read %d failed: %v", i, err) + } + args := []Value{{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 21}}} + if _, err := ctx.InvokeCalc(calcSym, args, scope); err != nil { + t.Fatalf("calc invocation %d failed: %v", i, err) + } + } +} diff --git a/internal/core/runtime/builtin_names.go b/internal/exec/runtime/builtin_names.go similarity index 100% rename from internal/core/runtime/builtin_names.go rename to internal/exec/runtime/builtin_names.go diff --git a/internal/core/runtime/builtins.go b/internal/exec/runtime/builtins.go similarity index 97% rename from internal/core/runtime/builtins.go rename to internal/exec/runtime/builtins.go index cd3a02b9df..4af55d2d4d 100644 --- a/internal/core/runtime/builtins.go +++ b/internal/exec/runtime/builtins.go @@ -1,8 +1,8 @@ package runtime import ( - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // builtins maps the fully-qualified name of a Kernel Function Library function diff --git a/internal/core/runtime/builtins_named.go b/internal/exec/runtime/builtins_named.go similarity index 97% rename from internal/core/runtime/builtins_named.go rename to internal/exec/runtime/builtins_named.go index 4f6fc99575..09a48f2663 100644 --- a/internal/core/runtime/builtins_named.go +++ b/internal/exec/runtime/builtins_named.go @@ -3,9 +3,9 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // registerNamedOperatorBuiltins adds the function-call forms of the operators diff --git a/internal/core/runtime/builtins_named_test.go b/internal/exec/runtime/builtins_named_test.go similarity index 99% rename from internal/core/runtime/builtins_named_test.go rename to internal/exec/runtime/builtins_named_test.go index 39467a9553..2ebfafd8d1 100644 --- a/internal/core/runtime/builtins_named_test.go +++ b/internal/exec/runtime/builtins_named_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // evalLibraryCall evaluates expr as an attribute value of a model that loads diff --git a/internal/core/runtime/builtins_occurrence.go b/internal/exec/runtime/builtins_occurrence.go similarity index 100% rename from internal/core/runtime/builtins_occurrence.go rename to internal/exec/runtime/builtins_occurrence.go diff --git a/internal/core/runtime/builtins_signature.go b/internal/exec/runtime/builtins_signature.go similarity index 99% rename from internal/core/runtime/builtins_signature.go rename to internal/exec/runtime/builtins_signature.go index 73c47d8f8e..bf25508934 100644 --- a/internal/core/runtime/builtins_signature.go +++ b/internal/exec/runtime/builtins_signature.go @@ -5,7 +5,7 @@ import ( "slices" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // builtinFunc is the implementation of a built-in: a function over runtime diff --git a/internal/core/runtime/builtins_test.go b/internal/exec/runtime/builtins_test.go similarity index 93% rename from internal/core/runtime/builtins_test.go rename to internal/exec/runtime/builtins_test.go index 1af26cc242..b3243ea88b 100644 --- a/internal/core/runtime/builtins_test.go +++ b/internal/exec/runtime/builtins_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) func TestBuiltin_SequenceSize(t *testing.T) { @@ -37,7 +37,7 @@ func TestSequenceFunctions_Includes(t *testing.T) { } for _, tt := range tests { model, resolver, root := parseAndBuildLibraryModel(t, tt.src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) sym := resolveSymbol(t, root, "result") decl := sym.Decl.(*ast.Usage) result, err := ctx.Eval(decl.Value) @@ -78,7 +78,7 @@ func TestBuiltin_ControlSelect(t *testing.T) { for _, tt := range tests { model, resolver, root := parseAndBuildLibraryModel(t, tt.src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) sym := resolveSymbol(t, root, "result") decl := sym.Decl.(*ast.Usage) result, err := ctx.Eval(decl.Value) @@ -125,7 +125,7 @@ func TestBuiltin_ControlCollect(t *testing.T) { for _, tt := range tests { model, resolver, root := parseAndBuildLibraryModel(t, tt.src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) sym := resolveSymbol(t, root, "result") decl := sym.Decl.(*ast.Usage) result, err := ctx.Eval(decl.Value) @@ -154,7 +154,7 @@ func TestBuiltin_ControlCollect(t *testing.T) { func TestBuiltin_ControlSelect_NonBooleanPredicate(t *testing.T) { src := `attribute result = ControlFunctions::select((1, 2, 3), { in x; x * 2 });` model, resolver, root := parseAndBuildLibraryModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) sym := resolveSymbol(t, root, "result") decl := sym.Decl.(*ast.Usage) _, err := ctx.Eval(decl.Value) @@ -201,7 +201,7 @@ func TestBuiltin_Track2Integration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { model, resolver, root := parseAndBuildLibraryModel(t, tt.src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) testSym := resolveSymbol(t, root, "test") testDecl := testSym.Decl.(*ast.Usage) diff --git a/internal/core/runtime/builtins_trace_test.go b/internal/exec/runtime/builtins_trace_test.go similarity index 100% rename from internal/core/runtime/builtins_trace_test.go rename to internal/exec/runtime/builtins_trace_test.go diff --git a/internal/core/runtime/calc_run_detach_test.go b/internal/exec/runtime/calc_run_detach_test.go similarity index 96% rename from internal/core/runtime/calc_run_detach_test.go rename to internal/exec/runtime/calc_run_detach_test.go index 8a89b48723..0d8b05752f 100644 --- a/internal/core/runtime/calc_run_detach_test.go +++ b/internal/exec/runtime/calc_run_detach_test.go @@ -4,8 +4,8 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // detachModel yields bodies that name outputs of the calc they escape from: diff --git a/internal/core/runtime/calc_statements.go b/internal/exec/runtime/calc_statements.go similarity index 87% rename from internal/core/runtime/calc_statements.go rename to internal/exec/runtime/calc_statements.go index 7e9e30130f..0ef07622c8 100644 --- a/internal/core/runtime/calc_statements.go +++ b/internal/exec/runtime/calc_statements.go @@ -4,8 +4,8 @@ import ( "fmt" "sort" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // calcStmtHost runs a calculation body's statements: it owns its locals, its @@ -39,18 +39,26 @@ func (h *calcStmtHost) attachPerformances(engine *stmtEngine) { run: h.ctx.newRun(), } h.flow = &ActionExecutor{ - performances: performances{ctx: h.ctx, self: h.self, root: root, owner: h}, + performances: performances{ctx: h.ctx, self: h.self, root: root, owner: h, behavior: h.shape.Sym}, action: h.shape.Sym, state: StateRunning, nextTokenID: 1, breakpoints: make(map[string]bool), firedBreakpoints: make(map[breakpointVisit]bool), } + h.flow.flow = h.flow h.perfs = &h.flow.performances h.env = engine.env engine.env.perf = root } +// readPerformance makes the host read the steps a completed performance of the case +// holds, performing none of its own: the results of a body concluded after its runs. +func (h *calcStmtHost) readPerformance(engine *stmtEngine, perf *actionFrame) { + h.env = engine.env + engine.env.perf = perf +} + // performance is the case's own performance, nil for a calculation. func (h *calcStmtHost) performance() *actionFrame { if h.perfs == nil { @@ -130,7 +138,7 @@ func (h *calcStmtHost) performer() *Instance { // effect performs the action a `perform` in a case body names, its outputs // returning to the body's values; a calculation states no effect at all. -func (h *calcStmtHost) effect(_ *stmtEnv, s lower.Effect) error { +func (h *calcStmtHost) effect(_ *stmtEngine, s lower.Effect) error { if h.perfs == nil || s.Kind != lower.EffectPerform { return fmt.Errorf("%w: a calculation cannot state '%s'", ErrCalcSideEffect, s.Kind) } @@ -191,6 +199,14 @@ func (h *calcStmtHost) performNode(engine *stmtEngine, graph *lower.ActionGraph, return engine.run(graph.Bodies[node]) } +func (h *calcStmtHost) runBlockFlow(engine *stmtEngine, block lower.Block) (stmtFlow, error) { + if h.perfs != nil { + return h.perfs.performBlockFlow(h.perfs.root, engine, block) + } + return flowNext, fmt.Errorf("%w: %s: the flow a body states in a calculation is not executable", + ErrStatementNotExecutable, h.describe()) +} + // setFeature writes a feature the case's performance holds; it holds none of its // own, so the write reaches the body's values. func (h *calcStmtHost) setFeature(name string, value Value) error { @@ -216,8 +232,13 @@ func (h *calcStmtHost) assignAround(name string, value Value) (bool, error) { return false, nil } +// returnAround writes a returned output as assignAround does: a case has no caller to keep it for. +func (h *calcStmtHost) returnAround(name string, value Value) (bool, error) { + return h.assignAround(name, value) +} + // pauseAt sets no breakpoint: a case's steps are not stepped interactively. -func (h *calcStmtHost) pauseAt(ast.Node) error { +func (h *calcStmtHost) pauseAt([]ast.Node, ast.Node) error { return nil } @@ -226,6 +247,9 @@ func (h *calcStmtHost) runOwnFlow(perf *actionFrame) error { return h.flow.runSubflow(perf) } +// endsOwn refuses a terminate of the case's own performance: a case runs to its result. +func (h *calcStmtHost) endsOwn() bool { return false } + // runFlow runs the token flow a case body states with its successions and control // nodes, as the case's own performance; a calculation states none. func (h *calcStmtHost) runFlow(block lower.Block) (stmtFlow, error) { diff --git a/internal/core/runtime/calc_test.go b/internal/exec/runtime/calc_test.go similarity index 84% rename from internal/core/runtime/calc_test.go rename to internal/exec/runtime/calc_test.go index 813f5fad88..21fec206e1 100644 --- a/internal/core/runtime/calc_test.go +++ b/internal/exec/runtime/calc_test.go @@ -5,12 +5,12 @@ import ( "path/filepath" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) func TestCalcInvocation_SimpleCalc(t *testing.T) { @@ -37,7 +37,7 @@ func TestCalcInvocation_SimpleCalc(t *testing.T) { model := semantics.NewModel(resolver) // Create runtime context - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) // Find the default value expression from Result.sum // Navigate: root -> test package -> Result part def -> sum attribute -> default expr diff --git a/internal/core/runtime/calc_usage.go b/internal/exec/runtime/calc_usage.go similarity index 86% rename from internal/core/runtime/calc_usage.go rename to internal/exec/runtime/calc_usage.go index 4db36b898a..676f139a12 100644 --- a/internal/core/runtime/calc_usage.go +++ b/internal/exec/runtime/calc_usage.go @@ -4,9 +4,9 @@ import ( "fmt" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // A calc computes as many values as it declares output features, but an @@ -47,7 +47,7 @@ func (ctx *Context) calcOutputs(chain []*symbols.Symbol, aliases *map[string]str index := make(map[string]int) for _, link := range chain { - for _, member := range declMembers(link.Decl) { + for _, member := range unwrappedDeclMembers(link.Decl) { usage, ok := member.(*ast.Usage) if !ok { continue @@ -299,7 +299,7 @@ func (ctx *Context) calcMemberNames(shape *calcShape) map[*symbols.Symbol]string members := make(map[*symbols.Symbol]string) redefined := make(map[*symbols.Symbol]string) for _, link := range ctx.calcChain(shape.Sym) { - for _, member := range declMembers(link.Decl) { + for _, member := range unwrappedDeclMembers(link.Decl) { var name string if subject, ok := subjectDeclaration(member); ok { name = subject.Name @@ -453,6 +453,29 @@ type calcRun struct { // perf is the case's performance, whose steps an output binding reads by name // (`step.pin`); nil for a calc, which performs none. perf *actionFrame + // boundInputs are the values the run's input parameters were bound to. + boundInputs []InputBinding +} + +// boundInputs are the values each non-subject input parameter of shape was +// bound to in env, in declaration order. +func boundInputs(shape *calcShape, env frame) []InputBinding { + var inputs []InputBinding + for i := range shape.Params { + param := &shape.Params[i] + if param.IsSubject { + continue + } + if value, ok := env.lookup(param.Name); ok { + inputs = append(inputs, InputBinding{Name: param.Name, Value: value}) + } + } + return inputs +} + +// inputs are the values the run's input parameters were bound to. +func (run *calcRun) inputs() []InputBinding { + return run.boundInputs } // newCalcRun holds the environment one evaluation of a calc computed. @@ -534,24 +557,68 @@ func (ctx *Context) CalcUsageOutputs(sym *symbols.Symbol, scope *symbols.Scope, // activation, so every output read of one usage within one activation answers // from one execution of its body, while another activation gets its own. func (ctx *Context) calcUsageRun(reader *EvalContext, sym *symbols.Symbol) (*calcRun, error) { + start, run, err := ctx.beginCalcUsage(reader, sym) + if err != nil || run != nil { + return run, err + } + run, err = ctx.finishCalcUsage(start) + if paused(err) && ctx.body.paused.onWait { + return nil, ctx.caseReadWaits(start) + } + return run, err +} + +// caseReadWaits refuses the wait a case's body paused the body on the stack for +// while an expression read the case's outputs: the read takes the case whole. +func (ctx *Context) caseReadWaits(start *calcUsageStart) error { + wait := ctx.body.paused.wait + ctx.body.paused = bodyPause{} + what := "the clock" + if wait.onMessage { + what = "a message" + } + err := fmt.Errorf("%w: %s waits for %s (%s) while its outputs are read, which only a case performed as a step may", + ErrCaseReadWaits, start.shape.Label, what, wait.exec.describeWaits(wait.perf)) + if start.ec.trace != nil { + start.ec.trace.RecordCalculationExitError(start.shape.Kind, start.shape.Name, err) + } + return calcFrame(start.shape.Kind, start.shape.Name, err) +} + +// calcUsageStart is an evaluation of a calc usage under way: its inputs bound and +// its body about to run, or paused part-way for a wait, as a case's steps may. +type calcUsageStart struct { + shape *calcShape + key calcUsageKey + ec, nested, reader *EvalContext + env frame + host *calcStmtHost + engine *stmtEngine + // deferResults leaves the results ending the steps unrun, for a Monte Carlo to + // evaluate over its sample once the run's observation is in. + deferResults bool + // inputs are the values each input parameter was bound to, captured before the + // body runs so a body's assignments cannot rewrite what it was given. + inputs []InputBinding +} + +// beginCalcUsage binds the usage's inputs and makes ready to run its body; the +// run is answered instead, with no start, where this activation evaluated the +// usage already. +func (ctx *Context) beginCalcUsage(reader *EvalContext, sym *symbols.Symbol) (*calcUsageStart, *calcRun, error) { if !isCalcUsageSymbol(sym) { - return nil, fmt.Errorf( + return nil, nil, fmt.Errorf( "%w: %s does not evaluate output features", ErrNotACalcUsage, ctx.qualifiedSymbolName(sym), ) } if err := ctx.checkCalcTyping(sym); err != nil { - return nil, err + return nil, nil, err } shape, err := ctx.calcShapeOf(sym) if err != nil { - return nil, err + return nil, nil, err } - if err := ctx.enterCalc(shape.Name); err != nil { - return nil, err - } - defer ctx.leaveCalc() - // The evaluation is looked up before the inputs are bound, so a usage already // evaluated in this activation is not re-bound: its outputs all answer from // the binding its first read established. @@ -561,34 +628,78 @@ func (ctx *Context) calcUsageRun(reader *EvalContext, sym *symbols.Symbol) (*cal } // A binding of the usage that reads the usage itself reads this same evaluation. if run := reader.calcRun; run != nil && run.shape.reads(sym) { - return run, nil + return nil, run, nil } if run, ok := ctx.run.calcUsageRuns[reader.activation][key]; ok { if ctx.trace != nil { ctx.trace.RecordCalcUsageReuse(shape.Kind, shape.Name) } - return run, nil + return nil, run, nil + } + + start, err := ctx.startCalcUsage(shape, key, reader, calcArgs{}) + if err != nil { + return nil, nil, err } + return start, nil, nil +} - leave, err := ctx.enterCalcUsage(shape, key) +// startCalcUsage binds the usage's inputs from args and makes its body ready to run. +func (ctx *Context) startCalcUsage(shape *calcShape, key calcUsageKey, reader *EvalContext, args calcArgs) (*calcUsageStart, error) { + leave, err := ctx.enterCalcUsageRun(shape, key) if err != nil { return nil, err } defer leave() - ec, nested, env, err := ctx.bindCalcUsage(shape, reader, calcArgs{}) + start := &calcUsageStart{shape: shape, key: key, reader: reader} + start.ec, start.nested, start.env, err = ctx.bindCalcUsage(shape, reader, args) if err != nil { return nil, err } - run, err := ctx.runCalcUsage(shape, ec, nested, env, reader) + start.inputs = boundInputs(shape, start.env) + start.host = &calcStmtHost{ctx: ctx, shape: shape, self: reader.self} + // A usage nested in a behavior body computes over that body's bindings, as + // an invocation of it does. + var enclosing []frame + if start.nested != nil { + enclosing = shape.bodyEnclosing(start.nested.enclosingRun(shape)) + } + start.engine = newStmtEngineIn(ctx, start.host, start.env, enclosing) + start.host.attachPerformances(start.engine) + return start, nil +} + +// enterCalcUsageRun spends the calc depth on the usage and marks it running for the +// call under way (binding its inputs, or running its body until it ends or pauses). +func (ctx *Context) enterCalcUsageRun(shape *calcShape, key calcUsageKey) (leave func(), err error) { + if err := ctx.enterCalc(shape.Name); err != nil { + return nil, err + } + leaveUsage, err := ctx.enterCalcUsage(shape, key) if err != nil { + ctx.leaveCalc() return nil, err } - runs, ok := ctx.run.calcUsageRuns[reader.activation] + return func() { + leaveUsage() + ctx.leaveCalc() + }, nil +} + +// finishCalcUsage runs the started usage's body to its end and records the run for +// this activation; a body that pauses for a wait answers the pause, to be +// finished again once the wait is over. +func (ctx *Context) finishCalcUsage(start *calcUsageStart) (*calcRun, error) { + run, err := ctx.runCalcUsage(start) + if err != nil { + return nil, err + } + runs, ok := ctx.run.calcUsageRuns[start.reader.activation] if !ok { runs = make(map[calcUsageKey]*calcRun) - ctx.run.calcUsageRuns[reader.activation] = runs + ctx.run.calcUsageRuns[start.reader.activation] = runs } - runs[key] = run + runs[start.key] = run return run, nil } @@ -764,20 +875,28 @@ func (ctx *Context) checkCalcTyping(sym *symbols.Symbol) error { // runCalcUsage runs a calc usage's computation once over its bound inputs, // keeping the environment the computation ends with so the usage's outputs can // be evaluated against it. -func (ctx *Context) runCalcUsage( - shape *calcShape, ec, nested *EvalContext, env frame, reader *EvalContext, -) (*calcRun, error) { - host := &calcStmtHost{ctx: ctx, shape: shape, self: reader.self} - // A usage nested in a behavior body computes over that body's bindings, as - // an invocation of it does. - var enclosing []frame - if nested != nil { - enclosing = shape.bodyEnclosing(nested.enclosingRun(shape)) +func (ctx *Context) runCalcUsage(start *calcUsageStart) (*calcRun, error) { + shape, ec, nested, env, reader, host, engine := start.shape, start.ec, start.nested, start.env, start.reader, start.host, start.engine + leave, err := ctx.enterCalcUsageRun(shape, start.key) + if err != nil { + return nil, err } - engine := newStmtEngineIn(ctx, host, env, enclosing) - host.attachPerformances(engine) - result, returned, err := runCalcSteps(engine, host, shape) + defer leave() + // With no body to pause, a case's flow drives the clock itself, so it is on the + // clock for the run as a top-level executor is; a body performing the case lists it. + if flow := host.flow; flow != nil && ctx.body == nil { + ctx.clock.attach(flow) + defer ctx.clock.detach(flow) + } + steps := shape.Steps + if start.deferResults { + steps, _ = shape.observationSteps() + } + result, returned, err := runCalcSteps(engine, host, steps) if err != nil { + if paused(err) { + return nil, err + } if ec.trace != nil { ec.trace.RecordCalculationExitError(shape.Kind, shape.Name, err) } @@ -793,7 +912,7 @@ func (ctx *Context) runCalcUsage( run := newCalcRun(shape, reader.scope, reader.self, env) run.outer, run.result, run.returned = nested, result, returned - run.activation, run.perf = engine.activation, host.performance() + run.activation, run.perf, run.boundInputs = engine.activation, host.performance(), start.inputs // The returned value is the result parameter's, read under its name or as // `result`; every other output states its own value, never the returned one. if returned { @@ -846,9 +965,7 @@ func (run *calcRun) value(ctx *Context, out calcOutput) (Value, error) { } } if out.Value == nil { - return Value{}, fmt.Errorf( - "%w: output %s of %s", ErrOutputNotAssigned, run.outputDescription(out), run.shape.Label, - ) + return Value{}, &UnassignedOutputError{Output: run.outputDescription(out), Calc: run.shape.Label} } if run.computing[out.Name] { return Value{}, fmt.Errorf( diff --git a/internal/core/runtime/calc_usage_body_local_test.go b/internal/exec/runtime/calc_usage_body_local_test.go similarity index 100% rename from internal/core/runtime/calc_usage_body_local_test.go rename to internal/exec/runtime/calc_usage_body_local_test.go diff --git a/internal/core/runtime/calc_usage_nested_test.go b/internal/exec/runtime/calc_usage_nested_test.go similarity index 98% rename from internal/core/runtime/calc_usage_nested_test.go rename to internal/exec/runtime/calc_usage_nested_test.go index e95bc6aef4..7865c6a2f8 100644 --- a/internal/core/runtime/calc_usage_nested_test.go +++ b/internal/exec/runtime/calc_usage_nested_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // nestedUsageOutputs evaluates every output of the named calc usage in src. diff --git a/internal/core/runtime/calc_usage_snapshot_test.go b/internal/exec/runtime/calc_usage_snapshot_test.go similarity index 100% rename from internal/core/runtime/calc_usage_snapshot_test.go rename to internal/exec/runtime/calc_usage_snapshot_test.go diff --git a/internal/core/runtime/calc_usage_step_test.go b/internal/exec/runtime/calc_usage_step_test.go similarity index 98% rename from internal/core/runtime/calc_usage_step_test.go rename to internal/exec/runtime/calc_usage_step_test.go index 4a1c80d3db..0e115a34b7 100644 --- a/internal/core/runtime/calc_usage_step_test.go +++ b/internal/exec/runtime/calc_usage_step_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // guardModel loops through a decision whose guards read a calc usage over an diff --git a/internal/exec/runtime/call_capture_test.go b/internal/exec/runtime/call_capture_test.go new file mode 100644 index 0000000000..20300bfe2e --- /dev/null +++ b/internal/exec/runtime/call_capture_test.go @@ -0,0 +1,87 @@ +package runtime + +import "testing" + +// pendingAsk is a call under way with an output written, an inout carried in and +// an object among its values, as a capture must take it. +func pendingAsk(object *Instance) *pendingCall { + return &pendingCall{ + id: 7, + outputs: map[string]Value{"result": constInt(1), "who": objectValue(object)}, + returns: map[string]bool{"result": true, "who": true}, + inouts: map[string]Value{"x": constInt(2)}, + } +} + +// checkPendingAsk fails unless call is pendingAsk's call with its object at who. +func checkPendingAsk(t *testing.T, what string, call *pendingCall, who int64) { + t.Helper() + if call == nil { + t.Fatalf("%s: no call pending, want the one captured", what) + } + if call.id != 7 || call.taken { + t.Errorf("%s: call id %d taken %v, want 7 not taken", what, call.id, call.taken) + } + if got := call.outputs["result"]; got.Const.Int != 1 || len(call.outputs) != 2 { + t.Errorf("%s: outputs %v, want result = 1 and who", what, call.outputs) + } + if got, _ := call.outputs["who"].Object(); got != who { + t.Errorf("%s: who = #%d, want #%d", what, got, who) + } + if !call.returns["result"] || !call.returns["who"] || len(call.returns) != 2 { + t.Errorf("%s: returns %v, want result and who", what, call.returns) + } + if got := call.inouts["x"]; got.Const.Int != 2 || len(call.inouts) != 1 { + t.Errorf("%s: inouts %v, want x = 2", what, call.inouts) + } +} + +// A state executor's capture takes the call it is answering by value: what the +// executor writes to the call after the capture is undone by the restore, and a +// restored call does not share its maps with the capture. +func TestStateCaptureTakesThePendingCall(t *testing.T) { + exec := callOwnerMachine(t) + exec.pendingCall = pendingAsk(exec.self) + capture := exec.capture() + + exec.pendingCall.outputs["result"] = constInt(9) + exec.pendingCall.inouts["x"] = constInt(3) + exec.pendingCall.taken = true + exec.pendingCall = nil + capture.restore() + checkPendingAsk(t, "after the restore", exec.pendingCall, exec.self.ID) + + exec.pendingCall.outputs["result"] = constInt(5) + exec.pendingCall.returns["extra"] = true + capture.restore() + checkPendingAsk(t, "after the second restore", exec.pendingCall, exec.self.ID) + + exec.pendingCall = nil + if capture = exec.capture(); capture.pendingCall != nil { + t.Errorf("a capture with no call pending holds %v", capture.pendingCall) + } +} + +// A held image carries the call a machine is answering: the copy's machine holds +// the same call with its values as the destination's own, apart from the source's. +func TestHeldImageCarriesThePendingCall(t *testing.T) { + _, src, bulb := lampBulb(t) + machine := lampMachine(t, bulb) + machine.pendingCall = pendingAsk(bulb) + + dst := imageInto(t, src, bulb) + copied, _ := dst.Instance(bulb.ID) + imaged := lampMachine(t, copied) + checkPendingAsk(t, "the copy", imaged.pendingCall, copied.ID) + + imaged.pendingCall.outputs["result"] = constInt(9) + imaged.pendingCall.taken = true + checkPendingAsk(t, "the source after the copy wrote", machine.pendingCall, bulb.ID) + + machine.pendingCall = nil + plain := imageInto(t, src, bulb) + plainCopy, _ := plain.Instance(bulb.ID) + if call := lampMachine(t, plainCopy).pendingCall; call != nil { + t.Errorf("a copy of a machine answering no call holds %v", call) + } +} diff --git a/internal/core/runtime/carry.go b/internal/exec/runtime/carry.go similarity index 100% rename from internal/core/runtime/carry.go rename to internal/exec/runtime/carry.go diff --git a/internal/core/runtime/carry_test.go b/internal/exec/runtime/carry_test.go similarity index 98% rename from internal/core/runtime/carry_test.go rename to internal/exec/runtime/carry_test.go index 0fcfa4ccdd..39d8be2d45 100644 --- a/internal/core/runtime/carry_test.go +++ b/internal/exec/runtime/carry_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) const carrySrc = ` @@ -24,7 +24,7 @@ package test { func carryContexts(t *testing.T) (held, row *Context, scope *symbols.Scope, eval func(string) Value) { t.Helper() model, resolver, root := parseAndBuildModel(t, carrySrc) - m := NewModel(model, resolver) + m := typedModel(model, resolver) held, row = NewContext(m, 10000), NewContext(m, 10000) pkg, _ := root.LookupLocal("test") scope = pkg.Scope diff --git a/internal/exec/runtime/case_step.go b/internal/exec/runtime/case_step.go new file mode 100644 index 0000000000..f2c8241c95 --- /dev/null +++ b/internal/exec/runtime/case_step.go @@ -0,0 +1,111 @@ +package runtime + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// isCaseStep reports whether node is a case nested in a body, performed as a step. +func isCaseStep(node ast.Node) bool { + usage, ok := node.(*ast.Usage) + return ok && lower.IsCaseNode(usage) +} + +// stepSymbol resolves the symbol a nested usage node declares and the body declaring +// it; nil when the node declares none. +func stepSymbol(flow *lower.ActionGraph, node ast.Node) (*symbols.Symbol, *symbols.Scope) { + scope := nodeScope(flow, node) + declaring := scope.Parent() + if declaring == nil { + declaring = flow.Scope + } + sym := memberSymbol(declaring, node) + if sym == nil { + sym = memberSymbol(flow.Scope, node) + declaring = flow.Scope + } + return sym, declaring +} + +// caseStepSymbol resolves the case a nested usage node declares, in the body that declares it. +func caseStepSymbol(flow *lower.ActionGraph, node ast.Node) (*symbols.Symbol, *symbols.Scope, error) { + sym, declaring := stepSymbol(flow, node) + if sym == nil || !isCalcUsageSymbol(sym) { + return nil, nil, fmt.Errorf("%w: %s is not resolved to a case", ErrNotACalcUsage, nodeDescription(node)) + } + return sym, declaring, nil +} + +// caseStepFrame is a case step whose body paused for a wait: the evaluation under +// way, to finish once the wait is over, and the step it runs as. +type caseStepFrame struct { + start *calcUsageStart + run *calcRun // the finished evaluation, nil while the body runs + endStep func() +} + +// abandon ends the step with the body that was performing the case. +func (f *caseStepFrame) abandon(*Context) { f.endStep() } + +func (f *caseStepFrame) clone() bodyFrame { c := *f; return &c } + +func (f *caseStepFrame) spell(s *stateSpeller) string { + return "case " + f.start.shape.Label + " " + s.nested(f.start.host.flow) +} + +// performCase runs a nested case as a step of the body (SysML v2 §7.21.2): its own +// body evaluates, taking the enclosing subject where it binds none, and the +// performance holds its outputs for later steps to read as `step.pin`. +func (e *performances) performCase(perf *actionFrame) error { + f, resumed, err := popFrame[*caseStepFrame](e.ctx) + if err != nil { + return err + } + if !resumed { + sym, declaring, err := caseStepSymbol(perf.flow, perf.node) + if err != nil { + return err + } + activation, endStep := e.ctx.beginStep() + reader := e.evalContextAround(perf, declaring) + reader.activation = activation + f = &caseStepFrame{endStep: endStep} + if f.start, f.run, err = e.ctx.beginCalcUsage(reader, sym); err != nil { + endStep() + return fmt.Errorf("%s: %w", nodeDescription(perf.node), err) + } + } + if f.run == nil { + // The case's own flow may wait on the clock, pausing the body; the step + // is finished once the body resumes past the wait. + run, err := e.ctx.finishCalcUsage(f.start) + if paused(err) { + return e.ctx.pausing(f, err) + } + if err != nil { + f.endStep() + return fmt.Errorf("%s: %w", nodeDescription(perf.node), err) + } + f.run = run + } + defer f.endStep() + run := f.run + outputs, err := run.outputValues(e.ctx) + if err != nil { + return fmt.Errorf("%s: %w", nodeDescription(perf.node), err) + } + for _, out := range outputs { + perf.features[out.Name] = ast.DirOut + perf.data[perf.key(out.Name)] = out.Value + } + if out := run.shape.resultOutput(); out != nil { + perf.result = out.Name + if perf.result == "" { + perf.result = resultOutputName + } + } + return nil +} diff --git a/internal/exec/runtime/cast.go b/internal/exec/runtime/cast.go new file mode 100644 index 0000000000..c1beb2abf1 --- /dev/null +++ b/internal/exec/runtime/cast.go @@ -0,0 +1,218 @@ +package runtime + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// evaluationTypeFQN types a deferred expression: the functions computing a result. +const evaluationTypeFQN = "Performances::Evaluation" + +// evalCast evaluates `x as T`: a CastExpression's result is the values of x that +// the type T classifies, so it selects values and never converts one (KerML 1.1 +// §8.3.4.9). Converting between types is what the library functions do. +func (ec *EvalContext) evalCast(n *ast.OperatorExpr) (Value, error) { + if len(n.Operands) != 1 || n.TypeRef == nil { + return Value{}, fmt.Errorf("%w: '%s' requires one value and one type", + ErrTypeMismatch, n.Operator) + } + target, ok := ec.resolveClassificationType(n.TypeRef) + if !ok { + return Value{}, fmt.Errorf("%w: %s", ErrUnresolvedType, + qualifiedNameToString(n.TypeRef)) + } + // A written sequence is cast entry by entry, each judged by its own types. + if _, ok := n.Operands[0].(*ast.SequenceExpr); ok { + kept, sources, err := ec.castEntries(n.Operands[0], target) + if err != nil { + return Value{}, err + } + return ec.sequenceFrom(kept, sources...) + } + value, err := ec.Eval(n.Operands[0]) + if err != nil { + return Value{}, err + } + return ec.castValue(soleElement(value), target, ec.declaredOperandTypes(n.Operands[0])) +} + +// castEntries casts one entry of a written sequence, answering the values target +// keeps of it and the values it holds, whose unit an empty result keeps. A KerML +// sequence is flat, so a nested one contributes its own entries. +func (ec *EvalContext) castEntries( + entry ast.Node, target *symbols.Symbol, +) (kept, sources []Value, err error) { + if seq, ok := entry.(*ast.SequenceExpr); ok { + for _, element := range seq.Elements { + elementKept, elementSources, err := ec.castEntries(element, target) + if err != nil { + return nil, nil, err + } + kept = append(kept, elementKept...) + sources = append(sources, elementSources...) + } + return kept, sources, nil + } + value, err := ec.Eval(entry) + if err != nil { + return nil, nil, err + } + out, err := ec.castValue(value, target, ec.declaredOperandTypes(entry)) + if err != nil { + return nil, nil, err + } + return elementsOf(out), []Value{value}, nil +} + +// declaredOperandTypes names every type an operand is declared with, which classifies +// its values where their own content does not state their type (KerML 1.0 §7.3.4.1). +func (ec *EvalContext) declaredOperandTypes(operand ast.Node) []*symbols.Symbol { + // An enumeration literal is of its enumeration however its value is written. + if sym, ok := ec.ctx.resolveTarget(ec.scope, operand); ok && sym != nil { + if canonical, aliased := ec.ctx.resolveAliasTarget(sym); aliased { + sym = canonical + } + if enum := semantics.EnumerationOwning(sym); enum != nil { + return []*symbols.Symbol{enum} + } + } + var declared []*symbols.Symbol + for _, typ := range ec.ctx.model.semantics.ExprResultTypes(ec.scope, operand) { + // A feature typed Anything states nothing about the values it holds. + if typ != nil && !semantics.IsAnything(typ) { + declared = append(declared, typ) + } + } + return declared +} + +// castValue keeps the values of value that target classifies: element-wise and in +// order for a collection, the value itself or the empty sequence for one value. +func (ec *EvalContext) castValue( + value Value, target *symbols.Symbol, declared []*symbols.Symbol, +) (Value, error) { + switch value.Kind { + case ValUndetermined: + return castUndetermined(value, ec.undeterminedClassification(declared, target)), nil + case ValNull, ValInvalid: + return sequenceOf(nil), nil + case ValSequence, ValSet: + elements := elementsOf(value) + kept := make([]Value, 0, len(elements)) + for _, element := range elements { + element, keep, err := ec.castKept(element, target, declared) + if err != nil { + return Value{}, err + } + if keep { + kept = append(kept, element) + } + } + if value.Kind == ValSet { + set := NewSetIn(ec.ctx) + for _, element := range kept { + set.Add(element) + } + if err := ec.ctx.chargeElements(int64(set.Size())); err != nil { + return Value{}, err + } + return NewSetValue(set), nil + } + return ec.sequenceFrom(kept, value) + } + kept, keep, err := ec.castKept(value, target, declared) + if err != nil { + return Value{}, err + } + if !keep { + return ec.sequenceFrom(nil, value) + } + return kept, nil +} + +// castKept is the value a cast keeps: the value itself, or the enumerated value it +// equals when target is an enumeration (`3 as Level` is `Level::high`). +func (ec *EvalContext) castKept( + value Value, target *symbols.Symbol, declared []*symbols.Symbol, +) (Value, bool, error) { + keep, err := ec.castKeeps(value, target, declared) + if err != nil || !keep { + return value, keep, err + } + enumerated, found, err := ec.ctx.asEnumerated(value, target) + if err != nil || !found { + return value, true, err + } + return enumerated, true, nil +} + +// castKeeps reports whether target classifies one value, by the shared classification; +// a verdict neither the value's types nor its content settle fails the cast (undecidedCast). +func (ec *EvalContext) castKeeps( + value Value, target *symbols.Symbol, declared []*symbols.Symbol, +) (bool, error) { + verdict, err := ec.ctx.classifyValue(ec.scope, value, target, declared, byAnyType) + if err != nil { + return false, err + } + switch verdict { + case semantics.ClassifiesAll: + return true, nil + case semantics.ClassifiesNone: + return false, nil + } + return false, ec.undecidedCast(value, target) +} + +// castUndetermined is `as` over values the model leaves open: all of them when +// their declared types conform to target, none when those exclude it, else some. +func castUndetermined(value Value, verdict semantics.TypeClassification) Value { + switch verdict { + case semantics.ClassifiesAll: + return value + case semantics.ClassifiesNone: + return sequenceOf(nil) + } + count := countOf(value) + count.Lower = semantics.Bound{Known: true} + return undeterminedOf(count, value) +} + +// undeterminedClassification is how target classifies values the model leaves +// open: by the types their operand declares, undecided where it declares none. +func (ec *EvalContext) undeterminedClassification( + declared []*symbols.Symbol, target *symbols.Symbol, +) semantics.TypeClassification { + if len(declared) == 0 { + return semantics.ClassifiesSome + } + return ec.ctx.model.semantics.ClassifiesTypes(declared, target) +} + +// classifyUndetermined answers `istype`, `hastype` and `@` over values the model +// leaves open by what their declared types and count settle; the rest stays open. +func classifyUndetermined(op ast.OperatorKind, value Value, verdict semantics.TypeClassification) Value { + lower := countOf(value).Lower + nonEmpty := lower.Known && lower.Value > 0 + switch verdict { + case semantics.ClassifiesNone: + if op == ast.OpAt || nonEmpty { + return boolValue(false) + } + case semantics.ClassifiesAll: + if op == ast.OpIsType || (op == ast.OpAt && nonEmpty) { + return boolValue(true) + } + } + return undeterminedOf(semantics.CountRange(1), value) +} + +// undecidedCast reports a cast whose verdict the value does not settle, so the +// cast fails rather than dropping a value that may well be one of the target's. +func (ec *EvalContext) undecidedCast(value Value, target *symbols.Symbol) error { + return fmt.Errorf("%w: whether %s (%s) is a value of %s is not stated by the value", + ErrUndecidedClassification, FormatValue(value), describeValue(value), symbolText(target)) +} diff --git a/internal/core/runtime/chain_trace_test.go b/internal/exec/runtime/chain_trace_test.go similarity index 100% rename from internal/core/runtime/chain_trace_test.go rename to internal/exec/runtime/chain_trace_test.go diff --git a/internal/exec/runtime/check.go b/internal/exec/runtime/check.go new file mode 100644 index 0000000000..398bf2f944 --- /dev/null +++ b/internal/exec/runtime/check.go @@ -0,0 +1,1415 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "sort" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// The model checker: a depth-first search over the schedules of one invocation — +// actions and state machines on one clock — one executor acting one unit per +// move, backtracking through snapshots. It finds the properties' violations, the +// deadlocks and the typed failures some schedule reaches, and the observables +// whose final value the schedule decides (docs/internals/design/bounded-model-checking.md). + +// CheckBudget bounds a check: the moves one schedule may make and the distinct +// states the search may visit; 0 leaves either unbounded. +type CheckBudget struct { + Depth int + States int +} + +// CheckOptions selects what a check reports and how it searches. +type CheckOptions struct { + // Diverge names the features whose divergence is reported: the action's own + // bare, a performed node's as `node.pin`, the performing object's as `this.`; + // none names the action's own features and the object's attributes. A name + // no feature answers to fails the check (ErrUnknownCheckFeature). + Diverge []string + // Reduce explores one representative of each class of equivalent schedules; + // off, every schedule. It is off only to test that the reduction loses nothing. + // A check with properties searches unreduced: what a property reads is not + // in the footprints the reduction is sound for. + Reduce bool +} + +// ErrUnknownCheckFeature is the typed error a check whose Diverge names no feature wraps. +var ErrUnknownCheckFeature = errors.New("no such feature to check divergence of") + +// UnknownCheckFeatureError names a feature Diverge selects that nothing answers to. +type UnknownCheckFeatureError struct { + Name string + Reason string +} + +func (e *UnknownCheckFeatureError) Error() string { + return fmt.Sprintf("%v: %s: %s", ErrUnknownCheckFeature, e.Name, e.Reason) +} + +// Is makes every UnknownCheckFeatureError match ErrUnknownCheckFeature. +func (e *UnknownCheckFeatureError) Is(target error) bool { return target == ErrUnknownCheckFeature } + +// CheckProperty is a property evaluated at every stable state of a check: a +// requirement or constraint, false at a state being a violation. Holds is asked +// in the check's context of the invocation the check runs, and again of a replay +// of the witness that claims it false or failing to evaluate. +type CheckProperty struct { + Name string + Holds func(*Context, *Invocation) (bool, error) +} + +// ViolationKind classifies what a schedule reached. +type ViolationKind int + +const ( + // ViolationProperty is a property evaluating false at a reached state. + ViolationProperty ViolationKind = iota + // ViolationDeadlock is a state with no move where an action is not complete. + ViolationDeadlock + // ViolationFailure is a typed runtime error a move raised. + ViolationFailure +) + +func (k ViolationKind) String() string { + switch k { + case ViolationProperty: + return "property" + case ViolationDeadlock: + return "deadlock" + case ViolationFailure: + return "failure" + } + return fmt.Sprintf("ViolationKind(%d)", int(k)) +} + +// Violation is one violation a schedule reached, with the schedule as its witness. +type Violation struct { + Kind ViolationKind + // Name is the property violated, empty for a deadlock or a failure. + Name string + // Err is the deadlock or the failure as the executor reported it, nil for a property. + Err error + // Depth is how many moves the witness makes. + Depth int + Witness Witness + // Mass is the probability of the schedules reaching this violation: the sum + // of their path masses, each the product of its picks' shares — a weighted + // choice's stated share, an unweighted one's uniform share. It is a lower + // bound while the report's MassBounded holds. + Mass float64 +} + +// String renders the violation for a report. +func (v Violation) String() string { + switch v.Kind { + case ViolationProperty: + return fmt.Sprintf("%s is false after %d moves", v.Name, v.Depth) + case ViolationDeadlock: + return fmt.Sprintf("deadlock after %d moves: %v", v.Depth, v.Err) + } + return fmt.Sprintf("failure after %d moves: %v", v.Depth, v.Err) +} + +// DivergentValue is one final value of a divergent feature and a schedule reaching it. +type DivergentValue struct { + Value string + Witness Witness +} + +// Divergence is a feature whose final value the schedule decides: every value +// some complete schedule leaves it with, in canonical order. +type Divergence struct { + Feature string + Values []DivergentValue +} + +// String spells the divergence as `x ends as 1 or 2`. +func (d Divergence) String() string { + values := make([]string, len(d.Values)) + for i, v := range d.Values { + values[i] = v.Value + } + return d.Feature + " ends as " + strings.Join(values, " or ") +} + +// CheckFinal is one distinct outcome of the complete schedules and a schedule +// reaching it; Values spells every feature divergence is reported over, the +// performing object's as `this.`, and Outcome spells the action's outputs +// with the object's values after them. +type CheckFinal struct { + Outcome string + Values map[string]string + Witness Witness + // identity is the outcome's identity with the object's values, every name quoted. + identity string +} + +// CheckVerdict is how a check ended. +type CheckVerdict int + +const ( + // CheckExhaustive found no violation and every schedule, up to equivalence, was searched. + CheckExhaustive CheckVerdict = iota + // CheckWithinBounds found no violation among the schedules the bounds let it search. + CheckWithinBounds + // CheckViolation found a violation. + CheckViolation + // CheckDivergent found no violation and a feature whose final value the schedule decides. + CheckDivergent +) + +func (v CheckVerdict) String() string { + switch v { + case CheckExhaustive: + return "no violation, exhaustive" + case CheckWithinBounds: + return "no violation within bounds" + case CheckViolation: + return "violation" + case CheckDivergent: + return "divergent" + } + return fmt.Sprintf("CheckVerdict(%d)", int(v)) +} + +// CheckReport is what a check of the schedules of an action found. +type CheckReport struct { + Verdict CheckVerdict + // States counts the distinct states visited; Moves the moves made; MaxDepth + // the longest schedule searched. + States int + Moves int + MaxDepth int + // BoundsHit names the bounds the search ran into: `depth`, `states`, and the + // executor's budgets by name (ExecutorBounds); none when exhaustive. + BoundsHit []string + // Limits are the executor's budgets the search ran under. + Limits Budgets + // Horizon is the instant the search stopped the clock at: no schedule was + // followed past it, and a state there is final. + Horizon Horizon + Violations []Violation + Divergent []Divergence + // Finals are the distinct outcomes of the complete schedules, in canonical order. + Finals []CheckFinal + // MassBounded reports the violations' masses are lower bounds: they are when a + // bound kept schedules out, moves left an interleaving out, a state was reached + // again, or the reduction left a move unexplored at a state. + MassBounded bool +} + +// Status renders how the check ended for a report. +func (r *CheckReport) Status() string { + s := r.Verdict.String() + if at, bounded := r.Horizon.Bounded(); bounded { + s += " up to t=" + semantics.FormatReal(at) + } + s += fmt.Sprintf(" (%d states, %d moves, depth %d", r.States, r.Moves, r.MaxDepth) + if len(r.BoundsHit) > 0 { + s += "; bounds hit: " + strings.Join(r.BoundsHit, ", ") + } + return s + ")" +} + +// CheckStopped is a check the caller stopped before it ended, with what it had +// searched so far; it unwraps to the caller's reason. +type CheckStopped struct { + States int + Moves int + MaxDepth int + Cause error +} + +func (e *CheckStopped) Error() string { + return fmt.Sprintf("check stopped after %d states and %d moves (depth %d): %v", e.States, e.Moves, e.MaxDepth, e.Cause) +} + +func (e *CheckStopped) Unwrap() error { return e.Cause } + +// Check searches the schedules of the invocation start begins in the context +// fresh makes. Every violation and every final value carries the witness a +// replay of the same starter follows (Replay). It stops with a CheckStopped +// when stop ends first; it fails when the run refused a move it selected. +// The start draws choice points itself (region entry order), so it is begun once per way of +// resolving them and the search runs from every state a start reaches. +func Check(stop context.Context, fresh func() (*Context, error), start Starter, budget CheckBudget, opts CheckOptions, props []CheckProperty) (*CheckReport, error) { + c := &checker{ + budget: budget, + opts: opts, + props: props, + visited: make(map[stateKey]*visitedState), + onStack: make(map[stateKey]int), + finals: make(map[string]int), + futures: make(map[futureKey]lower.Footprint), + machineFutures: make(map[*lower.StateGraph]lower.Footprint), + } + starts := [][]int{nil} + for i := 0; i < len(starts); i++ { + if err := stop.Err(); err != nil { + return nil, c.stopped(err) + } + more, err := c.searchFrom(stop, fresh, start, starts[i]) + if err != nil { + return nil, err + } + starts = append(starts, more...) + } + if err := stop.Err(); err != nil { + return nil, c.stopped(err) + } + if err := c.divergeReached(); err != nil { + return nil, err + } + return c.result(), nil +} + +// searchFrom begins the invocation with the start's choice points resolved by picks, searches +// from the state reached, and returns the pick sequences taking each other alternative drawn. +func (c *checker) searchFrom(stop context.Context, fresh func() (*Context, error), start Starter, picks []int) ([][]int, error) { + ctx, err := fresh() + if err != nil { + return nil, err + } + if err := ctx.SetSchedule(checkPolicy(&checkScript{due: -1, picks: picks})); err != nil { + return nil, err + } + if ctx.Trace() == nil { + ctx.SetTrace(NewTraceRecorder()) + } + c.ctx, c.budgets = ctx, ctx.Budgets() + run, err := beginInvocation(ctx, start) + check := run.checking() + if check.refused != nil { + return nil, check.refused + } + var more [][]int + for i, choice := range check.drawn { + for alt := 1; alt < len(choice.Alternatives); alt++ { + more = append(more, slices.Concat(picks, make([]int, i), []int{alt})) + } + } + // The start's own picks are the mass every schedule from it shares. + mass := 1.0 + for _, choice := range check.faced { + mass *= shareOf(choice, choice.Taken) + } + if err != nil { + // Failing to start fails on every schedule from it: a violation with no move. + c.violate(Violation{Kind: ViolationFailure, Err: err, Witness: c.failing(err), Mass: mass}) + return more, nil + } + defer run.inv.Release() + if err := run.inv.started(ctx); err != nil { + return nil, err + } + c.inv, c.run = run.inv, run + if err := c.resolveDiverge(); err != nil { + return nil, err + } + if err := c.search(stop, mass); err != nil { + return nil, err + } + return more, nil +} + +// checker is one search in progress. +type checker struct { + ctx *Context + inv *Invocation + run *invocationRun + budget CheckBudget + opts CheckOptions + props []CheckProperty + + visited map[stateKey]*visitedState + // onStack counts the frames on the stack at each state. + onStack map[stateKey]int + stack []*checkFrame + futures map[futureKey]lower.Footprint + // machineFutures is the footprint of a machine's whole graph, by graph. + machineFutures map[*lower.StateGraph]lower.Footprint + + // budgets are the executors' budgets, each a bound on the moves of its kind + // along one schedule. + budgets Budgets + moves int + maxDepth int + bounds []string + + violations []Violation + // finals indexes result.Finals by outcome identity. + finals map[string]int + results []CheckFinal + // revisit is set once the search reached a state a second time, leftOut once + // a frame ended with a move unexplored; either bounds the violation masses. + revisit, leftOut bool + // nested are the `node.pin` names Diverge selects; untold are those among them only + // a performance can tell, each dropped once a state held it. + nested, untold map[string]bool +} + +// visitedState is what the search remembers of a state: the moves explored from +// it, the shallowest depth it was searched from, whether a bound cut below it, +// and the properties found false at it. +type visitedState struct { + explored map[string]bool + depth int + cut bool + violated []string +} + +// checkFrame is one state on the search stack. +type checkFrame struct { + snap *Snapshot + // turn is the executor holding the turn in the state, nil for none. + turn checkedExecutor + key stateKey + depth int + // spent counts the moves of each kind the schedule to the state made. + spent spending + // all lists every move enabled in the state; moves the ones the search takes, + // in order; next indexes the one to take. + all []searchMove + moves []searchMove + next int + // sleep lists the moves asleep in the state: explored from an equivalent predecessor. + sleep []searchMove + // full is set once the state expands to every enabled move. + full bool + // cut is set once the depth bound cut a schedule through the state. + cut bool + // mass is the probability of the schedule reaching the state; units maps each + // executor a move belongs to to the units of its own the moves span, so a + // move's share weighs the owner pick and the unit pick as a run draws them. + // violated names the properties false on the path to it, so each is credited + // once per path. + mass float64 + units map[checkedExecutor]int + violated []string +} + +// spending counts the moves of one schedule by the executor budget each draws on. +type spending struct { + actionSteps, events, doSteps int64 +} + +// spend counts the move against its budget and reports the bound it exceeds, if any. +func (s *spending) spend(kind moveKind, budgets Budgets) (bound string, exceeded bool) { + switch kind { + case moveDispatch: + s.events++ + return BoundEvents, budgets.MaxStateEvents > 0 && s.events > budgets.MaxStateEvents + case moveDoStep: + s.doSteps++ + return BoundDoSteps, budgets.MaxDoSteps > 0 && s.doSteps > budgets.MaxDoSteps + } + s.actionSteps++ + return BoundActionSteps, budgets.MaxActionSteps > 0 && s.actionSteps > budgets.MaxActionSteps +} + +// searchMove is an enabled move with what the reduction needs of it: its +// canonical name and its footprint. +type searchMove struct { + enabledMove + name string + footprint lower.Footprint +} + +// same reports whether the two are one move: one unit taking one pick sequence. +func (m searchMove) same(o searchMove) bool { + return m.enabledMove.same(o.enabledMove) +} + +func containsMove(moves []searchMove, m searchMove) bool { + return slices.ContainsFunc(moves, m.same) +} + +func (c *checker) hit(bound string) { + if !slices.Contains(c.bounds, bound) { + c.bounds = append(c.bounds, bound) + } +} + +// violate records the violation; a property is reported once, by the shortest +// schedule found to reach a state where it is false, its mass the sum over the +// schedules reaching such a state. +func (c *checker) violate(v Violation) { + if v.Kind == ViolationProperty { + for i, seen := range c.violations { + if seen.Kind != ViolationProperty || seen.Name != v.Name { + continue + } + seen.Mass += v.Mass + if v.Depth < seen.Depth { + v.Mass = seen.Mass + c.violations[i] = v + } else { + c.violations[i].Mass = seen.Mass + } + return + } + } + c.violations = append(c.violations, v) +} + +// shareOf is the share of one choice point's alternative: the stated weight's +// share of the total for a weighted point, the uniform share otherwise. +func shareOf(c ChoicePoint, i int) float64 { + n := len(c.Alternatives) + if n == 0 { + return 1 + } + if c.Weighted() && i < len(c.Weights) { + total := 0.0 + for _, w := range c.Weights { + total += w + } + if total > 0 { + return c.Weights[i] / total + } + } + return 1 / float64(n) +} + +// witness is the schedule so far: the choices the run noted and its trace. +func (c *checker) witness() Witness { + w := Witness{DrawPolicy: c.ctx.DrawPolicyTaken(), ClockStep: c.ctx.ClockStepTaken(), Draws: c.ctx.DrawsTaken(), Choices: c.ctx.ChoicesTaken()} + w.Objects = c.ctx.objectsNamed(w.Choices) + if tr := c.ctx.Trace(); tr != nil { + w.Trace = tr.String() + } + return w +} + +// failing is the schedule so far ending in err, which a replay must raise again. +func (c *checker) failing(err error) Witness { + w := c.witness() + w.Fails = err.Error() + return w +} + +// search runs the depth-first search from the started invocation's state, +// stopping early once stop ends; mass is the probability of the start's picks. +func (c *checker) search(stop context.Context, mass float64) error { + if err := c.run.stabilize(); err != nil { + return c.failed(err, 0, mass) + } + if c.run.terminal() { + return c.complete(0, mass, nil) + } + root, key, err := c.enter(0, nil, mass, nil) + if err != nil || root == nil { + return err + } + c.onStack[key]++ + c.stack = append(c.stack, root) + for len(c.stack) > 0 { + if err := stop.Err(); err != nil { + c.releaseAll() + return c.stopped(err) + } + f := c.stack[len(c.stack)-1] + if f.next >= len(f.moves) { + c.stack = c.stack[:len(c.stack)-1] + c.onStack[f.key]-- + f.snap.Release() + if !f.full && len(f.moves) < len(f.all) { + c.leftOut = true + } + if f.cut && len(c.stack) > 0 { + c.cut(c.stack[len(c.stack)-1]) + } + continue + } + m := f.moves[f.next] + f.next++ + c.visited[f.key].explored[m.name] = true + f.snap.Restore() + c.run.turn = f.turn + if err := c.take(f, m); err != nil { + c.releaseAll() + return err + } + } + return nil +} + +// stopped is the check ended by its caller, with what it had searched so far. +func (c *checker) stopped(cause error) error { + return &CheckStopped{States: len(c.visited), Moves: c.moves, MaxDepth: c.maxDepth, Cause: cause} +} + +// cut marks the frame's state as one the depth bound cut a schedule through. +func (c *checker) cut(f *checkFrame) { + f.cut = true + c.visited[f.key].cut = true +} + +func (c *checker) releaseAll() { + for _, f := range c.stack { + f.snap.Release() + } + c.stack = nil +} + +// take makes the move from the frame's state and enters the state it reaches. +func (c *checker) take(f *checkFrame, m searchMove) error { + depth := f.depth + 1 + if c.budget.Depth > 0 && depth > c.budget.Depth { + c.hit("depth") + c.cut(f) + return nil + } + spent := f.spent + if bound, exceeded := spent.spend(m.Kind, c.budgets); exceeded { + c.hit(bound) + c.cut(f) + return nil + } + drawn, err := c.run.makeMove(m.enabledMove) + c.moves++ + c.maxDepth = max(c.maxDepth, depth) + f.reveal(m, drawn) + // The move's mass is its frame's shared among the owners a run draws between, + // then among the units of its own, times the share of every choice point it + // faced — scripted picks and drawn ones alike. + mass := f.mass / float64(max(len(f.units), 1)) / float64(max(f.units[m.Owner], 1)) + if check := c.run.checking(); check != nil { + for _, choice := range check.faced { + mass *= shareOf(choice, choice.Taken) + } + } + if err != nil { + return c.failed(err, depth, mass) + } + if err := c.run.stabilize(); err != nil { + return c.failed(err, depth, mass) + } + if c.run.terminal() { + return c.complete(depth, mass, f.violated) + } + child, key, err := c.enter(depth, c.childSleep(f, m), mass, f.violated) + if err != nil { + return err + } + if child != nil { + child.spent = spent + } + if seen := c.visited[key]; seen != nil && seen.cut { + c.cut(f) + } + if c.onStack[key] > 0 { + // A move closing a cycle on the stack: the state expands fully, so no move is ignored. + f.expand() + } + if child != nil { + c.onStack[key]++ + c.stack = append(c.stack, child) + } + return nil +} + +// failed classifies the error a move raised: a budget is a bound the search +// hit, a move the run made otherwise than selected fails the check, anything +// else is a violation on the schedule that reached it. +func (c *checker) failed(err error, depth int, mass float64) error { + if bound, isBound := boundOf(err); isBound { + c.hit(bound) + return nil + } + if errors.Is(err, ErrCheckRefused) { + return err + } + kind := ViolationFailure + if errors.Is(err, ErrActionDeadlock) || errors.Is(err, ErrAcceptDeadlock) { + kind = ViolationDeadlock + } + c.violate(Violation{Kind: kind, Err: err, Depth: depth, Witness: c.failing(err), Mass: mass}) + return nil +} + +// complete visits the terminal state the invocation reached: a state like any +// other, its properties evaluated when new, whose outcome is a final. +func (c *checker) complete(depth int, mass float64, violated []string) error { + if _, _, seen, _, err := c.visit(depth, mass, violated); err != nil || seen == nil { + return err + } + c.final() + return nil +} + +// visit records the stable state the invocation stands in, evaluating the +// properties at a new one; seen is nil when the states bound keeps the search out. +func (c *checker) visit(depth int, mass float64, violated []string) (form canonicalForm, key stateKey, seen *visitedState, visited bool, err error) { + form = c.run.canonicalState() + key = form.key() + if seen, visited = c.visited[key]; !visited { + if c.budget.States > 0 && len(c.visited) >= c.budget.States { + c.hit("states") + return form, key, nil, false, nil + } + seen = &visitedState{explored: make(map[string]bool), depth: depth} + c.visited[key] = seen + c.tellHeld() + c.properties(depth, mass, violated, seen) + } else { + c.revisit = true + // A path reaching a state where a property is false is credited the first + // time it violates it, however the property was found false here before. + for _, name := range seen.violated { + if !slices.Contains(violated, name) { + c.credit(name, mass) + } + } + if depth < seen.depth { + if seen.cut { + seen.explored = make(map[string]bool) + seen.cut = false + } + seen.depth = depth + } + } + return form, key, seen, visited, nil +} + +// credit adds the path's mass to the violation the property named stands for. +func (c *checker) credit(name string, mass float64) { + for i, v := range c.violations { + if v.Kind == ViolationProperty && v.Name == name { + c.violations[i].Mass += mass + return + } + } +} + +// enter visits the stable state the invocation stands in and returns the frame to +// search it from, nil when nothing remains to explore from it or a bound keeps +// the search out. +func (c *checker) enter(depth int, sleep []searchMove, mass float64, violated []string) (*checkFrame, stateKey, error) { + form, key, seen, visited, err := c.visit(depth, mass, violated) + if err != nil || seen == nil { + return nil, key, err + } + all := c.movesOf(form) + f := &checkFrame{turn: c.run.turn, key: key, depth: depth, all: all, sleep: sleep, + mass: mass, + units: ownerUnits(all), + violated: slices.Concat(violated, seen.violated), + } + f.moves = c.persistent(all, sleep) + if visited && !seen.wanted(f) { + return nil, key, nil + } + snap, err := c.inv.Snapshot() + if err != nil { + return nil, key, err + } + f.snap = snap + seen.mark(f.moves) + return f, key, nil +} + +// wanted drops from the frame the moves already explored from its state and +// reports whether any remain. +func (s *visitedState) wanted(f *checkFrame) bool { + kept := f.moves[:0] + for _, m := range f.moves { + if !s.explored[m.name] { + kept = append(kept, m) + } + } + f.moves = kept + return len(kept) > 0 +} + +func (s *visitedState) mark(moves []searchMove) { + for _, m := range moves { + s.explored[m.name] = true + } +} + +// movesOf lists the state's enabled moves with their canonical names and +// footprints, in canonical order. +func (c *checker) movesOf(form canonicalForm) []searchMove { + enabled := c.run.enabledMoves() + draw := len(owners(enabled)) > 1 + moves := make([]searchMove, 0, len(enabled)) + for _, m := range enabled { + moves = append(moves, c.named(form, m, draw)) + } + slices.SortFunc(moves, func(a, b searchMove) int { return strings.Compare(a.name, b.name) }) + return moves +} + +// named gives the move its canonical name — its executor's canonical name, its +// token's where it moves one, else its label, and its picks — and its footprint: +// the whole turn's where the move takes the turn, at a state drawing the due order. +func (c *checker) named(form canonicalForm, m enabledMove, draw bool) searchMove { + name := form.names[m.Owner] + ": " + if m.Token != 0 { + name += form.tokens[tokenKey{m.Owner, m.Token}] + } else { + name += m.Label + } + for _, pick := range m.Picks { + name = fmt.Sprintf("%s pick %d", name, pick+1) + } + footprint := c.footprintOf(m) + if draw { + footprint = c.turnFootprint(m) + } + return searchMove{enabledMove: m, name: name, footprint: footprint} +} + +// reveal adds, right after the move, the moves taking each other alternative of +// the choice points the move drew past its picks: one sibling per alternative, +// each taking the drawn choices before it at their first alternative. +func (f *checkFrame) reveal(m searchMove, drawn []ChoicePoint) { + var more []searchMove + for i, choice := range drawn { + for alt := 1; alt < len(choice.Alternatives); alt++ { + other := m + other.Picks = slices.Concat(m.Picks, make([]int, i), []int{alt}) + other.name = m.name + for _, pick := range other.Picks[len(m.Picks):] { + other.name = fmt.Sprintf("%s pick %d", other.name, pick+1) + } + more = append(more, other) + } + } + if len(more) == 0 { + return + } + f.all = slices.Insert(f.all, slices.IndexFunc(f.all, m.same)+1, more...) + f.moves = slices.Insert(f.moves, f.next, more...) +} + +// expand makes the frame explore every enabled move, the asleep ones included. +func (f *checkFrame) expand() { + if f.full { + return + } + f.full = true + f.sleep = nil + for _, m := range f.all { + if !containsMove(f.moves, m) { + f.moves = append(f.moves, m) + } + } +} + +// properties evaluates every property at the state; one false is a violation +// credited the path's mass unless the path already violated it, one failing to +// evaluate a failure, the witness naming the property either way. The names +// found false are remembered on the state, crediting paths reaching it again. +func (c *checker) properties(depth int, mass float64, violated []string, seen *visitedState) { + for _, p := range c.props { + holds, err := c.evaluate(p) + switch { + case err != nil: + w := c.failing(err) + w.Property = p.Name + c.violate(Violation{Kind: ViolationFailure, Name: p.Name, Err: err, Depth: depth, Witness: w, Mass: mass}) + case !holds: + if !slices.Contains(seen.violated, p.Name) { + seen.violated = append(seen.violated, p.Name) + } + if slices.Contains(violated, p.Name) { + continue + } + w := c.witness() + w.Property = p.Name + c.violate(Violation{Kind: ViolationProperty, Name: p.Name, Depth: depth, Witness: w, Mass: mass}) + } + } +} + +// ownerUnits maps each executor a move belongs to to the units of its own the +// moves span; a move's share of its frame's mass is one owner's times one of +// its units — the two draws a run makes where both wait. +func ownerUnits(moves []searchMove) map[checkedExecutor]int { + tokens := make(map[checkedExecutor]map[int64]bool) + for _, m := range moves { + set := tokens[m.Owner] + if set == nil { + set = make(map[int64]bool) + tokens[m.Owner] = set + } + set[m.Token] = true + } + counts := make(map[checkedExecutor]int, len(tokens)) + for owner, set := range tokens { + counts[owner] = len(set) + } + return counts +} + +// evaluate asks the property of the state under a probe: what evaluating it +// derives is given back. +func (c *checker) evaluate(p CheckProperty) (bool, error) { + defer c.ctx.beginProbe()() + return p.Holds(c.ctx, c.inv) +} + +// final records the outcome of a complete schedule, the first schedule +// reaching each distinct outcome being its witness. +func (c *checker) final() { + values, spelled, identity := c.spellFinal() + if _, seen := c.finals[identity]; seen { + return + } + c.finals[identity] = len(c.results) + c.results = append(c.results, CheckFinal{ + Outcome: spelled, + Values: values, + Witness: c.witness(), + identity: identity, + }) +} + +// spellFinal renders the completed state's outcome and divergence values under a probe; +// a selected performer feature the outcome leaves out (an item, one unset or in error) joins both. +func (c *checker) spellFinal() (values map[string]string, spelled, identity string) { + defer c.ctx.beginProbe()() + outcome := c.inv.Outcome() + values = c.divergenceValues() + spelled, identity = outcome.String(), outcome.identity() + prefixes := c.inv.performerPrefixes() + for _, name := range slices.Sorted(maps.Keys(values)) { + if _, carried := outcome.Outputs[name]; carried { + continue + } + if !slices.ContainsFunc(prefixes, func(p performer) bool { return strings.HasPrefix(name, p.name) }) { + continue + } + spelled += "; " + name + " = " + values[name] + identity += "; " + name + " = " + strconv.Quote(values[name]) + } + return values, spelled, identity +} + +// divergenceValues spells the observables divergence is reported over as the +// schedule left them, a selected feature holding no value as UnsetText: each +// action's own features from its root performance and its performed nodes' from +// the outputs under `node.`, each state machine's `finalState` and data, under +// the behavior's name in a joint invocation, and the performing objects' under +// their prefixes: `this.` for one object, `.` for several. +func (c *checker) divergenceValues() map[string]string { + defer c.ctx.beginProbe()() + values := make(map[string]string) + prefixes := c.inv.prefixes() + for i, exec := range c.inv.Actions { + c.actionDivergence(exec, prefixes[i], values) + } + for i, exec := range c.inv.States { + c.machineDivergence(exec, prefixes[len(c.inv.Actions)+i], values) + } + for _, p := range c.inv.performerPrefixes() { + c.performerDivergence(p.self, p.name, values) + } + return values +} + +// actionDivergence spells the action's own features and its performed nodes' +// under prefix. +func (c *checker) actionDivergence(exec *ActionExecutor, prefix string, values map[string]string) { + root := exec.root + own := make(map[string]Value) + for name := range root.ownFeatures() { + if !c.reportsDivergenceOf(root, prefix, name) { + continue + } + if value, held := root.data[name]; held { + own[name] = value + } else { + values[prefix+name] = UnsetText + } + } + for _, out := range c.ctx.ActionOutcome(own).RenderedOutputs() { + values[prefix+out.Name] = out.Text + } + if len(c.nested) == 0 { + return + } + held := c.heldUnderNodes(exec, prefix) + for name := range c.nested { + if held[name] { + values[name] = UnsetText + } + } + for _, out := range c.ctx.ActionOutcome(exec.Results()).RenderedOutputs() { + if _, wanted := c.nested[prefix+out.Name]; wanted { + values[prefix+out.Name] = out.Text + } + } +} + +// machineDivergence spells the machine's final configuration and its data under +// prefix: `finalState` bare, ` finalState` in a joint invocation. +func (c *checker) machineDivergence(exec *StateExecutor, prefix string, values map[string]string) { + final := finalStateKey(prefix) + if len(c.opts.Diverge) == 0 || slices.Contains(c.opts.Diverge, final) { + values[final] = exec.FinalStateName() + } + data := make(map[string]Value) + for name, value := range exec.StateData() { + if len(c.opts.Diverge) == 0 || slices.Contains(c.opts.Diverge, prefix+name) { + data[name] = value + } + } + for _, out := range c.ctx.ActionOutcome(data).RenderedOutputs() { + values[prefix+out.Name] = out.Text + } +} + +// finalStateKey names a machine's final configuration among the observables. +func finalStateKey(prefix string) string { + if prefix == "" { + return "finalState" + } + return strings.TrimSuffix(prefix, ".") + " finalState" +} + +// performerDivergence spells the performing object's features under prefix. +func (c *checker) performerDivergence(self *Instance, prefix string, values map[string]string) { + selfOwn := make(map[string]Value) + for name, held := range self.FeatureValues { + if !c.reportsPerformerDivergenceOf(prefix, name, held.Feature) { + continue + } + fv, err := self.GetFeatureValue(c.ctx, name) + if err != nil { + values[prefix+name] = "" + continue + } + switch { + case !fv.Materialized: + values[prefix+name] = UnsetText + case fv.Feature.Scalar(): + selfOwn[name] = fv.Value + default: + selfOwn[name] = fv.Values + } + } + for _, out := range c.ctx.ActionOutcome(selfOwn).RenderedOutputs() { + values[prefix+out.Name] = out.Text + } +} + +// reportsDivergenceOf reports whether the action's own feature, under the name +// its performance holds it by, is one divergence is reported over; absent names, every one is. +func (c *checker) reportsDivergenceOf(root *actionFrame, prefix, name string) bool { + if len(c.opts.Diverge) == 0 { + return true + } + return slices.ContainsFunc(c.opts.Diverge, func(d string) bool { + own, ofThis := strings.CutPrefix(d, prefix) + return ofThis && root.key(own) == name + }) +} + +// reportsPerformerDivergenceOf reports whether the performing object's feature, +// named under prefix, is one divergence is reported over; absent names, its attributes are. +func (c *checker) reportsPerformerDivergenceOf(prefix, name string, of *EffectiveFeature) bool { + if len(c.opts.Diverge) == 0 { + return of != nil && of.Symbol != nil && of.Symbol.Kind == symbols.SymbolAttributeUsage + } + return slices.Contains(c.opts.Diverge, prefix+name) +} + +// heldUnderNodes names, as `node.feature` under prefix, every feature the latest +// performances of the action's nodes hold at the state the executor stands in. +func (c *checker) heldUnderNodes(exec *ActionExecutor, prefix string) map[string]bool { + held := make(map[string]bool) + for name, sub := range exec.root.latestSubactions() { + sub.heldFeatures(prefix+name+".", held) + } + return held +} + +// tellHeld drops from untold every path a performance at this state holds. +func (c *checker) tellHeld() { + if len(c.untold) == 0 { + return + } + prefixes := c.inv.prefixes() + for i, exec := range c.inv.Actions { + for name := range c.heldUnderNodes(exec, prefixes[i]) { + delete(c.untold, name) + } + } +} + +// FeatureOwner tells whose feature a name a check compares resolves to. +type FeatureOwner int + +const ( + // OwnedByAction: a feature the action holds itself. + OwnedByAction FeatureOwner = iota + // OwnedByPerformer: `this.`, a feature of the object performing the action. + OwnedByPerformer + // OwnedByNode: `node.path`, a feature a performance of one of the action's nodes holds. + OwnedByNode +) + +// ResolveCheckFeatures resolves the names a check compares as Check does over an +// invocation of the started action exec alone, telling whose feature each is; a +// name nothing answers to is an UnknownCheckFeatureError. +func ResolveCheckFeatures(exec *ActionExecutor, names []string) (map[string]FeatureOwner, error) { + inv := &Invocation{Actions: []*ActionExecutor{exec}} + c := &checker{ctx: exec.ctx, inv: inv, opts: CheckOptions{Diverge: names}} + if err := c.resolveDiverge(); err != nil { + return nil, err + } + owners := make(map[string]FeatureOwner, len(names)) + for _, name := range names { + switch { + case strings.HasPrefix(name, "this."): + owners[name] = OwnedByPerformer + case c.nested[name]: + owners[name] = OwnedByNode + default: + owners[name] = OwnedByAction + } + } + return owners, nil +} + +// PerformerAttributes names, in name order, the attributes of the object performing +// the action: the features a check compares for it absent names; none without one. +func (e *ActionExecutor) PerformerAttributes() []string { + self := e.Performer() + if self == nil { + return nil + } + var names []string + for name, held := range self.FeatureValues { + if of := held.Feature; of != nil && of.Symbol != nil && of.Symbol.Kind == symbols.SymbolAttributeUsage { + names = append(names, name) + } + } + slices.Sort(names) + return names +} + +// resolveDiverge checks every name Diverge selects against what the started +// behaviors hold: `this.` the performing object's feature, `.` +// one of several performing objects'; under a behavior's name in a joint +// invocation, bare in a single one, an action's own feature, a `node.path` under +// a node it performs — told from the lowered flows where a call is settled and +// left to the performances where it is tied — or a machine's `finalState` or data. +func (c *checker) resolveDiverge() error { + c.nested, c.untold = make(map[string]bool), make(map[string]bool) + prefixes := c.inv.prefixes() + for _, name := range c.opts.Diverge { + if ofObject, err := c.resolvePerformerDiverge(name); ofObject { + if err != nil { + return err + } + continue + } + i, rest := c.inv.behaviorOf(prefixes, name) + switch { + case i < 0: + return &UnknownCheckFeatureError{Name: name, Reason: "no started behavior is named so"} + case i >= len(c.inv.Actions): + exec := c.inv.States[i-len(c.inv.Actions)] + if _, held := exec.StateData()[rest]; held || name == finalStateKey(prefixes[i]) { + continue + } + return &UnknownCheckFeatureError{Name: name, Reason: "the state machine holds no such feature"} + } + if err := c.resolveActionDiverge(c.inv.Actions[i], name, rest); err != nil { + return err + } + } + return nil +} + +// resolvePerformerDiverge checks a name of a performing object's feature: `this.` +// names the one object the behaviors perform on, `.` one of several; the +// name is not of an object when neither prefix applies. +func (c *checker) resolvePerformerDiverge(name string) (ofObject bool, err error) { + performers := c.inv.performers() + if feature, ofThis := strings.CutPrefix(name, "this."); ofThis { + switch len(performers) { + case 0: + return true, &UnknownCheckFeatureError{Name: name, Reason: "no object performs the behaviors"} + case 1: + return true, c.performerHolds(performers[0], name, feature) + } + names := make([]string, len(performers)) + for i, p := range performers { + names[i] = p.name + } + return true, &UnknownCheckFeatureError{Name: name, Reason: fmt.Sprintf( + "the behaviors perform on different objects, %s; name the object's feature, as %s.%s", + strings.Join(names, " and "), performers[0].name, feature)} + } + if len(performers) < 2 { + return false, nil + } + for _, p := range performers { + if feature, ofObject := strings.CutPrefix(name, p.name+"."); ofObject { + return true, c.performerHolds(p, name, feature) + } + } + return false, nil +} + +// performerHolds checks the object holds the feature, name being the feature as Diverge spells it. +func (c *checker) performerHolds(p performer, name, feature string) error { + if p.self.FeatureValues[feature] != nil { + return nil + } + object := "the performing object" + if !strings.HasPrefix(name, "this.") { + object = p.name + } + return &UnknownCheckFeatureError{Name: name, Reason: object + " has no feature " + feature} +} + +// resolveActionDiverge checks the action holds the feature rest names, own or +// under a node it performs; name is the feature as Diverge spells it. +func (c *checker) resolveActionDiverge(exec *ActionExecutor, name, rest string) error { + root := exec.root + if root.owns(rest) { + return nil + } + node, path, nested := strings.Cut(rest, ".") + named := root.nodesNamed(node) + if !nested || len(named) == 0 { + return &UnknownCheckFeatureError{Name: name, Reason: "the action holds no such feature and performs no such node"} + } + held, told := c.pathUnder(exec, exec.graph, named, path) + if !held { + return &UnknownCheckFeatureError{Name: name, Reason: "action node " + node + " holds no feature " + path} + } + c.nested[name] = true + if !told { + c.untold[name] = true + } + return nil +} + +// pathUnder reports whether a performance of a node among nodes, of graph, holds the +// feature path names — a pin of the node, or a path under a node its flow performs — +// and whether that is told before it runs; a call tied on its arguments' types, or a +// node whose pins cannot be told, may hold the path, the performance telling. +func (c *checker) pathUnder(exec *ActionExecutor, graph *lower.ActionGraph, nodes []ast.Node, path string) (held, told bool) { + told = true + for _, node := range nodes { + pins, err := exec.nodePins(graph, node) + if err != nil { + return true, false + } + settled, callees, tied := c.performedBy(graph, node) + if pins.declares(path) { + return true, !tied + } + name, rest, deeper := strings.Cut(path, ".") + if !deeper { + told = told && !tied + continue + } + if under := usagesNamed(graph.BlockNodes[node], name); len(under) > 0 { + if h, t := c.pathUnder(exec, graph, under, rest); h { + return true, t + } + } + if sub, owns := exec.subflowOf(graph, node); owns { + if h, t := c.pathUnder(exec, sub.Graph, flowNodesNamed(sub.Graph, name), rest); h { + return true, t + } + } + for _, callee := range callees { + flow, err := c.calleeFlow(graph, node, callee) + if err != nil { + return true, false + } + if h, t := c.pathUnder(exec, flow, flowNodesNamed(flow, name), rest); h { + return true, t && callee == settled + } + } + } + return false, told +} + +// performedBy is the action a node performs, settled, or the actions its call is tied +// among; a node performing none has neither. +func (c *checker) performedBy(graph *lower.ActionGraph, node ast.Node) (settled *symbols.Symbol, callees []*symbols.Symbol, tied bool) { + usage, ok := node.(*ast.Usage) + if !ok { + return nil, nil, false + } + inv, performs := nestedInvocation(usage) + if !performs || lower.IsCaseNode(usage) { + return nil, nil, false + } + settled, callees, err := actionCandidates(c.ctx, nodeScope(graph, node), inv) + if err != nil { + return nil, nil, true + } + if settled != nil { + return settled, []*symbols.Symbol{settled}, false + } + return nil, callees, true +} + +// calleeFlow lowers what node's performance of callee runs, as the executor will. +func (c *checker) calleeFlow(graph *lower.ActionGraph, node ast.Node, callee *symbols.Symbol) (*lower.ActionGraph, error) { + usage := node.(*ast.Usage) + inv, _ := nestedInvocation(usage) + inv.step, _ = stepSymbol(graph, node) + body, tool, err := c.ctx.performanceBody(inv.performed(callee), callee) + if err != nil { + return nil, err + } + return lowerPerformance(body, tool, c.ctx.Resolver()) +} + +// divergeReached fails the check when a path only a performance could tell was held +// at no state of an exhaustive search; a bounded one may have stopped short of it. +func (c *checker) divergeReached() error { + if len(c.bounds) > 0 { + return nil + } + for _, name := range c.opts.Diverge { + if c.untold[name] { + return &UnknownCheckFeatureError{Name: name, Reason: "no performance under the action held such a feature"} + } + } + return nil +} + +// result assembles what the search found. +func (c *checker) result() *CheckReport { + r := &CheckReport{ + States: len(c.visited), + Moves: c.moves, + MaxDepth: c.maxDepth, + BoundsHit: c.bounds, + Limits: c.ctx.Budgets(), + Horizon: c.horizon(), + Violations: c.violations, + Finals: slices.Clone(c.results), + } + sort.Slice(r.Finals, func(i, j int) bool { return r.Finals[i].identity < r.Finals[j].identity }) + r.Divergent = divergences(r.Finals) + switch { + case len(r.Violations) > 0: + r.Verdict = CheckViolation + case len(r.Divergent) > 0: + r.Verdict = CheckDivergent + case len(r.BoundsHit) > 0: + r.Verdict = CheckWithinBounds + default: + r.Verdict = CheckExhaustive + } + // The masses are exact only when every schedule was searched through states + // reached once — a bound, a left-out interleaving, a revisit, or a move the + // reduction skipped makes them lower bounds. + r.MassBounded = len(r.BoundsHit) > 0 || c.revisit || c.leftOut + return r +} + +func (c *checker) horizon() Horizon { + if c.inv == nil { + return Horizon{} + } + return c.inv.Horizon +} + +// divergences finds the features the finals disagree on, each value with the +// first final reaching it as its witness, features and values in order. +func divergences(finals []CheckFinal) []Divergence { + byFeature := make(map[string]map[string]Witness) + for _, final := range finals { + for name, value := range final.Values { + if byFeature[name] == nil { + byFeature[name] = make(map[string]Witness) + } + if _, seen := byFeature[name][value]; !seen { + byFeature[name][value] = final.Witness + } + } + } + var out []Divergence + for name, values := range byFeature { + if len(values) < 2 { + continue + } + d := Divergence{Feature: name} + for value, witness := range values { + d.Values = append(d.Values, DivergentValue{Value: value, Witness: witness}) + } + sort.Slice(d.Values, func(i, j int) bool { return d.Values[i].Value < d.Values[j].Value }) + out = append(out, d) + } + sort.Slice(out, func(i, j int) bool { return out[i].Feature < out[j].Feature }) + return out +} + +// The executor budgets a search runs under, by the name a bound hit reports; +// each names one field of Budgets, so a report spells the limit that stopped it. +const ( + BoundSteps = "steps" + BoundActionSteps = "actionSteps" + BoundEvents = "events" + BoundDoSteps = "doSteps" + BoundElements = "elements" + BoundBehaviors = "behaviors" +) + +// ExecutorBounds lists the executor budgets a bound hit may name, in report order. +var ExecutorBounds = []string{BoundSteps, BoundActionSteps, BoundEvents, BoundDoSteps, BoundElements, BoundBehaviors} + +// ExecutorBound is the limit the named executor bound has under the budgets, +// false for a name that is no executor bound's. The object behaviors' rounds +// are counted in events, so both names spell the events limit. +func ExecutorBound(name string, b Budgets) (int64, bool) { + switch name { + case BoundSteps: + return b.MaxSteps, true + case BoundActionSteps: + return b.MaxActionSteps, true + case BoundEvents, BoundBehaviors: + return b.MaxStateEvents, true + case BoundDoSteps: + return b.MaxDoSteps, true + case BoundElements: + return b.MaxElements, true + } + return 0, false +} + +// boundOf names the executor budget an error reports exhausted, false for an +// error that is no budget's; the behaviors' budget wraps the events one, so it +// is told apart first. +func boundOf(err error) (string, bool) { + switch { + case errors.Is(err, ErrBehaviorBudget): + return BoundBehaviors, true + case errors.Is(err, ErrStepLimitExceeded): + return BoundSteps, true + case errors.Is(err, ErrActionStepLimitExceeded): + return BoundActionSteps, true + case errors.Is(err, ErrStateEventLimitExceeded): + return BoundEvents, true + case errors.Is(err, ErrDoStepLimitExceeded): + return BoundDoSteps, true + case errors.Is(err, ErrElementLimitExceeded): + return BoundElements, true + } + return "", false +} diff --git a/internal/core/runtime/check_body.go b/internal/exec/runtime/check_body.go similarity index 85% rename from internal/core/runtime/check_body.go rename to internal/exec/runtime/check_body.go index 487a29bcdc..6f36350d2c 100644 --- a/internal/core/runtime/check_body.go +++ b/internal/exec/runtime/check_body.go @@ -25,9 +25,16 @@ func (s *stateSpeller) body(run *bodyRun) string { return b.String() } -// pause spells why a body paused: the breakpoint it met, or the wait it is in — -// the callee held by its caller, whole, else the performance whose flow waits. +// pause spells why a body paused: the statement boundary it yielded at, the token +// move it stepped, the breakpoint it met, or the wait it is in — the callee held by +// its caller, whole, else the performance whose flow waits. func (s *stateSpeller) pause(p bodyPause) string { + if p.yielded { + return "yielded between statements" + } + if p.tokenStep { + return "paused between two moves of its flow" + } if !p.onWait { return fmt.Sprintf("at breakpoint %q", p.breakpoint) } @@ -47,8 +54,8 @@ func (s *stateSpeller) nested(e *ActionExecutor) string { defer s.enter(e)() var b strings.Builder fmt.Fprintf(&b, "%s{state %s", symbolText(e.action), e.state) - if e.pausedAt != "" { - fmt.Fprintf(&b, " at %s", e.pausedAt) + if e.pausedAt.name != "" { + fmt.Fprintf(&b, " at %s", e.pausedAt.name) } for _, perf := range s.frames { b.WriteString("; ") @@ -105,7 +112,19 @@ func (f *loopFrame) spell(s *stateSpeller) string { } func (f *performFrame) spell(s *stateSpeller) string { - return fmt.Sprintf("perform %s phase %d", s.frameLabel(f.perf), f.phase) + ended := "" + if f.ended { + ended = " ended" + } + return fmt.Sprintf("perform %s phase %d%s", s.frameLabel(f.perf), f.phase, ended) +} + +func (f *blockFlowFrame) spell(s *stateSpeller) string { + ended := "" + if f.perf != nil && f.perf.ended { + ended = " ended" + } + return fmt.Sprintf("block flow %s%s", s.frameLabel(f.perf), ended) } func (f *subflowFrame) spell(s *stateSpeller) string { diff --git a/internal/core/runtime/check_corpus_test.go b/internal/exec/runtime/check_corpus_test.go similarity index 95% rename from internal/core/runtime/check_corpus_test.go rename to internal/exec/runtime/check_corpus_test.go index e10a160acf..1822865180 100644 --- a/internal/core/runtime/check_corpus_test.go +++ b/internal/exec/runtime/check_corpus_test.go @@ -9,13 +9,13 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // CheckExpected is a `.check.expected.json` beside a conformance case whose @@ -53,7 +53,7 @@ func checkCorpus(t *testing.T) []checkCase { } var cases []checkCase for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".expected.json") || strings.HasSuffix(entry.Name(), ".check.expected.json") { + if entry.IsDir() || !isConformanceCase(entry.Name()) { continue } name := strings.TrimSuffix(entry.Name(), ".expected.json") diff --git a/internal/core/runtime/check_horizon_test.go b/internal/exec/runtime/check_horizon_test.go similarity index 99% rename from internal/core/runtime/check_horizon_test.go rename to internal/exec/runtime/check_horizon_test.go index cd93dfca28..1d2b46343d 100644 --- a/internal/core/runtime/check_horizon_test.go +++ b/internal/exec/runtime/check_horizon_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // stateStarterOf starts the machine alone on the clock, up to the horizon. diff --git a/internal/core/runtime/check_invocation.go b/internal/exec/runtime/check_invocation.go similarity index 87% rename from internal/core/runtime/check_invocation.go rename to internal/exec/runtime/check_invocation.go index 4b1972f530..f037dfa849 100644 --- a/internal/core/runtime/check_invocation.go +++ b/internal/exec/runtime/check_invocation.go @@ -8,7 +8,8 @@ import ( "strconv" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // Invocation is what one check searches: the behaviors started on one clock, in @@ -195,7 +196,7 @@ func (inv *Invocation) Snapshot() (*Snapshot, error) { // Completed reports whether every started action reached its end. func (inv *Invocation) Completed() bool { for _, exec := range inv.Actions { - if exec.state != StateCompleted { + if !exec.state.Ended() { return false } } @@ -254,21 +255,52 @@ func (inv *Invocation) behaviorOf(prefixes []string, name string) (int, string) return -1, "" } -// Outcome is what the started behaviors came to: one behavior's own outcome, the -// joint outcome of several, each one's observables under its name. +// Outcome is what the started behaviors came to: one behavior's own outcome, or the joint +// outcome of several, plus what the performing objects hold (`this.` for one, `.` else). func (inv *Invocation) Outcome() Outcome { ctx := inv.Context() outcomes := make([]Outcome, 0, len(inv.Actions)+len(inv.States)) for _, exec := range inv.Actions { - outcomes = append(outcomes, ctx.ActionOutcome(exec.Results())) + outcomes = append(outcomes, exec.Outcome()) } for _, exec := range inv.States { outcomes = append(outcomes, exec.Outcome()) } - if len(outcomes) == 1 { - return outcomes[0] + var outcome Outcome + switch len(outcomes) { + case 0: + return Outcome{Outputs: make(map[string]Value)} + case 1: + outcome = outcomes[0] + default: + outcome = ctx.JointOutcome(inv.names(), outcomes) + } + for _, p := range inv.performerPrefixes() { + for name, value := range ctx.attributesHeld(p.self) { + outcome.Outputs[p.name+name] = value + } + } + return outcome +} + +// attributesHeld is the value each attribute of the object holds, a default derived +// when not yet read; an attribute holding nothing is left out. +func (ctx *Context) attributesHeld(self *Instance) map[string]Value { + held := make(map[string]Value) + for name, fv := range self.FeatureValues { + if of := fv.Feature; of == nil || of.Symbol == nil || of.Symbol.Kind != symbols.SymbolAttributeUsage { + continue + } + fv, err := self.GetFeatureValue(ctx, name) + switch { + case err != nil || !fv.Materialized: + case fv.Feature.Scalar(): + held[name] = fv.Value + default: + held[name] = fv.Values + } } - return ctx.JointOutcome(inv.names(), outcomes) + return held } // executors lists every executor the check moves, in canonical order: the started diff --git a/internal/exec/runtime/check_loop_replay_test.go b/internal/exec/runtime/check_loop_replay_test.go new file mode 100644 index 0000000000..a286c3c833 --- /dev/null +++ b/internal/exec/runtime/check_loop_replay_test.go @@ -0,0 +1,156 @@ +package runtime + +import ( + "context" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// loopingDoModel loads the conformance case whose `looping` state runs a `do` +// body of two branches each waiting on the clock and looping back, while a +// timed transition leaves the state; nested puts the machine on a part two deep. +func loopingDoModel(t *testing.T, nested bool) *exploreModel { + t.Helper() + more := "" + if nested { + more = ` + part def Vehicle { exhibit state modes : Machine; } + part def Mission { part vehicle : Vehicle; } + part mission : Mission; +` + } + return parseLibraryModel(t, loopingDoText(t, more)) +} + +// loopingDoText is the conformance case's text with more members in its package. +func loopingDoText(t *testing.T, more string) string { + t.Helper() + text, err := os.ReadFile(filepath.Join("testdata", "conformance", "state_do_action_loop_timed_exit.sysml")) + if err != nil { + t.Fatal(err) + } + return string(text[:len(text)-2]) + more + "}\n" +} + +// nestedStateStarterOf starts the machine on the object the path reaches under an +// instance of the part, up to the horizon. +func nestedStateStarterOf(part, sym *symbols.Symbol, path string, horizon Horizon) Starter { + return func(ctx *Context) (*Invocation, error) { + if _, err := ctx.Instantiate(part); err != nil { + return nil, err + } + obj, err := ctx.objectAt(path) + if err != nil { + return nil, err + } + exec, err := ctx.CreateStateExecutorFor(sym, obj) + if err != nil { + return nil, err + } + return &Invocation{States: []*StateExecutor{exec}, Horizon: horizon}, nil + } +} + +// Every witness of a check over a state whose `do` body loops through timed +// waits replays to its trace: at the round the body's branches are due together +// the timed exit is due too, and the moves the checker records there — the step +// order between the body and the exit after each token move, and the token order +// — are the moves replay makes, whether the machine runs at top level or on a part +// nested in another. The check diverges over `left` and `right`: the exit before +// either branch wrote, after one, or after both. +func TestCheckWitnessesOfALoopingDoRoundReplay(t *testing.T) { + for _, nested := range []bool{false, true} { + name := "top-level" + if nested { + name = "nested performer" + } + t.Run(name, func(t *testing.T) { + m := loopingDoModel(t, nested) + sym := m.state(t, "Machine") + start := stateStarterOf(sym, HorizonAt(5)) + if nested { + mission := namedOrFoundSymbol(t, m.idx, "test::mission", m.idx.DocumentRoot(m.path), ast.DefPart, ast.UsagePart) + start = nestedStateStarterOf(mission, sym, "test::mission#1.vehicle", HorizonAt(5)) + } + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, unreduced(), nil) + if err != nil { + t.Fatal(err) + } + if report.Verdict != CheckDivergent || len(report.Finals) == 0 || len(report.Violations) != 0 { + t.Fatalf("check: %s, want no violation, divergent, with finals", report.Status()) + } + if len(report.BoundsHit) != 0 { + t.Fatalf("check: %s, want no bound hit", report.Status()) + } + features := make([]string, 0, len(report.Divergent)) + for _, d := range report.Divergent { + features = append(features, d.Feature) + } + if !slices.Equal(features, []string{"left", "right"}) { + t.Fatalf("divergent %v, want left and right: the exit drawn before, between or after the branches' moves", report.Divergent) + } + for _, final := range report.Finals { + if final.Values["finalState"] != "heard+finished" || final.Values["late"] != "1" { + t.Fatalf("final %s, want heard+finished with late at 1", final.Outcome) + } + if !slices.ContainsFunc(final.Witness.Choices, func(c ChoiceTaken) bool { return c.Kind == ChoiceStepOrder }) { + t.Fatalf("witness %s records no step order at the round the exit is due", FormatChoices(final.Witness.Choices)) + } + if !slices.ContainsFunc(final.Witness.Choices, func(c ChoiceTaken) bool { return c.Kind == ChoiceTokenOrder }) { + t.Fatalf("witness %s records no token order at the do round", FormatChoices(final.Witness.Choices)) + } + r := replayWitness(t, m, start, final.Witness, final.Outcome) + if got := r.Ctx.Trace().String(); got != final.Witness.Trace { + t.Errorf("replay of %s left the trace\n%s\nwant the witness's\n%s", final.Outcome, got, final.Witness.Trace) + } + } + }) + } +} + +// An action performed inline in another's flow steps its own tokens within the +// performer's step: the checker records the inner branches' order at the step +// number the performer's token has, and replay follows it in the inner flow +// rather than refusing it against the outer token. +func TestCheckWitnessesOfAnInlinePerformanceReplay(t *testing.T) { + m := parseExploreModel(t, inlinePerformanceModel) + sym := m.action(t, "outer") + start := starterOf(sym) + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, unreduced(), nil) + if err != nil { + t.Fatal(err) + } + if report.Verdict != CheckExhaustive || len(report.Finals) != 1 { + t.Fatalf("check: %s, want no violation, exhaustive, one final", report.Status()) + } + for _, f := range report.Finals { + steps := make([]int, 0, len(f.Witness.Choices)) + for _, c := range f.Witness.Choices { + steps = append(steps, c.Step) + } + if !slices.Equal(steps, []int{3}) { + t.Fatalf("witness %s, want the inner order alone, at the performer's step 3", FormatChoices(f.Witness.Choices)) + } + r := replayWitness(t, m, start, f.Witness, f.Outcome) + if got := r.Ctx.Trace().String(); got != f.Witness.Trace { + t.Errorf("replay left the trace\n%s\nwant the witness's\n%s", got, f.Witness.Trace) + } + } + run := func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } + x := m.exploreAction(t, "explore", "outer") + if !x.Complete() || len(x.Outcomes) != 1 { + t.Fatalf("explore: %s, want one outcome, complete", x.Status()) + } + assertWitnessesReplay(t, x, m.fresh, run) +} diff --git a/internal/core/runtime/check_moves.go b/internal/exec/runtime/check_moves.go similarity index 78% rename from internal/core/runtime/check_moves.go rename to internal/exec/runtime/check_moves.go index 03c889814e..a76f01f24e 100644 --- a/internal/core/runtime/check_moves.go +++ b/internal/exec/runtime/check_moves.go @@ -6,7 +6,7 @@ import ( "fmt" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // The model checker's view of an invocation's state: the moves enabled in it, @@ -34,6 +34,8 @@ const ( moveDispatch // moveDoStep runs one due do behavior of a state machine one unit. moveDoStep + // moveEntry continues a held state entry. + moveEntry ) func (k moveKind) String() string { @@ -54,6 +56,8 @@ func (k moveKind) String() string { return "dispatch" case moveDoStep: return "do" + case moveEntry: + return "entry" } return fmt.Sprintf("moveKind(%d)", int(k)) } @@ -146,6 +150,9 @@ func (e *ActionExecutor) enabledMoves() []enabledMove { // moveKindOf classifies the move of an enabled token by its node. func (e *ActionExecutor) moveKindOf(t Token) moveKind { + if e.dynamics != nil { + return moveTrigger + } if t.body != nil { return moveResume } @@ -184,47 +191,62 @@ func (e *ActionExecutor) incomplete() error { return nil } -// enabledMoves lists the moves of the machine's state as its next unit of work -// runs them: the due do behaviors of the round under way, else the dispatch due, -// else the do behaviors due for a new round. +// enabledMoves lists the moves oneUnit makes: the dispatch a closed round owes; else +// the round's do steps and an acting dispatch, picked as the step order lists them. func (e *StateExecutor) enabledMoves() []enabledMove { defer e.ctx.beginExecutorRun(&e.driven)() if e.state != StateRunning && e.state != StateSuspended { return nil } - if !e.roundDone { - if due := e.dueRound(); len(due) > 0 { - return e.doMoves(due) + if len(e.held) > 0 { + dispatch, free := e.dispatchFree(e.dueDispatch()) + var moves []enabledMove + if free { + for _, move := range e.dispatchMoves(dispatch, true) { + move.Picks = slices.Concat([]int{0}, move.Picks) + moves = append(moves, move) + } + } + for i, item := range e.held { + picks := []int(nil) + if free || len(e.held) > 1 { + pick := i + if free { + pick++ + } + picks = []int{pick} + } + moves = append(moves, enabledMove{Owner: e, Node: item.owner, Label: e.entryLabel(item.owner), Kind: moveEntry, Picks: picks}) } + return moves + } + dispatch := e.dueDispatch() + due := e.dueDoActions() + if len(due) == 0 { + return e.dispatchMoves(dispatch, false) } - if moves := e.dispatchMoves(); len(moves) > 0 { + moves := e.doMoves(due, len(due) >= 2) + if !dispatch.acts { return moves } - return e.doMoves(e.dueRound()) -} - -// dueRound lists the do actions the next do step picks among: the round under -// way, or a new round of the due ones, those still registered. -func (e *StateExecutor) dueRound() []*doAction { - round := e.round - if len(round) == 0 { - for _, act := range e.doActions { - if act.due(e.ctx) { - round = append(round, act) - } - } + for i := range moves { + moves[i].Picks = slices.Concat([]int{0}, moves[i].Picks) + } + for _, m := range e.dispatchMoves(dispatch, true) { + m.Picks = slices.Concat([]int{1}, m.Picks) + moves = append(moves, m) } - return slices.DeleteFunc(slices.Clone(round), func(act *doAction) bool { return !e.isRunningDoAction(act) }) + return moves } -// doMoves is one do-step move per due do action, picked by its index in the round -// when the round draws an order. -func (e *StateExecutor) doMoves(due []*doAction) []enabledMove { +// doMoves is one do-step move per due do action, picked by its index among them +// where the unit draws an order among them. +func (e *StateExecutor) doMoves(due []*doAction, picked bool) []enabledMove { moves := make([]enabledMove, 0, len(due)) names := e.stateNames(statesOf(due)) for i, act := range due { m := enabledMove{Owner: e, Node: act.state, Kind: moveDoStep, Label: "do " + names[i]} - if len(due) >= 2 { + if picked { m.Picks = []int{i} } moves = append(moves, m) @@ -240,23 +262,21 @@ func statesOf(acts []*doAction) []*ast.StateNode { return states } -// dispatchMoves is the dispatch due, if any: one move when a change condition -// has risen or a signal is in flight — what is dispatched then follows from the -// machine — else one per event tied at the head of the queue, picked by its -// place among them. -func (e *StateExecutor) dispatchMoves() []enabledMove { - if e.changeRisen() || e.hasPendingSignal() { - return []enabledMove{{Owner: e, Kind: moveDispatch, Label: "dispatch"}} - } - if !e.hasDueEvent() { +// dispatchMoves is the dispatch due: one move, or one per event tied at the head +// picked by its place among them — among the acting ones where a step order draws it. +func (e *StateExecutor) dispatchMoves(d dueDispatch, stepOrder bool) []enabledMove { + if !d.due { return nil } - tied := e.eventQueue.Tied() - if len(tied) < 2 { - return []enabledMove{{Owner: e, Kind: moveDispatch, Label: "dispatch " + e.eventLabel(e.eventQueue.Peek())}} + events, label := d.tied, d.label + if stepOrder { + events, label = d.among, d.step + } + if len(events) < 2 { + return []enabledMove{{Owner: e, Kind: moveDispatch, Label: label}} } - moves := make([]enabledMove, 0, len(tied)) - for i, event := range tied { + moves := make([]enabledMove, 0, len(events)) + for i, event := range events { moves = append(moves, enabledMove{Owner: e, Kind: moveDispatch, Picks: []int{i}, Label: "dispatch " + e.eventLabel(event)}) } return moves diff --git a/internal/core/runtime/check_moves_test.go b/internal/exec/runtime/check_moves_test.go similarity index 98% rename from internal/core/runtime/check_moves_test.go rename to internal/exec/runtime/check_moves_test.go index 343f98b23f..e97ddcc8e0 100644 --- a/internal/core/runtime/check_moves_test.go +++ b/internal/exec/runtime/check_moves_test.go @@ -6,9 +6,9 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // checkedAction is a one-action invocation moved move by move under the check policy. diff --git a/internal/core/runtime/check_reduce.go b/internal/exec/runtime/check_reduce.go similarity index 98% rename from internal/core/runtime/check_reduce.go rename to internal/exec/runtime/check_reduce.go index f5d8919f01..3596f7f9e2 100644 --- a/internal/core/runtime/check_reduce.go +++ b/internal/exec/runtime/check_reduce.go @@ -3,8 +3,8 @@ package runtime import ( "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // Static partial-order reduction: from each state the search explores a @@ -47,6 +47,9 @@ func (c *checker) footprintOf(m enabledMove) lower.Footprint { // standing is the footprint of the node the token stands at; a body paused // mid-statement goes on with the rest of that node's, which the node's covers. func (c *checker) standing(exec *ActionExecutor, t Token) lower.Footprint { + if exec.dynamics != nil { + return dynamicsFootprint() + } return tokenGraphOf(exec, t).Footprints()[t.Location] } @@ -172,6 +175,9 @@ func (c *checker) machineFuture(e *StateExecutor) lower.Footprint { // tokenFuture is what the token may still touch: every node reachable from its // own, the one it stands at included, and from the nodes its frames stand at. func (c *checker) tokenFuture(exec *ActionExecutor, t Token) lower.Footprint { + if exec.dynamics != nil { + return dynamicsFootprint() + } future := c.reach(tokenGraphOf(exec, t), t.Location) for frame := t.frame; frame != nil && frame.node != nil; frame = frame.parent { flow := frame.flow diff --git a/internal/exec/runtime/check_reduce_state_test.go b/internal/exec/runtime/check_reduce_state_test.go new file mode 100644 index 0000000000..0124a4a747 --- /dev/null +++ b/internal/exec/runtime/check_reduce_state_test.go @@ -0,0 +1,263 @@ +package runtime + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// checkedInvocation is an invocation under the check policy, with the checker +// state the reduction reads footprints through. +type checkedInvocation struct { + run *invocationRun + c *checker +} + +func startCheckedInvocation(t *testing.T, m *exploreModel, actions, states []string) *checkedInvocation { + t.Helper() + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, checkPolicy(&checkScript{due: -1})) + var actionSyms, stateSyms []*symbols.Symbol + for _, name := range actions { + actionSyms = append(actionSyms, m.action(t, name)) + } + for _, name := range states { + stateSyms = append(stateSyms, m.state(t, name)) + } + run, err := beginInvocation(ctx, invocationOf(actionSyms, stateSyms)) + if err != nil { + t.Fatalf("start: %v", err) + } + t.Cleanup(run.inv.Release) + c := &checker{ + ctx: ctx, + inv: run.inv, + run: run, + opts: reduced(), + futures: make(map[futureKey]lower.Footprint), + machineFutures: make(map[*lower.StateGraph]lower.Footprint), + } + return &checkedInvocation{run: run, c: c} +} + +// moveOf finds the one enabled move of the named executor, after settling. +func (ci *checkedInvocation) moveOf(t *testing.T, owner string) enabledMove { + t.Helper() + if err := ci.run.stabilize(); err != nil { + t.Fatalf("stabilize: %v", err) + } + moves := ci.run.enabledMoves() + var found []enabledMove + for _, m := range moves { + if m.Owner.dueLabel() == owner { + found = append(found, m) + } + } + if len(found) != 1 { + t.Fatalf("%s has %d moves among %s", owner, len(found), moveLabels(moves)) + } + return found[0] +} + +// moveOfExecutor finds the one enabled move of the named executor whether or +// not another holds the turn. +func (ci *checkedInvocation) moveOfExecutor(t *testing.T, owner string) enabledMove { + t.Helper() + defer ci.run.enter()() + for _, exec := range ci.run.inv.executors() { + if exec.dueLabel() != owner { + continue + } + moves := exec.enabledMoves() + if len(moves) != 1 { + t.Fatalf("%s has %d moves among %s", owner, len(moves), moveLabels(moves)) + } + return moves[0] + } + t.Fatalf("no executor %s", owner) + return enabledMove{} +} + +func placeNames(places []lower.Place) string { + names := make([]string, len(places)) + for i, p := range places { + names[i] = p.String() + } + slices.Sort(names) + return strings.Join(names, ",") +} + +func TestStateDispatchFootprintsAreTheTransitionsOut(t *testing.T) { + m := reductionModel(t, "por_two_machines", false) + ci := startCheckedInvocation(t, m, nil, []string{"turner", "router", "loner"}) + turner := ci.moveOf(t, "state machine turner") + router := ci.moveOf(t, "state machine router") + loner := ci.moveOf(t, "state machine loner") + for _, m := range []enabledMove{turner, router, loner} { + if m.Kind != moveDispatch { + t.Fatalf("%s is a %s, want a dispatch", m, m.Kind) + } + } + writes := placeNames(ci.c.footprintOf(turner).Writes) + if !strings.Contains(writes, "level") { + t.Fatalf("turner's dispatch writes %q, want level", writes) + } + reads := placeNames(ci.c.footprintOf(router).Reads) + if !strings.Contains(reads, "level") { + t.Fatalf("router's dispatch reads %q, want level", reads) + } + if !ci.c.footprintOf(turner).Dependent(ci.c.footprintOf(router)) { + t.Fatal("turner's effect and router's guard are independent") + } + for _, other := range []enabledMove{turner, router} { + if ci.c.footprintOf(loner).Dependent(ci.c.footprintOf(other)) { + t.Fatalf("loner depends on %s", other) + } + if ci.c.futureOf(loner).Dependent(ci.c.futureOf(other)) { + t.Fatalf("loner's future depends on %s's", other) + } + } + if !ci.c.futureOf(turner).Dependent(ci.c.futureOf(router)) { + t.Fatal("turner's and router's futures are independent") + } +} + +// A dispatch out of a join's sources synchronizes on every other segment and +// leaves every region: it reads a sibling segment's guard and writes what the +// exit of a region no segment leaves from does, so an action touching either is +// dependent on it. +func TestStateDispatchFootprintCoversTheJoinsSiblings(t *testing.T) { + cases := []struct { + file, action, place string + reads bool + }{ + {file: "por_state_join_exit", action: "reader", place: "count"}, + {file: "por_state_join_guard", action: "arm", place: "armed", reads: true}, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + m := reductionModel(t, tc.file, false) + ci := startCheckedInvocation(t, m, []string{tc.action}, []string{"gather"}) + // The machine enters its regions and the action sends Go, which queues the dispatch. + for _, first := range []string{"state machine gather", "action " + tc.action, "action " + tc.action} { + m := ci.moveOf(t, first) + if _, err := ci.run.makeMove(m); err != nil { + t.Fatalf("move %s: %v", m, err) + } + } + act := ci.moveOf(t, "action "+tc.action) + dispatch := ci.moveOfExecutor(t, "state machine gather") + if dispatch.Kind != moveDispatch { + t.Fatalf("%s is a %s, want a dispatch", dispatch, dispatch.Kind) + } + fp := ci.c.footprintOf(dispatch) + if fp.Dynamic { + t.Fatalf("the dispatch is dynamic: %s", fp) + } + places, side := fp.Writes, "writes" + if tc.reads { + places, side = fp.Reads, "reads" + } + if got := placeNames(places); !strings.Contains(got, tc.place) { + t.Fatalf("the dispatch %s %q, want %s", side, got, tc.place) + } + if !ci.c.footprintOf(dispatch).Dependent(ci.c.footprintOf(act)) { + t.Fatalf("the dispatch and %s are independent", act) + } + }) + } +} + +func TestStateDoStepFootprintIsTheBehaviorPending(t *testing.T) { + m := reductionModel(t, "por_state_do_write", false) + ci := startCheckedInvocation(t, m, []string{"reader"}, []string{"counter"}) + entry := ci.moveOf(t, "state machine counter") + if _, err := ci.run.makeMove(entry); err != nil { + t.Fatalf("move %s: %v", entry, err) + } + step := ci.moveOf(t, "state machine counter") + if step.Kind != moveDoStep { + t.Fatalf("%s is a %s, want a do step", step, step.Kind) + } + fp := ci.c.footprintOf(step) + if fp.Dynamic { + t.Fatalf("the do step is dynamic: %s", fp) + } + if writes := placeNames(fp.Writes); !strings.Contains(writes, "count") { + t.Fatalf("the do step writes %q, want count", writes) + } + if future := ci.c.futureOf(step); !strings.Contains(placeNames(future.Writes), "count") { + t.Fatalf("the machine's future writes %q, want count", placeNames(future.Writes)) + } +} + +// A machine's future is the union of its transitions' footprints, the +// completion any of them queues included. +func TestMachineFutureKeepsCompletion(t *testing.T) { + m := reductionModel(t, "por_two_machines", false) + ci := startCheckedInvocation(t, m, nil, []string{"turner", "loner"}) + loner := ci.moveOf(t, "state machine loner") + if !ci.c.footprintOf(loner).Completion { + t.Fatal("loner's dispatch enters two, whose completion transition queues a completion") + } + if !ci.c.futureOf(loner).Completion { + t.Fatal("loner's future lost the completion its dispatch queues") + } + if ci.c.futureOf(ci.moveOf(t, "state machine turner")).Completion { + t.Fatal("turner's future queues a completion no transition of it does") + } + union := unionFootprints(lower.Footprint{Completion: true}, lower.Footprint{}) + if !union.Completion || !unionFootprints(lower.Footprint{}, union).Completion { + t.Fatal("the union of footprints drops a completion") + } +} + +func TestStateMoveUnitsAreTheirExecutor(t *testing.T) { + m := reductionModel(t, "por_two_machines", false) + ci := startCheckedInvocation(t, m, nil, []string{"turner", "router", "loner"}) + turner := ci.moveOf(t, "state machine turner") + before := ci.moveOf(t, "state machine loner") + if _, err := ci.run.makeMove(before); err != nil { + t.Fatalf("move %s: %v", before, err) + } + after := ci.moveOf(t, "state machine loner") + if before.unit() != after.unit() { + t.Fatalf("loner's unit moved from %v to %v", before.unit(), after.unit()) + } + if before.unit() == turner.unit() { + t.Fatal("two machines share a unit") + } +} + +func TestFailingMoveFootprintIsDynamic(t *testing.T) { + m := reductionModel(t, "por_two_machines", false) + ci := startCheckedInvocation(t, m, nil, []string{"loner"}) + failing := ci.moveOf(t, "state machine loner") + failing.Fails = errors.New("refused") + if !ci.c.footprintOf(failing).Dynamic || !ci.c.futureOf(failing).Dynamic { + t.Fatal("a failing move's footprint is not dynamic") + } +} + +// futureOf is the footprint of every move the unit may make from where it +// stands: for a token, the nodes it can reach in its flow and, when its flow +// ends, in the flows it returns to; for a machine, its whole graph. +func (c *checker) futureOf(m enabledMove) lower.Footprint { + if m.Fails != nil { + return lower.Footprint{Dynamic: true} + } + switch exec := m.Owner.(type) { + case *ActionExecutor: + return c.tokenFuture(exec, exec.tokens[exec.tokenIndex(m.Token)]) + case *StateExecutor: + return c.machineFuture(exec) + } + return lower.Footprint{Dynamic: true} +} diff --git a/internal/core/runtime/check_reduce_test.go b/internal/exec/runtime/check_reduce_test.go similarity index 96% rename from internal/core/runtime/check_reduce_test.go rename to internal/exec/runtime/check_reduce_test.go index 2ccd1edc94..89ef631716 100644 --- a/internal/core/runtime/check_reduce_test.go +++ b/internal/exec/runtime/check_reduce_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) var updateCheckReduction = flag.Bool("update-check-reduction", false, "Rewrite the reduction ratchet of testdata/check") @@ -28,12 +28,15 @@ var reductionCorpus = []reductionCase{ {file: "por_independent_branches", action: "parallel"}, {file: "por_alias", action: "aliased"}, {file: "por_address", action: "addressed"}, + {file: "por_streaming_flow", action: "stream"}, {file: "por_constructor", action: "populate", library: true}, {file: "por_state_independent", action: "parallel", states: []string{"stepper"}}, {file: "por_state_effect_write", action: "watcher", states: []string{"lighter"}}, {file: "por_state_guard_read", action: "turner", states: []string{"router"}}, {file: "por_state_do_write", action: "reader", states: []string{"counter"}}, {file: "por_state_send_accept", action: "sender", states: []string{"receiver"}}, + {file: "por_state_join_exit", action: "reader", states: []string{"gather"}}, + {file: "por_state_join_guard", action: "arm", states: []string{"gather"}}, {file: "por_two_machines", states: []string{"turner", "router", "loner"}}, } diff --git a/internal/core/runtime/check_replay.go b/internal/exec/runtime/check_replay.go similarity index 100% rename from internal/core/runtime/check_replay.go rename to internal/exec/runtime/check_replay.go diff --git a/internal/core/runtime/check_run.go b/internal/exec/runtime/check_run.go similarity index 95% rename from internal/core/runtime/check_run.go rename to internal/exec/runtime/check_run.go index bd5a61b0d5..6577502b60 100644 --- a/internal/core/runtime/check_run.go +++ b/internal/exec/runtime/check_run.go @@ -3,7 +3,7 @@ package runtime import ( "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // An invocation under check or replay moves one executor one unit at a time. The @@ -33,7 +33,7 @@ func beginInvocation(ctx *Context, start Starter) (*invocationRun, error) { defer r.enter()() inv, err := start(ctx) if err != nil { - return nil, err + return r, err } r.inv = inv return r, nil @@ -47,6 +47,12 @@ func (r *invocationRun) enter() func() { return r.ctx.enterRun(r.state) } +// checking is the run's resolution under the `check` policy, nil under another. +func (r *invocationRun) checking() *checkRun { + defer r.enter()() + return r.ctx.scheduling().check +} + // enabledMoves lists the moves of the state: the turn holder's while it has one // and another executor has one too, else every executor's in executor order, the // turn given up. @@ -126,7 +132,8 @@ func (r *invocationRun) stabilize() error { // park steps every running action with no move so its tokens park: at their // accepts, or on the clock. Under check the step is scripted to select none. An -// action whose tokens all stand parked is left as it is: its step is the clock's to retry. +// action whose tokens all stand parked is left as it is: its step is the clock's to +// retry. func (r *invocationRun) park() error { defer r.enter()() for _, exec := range r.inv.executors() { @@ -155,7 +162,7 @@ func (r *invocationRun) advanceClock() bool { if !ok || next <= r.ctx.clock.now { return false } - r.ctx.clock.now = next + r.ctx.setClock(next) r.turn = nil return true } diff --git a/internal/core/runtime/check_schedule.go b/internal/exec/runtime/check_schedule.go similarity index 88% rename from internal/core/runtime/check_schedule.go rename to internal/exec/runtime/check_schedule.go index 2738fa178e..5b21c7bbf8 100644 --- a/internal/core/runtime/check_schedule.go +++ b/internal/exec/runtime/check_schedule.go @@ -70,16 +70,19 @@ type checkRun struct { script *checkScript refused error // picked counts the picks the move under way consumed; drawn are the choice - // points it faced past them, each taken at its first alternative. + // points it faced past them, each taken at its first alternative. faced is + // every choice point the move resolved, scripted picks and drawn alike, each + // at the alternative taken — the probability mass of the move is their product. picked int drawn []ChoicePoint + faced []ChoicePoint // move is the step under way, nil between steps. move *checkMove } // begin starts a move: the picks are consumed from the first, nothing is drawn yet. func (r *checkRun) begin() { - r.picked, r.drawn, r.move = 0, nil, nil + r.picked, r.drawn, r.faced, r.move = 0, nil, nil, nil } // refuse records the first selected move the run could not make. @@ -122,10 +125,12 @@ type checkMove struct { // beginStep resolves the step as a replayed one resolves a witness move: the // selected token alone when two or more are able to act, else — one at most // able to act — that one first and the rest after, as a settling step tries them. +// A step of a do flow within the machine's move picks its token as a choice point +// of the move (ChoiceTokenOrder); any other nested step goes in declared order. func (r *checkRun) beginStep(tokens stepTokens) *checkMove { m := &checkMove{run: r, step: tokens.step, taken: -1, selected: r.script.token != 0} if tokens.owner != r.script.owner { - m.nested, m.selected = true, false + m.nested, m.selected = !tokens.stepped, false } r.move = m var enabled, rest, held []int64 @@ -150,6 +155,16 @@ func (r *checkRun) beginStep(tokens stepTokens) *checkMove { m.taken = i } } + if tokens.stepped && tokens.owner != r.script.owner { + if len(enabled) >= 2 { + m.taken = r.choose(ChoicePoint{Kind: ChoiceTokenOrder, Step: tokens.step, Alternatives: m.enabled}, nil) + m.selected = true + m.order = []int64{enabled[m.taken]} + } else { + m.order = slices.Concat(enabled, rest, held) + } + return m + } if !m.selected { m.order = slices.Concat(enabled, rest, held) return m @@ -250,6 +265,7 @@ func (r *checkRun) choose(c ChoicePoint, whereOf func(i int) string) int { c.Where = whereOf(0) } r.drawn = append(r.drawn, c) + r.faced = append(r.faced, c) return 0 } pick := r.script.picks[r.picked] @@ -261,12 +277,16 @@ func (r *checkRun) choose(c ChoicePoint, whereOf func(i int) string) int { r.refuse(fmt.Sprintf("the run faced %s and pick %d is not among them", c.Describe(), pick+1)) return 0 } + c.Taken = pick + r.faced = append(r.faced, c) return pick } // mark returns what a probe restores: whether a move was refused and what the // step under way drew. func (r *checkRun) mark() func() { - refused, picked, drawn, move := r.refused, r.picked, len(r.drawn), r.move - return func() { r.refused, r.picked, r.drawn, r.move = refused, picked, r.drawn[:drawn], move } + refused, picked, drawn, faced, move := r.refused, r.picked, slices.Clone(r.drawn), slices.Clone(r.faced), r.move + return func() { + r.refused, r.picked, r.drawn, r.faced, r.move = refused, picked, slices.Clone(drawn), slices.Clone(faced), move + } } diff --git a/internal/core/runtime/check_state.go b/internal/exec/runtime/check_state.go similarity index 93% rename from internal/core/runtime/check_state.go rename to internal/exec/runtime/check_state.go index 979db4dabb..146e2a6f50 100644 --- a/internal/core/runtime/check_state.go +++ b/internal/exec/runtime/check_state.go @@ -5,19 +5,21 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "maps" "slices" "sort" "strconv" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // The canonical form of a checked run's state is the text of what a future move -// can observe: the clock, the executor holding the turn, then every executor on -// it in invocation order — an action's tokens by node and performance and its +// can observe: the clock, where the modeled stream stands (what the run draws +// next), the executor holding the turn, then every executor on it in invocation +// order — an action's tokens by node and performance and its // performances root-first with what they hold, a state machine's configuration, // history, values, queue, timers and do progress — then the messages in flight // and the objects reached by their materialization path. Identities a run hands @@ -90,6 +92,11 @@ type stateSpeller struct { func (s *stateSpeller) spell(execs []checkedExecutor, turn checkedExecutor) string { fmt.Fprintf(&s.out, "clock t=%s\n", semantics.FormatReal(s.ctx.clock.now)) + if sched := s.ctx.run.scheduler; sched != nil { + if at := sched.modeled.position(); at != "" { + fmt.Fprintf(&s.out, "draws: %s\n", at) + } + } s.nameExecutors(execs) if turn != nil { fmt.Fprintf(&s.out, "turn: %s\n", s.names[turn]) @@ -236,17 +243,11 @@ func (s *stateSpeller) machine(e *StateExecutor) { } for _, act := range e.doActions { fmt.Fprintf(&s.out, " do{%s: %d pending", e.statePath(act.state), len(act.pending)) - if slices.Contains(e.round, act) { - s.out.WriteString(", in round") - } if act.run != nil { fmt.Fprintf(&s.out, ", paused{%s}", s.body(act.run.body)) } s.out.WriteString("}") } - if e.roundDone { - s.out.WriteString(" round done") - } s.out.WriteByte('\n') } @@ -310,7 +311,7 @@ func transitionKey(trans *lower.Transition) string { func (s *stateSpeller) transition(e *StateExecutor, trans *lower.Transition) string { transitions := e.graph.Transitions[trans.Source] if pos := slices.Index(transitions, trans); pos >= 0 { - return getNodeName(trans.Source) + " " + transitionName(transitions, pos) + return StateVertexName(trans.Source) + " " + transitionName(transitions, pos) } return transitionDescription(trans) } @@ -445,6 +446,24 @@ func (s *stateSpeller) frame(perf *actionFrame) string { for _, node := range sortedNodes(perf.subactions) { fmt.Fprintf(&b, " latest{%s = %s}", s.node(perf.graph, node), s.frameLabel(perf.subactions[node])) } + streamed := slices.Sorted(maps.Keys(perf.streamed)) + if len(streamed) > 0 { + fmt.Fprintf(&b, " streamed{%s}", strings.Join(streamed, ",")) + } + for _, node := range sortedNodes(perf.staged) { + pins := perf.staged[node] + for _, pin := range slices.Sorted(maps.Keys(pins)) { + for _, staged := range pins[pin] { + fmt.Fprintf(&b, " staged{%s.%s[%d] by %s}", s.node(perf.graph, node), pin, staged.at, s.frameLabel(staged.source)) + } + } + } + for _, node := range sortedNodes(perf.unreceived) { + for _, stream := range perf.unreceived[node] { + fmt.Fprintf(&b, " unreceived{%s.%s[%d] from %s.%s}", s.node(perf.graph, node), stream.pin, stream.at, + nodeKey(stream.source), orAnyPin(stream.flow.SourcePin)) + } + } return b.String() } @@ -660,10 +679,7 @@ func (s *stateSpeller) features(inst *Instance) string { } func (s *stateSpeller) feature(inst *Instance, name string) string { - fv, err := inst.GetFeatureValue(s.ctx, name) - if err != nil { - return "" - } + fv := inst.FeatureValues[name] if !fv.Feature.Scalar() { if fv.Values.Kind == ValInvalid { return "()" diff --git a/internal/exec/runtime/check_state_stream_test.go b/internal/exec/runtime/check_state_stream_test.go new file mode 100644 index 0000000000..5d6ec777a5 --- /dev/null +++ b/internal/exec/runtime/check_state_stream_test.go @@ -0,0 +1,41 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// TestCanonicalStateTellsUnreceivedStreamsApart: two performances whose late streamed +// values differ only in pin, queue position, source or count spell differently. +func TestCanonicalStateTellsUnreceivedStreamsApart(t *testing.T) { + node := func(offset int) ast.Node { + return &ast.Usage{NodeBase: ast.NodeBase{NodeSpan: source.Span{Offset: offset, Len: 1}}, Kind: ast.UsageAction} + } + target, srcA, srcB := node(10), node(20), node(30) + frameWith := func(streams ...unreceivedStream) *actionFrame { + return &actionFrame{unreceived: map[ast.Node][]unreceivedStream{target: streams}} + } + mark := func(source ast.Node, pin string, at int) unreceivedStream { + return unreceivedStream{flow: lower.ObjectFlow{SourcePin: pin, Target: target, TargetPin: pin}, source: source, pin: pin, at: at} + } + frames := []*actionFrame{ + frameWith(mark(srcA, "v", 0)), + frameWith(mark(srcA, "w", 0)), + frameWith(mark(srcA, "v", 1)), + frameWith(mark(srcB, "v", 0)), + frameWith(mark(srcA, "v", 0), mark(srcA, "v", 1)), + } + s := &stateSpeller{labels: make(map[*actionFrame]string)} + seen := make(map[string]int) + for i, perf := range frames { + s.labels[perf] = "action" + text := s.frame(perf) + if j, dup := seen[text]; dup { + t.Fatalf("frames %d and %d spell alike: %s", j, i, text) + } + seen[text] = i + } +} diff --git a/internal/exec/runtime/check_test.go b/internal/exec/runtime/check_test.go new file mode 100644 index 0000000000..b6f1cbb944 --- /dev/null +++ b/internal/exec/runtime/check_test.go @@ -0,0 +1,1558 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// checkModel checks the action named in the model under the options given. +func checkModel(t *testing.T, m *exploreModel, name string, budget CheckBudget, opts CheckOptions, props ...CheckProperty) *CheckReport { + t.Helper() + report, err := checkModelErr(t, m, name, budget, opts, props...) + if err != nil { + t.Fatalf("check %s: %v", name, err) + } + return report +} + +func checkModelErr(t *testing.T, m *exploreModel, name string, budget CheckBudget, opts CheckOptions, props ...CheckProperty) (*CheckReport, error) { + t.Helper() + sym := m.action(t, name) + return Check(context.Background(), m.fresh, starterOf(sym), budget, opts, props) +} + +func starterOf(sym *symbols.Symbol) Starter { + return func(ctx *Context) (*Invocation, error) { + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + return nil, err + } + return &Invocation{Actions: []*ActionExecutor{exec}}, nil + } +} + +// checkStart checks the invocation the starter starts, under the default bounds. +func checkStart(t *testing.T, m *exploreModel, start Starter, opts CheckOptions, props ...CheckProperty) *CheckReport { + t.Helper() + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, opts, props) + if err != nil { + t.Fatalf("check: %v", err) + } + return report +} + +// invocationOf starts the actions and the state machines, performed by no object. +func invocationOf(actions, states []*symbols.Symbol) Starter { + return func(ctx *Context) (*Invocation, error) { + inv := &Invocation{} + for _, sym := range actions { + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + inv.Release() + return nil, err + } + inv.Actions = append(inv.Actions, exec) + } + for _, sym := range states { + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + inv.Release() + return nil, err + } + inv.States = append(inv.States, exec) + } + return inv, nil + } +} + +// action is the one action a single-action invocation runs. +func (inv *Invocation) action() *ActionExecutor { return inv.Actions[0] } + +// An invocation that started nothing has an outcome holding nothing. +func TestEmptyInvocationHasAnEmptyOutcome(t *testing.T) { + outcome := (&Invocation{}).Outcome() + if outcome.FinalState != "" || len(outcome.StateVisits) != 0 || len(outcome.Outputs) != 0 { + t.Errorf("outcome of an empty invocation = %v, want nothing held", outcome) + } + if got := outcome.String(); got != "no outputs" { + t.Errorf("an empty invocation's outcome spells %q, want no outputs", got) + } +} + +// performedBy starts the action performed by a fresh instance of the part. +func performedBy(part, action *symbols.Symbol) Starter { + return func(ctx *Context) (*Invocation, error) { + self, err := ctx.Instantiate(part) + if err != nil { + return nil, err + } + exec, err := ctx.CreateActionExecutorFor(action, self) + if err != nil { + return nil, err + } + return &Invocation{Actions: []*ActionExecutor{exec}}, nil + } +} + +func reduced() CheckOptions { return CheckOptions{Reduce: true} } +func unreduced() CheckOptions { return CheckOptions{} } + +func conformanceModel(t *testing.T, name string) *exploreModel { + t.Helper() + text, err := os.ReadFile(filepath.Join("testdata", "conformance", name+".sysml")) + if err != nil { + t.Fatal(err) + } + return parseExploreModel(t, string(text)) +} + +func divergentValues(report *CheckReport, feature string) []string { + for _, d := range report.Divergent { + if d.Feature == feature { + values := make([]string, len(d.Values)) + for i, v := range d.Values { + values[i] = v.Value + } + return values + } + } + return nil +} + +// The oracle: a join waits for its slowest branch, so `arrived` is 3 on every +// schedule and nothing diverges. +func TestCheckJoinWaitsForSlowestBranch(t *testing.T) { + m := conformanceModel(t, "action_join_waits_for_slowest_branch") + for _, opts := range []CheckOptions{reduced(), unreduced()} { + report := checkModel(t, m, "gather", CheckBudget{}, opts) + if report.Verdict != CheckExhaustive { + t.Fatalf("reduce=%v: verdict %s, violations %v", opts.Reduce, report.Status(), report.Violations) + } + if len(report.Finals) != 1 { + t.Fatalf("reduce=%v: %d finals, want 1: %+v", opts.Reduce, len(report.Finals), report.Finals) + } + if got := report.Finals[0].Values["arrived"]; got != "3" { + t.Fatalf("reduce=%v: arrived = %s, want 3", opts.Reduce, got) + } + if got := report.Finals[0].Values["seen"]; got != "3" { + t.Fatalf("reduce=%v: seen = %s, want 3", opts.Reduce, got) + } + } +} + +// The oracle: two branches writing one feature leave it with either value; +// the flags each branch sets alone do not diverge. +func TestCheckForkBranchesWriteOneFeatureDiverge(t *testing.T) { + m := conformanceModel(t, "action_fork_branches_write_one_feature") + for _, opts := range []CheckOptions{reduced(), unreduced()} { + report := checkModel(t, m, "clash", CheckBudget{}, opts) + if report.Verdict != CheckDivergent { + t.Fatalf("reduce=%v: verdict %s, violations %v", opts.Reduce, report.Status(), report.Violations) + } + if got := divergentValues(report, "x"); strings.Join(got, ",") != "1,2" { + t.Fatalf("reduce=%v: x diverges over %v, want [1 2]", opts.Reduce, got) + } + for _, flag := range []string{"leftRan", "rightRan"} { + if got := divergentValues(report, flag); got != nil { + t.Fatalf("reduce=%v: %s diverges over %v, want not", opts.Reduce, flag, got) + } + } + if len(report.Finals) != 2 { + t.Fatalf("reduce=%v: %d finals, want 2", opts.Reduce, len(report.Finals)) + } + for _, final := range report.Finals { + if len(final.Witness.Choices) == 0 || final.Witness.Trace == "" { + t.Fatalf("reduce=%v: final %s has no witness", opts.Reduce, final.Outcome) + } + } + } +} + +// A completed state is a stable state: a property false only there is a +// violation on the schedule completing, at the depth that completes it. +func TestCheckEvaluatesPropertiesAtCompletion(t *testing.T) { + m := conformanceModel(t, "action_join_waits_for_slowest_branch") + incomplete := CheckProperty{Name: "incomplete", Holds: func(_ *Context, inv *Invocation) (bool, error) { + exec := inv.action() + return exec.State() != StateCompleted, nil + }} + for _, opts := range []CheckOptions{reduced(), unreduced()} { + report := checkModel(t, m, "gather", CheckBudget{}, opts, incomplete) + if report.Verdict != CheckViolation || len(report.Violations) != 1 { + t.Fatalf("reduce=%v: %s, violations %v; want the one at completion", opts.Reduce, report.Status(), report.Violations) + } + v := report.Violations[0] + if v.Kind != ViolationProperty || v.Name != "incomplete" || v.Depth != report.MaxDepth || len(v.Witness.Choices) == 0 { + t.Fatalf("reduce=%v: violation %+v, want incomplete false at the completing depth %d", opts.Reduce, v, report.MaxDepth) + } + r := replayWitness(t, m, starterOf(m.action(t, "gather")), v.Witness, "completion", incomplete) + if r.Err != nil || r.Inv.action().State() != StateCompleted { + t.Fatalf("reduce=%v: the replay ends %s with %v, want complete", opts.Reduce, r.Inv.action().State(), r.Err) + } + } + // An action complete before any move is the same state. + single := parseExploreModel(t, `package test { + action lone { first start; then done; } + }`) + report := checkModel(t, single, "lone", CheckBudget{}, reduced(), incomplete) + if report.Verdict != CheckViolation || len(report.Violations) != 1 || report.Violations[0].Depth != report.MaxDepth { + t.Fatalf("%s, violations %v; want the one at completion", report.Status(), report.Violations) + } +} + +// Naming the features to report narrows divergence to them. +func TestCheckDivergenceOfNamedFeaturesOnly(t *testing.T) { + m := conformanceModel(t, "action_fork_branches_write_one_feature") + report := checkModel(t, m, "clash", CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{"leftRan"}}) + if report.Verdict != CheckExhaustive { + t.Fatalf("verdict %s, want exhaustive; divergent %v", report.Status(), report.Divergent) + } +} + +// The reduction explores fewer moves than the full search and reaches the same finals. +func TestCheckReductionExploresFewerMoves(t *testing.T) { + m := conformanceModel(t, "action_join_waits_for_slowest_branch") + with := checkModel(t, m, "gather", CheckBudget{}, reduced()) + without := checkModel(t, m, "gather", CheckBudget{}, unreduced()) + if with.Moves >= without.Moves { + t.Fatalf("reduced search made %d moves, unreduced %d", with.Moves, without.Moves) + } + if with.States > without.States { + t.Fatalf("reduced search visited %d states, unreduced %d", with.States, without.States) + } +} + +// A search stopped by its caller reports how far it got. +func TestCheckStopsWhenCancelled(t *testing.T) { + m := conformanceModel(t, "action_join_waits_for_slowest_branch") + stop, cancel := context.WithCancel(context.Background()) + cancel() + _, err := Check(stop, m.fresh, starterOf(m.action(t, "gather")), CheckBudget{}, reduced(), nil) + var stopped *CheckStopped + if !errors.As(err, &stopped) || !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want CheckStopped wrapping context.Canceled", err) + } +} + +// A check stopped while its start's own draws are being enumerated begins no further start +// and reports the stop: the machine here draws its entry order at the start and rests at +// once, so no search ever reads the stop. +func TestCheckStopsWhenCancelledAmongStarts(t *testing.T) { + m := parseExploreModel(t, `package test { + state def Region { entry; then r1; state r1; } + state machine { + entry; then work; + state work parallel { + state a : Region; + state b : Region; + } + } + }`) + stop, cancel := context.WithCancel(context.Background()) + cancel() + starts := 0 + fresh := func() (*Context, error) { + starts++ + return m.fresh() + } + _, err := Check(stop, fresh, invocationOf(nil, []*symbols.Symbol{m.state(t, "machine")}), CheckBudget{}, reduced(), nil) + var stopped *CheckStopped + if !errors.As(err, &stopped) || !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want CheckStopped wrapping context.Canceled", err) + } + if starts != 0 { + t.Fatalf("starts = %d, want none begun after the stop", starts) + } +} + +// A stop raised while the last start is running, past the poll that opened it, is still +// reported rather than swallowed by the report over the starts that did finish. +func TestCheckStopsWhenCancelledDuringLastStart(t *testing.T) { + m := parseExploreModel(t, `package test { + state def Region { entry; then r1; state r1; } + state machine { + entry; then work; + state work parallel { + state a : Region; + state b : Region; + } + } + }`) + stop, cancel := context.WithCancel(context.Background()) + defer cancel() + starts := 0 + fresh := func() (*Context, error) { + if starts++; starts == 2 { + cancel() + } + return m.fresh() + } + _, err := Check(stop, fresh, invocationOf(nil, []*symbols.Symbol{m.state(t, "machine")}), CheckBudget{}, reduced(), nil) + var stopped *CheckStopped + if !errors.As(err, &stopped) || !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want CheckStopped wrapping context.Canceled", err) + } + if starts != 2 { + t.Fatalf("starts = %d, want the two entry orders", starts) + } +} + +// Every witness the check writes replays to the state it claims and leaves its trace. +func TestCheckWitnessesReplay(t *testing.T) { + for _, c := range []struct{ file, action string }{ + {"action_join_waits_for_slowest_branch", "gather"}, + {"action_fork_branches_write_one_feature", "clash"}, + } { + m := conformanceModel(t, c.file) + report := checkModel(t, m, c.action, CheckBudget{}, reduced()) + start := starterOf(m.action(t, c.action)) + for _, final := range report.Finals { + replayWitness(t, m, start, final.Witness, final.Outcome) + } + for _, d := range report.Divergent { + for _, v := range d.Values { + replayWitness(t, m, start, v.Witness, d.Feature+" = "+v.Value) + } + } + } +} + +func replayWitness(t *testing.T, m *exploreModel, start Starter, w Witness, claim string, props ...CheckProperty) *Replayed { + t.Helper() + parsed, err := ParseWitness(w.String()) + if err != nil { + t.Fatalf("%s: parsing the witness: %v", claim, err) + } + if parsed.Trace != w.Trace || parsed.Fails != w.Fails || parsed.Property != w.Property || len(parsed.Choices) != len(w.Choices) { + t.Fatalf("%s: the witness reads back otherwise:\n%s", claim, w) + } + r, err := Replay(context.Background(), m.fresh, start, parsed, props) + if err != nil { + t.Fatalf("%s: replay: %v", claim, err) + } + return r +} + +// The checker's replay follows a token order drawn once the clock retried a step +// as the runtime's does: the witness explore writes for the model whose step 3 is +// retried re-runs to the trace and value it claims, the kept move taken at the retry. +func TestCheckReplayFollowsAnOrderDrawnAfterTheClockRetriesAStep(t *testing.T) { + m, _ := clockRetriedModel(t) + start := starterOf(m.action(t, "wake")) + choices, err := ParseChoices("step 3: 2@performed first of 2@performed, 3@direct\nstep 4: 2@writeOne first of 2@writeOne, 3@direct\n") + if err != nil { + t.Fatal(err) + } + ctx, _ := m.fresh() + mustSchedule(t, ctx, ReplayPolicy(choices)) + trace := NewTraceRecorder() + ctx.SetTrace(trace) + inv, err := start(ctx) + if err != nil { + t.Fatal(err) + } + if err := inv.Actions[0].RunToCompletion(); err != nil { + t.Fatal(err) + } + if err := ctx.Unfollowed(); err != nil { + t.Fatal(err) + } + r := replayWitness(t, m, start, Witness{Choices: choices, Trace: trace.String()}, "x = 2") + if got := r.Outcome(); got != "x = 2" || r.Err != nil { + t.Fatalf("the replay reached %s, %v; want x = 2", got, r.Err) + } +} + +// The witnesses of a check over several behaviors replay: a second action begun +// beside `clash` shifts the step at which its branches race, and each divergent +// value's witness re-runs to the value it claims. +func TestCheckWitnessesOfSeveralBehaviorsReplay(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + action clash { + attribute x : Integer = 0; + first start; + fork split; + action left { assign x := 1; } + action right { assign x := 2; } + join sync; + done; + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then done; + } + action tick { + attribute n : Integer = 0; + first start; + then action one assign n := n + 1; + then action two assign n := n + 1; + then done; + } + }`) + clash, tick := m.action(t, "clash"), m.action(t, "tick") + start := func(ctx *Context) (*Invocation, error) { + inv := &Invocation{} + for _, sym := range []*symbols.Symbol{tick, clash} { + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + return nil, err + } + inv.Actions = append(inv.Actions, exec) + } + return inv, nil + } + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, unreduced(), nil) + if err != nil { + t.Fatal(err) + } + var xs []string + for _, d := range report.Divergent { + if !strings.HasSuffix(d.Feature, "x") { + continue + } + for _, v := range d.Values { + xs = append(xs, v.Value) + r := replayWitness(t, m, start, v.Witness, d.Feature+" = "+v.Value) + if got, err := r.FinalValue(d.Feature); err != nil || got != v.Value { + t.Errorf("replaying %s reached %s = %s, %v; want %s", FormatChoices(v.Witness.Choices), d.Feature, got, err, v.Value) + } + } + } + slices.Sort(xs) + if !slices.Equal(xs, []string{"1", "2"}) { + t.Fatalf("x ends as %v, want 1 and 2", xs) + } +} + +// A witness altered to another schedule does not replay: the disagreement is reported. +func TestCheckReplayDisagreesWithATamperedWitness(t *testing.T) { + m := conformanceModel(t, "action_fork_branches_write_one_feature") + report := checkModel(t, m, "clash", CheckBudget{}, reduced()) + w := report.Finals[0].Witness + other := Witness{Choices: report.Finals[1].Witness.Choices, Trace: w.Trace} + _, err := Replay(context.Background(), m.fresh, starterOf(m.action(t, "clash")), other, nil) + var dis *ReplayDisagreement + if !errors.As(err, &dis) || !errors.Is(err, ErrReplayDisagrees) { + t.Fatalf("replay = %v, want a ReplayDisagreement", err) + } +} + +// A replay whose caller goes away mid-run stops with the caller's error, not a +// disagreement: the context is cancelled once the run is under way, and every +// step of the witness left is skipped. +func TestCheckReplayStopsWhenCancelled(t *testing.T) { + m := conformanceModel(t, "action_fork_branches_write_one_feature") + report := checkModel(t, m, "clash", CheckBudget{}, reduced()) + w := report.Finals[0].Witness + stop, cancel := context.WithCancel(context.Background()) + defer cancel() + fresh := func() (*Context, error) { + cancel() + return m.fresh() + } + r, err := Replay(stop, fresh, starterOf(m.action(t, "clash")), w, nil) + if !errors.Is(err, context.Canceled) || errors.Is(err, ErrReplayDisagrees) { + t.Fatalf("replay = %v, want context.Canceled", err) + } + if r == nil || r.Inv == nil || r.Ctx.Trace().String() == w.Trace { + t.Fatalf("the replay ran to the claimed state after being cancelled") + } + if _, err := Replay(stop, m.fresh, starterOf(m.action(t, "clash")), w, nil); !errors.Is(err, context.Canceled) { + t.Fatalf("replay under a cancelled context = %v, want context.Canceled", err) + } +} + +// A performed action's witness lists the choices of every run of the context: the +// performer's own behaviors run before the action starts, and their choice points +// come first. Each divergent value replays, so `this.` is witnessed. +func TestCheckWitnessesAPerformedActionThroughItsPerformer(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + part def Tank { + attribute level : Integer = 0; + perform action fill { + first start; + fork split; + action a { assign this.level := 1; } + action b { assign this.level := 2; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then done; + } + } + }`) + fill := m.idx.LookupQualified("test::Tank::fill")[0] + tank := m.idx.LookupQualified("test::Tank")[0] + start := performedBy(tank, fill) + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{"this.level"}}, nil) + if err != nil { + t.Fatal(err) + } + if got := divergentValues(report, "this.level"); !slices.Equal(got, []string{"1", "2"}) { + t.Fatalf("this.level diverges over %v, want [1 2]: %s", got, report.Status()) + } + for _, d := range report.Divergent { + for _, v := range d.Values { + if len(v.Witness.Choices) != 2 { + t.Fatalf("%s = %s: witness %s, want the performer's move and the action's", d.Feature, v.Value, FormatChoices(v.Witness.Choices)) + } + replayWitness(t, m, start, v.Witness, d.Feature+" = "+v.Value) + } + } +} + +// A name given to Diverge selects one namespace: `level` the action's own +// attribute, `this.level` the performer's, though both are spelled level. +func TestCheckDivergeNamesTellTheActionsFeaturesFromThePerformers(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + part def Tank { + attribute level : Integer = 0; + perform action fill { + attribute level : Integer = 0; + first start; + fork split; + action a { assign this.level := 1; assign level := 1; } + action b { assign this.level := 2; assign level := 2; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then done; + } + } + }`) + fill := m.idx.LookupQualified("test::Tank::fill")[0] + tank := m.idx.LookupQualified("test::Tank")[0] + start := performedBy(tank, fill) + features := func(report *CheckReport) []string { + var names []string + for _, d := range report.Divergent { + names = append(names, d.Feature) + } + return names + } + for _, tc := range []struct { + diverge []string + want []string + }{ + {nil, []string{"level", "this.level"}}, + {[]string{"level"}, []string{"level"}}, + {[]string{"this.level"}, []string{"this.level"}}, + {[]string{"level", "this.level"}, []string{"level", "this.level"}}, + } { + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, CheckOptions{Reduce: true, Diverge: tc.diverge}, nil) + if err != nil { + t.Fatal(err) + } + if got := features(report); !slices.Equal(got, tc.want) { + t.Errorf("Diverge %v reports %v divergent, want %v", tc.diverge, got, tc.want) + } + for _, name := range tc.want { + if got := divergentValues(report, name); !slices.Equal(got, []string{"1", "2"}) { + t.Errorf("Diverge %v: %s diverges over %v, want [1 2]", tc.diverge, name, got) + } + } + } +} + +// Diverge selects any feature the performer holds, an attribute or not: two +// schedules leaving a selected item differently are two outcomes, though the +// outcome's own rendering carries the performer's attributes only. +func TestCheckDivergeTellsOutcomesApartByANonAttributeOfThePerformer(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + part def Tank { + item mode : String = "idle"; + perform action fill { + first start; + fork split; + action a { assign this.mode := "filling"; } + action b { assign this.mode := "draining"; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then done; + } + } + }`) + fill := m.idx.LookupQualified("test::Tank::fill")[0] + tank := m.idx.LookupQualified("test::Tank")[0] + start := performedBy(tank, fill) + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{"this.mode"}}, nil) + if err != nil { + t.Fatal(err) + } + if len(report.Finals) != 2 { + t.Fatalf("%d finals, want the two this.mode tells apart: %s", len(report.Finals), report.Status()) + } + if got := divergentValues(report, "this.mode"); !slices.Equal(got, []string{`"draining"`, `"filling"`}) { + t.Fatalf("this.mode diverges over %v, want [\"draining\" \"filling\"]: %s", got, report.Status()) + } + for _, final := range report.Finals { + if !strings.Contains(final.Outcome, "this.mode = "+final.Values["this.mode"]) { + t.Errorf("final %q does not spell this.mode = %s", final.Outcome, final.Values["this.mode"]) + } + } +} + +// A constraint of the performer evaluated at every state is a property: the +// schedule reaching a state where it is false is the violation, once, and its +// witness replays to that state. +func TestCheckPropertyOfThePerformerIsWitnessed(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + part def Tank { + attribute level : Integer = 0; + constraint low { level < 2 } + perform action fill { + first start; + fork split; + action a { assign this.level := 1; } + action b { assign this.level := 2; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then done; + } + } + }`) + fill := m.idx.LookupQualified("test::Tank::fill")[0] + tank := m.idx.LookupQualified("test::Tank")[0] + low := m.idx.LookupQualified("test::Tank::low")[0] + start := performedBy(tank, fill) + prop := CheckProperty{Name: "low", Holds: func(ctx *Context, inv *Invocation) (bool, error) { + exec := inv.action() + result, err := ctx.CheckConstraintOn(low, tank.Scope, exec.Performer()) + if err != nil && !errors.Is(err, ErrViolated) { + return false, err + } + return result.Holds, nil + }} + for _, opts := range []CheckOptions{reduced(), unreduced()} { + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, opts, []CheckProperty{prop}) + if err != nil { + t.Fatal(err) + } + if report.Verdict != CheckViolation || len(report.Violations) != 1 { + t.Fatalf("reduce=%v: %s, violations %v; want low false once", opts.Reduce, report.Status(), report.Violations) + } + v := report.Violations[0] + if v.Kind != ViolationProperty || v.Name != "low" { + t.Fatalf("reduce=%v: violation %+v, want the property low", opts.Reduce, v) + } + r := replayWitness(t, m, start, v.Witness, "low false", prop) + holds, err := prop.Holds(r.Ctx, r.Inv) + if err != nil || holds { + t.Fatalf("reduce=%v: replayed to a state where low = %v, %v; want false", opts.Reduce, holds, err) + } + } +} + +// The failure modes of the robustness tests are violations on the schedule that +// reaches them, each with its witness, not errors of the search. +func TestCheckReportsFailuresAsViolations(t *testing.T) { + cases := []struct { + name, action, src string + kind ViolationKind + err error + }{ + {"join starvation", "starve", `package test { + action starve { + first start; + action stranded; + join sync; + done; + succession first start then sync; + succession first stranded then sync; + succession first sync then done; + } + }`, ViolationDeadlock, ErrActionDeadlock}, + {"unbound parameter", "outer", `package test { + private import ScalarValues::*; + action def Adder { in a : Integer; in b : Integer; out sum : Integer; first step; action step { assign sum := a + b; } } + action adder : Adder; + action outer { + attribute a : Integer = 1; + first start; + then action run { + if a > 0 { + perform adder; + } + } + then done; + } + }`, ViolationFailure, ErrUnboundParameter}, + {"dangling succession", "outer", `package test { + action outer { + first leg; + action leg { + first a; + action a; + succession first a then missing; + } + } + }`, ViolationFailure, ErrInvalidActionFlow}, + {"all guards false", "pick", `package test { + private import ScalarValues::*; + action pick { + attribute level : Integer = 5; + first start; + action low; + action high; + done; + succession first start then choose; + succession first low then done; + succession first high then done; + decide choose; + if level > 10 then low; + if level > 20 then high; + } + }`, ViolationFailure, ErrNoEnabledSuccession}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + m := parseExploreModel(t, c.src) + report := checkModel(t, m, c.action, CheckBudget{}, reduced()) + if report.Verdict != CheckViolation || len(report.Violations) != 1 { + t.Fatalf("verdict %s, violations %v; want one violation", report.Status(), report.Violations) + } + v := report.Violations[0] + if v.Kind != c.kind || !errors.Is(v.Err, c.err) { + t.Fatalf("violation %s (%v), want %s wrapping %v", v.Kind, v.Err, c.kind, c.err) + } + if v.Witness.Fails != v.Err.Error() { + t.Fatalf("the witness claims %q, want the violation's %v", v.Witness.Fails, v.Err) + } + if v.Depth > 0 { + r := replayWitness(t, m, starterOf(m.action(t, c.action)), v.Witness, c.name) + if !errors.Is(r.Err, c.err) { + t.Fatalf("the replay ends with %v, want %v", r.Err, c.err) + } + } + // The same schedule claiming a state the run goes on from does not replay. + state := Witness{Choices: v.Witness.Choices, Trace: v.Witness.Trace} + _, err := Replay(context.Background(), m.fresh, starterOf(m.action(t, c.action)), state, nil) + if !errors.Is(err, ErrReplayDisagrees) { + t.Fatalf("replay of the schedule without its failure = %v, want a disagreement", err) + } + }) + } +} + +// The check lets go of the executor it started once the search ends, its bodies +// paused along the way or not. The clock drives it no further and a step of it is +// the released executor's refusal. +func TestCheckReleasesItsExecutorHoweverTheSearchEnds(t *testing.T) { + want := map[string]bool{ + "action_explore_performed_and_accept_due_together": true, + "action_fork_branches_write_one_feature": true, + } + for _, c := range checkCorpus(t) { + if !want[c.name] { + continue + } + t.Run(c.name, func(t *testing.T) { + var started *ActionExecutor + start := func(ctx *Context) (*Invocation, error) { + inv, err := c.start(ctx) + if err != nil { + return nil, err + } + started = inv.action() + return inv, nil + } + if _, err := Check(context.Background(), c.model.fresh, start, CheckBudget{}, reduced(), nil); err != nil { + t.Fatalf("check = %v", err) + } + if started == nil { + t.Fatal("the check started no executor") + } + if err := started.Step(); !errors.Is(err, ErrExecutorReleased) { + t.Fatalf("a step of the executor after the check = %v, want %v", err, ErrExecutorReleased) + } + if slices.ContainsFunc(started.ctx.clock.waiters, func(w clockWaiter) bool { return w == started }) { + t.Fatal("the clock still drives the executor after the check") + } + }) + } +} + +// A failing move that leaves no trace and makes no choice is still replayed: the +// witness claims the failure, so a replay makes the move and must raise it, and +// the same schedule claiming another failure disagrees. +func TestCheckReplaysAFailureLeavingNoTrace(t *testing.T) { + m := parseExploreModel(t, `package test { + action def Bad { action a; action b; } + action outer { + first start; + then action run : Bad; + then done; + } + }`) + report := checkModel(t, m, "outer", CheckBudget{}, reduced()) + if report.Verdict != CheckViolation || len(report.Violations) != 1 { + t.Fatalf("%s, violations %v; want one failure", report.Status(), report.Violations) + } + v := report.Violations[0] + if v.Kind != ViolationFailure || !errors.Is(v.Err, ErrInvalidActionFlow) || v.Depth != 2 { + t.Fatalf("violation %+v, want the flow without a start after two moves", v) + } + // The trace is the first move's alone: invoking Bad fails before it leaves any. + if v.Witness.Trace != "step 1: token 1@run" || len(v.Witness.Choices) != 0 || v.Witness.Fails == "" { + t.Fatalf("witness %+v, want the trace before the failing move, no choice and the failure", v.Witness) + } + start := starterOf(m.action(t, "outer")) + if r := replayWitness(t, m, start, v.Witness, "no initial node"); !errors.Is(r.Err, ErrInvalidActionFlow) { + t.Fatalf("the replay ends with %v, want %v", r.Err, ErrInvalidActionFlow) + } + other := Witness{Trace: v.Witness.Trace, Fails: "another failure"} + var dis *ReplayDisagreement + if _, err := Replay(context.Background(), m.fresh, start, other, nil); !errors.As(err, &dis) || !strings.Contains(dis.Reason, "otherwise than claimed") { + t.Fatalf("replay claiming another failure = %v, want a disagreement naming both", err) + } +} + +// An accept whose `via` port does not resolve is the routing error on every +// schedule that brings it a message to route, never a deadlock: the checker +// reaches the accept's move both before and after the sibling branch's write. +func TestCheckReportsAnUnresolvedViaPortAsTheRoutingError(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + port def Chan { out attribute v : Integer; } + part def Listener { + port out : Chan; + port other : ~Chan; + port in : ~Chan = 1 / 0; + connect out to other; + action listen { + attribute got : Integer = 0; + attribute fed : Boolean = false; + first start; + fork f; + action feed { send 4 via out; } + action noteFed { assign fed := true; } + action reader accept v : Integer via in; + action note { assign got := reader.v; } + join j; + done; + succession first start then f; + succession first f then feed; + succession first f then reader; + succession first feed then noteFed; + succession first reader then note; + succession first noteFed then j; + succession first note then j; + succession first j then done; + } + } + }`) + listen := m.idx.LookupQualified("test::Listener::listen")[0] + part := m.idx.LookupQualified("test::Listener")[0] + start := performedBy(part, listen) + for _, opts := range []CheckOptions{reduced(), unreduced()} { + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, opts, nil) + if err != nil { + t.Fatal(err) + } + if report.Verdict != CheckViolation || len(report.Violations) != 2 { + t.Fatalf("reduce=%v: %s with %d violations, want the routing error on both schedules", opts.Reduce, report.Status(), len(report.Violations)) + } + for _, v := range report.Violations { + if v.Kind != ViolationFailure || !errors.Is(v.Err, ErrDivisionByZero) { + t.Fatalf("reduce=%v: violation %s (%v), want the port's %v", opts.Reduce, v.Kind, v.Err, ErrDivisionByZero) + } + if errors.Is(v.Err, ErrAcceptDeadlock) || errors.Is(v.Err, ErrActionDeadlock) { + t.Fatalf("reduce=%v: the routing failure is reported as a deadlock: %v", opts.Reduce, v.Err) + } + r := replayWitness(t, m, start, v.Witness, "accept via in") + if !errors.Is(r.Err, ErrDivisionByZero) { + t.Fatalf("reduce=%v: the replay ends with %v, want %v", opts.Reduce, r.Err, ErrDivisionByZero) + } + } + } +} + +// A loop through a merge stops at the depth bound, named, without claiming exhaustiveness. +func TestCheckMergeLoopHitsTheDepthBound(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + action spin { + attribute n : Integer = 0; + first start; + merge m; + action a { assign n := n + 1; } + succession first start then m; + succession first m then a; + succession first a then m; + } + }`) + report := checkModel(t, m, "spin", CheckBudget{Depth: 12}, reduced()) + if report.Verdict != CheckWithinBounds { + t.Fatalf("verdict %s, want within bounds", report.Status()) + } + if !slices.Contains(report.BoundsHit, "depth") { + t.Fatalf("bounds hit %v, want depth", report.BoundsHit) + } + if report.MaxDepth != 12 { + t.Fatalf("max depth %d, want 12", report.MaxDepth) + } + bounded := checkModel(t, m, "spin", CheckBudget{States: 5}, reduced()) + if bounded.Verdict != CheckWithinBounds || !slices.Contains(bounded.BoundsHit, "states") || bounded.States != 5 { + t.Fatalf("%s, want the states bound hit at 5 states", bounded.Status()) + } + // The executor's own action-step budget is a bound named as that budget, its limit kept. + limits := DefaultBudgets() + limits.MaxActionSteps = 9 + fresh := func() (*Context, error) { + ctx, err := m.fresh() + if err != nil { + return nil, err + } + return ctx, ctx.SetBudgets(limits) + } + stepped, err := Check(context.Background(), fresh, starterOf(m.action(t, "spin")), CheckBudget{}, reduced(), nil) + if err != nil { + t.Fatal(err) + } + if stepped.Verdict != CheckWithinBounds || !slices.Equal(stepped.BoundsHit, []string{BoundActionSteps}) || stepped.MaxDepth != 9 { + t.Fatalf("%s, bounds %v, want only %s hit at depth 9", stepped.Status(), stepped.BoundsHit, BoundActionSteps) + } + if got, ok := ExecutorBound(BoundActionSteps, stepped.Limits); !ok || got != 9 { + t.Fatalf("%s limit %d, want 9", BoundActionSteps, got) + } +} + +// A state the depth bound cut under a long schedule is searched again when a +// shorter one reaches it, so what lies within the shorter one's bound is found. +func TestCheckSearchesAgainWhatTheDepthBoundCutFromAShorterWay(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + action detour { + attribute n : Integer = 0; + first start; + decide pick; + merge m1; + merge m2; + merge m3; + merge meet; + action bump { assign n := n + 1; } + done; + succession first start then pick; + succession first pick if n >= 0 then m1; + succession first pick if n >= 0 then meet; + succession first m1 then m2; + succession first m2 then m3; + succession first m3 then meet; + succession first meet then bump; + succession first bump then done; + } + }`) + low := CheckProperty{Name: "low", Holds: func(_ *Context, inv *Invocation) (bool, error) { + exec := inv.action() + return exec.Data()["n"].Const.Int < 1, nil + }} + // The short way makes n = 1 after 4 moves, the long way after 7: with a bound + // between, the long way (searched first) is cut at meet, the short way is not. + for _, depth := range []int{4, 5, 6} { + report := checkModel(t, m, "detour", CheckBudget{Depth: depth}, reduced(), low) + if report.Verdict != CheckViolation || len(report.Violations) != 1 { + t.Fatalf("depth %d: %s, want the violation the short way reaches", depth, report.Status()) + } + if v := report.Violations[0]; v.Kind != ViolationProperty || v.Name != "low" || v.Depth != 4 { + t.Fatalf("depth %d: violation %s at depth %d, want low at depth 4", depth, v, v.Depth) + } + if !slices.Contains(report.BoundsHit, "depth") { + t.Fatalf("depth %d: bounds hit %v, want depth", depth, report.BoundsHit) + } + } + // With room for both ways nothing is cut, and the search is exhaustive. + report := checkModel(t, m, "detour", CheckBudget{Depth: 8}, reduced(), low) + if report.Verdict != CheckViolation || len(report.BoundsHit) != 0 { + t.Fatalf("depth 8: %s, want a violation with no bound hit", report.Status()) + } +} + +// Every executor budget the search can exhaust is a bound of its own, named by +// its budget, its limit that budget's. +func TestCheckNamesEachExecutorBudget(t *testing.T) { + limits := Budgets{MaxSteps: 1, MaxActionSteps: 2, MaxStateEvents: 3, MaxDoSteps: 4, MaxElements: 5} + cases := []struct { + err error + name string + limit int64 + }{ + {ErrStepLimitExceeded, BoundSteps, 1}, + {ErrActionStepLimitExceeded, BoundActionSteps, 2}, + {ErrStateEventLimitExceeded, BoundEvents, 3}, + {budgetExceeded(ErrStateEventLimitExceeded, "behaviors", ErrBehaviorBudget), BoundBehaviors, 3}, + {ErrDoStepLimitExceeded, BoundDoSteps, 4}, + {ErrElementLimitExceeded, BoundElements, 5}, + } + if len(cases) != len(ExecutorBounds) { + t.Fatalf("%d cases over %d bounds %v", len(cases), len(ExecutorBounds), ExecutorBounds) + } + for _, c := range cases { + name, ok := boundOf(fmt.Errorf("wrapped: %w", c.err)) + if !ok || name != c.name || !slices.Contains(ExecutorBounds, name) { + t.Fatalf("%v names %q, want %s among %v", c.err, name, c.name, ExecutorBounds) + } + if limit, ok := ExecutorBound(name, limits); !ok || limit != c.limit { + t.Fatalf("%s limit %d, want %d", name, limit, c.limit) + } + } + if _, ok := boundOf(ErrActionDeadlock); ok { + t.Fatal("a deadlock is not a bound") + } + if _, ok := ExecutorBound("depth", limits); ok { + t.Fatal("depth is the search's bound, not the executor's") + } +} + +// Parking the last token on the clock is a move like any other: the search +// settles by advancing the clock to the earliest wait, never by a failure. +func TestCheckSettlesTimedBranchesOnTheClock(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import SI::*; + private import ScalarValues::*; + action timers { + attribute x : Integer = 0; + first start; + fork split; + action slow accept after 2 [s]; + action slowWrite { assign x := 1; } + action fast accept after 1 [s]; + action fastWrite { assign x := 2; } + join sync; + done; + succession first start then split; + succession first split then slow; + succession first split then fast; + succession first slow then slowWrite; + succession first fast then fastWrite; + succession first slowWrite then sync; + succession first fastWrite then sync; + succession first sync then done; + } + }`) + report := checkModel(t, m, "timers", CheckBudget{}, reduced()) + if report.Verdict != CheckExhaustive { + t.Fatalf("verdict %s, want exhaustive", report.Status()) + } + // The slow branch always writes last: no divergence over `x`. + if got := divergentValues(report, "x"); got != nil { + t.Fatalf("x diverges over %v, want the slow write to stand on every schedule", got) + } + if len(report.Finals) != 1 || report.Finals[0].Values["x"] != "1" { + t.Fatalf("finals %v, want one with x = 1", report.Finals) + } +} + +// A loop that reaches states already visited ends: the visited set closes it. +func TestCheckVisitedStatesCloseALoop(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + action toggle { + attribute on : Boolean = false; + first start; + merge m; + action flip { assign on := not on; } + succession first start then m; + succession first m then flip; + succession first flip then m; + } + }`) + report := checkModel(t, m, "toggle", CheckBudget{}, reduced()) + if report.Verdict != CheckExhaustive { + t.Fatalf("verdict %s, want exhaustive", report.Status()) + } + // start, then m and flip with `on` each way, the first pass through flip apart. + if report.States != 7 { + t.Fatalf("%d states for a two-valued loop, want 7", report.States) + } +} + +// Objects a set holds are told apart in the canonical form by their place in the +// set's order: each of a decision's ways marks one of three workers and the +// states differ afterwards in nothing but which, so both are searched on and +// the first worker's status is read as 1 on one schedule and 0 on the other. +func TestCheckTellsSetMembersApartInTheVisitedSet(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import Collections::*; + part def Worker { attribute status : Integer = 0; } + part def Team :> Set { part workers : Worker[3] :>> elements; } + part team : Team; + action def Mark { in w : Worker; first step; action step { assign w.status := 1; } } + action staff { + attribute idx : Integer = 0; + attribute seen : Integer = 0; + first start; + decide pick; + action one { assign idx := 1; } + action two { assign idx := 2; } + merge chosen; + action mark : Mark { in w = team.workers#(idx); } + action reset { assign idx := 0; } + action read { assign seen := team.workers#(1).status; } + done; + succession first start then pick; + succession first pick if idx == 0 then one; + succession first pick if idx == 0 then two; + succession first one then chosen; + succession first two then chosen; + succession first chosen then mark; + succession first mark then reset; + succession first reset then read; + succession first read then done; + } + }`) + report := checkModel(t, m, "staff", CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{"seen"}}) + if report.Verdict != CheckDivergent || len(report.BoundsHit) != 0 || len(report.Finals) != 2 { + t.Fatalf("%s with %d finals, want divergent, exhaustive, with one final per worker marked", report.Status(), len(report.Finals)) + } + if got := divergentValues(report, "seen"); !slices.Equal(got, []string{"0", "1"}) { + t.Fatalf("seen diverges over %v, want {0, 1}", got) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + exec, err := ctx.CreateActionExecutor(m.action(t, "staff")) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + form := (&Invocation{Actions: []*ActionExecutor{exec}}).canonicalState(nil) + for _, path := range []string{"workers[0]", "workers[1]", "workers[2]"} { + if !strings.Contains(form.text, "object test::team#1."+path+": ") { + t.Errorf("canonical form names no object at %s:\n%s", path, form.text) + } + } +} + +// A feature a final leaves unset is still reported, as UnsetText: a branch that +// may or may not set one makes it divergent over the value and ``, for +// the action's own and the performer's alike. +func TestCheckKeepsAnUnsetFeatureInADivergence(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + part def Tank { + attribute mark : Integer; + action fill { + attribute x : Integer = 0; + attribute y : Integer; + first start; + fork split; + action left { assign x := 1; } + decide look; + if x == 1 then note; + else idle; + action note { assign y := 1; assign this.mark := 1; } + action idle; + merge meet; + join sync; + done; + succession first start then split; + succession first split then left; + succession first split then look; + succession first note then meet; + succession first idle then meet; + succession first left then sync; + succession first meet then sync; + succession first sync then done; + } + } + }`) + fill := m.idx.LookupQualified("test::Tank::fill")[0] + tank := m.idx.LookupQualified("test::Tank")[0] + start := performedBy(tank, fill) + for _, diverge := range [][]string{nil, {"y", "this.mark"}} { + for _, opts := range []CheckOptions{reduced(), unreduced()} { + opts.Diverge = diverge + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, opts, nil) + if err != nil { + t.Fatal(err) + } + if report.Verdict != CheckDivergent { + t.Fatalf("Diverge %v, reduce=%v: %s, violations %v", diverge, opts.Reduce, report.Status(), report.Violations) + } + for _, name := range []string{"y", "this.mark"} { + if got := divergentValues(report, name); !slices.Equal(got, []string{"1", UnsetText}) { + t.Errorf("Diverge %v, reduce=%v: %s diverges over %v, want [1 %s]: %s", diverge, opts.Reduce, name, got, UnsetText, report.Status()) + } + } + if diverge == nil && divergentValues(report, "x") != nil { + t.Errorf("reduce=%v: x diverges: %s", opts.Reduce, report.Status()) + } + for _, d := range report.Divergent { + for _, v := range d.Values { + replayWitness(t, m, start, v.Witness, d.Feature+" = "+v.Value) + } + } + } + } +} + +// The action's own features are told from a performed node's by what the +// lowered graph declares, not by punctuation: an attribute named 'a.b' is the +// action's own, and a `node.pin` path selects the node's output. +func TestCheckDivergeTellsAQuotedNameFromANodePath(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + action def Mark { out tag : Integer; first set; action set { assign tag := 1; } } + action race { + attribute 'a.b' : Integer = 0; + first start; + fork split; + action left { assign 'a.b' := 1; } + action right { assign 'a.b' := 2; } + action marker : Mark; + join sync; + done; + succession first start then split; + succession first split then left; + succession first split then right; + succession first split then marker; + succession first left then sync; + succession first right then sync; + succession first marker then sync; + succession first sync then done; + } + }`) + for _, diverge := range [][]string{nil, {"a.b"}, {"a.b", "marker.tag"}} { + report := checkModel(t, m, "race", CheckBudget{}, CheckOptions{Reduce: true, Diverge: diverge}) + if got := divergentValues(report, "a.b"); !slices.Equal(got, []string{"1", "2"}) { + t.Errorf("Diverge %v: 'a.b' diverges over %v, want [1 2]: %s", diverge, got, report.Status()) + } + if len(report.Divergent) != 1 { + t.Errorf("Diverge %v: %d features divergent, want 'a.b' alone: %s", diverge, len(report.Divergent), report.Status()) + } + for _, final := range report.Finals { + if _, held := final.Values["marker.tag"]; held != slices.Contains(diverge, "marker.tag") { + t.Errorf("Diverge %v: final holds marker.tag = %v", diverge, held) + } + } + } + report := checkModel(t, m, "race", CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{"marker.tag"}}) + if len(report.Divergent) != 0 || report.Finals[0].Values["marker.tag"] != "1" { + t.Fatalf("Diverge marker.tag: %s, finals %v; want marker.tag = 1 on every schedule", report.Status(), report.Finals) + } +} + +// A Diverge name nothing answers to fails the check, typed, rather than silently +// selecting nothing; a feature declared but never valued is answered to, as unset. +func TestCheckRejectsAnUnknownDivergeName(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + action def Mark { out tag : Integer; first set; action set { assign tag := 1; } } + action lone { + attribute x : Integer = 0; + attribute later : Integer; + first start; + then action marker : Mark; + then done; + } + }`) + for _, name := range []string{"y", "this.x", "marker.nope", "nobody.tag", "x.y"} { + _, err := checkModelErr(t, m, "lone", CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{"x", name}}) + var unknown *UnknownCheckFeatureError + if !errors.As(err, &unknown) || !errors.Is(err, ErrUnknownCheckFeature) || unknown.Name != name { + t.Errorf("Diverge %s: %v, want ErrUnknownCheckFeature naming it", name, err) + } + } + report := checkModel(t, m, "lone", CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{"later", "marker.tag"}}) + if report.Verdict != CheckExhaustive || len(report.Finals) != 1 { + t.Fatalf("%s, want one final", report.Status()) + } + if values := report.Finals[0].Values; values["later"] != UnsetText || values["marker.tag"] != "1" { + t.Fatalf("final values %v, want later unset and marker.tag = 1", values) + } +} + +// A Diverge path under a node's own performances is told from the lowered flows, +// so a misspelt one fails the check even when no schedule completes to say so. +func TestCheckRejectsAnUnknownDeepDivergePathWhenNoScheduleCompletes(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + action def Emit { out v : Integer; first set; action set { assign v := 1; } } + action def Work { first start; then action output : Emit; then done; } + action stuck { + attribute x : Integer = 0; + first start; + action worker : Work; + action stranded; + join sync; + done; + succession first start then worker; + succession first worker then sync; + succession first stranded then sync; + succession first sync then done; + } + }`) + for _, name := range []string{"worker.output.typo", "worker.nope.v", "worker.output.v.deeper"} { + _, err := checkModelErr(t, m, "stuck", CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{name}}) + var unknown *UnknownCheckFeatureError + if !errors.As(err, &unknown) || unknown.Name != name { + t.Errorf("Diverge %s: %v, want ErrUnknownCheckFeature naming it", name, err) + } + } + report := checkModel(t, m, "stuck", CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{"worker.output.v"}}) + if report.Verdict != CheckViolation || len(report.Violations) != 1 || report.Violations[0].Kind != ViolationDeadlock { + t.Fatalf("%s, want the deadlock alone", report.Status()) + } + if len(report.Finals) != 0 { + t.Fatalf("%d finals, want none: no schedule completes", len(report.Finals)) + } +} + +// A path under a call tied on its arguments' types is told by the performances: +// one no performance holds fails an exhaustive check, while a bounded search that +// stops short of the performance keeps its bounded report. +func TestCheckKeepsABoundedReportWhenAPathUnderATiedCallIsUnreached(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + package A { + action def tag { in x : Integer; in y : Real; out mark : Integer; first step; action step { assign mark := 1; } } + } + package B { + action def tag { in x : Real; in y : Integer; out result : Integer; first step; action step { assign result := 2; } } + } + calc def same { in v; v } + action outer { + private import A::*; + private import B::*; + attribute p = same(1.5); + attribute q = same(2); + first start; + then action byResult = tag(x = p, y = q); + then action fin; + then done; + } + }`) + report := checkModel(t, m, "outer", CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{"byResult.result"}}) + if report.Verdict != CheckExhaustive || len(report.Finals) != 1 || report.Finals[0].Values["byResult.result"] != "2" { + t.Fatalf("%s, finals %+v: want the one final holding byResult.result = 2", report.Status(), report.Finals) + } + _, err := checkModelErr(t, m, "outer", CheckBudget{}, CheckOptions{Reduce: true, Diverge: []string{"byResult.mark"}}) + var unknown *UnknownCheckFeatureError + if !errors.As(err, &unknown) || unknown.Name != "byResult.mark" { + t.Fatalf("exhaustive: %v, want ErrUnknownCheckFeature naming byResult.mark", err) + } + for _, name := range []string{"byResult.result", "byResult.mark"} { + report, err := checkModelErr(t, m, "outer", CheckBudget{States: 1}, CheckOptions{Reduce: true, Diverge: []string{name}}) + if err != nil { + t.Fatalf("Diverge %s under a states bound: %v, want a bounded report", name, err) + } + if report.Verdict != CheckWithinBounds || !slices.Contains(report.BoundsHit, "states") { + t.Fatalf("Diverge %s under a states bound: %s, want no violation within bounds naming states", name, report.Status()) + } + } +} + +// A property may read what no footprint names, so a check with properties +// searches every interleaving: a property false only where one branch ran +// ahead of the other is found either way round, and the outcome-only search still reduces. +func TestCheckSearchesEveryOrderUnderAProperty(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + action pair { + attribute a : Integer = 0; + attribute b : Integer = 0; + first start; + fork split; + action left { assign a := 1; } + then action leftAgain { assign a := 2; } + action right { assign b := 1; } + then action rightAgain { assign b := 2; } + join sync; + done; + succession first start then split; + succession first split then left; + succession first split then right; + succession first leftAgain then sync; + succession first rightAgain then sync; + succession first sync then done; + } + }`) + at := func(exec *ActionExecutor, name string) int64 { return exec.Results()[name].Const.Int } + notLeftFirst := CheckProperty{Name: "notLeftFirst", Holds: func(_ *Context, inv *Invocation) (bool, error) { + exec := inv.action() + return !(at(exec, "a") == 2 && at(exec, "b") == 0), nil + }} + notRightFirst := CheckProperty{Name: "notRightFirst", Holds: func(_ *Context, inv *Invocation) (bool, error) { + exec := inv.action() + return !(at(exec, "b") == 2 && at(exec, "a") == 0), nil + }} + outcomes := checkModel(t, m, "pair", CheckBudget{}, reduced()) + if full := checkModel(t, m, "pair", CheckBudget{}, unreduced()); outcomes.Moves >= full.Moves { + t.Fatalf("outcome-only search made %d moves, unreduced %d; want fewer", outcomes.Moves, full.Moves) + } + for _, p := range []CheckProperty{notLeftFirst, notRightFirst} { + report := checkModel(t, m, "pair", CheckBudget{}, reduced(), p) + if report.Verdict != CheckViolation || len(report.Violations) != 1 || report.Violations[0].Name != p.Name { + t.Fatalf("%s under the reduction: %s, violations %v; want it false once", p.Name, report.Status(), report.Violations) + } + replayWitness(t, m, starterOf(m.action(t, "pair")), report.Violations[0].Witness, p.Name, p) + } +} + +// A property failing to evaluate is a failure whose witness names the property +// and what it raised; its replay evaluates the property again and holds the +// witness to it, so a replay given another property, or none, disagrees. +func TestCheckReplaysAPropertyThatFailsToEvaluate(t *testing.T) { + m := conformanceModel(t, "action_fork_branches_write_one_feature") + start := starterOf(m.action(t, "clash")) + offline := errors.New("the sensor is offline") + sensor := CheckProperty{Name: "sensor", Holds: func(_ *Context, inv *Invocation) (bool, error) { + exec := inv.action() + if exec.State() == StateCompleted && exec.Results()["x"].Const.Int == 2 { + return false, offline + } + return true, nil + }} + report := checkModel(t, m, "clash", CheckBudget{}, reduced(), sensor) + if report.Verdict != CheckViolation || len(report.Violations) != 1 { + t.Fatalf("%s, violations %v; want the sensor's failure once", report.Status(), report.Violations) + } + v := report.Violations[0] + if v.Kind != ViolationFailure || !errors.Is(v.Err, offline) || v.Witness.Property != "sensor" || v.Witness.Fails != offline.Error() { + t.Fatalf("violation %+v, want the sensor failing as its witness claims", v) + } + if !strings.HasSuffix(v.Witness.String(), "\n\nproperty: sensor\nfails: "+offline.Error()) { + t.Fatalf("witness:\n%s", v.Witness) + } + r := replayWitness(t, m, start, v.Witness, "sensor failing", sensor) + if !errors.Is(r.Err, offline) { + t.Fatalf("replay ends with %v, want the sensor's failure", r.Err) + } + quiet := CheckProperty{Name: "sensor", Holds: func(*Context, *Invocation) (bool, error) { return false, nil }} + for name, props := range map[string][]CheckProperty{"none": nil, "another": {quiet}} { + var dis *ReplayDisagreement + if _, err := Replay(context.Background(), m.fresh, start, v.Witness, props); !errors.As(err, &dis) { + t.Errorf("replay given %s property: %v, want a ReplayDisagreement", name, err) + } + } +} + +// TestCheckWeighsMovesByExecutorThenUnit: a move's share is the draw a run makes +// for it — the executor among those with a move, then the unit among its own — +// so the masses a check reports are the probabilities explore reports for the +// same invocation's outcomes. +func TestCheckWeighsMovesByExecutorThenUnit(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + action a { + attribute x : Integer = 0; + first start; + fork split; + action div { assign x := 1 / 0; } + action ref { assign x := 'm' + 1; } + succession first start then split; + succession first split then div; + succession first split then ref; + } + action b { + attribute y : Integer = 0; + first start; + then action w assign y := 1; + then action third assign y := 'n' * 2; + } + }`) + start := invocationOf([]*symbols.Symbol{m.action(t, "a"), m.action(t, "b")}, nil) + leafOf := func(text string) string { + switch { + case strings.Contains(text, "division by zero"): + return "div" + case strings.Contains(text, "reference: m"): + return "ref" + case strings.Contains(text, "reference: n"): + return "third" + } + return "" + } + + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, unreduced(), nil) + if err != nil { + t.Fatalf("check: %v", err) + } + mass := make(map[string]float64) + for _, v := range report.Violations { + leaf := leafOf(v.Err.Error()) + if leaf == "" { + t.Fatalf("violation %q names no leaf", v.Err) + } + mass[leaf] += v.Mass + } + + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, func(ctx *Context) (Outcome, error) { + run, err := beginInvocation(ctx, start) + if err != nil { + return Outcome{}, err + } + if err := run.inv.started(ctx); err != nil { + return Outcome{}, err + } + for { + if err := run.stabilize(); err != nil { + return Outcome{}, err + } + if run.terminal() { + return run.inv.Outcome(), nil + } + if err := run.step(owners(run.enabledMoves())); err != nil { + return Outcome{}, err + } + } + }) + if err != nil || !x.Complete() { + t.Fatalf("explore: %v, %v", err, x) + } + probs := make(map[string]float64) + for _, o := range x.Outcomes { + if o.Outcome.Err == nil { + t.Fatalf("outcome %q failed under no leaf", o.Outcome) + } + probs[leafOf(o.Outcome.Err.Error())] += o.Probability + } + + want := map[string]float64{"div": 0.25, "ref": 0.25, "third": 0.5} + for leaf, w := range want { + if math.Abs(probs[leaf]-w) > 1e-9 { + t.Errorf("explore's probability of the %s failure is %v, want %v", leaf, probs[leaf], w) + } + if math.Abs(mass[leaf]-probs[leaf]) > 1e-9 { + t.Errorf("check's mass of the %s failure is %v, want explore's %v", leaf, mass[leaf], probs[leaf]) + } + } +} diff --git a/internal/exec/runtime/choice.go b/internal/exec/runtime/choice.go new file mode 100644 index 0000000000..f8f029c5fa --- /dev/null +++ b/internal/exec/runtime/choice.go @@ -0,0 +1,370 @@ +package runtime + +import ( + "fmt" + "slices" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// ChoiceKind names what an executor chose among at a choice point. +type ChoiceKind int + +const ( + // ChoiceTokenOrder: several tokens were steppable in one step, and the step + // advanced them in an order the library does not fix. + ChoiceTokenOrder ChoiceKind = iota + // ChoiceDecisionBranch: several guards of one decision node held. + ChoiceDecisionBranch + // ChoiceWriteOrder: two tokens wrote one feature within one step, so which + // value the feature holds afterwards is the order the writes were applied in. + ChoiceWriteOrder + // ChoiceTransition: several transitions out of one state were enabled for + // one event. + ChoiceTransition + // ChoiceRegionOrder: one event enabled transitions in several regions, which + // fired in an order the library does not fix. + ChoiceRegionOrder + // ChoiceDueOrder: several executors had work due at one instant of the + // shared clock, and one of them ran first. + ChoiceDueOrder + // ChoiceDispatchOrder: several events the library leaves unordered — time + // triggers, or a time trigger and a pool event — were due at one instant, and + // one of them was dispatched first. + ChoiceDispatchOrder + // ChoiceEntryOrder: several orthogonal regions, or a fork's branches, each had + // a unit of their entry left, and one of them advanced first. + ChoiceEntryOrder + // ChoiceExitOrder: several orthogonal regions each had a state left to exit, + // and one of them exited first. + ChoiceExitOrder + // ChoiceStepOrder: a do behavior had a step due while an event was due for + // dispatch, and one of them went first. + ChoiceStepOrder + // ChoiceEntryStep: an event due for dispatch and a held entry could proceed, + // and one of them went first. + ChoiceEntryStep +) + +// String is the kind as a trace or diagnostic names it. +func (k ChoiceKind) String() string { + switch k { + case ChoiceTokenOrder: + return "token order" + case ChoiceDecisionBranch: + return "decision branch" + case ChoiceWriteOrder: + return "write order" + case ChoiceTransition: + return "transition" + case ChoiceRegionOrder: + return "region order" + case ChoiceDueOrder: + return "due order" + case ChoiceDispatchOrder: + return "dispatch order" + case ChoiceEntryOrder: + return "entry order" + case ChoiceExitOrder: + return "exit order" + case ChoiceStepOrder: + return "step order" + case ChoiceEntryStep: + return "entry step" + } + return fmt.Sprintf("ChoiceKind(%d)", int(k)) +} + +// ChoiceDiagnosticCode is the code every choice-point diagnostic carries. +const ChoiceDiagnosticCode = "choice-point" + +// UnevaluableGuardCode is the code a diagnostic about a guard the run could not +// evaluate carries. +const UnevaluableGuardCode = "guard-unevaluable" + +// RunNote is a finding a run records about itself without changing it: a choice +// point it made, or a guard it read only to report one and could not evaluate. +type RunNote interface { + // Describe renders the note for a diagnostic; String is its trace line. + Describe() string + String() string + // Diagnostic is the note as an informational finding about the run. + Diagnostic() diag.Diagnostic + // Location is the file and span of the declaration the note is about; file is + // "" when the runtime could not name one. + Location() (file string, span source.Span) +} + +// ChoicePoint is one point where an executor had several enabled alternatives the +// Kernel Semantic Library leaves unordered and took one by its own scheduling rule. +type ChoicePoint struct { + Kind ChoiceKind + // Step is the action step the choice was made in, 0 for a state machine. + Step int + // Where names the decision node, the state and event, or the instant of a + // due order; empty for a token order. + Where string + // Alternatives are canonical: tokens by ID, branches and transitions by + // declaration position, writes by writing token, reacting states by name in + // declaration order, executors due at one instant in the order they were + // created, tied events in arrival order. Taken indexes the one taken. + Alternatives []string + Taken int + // Weights are the probabilities the model states for the alternatives of a + // weighted decision, one per alternative; nil for a choice the model does not weight. + Weights []float64 + // Drew is the unit draw that selected Taken among the Weights, when Drawn. + Drew float64 + Drawn bool + // File and Span locate the declaration the choice was made at; File is "" + // when the runtime could not name one. + File string + Span source.Span +} + +// Weighted reports whether the model weights the alternatives. +func (c ChoicePoint) Weighted() bool { + return len(c.Weights) == len(c.Alternatives) && len(c.Weights) > 0 +} + +// Describe renders the choice for a diagnostic: the alternatives in canonical +// order and which one the executor took; a weighted decision's alternatives carry +// their weights and how the branch was selected. +func (c ChoicePoint) Describe() string { + alts := strings.Join(c.Alternatives, ", ") + taken := "" + if c.Taken >= 0 && c.Taken < len(c.Alternatives) { + taken = c.Alternatives[c.Taken] + } + if c.Kind == ChoiceDecisionBranch && c.Weighted() { + return fmt.Sprintf("step %d: %s branches %s hold (weighted; %s)", c.Step, c.Where, c.weightedAlternatives(), c.selection(taken)) + } + if c.Kind == ChoiceTransition && c.Weighted() { + return fmt.Sprintf("%s: transitions %s (weighted; %s)", c.Where, c.weightedAlternatives(), c.selection(taken)) + } + switch c.Kind { + case ChoiceTokenOrder: + return fmt.Sprintf("step %d: tokens %s (unordered; took %s first)", c.Step, alts, taken) + case ChoiceDecisionBranch: + return fmt.Sprintf("step %d: %s branches %s hold (unordered; took %s)", c.Step, c.Where, alts, taken) + case ChoiceWriteOrder: + return fmt.Sprintf("step %d: writes %s (unordered; %s stood)", c.Step, alts, taken) + case ChoiceTransition: + return fmt.Sprintf("%s: transitions %s (unordered; took %s)", c.Where, alts, taken) + case ChoiceRegionOrder: + if strings.HasPrefix(c.Where, firingWherePrefix) { + return fmt.Sprintf("%s: next %s (unordered; took %s first)", c.Where, alts, taken) + } + return fmt.Sprintf("%s: states %s react (unordered; took %s first)", c.Where, alts, taken) + case ChoiceDueOrder: + return fmt.Sprintf("at %s: due %s (unordered; ran %s first)", c.Where, alts, taken) + case ChoiceDispatchOrder: + return fmt.Sprintf("%s: %s (unordered; dispatched %s first)", c.Where, alts, taken) + case ChoiceEntryOrder, ChoiceExitOrder, ChoiceStepOrder, ChoiceEntryStep: + return fmt.Sprintf("%s: next %s (unordered; took %s first)", c.Where, alts, taken) + } + return fmt.Sprintf("%s: %s (unordered; took %s)", c.Kind, alts, taken) +} + +// weightedAlternatives lists the alternatives each with its weight. +func (c ChoicePoint) weightedAlternatives() string { + parts := make([]string, len(c.Alternatives)) + for i, alt := range c.Alternatives { + parts[i] = alt + " p=" + FormatWeight(c.Weights[i]) + } + return strings.Join(parts, ", ") +} + +// selection says how a weighted decision selected taken: by a draw, or without one. +func (c ChoicePoint) selection(taken string) string { + if c.Drawn { + return "drew " + FormatWeight(c.Drew) + ", took " + taken + } + return "took " + taken +} + +// String is the trace line the choice is recorded as. +func (c ChoicePoint) String() string { + return "choice " + c.Describe() +} + +// Location is where the choice was made. +func (c ChoicePoint) Location() (string, source.Span) { + return c.File, c.Span +} + +// Diagnostic is the choice as a finding about the run: informational, since a +// model is not wrong for admitting several orders and the run took one of them. +func (c ChoicePoint) Diagnostic() diag.Diagnostic { + return diag.Diagnostic{ + Severity: diag.SeverityInfo, + Span: c.Span, + Message: "choice point: " + c.Describe(), + Code: ChoiceDiagnosticCode, + Source: "runtime", + } +} + +// UnevaluableGuard is a guard an executor read only to report a choice, once a +// branch or transition already held, and could not evaluate. A guard with no +// result is not true, so its succession is not selected; the run is unchanged. +type UnevaluableGuard struct { + // Step is the action step the guard was read in, 0 for a state machine. + Step int + // Where names the decision node or the state and event, as a ChoicePoint does. + Where string + // Alternative is the branch or transition by declaration position, as a + // ChoicePoint lists it. + Alternative string + // Reason is the evaluation error. + Reason string + File string + Span source.Span +} + +// Describe renders the guard for a diagnostic: where it was read, which +// alternative it guards and why it has no result. +func (g UnevaluableGuard) Describe() string { + if g.Step > 0 { + return fmt.Sprintf("step %d: %s branch %s: %s (not selected)", g.Step, g.Where, g.Alternative, g.Reason) + } + return fmt.Sprintf("%s: transition %s: %s (not selected)", g.Where, g.Alternative, g.Reason) +} + +// String is the trace line the guard is recorded as. +func (g UnevaluableGuard) String() string { + return "unevaluable guard " + g.Describe() +} + +// Location is where the guard was declared. +func (g UnevaluableGuard) Location() (string, source.Span) { + return g.File, g.Span +} + +// Diagnostic is the guard as a finding about the run: informational, since the +// library selects no succession whose guard is not true and defines no failure. +func (g UnevaluableGuard) Diagnostic() diag.Diagnostic { + return diag.Diagnostic{ + Severity: diag.SeverityInfo, + Span: g.Span, + Message: "guard not evaluable: " + g.Describe(), + Code: UnevaluableGuardCode, + Source: "runtime", + } +} + +// noteChoice keeps a choice point for the run's diagnostics and, when tracing, +// writes it to the trace where it was made. +func (ctx *Context) noteChoice(c ChoicePoint) { + ctx.note(c) +} + +// note keeps n for the run's diagnostics and, when tracing, writes it to the +// trace where it was made. A probe's preview is not a run. +func (ctx *Context) note(n RunNote) { + ctx.noteFrom(n, nil, nil) +} + +// noteFrom is note for a note made by the behavior of an object, which the trace +// record names; nil for one the run made on its own. +func (ctx *Context) noteFrom(n RunNote, self *Instance, behavior *symbols.Symbol) { + if ctx.probes > 0 { + return + } + ctx.run.notes = append(ctx.run.notes, n) + if c, ok := n.(ChoicePoint); ok { + ctx.choices = append(ctx.choices, c.Choice()) + } + if ctx.trace != nil { + ctx.trace.RecordNote(TraceOrigin{At: ctx.clock.now, Object: self, Behavior: behavior}, n) + } +} + +// ChoicesTaken returns the choices every run of the context took, in order, as +// a witness lists them: what a `replay` policy over them follows run by run. +func (ctx *Context) ChoicesTaken() []ChoiceTaken { + return slices.Clone(ctx.choices) +} + +// Notes returns what the latest run noted about itself, in order: its choice +// points and the guards it could not evaluate. +func (ctx *Context) Notes() []RunNote { + return ctx.run.Notes() +} + +// NoteCount is how many notes the latest run has made so far. +func (ctx *Context) NoteCount() int { + return ctx.run.NoteCount() +} + +// Notes returns what the run noted so far, in order; nil for a run not begun. +func (run *runState) Notes() []RunNote { + if run == nil { + return nil + } + return slices.Clone(run.notes) +} + +// NoteCount is how many notes the run has made so far, so a caller driving it +// call by call can tell what one call noted. +func (run *runState) NoteCount() int { + if run == nil { + return 0 + } + return len(run.notes) +} + +// NotesSince returns the notes made since mark, a NoteCount read earlier, copying +// only those. +func (run *runState) NotesSince(mark int) []RunNote { + if mark < 0 { + mark = 0 + } + if run == nil || mark >= len(run.notes) { + return nil + } + return slices.Clone(run.notes[mark:]) +} + +// Notes returns what the executor's run noted so far, in order; see Context.Notes. +func (e *ActionExecutor) Notes() []RunNote { return e.driven.state.Notes() } + +// NoteCount is how many notes the executor's run has made so far. +func (e *ActionExecutor) NoteCount() int { return e.driven.state.NoteCount() } + +// NotesSince returns what the executor's run noted since mark, a NoteCount read earlier. +func (e *ActionExecutor) NotesSince(mark int) []RunNote { return e.driven.state.NotesSince(mark) } + +// Notes returns what the executor's run noted so far, in order; see Context.Notes. +func (e *StateExecutor) Notes() []RunNote { return e.driven.state.Notes() } + +// NoteCount is how many notes the executor's run has made so far. +func (e *StateExecutor) NoteCount() int { return e.driven.state.NoteCount() } + +// NotesSince returns what the executor's run noted since mark, a NoteCount read earlier. +func (e *StateExecutor) NotesSince(mark int) []RunNote { return e.driven.state.NotesSince(mark) } + +// Choices returns the choice points made since the latest run began, in order. +func (ctx *Context) Choices() []ChoicePoint { + var out []ChoicePoint + for _, n := range ctx.run.notes { + if c, ok := n.(ChoicePoint); ok { + out = append(out, c) + } + } + return out +} + +// UnevaluableGuards returns the guards the latest run could not evaluate, in order. +func (ctx *Context) UnevaluableGuards() []UnevaluableGuard { + var out []UnevaluableGuard + for _, n := range ctx.run.notes { + if g, ok := n.(UnevaluableGuard); ok { + out = append(out, g) + } + } + return out +} diff --git a/internal/exec/runtime/choice_test.go b/internal/exec/runtime/choice_test.go new file mode 100644 index 0000000000..2545270008 --- /dev/null +++ b/internal/exec/runtime/choice_test.go @@ -0,0 +1,1607 @@ +package runtime + +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" +) + +// A choice point renders one canonical line per kind, and its diagnostic is +// informational: the model is not wrong for admitting several orders. +func TestChoicePointRendering(t *testing.T) { + cases := []struct { + choice ChoicePoint + want string + }{ + {ChoicePoint{Kind: ChoiceTokenOrder, Step: 2, Alternatives: []string{"2@left", "3@right"}, Taken: 1}, + "choice step 2: tokens 2@left, 3@right (unordered; took 3@right first)"}, + {ChoicePoint{Kind: ChoiceDecisionBranch, Step: 3, Where: "decision pick", Alternatives: []string{"1->a", "2->b"}, Taken: 0}, + "choice step 3: decision pick branches 1->a, 2->b hold (unordered; took 1->a)"}, + {ChoicePoint{Kind: ChoiceWriteOrder, Step: 4, Alternatives: []string{"x := 1 by token 2", "x := 2 by token 3"}, Taken: 0}, + "choice step 4: writes x := 1 by token 2, x := 2 by token 3 (unordered; x := 1 by token 2 stood)"}, + {ChoicePoint{Kind: ChoiceTransition, Where: "state idle on accept Go", Alternatives: []string{"1->low", "2->high"}, Taken: 0}, + "choice state idle on accept Go: transitions 1->low, 2->high (unordered; took 1->low)"}, + {ChoicePoint{Kind: ChoiceRegionOrder, Where: "on accept Go", Alternatives: []string{"a1(exit)", "b1(exit)"}, Taken: 1}, + "choice on accept Go: next a1(exit), b1(exit) (unordered; took b1(exit) first)"}, + {ChoicePoint{Kind: ChoiceRegionOrder, Where: "do round at t=0.0", Alternatives: []string{"a1", "b1"}, Taken: 1}, + "choice do round at t=0.0: states a1, b1 react (unordered; took b1 first)"}, + {ChoicePoint{Kind: ChoiceEntryOrder, Where: "entering work", Alternatives: []string{"a1(entry)", "b1(entry)"}, Taken: 0}, + "choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first)"}, + {ChoicePoint{Kind: ChoiceExitOrder, Where: "exiting work", Alternatives: []string{"a1(exit)", "b1(exit)"}, Taken: 1}, + "choice exiting work: next a1(exit), b1(exit) (unordered; took b1(exit) first)"}, + {ChoicePoint{Kind: ChoiceStepOrder, Where: "at t=0.0", Alternatives: []string{"do top", "dispatch accept Stop"}, Taken: 1}, + "choice at t=0.0: next do top, dispatch accept Stop (unordered; took dispatch accept Stop first)"}, + } + for _, c := range cases { + if got := c.choice.String(); got != c.want { + t.Errorf("String() = %q, want %q", got, c.want) + } + d := c.choice.Diagnostic() + if d.Severity != diag.SeverityInfo { + t.Errorf("%s: severity = %v, want info", c.want, d.Severity) + } + if d.Code != ChoiceDiagnosticCode || d.Source != "runtime" { + t.Errorf("%s: code/source = %q/%q", c.want, d.Code, d.Source) + } + if d.Message != "choice point: "+strings.TrimPrefix(c.want, "choice ") { + t.Errorf("%s: message = %q", c.want, d.Message) + } + } +} + +// Each run starts its own list of choice points; the ones of a run ending in an +// error are still reported, since they explain how the run got there. +func TestChoicesResetPerRun(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action route { + attribute level : Integer = 75; + attribute handler : Integer = 0; + first start; + then decide select; + if level > 50 then warn; + if level > 70 then alarm; + action warn { assign handler := 1; } + then done; + action alarm { assign handler := 2; } + then done; + } + action plain { + attribute n : Integer = 0; + first start; + then action one { assign n := 1; } + then done; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + route := findSymbolByName(idx.DocumentRoot(""), "route", ast.DefAction) + plain := findSymbolByName(idx.DocumentRoot(""), "plain", ast.DefAction) + if route == nil || plain == nil { + t.Fatal("actions not found") + } + + if _, err := ctx.ExecuteAction(route); err != nil { + t.Fatalf("route: %v", err) + } + choices := ctx.Choices() + if len(choices) != 1 || choices[0].Kind != ChoiceDecisionBranch { + t.Fatalf("route choices = %v, want one decision branch", choices) + } + if choices[0].File != "" || choices[0].Span.Len == 0 { + t.Errorf("decision choice is not located: file %q span %+v", choices[0].File, choices[0].Span) + } + + if _, err := ctx.ExecuteAction(plain); err != nil { + t.Fatalf("plain: %v", err) + } + if got := ctx.Choices(); len(got) != 0 { + t.Fatalf("plain inherited choices from the earlier run: %v", got) + } +} + +// Two transitions out of one state enabled by one event are a choice point +// naming the state, the event and both transitions; the first still fires. +func TestTransitionChoiceNamesStateAndEvent(t *testing.T) { + src := `package test { + state Dispatcher { + attribute level : Integer = 8; + entry; then idle; + state idle; + state low; + state high; + transition first idle accept Go if level > 5 then low; + transition first idle accept Go if level > 7 then high; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Dispatcher", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "idle,low" { + t.Fatalf("visited %v, want idle then low: the first enabled transition still fires", visited) + } + choices := ctx.Choices() + if len(choices) != 1 || choices[0].Kind != ChoiceTransition { + t.Fatalf("choices = %v, want one transition choice", choices) + } + want := "choice state idle on accept Go: transitions 1->low, 2->high (unordered; took 1->low)" + if got := choices[0].String(); got != want { + t.Errorf("choice = %q, want %q", got, want) + } +} + +// Reporting a choice never changes the run: a guard after the first holding one +// that cannot be evaluated is no alternative, not an error the run never had; +// before any guard holds it still fails the run as it always did. +func TestLaterGuardErrorIsNotAChoiceNorAFailure(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action route { + attribute level : Integer = 75; + attribute handler : Integer = 0; + first start; + then decide select; + if level > 50 then warn; + if 1 / (level - 75) > 0 then alarm; + action warn { assign handler := 1; } + then done; + action alarm { assign handler := 2; } + then done; + } + action broken { + attribute level : Integer = 75; + first start; + then decide select; + if 1 / (level - 75) > 0 then alarm; + if level > 50 then warn; + action warn; + then done; + action alarm; + then done; + } + state Dispatcher { + attribute level : Integer = 8; + entry; then idle; + state idle; + state low; + state high; + transition first idle accept Go if level > 5 then low; + transition first idle accept Go if 1 / (level - 8) > 0 then high; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + root := idx.DocumentRoot("") + route := findSymbolByName(root, "route", ast.DefAction) + broken := findSymbolByName(root, "broken", ast.DefAction) + dispatcher := findSymbolByName(root, "Dispatcher", ast.DefState) + if route == nil || broken == nil || dispatcher == nil { + t.Fatal("behaviors not found") + } + + values, err := ctx.ExecuteAction(route) + if err != nil { + t.Fatalf("route: %v", err) + } + if got := FormatTraceValue(values["handler"]); got != "1" { + t.Fatalf("handler = %s, want the first holding guard's branch", got) + } + if got := ctx.Choices(); len(got) != 0 { + t.Fatalf("an unevaluable guard was reported as a choice: %v", got) + } + want := "unevaluable guard step 2: decision select branch 2->alarm: division by zero (not selected)" + if got := ctx.UnevaluableGuards(); len(got) != 1 || got[0].String() != want { + t.Fatalf("unevaluable guards = %v, want [%s]", got, want) + } + d := ctx.UnevaluableGuards()[0].Diagnostic() + if d.Severity != diag.SeverityInfo || d.Code != UnevaluableGuardCode || d.Source != "runtime" { + t.Errorf("diagnostic = %+v, want an informational %s from the runtime", d, UnevaluableGuardCode) + } + if !strings.HasPrefix(d.Message, "guard not evaluable: ") { + t.Errorf("message = %q, want it to say the guard is not evaluable", d.Message) + } + if file, span := ctx.UnevaluableGuards()[0].Location(); file != "" || span.Len == 0 { + t.Errorf("location = %q %v, want the guard's span in the test file", file, span) + } + + if _, err := ctx.ExecuteAction(broken); err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("broken: err = %v, want the first guard's evaluation error", err) + } + if got := ctx.UnevaluableGuards(); len(got) != 0 { + t.Fatalf("the first guard's failure was noted rather than raised: %v", got) + } + + _, visited, err := ctx.ExecuteStateWithEvents(dispatcher, []string{"Go"}) + if err != nil { + t.Fatalf("dispatcher: %v", err) + } + if strings.Join(visited, ",") != "idle,low" { + t.Fatalf("visited %v, want idle then low", visited) + } + if got := ctx.Choices(); len(got) != 0 { + t.Fatalf("an unevaluable transition guard was reported as a choice: %v", got) + } + got := ctx.UnevaluableGuards() + if len(got) != 1 || got[0].Where != "state idle on accept Go" || got[0].Alternative != "2->high" || + !strings.Contains(got[0].Reason, "division by zero") || got[0].Step != 0 { + t.Fatalf("unevaluable guards = %+v, want the second transition out of idle on Go", got) + } + if !strings.HasPrefix(got[0].Describe(), "state idle on accept Go: transition 2->high: ") { + t.Errorf("description = %q, want the state, event and transition first", got[0].Describe()) + } +} + +// A choice's later guard is read the same way: once a branch holds, one that +// cannot be evaluated is noted and not taken; a first one still fails the run. +func TestLaterChoiceGuardErrorIsNotAChoiceNorAFailure(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Router { + attribute level : Integer = 0; + attribute route : Integer = 0; + entry; then idle; + state idle; + choice pick; + state low { entry { assign route := 1; } } + state high { entry { assign route := 2; } } + transition first idle accept Go do assign level := 8 then pick; + transition first pick if level > 5 then low; + transition first pick if 1 / (level - 8) > 0 then high; + } + state Broken { + attribute level : Integer = 0; + entry; then idle; + state idle; + choice pick; + state low; + state high; + transition first idle accept Go do assign level := 8 then pick; + transition first pick if 1 / (level - 8) > 0 then high; + transition first pick if level > 5 then low; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + root := idx.DocumentRoot("") + router := findSymbolByName(root, "Router", ast.DefState) + broken := findSymbolByName(root, "Broken", ast.DefState) + if router == nil || broken == nil { + t.Fatal("machines not found") + } + + values, visited, err := ctx.ExecuteStateWithEvents(router, []string{"Go"}) + if err != nil { + t.Fatalf("router: %v", err) + } + if strings.Join(visited, ",") != "idle,low" || FormatTraceValue(values["route"]) != "1" { + t.Fatalf("visited %v with route %s, want idle then low by the first holding branch", visited, FormatTraceValue(values["route"])) + } + if got := ctx.Choices(); len(got) != 0 { + t.Fatalf("an unevaluable choice guard was reported as a choice: %v", got) + } + got := ctx.UnevaluableGuards() + if len(got) != 1 || got[0].Where != "choice pick" || got[0].Alternative != "2->high" || + !strings.Contains(got[0].Reason, "division by zero") || got[0].Step != 0 { + t.Fatalf("unevaluable guards = %+v, want the second branch out of pick", got) + } + if file, span := got[0].Location(); file != "" || span.Len == 0 { + t.Errorf("location = %q %v, want the guard's span in the test file", file, span) + } + + if _, _, err := ctx.ExecuteStateWithEvents(broken, []string{"Go"}); err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("broken: err = %v, want the first guard's evaluation error", err) + } + if got := ctx.UnevaluableGuards(); len(got) != 0 { + t.Fatalf("the first guard's failure was noted rather than raised: %v", got) + } +} + +// A guard read only to report a choice is previewed: what evaluating it costs +// and does is undone, so the run spends and traces exactly what first-match did. +func TestLaterGuardIsProbedWithoutCost(t *testing.T) { + route := func(second string) string { + return `package test { + private import ScalarValues::*; + calc def cost { in n : Integer; return : Integer = if n > 0 ? cost(n - 1) + 1 else 0; } + action route { + attribute level : Integer = 75; + attribute handler : Integer = 0; + first start; + then decide select; + if level > 50 then warn; + if ` + second + ` then alarm; + action warn { assign handler := 1; } + then done; + action alarm { assign handler := 2; } + then done; + } + }` + } + spent := func(t *testing.T, second string) int64 { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, route(second))) + sym := findSymbolByName(idx.DocumentRoot(""), "route", ast.DefAction) + if sym == nil { + t.Fatal("action not found") + } + values, err := ctx.ExecuteAction(sym) + if err != nil { + t.Fatalf("route with guard %s: %v", second, err) + } + if got := FormatTraceValue(values["handler"]); got != "1" { + t.Fatalf("handler = %s with guard %s, want the first holding guard's branch", got, second) + } + if len(ctx.Choices()) != 1 || len(ctx.UnevaluableGuards()) != 0 { + t.Fatalf("notes with guard %s = %v, want the one branch choice", second, ctx.Notes()) + } + return ctx.run.steps + } + cheap := spent(t, "level > 70") + dear := spent(t, "cost(40) > 0") + if cheap != dear { + t.Errorf("steps spent = %d with a cheap later guard, %d with a dear one; want the same", cheap, dear) + } +} + +// The first transition read is the run's, not a preview: its failure fails the +// run as it always has. +func TestFirstTransitionFailureStillFailsTheRun(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Dispatcher { + attribute level : Integer = 8; + entry; then idle; + state idle; + state low; + state high; + transition first idle accept Go if 1 / (level - 8) > 0 then high; + transition first idle accept Go if level > 5 then low; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Dispatcher", ast.DefState) + if sym == nil { + t.Fatal("state not found") + } + _, _, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + if err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("err = %v, want the first transition's evaluation error", err) + } + if got := ctx.UnevaluableGuards(); len(got) != 0 { + t.Fatalf("the first transition's failure was noted rather than raised: %v", got) + } +} + +// Writes to one object through different features are writes to one destination: +// a step writing `cell.mark` and `twin.mark` of the same cell is one conflict. +func TestWriteConflictOnOneObjectThroughTwoChains(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Cell { + attribute mark : Integer = 0; + } + part def Rig { + part cell : Cell; + ref part twin : Cell = cell; + perform action marking { + first start; + fork split; + action viaCell { assign cell.mark := 1; } + action viaTwin { assign twin.mark := 2; } + join sync; + done; + succession first start then split; + succession first split then viaCell; + succession first split then viaTwin; + succession first viaCell then sync; + succession first viaTwin then sync; + succession first sync then done; + } + } + }` + ctx, _, err := instantiateWithLibraries(t, src, "test::Rig") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + var writes []string + for _, c := range ctx.Choices() { + if c.Kind == ChoiceWriteOrder { + writes = append(writes, c.String()) + } + } + if len(writes) != 1 || !strings.Contains(writes[0], "writes mark of object #") || + !strings.Contains(writes[0], ":= 1 by token 2,") || !strings.Contains(writes[0], ":= 2 by token 3") { + t.Fatalf("write choices = %q, want one conflict on the cell's mark", writes) + } +} + +// A transition on a substate and one on its enclosing state enabled by the same +// event are ordered by UML (the substate's fires), so they are no choice point. +func TestAncestorPriorityIsNotAChoice(t *testing.T) { + src := `package test { + state Monitor { + attribute temp : Integer = 30; + entry; then running; + state running { + entry; then fine; + state fine; + state warm; + transition first fine accept Tick if temp > 20 then warm; + } + state halted; + transition first running accept Tick if temp > 10 then halted; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Monitor", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, []string{"Tick"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "running,fine,warm" { + t.Fatalf("visited %v, want the substate's transition to fire", visited) + } + if got := ctx.Choices(); len(got) != 0 { + t.Fatalf("ancestor priority reported as a choice: %v", got) + } +} + +// Leaves in sibling regions select the same transition out of the composite +// state enclosing them, which fires once: so does the choice among the +// transitions out of it. +func TestSharedAncestorChoiceIsReportedOnce(t *testing.T) { + src := `package test { + state Machine { + attribute level : Integer = 8; + entry; then work; + state work parallel { + state a { entry; then a1; state a1; } + state b { entry; then b1; state b1; } + } + state low; + state high; + transition first work accept Go if level > 5 then low; + transition first work accept Go if level > 7 then high; + transition first work accept Go if 1 / (level - 8) > 0 then high; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "work,a1,b1,low" { + t.Fatalf("visited %v, want both regions entered, then the first transition out of work", visited) + } + want := []string{ + "choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first)", + "choice state work on accept Go: transitions 1->low, 2->high (unordered; took 1->low)", + "choice exiting work: next a1(exit), b1(exit) (unordered; took a1(exit) first)", + } + if got := choiceStrings(ctx.Choices()); !slices.Equal(got, want) { + t.Fatalf("choices = %v, want exactly %v", got, want) + } + if got := ctx.UnevaluableGuards(); len(got) != 1 || got[0].Alternative != "3->high" { + t.Fatalf("unevaluable guards = %v, want the third transition out of work, once", got) + } +} + +// choiceStrings spells the choices as %trace does. +func choiceStrings(choices []ChoicePoint) []string { + out := make([]string, len(choices)) + for i, c := range choices { + out[i] = c.String() + } + return out +} + +// noteStrings spells the notes as %trace does. +func noteStrings(notes []RunNote) []string { + out := make([]string, len(notes)) + for i, n := range notes { + out[i] = n.String() + } + return out +} + +// A transition out of a composite state loses to one a nested state takes on the +// same event, so the alternatives found out of the composite state were never +// the run's to choose among: nothing about them is reported. +func TestAncestorChoiceSuppressedByNestedTransitionIsNotReported(t *testing.T) { + src := `package test { + state Machine { + attribute level : Integer = 8; + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept Go then a2; + } + state b { entry; then b1; state b1; } + } + state low; + state high; + transition first work accept Go if level > 5 then low; + transition first work accept Go if level > 7 then high; + transition first work accept Go if 1 / (level - 8) > 0 then high; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "work,a1,b1,a2" { + t.Fatalf("visited %v, want the nested transition to fire and work to stay active", visited) + } + want := []string{"choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first)"} + if got := noteStrings(ctx.Notes()); !slices.Equal(got, want) { + t.Fatalf("the outranked transitions out of work were reported: %v", got) + } +} + +// A step that fails after one token already went still made an ordering choice: +// the failing token could have gone first, and the diagnostics of a failed run +// must say what the run did before it failed. +func TestTokenOrderIsReportedWhenALaterTokenFails(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action race { + attribute x : Integer = 0; + attribute n : Integer = 0; + first start; + fork split; + action safe { assign x := 1; } + action failing { assign x := 1 / n; } + join sync; + done; + succession first start then split; + succession first split then failing; + succession first split then safe; + succession first safe then sync; + succession first failing then sync; + succession first sync then done; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "race", ast.DefAction) + if sym == nil { + t.Fatal("action not found") + } + _, err := ctx.ExecuteAction(sym) + if err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("err = %v, want the failing token's error", err) + } + var tokenOrders []ChoicePoint + for _, c := range ctx.Choices() { + if c.Kind == ChoiceTokenOrder { + tokenOrders = append(tokenOrders, c) + } + } + if len(tokenOrders) != 1 { + t.Fatalf("token-order choices = %v, want the one step both tokens took part in", tokenOrders) + } + got := tokenOrders[0] + if len(got.Alternatives) != 2 || !strings.HasSuffix(got.Alternatives[0], "@failing") || + !strings.HasSuffix(got.Alternatives[1], "@safe") || got.Taken != 1 { + t.Fatalf("choice = %s, want failing and safe as the alternatives, safe taken first", got) + } +} + +// One message two parked accepts both answer to goes to whichever is stepped +// first: the recipient is the executor's choice, and the accept left waiting is +// the alternative even though it did nothing in the step. +func TestSharedMessageAcceptIsAChoice(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action listen { + first start; + fork split; + action left accept a : Integer; + action right accept b : Integer; + join sync; + done; + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then done; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "listen", ast.DefAction) + if sym == nil { + t.Fatal("action not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + for i := 0; i < 10 && exec.State() != StateWaiting; i++ { + if err := exec.Step(); err != nil { + t.Fatalf("step %d: %v", i, err) + } + } + if exec.State() != StateWaiting { + t.Fatalf("state = %v, want both accepts parked", exec.State()) + } + if got := ctx.Choices(); len(got) != 0 { + t.Fatalf("choices before any message = %v, want none: two parked accepts have nothing to take", got) + } + at := func(node string) (Token, bool) { + for _, tok := range exec.Tokens() { + if nodeIdentifier(tok.Location) == node { + return tok, true + } + } + return Token{}, false + } + left, _ := at("left") + right, _ := at("right") + if left.Wait == nil || right.Wait == nil { + t.Fatalf("tokens = %v, want both accepts parked", exec.Tokens()) + } + one := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}} + ctx.PostMessage(Message{SignalType: "Integer", Value: &one}) + if err := exec.Step(); err != nil { + t.Fatalf("step with the message in flight: %v", err) + } + if still, ok := at("left"); !ok || still.Wait == nil { + t.Fatalf("tokens = %v, want left to keep waiting", exec.Tokens()) + } + if _, ok := at("right"); ok { + t.Fatalf("tokens = %v, want right, stepped first, to take the message and move on", exec.Tokens()) + } + got := ctx.Choices() + if len(got) != 1 || got[0].Kind != ChoiceTokenOrder { + t.Fatalf("choices = %v, want the one recipient choice", got) + } + want := fmt.Sprintf("choice step %d: tokens %d@left, %d@right (unordered; took %d@right first)", + got[0].Step, left.ID, right.ID, right.ID) + if got[0].String() != want { + t.Fatalf("choice = %s, want %s", got[0], want) + } + if pending := ctx.PendingMessages(); len(pending) != 0 { + t.Fatalf("pending messages = %+v, want the one message taken", pending) + } + if err := exec.Step(); err != nil { + t.Fatalf("step with nothing in flight: %v", err) + } + if got := ctx.Choices(); len(got) != 1 { + t.Fatalf("choices = %v, want no choice while left waits alone", got) + } +} + +// Two tokens writing one feature in one step is a choice point naming both +// writes and the one that stood, whether or not the values differ: which +// performance wrote last is the executor's order either way. +func TestWriteConflictChoice(t *testing.T) { + run := func(t *testing.T, leftValue string) []ChoicePoint { + src := `package test { + private import ScalarValues::*; + action race { + attribute x : Integer = 0; + first start; + fork split; + action left { assign x := ` + leftValue + `; } + action right { assign x := 2; } + join sync; + done; + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then done; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "race", ast.DefAction) + if sym == nil { + t.Fatal("action not found") + } + if _, err := ctx.ExecuteAction(sym); err != nil { + t.Fatalf("execute: %v", err) + } + var writes []ChoicePoint + for _, c := range ctx.Choices() { + if c.Kind == ChoiceWriteOrder { + writes = append(writes, c) + } + } + return writes + } + + conflicting := run(t, "1") + if len(conflicting) != 1 { + t.Fatalf("write choices = %v, want one", conflicting) + } + want := "choice step 3: writes x := 1 by token 2, x := 2 by token 3 (unordered; x := 1 by token 2 stood)" + if got := conflicting[0].String(); got != want { + t.Errorf("choice = %q, want %q", got, want) + } + agreeing := run(t, "2") + if len(agreeing) != 1 { + t.Fatalf("write choices = %v, want one", agreeing) + } + want = "choice step 3: writes x := 2 by token 2, x := 2 by token 3 (unordered; x := 2 by token 2 stood)" + if got := agreeing[0].String(); got != want { + t.Errorf("choice = %q, want %q", got, want) + } +} + +// Three tokens writing one feature in one step are one choice point listing +// every token's write and the one that stood, not two pairwise ones. +func TestThreeWritersAreOneChoice(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action race { + attribute x : Integer = 0; + first start; + fork split; + action a { assign x := 1; } + action b { assign x := 2; } + action c { assign x := 3; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first split then c; + succession first a then sync; + succession first b then sync; + succession first c then sync; + succession first sync then done; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "race", ast.DefAction) + if sym == nil { + t.Fatal("action not found") + } + values, err := ctx.ExecuteAction(sym) + if err != nil { + t.Fatalf("execute: %v", err) + } + if got := FormatTraceValue(values["x"]); got != "1" { + t.Fatalf("x = %s, want the last token stepped (the lowest) to stand", got) + } + writes := writeChoices(ctx) + want := "choice step 3: writes x := 1 by token 2, x := 2 by token 3, x := 3 by token 4 (unordered; x := 1 by token 2 stood)" + if len(writes) != 1 || writes[0].String() != want { + t.Fatalf("write choices = %v, want exactly [%s]", writes, want) + } +} + +// A token writing one feature twice in its step contributes its last write only: +// had it gone last, that is what would stand. +func TestRepeatedWritesByOneTokenListItsLast(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action race { + attribute x : Integer = 0; + first start; + fork split; + action left { assign x := 1; assign x := 3; } + action right { assign x := 2; } + join sync; + done; + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then done; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "race", ast.DefAction) + if sym == nil { + t.Fatal("action not found") + } + values, err := ctx.ExecuteAction(sym) + if err != nil { + t.Fatalf("execute: %v", err) + } + if got := FormatTraceValue(values["x"]); got != "3" { + t.Fatalf("x = %s, want left's last write to stand", got) + } + writes := writeChoices(ctx) + want := "choice step 3: writes x := 3 by token 2, x := 2 by token 3 (unordered; x := 3 by token 2 stood)" + if len(writes) != 1 || writes[0].String() != want { + t.Fatalf("write choices = %v, want exactly [%s]", writes, want) + } +} + +// A feature and the one redefining it are one destination, so writes under either +// name within one step conflict, direct from the performer or through a chain. +func TestAliasWritesAreOneDestination(t *testing.T) { + flow := func(viaMark, viaLabel string) string { + return ` + first start; + fork split; + action viaMark { ` + viaMark + ` } + action viaLabel { ` + viaLabel + ` } + join sync; + done; + succession first start then split; + succession first split then viaMark; + succession first split then viaLabel; + succession first viaMark then sync; + succession first viaLabel then sync; + succession first sync then done;` + } + cases := map[string]struct{ src, fqn string }{ + "performer": {`package test { + private import ScalarValues::*; + part def Cell { attribute mark : Integer = 0; } + part def Twin :> Cell { + attribute :>> mark; + attribute label :>> mark; + perform action marking {` + flow("assign mark := 1;", "assign label := 2;") + `} + } + }`, "test::Twin"}, + "chain": {`package test { + private import ScalarValues::*; + part def Cell { attribute mark : Integer = 0; } + part def Twin :> Cell { attribute label :>> mark; } + part def Rig { + part cell : Twin; + perform action marking {` + flow("assign cell.mark := 1;", "assign cell.label := 2;") + `} + } + }`, "test::Rig"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + ctx, _, err := instantiateWithLibraries(t, c.src, c.fqn) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + writes := writeChoices(ctx) + if len(writes) != 1 || len(writes[0].Alternatives) != 2 { + t.Fatalf("write choices = %v, want one conflict on the cell's mark", writes) + } + got := writes[0].String() + if !strings.Contains(got, " of object #") || !strings.Contains(got, ":= 1 by token 2,") || + !strings.Contains(got, ":= 2 by token 3") || !strings.Contains(got, ":= 1 by token 2 stood") { + t.Fatalf("choice = %q, want both names' writes as one destination, the mark's write standing", got) + } + }) + } +} + +// writeChoices is the write-order choice points of the last run. +func writeChoices(ctx *Context) []ChoicePoint { + var writes []ChoicePoint + for _, c := range ctx.Choices() { + if c.Kind == ChoiceWriteOrder { + writes = append(writes, c) + } + } + return writes +} + +// An object a probed later guard makes is undone with its identity, so the run's +// objects are numbered as when no later guard is probed at all. +func TestProbedGuardLeavesObjectIdentitiesUntouched(t *testing.T) { + route := func(second string) string { + return `package test { + private import ScalarValues::*; + item def Cell { attribute v : Integer; } + action route { + attribute level : Integer = 75; + attribute made : Integer = 0; + first start; + then decide select; + if level > 50 then warn; + if ` + second + ` then alarm; + action warn { assign made := new Cell(2).v; } + then done; + action alarm { assign made := new Cell(3).v; } + then done; + } + }` + } + objects := func(t *testing.T, second string) string { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, route(second))) + sym := findSymbolByName(idx.DocumentRoot(""), "route", ast.DefAction) + if sym == nil { + t.Fatal("action not found") + } + values, err := ctx.ExecuteAction(sym) + if err != nil { + t.Fatalf("route with guard %s: %v", second, err) + } + if got := FormatTraceValue(values["made"]); got != "2" { + t.Fatalf("made = %s with guard %s, want the first holding guard's branch", got, second) + } + if len(ctx.Choices()) != 1 || len(ctx.UnevaluableGuards()) != 0 { + t.Fatalf("notes with guard %s = %v, want the one branch choice", second, ctx.Notes()) + } + return fmt.Sprint(ctx.InstanceIDs()) + } + plain := objects(t, "level > 70") + probed := objects(t, "new Cell(1).v > 0") + if plain != probed { + t.Errorf("objects %s after a probed guard made one, %s otherwise; want the same identities", probed, plain) + } +} + +// A selected transition whose guard another region's reaction falsified before +// its turn does not fire, so nothing about selecting it is reported; the +// region order that let the other reaction go first is the run's choice: a's +// silent exit rides with its effect, which falsifies the guard and drops b out. +func TestNotesOfATransitionBlockedBeforeFiringAreDropped(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Machine { + attribute level : Integer = 8; + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept Go do assign level := 0 then a2; + } + state b { + entry; then b1; + state b1; + state b2; + state b3; + transition first b1 accept Go if level > 5 then b2; + transition first b1 accept Go if level > 7 then b3; + } + } + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "work,a1,b1,a2" { + t.Fatalf("visited %v, want region a to fire and region b, its guards blocked by a's effect, to stay", visited) + } + want := []string{ + "choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first)", + "choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first)", + } + if got := noteStrings(ctx.Notes()); !slices.Equal(got, want) { + t.Fatalf("notes %v, want the entry and region-order choices alone: %v", got, want) + } +} + +// A region-order choice names the occurrence dispatched, not the trigger of the +// region drawn first: two regions may spell one message differently (its type +// and a supertype), and the report must read the same whichever a seed draws. +func TestRegionOrderChoiceNamesTheOccurrenceNotTheTakenTrigger(t *testing.T) { + src := `package test { + private import ScalarValues::*; + attribute def Base; + attribute def Go :> Base; + state Machine { + entry; then start; + state start; + transition first start do send new Go() then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept Go then a2; + } + state b { + entry; then b1; + state b1; + state b2; + transition first b1 accept Base then b2; + } + } + } + }` + under := func(spelling string) ChoicePoint { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + policy, err := ParseSchedulePolicy(spelling) + if err != nil { + t.Fatal(err) + } + if err := ctx.SetSchedule(policy); err != nil { + t.Fatal(err) + } + if _, _, err := ctx.ExecuteStateWithEvents(sym, nil); err != nil { + t.Fatalf("%s: execute: %v", spelling, err) + } + return firstRegionOrderChoice(t, spelling, ctx.Notes(), "a1(exit), b1(exit)") + } + const want = "on accept Go" + if choice := under("reverse"); choice.Taken != 0 || choice.Where != want { + t.Fatalf("reverse: %s, want a1 first %q", choice.String(), want) + } + for seed := 1; seed <= 32; seed++ { + choice := under(fmt.Sprintf("seed:%d", seed)) + if choice.Where != want { + t.Fatalf("seed:%d: choice %s, want it named %q whichever region it took first", seed, choice.String(), want) + } + if choice.Taken == 1 { + return + } + } + t.Fatal("no seed up to 32 took b1 first; the case does not exercise the alternate draw") +} + +// firstRegionOrderChoice returns the dispatch's first draw among its firings, checking the +// run's notes are the entry order and those draws alone, every draw of the dispatch named alike. +func firstRegionOrderChoice(t *testing.T, label string, notes []RunNote, alternatives string) ChoicePoint { + t.Helper() + var firings []ChoicePoint + for _, note := range notes { + choice, ok := note.(ChoicePoint) + switch { + case ok && choice.Kind == ChoiceEntryOrder: + case ok && choice.Kind == ChoiceRegionOrder: + firings = append(firings, choice) + default: + t.Fatalf("%s: note %v, want the entry and region-order choices alone", label, note) + } + } + if len(firings) == 0 || strings.Join(firings[0].Alternatives, ", ") != alternatives { + t.Fatalf("%s: notes %v, want a region-order choice among %s first", label, notes, alternatives) + } + for _, choice := range firings[1:] { + if choice.Where != firings[0].Where { + t.Fatalf("%s: the draws of one dispatch are named %q and %q", label, firings[0].Where, choice.Where) + } + } + return firings[0] +} + +// A message sent from an event feature is named after that feature, as the +// accept subsetting it is written: two event features of one type dispatched to +// the same regions make choices a reader can tell apart. +func TestRegionOrderChoiceNamesTheEventFeatureSent(t *testing.T) { + const src = `package test { + item def Ping; + state Machine { + item alert : Ping; + item alarm : Ping; + entry; then start; + state start; + transition first start do send %s then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept :> %[1]s then a2; + } + state b { + entry; then b1; + state b1; + state b2; + transition first b1 accept :> %[1]s then b2; + } + } + } + }` + for _, feature := range []string{"alert", "alarm"} { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, fmt.Sprintf(src, feature))) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + if _, _, err := ctx.ExecuteStateWithEvents(sym, nil); err != nil { + t.Fatalf("%s: execute: %v", feature, err) + } + want := "on accept :> " + feature + if choice := firstRegionOrderChoice(t, feature, ctx.Notes(), "a1(exit), b1(exit)"); choice.Where != want { + t.Fatalf("%s: choice %v, want a region-order choice %q", feature, choice, want) + } + } +} + +// A transition that fires and fails in its effect was the run's choice all the +// same: the choice explains how the run got to the failure. +func TestNotesOfATransitionFailingInItsEffectAreKept(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Dispatcher { + attribute level : Integer = 8; + entry; then idle; + state idle; + state low; + state high; + transition first idle accept Go if level > 5 do assign level := 1 / (level - 8) then low; + transition first idle accept Go if level > 7 then high; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Dispatcher", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, _, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + if err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("err = %v, want the effect's evaluation error", err) + } + want := "choice state idle on accept Go: transitions 1->low, 2->high (unordered; took 1->low)" + if got := ctx.Choices(); len(got) != 1 || got[0].String() != want { + t.Fatalf("choices = %v, want exactly [%s]", got, want) + } +} + +// Change-triggered transitions out of one state enabled by one poll are a choice +// point as event-triggered ones are; the first declared fires. +func TestChangeTransitionChoice(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Monitor { + attribute temp : Integer = 0; + entry; then start; + state start; + state watching; + state cool; + state hot; + transition first start do assign temp := 30 then watching; + transition first watching accept when temp > 20 then cool; + transition first watching accept when temp > 25 then hot; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Monitor", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "start,watching,cool" { + t.Fatalf("visited %v, want the first declared change transition to fire", visited) + } + want := "choice state watching on change: transitions 1->cool, 2->hot (unordered; took 1->cool)" + if got := ctx.Choices(); len(got) != 1 || got[0].String() != want { + t.Fatalf("choices = %v, want exactly [%s]", got, want) + } +} + +// A change guard read once an earlier transition is enabled is a probe: one that +// cannot be evaluated is not enabled and not an error, and stays armed. +func TestLaterChangeGuardErrorIsNotAChoiceNorAFailure(t *testing.T) { + monitor := func(first, second string) string { + return `package test { + private import ScalarValues::*; + state Monitor { + attribute temp : Integer = 0; + entry; then start; + state start; + state watching; + state cool; + state hot; + transition first start do assign temp := 30 then watching; + transition first watching accept when temp > 20 if ` + first + ` then cool; + transition first watching accept when temp > 25 if ` + second + ` then hot; + } + }` + } + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, monitor("temp > 0", "1 / (temp - 30) > 0"))) + sym := findSymbolByName(idx.DocumentRoot(""), "Monitor", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "start,watching,cool" { + t.Fatalf("visited %v, want the first enabled change transition to fire", visited) + } + if got := ctx.Choices(); len(got) != 0 { + t.Fatalf("an unevaluable guard was reported as a choice: %v", got) + } + want := "unevaluable guard state watching on change: transition 2->hot: eval change guard: eval guard of transition watching -> hot: division by zero (not selected)" + if got := ctx.UnevaluableGuards(); len(got) != 1 || got[0].String() != want { + t.Fatalf("unevaluable guards = %v, want [%s]", got, want) + } + + idx, _, ctx = buildRuntime(t, "", parseAndBuild(t, monitor("1 / (temp - 30) > 0", "temp > 0"))) + sym = findSymbolByName(idx.DocumentRoot(""), "Monitor", ast.DefState) + if _, _, err := ctx.ExecuteStateWithEvents(sym, nil); err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("first guard: err = %v, want its evaluation error to fail the run", err) + } + if got := ctx.Notes(); len(got) != 0 { + t.Fatalf("a first guard's failure was noted: %v", got) + } +} + +// A composite state's change transition loses to a nested state's on the same rise +// and parallel regions fire alongside: the rise reports which region reacts first +// and, in the one state with two enabled, which transition; the outranked +// composite state draws nothing. Neither firing performs a behavior, so each is +// one draw of silent units. +func TestChangeTransitionChoiceUnderHierarchyAndRegions(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Machine { + attribute temp : Integer = 0; + entry; then start; + state start; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + state a3; + transition first a1 accept when temp > 20 then a2; + transition first a1 accept when temp > 25 then a3; + } + state b { + entry; then b1; + state b1; + state b2; + transition first b1 accept when temp > 20 then b2; + } + } + state halted; + state stopped; + transition first start do assign temp := 30 then work; + transition first work accept when temp > 20 then halted; + transition first work accept when temp > 25 then stopped; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "start,work,a1,b1,a2,b2" { + t.Fatalf("visited %v, want both regions to take their nested transitions and work to stay active", visited) + } + want := []string{ + "choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first)", + "choice on change: next a1(exit), b1(exit) (unordered; took a1(exit) first)", + "choice state a1 on change: transitions 1->a2, 2->a3 (unordered; took 1->a2)", + } + if got := choiceStrings(ctx.Choices()); !slices.Equal(got, want) { + t.Fatalf("choices = %v, want exactly %v", got, want) + } +} + +// A state's completion is one occurrence: several completion transitions out of +// it are one transition choice, drawn when the completion is dispatched, and the +// ones not drawn leave the queue rather than firing on a completion of their own. +func TestCompletionTransitionChoiceIsReportedOnce(t *testing.T) { + src := `package test { + state Switch { + entry; then ready; + state ready; + state left; + state right; + state settled; + transition first ready then left; + transition first ready then right; + transition first left then settled; + transition first right then settled; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Switch", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "ready,left,settled" { + t.Fatalf("visited %v, want ready, left, settled: one completion fires one transition", visited) + } + want := []string{"choice state ready: transitions 1->left, 2->right (unordered; took 1->left)"} + var got []string + for _, choice := range ctx.Choices() { + got = append(got, choice.String()) + } + if !slices.Equal(got, want) { + t.Fatalf("choices = %v, want exactly %v", got, want) + } +} + +// The guards of a state's queued completion transitions are read again when its +// completion is dispatched: one a sibling region's earlier completion has since +// disabled is no alternative, so the other fires and no choice is reported. +func TestCompletionTransitionChoiceRereadsGuards(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Machine { + attribute flag : Boolean = true; + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 do assign flag := false then a2; + } + state b { + entry; then ready; + state ready; + state left; + state right; + transition first ready if flag then left; + transition first ready then right; + } + } + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "work,a1,ready,a2,right" { + t.Fatalf("visited %v, want a1's completion to clear flag before ready's is dispatched, and ready to move to right", visited) + } + if got, want := choiceStrings(ctx.Choices()), []string{"choice entering work: next a1(entry), ready(entry) (unordered; took a1(entry) first)"}; !slices.Equal(got, want) { + t.Fatalf("choices = %v, want the entry order alone: the disabled completion transition is no alternative", got) + } +} + +// A completion transition whose guard was false when the state completed is read +// again when the completion is dispatched: one a sibling region's earlier +// completion has since enabled is an alternative, so the two are a choice. +func TestCompletionTransitionChoiceSeesLaterEnabledGuard(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Machine { + attribute flag : Boolean = false; + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 do assign flag := true then a2; + } + state b { + entry; then ready; + state ready; + state left; + state right; + transition first ready if flag then left; + transition first ready then right; + } + } + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "work,a1,ready,a2,left" { + t.Fatalf("visited %v, want a1's completion to set flag before ready's is dispatched, and the draw to take left", visited) + } + want := []string{ + "choice entering work: next a1(entry), ready(entry) (unordered; took a1(entry) first)", + "choice state ready: transitions 1->left, 2->right (unordered; took 1->left)", + } + if got := choiceStrings(ctx.Choices()); !slices.Equal(got, want) { + t.Fatalf("choices = %v, want exactly %v: the guard enabled since queuing is an alternative", got, want) + } +} + +// A state whose only completion transition has a false guard still completes: +// the guard is read again when the completion is dispatched, and one enabled +// since fires. +func TestSingleCompletionTransitionFiresOnceGuardHolds(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Machine { + attribute flag : Boolean = false; + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 do assign flag := true then a2; + } + state b { + entry; then ready; + state ready; + state left; + transition first ready if flag then left; + } + } + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "work,a1,ready,a2,left" { + t.Fatalf("visited %v, want ready's completion to fire once a1's effect set flag", visited) + } + if got, want := choiceStrings(ctx.Choices()), []string{"choice entering work: next a1(entry), ready(entry) (unordered; took a1(entry) first)"}; !slices.Equal(got, want) { + t.Fatalf("choices = %v, want the entry order alone: one completion transition is no choice", got) + } +} + +// A completion transition into a join whose other branch has not arrived is not +// enabled, so it is no alternative to draw: the other completion transition +// fires and the queue is not drained on a join that cannot move. +func TestCompletionTransitionChoiceSkipsUnreadyJoin(t *testing.T) { + src := `package test { + state Machine { + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept Go then a2; + } + state b { + entry; then ready; + state ready; + state right; + transition first ready then sync; + transition first ready then right; + } + } + join sync; + state done; + transition first a2 then sync; + transition first sync then done; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "work,a1,ready,right,a2" { + t.Fatalf("visited %v, want ready to move to right while the join waits on a2, then a2 alone", visited) + } + if got, want := choiceStrings(ctx.Choices()), []string{"choice entering work: next a1(entry), ready(entry) (unordered; took a1(entry) first)"}; !slices.Equal(got, want) { + t.Fatalf("choices = %v, want the entry order alone: a transition into an unready join is no alternative", got) + } +} + +// A queued completion transition whose guard can no longer be read once one +// alternative is enabled is noted as not selected, as for a triggered event, +// rather than failing the run. +func TestLaterCompletionGuardErrorIsNotedNotRaised(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Machine { + attribute d : Integer = 1; + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 do assign d := 0 then a2; + } + state b { + entry; then ready; + state ready; + state left; + state right; + transition first ready if d < 2 then left; + transition first ready if 1 / d > 0 then right; + } + } + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "work,a1,ready,a2,left" { + t.Fatalf("visited %v, want a1's completion to zero d before ready's is dispatched, and ready to move to left", visited) + } + if got, want := choiceStrings(ctx.Choices()), []string{"choice entering work: next a1(entry), ready(entry) (unordered; took a1(entry) first)"}; !slices.Equal(got, want) { + t.Fatalf("choices = %v, want the entry order alone: an unevaluable completion guard is no alternative", got) + } + got := ctx.UnevaluableGuards() + if len(got) != 1 || got[0].Where != "state ready" || got[0].Alternative != "2->right" || + !strings.Contains(got[0].Reason, "division by zero") { + t.Fatalf("unevaluable guards = %+v, want the second completion transition out of ready", got) + } +} + +// A completion guard that cannot be read when the state completes is not read +// then: the guards are read when the completion is dispatched, where a later +// unevaluable one is noted as not selected once an earlier one is enabled. +func TestBrokenLaterCompletionGuardDoesNotAbortEntry(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Machine { + entry; then ready; + state ready; + state left; + state right; + transition first ready then left; + transition first ready if 1 / 0 > 0 then right; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + _, visited, err := ctx.ExecuteStateWithEvents(sym, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Join(visited, ",") != "ready,left" { + t.Fatalf("visited %v, want ready to complete and move to left", visited) + } + if choices := ctx.Choices(); len(choices) != 0 { + t.Fatalf("choices = %v, want none: an unevaluable completion guard is no alternative", choices) + } + got := ctx.UnevaluableGuards() + if len(got) != 1 || got[0].Where != "state ready" || got[0].Alternative != "2->right" || + !strings.Contains(got[0].Reason, "division by zero") { + t.Fatalf("unevaluable guards = %+v, want the second completion transition out of ready", got) + } +} diff --git a/internal/core/runtime/classification.go b/internal/exec/runtime/classification.go similarity index 99% rename from internal/core/runtime/classification.go rename to internal/exec/runtime/classification.go index 60cbcf9cd4..66ebce9988 100644 --- a/internal/core/runtime/classification.go +++ b/internal/exec/runtime/classification.go @@ -4,8 +4,8 @@ import ( "math" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // classifiedBy is which of a value's types a classification reads (KerML 1.0 Table 5): diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go new file mode 100644 index 0000000000..b17df48a97 --- /dev/null +++ b/internal/exec/runtime/classifier_behavior.go @@ -0,0 +1,1320 @@ +package runtime + +import ( + "fmt" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// classifierBehaviorDecl is a behavior a type binds to its objects, paired with +// the member symbol that binds it. +type classifierBehaviorDecl struct { + behavior lower.ClassifierBehavior + member *symbols.Symbol +} + +// ObjectBehavior is a behavior one object runs because its type exhibits or +// performs it: an execution of its own, bound to that object's identity. +type ObjectBehavior struct { + // Name is the name the behavior answers to on the object. + Name string + Kind lower.ClassifierBehaviorKind + // Symbol is the state machine or action holding the body being run, which is + // the binding declaration itself when it states one. + Symbol *symbols.Symbol + // Object is the object performing the behavior. + Object *Instance + // member is the declaration binding the behavior to the object's type, which + // tells two behaviors apart even when neither is named. + member *symbols.Symbol + // bindings is the chain from member to Symbol: each element names the next, + // so a machine addressed by any of them is this one. + bindings []*symbols.Symbol + // kinds are the types the bindings conform to, so a machine stating its own + // body under `exhibit state m : M { ... }` is still a machine of kind M. + kinds []*symbols.Symbol + // binding is member's position among the type's behavior bindings, which + // outlives the symbols and so tells the behavior a restart puts in its place. + binding int + // State is the machine the object exhibits, nil for a performed action. + State *StateExecutor + // Action is the action the object performs, nil for an exhibited machine. + Action *ActionExecutor +} + +// Describe names the behavior and the object running it, for diagnostics. +func (b *ObjectBehavior) Describe() string { + name := b.Name + if name == "" { + name = symbolText(b.Symbol) + } + return fmt.Sprintf("%s %s of object #%d", b.Kind, name, b.Object.ID) +} + +// forgetBehaviorWrites drops what a run wrote, so a restarted behavior reads the +// object's declared initial values instead of what the discarded run left. +func (inst *Instance) forgetBehaviorWrites(ctx *Context) { + for _, fv := range inst.FeatureValues { + if !fv.Written { + continue + } + fv.Value, fv.Values = Value{}, Value{} + fv.Materialized, fv.Written = false, false + ctx.invalidateDependents(fv) + } +} + +// Behaviors are the behaviors the object runs, in declaration order. +func (inst *Instance) Behaviors() []*ObjectBehavior { + return inst.behaviors +} + +// Behavior returns the behavior of the given name the object runs. An unnamed +// behavior answers to no name and so is never returned. +func (inst *Instance) Behavior(name string) (*ObjectBehavior, bool) { + if name == "" { + return nil, false + } + for _, b := range inst.behaviors { + if b.Name == name { + return b, true + } + } + return nil, false +} + +// BehaviorNamed returns the behavior the object runs under the given name or +// under another name of the same feature: a redefinition renames the behavior +// it redefines (KerML 1.0 §7.3.4.5), so both names denote one execution. The +// renaming may come from any type of the object, a classifier included. +func (ctx *Context) BehaviorNamed(inst *Instance, name string) (*ObjectBehavior, bool) { + if b, ok := inst.Behavior(name); ok { + return b, true + } + if name == "" { + return nil, false + } + for _, typ := range inst.types() { + for _, b := range inst.behaviors { + if b.member != nil && slices.Contains(ctx.redefinedNames(b.member, typ), name) { + return b, true + } + } + for _, feat := range ctx.FeaturesOf(typ) { + if feat.Name != name || feat.Symbol == nil { + continue + } + for _, redefined := range ctx.redefinedNames(feat.Symbol, typ) { + if b, ok := inst.Behavior(redefined); ok { + return b, true + } + } + } + } + return nil, false +} + +// runsBound reports whether the object already runs the behavior a member of typ binds, +// under that member or one redefinition makes the same feature: a start reached twice, or +// a classifier renaming a running behavior, attaches nothing. +func (ctx *Context) runsBound(inst *Instance, member, typ *symbols.Symbol) bool { + for _, b := range inst.behaviors { + if b.member == member { + return true + } + if b.member == nil || member == nil { + continue + } + if slices.Contains(ctx.redefinedFeatures(member, typ), b.member) || + slices.Contains(ctx.redefinedFeatures(b.member, typ), member) { + return true + } + } + return false +} + +// ExhibitedState returns the machine the object exhibits, and false when it +// exhibits none. With several, it returns the first declared. +func (inst *Instance) ExhibitedState() (*ObjectBehavior, bool) { + for _, b := range inst.behaviors { + if b.Kind == lower.ExhibitedState { + return b, true + } + } + return nil, false +} + +// ExhibitedStates returns every machine the object exhibits, in declaration order. +func (inst *Instance) ExhibitedStates() []*ObjectBehavior { + var machines []*ObjectBehavior + for _, b := range inst.behaviors { + if b.Kind == lower.ExhibitedState { + machines = append(machines, b) + } + } + return machines +} + +// ExhibitedStatesOf returns the machines the object exhibits under sym's declaration: +// the one sym itself binds, or else every one reaching sym through its bindings +// (the usage it names, or the definition holding its body) or typed by it, since +// one definition can be the body or the kind of several exhibited usages. +// Declarations are compared. +func (inst *Instance) ExhibitedStatesOf(sym *symbols.Symbol) []*ObjectBehavior { + return inst.behaviorsOf(lower.ExhibitedState, sym) +} + +// PerformedActionsOf returns the actions the object performs under sym's declaration, +// matched as ExhibitedStatesOf matches machines. +func (inst *Instance) PerformedActionsOf(sym *symbols.Symbol) []*ObjectBehavior { + return inst.behaviorsOf(lower.PerformedAction, sym) +} + +// behaviorsOf is the object's behaviors of kind running sym's declaration. +func (inst *Instance) behaviorsOf(kind lower.ClassifierBehaviorKind, sym *symbols.Symbol) []*ObjectBehavior { + if sym == nil || sym.Decl == nil { + return nil + } + var bodies []*ObjectBehavior + for _, b := range inst.behaviors { + if b.Kind != kind { + continue + } + if b.member != nil && b.member.Decl == sym.Decl { + return []*ObjectBehavior{b} + } + if (len(b.bindings) > 1 && declaresAny(b.bindings[1:], sym)) || declaresAny(b.kinds, sym) { + bodies = append(bodies, b) + } + } + return bodies +} + +// ExhibitsState reports whether member is an exhibit declaration whose objects +// run sym's machine: member itself, anything its bindings reach, or a type they +// conform to. Declarations are compared. +func (ctx *Context) ExhibitsState(member, sym *symbols.Symbol) bool { + if member == nil || member.Decl == nil || sym == nil || sym.Decl == nil { + return false + } + behavior, ok := lower.ClassifierBehaviorOf(member.Decl) + if !ok || behavior.Kind != lower.ExhibitedState { + return false + } + chain, err := ctx.classifierBehaviorChain(classifierBehaviorDecl{behavior: behavior, member: member}) + return err == nil && (declaresAny(chain, sym) || declaresAny(ctx.behaviorKinds(chain), sym)) +} + +// behaviorKinds collects the types the bindings of a behavior conform to, in +// chain order, so a machine is addressable by the definition it is typed by +// even when a usage on the way states the body itself. +func (ctx *Context) behaviorKinds(chain []*symbols.Symbol) []*symbols.Symbol { + var kinds []*symbols.Symbol + for _, sym := range chain { + for _, sup := range ctx.model.semantics.AllSupertypes(sym) { + if !slices.Contains(chain, sup) && !slices.Contains(kinds, sup) { + kinds = append(kinds, sup) + } + } + } + return kinds +} + +// declaresAny reports whether one of syms declares what sym declares. +func declaresAny(syms []*symbols.Symbol, sym *symbols.Symbol) bool { + for _, s := range syms { + if s != nil && s.Decl == sym.Decl { + return true + } + } + return false +} + +// Member is the declaration binding the behavior to the object's type: the +// exhibiting or performing usage, which is what addresses this behavior +// when several run the same body. +func (b *ObjectBehavior) Member() *symbols.Symbol { + return b.member +} + +// classifierBehaviorsOf reports the behaviors every object of a type runs: +// those its own declaration binds and those it inherits. A behavior's own +// `perform`/`exhibit` members are steps its execution runs, so a performance +// occurrence — an object typed by an action or state — runs none as its own. +func (ctx *Context) classifierBehaviorsOf(typeSym *symbols.Symbol) []classifierBehaviorDecl { + if typeSym == nil || isBehaviorType(typeSym) { + return nil + } + if cached, ok := ctx.model.classifierBehaviors[typeSym]; ok { + return cached + } + var out []classifierBehaviorDecl + for _, member := range ctx.model.semantics.MembersOf(typeSym) { + if member.Decl == nil { + continue + } + if behavior, ok := lower.ClassifierBehaviorOf(member.Decl); ok { + out = append(out, classifierBehaviorDecl{behavior: behavior, member: member}) + } + } + ctx.model.classifierBehaviors[typeSym] = out + return out +} + +// startClassifierBehaviors gives the object an execution of every behavior its +// type exhibits or performs, and runs those executions to quiescence: no due +// event, no runnable do action, no deliverable message. A start reached from +// inside a running behavior only attaches, leaving the run to the outermost +// start, so materializing objects that exhibit each other terminates. +func (ctx *Context) startClassifierBehaviors(inst *Instance, mark int) error { + return ctx.startClassifierBehaviorsOf([]*Instance{inst}, mark) +} + +// startClassifierBehaviorsOf starts the behaviors of every one of the objects as +// one collective run, so objects materialized together exchange messages. +func (ctx *Context) startClassifierBehaviorsOf(objects []*Instance, mark int) error { + if ctx.declarative { + return nil + } + attached := len(ctx.objectBehaviors) + if err := ctx.startBehaviorsOfAll(objects); err != nil { + ctx.abandonCreationSince(mark, attached) + return err + } + return nil +} + +// abandonCreationSince undoes a creation that failed: neither the objects it +// registered after mark nor the behaviors attached after attached survive it. +func (ctx *Context) abandonCreationSince(mark, attached int) { + ctx.abandonCreationBetween(mark, len(ctx.created), attached, len(ctx.objectBehaviors)) +} + +// abandonCreationBetween undoes one stretch of creation: the objects registered +// from mark up to end and the behaviors attached from attached up to started. +func (ctx *Context) abandonCreationBetween(mark, end, attached, started int) { + ctx.forgetBehaviorsBetween(attached, started) + ctx.abandonInstancesBetween(mark, end) +} + +// abandonInstancesSince removes objects registered after mark, which a failed +// creation would otherwise leave behind, along with occurrences naming them. +func (ctx *Context) abandonInstancesSince(mark int) { + ctx.abandonInstancesBetween(mark, len(ctx.created)) +} + +// abandonInstancesBetween removes the objects registered from mark up to end, +// keeping those registered since, along with occurrences naming the removed. +func (ctx *Context) abandonInstancesBetween(mark, end int) { + abandoned := make(map[int64]bool) + var gone []*Instance + for _, id := range ctx.created[mark:end] { + if inst, live := ctx.instances[id]; live { + abandoned[id] = true + gone = append(gone, inst) + delete(ctx.instances, id) + } + } + ctx.created = append(ctx.created[:mark], ctx.created[end:]...) + if len(abandoned) == 0 { + return + } + for sym := range ctx.occurrences { + if _, live := ctx.liveOccurrences(sym); !live { + delete(ctx.occurrences, sym) + } + } + for annotation, id := range ctx.metadataObjects { + if _, live := ctx.instances[id]; !live { + delete(ctx.metadataObjects, annotation) + } + } + for sym, val := range ctx.namespaceBindings { + if namesAbandonedValue(val, abandoned) { + ctx.unbindNamespace(sym) + } + } + ctx.forgetLives(abandoned) + ctx.forgetVariantsNaming(abandoned) + ctx.forgetEdgesOf(gone) + ctx.forgetValuesNaming(abandoned) + ctx.forgetMessagesTo(abandoned) +} + +// forgetVariantsNaming unselects every variant whose object is abandoned, so the +// selection is made again, and its object built again, when next read. +func (ctx *Context) forgetVariantsNaming(abandoned map[int64]bool) { + for key, id := range ctx.variantObjects { + if !abandoned[id] { + continue + } + delete(ctx.variantObjects, key) + if key.variation == nil { + continue + } + selection := variantSelection{owner: key.owner, variation: key.variation.Name} + if ctx.selectedVariants[selection] == key.variant.Name { + delete(ctx.selectedVariants, selection) + } + } +} + +// forgetValuesNaming unmaterializes every feature value of a surviving object +// that names an abandoned one, so a later read materializes an object the +// session holds rather than reading one it does not. +func (ctx *Context) forgetValuesNaming(abandoned map[int64]bool) { + for _, inst := range ctx.instances { + for _, fv := range inst.FeatureValues { + if !fv.Materialized || !namesAbandoned(fv, abandoned) { + continue + } + fv.Value, fv.Values = Value{}, Value{} + fv.Materialized, fv.Written = false, false + ctx.invalidateDependents(fv) + } + } +} + +// namesAbandoned reports whether a feature value holds an object that is gone, +// directly or as the object a selected variant stands for. +func namesAbandoned(fv *FeatureValue, abandoned map[int64]bool) bool { + if namesAbandonedObject(fv.Value, abandoned) { + return true + } + for _, val := range elementsOf(fv.Values) { + if namesAbandonedObject(val, abandoned) { + return true + } + } + return false +} + +// namesAbandonedValue reports whether a value, or an element of a collection, names an +// abandoned object. +func namesAbandonedValue(val Value, abandoned map[int64]bool) bool { + if namesAbandonedObject(val, abandoned) { + return true + } + for _, elem := range elementsOf(val) { + if namesAbandonedObject(elem, abandoned) { + return true + } + } + return false +} + +// namesAbandonedObject reports whether a value is, or a variant standing for, an +// object that is gone, or an array, vector or tensor keeping one or an array holding one. +func namesAbandonedObject(val Value, abandoned map[int64]bool) bool { + if abandoned[keptObject(val)] { + return true + } + switch val.Kind { + case ValInstance, ValVariant: + return abandoned[val.Instance] + case ValArray: + for _, elem := range val.Array().Elements { + if namesAbandonedObject(elem, abandoned) { + return true + } + } + } + return false +} + +// forgetMessagesTo drops the messages addressed to an abandoned or destroyed object, or routed +// to such a port, which nothing can consume once the object holding its consumers is gone. +func (ctx *Context) forgetMessagesTo(abandoned map[int64]bool) { + kept := make([]Message, 0, len(ctx.messages)) + for _, msg := range ctx.messages { + if !abandoned[msg.Object] && !abandoned[msg.PortID] { + kept = append(kept, msg) + } + } + ctx.messages = kept + ctx.bus.cuts++ +} + +// restartClassifierBehaviors gives every object a fresh execution of the +// behaviors its type binds, run as one start so machines restarted alongside +// each other still exchange messages. A failure attaches nothing, leaving the +// objects running no behavior at all. +func (ctx *Context) restartClassifierBehaviors(objects []*Instance) error { + attached := len(ctx.objectBehaviors) + err := ctx.startBehaviorsOfAll(objects) + if err != nil { + ctx.forgetBehaviorsFrom(attached) + } + return err +} + +// startBehaviorsOfAll attaches the behaviors of every object before running any +// of them, so their starts are one collective run: the behaviors share it rather +// than each owning one, and witness moves they leave are for the runs after. An +// object that ended as it was reached, a part of an ended whole, performs nothing. +func (ctx *Context) startBehaviorsOfAll(objects []*Instance) error { + defer ctx.beginRun()() + defer ctx.holdDrivenWork()() + ctx.behaviorRunDepth++ + for _, inst := range objects { + if ctx.lifeEnded(inst) { + continue + } + if err := ctx.startBehaviorsOf(inst); err != nil { + ctx.behaviorRunDepth-- + return err + } + if err := ctx.materializeBehavingParts(inst); err != nil { + ctx.behaviorRunDepth-- + return err + } + } + ctx.behaviorRunDepth-- + return ctx.runAttachedBehaviors() +} + +// storing is a store under way and the journal of the hold it reached, nil until it reaches one. +// One that gathers takes the stores under it as its own, running what they start once all are done. +type storing struct { + commit, rollback func() + gathers bool +} + +// storedBeforeStarting runs store, a write or materialization, with the behaviors the hold it +// reaches starts (an object classified by the feature holding it) attached but not run until the +// value is stored, then runs them, so one reading the feature reads the object it started for. The +// hold's journal (beginHoldJournal) stays open over their run: a start that fails undoes the hold +// and the store, leaving what the store evaluated. Once kept, the older behaviors it woke answer; +// one of them failing is reported as its own, with the store kept. +func (ctx *Context) storedBeforeStarting(store func() error) error { + return ctx.stored(store, false) +} + +// storedTogether is storedBeforeStarting over several stores, the writes of a constructor's +// arguments: the behaviors any of them starts run once every one has stored its value. +func (ctx *Context) storedTogether(store func() error) error { + return ctx.stored(store, true) +} + +func (ctx *Context) stored(store func() error, gathers bool) error { + if n := len(ctx.storing); n > 0 && ctx.storing[n-1].gathers { + return store() + } + defer ctx.beginRun()() + defer ctx.holdDrivenWork()() + s := &storing{gathers: gathers} + ctx.storing = append(ctx.storing, s) + endBoundary := ctx.beginRunBoundary() + err := store() + if err == nil { + err = ctx.runAttachedBehaviors() + } + endBoundary() + ctx.storing = ctx.storing[:len(ctx.storing)-1] + if err != nil { + if s.rollback != nil { + s.rollback() + } + return err + } + if s.commit != nil { + s.commit() + } + return ctx.runAttachedBehaviors() +} + +// beginHoldJournal is beginJournal for a hold on a feature value: under a store, a hold only +// attaches the behaviors it starts, and the journal of the first is left to the store to close +// once it has run them (a later hold's journal is nested in it). +func (ctx *Context) beginHoldJournal() (commit, rollback func()) { + commit, rollback = ctx.beginJournal() + n := len(ctx.storing) + if n == 0 { + return commit, rollback + } + ctx.behaviorRunDepth++ + s := ctx.storing[n-1] + if s.commit != nil { + keep, undo := commit, rollback + return func() { ctx.behaviorRunDepth--; keep() }, func() { ctx.behaviorRunDepth--; undo() } + } + s.commit, s.rollback = commit, rollback + undo := rollback + return func() { ctx.behaviorRunDepth-- }, func() { + ctx.behaviorRunDepth-- + s.commit, s.rollback = nil, nil + undo() + } +} + +// materializeBehavingParts materializes the required composite parts of an +// object whose type runs behaviors, so the object runs to quiescence as a whole +// when it is created rather than part by part in the order its parts are first +// read. An optional part (lower bound 0) is required to hold nothing, so it is +// left unread. A part that fails to materialize or start fails its holder. +func (ctx *Context) materializeBehavingParts(inst *Instance) error { + for _, typ := range inst.types() { + features := ctx.FeaturesOf(typ) + for _, i := range ctx.behavingParts(typ) { + fv, ok := inst.FeatureValues[features[i].Name] + if !ok || fv.Materialized { + continue + } + // An adopted object's feature may differ from its type's; decide on it. + if fv.Feature != &features[i] && !ctx.holdsBehavingPart(fv.Feature) { + continue + } + if _, err := inst.GetFeatureValue(ctx, features[i].Name); err != nil { + return err + } + } + } + return nil +} + +// behavingParts returns the positions in FeaturesOf(typeSym) of the required +// composite parts whose objects run behaviors, memoized per type. +func (ctx *Context) behavingParts(typeSym *symbols.Symbol) []int { + if parts, ok := ctx.model.behavingFeatures[typeSym]; ok { + return parts + } + features := ctx.FeaturesOf(typeSym) + parts := []int{} + for i := range features { + if !ctx.model.semantics.IsConnectorObjectUsage(features[i].Symbol) && ctx.holdsBehavingPart(&features[i]) { + parts = append(parts, i) + } + } + ctx.model.behavingFeatures[typeSym] = parts + return parts +} + +// holdsBehavingPart reports whether a feature is required to hold objects that run behaviors. +func (ctx *Context) holdsBehavingPart(feat *EffectiveFeature) bool { + composite := ctx.requiredPartType(feat) + return composite != nil && ctx.runsBehaviors(composite, make(map[*symbols.Symbol]bool)) +} + +// requiredPartType is the type of the objects a composite feature is required to +// hold, or nil when it may hold none: an optional part (finite lower bound 0) or +// one whose lower bound is unknown. +func (ctx *Context) requiredPartType(feat *EffectiveFeature) *symbols.Symbol { + composite := ctx.CompositeTypeOf(feat) + mult := feat.Multiplicity + if composite == nil || !mult.Lower.Known { + return nil + } + if !mult.Lower.Infinite && mult.Lower.Value == 0 { + return nil + } + return composite +} + +// runsBehaviors reports whether objects of a type run behaviors, of their own or +// of a part they are required to hold; an optional part is left absent, so what +// it would run does not count. A type on the path being decided answers false: a +// composition cycle has no finite object, so nothing is lost by cutting it. +func (ctx *Context) runsBehaviors(typeSym *symbols.Symbol, visiting map[*symbols.Symbol]bool) bool { + if known, ok := ctx.model.behaving[typeSym]; ok { + return known + } + if visiting[typeSym] { + return false + } + visiting[typeSym] = true + defer delete(visiting, typeSym) + runs := len(ctx.classifierBehaviorsOf(typeSym)) > 0 + features := ctx.FeaturesOf(typeSym) + for i := range features { + if runs { + break + } + if ctx.model.semantics.IsConnectorObjectUsage(features[i].Symbol) { + continue + } + if composite := ctx.requiredPartType(&features[i]); composite != nil && ctx.runsBehaviors(composite, visiting) { + runs = true + } + } + ctx.model.behaving[typeSym] = runs + return runs +} + +// startBehaviorsOf attaches the object's behaviors and, at the outermost start, +// runs everything attached. +func (ctx *Context) startBehaviorsOf(inst *Instance) error { + defer ctx.holdDrivenWork()() + for _, typ := range inst.types() { + for i, decl := range ctx.classifierBehaviorsOf(typ) { + if ctx.runsBound(inst, decl.member, typ) { + continue + } + if ctx.trace != nil { + ctx.trace.RecordBehaviorStart(decl.behavior.Kind.String(), decl.behavior.Name, inst.ID) + } + behavior, err := ctx.attachClassifierBehavior(inst, decl) + if err != nil { + return err + } + behavior.binding = i + inst.behaviors = append(inst.behaviors, behavior) + ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) + ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + ctx.workChanged() + } + } + + return ctx.runAttachedBehaviors() +} + +// workChanged counts a change that can leave an attached behavior holding work: +// a message posted, the clock moved, an event queued, an executor run or left. +func (ctx *Context) workChanged() { ctx.work++ } + +// quiescence is the memo a full behavior scan leaves when it finds every +// attached behavior idle: the work and write marks it holds under, and whether +// the scan read the objects' data, so writes since then invalidate it. +type quiescence struct { + at uint64 + writes uint64 + readsData bool +} + +// holds reports whether the memo still answers: taken, and nothing it depends +// on moved since, the objects' data counting only where the scan read it. +func (q quiescence) holds(ctx *Context) bool { + return q.at != 0 && q.at == ctx.work && (!q.readsData || q.writes == ctx.writes) +} + +// setClock moves the shared clock, the work due on it moving with it. +func (ctx *Context) setClock(now float64) { + ctx.clock.now = now + ctx.workChanged() +} + +// holdDrivenWork marks, at an outermost start, the behaviors already holding +// work: a driver put it in flight, so the start leaves it to that driver. Once +// the start returns, the behaviors it attached are as their start left them. +func (ctx *Context) holdDrivenWork() func() { + if ctx.behaviorRunDepth > 0 || ctx.holdingDriven { + return func() { /* an outer start already holds them */ } + } + var held map[*ObjectBehavior]bool + if ctx.quiescent.holds(ctx) { + // Nothing woke a behavior since a full scan found them all idle. + } else if len(ctx.objectBehaviors) == 0 { + ctx.quiescent = quiescence{at: ctx.work, writes: ctx.writes} + } else { + memo := &pendingMemo{} + saved := ctx.polling + ctx.polling = memo + ctx.behaviorRunDepth++ + for _, behavior := range ctx.objectBehaviors { + if behavior.hasPendingWork() { + if held == nil { + held = map[*ObjectBehavior]bool{} + } + held[behavior] = true + } + } + ctx.behaviorRunDepth-- + ctx.polling = saved + if saved != nil && memo.readsData { + saved.readsData = true + } + if len(held) == 0 { + ctx.quiescent = quiescence{at: ctx.work, writes: ctx.writes, readsData: memo.readsData} + } + } + ctx.heldBehaviors = held + ctx.holdingDriven = true + attached := len(ctx.objectBehaviors) + return func() { + ctx.holdingDriven = false + ctx.heldBehaviors = nil + for _, behavior := range ctx.objectBehaviors[min(attached, len(ctx.objectBehaviors)):] { + behavior.settle() + } + } +} + +// settle records the execution as its start left it: what it does from here on +// is a move, and an object whose executions are all unmoved is pristine. +func (b *ObjectBehavior) settle() { + switch { + case b.State != nil: + b.State.moved = false + case b.Action != nil: + b.Action.moved = false + } +} + +// Moved reports whether the execution has left the state its start put it in. +func (b *ObjectBehavior) Moved() bool { + switch { + case b.State != nil: + return b.State.moved + case b.Action != nil: + return b.Action.moved + default: + return false + } +} + +// armedWaits lists the waits on the clock that hold the execution, due or not, those +// of the actions its paused work performs included. +func (b *ObjectBehavior) armedWaits() []ClockWait { + switch { + case b.State != nil: + return b.State.visibleArmedWaits() + case b.Action != nil: + return b.Action.visibleArmedWaits() + default: + return nil + } +} + +// runAttachedBehaviors runs everything attached, at the outermost start: a start +// reached from inside a running behavior leaves the run to that one. +func (ctx *Context) runAttachedBehaviors() error { + if ctx.behaviorRunDepth > 0 { + return nil + } + ctx.behaviorRunDepth++ + defer func() { ctx.behaviorRunDepth-- }() + return ctx.drainObjectBehaviors() +} + +// forgetBehaviorsFrom drops the behaviors attached since a start began, and the +// work queued for them: a start that failed queues nothing for a later one. +func (ctx *Context) forgetBehaviorsFrom(attached int) { + ctx.forgetBehaviorsBetween(attached, len(ctx.objectBehaviors)) +} + +// forgetBehaviorsBetween drops the behaviors attached from attached up to end, +// keeping those attached since, and the work queued for the dropped. +func (ctx *Context) forgetBehaviorsBetween(attached, end int) { + if attached >= end || end > len(ctx.objectBehaviors) { + return + } + ctx.forgetBehaviors(ctx.objectBehaviors[attached:end]) +} + +// forgetBehaviors detaches the given behaviors from their objects and from the +// context, wherever they stand among the behaviors attached. +func (ctx *Context) forgetBehaviors(behaviors []*ObjectBehavior) { + if len(behaviors) == 0 { + return + } + dropped := make(map[*ObjectBehavior]bool, len(behaviors)) + for _, behavior := range behaviors { + dropped[behavior] = true + } + for behavior := range dropped { + behavior.Object.behaviors = behaviorsExcept(behavior.Object.behaviors, dropped) + behavior.leaveClock() + } + ctx.objectBehaviors = behaviorsExcept(ctx.objectBehaviors, dropped) + ctx.pendingBehaviors = behaviorsExcept(ctx.pendingBehaviors, dropped) + ctx.workChanged() +} + +// leaveClock releases the behavior's execution, ending the work it left paused +// and withdrawing it from the clock, so a behavior dropped from its object is never driven again. +func (b *ObjectBehavior) leaveClock() { + switch { + case b.State != nil: + b.State.Release() + case b.Action != nil: + b.Action.Release() + } +} + +// behaviorsExcept returns the behaviors none of which is one being dropped. +func behaviorsExcept(behaviors []*ObjectBehavior, dropped map[*ObjectBehavior]bool) []*ObjectBehavior { + kept := make([]*ObjectBehavior, 0, len(behaviors)) + for _, behavior := range behaviors { + if !dropped[behavior] { + kept = append(kept, behavior) + } + } + return kept +} + +// drainObjectBehaviors runs the attached behaviors until the objects are +// collectively quiescent: nothing left to start, and no behavior holding an +// event a sibling's send put in flight. Bounded by the event budget, so +// endlessly signalling objects report a typed error instead of spinning. +func (ctx *Context) drainObjectBehaviors() error { + for rounds := int64(0); ; rounds++ { + if rounds >= ctx.maxStateEvents { + return budgetExceeded(ErrStateEventLimitExceeded, + fmt.Sprintf("%s: exceeded max events (%d rounds; raise %s to allow more), possible non-terminating exchange between objects", + ErrBehaviorBudget, ctx.maxStateEvents, MaxStateEventsEnvVar), ErrBehaviorBudget) + } + behavior, ok := ctx.nextRunnableBehavior() + if !ok { + return nil + } + if ctx.trace != nil { + ctx.trace.RecordBehaviorRun(behavior.Kind.String(), behavior.Name, behavior.Object.ID) + } + if err := behavior.run(); err != nil { + return fmt.Errorf("%s: %w", behavior.Describe(), err) + } + } +} + +// nextRunnableBehavior returns the next behavior with work to do: one not yet +// started, else one holding an event delivered while suspended — unless it held +// work before the start began (holdDrivenWork), or was attached before the +// innermost run boundary, as what it does cannot be undone with the change under way. +func (ctx *Context) nextRunnableBehavior() (*ObjectBehavior, bool) { + first, attached := 0, 0 + if n := len(ctx.runBoundaries); n > 0 { + boundary := ctx.runBoundaries[n-1] + first = min(boundary.pending, len(ctx.pendingBehaviors)) + attached = min(boundary.behaviors, len(ctx.objectBehaviors)) + } + // A behavior ended before its first run (its object destroyed) has no run to take. + for first < len(ctx.pendingBehaviors) { + behavior := ctx.pendingBehaviors[first] + ctx.pendingBehaviors = slices.Delete(ctx.pendingBehaviors, first, first+1) + if !behavior.completed() { + return behavior, true + } + } + // A context a full scan found idle, unchanged since, holds no runnable behavior. + if ctx.quiescent.holds(ctx) { + return nil, false + } + if attached >= len(ctx.objectBehaviors) { + // Every behavior pending is already held by a driver, or there are none. + if attached == 0 && len(ctx.heldBehaviors) == 0 { + ctx.quiescent = quiescence{at: ctx.work, writes: ctx.writes} + } + return nil, false + } + memo := &pendingMemo{} + saved := ctx.polling + ctx.polling = memo + for _, behavior := range ctx.objectBehaviors[attached:] { + if !ctx.heldBehaviors[behavior] && behavior.hasPendingWork() { + ctx.polling = saved + if saved != nil && memo.readsData { + saved.readsData = true + } + return behavior, true + } + } + ctx.polling = saved + if saved != nil && memo.readsData { + saved.readsData = true + } + if attached == 0 && len(ctx.heldBehaviors) == 0 { + ctx.quiescent = quiescence{at: ctx.work, writes: ctx.writes, readsData: memo.readsData} + } + return nil, false +} + +// hasPendingWork reports whether running the behavior again would advance it: a +// machine woken by an event due now or a signal in flight, or an action whose +// awaited message a sibling has since sent. An event scheduled for a later time +// is not work materialization waits for, and an execution that reached its end +// takes no step whatever is left addressed to it. +func (b *ObjectBehavior) hasPendingWork() bool { + switch { + case b.State != nil: + return !b.State.State().Ended() && (b.State.HasDueEvent() || b.State.HasPendingSignal()) + case b.Action != nil: + return !b.Action.State().Ended() && b.Action.HasPendingSignal() + default: + return false + } +} + +// attachClassifierBehavior builds the object's own execution of one behavior its +// type binds, seeded with the values the binding declaration supplies, and +// initializes it so its start is reported where every other behavior's is. +func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) (*ObjectBehavior, error) { + behavior, occurrence, err := ctx.bindClassifierBehavior(inst, decl) + if err != nil { + return nil, err + } + sym := behavior.Symbol + + arguments, err := ctx.classifierBehaviorArguments(inst, decl) + if err != nil { + return nil, err + } + + switch decl.behavior.Kind { + case lower.ExhibitedState: + exec, err := newStateExecutorForOccurrence(ctx, sym, inst, occurrence) + if err != nil { + return nil, fmt.Errorf("exhibited state machine %s of %s: %w", decl.behavior.Name, symbolText(inst.Type), err) + } + for name, value := range arguments { + exec.stateData[name] = value + } + if err := exec.initialize(); err != nil { + exec.Release() + return nil, fmt.Errorf("exhibited state machine %s of %s: %w", decl.behavior.Name, symbolText(inst.Type), err) + } + behavior.State = exec + case lower.PerformedAction: + exec, err := newActionExecutorOf(ctx, decl.member, sym, inst, occurrence) + if err != nil { + return nil, fmt.Errorf("performed action %s of %s: %w", decl.behavior.Name, symbolText(inst.Type), err) + } + if len(arguments) > 0 { + exec.SetInputs(arguments) + } + // An action stating no flow performs no step; the object still performs it, + // completed at once, rather than failing to be created. + begin := (*ActionExecutor).completeWithoutFlow + if exec.hasFlow() { + begin = (*ActionExecutor).initialize + } + if err := ctx.startAction(exec, begin); err != nil { + exec.Release() + return nil, fmt.Errorf("performed action %s of %s: %w", decl.behavior.Name, symbolText(inst.Type), err) + } + behavior.Action = exec + default: + return nil, fmt.Errorf("%w: %s", ErrUnsupportedClassifierBehavior, decl.behavior.Kind) + } + return behavior, nil +} + +// bindClassifierBehavior is the object's binding of one behavior its type declares, +// its execution still to be made, and the performance occurrence the binding holds. +func (ctx *Context) bindClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) (*ObjectBehavior, *Instance, error) { + chain, err := ctx.classifierBehaviorChain(decl) + if err != nil { + return nil, nil, err + } + sym := chain[len(chain)-1] + behavior := &ObjectBehavior{ + Name: decl.behavior.Name, + Kind: decl.behavior.Kind, + Symbol: sym, + Object: inst, + member: decl.member, + bindings: chain, + kinds: ctx.behaviorKinds(chain), + } + var occurrence *Instance + switch decl.behavior.Kind { + case lower.ExhibitedState: + occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrStatePerformanceOccurrence) + case lower.PerformedAction: + occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrActionPerformanceOccurrence) + default: + return nil, nil, fmt.Errorf("%w: %s", ErrUnsupportedClassifierBehavior, decl.behavior.Kind) + } + if err != nil { + return nil, nil, err + } + return behavior, occurrence, nil +} + +// performanceOccurrence returns the performance occurrence the binding +// declaration holds: the object the exhibited or performed usage's feature +// names, materialized when the feature holds none yet. sentinel types the +// failures, telling an exhibited machine's from a performed action's. +func (ctx *Context) performanceOccurrence( + inst *Instance, + decl classifierBehaviorDecl, + behavior *symbols.Symbol, + sentinel error, +) (*Instance, error) { + name := decl.behavior.Name + fv, ok := inst.FeatureValues[name] + if !ok || fv.Feature == nil || fv.Feature.Symbol != decl.member { + ok = false + for candidate, value := range inst.FeatureValues { + if value.Feature != nil && value.Feature.Symbol == decl.member { + name, ok = candidate, true + break + } + } + } + if !ok { + return nil, fmt.Errorf("%w: object #%d has no feature for %s %s", + sentinel, inst.ID, decl.behavior.Kind, decl.behavior.Name) + } + // A held occurrence is taken as it stands, so a destroyed object's binding still resolves. + if fv.HeldValue().Kind == ValInvalid { + var err error + if fv, err = inst.GetFeatureValue(ctx, name); err != nil { + return nil, fmt.Errorf("%w: materialize %s of object #%d: %w", + sentinel, name, inst.ID, err) + } + } + if fv.HeldValue().Kind == ValInvalid { + occurrence, err := ctx.materialize(behavior, 0, inst, name) + if err != nil { + return nil, fmt.Errorf("%w: materialize %s of object #%d: %w", + sentinel, name, inst.ID, err) + } + ctx.noteProbeWrite(fv) + before := ctx.beforeWrite(fv) + fv.Value = Value{Kind: ValInstance, Instance: occurrence.ID} + fv.Materialized = true + ctx.afterWrite(fv, before) + return occurrence, nil + } + id, ok := fv.HeldValue().Object() + if !ok { + return nil, fmt.Errorf("%w: %s of object #%d holds %s, not an occurrence", + sentinel, name, inst.ID, fv.HeldValue().Kind) + } + occurrence, ok := ctx.Instance(id) + if !ok { + return nil, fmt.Errorf("%w: %s of object #%d names unknown object #%d", + sentinel, name, inst.ID, id) + } + return occurrence, nil +} + +// run advances the object's behavior until it is quiescent: an action until it +// completes or waits for a message, a machine until no event is due and no do +// action is runnable. +func (b *ObjectBehavior) run() error { + switch { + case b.State != nil: + return b.State.RunToQuiescence() + case b.Action != nil: + if !b.Action.hasFlow() { + return nil + } + return b.Action.RunToQuiescence() + default: + return fmt.Errorf("%w: %s has no execution", ErrUnsupportedClassifierBehavior, b.Name) + } +} + +// classifierBehaviorSymbol resolves the element holding the body a binding +// declaration runs: the declaration itself when it states one, otherwise what it +// names — the feature it refers to or the definition it is typed by. +func (ctx *Context) classifierBehaviorSymbol(decl classifierBehaviorDecl) (*symbols.Symbol, error) { + chain, err := ctx.classifierBehaviorChain(decl) + if err != nil { + return nil, err + } + return chain[len(chain)-1], nil +} + +// classifierBehaviorChain resolves the bindings from a binding declaration to the +// element holding the body it runs: the declaration first, then what each names in +// turn, ending at the one stating a body — or at a performed action or exhibited +// state naming no element, which is the body itself (SysML v2 §8.3.16–8.3.17). +func (ctx *Context) classifierBehaviorChain(decl classifierBehaviorDecl) ([]*symbols.Symbol, error) { + sym := decl.member + chain := []*symbols.Symbol{sym} + for depth := 0; depth < maxBehaviorBindingDepth; depth++ { + stated := sym == decl.member && decl.behavior.StatesBody + if !stated && sym != decl.member { + stated = statesBehaviorBody(sym) + } + if stated { + return chain, nil + } + next := ctx.namedBehavior(sym) + if next == nil || next == sym { + if sym != decl.member { + return chain, nil + } + // An exhibit/perform naming nothing is its own body: eventOccurrence is + // the usage itself when there is no ownedReferenceSubsetting (§8.3.16). + if !decl.behavior.NamesBehavior { + return chain, nil + } + // A declaration naming nothing that holds a body is not executable: + // the type binds a behavior no element states. + return nil, fmt.Errorf("%w: %s %s of %s names no behavior body", + ErrUnresolvedClassifierBehavior, decl.behavior.Kind, decl.behavior.Name, symbolText(decl.member)) + } + sym = next + chain = append(chain, sym) + } + return nil, fmt.Errorf("%w: %s %s of %s names itself through %d bindings", + ErrUnresolvedClassifierBehavior, decl.behavior.Kind, decl.behavior.Name, symbolText(decl.member), maxBehaviorBindingDepth) +} + +// namedBehavior reports the element a binding declaration names: what it +// reference-subsets, the type it states, or — for `exhibit m;`, whose name is +// the state usage declared elsewhere — that usage. +func (ctx *Context) namedBehavior(sym *symbols.Symbol) *symbols.Symbol { + if ref := ctx.model.semantics.ReferencedFeature(sym); ref != nil { + return ref + } + if typ := ctx.extractType(sym); typ != nil { + return typ + } + if sym.Name != "" && sym.OwnerScope != nil { + if named, ok := ctx.lookupNameExcluding(sym.OwnerScope, sym.Name, sym); ok { + return named + } + } + return nil +} + +// classifierBehaviorArguments evaluates the values a binding declaration +// supplies to the behavior's parameters, against the object running it. +func (ctx *Context) classifierBehaviorArguments(inst *Instance, decl classifierBehaviorDecl) (map[string]Value, error) { + if len(decl.behavior.Arguments) == 0 { + return nil, nil + } + scope := DeclScope(decl.member) + if scope == nil { + scope = DeclScope(inst.Type) + } + args := make(map[string]Value, len(decl.behavior.Arguments)) + for _, arg := range decl.behavior.Arguments { + ec := NewEvalContextIn(ctx, scope, inst) + value, err := ec.Eval(arg.Value) + if err != nil { + return nil, fmt.Errorf("%s %s of %s: bind %s: %w", + decl.behavior.Kind, decl.behavior.Name, symbolText(inst.Type), arg.Name, err) + } + args[ctx.argumentParameter(scope, arg)] = value + } + return args, nil +} + +// argumentParameter names the behavior parameter an argument binds: the feature +// its declaration redefines (`in :>> x = 4` binds x), else its own name. +func (ctx *Context) argumentParameter(scope *symbols.Scope, arg lower.Attribute) string { + for _, redefined := range ctx.model.semantics.RedefinedFeatures(memberSymbol(scope, arg.Node)) { + if redefined.Name != "" { + return redefined.Name + } + } + return arg.Name +} + +// actionBodySymbol resolves the element holding the body an action symbol +// performs: itself when it states one, otherwise the action it names — the +// definition typing it, or the feature it refers to. The symbol itself is +// returned when nothing it names states a body, so the missing flow is reported +// against the declaration that was asked for. +func (ctx *Context) actionBodySymbol(action *symbols.Symbol) *symbols.Symbol { + sym := action + for depth := 0; depth < maxBehaviorBindingDepth; depth++ { + if statesBehaviorBody(sym) { + return sym + } + next := ctx.namedBehavior(sym) + if next == nil || next == sym || + (next.Kind != symbols.SymbolActionUsage && next.Kind != symbols.SymbolActionDef) { + return action + } + sym = next + } + return action +} + +// statesBehaviorBody reports whether a symbol's declaration states a behavior +// body of its own rather than naming an element that holds one. +func statesBehaviorBody(sym *symbols.Symbol) bool { + if sym == nil || sym.Decl == nil { + return false + } + members, err := lower.BehaviorMembers(sym.Decl) + if err != nil { + return false + } + return lower.StatesBehaviorBody(members) +} + +// assignPerformerFeature writes a value to the feature of that name of the +// object performing a behavior, and reports whether the object has one: a body +// that assigns a feature of its object writes that object, not shared data. +// The write is refused when the name does not resolve to that feature where the +// statement was written. +func assignPerformerFeature(ctx *Context, self *Instance, scope *symbols.Scope, name string, value Value) (bool, error) { + if self == nil { + return false, nil + } + if ctx.isClockTime(self, name) { + return true, fmt.Errorf("assignment to %s: %w: object #%d (%s)", name, ErrClockNotAssignable, self.ID, symbolText(self.Type)) + } + if _, ok := self.FeatureValues[name]; !ok { + return false, nil + } + if !namesPerformerFeature(ctx, self, scope, name) { + return true, fmt.Errorf("write %s of object #%d: %w: %s is a feature of %s, which the body does not name: "+ + "pass it as a parameter, or write the body in the declaration that holds it", + name, self.ID, ErrPerformerFeatureNotInScope, name, symbolText(self.Type)) + } + if err := self.SetFeatureValue(ctx, name, value); err != nil { + return true, fmt.Errorf("write %s of object #%d: %w", name, self.ID, err) + } + ctx.noteObjectWrite(self, name, value) + return true, nil +} + +// namesPerformerFeature reports whether name, resolved where the statement was +// written, denotes a feature of the object performing the behavior under any of +// its types: the performer is not a namespace the body's names are looked up in. +func namesPerformerFeature(ctx *Context, self *Instance, scope *symbols.Scope, name string) bool { + if ctx == nil || ctx.model.resolver == nil || self == nil || scope == nil { + return false + } + sym, ok := ctx.lookupName(scope, name) + if !ok { + return false + } + return performerHoldsFeature(ctx, self, sym) +} + +// performerHoldsFeature reports whether a resolved feature is one the object +// performing the behavior holds under any of its types. +func performerHoldsFeature(ctx *Context, self *Instance, sym *symbols.Symbol) bool { + if ctx == nil || self == nil || sym == nil { + return false + } + for _, typ := range self.types() { + if ctx.typeHoldsFeature(typ, sym) { + return true + } + } + return false +} + +// typeHoldsFeature reports whether a feature symbol is one the type holds: +// declared by the type itself or by one of its supertypes. +func (ctx *Context) typeHoldsFeature(typeSym, feature *symbols.Symbol) bool { + if typeSym == nil || feature == nil { + return false + } + owner := ctx.findOwnerType(feature) + if owner == nil { + return false + } + if owner == typeSym { + return true + } + for _, super := range ctx.model.semantics.AllSupertypes(typeSym) { + if super == owner { + return true + } + } + return false +} + +// symbolText names a symbol in diagnostics, falling back to its kind when it is +// anonymous. +func symbolText(sym *symbols.Symbol) string { + if sym == nil { + return unknownText + } + if sym.Name != "" { + return sym.Name + } + return sym.Kind.String() +} diff --git a/internal/exec/runtime/classifier_behavior_test.go b/internal/exec/runtime/classifier_behavior_test.go new file mode 100644 index 0000000000..b044c3ff4e --- /dev/null +++ b/internal/exec/runtime/classifier_behavior_test.go @@ -0,0 +1,2035 @@ +package runtime + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// intArgument is an integer argument of an invocation. +func intArgument(n int64) Value { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: n}} +} + +// An object of a type exhibiting a machine runs that machine on materialization. +func TestInstantiateStartsExhibitedStateMachine(t *testing.T) { + src := ` + part def Controller { + attribute log: String; + exhibit state modes { + entry; then off; + state off { + entry action mark { assign log := "off"; } + } + } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Controller")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + + behavior, ok := inst.ExhibitedState() + if !ok { + t.Fatalf("object exhibits no state machine, behaviors: %v", inst.Behaviors()) + } + if behavior.Name != "modes" { + t.Errorf("behavior name = %q, want modes", behavior.Name) + } + if got := activeStateNames(behavior.State); got != "off" { + t.Errorf("current state = %q, want off", got) + } +} + +// Two objects of one type exhibit two machines, with their own current states. +func TestExhibitedMachinesOfTwoObjectsAreIndependent(t *testing.T) { + src := ` + part def Light { + exhibit state modes { + entry; then dark; + state dark; + state lit; + transition dark_to_lit first dark accept Toggle then lit; + } + } + attribute def Toggle; + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + sym := resolveSymbol(t, root, "Light") + + first, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate first: %v", err) + } + second, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate second: %v", err) + } + if first.ID == second.ID { + t.Fatalf("two objects share identity %d", first.ID) + } + + firstMachine, _ := first.ExhibitedState() + secondMachine, _ := second.ExhibitedState() + if firstMachine.State == secondMachine.State { + t.Fatal("two objects share one machine execution") + } + + firstMachine.State.SendSignal("Toggle", nil) + if err := firstMachine.State.RunToCompletion(); err != nil { + t.Fatalf("run first machine: %v", err) + } + if got := activeStateNames(firstMachine.State); got != "lit" { + t.Errorf("first current state = %q, want lit", got) + } + if got := activeStateNames(secondMachine.State); got != "dark" { + t.Errorf("second current state = %q, want dark", got) + } +} + +// A machine's entry action writes the feature values of the object exhibiting it. +func TestExhibitedMachineWritesItsObjectsFeatureValues(t *testing.T) { + src := ` + part def Counter { + attribute count: Integer = 0; + exhibit state modes { + entry; then running; + state running { + entry action bump { assign count := count + 1; } + } + } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Counter")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "count") + if err != nil { + t.Fatalf("GetFeatureValue: %v", err) + } + if got := fv.HeldValue(); got.Const.Int != 1 { + t.Errorf("count = %v, want 1", got.Const) + } +} + +// A machine-declared feature is written on the exhibited performance occurrence, +// while a like-named feature of the performer remains distinct. +func TestExhibitedMachineWritesItsOwnOccurrence(t *testing.T) { + src := ` + state def Counting { + attribute count: Integer = 0; + entry; then running; + state running { + entry action bump { assign count := count + 1; } + } + } + part def Counter { + attribute count: Integer = 10; + exhibit state modes : Counting; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Counter")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + behavior, ok := inst.Behavior("modes") + if !ok { + t.Fatal("object runs no modes behavior") + } + occurrence := instanceAtPath(t, ctx, inst, "modes") + if behavior.State.occurrence != occurrence { + t.Fatal("machine does not target the occurrence held by modes") + } + + performerCount, err := inst.GetFeatureValue(ctx, "count") + if err != nil { + t.Fatalf("performer count: %v", err) + } + if got := performerCount.HeldValue().Const.Int; got != 10 { + t.Errorf("performer count = %d, want 10", got) + } + occurrenceCount, err := occurrence.GetFeatureValue(ctx, "count") + if err != nil { + t.Fatalf("occurrence count: %v", err) + } + if got := occurrenceCount.HeldValue().Const.Int; got != 1 { + t.Errorf("occurrence count = %d, want 1", got) + } + if got := behavior.State.stateData["count"].Const.Int; got != 1 { + t.Errorf("state data count = %d, want 1", got) + } +} + +// A redefinition renames the behavior it redefines, so the object runs one +// behavior that answers to both names; a name of no behavior answers to none. +func TestBehaviorNamedFollowsRedefinition(t *testing.T) { + src := ` + state def Modes { + entry; then off; + state off; + } + part def Lamp { + exhibit state modes : Modes; + perform action tick { action step; } + action blink; + } + part def FancyLamp :> Lamp { + exhibit state fancyModes :>> modes; + perform action fancyTick :>> tick { action step; } + action fancyBlink :>> blink; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "FancyLamp")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + for _, names := range [][2]string{{"fancyModes", "modes"}, {"fancyTick", "tick"}} { + renamed, ok := inst.Behavior(names[0]) + if !ok { + t.Fatalf("object runs no %s behavior: %v", names[0], inst.Behaviors()) + } + if _, ok := inst.Behavior(names[1]); ok { + t.Errorf("Behavior(%q) matched by name alone", names[1]) + } + for _, name := range names { + got, ok := ctx.BehaviorNamed(inst, name) + if !ok || got != renamed { + t.Errorf("BehaviorNamed(%q) = %v, %v; want the %s behavior", name, got, ok, names[0]) + } + } + } + for _, name := range []string{"blink", "fancyBlink", "nothing", ""} { + if got, ok := ctx.BehaviorNamed(inst, name); ok { + t.Errorf("BehaviorNamed(%q) = %v; want none", name, got) + } + } +} + +// An object classified by a type renaming a behavior it already runs, or by one it +// renames a behavior of, keeps running that one behavior, and answers to both names +// whichever of its types declared the name it is asked by. +func TestBehaviorNamedFollowsRedefinitionByAClassifier(t *testing.T) { + cases := map[string]struct { + room string + held string + types []string + }{ + "narrowed": { + room: `part lamp : Lamp [1]; part fancy : FancyLamp [1] = lamp;`, + held: "fancy", + types: []string{"FancyLamp"}, + }, + "widened": { + room: `part lamp : FancyLamp [1]; part plain : Lamp [1] = lamp;`, + held: "plain", + types: []string{"FancyLamp", "Lamp"}, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + src := ` + state def Modes { + entry; then off; + state off; + } + part def Lamp { + exhibit state modes : Modes; + perform action tick { action step; } + } + part def FancyLamp :> Lamp { + exhibit state fancyModes :>> modes; + perform action fancyTick :>> tick { action step; } + } + part def Room { ` + tc.room + ` } + part room : Room; + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + room, err := ctx.Instantiate(resolveSymbol(t, root, "room")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + lamp := readInstance(t, ctx, room, "lamp") + if held := readInstance(t, ctx, room, tc.held); held != lamp { + t.Fatalf("%s holds object %d, want lamp (%d)", tc.held, held.ID, lamp.ID) + } + for _, typ := range tc.types { + if !ctx.instanceConforms(lamp, resolveSymbol(t, root, typ)) { + t.Fatalf("lamp is of %v (classified by %v), want a %s", lamp.Type, lamp.classifiers, typ) + } + } + if got := len(lamp.Behaviors()); got != 2 { + t.Fatalf("lamp runs %d behaviors %v, want its one machine and one action", got, lamp.Behaviors()) + } + for _, names := range [][2]string{{"modes", "fancyModes"}, {"tick", "fancyTick"}} { + var found []*ObjectBehavior + for _, name := range names { + got, ok := ctx.BehaviorNamed(lamp, name) + if !ok { + t.Errorf("BehaviorNamed(%q) finds no behavior of the lamp", name) + continue + } + found = append(found, got) + } + if len(found) == 2 && found[0] != found[1] { + t.Errorf("BehaviorNamed(%q) and BehaviorNamed(%q) are two executions, want one", names[0], names[1]) + } + } + for _, name := range []string{"nothing", ""} { + if got, ok := ctx.BehaviorNamed(lamp, name); ok { + t.Errorf("BehaviorNamed(%q) = %v; want none", name, got) + } + } + }) + } +} + +// An action-declared feature is written on the action performance occurrence, +// while a like-named feature of the performer remains distinct. The results the +// run reports mirror the occurrence, which is authoritative. +func TestPerformedActionWritesItsOwnOccurrence(t *testing.T) { + src := ` + action def Bump { + attribute count: Integer = 0; + out attribute doubled: Integer; + action step { + assign count := count + 1; + assign doubled := count * 2; + } + first step; + } + part def Counter { + attribute count: Integer = 10; + perform action work : Bump; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Counter")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + behavior, ok := inst.Behavior("work") + if !ok { + t.Fatal("object runs no work behavior") + } + occurrence := instanceAtPath(t, ctx, inst, "work") + if behavior.Action.occurrence != occurrence { + t.Fatal("action does not target the occurrence held by work") + } + + performerCount, err := inst.GetFeatureValue(ctx, "count") + if err != nil { + t.Fatalf("performer count: %v", err) + } + if got := performerCount.HeldValue().Const.Int; got != 10 { + t.Errorf("performer count = %d, want 10", got) + } + for name, want := range map[string]int64{"count": 1, "doubled": 2} { + fv, err := occurrence.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("occurrence %s: %v", name, err) + } + if got := fv.HeldValue().Const.Int; got != want { + t.Errorf("occurrence %s = %d, want %d", name, got, want) + } + if got := behavior.Action.Results()[name].Const.Int; got != want { + t.Errorf("reported result %s = %d, want %d", name, got, want) + } + } +} + +// A performed action with no occurrence keeps its features in executor-local +// data, which is what a directly executed action has. +func TestDirectlyExecutedActionKeepsItsFeaturesLocal(t *testing.T) { + src := ` + action def Bump { + attribute count: Integer = 0; + action step { assign count := count + 1; } + first step; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + exec, err := newActionExecutor(ctx, resolveSymbol(t, root, "Bump"), nil) + if err != nil { + t.Fatalf("newActionExecutor: %v", err) + } + if exec.occurrence != nil { + t.Error("a directly executed action materialized a performance occurrence") + } + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.Results()["count"].Const.Int; got != 1 { + t.Errorf("count = %d, want 1", got) + } +} + +// A machine's state data has the collection shape its occurrence gives a +// many-valued attribute written with one element. +func TestExhibitedMachineNormalizesItsManyValuedAttribute(t *testing.T) { + src := ` + state def Logging { + attribute entries: String[*]; + entry; then running; + state running { + entry action note { assign entries := "first"; } + } + } + part def Log { + exhibit state modes : Logging; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Log")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + behavior, ok := inst.Behavior("modes") + if !ok { + t.Fatal("object runs no modes behavior") + } + occurrence := instanceAtPath(t, ctx, inst, "modes") + fv, err := occurrence.GetFeatureValue(ctx, "entries") + if err != nil { + t.Fatalf("occurrence entries: %v", err) + } + assertSingleStringCollection(t, "occurrence entries", fv.HeldValue(), "first") + assertSingleStringCollection(t, "state data entries", behavior.State.stateData["entries"], "first") +} + +func assertSingleStringCollection(t *testing.T, name string, value Value, want string) { + t.Helper() + if value.Kind != ValSequence && value.Kind != ValSet { + t.Fatalf("%s holds %v, want a collection", name, value.Kind) + } + elements := elementsOf(value) + if len(elements) != 1 || elements[0].Str() != want { + t.Errorf("%s = %v, want one element %q", name, elements, want) + } +} + +// invokeFixture owns an operation, a machine, calcs, constraints and an +// attribute, so one object exercises each classifier behavior path. +const invokeFixture = ` + part def Tank { + attribute level: Integer = 2; + action fillBy { in n; out filled; first apply; action apply { assign level := level + n; assign filled := level; } } + exhibit state modes { entry; then holding; state holding; } + calc capacity { in bonus : Integer; return total : Integer = level + bonus; } + calc rawCapacity { return : Integer = level + 1; } + constraint acceptable { in minimum : Integer; level >= minimum } + constraint rejected { level > 10 } + } +` + +// An operation of the object's type runs with that object as performer: it reads +// and writes that object's feature values and answers its declared outputs. +func TestInvokeOperationPerformedByTheObject(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, invokeFixture) + ctx := NewContext(typedModel(model, resolver), 10000) + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Tank")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + + results, err := ctx.InvokeOperation(inst, "fillBy", map[string]Value{"n": intArgument(3)}) + if err != nil { + t.Fatalf("InvokeOperation: %v", err) + } + if got, ok := results["filled"]; !ok || got.Const.Int != 5 { + t.Errorf("filled = %v, want 5", results) + } + fv, err := inst.GetFeatureValue(ctx, "level") + if err != nil { + t.Fatalf("GetFeatureValue: %v", err) + } + if got := fv.HeldValue(); got.Const.Int != 5 { + t.Errorf("level = %v, want 5", got.Const) + } + + results, err = ctx.InvokeOperation(inst, "capacity", map[string]Value{"bonus": intArgument(3)}) + if err != nil { + t.Fatalf("InvokeOperation(calc): %v", err) + } + if got, ok := results["total"]; !ok || got.Const.Int != 8 { + t.Errorf("calc result = %v, want total = 8", results) + } + results, err = ctx.InvokeOperation(inst, "rawCapacity", nil) + if err != nil { + t.Fatalf("InvokeOperation(anonymous calc): %v", err) + } + if got, ok := results["result"]; !ok || got.Const.Int != 6 { + t.Errorf("anonymous calc result = %v, want result = 6", results) + } + + for _, tc := range []struct { + name string + args map[string]Value + want bool + }{ + {"acceptable", map[string]Value{"minimum": intArgument(3)}, true}, + {"acceptable", map[string]Value{"minimum": intArgument(6)}, false}, + {"rejected", nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + results, err := ctx.InvokeOperation(inst, tc.name, tc.args) + if err != nil { + t.Fatalf("InvokeOperation(constraint): %v", err) + } + got, ok := results["result"] + if !ok || got.Kind != ValConst || got.Const.Kind != semantics.ValBool { + t.Fatalf("constraint result = %v, want Boolean result", results) + } + if got.Const.Bool != tc.want { + t.Errorf("constraint result = %v, want %v", got.Const.Bool, tc.want) + } + }) + } +} + +// positionalInvokeFixture owns operations a positional argument list exercises: +// a defaulted trailing parameter, an `out` and an `inout` parameter, and two +// calcs of one name told apart by arity. +const positionalInvokeFixture = ` + package test { + private import ScalarValues::*; + part def Tank { + attribute level : Integer = 2; + action fillBy { in n : Integer; in times : Integer = 1; out filled : Integer; + first apply; action apply { assign level := level + n * times; assign filled := level; } } + action drainInto { in n : Integer; inout sink : Integer; out drained : Integer; + first apply; action apply { assign level := level - n; assign sink := sink + n; assign drained := n; } } + calc scaled { in factor : Integer; return : Integer = level * factor; } + calc scaled { in factor : Integer; in offset : Integer; return : Integer = level * factor + offset; } + attribute label : String = ""; + action run { in x : String; first apply; action apply { assign label := x; } } + calc run { in x : Integer; return : Integer = level + x; } + action countUp : Count { inout redefines tally; + first apply; action apply { assign tally := tally + by; assign level := level + by; } } + } + action def Count { inout tally : Integer; in by : Integer = 1; } + } +` + +// A positional argument list binds the operation's `in` and `inout` parameters in +// declaration order, leaves a trailing defaulted parameter to its default, skips an +// `out` parameter, and selects among same-named operations by arity and by type — +// an action and a calc of one name each answering the arguments that fit it. +func TestInvokeOperationWithPositionalArguments(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, positionalInvokeFixture)) + tank := findSymbolByName(idx.DocumentRoot(""), "Tank", ast.DefPart) + if tank == nil { + t.Fatal("Tank not found") + } + inst, err := ctx.Instantiate(tank) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + positional := func(values ...Value) OperationArguments { + return OperationArguments{Positional: values} + } + + results, err := ctx.InvokeOperationWith(inst, "fillBy", positional(intArgument(3))) + if err != nil { + t.Fatalf("fillBy(3): %v", err) + } + wantResult(t, results, "filled", 5) + results, err = ctx.InvokeOperationWith(inst, "fillBy", positional(intArgument(3), intArgument(2))) + if err != nil { + t.Fatalf("fillBy(3, 2): %v", err) + } + wantResult(t, results, "filled", 11) + + results, err = ctx.InvokeOperationWith(inst, "drainInto", positional(intArgument(4), intArgument(10))) + if err != nil { + t.Fatalf("drainInto(4, 10): %v", err) + } + wantResult(t, results, "sink", 14) + wantResult(t, results, "drained", 4) + if fv, err := inst.GetFeatureValue(ctx, "level"); err != nil || fv.HeldValue().Const.Int != 7 { + t.Errorf("level = %v, %v, want 7", fv, err) + } + + results, err = ctx.InvokeOperationWith(inst, "scaled", positional(intArgument(2))) + if err != nil { + t.Fatalf("scaled(2): %v", err) + } + wantResult(t, results, "result", 14) + results, err = ctx.InvokeOperationWith(inst, "scaled", positional(intArgument(2), intArgument(1))) + if err != nil { + t.Fatalf("scaled(2, 1): %v", err) + } + wantResult(t, results, "result", 15) + results, err = ctx.InvokeOperationWith(inst, "scaled", OperationArguments{Named: map[string]Value{"factor": intArgument(3), "offset": intArgument(1)}}) + if err != nil { + t.Fatalf("scaled(factor=3, offset=1): %v", err) + } + wantResult(t, results, "result", 22) + + results, err = ctx.InvokeOperationWith(inst, "run", positional(intArgument(3))) + if err != nil { + t.Fatalf("run(3): %v", err) + } + wantResult(t, results, "result", 10) + if _, err = ctx.InvokeOperationWith(inst, "run", positional(NewStringValue("go"))); err != nil { + t.Fatalf(`run("go"): %v`, err) + } + if fv, err := inst.GetFeatureValue(ctx, "label"); err != nil || fv.HeldValue().Str() != "go" { + t.Errorf("label = %v, %v, want go", fv, err) + } + if _, err = ctx.InvokeOperationWith(inst, "run", OperationArguments{Named: map[string]Value{"x": NewStringValue("named")}}); err != nil { + t.Fatalf(`run(x="named"): %v`, err) + } + if fv, err := inst.GetFeatureValue(ctx, "label"); err != nil || fv.HeldValue().Str() != "named" { + t.Errorf("label = %v, %v, want named", fv, err) + } + + for _, tc := range []struct { + name string + op string + args OperationArguments + want error + }{ + {"fewer than required", "fillBy", positional(), ErrUnboundParameter}, + {"more than declared", "fillBy", positional(intArgument(1), intArgument(2), intArgument(3)), ErrOperationArity}, + {"out parameter counted", "drainInto", positional(intArgument(1), intArgument(2), intArgument(3)), ErrOperationArity}, + {"positional and named mixed", "fillBy", OperationArguments{Positional: []Value{intArgument(1)}, Named: map[string]Value{"n": intArgument(1)}}, ErrMixedArguments}, + {"out parameter named", "drainInto", OperationArguments{Named: map[string]Value{"n": intArgument(1), "sink": intArgument(1), "drained": intArgument(1)}}, ErrUnboundParameter}, + {"no arity fits", "scaled", positional(intArgument(1), intArgument(2), intArgument(3)), ErrOperationArity}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := ctx.InvokeOperationWith(inst, tc.op, tc.args); !errors.Is(err, tc.want) { + t.Fatalf("error = %v, want %v", err, tc.want) + } + }) + } +} + +// An unnamed `inout redefines` parameter is bound and returned under the name it +// inherits, positionally or by name, the defaulted trailing input left out. +func TestInvokeOperationReturnsRedefinedInout(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, positionalInvokeFixture)) + tank := findSymbolByName(idx.DocumentRoot(""), "Tank", ast.DefPart) + if tank == nil { + t.Fatal("Tank not found") + } + inst, err := ctx.Instantiate(tank) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + results, err := ctx.InvokeOperationWith(inst, "countUp", OperationArguments{Positional: []Value{intArgument(10), intArgument(4)}}) + if err != nil { + t.Fatalf("countUp(10, 4): %v", err) + } + wantResult(t, results, "tally", 14) + results, err = ctx.InvokeOperationWith(inst, "countUp", OperationArguments{Named: map[string]Value{"tally": intArgument(10)}}) + if err != nil { + t.Fatalf("countUp(tally=10): %v", err) + } + wantResult(t, results, "tally", 11) + if len(results) != 1 { + t.Errorf("results = %v, want tally alone", results) + } + if fv, err := inst.GetFeatureValue(ctx, "level"); err != nil || fv.HeldValue().Const.Int != 7 { + t.Errorf("level = %v, %v, want 7", fv, err) + } +} + +// wantResult checks that an operation answered name with the integer want. +func wantResult(t *testing.T, results map[string]Value, name string, want int64) { + t.Helper() + got, ok := results[name] + if !ok || got.Kind != ValConst || got.Const.Int != want { + t.Errorf("%s = %v, want %d", name, results, want) + } +} + +// An operation invocation counts its own calc or constraint cost against the +// step budget, while separate invocations receive separate budgets. +func TestInvokeOperationCountsItsOwnCostAgainstBudget(t *testing.T) { + newInvocation := func(maxSteps int64) (*Context, *Instance) { + model, resolver, root := parseAndBuildModel(t, invokeFixture) + ctx := NewContext(typedModel(model, resolver), maxSteps) + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Tank")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + return ctx, inst + } + + t.Run("calc", func(t *testing.T) { + ctx, inst := newInvocation(2) + results, err := ctx.InvokeOperation(inst, "rawCapacity", nil) + if !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("InvokeOperation(rawCapacity) with budget 2: %v, want ErrStepLimitExceeded", err) + } + if results != nil { + t.Fatalf("InvokeOperation(rawCapacity) with budget 2 = %v, want no results", results) + } + + ctx, inst = newInvocation(3) + for i := 0; i < 2; i++ { + results, err := ctx.InvokeOperation(inst, "rawCapacity", nil) + if err != nil { + t.Fatalf("InvokeOperation(rawCapacity), call %d with budget 3: %v", i, err) + } + if got, ok := results["result"]; !ok || got.Const.Int != 3 { + t.Fatalf("rawCapacity result on call %d = %v, want 3", i, results) + } + } + }) + + t.Run("constraint", func(t *testing.T) { + ctx, inst := newInvocation(2) + results, err := ctx.InvokeOperation(inst, "acceptable", map[string]Value{ + "minimum": intArgument(1), + }) + if !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("InvokeOperation(acceptable) with budget 2: %v, want ErrStepLimitExceeded", err) + } + if results != nil { + t.Fatalf("InvokeOperation(acceptable) with budget 2 = %v, want no results", results) + } + + ctx, inst = newInvocation(3) + for i := 0; i < 2; i++ { + results, err := ctx.InvokeOperation(inst, "acceptable", map[string]Value{ + "minimum": intArgument(1), + }) + if err != nil { + t.Fatalf("InvokeOperation(acceptable), call %d with budget 3: %v", i, err) + } + got, ok := results["result"] + if !ok || got.Kind != ValConst || got.Const.Kind != semantics.ValBool || !got.Const.Bool { + t.Fatalf("acceptable result on call %d = %v, want true", i, results) + } + } + }) +} + +const nestedCalcOperationFixture = ` + package test { + private import ScalarValues::*; + part def Tank { + attribute level : Integer = 2; + calc def ReadsLevel { + attribute observed : Integer = 0; + assign observed := level; + return value : Integer = observed; + } + calc reading : ReadsLevel; + calc capacityViaUsage { + return result : Integer = reading.value; + } + } + } +` + +// A calc operation preserves its performing object through a nested calc usage. +func TestCalcOperationNestedUsageSeesPerformingObject(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, nestedCalcOperationFixture)) + root := idx.DocumentRoot("") + tank := findSymbolByName(root, "Tank", ast.DefPart) + if tank == nil { + t.Fatal("Tank not found") + } + inst, err := ctx.Instantiate(tank) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if err := inst.SetFeatureValue(ctx, "level", intArgument(7)); err != nil { + t.Fatalf("SetFeatureValue: %v", err) + } + + results, err := ctx.InvokeOperation(inst, "capacityViaUsage", nil) + if err != nil { + t.Fatalf("InvokeOperation: %v", err) + } + if got, ok := results["result"]; !ok || got.Const.Int != 7 { + t.Errorf("nested calc result = %v, want result = 7", results) + } +} + +// An object-scoped calc usage keeps the materialized object's feature context. +func TestObjectScopedCalcUsageSeesPerformingObject(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, nestedCalcOperationFixture)) + root := idx.DocumentRoot("") + tank := findSymbolByName(root, "Tank", ast.DefPart) + if tank == nil { + t.Fatal("Tank not found") + } + inst, err := ctx.Instantiate(tank) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if err := inst.SetFeatureValue(ctx, "level", intArgument(7)); err != nil { + t.Fatalf("SetFeatureValue: %v", err) + } + matches := idx.LookupQualified("test::Tank::reading") + if len(matches) != 1 { + t.Fatalf("test::Tank::reading: %d matching symbols, want 1", len(matches)) + } + + outputs, err := ctx.CalcUsageOutputs(matches[0], matches[0].OwnerScope, inst) + if err != nil { + t.Fatalf("CalcUsageOutputs: %v", err) + } + wantInt(t, outputs, "value", 7) +} + +const nestedCalcInvocationFixture = ` + package test { + private import ScalarValues::*; + part def Robot { + attribute charge : Integer = 10; + calc direct { return : Integer = charge + 100; } + calc anon { return : Integer = charge * 2; } + calc nested { return : Integer = anon() + 1; } + calc usesDirect { return : Integer = direct() + 1000; } + action drain { + first start; + action cut { assign charge := 3; } + done; + succession first start then cut; + succession first cut then done; + } + } + } +` + +// A calc invocation expression preserves its enclosing calc's performing object. +func TestCalcInvocationExpressionSeesPerformingObject(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, nestedCalcInvocationFixture)) + root := idx.DocumentRoot("") + robot := findSymbolByName(root, "Robot", ast.DefPart) + if robot == nil { + t.Fatal("Robot not found") + } + inst, err := ctx.Instantiate(robot) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if _, err := ctx.InvokeOperation(inst, "drain", nil); err != nil { + t.Fatalf("InvokeOperation(drain): %v", err) + } + charge, err := inst.GetFeatureValue(ctx, "charge") + if err != nil { + t.Fatalf("GetFeatureValue(charge): %v", err) + } + if got := charge.HeldValue().Const.Int; got != 3 { + t.Fatalf("charge = %d, want 3", got) + } + + for _, tc := range []struct { + name string + want int64 + }{ + {"direct", 103}, + {"anon", 6}, + {"nested", 7}, + {"usesDirect", 1103}, + } { + t.Run(tc.name, func(t *testing.T) { + results, err := ctx.InvokeOperation(inst, tc.name, nil) + if err != nil { + t.Fatalf("InvokeOperation(%s): %v", tc.name, err) + } + got, ok := results["result"] + if !ok || got.Const.Int != tc.want { + t.Errorf("%s result = %v, want result = %d", tc.name, results, tc.want) + } + }) + } +} + +// Every path %invoke cannot run reports a typed error naming what it was asked. +func TestInvokeOperationFailureModes(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, invokeFixture) + ctx := NewContext(typedModel(model, resolver), 10000) + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Tank")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + + cases := []struct { + name string + object *Instance + operation string + args map[string]Value + want error + }{ + {"no object", nil, "fillBy", nil, ErrNoSuchBehavior}, + {"unknown operation", inst, "drain", nil, ErrNoSuchBehavior}, + {"state machine", inst, "modes", nil, ErrUnsupportedClassifierBehavior}, + {"attribute", inst, "level", nil, ErrNotABehavior}, + {"unbound parameter", inst, "fillBy", nil, ErrUnboundParameter}, + {"argument naming no parameter", inst, "fillBy", map[string]Value{"n": intArgument(1), "m": intArgument(2)}, ErrUnboundParameter}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ctx.InvokeOperation(tc.object, tc.operation, tc.args) + if !errors.Is(err, tc.want) { + t.Fatalf("error = %v, want %v", err, tc.want) + } + }) + } +} + +// An action stating no flow performs no step, so an object of a type performing +// one is still created, with that behavior of its own and nothing to run. +func TestPerformedActionWithoutAFlowStillMaterializes(t *testing.T) { + src := ` + action def Report; + part def Camera { + perform action report : Report; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Camera")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + behavior, ok := inst.Behavior("report") + if !ok { + t.Fatalf("object performs no report action, behaviors: %v", inst.Behaviors()) + } + if behavior.Action == nil { + t.Error("performed action has no execution") + } +} + +// The value an `out` member of the binding declares is the answer's default, +// not an input, so it neither fails the start nor is lost, with or without a flow. +func TestPerformedActionDeclaringAnOutputDefaultStarts(t *testing.T) { + for name, body := range map[string]string{ + "flowed": "out total : Integer = 7; first start; then done;", + "no_flow": "out total : Integer = 7; action step;", + } { + src := ` + private import ScalarValues::*; + part def Counter { perform action tick { ` + body + ` } } + ` + model, resolver, root := parseAndBuildLibraryModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Counter")) + if err != nil { + t.Errorf("%s: Instantiate: %v", name, err) + continue + } + if got := performedFeature(t, ctx, inst, "tick", "total"); !valueIdentical(got, constInt(7)) { + t.Errorf("%s: tick.total = %s; want 7", name, FormatValue(got)) + } + } +} + +// An `out` member on a typed binding redefines the answer's default; it does +// not replace the body, so the referenced action's flow still runs. +func TestPerformedActionOutputDefaultKeepsTheReferencedFlow(t *testing.T) { + model, resolver, root := parseAndBuildLibraryModel(t, ` + private import ScalarValues::*; + action def Report { + out total : Integer; + attribute steps : Integer = 0; + action step { assign steps := steps + 1; } + first step; + } + part def Counter { perform action report : Report { out total = 7; } } + `) + ctx := NewContext(typedModel(model, resolver), 10000) + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Counter")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if got := performedFeature(t, ctx, inst, "report", "total"); !valueIdentical(got, constInt(7)) { + t.Errorf("report.total = %s; want the binding's default 7", FormatValue(got)) + } + if got := performedFeature(t, ctx, inst, "report", "steps"); !valueIdentical(got, constInt(1)) { + t.Errorf("report.steps = %s; want 1, the referenced flow's one step", FormatValue(got)) + } +} + +// performedFeature reads a feature of the occurrence of the action an object performs. +func performedFeature(t *testing.T, ctx *Context, inst *Instance, action, feature string) Value { + t.Helper() + behavior, ok := inst.Behavior(action) + if !ok || behavior.Action == nil { + t.Fatalf("object performs no %s action, behaviors: %v", action, inst.Behaviors()) + } + fv, err := behavior.Action.occurrence.GetFeatureValue(ctx, feature) + if err != nil { + t.Fatalf("%s.%s: %v", action, feature, err) + } + return fv.HeldValue() +} + +// A single value written to a many-valued feature is that collection's one +// element, the shape materialization gives such a feature. +func TestWritingOneValueToAManyValuedFeatureHoldsACollection(t *testing.T) { + src := ` + part def Log { + attribute entries: String[*]; + exhibit state modes { + entry; then open; + state open { + entry action note { assign entries := "first"; } + } + } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Log")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "entries") + if err != nil { + t.Fatalf("GetFeatureValue: %v", err) + } + held := fv.HeldValue() + if held.Kind != ValSequence && held.Kind != ValSet { + t.Fatalf("entries holds %v, want a collection", held.Kind) + } + if got := elementsOf(held); len(got) != 1 || got[0].Str() != "first" { + t.Errorf("entries = %v, want one element \"first\"", got) + } +} + +// An exhibited state naming nothing — no reference subsetting, no typing — +// exhibits itself: the ExhibitStateUsage is its own exhibitedState +// (SysML v2 §8.3.17), so its machine is created and run, not refused. +func TestExhibitedStateNamingNothingExhibitsItself(t *testing.T) { + src := ` + part def Controller { + attribute level : Integer = 3; + exhibit state modes { + in amount : Integer = level; + entry; then a; + state a; + } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Controller")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + behavior, ok := inst.Behavior("modes") + if !ok || behavior.State == nil { + t.Fatalf("object exhibits no modes machine, behaviors: %v", inst.Behaviors()) + } +} + +// An exhibited state naming nothing whose body declares no initial state +// fails at initialization with ErrNoInitialState, like any machine stating an +// empty or parameter-only body — the declaration is its own body, not a +// naming error. +func TestExhibitedStateNamingNothingWithNoInitialStateIsReported(t *testing.T) { + for _, tc := range []struct { + name string + behavior string + want string + }{ + {"empty body", "exhibit state idle;", "idle"}, + {"parameter-only body", ` + attribute level : Integer = 3; + exhibit state modes { in amount : Integer = level; }`, "modes"}, + } { + t.Run(tc.name, func(t *testing.T) { + src := ` + part def Controller { + ` + tc.behavior + ` + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + _, err := ctx.Instantiate(resolveSymbol(t, root, "Controller")) + if !errors.Is(err, ErrNoInitialState) { + t.Fatalf("error = %v, want ErrNoInitialState", err) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error %q does not name the behavior %q", err, tc.want) + } + }) + } +} + +// A performed action naming nothing — no reference subsetting, no typing — +// performs itself: the PerformActionUsage is its own event occurrence +// (SysML v2 §8.3.16, eventOccurrence), so its object is created, not refused. +func TestPerformedActionNamingNothingPerformsItself(t *testing.T) { + src := ` + part def Engine { + attribute level : Integer = 3; + perform action boost { in amount : Integer = level; } + perform action idle; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Engine")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + for _, name := range []string{"boost", "idle"} { + behavior, ok := inst.Behavior(name) + if !ok || behavior.Action == nil { + t.Fatalf("object performs no %s action, behaviors: %v", name, inst.Behaviors()) + } + } +} + +// A type exhibits a machine through the declaration stating it inline, one typed +// by a definition, or one naming a state usage declared beside it, and every +// binding on the way to the body addresses it; a machine it merely performs, and +// a definition no declaration names, it does not exhibit. +func TestExhibitsStateResolvesTheBodyABindingRuns(t *testing.T) { + src := ` + state def Blink { entry; then dark; state dark; } + state def Check { entry; then checking; state checking; } + part def Lamp { + exhibit state front : Blink; + exhibit state own { entry; then idle; state idle; } + state spare : Blink; + exhibit spare; + state standby : Check; + exhibit state night ::> standby; + perform action watch { first start; then done; } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + lamp := resolveSymbol(t, root, "Lamp") + member := func(name string) *symbols.Symbol { + sym, ok := lamp.Scope.LookupLocal(name) + if !ok { + t.Fatalf("Lamp declares no %s", name) + } + return sym + } + exhibitSpare := lamp.Scope.LookupLocalAll("spare")[1] + + for _, tc := range []struct { + member, machine string + memberSym *symbols.Symbol + machineSym *symbols.Symbol + want bool + }{ + {"front", "Blink", member("front"), resolveSymbol(t, root, "Blink"), true}, + {"front", "front", member("front"), member("front"), true}, + {"own", "own", member("own"), member("own"), true}, + {"exhibit spare", "spare", exhibitSpare, member("spare"), true}, + {"exhibit spare", "Blink", exhibitSpare, resolveSymbol(t, root, "Blink"), true}, + {"night", "standby", member("night"), member("standby"), true}, + {"night", "Check", member("night"), resolveSymbol(t, root, "Check"), true}, + {"night", "Blink", member("night"), resolveSymbol(t, root, "Blink"), false}, + {"front", "Check", member("front"), resolveSymbol(t, root, "Check"), false}, + {"watch", "watch", member("watch"), member("watch"), false}, + {"spare", "spare", member("spare"), member("spare"), false}, + } { + if got := ctx.ExhibitsState(tc.memberSym, tc.machineSym); got != tc.want { + t.Errorf("ExhibitsState(%s, %s) = %v, want %v", tc.member, tc.machine, got, tc.want) + } + } + if ctx.ExhibitsState(nil, lamp) || ctx.ExhibitsState(lamp, nil) { + t.Error("ExhibitsState over a nil symbol reported an exhibit") + } + + // An object of the type addresses its machines the same way. + inst, err := ctx.Instantiate(lamp) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if got := inst.ExhibitedStatesOf(member("spare")); len(got) != 1 || got[0].Member() != exhibitSpare { + t.Errorf("ExhibitedStatesOf(spare) = %v, want the machine `exhibit spare` binds", got) + } + if got := inst.ExhibitedStatesOf(resolveSymbol(t, root, "Blink")); len(got) != 2 { + t.Errorf("ExhibitedStatesOf(Blink) = %v, want front and spare", got) + } + if got := inst.ExhibitedStatesOf(member("standby")); len(got) != 1 || got[0].Member() != member("night") { + t.Errorf("ExhibitedStatesOf(standby) = %v, want night", got) + } + if got := inst.ExhibitedStatesOf(member("watch")); len(got) != 0 { + t.Errorf("ExhibitedStatesOf(watch) = %v, want none", got) + } +} + +// An exhibited usage stating its own body under a definition's type is still a +// machine of that definition, and of the definitions it specializes, so the +// definition addresses it — while an unrelated definition does not. +func TestExhibitsStateThroughTheTypeOfABodyStatingUsage(t *testing.T) { + src := ` + state def Base { entry; then idle; state idle; } + state def Blink :> Base { entry; then dark; state dark; } + state def Check { entry; then checking; state checking; } + part def Lamp { + exhibit state tuned : Blink { state bright; } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + lamp := resolveSymbol(t, root, "Lamp") + tuned, ok := lamp.Scope.LookupLocal("tuned") + if !ok { + t.Fatal("Lamp declares no tuned") + } + def := func(name string) *symbols.Symbol { return resolveSymbol(t, root, name) } + + for _, tc := range []struct { + machine string + want bool + }{{"Blink", true}, {"Base", true}, {"Check", false}} { + if got := ctx.ExhibitsState(tuned, def(tc.machine)); got != tc.want { + t.Errorf("ExhibitsState(tuned, %s) = %v, want %v", tc.machine, got, tc.want) + } + } + + inst, err := ctx.Instantiate(lamp) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + for _, machine := range []string{"Blink", "Base"} { + if got := inst.ExhibitedStatesOf(def(machine)); len(got) != 1 || got[0].Member() != tuned { + t.Errorf("ExhibitedStatesOf(%s) = %v, want tuned", machine, got) + } + } + if got := inst.ExhibitedStatesOf(def("Check")); len(got) != 0 { + t.Errorf("ExhibitedStatesOf(Check) = %v, want none", got) + } +} + +// featureInt is the integer an object's feature value holds. +func featureInt(t *testing.T, ctx *Context, inst *Instance, name string) int64 { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("GetFeatureValue %s: %v", name, err) + } + return fv.HeldValue().Const.Int +} + +// An operation output the object's type also declares a feature for answers the +// caller: an action's own parameter is not the performing object's feature. +func TestOperationOutputNamedLikeAFeatureAnswersTheCaller(t *testing.T) { + src := ` + part def Gauge { + attribute level: Integer = 2; + action read { out level; first apply; action apply { assign level := 7; } } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Gauge")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + + results, err := ctx.InvokeOperation(inst, "read", nil) + if err != nil { + t.Fatalf("InvokeOperation: %v", err) + } + if got, ok := results["level"]; !ok || got.Const.Int != 7 { + t.Errorf("output level = %v, want 7", results) + } + fv, err := inst.GetFeatureValue(ctx, "level") + if err != nil { + t.Fatalf("GetFeatureValue: %v", err) + } + if got := fv.HeldValue(); got.Const.Int != 2 { + t.Errorf("object level = %v, want the feature untouched at 2", got.Const) + } +} + +// An object performing an action that awaits a message is materialized waiting +// rather than deadlocked, and the message a sibling sends wakes it. +func TestPerformedActionAwaitingAMessageIsWokenByASibling(t *testing.T) { + src := ` + package test { + part def Waiter { + attribute woken: Integer = 0; + perform action await { + first start; + action heard accept g : Integer; + action mark { assign woken := 1; } + done; + succession first start then heard; + succession first heard then mark; + succession first mark then done; + } + } + + part def Sender { + exhibit state sending { + entry; then sent; + state sent { entry send 5 to w; } + } + } + + part def Pair { + part w : Waiter; + part s : Sender; + } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + pkg := resolveSymbol(t, root, "test") + + // Materialized alone, the waiter parks at the accept: nothing has sent it a + // message yet, which is quiescence for an object rather than a deadlock. + alone := NewContext(typedModel(model, resolver), 10000) + waiter, err := alone.Instantiate(resolveSymbol(t, pkg.Scope, "Waiter")) + if err != nil { + t.Fatalf("Instantiate Waiter: %v", err) + } + behavior, ok := waiter.Behavior("await") + if !ok || behavior.Action == nil { + t.Fatalf("object performs no await action, behaviors: %v", waiter.Behaviors()) + } + if behavior.Action.state != StateWaiting { + t.Errorf("await is %v, want waiting at its accept", behavior.Action.state) + } + if got := featureInt(t, alone, waiter, "woken"); got != 0 { + t.Errorf("woken = %d before any message, want 0", got) + } + + // Materialized beside a sender, the message wakes the parked action, which + // then writes its own object's feature value. + ctx := NewContext(typedModel(model, resolver), 10000) + pair, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "Pair")) + if err != nil { + t.Fatalf("Instantiate Pair: %v", err) + } + // Each nested object is materialized when its feature value is reached, so the + // waiter parks before the sender it wakes is materialized at all. + nested := instanceAtPath(t, ctx, pair, "w") + instanceAtPath(t, ctx, pair, "s") + if got := featureInt(t, ctx, nested, "woken"); got != 1 { + t.Errorf("woken = %d, want 1 once the sibling's message arrived", got) + } +} + +// An action an object's type declares without performing it runs only once started: +// the object is materialized performing nothing, and `perform w.await.start` from the +// enclosing part runs it as the object, where the message a sibling sent before the +// start is waiting for it. +func TestStartedActionAwaitingAMessageIsWokenByASibling(t *testing.T) { + src := ` + package test { + part def Waiter { + attribute woken: Integer = 0; + action def Await { + first start; + action heard accept g : Integer; + action mark { assign woken := 1; } + done; + succession first start then heard; + succession first heard then mark; + succession first mark then done; + } + action await : Await; + } + + part def Sender { + exhibit state sending { + entry; then sent; + state sent { entry send 5 to w; } + } + } + + part def Pair { + part w : Waiter; + part s : Sender; + perform action kick { + first start; + then action go { perform w.await.start; } + then done; + } + } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + pkg := resolveSymbol(t, root, "test") + + alone := NewContext(typedModel(model, resolver), 10000) + waiter, err := alone.Instantiate(resolveSymbol(t, pkg.Scope, "Waiter")) + if err != nil { + t.Fatalf("Instantiate Waiter: %v", err) + } + if bs := waiter.Behaviors(); len(bs) != 0 { + t.Errorf("the Waiter performs %v when materialized, want nothing before a start", bs) + } + + ctx := NewContext(typedModel(model, resolver), 10000) + pair, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "Pair")) + if err != nil { + t.Fatalf("Instantiate Pair: %v", err) + } + nested := instanceAtPath(t, ctx, pair, "w") + behavior, ok := nested.Behavior("await") + if !ok || behavior.Action == nil { + t.Fatalf("the started Waiter performs no await action, behaviors: %v", nested.Behaviors()) + } + if behavior.Action.state != StateCompleted { + t.Errorf("await is %v, want complete once the sibling's message woke it", behavior.Action.state) + } + if got := featureInt(t, ctx, nested, "woken"); got != 1 { + t.Errorf("woken = %d, want 1 once the sibling's message arrived", got) + } + if l, ok := ctx.OccurrenceLife(nested.ID); !ok || !l.Alive() { + t.Errorf("OccurrenceLife(w) = %v, %v; want the object alive once its behavior is done", l, ok) + } +} + +// An object whose exhibited machine has a do behavior parked at an accept is woken +// by the message a sibling sends: the message is work of that object, which +// dispatching it to the do behavior takes off the bus. +func TestStateDoBehaviorAwaitingAMessageIsWokenByASibling(t *testing.T) { + src := ` + package test { + part def Waiter { + attribute woken: Integer = 0; + exhibit state waiting { + entry; then active; + state active { + do action await { + first start; + then action heard accept g : Integer; + then action mark assign woken := 1; + then done; + } + } + } + } + + part def Sender { + exhibit state sending { + entry; then sent; + state sent { entry send 5 to w; } + } + } + + part def Pair { + part w : Waiter; + part s : Sender; + } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + pkg := resolveSymbol(t, root, "test") + + alone := NewContext(typedModel(model, resolver), 10000) + waiter, err := alone.Instantiate(resolveSymbol(t, pkg.Scope, "Waiter")) + if err != nil { + t.Fatalf("Instantiate Waiter: %v", err) + } + behavior, ok := waiter.ExhibitedState() + if !ok { + t.Fatalf("object exhibits no machine, behaviors: %v", waiter.Behaviors()) + } + if behavior.hasPendingWork() { + t.Error("a do behavior parked at its accept is not work while no message is in flight") + } + if got := featureInt(t, alone, waiter, "woken"); got != 0 { + t.Errorf("woken = %d before any message, want 0", got) + } + five := integerValue(5) + alone.PostMessage(Message{SignalType: "Integer", Value: &five}) + if !behavior.hasPendingWork() { + t.Fatal("the message in flight is work of the object whose do behavior is parked at an accept for it") + } + if err := alone.drainObjectBehaviors(); err != nil { + t.Fatalf("run the object's behaviors: %v", err) + } + if got := featureInt(t, alone, waiter, "woken"); got != 1 { + t.Errorf("woken = %d, want 1 once the do behavior went on from its accept", got) + } + if left := alone.PendingMessages(); len(left) != 0 { + t.Errorf("%d messages left in flight, want the one taken by the do behavior", len(left)) + } + + ctx := NewContext(typedModel(model, resolver), 10000) + pair, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "Pair")) + if err != nil { + t.Fatalf("Instantiate Pair: %v", err) + } + nested := instanceAtPath(t, ctx, pair, "w") + instanceAtPath(t, ctx, pair, "s") + if got := featureInt(t, ctx, nested, "woken"); got != 1 { + t.Errorf("woken = %d, want 1 once the sibling's message arrived", got) + } + if left := ctx.PendingMessages(); len(left) != 0 { + t.Errorf("%d messages left in flight, want the sibling's taken by the do behavior", len(left)) + } +} + +// A failed materialization leaves no behavior of the object behind, so the next +// object materialized runs its own behaviors and nothing else. +func TestFailedMaterializationLeavesNoBehaviorBehind(t *testing.T) { + src := ` + part def Broken { + attribute n: Integer = 0; + exhibit state modes { entry; then on; state on { entry action bump { assign n := 1; } } } + exhibit state missing; + } + part def Fine { + exhibit state modes { entry; then idle; state idle; } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + broken, err := ctx.Instantiate(resolveSymbol(t, root, "Broken")) + if err == nil { + t.Fatal("expected the unresolved machine to fail materialization") + } + if broken != nil { + t.Errorf("failed materialization answered object %v", broken) + } + if got := len(ctx.objectBehaviors); got != 0 { + t.Errorf("%d behavior(s) left attached after a failed materialization", got) + } + if got := len(ctx.pendingBehaviors); got != 0 { + t.Errorf("%d behavior(s) left queued after a failed materialization", got) + } + + fine, err := ctx.Instantiate(resolveSymbol(t, root, "Fine")) + if err != nil { + t.Fatalf("Instantiate after a failed one: %v", err) + } + if got := len(ctx.objectBehaviors); got != 1 { + t.Errorf("%d behaviors attached, want only the new object's", got) + } + if _, ok := fine.ExhibitedState(); !ok { + t.Error("the new object exhibits no machine") + } +} + +// An object exhibiting a machine typed by the library's StateAction, with its own +// body, materializes, reads its attributes and runs that body, however named. +func TestObjectExhibitsAMachineTypedByTheLibraryStateAction(t *testing.T) { + for _, tc := range []struct{ name, typing string }{ + {"imported", "StateAction"}, + {"qualified", "States::StateAction"}, + } { + t.Run(tc.name, func(t *testing.T) { + src := ` + package test { + private import States::StateAction; + part def Mission { + attribute mass: Integer = 7; + attribute launched: Integer = 0; + exhibit state phases : ` + tc.typing + ` { + entry; then launch; + state launch { entry action mark { assign launched := 1; } } + } + } + }` + model, resolver, root := parseAndBuildLibraryModel(t, src) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "Mission")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if got := featureInt(t, ctx, inst, "mass"); got != 7 { + t.Errorf("mass = %d, want 7", got) + } + if got := featureInt(t, ctx, inst, "launched"); got != 1 { + t.Errorf("launched = %d, want 1 once the machine entered launch", got) + } + machine, ok := inst.ExhibitedState() + if !ok || machine.State == nil { + t.Fatal("the object exhibits no machine") + } + if machine.Name != "phases" { + t.Errorf("machine name = %q, want phases", machine.Name) + } + if got := machine.State.FinalStateName(); got != "launch" { + t.Errorf("final state = %q, want launch", got) + } + }) + } +} + +// A creation that fails leaves none of the objects it reached in the session: a +// sibling materialized on the way would otherwise survive running nothing. +func TestFailedMaterializationLeavesNoNeighbourBehind(t *testing.T) { + src := ` + package test { + item def Ping; + + part def Listener { + exhibit state listening { + entry; then waiting; + state waiting; + accept Ping then heard; + state heard; + } + } + + part def Broken { + exhibit state sending { + entry; then sent; + state sent { entry send Ping() to good; } + } + exhibit state empty; + } + + part good : Listener; + part bad : Broken; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + + if _, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "bad")); err == nil { + t.Fatal("expected the machine with no initial state to fail materialization") + } + if got := len(ctx.instances); got != 0 { + t.Errorf("%d object(s) left alive after a failed creation, want none", got) + } + if got := len(ctx.objectBehaviors); got != 0 { + t.Errorf("%d behavior(s) left attached after a failed creation", got) + } +} + +// Two nested objects addressing each other are materialized once each: an object +// is held by the feature that materializes it before its behaviors start, so a +// reply addressed back reaches it instead of materializing a second copy. The +// only other objects are the two messages the sends construct. +func TestMutuallyAddressedObjectsAreMaterializedOnce(t *testing.T) { + src := ` + package test { + item def Ping; + item def Pong; + + part def Node; + + part def Pair { + part a : Node { + exhibit state pinging { + entry; then sending; + state sending { + entry send new Ping() to b; + } + accept Pong then answered; + state answered; + } + } + part b : Node { + exhibit state replying { + entry; then waiting; + state waiting; + accept Ping then replied; + state replied { entry send new Pong() to a; } + } + } + } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + + pair, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "Pair")) + if err != nil { + t.Fatalf("Instantiate Pair: %v", err) + } + a := instanceAtPath(t, ctx, pair, "a") + b := instanceAtPath(t, ctx, pair, "b") + + if got := len(ctx.instances); got != 7 { + t.Errorf("%d objects materialized, want the pair, its parts, their state performances, and the two messages", got) + } + assertCurrentState(t, machineOf(t, a, "pinging").State, "answered") + assertCurrentState(t, machineOf(t, b, "replying").State, "replied") +} + +// machineOf is the object's execution of the named state machine. +func machineOf(t *testing.T, inst *Instance, name string) *ObjectBehavior { + t.Helper() + behavior, ok := inst.Behavior(name) + if !ok || behavior.State == nil { + t.Fatalf("object #%d runs no state machine %q", inst.ID, name) + } + return behavior +} + +// A holder's creation fails with the creation of a part it runs, and leaves +// nothing behind: neither the holder, nor the neighbour the failed part reached, +// nor any behavior of theirs. +func TestFailedNestedStartFailsTheHolder(t *testing.T) { + src := ` + package test { + item def Ping; + + part def Listener { + exhibit state listening { + entry; then waiting; + state waiting; + accept Ping then heard; + state heard; + } + } + + part def Broken { + exhibit state sending { + entry; then sent; + state sent { entry send Ping() to good; } + } + exhibit state empty; + } + + part def Group { + part good : Listener; + part bad : Broken; + } + part group : Group; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + + sym := resolveSymbol(t, pkg.Scope, "group") + if _, err := ctx.Instantiate(sym); err == nil { + t.Fatal("expected the part whose machine has no initial state to fail its holder's creation") + } + if got := len(ctx.instances); got != 0 { + t.Errorf("%d object(s) left alive after a failed creation, want none", got) + } + if got := len(ctx.objectBehaviors); got != 0 { + t.Errorf("%d behavior(s) left attached after a failed creation", got) + } + if _, named := ctx.occurrences[sym]; named { + t.Error("the failed creation is still what the usage denotes") + } +} + +// An optional part whose type runs a machine is not created with its holder: +// nothing is required to hold it, so nothing runs until something reads it. +func TestOptionalBehavingPartIsNotCreatedWithItsHolder(t *testing.T) { + src := ` + package test { + part def Ticker { + attribute n : ScalarValues::Integer = 0; + exhibit state ticking { + entry; then on; + state on { entry assign n := n + 1; } + } + } + part def Bay { part maybe : Ticker [0..1]; } + part def Holder { + part maybe : Ticker [0..1]; + part bay : Bay; + part must : Ticker; + } + part holder : Holder; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "holder")) + if err != nil { + t.Fatal(err) + } + if fv := inst.FeatureValues["maybe"]; fv.Materialized { + t.Errorf("the optional part was created with its holder: %+v", fv) + } + // A required part that runs nothing itself, and whose only behaving part is + // optional, is as lazy as any other part without behaviors. + if fv := inst.FeatureValues["bay"]; fv.Materialized { + t.Errorf("the part holding only an optional behaving part was created with its holder: %+v", fv) + } + if fv := inst.FeatureValues["must"]; !fv.Materialized { + t.Errorf("the required part was not created with its holder: %+v", fv) + } + // The holder, the required part, and the required part's state performance. + if got := len(ctx.instances); got != 3 { + t.Errorf("%d object(s) after creating the holder, want 3", got) + } + + // A part required in unbounded number is not optional: it fails its holder + // as reading it would. + src = ` + package test { + part def Ticker { + exhibit state ticking { entry; then on; state on; } + } + part def Holder { part all : Ticker [*..*]; } + part holder : Holder; + } + ` + model, resolver, root = parseAndBuildModel(t, src) + pkg = resolveSymbol(t, root, "test") + ctx = NewContext(typedModel(model, resolver), 10000) + if _, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "holder")); !errors.Is(err, ErrMultiplicityViolation) { + t.Errorf("creating a holder of an unbounded required part: got %v, want ErrMultiplicityViolation", err) + } + + // A required part whose upper bound cannot be determined is not optional + // either: it fails its holder as reading it would. + src = ` + package test { + part def Ticker { + exhibit state ticking { entry; then on; state on; } + } + part def Holder { part some : Ticker [1..n]; } + part holder : Holder; + } + ` + model, resolver, root = parseAndBuildModel(t, src) + pkg = resolveSymbol(t, root, "test") + ctx = NewContext(typedModel(model, resolver), 10000) + if _, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "holder")); err == nil || !strings.Contains(err.Error(), "unknown multiplicity") { + t.Errorf("creating a holder of a required part of unknown upper bound: got %v, want an unknown-multiplicity error", err) + } +} + +// A behavior started by a creation that names its own usage reaches the object +// being created, and a second creation of the usage is what it denotes from then +// on; a failed second creation leaves the first as what the usage denotes. +func TestStartupNamingItsOwnUsageReachesTheObjectCreated(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Counter { + attribute n : Integer = 0; + attribute seen : Integer = 0; + exhibit state sm { + entry; then s1; + state s1 { entry assign seen := counter.n + 1; } + } + } + part counter : Counter; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + sym := resolveSymbol(t, pkg.Scope, "counter") + + first, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate counter: %v", err) + } + if got := len(ctx.instances); got != 2 { + t.Errorf("%d objects materialized, want the counter and its state performance", got) + } + if ids := ctx.occurrences[sym]; !slices.Equal(ids, []int64{first.ID}) { + t.Errorf("counter denotes objects %v, want the one created, %d", ids, first.ID) + } + + second, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate counter again: %v", err) + } + if ids := ctx.occurrences[sym]; !slices.Equal(ids, []int64{second.ID}) { + t.Errorf("counter denotes objects %v after a second creation, want %d", ids, second.ID) + } + if _, held := ctx.Instance(first.ID); !held { + t.Error("the first object is gone after a second creation") + } +} + +// A machine that reached its final state takes no step, so a message still +// addressed to it does not make a later materialization spin to its budget. +func TestMessageLeftForACompletedMachineDoesNotBlockANewObject(t *testing.T) { + src := ` + part def Chirp { + exhibit state modes { + entry; then working; + state working { + entry; then inner; + state inner; + accept Ping then working; + succession first inner then done; + } + succession first working then done; + } + } + attribute def Ping; + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + sym := resolveSymbol(t, root, "Chirp") + + first, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate first: %v", err) + } + behavior, ok := first.ExhibitedState() + if !ok { + t.Fatal("the object exhibits no machine") + } + if got := behavior.State.State(); got != StateCompleted { + t.Fatalf("machine state = %v, want it completed at its final state", got) + } + // The completed machine's enclosing state still accepts Ping, so the message + // stays in flight with no consumer able to take it. + ctx.PostMessage(Message{SignalType: "Ping", Target: "modes", Object: first.ID}) + + second, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate second: %v", err) + } + if second.ID == first.ID { + t.Errorf("the second object took identity %d", second.ID) + } +} + +// A decision of an action an object performs reads the object's feature values, +// so a branch is chosen on what the action itself has written. +func TestPerformedActionDecidesOnItsOwnWrite(t *testing.T) { + src := ` + part def Watchdog { + attribute level: Integer = 0; + attribute alerted: Integer = 0; + + perform action watch { + first start; + + action raise { + assign level := 5; + } + + action alert { + assign alerted := 1; + } + + action quiet { + assign alerted := 2; + } + + done; + + succession first start then raise; + succession first raise then check; + succession first alert then done; + succession first quiet then done; + + decide check; + if level > 0 then alert; + else quiet; + } + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + inst, err := ctx.Instantiate(resolveSymbol(t, root, "Watchdog")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if got := featureInt(t, ctx, inst, "level"); got != 5 { + t.Fatalf("level = %d, want 5 written by the action", got) + } + if got := featureInt(t, ctx, inst, "alerted"); got != 1 { + t.Errorf("alerted = %d, want 1: the decision read the level the action wrote", got) + } +} + +// An action executed on an object performing it runs the performance the object +// already runs of it, so the object is written once: its outcome is that +// performance's, with the object's attributes. Inputs for it are refused, as its +// declaration binds its arguments; an object performing it twice over is refused +// naming the usages; an action the object does not perform is performed anew. +func TestActionExecutedOnItsPerformerRunsTheExistingPerformance(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + action def Fill { out poured : Integer; } + part def Tank { + attribute level : Integer = 0; + perform action fill : Fill { + first start; + then action pour { assign level := level + 1; assign poured := level; } + then done; + } + action top { + first start; + then action pour assign level := level + 10; + then done; + } + } + part def Twin { perform action morning : Fill; perform action evening : Fill; } + }`)) + tank, err := ctx.Instantiate(oneSymbol(t, idx, "P::Tank")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + fill := oneSymbol(t, idx, "P::Tank::fill") + results, err := ctx.ExecuteActionPerformedBy(fill, tank, nil) + if err != nil { + t.Fatalf("execute fill on the tank: %v", err) + } + if got := featureInt(t, ctx, tank, "level"); got != 1 { + t.Errorf("level = %d after executing fill on the tank, want 1: one performance", got) + } + if poured, ok := results["poured"]; !ok || poured.Const.Int != 1 { + t.Errorf("fill's results %v, want poured = 1 from the performance the tank runs", results) + } + outcome, err := ctx.ActionOutcomePerformedBy(oneSymbol(t, idx, "P::Fill"), tank, nil) + if err != nil { + t.Fatalf("outcome of Fill on the tank: %v", err) + } + if level, ok := outcome.Outputs["this.level"]; !ok || level.Const.Int != 1 { + t.Errorf("outcome %v, want this.level = 1", outcome.Outputs) + } + if _, err := ctx.ExecuteActionPerformedBy(fill, tank, map[string]Value{"n": intArgument(2)}); !errors.Is(err, ErrPerformedInputs) { + t.Errorf("fill with inputs on the tank: %v, want %v", err, ErrPerformedInputs) + } + if _, err := ctx.ExecuteActionPerformedBy(oneSymbol(t, idx, "P::Tank::top"), tank, nil); err != nil { + t.Fatalf("execute top on the tank: %v", err) + } + if got := featureInt(t, ctx, tank, "level"); got != 11 { + t.Errorf("level = %d after top, want 11: an action the tank does not perform runs anew", got) + } + + twin, err := ctx.Instantiate(oneSymbol(t, idx, "P::Twin")) + if err != nil { + t.Fatalf("Instantiate Twin: %v", err) + } + _, err = ctx.ExecuteActionPerformedBy(oneSymbol(t, idx, "P::Fill"), twin, nil) + if !errors.Is(err, ErrAmbiguousAction) || !strings.Contains(err.Error(), "morning and evening") { + t.Errorf("Fill on an object performing it twice: %v, want %v naming morning and evening", err, ErrAmbiguousAction) + } +} diff --git a/internal/exec/runtime/classify.go b/internal/exec/runtime/classify.go new file mode 100644 index 0000000000..2a100052b3 --- /dev/null +++ b/internal/exec/runtime/classify.go @@ -0,0 +1,406 @@ +package runtime + +import ( + "fmt" + "maps" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// instanceConforms reports whether an object is an instance of typ by its declaration or by +// a feature it was held as a value of (KerML 1.0 §7.3.4.1: a feature's values are instances of its types). +func (ctx *Context) instanceConforms(inst *Instance, typ *symbols.Symbol) bool { + // All of the object's types at once: a difference reads the types it subtracts too. + return ctx.model.semantics.ClassifiesTypes(inst.types(), typ) == semantics.ClassifiesAll +} + +// isDirectTypeOf reports whether typ is already a direct type of an object: one it was +// declared by or held by, or a type one of those is typed by when typ declares no more. +func (ctx *Context) isDirectTypeOf(inst *Instance, typ *symbols.Symbol) bool { + direct := ctx.directType(typ) + return slices.ContainsFunc(inst.types(), func(t *symbols.Symbol) bool { + return symbols.SameElement(t, typ) || (direct == typ && ctx.directType(t) == direct) + }) +} + +// canClassify reports whether an object may be held by a feature typed by typ: it is one +// already, or every type classifying it is comparable with typ, so holding it narrows it. +func (ctx *Context) canClassify(inst *Instance, typ *symbols.Symbol) bool { + if ctx.instanceConforms(inst, typ) { + return true + } + if !ctx.comparableTypes(inst.Type, typ, map[*symbols.Symbol]bool{}) { + return false + } + for _, c := range inst.classifiers { + if !ctx.comparableTypes(c, typ, map[*symbols.Symbol]bool{}) { + return false + } + } + return true +} + +// comparableTypes reports whether one of typ and other specializes the other. A +// feature stands for the types it is typed by, implicit base included. +func (ctx *Context) comparableTypes(typ, other *symbols.Symbol, seen map[*symbols.Symbol]bool) bool { + if typ == nil || other == nil || ctx.modelConforms(typ, other) || ctx.modelConforms(other, typ) { + return true + } + if !semantics.IsShapeFeature(typ) || seen[typ] { + return false + } + seen[typ] = true + for _, super := range ctx.model.semantics.DirectSupertypes(typ) { + if !ctx.comparableTypes(super, other, seen) { + return false + } + } + return true +} + +// classifyHeld classifies every object held as a value of feature by the feature itself, +// so it carries the features its type and body declare; the caller has checked each may be held. +// The value is classified whole: one object refused leaves every object as it was. +func (ctx *Context) classifyHeld(feature *symbols.Symbol, val Value) error { + if feature == nil { + return nil + } + commit, rollback := ctx.beginJournal() + for _, el := range elementsOf(val) { + id, ok := el.Object() + if !ok { + continue + } + inst, ok := ctx.instances[id] + if !ok || inst == nil || inst.Type == nil { + continue + } + if err := ctx.classify(inst, feature); err != nil { + rollback() + return err + } + } + commit() + return nil +} + +// holdWritten makes the objects written to a feature of inst its values (KerML §7.3.4.1): a composite +// feature adopts the ownerless ones first, so the behaviors the feature adds start on a part of a whole. +// The write is journaled whole: a refused classification leaves ownership as it was. +func (ctx *Context) holdWritten(inst *Instance, fv *FeatureValue, val Value) error { + if !holdsObjects(fv.Feature) { + return nil + } + commit, rollback := ctx.beginHoldJournal() + if err := ctx.adoptWritten(inst, fv, val); err != nil { + rollback() + return err + } + if err := ctx.classifyHeld(fv.Feature.heldBy(), val); err != nil { + rollback() + return err + } + commit() + return nil +} + +// holdDeclared is admitted for a declared value (a default or a binding's), with the composite +// adoption holdWritten gives a written one; a refused value leaves ownership as it was. +func (ctx *Context) holdDeclared(inst *Instance, fv *FeatureValue, val Value) (Value, error) { + commit, rollback := ctx.beginHoldJournal() + if err := ctx.adoptWritten(inst, fv, val); err != nil { + rollback() + return Value{}, err + } + val, err := ctx.admitted(fv.Feature, val, admitDeclared) + if err != nil { + rollback() + return Value{}, err + } + commit() + return val, nil +} + +// ownsHeld reports a composite usage (SysML v2 §7.6.2), whose objects are portions of the object holding +// it however they got there — instantiated, written, or stated by a default or a binding. +func (ctx *Context) ownsHeld(feat *EffectiveFeature) bool { + return holdsObjects(feat) && semantics.UsageIsComposite(feat.Symbol) && !isSubjectUsage(feat.Symbol) && + !ctx.model.semantics.IsVariationFeature(feat.Symbol) +} + +// adoptWritten makes inst the home (Instance.owner) of the ownerless objects a write to its composite +// feature holds, releasing the ones it drops; each change is noted for the journal under way to undo. +// Being a portion of inst does not depend on it: portionsOf reads the composite features themselves, +// so a write that would make inst a portion of itself is refused before anything is held; so is one +// that would give an ended inst a live portion, a portion's life lying within its whole's. +func (ctx *Context) adoptWritten(inst *Instance, fv *FeatureValue, val Value) error { + if !ctx.ownsHeld(fv.Feature) { + return nil + } + whole := ctx.lives[inst.ID] + for _, el := range elementsOf(val) { + id, ok := el.Object() + if !ok { + continue + } + child, found := ctx.instances[id] + if !found { + continue + } + if ctx.isPortionOf(inst, child) { + return fmt.Errorf("%w: %s #%d would hold %s #%d, a whole it is a portion of, as a portion of itself", + ErrOccurrenceLifetime, symbolText(inst.Type), inst.ID, symbolText(child.Type), child.ID) + } + if part := ctx.lives[id]; whole.ended != 0 && (part.ended == 0 || part.ended > whole.ended) { + return fmt.Errorf("%w: %s #%d ended at %d and cannot hold %s #%d, %s, as a portion of itself", + ErrOccurrenceLifetime, symbolText(inst.Type), inst.ID, whole.ended, symbolText(child.Type), child.ID, endedText(part)) + } + } + ctx.releaseDropped(inst, fv, val) + for _, el := range elementsOf(val) { + id, ok := el.Object() + if !ok { + continue + } + child, ok := ctx.instances[id] + if !ok || child.owner != nil { + continue + } + child.owner, child.ownerFeature = inst, fv.Feature.Name + ctx.noteProbeUndo(func() { child.owner, child.ownerFeature = nil, "" }) + } + return nil +} + +// endedText says when a life ended, or that it has not. +func endedText(l life) string { + if l.ended == 0 { + return "live" + } + return fmt.Sprintf("ended at %d", l.ended) +} + +// releaseDropped moves the home of an object the composite feature owned and the write no longer +// holds: to another composite feature still holding it, else free for the next feature written +// into to own it; each move is noted for the journal. +func (ctx *Context) releaseDropped(inst *Instance, fv *FeatureValue, val Value) { + kept := map[int64]bool{} + for _, el := range elementsOf(val) { + if id, ok := el.Object(); ok { + kept[id] = true + } + } + for _, el := range elementsOf(fv.HeldValue()) { + id, ok := el.Object() + if !ok || kept[id] { + continue + } + child, ok := ctx.instances[id] + if !ok || child.owner != inst || inst.FeatureValues[child.ownerFeature] != fv { + continue + } + feature := child.ownerFeature + child.owner, child.ownerFeature = ctx.otherHomeOf(child, fv) + ctx.noteProbeUndo(func() { child.owner, child.ownerFeature = inst, feature }) + } +} + +// otherHomeOf is the composite feature still holding child besides dropped — the lowest object's, +// its first feature by name, so the choice is the same on every run — or none. +func (ctx *Context) otherHomeOf(child *Instance, dropped *FeatureValue) (*Instance, string) { + ids := slices.Sorted(maps.Keys(ctx.instances)) + for _, id := range ids { + whole := ctx.instances[id] + if whole == child || ctx.lives[id].destroyed { + continue + } + for _, name := range slices.Sorted(maps.Keys(whole.FeatureValues)) { + fv := whole.FeatureValues[name] + if fv == dropped || fv.Feature == nil || fv.Feature.Name != name || !ctx.ownsHeld(fv.Feature) { + continue + } + if slices.ContainsFunc(elementsOf(fv.HeldValue()), func(el Value) bool { id, ok := el.Object(); return ok && id == child.ID }) { + return whole, name + } + } + } + return nil, "" +} + +// isPortionOf reports whether inst is whole itself or one of the portions whole holds, transitively. +func (ctx *Context) isPortionOf(inst, whole *Instance) bool { + return slices.Contains(ctx.portionsOf(whole), inst) +} + +// classify records typ as a classifier of inst with the features and behaviors it adds; a +// type the object already conforms to adds nothing and is recorded as a direct type alone. +// It is one transaction: a failure, or a probe rolling it back, leaves the object, what its +// behaviors wrote, the bus and the objects they made as they were. +func (ctx *Context) classify(inst *Instance, typ *symbols.Symbol) error { + if ctx.isDirectTypeOf(inst, typ) { + return nil + } + inherited := ctx.instanceConforms(inst, typ) + commit, rollback := ctx.beginJournal() + classifiers, values, running := inst.classifiers, maps.Clone(inst.FeatureValues), len(inst.behaviors) + ctx.noteProbeUndo(func() { + if len(inst.behaviors) > running { + ctx.forgetBehaviors(inst.behaviors[running:]) + } + inst.classifiers, inst.FeatureValues = classifiers, values + }) + inst.classifiers = append(inst.classifiers, typ) + if inherited { + commit() + return nil + } + carried := make(map[string]bool, len(inst.FeatureValues)) + for name := range inst.FeatureValues { + carried[name] = true + } + features := ctx.FeaturesOf(typ) + for i := range features { + feat := &features[i] + if !carried[feat.Name] { + inst.FeatureValues[feat.Name] = ctx.newFeatureValue(inst, feat) + continue + } + if err := ctx.refineFeatureValue(inst, inst.FeatureValues[feat.Name], feat, typ); err != nil { + rollback() + return err + } + } + ctx.unfoldSubsettedDefaults(inst, typ, features) + if err := ctx.aliasRedefinedFeatureValuesOf(inst, typ, carried); err != nil { + rollback() + return err + } + if err := ctx.startClassifierBehaviors(inst, len(ctx.created)); err != nil { + rollback() + return err + } + commit() + return nil +} + +// refineFeatureValue makes a carried feature value read the classifier's declaration when it redefines the +// one read (KerML 1.0 §7.3.4.5), or the classifier specializes the type declaring it and so masks it (§7.3.2.1). +func (ctx *Context) refineFeatureValue(inst *Instance, fv *FeatureValue, feat *EffectiveFeature, typ *symbols.Symbol) error { + have := fv.Feature + if feat.Symbol == nil || have.Symbol == nil || feat.Symbol == have.Symbol { + return nil + } + if !slices.Contains(ctx.redefinedFeatures(feat.Symbol, typ), have.Symbol) && + (!ctx.modelConforms(typ, have.OwnerType) || slices.Contains(ctx.redefinedFeatures(have.Symbol, have.OwnerType), feat.Symbol)) { + return nil + } + ctx.noteProbeWrite(fv) + if !fv.Materialized || (!fv.Written && feat.DefaultValue != have.DefaultValue) { + ctx.invalidateDependents(fv) + ctx.initFeatureValue(inst, fv, feat) + return nil + } + how := admitDeclared + if fv.Written { + how = admitWritten + } + held := fv.HeldValue() + what := func() string { return fmt.Sprintf("feature value %s.%s", inst.Type.Name, feat.Name) } + if err := ctx.checkAdmits(feat, what, &held, how); err != nil { + return err + } + val, err := ctx.admitted(feat, held, how) + if err != nil { + return err + } + fv.Feature, fv.Value, fv.Values = feat, Value{}, Value{} + if feat.Scalar() { + fv.Value = val + } else { + fv.Values = val + } + return nil +} + +// declaredBy gathers what each of an object's types declares for it, in type order and once +// per declaring scope: a classifier adds only what the types before it do not declare. +func declaredBy[T any](ctx *Context, types []*symbols.Symbol, of func(*symbols.Symbol) []T, scopeOf func(T) *symbols.Scope) []T { + if len(types) == 1 { + return of(types[0]) + } + covered := map[*symbols.Scope]bool{} + var out []T + for _, typ := range types { + for _, rel := range of(typ) { + if scope := scopeOf(rel); scope == nil || !covered[scope] { + out = append(out, rel) + } + } + covered[DeclScope(typ)] = true + for _, sup := range ctx.model.semantics.AllSupertypes(typ) { + covered[DeclScope(sup)] = true + } + } + return out +} + +// bindingsOf returns the binding connectors of inst's types involving the named feature. +func (ctx *Context) bindingsOf(inst *Instance, name string) []lower.Binding { + return declaredBy(ctx, inst.types(), + func(typ *symbols.Symbol) []lower.Binding { return ctx.bindingsForFeature(typ, name) }, + func(b lower.Binding) *symbols.Scope { return b.Scope }) +} + +// connectionsOf returns the connections inst owns through each of its types. +func (ctx *Context) connectionsOf(inst *Instance) []lower.Connection { + return declaredBy(ctx, inst.types(), ctx.objectConnections, + func(c lower.Connection) *symbols.Scope { return c.Scope }) +} + +// anonymousConnectorsOf returns the unnamed connector usages an object of types owns. +func (ctx *Context) anonymousConnectorsOf(types []*symbols.Symbol) []*symbols.Symbol { + return declaredBy(ctx, types, ctx.anonymousConnectors, + func(sym *symbols.Symbol) *symbols.Scope { return sym.OwnerScope }) +} + +// subsettingFeaturesOf returns the features of inst subsetting the named one through any +// of its types, in declaration order, each once. +func (ctx *Context) subsettingFeaturesOf(inst *Instance, name string) []EffectiveFeature { + types := inst.types() + if len(types) == 1 { + return ctx.SubsettingFeatures(inst, types[0], name) + } + seen := map[string]bool{} + var out []EffectiveFeature + for _, typ := range types { + for _, feat := range ctx.SubsettingFeatures(inst, typ, name) { + if !seen[feat.Name] { + seen[feat.Name] = true + out = append(out, feat) + } + } + } + return out +} + +// subsettedNamesOf returns the features of inst that its feature sym subsets, through any of its types. +func (ctx *Context) subsettedNamesOf(inst *Instance, sym *symbols.Symbol) []string { + types := inst.types() + if len(types) == 1 { + return ctx.subsettedNames(sym, types[0]) + } + seen := map[string]bool{} + var out []string + for _, typ := range types { + for _, name := range ctx.subsettedNames(sym, typ) { + if !seen[name] { + seen[name] = true + out = append(out, name) + } + } + } + return out +} diff --git a/internal/exec/runtime/classify_test.go b/internal/exec/runtime/classify_test.go new file mode 100644 index 0000000000..d5e1e10a01 --- /dev/null +++ b/internal/exec/runtime/classify_test.go @@ -0,0 +1,2086 @@ +package runtime + +import ( + "errors" + "maps" + "slices" + "sort" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// instantiateQualified materializes the object the qualified name declares. +func instantiateQualified(t *testing.T, ctx *Context, idx *symbols.Index, name string) *Instance { + t.Helper() + matches := idx.LookupQualified(name) + if len(matches) != 1 { + t.Fatalf("%s: %d matching symbols, want 1", name, len(matches)) + } + inst, err := ctx.Instantiate(matches[0]) + if err != nil { + t.Fatalf("Instantiate(%s): %v", name, err) + } + return inst +} + +// readInstance reads a feature holding one object and returns that object. +func readInstance(t *testing.T, ctx *Context, inst *Instance, name string) *Instance { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("GetFeatureValue(%s): %v", name, err) + } + held := fv.HeldValue() + if held.Kind != ValInstance { + t.Fatalf("%s = %s, want one object", name, FormatValue(held)) + } + obj, ok := ctx.getInstance(held.Instance) + if !ok { + t.Fatalf("%s: object %d not registered", name, held.Instance) + } + return obj +} + +func readInt(t *testing.T, ctx *Context, inst *Instance, name string) int64 { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("GetFeatureValue(%s): %v", name, err) + } + return intValue(t, map[string]Value{name: fv.HeldValue()}, name) +} + +func readBool(t *testing.T, ctx *Context, inst *Instance, name string) bool { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("GetFeatureValue(%s): %v", name, err) + } + v := fv.HeldValue() + if v.Kind != ValConst || v.Const.Kind != semantics.ValBool { + t.Fatalf("%s = %s, want a Boolean", name, FormatValue(v)) + } + return v.Const.Bool +} + +const classifiedValueModel = ` + package test { + private import ScalarValues::*; + item def Segment { item ends [2]; } + item def Loop { item edges : Segment [*]; } + item def Square :> Loop { + item :>> edges [4] = (e1, e2, e3, e4); + item e1 [1]; + item e2 [1]; + item e3 [1]; + item e4 [1]; + } + item sq : Square; + } +` + +// A feature's values are instances of its type (KerML 1.0 §7.3.4.1): an untyped member listed +// in `edges : Segment` is the same object, now a Segment, whichever is read first, and only once. +func TestListedValueIsClassifiedByTheFeatureType(t *testing.T) { + for _, first := range []string{"e1", "edges"} { + t.Run(first+"_first", func(t *testing.T) { + ctx, idx := libraryShapeContext(t, classifiedValueModel) + sq := instantiateQualified(t, ctx, idx, "test::sq") + segment := idx.LookupQualified("test::Segment")[0] + if _, err := sq.GetFeatureValue(ctx, first); err != nil { + t.Fatalf("GetFeatureValue(%s): %v", first, err) + } + + e1 := readInstance(t, ctx, sq, "e1") + edges, err := sq.GetFeatureValue(ctx, "edges") + if err != nil { + t.Fatalf("GetFeatureValue(edges): %v", err) + } + els := elementsOf(edges.HeldValue()) + if len(els) != 4 || els[0].Kind != ValInstance || els[0].Instance != e1.ID { + t.Fatalf("edges = %s, want e1 (%d) first of four", FormatValue(edges.HeldValue()), e1.ID) + } + if e1.Type.Name != "e1" || !ctx.instanceConforms(e1, segment) { + t.Fatalf("e1 is declared by %s and is a Segment: %v", e1.Type.Name, ctx.instanceConforms(e1, segment)) + } + ends, err := e1.GetFeatureValue(ctx, "ends") + if err != nil { + t.Fatalf("GetFeatureValue(e1.ends): %v", err) + } + if got := len(elementsOf(ends.HeldValue())); got != 2 { + t.Fatalf("e1.ends holds %d objects, want 2", got) + } + + before := len(e1.classifiers) + if err := ctx.classify(e1, segment); err != nil { + t.Fatalf("classify again: %v", err) + } + if len(e1.classifiers) != before { + t.Fatalf("classifying an object twice grew its classifiers from %d to %d", before, len(e1.classifiers)) + } + }) + } +} + +// A holding computed rather than listed — the object chosen by a condition, an index, an +// invocation or a body — classifies its object before it is first read all the same: the +// classifier's features and behavior are there whichever feature is read first. +func TestComputedHoldingIsClassifiedWhicheverIsReadFirst(t *testing.T) { + holdings := map[string]string{ + "conditional": "if pickLead ? lead else trail", + "indexed": "(trail, lead)#(2)", + "invocation": "SequenceFunctions::head((lead, trail))", + "body": "(lead, trail)->select { in x; x == lead }", + } + for name, holding := range holdings { + t.Run(name, func(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + private import ControlFunctions::*; + state def Glowing { + attribute cycles : Integer = 0; + entry; then lit; + state lit { entry action count { assign cycles := cycles + 1; } } + } + item def Tallied { attribute tally : Integer = 7; exhibit state glow : Glowing; } + item def Rack { + attribute pickLead : Boolean = true; + item lead [1]; + item trail [1]; + item tallied : Tallied [1] = `+holding+`; + } + item rack : Rack; + }`) + rack := instantiateQualified(t, ctx, idx, "test::rack") + tallied := idx.LookupQualified("test::Tallied")[0] + + lead := readInstance(t, ctx, rack, "lead") + if !ctx.instanceConforms(lead, tallied) { + t.Fatalf("lead, read first, is classified by %v, want Tallied", lead.classifiers) + } + if got := readInt(t, ctx, lead, "tally"); got != 7 { + t.Fatalf("lead.tally = %d, want 7", got) + } + glow, ok := lead.Behavior("glow") + if !ok || glow.State == nil || glow.State.stateData["cycles"].Const.Int != 1 { + t.Fatal("lead, read first, runs no glow state machine") + } + if held := readInstance(t, ctx, rack, "tallied"); held != lead { + t.Fatalf("tallied holds object %d, want lead (%d)", held.ID, lead.ID) + } + if trail := readInstance(t, ctx, rack, "trail"); ctx.instanceConforms(trail, tallied) { + t.Fatal("trail, which tallied does not hold, is a Tallied") + } + }) + } +} + +// An argument a calc's returns never pass on is not held by the feature the call values: +// reading it neither computes that feature — which may fail — nor classifies the object +// the call does return, so no behavior starts on it. +func TestArgumentNotReturnedIsNotHeldByTheCall(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + state def Glowing { + attribute cycles : Integer = 0; + entry; then lit; + state lit { entry action count { assign cycles := cycles + 1; } } + } + item def Tallied { attribute tally : Integer = 7; exhibit state glow : Glowing; } + calc def pickChosen { in chosen; in other; return : Anything = chosen; } + item def Rack { + item lead [1]; + item trail [1]; + item tallied : Tallied [1] = pickChosen(lead, trail); + item failing : Tallied [1] = pickChosen(3, trail); + } + item rack : Rack; + }`) + rack := instantiateQualified(t, ctx, idx, "test::rack") + tallied := idx.LookupQualified("test::Tallied")[0] + + trail := readInstance(t, ctx, rack, "trail") + if ctx.instanceConforms(trail, tallied) { + t.Fatal("trail, which no call returns, is a Tallied") + } + if rack.FeatureValues["tallied"].Materialized || rack.FeatureValues["lead"].Materialized { + t.Fatal("reading trail computed tallied, which does not hold it") + } + lead := readInstance(t, ctx, rack, "lead") + if !ctx.instanceConforms(lead, tallied) { + t.Fatalf("lead, read first, is classified by %v, want Tallied", lead.classifiers) + } + if glow, ok := lead.Behavior("glow"); !ok || glow.State == nil || glow.State.stateData["cycles"].Const.Int != 1 { + t.Fatal("lead, read first, runs no glow state machine") + } + if _, err := rack.GetFeatureValue(ctx, "failing"); err == nil { + t.Fatal("failing holds 3 as a Tallied without error") + } +} + +// A call through a feature chain, `picker.pickChosen(lead, trail)`, applies the calc the +// chain denotes: the feature it values holds the arguments that calc's returns pass on, +// and the chain itself is the callee, not an argument. +func TestChainCallReturnedArgumentsAreHeldByTheCall(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Tallied { attribute tally : Integer = 7; } + item def Picker { + calc pickChosen { in chosen; in other; return : Anything = chosen; } + calc pickOther { in chosen; in other; return : Anything = other; } + } + item def Rack { + item picker : Picker; + item lead [1]; + item trail [1]; + item spare [1]; + item tallied : Tallied [1] = picker.pickChosen(lead, trail); + item named : Tallied [1] = picker.pickOther(other = spare, chosen = trail); + } + item rack : Rack; + }`) + rack := instantiateQualified(t, ctx, idx, "test::rack") + tallied := idx.LookupQualified("test::Tallied")[0] + + trail := readInstance(t, ctx, rack, "trail") + if ctx.instanceConforms(trail, tallied) { + t.Fatal("trail, which neither call returns, is a Tallied") + } + if rack.FeatureValues["tallied"].Materialized || rack.FeatureValues["named"].Materialized { + t.Fatal("reading trail computed a call that does not hold it") + } + lead := readInstance(t, ctx, rack, "lead") + if !ctx.instanceConforms(lead, tallied) { + t.Fatalf("lead, read first, is classified by %v, want Tallied", lead.classifiers) + } + spare := readInstance(t, ctx, rack, "spare") + if !ctx.instanceConforms(spare, tallied) { + t.Fatalf("spare, read first, is classified by %v, want Tallied", spare.classifiers) + } + if picker := readInstance(t, ctx, rack, "picker"); ctx.instanceConforms(picker, tallied) { + t.Fatal("picker, the callee's holder, is a Tallied") + } +} + +// A holder that cannot materialize holds nothing: the held feature reads alike in either +// order, and the holder's own error is reported when the holder is read. +func TestFailingHolderDoesNotFailTheFeatureItWouldHold(t *testing.T) { + src := `package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + item def Gauge; + item def Tallied { attribute tally : Integer = 7; } + item def Rack { + item lead : Gauge [1]; + item trail : Gauge [1]; + item both : Gauge [0..*] = (lead, trail); + item stowed : Gauge [3] = both; + item tallied : Tallied [1] = lead; + attribute count : Integer = size(both); + } + item rack : Rack; + }` + for _, order := range [][]string{{"both", "stowed"}, {"stowed", "both"}, {"count", "stowed"}, {"lead", "tallied"}, {"tallied", "lead"}} { + ctx, idx := libraryShapeContext(t, src) + rack := instantiateQualified(t, ctx, idx, "test::rack") + for _, name := range order { + _, err := rack.GetFeatureValue(ctx, name) + switch name { + case "stowed": + if !errors.Is(err, ErrMultiplicityViolation) || !strings.Contains(err.Error(), "lower bound 3") { + t.Fatalf("reading %v: stowed: %v, want its own multiplicity violation", order, err) + } + case "tallied": + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("reading %v: tallied: %v, want a type mismatch", order, err) + } + default: + if err != nil { + t.Fatalf("reading %v: %s: %v", order, name, err) + } + } + } + if got := len(elementsOf(rack.FeatureValues["both"].HeldValue())); rack.FeatureValues["both"].Materialized && got != 2 { + t.Fatalf("reading %v: both holds %d objects, want 2", order, got) + } + if got := readInt(t, ctx, rack, "count"); got != 2 { + t.Fatalf("reading %v: count = %d, want 2", order, got) + } + } +} + +// Only a feature that may answer another's objects as its own holds them: an attribute +// computing from a feature, a condition tested, and an argument a calc's returns do not pass +// on hold nothing, so reading those features forces no other; a chain holds its last member +// read from the objects before it, not those objects. A function whose body the model does +// not state may return any argument. +func TestOnlyFeaturesPassingObjectsOnHoldThem(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + item def Gauge { item cell [1]; } + item def Tallied { attribute tally : Integer = 7; } + calc def pickFirst { in chosen : Gauge; in other : Gauge; return : Gauge = chosen; } + calc def pickAt { in gauges : Gauge [*]; in n : Integer; gauges#(n) } + calc def pickWhen { + in gauges : Gauge [*]; in fallback : Gauge; in wanted : Boolean; + if wanted { for g in gauges { return : Gauge = g; } } + return : Gauge = fallback; + } + item def Rack { + attribute pickLead : Boolean = true; + attribute count : Integer = 2; + attribute doubled : Integer = count * 2; + item lead : Gauge [1]; + item trail : Gauge [1]; + item spare : Gauge [1]; + item tallied : Tallied [1] = if pickLead ? lead else trail; + item cells [2] = (lead, trail).cell; + item picked = (lead, trail)#(count - 1); + item led : Tallied [1] = pickFirst(lead, trail); + item trailed : Tallied [1] = pickFirst(chosen = trail, other = lead); + item indexed : Tallied [1] = pickAt((lead, trail), count); + item looped : Tallied [1] = pickWhen((lead, trail), spare, pickLead); + item headed : Tallied [1] = head((lead, count)); + } + item rack : Rack; + }`) + rack := idx.LookupQualified("test::Rack")[0] + + want := map[string][]string{ + "lead": {"tallied", "picked", "led", "indexed", "looped", "headed"}, + "trail": {"tallied", "picked", "trailed", "indexed", "looped"}, + "spare": {"looped"}, + "lead.cell": {"cells"}, + "trail.cell": {"cells"}, + "pickLead": nil, + "count": nil, + "doubled": nil, + } + for name, holders := range want { + if got := ctx.holdingFeatures(rack, name); !slices.Equal(got, holders) { + t.Errorf("holdingFeatures(Rack, %s) = %v, want %v", name, got, holders) + } + } +} + +// A collect body's parameter stands for the operand's elements: a body answering it, directly, +// through a feature it declares, or in a nested body, passes the operand's objects on, so the +// collecting feature holds them whichever is read first; a body answering another feature +// passes that one on instead, and one chaining through the parameter passes on the chain's +// last member of the operand's objects. +func TestCollectedObjectsAreHeldByTheCollectingFeature(t *testing.T) { + src := `package test { + private import ScalarValues::*; + item def Tallied { attribute tally : Integer = 7; } + item def Rack { + item lead [1] { item cell [1]; } + item trail [1] { item cell [1]; } + item spare [1]; + item same : Tallied [2] = (lead, trail).{in x; x}; + item local : Tallied [2] = (lead, trail).{in x; private item held = x; held}; + item nested : Tallied [*] = (lead, trail).{in x; (x, spare).{in y; y}}; + item other : Tallied [2] = (lead, trail).{in x; spare}; + item cells [2] = (lead, trail).{in x; x.cell}; + } + item rack : Rack; + }` + ctx, idx := libraryShapeContext(t, src) + rack := idx.LookupQualified("test::Rack")[0] + want := map[string][]string{ + "lead": {"same", "local", "nested"}, + "trail": {"same", "local", "nested"}, + "spare": {"nested", "other"}, + "lead.cell": {"cells"}, + "trail.cell": {"cells"}, + } + for name, holders := range want { + if got := ctx.holdingFeatures(rack, name); !slices.Equal(got, holders) { + t.Errorf("holdingFeatures(Rack, %s) = %v, want %v", name, got, holders) + } + } + + for _, order := range [][]string{{"lead", "same"}, {"same", "lead"}} { + ctx, idx := libraryShapeContext(t, src) + rack := instantiateQualified(t, ctx, idx, "test::rack") + tallied := idx.LookupQualified("test::Tallied")[0] + for _, name := range order { + if _, err := rack.GetFeatureValue(ctx, name); err != nil { + t.Fatalf("reading %v: %s: %v", order, name, err) + } + if lead := readInstance(t, ctx, rack, "lead"); !ctx.instanceConforms(lead, tallied) { + t.Errorf("reading %v, after %s lead is classified by %v, want Tallied", + order, name, lead.classifiers) + } + } + } +} + +// Calcs returning through each other are analysed as one: a parameter one passes on only +// through the other's returns is passed on, whichever calc is met first and wherever the +// direct return stands beside the call. +func TestMutuallyRecursiveCalcsPassArgumentsThroughEachOther(t *testing.T) { + for name, holdings := range map[string]string{ + "through_first_met": "item viaA : Tallied [1] = pickA(lead, trail, true); item viaB : Tallied [1] = pickB(lead, trail, true);", + "through_last_met": "item viaB : Tallied [1] = pickB(lead, trail, true); item viaA : Tallied [1] = pickA(lead, trail, true);", + } { + t.Run(name, func(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Gauge; + item def Tallied { attribute tally : Integer = 7; } + calc def pickA { + in a : Gauge; in b : Gauge; in again : Boolean; + return : Gauge = if again ? pickB(a, b, false) else a; + } + calc def pickB { + in c : Gauge; in d : Gauge; in again : Boolean; + return : Gauge = if again ? pickA(d, c, false) else d; + } + item def Rack { + item lead : Gauge [1]; + item trail : Gauge [1]; + `+holdings+` + } + }`) + rack := idx.LookupQualified("test::Rack")[0] + for _, held := range []string{"lead", "trail"} { + got := ctx.holdingFeatures(rack, held) + sort.Strings(got) + if want := []string{"viaA", "viaB"}; !slices.Equal(got, want) { + t.Errorf("holdingFeatures(Rack, %s) = %v, want %v", held, got, want) + } + } + }) + } +} + +// A calc returning a local written from an input passes that input on, so the typed feature +// receiving the result classifies the argument whichever is read first; a data local passes nothing. +func TestCalcLocalsPassArgumentsOnToTheReturn(t *testing.T) { + src := `package test { + private import ScalarValues::*; + item def Gauge { item cell [1]; } + item def Tallied :> Gauge { attribute tally : Integer = 7; } + calc def viaDeclared { in chosen : Gauge; in other : Gauge; attribute held = chosen; return : Gauge = held; } + calc def viaAssigned { + in chosen : Gauge; in other : Gauge; + attribute held = other; + assign held := chosen; + return : Gauge = held; + } + calc def viaBlock { + in chosen : Gauge; in other : Gauge; in wanted : Boolean; + if wanted { attribute held = chosen; return : Gauge = held; } + return : Gauge = other; + } + calc def viaLater { + in chosen : Gauge; in other : Gauge; + attribute held = chosen; + attribute passed = held; + return : Gauge = passed; + } + calc def viaMember { in chosen : Gauge; in other : Gauge; attribute held = chosen; return = held.cell; } + calc def viaSelf { in chosen : Gauge; in other : Gauge; attribute held = chosen; assign held := held; return : Gauge = held; } + calc def viaComputed { in chosen : Gauge; in n : Integer; attribute held = n + 1; return : Integer = held; } + item def Rack { + attribute count : Integer = 2; + item lead : Gauge [1]; + item trail : Gauge [1]; + item declared : Tallied [1] = viaDeclared(lead, trail); + item assigned : Tallied [1] = viaAssigned(lead, trail); + item blocked : Tallied [1] = viaBlock(lead, trail, true); + item later : Tallied [1] = viaLater(lead, trail); + item celled : Tallied [1] = viaMember(lead, trail); + item selfed : Tallied [1] = viaSelf(lead, trail); + attribute computed : Integer = viaComputed(lead, count); + } + item rack : Rack; + }` + ctx, idx := libraryShapeContext(t, src) + rack := idx.LookupQualified("test::Rack")[0] + want := map[string][]string{ + "lead": {"declared", "assigned", "blocked", "later", "selfed"}, + "trail": {"assigned", "blocked"}, + "count": nil, + } + for name, holders := range want { + if got := ctx.holdingFeatures(rack, name); !slices.Equal(got, holders) { + t.Errorf("holdingFeatures(Rack, %s) = %v, want %v", name, got, holders) + } + } + + for _, order := range [][]string{{"lead", "declared"}, {"declared", "lead"}, {"lead", "assigned"}, {"lead", "blocked"}, {"lead", "later"}, {"lead", "selfed"}} { + ctx, idx := libraryShapeContext(t, src) + rack := instantiateQualified(t, ctx, idx, "test::rack") + for _, name := range order { + if _, err := rack.GetFeatureValue(ctx, name); err != nil { + t.Fatalf("reading %v: %s: %v", order, name, err) + } + } + lead := readInstance(t, ctx, rack, "lead") + if got := readInt(t, ctx, lead, "tally"); got != 7 { + t.Errorf("reading %v: lead.tally = %d, want 7 from the classifier", order, got) + } + } +} + +// The object a selected variant materialized is held like any other: a typed feature +// holding a variation's value classifies that object, which then carries the feature's +// own features and runs its behaviors, and stays the variant's object. +func TestSelectedVariantObjectIsClassifiedByTheFeatureHoldingIt(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + state def Glowing { + attribute cycles : Integer = 0; + entry; then lit; + state lit { entry action count { assign cycles := cycles + 1; } } + } + item def Engine { attribute cylinders : Integer = 4; } + item def Tallied :> Engine { attribute tally : Integer = 7; exhibit state glow : Glowing; } + item def Car { + variation item engine : Engine [1] { + variant item small : Engine; + variant item big : Engine { :>> cylinders = 8; } + } + } + item def Garage { + item car : Car { :>> engine = engine::big; } + item tallied : Tallied [1] = car.engine { attribute label : String = "kept"; } + } + item garage : Garage; + }`) + garage := instantiateQualified(t, ctx, idx, "test::garage") + tallied := idx.LookupQualified("test::Tallied")[0] + + fv, err := garage.GetFeatureValue(ctx, "tallied") + if err != nil { + t.Fatalf("garage.tallied: %v", err) + } + if fv.Value.Kind != ValVariant || fv.Value.Variant().Name != "big" || fv.Value.Instance == 0 { + t.Fatalf("garage.tallied = %+v, want the materialized big variant", fv.Value) + } + car := readInstance(t, ctx, garage, "car") + engine, err := car.GetFeatureValue(ctx, "engine") + if err != nil || engine.Value.Instance != fv.Value.Instance { + t.Fatalf("car.engine = %+v, %v; want the same object tallied holds (%d)", engine.Value, err, fv.Value.Instance) + } + big := ctx.instances[fv.Value.Instance] + if !ctx.instanceConforms(big, tallied) { + t.Fatalf("the big variant's object is classified by %v, want Tallied", big.classifiers) + } + if got := readInt(t, ctx, big, "cylinders"); got != 8 { + t.Fatalf("big.cylinders = %d, want the variant's 8", got) + } + if got := readInt(t, ctx, big, "tally"); got != 7 { + t.Fatalf("big.tally = %d, want 7", got) + } + if label, err := big.GetFeatureValue(ctx, "label"); err != nil || label.Value.Str() != "kept" { + t.Fatalf("big.label = %+v, %v; want the holding feature's \"kept\"", label, err) + } + glow, ok := big.Behavior("glow") + if !ok || glow.State == nil || glow.State.stateData["cycles"].Const.Int != 1 { + t.Fatal("the big variant's object runs no glow state machine") + } +} + +// A feature valued by a chain holds the chain's last member of the objects before it: reading +// that member on a nested object — through one owner, several, or a collection — classifies +// it before its classifier's features are read, while the objects along the chain and the +// chains other features read stay untouched. +func TestChainedHoldingClassifiesTheLastMemberWhicheverIsReadFirst(t *testing.T) { + src := `package test { + private import ScalarValues::*; + item def Engine { attribute cylinders : Integer = 4; } + item def Tallied :> Engine { attribute tally : Integer = 7; } + item def Car { + variation item engine : Engine [1] { + variant item small : Engine; + variant item big : Engine { :>> cylinders = 8; } + } + } + item def Plain { item engine : Engine [1]; } + item def Lot { item parked : Plain [1]; } + item def Garage { + item car : Car { :>> engine = engine::big; } + item spare : Car { :>> engine = engine::small; } + item fleet : Plain [2]; + item lot : Lot [1]; + item tallied : Tallied [1] = car.engine; + item spared : Tallied [1] = spare.engine; + item fleeted : Tallied [2] = fleet.engine; + item deep : Tallied [1] = lot.parked.engine; + attribute isTallied : Boolean = car.engine istype Tallied; + } + item garage : Garage; + }` + engineOf := func(t *testing.T, ctx *Context, holder *Instance) *Instance { + t.Helper() + fv, err := holder.GetFeatureValue(ctx, "engine") + if err != nil { + t.Fatalf("%s.engine: %v", holder.Type.Name, err) + } + id, ok := fv.HeldValue().Object() + if !ok { + t.Fatalf("%s.engine = %s, want one object", holder.Type.Name, FormatValue(fv.HeldValue())) + } + return ctx.instances[id] + } + + for _, first := range []string{"car.engine", "tallied"} { + t.Run(first+"_first", func(t *testing.T) { + ctx, idx := libraryShapeContext(t, src) + garage := instantiateQualified(t, ctx, idx, "test::garage") + tallied := idx.LookupQualified("test::Tallied")[0] + car := readInstance(t, ctx, garage, "car") + if first == "tallied" { + if _, err := garage.GetFeatureValue(ctx, "tallied"); err != nil { + t.Fatalf("garage.tallied: %v", err) + } + } + engine := engineOf(t, ctx, car) + if !ctx.instanceConforms(engine, tallied) { + t.Fatalf("car.engine is classified by %v, want Tallied", engine.classifiers) + } + if got := readInt(t, ctx, engine, "tally"); got != 7 { + t.Fatalf("car.engine.tally = %d, want 7", got) + } + if got := readInt(t, ctx, engine, "cylinders"); got != 8 { + t.Fatalf("car.engine.cylinders = %d, want the big variant's 8", got) + } + if ctx.instanceConforms(car, tallied) { + t.Fatalf("car, an object the chain passes through, is classified by %v", car.classifiers) + } + if fv := garage.FeatureValues["tallied"]; fv == nil || !fv.Materialized { + t.Fatal("garage.tallied is not read once car.engine is") + } + if held, ok := garage.FeatureValues["tallied"].HeldValue().Object(); !ok || held != engine.ID { + t.Fatalf("garage.tallied holds %s, want car.engine (%d)", FormatValue(garage.FeatureValues["tallied"].HeldValue()), engine.ID) + } + for _, other := range []string{"spared", "fleeted", "deep"} { + if garage.FeatureValues[other].Materialized { + t.Errorf("garage.%s, a chain through other objects, is read along with car.engine", other) + } + } + if !readBool(t, ctx, garage, "isTallied") { + t.Error("car.engine istype Tallied = false") + } + }) + } + + t.Run("over_a_collection", func(t *testing.T) { + ctx, idx := libraryShapeContext(t, src) + garage := instantiateQualified(t, ctx, idx, "test::garage") + tallied := idx.LookupQualified("test::Tallied")[0] + fleet, err := garage.GetFeatureValue(ctx, "fleet") + if err != nil { + t.Fatalf("garage.fleet: %v", err) + } + for _, el := range elementsOf(fleet.HeldValue()) { + plain := ctx.instances[el.Instance] + engine := engineOf(t, ctx, plain) + if !ctx.instanceConforms(engine, tallied) { + t.Fatalf("fleet.engine is classified by %v, want Tallied", engine.classifiers) + } + if ctx.instanceConforms(plain, tallied) { + t.Fatalf("a fleet car is classified by %v", plain.classifiers) + } + } + if got := len(elementsOf(garage.FeatureValues["fleeted"].HeldValue())); got != 2 { + t.Fatalf("garage.fleeted holds %d objects, want the two engines", got) + } + }) + + t.Run("through_nested_owners", func(t *testing.T) { + ctx, idx := libraryShapeContext(t, src) + garage := instantiateQualified(t, ctx, idx, "test::garage") + tallied := idx.LookupQualified("test::Tallied")[0] + lot := readInstance(t, ctx, garage, "lot") + parked := readInstance(t, ctx, lot, "parked") + engine := engineOf(t, ctx, parked) + if !ctx.instanceConforms(engine, tallied) { + t.Fatalf("lot.parked.engine is classified by %v, want Tallied", engine.classifiers) + } + if ctx.instanceConforms(parked, tallied) || ctx.instanceConforms(lot, tallied) { + t.Fatal("an object along lot.parked.engine is classified as Tallied") + } + if held := readInstance(t, ctx, garage, "deep"); held != engine { + t.Fatalf("garage.deep holds object %d, want lot.parked.engine (%d)", held.ID, engine.ID) + } + }) +} + +// A value none of whose types is comparable with the feature's type is refused: a number, +// an object of an unrelated definition, or one already classified by one, cannot become a Segment. +func TestIncomparableValueIsRefusedByTheFeatureType(t *testing.T) { + cases := map[string]struct { + decl string + want string + }{ + "integer": { + decl: `item :>> edges [1] = (count); attribute count : Integer = 3;`, + want: "cannot write 3 (an Integer) to a feature typed by Segment", + }, + "unrelated_object": { + decl: `item :>> edges [1] = (bolt); item bolt : Bolt;`, + want: "to a feature typed by Segment", + }, + "object_classified_by_an_unrelated_definition": { + decl: `item :>> edges [1] = (raw); item raw [1]; item fastener : Bolt [1] = raw;`, + want: "to a feature typed by Segment", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Segment; + item def Bolt; + item def Loop { item edges : Segment [*]; } + item def Broken :> Loop { `+tc.decl+` } + item broken : Broken; + }`) + broken := instantiateQualified(t, ctx, idx, "test::broken") + _, err := broken.GetFeatureValue(ctx, "edges") + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("edges: %v, want ErrTypeMismatch", err) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("edges: %v, want %q in the message", err, tc.want) + } + }) + } +} + +const heldObjectModel = ` + package test { + item def Segment { item ends [2]; } + item def Bolt; + item def Rack { + item slot : Segment [0..1] = raw; + item raw [1]; + item loose [1]; + } + item rack : Rack; + item def Bin { + item pair : Segment [2] = (spare, bolt); + item spare [1]; + item bolt : Bolt; + } + item bin : Bin; + attribute def Load { item seg : Segment; } + } +` + +// isUnclassified reports whether an object still is what it was declared alone. +func isUnclassified(inst *Instance) bool { + _, ends := inst.FeatureValues["ends"] + return len(inst.classifiers) == 0 && !ends +} + +// A declared holding classifies its object and a probe undoes both; a refused value +// classifies nothing, and a write or message admits an object by what it already is. +func TestClassificationIsKeptOrUndoneWithTheChange(t *testing.T) { + ctx, idx := libraryShapeContext(t, heldObjectModel) + rack := instantiateQualified(t, ctx, idx, "test::rack") + + // Reading raw reads slot, which holds it, first. + end := ctx.beginProbe() + raw := readInstance(t, ctx, rack, "raw") + if isUnclassified(raw) { + t.Fatal("raw is not a Segment while it is held by slot") + } + end() + if !isUnclassified(raw) { + t.Fatalf("after the probe, raw is classified by %v with %v", raw.classifiers, sortedNames(raw.FeatureValues)) + } + if rack.FeatureValues["slot"].Materialized { + t.Fatal("after the probe, slot still holds its value") + } + + bin := instantiateQualified(t, ctx, idx, "test::bin") + if _, err := bin.GetFeatureValue(ctx, "pair"); !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("pair = %v, want ErrTypeMismatch: bolt is a Bolt", err) + } + for _, inst := range ctx.instances { + if !isUnclassified(inst) { + t.Fatalf("a refused value classified object %d, a %s, by %v", inst.ID, symbolText(inst.Type), inst.classifiers) + } + } + + loose := readInstance(t, ctx, rack, "loose") + looseVal := Value{Kind: ValInstance, Instance: loose.ID} + if err := rack.SetFeatureValue(ctx, "slot", looseVal); !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("SetFeatureValue(slot, loose) = %v, want ErrTypeMismatch: loose is no Segment", err) + } + load := idx.LookupQualified("test::Load")[0] + if _, err := ctx.SignalMessage(load, map[string]Value{"seg": looseVal}, rack); !errors.Is(err, ErrSignalArgument) { + t.Fatalf("SignalMessage(Load, seg = loose) = %v, want ErrSignalArgument", err) + } + if !isUnclassified(loose) { + t.Fatal("a refused write or message classified its object") + } + + raw = readInstance(t, ctx, rack, "raw") + rawVal := Value{Kind: ValInstance, Instance: raw.ID} + if isUnclassified(raw) { + t.Fatal("raw is not a Segment once slot holds it") + } + if err := rack.SetFeatureValue(ctx, "slot", rawVal); err != nil { + t.Fatalf("SetFeatureValue(slot, raw), a Segment now: %v", err) + } + if _, err := ctx.SignalMessage(load, map[string]Value{"seg": rawVal}, rack); err != nil { + t.Fatalf("SignalMessage(Load, seg = raw), a Segment now: %v", err) + } +} + +// sortedNames is the feature names an object carries, sorted. +func sortedNames(values map[string]*FeatureValue) []string { + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// A qualified name of an enclosing type's feature, written in a nested usage, reads the enclosing +// object's value (KerML 1.0 §7.4.4); one naming a declaration outside the object reads that. +func TestQualifiedOuterFeatureReadsTheEnclosingObject(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + package Consts { attribute unit : Real = 7.0; } + item def Frame { + attribute span : Real; + attribute gap : Real; + attribute scale : Real = Consts::unit; + item bar { attribute len : Real = Frame::span; } + item strut { attribute len : Real = bar.len + Frame::gap; } + } + item frame : Frame { :>> span = 4.0; :>> gap = 1.0; } + item other : Frame { :>> span = 10.0; :>> gap = 1.0; } + }`) + for _, tc := range []struct{ object, feature, want string }{ + {"test::frame", "bar.len", "4.0"}, + {"test::frame", "strut.len", "5.0"}, + {"test::frame", "scale", "7.0"}, + {"test::other", "bar.len", "10.0"}, + {"test::other", "strut.len", "11.0"}, + } { + inst := instantiateQualified(t, ctx, idx, tc.object) + path := strings.Split(tc.feature, ".") + for _, step := range path[:len(path)-1] { + inst = readInstance(t, ctx, inst, step) + } + fv, err := inst.GetFeatureValue(ctx, path[len(path)-1]) + if err != nil { + t.Fatalf("%s.%s: %v", tc.object, tc.feature, err) + } + if got := FormatTraceValue(fv.HeldValue()); got != tc.want { + t.Fatalf("%s.%s = %s, want %s", tc.object, tc.feature, got, tc.want) + } + } +} + +// A feature valued by a chain over a collection holds the chain's values from every member, +// in member order, and the members' objects are made only when the chain is read. +func TestChainValueCollectsAcrossTheCollection(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + item def Corner; + item def Side { item corners : Corner [2]; } + item def Panel { + item sides : Side [3]; + item corners : Corner [*] = sides.corners; + } + item panel : Panel; + }`) + panel := instantiateQualified(t, ctx, idx, "test::panel") + if panel.FeatureValues["sides"].Materialized || panel.FeatureValues["corners"].Materialized { + t.Fatal("sides or corners materialized on instantiation") + } + made := len(ctx.instances) + + corners, err := panel.GetFeatureValue(ctx, "corners") + if err != nil { + t.Fatalf("GetFeatureValue(corners): %v", err) + } + got := elementsOf(corners.HeldValue()) + if len(got) != 6 { + t.Fatalf("corners holds %d objects, want 6", len(got)) + } + if len(ctx.instances)-made != 9 { + t.Fatalf("reading corners made %d objects, want 3 sides and 6 corners", len(ctx.instances)-made) + } + sides, err := panel.GetFeatureValue(ctx, "sides") + if err != nil { + t.Fatalf("GetFeatureValue(sides): %v", err) + } + for i, side := range elementsOf(sides.HeldValue()) { + obj, _ := ctx.getInstance(side.Instance) + fv, err := obj.GetFeatureValue(ctx, "corners") + if err != nil { + t.Fatalf("sides.%d.corners: %v", i+1, err) + } + for j, c := range elementsOf(fv.HeldValue()) { + if want := got[2*i+j]; want.Instance != c.Instance { + t.Fatalf("corners.%d = %s, want sides.%d.corners.%d = %s", 2*i+j+1, FormatValue(want), i+1, j+1, FormatValue(c)) + } + } + } +} + +// A binding end whose path crosses a collection reaches that feature on every object of it, in +// order: the end holds their values together, and a value the other end holds on its own +// determines none of the objects' parts (KerML 1.0 §7.3.4.6, §7.4.9.2). +func TestBindingEndAcrossACollection(t *testing.T) { + // shelf fills the definition; usage redefines its features on the instance. + instance := func(shelf, usage string) string { + return `package test { + private import ScalarValues::*; + item def Thing; + item def Group { + item items : Thing [2]; + attribute weights : Real [2] = (1.0, 2.0); + attribute shares : Real [2]; + } + item def Shelf { + item groups : Group [2]; + item allItems : Thing [0..*]; + attribute allWeights : Real [0..*] nonunique; + attribute allShares : Real [0..*] = (0.1, 0.2, 0.3, 0.4); + ` + shelf + ` + } + item shelf : Shelf { ` + usage + ` } + }` + } + model := func(shelf string) string { return instance(shelf, "") } + values := func(t *testing.T, ctx *Context, inst *Instance, name string) string { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + return FormatValue(fv.HeldValue()) + } + groupsOf := func(t *testing.T, ctx *Context, shelf *Instance) []*Instance { + t.Helper() + fv, err := shelf.GetFeatureValue(ctx, "groups") + if err != nil { + t.Fatalf("groups: %v", err) + } + var groups []*Instance + for _, g := range elementsOf(fv.HeldValue()) { + obj, _ := ctx.getInstance(g.Instance) + groups = append(groups, obj) + } + return groups + } + union := func(t *testing.T, ctx *Context, groups []*Instance, name string) []Value { + t.Helper() + var all []Value + for _, g := range groups { + fv, err := g.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("group.%s: %v", name, err) + } + all = append(all, elementsOf(fv.HeldValue())...) + } + return all + } + const bound = `bind [0..*] groups.items = [0..*] allItems; + bind [0..*] groups.weights = [0..*] allWeights; + bind [0..*] groups.shares = [0..*] allShares;` + + for _, order := range []string{"union_first", "members_first"} { + t.Run(order, func(t *testing.T) { + ctx, idx := libraryShapeContext(t, model(bound)) + shelf := instantiateQualified(t, ctx, idx, "test::shelf") + if order == "members_first" { + union(t, ctx, groupsOf(t, ctx, shelf), "items") + } else if shelf.FeatureValues["groups"].Materialized { + t.Fatal("groups materialized on instantiation") + } + all, err := shelf.GetFeatureValue(ctx, "allItems") + if err != nil { + t.Fatalf("allItems: %v", err) + } + got := elementsOf(all.HeldValue()) + want := union(t, ctx, groupsOf(t, ctx, shelf), "items") + if len(got) != 4 || len(want) != 4 { + t.Fatalf("allItems holds %d objects, the groups' items %d, want 4 each", len(got), len(want)) + } + for i := range want { + if got[i].Instance != want[i].Instance { + t.Fatalf("allItems.%d = %s, want %s, the groups' items in order", i+1, FormatValue(got[i]), FormatValue(want[i])) + } + } + if got := values(t, ctx, shelf, "allWeights"); got != "[1.0, 2.0, 1.0, 2.0]" { + t.Fatalf("allWeights = %s, want the groups' weights in order", got) + } + }) + } + + // The union's own value determines none of the members' parts: a member keeps what it + // holds on its own, and is undetermined without a value of its own, in either order. + for _, order := range []string{"union_first", "member_first"} { + t.Run("members_undetermined_by_the_union_"+order, func(t *testing.T) { + ctx, idx := libraryShapeContext(t, model(bound)) + shelf := instantiateQualified(t, ctx, idx, "test::shelf") + groups := groupsOf(t, ctx, shelf) + if order == "union_first" { + if got := values(t, ctx, shelf, "allShares"); got != "[0.1, 0.2, 0.3, 0.4]" { + t.Fatalf("allShares = %s, want its own value", got) + } + } + _, err := groups[0].GetFeatureValue(ctx, "shares") + var undetermined *UndeterminedBindingError + if !errors.As(err, &undetermined) { + t.Fatalf("groups.1.shares = %v, want UndeterminedBindingError", err) + } + if want := "binding end cannot be resolved: Group.shares is bound by `bind [0..*] groups.shares = [0..*] allShares` " + + "through every object groups holds; the model does not determine which of the bound values Group.shares holds"; err.Error() != want { + t.Fatalf("error = %q\nwant %q", err.Error(), want) + } + if got := values(t, ctx, shelf, "allShares"); got != "[0.1, 0.2, 0.3, 0.4]" { + t.Fatalf("allShares = %s, want its own value", got) + } + if got := values(t, ctx, groups[1], "weights"); got != "[1.0, 2.0]" { + t.Fatalf("groups.2.weights = %s, want its own default", got) + } + }) + } + + t.Run("union_disagrees_with_its_own_value", func(t *testing.T) { + ctx, idx := libraryShapeContext(t, instance(bound, ":>> allWeights = (9.0, 9.0, 9.0, 9.0);")) + shelf := instantiateQualified(t, ctx, idx, "test::shelf") + _, err := shelf.GetFeatureValue(ctx, "allWeights") + var conflict *BindingConflictError + if !errors.As(err, &conflict) { + t.Fatalf("allWeights = %v, want BindingConflictError", err) + } + }) + + // An end's multiplicity counts the values of every object reached together: four + // weights satisfy [4] though each group holds two, and fall short of [5..*]. + t.Run("end_multiplicity_over_the_union", func(t *testing.T) { + ctx, idx := libraryShapeContext(t, model("attribute four : Real [4] nonunique; bind [4] groups.weights = [4] four;")) + shelf := instantiateQualified(t, ctx, idx, "test::shelf") + if got := values(t, ctx, shelf, "four"); got != "[1.0, 2.0, 1.0, 2.0]" { + t.Fatalf("four = %s, want the groups' weights, four in all", got) + } + ctx, idx = libraryShapeContext(t, model("bind [5..*] groups.weights = [5..*] allWeights;")) + shelf = instantiateQualified(t, ctx, idx, "test::shelf") + _, err := shelf.GetFeatureValue(ctx, "allWeights") + if !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("allWeights = %v, want ErrMultiplicityViolation", err) + } + if want := "`bind [5..*] groups.weights = [5..*] allWeights` links [5..*] of groups.weights, which holds 4 value(s)"; !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q\nwant it to contain %q", err.Error(), want) + } + }) + + t.Run("path_reaching_no_object", func(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + item def Thing; + item def Group { item items : Thing [2]; } + item def Shelf { + item groups : Group [0..*]; + item allItems : Thing [0..*]; + bind [0..*] groups.items = [0..*] allItems; + } + item shelf : Shelf; + }`) + shelf := instantiateQualified(t, ctx, idx, "test::shelf") + all, err := shelf.GetFeatureValue(ctx, "allItems") + if err != nil { + t.Fatalf("allItems: %v", err) + } + if n := len(elementsOf(all.HeldValue())); n != 0 { + t.Fatalf("allItems holds %d objects, want none: groups holds no object", n) + } + }) + + // A step of the path that holds no object, or a feature the reached objects lack, is a typed refusal. + for _, tc := range []struct{ name, shelf, want string }{ + {"non_object_step", "bind [0..*] allWeights.x = [0..*] allItems;", `binding end cannot be resolved "allWeights.x": allWeights is not an object`}, + {"missing_feature", "bind [0..*] groups.nothing = [0..*] allItems;", `binding end cannot be resolved "groups.nothing": feature nothing not found`}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, idx := libraryShapeContext(t, instance(tc.shelf, ":>> allWeights = (1.0, 2.0);")) + shelf := instantiateQualified(t, ctx, idx, "test::shelf") + _, err := shelf.GetFeatureValue(ctx, "allItems") + if !errors.Is(err, ErrBindingEnd) || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("allItems = %v, want ErrBindingEnd containing %q", err, tc.want) + } + }) + } +} + +// A collection short of its lower bound is made up through its optional subsetters first, within +// their upper bounds, and through anonymous members only past them; a required one is never made twice. +func TestOptionalSubsetterFillsTheCollection(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + item def Side; + item def Panel { + item sides : Side [4]; + item left : Side [1] :> sides; + item top : Side [0..1] :> sides; + item spare : Side [0..2] :> sides; + } + item panel : Panel; + }`) + panel := instantiateQualified(t, ctx, idx, "test::panel") + sides, err := panel.GetFeatureValue(ctx, "sides") + if err != nil { + t.Fatalf("GetFeatureValue(sides): %v", err) + } + if got := len(elementsOf(sides.HeldValue())); got != 4 { + t.Fatalf("sides holds %d objects, want 4", got) + } + top := readInstance(t, ctx, panel, "top") + spare, err := panel.GetFeatureValue(ctx, "spare") + if err != nil { + t.Fatalf("GetFeatureValue(spare): %v", err) + } + if got := len(elementsOf(spare.HeldValue())); got != 2 { + t.Fatalf("spare holds %d objects, want 2", got) + } + els := elementsOf(sides.HeldValue()) + if left := readInstance(t, ctx, panel, "left"); els[0].Instance != left.ID || els[1].Instance != top.ID { + t.Fatalf("sides = %s, want left (%d) then top (%d) first", FormatValue(sides.HeldValue()), left.ID, top.ID) + } +} + +// A probe filling an optional subsetter that already holds an object is undone whole: the +// objects made up go, and what the subsetter held before, written as it was, stays. +func TestProbeFillingASubsetterKeepsWhatItHeld(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + item def Side; + item def Panel { + item sides : Side [4]; + item left : Side [1] :> sides; + item spare : Side [0..3] :> sides; + } + item panel : Panel; + item extra : Side; + }`) + panel := instantiateQualified(t, ctx, idx, "test::panel") + extra := instantiateQualified(t, ctx, idx, "test::extra") + held := sequenceOf([]Value{{Kind: ValInstance, Instance: extra.ID}}) + if err := panel.SetFeatureValue(ctx, "spare", held); err != nil { + t.Fatalf("SetFeatureValue(spare): %v", err) + } + before := *panel.FeatureValues["spare"] + objects := len(ctx.instances) + + end := ctx.beginProbe() + sides, err := panel.GetFeatureValue(ctx, "sides") + if err != nil { + t.Fatalf("GetFeatureValue(sides): %v", err) + } + if got := len(elementsOf(sides.HeldValue())); got != 4 { + t.Fatalf("sides holds %d objects in the probe, want 4", got) + } + if got := len(elementsOf(panel.FeatureValues["spare"].HeldValue())); got != 3 { + t.Fatalf("spare holds %d objects in the probe, want extra and two made up", got) + } + end() + + if got := len(ctx.instances); got != objects { + t.Fatalf("the probe left %d objects behind", got-objects) + } + if after := *panel.FeatureValues["spare"]; !after.Materialized || !after.Written || + FormatValue(after.HeldValue()) != FormatValue(before.HeldValue()) { + t.Fatalf("the probe changed spare from %s (written) to %s (materialized %t, written %t)", + FormatValue(before.HeldValue()), FormatValue(after.HeldValue()), after.Materialized, after.Written) + } + if sides := panel.FeatureValues["sides"]; sides.Materialized { + t.Fatalf("the probe left sides materialized as %s", FormatValue(sides.HeldValue())) + } +} + +// A collection read is one change: charged whole before any object is made, and a read +// the budget refuses leaves no object, filled subsetter or charged element behind. +func TestCollectionReadRefusedByTheBudgetFillsNothing(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + item def Side; + item def Panel { + item sides : Side [4]; + item left : Side [1] :> sides; + item top : Side [0..1] :> sides; + } + item panel : Panel; + }`) + panel := instantiateQualified(t, ctx, idx, "test::panel") + left := readInstance(t, ctx, panel, "left") + objects := len(ctx.instances) + + end := ctx.beginRun() + defer end() + ctx.maxElements = 3 + _, err := panel.GetFeatureValue(ctx, "sides") + if !errors.Is(err, ErrElementLimitExceeded) { + t.Fatalf("sides under a budget of 3: %v, want ErrElementLimitExceeded", err) + } + if ctx.run.elements != 0 { + t.Fatalf("a refused read left %d elements charged", ctx.run.elements) + } + if got := len(ctx.instances); got != objects { + t.Fatalf("a refused read left %d objects behind", got-objects) + } + if top := panel.FeatureValues["top"]; len(elementsOf(top.HeldValue())) != 0 { + t.Fatalf("a refused read filled top with %s", FormatValue(top.HeldValue())) + } + + // The budget runs out at each point of the read in turn; each refusal leaves nothing behind. + ctx.maxElements = 4 + maxSteps, refused := ctx.maxSteps, 0 + var sides *FeatureValue + for extra := int64(0); sides == nil; extra++ { + ctx.maxSteps = ctx.run.steps + extra + fv, err := panel.GetFeatureValue(ctx, "sides") + ctx.maxSteps = maxSteps + if err == nil { + sides = fv + break + } + if !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("sides with %d steps: %v, want ErrStepLimitExceeded", extra, err) + } + refused++ + if got := len(ctx.instances); got != objects { + t.Fatalf("a read refused after %d steps left %d objects behind", extra, got-objects) + } + if top := panel.FeatureValues["top"]; len(elementsOf(top.HeldValue())) != 0 { + t.Fatalf("a read refused after %d steps filled top with %s", extra, FormatValue(top.HeldValue())) + } + if ctx.run.elements != 0 { + t.Fatalf("a read refused after %d steps left %d elements charged", extra, ctx.run.elements) + } + } + if refused < 3 { + t.Fatalf("only %d step budgets refused the read; the objects it makes each take one", refused) + } + els := elementsOf(sides.HeldValue()) + if top := readInstance(t, ctx, panel, "top"); len(els) != 4 || els[0].Instance != left.ID || els[1].Instance != top.ID { + t.Fatalf("sides = %s, want left (%d) then top (%d) first of four", FormatValue(sides.HeldValue()), left.ID, top.ID) + } +} + +// Types conform one way or the other (KerML 1.0 §8.4.4.4, binding connector type conformance): +// an object held by a Segment feature and a Line :> Segment feature is both, in either order; +// held by two siblings under one general type, it is refused, as the pilot warns for such a binding. +func TestClassifiersMustBeComparable(t *testing.T) { + model := `package test { + item def Common; + item def Segment :> Common; + item def Line :> Segment; + item def Bolt :> Common; + item def Rack { item raw [1]; } + item rack : Rack; + }` + for _, order := range [][2]string{{"Segment", "Line"}, {"Line", "Segment"}} { + t.Run(order[0]+"_then_"+order[1], func(t *testing.T) { + ctx, idx := libraryShapeContext(t, model) + raw := readInstance(t, ctx, instantiateQualified(t, ctx, idx, "test::rack"), "raw") + for _, name := range order { + typ := idx.LookupQualified("test::" + name)[0] + if err := ctx.classify(raw, typ); err != nil { + t.Fatalf("classify(raw, %s): %v", name, err) + } + } + for _, name := range []string{"Common", "Segment", "Line"} { + if typ := idx.LookupQualified("test::" + name)[0]; !ctx.instanceConforms(raw, typ) { + t.Fatalf("raw, a Segment and a Line, is no %s", name) + } + } + }) + } + t.Run("siblings", func(t *testing.T) { + ctx, idx := libraryShapeContext(t, model) + raw := readInstance(t, ctx, instantiateQualified(t, ctx, idx, "test::rack"), "raw") + bolt, segment := idx.LookupQualified("test::Bolt")[0], idx.LookupQualified("test::Segment")[0] + if err := ctx.classify(raw, bolt); err != nil { + t.Fatalf("classify(raw, Bolt): %v", err) + } + if ctx.canClassify(raw, segment) { + t.Fatal("a Bolt can become a Segment, though neither conforms to the other") + } + if !ctx.canClassify(raw, idx.LookupQualified("test::Common")[0]) { + t.Fatal("a Bolt cannot be held as the Common it already is") + } + }) +} + +// A classification the classifier's body makes impossible (two names of one redefined +// feature valued) is refused whole outside any probe: the object keeps what it had. +func TestRefusedClassificationLeavesTheObjectAsItWas(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Ring { attribute ringCost : Real; } + item def Band :> Ring { attribute bandCost :>> ringCost = 400.0; attribute :>> ringCost = 500.0; } + item def Rack { item raw [1]; } + item rack : Rack; + }`) + rack := instantiateQualified(t, ctx, idx, "test::rack") + raw := readInstance(t, ctx, rack, "raw") + before := sortedNames(raw.FeatureValues) + + band := idx.LookupQualified("test::Band")[0] + if err := ctx.classify(raw, band); !errors.Is(err, ErrConflictingRedefinition) { + t.Fatalf("classify(raw, Band) = %v, want ErrConflictingRedefinition", err) + } + if len(raw.classifiers) != 0 { + t.Fatalf("a refused classification left raw classified by %v", raw.classifiers) + } + if after := sortedNames(raw.FeatureValues); strings.Join(after, ",") != strings.Join(before, ",") { + t.Fatalf("a refused classification changed raw's features from %v to %v", before, after) + } +} + +// An object classified by a type exhibiting a behavior runs it (KerML 1.0 §7.4.9), once, +// however many features hold it; a probe undoes the run with the holding. +func TestClassificationStartsTheClassifierBehaviors(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + state def Glowing { + attribute cycles : Integer = 0; + entry; then lit; + state lit { entry action count { assign cycles := cycles + 1; } } + } + part def Lamp { exhibit state glow : Glowing; } + part def Room { + part lamp : Lamp [1] = bulb; + part spare : Lamp [1] = bulb; + part bulb [1]; + } + part room : Room; + }`) + room := instantiateQualified(t, ctx, idx, "test::room") + + end := ctx.beginProbe() + bulb := readInstance(t, ctx, room, "lamp") + if _, ok := bulb.Behavior("glow"); !ok { + t.Fatal("bulb, a Lamp while lamp holds it, runs no glow behavior") + } + end() + if len(bulb.behaviors) != 0 || len(ctx.objectBehaviors) != 0 { + t.Fatalf("after the probe, bulb runs %d behaviors and the context %d", len(bulb.behaviors), len(ctx.objectBehaviors)) + } + + if bulb = readInstance(t, ctx, room, "lamp"); readInstance(t, ctx, room, "spare") != bulb { + t.Fatal("lamp and spare hold different objects") + } + if len(bulb.behaviors) != 1 { + t.Fatalf("bulb, held by two Lamp features, runs %d behaviors, want 1", len(bulb.behaviors)) + } + behavior, ok := bulb.Behavior("glow") + if !ok || behavior.State == nil { + t.Fatal("bulb runs no glow state machine") + } + if got := behavior.State.stateData["cycles"].Const.Int; got != 1 { + t.Fatalf("glow ran to cycles = %d, want 1", got) + } +} + +// A classification whose behaviors fail to start is undone whole: what a behavior wrote to +// a feature the object already had goes with the features and behaviors it added. +func TestRefusedClassificationUndoesWhatItsBehaviorsDid(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Counter { attribute hits : Integer = 0; } + item def Tallied :> Counter { + exhibit state tally { + entry; then on; + state on { entry action bump { assign hits := hits + 1; } } + } + exhibit state break { + entry; then on; + state on { entry action fail { assign hits := 1 / 0; } } + } + } + item def Rack { item raw : Counter [1]; } + item rack : Rack; + }`) + rack := instantiateQualified(t, ctx, idx, "test::rack") + raw := readInstance(t, ctx, rack, "raw") + hits, err := raw.GetFeatureValue(ctx, "hits") + if err != nil { + t.Fatalf("hits: %v", err) + } + before := FormatValue(hits.HeldValue()) + + if err := ctx.classify(raw, idx.LookupQualified("test::Tallied")[0]); !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("classify(raw, Tallied) = %v, want ErrDivisionByZero", err) + } + if len(raw.classifiers) != 0 || len(raw.behaviors) != 0 || len(ctx.objectBehaviors) != 0 { + t.Fatalf("a refused classification left raw classified by %v running %d behaviors (context: %d)", + raw.classifiers, len(raw.behaviors), len(ctx.objectBehaviors)) + } + if _, ok := raw.FeatureValues["tally"]; ok { + t.Fatal("a refused classification left raw with the tally feature") + } + if after := FormatValue(hits.HeldValue()); raw.FeatureValues["hits"] != hits || after != before { + t.Fatalf("a refused classification changed hits from %s to %s", before, after) + } +} + +// A variant a refused classification's behavior selected goes with it: a value variant +// stands for no object to abandon, so the selection itself is undone. +func TestRefusedClassificationUndoesTheVariantsItSelected(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Counter { attribute hits : Integer = 0; } + item def Car { + variation attribute power : Real { + variant attribute strong = 150.0; + variant attribute weak = 120.0; + } + } + item def Tallied :> Counter { + item car : Car [1] { attribute :>> power = power::strong; } + exhibit state tally { + entry; then on; + state on { entry action bump { assign hits := car.power / hits; } } + } + } + item def Rack { item raw : Counter [1]; item own : Car [1] { attribute :>> power = power::weak; } } + item rack : Rack; + }`) + rack := instantiateQualified(t, ctx, idx, "test::rack") + raw := readInstance(t, ctx, rack, "raw") + own := readInstance(t, ctx, rack, "own") + if _, err := own.GetFeatureValue(ctx, "power"); err != nil { + t.Fatalf("own.power: %v", err) + } + before := maps.Clone(ctx.selectedVariants) + if got := before[variantSelection{owner: own.ID, variation: "power"}]; got != "weak" { + t.Fatalf("own selected %q before classifying, want weak", got) + } + + if err := ctx.classify(raw, idx.LookupQualified("test::Tallied")[0]); !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("classify(raw, Tallied) = %v, want ErrDivisionByZero", err) + } + if !maps.Equal(ctx.selectedVariants, before) { + t.Fatalf("a refused classification changed the variants selected from %v to %v", before, ctx.selectedVariants) + } +} + +// A collection is classified whole: when a later object's classification fails, the objects +// classified before it are left as they were, behaviors, writes and all. +func TestRefusedCollectionClassificationUndoesTheEarlierObjects(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Counter { attribute hits : Rational; } + item def Tallied :> Counter { + exhibit state tally { + entry; then on; + state on { entry action bump { assign hits := 10 / hits; } } + } + } + item def Rack { + item lead : Counter [1] { :>> hits = 1; } + item trail : Counter [1] { :>> hits = 0; } + item tallied : Tallied [2] = (lead, trail); + } + item rack : Rack; + }`) + rack := instantiateQualified(t, ctx, idx, "test::rack") + + // lead is classified before trail's behavior fails; the read materializes it all the same. + if _, err := rack.GetFeatureValue(ctx, "tallied"); !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("rack.tallied = %v, want ErrDivisionByZero", err) + } + held := rack.FeatureValues["lead"].HeldValue() + lead, ok := ctx.getInstance(held.Instance) + if held.Kind != ValInstance || !ok { + t.Fatalf("lead = %s, want the object the failed read materialized", FormatValue(held)) + } + if len(lead.classifiers) != 0 || len(lead.behaviors) != 0 || len(ctx.objectBehaviors) != 0 { + t.Fatalf("a refused collection left lead classified by %v running %d behaviors (context: %d)", + lead.classifiers, len(lead.behaviors), len(ctx.objectBehaviors)) + } + if _, ok := lead.FeatureValues["tally"]; ok { + t.Fatal("a refused collection left lead with the tally feature") + } + if hits := lead.FeatureValues["hits"]; hits.Written { + t.Fatalf("a refused collection left lead.hits written to %s", FormatValue(hits.HeldValue())) + } +} + +// The objects a classification's behaviors make go with it when it is undone: a refused +// collection leaves the context holding what it held, however often the read is retried. +func TestRefusedCollectionClassificationAbandonsWhatItsBehaviorsMade(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Counter { attribute hits : Rational; } + item def Gauge { attribute reading : Integer = 1; } + item def Tallied :> Counter { + item gauge : Gauge [1]; + exhibit state tally { + entry; then on; + state on { entry action bump { assign hits := gauge.reading / hits; } } + } + } + item def Rack { + item lead : Counter [1] { :>> hits = 1; } + item trail : Counter [1] { :>> hits = 0; } + item tallied : Tallied [2] = (lead, trail); + } + item rack : Rack; + }`) + rack := instantiateQualified(t, ctx, idx, "test::rack") + + // The read materializes rack's lead and trail, which stay; lead's gauge, made by the + // tally lead ran as a Tallied, does not. + var held []int64 + var occurrences map[*symbols.Symbol][]int64 + for attempt := 1; attempt <= 3; attempt++ { + if _, err := rack.GetFeatureValue(ctx, "tallied"); !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("attempt %d: rack.tallied = %v, want ErrDivisionByZero", attempt, err) + } + if attempt == 1 { + held, occurrences = ctx.InstanceIDs(), maps.Clone(ctx.occurrences) + if len(held) != 3 { + t.Fatalf("a refused collection left the objects %v, want rack, lead and trail only", held) + } + } + if after := ctx.InstanceIDs(); !slices.Equal(after, held) { + t.Fatalf("attempt %d: a refused collection changed the objects held from %v to %v", attempt, held, after) + } + if !maps.EqualFunc(ctx.occurrences, occurrences, slices.Equal[[]int64]) { + t.Fatalf("attempt %d: a refused collection changed the occurrences held to %v", attempt, ctx.occurrences) + } + if len(ctx.created) != len(held) || len(ctx.objectBehaviors) != 0 { + t.Fatalf("attempt %d: a refused collection left %d creations and %d behaviors", + attempt, len(ctx.created), len(ctx.objectBehaviors)) + } + for _, name := range []string{"lead", "trail"} { + inst, ok := ctx.getInstance(rack.FeatureValues[name].HeldValue().Instance) + if !ok { + t.Fatalf("attempt %d: %s holds no object", attempt, name) + } + if _, ok := inst.FeatureValues["gauge"]; ok || len(inst.classifiers) != 0 || len(inst.behaviors) != 0 { + t.Fatalf("attempt %d: a refused collection left %s classified by %v running %d behaviors", + attempt, name, inst.classifiers, len(inst.behaviors)) + } + } + } +} + +// A classifier redefining a feature the object carries refines it (KerML 1.0 §7.3.4.5): the +// object reads the redefinition's default, type and multiplicity; what it held is kept where +// the redefinition admits it, and refused whole where it does not. +func TestNarrowerClassifierRefinesTheCarriedFeatures(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Engine; + item def V8 :> Engine; + item def Car { + attribute doors : Integer default = 4; + attribute tags : String [0..2] default = ("a", "b"); + item engine : Engine [1]; + } + item def Coupe :> Car { attribute :>> doors default = 2; item :>> engine : V8; } + item def Tagged :> Car { attribute :>> tags : String [1]; } + item def Rack { + item raw : Car [1]; + item sporty : Car [1] { attribute :>> doors default = 3; } + item coupe : Coupe [1] = raw; + item coupe2 : Coupe [1] = sporty; + } + item rack : Rack; + item car : Car; + }`) + rack := instantiateQualified(t, ctx, idx, "test::rack") + raw := readInstance(t, ctx, rack, "raw") + coupe := idx.LookupQualified("test::Coupe")[0] + if !ctx.instanceConforms(raw, coupe) { + t.Fatalf("raw held by coupe is classified by %v, want Coupe", raw.classifiers) + } + if doors := readInt(t, ctx, raw, "doors"); doors != 2 { + t.Errorf("raw.doors = %d as a Coupe, want Coupe's default 2", doors) + } + if feat := raw.FeatureValues["doors"].Feature; feat.OwnerType != coupe { + t.Errorf("raw.doors reads %s's declaration, want Coupe's", feat.OwnerType.Name) + } + if feat := raw.FeatureValues["tags"].Feature; feat.OwnerType == coupe { + t.Error("raw.tags, which Coupe does not redefine, reads a declaration of Coupe") + } + engine := readInstance(t, ctx, raw, "engine") + if v8 := idx.LookupQualified("test::V8")[0]; !ctx.instanceConforms(engine, v8) { + t.Errorf("raw.engine held by a Coupe's engine : V8 is classified by %v, want V8", engine.classifiers) + } + + // A usage's own redefinition is not refined by a classifier redefining what it redefines. + sporty := readInstance(t, ctx, rack, "sporty") + if doors := readInt(t, ctx, sporty, "doors"); doors != 3 { + t.Errorf("sporty.doors = %d as a Coupe, want the usage's own default 3", doors) + } + + // A written value stays through the refinement, read by the refining declaration. + car := instantiateQualified(t, ctx, idx, "test::car") + if err := car.SetFeatureValue(ctx, "doors", integerValue(5)); err != nil { + t.Fatalf("write car.doors: %v", err) + } + if err := ctx.classify(car, coupe); err != nil { + t.Fatalf("classify(car, Coupe): %v", err) + } + if doors := car.FeatureValues["doors"]; !doors.Written || doors.HeldValue().Const.Int != 5 || doors.Feature.OwnerType != coupe { + t.Errorf("car.doors = %s (written %t, declared by %s) after classifying, want the written 5 read by Coupe's declaration", + FormatValue(doors.HeldValue()), doors.Written, doors.Feature.OwnerType.Name) + } + + // A held value the refined declaration does not admit refuses the classification whole. + if err := car.SetFeatureValue(ctx, "tags", sequenceOf([]Value{NewStringValue("x"), NewStringValue("y")})); err != nil { + t.Fatalf("write car.tags: %v", err) + } + if err := ctx.classify(car, idx.LookupQualified("test::Tagged")[0]); !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("classify(car, Tagged) = %v, want ErrMultiplicityViolation for two values in tags [1]", err) + } + if len(car.classifiers) != 1 || car.FeatureValues["tags"].Feature.OwnerType == idx.LookupQualified("test::Tagged")[0] { + t.Errorf("a refused classification left car classified by %v reading tags from %s", + car.classifiers, car.FeatureValues["tags"].Feature.OwnerType.Name) + } +} + +// An object's relationships are those of every type classifying it — declared type first, +// then each classifier — and one a classifier inherits from a type already counted is not listed twice. +func TestRelationshipsComeFromEveryTypeOfTheObject(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Base { + attribute a : Integer; + attribute b : Integer = 1; + bind a = b; + attribute xs : Integer [*]; + attribute x1 : Integer [*] :> xs = (1); + item p; item q; + connect p to q; + } + item def Wide :> Base { + attribute c : Integer = 2; + bind a = c; + attribute x2 : Integer [*] :> xs = (2); + item r; + connect q to r; + } + item def Rack { item raw : Base [1]; } + item rack : Rack; + }`) + raw := readInstance(t, ctx, instantiateQualified(t, ctx, idx, "test::rack"), "raw") + if err := ctx.classify(raw, idx.LookupQualified("test::Wide")[0]); err != nil { + t.Fatalf("classify(raw, Wide): %v", err) + } + bindings := ctx.bindingsOf(raw, "a") + if len(bindings) != 2 || bindings[0].Ends[1].Path != "b" || bindings[1].Ends[1].Path != "c" { + t.Fatalf("bindings of a = %+v, want Base's a = b then Wide's a = c", bindings) + } + var subsetters []string + for _, feat := range ctx.subsettingFeaturesOf(raw, "xs") { + subsetters = append(subsetters, feat.Name) + } + if strings.Join(subsetters, ",") != "x1,x2" { + t.Fatalf("subsetters of xs = %v, want x1 then x2", subsetters) + } + var conns []string + for _, conn := range ctx.connectionsOf(raw) { + if ends := strings.Join(conn.Ends, "-"); ends == "p-q" || ends == "q-r" { + conns = append(conns, ends) + } + } + if strings.Join(conns, ",") != "p-q,q-r" { + t.Fatalf("connections = %v, want Base's p-q then Wide's q-r, each once", conns) + } + if anon := ctx.anonymousConnectorsOf(raw.types()); len(anon) != 4 { + t.Fatalf("anonymous connectors = %d, want Base's and Wide's bindings and connections", len(anon)) + } +} + +// Two comparable classifiers declaring one name without redefinition read the narrower +// type's declaration, whichever was added first — as an object created as that type would. +func TestSameNameFeaturesOfClassifiersReadTheNarrowerDeclaration(t *testing.T) { + const model = `package test { + private import ScalarValues::*; + item def Common; + item def A :> Common { attribute n : Real = 1.0; } + item def B :> A { attribute n : Real = 2.0; } + item def C :> A { attribute n : Integer [2] = (3, 4); } + item def Rack { item raw [1]; } + item rack : Rack; + }` + classified := func(t *testing.T, ctx *Context, idx *symbols.Index, order ...string) *Instance { + t.Helper() + raw := readInstance(t, ctx, instantiateQualified(t, ctx, idx, "test::rack"), "raw") + for _, name := range order { + if err := ctx.classify(raw, idx.LookupQualified("test::" + name)[0]); err != nil { + t.Fatalf("classify(raw, %s) after %v: %v", name, order, err) + } + } + return raw + } + for _, order := range [][]string{{"A", "B"}, {"B", "A"}} { + ctx, idx := libraryShapeContext(t, model) + raw := classified(t, ctx, idx, order...) + fv, err := raw.GetFeatureValue(ctx, "n") + if err != nil { + t.Fatalf("%v: n: %v", order, err) + } + if got := realValue(t, fv.HeldValue()); got != 2.0 || fv.Feature.OwnerType != idx.LookupQualified("test::B")[0] { + t.Errorf("%v: n = %v read by %s, want B's default 2.0 read by B's declaration", order, got, fv.Feature.OwnerType.Name) + } + } + for _, order := range [][]string{{"A", "C"}, {"C", "A"}} { + ctx, idx := libraryShapeContext(t, model) + raw := classified(t, ctx, idx, order...) + fv, err := raw.GetFeatureValue(ctx, "n") + if err != nil { + t.Fatalf("%v: n: %v", order, err) + } + if got := FormatValue(fv.HeldValue()); got != "[3, 4]" || fv.Feature.OwnerType != idx.LookupQualified("test::C")[0] { + t.Errorf("%v: n = %s read by %s, want C's [3, 4] read by C's declaration", order, got, fv.Feature.OwnerType.Name) + } + } + + // A held value the narrower declaration does not admit refuses the classification, leaving the object as it was. + ctx, idx := libraryShapeContext(t, model) + raw := classified(t, ctx, idx, "A") + if err := raw.SetFeatureValue(ctx, "n", Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: 1.5}}); err != nil { + t.Fatalf("write raw.n: %v", err) + } + err := ctx.classify(raw, idx.LookupQualified("test::C")[0]) + if !errors.Is(err, ErrMultiplicityViolation) && !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("classify(raw, C) with n = 1.5 = %v, want a typed refusal of a Real in n : Integer [2]", err) + } + fv := raw.FeatureValues["n"] + if len(raw.classifiers) != 1 || fv.Feature.OwnerType != idx.LookupQualified("test::A")[0] || realValue(t, fv.HeldValue()) != 1.5 { + t.Errorf("refused classification left raw classified by %v with n = %s read by %s, want A's declaration holding 1.5", + raw.classifiers, FormatValue(fv.HeldValue()), fv.Feature.OwnerType.Name) + } +} + +// Walks over an object's feature values cover the features its classifiers add — their defaults, +// their errors and the objects they hold — after the declared type's, each shared value once. +func TestObjectWalksCoverTheFeaturesClassifiersAdd(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Common; + item def Base { attribute mass : Real; } + item def Loaded :> Base { + attribute grossMass :>> mass = (1.0, 2.0); + attribute bad : Integer [1] = (5, 6); + item inner : Common; + } + item def Rack { item raw : Base [1]; } + item rack : Rack; + }`) + raw := readInstance(t, ctx, instantiateQualified(t, ctx, idx, "test::rack"), "raw") + if errs, _ := ctx.MaterializationErrors(raw); len(errs) != 0 { + t.Fatalf("MaterializationErrors as a Base = %v, want none", errs) + } + if nested := ctx.nestedObjects(raw); len(nested) != 0 { + t.Fatalf("nestedObjects as a Base = %d objects, want none", len(nested)) + } + if err := ctx.classify(raw, idx.LookupQualified("test::Loaded")[0]); err != nil { + t.Fatalf("classify(raw, Loaded): %v", err) + } + + var names []string + for _, of := range ctx.FeaturesOfObject(raw) { + names = append(names, of.Name) + } + if last := len(names) - 3; last < 1 || names[0] != "mass" || !slices.Equal(names[last:], []string{"grossMass", "bad", "inner"}) { + t.Errorf("FeaturesOfObject = %v, want the declared type's features then the classifier's grossMass, bad, inner", names) + } + errs, _ := ctx.MaterializationErrors(raw) + if len(errs) != 2 { + t.Fatalf("MaterializationErrors as a Loaded = %v, want mass/grossMass once and bad once", errs) + } + for _, err := range errs { + if !errors.Is(err, ErrMultiplicityViolation) { + t.Errorf("err = %v, want ErrMultiplicityViolation", err) + } + } + nested := ctx.nestedObjects(raw) + if len(nested) != 1 || nested[0].feature != "inner" || !ctx.instanceConforms(nested[0].instance, idx.LookupQualified("test::Common")[0]) { + t.Errorf("nestedObjects as a Loaded = %v, want the Common held by inner", nested) + } +} + +// A classifier's behavior writes the features the classifier itself declares: the +// performer is every type it is classified by, not only the one it was created as. +func TestClassifierBehaviorsWriteTheFeaturesTheClassifierAdds(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Counter { attribute hits : Integer = 0; } + item def Tallied :> Counter { + attribute tallies : Integer = 0; + exhibit state tally { + entry; then on; + state on { entry action bump { assign tallies := tallies + 1; } } + } + } + item def Rack { item raw : Counter [1]; } + item rack : Rack; + }`) + raw := readInstance(t, ctx, instantiateQualified(t, ctx, idx, "test::rack"), "raw") + if err := ctx.classify(raw, idx.LookupQualified("test::Tallied")[0]); err != nil { + t.Fatalf("classify(raw, Tallied): %v", err) + } + if tallies := readInt(t, ctx, raw, "tallies"); tallies != 1 { + t.Errorf("raw.tallies = %d after Tallied's behavior ran, want 1", tallies) + } +} + +// istype and hastype judge an object by every type it is classified by (KerML 1.0 +// §7.4.9): hastype exactly, istype by conformance. +func TestTypePredicatesSeeTheClassifiersOfAnObject(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Curve; + item def Line :> Curve; + item def Segment :> Line; + item def Shape { + item raw [1]; + item loose [1]; + item edge : Line [1] = raw; + attribute looseIsLine = loose istype Line; + attribute isLine = raw istype Line; + attribute isCurve = raw istype Curve; + attribute isSegment = raw istype Segment; + attribute hasLine = raw hastype Line; + attribute hasCurve = raw hastype Curve; + attribute hasSegment = raw hastype Segment; + } + item shape : Shape; + }`) + shape := instantiateQualified(t, ctx, idx, "test::shape") + if readInstance(t, ctx, shape, "edge") != readInstance(t, ctx, shape, "raw") { + t.Fatal("edge holds an object other than raw") + } + for name, want := range map[string]bool{ + "looseIsLine": false, + "isLine": true, "isCurve": true, "isSegment": false, + "hasLine": true, "hasCurve": false, "hasSegment": false, + } { + if got := readBool(t, ctx, shape, name); got != want { + t.Errorf("%s = %t once edge : Line holds raw, want %t", name, got, want) + } + } +} + +// A selected variant is exactly the variant chosen, and its object is judged by each type +// it is classified by once a typed feature holds it, like any object. +func TestTypePredicatesSeeTheTypesOfASelectedVariantObject(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + item def Engine { attribute cylinders : Integer = 4; } + item def Tallied :> Engine { attribute tally : Integer = 7; } + item def Car { + variation item engine : Engine [1] { + variant item small : Engine; + variant item big : Engine { :>> cylinders = 8; } + } + } + item def Garage { + item car : Car { :>> engine = engine::big; } + item tallied : Tallied [1] = car.engine; + attribute isEngine = car.engine istype Engine; + attribute hasEngine = car.engine hastype Engine; + attribute isTallied = car.engine istype Tallied; + attribute hasTallied = car.engine hastype Tallied; + attribute hasCar = car.engine hastype Car; + attribute isData = car.engine istype ScalarValues::ScalarValue; + } + item garage : Garage; + }`) + garage := instantiateQualified(t, ctx, idx, "test::garage") + fv, err := garage.GetFeatureValue(ctx, "tallied") + if err != nil || fv.Value.Kind != ValVariant || fv.Value.Instance == 0 { + t.Fatalf("garage.tallied = %+v, %v; want the materialized big variant", fv, err) + } + for name, want := range map[string]bool{ + "isEngine": true, "hasEngine": false, + "isTallied": true, "hasTallied": true, + "hasCar": false, "isData": false, + } { + if got := readBool(t, ctx, garage, name); got != want { + t.Errorf("%s = %t once tallied : Tallied holds the big variant, want %t", name, got, want) + } + } +} + +// A type an object already conforms to declares nothing it lacks, but a feature of that +// type holding it still makes it a direct type of the object: hastype answers alike in +// either classification order, and classifying by a type twice records it once. +func TestHoldingByAWiderTypeRecordsItAsADirectType(t *testing.T) { + for _, order := range [][]string{{"Line", "Segment"}, {"Segment", "Line"}} { + t.Run(strings.Join(order, "-then-"), func(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + item def Curve; + item def Line :> Curve { attribute slope : Real; } + item def Segment :> Line { attribute span : Real; } + item def Rack { item raw [1]; } + item rack : Rack; + }`) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + raw := readInstance(t, ctx, instantiateQualified(t, ctx, idx, "test::rack"), "raw") + for _, name := range order { + if err := ctx.classify(raw, idx.LookupQualified("test::" + name)[0]); err != nil { + t.Fatalf("classify(raw, %s): %v", name, err) + } + } + features := len(raw.FeatureValues) + if err := ctx.classify(raw, idx.LookupQualified("test::Line")[0]); err != nil { + t.Fatalf("classify(raw, Line) again: %v", err) + } + if len(raw.classifiers) != 2 || len(raw.FeatureValues) != features { + t.Fatalf("classifiers = %v with %d feature values after classifying by Line again, want Line and Segment once with %d", + raw.classifiers, len(raw.FeatureValues), features) + } + for _, name := range []string{"slope", "span"} { + if _, ok := raw.FeatureValues[name]; !ok { + t.Errorf("raw carries no %s", name) + } + } + for src, want := range map[string]bool{ + "rack.raw hastype Line": true, "rack.raw hastype Segment": true, + "rack.raw hastype Curve": false, "rack.raw istype Curve": true, + } { + val, err := evalIn(t, ctx, pkg.Scope, src) + if err != nil || val.Kind != ValConst || val.Const.Kind != semantics.ValBool { + t.Fatalf("%s = %s, %v; want a Boolean", src, FormatValue(val), err) + } + if val.Const.Bool != want { + t.Errorf("%s = %t, want %t", src, val.Const.Bool, want) + } + } + }) + } +} + +// Membership in an enumeration is decided by equality with its enumerated values, its only +// instances (SysML v2 §8.3.7); hastype reads the value's own type alone (KerML 1.0 §7.4.9.2). +func TestEnumerationClassifiesByItsEnumeratedValues(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + metadata def Hot; + enum def Level :> Integer { low = 1; high = 3 { attribute n = 9; @Hot; } } + enum def Grade :> Real { a = 4.0; b = 3.0; } + enum def Color { red; green; blue; } + enum def Rank :> Integer { one = 1; three = 3; } + enum def Size :> Real { = 60.0; = 70.0; } + enum def Wide :> Size { = 80.0; } + attribute def Even :> Integer; + attribute two : Integer = 2; + attribute three : Integer = 3; + attribute lvl : Level = Level::high; + attribute held : Level = three; + attribute cast : Level[0..1] = 3 as Level; + attribute ranked : Rank = Level::high; + attribute c : Color = Color::red; + calc def isLevel { in x : Level; return : Boolean = x hastype Level; } + calc def asLevel { in n : Integer; return : Level = n; } + calc def viaBody { in n : Integer; attribute doubled = n + n; return : Level = doubled - n; } + }`) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + for src, want := range map[string]bool{ + "3 istype Level": true, "2 istype Level": false, "three istype Level": true, "two istype Level": false, + "3 @ Level": true, "2 @ Level": false, "(1, 2, 3) @ Level": true, "(2, 4) @ Level": false, + "3 hastype Level": false, "3 hastype Integer": true, "three hastype Level": false, + "Level::high hastype Level": true, "Level::high hastype Integer": false, + "Level::high istype Integer": true, "Level::high istype Level": true, "Level::high istype Even": false, + "lvl hastype Level": true, "lvl hastype Integer": false, "lvl istype Integer": true, + "held hastype Level": true, "cast hastype Level": true, + "Level::high istype Rank": true, "Level::high hastype Rank": false, + "(Level::high as Rank) hastype Rank": true, "(Level::high as Rank) hastype Level": false, + "ranked hastype Rank": true, "ranked hastype Level": false, "Level::low istype Rank": true, + "60.0 istype Size": true, "65.0 istype Size": false, "(60.0 as Size) hastype Size": true, "60.0 hastype Size": false, + "60.0 istype Wide": true, "80.0 istype Wide": true, "65.0 istype Wide": false, "(60.0 as Wide) istype Wide": true, "(60.0 as Wide) hastype Size": true, + "(3 as Level) hastype Level": true, "(3 as Level) hastype Integer": false, "(3 as Level) == 3": true, + "Level::high == 3": true, "Level::high + 1 == 4": true, "(Level::high + 1) hastype Integer": true, + "4 istype Grade": true, "4.0 istype Grade": true, "2.5 istype Grade": false, "3 istype Grade": true, + "Grade::a hastype Grade": true, "Grade::a hastype Real": false, + "3 istype Color": false, "Color::red istype Color": true, "Color::red hastype Color": true, + "c hastype Color": true, "Color::red istype Level": false, "Level::high istype Color": false, + "(Color::red as Color) hastype Color": true, + "isLevel(3)": true, "isLevel(three)": true, "asLevel(3) hastype Level": true, "asLevel(3) hastype Integer": false, + "viaBody(3) hastype Level": true, "viaBody(3) hastype Integer": false, + "Level::high.n == 9": true, "lvl.n == 9": true, "(3 as Level).n == 9": true, + "Level::high @ Hot": true, "Level::low @ Hot": false, + "Level::high === 3": false, "Level::high === Level::high": true, "Level::high === Rank::three": false, + "lvl === Level::high": true, "(3 as Level) === Level::high": true, "Level::high !== 3": true, + } { + val, err := evalIn(t, ctx, pkg.Scope, src) + if err != nil || val.Kind != ValConst || val.Const.Kind != semantics.ValBool { + t.Fatalf("%s = %s, %v; want a Boolean", src, FormatValue(val), err) + } + if val.Const.Bool != want { + t.Errorf("%s = %t, want %t", src, val.Const.Bool, want) + } + } + for src, want := range map[string]string{ + "3 as Level": "3", "2 as Level": "[]", "(1, 2, 3, 4) as Level": "[1, 3]", "three as Level": "3", + "Color::red as Color": "Color::red", "Color::red as Level": "[]", "4 as Grade": "4.0", + } { + val, err := evalIn(t, ctx, pkg.Scope, src) + if err != nil { + t.Fatalf("%s: %v", src, err) + } + if got := FormatValue(val); got != want { + t.Errorf("%s = %s, want %s", src, got, want) + } + } + if _, err := evalIn(t, ctx, pkg.Scope, "5 as Even"); !errors.Is(err, ErrUndecidedClassification) { + t.Errorf("5 as Even: %v, want ErrUndecidedClassification", err) + } + for _, src := range []string{"isLevel(2)", "asLevel(2)"} { + if _, err := evalIn(t, ctx, pkg.Scope, src); !errors.Is(err, ErrTypeMismatch) { + t.Errorf("%s: %v, want ErrTypeMismatch", src, err) + } + } +} + +// A feature typed by an enumeration admits an enumerated value, held as the literal it +// equals, and refuses any other by the write-conformance rule. +func TestEnumerationTypedFeatureAdmitsOnlyEnumeratedValues(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package test { + private import ScalarValues::*; + enum def Level :> Integer { low = 1; high = 3; } + attribute two : Integer = 2; + attribute three : Integer = 3; + part def Dial { + attribute setting : Level = three; + attribute isHigh = setting hastype Level; + } + part def Broken { attribute setting : Level = two; } + part dial : Dial; + part broken : Broken; + }`) + dial := instantiateQualified(t, ctx, idx, "test::dial") + if got := readInt(t, ctx, dial, "setting"); got != 3 { + t.Fatalf("dial.setting = %d, want 3", got) + } + if !readBool(t, ctx, dial, "isHigh") { + t.Error("dial.setting hastype Level = false once 3 is held as a Level") + } + broken, err := ctx.Instantiate(idx.LookupQualified("test::broken")[0]) + if err == nil { + _, err = broken.GetFeatureValue(ctx, "setting") + } + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("broken.setting = 2: %v, want ErrTypeMismatch", err) + } + if !strings.Contains(err.Error(), "Level") { + t.Errorf("error = %v, want the feature's type named", err) + } +} diff --git a/internal/exec/runtime/clock.go b/internal/exec/runtime/clock.go new file mode 100644 index 0000000000..1ff7a56c47 --- /dev/null +++ b/internal/exec/runtime/clock.go @@ -0,0 +1,375 @@ +package runtime + +import ( + "cmp" + "errors" + "fmt" + "math" + "slices" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// secondFQN names the unit the clock counts in, so a duration carrying a unit +// is expressed in seconds before it is scheduled. +const secondFQN = "SI::s" + +// Clock is the simulation time every executor of one context shares: the current +// instant in seconds and the executors whose waits it reads in due order. +type Clock struct { + now float64 + waiters []clockWaiter + // step is the instant grid the waits come due on, in seconds; 0 is a + // continuous clock, on which a wait comes due exactly when it ends. + step float64 +} + +// ClockWait describes one wait on the clock for a view of the run. +type ClockWait struct { + // Due is the instant the wait comes due at, in seconds. + Due float64 + // holder is the executor waiting, what the wait itself; both are described + // only when a view asks, so scheduling on Due does not format labels. + holder dueHolder + what fmt.Stringer +} + +// dueHolder is an executor that names itself in a due-order choice. +type dueHolder interface { + dueLabel() string +} + +// Holder names the executor waiting. +func (w ClockWait) Holder() string { + if w.holder == nil { + return "" + } + return w.holder.dueLabel() +} + +// What describes the wait itself. +func (w ClockWait) What() string { + if w.what == nil { + return "" + } + return w.what.String() +} + +// Now returns the current simulation instant, in seconds. +func (c *Clock) Now() float64 { + return c.now +} + +// Step is the step the clock advances by, in seconds: waits come due at the +// first multiple of it not before they end; 0 is a continuous clock. +func (c *Clock) Step() float64 { + return c.step +} + +// ErrClockStep is the typed error a clock step that is not a finite, +// non-negative number is refused with. +var ErrClockStep = errors.New("invalid clock step") + +// CheckClockStep is ErrClockStep unless step is a finite, non-negative number of seconds. +func CheckClockStep(step float64) error { + if math.IsNaN(step) || math.IsInf(step, 0) || step < 0 { + return fmt.Errorf("%w: a clock steps by a finite, non-negative number of seconds, not %s", ErrClockStep, semantics.FormatReal(step)) + } + return nil +} + +// SetClockStep makes the waits set from now on come due at multiples of step +// seconds, as a simulation clock ticking by step does: a wait ending between +// two ticks comes due at the later. 0 restores the continuous clock. +func (ctx *Context) SetClockStep(step float64) error { + if err := CheckClockStep(step); err != nil { + return err + } + ctx.clock.step = step + return nil +} + +// ClockStep is the step the clock's waits come due on, 0 for a continuous clock. +func (ctx *Context) ClockStep() float64 { + return ctx.clock.step +} + +// ClockStepTaken is the step the run's waits come due on: the witness's under a +// `replay` policy, which follows the clock the witness ran on, else the clock's own. +func (ctx *Context) ClockStepTaken() float64 { + if ctx.schedule.kind == scheduleReplay { + return ctx.schedule.replay.witness.ClockStep + } + return ctx.clock.step +} + +// ParseClockStep reads a clock step as `-clock-step` and a witness spell it: a +// finite, non-negative number of seconds, else ErrClockStep. +func ParseClockStep(text string) (float64, error) { + step, err := strconv.ParseFloat(strings.TrimSpace(text), 64) + if err != nil { + return 0, fmt.Errorf("%w: %q is not a number of seconds", ErrClockStep, text) + } + if err := CheckClockStep(step); err != nil { + return 0, err + } + return step, nil +} + +// ClockStepParseError is a witness's clock step line that does not read as one. +type ClockStepParseError struct { + Text string + Line int + Reason string +} + +func (e *ClockStepParseError) Error() string { + if e.Line > 0 { + return fmt.Sprintf("%v: line %d: %q: %s", ErrClockStep, e.Line, e.Text, e.Reason) + } + return fmt.Sprintf("%v: %q: %s", ErrClockStep, e.Text, e.Reason) +} + +func (e *ClockStepParseError) Unwrap() error { return ErrClockStep } + +// onTick is the first tick of a clock stepping by step not before the instant t: t itself on a +// continuous clock, within rounding (at most a millionth of a tick) of a tick, or past what a float64 counts. +func onTick(t, step float64) float64 { + if step == 0 { + return t + } + ticks := t / step + if math.IsInf(ticks, 0) || math.Abs(ticks) >= 1<<53 { + return t + } + nearest := math.Round(ticks) + tolerance := math.Min(1e-9*math.Max(1, math.Abs(ticks)), 1e-6) + if math.Abs(ticks-nearest) <= tolerance { + return nearest * step + } + return math.Ceil(ticks) * step +} + +// Waits lists everything waiting on the clock for an instant it has not +// reached, by due instant and, at one instant, by the executors' creation order. +func (c *Clock) Waits() []ClockWait { + var waits []ClockWait + for _, w := range c.waiters { + for _, wait := range w.clockWaits() { + if wait.Due > c.now { + waits = append(waits, wait) + } + } + } + slices.SortStableFunc(waits, func(a, b ClockWait) int { return cmp.Compare(a.Due, b.Due) }) + return waits +} + +// armed lists every wait on the clock, due or not, by due instant and, at one +// instant, by the executors' creation order. +func (c *Clock) armed() []ClockWait { + var waits []ClockWait + for _, w := range c.waiters { + waits = append(waits, w.armedWaits()...) + } + slices.SortStableFunc(waits, func(a, b ClockWait) int { return cmp.Compare(a.Due, b.Due) }) + return waits +} + +// notYetDue keeps the waits, given earliest first, for instants past now. +func notYetDue(waits []ClockWait, now float64) []ClockWait { + i := slices.IndexFunc(waits, func(w ClockWait) bool { return w.Due > now }) + if i < 0 { + return nil + } + return waits[i:] +} + +// NextDue returns the earliest instant a wait comes due at past the current +// one, false when nothing waits on the clock. +func (c *Clock) NextDue() (float64, bool) { + next, found := 0.0, false + for _, w := range c.waiters { + for _, wait := range w.clockWaits() { + if wait.Due > c.now && (!found || wait.Due < next) { + next, found = wait.Due, true + } + } + } + return next, found +} + +// attach makes the clock drive an executor, after those created before it. +func (c *Clock) attach(w clockWaiter) { + c.waiters = append(c.waiters, w) +} + +// detach ends the clock's driving of an executor whose run its caller is done with. +func (c *Clock) detach(w clockWaiter) { + c.waiters = slices.DeleteFunc(c.waiters, func(x clockWaiter) bool { return x == w }) +} + +// forgetFinished drops the executors the clock has nothing left to drive. +func (c *Clock) forgetFinished() { + c.waiters = slices.DeleteFunc(c.waiters, clockWaiter.finished) +} + +// Clock returns the simulation clock every executor of this context shares. +func (ctx *Context) Clock() *Clock { + return &ctx.clock +} + +// ClockValue is the clock's instant now as a value: a duration in seconds, or a +// bare number where no library reduces the second. +func (ctx *Context) ClockValue() Value { + return ctx.instantValue(ctx.clock.Now()) +} + +// instantValue is the clock's instant t as a duration quantity, or a bare number +// where no library reduces the second. +func (ctx *Context) instantValue(t float64) Value { + second, err := ctx.clockUnit() + if err != nil { + return realConst(t) + } + return NewQuantityValue(&Quantity{Num: semantics.Value{Kind: semantics.ValReal, Real: t}, Unit: second}) +} + +// InstantValue is instant t as the runtime reports the clock: a quantity in +// seconds when the library defines them, otherwise a bare real. +func (ctx *Context) InstantValue(t float64) Value { + return ctx.instantValue(t) +} + +// ClockMagnitude reads a value as a number of clock units: a bare number is one +// already, a quantity is converted from its unit; what names it in errors. +func (ctx *Context) ClockMagnitude(val Value, what string) (float64, error) { + return ctx.timeMagnitude(val, what) +} + +// dueInstant is when a time trigger comes due: `after d` counts from now (a +// negative delay is refused); `at t` is taken as read, one already past due now. +// Either must be finite, and so must the instant a delay leads to. +func (ctx *Context) dueInstant(t *ast.TimeEvent, val Value, what string) (float64, error) { + magnitude, err := ctx.timeMagnitude(val, what) + if err != nil { + return 0, err + } + if math.IsNaN(magnitude) { + return 0, fmt.Errorf("%w: %s is not a number", ErrNegativeDuration, what) + } + if math.IsInf(magnitude, 0) { + return 0, fmt.Errorf("%w: %s is infinite", ErrNegativeDuration, what) + } + if t.Absolute { + if magnitude <= ctx.clock.now { + return ctx.clock.now, nil + } + return ctx.clock.tickOf(magnitude, ctx.ClockStepTaken(), what) + } + if magnitude < 0 { + return 0, fmt.Errorf("%w: %s %s is negative", ErrNegativeDuration, what, semantics.FormatReal(magnitude)) + } + due, err := ctx.clock.instantAfter(magnitude, what) + if err != nil { + return 0, err + } + return ctx.clock.tickOf(due, ctx.ClockStepTaken(), what) +} + +// tickOf is the tick a wait for the finite instant due comes due on under step; one +// past the last instant a float64 holds is refused, so the clock stays finite. +func (c *Clock) tickOf(due, step float64, what string) (float64, error) { + tick := onTick(due, step) + if math.IsInf(tick, 0) { + return 0, fmt.Errorf("%w: %s at t=%s comes due on a tick of the clock stepping by %s past the last instant the clock can hold", + ErrNegativeDuration, what, semantics.FormatReal(due), semantics.FormatReal(step)) + } + return tick, nil +} + +// instantAfter is the instant a finite, non-negative duration from now leads to; +// one past the last instant a float64 holds is refused, so the clock stays finite. +func (c *Clock) instantAfter(duration float64, what string) (float64, error) { + due := c.now + duration + if math.IsInf(due, 0) { + return 0, fmt.Errorf("%w: %s %s from t=%s leads past the last instant the clock can hold", + ErrNegativeDuration, what, semantics.FormatReal(duration), semantics.FormatReal(c.now)) + } + return due, nil +} + +// timeMagnitude reads a time trigger's duration or instant as a number of clock +// units: a bare number is already one, a quantity is converted from its unit. +func (ctx *Context) timeMagnitude(val Value, what string) (float64, error) { + switch val.Kind { + case ValConst: + switch val.Const.Kind { + case semantics.ValInt: + return float64(val.Const.Int), nil + case semantics.ValReal: + return val.Const.Real, nil + default: + return 0, fmt.Errorf("%s must be numeric, got %v", what, val.Const.Kind) + } + case ValQuantity: + return ctx.durationInClockUnits(val.Quantity(), what) + default: + return 0, fmt.Errorf("%s must be constant, got %v", what, val.Kind) + } +} + +// judgeTimeTriggerType refuses, before evaluating it, the trigger argument +// validation refuses; one the declarations leave open is left to its value. +func (ctx *Context) judgeTimeTriggerType(scope *symbols.Scope, t *ast.TimeEvent) error { + c := ctx.model.semantics.TimeEventConforms(scope, t) + if !c.Known || c.Holds { + return nil + } + keyword := "after" + if t.Absolute { + keyword = "at" + } + return fmt.Errorf("%w: `%s %s` must be a %s, found %s", + ErrTimeTriggerType, keyword, ctx.bindingExprText(t.Duration, scope), semantics.TimeEventType(t), c.Found) +} + +// durationInClockUnits expresses a quantity in the clock's unit, reporting a +// quantity that does not measure time as the dimension error it is. +func (ctx *Context) durationInClockUnits(q *Quantity, what string) (float64, error) { + second, err := ctx.clockUnit() + if err != nil { + return 0, fmt.Errorf("%s %s: %w", what, q, err) + } + if !q.Unit.Term.Commensurable(second.Term) { + return 0, fmt.Errorf("%w: %s %s is not a time: %s does not measure a duration", + ErrIncommensurableUnits, what, q, q.Unit) + } + magnitude, err := q.ConvertTo(second) + if err != nil { + return 0, fmt.Errorf("%s %s: %w", what, q, err) + } + return magnitude, nil +} + +// clockUnit is the second as the Quantities and Units library reduces it, so a +// duration converts by the same reduction every other quantity uses. +func (ctx *Context) clockUnit() (Unit, error) { + if ctx.model.resolver == nil || ctx.model.resolver.Index() == nil { + return Unit{}, fmt.Errorf("%w: no library to reduce %s in", semantics.ErrNotAUnit, secondFQN) + } + matches := ctx.model.resolver.Index().LookupQualified(secondFQN) + if len(matches) != 1 { + return Unit{}, fmt.Errorf("%w: %s names %d elements, so no clock unit is determined", + semantics.ErrNotAUnit, secondFQN, len(matches)) + } + term, err := ctx.model.semantics.UnitTermOf(matches[0]) + if err != nil { + return Unit{}, err + } + return Unit{Text: "s", Product: semantics.NamedUnitProduct(matches[0], "s", false), Term: term}, nil +} diff --git a/internal/exec/runtime/clock_read.go b/internal/exec/runtime/clock_read.go new file mode 100644 index 0000000000..488b0f1584 --- /dev/null +++ b/internal/exec/runtime/clock_read.go @@ -0,0 +1,88 @@ +package runtime + +import "fmt" + +// universalClockFQN names the Kernel Semantic Library's universal clock, which +// every occurrence's localClock defaults to. +const universalClockFQN = "Clocks::universalClock" + +// timeUniversalClockFQN names the Time domain library's universal clock, whose +// currentTime is a TimeInstantValue. +const timeUniversalClockFQN = "Time::universalClock" + +// clockFQN names the Kernel's Clock, whose currentTime every clock advances. +const clockFQN = "Clocks::Clock" + +// timeClockFQN names the Time library's Clock, whose currentTime is a quantity. +const timeClockFQN = "Time::Clock" + +// currentTimeName is the feature of a Clock that advances over its lifetime. +const currentTimeName = "currentTime" + +func init() { + registerLibraryFeature(universalClockFQN, func(ctx *Context) (Value, error) { + return ctx.universalClockObject(universalClockFQN) + }) + registerLibraryFeature(timeUniversalClockFQN, func(ctx *Context) (Value, error) { + return ctx.universalClockObject(timeUniversalClockFQN) + }) +} + +// universalClockObject is the run's universal clock as the one object the +// library usage denotes, materialized once per context. +func (ctx *Context) universalClockObject(fqn string) (Value, error) { + sym := ctx.librarySymbol(fqn) + if sym == nil { + return Value{}, fmt.Errorf("%w: no library declares %s", ErrUnresolvedReference, fqn) + } + inst, err := ctx.occurrenceOf(sym) + if err != nil { + return Value{}, err + } + return Value{Kind: ValInstance, Instance: inst.ID}, nil +} + +// clockMember answers a Clock object's currentTime from the shared clock every +// executor of the context advances: a Time::Clock reads the instant on its own +// scale, seconds since the run began as `accept at` waits for it; any other +// Clock the Kernel's bare number of seconds. Other members are not answered. +func (ctx *Context) clockMember(inst *Instance, name string) (Value, bool, error) { + if !ctx.isClockTime(inst, name) { + return Value{}, false, nil + } + if ctx.conformsToLibrary(ctx.objectType(inst), timeClockFQN) { + return ctx.ClockValue(), true, nil + } + return realConst(ctx.clock.Now()), true, nil +} + +// isClock reports whether inst is an object of a Clock, as the Kernel declares one. +func (ctx *Context) isClock(inst *Instance) bool { + return inst != nil && ctx.conformsToLibrary(ctx.objectType(inst), clockFQN) +} + +// isClockTime reports whether name, on inst, is a Clock's currentTime: the +// feature itself or a redefinition of it (`attribute now :>> currentTime;`), +// which is the same feature under another name. +func (ctx *Context) isClockTime(inst *Instance, name string) bool { + if !ctx.isClock(inst) { + return false + } + if name == currentTimeName { + return true + } + typ := ctx.objectType(inst) + sym, ok := ctx.model.semantics.LookupMember(typ, name) + if !ok { + return false + } + for _, redefined := range ctx.redefinedFeatures(sym, typ) { + if redefined.Name != currentTimeName || redefined.OwnerScope == nil { + continue + } + if ctx.conformsToLibrary(redefined.OwnerScope.Owner(), clockFQN) { + return true + } + } + return false +} diff --git a/internal/exec/runtime/clock_step_test.go b/internal/exec/runtime/clock_step_test.go new file mode 100644 index 0000000000..f0d6e23eaa --- /dev/null +++ b/internal/exec/runtime/clock_step_test.go @@ -0,0 +1,231 @@ +package runtime + +import ( + "errors" + "math" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// steppedModel waits three times and reads the clock after each wait, so the +// instants a run reads show the clock it ran on. +const steppedModel = ` +package test { + private import ScalarValues::*; + private import SI::*; + private import Time::*; + action def Stepped { + attribute six : TimeInstantValue = 6.0 [s]; + out t1 : Real; + out t2 : Real; + out t3 : Real; + action w1 accept after 2.3 [s]; + then action r1 { assign t1 := localClock.currentTime; } + then action w2 accept after 0.4 [s]; + then action r2 { assign t2 := localClock.currentTime; } + then action w3 accept at six; + then action r3 { assign t3 := localClock.currentTime; } + } +}` + +// runUnderStep runs name on a fresh context whose clock steps by step. +func runUnderStep(t *testing.T, m *exploreModel, name string, step float64) (*Context, map[string]Value, error) { + t.Helper() + ctx, _ := m.fresh() + if err := ctx.SetClockStep(step); err != nil { + t.Fatal(err) + } + out, err := ctx.ExecuteAction(m.action(t, name)) + return ctx, out, err +} + +func realOutputs(t *testing.T, out map[string]Value, names ...string) []float64 { + t.Helper() + got := make([]float64, len(names)) + for i, name := range names { + v := out[name] + if v.Kind != ValConst || v.Const.Kind != semantics.ValReal { + t.Fatalf("%s = %v, want a Real", name, v) + } + got[i] = v.Const.Real + } + return got +} + +// A continuous clock (the default) reads a wait due exactly when it ends: the +// instants are the sums of the durations, and an absolute wait is its instant. +func TestContinuousClockReadsWaitsExactly(t *testing.T) { + m := parseLibraryModel(t, steppedModel) + ctx, out, err := runUnderStep(t, m, "Stepped", 0) + if err != nil { + t.Fatal(err) + } + if ctx.ClockStep() != 0 { + t.Errorf("ClockStep() = %v, want 0", ctx.ClockStep()) + } + got := realOutputs(t, out, "t1", "t2", "t3") + for i, want := range []float64{2.3, 2.7, 6.0} { + if math.Abs(got[i]-want) > 1e-12 { + t.Errorf("t%d = %v, want %v", i+1, got[i], want) + } + } +} + +// Under a step, a wait comes due at the first tick not before it ends — a +// relative wait from the tick it was set on, an absolute one at its instant when +// that is a tick — so a run reads only ticks. +func TestSteppedClockReadsWaitsOnTicks(t *testing.T) { + m := parseLibraryModel(t, steppedModel) + for _, tc := range []struct { + step float64 + want []float64 + }{ + {1.0, []float64{3.0, 4.0, 6.0}}, + {0.5, []float64{2.5, 3.0, 6.0}}, + {0.1, []float64{2.3, 2.7, 6.0}}, + {4.0, []float64{4.0, 8.0, 8.0}}, + } { + ctx, out, err := runUnderStep(t, m, "Stepped", tc.step) + if err != nil { + t.Fatalf("step %v: %v", tc.step, err) + } + if ctx.ClockStep() != tc.step { + t.Errorf("step %v: ClockStep() = %v", tc.step, ctx.ClockStep()) + } + got := realOutputs(t, out, "t1", "t2", "t3") + for i, want := range tc.want { + if math.Abs(got[i]-want) > 1e-9 { + t.Errorf("step %v: t%d = %v, want %v", tc.step, i+1, got[i], want) + } + } + if now := ctx.Clock().Now(); math.Abs(now-tc.want[2]) > 1e-9 { + t.Errorf("step %v: the clock ended at %v, want %v", tc.step, now, tc.want[2]) + } + } +} + +// onTick lands an instant within floating-point rounding of a tick on that tick +// rather than the next, so a sum of steps does not drift a tick late. +func TestClockStepAbsorbsRounding(t *testing.T) { + sum := 0.0 + for i := 0; i < 10; i++ { + sum += 0.1 + } + if sum == 1.0 { + t.Fatal("ten 0.1 add to exactly 1.0 here; the case needs a rounded sum") + } + if got := onTick(sum, 0.1); got != 1.0 { + t.Errorf("onTick(%v) = %v, want 1.0", sum, got) + } + if got := onTick(1.0+1e-6, 0.1); math.Abs(got-1.1) > 1e-12 { + t.Errorf("onTick(1.000001) = %v, want 1.1", got) + } + if got := onTick(0, 0.1); got != 0 { + t.Errorf("onTick(0) = %v, want 0", got) + } + // Far along the clock the rounding allowed stays a sliver of a tick: an instant + // well past a tick is not pulled back onto it. + if got := onTick(1_000_000_000.4, 1); got != 1_000_000_001 { + t.Errorf("onTick(1000000000.4) = %v, want 1000000001", got) + } + if got := onTick(1_000_000_000, 1); got != 1_000_000_000 { + t.Errorf("onTick(1000000000) = %v, want 1000000000", got) + } + if got := onTick(1_000_000_000+1e-7, 1); got != 1_000_000_000 { + t.Errorf("onTick(1000000000+1e-7) = %v, want 1000000000", got) + } + for _, step := range []float64{1e-320, 1e-300, 1e-17} { + if got := onTick(2.3, step); got != 2.3 { + t.Errorf("onTick(2.3, %v) = %v, want 2.3: a step finer than a float64 tells apart leaves the instant", step, got) + } + } + if got := onTick(1e300, 1e-10); got != 1e300 { + t.Errorf("onTick(1e300, 1e-10) = %v, want 1e300", got) + } +} + +// CheckClockStep admits every finite, non-negative step and refuses the rest +// with ErrClockStep naming the value. +func TestCheckClockStep(t *testing.T) { + for _, step := range []float64{0, 1e-9, 0.01, 1, 3600} { + if err := CheckClockStep(step); err != nil { + t.Errorf("CheckClockStep(%v) = %v", step, err) + } + } + for _, step := range []float64{-1, math.NaN(), math.Inf(1), math.Inf(-1)} { + err := CheckClockStep(step) + if !errors.Is(err, ErrClockStep) { + t.Errorf("CheckClockStep(%v) = %v, want ErrClockStep", step, err) + } + if !strings.Contains(err.Error(), "finite, non-negative number of seconds") { + t.Errorf("CheckClockStep(%v) = %q, want the rule", step, err) + } + } +} + +// A witness of a run on a stepped clock names the step, reads back, and replays +// on that clock whatever the replaying context's own step is; a continuous run's +// witness names none. A step line out of place, doubled, unreadable or naming a +// continuous clock is refused with a typed error. +func TestWitnessCarriesTheClockStep(t *testing.T) { + m := parseLibraryModel(t, steppedModel) + ctx, out, err := runUnderStep(t, m, "Stepped", 1) + if err != nil { + t.Fatal(err) + } + w := Witness{DrawPolicy: ctx.DrawPolicyTaken(), ClockStep: ctx.ClockStepTaken(), Draws: ctx.DrawsTaken(), Choices: ctx.ChoicesTaken()} + text := w.String() + if !strings.HasPrefix(text, "clock steps by 1.0\n") { + t.Fatalf("the witness does not open with the clock step:\n%s", text) + } + parsed, err := ParseWitness(text) + if err != nil { + t.Fatalf("the witness does not read back: %v\n%s", err, text) + } + if parsed.ClockStep != 1 { + t.Fatalf("the witness reads back stepping by %v, want 1", parsed.ClockStep) + } + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(parsed)) + if got := replay.ClockStepTaken(); got != 1 { + t.Fatalf("the replay's clock steps by %v, want the witness's 1", got) + } + got, err := replay.ExecuteAction(m.action(t, "Stepped")) + if err == nil { + err = replay.Unfollowed() + } + if err != nil { + t.Fatalf("replay: %v", err) + } + if want, have := realOutputs(t, out, "t1", "t2", "t3"), realOutputs(t, got, "t1", "t2", "t3"); !slices.Equal(want, have) { + t.Errorf("the replay read %v, want the stepped run's %v", have, want) + } + if replay.ClockStep() != 0 { + t.Errorf("the replay changed the context's own clock step to %v", replay.ClockStep()) + } + + continuous, _, err := runUnderStep(t, m, "Stepped", 0) + if err != nil { + t.Fatal(err) + } + if text := (Witness{ClockStep: continuous.ClockStepTaken(), Choices: continuous.ChoicesTaken()}).String(); strings.Contains(text, clockStepPrefix) { + t.Errorf("a continuous run's witness names a clock step:\n%s", text) + } + + var parse *ClockStepParseError + for _, bad := range []struct{ text, reason string }{ + {"draw uniform(0.0, 1.0) = 1.0\nclock steps by 1.0\nno choice points\n", "before the draws"}, + {"clock steps by 1.0\nclock steps by 0.5\nno choice points\n", "named twice"}, + {"clock steps by 0.0\nno choice points\n", "continuous clock"}, + {"clock steps by -1\nno choice points\n", "non-negative"}, + {"clock steps by soon\nno choice points\n", "not a number"}, + } { + _, err := ParseWitness(bad.text) + if !errors.As(err, &parse) || !errors.Is(err, ErrClockStep) || !strings.Contains(err.Error(), bad.reason) || parse.Line == 0 { + t.Errorf("%q parsed as %v, want a ClockStepParseError on its line saying %q", bad.text, err, bad.reason) + } + } +} diff --git a/internal/core/runtime/collections.go b/internal/exec/runtime/collections.go similarity index 99% rename from internal/core/runtime/collections.go rename to internal/exec/runtime/collections.go index 6b1b17118f..d8e5d207bf 100644 --- a/internal/core/runtime/collections.go +++ b/internal/exec/runtime/collections.go @@ -5,9 +5,9 @@ import ( "math" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) const subsequenceOp = "SequenceFunctions::subsequence" diff --git a/internal/core/runtime/collections_test.go b/internal/exec/runtime/collections_test.go similarity index 98% rename from internal/core/runtime/collections_test.go rename to internal/exec/runtime/collections_test.go index 043f2569a1..9411431150 100644 --- a/internal/core/runtime/collections_test.go +++ b/internal/exec/runtime/collections_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // evalCollectionExpr evaluates expr as the value of an attribute of a model @@ -36,7 +36,7 @@ func evalCollectionExprBounded(t *testing.T, expr string, maxSteps int64) (Value select { case got := <-done: return got.value, got.err - case <-time.After(10 * time.Second): + case <-watchdog(10 * time.Second): t.Fatalf("%s did not terminate", expr) return Value{}, nil } @@ -75,7 +75,7 @@ package test { if !ok { t.Fatalf("result declares %T, want a usage", sym.Decl) } - ctx := NewContext(NewModel(model, resolver), maxSteps) + ctx := NewContext(typedModel(model, resolver), maxSteps) return NewEvalContext(ctx, scope), decl.Value } @@ -431,7 +431,7 @@ package test { if !ok { t.Fatalf("result declares %T, want a usage", sym.Decl) } - ec := NewEvalContext(NewContext(NewModel(model, resolver), 10000), pkg.Scope) + ec := NewEvalContext(NewContext(typedModel(model, resolver), 10000), pkg.Scope) got, err := ec.Eval(decl.Value) if !errors.Is(err, ErrReceiverWithNamedArgs) { t.Fatalf("factor->scale(n = 1) = (%v, %v), want %v", got, err, ErrReceiverWithNamedArgs) diff --git a/internal/exec/runtime/compile.go b/internal/exec/runtime/compile.go new file mode 100644 index 0000000000..211a5829fb --- /dev/null +++ b/internal/exec/runtime/compile.go @@ -0,0 +1,880 @@ +package runtime + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/workspace/envvar" +) + +// CalcCompileEnvVar switches the compiled tier for pure calc bodies off when set +// to 0 (or false/off/no), leaving every calc to the reference evaluator. +const CalcCompileEnvVar = "OPENSYSML_CALC_COMPILE" + +// CalcCompileFromEnv reports whether the environment leaves the compiled calc +// tier on, which it does unless CalcCompileEnvVar (or its legacy SYSML_ name) +// switches it off. +func CalcCompileFromEnv() bool { + return calcCompileFromValue(envvar.Lookup(CalcCompileEnvVar)) +} + +// calcCompileFromValue reads the switch's value: unset or empty leaves it on. +func calcCompileFromValue(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "0", "false", "off", "no": + return false + } + return true +} + +// SetCalcCompile turns the compiled tier on or off for this context. +func (ctx *Context) SetCalcCompile(on bool) { ctx.compileCalcs = on } + +// CalcCompile reports whether this context runs eligible calc bodies compiled. +func (ctx *Context) CalcCompile() bool { return ctx.compileCalcs } + +// compileState is where a shape stands with the compiled tier. +type compileState uint8 + +const ( + compileUndecided compileState = iota + compileInProgress + compileEligible + compileIneligible +) + +// maxCompiledParams bounds the parameters of a compiled calc, whose bound +// arguments an invocation records in one paramSet. +const maxCompiledParams = 64 + +// paramSet is a set of parameter slots below maxCompiledParams, one bit each; +// every slot it takes is checked against that bound when the calc compiles. +type paramSet uint64 + +func (s *paramSet) add(i int) { *s |= 1 << uint(i) } // #nosec G115 -- slot below maxCompiledParams + +func (s paramSet) has(i int) bool { return s&(1<= c.least) + case scalarBool: + return c.boolOK + } + return c.acceptsReal(v) +} + +// acceptsReal places a Real on the lattice by its representation, as the evaluator does. +func (c *scalarCheck) acceptsReal(v scalar) bool { + switch representationPrim(v.boxed()) { + case semantics.PrimRational: + return c.rationalOK + case semantics.PrimReal: + return c.realOK + } + return false +} + +// refuse is the evaluator's verdict on a value accepts declined, so a refusal +// carries the reference's message and a value it would take is taken. +func (c *scalarCheck) refuse(ctx *Context, v scalar, what func() string) error { + boxed := v.boxed() + return c.decl.check(ctx, &boxed, what) +} + +// compiledCalcOf answers the compiled body of shape, compiling it and every +// calc it invokes on first ask, or nil when the shape is ineligible. +func (ctx *Context) compiledCalcOf(shape *calcShape) *compiledCalc { + switch shape.compileState { + case compileEligible: + return shape.compiled + case compileIneligible: + return nil + } + batch := &compileBatch{ctx: ctx} + batch.compile(shape) + batch.settle() + if shape.compileState == compileEligible { + return shape.compiled + } + return nil +} + +// compileBatch is one compilation and the calc call graph it pulls in. A shape +// calling one still being compiled (a recursion or a cycle) takes its cell on +// trust, so the batch settles every call once each shape has an answer. +type compileBatch struct { + ctx *Context + // callees records the shapes each member compiled a call to. + callees map[*calcShape][]*calcShape +} + +// compile decides shape, compiling its callees first. +func (b *compileBatch) compile(shape *calcShape) { + shape.compileState = compileInProgress + shape.compiled = &compiledCalc{kind: shape.Kind, name: shape.Name} + c := &calcCompiler{batch: b, ctx: b.ctx, shape: shape} + if err := c.compile(shape.compiled); err != nil { + shape.withdraw(err.Error()) + return + } + shape.compileState = compileEligible +} + +// withdraw keeps shape on the evaluator for good, for the reason given. +func (shape *calcShape) withdraw(why string) { + shape.compileState = compileIneligible + shape.compiled = nil + shape.ineligibleWhy = why +} + +// call records that member compiled a call to callee. +func (b *compileBatch) call(member, callee *calcShape) { + if b.callees == nil { + b.callees = map[*calcShape][]*calcShape{} + } + b.callees[member] = append(b.callees[member], callee) +} + +// settle withdraws eligibility from every member calling an ineligible shape, +// to a fixpoint, and marks a member reading a library constant via a callee. +func (b *compileBatch) settle() { + for changed := true; changed; { + changed = false + for member, callees := range b.callees { + if member.compileState != compileEligible { + continue + } + for _, callee := range callees { + if callee.compileState != compileEligible { + member.withdraw("callee " + callee.Name + " is ineligible") + changed = true + break + } + if callee.compiled.readsLibrary && !member.compiled.readsLibrary { + member.compiled.readsLibrary = true + changed = true + } + } + } + } +} + +// ineligible says why a body stays with the evaluator; the reason is recorded +// on the shape and never reaches a user. +func ineligible(why string) error { return errors.New(why) } + +// calcCompiler compiles one shape's parameters and body. +type calcCompiler struct { + batch *compileBatch + ctx *Context + shape *calcShape + cell *compiledCalc +} + +// compile fills cell with the shape's compiled form, or says why it cannot. +func (c *calcCompiler) compile(cell *compiledCalc) error { + shape := c.shape + c.cell = cell + for i := range shape.Outputs { + if !shape.Outputs[i].IsResult { + return ineligible(fmt.Sprintf("output feature %q beside the result", shape.Outputs[i].Name)) + } + } + if len(shape.Params) > maxCompiledParams { + return ineligible(fmt.Sprintf("%d parameters", len(shape.Params))) + } + cell.params = make([]compiledParam, len(shape.Params)) + for i := range shape.Params { + param := &shape.Params[i] + if param.IsCalc { + return ineligible(fmt.Sprintf("parameter %q binds a function value", param.Name)) + } + check, ok := c.scalarCheckFor(¶m.Decl) + if !ok { + return ineligible(fmt.Sprintf("parameter %q declares a type outside the scalar lattice", param.Name)) + } + cell.params[i] = compiledParam{name: param.Name, check: check} + if param.Default == nil { + cell.required.add(i) + continue + } + scope := c.ctx.calcScope(param.Owner, shape.Sym, nil) + dflt, err := c.compileNode(param.Default, scope, newFrameLayout(shape.ParamNames, shape.Aliases, i)) + if err != nil { + return err + } + if !dflt.isConst { + return ineligible(fmt.Sprintf("default of %q is not a literal", param.Name)) + } + cell.params[i].dflt = dflt.expr() + } + var result *scalarCheck + if out := shape.resultOutput(); out != nil { + check, ok := c.scalarCheckFor(&out.Decl) + if !ok { + return ineligible("result declares a type outside the scalar lattice") + } + result = &check + } + layout := newFrameLayout(shape.ParamNames, shape.Aliases, len(shape.Params)) + if len(shape.Steps) == 0 { + if err := c.compileResultBinding(cell, layout, result); err != nil { + return err + } + } else if err := c.compileBody(cell, layout, result); err != nil { + return err + } + cell.frameSize = layout.size + return nil +} + +// compileResultBinding compiles a statement-less body: the expression bound to +// the result parameter, whose errors the evaluator words as an output feature's. +func (c *calcCompiler) compileResultBinding(cell *compiledCalc, layout *frameLayout, result *scalarCheck) error { + shape := c.shape + if shape.ResultExpr == nil { + return ineligible("body without a result expression") + } + scope := c.ctx.calcScope(shape.Sym, shape.Sym, nil) + if err := plainScope(scope); err != nil { + return err + } + body, err := c.compileNode(shape.ResultExpr, scope, layout) + if err != nil { + return err + } + cell.body = body.expr() + cell.result = result + cell.bodyErr = fmt.Sprintf("calc %s: output result: ", shape.Name) + cell.resultWhat = fmt.Sprintf("calc %s: output result", shape.Name) + return nil +} + +// compileBody compiles a body of statements, which must return on every path; +// one that may run off its end reads its result as an output feature, so declines. +func (c *calcCompiler) compileBody(cell *compiledCalc, layout *frameLayout, result *scalarCheck) error { + if ret, ok := c.singleReturn(); ok { + return c.compileReturnBody(cell, ret, layout, result) + } + stmts, returns, err := c.compileStatements(c.shape.Steps, layout, result) + if err != nil { + return err + } + if !returns { + return ineligible("body may end without returning") + } + cell.body = bodyStmt(stmts) + return nil +} + +// singleReturn answers the body's one statement when it is a valued return. +func (c *calcCompiler) singleReturn() (lower.Return, bool) { + if len(c.shape.Steps) != 1 { + return lower.Return{}, false + } + ret, ok := c.shape.Steps[0].(lower.Return) + return ret, ok && ret.Value != nil +} + +// compileReturnBody compiles a body that is one return as the expression itself, +// sparing the statement dispatch; the result is held to its declaration on exit. +func (c *calcCompiler) compileReturnBody(cell *compiledCalc, ret lower.Return, layout *frameLayout, result *scalarCheck) error { + if err := plainScope(ret.Scope); err != nil { + return err + } + body, err := c.compileNode(ret.Value, ret.Scope, layout) + if err != nil { + return err + } + cell.body = body.expr() + cell.result = result + cell.bodyErr = "evaluating the returned expression: " + cell.resultWhat = "result" + return nil +} + +// scalarCheckFor decides a declaration for scalars, declining a declared type +// the scalar lattice does not place — an enumeration among them, whose value a +// scalar cannot carry the identity of. A declaration stating no type, or one that +// does not resolve, holds any scalar, as it does on the evaluator; a non-scalar +// argument never reaches the compiled tier. +func (c *calcCompiler) scalarCheckFor(decl *calcMemberDecl) (scalarCheck, bool) { + check := scalarCheck{decl: decl, countOK: true, + boolOK: true, naturalOK: true, integerOK: true, rationalOK: true, realOK: true} + if decl.Target == nil { + return check, true + } + one := Value{Kind: ValConst} + check.countOK = c.ctx.writeCountRefusal(decl.Target, &one) == "" + if decl.Target.typ == nil { + return check, true + } + if decl.Target.typ.Kind == symbols.SymbolEnumerationDef { + return scalarCheck{}, false + } + prim := c.ctx.model.semantics.PrimTypeOf(decl.Target.typ) + if prim == semantics.PrimUnknown { + return scalarCheck{}, false + } + holds := func(from semantics.PrimType) bool { return semantics.PrimConforms(from, prim) } + check.boolOK = holds(semantics.PrimBoolean) + check.naturalOK = holds(semantics.PrimNatural) + check.integerOK = holds(semantics.PrimInteger) + check.rationalOK = holds(semantics.PrimRational) + check.realOK = holds(semantics.PrimReal) + if c.ctx.positiveScalar(decl.Target.typ) { + check.least = 1 + } + return check, true +} + +// cnode is one compiled expression node. The evaluator charges a step per node +// as it reaches it; the compiled form charges the steps up to a subtree's first +// fallible operation at once, which leaves the counter identical at every point +// an error can be observed. +type cnode struct { + // prefix is the steps charged before the subtree's first operation that can + // fail; infallible says no operation in it can, so prefix is the whole. + prefix int64 + infallible bool + // emit builds the closure; precharged says the node above charged prefix. + emit func(precharged bool) compiledExpr + // A leaf reads a frame slot or yields a constant. + leaf bool + isConst bool + slot int + constant scalar +} + +// expr is the node as a standalone expression charging its own steps. +func (n *cnode) expr() compiledExpr { return n.emit(false) } + +// compileNode compiles n written in scope, reading names from the frame slots +// the layout describes; a name outside it is admitted only as a library constant. +func (c *calcCompiler) compileNode(n ast.Node, scope *symbols.Scope, layout *frameLayout) (*cnode, error) { + switch e := n.(type) { + case *ast.LiteralInteger: + v, err := strconv.ParseInt(e.Value, 10, 64) + if err != nil { + return nil, ineligible(fmt.Sprintf("integer literal %s outside the range", e.Value)) + } + return constNode(intScalar(v)), nil + case *ast.LiteralReal: + v, err := semantics.ParseReal(e.Value) + if err != nil { + return nil, ineligible(fmt.Sprintf("real literal %s outside the range", e.Value)) + } + return constNode(realScalar(v)), nil + case *ast.LiteralBool: + return constNode(boolScalar(e.Value)), nil + case *ast.FeatureReference: + return c.compileName(e.Name, scope, layout) + case *ast.QualifiedName: + return c.compileName(e, scope, layout) + case *ast.OperatorExpr: + return c.compileOperator(e, scope, layout) + case *ast.InvocationExpr: + return c.compileInvocation(e, scope, layout) + default: + return nil, ineligible(fmt.Sprintf("%T is outside the pure subset", n)) + } +} + +// compileName resolves a name as the evaluator does: a frame binding first, +// then what the scope resolves it to, read only where it is a library constant. +func (c *calcCompiler) compileName(qn *ast.QualifiedName, scope *symbols.Scope, layout *frameLayout) (*cnode, error) { + if qn == nil || len(qn.Parts) == 0 { + return nil, ineligible("empty feature reference") + } + if scope == nil { + return nil, ineligible("name without a scope") + } + if len(qn.Parts) > 1 { + return c.compileQualifiedName(qn, scope) + } + name := qn.Parts[0].Text + if slot, ok := layout.lookup(name); ok { + return slotNode(slot), nil + } + if name == thatName || name == thisName { + return nil, ineligible(fmt.Sprintf("name %q reads the bound object", name)) + } + sym, ok := c.ctx.lookupName(scope, name) + if !ok || sym == nil { + return nil, ineligible(fmt.Sprintf("name %q is not bound in the frame", name)) + } + if c.ctx.readsAsFunction(sym) { + return nil, ineligible(fmt.Sprintf("name %q is a function value", name)) + } + node, err := c.libraryConstant(sym, name) + if err != nil { + return nil, err + } + c.cell.readsLibrary = true + return node, nil +} + +// compileQualifiedName resolves `A::B::x` as the evaluator does — first part in +// scope, the rest as members — and reads it where it is a library constant. +func (c *calcCompiler) compileQualifiedName(qn *ast.QualifiedName, scope *symbols.Scope) (*cnode, error) { + firstQN := &ast.QualifiedName{Global: qn.Global, Parts: []ast.NameSegment{qn.Parts[0]}} + firstQN.NodeBase = qn.NodeBase + sym, ok := c.ctx.resolveQualified(scope, firstQN) + if !ok || sym == nil { + return nil, ineligible(fmt.Sprintf("qualified name %s does not resolve", qualifiedNameToString(qn))) + } + for _, part := range qn.Parts[1:] { + if isCalcUsageSymbol(sym) { + return nil, ineligible(fmt.Sprintf("qualified name %s reads a calc usage", qualifiedNameToString(qn))) + } + next, found := c.ctx.model.semantics.LookupMember(sym, part.Text) + if !found { + return nil, ineligible(fmt.Sprintf("qualified name %s does not resolve", qualifiedNameToString(qn))) + } + sym = next + } + return c.libraryConstant(sym, qualifiedNameToString(qn)) +} + +// libraryConstant compiles a read of sym where it is a scalar constant the +// library seam supplies; anything else the name may denote keeps the evaluator. +func (c *calcCompiler) libraryConstant(sym *symbols.Symbol, name string) (*cnode, error) { + if c.ctx.model.semantics.VariationPointOwning(sym) != nil || semantics.EnumerationOwning(sym) != nil { + return nil, ineligible(fmt.Sprintf("name %q is a variant or an enumeration literal", name)) + } + val, ok, err := c.ctx.libraryFeatureValue(sym) + if !ok { + return nil, ineligible(fmt.Sprintf("name %q is not bound in the frame", name)) + } + if err != nil { + return nil, ineligible(fmt.Sprintf("library feature %q has no value: %v", name, err)) + } + v, ok := scalarOf(val) + if !ok { + return nil, ineligible(fmt.Sprintf("library feature %q is not a scalar", name)) + } + return constNode(v), nil +} + +// compileOperator compiles an operator application, folding it as the +// evaluator does before it looks at the operands. +func (c *calcCompiler) compileOperator(n *ast.OperatorExpr, scope *symbols.Scope, layout *frameLayout) (*cnode, error) { + if folded, ok := c.ctx.model.semantics.Eval(n); ok { + v, ok := scalarOfConst(folded) + if !ok { + return nil, ineligible("folds to a non-scalar constant") + } + return constNode(v), nil + } + switch n.Operator { + case ast.OpConditional: + if len(n.Operands) != 3 { + return nil, ineligible(fmt.Sprintf("conditional with %d operands", len(n.Operands))) + } + return c.compileOperands(n, scope, layout, conditionalNode) + case ast.OpAdd, ast.OpSub, ast.OpMul, ast.OpDiv, ast.OpMod, ast.OpPow: + if len(n.Operands) != 2 { + return nil, ineligible(fmt.Sprintf("arithmetic with %d operands", len(n.Operands))) + } + return c.compileOperands(n, scope, layout, arithmeticNode) + case ast.OpEq, ast.OpNeq: + if len(n.Operands) != 2 { + return nil, ineligible(fmt.Sprintf("equality with %d operands", len(n.Operands))) + } + return c.compileOperands(n, scope, layout, equalityNode) + case ast.OpEqEqEq, ast.OpNeqEqEq: + if len(n.Operands) != 2 { + return nil, ineligible(fmt.Sprintf("identity with %d operands", len(n.Operands))) + } + return c.compileOperands(n, scope, layout, identityNode) + case ast.OpLt, ast.OpLe, ast.OpGt, ast.OpGe: + if len(n.Operands) != 2 { + return nil, ineligible(fmt.Sprintf("comparison with %d operands", len(n.Operands))) + } + return c.compileOperands(n, scope, layout, comparisonNode) + case ast.OpAnd, ast.OpConditionalAnd, ast.OpOr, ast.OpConditionalOr, ast.OpXor, ast.OpImplies: + if len(n.Operands) != 2 { + return nil, ineligible(fmt.Sprintf("logical operator with %d operands", len(n.Operands))) + } + return c.compileOperands(n, scope, layout, logicalNode) + case ast.OpNeg, ast.OpPos, ast.OpNot: + if len(n.Operands) != 1 { + return nil, ineligible(fmt.Sprintf("unary operator with %d operands", len(n.Operands))) + } + // The least Integer is read with its sign, as the evaluator reads it. + if lit, ok := n.Operands[0].(*ast.LiteralInteger); ok && n.Operator == ast.OpNeg { + if _, err := strconv.ParseInt(lit.Value, 10, 64); err != nil { + if v, err := strconv.ParseInt("-"+lit.Value, 10, 64); err == nil { + return constNode(intScalar(v)), nil + } + } + } + return c.compileOperands(n, scope, layout, unaryNode) + default: + return nil, ineligible(fmt.Sprintf("operator '%s' is outside the pure subset", n.Operator)) + } +} + +// compileOperands compiles n's operands in order and builds the node over them. +func (c *calcCompiler) compileOperands(n *ast.OperatorExpr, scope *symbols.Scope, layout *frameLayout, + build func(op ast.OperatorKind, kids []*cnode) *cnode) (*cnode, error) { + kids := make([]*cnode, len(n.Operands)) + for i, operand := range n.Operands { + kid, err := c.compileNode(operand, scope, layout) + if err != nil { + return nil, err + } + kids[i] = kid + } + return build(n.Operator, kids), nil +} + +// callArguments are an invocation's arguments as written: the expressions in +// source order, a receiver first, and the parameter name each named one binds. +type callArguments struct { + exprs []ast.Node + names []string +} + +// callArgumentsOf reads an invocation's arguments, declining the forms the +// evaluator reports at run time: a receiver or positional beside named ones. +// names is the parameter each named argument binds, as the target resolved them. +func callArgumentsOf(n *ast.InvocationExpr, names []string, unbound []error) (callArguments, error) { + var args callArguments + if n.Operand != nil { + if len(n.NamedArgs) > 0 { + return args, ineligible("invocation with a receiver and named arguments") + } + args.exprs = append(args.exprs, n.Operand) + } + args.exprs = append(args.exprs, n.Args...) + if len(n.NamedArgs) == 0 { + return args, nil + } + if len(args.exprs) > 0 { + return args, ineligible("invocation with positional and named arguments") + } + for i, arg := range n.NamedArgs { + if names[i] == "" { + return args, ineligible("invocation with an unnamed argument") + } + if err := unbound[i]; err != nil { + return args, ineligible(err.Error()) + } + args.exprs = append(args.exprs, arg.Value) + args.names = append(args.names, names[i]) + } + return args, nil +} + +// slotsFor places the arguments in parameter slots by position or by name; a +// name written twice is left to the evaluator's report. +func (a *callArguments) slotsFor(params []string) ([]int, paramSet, error) { + slots := make([]int, len(a.exprs)) + var bound paramSet + for i := range a.exprs { + slot := i + if a.names != nil { + slot = -1 + for j, param := range params { + if param == a.names[i] { + slot = j + break + } + } + if slot < 0 { + return nil, 0, ineligible(fmt.Sprintf("no parameter %q to bind by name", a.names[i])) + } + if bound.has(slot) { + return nil, 0, ineligible(fmt.Sprintf("parameter %q bound by name twice", a.names[i])) + } + } + slots[i] = slot + bound.add(slot) + } + return slots, bound, nil +} + +// compileArguments compiles the argument expressions in source order. +func (c *calcCompiler) compileArguments(args *callArguments, scope *symbols.Scope, layout *frameLayout) ([]*cnode, error) { + kids := make([]*cnode, len(args.exprs)) + for i, arg := range args.exprs { + kid, err := c.compileNode(arg, scope, layout) + if err != nil { + return nil, err + } + kids[i] = kid + } + return kids, nil +} + +// scalarAggregates are the collection functions a scalar argument makes a +// one-element aggregation of, whose implementations the tier calls as they are. +var scalarAggregates = map[string]bool{ + "NumericalFunctions::sum": true, "NumericalFunctions::product": true, + "IntegerFunctions::sum": true, "IntegerFunctions::product": true, + "RationalFunctions::sum": true, "RationalFunctions::product": true, + "RealFunctions::sum": true, "RealFunctions::product": true, +} + +// compileInvocation compiles a call dispatched as the evaluator dispatches it: +// what the name resolves to in scope. A name that resolves to nothing is left +// to the evaluator, which reports it unresolved. +func (c *calcCompiler) compileInvocation(n *ast.InvocationExpr, scope *symbols.Scope, layout *frameLayout) (*cnode, error) { + if scope == nil { + return nil, ineligible("invocation without a scope") + } + target := (&EvalContext{ctx: c.ctx, scope: scope}).invocationTarget(n) + if target.err != nil { + return nil, target.err + } + args, err := callArgumentsOf(n, target.names, target.unbound) + if err != nil { + return nil, err + } + switch { + case len(target.ambiguous) > 0: + return nil, ineligible(fmt.Sprintf("%s is ambiguous", target.qualName)) + case target.builtin != nil: + return c.compileAggregate(target.builtinName, target.builtin, &args, scope, layout) + case target.library != nil: + return c.compileLibraryCall(target.library, &args, scope, layout) + case target.calc == nil: + return nil, ineligible(fmt.Sprintf("%s does not resolve to a calc", target.qualName)) + case target.shape == nil: + return nil, ineligible(fmt.Sprintf("%s is not a calc with a shape", target.qualName)) + } + return c.compileCalcCall(target.shape, &args, scope, layout) +} + +// compileCalcCall compiles a call of another eligible calc, the arguments +// placed in its slots and its arity checked here rather than per call. +func (c *calcCompiler) compileCalcCall(callee *calcShape, args *callArguments, scope *symbols.Scope, layout *frameLayout) (*cnode, error) { + if callee.compileState == compileUndecided { + c.batch.compile(callee) + } + if callee.compileState == compileIneligible { + return nil, ineligible(fmt.Sprintf("callee %s is ineligible", callee.Name)) + } + c.batch.call(c.shape, callee) + if len(args.exprs) > len(callee.Params) { + return nil, ineligible(fmt.Sprintf("%s called with %d arguments for %d parameters", callee.Name, len(args.exprs), len(callee.Params))) + } + slots, bound, err := args.slotsFor(callee.ParamNames) + if err != nil { + return nil, ineligible(fmt.Sprintf("%s: %v", callee.Name, err)) + } + for i := range callee.Params { + if !bound.has(i) && callee.Params[i].Default == nil { + return nil, ineligible(fmt.Sprintf("%s called without an argument for %q", callee.Name, callee.Params[i].Name)) + } + } + kids, err := c.compileArguments(args, scope, layout) + if err != nil { + return nil, err + } + return callNode(callee.compiled, kids, slots, bound), nil +} + +// compileLibraryCall compiles a call of a scalar library function binding every +// parameter; any other arity keeps the evaluator's own report. +func (c *calcCompiler) compileLibraryCall(fn *libraryFunction, args *callArguments, scope *symbols.Scope, layout *frameLayout) (*cnode, error) { + if !fn.scalar { + return nil, ineligible(fmt.Sprintf("library function %s is not over scalars alone", fn.name)) + } + if len(fn.params) > libraryArity || fn.hasOptional() { + return nil, ineligible(fmt.Sprintf("library function %s has optional or many parameters", fn.name)) + } + if len(args.exprs) != len(fn.params) { + return nil, ineligible(fmt.Sprintf("%s called with %d arguments for %d parameters", fn.name, len(args.exprs), len(fn.params))) + } + slots, bound, err := args.slotsFor(fn.paramNames()) + if err != nil { + return nil, ineligible(fmt.Sprintf("%s: %v", fn.name, err)) + } + if bound != allParams(len(fn.params)) { + return nil, ineligible(fmt.Sprintf("%s called without an argument for every parameter", fn.name)) + } + kids, err := c.compileArguments(args, scope, layout) + if err != nil { + return nil, err + } + return libraryCallNode(fn, kids, slots), nil +} + +// compileAggregate compiles a sum or product of one scalar argument, which the +// evaluator's built-in aggregates as a one-element collection. +func (c *calcCompiler) compileAggregate(name string, fn builtinFunc, args *callArguments, scope *symbols.Scope, layout *frameLayout) (*cnode, error) { + if !scalarAggregates[name] { + return nil, ineligible(fmt.Sprintf("collection function %s", name)) + } + if args.names != nil || len(args.exprs) != 1 { + return nil, ineligible(fmt.Sprintf("%s called other than with one positional argument", name)) + } + arg, err := c.compileNode(args.exprs[0], scope, layout) + if err != nil { + return nil, err + } + return aggregateNode(name, fn, arg), nil +} + +// invoke runs the body in the frame at base on the scalar stack; bound says which +// parameter slots hold arguments, the rest take their defaults. +func (c *compiledCalc) invoke(ctx *Context, base int, bound paramSet) (scalar, error) { + if err := ctx.enterCalc(c.name); err != nil { + return scalar{}, err + } + frame := ctx.frameAt(base, c.frameSize) + for i := range c.params { + p := &c.params[i] + v := frame[i] + source := "argument" + if !bound.has(i) { + var err error + if v, err = p.dflt(ctx, frame); err != nil { + ctx.leaveCalc() + return scalar{}, calcDefaultError(c.kind, c.name, p.name, err) + } + source = "default" + frame[i] = v + } + if !p.check.accepts(v) { + err := p.check.refuse(ctx, v, func() string { + return fmt.Sprintf("%s %s: %s for parameter %q", c.kind, c.name, source, p.name) + }) + if err != nil { + ctx.leaveCalc() + return scalar{}, err + } + } + } + result, err := c.body(ctx, frame) + ctx.leaveCalc() + if err != nil { + return scalar{}, calcFrame(c.kind, c.name, fmt.Errorf("%s%w", c.bodyErr, err)) + } + if c.result != nil && !c.result.accepts(result) { + if err := c.result.refuse(ctx, result, func() string { return c.resultWhat }); err != nil { + return scalar{}, calcFrame(c.kind, c.name, err) + } + } + return result, nil +} + +// frameAt extends the scalar stack to hold a frame of size slots at base; the +// local slots are written by their declarations before any read. +func (ctx *Context) frameAt(base, size int) []scalar { + if top := base + size; len(ctx.scalarStack) < top { + ctx.reserveScalars(top - len(ctx.scalarStack)) + } + return ctx.scalarStack[base : base+size] +} + +// invokeBoxed unboxes the arguments into a frame — by position, else by name — +// and runs the body; it declines a non-scalar argument or an unbound parameter. +func (c *compiledCalc) invokeBoxed(ctx *Context, args calcArgs) (Value, bool, error) { + if len(args.positional) > len(c.params) { + return Value{}, false, nil + } + base := len(ctx.scalarStack) + var bound paramSet + if args.named == nil { + for i, arg := range args.positional { + s, ok := scalarOf(arg) + if !ok { + ctx.scalarStack = ctx.scalarStack[:base] + return Value{}, false, nil + } + ctx.scalarStack = append(ctx.scalarStack, s) + bound.add(i) + } + } else { + ctx.reserveScalars(len(c.params)) + for i := range c.params { + arg, ok := args.named[c.params[i].name] + if i < len(args.positional) { + arg, ok = args.positional[i], true + } + if !ok { + continue + } + s, ok := scalarOf(arg) + if !ok { + ctx.scalarStack = ctx.scalarStack[:base] + return Value{}, false, nil + } + ctx.scalarStack[base+i] = s + bound.add(i) + } + } + if bound&c.required != c.required { + ctx.scalarStack = ctx.scalarStack[:base] + return Value{}, false, nil + } + result, err := c.invoke(ctx, base, bound) + ctx.scalarStack = ctx.scalarStack[:base] + if err != nil { + return Value{}, true, err + } + return result.boxed(), true, nil +} diff --git a/internal/core/runtime/compile_constructs_test.go b/internal/exec/runtime/compile_constructs_test.go similarity index 97% rename from internal/core/runtime/compile_constructs_test.go rename to internal/exec/runtime/compile_constructs_test.go index 12e4733c30..28981d4c82 100644 --- a/internal/core/runtime/compile_constructs_test.go +++ b/internal/exec/runtime/compile_constructs_test.go @@ -9,13 +9,13 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // compiledFixtures are the models under testdata/compiled, each exercising one @@ -79,9 +79,9 @@ func buildCompiledFixture(path string, src []byte) *compiledFixture { resolver := resolve.New(idx) model := semantics.NewModel(resolver) f := &compiledFixture{root: idx.DocumentRoot(path)} - f.compiled = NewContext(NewModel(model, resolver), differentialMaxSteps) + f.compiled = NewContext(typedModel(model, resolver), differentialMaxSteps) f.compiled.SetCalcCompile(true) - f.reference = NewContext(NewModel(model, resolver), differentialMaxSteps) + f.reference = NewContext(typedModel(model, resolver), differentialMaxSteps) f.reference.SetCalcCompile(false) return f } diff --git a/internal/core/runtime/compile_differential_test.go b/internal/exec/runtime/compile_differential_test.go similarity index 92% rename from internal/core/runtime/compile_differential_test.go rename to internal/exec/runtime/compile_differential_test.go index 9dc5866df4..e6679113f3 100644 --- a/internal/core/runtime/compile_differential_test.go +++ b/internal/exec/runtime/compile_differential_test.go @@ -9,12 +9,12 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // differentialRoots are the model trees whose calcs the differential test @@ -23,7 +23,7 @@ import ( var differentialRoots = []string{ filepath.Join("testdata", "conformance"), filepath.Join("testdata", "compiled"), - filepath.Join("..", "..", "..", "testdata"), + filepath.Join("..", "..", "..", "tests", "testdata"), filepath.Join("..", "..", "..", "examples"), filepath.Join("..", "..", "..", "docs", "manual", "examples"), } @@ -121,9 +121,9 @@ func differentialFile(t *testing.T, path string, reasons map[string]int) (eligib idx.ExpandWildcardImports() resolver := resolve.New(idx) model := semantics.NewModel(resolver) - compiled := NewContext(NewModel(model, resolver), differentialMaxSteps) + compiled := NewContext(typedModel(model, resolver), differentialMaxSteps) compiled.SetCalcCompile(true) - reference := NewContext(NewModel(model, resolver), differentialMaxSteps) + reference := NewContext(typedModel(model, resolver), differentialMaxSteps) reference.SetCalcCompile(false) root := idx.DocumentRoot(path) diff --git a/internal/exec/runtime/compile_test.go b/internal/exec/runtime/compile_test.go new file mode 100644 index 0000000000..352b7c2ed3 --- /dev/null +++ b/internal/exec/runtime/compile_test.go @@ -0,0 +1,434 @@ +package runtime + +import ( + "errors" + "math" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// compileModel holds calcs across the eligible subset and its borders. +const compileModel = ` +package test { + private import ScalarValues::*; + calc def Fib { + in k : Integer; + return : Integer = if k <= 1 ? k else Fib(k - 1) + Fib(k - 2); + } + calc def SumTo { + in n : Integer; + return : Integer = if n <= 0 ? 0 else n + SumTo(n - 1); + } + calc def Add { in a : Integer; in b : Integer; return : Integer = a + b; } + calc def Sub { in a : Integer; in b : Integer; return : Integer = a - b; } + calc def Mul { in a : Integer; in b : Integer; return : Integer = a * b; } + calc def Div { in a : Integer; in b : Integer; return : Real = a / b; } + calc def Mod { in a : Integer; in b : Integer; return : Integer = a % b; } + calc def Pow { in a : Integer; in b : Integer; return r = a ** b; } + calc def Neg { in a : Integer; return : Integer = -a; } + calc def Mixed { in a : Integer; in b : Real; return : Real = a * b + a / 2 - b % 2; } + calc def Less { in a : Integer; in b : Real; return : Boolean = a < b and a <= b or a > b and a >= b; } + calc def Same { in a : Integer; in b : Real; return : Boolean = a == b xor a === b; } + calc def Differs { in a : Integer; in b : Real; return : Boolean = a != b implies a !== b; } + calc def Guarded { in k : Integer; return : Boolean = k == 0 or 7 % k == 0; } + calc def Implied { in k : Integer; return : Boolean = k != 0 implies 7 % k == 0; } + calc def Choose { in k : Integer; return : Integer = if k > 0 ? 100 % k else 100 % (k - 1); } + calc def Not { in b : Boolean; return : Boolean = not b; } + calc def Least { return : Integer = -9223372036854775808; } + calc def Dflt { in a : Integer; in b : Integer = 10; in c : Real = 2.5; return : Real = a + b * c; } + calc def SameReal { in r : Real; return : Real = r; } + calc def SameRational { in q : Rational; return : Rational = q; } + calc def Natural1 { in n : Natural; return : Natural = n - 1; } + calc def Positive1 { in n : Positive; return : Positive = n - 1; } + calc def TailPos { in a : Integer; return : Rank; a - 1 } + attribute def Rank :> Positive; + calc def IsEven { in n : Integer; return : Boolean = if n == 0 ? true else IsOdd(n - 1); } + calc def IsOdd { in n : Integer; return : Boolean = if n == 0 ? false else IsEven(n - 1); } + calc def Deep { in n : Integer; return : Integer = if n <= 0 ? 0 else 1 + Deep(n - 1); } + calc def Nested { in a : Integer; in b : Integer; return : Integer = Add(Mul(a, b), Sub(a, Fib(b))); } + calc def Tail { in a : Integer; in b : Integer; a * b + Fib(b) } + calc def TailNat { in a : Integer; return : Natural; a - 1 } + + calc def Twice { in n : Integer; out a = n * 2; } + calc def UsesUsage { in k : Integer; calc tw : Twice { in n = k; } return : Integer = tw.a; } + calc def Local { in k : Integer; attribute m = k * 2; return : Integer = m; } + calc def LocalPos { in k : Integer; attribute p : Positive = k - 1; return : Integer = p; } + enum def Level :> Integer { low = 1; high = 3; } + calc def LocalEnum { in k : Integer; attribute l : Level = k; return : Integer = l + 0; } + calc def Stringy { in k : Integer; return : String = "x"; } + calc def NonLiteralDefault { in a : Integer; in b : Integer = a + 1; return : Integer = a + b; } + calc def Collects { in k : Integer; return r = (1, 2, k)->size(); } + calc def CallsIneligible { in k : Integer; return : Integer = k + UsesUsage(k); } + calc def CycleA { in k : Integer; return : Integer = if k <= 0 ? 0 else CycleB(k - 1); } + calc def CycleB { in k : Integer; return : Integer = if k <= 0 ? 0 else CycleA(k - 1) + UsesUsage(k); } + calc def NamedCall { in k : Integer; return : Integer = Add(a = k, b = 1); } + calc def UnknownName { in k : Integer; return : Integer = Add(a = k, c = 1); } + calc def ReceiverNamed { in k : Integer; return : Integer = k->Add(b = 1); } + calc def Untyped { in k; k + 1 } + calc def WithOut { in k : Integer; out o : Integer = k; return : Integer = k + 1; } + calc def Inherits :> Add; + calc def Redeclares :> Dflt { in b : Integer = 3; } +} +` + +// compileRuntime builds compileModel with the compiled tier on or off. +func compileRuntime(t *testing.T, compile bool) (*symbols.Scope, *Context) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, compileModel)) + ctx.maxSteps = DefaultMaxSteps + ctx.SetCalcCompile(compile) + return idx.DocumentRoot(""), ctx +} + +func intArg(i int64) Value { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: i}} +} + +func realArg(f float64) Value { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: f}} +} + +func boolArg(b bool) Value { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: b}} +} + +// calcOutcome is what one invocation observably did: its value or error, and +// the steps it spent. +type calcOutcome struct { + value Value + err error + steps int64 +} + +func invokeOutcome(t *testing.T, ctx *Context, scope *symbols.Scope, name string, args ...Value) calcOutcome { + t.Helper() + sym := findSymbolByName(scope, name, ast.DefCalc) + if sym == nil { + t.Fatalf("calc %s not found", name) + } + value, err := ctx.InvokeCalc(sym, args, scope) + return calcOutcome{value: value, err: err, steps: ctx.run.steps} +} + +// wantSameOutcome invokes name on both tiers and requires the same value or the +// same error text, and the same step count. +func wantSameOutcome(t *testing.T, name string, args ...Value) calcOutcome { + t.Helper() + scope, ctx := compileRuntime(t, true) + compiled := invokeOutcome(t, ctx, scope, name, args...) + scope, ctx = compileRuntime(t, false) + reference := invokeOutcome(t, ctx, scope, name, args...) + wantOutcomesEqual(t, name, compiled, reference) + return compiled +} + +func wantOutcomesEqual(t *testing.T, name string, compiled, reference calcOutcome) { + t.Helper() + switch { + case (compiled.err == nil) != (reference.err == nil): + t.Errorf("%s: compiled error %v, reference error %v", name, compiled.err, reference.err) + case compiled.err != nil: + if compiled.err.Error() != reference.err.Error() { + t.Errorf("%s: compiled error %q, reference error %q", name, compiled.err, reference.err) + } + default: + if !outcomeValuesIdentical(compiled.value, reference.value) { + t.Errorf("%s: compiled %s, reference %s", name, FormatTraceValue(compiled.value), FormatTraceValue(reference.value)) + } + } + if compiled.steps != reference.steps { + t.Errorf("%s: compiled spent %d steps, reference %d", name, compiled.steps, reference.steps) + } +} + +// outcomeValuesIdentical is identity down to the bits of a Real, so a NaN is +// identical to the same NaN and a negative zero only to a negative zero. +func outcomeValuesIdentical(compiled, reference Value) bool { + if compiled.Kind == ValConst && reference.Kind == ValConst && + compiled.Const.Kind == semantics.ValReal && reference.Const.Kind == semantics.ValReal { + return math.Float64bits(compiled.Const.Real) == math.Float64bits(reference.Const.Real) + } + return valueIdentical(compiled, reference) +} + +func wantOutcomeInt(t *testing.T, name string, got calcOutcome, want int64) { + t.Helper() + if got.err != nil { + t.Fatalf("%s: %v", name, got.err) + } + if got.value.Kind != ValConst || got.value.Const.Kind != semantics.ValInt || got.value.Const.Int != want { + t.Fatalf("%s = %s, want %d", name, FormatTraceValue(got.value), want) + } +} + +func wantErrorIs(t *testing.T, name string, got calcOutcome, target error) { + t.Helper() + if !errors.Is(got.err, target) { + t.Fatalf("%s: error %v, want %v", name, got.err, target) + } +} + +// The compiled tier spends the steps the evaluator would, so the budget trips +// at the same point either way. +func TestCompiledCalcStepParity(t *testing.T) { + for _, k := range []int64{0, 1, 2, 10, 15} { + wantSameOutcome(t, "Fib", intArg(k)) + } + for _, n := range []int64{0, 1, 50, 500} { + wantSameOutcome(t, "SumTo", intArg(n)) + } + got := wantSameOutcome(t, "Fib", intArg(20)) + wantOutcomeInt(t, "Fib(20)", got, 6765) + if got.steps == 0 { + t.Fatal("Fib(20) spent no steps") + } +} + +// Every eligible operator answers as the evaluator does, at the edges too. +func TestCompiledCalcOperatorParity(t *testing.T) { + extremes := []int64{0, 1, -1, 2, -2, 7, math.MaxInt64, math.MinInt64, math.MaxInt64 - 1, math.MinInt64 + 1} + for _, name := range []string{"Add", "Sub", "Mul", "Div", "Mod", "Pow"} { + for _, a := range extremes { + for _, b := range extremes { + wantSameOutcome(t, name, intArg(a), intArg(b)) + } + } + } + for _, a := range extremes { + wantSameOutcome(t, "Neg", intArg(a)) + wantSameOutcome(t, "Guarded", intArg(a)) + wantSameOutcome(t, "Implied", intArg(a)) + wantSameOutcome(t, "Choose", intArg(a)) + for _, b := range []float64{0, 1, -1, 0.5, 2, 1e308, -1e308, 3} { + wantSameOutcome(t, "Mixed", intArg(a), realArg(b)) + wantSameOutcome(t, "Less", intArg(a), realArg(b)) + wantSameOutcome(t, "Same", intArg(a), realArg(b)) + wantSameOutcome(t, "Differs", intArg(a), realArg(b)) + } + } + wantSameOutcome(t, "Not", boolArg(true)) + wantSameOutcome(t, "Not", boolArg(false)) + wantOutcomeInt(t, "Least", wantSameOutcome(t, "Least"), math.MinInt64) + wantOutcomeInt(t, "Untyped(3)", wantSameOutcome(t, "Untyped", intArg(3)), 4) + wantSameOutcome(t, "Untyped", realArg(0.5)) + wantSameOutcome(t, "Untyped", boolArg(true)) + wantOutcomeInt(t, "Tail(3, 5)", wantSameOutcome(t, "Tail", intArg(3), intArg(5)), 20) + wantOutcomeInt(t, "TailNat(4)", wantSameOutcome(t, "TailNat", intArg(4)), 3) + if refused := wantSameOutcome(t, "TailNat", intArg(0)); refused.err == nil { + t.Fatal("TailNat(0) yielded a negative Natural") + } +} + +// The errors a body raises keep their kind, their text and their calc frames. +func TestCompiledCalcErrorParity(t *testing.T) { + overflow := wantSameOutcome(t, "Add", intArg(math.MaxInt64), intArg(1)) + wantErrorIs(t, "Add", overflow, semantics.ErrArithmeticOverflow) + byZero := wantSameOutcome(t, "Mod", intArg(1), intArg(0)) + wantErrorIs(t, "Mod", byZero, ErrDivisionByZero) + nested := wantSameOutcome(t, "Nested", intArg(math.MaxInt64), intArg(3)) + wantErrorIs(t, "Nested", nested, semantics.ErrArithmeticOverflow) + if !strings.Contains(nested.err.Error(), "Mul") || !strings.Contains(nested.err.Error(), "Nested") { + t.Errorf("Nested error names neither frame: %v", nested.err) + } + negative := wantSameOutcome(t, "Natural1", intArg(0)) + wantErrorIs(t, "Natural1", negative, ErrTypeMismatch) + tailNegative := wantSameOutcome(t, "TailNat", intArg(0)) + wantErrorIs(t, "TailNat", tailNegative, ErrTypeMismatch) + tailOverflow := wantSameOutcome(t, "Tail", intArg(math.MaxInt64), intArg(2)) + wantErrorIs(t, "Tail", tailOverflow, semantics.ErrArithmeticOverflow) + badArg := wantSameOutcome(t, "Natural1", intArg(-1)) + wantErrorIs(t, "Natural1", badArg, ErrTypeMismatch) + wantOutcomeInt(t, "Positive1(2)", wantSameOutcome(t, "Positive1", intArg(2)), 1) + wantErrorIs(t, "Positive1", wantSameOutcome(t, "Positive1", intArg(0)), ErrTypeMismatch) + wantErrorIs(t, "Positive1", wantSameOutcome(t, "Positive1", intArg(1)), ErrTypeMismatch) + wantOutcomeInt(t, "TailPos(2)", wantSameOutcome(t, "TailPos", intArg(2)), 1) + wantErrorIs(t, "TailPos", wantSameOutcome(t, "TailPos", intArg(1)), ErrTypeMismatch) + wantOutcomeInt(t, "LocalPos(3)", wantSameOutcome(t, "LocalPos", intArg(3)), 2) + wantErrorIs(t, "LocalPos", wantSameOutcome(t, "LocalPos", intArg(1)), ErrTypeMismatch) + wantOutcomeInt(t, "LocalEnum(3)", wantSameOutcome(t, "LocalEnum", intArg(3)), 3) + wantErrorIs(t, "LocalEnum", wantSameOutcome(t, "LocalEnum", intArg(2)), ErrTypeMismatch) + realForInt := wantSameOutcome(t, "Add", realArg(1.5), intArg(1)) + wantErrorIs(t, "Add", realForInt, ErrTypeMismatch) + wantOutcomeReal(t, "SameReal(Inf)", wantSameOutcome(t, "SameReal", realArg(math.Inf(1))), math.Inf(1)) + wantOutcomeReal(t, "SameRational(1.5)", wantSameOutcome(t, "SameRational", realArg(1.5)), 1.5) + wantErrorIs(t, "SameReal", wantSameOutcome(t, "SameReal", realArg(math.NaN())), ErrTypeMismatch) + wantErrorIs(t, "SameRational", wantSameOutcome(t, "SameRational", realArg(math.NaN())), ErrTypeMismatch) + boolForInt := wantSameOutcome(t, "Add", boolArg(true), intArg(1)) + wantErrorIs(t, "Add", boolForInt, ErrTypeMismatch) + intForBool := wantSameOutcome(t, "Not", intArg(1)) + wantErrorIs(t, "Not", intForBool, ErrTypeMismatch) + tooMany := wantSameOutcome(t, "Neg", intArg(1), intArg(2)) + if tooMany.err == nil { + t.Fatal("Neg(1, 2) succeeded") + } + tooFew := wantSameOutcome(t, "Add", intArg(1)) + if tooFew.err == nil { + t.Fatal("Add(1) succeeded") + } +} + +// Defaults literal in the declaration bind as the evaluator binds them. +func TestCompiledCalcDefaults(t *testing.T) { + full := wantSameOutcome(t, "Dflt", intArg(1), intArg(2), realArg(3)) + if full.err != nil || full.value.Const.Real != 7 { + t.Fatalf("Dflt(1, 2, 3) = %s, %v", FormatTraceValue(full.value), full.err) + } + partial := wantSameOutcome(t, "Dflt", intArg(1)) + if partial.err != nil || partial.value.Const.Real != 26 { + t.Fatalf("Dflt(1) = %s, %v", FormatTraceValue(partial.value), partial.err) + } + // An inherited body binds the flattened parameters, a redeclared one in the + // slot of the parameter it redefines. + inherited := wantSameOutcome(t, "Inherits", intArg(2), intArg(3)) + wantOutcomeInt(t, "Inherits(2, 3)", inherited, 5) + redeclared := wantSameOutcome(t, "Redeclares", intArg(1)) + if redeclared.err != nil || redeclared.value.Const.Real != 8.5 { + t.Fatalf("Redeclares(1) = %s, %v", FormatTraceValue(redeclared.value), redeclared.err) + } +} + +// Recursion through a cycle resolves its targets lazily and still recurses in +// bounded depth. +func TestCompiledCalcCyclesAndLimits(t *testing.T) { + even := wantSameOutcome(t, "IsEven", intArg(101)) + if even.err != nil || even.value.Const.Bool { + t.Fatalf("IsEven(101) = %s, %v", FormatTraceValue(even.value), even.err) + } + deep := wantSameOutcome(t, "Deep", intArg(100000)) + wantErrorIs(t, "Deep", deep, ErrCalcRecursionLimit) + + for _, compile := range []bool{true, false} { + scope, ctx := compileRuntime(t, compile) + ctx.maxSteps = 1000 + got := invokeOutcome(t, ctx, scope, "Fib", intArg(20)) + wantErrorIs(t, "Fib under a small budget", got, ErrStepLimitExceeded) + if got.steps != ctx.maxSteps+1 { + t.Errorf("compile=%v: stopped at step %d, want %d", compile, got.steps, ctx.maxSteps+1) + } + } +} + +// eligibility answers whether the calc named compiled, asking once. +func eligibility(t *testing.T, ctx *Context, scope *symbols.Scope, name string) bool { + t.Helper() + sym := findSymbolByName(scope, name, ast.DefCalc) + if sym == nil { + t.Fatalf("calc %s not found", name) + } + shape, err := ctx.calcShapeOf(sym) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + compiled := ctx.compiledCalcOf(shape) + if (compiled != nil) != (shape.compileState == compileEligible) { + t.Fatalf("%s: compiled %v but state %d", name, compiled != nil, shape.compileState) + } + return compiled != nil +} + +// Only the scalar subset compiles — untyped and redeclared parameters, scalar +// locals, named calls included; a caller of an ineligible calc is ineligible too. +func TestCompiledCalcEligibility(t *testing.T) { + scope, ctx := compileRuntime(t, true) + eligible := []string{"Fib", "SumTo", "Add", "Div", "Pow", "Mixed", "Less", "Same", "Not", "Least", "Dflt", "Natural1", "Positive1", "TailPos", "IsEven", "IsOdd", "Nested", "Tail", "TailNat", "Untyped", "Inherits", "Local", "LocalPos", "NamedCall", "Redeclares"} + for _, name := range eligible { + if !eligibility(t, ctx, scope, name) { + t.Errorf("%s is ineligible, want eligible", name) + } + } + ineligible := []string{"UsesUsage", "Stringy", "NonLiteralDefault", "Collects", "CallsIneligible", "CycleA", "CycleB", "Twice", "WithOut", "UnknownName", "ReceiverNamed", "LocalEnum"} + for _, name := range ineligible { + if eligibility(t, ctx, scope, name) { + t.Errorf("%s is eligible, want ineligible", name) + } + if sym := findSymbolByName(scope, name, ast.DefCalc); ctx.model.calcShapes[sym].ineligibleWhy == "" { + t.Errorf("%s records no reason for its ineligibility", name) + } + } + for _, name := range ineligible { + wantSameOutcome(t, name, intArg(3)) + } + wantErrorIs(t, "UnknownName", wantSameOutcome(t, "UnknownName", intArg(3)), ErrUnknownParameter) + wantErrorIs(t, "ReceiverNamed", wantSameOutcome(t, "ReceiverNamed", intArg(3)), ErrReceiverWithNamedArgs) +} + +// A traced run takes the evaluator, so the trace records every sub-expression. +func TestCompiledCalcTraceFallsBack(t *testing.T) { + var traces [2]string + for i, compile := range []bool{true, false} { + scope, ctx := compileRuntime(t, compile) + tr := NewTraceRecorder() + ctx.SetTrace(tr) + got := invokeOutcome(t, ctx, scope, "Fib", intArg(5)) + wantOutcomeInt(t, "Fib(5)", got, 5) + traces[i] = tr.String() + sym := findSymbolByName(scope, "Fib", ast.DefCalc) + shape, err := ctx.calcShapeOf(sym) + if err != nil { + t.Fatal(err) + } + if shape.compileState != compileUndecided { + t.Errorf("compile=%v: a traced run compiled Fib", compile) + } + } + if traces[0] != traces[1] { + t.Errorf("traces differ:\n%s\n---\n%s", traces[0], traces[1]) + } + if !strings.Contains(traces[0], "eval operator -") { + t.Errorf("trace records no sub-expression:\n%s", traces[0]) + } +} + +// Named arguments and non-scalar values take the evaluator. +func TestCompiledCalcDeclines(t *testing.T) { + scope, ctx := compileRuntime(t, true) + sym := findSymbolByName(scope, "Add", ast.DefCalc) + named, err := ctx.InvokeCalcNamed(sym, map[string]Value{"b": intArg(2), "a": intArg(1)}, scope) + if err != nil || named.Const.Int != 3 { + t.Fatalf("Add(a = 1, b = 2) = %s, %v", FormatTraceValue(named), err) + } + str := wantSameOutcome(t, "Add", NewStringValue("x"), intArg(1)) + wantErrorIs(t, "Add", str, ErrTypeMismatch) + null := wantSameOutcome(t, "Add", Value{Kind: ValNull}, intArg(1)) + if null.err == nil { + t.Fatal("Add(null, 1) succeeded") + } +} + +// OPENSYSML_CALC_COMPILE=0 turns the tier off; anything else leaves it on. +func TestCalcCompileFromEnv(t *testing.T) { + for raw, want := range map[string]bool{"": true, "1": true, "yes": true, "0": false, " false ": false, "OFF": false, "no": false} { + if got := calcCompileFromValue(raw); got != want { + t.Errorf("calcCompileFromValue(%q) = %v, want %v", raw, got, want) + } + } + t.Setenv(CalcCompileEnvVar, "0") + _, ctx := compileRuntimeFromEnv(t) + if ctx.CalcCompile() { + t.Fatalf("%s=0 left the compiled tier on", CalcCompileEnvVar) + } + t.Setenv(CalcCompileEnvVar, "") + t.Setenv("SYSML_CALC_COMPILE", "0") + _, ctx = compileRuntimeFromEnv(t) + if ctx.CalcCompile() { + t.Fatal("SYSML_CALC_COMPILE=0 left the compiled tier on") + } + t.Setenv("SYSML_CALC_COMPILE", "") + scope, ctx := compileRuntimeFromEnv(t) + if !ctx.CalcCompile() { + t.Fatal("the compiled tier is off by default") + } + if !eligibility(t, ctx, scope, "Fib") { + t.Fatal("Fib did not compile with the tier on") + } +} + +func compileRuntimeFromEnv(t *testing.T) (*symbols.Scope, *Context) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, compileModel)) + return idx.DocumentRoot(""), ctx +} diff --git a/internal/core/runtime/compiled_ops.go b/internal/exec/runtime/compiled_ops.go similarity index 99% rename from internal/core/runtime/compiled_ops.go rename to internal/exec/runtime/compiled_ops.go index 9ab1a806cc..9b93ee5f1b 100644 --- a/internal/core/runtime/compiled_ops.go +++ b/internal/exec/runtime/compiled_ops.go @@ -5,8 +5,8 @@ import ( "math" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // scalar is an unboxed Integer, Real or Boolean, the values the compiled calc diff --git a/internal/core/runtime/compiled_stmts.go b/internal/exec/runtime/compiled_stmts.go similarity index 98% rename from internal/core/runtime/compiled_stmts.go rename to internal/exec/runtime/compiled_stmts.go index 0c201f68bb..234437537d 100644 --- a/internal/core/runtime/compiled_stmts.go +++ b/internal/exec/runtime/compiled_stmts.go @@ -3,9 +3,9 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // frameLayout assigns frame slots to the names a body reads: parameters first, diff --git a/internal/core/runtime/complex.go b/internal/exec/runtime/complex.go similarity index 96% rename from internal/core/runtime/complex.go rename to internal/exec/runtime/complex.go index c18927b12c..4b51fe2188 100644 --- a/internal/core/runtime/complex.go +++ b/internal/exec/runtime/complex.go @@ -5,9 +5,9 @@ import ( "math" "math/cmplx" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // realConst is the Real runtime value of a float64. diff --git a/internal/core/runtime/complex_test.go b/internal/exec/runtime/complex_test.go similarity index 98% rename from internal/core/runtime/complex_test.go rename to internal/exec/runtime/complex_test.go index 0e449366af..f0b21fdfbb 100644 --- a/internal/core/runtime/complex_test.go +++ b/internal/exec/runtime/complex_test.go @@ -5,8 +5,8 @@ import ( "math" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // complexContext builds a runtime over the standard library and evaluates diff --git a/internal/core/runtime/condition.go b/internal/exec/runtime/condition.go similarity index 97% rename from internal/core/runtime/condition.go rename to internal/exec/runtime/condition.go index 3a1f44ef03..8cf071172d 100644 --- a/internal/core/runtime/condition.go +++ b/internal/exec/runtime/condition.go @@ -7,10 +7,10 @@ import ( "strconv" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lexer" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // ErrAmbiguousSubject is returned when more than one object carries the checked @@ -571,6 +571,10 @@ func (ctx *Context) rootInstances() []*Instance { if inst == nil || held[inst.ID] || (nestedFeature(inst.Type) && ctx.readThrough(inst)) { continue } + // A destroyed object is no subject of anything; what it held is reached from where it stands. + if ctx.checkNotDestroyed(inst) != nil { + continue + } if ctx.denotesOccurrence(inst) { denoted[inst.Type] = append(denoted[inst.Type], inst) continue @@ -602,8 +606,13 @@ func (ctx *Context) heldObjectIDs() map[int64]bool { if inst == nil { continue } + destroyed := ctx.checkNotDestroyed(inst) != nil for _, fv := range inst.FeatureValues { for _, id := range heldObjects(fv.HeldValue()) { + // A destroyed holder is not walked, so a live object it refers to stands on its own. + if child, ok := ctx.instances[id]; destroyed && ok && ctx.checkNotDestroyed(child) == nil { + continue + } held[id] = true } } @@ -612,7 +621,8 @@ func (ctx *Context) heldObjectIDs() map[int64]bool { } // carriersUnder returns the objects reachable from roots whose type carries the -// features owner declares, roots included, in identity order. A declaration is +// features owner declares, roots included, in identity order; a destroyed object is +// passed over with what it holds. A declaration is // descended into once per path, so recursive composition is a finite search, and // one object stands for each declaration reached, so objects a multiplicity // repeated are one candidate however deep the named declaration sits in them. @@ -623,7 +633,8 @@ func (ctx *Context) carriersUnder(roots []*Instance, owner *symbols.Symbol) []ca path := make(map[*symbols.Symbol]bool) var descend func(root, inst *Instance, through string, features []string) descend = func(root, inst *Instance, through string, features []string) { - if inst == nil || seen[inst.ID] { + // A destroyed object a live holder retains is no carrier, and holds nothing to walk. + if inst == nil || seen[inst.ID] || ctx.checkNotDestroyed(inst) != nil { return } seen[inst.ID] = true @@ -802,7 +813,7 @@ func (ctx *Context) carrierLabels(carriers []carrier) []string { func qualifiedText(names []string) string { parts := make([]string, len(names)) for i, name := range names { - parts[i] = lexer.NameText(name) + parts[i] = source.NameText(name) } return strings.Join(parts, "::") } diff --git a/internal/core/runtime/condition_test.go b/internal/exec/runtime/condition_test.go similarity index 97% rename from internal/core/runtime/condition_test.go rename to internal/exec/runtime/condition_test.go index ede3f392cd..31c299d12d 100644 --- a/internal/core/runtime/condition_test.go +++ b/internal/exec/runtime/condition_test.go @@ -5,12 +5,12 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // conditionFixture loads src and returns the runtime context and the scope of @@ -22,7 +22,7 @@ func conditionFixture(t *testing.T, src string) (*Context, *symbols.Scope) { idx.AddDocument("test.sysml", file) idx.ExpandWildcardImports() resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) return ctx, idx.DocumentRoot("test.sysml").Children()[0] } diff --git a/internal/core/runtime/conditions_of_test.go b/internal/exec/runtime/conditions_of_test.go similarity index 99% rename from internal/core/runtime/conditions_of_test.go rename to internal/exec/runtime/conditions_of_test.go index 495a914463..f7e3bbb390 100644 --- a/internal/core/runtime/conditions_of_test.go +++ b/internal/exec/runtime/conditions_of_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // labelsOf renders the conditions collected, with their role, so one comparison diff --git a/internal/exec/runtime/conformance_test.go b/internal/exec/runtime/conformance_test.go new file mode 100644 index 0000000000..f06f2eaadf --- /dev/null +++ b/internal/exec/runtime/conformance_test.go @@ -0,0 +1,2472 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "math" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" + "github.com/Open-MBEE/OpenSysML/tests/fixtures" +) + +// ExpectedValue represents a typed value in expected.json +type ExpectedValue struct { + Type string `json:"type"` + Value interface{} `json:"value"` + // Unit is the measurement unit a Quantity is expressed in, as written + // ("m/s"). A quantity carries it, so a case asserting one pins that the unit + // survived the computation rather than only the magnitude. A MeasurementRef + // is that unit alone, with no value. + Unit string `json:"unit,omitempty"` + // Im is the imaginary part of a Complex, whose value is its real part. + Im *float64 `json:"im,omitempty"` + // Text is how a CoordinateFrame or CoordinateTransformation prints: the + // declaration it is over its axes (`spatialCF [m, m, m]`), or the frames it relates. + Text string `json:"text,omitempty"` + // Elements are the members a Sequence holds, in order, for a case asserting a + // multi-valued feature. Set it instead of value. A Vector's are its numbers, a + // VectorQuantity's its axes as Quantity values, an Array's or a TensorQuantity's + // its row-major elements. + Elements []ExpectedValue `json:"elements,omitempty"` + // Dimensions are an Array's or a TensorQuantity's, in order. + Dimensions []int64 `json:"dimensions,omitempty"` + // Error is the text producing this value must fail with, for a feature value whose + // contract is a diagnostic. Set it instead of type and value. + Error string `json:"error,omitempty"` +} + +// ExpectedEvaluation is one application of a case's calc as a function value: +// the calc, its arguments, and what it computed or failed with. +type ExpectedEvaluation struct { + Function string `json:"function"` + Arguments []ExpectedValue `json:"arguments"` + Result *ExpectedValue `json:"result,omitempty"` + Error string `json:"error,omitempty"` + // Selected marks the evaluation whose argument the case returned; Tied one + // computing the same result without being selected. + Selected bool `json:"selected,omitempty"` + Tied bool `json:"tied,omitempty"` +} + +// ExpectedEvent represents an event to inject during state machine execution: +// either a signal (`signal`) or an operation invocation (`call`). +type ExpectedEvent struct { + Signal string `json:"signal,omitempty"` // Signal type name + Call string `json:"call,omitempty"` // Invoked operation name + Args map[string]ExpectedValue `json:"args,omitempty"` // Signal feature bindings or call arguments + Value *ExpectedValue `json:"value,omitempty"` // The one bare value a signal carries + Results map[string]ExpectedValue `json:"results,omitempty"` // Outputs a synchronous call returns its caller +} + +// returnsResults reports whether a case observes what a call returns, so its +// events are performed one at a time as a synchronous caller performs them. +func returnsResults(events []ExpectedEvent) bool { + return slices.ContainsFunc(events, func(event ExpectedEvent) bool { return event.Results != nil }) +} + +// Performer is one object performing the case's behavior, and the outcome +// expected of that object's performance. +type Performer struct { + Object string `json:"object"` // qualified name of the object's usage + Events []ExpectedEvent `json:"events,omitempty"` + FinalState string `json:"finalState,omitempty"` + Terminated bool `json:"terminated,omitempty"` + StateVisits []string `json:"stateVisits,omitempty"` + Outputs map[string]ExpectedValue `json:"outputs,omitempty"` +} + +// AdmittedOutcome is one complete result a case admits: an action run's outputs, or a +// state performance's final state, visits and outputs. +type AdmittedOutcome struct { + Outputs map[string]ExpectedValue `json:"outputs,omitempty"` + FinalState string `json:"finalState,omitempty"` + Terminated bool `json:"terminated,omitempty"` + StateVisits []string `json:"stateVisits,omitempty"` + // Probability states the share the explore pass expects the linearizations + // reaching this outcome to carry, for a case whose open choices are drawn. + Probability *float64 `json:"probability,omitempty"` +} + +// ExpectedOutcome represents expected execution result +type ExpectedOutcome struct { + Type string `json:"type"` // "action", "state", "calc", "constraint", "requirement", "instance" + // Libraries loads the standard library into the case's index, for a case + // whose model names library elements the runtime resolves — the measurement + // unit of a quantity expression is one. + Libraries bool `json:"libraries,omitempty"` + // Documents are further source files of this directory indexed with the + // case's own, before it, for a case whose model spans documents — an extent + // reaching a usage another file declares. Each must parse clean. + Documents []string `json:"documents,omitempty"` + + // Outcomes are the complete results the case admits, in place of the single + // outputs/finalState/stateVisits, for a model whose library semantics leave + // more than one open. The observed run must match exactly one of them. + Outcomes []AdmittedOutcome `json:"outcomes,omitempty"` + // Admissible cites the section of docs/project/behavior-semantic-oracle.md + // deriving that every listed outcome is valid. Required beside Outcomes. + Admissible string `json:"admissible,omitempty"` + // Schedule pins the scheduling policy the case was recorded under, spelled as + // ParseSchedulePolicy reads it. Empty is the default policy. + Schedule string `json:"schedule,omitempty"` + // ModelSeed fixes the modeled draws — weighted branches, RandomFunctions — of + // a case under every policy of the sweep, as Context.SetModelSeed does. + ModelSeed *uint64 `json:"modelSeed,omitempty"` + // Draws fixes the policy the case's RandomFunctions calls resolve under, as + // ParseDrawPolicy reads it: min, max or average; empty draws at random. + Draws string `json:"draws,omitempty"` + // ClockStep pins the step, in seconds, the case's clock ticks by, as + // Context.SetClockStep reads it; 0 or absent is a continuous clock. + ClockStep float64 `json:"clockStep,omitempty"` + // ExploreBudget raises the budget the harness explores the case's outcomes + // under, for a case whose choice tree the default budget does not cover. + ExploreBudget *ExpectedExploreBudget `json:"exploreBudget,omitempty"` + + // Action fields + Outputs map[string]ExpectedValue `json:"outputs,omitempty"` + TokenCount *int `json:"tokenCount,omitempty"` + // Error is the text the execution is expected to fail with, for a case whose + // contract is a diagnostic rather than a result (a loop that never + // terminates). Empty means the execution must succeed. + Error string `json:"error,omitempty"` + // Diagnostics are the parse diagnostics the case's model is expected to + // report, one text per diagnostic, matched as a substring. Any diagnostic the + // case does not declare fails it: a model that parses with an error executes + // recovered input, which is not what the case states. + Diagnostics []string `json:"diagnostics,omitempty"` + + // State fields + Events []ExpectedEvent `json:"events,omitempty"` // Events to inject + FinalState string `json:"finalState,omitempty"` + StateVisits []string `json:"stateVisits,omitempty"` + // Terminated states that a `terminate` ends the run short of the behavior's + // own end; a case leaving it out expects the run to complete. + Terminated bool `json:"terminated,omitempty"` + + // Calc fields + Inputs []ExpectedValue `json:"inputs,omitempty"` + Result *ExpectedValue `json:"result,omitempty"` + // Reads are the values expressions of the model take, keyed by the qualified + // name of the feature whose value binding is evaluated ("M::z"). A calc + // usage's outputs are read through such features, so a case states what a + // model reading them computes rather than only what the usage produced. + Reads map[string]ExpectedValue `json:"reads,omitempty"` + + // Constraint/Requirement fields + Bindings map[string]ExpectedValue `json:"bindings,omitempty"` + Satisfied *bool `json:"satisfied,omitempty"` + // Evaluate names the element to execute or evaluate: a qualified path + // ("test::p::a") also reaches one nested in a part. Empty searches the model. + Evaluate string `json:"evaluate,omitempty"` + // Trace opts a case into a golden trace it does not carry yet, so + // -update-traces writes one. A case already carrying a golden needs no opt-in. + Trace bool `json:"trace,omitempty"` + + // Satisfy fields: the verdict expected of each satisfaction assertion the + // case states, keyed by the assertion as written ("satisfy r by p"), since + // such an assertion is anonymous. + Assertions map[string]bool `json:"assertions,omitempty"` + + // Analysis fields: the object run as the case's subject, by the qualified + // name of its usage, and the verdict ("satisfied", "not satisfied", + // "undecided") expected of each objective and assertion, by name. + Subject string `json:"subject,omitempty"` + Verdicts map[string]string `json:"verdicts,omitempty"` + // Evaluations are the applications of the case's own calcs as function values + // the run made, in order — a trade study's evaluation of each alternative. + // Stated, the run must have made exactly these. + Evaluations []ExpectedEvaluation `json:"evaluations,omitempty"` + + // Verification fields: the VerdictKind the run of the case's body produced, + // the text an error or inconclusive verdict carries, and the verdict of each + // verification subcase the body performs, by qualified name. + Verdict string `json:"verdict,omitempty"` + VerdictDetail string `json:"verdictDetail,omitempty"` + Subcases map[string]string `json:"subcases,omitempty"` + + // Performers are the objects that each perform the case's behavior, for a + // case whose contract depends on which object performs it — two objects + // selecting different variants of one variation route over their own. + Performers []Performer `json:"performers,omitempty"` + + // Instance fields + Instantiate string `json:"instantiate,omitempty"` // qualified name of the type to instantiate + FeatureValues map[string]ExpectedValue `json:"slots,omitempty"` // expected feature values, derived ones included + Constraints map[string]bool `json:"constraints,omitempty"` // constraint feature name -> satisfied on this instance + // Identical states pairs of paths through the instance that must reach the + // very same object, for a case whose contract is identity rather than a + // value — a connector end is the feature it attaches to, so + // ["link.source", "a.p"] holds only while they are one object. + Identical [][]string `json:"identical,omitempty"` + // Distinct states pairs of paths that must reach different objects, for a + // case pinning that two connectors attached to different features are + // told apart. + Distinct [][]string `json:"distinct,omitempty"` + // Objects are the behaviors materialized objects run because their type + // exhibits or performs them, one entry per object whose performance the case + // states. + Objects []ObjectRun `json:"objects,omitempty"` + // Materialization states what reading every feature value of the instance, + // and of the objects those hold, reports — the check `-instantiate` makes. + Materialization *ExpectedMaterialization `json:"materialization,omitempty"` + // Validation states what validating the instance as a whole reports: every + // assertion about it and the objects it holds, in report order. + Validation *ExpectedValidation `json:"validation,omitempty"` +} + +// ExpectedValidation is the report of validating an instance as a whole: each +// verdict in order, whether the object is shown valid, whether the walk was +// bounded, and the unreadable feature values. +type ExpectedValidation struct { + Verdicts []ExpectedObjectVerdict `json:"verdicts"` + Valid *bool `json:"valid,omitempty"` + Bounded bool `json:"bounded,omitempty"` + Unread []string `json:"unread,omitempty"` +} + +// ExpectedObjectVerdict is one verdict of a validation: the assertion's kind and +// text, the object's path from the root (empty for the root), status and error text. +type ExpectedObjectVerdict struct { + Kind string `json:"kind"` + Assertion string `json:"assertion"` + Object string `json:"object,omitempty"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// ExpectedMaterialization is the report of reading an instance's feature values in full: +// the errors in order, each matched as a substring, and whether the walk was bounded. +type ExpectedMaterialization struct { + Errors []string `json:"errors,omitempty"` + Bounded bool `json:"bounded,omitempty"` +} + +// ObjectRun is the performance a case expects of one materialized object's +// exhibited machine: which object it is, the events sent to that object's +// machine, and what the machine and the object hold afterwards. +type ObjectRun struct { + // Instance selects which materialization of the case's type the run is of, + // counted from 1. A case naming more than one materializes that many + // objects, which is how two objects of one type are stated to run + // independently. + Instance int `json:"instance,omitempty"` + // Path reaches a nested object from that materialization by dotted feature + // names; empty is the materialized object itself. + Path string `json:"path,omitempty"` + // Behavior names the behavior run, for an object running more than one. + // Empty is the machine the object exhibits. + Behavior string `json:"behavior,omitempty"` + Events []ExpectedEvent `json:"events,omitempty"` + FinalState string `json:"finalState,omitempty"` + Terminated bool `json:"terminated,omitempty"` + StateVisits []string `json:"stateVisits,omitempty"` + Values map[string]ExpectedValue `json:"slots,omitempty"` +} + +// TestExecutionConformance runs all behavioral execution conformance tests. +// Each case consists of .sysml and .expected.json. +// Cases listed in known_failures.txt are skipped with SKIP log. +func TestExecutionConformance(t *testing.T) { + conformanceDir := filepath.Join("testdata", "conformance") + + // Load known failures + knownFailures := loadKnownFailures(t, conformanceDir) + + // Walk conformance directory for .expected.json files + entries, err := os.ReadDir(conformanceDir) + if err != nil { + t.Fatalf("failed to read conformance directory: %v", err) + } + + testCount := 0 + for _, entry := range entries { + if entry.IsDir() || !isConformanceCase(entry.Name()) { + continue + } + + caseName := strings.TrimSuffix(entry.Name(), ".expected.json") + + // Check if known failure + if knownFailures[caseName] { + t.Logf("SKIP %s (known failure)", caseName) + continue + } + + testCount++ + t.Run(caseName, func(t *testing.T) { + t.Parallel() + runConformanceCase(t, conformanceDir, caseName, DefaultSchedulePolicy) + }) + } + + if testCount == 0 { + t.Fatalf("no runnable conformance cases in %s", conformanceDir) + } +} + +// sweepPolicies are the non-default policies the whole suite always runs under. +var sweepPolicies = fixtures.SweepPolicies + +// seedSweepEnv names the seeds the suite runs under besides seed:1, as a +// comma-separated list of non-negative integers; unset, the sweep is seed:1 alone. +const seedSweepEnv = "OPENSYSML_SCHEDULE_SEEDS" + +// sweepPoliciesWithSeeds is the sweep widened by the seeds seedSweepEnv names. +func sweepPoliciesWithSeeds(t *testing.T) []string { + policies := slices.Clone(sweepPolicies) + spelled := os.Getenv(seedSweepEnv) + if spelled == "" { + return policies + } + for _, field := range strings.Split(spelled, ",") { + seed, err := strconv.ParseUint(strings.TrimSpace(field), 10, 64) + if err != nil { + t.Fatalf("%s=%q: %q is not a seed: %v", seedSweepEnv, spelled, field, err) + } + policy := fmt.Sprintf("seed:%d", seed) + if !slices.Contains(policies, policy) { + policies = append(policies, policy) + } + } + return policies +} + +// TestExecutionConformanceUnderPolicies runs the whole suite under each +// non-default policy. A case pinning no policy was recorded under the default, +// so what it states must hold under any policy: one that differs has been +// pinning a scheduling artefact and is reported, never skipped. A pinned case +// runs under its own policy: pinning `reverse` says its result is one +// linearization, kept until its admissible set is derived or the bug fixed. +// OPENSYSML_SCHEDULE_SEEDS widens the sweep to further seeds. +func TestExecutionConformanceUnderPolicies(t *testing.T) { + conformanceDir := filepath.Join("testdata", "conformance") + knownFailures := loadKnownFailures(t, conformanceDir) + entries, err := os.ReadDir(conformanceDir) + if err != nil { + t.Fatalf("failed to read conformance directory: %v", err) + } + + for _, spelling := range sweepPoliciesWithSeeds(t) { + policy, err := ParseSchedulePolicy(spelling) + if err != nil { + t.Fatalf("sweep policy: %v", err) + } + t.Run(spelling, func(t *testing.T) { + for _, entry := range entries { + if entry.IsDir() || !isConformanceCase(entry.Name()) { + continue + } + caseName := strings.TrimSuffix(entry.Name(), ".expected.json") + if knownFailures[caseName] { + continue + } + t.Run(caseName, func(t *testing.T) { + t.Parallel() + runConformanceCase(t, conformanceDir, caseName, policy) + }) + } + }) + } +} + +// isConformanceCase tells a case's `.expected.json` from the `.check.expected.json` +// beside it, which states what a check of every schedule finds (check_corpus_test.go). +// The definition is shared with the generated documentation figures. +func isConformanceCase(fileName string) bool { + return fixtures.IsCase(fileName) +} + +// loadKnownFailures reads known_failures.txt and returns set of case names to skip +func loadKnownFailures(t *testing.T, conformanceDir string) map[string]bool { + failures, err := fixtures.KnownFailures(conformanceDir) + if err != nil { + t.Logf("warning: failed to read known_failures.txt: %v", err) + return nil + } + return failures +} + +// runConformanceCase executes a single conformance test case under policy, or +// under the policy the case pins if it pins one. +func runConformanceCase(t *testing.T, conformanceDir, caseName string, policy SchedulePolicy) { + runConformanceCaseWithOwned(t, conformanceDir, caseName, policy, false) +} + +func runConformanceCaseWithOwned(t *testing.T, conformanceDir, caseName string, policy SchedulePolicy, forceOwned bool) { + // Load .sysml file + sysmlPath := filepath.Join(conformanceDir, caseName+".sysml") + sysmlData, err := os.ReadFile(sysmlPath) + if err != nil { + t.Fatalf("failed to read %s: %v", sysmlPath, err) + } + + // Load .expected.json + expectedPath := filepath.Join(conformanceDir, caseName+".expected.json") + expectedData, err := os.ReadFile(expectedPath) + if err != nil { + t.Fatalf("failed to read %s: %v", expectedPath, err) + } + + var expected ExpectedOutcome + if err := json.Unmarshal(expectedData, &expected); err != nil { + t.Fatalf("failed to parse expected.json: %v", err) + } + for _, problem := range admissibleSchemaProblems(expected, oracleSectionTitles(t)) { + t.Error(problem) + } + if t.Failed() { + t.FailNow() + } + + // Parse and build model + src := source.New(sysmlPath, sysmlData) + p := parser.New(src) + file := p.ParseFile() + checkDiagnostics(t, p.Diagnostics, expected.Diagnostics) + + idx, sources := indexCaseDocuments(t, conformanceDir, src, file, expected) + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + model.SetSourceText(source.TextOf(sources, nil)) + fresh := func() *Context { + ctx := NewContext(typedModel(model, resolver), 10000) + applyCaseDraws(t, ctx, expected) + return ctx + } + ctx := fresh() + if err := ctx.SetSchedule(casePolicy(t, expected, policy)); err != nil { + t.Fatalf("schedule: %v", err) + } + + // Dispatch based on type + switch expected.Type { + case "action": + if forceOwned { + t.Skip("no object owns connectors before execution") + } + runActionConformance(t, ctx, idx, sysmlPath, expected) + case "state": + runStateConformance(t, ctx, idx, sysmlPath, expected, forceOwned) + case "calc": + runCalcConformance(t, ctx, idx, sysmlPath, expected) + case "calcUsage": + runCalcUsageConformance(t, ctx, idx, sysmlPath, expected) + case "constraint": + runConstraintConformance(t, ctx, idx, sysmlPath, expected) + case "requirement": + runRequirementConformance(t, ctx, idx, sysmlPath, expected) + case "satisfy": + runSatisfyConformance(t, ctx, idx, sysmlPath, expected) + case "analysis": + runAnalysisConformance(t, ctx, idx, sysmlPath, expected) + case "verification": + if forceOwned { + t.Skip("no object owns connectors before execution") + } + runVerificationConformance(t, ctx, idx, sysmlPath, expected) + case "instance": + runInstanceConformance(t, ctx, idx, sysmlPath, expected, forceOwned) + default: + if forceOwned { + t.Skip("no object owns connectors before execution") + } + t.Fatalf("unknown test type: %s", expected.Type) + } + // Exploration is the same under any policy, so the default suite does it once. + if policy == DefaultSchedulePolicy { + exploreConformanceCase(t, fresh, idx, sysmlPath, expected) + } +} + +// indexCaseDocuments indexes a case's model — the standard library when it asks for one, the +// further documents it lists, then its own file — and returns the sources indexed by path. +func indexCaseDocuments(t *testing.T, conformanceDir string, src *source.SourceFile, file *ast.RootNamespace, expected ExpectedOutcome) (*symbols.Index, map[string]*source.SourceFile) { + t.Helper() + idx := symbols.NewIndex() + if expected.Libraries { + idx = libs.NewModelIndex() + } + sources := map[string]*source.SourceFile{src.Name(): src} + for _, name := range expected.Documents { + path := filepath.Join(conformanceDir, name) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read document %s: %v", path, err) + } + doc := source.New(path, data) + p := parser.New(doc) + parsed := p.ParseFile() + checkDiagnostics(t, p.Diagnostics, nil) + idx.AddDocument(path, parsed) + sources[path] = doc + } + idx.AddDocument(src.Name(), file) + if expected.Libraries { + idx.ExpandWildcardImports() + } + return idx, sources +} + +// applyCaseDraws gives ctx the model seed, clock step and draw policy the case states, +// as -seed, -clock-step and -draws would. A pin that names none is a schema error. +func applyCaseDraws(t *testing.T, ctx *Context, expected ExpectedOutcome) { + t.Helper() + if expected.ModelSeed != nil { + ctx.SetModelSeed(*expected.ModelSeed) + } + if err := ctx.SetClockStep(expected.ClockStep); err != nil { + t.Fatalf("clockStep: %v", err) + } + if expected.Draws == "" { + return + } + draws, err := ParseDrawPolicy(expected.Draws) + if err != nil { + t.Fatalf("draws: %v", err) + } + ctx.SetDrawPolicy(draws) +} + +// casePolicy is the policy a case runs under: the one it pins, else the one the +// harness asked for. A pin that names no policy is a schema error. +func casePolicy(t *testing.T, expected ExpectedOutcome, policy SchedulePolicy) SchedulePolicy { + t.Helper() + if expected.Schedule == "" { + return policy + } + pinned, err := ParseSchedulePolicy(expected.Schedule) + if err != nil { + t.Fatalf("schedule: %v", err) + } + if pinned != policy { + t.Logf("pinned to %s", pinned) + } + return pinned +} + +// exploreConformanceCase fails unless exploration reaches exactly the listed +// outcomes within budget, naming any unlisted one with a witness. +func exploreConformanceCase(t *testing.T, fresh func() *Context, idx *symbols.Index, path string, expected ExpectedOutcome) { + t.Helper() + if len(expected.Outcomes) == 0 { + return + } + policy, err := ExplorePolicy(expected.ExploreBudget.budget()) + if err != nil { + t.Fatalf("exploreBudget: %v", err) + } + exploration, err := Explore(context.Background(), policy, func() (*Context, error) { return fresh(), nil }, conformanceRun(t, idx, path, expected)) + if err != nil { + t.Fatalf("explore: %v", err) + } + ctx := fresh() + reached := make([]int, len(expected.Outcomes)) + probs := make([]float64, len(expected.Outcomes)) + for _, explored := range exploration.Outcomes { + witness := FormatChoices(explored.Witness) + if explored.Outcome.Err != nil { + t.Errorf("exploration reached an error the case does not list: %v\n witness: %s", explored.Outcome.Err, witness) + continue + } + matched, report := matchedOutcomes(expected.Outcomes, func(r reporter, outcome AdmittedOutcome) { + validateOutcome(r, ctx, outcome, explored.Outcome) + }) + switch len(matched) { + case 1: + reached[matched[0]-1] += explored.Linearizations + probs[matched[0]-1] += explored.Probability + case 0: + t.Errorf("exploration reached an outcome the case does not list: %s\n witness: %s\n %s", + explored.Outcome, witness, strings.Join(report, "\n ")) + default: + t.Errorf("explored outcome %s matches admissible outcomes %v; an admissible set lists distinct outcomes", explored.Outcome, matched) + } + } + for i, linearizations := range reached { + if linearizations == 0 { + t.Errorf("admissible outcome %d of %d is unreachable: none of %d runs reached it", i+1, len(expected.Outcomes), exploration.Runs) + } else { + t.Logf("admissible outcome %d reached by %d of %d runs", i+1, linearizations, exploration.Runs) + } + if want := expected.Outcomes[i].Probability; want != nil && math.Abs(probs[i]-*want) > 1e-9 { + t.Errorf("admissible outcome %d carries probability %v, want %v", i+1, probs[i], *want) + } + } + if !exploration.Complete() { + t.Errorf("exploration %s under %s; raise the budget in %s with \"exploreBudget\": {\"runs\": N, \"depth\": D}", + exploration.Status(), policy, filepath.Base(strings.TrimSuffix(path, ".sysml"))+".expected.json") + } +} + +// conformanceRun is one run of a case as an exploration replays it: the case's +// action performed, or its state machine driven through the case's events. +func conformanceRun(t *testing.T, idx *symbols.Index, path string, expected ExpectedOutcome) func(*Context) (Outcome, error) { + t.Helper() + rootScope := idx.DocumentRoot(path) + switch expected.Type { + case "action": + actionSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefAction, ast.UsageAction) + return func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(actionSym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } + case "state": + stateSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefState, ast.UsageState) + events := queuedEvents(t, expected.Events) + return func(ctx *Context) (Outcome, error) { + exec, err := ctx.PerformState(stateSym, nil, events) + if err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + } + default: + t.Fatalf("outcomes apply to action and state cases, not %q", expected.Type) + return nil + } +} + +// validateOutcome checks an outcome a run reached against one the case admits. +func validateOutcome(r reporter, ctx *Context, want AdmittedOutcome, got Outcome) { + r.Helper() + validateTerminated(r, got.Terminated, want.Terminated) + validateFinalState(r, got.FinalState, want.FinalState) + validateStateVisits(r, got.StateVisits, want.StateVisits) + if want.Outputs != nil { + validateOutputs(r, ctx, want.Outputs, got.Outputs) + } +} + +// oraclePath is the semantic oracle an admissible set must cite a section of. +const oraclePath = "../../../docs/project/behavior-semantic-oracle.md" + +// oracleSectionTitles reads the section titles of the semantic oracle, which are +// the citations an admissible set may resolve to. +func oracleSectionTitles(t *testing.T) map[string]bool { + t.Helper() + data, err := os.ReadFile(oraclePath) + if err != nil { + t.Fatalf("read the semantic oracle: %v", err) + } + titles := make(map[string]bool) + for _, line := range strings.Split(string(data), "\n") { + if title, ok := strings.CutPrefix(line, "### "); ok { + titles[strings.TrimSpace(title)] = true + } else if title, ok := strings.CutPrefix(line, "## "); ok { + titles[strings.TrimSpace(title)] = true + } + } + return titles +} + +// ExpectedExploreBudget is the exploreBudget of a case: the runs and depth the +// harness explores its outcomes under, each defaulting to DefaultExploreBudget's. +type ExpectedExploreBudget struct { + Runs *int `json:"runs,omitempty"` + Depth *int `json:"depth,omitempty"` +} + +// budget is the case's budget with the defaults filled in. +func (b *ExpectedExploreBudget) budget() ExploreBudget { + budget := DefaultExploreBudget + if b == nil { + return budget + } + if b.Runs != nil { + budget.Runs = *b.Runs + } + if b.Depth != nil { + budget.Depth = *b.Depth + } + return budget +} + +// admissibleSchemaProblems reports how a case misuses outcomes and admissible: +// the two go together, replace the single outcome rather than sit beside it, +// list at least two distinct results, and cite a section the oracle has. +func admissibleSchemaProblems(expected ExpectedOutcome, oracleTitles map[string]bool) []string { + var problems []string + if len(expected.Outcomes) == 0 { + if expected.Admissible != "" { + problems = append(problems, "admissible is stated without outcomes to admit") + } + if expected.ExploreBudget != nil { + problems = append(problems, "exploreBudget is stated without outcomes to explore") + } + return problems + } + if _, err := ExplorePolicy(expected.ExploreBudget.budget()); err != nil { + problems = append(problems, "exploreBudget: "+err.Error()) + } + if expected.Type != "action" && expected.Type != "state" { + problems = append(problems, fmt.Sprintf("outcomes apply to action and state cases, not %q", expected.Type)) + } + if expected.Outputs != nil || expected.FinalState != "" || expected.StateVisits != nil || expected.Terminated { + problems = append(problems, "outcomes and a single outputs/finalState/stateVisits/terminated are stated together; a case uses one or the other") + } + if len(expected.Performers) > 0 { + problems = append(problems, "outcomes and performers are stated together") + } + if len(expected.Outcomes) < 2 { + problems = append(problems, "outcomes lists one result; state it as the single outcome") + } + for i, outcome := range expected.Outcomes { + if outcome.Outputs == nil && outcome.FinalState == "" && outcome.StateVisits == nil && !outcome.Terminated { + problems = append(problems, fmt.Sprintf("outcome %d states nothing", i+1)) + } + if p := outcome.Probability; p != nil && (*p < 0 || *p > 1) { + problems = append(problems, fmt.Sprintf("outcome %d states probability %v, not one of 0.0..1.0", i+1, *p)) + } + } + switch { + case expected.Admissible == "": + problems = append(problems, "outcomes are listed without admissible citing the oracle section deriving them") + case !oracleTitles[expected.Admissible]: + problems = append(problems, fmt.Sprintf("admissible %q is not a section title of %s", expected.Admissible, filepath.Base(oraclePath))) + } + return problems +} + +// reporter is what a validation reports mismatches to: the test itself, or a +// problem log when the harness probes an outcome that may legitimately not match. +type reporter interface { + Helper() + Errorf(format string, args ...any) + Logf(format string, args ...any) +} + +// problemLog collects the mismatches a validation reports. +type problemLog struct { + problems []string +} + +func (l *problemLog) Helper() {} + +func (l *problemLog) Errorf(format string, args ...any) { + l.problems = append(l.problems, fmt.Sprintf(format, args...)) +} + +func (l *problemLog) Logf(string, ...any) {} + +// matchOutcome checks that exactly one listed outcome matches the observed run: +// none means the run is inadmissible, several means the set is not distinct. +func matchOutcome(t *testing.T, outcomes []AdmittedOutcome, validate func(r reporter, outcome AdmittedOutcome)) { + t.Helper() + matched, report := matchedOutcomes(outcomes, validate) + switch len(matched) { + case 1: + t.Logf("matched admissible outcome %d of %d", matched[0], len(outcomes)) + case 0: + t.Errorf("the run matches none of the %d admissible outcomes:\n %s", len(outcomes), strings.Join(report, "\n ")) + default: + t.Errorf("the run matches admissible outcomes %v; an admissible set lists distinct outcomes", matched) + } +} + +// matchedOutcomes is the 1-based positions of the outcomes the run matches, with +// the problems that rule out each of the others. +func matchedOutcomes(outcomes []AdmittedOutcome, validate func(r reporter, outcome AdmittedOutcome)) (matched []int, report []string) { + for i, outcome := range outcomes { + log := &problemLog{} + validate(log, outcome) + if len(log.problems) == 0 { + matched = append(matched, i+1) + continue + } + report = append(report, fmt.Sprintf("outcome %d: %s", i+1, strings.Join(log.problems, "; "))) + } + return matched, report +} + +// checkDiagnostics fails the case unless the diagnostics its model reported are +// exactly the ones it declares: an undeclared diagnostic means the case executes +// recovered input, and a declared one that no diagnostic matches is stale. +func checkDiagnostics(t *testing.T, got []parser.Diagnostic, want []string) { + t.Helper() + for _, problem := range diagnosticProblems(got, want) { + t.Error(problem) + } +} + +// diagnosticProblems reports how the diagnostics a model produced differ from +// the ones a case declares, pairing each declaration with one diagnostic. +func diagnosticProblems(got []parser.Diagnostic, want []string) []string { + var problems []string + matched := make([]bool, len(want)) + for _, d := range got { + found := false + for i, w := range want { + if !matched[i] && strings.Contains(d.Message, w) { + matched[i] = true + found = true + break + } + } + if !found { + problems = append(problems, fmt.Sprintf("undeclared diagnostic at offset %d: %s", d.Span.Offset, d.Message)) + } + } + for i, w := range want { + if !matched[i] { + problems = append(problems, fmt.Sprintf("declared diagnostic %q was not reported", w)) + } + } + return problems +} + +// TestMain gives the package a primed library cache of its own, so a case sees +// the restored symbol shape whatever the machine's cache holds or runs first. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "opensysml-runtime-libs") + if err != nil { + fmt.Fprintf(os.Stderr, "library cache directory: %v\n", err) + os.Exit(1) + } + if err := os.Setenv("XDG_CACHE_HOME", dir); err != nil { + fmt.Fprintf(os.Stderr, "library cache directory: %v\n", err) + os.Exit(1) + } + if err := primeLibraryCache(); err != nil { + fmt.Fprintf(os.Stderr, "prime library cache: %v\n", err) + os.Exit(1) + } + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +// primeLibraryCache parses the standard library once and caches its records, +// persisting only once every file is indexed and its imports are expanded. +func primeLibraryCache() error { + src := libs.DefaultSource() + cache, err := libs.NewCache() + if err != nil { + return err + } + if err := libs.NewLoader(src, cache).LoadAll(symbols.NewIndex()); err != nil { + return fmt.Errorf("load the library: %w", err) + } + return nil +} + +// loadLibraries loads the standard library into idx, for a case that names its +// elements. The cache TestMain primed makes every load a hit, so what a case +// sees does not depend on what ran before it. +func loadLibraries(t *testing.T, idx *symbols.Index) { + t.Helper() + src := libs.DefaultSource() + cache, err := libs.NewCache() + if err != nil { + t.Fatalf("library cache: %v", err) + } + if err := libs.NewLoader(src, cache).LoadAll(idx); err != nil { + t.Fatalf("load the library: %v", err) + } +} + +// A loaded library carries its parsed declaration on every path, with the cache +// restoring only the facts derived from it. +func TestLoadLibrariesKeepsDeclarationsAndRestoresFacts(t *testing.T) { + idx := symbols.NewIndex() + loadLibraries(t, idx) + + matches := idx.LookupQualified("SI::metre") + if len(matches) != 1 { + t.Fatalf("SI::metre matched %d symbols, want 1", len(matches)) + } + sym := matches[0] + if sym.Decl == nil { + t.Error("SI::metre carries no declaration, want the one it was parsed from") + } + if sym.Name != "metre" { + t.Errorf("SI::metre is named %q, want its declared name", sym.Name) + } + if sym.Facts == nil || sym.Facts.Unit == nil { + t.Errorf("SI::metre has facts %+v, want its unit facts restored", sym.Facts) + } +} + +// runActionConformance executes action and validates outputs +func runActionConformance(t *testing.T, ctx *Context, idx *symbols.Index, path string, expected ExpectedOutcome) { + rootScope := idx.DocumentRoot(path) + actionSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefAction, ast.UsageAction) + + // Execute action + outcome, err := ctx.ActionOutcomePerformedBy(actionSym, nil, nil) + outputs := outcome.Outputs + if expected.Error != "" { + if err == nil { + t.Fatalf("expected execution to fail with %q, it completed with outputs %v", expected.Error, outputs) + } + if !strings.Contains(err.Error(), expected.Error) { + t.Fatalf("execution failed with %q, want an error containing %q", err, expected.Error) + } + return + } + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + + validateTerminated(t, outcome.Terminated, expected.Terminated) + validateOutputs(t, ctx, expected.Outputs, outputs) + if len(expected.Outcomes) > 0 { + matchOutcome(t, expected.Outcomes, func(r reporter, admitted AdmittedOutcome) { + validateTerminated(r, outcome.Terminated, admitted.Terminated) + validateOutputs(r, ctx, admitted.Outputs, outputs) + }) + } + + // Optional: validate token count + if expected.TokenCount != nil { + // Token count validation requires instrumentation in executor + // For now, skip - can add later if needed + t.Logf("token count validation not yet implemented (expected %d)", *expected.TokenCount) + } +} + +// runStateConformance executes a state machine and validates the final state. A +// case naming performers runs the machine once per object performing it, each +// against the outcome that object expects. +func runStateConformance(t *testing.T, ctx *Context, idx *symbols.Index, path string, expected ExpectedOutcome, forceOwned bool) { + rootScope := idx.DocumentRoot(path) + stateSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefState, ast.UsageState) + if len(expected.Performers) == 0 { + if forceOwned { + t.Skip("no object owns connectors before execution") + } + runOneStatePerformance(t, ctx, stateSym, nil, expected) + return + } + for _, performer := range expected.Performers { + self, err := ctx.Instantiate(oneSymbol(t, idx, performer.Object)) + if err != nil { + t.Fatalf("instantiate %s: %v", performer.Object, err) + } + if forceOwned { + if err := forceOwnedConnectors(ctx, self); err != nil { + t.Fatalf("OwnedConnectors(%s) failed before execution: %v", performer.Object, err) + } + } + t.Run(performer.Object, func(t *testing.T) { + runOneStatePerformance(t, ctx, stateSym, self, ExpectedOutcome{ + Events: performer.Events, + FinalState: performer.FinalState, + Terminated: performer.Terminated, + StateVisits: performer.StateVisits, + Outputs: performer.Outputs, + }) + }) + } +} + +func forceOwnedConnectors(ctx *Context, root *Instance) error { + visited := make(map[int64]bool) + var walk func(*Instance) error + walk = func(inst *Instance) error { + if inst == nil || visited[inst.ID] { + return nil + } + visited[inst.ID] = true + if _, err := inst.OwnedConnectors(ctx); err != nil { + return err + } + for _, feature := range ctx.FeaturesOfObject(inst) { + fv, err := inst.GetFeatureValue(ctx, feature.Name) + if err != nil { + continue + } + for _, child := range heldInstances(ctx, fv) { + if err := walk(child); err != nil { + return err + } + } + } + return nil + } + return walk(root) +} + +// queuedEvents converts the events a case declares into the events the runtime +// queues, so the conformance, trace and snapshot harnesses drive one performance. +func queuedEvents(t *testing.T, events []ExpectedEvent) []QueuedEvent { + t.Helper() + queued := make([]QueuedEvent, 0, len(events)) + for _, event := range events { + args := make(map[string]Value, len(event.Args)) + for name, val := range event.Args { + args[name] = expectedToRuntimeValue(t, val) + } + q := QueuedEvent{Signal: event.Signal, Call: event.Call, Args: args} + if event.Value != nil { + value := expectedToRuntimeValue(t, *event.Value) + q.Value = &value + } + queued = append(queued, q) + } + return queued +} + +// injectEvents queues the events a case declares onto an executor. +func injectEvents(t *testing.T, exec *StateExecutor, events []ExpectedEvent) { + t.Helper() + for _, event := range queuedEvents(t, events) { + if err := exec.Enqueue(event); err != nil { + t.Fatal(err) + } + } +} + +// runOneStatePerformance runs one performance of a state machine, by self or by +// no object, and validates it against the outcome expected of that performance. +func runOneStatePerformance(t *testing.T, ctx *Context, stateSym *symbols.Symbol, self *Instance, expected ExpectedOutcome) { + // The executor's own loop drives the run: a harness-local copy drifts from + // the semantics under test. + var exec *StateExecutor + var err error + if returnsResults(expected.Events) { + exec, err = callingPerformance(t, ctx, stateSym, self, expected.Events) + } else { + exec, err = ctx.PerformState(stateSym, self, queuedEvents(t, expected.Events)) + } + if expected.Error != "" { + requireError(t, "state machine", err, expected.Error) + return + } + if err != nil { + t.Fatalf("state machine: %v", err) + } + + if len(expected.Outcomes) == 0 { + validateStateOutcome(t, ctx, exec, AdmittedOutcome{ + Outputs: expected.Outputs, + FinalState: expected.FinalState, + Terminated: expected.Terminated, + StateVisits: expected.StateVisits, + }) + } else { + matchOutcome(t, expected.Outcomes, func(r reporter, outcome AdmittedOutcome) { + validateStateOutcome(r, ctx, exec, outcome) + }) + } +} + +// callingPerformance performs a machine as a caller does, one event per step: +// a call is performed synchronously and what it returns checked against the +// results the case states for it, every other event queued and run. +func callingPerformance(t *testing.T, ctx *Context, stateSym *symbols.Symbol, self *Instance, events []ExpectedEvent) (*StateExecutor, error) { + t.Helper() + exec, err := ctx.CreateStateExecutorFor(stateSym, self) + if err != nil { + return nil, err + } + for i, event := range events { + queued := queuedEvents(t, events[i:i+1])[0] + if event.Results == nil { + if err := exec.Enqueue(queued); err != nil { + exec.Release() + return nil, err + } + if err := exec.RunToCompletion(); err != nil { + exec.Release() + return nil, err + } + continue + } + results, err := exec.Call(queued.Call, queued.Args) + if err != nil { + exec.Release() + return nil, fmt.Errorf("call %d %s: %w", i, queued.Call, err) + } + validateOutputs(t, ctx, event.Results, results) + for name := range results { + if _, ok := event.Results[name]; !ok { + t.Errorf("call %d %s returned %s, which the case does not expect", i, queued.Call, name) + } + } + } + return exec, nil +} + +// validateStateOutcome checks a state performance against one outcome. +func validateStateOutcome(r reporter, ctx *Context, exec *StateExecutor, outcome AdmittedOutcome) { + r.Helper() + validateOutcome(r, ctx, outcome, exec.Outcome()) +} + +// validateOutputs checks each output a case states against the value produced. +func validateOutputs(r reporter, ctx *Context, want map[string]ExpectedValue, got map[string]Value) { + r.Helper() + for name, expectedVal := range want { + actual, ok := got[name] + if !ok { + r.Errorf("missing output %q", name) + continue + } + validateValue(r, ctx, name, expectedVal, actual) + } +} + +// validateFinalState checks the state a machine came to rest in, named as the +// case names it: orthogonal regions as "State1+State2", sorted by region name. +func validateFinalState(t reporter, got, want string) { + t.Helper() + if want == "" { + return + } + if got == "" { + t.Errorf("expected finalState %q, got empty", want) + } else if got != want { + t.Errorf("finalState mismatch: expected %q, got %q", want, got) + } +} + +// validateTerminated checks whether a `terminate` ended the run, as the case states. +func validateTerminated(t reporter, got, want bool) { + t.Helper() + switch { + case want && !got: + t.Errorf("expected the run to end terminated, it completed") + case got && !want: + t.Errorf("the run ended terminated; the case expects it to complete") + } +} + +// validateStateVisits checks the states a machine entered, in order. +func validateStateVisits(t reporter, got, want []string) { + t.Helper() + if len(want) == 0 { + return + } + if len(got) != len(want) { + t.Errorf("stateVisits length mismatch: expected %d, got %d", len(want), len(got)) + t.Logf(" expected: %v", want) + t.Logf(" actual: %v", got) + return + } + for i, name := range want { + if got[i] != name { + t.Errorf("stateVisits[%d] mismatch: expected %q, got %q", i, name, got[i]) + } + } +} + +// runCalcConformance invokes calc and validates result +func runCalcConformance(t *testing.T, ctx *Context, idx *symbols.Index, path string, expected ExpectedOutcome) { + // Find calc definition/usage + rootScope := idx.DocumentRoot(path) + calcSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefCalc, ast.UsageCalc) + + // Convert expected inputs to runtime Values + args := make([]Value, len(expected.Inputs)) + for i, input := range expected.Inputs { + args[i] = expectedToRuntimeValue(t, input) + } + + // Invoke calc + result, err := ctx.InvokeCalc(calcSym, args, rootScope) + if expected.Error != "" { + requireError(t, "InvokeCalc", err, expected.Error) + return + } + if err != nil { + t.Fatalf("InvokeCalc failed: %v", err) + } + + // Validate result + if expected.Result != nil { + validateValue(t, ctx, "result", *expected.Result, result) + } +} + +// runCalcUsageConformance evaluates a calc usage and validates the value of each +// output feature it computes, plus the value of every model feature the case +// reads those outputs through. +func runCalcUsageConformance(t *testing.T, ctx *Context, idx *symbols.Index, path string, expected ExpectedOutcome) { + rootScope := idx.DocumentRoot(path) + usageSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefCalc, ast.UsageCalc) + + outputs, err := ctx.CalcUsageOutputs(usageSym, usageSym.OwnerScope, nil) + if expected.Error != "" { + requireError(t, "CalcUsageOutputs", err, expected.Error) + return + } + if err != nil { + t.Fatalf("CalcUsageOutputs(%s) failed: %v", ctx.qualifiedSymbolName(usageSym), err) + } + + values := make(map[string]Value, len(outputs)) + for _, out := range outputs { + values[out.Name] = out.Value + } + for name, expectedVal := range expected.Outputs { + actual, ok := values[name] + if !ok { + t.Errorf("missing output %q among %v", name, outputNames(outputs)) + continue + } + validateValue(t, ctx, name, expectedVal, actual) + } + + for name, expectedVal := range expected.Reads { + validateRead(t, ctx, idx, name, expectedVal) + } + + // A calc that designates a result also answers an invocation with it, so a + // case may state both what the usage's outputs are and what invoking it + // yields — a statement-bodied calc has to get both right. + if expected.Result != nil { + args := make([]Value, len(expected.Inputs)) + for i, input := range expected.Inputs { + args[i] = expectedToRuntimeValue(t, input) + } + result, err := ctx.InvokeCalc(usageSym, args, rootScope) + if err != nil { + t.Fatalf("InvokeCalc(%s) failed: %v", ctx.qualifiedSymbolName(usageSym), err) + } + validateValue(t, ctx, "result", *expected.Result, result) + } +} + +// validateRead evaluates the value binding of the named feature, in the scope it +// is written in, and validates the value it takes. +func validateRead(t *testing.T, ctx *Context, idx *symbols.Index, name string, expected ExpectedValue) { + t.Helper() + matches := idx.LookupQualified(name) + if len(matches) != 1 { + t.Errorf("read %q: %d matching symbols, want 1", name, len(matches)) + return + } + usage, ok := matches[0].Decl.(*ast.Usage) + if !ok || usage.Value == nil { + t.Errorf("read %q: the feature binds no value to evaluate", name) + return + } + value, err := ctx.EvalWithScope(usage.Value, matches[0].OwnerScope) + if expected.Error != "" { + requireError(t, "read "+name, err, expected.Error) + return + } + if err != nil { + t.Errorf("read %q: %v", name, err) + return + } + validateValue(t, ctx, name, expected, value) +} + +// outputNames names the outputs an evaluation produced, for a message about one +// it did not. +func outputNames(outputs []CalcOutputValue) []string { + names := make([]string, 0, len(outputs)) + for _, out := range outputs { + names = append(names, out.Name) + } + return names +} + +// runConstraintConformance evaluates constraint and validates satisfaction +func runConstraintConformance(t *testing.T, ctx *Context, idx *symbols.Index, path string, expected ExpectedOutcome) { + // Find constraint definition/usage + rootScope := idx.DocumentRoot(path) + constraintSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefConstraint, ast.UsageConstraint) + + // An object the case materializes first is what the check is about, for a + // case whose contract is the subject the runtime picks. + if expected.Instantiate != "" { + if _, err := ctx.Instantiate(oneSymbol(t, idx, expected.Instantiate)); err != nil { + t.Fatalf("instantiate %s: %v", expected.Instantiate, err) + } + } + + // Apply bindings to context (if any) + if expected.Bindings != nil { + // Bindings need to be added to scope or instance + // For now, assume bindings are already in model + t.Logf("constraint bindings application not yet implemented") + } + + // Evaluate constraint. A violated assertion is a verdict, not a failure. + satisfied, err := ctx.EvaluateConstraint(constraintSym, constraintSym.OwnerScope) + if expected.Error != "" { + requireError(t, "EvaluateConstraint", err, expected.Error) + return + } + if err != nil && !errors.Is(err, ErrViolated) { + t.Fatalf("EvaluateConstraint failed: %v", err) + } + + // Validate satisfaction + if expected.Satisfied != nil { + if satisfied != *expected.Satisfied { + t.Errorf("constraint satisfied = %v, want %v", satisfied, *expected.Satisfied) + } + } +} + +// runRequirementConformance evaluates requirement and validates satisfaction +func runRequirementConformance(t *testing.T, ctx *Context, idx *symbols.Index, path string, expected ExpectedOutcome) { + // Find requirement definition/usage + rootScope := idx.DocumentRoot(path) + reqSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefRequirement, ast.UsageRequirement) + + // Evaluate requirement using symbol's defining scope (where sibling features + // visible). A violated condition is a verdict, not a failure. + satisfied, err := ctx.EvaluateRequirement(reqSym, reqSym.OwnerScope) + if err != nil && !errors.Is(err, ErrViolated) { + t.Fatalf("EvaluateRequirement failed: %v", err) + } + + // Validate satisfaction + if expected.Satisfied != nil { + if satisfied != *expected.Satisfied { + t.Errorf("requirement satisfied = %v, want %v", satisfied, *expected.Satisfied) + } + } +} + +// runAnalysisConformance runs the analysis case the outcome names: on the +// object `subject` names, with `inputs` as positional and `bindings` as named +// arguments, and checks its outputs, its verdicts and the model's reads of it. +func runAnalysisConformance(t *testing.T, ctx *Context, idx *symbols.Index, path string, expected ExpectedOutcome) { + rootScope := idx.DocumentRoot(path) + caseSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefAnalysisCase, ast.UsageAnalysisCase) + + result, err := ctx.RunAnalysis(caseSym, analysisArgsOf(t, ctx, idx, expected), rootScope, nil) + if expected.Error != "" { + requireError(t, "RunAnalysis", err, expected.Error) + // A failed run still reports what it decided and evaluated before failing. + checkCaseRun(t, ctx, idx, result, expected) + return + } + if err != nil { + t.Fatalf("RunAnalysis(%s) failed: %v", ctx.qualifiedSymbolName(caseSym), err) + } + + checkCaseRun(t, ctx, idx, result, expected) +} + +// checkCaseRun validates what one run of a case produced: its outputs, its +// result, the verdict of every objective and assertion, and the values a model +// reading its outputs computes. Analysis and verification cases run the same +// body, so their outcomes are checked the same way. +func checkCaseRun(t *testing.T, ctx *Context, idx *symbols.Index, result AnalysisResult, expected ExpectedOutcome) { + t.Helper() + values := make(map[string]Value, len(result.Outputs)) + for _, out := range result.Outputs { + values[out.Name] = out.Value + } + for name, expectedVal := range expected.Outputs { + actual, ok := values[name] + if !ok { + t.Errorf("missing output %q among %v", name, outputNames(result.Outputs)) + continue + } + validateValue(t, ctx, name, expectedVal, actual) + } + if expected.Result != nil { + actual, ok := values[resultOutputName] + if !ok { + t.Errorf("the case returned no result; its outputs are %v", outputNames(result.Outputs)) + } else { + validateValue(t, ctx, resultOutputName, *expected.Result, actual) + } + } + + verdicts := make(map[string]AnalysisVerdict, len(result.Verdicts)) + for _, verdict := range result.Verdicts { + verdicts[verdict.Name] = verdict + } + for name, want := range expected.Verdicts { + verdict, ok := verdicts[name] + if !ok { + t.Errorf("no verdict for %q among %v", name, verdictNames(result.Verdicts)) + continue + } + if got := verdict.Status.String(); got != want { + t.Errorf("%s %s: verdict %q (%s), want %q", verdict.Kind, name, got, verdict.Detail, want) + } + } + if len(result.Verdicts) != len(expected.Verdicts) { + t.Errorf("the case reported %d verdict(s) %v, the outcome states %d", + len(result.Verdicts), verdictNames(result.Verdicts), len(expected.Verdicts)) + } + if expected.Evaluations != nil { + checkEvaluations(t, ctx, result.Evaluations, expected.Evaluations) + } + + for name, expectedVal := range expected.Reads { + validateRead(t, ctx, idx, name, expectedVal) + } +} + +// checkEvaluations validates the function-value applications a run made against +// the ones the outcome states, in order. +func checkEvaluations(t *testing.T, ctx *Context, got []AnalysisEvaluation, want []ExpectedEvaluation) { + t.Helper() + if len(got) != len(want) { + t.Errorf("the run made %d evaluation(s), the outcome states %d", len(got), len(want)) + } + for i := 0; i < len(got) && i < len(want); i++ { + label := fmt.Sprintf("evaluation %d", i+1) + if got[i].Function != want[i].Function { + t.Errorf("%s: applied %s, want %s", label, got[i].Function, want[i].Function) + } + if len(got[i].Arguments) != len(want[i].Arguments) { + t.Errorf("%s: %d argument(s), want %d", label, len(got[i].Arguments), len(want[i].Arguments)) + } + for j := 0; j < len(got[i].Arguments) && j < len(want[i].Arguments); j++ { + validateValue(t, ctx, fmt.Sprintf("%s argument %d", label, j+1), want[i].Arguments[j], got[i].Arguments[j]) + } + switch { + case want[i].Error != "": + requireError(t, label, got[i].Error, want[i].Error) + case got[i].Error != nil: + t.Errorf("%s failed: %v", label, got[i].Error) + case want[i].Result != nil: + validateValue(t, ctx, label+" result", *want[i].Result, got[i].Result) + } + if got[i].Selected != want[i].Selected { + t.Errorf("%s: selected = %v, want %v", label, got[i].Selected, want[i].Selected) + } + if got[i].Tied != want[i].Tied { + t.Errorf("%s: tied = %v, want %v", label, got[i].Tied, want[i].Tied) + } + } +} + +// runVerificationConformance runs a verification case and validates the verdict +// its body produced, the verdict of each subcase it performs, and everything the +// same run reports as an analysis case's run does. +func runVerificationConformance(t *testing.T, ctx *Context, idx *symbols.Index, path string, expected ExpectedOutcome) { + rootScope := idx.DocumentRoot(path) + caseSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefVerificationCase, ast.UsageVerificationCase) + + result, err := ctx.RunVerification(caseSym, analysisArgsOf(t, ctx, idx, expected), rootScope, nil) + if err != nil { + t.Fatalf("RunVerification(%s) failed: %v", ctx.qualifiedSymbolName(caseSym), err) + } + if got := string(result.Verdict.Kind); got != expected.Verdict { + t.Errorf("verdict = %q (%s), want %q", got, result.Verdict.Detail, expected.Verdict) + } + if expected.VerdictDetail != "" && !strings.Contains(result.Verdict.Detail, expected.VerdictDetail) { + t.Errorf("verdict detail = %q, want it to contain %q", result.Verdict.Detail, expected.VerdictDetail) + } + checkSubcaseVerdicts(t, result.Subcases, expected.Subcases) + if result.Verdict.Kind == VerdictError { + return + } + checkCaseRun(t, ctx, idx, result.Run, expected) +} + +// checkSubcaseVerdicts validates the verdict of each verification subcase the +// case performs, which the library states no roll-up for. +func checkSubcaseVerdicts(t *testing.T, got []VerificationVerdict, want map[string]string) { + t.Helper() + kinds := make(map[string]string, len(got)) + for _, verdict := range got { + kinds[verdict.Case] = string(verdict.Kind) + } + for name, wantKind := range want { + kind, ok := kinds[name] + if !ok { + t.Errorf("no subcase verdict for %q among %v", name, kinds) + continue + } + if kind != wantKind { + t.Errorf("subcase %s verdict = %q, want %q", name, kind, wantKind) + } + } + if len(got) != len(want) { + t.Errorf("the case reported %d subcase verdict(s) %v, the outcome states %d", len(got), kinds, len(want)) + } +} + +// analysisArgsOf builds the arguments an analysis outcome states: the object its +// subject names, materialized, and its positional and named parameter values. +func analysisArgsOf(t *testing.T, ctx *Context, idx *symbols.Index, expected ExpectedOutcome) AnalysisArgs { + t.Helper() + args := AnalysisArgs{} + if expected.Subject != "" { + subject, err := ctx.Instantiate(oneSymbol(t, idx, expected.Subject)) + if err != nil { + t.Fatalf("Instantiate(%s) failed: %v", expected.Subject, err) + } + args.Subject = subject + } + for _, input := range expected.Inputs { + args.Positional = append(args.Positional, expectedToRuntimeValue(t, input)) + } + if len(expected.Bindings) > 0 { + args.Named = make(map[string]Value, len(expected.Bindings)) + for name, value := range expected.Bindings { + args.Named[name] = expectedToRuntimeValue(t, value) + } + } + return args +} + +func verdictNames(verdicts []AnalysisVerdict) []string { + names := make([]string, len(verdicts)) + for i, verdict := range verdicts { + names[i] = verdict.Name + } + return names +} + +// runSatisfyConformance evaluates the satisfaction assertions the case states +// and validates the verdict of each: the assertion binds the requirement's +// subject to the object its `by` operand names, so the verdict is about that +// object's values. +func runSatisfyConformance(t *testing.T, ctx *Context, idx *symbols.Index, path string, expected ExpectedOutcome) { + scope := idx.DocumentRoot(path) + if expected.Evaluate != "" { + matches := idx.LookupQualified(expected.Evaluate) + if len(matches) != 1 { + t.Fatalf("evaluate %q: %d matching symbols, want 1", expected.Evaluate, len(matches)) + } + if matches[0].Scope == nil { + t.Fatalf("evaluate %q: the element owns no scope, so it states no assertion", expected.Evaluate) + } + scope = matches[0].Scope + } + + assertions := ctx.SatisfyAssertionsIn(scope) + if len(assertions) == 0 { + t.Fatalf("no satisfaction assertion found") + } + if expected.Error != "" || expected.Satisfied != nil { + if len(assertions) != 1 { + t.Fatalf("%d satisfaction assertions found, want 1: name each verdict under \"assertions\"", len(assertions)) + } + } + + verdicts := make(map[string]bool, len(assertions)) + for _, a := range assertions { + satisfied, err := ctx.EvaluateSatisfaction(a) + if expected.Error != "" { + if err == nil { + t.Fatalf("expected evaluation to fail with %q, it reported satisfied = %v", expected.Error, satisfied) + } + if !strings.Contains(err.Error(), expected.Error) { + t.Fatalf("evaluation failed with %q, want an error containing %q", err, expected.Error) + } + return + } + if err != nil && !errors.Is(err, ErrViolated) { + t.Fatalf("EvaluateSatisfaction(%s) failed: %v", a.Text(), err) + } + verdicts[a.Text()] = satisfied + } + + if expected.Satisfied != nil { + for text, satisfied := range verdicts { + if satisfied != *expected.Satisfied { + t.Errorf("%s: satisfied = %v, want %v", text, satisfied, *expected.Satisfied) + } + } + } + for text, want := range expected.Assertions { + satisfied, ok := verdicts[text] + if !ok { + t.Errorf("no assertion %q among %v", text, slices.Sorted(maps.Keys(verdicts))) + continue + } + if satisfied != want { + t.Errorf("%s: satisfied = %v, want %v", text, satisfied, want) + } + } +} + +// runInstanceConformance instantiates a type and validates the values its feature values +// hold, including derived defaults, plus the verdict of each constraint the +// instance carries. +func runInstanceConformance(t *testing.T, ctx *Context, idx *symbols.Index, path string, expected ExpectedOutcome, forceOwned bool) { + if expected.Instantiate == "" { + t.Fatalf("instance case declares no \"instantiate\" type") + } + matches := idx.LookupQualified(expected.Instantiate) + if len(matches) != 1 { + t.Fatalf("instantiate %q: %d matching symbols, want 1", expected.Instantiate, len(matches)) + } + typeSym := matches[0] + + inst, err := ctx.Instantiate(typeSym) + if expected.Error != "" { + requireError(t, "Instantiate("+expected.Instantiate+")", err, expected.Error) + return + } + if err != nil { + t.Fatalf("Instantiate(%s) failed: %v", expected.Instantiate, err) + } + if forceOwned { + if err := forceOwnedConnectors(ctx, inst); err != nil { + t.Fatalf("OwnedConnectors(%s) failed before execution: %v", expected.Instantiate, err) + } + } + + for name, expectedVal := range expected.FeatureValues { + // A slot is read as an expression reads it: an optional one holding + // nothing is the empty sequence, a required one an error. + var value Value + fv, err := featureValueAtPath(t, ctx, inst, name) + if err == nil { + value, err = fv.ReadValue(name) + } + if expectedVal.Error != "" { + requireError(t, "feature value "+name, err, expectedVal.Error) + continue + } + if err != nil { + t.Errorf("feature value %q: %v", name, err) + continue + } + validateValue(t, ctx, name, expectedVal, value) + } + + validateIdentity(t, ctx, inst, expected) + validateObjectRuns(t, ctx, typeSym, inst, expected) + validateMaterialization(t, ctx, inst, expected.Materialization) + validateValidation(t, ctx, idx.DocumentRoot(path), inst, expected.Validation) + + for name, wantSatisfied := range expected.Constraints { + feat := featureNamed(ctx, typeSym, name) + if feat == nil || feat.Symbol == nil { + t.Errorf("constraint %q: no such feature on %s", name, expected.Instantiate) + continue + } + satisfied, err := ctx.EvaluateConstraintOn(feat.Symbol, feat.DeclScope(), inst) + if err != nil && !errors.Is(err, ErrViolated) { + t.Errorf("constraint %q: %v", name, err) + continue + } + if satisfied != wantSatisfied { + t.Errorf("constraint %q: satisfied = %v, want %v", name, satisfied, wantSatisfied) + } + } +} + +// validateMaterialization checks what reading every feature value of the +// instance reports against what the case states, when it states it. +func validateMaterialization(t *testing.T, ctx *Context, inst *Instance, expected *ExpectedMaterialization) { + t.Helper() + if expected == nil { + return + } + errs, bounded := ctx.MaterializationErrors(inst) + if bounded != expected.Bounded { + t.Errorf("materialization: bounded = %v, want %v", bounded, expected.Bounded) + } + if len(errs) != len(expected.Errors) { + t.Errorf("materialization: %d error(s) %v, want %d %v", len(errs), errs, len(expected.Errors), expected.Errors) + return + } + for i, want := range expected.Errors { + requireError(t, fmt.Sprintf("materialization error %d", i+1), errs[i], want) + } +} + +// validateValidation checks what validating the instance as a whole reports +// against what the case states, when it states it: the verdicts in order. +func validateValidation(t *testing.T, ctx *Context, scope *symbols.Scope, inst *Instance, expected *ExpectedValidation) { + t.Helper() + if expected == nil { + return + } + report, err := ctx.ValidateObject(inst, []*symbols.Scope{scope}) + if err != nil { + t.Fatalf("validation: %v", err) + } + if expected.Valid != nil && report.Valid() != *expected.Valid { + t.Errorf("validation: valid = %v, want %v", report.Valid(), *expected.Valid) + } + if report.Bounded != expected.Bounded { + t.Errorf("validation: bounded = %v, want %v", report.Bounded, expected.Bounded) + } + if len(report.Unread) != len(expected.Unread) { + t.Errorf("validation: %d unread %v, want %d %v", len(report.Unread), report.Unread, len(expected.Unread), expected.Unread) + } else { + for i, want := range expected.Unread { + requireError(t, fmt.Sprintf("validation unread %d", i+1), report.Unread[i], want) + } + } + var got []string + for _, v := range report.Verdicts { + got = append(got, fmt.Sprintf("%s %q on %q: %s", v.Kind, v.Text, strings.Join(v.Path, "."), v.Status)) + } + if len(report.Verdicts) != len(expected.Verdicts) { + t.Errorf("validation: %d verdict(s), want %d:\n%s", len(report.Verdicts), len(expected.Verdicts), strings.Join(got, "\n")) + return + } + for i, want := range expected.Verdicts { + v := report.Verdicts[i] + wantText := fmt.Sprintf("%s %q on %q: %s", want.Kind, want.Assertion, want.Object, want.Status) + if got[i] != wantText { + t.Errorf("validation verdict %d: %s, want %s", i+1, got[i], wantText) + continue + } + if v.Element == nil { + t.Errorf("validation verdict %d: names no element", i+1) + } + if v.Subject == nil { + t.Errorf("validation verdict %d: names no subject", i+1) + } + switch { + case want.Error != "": + requireError(t, fmt.Sprintf("validation verdict %d", i+1), v.Err, want.Error) + case v.Status == ValidationHolds && v.Err != nil: + t.Errorf("validation verdict %d holds yet carries %v", i+1, v.Err) + case v.Status != ValidationHolds && v.Err == nil: + t.Errorf("validation verdict %d is %s yet carries no error", i+1, v.Status) + } + } +} + +// featureValueAtPath reads a slot on the instance or on a nested object. +func featureValueAtPath(t *testing.T, ctx *Context, inst *Instance, path string) (*FeatureValue, error) { + t.Helper() + current := inst + parts := strings.Split(path, ".") + for i, name := range parts { + fv, err := current.GetFeatureValue(ctx, name) + if err != nil || i == len(parts)-1 { + return fv, err + } + id, ok := fv.HeldValue().Object() + if !ok { + return nil, fmt.Errorf("%s: %q holds %s, want an object", path, name, fv.HeldValue().Kind) + } + next, ok := ctx.Instance(id) + if !ok { + return nil, fmt.Errorf("%s: object %d is not materialized", path, id) + } + current = next + } + return nil, fmt.Errorf("empty feature path %q", path) +} + +// validateIdentity checks the identity a case states between the objects two +// paths through the instance reach, which is what a connector end asserts: it +// is the connected feature, not a copy of it. +func validateIdentity(t *testing.T, ctx *Context, inst *Instance, expected ExpectedOutcome) { + t.Helper() + for _, pair := range expected.Identical { + left, right := identityPair(t, ctx, inst, pair) + if left != right { + t.Errorf("%s is object %d and %s is object %d, want one object", + pair[0], left, pair[1], right) + } + } + for _, pair := range expected.Distinct { + left, right := identityPair(t, ctx, inst, pair) + if left == right { + t.Errorf("%s and %s are both object %d, want different objects", + pair[0], pair[1], left) + } + } +} + +// validateObjectRuns drives the behaviors materialized objects run because +// their type exhibits them, and checks each object's own machine and values: a +// case naming several materializations states that they run independently. +func validateObjectRuns(t *testing.T, ctx *Context, typeSym *symbols.Symbol, first *Instance, expected ExpectedOutcome) { + t.Helper() + if len(expected.Objects) == 0 { + return + } + objects := materializations(t, ctx, typeSym, first, expected.Objects) + for _, run := range expected.Objects { + obj := objects[instanceIndexOf(run)] + if run.Path != "" { + obj = instanceAtPath(t, ctx, obj, run.Path) + } + t.Run(runName(run), func(t *testing.T) { + exec := objectMachine(t, obj, run.Behavior) + injectEvents(t, exec, run.Events) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run machine of object #%d: %v", obj.ID, err) + } + validateFinalState(t, exec.FinalStateName(), run.FinalState) + validateTerminated(t, exec.Outcome().Terminated, run.Terminated) + validateStateVisits(t, exec.GetStateVisits(), run.StateVisits) + for name, want := range run.Values { + fv, err := featureValueAtPath(t, ctx, obj, name) + if err != nil { + t.Errorf("feature value %q of object #%d: %v", name, obj.ID, err) + continue + } + validateValue(t, ctx, name, want, fv.HeldValue()) + } + }) + } +} + +// materializations returns the objects the runs name, materializing as many of +// the case's type as the highest instance number asks for. +func materializations(t *testing.T, ctx *Context, typeSym *symbols.Symbol, first *Instance, runs []ObjectRun) []*Instance { + t.Helper() + count := 1 + for _, run := range runs { + if n := instanceIndexOf(run) + 1; n > count { + count = n + } + } + objects := []*Instance{first} + for i := 1; i < count; i++ { + next, err := ctx.Instantiate(typeSym) + if err != nil { + t.Fatalf("instantiate object %d: %v", i+1, err) + } + objects = append(objects, next) + } + return objects +} + +// instanceIndexOf is the zero-based materialization a run names. +func instanceIndexOf(run ObjectRun) int { + if run.Instance <= 0 { + return 0 + } + return run.Instance - 1 +} + +// runName names a run in subtest output. +func runName(run ObjectRun) string { + name := fmt.Sprintf("object%d", instanceIndexOf(run)+1) + if run.Path != "" { + name += "." + run.Path + } + if run.Behavior != "" { + name += "/" + run.Behavior + } + return name +} + +// objectMachine is the executor of the machine a run names on an object: the one +// it exhibits when the run names none. +func objectMachine(t *testing.T, obj *Instance, name string) *StateExecutor { + t.Helper() + behavior, ok := obj.ExhibitedState() + if name != "" { + behavior, ok = obj.Behavior(name) + } + if !ok { + t.Fatalf("object #%d runs no behavior %q", obj.ID, name) + } + if behavior.State == nil { + t.Fatalf("behavior %q of object #%d is not a state machine", behavior.Name, obj.ID) + } + return behavior.State +} + +// instanceAtPath walks a dotted path of feature names from inst to the object it +// reaches. +func instanceAtPath(t *testing.T, ctx *Context, inst *Instance, path string) *Instance { + t.Helper() + id := objectAtPath(t, ctx, inst, path) + obj, held := ctx.Instance(id) + if !held { + t.Fatalf("%s names object %d, which the context does not hold", path, id) + } + return obj +} + +// identityPair resolves the two paths of an identity assertion to the objects +// they reach. +func identityPair(t *testing.T, ctx *Context, inst *Instance, pair []string) (int64, int64) { + t.Helper() + if len(pair) != 2 { + t.Fatalf("identity assertion %v names %d paths, want two", pair, len(pair)) + } + return objectAtPath(t, ctx, inst, pair[0]), objectAtPath(t, ctx, inst, pair[1]) +} + +// objectAtPath walks a dotted path of feature names from inst and returns the +// object it reaches. A numeric segment indexes into the collection the feature +// before it holds, counted from 1 ("units.2"). +func objectAtPath(t *testing.T, ctx *Context, inst *Instance, path string) int64 { + t.Helper() + cur := inst + segments := strings.Split(path, ".") + for i := 0; i < len(segments); i++ { + name := segments[i] + fv, err := cur.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("%s: feature value %q: %v", path, name, err) + } + held := fv.HeldValue() + if i+1 < len(segments) { + if n, numErr := strconv.Atoi(segments[i+1]); numErr == nil { + elements := heldElements(held) + if n < 1 || n > len(elements) { + t.Fatalf("%s: %q holds %d elements, index %d is out of range", path, name, len(elements), n) + } + held = elements[n-1] + i++ + } + } + id, isObject := held.Object() + if !isObject { + t.Fatalf("%s: %q holds %s, want an object", path, name, held.Kind) + } + if i == len(segments)-1 { + return id + } + next, isHeld := ctx.Instance(id) + if !isHeld { + t.Fatalf("%s: %q names object %d, which the context does not hold", path, name, id) + } + cur = next + } + t.Fatalf("identity path is empty") + return 0 +} + +// requireError checks that what a case states as a diagnostic contract failed +// with the text it expects, since a case whose subject cannot be evaluated says +// so rather than asserting a value. +func requireError(t *testing.T, what string, err error, want string) { + t.Helper() + if err == nil { + t.Errorf("%s: expected an error containing %q, it succeeded", what, want) + return + } + if !strings.Contains(err.Error(), want) { + t.Errorf("%s: failed with %q, want an error containing %q", what, err, want) + } +} + +// featureNamed returns the effective feature of typeSym called name, or nil. +func featureNamed(ctx *Context, typeSym *symbols.Symbol, name string) *EffectiveFeature { + features := ctx.FeaturesOf(typeSym) + for i := range features { + if features[i].Name == name { + return &features[i] + } + } + return nil +} + +// findBehavioralSymbol searches for first symbol matching defKind or usageKind, +// failing the test when there is none. +func findBehavioralSymbol(t *testing.T, scope *symbols.Scope, defKind ast.DefinitionKind, usageKind ast.UsageKind) *symbols.Symbol { + sym := lookupBehavioralSymbol(scope, defKind, usageKind) + if sym == nil { + t.Fatalf("no behavioral symbol found (defKind=%v, usageKind=%v)", defKind, usageKind) + } + return sym +} + +// namedOrFoundSymbol returns the symbol the case names by qualified path, which +// reaches a nested element, or searches the model when it names none. +func namedOrFoundSymbol(t *testing.T, idx *symbols.Index, fqn string, scope *symbols.Scope, defKind ast.DefinitionKind, usageKind ast.UsageKind) *symbols.Symbol { + if fqn == "" { + return findBehavioralSymbol(t, scope, defKind, usageKind) + } + matches := idx.LookupQualified(fqn) + if len(matches) != 1 { + t.Fatalf("evaluate %q: %d matching symbols, want 1", fqn, len(matches)) + } + if !behavioralKind(matches[0], defKind, usageKind) { + t.Fatalf("evaluate %q: names a %T, want a %v/%v", fqn, matches[0].Decl, defKind, usageKind) + } + return matches[0] +} + +// namedSymbol returns the one symbol of the asked-for kind that a qualified path +// names, or nil. +func namedSymbol(idx *symbols.Index, fqn string, defKind ast.DefinitionKind, usageKind ast.UsageKind) *symbols.Symbol { + matches := idx.LookupQualified(fqn) + if len(matches) != 1 || !behavioralKind(matches[0], defKind, usageKind) { + return nil + } + return matches[0] +} + +// behavioralKind reports whether a symbol declares the definition or usage kind +// an entry point asks for. +func behavioralKind(sym *symbols.Symbol, defKind ast.DefinitionKind, usageKind ast.UsageKind) bool { + switch decl := sym.Decl.(type) { + case *ast.Definition: + return decl.Kind == defKind + case *ast.Usage: + return decl.Kind == usageKind + default: + return false + } +} + +// lookupBehavioralSymbol is findBehavioralSymbol for callers that probe several +// kinds and treat absence as "not this kind of model". +func lookupBehavioralSymbol(scope *symbols.Scope, defKind ast.DefinitionKind, usageKind ast.UsageKind) *symbols.Symbol { + // Check all child scopes (packages/namespaces) + for _, child := range scope.Children() { + // Look for named symbols + for _, name := range child.MemberNames() { + sym, ok := child.LookupLocal(name) + if !ok { + continue + } + if def, ok := sym.Decl.(*ast.Definition); ok && def.Kind == defKind { + return sym + } + if usage, ok := sym.Decl.(*ast.Usage); ok && usage.Kind == usageKind { + return sym + } + } + } + + // Also check root scope directly + for _, name := range scope.MemberNames() { + sym, ok := scope.LookupLocal(name) + if !ok { + continue + } + if def, ok := sym.Decl.(*ast.Definition); ok && def.Kind == defKind { + return sym + } + if usage, ok := sym.Decl.(*ast.Usage); ok && usage.Kind == usageKind { + return sym + } + } + + return nil +} + +// expectedToRuntimeValue converts ExpectedValue to runtime Value +func expectedToRuntimeValue(t *testing.T, ev ExpectedValue) Value { + switch ev.Type { + case "Integer": + var intVal int64 + switch v := ev.Value.(type) { + case float64: + intVal = int64(v) + case int64: + intVal = v + default: + t.Fatalf("invalid Integer value type: %T", ev.Value) + } + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: intVal}} + case "Real": + if v, ok := ev.Value.(float64); ok { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: v}} + } + t.Fatalf("invalid Real value type: %T", ev.Value) + case "Boolean": + if v, ok := ev.Value.(bool); ok { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: v}} + } + t.Fatalf("invalid Boolean value type: %T", ev.Value) + case "String": + if v, ok := ev.Value.(string); ok { + return NewStringValue(v) + } + t.Fatalf("invalid String value type: %T", ev.Value) + case "Null": + return Value{Kind: ValNull} + case "Infinity": + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInfinity}} + case "Variant": + t.Fatalf("a variant is named by the model, so it cannot be built from a case value") + case "EnumLiteral": + t.Fatalf("an enumeration literal is declared by the model, so it cannot be built from a case value") + case "MeasurementRef": + t.Fatalf("a measurement reference names a unit the model declares, so it cannot be built from a case value") + case "CoordinateFrame", "CoordinateTransformation": + t.Fatalf("a %s is declared by the model, so it cannot be built from a case value", ev.Type) + case "Function": + t.Fatalf("a function is a calc the model declares, so it cannot be built from a case value") + case "Metaobject": + t.Fatalf("a metaobject denotes an element the model declares, so it cannot be built from a case value") + case "Complex": + v, ok := ev.Value.(float64) + if !ok || ev.Im == nil { + t.Fatalf("invalid Complex value: %v (im %v)", ev.Value, ev.Im) + } + return NewComplex(complex(v, *ev.Im)) + case "Quantity": + v, ok := ev.Value.(float64) + if !ok { + t.Fatalf("invalid Quantity value type: %T", ev.Value) + } + return NewQuantityValue(&Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: v}, + Unit: Unit{Text: ev.Unit}, + }) + default: + t.Fatalf("unknown type: %s", ev.Type) + } + return Value{} +} + +// validateValue checks if runtime Value matches ExpectedValue +func validateValue(t reporter, ctx *Context, name string, expected ExpectedValue, actual Value) { + switch expected.Type { + case "Sequence": + if actual.Kind != ValSequence { + t.Errorf("%s: type = %v, want Sequence", name, actual.Kind) + return + } + elements := elementsOf(actual) + if len(elements) != len(expected.Elements) { + t.Errorf("%s: %d elements, want %d", name, len(elements), len(expected.Elements)) + return + } + for i, want := range expected.Elements { + validateValue(t, ctx, fmt.Sprintf("%s#(%d)", name, i+1), want, elements[i]) + } + case "Set": + if actual.Kind != ValSet || actual.Set() == nil { + t.Errorf("%s: type = %v, want Set", name, actual.Kind) + return + } + validateElements(t, ctx, name, expected.Elements, actual.Set().Elements()) + case "Vector": + if actual.Kind != ValVector || actual.Vector() == nil { + t.Errorf("%s: type = %v, want Vector", name, actual.Kind) + return + } + validateElements(t, ctx, name, expected.Elements, constValues(actual.Vector().Elements)) + case "VectorQuantity": + if actual.Kind != ValVectorQuantity || actual.VectorQuantity() == nil { + t.Errorf("%s: type = %v, want VectorQuantity", name, actual.Kind) + return + } + vq := actual.VectorQuantity() + axes := make([]Value, vq.Dimension()) + for i := range axes { + axes[i] = NewQuantityValue(vq.component(i)) + } + validateElements(t, ctx, name, expected.Elements, axes) + case "Array": + if actual.Kind != ValArray || actual.Array() == nil { + t.Errorf("%s: type = %v, want Array", name, actual.Kind) + return + } + if got := actual.Array().Dimensions; !slices.Equal(got, expected.Dimensions) { + t.Errorf("%s: dimensions = %v, want %v", name, got, expected.Dimensions) + } + validateElements(t, ctx, name, expected.Elements, actual.Array().Elements) + case "TensorQuantity": + if actual.Kind != ValTensorQuantity || actual.TensorQuantity() == nil { + t.Errorf("%s: type = %v, want TensorQuantity", name, actual.Kind) + return + } + tq := actual.TensorQuantity() + if got := tq.Dimensions; !slices.Equal(got, expected.Dimensions) { + t.Errorf("%s: dimensions = %v, want %v", name, got, expected.Dimensions) + } + validateElements(t, ctx, name, expected.Elements, tq.components()) + case "Instance": + if actual.Kind != ValInstance { + t.Errorf("%s: type = %v, want Instance", name, actual.Kind) + } + if ctx != nil && ctx.HoldsNoValue(actual) { + t.Errorf("%s: holds no value, want an instance holding one", name) + } + if usage, pinned := expected.Value.(string); pinned && ctx != nil { + inst, _ := ctx.Instance(actual.Instance) + if got := ctx.OccurrenceUsage(inst); got != usage { + t.Errorf("%s: the occurrence of %q, want %q", name, got, usage) + } + } + case "Unset": + // A valueless feature of a value type: materialized, holding no value. + if ctx == nil || !ctx.HoldsNoValue(actual) { + t.Errorf("%s: type = %v, want %s", name, actual.Kind, UnsetText) + } + case "Integer": + if actual.Kind != ValConst || actual.Const.Kind != semantics.ValInt { + t.Errorf("%s: type = %v (Const.Kind=%v), want Integer", name, actual.Kind, actual.Const.Kind) + return + } + want := int64(expected.Value.(float64)) + if actual.Const.Int != want { + t.Errorf("%s: value = %d, want %d", name, actual.Const.Int, want) + } + case "Real": + if actual.Kind != ValConst || actual.Const.Kind != semantics.ValReal { + t.Errorf("%s: type = %v (Const.Kind=%v), want Real", name, actual.Kind, actual.Const.Kind) + return + } + want := expected.Value.(float64) + if actual.Const.Real != want { + t.Errorf("%s: value = %f, want %f", name, actual.Const.Real, want) + } + case "Boolean": + if actual.Kind != ValConst || actual.Const.Kind != semantics.ValBool { + t.Errorf("%s: type = %v (Const.Kind=%v), want Boolean", name, actual.Kind, actual.Const.Kind) + return + } + want := expected.Value.(bool) + if actual.Const.Bool != want { + t.Errorf("%s: value = %v, want %v", name, actual.Const.Bool, want) + } + case "String": + if actual.Kind != ValString { + t.Errorf("%s: type = %v, want String", name, actual.Kind) + return + } + want := expected.Value.(string) + if actual.Str() != want { + t.Errorf("%s: value = %q, want %q", name, actual.Str(), want) + } + case "Null": + if actual.Kind != ValNull { + t.Errorf("%s: type = %v, want Null", name, actual.Kind) + } + case "Infinity": + if actual.Kind != ValConst || !actual.Const.IsUnbounded() { + t.Errorf("%s: type = %v (Const.Kind=%v), want the unbounded `*`", name, actual.Kind, actual.Const.Kind) + } + case "Variant": + if actual.Kind != ValVariant || actual.Variant() == nil { + t.Errorf("%s: type = %v, want Variant", name, actual.Kind) + return + } + want := expected.Value.(string) + if actual.Variant().Name != want { + t.Errorf("%s: variant = %q, want %q", name, actual.Variant().Name, want) + } + case "EnumLiteral": + if actual.Kind != ValEnumLiteral || actual.Literal() == nil { + t.Errorf("%s: type = %v, want EnumLiteral", name, actual.Kind) + return + } + want := expected.Value.(string) + if got := actual.LiteralText(); got != want { + t.Errorf("%s: literal = %q, want %q", name, got, want) + } + case "Complex": + if actual.Kind != ValComplex { + t.Errorf("%s: type = %v, want Complex", name, actual.Kind) + return + } + if expected.Im == nil { + t.Errorf("%s: a Complex case states its im", name) + return + } + if want := complex(expected.Value.(float64), *expected.Im); actual.Complex() != want { + t.Errorf("%s: value = %s, want %s", name, FormatComplex(actual.Complex()), FormatComplex(want)) + } + case "Quantity": + if actual.Kind != ValQuantity || actual.Quantity() == nil { + t.Errorf("%s: type = %v, want Quantity", name, actual.Kind) + return + } + if got := actual.Quantity().Unit.String(); got != expected.Unit { + t.Errorf("%s: unit = %q, want %q", name, got, expected.Unit) + } + want := expected.Value.(float64) + got := actual.Quantity().Num + switch got.Kind { + case semantics.ValReal: + // A magnitude computed by repeated arithmetic is compared within a + // tolerance: the case pins the physics, not the last bit of a float. + if math.Abs(got.Real-want) > 1e-9*math.Max(1, math.Abs(want)) { + t.Errorf("%s: magnitude = %v, want %v", name, got.Real, want) + } + case semantics.ValInt: + if float64(got.Int) != want { + t.Errorf("%s: magnitude = %d, want %v", name, got.Int, want) + } + default: + t.Errorf("%s: magnitude kind = %v, want a number", name, got.Kind) + } + case "MeasurementRef": + if actual.Kind != ValMeasurementRef || actual.MeasurementRef() == nil { + t.Errorf("%s: type = %v, want MeasurementRef", name, actual.Kind) + return + } + if got := actual.MeasurementRef().Unit.String(); got != expected.Unit { + t.Errorf("%s: unit = %q, want %q", name, got, expected.Unit) + } + case "Function": + if actual.Kind != ValFunction || actual.Function() == nil { + t.Errorf("%s: type = %v, want Function", name, actual.Kind) + return + } + if want := expected.Value.(string); actual.FunctionName() != want { + t.Errorf("%s: function = %q, want %q", name, actual.FunctionName(), want) + } + case "Metaobject": + if actual.Kind != ValMetaobject || actual.MetaobjectElement() == nil { + t.Errorf("%s: type = %v, want Metaobject", name, actual.Kind) + return + } + if want, pinned := expected.Value.(string); pinned && actual.MetaobjectText() != want { + t.Errorf("%s: metaobject = %q, want %q", name, actual.MetaobjectText(), want) + } + case "CoordinateFrame": + actual = denotedObjectValue(t, ctx, name, actual) + if actual.Kind != ValCoordinateFrame || actual.CoordinateFrame() == nil { + t.Errorf("%s: type = %v, want CoordinateFrame", name, actual.Kind) + return + } + if got := actual.CoordinateFrame().String(); got != expected.Text { + t.Errorf("%s: frame = %q, want %q", name, got, expected.Text) + } + case "CoordinateTransformation": + actual = denotedObjectValue(t, ctx, name, actual) + if actual.Kind != ValCoordinateTransformation || actual.CoordinateTransformation() == nil { + t.Errorf("%s: type = %v, want CoordinateTransformation", name, actual.Kind) + return + } + if got := actual.CoordinateTransformation().String(); got != expected.Text { + t.Errorf("%s: transformation = %q, want %q", name, got, expected.Text) + } + default: + t.Errorf("%s: unknown expected type %s", name, expected.Type) + } +} + +// denotedObjectValue reads an object a slot holds as an expression naming the +// slot does: a frame or transformation declaration is the value it declares. +func denotedObjectValue(t reporter, ctx *Context, name string, actual Value) Value { + t.Helper() + if ctx == nil || actual.Kind != ValInstance { + return actual + } + inst, ok := ctx.Instance(actual.Instance) + if !ok { + return actual + } + val, err := ctx.objectValue(inst) + if err != nil { + t.Errorf("%s: reading the object it holds: %v", name, err) + return actual + } + return val +} + +// validateElements checks the elements of a structured value one by one. +func validateElements(t reporter, ctx *Context, name string, expected []ExpectedValue, actual []Value) { + if len(actual) != len(expected) { + t.Errorf("%s: %d elements, want %d", name, len(actual), len(expected)) + return + } + for i, want := range expected { + validateValue(t, ctx, fmt.Sprintf("%s#(%d)", name, i+1), want, actual[i]) + } +} + +// TestAdmissibleOutcomesSchema pins that an admissible set cannot hide a bug: it +// replaces the single outcome, lists at least two, and cites an oracle section. +func TestAdmissibleOutcomesSchema(t *testing.T) { + titles := oracleSectionTitles(t) + const cited = "Concurrent branches writing one feature: the value is open, the writes are not" + if !titles[cited] { + t.Fatalf("the oracle no longer has a section titled %q", cited) + } + one := ExpectedValue{Type: "Integer", Value: 1.0} + two := ExpectedValue{Type: "Integer", Value: 2.0} + outcomes := []AdmittedOutcome{ + {Outputs: map[string]ExpectedValue{"x": one}}, + {Outputs: map[string]ExpectedValue{"x": two}}, + } + tests := []struct { + name string + expected ExpectedOutcome + problems int + }{ + {"single outcome", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs}, 0}, + {"admissible set", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: cited}, 0}, + {"state admissible set", ExpectedOutcome{Type: "state", Outcomes: []AdmittedOutcome{{FinalState: "A"}, {FinalState: "B"}}, Admissible: cited}, 0}, + {"outcomes beside outputs", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, Outcomes: outcomes, Admissible: cited}, 1}, + {"outcomes beside finalState", ExpectedOutcome{Type: "state", FinalState: "A", Outcomes: outcomes, Admissible: cited}, 1}, + {"outcomes beside performers", ExpectedOutcome{Type: "state", Performers: []Performer{{Object: "P::a"}}, Outcomes: outcomes, Admissible: cited}, 1}, + {"missing admissible", ExpectedOutcome{Type: "action", Outcomes: outcomes}, 1}, + {"admissible cites no section", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: "the value is open"}, 1}, + {"admissible without outcomes", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, Admissible: cited}, 1}, + {"one outcome listed", ExpectedOutcome{Type: "action", Outcomes: outcomes[:1], Admissible: cited}, 1}, + {"empty outcome", ExpectedOutcome{Type: "action", Outcomes: []AdmittedOutcome{outcomes[0], {}}, Admissible: cited}, 1}, + {"calc case", ExpectedOutcome{Type: "calc", Outcomes: outcomes, Admissible: cited}, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if problems := admissibleSchemaProblems(tt.expected, titles); len(problems) != tt.problems { + t.Errorf("problems = %v, want %d", problems, tt.problems) + } + }) + } +} + +// TestMatchOutcomeRequiresExactlyOne pins that a run matching no listed outcome +// fails, and so does one matching several, since the set must be distinct. +func TestMatchOutcomeRequiresExactlyOne(t *testing.T) { + outcomes := []AdmittedOutcome{ + {Outputs: map[string]ExpectedValue{"x": {Type: "Integer", Value: 1.0}}}, + {Outputs: map[string]ExpectedValue{"x": {Type: "Integer", Value: 2.0}}}, + {Outputs: map[string]ExpectedValue{"y": {Type: "Boolean", Value: true}}}, + } + intValue := func(n int64) Value { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: n}} + } + trueValue := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: true}} + tests := []struct { + name string + got map[string]Value + matched []int + }{ + {"one", map[string]Value{"x": intValue(2)}, []int{2}}, + {"none", map[string]Value{"x": intValue(3)}, nil}, + {"several", map[string]Value{"x": intValue(1), "y": trueValue}, []int{1, 3}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matched, report := matchedOutcomes(outcomes, func(r reporter, outcome AdmittedOutcome) { + validateOutputs(r, nil, outcome.Outputs, tt.got) + }) + if !slices.Equal(matched, tt.matched) { + t.Fatalf("matched %v, want %v (%v)", matched, tt.matched, report) + } + if len(report) != len(outcomes)-len(matched) { + t.Fatalf("report %v does not cover every unmatched outcome", report) + } + }) + } +} + +// TestConformanceDiagnosticsGate pins that a conformance case fails on a +// diagnostic it does not declare, and on a declaration nothing reported: a model +// that parses with an error would otherwise execute recovered input unnoticed. +func TestConformanceDiagnosticsGate(t *testing.T) { + diag := func(msg string) parser.Diagnostic { + return parser.Diagnostic{Message: msg, Span: source.Span{Offset: 7}} + } + tests := []struct { + name string + got []parser.Diagnostic + want []string + problems int + }{ + {"clean model, nothing declared", nil, nil, 0}, + {"undeclared diagnostic", []parser.Diagnostic{diag("expected ';' after transition")}, nil, 1}, + {"declared diagnostic", []parser.Diagnostic{diag("expected ';' after transition")}, []string{"after transition"}, 0}, + {"stale declaration", nil, []string{"after transition"}, 1}, + {"one declaration covers one diagnostic", []parser.Diagnostic{diag("bad"), diag("bad")}, []string{"bad"}, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if problems := diagnosticProblems(tt.got, tt.want); len(problems) != tt.problems { + t.Errorf("problems = %v, want %d", problems, tt.problems) + } + }) + } +} diff --git a/internal/exec/runtime/connector.go b/internal/exec/runtime/connector.go new file mode 100644 index 0000000000..ef0d0b9f59 --- /dev/null +++ b/internal/exec/runtime/connector.go @@ -0,0 +1,595 @@ +package runtime + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// ConnectorEnd is one end of a materialized connector: the name of the end +// feature it occupies, empty for an end the model leaves unnamed, and the value +// the end attaches to. The value is the connected feature itself — an object +// held at an end is the very object the connected feature holds, not a copy of +// it (KerML 1.0 §7.4.6) — so writing through one is read through the other. +type ConnectorEnd struct { + Name string + Value Value +} + +// participantEndName is the feature the ends of a connector occupy when they +// have no names of their own: `Links::Link::participant`, the ordered +// `[2..*]` feature every link's ends subset. A binary connector's `source` and +// `target` subset it in turn, so an object of a connector with any other arity +// holds its ends there. +const participantEndName = "participant" + +// materializeConnectorFeatureValue fills a feature value that holds a connector usage: the object it +// denotes, with its ends attached to the features the `connect` clause names, +// resolved against the instance that owns the connector. +func (ctx *Context) materializeConnectorFeatureValue(owner *Instance, fv *FeatureValue, name string) error { + if !fv.Feature.Scalar() { + return &ConnectorEndError{ + Connector: fmt.Sprintf("%s.%s", owner.Type.Name, name), + End: name, + Location: ctx.symbolLocation(fv.Feature.Symbol), + Err: errors.New("a connector of more than one object has no set of ends to attach"), + } + } + kept, held := owner.keptConnectors[fv] + return ctx.materializeConnectorAs(owner, fv.Feature.Symbol, ctx.connectorBaseOf(fv.Feature), kept, func(conn *Instance) { + if held { + // A probe discards the object, so the identity is kept for the one materialized after it. + ctx.noteProbeUndo(func() { owner.keepConnector(fv, kept) }) + delete(owner.keptConnectors, fv) + } + fv.Value = Value{Kind: ValInstance, Instance: conn.ID} + fv.Materialized = true + }) +} + +// connectorBaseOf returns the type an object of a connector usage is +// materialized from: the definition it names, or the usage itself when it names +// none — an implicitly typed `interface iface connect a.p to b.q` (SysML v2 +// §8.3.13) specializes a library connector whose declaration is indexed without +// a body, so the object carries the usage's own features and its ends. +func (ctx *Context) connectorBaseOf(feat *EffectiveFeature) *symbols.Symbol { + if base := ctx.CompositeTypeOf(feat); base != nil { + return base + } + return feat.Symbol +} + +// connectorEndFeatures returns the end features an object of the connector +// usage typeSym carries beyond the ones declared, whose names declared holds. +// The ends of an implicitly typed connector are declared by the library +// connector it specializes, whose declaration is indexed without a body, so +// they are answered from the ends the usage attaches: `source` and `target` for +// a binary connector, `participant` for any other arity. +func (ctx *Context) connectorEndFeatures(typeSym *symbols.Symbol, declared map[string]bool) []EffectiveFeature { + ends := ctx.model.semantics.ConnectorObjectEnds(typeSym) + if len(ends) == 0 { + return nil + } + var out []EffectiveFeature + for _, end := range ends { + name, mult := end.Name, singleValue() + if name == "" { + name, mult = participantEndName, participants(len(ends)) + } + if declared[name] { + continue + } + declared[name] = true + out = append(out, EffectiveFeature{ + Name: name, + Symbol: end.EndFeature, + OwnerType: typeSym, + Multiplicity: mult, + }) + } + return out +} + +// materializeConnector builds the object the connector usage connSym denotes in +// the context of owner, the instance whose features its ends name. base is the +// type the object is materialized from; keep receives the object once it is created whole. +func (ctx *Context) materializeConnector(owner *Instance, connSym, base *symbols.Symbol, keep func(*Instance)) error { + return ctx.materializeConnectorAs(owner, connSym, base, 0, keep) +} + +// materializeConnectorAs materializes a connector under the given identity, 0 for +// the next one the context hands out. The connector is created whole or not at +// all: a failure through the start of its behaviors undoes every write and +// message, and keep never sees it. Once kept, the older behaviors it woke answer; +// one of them failing is reported as its own, with the connector kept. +func (ctx *Context) materializeConnectorAs(owner *Instance, connSym, base *symbols.Symbol, id int64, keep func(*Instance)) error { + ends := ctx.model.semantics.ConnectorObjectEnds(connSym) + if len(ends) == 0 { + return fmt.Errorf("%w: %s declares no end to attach", ErrConnectorEnd, connectorName(connSym)) + } + + // An end may name the connector itself, or another connector that names this + // one back, which would attach ends forever. + ownerID := int64(0) + if owner != nil { + ownerID = owner.ID + } + key := connectorRef{owner: ownerID, connector: connSym} + if ctx.materializingConnectors[key] { + return fmt.Errorf("%w: connector %s attaches to itself", ErrCyclicFeatureValue, connectorName(connSym)) + } + ctx.materializingConnectors[key] = true + defer delete(ctx.materializingConnectors, key) + + mark := len(ctx.created) + commit, rollback := ctx.beginJournal() + endBoundary := ctx.beginRunBoundary() + abandon := func() { + endBoundary() + rollback() + } + inst, err := ctx.materialize(base, id, nil, "") + if err != nil { + abandon() + return err + } + + var unnamed []Value + ctx.behaviorRunDepth++ + for _, end := range ends { + val, err := ctx.attachConnectorEnd(owner, connSym, end) + if err != nil { + ctx.behaviorRunDepth-- + abandon() + return err + } + inst.Ends = append(inst.Ends, ConnectorEnd{Name: end.Name, Value: val}) + if end.Name == "" { + unnamed = append(unnamed, val) + continue + } + ctx.bindEndFeatureValue(inst, end, val) + } + ctx.behaviorRunDepth-- + if len(unnamed) > 0 { + ctx.bindParticipants(inst, inst.Ends) + } + if err := ctx.startClassifierBehaviors(inst, mark); err != nil { + abandon() + return err + } + endBoundary() + commit() + keep(inst) + return ctx.runAttachedBehaviors() +} + +// attachConnectorEnd evaluates what one end attaches to against the instance +// owning the connector, so the end holds the connected feature of that very +// object. An end naming nothing reachable is reported with its location: a +// connector that cannot be attached is no connector. +func (ctx *Context) attachConnectorEnd(owner *Instance, connSym *symbols.Symbol, end semantics.ConnectorEndAttachment) (Value, error) { + if end.Attachment == nil { + return Value{}, ctx.connectorEndError(connSym, end, errors.New("names no feature")) + } + if owner == nil { + return Value{}, ctx.connectorEndError(connSym, end, errors.New("no object owns the connector")) + } + scope := connSym.OwnerScope + if scope == nil { + scope = owner.Type.OwnerScope + } + ec := NewEvalContextIn(ctx, scope, owner) + defer ec.beginStep()() + val, err := ec.Eval(end.Attachment) + if err != nil { + return Value{}, ctx.connectorEndError(connSym, end, err) + } + if val.Kind == ValInvalid { + return Value{}, ctx.connectorEndError(connSym, end, errors.New("holds no value")) + } + return val, nil +} + +// bindEndFeatureValue writes an attached end into the feature value named after the end feature, +// adding the feature value when the object carries none: the ends of an implicitly typed +// connector are declared by a library connector, indexed without its body. +func (ctx *Context) bindEndFeatureValue(inst *Instance, end semantics.ConnectorEndAttachment, val Value) { + fv, ok := inst.FeatureValues[end.Name] + if !ok { + fv = &FeatureValue{Feature: &EffectiveFeature{ + Name: end.Name, + Symbol: end.EndFeature, + OwnerType: inst.Type, + Multiplicity: singleValue(), + }} + inst.FeatureValues[end.Name] = fv + } + ctx.noteProbeWrite(fv) + fv.Value = val + fv.Values = Value{} + fv.Materialized = true +} + +// bindParticipants writes every end into the participant feature value, in declaration +// order, for a connector whose ends have no names of their own: that feature is +// where a link holds the things it relates. +func (ctx *Context) bindParticipants(inst *Instance, ends []ConnectorEnd) { + seq := NewSequence() + for _, end := range ends { + seq.Append(end.Value) + } + fv, ok := inst.FeatureValues[participantEndName] + if !ok { + fv = &FeatureValue{Feature: &EffectiveFeature{ + Name: participantEndName, + OwnerType: inst.Type, + Multiplicity: participants(len(ends)), + }} + inst.FeatureValues[participantEndName] = fv + } + ctx.noteProbeWrite(fv) + fv.Value = Value{} + fv.Values = NewSequenceValue(seq) + fv.Materialized = true +} + +// singleValue is the multiplicity of a feature holding one value, which an end +// of a connector does. +func singleValue() semantics.Range { + return semantics.Range{ + Lower: semantics.Bound{Value: 1, Known: true}, + Upper: semantics.Bound{Value: 1, Known: true}, + } +} + +// participants is the multiplicity of the participant feature of a connector +// with n ends: the ends it has, and no upper bound, as the library declares. +func participants(n int) semantics.Range { + return semantics.Range{ + Lower: semantics.Bound{Value: int64(n), Known: true}, + Upper: semantics.Bound{Known: true, Infinite: true}, + } +} + +// OwnedConnectors returns the connectors the instance owns that no feature names — +// an anonymous `connect a.p to b.q` member — materializing each once, in +// declaration order. A named connector is reached through its feature value instead. +func (inst *Instance) OwnedConnectors(ctx *Context) ([]*Instance, error) { + defer ctx.beginRun()() + members := ctx.anonymousConnectorsOf(inst.types()) + keys := anonymousKeys(members) + inst.holdAnonymous(ctx, len(members)) + for i, member := range members { + if inst.anonymous[i] != 0 { + continue + } + if _, err := inst.materializeAnonymousConnector(ctx, i, member, keys[i]); err != nil { + return nil, err + } + } + return inst.anonymousConnectors(ctx) +} + +// materializeAnonymousConnector materializes the instance's i-th anonymous +// connector, member, under the identity a carry-over kept for its declaration, key, +// if any. The connector is returned along with the failure of an older behavior answering it. +func (inst *Instance) materializeAnonymousConnector(ctx *Context, i int, member *symbols.Symbol, key anonymousKey) (*Instance, error) { + kept := inst.keptIdentity(key) + var conn *Instance + err := ctx.materializeConnectorAs(inst, member, member, kept, func(c *Instance) { + conn = c + // A probe discards the object, so the identity is kept for the one materialized after it. + ctx.noteProbeUndo(func() { + if i < len(inst.anonymous) { + inst.anonymous[i] = 0 + } + if kept != 0 { + inst.keepAnonymousIdentity(key, kept) + } + }) + inst.anonymous[i] = conn.ID + inst.dropKeptIdentity(key) + }) + return conn, err +} + +// anonymousKey identifies an anonymous connector declaration across re-analyses: +// how it is written, and its rank among siblings written identically. +type anonymousKey struct { + decl string + rank int +} + +// keptAnonymous is the identity the object of an anonymous connector declaration +// had before a carry-over, which the one materialized again takes back. +type keptAnonymous struct { + key anonymousKey + id int64 +} + +// anonymousKeys keys the anonymous connector declarations, in their order. +func anonymousKeys(members []*symbols.Symbol) []anonymousKey { + keys := make([]anonymousKey, len(members)) + ranks := make(map[string]int, len(members)) + for i, member := range members { + decl := ast.Dump(member.Decl) + keys[i] = anonymousKey{decl: decl, rank: ranks[decl]} + ranks[decl]++ + } + return keys +} + +// MaterializedConnectors returns the anonymous connectors the instance has +// already materialized, none when they were never asked for; it materializes nothing. +func (inst *Instance) MaterializedConnectors(ctx *Context) []*Instance { + if inst.anonymous == nil { + return nil + } + conns, _ := inst.anonymousConnectors(ctx) + return conns +} + +// KeptConnectorIDs returns the identities of the connectors a carry-over set +// aside for the instance to materialize again, anonymous and named, ascending. +func (inst *Instance) KeptConnectorIDs() []int64 { + ids := make([]int64, 0, len(inst.keptAnonymous)+len(inst.keptConnectors)) + for _, kept := range inst.keptAnonymous { + ids = append(ids, kept.id) + } + for _, id := range inst.keptConnectors { + ids = append(ids, id) + } + slices.Sort(ids) + return ids +} + +// RestoreConnector materializes again the connector a carry-over set aside under id, +// which takes that identity back; nil when the instance kept no such identity. A +// connector returned along with an error is kept: an older behavior failed answering it. +func (inst *Instance) RestoreConnector(ctx *Context, id int64) (*Instance, error) { + if id == 0 { + return nil, nil + } + for _, kept := range inst.keptAnonymous { + if kept.id == id { + return inst.restoreAnonymousConnector(ctx, kept.key) + } + } + for name, fv := range inst.FeatureValues { + if inst.keptConnectors[fv] != id { + continue + } + _, err := inst.GetFeatureValue(ctx, name) + conn, _ := ctx.Instance(id) + return conn, err + } + return nil, nil +} + +// restoreAnonymousConnector materializes the instance's anonymous connector +// declared as key alone, leaving its siblings and the identities kept for them as +// they are; nil when the declarations as they are now have no such one. +func (inst *Instance) restoreAnonymousConnector(ctx *Context, key anonymousKey) (*Instance, error) { + defer ctx.beginRun()() + members := ctx.anonymousConnectorsOf(inst.types()) + i := slices.Index(anonymousKeys(members), key) + if i < 0 { + return nil, nil + } + inst.holdAnonymous(ctx, len(members)) + return inst.materializeAnonymousConnector(ctx, i, members[i], key) +} + +// holdAnonymous gives the instance a slot per anonymous connector declared, n of +// them, 0 standing for one not materialized yet. A probe leaves the slots as found. +func (inst *Instance) holdAnonymous(ctx *Context, n int) { + if inst.anonymous != nil && len(inst.anonymous) >= n { + return + } + prior := inst.anonymous + ctx.noteProbeUndo(func() { inst.anonymous = prior }) + inst.anonymous = make([]int64, n) + copy(inst.anonymous, prior) +} + +// keepAnonymous sets aside, for a carry-over from prev where the instance was of prevTypes, +// the anonymous connector identities by declaration; one whose declaration is gone is dropped. +func (inst *Instance) keepAnonymous(ctx, prev *Context, prevTypes []*symbols.Symbol) { + if inst.anonymous == nil && len(inst.keptAnonymous) == 0 { + return + } + prevKeys := anonymousKeys(prev.anonymousConnectorsOf(prevTypes)) + for i, id := range inst.anonymous { + if id != 0 && i < len(prevKeys) { + inst.keepAnonymousIdentity(prevKeys[i], id) + } + } + inst.anonymous = nil + now := anonymousKeys(ctx.anonymousConnectorsOf(inst.types())) + inst.keptAnonymous = slices.DeleteFunc(inst.keptAnonymous, func(kept keptAnonymous) bool { + return !slices.Contains(now, kept.key) + }) +} + +// keepAnonymousIdentity sets id aside as the identity the anonymous connector +// declared as key had, replacing any kept for that declaration before. +func (inst *Instance) keepAnonymousIdentity(key anonymousKey, id int64) { + inst.dropKeptIdentity(key) + inst.keptAnonymous = append(inst.keptAnonymous, keptAnonymous{key: key, id: id}) +} + +// dropKeptIdentity forgets the identity kept for the anonymous connector declared as key. +func (inst *Instance) dropKeptIdentity(key anonymousKey) { + inst.keptAnonymous = slices.DeleteFunc(inst.keptAnonymous, func(kept keptAnonymous) bool { + return kept.key == key + }) +} + +// keptIdentity returns the identity the anonymous connector declared as key had +// before a carry-over, 0 when it had none. +func (inst *Instance) keptIdentity(key anonymousKey) int64 { + for _, kept := range inst.keptAnonymous { + if kept.key == key { + return kept.id + } + } + return 0 +} + +// anonymousConnectors returns the objects the instance's anonymous connectors +// materialized to, skipping those not materialized yet and dropping any the +// context no longer holds. +func (inst *Instance) anonymousConnectors(ctx *Context) ([]*Instance, error) { + out := make([]*Instance, 0, len(inst.anonymous)) + for _, id := range inst.anonymous { + if id == 0 { + continue + } + if conn, ok := ctx.Instance(id); ok { + out = append(out, conn) + } + } + return out, nil +} + +// anonymousConnectors returns the connector usages an object of typeSym owns +// that declare no name, which are members of it all the same: `connect a.p to +// b.q;` joins its ends whether or not it is named. A usage is instantiated from +// what types it, so the declarations searched are typeSym's own and those of the +// types it specializes, most specific first. +func (ctx *Context) anonymousConnectors(typeSym *symbols.Symbol) []*symbols.Symbol { + if typeSym == nil { + return nil + } + var out []*symbols.Symbol + for _, decl := range append([]*symbols.Symbol{typeSym}, ctx.model.semantics.AllSupertypes(typeSym)...) { + // A library supertype states the metamodel frame every element + // specializes, not the model's own connections. + if decl != typeSym && ctx.libraryDeclared(decl) { + continue + } + for _, member := range unwrappedDeclMembers(decl.Decl) { + usage, ok := member.(*ast.Usage) + if !ok || usage.Ident.Name != "" || usage.Ident.ShortName != "" { + continue + } + // A succession or transition carries ends too, and relates its ends in + // time rather than joining them, so it is no connector to materialize. + sym := anonymousConnectorSymbol(decl, usage) + if len(ctx.model.semantics.ConnectorObjectEnds(sym)) < 2 { + continue + } + out = append(out, sym) + } + } + return out +} + +// anonymousConnectorSymbol returns a symbol standing for an anonymous connector +// declared in typeSym's body. An unnamed declaration is registered under no +// name, so the symbol is built here — it carries the declaration, the scope it +// was written in and the file it came from, which is what resolving its ends and +// reporting them needs. +func anonymousConnectorSymbol(typeSym *symbols.Symbol, usage *ast.Usage) *symbols.Symbol { + kind := symbols.SymbolConnectionUsage + switch usage.Kind { + case ast.UsageInterface: + kind = symbols.SymbolInterfaceUsage + case ast.UsageAllocation: + kind = symbols.SymbolAllocationUsage + case ast.UsageFlow: + kind = symbols.SymbolFlowUsage + case ast.UsageBinding: + kind = symbols.SymbolBindingUsage + } + return &symbols.Symbol{ + Kind: kind, + Decl: usage, + OwnerScope: typeSym.Scope, + DocName: typeSym.DocName, + DeclSpan: usage.Span(), + } +} + +// connectorName names a connector for an error message: its declared name, or +// the kind of connector it is when it declares none. +func connectorName(sym *symbols.Symbol) string { + if sym == nil { + return "connector" + } + if sym.Name != "" { + return sym.Name + } + if usage, ok := sym.Decl.(*ast.Usage); ok && usage.Keyword != "" { + return "anonymous " + usage.Keyword + } + return "anonymous connector" +} + +// ConnectorEndError reports a connector end that cannot be attached to what it +// names, carrying where the end was written so the model can be corrected. +type ConnectorEndError struct { + Connector string // the connector as declared + End string // the feature the end names, as written + Location string // file and position of the end + Err error // why the end could not be attached +} + +func (e *ConnectorEndError) Error() string { + where := "" + if e.Location != "" { + where = " at " + e.Location + } + return fmt.Sprintf("%s: %s end %q%s: %v", ErrConnectorEnd, e.Connector, e.End, where, e.Err) +} + +func (e *ConnectorEndError) Unwrap() error { return e.Err } + +// endText renders the feature an end names as it was written, for a message. +func endText(node ast.Node) string { + if chain, ok := node.(*ast.FeatureChainExpr); ok { + return endText(chain.Operand) + "." + ast.SimpleName(chain.Member) + } + qn := ast.AsQualifiedName(node) + if qn == nil { + return "" + } + parts := make([]string, len(qn.Parts)) + for i, part := range qn.Parts { + parts[i] = part.Text + } + return strings.Join(parts, ".") +} + +// Is reports that this error is an ErrConnectorEnd, so a caller can test for +// the condition without knowing which end of which connector failed. +func (e *ConnectorEndError) Is(target error) bool { return target == ErrConnectorEnd } + +// connectorEndError builds the diagnostic for an end that cannot be attached. +func (ctx *Context) connectorEndError(connSym *symbols.Symbol, end semantics.ConnectorEndAttachment, cause error) error { + written := endText(end.Attachment) + if written == "" { + written = end.Name + } + file := "" + if connSym != nil { + file = connSym.DocName + } + span := source.Span{} + if end.End != nil { + span = end.End.Span() + } + return &ConnectorEndError{ + Connector: connectorName(connSym), + End: written, + Location: ctx.sourceLocation(file, span), + Err: cause, + } +} diff --git a/internal/exec/runtime/connector_test.go b/internal/exec/runtime/connector_test.go new file mode 100644 index 0000000000..2415ebe15c --- /dev/null +++ b/internal/exec/runtime/connector_test.go @@ -0,0 +1,1160 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// instantiatePart instantiates the named part def, for a case about the +// connectors an object of it owns. +func instantiatePart(t *testing.T, name, src string) (*Instance, *Context) { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), name, ast.DefPart) + if sym == nil { + t.Fatalf("part def %s not found", name) + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate %s: %v", name, err) + } + return inst, ctx +} + +// featureValueInstance reads a feature value expected to hold one object and returns it. +func fvInstance(t *testing.T, ctx *Context, inst *Instance, path ...string) *Instance { + t.Helper() + cur := inst + for _, name := range path { + fv, err := cur.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("GetFeatureValue %s: %v", name, err) + } + if fv.Value.Kind != ValInstance && fv.Value.Kind != ValVariant { + t.Fatalf("feature value %s holds %s, want an object", name, fv.Value.Kind) + } + next, ok := ctx.Instance(fv.Value.Instance) + if !ok { + t.Fatalf("feature value %s names object %d, which the context does not hold", name, fv.Value.Instance) + } + cur = next + } + return cur +} + +const twoPortSystem = ` + package test { + private import ScalarValues::Real; + port def P { attribute rate : Real = 3.0; } + part def A { port p : P; } + part def B { port q : P; } + connection def Link { + end source : P; + end target : P; + } + part def Sys { + part a : A; + part b : B; + connection link : Link connect a.p to b.q; + interface iface connect a.p to b.q; + connect a.p to b.q; + } + } +` + +// A connector end is the feature it attaches to, not a copy of it: the object +// at `link.source` is the very object `a.p` holds (KerML 1.0 §7.4.6). +func TestConnectorEndsAreTheConnectedFeatures(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", twoPortSystem) + port := fvInstance(t, ctx, inst, "a", "p") + peer := fvInstance(t, ctx, inst, "b", "q") + + link := fvInstance(t, ctx, inst, "link") + if got := fvInstance(t, ctx, link, "source"); got.ID != port.ID { + t.Errorf("link.source is object %d, want a.p (%d)", got.ID, port.ID) + } + if got := fvInstance(t, ctx, link, "target"); got.ID != peer.ID { + t.Errorf("link.target is object %d, want b.q (%d)", got.ID, peer.ID) + } +} + +// Writing through a connected port is read through the end attached to it: +// sharing identity is what makes the connection observable. +func TestWritingAConnectedPortIsReadThroughTheEnd(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", twoPortSystem) + port := fvInstance(t, ctx, inst, "a", "p") + fv, err := port.GetFeatureValue(ctx, "rate") + if err != nil { + t.Fatalf("GetFeatureValue rate: %v", err) + } + fv.Value = Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: 9.5}} + + end := fvInstance(t, ctx, fvInstance(t, ctx, inst, "link"), "source") + read, err := end.GetFeatureValue(ctx, "rate") + if err != nil { + t.Fatalf("GetFeatureValue rate through the end: %v", err) + } + if read.Value.Const.Real != 9.5 { + t.Errorf("link.source.rate = %v, want the 9.5 written on a.p", read.Value) + } +} + +// An optional or abstract connector links nothing of its own — it holds what a +// connector subsetting it holds — while a required one still links its ends. +func TestOptionalConnectorLinksNothingOfItsOwn(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def A { port p : P; } + part def B { port q : P; } + connection def Link { end source : P; end target : P; } + part def Sys { + part a : A; + part b : B; + connection hitch : Link[0..1] connect a.p to b.q; + interface spare[0..1] connect a.p to b.q; + abstract connection links : Link[0..*]; + connection fitted : Link[0..1] connect a.p to b.q; + connection tow : Link :> fitted connect a.p to b.q; + connection link : Link connect a.p to b.q; + } + }`) + before := len(ctx.instances) + for _, name := range []string{"hitch", "spare"} { + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("sys.%s: %v", name, err) + } + if held := fv.HeldValue(); held.Kind != ValInvalid { + t.Errorf("sys.%s = %s, want no link", name, FormatValue(held)) + } + if read, err := fv.ReadValue(name); err != nil || elementCount(&read) != 0 { + t.Errorf("sys.%s reads %s, %v; want the empty sequence", name, FormatValue(read), err) + } + } + links, err := inst.GetFeatureValue(ctx, "links") + if err != nil { + t.Fatalf("sys.links: %v", err) + } + if held := links.HeldValue(); held.Kind != ValSequence || elementCount(&held) != 0 { + t.Errorf("sys.links = %s, want an empty collection", FormatValue(held)) + } + if n := len(ctx.instances); n != before { + t.Errorf("reading the optional connectors made %d object(s), want none", n-before) + } + tow := fvInstance(t, ctx, inst, "tow") + if got := fvInstance(t, ctx, inst, "fitted"); got.ID != tow.ID { + t.Errorf("sys.fitted is object %d, want the tow that subsets it (%d)", got.ID, tow.ID) + } + port := fvInstance(t, ctx, inst, "a", "p") + for _, name := range []string{"tow", "link"} { + if got := fvInstance(t, ctx, fvInstance(t, ctx, inst, name), "source"); got.ID != port.ID { + t.Errorf("%s.source is object %d, want a.p (%d)", name, got.ID, port.ID) + } + } +} + +// An untyped connector usage names no definition, so its type is implicit +// (SysML v2 §8.3.13): it materializes all the same, with the connected features +// at its ends, rather than reading as an unknown value. +func TestUntypedConnectorUsageMaterializes(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", twoPortSystem) + port := fvInstance(t, ctx, inst, "a", "p") + peer := fvInstance(t, ctx, inst, "b", "q") + + iface := fvInstance(t, ctx, inst, "iface") + if len(iface.Ends) != 2 { + t.Fatalf("iface has %d ends, want 2", len(iface.Ends)) + } + if got := fvInstance(t, ctx, iface, "source"); got.ID != port.ID { + t.Errorf("iface.source is object %d, want a.p (%d)", got.ID, port.ID) + } + if got := fvInstance(t, ctx, iface, "target"); got.ID != peer.ID { + t.Errorf("iface.target is object %d, want b.q (%d)", got.ID, peer.ID) + } +} + +// An anonymous `connect a.p to b.q` is a member of the object no feature names, and +// joins its ends exactly as a named connector does. +func TestAnonymousConnectorJoinsItsEnds(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", twoPortSystem) + port := fvInstance(t, ctx, inst, "a", "p") + peer := fvInstance(t, ctx, inst, "b", "q") + + conns, err := inst.OwnedConnectors(ctx) + if err != nil { + t.Fatalf("OwnedConnectors: %v", err) + } + if len(conns) != 1 { + t.Fatalf("object owns %d anonymous connectors, want 1", len(conns)) + } + ends := conns[0].Ends + if len(ends) != 2 { + t.Fatalf("anonymous connector has %d ends, want 2", len(ends)) + } + if ends[0].Value.Instance != port.ID || ends[1].Value.Instance != peer.ID { + t.Errorf("anonymous connector joins %d and %d, want a.p (%d) and b.q (%d)", + ends[0].Value.Instance, ends[1].Value.Instance, port.ID, peer.ID) + } +} + +// Reading the same anonymous connector twice reads the same object: it is one +// member of the object, materialized once. +func TestAnonymousConnectorIsMaterializedOnce(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", twoPortSystem) + first, err := inst.OwnedConnectors(ctx) + if err != nil { + t.Fatalf("OwnedConnectors: %v", err) + } + second, err := inst.OwnedConnectors(ctx) + if err != nil { + t.Fatalf("OwnedConnectors again: %v", err) + } + if len(first) != 1 || len(second) != 1 || first[0].ID != second[0].ID { + t.Errorf("anonymous connector read as %v then %v, want one object both times", first, second) + } +} + +func TestBindingConnectorIsAnObjectOfItsEnds(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def A { port p : P; } + part def B { port q : P; } + part def Sys { + part a : A; + part b : B; + binding bnd bind a.p = b.q; + } + }`) + port := fvInstance(t, ctx, inst, "a", "p") + peer := fvInstance(t, ctx, inst, "b", "q") + first := fvInstance(t, ctx, inst, "bnd") + second := fvInstance(t, ctx, inst, "bnd") + if len(first.Ends) != 2 { + t.Fatalf("binding has %d ends, want two", len(first.Ends)) + } + if first.Ends[0].Value.Instance != port.ID || first.Ends[1].Value.Instance != peer.ID { + t.Errorf("binding ends = %v, want a.p (%d) and b.q (%d)", first.Ends, port.ID, peer.ID) + } + if first.ID != second.ID { + t.Errorf("binding read as objects %d then %d", first.ID, second.ID) + } +} + +func TestAnonymousBindingAndFlowAreOwnedConnectors(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def A { port p : P; } + part def B { port q : P; } + part def Sys { + part a : A; + part b : B; + bind a.p = b.q; + flow a.p to b.q; + connect a.p to b.q; + } + }`) + port := fvInstance(t, ctx, inst, "a", "p") + peer := fvInstance(t, ctx, inst, "b", "q") + first, err := inst.OwnedConnectors(ctx) + if err != nil { + t.Fatalf("OwnedConnectors: %v", err) + } + second, err := inst.OwnedConnectors(ctx) + if err != nil { + t.Fatalf("OwnedConnectors again: %v", err) + } + if len(first) != 3 || len(second) != 3 { + t.Fatalf("owned connectors = %d then %d, want three", len(first), len(second)) + } + for i, conn := range first { + if len(conn.Ends) != 2 { + t.Errorf("connector %d has %d ends, want two", i, len(conn.Ends)) + } + if len(conn.Ends) == 2 && + (conn.Ends[0].Value.Instance != port.ID || conn.Ends[1].Value.Instance != peer.ID) { + t.Errorf("connector %d ends = %v, want a.p (%d) and b.q (%d)", + i, conn.Ends, port.ID, peer.ID) + } + if conn.ID != second[i].ID { + t.Errorf("connector %d read as %d then %d", i, conn.ID, second[i].ID) + } + } +} + +func TestFlowConnectorIsAnObjectOfItsEnds(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + item def Item; + port def P; + part def A { port p : P; } + part def B { port q : P; } + part def Sys { + part a : A; + part b : B; + flow f of Item from a.p to b.q; + } + }`) + port := fvInstance(t, ctx, inst, "a", "p") + peer := fvInstance(t, ctx, inst, "b", "q") + flow := fvInstance(t, ctx, inst, "f") + if len(flow.Ends) != 2 { + t.Fatalf("flow has %d ends, want two", len(flow.Ends)) + } + if got := fvInstance(t, ctx, flow, "source"); got.ID != port.ID { + t.Errorf("flow.source is object %d, want a.p (%d)", got.ID, port.ID) + } + if got := fvInstance(t, ctx, flow, "target"); got.ID != peer.ID { + t.Errorf("flow.target is object %d, want b.q (%d)", got.ID, peer.ID) + } +} + +func TestBindingConnectorEndFollowsAFeatureChain(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def Inner { port p : P; } + part def A { part sub : Inner; } + part def B { port q : P; } + part def Sys { + part a : A; + part b : B; + binding bnd bind a.sub.p = b.q; + } + }`) + port := fvInstance(t, ctx, inst, "a", "sub", "p") + bnd := fvInstance(t, ctx, inst, "bnd") + if got := fvInstance(t, ctx, bnd, "source"); got.ID != port.ID { + t.Errorf("bnd.source is object %d, want a.sub.p (%d)", got.ID, port.ID) + } +} + +func TestBindingConnectorEndsHoldBoundValues(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + part def A { attribute x : Integer = 3; } + part def B { attribute y : Integer = 3; } + part def Sys { + part a : A; + part b : B; + bind a.x = b.y; + } + }`) + owned, err := inst.OwnedConnectors(ctx) + if err != nil { + t.Fatalf("OwnedConnectors: %v", err) + } + if len(owned) != 1 || len(owned[0].Ends) != 2 { + t.Fatalf("owned bindings = %v, want one two-ended binding", owned) + } + for i, end := range owned[0].Ends { + if end.Value.Kind != ValConst || end.Value.Const.Int != 3 { + t.Errorf("binding end %d = %s, want 3", i, FormatValue(end.Value)) + } + } +} + +const nestedSystem = ` + package test { + private import ScalarValues::Real; + port def P { attribute rate : Real = 3.0; } + part def Inner { port p : P; } + part def A { part inner : Inner; } + part def B { port q : P; } + connection def Link { end source : P; end target : P; } + connection def Link2 :> Link { end s2 : P; end t2 : P; } + part def Sys { + part a : A; + part b : B; + connection nested : Link connect a.inner.p to b.q; + connection parts connect a to b; + connection tri connect (a, b, a.inner); + connection sub : Link2 connect a.inner.p to b.q; + } + } +` + +// An end may name a feature reached through a chain: `a.inner.p` attaches the +// port of the nested part, not a port of `a`. +func TestConnectorEndFollowsAFeatureChain(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", nestedSystem) + port := fvInstance(t, ctx, inst, "a", "inner", "p") + nested := fvInstance(t, ctx, inst, "nested") + if got := fvInstance(t, ctx, nested, "source"); got.ID != port.ID { + t.Errorf("nested.source is object %d, want a.inner.p (%d)", got.ID, port.ID) + } +} + +// An end may attach to a part rather than a port: what a connector relates is +// features, of whatever kind. +func TestConnectorEndAttachesToAPart(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", nestedSystem) + a := fvInstance(t, ctx, inst, "a") + b := fvInstance(t, ctx, inst, "b") + parts := fvInstance(t, ctx, inst, "parts") + if got := fvInstance(t, ctx, parts, "source"); got.ID != a.ID { + t.Errorf("parts.source is object %d, want a (%d)", got.ID, a.ID) + } + if got := fvInstance(t, ctx, parts, "target"); got.ID != b.ID { + t.Errorf("parts.target is object %d, want b (%d)", got.ID, b.ID) + } +} + +// A connector with more than two ends keeps every one of them, in declaration +// order, in the participant feature a link holds its ends in. +func TestNaryConnectorKeepsEveryEnd(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", nestedSystem) + want := []int64{ + fvInstance(t, ctx, inst, "a").ID, + fvInstance(t, ctx, inst, "b").ID, + fvInstance(t, ctx, inst, "a", "inner").ID, + } + tri := fvInstance(t, ctx, inst, "tri") + fv, err := tri.GetFeatureValue(ctx, participantEndName) + if err != nil { + t.Fatalf("GetFeatureValue participant: %v", err) + } + if fv.Values.Kind != ValSequence { + t.Fatalf("participant holds %s, want a sequence of the ends", fv.Values.Kind) + } + got := fv.Values.Sequence().Elements() + if len(got) != len(want) { + t.Fatalf("participant holds %d ends, want %d", len(got), len(want)) + } + for i, el := range got { + if el.Instance != want[i] { + t.Errorf("participant#%d is object %d, want %d", i+1, el.Instance, want[i]) + } + } +} + +// A connector declared in a specialization redefines the inherited ends by +// position (SysML v2 §8.3.13), so both names read the one end. +func TestRedefinedEndSharesTheInheritedFeatureValue(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", nestedSystem) + port := fvInstance(t, ctx, inst, "a", "inner", "p") + peer := fvInstance(t, ctx, inst, "b", "q") + sub := fvInstance(t, ctx, inst, "sub") + for _, name := range []string{"s2", "source"} { + if got := fvInstance(t, ctx, sub, name); got.ID != port.ID { + t.Errorf("sub.%s is object %d, want a.inner.p (%d)", name, got.ID, port.ID) + } + } + for _, name := range []string{"t2", "target"} { + if got := fvInstance(t, ctx, sub, name); got.ID != peer.ID { + t.Errorf("sub.%s is object %d, want b.q (%d)", name, got.ID, peer.ID) + } + } +} + +const variationSystem = ` + package test { + port def P; + part def Part { port p1 : P; port p2 : P; port p3 : P; } + part def Sys { + part x : Part; + variation interface link { + variant interface direct connect x.p1 to x.p2; + variant interface indirect connect x.p1 to x.p3; + } + } + part chosenDirect : Sys { ref :>> link = link::direct; } + part chosenIndirect : Sys { ref :>> link = link::indirect; } + } +` + +// The connection a selected `variant interface` declares is realized: the +// variation's feature value holds that variant's connector, with the features it connects +// at its ends. Selecting the other variant realizes the other connection. +func TestSelectedVariantInterfaceIsRealized(t *testing.T) { + for _, tt := range []struct { + usage string + target string // the port the selected variant connects x.p1 to + }{ + {"chosenDirect", "p2"}, + {"chosenIndirect", "p3"}, + } { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, variationSystem)) + sym := findSymbolByName(idx.DocumentRoot(""), tt.usage, ast.DefPart) + if sym == nil { + t.Fatalf("part %s not found", tt.usage) + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate %s: %v", tt.usage, err) + } + source := fvInstance(t, ctx, inst, "x", "p1") + want := fvInstance(t, ctx, inst, "x", tt.target) + other := "p3" + if tt.target == "p3" { + other = "p2" + } + unselected := fvInstance(t, ctx, inst, "x", other) + + link := fvInstance(t, ctx, inst, "link") + if len(link.Ends) != 2 { + t.Fatalf("%s: the realized connection has %d ends, want 2", tt.usage, len(link.Ends)) + } + if got := fvInstance(t, ctx, link, "source"); got.ID != source.ID { + t.Errorf("%s: link.source is object %d, want x.p1 (%d)", tt.usage, got.ID, source.ID) + } + got := fvInstance(t, ctx, link, "target") + if got.ID != want.ID { + t.Errorf("%s: link.target is object %d, want x.%s (%d)", tt.usage, got.ID, tt.target, want.ID) + } + if got.ID == unselected.ID { + t.Errorf("%s: link.target is x.%s, which the unselected variant connects", tt.usage, other) + } + } +} + +// testUnattachableConnectorEnd: an end naming a feature no object reaches is +// reported, with where the end was written — a connector that cannot be attached +// is no connector, and must not read as an unknown value or fabricate an object +// of the end's declared type. +func testUnattachableConnectorEnd(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def A { port p : P; } + part def Sys { + part a : A; + connection link connect a.p to a.missing; + } + } + `) + _, err := inst.GetFeatureValue(ctx, "link") + if !errors.Is(err, ErrConnectorEnd) { + t.Fatalf("expected ErrConnectorEnd, got: %v", err) + } + var endErr *ConnectorEndError + if !errors.As(err, &endErr) { + t.Fatalf("expected a *ConnectorEndError, got %T", err) + } + if endErr.End != "a.missing" { + t.Errorf("error names end %q, want a.missing", endErr.End) + } + if !strings.Contains(endErr.Location, "") { + t.Errorf("error is located at %q, want the file the end was written in", endErr.Location) + } + for _, id := range ctx.InstanceIDs() { + if obj, _ := ctx.Instance(id); obj.Ends != nil || obj.Type.Name == "link" { + t.Errorf("the connector that could not be attached was left behind as object %d", id) + } + } +} + +// testUnattachableConnectorLeavesNoBehavior: a connector that cannot be attached +// runs nothing either — the behaviors its type binds, started when the connector +// was created, go with it, so a later run drains only surviving objects' work. +func testUnattachableConnectorLeavesNoBehavior(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def A { port p : P; } + connection def Link { + attribute n : Integer = 0; + exhibit state life { entry; then up; state up { entry action bump { assign n := 1; } } } + } + part def Sys { + part a : A; + connection link : Link connect a.p to a.missing; + connection ok : Link connect a.p to a.p; + } + } + `) + if _, err := inst.GetFeatureValue(ctx, "link"); !errors.Is(err, ErrConnectorEnd) { + t.Fatalf("expected ErrConnectorEnd, got: %v", err) + } + if got := len(ctx.objectBehaviors); got != 0 { + t.Errorf("%d behavior(s) left attached by the connector that could not be attached", got) + } + if got := len(ctx.pendingBehaviors); got != 0 { + t.Errorf("%d behavior(s) left queued by the connector that could not be attached", got) + } + + ok := fvInstance(t, ctx, inst, "ok") + if got := len(ctx.objectBehaviors); got != 1 { + t.Errorf("%d behavior(s) attached, want the attached connector's alone", got) + } + if _, running := ok.ExhibitedState(); !running { + t.Error("the connector that attached exhibits no machine") + } + if got := featureInt(t, ctx, ok, "n"); got != 1 { + t.Errorf("n = %d, want 1 once the connector's machine entered up", got) + } +} + +// testUnattachableConnectorAbandonsWhatItsEndsMaterialized: an object an earlier +// end materialized goes with the connector a later end fails, its behaviors too. +// The part is a package-level occurrence, materialized when an end first names it. +func testUnattachableConnectorAbandonsWhatItsEndsMaterialized(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, ` + package test { + port def P; + part def Ticking { + port p : P; + exhibit state life { entry; then on; state on; } + } + part ticker : Ticking; + part def Sys { + connection link connect ticker.p to ticker.missing; + } + } + `) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + inst, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "Sys")) + if err != nil { + t.Fatalf("Instantiate Sys: %v", err) + } + before := len(ctx.InstanceIDs()) + if _, held := ctx.occurrences[resolveSymbol(t, pkg.Scope, "ticker")]; held { + t.Fatal("ticker was materialized with Sys: the end naming it materializes it") + } + if _, err := inst.GetFeatureValue(ctx, "link"); !errors.Is(err, ErrConnectorEnd) { + t.Fatalf("expected ErrConnectorEnd, got: %v", err) + } + if got := len(ctx.InstanceIDs()); got != before { + t.Errorf("%d object(s) held, want %d: the part the first end materialized is gone with the connector", got, before) + } + if got := len(ctx.objectBehaviors); got != 0 { + t.Errorf("%d behavior(s) left attached by the part the connector's first end materialized", got) + } + if got := len(ctx.pendingBehaviors); got != 0 { + t.Errorf("%d behavior(s) left queued by the part the connector's first end materialized", got) + } + + ticker, err := ctx.occurrenceOf(resolveSymbol(t, pkg.Scope, "ticker")) + if err != nil { + t.Fatalf("occurrenceOf ticker: %v", err) + } + assertCurrentState(t, machineOf(t, ticker, "life").State, "on") + if got := len(ctx.objectBehaviors); got != 1 { + t.Errorf("%d behavior(s) attached once ticker is read on its own, want its machine alone", got) + } +} + +// testUnattachableConnectorTouchesNoOtherObject: a connector that cannot attach +// has sent no message and written nothing on the objects that survive it. +func testUnattachableConnectorTouchesNoOtherObject(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, ` + package test { + item def Ping; + port def P; + part def A { port p : P; } + part def Listener { + attribute heard : Integer = 0; + exhibit state listening { + entry; then waiting; + state waiting; + accept Ping then noted; + state noted { entry action note { assign heard := heard + 1; } } + } + } + part good : Listener; + connection def Link { + exhibit state life { entry; then up; state up { entry send Ping() to good; } } + } + part def Sys { + part a : A; + connection link : Link connect a.p to a.missing; + connection ok : Link connect a.p to a.p; + } + } + `) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + good, err := ctx.occurrenceOf(resolveSymbol(t, pkg.Scope, "good")) + if err != nil { + t.Fatalf("occurrenceOf good: %v", err) + } + inst, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "Sys")) + if err != nil { + t.Fatalf("Instantiate Sys: %v", err) + } + if _, err := inst.GetFeatureValue(ctx, "link"); !errors.Is(err, ErrConnectorEnd) { + t.Fatalf("expected ErrConnectorEnd, got: %v", err) + } + assertCurrentState(t, machineOf(t, good, "listening").State, "waiting") + if got := featureInt(t, ctx, good, "heard"); got != 0 { + t.Errorf("heard = %d after the connector that could not be attached, want 0: its behavior never ran", got) + } + if got := len(ctx.messages); got != 0 { + t.Errorf("%d message(s) left on the bus by the connector that could not be attached", got) + } + + fvInstance(t, ctx, inst, "ok") + assertCurrentState(t, machineOf(t, good, "listening").State, "noted") + if got := featureInt(t, ctx, good, "heard"); got != 1 { + t.Errorf("heard = %d once the connector that attached ran, want 1", got) + } +} + +// testConnectorWhoseStartFailsLeavesNoTrace: a connector whose own behavior +// writes to and signals an older object before failing is undone whole — the +// write, the signal and what the older object would have done on it — and a +// connector whose start succeeds is answered by the older object once kept. +func testConnectorWhoseStartFailsLeavesNoTrace(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, ` + package test { + item def Ping; + port def P; + part def Listener { + attribute heard : Integer = 0; + exhibit state listening { + entry; then waiting; + state waiting; + accept Ping then noted; + state noted { entry action note { assign heard := heard + 1; } } + } + } + part good : Listener; + part def A { port p : P; } + connection def Faulty { + exhibit state life { + entry; then poke; + state poke { entry action bump { assign good.heard := good.heard + 10; } } + transition first poke then ping; + state ping { entry send Ping() to good; } + transition first ping then fail; + state fail { entry action bad { assign good.nosuch := 1; } } + } + } + connection def Fine { + exhibit state life { + entry; then poke; + state poke { entry action bump { assign good.heard := good.heard + 10; } } + transition first poke then ping; + state ping { entry send Ping() to good; } + } + } + part def Sys { + part a : A; + connection faulty : Faulty connect a.p to a.p; + connection fine : Fine connect a.p to a.p; + } + } + `) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + good, err := ctx.occurrenceOf(resolveSymbol(t, pkg.Scope, "good")) + if err != nil { + t.Fatalf("occurrenceOf good: %v", err) + } + inst, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "Sys")) + if err != nil { + t.Fatalf("Instantiate Sys: %v", err) + } + if _, err := inst.GetFeatureValue(ctx, "faulty"); !errors.Is(err, ErrNoSuchFeature) { + t.Fatalf("expected ErrNoSuchFeature from the connector's behavior, got: %v", err) + } + if fv := inst.FeatureValues["faulty"]; fv != nil && fv.Materialized { + t.Errorf("the connector whose start failed is held: %+v", fv) + } + assertCurrentState(t, machineOf(t, good, "listening").State, "waiting") + if got := featureInt(t, ctx, good, "heard"); got != 0 { + t.Errorf("heard = %d after the connector whose start failed, want 0: its write was undone", got) + } + if got := len(ctx.messages); got != 0 { + t.Errorf("%d message(s) left on the bus by the connector whose start failed", got) + } + if got := len(ctx.objectBehaviors) + len(ctx.pendingBehaviors); got != 1 { + t.Errorf("%d behavior(s) attached or pending besides good's machine", got-1) + } + + fine := fvInstance(t, ctx, inst, "fine") + assertCurrentState(t, machineOf(t, fine, "life").State, "ping") + assertCurrentState(t, machineOf(t, good, "listening").State, "noted") + if got := featureInt(t, ctx, good, "heard"); got != 11 { + t.Errorf("heard = %d once a connector's start succeeded, want 11: its write kept and its signal answered", got) + } + if got := len(ctx.messages); got != 0 { + t.Errorf("%d message(s) left on the bus once the connector was kept and answered", got) + } +} + +// testConnectorAnsweredByAFailingBehaviorIsKept: a connector created whole is +// kept when an older object's behavior fails answering it — its write kept, its +// object held under its feature, its behavior attached — and the failure is that +// behavior's, reported once: reading the connector again neither fails nor reruns it. +func testConnectorAnsweredByAFailingBehaviorIsKept(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, ` + package test { + item def Ping; + port def P; + part def Listener { + attribute heard : Integer = 0; + exhibit state listening { + entry; then waiting; + state waiting; + accept Ping then noted; + state noted { entry action note { assign heard := heard + nosuch; } } + } + } + part good : Listener; + part other : Listener; + part def A { port p : P; } + connection def Fine { + attribute pokes : Integer = 0; + exhibit state life { + entry; then poke; + state poke { entry action bump { assign good.heard := good.heard + 10; assign pokes := pokes + 1; } } + transition first poke then ping; + state ping { entry send Ping() to good; } + } + } + connection def Other { + exhibit state life { entry; then ping; state ping { entry send Ping() to other; } } + } + part def Sys { + part a : A; + connection fine : Fine connect a.p to a.p; + connection : Other connect a.p to a.p; + } + } + `) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + good, err := ctx.occurrenceOf(resolveSymbol(t, pkg.Scope, "good")) + if err != nil { + t.Fatalf("occurrenceOf good: %v", err) + } + inst, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "Sys")) + if err != nil { + t.Fatalf("Instantiate Sys: %v", err) + } + if _, err := inst.GetFeatureValue(ctx, "fine"); !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("expected the listener's behavior to fail on its unresolved reference, got: %v", err) + } + fv := inst.FeatureValues["fine"] + if fv == nil || !fv.Materialized || fv.Value.Kind != ValInstance { + t.Fatalf("the connector the listener failed answering is not held: %+v", fv) + } + fine, live := ctx.Instance(fv.Value.Instance) + if !live { + t.Fatalf("the context does not hold connector %d", fv.Value.Instance) + } + if len(fine.Ends) != 2 { + t.Fatalf("the kept connector has %d end(s), want 2", len(fine.Ends)) + } + for _, end := range fine.Ends { + if _, held := ctx.Instance(end.Value.Instance); end.Value.Kind != ValInstance || !held { + t.Errorf("end %q of the kept connector is not attached to a held object: %+v", end.Name, end.Value) + } + } + assertCurrentState(t, machineOf(t, fine, "life").State, "ping") + if got := featureInt(t, ctx, good, "heard"); got != 10 { + t.Errorf("heard = %d, want 10: the kept connector's write stays with the connector", got) + } + if got := featureInt(t, ctx, fine, "pokes"); got != 1 { + t.Errorf("pokes = %d, want 1: the connector's own write is kept", got) + } + if got := len(ctx.messages); got != 0 { + t.Errorf("%d message(s) left on the bus: the listener took the signal before failing", got) + } + + again, err := inst.GetFeatureValue(ctx, "fine") + if err != nil { + t.Fatalf("reading the kept connector again: %v", err) + } + if again.Value.Instance != fine.ID { + t.Errorf("reading again holds connector %d, want %d: the kept one", again.Value.Instance, fine.ID) + } + if got := featureInt(t, ctx, fine, "pokes"); got != 1 { + t.Errorf("pokes = %d after reading again, want 1: the kept connector does not run again", got) + } + + // An anonymous connector is kept the same way, in its slot. + if _, err := ctx.occurrenceOf(resolveSymbol(t, pkg.Scope, "other")); err != nil { + t.Fatalf("occurrenceOf other: %v", err) + } + if _, err := inst.OwnedConnectors(ctx); !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("expected the other listener's behavior to fail on its unresolved reference, got: %v", err) + } + if got := len(ctx.messages); got != 0 { + t.Errorf("%d message(s) left on the bus: the other listener took the signal before failing", got) + } + conns := inst.MaterializedConnectors(ctx) + if len(conns) != 1 { + t.Fatalf("the object holds %d anonymous connectors, want 1: the one the other listener failed answering", len(conns)) + } + assertCurrentState(t, machineOf(t, conns[0], "life").State, "ping") + if again, err := inst.OwnedConnectors(ctx); err != nil || len(again) != 1 || again[0].ID != conns[0].ID { + t.Errorf("OwnedConnectors again = %v, %v; want the kept connector %d and no error", again, err, conns[0].ID) + } +} + +// testUnattachableConnectorEndsRunNothingEarly: the behaviors of an object an +// end materializes run only once every end is attached, so a later end's failure +// leaves the objects that survive it exactly as they were. +func testUnattachableConnectorEndsRunNothingEarly(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, ` + package test { + item def Ping; + port def P; + part def Listener { + attribute heard : Integer = 0; + exhibit state listening { + entry; then waiting; + state waiting; + accept Ping then noted; + state noted { entry action note { assign heard := heard + 1; } } + } + } + part good : Listener; + part def Pinger { + port p : P; + exhibit state life { entry; then up; state up { entry send Ping() to good; } } + } + part pinger : Pinger; + part def Sys { + connection link connect pinger.p to pinger.missing; + connection ok connect pinger.p to pinger.p; + } + } + `) + pkg := resolveSymbol(t, root, "test") + ctx := NewContext(typedModel(model, resolver), 10000) + good, err := ctx.occurrenceOf(resolveSymbol(t, pkg.Scope, "good")) + if err != nil { + t.Fatalf("occurrenceOf good: %v", err) + } + inst, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "Sys")) + if err != nil { + t.Fatalf("Instantiate Sys: %v", err) + } + before := len(ctx.InstanceIDs()) + if _, held := ctx.occurrences[resolveSymbol(t, pkg.Scope, "pinger")]; held { + t.Fatal("pinger was materialized with Sys: the end naming it materializes it") + } + if _, err := inst.GetFeatureValue(ctx, "link"); !errors.Is(err, ErrConnectorEnd) { + t.Fatalf("expected ErrConnectorEnd, got: %v", err) + } + if got := len(ctx.InstanceIDs()); got != before { + t.Errorf("%d object(s) held, want %d: the part the first end materialized is gone with the connector", got, before) + } + assertCurrentState(t, machineOf(t, good, "listening").State, "waiting") + if got := featureInt(t, ctx, good, "heard"); got != 0 { + t.Errorf("heard = %d after the connector that could not be attached, want 0: the part its first end materialized never ran", got) + } + if got := len(ctx.messages); got != 0 { + t.Errorf("%d message(s) left on the bus by the part the connector's first end materialized", got) + } + if got := len(ctx.objectBehaviors) + len(ctx.pendingBehaviors); got != 1 { + t.Errorf("%d behavior(s) attached or pending besides good's machine", got-1) + } + + fvInstance(t, ctx, inst, "ok") + pinger, err := ctx.occurrenceOf(resolveSymbol(t, pkg.Scope, "pinger")) + if err != nil { + t.Fatalf("occurrenceOf pinger: %v", err) + } + assertCurrentState(t, machineOf(t, pinger, "life").State, "up") + assertCurrentState(t, machineOf(t, good, "listening").State, "noted") + if got := featureInt(t, ctx, good, "heard"); got != 1 { + t.Errorf("heard = %d once the connector that attached ran the part it materialized, want 1", got) + } +} + +// testMultiplicityOnAConnector: a connector usage holding more than one +// connector is not one connection, so there is no set of ends to attach — that +// is reported rather than filled with objects of the connector's type. +func testMultiplicityOnAConnector(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def A { port p : P; } + part def B { port q : P; } + part def Sys { + part a : A; + part b : B; + connection links[2] connect a.p to b.q; + } + } + `) + _, err := inst.GetFeatureValue(ctx, "links") + if !errors.Is(err, ErrConnectorEnd) { + t.Fatalf("expected ErrConnectorEnd, got: %v", err) + } + var endErr *ConnectorEndError + if !errors.As(err, &endErr) { + t.Fatalf("expected a *ConnectorEndError, got %T", err) + } + if !strings.Contains(endErr.Location, "") { + t.Errorf("error is located at %q, want the file the connector was written in", endErr.Location) + } +} + +// testConnectorAttachedToItself: an end naming the connector it belongs to would +// attach ends forever, so the cycle is reported rather than recursed into. +func testConnectorAttachedToItself(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def A { port p : P; } + part def Sys { + part a : A; + connection link connect link to a.p; + } + } + `) + if _, err := inst.GetFeatureValue(ctx, "link"); !errors.Is(err, ErrCyclicFeatureValue) { + t.Fatalf("expected ErrCyclicFeatureValue, got: %v", err) + } +} + +// testMutuallyAttachedConnectors: two connectors each naming the other as an end +// are a cycle across feature values, reported the same way as a self-attached one. +func testMutuallyAttachedConnectors(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def A { port p : P; } + part def Sys { + part a : A; + connection here connect there to a.p; + connection there connect here to a.p; + } + } + `) + if _, err := inst.GetFeatureValue(ctx, "here"); !errors.Is(err, ErrCyclicFeatureValue) { + t.Fatalf("expected ErrCyclicFeatureValue, got: %v", err) + } +} + +// An anonymous succession or transition carries ends too, but relates them in +// time rather than joining them, so it is no connector to materialize and must +// not be reported as one when the connectors of an object are read. +func TestAnonymousSuccessionIsNoConnector(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def A { port p : P; } + part def B { port q : P; } + action def Step; + part def Sys { + part a : A; + part b : B; + action one : Step; + action two : Step; + succession one then two; + connect a.p to b.q; + } + } + `) + conns, err := inst.OwnedConnectors(ctx) + if err != nil { + t.Fatalf("OwnedConnectors: %v", err) + } + if len(conns) != 1 { + t.Fatalf("the object owns %d anonymous connectors, want only the `connect`", len(conns)) + } + if len(conns[0].Ends) != 2 { + t.Errorf("the connector has %d ends, want 2", len(conns[0].Ends)) + } +} + +// A variation point belongs to the object that selected it: two objects of one +// type each selecting a variant must record their own selection, so neither +// overwrites the other's. +func TestVariantSelectionIsPerOwner(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + port def P; + part def Engine { port p : P; } + abstract part family { + variation part engine : Engine { + variant part electric : Engine; + variant part petrol : Engine; + } + } + part sedan :> family { part :>> engine = engine::electric; } + part coupe :> family { part :>> engine = engine::petrol; } + }`)) + owners := map[string]*Instance{} + for usage, want := range map[string]string{"test::sedan": "electric", "test::coupe": "petrol"} { + inst, err := ctx.Instantiate(oneSymbol(t, idx, usage)) + if err != nil { + t.Fatalf("%s: %v", usage, err) + } + if _, err := inst.GetFeatureValue(ctx, "engine"); err != nil { + t.Fatalf("%s.engine: %v", usage, err) + } + owners[want] = inst + if got := ctx.selectedVariants[variantSelection{owner: inst.ID, variation: "engine"}]; got != want { + t.Errorf("%s selected %q, want %q", usage, got, want) + } + } + // A connection of an object is governed by that object's own selection, so + // each object resolves the variation to the variant it selected. + conn := lower.Connection{Variation: "engine", Owner: lower.OwnerObject} + for want, inst := range owners { + if got := ctx.selectedVariant(conn, inst); got != want { + t.Errorf("routing resolved engine to %q for the object selecting %q", got, want) + } + } +} + +// A connector usage is not a part: instantiating the object it denotes must not +// go through ordinary composite materialization, which would build an object of +// each end's declared type instead of attaching the connected features. +func TestConnectorUsageIsRecognized(t *testing.T) { + idx, model, _ := buildRuntime(t, "", parseAndBuild(t, twoPortSystem)) + scope := idx.DocumentRoot("") + sys := findSymbolByName(scope, "Sys", ast.DefPart) + if sys == nil || sys.Scope == nil { + t.Fatal("Sys part def not found") + } + for name, want := range map[string]bool{"link": true, "iface": true, "a": false, "b": false} { + var sym *symbols.Symbol + if found, ok := sys.Scope.LookupLocal(name); ok { + sym = found + } + if sym == nil { + t.Fatalf("member %s not found", name) + } + if got := model.IsConnectorUsage(sym); got != want { + t.Errorf("IsConnectorUsage(%s) = %v, want %v", name, got, want) + } + } +} + +// Every connector-like usage that states ends with a connect clause attaches the +// same way, whatever keyword declares it: an allocation states its ends with +// `allocate` (SysML v2 §8.3.19) and a KerML `connector` with `connect`. Both +// take their implicit type from the library when they name no definition. +func TestEveryConnectorKindAttachesItsEnds(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def A { port p : P; } + part def B { port q : P; } + part def Sys { + part a : A; + part b : B; + allocation alloc allocate a.p to b.q; + connector wire connect a.p to b.q; + } + } + `) + port := fvInstance(t, ctx, inst, "a", "p") + peer := fvInstance(t, ctx, inst, "b", "q") + for _, name := range []string{"alloc", "wire"} { + conn := fvInstance(t, ctx, inst, name) + if got := fvInstance(t, ctx, conn, "source"); got.ID != port.ID { + t.Errorf("%s.source is object %d, want a.p (%d)", name, got.ID, port.ID) + } + if got := fvInstance(t, ctx, conn, "target"); got.ID != peer.ID { + t.Errorf("%s.target is object %d, want b.q (%d)", name, got.ID, peer.ID) + } + } +} diff --git a/internal/exec/runtime/constraint_test.go b/internal/exec/runtime/constraint_test.go new file mode 100644 index 0000000000..88d5c317b6 --- /dev/null +++ b/internal/exec/runtime/constraint_test.go @@ -0,0 +1,508 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +func TestConstraintEvaluation_Assert(t *testing.T) { + src := ` + package test { + constraint PositiveValue { + value > 0 + } + } + ` + + // Parse + file := parser.New(source.New("test.sysml", []byte(src))).ParseFile() + + // Build symbol index + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", file) + + // Create resolver and semantic model + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + + // Create runtime context + ctx := NewContext(typedModel(model, resolver), 10000) + + // Resolve constraint + rootScope := idx.DocumentRoot("test.sysml") + testPkg := rootScope.Children()[0] + constraintSym, ok := testPkg.LookupLocal("PositiveValue") + if !ok { + t.Fatal("PositiveValue constraint not found") + } + + // Note: This test will fail because 'value' is unbound + // In real usage, constraints are evaluated with bindings + _, err := ctx.EvaluateConstraint(constraintSym, testPkg) + if err == nil { + t.Fatal("Expected error for unbound 'value'") + } + + if !errors.Is(err, ErrUnresolvedReference) { + t.Errorf("err = %v; want it to be an unresolved reference", err) + } +} + +func TestConstraintEvaluation_AssertWithLiteral(t *testing.T) { + src := ` + package test { + constraint AlwaysTrue { + 5 > 3 + } + + constraint AlwaysFalse { + 2 > 10 + } + } + ` + + // Parse + file := parser.New(source.New("test.sysml", []byte(src))).ParseFile() + + // Build symbol index + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", file) + + // Create resolver and semantic model + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + + // Create runtime context + ctx := NewContext(typedModel(model, resolver), 10000) + + // Resolve constraints + rootScope := idx.DocumentRoot("test.sysml") + testPkg := rootScope.Children()[0] + + // Test AlwaysTrue + alwaysTrue, ok := testPkg.LookupLocal("AlwaysTrue") + if !ok { + t.Fatal("AlwaysTrue not found") + } + + satisfied, err := ctx.EvaluateConstraint(alwaysTrue, testPkg) + if err != nil { + t.Fatalf("AlwaysTrue evaluation failed: %v", err) + } + if !satisfied { + t.Fatal("AlwaysTrue should be satisfied") + } + t.Logf("✓ AlwaysTrue: assertion passed") + + // Test AlwaysFalse + alwaysFalse, ok := testPkg.LookupLocal("AlwaysFalse") + if !ok { + t.Fatal("AlwaysFalse not found") + } + + _, err = ctx.EvaluateConstraint(alwaysFalse, testPkg) + if err == nil { + t.Fatal("AlwaysFalse should fail") + } + if !errors.Is(err, ErrViolated) { + t.Fatalf("Expected a violation verdict, got: %v", err) + } + t.Logf("✓ AlwaysFalse: assertion failed (as expected)") +} + +func TestConstraintEvaluation_Assume(t *testing.T) { + src := ` + package test { + constraint WithAssumption { + assume constraint { 1 > 5 } // false assumption, but should pass + } + } + ` + + // Parse + file := parser.New(source.New("test.sysml", []byte(src))).ParseFile() + + // Build symbol index + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", file) + + // Create resolver and semantic model + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + + // Create runtime context + ctx := NewContext(typedModel(model, resolver), 10000) + + // Resolve constraint + rootScope := idx.DocumentRoot("test.sysml") + testPkg := rootScope.Children()[0] + constraintSym, ok := testPkg.LookupLocal("WithAssumption") + if !ok { + t.Fatal("WithAssumption not found") + } + + // Evaluate - should pass even though assumption is false + satisfied, err := ctx.EvaluateConstraint(constraintSym, testPkg) + if err != nil { + t.Fatalf("Assumption should not fail: %v", err) + } + if !satisfied { + t.Fatal("Constraint with assumption should be satisfied") + } + t.Logf("✓ Assumption passed (false assumptions are trusted)") +} + +func TestConstraintEvaluation_Negation(t *testing.T) { + src := ` + package test { + constraint NotNegative { + not (3 < 0) + } + } + ` + + // Parse + file := parser.New(source.New("test.sysml", []byte(src))).ParseFile() + + // Build symbol index + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", file) + + // Create resolver and semantic model + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + + // Create runtime context + ctx := NewContext(typedModel(model, resolver), 10000) + + // Resolve constraint + rootScope := idx.DocumentRoot("test.sysml") + testPkg := rootScope.Children()[0] + constraintSym, ok := testPkg.LookupLocal("NotNegative") + if !ok { + t.Fatal("NotNegative not found") + } + + // Evaluate - assert not (3 < 0) → assert not false → assert true → pass + satisfied, err := ctx.EvaluateConstraint(constraintSym, testPkg) + if err != nil { + t.Fatalf("Negated assertion failed: %v", err) + } + if !satisfied { + t.Fatal("Negated assertion should pass") + } + t.Logf("✓ Negated assertion passed") +} + +// A constraint with nothing to check has no verdict: reporting one would claim +// a check that never ran. +func TestConstraintWithoutConditionsIsNotAVerdict(t *testing.T) { + src := ` + package test { + constraint def Empty { } + part def Rig { + constraint nothing : Empty; + } + } + ` + file := parser.New(source.New("test.sysml", []byte(src))).ParseFile() + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", file) + resolver := resolve.New(idx) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) + + testPkg := idx.DocumentRoot("test.sysml").Children()[0] + rig, ok := testPkg.LookupLocal("Rig") + if !ok { + t.Fatal("Rig not found") + } + feat := featureNamed(ctx, rig, "nothing") + if feat == nil || feat.Symbol == nil { + t.Fatal("constraint feature not found") + } + + satisfied, err := ctx.EvaluateConstraintOn(feat.Symbol, feat.DeclScope(), nil) + if !errors.Is(err, ErrNoConditions) { + t.Fatalf("err = %v, want ErrNoConditions", err) + } + if satisfied { + t.Error("an unevaluated constraint reported as satisfied") + } +} + +func TestConstraintBodyStatementIsNotAVerdict(t *testing.T) { + // The assignment would make the condition hold; ignoring it would report a + // false verdict, so the check must refuse instead. + src := ` + package test { + constraint def Reassigned { + attribute y = 1; + assign y := 10; + y > 5 + } + part def Rig { + attribute z = 1; + constraint branched { if true { assign z := 10; } z > 5 } + constraint failedFirst { z > 100; assign z := 200; z > 5 } + assert not constraint denied { z > 100; assign z := 200; z > 5 } + assert constraint grouped { z > 100; assert not constraint { assign z := 200; z > 5 } } + } + } + ` + file := parser.New(source.New("test.sysml", []byte(src))).ParseFile() + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", file) + resolver := resolve.New(idx) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) + + testPkg := idx.DocumentRoot("test.sysml").Children()[0] + reassigned, ok := testPkg.LookupLocal("Reassigned") + if !ok { + t.Fatal("Reassigned not found") + } + satisfied, err := ctx.EvaluateConstraint(reassigned, testPkg) + if !errors.Is(err, ErrStatementNotExecuted) { + t.Fatalf("err = %v, want ErrStatementNotExecuted", err) + } + if want := "`assign` statement"; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want it to name the %s", err, want) + } + if satisfied { + t.Error("a constraint whose body statement was skipped reported as satisfied") + } + + rig, ok := testPkg.LookupLocal("Rig") + if !ok { + t.Fatal("Rig not found") + } + feat := featureNamed(ctx, rig, "branched") + if feat == nil || feat.Symbol == nil { + t.Fatal("constraint feature not found") + } + satisfied, err = ctx.EvaluateConstraintOn(feat.Symbol, feat.DeclScope(), nil) + if !errors.Is(err, ErrStatementNotExecuted) { + t.Fatalf("err = %v, want ErrStatementNotExecuted", err) + } + if want := "`if` statement"; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want it to name the %s", err, want) + } + if satisfied { + t.Error("a constraint whose body statement was skipped reported as satisfied") + } + + // A condition failing before the statement is no verdict either, not even + // for a negated constraint; the group case nests the statement. + for _, name := range []string{"failedFirst", "denied", "grouped"} { + feat := featureNamed(ctx, rig, name) + if feat == nil || feat.Symbol == nil { + t.Fatalf("constraint %s not found", name) + } + satisfied, err := ctx.EvaluateConstraintOn(feat.Symbol, feat.DeclScope(), nil) + if !errors.Is(err, ErrStatementNotExecuted) { + t.Errorf("%s: err = %v, want ErrStatementNotExecuted", name, err) + } + if want := "`assign` statement"; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: err = %v, want it to name the %s", name, err, want) + } + if satisfied { + t.Errorf("%s: reported as satisfied with its body statement skipped", name) + } + } +} + +func TestConstraintBodyPerformIsNotAVerdict(t *testing.T) { + // A performed action is a usage, not a statement node, and it is one more + // thing the body does that a verdict would have to account for. + src := ` + package test { + action def Bump { inout n; assign n := n + 10; } + constraint def Performed { + attribute y = 1; + perform action bump : Bump { inout n = y; } + y > 5 + } + part def Rig { + attribute z = 1; + action bump : Bump { inout n = z; } + constraint shorthand { perform bump; z > 5 } + constraint nested { assert constraint { perform bump; z > 5 } } + requirement required { require constraint { perform bump; z > 5 } } + } + } + ` + file := parser.New(source.New("test.sysml", []byte(src))).ParseFile() + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", file) + resolver := resolve.New(idx) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) + + testPkg := idx.DocumentRoot("test.sysml").Children()[0] + performed, ok := testPkg.LookupLocal("Performed") + if !ok { + t.Fatal("Performed not found") + } + satisfied, err := ctx.EvaluateConstraint(performed, testPkg) + if !errors.Is(err, ErrStatementNotExecuted) { + t.Fatalf("err = %v, want ErrStatementNotExecuted", err) + } + if want := "`perform` statement"; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want it to name the %s", err, want) + } + if satisfied { + t.Error("a constraint whose performed action was skipped reported as satisfied") + } + + rig, ok := testPkg.LookupLocal("Rig") + if !ok { + t.Fatal("Rig not found") + } + for _, name := range []string{"shorthand", "nested", "required"} { + feat := featureNamed(ctx, rig, name) + if feat == nil || feat.Symbol == nil { + t.Fatalf("%s not found", name) + } + evaluate := ctx.EvaluateConstraintOn + if name == "required" { + evaluate = ctx.EvaluateRequirementOn + } + satisfied, err := evaluate(feat.Symbol, feat.DeclScope(), nil) + if !errors.Is(err, ErrStatementNotExecuted) { + t.Errorf("%s: err = %v, want ErrStatementNotExecuted", name, err) + } + if want := "`perform` statement"; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: err = %v, want it to name the %s", name, err, want) + } + if satisfied { + t.Errorf("%s: reported as satisfied with its performed action skipped", name) + } + } +} + +func TestConstraintBodyActionFlowIsNotAVerdict(t *testing.T) { + // Action nodes and the successions between them are steps of the body too: + // a verdict that skipped them would answer a different constraint. + src := ` + package test { + constraint def Flowed { + attribute y = 1; + action a; action b; + first a then b; + y > 5 + } + part def Rig { + attribute z = 1; + constraint edge { action a; action b; first a then b; z > 5 } + constraint attached { action a then b; action b; z > 5 } + constraint named { action a; action b; succession s first a then b; z > 5 } + constraint node { action a; action b; fork f; first a then f; first f then b; z > 5 } + constraint plain { action a; z > 5 } + constraint nested { assert constraint { action a; action b; first a then b; z > 5 } } + requirement required { require constraint { action a; action b; first a then b; z > 5 } } + } + } + ` + file := parser.New(source.New("test.sysml", []byte(src))).ParseFile() + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", file) + resolver := resolve.New(idx) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) + + testPkg := idx.DocumentRoot("test.sysml").Children()[0] + flowed, ok := testPkg.LookupLocal("Flowed") + if !ok { + t.Fatal("Flowed not found") + } + satisfied, err := ctx.EvaluateConstraint(flowed, testPkg) + if !errors.Is(err, ErrStatementNotExecuted) { + t.Fatalf("err = %v, want ErrStatementNotExecuted", err) + } + if want := "`action` statement"; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want it to name the %s", err, want) + } + if satisfied { + t.Error("a constraint whose action flow was skipped reported as satisfied") + } + + rig, ok := testPkg.LookupLocal("Rig") + if !ok { + t.Fatal("Rig not found") + } + for _, name := range []string{"edge", "attached", "named", "node", "plain", "nested", "required"} { + feat := featureNamed(ctx, rig, name) + if feat == nil || feat.Symbol == nil { + t.Fatalf("%s not found", name) + } + evaluate := ctx.EvaluateConstraintOn + if name == "required" { + evaluate = ctx.EvaluateRequirementOn + } + satisfied, err := evaluate(feat.Symbol, feat.DeclScope(), nil) + if !errors.Is(err, ErrStatementNotExecuted) { + t.Errorf("%s: err = %v, want ErrStatementNotExecuted", name, err) + } + if want := "`action` statement"; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: err = %v, want it to name the %s", name, err, want) + } + if satisfied { + t.Errorf("%s: reported as satisfied with its action flow skipped", name) + } + } +} + +func TestConstraintBodySuccessionAloneIsNotAVerdict(t *testing.T) { + // A succession between actions declared outside the body is still a step + // the body states, and it is named by the keyword it was written with. + src := ` + package test { + part def Rig { + attribute z = 1; + action a; action b; + constraint edge { first a then b; z > 5 } + constraint attached { then b; z > 5 } + constraint named { succession s first a then b; z > 5 } + constraint flow { succession flow from a to b; z > 5 } + constraint final { done; z > 5 } + } + } + ` + file := parser.New(source.New("test.sysml", []byte(src))).ParseFile() + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", file) + resolver := resolve.New(idx) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) + + testPkg := idx.DocumentRoot("test.sysml").Children()[0] + rig, ok := testPkg.LookupLocal("Rig") + if !ok { + t.Fatal("Rig not found") + } + for name, want := range map[string]string{ + "edge": "`first` statement", + "attached": "`then` statement", + "named": "`succession` statement", + "flow": "`succession flow` statement", + "final": "`done` statement", + } { + feat := featureNamed(ctx, rig, name) + if feat == nil || feat.Symbol == nil { + t.Fatalf("%s not found", name) + } + satisfied, err := ctx.EvaluateConstraintOn(feat.Symbol, feat.DeclScope(), nil) + if !errors.Is(err, ErrStatementNotExecuted) { + t.Errorf("%s: err = %v, want ErrStatementNotExecuted", name, err) + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: err = %v, want it to name the %s", name, err, want) + } + if satisfied { + t.Errorf("%s: reported as satisfied with its succession skipped", name) + } + } +} diff --git a/internal/core/runtime/constructor_expr_test.go b/internal/exec/runtime/constructor_expr_test.go similarity index 100% rename from internal/core/runtime/constructor_expr_test.go rename to internal/exec/runtime/constructor_expr_test.go diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go new file mode 100644 index 0000000000..88690bf519 --- /dev/null +++ b/internal/exec/runtime/context.go @@ -0,0 +1,1871 @@ +package runtime + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// Context is the run-derived state of one execution over a Model: the objects +// it created, their values and lifetimes, the messages in flight, the clock, the +// executors' progress. A snapshot captures it; the Model it runs over is shared +// with every other run of the same model and is not part of it. +type Context struct { + // model is the model-derived part every context over one model shares. + model *Model + // ids hands out instance identities. Contexts holding the same objects share + // one sequence, so no two of them name different objects alike. + ids *idSequence + // took is one past the highest identity this context took from ids (see idMark). + took *idMark + maxSteps int64 + instances map[int64]*Instance + created []int64 + // lives holds, per registered object, when it began and ended (lifetimes.go). + lives map[int64]life + // lifetimes stands for the lives as a `=` value reads them, to derive again when they change. + lifetimes FeatureValue + + // maxActionSteps, maxStateEvents and maxDoSteps bound the executors this + // context runs: token-flow steps, dispatched events, and do actions. + // Unlike maxSteps they are counted by the executor, not here. + maxActionSteps int64 + maxStateEvents int64 + maxDoSteps int64 + + // streaming holds the pins whose writes are being carried on along streaming flows + // at this moment, to catch flows that lead a value back to where it was written. + streaming map[streamKey]bool + + // maxElements bounds the collection elements one run materializes, which is + // what its memory grows with, unlike a step. + maxElements int64 + + // maxInstances bounds the objects this context holds at once, nested ones + // counted; zero leaves it unbounded. + maxInstances int + + // framesReading holds the frame each object being read is (nil for a + // transformation), so `target = that` finds it and a cycle is reported. + framesReading map[int64]*CoordinateFrame + + // evaluations is the log of the case run under way (evaluation_log.go), nil + // outside one. + evaluations *evaluationLog + + // calcUsageRunning holds the calc usages whose bodies are running, so a body + // reading its own usage is a recursion rather than a nested evaluation. + calcUsageRunning map[calcUsageKey]*calcShape + + // activations numbers the body activations begun in this context: a calc + // invocation, a block entry, a loop iteration, a body application. + activations int64 + // runs numbers the behavior runs begun in this context — calc invocations, calc + // usage evaluations, action performances — which functions closing over one carry. + runs int64 + + // occurrences holds the objects each usage carrying no value of its own denotes, in + // declaration order: one for a usage of one occurrence, its lower bound for a collection. + occurrences map[*symbols.Symbol][]int64 + // namespaceBindings holds the value each namespace-level object usage given a value + // denotes, so every read of it reads the one binding rather than evaluating it anew. + namespaceBindings map[*symbols.Symbol]Value + // bindingStack holds the namespace-level usages whose values are being evaluated, innermost + // last, so a value reaching back to its own usage is a cycle, and an extent finds no object of it yet. + bindingStack []*symbols.Symbol + // bindingReads holds, per bound usage, the declarations its value read to arrive at the binding, + // so a re-analysis carries the binding only while every one of them still reads the same. + bindingReads map[*symbols.Symbol]*bindingReads + // metadataObjects holds the object each metadata annotation denotes, so + // reading `.metadata` twice reads one object per annotation. The annotation + // is named by the element it annotates and its place among that element's + // annotations, so a reanalysis can rebind it. + metadataObjects map[metadataAnnotation]int64 + // tools runs the external tool a ToolExecution names; nil refuses every such action. + tools ToolRunner + + // variantObjects holds the object a variant stands for per owner that + // selected it, so repeated reads of one selection read the same object. + variantObjects map[variantObject]int64 + + // selectedVariants records, per owner and variation name, the variant bound + // to it in this run. Routing consults it: a connection a `variant interface` + // declares joins its ends only where that variant is the one selected. + selectedVariants map[variantSelection]string + + // materializingConnectors holds the connectors whose ends are being attached, + // so a connector reached from its own end is reported as a cycle. + materializingConnectors map[connectorRef]bool + + // resolvingBindings guards binding endpoint resolution for one instance + // feature, so a valueless binding cycle is reported rather than recursed. + resolvingBindings map[featureValueRef]bool + bindingOwners map[featureValueRef]*ast.Usage + + // pendingBehaviors are the object behaviors attached but not yet run, drained + // by the outermost materialization so a start reached from inside a running + // behavior does not run it recursively. + pendingBehaviors []*ObjectBehavior + + // behaviorRunDepth is the number of classifier-behavior starts under way. + behaviorRunDepth int + + // declarative makes the context read declared values only: no classifier + // behavior starts when an object is materialized (see DeclaredReader). + declarative bool + + // heldBehaviors are the behaviors already holding work when the outermost + // start under way began: a driver put it in flight, and dispatches it. + heldBehaviors map[*ObjectBehavior]bool + // holdingDriven marks that hold however it came out, nil map included; a + // nested start leaves the driving to the outermost one. + holdingDriven bool + + // objectBehaviors are every behavior an object of this context runs, so a + // drain to quiescence can re-run one a sibling's send woke. + objectBehaviors []*ObjectBehavior + + // trace records evaluation, nil when not tracing. + trace *TraceRecorder + // stepWrites is the ledger of the action step under way, nil between steps. + stepWrites *stepWriteLedger + + // actionDepth is the number of action invocations currently on the stack, + // bounding recursion across nested action executors. + actionDepth int + + // body is the body run on the stack a breakpoint or a wait on the clock + // pauses (action_body_run.go), nil while none is. + body *bodyRun + // clockHeldBy names the behavior on the stack that must end at the instant it + // runs at, so no wait on the clock under it may advance the clock; "" for none. + clockHeldBy string + + // calcDepth is the number of calc invocations currently on the stack, which + // maxCalcDepth bounds, so a recursion evaluates while it stays within it. + calcDepth int + maxCalcDepth int64 + + // maxSweepRuns bounds the runs one parameter sweep or sample asks for. + maxSweepRuns int64 + + // freeInvocationFrames are the frames of returned calc invocations, kept so a + // recursion reuses storage rather than allocating per call. + freeInvocationFrames []*invocationFrame + + // argStack holds the positional arguments of the calc invocations under way, + // innermost last, so an invocation borrows rather than allocates its storage. + argStack []Value + + // scalarStack holds the frames of the compiled calc invocations under way — + // parameters, then body locals — innermost last, the compiled tier's + // counterpart of argStack. + scalarStack []scalar + + // libraryArgBuf holds the boxed arguments of the library call a compiled body + // is making, and libraryEval the context a collection built-in it calls takes. + libraryArgBuf []Value + libraryEval EvalContext + + // compileCalcs enables the compiled tier for eligible calc bodies; the + // OPENSYSML_CALC_COMPILE escape hatch clears it. + compileCalcs bool + + // probes is the number of probes under way; see beginProbe. + probes int + // journals is the number of probes and transactions under way: while one is, + // every change is journaled for it to undo; see beginJournal. + journals int + // journalWrites are the feature values the journals under way changed, with + // what each held before, restored as each is undone. + journalWrites []journalWrite + // journalUndos restore what else the journals under way changed on an object — + // the identities it keeps for connectors not yet materialized — run in reverse + // as each is undone; see noteProbeUndo. + journalUndos []func() + // snapshots are the live snapshots of this context, oldest first (snapshot.go); + // each is a journal under way. + snapshots []*Snapshot + // deriving are the `=` values being derived, innermost last; every feature + // value read while one is records it as a dependent (see dependents.go). + deriving []derivation + // runBoundaries mark, innermost last, where in objectBehaviors and in + // pendingBehaviors the behaviors a change still to be kept or undone attached + // begin: the only ones a drain under it may run (see nextRunnableBehavior). + runBoundaries []runBoundary + // storing are the stores under way, innermost last, each keeping the journal of the hold + // it reached open until the behaviors the hold started have run (see storedBeforeStarting). + storing []*storing + // run is the state of the run under way, or of the latest one ended; see beginRun. + run *runState + // runDepth is the number of runs currently under way, so the state is installed + // per top-level run rather than kept over the context's whole life. + runDepth int + + // schedule is the policy the next run resolves its choice points under. + schedule SchedulePolicy + // modelSeed fixes the modeled draws of the runs, whatever the policy (modeled.go). + modelSeed modelSeed + // drawPolicy is how the runs resolve their random draws (draw_policy.go). + drawPolicy DrawPolicy + // exploring is the exploration run this context's runs take part in, nil + // outside Explore (explore.go). + exploring *exploreRun + // replaying is the witness this context's runs follow in turn under a + // `replay` policy (replay.go), nil under any other. + replaying *replayRun + // choices are the choice points the context's runs resolved, in order: the + // witness a replay of them follows. + choices []ChoiceTaken + // draws are the random draws the context's runs made, in order (modeled.go). + draws []DrawTaken + + // messages are the signals in flight, oldest first. The bus is context-wide, + // so a message one behavior sends can be accepted in another. + messages []Message + // bus counts what changed the messages in flight; writes counts the feature + // values written or restored. A machine's poll of the bus is memoized on them. + bus busSerials + writes uint64 + // polling, while a machine scans the bus, notes what the scan read beyond it. + polling *pendingMemo + // mail, while a state's do behavior runs, is where its accepts look in place + // of the bus: the message its machine dispatched to it, none between dispatches. + mail *[]Message + + // clock is the simulation time every executor of this context shares, and + // clockRun the run an advance of it draws its due-order choices from. + clock Clock + clockRun executorRun + // work counts the changes that can leave an attached behavior holding work; + // quiescent is the memo a full scan leaves when it finds them all idle. + work uint64 + quiescent quiescence + // onStack lists the runs of the executors whose calls are under way, outermost first. + onStack []*executorRun + + // derivingFeatureValues holds the feature values whose defaults are being evaluated, so a + // default that refers back to its own feature value is reported as a cycle. + derivingFeatureValues map[featureValueRef]bool + + // collectingSubsets holds the feature values whose subsetting features are being read, + // so features that subset each other are reported as a cycle. + collectingSubsets map[featureValueRef]bool + // readingSubsetted holds the optional features whose subsetted collections are + // being read ahead of them, so two subsetting each other do not recurse. + readingSubsetted map[featureValueRef]bool +} + +// featureValueRef identifies one feature value of one instance. +type featureValueRef struct { + instance int64 + feature string +} + +// featureOfType names a declared feature read as a feature of one type. +type featureOfType struct { + feature, owner *symbols.Symbol +} + +// variantSelection identifies a variation point of one object: two objects of a +// type each select their own variant of the same variation. +type variantSelection struct { + owner int64 + variation string +} + +// connectorRef identifies one connector being materialized in the context of +// the object whose features its ends name. +type connectorRef struct { + owner int64 + connector *symbols.Symbol +} + +// NewContext creates a run's context over the model-derived part model. +// maxSteps sets the runaway guard (step counter limit); the executor bounds take +// their defaults, which SetBudgets replaces. +// It panics if maxSteps <= 0: the limit is a programmer-supplied invariant, not +// user input, so callers must pass a positive value. +func NewContext(model *Model, maxSteps int64) *Context { + if maxSteps <= 0 { + panic(fmt.Sprintf("runtime: maxSteps must be > 0, got %d", maxSteps)) + } + if model == nil { + panic("runtime: NewContext needs a Model") + } + ctx := &Context{ + model: model, + maxSteps: maxSteps, + instances: make(map[int64]*Instance), + lives: make(map[int64]life), + + compileCalcs: CalcCompileFromEnv(), + + run: &runState{ + calcUsageRuns: make(map[int64]map[calcUsageKey]*calcRun), + }, + calcUsageRunning: make(map[calcUsageKey]*calcShape), + + maxActionSteps: DefaultMaxActionSteps, + maxStateEvents: DefaultMaxStateEvents, + maxDoSteps: DefaultMaxDoSteps, + maxElements: DefaultMaxElements, + maxCalcDepth: DefaultMaxCalcDepth, + maxSweepRuns: DefaultMaxSweepRuns, + + occurrences: make(map[*symbols.Symbol][]int64), + namespaceBindings: make(map[*symbols.Symbol]Value), + bindingReads: make(map[*symbols.Symbol]*bindingReads), + metadataObjects: make(map[metadataAnnotation]int64), + variantObjects: make(map[variantObject]int64), + selectedVariants: make(map[variantSelection]string), + + materializingConnectors: make(map[connectorRef]bool), + derivingFeatureValues: make(map[featureValueRef]bool), + resolvingBindings: make(map[featureValueRef]bool), + bindingOwners: make(map[featureValueRef]*ast.Usage), + collectingSubsets: make(map[featureValueRef]bool), + readingSubsetted: make(map[featureValueRef]bool), + } + ctx.took = &idMark{high: 1} + ctx.ids = newIDSequence(ctx.took) + return ctx +} + +// Model returns the model-derived part this context runs over. +func (ctx *Context) Model() *Model { + return ctx.model +} + +// declaredSymbol is the symbol a scope tree registered with the model declares +// for the declaration sym stands for, or sym itself when none does. +func (ctx *Context) declaredSymbol(sym *symbols.Symbol) *symbols.Symbol { + return ctx.model.declaredSymbol(sym) +} + +// collectDeclared records the symbol declared by each node under scope. +func collectDeclared(scope *symbols.Scope, into map[ast.Node]*symbols.Symbol) { + scope.ForEachMember(func(sym *symbols.Symbol) bool { + if sym.Decl != nil { + into[sym.Decl] = sym + } + return true + }) + for _, child := range scope.Children() { + collectDeclared(child, into) + } +} + +// sourceLocation renders where a span in a file was written, as +// `file:line:col`. It falls back to a byte offset for a file whose text was not +// registered, and to the file name alone when there is no span, so a diagnostic +// always says as much as the context knows. +func (ctx *Context) sourceLocation(file string, span source.Span) string { + if file == "" { + return "" + } + sf, ok := ctx.model.sources[file] + if !ok || span.End() > sf.Len() { + if span.Len == 0 && span.Offset == 0 { + return file + } + return fmt.Sprintf("%s:#%d", file, span.Offset) + } + pos := sf.Lines().PosAt(span.Offset) + return fmt.Sprintf("%s:%d:%d", file, pos.Line, pos.Col) +} + +// symbolLocation renders where a symbol was declared, empty for none. +func (ctx *Context) symbolLocation(sym *symbols.Symbol) string { + if sym == nil { + return "" + } + return ctx.sourceLocation(sym.DocName, sym.DeclSpan) +} + +// SetTrace attaches a trace recorder to this context, so that every expression +// and calc evaluated through it is recorded. Pass nil to stop tracing. +func (ctx *Context) SetTrace(tr *TraceRecorder) { + ctx.trace = tr +} + +// Trace is the recorder attached to this context, nil when not tracing. +func (ctx *Context) Trace() *TraceRecorder { + return ctx.trace +} + +// SetSchedule sets the policy the runs started from now on resolve their choice +// points under; a run already under way keeps the one it started with. An +// `explore` policy is ErrExploreUndriven: it is driven by Explore. A `replay` +// policy starts its witness over, which the runs then follow in turn. +func (ctx *Context) SetSchedule(policy SchedulePolicy) error { + if _, explores := policy.Exploration(); explores { + return fmt.Errorf("%w: %s replays whole runs from the start, so it is driven by Explore", ErrExploreUndriven, policy) + } + ctx.schedule = policy + ctx.replaying = nil + if policy.kind == scheduleReplay { + ctx.replaying = newReplayRun(policy.replay.witness) + } + return nil +} + +// ErrRescheduleMidRun is Reschedule from inside a step. +var ErrRescheduleMidRun = errors.New("reschedule inside a step") + +// Reschedule is SetSchedule reaching the runs driven call by call as well — the +// clock and the behaviors the objects run — which resolve their choice points +// under the policy from their next step on, as a run started under it would from +// its first: their configurations, pending events, clock and choices so far are +// kept, and the draws of a seeded policy start over. It fails with +// ErrRescheduleMidRun from inside a step. +func (ctx *Context) Reschedule(policy SchedulePolicy) error { + if ctx.runDepth > 0 || ctx.actionDepth > 0 || ctx.calcDepth > 0 || ctx.body != nil || ctx.probes > 0 { + return ErrRescheduleMidRun + } + if err := ctx.SetSchedule(policy); err != nil { + return err + } + for _, state := range ctx.drivenRunStates() { + state.scheduler = ctx.newScheduler() + } + return nil +} + +// drivenRunStates are the states of the runs driven call by call, each once. +func (ctx *Context) drivenRunStates() []*runState { + var states []*runState + add := func(state *runState) { + if state != nil && !slices.Contains(states, state) { + states = append(states, state) + } + } + add(ctx.clockRun.state) + for _, behavior := range ctx.objectBehaviors { + switch { + case behavior.State != nil: + add(behavior.State.driven.state) + case behavior.Action != nil: + add(behavior.Action.driven.state) + } + } + return states +} + +// beginExploration makes the context's runs the given run of an exploration; the +// state installed for a run no bracket began starts over, drawing from it. +func (ctx *Context) beginExploration(policy SchedulePolicy, run *exploreRun) { + ctx.schedule = policy + ctx.exploring, ctx.replaying = run, nil + ctx.run = ctx.newRunState() +} + +// newScheduler starts the resolutions of one run under the context's policy. +func (ctx *Context) newScheduler() *scheduler { + return ctx.schedulerUnder(ctx.schedule) +} + +// schedulerUnder starts one run's resolutions under policy, drawing from the +// exploration or the witness the context's runs share. +func (ctx *Context) schedulerUnder(policy SchedulePolicy) *scheduler { + s := policy.start() + s.explore = ctx.exploring + if policy.kind == scheduleReplay && ctx.replaying != nil { + s.replay = ctx.replaying + } + if s.replay != nil { + s.replay.ctx = ctx + } + s.modeled = ctx.modeledUnder(policy, s.replay) + s.draws = ctx.drawPolicy + if s.replay != nil { + s.draws = s.replay.policy + } + return s +} + +// Schedule returns the policy the next run resolves its choice points under. +func (ctx *Context) Schedule() SchedulePolicy { + return ctx.schedule +} + +// scheduling returns the resolutions the run under way draws, starting them for +// a run no bracket began. +func (ctx *Context) scheduling() *scheduler { + if ctx.run.scheduler == nil { + ctx.run.scheduler = ctx.newScheduler() + } + return ctx.run.scheduler +} + +// Semantics returns the semantic model this context operates over. +func (ctx *Context) Semantics() *semantics.Model { + return ctx.model.semantics +} + +// conforms is the model's conformance across scope trees: the index and a +// document each build a symbol of their own for one declaration, so a symbol +// conforms to another declared by the same node as it or one of its supertypes. +func (ctx *Context) conforms(a, b *symbols.Symbol) bool { + if ctx.modelConforms(a, b) { + return true + } + if a == nil || b == nil || b.Decl == nil { + return false + } + if a.Decl == b.Decl { + return true + } + for _, sup := range ctx.model.semantics.AllSupertypes(a) { + if sup != nil && sup.Decl == b.Decl { + return true + } + } + return false +} + +// Resolver returns the name resolver this context resolves references with. +func (ctx *Context) Resolver() *resolve.Resolver { + return ctx.model.resolver +} + +// SourceLocation renders where a span in a file was written, as `file:line:col`, +// falling back to a byte offset for a file whose text was not registered. +func (ctx *Context) SourceLocation(file string, span source.Span) string { + return ctx.sourceLocation(file, span) +} + +// idSequence hands out instance identities, one per object over the contexts +// sharing it. Of each it keeps only its mark, so a context dropped is not kept alive. +type idSequence struct { + next int64 + marks []*idMark +} + +// idMark is one past the highest identity a context took from its sequence and +// has not rolled back; it outlives the context, so what a dropped one took stays taken. +type idMark struct { + high int64 +} + +// newIDSequence starts the identities of one context at 1; 0 is no identity. +func newIDSequence(mark *idMark) *idSequence { + return &idSequence{next: 1, marks: []*idMark{mark}} +} + +// share hands the sequence to ctx as well, raised past what ctx handed out so far. +func (s *idSequence) share(ctx *Context) { + s.atLeast(ctx.ids.next) + ctx.ids.marks = slices.DeleteFunc(ctx.ids.marks, func(m *idMark) bool { return m == ctx.took }) + s.marks = append(s.marks, ctx.took) + ctx.ids = s +} + +// take hands out the next identity to the context marked by mark. +func (s *idSequence) take(mark *idMark) int64 { + id := s.next + s.next++ + mark.high = s.next + return id +} + +// atLeast raises the sequence to hand out id next, never lowering it. +func (s *idSequence) atLeast(id int64) { + if id > s.next { + s.next = id + } +} + +// release hands out id next again once ctx, rolling back, holds no identity from id +// on — but never one another context sharing the sequence took. +func (s *idSequence) release(ctx *Context, id int64) { + if ctx.holdsIdentityFrom(id) { + return + } + ctx.took.high = min(ctx.took.high, id) + next := id + for _, mark := range s.marks { + next = max(next, mark.high) + } + if next < s.next { + s.next = next + } +} + +// holdsIdentityFrom reports whether an object, or a connector one set aside, +// holds an identity at or past id. +func (ctx *Context) holdsIdentityFrom(id int64) bool { + for held, inst := range ctx.instances { + if held >= id { + return true + } + for _, kept := range inst.keptConnectors { + if kept >= id { + return true + } + } + for _, kept := range inst.keptAnonymous { + if kept.id >= id { + return true + } + } + } + return false +} + +// heldIdentities are the identities ctx holds: its objects' and those set aside +// for connectors not materialized again. +func (ctx *Context) heldIdentities() map[int64]bool { + held := make(map[int64]bool, len(ctx.instances)) + for id, inst := range ctx.instances { + held[id] = true + for _, kept := range inst.KeptConnectorIDs() { + held[kept] = true + } + } + return held +} + +// allocateID returns the next instance ID and increments the counter. +func (ctx *Context) allocateID() int64 { + return ctx.ids.take(ctx.took) +} + +// claimID counts id as taken by this context: an identity an object was made or +// adopted under rather than handed out here. +func (ctx *Context) claimID(id int64) { + ctx.ids.atLeast(id + 1) + ctx.took.high = max(ctx.took.high, id+1) +} + +// runState is what one run keeps of itself: the budget it spent, what it noted +// (see note), its scheduler, and the calc usage evaluations of its open activations. +type runState struct { + steps int64 + elements int64 + notes []RunNote + // scheduler is the resolutions the run's choices draw from (scheduler.go). + scheduler *scheduler + // calcUsageRuns holds, per activation under way, the evaluation of each calc + // usage read in it, so its outputs answer from one run of the body (calc_usage.go). + calcUsageRuns map[int64]map[calcUsageKey]*calcRun + // extentCandidates holds, per type an extent was taken of, the namespace usages + // that may hold one (extent.go). + extentCandidates map[*symbols.Symbol]*extentCandidates +} + +// newRunState is the state a run starts with, under the schedule policy set now. +func (ctx *Context) newRunState() *runState { + return &runState{ + scheduler: ctx.newScheduler(), + calcUsageRuns: make(map[int64]map[calcUsageKey]*calcRun), + } +} + +// enterRun brackets one call of the run with this state: a top-level call installs +// it, and it stays installed after so callers read that run; a nested one shares the outer's. +func (ctx *Context) enterRun(state *runState) func() { + if ctx.runDepth == 0 { + ctx.run = state + } + ctx.runDepth++ + return ctx.leaveRun +} + +// leaveRun ends one call of the run under way. +func (ctx *Context) leaveRun() { + ctx.runDepth-- +} + +// beginRun starts a run and returns the function that ends it: a top-level run +// starts on a fresh state, so the budget bounds one run, not a whole session. +// No body around the run pauses for a wait under it (syncBoundary). +func (ctx *Context) beginRun() func() { + leave := ctx.nestRun() + restore := ctx.syncBoundary() + return func() { + restore() + leave() + } +} + +// nestRun is beginRun for a run the body on the stack pauses for: a nested action +// performed from a body, whose waits pause the body. +func (ctx *Context) nestRun() func() { + if ctx.runDepth > 0 { + return ctx.enterRun(ctx.run) + } + return ctx.enterRun(ctx.newRunState()) +} + +// executorRun is a run driven call by call: its state, nil until its first call +// begins it, which every later call resumes; owned when that state is its own +// rather than an enclosing run's. +type executorRun struct { + state *runState + owned bool + // exec is the executor the run drives, nil for the clock's; caller is the run + // a callee's executor was begun under, whose performance encloses it. + exec endable + caller *executorRun + // serial counts the calls into the executor and into those begun under it; + // active is how many of them are under way. + serial uint64 + active int +} + +// endable is an executor whose performance an occurrence's end may end. +type endable interface { + // endsWith reports whether the executor's performance ends with the objects + // ended: its occurrence or performer, or the performance it is a step of. + endsWith(ended map[int64]bool) bool + // endTerminated ends the executor's performance where it is, terminated. + endTerminated() + // performerEnded reports whether the executor's occurrence or performer has ended. + performerEnded() bool +} + +// endsWithin reports whether the run's executor ends with the objects ended. +func (run *executorRun) endsWithin(ended map[int64]bool) bool { + return run != nil && run.exec != nil && run.exec.endsWith(ended) +} + +// beginExecutorRun brackets one call into a call-by-call driven executor: the run's +// own state, fresh at its first call, is installed for each, whatever ran in between. +// The run is on the stack of executors under way until the call returns. +func (ctx *Context) beginExecutorRun(run *executorRun) func() { + if run.state == nil { + if ctx.runDepth > 0 { + run.state = ctx.run + } else { + run.state, run.owned = ctx.newRunState(), true + } + } + run.stir(1) + ctx.onStack = append(ctx.onStack, run) + leave := ctx.enterRun(run.state) + ctx.workChanged() + // A call into an executor whose performer ended in between finds its performance over. + if run.exec != nil && run.exec.performerEnded() { + run.exec.endTerminated() + } + return func() { + leave() + ctx.onStack = ctx.onStack[:len(ctx.onStack)-1] + run.stir(-1) + ctx.workChanged() + } +} + +// stir counts a change of what the run's executor holds, from a call into it or +// into one begun under it (entered +1, left -1) or a restore (0); a memo over the +// executor's state keys on the count and stands only while no call is under way. +func (run *executorRun) stir(entering int) { + for r := run; r != nil; r = r.caller { + r.serial++ + r.active += entering + } +} + +// settled reports whether no call into the run's executor, or into one begun +// under it, is under way. +func (run *executorRun) settled() bool { return run.active == 0 } + +// innermostRun is the run of the executor whose call is under way, nil outside any. +func (ctx *Context) innermostRun() *executorRun { + if len(ctx.onStack) == 0 { + return nil + } + return ctx.onStack[len(ctx.onStack)-1] +} + +// underWay reports whether a call into run's executor is on the stack. +func (ctx *Context) underWay(run *executorRun) bool { + return slices.Contains(ctx.onStack, run) +} + +// endedWhole is the refusal of a call-by-call run of its own that ended with +// witness moves left over; one sharing an enclosing run, or stepped inside one +// (the clock's advance), leaves them to it. +func (ctx *Context) endedWhole(run *executorRun) error { + if !run.owned || ctx.run != run.state { + return nil + } + return run.state.scheduler.unfollowed("the run ended") +} + +// previewExecutorRun installs, for a preview of a call into a call-by-call driven +// executor, the state that call would run on, restored after; nothing is begun. +func (ctx *Context) previewExecutorRun(run *executorRun) func() { + if ctx.runDepth > 0 { + return func() { /* nested: the outer run's state */ } + } + saved := ctx.run + if run.state != nil { + ctx.run = run.state + } else { + ctx.run = ctx.newRunState() + } + ctx.workChanged() + return func() { ctx.run = saved; ctx.workChanged() } +} + +// endExecutorRun brackets the release of a call-by-call driven run: its leftovers +// are ended on its own state, nested or not, and the state installed before is restored. +func (ctx *Context) endExecutorRun(run *executorRun) func() { + if run.state == nil { + return func() { /* never begun: nothing of its own to end */ } + } + saved := ctx.run + ctx.run = run.state + return func() { ctx.run = saved } +} + +// beginProbe brackets an evaluation previewing what a run would do, restoring the +// budget, trace, bus, variant selections, objects made (identities included), +// behaviors attached, every feature value written (see noteProbeWrite) and every +// other change noted (see noteProbeUndo) after. The writes it makes are not the +// step's (see noteWrite); behaviors it starts are the only ones it runs (see nextRunnableBehavior). +func (ctx *Context) beginProbe() func() { + run := ctx.run + steps, elements, trace, writes := run.steps, run.elements, ctx.trace, ctx.stepWrites + ids, nextID := ctx.ids, ctx.ids.next + endBoundary := func() { /* no boundary to close */ } + if ctx.probes == 0 { + endBoundary = ctx.beginRunBoundary() + } + _, rollback := ctx.beginJournal() + restoreSchedule := run.scheduler.mark() + restoreBody := ctx.syncBoundary() + ctx.trace, ctx.stepWrites = nil, nil + ctx.runDepth++ + ctx.probes++ + return func() { + restoreBody() + rollback() + endBoundary() + restoreSchedule() + if ctx.ids == ids { + ids.release(ctx, nextID) + } + ctx.probes-- + ctx.leaveRun() + run.steps, run.elements, ctx.trace, ctx.stepWrites = steps, elements, trace, writes + } +} + +// runBoundary is where in objectBehaviors and pendingBehaviors the behaviors +// attached since a change began start. +type runBoundary struct { + behaviors, pending int +} + +// beginRunBoundary confines drains to the behaviors attached from now until the +// returned function is called: what an older behavior does cannot be undone. +func (ctx *Context) beginRunBoundary() func() { + ctx.runBoundaries = append(ctx.runBoundaries, runBoundary{ + behaviors: len(ctx.objectBehaviors), + pending: len(ctx.pendingBehaviors), + }) + return func() { ctx.runBoundaries = ctx.runBoundaries[:len(ctx.runBoundaries)-1] } +} + +// beginJournal brackets a change to be kept whole or not at all: the feature +// values written (see noteProbeWrite), the other changes noted (see +// noteProbeUndo — variant selections among them), the bus, and the objects made +// and behaviors attached are journaled until commit keeps them or rollback +// restores them. A commit inside an enclosing journal leaves the entries to it. +func (ctx *Context) beginJournal() (commit, rollback func()) { + mark := ctx.markJournal() + ctx.journals++ + commit = func() { + ctx.journals-- + if ctx.journals == 0 { + ctx.journalWrites, ctx.journalUndos = ctx.journalWrites[:mark.writes], ctx.journalUndos[:mark.undos] + } + } + rollback = func() { + ctx.journals-- + ctx.rollbackJournal(mark) + } + return commit, rollback +} + +// journalWrite is a feature value a journal changed and what it held before. +type journalWrite struct { + fv *FeatureValue + prior FeatureValue +} + +// noteProbeWrite records a feature value about to change, for the probe or +// transaction under way to restore; outside one it records nothing. +func (ctx *Context) noteProbeWrite(fv *FeatureValue) { + ctx.writes++ + if ctx.journals == 0 { + return + } + ctx.journalWrites = append(ctx.journalWrites, journalWrite{fv: fv, prior: *fv}) +} + +// noteProbeUndo records how to restore state no feature value holds that is about +// to change, for the probe or transaction under way to run; outside one it +// records nothing. +func (ctx *Context) noteProbeUndo(undo func()) { + if ctx.journals == 0 { + return + } + ctx.journalUndos = append(ctx.journalUndos, undo) +} + +// newActivation begins one activation: the identity of a single execution of a +// body, which the values a calc usage answers within it belong to. +func (ctx *Context) newActivation() int64 { + ctx.activations++ + return ctx.activations +} + +// newRun begins one behavior run: the identity a function closing over it carries, +// which no other run of the same behavior shares. +func (ctx *Context) newRun() int64 { + ctx.runs++ + return ctx.runs +} + +// endActivation forgets what an activation computed, once it has ended, and the +// activations of the calc usage evaluations it held. +func (ctx *Context) endActivation(activation int64) { + runs, ok := ctx.run.calcUsageRuns[activation] + if !ok { + return + } + delete(ctx.run.calcUsageRuns, activation) + for _, run := range runs { + ctx.endActivation(run.activation) + } +} + +// incrementStep increments the step counter and returns ErrStepLimitExceeded if limit reached. +// The error names the effective budget and the variable that raises it. +func (ctx *Context) incrementStep() error { + ctx.run.steps++ + if ctx.run.steps > ctx.maxSteps { + return ctx.stepLimitExceeded() + } + return nil +} + +// stepLimitExceeded reports the step budget spent, naming the variable that raises +// it; kept out of line so the step charge on every evaluation inlines. +// +//go:noinline +func (ctx *Context) stepLimitExceeded() error { + return fmt.Errorf("%w (%d steps; raise %s to allow more)", ErrStepLimitExceeded, ctx.maxSteps, MaxStepsEnvVar) +} + +// elementScope brackets one evaluation and returns the function releasing what +// it materialized, so the bound counts elements held at once, not in total. +func (ctx *Context) elementScope() func() { + run, held := ctx.run, ctx.run.elements + return func() { run.elements = held } +} + +// beginStep brackets one evaluation outside a body: it answers the activation the +// evaluation runs in and the function ending it, releasing what it materialized. +func (ctx *Context) beginStep() (int64, func()) { + activation := ctx.newActivation() + release := ctx.elementScope() + return activation, func() { + ctx.endActivation(activation) + release() + } +} + +// chargeElements counts elements an evaluation materializes, which unlike a step +// is memory the collection holding it keeps, against the element budget. +func (ctx *Context) chargeElements(n int64) error { + ctx.run.elements += n + // A count that overflowed is past any budget, so it reads as one. + if ctx.run.elements > ctx.maxElements || ctx.run.elements < 0 { + return fmt.Errorf("%w (%d elements; raise %s to allow more)", ErrElementLimitExceeded, ctx.maxElements, MaxElementsEnvVar) + } + return nil +} + +// Instance retrieves an instance by ID, so a caller holding a ValInstance can +// reach the object it names. +func (ctx *Context) Instance(id int64) (*Instance, bool) { + return ctx.getInstance(id) +} + +// InstanceIDs lists the identities of every object this context holds, in +// ascending order, so a caller can say which ids an unknown one is not among. +func (ctx *Context) InstanceIDs() []int64 { + ids := make([]int64, 0, len(ctx.instances)) + for id := range ctx.instances { + ids = append(ids, id) + } + slices.Sort(ids) + return ids +} + +// InstanceCount is how many objects this context holds, nested ones counted. +func (ctx *Context) InstanceCount() int { + return len(ctx.instances) +} + +// SetMaxInstances bounds the objects this context holds at once, nested ones +// counted, zero lifting it; the materialization past it is ErrInstanceLimitExceeded. +func (ctx *Context) SetMaxInstances(n int) { + ctx.maxInstances = max(n, 0) +} + +// MaxInstances is the bound SetMaxInstances set, zero when there is none. +func (ctx *Context) MaxInstances() int { + return ctx.maxInstances +} + +// instanceRoom is the refusal of one more object where the bound holds no more. +func (ctx *Context) instanceRoom() error { + if ctx.maxInstances > 0 && len(ctx.instances) >= ctx.maxInstances { + return fmt.Errorf("%w (%d objects held)", ErrInstanceLimitExceeded, ctx.maxInstances) + } + return nil +} + +// getInstance retrieves an instance by ID. +func (ctx *Context) getInstance(id int64) (*Instance, bool) { + inst, ok := ctx.instances[id] + return inst, ok +} + +// registerInstance stores an instance in the registry. +func (ctx *Context) registerInstance(inst *Instance) { + if inst.ID <= 0 { + panic(fmt.Sprintf("runtime: invalid instance ID %d (must be > 0)", inst.ID)) + } + if _, exists := ctx.instances[inst.ID]; exists { + panic(fmt.Sprintf("runtime: duplicate instance ID %d", inst.ID)) + } + ctx.instances[inst.ID] = inst + ctx.created = append(ctx.created, inst.ID) +} + +// EvaluateConstraint evaluates a constraint definition/usage naming no object: +// against the single object of this runtime carrying it, the declared defaults +// when there is none, ErrAmbiguousSubject when there are several. +// Returns (satisfied, error). If IsAssert=true, violation is an error. +// If IsAssert=false (assume), always returns (true, nil) but logs assumptions. +func (ctx *Context) EvaluateConstraint(sym *symbols.Symbol, scope *symbols.Scope) (bool, error) { + return ctx.EvaluateConstraintOn(sym, scope, nil) +} + +// RequireConstraint returns an ErrNotAConstraint usage error unless sym +// declares a constraint, so a caller can settle the kind before evaluating. +func RequireConstraint(sym *symbols.Symbol) error { + if _, ok := ast.OwnedConstraintOf(sym.Decl); ok { + return nil + } + if ast.ConstraintReferenceOf(sym.Decl) != nil { + return nil + } + switch decl := sym.Decl.(type) { + case *ast.Definition: + if decl.Kind == ast.DefConstraint { + return nil + } + case *ast.Usage: + if decl.Kind == ast.UsageConstraint { + return nil + } + } + return notOfKind(ErrNotAConstraint, sym, "constraint") +} + +// RequireRequirement returns an ErrNotARequirement usage error unless sym +// declares a requirement. +func RequireRequirement(sym *symbols.Symbol) error { + switch decl := sym.Decl.(type) { + case *ast.Definition: + if decl.Kind == ast.DefRequirement { + return nil + } + case *ast.Usage: + if decl.Kind == ast.UsageRequirement { + return nil + } + } + return notOfKind(ErrNotARequirement, sym, "requirement") +} + +// EvaluateConstraintOn evaluates a constraint against a concrete instance: a +// feature the constraint names resolves to that instance's feature value, so the same +// constraint can pass for one instance and fail for another. An instance that +// does not carry the constraint itself is searched for the nested object that +// does; a nil instance leaves the subject to EvaluateConstraint's rule. +func (ctx *Context) EvaluateConstraintOn(sym *symbols.Symbol, scope *symbols.Scope, self *Instance) (bool, error) { + result, err := ctx.CheckConstraintOn(sym, scope, self) + return result.Holds, err +} + +// CheckConstraintOn evaluates a constraint as EvaluateConstraintOn does and also +// reports the object it turned out to be about, which a caller labelling the +// verdict needs: it is not always the instance supplied. +func (ctx *Context) CheckConstraintOn(sym *symbols.Symbol, scope *symbols.Scope, self *Instance) (CheckResult, error) { + defer ctx.beginRun()() + + if err := RequireConstraint(sym); err != nil { + return CheckResult{Subject: self}, err + } + subject, err := ctx.checkSubject("constraint", sym.Name, sym, self) + if err != nil { + return CheckResult{}, err + } + + // Evaluate every condition the constraint states, inherited ones included. + conds := ctx.conditionsOf(sym, ctx.chainMembers(sym, scope)) + holds, err := ctx.evaluateConditions(conditionCheck{ + sym: sym, + kind: "constraint", + what: "assertion", + self: subject.instance, + negated: NegatedDecl(sym), + }, conds) + return ctx.checkResultOf(holds, subject), err +} + +// CheckResult is the outcome of one check: whether it holds, the object its +// conditions were evaluated against — nil when they were evaluated against the +// declaration because no object carries the checked element — and, for a nested +// subject, the object the search started from plus the features walked from it, +// one name a segment — ending in the declaration the object materializes, as an +// ambiguity names it — which are how a caller names an object holding no name of +// its own. +type CheckResult struct { + Holds bool + Subject *Instance + SubjectRoot *Instance + SubjectPath []string +} + +// checkResultOf reports a verdict about the object a check resolved to. +func (ctx *Context) checkResultOf(holds bool, subject carrier) CheckResult { + return CheckResult{ + Holds: holds, + Subject: subject.instance, + SubjectRoot: subject.root, + SubjectPath: ctx.carrierFeatures(subject), + } +} + +// memberBindings evaluates the values members bind by name — a subject or actor +// supplied by an expression (`actor operator = limit;`) — so a condition naming +// one reads it. kind and element name the checked element in messages. A non-nil +// subject is the object supplied from outside (the `by` of a satisfaction +// assertion): it binds every subject the members declare, whose own binding is +// then neither evaluated nor used. Values are held to their member's effective +// declaration (holdBound) in one transaction, so a refused binding leaves nothing +// behind. enclosing are the values bound around the element (a case run's, for its +// objective), which the binding expressions read. +func (ctx *Context) memberBindings(sym *symbols.Symbol, kind, element string, members []scopedMember, self *Instance, subject *Instance, enclosing frame) (map[string]Value, error) { + bindings := make(map[string]Value) + features := ctx.conditionFeatures(sym) + // The bindings are evaluated as one, so a calc usage two of them read answers + // from one evaluation, and the next check reads it again. + activation, endStep := ctx.beginStep() + defer endStep() + evalIn := func(memberScope *symbols.Scope) *EvalContext { + ec := NewEvalContextIn(ctx, memberScope, self) + ec.activation = activation + ec.features = features + if enclosing.vars != nil { + ec.pushFrame(enclosing) + } + ec.Push(bindings) + return ec + } + superseded := ctx.redefinedAmong(sym, members) + hold := func(member scopedMember, what string, value Value) error { + if memberSym := memberSymbol(member.scope, member.node); memberSym == nil || superseded[memberSym] { + return nil + } + return ctx.holdBound(sym, member, fmt.Sprintf("%s %s: %s", kind, element, what), value) + } + + commit, rollback := ctx.beginJournal() + for _, member := range members { + var what string + var names []string + var expr ast.Node + isSubject := false + switch rm := member.node.(type) { + case *ast.SubjectMember: + what, names, expr, isSubject = "subject", ctx.memberNames(sym, member, rm.Ident.Name, rm.Ident.ShortName), rm.BindingExpr, true + case *ast.Usage: + switch rm.Kind { + case ast.UsageSubject: + what, names, expr, isSubject = "subject", ctx.memberNames(sym, member, effectiveName(rm), rm.Ident.ShortName), rm.Value, true + case ast.UsageActor: + what, names, expr = "actor", ctx.memberNames(sym, member, effectiveName(rm), rm.Ident.ShortName), rm.Value + } + default: + continue + } + if isSubject && subject != nil { + value := Value{Kind: ValInstance, Instance: subject.ID} + if err := hold(member, what, value); err != nil { + rollback() + return nil, err + } + for _, name := range names { + bindings[name] = value + } + continue + } + if expr == nil { + // A redeclaration valuing nothing reads the value the feature it + // redefines binds, under its own names too. + if value, ok := boundUnder(bindings, names); ok { + if err := hold(member, what+" binding", value); err != nil { + rollback() + return nil, err + } + for _, name := range names { + bindings[name] = value + } + } + continue + } + value, err := evalIn(member.scope).Eval(expr) + if err != nil { + rollback() + return nil, fmt.Errorf("%s %s: %s binding evaluation failed: %w", kind, element, what, err) + } + if err := hold(member, what+" binding", value); err != nil { + rollback() + return nil, err + } + for _, name := range names { + bindings[name] = value + } + } + commit() + return bindings, nil +} + +// redefinedAmong is the set of members of owner another of members redefines: their +// declarations are superseded by the redefining member's, which holds the value. +func (ctx *Context) redefinedAmong(owner *symbols.Symbol, members []scopedMember) map[*symbols.Symbol]bool { + superseded := make(map[*symbols.Symbol]bool) + for _, member := range members { + memberSym := memberSymbol(member.scope, member.node) + if memberSym == nil { + continue + } + for _, redefined := range ctx.redefinedFeatures(memberSym, owner) { + superseded[redefined] = true + } + } + return superseded +} + +// holdBound holds val as the value of a bound member of owner: itself and the features it +// redefines, checked against their declaration folded together (see holdAs). +func (ctx *Context) holdBound(owner *symbols.Symbol, member scopedMember, what string, val Value) error { + memberSym := memberSymbol(member.scope, member.node) + if memberSym == nil { + return nil + } + features := append([]*symbols.Symbol{memberSym}, ctx.redefinedFeatures(memberSym, owner)...) + return ctx.holdAs(member.scope, what, ctx.boundMemberDecl(owner, features), val, features...) +} + +// holdAs checks val against decl's multiplicity and type, then classifies its objects by each of +// features as one transaction, as a declared feature value is held (KerML §7.3.4.1); what names the binding. +func (ctx *Context) holdAs(scope *symbols.Scope, what string, decl calcMemberDecl, val Value, features ...*symbols.Symbol) error { + if err := decl.admits(ctx, scope, what, val); err != nil { + return err + } + commit, rollback := ctx.beginJournal() + for _, feature := range features { + if err := ctx.classifyHeld(feature, val); err != nil { + rollback() + return fmt.Errorf("%s: %w", what, err) + } + } + commit() + return nil +} + +// memberNames are the names a condition may read a bound member of owner by: its +// own and those of every feature it redefines, one feature with it (KerML §7.3.4.5). +func (ctx *Context) memberNames(owner *symbols.Symbol, member scopedMember, name, shortName string) []string { + names := bindingNames(name, shortName) + memberSym := memberSymbol(member.scope, member.node) + if memberSym == nil { + return names + } + seen := make(map[string]bool, len(names)) + for _, n := range names { + seen[n] = true + } + for _, redefined := range ctx.redefinedFeatures(memberSym, owner) { + for _, n := range bindingNames(redefined.Name, redefined.ShortName) { + if !seen[n] { + seen[n] = true + names = append(names, n) + } + } + } + return names +} + +// boundUnder returns the value bindings hold under any of names. +func boundUnder(bindings map[string]Value, names []string) (Value, bool) { + for _, name := range names { + if value, ok := bindings[name]; ok { + return value, true + } + } + return Value{}, false +} + +// bindingNames are the names a condition may read a bound member by: its name +// and its short name, whichever it declares. +func bindingNames(name, shortName string) []string { + var names []string + if name != "" { + names = append(names, name) + } + if shortName != "" && shortName != name { + names = append(names, shortName) + } + return names +} + +// effectiveName is the name a usage answers to, which for a member written as a +// reference is its reference's rather than a declared one (ast.EffectiveName). +func effectiveName(u *ast.Usage) string { + name, _ := ast.EffectiveName(u) + return name +} + +// NegatedDecl reports whether sym's declaration asserts that its conditions do +// not hold (`assert not constraint { … }`, `assert not satisfy … by …`). +func NegatedDecl(sym *symbols.Symbol) bool { + usage, ok := sym.Decl.(*ast.Usage) + return ok && usage.IsNegated +} + +// scopedMember is a declaration member with the scope it was written in, since +// an inherited member's names resolve where its supertype was declared. +type scopedMember struct { + node ast.Node + scope *symbols.Scope +} + +// chainMembers returns the members of the types sym takes members from (its +// supertypes and the feature it references), most general first, then sym's own. +// The library's frame states no model conditions and contributes none; a domain +// library's supertype contributes as a model's does. +func (ctx *Context) chainMembers(sym *symbols.Symbol, scope *symbols.Scope) []scopedMember { + var out []scopedMember + supers := ctx.model.semantics.MemberSources(sym) + for i := len(supers) - 1; i >= 0; i-- { + link := supers[i] + if link == nil || ctx.frameDeclared(link) { + continue + } + for _, node := range unwrappedDeclMembers(link.Decl) { + out = append(out, scopedMember{node: node, scope: bodyScope(link, link.OwnerScope)}) + } + } + for _, node := range unwrappedDeclMembers(sym.Decl) { + out = append(out, scopedMember{node: node, scope: bodyScope(sym, scope)}) + } + return out +} + +// bodyScope is the scope a member of sym's body was written in: sym's own body, +// where its sibling declarations answer a name before the enclosing namespace +// does (KerML 8.2.3.5.4). fallback covers a declaration that owns no scope. +func bodyScope(sym *symbols.Symbol, fallback *symbols.Scope) *symbols.Scope { + if sym != nil && sym.Scope != nil { + return sym.Scope + } + return fallback +} + +// EvaluateRequirement evaluates a requirement definition/usage naming no object, +// choosing its subject as EvaluateConstraint does. +// Returns (satisfied, error). Validates subject/actor types and evaluates assume/require expressions. +// Assume members always pass (trusted), require members must evaluate to true. +func (ctx *Context) EvaluateRequirement(sym *symbols.Symbol, scope *symbols.Scope) (bool, error) { + return ctx.EvaluateRequirementOn(sym, scope, nil) +} + +// EvaluateRequirementOn evaluates a requirement against a concrete instance, +// binding the features it names to that instance's feature values. The subject is chosen +// as EvaluateConstraintOn chooses it, and the subject/actor bindings are +// evaluated against that same object. +func (ctx *Context) EvaluateRequirementOn(sym *symbols.Symbol, scope *symbols.Scope, self *Instance) (bool, error) { + result, err := ctx.CheckRequirementOn(sym, scope, self) + return result.Holds, err +} + +// CheckRequirementOn evaluates a requirement as EvaluateRequirementOn does and +// also reports the object it turned out to be about. +func (ctx *Context) CheckRequirementOn(sym *symbols.Symbol, scope *symbols.Scope, self *Instance) (CheckResult, error) { + defer ctx.beginRun()() + + if err := RequireRequirement(sym); err != nil { + return CheckResult{Subject: self}, err + } + subject, err := ctx.checkSubject("requirement", sym.Name, sym, self) + if err != nil { + return CheckResult{}, err + } + + // Requirement-local bindings are shared by every member, whichever scope it + // was declared in. + members := ctx.chainMembers(sym, scope) + + // First pass: process subject/actor bindings + reqBindings, err := ctx.memberBindings(sym, "requirement", sym.Name, members, subject.instance, nil, frame{}) + + if err != nil { + return ctx.checkResultOf(false, subject), err + } + + // Second pass: evaluate the assumed and required conditions. + conds := ctx.conditionsOf(sym, members) + holds, err := ctx.evaluateConditions(conditionCheck{ + sym: sym, + kind: "requirement", + what: "require condition", + self: subject.instance, + bindings: mapFrame(reqBindings), + negated: NegatedDecl(sym), + }, conds) + if err != nil { + err = unboundSubjectError(err, "requirement", sym.Name, ctx.unboundSubjectNames(sym, members, subject.instance)) + } + return ctx.checkResultOf(holds, subject), err +} + +// unboundSubjectNames are the subjects the members declare that nothing supplies +// a value for: no binding expression, no object supplied from outside. +func (ctx *Context) unboundSubjectNames(sym *symbols.Symbol, members []scopedMember, subject *Instance) map[string]bool { + if subject != nil { + return nil + } + names := make(map[string]bool) + for _, member := range members { + switch rm := member.node.(type) { + case *ast.SubjectMember: + if rm.BindingExpr == nil { + for _, name := range ctx.memberNames(sym, member, rm.Ident.Name, rm.Ident.ShortName) { + names[name] = true + } + } + case *ast.Usage: + if rm.Kind == ast.UsageSubject { + for _, name := range ctx.memberNames(sym, member, effectiveName(rm), rm.Ident.ShortName) { + names[name] = true + } + } + } + } + return names +} + +// unboundSubjectError reports a condition that read an unbound subject as such, +// rather than as a feature that happens to carry no value. +func unboundSubjectError(err error, kind, element string, unbound map[string]bool) error { + var noValue *NoValueError + if !errors.As(err, &noValue) || !unbound[noValue.Feature] { + return err + } + return &UnboundSubjectError{Kind: kind, Element: element, Subject: noValue.Feature} +} + +// ExecuteAction executes an action definition/usage to completion. +// Returns the values the action's features hold when it completed. +func (ctx *Context) ExecuteAction(action *symbols.Symbol) (map[string]Value, error) { + return ctx.ExecuteActionWithInputs(action, nil) +} + +// ExecuteActionWithInputs executes an action, seeding its feature space with the +// provided input parameter bindings (keyed by parameter name). Inputs override +// action attribute defaults of the same name. Returns the final feature values. +func (ctx *Context) ExecuteActionWithInputs(action *symbols.Symbol, inputs map[string]Value) (map[string]Value, error) { + return ctx.ExecuteActionPerformedBy(action, nil, inputs) +} + +// ExecuteActionPerformedBy executes an action performed by self, whose +// connections route what the action sends and whose variant selections decide +// which of them are realized. A nil self performs the action outside any object. +func (ctx *Context) ExecuteActionPerformedBy(action *symbols.Symbol, self *Instance, inputs map[string]Value) (map[string]Value, error) { + exec, err := ctx.performAction(action, self, inputs) + if err != nil { + return nil, err + } + // Return the values the action's features hold once it completed + return exec.Results(), nil +} + +// ActionOutcomePerformedBy runs an action as ExecuteActionPerformedBy does and reports +// the outcome an exploration compares: its features and, under `this.`, the performer's attributes. +func (ctx *Context) ActionOutcomePerformedBy(action *symbols.Symbol, self *Instance, inputs map[string]Value) (Outcome, error) { + exec, err := ctx.performAction(action, self, inputs) + if err != nil { + return Outcome{}, err + } + return (&Invocation{Actions: []*ActionExecutor{exec}}).Outcome(), nil +} + +// performAction runs action to completion, performed by self, and returns the +// executor that ran it, whose root performance holds what it produced. An object +// performing the action runs the performance it already runs rather than a second. +func (ctx *Context) performAction(action *symbols.Symbol, self *Instance, inputs map[string]Value) (*ActionExecutor, error) { + exec, err := performanceOf(action, self, inputs) + if err != nil { + return nil, err + } + if exec == nil { + return ctx.performActionFrom(action, action, self, inputs, (*ActionExecutor).initialize) + } + top := ctx.runDepth == 0 + defer ctx.beginRun()() + if err := ctx.runPerformance(exec, top); err != nil { + return nil, err + } + return exec, nil +} + +// startActionStep starts an action performed as a step of an enclosing behavior: +// one stating a flow is initialized, one stating none takes its inputs, binds its +// computed outputs and ends at once, as an object performing such an action does. +func startActionStep(exec *ActionExecutor) error { + if !exec.hasFlow() { + return exec.completeWithoutFlow() + } + return exec.initialize() +} + +// performActionFrom creates the executor for a performance of performed running +// action, seeds its inputs, starts it with start, and runs it to completion; the +// clock drives it no further, and no body around it pauses for its waits. +func (ctx *Context) performActionFrom(performed, action *symbols.Symbol, self *Instance, inputs map[string]Value, start func(*ActionExecutor) error) (*ActionExecutor, error) { + top := ctx.runDepth == 0 + defer ctx.beginRun()() + + exec, err := ctx.beginPerformed(performed, action, self, inputs, top, nil, start) + if err != nil { + return nil, err + } + if err := ctx.runPerformed(exec, top); err != nil { + return nil, err + } + return exec, nil +} + +// beginPerformed creates the executor for a performance of performed running +// action, seeds its inputs and starts it with start, on the clock until it is run; +// top marks the performance a top-level run begins on; listener, if any, is +// installed before the start so the outputs' declared values stream too. +func (ctx *Context) beginPerformed(performed, action *symbols.Symbol, self *Instance, inputs map[string]Value, top bool, listener *outputListener, start func(*ActionExecutor) error) (*ActionExecutor, error) { + exec, err := newActionExecutorOf(ctx, performed, action, self, nil) + if err != nil { + return nil, fmt.Errorf("create action executor: %w", err) + } + exec.beginsRun = top + if listener != nil { + exec.listen(listener.perf, listener.take) + } + if !top { + exec.driven.caller = ctx.innermostRun() + } + + // Bind inputs before initialization so they seed the initial token. + if len(inputs) > 0 { + exec.SetInputs(inputs) + } + + if err := ctx.startAction(exec, start); err != nil { + ctx.clock.detach(exec) + return nil, err + } + return exec, nil +} + +// runPerformed runs a performance beginPerformed started to completion, after +// which the clock drives it no further; a body around it pauses where it waits. +func (ctx *Context) runPerformed(exec *ActionExecutor, top bool) error { + if err := ctx.runPerformance(exec, top); err != nil { + if !paused(err) { + ctx.clock.detach(exec) + } + return err + } + ctx.clock.detach(exec) + return nil +} + +// runPerformance runs a started performance to completion, leaving it on the +// clock; a body around it pauses where it waits. +func (ctx *Context) runPerformance(exec *ActionExecutor, top bool) error { + if !exec.state.Ended() { + if err := exec.RunToCompletion(); err != nil { + if paused(err) { + return err + } + return fmt.Errorf("execute action: %w", err) + } + } + if err := ctx.settledObjects(top); err != nil { + return fmt.Errorf("execute action: %w", err) + } + if err := ctx.followedWhole(top); err != nil { + return fmt.Errorf("execute action: %w", err) + } + return nil +} + +// settledObjects runs, once a top-level run's own performance ended, the behaviors +// of the objects it left with work: one it started, or woke with a message it sent. +func (ctx *Context) settledObjects(top bool) error { + if !top { + return nil + } + return ctx.runAttachedBehaviors() +} + +// followedWhole is the refusal of a top-level run that ended with witness moves +// left over; a nested run leaves what is left to the run enclosing it. +func (ctx *Context) followedWhole(top bool) error { + if !top { + return nil + } + return ctx.Unfollowed() +} + +// startAction begins an executor however its action is performed: one a ToolExecution +// annotates, on the action as named or a type of it, is performed by its tool, which +// completes it; any other is begun by begin. Every way of starting an action passes through here. +func (ctx *Context) startAction(exec *ActionExecutor, begin func(*ActionExecutor) error) error { + if exec.tool != nil { + if err := exec.performByTool(exec.tool); err != nil { + return fmt.Errorf("perform action by tool: %w", err) + } + return nil + } + if err := begin(exec); err != nil { + return fmt.Errorf("initialize action: %w", err) + } + return nil +} + +// ExecuteState executes a state machine, processing events until completion or suspension. +// Returns final state data from the state machine's execution. +// Execution stops when: +// - A final state is reached (StateCompleted) +// - Event queue is empty (StateSuspended) +// - Max event processing steps exceeded (error) +func (ctx *Context) ExecuteState(stateMachine *symbols.Symbol) (map[string]Value, error) { + data, _, err := ctx.ExecuteStateWithEvents(stateMachine, nil) + return data, err +} + +// ExecuteStateWithEvents executes a state machine, first injecting the provided +// signal events (by signal-type name) into the event queue, then processing all +// events until completion or suspension. Returns the final state data and the +// ordered list of visited state names. +func (ctx *Context) ExecuteStateWithEvents(stateMachine *symbols.Symbol, events []string) (map[string]Value, []string, error) { + return ctx.ExecuteStatePerformedBy(stateMachine, nil, events) +} + +// ExecuteStatePerformedBy executes a state machine performed by self, whose +// connections route what the machine sends and whose variant selections decide +// which of them are realized. A nil self performs it outside any object. +func (ctx *Context) ExecuteStatePerformedBy(stateMachine *symbols.Symbol, self *Instance, events []string) (map[string]Value, []string, error) { + exec, err := ctx.performState(stateMachine, self, events) + if err != nil { + return nil, nil, err + } + // Return state machine data and the real ordered visit trace + return exec.StateData(), exec.GetStateVisits(), nil +} + +// StateOutcomeWithEvents runs a state machine as ExecuteStateWithEvents does and +// reports where it came to as the outcome an exploration compares. +func (ctx *Context) StateOutcomeWithEvents(stateMachine *symbols.Symbol, events []string) (Outcome, error) { + return ctx.StateOutcomePerformedBy(stateMachine, nil, events) +} + +// StateOutcomePerformedBy runs a state machine as ExecuteStatePerformedBy does and reports +// the outcome an exploration compares: its own and, under `this.`, the performer's attributes. +func (ctx *Context) StateOutcomePerformedBy(stateMachine *symbols.Symbol, self *Instance, events []string) (Outcome, error) { + exec, err := ctx.performState(stateMachine, self, events) + if err != nil { + return Outcome{}, err + } + return (&Invocation{States: []*StateExecutor{exec}}).Outcome(), nil +} + +// ErrAmbiguousMachine is the typed error a machine named on an object exhibiting +// it under several usages wraps. +var ErrAmbiguousMachine = errors.New("ambiguous state machine") + +// ErrAmbiguousAction is the typed error an action named on an object performing +// it under several usages wraps. +var ErrAmbiguousAction = errors.New("ambiguous action") + +// ErrPerformedInputs is the typed error inputs given for an action the object +// performs already wrap: its performance took the arguments its declaration binds. +var ErrPerformedInputs = errors.New("inputs for a performed action") + +// performanceOf is the performance self runs of action's declaration, to run in +// place of a second; nil when self performs none. +func performanceOf(action *symbols.Symbol, self *Instance, inputs map[string]Value) (*ActionExecutor, error) { + if self == nil { + return nil, nil + } + switch performed := self.PerformedActionsOf(action); len(performed) { + case 0: + return nil, nil + case 1: + if len(inputs) > 0 { + return nil, fmt.Errorf("%w: the object performs %s already, with the arguments its declaration binds", ErrPerformedInputs, symbolText(action)) + } + return performed[0].Action, nil + default: + return nil, fmt.Errorf("%w: the object performs %s as %s", ErrAmbiguousAction, symbolText(action), strings.Join(behaviorUsages(performed), " and ")) + } +} + +// behaviorUsages names the usages the behaviors are bound under, unnamed ones left out. +func behaviorUsages(behaviors []*ObjectBehavior) []string { + usages := make([]string, 0, len(behaviors)) + for _, b := range behaviors { + if member := b.Member(); member != nil && member.Name != "" { + usages = append(usages, member.Name) + } + } + return usages +} + +// exhibitedBy is the machine self exhibits under stateMachine's declaration, to +// run in place of a second performance of it; nil when self exhibits none. +func exhibitedBy(stateMachine *symbols.Symbol, self *Instance) (*StateExecutor, error) { + if self == nil { + return nil, nil + } + switch exhibited := self.ExhibitedStatesOf(stateMachine); len(exhibited) { + case 0: + return nil, nil + case 1: + return exhibited[0].State, nil + default: + return nil, fmt.Errorf("%w: the object exhibits %s as %s", ErrAmbiguousMachine, symbolText(stateMachine), strings.Join(behaviorUsages(exhibited), " and ")) + } +} + +// performState runs a state machine performed by self to completion or +// suspension, the events injected before it runs, and returns its executor. An +// object exhibiting the machine runs the one it exhibits rather than a second. +func (ctx *Context) performState(stateMachine *symbols.Symbol, self *Instance, events []string) (*StateExecutor, error) { + top := ctx.runDepth == 0 + defer ctx.beginRun()() + + exec, err := exhibitedBy(stateMachine, self) + if err != nil { + return nil, err + } + if exec == nil { + if exec, err = newStateExecutor(ctx, stateMachine, self); err != nil { + return nil, fmt.Errorf("create state executor: %w", err) + } + defer ctx.clock.detach(exec) + if err := exec.initialize(); err != nil { + return nil, fmt.Errorf("initialize state machine: %w", err) + } + } + + // Inject external signal events. Each event name is treated as a signal type + // with no arguments; matching accept-triggers consume it in order. + for _, event := range events { + exec.SendSignal(event, nil) + } + + if err := exec.RunToCompletion(); err != nil { + return nil, err + } + if err := ctx.followedWhole(top); err != nil { + return nil, err + } + return exec, nil +} + +// CreateActionExecutor creates an action executor without starting execution. +// For REPL debugging - allows step-by-step execution control. +func (ctx *Context) CreateActionExecutor(action *symbols.Symbol) (*ActionExecutor, error) { + return ctx.CreateActionExecutorFor(action, nil) +} + +// CreateActionExecutorFor creates an action executor for an action performed by +// self, without starting execution. An action a ToolExecution annotates has no flow to +// step: its tool is invoked once and the executor returned completed with its outputs. +func (ctx *Context) CreateActionExecutorFor(action *symbols.Symbol, self *Instance) (*ActionExecutor, error) { + return ctx.CreateActionExecutorWithInputs(action, self, nil) +} + +// CreateActionExecutorWithInputs creates an action executor for an action +// performed by self with its inputs bound ahead of its defaults, without +// starting execution. +func (ctx *Context) CreateActionExecutorWithInputs(action *symbols.Symbol, self *Instance, inputs map[string]Value) (*ActionExecutor, error) { + exec, err := newActionExecutor(ctx, action, self) + if err != nil { + return nil, fmt.Errorf("create action executor: %w", err) + } + exec.beginsRun = true + if len(inputs) > 0 { + exec.SetInputs(inputs) + } + + if err := ctx.startAction(exec, (*ActionExecutor).initialize); err != nil { + exec.Release() + return nil, err + } + + return exec, nil +} + +// CreateStateExecutor creates a state executor without starting execution. +// For REPL debugging - allows step-by-step execution control. +func (ctx *Context) CreateStateExecutor(stateMachine *symbols.Symbol) (*StateExecutor, error) { + return ctx.CreateStateExecutorFor(stateMachine, nil) +} + +// CreateStateExecutorFor creates a state executor for a machine performed by +// self, without starting execution. +func (ctx *Context) CreateStateExecutorFor(stateMachine *symbols.Symbol, self *Instance) (*StateExecutor, error) { + exec, err := newStateExecutor(ctx, stateMachine, self) + if err != nil { + return nil, fmt.Errorf("create state executor: %w", err) + } + + // Initialize (enters initial state, schedules initial events) + if err := exec.initialize(); err != nil { + exec.Release() + return nil, fmt.Errorf("initialize state machine: %w", err) + } + + return exec, nil +} diff --git a/internal/exec/runtime/context_test.go b/internal/exec/runtime/context_test.go new file mode 100644 index 0000000000..8e2a96e754 --- /dev/null +++ b/internal/exec/runtime/context_test.go @@ -0,0 +1,302 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +func TestContextIDAllocation(t *testing.T) { + idx := symbols.NewIndex() + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + ctx := NewContext(typedModel(model, resolver), 100000) + + id1 := ctx.allocateID() + id2 := ctx.allocateID() + + if id1 == id2 { + t.Error("expected unique IDs, got duplicates") + } + if id1 != 1 || id2 != 2 { + t.Errorf("expected sequential IDs 1,2; got %d,%d", id1, id2) + } +} + +func TestContextStepCounter(t *testing.T) { + idx := symbols.NewIndex() + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + ctx := NewContext(typedModel(model, resolver), 10) + + for i := 0; i < 10; i++ { + if err := ctx.incrementStep(); err != nil { + t.Fatalf("step %d failed: %v", i, err) + } + } + + // 11th step should error + if err := ctx.incrementStep(); err == nil { + t.Error("expected step limit error, got nil") + } +} + +func TestContext_ExecuteAction(t *testing.T) { + idx := symbols.NewIndex() + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + ctx := NewContext(typedModel(model, resolver), 100000) + + // Create simple action: initial → action(x=42) → final + initial := &ast.InitialNode{First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}} + actionNode := &ast.ActionExecutionNode{ + Name: "compute", + Expression: &ast.LiteralInteger{ + Value: "42", + }, + } + final := &ast.FinalNode{} + + edge1 := &ast.SuccessionEdge{ + Source: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "start"}}}, + Target: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "compute"}}}, + } + edge2 := &ast.SuccessionEdge{ + Source: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "compute"}}}, + Target: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "done"}}}, + } + + actionSym := &symbols.Symbol{ + Name: "TestAction", + Kind: symbols.SymbolActionUsage, + Decl: &ast.Usage{ + Kind: ast.UsageAction, + Ident: ast.Identification{Name: "TestAction"}, + Members: []ast.Node{initial, actionNode, final, edge1, edge2}, + }, + } + + // Execute + result, err := ctx.ExecuteAction(actionSym) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Verify result contains action output + val, ok := result["result"] + if !ok { + t.Fatal("expected 'result' key in output") + } + + if val.Kind != ValConst { + t.Errorf("expected ValConst, got %v", val.Kind) + } + if val.Const.Kind != semantics.ValInt { + t.Errorf("expected ValInt, got %v", val.Const.Kind) + } + if val.Const.Int != 42 { + t.Errorf("expected 42, got %d", val.Const.Int) + } +} + +func TestContext_ExecuteAction_InvalidSymbol(t *testing.T) { + idx := symbols.NewIndex() + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + ctx := NewContext(typedModel(model, resolver), 100000) + + // Pass non-action symbol + notAction := &symbols.Symbol{ + Name: "NotAction", + Kind: symbols.SymbolPartUsage, + Decl: &ast.Usage{Kind: ast.UsagePart}, + } + + _, err := ctx.ExecuteAction(notAction) + if err == nil { + t.Error("expected error for non-action symbol, got nil") + } +} + +func TestContext_ExecuteState(t *testing.T) { + idx := symbols.NewIndex() + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + ctx := NewContext(typedModel(model, resolver), 100000) + + // Create simple state machine: idle →[after 5]→ done (final) + idle := &ast.StateNode{ + Name: "idle", + } + done := &ast.StateNode{ + Name: "done", + } + + trans := &ast.TransitionEdge{ + Source: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "idle"}}}, + Target: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "done"}}}, + Trigger: &ast.TimeEvent{ + Duration: &ast.LiteralReal{Value: "5.0"}, + }, + } + + stateMachineSym := &symbols.Symbol{ + Name: "TestStateMachine", + Kind: symbols.SymbolStateUsage, + Decl: &ast.Usage{ + Kind: ast.UsageState, + Ident: ast.Identification{Name: "TestStateMachine"}, + Members: []ast.Node{entryStart("idle"), idle, done, trans}, + }, + } + + // Execute + result, err := ctx.ExecuteState(stateMachineSym) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Verify result is stateData map (may be empty for this simple machine) + if result == nil { + t.Error("expected non-nil result") + } +} + +func TestContext_ExecuteState_InvalidSymbol(t *testing.T) { + idx := symbols.NewIndex() + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + ctx := NewContext(typedModel(model, resolver), 100000) + + // Pass non-state symbol + notState := &symbols.Symbol{ + Name: "NotState", + Kind: symbols.SymbolPartUsage, + Decl: &ast.Usage{Kind: ast.UsagePart}, + } + + _, err := ctx.ExecuteState(notState) + if err == nil { + t.Error("expected error for non-state symbol, got nil") + } +} + +// Task 49: Integration test - combined action + state machine +// State machine entry action invokes an action execution +func TestContext_Integration_ActionWithinState(t *testing.T) { + idx := symbols.NewIndex() + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + ctx := NewContext(typedModel(model, resolver), 100000) + + // Create a simple action: compute = 10 + 20 + initial := &ast.InitialNode{ + First: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "initial"}}}, + } + compute := &ast.ActionExecutionNode{ + Name: "compute", + Expression: &ast.OperatorExpr{ + Operator: ast.OpAdd, + Operands: []ast.Node{ + &ast.LiteralInteger{Value: "10"}, + &ast.LiteralInteger{Value: "20"}, + }, + }, + } + final := &ast.FinalNode{} + edge1 := &ast.ControlFlowEdge{ + Source: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "initial"}}}, + Target: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "compute"}}}, + } + edge2 := &ast.ControlFlowEdge{ + Source: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "compute"}}}, + Target: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "done"}}}, + } + + actionSym := &symbols.Symbol{ + Name: "ComputeAction", + Kind: symbols.SymbolActionUsage, + Decl: &ast.Usage{ + Kind: ast.UsageAction, + Ident: ast.Identification{Name: "ComputeAction"}, + Members: []ast.Node{initial, compute, final, edge1, edge2}, + }, + } + + // Execute action standalone first + actionResult, err := ctx.ExecuteAction(actionSym) + if err != nil { + t.Fatalf("action execution failed: %v", err) + } + + // Verify action result + resultVal, ok := actionResult["result"] + if !ok { + t.Fatal("expected 'result' in action output") + } + if resultVal.Kind != ValConst { + t.Fatalf("expected const value, got %v", resultVal.Kind) + } + if resultVal.Const.Kind != semantics.ValInt || resultVal.Const.Int != 30 { + t.Errorf("expected result=30, got %v", resultVal.Const) + } + + // Create state machine with entry action + // State 'processing' executes the action on entry + processing := &ast.StateNode{ + Name: "processing", + Entry: []ast.Node{ + &ast.ActionExecutionNode{ + Name: "entryAction", + Expression: &ast.OperatorExpr{ + Operator: ast.OpMul, + Operands: []ast.Node{ + &ast.LiteralInteger{Value: "3"}, + &ast.LiteralInteger{Value: "7"}, + }, + }, + }, + }, + } + done := &ast.StateNode{ + Name: "done", + } + trans := &ast.TransitionEdge{ + Source: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "processing"}}}, + Target: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "done"}}}, + Trigger: &ast.TimeEvent{ + Duration: &ast.LiteralReal{Value: "1.0"}, + }, + } + + stateSym := &symbols.Symbol{ + Name: "ProcessingStateMachine", + Kind: symbols.SymbolStateUsage, + Decl: &ast.Usage{ + Kind: ast.UsageState, + Ident: ast.Identification{Name: "ProcessingStateMachine"}, + Members: []ast.Node{entryStart("processing"), processing, done, trans}, + }, + } + + // Execute state machine + stateResult, err := ctx.ExecuteState(stateSym) + if err != nil { + t.Fatalf("state machine execution failed: %v", err) + } + + // Verify entry action executed (stored in stateData) + entryVal, ok := stateResult["entryAction"] + if !ok { + t.Fatal("expected 'entryAction' in stateData") + } + if entryVal.Kind != ValConst { + t.Fatalf("expected const value, got %v", entryVal.Kind) + } + if entryVal.Const.Kind != semantics.ValInt || entryVal.Const.Int != 21 { + t.Errorf("expected entryAction=21 (3*7), got %v", entryVal.Const) + } +} diff --git a/internal/exec/runtime/coordinate_frame.go b/internal/exec/runtime/coordinate_frame.go new file mode 100644 index 0000000000..411e5237d9 --- /dev/null +++ b/internal/exec/runtime/coordinate_frame.go @@ -0,0 +1,396 @@ +package runtime + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// CoordinateFrame is a VectorMeasurementReference held as a value: the axes of a +// coordinate frame, or the one axis a measurement scale is, with the placement +// relating it to another reference where the model states one. +type CoordinateFrame struct { + Decl *symbols.Symbol // the usage declaring the frame; nil for one composed by arithmetic + Type *symbols.Symbol // the type the frame is a value of + Dimensions []int64 // `dimensions`: (3) for a 3-D frame, () for a scale + Axes []Unit // `mRefs`, one per element the dimensions shape + Transformation *CoordinateTransformation // `transformation`, whose target is this frame; nil for none + Scale *MeasurementScale // what a MeasurementScale adds; nil for a frame + Object int64 // the object the frame was read from; 0 for one composed + Text string // the frame as written: `spatialCF`, `spatialCF / s` +} + +// MeasurementScale is what a scale states beyond its one axis: the unit a +// magnitude on it is in, and the point mapping it onto another reference. +type MeasurementScale struct { + Unit Unit + Mapping *QuantityValueMapping +} + +// QuantityValueMapping is one point given on the scale and on the reference it +// is defined against, as `quantityValueMapping` states it. +type QuantityValueMapping struct { + Mapped Quantity // the point on the scale, in the scale's unit + Reference Quantity // the same point on the reference +} + +// CoordinateTransformation is a CoordinateTransformation held as a value: the +// placement of the target in the source by at most one of Placement, Sequence +// and Affine; a subtype of no library shape holds none and cannot be applied. +type CoordinateTransformation struct { + Decl *symbols.Symbol // the usage declaring the transformation; nil for a constructed one + Type *symbols.Symbol // the type the transformation is a value of + Source *CoordinateFrame // `source`; nil when the model states none + Target *CoordinateFrame // `target`; nil for a frame's own transformation, whose target it is + Placement *FramePlacement + Sequence []FrameStep + Affine *AffineTransformation3d + Object int64 +} + +// FramePlacement is a CoordinateFramePlacement: the target frame's origin as a +// vector in the source frame, and its basis directions there; none for the +// source's own orientation. +type FramePlacement struct { + Origin Value // a vector quantity, or a scalar quantity for a one-dimensional frame + BasisDirections []Value // vector quantities, one per target axis +} + +// FrameStep is one TranslationOrRotation of a TranslationRotationSequence. +type FrameStep struct { + Translation *Value // Translation::translationVector, a vector quantity + Axis *Value // Rotation::axisDirection, a vector quantity + Angle *Quantity // Rotation::angle, an angular measure + Intrinsic bool // Rotation::isIntrinsic + Object int64 +} + +// AffineTransformation3d is an AffineTransformationMatrix3d: the 3x3 rotation +// in row-major order and the translation, both bare numbers. +type AffineTransformation3d struct { + Rotation [9]float64 + Translation [3]float64 +} + +// NewCoordinateFrameValue wraps a frame as a value. +func NewCoordinateFrameValue(frame *CoordinateFrame) Value { + return Value{Kind: ValCoordinateFrame, ref: frame} +} + +// NewCoordinateTransformationValue wraps a transformation as a value. +func NewCoordinateTransformationValue(t *CoordinateTransformation) Value { + return Value{Kind: ValCoordinateTransformation, ref: t} +} + +// IsScale reports whether the frame is a measurement scale. +func (f *CoordinateFrame) IsScale() bool { return f != nil && f.Scale != nil } + +// FlattenedSize is the number of axes the dimensions shape: their product, 1 for +// a scalar reference's empty dimensions; false when it does not fit an Integer. +func (f *CoordinateFrame) FlattenedSize() (int64, bool) { + return flattenedSize(f.Dimensions) +} + +// flattenedSizeError is the typed refusal of dimensions whose product overflows. +func (f *CoordinateFrame) flattenedSizeError(what string) error { + return fmt.Errorf("%w: %s: flattenedSize of dimensions %s exceeds the Integer range", + semantics.ErrArithmeticOverflow, what, FormatValue(intSequence(f.Dimensions))) +} + +// Name is how the frame is referred to in diagnostics: its declared name, else +// how it was composed. +func (f *CoordinateFrame) Name() string { + if f == nil { + return unknownText + } + if f.Text != "" { + return f.Text + } + if f.Decl != nil { + return symbolText(f.Decl) + } + return "a coordinate frame" +} + +// String renders the frame as its name over its axes: `spatialCF [m, m, m]`; a +// scale over the unit its magnitudes are in: `°C_abs [°C]`. +func (f *CoordinateFrame) String() string { + if f == nil { + return unknownText + } + if f.Scale != nil { + return f.Name() + " [" + f.Scale.Unit.String() + "]" + } + axes := make([]string, len(f.Axes)) + for i, axis := range f.Axes { + axes[i] = (&MeasurementRef{Unit: axis}).String() + } + return f.Name() + " [" + strings.Join(axes, ", ") + "]" +} + +// axisRefs are the axes as measurement reference values, what `mRefs` answers. +func (f *CoordinateFrame) axisRefs() []Value { + out := make([]Value, len(f.Axes)) + for i, axis := range f.Axes { + out[i] = measurementRefOf(axis) + } + return out +} + +// sameAxes holds when the two frames' axes are equal reference by reference. +func (f *CoordinateFrame) sameAxes(other *CoordinateFrame) bool { + if len(f.Axes) != len(other.Axes) { + return false + } + for i := range f.Axes { + if !(&MeasurementRef{Unit: f.Axes[i]}).equal(&MeasurementRef{Unit: other.Axes[i]}) { + return false + } + } + return true +} + +// equal holds for one identity: a declared frame is the object it was read from, +// a frame composed by `CoordinateFrame*` or `/` is its dimensions, axes, scale and +// transformation, whatever declaration it was later bound to. +func (f *CoordinateFrame) equal(other *CoordinateFrame) bool { + if f == nil || other == nil { + return f == other + } + if f.Object != 0 || other.Object != 0 { + return f.Object == other.Object + } + if !equalInt64s(f.Dimensions, other.Dimensions) || !f.sameAxes(other) { + return false + } + if (f.Scale == nil) != (other.Scale == nil) { + return false + } + if f.Scale != nil && !f.Scale.equal(other.Scale) { + return false + } + return f.Transformation.equal(other.Transformation) +} + +// key identifies the frame the way equal compares it. +func (f *CoordinateFrame) key() string { + if f == nil { + return "" + } + if f.Object != 0 { + return "obj:" + strconv.FormatInt(f.Object, 10) + } + var b strings.Builder + b.WriteString("dims:") + for _, d := range f.Dimensions { + b.WriteString(strconv.FormatInt(d, 10) + ",") + } + b.WriteString("|axes:") + for _, axis := range f.Axes { + b.WriteString((&MeasurementRef{Unit: axis}).key() + ",") + } + if f.Scale != nil { + b.WriteString("|scale:" + (&MeasurementRef{Unit: f.Scale.Unit}).key()) + if f.Scale.Mapping != nil { + b.WriteString("|map:" + strconv.FormatUint(valueHash(NewQuantityValue(&f.Scale.Mapping.Mapped)), 10)) + b.WriteString("," + strconv.FormatUint(valueHash(NewQuantityValue(&f.Scale.Mapping.Reference)), 10)) + } + } + if f.Transformation != nil { + b.WriteString("|xf:" + f.Transformation.key()) + } + return b.String() +} + +// equal holds for one unit and one mapping (or none on both sides). +func (s *MeasurementScale) equal(other *MeasurementScale) bool { + if !(&MeasurementRef{Unit: s.Unit}).equal(&MeasurementRef{Unit: other.Unit}) { + return false + } + if (s.Mapping == nil) != (other.Mapping == nil) { + return false + } + if s.Mapping == nil { + return true + } + return valueEqual(NewQuantityValue(&s.Mapping.Mapped), NewQuantityValue(&other.Mapping.Mapped)) && + valueEqual(NewQuantityValue(&s.Mapping.Reference), NewQuantityValue(&other.Mapping.Reference)) +} + +// Name is how the transformation is referred to in diagnostics. +func (t *CoordinateTransformation) Name() string { + if t == nil { + return unknownText + } + if t.Decl != nil { + return symbolText(t.Decl) + } + if t.Type != nil { + return "a " + symbolText(t.Type) + } + return "a coordinate transformation" +} + +// sameDimensions is CoordinateTransformation's validSourceTargetDimensions: source +// and target state the same dimensions, so a vector over one is shaped for the other. +func (t *CoordinateTransformation) sameDimensions(name string) error { + if equalInt64s(t.Source.Dimensions, t.Target.Dimensions) && len(t.Source.Axes) == len(t.Target.Axes) { + return nil + } + return fmt.Errorf("%w: %s: %s relates %s of dimensions %s (%d axes) to %s of dimensions %s (%d axes); CoordinateTransformation asserts source.dimensions == target.dimensions", + ErrMultiplicityViolation, name, t.Name(), + t.Source.Name(), FormatValue(intSequence(t.Source.Dimensions)), len(t.Source.Axes), + t.Target.Name(), FormatValue(intSequence(t.Target.Dimensions)), len(t.Target.Axes)) +} + +// String renders the transformation as its name over the frames it relates: +// `trs (datum → lbcf)`. +func (t *CoordinateTransformation) String() string { + if t == nil { + return unknownText + } + source, target := "?", "?" + if t.Source != nil { + source = t.Source.Name() + } + if t.Target != nil { + target = t.Target.Name() + } + return t.Name() + " (" + source + " → " + target + ")" +} + +// shapeName names the library shape the transformation has, for diagnostics. +func (t *CoordinateTransformation) shapeName() string { + switch { + case t.Placement != nil: + return "CoordinateFramePlacement" + case t.Sequence != nil: + return "TranslationRotationSequence" + case t.Affine != nil: + return "AffineTransformationMatrix3d" + } + return "an unrecognized CoordinateTransformation" +} + +// equal holds for equal source, target, shape and content (`lbcf.transformation == trs`); +// a transformation of no recognized shape has no content, so it equals only its own object. +func (t *CoordinateTransformation) equal(other *CoordinateTransformation) bool { + if t == nil || other == nil { + return t == other + } + if !t.Source.equal(other.Source) || !t.Target.equal(other.Target) { + return false + } + if (t.Placement == nil) != (other.Placement == nil) || (t.Sequence == nil) != (other.Sequence == nil) || + (t.Affine == nil) != (other.Affine == nil) { + return false + } + switch { + case t.Placement != nil: + if !valueEqual(t.Placement.Origin, other.Placement.Origin) || + len(t.Placement.BasisDirections) != len(other.Placement.BasisDirections) { + return false + } + for i := range t.Placement.BasisDirections { + if !valueEqual(t.Placement.BasisDirections[i], other.Placement.BasisDirections[i]) { + return false + } + } + case t.Sequence != nil: + if len(t.Sequence) != len(other.Sequence) { + return false + } + for i := range t.Sequence { + if !t.Sequence[i].equal(other.Sequence[i]) { + return false + } + } + case t.Affine != nil: + return *t.Affine == *other.Affine + default: + return t.Object == other.Object + } + return true +} + +// equal holds for one step kind with equal parts. +func (s FrameStep) equal(other FrameStep) bool { + if (s.Translation == nil) != (other.Translation == nil) || (s.Axis == nil) != (other.Axis == nil) { + return false + } + if s.Translation != nil { + return valueEqual(*s.Translation, *other.Translation) + } + return s.Intrinsic == other.Intrinsic && valueEqual(*s.Axis, *other.Axis) && + valueEqual(NewQuantityValue(s.Angle), NewQuantityValue(other.Angle)) +} + +// key identifies the transformation the way equal compares it. +func (t *CoordinateTransformation) key() string { + if t == nil { + return "" + } + var b strings.Builder + b.WriteString(t.shapeName() + "|src:" + t.Source.key() + "|tgt:" + t.Target.key()) + switch { + case t.Placement != nil: + b.WriteString("|o:" + strconv.FormatUint(valueHash(t.Placement.Origin), 10)) + for _, dir := range t.Placement.BasisDirections { + b.WriteString("|b:" + strconv.FormatUint(valueHash(dir), 10)) + } + case t.Sequence != nil: + for _, step := range t.Sequence { + if step.Translation != nil { + b.WriteString("|t:" + strconv.FormatUint(valueHash(*step.Translation), 10)) + continue + } + b.WriteString("|r:" + strconv.FormatUint(valueHash(*step.Axis), 10) + + "," + strconv.FormatBool(step.Intrinsic) + + "," + strconv.FormatUint(valueHash(NewQuantityValue(step.Angle)), 10)) + } + case t.Affine != nil: + b.WriteString(fmt.Sprintf("|m:%v%v", t.Affine.Rotation, t.Affine.Translation)) + default: + b.WriteString("|obj:" + strconv.FormatInt(t.Object, 10)) + } + return b.String() +} + +// scalarFrame is the one-dimensional frame a scalar measurement reference is, +// what a placement's `source = K` names. +func scalarFrame(ref *MeasurementRef) *CoordinateFrame { + return &CoordinateFrame{ + Decl: ref.Declaration(), + Type: nil, + Axes: []Unit{ref.Unit}, + Text: ref.String(), + } +} + +// frameOfReference is the frame a value names as a transformation's source or +// target: a frame itself, or the one-dimensional frame a scalar reference is. +func frameOfReference(what string, val Value) (*CoordinateFrame, error) { + switch val.Kind { + case ValCoordinateFrame: + return val.CoordinateFrame(), nil + case ValMeasurementRef: + return scalarFrame(val.MeasurementRef()), nil + } + return nil, fmt.Errorf("%w: %s is %s, want a coordinate frame or a measurement reference", + ErrTypeMismatch, what, describeValue(val)) +} + +// scaleAnchoredUnit is the unit a quantity `x [S]` on scale S carries: named by +// the scale's declaration, reducing to the scale itself so that it is +// commensurable with nothing but another point on the scale. +func scaleAnchoredUnit(decl *symbols.Symbol) Unit { + name := unitSymbolName(decl) + return Unit{ + Text: name, + Product: semantics.NamedUnitProduct(decl, name, false), + Term: semantics.UnitTerm{Scale: semantics.UnitScale(1), Factors: []semantics.UnitFactor{{Unit: decl, Exponent: 1}}}, + } +} diff --git a/internal/core/runtime/coordinate_frame_test.go b/internal/exec/runtime/coordinate_frame_test.go similarity index 99% rename from internal/core/runtime/coordinate_frame_test.go rename to internal/exec/runtime/coordinate_frame_test.go index ba5413967b..f7dc6c5be0 100644 --- a/internal/core/runtime/coordinate_frame_test.go +++ b/internal/exec/runtime/coordinate_frame_test.go @@ -3,7 +3,7 @@ package runtime import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) const frameKindSrc = ` diff --git a/internal/core/runtime/cross_region_transition_test.go b/internal/exec/runtime/cross_region_transition_test.go similarity index 100% rename from internal/core/runtime/cross_region_transition_test.go rename to internal/exec/runtime/cross_region_transition_test.go diff --git a/internal/exec/runtime/debug_api_test.go b/internal/exec/runtime/debug_api_test.go new file mode 100644 index 0000000000..456b6baffc --- /dev/null +++ b/internal/exec/runtime/debug_api_test.go @@ -0,0 +1,2094 @@ +package runtime + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +const debugActionSrc = `package test { + action tally { + attribute total = 0; + + first start; + + action accumulate { + assign total := total + 5; + } + + done; + + succession first start then accumulate; + succession first accumulate then done; + } +}` + +const debugStateSrc = `package test { + state Cycle { + entry; then init; + state init; + state waiting; + accept after 10 then working; + state working; + accept after 5 then done; + + succession first init then waiting; + } +}` + +// debugActionExecutor builds an initialized executor for the tally action. +func debugActionExecutor(t *testing.T) *ActionExecutor { + t.Helper() + ctx, sym := loadAction(t, debugActionSrc, "tally") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + return exec +} + +// The accessors a debugger reads between steps report the executor's state. +func TestActionExecutorDebugAccessors(t *testing.T) { + exec := debugActionExecutor(t) + + if got := exec.ActionSymbol(); got == nil || got.Name != "tally" { + t.Fatalf("ActionSymbol() = %v, want tally", got) + } + if got := exec.State(); got != StateRunning { + t.Errorf("State() = %v, want %v", got, StateRunning) + } + + tokens := exec.Tokens() + if len(tokens) != 1 { + t.Fatalf("Tokens() = %d tokens, want 1", len(tokens)) + } + if name := ActionNodeName(tokens[0].Location); name != "start" { + t.Errorf("token sits at %q, want start", name) + } + + // Tokens is a copy: mutating it must not disturb the executor. + tokens[0].ID = -1 + if exec.Tokens()[0].ID == -1 { + t.Error("Tokens() exposed the executor's own slice") + } + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } +} + +func TestActionExecutorNodeNames(t *testing.T) { + names := strings.Join(debugActionExecutor(t).NodeNames(), ",") + for _, want := range []string{"start", "accumulate", "done"} { + if !strings.Contains(names, want) { + t.Errorf("NodeNames() = %s, want it to contain %q", names, want) + } + } +} + +// A breakpoint stops a run when a token reaches the node, with the tokens left +// in place so the run can resume. +func TestSetBreakpointStopsRun(t *testing.T) { + exec := debugActionExecutor(t) + exec.SetBreakpoint("accumulate") + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + + if got := exec.PausedAt(); got != "accumulate" { + t.Fatalf("PausedAt() = %q, want accumulate", got) + } + if got := exec.State(); got != StateSuspended { + t.Errorf("State() = %v, want %v", got, StateSuspended) + } + tokens := exec.Tokens() + if len(tokens) != 1 || ActionNodeName(tokens[0].Location) != "accumulate" { + t.Fatalf("expected one token at accumulate, got %v", tokens) + } + + // Resuming past the breakpoint completes the action. + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("resume: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } + if got := exec.PausedAt(); got != "" { + t.Errorf("PausedAt() = %q after completing, want empty", got) + } + if total, ok := exec.Results()["total"]; !ok || total.Const.Int != 5 { + t.Errorf("results = %v, want total 5", exec.Results()) + } +} + +// A step stating a short name and a redefinition answers to its short name +// alone: a declared short name is a name, so the step takes none from what it +// redefines (KerML 7.3.4.5), and a breakpoint on that name never fires. +func TestBreakpointNamesAShortNamedStepByItsShortName(t *testing.T) { + const src = `package test { + action def Base { action accumulate; } + action tally : Base { + attribute total = 0; + first start; + action :>> accumulate { + assign total := total + 5; + } + done; + succession first start then acc; + succession first acc then done; + } +}` + + for breakpoint, want := range map[string]string{"acc": "acc", "accumulate": ""} { + ctx, sym := loadAction(t, src, "tally") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + exec.SetBreakpoint(breakpoint) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.PausedAt(); got != want { + t.Errorf("breakpoint %s: PausedAt() = %q, want %q", breakpoint, got, want) + } + } +} + +func TestClearBreakpointsResumesUnconditionally(t *testing.T) { + exec := debugActionExecutor(t) + exec.SetBreakpoint("accumulate") + exec.ClearBreakpoints() + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.PausedAt(); got != "" { + t.Errorf("PausedAt() = %q, want empty after clearing breakpoints", got) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } +} + +// A breakpoint on a node no token reaches leaves the run unaffected. +func TestBreakpointOnUnreachedNode(t *testing.T) { + exec := debugActionExecutor(t) + exec.SetBreakpoint("nowhere") + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } +} + +// Stepping resumes a run a breakpoint suspended. +func TestStepResumesFromBreakpoint(t *testing.T) { + exec := debugActionExecutor(t) + exec.SetBreakpoint("accumulate") + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + + if err := exec.Step(); err != nil { + t.Fatalf("Step: %v", err) + } + if exec.State() == StateSuspended { + t.Error("Step() left the executor suspended") + } +} + +// A step landing a token on a breakpoint suspends the run there, as a run to +// completion would; the step after resumes past it, and a breakpoint set on +// the node a token already sits on stops the next step before it moves. +func TestStepToBreakpointPausesWhereARunWould(t *testing.T) { + exec := debugActionExecutor(t) + exec.SetBreakpoint("accumulate") + + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("StepToBreakpoint: %v", err) + } + if got := exec.PausedAt(); got != "accumulate" { + t.Fatalf("PausedAt() after the step onto it = %q, want accumulate", got) + } + if got := exec.State(); got != StateSuspended { + t.Errorf("State() = %v, want %v", got, StateSuspended) + } + if total := exec.Results()["total"]; total.Const.Int != 0 { + t.Errorf("total = %v before accumulate performs, want 0", total) + } + + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("resuming StepToBreakpoint: %v", err) + } + if got := exec.PausedAt(); got != "" { + t.Errorf("PausedAt() after resuming = %q, want empty", got) + } + if total := exec.Results()["total"]; total.Const.Int != 5 { + t.Errorf("total = %v after accumulate performed, want 5", total) + } + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("final StepToBreakpoint: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } + + // Set on the node the token sits on, the breakpoint holds the next step. + exec = debugActionExecutor(t) + exec.SetBreakpoint("start") + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("StepToBreakpoint at start: %v", err) + } + if got := exec.PausedAt(); got != "start" { + t.Errorf("PausedAt() = %q, want start", got) + } + if tokens := exec.Tokens(); len(tokens) != 1 || ActionNodeName(tokens[0].Location) != "start" { + t.Errorf("tokens = %v, want the one still at start", tokens) + } + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("resuming from start: %v", err) + } + if tokens := exec.Tokens(); len(tokens) != 1 || ActionNodeName(tokens[0].Location) != "accumulate" { + t.Errorf("tokens = %v, want the one moved on to accumulate", tokens) + } +} + +// actionNodeNamed is the node of the executor's own flow with the given name. +func actionNodeNamed(t *testing.T, exec *ActionExecutor, name string) ast.Node { + t.Helper() + for _, node := range exec.Graph().Nodes { + if ActionNodeName(node) == name { + return node + } + } + t.Fatalf("no node named %s among %v", name, exec.NodeNames()) + return nil +} + +// Replacing the breakpoints set by identity keeps a stop already made at one kept, +// so the next step resumes past it; one removed stops the run again once re-set. +func TestReplaceBreakpointsAtKeepsAStopAlreadyMade(t *testing.T) { + exec := debugActionExecutor(t) + at := []NodeBreakpoint{{Node: actionNodeNamed(t, exec, "accumulate")}} + exec.ReplaceBreakpointsAt(at) + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("StepToBreakpoint: %v", err) + } + if got := exec.PausedAt(); got != "accumulate" { + t.Fatalf("PausedAt() = %q, want accumulate", got) + } + + exec.ReplaceBreakpointsAt(at) + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("StepToBreakpoint after setting the same breakpoints again: %v", err) + } + if got := exec.PausedAt(); got != "" { + t.Errorf("PausedAt() = %q after setting the same breakpoints again, want the run resumed", got) + } + if total := exec.Results()["total"]; total.Const.Int != 5 { + t.Errorf("total = %v after the resumed step, want accumulate performed (5)", total) + } + + exec = debugActionExecutor(t) + at = []NodeBreakpoint{{Node: actionNodeNamed(t, exec, "accumulate")}} + exec.ReplaceBreakpointsAt(at) + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("StepToBreakpoint: %v", err) + } + exec.ReplaceBreakpointsAt(nil) + exec.ReplaceBreakpointsAt(at) + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("StepToBreakpoint after re-setting the breakpoint: %v", err) + } + if got := exec.PausedAt(); got != "accumulate" { + t.Errorf("PausedAt() = %q after re-setting the breakpoint, want accumulate stopped at again", got) + } + if total := exec.Results()["total"]; total.Const.Int != 0 { + t.Errorf("total = %v while stopped again, want 0", total) + } +} + +// A stop made at a node a body performs follows the same rule: kept, the resumed +// body passes it; removed and re-set while it stands, the body stops there again. +func TestReplaceBreakpointsAtResetsABodyStopRemoved(t *testing.T) { + stopAtQ := func(t *testing.T) (*ActionExecutor, []NodeBreakpoint) { + t.Helper() + exec := blockDebugExecutor(t) + choose := actionNodeNamed(t, exec, "choose") + var q ast.Node + for _, node := range exec.Graph().BlockNodes[choose] { + if ActionNodeName(node) == "q" { + q = node + } + } + if q == nil { + t.Fatalf("choose's blocks declare no q: %v", exec.Graph().BlockNodes[choose]) + } + at := []NodeBreakpoint{{Within: []ast.Node{choose}, Node: q}} + exec.ReplaceBreakpointsAt(at) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.PausedAt(); got != "q" { + t.Fatalf("PausedAt() = %q, want q", got) + } + return exec, at + } + + exec, at := stopAtQ(t) + exec.ReplaceBreakpointsAt(at) + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("StepToBreakpoint after setting the same breakpoints again: %v", err) + } + if got := exec.PausedAt(); got != "" { + t.Errorf("PausedAt() = %q after setting the same breakpoints again, want the body resumed past q", got) + } + if _, ok := exec.Results()["choose.q.n"]; !ok { + t.Errorf("results = %v, want q performed by the resumed step", exec.Results()) + } + + exec, at = stopAtQ(t) + exec.ReplaceBreakpointsAt(nil) + exec.ReplaceBreakpointsAt(at) + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("StepToBreakpoint after re-setting the breakpoint: %v", err) + } + if got := exec.PausedAt(); got != "q" { + t.Errorf("PausedAt() = %q after re-setting the breakpoint, want q stopped at again", got) + } + if _, ok := exec.Results()["choose.q.n"]; ok { + t.Errorf("results = %v while stopped again, q must not have performed", exec.Results()) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("resume: %v", err) + } + if total := exec.Results()["total"]; total.Const.Int != 13 { + t.Errorf("total = %v after resuming, want 13", total) + } + + exec, _ = stopAtQ(t) + exec.ClearBreakpoints() + exec.SetBreakpoint("q") + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("StepToBreakpoint after clearing and naming the breakpoint again: %v", err) + } + if got := exec.PausedAt(); got != "q" { + t.Errorf("PausedAt() = %q after clearing and naming the breakpoint again, want q stopped at again", got) + } +} + +// Resume returns a run a breakpoint suspended to the clock, which then runs it past +// the breakpoint; a run in any other state is left alone. +func TestResumeReturnsAPausedRunToTheClock(t *testing.T) { + ctx, sym := loadAction(t, debugActionSrc, "tally") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if exec.Resume() { + t.Error("Resume() = true for a run no breakpoint suspended") + } + exec.ReplaceBreakpointsAt([]NodeBreakpoint{{Node: actionNodeNamed(t, exec, "accumulate")}}) + if err := exec.StepToBreakpoint(); err != nil { + t.Fatalf("StepToBreakpoint: %v", err) + } + if _, err := ctx.Advance(0); err != nil { + t.Fatalf("Advance while suspended: %v", err) + } + if got := exec.State(); got != StateSuspended { + t.Fatalf("State() = %v after an advance while suspended, want the run still %v", got, StateSuspended) + } + + if !exec.Resume() { + t.Fatal("Resume() = false for a run a breakpoint suspended") + } + if got, paused := exec.State(), exec.PausedAt(); got != StateRunning || paused != "" { + t.Fatalf("State(), PausedAt() = %v, %q after Resume, want %v and none", got, paused, StateRunning) + } + if _, err := ctx.Advance(0); err != nil { + t.Fatalf("Advance after Resume: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v after the advance, want %v", got, StateCompleted) + } + if total := exec.Results()["total"]; total.Const.Int != 5 { + t.Errorf("total = %v after the advance, want 5", total) + } +} + +// reusedFlowSrc runs one inherited action declaration in two nested flows. +const reusedFlowSrc = `package test { + action def Check { + action look; + } + action twice { + first start; + action a : Check { first begin; then look; } + action b : Check { first begin; then look; } + succession first start then a; + succession first a then b; + } +}` + +// A breakpoint set by identity is on one occurrence of a node: the one in the flow +// of the nested node named, not the same declaration another nested flow runs. +func TestBreakpointsAtDistinguishReusedNestedFlows(t *testing.T) { + ctx, sym := loadAction(t, reusedFlowSrc, "twice") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + a, b := actionNodeNamed(t, exec, "a"), actionNodeNamed(t, exec, "b") + sub := exec.Graph().Subflows[a] + if sub == nil || sub.Graph == nil || exec.Graph().Subflows[b] == nil || exec.Graph().Subflows[b].Graph == nil { + t.Fatalf("a and b own no flows: %v", exec.Graph().Subflows) + } + var look ast.Node + for _, node := range sub.Graph.Nodes { + if ActionNodeName(node) == "look" { + look = node + } + } + if look == nil || !slices.Contains(exec.Graph().Subflows[b].Graph.Nodes, look) { + t.Fatalf("look is not one node both flows run: %v", look) + } + + exec.ReplaceBreakpointsAt([]NodeBreakpoint{{Within: []ast.Node{b}, Node: look}}) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.PausedAt(); got != "look" { + t.Fatalf("PausedAt() = %q, want look", got) + } + tokens := exec.Tokens() + if len(tokens) != 1 || tokens[0].Location != look || !slices.Equal(tokens[0].Within(), []ast.Node{b}) { + t.Fatalf("tokens = %v, want the one at look within b", tokens) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("resuming RunToCompletion: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v after resuming, want %v", got, StateCompleted) + } +} + +// blockDebugSrc declares action nodes inside an `if` branch and a loop body. +const blockDebugSrc = `package test { + private import ScalarValues::*; + action outer { + attribute total : Integer = 0; + first start; + then action choose { + if total == 0 { + action p { out v : Integer = 7; } + action q { in n : Integer = p.v; assign total := total + n; } + } + } + then action iterate { + for i in 1..3 { + action add { in n : Integer = i; assign total := total + n; } + } + } + then done; + } +}` + +// blockDebugExecutor builds an initialized executor for the outer action. +func blockDebugExecutor(t *testing.T) *ActionExecutor { + t.Helper() + ctx, sym := loadAction(t, blockDebugSrc, "outer") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + return exec +} + +// A node a block declares is one a breakpoint can name. +func TestNodeNamesIncludeBlockFlowNodes(t *testing.T) { + names := strings.Join(blockDebugExecutor(t).NodeNames(), ",") + for _, want := range []string{"choose", "p", "q", "iterate", "add"} { + if !strings.Contains(","+names+",", ","+want+",") { + t.Errorf("NodeNames() = %s, want it to contain %q", names, want) + } + } +} + +// A breakpoint on a node an `if` branch declares pauses the run before that node +// performs, with the branch's token left at the node running the block. +func TestBreakpointPausesBeforeABranchNode(t *testing.T) { + exec := blockDebugExecutor(t) + exec.SetBreakpoint("q") + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.PausedAt(); got != "q" { + t.Fatalf("PausedAt() = %q, want q", got) + } + if got := exec.State(); got != StateSuspended { + t.Errorf("State() = %v, want %v", got, StateSuspended) + } + if tokens := exec.Tokens(); len(tokens) != 1 || ActionNodeName(tokens[0].Location) != "choose" { + t.Fatalf("expected one token at choose, got %v", tokens) + } + results := exec.Results() + if v, ok := results["choose.p.v"]; !ok || v.Const.Int != 7 { + t.Errorf("results = %v, want choose.p.v 7 (p performed before the pause)", results) + } + if _, ok := results["choose.q.n"]; ok { + t.Errorf("results = %v, q must not have performed yet", results) + } + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("resume: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } + if total := exec.Results()["total"]; total.Const.Int != 13 { + t.Errorf("total = %v, want 13", total) + } +} + +// The breakpoint paused at is identified by the node and its flow, not by where the +// tokens are: a branch node pauses the run while its token stays on the enclosing node. +func TestPausedBreakpointIdentifiesABranchNodeInItsFlow(t *testing.T) { + exec := blockDebugExecutor(t) + if _, ok := exec.PausedBreakpoint(); ok { + t.Fatal("PausedBreakpoint() reports a stop before any run") + } + choose := actionNodeNamed(t, exec, "choose") + var q ast.Node + for _, node := range exec.Graph().BlockNodes[choose] { + if ActionNodeName(node) == "q" { + q = node + } + } + if q == nil { + t.Fatalf("choose's blocks declare no q: %v", exec.Graph().BlockNodes[choose]) + } + exec.ReplaceBreakpointsAt([]NodeBreakpoint{{Within: []ast.Node{choose}, Node: q}}) + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.PausedAt(); got != "q" { + t.Fatalf("PausedAt() = %q, want q", got) + } + bp, ok := exec.PausedBreakpoint() + if !ok || bp.Node != q || !slices.Equal(bp.Within, []ast.Node{choose}) { + t.Errorf("PausedBreakpoint() = %+v, %v, want q within choose", bp, ok) + } + if tokens := exec.Tokens(); len(tokens) != 1 || tokens[0].Location != choose { + t.Errorf("tokens = %v, want the one still on choose", tokens) + } + // The path reported is the caller's own: writing to it leaves the stop where it was. + bp.Within[0] = q + if again, ok := exec.PausedBreakpoint(); !ok || !slices.Equal(again.Within, []ast.Node{choose}) { + t.Errorf("PausedBreakpoint() = %+v, %v after writing to the path reported, want q still within choose", again, ok) + } + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("resume: %v", err) + } + if bp, ok := exec.PausedBreakpoint(); ok { + t.Errorf("PausedBreakpoint() = %+v after resuming, want none", bp) + } +} + +// A breakpoint on a node a loop body declares pauses the run once per iteration, +// resuming once per pause, as a breakpoint on a node of the action's own flow does. +func TestBreakpointPausesOnEachLoopIteration(t *testing.T) { + exec := blockDebugExecutor(t) + exec.SetBreakpoint("add") + + for iteration, wantTotal := range []int64{7, 8, 10} { + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("iteration %d: RunToCompletion: %v", iteration, err) + } + if got := exec.PausedAt(); got != "add" { + t.Fatalf("iteration %d: PausedAt() = %q, want add", iteration, got) + } + if total := exec.Results()["total"]; total.Const.Int != wantTotal { + t.Errorf("iteration %d: total = %v, want %d", iteration, total, wantTotal) + } + } + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("final run: %v", err) + } + if got := exec.PausedAt(); got != "" { + t.Errorf("PausedAt() = %q after completing, want empty", got) + } + if total := exec.Results()["total"]; total.Const.Int != 13 { + t.Errorf("total = %v, want 13", total) + } +} + +// TestStepSpendsTheActionBudgetOnABlockNodesOwnFlow: one Step performs the flow a +// block-declared node states of its own to its end, so a cycle in it spends the +// action's token-flow budget within that step. +func TestStepSpendsTheActionBudgetOnABlockNodesOwnFlow(t *testing.T) { + ctx, sym := loadAction(t, `package test { + private import ScalarValues::*; + action outer { + attribute x : Integer = 3; + first start; + then action pick { + if x > 0 { + action leg { + first a; + action a; + action b; + succession first a then b; + succession first b then a; + } + } + } + then done; + } + }`, "outer") + ctx.maxActionSteps = 50 + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + for i := 0; i < 10; i++ { + err = exec.Step() + if err != nil { + break + } + } + if !errors.Is(err, ErrActionStepLimitExceeded) { + t.Fatalf("Step() = %v, want ErrActionStepLimitExceeded", err) + } +} + +// A breakpoint on a step of the flow a block node owns pauses the run when a +// token of that flow reaches it, with the tokens of both flows in view. +func TestBreakpointPausesInsideABlockNodesOwnFlow(t *testing.T) { + ctx, sym := loadAction(t, `package test { + private import ScalarValues::*; + action outer { + out attribute total : Integer = 0; + first start; + then action choose { + if total == 0 { + action split { + out sum : Integer; + first start; + then action left { out a : Integer; assign a := 10; } + then action gather { assign sum := left.a + 1; } + then done; + } + action report { assign total := split.sum; } + } + } + then done; + } + }`, "outer") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + exec.SetBreakpoint("gather") + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.PausedAt(); got != "gather" { + t.Fatalf("PausedAt() = %q, want gather", got) + } + var at []string + for _, token := range exec.Tokens() { + at = append(at, ActionNodeName(token.Location)) + } + if got := strings.Join(at, ","); got != "choose,gather" { + t.Errorf("tokens at %s, want choose,gather", got) + } + if a, ok := exec.Results()["choose.split.left.a"]; !ok || a.Const.Int != 10 { + t.Errorf("results = %v, want choose.split.left.a 10", exec.Results()) + } + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("resume: %v", err) + } + if total := exec.Results()["total"]; total.Const.Int != 11 { + t.Errorf("total = %v, want 11", total) + } +} + +// Stepping resumes the paused block node and pauses again at the next one. +func TestStepResumesAPausedBlockNode(t *testing.T) { + exec := blockDebugExecutor(t) + exec.SetBreakpoint("add") + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + + if err := exec.Step(); err != nil { + t.Fatalf("Step: %v", err) + } + if got := exec.PausedAt(); got != "add" { + t.Errorf("PausedAt() = %q after a step, want the next iteration's add", got) + } + if total := exec.Results()["total"]; total.Const.Int != 8 { + t.Errorf("total = %v after a step, want 8", total) + } + + exec.ClearBreakpoints() + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("resume: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } +} + +// forkedDebugSrc forks into two nodes whose loop bodies each declare a node, so +// two tokens meet body breakpoints of their own. +const forkedDebugSrc = `package test { + private import ScalarValues::*; + action outer { + out attribute left : Integer = 0; + out attribute right : Integer = 0; + fork split; + action l { + assign left := 100; + for i in 1..2 { action addL { in n : Integer = i; assign left := left + n; } } + } + action r { + assign right := 100; + for i in 1..2 { action addR { in n : Integer = i; assign right := right + n; } } + } + join sync; + succession first start then split; + succession first split then l; + succession first split then r; + succession first l then sync; + succession first r then sync; + succession first sync then done; + } +}` + +// stepUntilPaused steps exec until a breakpoint suspends it, returning the +// number of steps taken. +func stepUntilPaused(t *testing.T, exec *ActionExecutor) int { + t.Helper() + for steps := 1; steps <= 20; steps++ { + if err := exec.Step(); err != nil { + t.Fatalf("Step %d: %v", steps, err) + } + if exec.State() == StateSuspended { + return steps + } + } + t.Fatal("no breakpoint paused the run") + return 0 +} + +// A body breakpoint one token meets ends the step: its sibling out of the fork +// stays put, its body not begun, and resumes on its own coroutine next step. +func TestBodyBreakpointFreezesSiblingTokens(t *testing.T) { + ctx, sym := loadAction(t, forkedDebugSrc, "outer") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + exec.SetBreakpoint("addL") + exec.SetBreakpoint("addR") + + stepUntilPaused(t, exec) + first := exec.PausedAt() + other := map[string]string{"addL": "right", "addR": "left"}[first] + if other == "" { + t.Fatalf("PausedAt() = %q, want addL or addR", first) + } + if v := exec.Results()[other]; v.Const.Int != 0 { + t.Errorf("%s = %v while paused at %s, want 0: the sibling's body ran", other, v, first) + } + if n := len(exec.Tokens()); n != 2 { + t.Errorf("%d tokens while paused, want the fork's 2", n) + } + + // The sibling steps next and pauses in a body of its own; the two then take + // turns, each pausing once per iteration. + pauses := []string{first} + for len(pauses) < 4 { + stepUntilPaused(t, exec) + pauses = append(pauses, exec.PausedAt()) + } + if pauses[0] == pauses[1] || pauses[1] == pauses[2] || pauses[2] == pauses[3] { + t.Errorf("pauses %v, want the two bodies taking turns", pauses) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("final run: %v", err) + } + if got := exec.PausedAt(); got != "" { + t.Errorf("PausedAt() = %q after four pauses, want a run to the end", got) + } + results := exec.Results() + if results["left"].Const.Int != 103 || results["right"].Const.Int != 103 { + t.Errorf("left, right = %v, %v, want 103, 103", results["left"], results["right"]) + } +} + +// convergingDebugSrc forks into two nodes whose successions both lead to the node +// declared by decl, named node: it is performed once, after both have arrived. +func convergingDebugSrc(node, decl string) string { + return `package test { + private import ScalarValues::*; + action outer { + out attribute hits : Integer = 0; + fork split; + action l { assign hits := hits + 1; } + action r { assign hits := hits + 1; } + ` + decl + ` + succession first start then split; + succession first split then l; + succession first split then r; + succession first l then ` + node + `; + succession first r then ` + node + `; + succession first ` + node + ` then done; + } +}` +} + +// A breakpoint on a node two fork branches converge on — a join or a plain node — +// pauses once, before its one performance, with both arrivals in; not once per token. +func TestBreakpointPausesOncePerSynchronizedPerformance(t *testing.T) { + cases := []struct { + node, decl string + hits int64 + }{ + {"sync", "join sync;", 2}, + {"scale", "action scale { assign hits := hits * 10; }", 20}, + } + for _, tc := range cases { + t.Run(tc.node, func(t *testing.T) { + ctx, sym := loadAction(t, convergingDebugSrc(tc.node, tc.decl), "outer") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + exec.SetBreakpoint(tc.node) + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.PausedAt(); got != tc.node { + t.Fatalf("PausedAt() = %q, want %s", got, tc.node) + } + if got := len(exec.Tokens()); got != 2 { + t.Fatalf("%d tokens while paused, want the 2 arrivals at %s", got, tc.node) + } + for _, tok := range exec.Tokens() { + if ActionNodeName(tok.Location) != tc.node { + t.Errorf("token %d @ %s while paused, want %s", tok.ID, ActionNodeName(tok.Location), tc.node) + } + if awaiting := exec.Awaiting(tok); len(awaiting) > 0 { + t.Errorf("paused at %s while token %d still awaits %d successions", tc.node, tok.ID, len(awaiting)) + } + } + if hits := exec.Results()["hits"]; hits.Const.Int != 2 { + t.Errorf("hits = %v while paused, want 2: both branches ran, %s did not", hits, tc.node) + } + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("resume: %v", err) + } + if got := exec.PausedAt(); got != "" { + t.Fatalf("PausedAt() = %q after resuming, want a run to the end", got) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } + if hits := exec.Results()["hits"]; hits.Const.Int != tc.hits { + t.Errorf("hits = %v, want %d", hits, tc.hits) + } + }) + } +} + +// A token entering a nested flow in the step a synchronization drops tokens below it +// takes no second step: the breakpoint on the nested flow's first node still pauses. +func TestBreakpointOnANestedFirstNodePausesWhileOthersSynchronize(t *testing.T) { + ctx, sym := loadAction(t, `package test { + private import ScalarValues::*; + action outer { + out attribute hits : Integer = 0; + out attribute n : Integer = 0; + fork split; + action l { assign hits := hits + 1; } + action r { assign hits := hits + 1; } + join sync; + action tally { assign hits := hits * 10; } + action pre { assign n := n + 1; } + action nested { + action inner { assign n := n * 10; } + first inner; + } + succession first start then split; + succession first split then l; + succession first split then r; + succession first split then pre; + succession first l then sync; + succession first r then sync; + succession first sync then tally; + succession first pre then nested; + } +}`, "outer") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + exec.SetBreakpoint("inner") + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.PausedAt(); got != "inner" { + t.Fatalf("PausedAt() = %q, want inner", got) + } + if n := exec.Results()["n"]; n.Const.Int != 1 { + t.Errorf("n = %v while paused, want 1: pre ran, inner did not", n) + } + var atInner int + for _, tok := range exec.Tokens() { + if ActionNodeName(tok.Location) == "inner" { + atInner++ + } + } + if atInner != 1 { + t.Errorf("%d tokens at inner while paused, want 1", atInner) + } + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("resume: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } + if n := exec.Results()["n"]; n.Const.Int != 10 { + t.Errorf("n = %v, want 10", n) + } + if hits := exec.Results()["hits"]; hits.Const.Int != 20 { + t.Errorf("hits = %v, want 20", hits) + } +} + +// A breakpoint on a node reached from the flow's start and back around a loop pauses +// before each pass: once per performance, for as many performances as the loop makes. +func TestBreakpointOnALoopedNodePausesEachPass(t *testing.T) { + ctx, sym := loadAction(t, `package test { + private import ScalarValues::*; + action count { + out attribute n : Integer = 0; + action bump { assign n := n + 1; } + decide again; + succession first start then bump; + succession first bump then again; + succession first again if n < 3 then bump; + succession first again if n >= 3 then done; + } +}`, "count") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + exec.SetBreakpoint("bump") + + for pass := int64(0); pass < 3; pass++ { + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("pass %d: RunToCompletion: %v", pass, err) + } + if got := exec.PausedAt(); got != "bump" { + t.Fatalf("pass %d: PausedAt() = %q, want bump", pass, got) + } + if n := exec.Results()["n"]; n.Const.Int != pass { + t.Errorf("pass %d: n = %v while paused, want %d", pass, n, pass) + } + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("final run: %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } + if n := exec.Results()["n"]; n.Const.Int != 3 { + t.Errorf("n = %v, want 3", n) + } +} + +// Releasing an executor paused in a body ends the paused work, so nothing of the +// run stays suspended; a later step is refused, and releasing again is harmless. +func TestReleaseEndsAPausedBody(t *testing.T) { + exec := blockDebugExecutor(t) + exec.SetBreakpoint("add") + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + var run *bodyRun + for _, token := range exec.tokens { + if token.body != nil { + run = token.body + } + } + if run == nil { + t.Fatal("no token holds paused work while paused at add") + } + + exec.Release() + if !run.ended { + t.Error("the paused work is still paused after Release") + } + if !errors.Is(run.err, ErrActionDeadlock) { + t.Errorf("the ended work reports %v, want ErrActionDeadlock (abandoned)", run.err) + } + for _, token := range exec.tokens { + if token.body != nil { + t.Errorf("token %d still holds paused work after Release", token.ID) + } + } + exec.Release() + if err := exec.Step(); !errors.Is(err, ErrExecutorReleased) { + t.Errorf("Step after Release = %v, want ErrExecutorReleased", err) + } +} + +// A run that fails its token-flow budget while a body is paused ends the paused +// work as a failing step does, so nothing of the run stays suspended. +func TestBudgetFailureEndsAPausedBody(t *testing.T) { + exec := blockDebugExecutor(t) + exec.SetBreakpoint("add") + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + var run *bodyRun + for _, token := range exec.tokens { + if token.body != nil { + run = token.body + } + } + if run == nil { + t.Fatal("no token holds paused work while paused at add") + } + + exec.ctx.maxActionSteps = 0 + if err := exec.RunToCompletion(); !errors.Is(err, ErrActionStepLimitExceeded) { + t.Fatalf("resume = %v, want ErrActionStepLimitExceeded", err) + } + if !run.ended { + t.Error("the paused work is still paused after the budget failure") + } + for _, token := range exec.tokens { + if token.body != nil { + t.Errorf("token %d still holds paused work after the budget failure", token.ID) + } + } +} + +// A released executor refuses to step whatever state its run ended in. +func TestReleasedExecutorRefusesToStep(t *testing.T) { + exec := blockDebugExecutor(t) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if exec.State() != StateCompleted { + t.Fatalf("State() = %v after the run, want StateCompleted", exec.State()) + } + exec.Release() + if err := exec.Step(); !errors.Is(err, ErrExecutorReleased) { + t.Errorf("Step of a released, completed executor = %v, want ErrExecutorReleased", err) + } + if err := exec.RunToCompletion(); !errors.Is(err, ErrExecutorReleased) { + t.Errorf("RunToCompletion of a released, completed executor = %v, want ErrExecutorReleased", err) + } +} + +// A step that fails ends the work another token had paused. +func TestFailedStepEndsPausedBodies(t *testing.T) { + ctx, sym := loadAction(t, `package test { + private import ScalarValues::*; + action outer { + out attribute total : Integer = 0; + fork split; + action l { for i in 1..2 { action add { assign total := total + i; } } } + action wait {} + action r { assign total := total / 0; } + join sync; + succession first start then split; + succession first split then l; + succession first split then wait; + succession first wait then r; + succession first l then sync; + succession first r then sync; + succession first sync then done; + } + }`, "outer") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + exec.SetBreakpoint("add") + + var run *bodyRun + for steps := 0; run == nil && steps < 20; steps++ { + if err := exec.Step(); err != nil { + t.Fatalf("Step: %v", err) + } + for _, token := range exec.tokens { + if token.body != nil { + run = token.body + } + } + } + if run == nil { + t.Fatal("no token paused in its body") + } + if err := exec.RunToCompletion(); !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("RunToCompletion = %v, want ErrDivisionByZero from the sibling", err) + } + if !run.ended { + t.Error("the paused work is still paused after the run failed") + } +} + +func TestStateExecutorDebugAccessors(t *testing.T) { + ctx, sym := loadState(t, debugStateSrc, "Cycle") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + + if got := exec.StateMachineSymbol(); got == nil || got.Name != "Cycle" { + t.Fatalf("StateMachineSymbol() = %v, want Cycle", got) + } + if got := exec.State(); got != StateRunning { + t.Errorf("State() = %v, want %v", got, StateRunning) + } + if got := exec.CurrentTime(); got != 0 { + t.Errorf("CurrentTime() = %v, want 0", got) + } + if exec.EventQueue().Len() == 0 { + t.Error("EventQueue() is empty, want the initial completion event") + } + if got := activeStateNames(exec); got != "init" { + t.Errorf("ActiveStates() = %s, want init", got) + } + if exec.StateData() == nil { + t.Error("StateData() = nil") + } + + // Drain the queue: time follows the events' timestamps. + for exec.HasPendingWork() && exec.State() == StateRunning { + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + } + + if got := exec.CurrentTime(); got != 15 { + t.Errorf("CurrentTime() = %v, want 15", got) + } + if got := activeStateNames(exec); got != "done" { + t.Errorf("ActiveStates() = %s, want done", got) + } + if len(exec.StateStack()) == 0 { + t.Error("StateStack() is empty") + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v, want %v", got, StateCompleted) + } +} + +// RunDoRound advances a state's do behavior without dispatching an event, so a +// debugger can run work that is due now while leaving a future event queued. +func TestRunDoRoundRunsDoWorkOnly(t *testing.T) { + exec := stateExecutorForSource(t, "Slow", `package test { + state Slow { + attribute count = 0; + entry; then init; + state init; + state working { + do { assign count := count + 1; } + } + accept after 100 then done; + succession first init then working; + } + }`) + + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if !exec.HasPendingDoWork() { + t.Fatal("HasPendingDoWork() = false, want the do behavior of working pending") + } + + queued := exec.EventQueue().Len() + ran, err := exec.RunDoRound() + if err != nil { + t.Fatalf("RunDoRound: %v", err) + } + if ran != 1 { + t.Errorf("RunDoRound() ran %d actions, want 1", ran) + } + if got := exec.EventQueue().Len(); got != queued { + t.Errorf("event queue length = %d, want %d (no event dispatched)", got, queued) + } + if got := exec.CurrentTime(); got != 0 { + t.Errorf("CurrentTime() = %v, want 0 (the future event is untouched)", got) + } + if exec.HasPendingDoWork() { + t.Error("HasPendingDoWork() = true after the behavior's only action ran") + } +} + +// ActiveStates reports every region's state for an orthogonal machine, where +// CurrentState has no single answer to give. +func TestActiveStatesCoversOrthogonalRegions(t *testing.T) { + exec := stateExecutorForSource(t, "TrafficLight", `package test { + state def TrafficLight parallel { + state pedestrian { + entry; then start; + state start; + state Walk; + succession first start then Walk; + } + state vehicle { + entry; then begin; + state begin; + state Green; + succession first begin then Green; + } + } + }`) + + if exec.CurrentState() != nil { + t.Error("CurrentState() should have no single answer for an orthogonal machine") + } + if got := len(exec.ActiveStates()); got != 2 { + t.Fatalf("ActiveStates() returned %d states, want one per region", got) + } +} + +// activeStateNames joins the machine's active configuration for comparison. +func activeStateNames(exec *StateExecutor) string { + names := make([]string, 0, 2) + for _, state := range exec.ActiveStates() { + names = append(names, state.Name) + } + return strings.Join(names, "|") +} + +// firedNames spells the transitions an executor logged as source->target, an +// entry transition as ->target, so a test compares the log against the model. +func firedNames(exec *StateExecutor) []string { + out := make([]string, 0, exec.FiredCount()) + for _, fired := range exec.FiredTransitions() { + source := "" + if fired.Source != nil { + source = StateVertexName(fired.Source) + } + out = append(out, source+"->"+StateVertexName(fired.Target)) + } + return out +} + +// The fired-transition log names the entry transition, then every transition +// taken in order; a mark read before a step delimits what that step fired. +func TestFiredTransitionsLogsEachTransitionInOrder(t *testing.T) { + ctx, sym := loadState(t, debugStateSrc, "Cycle") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + exec.KeepFired(true) + if got := firedNames(exec); !slices.Equal(got, []string{"->init"}) { + t.Fatalf("after start fired = %v, want the entry transition only", got) + } + mark := exec.FiredCount() + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if got := firedNames(exec)[mark:]; !slices.Equal(got, []string{"init->waiting"}) { + t.Fatalf("first step fired = %v, want init->waiting", got) + } + for exec.HasPendingWork() && exec.State() == StateRunning { + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + } + want := []string{"->init", "init->waiting", "waiting->working", "working->done"} + if got := firedNames(exec); !slices.Equal(got, want) { + t.Fatalf("fired = %v, want %v", got, want) + } +} + +// A machine keeps only the dispatch under way's firings unless a debugger asks it +// to keep them, and then releases what FiredSince read: the record is bounded by +// what its reader has yet to see, while FiredCount counts every firing. +func TestFiredSinceReleasesTheFiringsRead(t *testing.T) { + ctx, sym := loadState(t, debugStateSrc, "Cycle") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + if got := firedNames(exec); !slices.Equal(got, []string{"->init"}) { + t.Fatalf("after start fired = %v, want the entry transition until a dispatch begins", got) + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if got := firedNames(exec); !slices.Equal(got, []string{"init->waiting"}) || exec.FiredCount() != 2 { + t.Fatalf("unkept, after one dispatch fired = %v of %d, want that dispatch's alone of 2", got, exec.FiredCount()) + } + + exec.KeepFired(true) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + kept := []string{"init->waiting", "waiting->working"} + if got := firedNames(exec); !slices.Equal(got, kept) || exec.FiredCount() != 3 { + t.Fatalf("kept, fired = %v of %d, want %v of 3", got, exec.FiredCount(), kept) + } + if got := exec.FiredSince(0); len(got) != 2 || got[0].Target != exec.FiredTransitions()[0].Target { + t.Errorf("FiredSince(0) = %v, want the two kept firings, the released one gone", got) + } + if got := exec.FiredSince(2); len(got) != 1 || StateVertexName(got[0].Target) != "working" { + t.Errorf("FiredSince(2) = %v, want waiting->working alone", got) + } + if got := firedNames(exec); !slices.Equal(got, []string{"waiting->working"}) || exec.FiredCount() != 3 { + t.Errorf("after reading since 2, fired = %v of %d, want the one read of 3: the earlier ones are released", got, exec.FiredCount()) + } + if got := exec.FiredSince(exec.FiredCount()); got != nil || exec.FiredCount() != 3 { + t.Errorf("FiredSince(FiredCount()) = %v of %d, want nil of 3", got, exec.FiredCount()) + } + if got := exec.FiredSince(exec.FiredCount() + 3); got != nil || len(exec.FiredTransitions()) != 0 { + t.Errorf("FiredSince past the count = %v, want nil and nothing kept", got) + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if got := exec.FiredSince(3); len(got) != 1 || StateVertexName(got[0].Target) != "done" || exec.FiredCount() != 4 { + t.Errorf("FiredSince(3) = %v of %d, want working->done of 4", got, exec.FiredCount()) + } + exec.KeepFired(false) + if got := firedNames(exec); len(got) != 0 || exec.FiredCount() != 4 { + t.Errorf("no longer kept, fired = %v of %d, want none of 4", got, exec.FiredCount()) + } +} + +// A compound transition through a junction logs each segment; a fork logs the +// transition into it and then its branches; a join logs every branch into it in +// the order they fire. +func TestFiredTransitionsLogsCompoundAndForkSegments(t *testing.T) { + src := `package test { + state Machine { + attribute priority : Integer = 2; + entry; then init; + state init; + junction route; + state low; + state high; + state working parallel { + state left { + entry; then leftStart; + state leftStart; + state building; + succession first leftStart then building; + } + state right { + entry; then rightStart; + state rightStart; + state checking; + succession first rightStart then checking; + } + } + fork split; + join sync; + transition first init then route; + transition first route if priority > 5 then high; + transition first route then low; + transition first low then split; + transition first high then split; + transition first split then building; + transition first split then checking; + transition first building then sync; + transition first checking then sync; + transition first sync then done; + } + }` + ctx, sym := loadState(t, src, "Machine") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + exec.KeepFired(true) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + want := []string{ + "->init", "init->route", "route->low", "low->split", + "split->building", "split->checking", + "building->sync", "checking->sync", "sync->done", + } + if got := firedNames(exec); !slices.Equal(got, want) { + t.Fatalf("fired = %v, want %v", got, want) + } +} + +// A compound transition through a choice logs the segments into the choice as +// well as the branch it takes; one through two choices logs every segment. +func TestFiredTransitionsLogsSegmentsIntoAChoice(t *testing.T) { + src := `package test { + state Machine { + attribute priority : Integer = 2; + entry; then init; + state init; + choice route; + choice again; + state low; + state high; + transition first init do assign priority := priority + 1 then route; + transition first route if priority > 5 then high; + transition first route then again; + transition first again if priority > 5 then high; + transition first again then low; + } + }` + ctx, sym := loadState(t, src, "Machine") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + exec.KeepFired(true) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + want := []string{"->init", "init->route", "route->again", "again->low"} + if got := firedNames(exec); !slices.Equal(got, want) { + t.Fatalf("fired = %v, want %v", got, want) + } +} + +// A history entered before its owner has run takes its default transition, and +// that route is logged after the transition into the history: straight to the +// default state, through a choice with each segment on the way, or into a +// terminate that ends the machine. +func TestFiredTransitionsLogsDefaultHistoryRoute(t *testing.T) { + for _, tc := range []struct { + name, src string + want []string + ended bool + }{ + {"direct", `package test { + state Machine { + entry; then init; + state init; + state running { + history previous; + state idle; + state busy; + transition first previous then idle; + transition first idle accept work then busy; + } + transition first init accept go then previous; + } + }`, []string{"->init", "init->previous", "previous->idle"}, false}, + {"choice", `package test { + state Machine { + attribute priority : Integer = 2; + entry; then init; + state init; + state running { + history previous; + choice route; + state idle; + state busy; + transition first previous then route; + transition first route if priority > 5 then busy; + transition first route then idle; + } + transition first init accept go then previous; + } + }`, []string{"->init", "init->previous", "previous->route", "route->idle"}, false}, + {"terminate", `package test { + state Machine { + entry; then init; + state init; + state running { + history previous; + state idle; + action stop terminate; + transition first previous then stop; + transition first idle accept work then stop; + } + transition first init accept go then previous; + } + }`, []string{"->init", "init->previous", "previous->stop"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, sym := loadState(t, tc.src, "Machine") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + exec.KeepFired(true) + exec.SendSignal("go", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if tc.ended { + if exec.State() != StateTerminated { + t.Fatalf("state = %v, want the machine ended at the terminate", exec.State()) + } + } else if got := activeStateNames(exec); !strings.Contains(got, "idle") { + t.Fatalf("active = %s, want idle", got) + } + if got := firedNames(exec); !slices.Equal(got, tc.want) { + t.Fatalf("fired = %v, want %v", got, tc.want) + } + }) + } +} + +// A firing that fails midway logs nothing: not the fork and its branches, nor +// the segments of a compound transition whose last effect fails, nor those +// into a choice when the branch out of it fails. +func TestFiredTransitionsOmitsAFailedFiring(t *testing.T) { + for _, tc := range []struct { + name, src string + }{ + {"fork", `package test { + state Machine { + attribute counter : Integer = 0; + entry; then init; + state init; + state working parallel { + state left { entry; then leftStart; state leftStart; state building; } + state right { entry; then rightStart; state rightStart; state checking; } + } + fork split; + transition first init accept go do assign counter := missingName + 1 then split; + transition first split then building; + transition first split then checking; + } + }`}, + {"compound", `package test { + state Machine { + attribute counter : Integer = 0; + entry; then init; + state init; + junction route; + state low; + transition first init accept go then route; + transition first route do assign counter := missingName + 1 then low; + } + }`}, + {"choice", `package test { + state Machine { + attribute counter : Integer = 0; + entry; then init; + state init; + choice route; + state low; + transition first init accept go then route; + transition first route do assign counter := missingName + 1 then low; + } + }`}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, sym := loadState(t, tc.src, "Machine") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + exec.KeepFired(true) + if got := firedNames(exec); !slices.Equal(got, []string{"->init"}) { + t.Fatalf("after start fired = %v, want the entry transition only", got) + } + exec.SendSignal("go", nil) + if err := exec.ProcessNextEvent(); !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("ProcessNextEvent: %v, want ErrUnresolvedReference from the effect", err) + } + if got := firedNames(exec); !slices.Equal(got, []string{"->init"}) { + t.Errorf("fired = %v after the failed firing, want the entry transition only", got) + } + }) + } +} + +// A breakpoint pauses the machine as the dispatch entering the state completes, +// even one leaving it again; the clock skips the paused machine until resumed. +func TestStateBreakpointPausesOnATransientState(t *testing.T) { + src := `package test { + state Vehicle { + entry; then cruising; + state cruising; + accept after 5 then braking; + state braking; + then stopped; + state stopped; + accept after 5 then done; + } + }` + ctx, sym := loadState(t, src, "Vehicle") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + exec.SetBreakpointAt(stateNamed(t, exec, "braking")) + + if _, err := ctx.Advance(20); err != nil { + t.Fatalf("Advance: %v", err) + } + if got := exec.PausedAt(); got == nil || StateVertexName(got) != "braking" { + t.Fatalf("PausedAt() = %v, want braking", got) + } + if got := exec.State(); got != StateSuspended { + t.Errorf("State() = %v, want %v", got, StateSuspended) + } + if got := activeStateNames(exec); got != "braking" { + t.Errorf("ActiveStates() = %s, want braking", got) + } + if got := ctx.Clock().Now(); got != 20 { + t.Errorf("clock = %v, want 20: the clock moves on past a paused machine", got) + } + if !exec.HasDueEvent() { + t.Error("HasDueEvent() = false, want the completion event held for the resumed run") + } + + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if exec.PausedAt() != nil { + t.Errorf("PausedAt() = %v after resuming, want nil", exec.PausedAt()) + } + if got := activeStateNames(exec); got != "stopped" { + t.Errorf("ActiveStates() = %s, want stopped", got) + } + if got := exec.CurrentTime(); got != 20 { + t.Errorf("CurrentTime() = %v, want 20", got) + } +} + +// A dispatch that fails entering a breakpoint state pauses on nothing, then and +// later: the next dispatch to succeed does not pause on the state it entered. +func TestStateBreakpointStagedByAFailedEntryIsDropped(t *testing.T) { + src := `package test { + state Machine { + attribute counter : Integer = 0; + entry; then init; + state init; + state arming { + entry assign counter := missingName + 1; + } + state idle; + transition first init accept go then arming; + transition first init accept rest then idle; + transition first arming accept rest then idle; + } + }` + ctx, sym := loadState(t, src, "Machine") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + exec.SetBreakpointAt(stateNamed(t, exec, "arming")) + exec.SendSignal("go", nil) + if err := exec.ProcessNextEvent(); !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("ProcessNextEvent: %v, want ErrUnresolvedReference from the entry", err) + } + if exec.PausedAt() != nil || exec.State() == StateSuspended { + t.Fatalf("paused at %v in state %v after the failed entry, want no pause", exec.PausedAt(), exec.State()) + } + + exec.ClearBreakpoints() + exec.SendSignal("rest", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if exec.PausedAt() != nil || exec.State() == StateSuspended { + t.Fatalf("paused at %v in state %v with no breakpoint set, want none", exec.PausedAt(), exec.State()) + } +} + +// A breakpoint on `done` pauses the machine standing on it, its completion held +// for the resumed run: only then do its exit behaviors run and its performance end. +func TestStateBreakpointOnDonePausesBeforeCompleting(t *testing.T) { + src := `package test { + private import ScalarValues::*; + state Machine { + attribute exited : Integer = 0; + entry; then idle; + state idle { + exit action { assign exited := 1; } + } + transition first idle accept Stop then done; + } + }` + ctx, sym := loadState(t, src, "Machine") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + done := stateNamed(t, exec, "done") + exec.SetBreakpointAt(done) + + exec.SendSignal("Stop", nil) + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if got := exec.PausedAt(); got != ast.Node(done) { + t.Fatalf("PausedAt() = %v, want done", got) + } + if got := exec.State(); got != StateSuspended { + t.Errorf("State() = %v, want %v", got, StateSuspended) + } + if !exec.CompletionDue() || !exec.HasPendingWork() { + t.Errorf("CompletionDue() = %v, HasPendingWork() = %v, want the completion held", exec.CompletionDue(), exec.HasPendingWork()) + } + if got := activeStateNames(exec); got != "done" { + t.Errorf("ActiveStates() = %s, want done", got) + } + if got := exec.StateData()["exited"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("exited = %v, want 1: idle's exit action ran on the way to done", got) + } + + if _, err := ctx.Advance(5); err != nil { + t.Fatalf("Advance: %v", err) + } + if got := exec.State(); got != StateSuspended { + t.Fatalf("State() = %v after the clock moved, want the pause held until resumed", got) + } + + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence (resumed): %v", err) + } + if got := exec.State(); got != StateCompleted { + t.Errorf("State() = %v after resuming, want %v", got, StateCompleted) + } + if exec.PausedAt() != nil || exec.CompletionDue() || exec.HasPendingWork() { + t.Errorf("PausedAt() = %v, CompletionDue() = %v, HasPendingWork() = %v after completing, want none", + exec.PausedAt(), exec.CompletionDue(), exec.HasPendingWork()) + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence (completed): %v", err) + } +} + +// A machine paused on `done` completes as its next single step, whichever +// driver takes it. +func TestStateBreakpointOnDoneCompletesOnTheNextStep(t *testing.T) { + src := `package test { + state Machine { + entry; then idle; + state idle; + transition first idle accept Stop then done; + } + }` + for _, tc := range []struct { + name string + step func(exec *StateExecutor) error + }{ + {"ProcessNextEvent", func(exec *StateExecutor) error { exec.Resume(); return exec.ProcessNextEvent() }}, + {"stepOne", func(exec *StateExecutor) error { return exec.stepOne() }}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, sym := loadState(t, src, "Machine") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + exec.SetBreakpointAt(stateNamed(t, exec, "done")) + exec.SendSignal("Stop", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if got := exec.State(); got != StateSuspended || !exec.CompletionDue() { + t.Fatalf("State() = %v, CompletionDue() = %v, want suspended with the completion held", got, exec.CompletionDue()) + } + if err := tc.step(exec); err != nil { + t.Fatalf("step: %v", err) + } + if got := exec.State(); got != StateCompleted || exec.CompletionDue() { + t.Errorf("State() = %v, CompletionDue() = %v after the step, want completed", got, exec.CompletionDue()) + } + }) + } +} + +// Breakpoints are cleared as a set; a pause already reached stands until resumed. +func TestClearStateBreakpointsRunsThrough(t *testing.T) { + ctx, sym := loadState(t, debugStateSrc, "Cycle") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + exec.SetBreakpointAt(stateNamed(t, exec, "waiting")) + exec.ClearBreakpoints() + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if exec.PausedAt() != nil || exec.State() != StateCompleted { + t.Fatalf("paused at %v in state %v, want a completed run", exec.PausedAt(), exec.State()) + } +} + +// A breakpoint on a pseudostate pauses the machine as the dispatch passing +// through it completes, in the state the route reached; a dispatch that fails on +// the way through pauses on nothing. +func TestPseudostateBreakpointPausesAfterTheRouteThroughIt(t *testing.T) { + src := `package test { + state Machine { + attribute counter : Integer = 0; + entry; then init; + state init; + junction route; + state low; + state high; + transition first init accept go then route; + transition first route if counter > 5 then high; + transition first route then low; + transition first low accept go then route; + transition first low accept bad do assign counter := missingName then route; + } + }` + ctx, sym := loadState(t, src, "Machine") + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + var route *ast.PseudostateNode + for _, ps := range exec.graph.Pseudostates { + if ps.Name == "route" { + route = ps + } + } + if route == nil { + t.Fatal("the graph lowers no pseudostate route") + } + exec.SetBreakpointAt(route) + + exec.SendSignal("go", nil) + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if got := exec.PausedAt(); got != ast.Node(route) { + t.Fatalf("PausedAt() = %v, want the junction route", got) + } + if got := exec.State(); got != StateSuspended { + t.Errorf("State() = %v, want %v", got, StateSuspended) + } + if got := activeStateNames(exec); got != "low" { + t.Errorf("ActiveStates() = %s, want low, where the route through the junction ended", got) + } + + exec.Resume() + exec.SendSignal("bad", nil) + if err := exec.ProcessNextEvent(); !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("ProcessNextEvent: %v, want ErrUnresolvedReference from the effect", err) + } + if got := exec.PausedAt(); got != nil { + t.Errorf("PausedAt() = %v after a failed dispatch, want nil", got) + } +} + +// traversalNames spells the traversal log as source->target, prefixing an edge +// of a nested action's own flow with that action's name. +func traversalNames(exec *ActionExecutor) []string { + out := make([]string, 0, exec.TraversalCount()) + for _, tr := range exec.Traversals() { + prefix := "" + for _, owner := range tr.Within { + prefix += ActionNodeName(owner) + "/" + } + out = append(out, prefix+ActionNodeName(tr.Edge.Source)+"->"+ActionNodeName(tr.Edge.Target)) + } + return out +} + +// The traversal log records every succession taken, fork branches, nested flows +// and join branches included; a mark read before a step delimits what it took. +func TestTraversalsLogEachSuccessionInOrder(t *testing.T) { + src := `package test { + action Drive { + attribute speed : Integer = 0; + first start; + fork split; + action prep { first begin; action warm; succession first begin then warm; } + action tally { assign speed := speed + 1; } + join sync; + done; + succession first start then split; + succession first split then prep; + succession first split then tally; + succession first prep then sync; + succession first tally then sync; + succession first sync then done; + } + }` + ctx, sym := loadAction(t, src, "Drive") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + exec.KeepTraversals(true) + if got := traversalNames(exec); len(got) != 0 { + t.Fatalf("before any step traversals = %v, want none", got) + } + if err := exec.Step(); err != nil { + t.Fatalf("Step: %v", err) + } + if got := traversalNames(exec); !slices.Equal(got, []string{"start->split"}) { + t.Fatalf("first step traversals = %v, want start->split", got) + } + mark := exec.TraversalCount() + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + got := traversalNames(exec)[mark:] + want := []string{ + "split->prep", "split->tally", "tally->sync", + "prep/begin->warm", "prep->sync", "sync->done", + } + if !slices.Equal(got, want) { + t.Fatalf("run traversals = %v, want %v", got, want) + } + tokens := make(map[int64]bool) + for _, tr := range exec.Traversals() { + tokens[tr.Token] = true + } + if len(tokens) < 2 { + t.Errorf("traversals name %d token(s), want the fork's branches to be distinct", len(tokens)) + } + + // The copies are the caller's: a path overwritten in one leaves the record as it was. + nested := slices.IndexFunc(exec.Traversals(), func(tr Traversal) bool { return len(tr.Within) > 0 }) + if nested < 0 { + t.Fatal("no traversal ran in a nested flow") + } + exec.Traversals()[nested].Within[0] = nil + exec.TraversalsSince(0)[nested].Within[0] = nil + if got := traversalNames(exec); !slices.Equal(got[mark:], want) { + t.Errorf("after writing into copies, traversals = %v, want %v unchanged", got[mark:], want) + } +} + +// An action keeps no successions unless a debugger asks it to, and then releases +// what TraversalsSince read: the record is bounded by what its reader has yet to +// see, while TraversalCount counts every succession. +func TestTraversalsSinceReleasesTheSuccessionsRead(t *testing.T) { + src := `package test { + action Drive { + first start; + action prep; + action tally; + done; + succession first start then prep; + succession first prep then tally; + succession first tally then done; + } + }` + ctx, sym := loadAction(t, src, "Drive") + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.Step(); err != nil { + t.Fatalf("Step: %v", err) + } + if got := traversalNames(exec); len(got) != 0 || exec.TraversalCount() != 1 { + t.Fatalf("unkept, after one step traversals = %v of %d, want none of 1", got, exec.TraversalCount()) + } + + exec.KeepTraversals(true) + if err := exec.Step(); err != nil { + t.Fatalf("Step: %v", err) + } + if got := traversalNames(exec); !slices.Equal(got, []string{"prep->tally"}) || exec.TraversalCount() != 2 { + t.Fatalf("kept, traversals = %v of %d, want prep->tally of 2", got, exec.TraversalCount()) + } + if got := exec.TraversalsSince(-1); len(got) != 1 { + t.Errorf("TraversalsSince(-1) = %v, want the one kept succession, the released one gone", got) + } + if got := exec.TraversalsSince(exec.TraversalCount()); got != nil || len(exec.Traversals()) != 0 || exec.TraversalCount() != 2 { + t.Errorf("TraversalsSince(TraversalCount()) = %v leaving %d kept of %d, want nil, none, 2", got, len(exec.Traversals()), exec.TraversalCount()) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if got := exec.TraversalsSince(2); len(got) != 1 || ActionNodeName(got[0].Edge.Target) != "done" || exec.TraversalCount() != 3 { + t.Errorf("TraversalsSince(2) = %v of %d, want tally->done of 3", got, exec.TraversalCount()) + } + if got := exec.TraversalsSince(2); len(got) != 1 { + t.Errorf("read again from the same mark, TraversalsSince(2) = %v, want tally->done still kept", got) + } + exec.KeepTraversals(false) + if got := traversalNames(exec); len(got) != 0 || exec.TraversalCount() != 3 { + t.Errorf("no longer kept, traversals = %v of %d, want none of 3", got, exec.TraversalCount()) + } +} + +// An advance stops as the debugged machine pauses at a breakpoint: a sibling due +// at the same instant does not run before the advance returns, and runs once the +// clock is driven again. The default policy runs the last registered waiter +// first, so the debugged bulb is instantiated after its sibling. +func TestAdvanceUntilHaltsBeforeSiblingsRun(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "lamp.sysml", parseAndBuild(t, lampSource)) + root := idx.DocumentRoot("lamp.sysml") + bulbDef := resolveSymbol(t, root, "Bulb") + sibling, err := ctx.Instantiate(bulbDef) + if err != nil { + t.Fatalf("Instantiate sibling: %v", err) + } + debugged, err := ctx.Instantiate(bulbDef) + if err != nil { + t.Fatalf("Instantiate debugged: %v", err) + } + level := map[string]Value{"level": integerValue(7)} + for _, bulb := range []*Instance{sibling, debugged} { + runTo(t, root, ctx, bulb, "go", nil) + runTo(t, root, ctx, bulb, "Dim", level) + if leaf := lampLeaf(t, bulb); leaf != "dimmed" { + t.Fatalf("bulb #%d is in %s, want dimmed", bulb.ID, leaf) + } + } + machine := lampMachine(t, debugged) + machine.SetBreakpointAt(stateNamed(t, machine, "off")) + + report, err := ctx.AdvanceUntil(10, func() bool { return machine.PausedAt() != nil }) + if err != nil { + t.Fatalf("AdvanceUntil: %v", err) + } + if report.To != 5 || machine.PausedAt() == nil { + t.Fatalf("advance reached t=%v paused at %v, want held at t=5 on the breakpoint", report.To, machine.PausedAt()) + } + if leaf := lampLeaf(t, sibling); leaf != "dimmed" { + t.Errorf("the sibling is in %s after the debugged machine paused, want still dimmed: it ran after the halt", leaf) + } + + if !machine.Resume() { + t.Fatal("Resume: the machine was not paused") + } + if _, err := ctx.Advance(0); err != nil { + t.Fatalf("Advance(0): %v", err) + } + if leaf := lampLeaf(t, sibling); leaf != "off" { + t.Errorf("the sibling is in %s once the clock is driven again, want off", leaf) + } + if leaf := lampLeaf(t, debugged); leaf != "off" { + t.Errorf("the debugged machine is in %s after resuming, want off", leaf) + } +} diff --git a/internal/core/runtime/declared.go b/internal/exec/runtime/declared.go similarity index 75% rename from internal/core/runtime/declared.go rename to internal/exec/runtime/declared.go index 71c4f9d525..48bc3d6c36 100644 --- a/internal/core/runtime/declared.go +++ b/internal/exec/runtime/declared.go @@ -3,10 +3,10 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // DeclaredReader reads feature values as the model declares them for a concrete @@ -17,6 +17,8 @@ type DeclaredReader struct { } // NewDeclaredReader creates a reader over a fresh, behavior-free runtime context. +// model carries the checker's argument typing, as NewModel requires: a feature +// valued by a call is read through it, and fails with ErrNoArgumentTyper without one. func NewDeclaredReader(model *semantics.Model, resolver *resolve.Resolver) *DeclaredReader { ctx := NewContext(NewModel(model, resolver), DefaultMaxSteps) ctx.declarative = true @@ -44,6 +46,29 @@ func (r *DeclaredReader) Read(sym *symbols.Symbol, name string) (Value, error) { return val, nil } +// Validate checks every assertion about the object the element declares, and the +// objects it holds, as the model declares them: ErrNotAnObject for an element +// with no object (a package, an attribute). +func (r *DeclaredReader) Validate(sym *symbols.Symbol, scopes []*symbols.Scope) (ValidationReport, error) { + if sym == nil { + return ValidationReport{}, fmt.Errorf("%w: no element to validate", ErrUnresolvedReference) + } + if err := RequireObject(sym); err != nil { + return ValidationReport{}, err + } + inst, err := r.objectOf(sym) + if err != nil { + return ValidationReport{}, err + } + return r.ctx.ValidateObject(inst, scopes) +} + +// VerificationVerdictsIn runs the verification cases in scopes whose objective +// verifies req, in the reader's behavior-free context. +func (r *DeclaredReader) VerificationVerdictsIn(scopes []*symbols.Scope, req *symbols.Symbol) []VerificationVerdict { + return r.ctx.VerificationVerdictsIn(scopes, req) +} + // objectOf materializes the element once, so all its features read from one object. func (r *DeclaredReader) objectOf(sym *symbols.Symbol) (*Instance, error) { if sym == nil { diff --git a/internal/core/runtime/declared_test.go b/internal/exec/runtime/declared_test.go similarity index 83% rename from internal/core/runtime/declared_test.go rename to internal/exec/runtime/declared_test.go index 7819489023..ba020235fa 100644 --- a/internal/core/runtime/declared_test.go +++ b/internal/exec/runtime/declared_test.go @@ -5,10 +5,10 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) const declaredReaderModel = ` @@ -54,6 +54,10 @@ package DerivedRepro { calc scaled { in factor : Real; return : Real = factor * 2.0; } attribute n : Real = scaled(); } + part def Invoking { + calc twice { in x : Real; return : Real = x * 2.0; } + attribute n : Real = twice(3.0); + } part def Behaving { attribute started : Boolean = false; exhibit state running { @@ -203,7 +207,7 @@ func TestDeclaredReaderAgreesWithTheRun(t *testing.T) { pkg, _ := root.LookupLocal("DerivedRepro") rocket := symbolAt(t, resolver, pkg.Scope, "rocket") - ctx := NewContext(NewModel(model, resolver), DefaultMaxSteps) + ctx := NewContext(typedModel(model, resolver), DefaultMaxSteps) inst, err := ctx.Instantiate(rocket) if err != nil { t.Fatalf("Instantiate: %v", err) @@ -222,3 +226,27 @@ func TestDeclaredReaderAgreesWithTheRun(t *testing.T) { t.Errorf("declared %s, run %s, want both 4570000 [kg]", declared, ran) } } + +// A feature valued by a call reads through the model's argument typing: the +// fixture's typed model evaluates it, and a fresh semantic model with none +// installed fails with ErrNoArgumentTyper rather than selecting untyped. +func TestDeclaredReaderSelectsCallsThroughTheArgumentTyper(t *testing.T) { + reader, resolver, scope := declaredReaderFixture(t) + invoking := symbolAt(t, resolver, scope, "Invoking") + val, err := reader.Read(invoking, "n") + if err != nil { + t.Fatalf("Invoking.n: %v", err) + } + if val.Kind != ValConst || val.Const.Kind != semantics.ValReal || val.Const.Real != 6.0 { + t.Errorf("Invoking.n = %s, want 6.0", describeValue(val)) + } + + untyped := semantics.NewModel(resolver) + if untyped.HasArgumentTyper() { + t.Fatal("a fresh semantic model must not carry an argument typer") + } + _, err = NewDeclaredReader(untyped, resolver).Read(invoking, "n") + if !errors.Is(err, ErrNoArgumentTyper) { + t.Fatalf("Invoking.n over an untyped model: err = %v, want ErrNoArgumentTyper", err) + } +} diff --git a/internal/core/runtime/dependents.go b/internal/exec/runtime/dependents.go similarity index 100% rename from internal/core/runtime/dependents.go rename to internal/exec/runtime/dependents.go diff --git a/internal/core/runtime/derived_invalidation_bench_test.go b/internal/exec/runtime/derived_invalidation_bench_test.go similarity index 89% rename from internal/core/runtime/derived_invalidation_bench_test.go rename to internal/exec/runtime/derived_invalidation_bench_test.go index 909586cfb8..c3f36090a7 100644 --- a/internal/core/runtime/derived_invalidation_bench_test.go +++ b/internal/exec/runtime/derived_invalidation_bench_test.go @@ -5,12 +5,12 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // benchRuntime builds a runtime over the standard library and src. @@ -21,7 +21,7 @@ func benchRuntime(b *testing.B, src string) (*Context, *symbols.Index) { idx.ExpandWildcardImports() resolver := resolve.New(idx) model := semantics.NewModel(resolver) - return NewContext(NewModel(model, resolver), 1_000_000), idx + return NewContext(typedModel(model, resolver), 1_000_000), idx } func benchInstantiate(b *testing.B, ctx *Context, idx *symbols.Index, qualified string) *Instance { diff --git a/internal/core/runtime/derived_invalidation_test.go b/internal/exec/runtime/derived_invalidation_test.go similarity index 99% rename from internal/core/runtime/derived_invalidation_test.go rename to internal/exec/runtime/derived_invalidation_test.go index 93e3eb8211..16316ebe4b 100644 --- a/internal/core/runtime/derived_invalidation_test.go +++ b/internal/exec/runtime/derived_invalidation_test.go @@ -494,7 +494,7 @@ func TestSelfReferentialDerivedValueStaysACycle(t *testing.T) { if !errors.Is(err, ErrCyclicFeatureValue) { t.Fatalf("GetFeatureValue(x) error = %v, want ErrCyclicFeatureValue", err) } - case <-time.After(5 * time.Second): + case <-watchdog(5 * time.Second): t.Fatal("GetFeatureValue(x) hung") } if deps := loop.FeatureValues["x"].dependents; len(deps) != 0 { diff --git a/internal/core/runtime/describe.go b/internal/exec/runtime/describe.go similarity index 95% rename from internal/core/runtime/describe.go rename to internal/exec/runtime/describe.go index de07f6482b..848d325ae2 100644 --- a/internal/core/runtime/describe.go +++ b/internal/exec/runtime/describe.go @@ -3,8 +3,8 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // notOfKind reports that sym declares something other than the kind asked of diff --git a/internal/exec/runtime/describe_test.go b/internal/exec/runtime/describe_test.go new file mode 100644 index 0000000000..53c8fe9bbf --- /dev/null +++ b/internal/exec/runtime/describe_test.go @@ -0,0 +1,54 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestDescribeOperandEnumerationLiteral: an operator diagnostic names the +// literal an operand is, so the reader sees which enumeration it came from. +func TestDescribeOperandEnumerationLiteral(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, ` +package D { + enum def Color { red; green; } +} +attribute test = D::Color::red; +`) + ctx := NewContext(typedModel(model, resolver), 1000) + sym := resolveSymbol(t, root, "test") + val, err := ctx.Eval(sym.Decl.(*ast.Usage).Value) + if err != nil { + t.Fatalf("eval: %v", err) + } + if got := describeOperand(val); got != "the enumeration literal Color::red" { + t.Errorf("describeOperand = %q", got) + } + // A literal that was never resolved is still described, never printed blank. + if got := describeOperand(Value{Kind: ValEnumLiteral}); got != "the enumeration literal " { + t.Errorf("describeOperand of an unresolved literal = %q", got) + } +} + +// A diagnostic names a declaration as the notation writes it, since several +// spellings share one kind. +func TestDescribeDeclNamesTheWrittenDeclaration(t *testing.T) { + _, _, root := parseAndBuildModel(t, ` +datatype T; +part def Wheel; +part w : Wheel; +`) + want := map[string]string{ + "T": "a datatype usage", + "Wheel": "a part def", + "w": "a part usage", + } + for name, description := range want { + if got := describeDecl(resolveSymbol(t, root, name).Decl); got != description { + t.Errorf("describeDecl(%s) = %q, want %q", name, got, description) + } + } + if got := describeDecl(nil); got != "nothing" { + t.Errorf("describeDecl(nil) = %q, want %q", got, "nothing") + } +} diff --git a/internal/core/runtime/dispatch_order_test.go b/internal/exec/runtime/dispatch_order_test.go similarity index 81% rename from internal/core/runtime/dispatch_order_test.go rename to internal/exec/runtime/dispatch_order_test.go index 832cf8d8b0..e968a1a3fa 100644 --- a/internal/core/runtime/dispatch_order_test.go +++ b/internal/exec/runtime/dispatch_order_test.go @@ -4,12 +4,12 @@ import ( "fmt" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // dispatchOrderRun advances one machine, whose two regions each arm a timer // falling due at t=2, under policy; it returns the value the effects left in -// `last` and the run's choices. +// `last` and the choices of the machine's entry and of the advance, in order. func dispatchOrderRun(t *testing.T, policy SchedulePolicy) (int64, []ChoicePoint) { t.Helper() idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` @@ -45,6 +45,7 @@ func dispatchOrderRun(t *testing.T, policy SchedulePolicy) (int64, []ChoicePoint if err != nil { t.Fatalf("create machine: %v", err) } + choices := ctx.Choices() if _, err := ctx.Advance(3); err != nil { t.Fatalf("%s: Advance: %v", policy, err) } @@ -52,7 +53,7 @@ func dispatchOrderRun(t *testing.T, policy SchedulePolicy) (int64, []ChoicePoint if last.Kind != ValConst { t.Fatalf("%s: last = %v, want an integer", policy, last) } - return last.Const.Int, ctx.Choices() + return last.Const.Int, append(choices, ctx.Choices()...) } // Two time triggers falling due at one instant are a dispatch-order choice: the @@ -67,10 +68,10 @@ func TestDispatchOrderChoice(t *testing.T) { if last != tc.last { t.Errorf("%s: last = %d, want %d (arrival order kept)", tc.policy, last, tc.last) } - if len(choices) != 1 { - t.Fatalf("%s: choices = %v, want the one dispatch-order choice", tc.policy, choices) + if len(choices) != 2 || choices[0].Kind != ChoiceEntryOrder { + t.Fatalf("%s: choices = %v, want the regions' entry order and the one dispatch-order choice", tc.policy, choices) } - c := choices[0] + c := choices[1] if c.Kind != ChoiceDispatchOrder || c.Where != "events at t=2.0" || c.Taken != 0 { t.Errorf("%s: choice = %+v, want a dispatch order at t=2.0 taking the first", tc.policy, c) } @@ -95,8 +96,8 @@ func TestDispatchOrderChoice(t *testing.T) { seen := map[int64]bool{} for seed := 0; seed < 16; seed++ { last, choices := dispatchOrderRun(t, mustPolicy(t, fmt.Sprintf("seed:%d", seed))) - if len(choices) != 1 || choices[0].Kind != ChoiceDispatchOrder { - t.Fatalf("seed:%d: choices = %v, want one dispatch-order choice", seed, choices) + if len(choices) != 2 || choices[1].Kind != ChoiceDispatchOrder { + t.Fatalf("seed:%d: choices = %v, want the entry order and one dispatch-order choice", seed, choices) } seen[last] = true } @@ -109,13 +110,13 @@ func TestDispatchOrderChoice(t *testing.T) { // naming an event not due. func TestDispatchOrderReplay(t *testing.T) { _, choices := dispatchOrderRun(t, mustPolicy(t, "seed:1")) - witness := choices[0].Choice() + entry, witness := choices[0].Choice(), choices[1].Choice() witness.Taken, witness.Took = 1, witness.Among[1] - last, replayed := dispatchOrderRun(t, ReplayPolicy([]ChoiceTaken{witness})) + last, replayed := dispatchOrderRun(t, ReplayPolicy([]ChoiceTaken{entry, witness})) if last != 1 { t.Errorf("replaying the second-first order left last = %d, want 1", last) } - if len(replayed) != 1 || replayed[0].Taken != 1 { - t.Errorf("replay choices = %v, want the dispatch order taking the second", replayed) + if len(replayed) != 2 || replayed[1].Taken != 1 { + t.Errorf("replay choices = %v, want the entry order and the dispatch order taking the second", replayed) } } diff --git a/internal/core/runtime/division_test.go b/internal/exec/runtime/division_test.go similarity index 92% rename from internal/core/runtime/division_test.go rename to internal/exec/runtime/division_test.go index 1308b20439..004d4dcefa 100644 --- a/internal/core/runtime/division_test.go +++ b/internal/exec/runtime/division_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // quotientModel divides parameters, so the quotient is the runtime's and not @@ -15,7 +15,7 @@ const quotientModel = `calc def quotient { in a : Integer; in b : Integer; retur // Rational for every sign combination, exact or not, never truncated. func TestWholeNumberQuotientIsRational(t *testing.T) { model, resolver, root := parseAndBuildModel(t, quotientModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) quotient := resolveSymbol(t, root, "quotient") cases := []struct { @@ -46,7 +46,7 @@ func TestWholeNumberQuotientIsRational(t *testing.T) { // exact rationals rounded once, not through pre-rounded float64 operands. func TestQuotientBeyondFloatExactRangeRoundsOnce(t *testing.T) { model, resolver, root := parseAndBuildModel(t, quotientModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) quotient := resolveSymbol(t, root, "quotient") cases := []struct { @@ -74,7 +74,7 @@ func TestQuotientBeyondFloatExactRangeRoundsOnce(t *testing.T) { // what a zero divisor is — an error, not an infinity. func TestWholeNumberDivisionByZeroIsReported(t *testing.T) { model, resolver, root := parseAndBuildModel(t, quotientModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) quotient := resolveSymbol(t, root, "quotient") got, err := ctx.InvokeCalc(quotient, []Value{constInt(7), constInt(0)}, root) @@ -88,7 +88,7 @@ func TestWholeNumberDivisionByZeroIsReported(t *testing.T) { func TestRemainderStaysInteger(t *testing.T) { const remainderModel = `calc def remainder { in a : Integer; in b : Integer; return : Integer = a % b; }` model, resolver, root := parseAndBuildModel(t, remainderModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) remainder := resolveSymbol(t, root, "remainder") cases := []struct { diff --git a/internal/exec/runtime/doc.go b/internal/exec/runtime/doc.go new file mode 100644 index 0000000000..9adc0086d8 --- /dev/null +++ b/internal/exec/runtime/doc.go @@ -0,0 +1,48 @@ +// Package runtime provides the SysML v2 execution runtime: expression +// evaluation, instance materialization, and KerML operator library. +// +// # Usage Example +// +// // Create the model-derived part once, then a runtime context over it +// model := runtime.NewModel(passes.NewTypedModel(resolver), resolver) +// model.SetExpressionParser(parser.ParseOneExpression) +// ctx := runtime.NewContext(model, runtime.DefaultMaxSteps) +// +// // Instantiate a part +// partSym := resolveSymbol(root, "MyCar") +// inst, err := ctx.Instantiate(partSym) +// if err != nil { +// log.Fatal(err) +// } +// +// // Evaluate an expression +// exprNode := parseExpression("1 + 2") +// result, err := ctx.Eval(exprNode) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(result.Const.Int) // 3 +// +// # Architecture +// +// The runtime is organized in three tiers: +// +// - Tier 1: Feature flattening (effective-feature lists per type) +// - Tier 2: Instance model (lazy feature value materialization, multiplicity-driven collections) +// - Tier 3: Expression evaluator (literals, operators, feature access, calc invocation, KerML builtins) +// +// Key types: +// - Context: Runtime execution context (ID allocator, instance registry, memoization) +// - Value: Runtime-evaluable value (int/real/bool/string/null/instance/Sequence/Set) +// - EffectiveFeature: One entry in a type's effective feature list (Tier 1 schema) +// - Instance: Runtime-materialized object with typed feature values +// - EvalContext: Lexical environment for evaluation (frame stack) +// +// # Integration +// +// - Consumes semantics.Model (inherits features, multiplicity, constant folding) +// - Gates on pass-validated models (LevelConstraint success) +// - One Context per workspace session (LSP/REPL lifetime) +// +// Behavioral simulation (actions, state machines) is out of scope (future Tiers 4–5). +package runtime diff --git a/internal/exec/runtime/draw_policy.go b/internal/exec/runtime/draw_policy.go new file mode 100644 index 0000000000..6ec9b39c88 --- /dev/null +++ b/internal/exec/runtime/draw_policy.go @@ -0,0 +1,136 @@ +package runtime + +import ( + "errors" + "fmt" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// DrawPolicy is how a run resolves the draws of the RandomFunctions library: at +// random from the seeded stream, or at a fixed point of each call's distribution. +// Weighted decisions are not durations, so they draw the same under every policy. +type DrawPolicy int + +const ( + // DrawRandom draws from the seeded modeled stream, or a witness's recorded draws. + DrawRandom DrawPolicy = iota + // DrawMin yields the least value each call can draw. + DrawMin + // DrawMax yields the greatest value each call can draw. + DrawMax + // DrawAverage yields the mean of each call's distribution. + DrawAverage +) + +// DrawPolicyNames lists the spellings ParseDrawPolicy accepts, for usage text. +var DrawPolicyNames = []string{"random", "min", "max", "average"} + +// String is the policy's spelling, as ParseDrawPolicy reads it. +func (p DrawPolicy) String() string { + switch p { + case DrawMin: + return "min" + case DrawMax: + return "max" + case DrawAverage: + return "average" + } + return "random" +} + +// Fixed reports whether the policy resolves every draw without a random stream. +func (p DrawPolicy) Fixed() bool { return p != DrawRandom } + +// ErrDrawPolicy is the typed error every unreadable draw policy spelling wraps. +var ErrDrawPolicy = errors.New("invalid draw policy") + +// ParseDrawPolicy reads a policy spelling: one of DrawPolicyNames. +func ParseDrawPolicy(text string) (DrawPolicy, error) { + switch strings.TrimSpace(text) { + case "random": + return DrawRandom, nil + case "min": + return DrawMin, nil + case "max": + return DrawMax, nil + case "average": + return DrawAverage, nil + } + return DrawRandom, fmt.Errorf("%w: %q is not one of %s", ErrDrawPolicy, text, strings.Join(DrawPolicyNames, ", ")) +} + +// ErrDrawUnbounded is the typed error a fixed policy raises at a call whose +// distribution has no such point: a normal has neither a least nor a greatest value. +var ErrDrawUnbounded = errors.New("draw policy has no value for the call") + +// DrawUnboundedError names the call a fixed policy could not resolve. +type DrawUnboundedError struct { + What string + Policy DrawPolicy +} + +func (e *DrawUnboundedError) Error() string { + return fmt.Sprintf("%v: %s under %s: the distribution is unbounded", ErrDrawUnbounded, e.What, e.Policy) +} + +// Is makes every DrawUnboundedError match ErrDrawUnbounded. +func (e *DrawUnboundedError) Is(target error) bool { return target == ErrDrawUnbounded } + +// SetDrawPolicy fixes how the runs started from now on resolve their random draws; +// a run under way, paused or not, keeps the policy it started under. +func (ctx *Context) SetDrawPolicy(policy DrawPolicy) { + ctx.drawPolicy = policy +} + +// DrawPolicy is the policy the runs started from now on resolve their random draws under. +func (ctx *Context) DrawPolicy() DrawPolicy { + return ctx.drawPolicy +} + +// DrawPolicyTaken is the policy the last run's draws were resolved under: the +// witness's under a `replay` policy, whose recorded draws the run consumed, else +// the one the run started under, else the context's own before any run. +func (ctx *Context) DrawPolicyTaken() DrawPolicy { + if w, ok := ctx.schedule.Witness(); ok { + return w.DrawPolicy + } + if ctx.run != nil && ctx.run.scheduler != nil { + return ctx.run.scheduler.draws + } + return ctx.drawPolicy +} + +// fixedPoint is the value a fixed policy resolves the distribution to, if it has one. +func (d distribution) fixedPoint(policy DrawPolicy) (semantics.Value, bool) { + if d.fixed == nil { + return semantics.Value{}, false + } + return d.fixed(policy) +} + +// admitsUnder reports whether the distribution can yield v under policy: any value +// it draws at random, or exactly the fixed point a fixed policy resolves it to. +func (d distribution) admitsUnder(policy DrawPolicy, v semantics.Value) bool { + if !policy.Fixed() { + return d.admits(v) + } + point, ok := d.fixedPoint(policy) + return ok && point == v +} + +// realPoints is a fixed function over a bounded Real distribution with the mean given. +func realPoints(lo, hi, mean float64) func(DrawPolicy) (semantics.Value, bool) { + return func(policy DrawPolicy) (semantics.Value, bool) { + switch policy { + case DrawMin: + return drawnReal(lo), true + case DrawMax: + return drawnReal(hi), true + case DrawAverage: + return drawnReal(mean), true + } + return semantics.Value{}, false + } +} diff --git a/internal/exec/runtime/draw_policy_test.go b/internal/exec/runtime/draw_policy_test.go new file mode 100644 index 0000000000..a6ec7d192f --- /dev/null +++ b/internal/exec/runtime/draw_policy_test.go @@ -0,0 +1,390 @@ +package runtime + +import ( + "errors" + "math" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// drawPolicyModel calls every RandomFunctions function but normal once, and waits +// a drawn duration, so each policy resolves four draws. +const drawPolicyModel = ` +package test { + private import ScalarValues::*; + private import SI::*; + private import RandomFunctions::*; + action draw { + attribute u : Real = uniform(2.0, 6.0); + attribute n : Integer = uniformInteger(1, 6); + attribute tri : Real = triangular(1.0, 4.0, 10.0); + first start; + then action wait accept after uniform(1, 80) [s]; + then done; + } +}` + +// realOut is the Real an output holds. +func realOut(t *testing.T, out map[string]Value, name string) float64 { + t.Helper() + v, ok := out[name] + if !ok || v.Const.Kind != semantics.ValReal { + t.Fatalf("%s = %v, want a Real", name, v) + } + return v.Const.Real +} + +// formatDraws spells draws one per line, as a witness lists them. +func formatDraws(draws []DrawTaken) string { + var b strings.Builder + for _, d := range draws { + b.WriteString(d.String() + "\n") + } + return b.String() +} + +// runUnderDraws runs the action named on a fresh context under the draw policy, +// unseeded unless a seed is given. +func runUnderDraws(t *testing.T, m *exploreModel, name string, policy DrawPolicy, seed ...uint64) (*Context, map[string]Value, error) { + t.Helper() + ctx, _ := m.fresh() + ctx.SetDrawPolicy(policy) + if len(seed) > 0 { + ctx.SetModelSeed(seed[0]) + } + out, err := ctx.ExecuteAction(m.action(t, name)) + return ctx, out, err +} + +// A draw policy spells and parses as -draws takes it, random being the default +// and the only policy that is not fixed. +func TestDrawPolicySpellsAndParses(t *testing.T) { + for _, policy := range []DrawPolicy{DrawRandom, DrawMin, DrawMax, DrawAverage} { + parsed, err := ParseDrawPolicy(policy.String()) + if err != nil || parsed != policy { + t.Errorf("ParseDrawPolicy(%q) = %v, %v; want %v", policy.String(), parsed, err, policy) + } + if policy.Fixed() != (policy != DrawRandom) { + t.Errorf("%s.Fixed() = %v", policy, policy.Fixed()) + } + } + if _, err := ParseDrawPolicy("median"); !errors.Is(err, ErrDrawPolicy) || !strings.Contains(err.Error(), "random, min, max, average") { + t.Errorf("ParseDrawPolicy(median) = %v, want ErrDrawPolicy naming the policies", err) + } + if _, err := ParseDrawPolicy(""); !errors.Is(err, ErrDrawPolicy) { + t.Errorf("ParseDrawPolicy(\"\") = %v, want ErrDrawPolicy", err) + } + var ctx Context + if ctx.DrawPolicy() != DrawRandom { + t.Errorf("a fresh context draws by %s, want random", ctx.DrawPolicy()) + } +} + +// A fixed policy resolves every call to the point of its distribution the policy +// names, needs no seed, and records the draws it made as a witness lists them. +func TestFixedDrawPoliciesResolveEveryCallWithoutASeed(t *testing.T) { + m := parseLibraryModel(t, drawPolicyModel) + cases := []struct { + policy DrawPolicy + u, tri float64 + n int64 + wait string + }{ + {DrawMin, 2.0, 1.0, 1, "draw uniform(1, 80) = 1.0"}, + {DrawMax, 6.0, 10.0, 6, "draw uniform(1, 80) = 80.0"}, + {DrawAverage, 4.0, 5.0, 4, "draw uniform(1, 80) = 40.5"}, + } + for _, tc := range cases { + t.Run(tc.policy.String(), func(t *testing.T) { + ctx, out, err := runUnderDraws(t, m, "draw", tc.policy) + if err != nil { + t.Fatal(err) + } + if got := realOut(t, out, "u"); got != tc.u { + t.Errorf("u = %v, want %v", got, tc.u) + } + if got := realOut(t, out, "tri"); got != tc.tri { + t.Errorf("tri = %v, want %v", got, tc.tri) + } + if got := takenInt(t, out, "n"); got != tc.n { + t.Errorf("n = %d, want %d", got, tc.n) + } + draws := ctx.DrawsTaken() + if len(draws) != 4 || draws[3].String() != tc.wait { + t.Errorf("recorded %v, want four draws ending in %q", draws, tc.wait) + } + if ctx.DrawPolicyTaken() != tc.policy { + t.Errorf("the run drew by %s, want %s", ctx.DrawPolicyTaken(), tc.policy) + } + again, _, err := runUnderDraws(t, m, "draw", tc.policy, 5) + if err != nil { + t.Fatal(err) + } + if a, b := formatDraws(ctx.DrawsTaken()), formatDraws(again.DrawsTaken()); a != b { + t.Errorf("a seed changed the fixed draws:\n%s\n%s", a, b) + } + }) + } +} + +// The average of a uniformInteger is its midpoint, a half rounded toward hi, and +// stays exact at the ends of the Integer range; the average of a normal is its mean. +func TestAverageDrawsOfDiscreteAndUnboundedCalls(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute even : Integer = uniformInteger(1, 5); + attribute odd : Integer = uniformInteger(1, 6); + attribute negative : Integer = uniformInteger(-6, -1); + attribute wide : Integer = uniformInteger(-9223372036854775807, 9223372036854775807); + attribute g : Real = normal(12.0, 3.0); + first start; then done; + } + }`) + _, out, err := runUnderDraws(t, m, "draw", DrawAverage) + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]int64{"even": 3, "odd": 4, "negative": -3, "wide": 0} { + if got := takenInt(t, out, name); got != want { + t.Errorf("%s = %d, want %d", name, got, want) + } + } + if got := realOut(t, out, "g"); got != 12.0 { + t.Errorf("g = %v, want the mean 12", got) + } +} + +// Under random the policy changes nothing: the seeded draws are today's, and the +// witness carries no policy line. +func TestRandomDrawPolicyIsTheSeededStream(t *testing.T) { + m := parseLibraryModel(t, drawPolicyModel) + ctx, out, err := runAction(t, m, "draw", "declared", 7) + if err != nil { + t.Fatal(err) + } + random, again, err := runUnderDraws(t, m, "draw", DrawRandom, 7) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"u", "n", "tri"} { + if out[name].Const != again[name].Const { + t.Errorf("%s = %v under random, want %v", name, again[name], out[name]) + } + } + if u := realOut(t, out, "u"); u < 2 || u >= 6 { + t.Errorf("u = %v, want a draw in [2, 6)", u) + } + w := Witness{DrawPolicy: random.DrawPolicyTaken(), Draws: ctx.DrawsTaken(), Choices: ctx.ChoicesTaken()} + if strings.Contains(w.String(), drawPolicyPrefix) { + t.Errorf("a random run's witness names a policy:\n%s", w) + } +} + +// A witness of a fixed-policy run names the policy, reads back, and replays to the +// same values with no seed on the replaying context; a witness that names a policy +// after its draws, or names one twice, is refused with a typed error. +func TestWitnessCarriesTheDrawPolicy(t *testing.T) { + m := parseLibraryModel(t, drawPolicyModel) + ctx, out, err := runUnderDraws(t, m, "draw", DrawMax) + if err != nil { + t.Fatal(err) + } + w := Witness{DrawPolicy: ctx.DrawPolicyTaken(), Draws: ctx.DrawsTaken(), Choices: ctx.ChoicesTaken()} + text := w.String() + if !strings.HasPrefix(text, "draws by max\ndraw uniform(2.0, 6.0) = 6.0\n") { + t.Fatalf("the witness does not open with the policy and the first draw:\n%s", text) + } + parsed, err := ParseWitness(text) + if err != nil { + t.Fatalf("the witness does not read back: %v\n%s", err, text) + } + if parsed.DrawPolicy != DrawMax || formatDraws(parsed.Draws) != formatDraws(w.Draws) { + t.Fatalf("the witness reads back as %s over %s", parsed.DrawPolicy, formatDraws(parsed.Draws)) + } + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(parsed)) + got, err := replay.ExecuteAction(m.action(t, "draw")) + if err == nil { + err = replay.Unfollowed() + } + if err != nil { + t.Fatalf("replay: %v", err) + } + for _, name := range []string{"u", "n", "tri"} { + if got[name].Const != out[name].Const { + t.Errorf("replay gave %s = %v, want %v", name, got[name], out[name]) + } + } + if replay.DrawPolicyTaken() != DrawMax { + t.Errorf("the replay drew by %s, want the witness's max", replay.DrawPolicyTaken()) + } + + misplaced := strings.Replace(text, "draws by max\n", "", 1) + misplaced = strings.Replace(misplaced, "\nno choice points", "\ndraws by max\nno choice points", 1) + var parse *DrawParseError + if _, err := ParseWitness(misplaced); !errors.As(err, &parse) || !strings.Contains(err.Error(), "comes before the draws") { + t.Errorf("a policy after the draws parsed: %v", err) + } + if _, err := ParseWitness("draws by median\nno choice points\n"); !errors.As(err, &parse) || !errors.Is(err, ErrDrawPolicy) && !strings.Contains(err.Error(), "median") { + t.Errorf("an unknown policy parsed: %v", err) + } + for _, twice := range []string{ + "draws by min\ndraws by max\ndraw uniform(0.0, 1.0) = 1.0\nno choice points\n", + "draws by max\ndraws by max\ndraw uniform(0.0, 1.0) = 1.0\nno choice points\n", + "draws by random\ndraws by random\nno choice points\n", + } { + _, err := ParseWitness(twice) + if !errors.As(err, &parse) || !strings.Contains(err.Error(), "named twice") || parse.Line != 2 { + t.Errorf("a witness naming its policy twice parsed: %v\n%s", err, twice) + } + } +} + +// A replay admits a draw only where the witness's policy could have made it: at hi +// under max, though uniform never draws hi at random; not at the midpoint under +// max, though uniform draws it at random. +func TestReplayAdmitsDrawsUnderTheWitnessPolicy(t *testing.T) { + m := parseLibraryModel(t, drawingModel) + realValue := func(x float64) semantics.Value { return semantics.Value{Kind: semantics.ValReal, Real: x} } + integer := func(n int64) semantics.Value { return semantics.Value{Kind: semantics.ValInt, Int: n} } + cases := []struct { + name string + policy DrawPolicy + d, n semantics.Value + ok bool + reason string + }{ + {"max at the fixed points", DrawMax, realValue(1), integer(6), true, ""}, + {"min at the fixed points", DrawMin, realValue(0), integer(1), true, ""}, + {"average at the fixed points", DrawAverage, realValue(0.5), integer(4), true, ""}, + {"random at hi", DrawRandom, realValue(1), integer(6), false, "records 1.0, which the call cannot draw"}, + {"max off hi", DrawMax, realValue(0.5), integer(6), false, "records 0.5, which the call cannot draw under max"}, + {"max off the greatest integer", DrawMax, realValue(1), integer(5), false, "records 5, which the call cannot draw under max"}, + {"average off the midpoint", DrawAverage, realValue(0.5), integer(3), false, "records 3, which the call cannot draw under average"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := Witness{DrawPolicy: tc.policy, Draws: []DrawTaken{{What: "uniform(0.0, 1.0)", Value: tc.d}, {What: "uniformInteger(1, 6)", Value: tc.n}}} + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(w)) + _, err := replay.ExecuteAction(m.action(t, "draw")) + if err == nil { + err = replay.Unfollowed() + } + if tc.ok { + if err != nil { + t.Fatalf("replay refused a draw the policy makes: %v", err) + } + return + } + var refused *WitnessDrawError + if !errors.As(err, &refused) { + t.Fatalf("error = %v, want a WitnessDrawError", err) + } + if !strings.Contains(err.Error(), tc.reason) { + t.Errorf("error %q does not say %q", err, tc.reason) + } + }) + } +} + +// A weighted decision draws from the seed under every policy, and unseeded takes +// the most probable branch: the policy resolves durations, not decisions. +func TestWeightedDecisionsDrawTheSameUnderEveryPolicy(t *testing.T) { + m := parseLibraryModel(t, weightedRouteModel) + _, seeded, err := runAction(t, m, "route", "declared", 3) + if err != nil { + t.Fatal(err) + } + for _, policy := range []DrawPolicy{DrawMin, DrawMax, DrawAverage} { + _, out, err := runUnderDraws(t, m, "route", policy, 3) + if err != nil { + t.Fatalf("%s: %v", policy, err) + } + if got, want := takenInt(t, out, "taken"), takenInt(t, seeded, "taken"); got != want { + t.Errorf("%s under seed 3 took %d, want the random policy's %d", policy, got, want) + } + ctx, out, err := runUnderDraws(t, m, "route", policy) + if err != nil { + t.Fatalf("%s unseeded: %v", policy, err) + } + if got := takenInt(t, out, "taken"); got != 1 { + t.Errorf("%s unseeded took %d, want the 0.7 branch", policy, got) + } + if choices := ctx.Choices(); len(choices) != 1 || choices[0].Drawn { + t.Errorf("%s unseeded: choices %v, want one undrawn decision", policy, choices) + } + } +} + +// A witness naming a fixed policy and recording no draw leaves them to the policy: the +// replay resolves each call to its fixed point, as the run did, and records what it took; +// a partial record, or none under random, is still a witness the replay refuses. +func TestFixedPolicyWitnessWithoutDrawsReplaysByThePolicy(t *testing.T) { + m := parseLibraryModel(t, drawPolicyModel) + for _, tc := range []struct { + policy DrawPolicy + u, tri float64 + n int64 + }{{DrawMin, 2, 1, 1}, {DrawMax, 6, 10, 6}, {DrawAverage, 4, 5, 4}} { + w, err := ParseWitness(drawPolicyPrefix + tc.policy.String() + "\nno choice points\n") + if err != nil { + t.Fatal(err) + } + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(w)) + out, err := replay.ExecuteAction(m.action(t, "draw")) + if err == nil { + err = replay.Unfollowed() + } + if err != nil { + t.Fatalf("%s: replay refused a witness leaving its draws to the policy: %v", tc.policy, err) + } + if u, tri, n := realOut(t, out, "u"), realOut(t, out, "tri"), takenInt(t, out, "n"); u != tc.u || tri != tc.tri || n != tc.n { + t.Errorf("%s replayed u = %v, tri = %v, n = %d, want %v, %v, %d", tc.policy, u, tri, n, tc.u, tc.tri, tc.n) + } + if replay.DrawPolicyTaken() != tc.policy || len(replay.DrawsTaken()) != 4 { + t.Errorf("%s replay drew by %s over %s, want the policy and its four draws", tc.policy, replay.DrawPolicyTaken(), formatDraws(replay.DrawsTaken())) + } + } + partial, err := ParseWitness("draws by max\ndraw uniform(2.0, 6.0) = 6.0\nno choice points\n") + if err != nil { + t.Fatal(err) + } + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(partial)) + _, err = replay.ExecuteAction(m.action(t, "draw")) + var refused *WitnessDrawError + if !errors.As(err, &refused) || !strings.Contains(err.Error(), "records no draw left for it") { + t.Fatalf("a witness recording one draw of four replayed: %v", err) + } +} + +// The clock of a run whose durations are all drawn is deterministic under a +// fixed policy: the same duration every run, at the point the policy names. +func TestFixedDrawPolicyMakesTheClockDeterministic(t *testing.T) { + m := parseLibraryModel(t, drawPolicyModel) + for _, tc := range []struct { + policy DrawPolicy + clock float64 + }{{DrawMin, 1}, {DrawMax, 80}, {DrawAverage, 40.5}} { + var clocks []float64 + for i := 0; i < 3; i++ { + ctx, _, err := runUnderDraws(t, m, "draw", tc.policy) + if err != nil { + t.Fatal(err) + } + clocks = append(clocks, ctx.Clock().Now()) + } + for _, c := range clocks { + if math.Abs(c-tc.clock) > 1e-9 { + t.Errorf("%s: the clock ended at %v, want %v every run (%v)", tc.policy, c, tc.clock, clocks) + } + } + } +} diff --git a/internal/core/runtime/element_budget_test.go b/internal/exec/runtime/element_budget_test.go similarity index 98% rename from internal/core/runtime/element_budget_test.go rename to internal/exec/runtime/element_budget_test.go index 570214c5ed..aa3656aca7 100644 --- a/internal/core/runtime/element_budget_test.go +++ b/internal/exec/runtime/element_budget_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // evalUnderElementBudget evaluates expr with room for maxElements materialized @@ -145,7 +145,7 @@ func TestElementBudgetIsReleasedByEveryStep(t *testing.T) { // session evaluating many collections is not stopped by the ones before. func TestElementBudgetIsPerRun(t *testing.T) { model, resolver, _ := parseAndBuildModel(t, `part def Simple {}`) - ctx := NewContext(NewModel(model, resolver), DefaultMaxSteps) + ctx := NewContext(typedModel(model, resolver), DefaultMaxSteps) ctx.maxElements = 4 for i := 0; i < 3; i++ { diff --git a/internal/core/runtime/empty_aggregate_test.go b/internal/exec/runtime/empty_aggregate_test.go similarity index 98% rename from internal/core/runtime/empty_aggregate_test.go rename to internal/exec/runtime/empty_aggregate_test.go index 838af229bc..3ce22478eb 100644 --- a/internal/core/runtime/empty_aggregate_test.go +++ b/internal/exec/runtime/empty_aggregate_test.go @@ -4,9 +4,9 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // emptyAggregateModel declares collections of every kind an aggregate is taken diff --git a/internal/exec/runtime/errors.go b/internal/exec/runtime/errors.go new file mode 100644 index 0000000000..56174c32b9 --- /dev/null +++ b/internal/exec/runtime/errors.go @@ -0,0 +1,687 @@ +package runtime + +import ( + "errors" + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +var ( + // ErrStepLimitExceeded is returned when the evaluation step counter exceeds maxSteps. + ErrStepLimitExceeded = errors.New("evaluation step limit exceeded") + + // ErrElementLimitExceeded is returned when the collection elements one run + // materializes exceed maxElements. It is a bound on memory rather than on + // work, so it is its own error and its own budget. + ErrElementLimitExceeded = errors.New("collection element limit exceeded") + + // ErrInstanceLimitExceeded is returned when materializing one more object + // would take the context past the bound SetMaxInstances set. + ErrInstanceLimitExceeded = errors.New("object limit exceeded") + + // ErrUnresolvedReference is returned when a feature reference cannot be resolved. + ErrUnresolvedReference = errors.New("unresolved reference") + + // ErrAmbiguousReference is returned when a qualified name names several elements. + ErrAmbiguousReference = errors.New("ambiguous reference") + + // ErrTypeMismatch is returned when an operation receives a value of unexpected type. + ErrTypeMismatch = errors.New("type mismatch") + + // ErrDivisionByZero is returned when a division or remainder has a zero + // divisor. It is the answer to the expression, not a missing declaration. + ErrDivisionByZero = semantics.ErrDivisionByZero + + // ErrMultiplicityViolation is returned when a feature value access/assignment violates multiplicity bounds. + ErrMultiplicityViolation = errors.New("multiplicity violation") + + // ErrUniquenessViolation is returned when a value written to a unique feature repeats one of its values. + ErrUniquenessViolation = errors.New("uniqueness violation") + + // ErrUninitializedFeatureValue is returned when accessing a feature value that has no value and no default. + ErrUninitializedFeatureValue = errors.New("uninitialized feature value") + + // ErrBindingConflict is returned when two binding ends hold unequal values. + ErrBindingConflict = errors.New("binding conflict") + + // ErrBindingCycle is returned when a binding component has no value. + ErrBindingCycle = errors.New("binding cycle") + + // ErrBindingEnd is returned when a binding endpoint cannot be resolved to a feature. + ErrBindingEnd = errors.New("binding end cannot be resolved") + + // ErrNotACalc is returned when a calc invocation targets a symbol that is + // not a calc definition or usage. + ErrNotACalc = errors.New("not a calc") + + // ErrNotAFunction is returned when a value that is no function is called, + // or is bound where a calc-typed feature needs one. + ErrNotAFunction = errors.New("not a function") + + // ErrNotAConstraint is returned when a symbol asked to be evaluated as a + // constraint declares something else. It is a usage error about the request, + // not a verdict about the model, so callers can tell the two apart. + ErrNotAConstraint = errors.New("not a constraint") + + // ErrNotARequirement is returned when a symbol asked to be evaluated as a + // requirement declares something else. Like ErrNotAConstraint it reports the + // request, not the model. + ErrNotARequirement = errors.New("not a requirement") + + // ErrNotAnObject is returned when a symbol asked to be validated as an object + // has no objects: a package, an attribute, a comment. It reports the request. + ErrNotAnObject = errors.New("not an object") + + // ErrNotAnAnalysis is returned when a symbol asked for its objectives or + // asked to be run is not a case whose body runs. Like ErrNotAConstraint it + // reports the request, not the model. + ErrNotAnAnalysis = errors.New("not an analysis case") + + // ErrNotAVerification is returned when a symbol asked for a verification + // verdict is not a verification case definition or usage. + ErrNotAVerification = errors.New("not a verification case") + + // ErrCalcArity is returned when a calc invocation passes more arguments than + // the calc declares input parameters. + ErrCalcArity = errors.New("calc argument count mismatch") + + // ErrUnboundParameter is returned when a calc input parameter receives + // neither an argument nor a declared default. + ErrUnboundParameter = errors.New("unbound parameter") + + // ErrPerformerNotObject is returned when the operand of a `part.callee` performance + // denotes no single object for the callee to run as. + ErrPerformerNotObject = errors.New("performer is not an object") + + // ErrUnknownParameter is returned when a named argument does not name any + // input parameter of the invoked calc. + ErrUnknownParameter = errors.New("unknown parameter") + + // ErrUnknownActionInput is returned when a supplied input names no parameter + // or attribute of the action performed. + ErrUnknownActionInput = errors.New("unknown action input") + + // ErrOutputActionInput is returned when a supplied input names a parameter + // the action only writes back (`out`), which a caller does not seed. + ErrOutputActionInput = errors.New("output action parameter given as input") + + // ErrNoResultExpression is returned when a calc body declares no return + // expression, directly or by inheritance. + ErrNoResultExpression = errors.New("no result expression") + + // ErrConflictingResultExpressions is returned when a calc or constraint owns or + // inherits more than one result expression (KerML 8.3.4.8); no body is chosen. + ErrConflictingResultExpressions = errors.New("more than one result expression, owned or inherited") + + // ErrUnsupportedOperator is returned when an operator has no runtime + // evaluation, so an expression naming it fails rather than yielding nothing. + ErrUnsupportedOperator = errors.New("unsupported operator") + + // ErrUnresolvedType is returned when a type classification operand names no + // resolvable type. + ErrUnresolvedType = errors.New("unresolved type") + + // ErrUnboundedExtent is returned when `all T` names a type whose instances no + // run enumerates: a data type that is not an enumeration (`all Integer`, `all Point`). + ErrUnboundedExtent = errors.New("unbounded extent") + + // ErrExtentUnavailable is returned when `all T` would have to count objects the run cannot + // denote (a namespace-level usage of several occurrences) or cannot make without recursing. + ErrExtentUnavailable = errors.New("extent unavailable") + + // ErrUndeterminedValueType is returned when a value classification has no + // direct runtime type to compare. + ErrUndeterminedValueType = errors.New("value type cannot be determined") + + // ErrUndecidedClassification is returned when a cast reaches a value whose + // classification by a type narrower than the value's own the value does not + // settle, so the cast fails rather than dropping a value that may be one. + ErrUndecidedClassification = errors.New("classification of a value cannot be decided") + + // ErrCalcNoReturn is returned when a calc body runs to its end without + // returning: it computed no result, which is not the same as a null one. + ErrCalcNoReturn = errors.New("calculation returned no value") + + // ErrCalcSideEffect is returned when a calc body states an effect on the + // world outside it — send, perform, accept, terminate. A calculation + // computes a value, so an effect is rejected rather than performed. + ErrCalcSideEffect = errors.New("side effect in a calculation body") + + // ErrCalcExternalAssignment is returned when a calc body assigns to a name it + // does not declare itself, which would make the calculation impure. + ErrCalcExternalAssignment = errors.New("assignment outside the calculation body") + + // ErrStatementNotExecutable is returned when a body reaches a member the + // lowering marked as not executable in that kind of body (lower.Unsupported). + ErrStatementNotExecutable = errors.New("statement not executable") + + // ErrCalcUsageRecursion is returned when a calc or analysis usage's body reads + // the usage itself: bound once, it would run itself without end. + ErrCalcUsageRecursion = errors.New("a calc usage runs itself") + + // ErrCaseReadWaits is returned when an analysis or verification case whose + // outputs an expression reads waits on the clock or for a message: the read + // takes the case whole, and only a case performed as a step pauses the body. + ErrCaseReadWaits = errors.New("a case read as a feature waits") + + // ErrReturnOutsideCalc is returned when a `return` is executed by a host that + // has no result to return, an action node's body. + ErrReturnOutsideCalc = errors.New("'return' outside a calculation body") + + // ErrAcceptDeadlock is returned when an action can no longer progress + // because every token it has left is parked at an accept, so no token can + // post the message any of them waits for. An accept suspends the action + // rather than failing, so this is how a suspension that can never end is + // reported instead of hanging. + ErrAcceptDeadlock = errors.New("accept deadlock") + + // ErrActionDeadlock is returned when action tokens cannot make progress. + ErrActionDeadlock = errors.New("action deadlock") + + // ErrTerminateTarget is returned when a `terminate` names no ongoing performance + // of an action node in a flow enclosing it, and no occurrence either. + ErrTerminateTarget = errors.New("terminate names nothing to end") + + // ErrPerformanceEnded is returned when a `terminate` names a performance that + // has already ended, by completing or by an earlier terminate. + ErrPerformanceEnded = errors.New("performance already ended") + + // ErrTerminateOccurrence is returned when a `terminate` names a value that is no + // occurrence, or an occurrence that is not ongoing; it wraps the lifetime error. + ErrTerminateOccurrence = errors.New("occurrence cannot be terminated") + + // ErrExecutorReleased is returned when a released executor is stepped. + ErrExecutorReleased = errors.New("executor released") + + // ErrInvalidActionFlow is returned for a structurally invalid action graph. + ErrInvalidActionFlow = errors.New("invalid action flow") + + // ErrNoEnabledSuccession is returned when a decision can select no branch. + ErrNoEnabledSuccession = errors.New("no enabled succession") + + // ErrNegativeDuration is returned when a delay — an `accept after`, a time + // transition's or an advance of the clock — is negative, infinite, no number + // at all, or leads past the last instant the clock can hold: the clock never + // runs backwards and always reads a finite instant. + ErrNegativeDuration = errors.New("negative duration") + + // ErrNothingDue is returned when one step is asked of an executor whose only + // remaining work waits on the clock for an instant it has not reached. + ErrNothingDue = errors.New("nothing due at the current instant") + + // ErrTimeTriggerType is returned when a time trigger's argument is declared as + // no value of the type the trigger takes — the judgement validation makes of it. + ErrTimeTriggerType = errors.New("time trigger argument of the wrong type") + + // ErrActionResultParameter is returned when an action to perform declares a + // `return` parameter, which only a function or expression owns. + ErrActionResultParameter = errors.New("action declares a return parameter") + + // ErrCalcRecursionLimit is returned when calc invocation nests deeper than + // the run's calc depth budget, which an unbounded recursion would otherwise + // do until the process ran out of stack. + ErrCalcRecursionLimit = errors.New("calc recursion limit exceeded") + + // ErrActionStepLimitExceeded is returned when an action executor exceeds + // its token-flow step budget. + ErrActionStepLimitExceeded = errors.New("action step limit exceeded") + + // ErrStateEventLimitExceeded is returned when state processing exceeds its + // event budget. + ErrStateEventLimitExceeded = errors.New("state event limit exceeded") + + // ErrNoInitialState is returned when a state machine initializes with no + // entry into its states: it states no `entry; then ;`. + ErrNoInitialState = errors.New("no initial state found") + + // ErrNoEntryTransitionHolds is returned when a body's guarded entry transitions + // (`entry; if c then s;`) all have false guards, so it has no state to start in. + ErrNoEntryTransitionHolds = errors.New("no entry transition holds") + + // ErrHistoryWithoutEntry is returned when a transition reaches a history + // pseudostate that has nothing to restore and nothing to enter instead: its + // owner was never left, the history has no default transition, and the owner + // declares no entry transition of its own. + ErrHistoryWithoutEntry = errors.New("history has no configuration to restore and no entry to fall back on") + + // ErrChoiceWithoutBranch is returned when a compound transition reaches a + // choice pseudostate none of whose outgoing guards holds against the data as + // the segments into it left it. + ErrChoiceWithoutBranch = errors.New("choice has no enabled outgoing transition") + + // ErrStatePerformanceOccurrence is returned when an exhibited machine cannot + // read or write the occurrence of its state usage. + ErrStatePerformanceOccurrence = errors.New("state performance occurrence unavailable") + + // ErrActionPerformanceOccurrence is returned when a performed action cannot + // read or write the occurrence of its action usage. + ErrActionPerformanceOccurrence = errors.New("action performance occurrence unavailable") + + // ErrDoStepLimitExceeded is returned when a state do behavior exceeds its + // action-step budget. + ErrDoStepLimitExceeded = errors.New("state do-step limit exceeded") + + // ErrStateBehaviorWaits is returned when an entry or exit behavior, or a + // transition effect, waits for the clock: those are performed whole at the + // instant they are triggered, and only a do behavior pauses on the clock. + ErrStateBehaviorWaits = errors.New("state behavior waits for the clock") + + // ErrActionArity is returned when an action invocation passes more + // positional arguments than the action declares input parameters. + ErrActionArity = errors.New("action argument count mismatch") + + // ErrDuplicateArgument is returned when an action invocation binds one input + // parameter twice: by two named arguments, or by a positional and a named one. + ErrDuplicateArgument = errors.New("argument bound more than once") + + // ErrOperationArity is returned when an operation invocation passes more + // positional arguments than the operation declares input parameters. + ErrOperationArity = errors.New("operation argument count mismatch") + + // ErrMixedArguments is returned when an operation invocation gives positional and + // named arguments together: an argument list is one form or the other (KerML 8.2.5.8.3). + ErrMixedArguments = errors.New("positional and named arguments mixed") + + // ErrNodeNotPerformed is returned when a pin of an action node is read before + // any performance of the node has started. + ErrNodeNotPerformed = errors.New("action node read before it is performed") + + // ErrNodePin is returned when a pin read, flow, or binding names a feature the + // action node does not declare, or the node's result where it has none. + ErrNodePin = errors.New("action node pin not declared") + + // ErrFlowSource is returned when a flow's source completes with its pin holding + // no value to carry: a streaming source that never wrote, a succession source that produced nothing. + ErrFlowSource = errors.New("flow source produced no value") + + // ErrStreamUnreceived is returned when a streaming flow carried a value after its + // target's last performance ended, so no performance of the target ever received it. + ErrStreamUnreceived = errors.New("streamed value reached no performance of its target") + + // ErrStreamCycle is returned when streaming flows lead a value written to a pin back + // to that pin while the write is still being carried on. + ErrStreamCycle = errors.New("streaming flows form a cycle") + + // ErrViolated is returned when an asserted constraint or a required + // condition evaluates to false. It is a verdict about the model, not a + // failure to evaluate, so callers can tell the two apart. + ErrViolated = errors.New("evaluated to false") + + // ErrNoValue is returned when a feature a condition names carries no value: + // neither a feature value on the object being checked nor a declared default. + ErrNoValue = errors.New("no value") + + // ErrNoConditions is returned when a constraint or requirement carries no + // condition to evaluate: reporting a verdict would claim a check that never ran. + ErrNoConditions = errors.New("no condition to evaluate") + + // ErrStatementNotExecuted is returned when a constraint body states an action + // statement: the evaluator does not run it, so a verdict would ignore it. + ErrStatementNotExecuted = errors.New("statement in a constraint body is not executed by OpenSysML") + + // ErrUnboundSubject is returned when a condition reads a subject nothing + // supplied: the check is about no object, so it reaches no verdict. + ErrUnboundSubject = errors.New("subject is unbound") + + // ErrCyclicFeatureValue is returned when a feature value's default value depends, directly or + // through other feature values, on the one being computed. + ErrCyclicFeatureValue = errors.New("cyclic feature value dependency") + + // ErrConnectorEnd is returned when a connector cannot be attached to the + // features its ends name: an end naming nothing reachable from the object + // owning the connector, or one carrying no value. A connector whose ends + // cannot be attached relates nothing, so it is an error rather than an object + // with defaults at its ends. + ErrConnectorEnd = errors.New("connector end cannot be attached") + + // ErrNotAQuantity is returned when `x [y]` is not a quantity expression: + // y names no measurement unit, or x is no magnitude. + ErrNotAQuantity = errors.New("not a quantity expression") + + // ErrIncommensurableUnits is returned when an operation combines quantities + // whose units measure different things; see semantics.ErrIncommensurableUnits. + ErrIncommensurableUnits = semantics.ErrIncommensurableUnits + + // ErrUnitRoot is returned when the root of a quantity is taken whose unit + // has none: `sqrt(9 [m])`, since no unit squares to a metre. + ErrUnitRoot = errors.New("unit has no root") + + // ErrScalePoint is returned when an operation is asked of a point on a + // measurement scale that has no meaning for a point (its multiple, the sum of two). + ErrScalePoint = errors.New("operation is not defined on a point of a measurement scale") + + // ErrNotASatisfaction is returned when a satisfaction assertion is asked of + // an element that states none. + ErrNotASatisfaction = errors.New("not a satisfaction assertion") + + // ErrNoRequirement is returned when a satisfaction assertion states no + // requirement to evaluate: it references none, or references one that + // resolves to nothing. + ErrNoRequirement = errors.New("no requirement to satisfy") + + // ErrUnresolvedClassifierBehavior is returned when a type exhibits or + // performs a behavior that names an element no element states a body for, + // so the objects of that type have nothing to run. An exhibit or perform + // declaration naming nothing is its own body and never fails here. + ErrUnresolvedClassifierBehavior = errors.New("classifier behavior names no body") + + // ErrUnsupportedClassifierBehavior is returned when a type binds a behavior + // the runtime does not execute on an object. + ErrUnsupportedClassifierBehavior = errors.New("unsupported classifier behavior") + + // ErrNoSuchBehavior is returned when a behavior asked of an object is none + // the object's type owns, exhibits or performs. + ErrNoSuchBehavior = errors.New("object has no such behavior") + + // ErrNotABehavior is returned when a name invoked on an object resolves to an + // element that states no behavior to run. + ErrNotABehavior = errors.New("not a behavior") + + // ErrNotASignal is returned when a message injected from outside the model + // names an element that is no definition, so no accept could be typed by it. + ErrNotASignal = errors.New("not a signal definition") + + // ErrSignalArgument is returned when a message injected from outside the + // model carries an argument its signal definition has no feature for. + ErrSignalArgument = errors.New("signal argument") + + // ErrBehaviorBudget is returned when the behaviors of materialized objects + // never reach quiescence within the event budget. + ErrBehaviorBudget = errors.New("object behaviors exceeded their budget") + + // ErrNotACalcUsage is returned when an output feature is read from a symbol + // that is not a calc usage: only a usage carries an evaluation whose outputs + // are features. + ErrNotACalcUsage = errors.New("not a calc usage") + + // ErrUnknownOutput is returned when a name read from a calc usage is not one + // of the output features its calc declares. + ErrUnknownOutput = errors.New("unknown output") + + // ErrOutputNotAssigned is returned when a declared output carries no value + // because the activation never assigned it. It is a kind of ErrNoValue. + ErrOutputNotAssigned = fmt.Errorf("%w: output never assigned", ErrNoValue) + + // ErrConflictingOutput is returned when one activation would bind an output + // twice: by its declaration and by an assignment, or by two assignments. + ErrConflictingOutput = errors.New("output bound more than once") + + // ErrCyclicOutput is returned when an output feature's binding depends, + // directly or through other outputs, on the output being computed. + ErrCyclicOutput = errors.New("cyclic output dependency") + + // ErrAmbiguousResult is returned when a calc declaring several output + // features is invoked as an expression. A function invocation has exactly + // one result (KerML 7.4.9), so a calc that designates none has no value to + // hand back and is read through a calc usage's output features instead. + ErrAmbiguousResult = errors.New("calculation has no single result") + + // ErrIndexOutOfRange is returned when an index names no position of the + // sequence or string it indexes; indices are 1-based, so 0 is out of range + // as much as size+1 is, and each operation names what it indexed. + ErrIndexOutOfRange = errors.New("index out of range") + + // ErrBodyArity is returned when the body expression a collection operation + // is given declares a number of parameters the operation cannot call it + // with: `select` calls its selector with one element, so a selector + // declaring two parameters has no second argument to receive. + ErrBodyArity = errors.New("body parameter count mismatch") + + // ErrUnsupportedBodyDeclaration is returned when a body expression declares + // features of its own: the evaluator binds its parameters, not its + // declarations, so applying it would read them as unresolved. + ErrUnsupportedBodyDeclaration = errors.New("unsupported declaration in a body expression") + + // ErrReceiverWithNamedArgs is returned when a receiver is written before a + // call whose arguments are named, `x->f(a = 1)`. The receiver binds by + // position and the arguments by name, so which parameter the receiver binds + // to is unstated; it is reported rather than dropped. + ErrReceiverWithNamedArgs = errors.New("receiver combined with named arguments") + + // ErrVariationUnselected is returned when a variation is read without having + // been bound to one of its variants: it classifies its variants abstractly, + // so it stands for no one value until a variant is selected. + ErrVariationUnselected = errors.New("variation has no variant selected") + + // ErrNotAVariant is returned when a variation is bound to something that is + // not one of the variants it offers. + ErrNotAVariant = errors.New("not a variant of the variation") + + // ErrMultipleVariants is returned when a variation is bound to more than one + // variant, which selects no single configuration. + ErrMultipleVariants = errors.New("more than one variant selected") + + // ErrNotALiteral is returned when a name qualified by an enumeration + // definition names something the enumeration does not declare as a literal. + ErrNotALiteral = errors.New("not a literal of the enumeration") + + // ErrConflictingRedefinition is returned when one declaration values the + // same feature under two of its names: a redefinition renames one feature, + // so which of the two values it holds would be a silent pick. + ErrConflictingRedefinition = errors.New("one feature valued under two names") + + // ErrValuedFeatureRestated is returned when a feature is both bound to a + // value and given a body restating features of it: the bound value supplies + // those features, so the restatement could only be silently dropped. + ErrValuedFeatureRestated = errors.New("feature both valued and restated in a body") + + // ErrFeatureValueMaterialization marks an error as a feature value that could not be + // materialized, whatever kept it from materializing. Reading a feature value is what + // finds such a failure, so a surface reporting one answered nothing about + // that feature value rather than deciding anything about the model. + ErrFeatureValueMaterialization = errors.New("feature value could not be materialized") + + // ErrNoSuchFeature is returned when a member read, a write, or a chained + // assignment reaches an object whose type declares no feature of that name: + // the object has nothing to answer with and nowhere to hold the value. + ErrNoSuchFeature = errors.New("object has no such feature") + + // ErrClockNotAssignable is returned when an assignment targets a Clock's + // currentTime: the run advances it, so no statement writes it. + ErrClockNotAssignable = errors.New("a clock's currentTime advances with the run and is not assigned") + + // ErrNoSubject is returned when the feature a satisfaction assertion names + // with `by` cannot supply a subject: it resolves to nothing, or no object of + // it can be created. + ErrNoSubject = errors.New("no subject to satisfy the requirement") + + // ErrPerformerFeatureNotInScope is returned when a behavior body names a + // feature only the object performing it declares: the performing object is + // not a namespace the body's names resolve in, so the name has no referent. + ErrPerformerFeatureNotInScope = errors.New("name is not in scope of the behavior body") + + // ErrThisNotAnObject is returned when `this` is read where no object owns + // what is being evaluated: the context occurrence is the performance itself, + // whose features a name written in its body does not reach. + ErrThisNotAnObject = errors.New("this names no object here") +) + +type budgetExceededError struct { + message string + errs []error +} + +// UnassignedOutputError names the declared output a run could not read +// because the activation never assigned it, and the calc that declares it. +type UnassignedOutputError struct { + Output string + Calc string +} + +// Error keeps the diagnostic text an ErrOutputNotAssigned carried. +func (e *UnassignedOutputError) Error() string { + return fmt.Sprintf("%s: output %s of %s", ErrOutputNotAssigned, e.Output, e.Calc) +} + +// Unwrap reports the failure as an ErrOutputNotAssigned. +func (e *UnassignedOutputError) Unwrap() error { return ErrOutputNotAssigned } + +func (e *budgetExceededError) Error() string { return e.message } + +func (e *budgetExceededError) Unwrap() []error { return e.errs } + +func budgetExceeded(sentinel error, message string, causes ...error) error { + errs := make([]error, 0, len(causes)+1) + errs = append(errs, sentinel) + errs = append(errs, causes...) + return &budgetExceededError{message: message, errs: errs} +} + +// NoValueError reports a feature a condition names that carries no value, +// naming the feature so a caller can tell which one is uninitialized. +type NoValueError struct { + Feature string + // Ref is the written name whose read found no value, so a caller can tell a + // read of its own expression from one made while evaluating a default. + Ref *ast.QualifiedName + // Symbol is the feature declaration the read reached, when it is known. + Symbol *symbols.Symbol +} + +func (e *NoValueError) Error() string { + return fmt.Sprintf("%v for feature %s", ErrNoValue, e.Feature) +} + +func (e *NoValueError) Unwrap() error { return ErrNoValue } + +// CyclicBindingError reports a namespace-level usage whose value reaches back to the usage +// itself while it is being bound, naming that usage. +type CyclicBindingError struct { + Usage *symbols.Symbol + Stated string +} + +func (e *CyclicBindingError) Error() string { + return fmt.Sprintf("%v: %s", ErrCyclicFeatureValue, e.Stated) +} + +func (e *CyclicBindingError) Unwrap() error { return ErrCyclicFeatureValue } + +// UnboundSubjectError reports a check whose subject nothing supplied, naming +// the subject and how a caller supplies one. +type UnboundSubjectError struct { + Kind string // "constraint", "requirement", "analysis", "verification" or "objective" + Element string // name of the element declaring the subject + Subject string // name of the subject parameter +} + +func (e *UnboundSubjectError) Error() string { + switch e.Kind { + case "analysis", "verification": + return fmt.Sprintf("%s %s: %s %v: bind it (`subject %s = `) or run it on an object", + e.Kind, e.Element, e.Subject, ErrUnboundSubject, e.Subject) + case "objective": + return fmt.Sprintf("%s %s: %s %v: bind it (`subject %s = `) or return a result from the case for it to default to", + e.Kind, e.Element, e.Subject, ErrUnboundSubject, e.Subject) + } + return fmt.Sprintf("%s %s: %s %v: bind it (`subject %s = `), check it on an object, or assert `satisfy %s by `", + e.Kind, e.Element, e.Subject, ErrUnboundSubject, e.Subject, e.Element) +} + +func (e *UnboundSubjectError) Unwrap() error { return ErrUnboundSubject } + +// ViolationError reports a condition that evaluated to false, naming the +// condition so a verdict says which one failed. It unwraps to ErrViolated, +// since it is a verdict about the model rather than a failure to evaluate. +type ViolationError struct { + Kind string // "constraint" or "requirement" + Element string // name of the element stating the condition + What string // "assertion" or "require condition" + Condition string // the condition, rendered +} + +func (e *ViolationError) Error() string { + return fmt.Sprintf("%s %s: %s %v: %s", e.Kind, e.Element, e.What, ErrViolated, e.Condition) +} + +func (e *ViolationError) Unwrap() error { return ErrViolated } + +// FeatureValueError marks a feature value that could not be materialized. It reads as the error +// that kept the feature value from materializing and unwraps to it as well as to +// ErrFeatureValueMaterialization, so a caller tests either. +type FeatureValueError struct { + Err error +} + +func (e *FeatureValueError) Error() string { return e.Err.Error() } + +func (e *FeatureValueError) Unwrap() []error { return []error{ErrFeatureValueMaterialization, e.Err} } + +// OperandTypeError reports an operator applied to operand types it is not +// defined for, naming the operator and both operands and carrying the span of +// the expression so a surface holding the source can point at it. +type OperandTypeError struct { + Op string // the operator, as written + Left string // description of the left operand's type + Right string // description of the right operand's type + Library string // the library function that would have to declare it, if any + Span source.Span // span of the operator expression +} + +func (e *OperandTypeError) Error() string { + msg := fmt.Sprintf("%v: operator '%s' is not defined for %s and %s", ErrTypeMismatch, e.Op, e.Left, e.Right) + if e.Library != "" { + msg += "; " + e.Library + } + return msg +} + +func (e *OperandTypeError) Unwrap() error { return ErrTypeMismatch } + +// CalcFrameError reports an error raised inside a calc invocation, counting the +// calc frames it propagated through so a recursion reports a depth rather than +// one wrapped line per frame. +type CalcFrameError struct { + Kind string // the notation keyword of the calc: `calc` or `analysis` + Calc string // the calc the error surfaced from + Frames int // calc frames the error propagated through + Err error + + // calcs names every calc the chain already passed through, so a cycle is + // counted rather than wrapped again. + calcs map[string]bool +} + +func (e *CalcFrameError) Error() string { + if e.Frames > 1 { + return fmt.Sprintf("%s %s: … %d frames: %v", e.Kind, e.Calc, e.Frames, e.Err) + } + return fmt.Sprintf("%s %s: %v", e.Kind, e.Calc, e.Err) +} + +func (e *CalcFrameError) Unwrap() error { return e.Err } + +// calcFrame adds one calc frame to err. A calc the chain already passed through +// is counted rather than wrapped again, so a recursion reports a depth instead +// of one line per frame, while a calc calling another still names both. +func calcFrame(kind, calc string, err error) error { + var framed *CalcFrameError + if errors.As(err, &framed) { + if framed.calcs[calc] { + return &CalcFrameError{ + Kind: kind, + Calc: calc, + Frames: framed.Frames + 1, + Err: framed.Err, + calcs: framed.calcs, + } + } + calcs := make(map[string]bool, len(framed.calcs)+1) + for name := range framed.calcs { + calcs[name] = true + } + calcs[calc] = true + return &CalcFrameError{Kind: kind, Calc: calc, Frames: 1, Err: err, calcs: calcs} + } + return &CalcFrameError{Kind: kind, Calc: calc, Frames: 1, Err: err, calcs: map[string]bool{calc: true}} +} + +// calcDefaultError reports err raised evaluating calc's default for param as one +// frame of calc, so a default re-invoking its own calc collapses into a count. +func calcDefaultError(kind, calc, param string, err error) error { + return calcFrame(kind, calc, fmt.Errorf("default for parameter %q: %w", param, err)) +} diff --git a/internal/exec/runtime/eval.go b/internal/exec/runtime/eval.go new file mode 100644 index 0000000000..0eaf0cc800 --- /dev/null +++ b/internal/exec/runtime/eval.go @@ -0,0 +1,3622 @@ +package runtime + +import ( + "errors" + "fmt" + "math" + "slices" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// EvalContext is the lexical environment during evaluation (Tier 3). +type EvalContext struct { + ctx *Context // runtime context + scope *symbols.Scope // scope context for name resolution + self *Instance // instance a feature name resolves against, nil when unbound + frames []frame // stack of local bindings (innermost = frames[len-1]) + trace *TraceRecorder // evaluation trace recorder, nil when not tracing + + // features are the features of the element being evaluated — a requirement's + // or constraint's own, inherited and rebound features — which its conditions + // may name wherever those conditions were written. + features map[string]scopedExpr + + // resolving holds the features whose own value is being evaluated, so a value + // written in terms of a same-named outer one does not resolve to itself. + resolving map[string]bool + + // calcRun is the calc evaluation whose output feature is being computed, so an + // output binding written in terms of the calc's other outputs reads them from + // the same evaluation. It is nil everywhere else. + calcRun *calcRun + + // inBehaviorBody marks a statement of a behavior body, which reaches the + // object performing it only through names that resolve to its features. + inBehaviorBody bool + + // valuing is the pin whose value the expression states, so `inout log = log` + // written at that pin reads the log around it rather than the pin itself. + valuing *symbols.Symbol + + // activation identifies the execution of the body this evaluation belongs to, + // so every output read of one calc usage within it comes from one evaluation + // of that usage. It is zero outside a body, where nothing can change between + // two reads. + activation int64 + + // entered is the last activation allocated before the built-in being applied + // was entered: an occurrence begun since began during the call. + entered int64 +} + +// NewEvalContext creates an evaluation context with an empty frame stack. It +// inherits the runtime context's trace recorder, so every evaluation reached +// from a traced context is recorded, including nested calc invocations. +func NewEvalContext(ctx *Context, scope *symbols.Scope) *EvalContext { + return &EvalContext{ + ctx: ctx, + scope: scope, + frames: nil, + trace: ctx.trace, + } +} + +// NewEvalContextIn creates an evaluation context bound to an instance, so that +// a feature name resolves to that instance's feature value rather than to the +// declared default of the same name. +func NewEvalContextIn(ctx *Context, scope *symbols.Scope, self *Instance) *EvalContext { + ec := NewEvalContext(ctx, scope) + ec.self = self + return ec +} + +// beginStep gives an evaluation outside a body a scope of its own, so what it reads +// - a calc usage's outputs, a collection's elements - is not held past the step. The +// returned function ends it. +func (ec *EvalContext) beginStep() func() { + activation, end := ec.ctx.beginStep() + ec.activation = activation + return end +} + +// evalIn returns a context that resolves names in scope while sharing this +// one's bindings and trace, for a body member written in another declaration's +// scope (an inherited calc result or parameter default). +func (ec *EvalContext) evalIn(scope *symbols.Scope) *EvalContext { + if scope == nil || scope == ec.scope { + return ec + } + return &EvalContext{ + ctx: ec.ctx, scope: scope, self: ec.self, frames: ec.frames, trace: ec.trace, + features: ec.features, resolving: ec.resolving, calcRun: ec.calcRun, + activation: ec.activation, inBehaviorBody: ec.inBehaviorBody, valuing: ec.valuing, + } +} + +// valuedFeature is the value the element being evaluated binds to name, unless +// that value is the one being evaluated (`in mass = mass` reads the outer mass). +func (ec *EvalContext) valuedFeature(name string) (scopedExpr, bool) { + bound, declared := ec.features[name] + return bound, declared && bound.expr != nil && !ec.resolving[name] +} + +// valuedFeatureValue evaluates the value the element being evaluated binds to +// name; ok is false when it binds none or that value is the one being evaluated. +func (ec *EvalContext) valuedFeatureValue(name string) (val Value, ok bool, err error) { + bound, ok := ec.valuedFeature(name) + if !ok { + return Value{}, false, nil + } + if ec.resolving == nil { + ec.resolving = map[string]bool{} + } + ec.resolving[name] = true + val, err = ec.evalIn(bound.scope).inEnv(bound.env).Eval(bound.expr) + delete(ec.resolving, name) + if err != nil || bound.decl == nil { + return val, true, err + } + held := bound.held + if held == nil { + held = bound.decl + } + val, err = ec.conformBodyDeclared(held, val) + return val, true, err +} + +// conformBodyDeclared holds val as the value of a declaration a body carries: its count +// answers to a stated multiplicity only, a local omitting one keeping the initializer's. +func (ec *EvalContext) conformBodyDeclared(sym *symbols.Symbol, val Value) (Value, error) { + if mult, stated := ec.ctx.statedMultiplicity(sym); stated { + if msg := mult.HeldViolation(heldCountOf(&val)); msg != "" { + return Value{}, fmt.Errorf("feature value %s: %w: %s", ec.ctx.qualifiedSymbolName(sym), ErrMultiplicityViolation, msg) + } + } + return ec.conformHeld(sym, val, false) +} + +// inEnv returns a context reading env instead of this one's features and +// bindings. An enclosing environment holds other features than the ones being +// resolved, so a same name there is a fresh read rather than a cycle. +func (ec *EvalContext) inEnv(env *conditionEnv) *EvalContext { + if env == nil { + return ec + } + out := ec.over(ec.scope, []frame{env.bindings}) + out.features = env.features + if env.enclosing { + out.resolving = nil + } + return out +} + +// nestedEnv returns a context resolving names in scope over this one's +// environment, for a declaration nested in the body being evaluated: its +// bindings stay in force under whatever frame the nested declaration pushes. +func (ec *EvalContext) nestedEnv(scope *symbols.Scope) *EvalContext { + frames := make([]frame, len(ec.frames)) + copy(frames, ec.frames) + return ec.over(scope, frames) +} + +// closure snapshots the environment for an expression evaluated later; bindings +// are copied since an invocation's frame storage is reused once it returns, and +// the calc evaluation whose outputs it may name is detached from that storage. +func (ec *EvalContext) closure() *EvalContext { + out := ec.over(ec.scope, snapshotFrames(ec.frames)) + out.calcRun = ec.calcRun.detached() + return out +} + +// snapshotFrames copies the bindings of frames, which their runs may reuse or drop. +func snapshotFrames(frames []frame) []frame { + if len(frames) == 0 { + return nil + } + out := make([]frame, len(frames)) + for i, f := range frames { + out[i] = f.snapshot() + } + return out +} + +// over is this environment resolving names in scope over frames of its own. +func (ec *EvalContext) over(scope *symbols.Scope, frames []frame) *EvalContext { + return &EvalContext{ + ctx: ec.ctx, scope: scope, self: ec.self, frames: frames, trace: ec.trace, + features: ec.features, resolving: ec.resolving, calcRun: ec.calcRun, + activation: ec.activation, inBehaviorBody: ec.inBehaviorBody, valuing: ec.valuing, + } +} + +// lookupName resolves a simple name where the expression was written. A name +// resolving to the pin being valued, or to a parameter that pin redefines, names +// what the pin masks: the feature of that name around the usage owning the pin. +func (ec *EvalContext) lookupName(name string) (*symbols.Symbol, bool) { + sym, ok := ec.ctx.lookupName(ec.scope, name) + if !ok || !ec.namesValuedPin(sym) { + return sym, ok + } + sym, ok = ec.ctx.lookupNameExcluding(ec.scope, name, ec.valuing) + if !ok || !ec.namesValuedPin(sym) || ec.valuing.OwnerScope == nil { + return sym, ok + } + return ec.ctx.lookupName(ec.valuing.OwnerScope.Parent(), name) +} + +// namesValuedPin reports whether sym is the pin being valued or a feature it +// redefines, which the pin is the same feature as. +func (ec *EvalContext) namesValuedPin(sym *symbols.Symbol) bool { + if sym == nil || ec.valuing == nil { + return false + } + if sym == ec.valuing { + return true + } + for _, redefined := range ec.ctx.model.semantics.AllRedefinedFeatures(ec.valuing) { + if redefined == sym { + return true + } + } + return false +} + +// Push adds a new frame to the stack (on calc invocation, lambda entry). +func (ec *EvalContext) Push(bindings map[string]Value) { + ec.frames = append(ec.frames, mapFrame(bindings)) +} + +// pushFrame adds a frame to the stack. +func (ec *EvalContext) pushFrame(f frame) { + ec.frames = append(ec.frames, f) +} + +// hasPerformanceFrame reports whether an action performance is on the stack. +func (ec *EvalContext) hasPerformanceFrame() bool { + for i := range ec.frames { + if ec.frames[i].perf != nil { + return true + } + } + return false +} + +// lookupSubaction finds the node named name in the flow of an action performance +// on the stack, innermost first, and returns its latest performance. Where the +// name resolves in the reading scope, it is that declaration's node — or no node +// at all when the declaration is a feature, which shadows a same-named node. +func (ec *EvalContext) lookupSubaction(name string) (perf *actionFrame, declared bool, err error) { + if !ec.hasPerformanceFrame() { + return nil, false, nil + } + var decl ast.Node + if ec.ctx.model.resolver != nil { + if sym, ok := ec.lookupName(name); ok && sym != nil { + if usage, ok := sym.Decl.(*ast.Usage); ok && usage.Kind != ast.UsageAction && !lower.IsCaseNode(usage) { + return nil, false, nil + } + decl = sym.Decl + } + } + for i := len(ec.frames) - 1; i >= 0; i-- { + f := ec.frames[i].perf + if f == nil { + continue + } + if perf, declared, err = f.subaction(name, decl); declared { + return perf, true, err + } + } + return nil, false, nil +} + +// evalSubactionPath reads `node.pin` or `node.inner.pin` through the performances +// of the nodes the path names; the rest of the path past a pin (`node.pin.member`) +// is chained through the pin's value, and a path ending at a node reads its result. +func (ec *EvalContext) evalSubactionPath(perf *actionFrame, parts []ast.NameSegment) (Value, error) { + for i, part := range parts { + if inner, declared, err := perf.subaction(part.Text, nil); declared { + if err != nil { + return Value{}, err + } + perf = inner + continue + } + if i != len(parts)-1 && !perf.declares(part.Text) { + return Value{}, fmt.Errorf("%w: %s declares no node or pin %s to read %s through", + ErrNodePin, perf.describe(), part.Text, parts[len(parts)-1].Text) + } + value, err := perf.pin(part.Text) + if err != nil { + return Value{}, err + } + return ec.chainMemberValue(value, parts[i+1:], perf.path()+"."+part.Text) + } + return perf.resultValue() +} + +// Pop removes the top frame from the stack (on return, lambda exit). +func (ec *EvalContext) Pop() { + if len(ec.frames) > 0 { + ec.frames = ec.frames[:len(ec.frames)-1] + } +} + +// Lookup searches for a name in the frame stack (innermost first). +func (ec *EvalContext) Lookup(name string) (Value, bool) { + for i := len(ec.frames) - 1; i >= 0; i-- { + if val, ok := ec.frames[i].lookup(name); ok { + return val, true + } + } + return Value{}, false +} + +// Eval evaluates an expression node. Returns a Value or an error. +// Increments the run's steps on each eval call; errors when they are >= ctx.maxSteps. +// When the context is traced, the evaluation is recorded after its +// sub-expressions, which makes sub-expression order part of the trace. +func (ec *EvalContext) Eval(node ast.Node) (Value, error) { + if ec.trace == nil { + return ec.eval(node) + } + ec.trace.BeginEval() + value, err := ec.eval(node) + ec.trace.EndEval(TraceLabel(node), value, err) + return value, err +} + +// eval dispatches one expression node, without trace bookkeeping. +func (ec *EvalContext) eval(node ast.Node) (Value, error) { + // Step counter + if err := ec.ctx.incrementStep(); err != nil { + return Value{}, err + } + + // Dispatch by node type (scaffolding; full implementation in later tasks) + switch n := node.(type) { + case *ast.LiteralInteger: + return ec.evalLiteralInteger(n) + case *ast.LiteralReal: + return ec.evalLiteralReal(n) + case *ast.LiteralBool: + return ec.evalLiteralBool(n) + case *ast.LiteralString: + return ec.evalLiteralString(n) + case *ast.LiteralInfinity: + return ec.evalLiteralInfinity(n) + case *ast.MetadataAccessExpr: + return ec.evalMetadataAccess(n) + case *ast.NullExpr: + return ec.evalNull(n) + case *ast.FeatureReference: + val, err := ec.evalFeatureReference(n) + return ec.declaredElements(n, val, err) + case *ast.QualifiedName: + val, err := ec.evalName(n) + return ec.declaredElements(n, val, err) + case *ast.FeatureChainExpr: + val, err := ec.evalFeatureChain(n) + return ec.declaredElements(n, val, err) + case *ast.OperatorExpr: + return ec.evalOperator(n) + case *ast.SequenceExpr: + return ec.evalSequenceExpr(n) + case *ast.CollectExpr: + return ec.evalCollectExpr(n) + case *ast.SelectExpr: + return ec.evalSelectExpr(n) + case *ast.InvocationExpr: + return ec.evalInvocation(n) + case *ast.IndexExpr: + return ec.evalIndexExpr(n) + case *ast.ConstructorExpr: + return ec.evalConstructor(n) + case *ast.BodyExpr: + // A body is a value closed over its environment, applied where it is called. + return NewExprValue(n, ec.closure()), nil + default: + return Value{}, fmt.Errorf("unsupported node type: %T", node) + } +} + +// Eval is the top-level entry point for evaluating an expression in an empty environment. +// Resolves names from the root scope. +func (ctx *Context) Eval(node ast.Node) (Value, error) { + defer ctx.beginRun()() + + // Use resolver's root scope for name resolution + // (In a full implementation, this would track evaluation context scope) + ec := NewEvalContext(ctx, nil) + return ec.Eval(node) +} + +// EvalWithScope evaluates an expression with a given scope context for name resolution. +func (ctx *Context) EvalWithScope(node ast.Node, scope *symbols.Scope) (Value, error) { + defer ctx.beginRun()() + + ec := NewEvalContext(ctx, scope) + return ec.Eval(node) +} + +// EvalDeclaredValue evaluates the value a usage declaration binds, as a read of +// the declaration does: in its own scope, and answering to its declared type. A +// usage binding none but shaped as an Array by its own features is that Array. +func (ctx *Context) EvalDeclaredValue(sym *symbols.Symbol) (Value, error) { + value := ctx.extractDefaultValue(sym) + if value == nil { + defer ctx.beginRun()() + if val, ok, err := ctx.declaredArrayValue(sym); ok { + return val, err + } + // A calc definition, or a calc usage awaiting arguments, is a function. + if val, ok, err := ctx.FunctionValue(sym); ok { + return val, err + } + // A calc usage returning one unnamed result is read as that result. + if isCalcUsageSymbol(sym) && ctx.returnsResult(sym) { + return NewEvalContext(ctx, sym.OwnerScope).evalCalcUsageMembers(sym, resultSegments) + } + // A namespace-level object usage reads as the objects it denotes for the run. + if ctx.namesOneObject(sym) || ctx.namesObjects(sym) { + return ctx.denotedValue(sym) + } + // Read as a name of it is read: a feature nothing values is undetermined. + return NewEvalContext(ctx, sym.OwnerScope).withoutValue(sym, ctx.qualifiedSymbolName(sym), nil) + } + defer ctx.beginRun()() + + return NewEvalContext(ctx, sym.OwnerScope).declaredValue(sym, value) +} + +// EvalWithScopeOn evaluates an expression against a concrete instance, so a +// feature it names reads that object's feature value. It brackets one run, as +// EvalWithScope does, which is what bounds the evaluation by the step budget. +func (ctx *Context) EvalWithScopeOn(node ast.Node, scope *symbols.Scope, self *Instance) (Value, error) { + defer ctx.beginRun()() + + return NewEvalContextIn(ctx, scope, self).Eval(node) +} + +// evalLiteralInteger evaluates an integer literal, reporting one outside the +// Integer range rather than clamping it. +func (ec *EvalContext) evalLiteralInteger(n *ast.LiteralInteger) (Value, error) { + val, ok := ec.ctx.model.integerLiterals[n] + if !ok { + var err error + if val, err = strconv.ParseInt(n.Value, 10, 64); err != nil { + return Value{}, fmt.Errorf("%w: literal %s is outside the Integer range", + semantics.ErrArithmeticOverflow, n.Value) + } + ec.ctx.model.integerLiterals[n] = val + } + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: val}}, nil +} + +// evalLiteralReal evaluates a real literal, reporting one outside the Real +// range rather than carrying it as an infinity. +func (ec *EvalContext) evalLiteralReal(n *ast.LiteralReal) (Value, error) { + val, ok := ec.ctx.model.realLiterals[n] + if !ok { + var err error + if val, err = semantics.ParseReal(n.Value); err != nil { + return Value{}, fmt.Errorf("%w: literal %s is outside the Real range", err, n.Value) + } + ec.ctx.model.realLiterals[n] = val + } + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: val}}, nil +} + +// evalLiteralBool evaluates a boolean literal. +func (ec *EvalContext) evalLiteralBool(n *ast.LiteralBool) (Value, error) { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: n.Value}}, nil +} + +// evalLiteralString evaluates a string literal, which spells its text with the +// quotes and escapes of the notation. +func (ec *EvalContext) evalLiteralString(n *ast.LiteralString) (Value, error) { + return NewStringValue(source.StringValue(n.Value)), nil +} + +// evalNull evaluates a null expression. +// evalLiteralInfinity evaluates `*`, the unbounded value: a scalar constant of +// its own, ordered above every finite number and refused by arithmetic. +func (ec *EvalContext) evalLiteralInfinity(_ *ast.LiteralInfinity) (Value, error) { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInfinity}}, nil +} + +func (ec *EvalContext) evalNull(n *ast.NullExpr) (Value, error) { + return Value{Kind: ValNull}, nil +} + +// declaredElements types a feature read that yields no element by the quantity +// dimension the read declares (KerML 8.4.4.9: a feature's values are of its type), +// in that dimension's coherent unit; an aggregate of the read then keeps the kind. +func (ec *EvalContext) declaredElements(node ast.Node, val Value, err error) (Value, error) { + if err != nil || !isEmptyValue(val) { + return val, err + } + if typed, ok := ec.ctx.emptyOfDeclared(ec.scope, node); ok { + return typed, nil + } + return val, nil +} + +// emptyOfDeclared is the empty sequence of the quantities an expression is +// statically declared to yield, in their coherent unit; false where the +// declarations fix no dimension or a dimensionless one. +func (ctx *Context) emptyOfDeclared(scope *symbols.Scope, node ast.Node) (Value, bool) { + if ctx.model.semantics == nil || scope == nil { + return Value{}, false + } + return ctx.emptyOfDimension(ctx.model.semantics.DimensionOfExpr(scope, node)) +} + +// emptyOfFeature is emptyOfDeclared for the values a feature declares it holds. +func (ctx *Context) emptyOfFeature(feat *EffectiveFeature) (Value, bool) { + if feat == nil { + return Value{}, false + } + return ctx.emptyOfSymbol(feat.heldBy()) +} + +// emptyOfSymbol is emptyOfDeclared for the values a declared feature holds. +func (ctx *Context) emptyOfSymbol(sym *symbols.Symbol) (Value, bool) { + if ctx.model.semantics == nil || sym == nil { + return Value{}, false + } + return ctx.emptyOfDimension(ctx.model.semantics.DimensionOfFeature(sym)) +} + +// declaredCount is how many values an expression holds wherever it is evaluated, +// as far as the declarations it reads fix that: `[0..*]` where they leave it open. +func (ec *EvalContext) declaredCount(scope *symbols.Scope, node ast.Node) semantics.Range { + if ec.ctx.model.semantics == nil || scope == nil { + return openRange() + } + if count, ok := ec.ctx.model.semantics.ValuesHeldBy(scope, node); ok { + return count + } + return openRange() +} + +// emptyOfDimension is the empty sequence of quantities of a dimension, in its +// coherent unit; false where none is fixed or it is dimensionless. +func (ctx *Context) emptyOfDimension(dim semantics.Dimension, ok bool) (Value, bool) { + if !ok || dim.Term.Dimensionless() { + return Value{}, false + } + unit, ok := ctx.model.semantics.CoherentUnit(dim) + if !ok { + return Value{}, false + } + return NewEmptySequenceOf(unit), true +} + +// readFeatureValue is what fv reads as in an expression, an empty read typed by +// the quantities its feature declares. +func (ctx *Context) readFeatureValue(fv *FeatureValue, name string) (Value, error) { + val, err := fv.ReadValue(name) + if err != nil || val.Kind != ValSequence || val.Sequence().Size() != 0 { + return val, err + } + if typed, ok := ctx.emptyOfFeature(fv.Feature); ok { + return typed, nil + } + return val, nil +} + +// evalFeatureReference evaluates a feature reference (variable lookup). +func (ec *EvalContext) evalFeatureReference(n *ast.FeatureReference) (Value, error) { + if n == nil { + return Value{}, fmt.Errorf("empty feature reference") + } + return ec.evalName(n.Name) +} + +// thatName is the implicit feature every usage takes from the base usage: it +// names the instance featuring the value being evaluated ([KerML, 8.4.2]). +const thatName = "that" + +// thisName is the context occurrence of what is being evaluated, which for a +// performance an object owns is that object ([KerML] Occurrences::this). +const thisName = "this" + +// evalName evaluates a name as a reference to what it names, which is what an +// expression written as a bare name is: `rate`, `A::B::x`. +func (ec *EvalContext) evalName(qn *ast.QualifiedName) (Value, error) { + // Outside an expression body no body-local declaration can shadow a bound + // name, so a frame binding is the answer: the common case, kept small. + if qn != nil && len(qn.Parts) == 1 && (ec.scope == nil || !ec.scope.BodyLocal()) { + if val, ok := ec.Lookup(qn.Parts[0].Text); ok { + return val, nil + } + } + return ec.evalNameGeneral(qn) +} + +// evalNameGeneral evaluates a name through every source that may answer it, in +// shadowing order. +func (ec *EvalContext) evalNameGeneral(qn *ast.QualifiedName) (Value, error) { + if qn == nil || len(qn.Parts) == 0 { + return Value{}, fmt.Errorf("empty feature reference") + } + + // Simple case: single-part name lookup in frame stack or scope + if len(qn.Parts) == 1 { + name := qn.Parts[0].Text + // A declaration inside an expression body is local to that body and + // shadows features of the element carrying the expression. + if ec.scope != nil { + if sym, ok := symbols.LookupBodyLocal(ec.scope, name); ok { + // Body parameters and statement locals are supplied by the frame lookup below. + _, bodyMember := sym.OwnerScope.Node().(*ast.BodyExpr) + _, param := sym.Decl.(*ast.BodyExpr) + if bodyMember && !param { + if ec.resolving[name] { + return Value{}, fmt.Errorf("%w: %s", ErrCyclicFeatureValue, name) + } + if value := ec.ctx.extractDefaultValue(sym); value != nil { + if ec.resolving == nil { + ec.resolving = map[string]bool{} + } + ec.resolving[name] = true + val, err := ec.declaredValue(sym, value) + delete(ec.resolving, name) + return val, err + } + return Value{}, &NoValueError{Feature: name, Ref: qn} + } + } + } + // Try frame stack first (local bindings from calc/lambda params) + if val, ok := ec.Lookup(name); ok { + return val, nil + } + // Then a node of an action performance in the frame stack, read as a value. + if perf, declared, err := ec.lookupSubaction(name); declared { + if err != nil { + return Value{}, err + } + return perf.resultValue() + } + // Then another output feature of the calc whose output is being computed: + // an `out` binding may be written in terms of the calc's other outputs, + // which are evaluated from the same run of its body. + if value, ok, err := ec.calcRun.lookupOutput(ec.ctx, name); ok { + return value, err + } + // Then a valued feature of the element being evaluated: it is declared + // inside that element, so it masks a same-named member of the object + // carrying it, and a value a typed usage binds masks the default of the + // declaration it redefines. + // A feature whose own value is already being evaluated is skipped, so + // `in mass = mass` reads the outer mass rather than itself. + if val, ok, err := ec.valuedFeatureValue(name); ok { + return val, err + } + // Then the bound instance: a feature value holds the value this object actually + // carries, which overrides the declared default the scope would yield. + if ec.self != nil && ec.selfFeatureInScope(name) { + if val, ok, err := ec.selfFeatureValue(name); err != nil { + return Value{}, err + } else if ok { + return val, nil + } + } + // Then `that`, which every usage takes from the base usage: it names the + // instance featuring the value being evaluated, which is the bound one. + if name == thatName && ec.self != nil { + return Value{Kind: ValInstance, Instance: ec.self.ID}, nil + } + // Then `self`, the thing being evaluated, read as its value. + if ec.self != nil && ec.namesSelf(name) { + return ec.ctx.objectValue(ec.self) + } + // Then `this`, the context occurrence of what is being evaluated: the + // object owning the performance, which is the bound instance. + if name == thisName && ec.namesOccurrenceThis(name) { + return ec.thisValue() + } + // Then the scope the expression was written in: a sibling attribute, a + // member of an enclosing namespace, or a name an import brought in, found + // the way a written reference finds it. The declaration's own value is + // evaluated in the scope it was declared in, so the imports in force there + // — rather than the ones in force here — answer the names it uses. + if ec.scope != nil && !ec.resolving[name] { + if sym, ok := ec.lookupName(name); ok && sym != nil { + // An inherited expression reads the feature as the running behavior + // inherits it: through the redefinition, when it states one. + sym = ec.ctx.inheritedFeature(ec.runningBehavior(), sym) + ec.ctx.noteDeclarationRead(sym) + // An enumerated value is the value of its enumeration it stands + // for; any other variant names a choice, not the value it declares. + if semantics.EnumerationOwning(sym) != nil { + return ec.enumLiteralValue(sym) + } + if ec.ctx.model.semantics.VariationPointOwning(sym) != nil { + return variantReference(sym), nil + } + // A feature of an enclosing type, named from a nested usage, is read + // from the object enclosing the bound one: `e1` inside `e3` is the + // containing rectangle's e1, not a fresh occurrence of the declaration. + if val, ok, err := ec.outerFeatureValue(sym); ok { + return val, err + } + // A transformation's target is the frame featuring it, which its + // value carries and no object states. + if val, ok, err := ec.featuringReferenceValue(sym); ok { + return val, err + } + // A library feature's value comes from the feature seam, not its + // declared body: a warm library cache restores symbols without AST. + if val, ok, err := ec.ctx.libraryFeatureValue(sym); ok { + return val, err + } + // A measurement unit declaration is the measurement reference it names. + if val, ok, err := ec.ctx.MeasurementUnitValue(sym); ok { + return val, err + } + // A part, item or structured value names an object, so the name + // evaluates to that object rather than to a value the declaration + // would have to hold. + if val, ok, err := ec.occurrenceReference(sym); ok { + return val, err + } + // A calc definition, or a calc usage awaiting arguments, is a function. + if val, ok, err := ec.calcAsValue(sym); ok { + return val, err + } + // A calc usage returning one unnamed result is read as that result. + if isCalcUsageSymbol(sym) && ec.ctx.returnsResult(sym) { + return ec.evalCalcUsageMembers(sym, resultSegments) + } + // A feature declared with no value, whose multiplicity admits none, + // states the empty sequence — what an object holding nothing reads. + if val, ok := ec.emptyDeclaredFeature(sym); ok { + return val, nil + } + if value := ec.ctx.extractDefaultValue(sym); value != nil { + if ec.resolving == nil { + ec.resolving = map[string]bool{} + } + ec.resolving[name] = true + val, err := ec.declaredValue(sym, value) + delete(ec.resolving, name) + return val, err + } + // A variation holds nothing until it is bound, whether it is read + // through an object or through its declaration. + if ec.ctx.model.semantics.IsVariationFeature(sym) { + return Value{}, fmt.Errorf("%w: %s", ErrVariationUnselected, name) + } + return ec.resolvedWithoutValue(sym, qn) + } + } + // Nothing outside the feature supplies its value, so its own value depends + // on itself. + if ec.resolving[name] { + return Value{}, fmt.Errorf("%w: %s", ErrCyclicFeatureValue, name) + } + // A feature the element declares but nothing gives a value to is + // uninitialized rather than unresolved. + if _, declared := ec.features[name]; declared { + return Value{}, &NoValueError{Feature: name, Ref: qn} + } + if ec.ctx.model.resolver != nil && ec.scope != nil { + return Value{}, fmt.Errorf("%w: %s", ErrUnresolvedReference, ec.ctx.model.resolver.UnresolvedName(ec.scope, name, qn)) + } + return Value{}, fmt.Errorf("%w: %s", ErrUnresolvedReference, name) + } + + // A multi-part name A::B::x resolves as the checker resolves it — imports, + // visibility, aliases and inherited members included — so the two agree. It + // is read in this evaluation's scope, since one expression may be evaluated + // in several. + reading := ec.ctx.readQualified(ec.scope, qn) + + // A path starting at a node of an action performance on the stack reads + // through that node's performance: `p.v`, `leg.inner.v`. + if perf, declared, err := ec.lookupSubaction(qn.Parts[0].Text); declared && !qn.Global { + if err != nil { + return Value{}, err + } + return ec.evalSubactionPath(perf, qn.Parts[1:]) + } + + // A calc usage's output features are computed rather than declared values, + // so a name qualified by one reads the rest from an evaluation of the usage. + for i := 0; i < len(qn.Parts)-1; i++ { + part, resolved := reading.Part(i) + if !resolved { + break + } + if isCalcUsageSymbol(part) { + return ec.evalCalcUsageMembers(part, qn.Parts[i+1:]) + } + } + currentSym, ok := reading.Symbol() + if !ok { + return Value{}, ec.unresolvedQualifiedName(qn, reading) + } + ec.ctx.noteDeclarationRead(currentSym) + + // A feature of a behavior whose run is on the stack (`MassCase::result` in its + // objective or assertion) reads the value that run bound to it. + if qualifier, ok := reading.Part(len(qn.Parts) - 2); ok { + if val, ok := ec.frameFeatureValue(qualifier, currentSym); ok { + return val, nil + } + } + // A library feature reads through the feature seam, whatever the library + // declares for it and whether or not the cache kept its declaration. + if val, ok, err := ec.ctx.libraryFeatureValue(currentSym); ok { + return val, err + } + // A measurement unit declaration (`SI::m`) is the measurement reference it names. + if val, ok, err := ec.ctx.MeasurementUnitValue(currentSym); ok { + return val, err + } + // A qualified feature of an enclosing type (`Rectangle::length` inside its + // `e1`) reads the enclosing object's value of that feature. + if val, ok, err := ec.outerFeatureValue(currentSym); ok { + return val, err + } + + // A calc definition, or a calc usage awaiting arguments, is a function. + if val, ok, err := ec.calcAsValue(currentSym); ok { + return val, err + } + + // Evaluate the final symbol's declaration + if decl, ok := currentSym.Decl.(*ast.Usage); ok { + // An enumerated value is a value of its enumeration, whether or not it + // declares one of its own; any other variant names a choice its variation + // can be bound to, and compares equal to the variation that selected it. + if semantics.EnumerationOwning(currentSym) != nil { + return ec.enumLiteralValue(currentSym) + } + if ec.ctx.model.semantics.VariationPointOwning(currentSym) != nil { + return variantReference(currentSym), nil + } + if decl.Value != nil { + return ec.declaredValue(currentSym, decl.Value) + } + if ec.ctx.model.semantics.IsVariationFeature(currentSym) { + return Value{}, fmt.Errorf("%w: %s", ErrVariationUnselected, qualifiedNameToString(qn)) + } + // A part, item or structured value names an object, read as that object. + if val, ok, err := ec.occurrenceReference(currentSym); ok { + return val, err + } + // A calc usage returning one unnamed result is read as that result. + if isCalcUsageSymbol(currentSym) && ec.ctx.returnsResult(currentSym) { + return ec.evalCalcUsageMembers(currentSym, resultSegments) + } + // A calc usage or a KerML type is never the empty sequence, whatever it admits. + if isCalcUsageSymbol(currentSym) || declaresType(currentSym) { + return ec.resolvedWithoutValue(currentSym, qn) + } + // A valueless feature admitting nothing is the empty sequence, however spelled. + if val, ok := ec.emptyDeclaredFeature(currentSym); ok { + return val, nil + } + } + // A require/assume constraint reads the value it binds, as a constraint usage does. + if oc, ok := ast.OwnedConstraintOf(currentSym.Decl); ok && oc.Value != nil { + return ec.declaredValue(currentSym, oc.Value) + } + // A subject is bound or, admitting nothing, empty; otherwise it awaits a binding. + if decl, ok := currentSym.Decl.(*ast.SubjectMember); ok { + if decl.BindingExpr != nil { + return ec.declaredValue(currentSym, decl.BindingExpr) + } + if val, ok := ec.emptyDeclaredFeature(currentSym); ok { + return val, nil + } + } + return ec.resolvedWithoutValue(currentSym, qn) +} + +// frameFeatureValue reads the resolved member sym, qualified by qualifier, from the innermost +// frame whose owner is (or specializes) the qualifier, under the name that owner's run binds it by. +func (ec *EvalContext) frameFeatureValue(qualifier, sym *symbols.Symbol) (Value, bool) { + for i := len(ec.frames) - 1; i >= 0; i-- { + f := ec.frames[i] + if f.owner == nil || !f.owner.qualifiedBy(ec.ctx, qualifier) { + continue + } + name, ok := f.owner.memberName(ec.ctx, sym) + if !ok { + continue + } + if val, ok := f.lookup(name); ok { + return val, true + } + } + return Value{}, false +} + +// resolvedWithoutValue reads a name that resolves to sym but no value: undetermined +// at model level, uninitialized (not unresolved) when an object features it. +func (ec *EvalContext) resolvedWithoutValue(sym *symbols.Symbol, qn *ast.QualifiedName) (Value, error) { + return ec.withoutValue(sym, qualifiedNameToString(qn), qn) +} + +// withoutValue reads sym, spelled as written, when it binds no value. +func (ec *EvalContext) withoutValue(sym *symbols.Symbol, spelled string, qn *ast.QualifiedName) (Value, error) { + // Definitions are types, not values. + if declaresType(sym) { + return Value{}, fmt.Errorf("cannot evaluate definition %s", spelled) + } + // A calc usage is an evaluation, not a value: it is read through the output + // features it computes, since a name it does not designate a result for has + // no one value. + if isCalcUsageSymbol(sym) { + return Value{}, fmt.Errorf( + "%w: calc usage %s computes output features (%s); read one of them", + ErrNoValue, spelled, ec.ctx.calcUsageOutputSummary(sym), + ) + } + // A usage of any kind — a subject or a state included — is a feature. + if _, usage := sym.Decl.(*ast.Usage); usage || semantics.IsShapeFeature(sym) { + if ec.modelLevel() { + return ec.undeterminedFeature(sym, spelled), nil + } + return Value{}, &NoValueError{Feature: spelled, Ref: qn} + } + return Value{}, fmt.Errorf("cannot evaluate %s %s", sym.Kind, spelled) +} + +// modelLevel reports an evaluation featured by nothing: no object, element or +// behavior supplies feature values, so the model alone determines them. +func (ec *EvalContext) modelLevel() bool { + return ec.self == nil && ec.features == nil && !ec.inBehaviorBody && !ec.hasPerformanceFrame() +} + +// undeterminedFeature is the model-level value of a feature nothing gives a +// value to: as many values as its multiplicity states, none of them known; +// the empty sequence where it states there are none. +func (ec *EvalContext) undeterminedFeature(sym *symbols.Symbol, spelled string) Value { + count := ec.ctx.featureMultiplicity(sym, ec.ctx.findOwnerType(sym)) + if n, exact := count.Exactly(); exact && n == 0 { + if typed, ok := ec.ctx.emptyOfSymbol(sym); ok { + return typed + } + return sequenceOf(nil) + } + return undeterminedFeatureValue(noValueReason(spelled), count, sym) +} + +// declaresType reports a symbol that declares a type: a definition, or a KerML +// class, struct, behavior, datatype or function, which the parser records as a +// usage and the symbol builder classifies as the type it declares. +func declaresType(sym *symbols.Symbol) bool { + if isDefinitionSymbol(sym) { + return true + } + switch sym.Kind { + case symbols.SymbolKerMLType, symbols.SymbolMetaclass, symbols.SymbolAttributeDef, symbols.SymbolCalcDef: + return true + default: + return false + } +} + +// unresolvedQualifiedName reports a multi-part name the resolver rejected: as +// ambiguous when it named several elements; otherwise against the variants or +// literals of a variation or enumeration the deepest resolved segment reached. +func (ec *EvalContext) unresolvedQualifiedName(qn *ast.QualifiedName, reading resolve.Reading) error { + written := qualifiedNameToString(qn) + if qn.Global { + written = "$::" + written + } + if n, ok := reading.Ambiguity(); ok { + return fmt.Errorf("%w: %s (%d candidates)", ErrAmbiguousReference, written, n) + } + for i := len(qn.Parts) - 2; i >= 0; i-- { + owner, ok := reading.Part(i) + if !ok { + continue + } + memberName := qn.Parts[i+1].Text + if owner.Kind == symbols.SymbolEnumerationDef { + return fmt.Errorf("%w: %s is not a literal of %s (%s)", + ErrNotALiteral, memberName, owner.Name, ec.ctx.enumerationSummary(owner)) + } + if ec.ctx.model.semantics.IsVariationFeature(owner) { + return fmt.Errorf("%w: %s is not a variant of %s (%s)", + ErrNotAVariant, memberName, owner.Name, ec.ctx.variantSummary(owner)) + } + if ec.ctx.model.resolver != nil { + return fmt.Errorf("%w: %s", ErrUnresolvedReference, ec.ctx.model.resolver.UnresolvedMember(ec.scope, qn, owner, i+1)) + } + break + } + return fmt.Errorf("%w: %s", ErrUnresolvedReference, written) +} + +// declaredValue evaluates the value a declaration binds in the scope it was written +// in (its units and imports answer its names); the value answers to the declared type. +// A namespace-level object usage's value is one binding (KerML 1.0 §7.4.11), kept for the run. +func (ec *EvalContext) declaredValue(sym *symbols.Symbol, value ast.Node) (Value, error) { + ec.ctx.noteDeclarationRead(sym) + if val, ok := ec.ctx.namespaceBindings[sym]; ok { + return val, nil + } + if !namespaceObjectUsage(sym) { + return ec.evaluateDeclared(sym, value) + } + if ec.ctx.binding(sym) { + return Value{}, &CyclicBindingError{Usage: sym, Stated: ec.ctx.qualifiedSymbolName(sym)} + } + ec.ctx.bindingStack = append(ec.ctx.bindingStack, sym) + defer func() { ec.ctx.bindingStack = ec.ctx.bindingStack[:len(ec.ctx.bindingStack)-1] }() + // The binding is made whole or not at all: a value refused after constructing + // objects leaves none of them, nor their behaviors, behind. + commit, rollback := ec.ctx.beginJournal() + val, err := ec.evaluateDeclared(sym, value) + if err != nil { + rollback() + delete(ec.ctx.bindingReads, sym) + return Value{}, err + } + ec.ctx.bindNamespace(sym, val) + commit() + return val, nil +} + +// binding reports whether sym's value is being evaluated. +func (ctx *Context) binding(sym *symbols.Symbol) bool { + return slices.Contains(ctx.bindingStack, sym) +} + +// bindNamespace records the value a namespace-level usage denotes for the run; a probe +// that made it is undone with it. +func (ctx *Context) bindNamespace(sym *symbols.Symbol, val Value) { + ctx.noteProbeUndo(func() { ctx.unbindNamespace(sym) }) + ctx.namespaceBindings[sym] = val +} + +// unbindNamespace forgets a namespace-level usage's binding, and what was read to make it. +func (ctx *Context) unbindNamespace(sym *symbols.Symbol) { + delete(ctx.namespaceBindings, sym) + delete(ctx.bindingReads, sym) +} + +// evaluateDeclared evaluates a declaration's value anew, answering to its declared type. +func (ec *EvalContext) evaluateDeclared(sym *symbols.Symbol, value ast.Node) (Value, error) { + val, err := ec.evalIn(sym.OwnerScope).Eval(value) + if err != nil { + return Value{}, err + } + return ec.conformDeclared(sym, val) +} + +// conformDeclared holds val as the value of the feature sym declares, once it answers to +// the declared type, uniqueness and multiplicity (KerML 1.0 §7.3.4). +func (ec *EvalContext) conformDeclared(sym *symbols.Symbol, val Value) (Value, error) { + return ec.conformHeld(sym, val, true) +} + +// conformHeld is conformDeclared, judging an undetermined count against the effective +// multiplicity only when countJudged. +func (ec *EvalContext) conformHeld(sym *symbols.Symbol, val Value, countJudged bool) (Value, error) { + what := func() string { return fmt.Sprintf("feature value %s", ec.ctx.qualifiedSymbolName(sym)) } + if err := ec.ctx.checkWriteType(sym.OwnerScope, what, ec.ctx.extractType(sym), &val, admitDeclared); err != nil { + return Value{}, err + } + if msg := ec.ctx.declaredUniquenessRefusal(sym, &val); msg != "" { + return Value{}, fmt.Errorf("%s: %w: %s", what(), ErrUniquenessViolation, msg) + } + if countJudged { + if msg := ec.ctx.declaredCountRefusal(sym, &val); msg != "" { + return Value{}, fmt.Errorf("%s: %w: %s", what(), ErrMultiplicityViolation, msg) + } + } + if err := ec.ctx.classifyHeld(sym, val); err != nil { + return Value{}, fmt.Errorf("%s: %w", what(), err) + } + return ec.bindVariationOf(sym, ec.ctx.classifiedFrame(sym, ec.ctx.declaredCollection(sym, val))) +} + +// occurrenceReference evaluates a name denoting objects of its own — an occurrence, a +// structured value, or a namespace's collection of occurrences — as those objects, +// materialized once. Reports whether the symbol denotes such objects. +func (ec *EvalContext) occurrenceReference(sym *symbols.Symbol) (Value, bool, error) { + if !ec.ctx.namesOneObject(sym) && !ec.ctx.namesObjects(sym) { + return Value{}, false, nil + } + ec.ctx.noteDeclarationRead(sym) + val, err := ec.ctx.denotedValue(sym) + return val, true, err +} + +// emptyDeclaredFeature reads a valueless feature declaration whose lower bound is +// zero as the empty sequence an object holding nothing reads. At model level no +// object holds it, so it stays undetermined. A variation is a choice, not an empty feature. +func (ec *EvalContext) emptyDeclaredFeature(sym *symbols.Symbol) (Value, bool) { + if ec.modelLevel() || !ec.ctx.optionalValueless(sym) || ec.ctx.model.semantics.IsVariationFeature(sym) { + return Value{}, false + } + return sequenceOf(nil), true +} + +// namesSelf reports whether the name resolves, where the expression was written, +// to the `self` feature every thing has of itself or a restatement of it. +func (ec *EvalContext) namesSelf(name string) bool { + if ec.scope == nil { + return false + } + sym, ok := ec.lookupName(name) + return ok && ec.ctx.model.semantics.IsSelf(sym) +} + +// namesOccurrenceThis reports whether the name resolves to the library's +// context occurrence feature `this` where the expression was written. +func (ec *EvalContext) namesOccurrenceThis(name string) bool { + if ec.scope == nil { + return false + } + sym, ok := ec.lookupName(name) + return ok && ec.ctx.model.resolver.IsOccurrenceThis(sym) +} + +// thisValue is the object owning the performance being evaluated. A body no +// object owns has none: `this` there is the performance itself. +func (ec *EvalContext) thisValue() (Value, error) { + object := ec.ctx.model.resolver.ThisContext(ec.scope) + if object == nil { + return Value{}, fmt.Errorf("%w: this names the performance itself, which no object owns", + ErrThisNotAnObject) + } + if ec.self == nil { + return Value{}, fmt.Errorf("%w: no object of %s performs this body", + ErrThisNotAnObject, symbolText(object)) + } + return Value{Kind: ValInstance, Instance: ec.self.ID}, nil +} + +// selfFeatureInScope reports whether the bound instance's feature of that name +// may answer here: in a behavior body only when the name resolves to it. +func (ec *EvalContext) selfFeatureInScope(name string) bool { + if !ec.inBehaviorBody { + return true + } + if ec.ctx == nil || ec.ctx.model.resolver == nil || ec.scope == nil { + return false + } + sym, ok := ec.lookupName(name) + return ok && performerHoldsFeature(ec.ctx, ec.self, sym) +} + +// selfFeatureValue reads the named feature value of the bound instance. Reports whether the +// instance has such a feature value; an error means the feature value exists but could not be +// materialized. +func (ec *EvalContext) selfFeatureValue(name string) (Value, bool, error) { + // A clock's currentTime, under any of its names, is the run's shared clock. + if now, isClock, err := ec.ctx.clockMember(ec.self, name); isClock { + return now, true, err + } + if _, ok := ec.self.FeatureValues[name]; !ok { + return Value{}, false, nil + } + fv, err := ec.self.GetFeatureValue(ec.ctx, name) + if err != nil { + return Value{}, true, err + } + value, err := ec.ctx.readFeatureValue(fv, name) + if err != nil { + return value, true, err + } + // An object the feature holds is read as what it denotes, as a chain reads it. + if inst, ok := ec.ctx.instances[value.Instance]; ok && value.Kind == ValInstance { + value, err = ec.ctx.objectValue(inst) + } + return value, true, err +} + +// evalFeatureChain evaluates a feature chain expression (e.g., obj.member.submember). +func (ec *EvalContext) evalFeatureChain(n *ast.FeatureChainExpr) (Value, error) { + if n.Member == nil || len(n.Member.Parts) == 0 { + return Value{}, fmt.Errorf("empty member chain") + } + base, parts := chainBase(n) + + // A node of an action performance on the stack carries its pins in its own + // performance, which `p.v` reads. + if name := simpleEndName(base); name != "" { + if perf, declared, err := ec.lookupSubaction(name); declared { + if err != nil { + return Value{}, err + } + return ec.evalSubactionPath(perf, parts) + } + } + + // A transition's payload, `T.d`, is what its trigger bound in the firing the + // reading behavior is performed within. + if val, ok, err := ec.transitionPayload(base, parts); ok { + return val, err + } + + // A calc usage carries no value of its own: its output features are computed + // by evaluating it, so `c.a` runs the usage — once — and reads the output + // from that evaluation rather than from a feature value. + if sym, ok := ec.calcUsageOperand(base); ok { + return ec.evalCalcUsageMembers(sym, parts) + } + + // A part carries no value of its own: it denotes an occurrence, whose features + // `lander.mass.mDry` reads, so the chain is read from that object. + if sym, ok := ec.occurrenceOperand(base); ok { + // A usage of an enclosing object is read from that object, so a sibling + // chain `e1.length` inside `e3` reads the containing rectangle's e1. + if val, ok, err := ec.outerFeatureValue(sym); ok { + if err != nil { + return Value{}, err + } + return ec.chainMemberValue(val, parts, sym.Name) + } + // The objects read as their values, whose own members they answer before the objects'. + val, err := ec.ctx.denotedValue(sym) + if err != nil { + return Value{}, err + } + return ec.chainMemberValue(val, parts, sym.Name) + } + + // Evaluate the operand (left side of the chain) + operand, err := ec.Eval(n.Operand) + if err != nil { + var noValue *NoValueError + if errors.As(err, &noValue) { + if unresolved := ec.chainMembersDeclared(base, parts); unresolved != nil { + return Value{}, unresolved + } + } + return Value{}, err + } + + if operand.Kind == ValInstance { + if _, ok := ec.ctx.instances[operand.Instance]; !ok { + return Value{}, fmt.Errorf("instance ID %d not found", operand.Instance) + } + } + // An undetermined value a feature holds is chained as that feature's, so its + // members are the ones the feature declares however the value was computed. + if u := operand.Undetermined(); u != nil && u.feature == nil { + if sym, ok := ec.chainBaseSymbol(base); ok { + operand = undeterminedFeatureValue(u.reason, u.count, sym) + } + } + + return ec.chainMemberValue(operand, n.Member.Parts, "") +} + +// chainMembersDeclared reports the first chain member nothing declares, so +// `wheels.nonexistent` is unresolved rather than unset when wheels has no value. +func (ec *EvalContext) chainMembersDeclared(base ast.Node, parts []ast.NameSegment) error { + cur, ok := ec.chainBaseSymbol(base) + if !ok { + return nil + } + for _, part := range parts { + next, ok := ec.ctx.declaredMember(cur, part.Text) + if !ok { + return fmt.Errorf("%w: %s has no member %s", ErrUnresolvedReference, cur.Name, part.Text) + } + cur = next + } + return nil +} + +// chainBaseSymbol is the feature a chain's base names, when it names one. +func (ec *EvalContext) chainBaseSymbol(base ast.Node) (*symbols.Symbol, bool) { + ref, ok := base.(*ast.FeatureReference) + if !ok || ref.Name == nil || ec.ctx.model.resolver == nil { + return nil, false + } + sym, ok := ec.ctx.resolveQualified(ec.scope, ref.Name) + return sym, ok && sym != nil +} + +// declaredMember is what an object of sym holds under name: a feature of its +// shape, or a member (calc usage, variant) the model reaches by name. +func (ctx *Context) declaredMember(sym *symbols.Symbol, name string) (*symbols.Symbol, bool) { + for _, feat := range ctx.FeaturesOf(sym) { + if feat.Name == name && feat.Symbol != nil { + return feat.Symbol, true + } + } + return ctx.model.semantics.LookupMember(sym, name) +} + +// chainBase flattens a nested feature chain: `lander.mass.mDry` is one chain of +// members from `lander`, not a chain through the value of `lander.mass`. +func chainBase(n *ast.FeatureChainExpr) (ast.Node, []ast.NameSegment) { + operand, parts := n.Operand, n.Member.Parts + for { + inner, ok := operand.(*ast.FeatureChainExpr) + if !ok || inner.Member == nil || len(inner.Member.Parts) == 0 { + return operand, parts + } + parts = append(append([]ast.NameSegment{}, inner.Member.Parts...), parts...) + operand = inner.Operand + } +} + +// chainMemberValue reads the members named by parts from the object value names, +// navigating through the objects the intermediate members name. from names the +// member value came from, for a diagnostic about chaining through it. +// +// A chain's values are its last feature's values over every object the features +// before it name (KerML 1.0 §7.3.4.6), so a multi-valued member is navigated +// through each of its objects, concatenated in order and flattened one level. +func (ec *EvalContext) chainMemberValue(value Value, parts []ast.NameSegment, from string) (Value, error) { + if len(parts) == 0 { + if inst, ok := ec.ctx.instances[value.Instance]; ok && value.Kind == ValInstance { + return ec.ctx.objectValue(inst) + } + return value, nil + } + name, rest := parts[0].Text, parts[1:] + + if literal := value.EnumerationLiteral(); literal != nil { + // A literal is an occurrence of its enumeration, so its own features are + // read from the object that literal stands for, whatever scalar it equals. + inst, err := ec.ctx.enumLiteralObject(literal) + if err != nil { + return Value{}, err + } + return ec.chainMemberValue(Value{Kind: ValInstance, Instance: inst.ID}, parts, from) + } + switch value.Kind { + case ValSequence, ValSet: + return ec.chainOverElements(value, parts, from) + case ValArray, ValVector, ValVectorQuantity, ValTensorQuantity, ValQuantity, ValMeasurementRef, ValCoordinateFrame, ValCoordinateTransformation: + // An array or vector read from an object keeps that object's members; a + // frame answers its own features from the value, then from its object. + if inst, ok := ec.ctx.structuredObject(value); ok && isStructuredValue(&value) { + return ec.chainMemberValue(Value{Kind: ValInstance, Instance: inst.ID}, parts, from) + } + return ec.chainOwnFeature(value, parts, from) + case ValInstance, ValVariant: + // handled below + case ValMetaobject: + // A metaobject answers its metaclass's features for the element it denotes. + return ec.chainOwnFeature(value, parts, from) + case ValUndetermined: + return ec.chainThroughUndetermined(value, parts, from) + default: + if err := metadataOfAValue(value, parts); err != nil { + return Value{}, err + } + return Value{}, fmt.Errorf("cannot chain through non-instance member %s (%v)", from, value.Kind) + } + + // A selected variant is chained through the object it materialized. + id, isObject := value.Object() + if !isObject { + if err := metadataOfAValue(value, parts); err != nil { + return Value{}, err + } + return Value{}, fmt.Errorf("cannot chain through non-instance member %s (%v)", from, value.Kind) + } + inst, ok := ec.ctx.instances[id] + if !ok { + return Value{}, fmt.Errorf("instance ID %d not found for member %s", id, from) + } + // A frame, scale or transformation object answers its members from the value it is. + if ref, isRef, err := ec.ctx.referenceValueOfObject(inst); isRef { + if err != nil { + return Value{}, err + } + return ec.chainMemberValue(ref, parts, from) + } + // A shaped Array object answers Array's features and their redefinitions from + // the value; its other members stay the object's, whatever their names. + if arr, isArray, err := ec.ctx.arrayOfObject(inst); isArray { + if err != nil { + return Value{}, err + } + if base, member, ok := ec.ctx.arrayFeatureNamed(inst.Type, name); ok { + answer, err := ec.ctx.structuredMember(arr, base, member, inst.Type) + if err != nil { + return Value{}, err + } + return ec.chainMemberValue(answer, rest, name) + } + } + // A clock's currentTime is the run's shared clock, which no feature value holds. + if now, isClock, err := ec.ctx.clockMember(inst, name); isClock { + if err != nil { + return Value{}, err + } + return ec.chainMemberValue(now, rest, name) + } + fvDecl, ok := inst.FeatureValues[name] + if !ok { + // A calc usage is an evaluation rather than a feature value, so its outputs are + // read from a run of it against this object. + if sym, found := ec.ctx.model.semantics.LookupMember(inst.Type, name); found && isCalcUsageSymbol(sym) { + return ec.calcUsageMemberValue(sym, inst, rest) + } + return Value{}, fmt.Errorf("%w: member %s not found in instance", ErrNoSuchFeature, name) + } + // A variant named through the variation feature it belongs to is the choice + // itself, not a member of the variation's value. + if variant, rest, ok := ec.variantSegment(fvDecl.Feature, rest); ok { + if len(rest) == 0 { + return variantReference(variant), nil + } + // Members are read from the object the variant stands for. + val, err := ec.ctx.variantValue(fvDecl.Feature.Symbol, variant, inst.ID) + if err != nil { + return Value{}, err + } + return ec.chainMemberValue(val, rest, variant.Name) + } + // Read through the feature value so a derived or composite member is materialized + // on demand rather than read as an empty feature value. + fv, open, err := ec.memberFeatureValue(inst, name) + if err != nil { + return Value{}, err + } + // An object read through the model holds, for a feature whose count the model + // leaves open, no fixed sequence of values. + if val, ok := ec.openFeatureRead(inst, fv, open, from, name); ok { + return ec.chainMemberValue(val, rest, name) + } + member, err := ec.ctx.readFeatureValue(fv, name) + if err != nil { + return Value{}, err + } + return ec.chainMemberValue(member, rest, name) +} + +// chainOwnFeature continues a chain through a feature the value answers from +// itself rather than from an object it names. +func (ec *EvalContext) chainOwnFeature(value Value, parts []ast.NameSegment, from string) (Value, error) { + member, err := ec.ownFeature(value, parts[0].Text) + if err != nil { + return Value{}, err + } + return ec.chainMemberValue(member, parts[1:], from) +} + +// ownFeature reads a feature a value answers from itself: a metaobject's +// reflective feature, or a library feature of an array, vector, quantity, +// measurement reference or frame. +func (ec *EvalContext) ownFeature(value Value, name string) (Value, error) { + if value.Kind == ValMetaobject { + return ec.metaobjectFeature(value, name) + } + member, ok, err := ec.ctx.structuredFeature(value, name) + if err != nil { + return Value{}, err + } + if !ok { + return Value{}, fmt.Errorf("%w: %s has no feature %s", ErrTypeMismatch, describeValue(value), name) + } + return member, nil +} + +// chainOverElements reads the rest of a chain from every element of a +// multi-valued member, concatenating the values each contributes. +func (ec *EvalContext) chainOverElements(value Value, parts []ast.NameSegment, from string) (Value, error) { + var collected, reads []Value + for _, elem := range elementsOf(value) { + val, err := ec.chainMemberValue(elem, parts, from) + if err != nil { + return Value{}, err + } + contributed := elementsOf(val) + if err := ec.ctx.chargeElements(int64(len(contributed))); err != nil { + return Value{}, err + } + collected = append(collected, contributed...) + reads = append(reads, val) + } + // A member some element leaves undetermined is undetermined over all of them. + if _, open := undeterminedIn(reads...); open { + return undeterminedElements(reads), nil + } + if len(collected) == 0 { + if unit, ok := elementUnitOf(reads...); ok { + return NewEmptySequenceOf(unit), nil + } + } + return sequenceOf(collected), nil +} + +// enumLiteralValue is the value a literal declares — a scalar-specializing +// enumeration's literal *is* its value — else the identity of the literal. +func (ec *EvalContext) enumLiteralValue(sym *symbols.Symbol) (Value, error) { + value := semantics.LiteralValue(sym) + if value == nil { + return NewEnumLiteral(sym), nil + } + val, err := ec.evalIn(DeclScope(sym)).Eval(value) + if err != nil { + return Value{}, fmt.Errorf("enumeration literal %s: %w", sym.Name, err) + } + return val.ofLiteral(sym), nil +} + +// EnumerationLiteralValue is the value sym has when it is an enumeration +// literal, reported as such so a caller holding only a symbol — an `%eval` of a +// literal — answers with the value rather than "no value". +func (ctx *Context) EnumerationLiteralValue(sym *symbols.Symbol) (Value, bool, error) { + if semantics.EnumerationOwning(sym) == nil { + return Value{}, false, nil + } + val, err := NewEvalContext(ctx, DeclScope(sym)).enumLiteralValue(sym) + return val, true, err +} + +// enumLiteralObject returns the object a literal stands for, materialized once +// so the features it carries read the same object every time. +func (ctx *Context) enumLiteralObject(literal *symbols.Symbol) (*Instance, error) { + if literal == nil { + return nil, fmt.Errorf("%w: the literal was never resolved", ErrNotALiteral) + } + inst, err := ctx.occurrenceOf(literal) + if err != nil { + return nil, fmt.Errorf("enumeration literal %s: %w", literal.Name, err) + } + return inst, nil +} + +// enumerationSummary names the literals an enumeration declares, for a report +// about a qualified name that is none of them. +func (ctx *Context) enumerationSummary(enum *symbols.Symbol) string { + literals := ctx.model.semantics.LiteralsOf(enum) + if len(literals) == 0 { + return "it declares no literals" + } + names := make([]string, 0, len(literals)) + for _, lit := range literals { + names = append(names, lit.Name) + } + return "literals: " + strings.Join(names, ", ") +} + +// unimplementedOperators names the operators the runtime does not evaluate and +// says what each would need, so reaching one reports why rather than "unsupported". +var unimplementedOperators = map[ast.OperatorKind]string{ + ast.OpBitNot: "bitwise complement is declared by no function library the runtime applies", + ast.OpIndex: "indexing is evaluated from an IndexExpression, not this operator", +} + +// evalOperator evaluates an operator expression. A constant one is answered by +// the folder; every other operator the folder recognizes is evaluated here, so +// an operand that depends on a parameter does not make the operator fail. +func (ec *EvalContext) evalOperator(n *ast.OperatorExpr) (Value, error) { + // Try constant folding first + if semVal, ok := ec.ctx.model.semantics.Eval(n); ok { + return Value{Kind: ValConst, Const: semVal}, nil + } + + // Otherwise, recursively eval operands + switch n.Operator { + case ast.OpConditional: + return ec.evalConditional(n) + case ast.OpNullCoalesce: + return ec.evalNullCoalesce(n) + case ast.OpAdd, ast.OpSub, ast.OpMul, ast.OpDiv, ast.OpMod, ast.OpPow: + return ec.evalArithmetic(n) + case ast.OpEq, ast.OpNeq: + return ec.evalEquality(n) + case ast.OpEqEqEq, ast.OpNeqEqEq: + return ec.evalIdentity(n) + case ast.OpLt, ast.OpLe, ast.OpGt, ast.OpGe: + return ec.evalComparison(n) + case ast.OpAnd, ast.OpConditionalAnd, ast.OpOr, ast.OpConditionalOr, ast.OpXor, ast.OpImplies: + return ec.evalLogical(n) + case ast.OpNeg, ast.OpPos, ast.OpNot: + return ec.evalUnary(n) + case ast.OpRange: + return ec.evalRange(n) + case ast.OpAt: + if ec.classifiesValue(n) { + return ec.evalTypeClassification(n) + } + return ec.evalClassification(n) + case ast.OpMetaAt: + return ec.evalClassification(n) + case ast.OpHasType, ast.OpIsType: + return ec.evalTypeClassification(n) + case ast.OpAs: + return ec.evalCast(n) + case ast.OpAll: + return ec.evalExtent(n) + case ast.OpMeta: + return ec.evalMetaCast(n) + default: + if why, ok := unimplementedOperators[n.Operator]; ok { + return Value{}, fmt.Errorf("%w: '%s': %s", ErrUnsupportedOperator, n.Operator, why) + } + return Value{}, fmt.Errorf("%w: '%s'", ErrUnsupportedOperator, n.Operator) + } +} + +// classifiesValue reports whether `x @ T` is `x istype T`: a subject is written +// and T is an ordinary type, not a metadata type (which only annotations have). +func (ec *EvalContext) classifiesValue(n *ast.OperatorExpr) bool { + if len(n.Operands) != 1 || n.TypeRef == nil { + return false + } + target, ok := ec.resolveClassificationType(n.TypeRef) + return ok && !semantics.IsMetadataType(target) +} + +// resolveClassificationType resolves the type a classification names, seeing +// through an alias to the type it stands for. +func (ec *EvalContext) resolveClassificationType(qn *ast.QualifiedName) (*symbols.Symbol, bool) { + target, ok := ec.ctx.resolveQualified(ec.scope, qn) + if !ok || target == nil { + return nil, false + } + if canonical, ok := ec.ctx.resolveAliasTarget(target); ok { + target = canonical + } + ec.ctx.noteDeclarationRead(target) + return target, true +} + +// evalTypeClassification evaluates `x hastype T`, `x istype T` and the value form of `x @ T` +// (KerML 1.0 §7.4.9.2): `hastype` reads the direct types alone, `@` holds when any value is of T. +func (ec *EvalContext) evalTypeClassification(n *ast.OperatorExpr) (Value, error) { + if len(n.Operands) != 1 || n.TypeRef == nil { + return Value{}, fmt.Errorf("%w: '%s' requires one value and one type", + ErrTypeMismatch, n.Operator) + } + target, ok := ec.resolveClassificationType(n.TypeRef) + if !ok { + return Value{}, fmt.Errorf("%w: %s", ErrUnresolvedType, + qualifiedNameToString(n.TypeRef)) + } + value, err := ec.Eval(n.Operands[0]) + if err != nil { + return Value{}, err + } + declared := ec.declaredOperandTypes(n.Operands[0]) + if value.Kind == ValUndetermined { + return classifyUndetermined(n.Operator, value, ec.undeterminedClassification(declared, target)), nil + } + by := byAnyType + if n.Operator == ast.OpHasType { + by = byOwnType + } + matches, err := ec.valuesClassified(soleElement(value), target, declared, by, n.Operator == ast.OpAt) + if err != nil { + return Value{}, err + } + return boolValue(matches), nil +} + +// valuesClassified reports whether target classifies every value of value — or, for `@`, any +// (KerML 1.0 §7.4.9.2) — so an empty value satisfies `istype` and `hastype` and fails `@`. +func (ec *EvalContext) valuesClassified( + value Value, target *symbols.Symbol, declared []*symbols.Symbol, by classifiedBy, anyOf bool, +) (bool, error) { + var elements []Value + switch value.Kind { + case ValNull, ValInvalid, ValSequence, ValSet: + elements = elementsOf(value) + default: + elements = []Value{value} + } + for _, element := range elements { + verdict, err := ec.ctx.classifyValue(ec.scope, element, target, declared, by) + if err != nil { + return false, err + } + if (verdict == semantics.ClassifiesAll) == anyOf { + return anyOf, nil + } + } + return !anyOf, nil +} + +// directValueTypes names the types a value is of, resolved in the scope reading it: +// the scalar type of a constant, the enumeration a literal belongs to, for an object its +// declared type then the type of each feature it was held as a value of, and for a selected +// variant the variant itself then what its object was held by. +func (ctx *Context) directValueTypes(scope *symbols.Scope, value Value) ([]*symbols.Symbol, error) { + id, ok := value.Object() + if !ok { + typ, err := ctx.directValueType(scope, value) + if err != nil { + return nil, err + } + return []*symbols.Symbol{typ}, nil + } + inst, ok := ctx.instances[id] + if !ok || inst == nil || inst.Type == nil { + return nil, fmt.Errorf("%w: instance %d", ErrUndeterminedValueType, id) + } + var out []*symbols.Symbol + types := inst.types() + if value.Kind == ValVariant { + out, types = []*symbols.Symbol{value.Variant()}, inst.classifiers + } + for _, sym := range types { + if typ := ctx.directType(sym); !slices.Contains(out, typ) { + out = append(out, typ) + } + } + return out, nil +} + +// directType is the type an object is of for being of sym: the type a feature is typed +// by, else sym itself. +func (ctx *Context) directType(sym *symbols.Symbol) *symbols.Symbol { + if typ := ctx.extractType(sym); typ != nil { + return typ + } + return sym +} + +// directValueType names the one type a value is of (see directValueTypes); an object +// answers with its declared type, a selected variant with the variant. +func (ctx *Context) directValueType(scope *symbols.Scope, value Value) (*symbols.Symbol, error) { + if _, ok := value.Object(); ok { + types, err := ctx.directValueTypes(scope, value) + if err != nil { + return nil, err + } + return types[0], nil + } + switch value.Kind { + case ValConst: + switch value.Const.Kind { + case semantics.ValInt, semantics.ValReal, semantics.ValBool: + return ctx.scalarValueType(scope, value) + case semantics.ValInfinity: + // `*` is the natural number exceeding every other (KerML 8.4.4.6). + if positive := ctx.librarySymbol(positiveTypeFQN); positive != nil { + return positive, nil + } + return nil, fmt.Errorf("%w: direct type %q", ErrUndeterminedValueType, positiveTypeFQN) + default: + return nil, fmt.Errorf("%w: %s", ErrUndeterminedValueType, value.Kind) + } + case ValString, ValComplex: + return ctx.scalarValueType(scope, value) + case ValVariant: + if value.Variant() == nil { + return nil, fmt.Errorf("%w: variant", ErrUndeterminedValueType) + } + return value.Variant(), nil + case ValEnumLiteral: + if value.Literal() == nil { + return nil, fmt.Errorf("%w: enumeration literal", ErrUndeterminedValueType) + } + enum := semantics.EnumerationOwning(value.Literal()) + if enum == nil { + return nil, fmt.Errorf("%w: enumeration literal %s", + ErrUndeterminedValueType, value.Literal().Name) + } + return enum, nil + case ValFunction: + // A function is of the calc it is a value of: a usage's type is that usage. + if value.Function() == nil { + return nil, fmt.Errorf("%w: function", ErrUndeterminedValueType) + } + return value.Function(), nil + case ValMetaobject: + // A metaobject is an instance of the reflective metaclass of its element. + if value.MetaobjectClass() == nil { + return nil, fmt.Errorf("%w: metaobject", ErrUndeterminedValueType) + } + return value.MetaobjectClass(), nil + case ValQuantity: + if value.Quantity() == nil { + return nil, fmt.Errorf("%w: quantity", ErrUndeterminedValueType) + } + return ctx.directValueType(scope, Value{Kind: ValConst, Const: value.Quantity().Num}) + case ValArray, ValVector, ValVectorQuantity, ValTensorQuantity: + return ctx.structuredValueType(value) + case ValMeasurementRef: + return ctx.measurementRefValueType(value.MeasurementRef()) + case ValCoordinateFrame: + return ctx.frameValueType(value.CoordinateFrame()) + case ValCoordinateTransformation: + return ctx.transformationValueType(value.CoordinateTransformation()) + default: + return nil, fmt.Errorf("%w: %s", ErrUndeterminedValueType, value.Kind) + } +} + +// scalarValueType is the ScalarValues type a scalar's representation states (a finite +// real a Rational, KerML 8.4.4.9.2), whatever same-named type scope sees; a NaN is of none. +func (ctx *Context) scalarValueType(scope *symbols.Scope, value Value) (*symbols.Symbol, error) { + prim := representationPrim(value) + if prim == semantics.PrimUnknown { + return nil, fmt.Errorf("%w: NaN is of no scalar type", ErrUndeterminedValueType) + } + if scalar := ctx.scalarLibraryType(value); scalar != nil { + return scalar, nil + } + fqn := semantics.ScalarFQN(prim) + // Only in a library-free model may a same-named type the model declares stand in. + if !ctx.libraryLoaded() { + if typ := ctx.resolveType(scope, fqn[strings.LastIndex(fqn, "::")+2:]); typ != nil { + return typ, nil + } + } + return nil, fmt.Errorf("%w: direct type %q", ErrUndeterminedValueType, fqn) +} + +// evalClassification evaluates `@T` (metadata T annotates the subject) and `@@T` +// (the subject's own metaclass conforms to T) through the semantic model, so the +// verdict is the one an element filter writing the same test reaches. +func (ec *EvalContext) evalClassification(n *ast.OperatorExpr) (Value, error) { + elem, err := ec.classifiedElement(n) + if err != nil { + return Value{}, err + } + classified, err := ec.ctx.model.semantics.EvalClassification(ec.scope, n, elem) + if err != nil { + return Value{}, err + } + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: classified}}, nil +} + +// classifiedElement is the element a classification's subject denotes, since +// metadata annotates elements and not values: the object being evaluated for an +// implicit subject or `self`, the element a name names, else what its value +// denotes. +func (ec *EvalContext) classifiedElement(n *ast.OperatorExpr) (*symbols.Symbol, error) { + if len(n.Operands) > 1 { + return nil, semantics.UnevaluableClassification( + fmt.Sprintf("`%s` classifies one subject, and %d were given", n.Operator, len(n.Operands)), n.Span()) + } + if len(n.Operands) == 0 || isSelfName(n.Operands[0]) { + if ec.self == nil { + return nil, semantics.UnevaluableClassification( + fmt.Sprintf("`%s` leaves its subject implicit and no object is being evaluated", n.Operator), n.Span()) + } + return ec.self.Type, nil + } + subject := n.Operands[0] + // A name is the element it names: what `p @ Safety` classifies is the + // declaration p, the same element a filter condition would be judged for. + if qn := subjectName(subject); qn != nil { + if sym, ok := ec.ctx.resolveQualified(ec.scope, qn); ok && sym != nil { + return sym, nil + } + } + val, err := ec.Eval(subject) + if err != nil { + return nil, err + } + elem, ok := ec.elementDenotedBy(val) + if !ok { + return nil, semantics.UnevaluableClassification( + fmt.Sprintf("a %s denotes no element to classify", val.Kind), subject.Span()) + } + return elem, nil +} + +// elementDenotedBy is the element a value stands for: the classifier an object +// was materialized from, the variant a variation was bound to, or the literal an +// enumeration value is. Every other value is a datum, which nothing annotates. +func (ec *EvalContext) elementDenotedBy(val Value) (*symbols.Symbol, bool) { + switch val.Kind { + case ValInstance: + inst, ok := ec.ctx.instances[val.Instance] + if !ok || inst.Type == nil { + return nil, false + } + return inst.Type, true + case ValVariant: + return val.Variant(), val.Variant() != nil + case ValMetaobject: + return val.MetaobjectElement(), val.MetaobjectElement() != nil + default: + literal := val.EnumerationLiteral() + return literal, literal != nil + } +} + +// subjectName is the qualified name a classification's subject is written as, or +// nil for a subject that is no name. +func subjectName(n ast.Node) *ast.QualifiedName { + switch subject := n.(type) { + case *ast.FeatureReference: + return subject.Name + case *ast.QualifiedName: + return subject + default: + return nil + } +} + +// isSelfName reports whether a subject is written as `self`, which names the +// object being evaluated — the same subject the notation leaves out. +func isSelfName(n ast.Node) bool { + qn := subjectName(n) + return qn != nil && len(qn.Parts) == 1 && qn.Parts[0].Text == "self" +} + +// evalConditional evaluates `if c ? a else b`, evaluating only the branch the +// condition selects — the other one is never evaluated, so a guarded recursion +// terminates at its base case. +func (ec *EvalContext) evalConditional(n *ast.OperatorExpr) (Value, error) { + if len(n.Operands) != 3 { + return Value{}, fmt.Errorf("conditional requires 3 operands, got %d", len(n.Operands)) + } + cond, err := ec.valueOperand(n.Operands[0]) + if err != nil { + return Value{}, err + } + // A condition the model leaves open selects no branch, so neither is + // evaluated; the result holds as many values as either branch declares. + if cond.Kind == ValUndetermined { + if err := ec.ctx.openBoolOperand("condition of 'if'", cond); err != nil { + return Value{}, err + } + count := ec.declaredCount(ec.scope, n.Operands[1]).Covering(ec.declaredCount(ec.scope, n.Operands[2])) + return undeterminedOf(count, cond), nil + } + held, err := boolOperand("condition of 'if'", cond) + if err != nil { + return Value{}, err + } + if held { + return ec.Eval(n.Operands[1]) + } + return ec.Eval(n.Operands[2]) +} + +// evalNullCoalesce evaluates `a ?? b`, evaluating b only when a is empty. +func (ec *EvalContext) evalNullCoalesce(n *ast.OperatorExpr) (Value, error) { + if len(n.Operands) != 2 { + return Value{}, fmt.Errorf("'??' requires 2 operands, got %d", len(n.Operands)) + } + left, err := ec.Eval(n.Operands[0]) + if err != nil { + return Value{}, err + } + second := func() (Value, error) { return ec.Eval(n.Operands[1]) } + return coalesceNull(left, second, ec.declaredCount(ec.scope, n.Operands[1])) +} + +// coalesceNull is `??` over an evaluated first operand: the operand unless it +// is empty, else the second operand, evaluated only then. fallback is the count +// the second would yield, as far as the declarations fix it. +func coalesceNull(first Value, second func() (Value, error), fallback semantics.Range) (Value, error) { + // An open operand that may be empty leaves which operand `??` yields open: + // the first, holding at least one value, or the second. + if first.Kind == ValUndetermined && !certainlyNonEmpty(first) { + if certainlyEmpty(first) { + return second() + } + return undeterminedOf(nonEmptyCount(countOf(first)).Covering(fallback), first), nil + } + if !isEmptyValue(first) { + return first, nil + } + return second() +} + +// isEmptyValue reports whether a value is the empty sequence, which `null`, +// `()` and an empty set all denote. +func isEmptyValue(val Value) bool { + switch val.Kind { + case ValNull: + return true + case ValSequence, ValSet: + return len(elementsOf(val)) == 0 + } + return false +} + +// evalIdentity evaluates the identity operators (===, !==). Two values are the +// same one when they have the same kind and the same content, so an Integer is +// never identical to a Real of equal magnitude. +func (ec *EvalContext) evalIdentity(n *ast.OperatorExpr) (Value, error) { + if len(n.Operands) != 2 { + return Value{}, fmt.Errorf("identity requires 2 operands, got %d", len(n.Operands)) + } + left, err := ec.Eval(n.Operands[0]) + if err != nil { + return Value{}, err + } + right, err := ec.Eval(n.Operands[1]) + if err != nil { + return Value{}, err + } + if _, open := undeterminedIn(left, right); open { + return undeterminedResult(left, right), nil + } + + same := valueIdentical(left, right) + if n.Operator == ast.OpNeqEqEq { + same = !same + } + return boolValue(same), nil +} + +// valueIdentical reports whether two values are the same value, which is what +// the identity operator `===` and SequenceFunctions::same ask. Identity is +// stricter than equality: a value of another kind, or a constant of another +// kind, is never the same value, so an Integer is not identical to a Real of +// equal magnitude, nor an enumeration's literal to the bare scalar it equals or +// to another enumeration's literal of that value. +func valueIdentical(left, right Value) bool { + if isEmptyValue(left) || isEmptyValue(right) { + return isEmptyValue(left) && isEmptyValue(right) + } + if left.Kind != right.Kind || left.EnumerationLiteral() != right.EnumerationLiteral() { + return false + } + if left.Kind == ValConst && left.Const.Kind != right.Const.Kind { + return false + } + return valueEqual(left, right) +} + +// evalArithmetic evaluates arithmetic operators (+, -, *, /, %, **). +func (ec *EvalContext) evalArithmetic(n *ast.OperatorExpr) (Value, error) { + if len(n.Operands) < 2 { + return Value{}, fmt.Errorf("arithmetic operator requires 2 operands") + } + left, err := ec.valueOperand(n.Operands[0]) + if err != nil { + return Value{}, err + } + right, err := ec.valueOperand(n.Operands[1]) + if err != nil { + return Value{}, err + } + // Operator notation over a vector is the VectorFunctions operator of the same + // symbol, which specializes DataFunctions'; over a tensor, TensorCalculations'. + if val, ok, err := ec.ctx.vectorArithmetic(n.Operator, left, right); ok { + return val, err + } + if val, ok, err := ec.ctx.tensorArithmetic(n.Operator, left, right); ok { + return val, err + } + return ec.ctx.arithmeticValues(n.Operator, left, right, n.Span()) +} + +// arithmeticValues applies a binary arithmetic operator to two evaluated +// operands; the operator notation and the library's `'+'` forms both use it. +func (ctx *Context) arithmeticValues(op ast.OperatorKind, left, right Value, span source.Span) (Value, error) { + // An operand the model leaves open leaves the result open, unless the + // determined operand or the open one's declared type alone makes the operation fail. + if _, open := undeterminedIn(left, right); open { + if err := definiteArithmeticError(op, left, right); err != nil { + return Value{}, err + } + if err := ctx.openBinaryOperands(op, left, right, span, arithmeticDomain(op)); err != nil { + return Value{}, err + } + return undeterminedResult(left, right), nil + } + // '+' over two strings concatenates, the one arithmetic operator + // StringFunctions declares; a non-string operand is not coerced. + if op == ast.OpAdd && left.Kind == ValString && right.Kind == ValString { + return concatStrings(left.Str(), right.Str()), nil + } + + // A product, quotient or power of measurement references is the unit it composes. + if ref, ok, err := ctx.composeMeasurementRefs(op, left, right); ok { + return ref, err + } + // A coordinate frame times or over a unit is the frame of composed axes. + if frame, ok, err := ctx.composeFrame(op, left, right); ok { + return frame, err + } + + // A quantity carries its unit through arithmetic: a sum converts, a product + // composes units. + if lq, rq, ok := quantityOperands(left, right); ok { + switch op { + case ast.OpAdd, ast.OpSub: + return ctx.addQuantities(op, lq, rq) + case ast.OpMul, ast.OpDiv: + return ctx.scaleQuantities(op, lq, rq) + case ast.OpPow: + if right.Kind != ValConst { + return Value{}, fmt.Errorf("%w: exponent of a quantity is a quantity", ErrTypeMismatch) + } + return ctx.powQuantity(lq, right.Const) + case ast.OpMod: + return Value{}, fmt.Errorf("%w: '%%' is not defined for a quantity", ErrTypeMismatch) + } + } + + // A complex operand makes the operation ComplexFunctions', the numeric + // operand beside it being a Complex too. + if lz, rz, ok := complexOperands(left, right); ok { + return complexArithmetic(op, lz, rz, left, right, span) + } + + // Arithmetic is defined on constants; anything else names the operator and + // both operand types rather than reporting a bare mismatch. + if left.Kind != ValConst || right.Kind != ValConst { + return Value{}, &OperandTypeError{ + Op: op.String(), + Left: describeOperand(left), + Right: describeOperand(right), + Span: span, + } + } + + res, err := constArithmetic(op, left.Const, right.Const) + if err != nil { + return Value{}, err + } + return Value{Kind: ValConst, Const: res}, nil +} + +// definiteArithmeticError is why op over an open operand fails whatever that +// operand holds: the other is a zero divisor or the unbounded `*`. +func definiteArithmeticError(op ast.OperatorKind, left, right Value) error { + for _, operand := range []Value{left, right} { + if operand.Kind == ValConst && operand.Const.IsUnbounded() { + return fmt.Errorf("%w: operator '%s' is not defined for the unbounded value '*'", ErrTypeMismatch, op) + } + } + if op != ast.OpDiv && op != ast.OpMod { + return nil + } + if q, ok := asQuantity(right); ok && q.Num.IsNumeric() && q.Num.AsReal() == 0 { + return ErrDivisionByZero + } + return nil +} + +// constArithmetic is arithmetic over two scalar constants, the core the +// evaluator and the compiled calc tier share so both report the same results +// and the same errors. +func constArithmetic(op ast.OperatorKind, left, right semantics.Value) (semantics.Value, error) { + // The unbounded `*` is no number: arithmetic over it is refused rather than + // answered with a finite result or an infinity. + if left.IsUnbounded() || right.IsUnbounded() { + return semantics.Value{}, fmt.Errorf("%w: operator '%s' is not defined for the unbounded value '*': %s %s %s", + ErrTypeMismatch, op, semantics.FormatConst(left), op, semantics.FormatConst(right)) + } + + // Exponentiation shares the folder's implementation, so a folded and an + // evaluated `**` agree; the folder declines where this reports the error. + if op == ast.OpPow { + return semantics.Pow(left, right) + } + + // Integer arithmetic: an out-of-range result is reported, not wrapped. + if left.Kind == semantics.ValInt && right.Kind == semantics.ValInt { + // A quotient is a Rational: the exact ratio, rounded once to float64 so + // operands beyond 2^53 are not rounded before dividing. + if op == ast.OpDiv { + q, ok := semantics.IntQuotient(left.Int, right.Int) + if !ok { + return semantics.Value{}, ErrDivisionByZero + } + return semantics.Value{Kind: semantics.ValReal, Real: q}, nil + } + var result int64 + switch op { + case ast.OpAdd, ast.OpSub, ast.OpMul: + var ok bool + if result, ok = semantics.IntArith(op, left.Int, right.Int); !ok { + return semantics.Value{}, semantics.IntegerOverflow(op, left.Int, right.Int) + } + case ast.OpMod: + if right.Int == 0 { + return semantics.Value{}, ErrDivisionByZero + } + result = left.Int % right.Int + } + return semantics.Value{Kind: semantics.ValInt, Int: result}, nil + } + + // Real arithmetic (coerce int to real if needed) + leftReal := toReal(left) + rightReal := toReal(right) + var result float64 + switch op { + case ast.OpAdd: + result = leftReal + rightReal + case ast.OpSub: + result = leftReal - rightReal + case ast.OpMul: + result = leftReal * rightReal + case ast.OpDiv: + // A real quotient by zero is reported, as an integer one, a quantity one + // and the constant folder all report it, rather than carried as an infinity. + if rightReal == 0 { + return semantics.Value{}, ErrDivisionByZero + } + result = leftReal / rightReal + case ast.OpMod: + if rightReal == 0 { + return semantics.Value{}, ErrDivisionByZero + } + result = math.Mod(leftReal, rightReal) + } + // A result that is not a finite Real is reported, not carried as an infinity. + return semantics.RealResult(result) +} + +// toReal converts a semantics.Value to float64. +func toReal(v semantics.Value) float64 { + if v.Kind == semantics.ValInt { + return float64(v.Int) + } + return v.Real +} + +// evalEquality evaluates equality operators (==, !=). +func (ec *EvalContext) evalEquality(n *ast.OperatorExpr) (Value, error) { + if len(n.Operands) != 2 { + return Value{}, fmt.Errorf("equality requires 2 operands, got %d", len(n.Operands)) + } + + left, err := ec.Eval(n.Operands[0]) + if err != nil { + return Value{}, err + } + right, err := ec.Eval(n.Operands[1]) + if err != nil { + return Value{}, err + } + return ec.ctx.equalityValues(n.Operator, soleElement(left), soleElement(right)) +} + +// equalityValues applies `==` or `!=` to two evaluated operands; the operator +// notation and the library's `'=='` forms both use it. +func (ctx *Context) equalityValues(op ast.OperatorKind, left, right Value) (Value, error) { + if _, open := undeterminedIn(left, right); open { + return undeterminedResult(left, right), nil + } + // Comparing a value with a variant compares it with the value that variant + // declares; comparing two variants compares the choice itself. + if (left.Kind == ValVariant) != (right.Kind == ValVariant) { + var err error + if left, err = ctx.variantAsValue(left); err != nil { + return Value{}, err + } + if right, err = ctx.variantAsValue(right); err != nil { + return Value{}, err + } + } + + // Quantities compare in a common unit; incommensurable ones are an error, + // not an inequality. + if lq, rq, ok := quantityOperands(left, right); ok { + return ctx.equalQuantities(op, lq, rq) + } + + // Two Collection objects compare by their elements (CollectionFunctions::'=='). + if lc, ok, err := ctx.collectionObjectElements(left); err != nil { + return Value{}, err + } else if ok { + if rc, ok, err := ctx.collectionObjectElements(right); err != nil { + return Value{}, err + } else if ok { + left, right = lc, rc + } + } + + equal := ctx.equalValues(left, right) + if op == ast.OpNeq { + equal = !equal + } + return boolValue(equal), nil +} + +// evalComparison evaluates comparison operators (<, <=, >, >=). +func (ec *EvalContext) evalComparison(n *ast.OperatorExpr) (Value, error) { + if len(n.Operands) != 2 { + return Value{}, fmt.Errorf("comparison requires 2 operands, got %d", len(n.Operands)) + } + + left, err := ec.valueOperand(n.Operands[0]) + if err != nil { + return Value{}, err + } + + right, err := ec.valueOperand(n.Operands[1]) + if err != nil { + return Value{}, err + } + return ec.ctx.comparisonValues(n.Operator, left, right, n.Span()) +} + +// comparisonValues applies an ordering operator to two evaluated operands; the +// operator notation and the library's `'<'` forms both use it. +func (ctx *Context) comparisonValues(op ast.OperatorKind, left, right Value, span source.Span) (Value, error) { + if _, open := undeterminedIn(left, right); open { + if err := ctx.openBinaryOperands(op, left, right, span, comparisonDomain); err != nil { + return Value{}, err + } + return undeterminedResult(left, right), nil + } + // Quantities are ordered on a common reference, so a magnitude is never + // compared across units or scales without conversion. + if lq, rq, ok := quantityOperands(left, right); ok { + return ctx.compareQuantities(op, lq, rq) + } + + // StringFunctions declares the comparisons over two String operands, so a + // string orders against a string and against nothing else. + if left.Kind == ValString || right.Kind == ValString { + if left.Kind != ValString || right.Kind != ValString { + return Value{}, &OperandTypeError{ + Op: op.String(), + Left: describeOperand(left), + Right: describeOperand(right), + Span: span, + } + } + ordered, err := compareStrings(op, left.Str(), right.Str()) + if err != nil { + return Value{}, err + } + return boolValue(ordered), nil + } + + // Numbers, Strings and quantities are ordered, so the operand blamed is the + // other one, or the pairing when a quantity meets the unbounded value. + refuse := func(library string) (Value, error) { + return Value{}, &OperandTypeError{ + Op: op.String(), + Left: describeOperand(left), + Right: describeOperand(right), + Library: library, + Span: span, + } + } + for _, val := range []Value{left, right} { + if val.Kind != ValConst && val.Kind != ValQuantity { + return refuse(ctx.orderingGap(op, val)) + } + } + if left.Kind == ValQuantity || right.Kind == ValQuantity { + for _, val := range []Value{left, right} { + if val.Kind == ValConst && val.Const.Kind == semantics.ValBool { + return refuse(booleanOrderingGap(op)) + } + if val.Kind == ValConst { + return refuse(fmt.Sprintf("QuantityCalculations::'%s' takes ScalarQuantityValue operands and %s is none", op, describeOperand(val))) + } + } + } + + result, err := constComparison(op, left.Const, right.Const) + if err != nil { + var mismatch *OperandTypeError + if errors.As(err, &mismatch) { + mismatch.Span = span + } + return Value{}, err + } + return boolValue(result), nil +} + +// orderingGap says which Kernel Function Library function would have to declare +// the ordering operator op for val, and why none does: DataFunctions::'<' and +// ScalarFunctions::'<' are abstract, declared concretely by the numeric libraries +// and StringFunctions alone. +func (ctx *Context) orderingGap(op ast.OperatorKind, val Value) string { + fn := func(pkg string) string { return fmt.Sprintf("%s::'%s'", pkg, op) } + notScalar := func(what string) string { + return fmt.Sprintf("%s is abstract and no library function declares '%s' for %s, which is no ScalarValue", fn("DataFunctions"), op, what) + } + switch val.Kind { + case ValComplex: + return fmt.Sprintf("%s is abstract and ComplexFunctions declares no '%s' for Complex", fn("NumericalFunctions"), op) + case ValEnumLiteral: + enum := semantics.EnumerationOwning(val.Literal()) + if enum == nil { + return notScalar(val.LiteralText()) + } + if ctx.conformsToLibrary(enum, "ScalarValues::ScalarValue") { + return fmt.Sprintf("the library orders %s by value and %s declares none", enum.Name, val.LiteralText()) + } + return notScalar("the enumeration " + enum.Name) + case ValNull, ValSequence, ValSet: + return fmt.Sprintf("%s takes one DataValue per operand", fn("DataFunctions")) + case ValInstance, ValVariant: + what := describeOperand(val) + if types, err := ctx.directValueTypes(nil, val); err == nil && len(types) > 0 { + what = fmt.Sprintf("an instance of the %s %s", ast.Notation(types[0].Decl), types[0].Name) + } + if ctx.isDataValue(val) { + return notScalar(what) + } + return fmt.Sprintf("%s takes DataValue operands and %s is none", fn("DataFunctions"), what) + case ValFunction, ValExpr: + return fmt.Sprintf("%s takes DataValue operands and %s is none", fn("DataFunctions"), describeOperand(val)) + } + return notScalar(describeOperand(val)) +} + +// booleanOrderingGap is orderingGap for a Boolean, which the compiled tier +// reports without a Context. +func booleanOrderingGap(op ast.OperatorKind) string { + return fmt.Sprintf("ScalarFunctions::'%s' is abstract and BooleanFunctions declares no '%s' for Boolean", op, op) +} + +// constComparison orders two scalar constants, the core the evaluator and the +// compiled calc tier share. +func constComparison(op ast.OperatorKind, left, right semantics.Value) (bool, error) { + // A Boolean is a ScalarValue no library orders, so it is refused rather + // than read as a number. + if left.Kind == semantics.ValBool || right.Kind == semantics.ValBool { + return false, &OperandTypeError{ + Op: op.String(), + Left: describeValue(Value{Kind: ValConst, Const: left}), + Right: describeValue(Value{Kind: ValConst, Const: right}), + Library: booleanOrderingGap(op), + } + } + // The unbounded `*` orders above every finite number and equals itself. + if left.IsUnbounded() || right.IsUnbounded() { + order, ok := semantics.UnboundedOrder(left, right) + if !ok { + return false, fmt.Errorf("%w: '%s' is not defined between %s and %s", + ErrTypeMismatch, op, semantics.FormatConst(left), semantics.FormatConst(right)) + } + res, ok := semantics.OrderSatisfies(op, order) + if !ok { + return false, fmt.Errorf("unknown comparison operator: %v", op) + } + return res, nil + } + + // Compare integers + if left.Kind == semantics.ValInt && right.Kind == semantics.ValInt { + switch op { + case ast.OpLt: + return left.Int < right.Int, nil + case ast.OpLe: + return left.Int <= right.Int, nil + case ast.OpGt: + return left.Int > right.Int, nil + case ast.OpGe: + return left.Int >= right.Int, nil + default: + return false, fmt.Errorf("unknown comparison operator: %v", op) + } + } + + // Compare reals (coerce int to real) + leftReal := toReal(left) + rightReal := toReal(right) + switch op { + case ast.OpLt: + return leftReal < rightReal, nil + case ast.OpLe: + return leftReal <= rightReal, nil + case ast.OpGt: + return leftReal > rightReal, nil + case ast.OpGe: + return leftReal >= rightReal, nil + default: + return false, fmt.Errorf("unknown comparison operator: %v", op) + } +} + +// evalLogical evaluates the Boolean binary operators. `and`, `or` and `implies` +// decide on their left operand alone where they can, so the operand a guard +// rules out is never evaluated. +func (ec *EvalContext) evalLogical(n *ast.OperatorExpr) (Value, error) { + if len(n.Operands) != 2 { + return Value{}, fmt.Errorf("logical operator requires 2 operands, got %d", len(n.Operands)) + } + + left, err := ec.valueOperand(n.Operands[0]) + if err != nil { + return Value{}, err + } + // An open left operand decides nothing, so the right one is read: where it + // fixes the result on its own, the result is known. + if left.Kind == ValUndetermined { + if err := ec.ctx.openBoolOperand(fmt.Sprintf("left operand of '%s'", n.Operator), left); err != nil { + return Value{}, err + } + right, err := ec.valueOperand(n.Operands[1]) + if err != nil { + return Value{}, err + } + return ec.ctx.logicalWithOpenLeft(n.Operator, left, right) + } + l, err := boolOperand(fmt.Sprintf("left operand of '%s'", n.Operator), left) + if err != nil { + return Value{}, err + } + + if decided, result := shortCircuit(n.Operator, l); decided { + return boolValue(result), nil + } + + right, err := ec.Eval(n.Operands[1]) + if err != nil { + return Value{}, err + } + return ec.ctx.logicalWithRight(n.Operator, l, right) +} + +// logicalWithRight is a Boolean operator its left operand l did not decide, over +// the right one; an open right leaves it open once its declared type admits a Boolean. +func (ctx *Context) logicalWithRight(op ast.OperatorKind, l bool, right Value) (Value, error) { + what := fmt.Sprintf("right operand of '%s'", op) + if right.Kind == ValUndetermined { + if err := ctx.openBoolOperand(what, right); err != nil { + return Value{}, err + } + return undeterminedResult(right), nil + } + r, err := boolOperand(what, right) + if err != nil { + return Value{}, err + } + return combineBooleans(op, l, r) +} + +// logicalWithOpenLeft is a Boolean operator over an open left operand: a right one +// fixing the result alone (`and false`, `or true`, `implies true`) fixes it here too. +func (ctx *Context) logicalWithOpenLeft(op ast.OperatorKind, left, right Value) (Value, error) { + what := fmt.Sprintf("right operand of '%s'", op) + if right.Kind == ValUndetermined { + if err := ctx.openBoolOperand(what, right); err != nil { + return Value{}, err + } + return undeterminedResult(left, right), nil + } + r, err := boolOperand(what, right) + if err != nil { + return Value{}, err + } + switch op { + case ast.OpAnd, ast.OpConditionalAnd: + if !r { + return boolValue(false), nil + } + case ast.OpOr, ast.OpConditionalOr, ast.OpImplies: + if r { + return boolValue(true), nil + } + } + return undeterminedResult(left), nil +} + +// combineBooleanValues applies a binary Boolean operator to two evaluated +// operands, either of which the model may leave open. +func (ctx *Context) combineBooleanValues(op ast.OperatorKind, left, right Value) (Value, error) { + what := fmt.Sprintf("left operand of '%s'", op) + if left.Kind == ValUndetermined { + if err := ctx.openBoolOperand(what, left); err != nil { + return Value{}, err + } + return ctx.logicalWithOpenLeft(op, left, right) + } + l, err := boolOperand(what, left) + if err != nil { + return Value{}, err + } + if decided, result := shortCircuit(op, l); decided { + return boolValue(result), nil + } + return ctx.logicalWithRight(op, l, right) +} + +// shortCircuit reports whether a Boolean operator is decided by its left +// operand alone, and the result when it is: `and` by false, `or` by true and +// `implies` by false. `xor`, `|` and `&` always read both operands. +func shortCircuit(op ast.OperatorKind, l bool) (decided, result bool) { + switch op { + case ast.OpAnd, ast.OpConditionalAnd: + return !l, false + case ast.OpOr, ast.OpConditionalOr: + return l, true + case ast.OpImplies: + return !l, true + } + return false, false +} + +// combineBooleans applies a binary Boolean operator to two Booleans; the +// operator notation and the library's `'xor'` forms both use it. +func combineBooleans(op ast.OperatorKind, l, r bool) (Value, error) { + switch op { + case ast.OpAnd, ast.OpConditionalAnd: + return boolValue(l && r), nil + case ast.OpOr, ast.OpConditionalOr: + return boolValue(l || r), nil + case ast.OpXor: + return boolValue(l != r), nil + case ast.OpImplies: + return boolValue(!l || r), nil + } + return Value{}, fmt.Errorf("%w: '%s' is not a Boolean operator", ErrUnsupportedOperator, op) +} + +// valueOperand evaluates an operand an operator needs one value of: a feature +// holding none is reported as such, not as an operand of the wrong type, and a +// one-element collection is the value it holds. +func (ec *EvalContext) valueOperand(node ast.Node) (Value, error) { + val, err := ec.Eval(node) + if err != nil { + return Value{}, err + } + if ec.ctx.HoldsNoValue(val) { + return Value{}, ec.ctx.noValueError(val, node) + } + return soleElement(val), nil +} + +// boolOperand reads a Boolean out of a value, naming what was expected when the +// value is not one. +func boolOperand(what string, v Value) (bool, error) { + v = soleElement(v) + if v.Kind != ValConst || v.Const.Kind != semantics.ValBool { + return false, fmt.Errorf("%w: %s must be Boolean, got %s", ErrTypeMismatch, what, v.Kind) + } + return v.Const.Bool, nil +} + +// evalUnary evaluates the unary operators (-, +, not). +func (ec *EvalContext) evalUnary(n *ast.OperatorExpr) (Value, error) { + if len(n.Operands) != 1 { + return Value{}, fmt.Errorf("unary operator requires 1 operand, got %d", len(n.Operands)) + } + + // The least Integer is the one literal whose magnitude alone is outside the + // range, so its sign is read together with it; every other operand is + // evaluated as usual. + if n.Operator == ast.OpNeg { + if lit, ok := n.Operands[0].(*ast.LiteralInteger); ok { + if _, err := strconv.ParseInt(lit.Value, 10, 64); err != nil { + if val, err := strconv.ParseInt("-"+lit.Value, 10, 64); err == nil { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: val}}, nil + } + } + } + } + + operand, err := ec.valueOperand(n.Operands[0]) + if err != nil { + return Value{}, err + } + if isVectorKind(operand) { + switch n.Operator { + case ast.OpPos: + return operand, nil + case ast.OpNeg: + return vectorSubtract("VectorFunctions::'-'", ec.ctx, []Value{operand, nullValue()}) + } + } + return ec.ctx.unaryValue(n.Operator, operand) +} + +// unaryValue applies `not`, `-` or `+` to an evaluated operand; the operator +// notation and the library's `'not'` forms both use it. +func (ctx *Context) unaryValue(op ast.OperatorKind, operand Value) (Value, error) { + if operand.Kind == ValUndetermined { + if err := ctx.openUnaryOperand(op, operand); err != nil { + return Value{}, err + } + return undeterminedResult(operand), nil + } + switch op { + case ast.OpNot: + if operand.Kind != ValConst { + return Value{}, fmt.Errorf("%w: logical not requires bool operand, got %v", ErrTypeMismatch, operand.Kind) + } + case ast.OpNeg, ast.OpPos: + if operand.Kind == ValQuantity { + if op == ast.OpPos { + return operand, nil + } + return ctx.negateQuantity(operand.Quantity()) + } + if operand.Kind == ValComplex { + if op == ast.OpPos { + return operand, nil + } + return NewComplex(-operand.Complex()), nil + } + // Arithmetic sign: -number, +number + if operand.Kind != ValConst { + return Value{}, fmt.Errorf("%w: unary '%s' requires numeric operand, got %v", ErrTypeMismatch, op, operand.Kind) + } + default: + return Value{}, fmt.Errorf("%w: '%s' is not a unary operator", ErrUnsupportedOperator, op) + } + result, err := constUnary(op, operand.Const) + if err != nil { + return Value{}, err + } + return Value{Kind: ValConst, Const: result}, nil +} + +// constUnary applies `not`, `-` or `+` to a scalar constant, the core the +// evaluator and the compiled calc tier share. +func constUnary(op ast.OperatorKind, operand semantics.Value) (semantics.Value, error) { + if op == ast.OpNot { + // Logical not: not bool + if operand.Kind != semantics.ValBool { + return semantics.Value{}, fmt.Errorf("%w: logical not requires bool operand, got %s", ErrTypeMismatch, semantics.FormatConst(operand)) + } + return semantics.Value{Kind: semantics.ValBool, Bool: !operand.Bool}, nil + } + if op == ast.OpNeg && operand.Kind == semantics.ValInt && operand.Int == math.MinInt64 { + return semantics.Value{}, fmt.Errorf("%w: -(%d) exceeds the Integer range", + semantics.ErrArithmeticOverflow, operand.Int) + } + result, ok := semantics.EvalUnary(op, operand) + if !ok { + return semantics.Value{}, fmt.Errorf("%w: unary '%s' is not defined for %s", ErrTypeMismatch, op, semantics.FormatConst(operand)) + } + return result, nil +} + +// evalSequenceExpr evaluates a sequence expression, `(1, 2, 3)`. A KerML +// sequence is flat: an element that is itself a collection contributes its +// elements, which is what makes SequenceFunctions::union the sequence +// expression `(seq1, seq2)` rather than a two-element sequence of sequences. +func (ec *EvalContext) evalSequenceExpr(n *ast.SequenceExpr) (Value, error) { + elements := make([]Value, 0, len(n.Elements)) + var open bool + for _, elem := range n.Elements { + val, err := ec.Eval(elem) + if err != nil { + return Value{}, err + } + if val.Kind == ValUndetermined { + open = true + elements = append(elements, val) + continue + } + elements = append(elements, elementsOf(val)...) + } + if open { + return undeterminedElements(elements), nil + } + return ec.newSequence(elements) +} + +// evalCollectExpr evaluates `operand.{in x; ...}`, the collect notation, which +// KerML defines as ControlFunctions::collect of the operand and the body. It +// evaluates through that one implementation, so the notation and the call +// `collect(seq, {in x; ...})` compute the same result. +func (ec *EvalContext) evalCollectExpr(n *ast.CollectExpr) (Value, error) { + return ec.evalCollectionNotation("collect", n.Operand, n.Body, builtinControlCollect) +} + +// evalSelectExpr evaluates `operand.?{in x; ...}`, the select notation, which +// KerML defines as ControlFunctions::select of the operand and the body. +func (ec *EvalContext) evalSelectExpr(n *ast.SelectExpr) (Value, error) { + return ec.evalCollectionNotation("select", n.Operand, n.Body, builtinControlSelect) +} + +// evalCollectionNotation evaluates a notation whose meaning is a library +// function of an operand and a body: the body is evaluated to the function it +// denotes rather than to a value, and the operation decides what to call it +// with. +func (ec *EvalContext) evalCollectionNotation( + notation string, + operandExpr, bodyExpr ast.Node, + fn builtinFunc, +) (Value, error) { + operand, err := ec.Eval(operandExpr) + if err != nil { + return Value{}, err + } + if bodyExpr == nil { + return Value{}, fmt.Errorf("%w: %s states no body", ErrNoResultExpression, notation) + } + body, err := ec.Eval(bodyExpr) + if err != nil { + return Value{}, err + } + return fn(ec, []Value{operand, body}) +} + +// invocationKey identifies one invocation expression in the scope it is +// evaluated in, which is what its written name resolves against. +type invocationKey struct { + node *ast.InvocationExpr + scope *symbols.Scope + // redefining is the type whose redefinitions the callee answers to: the behavior + // whose run evaluates the expression, else the type of the object it is bound to. + redefining *symbols.Symbol +} + +// invocationTarget is what an invocation expression denotes, resolved once per +// context; at most one implementation is set, in the order they are tried. +type invocationTarget struct { + qualName string + ambiguous []*symbols.Symbol // the equally specific declarations the written name denotes + undetermined []*symbols.Symbol // the declarations the static argument types leave open, settled by the values + calc *symbols.Symbol // the declaration the written name resolves to, nil for none + builtin builtinFunc // the built-in the name denotes: the library declaration calc is + builtinName string // the built-in's registered name, keying its declared signature + library *libraryFunction // the library function the name denotes: the library declaration calc is + shape *calcShape // calc's invocation interface, nil when it has none + predicate bool // calc is a constraint or requirement, applied as a predicate + names []string // the parameter each named argument binds, as calc's signature spells it + unbound []error // per named argument, why calc has no parameter for it; nil when it binds + candidates []*symbols.Symbol // the declarations the written name may denote, which the selection chose among + err error // why no declaration was selected, when selection itself failed +} + +// invocationTarget resolves what n denotes in this context's scope, memoized +// per context: resolution reads only the model, which is fixed for its life. +// The declaration is the one the checker selects for the call, so the two agree: a +// library function is callable only where the model imports it or writes it qualified, +// and a declaration of the model's own is invoked as written even under a name a +// library built-in is registered by. What the selection read of the model is recorded for the +// binding being made on every use, a memoized target's included. +func (ec *EvalContext) invocationTarget(n *ast.InvocationExpr) *invocationTarget { + key := invocationKey{node: n, scope: ec.scope, redefining: ec.redefiningType()} + target, ok := ec.ctx.model.invocationTargets[key] + if !ok { + target = ec.selectInvocationTarget(key, n) + ec.ctx.model.invocationTargets[key] = target + } + ec.ctx.noteInvocationRead(ec.scope, n.Type, target.candidates) + for b := key.redefining; b != nil; b = enclosingBehavior(b) { + ec.ctx.noteTypeRead(b) + } + ec.ctx.noteDeclarationRead(target.calc) + return target +} + +// selectInvocationTarget resolves n as the checker selects it, from the candidates its name denotes. +func (ec *EvalContext) selectInvocationTarget(key invocationKey, n *ast.InvocationExpr) *invocationTarget { + model := ec.ctx.model + target := &invocationTarget{ + qualName: qualifiedNameToString(n.Type), + candidates: model.resolver.InvocationCandidates(ec.scope, n.Type), + } + sel, err := model.selectCall(ec.scope, n, semantics.PerformsBehavior) + if err != nil { + target.err = err + return target + } + switch { + case sel.Ambiguous && sel.Undetermined: + target.undetermined = sel.Tied + case sel.Ambiguous: + target.ambiguous = sel.Tied + case sel.Called() != nil: + sym := sel.Called() + ec.ctx.implementInvocation(target, ec.ctx.inheritedFeature(key.redefining, sym)) + } + if len(n.NamedArgs) > 0 { + target.names, target.unbound = ec.ctx.boundParameterNames(ec.scope, target.calc, n.NamedArgs) + } + return target +} + +// redefiningType is the type whose redefinitions a callee here answers to: the +// running behavior, else the type of the object the expression is bound to. +func (ec *EvalContext) redefiningType() *symbols.Symbol { + if running := ec.runningBehavior(); running != nil { + return running + } + if ec.self != nil { + return ec.self.Type + } + return nil +} + +// runningBehavior is the behavior whose run the expression is evaluated in: the +// owner of the innermost frame a run pushed, nil outside any run. +func (ec *EvalContext) runningBehavior() *symbols.Symbol { + for i := len(ec.frames) - 1; i >= 0; i-- { + if owner := ec.frames[i].owner; owner != nil { + return owner.Sym + } + } + return nil +} + +// inheritedFeature is sym as the running behavior, or one enclosing it, inherits +// it: a feature redefined there answers to the redefinition (KerML §7.3.4.5). +func (ctx *Context) inheritedFeature(running, sym *symbols.Symbol) *symbols.Symbol { + for b := running; b != nil && sym != nil; b = enclosingBehavior(b) { + if !ctx.model.semantics.InheritanceMasked(b, sym) { + continue + } + if redefiner := ctx.model.semantics.NamingRedefiner(b, sym); redefiner != nil { + return redefiner + } + } + return sym +} + +// boundParameterNames is the parameter each named argument binds in callee, spelled as +// callee's signature spells it, and per argument the error when callee has no such +// parameter; a label kept as written. Without a callee every label is kept as written. +func (ctx *Context) boundParameterNames(scope *symbols.Scope, callee *symbols.Symbol, named []ast.NamedArg) ([]string, []error) { + names := make([]string, len(named)) + unbound := make([]error, len(named)) + for i, arg := range named { + if arg.Name == nil || len(arg.Name.Parts) == 0 { + continue + } + names[i] = arg.Name.Text() + if callee == nil || ctx.model.semantics == nil { + continue + } + if name, ok := ctx.model.semantics.BoundParameter(scope, callee, arg.Name); ok { + names[i] = name + } else { + unbound[i] = fmt.Errorf("%w: %s has no parameter named %q", + ErrUnknownParameter, ctx.qualifiedSymbolName(callee), names[i]) + } + } + return names, unbound +} + +// implementInvocation records how a call of the selected sym is applied: by a +// library implementation, by its calc shape, or — when a model calc binds the +// arguments through parameters of its own — by invokeCalcWithSelf. +func (ctx *Context) implementInvocation(target *invocationTarget, sym *symbols.Symbol) { + target.calc = sym + if perf := ctx.libraryCalcPerformed(sym); perf != nil { + if perf.signature != nil { + return + } + sym = perf.lib + } + if fn, ok := ctx.builtinFor(sym); ok { + target.builtin, target.builtinName = fn, ctx.qualifiedSymbolName(sym) + } else if fn, ok := ctx.libraryFunctionFor(sym); ok { + target.library = fn + } else if shape, err := ctx.calcShapeOf(sym); err == nil { + target.shape = shape + } else if isPredicateDecl(sym.Decl) { + target.predicate = true + } +} + +// ambiguousInvocationError names the equally specific declarations a call of +// qualName denotes. +func ambiguousInvocationError(qualName string, candidates []*symbols.Symbol) error { + names := make([]string, len(candidates)) + for i, sym := range candidates { + names[i] = symbols.FQNOf(sym) + } + return fmt.Errorf("%w: %s denotes %s", ErrAmbiguousInvocation, qualName, strings.Join(names, ", ")) +} + +// unresolvedInvocation reports a call to a name that denotes nothing, with the +// same "did you mean" hint the validator gives a reference: for a qualified +// name, the quoted member the segment past the deepest resolved one may start. +func (ec *EvalContext) unresolvedInvocation(qn *ast.QualifiedName, written string) error { + if qn == nil || ec.ctx.model.resolver == nil { + return fmt.Errorf("%w: %s", ErrUnresolvedReference, written) + } + if len(qn.Parts) == 1 && !qn.Global { + return fmt.Errorf("%w: %s", ErrUnresolvedReference, ec.ctx.model.resolver.UnresolvedName(ec.scope, written, qn)) + } + reading := ec.ctx.readQualified(ec.scope, qn) + for i := len(qn.Parts) - 2; i >= 0; i-- { + if owner, ok := reading.Part(i); ok { + return fmt.Errorf("%w: %s", ErrUnresolvedReference, ec.ctx.model.resolver.UnresolvedMember(ec.scope, qn, owner, i+1)) + } + } + return fmt.Errorf("%w: %s", ErrUnresolvedReference, written) +} + +// evalInvocation evaluates a function/calc invocation. +func (ec *EvalContext) evalInvocation(n *ast.InvocationExpr) (Value, error) { + // `holder.f(a)`: the chain names the function applied, not the callee's type. + if chain := semantics.ChainCallee(n); chain != nil { + return ec.evalChainInvocation(n, chain) + } + target := ec.invocationTarget(n) + qualName := target.qualName + if target.err != nil { + return Value{}, fmt.Errorf("%s: %w", qualName, target.err) + } + if len(target.ambiguous) > 0 { + return Value{}, ambiguousInvocationError(qualName, target.ambiguous) + } + if len(target.undetermined) > 0 { + return ec.evalUndeterminedInvocation(n, target) + } + + // A receiver binds by position, so it has no meaning beside arguments that + // bind by name: reported rather than evaluated and dropped. + if n.Operand != nil && len(n.NamedArgs) > 0 { + return Value{}, fmt.Errorf( + "%w: %s is called with a receiver and named arguments", + ErrReceiverWithNamedArgs, qualName, + ) + } + + // Eval args in source order. An operand is the first argument of the + // invocation it is written before: `seq->size()` invokes size with seq, which + // is how the semantics layer reads the same expression, so the two agree on + // which parameter an argument binds. + exprs := semantics.InvocationArgs(n) + // A calc-typed feature bound to a function value here — a parameter given a + // calc as its argument — applies that value, not the feature's own declaration. + if fn, ok, err := ec.boundFunction(target.calc, n.Type); ok { + if err != nil { + return Value{}, err + } + // Named arguments bind parameters of the calc applied, not of the feature named. + applied := *target + if len(n.NamedArgs) > 0 { + applied.names, applied.unbound = ec.ctx.boundParameterNames(ec.scope, fn.Function(), n.NamedArgs) + } + callArgs, err := ec.evalInvocationArgs(qualName, exprs, n.NamedArgs, &applied) + if err != nil { + return Value{}, err + } + return ec.invokeFunction(qualName, fn, callArgs) + } + // A calc bound by position alone consumes its arguments within the call, so + // they live on the context's argument stack rather than in a slice of their own. + if target.shape != nil && len(n.NamedArgs) == 0 { + return ec.invokeCalcShapeStacked(target.shape, exprs, ec.enclosingRun(target.shape)) + } + // A built-in binds its arguments by its declared signature. + if target.builtin != nil { + return ec.invokeBuiltin(target.builtinName, target.builtin, exprs, n.NamedArgs, target.names, target.unbound) + } + + callArgs, err := ec.evalInvocationArgs(qualName, exprs, n.NamedArgs, target) + if err != nil { + return Value{}, err + } + + // An argument that fails is reported before the target is judged. A name + // that resolves to nothing denotes nothing, not the library function of + // that name: the validator reports the same expression unresolved. + if target.calc == nil && target.library == nil { + return Value{}, ec.unresolvedInvocation(n.Type, qualName) + } + return ec.applyInvocation(target, callArgs) +} + +// applyInvocation applies target to arguments already evaluated, through the one calc +// path, so an expression and a direct InvokeCalc bind parameters and trace identically. +func (ec *EvalContext) applyInvocation(target *invocationTarget, callArgs calcArgs) (Value, error) { + if target.library != nil { + return target.library.invoke(ec.ctx, callArgs) + } + if target.predicate { + return ec.invokePredicate(target.calc, callArgs) + } + if target.shape == nil { + return ec.ctx.invokeCalcWithSelf(target.calc, callArgs, ec.scope, ec.self) + } + return ec.ctx.invokeCalcShapeIn(target.shape, callArgs, ec.scope, ec.self, ec.enclosingRun(target.shape)) +} + +// enclosingRun is the environment a nested calc closes over here: the frames through +// the innermost run of the behavior it is declared in, none when no such run is active. +func (ec *EvalContext) enclosingRun(shape *calcShape) []frame { + return runOf(ec.ctx, ec.frames, enclosingBehavior(shape.Sym)) +} + +// evalChainInvocation applies the function value a feature chain denotes to the +// arguments written after it (KerMLExpressions InstantiatedTypeMember → OwnedFeatureChain). +func (ec *EvalContext) evalChainInvocation(n *ast.InvocationExpr, chain *ast.FeatureChainExpr) (Value, error) { + callee := chainText(chain) + fn, err := ec.chainCallee(chain) + if err != nil { + return Value{}, err + } + if fn.Kind != ValFunction { + return Value{}, fmt.Errorf("%w: %s is %s, not a function", ErrNotAFunction, callee, describeValue(fn)) + } + target := &invocationTarget{qualName: callee, calc: fn.Function()} + if len(n.NamedArgs) > 0 { + target.names, target.unbound = ec.ctx.boundParameterNames(ec.scope, fn.Function(), n.NamedArgs) + } + callArgs, err := ec.evalInvocationArgs(callee, n.Args, n.NamedArgs, target) + if err != nil { + return Value{}, err + } + return ec.invokeFunction(callee, fn, callArgs) +} + +// chainCallee is what a feature chain denotes in call position: a calc of the receiver's +// object is the function applied over it even where a bare read would compute its result. +func (ec *EvalContext) chainCallee(chain *ast.FeatureChainExpr) (Value, error) { + if chain.Member == nil || len(chain.Member.Parts) != 1 { + return ec.Eval(chain) + } + receiver, err := ec.Eval(chain.Operand) + if err != nil { + return Value{}, err + } + name := chain.Member.Parts[0].Text + if id, isObject := receiver.Object(); isObject { + if inst, ok := ec.ctx.instances[id]; ok { + if _, held := inst.FeatureValues[name]; !held { + if sym, found := ec.ctx.model.semantics.LookupMember(inst.Type, name); found && isCalcUsageSymbol(sym) { + return NewEvalContextIn(ec.ctx, sym.OwnerScope, inst).functionValueOf(sym) + } + } + } + } + return ec.chainMemberValue(receiver, chain.Member.Parts, "") +} + +// chainText spells a feature chain as written, `holder.scale`. +func chainText(n ast.Node) string { + switch c := n.(type) { + case *ast.FeatureChainExpr: + return chainText(c.Operand) + "." + qualifiedNameToString(c.Member) + case *ast.FeatureReference: + return qualifiedNameToString(c.Name) + } + return TraceLabel(n) +} + +// evalUndeterminedInvocation evaluates a call left open among target.undetermined: the +// values' types select; a tie they leave is reported, values fitting none run the first. +func (ec *EvalContext) evalUndeterminedInvocation(n *ast.InvocationExpr, target *invocationTarget) (Value, error) { + qualName := target.qualName + if n.Operand != nil && len(n.NamedArgs) > 0 { + return Value{}, fmt.Errorf("%w: %s is called with a receiver and named arguments", ErrReceiverWithNamedArgs, qualName) + } + exprs := semantics.InvocationArgs(n) + // An argument some candidate takes as an `expr` stays unevaluated, unknown to the + // selection, so a short-circuiting built-in never sees a branch it would not have run. + written := writtenArguments(exprs, n.NamedArgs) + deferred := ec.deferredArguments(target.undetermined, exprs, n.NamedArgs) + args := make([]semantics.Argument, 0, len(written)) + for k, w := range written { + var name *ast.QualifiedName + if k >= len(exprs) { + name = n.NamedArgs[k-len(exprs)].Name + if name == nil || len(name.Parts) == 0 { + continue + } + } + if deferred[k] { + args = append(args, semantics.Argument{Name: name}) + continue + } + val, err := w.eval(ec) + if err != nil { + return Value{}, err + } + args = append(args, ec.valueArgument(val, name)) + } + sel := ec.ctx.model.semantics.SelectAmongArguments(ec.scope, target.undetermined, args, semantics.PerformsBehavior) + if sel.Ambiguous { + return Value{}, ambiguousInvocationError(qualName, sel.Tied) + } + settled := &invocationTarget{qualName: qualName} + ec.ctx.implementInvocation(settled, ec.ctx.inheritedFeature(ec.runningBehavior(), sel.Called())) + callee := settled.calc + fn, applied, err := ec.boundFunction(settled.calc, n.Type) + if err != nil { + return Value{}, err + } + if applied { + callee = fn.Function() + } + if len(n.NamedArgs) > 0 { + settled.names, settled.unbound = ec.ctx.boundParameterNames(ec.scope, callee, n.NamedArgs) + } + if settled.builtin != nil && !applied { + return ec.invokeBuiltinWith(settled.builtinName, settled.builtin, exprs, n.NamedArgs, settled.names, settled.unbound, + func(params []declaredParam, param, at int) (Value, error) { + w := written[at] + if _, body := w.expr.(*ast.BodyExpr); !body && param < len(params) && params[param].deferred { + return NewExprValue(w.expr, ec.closure()), nil + } + return w.eval(ec) + }) + } + values := make([]Value, len(written)) + for k, w := range written { + val, err := w.eval(ec) + if err != nil { + return Value{}, err + } + values[k] = val + } + callArgs, err := bindEvaluatedArgs(qualName, values[:len(exprs)], values[len(exprs):], settled) + if err != nil { + return Value{}, err + } + if applied { + return ec.invokeFunction(qualName, fn, callArgs) + } + return ec.applyInvocation(settled, callArgs) +} + +// writtenArgument is one argument of a call as written, and its value once evaluated. +type writtenArgument struct { + expr ast.Node + value Value + evaluated bool +} + +// writtenArguments lists a call's arguments in source order, the positional ones first. +func writtenArguments(exprs []ast.Node, named []ast.NamedArg) []*writtenArgument { + written := make([]*writtenArgument, 0, len(exprs)+len(named)) + for _, expr := range exprs { + written = append(written, &writtenArgument{expr: expr}) + } + for _, arg := range named { + written = append(written, &writtenArgument{expr: arg.Value}) + } + return written +} + +// eval evaluates the argument once in ec; a later ask reads the value. +func (w *writtenArgument) eval(ec *EvalContext) (Value, error) { + if w.evaluated { + return w.value, nil + } + val, err := ec.Eval(w.expr) + if err != nil { + return Value{}, err + } + w.value, w.evaluated = val, true + return val, nil +} + +// deferredArguments marks, per argument written, whether any of the candidates binds it to +// an `expr` parameter of a built-in, which takes the expression rather than its value. +func (ec *EvalContext) deferredArguments(candidates []*symbols.Symbol, exprs []ast.Node, named []ast.NamedArg) []bool { + deferred := make([]bool, len(exprs)+len(named)) + for _, c := range candidates { + var candidate invocationTarget + ec.ctx.implementInvocation(&candidate, ec.ctx.inheritedFeature(ec.runningBehavior(), c)) + if candidate.builtin == nil { + continue + } + params := builtinSignatures[candidate.builtinName] + for i := range exprs { + if i < len(params) && params[i].deferred { + deferred[i] = true + } + } + if len(named) == 0 { + continue + } + names, _ := ec.ctx.boundParameterNames(ec.scope, candidate.calc, named) + for j, name := range names { + for _, p := range params { + if p.name == name && p.deferred { + deferred[len(exprs)+j] = true + } + } + } + } + return deferred +} + +// valueArgument types an evaluated argument for overload selection: a scalar as the literal +// spelling it, an object by every type classifying it, a collection by what its elements share. +func (ec *EvalContext) valueArgument(val Value, name *ast.QualifiedName) semantics.Argument { + arg := semantics.Argument{Name: name} + elements := elementsOf(val) + if len(elements) == 0 { + arg.Empty = true + return arg + } + if prim := spelledPrim(elements); prim != semantics.PrimUnknown { + arg.Prim, arg.Exact = prim, true + return arg + } + var common []*symbols.Symbol + for i, el := range elements { + types, err := ec.ctx.valueTypes(ec.scope, el) + if err != nil || len(types) == 0 { + return arg + } + if i == 0 { + common = types + continue + } + if common = ec.sharedTypes(common, types); len(common) == 0 { + return arg + } + } + arg.Type, arg.Also = common[0], common[1:] + arg.Prim = ec.ctx.model.semantics.PrimTypeOf(common[0]) + return arg +} + +// spelledPrim is the type the checker gives a literal spelling each element (a nonnegative +// integer a Natural), widened over them; PrimUnknown for a non-scalar element. +func spelledPrim(elements []Value) semantics.PrimType { + common := semantics.PrimUnknown + for i, el := range elements { + prim := representationPrim(el) + if prim == semantics.PrimInteger && el.Const.Int >= 0 { + prim = semantics.PrimNatural + } + switch { + case prim == semantics.PrimUnknown: + return semantics.PrimUnknown + case i == 0, semantics.PrimConforms(common, prim): + common = prim + case !semantics.PrimConforms(prim, common): + return semantics.PrimUnknown + } + } + return common +} + +// sharedTypes narrows the types the elements so far share to those an element of types +// also is: a type it conforms to stays, one it generalizes widens to the element's, else drops. +func (ec *EvalContext) sharedTypes(common, types []*symbols.Symbol) []*symbols.Symbol { + var shared []*symbols.Symbol + for _, c := range common { + kept := c + found := false + for _, t := range types { + switch { + case ec.ctx.modelConforms(t, c): + kept, found = c, true + case ec.ctx.modelConforms(c, t): + kept, found = t, true + } + if found { + break + } + } + if found && !slices.Contains(shared, kept) { + shared = append(shared, kept) + } + } + return shared +} + +// evalInvocationArgs evaluates an invocation's arguments in source order into the +// calc arguments they bind: positional, or named against target's parameter names. +// The notation keeps the two forms mutually exclusive. +func (ec *EvalContext) evalInvocationArgs(qualName string, exprs []ast.Node, namedArgs []ast.NamedArg, target *invocationTarget) (calcArgs, error) { + args := make([]Value, len(exprs)) + for i, arg := range exprs { + val, err := ec.Eval(arg) + if err != nil { + return calcArgs{}, err + } + args[i] = val + } + if len(namedArgs) == 0 { + return calcArgs{positional: args}, nil + } + named := make(map[string]Value, len(namedArgs)) + for i, arg := range namedArgs { + name, err := target.namedBinding(qualName, i, named) + if err != nil { + return calcArgs{}, err + } + val, err := ec.Eval(arg.Value) + if err != nil { + return calcArgs{}, err + } + named[name] = val + } + return calcArgs{named: named}, nil +} + +// bindEvaluatedArgs binds arguments already evaluated in source order, as +// evalInvocationArgs binds the ones it evaluates. +func bindEvaluatedArgs(qualName string, positional, namedValues []Value, target *invocationTarget) (calcArgs, error) { + if len(namedValues) == 0 { + return calcArgs{positional: positional}, nil + } + named := make(map[string]Value, len(namedValues)) + for i, val := range namedValues { + name, err := target.namedBinding(qualName, i, named) + if err != nil { + return calcArgs{}, err + } + named[name] = val + } + return calcArgs{named: named}, nil +} + +// namedBinding is the parameter the i-th named argument binds, or why it binds none: +// unnamed, no such parameter of the target, or a parameter already bound in named. +func (target *invocationTarget) namedBinding(qualName string, i int, named map[string]Value) (string, error) { + name := target.names[i] + if name == "" { + return "", fmt.Errorf("unnamed argument in invocation of %s", qualName) + } + if err := target.unbound[i]; err != nil { + return "", err + } + if _, dup := named[name]; dup { + return "", fmt.Errorf("%w: %s binds parameter %q twice", ErrCalcArity, qualName, name) + } + return name, nil +} + +// invokeCalcShapeStacked evaluates exprs onto the context's argument stack and +// invokes shape with them, popping them however the invocation ends. +func (ec *EvalContext) invokeCalcShapeStacked(shape *calcShape, exprs []ast.Node, enclosing []frame) (Value, error) { + ctx := ec.ctx + base := len(ctx.argStack) + for _, arg := range exprs { + val, err := ec.Eval(arg) + if err != nil { + ctx.popArgs(base) + return Value{}, err + } + ctx.argStack = append(ctx.argStack, val) + } + top := len(ctx.argStack) + args := ctx.argStack[base:top:top] + result, err := ctx.invokeCalcShapeIn(shape, calcArgs{positional: args}, ec.scope, ec.self, enclosing) + ctx.popArgs(base) + return result, err +} + +// popArgs releases the arguments pushed since the stack was base deep. +func (ctx *Context) popArgs(base int) { + clear(ctx.argStack[base:]) + ctx.argStack = ctx.argStack[:base] +} + +// qualifiedNameToString converts a QualifiedName AST node to "Package::Name" format. +func qualifiedNameToString(qn *ast.QualifiedName) string { + if qn == nil { + return "" + } + parts := make([]string, 0, len(qn.Parts)) + for _, seg := range qn.Parts { + if seg.Text != "" { + parts = append(parts, seg.Text) + } + } + return strings.Join(parts, "::") +} + +// equalValues is `==` over two operands: a set meeting a sequence flows into the +// ordered context and is compared as its canonical sequence; otherwise valueEqual. +func (ctx *Context) equalValues(a, b Value) bool { + if a.Kind == ValSet && b.Kind == ValSequence || a.Kind == ValSequence && b.Kind == ValSet { + return ctx.sequenceEqual(sequenceOf(elementsOf(a)).Sequence(), sequenceOf(elementsOf(b)).Sequence()) + } + return ctx.valueEqual(a, b) +} + +// equalValues is `==` with no context, for values that carry no scale point. +func equalValues(a, b Value) bool { + return (*Context)(nil).equalValues(a, b) +} + +// valueEqual is deep equality with no context: a point on a scale is one value +// with a magnitude on that scale only. +func valueEqual(a, b Value) bool { + return (*Context)(nil).valueEqual(a, b) +} + +// valueEqual checks deep equality of two runtime values: whether they are one +// value, as a set's membership judges. A set is never the sequence of its members. +// With a context a point on a scale equals the magnitude it is on its reference. +func (ctx *Context) valueEqual(a, b Value) bool { + if isEmptyValue(a) || isEmptyValue(b) { + return isEmptyValue(a) && isEmptyValue(b) + } + // A complex number equals the number it is, whichever kind carries it. + if a.Kind == ValComplex || b.Kind == ValComplex { + return complexEqual(a, b) + } + if a.Kind != b.Kind { + return false + } + switch a.Kind { + case ValConst: + // Delegate to semantics layer for const equality + result, ok := semantics.EvalBinary(ast.OpEq, a.Const, b.Const) + return ok && result.Kind == semantics.ValBool && result.Bool + case ValString: + return a.Str() == b.Str() + case ValNull: + return true + case ValInstance: + return a.Instance == b.Instance + case ValSequence: + return ctx.sequenceEqual(a.Sequence(), b.Sequence()) + case ValSet: + return ctx.setsEqual(a.Set(), b.Set()) + case ValVariant: + // A variation compares equal to the variant it selected. + return a.Variant() == b.Variant() + case ValEnumLiteral: + // A literal is its own identity: two literals are equal exactly when they + // are the same declaration, across enumerations included. + return a.Literal() == b.Literal() + case ValQuantity: + // Incommensurable units are not equal here: an equality that has to hold + // or fail (a set member, a sequence element) has no error to report. + c, err := ctx.canonicalMagnitudes(*a.Quantity(), *b.Quantity()) + return err == nil && c == 0 + case ValArray: + return ctx.arrayEqual(a.Array(), b.Array()) + case ValVector: + return ctx.vectorEqual(a.Vector(), b.Vector()) + case ValVectorQuantity: + return ctx.vectorQuantityEqual(a.VectorQuantity(), b.VectorQuantity()) + case ValTensorQuantity: + return ctx.tensorQuantityEqual(a.TensorQuantity(), b.TensorQuantity()) + case ValMeasurementRef: + return a.MeasurementRef().equal(b.MeasurementRef()) + case ValCoordinateFrame: + return a.CoordinateFrame().equal(b.CoordinateFrame()) + case ValCoordinateTransformation: + return a.CoordinateTransformation().equal(b.CoordinateTransformation()) + case ValFunction: + // A function is the calc it is a value of, read against the same object and, for + // one closing over a body's bindings, within the same run of that body. + return a.Function() == b.Function() && a.FunctionSelf() == b.FunctionSelf() && + a.functionRun() == b.functionRun() + case ValMetaobject: + // A metaobject is the element it denotes, whichever metaclass it was cast to. + return symbols.SameElement(a.MetaobjectElement(), b.MetaobjectElement()) + case ValUndetermined: + return a.ref == b.ref + default: + return false + } +} + +// arrayEqual holds for arrays of the same dimensions with equal elements. +func (ctx *Context) arrayEqual(a, b *Array) bool { + if a == nil || b == nil { + return a == b + } + if len(a.Dimensions) != len(b.Dimensions) || len(a.Elements) != len(b.Elements) { + return false + } + for i := range a.Dimensions { + if a.Dimensions[i] != b.Dimensions[i] { + return false + } + } + for i := range a.Elements { + if !ctx.valueEqual(a.Elements[i], b.Elements[i]) { + return false + } + } + return true +} + +// vectorEqual holds for vectors of one dimension whose numbers are equal. +func (ctx *Context) vectorEqual(a, b *Vector) bool { + if a == nil || b == nil { + return a == b + } + if len(a.Elements) != len(b.Elements) { + return false + } + for i := range a.Elements { + if !ctx.valueEqual(constValue(a.Elements[i]), constValue(b.Elements[i])) { + return false + } + } + return true +} + +// vectorQuantityEqual holds for vector quantities whose axes are equal quantities. +func (ctx *Context) vectorQuantityEqual(a, b *VectorQuantity) bool { + if a == nil || b == nil { + return a == b + } + if a.Dimension() != b.Dimension() { + return false + } + // Vectors are equal over one mRef only: the same frame, or none for both. + if !sameVectorFrame(a, b) { + return false + } + for i := 0; i < a.Dimension(); i++ { + if !ctx.valueEqual(NewQuantityValue(a.component(i)), NewQuantityValue(b.component(i))) { + return false + } + } + return true +} + +// sameVectorFrame holds when both vectors are over one frame or neither is over any. +func sameVectorFrame(a, b *VectorQuantity) bool { + if (a.Frame == nil) != (b.Frame == nil) { + return false + } + return a.Frame == nil || a.Frame.equal(b.Frame) +} + +// sequenceEqual checks structural equality of sequences (element-wise). +func (ctx *Context) sequenceEqual(a, b *Sequence) bool { + if a == nil || b == nil { + return a == b + } + if a.Size() != b.Size() { + return false + } + for i := 0; i < a.Size(); i++ { + aElem, _ := a.At(i) + bElem, _ := b.At(i) + if !ctx.valueEqual(aElem, bElem) { + return false + } + } + return true +} diff --git a/internal/core/runtime/eval_cache_test.go b/internal/exec/runtime/eval_cache_test.go similarity index 93% rename from internal/core/runtime/eval_cache_test.go rename to internal/exec/runtime/eval_cache_test.go index 55e99c215e..f0bace8ce1 100644 --- a/internal/core/runtime/eval_cache_test.go +++ b/internal/exec/runtime/eval_cache_test.go @@ -4,10 +4,10 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // parseExpr parses src as one expression, so a test can evaluate the same node twice. @@ -25,7 +25,7 @@ func parseExpr(t *testing.T, src string) ast.Node { // reports the same error each time rather than a cached success. func TestRepeatedLiteralEvaluationAnswersAlike(t *testing.T) { model, resolver, _ := parseAndBuildModel(t, sumModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) for _, tc := range []struct { src string @@ -70,7 +70,7 @@ func TestNestedInvocationArgumentsStayDistinct(t *testing.T) { calc def fib { in k : Integer; return : Integer = if k <= 1 ? k else fib(k - 1) + fib(k - 2); } calc def three { in a : Integer; in b : Integer; in c : Integer; return : Integer = a * 100 + b * 10 + c; } `) - ctx := NewContext(NewModel(model, resolver), 100000) + ctx := NewContext(typedModel(model, resolver), 100000) ec := NewEvalContext(ctx, root) for _, tc := range []struct { @@ -115,7 +115,7 @@ func TestRepeatedInvocationEvaluationAnswersAlike(t *testing.T) { calc def twice { in x : Integer; return : Integer = x + x; } calc def fib { in k : Integer; return : Integer = if k <= 1 ? k else fib(k - 1) + fib(k - 2); } `) - ctx := NewContext(NewModel(model, resolver), 100000) + ctx := NewContext(typedModel(model, resolver), 100000) ec := NewEvalContext(ctx, root) for _, tc := range []struct { diff --git a/internal/core/runtime/eval_classification_test.go b/internal/exec/runtime/eval_classification_test.go similarity index 94% rename from internal/core/runtime/eval_classification_test.go rename to internal/exec/runtime/eval_classification_test.go index cbcd0d995f..c56b1bca88 100644 --- a/internal/core/runtime/eval_classification_test.go +++ b/internal/exec/runtime/eval_classification_test.go @@ -5,11 +5,11 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // classificationModel annotates elements in each form the parser accepts — @@ -35,7 +35,7 @@ const classificationModel = ` func constraintVerdict(t *testing.T, src, name string) (bool, error) { t.Helper() model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) return ctx.EvaluateConstraint(resolveSymbol(t, root, name), root) } @@ -99,7 +99,7 @@ func TestEvalClassificationInACalcBody(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) got, err := ctx.InvokeCalc(resolveSymbol(t, root, "classify"), []Value{{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: true}}}, root) if err != nil { @@ -124,7 +124,7 @@ func TestEvalClassificationOfTheObjectBeingEvaluated(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) for _, tc := range []struct { typ, constraint string diff --git a/internal/core/runtime/eval_literal_arith_test.go b/internal/exec/runtime/eval_literal_arith_test.go similarity index 97% rename from internal/core/runtime/eval_literal_arith_test.go rename to internal/exec/runtime/eval_literal_arith_test.go index efcf23c99c..0dccba1953 100644 --- a/internal/core/runtime/eval_literal_arith_test.go +++ b/internal/exec/runtime/eval_literal_arith_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // TestStringLiteralEscapes evaluates the escapes KerML §8.2.2 defines: a literal diff --git a/internal/core/runtime/eval_name_order_test.go b/internal/exec/runtime/eval_name_order_test.go similarity index 93% rename from internal/core/runtime/eval_name_order_test.go rename to internal/exec/runtime/eval_name_order_test.go index da67e14f6c..beadd33373 100644 --- a/internal/core/runtime/eval_name_order_test.go +++ b/internal/exec/runtime/eval_name_order_test.go @@ -3,8 +3,8 @@ package runtime import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // nameOrderModel declares k at every level a bare name may resolve through: @@ -39,7 +39,7 @@ func wantNamedInt(t *testing.T, what string, got Value, err error, want int64) { // parameter over the calc's lexical scope — however the frames are stored. func TestSimpleNameShadowingOrder(t *testing.T) { model, resolver, root := parseAndBuildLibraryModel(t, nameOrderModel) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) pkg, _ := root.LookupLocal("test") scope := pkg.Scope @@ -63,7 +63,7 @@ func TestSimpleNameShadowingOrder(t *testing.T) { // element's own features, which mask the scope's member. func TestSimpleNameFallbackOrder(t *testing.T) { model, resolver, root := parseAndBuildLibraryModel(t, nameOrderModel) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) pkg, _ := root.LookupLocal("test") scope := pkg.Scope shape, err := ctx.calcShapeOf(findSymbolByName(scope, "Named", ast.DefCalc)) diff --git a/internal/core/runtime/eval_no_value_test.go b/internal/exec/runtime/eval_no_value_test.go similarity index 96% rename from internal/core/runtime/eval_no_value_test.go rename to internal/exec/runtime/eval_no_value_test.go index 36b1c4135a..78abb30ee6 100644 --- a/internal/core/runtime/eval_no_value_test.go +++ b/internal/exec/runtime/eval_no_value_test.go @@ -25,7 +25,7 @@ package test { // the feature as the reason; unresolved is reserved for names no declaration answers. func TestDeclaredFeatureWithoutValueIsNotUnresolved(t *testing.T) { model, resolver, root := parseAndBuildModel(t, valuelessFeaturesModel) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) pkg, _ := root.LookupLocal("test") car, _ := pkg.Scope.LookupLocal("Car") scope := car.Scope @@ -61,7 +61,7 @@ func TestDeclaredFeatureWithoutValueIsNotUnresolved(t *testing.T) { // missing value. func TestChainOverValuelessOperandResolvesItsMembers(t *testing.T) { model, resolver, root := parseAndBuildModel(t, valuelessFeaturesModel) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) pkg, _ := root.LookupLocal("test") car, _ := pkg.Scope.LookupLocal("Car") scope := car.Scope @@ -94,7 +94,7 @@ package test { part car : Car { attribute unsetMass : Real; } } `) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) pkg, _ := root.LookupLocal("test") scope := pkg.Scope diff --git a/internal/core/runtime/eval_operator_test.go b/internal/exec/runtime/eval_operator_test.go similarity index 94% rename from internal/core/runtime/eval_operator_test.go rename to internal/exec/runtime/eval_operator_test.go index 9b53bd1761..adb005ccc0 100644 --- a/internal/core/runtime/eval_operator_test.go +++ b/internal/exec/runtime/eval_operator_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // TestUnimplementedOperatorReportsWhy requires an operator the runtime does not @@ -16,7 +16,7 @@ func TestUnimplementedOperatorReportsWhy(t *testing.T) { const src = `calc def complement { in n : Integer; return : Integer = ~n; }` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) complement := resolveSymbol(t, root, "complement") _, err := ctx.InvokeCalc(complement, []Value{constInt(1)}, root) @@ -91,7 +91,7 @@ func evalTypeClassificationExpr(t *testing.T, expr string) (bool, error) { pkg := resolveSymbol(t, root, "test") result := resolveSymbol(t, pkg.Scope, "result") decl := result.Decl.(*ast.Usage) - value, err := NewEvalContext(NewContext(NewModel(model, resolver), 10000), pkg.Scope).Eval(decl.Value) + value, err := NewEvalContext(NewContext(typedModel(model, resolver), 10000), pkg.Scope).Eval(decl.Value) if err != nil { return false, err } @@ -115,7 +115,7 @@ func TestTypeClassificationFollowsSelectedVariant(t *testing.T) { attribute declared = garage.chosen hastype Vehicle; ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) for _, tt := range []struct { name string want bool @@ -159,7 +159,7 @@ func TestClassificationWeighsEveryTypeOfAnObject(t *testing.T) { attribute burnerCast = (shop.retrofit as CombustionVehicle) == (); ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) for _, tt := range []struct { name string want bool @@ -213,7 +213,7 @@ package test { if !ok { t.Fatalf("result declares %T, want a usage", sym.Decl) } - ec := NewEvalContext(NewContext(NewModel(model, resolver), 10000), pkg.Scope) + ec := NewEvalContext(NewContext(typedModel(model, resolver), 10000), pkg.Scope) type outcome struct { value Value @@ -227,7 +227,7 @@ package test { select { case got := <-done: return got.value, got.err - case <-time.After(10 * time.Second): + case <-watchdog(10 * time.Second): t.Fatalf("%s did not terminate", expr) return Value{}, nil } @@ -318,7 +318,7 @@ package test { calc def remainder { in a : Real; in b : Real; return : Real = a % b; } }` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) for _, name := range []string{"quotient", "remainder"} { sym := resolveSymbol(t, root, "test") if sym.Scope == nil { @@ -347,7 +347,7 @@ func TestComposedCastUndecidedWhenNoOperandExcludes(t *testing.T) { attribute subtracted = 5 as OddInteger; ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) for _, name := range []string{"intersected", "subtracted"} { t.Run(name, func(t *testing.T) { sym := resolveSymbol(t, root, name) diff --git a/internal/exec/runtime/eval_test.go b/internal/exec/runtime/eval_test.go new file mode 100644 index 0000000000..c35fca318a --- /dev/null +++ b/internal/exec/runtime/eval_test.go @@ -0,0 +1,672 @@ +package runtime + +import ( + "errors" + "fmt" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +func parseAndBuildModel(t *testing.T, code string) (*semantics.Model, *resolve.Resolver, *symbols.Scope) { + t.Helper() + src := source.New("test.sysml", []byte(code)) + p := parser.New(src) + root := p.ParseFile() + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", root) + rootScope := idx.DocumentRoot("test.sysml") + if rootScope == nil { + t.Fatal("rootScope nil") + } + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + model.SetArgumentTyper(passes.NewArgumentTyper(resolver, model)) + return model, resolver, rootScope +} + +// parseAndBuildLibraryModel is parseAndBuildModel over the standard library, for +// a model whose imports name library packages. +func parseAndBuildLibraryModel(t *testing.T, code string) (*semantics.Model, *resolve.Resolver, *symbols.Scope) { + t.Helper() + root := parser.New(source.New("test.sysml", []byte(code))).ParseFile() + idx := libs.NewModelIndex() + idx.AddDocument("test.sysml", root) + idx.ExpandWildcardImports() + rootScope := idx.DocumentRoot("test.sysml") + if rootScope == nil { + t.Fatal("rootScope nil") + } + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + model.SetArgumentTyper(passes.NewArgumentTyper(resolver, model)) + return model, resolver, rootScope +} + +func resolveSymbol(t *testing.T, rootScope *symbols.Scope, name string) *symbols.Symbol { + t.Helper() + sym, ok := rootScope.LookupLocal(name) + if !ok || sym == nil { + t.Fatalf("symbol %q not found", name) + } + return sym +} + +func TestEval_Literals(t *testing.T) { + tests := []struct { + src string + expected Value + }{ + {"42", Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 42}}}, + {"3.14", Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: 3.14}}}, + {"true", Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: true}}}, + {`"hello"`, NewStringValue("hello")}, + {"null", Value{Kind: ValNull}}, + } + + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + // Wrap expression in attribute default + model, resolver, root := parseAndBuildModel(t, "attribute test = "+tt.src+";") + ctx := NewContext(typedModel(model, resolver), 1000) + + // Extract expression from attribute value + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + expr := attrDecl.Value + + result, err := ctx.Eval(expr) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + + if result.Kind != tt.expected.Kind { + t.Errorf("expected Kind %v, got %v", tt.expected.Kind, result.Kind) + } + }) + } +} + +func TestEval_Arithmetic(t *testing.T) { + src := `attribute test = 1 + 2;` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 1000) + + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + expr := attrDecl.Value + + result, err := ctx.Eval(expr) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + + if result.Kind != ValConst || result.Const.Int != 3 { + t.Errorf("expected 3, got %v", result) + } +} + +func TestEval_SequenceExpr(t *testing.T) { + // Test (1, 2, 3) sequence construction + src := `attribute test = (1, 2, 3);` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 1000) + + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + expr := attrDecl.Value + + result, err := ctx.Eval(expr) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + + if result.Kind != ValSequence { + t.Fatalf("expected ValSequence, got %v", result.Kind) + } + if result.Sequence().Size() != 3 { + t.Errorf("expected size 3, got %d", result.Sequence().Size()) + } + + // Check elements + elem0, _ := result.Sequence().At(0) + if elem0.Kind != ValConst || elem0.Const.Int != 1 { + t.Errorf("elem[0] expected 1, got %v", elem0) + } +} + +func TestEval_StepLimit(t *testing.T) { + // Verify step counter triggers on deep recursion + // (Step counter already wired in Context.incrementStep + eval.go) + src := `part def Simple {}` + model, resolver, _ := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 5) // very low limit + + // The budget bounds one run, so the literals are evaluated within one rather + // than starting a run each. + defer ctx.beginRun()() + + // Eval 6 literals → should exceed 5 steps + for i := 0; i < 6; i++ { + _, err := ctx.Eval(&ast.LiteralInteger{Value: "1"}) + if err != nil { + if errors.Is(err, ErrStepLimitExceeded) { + return // success — limit triggered + } + t.Fatalf("unexpected error: %v", err) + } + } + t.Error("expected ErrStepLimitExceeded but got none") +} + +func TestEval_QualifiedNameLookup(t *testing.T) { + tests := []struct { + src string + expected int64 + }{ + // Qualified name: nested namespace + {"package A { attribute x = 42; } attribute test = A::x;", 42}, + // Qualified name: nested definition + {"part def Vehicle { attribute speed = 100; } attribute test = Vehicle::speed;", 100}, + // Multi-level qualified name + {"package A { package B { attribute val = 7; } } attribute test = A::B::val;", 7}, + } + + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, tt.src) + ctx := NewContext(typedModel(model, resolver), 1000) + + testSym := resolveSymbol(t, root, "test") + testDecl := testSym.Decl.(*ast.Usage) + + result, err := ctx.Eval(testDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + + if result.Kind != ValConst || result.Const.Kind != semantics.ValInt || result.Const.Int != tt.expected { + t.Errorf("expected int %d, got %v", tt.expected, result) + } + }) + } +} + +func TestEval_EqualityConst(t *testing.T) { + tests := []struct { + src string + expected bool + }{ + {"42 == 42", true}, + {"42 == 43", false}, + {"3.14 == 3.14", true}, + {"3.14 != 3.15", true}, + {"true == true", true}, + {"true == false", false}, + {"false != true", true}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, "attribute test = "+tt.src+";") + ctx := NewContext(typedModel(model, resolver), 1000) + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + result, err := ctx.Eval(attrDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + if result.Kind != ValConst || result.Const.Kind != semantics.ValBool { + t.Fatalf("expected bool, got %v", result) + } + if result.Const.Bool != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result.Const.Bool) + } + }) + } +} + +func TestEval_EqualityString(t *testing.T) { + tests := []struct { + src string + expected bool + }{ + {`"hello" == "hello"`, true}, + {`"hello" == "world"`, false}, + {`"foo" != "bar"`, true}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, "attribute test = "+tt.src+";") + ctx := NewContext(typedModel(model, resolver), 1000) + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + result, err := ctx.Eval(attrDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + if result.Kind != ValConst || result.Const.Kind != semantics.ValBool { + t.Fatalf("expected bool, got %v", result) + } + if result.Const.Bool != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result.Const.Bool) + } + }) + } +} + +func TestEval_EqualityNull(t *testing.T) { + tests := []struct { + src string + expected bool + }{ + {"null == null", true}, + {"null != null", false}, + {"null == ()", true}, + {"() == null", true}, + {"() != null", false}, + {"null === ()", true}, + {"() !== null", false}, + {"() == 0", false}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, "attribute test = "+tt.src+";") + ctx := NewContext(typedModel(model, resolver), 1000) + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + result, err := ctx.Eval(attrDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + if result.Kind != ValConst || result.Const.Kind != semantics.ValBool { + t.Fatalf("expected bool, got %v", result) + } + if result.Const.Bool != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result.Const.Bool) + } + }) + } +} + +func TestEval_EqualityCrossKind(t *testing.T) { + tests := []struct { + src string + expected bool + }{ + {"42 == null", false}, + {`"hello" == 42`, false}, + {`"hello" != 42`, true}, + {"null != 42", true}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, "attribute test = "+tt.src+";") + ctx := NewContext(typedModel(model, resolver), 1000) + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + result, err := ctx.Eval(attrDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + if result.Kind != ValConst || result.Const.Kind != semantics.ValBool { + t.Fatalf("expected bool, got %v", result) + } + if result.Const.Bool != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result.Const.Bool) + } + }) + } +} + +func TestEval_LogicalAnd(t *testing.T) { + tests := []struct { + src string + expected bool + }{ + {"true & true", true}, + {"true & false", false}, + {"false & true", false}, + {"false & false", false}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, "attribute test = "+tt.src+";") + ctx := NewContext(typedModel(model, resolver), 1000) + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + result, err := ctx.Eval(attrDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + if result.Kind != ValConst || result.Const.Kind != semantics.ValBool { + t.Fatalf("expected bool, got %v", result) + } + if result.Const.Bool != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result.Const.Bool) + } + }) + } +} + +func TestEval_LogicalOr(t *testing.T) { + tests := []struct { + src string + expected bool + }{ + {"true | true", true}, + {"true | false", true}, + {"false | true", true}, + {"false | false", false}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, "attribute test = "+tt.src+";") + ctx := NewContext(typedModel(model, resolver), 1000) + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + result, err := ctx.Eval(attrDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + if result.Kind != ValConst || result.Const.Kind != semantics.ValBool { + t.Fatalf("expected bool, got %v", result) + } + if result.Const.Bool != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result.Const.Bool) + } + }) + } +} + +func TestEval_LogicalNot(t *testing.T) { + tests := []struct { + src string + expected bool + }{ + {"not true", false}, + {"not false", true}, + {"not not true", true}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, "attribute test = "+tt.src+";") + ctx := NewContext(typedModel(model, resolver), 1000) + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + result, err := ctx.Eval(attrDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + if result.Kind != ValConst || result.Const.Kind != semantics.ValBool { + t.Fatalf("expected bool, got %v", result) + } + if result.Const.Bool != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result.Const.Bool) + } + }) + } +} + +func TestEval_NegationArithmetic(t *testing.T) { + tests := []struct { + src string + expected int64 + }{ + {"-42", -42}, + {"-(-5)", 5}, + {"-(3 + 2)", -5}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, "attribute test = "+tt.src+";") + ctx := NewContext(typedModel(model, resolver), 1000) + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + result, err := ctx.Eval(attrDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + if result.Kind != ValConst || result.Const.Kind != semantics.ValInt { + t.Fatalf("expected int, got %v", result) + } + if result.Const.Int != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result.Const.Int) + } + }) + } +} + +func TestEval_NegationArithmeticReal(t *testing.T) { + tests := []struct { + src string + expected float64 + }{ + {"-3.14", -3.14}, + {"-(-2.5)", 2.5}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, "attribute test = "+tt.src+";") + ctx := NewContext(typedModel(model, resolver), 1000) + attrSym := resolveSymbol(t, root, "test") + attrDecl := attrSym.Decl.(*ast.Usage) + result, err := ctx.Eval(attrDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + if result.Kind != ValConst || result.Const.Kind != semantics.ValReal { + t.Fatalf("expected real, got %v", result) + } + if result.Const.Real != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result.Const.Real) + } + }) + } +} + +func TestEval_Track1Integration(t *testing.T) { + // Test combining equality, logical, negation, and qualified names + tests := []struct { + src string + expected interface{} // bool or int64 + }{ + // Equality + logical operators + {"attribute test = (42 == 42) & (100 != 99);", true}, + {"attribute test = (10 == 11) | (5 == 5);", true}, + {"attribute test = not (42 == 43);", true}, + + // Qualified names + operators + {"package A { attribute x = 42; } attribute test = A::x == 42;", true}, + {"package A { attribute x = 10; } attribute test = -(A::x);", int64(-10)}, + + // Complex nested expression + {"package A { attribute x = 5; attribute y = 10; } attribute test = (A::x < A::y) & (A::y == 10);", true}, + } + + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, tt.src) + ctx := NewContext(typedModel(model, resolver), 1000) + + testSym := resolveSymbol(t, root, "test") + testDecl := testSym.Decl.(*ast.Usage) + + result, err := ctx.Eval(testDecl.Value) + if err != nil { + t.Fatalf("Eval failed: %v", err) + } + + switch exp := tt.expected.(type) { + case bool: + if result.Kind != ValConst || result.Const.Kind != semantics.ValBool || result.Const.Bool != exp { + t.Errorf("expected bool %v, got %v", exp, result) + } + case int64: + if result.Kind != ValConst || result.Const.Kind != semantics.ValInt || result.Const.Int != exp { + t.Errorf("expected int %d, got %v", exp, result) + } + } + }) + } +} + +// enumSource declares the enumerations the literal tests evaluate against: one +// of plain literals, one whose literals carry attributes of their own. +const enumSource = ` +package D { + enum def Color { red; green; blue; } + enum def Level { + low { attribute n = 1; } + high { attribute n = 9; } + } + enum def Grade { A = 4; B = 3; } +} +attribute test = %s; +` + +// evalEnumExpr evaluates expr against the enumerations enumSource declares. +func evalEnumExpr(t *testing.T, expr string) (Value, error) { + t.Helper() + model, resolver, root := parseAndBuildModel(t, fmt.Sprintf(enumSource, expr)) + ctx := NewContext(typedModel(model, resolver), 1000) + testSym := resolveSymbol(t, root, "test") + return ctx.Eval(testSym.Decl.(*ast.Usage).Value) +} + +// TestEval_EnumerationLiteralIsAValue pins that a literal evaluates to the +// identity of that literal, which is what renders and what compares. +func TestEval_EnumerationLiteralIsAValue(t *testing.T) { + val, err := evalEnumExpr(t, "D::Color::red") + if err != nil { + t.Fatalf("Eval: %v", err) + } + if val.Kind != ValEnumLiteral { + t.Fatalf("kind = %v, want ValEnumLiteral", val.Kind) + } + if val.Literal() == nil || val.Literal().Name != "red" { + t.Fatalf("literal = %v, want red", val.Literal()) + } + if got := val.LiteralText(); got != "Color::red" { + t.Errorf("LiteralText() = %q, want %q", got, "Color::red") + } + if got := FormatTraceValue(val); got != "Color::red" { + t.Errorf("FormatTraceValue() = %q, want %q", got, "Color::red") + } + if got := describeOperand(val); got != "the enumeration literal Color::red" { + t.Errorf("describeOperand() = %q", got) + } +} + +// TestEval_EnumerationLiteralEquality pins that literal equality is identity: +// the same literal is equal to itself, and no other literal is equal to it, +// including one of another enumeration. +func TestEval_EnumerationLiteralEquality(t *testing.T) { + for _, tt := range []struct { + expr string + want bool + }{ + {"D::Color::red == D::Color::red", true}, + {"D::Color::red != D::Color::red", false}, + {"D::Color::red == D::Color::green", false}, + {"D::Color::red != D::Color::green", true}, + {"D::Color::red == D::Level::low", false}, + {"D::Color::red != D::Level::low", true}, + {"D::Color::red === D::Color::red", true}, + {"D::Color::red === D::Color::blue", false}, + {"D::Color::red == 1", false}, + {"D::Color::red == \"red\"", false}, + } { + t.Run(tt.expr, func(t *testing.T) { + val, err := evalEnumExpr(t, tt.expr) + if err != nil { + t.Fatalf("Eval: %v", err) + } + if val.Kind != ValConst || val.Const.Kind != semantics.ValBool { + t.Fatalf("value = %v, want a Boolean", val) + } + if val.Const.Bool != tt.want { + t.Errorf("%s = %v, want %v", tt.expr, val.Const.Bool, tt.want) + } + }) + } +} + +// TestEval_EnumerationLiteralOwnAttributes pins that a literal is an occurrence +// of its enumeration: the attributes it declares are readable through it, and +// each literal holds its own. +func TestEval_EnumerationLiteralOwnAttributes(t *testing.T) { + for expr, want := range map[string]int64{ + "D::Level::low.n": 1, + "D::Level::high.n": 9, + } { + val, err := evalEnumExpr(t, expr) + if err != nil { + t.Fatalf("Eval %s: %v", expr, err) + } + if val.Kind != ValConst || val.Const.Kind != semantics.ValInt || val.Const.Int != want { + t.Errorf("%s = %v, want %d", expr, val, want) + } + } +} + +// TestEval_EnumerationLiteralReadTwiceIsOneObject pins that the object a literal +// stands for is materialized once, so reading its attributes repeatedly reads +// the same occurrence rather than piling up objects. +func TestEval_EnumerationLiteralReadTwiceIsOneObject(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, + fmt.Sprintf(enumSource, "D::Level::high.n == D::Level::high.n")) + ctx := NewContext(typedModel(model, resolver), 1000) + testSym := resolveSymbol(t, root, "test") + val, err := ctx.Eval(testSym.Decl.(*ast.Usage).Value) + if err != nil { + t.Fatalf("Eval: %v", err) + } + if val.Kind != ValConst || !val.Const.Bool { + t.Fatalf("value = %v, want true", val) + } + if len(ctx.instances) != 1 { + t.Errorf("materialized %d objects, want 1", len(ctx.instances)) + } +} + +// TestEval_EnumerationLiteralWithAValue pins that a literal of an enumeration +// giving its literals values evaluates to the value it declares. +func TestEval_EnumerationLiteralWithAValue(t *testing.T) { + val, err := evalEnumExpr(t, "D::Grade::A") + if err != nil { + t.Fatalf("Eval: %v", err) + } + if val.Kind != ValConst || val.Const.Kind != semantics.ValInt || val.Const.Int != 4 { + t.Fatalf("value = %v, want 4", val) + } +} + +// TestEval_EnumerationLiteralInASet pins that a set holds one element per +// literal: the same literal added twice is one member, two literals are two. +func TestEval_EnumerationLiteralInASet(t *testing.T) { + red, err := evalEnumExpr(t, "D::Color::red") + if err != nil { + t.Fatalf("Eval: %v", err) + } + green, err := evalEnumExpr(t, "D::Color::green") + if err != nil { + t.Fatalf("Eval: %v", err) + } + set := NewSet() + set.Add(red) + set.Add(red) + set.Add(green) + if set.Size() != 2 { + t.Errorf("set size = %d, want 2", set.Size()) + } + if !set.Contains(red) || !set.Contains(green) { + t.Errorf("set %v does not hold both literals", set.Elements()) + } +} diff --git a/internal/core/runtime/evaluation_log.go b/internal/exec/runtime/evaluation_log.go similarity index 98% rename from internal/core/runtime/evaluation_log.go rename to internal/exec/runtime/evaluation_log.go index 9649ed319d..ed3e7bbeec 100644 --- a/internal/core/runtime/evaluation_log.go +++ b/internal/exec/runtime/evaluation_log.go @@ -4,7 +4,7 @@ import ( "slices" "sort" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // evaluationLog records, for the case run under way, each application of one of diff --git a/internal/core/runtime/executor_common.go b/internal/exec/runtime/executor_common.go similarity index 75% rename from internal/core/runtime/executor_common.go rename to internal/exec/runtime/executor_common.go index 5ed7048906..eae43e865e 100644 --- a/internal/core/runtime/executor_common.go +++ b/internal/exec/runtime/executor_common.go @@ -5,10 +5,11 @@ import ( "container/heap" "fmt" "slices" + "sort" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // Token represents a control token in action execution. It carries no values of @@ -39,6 +40,12 @@ type Token struct { body *bodyRun } +// Within lists the nested action nodes whose own flows the token runs in, +// outermost first; empty for a token in the action's own flow. +func (t Token) Within() []ast.Node { + return t.frame.within() +} + // travel moves the token along a succession in the given sweep, recording the one it arrived over. func (t *Token) travel(edge lower.ActionEdge, sweep uint64) { t.Location = edge.Target @@ -80,13 +87,17 @@ func (w AcceptWait) String() string { type ExecutionState int const ( - StateReady ExecutionState = iota // Not started - StateRunning // In progress - StateCompleted // Reached terminal state - StateSuspended // Paused for debugging - StateWaiting // Every remaining token is parked at an accept + StateReady ExecutionState = iota // Not started + StateRunning // In progress + StateCompleted // Reached terminal state + StateSuspended // Paused for debugging + StateWaiting // Every remaining token is parked at an accept + StateTerminated // Ended by a terminate before reaching a terminal state ) +// Ended reports an execution over for good: completed, or ended by a terminate. +func (s ExecutionState) Ended() bool { return s == StateCompleted || s == StateTerminated } + func (s ExecutionState) String() string { switch s { case StateReady: @@ -99,6 +110,8 @@ func (s ExecutionState) String() string { return "Suspended" case StateWaiting: return "Waiting" + case StateTerminated: + return "Terminated" default: return "Unknown" } @@ -158,6 +171,13 @@ func (q *EventQueue) Push(e Event) { heap.Push(&q.events, e) } +// With is a copy of the queue with the event pushed; the queue itself is untouched. +func (q *EventQueue) With(e Event) *EventQueue { + copied := &EventQueue{events: slices.Clone(q.events)} + copied.Push(e) + return copied +} + // Pop removes and returns the earliest event. func (q *EventQueue) Pop() Event { if len(q.events) == 0 { @@ -201,6 +221,34 @@ func (q *EventQueue) Tied() []Event { return tied } +// CompletionsOf lists the queued completion events carrying a transition out of +// source, in arrival order. +func (q *EventQueue) CompletionsOf(source ast.Node) []Event { + var events []Event + for _, event := range q.events { + if trans, ok := event.Payload.(*lower.Transition); ok && isCompletionEvent(event) && trans.Source == source { + events = append(events, event) + } + } + slices.SortFunc(events, func(a, b Event) int { return cmp.Compare(a.ID, b.ID) }) + return events +} + +// TimerOf returns the queued time event carrying trans, false when its timer is +// not running. +func (q *EventQueue) TimerOf(trans *lower.Transition) (Event, bool) { + for _, event := range q.events { + if event.Type != EventTime { + continue + } + payload, ok := event.Payload.(*lower.Transition) + if ok && (payload == trans || sameTimerGroup(payload, trans)) { + return event, true + } + } + return Event{}, false +} + // Take removes and returns the event with the given ID, false when none has it. func (q *EventQueue) Take(id int64) (Event, bool) { for i, event := range q.events { @@ -225,6 +273,13 @@ func (q *EventQueue) Len() int { return len(q.events) } +// Events lists the pending events in dispatch order. +func (q *EventQueue) Events() []Event { + events := eventHeap(slices.Clone(q.events)) + sort.Sort(events) + return events +} + // Withdraw drops every pending event the predicate accepts, which cancels an // occurrence a state no longer waits for. func (q *EventQueue) Withdraw(drop func(Event) bool) { @@ -274,6 +329,20 @@ func isCompletionEvent(event Event) bool { } } +// isTimerExpiry reports whether an event is a time trigger's expiry: a timed +// transition due, as opposed to a completion event queued at the same instant. +func isTimerExpiry(event Event) bool { + if event.Type != EventTime { + return false + } + trans, ok := event.Payload.(*lower.Transition) + if !ok { + return false + } + _, timed := trans.Trigger.(*ast.TimeEvent) + return timed +} + func (h eventHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } diff --git a/internal/core/runtime/executor_common_test.go b/internal/exec/runtime/executor_common_test.go similarity index 100% rename from internal/core/runtime/executor_common_test.go rename to internal/exec/runtime/executor_common_test.go diff --git a/internal/exec/runtime/explore.go b/internal/exec/runtime/explore.go new file mode 100644 index 0000000000..e05ab1a119 --- /dev/null +++ b/internal/exec/runtime/explore.go @@ -0,0 +1,521 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "slices" + "sort" + "strings" +) + +// `explore` replays a run from a fresh context per linearization: a recorded +// choice prefix, then the first untried alternative, earliest first, within a budget. + +// ExploreBudget bounds an exploration: how many runs it may make and how many +// choice points one run may resolve before the rest take their first alternative. +type ExploreBudget struct { + Runs int + Depth int +} + +// DefaultExploreBudget is the budget `explore` has when its spelling names none. +var DefaultExploreBudget = ExploreBudget{Runs: 1024, Depth: 64} + +// ErrNotExploring is the typed error Explore returns for a policy that is not `explore`. +var ErrNotExploring = errors.New("scheduling policy does not explore") + +// ErrExploreUndriven is the typed error SetSchedule returns for `explore`, which +// Explore drives over fresh contexts rather than one. +var ErrExploreUndriven = errors.New("explore is not a policy one context runs under") + +// ErrExplorationDiverged is the typed error for a replay that did not meet the +// choice points its prefix recorded, so no outcome set can be trusted. +var ErrExplorationDiverged = errors.New("exploration diverged") + +// ChoiceTaken is one choice point of a run and the alternative it took, as the +// witness of an outcome lists them. +type ChoiceTaken struct { + Kind ChoiceKind + // Step is the action step the choice was made in, 0 for a state machine. + Step int + // Where names the decision node or the state and event; empty for a token order. + Where string + // Alternatives is how many the point had; Taken indexes the one taken. + Alternatives int + Taken int + // Among names the alternatives as a trace does; Took is the one taken: the + // token tried next, a branch or a transition by declared position and target, + // or the state whose transition fired first. + Among []string + Took string + // Weights are the probabilities the model states for the alternatives, one + // per alternative, for a weighted decision; nil for a choice it does not weight. + Weights []float64 + // Drew is the unit draw that selected Taken among the Weights, when Drawn. + Drew float64 + Drawn bool +} + +// Weighted reports whether the model weights the alternatives. +func (c ChoiceTaken) Weighted() bool { return len(c.Weights) == c.Alternatives && c.Alternatives > 0 } + +// String renders the choice for a table or a failure message, as ParseChoice +// reads it back: a name the line's own punctuation occurs in is quoted, and a +// weighted branch carries the alternatives with their weights and the draw. +func (c ChoiceTaken) String() string { + switch c.Kind { + case ChoiceTokenOrder: + return fmt.Sprintf("step %d: %s first of %s", c.Step, choiceLabel(c.Took), choiceLabels(c.Among)) + case ChoiceDecisionBranch: + return fmt.Sprintf("step %d: %s -> %s%s", c.Step, choiceLabel(c.Where), choiceLabel(c.Took), c.weightedTail()) + case ChoiceTransition: + return fmt.Sprintf("%s -> %s%s", choiceLabel(c.Where), choiceLabel(c.Took), c.weightedTail()) + case ChoiceRegionOrder, ChoiceDueOrder, ChoiceDispatchOrder, ChoiceEntryOrder, ChoiceExitOrder, ChoiceStepOrder, ChoiceEntryStep: + return fmt.Sprintf("%s: %s first of %s", choiceLabel(c.Where), choiceLabel(c.Took), choiceLabels(c.Among)) + } + return fmt.Sprintf("%s -> %s", c.Kind, choiceLabel(c.Took)) +} + +// weightedTail spells what a weighted choice carries past the branch taken — +// ` among p=, …` and ` drew ` when a draw selected it — "" for an unweighted one. +func (c ChoiceTaken) weightedTail() string { + if !c.Weighted() || len(c.Among) != len(c.Weights) { + return "" + } + tail := markAmong + weightedLabels(c.Among, c.Weights) + if c.Drawn { + tail += markDrew + FormatWeight(c.Drew) + } + return tail +} + +// weightedLabels spells the alternatives with their weights, ` p=, …`. +func weightedLabels(among []string, weights []float64) string { + parts := make([]string, len(among)) + for i, alt := range among { + parts[i] = choiceLabel(alt) + markWeight + FormatWeight(weights[i]) + } + return strings.Join(parts, markList) +} + +// FormatChoices renders a witness as one line, its choices in run order. +func FormatChoices(choices []ChoiceTaken) string { + if len(choices) == 0 { + return "no choice points" + } + parts := make([]string, len(choices)) + for i, c := range choices { + parts[i] = c.String() + } + return strings.Join(parts, "; ") +} + +// ExploredOutcome is one distinct outcome an exploration reached: how many +// linearizations reached it, the probability they carry, and the choices of +// the first run that did. +type ExploredOutcome struct { + Outcome Outcome + Linearizations int + // Probability is the share of the schedule space reaching this outcome: the + // sum of its linearizations' probabilities, each the product of its choice + // points' shares — a weighted point's stated weight, an unweighted one's + // uniform share. It is the model's own probability when every point is + // weighted, and a lower bound while the exploration is incomplete. + Probability float64 + Witness []ChoiceTaken + // WitnessRun is the 1-based number of the run the witness is. + WitnessRun int +} + +// Exploration is what exploring a behavior found: its distinct outcomes, in +// canonical order, and whether every linearization within the budget was run. +type Exploration struct { + Budget ExploreBudget + Runs int + Outcomes []ExploredOutcome + // BudgetsHit names the budgets the exploration ran into, `runs` before + // `depth`; none when it is complete. + BudgetsHit []string +} + +// Complete reports whether every linearization was run. +func (x *Exploration) Complete() bool { return len(x.BudgetsHit) == 0 } + +// Probability is the share of the schedule space the exploration covered: the +// sum of its outcomes' probabilities, 1 for a complete one. +func (x *Exploration) Probability() float64 { + total := 0.0 + for _, o := range x.Outcomes { + total += o.Probability + } + return total +} + +// ProbabilitiesBounded reports whether the outcomes' probabilities are lower +// bounds: they are while a budget kept some linearizations unexplored. +func (x *Exploration) ProbabilitiesBounded() bool { return !x.Complete() } + +// Status renders how the exploration ended: `complete (N runs)`, or which budget +// was hit after how many runs. +func (x *Exploration) Status() string { + if x.Complete() { + return fmt.Sprintf("complete (%d runs)", x.Runs) + } + named := make([]string, len(x.BudgetsHit)) + for i, budget := range x.BudgetsHit { + limit := x.Budget.Runs + if budget == "depth" { + limit = x.Budget.Depth + } + named[i] = fmt.Sprintf("%s budget %d", budget, limit) + } + return fmt.Sprintf("incomplete: %s hit after %d runs; probabilities are lower bounds", strings.Join(named, " and "), x.Runs) +} + +// Explore runs a behavior once per linearization within the policy's budget, one run +// at a time in plan order: fresh builds each run's context, run performs it and reports +// the outcome. A run that failed is an outcome; a caller that goes away between runs +// takes the exploration with it, its error being stop's. It is ExploreWith on one job. +func Explore(stop context.Context, policy SchedulePolicy, fresh func() (*Context, error), run func(*Context) (Outcome, error)) (*Exploration, error) { + return ExploreWith(stop, policy, 1, func(int) (*Context, error) { return fresh() }, run) +} + +// sortOutcomes puts an exploration's outcomes in canonical order. +func sortOutcomes(outcomes []ExploredOutcome) { + sort.SliceStable(outcomes, func(i, j int) bool { + a, b := outcomes[i].Outcome, outcomes[j].Outcome + if as, bs := a.String(), b.String(); as != bs { + return as < bs + } + return a.identity() < b.identity() + }) +} + +// slotKind is what an exploration slot resolves: a pick among alternatives given +// in declaration order, or which of the tokens able to act a step tries next. +type slotKind int + +const ( + slotPick slotKind = iota + slotTokens +) + +// exploreSlot is one decision an exploring run made: the alternatives it had, +// the one taken, and how the run describes it. +type exploreSlot struct { + kind slotKind + alternatives int + taken int + beyond bool // resolved past the depth budget, so not the exploration's to vary + step int + tokens []int64 // the tokens able to act, sorted by ID + labels []string // those tokens as the trace names them + choice ChoicePoint + described bool +} + +// exploreRun is the plan and record of one exploring run: the slots it must +// follow and the slots it made. +type exploreRun struct { + prefix []exploreSlot + depth int + record []exploreSlot + explored int // choice points made that count against depth + depthHit bool + duplicate bool // the frontier's alternative could not act, so the run repeats one made + diverged error +} + +// pick resolves a choice among n alternatives: the planned one within the prefix, +// the first otherwise. +func (r *exploreRun) pick(n int) int { + slot := exploreSlot{kind: slotPick, alternatives: n} + r.resolve(&slot, fmt.Sprintf("%d alternatives to pick from", n)) + return slot.taken +} + +// describe attaches to the last pick how the run reports it. +func (r *exploreRun) describe(c ChoicePoint) { + for i := len(r.record) - 1; i >= 0; i-- { + if slot := &r.record[i]; slot.kind == slotPick { + slot.choice, slot.described = c, true + return + } + } +} + +// exploreStep tries a step's tokens one at a time until one acts: that move is +// the step, a choice among the tokens able to act at that moment. +type exploreStep struct { + run *exploreRun + tokens stepTokens + remaining []int64 // sorted by ID, not yet tried + held []int64 // tried last, as they never act on their own + slot int // index in run.record of the slot open, -1 between picks + dupBefore bool // run.duplicate when the slot opened, restored if the slot is dropped + moved bool // a token acted, so the step is over + choice *exploreSlot // the pick the move resolved, nil when one token alone could act +} + +// beginStep opens the step's picks. +func (r *exploreRun) beginStep(tokens stepTokens) *exploreStep { + s := &exploreStep{run: r, tokens: tokens, slot: -1} + for _, id := range tokens.ids { + if tokens.held[id] { + s.held = append(s.held, id) + } else { + s.remaining = append(s.remaining, id) + } + } + slices.Sort(s.remaining) + slices.Sort(s.held) + return s +} + +// next picks the token to try: the planned or first of those able to act when at +// least two are, the only one when one is, else the first left, which will not act. +func (s *exploreStep) next() (int64, bool) { + if s.moved { + return 0, false + } + if len(s.remaining) == 0 { + if len(s.held) == 0 { + return 0, false + } + id := s.held[0] + s.held = s.held[1:] + return id, true + } + if s.slot >= 0 { + slot := &s.run.record[s.slot] + return slot.tokens[slot.taken], true + } + enabled := make([]int64, 0, len(s.remaining)) + for _, id := range s.remaining { + if s.tokens.enabled(id) { + enabled = append(enabled, id) + } + } + switch len(enabled) { + case 0: + return s.remaining[0], true + case 1: + return enabled[0], true + } + slot := exploreSlot{kind: slotTokens, alternatives: len(enabled), step: s.tokens.step, tokens: enabled} + s.dupBefore = s.run.duplicate + s.run.resolve(&slot, fmt.Sprintf("step %d: tokens %v able to act", s.tokens.step, enabled)) + s.slot = len(s.run.record) - 1 + slot = s.run.record[s.slot] + slot.labels = make([]string, len(slot.tokens)) + for i, id := range slot.tokens { + slot.labels[i] = s.tokens.label(id) + } + s.run.record[s.slot] = slot + return slot.tokens[slot.taken], true +} + +// acted ends the step when the token acted, closing the pick; one that did not +// was no alternative, so it leaves the slot and the pick is made again. +func (s *exploreStep) acted(id int64, acted bool) { + if i := slices.Index(s.remaining, id); i >= 0 { + s.remaining = slices.Delete(s.remaining, i, i+1) + } + if acted { + s.moved = true + if s.slot >= 0 { + slot := s.run.record[s.slot] + s.choice = &slot + s.slot = -1 + } + return + } + if s.slot < 0 { + return + } + slot := &s.run.record[s.slot] + if i := slices.Index(slot.tokens, id); i >= 0 { + slot.tokens = slices.Delete(slices.Clone(slot.tokens), i, i+1) + slot.labels = slices.Delete(slices.Clone(slot.labels), i, i+1) + slot.alternatives-- + } + if len(slot.tokens) == 0 { + // No token of the pick acted, so it was no choice point and repeated nothing. + s.run.record = s.run.record[:s.slot] + s.run.explored-- + s.run.duplicate = s.dupBefore + s.slot = -1 + return + } + if slot.taken >= len(slot.tokens) { + // The plan's untried alternative could not act: the run repeats the last one made. + slot.taken = len(slot.tokens) - 1 + s.run.duplicate = true + } +} + +// resolve settles the slot's alternative: the planned one within the prefix, the +// first past it or past the depth budget; the slot is recorded as made. +func (r *exploreRun) resolve(slot *exploreSlot, faced string) { + i := len(r.record) + switch { + case i < len(r.prefix): + planned := r.prefix[i] + if !planned.matches(*slot) { + r.diverge(fmt.Sprintf("choice %d: %s, planned %s", i+1, faced, planned.describePlan())) + } else { + slot.taken, slot.beyond = planned.taken, planned.beyond + if slot.kind == slotTokens { + slot.tokens, slot.alternatives = planned.tokens, planned.alternatives + } + } + case r.explored >= r.depth: + slot.beyond = true + } + if slot.beyond { + r.depthHit = true + } + r.explored++ + r.record = append(r.record, *slot) +} + +// matches reports whether the slot faced is the one planned: the same pick, or +// a step whose tokens able to act include those the plan found so. +func (planned exploreSlot) matches(faced exploreSlot) bool { + if planned.kind != faced.kind { + return false + } + if planned.kind == slotPick { + return planned.alternatives == faced.alternatives + } + for _, id := range planned.tokens { + if !slices.Contains(faced.tokens, id) { + return false + } + } + return true +} + +// diverge records the first way the run left its plan. +func (r *exploreRun) diverge(reason string) { + if r.diverged == nil { + r.diverged = errors.New(reason) + } +} + +// followed reports whether the run made every choice its prefix planned, as planned. +func (r *exploreRun) followed() error { + if r.diverged != nil { + return r.diverged + } + if len(r.record) < len(r.prefix) { + return fmt.Errorf("%d choice points reached, %d planned", len(r.record), len(r.prefix)) + } + return nil +} + +// choices lists the choice points the run made, in order, as a witness. +func (r *exploreRun) choices() []ChoiceTaken { + out := make([]ChoiceTaken, len(r.record)) + for i, slot := range r.record { + out[i] = slot.asChoice() + } + return out +} + +// share is the probability the slot's draw resolved to the alternative taken: +// the stated weight's share of the weights for a weighted pick, the uniform +// share of its alternatives otherwise — what a seeded run takes each with. +func (s exploreSlot) share() float64 { + if s.alternatives <= 0 { + return 1 + } + if s.kind == slotPick && s.described && s.choice.Weighted() && s.taken < len(s.choice.Weights) { + total := 0.0 + for _, w := range s.choice.Weights { + total += w + } + if total > 0 { + return s.choice.Weights[s.taken] / total + } + } + return 1 / float64(s.alternatives) +} + +// probability is the linearization's share of the schedule space: the product +// of its slots' shares, those past the depth budget contributing theirs. +func (r *exploreRun) probability() float64 { + p := 1.0 + for _, slot := range r.record { + p *= slot.share() + } + return p +} + +// asChoice renders the slot as the choice the run took. +func (s exploreSlot) asChoice() ChoiceTaken { + if s.kind == slotTokens { + return ChoiceTaken{ + Kind: ChoiceTokenOrder, + Step: s.step, + Alternatives: s.alternatives, + Taken: s.taken, + Among: s.labels, + Took: s.labels[s.taken], + } + } + c := ChoiceTaken{Kind: ChoiceDecisionBranch, Alternatives: s.alternatives, Taken: s.taken} + if s.described { + c.Kind, c.Step, c.Where, c.Among = s.choice.Kind, s.choice.Step, s.choice.Where, s.choice.Alternatives + if s.taken < len(s.choice.Alternatives) { + c.Took = s.choice.Alternatives[s.taken] + } + if s.choice.Weighted() { + c.Weights, c.Drew, c.Drawn = slices.Clone(s.choice.Weights), s.choice.Drew, s.choice.Drawn + } + } + if c.Took == "" { + c.Took = fmt.Sprintf("alternative %d of %d", s.taken+1, s.alternatives) + } + return c +} + +// describePlan renders the slot as a plan: what was faced and what was taken. +func (s exploreSlot) describePlan() string { + if s.kind == slotTokens { + return fmt.Sprintf("step %d: token %d of %v", s.step, s.tokens[s.taken], s.tokens) + } + return fmt.Sprintf("alternative %d of %d", s.taken+1, s.alternatives) +} + +// unexplored is the prefixes this run leaves to explore, earliest first: for each choice +// the run owns — the last its prefix planned and every one it made below — the record up +// to that choice taking its next alternative, when one is untried within depth. The +// choices before the last planned one belong to the runs that planned them. +func (r *exploreRun) unexplored() [][]exploreSlot { + var next [][]exploreSlot + for i := max(len(r.prefix)-1, 0); i < len(r.record); i++ { + slot := r.record[i] + if slot.beyond || slot.taken+1 >= slot.alternatives { + continue + } + prefix := make([]exploreSlot, i+1) + copy(prefix, r.record[:i+1]) + prefix[i].taken++ + next = append(next, prefix) + } + return next +} + +// mark returns what a probe restores: the run's position, so previewing does +// not make choices the run itself goes on to make. +func (r *exploreRun) mark() func() { + record, explored := len(r.record), r.explored + depthHit, diverged := r.depthHit, r.diverged + return func() { + r.record = r.record[:record] + r.explored = explored + r.depthHit, r.diverged = depthHit, diverged + } +} diff --git a/internal/exec/runtime/explore_order_test.go b/internal/exec/runtime/explore_order_test.go new file mode 100644 index 0000000000..aee9531270 --- /dev/null +++ b/internal/exec/runtime/explore_order_test.go @@ -0,0 +1,50 @@ +package runtime + +import ( + "path/filepath" + "strings" + "testing" +) + +var earlyRaceLongTailPath = filepath.Join("testdata", "conformance", "action_explore_early_race_long_tail.sysml") + +// The plan varies every choice point of the first run once, earliest first, before it varies +// any twice: an open choice met early, followed by many closed ones, is varied by the second +// run whatever the tails below it fan out to, on one job as on eight. +func TestExploreVariesEveryChoiceOfTheFirstRunFirst(t *testing.T) { + idx, path := indexFile(t, earlyRaceLongTailPath) + run := actionRun(t, idx, path, "race") + fresh := newExploreWorkers(idx).fresh + + complete := exploreBoth(t, DefaultExploreBudget, fresh, run) + if !complete.Complete() || complete.Runs != 20 || len(complete.Outcomes) != 2 { + t.Fatalf("status %q with %d outcomes, want complete after the 20 orders of the two branches", complete.Status(), len(complete.Outcomes)) + } + first, other := complete.Outcomes[1], complete.Outcomes[0] + if first.WitnessRun != 1 { + first, other = other, first + } + if first.WitnessRun != 1 || first.Outcome.String() != "p = 2; q = 2; x = 2" { + t.Fatalf("first run reached %s as run %d, want x = 2 as run 1", first.Outcome, first.WitnessRun) + } + if len(first.Witness) < 2 || first.Witness[0].Alternatives != 2 { + t.Fatalf("first run's witness %s, want the write order then the tails", FormatChoices(first.Witness)) + } + if other.WitnessRun != 2 || other.Outcome.String() != "p = 2; q = 2; x = 1" { + t.Fatalf("%s reached first by run %d, want x = 1 by run 2, the first run's earliest choice varied", other.Outcome, other.WitnessRun) + } + for _, o := range complete.Outcomes { + if o.Linearizations != 10 { + t.Errorf("%s reached by %d linearizations, want 10", o.Outcome, o.Linearizations) + } + } + + enough := exploreBoth(t, ExploreBudget{Runs: 2, Depth: 64}, fresh, run) + if enough.Complete() || len(enough.Outcomes) != 2 || strings.Join(enough.BudgetsHit, ",") != "runs" { + t.Fatalf("status %q with %d outcomes under 2 runs, want both values at the runs cut", enough.Status(), len(enough.Outcomes)) + } + short := exploreBoth(t, ExploreBudget{Runs: 1, Depth: 64}, fresh, run) + if short.Complete() || len(short.Outcomes) != 1 { + t.Fatalf("status %q with %d outcomes under 1 run, want the first run's value alone", short.Status(), len(short.Outcomes)) + } +} diff --git a/internal/core/runtime/explore_queue.go b/internal/exec/runtime/explore_queue.go similarity index 85% rename from internal/core/runtime/explore_queue.go rename to internal/exec/runtime/explore_queue.go index 75af732373..9f2e64520f 100644 --- a/internal/core/runtime/explore_queue.go +++ b/internal/exec/runtime/explore_queue.go @@ -11,7 +11,10 @@ import ( // each run's context for the job making it; the result does not depend on jobs. // // The prefixes form a work queue in plan order, the order one job visits them, and the runs -// budget is a cut in that order. A run is committed once every prefix before it has completed; +// budget is a cut in that order. Plan order is by departures first — every prefix departing +// from the first run at one choice before any departing at two — so a budget of one more run +// than the first run's choice points varies each of them at least once, then by the position +// of the choice varied, earliest first. A run is committed once every prefix before it has completed; // one started earlier is speculative, and at most jobs of those are discarded in all. The queue // never holds more than runs prefixes, so no more than runs jobs are ever put to work. func ExploreWith(stop context.Context, policy SchedulePolicy, jobs int, fresh func(job int) (*Context, error), run func(*Context) (Outcome, error)) (*Exploration, error) { @@ -33,7 +36,7 @@ func newExploreQueue(policy SchedulePolicy, budget ExploreBudget, jobs int) *exp reached: make(map[string]int), } q.wake = sync.NewCond(&q.mu) - q.insert(0, []*explorePrefix{{}}) + q.insert([]*explorePrefix{{}}) return q } @@ -75,7 +78,10 @@ const ( // explorePrefix is one prefix on the queue: its position in plan order and, once run, the result, // held only until the run is folded or dropped so the queue keeps no context but the witnesses'. type explorePrefix struct { - prefix []exploreSlot + prefix []exploreSlot + // key places the prefix in plan order: its parent's key, then its rank among the + // prefixes the parent's run left; its length is how many choices the run departs at. + key []int index int state prefixState replay *exploreRun // nil when fresh failed @@ -205,7 +211,7 @@ func (q *exploreQueue) speculative() int { return n } -// finish records a run's result, queues the prefixes it leaves right after it and folds +// finish records a run's result, queues the prefixes it leaves in plan order and folds // what is now committed; a run dropped meanwhile is discarded. func (q *exploreQueue) finish(p *explorePrefix, replay *exploreRun, outcome Outcome, err error) { if err == nil { @@ -225,19 +231,34 @@ func (q *exploreQueue) finish(p *explorePrefix, replay *exploreRun, outcome Outc children := replay.unexplored() next := make([]*explorePrefix, len(children)) for i, prefix := range children { - next[i] = &explorePrefix{prefix: prefix} + next[i] = &explorePrefix{prefix: prefix, key: append(slices.Clone(p.key), i)} } - q.insert(p.index+1, next) + q.insert(next) } q.fold() } -// insert queues prefixes at position at, moving what follows back; whatever moves past the -// runs cut is dropped, a started run among it discarded. -func (q *exploreQueue) insert(at int, prefixes []*explorePrefix) { +// before reports whether p precedes o in plan order: fewer departures, then the lesser key. +func (p *explorePrefix) before(o *explorePrefix) bool { + if len(p.key) != len(o.key) { + return len(p.key) < len(o.key) + } + return slices.Compare(p.key, o.key) < 0 +} + +// insert queues one run's prefixes, consecutive in plan order, where that order puts them, +// moving what follows back; whatever moves past the runs cut is dropped, a started run among +// it discarded. +func (q *exploreQueue) insert(prefixes []*explorePrefix) { if len(prefixes) == 0 { return } + at, _ := slices.BinarySearchFunc(q.order, prefixes[0], func(o, p *explorePrefix) int { + if o.before(p) { + return -1 + } + return 1 + }) q.order = slices.Insert(q.order, at, prefixes...) if len(q.order) > q.budget.Runs { q.beyond = true @@ -278,11 +299,13 @@ func (q *exploreQueue) fold() { q.result.Outcomes = append(q.result.Outcomes, ExploredOutcome{ Outcome: p.outcome, Linearizations: 1, + Probability: p.replay.probability(), Witness: p.replay.choices(), WitnessRun: q.frontier, }) } else if !p.replay.duplicate { q.result.Outcomes[i].Linearizations++ + q.result.Outcomes[i].Probability += p.replay.probability() } p.release() } diff --git a/internal/core/runtime/explore_queue_test.go b/internal/exec/runtime/explore_queue_test.go similarity index 96% rename from internal/core/runtime/explore_queue_test.go rename to internal/exec/runtime/explore_queue_test.go index affd707361..5808bf13e3 100644 --- a/internal/core/runtime/explore_queue_test.go +++ b/internal/exec/runtime/explore_queue_test.go @@ -12,13 +12,13 @@ import ( "sync/atomic" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // exploreJobs is the job count the determinism tests run beside one job. @@ -41,7 +41,7 @@ func (w *exploreWorkers) fresh(job int) (*Context, error) { m, ok := w.workers[job] if !ok { resolver := resolve.New(w.idx) - m = NewModel(semantics.NewModel(resolver), resolver) + m = typedModel(semantics.NewModel(resolver), resolver) w.workers[job] = m } w.mu.Unlock() diff --git a/internal/exec/runtime/explore_test.go b/internal/exec/runtime/explore_test.go new file mode 100644 index 0000000000..b6f8752341 --- /dev/null +++ b/internal/exec/runtime/explore_test.go @@ -0,0 +1,1625 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +// exploreModel is a model parsed and lowered once, from which every run of an +// exploration builds its own context. +type exploreModel struct { + idx *symbols.Index + model *semantics.Model + resolver *resolve.Resolver + path string +} + +func parseExploreModel(t *testing.T, text string) *exploreModel { + t.Helper() + path := filepath.Join(t.TempDir(), "explore.sysml") + p := parser.New(source.New(path, []byte(text))) + file := p.ParseFile() + if len(p.Diagnostics) > 0 { + t.Fatalf("parse: %v", p.Diagnostics) + } + idx := symbols.NewIndex() + idx.AddDocument(path, file) + resolver := resolve.New(idx) + return &exploreModel{idx: idx, model: semantics.NewModel(resolver), resolver: resolver, path: path} +} + +// parseLibraryModel is parseExploreModel over the standard libraries, for models using them. +func parseLibraryModel(t *testing.T, text string) *exploreModel { + t.Helper() + if err := primeLibraryCache(); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "explore.sysml") + idx := libs.NewModelIndex() + idx.AddDocument(path, parseAndBuild(t, text)) + idx.ExpandWildcardImports() + resolver := resolve.New(idx) + return &exploreModel{idx: idx, model: semantics.NewModel(resolver), resolver: resolver, path: path} +} + +func (m *exploreModel) fresh() (*Context, error) { + return NewContext(parsingModel(m.model, m.resolver), 10000), nil +} + +func (m *exploreModel) action(t *testing.T, name string) *symbols.Symbol { + t.Helper() + return namedOrFoundSymbol(t, m.idx, "test::"+name, m.idx.DocumentRoot(m.path), ast.DefAction, ast.UsageAction) +} + +func (m *exploreModel) state(t *testing.T, name string) *symbols.Symbol { + t.Helper() + return namedOrFoundSymbol(t, m.idx, "test::"+name, m.idx.DocumentRoot(m.path), ast.DefState, ast.UsageState) +} + +// exploreAction explores the runs of an action performed by no object. +func (m *exploreModel) exploreAction(t *testing.T, spelling, name string) *Exploration { + t.Helper() + policy, err := ParseSchedulePolicy(spelling) + if err != nil { + t.Fatalf("policy %s: %v", spelling, err) + } + sym := m.action(t, name) + result, err := Explore(context.Background(), policy, m.fresh, func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + }) + if err != nil { + t.Fatalf("explore %s: %v", name, err) + } + return result +} + +func outcomeTexts(x *Exploration) []string { + texts := make([]string, len(x.Outcomes)) + for i, o := range x.Outcomes { + texts[i] = o.Outcome.String() + } + return texts +} + +const threeWritersModel = `package test { + action race { + attribute x : Integer = 0; + first start; + fork split; + action a { assign x := 1; } + action b { assign x := 2; } + action c { assign x := 3; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first split then c; + succession first a then sync; + succession first b then sync; + succession first c then sync; + succession first sync then done; + } +}` + +func TestExploreThreeWritersReachEveryOutcomeOnce(t *testing.T) { + m := parseExploreModel(t, threeWritersModel) + x := m.exploreAction(t, "explore", "race") + if !x.Complete() || x.Runs != 6 { + t.Fatalf("status %q, want complete after the 6 orders of three tokens", x.Status()) + } + want := []string{"x = 1", "x = 2", "x = 3"} + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } + for _, o := range x.Outcomes { + if o.Linearizations != 2 { + t.Errorf("%s reached by %d linearizations, want 2 (the writer last, the other two either way)", o.Outcome, o.Linearizations) + } + w := o.Witness + if len(w) != 2 || w[0].Kind != ChoiceTokenOrder || w[0].Alternatives != 3 || w[1].Alternatives != 2 { + t.Errorf("%s witness %v, want a token-order choice among 3 then one among the 2 left", o.Outcome, w) + } + } +} + +func TestExploreIsDeterministic(t *testing.T) { + m := parseExploreModel(t, threeWritersModel) + first := m.exploreAction(t, "explore", "race") + second := m.exploreAction(t, "explore", "race") + if first.Status() != second.Status() { + t.Fatalf("status %q then %q", first.Status(), second.Status()) + } + for i := range first.Outcomes { + a, b := first.Outcomes[i], second.Outcomes[i] + if a.Outcome.String() != b.Outcome.String() || a.Linearizations != b.Linearizations || + FormatChoices(a.Witness) != FormatChoices(b.Witness) || a.WitnessRun != b.WitnessRun { + t.Errorf("outcome %d differs between explorations: %+v then %+v", i, a, b) + } + } +} + +func TestExploreNoChoicePointsIsOneRun(t *testing.T) { + m := parseExploreModel(t, `package test { + action straight { + attribute x : Integer = 0; + first start; + action a { assign x := x + 1; } + action b { assign x := x * 10; } + done; + succession first start then a; + succession first a then b; + succession first b then done; + } + }`) + x := m.exploreAction(t, "explore", "straight") + if x.Runs != 1 || !x.Complete() { + t.Fatalf("status %q, want complete (1 runs)", x.Status()) + } + if got := outcomeTexts(x); len(got) != 1 || got[0] != "x = 10" { + t.Fatalf("outcomes %v, want [x = 10]", got) + } + if w := x.Outcomes[0].Witness; len(w) != 0 || FormatChoices(w) != "no choice points" { + t.Fatalf("witness %v, want none", w) + } +} + +func TestExploreRunsBudgetIsIncomplete(t *testing.T) { + m := parseExploreModel(t, threeWritersModel) + x := m.exploreAction(t, "explore:runs=1", "race") + if x.Complete() || x.Runs != 1 || !x.ProbabilitiesBounded() { + t.Fatalf("status %q, want incomplete after 1 run with bounded probabilities", x.Status()) + } + if want := "incomplete: runs budget 1 hit after 1 runs; probabilities are lower bounds"; x.Status() != want { + t.Fatalf("status %q, want %q", x.Status(), want) + } + if len(x.Outcomes) != 1 { + t.Fatalf("outcomes %v, want the one run's", outcomeTexts(x)) + } +} + +func TestExploreDepthZeroIsIncomplete(t *testing.T) { + m := parseExploreModel(t, threeWritersModel) + x := m.exploreAction(t, "explore:depth=0", "race") + if x.Complete() || x.Runs != 1 || !x.ProbabilitiesBounded() { + t.Fatalf("status %q, want incomplete after the one run that varied nothing, with bounded probabilities", x.Status()) + } + if want := "incomplete: depth budget 0 hit after 1 runs; probabilities are lower bounds"; x.Status() != want { + t.Fatalf("status %q, want %q", x.Status(), want) + } +} + +func TestExploreErrorIsAnOutcome(t *testing.T) { + m := parseExploreModel(t, `package test { + action fragile { + attribute x : Integer = 0; + attribute y : Integer = 1; + first start; + fork split; + action a { assign x := 0; } + action b { assign x := 4; } + join sync; + action divide { assign y := 8 / x; } + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then divide; + succession first divide then done; + } + }`) + x := m.exploreAction(t, "explore", "fragile") + if !x.Complete() || x.Runs != 2 { + t.Fatalf("status %q, want complete (2 runs)", x.Status()) + } + var errored, computed int + for _, o := range x.Outcomes { + if o.Outcome.Err != nil { + errored++ + if !strings.HasPrefix(o.Outcome.String(), "error: ") { + t.Errorf("error outcome renders as %q", o.Outcome) + } + } else { + computed++ + } + } + if errored != 1 || computed != 1 { + t.Fatalf("outcomes %v, want one error outcome and one computed", outcomeTexts(x)) + } +} + +func TestExploreDecisionInLoop(t *testing.T) { + m := parseExploreModel(t, `package test { + action count { + attribute n : Integer = 0; + attribute odd : Integer = 0; + attribute even : Integer = 0; + first start; + decide pick; + action left { assign odd := odd + 1; assign n := n + 1; } + action right { assign even := even + 1; assign n := n + 1; } + merge again; + done; + succession first start then pick; + succession first pick if n < 2 then left; + succession first pick if n < 3 then right; + succession first pick if n >= 3 then done; + succession first left then again; + succession first right then again; + succession first again then pick; + } + }`) + x := m.exploreAction(t, "explore", "count") + if !x.Complete() { + t.Fatalf("status %q, want complete", x.Status()) + } + // Two overlapping guards hold while n < 2, so the first two rounds branch and + // the third takes `right` alone: 4 linearizations, 3 outcomes by even/odd. + if x.Runs != 4 { + t.Fatalf("%d runs, want 4", x.Runs) + } + want := []string{ + "even = 1; n = 3; odd = 2", + "even = 2; n = 3; odd = 1", + "even = 3; n = 3; odd = 0", + } + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } +} + +func TestExploreStateTransitionConflict(t *testing.T) { + m := parseExploreModel(t, `package test { + state def Machine { + entry; then idle; + state idle; + state left; + state right; + transition idle_left first idle accept go then left; + transition idle_right first idle accept go then right; + } + }`) + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + sym := m.state(t, "Machine") + x, err := Explore(context.Background(), policy, m.fresh, func(ctx *Context) (Outcome, error) { + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + return Outcome{}, err + } + if err := exec.initialize(); err != nil { + return Outcome{}, err + } + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + }) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 2 { + t.Fatalf("status %q, want complete (2 runs)", x.Status()) + } + want := []string{"finalState left; visits idle, left", "finalState right; visits idle, right"} + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } + if w := x.Outcomes[1].Witness; len(w) != 1 || w[0].Kind != ChoiceTransition || !strings.HasSuffix(w[0].Took, "->right") { + t.Fatalf("witness of right %v, want the transition choice into right", w) + } +} + +// A choice whose branches are enabled only by the incoming transition's effect is +// a choice point resolved as the choice is reached: exploration enumerates both +// branches, each witness naming the choice and the branch taken, and a seed that +// drew a branch draws it again; the run whose effect is not run first — a +// junction's static reading — would find neither branch enabled. +func TestExploreDynamicChoiceBranches(t *testing.T) { + m := parseExploreModel(t, `package test { + state def Machine { + attribute level : Integer = 0; + entry; then idle; + state idle; + choice pick; + state left; + state right; + transition first idle accept go do assign level := 8 then pick; + transition first pick if level > 5 then left; + transition first pick if level > 7 then right; + } + }`) + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + sym := m.state(t, "Machine") + run := func(ctx *Context) (Outcome, error) { + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + return Outcome{}, err + } + if err := exec.initialize(); err != nil { + return Outcome{}, err + } + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + } + x, err := Explore(context.Background(), policy, m.fresh, run) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 2 { + t.Fatalf("status %q, want complete (2 runs)", x.Status()) + } + want := []string{"finalState left; visits idle, left; level = 8", "finalState right; visits idle, right; level = 8"} + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } + w := x.Outcomes[1].Witness + if len(w) != 1 || w[0].Kind != ChoiceTransition || w[0].Where != "choice pick" || !strings.HasSuffix(w[0].Took, "->right") { + t.Fatalf("witness of right %v, want the branch of choice pick into right", w) + } + + under := func(spelling string) (Outcome, ChoicePoint) { + t.Helper() + fixed, err := ParseSchedulePolicy(spelling) + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + if err := ctx.SetSchedule(fixed); err != nil { + t.Fatal(err) + } + outcome, err := run(ctx) + if err != nil { + t.Fatalf("%s: %v", spelling, err) + } + notes := ctx.Notes() + if len(notes) != 1 { + t.Fatalf("%s: notes %v, want the one choice at pick", spelling, notes) + } + choice, ok := notes[0].(ChoicePoint) + if !ok || choice.Kind != ChoiceTransition || choice.Where != "choice pick" { + t.Fatalf("%s: note %v, want the branch choice at pick", spelling, notes[0]) + } + return outcome, choice + } + reached := make(map[int]bool) + for _, spelling := range []string{"seed:1", "seed:2", "seed:3", "seed:6"} { + outcome, choice := under(spelling) + if got := outcome.String(); got != want[choice.Taken] { + t.Fatalf("%s: outcome %q after taking %s, want %q", spelling, got, choice.Alternatives[choice.Taken], want[choice.Taken]) + } + if again, _ := under(spelling); again.String() != outcome.String() { + t.Fatalf("%s: outcome %q, then %q; want the seed to replay its run", spelling, outcome, again) + } + reached[choice.Taken] = true + } + if len(reached) != 2 { + t.Fatalf("seeds reached only %v, want both branches of pick", reached) + } +} + +// A junction with several branches enabled when the incoming transition is +// selected is a choice point read statically, before the incoming effect: the +// branch guards see the data as it stood before the effect, exploration +// enumerates both branches with a witness at the junction, and a seed replays +// its draw. A guard the effect would enable is not a branch. +func TestExploreStaticJunctionBranches(t *testing.T) { + m := parseExploreModel(t, `package test { + state def Machine { + attribute level : Integer = 0; + attribute route : Integer = 0; + entry; then idle; + state idle; + junction split; + state left; + state right; + state late; + transition first idle accept go do assign level := 8 then split; + transition first split if level == 0 do assign route := 1 then left; + transition first split if level < 5 do assign route := 2 then right; + transition first split if level > 7 do assign route := 3 then late; + } + }`) + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + sym := m.state(t, "Machine") + run := func(ctx *Context) (Outcome, error) { + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + return Outcome{}, err + } + if err := exec.initialize(); err != nil { + return Outcome{}, err + } + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + } + x, err := Explore(context.Background(), policy, m.fresh, run) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 2 { + t.Fatalf("status %q, want complete (2 runs)", x.Status()) + } + want := []string{ + "finalState left; visits idle, left; level = 8; route = 1", + "finalState right; visits idle, right; level = 8; route = 2", + } + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } + w := x.Outcomes[1].Witness + if len(w) != 1 || w[0].Kind != ChoiceTransition || w[0].Where != "junction split" || !strings.HasSuffix(w[0].Took, "->right") { + t.Fatalf("witness of right %v, want the branch of junction split into right", w) + } + if alts := w[0].Alternatives; alts != 2 { + t.Fatalf("alternatives %v, want the two branches enabled before the effect", alts) + } + + for _, spelling := range []string{"seed:1", "seed:2", "seed:3"} { + fixed, err := ParseSchedulePolicy(spelling) + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + if err := ctx.SetSchedule(fixed); err != nil { + t.Fatal(err) + } + outcome, err := run(ctx) + if err != nil { + t.Fatalf("%s: %v", spelling, err) + } + notes := ctx.Notes() + if len(notes) != 1 { + t.Fatalf("%s: notes %v, want the one choice at split", spelling, notes) + } + choice, ok := notes[0].(ChoicePoint) + if !ok || choice.Kind != ChoiceTransition || choice.Where != "junction split" { + t.Fatalf("%s: note %v, want the branch choice at split", spelling, notes[0]) + } + if got := outcome.String(); got != want[choice.Taken] { + t.Fatalf("%s: outcome %q after taking %s, want %q", spelling, got, choice.Alternatives[choice.Taken], want[choice.Taken]) + } + } +} + +// A junction with two branches enabled, one of them on to a junction none of +// whose guards holds: the draw is still made, and the dead end is the outcome of +// the run that draws it alone, not a failure of the transition into the first. +func TestExploreJunctionBranchBeyondWhichNoGuardHolds(t *testing.T) { + m := parseExploreModel(t, `package test { + state def Machine { + attribute level : Integer = 0; + entry; then idle; + state idle; + junction split; + junction stuck; + state ready; + state never; + transition first idle accept go then split; + transition first split then ready; + transition first split then stuck; + transition first stuck if level > 0 then never; + } + }`) + sym := m.state(t, "Machine") + run := func(ctx *Context) (Outcome, error) { + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + return Outcome{}, err + } + if err := exec.initialize(); err != nil { + return Outcome{}, err + } + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + } + declared, err := ParseSchedulePolicy("declared") + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + if err := ctx.SetSchedule(declared); err != nil { + t.Fatal(err) + } + outcome, err := run(ctx) + if err != nil { + t.Fatalf("declared: %v, want the branch into ready taken", err) + } + if got := outcome.String(); got != "finalState ready; visits idle, ready; level = 0" { + t.Fatalf("declared: outcome %q, want ready reached", got) + } + + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + x, err := Explore(context.Background(), policy, m.fresh, run) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 2 { + t.Fatalf("status %q, want complete (2 runs)", x.Status()) + } + got := outcomeTexts(x) + if len(got) != 2 || got[1] != "finalState ready; visits idle, ready; level = 0" { + t.Fatalf("outcomes %v, want the dead end at stuck and ready", got) + } + if !strings.HasPrefix(got[0], "error: ") || !strings.HasSuffix(got[0], "fire transition out of idle: evaluate pseudostate: junction stuck: no guard evaluated to true") { + t.Fatalf("outcomes %v, want the first the dead end at stuck, met as the transition fires", got) + } + w := x.Outcomes[0].Witness + if len(w) != 1 || w[0].Kind != ChoiceTransition || w[0].Where != "junction split" || !strings.HasSuffix(w[0].Took, "->stuck") { + t.Fatalf("witness of the dead end %v, want the branch of junction split into stuck", w) + } +} + +// The branch drawn noted a guard it could not read at a junction on its way +// before it dead-ended: the run fails with the dead end and keeps the note. +func TestJunctionBranchDeadEndKeepsItsNotes(t *testing.T) { + m := parseExploreModel(t, `package test { + state def Machine { + attribute d : Integer = 0; + entry; then idle; + state idle; + junction split; + junction nested; + junction stuck; + state ready; + state other; + state never; + transition first idle accept go then split; + transition first split then nested; + transition first split then ready; + transition first nested if d == 0 then stuck; + transition first nested if 1 / d > 0 then other; + transition first stuck if d > 0 then never; + } + }`) + declared, err := ParseSchedulePolicy("declared") + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + if err := ctx.SetSchedule(declared); err != nil { + t.Fatal(err) + } + exec, err := newStateExecutor(ctx, m.state(t, "Machine"), nil) + if err != nil { + t.Fatal(err) + } + if err := exec.initialize(); err != nil { + t.Fatal(err) + } + exec.SendSignal("go", nil) + err = exec.RunToCompletion() + if err == nil || !strings.Contains(err.Error(), "junction stuck: no guard evaluated to true") { + t.Fatalf("RunToCompletion: %v, want the dead end at stuck", err) + } + var drew, noted bool + for _, n := range ctx.Notes() { + switch n := n.(type) { + case ChoicePoint: + drew = drew || n.Where == "junction split" + case UnevaluableGuard: + noted = noted || n.Where == "junction nested" && strings.Contains(n.Reason, "division by zero") + } + } + if !drew || !noted { + t.Fatalf("notes %v, want the draw at split and the guard at nested that could not be read", ctx.Notes()) + } +} + +// A junction's branch is drawn only as its transition fires, after the entry and +// firing-unit draws before it; a run whose sibling effect disarms the junction +// first draws nothing there. Every witness and every seed's choices replay. +func TestExploreJunctionDrawnAsTransitionFires(t *testing.T) { + m := parseExploreModel(t, `package test { + state def Machine { + attribute armed : Boolean = true; + attribute route : Integer = 0; + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept go do assign armed := false then a2; + } + state b { + entry; then b1; + state b1; + junction split; + state left; + state right; + transition first b1 accept go if armed then split; + transition first split do assign route := 1 then left; + transition first split do assign route := 2 then right; + } + } + } + }`) + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + sym := m.state(t, "Machine") + run := func(ctx *Context) (Outcome, error) { + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + return Outcome{}, err + } + if err := exec.initialize(); err != nil { + return Outcome{}, err + } + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + } + x, err := Explore(context.Background(), policy, m.fresh, run) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 6 { + t.Fatalf("status %q, want complete (6 runs)", x.Status()) + } + // Each outcome's junction draw, "" where the disarming effect ran first. + want := map[string]string{ + "finalState a2+b1; visits work, a1, b1, a2; armed = false; route = 0": "", + "finalState a2+b1; visits work, b1, a1, a2; armed = false; route = 0": "", + "finalState a2+left; visits work, a1, b1, left, a2; armed = false; route = 1": "junction split -> 1->left", + "finalState a2+left; visits work, b1, a1, left, a2; armed = false; route = 1": "junction split -> 1->left", + "finalState a2+right; visits work, a1, b1, right, a2; armed = false; route = 2": "junction split -> 2->right", + "finalState a2+right; visits work, b1, a1, right, a2; armed = false; route = 2": "junction split -> 2->right", + } + junctionDraw := func(taken []ChoiceTaken) string { + for _, c := range taken { + if c.Kind == ChoiceTransition { + return c.String() + } + } + return "" + } + for _, o := range x.Outcomes { + draw, ok := want[o.Outcome.String()] + if !ok { + t.Fatalf("outcome %s, want one of %v", o.Outcome, slices.Sorted(maps.Keys(want))) + } + if got := junctionDraw(o.Witness); got != draw { + t.Errorf("%s: witness %q draws %q at the junction, want %q", o.Outcome, FormatChoices(o.Witness), got, draw) + } + if o.Witness[0].Kind != ChoiceEntryOrder || o.Witness[1].Kind != ChoiceRegionOrder { + t.Errorf("%s: witness %q, want the entry then a firing unit drawn first", o.Outcome, FormatChoices(o.Witness)) + } + } + if len(x.Outcomes) != len(want) { + t.Fatalf("%d outcomes, want %d", len(x.Outcomes), len(want)) + } + assertWitnessesReplay(t, x, m.fresh, run) + + for _, spelling := range []string{"declared", "reverse", "seed:1", "seed:2", "seed:3", "seed:4"} { + fixed, err := ParseSchedulePolicy(spelling) + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, fixed) + outcome, err := run(ctx) + if err != nil { + t.Fatalf("%s: %v", spelling, err) + } + var taken []ChoiceTaken + for _, c := range ctx.Choices() { + taken = append(taken, c.Choice()) + } + draw, ok := want[outcome.String()] + if !ok { + t.Fatalf("%s: outcome %s, want one explored", spelling, outcome) + } + if got := junctionDraw(taken); got != draw { + t.Errorf("%s: %s made the choices %q drawing %q at the junction, want %q", spelling, outcome, FormatChoices(taken), got, draw) + } + if again, _, err := replayed(t, m.fresh, run, taken); err != nil { + t.Errorf("%s: replaying its choices %q: %v", spelling, FormatChoices(taken), err) + } else if again.String() != outcome.String() { + t.Errorf("%s: replaying its choices reached %s, want %s", spelling, again, outcome) + } + } +} + +// A history with no record whose default transition ends at a junction with two +// branches enabled: the draw is recorded as a choice point at the junction, as a +// junction reached from a transition is, so exploration enumerates both entries +// and a seed's run replays from its choices. +func TestExploreHistoryDefaultThroughJunction(t *testing.T) { + m := parseExploreModel(t, `package test { + state def Machine { + attribute route : Integer = 0; + entry; then idle; + state idle; + state work { + entry; then w1; + state w1; + state w2; + history resume; + junction split; + transition first resume then split; + transition first split do assign route := 1 then w1; + transition first split do assign route := 2 then w2; + } + transition first idle accept go then resume; + } + }`) + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + sym := m.state(t, "Machine") + run := func(ctx *Context) (Outcome, error) { + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + return Outcome{}, err + } + if err := exec.initialize(); err != nil { + return Outcome{}, err + } + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + } + x, err := Explore(context.Background(), policy, m.fresh, run) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 2 { + t.Fatalf("status %q, want complete (2 runs)", x.Status()) + } + want := []string{ + "finalState w1; visits idle, work, w1; route = 1", + "finalState w2; visits idle, work, w2; route = 2", + } + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } + for i, o := range x.Outcomes { + if got, wantW := FormatChoices(o.Witness), fmt.Sprintf("junction split -> %d->w%d", i+1, i+1); got != wantW { + t.Errorf("%s: witness %q, want %q", o.Outcome, got, wantW) + } + } + assertWitnessesReplay(t, x, m.fresh, run) + + for _, spelling := range []string{"declared", "seed:1", "seed:2", "seed:3"} { + fixed, err := ParseSchedulePolicy(spelling) + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, fixed) + outcome, err := run(ctx) + if err != nil { + t.Fatalf("%s: %v", spelling, err) + } + notes := ctx.Notes() + if len(notes) != 1 { + t.Fatalf("%s: notes %v, want the one choice at split", spelling, notes) + } + choice, ok := notes[0].(ChoicePoint) + if !ok || choice.Kind != ChoiceTransition || choice.Where != "junction split" { + t.Fatalf("%s: note %v, want the branch choice at split", spelling, notes[0]) + } + if got := outcome.String(); got != want[choice.Taken] { + t.Fatalf("%s: outcome %q after taking %s, want %q", spelling, got, choice.Alternatives[choice.Taken], want[choice.Taken]) + } + if len(ctx.Choices()) != 1 { + t.Fatalf("%s: choices %v, want the one at split", spelling, ctx.Choices()) + } + if again, _, err := replayed(t, m.fresh, run, []ChoiceTaken{choice.Choice()}); err != nil { + t.Errorf("%s: replaying its choice: %v", spelling, err) + } else if again.String() != outcome.String() { + t.Errorf("%s: replaying its choice reached %s, want %s", spelling, again, outcome) + } + } +} + +// One event enabling a transition in each of two regions: exploration draws the +// entry order and then the firings' order (each firing's silent exit and entry +// ride with its effect), every policy reporting both; a change occurrence likewise. +func TestExploreSiblingRegionOrder(t *testing.T) { + t.Run("event", func(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute last : Integer = 0; + entry; then work; + state work parallel { + state a { entry; then a1; state a1; state a2; transition first a1 accept go do assign last := 1 then a2; } + state b { entry; then b1; state b1; state b2; transition first b1 accept go do assign last := 2 then b2; } + } + } + }`) + checkSiblingRegionOrder(t, m, "go", "on accept go", []string{ + "finalState a2+b2; visits work, a1, b1, a2, b2; last = 2", + "finalState a2+b2; visits work, a1, b1, b2, a2; last = 1", + "finalState a2+b2; visits work, b1, a1, a2, b2; last = 2", + "finalState a2+b2; visits work, b1, a1, b2, a2; last = 1", + }) + }) + t.Run("change", func(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute temp : Integer = 0; + attribute last : Integer = 0; + entry; then start; + state start; + state work parallel { + state a { entry; then a1; state a1; state a2; transition first a1 accept when temp > 20 do assign last := 1 then a2; } + state b { entry; then b1; state b1; state b2; transition first b1 accept when temp > 20 do assign last := 2 then b2; } + } + transition first start do assign temp := 30 then work; + } + }`) + checkSiblingRegionOrder(t, m, "", "on change", []string{ + "finalState a2+b2; visits start, work, a1, b1, a2, b2; last = 2; temp = 30", + "finalState a2+b2; visits start, work, a1, b1, b2, a2; last = 1; temp = 30", + "finalState a2+b2; visits start, work, b1, a1, a2, b2; last = 2; temp = 30", + "finalState a2+b2; visits start, work, b1, a1, b2, a2; last = 1; temp = 30", + }) + }) +} + +// checkSiblingRegionOrder checks exploration reaches both entry and both firing orders +// (`last` reads 2 where a's effect ran first), fixed policies take declaration order and report it. +func checkSiblingRegionOrder(t *testing.T, m *exploreModel, signal, where string, want []string) { + sym := m.state(t, "Machine") + run := func(ctx *Context) (Outcome, error) { + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + return Outcome{}, err + } + if err := exec.initialize(); err != nil { + return Outcome{}, err + } + if signal != "" { + exec.SendSignal(signal, nil) + } + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + } + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + x, err := Explore(context.Background(), policy, m.fresh, run) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 4 { + t.Fatalf("status %q, want complete (4 runs)", x.Status()) + } + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } + // The second draw of every witness is the two firings' first unit order. + firstUnit := where + ": %s first of a1(exit), b1(exit)" + for _, o := range x.Outcomes { + if got := o.Witness[1].String(); got != fmt.Sprintf(firstUnit, "a1(exit)") && got != fmt.Sprintf(firstUnit, "b1(exit)") { + t.Fatalf("witness of %s draws %q second, want %q", o.Outcome, got, firstUnit) + } + } + under := func(spelling string) (Outcome, []ChoicePoint) { + t.Helper() + fixed, err := ParseSchedulePolicy(spelling) + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + if err := ctx.SetSchedule(fixed); err != nil { + t.Fatal(err) + } + outcome, err := run(ctx) + if err != nil { + t.Fatalf("%s: %v", spelling, err) + } + choices := ctx.Choices() + if len(ctx.Notes()) != len(choices) || len(choices) != 2 { + t.Fatalf("%s: notes %v, want the entry and firing-unit choices alone", spelling, ctx.Notes()) + } + if choices[0].Kind != ChoiceEntryOrder || strings.Join(choices[0].Alternatives, ", ") != "a1(entry), b1(entry)" { + t.Fatalf("%s: note %v, want the entry order drawn among a1(entry), b1(entry)", spelling, choices[0]) + } + if choices[1].Kind != ChoiceRegionOrder || choices[1].Where != where || strings.Join(choices[1].Alternatives, ", ") != "a1(exit), b1(exit)" { + t.Fatalf("%s: note %v, want a firing-unit choice among a1(exit), b1(exit) %s", spelling, choices[1], where) + } + return outcome, choices + } + for _, spelling := range []string{"reverse", "declared"} { + outcome, choices := under(spelling) + for _, c := range choices { + if c.Taken != 0 { + t.Fatalf("%s: %v, want declaration order at every draw", spelling, c) + } + } + if got := outcome.String(); got != want[0] { + t.Fatalf("%s: outcome %q, want %q", spelling, got, want[0]) + } + } + reached := make(map[string]string) + for _, spelling := range []string{"seed:1", "seed:8"} { + outcome, _ := under(spelling) + if !slices.Contains(want, outcome.String()) { + t.Fatalf("%s: outcome %q, want one explored", spelling, outcome) + } + if again, _ := under(spelling); again.String() != outcome.String() { + t.Fatalf("%s: outcome %q, then %q; want the seed to replay its run", spelling, outcome, again) + } + last := outcome.String()[strings.LastIndex(outcome.String(), "last = "):] + reached[last[:8]] = spelling + } + if len(reached) != 2 { + t.Fatalf("seeds reached only %v, want both effect orders", reached) + } +} + +// Executors due at one instant are a due-order choice the exploration +// enumerates: three machines waking at t=5 come to their 6 orders, each +// witness naming the choice by kind, instant, alternatives and the one taken. +func TestExploreDueOrder(t *testing.T) { + file := parseAndBuild(t, ` + package test { + private import SI::*; + private import ScalarValues::*; + part def Cell { attribute mark : Integer = 0; } + part cell : Cell; + state def Ticker { + attribute seen : Integer = -1; + entry; then waiting; + state waiting; + accept after 5 [s] then took; + state took { + entry action take { assign seen := cell.mark; assign cell.mark := cell.mark + 1; } + } + } + state a : Ticker; + state b : Ticker; + state c : Ticker; + } + `) + idx, model, _ := buildRuntimeWithLibraries(t, "", file) + root := idx.DocumentRoot("") + resolver := resolve.New(idx) + names := []string{"a", "b", "c"} + syms := make([]*symbols.Symbol, len(names)) + for i, name := range names { + syms[i] = namedOrFoundSymbol(t, idx, "test::"+name, root, ast.DefState, ast.UsageState) + } + fresh := func() (*Context, error) { return NewContext(typedModel(model, resolver), 10000), nil } + run := func(ctx *Context) (Outcome, error) { + execs := make([]*StateExecutor, len(syms)) + for i, sym := range syms { + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return Outcome{}, err + } + execs[i] = exec + } + if _, err := ctx.Advance(5); err != nil { + return Outcome{}, err + } + outputs := make(map[string]Value, len(execs)) + for i, exec := range execs { + outputs[names[i]] = exec.StateData()["seen"] + } + return ctx.ActionOutcome(outputs), nil + } + x, err := Explore(context.Background(), mustPolicy(t, "explore"), fresh, run) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 6 { + t.Fatalf("status %q, want complete after the 6 orders of three machines", x.Status()) + } + want := []string{ + "a = 0; b = 1; c = 2", "a = 0; b = 2; c = 1", "a = 1; b = 0; c = 2", + "a = 1; b = 2; c = 0", "a = 2; b = 0; c = 1", "a = 2; b = 1; c = 0", + } + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } + for _, o := range x.Outcomes { + if o.Linearizations != 1 { + t.Errorf("%s reached by %d linearizations, want 1", o.Outcome, o.Linearizations) + } + w := o.Witness + if len(w) != 2 || w[0].Kind != ChoiceDueOrder || w[0].Alternatives != 3 || w[1].Kind != ChoiceDueOrder || w[1].Alternatives != 2 { + t.Errorf("%s witness %v, want a due-order choice among 3 then one among the 2 left", o.Outcome, w) + } + } + const wantWitness = "t=5.0: state machine c first of state machine a, state machine b, state machine c; t=5.0: state machine b first of state machine a, state machine b" + if got := FormatChoices(x.Outcomes[5].Witness); got != wantWitness { + t.Fatalf("witness of a = 2; b = 1; c = 0:\n%s\nwant\n%s", got, wantWitness) + } + for _, spelling := range []string{"reverse", "declared", "seed:1"} { + ctx, _ := fresh() + if err := ctx.SetSchedule(mustPolicy(t, spelling)); err != nil { + t.Fatal(err) + } + outcome, err := run(ctx) + if err != nil { + t.Fatalf("%s: %v", spelling, err) + } + if !slices.Contains(want, outcome.String()) { + t.Errorf("%s: outcome %q is not one the exploration reached", spelling, outcome.String()) + } + } +} + +// A body paused on the clock whose wait is over is a token able to act, so it is +// an alternative to a sibling parked accept due at the same instant, not work +// swept up after the sibling has acted. +func TestExplorePausedBodyDueIsAMove(t *testing.T) { + path := filepath.Join("testdata", "conformance", "action_explore_performed_and_accept_due_together.sysml") + text, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + idx, model, _ := buildRuntimeWithLibraries(t, path, parseAndBuild(t, string(text))) + resolver := resolve.New(idx) + sym := namedOrFoundSymbol(t, idx, "test::wake", idx.DocumentRoot(path), ast.DefAction, ast.UsageAction) + fresh := func() (*Context, error) { return NewContext(typedModel(model, resolver), 10000), nil } + run := func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } + x, err := Explore(context.Background(), mustPolicy(t, "explore"), fresh, run) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 6 { + t.Fatalf("status %q, want complete after the 6 interleavings of two chains of two moves", x.Status()) + } + want := []string{"x = 1", "x = 2"} + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } + for _, o := range x.Outcomes { + if o.Linearizations != 3 { + t.Errorf("%s reached by %d linearizations, want 3", o.Outcome, o.Linearizations) + } + } + const wantWitness = "step 3: 2@performed first of 2@performed, 3@direct; step 4: 2@writeOne first of 2@writeOne, 3@direct" + if got := FormatChoices(x.Outcomes[1].Witness); got != wantWitness { + t.Fatalf("witness of x = 2:\n%s\nwant\n%s", got, wantWitness) + } + // The fixed sweeps resume the paused body after the sibling has acted; the + // writes then run in the policy's order, so the one it steps last stands. + fixed := map[string]string{"reverse": "x = 1", "declared": "x = 2", "seed:1": "x = 1"} + for _, spelling := range []string{"reverse", "declared", "seed:1"} { + ctx, _ := fresh() + if err := ctx.SetSchedule(mustPolicy(t, spelling)); err != nil { + t.Fatal(err) + } + outcome, err := run(ctx) + if err != nil { + t.Fatalf("%s: %v", spelling, err) + } + if got := outcome.String(); got != fixed[spelling] { + t.Errorf("%s: outcome %q, want %s", spelling, got, fixed[spelling]) + } + } +} + +func TestExploreRejectsOtherPolicies(t *testing.T) { + m := parseExploreModel(t, threeWritersModel) + _, err := Explore(context.Background(), DefaultSchedulePolicy, m.fresh, func(*Context) (Outcome, error) { return Outcome{}, nil }) + if !errors.Is(err, ErrNotExploring) { + t.Fatalf("Explore under %s: %v, want ErrNotExploring", DefaultSchedulePolicy, err) + } + ctx, _ := m.fresh() + policy, _ := ParseSchedulePolicy("explore") + if err := ctx.SetSchedule(policy); !errors.Is(err, ErrExploreUndriven) { + t.Fatalf("SetSchedule(explore): %v, want ErrExploreUndriven", err) + } +} + +// A caller that goes away between runs ends the exploration with its error +// before the next context is built; no partial outcome set is reported. +func TestExploreStopsWhenTheCallerGoesAway(t *testing.T) { + m := parseExploreModel(t, threeWritersModel) + sym := m.action(t, "race") + stop, cancel := context.WithCancel(context.Background()) + defer cancel() + runs, built := 0, 0 + fresh := func() (*Context, error) { + built++ + return m.fresh() + } + x, err := Explore(stop, mustPolicy(t, "explore"), fresh, func(ctx *Context) (Outcome, error) { + runs++ + if runs == 2 { + cancel() + } + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + }) + if !errors.Is(err, context.Canceled) || x != nil { + t.Fatalf("Explore after cancel: %v, %v; want context.Canceled and no exploration", x, err) + } + if runs != 2 || built != 2 { + t.Fatalf("%d runs of %d contexts, want 2 of 2: no third context built", runs, built) + } +} + +func TestParseExplorePolicy(t *testing.T) { + for _, spelling := range []string{"explore", "explore:runs=10", "explore:depth=20", "explore:runs=10,depth=20", "explore:depth=20,runs=10"} { + policy, err := ParseSchedulePolicy(spelling) + if err != nil { + t.Errorf("%s: %v", spelling, err) + continue + } + budget, ok := policy.Exploration() + if !ok { + t.Errorf("%s does not explore", spelling) + } + if strings.Contains(spelling, "runs=10") && budget.Runs != 10 { + t.Errorf("%s: runs %d", spelling, budget.Runs) + } + if strings.Contains(spelling, "depth=20") && budget.Depth != 20 { + t.Errorf("%s: depth %d", spelling, budget.Depth) + } + if !strings.Contains(spelling, "runs=") && budget.Runs != DefaultExploreBudget.Runs { + t.Errorf("%s: runs %d, want the default %d", spelling, budget.Runs, DefaultExploreBudget.Runs) + } + } + for _, spelling := range []string{"explore:", "explore:runs", "explore:runs=", "explore:runs=x", "explore:runs=-1", "explore:runs=0x10", "explore:width=3", "explore:runs=1,runs=2", "explore:runs=1,", "explore:1"} { + _, err := ParseSchedulePolicy(spelling) + var typed *SchedulePolicyError + if !errors.Is(err, ErrInvalidSchedulePolicy) || !errors.As(err, &typed) { + t.Errorf("%s: %v, want a SchedulePolicyError", spelling, err) + } + } +} + +// Every run gets a context of its own, so identities, messages and notes made +// by one run are not seen by the next. +func TestExploreRunsShareNoState(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + part def Rover; + part def Fleet { + part scout : Rover; + ref part lead : Rover; + attribute pings : Integer = 0; + exhibit state run { + entry; then idle; + state idle; + state a { entry action seta { assign lead := scout; assign pings := pings + 1; } } + state b { entry action setb { assign lead := scout; assign pings := pings + 1; } } + transition idle_a first idle accept go then a; + transition idle_b first idle accept go then b; + transition a_done first a accept ping then done; + transition b_done first b accept ping then done; + state done; + } + } + }`) + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + fleet := oneSymbol(t, m.idx, "test::Fleet") + stateSym := oneSymbol(t, m.idx, "test::Fleet::run") + var contexts int + x, err := Explore(context.Background(), policy, func() (*Context, error) { + contexts++ + return m.fresh() + }, func(ctx *Context) (Outcome, error) { + self, err := ctx.Instantiate(fleet) + if err != nil { + return Outcome{}, err + } + exec, err := newStateExecutor(ctx, stateSym, self) + if err != nil { + return Outcome{}, err + } + if err := exec.initialize(); err != nil { + return Outcome{}, err + } + exec.SendSignal("go", nil) + exec.SendSignal("ping", nil) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return fleetOutcome(ctx, self, "lead", "pings") + }) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 2 || contexts != 2 { + t.Fatalf("status %q with %d contexts, want complete (2 runs) in 2 contexts", x.Status(), contexts) + } + if got := outcomeTexts(x); len(got) != 1 || x.Outcomes[0].Linearizations != 2 { + t.Fatalf("outcomes %v, want one reached by both orders", got) + } + if got := x.Outcomes[0].Outcome.String(); got != "lead = test::Rover#1{}; pings = 1" { + t.Fatalf("outcome %q, want lead bound to the scout and pings = 1", got) + } +} + +// fleetOutcome is the outcome of a run on a Fleet: the values self holds under names. +func fleetOutcome(ctx *Context, self *Instance, names ...string) (Outcome, error) { + outputs := make(map[string]Value, len(names)) + for _, name := range names { + fv, err := self.GetFeatureValue(ctx, name) + if err != nil { + return Outcome{}, err + } + outputs[name] = fv.Value + } + return ctx.ActionOutcome(outputs), nil +} + +// fleetModel is a Fleet whose machine takes one of two transitions on `go`, each +// binding the fleet's lead and backup to its vehicles in the order it states. +func fleetModel(t *testing.T, transitions string) *exploreModel { + t.Helper() + return parseExploreModel(t, `package test { + private import ScalarValues::*; + part def Vehicle; + part def Scout :> Vehicle { attribute id : Integer = 1; } + part def Rover :> Vehicle { attribute id : Integer = 2; } + part def Fleet { + part scout : Scout; + part rover : Rover; + ref part lead : Vehicle; + ref part backup : Vehicle; + attribute picks : Integer = 0; + exhibit state run { + entry; then idle; + state idle; + state done; + `+transitions+` + } + } + }`) +} + +// exploreFleet drives the fleet's machine through `go` once per linearization, +// reporting the id lead held in each run. +func exploreFleet(t *testing.T, m *exploreModel) (*Exploration, []int64) { + t.Helper() + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + fleet := oneSymbol(t, m.idx, "test::Fleet") + stateSym := oneSymbol(t, m.idx, "test::Fleet::run") + var leadIDs []int64 + x, err := Explore(context.Background(), policy, m.fresh, func(ctx *Context) (Outcome, error) { + self, err := ctx.Instantiate(fleet) + if err != nil { + return Outcome{}, err + } + exec, err := newStateExecutor(ctx, stateSym, self) + if err != nil { + return Outcome{}, err + } + if err := exec.initialize(); err != nil { + return Outcome{}, err + } + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + outcome, err := fleetOutcome(ctx, self, "lead", "backup", "picks") + if err == nil { + leadIDs = append(leadIDs, outcome.Outputs["lead"].Instance) + } + return outcome, err + }) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 2 { + t.Fatalf("status %q, want complete (2 runs)", x.Status()) + } + return x, leadIDs +} + +// Two runs binding lead to objects of two types are two outcomes even though each +// run gives its object the same id, which is not the outcome's to compare. +func TestExploreTellsObjectsApartByWhatTheyAre(t *testing.T) { + m := fleetModel(t, ` + transition idle_scout first idle accept go do { assign lead := scout; assign backup := scout; assign picks := picks + 1; } then done; + transition idle_rover first idle accept go do { assign lead := rover; assign backup := scout; assign picks := picks + 1; } then done;`) + x, leadIDs := exploreFleet(t, m) + if leadIDs[0] != leadIDs[1] { + t.Fatalf("lead ids %v, want the same id in both runs (each run's first object)", leadIDs) + } + want := []string{ + "backup = test::Scout#1{id = 1}; lead = #1; picks = 1", + "backup = test::Scout#1{id = 1}; lead = test::Rover#2{id = 2}; picks = 1", + } + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } + for _, o := range x.Outcomes { + if o.Linearizations != 1 { + t.Errorf("%s reached by %d linearizations, want 1", o.Outcome, o.Linearizations) + } + } +} + +// Two runs binding lead and backup to the same two objects in either order give +// those objects different ids, which does not make them two outcomes. +func TestExploreEquatesObjectsByWhatTheyAre(t *testing.T) { + m := fleetModel(t, ` + transition lead_first first idle accept go do { assign lead := scout; assign backup := rover; assign picks := picks + 1; } then done; + transition backup_first first idle accept go do { assign backup := rover; assign lead := scout; assign picks := picks + 1; } then done;`) + x, leadIDs := exploreFleet(t, m) + if leadIDs[0] == leadIDs[1] { + t.Fatalf("lead ids %v, want different ids (the scout is the first object of one run and the second of the other)", leadIDs) + } + want := []string{"backup = test::Rover#1{id = 2}; lead = test::Scout#2{id = 1}; picks = 1"} + if got := outcomeTexts(x); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("outcomes %v, want %v", got, want) + } + if x.Outcomes[0].Linearizations != 2 { + t.Fatalf("reached by %d linearizations, want 2", x.Outcomes[0].Linearizations) + } +} + +// A name spelling the rendering's delimiters can make two outcomes render alike; +// their identities still tell them apart. +func TestOutcomeIdentityQuotesNames(t *testing.T) { + one := Outcome{Outputs: map[string]Value{`a = "1"; b`: NewStringValue("2")}} + two := Outcome{Outputs: map[string]Value{"a": NewStringValue("1"), "b": NewStringValue("2")}} + if one.String() != two.String() { + t.Fatalf("renderings %q and %q, want alike", one, two) + } + if one.identity() == two.identity() { + t.Fatalf("identity %q shared, want two", one.identity()) + } + same := Outcome{Outputs: map[string]Value{"b": NewStringValue("2"), "a": NewStringValue("1")}} + if same.identity() != two.identity() { + t.Fatalf("identities %q and %q, want one", same.identity(), two.identity()) + } +} + +// Two typed regions' states share a name, so the region-order choice spells each +// alternative with the region it sits in. +func TestExploreSiblingRegionOrderNamesTypedRegions(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Region { entry; then r1; state r1; state r2; transition first r1 accept go then r2; } + state def Machine { + entry; then work; + state work parallel { + state a : Region; + state b : Region; + } + } + }`) + sym := m.state(t, "Machine") + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + x, err := Explore(context.Background(), policy, m.fresh, func(ctx *Context) (Outcome, error) { + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + return Outcome{}, err + } + if err := exec.initialize(); err != nil { + return Outcome{}, err + } + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + }) + if err != nil { + t.Fatal(err) + } + if !x.Complete() || x.Runs != 4 || len(x.Outcomes) != 1 || x.Outcomes[0].Linearizations != 4 { + t.Fatalf("status %q with %d outcomes, want complete (4 runs) reaching one outcome", x.Status(), len(x.Outcomes)) + } + want := "entering work: a.r1(entry) first of a.r1(entry), b.r1(entry); on accept go: a.r1(exit) first of a.r1(exit), b.r1(exit)" + if got := FormatChoices(x.Outcomes[0].Witness); got != want { + t.Fatalf("witness %q, want the regions naming the alternatives:\n%s", got, want) + } +} + +// Two active regions of one name are spelled in declaration order, so the final +// state is the same on every run. +func TestFinalStateNameOrdersRegionsOfOneNameByDeclaration(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + entry; then work; + state work parallel { + state left { + entry; then inner; + state inner parallel { + state r { entry; then l1; state l1; } + state s { entry; then l2; state l2; } + } + } + state right { + entry; then inner; + state inner parallel { + state r { entry; then r1; state r1; } + state s { entry; then r2; state r2; } + } + } + } + } + }`) + sym := m.state(t, "Machine") + for run := 0; run < 20; run++ { + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + t.Fatal(err) + } + if err := exec.initialize(); err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + if got := exec.FinalStateName(); got != "inner+l1+r1+inner+l2+r2" { + t.Fatalf("run %d: final state %q, want inner+l1+r1+inner+l2+r2", run, got) + } + } +} + +// linkedNodes instantiates n nodes of one type chained by `next`, the last +// pointing back at the first, and returns the head as an outcome. +func linkedNodes(t *testing.T, m *exploreModel, ids ...int64) Outcome { + t.Helper() + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + node := oneSymbol(t, m.idx, "test::Node") + nodes := make([]*Instance, len(ids)) + for i, id := range ids { + if nodes[i], err = ctx.Instantiate(node); err != nil { + t.Fatal(err) + } + value := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: id}} + if err := nodes[i].SetFeatureValue(ctx, "id", value); err != nil { + t.Fatal(err) + } + } + for i, inst := range nodes { + next := nodes[(i+1)%len(nodes)] + if err := inst.SetFeatureValue(ctx, "next", Value{Kind: ValInstance, Instance: next.ID}); err != nil { + t.Fatal(err) + } + } + return ctx.ActionOutcome(map[string]Value{"head": {Kind: ValInstance, Instance: nodes[0].ID}}) +} + +// A cycle of objects is spelled once around, the object closing it named by its +// number; nodes of one type nested past the rendering's depth still count toward +// the identity, so two rings rendered alike are two outcomes when they differ. +func TestOutcomeIdentityOpensEveryObject(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + part def Node { attribute id : Integer = 0; ref part next : Node; } + }`) + ring := linkedNodes(t, m, 1, 2) + if got, want := ring.String(), "head = test::Node#1{id = 1, next = test::Node#2{id = 2, next = #1}}"; got != want { + t.Fatalf("ring %q, want %q", got, want) + } + if got, want := ring.identity(), `finalState ""; "head" = "test::Node"#1{"id" = 1, "next" = "test::Node"#2{"id" = 2, "next" = #1}}`; got != want { + t.Fatalf("ring identity %q, want %q", got, want) + } + if again := linkedNodes(t, m, 1, 2); again.identity() != ring.identity() { + t.Fatalf("identities %q and %q, want one", again.identity(), ring.identity()) + } + if loop := linkedNodes(t, m, 1, 1); loop.identity() == ring.identity() { + t.Fatalf("a self-loop and a ring share identity %q", loop.identity()) + } + long := linkedNodes(t, m, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + other := linkedNodes(t, m, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11) + if !strings.HasSuffix(long.String(), "{…}}}}}}}}}") || long.String() != other.String() { + t.Fatalf("renderings %q and %q, want alike and cut at depth", long, other) + } + if long.identity() == other.identity() { + t.Fatalf("identity %q shared by two rings differing past the rendering's depth", long.identity()) + } +} + +// An outcome built without a context spells an object by the id its run gave it. +func TestOutcomeWithoutContextKeepsObjectIDs(t *testing.T) { + o := Outcome{Outputs: map[string]Value{"lead": {Kind: ValInstance, Instance: 7}}} + if got := o.String(); got != "lead = instance(7)" { + t.Fatalf("outcome %q, want lead = instance(7)", got) + } +} + +// mustSchedule sets a policy a context can run under, failing the test otherwise. +func mustSchedule(t *testing.T, ctx *Context, policy SchedulePolicy) { + t.Helper() + if err := ctx.SetSchedule(policy); err != nil { + t.Fatalf("schedule %s: %v", policy, err) + } +} diff --git a/internal/core/runtime/exponentiation_test.go b/internal/exec/runtime/exponentiation_test.go similarity index 92% rename from internal/core/runtime/exponentiation_test.go rename to internal/exec/runtime/exponentiation_test.go index 709f8bb5fd..e45241e9a0 100644 --- a/internal/core/runtime/exponentiation_test.go +++ b/internal/exec/runtime/exponentiation_test.go @@ -5,9 +5,9 @@ import ( "math" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // powerModel is a calc over parameters, so its `**` cannot be folded and is @@ -44,7 +44,7 @@ func TestExponentiationRuntimeMatchesFolding(t *testing.T) { } model, resolver, root := parseAndBuildModel(t, powerModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) power := resolveSymbol(t, root, "power") for _, tc := range cases { @@ -85,7 +85,7 @@ func TestExponentiationErrorsAtEvaluation(t *testing.T) { } model, resolver, root := parseAndBuildModel(t, powerModel) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) power := resolveSymbol(t, root, "power") for _, tc := range cases { diff --git a/internal/exec/runtime/expression_parser_test.go b/internal/exec/runtime/expression_parser_test.go new file mode 100644 index 0000000000..a005373f89 --- /dev/null +++ b/internal/exec/runtime/expression_parser_test.go @@ -0,0 +1,99 @@ +package runtime + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" +) + +// parsingModel is typedModel with the notation's parser installed as well, so a run +// reads witness inputs and tool units the way every product frontend does. +func parsingModel(sem *semantics.Model, resolver *resolve.Resolver) *Model { + m := typedModel(sem, resolver) + m.SetExpressionParser(parser.ParseOneExpression) + return m +} + +// A model given no parser refuses, with the typed error, the two places a run reads +// notation text: a witness input read from a file and the unit a tool answers in. A +// witness made in memory carries values, not text, so it needs no parser. +func TestRuntimeWithoutExpressionParserRefusesText(t *testing.T) { + t.Run("witness input from a file", func(t *testing.T) { + m := parseExploreModel(t, inputModel) + sym := m.action(t, "gate") + file := filepath.Join(t.TempDir(), "witness.txt") + if err := os.WriteFile(file, []byte("input n = 5\nno choice points\n"), 0o600); err != nil { + t.Fatal(err) + } + ctx := NewContext(NewModel(m.model, m.resolver), 10000) + mustSchedule(t, ctx, mustPolicy(t, "replay:"+file)) + _, err := ctx.ExecuteAction(sym) + if !errors.Is(err, ErrNoExpressionParser) { + t.Fatalf("ExecuteAction = %v, want ErrNoExpressionParser", err) + } + if errors.Is(err, ErrWitnessInput) { + t.Fatalf("ExecuteAction = %v, must not read as a refused witness input", err) + } + }) + t.Run("witness input in memory", func(t *testing.T) { + m := parseExploreModel(t, inputModel) + sym := m.action(t, "gate") + fast := m.idx.LookupQualified("test::Mode::Fast") + if len(fast) != 1 { + t.Fatalf("Mode::Fast indexed %d times", len(fast)) + } + ctx := NewContext(NewModel(m.model, m.resolver), 10000) + mustSchedule(t, ctx, ReplayOf(Witness{Inputs: []InputTaken{ + InputOf("n", intOf(5)), InputOf("mode", NewEnumLiteral(fast[0]))}})) + out, err := ctx.ExecuteAction(sym) + if err != nil { + t.Fatal(err) + } + if FormatValue(out["over"]) != "true" { + t.Errorf("held %v", out) + } + }) + t.Run("tool unit", func(t *testing.T) { + idx, sem, parsing := buildRuntimeWithLibraries(t, "", parseAndBuild(t, toolModel)) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + ctx := NewContext(NewModel(sem, parsing.Model().Resolver()), 10000) + ctx.SetToolRunner(&recordingRunner{answer: map[string]ToolValue{ + "a": {Value: toolReal(2), Unit: "m/s**2"}, + "v": {Value: toolReal(43.2), Unit: "km/h"}, + "x": {Value: toolReal(11000), Unit: "cm"}, + }}) + _, err := ctx.ExecuteAction(calcNamed(t, pkg.Scope, "Drive")) + if !errors.Is(err, ErrNoExpressionParser) { + t.Fatalf("ExecuteAction = %v, want ErrNoExpressionParser", err) + } + var toolErr *ToolError + if errors.As(err, &toolErr) { + t.Fatalf("ExecuteAction = %v, must not read as the tool's malformed output", err) + } + if _, err := ctx.UnitOf(pkg.Scope, "m/s**2"); !errors.Is(err, ErrNoExpressionParser) { + t.Fatalf("UnitOf = %v, want ErrNoExpressionParser", err) + } + }) + t.Run("replaced parser forgets the units the previous one read", func(t *testing.T) { + idx, _, parsing := buildRuntimeWithLibraries(t, "", parseAndBuild(t, toolModel)) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + if _, err := parsing.UnitOf(pkg.Scope, "m/s**2"); err != nil { + t.Fatalf("UnitOf with the parser installed: %v", err) + } + parsing.Model().SetExpressionParser(nil) + if _, err := parsing.UnitOf(pkg.Scope, "m/s**2"); !errors.Is(err, ErrNoExpressionParser) { + t.Fatalf("UnitOf after removing the parser = %v, want ErrNoExpressionParser", err) + } + }) +} diff --git a/internal/exec/runtime/extent.go b/internal/exec/runtime/extent.go new file mode 100644 index 0000000000..c355fe7a98 --- /dev/null +++ b/internal/exec/runtime/extent.go @@ -0,0 +1,536 @@ +package runtime + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// evalExtent evaluates `all T` (KerML 1.0 §7.4.9.2, BaseFunctions::'all'): the instances of T +// this run enumerates, in declaration order; objects materialize lazily, so the extent is the run's. +func (ec *EvalContext) evalExtent(n *ast.OperatorExpr) (Value, error) { + qn := semantics.ExtentTypeName(n) + if qn == nil { + return Value{}, fmt.Errorf("%w: 'all' requires the name of a type", ErrTypeMismatch) + } + sem := ec.ctx.model.semantics + target, ok := ec.ctx.extentOperand(ec.scope, qn) + if !ok { + return Value{}, fmt.Errorf("%w: %s", ErrUnresolvedType, qualifiedNameToString(qn)) + } + if !semantics.IsType(target) { + return Value{}, fmt.Errorf("%w: 'all' requires a type, %s is a %s", + ErrTypeMismatch, qualifiedNameToString(qn), target.Notation()) + } + switch { + case target.Kind == symbols.SymbolEnumerationDef: + return ec.literalValues(sem.LiteralsOf(target)) + case sem.IsVariationFeature(target): + return ec.variantValues(target, sem.VariantsOf(target)) + case sem.IsDataType(target): + // A data value is not created by a run, so what a run's attributes hold is not the extent. + return Value{}, fmt.Errorf("%w: %s is a data type, whose values are not enumerated (only an enumeration's literals are)", + ErrUnboundedExtent, qualifiedNameToString(qn)) + } + // The extent is the objects there are, alive: what derives it reads the lives. + ec.ctx.readsLives() + roots, err := ec.extentRoots(target) + if err != nil { + return Value{}, err + } + return ec.ctx.objectsOf(roots, target) +} + +// extentOperand is what `all T` names, as semantics.ExtentOperand finds it: T through an alias to its +// target, recorded by what the name denoted and what the type declares for the binding being made. +func (ctx *Context) extentOperand(scope *symbols.Scope, qn *ast.QualifiedName) (*symbols.Symbol, bool) { + sym, ok := ctx.resolveQualified(scope, qn) + if !ok || sym == nil { + return nil, false + } + if alias, ok := ctx.resolveAliasTarget(sym); ok && alias != nil { + sym = alias + } + ctx.noteDeclarationRead(sym) + return sym, true +} + +// literalValues is the sequence of the values an enumeration's literals stand for. +func (ec *EvalContext) literalValues(literals []*symbols.Symbol) (Value, error) { + values := make([]Value, 0, len(literals)) + for _, literal := range literals { + val, err := ec.enumLiteralValue(literal) + if err != nil { + return Value{}, err + } + values = append(values, val) + } + return ec.newSequence(values) +} + +// variantValues is what a variation's variants stand for: the value each declares, or an +// object of it, materialized once for the object evaluating, as a selection is. +func (ec *EvalContext) variantValues(variation *symbols.Symbol, variants []*symbols.Symbol) (Value, error) { + owner := int64(0) + if ec.self != nil { + owner = ec.self.ID + } + values := make([]Value, 0, len(variants)) + for _, variant := range variants { + val, err := ec.ctx.variantValue(variation, variant, owner) + if err != nil { + return Value{}, err + } + values = append(values, val) + } + return ec.newSequence(values) +} + +// objectsOf is this run's objects of target under roots, each then its features in declaration +// order: a feature that may hold one is read (a failing read ends the extent) unless it would create +// an object of a declaration already on the path; what a feature holds is walked regardless. +func (ctx *Context) objectsOf(roots []*Instance, target *symbols.Symbol) (Value, error) { + var values []Value + seen := make(map[int64]bool) + path := make(map[*symbols.Symbol]int) + through := func(inst *Instance, of ObjectFeature) (*FeatureValue, error) { + if !ctx.mayHold(of.Feature.Symbol, target, make(map[*symbols.Symbol]bool)) { + return nil, nil + } + // A value whose every possible type is on the path is not read; any other read tells by what it made. + types := ctx.createdTypes(of.Feature) + recursive := len(types) > 0 + for _, typ := range types { + if path[typ] == 0 { + recursive = false + } + } + if recursive { + return nil, nil + } + return ctx.readUnlessRecursive(inst, of.Name, target, path) + } + var descend func(inst *Instance) error + descend = func(inst *Instance) error { + if inst == nil || seen[inst.ID] { + return nil + } + seen[inst.ID] = true + // A destroyed object left the extent with its portions; what it referred to is reached from where it is held. + if ctx.checkNotDestroyed(inst) != nil { + return nil + } + if ctx.isOf(inst, target) { + val, err := ctx.objectValue(inst) + if err != nil { + return err + } + values = append(values, val) + } + declared := ctx.declarationsOf(inst) + for _, decl := range declared { + path[decl]++ + } + defer func() { + for _, decl := range declared { + path[decl]-- + } + }() + children, err := ctx.heldObjectsOf(inst, through, true) + if err != nil { + return fmt.Errorf("object of %s: %w", symbolText(inst.Type), err) + } + for _, child := range children { + if err := descend(child.instance); err != nil { + return err + } + } + return nil + } + for _, root := range roots { + if err := descend(root); err != nil { + return Value{}, err + } + } + return ctx.newSequence(values) +} + +// readUnlessRecursive reads a feature of inst whose objects only the read reveals: kept when none +// it created is of a declaration on the path, else undone and nil as if unread. A failing read is +// reported, as is a value making an object on the path together with one that may lead to target. +func (ctx *Context) readUnlessRecursive(inst *Instance, name string, target *symbols.Symbol, path map[*symbols.Symbol]int) (*FeatureValue, error) { + commit, rollback := ctx.beginJournal() + mark := len(ctx.created) + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + rollback() + return nil, err + } + held := make(map[int64]bool) + for _, id := range heldObjects(fv.HeldValue()) { + held[id] = true + } + var recursive, reached *Instance + for _, id := range ctx.created[mark:] { + made, live := ctx.instances[id] + if !live { + continue + } + if ctx.onPath(made, path) != nil { + recursive = made + } else if held[id] && ctx.mayReach(made, target) { + reached = made + } + } + if recursive == nil { + commit() + return fv, nil + } + rollback() + if reached != nil { + return nil, fmt.Errorf("%w: the value of %s makes an object of %s, already on the path, together with one of %s, which the extent cannot reach without it", + ErrExtentUnavailable, name, symbolText(ctx.onPath(recursive, path)), symbolText(reached.Type)) + } + return nil, nil +} + +// mayReach reports whether inst, or an object a feature of it holds however deep, may be of target. +func (ctx *Context) mayReach(inst *Instance, target *symbols.Symbol) bool { + if ctx.isOf(inst, target) { + return true + } + for _, of := range ctx.FeaturesOfObject(inst) { + if of.Name != "" && holdsObjects(of.Feature) && ctx.mayHold(of.Feature.Symbol, target, make(map[*symbols.Symbol]bool)) { + return true + } + } + return false +} + +// onPath is the declaration on the path inst is of, if any. +func (ctx *Context) onPath(inst *Instance, path map[*symbols.Symbol]int) *symbols.Symbol { + for _, decl := range ctx.declarationsOf(inst) { + if path[decl] > 0 { + return decl + } + } + return nil +} + +// declarationsOf is the declarations an object is of: the types it was created as and since +// classified by, and the types each usage among them is written with. +func (ctx *Context) declarationsOf(inst *Instance) []*symbols.Symbol { + var out []*symbols.Symbol + seen := make(map[*symbols.Symbol]bool) + var add func(sym *symbols.Symbol) + add = func(sym *symbols.Symbol) { + if sym == nil || seen[sym] { + return + } + seen[sym] = true + out = append(out, sym) + if sym.IsFeature() { + for _, typ := range ctx.model.semantics.DeclaredFeatureTypes(sym) { + add(typ) + } + } + } + for _, typ := range inst.types() { + add(typ) + } + return out +} + +// extentRoots is the objects an extent is searched from, in declaration order: the run's free-standing +// objects, the evaluating object's outermost holder, and what the model's namespace usages denote. +func (ec *EvalContext) extentRoots(target *symbols.Symbol) ([]*Instance, error) { + ctx := ec.ctx + var roots []*Instance + declared := make(map[int64]*symbols.Symbol) + add := func(inst *Instance, decl *symbols.Symbol) { + if inst != nil && declared[inst.ID] == nil { + declared[inst.ID] = decl + roots = append(roots, inst) + } + } + for top := ec.self; top != nil; top = top.owner { + if top.owner == nil { + add(top, top.Type) + } + } + for _, sym := range ctx.extentCandidates(target) { + if namespaceObjectUsage(sym) { + if ctx.binding(sym) { + continue + } + bound, err := ec.boundObjects(sym) + if err != nil { + return nil, err + } + for _, inst := range bound { + add(inst, sym) + } + continue + } + denoted, err := ctx.denotedObjects(sym) + if err != nil { + return nil, err + } + for _, inst := range denoted { + add(inst, sym) + } + } + held := ctx.heldObjectIDs() + for _, id := range ctx.created { + inst, live := ctx.instances[id] + if !live || held[id] || (nestedFeature(inst.Type) && ctx.readThrough(inst)) { + continue + } + add(inst, inst.Type) + } + sort.SliceStable(roots, func(i, j int) bool { + return declaredBefore(declared[roots[i].ID], declared[roots[j].ID]) + }) + return roots, nil +} + +// boundObjects is the objects a namespace-level usage's value binds it to, read once for the run; +// a value depending on a usage still being bound yields none yet. +func (ec *EvalContext) boundObjects(sym *symbols.Symbol) ([]*Instance, error) { + val, err := NewEvalContext(ec.ctx, sym.OwnerScope).declaredValue(sym, sym.Decl.(*ast.Usage).Value) + if err != nil { + var cycle *CyclicBindingError + if errors.As(err, &cycle) && ec.ctx.binding(cycle.Usage) { + return nil, nil + } + return nil, fmt.Errorf("usage %s: %w", symbolText(sym), err) + } + var out []*Instance + for _, id := range heldObjects(val) { + if inst, live := ec.ctx.instances[id]; live { + out = append(out, inst) + } + } + return out, nil +} + +// undenotedUsage refuses an extent for a namespace usage the run denotes no object of: one +// of a count the model does not fix, or a port, an interaction point of an object the +// namespace has none of. +func (ctx *Context) undenotedUsage(sym *symbols.Symbol) error { + if mult := ctx.featureMultiplicity(sym, ctx.findOwnerType(sym)); !mult.Lower.Known || !mult.Upper.Known { + return fmt.Errorf("%w: usage %s declares %s occurrences, a count the model does not fix, which the run denotes no object of", + ErrExtentUnavailable, symbolText(sym), mult.Text()) + } + return fmt.Errorf("%w: usage %s is a %s at namespace level, which the run denotes no object of", + ErrExtentUnavailable, symbolText(sym), sym.Notation()) +} + +// usageCensus is the object usages the namespaces of every document of the model declare, in +// document-name then declaration order, and the digest a binding that walked them is carried by. +type usageCensus struct { + usages []*symbols.Symbol + digest string +} + +// modelUsages is the model's usage census, taken once per Model, each usage as the symbol a +// registered scope tree declares for it; variations and optional valueless usages are not listed. +func (ctx *Context) modelUsages() *usageCensus { + if ctx.model.census != nil { + return ctx.model.census + } + census := &usageCensus{} + var walk func(scope *symbols.Scope) + walk = func(scope *symbols.Scope) { + scope.ForEachMember(func(sym *symbols.Symbol) bool { + if sym.Scope != nil && sym.Scope != scope && namespaceScope(sym.Scope) { + walk(sym.Scope) + return true + } + sym = ctx.declaredSymbol(sym) + if ctx.model.semantics.IsVariationFeature(sym) || ctx.optionalValueless(sym) { + return true + } + if objectFeature(sym) || ctx.namesOneObject(sym) { + census.usages = append(census.usages, sym) + } + return true + }) + } + if ctx.model.resolver != nil { + if idx := ctx.model.resolver.Index(); idx != nil { + for _, doc := range idx.Documents() { + walk(idx.DocumentRoot(doc)) + } + } + } + sort.SliceStable(census.usages, func(i, j int) bool { + return declaredBefore(census.usages[i], census.usages[j]) + }) + var b strings.Builder + for _, sym := range census.usages { + fmt.Fprintf(&b, "%s/%s;", ctx.fqnOf(sym), sym.Kind) + } + census.digest = b.String() + ctx.model.census = census + return census +} + +// extentCandidates is the census usages that may hold target, judged once per run. A binding +// taking the extent reads the census, the types judged and the candidates' declarations. +func (ctx *Context) extentCandidates(target *symbols.Symbol) []*symbols.Symbol { + census := ctx.modelUsages() + ctx.noteCensusRead(census) + found, ok := ctx.run.extentCandidates[target] + if ok { + for _, typ := range found.judged { + ctx.noteTypeRead(typ) + } + } else { + found = &extentCandidates{} + judged := make(map[*symbols.Symbol]bool) + for _, sym := range census.usages { + visited := make(map[*symbols.Symbol]bool) + if ctx.mayHold(sym, target, visited) { + found.usages = append(found.usages, sym) + } + for typ := range visited { + if !judged[typ] { + judged[typ] = true + found.judged = append(found.judged, typ) + } + } + } + found.judged = append(found.judged, target) + if ctx.run.extentCandidates == nil { + ctx.run.extentCandidates = map[*symbols.Symbol]*extentCandidates{} + } + ctx.run.extentCandidates[target] = found + } + for _, sym := range found.usages { + ctx.noteDeclarationRead(sym) + } + return found.usages +} + +// extentCandidates is what one run found the extent of a type may be rooted in. +type extentCandidates struct { + usages []*symbols.Symbol + judged []*symbols.Symbol +} + +// namespaceObjectUsage reports whether sym is an object-holding usage (ports included) a +// namespace declares with a value: the value binds it to the objects it stands for. +func namespaceObjectUsage(sym *symbols.Symbol) bool { + if !objectFeature(sym) || sym.OwnerScope == nil || !namespaceScope(sym.OwnerScope) { + return false + } + return sym.Decl.(*ast.Usage).Value != nil +} + +// namespaceScope reports whether scope is a package, a namespace or a document root, rather +// than the body of a type or a body's locals. +func namespaceScope(scope *symbols.Scope) bool { + if scope.BodyLocal() { + return false + } + if scope.Owner() == nil { + return scope.Parent() == nil + } + switch scope.Owner().Decl.(type) { + case *ast.Package, *ast.Namespace: + return true + } + return false +} + +// givenValue is the value an object usage is given, whose objects are what it yields. +func (ctx *Context) givenValue(sym *symbols.Symbol) (ast.Node, bool) { + if !objectFeature(sym) { + return nil, false + } + value := sym.Decl.(*ast.Usage).Value + return value, value != nil +} + +// createdTypes is the declarations the objects an unread feature comes to hold would be of: the +// composite it materializes, or the types its value results in (its declared type, where unknown). +func (ctx *Context) createdTypes(feature *EffectiveFeature) []*symbols.Symbol { + ctx.noteTypeRead(feature.Symbol) + if composite := ctx.CompositeTypeOf(feature); composite != nil { + return []*symbols.Symbol{composite} + } + value, valued := ctx.givenValue(feature.Symbol) + if !valued { + return nil + } + if types := ctx.model.semantics.ExprResultTypes(feature.Symbol.OwnerScope, value); len(types) > 0 { + return types + } + if declared := ctx.extractType(feature.Symbol); declared != nil { + return []*symbols.Symbol{declared} + } + return nil +} + +// mayHold reports whether an object of typ, or one a feature of it holds however deep, may be +// of target. Each type is descended into once, so recursive composition ends. +func (ctx *Context) mayHold(typ, target *symbols.Symbol, visited map[*symbols.Symbol]bool) bool { + if typ == nil || visited[typ] { + return false + } + visited[typ] = true + if ctx.modelConforms(typ, target) { + return true + } + if value, valued := ctx.givenValue(typ); valued { + declared := ctx.extractType(typ) + if declared != nil && ctx.modelConforms(target, declared) { + return true + } + types := ctx.model.semantics.ExprResultTypes(typ.OwnerScope, value) + if len(types) == 0 { + return true + } + for _, valueType := range types { + if ctx.modelConforms(target, valueType) || ctx.mayHold(valueType, target, visited) { + return true + } + } + if declared == nil { + return false + } + } + for _, member := range ctx.model.semantics.MembersOf(typ) { + if objectFeature(member) && ctx.mayHold(member, target, visited) { + return true + } + } + return false +} + +// isOf reports whether a type inst is of, or was classified by, conforms to target. +func (ctx *Context) isOf(inst *Instance, target *symbols.Symbol) bool { + for _, typ := range inst.types() { + if ctx.modelConforms(typ, target) { + return true + } + } + return false +} + +// declaredBefore orders declarations as their documents, then their positions in them, do. +func declaredBefore(a, b *symbols.Symbol) bool { + if a == nil || b == nil { + return a == nil && b != nil + } + if a.DocName != b.DocName { + return a.DocName < b.DocName + } + return a.DeclSpan.Offset < b.DeclSpan.Offset +} diff --git a/internal/core/runtime/extent_test.go b/internal/exec/runtime/extent_test.go similarity index 100% rename from internal/core/runtime/extent_test.go rename to internal/exec/runtime/extent_test.go diff --git a/internal/exec/runtime/f62_f63_node_body_test.go b/internal/exec/runtime/f62_f63_node_body_test.go new file mode 100644 index 0000000000..143197301f --- /dev/null +++ b/internal/exec/runtime/f62_f63_node_body_test.go @@ -0,0 +1,150 @@ +package runtime + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestF62F63NodeBodyRobustness covers the failure modes the newly accepted node +// bodies reach: a body statement that cannot run must error, never panic or hang. +func TestF62F63NodeBodyRobustness(t *testing.T) { + t.Run("control_node_body_never_terminates", testControlNodeBodyNeverTerminates) + t.Run("control_node_body_sends_without_a_message", testControlNodeBodySendsWithoutAMessage) + t.Run("send_body_declares_no_payload", testSendBodyDeclaresNoPayload) + t.Run("send_via_a_port_to_a_receiver", testSendViaAPortToAReceiver) +} + +func runActionForError(t *testing.T, src, name string) error { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), name, ast.DefAction) + if sym == nil { + t.Fatalf("action %s not found", name) + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + return err + } + return exec.RunToCompletion() +} + +// A control node body runs as control reaches the node, so a loop of it that +// never ends is bounded by the step budget instead of hanging. +func testControlNodeBodyNeverTerminates(t *testing.T) { + src := ` + package test { + action gate { + attribute n = 0; + first start; + fork split { + while n >= 0 { assign n := n + 1; } + } + done; + succession first start then split; + succession first split then done; + } + } + ` + err := runActionForError(t, src, "gate") + if err == nil { + t.Fatal("expected the step budget to bound the fork body, it completed") + } + t.Logf("error: %v", err) +} + +// A send in a control node body is the same statement as anywhere else, so one +// with no message is reported there too. +func testControlNodeBodySendsWithoutAMessage(t *testing.T) { + src := ` + package test { + action gate { + first start; + fork split { send to receiver { } } + action receiver accept n : Integer; + done; + succession first start then split; + succession first split then receiver; + succession first receiver then done; + } + } + ` + err := runActionForError(t, src, "gate") + if err == nil { + t.Fatal("expected an error, the send in the fork body declared no message") + } + if !strings.Contains(err.Error(), "send") { + t.Errorf("expected the error to name the send, got: %v", err) + } +} + +// A send with neither an argument nor a payload in its body has no message to +// send, which is reported rather than sending nothing. +func testSendBodyDeclaresNoPayload(t *testing.T) { + src := ` + package test { + action talk { + first start; + action sender { + send to receiver { } + } + action receiver accept n : Integer; + done; + succession first start then sender; + succession first sender then receiver; + succession first receiver then done; + } + } + ` + err := runActionForError(t, src, "talk") + if err == nil { + t.Fatal("expected an error, the send declared no message") + } + if !strings.Contains(err.Error(), "send") { + t.Errorf("expected the error to name the send, got: %v", err) + } +} + +// `send x via p to r` keeps both destination constraints through routing. +func testSendViaAPortToAReceiver(t *testing.T) { + src := ` + package test { + port def Pt; + action talk { + port p : Pt; + port inPort : Pt; + connect p to inPort; + attribute receiverGot : Integer = 0; + attribute siblingGot : Integer = 0; + first start; + action sender { + send 42 via p to receiver; + send 7 via p to sibling; + } + action receiver accept n : Integer via inPort { + assign receiverGot := n; + } + action sibling accept n : Integer via inPort { + assign siblingGot := n; + } + fork split; + join sync; + done; + succession first start then sender; + succession first sender then split; + succession first split then receiver; + succession first split then sibling; + succession first receiver then sync; + succession first sibling then sync; + succession first sync then done; + } + } + ` + outputs, err := executeActionSource(t, "talk", src) + if err != nil { + t.Fatalf("send routed to its named receiver: %v", err) + } + assertIntOutput(t, outputs, "receiverGot", 42) + assertIntOutput(t, outputs, "siblingGot", 7) +} diff --git a/internal/core/runtime/f64_body_declaration_test.go b/internal/exec/runtime/f64_body_declaration_test.go similarity index 100% rename from internal/core/runtime/f64_body_declaration_test.go rename to internal/exec/runtime/f64_body_declaration_test.go diff --git a/internal/core/runtime/fork_join_test.go b/internal/exec/runtime/fork_join_test.go similarity index 98% rename from internal/core/runtime/fork_join_test.go rename to internal/exec/runtime/fork_join_test.go index c26257e175..dcd9342f8c 100644 --- a/internal/core/runtime/fork_join_test.go +++ b/internal/exec/runtime/fork_join_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // executeStateSource executes the named state machine declared in src and diff --git a/internal/core/runtime/frame.go b/internal/exec/runtime/frame.go similarity index 88% rename from internal/core/runtime/frame.go rename to internal/exec/runtime/frame.go index ab1d817a7e..4e5b4b3df8 100644 --- a/internal/core/runtime/frame.go +++ b/internal/exec/runtime/frame.go @@ -1,6 +1,9 @@ package runtime -import "github.com/Open-MBEE/OpenSysML/internal/core/symbols" +import ( + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) // frame is one level of local bindings an evaluation reads: a calc invocation's // parameter slots, a map of named values, or both. @@ -25,6 +28,16 @@ type frame struct { // merged are the behaviors whose runs' bindings the frame flattened into its // own (flattenFrames), which it still answers for. merged []*symbols.Symbol + // firing is the state machine firing the frame's values are read within, so + // `T.d` answers what the taken transition's trigger bound; nil outside a machine. + firing *firing +} + +// firing is one transition being taken by a state machine, as the behaviors it +// performs read it: the transition and the values its trigger bound, by name. +type firing struct { + taken *lower.Transition + payload map[string]Value } // canonical is the name aliases bind name under: its redefinition's, else its own. @@ -99,7 +112,7 @@ func (f frame) performs() *symbols.Symbol { // withVars is the frame holding vars in place of its own, still answering for // the same run and performance. func (f frame) withVars(vars map[string]Value) frame { - return frame{vars: vars, aliases: f.aliases, perf: f.perf, owner: f.owner, performed: f.performed, run: f.run, merged: f.merged} + return frame{vars: vars, aliases: f.aliases, perf: f.perf, owner: f.owner, performed: f.performed, run: f.run, merged: f.merged, firing: f.firing} } // lookup finds name in the frame: a slot binding it, else the map. @@ -162,6 +175,22 @@ func (f frame) snapshot() frame { out.aliases[name] = alias } } + out.firing = f.firing.snapshot() + return out +} + +// snapshot copies the firing and its payload into independent storage. +func (f *firing) snapshot() *firing { + if f == nil { + return nil + } + out := &firing{taken: f.taken} + if len(f.payload) > 0 { + out.payload = make(map[string]Value, len(f.payload)) + for name, value := range f.payload { + out.payload[name] = value + } + } return out } diff --git a/internal/core/runtime/frame_functions.go b/internal/exec/runtime/frame_functions.go similarity index 98% rename from internal/core/runtime/frame_functions.go rename to internal/exec/runtime/frame_functions.go index 1575fdc7a8..bdd7d7af28 100644 --- a/internal/core/runtime/frame_functions.go +++ b/internal/exec/runtime/frame_functions.go @@ -3,9 +3,9 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // frameIndex evaluates the index of `num [ref]` when it names or composes a diff --git a/internal/core/runtime/frame_read.go b/internal/exec/runtime/frame_read.go similarity index 99% rename from internal/core/runtime/frame_read.go rename to internal/exec/runtime/frame_read.go index f21bb66c98..40ded36bc6 100644 --- a/internal/core/runtime/frame_read.go +++ b/internal/exec/runtime/frame_read.go @@ -3,8 +3,8 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // Library types a frame, scale or transformation is read against. diff --git a/internal/core/runtime/frame_transform.go b/internal/exec/runtime/frame_transform.go similarity index 99% rename from internal/core/runtime/frame_transform.go rename to internal/exec/runtime/frame_transform.go index 0e169cc172..1caf140c70 100644 --- a/internal/core/runtime/frame_transform.go +++ b/internal/exec/runtime/frame_transform.go @@ -4,7 +4,7 @@ import ( "fmt" "math" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // framePose is where a target frame sits in its source frame: a source-coordinate diff --git a/internal/core/runtime/function_value.go b/internal/exec/runtime/function_value.go similarity index 98% rename from internal/core/runtime/function_value.go rename to internal/exec/runtime/function_value.go index b358fc624f..d59ad016d3 100644 --- a/internal/core/runtime/function_value.go +++ b/internal/exec/runtime/function_value.go @@ -3,8 +3,8 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // functionValue is a calc read as a value: its lowered invocation interface and diff --git a/internal/exec/runtime/held.go b/internal/exec/runtime/held.go new file mode 100644 index 0000000000..600efffd7e --- /dev/null +++ b/internal/exec/runtime/held.go @@ -0,0 +1,104 @@ +package runtime + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// HeldObject is one object a feature of another object holds, under the segment +// a path names it by: the feature's name, indexed for a collection's element. +type HeldObject struct { + Feature string + Segment string + Instance *Instance +} + +// HeldObjectsError reports the feature HeldObjects could not read, and why. +type HeldObjectsError struct { + Feature string + Err error +} + +func (e *HeldObjectsError) Error() string { + return fmt.Sprintf("feature %s: %v", e.Feature, e.Err) +} + +func (e *HeldObjectsError) Unwrap() error { return e.Err } + +// HeldObjects returns the objects inst's object-valued features hold — occurrence +// usages and structured attributes alike — in feature order then collection order, +// materializing lazy ones as reading them does. An object reached through several +// features is listed once, under a scalar feature when it has one. A feature that +// cannot be read is a HeldObjectsError. +func (ctx *Context) HeldObjects(inst *Instance) ([]HeldObject, error) { + if err := ctx.checkNotDestroyed(inst); err != nil { + return nil, err + } + var out []HeldObject + var indexedAt []bool + at := make(map[int64]int) + read := make(map[*FeatureValue]bool) + reach := func(feature string, segment string, indexed bool, val Value) { + id, ok := val.Object() + if !ok || ctx.HoldsNoValue(val) { + return + } + child, ok := ctx.instances[id] + if !ok { + return + } + held := HeldObject{Feature: feature, Segment: segment, Instance: child} + if i, seen := at[id]; seen { + if indexedAt[i] && !indexed { + out[i], indexedAt[i] = held, false + } + return + } + at[id] = len(out) + out = append(out, held) + indexedAt = append(indexedAt, indexed) + } + for _, of := range ctx.FeaturesOfObject(inst) { + if of.Name == "" || !(holdsObjects(of.Feature) || ctx.namesStructuredValue(of.Feature.Symbol)) { + continue + } + fv, err := inst.GetFeatureValue(ctx, of.Name) + if err != nil { + return nil, &HeldObjectsError{Feature: of.Name, Err: err} + } + if fv == nil || read[fv] { + continue + } + read[fv] = true + segment := source.NameText(of.Name) + if fv.Values.Kind == ValInvalid { + reach(of.Name, segment, false, fv.Value) + continue + } + for i, element := range elementsOf(fv.Values) { + reach(of.Name, fmt.Sprintf("%s[%d]", segment, i+1), true, element) + } + } + return out, nil +} + +// Types returns the types an object has: the one it was materialized from, +// then the classifiers a behavior has since given it. +func (inst *Instance) Types() []*symbols.Symbol { + return append([]*symbols.Symbol(nil), inst.types()...) +} + +// HeldUnder returns the usage an object's owner holds it as (`Car::wheels` for +// `car.wheels[2]`), or nil for an object held by none. +func (inst *Instance) HeldUnder() *symbols.Symbol { + if inst.owner == nil { + return nil + } + fv, ok := inst.owner.FeatureValues[inst.ownerFeature] + if !ok || fv.Feature == nil { + return nil + } + return fv.Feature.Symbol +} diff --git a/internal/core/runtime/held_image.go b/internal/exec/runtime/held_image.go similarity index 94% rename from internal/core/runtime/held_image.go rename to internal/exec/runtime/held_image.go index 695240724b..86b617ba01 100644 --- a/internal/core/runtime/held_image.go +++ b/internal/exec/runtime/held_image.go @@ -7,7 +7,7 @@ import ( "math/rand/v2" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // ErrImageIdentityTaken is the typed error for an image whose object's identity @@ -99,6 +99,7 @@ type imagedFeature struct { bindingDerived bool dependents []imagedFeatureRef reads []imagedFeatureRef + readsLives bool // derived from the lives: which objects there are, and when each began and ended } // imagedFeatureRef names a feature value of the image: an object's, by position. @@ -126,13 +127,15 @@ func (obj imagedObject) keptIDs() []int64 { } // imagedRun is one run's bookkeeping by value: what it spent and noted, the policy its -// choices draw under, and the generator's position where that policy is seeded. +// choices draw under, the generator's position where that policy is seeded, and the +// modeled stream's position where the run draws its own randomness from a seed. type imagedRun struct { steps, elements int64 notes []RunNote scheduled bool policy SchedulePolicy generator *rand.PCG + modeled *rand.PCG } // Holds reports whether the image holds an object under id. @@ -433,11 +436,18 @@ func (t *imaging) run(state *runState) (int, error) { if s.explore != nil { return 0, fmt.Errorf("%w: an exploration of the schedule under way", ErrImageBound) } + if s.replay != nil { + return 0, fmt.Errorf("%w: a witness being followed", ErrImageBound) + } run.scheduled, run.policy = true, s.policy if s.pcg != nil { generator := *s.pcg run.generator = &generator } + if s.modeled.seeded() { + modeled := *s.modeled.pcg + run.modeled = &modeled + } } at := len(t.img.runStates) t.runs[state] = at @@ -461,6 +471,7 @@ func (t *imaging) finish() { fv := inst.FeatureValues[img.objects[i].features[j].names[0]] img.objects[i].features[j].dependents = t.edges(fv.dependents) img.objects[i].features[j].reads = t.edges(fv.reads) + img.objects[i].features[j].readsLives = slices.Contains(fv.reads, &ctx.lifetimes) } } // A usage's occurrences are imaged whole: an image holding some of them holds none. @@ -615,6 +626,7 @@ type materializeMark struct { activations, runs int64 clock float64 clockRun *runState + readLives []*FeatureValue } func (ctx *Context) materializeMark() materializeMark { @@ -623,6 +635,7 @@ func (ctx *Context) materializeMark() materializeMark { nextID: ctx.ids.next, tookHigh: ctx.took.high, ids: ctx.ids, activations: ctx.activations, runs: ctx.runs, clock: ctx.clock.now, clockRun: ctx.clockRun.state, + readLives: slices.Clone(ctx.lifetimes.dependents), } } @@ -636,8 +649,9 @@ func (mark materializeMark) rollBack(ctx *Context) { ctx.ids.release(ctx, mark.nextID) } ctx.activations, ctx.runs = mark.activations, mark.runs - ctx.clock.now = mark.clock + ctx.setClock(mark.clock) ctx.clockRun.state = mark.clockRun + ctx.lifetimes.dependents = mark.readLives } // materializing builds one context's objects for an image. @@ -703,9 +717,11 @@ func (m *materializing) run() error { for _, obj := range img.objects { m.edges(obj) } + // The objects made are lives of dst: what derived from the lives, imaged or dst's own, derives again. + dst.livesChanged() dst.activations = max(dst.activations, img.activations) dst.runs = max(dst.runs, img.runs) - dst.clock.now = img.clock + dst.setClock(img.clock) for _, run := range img.runStates { m.runs = append(m.runs, m.runState(run)) } @@ -727,6 +743,8 @@ func (m *materializing) run() error { } // Nothing below fails: what names the objects made is installed once they all stand. dst.messages = append(dst.messages, messages...) + dst.bus.posts += uint64(len(messages)) + dst.workChanged() for sym, ids := range img.occurrences { dst.occurrences[sym] = slices.Clone(ids) } @@ -801,7 +819,7 @@ func (m *materializing) feature(inst *Instance, f EffectiveFeature) *EffectiveFe } // edges links the feature values of one object made to the ones they read and -// that read them, within the image. +// that read them, within the image, and to dst's lives where they read the lives. func (m *materializing) edges(obj imagedObject) { inst := m.made[obj.id] for _, f := range obj.features { @@ -812,6 +830,10 @@ func (m *materializing) edges(obj imagedObject) { for _, ref := range f.reads { fv.reads = append(fv.reads, m.featureAt(ref)) } + if f.readsLives { + fv.reads = append(fv.reads, &m.dst.lifetimes) + m.dst.lifetimes.dependents = append(m.dst.lifetimes.dependents, fv) + } } } @@ -822,7 +844,8 @@ func (m *materializing) featureAt(ref imagedFeatureRef) *FeatureValue { } // runState is a run of dst's own standing where an imaged run stood: what it spent -// and noted, and, where both schedules are seeded, the imaged generator's position. +// and noted, where both schedules are seeded the imaged generator's position, and +// where the imaged run drew from a seed its modeled stream's position. func (m *materializing) runState(run imagedRun) *runState { state := &runState{ steps: run.steps, elements: run.elements, notes: slices.Clone(run.notes), @@ -833,6 +856,9 @@ func (m *materializing) runState(run imagedRun) *runState { if run.generator != nil && state.scheduler.pcg != nil { *state.scheduler.pcg = *run.generator } + if run.modeled != nil { + state.scheduler.modeled = modeledAt(*run.modeled) + } } return state } diff --git a/internal/exec/runtime/held_image_behavior.go b/internal/exec/runtime/held_image_behavior.go new file mode 100644 index 0000000000..b67ff035e6 --- /dev/null +++ b/internal/exec/runtime/held_image_behavior.go @@ -0,0 +1,804 @@ +package runtime + +import ( + "fmt" + "maps" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// imagedBehavior is one object's execution of a behavior its type binds, by value. +// The lowered graph is kept as is: lowered IR is derived from the shared, frozen +// declarations and never written once lowered, so every context reads one copy. +type imagedBehavior struct { + object int64 + attached int // position among the behaviors of the context imaged + member *symbols.Symbol + binding int + name string + kind lower.ClassifierBehaviorKind + onClock bool + action *imagedAction + state *imagedState +} + +// imagedAction is an action executor's state by value, its frames by position. +type imagedAction struct { + graph *lower.ActionGraph + tokens []Token + tokenFrames []int + state ExecutionState + nextTokenID int64 + stepCount int + sweep, sweeps uint64 + inputs map[string]Value + pausedAt breakpointStop + released bool + pauses int64 + steps, stepsSpent int64 + inRun, moved bool + awaiting int + breakpoints map[string]bool + breakpointNodes []NodeBreakpoint + firedBreakpoints map[breakpointVisit]bool + traversals []Traversal + traversalBase int + keepTraversals bool + run int + dynamics *stateSpaceRun + frames []imagedFrame +} + +// imagedFrame is one performance's state by value, the frames it points at by position. +type imagedFrame struct { + saved actionFrame + parent int + locals []map[string]Value + data map[string]Value + outer []imagedOuter + subactions map[ast.Node]int + pending map[ast.Node]map[string][]Value + staged map[ast.Node]map[string][]imagedStaged + nested map[ast.Node][]nestedDelivery +} + +// imagedStaged is a staged streaming write, its source performance by position. +type imagedStaged struct { + source int + pin string + flow ast.Node + at int +} + +// imagedOuter is one level of bindings around a performance, its performance by position. +type imagedOuter struct { + vars map[string]Value + aliases map[string]string + perf int + performed *symbols.Symbol + run int64 + merged []*symbols.Symbol +} + +// imagedState is a state executor's state by value. +type imagedState struct { + graph *lower.StateGraph + state ExecutionState + activeConfig *StateConfiguration + nextEventID int64 + events []Event + stateData map[string]Value + stateAttrs map[*ast.StateNode]map[string]Value + stateVisits []string + stateStack []*ast.StateNode + fired []FiredTransition + firedBase int + keepFired bool + breakpointNodes map[ast.Node]bool + breakpointHit *ast.StateNode + pausedAt ast.Node + completionDue bool + history map[*ast.StateNode]historyRecord + deferred []Event + lastDispatch *Dispatch + lastEventAt float64 + doActions []doActionCapture + machineExited bool + run int + inRun, moved bool + timerScheduled map[*lower.Transition]bool + timeTriggerVerdict map[*lower.Transition]error + changeFired map[*lower.Transition]bool + firingChange *lower.Transition + firingNotes []RunNote + changeRearmed map[*lower.Transition]bool + changeWaits []changeWait + held []heldEntry + entering map[*ast.StateNode]bool + enteringMachine bool + activeAtEntry map[*ast.StateNode]bool + pendingCall *pendingCall +} + +// behavior takes one behavior's execution. +func (t *imaging) behavior(b *ObjectBehavior) error { + img := imagedBehavior{ + object: b.Object.ID, attached: slices.Index(t.ctx.objectBehaviors, b), + member: b.member, binding: b.binding, name: b.Name, kind: b.Kind, + } + t.declared[b.Symbol] = true + for _, bound := range b.bindings { + t.declared[bound] = true + } + var err error + switch { + case b.State != nil: + img.onClock = slices.Contains(t.ctx.clock.waiters, clockWaiter(b.State)) + img.state, err = t.stateExecutor(b.State) + case b.Action != nil: + img.onClock = slices.Contains(t.ctx.clock.waiters, clockWaiter(b.Action)) + img.action, err = t.actionExecutor(b.Action) + default: + return fmt.Errorf("%w: a behavior with no execution", ErrImageBound) + } + if err != nil { + return err + } + t.img.behaviors = append(t.img.behaviors, img) + return nil +} + +// actionExecutor takes an action executor's state, its frames by position. +func (t *imaging) actionExecutor(e *ActionExecutor) (*imagedAction, error) { + if e.inRun { + return nil, fmt.Errorf("%w: action %s is running", ErrSnapshotMidRun, symbolText(e.action)) + } + frames := e.reachableFrames() + at := func(perf *actionFrame) int { return slices.Index(frames, perf) } + img := &imagedAction{ + graph: e.graph, state: e.state, nextTokenID: e.nextTokenID, stepCount: e.stepCount, + sweep: e.sweep, sweeps: e.sweeps, pausedAt: e.pausedAt, released: e.released, + pauses: e.pauses, steps: e.steps, stepsSpent: e.stepsSpent, inRun: e.inRun, moved: e.moved, + awaiting: at(e.awaiting), + breakpoints: maps.Clone(e.breakpoints), + breakpointNodes: cloneBreakpoints(e.breakpointNodes), + firedBreakpoints: maps.Clone(e.firedBreakpoints), + traversals: cloneTraversals(e.traversals), + traversalBase: e.traversalBase, + keepTraversals: e.keepTraversals, + } + var err error + if img.run, err = t.run(e.driven.state); err != nil { + return nil, err + } + if err := t.values(e.inputs); err != nil { + return nil, fmt.Errorf("inputs: %w", err) + } + img.inputs = maps.Clone(e.inputs) + if e.dynamics != nil { + if err := t.value(e.dynamics.stepValue); err != nil { + return nil, fmt.Errorf("time step: %w", err) + } + img.dynamics = e.dynamics.clone() + } + for _, token := range e.tokens { + if token.body != nil { + return nil, fmt.Errorf("%w: token %d of %s at %s", ErrSnapshotPausedBody, + token.ID, symbolText(e.action), ActionNodeName(token.Location)) + } + copied := token + if token.Wait != nil { + wait := *token.Wait + copied.Wait = &wait + } + copied.frame = nil + img.tokens = append(img.tokens, copied) + img.tokenFrames = append(img.tokenFrames, at(token.frame)) + } + for _, perf := range frames { + frame, err := t.frame(perf, at) + if err != nil { + return nil, err + } + img.frames = append(img.frames, frame) + } + return img, nil +} + +// frame takes one performance's state, the frames it points at by position. +func (t *imaging) frame(perf *actionFrame, at func(*actionFrame) int) (imagedFrame, error) { + if perf.inBody { + return imagedFrame{}, fmt.Errorf("%w: a body of %s is running", ErrSnapshotMidRun, perf.label) + } + f := imagedFrame{saved: *perf, parent: at(perf.parent)} + f.saved.parent, f.saved.locals, f.saved.outer, f.saved.data = nil, nil, nil, nil + f.saved.subactions, f.saved.pending, f.saved.staged, f.saved.nested = nil, nil, nil, nil + f.saved.connections = slices.Clone(perf.connections) + f.saved.features = maps.Clone(perf.features) + f.saved.aliases = maps.Clone(perf.aliases) + f.saved.outputs = slices.Clone(perf.outputs) + f.saved.nodes = slices.Clone(perf.nodes) + f.saved.streamed = maps.Clone(perf.streamed) + f.saved.unreceived = cloneUnreceived(perf.unreceived) + for _, local := range perf.locals { + if err := t.values(local); err != nil { + return imagedFrame{}, err + } + f.locals = append(f.locals, maps.Clone(local)) + } + if err := t.values(perf.data); err != nil { + return imagedFrame{}, err + } + f.data = maps.Clone(perf.data) + outer, err := t.outerFrames(perf, at) + if err != nil { + return imagedFrame{}, err + } + f.outer = outer + if perf.subactions != nil { + f.subactions = make(map[ast.Node]int, len(perf.subactions)) + for node, sub := range perf.subactions { + f.subactions[node] = at(sub) + } + } + if err := t.nestedValues(perf.pending); err != nil { + return imagedFrame{}, err + } + f.pending = clonePending(perf.pending) + f.staged = stagedImaged(perf.staged, at) + if err := t.deliveredValues(perf.nested); err != nil { + return imagedFrame{}, err + } + f.nested = cloneNested(perf.nested) + return f, nil +} + +// outerFrames images the bindings around perf. +func (t *imaging) outerFrames(perf *actionFrame, at func(*actionFrame) int) ([]imagedOuter, error) { + var outer []imagedOuter + for _, o := range perf.outer { + if o.slots != nil || o.owner != nil { + return nil, fmt.Errorf("%w: a frame of a calc around %s", ErrImageBound, perf.label) + } + if o.perf != nil && at(o.perf) < 0 { + return nil, fmt.Errorf("%w: a performance around %s the run no longer reaches", ErrImageBound, perf.label) + } + if err := t.values(o.vars); err != nil { + return nil, err + } + outer = append(outer, imagedOuter{ + vars: maps.Clone(o.vars), aliases: maps.Clone(o.aliases), perf: at(o.perf), + performed: o.performed, run: o.run, merged: slices.Clone(o.merged), + }) + } + return outer, nil +} + +// nestedValues checks every value a pending table holds. +func (t *imaging) nestedValues(pending map[ast.Node]map[string][]Value) error { + for _, pins := range pending { + for _, values := range pins { + for _, v := range values { + if err := t.value(v); err != nil { + return err + } + } + } + } + return nil +} + +// deliveredValues checks every value a nested-delivery table holds. +func (t *imaging) deliveredValues(nested map[ast.Node][]nestedDelivery) error { + for _, deliveries := range nested { + for _, d := range deliveries { + if err := t.value(d.value); err != nil { + return err + } + } + } + return nil +} + +// stagedImaged images staged's sources by position. +func stagedImaged(staged map[ast.Node]map[string][]stagedStream, at func(*actionFrame) int) map[ast.Node]map[string][]imagedStaged { + if staged == nil { + return nil + } + imaged := make(map[ast.Node]map[string][]imagedStaged, len(staged)) + for node, pins := range staged { + imaged[node] = make(map[string][]imagedStaged, len(pins)) + for pin, entries := range pins { + for _, s := range entries { + imaged[node][pin] = append(imaged[node][pin], imagedStaged{source: at(s.source), pin: s.pin, flow: s.flow, at: s.at}) + } + } + } + return imaged +} + +// stateExecutor takes a state executor's state. +func (t *imaging) stateExecutor(e *StateExecutor) (*imagedState, error) { + if e.inRun { + return nil, fmt.Errorf("%w: state machine %s is running", ErrSnapshotMidRun, symbolText(e.stateMachine)) + } + img := &imagedState{ + graph: e.graph, state: e.state, activeConfig: cloneConfiguration(e.activeConfig), + nextEventID: e.nextEventID, + stateData: maps.Clone(e.stateData), + stateAttrs: make(map[*ast.StateNode]map[string]Value, len(e.stateAttrs)), + stateVisits: slices.Clone(e.stateVisits), + stateStack: slices.Clone(e.stateStack), + fired: slices.Clone(e.fired), + firedBase: e.firedBase, + keepFired: e.keepFired, + breakpointNodes: maps.Clone(e.breakpointNodes), + breakpointHit: e.breakpointHit, + pausedAt: e.pausedAt, + completionDue: e.completionDue, + history: make(map[*ast.StateNode]historyRecord, len(e.history)), + deferred: slices.Clone(e.deferred), + lastDispatch: cloneDispatch(e.lastDispatch), + lastEventAt: e.lastEventAt, + machineExited: e.machineExited, + inRun: e.inRun, + moved: e.moved, + timerScheduled: maps.Clone(e.timerScheduled), + timeTriggerVerdict: maps.Clone(e.timeTriggerVerdict), + changeFired: maps.Clone(e.changeFired), + firingChange: e.firingChange, + firingNotes: slices.Clone(e.firingNotes), + changeRearmed: maps.Clone(e.changeRearmed), + changeWaits: slices.Clone(e.changeWaits), + held: cloneHeldEntries(e.held), + entering: maps.Clone(e.entering), + enteringMachine: e.enteringMachine, + activeAtEntry: maps.Clone(e.activeAtEntry), + pendingCall: e.pendingCall.clone(), + } + var err error + if img.run, err = t.run(e.driven.state); err != nil { + return nil, err + } + if err := t.values(e.stateData); err != nil { + return nil, err + } + if call := e.pendingCall; call != nil { + if err := t.values(call.outputs); err != nil { + return nil, fmt.Errorf("call: %w", err) + } + if err := t.values(call.inouts); err != nil { + return nil, fmt.Errorf("call: %w", err) + } + } + for node, attrs := range e.stateAttrs { + if err := t.values(attrs); err != nil { + return nil, fmt.Errorf("state %s: %w", StateVertexName(node), err) + } + img.stateAttrs[node] = maps.Clone(attrs) + } + for node, record := range e.history { + img.history[node] = historyRecord{child: record.child, regions: maps.Clone(record.regions)} + } + if e.eventQueue != nil { + img.events = slices.Clone(e.eventQueue.events) + } + for _, event := range img.events { + if err := t.event(event); err != nil { + return nil, err + } + } + for _, event := range img.deferred { + if err := t.event(event); err != nil { + return nil, err + } + } + if img.lastDispatch != nil { + if err := t.event(img.lastDispatch.Event); err != nil { + return nil, err + } + } + for _, act := range e.doActions { + if act.run != nil { + return nil, fmt.Errorf("%w: do behavior of state %s of %s", ErrSnapshotPausedBody, + StateVertexName(act.state), symbolText(e.stateMachine)) + } + if err := t.firing(act.firing); err != nil { + return nil, fmt.Errorf("do behavior of state %s: %w", StateVertexName(act.state), err) + } + img.doActions = append(img.doActions, doActionCapture{act: &doAction{state: act.state}, pending: slices.Clone(act.pending), firing: act.firing.snapshot()}) + } + for _, item := range img.held { + if err := t.firing(item.firing); err != nil { + return nil, fmt.Errorf("held entry of %s: %w", StateVertexName(item.owner), err) + } + } + return img, nil +} + +// firing checks that the payload a firing bound carries. +func (t *imaging) firing(f *firing) error { + if f == nil { + return nil + } + return t.values(f.payload) +} + +// event checks that an event's payload carries, reaching what it names. +func (t *imaging) event(event Event) error { + switch payload := event.Payload.(type) { + case nil, *lower.Transition, *ast.TransitionEdge: + return nil + case Message: + return t.message(payload) + case Call: + if err := t.values(payload.Args); err != nil { + return fmt.Errorf("call %s: %w", payload.Operation, err) + } + return nil + } + return fmt.Errorf("%w: event %d carries a %T", ErrImageBound, event.ID, event.Payload) +} + +// behavior gives the object made for an imaged behavior an execution of its own +// standing where the imaged one stood. +func (m *materializing) behavior(b imagedBehavior) error { + dst := m.dst + inst := m.made[b.object] + decl, ok := m.declaration(inst, b.member) + if !ok { + return fmt.Errorf("%w: the type binds no such behavior", ErrImageBound) + } + behavior, occurrence, err := dst.bindClassifierBehavior(inst, decl) + if err != nil { + return err + } + behavior.binding = b.binding + switch { + case b.state != nil: + exec := newStateExecutorOn(dst, behavior.Symbol, inst, occurrence, b.state.graph) + if err := m.stateExecutor(exec, b.state); err != nil { + return err + } + if b.onClock { + dst.clock.attach(exec) + } + behavior.State = exec + case b.action != nil: + action, tool, err := dst.performanceBody(decl.member, behavior.Symbol) + if err != nil { + return err + } + exec := newActionExecutorOn(dst, decl.member, action, tool, b.action.graph, inst, occurrence) + if err := m.actionExecutor(exec, b.action); err != nil { + dst.clock.detach(exec) + return err + } + if !b.onClock { + dst.clock.detach(exec) + } + behavior.Action = exec + } + inst.behaviors = append(inst.behaviors, behavior) + dst.objectBehaviors = append(dst.objectBehaviors, behavior) + dst.workChanged() + return nil +} + +// declaration finds, among the behaviors the object's types bind or declare for a +// start, the one member declares. +func (m *materializing) declaration(inst *Instance, member *symbols.Symbol) (classifierBehaviorDecl, bool) { + for _, typ := range inst.types() { + for _, decl := range m.dst.classifierBehaviorsOf(typ) { + if decl.member == member { + return decl, true + } + } + } + return m.dst.startableDeclaration(inst, member) +} + +// runOf is the run of dst's own made for an imaged run, nil for none. +func (m *materializing) runOf(at int) *runState { + if at < 0 { + return nil + } + return m.runs[at] +} + +// actionExecutor puts an imaged action's state on a fresh execution of dst's. +func (m *materializing) actionExecutor(e *ActionExecutor, img *imagedAction) error { + frames := make([]*actionFrame, len(img.frames)) + for i := range frames { + frames[i] = &actionFrame{} + } + frameAt := func(at int) *actionFrame { + if at < 0 { + return nil + } + return frames[at] + } + for i, f := range img.frames { + if err := m.frame(frames[i], f, frameAt); err != nil { + return err + } + } + if len(frames) > 0 { + e.root = frames[0] + } + e.tokens = make([]Token, 0, len(img.tokens)) + for i, token := range img.tokens { + copied := token + if token.Wait != nil { + wait := *token.Wait + copied.Wait = &wait + } + copied.frame = frameAt(img.tokenFrames[i]) + e.tokens = append(e.tokens, copied) + } + e.state, e.nextTokenID, e.stepCount, e.sweep, e.sweeps = img.state, img.nextTokenID, img.stepCount, img.sweep, img.sweeps + e.pausedAt, e.released, e.pauses = img.pausedAt, img.released, img.pauses + e.steps, e.stepsSpent, e.inRun, e.moved = img.steps, img.stepsSpent, img.inRun, img.moved + e.awaiting = frameAt(img.awaiting) + e.breakpoints = maps.Clone(img.breakpoints) + if e.breakpoints == nil { + e.breakpoints = make(map[string]bool) + } + e.breakpointNodes = cloneBreakpoints(img.breakpointNodes) + e.traversals, e.traversalBase, e.keepTraversals = cloneTraversals(img.traversals), img.traversalBase, img.keepTraversals + e.firedBreakpoints = maps.Clone(img.firedBreakpoints) + if e.firedBreakpoints == nil { + e.firedBreakpoints = make(map[breakpointVisit]bool) + } + var err error + if e.inputs, err = m.values(img.inputs); err != nil { + return fmt.Errorf("inputs: %w", err) + } + e.driven.state = m.runOf(img.run) + if img.dynamics != nil { + e.dynamics = img.dynamics.clone() + if e.dynamics.stepValue, err = m.value(img.dynamics.stepValue); err != nil { + return fmt.Errorf("time step: %w", err) + } + } + return nil +} + +// frame fills one performance of dst's from its image. +func (m *materializing) frame(perf *actionFrame, img imagedFrame, frameAt func(int) *actionFrame) error { + *perf = img.saved + perf.parent = frameAt(img.parent) + perf.connections = slices.Clone(img.saved.connections) + perf.features = maps.Clone(img.saved.features) + perf.aliases = maps.Clone(img.saved.aliases) + perf.outputs = slices.Clone(img.saved.outputs) + perf.nodes = slices.Clone(img.saved.nodes) + perf.streamed = maps.Clone(img.saved.streamed) + perf.unreceived = cloneUnreceived(img.saved.unreceived) + var err error + perf.locals = nil + for _, local := range img.locals { + carried, err := m.values(local) + if err != nil { + return err + } + perf.locals = append(perf.locals, carried) + } + if perf.data, err = m.values(img.data); err != nil { + return err + } + perf.outer = nil + for _, outer := range img.outer { + vars, err := m.values(outer.vars) + if err != nil { + return err + } + perf.outer = append(perf.outer, frame{ + vars: vars, aliases: maps.Clone(outer.aliases), perf: frameAt(outer.perf), + performed: outer.performed, run: outer.run, merged: slices.Clone(outer.merged), + }) + } + if img.subactions != nil { + perf.subactions = make(map[ast.Node]*actionFrame, len(img.subactions)) + for node, at := range img.subactions { + perf.subactions[node] = frameAt(at) + } + } + if err := m.pending(perf, img.pending); err != nil { + return err + } + perf.staged = stagedMaterialized(img.staged, frameAt) + if err := m.nested(perf, img.nested); err != nil { + return err + } + return nil +} + +// pending fills perf's pending table from img's. +func (m *materializing) pending(perf *actionFrame, pending map[ast.Node]map[string][]Value) error { + if pending == nil { + return nil + } + perf.pending = make(map[ast.Node]map[string][]Value, len(pending)) + for node, pins := range pending { + carriedPins := make(map[string][]Value, len(pins)) + for pin, values := range pins { + for _, v := range values { + carried, err := m.value(v) + if err != nil { + return err + } + carriedPins[pin] = append(carriedPins[pin], carried) + } + } + perf.pending[node] = carriedPins + } + return nil +} + +// nested fills perf's nested-delivery table from img's. +func (m *materializing) nested(perf *actionFrame, nested map[ast.Node][]nestedDelivery) error { + if nested == nil { + return nil + } + perf.nested = make(map[ast.Node][]nestedDelivery, len(nested)) + for node, deliveries := range nested { + for _, d := range deliveries { + carried, err := m.value(d.value) + if err != nil { + return err + } + perf.nested[node] = append(perf.nested[node], nestedDelivery{path: slices.Clone(d.path), pin: d.pin, value: carried}) + } + } + return nil +} + +// stagedMaterialized materializes img's staged writes, their sources by position. +func stagedMaterialized(staged map[ast.Node]map[string][]imagedStaged, frameAt func(int) *actionFrame) map[ast.Node]map[string][]stagedStream { + if staged == nil { + return nil + } + materialized := make(map[ast.Node]map[string][]stagedStream, len(staged)) + for node, pins := range staged { + materialized[node] = make(map[string][]stagedStream, len(pins)) + for pin, entries := range pins { + for _, s := range entries { + materialized[node][pin] = append(materialized[node][pin], stagedStream{source: frameAt(s.source), pin: s.pin, flow: s.flow, at: s.at}) + } + } + } + return materialized +} + +// stateExecutor puts an imaged machine's state on a fresh execution of dst's. +func (m *materializing) stateExecutor(e *StateExecutor, img *imagedState) error { + var err error + e.state, e.activeConfig, e.nextEventID = img.state, cloneConfiguration(img.activeConfig), img.nextEventID + if e.stateData, err = m.values(img.stateData); err != nil { + return err + } + if e.stateData == nil { + e.stateData = make(map[string]Value) + } + for node, attrs := range img.stateAttrs { + if e.stateAttrs[node], err = m.values(attrs); err != nil { + return fmt.Errorf("state %s: %w", StateVertexName(node), err) + } + } + e.stateVisits, e.stateStack = slices.Clone(img.stateVisits), slices.Clone(img.stateStack) + e.fired, e.firedBase, e.keepFired = slices.Clone(img.fired), img.firedBase, img.keepFired + e.breakpointNodes = maps.Clone(img.breakpointNodes) + if e.breakpointNodes == nil { + e.breakpointNodes = make(map[ast.Node]bool) + } + e.breakpointHit, e.pausedAt, e.completionDue = img.breakpointHit, img.pausedAt, img.completionDue + for node, record := range img.history { + e.history[node] = &historyRecord{child: record.child, regions: maps.Clone(record.regions)} + } + e.eventQueue.events = make(eventHeap, 0, len(img.events)) + for _, event := range img.events { + carried, err := m.event(event) + if err != nil { + return err + } + e.eventQueue.events = append(e.eventQueue.events, carried) + } + e.deferred = make([]Event, 0, len(img.deferred)) + for _, event := range img.deferred { + carried, err := m.event(event) + if err != nil { + return err + } + e.deferred = append(e.deferred, carried) + } + if img.lastDispatch != nil { + dispatch := cloneDispatch(img.lastDispatch) + if dispatch.Event, err = m.event(img.lastDispatch.Event); err != nil { + return err + } + e.lastDispatch = dispatch + } + e.lastEventAt = img.lastEventAt + for _, act := range img.doActions { + carried, err := m.firing(act.firing) + if err != nil { + return fmt.Errorf("do behavior of state %s: %w", StateVertexName(act.act.state), err) + } + e.doActions = append(e.doActions, &doAction{state: act.act.state, pending: slices.Clone(act.pending), firing: carried}) + } + e.machineExited, e.inRun, e.moved = img.machineExited, img.inRun, img.moved + e.driven.state = m.runOf(img.run) + e.timerScheduled = maps.Clone(img.timerScheduled) + e.timeTriggerVerdict = maps.Clone(img.timeTriggerVerdict) + e.changeFired = maps.Clone(img.changeFired) + e.firingChange, e.firingNotes = img.firingChange, slices.Clone(img.firingNotes) + e.changeRearmed = maps.Clone(img.changeRearmed) + e.changeWaits = slices.Clone(img.changeWaits) + e.held = cloneHeldEntries(img.held) + for i := range e.held { + if e.held[i].firing, err = m.firing(img.held[i].firing); err != nil { + return fmt.Errorf("held entry of %s: %w", StateVertexName(e.held[i].owner), err) + } + } + clear(e.entering) + for state, entering := range img.entering { + e.entering[state] = entering + } + e.enteringMachine = img.enteringMachine + e.activeAtEntry = maps.Clone(img.activeAtEntry) + return m.pendingCall(e, img.pendingCall) +} + +// pendingCall gives e the imaged call, its values carried as dst's own. +func (m *materializing) pendingCall(e *StateExecutor, call *pendingCall) error { + if call == nil { + e.pendingCall = nil + return nil + } + carried := call.clone() + var err error + if carried.outputs, err = m.values(call.outputs); err != nil { + return err + } + if carried.inouts, err = m.values(call.inouts); err != nil { + return err + } + e.pendingCall = carried + return nil +} + +// firing is a firing as dst carries it, the payload it bound as dst's own values. +func (m *materializing) firing(f *firing) (*firing, error) { + if f == nil { + return nil, nil + } + payload, err := m.values(f.payload) + if err != nil { + return nil, err + } + return &firing{taken: f.taken, payload: payload}, nil +} + +// event is an event as dst carries it. +func (m *materializing) event(event Event) (Event, error) { + out := event + switch payload := event.Payload.(type) { + case Message: + carried, err := m.message(payload) + if err != nil { + return Event{}, err + } + out.Payload = carried + case Call: + args, err := m.values(payload.Args) + if err != nil { + return Event{}, fmt.Errorf("call %s: %w", payload.Operation, err) + } + out.Payload = Call{Operation: payload.Operation, Declared: payload.Declared, Args: args} + } + return out, nil +} diff --git a/internal/exec/runtime/held_image_test.go b/internal/exec/runtime/held_image_test.go new file mode 100644 index 0000000000..9c3d127c58 --- /dev/null +++ b/internal/exec/runtime/held_image_test.go @@ -0,0 +1,1393 @@ +package runtime + +import ( + "errors" + "fmt" + "maps" + "slices" + "strings" + "sync" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// lampBulb materializes a Bulb over lampSource and answers the document root with it. +func lampBulb(t *testing.T) (*symbols.Scope, *Context, *Instance) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "lamp.sysml", parseAndBuild(t, lampSource)) + root := idx.DocumentRoot("lamp.sysml") + bulb, err := ctx.Instantiate(resolveSymbol(t, root, "Bulb")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + return root, ctx, bulb +} + +// dispatchTo posts a signal to the bulb and lets its machine dispatch it. +func dispatchTo(t *testing.T, root *symbols.Scope, ctx *Context, bulb *Instance, signal string, args map[string]Value) { + t.Helper() + msg, err := ctx.SignalMessage(resolveSymbol(t, root, signal), args, bulb) + if err != nil { + t.Fatalf("SignalMessage(%s): %v", signal, err) + } + ctx.PostMessage(msg) + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatalf("object #%d exhibits no machine", bulb.ID) + } + if err := behavior.State.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(%s): %v", signal, err) + } +} + +// lampLeaf is the active leaf of the bulb's machine. +func lampLeaf(t *testing.T, bulb *Instance) string { + t.Helper() + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatalf("object #%d exhibits no machine", bulb.ID) + } + return activeLeaf(behavior.State) +} + +// lampBrightness is the brightness the bulb's machine holds. +func lampBrightness(t *testing.T, bulb *Instance) string { + t.Helper() + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatalf("object #%d exhibits no machine", bulb.ID) + } + return FormatValue(behavior.State.StateData()["brightness"]) +} + +// imageInto takes an image of the objects and materializes it into a fresh +// context over the same model, answering that context. +func imageInto(t *testing.T, ctx *Context, objects ...*Instance) *Context { + t.Helper() + img, err := ctx.Image(objects...) + if err != nil { + t.Fatalf("Image: %v", err) + } + dst := NewContext(ctx.Model(), 10000) + if err := img.Materialize(dst); err != nil { + t.Fatalf("Materialize: %v", err) + } + return dst +} + +// A held entry survives a portable image: the copy remains at the same entry +// boundary and finishes with the same result as the source. +func TestHeldImageCarriesAnEntryBoundary(t *testing.T) { + const source = ` + private import ScalarValues::*; + item def Ping; + state Machine { + attribute hits : Integer = 0; + entry; then start; + state start; + state working { + attribute :>> isRunToCompletion = false; + state step { + entry action count { assign hits := hits + 1; } + } + entry action { send new Ping() to Machine; } then step; + } + state done; + transition first working accept Ping then done; + succession first start then working; + } + part def Host { exhibit state machine : Machine; } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "held-entry.sysml", parseAndBuild(t, source)) + hostSym := resolveSymbol(t, idx.DocumentRoot("held-entry.sysml"), "Host") + host, err := ctx.materialize(hostSym, 0, nil, "") + if err != nil { + t.Fatalf("materialize: %v", err) + } + decls := ctx.classifierBehaviorsOf(hostSym) + if len(decls) != 1 { + t.Fatalf("Host has %d classifier behaviors, want one", len(decls)) + } + behavior, err := ctx.attachClassifierBehavior(host, decls[0]) + if err != nil { + t.Fatalf("attachClassifierBehavior: %v", err) + } + behavior.binding = 0 + host.behaviors = append(host.behaviors, behavior) + ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + state, ok := host.ExhibitedState() + if !ok { + t.Fatal("Host exhibits no state machine") + } + if err := state.State.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if !state.State.HoldsEntry() { + t.Fatal("source did not stop at an entry boundary") + } + + dst := imageInto(t, ctx, host) + copied, ok := dst.Instance(host.ID) + if !ok { + t.Fatalf("destination has no Host #%d", host.ID) + } + copiedBehavior, ok := copied.ExhibitedState() + if !ok { + t.Fatal("copy exhibits no state machine") + } + if !copiedBehavior.State.HoldsEntry() { + t.Fatal("imaged copy lost its held entry") + } + if err := copiedBehavior.State.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence(copy): %v", err) + } + if err := state.State.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence(source): %v", err) + } + if got, want := FormatValue(copiedBehavior.State.StateData()["hits"]), FormatValue(state.State.StateData()["hits"]); got != want { + t.Fatalf("copy hits = %s, source hits = %s", got, want) + } + if got, want := copiedBehavior.State.stateVisits, state.State.stateVisits; !slices.Equal(got, want) { + t.Fatalf("copy visits = %v, source visits = %v", got, want) + } +} + +// A held entry and a pending do action carry the transition that entered them +// through a portable image: resumed in the copy, the substate's entry and the do +// behaviors still read the payload the transition accepted. +func TestHeldImageCarriesTheEnteringFiring(t *testing.T) { + const source = ` + private import ScalarValues::*; + attribute def Level { attribute n : Integer; } + state def Machine { + attribute did : Integer = 0; + attribute entered : Integer = 0; + attribute innerDid : Integer = 0; + entry; then idle; + state idle; + state work { + attribute :>> isRunToCompletion = false; + do { in level : Integer = raise.l.n ?? 99; assign did := level; } + entry; then inner; + state inner { + entry action { in level : Integer = raise.l.n ?? 99; assign entered := level; } + do { in level : Integer = raise.l.n ?? 99; assign innerDid := level; } + } + } + transition raise first idle accept l : Level then work; + } + part def Host { exhibit state machine : Machine; } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "entering-firing.sysml", parseAndBuild(t, source)) + root := idx.DocumentRoot("entering-firing.sysml") + host, err := ctx.Instantiate(resolveSymbol(t, root, "Host")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + dispatchTo(t, root, ctx, host, "Level", map[string]Value{"n": integerValue(7)}) + machine := lampMachine(t, host) + if !machine.HoldsEntry() { + t.Fatal("source did not stop at work's entry boundary") + } + if len(machine.doActions) != 1 || machine.doActions[0].run != nil { + t.Fatalf("do actions %v, want work's do behavior pending", machine.doActions) + } + + dst := imageInto(t, ctx, host) + copied, ok := dst.Instance(host.ID) + if !ok { + t.Fatalf("destination has no Host #%d", host.ID) + } + imaged := lampMachine(t, copied) + if !imaged.HoldsEntry() { + t.Fatal("imaged copy lost its held entry") + } + for name, exec := range map[string]*StateExecutor{"copy": imaged, "source": machine} { + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence(%s): %v", name, err) + } + if got := activeLeaf(exec); got != "inner" { + t.Errorf("%s is at %s, want inner", name, got) + } + for _, attr := range []string{"did", "entered", "innerDid"} { + if got := FormatValue(exec.StateData()[attr]); got != "7" { + t.Errorf("%s %s = %s, want 7: the entering transition's payload", name, attr, got) + } + } + } +} + +// A state machine that fired a transition is imaged as it stands: the copy is the +// same object under the same identity in the other context, in the same state, +// and goes on from there as the original does — while neither sees the other's moves. +func TestHeldImageCarriesAMovedStateMachine(t *testing.T) { + root, src, bulb := lampBulb(t) + dispatchTo(t, root, src, bulb, "go", nil) + if got := lampLeaf(t, bulb); got != "on" { + t.Fatalf("state after go = %s, want on", got) + } + if err := src.Pristine(bulb); err == nil { + t.Fatal("a bulb whose machine fired reads as pristine") + } + + dst := imageInto(t, src, bulb) + copied, held := dst.Instance(bulb.ID) + if !held || copied == bulb || copied.Type != bulb.Type { + t.Fatalf("Instance(#%d) in the destination = %v, %v; want an object of its own of the same type", bulb.ID, copied, held) + } + if got := lampLeaf(t, copied); got != "on" { + t.Errorf("the copy is at %s, want on as imaged", got) + } + var hse *HeldStateError + if err := dst.Pristine(copied); !errors.As(err, &hse) || !strings.Contains(err.Error(), "has moved") { + t.Errorf("Pristine(copy) = %v, want the moved machine refused as in the source", err) + } + if next := dst.ids.next; next <= bulb.ID { + t.Errorf("the destination's next identity is %d, want past the imaged #%d", next, bulb.ID) + } + + level := map[string]Value{"level": integerValue(7)} + dispatchTo(t, root, src, bulb, "Dim", level) + dispatchTo(t, root, dst, copied, "Dim", level) + for name, obj := range map[string]*Instance{"source": bulb, "copy": copied} { + if got := lampLeaf(t, obj); got != "dimmed" { + t.Errorf("%s at %s after Dim, want dimmed", name, got) + } + if got := lampBrightness(t, obj); got != "7" { + t.Errorf("%s brightness = %s after Dim 7, want 7", name, got) + } + } + + // Only the copy's clock advances: the source's machine stays dimmed. + if _, err := dst.Advance(5); err != nil { + t.Fatalf("Advance(copy): %v", err) + } + if got := lampLeaf(t, copied); got != "off" { + t.Errorf("the copy is at %s after 5 s, want off", got) + } + if got := lampLeaf(t, bulb); got != "dimmed" { + t.Errorf("the source moved to %s with the copy's clock, want dimmed", got) + } + if src.clock.now != 0 { + t.Errorf("the source's clock reads %v, want 0", src.clock.now) + } +} + +// A fresh object's image is pristine where the object is: the copy's execution is +// as its start left it. +func TestHeldImageOfAFreshObjectIsPristine(t *testing.T) { + _, src, bulb := lampBulb(t) + if err := src.Pristine(bulb); err != nil { + t.Fatalf("Pristine(fresh bulb) = %v, want admitted", err) + } + dst := imageInto(t, src, bulb) + copied, _ := dst.Instance(bulb.ID) + if err := dst.Pristine(copied); err != nil { + t.Errorf("Pristine(copy of a fresh bulb) = %v, want admitted", err) + } + if got := lampLeaf(t, copied); got != "off" { + t.Errorf("the copy is at %s, want off", got) + } +} + +const waiterSource = ` + package test { + part def Waiter { + attribute woken: Integer = 0; + perform action await { + first start; + action heard accept g : Integer; + action mark { assign woken := g; } + done; + succession first start then heard; + succession first heard then mark; + succession first mark then done; + } + } + } +` + +// A run draws its choices under the policy set when it started, whatever is set +// later; the copy's run draws under that policy too, from the generator's position, +// not under the destination's. +func TestHeldImageCarriesTheRunsSchedulePolicy(t *testing.T) { + idx, _, src := buildRuntimeWithLibraries(t, "lamp.sysml", parseAndBuild(t, lampSource)) + root := idx.DocumentRoot("lamp.sysml") + seeded := mustPolicy(t, "seed:7") + mustSchedule(t, src, seeded) + bulb, err := src.Instantiate(resolveSymbol(t, root, "Bulb")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + dispatchTo(t, root, src, bulb, "go", nil) + mustSchedule(t, src, mustPolicy(t, "declared")) + machine, _ := bulb.ExhibitedState() + if got := machine.State.driven.state.scheduler.policy; got != seeded { + t.Fatalf("the source's run draws under %s, want %s from its start", got, seeded) + } + + dst := imageInto(t, src, bulb) + copied, _ := dst.Instance(bulb.ID) + image, _ := copied.ExhibitedState() + run := image.State.driven.state + if run == nil || run.scheduler == nil { + t.Fatalf("the copy's machine has no run of its own: %v", run) + } + if run.scheduler.policy != seeded { + t.Errorf("the copy's run draws under %s, want %s as the source's does (the destination's is %s)", + run.scheduler.policy, seeded, dst.Schedule()) + } + if run.scheduler.pcg == nil || *run.scheduler.pcg != *machine.State.driven.state.scheduler.pcg { + t.Error("the copy's generator is not at the source's position") + } + if run.scheduler.rng == nil { + t.Error("the copy's seeded run has no generator to draw from") + } +} + +const drawingRollerSource = ` + private import ScalarValues::*; + private import RandomFunctions::*; + attribute def go; + state def Roller { + attribute first : Real = 0.0; + attribute second : Real = 0.0; + entry; then idle; + state idle; + transition idle_once first idle accept go do assign first := uniform(0.0, 1.0) then once; + state once; + transition once_twice first once accept go do assign second := uniform(0.0, 1.0) then twice; + state twice; + } + part def Die { exhibit state roll : Roller; } +` + +// rollerDraw is the value the die's machine holds under name. +func rollerDraw(t *testing.T, die *Instance, name string) Value { + t.Helper() + behavior, ok := die.ExhibitedState() + if !ok { + t.Fatalf("object #%d exhibits no machine", die.ID) + } + return behavior.State.StateData()[name] +} + +// A run's modeled draws continue in the copy where the source's stopped: the copy's +// next draw is the source's next, not its first over again, whatever the destination +// seeds — under the model seed and under a `seed:` schedule alike. +func TestHeldImageCarriesTheModeledStream(t *testing.T) { + seeds := map[string]func(*Context){ + "model seed": func(ctx *Context) { ctx.SetModelSeed(11) }, + "schedule seed": func(ctx *Context) { + mustSchedule(t, ctx, mustPolicy(t, "seed:7")) + }, + } + for name, seed := range seeds { + t.Run(name, func(t *testing.T) { + idx, _, src := buildRuntimeWithLibraries(t, "roller.sysml", parseAndBuild(t, drawingRollerSource)) + root := idx.DocumentRoot("roller.sysml") + seed(src) + die, err := src.Instantiate(resolveSymbol(t, root, "Die")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + dispatchTo(t, root, src, die, "go", nil) + first := rollerDraw(t, die, "first") + + dst := imageInto(t, src, die) + if _, set := dst.ModelSeed(); set || dst.Schedule() != DefaultSchedulePolicy { + t.Fatalf("the destination seeds its runs itself: %v", dst.Schedule()) + } + dispatchTo(t, root, src, die, "go", nil) + second := rollerDraw(t, die, "second") + if first == second { + t.Fatalf("the source drew %v twice", first) + } + + copied, _ := dst.Instance(die.ID) + dispatchTo(t, root, dst, copied, "go", nil) + if got := rollerDraw(t, copied, "second"); got != second { + t.Errorf("the copy drew %v for its second, want the source's %v (its first was %v)", got, second, first) + } + if draws := dst.DrawsTaken(); len(draws) != 1 || constValue(draws[0].Value) != second { + t.Errorf("the destination recorded %v, want the one draw the copy made", draws) + } + }) + } +} + +// A run following a witness is bound to its context: the image refuses it as +// ErrImageBound rather than restart the witness in the copy. +func TestHeldImageRefusesARunFollowingAWitness(t *testing.T) { + idx, _, src := buildRuntimeWithLibraries(t, "roller.sysml", parseAndBuild(t, drawingRollerSource)) + root := idx.DocumentRoot("roller.sysml") + src.SetModelSeed(11) + die, err := src.Instantiate(resolveSymbol(t, root, "Die")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + dispatchTo(t, root, src, die, "go", nil) + dispatchTo(t, root, src, die, "go", nil) + witness := Witness{Draws: src.DrawsTaken()} + + replaying := NewContext(src.Model(), 10000) + mustSchedule(t, replaying, ReplayOf(witness)) + replayed, err := replaying.Instantiate(resolveSymbol(t, root, "Die")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + dispatchTo(t, root, replaying, replayed, "go", nil) + if got, want := rollerDraw(t, replayed, "first"), rollerDraw(t, die, "first"); got != want { + t.Fatalf("the replay drew %v for its first, want the witness's %v", got, want) + } + var bound *HeldImageError + if _, err := replaying.Image(replayed); !errors.Is(err, ErrImageBound) || !errors.As(err, &bound) || bound.ID != replayed.ID { + t.Errorf("Image over a run following a witness = %v, want ErrImageBound naming #%d", err, replayed.ID) + } +} + +// A performed action parked at an accept is imaged with its token where it parked: +// the copy takes the message it awaits in the other context and writes its own object. +// Parking there is where the start left it, so the object stays pristine. +func TestHeldImageCarriesAParkedAction(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, waiterSource) + pkg := resolveSymbol(t, root, "test") + src := NewContext(typedModel(model, resolver), 10000) + waiter, err := src.Instantiate(resolveSymbol(t, pkg.Scope, "Waiter")) + if err != nil { + t.Fatalf("Instantiate Waiter: %v", err) + } + if err := src.Pristine(waiter); err != nil { + t.Fatalf("Pristine(waiter parked by its start) = %v, want admitted", err) + } + + dst := imageInto(t, src, waiter) + copied, _ := dst.Instance(waiter.ID) + behavior, ok := copied.Behavior("await") + if !ok || behavior.Action == nil { + t.Fatalf("the copy performs no await action, behaviors: %v", copied.Behaviors()) + } + if behavior.Action.State() != StateWaiting { + t.Fatalf("the copy's await is %v, want waiting at its accept", behavior.Action.State()) + } + tokens := behavior.Action.Tokens() + if len(tokens) != 1 || tokens[0].Wait == nil || tokens[0].Wait.ParamName != "g" { + t.Fatalf("the copy's tokens = %+v, want one parked at the accept of g", tokens) + } + + nine := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 9}} + dst.PostMessage(Message{SignalType: "Integer", Object: copied.ID, Value: &nine}) + if err := behavior.Action.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion(copy): %v", err) + } + if behavior.Action.State() != StateCompleted { + t.Errorf("the copy's await is %v after its message, want completed", behavior.Action.State()) + } + if got := featureInt(t, dst, copied, "woken"); got != 9 { + t.Errorf("the copy's woken = %d, want 9", got) + } + if got := featureInt(t, src, waiter, "woken"); got != 0 { + t.Errorf("the source's woken = %d after the copy's message, want 0", got) + } + if original, _ := waiter.Behavior("await"); original.Action.State() != StateWaiting { + t.Errorf("the source's await is %v, want still waiting", original.Action.State()) + } +} + +// lampMachine is the machine the bulb exhibits. +func lampMachine(t *testing.T, bulb *Instance) *StateExecutor { + t.Helper() + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatalf("object #%d exhibits no machine", bulb.ID) + } + return behavior.State +} + +// runTo posts a signal to the bulb and runs its machine to quiescence: a +// breakpoint pauses a run, not a single dispatch. +func runTo(t *testing.T, root *symbols.Scope, ctx *Context, bulb *Instance, signal string, args map[string]Value) { + t.Helper() + msg, err := ctx.SignalMessage(resolveSymbol(t, root, signal), args, bulb) + if err != nil { + t.Fatalf("SignalMessage(%s): %v", signal, err) + } + ctx.PostMessage(msg) + if err := lampMachine(t, bulb).RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence(%s): %v", signal, err) + } +} + +// A machine paused at a breakpoint is imaged as its debugger left it: the copy is +// suspended at the same breakpoint, keeps the breakpoints set and the transitions +// fired, and the destination's clock does not move it until it is resumed. A copy +// taken before the breakpoint is reached carries the breakpoint and pauses at it. +func TestHeldImageCarriesAPausedStateMachine(t *testing.T) { + root, src, bulb := lampBulb(t) + machine := lampMachine(t, bulb) + dimmed := stateNamed(t, machine, "dimmed") + machine.SetBreakpointAt(dimmed) + machine.KeepFired(true) + level := map[string]Value{"level": integerValue(7)} + + early := imageInto(t, src, bulb) + earlyCopy, _ := early.Instance(bulb.ID) + runTo(t, root, early, earlyCopy, "go", nil) + runTo(t, root, early, earlyCopy, "Dim", level) + if got := lampMachine(t, earlyCopy).PausedAt(); got != dimmed { + t.Errorf("the copy imaged before the breakpoint paused at %v, want dimmed: the breakpoint was not carried", got) + } + + runTo(t, root, src, bulb, "go", nil) + runTo(t, root, src, bulb, "Dim", level) + if got := machine.PausedAt(); got != dimmed || machine.State() != StateSuspended { + t.Fatalf("the source paused at %v in state %v, want dimmed, suspended", got, machine.State()) + } + fired := machine.FiredTransitions() + + dst := imageInto(t, src, bulb) + copied, _ := dst.Instance(bulb.ID) + imaged := lampMachine(t, copied) + if imaged.State() != StateSuspended || imaged.PausedAt() != dimmed { + t.Errorf("the copy is %v paused at %v, want suspended at dimmed as imaged", imaged.State(), imaged.PausedAt()) + } + if got := imaged.FiredTransitions(); !slices.Equal(got, fired) || imaged.FiredCount() != len(fired) { + t.Errorf("the copy fired %v, want the source's %v", got, fired) + } + if got := imaged.FiredSince(len(fired) - 1); len(got) != 1 || got[0] != fired[len(fired)-1] { + t.Errorf("FiredSince(last) = %v, want the last firing alone", got) + } + if got := imaged.FiredTransitions(); len(got) != 1 || imaged.FiredCount() != len(fired) { + t.Errorf("after FiredSince(last) the copy keeps %v of %d, want the last firing alone, still counting %d", got, imaged.FiredCount(), len(fired)) + } + + if _, err := dst.Advance(5); err != nil { + t.Fatalf("Advance(imaged): %v", err) + } + if got := lampLeaf(t, copied); got != "dimmed" { + t.Errorf("the paused copy moved to %s with the clock, want held at dimmed until resumed", got) + } + if err := imaged.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence(imaged): %v", err) + } + if got := lampLeaf(t, copied); got != "off" { + t.Errorf("the resumed copy is at %s, want off after 5 s", got) + } + if imaged.PausedAt() != nil { + t.Errorf("the resumed copy still reads paused at %v", imaged.PausedAt()) + } + if imaged.FiredCount() != len(fired)+1 { + t.Errorf("the copy fired %d transitions, want the imaged %d and dim_out", imaged.FiredCount(), len(fired)) + } + + // The copy resumed from the breakpoint it was imaged at; the source holds it still. + if got := lampLeaf(t, bulb); got != "dimmed" || machine.PausedAt() != dimmed { + t.Errorf("the source is at %s paused at %v, want dimmed, paused at dimmed", got, machine.PausedAt()) + } + if machine.FiredCount() != len(fired) { + t.Errorf("the source fired %d transitions after the copy resumed, want %d", machine.FiredCount(), len(fired)) + } + runTo(t, root, dst, copied, "go", nil) + runTo(t, root, dst, copied, "Dim", level) + if imaged.PausedAt() != dimmed { + t.Errorf("the copy reached dimmed again without pausing: the breakpoint was not carried") + } +} + +// An action paused at a breakpoint set by identity is imaged as its debugger left +// it: the copy is suspended at the same node in its flow, keeps the breakpoint and +// the successions kept and counted, and runs on from the pause independently of +// the source. +func TestHeldImageCarriesAPausedAction(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, waiterSource) + pkg := resolveSymbol(t, root, "test") + src := NewContext(typedModel(model, resolver), 10000) + waiter, err := src.Instantiate(resolveSymbol(t, pkg.Scope, "Waiter")) + if err != nil { + t.Fatalf("Instantiate Waiter: %v", err) + } + await := func(t *testing.T, obj *Instance) *ActionExecutor { + t.Helper() + behavior, ok := obj.Behavior("await") + if !ok || behavior.Action == nil { + t.Fatalf("object #%d performs no await action, behaviors: %v", obj.ID, obj.Behaviors()) + } + return behavior.Action + } + nine := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 9}} + action := await(t, waiter) + mark := actionNodeNamed(t, action, "mark") + action.ReplaceBreakpointsAt([]NodeBreakpoint{{Node: mark}}) + action.KeepTraversals(true) + + early := imageInto(t, src, waiter) + earlyCopy, _ := early.Instance(waiter.ID) + early.PostMessage(Message{SignalType: "Integer", Object: earlyCopy.ID, Value: &nine}) + if err := await(t, earlyCopy).RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion(early copy): %v", err) + } + if bp, ok := await(t, earlyCopy).PausedBreakpoint(); !ok || bp.Node != mark { + t.Errorf("the copy imaged before the breakpoint paused at %+v, %v; want mark: the breakpoint was not carried", bp, ok) + } + + src.PostMessage(Message{SignalType: "Integer", Object: waiter.ID, Value: &nine}) + if err := action.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if bp, ok := action.PausedBreakpoint(); !ok || bp.Node != mark || action.State() != StateSuspended { + t.Fatalf("the source paused at %+v, %v in state %v; want mark, suspended", bp, ok, action.State()) + } + // start then heard was taken before the debugger asked for the record, so only + // heard then mark is kept, though both are counted. + traversals, taken := action.Traversals(), action.TraversalCount() + if len(traversals) != 1 || taken != 2 { + t.Fatalf("the source keeps %d of %d successions, want heard then mark of start then heard, heard then mark", len(traversals), taken) + } + + dst := imageInto(t, src, waiter) + copied, _ := dst.Instance(waiter.ID) + imaged := await(t, copied) + if imaged.State() != StateSuspended || imaged.PausedAt() != "mark" { + t.Errorf("the copy is %v paused at %q, want suspended at mark as imaged", imaged.State(), imaged.PausedAt()) + } + if bp, ok := imaged.PausedBreakpoint(); !ok || bp.Node != mark || len(bp.Within) != 0 { + t.Errorf("the copy's PausedBreakpoint() = %+v, %v, want mark in the action's own flow", bp, ok) + } + if got := imaged.Traversals(); imaged.TraversalCount() != taken || !traversalsEqual(got, traversals) { + t.Errorf("the copy took %v of %d, want the source's %v of %d", got, imaged.TraversalCount(), traversals, taken) + } + if got := imaged.TraversalsSince(1); len(got) != 1 || got[0].Edge != traversals[0].Edge { + t.Errorf("TraversalsSince(1) = %v, want the last succession alone", got) + } + if got := imaged.TraversalsSince(taken); got != nil || len(imaged.Traversals()) != 0 || imaged.TraversalCount() != taken { + t.Errorf("TraversalsSince(%d) = %v leaving %d kept of %d, want nil, none, %d", taken, got, len(imaged.Traversals()), imaged.TraversalCount(), taken) + } + + if err := imaged.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion(imaged): %v", err) + } + if imaged.State() != StateCompleted { + t.Errorf("the resumed copy is %v, want completed", imaged.State()) + } + if _, ok := imaged.PausedBreakpoint(); ok { + t.Error("the completed copy still reads paused") + } + if got := featureInt(t, dst, copied, "woken"); got != 9 { + t.Errorf("the copy's woken = %d, want 9", got) + } + if got := imaged.Traversals(); imaged.TraversalCount() != taken+1 || len(got) != 1 || ActionNodeName(got[0].Edge.Source) != "mark" { + t.Errorf("the copy took %d successions keeping %v, want the imaged %d and mark then done, keeping that one", imaged.TraversalCount(), got, taken) + } + if action.State() != StateSuspended || action.TraversalCount() != taken { + t.Errorf("the source is %v after %d successions once the copy ran on, want still suspended after %d", action.State(), action.TraversalCount(), taken) + } + if got := featureInt(t, src, waiter, "woken"); got != 0 { + t.Errorf("the source's woken = %d, want 0", got) + } +} + +// traversalsEqual reports whether a and b record the same successions by the same tokens. +func traversalsEqual(a, b []Traversal) bool { + return slices.EqualFunc(a, b, func(x, y Traversal) bool { + return x.Token == y.Token && x.Edge == y.Edge && slices.Equal(x.Within, y.Within) + }) +} + +// An image is refused, by its typed reason, where the state cannot be carried: a +// context inside a step, an object of another context, a destination holding the +// identity or past the image's instant, a feature being written, a value of a body, a destroyed root. +func TestHeldImageRefusesWhatItCannotCarry(t *testing.T) { + root, src, bulb := lampBulb(t) + src.runDepth++ + if _, err := src.Image(bulb); !errors.Is(err, ErrSnapshotMidRun) { + t.Errorf("Image inside a step = %v, want ErrSnapshotMidRun", err) + } + src.runDepth-- + + other := NewContext(src.Model(), 10000) + if _, err := other.Image(bulb); !errors.Is(err, ErrImageRoot) { + t.Errorf("Image of another context's object = %v, want ErrImageRoot", err) + } + + img, err := src.Image(bulb) + if err != nil { + t.Fatalf("Image: %v", err) + } + taken := imageInto(t, src, bulb) + var hie *HeldImageError + if err := img.Materialize(taken); !errors.Is(err, ErrImageIdentityTaken) || !errors.As(err, &hie) || hie.ID != bulb.ID { + t.Errorf("Materialize over a held identity = %v, want ErrImageIdentityTaken naming #%d", err, bulb.ID) + } + if err := img.Materialize(src); !errors.Is(err, ErrImageIdentityTaken) { + t.Errorf("Materialize into the source = %v, want ErrImageIdentityTaken", err) + } + ahead := NewContext(src.Model(), 10000) + ahead.clock.now = 5 + if err := img.Materialize(ahead); !errors.Is(err, ErrImageClock) { + t.Errorf("Materialize into a context at t=5 = %v, want ErrImageClock", err) + } + if n := len(ahead.instances); n != 0 { + t.Errorf("the refused materialization left %d objects behind", n) + } + + // A feature being written is state bound to the context writing it. + src.instances[bulb.ID].FeatureValues["lamp"].changing = true + if _, err := src.Image(bulb); !errors.Is(err, ErrImageBound) || !errors.As(err, &hie) || hie.ID != bulb.ID { + t.Errorf("Image over a feature being written = %v, want ErrImageBound naming #%d", err, bulb.ID) + } + src.instances[bulb.ID].FeatureValues["lamp"].changing = false + + // A value closed over the run that made it names what no other context holds. + inBody := Value{Kind: ValFunction, ref: &functionValue{ + shape: &calcShape{Sym: &symbols.Symbol{Name: "inBody"}, Name: "inBody"}, + enclosing: []frame{{vars: map[string]Value{"k": integerValue(1)}, run: 1}}, + }} + src.instances[bulb.ID].FeatureValues["lamp"].Value = inBody + var notPortable *NotPortableError + if _, err := src.Image(bulb); !errors.As(err, ¬Portable) || !errors.As(err, &hie) || hie.ID != bulb.ID { + t.Errorf("Image over a function of a body = %v, want a NotPortableError naming #%d", err, bulb.ID) + } + + // A destroyed object is no longer one to sweep on: the image refuses it as a root. + plain, err := src.Instantiate(resolveSymbol(t, root, "Plain")) + if err != nil { + t.Fatalf("Instantiate(Plain): %v", err) + } + if err := src.destroy(plain); err != nil { + t.Fatalf("destroy: %v", err) + } + if _, err := src.Image(plain); !errors.Is(err, ErrOccurrenceDestroyed) || !errors.As(err, &hie) || hie.ID != plain.ID { + t.Errorf("Image of a destroyed object = %v, want ErrOccurrenceDestroyed naming #%d", err, plain.ID) + } +} + +// A message in flight naming an object the context does not hold — as its event +// occurrence, or the port it reached — is ErrImageRoot naming that object, whether the +// message is bound for an object of the closure or open to any consumer; the image +// panics on none of them. +func TestHeldImageRefusesAMessageNamingAnObjectNotHeld(t *testing.T) { + root, src, bulb := lampBulb(t) + dispatchTo(t, root, src, bulb, "go", nil) + sound := slices.Clone(src.messages) + for _, tc := range []struct { + name string + msg Message + }{ + {"event occurrence of a message to the bulb", Message{Object: bulb.ID, SignalType: "go", EventObject: 404}}, + {"port of a message to the bulb", Message{Object: bulb.ID, SignalType: "go", PortID: 404}}, + {"event occurrence of an open message", Message{SignalType: "go", EventObject: 404}}, + {"port of an open message", Message{SignalType: "go", PortID: 404}}, + } { + src.messages = append(slices.Clone(sound), tc.msg) + _, err := src.Image(bulb) + var hie *HeldImageError + if !errors.Is(err, ErrImageRoot) || !errors.As(err, &hie) || !strings.Contains(err.Error(), "object #404") { + t.Errorf("Image with the %s not held = %v, want ErrImageRoot naming #404", tc.name, err) + } + } + src.messages = sound + if _, err := src.Image(bulb); err != nil { + t.Errorf("Image with the messages as they were = %v, want taken", err) + } +} + +// The messages open to any consumer are the shared bus, which only an execution +// reads: the image of a closure running no behavior leaves them be, so an open message +// carrying what no image can — or naming objects of its own — does not keep a written +// plain object from sweeping; a closure with an execution carries them as before. +func TestHeldImageOfABehaviorlessClosureLeavesTheBusBe(t *testing.T) { + root, src, bulb := lampBulb(t) + plain, err := src.Instantiate(resolveSymbol(t, root, "Plain")) + if err != nil { + t.Fatalf("Instantiate(Plain): %v", err) + } + inBody := Value{Kind: ValFunction, ref: &functionValue{ + shape: &calcShape{Sym: &symbols.Symbol{Name: "inBody"}, Name: "inBody"}, + enclosing: []frame{{vars: map[string]Value{"k": integerValue(1)}, run: 1}}, + }} + src.PostMessage(Message{SignalType: "go", Payload: map[string]Value{"k": inBody}}) + src.PostMessage(Message{SignalType: "go", EventObject: bulb.ID}) + + img, err := src.Image(plain) + if err != nil { + t.Fatalf("Image of a plain object beside an open message it cannot carry = %v, want taken", err) + } + if len(img.messages) != 0 || img.Holds(bulb.ID) { + t.Errorf("the plain object's image carries %d messages and holds the bulb: %v; want none and no", len(img.messages), img.Holds(bulb.ID)) + } + dst := NewContext(src.Model(), 10000) + if err := img.Materialize(dst); err != nil { + t.Fatalf("Materialize: %v", err) + } + if n := len(dst.messages); n != 0 { + t.Errorf("the destination has %d messages in flight, want none", n) + } + + var notPortable *NotPortableError + if _, err := src.Image(bulb); !errors.As(err, ¬Portable) { + t.Errorf("Image of the bulb, whose machine reads the bus = %v, want a NotPortableError", err) + } +} + +// A materialization that fails after its objects stand — here on the last message +// carried — leaves the destination as it found it: no object, behavior, identity, +// counter, clock or run of the image stays behind, and the image goes in whole next time. +func TestHeldImageMaterializeFailsWhole(t *testing.T) { + root, src, bulb := lampBulb(t) + dispatchTo(t, root, src, bulb, "go", nil) + if _, err := src.Advance(2); err != nil { + t.Fatalf("Advance: %v", err) + } + img, err := src.Image(bulb) + if err != nil { + t.Fatalf("Image: %v", err) + } + if len(img.behaviors) == 0 || img.clock != 2 { + t.Fatalf("the image carries %d behaviors at t=%v, want a machine at t=2", len(img.behaviors), img.clock) + } + sound := img.messages + inBody := Value{Kind: ValFunction, ref: &functionValue{ + shape: &calcShape{Sym: &symbols.Symbol{Name: "inBody"}, Name: "inBody"}, + enclosing: []frame{{vars: map[string]Value{"k": integerValue(1)}, run: 1}}, + }} + img.messages = append(slices.Clone(sound), Message{Object: bulb.ID, SignalType: "go", Payload: map[string]Value{"k": inBody}}) + + dst := NewContext(src.Model(), 10000) + before := destinationStateOf(dst) + var notPortable *NotPortableError + if err := img.Materialize(dst); !errors.As(err, ¬Portable) { + t.Fatalf("Materialize with a message it cannot carry = %v, want a NotPortableError", err) + } + if after := destinationStateOf(dst); after != before { + t.Errorf("the failed materialization changed the destination:\n before %+v\n after %+v", before, after) + } + + // A destination on a sequence another context took ahead of it holds what it + // took, not the sequence's high-water mark, once the failed materialization is undone. + ahead := NewContext(src.Model(), 10000) + ahead.claimID(99) + shared := NewContext(src.Model(), 10000) + shared.AdoptIdentities(ahead) + if shared.ids.next != 100 || shared.took.high != 1 { + t.Fatalf("the shared sequence is at %d with the destination's mark at %d, want 100 and 1", shared.ids.next, shared.took.high) + } + before = destinationStateOf(shared) + if err := img.Materialize(shared); !errors.As(err, ¬Portable) { + t.Fatalf("Materialize into the shared sequence = %v, want a NotPortableError", err) + } + if after := destinationStateOf(shared); after != before { + t.Errorf("the failed materialization changed the destination on a shared sequence:\n before %+v\n after %+v", before, after) + } + + img.messages = sound + if err := img.Materialize(dst); err != nil { + t.Fatalf("Materialize after the failure: %v", err) + } + copied, ok := dst.Instance(bulb.ID) + if !ok { + t.Fatalf("the destination holds no #%d", bulb.ID) + } + if got := lampLeaf(t, copied); got != "on" { + t.Errorf("the copy's state = %s, want on", got) + } + if dst.clock.now != 2 { + t.Errorf("the destination's clock = %v, want 2", dst.clock.now) + } +} + +// The identities a carry-over set aside for connectors not materialized again are the +// image's too: the destination hands none of them out, so a connector asked for after +// the materialization takes its identity back; a destination already holding an object +// under one refuses the image whole. +func TestHeldImageKeepsTheIdentitiesSetAsideForConnectors(t *testing.T) { + src := strings.Replace(adoptConnectSrc, "connect a.p to b.q;", "connect a.p to b.q; connection c connect a.p to b.q;", 1) + prev := contextOver(t, src) + obj, err := prev.Instantiate(lookupOne(t, prev.Resolver().Index(), "Demo::Sys")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if _, err := obj.OwnedConnectors(prev); err != nil { + t.Fatalf("OwnedConnectors: %v", err) + } + fvInstance(t, prev, obj, "c") + shapes := prev.ShapesOf(obj) + ctx := contextOver(t, src+"\npart def Widget;") + if _, err := ctx.Adopt(prev, shapes, obj); err != nil { + t.Fatalf("Adopt: %v", err) + } + kept := obj.KeptConnectorIDs() + if len(kept) != 2 { + t.Fatalf("KeptConnectorIDs() = %v after the carry-over, want the two connectors", kept) + } + img, err := ctx.Image(obj) + if err != nil { + t.Fatalf("Image: %v", err) + } + widget := lookupOne(t, ctx.Resolver().Index(), "Widget") + + dst := NewContext(ctx.Model(), 10000) + if err := img.Materialize(dst); err != nil { + t.Fatalf("Materialize: %v", err) + } + copied, ok := dst.Instance(obj.ID) + if !ok { + t.Fatalf("the destination holds no #%d", obj.ID) + } + if got := copied.KeptConnectorIDs(); !slices.Equal(got, kept) { + t.Fatalf("the copy's KeptConnectorIDs() = %v, want %v", got, kept) + } + for range kept { + made, err := dst.Instantiate(widget) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if slices.Contains(kept, made.ID) { + t.Errorf("the destination handed out #%d, an identity set aside for a connector", made.ID) + } + } + for _, id := range kept { + conn, err := copied.RestoreConnector(dst, id) + if err != nil { + t.Fatalf("RestoreConnector(%d): %v", id, err) + } + if conn == nil || conn.ID != id || len(conn.Ends) != 2 { + t.Fatalf("RestoreConnector(%d) = %v, want the connector under that identity", id, conn) + } + port := fvInstance(t, dst, copied, "a", "p") + if end := conn.Ends[0].Value; !holdsObject(end, port.ID) { + t.Errorf("connector %d's end holds %v, want the port object %d of the copy", id, end, port.ID) + } + } + + taken := NewContext(ctx.Model(), 10000) + taken.claimID(kept[0] - 1) + holder, err := taken.Instantiate(widget) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if holder.ID != kept[0] { + t.Fatalf("the holder is #%d, want #%d", holder.ID, kept[0]) + } + before := destinationStateOf(taken) + err = img.Materialize(taken) + var imageErr *HeldImageError + if !errors.Is(err, ErrImageIdentityTaken) || !errors.As(err, &imageErr) || imageErr.ID != obj.ID { + t.Fatalf("Materialize into a context holding #%d = %v, want ErrImageIdentityTaken about #%d", kept[0], err, obj.ID) + } + if !strings.Contains(err.Error(), fmt.Sprintf("connector #%d set aside", kept[0])) { + t.Errorf("Materialize = %v, want it to name connector #%d", err, kept[0]) + } + if after := destinationStateOf(taken); after != before { + t.Errorf("the refused materialization changed the destination:\n before %+v\n after %+v", before, after) + } +} + +// An identity the destination set aside for a connector is held though no object is +// under it yet: an image whose object has that identity is refused, so the connector +// takes its identity back when asked for. +func TestHeldImageRefusesAnIdentitySetAsideByTheDestination(t *testing.T) { + src := strings.Replace(adoptConnectSrc, "connect a.p to b.q;", "connect a.p to b.q; connection c connect a.p to b.q;", 1) + prev := contextOver(t, src) + obj, err := prev.Instantiate(lookupOne(t, prev.Resolver().Index(), "Demo::Sys")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if _, err := obj.OwnedConnectors(prev); err != nil { + t.Fatalf("OwnedConnectors: %v", err) + } + fvInstance(t, prev, obj, "c") + dst := contextOver(t, src+"\npart def Widget;") + if _, err := dst.Adopt(prev, prev.ShapesOf(obj), obj); err != nil { + t.Fatalf("Adopt: %v", err) + } + kept := obj.KeptConnectorIDs() + if len(kept) != 2 { + t.Fatalf("KeptConnectorIDs() = %v after the carry-over, want the two connectors", kept) + } + if _, held := dst.Instance(kept[0]); held { + t.Fatalf("the destination holds an object under #%d, want the identity set aside only", kept[0]) + } + + other := NewContext(dst.Model(), 10000) + other.claimID(kept[0] - 1) + widget, err := other.Instantiate(lookupOne(t, dst.Resolver().Index(), "Widget")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if widget.ID != kept[0] { + t.Fatalf("the widget is #%d, want #%d", widget.ID, kept[0]) + } + img, err := other.Image(widget) + if err != nil { + t.Fatalf("Image: %v", err) + } + before := destinationStateOf(dst) + err = img.Materialize(dst) + var imageErr *HeldImageError + if !errors.Is(err, ErrImageIdentityTaken) || !errors.As(err, &imageErr) || imageErr.ID != widget.ID { + t.Fatalf("Materialize over an identity set aside = %v, want ErrImageIdentityTaken about #%d", err, widget.ID) + } + if after := destinationStateOf(dst); after != before { + t.Errorf("the refused materialization changed the destination:\n before %+v\n after %+v", before, after) + } + conn, err := obj.RestoreConnector(dst, kept[0]) + if err != nil { + t.Fatalf("RestoreConnector(%d): %v", kept[0], err) + } + if conn == nil || conn.ID != kept[0] { + t.Fatalf("RestoreConnector(%d) = %v, want the connector under that identity", kept[0], conn) + } +} + +// A usage the image has denote an object of its own is refused where the destination +// already has it denote another live object; a destination not yet holding one has the +// usage denote the copy. +func TestHeldImageRefusesAUsageDenotingAnotherObjectOfTheDestination(t *testing.T) { + root, src, _ := lampBulb(t) + usage := resolveSymbol(t, root, "plain") + plain, err := src.Instantiate(usage) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if got := src.OccurrenceUsage(plain); got != "plain" { + t.Fatalf("OccurrenceUsage(#%d) = %q, want plain", plain.ID, got) + } + img, err := src.Image(plain) + if err != nil { + t.Fatalf("Image: %v", err) + } + + dst := NewContext(src.Model(), 10000) + dst.claimID(100) + own, err := dst.Instantiate(usage) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + before := destinationStateOf(dst) + err = img.Materialize(dst) + var imageErr *HeldImageError + if !errors.Is(err, ErrImageBindingTaken) || !errors.As(err, &imageErr) || imageErr.ID != plain.ID { + t.Fatalf("Materialize where plain denotes #%d = %v, want ErrImageBindingTaken about #%d", own.ID, err, plain.ID) + } + if !strings.Contains(err.Error(), fmt.Sprintf("usage plain denotes #%d", own.ID)) { + t.Errorf("Materialize = %v, want it to name the usage and #%d", err, own.ID) + } + if after := destinationStateOf(dst); after != before { + t.Errorf("the refused materialization changed the destination:\n before %+v\n after %+v", before, after) + } + if got := dst.OccurrenceUsage(own); got != "plain" { + t.Errorf("OccurrenceUsage(#%d) = %q after the refusal, want plain", own.ID, got) + } + + free := NewContext(src.Model(), 10000) + if err := img.Materialize(free); err != nil { + t.Fatalf("Materialize into a context not holding plain: %v", err) + } + copied, ok := free.Instance(plain.ID) + if !ok { + t.Fatalf("the destination holds no #%d", plain.ID) + } + if got := free.OccurrenceUsage(copied); got != "plain" { + t.Errorf("OccurrenceUsage(copy) = %q, want plain", got) + } +} + +// destinationState is every part of a context a materialization writes, as one value to compare. +type destinationState struct { + instances, created, lives, behaviors, messages, occurrences, metadata, variants, selected int + onClock int + nextID, tookHigh, activations, runs int64 + clock float64 + clockRun *runState +} + +func destinationStateOf(ctx *Context) destinationState { + return destinationState{ + instances: len(ctx.instances), created: len(ctx.created), lives: len(ctx.lives), behaviors: len(ctx.objectBehaviors), + messages: len(ctx.messages), occurrences: len(ctx.occurrences), metadata: len(ctx.metadataObjects), + variants: len(ctx.variantObjects), selected: len(ctx.selectedVariants), + onClock: len(ctx.clock.waiters), + nextID: ctx.ids.next, tookHigh: ctx.took.high, activations: ctx.activations, runs: ctx.runs, + clock: ctx.clock.now, clockRun: ctx.clockRun.state, + } +} + +// An object whose start left a do action's body paused at its accept is refused +// by the portable image, whose model may not be the one the body's statements are +// of; the in-place snapshot captures the wait and restores the body to it. +func TestHeldImageRefusesAPausedBody(t *testing.T) { + const source = ` + private import SI::*; + state def Watching { + entry; then watching; + state watching { + do action poll { + first start; + then action wait accept after 3 [s]; + then done; + } + } + } + part def Watcher { exhibit state w : Watching; } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "watcher.sysml", parseAndBuild(t, source)) + watcher, err := ctx.Instantiate(resolveSymbol(t, idx.DocumentRoot("watcher.sysml"), "Watcher")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if err := ctx.Pristine(watcher); err != nil { + t.Fatalf("Pristine = %v, want the watcher as its start left it", err) + } + var hie *HeldImageError + _, err = ctx.Image(watcher) + if !errors.Is(err, ErrSnapshotPausedBody) || !errors.As(err, &hie) || hie.ID != watcher.ID { + t.Fatalf("Image = %v, want ErrSnapshotPausedBody naming #%d", err, watcher.ID) + } + snapshot, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot = %v, want the paused body captured", err) + } + pausedDo := func() *doRun { + for _, behavior := range ctx.objectBehaviors { + if behavior.State != nil { + for _, act := range behavior.State.doActions { + return act.run + } + } + } + return nil + } + if run := pausedDo(); run == nil || len(run.body.cursor) == 0 || !run.body.paused.onWait { + t.Fatalf("do body = %+v, want paused on its wait", run) + } + if _, err := ctx.Advance(3); err != nil { + t.Fatalf("Advance = %v", err) + } + if run := pausedDo(); run != nil { + t.Fatalf("do body after the wait = %+v, want done", run) + } + snapshot.Restore() + if run := pausedDo(); run == nil || !run.body.paused.onWait { + t.Fatalf("restored do body = %+v, want paused on its wait again", run) + } + if _, err := ctx.Advance(3); err != nil { + t.Fatalf("Advance after the restore = %v", err) + } + if run := pausedDo(); run != nil { + t.Fatalf("restored do body after the wait = %+v, want done", run) + } + snapshot.Release() +} + +// heldDigest renders the objects held under ids, their lifetimes, features, +// behaviors and executions, so a copy can be compared with what it was taken from. +func heldDigest(ctx *Context, held func(id int64) bool) string { + var b strings.Builder + for _, id := range ctx.created { + inst := ctx.instances[id] + if inst == nil || !held(id) { + continue + } + owner := int64(0) + if inst.owner != nil { + owner = inst.owner.ID + } + fmt.Fprintf(&b, "#%d %s owner=#%d.%s life=%+v ends=%d\n", id, symbolText(inst.Type), owner, inst.ownerFeature, ctx.lives[id], len(inst.Ends)) + for _, name := range slices.Sorted(maps.Keys(inst.FeatureValues)) { + fv := inst.FeatureValues[name] + fmt.Fprintf(&b, " %s = %s written=%t materialized=%t\n", name, FormatValue(fv.HeldValue()), fv.Written, fv.Materialized) + } + for _, behavior := range inst.behaviors { + fmt.Fprintf(&b, " behavior %s %s moved=%t\n", behavior.Kind, behavior.Name, behavior.Moved()) + if exec := behavior.Action; exec != nil { + fmt.Fprintf(&b, " action state=%v inputs=%s\n", exec.State(), formatValues(exec.inputs)) + for _, token := range exec.Tokens() { + fmt.Fprintf(&b, " token %d at %s wait=%+v\n", token.ID, ActionNodeName(token.Location), token.Wait) + } + } + if exec := behavior.State; exec != nil { + var active []string + for _, state := range exec.ActiveStates() { + active = append(active, StateVertexName(state)) + } + fmt.Fprintf(&b, " state=%v active=%v queue=%d deferred=%d data=%s\n", + exec.State(), active, exec.eventQueue.Len(), len(exec.deferred), formatValues(exec.StateData())) + } + } + } + fmt.Fprintf(&b, "clock=%v\n", ctx.clock.now) + for _, msg := range ctx.messages { + if msg.Object == 0 || held(msg.Object) { + fmt.Fprintf(&b, "message %s -> %s #%d\n", msg.SignalType, msg.Target, msg.Object) + } + } + return b.String() +} + +// TestHeldImageRoundTrip images the object graph of every instance conformance case +// as its materialization left it, materializes the image into another context, and +// runs the case's object machines in both: the same graph, trace and error either +// side, the source untouched by the copy's run. No instance case is refused; the +// refusals are pinned by TestHeldImageRefusesWhatItCannotCarry. +func TestHeldImageRoundTrip(t *testing.T) { + forEachConformanceCase(t, func(t *testing.T, conformanceDir, testName string, expected ExpectedOutcome) { + if expected.Type != "instance" { + t.Skip("not an instance case") + } + ctx, idx, _ := loadTraceCase(t, conformanceDir, testName, expected, DefaultSchedulePolicy) + typeSym := oneSymbol(t, idx, expected.Instantiate) + first, err := ctx.Instantiate(typeSym) + if err != nil { + t.Skipf("materialization fails, so nothing is held: %v", err) + } + objects := materializations(t, ctx, typeSym, first, expected.Objects) + + img, err := ctx.Image(objects...) + if err != nil { + t.Fatalf("Image as materialized: %v", err) + } + dst := NewContext(ctx.Model(), 10000) + mustSchedule(t, dst, casePolicy(t, expected, DefaultSchedulePolicy)) + if err := img.Materialize(dst); err != nil { + t.Fatalf("Materialize: %v", err) + } + before := heldDigest(ctx, img.Holds) + if got := heldDigest(dst, everyObject); got != before { + t.Fatalf("the copy differs from the graph imaged, line %d\n=== SOURCE ===\n%s\n=== COPY ===\n%s", firstDifferingLine(before, got), before, got) + } + if dst.ids.next <= ctx.ids.next-1 && len(img.objects) > 0 && dst.ids.next <= img.objects[len(img.objects)-1].id { + t.Fatalf("the copy's next identity %d is not past the imaged objects", dst.ids.next) + } + + // The copy's run leaves the source as imaged. + copyTrace := NewTraceRecorder() + dst.SetTrace(copyTrace) + copyErr := runImagedMachines(t, dst, objects, expected) + if got := heldDigest(ctx, img.Holds); got != before { + t.Fatalf("the copy's run wrote the source, line %d\n=== BEFORE ===\n%s\n=== AFTER ===\n%s", firstDifferingLine(before, got), before, got) + } + sourceTrace := NewTraceRecorder() + ctx.SetTrace(sourceTrace) + sourceErr := runImagedMachines(t, ctx, objects, expected) + want := roundTripOutcome{trace: sourceTrace.String(), values: heldDigest(ctx, everyObject), err: errorText(sourceErr)} + got := roundTripOutcome{trace: copyTrace.String(), values: heldDigest(dst, everyObject), err: errorText(copyErr)} + if got != want { + t.Fatalf("the copy's run differs from the source's\n%s", outcomeDiff(want, got)) + } + + // The graph the run left, moved executions and all, images too. + ran, err := ctx.Image(objects...) + if err != nil { + t.Fatalf("Image after the run: %v", err) + } + later := NewContext(ctx.Model(), 10000) + mustSchedule(t, later, casePolicy(t, expected, DefaultSchedulePolicy)) + if err := ran.Materialize(later); err != nil { + t.Fatalf("Materialize after the run: %v", err) + } + if got := heldDigest(later, everyObject); got != want.values { + t.Fatalf("the copy of the graph the run left differs, line %d\n=== SOURCE ===\n%s\n=== COPY ===\n%s", firstDifferingLine(want.values, got), want.values, got) + } + }) +} + +// runImagedMachines runs the case's object machines on the objects ctx holds under +// the given objects' identities, as runObjectMachines does on the objects themselves. +func runImagedMachines(t *testing.T, ctx *Context, objects []*Instance, expected ExpectedOutcome) error { + t.Helper() + for _, run := range expected.Objects { + obj, held := ctx.Instance(objects[instanceIndexOf(run)].ID) + if !held { + t.Fatalf("the context holds no object #%d", objects[instanceIndexOf(run)].ID) + } + if run.Path != "" { + obj = instanceAtPath(t, ctx, obj, run.Path) + } + exec := objectMachine(t, obj, run.Behavior) + injectEvents(t, exec, run.Events) + if err := exec.RunToCompletion(); err != nil { + return err + } + } + return nil +} + +// everyObject holds every identity: the digest of a whole context. +func everyObject(int64) bool { return true } + +func errorText(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +// One image serves two sweeps at once: each on a goroutine of its own materializes it +// into eight contexts, each over a model of its own on the shared index as the parallel +// sweep's jobs are, and runs the copies concurrently; the source — the digest of its +// objects and the state of its machine — is as it was before. +func TestHeldImageServesConcurrentSweeps(t *testing.T) { + idx, _, src := buildRuntimeWithLibraries(t, "lamp.sysml", parseAndBuild(t, lampSource)) + root := idx.DocumentRoot("lamp.sysml") + bulb, err := src.Instantiate(resolveSymbol(t, root, "Bulb")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + dispatchTo(t, root, src, bulb, "go", nil) + img, err := src.Image(bulb) + if err != nil { + t.Fatalf("Image: %v", err) + } + before := heldDigest(src, everyObject) + dim := resolveSymbol(t, root, "Dim") + + var wg sync.WaitGroup + errs := make(chan error, 16) + for sweep := 0; sweep < 2; sweep++ { + for row := 0; row < 8; row++ { + wg.Add(1) + go func(level int64) { + defer wg.Done() + resolver := resolve.New(idx) + dst := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) + if err := img.Materialize(dst); err != nil { + errs <- fmt.Errorf("Materialize: %w", err) + return + } + copied, held := dst.Instance(bulb.ID) + if !held { + errs <- fmt.Errorf("the copy holds no #%d", bulb.ID) + return + } + msg, err := dst.SignalMessage(dim, map[string]Value{"level": integerValue(level)}, copied) + if err != nil { + errs <- err + return + } + dst.PostMessage(msg) + behavior, _ := copied.ExhibitedState() + if err := behavior.State.ProcessNextEvent(); err != nil { + errs <- err + return + } + if got := FormatValue(behavior.State.StateData()["brightness"]); got != fmt.Sprint(level) { + errs <- fmt.Errorf("a row dimmed to %d reads brightness %s", level, got) + } + }(int64(sweep*8 + row + 1)) + } + } + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + if got := lampLeaf(t, bulb); got != "on" { + t.Errorf("the source is at %s after the sweeps, want on", got) + } + if after := heldDigest(src, everyObject); after != before { + t.Errorf("the sweeps changed the source:\n%s\nwas\n%s", after, before) + } +} diff --git a/internal/core/runtime/history_test.go b/internal/exec/runtime/history_test.go similarity index 98% rename from internal/core/runtime/history_test.go rename to internal/exec/runtime/history_test.go index 61891f2c29..a925618ab8 100644 --- a/internal/core/runtime/history_test.go +++ b/internal/exec/runtime/history_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // transitionBetween returns the lowered transition from the named source to the @@ -14,11 +14,11 @@ import ( func transitionBetween(t *testing.T, exec *StateExecutor, source, target string) *lower.Transition { t.Helper() for node, transitions := range exec.graph.Transitions { - if getNodeName(node) != source { + if StateVertexName(node) != source { continue } for _, trans := range transitions { - if getNodeName(trans.Target) == target { + if StateVertexName(trans.Target) == target { return trans } } @@ -29,7 +29,7 @@ func transitionBetween(t *testing.T, exec *StateExecutor, source, target string) func fire(t *testing.T, exec *StateExecutor, source, target string) { t.Helper() - fired, err := exec.resolveAndFire(nil, transitionBetween(t, exec, source, target)) + fired, err := exec.resolveAndFire(nil, transitionBetween(t, exec, source, target), nil) if err != nil { t.Fatalf("fire %s -> %s: %v", source, target, err) } diff --git a/internal/exec/runtime/holders.go b/internal/exec/runtime/holders.go new file mode 100644 index 0000000000..93a721b564 --- /dev/null +++ b/internal/exec/runtime/holders.go @@ -0,0 +1,451 @@ +package runtime + +import ( + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// holdingFeatures names the features of typ whose stated value may hold the objects of the +// feature at path — a feature's name, or a chain of names from one of typ's features into the +// objects it holds — so their types classify them (KerML 1.0 §7.3.4.1); memoized per type. +func (ctx *Context) holdingFeatures(typ *symbols.Symbol, path string) []string { + index, ok := ctx.model.holders[typ] + if !ok { + index = make(map[string][]string) + features := ctx.FeaturesOf(typ) + data := make(map[string]bool, len(features)) + for i := range features { + data[features[i].Name] = ctx.holdsData(&features[i]) + } + for i := range features { + feat := &features[i] + if feat.DefaultValue == nil || !ctx.valueBinds(feat) || data[feat.Name] { + continue + } + for _, held := range ctx.mentionedFeatures(typ, feat) { + if root, _, _ := strings.Cut(held, "."); root != feat.Name && !data[root] { + index[held] = append(index[held], feat.Name) + } + } + } + ctx.model.holders[typ] = index + } + return index[path] +} + +// holdsData reports a feature whose values are data, never objects: one declared an attribute +// by keyword (`:>> x` alone declares no kind), or typed by a data type. It classifies nothing. +func (ctx *Context) holdsData(feat *EffectiveFeature) bool { + if feat.Symbol != nil { + usage, ok := feat.Symbol.Decl.(*ast.Usage) + if ok && usage.Kind == ast.UsageAttribute && usage.Keyword != "" { + return true + } + } + return ctx.model.semantics.IsDataType(feat.Type) +} + +// mentionedFeatures names the features of typ whose values feat's value may pass on as its +// own, wherever its expression refers to them: a feature by name, a chain into the objects +// one holds as the names along it. +func (ctx *Context) mentionedFeatures(typ *symbols.Symbol, feat *EffectiveFeature) []string { + scope := feat.DefaultScope() + if scope == nil { + return nil + } + var paths []string + seen := make(map[string]bool) + for _, passed := range ctx.passedFeatureReferences(scope, feat.DefaultValue) { + sym := passed.symbol(ctx) + if sym == nil { + continue + } + if name, ok := ctx.denotedFeature(typ, sym); ok { + path := strings.Join(append([]string{name}, passed.members...), ".") + if !seen[path] { + seen[path] = true + paths = append(paths, path) + } + } + } + return paths +} + +// passedReference is a feature reference an expression may answer the values of, in the +// scope it is written in: a body's parameters are looked up in the body's own. members +// are the names a chain reads from the objects the reference holds, none for the +// reference's own values. +type passedReference struct { + scope *symbols.Scope + ref *ast.FeatureReference + members []string +} + +// symbol resolves the reference where it is written. +func (p passedReference) symbol(ctx *Context) *symbols.Symbol { + return ctx.referencedSymbol(p.scope, p.ref.Name) +} + +// through is the reference read on through further members. +func (p passedReference) through(members []string) passedReference { + if len(members) == 0 { + return p + } + p.members = append(append([]string{}, p.members...), members...) + return p +} + +// readThrough is refs each read on through members. +func readThrough(refs []passedReference, members []string) []passedReference { + if len(members) == 0 { + return refs + } + out := make([]passedReference, len(refs)) + for i, ref := range refs { + out[i] = ref.through(members) + } + return out +} + +// passedFeatureReferences lists the feature references an expression written in scope +// may answer the values of as its own: a branch chosen, an element indexed or selected, +// an argument a call returns, a collected element the body answers, a chain's last member +// read from what its operand passes on — not a condition or an operand computed from. +func (ctx *Context) passedFeatureReferences(scope *symbols.Scope, expr ast.Node) []passedReference { + var refs []passedReference + var visit func(nodes ...ast.Node) + visit = func(nodes ...ast.Node) { + for _, n := range nodes { + switch n := n.(type) { + case *ast.FeatureReference: + refs = append(refs, passedReference{scope: scope, ref: n}) + case *ast.FeatureChainExpr: + if n.Member == nil || len(n.Member.Parts) == 0 { + continue + } + base, parts := chainBase(n) + members := make([]string, len(parts)) + for i, part := range parts { + members[i] = part.Text + } + refs = append(refs, readThrough(ctx.passedFeatureReferences(scope, base), members)...) + case *ast.SequenceExpr: + visit(n.Elements...) + case *ast.OperatorExpr: + switch n.Operator { + case ast.OpConditional: + if len(n.Operands) > 1 { + visit(n.Operands[1:]...) + } + case ast.OpNullCoalesce, ast.OpAs: + visit(n.Operands...) + } + case *ast.IndexExpr: + visit(n.Operand) + case *ast.InvocationExpr: + visit(ctx.returnedArguments(scope, n)...) + case *ast.CollectExpr: + refs = append(refs, ctx.collectedReferences(scope, n.Operand, n.Body)...) + case *ast.SelectExpr: + visit(n.Operand) + case *ast.BodyExpr: + refs = append(refs, ctx.bodyReferences(scope, n, nil)...) + case *ast.Usage: + visit(n.Value) + } + } + } + visit(expr) + return refs +} + +// collectedReferences lists what `operand.body` may answer: what the body's result +// answers, the operand's values where that is an element; every value of the operand +// for a body not written there, whose result is not known here. +func (ctx *Context) collectedReferences(scope *symbols.Scope, operand, body ast.Node) []passedReference { + b, ok := body.(*ast.BodyExpr) + if !ok { + return ctx.passedFeatureReferences(scope, operand) + } + return ctx.bodyReferences(scope, b, operand) +} + +// bodyReferences lists what a body expression's result may answer, its parameters +// standing for the elements of operand — for none when operand is nil — and the +// features it declares for their values. +func (ctx *Context) bodyReferences(scope *symbols.Scope, body *ast.BodyExpr, operand ast.Node) []passedReference { + bodyScope := symbols.BodyExprScope(scope, body) + if bodyScope == scope { + return ctx.passedFeatureReferences(scope, body.Result) + } + var refs []passedReference + seen := make(map[*symbols.Symbol]bool) + var expand func(passed []passedReference) + expand = func(passed []passedReference) { + for _, p := range passed { + sym := p.symbol(ctx) + if sym == nil || sym.OwnerScope != bodyScope { + refs = append(refs, p) + continue + } + if seen[sym] { + continue + } + seen[sym] = true + if sym.Decl == body { + if operand != nil { + refs = append(refs, readThrough(ctx.passedFeatureReferences(scope, operand), p.members)...) + } + continue + } + if value := ctx.extractDefaultValue(sym); value != nil { + expand(readThrough(ctx.passedFeatureReferences(bodyScope, value), p.members)) + } + } + } + expand(ctx.passedFeatureReferences(bodyScope, body.Result)) + return refs +} + +// returnedArguments lists the arguments of a call its result may consist of: those bound +// to parameters a calc's returns pass on; every one for a function whose body is not +// written in the model; none for a call that denotes nothing or computes no result. +func (ctx *Context) returnedArguments(scope *symbols.Scope, call *ast.InvocationExpr) []ast.Node { + var target *invocationTarget + if chain := semantics.ChainCallee(call); chain != nil { + target = ctx.chainTarget(scope, chain, call.NamedArgs) + } else { + target = NewEvalContext(ctx, scope).invocationTarget(call) + } + positional := semantics.InvocationArgs(call) + var args []ast.Node + switch { + case target.shape != nil: + passed := ctx.returnedParameters(target.shape) + for i, arg := range positional { + if i < len(target.shape.ParamNames) && passed[target.shape.ParamNames[i]] { + args = append(args, arg) + } + } + for i, named := range call.NamedArgs { + if passed[target.names[i]] { + args = append(args, named.Value) + } + } + case target.builtin != nil || target.library != nil: + args = append(args, positional...) + for _, named := range call.NamedArgs { + args = append(args, named.Value) + } + } + return args +} + +// chainTarget is how a call `x.f(a)` is applied as far as the model states it: by the +// shape of the calc feature the chain denotes, which the named arguments bind parameters of. +func (ctx *Context) chainTarget(scope *symbols.Scope, chain *ast.FeatureChainExpr, named []ast.NamedArg) *invocationTarget { + target := &invocationTarget{qualName: chainText(chain)} + if sym, ok := ctx.resolveTarget(scope, chain); ok && sym != nil { + if shape, err := ctx.calcShapeOf(sym); err == nil { + target.calc, target.shape = sym, shape + } + } + if len(named) > 0 { + target.names, target.unbound = ctx.boundParameterNames(scope, target.calc, named) + } + return target +} + +// returnedAnalysis is the parameters a calc shape's returns pass on, as far as known. +// Shapes calling each other are analysed together: each is provisional until the first +// of them entered — the root of the cycle — is stable, when all of them are final. +type returnedAnalysis struct { + passed map[string]bool + done bool + // active is the shape's position on the analysis stack while its returns are + // followed, -1 otherwise; low is the lowest position a call reached from it. + active, low int +} + +// returnedParameters names the parameters of a calc whose values its result may consist +// of: those its return expressions pass on, a `for` variable standing for its collection. +// Memoized per shape; a call within a cycle reads the set as far as it is known, and the +// cycle's root iterates every shape of it until none grows. +func (ctx *Context) returnedParameters(shape *calcShape) map[string]bool { + a, ok := ctx.model.returnedParams[shape] + if !ok { + a = &returnedAnalysis{passed: make(map[string]bool), active: -1} + ctx.model.returnedParams[shape] = a + } + if a.done { + return a.passed + } + if a.active >= 0 { + top := ctx.model.returnedStack[len(ctx.model.returnedStack)-1] + top.low = min(top.low, a.active) + return a.passed + } + a.active, a.low = len(ctx.model.returnedStack), len(ctx.model.returnedStack) + ctx.model.returnedStack = append(ctx.model.returnedStack, a) + provisional := len(ctx.model.returnedProvisional) + params := make(map[string]bool, len(shape.ParamNames)) + for _, name := range shape.ParamNames { + params[name] = true + } + for { + known := len(a.passed) + ctx.collectReturnedParameters(shape, a.passed, params) + if len(a.passed) == known { + break + } + } + index := a.active + ctx.model.returnedStack = ctx.model.returnedStack[:index] + a.active = -1 + if a.low < index { + caller := ctx.model.returnedStack[index-1] + caller.low = min(caller.low, a.low) + ctx.model.returnedProvisional = append(ctx.model.returnedProvisional, a) + return a.passed + } + for _, member := range ctx.model.returnedProvisional[provisional:] { + member.done = true + } + ctx.model.returnedProvisional = ctx.model.returnedProvisional[:provisional] + a.done = true + return a.passed +} + +// valueSource is an expression a local's value may come from, in the scope it is written in. +type valueSource struct { + scope *symbols.Scope + expr ast.Node +} + +// collectReturnedParameters adds to passed the parameters of shape its returns and result +// binding pass on, directly or through the locals its body writes them to. +func (ctx *Context) collectReturnedParameters(shape *calcShape, passed, params map[string]bool) { + var returns []valueSource + locals := make(map[*symbols.Symbol][]valueSource) + written := func(in *symbols.Scope, name string, source valueSource) { + if source.expr == nil { + return + } + if sym, ok := ctx.lookupName(in, name); ok { + locals[sym] = append(locals[sym], source) + } + } + var walk func(stmts []lower.Statement) + walk = func(stmts []lower.Statement) { + for _, stmt := range stmts { + switch s := stmt.(type) { + case lower.Return: + returns = append(returns, valueSource{scope: s.Scope, expr: s.Value}) + case lower.Declare: + written(s.Scope, s.Name, valueSource{scope: s.Scope, expr: s.Value}) + case lower.Assign: + if s.Chain == nil { + written(s.Scope, s.Target, valueSource{scope: s.Scope, expr: s.Value}) + } + case lower.If: + walk(s.Then.Steps()) + if s.Else != nil { + walk(s.Else.Steps()) + } + case lower.Loop: + if s.Variable != "" { + written(s.Body.Scope, s.Variable, valueSource{scope: s.Scope, expr: s.Collection}) + } + walk(s.Body.Steps()) + case lower.Block: + walk(s.Steps()) + } + } + } + walk(shape.Body) + for _, binding := range shape.Bindings { + for i := range binding.Ends { + if binding.Ends[i].Path == "result" && binding.Ends[1-i].Expr != nil { + returns = append(returns, valueSource{scope: binding.Scope, expr: binding.Ends[1-i].Expr}) + } + } + } + + // A chain from a parameter passes on objects the parameter holds, not the parameter; + // a local written from itself is followed once. + following := make(map[*symbols.Symbol]bool) + var follow func(source valueSource, members []string) + follow = func(source valueSource, members []string) { + if source.scope == nil { + return + } + for _, ref := range readThrough(ctx.passedFeatureReferences(source.scope, source.expr), members) { + sym := ref.symbol(ctx) + if sym == nil || following[sym] { + continue + } + if name, ok := ctx.denotedFeature(shape.Sym, sym); ok && params[name] { + if len(ref.members) == 0 { + passed[name] = true + } + continue + } + following[sym] = true + for _, from := range locals[sym] { + follow(from, ref.members) + } + delete(following, sym) + } + } + for _, ret := range returns { + follow(ret, nil) + } +} + +// referencedSymbol resolves a name the way the evaluator does: a single part by +// lookup in the scope, more through the qualified-name reader. +func (ctx *Context) referencedSymbol(scope *symbols.Scope, qn *ast.QualifiedName) *symbols.Symbol { + if qn == nil || len(qn.Parts) == 0 { + return nil + } + if len(qn.Parts) == 1 && !qn.Global { + if sym, ok := ctx.lookupName(scope, qn.Parts[0].Text); ok { + return sym + } + return nil + } + sym, _ := ctx.readQualified(scope, qn).Symbol() + return sym +} + +// materializeHolders reads the features whose value lists the named feature of inst — on inst, +// or on an object holding it as a chain through the features leading to inst — before its +// objects are made, so every holding feature classifies them whichever is read first. +// A holder that fails to materialize holds nothing: its error is its own, reported when it is read. +func (ctx *Context) materializeHolders(inst *Instance, name string) { + if fv, ok := inst.FeatureValues[name]; !ok || ctx.holdsData(fv.Feature) { + return + } + path := name + for holding := inst; holding != nil; { + for _, typ := range holding.types() { + for _, holder := range ctx.holdingFeatures(typ, path) { + fv, ok := holding.FeatureValues[holder] + if !ok || fv.Materialized || + ctx.derivingFeatureValues[featureValueRef{instance: holding.ID, feature: holder}] { + continue + } + _, _ = holding.GetFeatureValue(ctx, holder) + } + } + owner, feature := holding.Owner() + if owner == nil || feature == "" { + break + } + path, holding = feature+"."+path, owner + } +} diff --git a/internal/core/runtime/infinity_test.go b/internal/exec/runtime/infinity_test.go similarity index 96% rename from internal/core/runtime/infinity_test.go rename to internal/exec/runtime/infinity_test.go index ab8a8bbb9e..6aa19700c7 100644 --- a/internal/core/runtime/infinity_test.go +++ b/internal/exec/runtime/infinity_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // evalDeclaredExpr evaluates expr as the value of an attribute declared beside src. @@ -36,7 +36,7 @@ func declaredExpr(t *testing.T, src, expr string) (*Context, *symbols.Scope, ast if !ok { t.Fatalf("result declares %T, want a usage", sym.Decl) } - return NewContext(NewModel(model, resolver), 10000), pkg.Scope, decl.Value + return NewContext(typedModel(model, resolver), 10000), pkg.Scope, decl.Value } // TestInfinityValue evaluates `*`: its own scalar value, printed as written. diff --git a/internal/core/runtime/instance.go b/internal/exec/runtime/instance.go similarity index 92% rename from internal/core/runtime/instance.go rename to internal/exec/runtime/instance.go index b2319b392d..d6618ce09f 100644 --- a/internal/core/runtime/instance.go +++ b/internal/exec/runtime/instance.go @@ -4,9 +4,9 @@ import ( "fmt" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // maxMaterializedLowerBound bounds the anonymous objects a collection feature value is @@ -178,7 +178,14 @@ func (ctx *Context) noValueError(val Value, node ast.Node) *NoValueError { // own. The object becomes what the usage denotes from then on, so an expression // naming the usage or a feature path under it reads this object as it was run. func (ctx *Context) Instantiate(sym *symbols.Symbol) (*Instance, error) { - mark := len(ctx.created) + return ctx.InstantiateRead(sym, nil) +} + +// InstantiateRead is Instantiate with read run over the new object before it is +// returned, as one creation: a read that fails abandons the object with all it +// materialized, so a context under a bound is left as it was. +func (ctx *Context) InstantiateRead(sym *symbols.Symbol, read func(*Instance) error) (*Instance, error) { + mark, attached := len(ctx.created), len(ctx.objectBehaviors) inst, err := ctx.materialize(sym, 0, nil, "") if err != nil { ctx.abandonInstancesSince(mark) @@ -188,6 +195,11 @@ func (ctx *Context) Instantiate(sym *symbols.Symbol) (*Instance, error) { // reaches this object; a failed start abandons the occurrence with it. inst.explicit = true prior, hadPrior := ctx.occurrences[sym] + restore := func() { + if hadPrior { + ctx.occurrences[sym] = prior + } + } if ctx.registersOccurrence(sym) { ctx.noteProbeUndo(func() { if hadPrior { @@ -199,11 +211,16 @@ func (ctx *Context) Instantiate(sym *symbols.Symbol) (*Instance, error) { ctx.occurrences[sym] = []int64{inst.ID} } if err := ctx.startClassifierBehaviors(inst, mark); err != nil { - if hadPrior { - ctx.occurrences[sym] = prior - } + restore() return nil, err } + if read != nil { + if err := read(inst); err != nil { + ctx.abandonCreationSince(mark, attached) + restore() + return nil, err + } + } return inst, nil } @@ -301,6 +318,9 @@ func (ctx *Context) materialize(sym *symbols.Symbol, id int64, owner *Instance, if err := ctx.incrementStep(); err != nil { return nil, err } + if err := ctx.instanceRoom(); err != nil { + return nil, err + } if _, taken := ctx.instances[id]; taken || id <= 0 { id = ctx.allocateID() @@ -698,30 +718,36 @@ func (inst *Instance) SetFeatureValue(ctx *Context, name string, value Value) er if err != nil { return err } - ctx.noteProbeWrite(fv) - before := ctx.beforeWrite(fv) - if fv.Feature.Scalar() { - fv.Value = value - fv.Values = Value{} - } else { - fv.Values = value - fv.Value = Value{} - } - fv.Materialized, fv.Written = true, true - fv.BindingDerived, fv.Assumed = false, false - ctx.afterWrite(fv, before) - return nil + return ctx.storedBeforeStarting(func() error { + if err := ctx.holdWritten(inst, fv, value); err != nil { + return fmt.Errorf("feature %s.%s: %w", inst.Type.Name, name, err) + } + ctx.noteProbeWrite(fv) + before := ctx.beforeWrite(fv) + if fv.Feature.Scalar() { + fv.Value = value + fv.Values = Value{} + } else { + fv.Values = value + fv.Value = Value{} + } + fv.Materialized, fv.Written = true, true + fv.BindingDerived, fv.Assumed = false, false + ctx.afterWrite(fv, before) + return nil + }) } // materializeFeatureValue is GetFeatureValue's materialization: the feature value's value, evaluated and // checked against the multiplicity governing its feature the first time it is read. func (inst *Instance) materializeFeatureValue(ctx *Context, name string, open *openPopulation) (*FeatureValue, error) { - defer ctx.beginRun()() - fv := inst.FeatureValues[name] - before := ctx.beforeWrite(fv) - err := inst.materializeBoundOrIntrinsic(ctx, fv, name, open) - ctx.afterWrite(fv, before) + err := ctx.storedBeforeStarting(func() error { + before := ctx.beforeWrite(fv) + err := inst.materializeBoundOrIntrinsic(ctx, fv, name, open) + ctx.afterWrite(fv, before) + return err + }) if err != nil { return nil, err } @@ -751,9 +777,12 @@ func (inst *Instance) materializeFeatureValueIntrinsic(ctx *Context, name string func (inst *Instance) materializeIntrinsicValue(ctx *Context, name string, open *openPopulation) (*FeatureValue, error) { fv := inst.FeatureValues[name] - before := ctx.beforeWrite(fv) - _, err := inst.materializeIntrinsic(ctx, fv, name, open) - ctx.afterWrite(fv, before) + err := ctx.storedBeforeStarting(func() error { + before := ctx.beforeWrite(fv) + _, err := inst.materializeIntrinsic(ctx, fv, name, open) + ctx.afterWrite(fv, before) + return err + }) if err != nil { return nil, err } @@ -816,13 +845,13 @@ func (inst *Instance) materializeIntrinsic(ctx *Context, fv *FeatureValue, name // An abstract feature has no values of its own (KerML 1.0 §7.3.3.1) and an // optional one demands none: each, a connector included, holds only contributions — // unless the declaration's body binds a feature of the one object it then holds. - if fv.Feature.HoldsOnlyContributions() && !ctx.bodyBindsAFeature(fv.Feature) && (ctx.model.semantics.IsConnectorUsage(fv.Feature.Symbol) || ctx.CompositeTypeOf(fv.Feature) != nil) { + if fv.Feature.HoldsOnlyContributions() && !ctx.bodyBindsAFeature(fv.Feature) && (ctx.model.semantics.IsConnectorObjectUsage(fv.Feature.Symbol) || ctx.CompositeTypeOf(fv.Feature) != nil) { return inst.holdContributions(ctx, fv, name) } // A connector holds the features it connects at its ends rather than objects // of its own, so it is materialized from what the `connect` clause names. - if ctx.model.semantics.IsConnectorUsage(fv.Feature.Symbol) { + if ctx.model.semantics.IsConnectorObjectUsage(fv.Feature.Symbol) { if err := ctx.materializeConnectorFeatureValue(inst, fv, name); err != nil { return nil, err } @@ -865,7 +894,7 @@ func (inst *Instance) materializeDerived(ctx *Context, fv *FeatureValue, name st if err := ctx.checkDefault(inst, fv, name, &val, admitDeclared); err != nil { return nil, err } - if val, err = ctx.admitted(fv.Feature, val, admitDeclared); err != nil { + if val, err = ctx.holdDeclared(inst, fv, val); err != nil { return nil, err } ctx.noteProbeWrite(fv) @@ -1038,7 +1067,7 @@ func (inst *Instance) holdContributed(ctx *Context, fv *FeatureValue, name strin if err := ctx.checkDefault(inst, fv, name, &val, admitDeclared); err != nil { return nil, err } - val, err := ctx.admitted(fv.Feature, val, admitDeclared) + val, err := ctx.holdDeclared(inst, fv, val) if err != nil { return nil, fmt.Errorf("feature value %s.%s: %w", inst.Type.Name, name, err) } @@ -1069,9 +1098,14 @@ func (ctx *Context) CompositeTypeOf(feat *EffectiveFeature) *symbols.Symbol { if ctx.model.semantics.IsVariationFeature(feat.Symbol) { return nil } - // A subject is a reference usage (SysML.xtext SubjectUsage): it holds what - // is bound to it, never an object of its own. - if isSubjectUsage(feat.Symbol) { + // A reference usage — a subject (SysML.xtext SubjectUsage), a `ref` usage or + // one that `references` another — holds what is bound to it, never an object of its own. + if isSubjectUsage(feat.Symbol) || isReferenceUsage(feat.Symbol) { + return nil + } + // A behavior's parameter is referential too (SysML v2 `validateUsageIsReferential`): an + // object flows into it, so it holds none of its own; a value-typed one keeps its placeholder. + if semantics.IsBehaviorParameter(feat.Symbol) && !ctx.model.semantics.IsDataType(feat.Type) { return nil } if feat.Symbol != nil && (ctx.declaresFeatures(feat) || untypedOccurrenceUsage(feat)) { @@ -1094,16 +1128,26 @@ func (ctx *Context) declaresFeatures(feat *EffectiveFeature) bool { return false } -// untypedOccurrenceUsage reports an occurrence usage declaring no type, which its kind's implicit -// base still classifies (SysML v2 §7.9.2: an untyped `item` is an Items::Item). +// untypedOccurrenceUsage reports an occurrence or port usage declaring no type, which its +// kind's implicit base still classifies (SysML v2 §7.9.2: an untyped `item` is an Items::Item, +// an untyped `port` a Ports::Port). func untypedOccurrenceUsage(feat *EffectiveFeature) bool { - if feat.Type != nil || !isOccurrenceUsage(feat.Symbol) { + if feat.Type != nil || !(isOccurrenceUsage(feat.Symbol) || untypedPortUsage(feat.Symbol)) { return false } usage := feat.Symbol.Decl.(*ast.Usage) return !usage.IsReference } +// untypedPortUsage reports a port usage, whose object a binding connector can join to another's. +func untypedPortUsage(sym *symbols.Symbol) bool { + if sym == nil || sym.Kind != symbols.SymbolPortUsage { + return false + } + usage, ok := sym.Decl.(*ast.Usage) + return ok && usage.Value == nil +} + // isSubjectUsage reports whether sym is the subject parameter of a case. func isSubjectUsage(sym *symbols.Symbol) bool { if sym == nil { @@ -1118,10 +1162,31 @@ func isSubjectUsage(sym *symbols.Symbol) bool { return false } +// isReferenceUsage reports a usage declared `ref` or with a `references` relationship +// (SysML v2 §7.6.2: Usage::isReference), which owns none of the objects it holds. +func isReferenceUsage(sym *symbols.Symbol) bool { + if sym == nil { + return false + } + usage, ok := sym.Decl.(*ast.Usage) + if !ok { + return false + } + if usage.IsReference { + return true + } + for _, rel := range usage.Relationships { + if rel != nil && rel.Kind == ast.RelReferences { + return true + } + } + return false +} + // declaresFeatures reports whether a usage's own body restates or adds features, // which the object it materializes has to carry. func declaresFeatures(sym *symbols.Symbol) bool { - for _, member := range declMembers(sym.Decl) { + for _, member := range unwrappedDeclMembers(sym.Decl) { usage, ok := member.(*ast.Usage) if !ok { continue @@ -1174,7 +1239,7 @@ func (ctx *Context) restatedValueInBody(sym, typ *symbols.Symbol) string { for _, f := range ctx.FeaturesOf(typ) { inherited[f.Name] = true } - for _, member := range declMembers(sym.Decl) { + for _, member := range unwrappedDeclMembers(sym.Decl) { usage, ok := member.(*ast.Usage) if !ok || !valuesAFeature(usage) { continue @@ -1245,7 +1310,7 @@ func valuesAFeature(usage *ast.Usage) bool { if usage.Value != nil { return true } - for _, member := range declMembers(usage) { + for _, member := range unwrappedDeclMembers(usage) { if nested, ok := member.(*ast.Usage); ok && valuesAFeature(nested) { return true } diff --git a/internal/exec/runtime/instance_limit_test.go b/internal/exec/runtime/instance_limit_test.go new file mode 100644 index 0000000000..89af5e2913 --- /dev/null +++ b/internal/exec/runtime/instance_limit_test.go @@ -0,0 +1,110 @@ +package runtime + +import ( + "errors" + "testing" +) + +const instanceLimitSrc = ` + part def Wheel; + part def Engine; + part def Car { + part engine : Engine; + part wheels : Wheel[2]; + } + part car : Car; +` + +// TestInstanceLimit_CountsNestedObjects: the bound counts the objects a read +// materializes under a root, and a read that would pass it fails leaving none +// of them, the ones that fit included. +func TestInstanceLimit_CountsNestedObjects(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, instanceLimitSrc) + ctx := NewContext(typedModel(model, resolver), 1000) + ctx.SetMaxInstances(3) + if got := ctx.MaxInstances(); got != 3 { + t.Fatalf("MaxInstances = %d, want 3", got) + } + carSym := resolveSymbol(t, root, "car") + + car, err := ctx.Instantiate(carSym) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if _, err := car.GetFeatureValue(ctx, "engine"); err != nil { + t.Fatalf("engine: %v", err) + } + if got := ctx.InstanceCount(); got != 2 { + t.Fatalf("InstanceCount after engine = %d, want 2", got) + } + + // The first wheel fits, the second does not: neither stays. + _, err = car.GetFeatureValue(ctx, "wheels") + if !errors.Is(err, ErrInstanceLimitExceeded) { + t.Fatalf("wheels past the bound: err = %v, want ErrInstanceLimitExceeded", err) + } + if got := ctx.InstanceCount(); got != 2 { + t.Errorf("InstanceCount after the refused read = %d, want 2: the failed read left objects behind", got) + } + if _, live := ctx.Instance(3); live { + t.Error("the wheel that fit is still registered after the read failed") + } + if fv := car.FeatureValues["wheels"]; fv != nil && fv.Materialized { + t.Error("wheels reads as materialized after the refused read") + } +} + +// TestInstanceLimit_RootFailsWhole: a creation that passes the bound in a +// nested object leaves neither the root nor the objects under it. +func TestInstanceLimit_RootFailsWhole(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, instanceLimitSrc) + ctx := NewContext(typedModel(model, resolver), 1000) + ctx.SetMaxInstances(5) + carSym := resolveSymbol(t, root, "car") + + readAll := func(inst *Instance) error { + for _, name := range []string{"engine", "wheels"} { + if _, err := inst.GetFeatureValue(ctx, name); err != nil { + return err + } + } + return nil + } + first, err := ctx.InstantiateRead(carSym, readAll) + if err != nil { + t.Fatalf("first car: %v", err) + } + if got := ctx.InstanceCount(); got != 4 { + t.Fatalf("InstanceCount after the first car = %d, want 4", got) + } + + _, err = ctx.InstantiateRead(carSym, readAll) + if !errors.Is(err, ErrInstanceLimitExceeded) { + t.Fatalf("second car: err = %v, want ErrInstanceLimitExceeded", err) + } + if got := ctx.InstanceCount(); got != 4 { + t.Errorf("InstanceCount after the refused car = %d, want 4", got) + } + if _, live := ctx.Instance(first.ID + 4); live { + t.Error("the refused car's root is still registered") + } + if ids := ctx.occurrences[carSym]; len(ids) != 1 || ids[0] != first.ID { + t.Errorf("car denotes %v, want the first car #%d alone", ids, first.ID) + } + + // Room for one more object, and no more, remains; lifting the bound frees it. + wheelSym := resolveSymbol(t, root, "Wheel") + if _, err := ctx.Instantiate(wheelSym); err != nil { + t.Fatalf("fifth object: %v", err) + } + if _, err := ctx.Instantiate(wheelSym); !errors.Is(err, ErrInstanceLimitExceeded) { + t.Fatalf("sixth object: err = %v, want ErrInstanceLimitExceeded", err) + } + ctx.SetMaxInstances(0) + if _, err := ctx.Instantiate(wheelSym); err != nil { + t.Fatalf("unbounded Instantiate: %v", err) + } + if got := ctx.InstanceCount(); got != 6 { + t.Errorf("InstanceCount = %d, want 6", got) + } +} diff --git a/internal/core/runtime/instance_test.go b/internal/exec/runtime/instance_test.go similarity index 95% rename from internal/core/runtime/instance_test.go rename to internal/exec/runtime/instance_test.go index 371d185b67..23c48cff4b 100644 --- a/internal/core/runtime/instance_test.go +++ b/internal/exec/runtime/instance_test.go @@ -11,7 +11,7 @@ func TestInstantiate_SimplePartDef(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) wheelSym := resolveSymbol(t, root, "Wheel") @@ -54,7 +54,7 @@ func TestInstantiate_UnboundedDefaultIsNotAScalar(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) inst, err := ctx.Instantiate(resolveSymbol(t, root, "Car")) if err != nil { @@ -75,7 +75,7 @@ func TestInstantiate_UnboundedDefaultIsNotAScalar(t *testing.T) { func TestInstantiate_IDAllocation(t *testing.T) { src := `part def A {}` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) aSym := resolveSymbol(t, root, "A") @@ -95,7 +95,7 @@ func TestGetFeatureValue_LazyComposite(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) carSym := resolveSymbol(t, root, "Car") inst, err := ctx.Instantiate(carSym) @@ -146,7 +146,7 @@ func TestMultiValuedDefaultMaterializes(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) inst, err := ctx.Instantiate(resolveSymbol(t, root, "Rig")) if err != nil { @@ -259,7 +259,7 @@ func TestNestedUsageBodyOverridesItsType(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) car, err := ctx.Instantiate(resolveSymbol(t, root, "Car")) if err != nil { @@ -283,7 +283,7 @@ func TestCompositeTypeOfIgnoresDefaultedFeature(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) features := ctx.FeaturesOf(resolveSymbol(t, root, "Gauge")) for i := range features { @@ -310,7 +310,7 @@ func TestUntypedNestedPartMaterializes(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) car, err := ctx.Instantiate(resolveSymbol(t, root, "Car")) if err != nil { @@ -339,7 +339,7 @@ func TestBodyGovernsAnInheritedValue(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) band, err := ctx.Instantiate(resolveSymbol(t, root, "Band")) if err != nil { @@ -375,7 +375,7 @@ func TestRenamedRedefinitionBodyGovernsAnInheritedValue(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) band, err := ctx.Instantiate(resolveSymbol(t, root, "Band")) if err != nil { @@ -407,7 +407,7 @@ func TestConditionsDoNotReadAGovernedOverValue(t *testing.T) { } ` model, resolver, root := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) features := ctx.conditionFeatures(resolveSymbol(t, root, "Band")) if got, ok := features["cost"]; !ok || got.expr != nil { @@ -447,7 +447,7 @@ func TestNullDefaultHoldsNoElements(t *testing.T) { attribute nothing[0..*] = null; } `) - ctx := NewContext(NewModel(model, resolver), 1000) + ctx := NewContext(typedModel(model, resolver), 1000) inst, err := ctx.Instantiate(resolveSymbol(t, root, "Rig")) if err != nil { t.Fatalf("Instantiate: %v", err) diff --git a/internal/core/runtime/invocation_selection_test.go b/internal/exec/runtime/invocation_selection_test.go similarity index 97% rename from internal/core/runtime/invocation_selection_test.go rename to internal/exec/runtime/invocation_selection_test.go index 44e33d57fa..23ba575d6c 100644 --- a/internal/core/runtime/invocation_selection_test.go +++ b/internal/exec/runtime/invocation_selection_test.go @@ -6,11 +6,11 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // Failure modes of overload selection: each returns a typed error naming the @@ -60,6 +60,41 @@ func TestInvocationSelectionRobustness(t *testing.T) { t.Run("action_call_argument_cannot_name_a_feature_out_of_scope", testActionCallArgumentCannotNameAFeatureOutOfScope) } +// A model handed to the runtime without the checker's argument typing cannot +// reach overload selection: the call fails with ErrNoArgumentTyper instead of +// selecting on weaker, untyped arguments. +func TestInvocationSelectionRequiresArgumentTyper(t *testing.T) { + src := ` + package A { private import ScalarValues::*; calc def pick { in x : Integer; return : Integer = 1; } } + package B { private import ScalarValues::*; calc def pick { in x : String; return : Integer = 2; } } + package test { + private import ScalarValues::*; + private import A::*; + private import B::*; + calc chooseInt { in v : Integer; pick(v) } + } + ` + idx := libs.NewModelIndex() + idx.AddDocument("", parseAndBuild(t, src)) + idx.ExpandWildcardImports() + resolver := resolve.New(idx) + sem := semantics.NewModel(resolver) + if sem.HasArgumentTyper() { + t.Fatal("a fresh semantic model must not carry an argument typer") + } + ctx := NewContext(NewModel(sem, resolver), 10000) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "chooseInt", ast.DefCalc) + if sym == nil { + t.Fatal("chooseInt calc not found") + } + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 3}} + _, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if !errors.Is(err, ErrNoArgumentTyper) { + t.Fatalf("InvokeCalc error = %v, want ErrNoArgumentTyper", err) + } +} + // overloadedActionsSrc declares two imported same-named actions, told apart // by the type of their one input. const overloadedActionsSrc = ` @@ -1182,7 +1217,7 @@ func testBareCallSelectsAmongOtherDocumentsRootDeclarations(t *testing.T) { `)) idx.ExpandWildcardImports() resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) rootScope := idx.DocumentRoot("") for calc, want := range map[string]int64{"byInt": 1, "byString": 2, "byBool": 3} { sym := findSymbolByName(rootScope, calc, ast.DefCalc) @@ -1226,7 +1261,7 @@ func testBareCallReachesPrivateRootDeclarationsOfOtherDocuments(t *testing.T) { `)) idx.ExpandWildcardImports() resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) rootScope := idx.DocumentRoot("") for calc, want := range map[string]int64{"byInt": 1, "byString": 2, "byBool": 3} { sym := findSymbolByName(rootScope, calc, ast.DefCalc) diff --git a/internal/exec/runtime/invoke_action.go b/internal/exec/runtime/invoke_action.go new file mode 100644 index 0000000000..663bf969ea --- /dev/null +++ b/internal/exec/runtime/invoke_action.go @@ -0,0 +1,565 @@ +package runtime + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// maxActionNestingDepth bounds how deep action-in-action invocation may go. An +// action that reaches itself, directly or through a cycle, would otherwise +// recurse until the process ran out of stack rather than reporting the model +// error, since each nested invocation runs on a fresh executor. +const maxActionNestingDepth = 32 + +// actionInvocation is a nested action usage that performs another action, in any +// of the three forms the parser produces: +// +// perform Callee; // anonymous usage, 'references' relationship +// action call : Callee; // named usage, 'typing' relationship +// action call = Callee(1); // named usage, invocation expression value +// perform part.callee; // a feature chain: the action a part of the performer performs +type actionInvocation struct { + target *ast.QualifiedName + args []ast.Node + named []ast.NamedArg + // chain is the `part.callee` form of the target, whose operand denotes the object + // performing the callee (SysML 7.17.6); target is then its last member. + chain *ast.FeatureChainExpr + // expr is the `Callee(...)` form, whose argument list, even an empty one, states + // every input the caller passes and selects among same-named actions as for calcs. + expr *ast.InvocationExpr + // referrer is the usage owning a reference subsetting, whose own effective + // name is the one the target names (see resolve.ResolveReferenceTarget). + referrer ast.Node + // step is the usage declaring the invocation, whose metadata and parameters + // bind the performance; nil for an invocation no usage of the body declares. + step *symbols.Symbol +} + +// performed is what the invocation performs: the step declaring it, else the callee itself. +func (inv actionInvocation) performed(callee *symbols.Symbol) *symbols.Symbol { + if inv.step != nil { + return inv.step + } + return callee +} + +// name renders the action as invoked, for diagnostics: the whole chain of a `part.callee`. +func (inv actionInvocation) name() string { + if inv.chain != nil { + return lower.FeaturePath(inv.chain) + } + return inv.target.Text() +} + +// chainedInvocation reads a `part.callee` target: the callee is the chain's last member, +// performed by the object its operand denotes. A chain naming no member performs nothing. +func chainedInvocation(chain *ast.FeatureChainExpr, referrer ast.Node) (actionInvocation, bool) { + if chain.Member == nil || len(chain.Member.Parts) == 0 { + return actionInvocation{}, false + } + return actionInvocation{target: chain.Member, chain: chain, referrer: referrer}, true +} + +// nestedInvocation reports the action a nested usage performs, if any. A usage +// that only carries its own body (assignments, sends, accepts) performs nothing. +// Only typing and reference-subsetting edges name a performed action: the port +// of `accept msg : T via p` is a via edge, not a reference subsetting. A +// reference names both the action and its performer, so it wins over a typing +// that only restates the action's definition. +func nestedInvocation(usage *ast.Usage) (actionInvocation, bool) { + if invocation := usage.PerformedInvocation(); invocation != nil { + return expressionInvocation(invocation), true + } + if inv, ok := referencedInvocation(usage); ok { + return inv, true + } + for _, rel := range usage.Relationships { + if rel.Kind != ast.RelTyping { + continue + } + if target, ok := rel.Target.(*ast.QualifiedName); ok { + return actionInvocation{target: target}, true + } + } + return actionInvocation{}, false +} + +// referencedInvocation reports the action a usage's reference subsetting performs. +func referencedInvocation(usage *ast.Usage) (actionInvocation, bool) { + for _, rel := range usage.Relationships { + if rel.Kind != ast.RelReferences { + continue + } + switch target := rel.Target.(type) { + case *ast.QualifiedName: + return actionInvocation{target: target, referrer: usage}, true + case *ast.FeatureChainExpr: + return chainedInvocation(target, usage) + } + } + return actionInvocation{}, false +} + +// expressionInvocation reads an invocation expression as the action call it +// writes. A receiver is the first argument, as `seq->size()` is for a calc. +func expressionInvocation(e *ast.InvocationExpr) actionInvocation { + args := e.Args + if e.Operand != nil { + args = append([]ast.Node{e.Operand}, e.Args...) + } + return actionInvocation{target: e.Type, args: args, named: e.NamedArgs, expr: e} +} + +// invocationArguments resolves the action a `Callee(...)` invocation names and evaluates its +// arguments in ec, the caller's context, keyed by the input parameter of performanceInterface +// they bind. A call the checker leaves tied on arguments of unknown type is settled by the +// values, evaluated once. The other forms resolve the callee and bind nothing. +func invocationArguments( + ctx *Context, scope *symbols.Scope, inv actionInvocation, ec *EvalContext, +) (map[string]Value, *symbols.Symbol, error) { + sym, tied, err := actionCandidates(ctx, scope, inv) + if err != nil { + return nil, nil, err + } + if inv.expr == nil { + return nil, sym, nil + } + if inv.expr.Operand != nil && len(inv.named) > 0 { + return nil, nil, fmt.Errorf( + "%w: %s is called with a receiver and named arguments", + ErrReceiverWithNamedArgs, inv.name(), + ) + } + written := writtenArguments(inv.args, inv.named) + if sym == nil { + if sym, err = settleAction(ec, inv, tied, written); err != nil { + return nil, nil, err + } + } + held, err := ctx.performanceInterface(inv.performed(sym), sym) + if err != nil { + return nil, nil, err + } + in, _ := parameterNames(ctx.actionParametersOf(held)) + arguments := make(map[string]Value, len(written)) + if err := bindArgumentList(ec, inv, held, in, arguments, written); err != nil { + return nil, nil, err + } + return arguments, sym, nil +} + +// settleAction selects among the tied actions by the types of the arguments' values, +// evaluated in source order. +func settleAction(ec *EvalContext, inv actionInvocation, tied []*symbols.Symbol, written []*writtenArgument) (*symbols.Symbol, error) { + args := make([]semantics.Argument, 0, len(written)) + for k, w := range written { + var name *ast.QualifiedName + if k >= len(inv.args) { + name = inv.named[k-len(inv.args)].Name + if name == nil || len(name.Parts) == 0 { + continue + } + } + val, err := w.eval(ec) + if err != nil { + return nil, fmt.Errorf("eval argument %d of %s: %w", k+1, inv.name(), err) + } + args = append(args, ec.valueArgument(val, name)) + } + sel := ec.ctx.model.semantics.SelectAmongArguments(ec.scope, tied, args, semantics.PerformsAction) + if sel.Ambiguous || sel.Called() == nil { + return nil, ambiguousInvocationError(inv.name(), sel.Tied) + } + return sel.Called(), nil +} + +// invokeAction runs the action named by inv to completion as a sub-execution of +// the caller, performed by self or by the part a `part.callee` target denotes, and +// returns the values its features ended with and, among them, its output parameters'. +// +// The callee gets a fresh executor with its own tokens, so values cross the +// boundary only through parameters: arguments, evaluated in the caller's data (or, +// for an argument-less invocation, caller values of the same name) seed the +// callee's `in` and `inout` parameters, and its `out` and `inout` parameters come +// back to the caller. An action with no parameters therefore reads and writes +// nothing in its caller. +// +// A body around the call pauses where the callee waits, and the call is +// re-entered to go on with it. +func invokeAction( + ctx *Context, + scope *symbols.Scope, + inv actionInvocation, + data map[string]Value, + self *Instance, +) (features, outputs map[string]Value, err error) { + if callee, resumed, err := popFrame[*calleeFrame](ctx); err != nil { + return nil, nil, err + } else if resumed { + return ctx.runCallee(callee) + } + // Arguments are evaluated as the caller's body is: over its values, performed by self. + ec := NewEvalContextIn(ctx, scope, self) + ec.inBehaviorBody = true + ec.Push(data) + defer ec.beginStep()() + arguments, sym, err := invocationArguments(ctx, scope, inv, ec) + if err != nil { + return nil, nil, err + } + performer, err := ctx.performerOf(ec, inv, self) + if err != nil { + return nil, nil, err + } + return invokeBoundAction(ctx, inv, sym, arguments, data, performer) +} + +// performerOf is the object the callee runs as: for a `part.callee` target, the one +// object the chain's operand denotes over the caller's values in ec; else self. +func (ctx *Context) performerOf(ec *EvalContext, inv actionInvocation, self *Instance) (*Instance, error) { + if inv.chain == nil { + return self, nil + } + value, err := ec.Eval(inv.chain.Operand) + if err != nil { + return nil, fmt.Errorf("eval performer of %s: %w", inv.name(), err) + } + if value.Kind != ValInstance { + return nil, fmt.Errorf("%w: %s is performed by %s, which is no one object", + ErrPerformerNotObject, inv.name(), FormatValue(value)) + } + performer, ok := ctx.Instance(value.Instance) + if !ok { + return nil, fmt.Errorf("%w: %s is performed by object #%d, which no longer exists", + ErrPerformerNotObject, inv.name(), value.Instance) + } + return performer, nil +} + +// invokeBoundAction is invokeAction with the callee sym resolved and its inputs already +// bound in pins (the performing node's, arguments included); a bare `perform`/typed usage +// still reads data. +func invokeBoundAction( + ctx *Context, + inv actionInvocation, + sym *symbols.Symbol, + pins map[string]Value, + data map[string]Value, + self *Instance, +) (features, outputs map[string]Value, err error) { + if callee, resumed, err := popFrame[*calleeFrame](ctx); err != nil { + return nil, nil, err + } else if resumed { + return ctx.runCallee(callee) + } + if ctx.actionDepth >= maxActionNestingDepth { + return nil, nil, fmt.Errorf( + "action invocation nested more than %d deep at %s (recursive action?)", + maxActionNestingDepth, inv.name(), + ) + } + + params, err := ctx.performanceParameters(inv.performed(sym), sym) + if err != nil { + return nil, nil, err + } + in, out := parameterNames(params) + inputs := make(map[string]Value, len(in)) + for _, name := range in { + if value, ok := pins[name]; ok { + inputs[name] = value + } + } + if inv.expr == nil { + // A bare `perform`/typed usage reads the caller's values of the parameters' + // own names, which is how data reaches an action performed inside a flow. + for _, name := range in { + if _, bound := inputs[name]; bound { + continue + } + if value, ok := data[name]; ok { + inputs[name] = value + } + } + } + if err := checkInputsBound(inv, params, inputs); err != nil { + return nil, nil, err + } + + callee, err := ctx.beginOrJoinCallee(inv, sym, self, inputs, nil) + if err != nil { + return nil, nil, fmt.Errorf("invoke action %s: %w", inv.name(), err) + } + callee.name, callee.out = inv.name(), out + return ctx.runCallee(callee) +} + +// calleeFrame is an action a body performs as a sub-execution, kept where the body +// paused on the action's wait: name is the action as invoked and out the names of +// its output parameters, read once it completes. joined marks the performance the +// performer already runs of the action, which the caller waits on rather than owns. +type calleeFrame struct { + exec *ActionExecutor + name string + out []string + joined bool + // performer is the node's performance, listening to a joined callee's outputs + // across the node's pauses. + performer *actionFrame +} + +func (f *calleeFrame) abandon(*Context) { + if !f.joined { + f.exec.Release() + return + } + f.exec.unlisten(f.performer) +} + +func (f *calleeFrame) clone() bodyFrame { c := *f; return &c } + +// beginOrJoinCallee begins a performance of the action inv names on self, or, for a +// `part.callee` whose object performs the callee already (its type performs it), joins +// that one performance, as a run of the action named on an object does (performAction); +// listener, if any, takes each write to the outputs of either. +func (ctx *Context) beginOrJoinCallee(inv actionInvocation, sym *symbols.Symbol, self *Instance, inputs map[string]Value, listener *outputListener) (*calleeFrame, error) { + if inv.chain != nil { + exec, err := performanceOf(sym, self, inputs) + if err != nil { + return nil, err + } + if exec != nil { + if listener != nil { + exec.listen(listener.perf, listener.take) + } + return &calleeFrame{exec: exec, joined: true}, nil + } + } + return ctx.beginCallee(inv.performed(sym), sym, self, inputs, listener) +} + +// beginCallee starts action, a performance of performed, as a sub-execution of +// the caller nested one deeper, on the clock until run to completion; listener, +// if any, takes each write to its outputs from its first declared value on. +func (ctx *Context) beginCallee(performed, action *symbols.Symbol, self *Instance, inputs map[string]Value, listener *outputListener) (*calleeFrame, error) { + ctx.actionDepth++ + defer func() { ctx.actionDepth-- }() + defer ctx.nestRun()() + exec, err := ctx.beginPerformed(performed, action, self, inputs, false, listener, startActionStep) + if err != nil { + return nil, err + } + return &calleeFrame{exec: exec}, nil +} + +// runCallee runs the sub-execution to completion, nested one deeper while it runs, +// and returns the values its features ended with and those of its outputs; a body +// around it pauses on the action's waits, keeping the frame to go on from. +func (ctx *Context) runCallee(callee *calleeFrame) (features, outputs map[string]Value, err error) { + ctx.actionDepth++ + defer func() { ctx.actionDepth-- }() + defer ctx.nestRun()() + if err := ctx.runCalleePerformance(callee); err != nil { + if paused(err) { + return nil, nil, ctx.pausing(callee, err) + } + return nil, nil, fmt.Errorf("invoke action %s: %w", callee.name, err) + } + features = callee.exec.root.data + outputs = make(map[string]Value, len(callee.out)) + for _, name := range callee.out { + if value, ok := features[name]; ok { + outputs[name] = value + } + } + return features, outputs, nil +} + +// runCalleePerformance runs the callee's performance on; one the caller owns leaves +// the clock once run, one joined stays the object's, on the clock as its type binds it. +func (ctx *Context) runCalleePerformance(callee *calleeFrame) error { + if callee.joined { + return ctx.runPerformance(callee.exec, false) + } + return ctx.runPerformed(callee.exec, false) +} + +// resolveActionSymbol is the action inv names; a call only its arguments' values can +// settle is refused as ambiguous. +func resolveActionSymbol( + ctx *Context, + scope *symbols.Scope, + inv actionInvocation, +) (*symbols.Symbol, error) { + sym, tied, err := actionCandidates(ctx, scope, inv) + if err != nil { + return nil, err + } + if sym == nil { + return nil, ambiguousInvocationError(inv.name(), tied) + } + return sym, nil +} + +// actionCandidates resolves the action inv names: the one it denotes, or, for a call the +// checker leaves tied on arguments of unknown type, nil and the tied actions. +func actionCandidates( + ctx *Context, + scope *symbols.Scope, + inv actionInvocation, +) (*symbols.Symbol, []*symbols.Symbol, error) { + target := inv.target + if target == nil || len(target.Parts) == 0 { + return nil, nil, fmt.Errorf("empty action reference") + } + name := target.Text() + if scope == nil || ctx.model.resolver == nil { + return nil, nil, fmt.Errorf("cannot resolve action %s: no scope", name) + } + var sym *symbols.Symbol + var ok bool + switch { + case inv.chain != nil: + sym, ok = ctx.resolveReferenceTarget(scope, inv.referrer, inv.chain) + case inv.referrer != nil: + sym, ok = ctx.resolveReferenceTarget(scope, inv.referrer, target) + case inv.expr != nil: + sel, err := ctx.selectInvocation(scope, inv.expr, semantics.PerformsAction) + if err != nil { + return nil, nil, err + } + switch { + case sel.Ambiguous && sel.Undetermined: + return nil, sel.Tied, nil + case sel.Ambiguous: + return nil, nil, ambiguousInvocationError(name, sel.Tied) + } + sym = sel.Called() + ok = sym != nil + default: + sym, ok = ctx.resolveQualified(scope, target) + } + if !ok || sym == nil { + return nil, nil, fmt.Errorf("unresolved action reference: %s", name) + } + if inv.referrer != nil && sym.Decl == inv.referrer { + return nil, nil, fmt.Errorf("unresolved action reference: %s (a perform statement cannot perform itself)", name) + } + if !ctx.model.semantics.Performable(semantics.PerformsAction, sym) { + return nil, nil, fmt.Errorf("%w: %s is not an action (%v)", ErrNotABehavior, name, sym.Kind) + } + return sym, nil, nil +} + +// bindArgumentList binds an invocation's arguments, written, into inputs by the callee's +// parameter order (positional) or names (named); each is evaluated in ec, the caller's +// context, unless settling the callee already did. A parameter two arguments would bind +// is rejected rather than taking the later one. +func bindArgumentList(ec *EvalContext, inv actionInvocation, callee *symbols.Symbol, in []string, inputs map[string]Value, written []*writtenArgument) error { + if len(inv.args) > len(in) { + return fmt.Errorf( + "%w: action %s takes %d input parameter(s), got %d argument(s)", + ErrActionArity, inv.name(), len(in), len(inv.args), + ) + } + bound := make(map[string]bool, len(written)) + for i := range inv.args { + value, err := written[i].eval(ec) + if err != nil { + return fmt.Errorf("eval argument %d of %s: %w", i+1, inv.name(), err) + } + inputs[in[i]] = value + bound[in[i]] = true + } + + names, unbound := ec.ctx.boundParameterNames(ec.scope, callee, inv.named) + for i := range inv.named { + name := names[i] + if name == "" { + return fmt.Errorf("unnamed argument in invocation of %s", inv.name()) + } + if err := unbound[i]; err != nil { + return err + } + if !contains(in, name) { + return fmt.Errorf( + "%w: action %s has no input parameter %q", + ErrUnknownParameter, inv.name(), name, + ) + } + if bound[name] { + return fmt.Errorf( + "%w: input parameter %q of %s is given more than one argument", + ErrDuplicateArgument, name, inv.name(), + ) + } + bound[name] = true + value, err := written[len(inv.args)+i].eval(ec) + if err != nil { + return fmt.Errorf("eval argument %q of %s: %w", name, inv.name(), err) + } + inputs[name] = value + } + return nil +} + +// actionParameter is one parameter an action declares. +type actionParameter struct { + Name string + // Direction is the parameter's declared direction, which decides whether the + // caller writes it, reads it back, or both. + Direction ast.FeatureDirection + // Optional reports whether an invocation may bind no argument to the parameter: it + // or a parameter it redefines gives a value, or its multiplicity admits none. + Optional bool + // IsResult marks the `return` parameter, what the action's value read yields. + IsResult bool +} + +// actionParametersOf returns an action's parameters in invocation order: its own, then +// the inherited ones none redefines (KerML 7.4.7.2) — the signature the type checker uses. +func (ctx *Context) actionParametersOf(sym *symbols.Symbol) []actionParameter { + var params []actionParameter + for _, param := range ctx.model.semantics.BehaviorParametersOf(sym) { + if param.Symbol == nil || param.Symbol.Name == "" { + continue + } + params = append(params, actionParameter{ + Name: param.Symbol.Name, + Direction: param.Direction, + Optional: ctx.model.semantics.OptionalParameter(param.Symbol), + IsResult: param.IsResult, + }) + } + return params +} + +// parameterNames splits parameters into those the caller writes and reads back. +func parameterNames(params []actionParameter) (in, out []string) { + for _, param := range params { + switch param.Direction { + case ast.DirIn: + in = append(in, param.Name) + case ast.DirOut: + out = append(out, param.Name) + case ast.DirInOut: + in = append(in, param.Name) + out = append(out, param.Name) + } + } + return in, out +} + +func contains(names []string, name string) bool { + for _, candidate := range names { + if candidate == name { + return true + } + } + return false +} diff --git a/internal/core/runtime/invoke_action_test.go b/internal/exec/runtime/invoke_action_test.go similarity index 95% rename from internal/core/runtime/invoke_action_test.go rename to internal/exec/runtime/invoke_action_test.go index 0877f4f367..d0a6361ea0 100644 --- a/internal/core/runtime/invoke_action_test.go +++ b/internal/exec/runtime/invoke_action_test.go @@ -5,11 +5,11 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // loadAction parses src and returns a context plus the named action symbol. @@ -21,7 +21,7 @@ func loadAction(t *testing.T, src, actionName string) (*Context, *symbols.Symbol idx := symbols.NewIndex() idx.AddDocument(path, file) resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 100000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 100000) sym := findSymbolOfKind(idx.DocumentRoot(path), actionName, symbols.SymbolActionDef, symbols.SymbolActionUsage) if sym == nil { @@ -60,7 +60,7 @@ func loadState(t *testing.T, src, stateName string) (*Context, *symbols.Symbol) idx := symbols.NewIndex() idx.AddDocument(path, file) resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 100000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 100000) sym := findSymbolOfKind(idx.DocumentRoot(path), stateName, symbols.SymbolStateDef, symbols.SymbolStateUsage) if sym == nil { diff --git a/internal/core/runtime/invoke_calc.go b/internal/exec/runtime/invoke_calc.go similarity index 96% rename from internal/core/runtime/invoke_calc.go rename to internal/exec/runtime/invoke_calc.go index d6382c71ee..c324e3a0c1 100644 --- a/internal/core/runtime/invoke_calc.go +++ b/internal/exec/runtime/invoke_calc.go @@ -5,10 +5,10 @@ import ( "sort" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lexer" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // calcParameter is one input parameter of a calc, in positional order. @@ -233,7 +233,7 @@ func (ctx *Context) calcInterfaceOf(sym *symbols.Symbol) (*calcShape, error) { return nil, fmt.Errorf("%w: %s states or inherits a result expression from each of %s", ErrConflictingResultExpressions, label, strings.Join(names, ", ")) } - body, bodyOwner := calcBody(chain) + body, bodyOwner := ctx.calcBody(chain) shape := &calcShape{ Sym: sym, Name: name, @@ -319,7 +319,7 @@ func (ctx *Context) calcParameters(chain []*symbols.Symbol, aliases *map[string] index := make(map[string]int) for _, link := range chain { - for _, member := range declMembers(link.Decl) { + for _, member := range unwrappedDeclMembers(link.Decl) { if subject, ok := subjectDeclaration(member); ok { params = ctx.subjectParameter(params, index, aliases, link, member, subject) continue @@ -377,12 +377,12 @@ func (ctx *Context) redeclaredIndex(index map[string]int, sym *symbols.Symbol, n // calcBody returns the computation the invoked calc runs — its own body if that // states one, otherwise the closest inherited one — with the calc that declares // it, whose scope the body's statements are written in. -func calcBody(chain []*symbols.Symbol) ([]lower.Statement, *symbols.Symbol) { +func (ctx *Context) calcBody(chain []*symbols.Symbol) ([]lower.Statement, *symbols.Symbol) { var stated []lower.Statement var owner *symbols.Symbol for i := len(chain) - 1; i >= 0; i-- { link := chain[i] - stmts := lower.CalcBody(link.Decl, declMembers(link.Decl), link.Scope) + stmts := lower.CalcBodyWith(link.Decl, unwrappedDeclMembers(link.Decl), link.Scope, ctx.Resolver()) if lower.Returns(stmts) { return stmts, link } @@ -402,7 +402,7 @@ func unboundResultHint(chain []*symbols.Symbol) string { if chain[i] == nil { continue } - members := declMembers(chain[i].Decl) + members := unwrappedDeclMembers(chain[i].Decl) result := unboundResultParameter(members) if result == nil { continue @@ -411,7 +411,7 @@ func unboundResultHint(chain []*symbols.Symbol) string { who, trailing, expr := "the result parameter", "of the body", "" typ := lower.TypeText(result) if name != "" { - spelled := lexer.NameText(name) + spelled := source.NameText(name) who = "result parameter " + spelled if sibling := valuedMemberNamed(members, name, result); sibling != nil { trailing, expr = "`"+spelled+"`", spelled @@ -785,7 +785,7 @@ func (ctx *Context) runCalcBody(shape *calcShape, frame *invocationFrame, caller frame.env = stmtEnv{data: frame.locals(), enclosing: shape.bodyEnclosing(enclosing)} frame.engine = stmtEngine{ctx: ctx, host: &frame.host, env: &frame.env, activation: activation, frameBuf: frame.engine.frameBuf} frame.host.attachPerformances(&frame.engine) - result, returned, err := runCalcSteps(&frame.engine, &frame.host, shape) + result, returned, err := runCalcSteps(&frame.engine, &frame.host, shape.Steps) if err != nil { return Value{}, err } @@ -809,17 +809,27 @@ func (ctx *Context) runCalcBody(shape *calcShape, frame *invocationFrame, caller return run.value(ctx, out) } -// runCalcSteps runs the calc's lowered computation on engine, whose data holds -// the calc's parameters on the way in and its locals on the way out, reporting +// runCalcSteps runs the calc's lowered steps on engine, whose data holds the +// calc's parameters on the way in and its locals on the way out, reporting // the value host took from a `return` and whether the body returned one. -func runCalcSteps(engine *stmtEngine, host *calcStmtHost, shape *calcShape) (Value, bool, error) { - flow, err := engine.run(shape.Steps) +func runCalcSteps(engine *stmtEngine, host *calcStmtHost, steps []lower.Statement) (Value, bool, error) { + flow, err := engine.run(steps) if err != nil { return Value{}, false, err } return host.result, flow == flowReturn, nil } +// observationSteps splits Steps at the results ending them (lower.IsResult): the +// steps a run of a Monte Carlo case performs, then the results run over its sample. +func (shape *calcShape) observationSteps() (steps, results []lower.Statement) { + end := len(shape.Steps) + for end > 0 && lower.IsResult(shape.Steps[end-1]) { + end-- + } + return shape.Steps[:end], shape.Steps[end:] +} + // checkArgs rejects an argument list that cannot bind to the parameters at all: // more positional arguments than parameters, or a name that is not one. func (shape *calcShape) checkArgs(args calcArgs) error { @@ -1057,7 +1067,7 @@ func (ctx *Context) redeclaresInputs(chain []*symbols.Symbol) bool { // owns, in declaration order. func (ctx *Context) ownedInputSymbols(sym *symbols.Symbol) []*symbols.Symbol { var inputs []*symbols.Symbol - for _, member := range declMembers(sym.Decl) { + for _, member := range unwrappedDeclMembers(sym.Decl) { usage, ok := member.(*ast.Usage) if !ok || (usage.Direction != ast.DirIn && usage.Direction != ast.DirInOut) { continue @@ -1149,7 +1159,7 @@ func (ctx *Context) applyLibraryPerformance(perf *libraryPerformance, args calcA // calcComputes reports whether a calc chain states a computation: a body that // returns or assigns an output, or a binding of the result. func (ctx *Context) calcComputes(chain []*symbols.Symbol) bool { - body, _ := calcBody(chain) + body, _ := ctx.calcBody(chain) if lower.Returns(body) || resultBindingExpr(calcBindings(chain)) != nil { return true } @@ -1239,19 +1249,14 @@ func isStateSymbol(sym *symbols.Symbol) bool { return sym.Kind == symbols.SymbolStateDef || sym.Kind == symbols.SymbolStateUsage } -// declMembers returns the body members of a definition, usage or named owned -// constraint, unwrapping the Membership wrappers the parser produces. -func declMembers(decl ast.Node) []ast.Node { - var members []ast.Node +// unwrappedDeclMembers is ast.DeclMembers extended to named owned constraints, +// with the Membership wrappers the parser produces unwrapped. +func unwrappedDeclMembers(decl ast.Node) []ast.Node { if oc, ok := ast.OwnedConstraintOf(decl); ok { return oc.Body } - switch d := decl.(type) { - case *ast.Definition: - members = d.Members - case *ast.Usage: - members = d.Members - default: + members := ast.DeclMembers(decl) + if members == nil { return nil } diff --git a/internal/core/runtime/invoke_calc_body_test.go b/internal/exec/runtime/invoke_calc_body_test.go similarity index 98% rename from internal/core/runtime/invoke_calc_body_test.go rename to internal/exec/runtime/invoke_calc_body_test.go index 7f50daccc3..f52561abba 100644 --- a/internal/core/runtime/invoke_calc_body_test.go +++ b/internal/exec/runtime/invoke_calc_body_test.go @@ -5,11 +5,11 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // inheritedBodyModel declares the same attribute name in two packages: the diff --git a/internal/core/runtime/invoke_calc_builtin_test.go b/internal/exec/runtime/invoke_calc_builtin_test.go similarity index 99% rename from internal/core/runtime/invoke_calc_builtin_test.go rename to internal/exec/runtime/invoke_calc_builtin_test.go index f8558de717..c69fc76f9e 100644 --- a/internal/core/runtime/invoke_calc_builtin_test.go +++ b/internal/exec/runtime/invoke_calc_builtin_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // directBuiltinModel declares calcs that reach built-ins through invocation diff --git a/internal/core/runtime/invoke_calc_frames_test.go b/internal/exec/runtime/invoke_calc_frames_test.go similarity index 97% rename from internal/core/runtime/invoke_calc_frames_test.go rename to internal/exec/runtime/invoke_calc_frames_test.go index 02f57d8d28..3574b6de8e 100644 --- a/internal/core/runtime/invoke_calc_frames_test.go +++ b/internal/exec/runtime/invoke_calc_frames_test.go @@ -6,9 +6,9 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // framesModel exercises the frames a recursion runs in: fib reads its parameter diff --git a/internal/core/runtime/invoke_calc_recursion_test.go b/internal/exec/runtime/invoke_calc_recursion_test.go similarity index 96% rename from internal/core/runtime/invoke_calc_recursion_test.go rename to internal/exec/runtime/invoke_calc_recursion_test.go index 9fc444a69e..4dadf47c37 100644 --- a/internal/core/runtime/invoke_calc_recursion_test.go +++ b/internal/exec/runtime/invoke_calc_recursion_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // recursiveSumModel sums the integers up to its argument by recursion, so the diff --git a/internal/exec/runtime/invoke_operation.go b/internal/exec/runtime/invoke_operation.go new file mode 100644 index 0000000000..0ea355da77 --- /dev/null +++ b/internal/exec/runtime/invoke_operation.go @@ -0,0 +1,240 @@ +package runtime + +import ( + "errors" + "fmt" + "maps" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// OperationArguments is an invocation's argument list (KerML 8.2.5.8.3): either +// positional, bound to the operation's `in` and `inout` parameters in declaration +// order, or named. A list giving both is refused. +type OperationArguments struct { + Positional []Value + Named map[string]Value +} + +// InvokeOperation runs a behavior the object's type owns with the object as the +// performer: what the body reads and writes is that object's feature values, and +// what it sends and accepts carries that object's identity. Arguments bind to the +// operation's `in` and `inout` parameters by name. +func (ctx *Context) InvokeOperation(inst *Instance, name string, args map[string]Value) (map[string]Value, error) { + return ctx.InvokeOperationWith(inst, name, OperationArguments{Named: args}) +} + +// InvokeOperationWith is InvokeOperation taking either argument list form. +func (ctx *Context) InvokeOperationWith(inst *Instance, name string, args OperationArguments) (map[string]Value, error) { + defer ctx.beginRun()() + + if inst == nil { + return nil, fmt.Errorf("%w: no object to perform %s", ErrNoSuchBehavior, name) + } + if err := ctx.checkPerformer(inst); err != nil { + return nil, fmt.Errorf("invoke %s on object #%d: %w", name, inst.ID, err) + } + if len(args.Positional) > 0 && len(args.Named) > 0 { + return nil, fmt.Errorf("%w: operation %s is invoked with %d positional and %d named argument(s)", + ErrMixedArguments, name, len(args.Positional), len(args.Named)) + } + sym, err := ctx.operationOf(inst, name, args) + if err != nil { + return nil, err + } + inputs, err := operationInputs(ctx.model.semantics.SignatureParametersOf(sym), name, args) + if err != nil { + return nil, err + } + switch { + case isActionSymbol(sym): + results, err := ctx.ExecuteActionPerformedBy(sym, inst, inputs) + if err != nil { + return nil, fmt.Errorf("invoke %s on object #%d: %w", name, inst.ID, err) + } + + _, out := parameterNames(ctx.actionParametersOf(sym)) + outputs := make(map[string]Value, len(out)) + for _, param := range out { + if value, ok := results[param]; ok { + outputs[param] = value + } + } + return outputs, nil + case isCalcSymbol(sym): + shape, err := ctx.calcShapeOf(sym) + if err != nil { + return nil, fmt.Errorf("invoke %s on object #%d: %w", name, inst.ID, err) + } + result, err := ctx.invokeCalcNamedShapeOn(shape, inputs, DeclScope(sym), inst) + if err != nil { + return nil, fmt.Errorf("invoke %s on object #%d: %w", name, inst.ID, err) + } + return map[string]Value{shape.resultName(): result}, nil + case isConstraintSymbol(sym): + holds, err := ctx.evaluateConstraintInvocation(sym, DeclScope(sym), inst, inputs) + if err != nil { + return nil, fmt.Errorf("invoke %s on object #%d: %w", name, inst.ID, err) + } + return map[string]Value{"result": boolValue(holds)}, nil + } + return nil, fmt.Errorf("%w: %s of %s", ErrNotABehavior, name, symbolText(inst.Type)) +} + +// resultName is the name an invocation returns the calc's result under: its +// named result parameter, else `result`. +func (shape *calcShape) resultName() string { + for _, output := range shape.Outputs { + if output.IsResult && output.Name != "" { + return output.Name + } + } + return "result" +} + +// operationOf resolves the member of the object's type that name invokes — among +// several so named, the one the arguments' values select as a call in the model +// would — and reports a member that states no executable behavior. +func (ctx *Context) operationOf(inst *Instance, name string, args OperationArguments) (*symbols.Symbol, error) { + member, err := ctx.memberCalled(inst.Type, inst, name, args) + if err != nil { + return nil, err + } + if member == nil { + return nil, fmt.Errorf("%w: %s of object #%d (type %s)", + ErrNoSuchBehavior, name, inst.ID, symbolText(inst.Type)) + } + switch member.Kind { + case symbols.SymbolActionDef, symbols.SymbolActionUsage: + return member, nil + case symbols.SymbolStateDef, symbols.SymbolStateUsage: + return nil, fmt.Errorf("%w: %s of %s is a state machine, which runs as the object's exhibited machine", + ErrUnsupportedClassifierBehavior, name, symbolText(inst.Type)) + case symbols.SymbolCalcDef, symbols.SymbolCalcUsage, + symbols.SymbolConstraintDef, symbols.SymbolConstraintUsage: + return member, nil + default: + return nil, fmt.Errorf("%w: %s of %s is a %s", + ErrNotABehavior, name, symbolText(inst.Type), member.Kind) + } +} + +// memberCalled is the member of owner that a call of name with args denotes: +// among several so named, the one the arguments' values select as a call in +// the model would, evaluated as self; nil when none is so named. +func (ctx *Context) memberCalled(owner *symbols.Symbol, self *Instance, name string, args OperationArguments) (*symbols.Symbol, error) { + var candidates []*symbols.Symbol + for _, candidate := range ctx.model.semantics.MembersOf(owner) { + if candidate.Name == name { + candidates = append(candidates, candidate) + } + } + switch len(candidates) { + case 0: + return nil, nil + case 1: + return candidates[0], nil + } + scope := DeclScope(owner) + ec := NewEvalContextIn(ctx, scope, self) + typed := make([]semantics.Argument, 0, len(args.Positional)+len(args.Named)) + for _, value := range args.Positional { + typed = append(typed, ec.valueArgument(value, nil)) + } + for _, param := range slices.Sorted(maps.Keys(args.Named)) { + typed = append(typed, ec.valueArgument(args.Named[param], ast.QualifiedNameOf(param))) + } + sel := ctx.model.semantics.SelectAmongArguments(scope, candidates, typed, semantics.PerformsOperation) + if sel.Ambiguous || sel.Called() == nil { + return nil, ambiguousInvocationError(name, sel.Tied) + } + return sel.Called(), nil +} + +func isConstraintSymbol(sym *symbols.Symbol) bool { + if sym == nil { + return false + } + if _, ok := ast.OwnedConstraintOf(sym.Decl); ok { + return true + } + switch decl := sym.Decl.(type) { + case *ast.Definition: + return decl.Kind == ast.DefConstraint + case *ast.Usage: + return decl.Kind == ast.UsageConstraint + } + return sym.Kind == symbols.SymbolConstraintDef || sym.Kind == symbols.SymbolConstraintUsage +} + +func (ctx *Context) evaluateConstraintInvocation(sym *symbols.Symbol, scope *symbols.Scope, self *Instance, bindings map[string]Value) (bool, error) { + if err := RequireConstraint(sym); err != nil { + return false, err + } + subject, err := ctx.checkSubject("constraint", sym.Name, sym, self) + if err != nil { + return false, err + } + holds, err := ctx.evaluateConditions(conditionCheck{ + sym: sym, + kind: "constraint", + what: "assertion", + self: subject.instance, + bindings: mapFrame(bindings), + negated: NegatedDecl(sym), + }, ctx.conditionsOf(sym, ctx.chainMembers(sym, scope))) + if errors.Is(err, ErrViolated) { + return false, nil + } + return holds, err +} + +// operationInputs binds arguments to the operation's input parameters — a positional +// list in signature order, a named one by name — reporting a surplus positional, an +// argument naming no parameter and a parameter left with no value: any would +// otherwise run the body against values the invocation never stated. +func operationInputs(params []semantics.SignatureParameter, name string, args OperationArguments) (map[string]Value, error) { + named := args.Named + if len(args.Positional) > 0 { + if len(args.Positional) > len(params) { + return nil, fmt.Errorf("%w: operation %s takes %d input parameter(s), got %d argument(s)", + ErrOperationArity, name, len(params), len(args.Positional)) + } + named = make(map[string]Value, len(args.Positional)) + for i, value := range args.Positional { + named[params[i].Name] = value + } + } + inputs := make(map[string]Value, len(named)) + for _, param := range params { + value, bound := named[param.Name] + switch { + case bound: + inputs[param.Name] = value + case param.Optional: + default: + return nil, fmt.Errorf("%w: parameter %s of operation %s has no argument and no default", + ErrUnboundParameter, param.Name, name) + } + } + for arg := range named { + if !bindsParameter(params, arg) { + return nil, fmt.Errorf("%w: %s is no input parameter of operation %s", + ErrUnboundParameter, arg, name) + } + } + return inputs, nil +} + +// bindsParameter reports whether name is an input parameter an invocation binds. +func bindsParameter(params []semantics.SignatureParameter, name string) bool { + for _, param := range params { + if param.Name == name { + return true + } + } + return false +} diff --git a/internal/core/runtime/invoke_predicate.go b/internal/exec/runtime/invoke_predicate.go similarity index 96% rename from internal/core/runtime/invoke_predicate.go rename to internal/exec/runtime/invoke_predicate.go index bdc018767c..deebac36b6 100644 --- a/internal/core/runtime/invoke_predicate.go +++ b/internal/exec/runtime/invoke_predicate.go @@ -3,8 +3,8 @@ package runtime import ( "errors" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // A constraint or requirement is a predicate (KerML §7.4.9; SysML v2 §7.19, §7.21): @@ -145,6 +145,9 @@ func flattenFrames(frames []frame) frame { if f.perf != nil { out.perf = f.perf } + if f.firing != nil { + out.firing = f.firing + } if run := f.running(); run != nil { out.merged = append(out.merged, run) } diff --git a/internal/exec/runtime/library_census_probes_test.go b/internal/exec/runtime/library_census_probes_test.go new file mode 100644 index 0000000000..be2563e35e --- /dev/null +++ b/internal/exec/runtime/library_census_probes_test.go @@ -0,0 +1,484 @@ +package runtime + +import ( + "math" + "strings" +) + +// censusPackages are the analysis-domain libraries and the function libraries +// they build on, with the representative invocation of each declaration. +var censusPackages = []censusPackage{ + {name: "AnalysisTooling", path: "Domain Libraries/Analysis/AnalysisTooling.sysml"}, + {name: "SampledFunctions", path: "Domain Libraries/Analysis/SampledFunctions.sysml", probes: sampledFunctionProbes}, + {name: "TradeStudies", path: "Domain Libraries/Analysis/TradeStudies.sysml", probes: tradeStudyProbes}, + {name: "StateSpaceRepresentation", path: "Domain Libraries/Analysis/StateSpaceRepresentation.sysml", probes: stateSpaceProbes}, + {name: "VectorFunctions", path: "Kernel Libraries/Kernel Function Library/VectorFunctions.kerml", probes: vectorFunctionProbes}, + {name: "OccurrenceFunctions", path: "Kernel Libraries/Kernel Function Library/OccurrenceFunctions.kerml", probes: occurrenceFunctionProbes}, +} + +// probeModel wraps the declarations of a probe in the package `test`, importing +// the library packages it uses. +func probeModel(imports, body string) string { + model := "package test {\n\tprivate import ScalarValues::*;\n" + for _, pkg := range strings.Fields(imports) { + model += "\tprivate import " + pkg + "::*;\n" + } + return model + body + "}\n" +} + +func expectReal(v float64) ExpectedValue { return ExpectedValue{Type: "Real", Value: v} } + +func expectBool(v bool) ExpectedValue { return ExpectedValue{Type: "Boolean", Value: v} } + +func expectInt(v int64) ExpectedValue { return ExpectedValue{Type: "Integer", Value: float64(v)} } + +func expectReals(vs ...float64) []ExpectedValue { + elements := make([]ExpectedValue, len(vs)) + for i, v := range vs { + elements[i] = expectReal(v) + } + return elements +} + +func expectVector(vs ...float64) ExpectedValue { + return ExpectedValue{Type: "Vector", Elements: expectReals(vs...)} +} + +func expectSequence(vs ...float64) ExpectedValue { + return ExpectedValue{Type: "Sequence", Elements: expectReals(vs...)} +} + +// sampledPairs is a sampled function of two samples, (1 → 2) and (3 → 4). +const sampledPairs = ` attribute s : SampledFunction = new SampledFunction( + samples = (new SamplePair(1.0, 2.0), new SamplePair(3.0, 4.0))); +` + +var sampledFunctionProbes = []libraryProbe{ + { + decl: "SampledFunctions::Domain", + model: probeModel("SampledFunctions", sampledPairs+"\tattribute r = Domain(s);\n"), + read: "test::r", + want: expectSequence(1.0, 3.0), + }, + { + decl: "SampledFunctions::Range", + model: probeModel("SampledFunctions", sampledPairs+"\tattribute r = Range(s);\n"), + read: "test::r", + want: expectSequence(2.0, 4.0), + }, + { + decl: "SampledFunctions::Sample", + model: probeModel("SampledFunctions", ` calc def Sq { in v : Real; return : Real = v * v; } + attribute r = Range(Sample(Sq, (1.0, 2.0, 3.0))); +`), + read: "test::r", + want: expectSequence(1.0, 4.0, 9.0), + }, + { + decl: "SampledFunctions::Interpolate", + model: probeModel("SampledFunctions", sampledPairs+` calc def First :> Interpolate { return :>> result = fn.samples#(1).rangeValue; } + attribute r = First(s, 9.0); +`), + read: "test::r", + want: expectReal(2.0), + }, + { + // The library's Linear formula: f = (2-1)/(1-3) = -0.5, 4 + f*(2-4) = 5. + decl: "SampledFunctions::interpolateLinear", + model: probeModel("SampledFunctions", sampledPairs+"\tattribute r = interpolateLinear(s, 2.0);\n"), + read: "test::r", + want: expectReal(5.0), + }, +} + +// engines are two alternatives a trade study chooses between. +const engines = ` part def Engine { attribute mass : Real; } + part light : Engine { attribute :>> mass = 10.0; } + part heavy : Engine { attribute :>> mass = 20.0; } +` + +// byMass is a trade study over the two engines with the objective named. +func byMass(objective string) string { + return engines + ` analysis study : TradeStudy { + subject : Engine[1..*] = (heavy, light); + objective : ` + objective + `; + calc :>> evaluationFunction { + in part e :>> alternative : Engine; + return :>> result : Real = e.mass; + } + return part :>> selectedAlternative : Engine; + } +` +} + +var tradeStudyProbes = []libraryProbe{ + { + decl: "TradeStudies::EvaluationFunction", + model: probeModel("TradeStudies", engines+` calc massOf : EvaluationFunction { + in part e :>> alternative : Engine; + return :>> result : Real = e.mass; + } + attribute r : Real = massOf(heavy); +`), + read: "test::r", + want: expectReal(20.0), + }, + { + decl: "TradeStudies::TradeStudyObjective", + model: probeModel("TradeStudies", ` requirement def ExactlyTwenty :> TradeStudyObjective { attribute :>> best = 20.0; } +`+byMass("ExactlyTwenty")+"\tattribute r : Real = study.selectedAlternative.mass;\n"), + read: "test::r", + want: expectReal(20.0), + }, + { + decl: "TradeStudies::MinimizeObjective", + model: probeModel("TradeStudies", byMass("MinimizeObjective")+"\tattribute r : Real = study.selectedAlternative.mass;\n"), + read: "test::r", + want: expectReal(10.0), + }, + { + decl: "TradeStudies::MaximizeObjective", + model: probeModel("TradeStudies", byMass("MaximizeObjective")+"\tattribute r : Real = study.selectedAlternative.mass;\n"), + read: "test::r", + want: expectReal(20.0), + }, + { + decl: "TradeStudies::TradeStudy", + model: probeModel("TradeStudies", byMass("MinimizeObjective")+"\tattribute r : Boolean = study.selectedAlternative === light;\n"), + read: "test::r", + want: expectBool(true), + }, + { + decl: "TradeStudies::TradeStudy::evaluationFunction", + model: probeModel("TradeStudies", byMass("MinimizeObjective")+"\tattribute r : Real = study.evaluationFunction(heavy);\n"), + read: "test::r", + want: expectReal(20.0), + }, + { + decl: "TradeStudies::TradeStudy::tradeStudyObjective", + model: probeModel("TradeStudies", byMass("MinimizeObjective")), + analysis: "test::study", + verdict: "tradeStudyObjective", + want: expectBool(true), + }, +} + +// stateSpaceModel specializes the state-space library as a model does: a calc for +// each abstract calc definition and a dynamics of each kind over a two-axis position, +// the continuous and discrete ones stepping once over one second. +const stateSpaceModel = ` private import Quantities::*; + private import SI::*; + private import VectorFunctions::*; + attribute def Force :> Input; + attribute def Position :> StateSpace; + attribute def Reading :> Output; + attribute force : Force = 2 [N] * VectorOf((1.0, 2.0)); + attribute position : Position = 1 [m] * VectorOf((1.0, 1.0)); + calc def Hold :> GetNextState { + in input : Input; + in stateSpace : StateSpace; + in timeStep : DurationValue; + return : StateSpace = stateSpace; + } + calc def Echo :> GetOutput { + in input : Input; + in stateSpace : StateSpace; + return : Output = stateSpace; + } + calc def Rate :> GetDerivative { + in input : Input; + in stateSpace : StateSpace; + return : StateDerivative = stateSpace / 1 [s]; + } + calc def Euler :> Integrate { + in getDerivative : GetDerivative; + in input : Input; + in initialState : StateSpace; + in timeInterval : DurationValue; + return result : StateSpace = initialState + getDerivative(input, initialState) * timeInterval; + } + calc def Shift :> GetDifference { + in input : Input; + in stateSpace : StateSpace; + return : StateSpace = stateSpace; + } + action def Plant :> StateSpaceDynamics { + calc :>> getNextState : Hold; + calc :>> getOutput : Echo; + attribute :>> stateSpace = position; + in :>> input = force; + } + action plant : Plant; + action def Damper :> ContinuousStateSpaceDynamics, StateSpaceIntegration::FixedStepDynamics { + calc :>> getDerivative : Rate; + calc :>> getOutput : Echo; + attribute :>> stateSpace = position; + attribute :>> timeStep = 1 [s]; + attribute :>> stopTime = 1 [s]; + in :>> input = force; + } + action damper : Damper; + action def Spring :> DiscreteStateSpaceDynamics, StateSpaceIntegration::FixedStepDynamics { + calc :>> getDifference : Shift; + calc :>> getOutput : Echo; + attribute :>> stateSpace = position; + attribute :>> timeStep = 1 [s]; + attribute :>> stopTime = 1 [s]; + in :>> input = force; + } + action spring : Spring; +` + +// metres is a two-axis vector quantity in metres. +func metres(vs ...float64) ExpectedValue { + elements := make([]ExpectedValue, len(vs)) + for i, v := range vs { + elements[i] = ExpectedValue{Type: "Quantity", Value: v, Unit: "m"} + } + return ExpectedValue{Type: "VectorQuantity", Elements: elements} +} + +// stateSpaceProbes invoke each declaration as the library declares it; the continuous +// and discrete dynamics run one step of the state-space runner, the base dynamics has none. +var stateSpaceProbes = []libraryProbe{ + stateSpaceRead("GetNextState", "Hold(force, position, 1 [s])", metres(1.0, 1.0)), + stateSpaceRead("GetOutput", "Echo(force, position)", metres(1.0, 1.0)), + stateSpaceAction("StateSpaceEventDef"), + stateSpaceAction("ZeroCrossingEventDef"), + stateSpacePerform("StateSpaceDynamics", "plant", metres(1.0, 1.0)), + stateSpaceRead("StateSpaceDynamics::getNextState", "plant.getNextState(force, position, 1 [s])", metres(1.0, 1.0)), + stateSpaceRead("StateSpaceDynamics::getOutput", "plant.getOutput(force, position)", metres(1.0, 1.0)), + stateSpaceRead("GetDerivative", "Rate(force, position)", ExpectedValue{Type: "VectorQuantity", Elements: []ExpectedValue{ + {Type: "Quantity", Value: 1.0, Unit: "SI::'m/s'"}, {Type: "Quantity", Value: 1.0, Unit: "SI::'m/s'"}}}), + stateSpaceRead("Integrate", "Euler(Rate, force, position, 2 [s])", metres(3.0, 3.0)), + // One RK4 step of dx/dt = x over a second: 1 + 1 + 1/2 + 1/6 + 1/24. + stateSpacePerform("ContinuousStateSpaceDynamics", "damper", metres(2.7083333333333335, 2.7083333333333335)), + stateSpaceRead("ContinuousStateSpaceDynamics::getDerivative", "damper.getDerivative(force, position)", ExpectedValue{ + Type: "VectorQuantity", Elements: []ExpectedValue{ + {Type: "Quantity", Value: 1.0, Unit: "SI::'m/s'"}, {Type: "Quantity", Value: 1.0, Unit: "SI::'m/s'"}}}), + // The library leaves Integrate abstract; an Euler step over one second is the value checked. + stateSpaceRead("ContinuousStateSpaceDynamics::getNextState", "damper.getNextState(force, position, 1 [s])", metres(2.0, 2.0)), + stateSpaceRead("ContinuousStateSpaceDynamics::getNextState::integrate", "damper.getNextState.integrate.result", metres(2.0, 2.0)), + stateSpaceRead("GetDifference", "Shift(force, position)", metres(1.0, 1.0)), + stateSpacePerform("DiscreteStateSpaceDynamics", "spring", metres(2.0, 2.0)), + stateSpaceRead("DiscreteStateSpaceDynamics::getDifference", "spring.getDifference(force, position)", metres(1.0, 1.0)), + stateSpaceRead("DiscreteStateSpaceDynamics::getNextState", "spring.getNextState(force, position, 1 [s])", metres(2.0, 2.0)), +} + +// stateSpaceRead evaluates an expression over stateSpaceModel and checks its value. +func stateSpaceRead(name, expr string, want ExpectedValue) libraryProbe { + return libraryProbe{ + decl: "StateSpaceRepresentation::" + name, + model: probeModel("StateSpaceRepresentation", stateSpaceModel+"\tattribute r = "+expr+";\n"), + read: "test::r", + want: want, + } +} + +// stateSpacePerform performs one of stateSpaceModel's dynamics and reads its output. +func stateSpacePerform(name, usage string, want ExpectedValue) libraryProbe { + return libraryProbe{ + decl: "StateSpaceRepresentation::" + name, + model: probeModel("StateSpaceRepresentation", stateSpaceModel), + action: "test::" + usage, + output: "output", + want: want, + } +} + +// stateSpaceAction performs an event definition as declared; it has no body and +// no output, so no value it could answer passes. +func stateSpaceAction(name string) libraryProbe { + return libraryProbe{ + decl: "StateSpaceRepresentation::" + name, + model: probeModel("StateSpaceRepresentation", "\taction run : "+name+";\n"), + action: "test::run", + output: "output", + want: metres(0.0), + } +} + +var vectorFunctionProbes = []libraryProbe{ + vectorProbe("isZeroVector", "isZeroVector(CartesianVectorOf((0.0, 0.0)))", expectBool(true)), + vectorProbe("+", "VectorFunctions::'+'((1.0, 2.0), (3.0, 4.0))", expectVector(4.0, 6.0)), + vectorProbe("-", "VectorFunctions::'-'((1.0, 2.0), (3.0, 4.0))", expectVector(-2.0, -2.0)), + vectorProbe("sum0", "sum0((CartesianVectorOf((1.0, 2.0)), CartesianVectorOf((3.0, 4.0))), CartesianVectorOf((0.0, 0.0)))", expectVector(4.0, 6.0)), + vectorProbe("VectorOf", "VectorOf((1.0, 2.0, 3.0))", expectVector(1.0, 2.0, 3.0)), + vectorProbe("scalarVectorMult", "scalarVectorMult(2.0, (1.0, 2.0))", expectVector(2.0, 4.0)), + vectorProbe("vectorScalarMult", "vectorScalarMult((1.0, 2.0), 3.0)", expectVector(3.0, 6.0)), + vectorProbe("vectorScalarDiv", "vectorScalarDiv((2.0, 4.0), 2.0)", expectVector(1.0, 2.0)), + vectorProbe("inner", "inner((1.0, 2.0), (3.0, 4.0))", expectReal(11.0)), + vectorProbe("norm", "norm((3.0, 4.0))", expectReal(5.0)), + vectorProbe("angle", "angle((1.0, 0.0), (0.0, 1.0))", expectReal(math.Pi/2)), + vectorProbe("CartesianVectorOf", "CartesianVectorOf((1.0, 2.0))", expectVector(1.0, 2.0)), + vectorProbe("CartesianThreeVectorOf", "CartesianThreeVectorOf((1.0, 2.0, 3.0))", expectVector(1.0, 2.0, 3.0)), + vectorProbe("isCartesianZeroVector", "isCartesianZeroVector(CartesianVectorOf((0.0, 1.0)))", expectBool(false)), + vectorProbe("cartesian+", "'cartesian+'((1.0, 2.0), (3.0, 4.0))", expectVector(4.0, 6.0)), + vectorProbe("cartesian-", "'cartesian-'((1.0, 2.0), (3.0, 4.0))", expectVector(-2.0, -2.0)), + vectorProbe("cartesianScalarVectorMult", "cartesianScalarVectorMult(2.0, (1.0, 2.0))", expectVector(2.0, 4.0)), + vectorProbe("cartesianVectorScalarMult", "cartesianVectorScalarMult((1.0, 2.0), 2.0)", expectVector(2.0, 4.0)), + vectorProbe("cartesianInner", "cartesianInner((1.0, 2.0), (3.0, 4.0))", expectReal(11.0)), + vectorProbe("cartesianNorm", "cartesianNorm((3.0, 4.0))", expectReal(5.0)), + vectorProbe("cartesianAngle", "cartesianAngle((1.0, 0.0), (0.0, 1.0))", expectReal(math.Pi/2)), + vectorProbe("sum", "sum((CartesianThreeVectorOf((1.0, 2.0, 3.0)), CartesianThreeVectorOf((4.0, 5.0, 6.0))))", expectVector(5.0, 7.0, 9.0)), + invariantProbe("'+'", "zeroAddition", plusParams, twoVectors, "VectorOf", true), + invariantProbe("'+'", "commutivity", plusParams, twoVectors, "VectorOf", true), + invariantProbe("'-'", "negation", plusParams, twoVectors, "VectorOf", true), + // The text reads v + u == w, which u = v - w = (-2, -2) does not satisfy: v + u = (-1, 0). + invariantProbe("'-'", "difference", plusParams, twoVectors, "VectorOf", false), + invariantProbe("sum0", "precondition", "in coll : VectorValue[*]; in zero : VectorValue; return :>> s", "((), CartesianVectorOf((0.0, 0.0)))", "VectorOf", true), + invariantProbe("scalarVectorMult", "scaling", scalingParams, "(2.0, VectorOf((1.0, 2.0)))", "VectorOf", true), + invariantProbe("scalarVectorMult", "zeroLength", scalingParams, "(2.0, VectorOf((1.0, 2.0)))", "VectorOf", true), + invariantProbe("inner", "commmutivity", innerParams, twoVectors, "", true), + invariantProbe("inner", "zeroInner", innerParams, twoVectors, "", true), + invariantProbe("norm", "squareNorm", normParams, "(VectorOf((3.0, 4.0)))", "", true), + invariantProbe("norm", "lengthZero", normParams, "(VectorOf((3.0, 4.0)))", "", true), + invariantProbe("angle", "commutivity", angleParams, axes, "", true), + invariantProbe("angle", "lengthInsensitive", angleParams, axes, "", true), + invariantProbe("'cartesian+'", "precondition", cartesianPlusParams, twoCartesian, "CartesianVectorOf", true), + invariantProbe("'cartesian-'", "precondition", cartesianPlusParams, twoCartesian, "CartesianVectorOf", true), + invariantProbe("cartesianInner", "precondition", "in v : CartesianVectorValue; in w : CartesianVectorValue; return :>> x", twoCartesian, "", true), + invariantProbe("cartesianAngle", "precondition", "in v : CartesianVectorValue; in w : CartesianVectorValue; return :>> theta", "(CartesianVectorOf((1.0, 0.0)), CartesianVectorOf((0.0, 1.0)))", "", true), +} + +// The parameter lists of the functions whose invariants are probed, as the library declares them. +const ( + plusParams = "in v : VectorValue; in w : VectorValue[0..1]; return :>> u" + scalingParams = "in x : NumericalValue; in v : NumericalVectorValue; return :>> w" + innerParams = "in v : NumericalVectorValue; in w : NumericalVectorValue; return :>> x" + normParams = "in v : NumericalVectorValue; return :>> l" + angleParams = "in v : NumericalVectorValue; in w : NumericalVectorValue; return :>> theta" + cartesianPlusParams = "in v : CartesianVectorValue; in w : CartesianVectorValue[0..1]; return :>> u" + twoVectors = "(VectorOf((1.0, 2.0)), VectorOf((3.0, 4.0)))" + twoCartesian = "(CartesianVectorOf((1.0, 2.0)), CartesianVectorOf((3.0, 4.0)))" + axes = "(VectorOf((1.0, 0.0)), VectorOf((0.0, 1.0)))" +) + +// invariantProbe invokes a function's invariant where its parameters are bound: from the +// result of a specialization, which is 1 when the invariant holds and 0 when it does not. +func invariantProbe(fn, inv, params, args, marker string, holds bool) libraryProbe { + one, zero, want := "1.0", "0.0", expectReal(0.0) + if marker != "" { + one, zero, want = marker+"(1.0)", marker+"(0.0)", expectVector(0.0) + } + if holds { + want = expectReal(1.0) + if marker != "" { + want = expectVector(1.0) + } + } + return libraryProbe{ + decl: "VectorFunctions::" + strings.Trim(fn, "'") + "::" + inv, + model: probeModel("VectorValues VectorFunctions", + "\tcalc def Probe :> "+fn+" { "+params+" = if "+inv+"() ? "+one+" else "+zero+"; }\n"+ + "\tattribute r = Probe"+args+";\n"), + read: "test::r", + want: want, + } +} + +// vectorProbe reads one expression over VectorFunctions at package level. +func vectorProbe(name, expr string, want ExpectedValue) libraryProbe { + return libraryProbe{ + decl: "VectorFunctions::" + name, + model: probeModel("VectorValues VectorFunctions", "\tattribute r = "+expr+";\n"), + read: "test::r", + want: want, + } +} + +// widgets is a part definition whose parts are the occurrences the probes act on. +const widgets = ` part def Widget { attribute n : Integer = 1; } +` + +// robot performs one action whose body assigns r from the statements given, +// over its part `arm`, its group `spares` and the parts `spare` and `fresh` +// the action holds. +func robot(body string) string { + return probeModel("OccurrenceFunctions", widgets+` private import SequenceFunctions::size; + part def Robot { + part arm : Widget; + part spares : Widget[0..*]; + attribute r : Boolean; + attribute count : Integer; + perform action go { + part fresh : Widget; + part spare : Widget; + first start; + then action work { +`+body+` } + then done; + } + } +`) +} + +var occurrenceFunctionProbes = []libraryProbe{ + { + decl: "OccurrenceFunctions::===", + model: probeModel("OccurrenceFunctions", widgets+` part bench { + part a : Widget; + part b : Widget; + attribute r : Boolean = OccurrenceFunctions::'==='(a, a) and not OccurrenceFunctions::'==='(a, b); + } +`), + instantiate: "test::bench", + slot: "r", + want: expectBool(true), + }, + { + decl: "OccurrenceFunctions::isDuring", + model: robot("\t\t\t\tassign r := isDuring(arm);\n"), + instantiate: "test::Robot", + slot: "r", + want: expectBool(true), + }, + { + decl: "OccurrenceFunctions::create", + model: robot("\t\t\t\tassign r := create(fresh) === fresh;\n"), + instantiate: "test::Robot", + slot: "r", + want: expectBool(true), + }, + { + decl: "OccurrenceFunctions::destroy", + model: robot("\t\t\t\tassign arm := destroy(arm);\n\t\t\t\tassign r := isDuring(arm);\n"), + instantiate: "test::Robot", + slot: "r", + want: expectBool(false), + }, + { + decl: "OccurrenceFunctions::addNew", + model: robot("\t\t\t\tassign spares := addNew(spares, spare);\n\t\t\t\tassign count := size(spares);\n"), + instantiate: "test::Robot", + slot: "count", + want: expectInt(1), + }, + { + decl: "OccurrenceFunctions::addNewAt", + model: robot("\t\t\t\tassign spares := addNewAt(spares, spare, 1);\n\t\t\t\tassign count := size(spares);\n"), + instantiate: "test::Robot", + slot: "count", + want: expectInt(1), + }, + { + decl: "OccurrenceFunctions::removeOld", + model: robot(` assign spares := addNew(spares, spare); + perform action clean : removeOld { in group = spares; in occ = spare; } + assign r := isDuring(spare); +`), + instantiate: "test::Robot", + slot: "r", + want: expectBool(false), + }, + { + decl: "OccurrenceFunctions::removeOldAt", + model: robot(` assign spares := addNew(spares, spare); + perform action clean : removeOldAt { in group = spares; in index = 1; } + assign r := isDuring(spare); +`), + instantiate: "test::Robot", + slot: "r", + want: expectBool(false), + }, +} diff --git a/internal/exec/runtime/library_census_test.go b/internal/exec/runtime/library_census_test.go new file mode 100644 index 0000000000..d152a4b686 --- /dev/null +++ b/internal/exec/runtime/library_census_test.go @@ -0,0 +1,424 @@ +package runtime + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" + "github.com/Open-MBEE/OpenSysML/tests/fixtures" +) + +var updateLibraryCensus = flag.Bool("update-library-census", false, "Rewrite docs/project/analysis-library-census.json from this run") + +// libraryCensusCommand reproduces the committed census. +const libraryCensusCommand = "go test ./internal/exec/runtime -run TestAnalysisLibraryCensus -update-library-census" + +// censusPackage is one library package the census measures: its file in the +// bundled standard library and a representative invocation of each callable declaration. +type censusPackage struct { + name string + path string + probes []libraryProbe +} + +// libraryProbe is one declaration's representative invocation: a model using it and the +// value checked, read as a feature (read), a slot (instantiate+slot), an action output or +// a case verdict (analysis+verdict). +type libraryProbe struct { + decl string + model string + read string + instantiate string + slot string + action string + output string + analysis string + verdict string + want ExpectedValue +} + +// undeterminedRefusal names the refusal an Undetermined result is: the runtime +// answering that the model does not determine the value, rather than a value. +const undeterminedRefusal = "Undetermined" + +// censusSentinels names the typed errors a refusal is recorded by. A refusal +// typed by none of them fails the census, so no untyped refusal is counted. +var censusSentinels = []struct { + name string + err error +}{ + {"ErrActionArity", ErrActionArity}, + {"ErrActionPerformanceOccurrence", ErrActionPerformanceOccurrence}, + {"ErrAmbiguousReference", ErrAmbiguousReference}, + {"ErrBodyArity", ErrBodyArity}, + {"ErrCalcArity", ErrCalcArity}, + {"ErrCalcNoReturn", ErrCalcNoReturn}, + {"ErrIndexOutOfRange", ErrIndexOutOfRange}, + {"ErrInvalidActionFlow", ErrInvalidActionFlow}, + {"ErrInvalidInput", ErrInvalidInput}, + {"ErrMultiplicityViolation", ErrMultiplicityViolation}, + {"ErrNoResultExpression", ErrNoResultExpression}, + {"ErrNoSuchBehavior", ErrNoSuchBehavior}, + {"ErrNoSuchFeature", ErrNoSuchFeature}, + {"ErrNoValue", ErrNoValue}, + {"ErrNotABehavior", ErrNotABehavior}, + {"ErrNotACalc", ErrNotACalc}, + {"ErrNotACalcUsage", ErrNotACalcUsage}, + {"ErrNotAFunction", ErrNotAFunction}, + {"ErrNotAnAnalysis", ErrNotAnAnalysis}, + {"ErrNotAnObject", ErrNotAnObject}, + {"ErrNotAnOccurrence", ErrNotAnOccurrence}, + {"ErrOccurrenceDestroyed", ErrOccurrenceDestroyed}, + {"ErrOccurrenceLifetime", ErrOccurrenceLifetime}, + {"ErrStatePerformanceOccurrence", ErrStatePerformanceOccurrence}, + {"ErrStateSpaceDiverged", ErrStateSpaceDiverged}, + {"ErrStateSpaceStep", ErrStateSpaceStep}, + {"ErrStateSpaceValue", ErrStateSpaceValue}, + {"ErrStepLimitExceeded", ErrStepLimitExceeded}, + {"ErrTypeMismatch", ErrTypeMismatch}, + {"ErrUnboundParameter", ErrUnboundParameter}, + {"ErrUndeterminedValueType", ErrUndeterminedValueType}, + {"ErrUnevaluableLibraryFunction", ErrUnevaluableLibraryFunction}, + {"ErrUninitializedFeatureValue", ErrUninitializedFeatureValue}, + {"ErrUnknownOutput", ErrUnknownOutput}, + {"ErrUnknownParameter", ErrUnknownParameter}, + {"ErrUnresolvedReference", ErrUnresolvedReference}, + {"ErrUnresolvedType", ErrUnresolvedType}, + {"ErrUnsupportedBodyDeclaration", ErrUnsupportedBodyDeclaration}, + {"ErrUnsupportedOperator", ErrUnsupportedOperator}, + {"ErrUnsupportedStateSpace", lower.ErrUnsupportedStateSpace}, + {"ErrViolated", ErrViolated}, +} + +// TestAnalysisLibraryCensus records, per callable library declaration, whether its +// invocation evaluated, was refused by a typed error or was wrong, and pins the committed file. +func TestAnalysisLibraryCensus(t *testing.T) { + census := fixtures.LibraryCensus{Command: libraryCensusCommand} + for _, pkg := range censusPackages { + census.Packages = append(census.Packages, measureLibraryPackage(t, pkg)) + } + if t.Failed() { + t.Fatal("the census has probe defects above; the committed file is not compared") + } + got, err := fixtures.FormatLibraryCensus(census) + if err != nil { + t.Fatalf("census: %v", err) + } + path := filepath.Join("..", "..", "..", filepath.FromSlash(fixtures.LibraryCensusPath)) + if *updateLibraryCensus { + if err := os.WriteFile(path, got, 0o644); err != nil { // #nosec G306 -- a committed documentation file + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) // #nosec G304 -- the committed census this test maintains + if err != nil { + t.Fatalf("%v; run `%s`", err, libraryCensusCommand) + } + if string(want) != string(got) { + t.Errorf("%s disagrees with this run; run `%s` and commit the result\n%s", + fixtures.LibraryCensusPath, libraryCensusCommand, censusDiff(string(want), string(got))) + } +} + +// TestAnalysisLibraryCensusProbesEveryDeclaration fails a library declaration without +// a probe and a probe naming no declaration. +func TestAnalysisLibraryCensusProbesEveryDeclaration(t *testing.T) { + ctx := newLibraryContext(t, libs.NewModelIndex()) + for _, pkg := range censusPackages { + t.Run(pkg.name, func(t *testing.T) { + declared := callableDeclarations(t, ctx, pkg.name) + probed := map[string]int{} + for _, probe := range pkg.probes { + probed[probe.decl]++ + } + for _, decl := range declared { + if probed[decl] != 1 { + t.Errorf("%s has %d probes, want 1", decl, probed[decl]) + } + delete(probed, decl) + } + for decl := range probed { + t.Errorf("%s is probed and is not a callable declaration of %s", decl, pkg.name) + } + }) + } +} + +// measureLibraryPackage runs every probe of a package against a fresh context +// over the standard library and records the verdicts. +func measureLibraryPackage(t *testing.T, pkg censusPackage) fixtures.LibraryPackage { + t.Helper() + if _, err := libs.DefaultSource().Read(pkg.path); err != nil { + t.Errorf("%s: %v", pkg.name, err) + } + ctx := newLibraryContext(t, libs.NewModelIndex()) + measured := fixtures.LibraryPackage{ + Name: pkg.name, + Path: pkg.path, + Declarations: callableDeclarations(t, ctx, pkg.name), + Evaluated: []string{}, + Refused: []fixtures.LibraryRefusal{}, + Wrong: []fixtures.LibraryMismatch{}, + } + for _, probe := range pkg.probes { + verdict := runLibraryProbe(t, probe) + switch { + case verdict.err != nil: + name := sentinelName(verdict.err) + if name == "" { + t.Errorf("%s: refused by an error typed by no sentinel the census knows: %v", probe.decl, verdict.err) + continue + } + measured.Refused = append(measured.Refused, fixtures.LibraryRefusal{ + Declaration: probe.decl, Error: name, Message: verdict.err.Error(), + }) + case verdict.undetermined != nil: + measured.Refused = append(measured.Refused, fixtures.LibraryRefusal{ + Declaration: probe.decl, Error: undeterminedRefusal, Message: verdict.undetermined.Reason(), + }) + case len(verdict.problems) > 0: + measured.Wrong = append(measured.Wrong, fixtures.LibraryMismatch{ + Declaration: probe.decl, Mismatch: strings.Join(verdict.problems, "; "), + }) + default: + measured.Evaluated = append(measured.Evaluated, probe.decl) + } + } + return measured +} + +// probeVerdict is what one probe observed: the error the runtime answered, the +// undetermined result it declined with, or the problems the check of the value reported. +type probeVerdict struct { + err error + undetermined *Undetermined + problems []string +} + +// runLibraryProbe indexes the probe's model over the standard library and evaluates the +// value it checks; a model that does not parse is a probe defect, not a verdict. +func runLibraryProbe(t *testing.T, probe libraryProbe) probeVerdict { + t.Helper() + path := "census/" + strings.ReplaceAll(probe.decl, "::", "/") + ".sysml" + src := source.New(path, []byte(probe.model)) + p := parser.New(src) + file := p.ParseFile() + if len(p.Diagnostics) > 0 { + t.Errorf("%s: the probe model has %d parse diagnostics: %v", probe.decl, len(p.Diagnostics), p.Diagnostics) + return probeVerdict{} + } + idx := libs.NewModelIndex() + idx.AddDocument(path, file) + ctx := newLibraryContext(t, idx) + + var value Value + var err error + switch { + case probe.read != "": + value, err = readProbeFeature(t, ctx, idx, probe) + case probe.instantiate != "": + value, err = readProbeSlot(t, ctx, idx, probe) + case probe.action != "": + value, err = readProbeOutput(t, ctx, idx, probe) + case probe.analysis != "": + value, err = readProbeVerdict(t, ctx, idx, probe) + default: + t.Errorf("%s: the probe reads nothing", probe.decl) + return probeVerdict{} + } + if err != nil { + return probeVerdict{err: err} + } + if u := value.Undetermined(); u != nil { + return probeVerdict{undetermined: u} + } + log := &problemLog{} + validateValue(log, ctx, probe.decl, probe.want, value) + return probeVerdict{problems: log.problems} +} + +func readProbeFeature(t *testing.T, ctx *Context, idx *symbols.Index, probe libraryProbe) (Value, error) { + t.Helper() + sym := oneProbeSymbol(t, idx, probe.decl, probe.read) + if sym == nil { + return Value{}, nil + } + usage, ok := sym.Decl.(*ast.Usage) + if !ok || usage.Value == nil { + t.Errorf("%s: %s binds no value to evaluate", probe.decl, probe.read) + return Value{}, nil + } + return ctx.EvalWithScope(usage.Value, sym.OwnerScope) +} + +func readProbeSlot(t *testing.T, ctx *Context, idx *symbols.Index, probe libraryProbe) (Value, error) { + t.Helper() + sym := oneProbeSymbol(t, idx, probe.decl, probe.instantiate) + if sym == nil { + return Value{}, nil + } + inst, err := ctx.Instantiate(sym) + if err != nil { + return Value{}, err + } + fv, err := featureValueAtPath(t, ctx, inst, probe.slot) + if err != nil { + return Value{}, err + } + return fv.ReadValue(probe.slot) +} + +func readProbeOutput(t *testing.T, ctx *Context, idx *symbols.Index, probe libraryProbe) (Value, error) { + t.Helper() + sym := oneProbeSymbol(t, idx, probe.decl, probe.action) + if sym == nil { + return Value{}, nil + } + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Value{}, err + } + value, ok := outputs[probe.output] + if !ok { + return Value{}, fmt.Errorf("%w: %s produced no output %q", ErrUnknownOutput, probe.action, probe.output) + } + return value, nil +} + +// readProbeVerdict runs the case and reads the named verdict as a Boolean; an +// undecided verdict is the undetermined value its detail explains. +func readProbeVerdict(t *testing.T, ctx *Context, idx *symbols.Index, probe libraryProbe) (Value, error) { + t.Helper() + sym := oneProbeSymbol(t, idx, probe.decl, probe.analysis) + if sym == nil { + return Value{}, nil + } + result, err := ctx.RunAnalysis(sym, AnalysisArgs{}, nil, nil) + if err != nil { + return Value{}, err + } + for _, verdict := range result.Verdicts { + if verdict.Name != probe.verdict { + continue + } + if verdict.Status == VerdictUndecided { + return NewUndeterminedValue(verdict.Detail, semantics.Range{}), nil + } + return boolValue(verdict.Status == VerdictSatisfied), nil + } + t.Errorf("%s: %s decided no verdict %q", probe.decl, probe.analysis, probe.verdict) + return Value{}, nil +} + +// oneProbeSymbol resolves a qualified name the probe states, reporting a probe +// defect when it names no one symbol. +func oneProbeSymbol(t *testing.T, idx *symbols.Index, decl, fqn string) *symbols.Symbol { + t.Helper() + matches := idx.LookupQualified(fqn) + if len(matches) != 1 { + t.Errorf("%s: %q names %d symbols, want 1", decl, fqn, len(matches)) + return nil + } + return matches[0] +} + +func newLibraryContext(t *testing.T, idx *symbols.Index) *Context { + t.Helper() + resolver := resolve.New(idx) + return NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) +} + +// sentinelName is the census's name for the sentinel an error is typed by, or +// "" when no known sentinel types it. +func sentinelName(err error) string { + for _, sentinel := range censusSentinels { + if errors.Is(err, sentinel.err) { + return sentinel.name + } + } + return "" +} + +// callableDeclarations lists, in declaration order, the package's public calc, behavior, +// action, constraint, predicate and case definitions and usages at any depth. +func callableDeclarations(t *testing.T, ctx *Context, pkg string) []string { + t.Helper() + idx := ctx.model.resolver.Index() + matches := idx.LookupQualified(pkg) + if len(matches) != 1 || matches[0].Kind != symbols.SymbolPackage { + t.Fatalf("%q names %d symbols, want one package", pkg, len(matches)) + } + names := []string{} + var walk func(scope *symbols.Scope) + walk = func(scope *symbols.Scope) { + for _, sym := range scope.AllMembers() { + if sym.Visibility == ast.VisibilityPrivate { + continue + } + if sym.Name != "" && isCallableDeclaration(sym.Decl) { + names = append(names, ctx.qualifiedSymbolName(sym)) + } + if sym.Scope != nil && sym.Kind != symbols.SymbolAlias { + walk(sym.Scope) + } + } + } + walk(matches[0].Scope) + return names +} + +func isCallableDeclaration(decl ast.Node) bool { + switch d := decl.(type) { + case *ast.Definition: + switch d.Kind { + case ast.DefCalc, ast.DefBehavior, ast.DefAction, ast.DefConstraint, ast.DefPredicate, + ast.DefRequirement, ast.DefCase, ast.DefAnalysisCase, ast.DefVerificationCase, ast.DefUseCase: + return true + } + case *ast.Usage: + if d.Direction != ast.DirNone { + return false + } + switch d.Kind { + case ast.UsageCalc, ast.UsageExpr, ast.UsageBehavior, ast.UsageAction, ast.UsagePredicate, + ast.UsageConstraint, ast.UsageRequirement, ast.UsageObjective, + ast.UsageCase, ast.UsageAnalysisCase, ast.UsageVerificationCase, ast.UsageUseCase: + return true + } + } + return false +} + +// censusDiff is the lines of the committed census and of the run that differ. +func censusDiff(want, got string) string { + wantLines := strings.Split(want, "\n") + gotLines := strings.Split(got, "\n") + var b strings.Builder + for i := 0; i < len(wantLines) || i < len(gotLines); i++ { + var old, fresh string + if i < len(wantLines) { + old = wantLines[i] + } + if i < len(gotLines) { + fresh = gotLines[i] + } + if old == fresh { + continue + } + fmt.Fprintf(&b, "@@ line %d @@\n-%s\n+%s\n", i+1, old, fresh) + } + return b.String() +} diff --git a/internal/core/runtime/library_conversions.go b/internal/exec/runtime/library_conversions.go similarity index 99% rename from internal/core/runtime/library_conversions.go rename to internal/exec/runtime/library_conversions.go index 6c135f4e6b..3f31708bae 100644 --- a/internal/core/runtime/library_conversions.go +++ b/internal/exec/runtime/library_conversions.go @@ -7,7 +7,7 @@ import ( "math/big" "strconv" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // ErrInvalidNotation reports a String a conversion function cannot read as a diff --git a/internal/core/runtime/library_conversions_test.go b/internal/exec/runtime/library_conversions_test.go similarity index 99% rename from internal/core/runtime/library_conversions_test.go rename to internal/exec/runtime/library_conversions_test.go index 9e5bbff970..434893c5f6 100644 --- a/internal/core/runtime/library_conversions_test.go +++ b/internal/exec/runtime/library_conversions_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) func constBool(b bool) Value { diff --git a/internal/core/runtime/library_frame.go b/internal/exec/runtime/library_frame.go similarity index 86% rename from internal/core/runtime/library_frame.go rename to internal/exec/runtime/library_frame.go index 86866f8786..54cc4faa2f 100644 --- a/internal/core/runtime/library_frame.go +++ b/internal/exec/runtime/library_frame.go @@ -1,6 +1,6 @@ package runtime -import "github.com/Open-MBEE/OpenSysML/internal/core/symbols" +import "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" // libraryTier reports the tier of the library that declares sym, TierNone for a // declaration of the model under evaluation. diff --git a/internal/core/runtime/library_functions.go b/internal/exec/runtime/library_functions.go similarity index 95% rename from internal/core/runtime/library_functions.go rename to internal/exec/runtime/library_functions.go index 4ee76dbab4..b07b8d7510 100644 --- a/internal/core/runtime/library_functions.go +++ b/internal/exec/runtime/library_functions.go @@ -9,11 +9,10 @@ import ( "strings" "unicode/utf8" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lexer" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // ErrUnevaluableLibraryFunction is returned for a function library declaration @@ -57,7 +56,7 @@ type libraryApply func(name string, ctx *Context, args []Value) (Value, error) func writtenName(fqn string) string { parts := strings.Split(fqn, "::") for i, part := range parts { - if !lexer.IsIdentifier(part) || lexer.IsKeywordIn(part, source.KindKerML) { + if !source.IsIdentifier(part) || source.IsKeywordIn(part, source.KindKerML) { parts[i] = "'" + part + "'" } } @@ -128,13 +127,17 @@ func init() { registerUnevaluableDeclarations() // OpenSysMLMathFunctions is the non-normative OpenSysML extension library - // (internal/core/libs/stdlib/OpenSysML Libraries/OpenSysMLMathFunctions.kerml), + // (internal/workspace/libs/stdlib/OpenSysML Libraries/OpenSysMLMathFunctions.kerml), // which declares the exponential, logarithmic and two-argument arctangent - // functions the OMG Kernel Function Library omits. + // functions, the ceiling and the Integer quotient the OMG Kernel Function Library omits. registerLibraryFunction("OpenSysMLMathFunctions::exp", []string{"x"}, realUnary(math.Exp)) registerLibraryFunction("OpenSysMLMathFunctions::ln", []string{"x"}, naturalLog, positiveReal) registerLibraryFunction("OpenSysMLMathFunctions::log", []string{"x", "base"}, logToBase, positiveReal, logarithmBase) registerLibraryFunction("OpenSysMLMathFunctions::atan2", []string{"y", "x"}, atan2Real) + registerLibraryFunction("OpenSysMLMathFunctions::ceiling", []string{"x"}, ceilingToInteger) + registerLibraryFunction("OpenSysMLMathFunctions::quotient", []string{"x", "y"}, integerQuotient, integerDomain, integerDomain) + + registerRandomFunctions() } // registerVectorFunctions registers VectorFunctions (Kernel Function Library). @@ -224,6 +227,19 @@ func registerStringFunctions() { // arguments, which is what most of the numeric library declares, under the // domain each parameter puts on its argument by itself. func registerLibraryFunction(name string, params []string, apply func([]semantics.Value) (semantics.Value, error), domains ...scalarDomain) { + registerContextFunction(name, params, pureScalars(apply), domains...) +} + +// pureScalars lifts an implementation over its arguments alone to a contextApply. +func pureScalars(apply func([]semantics.Value) (semantics.Value, error)) contextApply { + return func(_ *Context, _ string, values []semantics.Value) (semantics.Value, error) { + return apply(values) + } +} + +// registerContextFunction is registerLibraryFunction for an implementation that +// reads the run it is called in — a random draw from its modeled stream. +func registerContextFunction(name string, params []string, apply contextApply, domains ...scalarDomain) { registerValueFunction(name, params, len(params), numericScalars(params, apply, domains)) libraryFunctions[name].scalar = true undeterminedAware[name] = true @@ -268,11 +284,15 @@ func registerUnevaluable(name string, params []declaredParam, reason string) { // by itself, which every determined argument meets before the function applies. type scalarDomain func(x semantics.Value) error +// contextApply computes one scalar numeric function in the run calling it, under +// the name it was dispatched by. +type contextApply func(ctx *Context, name string, values []semantics.Value) (semantics.Value, error) + // numericScalars adapts an implementation over scalar numeric values: every // parameter of such a declaration is one number, so a collection of several, a // string, an instance or a quantity does not conform to it. An argument the // model leaves open leaves the result open once the determined ones conform. -func numericScalars(params []string, apply func([]semantics.Value) (semantics.Value, error), domains []scalarDomain) libraryApply { +func numericScalars(params []string, apply contextApply, domains []scalarDomain) libraryApply { return func(name string, ctx *Context, args []Value) (Value, error) { values := make([]semantics.Value, len(args)) for i, arg := range args { @@ -305,7 +325,7 @@ func numericScalars(params []string, apply func([]semantics.Value) (semantics.Va if val, open := ctx.openInvocation(name, args...); open { return val, nil } - result, err := apply(values) + result, err := apply(ctx, name, values) if err != nil { return Value{}, fmt.Errorf("function %s: %w", name, err) } @@ -663,6 +683,11 @@ func floorToInteger(args []semantics.Value) (semantics.Value, error) { return integerResult(math.Floor(asReal(args[0]))) } +// ceilingToInteger is OpenSysMLMathFunctions::ceiling, which returns Integer. +func ceilingToInteger(args []semantics.Value) (semantics.Value, error) { + return integerResult(math.Ceil(asReal(args[0]))) +} + // roundToInteger is RealFunctions::round, which returns Integer. Halves round // away from zero, as math.Round does. func roundToInteger(args []semantics.Value) (semantics.Value, error) { @@ -717,6 +742,20 @@ func integerExtremum(larger bool) func([]semantics.Value) (semantics.Value, erro } } +// integerQuotient is OpenSysMLMathFunctions::quotient, the exact ratio of two +// Integers truncated toward zero. The one quotient outside the Integer range, +// the most negative Integer by -1, is reported rather than wrapped to itself. +func integerQuotient(args []semantics.Value) (semantics.Value, error) { + x, y := args[0].Int, args[1].Int + if y == 0 { + return semantics.Value{}, ErrDivisionByZero + } + if x == math.MinInt64 && y == -1 { + return semantics.Value{}, fmt.Errorf("%w: quotient(%d, %d) exceeds the Integer range", semantics.ErrArithmeticOverflow, x, y) + } + return semantics.Value{Kind: semantics.ValInt, Int: x / y}, nil +} + // integerDomain is the domain of an Integer parameter: a Real does not conform. func integerDomain(v semantics.Value) error { _, err := asInteger(v) diff --git a/internal/core/runtime/library_functions_test.go b/internal/exec/runtime/library_functions_test.go similarity index 94% rename from internal/core/runtime/library_functions_test.go rename to internal/exec/runtime/library_functions_test.go index 9e471a0e38..decf2d4008 100644 --- a/internal/core/runtime/library_functions_test.go +++ b/internal/exec/runtime/library_functions_test.go @@ -7,13 +7,13 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // libCtx returns a runtime context over an empty model, enough to apply a @@ -22,7 +22,7 @@ func libCtx(t *testing.T) *Context { t.Helper() idx := symbols.NewIndex() resolver := resolve.New(idx) - return NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + return NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) } func constInt(i int64) Value { @@ -105,6 +105,20 @@ func TestLibraryFunctionValues(t *testing.T) { {"OpenSysMLMathFunctions::atan2", []Value{constReal(-1), constReal(1)}, semantics.Value{Kind: semantics.ValReal, Real: -math.Pi / 4}}, {"OpenSysMLMathFunctions::atan2", []Value{constReal(-1), constReal(-1)}, semantics.Value{Kind: semantics.ValReal, Real: -3 * math.Pi / 4}}, {"OpenSysMLMathFunctions::atan2", []Value{constInt(1), constInt(0)}, semantics.Value{Kind: semantics.ValReal, Real: math.Pi / 2}}, + {"OpenSysMLMathFunctions::ceiling", []Value{constReal(2.1)}, semantics.Value{Kind: semantics.ValInt, Int: 3}}, + {"OpenSysMLMathFunctions::ceiling", []Value{constReal(-2.9)}, semantics.Value{Kind: semantics.ValInt, Int: -2}}, + {"OpenSysMLMathFunctions::ceiling", []Value{constReal(3.0)}, semantics.Value{Kind: semantics.ValInt, Int: 3}}, + {"OpenSysMLMathFunctions::ceiling", []Value{constInt(-4)}, semantics.Value{Kind: semantics.ValInt, Int: -4}}, + {"OpenSysMLMathFunctions::ceiling", []Value{constReal(math.MinInt64)}, semantics.Value{Kind: semantics.ValInt, Int: math.MinInt64}}, + {"OpenSysMLMathFunctions::ceiling", []Value{constReal(-9223372036854774784)}, semantics.Value{Kind: semantics.ValInt, Int: -9223372036854774784}}, + {"OpenSysMLMathFunctions::ceiling", []Value{constReal(9223372036854774784)}, semantics.Value{Kind: semantics.ValInt, Int: 9223372036854774784}}, + {"OpenSysMLMathFunctions::quotient", []Value{constInt(7), constInt(2)}, semantics.Value{Kind: semantics.ValInt, Int: 3}}, + {"OpenSysMLMathFunctions::quotient", []Value{constInt(-7), constInt(2)}, semantics.Value{Kind: semantics.ValInt, Int: -3}}, + {"OpenSysMLMathFunctions::quotient", []Value{constInt(7), constInt(-2)}, semantics.Value{Kind: semantics.ValInt, Int: -3}}, + {"OpenSysMLMathFunctions::quotient", []Value{constInt(-7), constInt(-2)}, semantics.Value{Kind: semantics.ValInt, Int: 3}}, + {"OpenSysMLMathFunctions::quotient", []Value{constInt(27021597764222979), constInt(3)}, semantics.Value{Kind: semantics.ValInt, Int: 9007199254740993}}, + {"OpenSysMLMathFunctions::quotient", []Value{constInt(math.MinInt64), constInt(1)}, semantics.Value{Kind: semantics.ValInt, Int: math.MinInt64}}, + {"OpenSysMLMathFunctions::quotient", []Value{constInt(math.MaxInt64), constInt(-1)}, semantics.Value{Kind: semantics.ValInt, Int: -math.MaxInt64}}, } for _, tc := range cases { @@ -162,6 +176,14 @@ func TestLibraryFunctionErrors(t *testing.T) { {"string argument to the logarithm", "OpenSysMLMathFunctions::ln", []Value{NewStringValue("1")}, ErrTypeMismatch}, {"string base", "OpenSysMLMathFunctions::log", []Value{constReal(8), NewStringValue("2")}, ErrTypeMismatch}, {"boolean argument to the angle", "OpenSysMLMathFunctions::atan2", []Value{constReal(1), boolValue(false)}, ErrTypeMismatch}, + {"ceiling at the Integer boundary", "OpenSysMLMathFunctions::ceiling", []Value{constReal(-float64(math.MinInt64))}, semantics.ErrArithmeticOverflow}, + {"ceiling below the Integer range", "OpenSysMLMathFunctions::ceiling", []Value{constReal(-1e20)}, semantics.ErrArithmeticOverflow}, + {"ceiling of a string", "OpenSysMLMathFunctions::ceiling", []Value{NewStringValue("1")}, ErrTypeMismatch}, + {"quotient by zero", "OpenSysMLMathFunctions::quotient", []Value{constInt(7), constInt(0)}, ErrDivisionByZero}, + {"quotient of the least Integer by -1", "OpenSysMLMathFunctions::quotient", []Value{constInt(math.MinInt64), constInt(-1)}, semantics.ErrArithmeticOverflow}, + {"Real argument to the quotient", "OpenSysMLMathFunctions::quotient", []Value{constReal(7.5), constInt(2)}, ErrTypeMismatch}, + {"Real divisor to the quotient", "OpenSysMLMathFunctions::quotient", []Value{constInt(7), constReal(2.0)}, ErrTypeMismatch}, + {"quotient with one argument", "OpenSysMLMathFunctions::quotient", []Value{constInt(7)}, ErrCalcArity}, } for _, tc := range cases { @@ -217,7 +239,7 @@ func TestOpenSysMLMathFunctionsMatchTheShippedDeclarations(t *testing.T) { idx.AddDocument(path, file) idx.MarkLibrary(path) resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) declared := 0 for _, sym := range idx.LookupDirectChildren("OpenSysMLMathFunctions") { @@ -233,8 +255,8 @@ func TestOpenSysMLMathFunctionsMatchTheShippedDeclarations(t *testing.T) { } checkLibrarySignature(t, ctx, fqn, sym, fn) } - if declared != 4 { - t.Errorf("%s declares %d functions, want 4 (exp, ln, log, atan2)", path, declared) + if declared != 6 { + t.Errorf("%s declares %d functions, want 6 (exp, ln, log, atan2, ceiling, quotient)", path, declared) } } @@ -440,7 +462,7 @@ func libraryModelContext(t *testing.T, src string) (*Context, *symbols.Index) { idx.AddDocument("", file) idx.ExpandWildcardImports() resolver := resolve.New(idx) - return NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000), idx + return NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000), idx } // contextForSource indexes src as one document and returns a runtime context @@ -451,7 +473,7 @@ func contextForSource(t *testing.T, src string) (*Context, *symbols.Index) { idx := symbols.NewIndex() idx.AddDocument("", file) resolver := resolve.New(idx) - return NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000), idx + return NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000), idx } // lookupOne returns the single symbol with that fully-qualified name. @@ -1203,6 +1225,7 @@ var vendoredFunctionPackages = map[string]string{ "TrigFunctions": "Kernel Libraries/Kernel Function Library/TrigFunctions.kerml", "VectorFunctions": "Kernel Libraries/Kernel Function Library/VectorFunctions.kerml", "OpenSysMLMathFunctions": "OpenSysML Libraries/OpenSysMLMathFunctions.kerml", + "RandomFunctions": "OpenSysML Libraries/RandomFunctions.kerml", } // Every function each vendored package declares, operator-named ones included, @@ -1223,7 +1246,7 @@ func TestVendoredFunctionsAreAllDispatchable(t *testing.T) { idx := symbols.NewIndex() idx.AddDocument(path, file) resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) declared := 0 for _, sym := range idx.LookupDirectChildren(pkg) { @@ -1276,7 +1299,7 @@ func checkBuiltinSignature(t *testing.T, ctx *Context, fqn string, sym *symbols. return } var declared []declaredParam - for _, member := range declMembers(sym.Decl) { + for _, member := range unwrappedDeclMembers(sym.Decl) { usage, ok := member.(*ast.Usage) if !ok || (usage.Direction != ast.DirIn && usage.Direction != ast.DirInOut) { continue @@ -1335,7 +1358,7 @@ func TestLibraryFeatureNameReadFromItsLibraryDeclaration(t *testing.T) { t.Fatalf("twoPi declares %T with no value", sym.Decl) } - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) got, err := NewEvalContext(ctx, pkg.Scope).Eval(decl.Value) if err != nil { t.Fatalf("2 * TrigFunctions::pi = error %v", err) @@ -1354,7 +1377,7 @@ func libraryContextForSource(t *testing.T, src string) (*Context, *symbols.Index idx.AddDocument("lib.kerml", file) idx.MarkLibrary("lib.kerml") resolver := resolve.New(idx) - return NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000), idx + return NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000), idx } // The library declares `feature pi : Real` with no value, so its value comes from diff --git a/internal/core/runtime/library_operators.go b/internal/exec/runtime/library_operators.go similarity index 98% rename from internal/core/runtime/library_operators.go rename to internal/exec/runtime/library_operators.go index 02bd1ec91e..c33c02f207 100644 --- a/internal/core/runtime/library_operators.go +++ b/internal/exec/runtime/library_operators.go @@ -3,9 +3,9 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // operandDomain binds an argument to the parameter type one function library @@ -296,7 +296,7 @@ func registerGenericExtrema() { // `max`/`min` do, and strings and quantities answer with the operand chosen. // A kind the library declares no ordering for is refused. func genericExtremum(larger bool) libraryApply { - extremum := numericScalars([]string{"x", "y"}, numericExtremum(larger), nil) + extremum := numericScalars([]string{"x", "y"}, pureScalars(numericExtremum(larger)), nil) return func(name string, ctx *Context, args []Value) (Value, error) { args, err := checkOperands(ctx, name, anyOperand, args) if err != nil { diff --git a/internal/core/runtime/library_operators_test.go b/internal/exec/runtime/library_operators_test.go similarity index 99% rename from internal/core/runtime/library_operators_test.go rename to internal/exec/runtime/library_operators_test.go index 30f5f0c053..9c3f4e4e1c 100644 --- a/internal/core/runtime/library_operators_test.go +++ b/internal/exec/runtime/library_operators_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // The function form of each operator answers what the operator notation does. diff --git a/internal/exec/runtime/library_provenance_test.go b/internal/exec/runtime/library_provenance_test.go new file mode 100644 index 0000000000..9e70cac85b --- /dev/null +++ b/internal/exec/runtime/library_provenance_test.go @@ -0,0 +1,124 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// contextOverLibrary indexes libSrc as library content and src as the model, and +// returns a runtime context over both. +func contextOverLibrary(t *testing.T, libSrc, src string) (*Context, *symbols.Index) { + t.Helper() + idx := symbols.NewIndex() + idx.AddDocument("frame.sysml", parser.New(source.New("frame.sysml", []byte(libSrc))).ParseFile()) + idx.MarkLibrary("frame.sysml") + idx.AddDocument("", parser.New(source.New("", []byte(src))).ParseFile()) + resolver := resolve.New(idx) + return NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000), idx +} + +// featureNames is the names of a shape's effective features, in order. +func featureNames(features []EffectiveFeature) []string { + out := make([]string, 0, len(features)) + for _, f := range features { + out = append(out, f.Name) + } + return out +} + +// A library supertype's features frame what a part is, and materializing them +// would give the object feature values the model never asked for. +func TestShapeLeavesOutLibraryDeclaredFeatures(t *testing.T) { + ctx, idx := contextOverLibrary(t, `package Frame { + part def Framed { + attribute frameKind; + } +}`, `package M { + part def Chassis :> Frame::Framed { + attribute weight; + } + part def Car :> Chassis { + attribute mass; + } +}`) + + got := featureNames(ctx.buildFeatures(lookupOne(t, idx, "M::Car"))) + want := map[string]bool{"mass": true, "weight": true} + for _, name := range got { + if !want[name] { + t.Fatalf("Car features = %v; a library-declared feature is not the model's", got) + } + delete(want, name) + } + if len(want) != 0 { + t.Fatalf("Car features = %v, missing %v: only library provenance withholds a feature", got, want) + } +} + +// chainMembers is what conditions, objectives and parameters are read from, so a +// library link contributing members would make them the model's. +func TestChainMembersLeavesOutLibraryDeclaredMembers(t *testing.T) { + ctx, idx := contextOverLibrary(t, `package Frame { + constraint def Framed { + assume constraint { true } + } +}`, `package M { + constraint def Limit :> Frame::Framed { + require constraint { 1 < 2 } + } +}`) + + sym := lookupOne(t, idx, "M::Limit") + if n := len(ctx.chainMembers(sym, sym.OwnerScope)); n != 1 { + t.Fatalf("chainMembers(M::Limit) returned %d members, want the model's one", n) + } +} + +// A calc specializing a library calc takes its parameters from what this runtime +// implements, so the library link contributes none. +func TestCalcChainLeavesOutLibraryDeclaredLinks(t *testing.T) { + ctx, idx := contextOverLibrary(t, `package Frame { + calc def Framed { + in x; + return : Real; + } +}`, `package M { + calc def Double :> Frame::Framed { + in y : Real; + return : Real = y * 2; + } +}`) + + chain := ctx.calcChain(lookupOne(t, idx, "M::Double")) + if len(chain) != 1 || symbols.FQNOf(chain[0]) != "M::Double" { + t.Fatalf("calcChain(M::Double) = %v, want the model's calc alone", chain) + } +} + +// An anonymous connector declared by a library supertype joins ends of the +// metamodel frame, not of the object the model materialized. +func TestAnonymousConnectorsLeaveOutLibraryDeclaredOnes(t *testing.T) { + ctx, idx := contextOverLibrary(t, `package Frame { + part def End; + part def Framed { + part a : End; + part b : End; + connect a to b; + } +}`, `package M { + part def Bus :> Frame::Framed { + part p : Frame::End; + part q : Frame::End; + connect p to q; + } +}`) + + if conns := ctx.anonymousConnectors(lookupOne(t, idx, "M::Bus")); len(conns) != 1 { + t.Fatalf("anonymousConnectors(M::Bus) returned %d connectors, want the model's one", len(conns)) + } +} diff --git a/internal/core/runtime/library_shape_test.go b/internal/exec/runtime/library_shape_test.go similarity index 98% rename from internal/core/runtime/library_shape_test.go rename to internal/exec/runtime/library_shape_test.go index 4e689c4282..8d8e1f2ca7 100644 --- a/internal/core/runtime/library_shape_test.go +++ b/internal/exec/runtime/library_shape_test.go @@ -7,10 +7,10 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // libraryShapeContext builds a runtime over the bundled standard library and the @@ -303,7 +303,7 @@ func TestUnevaluableLibraryFeatureIsATypedError(t *testing.T) { idx.AddDocument("", parseAndBuild(t, `package test { part def P :> Lib::Base; }`)) idx.ExpandWildcardImports() resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) obj, err := ctx.Instantiate(oneSymbol(t, idx, "test::P")) if err != nil { diff --git a/internal/core/runtime/library_unevaluable.go b/internal/exec/runtime/library_unevaluable.go similarity index 100% rename from internal/core/runtime/library_unevaluable.go rename to internal/exec/runtime/library_unevaluable.go diff --git a/internal/exec/runtime/lifetimes.go b/internal/exec/runtime/lifetimes.go new file mode 100644 index 0000000000..f87cc03614 --- /dev/null +++ b/internal/exec/runtime/lifetimes.go @@ -0,0 +1,356 @@ +package runtime + +import ( + "errors" + "fmt" + "sort" +) + +var ( + // ErrOccurrenceDestroyed is returned when a destroyed occurrence is read or + // written: its features hold nothing after its end, and it cannot end twice. + ErrOccurrenceDestroyed = errors.New("occurrence was destroyed") + + // ErrNotAnOccurrence is returned when a value that denotes no object of the + // model is passed where OccurrenceFunctions takes an Occurrence. + ErrNotAnOccurrence = errors.New("value is not an occurrence") + + // ErrOccurrenceLifetime is returned when an occurrence's lifetime admits no + // such change: `create` of one begun before the call, `destroy` of one ended. + ErrOccurrenceLifetime = errors.New("occurrence lifetime does not admit the change") +) + +// life is the lifetime of one occurrence in the runtime's execution order, kept +// beside the instances so the Kernel Semantic Library's frame stays off them. +type life struct { + reached int64 // the activation the runtime materialized the object at + began int64 // the activation the occurrence began at + ended int64 // the activation the occurrence ended at, 0 while it lives + destroyed bool // ended by `destroy`, after which its features are not read +} + +// alive reports whether the occurrence has begun and not ended. +func (l life) alive() bool { return l.began > 0 && l.ended == 0 } + +// readsLives lists the `=` value being derived, if any, as reading the lives: which +// objects there are, and when each began and ended. +func (ctx *Context) readsLives() { ctx.noteRead(&ctx.lifetimes) } + +// livesChanged unmaterializes what derived from the lives, save what is deriving now: +// a change a `=` value makes while deriving is its own, and what it derives reflects it. +func (ctx *Context) livesChanged() { + src := &ctx.lifetimes + var deriving, settled []*FeatureValue + for _, dep := range src.dependents { + if ctx.isDeriving(dep) { + deriving = append(deriving, dep) + } else { + settled = append(settled, dep) + } + } + if len(settled) == 0 { + return + } + ctx.noteProbeWrite(src) + src.dependents = listDependents(deriving, ctx.invalidate(settled)) +} + +// isDeriving reports whether fv is being derived right now. +func (ctx *Context) isDeriving(fv *FeatureValue) bool { + for i := range ctx.deriving { + if ctx.deriving[i].fv == fv { + return true + } + } + return false +} + +// forgetDerivedFrom unmaterializes what derived from a feature of the ended objects: +// read again, it finds them destroyed, as reading the feature itself does. +func (ctx *Context) forgetDerivedFrom(ended map[int64]bool) { + for id := range ended { + inst, ok := ctx.instances[id] + if !ok { + continue + } + for _, fv := range inst.FeatureValues { + ctx.invalidateDependents(fv) + } + } +} + +// lifeOf answers the lifetime of inst for function op; an object the context +// holds without one is a fault of the context, reported rather than guessed. +func (ctx *Context) lifeOf(op string, inst *Instance) (life, error) { + ctx.readsLives() + l, ok := ctx.lives[inst.ID] + if !ok { + return life{}, fmt.Errorf("%w: function %s: object #%d (%s) has no lifetime here", + ErrOccurrenceLifetime, writtenName(op), inst.ID, symbolText(inst.Type)) + } + return l, nil +} + +// OccurrenceLife is the lifetime of an occurrence as the describe API reports +// it: where in the execution order it began and ended, 0 for "not yet". +type OccurrenceLife struct { + Began int64 + Ended int64 + // Destroyed marks an end by `destroy`, after which the features hold nothing; + // a performance that completed keeps its final values. + Destroyed bool +} + +// Alive reports whether the occurrence has begun and not ended. +func (l OccurrenceLife) Alive() bool { return l.Began > 0 && l.Ended == 0 } + +// String renders the lifetime for a debugging surface. +func (l OccurrenceLife) String() string { + switch { + case l.Ended == 0: + return fmt.Sprintf("alive since %d", l.Began) + case l.Destroyed: + return fmt.Sprintf("destroyed at %d, alive since %d", l.Ended, l.Began) + default: + return fmt.Sprintf("ended at %d, alive since %d", l.Ended, l.Began) + } +} + +// Destroyed reports the activation at which inst was destroyed, and whether it +// was; an object the context holds without a lifetime or one merely ended is not. +func (ctx *Context) Destroyed(inst *Instance) (at int64, ok bool) { + if l, held := ctx.lives[inst.ID]; held && l.destroyed { + return l.ended, true + } + return 0, false +} + +// OccurrenceLife answers the lifetime of the occurrence an instance identity +// denotes, and false for an identity the context never registered. +func (ctx *Context) OccurrenceLife(id int64) (OccurrenceLife, bool) { + l, ok := ctx.lives[id] + if !ok { + return OccurrenceLife{}, false + } + return OccurrenceLife{Began: l.began, Ended: l.ended, Destroyed: l.destroyed}, true +} + +// beginLife records inst materialized now. A part of an object exists as long as +// its whole does: it began when its owner did and, if the owner has ended, ended +// with it, however late it is first read; anything else begins now. +func (ctx *Context) beginLife(inst *Instance) { + now := ctx.newActivation() + l := life{reached: now, began: now} + if inst.owner != nil { + if owner, ok := ctx.lives[inst.owner.ID]; ok && owner.began != 0 { + l.began, l.ended, l.destroyed = owner.began, owner.ended, owner.destroyed + } + } + ctx.lives[inst.ID] = l + ctx.livesChanged() +} + +// createDuring starts inst during the call entered at mark: only an object the +// call itself first reached can start there; one reached before it began already. +// It starts where the call reached it, ahead of the portions and performances +// reached with it, which a part that had begun with the whole holding it joins. +func (ctx *Context) createDuring(op string, inst *Instance, mark int64) error { + prior, err := ctx.lifeOf(op, inst) + if err != nil { + return err + } + switch { + case prior.destroyed: + return fmt.Errorf("function %s: %w: object #%d (%s) was destroyed at %d", + writtenName(op), ErrOccurrenceDestroyed, inst.ID, symbolText(inst.Type), prior.ended) + case prior.reached <= mark: + return fmt.Errorf("%w: function %s: object #%d (%s) began at %d, before the call", + ErrOccurrenceLifetime, writtenName(op), inst.ID, symbolText(inst.Type), prior.began) + } + ctx.lives[inst.ID] = life{reached: prior.reached, began: prior.reached} + ctx.noteProbeUndo(func() { ctx.lives[inst.ID] = prior }) + ctx.livesChanged() + for _, portion := range ctx.portionsOf(inst)[1:] { + if held := ctx.lives[portion.ID]; held.began < prior.reached { + ctx.lives[portion.ID] = life{reached: held.reached, began: prior.reached, ended: held.ended, destroyed: held.destroyed} + ctx.noteProbeUndo(func() { ctx.lives[portion.ID] = held }) + } + } + if ctx.trace != nil { + ctx.trace.RecordOccurrenceCreated(symbolText(inst.Type), inst.ID) + } + return nil +} + +// destroy ends inst and every object it holds as a portion of itself, none of which +// outlives its whole or ends twice, and ends the behaviors they perform; a behavior +// under way ends where its call catches the unwinding, which destroy returns. +func (ctx *Context) destroy(inst *Instance) error { + if err := ctx.checkLiving(inst); err != nil { + return err + } + // One boundary ends the whole and its portions; a portion that ended before its whole stays ended where it did. + ended := map[int64]bool{} + at := ctx.newActivation() + for _, portion := range ctx.portionsOf(inst) { + prior := ctx.lives[portion.ID] + if prior.ended != 0 { + continue + } + ended[portion.ID] = true + ctx.lives[portion.ID] = life{reached: prior.reached, began: prior.began, ended: at, destroyed: true} + ctx.noteProbeUndo(func() { ctx.lives[portion.ID] = prior }) + if ctx.trace != nil { + ctx.trace.RecordOccurrenceDestroyed(symbolText(portion.Type), portion.ID) + } + } + ctx.livesChanged() + ctx.forgetDerivedFrom(ended) + ctx.endBehaviorsWith(ended) + ctx.forgetMessagesTo(ended) + if ctx.innermostRun().endsWithin(ended) { + return &terminated{object: inst, ended: ended} + } + return nil +} + +// portionsOf lists inst and, in identity order, the objects it holds as portions of itself: +// those its composite features hold (wherever their home is) and those it is home to, +// transitively, each once however many names of a redefined feature hold it. +func (ctx *Context) portionsOf(inst *Instance) []*Instance { + portions := []*Instance{inst} + listed := map[int64]bool{inst.ID: true} + for i := 0; i < len(portions); i++ { + var owned []*Instance + for _, fv := range portions[i].FeatureValues { + composite := ctx.ownsHeld(fv.Feature) + for _, element := range elementsOf(fv.HeldValue()) { + id, ok := element.Object() + if !ok || listed[id] { + continue + } + if held, found := ctx.instances[id]; found && (composite || held.owner == portions[i]) { + listed[id] = true + owned = append(owned, held) + } + } + } + sort.Slice(owned, func(a, b int) bool { return owned[a].ID < owned[b].ID }) + portions = append(portions, owned...) + } + return portions +} + +// checkPerformer refuses a destroyed or ended object as the performer of a behavior: +// an occurrence performs nothing after its end. A nil self performs outside any object. +func (ctx *Context) checkPerformer(self *Instance) error { + if self == nil { + return nil + } + if err := ctx.checkNotDestroyed(self); err != nil { + return fmt.Errorf("performer of the behavior: %w", err) + } + if l, ok := ctx.lives[self.ID]; ok && l.ended != 0 { + return fmt.Errorf("performer of the behavior: %w: object #%d (%s) ended at %d already", + ErrOccurrenceLifetime, self.ID, symbolText(self.Type), l.ended) + } + return nil +} + +// lifeEnded reports whether inst's lifetime here has ended; nil and an object with +// no lifetime recorded have not. +func (ctx *Context) lifeEnded(inst *Instance) bool { + if inst == nil { + return false + } + l, ok := ctx.lives[inst.ID] + return ok && l.ended != 0 +} + +// checkLiving refuses an occurrence that has no lifetime here, was destroyed, or ended already. +func (ctx *Context) checkLiving(inst *Instance) error { + prior, ok := ctx.lives[inst.ID] + switch { + case !ok: + return fmt.Errorf("%w: object #%d (%s) has no lifetime here", + ErrOccurrenceLifetime, inst.ID, symbolText(inst.Type)) + case prior.destroyed: + return fmt.Errorf("%w: object #%d (%s) was destroyed at %d already", + ErrOccurrenceDestroyed, inst.ID, symbolText(inst.Type), prior.ended) + case prior.ended != 0: + return fmt.Errorf("%w: object #%d (%s) ended at %d already", + ErrOccurrenceLifetime, inst.ID, symbolText(inst.Type), prior.ended) + } + return nil +} + +// completed reports whether the behavior's executor has reached its end. +func (b *ObjectBehavior) completed() bool { + switch { + case b.Action != nil: + return b.Action.State().Ended() + case b.State != nil: + return b.State.State().Ended() + } + return true +} + +// beginPerformanceLife records the performance occurrence inst stands for +// starting at activation, where its execution begins; nil stands for none. +func (ctx *Context) beginPerformanceLife(inst *Instance, activation int64) { + if inst == nil { + return + } + prior, ok := ctx.lives[inst.ID] + if !ok || prior.ended != 0 { + return + } + ctx.lives[inst.ID] = life{reached: prior.reached, began: activation} + ctx.noteProbeUndo(func() { ctx.lives[inst.ID] = prior }) + ctx.livesChanged() +} + +// endPerformanceLife records a performance occurrence completing, where one +// stands for the execution; one ended already (by `destroy`) stays as it is. +func (ctx *Context) endPerformanceLife(inst *Instance) { + if inst == nil { + return + } + prior, ok := ctx.lives[inst.ID] + if !ok || prior.ended != 0 { + return + } + ctx.lives[inst.ID] = life{reached: prior.reached, began: prior.began, ended: ctx.newActivation()} + ctx.noteProbeUndo(func() { ctx.lives[inst.ID] = prior }) + ctx.livesChanged() +} + +// carryLife keeps a carried-over object destroyed when it was destroyed in the +// context it came from; a living one begins anew here, as its behaviors do. +func (ctx *Context) carryLife(prev *Context, inst *Instance) { + if prev == nil || prev == ctx { + return + } + if l, ok := prev.lives[inst.ID]; ok && l.destroyed { + here := ctx.lives[inst.ID] + ctx.lives[inst.ID] = life{reached: here.reached, began: here.began, ended: ctx.newActivation(), destroyed: true} + ctx.livesChanged() + } +} + +// forgetLives drops the lives of abandoned objects with the objects. +func (ctx *Context) forgetLives(abandoned map[int64]bool) { + for id := range abandoned { + delete(ctx.lives, id) + } +} + +// checkNotDestroyed reports a destroyed object as such, so nothing reads it as +// one still holding values. +func (ctx *Context) checkNotDestroyed(inst *Instance) error { + if at, destroyed := ctx.Destroyed(inst); destroyed { + return fmt.Errorf("%w: object #%d (%s) was destroyed at %d", + ErrOccurrenceDestroyed, inst.ID, symbolText(inst.Type), at) + } + return nil +} diff --git a/internal/core/runtime/lifetimes_test.go b/internal/exec/runtime/lifetimes_test.go similarity index 88% rename from internal/core/runtime/lifetimes_test.go rename to internal/exec/runtime/lifetimes_test.go index 1f6c9ab773..76575d300b 100644 --- a/internal/core/runtime/lifetimes_test.go +++ b/internal/exec/runtime/lifetimes_test.go @@ -2,9 +2,10 @@ package runtime import ( "errors" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" "strings" "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // lifetimeFixture instantiates test:: of src and answers the object and a @@ -122,7 +123,8 @@ func TestBindingRefusesADestroyedEnd(t *testing.T) { } // TestDestroyedObjectPerformsNothing: a destroyed object performs no behavior of -// its type, not even one that touches none of its features, and sends nothing. +// its type, not even one that touches none of its features, and sends nothing; +// the message it addressed to itself (`tower` resolves to the beacon) leaves with it. func TestDestroyedObjectPerformsNothing(t *testing.T) { instantiate, invoke, ctx := lifetimeFixture(t, ` package test { @@ -153,8 +155,8 @@ func TestDestroyedObjectPerformsNothing(t *testing.T) { t.Errorf("invoke %s on a destroyed object = %v; want %v", op, err, ErrOccurrenceDestroyed) } } - if sent := len(ctx.PendingMessages()); sent != 1 { - t.Errorf("messages after destroy = %d, want the 1 sent before it", sent) + if sent := len(ctx.PendingMessages()); sent != 0 { + t.Errorf("messages after destroy = %d, want 0: the Ping it addressed to itself left with it, and it sent nothing since", sent) } members := map[string]*symbols.Symbol{} for _, member := range ctx.model.semantics.MembersOf(beacon.Type) { @@ -227,17 +229,68 @@ func TestDestroyEndsPortionsAndRefusesReads(t *testing.T) { } } -// TestDestroyRefusedWhilePerforming: an object whose exhibited state machine has -// not completed cannot end; the refusal names the behavior under way. -func TestDestroyRefusedWhilePerforming(t *testing.T) { +// TestTerminateEndsPortionsWithTheirWhole: `terminate` ends the object and the +// parts it holds at one boundary, so no part outlives its whole, and none is +// destroyed. +func TestTerminateEndsPortionsWithTheirWhole(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, lifetimeModel) + bench := instantiate("Bench") + var parts []*Instance + for _, name := range []string{"w", "spare"} { + fv, err := bench.GetFeatureValue(ctx, name) + if err != nil { + t.Fatal(err) + } + id, _ := fv.HeldValue().Object() + part, _ := ctx.getInstance(id) + parts = append(parts, part) + } + + ended, err := ctx.endOccurrence(bench) + if err != nil { + t.Fatalf("endOccurrence(bench): %v", err) + } + whole, _ := ctx.OccurrenceLife(bench.ID) + if whole.Alive() || whole.Destroyed { + t.Fatalf("OccurrenceLife(bench) = %v; want ended, not destroyed", whole) + } + for _, part := range parts { + if !ended[part.ID] { + t.Errorf("portion #%d is not among the objects ended", part.ID) + } + l, ok := ctx.OccurrenceLife(part.ID) + if !ok || l.Alive() || l.Destroyed { + t.Errorf("OccurrenceLife(#%d) = %v; want ended, not destroyed", part.ID, l) + } + if l.Ended != whole.Ended { + t.Errorf("OccurrenceLife(#%d) ended at %d; want %d, with its whole", part.ID, l.Ended, whole.Ended) + } + } + if _, err := ctx.endOccurrence(bench); !errors.Is(err, ErrOccurrenceLifetime) { + t.Errorf("second terminate: %v, want %v", err, ErrOccurrenceLifetime) + } +} + +// TestDestroyEndsTheMachinePerformed: destroying an object ends the state machine +// it exhibits with it, terminated where it stood. +func TestDestroyEndsTheMachinePerformed(t *testing.T) { instantiate, invoke, ctx := lifetimeFixture(t, lifetimeModel) rover := instantiate("Rover") - _, err := invoke("DestroyRover", objectValue(rover)) - if !errors.Is(err, ErrOccurrenceLifetime) || !strings.Contains(err.Error(), "under way") { - t.Fatalf("destroy(rover) = %v; want %v naming the behavior under way", err, ErrOccurrenceLifetime) + modes, ok := rover.Behavior("modes") + if !ok || modes.State == nil || modes.State.State().Ended() { + t.Fatalf("modes = %v, %v; want a machine under way", modes, ok) + } + if _, err := invoke("DestroyRover", objectValue(rover)); err != nil { + t.Fatalf("destroy(rover) = %v; want the machine ended with its performer", err) + } + if l, _ := ctx.OccurrenceLife(rover.ID); !l.Destroyed { + t.Errorf("OccurrenceLife(rover) = %v; want destroyed", l) + } + if modes.State.State() != StateTerminated { + t.Errorf("modes = %v after destroy; want terminated", modes.State.State()) } - if l, _ := ctx.OccurrenceLife(rover.ID); !l.Alive() { - t.Errorf("OccurrenceLife(rover) = %v after the refusal; want alive", l) + if l, ok := ctx.OccurrenceLife(modes.State.occurrence.ID); !ok || l.Alive() { + t.Errorf("OccurrenceLife(modes) = %v, %v; want ended", l, ok) } } diff --git a/internal/core/runtime/literal_type_test.go b/internal/exec/runtime/literal_type_test.go similarity index 96% rename from internal/core/runtime/literal_type_test.go rename to internal/exec/runtime/literal_type_test.go index 2d34608c26..f9972a2554 100644 --- a/internal/core/runtime/literal_type_test.go +++ b/internal/exec/runtime/literal_type_test.go @@ -4,9 +4,9 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // literalTypeContext builds a library-backed model with a package that imports nothing @@ -176,7 +176,7 @@ func TestUserScalarTypeStandsInOnlyWithoutALibrary(t *testing.T) { } resolver := resolve.New(idx) sem := semantics.NewModel(resolver) - ctx := NewContext(NewModel(sem, resolver), 10000) + ctx := NewContext(typedModel(sem, resolver), 10000) pkg, ok := idx.DocumentRoot("").LookupLocal("Shadow") if !ok || pkg.Scope == nil { t.Fatal("package Shadow not indexed") diff --git a/internal/core/runtime/materialize.go b/internal/exec/runtime/materialize.go similarity index 98% rename from internal/core/runtime/materialize.go rename to internal/exec/runtime/materialize.go index 443f5654bb..5b6c849371 100644 --- a/internal/core/runtime/materialize.go +++ b/internal/exec/runtime/materialize.go @@ -1,6 +1,6 @@ package runtime -import "github.com/Open-MBEE/OpenSysML/internal/core/symbols" +import "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" const ( // maxMaterializeDepth bounds how deep a materialization walk descends into the diff --git a/internal/core/runtime/materialize_test.go b/internal/exec/runtime/materialize_test.go similarity index 98% rename from internal/core/runtime/materialize_test.go rename to internal/exec/runtime/materialize_test.go index be9277904b..1dfd235972 100644 --- a/internal/core/runtime/materialize_test.go +++ b/internal/exec/runtime/materialize_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // MaterializationErrors reads the feature values a caller would otherwise leave lazy, so @@ -194,7 +194,7 @@ func TestMaterializationErrorsBoundsAWideModel(t *testing.T) { if !hit { t.Error("bounded = false, want the walk to report it stopped at its budget") } - case <-time.After(30 * time.Second): + case <-watchdog(30 * time.Second): t.Fatal("MaterializationErrors did not return, want a bounded walk") } } diff --git a/internal/core/runtime/measurement_ref.go b/internal/exec/runtime/measurement_ref.go similarity index 98% rename from internal/core/runtime/measurement_ref.go rename to internal/exec/runtime/measurement_ref.go index 1f3800fecf..c89507e6e4 100644 --- a/internal/core/runtime/measurement_ref.go +++ b/internal/exec/runtime/measurement_ref.go @@ -5,10 +5,10 @@ import ( "fmt" "strconv" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lexer" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // MeasurementRef is a ScalarMeasurementReference held as a value: the Unit a @@ -155,9 +155,9 @@ func (ctx *Context) qualifiedUnitName(sym *symbols.Symbol) string { // unitSymbolName is the symbol a unit is written by (`km`), its name otherwise. func unitSymbolName(sym *symbols.Symbol) string { if sym.ShortName != "" { - return lexer.NameText(sym.ShortName) + return source.NameText(sym.ShortName) } - return lexer.NameText(sym.Name) + return source.NameText(sym.Name) } // composeMeasurementRefs is the unit a `*`, `/` or `**`/`^` (Real exponent) of diff --git a/internal/core/runtime/measurement_ref_functions.go b/internal/exec/runtime/measurement_ref_functions.go similarity index 98% rename from internal/core/runtime/measurement_ref_functions.go rename to internal/exec/runtime/measurement_ref_functions.go index be822747cc..bcd0a5fcbd 100644 --- a/internal/core/runtime/measurement_ref_functions.go +++ b/internal/exec/runtime/measurement_ref_functions.go @@ -3,7 +3,7 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // measurementRefArg reads a ScalarMeasurementReference (MeasurementUnit) parameter. diff --git a/internal/exec/runtime/measurement_ref_test.go b/internal/exec/runtime/measurement_ref_test.go new file mode 100644 index 0000000000..3f43c10f13 --- /dev/null +++ b/internal/exec/runtime/measurement_ref_test.go @@ -0,0 +1,488 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// measurementRefContext evaluates expressions in a package that imports the +// units, the quantity calculations and the measurement-reference calculations. +func measurementRefContext(t *testing.T) (*Context, *symbols.Scope) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + public import ISQ::*; + public import SI::*; + public import ScalarValues::*; + public import QuantityCalculations::*; + public import MeasurementRefCalculations::*; + private import MeasurementReferences::*; + attribute halfMetre : LengthUnit { :>> unitConversion : ConversionByConvention { :>> referenceUnit = m; :>> conversionFactor = 0.5; } } + attribute demiMetre : LengthUnit { :>> unitConversion : ConversionByConvention { :>> referenceUnit = m; :>> conversionFactor = 1/2; } } + attribute side : LengthValue = 3 [km]; + attribute unit : LengthUnit = m; + attribute area : AreaUnit = m * m; + attribute speed : SpeedUnit = km / h; + attribute wrongUnit : LengthUnit = s; + attribute wrongDimension : AreaUnit = m * s; + attribute notAUnit : LengthValue = m; + attribute notAnArea : AreaValue = m * m; + attribute notAScale : Time::TimeScale = h * s / min; + attribute notAnInterval : IntervalScale = h * s / min; + attribute aDuration : DurationUnit = h * s / min; + attribute epoch : Time::TimeScale = Time::UTC; + attribute exponent : Real = 2.0; + attribute powered : AreaUnit = m ** exponent; + attribute misPowered : LengthUnit = m ** exponent; + attribute inferred = m * m; + attribute inferredAgain = inferred; + attribute celsius : IntervalScale = SI::'°C_abs'; + attribute units : MeasurementUnit[0..*] = (m); + attribute scales : ScalarMeasurementReference[0..*] = (Time::UTC); + attribute vq : Quantities::VectorQuantityValue = VectorFunctions::VectorOf((1.0, 2.0, 3.0)) [m]; + attribute scaled = VectorFunctions::VectorOf((1.0, 2.0)) [m] * (2 [s]); + package Imperial { + attribute mile : LengthUnit { :>> unitConversion : ConversionByConvention { :>> referenceUnit = SI::m; :>> conversionFactor = 1609.344; } } + } + } + `)) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + return ctx, pkg.Scope +} + +// TestMeasurementRefValues: a unit declaration evaluates to the measurement +// reference it names, references compose as MeasurementRefCalculations declares, +// and a quantity is built from or converted to one. +func TestMeasurementRefValues(t *testing.T) { + ctx, scope := measurementRefContext(t) + + cases := []struct { + src string + want string + }{ + {"m", "m"}, + {"SI::m", "m"}, + {"SI::km", "km"}, + {"SI::'m/s'", "'m/s'"}, + {"MeasurementReferences::one", "one"}, + {"unit", "m"}, + {"m * s", "m*s"}, + {"m / s", "m/s"}, + {"m ** 2", "m**2"}, + {"m ** 0.5", "m**0.5"}, + {"km / m", "km/m"}, + {"m / m", "1"}, + {"area", "m**2"}, + {"speed", "km/h"}, + {"aDuration", "h*s/min"}, + {"m ** exponent", "m**2"}, + {"powered", "m**2"}, + {"MeasurementRefCalculations::'**'(m, exponent)", "m**2"}, + {"ToString(m ** exponent)", `"m**2"`}, + {"ConvertQuantity(1 [m*m], m ** exponent)", "1 [m**2]"}, + {"inferred", "m**2"}, + {"ToString(inferred)", `"m**2"`}, + {"'['(2, inferred)", "2 [m**2]"}, + {"ConvertQuantity(1 [km * km], inferred)", "1000000.0 [m**2]"}, + {"ToString(inferredAgain)", `"m**2"`}, + {"MeasurementRefCalculations::'*'(m, s)", "m*s"}, + {"MeasurementRefCalculations::'/'(m, s)", "m/s"}, + {"MeasurementRefCalculations::'**'(m, 2)", "m**2"}, + {"MeasurementRefCalculations::'^'(m, 3)", "m**3"}, + {"MeasurementRefCalculations::ToString(km)", `"km"`}, + {"ToString(m / s)", `"m/s"`}, + {"ToString(SI::'m/s')", `"'m/s'"`}, + {"QuantityCalculations::'['(3, m)", "3 [m]"}, + {"'['(2.5, km)", "2.5 [km]"}, + {"'['(2, m / s)", "2 [m/s]"}, + {"ConvertQuantity(3 [km], m)", "3000.0 [m]"}, + {"ConvertQuantity(300 [cm], m)", "3.0 [m]"}, + {"ConvertQuantity(3 [m], cm)", "300.0 [cm]"}, + {"ConvertQuantity(3 [km], km)", "3 [km]"}, + {"ConvertQuantity(side, m)", "3000.0 [m]"}, + {"ConvertQuantity(2 [m/s], SI::'km/h')", "7.2 ['km/h']"}, + {"ConvertQuantity(1 [h], s)", "3600.0 [s]"}, + {"ConvertQuantity(1 [m*m], m ** 2)", "1 [m**2]"}, + {"side.num", "3"}, + {"side.mRef", "km"}, + {"(2.5 [m/s]).mRef", "m/s"}, + {"(2.5 [m/s]).num", "2.5"}, + {"side.rank", "0"}, + {"side.dimensions", "[]"}, + {"side.elements", "[3]"}, + {"side.order", "0"}, + {"vq.mRef", "m"}, + {"vq.num", "[1.0, 2.0, 3.0]"}, + {"scaled.mRef", "m*s"}, + {"scaled.mRef == m * s", "true"}, + {"m.rank", "0"}, + {"m.dimensions", "[]"}, + {"m.flattenedSize", "1"}, + {"m.elements", "[m]"}, + {"m.mRefs", "[m]"}, + {"m.isBound", "false"}, + {"m.order", "0"}, + {"m.isOrthogonal", "true"}, + {"side.mRef.isBound", "false"}, + {"Time::UTC", "UTC [s]"}, + {"epoch", "UTC [s]"}, + {"Time::UTC.unit", "s"}, + {"SI::'°C_abs'", "'°C_abs' ['°C']"}, + {"celsius", "'°C_abs' ['°C']"}, + {"celsius.unit", "'°C'"}, + {"'['(3, Time::UTC)", "3 [UTC]"}, + {"3 [Time::UTC]", "3 [UTC]"}, + {"(3 [Time::UTC]).mRef", "UTC [s]"}, + {"MeasurementRefCalculations::ToString(Time::UTC)", `"UTC"`}, + {"ConvertQuantity(273.15 [K], SI::'°C_abs')", "0.0 ['°C_abs']"}, + {"ConvertQuantity(0.0 ['°C_abs'], K)", "273.15 [K]"}, + {"ConvertQuantity(100.0 ['°C_abs'], K)", "373.15 [K]"}, + {"MeasurementRefCalculations::'*'(units, s)", "m*s"}, + {"MeasurementRefCalculations::'**'(units, (2))", "m**2"}, + {"ToString(units)", `"m"`}, + {"'['(2, units)", "2 [m]"}, + {"MeasurementRefCalculations::ToString(scales)", `"UTC"`}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, tc.src) + if err != nil { + t.Fatalf("%s: %v", tc.src, err) + } + if rendered := FormatValue(got); rendered != tc.want { + t.Errorf("%s = %s, want %s", tc.src, rendered, tc.want) + } + }) + } +} + +// TestMeasurementRefDeclarationMembers: a reference naming a declaration answers +// that declaration's members from its materialized object, following the +// record's redefinitions (`conversionFactor = prefix.conversionFactor`) and +// defaults (`isExact default true`); a member the library states no value for +// is empty, as the pilot leaves it, and a member the value carries (isBound, +// mRefs) is answered by the value whether or not the declaration restates it. +func TestMeasurementRefDeclarationMembers(t *testing.T) { + ctx, scope := measurementRefContext(t) + + cases := []struct{ src, want string }{ + {"km.unitConversion.conversionFactor", "1000.0"}, + {"km.unitConversion.referenceUnit", "m"}, + {"km.unitConversion.isExact", "true"}, + {"km.unitConversion.prefix.longName", `"kilo"`}, + {"km.unitConversion.prefix.conversionFactor", "1000.0"}, + {"side.mRef.unitConversion.conversionFactor", "1000.0"}, + {"ConvertQuantity(1 [km], km.unitConversion.referenceUnit)", "1000.0 [m]"}, + {"halfMetre.unitConversion.conversionFactor", "0.5"}, + {"demiMetre.unitConversion.conversionFactor", "0.5"}, + {"halfMetre.unitConversion.isExact", "true"}, + {"Imperial::m.unitConversion.conversionFactor", "1609.344"}, + {"m.unitConversion", "[]"}, + {"m.quantityDimension.quantityPowerFactors#(1).exponent", "1"}, + {"SI::'m/s'.quantityDimension.quantityPowerFactors#(2).exponent", "-1"}, + {"m.unitPowerFactors#(1).unit", "m"}, + {"m.unitPowerFactors#(1).exponent", "1"}, + {"km.unitPowerFactors#(1).unit", "km"}, + {"SI::'m/s'.unitPowerFactors", "[]"}, + {"K.definitionalQuantityValues#(1).num", "[273.16]"}, + {"K.definitionalQuantityValues#(1).definition", `"temperature in kelvin of pure water at the triple point"`}, + {"K.temperatureOfWaterAtTriplePointInK.num", "[273.16]"}, + {"m.definitionalQuantityValues", "[]"}, + {"SI::'m/s'.isBound", "false"}, + {"SI::'m/s'.mRefs", "['m/s']"}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, tc.src) + if err != nil { + t.Fatalf("%s: %v", tc.src, err) + } + if rendered := FormatValue(got); rendered != tc.want { + t.Errorf("%s = %s, want %s", tc.src, rendered, tc.want) + } + }) + } + + first, err := evalIn(t, ctx, scope, "km.unitConversion") + if err != nil { + t.Fatal(err) + } + again, err := evalIn(t, ctx, scope, "km.unitConversion") + if err != nil { + t.Fatal(err) + } + if first.Kind != ValInstance || first.Instance != again.Instance { + t.Errorf("km.unitConversion = %s then %s, want one object", FormatValue(first), FormatValue(again)) + } +} + +// TestMeasurementRefEquality: references are equal at one reduction and one +// scale, a ratio that cancels included; a named dimension-one unit is only +// itself; and a set keys them the same way. +func TestMeasurementRefEquality(t *testing.T) { + ctx, scope := measurementRefContext(t) + + cases := []struct { + left, right string + want bool + }{ + {"m", "SI::m", true}, + {"m", "unit", true}, + {"side.mRef", "km", true}, + {"side.mRef", "m", false}, + {"km", "m", false}, + {"SI::'m/s'", "m / s", true}, + {"SI::'km/h'", "km / h", true}, + {"SI::'km/h'", "m / s", false}, + {"m ** 2", "m * m", true}, + {"area", "m ** 2", true}, + {"m / m", "s / s", true}, + {"km / m", "m / mm", true}, + {"km / m", "m / m", false}, + {"m / m", "MeasurementReferences::one", false}, + {"km / m", "MeasurementReferences::one", false}, + {"rad", "rad", true}, + {"rad", "sr", false}, + {"rad", "m / m", false}, + {"rad / rad", "m / m", true}, + {"m", "3", false}, + {"m", `"m"`, false}, + {"m", "1 [m]", false}, + {"vq.mRef", "m", true}, + {"(1 [m]).mRef", "(2 [m]).mRef", true}, + {"halfMetre", "demiMetre", true}, + {"halfMetre", "m", false}, + {"halfMetre / s", "demiMetre / s", true}, + } + for _, tc := range cases { + t.Run(tc.left+" == "+tc.right, func(t *testing.T) { + left, err := evalIn(t, ctx, scope, tc.left) + if err != nil { + t.Fatalf("%s: %v", tc.left, err) + } + right, err := evalIn(t, ctx, scope, tc.right) + if err != nil { + t.Fatalf("%s: %v", tc.right, err) + } + if got := valueEqual(left, right); got != tc.want { + t.Errorf("valueEqual(%s, %s) = %v, want %v", tc.left, tc.right, got, tc.want) + } + if got := valueEqual(right, left); got != tc.want { + t.Errorf("valueEqual(%s, %s) = %v, want %v", tc.right, tc.left, got, tc.want) + } + if sameKey := valueKeyFunc(left) == valueKeyFunc(right); sameKey != tc.want { + t.Errorf("valueKey(%s) == valueKey(%s) is %v, want %v", tc.left, tc.right, sameKey, tc.want) + } + got, err := evalIn(t, ctx, scope, tc.left+" == "+tc.right) + if err != nil { + t.Fatalf("%s == %s: %v", tc.left, tc.right, err) + } + if FormatValue(got) != FormatValue(boolValue(tc.want)) { + t.Errorf("%s == %s = %s, want %v", tc.left, tc.right, FormatValue(got), tc.want) + } + }) + } +} + +// TestMeasurementRefClassification: a reference is of its declaration's type; a +// composed unit is a DerivedUnit, and a unit definition fixing its dimension +// (AreaUnit's quantityDimension) classifies the composed unit of that dimension, +// as a feature of that type holds it. +func TestMeasurementRefClassification(t *testing.T) { + ctx, scope := measurementRefContext(t) + + cases := []struct { + src string + want string + }{ + {"m istype LengthUnit", "true"}, + {"m istype DurationUnit", "false"}, + {"m istype MeasurementReferences::ScalarMeasurementReference", "true"}, + {"m istype MeasurementReferences::MeasurementUnit", "true"}, + {"m istype MeasurementReferences::TensorMeasurementReference", "true"}, + {"m hastype LengthUnit", "true"}, + {"m hastype MeasurementReferences::TensorMeasurementReference", "false"}, + {"side.mRef istype LengthUnit", "true"}, + {"(m * s) istype MeasurementReferences::MeasurementUnit", "true"}, + {"(m * s) istype MeasurementReferences::DerivedUnit", "true"}, + {"m istype MeasurementReferences::DerivedUnit", "false"}, + {"(m * s) istype LengthUnit", "false"}, + {"(m * m) istype AreaUnit", "true"}, + {"(m * s) istype AreaUnit", "false"}, + {"(m * m) hastype AreaUnit", "false"}, + {"(m * m) @ MeasurementReferences::MeasurementUnit", "true"}, + {"m istype ScalarValues::Real", "false"}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, tc.src) + if err != nil { + t.Fatalf("%s: %v", tc.src, err) + } + if rendered := FormatValue(got); rendered != tc.want { + t.Errorf("%s = %s, want %s", tc.src, rendered, tc.want) + } + }) + } +} + +// TestMeasurementRefReport: what a reference cannot answer is a typed error +// naming the declaration or the operator, never a number or a made-up member. +func TestMeasurementRefReport(t *testing.T) { + ctx, scope := measurementRefContext(t) + + cases := []struct { + src string + want error + text string + }{ + {"wrongUnit", ErrTypeMismatch, "cannot write the measurement reference s, a measurement reference of dimension T, to a feature typed by LengthUnit"}, + {"wrongDimension", ErrTypeMismatch, "cannot write the measurement reference m*s, a measurement reference of dimension L·T, to a feature typed by AreaUnit"}, + {"notAUnit", ErrTypeMismatch, "cannot write the measurement reference m, a measurement reference typed LengthUnit, to a feature typed by LengthValue"}, + {"notAnArea", ErrTypeMismatch, "cannot write the measurement reference m**2, a measurement reference typed DerivedUnit, to a feature typed by AreaValue"}, + {"misPowered", ErrTypeMismatch, "cannot write the measurement reference m**2, a measurement reference of dimension L^2, to a feature typed by LengthUnit"}, + {"notAScale", ErrTypeMismatch, "cannot write the measurement reference h*s/min, a measurement reference typed DerivedUnit, to a feature typed by TimeScale"}, + {"notAnInterval", ErrTypeMismatch, "cannot write the measurement reference h*s/min, a measurement reference typed DerivedUnit, to a feature typed by IntervalScale"}, + {"ConvertQuantity(3 [s], Time::UTC)", ErrUnevaluableLibraryFunction, "Time::UTC"}, + {"ConvertQuantity(3 [Time::UTC], s)", ErrUnevaluableLibraryFunction, "Time::UTC"}, + {"ConvertQuantity(3 [K], Time::UTC)", ErrIncommensurableUnits, ""}, + {"Time::UTC / s", ErrUnevaluableLibraryFunction, "UTC is a measurement scale"}, + {"m + m", ErrTypeMismatch, "operator '+' is not defined for a measurement reference and a measurement reference"}, + {"m - s", ErrTypeMismatch, "operator '-' is not defined for a measurement reference and a measurement reference"}, + {"m * 3", ErrTypeMismatch, "operator '*' is not defined for a measurement reference and an Integer"}, + {"3 * m", ErrTypeMismatch, "operator '*' is not defined for an Integer and a measurement reference"}, + {"m / 2.0", ErrTypeMismatch, "operator '/' is not defined for a measurement reference and a Real"}, + {"m ** s", ErrTypeMismatch, "operator '**' is not defined for a measurement reference and a measurement reference"}, + {"-m", ErrTypeMismatch, "unary '-' requires numeric operand, got measurement reference"}, + {"m < s", ErrTypeMismatch, "operator '<' is not defined for a measurement reference and a measurement reference; DataFunctions::'<' is abstract and no library function declares '<' for a measurement reference, which is no ScalarValue"}, + {"(m / s).quantityDimension", ErrUnevaluableLibraryFunction, "MeasurementReferences::DerivedUnit::quantityDimension: m/s is a MeasurementReferences::DerivedUnit reducing to metre·second^-1, which names no declaration whose member quantityDimension could be read"}, + {"(m / s).unitConversion", ErrUnevaluableLibraryFunction, "MeasurementReferences::DerivedUnit::unitConversion: m/s is a MeasurementReferences::DerivedUnit reducing to metre·second^-1"}, + {"(m / s).unitPowerFactors", ErrUnevaluableLibraryFunction, "MeasurementReferences::DerivedUnit::unitPowerFactors: m/s is a MeasurementReferences::DerivedUnit reducing to metre·second^-1"}, + {"(m ** exponent).definitionalQuantityValues", ErrUnevaluableLibraryFunction, "MeasurementReferences::DerivedUnit::definitionalQuantityValues: m**2 is a MeasurementReferences::DerivedUnit reducing to metre^2"}, + {"m.hasValidUnitPowerFactors", ErrUnevaluableLibraryFunction, "MeasurementReferences::ScalarMeasurementReference::hasValidUnitPowerFactors: a measurement reference value holds the unit m and its reduction metre, not the declaration's member hasValidUnitPowerFactors"}, + {"(m / s).foo", ErrTypeMismatch, "measurement reference has no feature foo"}, + {"m.foo", ErrTypeMismatch, "measurement reference has no feature foo"}, + {"side.isBound", ErrUnevaluableLibraryFunction, "Quantities::ScalarQuantityValue::isBound: a scalar quantity value holds its num and mRef, not whether the quantity is bound"}, + {"side.quantityDimension", ErrTypeMismatch, "a quantity in km has no feature quantityDimension"}, + {"ConvertQuantity(side, s)", ErrIncommensurableUnits, "function QuantityCalculations::ConvertQuantity: incommensurable units: cannot express km (1000·metre) in s (second)"}, + {"ConvertQuantity(1 [m], SI::'m/s')", ErrIncommensurableUnits, "cannot express m (metre) in 'm/s' (metre·second^-1)"}, + {"ConvertQuantity(3, m)", ErrIncommensurableUnits, "cannot express 1 (1) in m (metre)"}, + {"ConvertQuantity(m, m)", ErrTypeMismatch, `function QuantityCalculations::ConvertQuantity parameter "x" requires a quantity`}, + {"'['(3, 1 [m])", ErrTypeMismatch, `function QuantityCalculations::'[' parameter "mRef" requires a measurement reference such as SI::m, got a quantity in m`}, + {"'['(\"3\", m)", ErrTypeMismatch, `function QuantityCalculations::'[' parameter "num" requires a numeric value`}, + {"MeasurementRefCalculations::'/'(m, 2)", ErrTypeMismatch, `function MeasurementRefCalculations::'/' parameter "y" requires a measurement reference such as SI::m, got an Integer`}, + {"MeasurementRefCalculations::'^'(m, s)", ErrTypeMismatch, `function MeasurementRefCalculations::'^' parameter "y" requires a numeric value`}, + {"MeasurementRefCalculations::ToString(1 [m])", ErrTypeMismatch, `function MeasurementRefCalculations::ToString parameter "x" requires a measurement reference such as SI::m, got a quantity in m`}, + {"MeasurementRefCalculations::'CoordinateFrame*'(m, s)", ErrTypeMismatch, `function MeasurementRefCalculations::'CoordinateFrame*' parameter "x" requires a coordinate frame`}, + {"MeasurementRefCalculations::'CoordinateFrame/'(m, s)", ErrTypeMismatch, `function MeasurementRefCalculations::'CoordinateFrame/' parameter "x" requires a coordinate frame`}, + {"VectorCalculations::'['((1.0, 2.0), m)", ErrTypeMismatch, `function VectorCalculations::'[' parameter "mRef" requires a coordinate frame`}, + {"VectorCalculations::transform(m, vq)", ErrTypeMismatch, `function VectorCalculations::transform parameter "transformation" requires a coordinate transformation`}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, tc.src) + if err == nil { + t.Fatalf("%s = %s, want error %v", tc.src, FormatValue(got), tc.want) + } + if tc.want != nil && !errors.Is(err, tc.want) { + t.Errorf("%s: error %v, want %v", tc.src, err, tc.want) + } + if !strings.Contains(err.Error(), tc.text) { + t.Errorf("%s: error %q does not mention %q", tc.src, err, tc.text) + } + }) + } +} + +// TestVectorQuantityMRefNeedsOneUnit: a vector quantity's mRef is the one +// reference its axes share, however each is spelt; axes measuring differently, +// even under one spelling, have no one scalar reference, so mRef is a typed error. +func TestVectorQuantityMRefNeedsOneUnit(t *testing.T) { + ctx, scope := measurementRefContext(t) + unitOf := func(src string) Unit { + val, err := evalIn(t, ctx, scope, src) + if err != nil { + t.Fatalf("%s: %v", src, err) + } + if val.Kind != ValMeasurementRef { + t.Fatalf("%s = %s, want a measurement reference", src, FormatValue(val)) + } + return val.MeasurementRef().Unit + } + metre, qualified, second, mile := unitOf("m"), unitOf("(1 [SI::m]).mRef"), unitOf("s"), unitOf("Imperial::mile") + if qualified.String() == metre.String() || mile.String() != metre.String() { + t.Fatalf("spellings %q, %q and %q do not exercise the cases", metre, qualified, mile) + } + num := []semantics.Value{{Kind: semantics.ValReal, Real: 1}, {Kind: semantics.ValReal, Real: 2}} + wantMRef := func(units []Unit, want Unit) { + t.Helper() + vq := NewVectorQuantityValue(num, units) + got, ok, err := ctx.structuredFeature(vq, "mRef") + if !ok || err != nil || !valueEqual(got, measurementRefOf(want)) || FormatValue(got) != want.String() { + t.Fatalf("mRef of %s = %s, %v, %v; want %s", FormatValue(vq), FormatValue(got), ok, err, want) + } + } + wantNoMRef := func(units []Unit, axes string) { + t.Helper() + vq := NewVectorQuantityValue(num, units) + _, ok, err := ctx.structuredFeature(vq, "mRef") + if !ok || !errors.Is(err, ErrUnevaluableLibraryFunction) { + t.Fatalf("mRef of %s = %v, %v; want %v", FormatValue(vq), ok, err, ErrUnevaluableLibraryFunction) + } + want := "Quantities::VectorQuantityValue::mRef: the axes of " + axes + " carry different units, and no one measurement reference names them all" + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } + + wantMRef([]Unit{metre, metre}, metre) + wantMRef([]Unit{metre, qualified}, metre) + wantMRef([]Unit{qualified, metre}, qualified) + wantNoMRef([]Unit{metre, second}, "⟨1.0 [m], 2.0 [s]⟩") + wantNoMRef([]Unit{metre, mile}, "⟨1.0 [m], 2.0 [m]⟩") +} + +// TestMeasurementRefDescribed: the kind describes, renders and traces itself. +func TestMeasurementRefDescribed(t *testing.T) { + ctx, scope := measurementRefContext(t) + ref, err := evalIn(t, ctx, scope, "km / h") + if err != nil { + t.Fatalf("km / h: %v", err) + } + if got := describeOperand(ref); got != "a measurement reference" { + t.Errorf("describeOperand(km/h) = %q", got) + } + if got := describeValue(ref); got != "measurement reference" { + t.Errorf("describeValue(km/h) = %q", got) + } + if got := FormatValue(ref); got != "km/h" { + t.Errorf("FormatValue(km/h) = %q", got) + } + if got := ref.Kind.String(); got != "measurement reference" { + t.Errorf("Kind.String() = %q", got) + } + if ref.MeasurementRef().Declaration() != nil { + t.Error("km/h names a single declaration") + } + single, err := evalIn(t, ctx, scope, "SI::km") + if err != nil { + t.Fatalf("SI::km: %v", err) + } + if decl := single.MeasurementRef().Declaration(); decl == nil || unitSymbolName(decl) != "km" { + t.Errorf("SI::km names the declaration %v, want km", decl) + } + var nilRef *MeasurementRef + if got := nilRef.String(); got != "" { + t.Errorf("nil reference renders %q", got) + } + if nilRef.equal(nilRef) != true || nilRef.equal(ref.MeasurementRef()) { + t.Error("nil reference equality is not identity") + } +} diff --git a/internal/exec/runtime/messages_test.go b/internal/exec/runtime/messages_test.go new file mode 100644 index 0000000000..abd500c8ee --- /dev/null +++ b/internal/exec/runtime/messages_test.go @@ -0,0 +1,154 @@ +package runtime + +import ( + "errors" + "regexp" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestErrorMessages pins the text of the runtime errors a user reads: they name +// SysML concepts rather than Go types, and a recursion reports a frame count +// rather than one wrapped line per frame. +func TestErrorMessages(t *testing.T) { + src := ` + package test { + part def Wheel; + calc countdown { + in n: Integer; + countdown(n - 1) + } + calc inner { + in n: Integer; + n / 0 + } + calc outer { + in n: Integer; + inner(n) + } + constraint def Bounded { + 1 < 2 + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + wheel := findSymbolByName(rootScope, "Wheel", ast.DefPart) + countdown := findSymbolByName(rootScope, "countdown", ast.DefCalc) + outer := findSymbolByName(rootScope, "outer", ast.DefCalc) + if wheel == nil || countdown == nil || outer == nil { + t.Fatal("fixture symbols not found") + } + + t.Run("calc_of_wrong_kind", func(t *testing.T) { + _, err := ctx.InvokeCalc(wheel, nil, rootScope) + assertMessage(t, err, "not a calc: test::Wheel is a part def, not a calc definition or usage") + }) + + t.Run("constraint_of_wrong_kind", func(t *testing.T) { + _, err := ctx.EvaluateConstraint(wheel, rootScope) + assertMessage(t, err, "not a constraint: Wheel is a part def, not a constraint definition or usage") + }) + + t.Run("requirement_of_wrong_kind", func(t *testing.T) { + _, err := ctx.EvaluateRequirement(wheel, rootScope) + assertMessage(t, err, "not a requirement: Wheel is a part def, not a requirement definition or usage") + }) + + t.Run("recursion_collapses_to_a_frame_count", func(t *testing.T) { + // A shallow depth budget, so the bound the message is about is the one + // this recursion reaches first. + defer func(was int64) { ctx.maxCalcDepth = was }(ctx.maxCalcDepth) + ctx.maxCalcDepth = 8 + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 10}} + _, err := ctx.InvokeCalc(countdown, []Value{arg}, rootScope) + if err == nil { + t.Fatal("expected the recursion to be bounded") + } + got := err.Error() + if !regexp.MustCompile(`^calc test::countdown: … \d+ frames: `).MatchString(got) { + t.Errorf("err = %q; want a leading frame count", got) + } + if !strings.Contains(got, "calc recursion limit exceeded") { + t.Errorf("err = %q; want the recursion limit reported", got) + } + if n := strings.Count(got, "calc test::countdown: "); n != 1 { + t.Errorf("err names the calc frame %d times, want once: %q", n, got) + } + }) +} + +// TestNestedCalcNamesTheFailingCalc keeps a distinct nested calc named: only a +// calc the chain repeats is collapsed into the frame count. +func TestNestedCalcNamesTheFailingCalc(t *testing.T) { + src := ` + package test { + calc inner { + in n: Integer; + n / 0 + } + calc outer { + in n: Integer; + inner(n) + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + outer := findSymbolByName(rootScope, "outer", ast.DefCalc) + if outer == nil { + t.Fatal("fixture symbols not found") + } + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}} + _, err := ctx.InvokeCalc(outer, []Value{arg}, rootScope) + if err == nil { + t.Fatal("expected the division by zero to fail") + } + got := err.Error() + for _, want := range []string{"calc test::outer", "calc test::inner", "division by zero"} { + if !strings.Contains(got, want) { + t.Errorf("err = %q; want it to contain %q", got, want) + } + } + if strings.Contains(got, "frames") { + t.Errorf("err = %q; want no frame count for distinct calcs", got) + } +} + +// TestMutualRecursionNamesTheFrameItCollapsed pins the collapsed frame naming +// the calc it was entered for, not one an inner frame recorded. +func TestMutualRecursionNamesTheFrameItCollapsed(t *testing.T) { + inner := calcFrame("calc", "a", errors.New("boom")) + got := calcFrame("calc", "a", calcFrame("calc", "b", inner)).Error() + if !strings.HasPrefix(got, "calc a: … 2 frames: ") { + t.Errorf("err = %q; want the outer frame named a", got) + } +} + +// TestOperandTypeErrorMessage pins the type-mismatch text: the operator and both +// operand types, and the span for a surface that echoes the source. +func TestOperandTypeErrorMessage(t *testing.T) { + err := &OperandTypeError{Op: "+", Left: "an Integer", Right: "a string"} + assertMessage(t, err, "type mismatch: operator '+' is not defined for an Integer and a string") + err = &OperandTypeError{Op: "<", Left: "the enumeration literal Color::red", Right: "the enumeration literal Color::blue", + Library: "DataFunctions::'<' is abstract and no library function declares '<' for the enumeration Color, which is no ScalarValue"} + assertMessage(t, err, "type mismatch: operator '<' is not defined for the enumeration literal Color::red and the enumeration literal Color::blue; DataFunctions::'<' is abstract and no library function declares '<' for the enumeration Color, which is no ScalarValue") +} + +// assertMessage asserts err reads exactly want and names no Go type. +func assertMessage(t *testing.T, err error, want string) { + t.Helper() + if err == nil { + t.Fatalf("expected error %q, got nil", want) + } + if err.Error() != want { + t.Errorf("err = %q; want %q", err.Error(), want) + } + if strings.Contains(err.Error(), "*ast.") { + t.Errorf("err names a Go type: %q", err.Error()) + } +} diff --git a/internal/exec/runtime/metadata.go b/internal/exec/runtime/metadata.go new file mode 100644 index 0000000000..fad755d295 --- /dev/null +++ b/internal/exec/runtime/metadata.go @@ -0,0 +1,194 @@ +package runtime + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// evalMetadataAccess evaluates `ref.metadata` (KerML 8.4.4.10 MetadataAccessExpression): +// the metadata annotating the element ref names, as a sequence of objects of the +// annotating metadata types, in the order the annotations are stated. An element +// no metadata annotates reads as the empty sequence. +func (ec *EvalContext) evalMetadataAccess(n *ast.MetadataAccessExpr) (Value, error) { + sym, err := ec.metadataSubject(n) + if err != nil { + return Value{}, err + } + if ec.ctx.model.semantics == nil { + return Value{}, fmt.Errorf("%w: no model holds the metadata of %s", + ErrTypeMismatch, ec.ctx.qualifiedSymbolName(sym)) + } + annotations := ec.ctx.model.semantics.ElementMetadataOf(sym) + // One access answers every annotation or none: what a failing one wrote, made + // or started, here or in a behavior it woke, is undone with it. + commit, rollback := ec.ctx.beginJournal() + values := make([]Value, 0, len(annotations)) + for i, annotation := range annotations { + val, err := ec.metadataInstance(metadataAnnotation{element: sym, index: i}, annotation) + if err != nil { + rollback() + return Value{}, err + } + values = append(values, val) + } + // The annotations are followed by the element's own reflective metaobject + // (KerML 1.0 §8.3.4.8.15). + meta, err := ec.reflectiveMetaobject(sym) + if err != nil { + rollback() + return Value{}, err + } + seq, err := ec.newSequence(append(values, meta)) + if err != nil { + rollback() + return Value{}, err + } + commit() + return seq, nil +} + +// metadataSubject is the element `ref.metadata` reads the metadata of. A name +// that resolves to no element, or to something that is a value rather than an +// element, is refused rather than answered with an empty sequence. +func (ec *EvalContext) metadataSubject(n *ast.MetadataAccessExpr) (*symbols.Symbol, error) { + name := ast.QualifiedText(n.Ref) + if name == "" { + return nil, fmt.Errorf("%w: metadata access names no element", ErrTypeMismatch) + } + if ec.ctx == nil || ec.ctx.model.resolver == nil { + return nil, fmt.Errorf("%w: %s", ErrUnresolvedReference, name) + } + sym, ok := ec.ctx.resolveQualified(ec.scope, n.Ref) + if !ok || sym == nil { + return nil, fmt.Errorf("%w: %s", ErrUnresolvedReference, name) + } + if resolved, aliasOK := ec.ctx.resolveAliasTarget(sym); aliasOK { + sym = resolved + } + if sym.Decl == nil { + return nil, fmt.Errorf("%w: metadata access requires an element, but %s declares none", + ErrTypeMismatch, name) + } + return sym, nil +} + +// metadataOfAValue reports `.metadata` read from a value rather than an element: +// only an element is annotated, so a scalar has no metadata to answer with. +func metadataOfAValue(value Value, parts []ast.NameSegment) error { + if len(parts) == 0 || parts[0].Text != "metadata" { + return nil + } + return fmt.Errorf("%w: metadata access requires an element, but %s is a value", + ErrTypeMismatch, describeValue(value)) +} + +// metadataAnnotation names one annotation of one element: the element it +// annotates and its place among that element's annotations. +type metadataAnnotation struct { + element *symbols.Symbol + index int +} + +// metadataAnnotationDigest renders the annotation at that place as this context +// reads it: its metadata type and the text it states, so an annotation edited, +// reordered or retyped is not taken for the one an object was made for. The +// empty string says this context has no annotation there. +func (ctx *Context) metadataAnnotationDigest(element *symbols.Symbol, index int) string { + if ctx.model.semantics == nil || element == nil { + return "" + } + annotations := ctx.model.semantics.ElementMetadataOf(element) + if index < 0 || index >= len(annotations) { + return "" + } + annotation := annotations[index] + if annotation.Node == nil { + return "" + } + // An `about` annotation states itself away from the element it annotates, so + // the text comes from the document stating it. + return ctx.qualifiedSymbolName(annotation.Type) + " " + annotation.Doc + " " + + ctx.textIn(annotation.Doc, annotation.Node.Span()) +} + +// metadataInstance is the object one annotation denotes, of its metadata type, +// with the features its body binds set to the values they are bound to and the +// remaining ones keeping the defaults the type declares. One annotation denotes +// one object, so a second read of it answers the object the first made. +func (ec *EvalContext) metadataInstance(key metadataAnnotation, annotation semantics.ElementMetadata) (Value, error) { + ctx := ec.ctx + if id, held := ctx.metadataObjects[key]; held { + if _, live := ctx.instances[id]; live { + return Value{Kind: ValInstance, Instance: id}, nil + } + } + inst, err := ctx.materialize(annotation.Type, 0, nil, "") + if err != nil { + return Value{}, fmt.Errorf("metadata %s: %w", ctx.qualifiedSymbolName(annotation.Type), err) + } + if err := ec.bindMetadataFeatures(annotation.Type, inst, annotation.Bindings); err != nil { + return Value{}, err + } + ctx.metadataObjects[key] = inst.ID + return Value{Kind: ValInstance, Instance: inst.ID}, nil +} + +// bindMetadataFeatures writes the values one body level binds to the object it +// annotates, descending into the object a nested declaration writes through. +func (ec *EvalContext) bindMetadataFeatures(typ *symbols.Symbol, inst *Instance, bindings []semantics.MetadataBinding) error { + ctx := ec.ctx + name := ctx.qualifiedSymbolName(typ) + for _, binding := range bindings { + if fv, held := inst.FeatureValues[binding.Feature]; !held || fv == nil { + return fmt.Errorf("%w: metadata %s declares no feature %s", + ErrTypeMismatch, name, binding.Feature) + } + if binding.Value != nil { + // A value naming a sibling feature reads it off the object being bound. + val, err := NewEvalContextIn(ctx, binding.Scope, inst).Eval(binding.Value) + if err != nil { + return fmt.Errorf("metadata %s: %s: %w", name, binding.Feature, err) + } + if err := inst.SetFeatureValue(ctx, binding.Feature, val); err != nil { + return fmt.Errorf("metadata %s: %w", name, err) + } + } + if len(binding.Nested) == 0 { + continue + } + nested, err := ec.metadataNestedObject(typ, inst, binding.Feature) + if err != nil { + return err + } + if err := ec.bindMetadataFeatures(typ, nested, binding.Nested); err != nil { + return err + } + } + return nil +} + +// metadataNestedObject is the object a nested body level writes through: the one +// the named feature holds, which a feature holding a value rather than an object +// has none of. +func (ec *EvalContext) metadataNestedObject(typ *symbols.Symbol, inst *Instance, feature string) (*Instance, error) { + ctx := ec.ctx + name := ctx.qualifiedSymbolName(typ) + fv, err := inst.GetFeatureValue(ctx, feature) + if err != nil { + return nil, fmt.Errorf("metadata %s: %s: %w", name, feature, err) + } + id, isObject := fv.HeldValue().Object() + if !isObject { + return nil, fmt.Errorf("%w: metadata %s: feature %s holds no object to bind through", + ErrTypeMismatch, name, feature) + } + nested, live := ctx.instances[id] + if !live || nested == nil { + return nil, fmt.Errorf("%w: metadata %s: feature %s holds no object to bind through", + ErrTypeMismatch, name, feature) + } + return nested, nil +} diff --git a/internal/exec/runtime/metadata_test.go b/internal/exec/runtime/metadata_test.go new file mode 100644 index 0000000000..7e5d015d7f --- /dev/null +++ b/internal/exec/runtime/metadata_test.go @@ -0,0 +1,709 @@ +package runtime + +import ( + "errors" + "fmt" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +// metadataSrc annotates seatBelt three times: from an `about` usage declared +// before the part, and twice inline. `.metadata` answers them in that textual +// order, the `about` one first. +const metadataSrc = ` +package before { + metadata test::Heritage about test::seatBelt; +} + +package test { + private import ScalarValues::*; + + metadata def Safety { + attribute isMandatory : Boolean; + attribute level : Integer = 2; + } + + metadata def Legacy; + + metadata def Heritage; + + part def Vehicle; + + part seatBelt : Vehicle { + @Safety { + isMandatory = true; + } + @Legacy; + } + + part plain : Vehicle; + + attribute mass = 3; +} + +package about { + metadata test::Legacy about test::plain; +} +` + +// metadataTypeNames is the metadata type of every object in a `.metadata` +// sequence, which the element's own reflective metaobject ends (KerML 8.3.4.8.15). +func metadataTypeNames(t *testing.T, ctx *Context, value Value) []string { + t.Helper() + if value.Kind != ValSequence { + t.Fatalf("metadata read as %v, want a sequence", value.Kind) + } + elements := elementsOf(value) + if len(elements) == 0 || elements[len(elements)-1].Kind != ValMetaobject { + t.Fatalf("metadata %s does not end with the reflective metaobject", FormatValue(value)) + } + names := []string{} + for _, elem := range elements[:len(elements)-1] { + inst, ok := ctx.getInstance(elem.Instance) + if !ok { + t.Fatalf("element %v is no object", elem.Kind) + } + names = append(names, ctx.qualifiedSymbolName(inst.Type)) + } + return names +} + +// featureValue is what an object holds for the named feature. +func featureValue(t *testing.T, ctx *Context, inst *Instance, name string) Value { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("feature %s: %v", name, err) + } + return fv.HeldValue() +} + +// TestMetadataAccessBoundValues reads the metadata of an annotated element: one +// object per annotation, in textual order whichever form states it, carrying the +// values its body binds and the defaults its type declares. +func TestMetadataAccessBoundValues(t *testing.T) { + ctx, got, err := evalDeclaredExpr(t, metadataSrc, "test::seatBelt.metadata") + if err != nil { + t.Fatalf("seatBelt.metadata failed: %v", err) + } + if want := []string{"test::Heritage", "test::Safety", "test::Legacy"}; !slices.Equal(metadataTypeNames(t, ctx, got), want) { + t.Errorf("metadata types = %v, want %v", metadataTypeNames(t, ctx, got), want) + } + safety, ok := ctx.getInstance(elementsOf(got)[1].Instance) + if !ok { + t.Fatal("the Safety metadata value is no object") + } + if v := featureValue(t, ctx, safety, "isMandatory"); FormatValue(v) != "true" { + t.Errorf("isMandatory = %s, want true", FormatValue(v)) + } + if v := featureValue(t, ctx, safety, "level"); FormatValue(v) != "2" { + t.Errorf("level = %s, want the declared default 2", FormatValue(v)) + } +} + +// TestMetadataAccessAboutForm reads metadata an `about` annotation states elsewhere. +func TestMetadataAccessAboutForm(t *testing.T) { + ctx, got, err := evalDeclaredExpr(t, metadataSrc, "test::plain.metadata") + if err != nil { + t.Fatalf("plain.metadata failed: %v", err) + } + if want := []string{"test::Legacy"}; !slices.Equal(metadataTypeNames(t, ctx, got), want) { + t.Errorf("metadata types = %v, want %v", metadataTypeNames(t, ctx, got), want) + } +} + +// TestMetadataAccessEmpty reads the metadata of an element nothing annotates: +// only its reflective metaobject, of the metaclass its declaration is of. +func TestMetadataAccessEmpty(t *testing.T) { + for expr, want := range map[string]string{ + "test::mass.metadata": "meta(test::mass : SysML::Systems::AttributeUsage)", + "test::Vehicle.metadata": "meta(test::Vehicle : SysML::Systems::PartDefinition)", + } { + ctx, got, err := evalDeclaredExpr(t, metadataSrc, expr) + if err != nil { + t.Fatalf("%s failed: %v", expr, err) + } + if names := metadataTypeNames(t, ctx, got); len(names) != 0 { + t.Errorf("%s = %v, want no annotation", expr, names) + } + if text := FormatTraceValue(elementsOf(got)[0]); text != want { + t.Errorf("%s = %s, want %s", expr, text, want) + } + } +} + +// TestMetadataAccessNotAnElement refuses `.metadata` read from a value. +func TestMetadataAccessNotAnElement(t *testing.T) { + for _, expr := range []string{"1.metadata", `"abc".metadata`, "(1 + 2).metadata"} { + _, _, err := evalDeclaredExpr(t, metadataSrc, expr) + if err == nil { + t.Fatalf("%s succeeded, want a typed error", expr) + } + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("%s: error %v, want a type mismatch", expr, err) + } + if !strings.Contains(err.Error(), "metadata access requires an element") { + t.Errorf("%s: error %q, want it to require an element", expr, err) + } + } +} + +// TestMetadataAccessUnresolved refuses `.metadata` over a name that names nothing. +func TestMetadataAccessUnresolved(t *testing.T) { + _, _, err := evalDeclaredExpr(t, metadataSrc, "test::nope.metadata") + if !errors.Is(err, ErrUnresolvedReference) { + t.Errorf("error %v, want an unresolved reference", err) + } +} + +// TestMetadataAccessUnknownFeature refuses a body binding the metadata type declares +// no feature for, rather than dropping the value. +func TestMetadataAccessUnknownFeature(t *testing.T) { + src := ` +package test { + metadata def Safety { + attribute level : ScalarValues::Integer = 2; + } + + part def Vehicle; + + part seatBelt : Vehicle { + @Safety { + severity = 3; + } + } +} +` + _, _, err := evalDeclaredExpr(t, src, "test::seatBelt.metadata") + if err == nil { + t.Fatal("an unknown metadata feature succeeded, want a typed error") + } + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("error %v, want a type mismatch", err) + } + if !strings.Contains(err.Error(), "severity") { + t.Errorf("error %q, want it to name severity", err) + } +} + +// TestMetadataAccessFailureAbandonsInstances requires one access to materialize +// every annotation or none: an annotation that fails leaves behind no object, +// and no behavior of one, of the annotations read before it. +func TestMetadataAccessFailureAbandonsInstances(t *testing.T) { + src := ` +package test { + part def Controller { + attribute count : ScalarValues::Integer = 0; + exhibit state modes { + entry; then running; + state running { + entry action bump { assign count := count + 1; } + } + } + } + + part ctrl : Controller; + + metadata def Safety { + attribute level : ScalarValues::Integer = 2; + } + + part def Vehicle; + + part reader : Vehicle { + @Safety { + level = ctrl.count; + } + } + + part seatBelt : Vehicle { + @Safety { + level = ctrl.count; + } + @Safety { + severity = 3; + } + } +} +` + // The first annotation reads an object whose machine runs, so the failing + // access has behaviors as well as objects to abandon. + ok, _, err := evalDeclaredExpr(t, src, "test::reader.metadata") + if err != nil { + t.Fatalf("test::reader.metadata: %v", err) + } + if len(ok.objectBehaviors) == 0 { + t.Fatal("reading the annotation started no behavior, so the test proves nothing") + } + + ctx, _, err := evalDeclaredExpr(t, src, "test::seatBelt.metadata") + if err == nil { + t.Fatal("the failing annotation succeeded, want a typed error") + } + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("error %v, want a type mismatch", err) + } + if live := len(ctx.instances); live != 0 { + t.Errorf("%d object(s) outlived the failed access, want none", live) + } + if created := len(ctx.created); created != 0 { + t.Errorf("%d object(s) stay registered as created, want none", created) + } + if attached, pending := len(ctx.objectBehaviors), len(ctx.pendingBehaviors); attached != 0 || pending != 0 { + t.Errorf("%d behavior(s) (%d pending) outlived the failed access, want none", attached, pending) + } +} + +// TestMetadataAccessNestedBindings reads an annotation whose body binds a value +// through a nested declaration: the object the outer feature holds carries it. +func TestMetadataAccessNestedBindings(t *testing.T) { + src := ` +package test { + private import ScalarValues::*; + + metadata def Cause { + attribute code : Integer = 0; + } + + metadata def Risk { + attribute level : Integer = 0; + attribute note : String = "none"; + attribute cause : Cause; + } + + metadata def Safety { + attribute risk : Risk; + } + + part def Vehicle; + + part seatBelt : Vehicle { + @Safety { + risk { + level = 7; + cause { + code = 42; + } + } + } + } +} +` + ctx, got, err := evalDeclaredExpr(t, src, "test::seatBelt.metadata") + if err != nil { + t.Fatalf("seatBelt.metadata failed: %v", err) + } + safety, ok := ctx.getInstance(elementsOf(got)[0].Instance) + if !ok { + t.Fatal("the metadata value is no object") + } + id, isObject := featureValue(t, ctx, safety, "risk").Object() + if !isObject { + t.Fatal("risk holds no object") + } + risk, ok := ctx.getInstance(id) + if !ok { + t.Fatal("the object risk holds is not live") + } + if v := featureValue(t, ctx, risk, "level"); FormatValue(v) != "7" { + t.Errorf("risk.level = %s, want the nested binding 7", FormatValue(v)) + } + if v := featureValue(t, ctx, risk, "note"); FormatValue(v) != `"none"` { + t.Errorf("risk.note = %s, want the declared default", FormatValue(v)) + } + causeID, isObject := featureValue(t, ctx, risk, "cause").Object() + if !isObject { + t.Fatal("cause holds no object") + } + cause, ok := ctx.getInstance(causeID) + if !ok { + t.Fatal("the object cause holds is not live") + } + if v := featureValue(t, ctx, cause, "code"); FormatValue(v) != "42" { + t.Errorf("risk.cause.code = %s, want the nested binding 42", FormatValue(v)) + } +} + +// TestMetadataAccessBindingScope reads a body value naming a feature of the +// metadata type that an element around the annotation also names: the body sees +// the metadata type's own member. +func TestMetadataAccessBindingScope(t *testing.T) { + src := ` +package test { + private import ScalarValues::*; + + attribute limit = 1; + + metadata def Safety { + attribute limit : Integer = 9; + attribute level : Integer = 0; + } + + part def Vehicle; + + part seatBelt : Vehicle { + @Safety { + level = limit; + } + } +} +` + ctx, got, err := evalDeclaredExpr(t, src, "test::seatBelt.metadata") + if err != nil { + t.Fatalf("seatBelt.metadata failed: %v", err) + } + safety, ok := ctx.getInstance(elementsOf(got)[0].Instance) + if !ok { + t.Fatal("the metadata value is no object") + } + if v := featureValue(t, ctx, safety, "level"); FormatValue(v) != "9" { + t.Errorf("level = %s, want 9, the limit the metadata type declares", FormatValue(v)) + } +} + +// TestMetadataAccessRenamedRedefinition binds a metadata feature through a +// redefinition that gives it a name of its own, at both body levels. +func TestMetadataAccessRenamedRedefinition(t *testing.T) { + src := ` +package test { + private import ScalarValues::*; + + metadata def Cause { + attribute code : Integer = 0; + } + + metadata def Safety { + attribute level : Integer = 0; + attribute cause : Cause; + } + + part def Vehicle; + + part seatBelt : Vehicle { + @Safety { + attribute severity :>> level = 3; + cause { + attribute reason :>> code = 42; + } + } + } +} +` + ctx, got, err := evalDeclaredExpr(t, src, "test::seatBelt.metadata") + if err != nil { + t.Fatalf("seatBelt.metadata failed: %v", err) + } + safety, ok := ctx.getInstance(elementsOf(got)[0].Instance) + if !ok { + t.Fatal("the metadata value is no object") + } + if v := featureValue(t, ctx, safety, "level"); FormatValue(v) != "3" { + t.Errorf("level = %s, want the redefining binding 3", FormatValue(v)) + } + id, isObject := featureValue(t, ctx, safety, "cause").Object() + if !isObject { + t.Fatal("cause holds no object") + } + cause, ok := ctx.getInstance(id) + if !ok { + t.Fatal("the object cause holds is not live") + } + if v := featureValue(t, ctx, cause, "code"); FormatValue(v) != "42" { + t.Errorf("cause.code = %s, want the redefining binding 42", FormatValue(v)) + } +} + +// TestMetadataAccessDependentBindings reads a body value naming a feature an +// earlier binding of the same body bound: it reads what was bound, not the +// default the metadata type declares. +func TestMetadataAccessDependentBindings(t *testing.T) { + src := ` +package test { + private import ScalarValues::*; + + metadata def Safety { + attribute level : Integer = 0; + attribute margin : Integer = 0; + } + + part def Vehicle; + + part seatBelt : Vehicle { + @Safety { + level = 2; + margin = level + 1; + } + } +} +` + ctx, got, err := evalDeclaredExpr(t, src, "test::seatBelt.metadata") + if err != nil { + t.Fatalf("seatBelt.metadata failed: %v", err) + } + safety, ok := ctx.getInstance(elementsOf(got)[0].Instance) + if !ok { + t.Fatal("the metadata value is no object") + } + if v := featureValue(t, ctx, safety, "margin"); FormatValue(v) != "3" { + t.Errorf("margin = %s, want 3, one more than the level bound before it", FormatValue(v)) + } +} + +// TestMetadataAccessSameObjects reads the metadata of one element twice: an +// annotation denotes one object, so both reads answer it and no second object +// of the metadata type is made. +func TestMetadataAccessSameObjects(t *testing.T) { + src := ` +package test { + private import ScalarValues::*; + + metadata def Safety { + attribute level : Integer = 0; + } + + part def Vehicle; + + part seatBelt : Vehicle { + @Safety { + level = 3; + } + } +} +` + ctx, scope, decl := declaredExpr(t, src, "test::seatBelt.metadata") + first, err := NewEvalContext(ctx, scope).Eval(decl) + if err != nil { + t.Fatalf("seatBelt.metadata failed: %v", err) + } + made := len(ctx.instances) + second, err := NewEvalContext(ctx, scope).Eval(decl) + if err != nil { + t.Fatalf("the second seatBelt.metadata failed: %v", err) + } + one, two := elementsOf(first), elementsOf(second) + if len(one) != 2 || len(two) != 2 { + t.Fatalf("read %d then %d metadata values, want the annotation and the metaobject each time", len(one), len(two)) + } + if one[0].Instance != two[0].Instance { + t.Errorf("the reads answered objects %d and %d, want one object", one[0].Instance, two[0].Instance) + } + if !valueEqual(one[1], two[1]) || one[1].Kind != ValMetaobject { + t.Errorf("the reads answered metaobjects %s and %s, want one", FormatValue(one[1]), FormatValue(two[1])) + } + if len(ctx.instances) != made { + t.Errorf("the second read left %d objects, want the %d the first did", len(ctx.instances), made) + } +} + +const adoptMetadataSrc = `package Demo { + metadata def Safety { attribute level = 3; } + part def Vehicle; + part seatBelt : Vehicle { + @Safety { + level = 5; + } + } + part def Holder { attribute mark [*]; } + part def Reader { attribute seen [*] = seatBelt.metadata; } +}` + +// TestAdoptKeepsTheObjectAnAnnotationDenotes carries an object holding what an +// annotation denotes into a re-analysis: the annotation still denotes that +// object there, so reading it again answers it rather than making a second one. +func TestAdoptKeepsTheObjectAnAnnotationDenotes(t *testing.T) { + prev := libraryContextOver(t, adoptMetadataSrc) + reader, err := prev.Instantiate(lookupOne(t, prev.Resolver().Index(), "Demo::Reader")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + fv, err := reader.GetFeatureValue(prev, "seen") + if err != nil { + t.Fatalf("GetFeatureValue(seen): %v", err) + } + holder, err := prev.Instantiate(lookupOne(t, prev.Resolver().Index(), "Demo::Holder")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if err := holder.SetFeatureValue(prev, "mark", fv.HeldValue()); err != nil { + t.Fatalf("SetFeatureValue(mark): %v", err) + } + carried := annotationRead(t, "mark", fv.HeldValue()) + shapes := prev.ShapesOf(holder) + + ctx := libraryContextOver(t, adoptMetadataSrc+"\npart def Widget;") + if _, err := ctx.Adopt(prev, shapes, holder); err != nil { + t.Fatalf("Adopt: %v", err) + } + if _, found := ctx.Instance(carried.Instance); !found { + t.Fatalf("the metadata object %d was not carried over, so the test proves nothing", + carried.Instance) + } + made := len(ctx.instances) + again, err := ctx.Instantiate(lookupOne(t, ctx.model.resolver.Index(), "Demo::Reader")) + if err != nil { + t.Fatalf("Instantiate after adoption: %v", err) + } + seen, err := again.GetFeatureValue(ctx, "seen") + if err != nil { + t.Fatalf("GetFeatureValue(seen) after adoption: %v", err) + } + read := annotationRead(t, "seen after adoption", seen.HeldValue()) + if read.Instance != carried.Instance { + t.Errorf("the annotation denotes object %d after adoption, want the carried %d", + read.Instance, carried.Instance) + } + if len(ctx.instances) != made+1 { + t.Errorf("reading the annotation again left %d objects, want the %d carried plus the reader", + len(ctx.instances), made) + } +} + +// TestAdoptReadsAChangedAnnotationAgain edits the annotation body between the +// two analyses: the object made for what it said before does not stand for what +// it says now, so the annotation is read again and answers the new value. +func TestAdoptReadsAChangedAnnotationAgain(t *testing.T) { + prev := libraryContextOver(t, adoptMetadataSrc) + reader, err := prev.Instantiate(lookupOne(t, prev.Resolver().Index(), "Demo::Reader")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + fv, err := reader.GetFeatureValue(prev, "seen") + if err != nil { + t.Fatalf("GetFeatureValue(seen): %v", err) + } + holder, err := prev.Instantiate(lookupOne(t, prev.Resolver().Index(), "Demo::Holder")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if err := holder.SetFeatureValue(prev, "mark", fv.HeldValue()); err != nil { + t.Fatalf("SetFeatureValue(mark): %v", err) + } + carried := annotationRead(t, "seen", fv.HeldValue()) + shapes := prev.ShapesOf(holder) + + ctx := libraryContextOver(t, strings.Replace(adoptMetadataSrc, "level = 5", "level = 9", 1)) + if _, err := ctx.Adopt(prev, shapes, holder); err != nil { + t.Fatalf("Adopt: %v", err) + } + again, err := ctx.Instantiate(lookupOne(t, ctx.model.resolver.Index(), "Demo::Reader")) + if err != nil { + t.Fatalf("Instantiate after adoption: %v", err) + } + seen, err := again.GetFeatureValue(ctx, "seen") + if err != nil { + t.Fatalf("GetFeatureValue(seen) after adoption: %v", err) + } + read := annotationRead(t, "seen after adoption", seen.HeldValue()) + if read.Instance == carried.Instance { + t.Fatalf("the edited annotation reused object %d, made for what it said before", + carried.Instance) + } + obj, found := ctx.Instance(read.Instance) + if !found { + t.Fatalf("the annotation reads as object %d, which the context does not hold", read.Instance) + } + level, err := obj.GetFeatureValue(ctx, "level") + if err != nil { + t.Fatalf("GetFeatureValue(level): %v", err) + } + if got := fmt.Sprint(level.Value.Const); !strings.Contains(got, "9") { + t.Errorf("level = %s, want the 9 the annotation states now", got) + } +} + +// contextOverDocs indexes each named document over the library, whose +// metaclasses `.metadata` answers, and gives the context every text, so a +// digest over an annotation reads the document stating it. +func contextOverDocs(t *testing.T, docs [][2]string) *Context { + t.Helper() + idx := libs.NewModelIndex() + for _, doc := range docs { + idx.AddDocument(doc[0], parser.New(source.New(doc[0], []byte(doc[1]))).ParseFile()) + } + resolver := resolve.New(idx) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) + for _, doc := range docs { + ctx.Model().RegisterSource(source.New(doc[0], []byte(doc[1]))) + } + return ctx +} + +// annotationRead is the object the one annotation of a `.metadata` read denotes, +// which the element's reflective metaobject follows. +func annotationRead(t *testing.T, what string, value Value) Value { + t.Helper() + vals := elementsOf(value) + if len(vals) != 2 || vals[0].Kind != ValInstance || vals[1].Kind != ValMetaobject { + t.Fatalf("%s reads as %s, want the annotation's object then the metaobject", what, FormatValue(value)) + } + return vals[0] +} + +const adoptAboutModel = `package Demo { + metadata def Safety { attribute level = 3; } + part def Vehicle; + part seatBelt : Vehicle; + part def Holder { attribute mark [*]; } + part def Reader { attribute seen [*] = seatBelt.metadata; } +}` + +const adoptAboutNotes = `package Notes { + metadata Demo::Safety about Demo::seatBelt { + level = 5; + } +}` + +// TestAdoptReadsAChangedAboutAnnotationAgain states the annotation away from the +// element it annotates: editing the annotating document is what makes the object +// made for it stale, and editing the annotated element's document does not. +func TestAdoptReadsAChangedAboutAnnotationAgain(t *testing.T) { + read := func(t *testing.T, ctx *Context, obj *Instance) Value { + t.Helper() + fv, err := obj.GetFeatureValue(ctx, "seen") + if err != nil { + t.Fatalf("GetFeatureValue(seen): %v", err) + } + return annotationRead(t, "seen", fv.HeldValue()) + } + carry := func(t *testing.T, model, notes string) (int64, int64) { + t.Helper() + prev := contextOverDocs(t, [][2]string{{"model.sysml", adoptAboutModel}, {"notes.sysml", adoptAboutNotes}}) + reader, err := prev.Instantiate(lookupOne(t, prev.Resolver().Index(), "Demo::Reader")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + before := read(t, prev, reader) + holder, err := prev.Instantiate(lookupOne(t, prev.Resolver().Index(), "Demo::Holder")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if err := holder.SetFeatureValue(prev, "mark", before); err != nil { + t.Fatalf("SetFeatureValue(mark): %v", err) + } + ctx := contextOverDocs(t, [][2]string{{"model.sysml", model}, {"notes.sysml", notes}}) + if _, err := ctx.Adopt(prev, prev.ShapesOf(holder), holder); err != nil { + t.Fatalf("Adopt: %v", err) + } + again, err := ctx.Instantiate(lookupOne(t, ctx.model.resolver.Index(), "Demo::Reader")) + if err != nil { + t.Fatalf("Instantiate after adoption: %v", err) + } + return before.Instance, read(t, ctx, again).Instance + } + + before, after := carry(t, adoptAboutModel+"\npart def Widget;", adoptAboutNotes) + if before != after { + t.Errorf("an unedited annotation read as object %d, want the %d it denoted", after, before) + } + before, after = carry(t, adoptAboutModel, strings.Replace(adoptAboutNotes, "level = 5", "level = 9", 1)) + if before == after { + t.Errorf("the edited annotation reused object %d, made for what it said before", before) + } +} diff --git a/internal/core/runtime/metaobject.go b/internal/exec/runtime/metaobject.go similarity index 98% rename from internal/core/runtime/metaobject.go rename to internal/exec/runtime/metaobject.go index 8c317e1acd..c5e337ab37 100644 --- a/internal/core/runtime/metaobject.go +++ b/internal/exec/runtime/metaobject.go @@ -5,9 +5,9 @@ import ( "fmt" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // ErrNoMetaclass is returned when an element's declaration is classified by no diff --git a/internal/exec/runtime/model.go b/internal/exec/runtime/model.go new file mode 100644 index 0000000000..72ce667e6b --- /dev/null +++ b/internal/exec/runtime/model.go @@ -0,0 +1,287 @@ +package runtime + +import ( + "errors" + "sort" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// ErrNoArgumentTyper reports a call reached on a semantic model with no argument +// typing installed: the run would select among overloads by arity alone, weaker +// than the checker did, so it selects nothing. +var ErrNoArgumentTyper = errors.New("no argument typing installed on the semantic model") + +// selectCall is the declaration e calls in scope as the checker selects it, from the +// semantic model's typing of the arguments; ErrNoArgumentTyper when none is installed. +func (m *Model) selectCall(scope *symbols.Scope, e *ast.InvocationExpr, performs semantics.Performs) (*semantics.InvocationSelection, error) { + if !m.semantics.HasArgumentTyper() { + return nil, ErrNoArgumentTyper + } + return m.semantics.SelectCall(scope, e, performs), nil +} + +// Model is the model-derived part of execution: the semantic model and resolver a +// run reads, and what is memoized from them — calc shapes, write targets, +// invocation targets, literal values, effective features, the behaviors a type +// binds — which the model fixes and no run changes. Every context built over one +// Model reuses it, so a run pays for none of it again; the run-derived state, what +// a run creates and a snapshot captures, is the Context's. +// +// The memo tables fill lazily into plain maps, as the resolver's and the semantic +// model's do, so a Model is for one goroutine at a time: one per analysis worker. +type Model struct { + semantics *semantics.Model + resolver *resolve.Resolver + + features map[*symbols.Symbol][]EffectiveFeature + + // arrayFeatures memoizes the declarations of Collections::Array's features + // by name; see arrayFeatureSymbols. + arrayFeatures map[*symbols.Symbol]string + + // frameFeatures memoizes the declarations of the MeasurementReferences features + // a coordinate frame, scale or transformation is read by; see frameFeatureSymbols. + frameFeatures map[*symbols.Symbol]string + + // denotedFeatures memoizes, per type, the name of its feature each declared + // feature symbol denotes on an object of that type: itself or a redefinition. + denotedFeatures map[*symbols.Symbol]map[*symbols.Symbol]string + + // holders memoizes, per type, the features whose stated value lists each + // named feature of the type. + holders map[*symbols.Symbol]map[string][]string + + // returnedParams memoizes, per calc shape, the parameters its result passes on; + // returnedStack is the shapes under analysis, returnedProvisional those awaiting + // the root of their call cycle. + returnedParams map[*calcShape]*returnedAnalysis + returnedStack []*returnedAnalysis + returnedProvisional []*returnedAnalysis + + // redefined memoizes, per feature of a type, the features it redefines + // transitively; callers read the shared slice and never append to it. + redefined map[featureOfType][]*symbols.Symbol + + // writeTargets memoizes the declaration an assignment's target names, per + // scope the statement was written in: what a value written must conform to. + writeTargets map[writeTargetKey]*writeTarget + + // calcShapes memoizes resolved calc invocation interfaces (parameters, + // defaults, result expression) per calc symbol. + calcShapes map[*symbols.Symbol]*calcShape + + // predicateShapes memoizes the invocation interfaces of constraints and + // requirements applied as predicates. + predicateShapes map[*symbols.Symbol]*calcShape + + // librarySymbols memoizes the library declaration each qualified name denotes; + // see librarySymbol. + librarySymbols map[string]*symbols.Symbol + + // verificationCases memoizes the verification cases declared under a scope; + // see verificationCasesIn. + verificationCases map[*symbols.Scope][]*symbols.Symbol + + // libraryPerformances memoizes, per model calc, the inherited library function a + // call of it applies; nil for a calc that computes on its own. + libraryPerformances map[*symbols.Symbol]*libraryPerformance + + // invocationTargets memoizes what each invocation expression denotes in the + // scope it is evaluated in; the model does not change under one Model. + invocationTargets map[invocationKey]*invocationTarget + + // integerLiterals and realLiterals memoize the value each numeric literal + // node spells, so a literal in a recursion is parsed once per model. + integerLiterals map[*ast.LiteralInteger]int64 + realLiterals map[*ast.LiteralReal]float64 + + // census memoizes the object usages the model's namespaces declare; see modelUsages. + census *usageCensus + + // behaving memoizes runsBehaviors per type; the model is fixed for the Model's life. + behaving map[*symbols.Symbol]bool + // behavingFeatures memoizes behavingParts and redefGroups redefinitionGroups, per type. + behavingFeatures map[*symbols.Symbol][]int + redefGroups map[*symbols.Symbol][][]string + // subsetters memoizes, per type, the features subsetting each named feature of it + // under any of its redefinition names; callers read the shared slice. + subsetters map[*symbols.Symbol]map[string][]EffectiveFeature + // subsetted memoizes subsettedNames per feature of a type; callers read the shared slice. + subsetted map[featureOfType][]string + + // toolExecutions memoizes toolExecutionOf per action; toolUnits the units tool + // answers spell, per scope they are read in. + toolExecutions map[*symbols.Symbol]*toolExecution + toolUnits map[toolUnitKey]semantics.Unit + + // objectConns memoizes the connections declared by each type an object is + // of, which a behavior that object performs routes over. + objectConns map[*symbols.Symbol][]lower.Connection + + // bindingIR memoizes binding connectors declared by each materialized + // object type, including bindings inherited from its supertypes. + bindingIR map[*symbols.Symbol][]lower.Binding + bindingFeatures map[*symbols.Symbol]map[string][]lower.Binding + + // classifierBehaviors memoizes the behaviors each type binds to its objects: + // the machines it exhibits and the actions it performs. + classifierBehaviors map[*symbols.Symbol][]classifierBehaviorDecl + + // triggerTypes memoizes the definition an accept's type reference denotes in the + // scope it is written in, and signalMatches whether a signal conforms to one; a + // machine judges every message in flight against every trigger it holds each step. + triggerTypes map[triggerTypeKey]*symbols.Symbol + signalMatches map[signalMatchKey]bool + + // sources holds the text of the files the model was read from, by name, so an + // error about a declaration can say where it was written. A file no caller + // registered is reported by name and byte offset instead. + sources map[string]*source.SourceFile + + // scopes holds the scope trees the caller resolves references in; declared + // maps each declaration node to the symbol they declare for it, built on first use. + scopes []*symbols.Scope + declared map[ast.Node]*symbols.Symbol + + // parse reads the notation text a run receives as text: a witness file's input + // values and the units a tool answers in; installed by SetExpressionParser. + parse ExpressionParser +} + +// ExpressionParser parses text, read from origin, as exactly one expression; false for +// anything else. The caller that builds a Model supplies it; the runtime parses nothing itself. +type ExpressionParser func(origin, text string) (ast.Node, bool) + +// ErrNoExpressionParser is the typed error a run returns on reaching notation text +// to read with no ExpressionParser installed on its Model. +var ErrNoExpressionParser = errors.New("no expression parser installed on the runtime model") + +// SetExpressionParser installs the parser the Model reads witness input values and tool +// units with; units the previous parser read are forgotten, so every lookup goes through it. +func (m *Model) SetExpressionParser(parse ExpressionParser) { + m.parse = parse + clear(m.toolUnits) +} + +// parseOneExpression reads text as exactly one expression with the installed parser; ok is +// false for text that is not one, err ErrNoExpressionParser when no parser is installed. +func (m *Model) parseOneExpression(origin, text string) (expr ast.Node, ok bool, err error) { + if m.parse == nil { + return nil, false, ErrNoExpressionParser + } + expr, ok = m.parse(origin, text) + return expr, ok, nil +} + +// NewModel builds the model-derived part of execution over a semantic model and +// the resolver it resolves names with; either may be nil for a context that +// evaluates literals alone. The caller installs the checker's argument typing on +// sem (semantics.Model.SetArgumentTyper) before any call is selected; a run that +// selects a call without one fails with ErrNoArgumentTyper. Contexts are built +// over it with NewContext. +func NewModel(sem *semantics.Model, resolver *resolve.Resolver) *Model { + return &Model{ + semantics: sem, + resolver: resolver, + features: make(map[*symbols.Symbol][]EffectiveFeature), + denotedFeatures: make(map[*symbols.Symbol]map[*symbols.Symbol]string), + holders: make(map[*symbols.Symbol]map[string][]string), + returnedParams: make(map[*calcShape]*returnedAnalysis), + redefined: make(map[featureOfType][]*symbols.Symbol), + writeTargets: make(map[writeTargetKey]*writeTarget), + calcShapes: make(map[*symbols.Symbol]*calcShape), + predicateShapes: make(map[*symbols.Symbol]*calcShape), + librarySymbols: make(map[string]*symbols.Symbol), + verificationCases: make(map[*symbols.Scope][]*symbols.Symbol), + libraryPerformances: make(map[*symbols.Symbol]*libraryPerformance), + invocationTargets: make(map[invocationKey]*invocationTarget), + integerLiterals: make(map[*ast.LiteralInteger]int64), + realLiterals: make(map[*ast.LiteralReal]float64), + behaving: make(map[*symbols.Symbol]bool), + behavingFeatures: make(map[*symbols.Symbol][]int), + redefGroups: make(map[*symbols.Symbol][][]string), + subsetters: make(map[*symbols.Symbol]map[string][]EffectiveFeature), + subsetted: make(map[featureOfType][]string), + toolExecutions: make(map[*symbols.Symbol]*toolExecution), + toolUnits: make(map[toolUnitKey]semantics.Unit), + objectConns: make(map[*symbols.Symbol][]lower.Connection), + bindingIR: make(map[*symbols.Symbol][]lower.Binding), + bindingFeatures: make(map[*symbols.Symbol]map[string][]lower.Binding), + classifierBehaviors: make(map[*symbols.Symbol][]classifierBehaviorDecl), + triggerTypes: make(map[triggerTypeKey]*symbols.Symbol), + signalMatches: make(map[signalMatchKey]bool), + sources: make(map[string]*source.SourceFile), + } +} + +// Semantics returns the semantic model the Model is derived from. +func (m *Model) Semantics() *semantics.Model { + return m.semantics +} + +// Resolver returns the name resolver the Model resolves references with. +func (m *Model) Resolver() *resolve.Resolver { + return m.resolver +} + +// RegisterSource gives the Model the text of a file it was read from, so an +// error about a declaration in it reports a line and column. +func (m *Model) RegisterSource(sf *source.SourceFile) { + if sf == nil { + return + } + m.sources[sf.Name()] = sf +} + +// Text answers a span in a document from the registered files, falling back to +// the notation lookup the semantic model reads documentation from. +func (m *Model) Text() source.Lookup { + return source.TextOf(m.sources, m.semantics.SourceText()) +} + +// Sources returns the registered files of the model, in name order. +func (m *Model) Sources() []*source.SourceFile { + files := make([]*source.SourceFile, 0, len(m.sources)) + for _, sf := range m.sources { + files = append(files, sf) + } + sort.Slice(files, func(i, j int) bool { return files[i].Name() < files[j].Name() }) + return files +} + +// RegisterScope gives the Model a scope tree the caller resolves references in, +// so a declaration carried over by Adopt is rebound to the symbol that tree +// declares for it rather than to the index's own. +func (m *Model) RegisterScope(scope *symbols.Scope) { + if scope == nil { + return + } + m.scopes = append(m.scopes, scope) + m.declared = nil + m.census = nil +} + +// declaredSymbol is the symbol a registered scope tree declares for the +// declaration sym stands for, or sym itself when none does (a library declaration, +// or a Model resolving in the index's tree alone). +func (m *Model) declaredSymbol(sym *symbols.Symbol) *symbols.Symbol { + if sym == nil || sym.Decl == nil || len(m.scopes) == 0 { + return sym + } + if m.declared == nil { + m.declared = make(map[ast.Node]*symbols.Symbol) + for _, scope := range m.scopes { + collectDeclared(scope, m.declared) + } + } + if local, ok := m.declared[sym.Decl]; ok { + return local + } + return sym +} diff --git a/internal/exec/runtime/modeled.go b/internal/exec/runtime/modeled.go new file mode 100644 index 0000000000..a5b8baaa5e --- /dev/null +++ b/internal/exec/runtime/modeled.go @@ -0,0 +1,406 @@ +package runtime + +import ( + "errors" + "fmt" + "math" + "math/rand/v2" + "slices" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// A model states its own randomness — a weighted branch out of a decision, a +// RandomFunctions call — apart from the orders the library leaves open, which the +// scheduling policy resolves. Modeled draws come from a stream of their own, so +// the token order a seed fixes and the values the model draws are two knobs. + +// modeledStream keys the stream of modeled draws off a seed, apart from the token-order stream. +const modeledStream uint64 = 0xD1B54A32D192ED03 + +// ErrUnseededDraw is the typed error a modeled draw raises when no seed fixes it. +var ErrUnseededDraw = errors.New("modeled randomness needs a seed") + +// UnseededDrawError names the draw a run could not make for want of a seed. +type UnseededDrawError struct { + What string +} + +func (e *UnseededDrawError) Error() string { + return fmt.Sprintf("%v: %s draws a random value; seed the run, as -seed or %%seed , or schedule it under seed:", ErrUnseededDraw, e.What) +} + +// Is makes every UnseededDrawError match ErrUnseededDraw. +func (e *UnseededDrawError) Is(target error) bool { return target == ErrUnseededDraw } + +// ErrRandomDomain is the typed error a random function raises on arguments that +// bound no distribution: `uniform(hi, lo)`, a negative standard deviation. +var ErrRandomDomain = errors.New("random function arguments bound no distribution") + +// ErrBranchWeights is the typed error a weighted decision raises when the weights +// its branches draw, once evaluated, are no probabilities. +var ErrBranchWeights = errors.New("invalid branch weights") + +// modeledSource is the stream one run's modeled draws come from: a generator a +// seed fixes, or the draws a witness recorded, followed in order. +type modeledSource struct { + pcg *rand.PCG + rng *rand.Rand + replay *replayRun +} + +// newModeledSource starts the modeled stream seed fixes. +func newModeledSource(seed uint64) *modeledSource { + pcg := rand.NewPCG(seed, seed^modeledStream) + return modeledAt(*pcg) +} + +// modeledAt resumes the modeled stream at a generator's position. +func modeledAt(state rand.PCG) *modeledSource { + pcg := &state + // #nosec G404 -- a replayable run needs a stated generator, not a cryptographic one. + return &modeledSource{pcg: pcg, rng: rand.New(pcg)} +} + +// modeledDraws follows the draws the run's witness recorded. +func modeledDraws(r *replayRun) *modeledSource { + return &modeledSource{replay: r} +} + +// seeded reports whether the source draws from a generator of its own. +func (m *modeledSource) seeded() bool { return m != nil && m.rng != nil } + +// replays reports whether the source hands out a witness's recorded draws. +func (m *modeledSource) replays() bool { return m != nil && m.replay != nil } + +// mark returns what a probe restores: the generator's state, or the witness's position. +func (m *modeledSource) mark() func() { + if m == nil || m.pcg == nil { + return func() { /* nothing drawn from a generator */ } + } + saved := *m.pcg + return func() { *m.pcg = saved } +} + +// position spells where the stream stands — what the run draws next — for a +// checker telling apart states alike in every other way; "" for a run that cannot draw. +func (m *modeledSource) position() string { + switch { + case m == nil: + return "" + case m.replay != nil: + return fmt.Sprintf("witness draw %d", m.replay.nextDraw+1) + } + state, err := m.pcg.MarshalBinary() + if err != nil { + return "generator " + err.Error() + } + return fmt.Sprintf("generator %x", state) +} + +// modelSeed is the seed a context's runs draw their modeled randomness from when +// set, whatever the scheduling policy; nil leaves it to a `seed:` policy. +type modelSeed struct { + seed uint64 + set bool +} + +// SetModelSeed fixes the seed the modeled draws of the runs started from now on +// come from, independent of the scheduling policy; ClearModelSeed unsets it. +func (ctx *Context) SetModelSeed(seed uint64) { + ctx.modelSeed = modelSeed{seed: seed, set: true} +} + +// ClearModelSeed leaves modeled draws to the scheduling policy's seed, if any. +func (ctx *Context) ClearModelSeed() { + ctx.modelSeed = modelSeed{} +} + +// ModelSeed is the seed set by SetModelSeed, and whether one is. +func (ctx *Context) ModelSeed() (uint64, bool) { + return ctx.modelSeed.seed, ctx.modelSeed.set +} + +// modeledUnder is the stream the run's modeled draws come from under policy: the +// witness's draws under replay, else the model seed's stream, else the schedule +// seed's; nil for a run that cannot draw. +func (ctx *Context) modeledUnder(policy SchedulePolicy, replay *replayRun) *modeledSource { + if policy.kind == scheduleReplay && replay != nil { + return modeledDraws(replay) + } + if ctx.modelSeed.set { + return newModeledSource(ctx.modelSeed.seed) + } + if policy.kind == scheduleSeeded { + return newModeledSource(policy.seed) + } + return nil +} + +// DrawTaken is one random value a run drew: the call that drew it with its +// arguments as evaluated, and the value, spelt `draw uniform(1, 80) = 42.5`. +type DrawTaken struct { + What string + Value semantics.Value +} + +// DrawDiagnosticCode is the code a diagnostic about a random draw carries. +const DrawDiagnosticCode = "random-draw" + +// DrawPoint is a random draw as a run notes it, in the trace where it was made. +type DrawPoint struct { + Draw DrawTaken +} + +// Describe renders the draw for a diagnostic. +func (d DrawPoint) Describe() string { return d.Draw.Describe() } + +// String is the trace line the draw is recorded as: its witness line. +func (d DrawPoint) String() string { return d.Draw.String() } + +// Location is where the draw was made; a call inside an expression names no file. +func (d DrawPoint) Location() (string, source.Span) { return "", source.Span{} } + +// Diagnostic is the draw as an informational finding about the run. +func (d DrawPoint) Diagnostic() diag.Diagnostic { + return diag.Diagnostic{ + Severity: diag.SeverityInfo, + Message: "random draw: " + d.Describe(), + Code: DrawDiagnosticCode, + Source: "runtime", + } +} + +// distribution is what one random call draws: a value from the generator, the +// values it could yield at all, which a witness's recorded draw is checked against, +// and the fixed point each non-random DrawPolicy resolves it to, if it has one. +type distribution struct { + draw func(rng *rand.Rand) semantics.Value + admits func(v semantics.Value) bool + fixed func(policy DrawPolicy) (semantics.Value, bool) +} + +// draw makes the draw the call what asks for — the witness's under replay, the +// distribution's fixed point under a fixed policy, else one from the run's modeled +// stream — noting it for the trace and the witness; a probe's draw is undone with the probe. +func (ctx *Context) draw(what string, dist distribution) (semantics.Value, error) { + val, err := ctx.scheduling().draw(what, dist) + if err != nil { + return semantics.Value{}, err + } + taken := DrawTaken{What: what, Value: val} + if ctx.probes == 0 { + ctx.draws = append(ctx.draws, taken) + } + ctx.note(DrawPoint{Draw: taken}) + return val, nil +} + +// DrawsTaken returns the random draws every run of the context made, in order, as +// a witness lists them: what a `replay` policy over them hands the same calls. +func (ctx *Context) DrawsTaken() []DrawTaken { + return slices.Clone(ctx.draws) +} + +// drawPrefix opens a draw line of a witness. +const drawPrefix = "draw " + +// String spells the draw as a witness lists it and ParseDraw reads it back. +func (d DrawTaken) String() string { + return drawPrefix + d.What + " = " + formatDrawn(d.Value) +} + +// Describe renders the draw for a diagnostic. +func (d DrawTaken) Describe() string { + return d.What + " drew " + formatDrawn(d.Value) +} + +// formatDrawn spells a drawn number so it reads back as the kind it is: a Real +// always carries a point or an exponent. +func formatDrawn(v semantics.Value) string { + if v.Kind == semantics.ValInt { + return strconv.FormatInt(v.Int, 10) + } + s := strconv.FormatFloat(v.Real, 'g', -1, 64) + if !strings.ContainsAny(s, ".eIN") { + s += ".0" + } + return s +} + +// ErrInvalidDraw is the typed error every unreadable draw line wraps. +var ErrInvalidDraw = errors.New("invalid draw") + +// DrawParseError reports a draw line ParseDraw could not read, with why. +type DrawParseError struct { + Text string + Line int + Reason string +} + +func (e *DrawParseError) Error() string { + if e.Line > 0 { + return fmt.Sprintf("%v: line %d: %q: %s", ErrInvalidDraw, e.Line, e.Text, e.Reason) + } + return fmt.Sprintf("%v: %q: %s", ErrInvalidDraw, e.Text, e.Reason) +} + +// Is makes every DrawParseError match ErrInvalidDraw. +func (e *DrawParseError) Is(target error) bool { return target == ErrInvalidDraw } + +// ParseDraw reads one draw as DrawTaken.String spells it: `draw = `. +func ParseDraw(text string) (DrawTaken, error) { + text = strings.TrimSpace(text) + fail := func(reason string) (DrawTaken, error) { + return DrawTaken{}, &DrawParseError{Text: text, Reason: reason} + } + rest, ok := strings.CutPrefix(text, drawPrefix) + if !ok { + return fail("a draw line starts with `draw `: draw = ") + } + at := strings.LastIndex(rest, " = ") + if at < 0 { + return fail("a draw needs ` = ` between the call and its value: draw = ") + } + what, written := strings.TrimSpace(rest[:at]), strings.TrimSpace(rest[at+len(" = "):]) + if what == "" { + return fail("a draw names the call that drew it before ` = `") + } + if n, err := strconv.ParseInt(written, 10, 64); err == nil { + return DrawTaken{What: what, Value: semantics.Value{Kind: semantics.ValInt, Int: n}}, nil + } + x, err := strconv.ParseFloat(written, 64) + if err != nil || written == "" { + return fail("a draw's value is a number: draw = ") + } + return DrawTaken{What: what, Value: semantics.Value{Kind: semantics.ValReal, Real: x}}, nil +} + +// ErrWitnessDraw is the typed error a replay raises at a draw the run cannot +// consume: one the witness records that the run does not make, or one the run +// makes that the witness does not record. +var ErrWitnessDraw = errors.New("witness draw not consumable") + +// WitnessDrawError names the draw a replay could not follow and why. +type WitnessDrawError struct { + // Draw is the 1-based position of the draw in the witness, 0 past its last. + Draw int + What string + Reason string +} + +func (e *WitnessDrawError) Error() string { + if e.Draw > 0 { + return fmt.Sprintf("%v: draw %d: %s: %s", ErrWitnessDraw, e.Draw, e.What, e.Reason) + } + return fmt.Sprintf("%v: %s: %s", ErrWitnessDraw, e.What, e.Reason) +} + +// Is makes every WitnessDrawError match ErrWitnessDraw. +func (e *WitnessDrawError) Is(target error) bool { return target == ErrWitnessDraw } + +// draw is the value the call what draws: the witness's next recorded draw under +// replay, which must be of the same call and one the call could draw, else a draw +// from the seeded generator. +func (m *modeledSource) draw(what string, dist distribution) (semantics.Value, error) { + if m == nil { + return semantics.Value{}, &UnseededDrawError{What: what} + } + if m.replay != nil { + return m.replay.takeDraw(what, dist) + } + return dist.draw(m.rng), nil +} + +// unit is a draw in [0, 1) deciding a weighted branch; a replay decides it by the +// witness's choice line instead, so it draws none. +func (m *modeledSource) unit() (float64, bool) { + if !m.seeded() { + return 0, false + } + return m.rng.Float64(), true +} + +// weightedPick is the alternative a unit draw u in [0, 1) selects among weights +// summing to their total: the first whose cumulative weight exceeds u. +func weightedPick(weights []float64, total, u float64) int { + target := u * total + acc := 0.0 + for i, w := range weights { + acc += w + if target < acc && w > 0 { + return i + } + } + for i := len(weights) - 1; i >= 0; i-- { + if weights[i] > 0 { + return i + } + } + return 0 +} + +// mostProbable is the first alternative of the greatest weight. +func mostProbable(weights []float64) int { + best := 0 + for i, w := range weights { + if w > weights[best] { + best = i + } + } + return best +} + +// checkWeights refuses weights that are no probabilities: one outside [0, 1], or +// none of them positive. +func checkWeights(where string, weights []float64) (float64, error) { + total := 0.0 + for i, w := range weights { + if math.IsNaN(w) || w < 0 || w > 1 { + return 0, fmt.Errorf("%w: %s: branch %d weighs %s, not a probability in [0, 1]", + ErrBranchWeights, where, i, FormatWeight(w)) + } + total += w + } + if total <= 0 { + return 0, fmt.Errorf("%w: %s: no holding branch has a positive weight", ErrBranchWeights, where) + } + return total, nil +} + +// checkDistribution refuses the weights read out of a decision as lowering +// refuses constant ones — one outside [0, 1], none positive among the holding +// branches, or a sum off 1 by more than lower.ProbabilityTolerance — and +// returns the holding branches' weights, which the draw renormalizes. +func checkDistribution(where string, declared []float64, holding []int) ([]float64, error) { + total := 0.0 + for i, w := range declared { + if math.IsNaN(w) || w < 0 || w > 1 { + return nil, fmt.Errorf("%w: %s: branch %d weighs %s, not a probability in [0, 1]", + ErrBranchWeights, where, i, FormatWeight(w)) + } + total += w + } + weights := make([]float64, len(holding)) + for i, pos := range holding { + weights[i] = declared[pos] + } + if _, err := checkWeights(where, weights); err != nil { + return nil, err + } + if math.Abs(total-1) > lower.ProbabilityTolerance { + return nil, fmt.Errorf("%w: %s: the weights of its branches sum to %s, not 1.0", + ErrBranchWeights, where, FormatWeight(total)) + } + return weights, nil +} + +// FormatWeight spells a weight as a trace reports it. +func FormatWeight(w float64) string { + return strconv.FormatFloat(w, 'g', -1, 64) +} diff --git a/internal/exec/runtime/montecarlo.go b/internal/exec/runtime/montecarlo.go new file mode 100644 index 0000000000..38e5654f88 --- /dev/null +++ b/internal/exec/runtime/montecarlo.go @@ -0,0 +1,366 @@ +package runtime + +import ( + "errors" + "fmt" + "math" + "math/big" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// A Monte Carlo is a sweep over nothing but the seed: one ordinary run of a +// behavior per row, each drawing its modeled randomness from a seed of its own +// derived from the one given, and a table of what the runs observed. + +// ErrSweepRuns reports a Monte Carlo that runs nothing or states a range too. +var ErrSweepRuns = errors.New("invalid run request") + +// RunParam names the column a Monte Carlo table numbers its runs in. +const RunParam = "run" + +// runSeedStride spaces the run seeds before mixing, an odd constant so every run +// of one Monte Carlo lands on a different input of the mixer. +const runSeedStride uint64 = 0x9E3779B97F4A7C15 + +// RunSeed is the model seed run i (from 1) of a Monte Carlo seeded from seed +// draws under: SplitMix64's finalizer over seed + i·stride, a bijection, so two +// runs of one Monte Carlo never share a seed, and run i is the same on every platform. +func RunSeed(seed uint64, i int64) uint64 { + z := seed + unsignedInt(i)*runSeedStride + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) * 0x94D049BB133111EB + return z ^ (z >> 31) +} + +// MonteCarloPlan is the plan of runs runs seeded from seed: one row per run and +// no ranges. +func MonteCarloPlan(runs int64, seed uint64) SweepPlan { + return SweepPlan{Runs: runs, Seed: seed, MonteCarlo: true} +} + +// SeedlessMonteCarloPlan is the plan of runs runs seeded from nothing: a fixed draw +// policy resolves every RandomFunctions call, and a weighted decision is an unseeded draw. +func SeedlessMonteCarloPlan(runs int64) SweepPlan { + return SweepPlan{Runs: runs, MonteCarlo: true, Seedless: true} +} + +// runBindings is one row per run, binding RunParam to the run's number from 1; +// the run's seed is RunSeed of the plan's, which the run derives when it starts. +func (ctx *Context) runBindings(plan SweepPlan, limit int64) ([][]SweepBinding, error) { + if len(plan.Ranges) > 0 { + return nil, fmt.Errorf("%w: a Monte Carlo runs one behavior as declared; it states no range", ErrSweepRuns) + } + if plan.Sampled { + return nil, fmt.Errorf("%w: a Monte Carlo draws its seeds from the seed given; it samples no range", ErrSweepRuns) + } + if plan.Runs < 1 { + return nil, fmt.Errorf("%w: %d run(s) runs nothing; ask for at least one", ErrSweepRuns, plan.Runs) + } + if plan.Runs > limit { + return nil, ctx.sweepBudgetError(limit) + } + rows := make([][]SweepBinding, 0, plan.Runs) + for i := int64(1); i <= plan.Runs; i++ { + number := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: i}} + rows = append(rows, []SweepBinding{{Param: RunParam, Value: number}}) + } + return rows, nil +} + +// RunNumber is the number of the run a Monte Carlo row is, from its bindings. +func RunNumber(bindings []SweepBinding) (int64, bool) { + for _, b := range bindings { + if b.Param == RunParam && b.Value.Kind == ValConst && b.Value.Const.Kind == semantics.ValInt { + return b.Value.Const.Int, true + } + } + return 0, false +} + +// Distribution summarises one observable over the runs of a Monte Carlo: extremes, +// mean, sample standard deviation, nearest-rank p50/p90 and a histogram, exact on +// Integers save the Real mean and deviation. +type Distribution struct { + Count int + Integral bool + Min semantics.Value + Mean float64 + // Deviation is the sample standard deviation about Mean, 0 for fewer than two runs. + Deviation float64 + Max semantics.Value + P50 semantics.Value + P90 semantics.Value + Histogram []HistogramBin +} + +// HistogramBin is one bin of a histogram and how many runs fell in it: the +// numbers from Lo up to Hi, Hi included in the last bin and in every bin of an +// integral distribution. +type HistogramBin struct { + Lo, Hi semantics.Value + Count int +} + +// HistogramBins is the most bins a histogram has. +const HistogramBins = 8 + +// Distribute summarises numbers in any order, nil for none: all Integers make an +// integral distribution computed on int64, a Real among them one over Reals. +func Distribute(numbers []semantics.Value) *Distribution { + if len(numbers) == 0 { + return nil + } + ints := make([]int64, 0, len(numbers)) + for _, v := range numbers { + if v.Kind != semantics.ValInt { + return distributeReals(numbers) + } + ints = append(ints, v.Int) + } + return distributeInts(ints) +} + +// distributeInts summarises Integers on the Integers themselves; only the mean +// rounds, once, after an exact sum. +func distributeInts(values []int64) *Distribution { + sorted := slices.Clone(values) + slices.Sort(sorted) + n := len(sorted) + sum := new(big.Int) + for _, v := range sorted { + sum.Add(sum, big.NewInt(v)) + } + exactMean := new(big.Rat).SetFrac(sum, big.NewInt(int64(n))) + mean, _ := exactMean.Float64() + return &Distribution{ + Count: n, + Integral: true, + Min: drawnInt(sorted[0]), + Mean: mean, + Deviation: intDeviationOf(sorted, exactMean), + Max: drawnInt(sorted[n-1]), + P50: drawnInt(nearestRank(sorted, 0.5)), + P90: drawnInt(nearestRank(sorted, 0.9)), + Histogram: integralHistogram(sorted), + } +} + +// distributeReals summarises numbers as Reals; the mean rounds once, after an exact +// sum, so finite observations never overflow it. +func distributeReals(numbers []semantics.Value) *Distribution { + sorted := make([]float64, len(numbers)) + for i, v := range numbers { + sorted[i] = v.AsReal() + } + slices.Sort(sorted) + n := len(sorted) + mean := meanOf(sorted) + return &Distribution{ + Count: n, + Min: drawnReal(sorted[0]), + Mean: mean, + Deviation: deviationOf(sorted, mean), + Max: drawnReal(sorted[n-1]), + P50: drawnReal(nearestRank(sorted, 0.5)), + P90: drawnReal(nearestRank(sorted, 0.9)), + Histogram: histogram(sorted), + } +} + +// meanOf is the mean of values, exact until it rounds to a Real; a value that is +// no finite number carries into the mean as Real arithmetic would carry it. +func meanOf(values []float64) float64 { + counted := make([]Counted, len(values)) + for i, v := range values { + counted[i] = Counted{Count: 1, Value: v} + } + mean, _ := WeightedMean(counted) + return mean +} + +// Counted is one term of a weighted mean: Value, over Count runs; the count is +// exact, as the runs a tool summarises are. +type Counted struct { + Count int64 + Value float64 +} + +// WeightedMean is the mean of the values weighted by their counts, exact until it +// rounds to a Real, with the exact total count; a value that is no finite number +// carries into the mean as Real arithmetic would carry it. +func WeightedMean(weighted []Counted) (mean float64, count int64) { + sum := new(big.Rat) + var realSum float64 + exact := true + for _, w := range weighted { + count += w.Count + realSum += float64(w.Count) * w.Value + if math.IsInf(w.Value, 0) || math.IsNaN(w.Value) { + exact = false + } else if exact { + sum.Add(sum, new(big.Rat).Mul(big.NewRat(w.Count, 1), new(big.Rat).SetFloat64(w.Value))) + } + } + if count == 0 { + return 0, 0 + } + if !exact { + return realSum / float64(count), count + } + mean, _ = sum.Quo(sum, big.NewRat(count, 1)).Float64() + return mean, count +} + +// deviationOf is the sample standard deviation of values about their mean, 0 for fewer than two. +func deviationOf(values []float64, mean float64) float64 { + spreads := make([]Spread, len(values)) + for i, v := range values { + spreads[i] = Spread{Weight: 1, Value: v, About: true} + } + return PooledDeviation(mean, spreads, len(values)-1) +} + +// Spread is one term of a pooled sum of squares: Weight times the square of Value, +// or of Value's distance from the pool's mean when About it. +type Spread struct { + Weight, Value float64 + About bool +} + +// PooledDeviation is the root of the weighted squares of spreads, those About the mean +// taken from it, over dof degrees of freedom, 0 for none. Values and mean are scaled +// by the power of two below the largest before differencing and squaring, which rounds +// nothing, so finite values however large or far apart pool to a finite deviation. +func PooledDeviation(mean float64, spreads []Spread, dof int) float64 { + if dof < 1 { + return 0 + } + var largest float64 + for _, s := range spreads { + largest = math.Max(largest, math.Abs(s.Value)) + if s.About { + largest = math.Max(largest, math.Abs(mean)) + } + } + if largest == 0 || math.IsInf(largest, 0) || math.IsNaN(largest) { + return largest + } + _, exp := math.Frexp(largest) + scale := math.Ldexp(1, exp-1) + about := mean / scale + var sum float64 + for _, s := range spreads { + d := s.Value / scale + if s.About { + d -= about + } + sum += s.Weight * d * d + } + return scale * math.Sqrt(sum/float64(dof)) +} + +// intDeviationOf is the sample standard deviation of Integers about their exact mean, +// the squared deviations summed exactly so only the root rounds; 0 under two values. +func intDeviationOf(values []int64, mean *big.Rat) float64 { + if len(values) < 2 { + return 0 + } + sum := new(big.Rat) + for _, v := range values { + d := new(big.Rat).Sub(new(big.Rat).SetInt64(v), mean) + sum.Add(sum, d.Mul(d, d)) + } + variance, _ := sum.Quo(sum, big.NewRat(int64(len(values)-1), 1)).Float64() + return math.Sqrt(variance) +} + +// drawnInt is n as an Integer value. +func drawnInt(n int64) semantics.Value { + return semantics.Value{Kind: semantics.ValInt, Int: n} +} + +// nearestRank is the p-quantile of sorted by nearest rank. +func nearestRank[T any](sorted []T, p float64) T { + k := int(math.Ceil(p * float64(len(sorted)))) + if k < 1 { + k = 1 + } + return sorted[k-1] +} + +// histogram bins sorted Reals into at most HistogramBins equal-width bins; a span too +// narrow to divide into Real widths bins each distinct value on its own. +func histogram(sorted []float64) []HistogramBin { + lo, hi := sorted[0], sorted[len(sorted)-1] + if lo == hi || math.IsInf(hi-lo, 0) { + return []HistogramBin{{Lo: drawnReal(lo), Hi: drawnReal(hi), Count: len(sorted)}} + } + width := (hi - lo) / HistogramBins + if width == 0 { + return distinctHistogram(sorted) + } + bins := make([]HistogramBin, HistogramBins) + for i := range bins { + bins[i].Lo = drawnReal(lo + float64(i)*width) + bins[i].Hi = drawnReal(lo + float64(i+1)*width) + } + bins[len(bins)-1].Hi = drawnReal(hi) + for _, v := range sorted { + bins[min(int((v-lo)/width), HistogramBins-1)].Count++ + } + return bins +} + +// distinctHistogram bins sorted Reals one distinct value to a bin. +func distinctHistogram(sorted []float64) []HistogramBin { + var bins []HistogramBin + for _, v := range sorted { + if n := len(bins); n > 0 && bins[n-1].Lo == drawnReal(v) { + bins[n-1].Count++ + continue + } + bins = append(bins, HistogramBin{Lo: drawnReal(v), Hi: drawnReal(v), Count: 1}) + } + return bins +} + +// integralHistogram bins sorted Integers by whole widths, both bounds included; +// offsets from the minimum are unsigned so the whole int64 range stays exact. +func integralHistogram(sorted []int64) []HistogramBin { + lo, hi := sorted[0], sorted[len(sorted)-1] + span := unsignedInt(hi) - unsignedInt(lo) + if span == math.MaxUint64 { + return []HistogramBin{{Lo: drawnInt(lo), Hi: drawnInt(hi), Count: len(sorted)}} + } + width := ceilDiv(span+1, HistogramBins) + var bins []HistogramBin + for from := uint64(0); from <= span; from += width { + to := span + if span-from >= width { + to = from + width - 1 + } + bins = append(bins, HistogramBin{ + Lo: drawnInt(signedInt(unsignedInt(lo) + from)), + Hi: drawnInt(signedInt(unsignedInt(lo) + to)), + }) + if to == span { + break + } + } + for _, v := range sorted { + bin := min((unsignedInt(v)-unsignedInt(lo))/width, HistogramBins-1) + bins[bin].Count++ + } + return bins +} + +// ceilDiv is ⌈n / d⌉ for d > 0, without overflowing near the top of uint64. +func ceilDiv(n, d uint64) uint64 { + q := n / d + if n%d != 0 { + q++ + } + return q +} diff --git a/internal/exec/runtime/montecarlo_case.go b/internal/exec/runtime/montecarlo_case.go new file mode 100644 index 0000000000..e3a4bc30d8 --- /dev/null +++ b/internal/exec/runtime/montecarlo_case.go @@ -0,0 +1,536 @@ +package runtime + +import ( + "errors" + "fmt" + "maps" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// A Simulation::MonteCarlo case is an analysis of repeated runs, each on a fresh subject +// reading `observed`; runs, mean, deviation and outOfSpec are bound over the sample. + +// MonteCarloCaseFQN names the library analysis of repeated runs. +const MonteCarloCaseFQN = "Simulation::MonteCarlo" + +// The features Simulation::MonteCarlo declares, by the name each is declared under; +// the outputs are exported for the comparison of a case's statistics with a tool's. +const ( + monteCarloObserved = "observed" + MonteCarloRunsOutput = "runs" + MonteCarloMeanOutput = "mean" + MonteCarloDeviationOutput = "deviation" + MonteCarloOutOfSpecOutput = "outOfSpec" +) + +// ErrNotMonteCarlo reports repeating a case that is no Simulation::MonteCarlo analysis. +var ErrNotMonteCarlo = errors.New("not a Simulation::MonteCarlo analysis") + +// ErrMonteCarloObserved reports a run whose `observed` the statistics cannot be taken of. +var ErrMonteCarloObserved = errors.New("invalid observation") + +// IsMonteCarloCase reports whether sym declares an analysis case specializing +// Simulation::MonteCarlo, matched by identity so a case merely named alike is none. +func (ctx *Context) IsMonteCarloCase(sym *symbols.Symbol) bool { + return sym != nil && RequireAnalysis(sym) == nil && ctx.specializesLibraryType(sym, MonteCarloCaseFQN) +} + +// RequireMonteCarloCase reports ErrNotMonteCarlo for a symbol that is not an +// analysis case specializing Simulation::MonteCarlo, describing what it is instead. +func (ctx *Context) RequireMonteCarloCase(sym *symbols.Symbol) error { + if err := ctx.RequireAnalysisCase(sym); err != nil { + return err + } + if !ctx.IsMonteCarloCase(sym) { + return fmt.Errorf("%w: %s specializes no %s, so its runs have no statistics to bind", + ErrNotMonteCarlo, ctx.qualifiedSymbolName(sym), MonteCarloCaseFQN) + } + return nil +} + +// MonteCarloRun is one run of a Simulation::MonteCarlo case: its steps performed +// and `observed` read, its statistics unbound until ConcludeMonteCarlo binds the sample's. +type MonteCarloRun struct { + ctx *Context + sym *symbols.Symbol + scope *symbols.Scope + run *calcRun + log *evaluationLog + // results are the returns ending the case's steps, evaluated by ConcludeMonteCarlo. + results []lower.Statement + // left marks the checks this run alone left undecided, for the sample to decide. + left []bool + + // Case is the qualified name of the case that ran; Subject the object it ran on. + Case string + Subject *Instance + + // Number is the run's number in its Monte Carlo, the row it was drawn as; a + // sample names a run by it, whatever earlier runs failed. + Number int64 + + // Observed is the value the run's `observed` came to, null when the run left it unbound. + Observed Value + + // Inputs are the values the run bound the case's input parameters to, in + // declaration order. Outputs are every declared output of this iteration + // as the iteration established it — the sample's statistics excluded — with + // the observed feature appended when it declares no output of its own. + // Unread holds the outputs that could not be read, by name. + Inputs []InputBinding + Outputs []CalcOutputValue + Unread map[string]error + + // Verdicts are the case's checks over this run: on its own until ConcludeMonteCarlo + // settles them over the sample, which keeps the checks that are the sample's alone. + Verdicts []AnalysisVerdict +} + +// Context is the context the run was made in, which its values are read through. +func (r *MonteCarloRun) Context() *Context { return r.ctx } + +// ObserveMonteCarlo makes one run of a Simulation::MonteCarlo case as RunAnalysis would, +// reading `observed` and the checks in place of the outputs, which ConcludeMonteCarlo evaluates. +func (ctx *Context) ObserveMonteCarlo(sym *symbols.Symbol, args AnalysisArgs, scope *symbols.Scope, self *Instance) (*MonteCarloRun, error) { + defer ctx.beginRun()() + + if err := ctx.RequireMonteCarloCase(sym); err != nil { + return nil, err + } + if err := ctx.checkCalcTyping(sym); err != nil { + return nil, err + } + shape, err := ctx.calcShapeOf(sym) + if err != nil { + return nil, err + } + asUsage := args.Subject == nil && len(args.Positional) == 0 && len(args.Named) == 0 && isCalcUsageSymbol(sym) + var calcArgs calcArgs + if !asUsage { + if calcArgs, err = shape.analysisArgs(args); err != nil { + return nil, err + } + } + reader := NewEvalContextIn(ctx, scope, self) + log := ctx.beginEvaluationLog(sym) + defer ctx.endEvaluationLog(log) + + var run *calcRun + if asUsage { + run, err = ctx.calcUsageObservation(reader, sym) + } else { + run, err = ctx.analysisRun(shape, reader, calcArgs, true) + } + if err != nil { + return nil, err + } + observed, err := ctx.monteCarloObserved(run) + if err != nil { + return nil, err + } + // The run's environment outlives the invocation: the conclusion reads it once the sample is in. + run = run.detached() + _, results := shape.observationSteps() + r := &MonteCarloRun{ + ctx: ctx, sym: sym, scope: scope, run: run, log: log, results: results, + Case: shape.Name, + Subject: run.boundSubject(ctx), + Observed: observed, + Inputs: run.inputs(), + } + r.Verdicts = r.checks() + r.Outputs, r.Unread = r.iterationOutputs() + r.left = make([]bool, len(r.Verdicts)) + for i, v := range r.Verdicts { + r.left[i] = v.Status == VerdictUndecided + } + return r, nil +} + +// iterationOutputs are the values this run's declared outputs came to, the +// observed feature appended when it is not among them. The outputs that are +// the sample's — runs, mean, deviation and outOfSpec — are left out whatever +// their binding; any other output that cannot be read is in the error map +// returned beside them. The whole read runs in a probe: nothing it evaluates +// is kept, drawn or written in the run or its context. +func (r *MonteCarloRun) iterationOutputs() ([]CalcOutputValue, map[string]error) { + ctx := r.ctx + defer ctx.beginRun()() + defer ctx.beginProbe()() + kept := r.run.outputs + r.run.outputs = maps.Clone(kept) + defer func() { r.run.outputs = kept }() + stats := map[string]bool{} + for _, feature := range []string{ + MonteCarloRunsOutput, MonteCarloMeanOutput, + MonteCarloDeviationOutput, MonteCarloOutOfSpecOutput, + } { + if name, ok := ctx.monteCarloMember(r.run.shape, feature); ok { + stats[name] = true + } + } + var outputs []CalcOutputValue + unread := map[string]error{} + for _, out := range r.run.shape.Outputs { + if out.Name == "" || stats[out.Name] { + continue + } + value, err := r.run.output(ctx, out.Name) + if err != nil { + var u *UnassignedOutputError + // Reading an unbound statistic through the binding leaves the + // output to the conclusion as much as the statistic itself. + if !(errors.As(err, &u) && stats[u.Output]) { + unread[out.Name] = err + } + continue + } + outputs = append(outputs, CalcOutputValue{Name: out.Name, Value: value}) + } + if name, ok := ctx.monteCarloMember(r.run.shape, monteCarloObserved); ok { + seen := false + for _, out := range outputs { + if out.Name == name { + seen = true + break + } + } + if !seen { + outputs = append(outputs, CalcOutputValue{Name: name, Value: r.Observed}) + } + } + return outputs, unread +} + +// checks decides the case's checks over the run as it stands. The outputs they read are +// evaluated for them alone, not kept: the conclusion evaluates each once, over the sample. +func (r *MonteCarloRun) checks() []AnalysisVerdict { + kept := r.run.outputs + r.run.outputs = maps.Clone(kept) + defer func() { r.run.outputs = kept }() + return r.ctx.analysisVerdicts(r.run, r.sym, r.scope) +} + +// calcUsageObservation runs a case usage as calcUsageRun does, its results deferred. +func (ctx *Context) calcUsageObservation(reader *EvalContext, sym *symbols.Symbol) (*calcRun, error) { + start, run, err := ctx.beginCalcUsage(reader, sym) + if err != nil || run != nil { + return run, err + } + start.deferResults = true + return ctx.finishCalcUsage(start) +} + +// monteCarloObserved reads the run's `observed`: the feature the library declares, +// under whichever name the case redefines it. +func (ctx *Context) monteCarloObserved(run *calcRun) (Value, error) { + name, ok := ctx.monteCarloMember(run.shape, monteCarloObserved) + if !ok { + return Value{}, fmt.Errorf("%w: %s declares no %s::%s to observe", + ErrMonteCarloObserved, run.shape.Label, MonteCarloCaseFQN, monteCarloObserved) + } + if value, ok := run.env.lookup(name); ok { + return value, nil + } + return Value{Kind: ValNull}, nil +} + +// monteCarloMember is the name the case's run binds the library feature under. +func (ctx *Context) monteCarloMember(shape *calcShape, feature string) (string, bool) { + if ctx.model.resolver == nil || ctx.model.resolver.Index() == nil { + return "", false + } + for _, sym := range ctx.model.resolver.Index().LookupQualified(MonteCarloCaseFQN + "::" + feature) { + if name, ok := shape.memberName(ctx, sym); ok { + return name, true + } + } + return "", false +} + +// MonteCarloStatistics are the statistics of a sample of runs, as +// Simulation::MonteCarlo declares them. +type MonteCarloStatistics struct { + // Runs is the number of runs observed. + Runs int64 + // Mean is the arithmetic mean of the observations. + Mean float64 + // Deviation is their sample standard deviation, which under two runs there is none of. + Deviation float64 + // OutOfSpec is the number of runs a check of the runs did not hold in, counted by + // ConcludeMonteCarlo once the runs' checks are settled; MonteCarloSample leaves it 0. + OutOfSpec int64 + // Unit is the unit quantity observations were taken in, which Mean and Deviation + // are expressed in; nil when the observations were bare numbers. + Unit *Unit +} + +// statistic is Mean or Deviation as a value: a Real, or a quantity in the sample's unit. +func (s MonteCarloStatistics) statistic(x float64) Value { + if s.Unit == nil { + return constValue(drawnReal(x)) + } + return NewQuantityValue(&Quantity{Num: drawnReal(x), Unit: s.Unit.Clone()}) +} + +// MonteCarloSample is the statistics of runs, each of which observed a number or a +// quantity, the quantities expressed in the first run's unit; one observing none, +// no number, or a quantity of another dimension refuses the sample, named by its Number. +func MonteCarloSample(runs []*MonteCarloRun) (MonteCarloStatistics, error) { + if len(runs) == 0 { + return MonteCarloStatistics{}, fmt.Errorf("%w: no run observed anything", ErrMonteCarloObserved) + } + numbers := make([]semantics.Value, 0, len(runs)) + var unit *Unit + first := runs[0] + for i, run := range runs { + observed := soleElement(run.Observed) + number, ok := MagnitudeValue(observed) + if !ok { + return MonteCarloStatistics{}, fmt.Errorf("%w: run %d of %s observed %s, not a number", + ErrMonteCarloObserved, run.Number, run.Case, describeObserved(observed)) + } + q := observed.Quantity() + switch { + case i == 0 && q != nil: + u := q.Unit.Clone() + unit = &u + case (q == nil) != (unit == nil): + return MonteCarloStatistics{}, fmt.Errorf("%w: run %d of %s observed %s where run %d observed %s; a sample is of numbers or of quantities, not both", + ErrMonteCarloObserved, run.Number, run.Case, describeObserved(observed), first.Number, describeObserved(soleElement(first.Observed))) + case q != nil: + magnitude, err := q.ConvertTo(*unit) + if err != nil { + return MonteCarloStatistics{}, fmt.Errorf("%w: run %d of %s: %w", ErrMonteCarloObserved, run.Number, run.Case, err) + } + number = drawnReal(magnitude) + } + numbers = append(numbers, number) + } + d := Distribute(numbers) + return MonteCarloStatistics{Runs: int64(d.Count), Mean: d.Mean, Deviation: d.Deviation, Unit: unit}, nil +} + +// describeObserved words an observation as the sample's refusal names it. +func describeObserved(value Value) string { + if value.Kind == ValNull { + return "no value" + } + return FormatValue(value) +} + +// ConcludeMonteCarlo settles every run's checks over the sample's runs, mean and deviation, +// counts outOfSpec over them, and concludes the case in the last run with the sample's checks. +func ConcludeMonteCarlo(runs []*MonteCarloRun, stats MonteCarloStatistics) (AnalysisResult, error) { + if len(runs) == 0 { + return AnalysisResult{}, fmt.Errorf("%w: no run to conclude", ErrMonteCarloObserved) + } + for _, r := range runs { + r.settle(stats) + } + sample := sampleChecks(runs) + for _, r := range runs { + r.Verdicts = verdictsOutside(r.Verdicts, sample) + if r.failsOwn() { + stats.OutOfSpec++ + } + } + return runs[len(runs)-1].conclude(stats, sample) +} + +// settle binds the statistics of the observations in the run and decides over them the +// checks the run left to the sample; a statistic that cannot be bound leaves them undecided. +func (r *MonteCarloRun) settle(stats MonteCarloStatistics) { + ctx := r.ctx + defer ctx.beginRun()() + r.log.enclosing = ctx.evaluations + ctx.evaluations = r.log + defer ctx.endEvaluationLog(r.log) + + var settled []AnalysisVerdict + if err := r.bindObservationStatistics(stats); err != nil { + settled = ctx.undecidedVerdicts(r.sym, r.scope, err) + } else { + settled = r.checks() + } + if len(settled) != len(r.Verdicts) { + return + } + for i, v := range settled { + if r.left[i] { + r.Verdicts[i] = v + } + } +} + +// bindObservationStatistics binds runs, mean and deviation (empty under two runs). +func (r *MonteCarloRun) bindObservationStatistics(stats MonteCarloStatistics) error { + deviation := Value{Kind: ValNull} + if stats.Runs >= 2 { + deviation = stats.statistic(stats.Deviation) + } + bound := []struct { + feature string + value Value + }{ + {MonteCarloRunsOutput, constValue(drawnInt(stats.Runs))}, + {MonteCarloMeanOutput, stats.statistic(stats.Mean)}, + {MonteCarloDeviationOutput, deviation}, + } + for _, b := range bound { + if err := r.bindStatistic(b.feature, b.value); err != nil { + return err + } + } + return nil +} + +// sampleChecks marks the checks that are the sample's: every run left them and settled +// them alike. A check some run decided, or the runs settled apart, is a check of the runs. +func sampleChecks(runs []*MonteCarloRun) []bool { + last := runs[len(runs)-1] + sample := make([]bool, len(last.Verdicts)) + for i := range sample { + sample[i] = true + for _, r := range runs { + if len(r.Verdicts) != len(sample) || !r.left[i] || !sameVerdict(r.Verdicts[i], last.Verdicts[i]) { + sample[i] = false + break + } + } + } + return sample +} + +func sameVerdict(a, b AnalysisVerdict) bool { + return a.Status == b.Status && a.Detail == b.Detail +} + +// failsOwn reports a run some check of the runs did not hold in. +func (r *MonteCarloRun) failsOwn() bool { + for _, v := range r.Verdicts { + if v.Status == VerdictNotSatisfied { + return true + } + } + return false +} + +// verdictsOutside keeps the verdicts of the checks not marked, in order. +func verdictsOutside(verdicts []AnalysisVerdict, marked []bool) []AnalysisVerdict { + kept := make([]AnalysisVerdict, 0, len(verdicts)) + for i, v := range verdicts { + if i >= len(marked) || !marked[i] { + kept = append(kept, v) + } + } + return kept +} + +// verdictsWithin keeps the verdicts of the checks marked, in order. +func verdictsWithin(verdicts []AnalysisVerdict, marked []bool) []AnalysisVerdict { + kept := make([]AnalysisVerdict, 0, len(verdicts)) + for i, v := range verdicts { + if i < len(marked) && marked[i] { + kept = append(kept, v) + } + } + return kept +} + +// conclude binds outOfSpec over the settled run, then reports the case's outputs and +// the verdicts of the sample's checks, judged over the results and all four statistics. +func (r *MonteCarloRun) conclude(stats MonteCarloStatistics, sample []bool) (AnalysisResult, error) { + ctx := r.ctx + defer ctx.beginRun()() + r.log.enclosing = ctx.evaluations + ctx.evaluations = r.log + defer ctx.endEvaluationLog(r.log) + + result := AnalysisResult{Case: r.Case, Subject: r.Subject} + if err := r.bindObservationStatistics(stats); err != nil { + return result, err + } + if err := r.bindStatistic(MonteCarloOutOfSpecOutput, constValue(drawnInt(stats.OutOfSpec))); err != nil { + return result, err + } + if err := r.returnResults(); err != nil { + result.Verdicts = verdictsWithin(ctx.undecidedVerdicts(r.sym, r.scope, err), sample) + result.Evaluations = r.log.evaluations(Value{}, false) + return result, err + } + outputs, err := r.run.outputValues(ctx) + result.Outputs = outputs + if err != nil { + result.Verdicts = verdictsWithin(ctx.undecidedVerdicts(r.sym, r.scope, err), sample) + result.Evaluations = r.log.evaluations(Value{}, false) + return result, err + } + result.Verdicts = verdictsWithin(ctx.analysisVerdicts(r.run, r.sym, r.scope), sample) + result.Evaluations = r.log.evaluations(r.run.caseResult(r.run.bindingsFrame(ctx).vars)) + return result, nil +} + +// bindStatistic gives the library's output feature its value, under the name the +// case binds it, checked against the declaration as a binding's value is. The run's +// frame holds it too, so the results read it as they read any value the body bound. +func (r *MonteCarloRun) bindStatistic(feature string, value Value) error { + name, ok := r.ctx.monteCarloMember(r.run.shape, feature) + if !ok { + return fmt.Errorf("%w: %s declares no %s::%s to bind", + ErrMonteCarloObserved, r.run.shape.Label, MonteCarloCaseFQN, feature) + } + out, ok := r.run.shape.output(name) + if !ok { + return fmt.Errorf("%w: %s of %s is no output", ErrMonteCarloObserved, name, r.run.shape.Label) + } + if err := out.Decl.check(r.ctx, &value, func() string { + return fmt.Sprintf("%s: output %s", r.run.shape.Label, name) + }); err != nil { + return err + } + r.run.outputs[name] = value + r.run.env.set(name, value) + return nil +} + +// returnResults runs the deferred results over the run's frame with the statistics +// bound, as the body's end would: a `return` on any path, nested or not, yields the +// result, read over the steps the run performed. +func (r *MonteCarloRun) returnResults() error { + ctx, run := r.ctx, r.run + host := &calcStmtHost{ctx: ctx, shape: run.shape, self: run.self} + var enclosing []frame + if run.outer != nil { + enclosing = run.shape.bodyEnclosing(run.outer.enclosingRun(run.shape)) + } + engine := newStmtEngineIn(ctx, host, run.env, enclosing) + defer engine.finish() + host.readPerformance(engine, run.perf) + result, returned, err := runCalcSteps(engine, host, r.results) + if err != nil { + return calcFrame(run.shape.Kind, run.shape.Name, fmt.Errorf("result: %w", err)) + } + if !returned { + return nil + } + if out := run.shape.resultOutput(); out != nil && out.Name != "" { + run.outputs[out.Name] = result + } else if _, anonymous := run.shape.anonymousResult(); anonymous { + run.outputs[resultOutputName] = result + } + run.result, run.returned = result, true + return nil +} + +// monteCarloUnconcluded says why one run of a Monte Carlo case reads a value that +// is unbound: its statistics are of repeated runs, which a single run has none of. +func (ctx *Context) monteCarloUnconcluded(sym *symbols.Symbol, err error) error { + if !errors.Is(err, ErrMultiplicityViolation) || !ctx.IsMonteCarloCase(sym) { + return err + } + return fmt.Errorf("%w; the statistics of a %s case are of repeated runs, which one run leaves unbound: ask for them as -runs ", err, MonteCarloCaseFQN) +} diff --git a/internal/exec/runtime/montecarlo_test.go b/internal/exec/runtime/montecarlo_test.go new file mode 100644 index 0000000000..cf02fd99f9 --- /dev/null +++ b/internal/exec/runtime/montecarlo_test.go @@ -0,0 +1,279 @@ +package runtime + +import ( + "errors" + "fmt" + "math" + "reflect" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// The seed of each run is a function of the Monte Carlo's seed and the run's +// number alone: the same pair derives the same seed, and the runs of one Monte +// Carlo — and of neighbouring seeds — all differ. +func TestRunSeedIsDistinctPerRunAndReproducible(t *testing.T) { + seen := make(map[uint64]string) + for _, seed := range []uint64{0, 1, 2, math.MaxUint64} { + for i := int64(1); i <= 1000; i++ { + run := RunSeed(seed, i) + if run != RunSeed(seed, i) { + t.Fatalf("RunSeed(%d, %d) differs between calls", seed, i) + } + key := seen[run] + if key != "" { + t.Fatalf("RunSeed(%d, %d) = %d, which %s also derives", seed, i, run, key) + } + seen[run] = fmt.Sprintf("seed %d run %d", seed, i) + } + } +} + +// A Monte Carlo plan makes one row per run, numbered from 1 in RunParam, and +// nothing above the run limit. +func TestMonteCarloPlanNumbersItsRuns(t *testing.T) { + ctx, _ := analysisFixture(t, sweepRobustnessModel) + rows, err := ctx.sweepBindings(MonteCarloPlan(3, 9), 10) + if err != nil { + t.Fatal(err) + } + var numbers []int64 + for _, row := range rows { + n, ok := RunNumber(row) + if !ok || len(row) != 1 { + t.Fatalf("row %v numbers no run", row) + } + numbers = append(numbers, n) + } + if want := []int64{1, 2, 3}; !reflect.DeepEqual(numbers, want) { + t.Errorf("runs numbered %v, want %v", numbers, want) + } + if _, err := ctx.sweepBindings(MonteCarloPlan(11, 9), 10); err == nil { + t.Error("11 runs under a limit of 10 were planned") + } + if _, ok := RunNumber(nil); ok { + t.Error("no bindings number a run") + } +} + +// reals and ints are values for Distribute. +func reals(xs ...float64) []semantics.Value { + vs := make([]semantics.Value, len(xs)) + for i, x := range xs { + vs[i] = drawnReal(x) + } + return vs +} + +func ints(ns ...int64) []semantics.Value { + vs := make([]semantics.Value, len(ns)) + for i, n := range ns { + vs[i] = drawnInt(n) + } + return vs +} + +// Distribute reports the extremes, the mean, and nearest-rank percentiles, each +// a value some run took, and bins the range into equal widths. +func TestDistributeSummarisesTheRuns(t *testing.T) { + d := Distribute(reals(5, 1, 4, 2, 3, 10, 6, 7, 8, 9)) + if d.Count != 10 || d.Integral || d.Min != drawnReal(1) || d.Max != drawnReal(10) || d.Mean != 5.5 { + t.Errorf("count %d integral %v min %v max %v mean %v", d.Count, d.Integral, d.Min, d.Max, d.Mean) + } + if d.P50 != drawnReal(5) || d.P90 != drawnReal(9) { + t.Errorf("p50 %v p90 %v, want the 5th and the 9th smallest", d.P50, d.P90) + } + if len(d.Histogram) != HistogramBins { + t.Fatalf("%d bins, want %d", len(d.Histogram), HistogramBins) + } + total := 0 + for i, bin := range d.Histogram { + total += bin.Count + if i > 0 && bin.Lo != d.Histogram[i-1].Hi { + t.Errorf("bin %d starts at %v, the one before ends at %v", i, bin.Lo, d.Histogram[i-1].Hi) + } + } + if total != 10 || d.Histogram[0].Lo != drawnReal(1) || d.Histogram[HistogramBins-1].Hi != drawnReal(10) { + t.Errorf("bins %v cover %d run(s), want all 10 from 1 to 10", d.Histogram, total) + } + if Distribute(nil) != nil { + t.Error("no values have a distribution") + } +} + +// Integers bin by whole widths, one number per bin when the range allows and +// both ends of a bin included; a single value is one bin; a Real among the +// Integers makes the distribution one over Reals. +func TestDistributeBinsIntegersWhole(t *testing.T) { + d := Distribute(ints(1, 2, 2, 3, 6)) + if !d.Integral || d.Min != drawnInt(1) || d.Max != drawnInt(6) || d.P50 != drawnInt(2) || d.P90 != drawnInt(6) || d.Mean != 2.8 { + t.Errorf("integral %v min %v max %v p50 %v p90 %v mean %v", d.Integral, d.Min, d.Max, d.P50, d.P90, d.Mean) + } + want := []HistogramBin{ + {drawnInt(1), drawnInt(1), 1}, {drawnInt(2), drawnInt(2), 2}, {drawnInt(3), drawnInt(3), 1}, + {drawnInt(4), drawnInt(4), 0}, {drawnInt(5), drawnInt(5), 0}, {drawnInt(6), drawnInt(6), 1}, + } + if !reflect.DeepEqual(d.Histogram, want) { + t.Errorf("bins %v, want %v", d.Histogram, want) + } + wide := Distribute(ints(0, 100)) + if len(wide.Histogram) != 8 || wide.Histogram[0] != (HistogramBin{drawnInt(0), drawnInt(12), 1}) || wide.Histogram[7] != (HistogramBin{drawnInt(91), drawnInt(100), 1}) { + t.Errorf("bins over 0..100: %v", wide.Histogram) + } + one := Distribute(reals(4, 4, 4)) + if len(one.Histogram) != 1 || one.Histogram[0] != (HistogramBin{drawnReal(4), drawnReal(4), 3}) { + t.Errorf("bins over one value: %v", one.Histogram) + } + mixed := Distribute([]semantics.Value{drawnInt(1), drawnReal(2.5)}) + if mixed.Integral || mixed.Min != drawnReal(1) || mixed.Max != drawnReal(2.5) { + t.Errorf("a Real among Integers: integral %v min %v max %v", mixed.Integral, mixed.Min, mixed.Max) + } +} + +// Reals at the edges of the representable range are summarised as any others: the +// mean of finite observations is finite whatever their sum would be, and a span too +// narrow to divide into eight widths bins each distinct value on its own. +func TestDistributeRealsAtTheEdgesOfTheRange(t *testing.T) { + huge := Distribute(reals(1e308, 1e308)) + if huge.Mean != 1e308 { + t.Errorf("mean %v, want 1e308: the sum overflows, the mean does not", huge.Mean) + } + if mixed := Distribute(reals(math.MaxFloat64, -math.MaxFloat64, 1e308, -1e308)); mixed.Mean != 0 { + t.Errorf("mean %v, want 0 over values of both signs", mixed.Mean) + } + if d := Distribute(reals(0.5, 0.25, 0.25)); d.Mean != 1.0/3 { + t.Errorf("mean %v, want 1/3 rounded once", d.Mean) + } + inf := Distribute(reals(1, math.Inf(1))) + if !math.IsInf(inf.Mean, 1) { + t.Errorf("mean %v, want +Inf where an observation is", inf.Mean) + } + tiny := math.SmallestNonzeroFloat64 + narrow := Distribute(reals(0, tiny, tiny, 2*tiny)) + want := []HistogramBin{{drawnReal(0), drawnReal(0), 1}, {drawnReal(tiny), drawnReal(tiny), 2}, {drawnReal(2 * tiny), drawnReal(2 * tiny), 1}} + if !reflect.DeepEqual(narrow.Histogram, want) { + t.Errorf("bins %v, want one a distinct value %v", narrow.Histogram, want) + } + if narrow.Min != drawnReal(0) || narrow.Max != drawnReal(2*tiny) || narrow.Mean != tiny { + t.Errorf("min %v mean %v max %v", narrow.Min, narrow.Mean, narrow.Max) + } + wide := Distribute(reals(-math.MaxFloat64, math.MaxFloat64, 0)) + if len(wide.Histogram) != 1 || wide.Histogram[0].Count != 3 || wide.Mean != 0 { + t.Errorf("over the whole Real range: bins %v mean %v, want one bin and a mean of 0", wide.Histogram, wide.Mean) + } + if far := Distribute(reals(9e307, 1e308)); math.Abs(far.Deviation-5e306*math.Sqrt2) > 1e292 { + t.Errorf("deviation %v, want %v: the squared deviations overflow, the deviation does not", far.Deviation, 5e306*math.Sqrt2) + } + if wide.Deviation != math.MaxFloat64 { + t.Errorf("deviation %v over the whole Real range, want %v", wide.Deviation, math.MaxFloat64) + } + if off := Distribute(reals(-math.MaxFloat64, math.MaxFloat64, math.MaxFloat64, math.MaxFloat64)); off.Deviation != math.MaxFloat64 { + t.Errorf("deviation %v about a mean of max/2, want %v: the distance from the mean overflows, the deviation does not", off.Deviation, math.MaxFloat64) + } + if huge.Deviation != 0 || !math.IsInf(inf.Deviation, 1) { + t.Errorf("deviation %v of equal values, want 0; %v where an observation is infinite, want +Inf", huge.Deviation, inf.Deviation) + } +} + +// Integers beyond 2^53, which a Real cannot tell apart, stay distinct in every +// statistic but the mean — the deviation is of the exact Integers about their exact +// mean, rounding once at the root — and the whole Integer range bins without overflowing. +func TestDistributeKeepsLargeIntegersExact(t *testing.T) { + const big = int64(1) << 53 + d := Distribute(ints(big+1, big)) + if d.Min != drawnInt(big) || d.Max != drawnInt(big+1) || d.P50 != drawnInt(big) || d.P90 != drawnInt(big+1) { + t.Errorf("min %v max %v p50 %v p90 %v, want %d and %d apart", d.Min, d.Max, d.P50, d.P90, big, big+1) + } + if d.Mean != float64(big) { + t.Errorf("mean %v, want the nearest Real to %d.5", d.Mean, big) + } + if d.Deviation != math.Sqrt(0.5) { + t.Errorf("deviation %v, want %v: the sample deviation of two Integers one apart", d.Deviation, math.Sqrt(0.5)) + } + if spread, want := Distribute(ints(math.MaxInt64, math.MinInt64)), math.Sqrt(0.5)*math.Ldexp(1, 64); math.Abs(spread.Deviation-want) > want*1e-15 { + t.Errorf("deviation over the Integer extremes %v, want %v", spread.Deviation, want) + } + want := []HistogramBin{{drawnInt(big), drawnInt(big), 1}, {drawnInt(big + 1), drawnInt(big + 1), 1}} + if !reflect.DeepEqual(d.Histogram, want) { + t.Errorf("bins %v, want %v", d.Histogram, want) + } + whole := Distribute(ints(math.MaxInt64, math.MinInt64, 0)) + if whole.Mean != -1.0/3 || whole.Min != drawnInt(math.MinInt64) || whole.Max != drawnInt(math.MaxInt64) { + t.Errorf("over the whole Integer range: min %v mean %v max %v, want a mean of -1/3", whole.Min, whole.Mean, whole.Max) + } + if len(whole.Histogram) != 1 || whole.Histogram[0].Count != 3 { + t.Errorf("bins over the whole Integer range: %v", whole.Histogram) + } + extremes := Distribute(ints(math.MaxInt64, math.MinInt64+1)) + if n := len(extremes.Histogram); n != HistogramBins || extremes.Histogram[0].Lo != drawnInt(math.MinInt64+1) || extremes.Histogram[n-1].Hi != drawnInt(math.MaxInt64) { + t.Errorf("bins over all but one Integer: %v", extremes.Histogram) + } + if extremes.Mean != 0 { + t.Errorf("mean %v, want 0: the sum is exact before it rounds", extremes.Mean) + } +} + +// A sample is of numbers or of quantities: quantities are expressed in the first +// run's unit and the statistics carry it; a sample mixing the two, or quantities of +// different dimensions, or observing no number at all, is refused naming the run. +func TestMonteCarloSampleTakesQuantitiesInTheFirstRunsUnit(t *testing.T) { + metre := semantics.UnitFactor{Unit: &symbols.Symbol{Name: "m"}, Exponent: 1} + second := semantics.UnitFactor{Unit: &symbols.Symbol{Name: "s"}, Exponent: 1} + quantity := func(num semantics.Value, text string, scale float64, factor semantics.UnitFactor) Value { + return NewQuantityValue(&Quantity{Num: num, Unit: Unit{Text: text, Term: semantics.UnitTerm{ + Scale: semantics.UnitScale(scale), Factors: []semantics.UnitFactor{factor}, + }}}) + } + runs := func(observed ...Value) []*MonteCarloRun { + made := make([]*MonteCarloRun, len(observed)) + for i, v := range observed { + made[i] = &MonteCarloRun{Case: "Mc", Number: int64(i + 1), Observed: v} + } + return made + } + km := quantity(semantics.Value{Kind: semantics.ValInt, Int: 1}, "SI::km", 1000, metre) + m := quantity(semantics.Value{Kind: semantics.ValReal, Real: 3000}, "SI::m", 1, metre) + s := quantity(semantics.Value{Kind: semantics.ValInt, Int: 2}, "SI::s", 1, second) + + single := NewSequence() + single.Append(km) + stats, err := MonteCarloSample(runs(km, m, NewSequenceValue(single))) + if err != nil { + t.Fatalf("MonteCarloSample: %v", err) + } + if stats.Runs != 3 || stats.Mean != 5.0/3 || stats.Unit == nil || stats.Unit.Text != "SI::km" { + t.Errorf("stats = %+v; want 3 runs with mean 5/3 in SI::km", stats) + } + if got := stats.statistic(stats.Mean).Quantity(); got == nil || got.Num.Real != 5.0/3 || got.Unit.Text != "SI::km" { + t.Errorf("Mean = %s; want 1.6666666666666667 [SI::km]", FormatValue(stats.statistic(stats.Mean))) + } + + stats, err = MonteCarloSample(runs(intOf(1), realOf(2))) + if err != nil || stats.Unit != nil || stats.Mean != 1.5 { + t.Errorf("stats, err = %+v, %v; want a unitless mean of 1.5", stats, err) + } + + for _, refused := range []struct { + runs []*MonteCarloRun + names []string + }{ + {runs(km, s), []string{"run 2 of Mc", "SI::s", "SI::km"}}, + {runs(km, realOf(2)), []string{"run 2 of Mc", "observed 2.0", "1 [SI::km]", "numbers or of quantities"}}, + {runs(realOf(2), km), []string{"run 2 of Mc", "observed 1 [SI::km]", "run 1 observed 2.0"}}, + {runs(km, Value{Kind: ValNull}), []string{"run 2 of Mc", "no value", "not a number"}}, + {runs(NewStringValue("x")), []string{"run 1 of Mc", `"x"`, "not a number"}}, + } { + _, err := MonteCarloSample(refused.runs) + if !errors.Is(err, ErrMonteCarloObserved) { + t.Fatalf("MonteCarloSample = %v; want an ErrMonteCarloObserved", err) + } + for _, name := range refused.names { + if !strings.Contains(err.Error(), name) { + t.Errorf("err = %v; want it to name %q", err, name) + } + } + } +} diff --git a/internal/core/runtime/negation_test.go b/internal/exec/runtime/negation_test.go similarity index 100% rename from internal/core/runtime/negation_test.go rename to internal/exec/runtime/negation_test.go diff --git a/internal/core/runtime/object_path.go b/internal/exec/runtime/object_path.go similarity index 99% rename from internal/core/runtime/object_path.go rename to internal/exec/runtime/object_path.go index 8f117edc5b..c921540a98 100644 --- a/internal/core/runtime/object_path.go +++ b/internal/exec/runtime/object_path.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // A materialization path names an object independently of its run's numbering: a diff --git a/internal/core/runtime/object_path_test.go b/internal/exec/runtime/object_path_test.go similarity index 100% rename from internal/core/runtime/object_path_test.go rename to internal/exec/runtime/object_path_test.go diff --git a/internal/exec/runtime/occurrence_terminate.go b/internal/exec/runtime/occurrence_terminate.go new file mode 100644 index 0000000000..0eed943acc --- /dev/null +++ b/internal/exec/runtime/occurrence_terminate.go @@ -0,0 +1,199 @@ +package runtime + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" +) + +// terminateOccurrence ends the occurrence s names (SysML v2 §7.17.10): its lifetime +// and those of the objects it holds end now, with every behavior they perform. The +// body unwinds where its own executor's performance is among them. +func (e *performances) terminateOccurrence(engine *stmtEngine, s lower.Effect) error { + text := e.ctx.bindingExprText(s.TargetExpr, s.Scope) + target, err := e.terminateTargetOf(engine, s, text) + if err != nil { + return err + } + ended, err := e.ctx.endOccurrence(target) + if err != nil { + return fmt.Errorf("%w: 'terminate %s': %w", ErrTerminateOccurrence, text, err) + } + if e.flow.endsWith(ended) { + return &terminated{object: target, ended: ended} + } + return nil +} + +// terminateTargetOf evaluates the occurrence expression of s, spelled text, in the +// frames of the statement stating it; a value that is no object held here is refused. +func (e *performances) terminateTargetOf(engine *stmtEngine, s lower.Effect, text string) (*Instance, error) { + value, err := engine.evalIn(s.Scope).Eval(s.TargetExpr) + if err != nil { + return nil, fmt.Errorf("%w: 'terminate %s': %w", ErrTerminateTarget, text, err) + } + id, ok := value.Object() + if !ok { + return nil, fmt.Errorf("%w: 'terminate %s' names a %s: %w", + ErrTerminateOccurrence, text, value.Kind, ErrNotAnOccurrence) + } + inst, found := e.ctx.Instance(id) + if !found { + return nil, fmt.Errorf("%w: 'terminate %s' names object #%d, which this context does not hold: %w", + ErrTerminateOccurrence, text, id, ErrOccurrenceLifetime) + } + return inst, nil +} + +// endOccurrence ends inst's lifetime now, and with it the objects it holds and the +// behaviors any of them performs; it returns the identities ended. An occurrence +// that is not ongoing is refused. +func (ctx *Context) endOccurrence(inst *Instance) (map[int64]bool, error) { + if err := ctx.checkLiving(inst); err != nil { + return nil, err + } + if tr := ctx.trace; tr != nil { + tr.RecordOccurrenceTerminated(symbolText(inst.Type), inst.ID) + } + ended := make(map[int64]bool) + // One boundary ends the whole and its portions: none outlives its whole. + at := ctx.newActivation() + for _, portion := range ctx.portionsOf(inst) { + if l, ok := ctx.lives[portion.ID]; ok && l.ended != 0 { + continue + } + ended[portion.ID] = true + ctx.endLifeAt(portion, at) + } + ctx.endBehaviorsWith(ended) + return ended, nil +} + +// endLifeAt records inst's lifetime ending at the activation at, undone with a probe. +func (ctx *Context) endLifeAt(inst *Instance, at int64) { + prior := ctx.lives[inst.ID] + ctx.lives[inst.ID] = life{reached: prior.reached, began: prior.began, ended: at} + ctx.noteProbeUndo(func() { ctx.lives[inst.ID] = prior }) + ctx.livesChanged() +} + +// endBehaviorsWith ends, in the order the behaviors were begun, every behavior whose +// performance ends with the objects ended and that no call under way is running; +// one under way ends where its call catches the unwinding. +func (ctx *Context) endBehaviorsWith(ended map[int64]bool) { + for _, b := range ctx.objectBehaviors { + if b.completed() { + continue + } + switch { + case b.Action != nil && b.Action.endsWith(ended) && !ctx.underWay(&b.Action.driven): + ctx.noteEndingUndo(b) + b.Action.endTerminated() + case b.State != nil && b.State.endsWith(ended) && !ctx.underWay(&b.State.driven): + ctx.noteEndingUndo(b) + b.State.endTerminated() + } + } +} + +// noteEndingUndo captures the behavior's executor and the run it drives where they stand, +// for the journal under way to put them back should the ending be rolled back. +func (ctx *Context) noteEndingUndo(b *ObjectBehavior) { + if ctx.journals == 0 { + return + } + var captured executorCaptures + if b.Action != nil { + captured.captureAction(b.Action) + captured.captureRunState(b.Action.driven.state) + } else { + captured.captureState(b.State) + captured.captureRunState(b.State.driven.state) + } + ctx.noteProbeUndo(captured.restore) +} + +// endsWith reports whether the executor's performance ends with the objects ended: +// its occurrence or performer is among them, or the performance it was begun under ends. +func (e *ActionExecutor) endsWith(ended map[int64]bool) bool { + if e.occurrence != nil && ended[e.occurrence.ID] || e.self != nil && ended[e.self.ID] { + return true + } + return e.driven.caller.endsWithin(ended) +} + +// performerEnded reports whether the action's occurrence or performer ended. +func (e *ActionExecutor) performerEnded() bool { + return e.ctx.lifeEnded(e.occurrence) || e.ctx.lifeEnded(e.self) +} + +// endTerminated ends the action's performance where it is: every token is dropped +// with the bodies and performances it held, and the run is terminated. +func (e *ActionExecutor) endTerminated() { + if e.state.Ended() { + return + } + e.endPausedBodies() + e.dropTokensIn(e.root, 0) + endNested(e.root) + e.root.ended, e.root.live = true, 0 + e.state = StateTerminated + e.ctx.endPerformanceLife(e.occurrence) +} + +// endedByOccurrence settles the unwinding of a terminate of an occurrence at this +// executor's step: one whose performance ends with it ends here, and the unwinding +// goes on only while the performance it was begun under ends too. +func (e *ActionExecutor) endedByOccurrence(t *terminated, err error) error { + if !e.endsWith(t.ended) { + return err + } + e.endTerminated() + if e.driven.caller.endsWithin(t.ended) { + return err + } + return nil +} + +// endsWith reports whether the machine's performance ends with the objects ended: +// its occurrence or the object exhibiting it is among them, or the performance it +// was begun under ends. +func (e *StateExecutor) endsWith(ended map[int64]bool) bool { + if e.occurrence != nil && ended[e.occurrence.ID] || e.self != nil && ended[e.self.ID] { + return true + } + return e.driven.caller.endsWithin(ended) +} + +// performerEnded reports whether the machine's occurrence or exhibiting object ended. +func (e *StateExecutor) performerEnded() bool { + return e.ctx.lifeEnded(e.occurrence) || e.ctx.lifeEnded(e.self) +} + +// endTerminated ends the machine's performance where it is, as a transition to a +// terminate action does: no state is exited, the do behaviors under way are abandoned. +func (e *StateExecutor) endTerminated() { + if e.state.Ended() { + return + } + abandoned := e.abandonMachine() + if e.trace() != nil { + e.trace().RecordStateEndedWithOccurrence(symbolText(e.stateMachine), abandoned) + } + e.state = StateTerminated + e.ctx.endPerformanceLife(e.occurrence) +} + +// endedByOccurrence settles the unwinding of a terminate of an occurrence at a call +// into the machine: one whose performance ends with it ends here, and the unwinding +// goes on only while the performance it was begun under ends too. +func (e *StateExecutor) endedByOccurrence(err *error) { + t := unwound(*err) + if t == nil || t.perf != nil || !e.endsWith(t.ended) { + return + } + e.endTerminated() + if !e.driven.caller.endsWithin(t.ended) { + *err = nil + } +} diff --git a/internal/core/runtime/open_domain.go b/internal/exec/runtime/open_domain.go similarity index 97% rename from internal/core/runtime/open_domain.go rename to internal/exec/runtime/open_domain.go index ae253ad633..31083a78d1 100644 --- a/internal/core/runtime/open_domain.go +++ b/internal/exec/runtime/open_domain.go @@ -3,9 +3,9 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // The library types an operator's domain is spelled in. An operand read from a diff --git a/internal/core/runtime/optional_feature_test.go b/internal/exec/runtime/optional_feature_test.go similarity index 99% rename from internal/core/runtime/optional_feature_test.go rename to internal/exec/runtime/optional_feature_test.go index ab7e161fb4..a8d4d52157 100644 --- a/internal/core/runtime/optional_feature_test.go +++ b/internal/exec/runtime/optional_feature_test.go @@ -4,9 +4,9 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // An optional composite feature fills to its lower bound like a collection: diff --git a/internal/core/runtime/outcome.go b/internal/exec/runtime/outcome.go similarity index 91% rename from internal/core/runtime/outcome.go rename to internal/exec/runtime/outcome.go index bbe41f6fb2..cfcde8c123 100644 --- a/internal/core/runtime/outcome.go +++ b/internal/exec/runtime/outcome.go @@ -13,7 +13,11 @@ type Outcome struct { Outputs map[string]Value FinalState string StateVisits []string - Err error + // Terminated is set when the performance was ended short of its own flow: a + // machine's transition to a terminate action, or a terminate of an occurrence + // it belongs to. An action's own `terminate` node completes it instead. + Terminated bool + Err error // ctx is the run's context, where the objects the outputs hold live. ctx *Context } @@ -37,6 +41,9 @@ func (o Outcome) String() string { return "error: " + o.Err.Error() } var parts []string + if o.Terminated { + parts = append(parts, "terminated") + } if o.FinalState != "" { parts = append(parts, "finalState "+o.FinalState) } @@ -67,6 +74,9 @@ func (o Outcome) identity() string { return "error: " + strconv.Quote(o.Err.Error()) } parts := []string{"finalState " + strconv.Quote(o.FinalState)} + if o.Terminated { + parts = append(parts, "terminated") + } for _, visit := range o.StateVisits { parts = append(parts, "visit "+strconv.Quote(visit)) } @@ -97,6 +107,14 @@ func (ctx *Context) ActionOutcome(outputs map[string]Value) Outcome { return Outcome{Outputs: outputs, ctx: ctx} } +// Outcome is the outcome of the run so far: the values its features hold, and +// whether a `terminate` ended it short of its own end. +func (e *ActionExecutor) Outcome() Outcome { + outcome := e.ctx.ActionOutcome(e.Results()) + outcome.Terminated = e.state == StateTerminated + return outcome +} + // Outcome is the outcome of the performance so far: the configuration the // machine rests in, the states it entered and the values it holds. func (e *StateExecutor) Outcome() Outcome { @@ -104,6 +122,7 @@ func (e *StateExecutor) Outcome() Outcome { FinalState: e.FinalStateName(), StateVisits: slices.Clone(e.stateVisits), Outputs: e.StateData(), + Terminated: e.state == StateTerminated, ctx: e.ctx, } } @@ -115,6 +134,9 @@ func (ctx *Context) JointOutcome(names []string, outcomes []Outcome) Outcome { outputs := make(map[string]Value) for i, o := range outcomes { name := names[i] + if o.Terminated { + outputs[name+" terminated"] = boolValue(true) + } if o.FinalState != "" { outputs[name+" finalState"] = NewStringValue(o.FinalState) } diff --git a/internal/core/runtime/outer_feature.go b/internal/exec/runtime/outer_feature.go similarity index 96% rename from internal/core/runtime/outer_feature.go rename to internal/exec/runtime/outer_feature.go index 0b0c858990..58e4f155b1 100644 --- a/internal/core/runtime/outer_feature.go +++ b/internal/exec/runtime/outer_feature.go @@ -1,8 +1,8 @@ package runtime import ( - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // outerFeatureValue reads the resolved feature from the nearest owner carrying it: a nested usage diff --git a/internal/core/runtime/owned_constraint_value_test.go b/internal/exec/runtime/owned_constraint_value_test.go similarity index 99% rename from internal/core/runtime/owned_constraint_value_test.go rename to internal/exec/runtime/owned_constraint_value_test.go index 3836ad890c..b01cf835c1 100644 --- a/internal/core/runtime/owned_constraint_value_test.go +++ b/internal/exec/runtime/owned_constraint_value_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // ownedConstraintValueFixture declares requirements whose constraints bind a diff --git a/internal/core/runtime/part_feature_chain_test.go b/internal/exec/runtime/part_feature_chain_test.go similarity index 99% rename from internal/core/runtime/part_feature_chain_test.go rename to internal/exec/runtime/part_feature_chain_test.go index 305c1eaf0f..c0db982675 100644 --- a/internal/core/runtime/part_feature_chain_test.go +++ b/internal/exec/runtime/part_feature_chain_test.go @@ -1,9 +1,10 @@ package runtime import ( - "github.com/Open-MBEE/OpenSysML/internal/core/ast" "strings" "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // partChainModel declares a calc usage nested in a part, whose outputs a feature diff --git a/internal/exec/runtime/perform.go b/internal/exec/runtime/perform.go new file mode 100644 index 0000000000..5a3ba6aa4c --- /dev/null +++ b/internal/exec/runtime/perform.go @@ -0,0 +1,354 @@ +package runtime + +import ( + "errors" + "fmt" + "maps" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// QueuedEvent is one occurrence a driven state performance queues before it +// runs: a signal by type, carrying arguments or one bare payload, or an +// operation call carrying arguments. +type QueuedEvent struct { + Signal string + Call string + Args map[string]Value + Value *Value +} + +// ErrMalformedEvent is the typed error Enqueue returns for an event that is +// not exactly one signal or one call. +var ErrMalformedEvent = errors.New("malformed queued event") + +// Enqueue queues one event on the executor as a case declares it. +func (e *StateExecutor) Enqueue(event QueuedEvent) error { + switch { + case event.Call != "" && event.Signal != "": + return fmt.Errorf("%w: event declares both signal %q and call %q", ErrMalformedEvent, event.Signal, event.Call) + case event.Value != nil && (event.Call != "" || len(event.Args) > 0): + return fmt.Errorf("%w: event %s%s carries a bare value beside its arguments", ErrMalformedEvent, event.Signal, event.Call) + case event.Value != nil && event.Signal == "": + return fmt.Errorf("%w: event carries a bare value but declares no signal", ErrMalformedEvent) + case event.Call != "": + payload, err := e.callPayload(event.Call, event.Args) + if err != nil { + return err + } + e.queueCall(payload) + case event.Value != nil: + value := *event.Value + e.enqueueSignal(Message{SignalType: event.Signal, Value: &value}) + case event.Signal != "": + e.SendSignal(event.Signal, event.Args) + default: + return fmt.Errorf("%w: event declares neither a signal nor a call", ErrMalformedEvent) + } + return nil +} + +// ErrCallNotReturned reports a call the run left queued or deferred, so a +// synchronous caller would still be waiting on it. +var ErrCallNotReturned = errors.New("call not returned") + +// pendingCall is the synchronous call Call is waiting on, the outputs the +// machine has returned to its caller so far, the names the operation's +// declaration lets out (nil when the machine's owner declares no such operation) +// and the inout arguments a taken call returns as they are until a behavior +// writes them. +type pendingCall struct { + id int64 + outputs map[string]Value + returns map[string]bool + inouts map[string]Value + taken bool +} + +// clone copies the call where it stands, its maps by value, for a capture. +func (call *pendingCall) clone() *pendingCall { + if call == nil { + return nil + } + copied := *call + copied.outputs, copied.returns, copied.inouts = maps.Clone(call.outputs), maps.Clone(call.returns), maps.Clone(call.inouts) + return &copied +} + +// Call queues the call event, runs the machine through the run-to-completion +// step dispatching it and releases the caller with the outputs the behaviors that +// step fired returned, by name (PSSM 8.5.9): the operation's out and inout +// parameters when the machine's owner declares it, an inout unwritten by the step +// as the caller passed it, every output otherwise. Events that step queued and +// timers it armed stay for the machine's later runs; the clock does not move. +func (e *StateExecutor) Call(operation string, args map[string]Value) (map[string]Value, error) { + payload, err := e.callPayload(operation, args) + if err != nil { + return nil, err + } + call, err := e.newPendingCall(payload) + if err != nil { + return nil, err + } + e.pendingCall = call + defer func() { e.pendingCall = nil }() + e.queueCall(payload) + if err := e.RunToQuiescence(); err != nil { + return nil, err + } + if held := e.eventDisposition(call.id); held != "" { + return nil, fmt.Errorf("%w: %s is still %s", ErrCallNotReturned, operation, held) + } + // A step undone within the run restored the call as it stood when captured. + if e.pendingCall != nil { + call = e.pendingCall + } + return call.outputs, nil +} + +// ErrNoSuchAttribute is the typed error WriteAttribute returns for a name the +// machine declares no attribute for. +var ErrNoSuchAttribute = errors.New("no such attribute") + +// WriteAttribute assigns an attribute the machine declares from outside it, +// between its steps, with the checks an assignment in its behavior gets. +func (e *StateExecutor) WriteAttribute(name string, value Value) error { + if !e.declaresAttribute(name) { + return fmt.Errorf("%w: state machine %s declares no attribute %q", ErrNoSuchAttribute, symbolText(e.stateMachine), name) + } + return e.assignAttribute(name, value) +} + +// callReleased reports whether the pending call's event has been dispatched, so +// its run-to-completion step is done; a deferred call still holds its caller. +func (e *StateExecutor) callReleased() bool { + return e.pendingCall != nil && e.eventDisposition(e.pendingCall.id) == "" +} + +// callOwner is the type whose members a called operation is looked up among: the +// machine's owner, the machine itself when it stands alone. +func (e *StateExecutor) callOwner() *symbols.Symbol { + if e.self != nil && e.self.Type != nil { + return e.self.Type + } + return e.stateMachine +} + +// callTriggerOperations reads the declared operations a call trigger names, as a +// UML call event names one: the owner's behavior members of the trigger's name +// declaring an input for each trigger parameter — those declaring exactly the +// trigger's parameters when any does — memoized per trigger. +func (e *StateExecutor) callTriggerOperations(trigger *ast.CallEvent) []*symbols.Symbol { + if named, memoized := e.callTriggers[trigger]; memoized { + return named + } + name := ast.SimpleName(trigger.Operation) + var loose, exact []*symbols.Symbol + for _, member := range e.ctx.model.semantics.MembersOf(e.callOwner()) { + if member.Name != name || !isActionSymbol(member) && !isCalcSymbol(member) && !isConstraintSymbol(member) { + continue + } + inputs := make(map[string]bool) + for _, param := range e.ctx.model.semantics.SignatureParametersOf(member) { + inputs[param.Name] = true + } + declared := 0 + for _, param := range trigger.Parameters { + if inputs[param.Text] { + declared++ + } + } + if declared < len(trigger.Parameters) { + continue + } + loose = append(loose, member) + if len(inputs) == len(trigger.Parameters) { + exact = append(exact, member) + } + } + named := loose + if len(exact) > 0 { + named = exact + } + if e.callTriggers == nil { + e.callTriggers = make(map[*ast.CallEvent][]*symbols.Symbol) + } + e.callTriggers[trigger] = named + return named +} + +// callPayload builds the call event's payload: the operation's declaration as a +// behavior member of the machine's owner — among several so named, the one the +// arguments select as a call in the model would — and the arguments bound to its +// inputs as an invocation binds them; an operation no member of that name +// declares as a behavior carries the arguments as given and no declaration. +func (e *StateExecutor) callPayload(operation string, args map[string]Value) (Call, error) { + member, err := e.ctx.memberCalled(e.callOwner(), e.self, operation, OperationArguments{Named: args}) + if err != nil { + return Call{}, err + } + if !isActionSymbol(member) && !isCalcSymbol(member) && !isConstraintSymbol(member) { + return Call{Operation: operation, Args: args}, nil + } + inputs, err := e.callInputs(member, operation, args) + if err != nil { + return Call{}, err + } + return Call{Operation: operation, Declared: member, Args: inputs}, nil +} + +// newPendingCall takes the names the call returns: an action's out and inout +// parameters, with the inout values the call carries in, a calc's or constraint's +// result; a call of no declared operation returns every output. +func (e *StateExecutor) newPendingCall(payload Call) (*pendingCall, error) { + call := &pendingCall{id: e.nextEventID, outputs: make(map[string]Value)} + member, inputs := payload.Declared, payload.Args + if member == nil { + return call, nil + } + call.returns = make(map[string]bool) + switch { + case isActionSymbol(member): + for _, param := range e.ctx.actionParametersOf(member) { + switch param.Direction { + case ast.DirOut: + call.returns[param.Name] = true + case ast.DirInOut: + call.returns[param.Name] = true + if value, ok := inputs[param.Name]; ok { + if call.inouts == nil { + call.inouts = make(map[string]Value) + } + call.inouts[param.Name] = value + } + } + } + case isCalcSymbol(member): + shape, err := e.ctx.calcShapeOf(member) + if err != nil { + return nil, err + } + call.returns[shape.resultName()] = true + default: + call.returns["result"] = true + } + return call, nil +} + +// callInputs binds the arguments to the operation's inputs as InvokeOperation +// binds them — by name, each value checked against its parameter's declaration, a +// parameter no argument binds holding its default — so the event carries a value +// for each parameter, as PSSM 8.5.9's call event execution does. +func (e *StateExecutor) callInputs(member *symbols.Symbol, operation string, args map[string]Value) (map[string]Value, error) { + ctx := e.ctx + inputs, err := operationInputs(ctx.model.semantics.SignatureParametersOf(member), operation, OperationArguments{Named: args}) + if err != nil { + return nil, err + } + scope := DeclScope(member) + ec := NewEvalContextIn(ctx, scope, e.self) + defer ec.beginStep()() + if isCalcSymbol(member) { + shape, err := ctx.calcShapeOf(member) + if err != nil { + return nil, err + } + bound := mapFrame(make(map[string]Value, len(shape.Params))) + ec.frames = []frame{bound} + if err := ctx.bindCalcParameters(shape, ec, calcArgs{named: inputs}, scope, bound, nil); err != nil { + return nil, err + } + return bound.vars, nil + } + ec.frames = []frame{mapFrame(inputs)} + where := "call " + operation + for _, param := range ctx.model.semantics.BehaviorParametersOf(member) { + if param.Symbol == nil || param.Direction != ast.DirIn && param.Direction != ast.DirInOut { + continue + } + name := param.Symbol.Name + value, bound := inputs[name] + if !bound { + expr, declared := ctx.model.semantics.ParameterDefault(param.Symbol) + if expr == nil { + continue + } + if value, err = ec.evalIn(declared).Eval(expr); err != nil { + return nil, fmt.Errorf("%s: eval default of %s: %w", where, name, err) + } + } + if err := ctx.checkNamedWrite(scope, where, name, &value); err != nil { + return nil, err + } + inputs[name] = value + } + return inputs, nil +} + +// callTaken notes that a transition fired on the pending call's event, so an inout +// argument no behavior of the step wrote goes back to the caller as it came. +func (e *StateExecutor) callTaken(event *Event) { + call := e.pendingCall + if call == nil || call.taken || event == nil || event.ID != call.id { + return + } + call.taken = true + for name, value := range call.inouts { + if _, written := call.outputs[name]; !written { + call.outputs[name] = value + } + } +} + +// recordCallOutput keeps an output a behavior returned to the machine while the +// pending call's event is being dispatched, for Call to release the caller with; +// a name the declared operation does not return stays the machine's own. +func (e *StateExecutor) recordCallOutput(name string, value Value) { + call := e.pendingCall + if call == nil || e.firingEvent == nil || e.firingEvent.ID != call.id { + return + } + if call.returns != nil && !call.returns[name] { + return + } + call.outputs[name] = value +} + +// eventDisposition is "queued" or "deferred" for an event the machine still +// holds, empty once it was dispatched. +func (e *StateExecutor) eventDisposition(id int64) string { + for _, event := range e.eventQueue.Events() { + if event.ID == id { + return "queued" + } + } + for _, event := range e.deferred { + if event.ID == id { + return "deferred" + } + } + return "" +} + +// PerformState drives one performance of a state machine, by self or by no +// object: it enters the initial state, queues the events, and runs through the +// executor's own loop to completion or suspension, returning the executor for +// its outcome. The conformance harness and the referees drive machines this way. +func (ctx *Context) PerformState(stateMachine *symbols.Symbol, self *Instance, events []QueuedEvent) (*StateExecutor, error) { + exec, err := ctx.CreateStateExecutorFor(stateMachine, self) + if err != nil { + return nil, err + } + for _, event := range events { + if err := exec.Enqueue(event); err != nil { + exec.Release() + return nil, err + } + } + if err := exec.RunToCompletion(); err != nil { + exec.Release() + return nil, err + } + return exec, nil +} diff --git a/internal/core/runtime/perform_test.go b/internal/exec/runtime/perform_test.go similarity index 100% rename from internal/core/runtime/perform_test.go rename to internal/exec/runtime/perform_test.go diff --git a/internal/core/runtime/pilot_first_then_test.go b/internal/exec/runtime/pilot_first_then_test.go similarity index 92% rename from internal/core/runtime/pilot_first_then_test.go rename to internal/exec/runtime/pilot_first_then_test.go index 4f7cd5f0f3..0a4333489f 100644 --- a/internal/core/runtime/pilot_first_then_test.go +++ b/internal/exec/runtime/pilot_first_then_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // OMG's `provide power` writes `first start;` and eight `first X then Y;` successions; diff --git a/internal/core/runtime/pristine.go b/internal/exec/runtime/pristine.go similarity index 95% rename from internal/core/runtime/pristine.go rename to internal/exec/runtime/pristine.go index 8a7ff0d779..f22a681e35 100644 --- a/internal/core/runtime/pristine.go +++ b/internal/exec/runtime/pristine.go @@ -5,8 +5,8 @@ import ( "maps" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // HeldStateError reports an object that is no longer as its declaration materializes diff --git a/internal/core/runtime/pristine_test.go b/internal/exec/runtime/pristine_test.go similarity index 98% rename from internal/core/runtime/pristine_test.go rename to internal/exec/runtime/pristine_test.go index c5f48175d9..3632ecbc7c 100644 --- a/internal/core/runtime/pristine_test.go +++ b/internal/exec/runtime/pristine_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // refusal checks Pristine refuses the object with a HeldStateError giving reason. @@ -75,7 +75,7 @@ func TestPristineFollowsAStateMachinesMoves(t *testing.T) { // the action through a snapshot as the machine's does. func TestPristineFollowsAPerformedActionsMoves(t *testing.T) { model, resolver, root := parseAndBuildModel(t, waiterSource) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) waiter, err := ctx.Instantiate(resolveSymbol(t, resolveSymbol(t, root, "test").Scope, "Waiter")) if err != nil { t.Fatalf("Instantiate Waiter: %v", err) @@ -491,7 +491,7 @@ func TestPristineSeesTheWaitOfAnActionAPausedBodyPerforms(t *testing.T) { t.Fatalf("Instantiate Sleeper: %v", err) } rest := sleeper.behaviors[0] - if rest.Action == nil || len(rest.Action.armedWaits()) != 0 || len(rest.Action.heldWaiters()) != 1 { + if rest.Action == nil || len(rest.Action.armedWaits()) != 0 || len(rest.Action.pausedWaiters()) != 1 { t.Fatalf("rest = %+v; want an action holding no wait of its own and one performed action", rest) } if waits := rest.armedWaits(); len(waits) != 1 || waits[0].Due != 3 { diff --git a/internal/exec/runtime/pseudostate_test.go b/internal/exec/runtime/pseudostate_test.go new file mode 100644 index 0000000000..f616b3e242 --- /dev/null +++ b/internal/exec/runtime/pseudostate_test.go @@ -0,0 +1,570 @@ +package runtime + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +const forkJoinMachine = `package test { + state Machine { + attribute leftRan : Integer = 0; + attribute rightRan : Integer = 0; + attribute merged : Integer = 0; + + entry; then init; + state init; + state idle; + state running parallel { + state left { + entry; then lstart; + state lstart; + state working { entry { assign leftRan := 1; } } + succession first lstart then working; + } + state right { + entry; then rstart; + state rstart; + state watching { entry { assign rightRan := 1; } } + succession first rstart then watching; + } + } + fork split; + join sync; + + succession first init then idle; + transition first idle then split; + transition first split then working; + transition first split then watching; + transition first working then sync; + transition first watching then sync; + transition first sync then done; + } +}` + +// A fork makes one state active per orthogonal region; the join only releases +// once every branch has arrived. +func TestForkAndJoinPseudostates(t *testing.T) { + ctx, machine := loadState(t, forkJoinMachine, "Machine") + + data, visited, err := ctx.ExecuteStateWithEvents(machine, nil) + if err != nil { + t.Fatalf("ExecuteStateWithEvents: %v", err) + } + + for _, name := range []string{"leftRan", "rightRan"} { + if got := intValue(t, data, name); got != 1 { + t.Errorf("%s = %d, want 1 (fork branch entered)", name, got) + } + } + for _, want := range []string{"working", "watching", "done"} { + if !containsState(visited, want) { + t.Errorf("state %q not visited, visits: %v", want, visited) + } + } +} + +func TestForkBranchesMustBeInDistinctRegions(t *testing.T) { + ctx, machine := loadState(t, `package test { + state Machine { + entry; then init; + state init; + state idle; + state running parallel { + state left { + entry; then lstart; + state lstart; + state working; + state alsoWorking; + succession first lstart then working; + } + state right { + entry; then rstart; + state rstart; + state watching; + succession first rstart then watching; + } + } + fork split; + + succession first init then idle; + transition first idle then split; + transition first split then working; + transition first split then alsoWorking; + } +}`, "Machine") + + _, _, err := ctx.ExecuteStateWithEvents(machine, nil) + if err == nil || !strings.Contains(err.Error(), "same region") { + t.Fatalf("error = %v, want branches in the same region to be rejected", err) + } +} + +func TestJoinWaitsForEveryBranch(t *testing.T) { + ctx, machine := loadState(t, `package test { + state Machine { + entry; then init; + state init; + state idle; + state running parallel { + state left { + entry; then lstart; + state lstart; + state working; + succession first lstart then working; + } + state right { + entry; then rstart; + state rstart; + state watching; + state stillWatching; + succession first rstart then watching; + transition first watching then stillWatching; + } + } + fork split; + join sync; + + succession first init then idle; + transition first idle then split; + transition first split then working; + transition first split then watching; + transition first working then sync; + transition first stillWatching then sync; + transition first sync then done; + } +}`, "Machine") + + _, visited, err := ctx.ExecuteStateWithEvents(machine, nil) + if err != nil { + t.Fatalf("ExecuteStateWithEvents: %v", err) + } + // The left branch reaches the join first and must wait for the right branch + // to move through stillWatching before done is entered. + for _, want := range []string{"working", "watching", "stillWatching", "done"} { + if !containsState(visited, want) { + t.Fatalf("state %q not visited, visits: %v", want, visited) + } + } + if indexOfState(visited, "stillWatching") > indexOfState(visited, "done") { + t.Errorf("join released before the right branch arrived, visits: %v", visited) + } +} + +// A transition into a join is not enabled until every branch has arrived: an +// event reaching one branch first fires nothing, Decide and LastDispatch say so; +// the completion of the last branch fires nothing either while the other +// segment's trigger is not that occurrence, and the join fires on the event +// that enables every segment at once. +func TestJoinBranchArrivingFirstFiresNothing(t *testing.T) { + ctx, machine := loadState(t, `package test { + attribute def Go; + state Machine { + entry; then running; + state running parallel { + state left { + entry; then a; + state a; + } + state right { + entry; then b0; + state b0; + state b; + transition first b0 accept after 2 then b; + } + } + join sync; + transition first a accept Go then sync; + transition first b then sync; + transition first sync then done; + } +}`, "Machine") + exec, err := ctx.CreateStateExecutor(machine) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + goMsg := Message{SignalType: "Go"} + if accepted, err := exec.AcceptsMessage(goMsg); err != nil || !accepted { + t.Fatalf("AcceptsMessage(Go) = %v, %v; want a in the left region to accept it", accepted, err) + } + if d, err := exec.Decide(goMsg); err != nil || d.Enabled() { + t.Errorf("Decide(Go) with the right branch in b0 = %+v, %v; want nothing enabled", d, err) + } + + exec.SendSignal("Go", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(Go): %v", err) + } + if d, ok := exec.LastDispatch(); !ok || d.Fired || d.Deferred { + t.Errorf("LastDispatch after Go = %+v, %v; want dispatched, neither fired nor deferred", d, ok) + } + if got := activeStateNames(exec); got != "a|b0" { + t.Fatalf("configuration after Go = %s, want a|b0", got) + } + + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(after 2): %v", err) + } + if d, ok := exec.LastDispatch(); !ok || !d.Fired { + t.Errorf("LastDispatch after the timer = %+v, %v; want b0 -> b fired", d, ok) + } + if got := activeStateNames(exec); got != "a|b" { + t.Fatalf("configuration after the timer = %s, want a|b", got) + } + + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(completion of b): %v", err) + } + if d, ok := exec.LastDispatch(); !ok || d.Fired || d.Deferred { + t.Errorf("LastDispatch after b completed = %+v, %v; want dispatched, neither fired nor deferred: a's segment waits for Go", d, ok) + } + if got := activeStateNames(exec); got != "a|b" { + t.Fatalf("configuration after b completed = %s, want a|b", got) + } + + if d, err := exec.Decide(goMsg); err != nil || !d.Enabled() { + t.Errorf("Decide(Go) with both branches arrived = %+v, %v; want the join enabled", d, err) + } + exec.SendSignal("Go", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(second Go): %v", err) + } + if d, ok := exec.LastDispatch(); !ok || !d.Fired { + t.Errorf("LastDispatch after the second Go = %+v, %v; want the join fired", d, ok) + } + if exec.State() != StateCompleted { + t.Errorf("machine %v after the join, want completed; configuration %s", exec.State(), activeStateNames(exec)) + } +} + +// A segment into a join drawn among several transitions out of its source, whose +// join another region's effect then disarms before its turn: the join fires +// nothing, and the draw that never fired is not among the run's choices. +func TestJoinDisarmedBeforeItsTurnRecordsNoChoice(t *testing.T) { + ctx, machine := loadState(t, `package test { + attribute def Go; + state Machine { + attribute armed : Boolean = true; + entry; then running; + state running parallel { + state c { + entry; then c1; + state c1; + state c2; + transition first c1 accept Go do assign armed := false then c2; + } + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept Go then sync; + transition first a1 accept Go then a2; + } + state b { + entry; then b1; + state b1; + transition first b1 accept Go if armed then sync; + } + } + join sync; + transition first sync then done; + } +}`, "Machine") + // `declared` fires the regions in declaration order, c before a, and takes + // a1's first transition, the segment into the join. + policy, err := ParseSchedulePolicy("declared") + if err != nil { + t.Fatal(err) + } + if err := ctx.SetSchedule(policy); err != nil { + t.Fatal(err) + } + exec, err := ctx.CreateStateExecutor(machine) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + exec.SendSignal("Go", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(Go): %v", err) + } + if got := activeStateNames(exec); got != "c2|a1|b1" { + t.Fatalf("configuration after Go = %s, want c2|a1|b1: c's effect disarmed b's segment, so a's fires nothing", got) + } + for _, c := range ctx.Choices() { + if c.Kind == ChoiceTransition { + t.Errorf("choices include %s: a's segment fired nothing, so its draw is no choice the run made", c) + } + } +} + +// A segment into a join may leave a composite state whose substate is active: +// the occurrence reaches that state from within, so Decide reports the join +// enabled and dispatching the occurrence fires it, the substate exited too. +func TestJoinFromActiveCompositeSourcesFires(t *testing.T) { + ctx, machine := loadState(t, `package test { + attribute def Go; + state Machine { + entry; then running; + state running parallel { + state left { + entry; then ia; + state ia { + entry; then a1; + state a1; + } + transition first ia accept Go then sync; + } + state right { + entry; then b; + state b; + transition first b accept Go then sync; + } + } + join sync; + transition first sync then done; + } +}`, "Machine") + exec, err := ctx.CreateStateExecutor(machine) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + if got := activeStateNames(exec); got != "a1|b" { + t.Fatalf("initial configuration = %s, want a1|b", got) + } + goMsg := Message{SignalType: "Go"} + if d, err := exec.Decide(goMsg); err != nil || !d.Enabled() { + t.Errorf("Decide(Go) = %+v, %v; want the join enabled with ia active through a1", d, err) + } + exec.SendSignal("Go", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(Go): %v", err) + } + if d, ok := exec.LastDispatch(); !ok || !d.Fired { + t.Errorf("LastDispatch after Go = %+v, %v; want the join fired", d, ok) + } + if exec.State() != StateCompleted { + t.Errorf("machine %v after the join, want completed; configuration %s", exec.State(), activeStateNames(exec)) + } +} + +// A completion segment into a join is enabled once its source has completed, +// not while it is merely active: a Go dispatched while b's do behavior still runs +// fires nothing and the behavior goes on; b's completion, once the behavior ends, +// fires nothing while a's segment waits for Go; the Go that follows fires the join. +func TestJoinCompletionSegmentWaitsForItsSourcesDoBehavior(t *testing.T) { + ctx, machine := loadState(t, `package test { + attribute def Go; + attribute def Tick; + state Machine { + attribute ticks : Integer = 0; + entry; then running; + state running parallel { + state left { + entry; then a; + state a; + } + state right { + entry; then b; + state b { + do action busy { + first start; + then action wait accept Tick; + then action count assign ticks := ticks + 1; + then done; + } + } + } + } + join sync; + transition first a accept Go then sync; + transition first b then sync; + transition first sync then done; + } +}`, "Machine") + exec, err := ctx.CreateStateExecutor(machine) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + goMsg := Message{SignalType: "Go"} + if d, err := exec.Decide(goMsg); err != nil || d.Enabled() { + t.Errorf("Decide(Go) with b's do behavior running = %+v, %v; want nothing enabled", d, err) + } + exec.SendSignal("Go", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(Go): %v", err) + } + if d, ok := exec.LastDispatch(); !ok || d.Fired || d.Deferred { + t.Errorf("LastDispatch after Go = %+v, %v; want dispatched, neither fired nor deferred", d, ok) + } + if got := activeStateNames(exec); got != "a|b" { + t.Fatalf("configuration after Go = %s, want a|b", got) + } + + exec.SendSignal("Tick", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(Tick): %v", err) + } + if got := ticksAfter(t, exec); got != 1 { + t.Fatalf("ticks after Tick = %d, want 1: the do behavior ran on rather than being abandoned", got) + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(completion of b): %v", err) + } + if d, ok := exec.LastDispatch(); !ok || d.Fired || d.Deferred { + t.Errorf("LastDispatch after b completed = %+v, %v; want dispatched, neither fired nor deferred: a's segment waits for Go", d, ok) + } + if got := activeStateNames(exec); got != "a|b" { + t.Fatalf("configuration after b completed = %s, want a|b", got) + } + + if d, err := exec.Decide(goMsg); err != nil || !d.Enabled() { + t.Errorf("Decide(Go) with b completed = %+v, %v; want the join enabled", d, err) + } + exec.SendSignal("Go", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(second Go): %v", err) + } + if d, ok := exec.LastDispatch(); !ok || !d.Fired { + t.Errorf("LastDispatch after the second Go = %+v, %v; want the join fired", d, ok) + } + if exec.State() != StateCompleted { + t.Errorf("machine %v after the join, want completed; configuration %s", exec.State(), activeStateNames(exec)) + } +} + +// A completion segment out of a composite state is enabled once the state's body +// is at `done`, and re-entering the state starts its body over: a Go dispatched +// while b's body is at b1 fires nothing; a Go once it is done fires the join. +func TestJoinCompletionSegmentWaitsForItsSourcesBody(t *testing.T) { + ctx, machine := loadState(t, `package test { + attribute def Go; + attribute def Tick; + attribute def Again; + state Machine { + entry; then running; + state running parallel { + state left { + entry; then a; + state a; + } + state right { + entry; then b; + state b { + entry; then b1; + state b1; + transition first b1 accept Tick then done; + } + transition first b accept Again then b; + } + } + join sync; + transition first a accept Go then sync; + transition first b then sync; + transition first sync then done; + } +}`, "Machine") + exec, err := ctx.CreateStateExecutor(machine) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + goMsg := Message{SignalType: "Go"} + dispatch := func(signal string) { + t.Helper() + exec.SendSignal(signal, nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(%s): %v", signal, err) + } + } + if d, err := exec.Decide(goMsg); err != nil || d.Enabled() { + t.Errorf("Decide(Go) with b's body at b1 = %+v, %v; want nothing enabled", d, err) + } + dispatch("Go") + if got := activeStateNames(exec); got != "a|b1" { + t.Fatalf("configuration after Go = %s, want a|b1", got) + } + + dispatch("Tick") + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(completion of b): %v", err) + } + if d, ok := exec.LastDispatch(); !ok || d.Fired { + t.Errorf("LastDispatch after b completed = %+v, %v; want nothing fired: a's segment waits for Go", d, ok) + } + if d, err := exec.Decide(goMsg); err != nil || !d.Enabled() { + t.Errorf("Decide(Go) with b's body done = %+v, %v; want the join enabled", d, err) + } + + dispatch("Again") + if got := activeStateNames(exec); got != "a|b1" { + t.Fatalf("configuration after Again = %s, want a|b1: b re-entered starts its body over", got) + } + if d, err := exec.Decide(goMsg); err != nil || d.Enabled() { + t.Errorf("Decide(Go) after b re-entered = %+v, %v; want nothing enabled", d, err) + } + dispatch("Go") + if got := activeStateNames(exec); got != "a|b1" { + t.Fatalf("configuration after Go = %s, want a|b1", got) + } + + dispatch("Tick") + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(completion of b): %v", err) + } + dispatch("Go") + if d, ok := exec.LastDispatch(); !ok || !d.Fired { + t.Errorf("LastDispatch after the last Go = %+v, %v; want the join fired", d, ok) + } + if exec.State() != StateCompleted { + t.Errorf("machine %v after the join, want completed; configuration %s", exec.State(), activeStateNames(exec)) + } +} + +// entryStart designates the state a machine or region starts in, as the +// `entry; then ;` succession out of the body's entry action does. +func entryStart(name string) *ast.SuccessionEdge { + return &ast.SuccessionEdge{ + SourceMember: &ast.EntryMember{}, + Target: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: name}}}, + } +} + +func transitionMember(source, target string) *ast.TransitionMember { + return &ast.TransitionMember{ + Source: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: source}}}, + Target: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: target}}}, + } +} + +func stateExecutorFor(t *testing.T, machine *ast.Usage) *StateExecutor { + t.Helper() + idx := symbols.NewIndex() + resolver := resolve.New(idx) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 100000) + + exec, err := newStateExecutor(ctx, &symbols.Symbol{ + Kind: symbols.SymbolStateUsage, + Name: machine.Ident.Name, + Decl: machine, + }, nil) + if err != nil { + t.Fatalf("newStateExecutor: %v", err) + } + return exec +} + +func containsState(visits []string, name string) bool { + return indexOfState(visits, name) >= 0 +} + +func indexOfState(visits []string, name string) int { + for i, visit := range visits { + if visit == name { + return i + } + } + return -1 +} diff --git a/internal/core/runtime/qualified_import_test.go b/internal/exec/runtime/qualified_import_test.go similarity index 97% rename from internal/core/runtime/qualified_import_test.go rename to internal/exec/runtime/qualified_import_test.go index 64bf75e925..e11dbf7cd2 100644 --- a/internal/core/runtime/qualified_import_test.go +++ b/internal/exec/runtime/qualified_import_test.go @@ -6,10 +6,10 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // qualifiedImportModel declares one package of values and the façades that diff --git a/internal/core/runtime/qualified_value_scope_test.go b/internal/exec/runtime/qualified_value_scope_test.go similarity index 100% rename from internal/core/runtime/qualified_value_scope_test.go rename to internal/exec/runtime/qualified_value_scope_test.go diff --git a/internal/core/runtime/quantity.go b/internal/exec/runtime/quantity.go similarity index 98% rename from internal/core/runtime/quantity.go rename to internal/exec/runtime/quantity.go index 230344e97e..99aabb4a8b 100644 --- a/internal/core/runtime/quantity.go +++ b/internal/exec/runtime/quantity.go @@ -4,8 +4,8 @@ import ( "fmt" "math" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // Quantity is a scalar quantity value, the representation the semantic layer diff --git a/internal/core/runtime/quantity_functions.go b/internal/exec/runtime/quantity_functions.go similarity index 99% rename from internal/core/runtime/quantity_functions.go rename to internal/exec/runtime/quantity_functions.go index b962555161..771a67f98d 100644 --- a/internal/core/runtime/quantity_functions.go +++ b/internal/exec/runtime/quantity_functions.go @@ -4,8 +4,8 @@ import ( "fmt" "math" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // init registers the Quantities and Units domain library's calculation packages: diff --git a/internal/core/runtime/quantity_functions_test.go b/internal/exec/runtime/quantity_functions_test.go similarity index 98% rename from internal/core/runtime/quantity_functions_test.go rename to internal/exec/runtime/quantity_functions_test.go index 0422d2638c..8324945a89 100644 --- a/internal/core/runtime/quantity_functions_test.go +++ b/internal/exec/runtime/quantity_functions_test.go @@ -7,11 +7,11 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // TestQuantityCalculationsAreAllDispatchable: every Quantities and Units calculation is @@ -28,7 +28,7 @@ func TestQuantityCalculationsAreAllDispatchable(t *testing.T) { idx := libs.NewModelIndex() resolver := resolve.New(idx) model := semantics.NewModel(resolver) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) for pkg, path := range packages { t.Run(pkg, func(t *testing.T) { diff --git a/internal/exec/runtime/quantity_test.go b/internal/exec/runtime/quantity_test.go new file mode 100644 index 0000000000..ef5a24a137 --- /dev/null +++ b/internal/exec/runtime/quantity_test.go @@ -0,0 +1,546 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// quantityContext builds a runtime over the standard library and evaluates +// expressions in the scope of a package that imports SI. +func quantityContext(t *testing.T) (*Context, *symbols.Scope) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + public import SI::*; + private import NumericalFunctions::*; + attribute speeds = (1.0, 2.0, 3.0); + } + `)) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + return ctx, pkg.Scope +} + +// evalIn evaluates the expression written in src in scope. +func evalIn(t *testing.T, ctx *Context, scope *symbols.Scope, src string) (Value, error) { + t.Helper() + p := parser.New(source.New("", []byte(src))) + expr := p.ParseExpression() + if expr == nil || len(p.Diagnostics) > 0 { + t.Fatalf("parse %q: %v", src, p.Diagnostics) + } + return ctx.EvalWithScope(expr, scope) +} + +// TestQuantityEvaluation evaluates quantity expressions over library units: a +// quantity keeps its unit, commensurable units convert, and a ratio of like +// quantities is a number. +func TestQuantityEvaluation(t *testing.T) { + ctx, scope := quantityContext(t) + + cases := []struct { + src string + want string // rendered value + }{ + {"1.5 [m/s]", "1.5 [m/s]"}, + {"1.5 [m/s] + 1.8 [km/h]", "2.0 [m/s]"}, + {"3.0 [km] + 500.0 [m]", "3.5 [km]"}, + {"10.0 [m] / 2.0 [s]", "5.0 [SI::'m/s']"}, + {"2.0 [m] * 3.0 [m]", "6.0 [SI::'m²']"}, + {"-2.5 [m/s]", "-2.5 [m/s]"}, + {"3.0 [m] * 2.0", "6.0 [m]"}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, tc.src) + if err != nil { + t.Fatalf("%s: %v", tc.src, err) + } + if got.Kind != ValQuantity { + t.Fatalf("%s = %v (%s), want a quantity", tc.src, got, got.Kind) + } + if got.Quantity().String() != tc.want { + t.Errorf("%s = %s, want %s", tc.src, got.Quantity(), tc.want) + } + }) + } +} + +// TestQuantityArithmeticReportsOverflow: a magnitude no Real holds is reported +// for a quantity as it is for a bare Real, rather than carried as an infinity. +func TestQuantityArithmeticReportsOverflow(t *testing.T) { + ctx, scope := quantityContext(t) + + for _, src := range []string{ + "1e308 [m] + 1e308 [m]", + "1e200 [m] * 1e200 [s]", + "1e308 [m] / 1e-308 [s]", + "1e308 [m] / 1e-308 [m]", + } { + got, err := evalIn(t, ctx, scope, src) + if !errors.Is(err, semantics.ErrArithmeticOverflow) { + t.Errorf("%s = %+v, %v; want ErrArithmeticOverflow", src, got, err) + } + } +} + +// TestQuantityComparison compares quantities across commensurable units, +// including at the exact boundary the lunar-lander requirement sits on. +func TestQuantityComparison(t *testing.T) { + cases := []struct { + src string + want bool + }{ + {"1.5 [m/s] <= 5.4 [km/h]", true}, + {"5.4 [km/h] <= 1.5 [m/s]", true}, + {"1.5 [m/s] < 5.4 [km/h]", false}, + {"1.5 [m/s] == 5.4 [km/h]", true}, + {"1.5 [m/s] != 5.4 [km/h]", false}, + {"2.0 [m/s] > 5.4 [km/h]", true}, + {"1.0 [km] == 1000.0 [m]", true}, + {"4.0 [m] / 2.0 [m] == 2.0", true}, + } + ctx, scope := quantityContext(t) + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, tc.src) + if err != nil { + t.Fatalf("%s: %v", tc.src, err) + } + if got.Kind != ValConst || got.Const.Kind != semantics.ValBool { + t.Fatalf("%s = %v, want a boolean", tc.src, got) + } + if got.Const.Bool != tc.want { + t.Errorf("%s = %v, want %v", tc.src, got.Const.Bool, tc.want) + } + }) + } +} + +// TestQuantityIncommensurable: an operation between units that measure different +// things is an error, never a comparison of the bare magnitudes. +func TestQuantityIncommensurable(t *testing.T) { + ctx, scope := quantityContext(t) + for _, src := range []string{ + "1.5 [m/s] <= 2.0 [s]", + "1.5 [m] + 2.0 [s]", + "1.5 [m] <= 2.0", + "1.5 [m] == 1.5 [s]", + } { + t.Run(src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, src) + if !errors.Is(err, ErrIncommensurableUnits) { + t.Fatalf("%s = %v, %v; want ErrIncommensurableUnits", src, got, err) + } + }) + } +} + +// TestQuantityAgainstBareNumber is the operator matrix of a quantity against a +// number naming no unit. A bare zero is the null quantity of every dimension, so +// a comparison reads it in the quantity's unit (`xoffset > 0`); any other number +// is dimensionless and a comparison or sum with it is incommensurable, while a +// product or quotient scales the quantity. A quantity of another dimension is +// incommensurable whatever its magnitude. +func TestQuantityAgainstBareNumber(t *testing.T) { + ctx, scope := quantityContext(t) + values := map[string]string{ + "3 [m] > 0": "true", + "3 [m] < 0": "false", + "3 [m] >= 0": "true", + "3 [m] <= 0": "false", + "3 [m] == 0": "false", + "3 [m] != 0": "true", + "0 [m] == 0": "true", + "0 [m] != 0.0": "false", + "-2 [m] < 0": "true", + "0 < 3 [m]": "true", + "0.0 >= 3 [m]": "false", + "0 == 3 [m]": "false", + "0 != 3 [s]": "true", + "3 [m] * 2": "6 [m]", + "3 [m] / 2": "1.5 [m]", + "2 * 3 [m]": "6 [m]", + "3 [m] > 0 or 1 [s] > 0": "true", + } + for src, want := range values { + t.Run(src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, src) + if err != nil { + t.Fatalf("%s: %v", src, err) + } + if FormatValue(got) != want { + t.Errorf("%s = %s, want %s", src, FormatValue(got), want) + } + }) + } + for _, src := range []string{ + "3 [m] > 1", "3 [m] < 1", "3 [m] >= 3", "3 [m] <= 3", "3 [m] == 3", "3 [m] != 3", + "1 < 3 [m]", "3 [m] + 1", "3 [m] - 1", "1 + 3 [m]", "3 [m] + 0", "3 [m] - 0", + "3 [m] > 0 [s]", "0 [m] == 0 [s]", "3 [m] + 0 [s]", + } { + t.Run(src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, src) + if !errors.Is(err, ErrIncommensurableUnits) { + t.Fatalf("%s = %v, %v; want ErrIncommensurableUnits", src, got, err) + } + }) + } +} + +// TestQuantityExponentiation raises quantities to constant exponents. The +// magnitude comes from semantics.Pow, the implementation `**` shares with the +// folder and the scalar path, so Integer operands with a non-negative exponent +// keep an Integer magnitude while the unit is raised as a real exponent. +func TestQuantityExponentiation(t *testing.T) { + ctx, scope := quantityContext(t) + + cases := []struct { + src string + want string // rendered value + wantKind semantics.ValueKind + }{ + {"(2 [m]) ** 3", "8 [SI::'m³']", semantics.ValInt}, + {"(2.0 [m]) ** 3", "8.0 [SI::'m³']", semantics.ValReal}, + {"(3.0 [m/s]) ** 2.0", "9.0 [SI::'m²⋅s⁻²']", semantics.ValReal}, + {"(2.0 [m]) ** -1", "0.5 [SI::'m⁻¹']", semantics.ValReal}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, tc.src) + if err != nil { + t.Fatalf("%s: %v", tc.src, err) + } + if got.Kind != ValQuantity { + t.Fatalf("%s = %v (%s), want a quantity", tc.src, got, got.Kind) + } + if got.Quantity().String() != tc.want { + t.Errorf("%s = %s, want %s", tc.src, got.Quantity(), tc.want) + } + if got.Quantity().Num.Kind != tc.wantKind { + t.Errorf("%s magnitude is %v, want %v", tc.src, got.Quantity().Num.Kind, tc.wantKind) + } + }) + } +} + +// TestQuantityExponentiationReports: the magnitude of an exponentiated quantity +// obeys the same domain and range as a bare number's, so an undefined or +// non-finite result is reported rather than carried as an Inf or a NaN in a +// unit. +func TestQuantityExponentiationReports(t *testing.T) { + ctx, scope := quantityContext(t) + + cases := []struct { + src string + wantErr error + }{ + {"(0.0 [m]) ** -1.0", semantics.ErrArithmeticDomain}, + {"(-2.0 [m]) ** 0.5", semantics.ErrArithmeticDomain}, + {"(1.0e300 [m]) ** 3.0", semantics.ErrArithmeticOverflow}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, tc.src) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("%s = %v, %v; want %v", tc.src, got, err, tc.wantErr) + } + }) + } +} + +// TestBareNumberSum: a sum or difference answers in the left operand's unit, and a +// bare number's unit is none, so it answers a bare number rather than `3 [1]`. +func TestBareNumberSum(t *testing.T) { + ctx, scope := quantityContext(t) + + cases := []struct { + src string + want string + }{ + {"1 [rad] + 2", "3 [rad]"}, + {"2 + 1 [rad]", "3"}, + {"2 - 1 [rad]", "1"}, + {"2 + 1 [m/m]", "3"}, + {"2.5 + 1 ['°']", "2.51745329"}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, tc.src) + if err != nil { + t.Fatalf("%s: %v", tc.src, err) + } + if FormatValue(got) != tc.want { + t.Errorf("%s = %s, want %s", tc.src, FormatValue(got), tc.want) + } + }) + } +} + +// TestComposedUnitCanonical: a composed unit displays in the coherent unit of its +// dimension, its scale folded, while a written unit (`N*m`, `km/h`) keeps its spelling. +func TestComposedUnitCanonical(t *testing.T) { + ctx, scope := quantityContext(t) + + cases := []struct { + src string + want string + }{ + {"3 [m] * 3 [m]", "9 [SI::'m²']"}, + {"(3 [m]) ** 2", "9 [SI::'m²']"}, + {"3 [m] * 3 [m] / 3 [m]", "3.0 [SI::m]"}, + {"(3 [m]) ** 2 / 3 [m]", "3.0 [SI::m]"}, + {"2 [m] * 2 [SI::m]", "4 [SI::'m²']"}, + {"2 [SI::m] * 2 [m]", "4 [SI::'m²']"}, + {"2 [SI::metre] * 2 [SI::m]", "4 [SI::'m²']"}, + {"2 [metre] * 2 [SI::m]", "4 [SI::'m²']"}, + {"1 [N] * 2 [m]", "2 [SI::'kg⋅m²⋅s⁻²']"}, + {"2 [N*m]", "2 [N*m]"}, + {"1 [N*m] * 2 [m]", "2 [kg*m**3/s**2]"}, + {"36 [km/h] / 2 [h]", "0.001388888888888889 [SI::'m⋅s⁻²']"}, + {"1 [m/s] * 1 [kg/s]", "1 [SI::N]"}, + {"1 [m/s] / 1 [kg/s]", "1.0 [m/kg]"}, + {"6 [m] / 2 [s] / 3 [kg]", "1.0 [m/(kg*s)]"}, + {"2 [m] * 1 [N]", "2 [SI::'kg⋅m²⋅s⁻²']"}, + {"2 [rad] * 3 [m]", "6 [m*rad]"}, + {"2 [rad] * 3", "6 [rad]"}, + {"(2.0 [m]) ** -1", "0.5 [SI::'m⁻¹']"}, + {"(4 [m*m]) ** 0.5", "2.0 [m]"}, + {"2 [m/s] * 2", "4 [SI::'m/s']"}, + {"2 * 2 [m/s]", "4 [SI::'m/s']"}, + {"2 [m/s] / 2", "1.0 [SI::'m/s']"}, + {"1 [m] + 2 [m]", "3 [m]"}, + {"1 [km] + 500 [m]", "1.5 [km]"}, + {"1 [km] + 1000 [m]", "2.0 [km]"}, + {"-(2 [m])", "-2 [m]"}, + {"1 ['A/m']", "1 ['A/m']"}, + {"1 ['A/m'] * 2 [m]", "2 [SI::A]"}, + {"(2 ['A/m']) ** 2", "4 [A**2/m**2]"}, + {"6 [m] / 2 ['A/m']", "3.0 [m**2/A]"}, + {"1 [SI::'A/m'] * 2 [m]", "2 [SI::A]"}, + {"1 ['A/m²'] * 2 ['A/m']", "2 [A**2/m**3]"}, + {"90 ['°'] * 2 ['°']", "180 ['°'**2]"}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalIn(t, ctx, scope, tc.src) + if err != nil { + t.Fatalf("%s: %v", tc.src, err) + } + if got.Kind != ValQuantity { + t.Fatalf("%s = %v (%s), want a quantity", tc.src, got, got.Kind) + } + if got.Quantity().String() != tc.want { + t.Errorf("%s = %s, want %s", tc.src, got.Quantity(), tc.want) + } + }) + } +} + +// TestUnitProductRendering renders unit products built directly, covering the +// grouping a unit expression needs to read back as the unit it names. +func TestUnitProductRendering(t *testing.T) { + named := func(name string) semantics.UnitProduct { return semantics.NamedUnitProduct(nil, name, false) } + metre, otherMetre := &symbols.Symbol{Name: "metre"}, &symbols.Symbol{Name: "metre"} + resolved := func(sym *symbols.Symbol, name string) semantics.UnitProduct { + return semantics.NamedUnitProduct(sym, name, false) + } + baseUnit := func(sym *symbols.Symbol) semantics.UnitTerm { + return semantics.UnitTerm{Scale: semantics.UnitScale(1), Factors: []semantics.UnitFactor{{Unit: sym, Exponent: 1}}} + } + second := &symbols.Symbol{Name: "second"} + opaque := func(name string, reduces semantics.UnitTerm) semantics.UnitProduct { + return semantics.OpaqueUnitProduct(name, reduces) + } + cases := []struct { + name string + unit semantics.UnitProduct + want string + }{ + {"empty is one", semantics.UnitProduct{}, "1"}, + {"square", named("m").Times(named("m")), "m**2"}, + {"cancelled", named("m").Times(named("m")).DividedBy(named("m")), "m"}, + {"fully cancelled", named("m").DividedBy(named("m")), "1"}, + {"reciprocal", named("m").Pow(-1), "1/m"}, + {"reciprocal square", named("s").Pow(-2), "1/s**2"}, + {"grouped denominator", named("m").DividedBy(named("s")).DividedBy(named("kg")), "m/(kg*s)"}, + {"fractional power", named("m").Pow(0.5), "m**0.5"}, + {"composed name quoted", named("km/h").Pow(2), "'km/h'**2"}, + {"quoted name is one unit", named("'A/m'").Pow(2), "'A/m'**2"}, + {"quoted name qualified", named("SI::'A/m'").Times(named("m")), "SI::'A/m'*m"}, + {"quoted name divided by", named("m").DividedBy(named("'A/m'")), "m/'A/m'"}, + {"quoted name in a composed name escaped", named("'A/m'*m").Pow(2), `'\'A/m\'*m'**2`}, + {"name holding a quote escaped", named("it's").Times(named("m")), `'it\'s'*m`}, + {"name holding a newline escaped", named("metres\nper second").Times(named("m")), `'metres\nper second'*m`}, + {"reduction text quoted", named("1000/1·SI::metre").Pow(2), "'1000/1·SI::metre'**2"}, + {"sorted by name", named("s").Times(named("m")).Times(named("s")), "m*s**2"}, + {"operand order does not matter", named("m").Times(named("N")), "N*m"}, + {"one unit under two spellings", resolved(metre, "m").Times(resolved(metre, "SI::m")), "m**2"}, + {"one unit under two spellings, commuted", resolved(metre, "SI::m").Times(resolved(metre, "m")), "m**2"}, + {"one unit under two spellings, equally qualified", resolved(metre, "SI::metre").Times(resolved(metre, "SI::m")), "SI::m**2"}, + {"one unit under two spellings, divided", resolved(metre, "SI::m").Pow(2).DividedBy(resolved(metre, "m")), "m"}, + {"two units under one spelling", resolved(metre, "m").Times(resolved(otherMetre, "m")), "m*m"}, + {"an unresolved unit is not the resolved one it is spelt like", resolved(metre, "m").DividedBy(named("m")), "m/m"}, + {"two opaque units under one spelling reducing alike", opaque("foo", baseUnit(metre)).Times(opaque("foo", baseUnit(metre))), "foo**2"}, + {"two opaque units under one spelling reducing apart", opaque("foo", baseUnit(metre)).Times(opaque("foo", baseUnit(second))), "foo*foo"}, + {"two opaque units under one spelling reducing apart do not cancel", opaque("foo", baseUnit(metre)).DividedBy(opaque("foo", baseUnit(second))), "foo/foo"}, + {"an opaque unit of unknown reduction is the one so spelt", opaque("foo", baseUnit(metre)).DividedBy(named("foo")), "1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.unit.String(); got != tc.want { + t.Errorf("%+v renders %q, want %q", tc.unit, got, tc.want) + } + }) + } +} + +// TestFormatTraceValueQuantity: a trace of a unit-carrying value names the +// unit, since a magnitude alone answers nothing about what was computed. +func TestFormatTraceValueQuantity(t *testing.T) { + metre := Unit{Text: "m/s", Term: semantics.UnitTerm{Scale: semantics.UnitScale(1)}} + cases := []struct { + name string + val Value + want string + }{ + {"real magnitude", NewQuantityValue(&Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: 1.5}, Unit: metre}), "1.5 [m/s]"}, + {"whole real magnitude", NewQuantityValue(&Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: 5}, Unit: metre}), "5.0 [m/s]"}, + {"integer magnitude", NewQuantityValue(&Quantity{ + Num: semantics.Value{Kind: semantics.ValInt, Int: 5}, Unit: metre}), "5 [m/s]"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := FormatTraceValue(tc.val); got != tc.want { + t.Errorf("FormatTraceValue(%s) = %q, want %q", tc.name, got, tc.want) + } + }) + } +} + +// TestSequenceIndexIsNotAQuantity: `seq#(i)` shares its node with a quantity +// expression but is a different operation — it indexes the sequence rather than +// reading the index as a measurement unit, and an index it cannot answer is +// reported as an index error and not as a malformed quantity. +func TestSequenceIndexIsNotAQuantity(t *testing.T) { + ctx, scope := quantityContext(t) + + got, err := evalIn(t, ctx, scope, "speeds#(2)") + if err != nil { + t.Fatalf("speeds#(2): %v", err) + } + if got.Kind != ValConst || got.Const.Kind != semantics.ValReal || got.Const.Real != 2.0 { + t.Errorf("speeds#(2) = %v, want the real 2.0", got) + } + + if _, err := evalIn(t, ctx, scope, "speeds#(4)"); !errors.Is(err, ErrIndexOutOfRange) { + t.Errorf("speeds#(4) error = %v, want ErrIndexOutOfRange", err) + } + if _, err := evalIn(t, ctx, scope, "speeds#(4)"); errors.Is(err, ErrNotAQuantity) { + t.Errorf("a sequence index is not a malformed quantity: %v", err) + } +} + +// TestQuantityAndIndexNotationsCoexist pins both meanings of the shared node in +// one place: the bracket form is a quantity, the parenthesized form an index, +// and a model can write the two of them in one expression. +func TestQuantityAndIndexNotationsCoexist(t *testing.T) { + ctx, scope := quantityContext(t) + + quantity, err := evalIn(t, ctx, scope, "5 [m]") + if err != nil { + t.Fatalf("5 [m]: %v", err) + } + if quantity.Kind != ValQuantity || quantity.Quantity().String() != "5 [m]" { + t.Errorf("5 [m] = %v (%s), want the quantity 5 [m]", quantity, quantity.Kind) + } + + // The index of a sequence of quantities is a quantity, so the two notations + // compose: `(1 [m], 2 [m])#(2)` is `2 [m]`. + indexed, err := evalIn(t, ctx, scope, "(1 [m], 2 [m])#(2)") + if err != nil { + t.Fatalf("(1 [m], 2 [m])#(2): %v", err) + } + if indexed.Kind != ValQuantity || indexed.Quantity().String() != "2 [m]" { + t.Errorf("(1 [m], 2 [m])#(2) = %v (%s), want the quantity 2 [m]", indexed, indexed.Kind) + } + + // An index that is not a whole number is an index error, not a unit: the + // notation `speeds#(1.5)` names no position of the sequence. + if _, err := evalIn(t, ctx, scope, "speeds#(1.5)"); !errors.Is(err, ErrTypeMismatch) { + t.Errorf("speeds#(1.5) error = %v, want ErrTypeMismatch", err) + } + // A unit named where an index belongs stays a quantity error, since the + // bracket says the expression is a quantity. + if _, err := evalIn(t, ctx, scope, "speeds [m]"); !errors.Is(err, ErrNotAQuantity) { + t.Errorf("speeds [m] error = %v, want ErrNotAQuantity", err) + } +} + +// TestNotAQuantityHintIsStatic: a bracket naming no unit reports the index +// notation over an operand declared a collection, without evaluating the operand — +// the diagnostic materializes no object and runs no calc. +func TestNotAQuantityHintIsStatic(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + private import ScalarValues::*; + private import Collections::*; + private import VectorFunctions::*; + attribute notAUnit = 3.0; + attribute grid : Array { + :>> dimensions = (2, 2); + :>> elements = (1, 2, 3, 4); + } + attribute v : VectorValues::CartesianVectorValue = VectorOf((1, 2)); + attribute speeds : Real[3] = (1.0, 2.0, 3.0); + attribute one : Real = 1.0; + calc def pick { return : Array = grid; } + } + `)) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + const hint = "with `#(…)`" + for _, tc := range []struct{ src, want string }{ + {"grid [notAUnit]", "index an array " + hint}, + {"v [notAUnit]", "index a vector " + hint}, + {"speeds [notAUnit]", "index a sequence " + hint}, + {"one [notAUnit]", ""}, + {"pick() [notAUnit]", ""}, + {"1.5 [notAUnit]", ""}, + } { + before := len(ctx.InstanceIDs()) + _, err := evalIn(t, ctx, pkg.Scope, tc.src) + if !errors.Is(err, ErrNotAQuantity) { + t.Errorf("%s: error = %v, want ErrNotAQuantity", tc.src, err) + continue + } + if tc.want == "" && strings.Contains(err.Error(), hint) { + t.Errorf("%s: %v hints at indexing an operand not declared a collection", tc.src, err) + } + if tc.want != "" && !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: %v, want it to say %q", tc.src, err, tc.want) + } + if after := len(ctx.InstanceIDs()); after != before { + t.Errorf("%s: materialized %d object(s) while reporting the error", tc.src, after-before) + } + } + // The objects the operands name are materialized by evaluating them, not by the diagnostic. + if _, err := evalIn(t, ctx, pkg.Scope, "pick()"); err != nil { + t.Fatalf("pick(): %v", err) + } + if len(ctx.InstanceIDs()) == 0 { + t.Error("pick() materialized no object: the effect the diagnostic must not have is not observable") + } +} diff --git a/internal/exec/runtime/quiescence_test.go b/internal/exec/runtime/quiescence_test.go new file mode 100644 index 0000000000..9883685f6a --- /dev/null +++ b/internal/exec/runtime/quiescence_test.go @@ -0,0 +1,228 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +const quiescentLampSource = ` + private import ScalarValues::*; + private import SI::*; + attribute def go; + state def Lamp { + entry; then off; + state off; + transition first off accept go then on; + state on; + } + part def Bulb { + attribute level : Integer = 0; + exhibit state lamp : Lamp; +} +` + +const quiescentTimerSource = ` + private import ScalarValues::*; + private import SI::*; + state def Watch { + entry; then waiting; + state waiting; + transition first waiting accept after 1 [s] then done; + state done; + } + part def Winder { exhibit state watch : Watch; } +` + +func quiescentBulb(t *testing.T, src, doc, part string) (*Context, *Instance, *symbols.Scope) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, doc, parseAndBuild(t, src)) + root := idx.DocumentRoot(doc) + inst, err := ctx.Instantiate(resolveSymbol(t, root, part)) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + return ctx, inst, root +} + +// A signal posted into a context a scan found idle is seen as pending work +// again: the next run advances the machine it wakes. +func TestQuiescenceBrokenByPostedSignal(t *testing.T) { + ctx, bulb, root := quiescentBulb(t, quiescentLampSource, "quiescent_lamp.sysml", "Bulb") + if !ctx.quiescent.holds(ctx) { + t.Fatalf("context not quiescent after materialization: work=%d memo=%+v", ctx.work, ctx.quiescent) + } + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatal("the bulb exhibits no machine") + } + exec := behavior.State + + msg, err := ctx.SignalMessage(resolveSymbol(t, root, "go"), nil, bulb) + if err != nil { + t.Fatalf("SignalMessage(go): %v", err) + } + ctx.PostMessage(msg) + if ctx.quiescent.holds(ctx) { + t.Fatal("the posted signal left the context quiescent") + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if got := activeLeaf(exec); got != "on" { + t.Fatalf("state after go = %s, want on", got) + } +} + +// A clock moved past a wait's due instant is seen as pending work again: the +// advance fires the transition the wait armed. +func TestQuiescenceBrokenByClockAdvance(t *testing.T) { + ctx, winder, _ := quiescentBulb(t, quiescentTimerSource, "quiescent_watch.sysml", "Winder") + if !ctx.quiescent.holds(ctx) { + t.Fatalf("context not quiescent after materialization: work=%d memo=%+v", ctx.work, ctx.quiescent) + } + behavior, ok := winder.ExhibitedState() + if !ok { + t.Fatal("the winder exhibits no machine") + } + exec := behavior.State + if _, err := ctx.Advance(2); err != nil { + t.Fatalf("Advance(2): %v", err) + } + if got := activeLeaf(exec); got != "done" { + t.Fatalf("state after advancing past the timer = %s, want done", got) + } +} + +// A run begun while the context is quiescent counts as work itself: the run +// can leave a machine's new state accepting what the bus already holds. +func TestQuiescenceBrokenByExecutorRun(t *testing.T) { + ctx, bulb, _ := quiescentBulb(t, quiescentLampSource, "quiescent_lamp.sysml", "Bulb") + if !ctx.quiescent.holds(ctx) { + t.Fatalf("context not quiescent after materialization: work=%d memo=%+v", ctx.work, ctx.quiescent) + } + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatal("the bulb exhibits no machine") + } + if err := behavior.State.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if ctx.quiescent.holds(ctx) { + t.Fatal("an executor run left the context quiescent") + } +} + +// A restore puts back the bus, clock and behaviors as they stood: work the +// snapshot saw is work again, so a context found idle since scans once more. +func TestQuiescenceBrokenBySnapshotRestore(t *testing.T) { + ctx, bulb, root := quiescentBulb(t, quiescentLampSource, "quiescent_lamp.sysml", "Bulb") + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatal("the bulb exhibits no machine") + } + exec := behavior.State + + msg, err := ctx.SignalMessage(resolveSymbol(t, root, "go"), nil, bulb) + if err != nil { + t.Fatalf("SignalMessage(go): %v", err) + } + ctx.PostMessage(msg) + snap, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if got := activeLeaf(exec); got != "on" { + t.Fatalf("state after go = %s, want on", got) + } + if err := ctx.drainObjectBehaviors(); err != nil { + t.Fatalf("drain: %v", err) + } + if !ctx.quiescent.holds(ctx) { + t.Fatalf("context not quiescent after drain: work=%d memo=%+v", ctx.work, ctx.quiescent) + } + snap.Restore() + if ctx.quiescent.holds(ctx) { + t.Fatal("the restore left the context quiescent") + } + if err := ctx.drainObjectBehaviors(); err != nil { + t.Fatalf("drain after restore: %v", err) + } + if got := activeLeaf(exec); got != "on" { + t.Fatalf("state after restored go = %s, want on", got) + } +} + +// A scan whose poll read the objects' data holds only until the next write; +// one that read nothing holds across writes, as quiescence is meant to. +func TestQuiescenceInvalidatedByWritesOnlyWhenTheScanReadData(t *testing.T) { + ctx, bulb, root := quiescentBulb(t, quiescentLampSource, "quiescent_lamp.sysml", "Bulb") + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatal("the bulb exhibits no machine") + } + exec := behavior.State + if !ctx.quiescent.holds(ctx) { + t.Fatalf("context not quiescent after materialization: memo=%+v", ctx.quiescent) + } + if err := bulb.SetFeatureValue(ctx, "level", intArgument(3)); err != nil { + t.Fatalf("SetFeatureValue: %v", err) + } + if !ctx.quiescent.holds(ctx) { + t.Fatal("a write invalidated a scan that read no data") + } + + // A cached poll that read data reports the read to a poll enclosing it, so + // a scan memoizing over it marks its quiescence data-dependent. + exec.pendingSignal() + exec.pending.readsData = true + exec.pending.writes = ctx.writes + probe := &pendingMemo{} + saved := ctx.polling + ctx.polling = probe + exec.pendingSignal() + ctx.polling = saved + if !probe.readsData { + t.Fatal("a cached data-reading poll did not report its read to the enclosing poll") + } + + // Marked as data-reading, the memo no longer covers the write: the work the + // write drives rescans and records a fresh quiescence instead of standing on it. + ctx.quiescent.readsData = true + stale := ctx.quiescent + if err := bulb.SetFeatureValue(ctx, "level", intArgument(4)); err != nil { + t.Fatalf("SetFeatureValue: %v", err) + } + if ctx.quiescent == stale { + t.Fatal("a write left a data-reading scan's quiescence standing") + } + _ = root +} + +// A store on an object with nothing to run allocates no run bookkeeping: the +// behavior scan it brackets finds no behaviors to scan. +func TestIdleStoreAllocatesNoRunBookkeeping(t *testing.T) { + const doc = "idle_store.sysml" + const src = ` + private import ScalarValues::*; + part def Box { attribute level : Integer = 0; } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, doc, parseAndBuild(t, src)) + inst, err := ctx.Instantiate(resolveSymbol(t, idx.DocumentRoot(doc), "Box")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + vals := [2]Value{constInt(1), constInt(2)} + i := 0 + if got := testing.AllocsPerRun(1000, func() { + i++ + if err := inst.SetFeatureValue(ctx, "level", vals[i&1]); err != nil { + t.Fatal(err) + } + }); got > 9 { + t.Fatalf("%.0f allocations per idle store, want <= 9", got) + } +} diff --git a/internal/exec/runtime/random_functions.go b/internal/exec/runtime/random_functions.go new file mode 100644 index 0000000000..d260962bf5 --- /dev/null +++ b/internal/exec/runtime/random_functions.go @@ -0,0 +1,194 @@ +package runtime + +import ( + "fmt" + "math" + "math/rand/v2" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// RandomFunctionsFQN names the OpenSysML library of random functions. +const RandomFunctionsFQN = "RandomFunctions" + +// registerRandomFunctions registers the OpenSysML RandomFunctions library +// (internal/workspace/libs/stdlib/OpenSysML Libraries/RandomFunctions.kerml): each call +// is a draw from the run's modeled stream, recorded for the trace and the witness. +func registerRandomFunctions() { + registerContextFunction(RandomFunctionsFQN+"::uniform", []string{"lo", "hi"}, drawUniform) + registerContextFunction(RandomFunctionsFQN+"::uniformInteger", []string{"lo", "hi"}, drawUniformInteger, integerDomain, integerDomain) + registerContextFunction(RandomFunctionsFQN+"::triangular", []string{"lo", "mode", "hi"}, drawTriangular) + registerContextFunction(RandomFunctionsFQN+"::normal", []string{"mean", "sd"}, drawNormal) +} + +// drawCall spells the call a draw records: the function's own name over its +// arguments as evaluated, `uniform(1, 80)`. +func drawCall(name string, args []semantics.Value) string { + if i := strings.LastIndex(name, "::"); i >= 0 { + name = name[i+len("::"):] + } + parts := make([]string, len(args)) + for i, arg := range args { + parts[i] = formatDrawn(arg) + } + return name + "(" + strings.Join(parts, ", ") + ")" +} + +// drawUniform is RandomFunctions::uniform: a Real uniform on [lo, hi), lo at most +// hi; the fixed policies yield lo, hi (the bound, though no random draw reaches it) and their midpoint. +func drawUniform(ctx *Context, name string, args []semantics.Value) (semantics.Value, error) { + lo, hi := asReal(args[0]), asReal(args[1]) + if err := finiteBounds(name, args); err != nil { + return semantics.Value{}, err + } + if lo > hi { + return semantics.Value{}, fmt.Errorf("%w: uniform(%s, %s): lo exceeds hi", ErrRandomDomain, formatDrawn(args[0]), formatDrawn(args[1])) + } + return ctx.draw(drawCall(name, args), distribution{ + draw: func(rng *rand.Rand) semantics.Value { + // Rounding at the top of a wide range may land on hi; the interval excludes it. + return drawnReal(math.Min(between(lo, hi, rng.Float64()), math.Nextafter(hi, lo))) + }, + admits: realHalfOpen(lo, hi), + fixed: realPoints(lo, hi, between(lo, hi, 0.5)), + }) +} + +// drawUniformInteger is RandomFunctions::uniformInteger: an Integer uniform on +// [lo, hi], both ends included, lo at most hi; the span is counted unsigned so +// the whole Integer range stays exact. The average is the midpoint, a half +// rounded toward hi. +func drawUniformInteger(ctx *Context, name string, args []semantics.Value) (semantics.Value, error) { + lo, hi := args[0].Int, args[1].Int + if lo > hi { + return semantics.Value{}, fmt.Errorf("%w: uniformInteger(%d, %d): lo exceeds hi", ErrRandomDomain, lo, hi) + } + span := unsignedInt(hi) - unsignedInt(lo) + return ctx.draw(drawCall(name, args), distribution{ + draw: func(rng *rand.Rand) semantics.Value { + return semantics.Value{Kind: semantics.ValInt, Int: signedInt(unsignedInt(lo) + drawOffset(rng, span))} + }, + admits: func(v semantics.Value) bool { return v.Kind == semantics.ValInt && lo <= v.Int && v.Int <= hi }, + fixed: func(policy DrawPolicy) (semantics.Value, bool) { + var n int64 + switch policy { + case DrawMin: + n = lo + case DrawMax: + n = hi + case DrawAverage: + n = signedInt(unsignedInt(lo) + span/2 + span%2) + default: + return semantics.Value{}, false + } + return semantics.Value{Kind: semantics.ValInt, Int: n}, true + }, + }) +} + +// drawTriangular is RandomFunctions::triangular: a Real on [lo, hi] densest at +// mode, lo at most mode at most hi and lo below hi, drawn by the inverse of its +// distribution function; its average is the distribution's mean (lo + mode + hi) / 3. +func drawTriangular(ctx *Context, name string, args []semantics.Value) (semantics.Value, error) { + lo, mode, hi := asReal(args[0]), asReal(args[1]), asReal(args[2]) + if err := finiteBounds(name, args); err != nil { + return semantics.Value{}, err + } + if !(lo <= mode && mode <= hi && lo < hi) { + return semantics.Value{}, fmt.Errorf("%w: triangular(%s, %s, %s): needs lo <= mode <= hi with lo < hi", + ErrRandomDomain, formatDrawn(args[0]), formatDrawn(args[1]), formatDrawn(args[2])) + } + return ctx.draw(drawCall(name, args), distribution{ + draw: func(rng *rand.Rand) semantics.Value { + u, cut := rng.Float64(), fractionOf(lo, mode, hi) + if u < cut { + return drawnReal(between(lo, hi, math.Sqrt(u*cut))) + } + return drawnReal(between(lo, hi, 1-math.Sqrt((1-u)*(1-cut)))) + }, + admits: realWithin(lo, hi), + fixed: realPoints(lo, hi, lo/3+mode/3+hi/3), + }) +} + +// drawNormal is RandomFunctions::normal: a finite Real about mean with sd >= 0 +// (zero draws mean); a tail overflowing to infinity is drawn again. Its average is +// mean; with sd > 0 it has no least or greatest value, so `min` and `max` refuse it. +func drawNormal(ctx *Context, name string, args []semantics.Value) (semantics.Value, error) { + mean, sd := asReal(args[0]), asReal(args[1]) + if err := finiteBounds(name, args); err != nil { + return semantics.Value{}, err + } + if sd < 0 { + return semantics.Value{}, fmt.Errorf("%w: normal(%s, %s): sd is negative", ErrRandomDomain, formatDrawn(args[0]), formatDrawn(args[1])) + } + return ctx.draw(drawCall(name, args), distribution{ + draw: func(rng *rand.Rand) semantics.Value { + for { + if x := mean + sd*rng.NormFloat64(); !math.IsInf(x, 0) { + return drawnReal(x) + } + } + }, + admits: func(v semantics.Value) bool { + if sd == 0 { + return v.Kind == semantics.ValReal && v.Real == mean + } + return v.Kind == semantics.ValReal && !math.IsInf(v.Real, 0) && !math.IsNaN(v.Real) + }, + fixed: func(policy DrawPolicy) (semantics.Value, bool) { + if policy == DrawAverage || sd == 0 { + return drawnReal(mean), true + } + return semantics.Value{}, false + }, + }) +} + +// finiteBounds refuses a distribution parameter that is infinite or not a number: +// no distribution is bounded by it. +func finiteBounds(name string, args []semantics.Value) error { + for _, arg := range args { + if x := asReal(arg); math.IsInf(x, 0) || math.IsNaN(x) { + return fmt.Errorf("%w: %s: %s is not a finite number", ErrRandomDomain, drawCall(name, args), formatDrawn(arg)) + } + } + return nil +} + +// drawnReal is x as a Real value. +func drawnReal(x float64) semantics.Value { + return semantics.Value{Kind: semantics.ValReal, Real: x} +} + +// between is the point the fraction t in [0, 1] of the way from lo to hi, clamped +// to [lo, hi]; the span hi-lo is formed only where it is finite. +func between(lo, hi, t float64) float64 { + x := lo*(1-t) + hi*t + if span := hi - lo; !math.IsInf(span, 0) { + x = lo + t*span + } + return math.Max(lo, math.Min(x, hi)) +} + +// fractionOf is where x in [lo, hi] lies between lo and hi, in [0, 1], for lo < hi; +// halving keeps a span too wide for a float finite. +func fractionOf(lo, x, hi float64) float64 { + if span := hi - lo; !math.IsInf(span, 0) { + return (x - lo) / span + } + return (x/2 - lo/2) / (hi/2 - lo/2) +} + +// realWithin admits a Real on [lo, hi]: what a bounded distribution can draw. +func realWithin(lo, hi float64) func(v semantics.Value) bool { + return func(v semantics.Value) bool { return v.Kind == semantics.ValReal && lo <= v.Real && v.Real <= hi } +} + +// realHalfOpen admits a Real on [lo, hi), or lo alone when lo == hi: what uniform can draw. +func realHalfOpen(lo, hi float64) func(v semantics.Value) bool { + return func(v semantics.Value) bool { + return v.Kind == semantics.ValReal && lo <= v.Real && (v.Real < hi || v.Real == lo) + } +} diff --git a/internal/core/runtime/range.go b/internal/exec/runtime/range.go similarity index 96% rename from internal/core/runtime/range.go rename to internal/exec/runtime/range.go index 5de544d317..7a305401d6 100644 --- a/internal/core/runtime/range.go +++ b/internal/exec/runtime/range.go @@ -3,8 +3,8 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // A range is not a value kind of its own: the Kernel Function Library declares diff --git a/internal/core/runtime/range_test.go b/internal/exec/runtime/range_test.go similarity index 100% rename from internal/core/runtime/range_test.go rename to internal/exec/runtime/range_test.go diff --git a/internal/core/runtime/region_pseudostate_test.go b/internal/exec/runtime/region_pseudostate_test.go similarity index 99% rename from internal/core/runtime/region_pseudostate_test.go rename to internal/exec/runtime/region_pseudostate_test.go index 918f12845d..7b11e05e47 100644 --- a/internal/core/runtime/region_pseudostate_test.go +++ b/internal/exec/runtime/region_pseudostate_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // runStateMachine runs a machine to completion and returns its executor, so a diff --git a/internal/exec/runtime/replay.go b/internal/exec/runtime/replay.go new file mode 100644 index 0000000000..6b79713b64 --- /dev/null +++ b/internal/exec/runtime/replay.go @@ -0,0 +1,1324 @@ +package runtime + +import ( + "errors" + "fmt" + "maps" + "math" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// The `replay:` policy fixes a witness's input lines before the run's first move, follows +// its choice lines move for move, then picks as `reverse` does, still one token a step; a move the run cannot make where +// the witness makes it is refused, naming the move, as is an input the run cannot fix. + +// ErrReplayRefused is the typed error every refused replay move wraps. +var ErrReplayRefused = errors.New("replay refused") + +// ReplayError reports a witness move the run could not follow: which move, the +// move itself, and what the run faced instead. +type ReplayError struct { + // Move is the 1-based position of the move in the witness. + Move int + Choice ChoiceTaken + Faced string +} + +func (e *ReplayError) Error() string { + return fmt.Sprintf("%v: move %d (%s): %s", ErrReplayRefused, e.Move, e.Choice, e.Faced) +} + +// Is makes every ReplayError match ErrReplayRefused. +func (e *ReplayError) Is(target error) bool { return target == ErrReplayRefused } + +// ErrInvalidChoice is the typed error every unparseable choice line wraps. +var ErrInvalidChoice = errors.New("invalid choice") + +// ChoiceParseError reports a line of a witness that spells no choice, with why. +type ChoiceParseError struct { + // Line is the 1-based line of the witness, 0 for a line parsed on its own. + Line int + Text string + Reason string +} + +func (e *ChoiceParseError) Error() string { + if e.Line > 0 { + return fmt.Sprintf("%v: line %d %q: %s", ErrInvalidChoice, e.Line, e.Text, e.Reason) + } + return fmt.Sprintf("%v: %q: %s", ErrInvalidChoice, e.Text, e.Reason) +} + +// Is makes every ChoiceParseError match ErrInvalidChoice. +func (e *ChoiceParseError) Is(target error) bool { return target == ErrInvalidChoice } + +// ErrInvalidInput is the typed error every unparseable input line wraps. +var ErrInvalidInput = errors.New("invalid input") + +// InputParseError reports a line of a witness that spells no input, with why. +type InputParseError struct { + // Line is the 1-based line of the witness, 0 for a line parsed on its own. + Line int + Text string + Reason string +} + +func (e *InputParseError) Error() string { + if e.Line > 0 { + return fmt.Sprintf("%v: line %d %q: %s", ErrInvalidInput, e.Line, e.Text, e.Reason) + } + return fmt.Sprintf("%v: %q: %s", ErrInvalidInput, e.Text, e.Reason) +} + +// Is makes every InputParseError match ErrInvalidInput. +func (e *InputParseError) Is(target error) bool { return target == ErrInvalidInput } + +// ErrInvalidObject is the typed error every unparseable object line wraps. +var ErrInvalidObject = errors.New("invalid object") + +// ObjectParseError reports a line of a witness that names no object, with why. +type ObjectParseError struct { + // Line is the 1-based line of the witness, 0 for a line parsed on its own. + Line int + Text string + Reason string +} + +func (e *ObjectParseError) Error() string { + if e.Line > 0 { + return fmt.Sprintf("%v: line %d %q: %s", ErrInvalidObject, e.Line, e.Text, e.Reason) + } + return fmt.Sprintf("%v: %q: %s", ErrInvalidObject, e.Text, e.Reason) +} + +// Is makes every ObjectParseError match ErrInvalidObject. +func (e *ObjectParseError) Is(target error) bool { return target == ErrInvalidObject } + +// ErrWitnessObject is the typed error for an object a witness names that the +// replaying run has no object at the path of. +var ErrWitnessObject = errors.New("witness object") + +// WitnessObjectError reports an object a witness names by path that the run +// replaying it did not make. +type WitnessObjectError struct { + Object ObjectNamed + Reason string +} + +func (e *WitnessObjectError) Error() string { + return fmt.Sprintf("%v: %s: %s", ErrWitnessObject, e.Object, e.Reason) +} + +// Is makes every WitnessObjectError match ErrWitnessObject. +func (e *WitnessObjectError) Is(target error) bool { return target == ErrWitnessObject } + +// ErrWitnessInput is the typed error every witness input the run cannot fix wraps. +var ErrWitnessInput = errors.New("witness input refused") + +// WitnessInputError reports a witness input the run could not fix before its +// first move: the feature named, and why. +type WitnessInputError struct { + Feature string + Reason string +} + +func (e *WitnessInputError) Error() string { + return fmt.Sprintf("%v: %s: %s", ErrWitnessInput, e.Feature, e.Reason) +} + +// Is makes every WitnessInputError match ErrWitnessInput. +func (e *WitnessInputError) Is(target error) bool { return target == ErrWitnessInput } + +// InputTaken is one input a witness fixes before the run's first move: a feature of +// the action or its performer and its value, spelt `input = `. +type InputTaken struct { + Feature string + // Value is the value, when the witness was made in memory; ValInvalid for one + // read from a file, whose Written is evaluated where the action's defaults are. + Value Value + // Written is the value as the notation spells it: what a witness file holds. + Written string +} + +// InputOf is the input fixing feature at value, spelt as the value formats. +func InputOf(feature string, value Value) InputTaken { + return InputTaken{Feature: feature, Value: value, Written: FormatValue(value)} +} + +// String spells the input as a witness lists it and ParseInput reads it back. +func (in InputTaken) String() string { + feature := in.Feature + if labelNeedsQuoting(feature) || strings.ContainsAny(feature, " \t") { + feature = source.UnrestrictedNameText(feature) + } + return inputPrefix + feature + " = " + in.Written +} + +// inputPrefix opens an input line of a witness. +const inputPrefix = "input " + +// ParseInput reads one input as InputTaken.String spells it: `input = `, +// the value any expression the notation reads, evaluated when the run begins. +func ParseInput(text string) (InputTaken, error) { + text = strings.TrimSpace(text) + fail := func(reason string) (InputTaken, error) { + return InputTaken{}, &InputParseError{Text: text, Reason: reason} + } + rest, ok := strings.CutPrefix(text, inputPrefix) + if !ok { + return fail("an input line starts with `input `: input = ") + } + feature, mark, written, ok := readLabel(rest, " = ") + written = strings.TrimSpace(written) + quoted := strings.HasPrefix(rest, "'") + switch { + case !ok: + return fail("the feature's quoted name is left open") + case mark == "": + return fail("an input needs ` = ` between the feature and its value: input = ") + case feature == "" || !quoted && strings.ContainsAny(feature, " \t"): + return fail("an input names one feature of the action or its performer before ` = `") + case written == "": + return fail("an input needs a value after ` = `") + } + return InputTaken{Feature: feature, Written: written}, nil +} + +// ObjectNamed is an object the moves of a witness name by the number its run gave +// it, bound to its materialization path, `object #2 = Plant::spare#1`, so the run +// replaying the witness finds the object among its own. +type ObjectNamed struct { + ID int64 + Path string +} + +// String spells the binding as a witness lists it and ParseObject reads it back. +func (o ObjectNamed) String() string { + return fmt.Sprintf("%s%d = %s", objectPrefix, o.ID, o.Path) +} + +// Number is how the moves name the object: `object #`. +func (o ObjectNamed) Number() string { return objectPrefix + strconv.FormatInt(o.ID, 10) } + +// objectPrefix opens an object line of a witness, and names an object in a move. +const objectPrefix = "object #" + +// ParseObject reads one object binding as ObjectNamed.String spells it. +func ParseObject(text string) (ObjectNamed, error) { + text = strings.TrimSpace(text) + fail := func(reason string) (ObjectNamed, error) { + return ObjectNamed{}, &ObjectParseError{Text: text, Reason: reason} + } + rest, ok := strings.CutPrefix(text, objectPrefix) + if !ok { + return fail("an object line starts with `object #`: object # = ") + } + digits, path, found := strings.Cut(rest, " = ") + id, err := strconv.ParseInt(digits, 10, 64) + if !found || err != nil || id < 1 { + return fail("an object is numbered from 1 and bound with ` = `: object # = ") + } + path = strings.TrimSpace(path) + if path == "" { + return fail("the object's path is missing: object # = ") + } + return ObjectNamed{ID: id, Path: path}, nil +} + +// Witness is one schedule as a witness file holds it: the objects its moves name +// bound to their paths, the inputs the run fixes before its first move, the values +// its random draws took, the choices that fix the schedule as a replay follows them, +// and the trace the run leaves, as the trace recorder writes it. +type Witness struct { + Objects []ObjectNamed + Inputs []InputTaken + // DrawPolicy is the policy the draws were resolved under; the line is written + // only for a fixed policy, so a random run's witness reads as before. + DrawPolicy DrawPolicy + // ClockStep is the step, in seconds, the run's clock ticked by; the line is + // written only for a stepped clock, so a continuous run's witness reads as before. + ClockStep float64 + Draws []DrawTaken + Choices []ChoiceTaken + Trace string + // Property names the property false at the state the schedule reaches, or + // whose evaluation there fails as Fails says; empty for a state or a run's failure. + Property string + // Fails is the deadlock or failure the schedule ends in, as the executor + // spells it — or the property's evaluation raised; empty for a state the run goes on from. + Fails string +} + +// The lines closing a witness after its trace: the property it claims, the failure it ends in. +const ( + propertyPrefix = "property: " + failsPrefix = "fails: " +) + +// String renders the witness as a file holds it: its objects one per line, its +// inputs one per line, `draws by ` for a fixed draw policy, `clock steps by ` for a stepped clock, its +// draws one per line, its choices one per line — or `no choice points` — a blank line, the trace, and after a blank line the claims closing it: `property: ` +// for a property's, `fails: ` for a schedule ending in a failure, last. +func (w Witness) String() string { + var b strings.Builder + for _, o := range w.Objects { + b.WriteString(o.String()) + b.WriteByte('\n') + } + for _, in := range w.Inputs { + b.WriteString(in.String()) + b.WriteByte('\n') + } + if w.DrawPolicy.Fixed() { + b.WriteString(drawPolicyPrefix + w.DrawPolicy.String() + "\n") + } + if w.ClockStep > 0 { + b.WriteString(clockStepPrefix + semantics.FormatReal(w.ClockStep) + "\n") + } + for _, d := range w.Draws { + b.WriteString(d.String()) + b.WriteByte('\n') + } + if len(w.Choices) == 0 { + b.WriteString("no choice points\n") + } + for _, c := range w.Choices { + b.WriteString(c.String()) + b.WriteByte('\n') + } + b.WriteByte('\n') + b.WriteString(w.Trace) + if w.Property != "" || w.Fails != "" { + b.WriteString("\n") + } + if w.Property != "" { + b.WriteString("\n" + propertyPrefix + w.Property) + } + if w.Fails != "" { + b.WriteString("\n" + failsPrefix + w.Fails) + } + return b.String() +} + +// Empty reports whether the witness fixes no input, records no draw and takes no choice. +func (w Witness) Empty() bool { return len(w.Inputs) == 0 && len(w.Draws) == 0 && len(w.Choices) == 0 } + +// objectNumber matches an object a move names by number. +var objectNumber = regexp.MustCompile(regexp.QuoteMeta(objectPrefix) + `(\d+)`) + +// objectsNamed binds every object the choices name by number to its path in this +// context, in order of first mention, so a witness of them replays in another run. +func (ctx *Context) objectsNamed(choices []ChoiceTaken) []ObjectNamed { + var objects []ObjectNamed + seen := make(map[int64]bool) + name := func(label string) { + for _, m := range objectNumber.FindAllStringSubmatch(label, -1) { + id, err := strconv.ParseInt(m[1], 10, 64) + if err != nil || seen[id] { + continue + } + seen[id] = true + objects = append(objects, ObjectNamed{ID: id, Path: ctx.objectPath(id)}) + } + } + for _, c := range choices { + name(c.Where) + for _, alt := range c.Among { + name(alt) + } + name(c.Took) + } + return objects +} + +// ReplayPolicy is the `replay` policy over choices held in memory, fixing no +// input. Its spelling names no file, so it does not read back; write the +// witness out to name it. +func ReplayPolicy(choices []ChoiceTaken) SchedulePolicy { + return ReplayOf(Witness{Choices: choices}) +} + +// ReplayOf is the `replay` policy over a witness held in memory: its inputs are +// fixed before the first move of the run, its choices followed move for move. +func ReplayOf(w Witness) SchedulePolicy { + return SchedulePolicy{kind: scheduleReplay, replay: &replayScript{witness: cloneWitness(w)}} +} + +func cloneWitness(w Witness) Witness { + w.Objects, w.Inputs, w.Draws, w.Choices = slices.Clone(w.Objects), slices.Clone(w.Inputs), slices.Clone(w.Draws), slices.Clone(w.Choices) + return w +} + +// Replay returns the choices of a `replay` policy's witness, and whether the policy is one. +func (p SchedulePolicy) Replay() ([]ChoiceTaken, bool) { + if p.kind != scheduleReplay { + return nil, false + } + return slices.Clone(p.replay.witness.Choices), true +} + +// Witness returns the witness a `replay` policy follows, and whether the policy is one. +func (p SchedulePolicy) Witness() (Witness, bool) { + if p.kind != scheduleReplay { + return Witness{}, false + } + return cloneWitness(p.replay.witness), true +} + +// Unfollowed is the first witness move the last run under a `replay` policy could +// not make — one refused, or one left over when the run ended — as a ReplayError; +// nil when the run followed its witness whole or ran under another policy. +func (ctx *Context) Unfollowed() error { + return ctx.run.scheduler.unfollowed("the run ended") +} + +// Choice is the choice point as a witness lists it: what ChoiceTaken.String spells +// and ParseChoice reads back. +func (c ChoicePoint) Choice() ChoiceTaken { + taken := ChoiceTaken{Kind: c.Kind, Step: c.Step, Where: c.Where, Alternatives: len(c.Alternatives), Taken: c.Taken, Among: slices.Clone(c.Alternatives)} + if c.Taken >= 0 && c.Taken < len(c.Alternatives) { + taken.Took = c.Alternatives[c.Taken] + } + if c.Weighted() { + taken.Weights, taken.Drew, taken.Drawn = slices.Clone(c.Weights), c.Drew, c.Drawn + } + return taken +} + +// replayScript is the witness a replay policy follows and the file it was read from. +type replayScript struct { + file string + witness Witness +} + +// ParseChoices reads the choices of a witness header naming no object and spelling +// no input: as ChoiceTaken.String spells them, one per line or joined by `; `, +// ending at the first blank line after it; what follows is ignored. +func ParseChoices(text string) ([]ChoiceTaken, error) { + w, _, err := readHeader(text) + if err != nil { + return nil, err + } + if len(w.Objects) > 0 { + return nil, &ObjectParseError{Text: w.Objects[0].String(), Reason: "a witness naming objects is read by ParseWitness"} + } + if len(w.Inputs) > 0 { + return nil, &InputParseError{Text: w.Inputs[0].String(), Reason: "a witness with inputs is read by ParseWitness"} + } + if len(w.Draws) > 0 { + return nil, &DrawParseError{Text: w.Draws[0].String(), Reason: "a witness with draws is read by ParseWitness"} + } + return w.Choices, nil +} + +// ParseWitness reads a witness as Witness.String writes it: the header of input +// and choice lines, after the blank line ending it the trace, exact, and after a +// blank line ending that the claims closing it, if any. +func ParseWitness(text string) (Witness, error) { + w, _, err := readWitness(text) + return w, err +} + +// readWitness reads a witness as ParseWitness does and says whether the text has +// a header: one of `no choice points` alone spells a run with no choice to make. +func readWitness(text string) (Witness, bool, error) { + w, headed, err := readHeader(text) + if err != nil { + return Witness{}, headed, err + } + lines := strings.SplitAfter(text, "\n") + begun := false + for i, line := range lines { + if strings.TrimSpace(line) == "" { + if begun { + w.Trace = strings.Join(lines[i+1:], "") + w.readClaims() + break + } + continue + } + begun = true + } + return w, headed, nil +} + +// readClaims splits the claims closing the witness off its trace. +func (w *Witness) readClaims() { + if trace, claims, found := strings.Cut(w.Trace, "\n\n"+propertyPrefix); found { + w.Trace = trace + w.Property, w.Fails, _ = strings.Cut(strings.TrimSuffix(claims, "\n"), "\n"+failsPrefix) + return + } + if trace, fails, found := strings.Cut(w.Trace, "\n\n"+failsPrefix); found { + w.Trace, w.Fails = trace, strings.TrimSuffix(fails, "\n") + } +} + +// drawPolicyPrefix opens the witness line naming the draw policy of its draws. +const drawPolicyPrefix = "draws by " + +// clockStepPrefix opens the witness line naming the step its clock ticked by. +const clockStepPrefix = "clock steps by " + +// readHeader reads a witness header: object lines as ObjectNamed.String spells +// them, input lines as InputTaken.String spells them, a `draws by ` line, a `clock steps by ` line, +// draw lines as DrawTaken.String spells them, then choices as ChoiceTaken.String spells them, one per line or joined by `; `, ending at the +// first blank line after it. It says whether the text has a header. +func readHeader(text string) (w Witness, headed bool, err error) { + policied, stepped := false, false + for i, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + if headed { + break + } + continue + } + headed = true + if line == "no choice points" { + continue + } + if strings.HasPrefix(line, objectPrefix) { + o, err := ParseObject(line) + if err == nil && (len(w.Inputs) > 0 || len(w.Draws) > 0 || len(w.Choices) > 0) { + err = &ObjectParseError{Text: line, Reason: "objects come before the inputs and the moves, and before the draws"} + } + if err == nil && slices.ContainsFunc(w.Objects, func(seen ObjectNamed) bool { return seen.ID == o.ID }) { + err = &ObjectParseError{Text: line, Reason: o.Number() + " is bound twice"} + } + if err != nil { + var parse *ObjectParseError + if errors.As(err, &parse) { + parse.Line = i + 1 + } + return Witness{}, true, err + } + w.Objects = append(w.Objects, o) + continue + } + if strings.HasPrefix(line, inputPrefix) { + in, err := ParseInput(line) + if err == nil && (len(w.Draws) > 0 || len(w.Choices) > 0) { + err = &InputParseError{Text: line, Reason: "inputs come before the moves, and before the draws"} + } + if err != nil { + var parse *InputParseError + if errors.As(err, &parse) { + parse.Line = i + 1 + } + return Witness{}, true, err + } + w.Inputs = append(w.Inputs, in) + continue + } + if rest, ok := strings.CutPrefix(line, drawPolicyPrefix); ok { + policy, err := ParseDrawPolicy(rest) + if err == nil && (len(w.Draws) > 0 || len(w.Choices) > 0) { + err = &DrawParseError{Text: line, Reason: "the draw policy comes before the draws and the moves"} + } + if err == nil && policied { + err = &DrawParseError{Text: line, Reason: "the draw policy is named twice, and a witness draws by one"} + } + if err != nil { + var parse *DrawParseError + if !errors.As(err, &parse) { + parse = &DrawParseError{Text: line, Reason: err.Error()} + } + parse.Line = i + 1 + return Witness{}, true, parse + } + w.DrawPolicy, policied = policy, true + continue + } + if rest, ok := strings.CutPrefix(line, clockStepPrefix); ok { + step, err := ParseClockStep(rest) + if err == nil && step == 0 { + err = &ClockStepParseError{Text: line, Reason: "a continuous clock writes no clock step line"} + } + if err == nil && (len(w.Draws) > 0 || len(w.Choices) > 0) { + err = &ClockStepParseError{Text: line, Reason: "the clock step comes before the draws and the moves"} + } + if err == nil && stepped { + err = &ClockStepParseError{Text: line, Reason: "the clock step is named twice, and a run's clock steps by one"} + } + if err != nil { + var parse *ClockStepParseError + if !errors.As(err, &parse) { + parse = &ClockStepParseError{Text: line, Reason: err.Error()} + } + parse.Line = i + 1 + return Witness{}, true, parse + } + w.ClockStep, stepped = step, true + continue + } + if strings.HasPrefix(line, drawPrefix) { + d, err := ParseDraw(line) + if err == nil && len(w.Choices) > 0 { + err = &DrawParseError{Text: line, Reason: "draws come before the moves"} + } + if err != nil { + var parse *DrawParseError + if errors.As(err, &parse) { + parse.Line = i + 1 + } + return Witness{}, true, err + } + w.Draws = append(w.Draws, d) + continue + } + for _, part := range splitChoices(line) { + c, err := ParseChoice(part) + if err != nil { + var parse *ChoiceParseError + if errors.As(err, &parse) { + parse.Line = i + 1 + } + return Witness{}, true, err + } + w.Choices = append(w.Choices, c) + } + } + return w, headed, nil +} + +// ParseChoice reads one choice as ChoiceTaken.String spells it: `step N: T first of A, B`, +// `step N: decision D -> B`, `S -> T`, `W: X first of A, B` (a region order, a due +// order at `t=…`, or a dispatch order among `events at t=…`). +func ParseChoice(text string) (ChoiceTaken, error) { + text = strings.TrimSpace(text) + fail := func(reason string) (ChoiceTaken, error) { + return ChoiceTaken{}, &ChoiceParseError{Text: text, Reason: reason} + } + step, rest := 0, text + if after, ok := strings.CutPrefix(text, "step "); ok { + digits, tail, found := strings.Cut(after, markWhere) + n, err := strconv.Atoi(digits) + if !found || err != nil || n < 1 { + return fail("step needs a positive number and a colon: step : …") + } + step, rest = n, tail + } + first, mark, after, ok := readLabel(rest, markFirstOf, markArrow, markWhere) + if !ok { + return fail(unclosedQuote) + } + switch mark { + case markFirstOf, markWhere: + return parseOrderChoice(fail, step, first, mark, after) + case markArrow: + return parseTransitionChoice(fail, step, first, after) + } + return fail(notAChoice) +} + +// parseOrderChoice reads the order after ` first of ` (a step's token order) +// or `: first of ` (a region or due order) as ParseChoice found it. +func parseOrderChoice(fail func(string) (ChoiceTaken, error), step int, first, mark, after string) (ChoiceTaken, error) { + c := ChoiceTaken{Kind: ChoiceTokenOrder, Step: step, Took: first} + if mark == markWhere { + if step > 0 { + return fail("a step's order names the token first: step : first of …") + } + c.Kind, c.Where = ChoiceRegionOrder, first + switch { + case strings.HasPrefix(first, "t="): + c.Kind = ChoiceDueOrder + case strings.HasPrefix(first, dispatchWherePrefix): + c.Kind = ChoiceDispatchOrder + case strings.HasPrefix(first, enteringWherePrefix), strings.HasPrefix(first, forkWherePrefix): + c.Kind = ChoiceEntryOrder + case strings.HasPrefix(first, exitingWherePrefix): + c.Kind = ChoiceExitOrder + case strings.HasPrefix(first, entryStepWherePrefix): + c.Kind = ChoiceEntryStep + case strings.HasPrefix(first, stepWherePrefix): + c.Kind = ChoiceStepOrder + } + var ok bool + if c.Took, mark, after, ok = readLabel(after, markFirstOf); !ok { + return fail(unclosedQuote) + } + if mark == "" { + return fail(notAChoice) + } + } else if step == 0 { + return fail("an order outside a step needs where it was made: : first of …") + } + among, ok := splitLabels(after, markList) + if !ok { + return fail(unclosedQuote) + } + c.Among, c.Alternatives, c.Taken = among, len(among), slices.Index(among, c.Took) + if c.Taken < 0 { + return fail(fmt.Sprintf("%s is not among %s", choiceLabel(c.Took), choiceLabels(among))) + } + return c, nil +} + +// parseTransitionChoice reads ` -> `, a decision branch inside a step +// and a transition outside one, with the alternatives, weights and draw a weighted +// one carries after ` among `. +func parseTransitionChoice(fail func(string) (ChoiceTaken, error), step int, first, after string) (ChoiceTaken, error) { + took, mark, after, ok := readLabel(after, markAmong) + if !ok { + return fail(unclosedQuote) + } + if first == "" || took == "" { + return fail("a branch or transition needs both sides of ->") + } + c := ChoiceTaken{Kind: ChoiceTransition, Where: first, Took: took} + if step > 0 { + c.Kind, c.Step = ChoiceDecisionBranch, step + } + if mark == markAmong { + return parseWeighted(fail, c, after) + } + return c, nil +} + +// parseWeighted reads what follows ` among ` on a weighted line — ` p=, …` +// then ` drew ` when a draw selected the branch — into c. +func parseWeighted(fail func(string) (ChoiceTaken, error), c ChoiceTaken, text string) (ChoiceTaken, error) { + const shape = "a weighted branch lists every alternative with its weight: … among p=, p= drew " + for { + alt, mark, rest, ok := readLabel(text, markWeight) + if !ok { + return fail(unclosedQuote) + } + if mark == "" { + return fail(shape) + } + number, sep := rest, "" + if at, m := indexMark(rest, markList, markDrew); at >= 0 { + number, sep, rest = rest[:at], m, rest[at+len(m):] + } + w, err := strconv.ParseFloat(number, 64) + if err != nil { + return fail("a weight is a number: p=") + } + c.Among, c.Weights = append(c.Among, alt), append(c.Weights, w) + if sep == markList { + text = rest + continue + } + if sep == markDrew { + u, err := strconv.ParseFloat(rest, 64) + if err != nil || !(0 <= u && u < 1) { + return fail("the draw selecting a weighted branch is a number in [0, 1): … drew ") + } + c.Drew, c.Drawn = u, true + } + break + } + c.Alternatives, c.Taken = len(c.Among), slices.Index(c.Among, c.Took) + if c.Taken < 0 { + return fail(fmt.Sprintf("%s is not among %s", choiceLabel(c.Took), choiceLabels(c.Among))) + } + return c, nil +} + +// The names a choice line carries — tokens, branches, states, where the choice was +// made — are written as they are unless the line's own punctuation occurs in them; +// then the name is written quoted, 'like this', escaped as an unrestricted name is. + +// The marks a choice line's grammar reads as structure. +const ( + markChoices = "; " + markFirstOf = " first of " + markArrow = " -> " + markList = ", " + markWhere = ": " + markAmong = " among " + markWeight = " p=" + markDrew = " drew " +) + +// linePunctuation is what a choice line's grammar reads as structure. +var linePunctuation = []string{markChoices, markFirstOf, markArrow, markList, markWhere, markAmong, markWeight, markDrew} + +const ( + unclosedQuote = "a quoted name needs its closing quote, followed by the line's punctuation" + notAChoice = "not a token order, branch, transition, region, due or dispatch order" +) + +// choiceLabel spells a name as a choice line carries it. +func choiceLabel(name string) string { + if labelNeedsQuoting(name) { + return source.UnrestrictedNameText(name) + } + return name +} + +// choiceLabels spells a list of names as a choice line carries them. +func choiceLabels(names []string) string { + labels := make([]string, len(names)) + for i, name := range names { + labels[i] = choiceLabel(name) + } + return strings.Join(labels, markList) +} + +// labelNeedsQuoting reports a name a line could not read back as it is: empty, +// punctuated like the line, quote-led, step-led, or with whitespace to lose. +func labelNeedsQuoting(name string) bool { + if name == "" || name[0] == '\'' || strings.HasPrefix(name, "step ") || strings.TrimSpace(name) != name { + return true + } + for _, mark := range linePunctuation { + if strings.Contains(name, mark) { + return true + } + } + return strings.ContainsAny(name, "\n\r") +} + +// readLabel reads the name text starts with — a quoted one to its closing quote, a +// plain one to the first of the marks — with the mark that ended it ("" at the end of +// text) and what follows the mark; false for a quote left open or not followed by a mark. +func readLabel(text string, marks ...string) (name, mark, after string, ok bool) { + if !strings.HasPrefix(text, "'") { + at, mark := indexMark(text, marks...) + if at < 0 { + return text, "", "", true + } + return text[:at], mark, text[at+len(mark):], true + } + end := closingQuote(text, 0) + if end < 0 { + return "", "", "", false + } + name, after = source.StringValue(text[:end+1]), text[end+1:] + if after == "" { + return name, "", "", true + } + for _, m := range marks { + if rest, found := strings.CutPrefix(after, m); found { + return name, m, rest, true + } + } + return "", "", "", false +} + +// closingQuote is the index of the quote closing the name opened at text[open], +// past its backslash escapes; -1 when the name is left open. +func closingQuote(text string, open int) int { + for i := open + 1; i < len(text); i++ { + switch text[i] { + case '\\': + i++ + case '\'': + return i + } + } + return -1 +} + +// indexMark is where the first of the marks occurs in text outside its quoted names, +// with the mark; -1 when none does. A quote opens a name only where a name may begin. +func indexMark(text string, marks ...string) (int, string) { + for i := 0; i < len(text); i++ { + if text[i] == '\'' && nameMayBegin(text[:i]) { + if i = closingQuote(text, i); i < 0 { + return -1, "" + } + continue + } + for _, m := range marks { + if strings.HasPrefix(text[i:], m) { + return i, m + } + } + } + return -1, "" +} + +// nameMayBegin reports whether a name may begin after before: at the start of the +// text or right after the line's punctuation. +func nameMayBegin(before string) bool { + if before == "" { + return true + } + for _, mark := range linePunctuation { + if strings.HasSuffix(before, mark) { + return true + } + } + return false +} + +// splitChoices splits a line into the choices it joins by `; `, outside quoted names. +func splitChoices(line string) []string { + var parts []string + for { + at, _ := indexMark(line, markChoices) + if at < 0 { + return append(parts, line) + } + parts, line = append(parts, line[:at]), line[at+len(markChoices):] + } +} + +// splitLabels reads the names text lists separated by sep; false for a quote left open. +func splitLabels(text, sep string) ([]string, bool) { + var names []string + for { + name, mark, after, ok := readLabel(text, sep) + if !ok { + return nil, false + } + names = append(names, name) + if mark == "" { + return names, true + } + text = after + } +} + +// replayRun follows one run's witness: the objects its moves name to bind to the +// run's own, the inputs to fix before its first move, the draws to hand its random +// calls in turn, the moves left and the first it refused. +type replayRun struct { + // unbound are the objects the witness names that the run has not made yet; + // renumber maps the witness's numbers of those it has to the run's own. + unbound []ObjectNamed + renumber map[string]string + inputs []InputTaken + policy DrawPolicy + draws []DrawTaken + nextDraw int + choices []ChoiceTaken + next int + refused error + // ctx is the context whose run follows the witness. + ctx *Context +} + +func newReplayRun(w Witness) *replayRun { + return &replayRun{ + unbound: slices.Clone(w.Objects), + renumber: make(map[string]string, len(w.Objects)), + inputs: slices.Clone(w.Inputs), + policy: w.DrawPolicy, + draws: slices.Clone(w.Draws), + choices: slices.Clone(w.Choices), + } +} + +// bind binds the objects the witness names that the run has made by now, at their +// paths: a run makes an object when it first reaches it, which may be moves in. +func (r *replayRun) bind() { + if r.ctx == nil { + return + } + r.unbound = slices.DeleteFunc(r.unbound, func(o ObjectNamed) bool { + inst, err := r.ctx.objectAt(o.Path) + if err != nil { + return false + } + r.renumber[o.Number()] = objectPrefix + strconv.FormatInt(inst.ID, 10) + return true + }) +} + +// current is the next move with its objects numbered as this run numbers them, and +// the first object it names that the run has not made, nil when it names none. +func (r *replayRun) current() (ChoiceTaken, *ObjectNamed) { + r.bind() + c := r.choices[r.next] + c.Where, c.Took = r.relabel(c.Where), r.relabel(c.Took) + c.Among = slices.Clone(c.Among) + for i, alt := range c.Among { + c.Among[i] = r.relabel(alt) + } + for i, o := range r.unbound { + if c.names(o.Number()) { + return c, &r.unbound[i] + } + } + return c, nil +} + +// refuseUnbound refuses the witness at a move naming an object the run has not made. +func (r *replayRun) refuseUnbound(o ObjectNamed) { + if r.refused == nil { + _, err := r.ctx.objectAt(o.Path) + r.refused = &WitnessObjectError{Object: o, Reason: err.Error()} + } +} + +// relabel renumbers the objects a label names to the run's own numbers. +func (r *replayRun) relabel(label string) string { + return objectNumber.ReplaceAllStringFunc(label, func(number string) string { + if renumbered, ok := r.renumber[number]; ok { + return renumbered + } + return number + }) +} + +// names reports whether the choice's place or any alternative spells the object number. +func (c ChoiceTaken) names(number string) bool { + mentions := func(label string) bool { + for _, found := range objectNumber.FindAllString(label, -1) { + if found == number { + return true + } + } + return false + } + return mentions(c.Where) || mentions(c.Took) || slices.ContainsFunc(c.Among, mentions) +} + +// takeInputs hands the run's witness inputs to the performance beginning it, once. +func (r *replayRun) takeInputs() []InputTaken { + inputs := r.inputs + r.inputs = nil + return inputs +} + +// drawsByPolicy reports whether the witness leaves its draws to its fixed policy: it +// names one and records no draw, so each call resolves to its fixed point as the run would. +func (r *replayRun) drawsByPolicy() bool { + return r.policy.Fixed() && len(r.draws) == 0 +} + +// takeDraw hands the call what the witness's next recorded draw, which must be of +// the same call and a value the call admits; a draw the witness does not record, +// records for another call, or records outside the call's distribution refuses +// the witness and fails the call. +// takeDraw hands out the witness's next recorded draw for the call what, refusing +// one the call cannot make: under the witness's fixed policy, any but the fixed point. +func (r *replayRun) takeDraw(what string, dist distribution) (semantics.Value, error) { + if r.nextDraw >= len(r.draws) { + err := &WitnessDrawError{What: what, Reason: "the witness records no draw left for it"} + if r.refused == nil { + r.refused = err + } + return semantics.Value{}, err + } + d := r.draws[r.nextDraw] + if d.What != what { + err := &WitnessDrawError{Draw: r.nextDraw + 1, What: d.What, Reason: "the run drew " + what + " instead"} + if r.refused == nil { + r.refused = err + } + return semantics.Value{}, err + } + if !dist.admitsUnder(r.policy, d.Value) { + reason := "the witness records " + formatDrawn(d.Value) + ", which the call cannot draw" + if r.policy.Fixed() { + reason += " under " + r.policy.String() + } + err := &WitnessDrawError{Draw: r.nextDraw + 1, What: d.What, Reason: reason} + if r.refused == nil { + r.refused = err + } + return semantics.Value{}, err + } + r.nextDraw++ + return d.Value, nil +} + +// following reports whether moves are left to follow and none was refused. +func (r *replayRun) following() bool { + return r.refused == nil && r.next < len(r.choices) +} + +// refuse records the first move the run could not follow, with what it faced. +func (r *replayRun) refuse(faced string) { + if r.refused == nil { + r.refused = &ReplayError{Move: r.next + 1, Choice: r.choices[r.next], Faced: faced} + } +} + +// unfollowed is the refusal of a run that ended with moves or draws left, or that +// began no performance to fix its inputs on; nil otherwise. +func (r *replayRun) unfollowed(how string) error { + if r.refused == nil && len(r.inputs) > 0 { + r.refused = &WitnessInputError{Feature: r.inputs[0].Feature, Reason: how + " with no action performance begun to fix it on"} + } + if r.following() { + r.refuse(how) + } + if r.refused == nil && r.nextDraw < len(r.draws) { + d := r.draws[r.nextDraw] + r.refused = &WitnessDrawError{Draw: r.nextDraw + 1, What: d.What, Reason: how + " without drawing it"} + } + return r.refused +} + +// replayMove is one step under replay: the token the witness moves, or with no +// move at this step the tokens tried as an exploring step tries them. +type replayMove struct { + run *replayRun + step int + order []int64 + next int + moved bool + choice *ChoiceTaken + // enabled labels the tokens able to act, sorted by ID; taken indexes the one moved. + enabled []string + taken int + // kept names a token of the witness's move present but not yet able to act: the move waits for the clock's retry. + kept string +} + +// beginStep resolves the step by the witness's move at it: taken when each token named is able to act, kept for +// the clock's retry when one at most is and the rest are present but parked or held; a token absent is refused. +// Past the witness the step is still one token's move, the last able to act, as `reverse` orders them. +func (r *replayRun) beginStep(tokens stepTokens) *replayMove { + m := &replayMove{run: r, step: tokens.step} + var enabled, rest, held []int64 + for _, id := range tokens.ids { + switch { + case tokens.held[id]: + held = append(held, id) + case tokens.enabled(id): + enabled = append(enabled, id) + default: + rest = append(rest, id) + } + } + slices.Sort(enabled) + slices.Sort(rest) + slices.Sort(held) + m.enabled = make([]string, len(enabled)) + for i, id := range enabled { + m.enabled[i] = tokens.label(id) + } + if !r.following() { + if len(enabled) < 2 { + m.order = slices.Concat(enabled, rest, held) + return m + } + m.taken = len(enabled) - 1 + m.order = []int64{enabled[m.taken]} + return m + } + able := m.able() + r.hoistOrder(tokens) + current, unbound := r.current() + c := ¤t + // With one token at most able to act, an order over tokens this flow lacks is + // another performance's step of the same number, made within this move or after it. + if c.Kind == ChoiceTokenOrder && c.Step == tokens.step && (len(enabled) >= 2 || tokens.hasAll(c.Among)) { + if unbound != nil { + r.refuseUnbound(*unbound) + return m + } + for _, alt := range c.Among { + switch { + case slices.Contains(m.enabled, alt): + case len(enabled) < 2 && tokens.has(alt): + if m.kept == "" { + m.kept = alt + } + default: + r.refuse(fmt.Sprintf("step %d: %s is not able to act (%s)", tokens.step, alt, able)) + return m + } + } + if m.kept != "" { + m.order = slices.Concat(enabled, rest, held) + return m + } + m.taken = slices.Index(m.enabled, c.Took) + if m.taken < 0 { + r.refuse(fmt.Sprintf("step %d: %s is not able to act (%s)", tokens.step, c.Took, able)) + return m + } + r.next++ + m.choice = c + m.order = []int64{enabled[m.taken]} + return m + } + switch { + case len(enabled) < 2: + m.order = slices.Concat(enabled, rest, held) + case c.Step > 0 && c.Step < tokens.step: + r.refuse(fmt.Sprintf("the run is at step %d and step %d had no such move", tokens.step, c.Step)) + default: + r.refuse(fmt.Sprintf("step %d: the run must pick a token (%s) and the witness names none", tokens.step, able)) + } + return m +} + +// hoistOrder moves the step's token order, which a witness may spell after the +// choices the token's move made, ahead of them: it is resolved first when replaying. +func (r *replayRun) hoistOrder(tokens stepTokens) { + for j := r.next; j < len(r.choices); j++ { + c := r.choices[j] + if c.Step != tokens.step { + return + } + if c.Kind == ChoiceTokenOrder && tokens.hasAll(c.Among) { + copy(r.choices[r.next+1:j+1], r.choices[r.next:j]) + r.choices[r.next] = c + return + } + } +} + +// able spells the tokens able to act, as a refusal names them. +func (m *replayMove) able() string { + if len(m.enabled) == 0 { + return "none is able to act" + } + return "able to act: " + strings.Join(m.enabled, ", ") +} + +// ended settles a kept move: the clock retrying the step faces it then; a step ending any other way refuses it. +func (m *replayMove) ended(retry bool) { + if m.kept != "" && !retry { + m.run.refuse(fmt.Sprintf("step %d: %s is not able to act (%s)", m.step, m.kept, m.able())) + } +} + +// nextToken is the token to try next; false once one acted or none is left. +func (m *replayMove) nextToken() (int64, bool) { + if m.moved || m.next >= len(m.order) { + return 0, false + } + id := m.order[m.next] + m.next++ + return id, true +} + +// acted ends the step when the token acted; the witness's token not acting is a +// move the run could not make. +func (m *replayMove) acted(acted bool) { + if acted { + m.moved = true + return + } + if m.choice != nil { + m.run.refuse(fmt.Sprintf("step %d: %s did not act", m.step, m.choice.Took)) + } +} + +// reported is the token-order choice the step notes: the witness's when it names +// several, else the tokens able to act when several were and the one moved. +func (m *replayMove) reported() (alternatives []string, taken int, ok bool) { + if !m.moved { + return nil, 0, false + } + if m.choice != nil && len(m.choice.Among) >= 2 { + return m.choice.Among, m.choice.Taken, true + } + if len(m.enabled) >= 2 { + return m.enabled, m.taken, true + } + return nil, 0, false +} + +// choose resolves a pick among c.Alternatives by the witness's next move, which +// must be a choice of the same kind at the same place naming one of them; whereOf +// is the place as the run reports it once alternative i is taken, nil for c.Where. +func (r *replayRun) choose(c ChoicePoint, whereOf func(i int) string) int { + w, unbound := r.current() + if unbound != nil { + r.refuseUnbound(*unbound) + return 0 + } + alts := strings.Join(c.Alternatives, ", ") + taken := slices.Index(c.Alternatives, w.Took) + if whereOf != nil && taken >= 0 { + c.Where = whereOf(taken) + } + if w.Kind != c.Kind || w.Where != c.Where { + r.refuse("the run faced " + c.Describe()) + return 0 + } + if w.Step != c.Step { + r.refuse(fmt.Sprintf("the run is at step %d and step %d had no such move", c.Step, w.Step)) + return 0 + } + for _, alt := range w.Among { + if !slices.Contains(c.Alternatives, alt) { + r.refuse(fmt.Sprintf("%s is not enabled (enabled: %s)", alt, alts)) + return 0 + } + } + if taken < 0 { + r.refuse(fmt.Sprintf("%s is not enabled (enabled: %s)", w.Took, alts)) + return 0 + } + if (w.Weighted() || c.Weighted()) && !r.weighedAlike(w, c, taken) { + return 0 + } + r.next++ + return taken +} + +// chooseWeighted follows the witness's move at a weighted decision, the run's choice +// carrying the draw the witness records where one selected the branch. +func (r *replayRun) chooseWeighted(c *ChoicePoint, whereOf func(i int) string) int { + w, unbound := r.current() + taken := r.choose(*c, whereOf) + if unbound == nil && r.refused == nil && w.Drawn { + c.Drew, c.Drawn = w.Drew, true + } + return taken +} + +// weighedAlike reports whether the witness's weighted move w fits the decision the run +// faces: the same branches weighed the same, and a recorded draw that selects the branch +// taken; a move that does not fit is refused. +func (r *replayRun) weighedAlike(w ChoiceTaken, c ChoicePoint, taken int) bool { + if !c.Weighted() { + r.refuse("the run's decision weighs no branch") + return false + } + if !w.Weighted() || len(w.Among) != len(c.Alternatives) { + r.refuse("the run's decision weighs every branch: " + weightedLabels(c.Alternatives, c.Weights)) + return false + } + for i, alt := range w.Among { + if slices.Index(w.Among, alt) != i { + r.refuse(fmt.Sprintf("the move weighs %s twice: %s", choiceLabel(alt), choiceLabels(w.Among))) + return false + } + at := slices.Index(c.Alternatives, alt) + if at < 0 { + r.refuse(fmt.Sprintf("%s is not among the run's branches: %s", choiceLabel(alt), weightedLabels(c.Alternatives, c.Weights))) + return false + } + if got := c.Weights[at]; got != w.Weights[i] { + r.refuse(fmt.Sprintf("%s weighs p=%s, not p=%s", choiceLabel(alt), FormatWeight(got), FormatWeight(w.Weights[i]))) + return false + } + } + if !w.Drawn { + return true + } + if math.IsNaN(w.Drew) || w.Drew < 0 || w.Drew >= 1 { + r.refuse(fmt.Sprintf("the draw %s is no unit draw in [0, 1)", FormatWeight(w.Drew))) + return false + } + total := 0.0 + for _, weight := range c.Weights { + total += weight + } + if pick := weightedPick(c.Weights, total, w.Drew); pick != taken { + r.refuse(fmt.Sprintf("the draw %s selects %s, not %s", FormatWeight(w.Drew), choiceLabel(c.Alternatives[pick]), choiceLabel(w.Took))) + return false + } + return true +} + +// mark returns what a probe restores: the run's position in the witness and the +// objects it had bound, which the probe's run may have made and unmade. +func (r *replayRun) mark() func() { + next, nextDraw, refused := r.next, r.nextDraw, r.refused + unbound, renumber := slices.Clone(r.unbound), maps.Clone(r.renumber) + return func() { + r.next, r.nextDraw, r.refused, r.unbound, r.renumber = next, nextDraw, refused, unbound, renumber + } +} diff --git a/internal/core/runtime/replay_input_test.go b/internal/exec/runtime/replay_input_test.go similarity index 100% rename from internal/core/runtime/replay_input_test.go rename to internal/exec/runtime/replay_input_test.go diff --git a/internal/exec/runtime/replay_test.go b/internal/exec/runtime/replay_test.go new file mode 100644 index 0000000000..8348599c0b --- /dev/null +++ b/internal/exec/runtime/replay_test.go @@ -0,0 +1,2015 @@ +package runtime + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// Every kind of choice a witness lists reads back from the line that spells it. +func TestParseChoiceReadsEveryKind(t *testing.T) { + cases := []struct { + line string + want ChoiceTaken + }{ + {"step 3: 2@left first of 2@left, 3@right", ChoiceTaken{Kind: ChoiceTokenOrder, Step: 3, Alternatives: 2, Taken: 0, Among: []string{"2@left", "3@right"}, Took: "2@left"}}, + {"step 3: 3@right first of 2@left, 3@right", ChoiceTaken{Kind: ChoiceTokenOrder, Step: 3, Alternatives: 2, Taken: 1, Among: []string{"2@left", "3@right"}, Took: "3@right"}}, + {"step 5: decision select -> 2->alarm", ChoiceTaken{Kind: ChoiceDecisionBranch, Step: 5, Where: "decision select", Took: "2->alarm"}}, + {"state idle on accept go -> 2->right", ChoiceTaken{Kind: ChoiceTransition, Where: "state idle on accept go", Took: "2->right"}}, + {"on accept go: b1 first of a1, b1", ChoiceTaken{Kind: ChoiceRegionOrder, Where: "on accept go", Alternatives: 2, Taken: 1, Among: []string{"a1", "b1"}, Took: "b1"}}, + {"t=5.0: state machine c first of state machine a, state machine b, state machine c", ChoiceTaken{Kind: ChoiceDueOrder, Where: "t=5.0", Alternatives: 3, Taken: 2, Among: []string{"state machine a", "state machine b", "state machine c"}, Took: "state machine c"}}, + {"at t=0.0: do top first of do top, dispatch accept Stop", ChoiceTaken{Kind: ChoiceStepOrder, Where: "at t=0.0", Alternatives: 2, Taken: 0, Among: []string{"do top", "dispatch accept Stop"}, Took: "do top"}}, + {"at t=2.0: dispatch signal first of do a, do b, dispatch signal", ChoiceTaken{Kind: ChoiceStepOrder, Where: "at t=2.0", Alternatives: 3, Taken: 2, Among: []string{"do a", "do b", "dispatch signal"}, Took: "dispatch signal"}}, + {"at t=1.0: dispatch change level > 3 1->high first of do low, dispatch change level > 3 1->high", ChoiceTaken{Kind: ChoiceStepOrder, Where: "at t=1.0", Alternatives: 2, Taken: 1, Among: []string{"do low", "dispatch change level > 3 1->high"}, Took: "dispatch change level > 3 1->high"}}, + {"at t=3.0: dispatch first of do work, dispatch", ChoiceTaken{Kind: ChoiceStepOrder, Where: "at t=3.0", Alternatives: 2, Taken: 1, Among: []string{"do work", "dispatch"}, Took: "dispatch"}}, + {"step 2: decision select -> 2->slow among 1->fast p=0.7, 2->slow p=0.3 drew 0.7748", ChoiceTaken{Kind: ChoiceDecisionBranch, Step: 2, Where: "decision select", Alternatives: 2, Taken: 1, Among: []string{"1->fast", "2->slow"}, Took: "2->slow", Weights: []float64{0.7, 0.3}, Drew: 0.7748, Drawn: true}}, + {"step 2: decision select -> 1->fast among 1->fast p=0.7, 2->slow p=0.3", ChoiceTaken{Kind: ChoiceDecisionBranch, Step: 2, Where: "decision select", Alternatives: 2, Taken: 0, Among: []string{"1->fast", "2->slow"}, Took: "1->fast", Weights: []float64{0.7, 0.3}}}, + } + for _, c := range cases { + got, err := ParseChoice(c.line) + if err != nil { + t.Errorf("%q: %v", c.line, err) + continue + } + if got.String() != c.line { + t.Errorf("%q read back as %q", c.line, got) + } + if got.Kind != c.want.Kind || got.Step != c.want.Step || got.Where != c.want.Where || got.Alternatives != c.want.Alternatives || + got.Taken != c.want.Taken || strings.Join(got.Among, "|") != strings.Join(c.want.Among, "|") || got.Took != c.want.Took || + !slices.Equal(got.Weights, c.want.Weights) || got.Drew != c.want.Drew || got.Drawn != c.want.Drawn { + t.Errorf("%q: %+v, want %+v", c.line, got, c.want) + } + } +} + +// A line that spells no choice is a typed error naming the line and why; a +// witness is read a line at a time, or as FormatChoices joins it. +func TestParseChoicesRejectsWhatSpellsNoChoice(t *testing.T) { + for _, text := range []string{"step 0: a first of a, b", "step x: a first of a, b", "a first of b, c", "c first of a, b", "step 2: c first of a, b", "-> x", "x ->", "nonsense", + "step 2: d -> x among x", "step 2: d -> x among x p=heavy", "step 2: d -> y among x p=0.5, z p=0.5", "step 2: d -> x among x p=1 drew 1.5", "step 2: d -> x among x p=1 drew u"} { + _, err := ParseChoices("step 1: a first of a, b\n" + text) + var typed *ChoiceParseError + if !errors.As(err, &typed) || !errors.Is(err, ErrInvalidChoice) { + t.Errorf("%q: error %T %v, want a ChoiceParseError", text, err, err) + continue + } + if typed.Line != 2 || typed.Text != text || !strings.Contains(err.Error(), "line 2") { + t.Errorf("%q: error %q does not name line 2", text, err) + } + } + joined, err := ParseChoices("step 1: a first of a, b; step 2: decision d -> 1->x\n\nno choice points\n") + if err != nil { + t.Fatal(err) + } + if FormatChoices(joined) != "step 1: a first of a, b; step 2: decision d -> 1->x" { + t.Fatalf("read %v", joined) + } + none, err := ParseChoices("no choice points\n") + if err != nil || len(none) != 0 { + t.Fatalf("no choice points read as %v, %v", none, err) + } +} + +// A witness file may carry the run's trace after its header, separated by one blank +// line: the header is read and the trace, which spells no choice, is ignored. +func TestParseChoicesStopsAtBlankLine(t *testing.T) { + text := "\n\nstep 1: 2@b first of 1@a, 2@b\nstep 2: decision d -> 1->x\n\n" + + "[step 1] token 2 at b\n[step 2] decision d took 1->x\nnonsense that is no choice\n" + choices, err := ParseChoices(text) + if err != nil { + t.Fatal(err) + } + if FormatChoices(choices) != "step 1: 2@b first of 1@a, 2@b; step 2: decision d -> 1->x" { + t.Fatalf("read %v", choices) + } + if _, err := ParseChoices("step 1: 2@b first of 1@a, 2@b\nnonsense\n"); err == nil { + t.Fatal("a line spelling no choice inside the header was accepted") + } +} + +// `replay:` reads the file when the policy is parsed: a missing file, an +// unreadable line, an empty file or no file at all is a typed policy error; a +// header of `no choice points` is the witness of a run with none and is followed; +// the policy spells its file back and hands out its witness. +func TestParseReplayPolicy(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "witness.txt") + if err := os.WriteFile(file, []byte("step 3: 3@c first of 1@a, 2@b, 3@c\nstep 4: 2@b first of 1@a, 2@b\n"), 0o600); err != nil { + t.Fatal(err) + } + policy, err := ParseSchedulePolicy("replay:" + file) + if err != nil { + t.Fatal(err) + } + if got := policy.String(); got != "replay:"+file { + t.Errorf("String() = %q", got) + } + if policy.IsDefault() { + t.Error("a replay is not the default policy") + } + choices, ok := policy.Replay() + if !ok || len(choices) != 2 || choices[1].Took != "2@b" { + t.Fatalf("Replay() = %v, %v", choices, ok) + } + if _, ok := DefaultSchedulePolicy.Replay(); ok { + t.Error("reverse hands out a witness") + } + bad := filepath.Join(dir, "bad.txt") + if err := os.WriteFile(bad, []byte("step 3: 3@c first of 1@a, 2@b, 3@c\nnonsense\n"), 0o600); err != nil { + t.Fatal(err) + } + empty := filepath.Join(dir, "empty.txt") + if err := os.WriteFile(empty, nil, 0o600); err != nil { + t.Fatal(err) + } + blank := filepath.Join(dir, "blank.txt") + if err := os.WriteFile(blank, []byte("\n\n\n"), 0o600); err != nil { + t.Fatal(err) + } + traceOnly := filepath.Join(dir, "trace-only.txt") + if err := os.WriteFile(traceOnly, []byte("[trace] step 1\n"), 0o600); err != nil { + t.Fatal(err) + } + spellings := []string{"replay", "replay:", "replay:" + filepath.Join(dir, "missing.txt"), "replay:" + bad, + "replay:" + empty, "replay:" + blank, "replay:" + traceOnly} + for _, spelling := range spellings { + _, err := ParseSchedulePolicy(spelling) + var typed *SchedulePolicyError + if !errors.As(err, &typed) || !errors.Is(err, ErrInvalidSchedulePolicy) || typed.Spelling != spelling { + t.Errorf("%q: error %T %v, want a SchedulePolicyError naming the spelling", spelling, err, err) + } + } + if _, err := ParseSchedulePolicy("replay:" + bad); err == nil || !strings.Contains(err.Error(), "line 2") { + t.Errorf("an unreadable witness line is not named: %v", err) + } + for _, file := range []string{empty, blank} { + if _, err := ParseSchedulePolicy("replay:" + file); err == nil || !strings.Contains(err.Error(), "names no move to follow") { + t.Errorf("%s: a witness naming no move is not refused as such: %v", file, err) + } + } + if _, err := ParseSchedulePolicy("replay:" + traceOnly); err == nil || !strings.Contains(err.Error(), "line 1") { + t.Errorf("a trace with no header is not refused at its first line: %v", err) + } + if choices, err := ParseChoices("no choice points\n"); err != nil || len(choices) != 0 { + t.Errorf("ParseChoices(no choice points) = %v, %v; want no choices and no error", choices, err) + } + moveless := filepath.Join(dir, "moveless.txt") + if err := os.WriteFile(moveless, []byte("no choice points\n\n[trace] step 1\n"), 0o600); err != nil { + t.Fatal(err) + } + policy, err = ParseSchedulePolicy("replay:" + moveless) + if err != nil { + t.Fatalf("a witness of no choice points is refused: %v", err) + } + if choices, ok := policy.Replay(); !ok || len(choices) != 0 || policy.String() != "replay:"+moveless { + t.Errorf("replay of no choice points = %v, %v, %q; want an empty witness spelling its file", choices, ok, policy) + } + if got := SchedulePolicyNames[len(SchedulePolicyNames)-1]; got != "replay:" { + t.Errorf("SchedulePolicyNames ends with %q", got) + } +} + +// replayed runs one run of the model under the witness, as the exploration ran it. +func replayed(t *testing.T, fresh func() (*Context, error), run func(*Context) (Outcome, error), witness []ChoiceTaken) (Outcome, []ChoiceTaken, error) { + t.Helper() + ctx, err := fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + outcome, err := run(ctx) + if err == nil { + err = ctx.Unfollowed() + } + var choices []ChoiceTaken + for _, c := range ctx.Choices() { + choices = append(choices, c.Choice()) + } + return outcome, choices, err +} + +// choiceKinds lists the kind of each choice, in order. +func choiceKinds(choices []ChoiceTaken) []ChoiceKind { + kinds := make([]ChoiceKind, len(choices)) + for i, c := range choices { + kinds[i] = c.Kind + } + return kinds +} + +func countKind(kinds []ChoiceKind, kind ChoiceKind) int { + n := 0 + for _, k := range kinds { + if k == kind { + n++ + } + } + return n +} + +// takenOf is the choices a run recorded as the moves of a witness. +func takenOf(choices []ChoicePoint) []ChoiceTaken { + out := make([]ChoiceTaken, len(choices)) + for i, c := range choices { + out[i] = c.Choice() + } + return out +} + +// assertWitnessesReplay checks every outcome of an exploration: the run under its +// witness reaches the outcome, and its choices are the witness's lines. +func assertWitnessesReplay(t *testing.T, x *Exploration, fresh func() (*Context, error), run func(*Context) (Outcome, error)) { + t.Helper() + for _, o := range x.Outcomes { + outcome, choices, err := replayed(t, fresh, run, o.Witness) + if err != nil { + t.Errorf("%s: replaying %s: %v", o.Outcome, FormatChoices(o.Witness), err) + continue + } + if outcome.String() != o.Outcome.String() { + t.Errorf("replaying %s reached %s, want %s", FormatChoices(o.Witness), outcome, o.Outcome) + } + if got, want := FormatChoices(choices), FormatChoices(o.Witness); got != want { + t.Errorf("replaying %s made the choices\n%s", want, got) + } + } +} + +// Every witness an exploration of an action writes replays to its outcome with +// the witness's choices: token orders, and decisions in a loop. +func TestReplayFollowsActionWitnesses(t *testing.T) { + for name, text := range map[string]string{"race": threeWritersModel, "route": choiceModel, "count": decisionLoopModel} { + m := parseExploreModel(t, text) + x := m.exploreAction(t, "explore", name) + if !x.Complete() { + t.Fatalf("%s: status %q, want complete", name, x.Status()) + } + sym := m.action(t, name) + run := func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } + assertWitnessesReplay(t, x, m.fresh, run) + } +} + +// decisionLoopModel branches on two overlapping guards inside a merge loop. +const decisionLoopModel = `package test { + action count { + attribute n : Integer = 0; + attribute odd : Integer = 0; + attribute even : Integer = 0; + first start; + decide pick; + action left { assign odd := odd + 1; assign n := n + 1; } + action right { assign even := even + 1; assign n := n + 1; } + merge again; + done; + succession first start then pick; + succession first pick if n < 2 then left; + succession first pick if n < 3 then right; + succession first pick if n >= 3 then done; + succession first left then again; + succession first right then again; + succession first again then pick; + } +}` + +// A witness written to a file and read back through `replay:` is the same +// run: the trace of the replay is the trace exploration recorded for it. +func TestReplayFileReproducesTheExploredRun(t *testing.T) { + m := parseExploreModel(t, choiceModel) + x := m.exploreAction(t, "explore", "route") + sym := m.action(t, "route") + run := func(ctx *Context, policy SchedulePolicy) (string, map[string]Value) { + t.Helper() + mustSchedule(t, ctx, policy) + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatal(err) + } + trace := NewTraceRecorder() + exec.SetTrace(trace) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("%s: %v", policy, err) + } + if err := ctx.Unfollowed(); err != nil { + t.Fatalf("%s: %v", policy, err) + } + return trace.String(), exec.Results() + } + for i, o := range x.Outcomes { + file := filepath.Join(t.TempDir(), "witness.txt") + lines := make([]string, len(o.Witness)) + for j, c := range o.Witness { + lines[j] = c.String() + } + if err := os.WriteFile(file, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + policy := mustPolicy(t, "replay:"+file) + ctx, _ := m.fresh() + trace, outputs := run(ctx, policy) + if got := ctx.ActionOutcome(outputs).String(); got != o.Outcome.String() { + t.Errorf("outcome %d: replay of %s reached %s, want %s", i, FormatChoices(o.Witness), got, o.Outcome) + } + again, _ := m.fresh() + if second, _ := run(again, policy); second != trace { + t.Errorf("outcome %d: two replays of one file differ\n%s\n---\n%s", i, trace, second) + } + if !strings.Contains(trace, "choice step") { + t.Errorf("outcome %d: the replay records no choice lines\n%s", i, trace) + } + } +} + +// State machine witnesses replay too: a transition conflict, sibling regions +// reacting to one event and executors due at one instant. +func TestReplayFollowsStateWitnesses(t *testing.T) { + t.Run("transition", func(t *testing.T) { + m := parseExploreModel(t, `package test { + state def Machine { + entry; then idle; + state idle; + state left; + state right; + transition idle_left first idle accept go then left; + transition idle_right first idle accept go then right; + } + }`) + sym := m.state(t, "Machine") + run := stateRun(sym, "go") + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil || !x.Complete() || x.Runs != 2 { + t.Fatalf("explore: %v, %v", x, err) + } + assertWitnessesReplay(t, x, m.fresh, run) + }) + // A choice's branches are read after the incoming effect; the witness names + // the branch taken as the run's choice line does. + t.Run("dynamic choice", func(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute level : Integer = 0; + entry; then idle; + state idle; + choice pick; + state left; + state right; + transition first idle accept go do assign level := 8 then pick; + transition first pick if level > 5 then left; + transition first pick if level > 7 then right; + } + }`) + sym := m.state(t, "Machine") + run := stateRun(sym, "go") + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil || !x.Complete() || x.Runs != 2 { + t.Fatalf("explore: %v, %v", x, err) + } + for _, o := range x.Outcomes { + if len(o.Witness) != 1 || o.Witness[0].Kind != ChoiceTransition || o.Witness[0].Where != "choice pick" { + t.Fatalf("witness of %s is %s, want the one branch choice at pick", o.Outcome, FormatChoices(o.Witness)) + } + } + assertWitnessesReplay(t, x, m.fresh, run) + }) + t.Run("regions", func(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute last : Integer = 0; + entry; then work; + state work parallel { + state a { entry; then a1; state a1; state a2; transition first a1 accept go do assign last := 1 then a2; } + state b { entry; then b1; state b1; state b2; transition first b1 accept go do assign last := 2 then b2; } + } + } + }`) + sym := m.state(t, "Machine") + run := stateRun(sym, "go") + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + // Two entry orders, then the two orders of two firings, each one draw: its + // silent exit and entry ride with its effect. + if err != nil || !x.Complete() || x.Runs != 4 || len(x.Outcomes) != 4 { + t.Fatalf("explore: %v, %v", x, err) + } + assertWitnessesReplay(t, x, m.fresh, run) + }) + // A dispatch draws every region's transition before the order its units fire + // in, and notes each with its firing; the witness lists the draws, the run the firings. + t.Run("regions with a conflict", func(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute last : Integer = 0; + entry; then work; + state work parallel { + state a { entry; then a1; state a1; state a2; state a3; + transition first a1 accept go do assign last := 1 then a2; + transition first a1 accept go do assign last := 2 then a3; } + state b { entry; then b1; state b1; state b2; transition first b1 accept go do assign last := 3 then b2; } + } + } + }`) + sym := m.state(t, "Machine") + run := stateRun(sym, "go") + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil || !x.Complete() || x.Runs != 8 { + t.Fatalf("explore: %v, %v", x, err) + } + for _, o := range x.Outcomes { + kinds := choiceKinds(o.Witness) + if len(kinds) < 3 || kinds[0] != ChoiceEntryOrder || kinds[1] != ChoiceTransition || + slices.ContainsFunc(kinds[2:], func(k ChoiceKind) bool { return k != ChoiceRegionOrder }) { + t.Fatalf("witness %s draws %v, want the entry, the transition then the unit order", FormatChoices(o.Witness), kinds) + } + outcome, choices, err := replayed(t, m.fresh, run, o.Witness) + if err != nil { + t.Errorf("%s: replaying %s: %v", o.Outcome, FormatChoices(o.Witness), err) + continue + } + if outcome.String() != o.Outcome.String() { + t.Errorf("replaying %s reached %s, want %s", FormatChoices(o.Witness), outcome, o.Outcome) + } + // Replay notes the transition when its firing's first unit is reached. + if kinds := choiceKinds(choices); len(kinds) < 3 || kinds[0] != ChoiceEntryOrder || kinds[1] != ChoiceRegionOrder || + countKind(kinds, ChoiceTransition) != 1 || + slices.ContainsFunc(kinds[2:], func(k ChoiceKind) bool { return k != ChoiceRegionOrder && k != ChoiceTransition }) { + t.Errorf("replaying %s noted %v, want the entry, unit orders and one transition among them", FormatChoices(o.Witness), kinds) + } + got, want := strings.Split(FormatChoices(choices), "; "), strings.Split(FormatChoices(o.Witness), "; ") + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("replaying %s made the choices\n%s", FormatChoices(o.Witness), FormatChoices(choices)) + } + } + }) + t.Run("due", func(t *testing.T) { + fresh, run := dueOrderModel(t) + x, err := Explore(context.Background(), mustPolicy(t, "explore"), fresh, run) + if err != nil || !x.Complete() || x.Runs != 6 { + t.Fatalf("explore: %v, %v", x, err) + } + assertWitnessesReplay(t, x, fresh, run) + }) +} + +// dueOrderModel is three tickers waking at one instant, each taking the next mark +// of a shared cell, so the order they run in is the outcome. +func dueOrderModel(t *testing.T) (func() (*Context, error), func(*Context) (Outcome, error)) { + t.Helper() + file := parseAndBuild(t, ` + package test { + private import SI::*; + private import ScalarValues::*; + part def Cell { attribute mark : Integer = 0; } + part cell : Cell; + state def Ticker { + attribute seen : Integer = -1; + entry; then waiting; + state waiting; + accept after 5 [s] then took; + state took { + entry action take { assign seen := cell.mark; assign cell.mark := cell.mark + 1; } + } + } + state a : Ticker; + state b : Ticker; + state c : Ticker; + } + `) + idx, model, _ := buildRuntimeWithLibraries(t, "", file) + root := idx.DocumentRoot("") + resolver := resolve.New(idx) + names := []string{"a", "b", "c"} + syms := make([]*symbols.Symbol, len(names)) + for i, name := range names { + syms[i] = namedOrFoundSymbol(t, idx, "test::"+name, root, ast.DefState, ast.UsageState) + } + fresh := func() (*Context, error) { return NewContext(typedModel(model, resolver), 10000), nil } + run := func(ctx *Context) (Outcome, error) { + execs := make([]*StateExecutor, len(syms)) + for i, sym := range syms { + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return Outcome{}, err + } + execs[i] = exec + } + if _, err := ctx.Advance(5); err != nil { + return Outcome{}, err + } + outputs := make(map[string]Value, len(execs)) + for i, exec := range execs { + outputs[names[i]] = exec.StateData()["seen"] + } + return ctx.ActionOutcome(outputs), nil + } + return fresh, run +} + +// stateRun initializes the machine, sends it signal and runs it to completion. +func stateRun(sym *symbols.Symbol, signal string) func(*Context) (Outcome, error) { + return func(ctx *Context) (Outcome, error) { + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return Outcome{}, err + } + exec.SendSignal(signal, nil) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + } +} + +// doForkMachine forks the do action of one state, whose token order decides x. +const doForkMachine = `package test { + private import ScalarValues::*; + state def Machine { + attribute x : Integer = 0; + entry; then busy; + state busy { + do action work { + first start; + then fork split; + succession split then left; + succession split then right; + action left { assign x := 1; } + action right { assign x := 2; } + succession left then sync; + succession right then sync; + join sync; + then done; + } + } + transition first busy accept go then idle; + state idle; + } +}` + +// The token orders a do action's flow draws are steps of its own, numbered on, +// among the step orders the machine draws between the do action and the dispatch: +// the witness of a fork within a state's do action replays to its outcome, and so +// does the one of the dispatch leaving the state before the do action acted. +func TestReplayFollowsDoActionWitnesses(t *testing.T) { + m := parseExploreModel(t, doForkMachine) + run := stateRun(m.state(t, "Machine"), "go") + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil || !x.Complete() || len(x.Outcomes) != 3 { + t.Fatalf("explore: %v, %v", x, err) + } + for _, o := range x.Outcomes { + got := FormatChoices(o.Witness) + if strings.HasSuffix(o.Outcome.String(), "x = 0") { + if strings.Contains(got, "step 3: ") || !strings.HasPrefix(got, "at t=0.0: dispatch accept go first of do busy, dispatch accept go") { + t.Errorf("%s: witness %s, want the dispatch drawn before the do action, no token order", o.Outcome, got) + } + continue + } + if strings.Count(got, "step 3: ") != 1 || !strings.HasPrefix(got, "at t=0.0: do busy first of do busy, dispatch accept go") { + t.Errorf("%s: witness %s, want the do action drawn first, then one token order at the flow's third step", o.Outcome, got) + } + } + assertWitnessesReplay(t, x, m.fresh, run) +} + +// Sibling objects exhibiting one machine perform its do action each at steps of +// their own: a step of one that draws no choice — one token able to act, none +// the move names — leaves the witness move to the sibling's, so the witnesses +// of a run over both replay to their outcomes. +func TestReplayFollowsSiblingDoActionWitnesses(t *testing.T) { + m := parseExploreModel(t, strings.Replace(doForkMachine, "\n}", ` + part def Pair { + part a : Thing; + part b : Thing; + } + part def Thing { + exhibit state m : Machine; + } + part pair : Pair; +}`, 1)) + sym := m.state(t, "Machine") + pair := namedOrFoundSymbol(t, m.idx, "test::pair", m.idx.DocumentRoot(m.path), ast.DefPart, ast.UsagePart) + run := func(ctx *Context) (Outcome, error) { + if _, err := ctx.Instantiate(pair); err != nil { + return Outcome{}, err + } + a, err := ctx.objectAt("test::pair#1.a") + if err != nil { + return Outcome{}, err + } + exec, err := ctx.CreateStateExecutorFor(sym, a) + if err != nil { + return Outcome{}, err + } + if _, err := ctx.Advance(1); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + } + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil || !x.Complete() || len(x.Outcomes) != 2 { + t.Fatalf("explore: %v, %v", x, err) + } + for _, o := range x.Outcomes { + if len(o.Witness) != 3 { + t.Errorf("%s: witness %s, want the token order of each of the three performances", o.Outcome, FormatChoices(o.Witness)) + } + outcome, _, err := replayed(t, m.fresh, run, o.Witness) + if err != nil { + t.Errorf("%s: replaying %s: %v", o.Outcome, FormatChoices(o.Witness), err) + } else if outcome.String() != o.Outcome.String() { + t.Errorf("replaying %s reached %s, want %s", FormatChoices(o.Witness), outcome, o.Outcome) + } + } +} + +// A witness move the run cannot make is refused with a typed error naming the +// move: a token not able to act, a branch not holding, a move at a step the run +// is past, a move where the run has none, and one left over when the run ends. +func TestReplayRefusesAMoveNotEnabled(t *testing.T) { + m := parseExploreModel(t, choiceModel) + sym := m.action(t, "route") + run := func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } + x := m.exploreAction(t, "explore", "route") + good := x.Outcomes[0].Witness + const wantGood = "step 3: 2@a first of 2@a, 3@b, 4@c; step 4: 3@b first of 3@b, 4@c; step 7: decision select -> 1->warn" + if FormatChoices(good) != wantGood { + t.Fatalf("witness %s, want %s", FormatChoices(good), wantGood) + } + orders := good[0].String() + "\n" + good[1].String() + "\n" + cases := []struct { + name string + lines string + move int + faced string + }{ + {"token not able", "step 3: 9@zzz first of 2@a, 9@zzz", 1, "9@zzz is not able to act (able to act: 2@a, 3@b, 4@c)"}, + {"alternative not able", "step 3: 2@a first of 2@a, 9@zzz", 1, "9@zzz is not able to act"}, + {"token order where one token acts", "step 1: 1@a first of 1@a, 2@b", 1, "the run is at step 3 and step 1 had no such move"}, + {"step already past", "step 1: decision select -> 1->warn", 1, "step 1 had no such move"}, + {"branch not holding", orders + "step 7: decision select -> 3->nowhere", 3, "3->nowhere is not enabled (enabled: 1->warn, 2->alarm)"}, + {"branch at the wrong place", orders + "step 7: decision elsewhere -> 1->warn", 3, "the run faced"}, + {"branch at the wrong step", orders + "step 6: decision select -> 1->warn", 3, "step 6 had no such move"}, + {"branch where a token order is faced", "step 3: decision select -> 1->warn", 1, "must pick a token (able to act: 2@a, 3@b, 4@c)"}, + {"move left over", wantGood + "; step 99: 1@a first of 1@a, 2@b", 4, "the run ended"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + witness, err := ParseChoices(strings.ReplaceAll(c.lines, "; ", "\n")) + if err != nil { + t.Fatal(err) + } + _, _, err = replayed(t, m.fresh, run, witness) + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) { + t.Fatalf("error %T %v, want a ReplayError", err, err) + } + if refused.Move != c.move || refused.Choice.String() != witness[c.move-1].String() { + t.Errorf("refused move %d (%s), want move %d (%s)", refused.Move, refused.Choice, c.move, witness[c.move-1]) + } + if !strings.Contains(err.Error(), c.faced) || !strings.Contains(err.Error(), witness[c.move-1].String()) { + t.Errorf("error %q does not say %q and name the move", err, c.faced) + } + }) + } + if _, _, err := replayed(t, m.fresh, run, good); err != nil { + t.Fatalf("the witness itself is refused: %v", err) + } +} + +// clockRetriedModel loads the conformance case whose step 3 the clock retries: on +// the first pass only the performed branch acts, so explore draws the token order +// at the retry, where both branches are due. +func clockRetriedModel(t *testing.T) (m *exploreModel, run func(*Context) (Outcome, error)) { + t.Helper() + path := filepath.Join("testdata", "conformance", "action_explore_performed_and_accept_due_together.sysml") + text, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + m = parseLibraryModel(t, string(text)) + sym := m.action(t, "wake") + run = func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } + return m, run +} + +// A token order explore draws once the clock has retried a step replays: the +// move is kept through the pass where one token alone is able to act, and taken +// at the retry. Each of the six linearizations reaches the outcome it recorded. +func TestReplayFollowsAnOrderDrawnAfterTheClockRetriesAStep(t *testing.T) { + m, run := clockRetriedModel(t) + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil || !x.Complete() || x.Runs != 6 { + t.Fatalf("explore: %v, %v", x, err) + } + assertWitnessesReplay(t, x, m.fresh, run) + const ( + perf = "step 3: 2@performed first of 2@performed, 3@direct" + direct = "step 3: 3@direct first of 2@performed, 3@direct" + ) + for witness, want := range map[string]string{ + perf + "; step 4: 2@writeOne first of 2@writeOne, 3@direct": "x = 2", + perf + "; step 4: 3@direct first of 2@writeOne, 3@direct; step 5: 2@writeOne first of 2@writeOne, 3@writeTwo": "x = 2", + perf + "; step 4: 3@direct first of 2@writeOne, 3@direct; step 5: 3@writeTwo first of 2@writeOne, 3@writeTwo": "x = 1", + direct + "; step 4: 2@performed first of 2@performed, 3@writeTwo; step 5: 2@writeOne first of 2@writeOne, 3@writeTwo": "x = 2", + direct + "; step 4: 2@performed first of 2@performed, 3@writeTwo; step 5: 3@writeTwo first of 2@writeOne, 3@writeTwo": "x = 1", + direct + "; step 4: 3@writeTwo first of 2@performed, 3@writeTwo": "x = 1", + } { + choices, err := ParseChoices(strings.ReplaceAll(witness, "; ", "\n")) + if err != nil { + t.Fatal(err) + } + outcome, made, err := replayed(t, m.fresh, run, choices) + if err != nil { + t.Errorf("replaying %s: %v", witness, err) + continue + } + if outcome.String() != want { + t.Errorf("replaying %s reached %s, want %s", witness, outcome, want) + } + if got := FormatChoices(made); got != witness { + t.Errorf("replaying %s made the choices\n%s", witness, got) + } + } +} + +// A move kept for the clock's retry is bounded by presence and by the retry: an +// alternative absent from the step, which a nested performance's step of the same +// number might yet hold, is refused at the retry, and one present but parked +// on an accept no send answers is refused when the step ends without a retry — +// whether the other token acts or none does, in which case the run is deadlocked. +func TestReplayRefusesAParkedTokenTheClockCannotEnable(t *testing.T) { + retried, run := clockRetriedModel(t) + fresh := retried.fresh + stalled := func(sends bool) (func() (*Context, error), func(*Context) (Outcome, error)) { + sender := "succession first start then split;" + if sends { + sender = "action sender { send 7 to reader; }\nsuccession first start then sender;\nsuccession first sender then split;" + } + m := parseExploreModel(t, `package test { + action stall { + attribute got : Integer = 0; + first start; + `+sender+` + fork split; + action reader accept n : Integer; + action recorder { assign got := n; } + action listener accept text : String; + join sync; + done; + succession first split then reader; + succession first split then listener; + succession first reader then recorder; + succession first recorder then sync; + succession first listener then sync; + succession first sync then done; + } + }`) + sym := m.action(t, "stall") + return m.fresh, func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } + } + oneParked, oneParkedRun := stalled(true) + bothParked, bothParkedRun := stalled(false) + ctx, _ := bothParked() + if _, err := bothParkedRun(ctx); !errors.Is(err, ErrAcceptDeadlock) { + t.Fatalf("the stalled model under the default policy: %v, want %v", err, ErrAcceptDeadlock) + } + cases := []struct { + name string + fresh func() (*Context, error) + run func(*Context) (Outcome, error) + line string + faced string + }{ + {"alternative absent", fresh, run, "step 3: 2@performed first of 2@performed, 9@zzz", "step 3: 9@zzz is not able to act (able to act: 2@performed, 3@direct)"}, + {"token absent beside one parked", fresh, run, "step 3: 9@zzz first of 3@direct, 9@zzz", "step 3: 9@zzz is not able to act (able to act: 2@performed, 3@direct)"}, + {"parked beside a token that acts", oneParked, oneParkedRun, "step 4: 3@listener first of 2@reader, 3@listener", "step 4: 3@listener is not able to act (able to act: 2@reader)"}, + {"parked in a deadlock", bothParked, bothParkedRun, "step 3: 2@reader first of 2@reader, 3@listener", "step 3: 2@reader is not able to act (none is able to act)"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + witness, err := ParseChoices(c.line) + if err != nil { + t.Fatal(err) + } + _, _, err = replayed(t, c.fresh, c.run, witness) + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) { + t.Fatalf("error %T %v, want a ReplayError", err, err) + } + if refused.Move != 1 || refused.Choice.String() != c.line || refused.Faced != c.faced { + t.Errorf("refused move %d (%s): %q, want move 1 (%s): %q", refused.Move, refused.Choice, refused.Faced, c.line, c.faced) + } + }) + } +} + +// A run that outlives its witness goes on as `reverse` does: the witness's first +// move is taken, and from there the choices are the default policy's. +func TestReplayFallsBackToReverse(t *testing.T) { + m := parseExploreModel(t, choiceModel) + sym := m.action(t, "route") + run := func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } + x := m.exploreAction(t, "explore", "route") + // The exploration's first run takes `1@a` first; reverse takes `3@c` first, + // so a witness of that one move steers the run off the default path. + first := x.Outcomes[0].Witness[:1] + outcome, choices, err := replayed(t, m.fresh, run, first) + if err != nil { + t.Fatal(err) + } + if len(choices) < 2 || choices[0].String() != first[0].String() { + t.Fatalf("choices %s, want %s then the rest", FormatChoices(choices), first[0]) + } + ctx, _ := m.fresh() + mustSchedule(t, ctx, DefaultSchedulePolicy) + def, err := run(ctx) + if err != nil { + t.Fatal(err) + } + var defChoices []ChoiceTaken + for _, c := range ctx.Choices() { + defChoices = append(defChoices, c.Choice()) + } + if defChoices[0].String() == first[0].String() { + t.Fatalf("reverse takes %s too; the witness steers nothing", first[0]) + } + // After the first token order the choices left (write orders, the decision) + // are the ones reverse makes, being the default's. + for i := 1; i < len(choices) && i < len(defChoices); i++ { + if choices[i].Kind == ChoiceDecisionBranch && defChoices[i].Kind == ChoiceDecisionBranch && choices[i].Took != defChoices[i].Took { + t.Errorf("past the witness the decision took %s, reverse takes %s", choices[i].Took, defChoices[i].Took) + } + } + if outcome.String() == def.String() { + t.Logf("witness %s and reverse reach one outcome %s", first[0], outcome) + } +} + +// forkDecisionModel decides while a sibling token is able to act, so one step +// holds both a token order and a decision. +const forkDecisionModel = `package test { + action mix { + attribute level : Integer = 75; + attribute handler : Integer = 0; + attribute x : Integer = 0; + first start; + fork split; + action a { assign x := 1; } + decide select; + action warn { assign handler := 1; } + action alarm { assign handler := 2; } + merge either; + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then select; + succession first select if level > 50 then warn; + succession first select if level > 70 then alarm; + succession first a then sync; + succession first warn then either; + succession first alarm then either; + succession first either then sync; + succession first sync then done; + } +}` + +// A run notes a step's decision before the token order that led to it; a witness +// written the other way round, order first as a checker states its moves, +// replays the same. +func TestReplayReadsAStepsOrderInEitherPlace(t *testing.T) { + m := parseExploreModel(t, forkDecisionModel) + sym := m.action(t, "mix") + run := func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } + const recorded = "step 3: decision select -> 2->alarm; step 3: 3@select first of 2@a, 3@select" + const stated = "step 3: 3@select first of 2@a, 3@select; step 3: decision select -> 2->alarm" + var traces []string + for _, text := range []string{recorded, stated} { + w, err := ParseChoices(text) + if err != nil { + t.Fatal(err) + } + outcome, choices, err := replayed(t, m.fresh, run, w) + if err != nil { + t.Fatalf("%s: %v", text, err) + } + if got := outcome.String(); got != "handler = 2; level = 75; x = 1" { + t.Errorf("%s reached %s", text, got) + } + traces = append(traces, FormatChoices(choices)) + } + if traces[0] != traces[1] || !strings.HasPrefix(traces[0], recorded) { + t.Errorf("the two spellings made different choices:\n%s\n%s", traces[0], traces[1]) + } +} + +// The policies that exist keep their behaviour: their traces are unchanged by the +// replay policy existing, and a probe under replay does not move the witness. +func TestReplayProbeLeavesTheWitnessInPlace(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package test { + state Dispatcher { + attribute level : Integer = 8; + entry; then idle; + state idle; + state low; + state high; + transition first idle accept Go if level > 5 then low; + transition first idle accept Go if level > 7 then high; + } + }`)) + sym := findSymbolByName(idx.DocumentRoot(""), "Dispatcher", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + witness, err := ParseChoices("state idle on accept Go -> 2->high\n") + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + // Deciding previews the transition without making it, so the witness's move is + // still there for the run. + for i := 0; i < 2; i++ { + decision, err := exec.Decide(Message{SignalType: "Go"}) + if err != nil || len(decision.Fires) != 1 || !strings.HasSuffix(decision.Fires[0], "-> high") { + t.Fatalf("Decide %d: %+v, %v; want the witness's transition into high", i, decision, err) + } + } + exec.SendSignal("Go", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + if err := ctx.Unfollowed(); err != nil { + t.Fatal(err) + } + if got := activeLeaf(exec); got != "high" { + t.Fatalf("ended in %s, want high", got) + } +} + +// A behavior run whole by the context — an action or a state machine — ends +// refused when the witness has moves left over, with no call to Unfollowed needed. +func TestReplayRefusesMovesLeftOverByARun(t *testing.T) { + t.Run("action", func(t *testing.T) { + m := parseExploreModel(t, choiceModel) + sym := m.action(t, "route") + good := m.exploreAction(t, "explore", "route").Outcomes[0].Witness + extra := ChoiceTaken{Kind: ChoiceTokenOrder, Step: 99, Among: []string{"1@a", "2@b"}, Took: "1@a", Alternatives: 2} + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(append(slices.Clone(good), extra))) + // Before any run there is nothing unfollowed, and asking begins no run. + if err := ctx.Unfollowed(); err != nil || ctx.run.scheduler != nil { + t.Fatalf("before a run: Unfollowed() = %v, scheduler begun %v", err, ctx.run.scheduler != nil) + } + _, err = ctx.ExecuteAction(sym) + assertRefusedLeftOver(t, err, len(good)+1, extra) + }) + t.Run("state", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package test { + state Dispatcher { + entry; then idle; + state idle; + state low; + state high; + transition first idle accept Go then low; + transition first idle accept Go then high; + } + }`)) + sym := findSymbolByName(idx.DocumentRoot(""), "Dispatcher", ast.DefState) + if sym == nil { + t.Fatal("state machine not found") + } + witness, err := ParseChoices("state idle on accept Go -> 2->high\nstate high on accept Go -> 1->idle\n") + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + _, _, err = ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + assertRefusedLeftOver(t, err, 2, witness[1]) + }) +} + +// assertRefusedLeftOver checks that err is the refusal of the witness's move +// left over when the run ended. +func assertRefusedLeftOver(t *testing.T, err error, move int, choice ChoiceTaken) { + t.Helper() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) { + t.Fatalf("error %T %v, want a ReplayError", err, err) + } + if refused.Move != move || refused.Choice.String() != choice.String() || refused.Faced != "the run ended" { + t.Errorf("refused %+v, want move %d (%s) faced the run ended", refused, move, choice) + } +} + +// A do-order move naming a state whose behavior is not due is refused before +// either due behavior acts, so the run stops where the witness stopped fitting: +// the dispatch drawn ahead of lwork's first move leaves both behaviors due, and +// the move naming zork is refused with neither having moved. +func TestReplayRefusesADoOrderMoveNotEnabled(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Interleave parallel { + attribute seq : Integer = 0; + state left { + entry; then lstart; + state lstart; + state lwork { do { assign seq := seq * 10 + 1; assign seq := seq * 10 + 2; } } + succession first lstart then lwork; + } + state right { + entry; then rstart; + state rstart; + state rwork { do { assign seq := seq * 10 + 4; assign seq := seq * 10 + 5; } } + succession first rstart then rwork; + } + } + }`) + sym := m.state(t, "Interleave") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices("entering Interleave: lstart(entry) first of lstart(entry), rstart(entry)\nat t=0.0: dispatch completion rstart 1->rwork first of do lwork, dispatch completion rstart 1->rwork\ndo round at t=0.0: zork first of lwork, zork\n") + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || refused.Move != 3 || !strings.Contains(err.Error(), "zork is not enabled (enabled: lwork, rwork)") { + t.Fatalf("error %T %v, want the do-order move refused", err, err) + } + if seq := FormatValue(exec.StateData()["seq"]); seq != "0" { + t.Errorf("seq is %v after the refusal, want 0: neither due behavior may act on a refused move", seq) + } +} + +// A transition move refused on a message is refused before the message reaches +// the do behavior parked at an accept for it in a sibling region, so a refused +// replay leaves the machine's data as it found it. +func TestReplayRefusesATransitionMoveBeforeDoBehaviorsTakeTheMessage(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Go; + state def Waiter parallel { + attribute total : Integer = 0; + state left { + entry; then lwork; + state lwork { + do action work { + first start; + then action reader accept Go; + then action count assign total := total + 10; + then done; + } + } + } + state right { + entry; then rwait; + state rwait; + transition first rwait accept Go then rdone; + transition first rwait accept Go then rother; + state rdone { entry assign total := total + 1; } + state rother { entry assign total := total + 2; } + } + } + }`) + sym := m.state(t, "Waiter") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices("entering Waiter: lwork(entry) first of lwork(entry), rwait(entry)\nentering Waiter: rwait(entry) first of do lwork, rwait(entry)\nstate rwait on accept Go -> 3->nowhere\n") + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to the accept: %v", err) + } + exec.SendSignal("Go", nil) + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) || refused.Move != 3 || !strings.Contains(err.Error(), "3->nowhere") { + t.Fatalf("error %T %v, want the transition move refused", err, err) + } + if total := FormatValue(exec.StateData()["total"]); total != "0" { + t.Errorf("total is %v after the refusal, want 0: the do behavior may not take a message whose dispatch is refused", total) + } +} + +// A refused move at a choice changes nothing: the compound transition is undone +// whole — the exit made ahead of the choice, the incoming effect its guards were +// read against, the do behavior the exit abandoned — no branch is entered and no +// choice recorded, and the refusal is the run's. +func TestReplayRefusedChoiceMoveChangesNothing(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Go; + attribute def Tick; + state def Machine { + attribute level : Integer = 0; + attribute exited : Integer = 0; + attribute went : Integer = 0; + entry; then idle; + state idle { + exit assign exited := 1; + do action work { + first start; + then action reader accept Tick; + then action count assign went := 100; + then done; + } + } + choice pick; + state one { entry assign went := 1; } + state two { entry assign went := 2; } + state three { entry assign went := 3; } + transition first idle accept Go do assign level := 8 then pick; + transition first pick if level > 5 then one; + transition first pick if level > 7 then two; + transition first pick if level > 9 then three; + } + }`) + sym := m.state(t, "Machine") + witness, err := ParseChoices("choice pick -> 3->three\n") + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to the do behavior's accept: %v", err) + } + if len(exec.doActions) != 1 || exec.doActions[0].run == nil { + t.Fatalf("do actions %v, want idle's do behavior paused at its accept", exec.doActions) + } + paused := exec.doActions[0].run + exec.SendSignal("Go", nil) + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) || refused.Move != 1 || !strings.Contains(err.Error(), "3->three is not enabled (enabled: 1->one, 2->two)") { + t.Fatalf("error %T %v, want the choice move refused as not enabled", err, err) + } + data := exec.StateData() + for name, want := range map[string]string{"level": "0", "exited": "0", "went": "0"} { + if got := FormatValue(data[name]); got != want { + t.Errorf("%s is %s after the refusal, want %s: the move is undone whole", name, got, want) + } + } + if state, ok := exec.CurrentState().(*ast.StateNode); !ok || state.Name != "idle" { + t.Errorf("the machine is in %v after the refusal, want idle", exec.CurrentState()) + } + if len(exec.doActions) != 1 || exec.doActions[0].run != paused { + t.Errorf("do actions %v after the refusal, want idle's do behavior paused as it was", exec.doActions) + } + if choices := ctx.Choices(); len(choices) != 0 { + t.Errorf("the run recorded %v, want no choice: a refused move is not one made", choices) + } + if ctx.Unfollowed() == nil { + t.Error("the refusal is not reported for the run") + } +} + +// A history's default transition drawn at a junction and refused at the choice +// beyond it is undone whole: the machine stays put and the junction's draw, +// made inside the move, is neither a note nor a choice of the run. +func TestReplayRefusedChoiceUndoesTheHistoryDefaultsJunctionDraw(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute route : Integer = 0; + attribute level : Integer = 0; + entry; then idle; + state idle; + state work { + entry; then w1; + state w1; + state w2; + state w3; + history resume; + junction split; + choice pick; + transition first resume then split; + transition first split do assign route := 1 then pick; + transition first split do assign route := 2 then pick; + transition first pick if level > 5 then w1; + transition first pick if level > 7 then w2; + transition first pick if level > 9 then w3; + } + transition first idle accept Go do assign level := 8 then resume; + } + }`) + sym := m.state(t, "Machine") + witness, err := ParseChoices("junction split -> 2->pick\nchoice pick -> 3->w3\n") + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + exec.SendSignal("Go", nil) + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) || refused.Move != 2 || !strings.Contains(err.Error(), "3->w3 is not enabled (enabled: 1->w1, 2->w2)") { + t.Fatalf("error %T %v, want the choice move refused as not enabled", err, err) + } + data := exec.StateData() + for name, want := range map[string]string{"route": "0", "level": "0"} { + if got := FormatValue(data[name]); got != want { + t.Errorf("%s is %s after the refusal, want %s: the move is undone whole", name, got, want) + } + } + if state, ok := exec.CurrentState().(*ast.StateNode); !ok || state.Name != "idle" { + t.Errorf("the machine is in %v after the refusal, want idle", exec.CurrentState()) + } + for _, n := range ctx.Notes() { + if _, ok := n.(ChoicePoint); ok { + t.Errorf("the run noted %v, want the junction's draw undone with the move", n) + } + } + if choices := ctx.Choices(); len(choices) != 0 { + t.Errorf("the run recorded %v, want no choice: the junction's draw is undone with the move", choices) + } + if taken := ctx.ChoicesTaken(); len(taken) != 0 { + t.Errorf("the context's witness holds %v, want no move: the junction's draw is undone with the move", taken) + } +} + +// A transition drawn at a junction and refused at the choice beyond it is undone +// whole, fired from the machine's own state or from inside an orthogonal region: +// the machine stays put and the junction's draw is neither a note nor a choice +// of the run. +func TestReplayRefusedChoiceUndoesTheJunctionDrawBeforeIt(t *testing.T) { + for name, tc := range map[string]struct { + model, entry, stays string + }{ + "direct": {`package test { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute route : Integer = 0; + attribute level : Integer = 0; + entry; then idle; + state idle; + state one; + state two; + state three; + junction split; + choice pick; + transition first idle accept Go do assign level := 8 then split; + transition first split do assign route := 1 then pick; + transition first split do assign route := 2 then pick; + transition first pick if level > 5 then one; + transition first pick if level > 7 then two; + transition first pick if level > 9 then three; + } + }`, "", "idle"}, + "in region": {`package test { + private import ScalarValues::*; + attribute def Go; + state def Machine parallel { + attribute route : Integer = 0; + attribute level : Integer = 0; + state left { + entry; then idle; + state idle; + junction split; + choice pick; + state one; + state two; + state three; + transition first idle accept Go do assign level := 8 then split; + transition first split do assign route := 1 then pick; + transition first split do assign route := 2 then pick; + transition first pick if level > 5 then one; + transition first pick if level > 7 then two; + transition first pick if level > 9 then three; + } + state right { + entry; then rest; + state rest; + } + } + }`, "entering Machine: idle(entry) first of idle(entry), rest(entry)\n", "idle|rest"}, + } { + t.Run(name, func(t *testing.T) { + m := parseExploreModel(t, tc.model) + sym := m.state(t, "Machine") + witness, err := ParseChoices(tc.entry + "junction split -> 2->pick\nchoice pick -> 3->three\n") + if err != nil { + t.Fatal(err) + } + move := len(witness) + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + exec.SendSignal("Go", nil) + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) || refused.Move != move || !strings.Contains(err.Error(), "3->three is not enabled (enabled: 1->one, 2->two)") { + t.Fatalf("error %T %v, want the choice move refused as not enabled", err, err) + } + data := exec.StateData() + for name, want := range map[string]string{"route": "0", "level": "0"} { + if got := FormatValue(data[name]); got != want { + t.Errorf("%s is %s after the refusal, want %s: the move is undone whole", name, got, want) + } + } + if got := activeStateNames(exec); got != tc.stays { + t.Errorf("the machine is in %s after the refusal, want %s", got, tc.stays) + } + entered := witness[:move-2] + if got := FormatChoices(takenOf(ctx.Choices())); got != FormatChoices(entered) { + t.Errorf("the run recorded %v, want the entry's draws alone: the junction's draw is undone with the move", got) + } + if got := FormatChoices(ctx.ChoicesTaken()); got != FormatChoices(entered) { + t.Errorf("the context's witness holds %v, want the entry's moves alone: the junction's draw is undone with the move", got) + } + }) + } +} + +// A segment out of a pseudostate declared in a composite state runs its effect +// after the composite's entry, so a witness refused at the choice beyond has the +// composite entered ahead: its entry, the effects and the entry's do behavior are +// undone with the move, and nothing of the entry made ahead is left behind. +func TestReplayRefusedChoiceUndoesTheOwnerEnteredAhead(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute log : String = ""; + attribute level : Integer = 0; + entry; then idle; + state idle; + state work { + entry { assign log := log + "work(entry);"; } + do { assign log := log + "work(do);"; } + junction split; + choice pick; + state one; + state two; + state three; + transition first split do { assign level := 8; assign log := log + "split(effect);"; } then pick; + transition first pick if level > 5 then one; + transition first pick if level > 7 then two; + transition first pick if level > 9 then three; + } + transition first idle accept Go do { assign log := log + "go(effect);"; } then split; + } + }`) + sym := m.state(t, "Machine") + witness, err := ParseChoices("choice pick -> 3->three\n") + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + exec.SendSignal("Go", nil) + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) || !strings.Contains(err.Error(), "3->three is not enabled (enabled: 1->one, 2->two)") { + t.Fatalf("error %T %v, want the choice move refused as not enabled", err, err) + } + data := exec.StateData() + for name, want := range map[string]string{"log": `""`, "level": "0"} { + if got := FormatValue(data[name]); got != want { + t.Errorf("%s is %s after the refusal, want %s: the owner's entry made ahead is undone with the move", name, got, want) + } + } + if got := activeStateNames(exec); got != "idle" { + t.Errorf("the machine is in %s after the refusal, want idle", got) + } + if len(exec.doActions) != 0 { + t.Errorf("%d do behaviors run after the refusal, want none: the owner's is undone with its entry", len(exec.doActions)) + } + if len(exec.enteredAhead) != 0 { + t.Errorf("the executor still holds %d states entered ahead after the refusal, want none", len(exec.enteredAhead)) + } +} + +// A join's incoming segments are drawn one at a time, so a witness refused at a +// later draw has an earlier segment made: the whole join is undone — the exit +// and effect of the segment fired, the do behavior the exit abandoned, the note +// and trace of the draw followed — and the sources stand where they were. +func TestReplayRefusedJoinDrawChangesNothing(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Go; + attribute def Tick; + state def Machine { + attribute log : String = ""; + attribute exited : Integer = 0; + entry; then work; + state work parallel { + state left { + entry; then a; + state a { + exit assign exited := exited + 1; + do action wait { + first start; + then action reader accept Tick; + then done; + } + } + transition first a accept Go do assign log := log + "a;" then sync; + } + state middle { + entry; then b; + state b { exit assign exited := exited + 1; } + transition first b do assign log := log + "b;" then sync; + } + state right { + entry; then c; + state c { exit assign exited := exited + 1; } + transition first c do assign log := log + "c;" then sync; + } + } + join sync; + state rest; + transition first sync do assign log := log + "sync;" then rest; + } + }`) + sym := m.state(t, "Machine") + witness, err := ParseChoices("entering work: a(entry) first of a(entry), b(entry), c(entry)\nentering work: b(entry) first of do a, b(entry), c(entry)\nentering work: c(entry) first of do a, c(entry)\njoin sync: b first of a, b, c\njoin sync: a first of a, b\n") + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + trace := NewTraceRecorder() + exec.SetTrace(trace) + // The Go a's segment takes arrives with b and c completed and a's do behavior waiting. + exec.SendSignal("Go", nil) + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) || refused.Move != 5 { + t.Fatalf("error %T %v, want the join's second draw refused", err, err) + } + data := exec.StateData() + for name, want := range map[string]string{"log": `""`, "exited": "0"} { + if got := FormatValue(data[name]); got != want { + t.Errorf("%s is %s after the refusal, want %s: the join is undone whole", name, got, want) + } + } + for _, name := range []string{"a", "b", "c"} { + if !exec.isActive(stateNamed(t, exec, name)) { + t.Errorf("%s is not active after the refusal, want every source where it was", name) + } + } + if len(exec.doActions) != 1 || exec.doActions[0].run == nil { + t.Errorf("do actions %v after the refusal, want a's do behavior paused as it was", exec.doActions) + } + if got := FormatChoices(takenOf(ctx.Choices())); got != FormatChoices(witness[:3]) { + t.Errorf("the run recorded %v, want the entry's draws alone: a refused move is not one made", got) + } + if got := FormatChoices(ctx.ChoicesTaken()); got != FormatChoices(witness[:3]) { + t.Errorf("the context holds %v, want the entry's draws alone taken", got) + } + if got := trace.String(); strings.Contains(got, "choice join sync") || strings.Contains(got, "exit: b") { + t.Errorf("trace after the refusal holds the segment fired:\n%s", got) + } + if ctx.Unfollowed() == nil { + t.Error("the refusal is not reported for the run") + } +} + +// The draw among a source's transitions that selects the segment into a join is +// a choice of the join's compound transition: a witness refused at a later join +// draw leaves it unrecorded too, as though the occurrence had moved nothing. +func TestReplayRefusedJoinDrawUndoesTheSegmentsChoice(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute log : String = ""; + entry; then work; + state work parallel { + state left { + entry; then a; + state a; + state a2; + transition first a accept Go do assign log := log + "a2;" then a2; + transition first a accept Go do assign log := log + "a;" then sync; + } + state middle { + entry; then b; + state b; + transition first b accept Go do assign log := log + "b;" then sync; + } + state right { + entry; then c; + state c; + transition first c accept Go do assign log := log + "c;" then sync; + } + } + join sync; + state rest; + transition first sync do assign log := log + "sync;" then rest; + } + }`) + sym := m.state(t, "Machine") + witness, err := ParseChoices("entering work: a(entry) first of a(entry), b(entry), c(entry)\nentering work: b(entry) first of b(entry), c(entry)\nstate a on accept Go -> 2->sync\njoin sync: b first of a, b, c\njoin sync: a first of a, b\n") + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to the accept: %v", err) + } + exec.SendSignal("Go", nil) + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) || refused.Move != 5 { + t.Fatalf("error %T %v, want the join's second draw refused", err, err) + } + if got := FormatValue(exec.StateData()["log"]); got != `""` { + t.Errorf("log is %s after the refusal, want nothing written: the join is undone whole", got) + } + for _, name := range []string{"a", "b", "c"} { + if !exec.isActive(stateNamed(t, exec, name)) { + t.Errorf("%s is not active after the refusal, want every source where it was", name) + } + } + if got := FormatChoices(takenOf(ctx.Choices())); got != FormatChoices(witness[:2]) { + t.Errorf("the run recorded %v, want the entry's draws alone: the draw among a's transitions is undone with the join", got) + } + if got := FormatChoices(ctx.ChoicesTaken()); got != FormatChoices(witness[:2]) { + t.Errorf("the context holds %v, want the entry's draws alone taken", got) + } +} + +// A choice move naming a branch the choice's guards do not enable is refused +// where the choice is resolved, so the run neither takes a branch nor exits 0. +func TestReplayRefusesAChoiceBranchNotEnabled(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute level : Integer = 0; + entry; then idle; + state idle; + choice pick; + state left; + state right; + state never; + transition first idle accept Go do assign level := 8 then pick; + transition first pick if level > 5 then left; + transition first pick if level > 7 then right; + transition first pick if level < 0 then never; + } + }`) + sym := m.state(t, "Machine") + for _, tc := range []struct{ witness, refused string }{ + {"choice pick -> 3->never\n", "3->never is not enabled (enabled: 1->left, 2->right)"}, + {"choice pick -> 9->nowhere\n", "9->nowhere is not enabled (enabled: 1->left, 2->right)"}, + } { + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices(tc.witness) + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to idle: %v", err) + } + exec.SendSignal("Go", nil) + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) || refused.Move != 1 || !strings.Contains(err.Error(), tc.refused) { + t.Fatalf("%q: error %T %v, want the choice move refused with %q", tc.witness, err, err, tc.refused) + } + if visits := exec.GetStateVisits(); slices.Contains(visits, "left") || slices.Contains(visits, "right") || slices.Contains(visits, "never") { + t.Errorf("%q: visits %v after the refusal, want no branch taken", tc.witness, visits) + } + } +} + +// A refused decision move leaves the token at the decision: no branch is taken, +// so neither branch's action ran. +func TestReplayRefusedDecisionTakesNoBranch(t *testing.T) { + m := parseExploreModel(t, choiceModel) + sym := m.action(t, "route") + good := m.exploreAction(t, "explore", "route").Outcomes[0].Witness + witness, err := ParseChoices(good[0].String() + "\n" + good[1].String() + "\nstep 7: decision select -> 3->nowhere\n") + if err != nil { + t.Fatal(err) + } + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatal(err) + } + for err == nil && exec.State() == StateRunning { + err = exec.Step() + } + var refused *ReplayError + if !errors.As(err, &refused) || refused.Move != 3 || !strings.Contains(refused.Faced, "3->nowhere is not enabled") { + t.Fatalf("error %v, want move 3 refused as not enabled", err) + } + tokens := exec.Tokens() + if len(tokens) != 1 { + t.Fatalf("%d tokens after the refusal, want the one at the decision", len(tokens)) + } + if decision, ok := tokens[0].Location.(*ast.DecisionNode); !ok || decision.Name != "select" { + t.Errorf("token at %T, want decision select", tokens[0].Location) + } + if got := FormatTraceValue(exec.Data()["handler"]); got != "0" { + t.Errorf("handler = %s, want 0: no branch ran", got) + } + if ctx.Unfollowed() == nil { + t.Error("the refusal is not reported for the run") + } +} + +// An executor driven call by call — as the REPL drives it — refuses the witness +// moves left over when it completes, as a run under ExecuteAction does; a +// witness the run uses up is followed whole. +func TestReplayRefusesMovesLeftOverByADrivenExecutor(t *testing.T) { + extra := ChoiceTaken{Kind: ChoiceTokenOrder, Step: 99, Among: []string{"1@a", "2@b"}, Took: "1@a", Alternatives: 2} + t.Run("action run to completion", func(t *testing.T) { + m := parseExploreModel(t, choiceModel) + good := m.exploreAction(t, "explore", "route").Outcomes[0].Witness + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(append(slices.Clone(good), extra))) + exec, err := ctx.CreateActionExecutor(m.action(t, "route")) + if err != nil { + t.Fatal(err) + } + assertRefusedLeftOver(t, exec.RunToCompletion(), len(good)+1, extra) + if exec.State() != StateCompleted { + t.Errorf("state %v, want completed: the run ended before the move was refused", exec.State()) + } + }) + t.Run("action stepped", func(t *testing.T) { + m := parseExploreModel(t, choiceModel) + good := m.exploreAction(t, "explore", "route").Outcomes[0].Witness + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(append(slices.Clone(good), extra))) + exec, err := ctx.CreateActionExecutor(m.action(t, "route")) + if err != nil { + t.Fatal(err) + } + steps := 0 + for err == nil && exec.State() == StateRunning { + err = exec.Step() + steps++ + } + assertRefusedLeftOver(t, err, len(good)+1, extra) + if exec.State() != StateCompleted || steps < 7 { + t.Errorf("state %v after %d steps, want completed at the last step", exec.State(), steps) + } + }) + t.Run("action followed whole", func(t *testing.T) { + m := parseExploreModel(t, choiceModel) + good := m.exploreAction(t, "explore", "route").Outcomes[0].Witness + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(good)) + exec, err := ctx.CreateActionExecutor(m.action(t, "route")) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if err := ctx.Unfollowed(); err != nil { + t.Errorf("unfollowed: %v", err) + } + }) + const dispatcher = `package test { + state def Dispatcher { + entry; then idle; + state idle; + state low; + transition first idle accept Go then low; + transition first idle accept Go then done; + } + }` + stateWitness := func(t *testing.T, lines string) []ChoiceTaken { + t.Helper() + witness, err := ParseChoices(lines) + if err != nil { + t.Fatal(err) + } + return witness + } + t.Run("state run to completion", func(t *testing.T) { + m := parseExploreModel(t, dispatcher) + witness := stateWitness(t, "state idle on accept Go -> 2->done\nstate low on accept Go -> 1->idle\n") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(m.state(t, "Dispatcher")) + if err != nil { + t.Fatal(err) + } + exec.SendSignal("Go", nil) + assertRefusedLeftOver(t, exec.RunToCompletion(), 2, witness[1]) + if exec.State() != StateCompleted { + t.Errorf("state %v, want completed", exec.State()) + } + }) + t.Run("state stepped", func(t *testing.T) { + m := parseExploreModel(t, dispatcher) + witness := stateWitness(t, "state idle on accept Go -> 2->done\nstate low on accept Go -> 1->idle\n") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(m.state(t, "Dispatcher")) + if err != nil { + t.Fatal(err) + } + exec.SendSignal("Go", nil) + assertRefusedLeftOver(t, exec.ProcessNextEvent(), 2, witness[1]) + if exec.State() != StateCompleted { + t.Errorf("state %v, want completed", exec.State()) + } + }) + t.Run("action run by the clock's advance", func(t *testing.T) { + m := parseExploreModel(t, choiceModel) + good := m.exploreAction(t, "explore", "route").Outcomes[0].Witness + for _, leftOver := range []bool{false, true} { + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness := slices.Clone(good) + if leftOver { + witness = append(witness, extra) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateActionExecutor(m.action(t, "route")) + if err != nil { + t.Fatal(err) + } + _, err = ctx.Advance(0) + switch { + case leftOver: + assertRefusedLeftOver(t, err, len(good)+1, extra) + case err != nil: + t.Fatalf("a witness the advance uses up is followed whole: %v", err) + } + if exec.State() != StateCompleted { + t.Errorf("left over %v: state %v, want completed", leftOver, exec.State()) + } + } + }) + t.Run("state not yet complete", func(t *testing.T) { + m := parseExploreModel(t, dispatcher) + witness := stateWitness(t, "state idle on accept Go -> 1->low\nstate low on accept Go -> 1->idle\n") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(m.state(t, "Dispatcher")) + if err != nil { + t.Fatal(err) + } + exec.SendSignal("Go", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("a move left for a machine still running is not left over: %v", err) + } + if exec.State() == StateCompleted { + t.Fatal("the machine completed in low") + } + }) +} + +// A move naming an object the witness binds to a path the run has no object at is +// refused as a witness object, at that move: the run may make the object moves in. +func TestReplayRefusesAMoveNamingAnObjectTheRunDidNotMake(t *testing.T) { + m := parseExploreModel(t, choiceModel) + good := m.exploreAction(t, "explore", "route").Outcomes[0].Witness + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + w := Witness{ + Objects: []ObjectNamed{{ID: 9, Path: "test::beacon#1"}}, + Choices: slices.Clone(good), + } + w.Choices[0] = ChoiceTaken{Kind: ChoiceTokenOrder, Step: good[0].Step, + Among: []string{"2@a of object #9", "3@b of object #9"}, Took: "2@a of object #9", Alternatives: 2} + mustSchedule(t, ctx, ReplayOf(w)) + exec, err := ctx.CreateActionExecutor(m.action(t, "route")) + if err != nil { + t.Fatal(err) + } + err = exec.RunToCompletion() + var refused *WitnessObjectError + if !errors.As(err, &refused) || !errors.Is(err, ErrWitnessObject) { + t.Fatalf("error %T %v, want a WitnessObjectError", err, err) + } + if refused.Object != w.Objects[0] || !strings.Contains(refused.Reason, "test::beacon#1") { + t.Errorf("refused %+v", refused) + } + if exec.State() == StateCompleted { + t.Error("the run went on past the move it refused") + } +} + +// Every choice reads back from the line that spells it, whatever punctuation the +// names it carries share with the line; a witness of such lines reads back whole. +func TestChoiceLinesRoundTripPunctuatedNames(t *testing.T) { + names := []string{"a, b", "x -> y", "p; q", "k: v", "it's", `back\slash`, "first of all", "step 3", " padded ", "tab\there", "line\nbreak", "plain", "one among many", "fast p=0.7", "then drew 0.5"} + var choices []ChoiceTaken + for i, name := range names { + others := []string{name, names[(i+1)%len(names)], names[(i+2)%len(names)]} + choices = append(choices, + ChoiceTaken{Kind: ChoiceTokenOrder, Step: i + 1, Alternatives: 3, Taken: 0, Among: others, Took: name}, + ChoiceTaken{Kind: ChoiceDecisionBranch, Step: i + 1, Where: "decision " + name, Took: "1->" + name}, + ChoiceTaken{Kind: ChoiceDecisionBranch, Step: i + 1, Where: "decision " + name, Alternatives: 3, Taken: 0, Among: others, Took: name, Weights: []float64{0.5, 0.25, 0.25}, Drew: 0.125, Drawn: true}, + ChoiceTaken{Kind: ChoiceDecisionBranch, Step: i + 1, Where: "decision " + name, Alternatives: 3, Taken: 2, Among: others, Took: others[2], Weights: []float64{0, 1e-9, 0.999999999}}, + ChoiceTaken{Kind: ChoiceTransition, Where: "state " + name + " on accept " + name, Took: "2->" + name}, + ChoiceTaken{Kind: ChoiceRegionOrder, Where: "on accept " + name, Alternatives: 3, Taken: 1, Among: []string{others[1], name, others[2]}, Took: name}, + ChoiceTaken{Kind: ChoiceDueOrder, Where: "t=5.0", Alternatives: 3, Taken: 2, Among: []string{others[1], others[2], name}, Took: name}, + ) + } + var lines []string + for _, want := range choices { + line := want.String() + got, err := ParseChoice(line) + if err != nil { + t.Errorf("%s: %v", line, err) + continue + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s reads back as %+v, want %+v", line, got, want) + } + lines = append(lines, line) + } + for _, line := range lines { + if strings.ContainsAny(line, "\n\r") { + t.Errorf("%q spans lines", line) + } + } + text := strings.Join(lines[:5], "; ") + "\n" + strings.Join(lines[5:], "\n") + "\n\nstep 1: tokens 1@'a, b'\nanything -> at all; after the blank line\n" + got, err := ParseChoices(text) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, choices) { + t.Errorf("witness reads back as\n%s\nwant\n%s", FormatChoices(got), FormatChoices(choices)) + } + plain := ChoiceTaken{Kind: ChoiceTokenOrder, Step: 3, Alternatives: 2, Among: []string{"2@a", "3@b"}, Took: "2@a"} + if got := plain.String(); got != "step 3: 2@a first of 2@a, 3@b" { + t.Errorf("a plain name is quoted: %s", got) + } +} + +// A quoted name left open, or closed where the line's punctuation does not follow, +// is a parse error naming the quote. +func TestParseChoiceRejectsAnUnclosedQuote(t *testing.T) { + for _, line := range []string{ + "step 3: 'a, b first of 'a, b', 3@b", + "step 3: 'a, b'x first of 'a, b', 3@b", + "'state idle -> 2->right", + "on accept go: 'b1 first of a1, 'b1'", + } { + _, err := ParseChoice(line) + var parse *ChoiceParseError + if !errors.As(err, &parse) || parse.Reason != unclosedQuote { + t.Errorf("%s: %v, want the unclosed quote refused", line, err) + } + } +} diff --git a/internal/core/runtime/requirement_test.go b/internal/exec/runtime/requirement_test.go similarity index 90% rename from internal/core/runtime/requirement_test.go rename to internal/exec/runtime/requirement_test.go index e8956512c7..38b9d8155d 100644 --- a/internal/core/runtime/requirement_test.go +++ b/internal/exec/runtime/requirement_test.go @@ -5,11 +5,11 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) func TestRequirementEvaluation_RequireWithLiteral(t *testing.T) { @@ -37,7 +37,7 @@ func TestRequirementEvaluation_RequireWithLiteral(t *testing.T) { model := semantics.NewModel(resolver) // Create runtime context - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) // Resolve requirements rootScope := idx.DocumentRoot("test.sysml") @@ -96,7 +96,7 @@ func TestRequirementEvaluation_Assume(t *testing.T) { model := semantics.NewModel(resolver) // Create runtime context - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) // Resolve requirement rootScope := idx.DocumentRoot("test.sysml") @@ -139,7 +139,7 @@ func TestRequirementEvaluation_SubjectNotFound(t *testing.T) { model := semantics.NewModel(resolver) // Create runtime context - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) // Resolve requirement rootScope := idx.DocumentRoot("test.sysml") @@ -190,7 +190,7 @@ func TestRequirementEvaluation_Complete(t *testing.T) { model := semantics.NewModel(resolver) // Create runtime context - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) // Resolve requirement rootScope := idx.DocumentRoot("test.sysml") diff --git a/internal/exec/runtime/reschedule_test.go b/internal/exec/runtime/reschedule_test.go new file mode 100644 index 0000000000..9cbec96561 --- /dev/null +++ b/internal/exec/runtime/reschedule_test.go @@ -0,0 +1,195 @@ +package runtime + +import ( + "errors" + "fmt" + "testing" +) + +// rescheduleSource is an object whose machine offers two transitions on one +// signal, so which fires is a choice: the first declared under `reverse` and +// `declared`, a draw under a seed. +const rescheduleSource = ` + attribute def Go; + part def Chooser { + exhibit state pick { + entry; then start; + state start; + state first; + state second; + transition to_first first start accept Go then first; + transition to_second first start accept Go then second; + transition back_first first first accept Go then start; + transition back_second first second accept Go then start; + } + } + part chooser : Chooser; +` + +// rescheduleRun instantiates the chooser and answers a function posting Go and +// advancing the clock, which reports the state the machine is then in. +func rescheduleRun(t *testing.T) (*Context, func() string) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "chooser.sysml", parseAndBuild(t, rescheduleSource)) + root := idx.DocumentRoot("chooser.sysml") + chooser, err := ctx.Instantiate(resolveSymbol(t, root, "chooser")) + if err != nil { + t.Fatalf("Instantiate(chooser): %v", err) + } + machines := chooser.ExhibitedStates() + if len(machines) != 1 || machines[0].State == nil { + t.Fatalf("chooser exhibits %d machines; want pick", len(machines)) + } + goSym := resolveSymbol(t, root, "Go") + return ctx, func() string { + t.Helper() + msg, err := ctx.SignalMessage(goSym, nil, chooser) + if err != nil { + t.Fatalf("SignalMessage(Go): %v", err) + } + ctx.PostMessage(msg) + if _, err := ctx.Advance(1); err != nil { + t.Fatalf("Advance: %v", err) + } + return activeStateNames(machines[0].State) + } +} + +// seedGoingSecond is a seed whose first `draws` draws each take the machine to +// `second`. +func seedGoingSecond(t *testing.T, draws int) SchedulePolicy { + t.Helper() +seeds: + for seed := 0; seed < 256; seed++ { + policy := mustPolicy(t, fmt.Sprintf("seed:%d", seed)) + ctx, fireGo := rescheduleRun(t) + if err := ctx.Reschedule(policy); err != nil { + t.Fatal(err) + } + for i := 0; i < draws; i++ { + if fireGo() != "second" { + continue seeds + } + fireGo() + } + return policy + } + t.Fatalf("no seed under 256 draws to_second %d times running", draws) + return SchedulePolicy{} +} + +// Reschedule reaches the machine an object runs and the clock driving it: from +// the next step on they choose under the new policy, where they stand kept, +// while SetSchedule leaves them under the policy they started with. +func TestRescheduleReachesTheDrivenRuns(t *testing.T) { + seeded := seedGoingSecond(t, 1) + ctx, fireGo := rescheduleRun(t) + if got := fireGo(); got != "first" { + t.Fatalf("Go under the default reverse policy went to %s, want first", got) + } + if got := fireGo(); got != "start" { + t.Fatalf("Go back went to %s, want start", got) + } + if err := ctx.SetSchedule(seeded); err != nil { + t.Fatal(err) + } + if got := fireGo(); got != "first" { + t.Fatalf("Go after SetSchedule(%s) went to %s, want first: a run under way keeps its policy", seeded, got) + } + if got := fireGo(); got != "start" { + t.Fatalf("Go back went to %s, want start", got) + } + if err := ctx.Reschedule(seeded); err != nil { + t.Fatal(err) + } + if got := fireGo(); got != "second" { + t.Fatalf("Go after Reschedule(%s) went to %s, want second", seeded, got) + } + if got := fireGo(); got != "start" { + t.Fatalf("Go back went to %s, want start", got) + } + if err := ctx.Reschedule(mustPolicy(t, "declared")); err != nil { + t.Fatal(err) + } + if got := fireGo(); got != "first" { + t.Fatalf("Go after Reschedule(declared) went to %s, want first", got) + } + if got := ctx.Clock().Now(); got != 7 { + t.Errorf("clock after seven advances = %v, want 7: rescheduling keeps the clock", got) + } + if got := len(ctx.Choices()); got != 4 { + t.Errorf("choices after four turns out of start = %d, want 4: rescheduling keeps the choices so far", got) + } +} + +// Restoring a snapshot taken before a Reschedule puts the runs under way back +// under the schedulers they had, where they had them, so the turns after it go +// as they would have. +func TestRescheduleIsUndoneWithTheSnapshot(t *testing.T) { + seeded := seedGoingSecond(t, 2) + ctx, fireGo := rescheduleRun(t) + if err := ctx.Reschedule(seeded); err != nil { + t.Fatal(err) + } + if got := fireGo(); got != "second" { + t.Fatalf("Go under %s went to %s, want second", seeded, got) + } + if got := fireGo(); got != "start" { + t.Fatalf("Go back went to %s, want start", got) + } + snapshot, err := ctx.Snapshot() + if err != nil { + t.Fatal(err) + } + if err := ctx.Reschedule(mustPolicy(t, "declared")); err != nil { + t.Fatal(err) + } + if got := fireGo(); got != "first" { + t.Fatalf("Go after Reschedule(declared) went to %s, want first", got) + } + snapshot.Restore() + if got := fireGo(); got != "second" { + t.Errorf("Go after restoring the snapshot went to %s, want second: the second draw of %s", got, seeded) + } +} + +// A seed set by Reschedule starts its draws over, so a turn under it goes where +// a run started under that seed goes first. +func TestRescheduleStartsASeedOver(t *testing.T) { + first := make(map[int]string) + for seed := 0; seed < 8; seed++ { + ctx, fireGo := rescheduleRun(t) + if err := ctx.Reschedule(mustPolicy(t, fmt.Sprintf("seed:%d", seed))); err != nil { + t.Fatal(err) + } + first[seed] = fireGo() + } + ctx, fireGo := rescheduleRun(t) + fireGo() + for seed := 0; seed < 8; seed++ { + if got := fireGo(); got != "start" { + t.Fatalf("Go back went to %s, want start", got) + } + if err := ctx.Reschedule(mustPolicy(t, fmt.Sprintf("seed:%d", seed))); err != nil { + t.Fatal(err) + } + if got := fireGo(); got != first[seed] { + t.Errorf("seed:%d set after earlier turns went to %s; a run started under it goes to %s", seed, got, first[seed]) + } + } +} + +// Reschedule refuses an exploration as SetSchedule does, and refuses to change a +// run from inside one of its steps. +func TestRescheduleRefusals(t *testing.T) { + ctx, _ := rescheduleRun(t) + if err := ctx.Reschedule(mustPolicy(t, "explore")); !errors.Is(err, ErrExploreUndriven) { + t.Errorf("Reschedule(explore) = %v, want ErrExploreUndriven", err) + } + leave := ctx.beginRun() + err := ctx.Reschedule(mustPolicy(t, "declared")) + leave() + if !errors.Is(err, ErrRescheduleMidRun) { + t.Errorf("Reschedule inside a run = %v, want ErrRescheduleMidRun", err) + } +} diff --git a/internal/exec/runtime/robustness_accept_payload_test.go b/internal/exec/runtime/robustness_accept_payload_test.go new file mode 100644 index 0000000000..f635328768 --- /dev/null +++ b/internal/exec/runtime/robustness_accept_payload_test.go @@ -0,0 +1,82 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessAcceptPayload exercises an accept whose payload flows on +// through the node's output: a specialized signal satisfies a general accept +// and its attributes read through the pin; an accept nothing posts to is a +// typed deadlock; a send to an object running no behavior completes. +func TestRuntimeRobustnessAcceptPayload(t *testing.T) { + t.Run("specialized_signal_flows_through_the_pin", testAcceptPayloadSpecializedSignalFlows) + t.Run("unposted_accept_is_a_typed_deadlock", testAcceptPayloadUnpostedDeadlock) + t.Run("send_to_an_idle_object_completes", testAcceptPayloadSendToIdleObject) +} + +// signalPayloadModel runs one sender and one accept typed Reading in an action, +// the accepted value flowing to a consumer that doubles its level. +func signalPayloadModel(sender string) string { + return `package test { + private import ScalarValues::*; + attribute def Reading { attribute level : Integer; } + attribute def Calibrated :> Reading; + action def Communicator { + out total : Integer = 0; + first start; + action sender { ` + sender + ` } + action receiver accept msg : Reading; + action consumer { in value : Reading; assign total := value.level * 2; } + done; + flow receiver.msg to consumer.value; + succession first start then sender; + succession first sender then receiver; + succession first receiver then consumer; + succession first consumer then done; + } + }` +} + +func testAcceptPayloadSpecializedSignalFlows(t *testing.T) { + outputs, err := executeActionSource(t, "Communicator", signalPayloadModel("send new Calibrated(level = 8) to receiver;")) + if err != nil { + t.Fatal(err) + } + if got := intOutput(t, outputs, "total"); got != 16 { + t.Fatalf("total = %d, want 16 from the Calibrated level accepted as a Reading", got) + } +} + +func testAcceptPayloadUnpostedDeadlock(t *testing.T) { + _, err := executeActionSource(t, "Communicator", signalPayloadModel("")) + if !errors.Is(err, ErrAcceptDeadlock) || !strings.Contains(err.Error(), "accept msg waiting since step 3 for a message of type Reading") { + t.Fatalf("error = %v, want ErrAcceptDeadlock naming the waiting accept", err) + } +} + +func testAcceptPayloadSendToIdleObject(t *testing.T) { + outputs, err := executeActionSource(t, "Notifier", `package test { + private import ScalarValues::*; + attribute def Go { attribute level : Integer; } + part def Target; + action def Notifier { + out sent : Integer = 0; + first start; + action make { out target : Target = new Target(); } + action notify { in target : Target; send new Go(level = 4) to target; assign sent := 4; } + done; + flow make.target to notify.target; + succession first start then make; + succession first make then notify; + succession first notify then done; + } + }`) + if err != nil { + t.Fatal(err) + } + if got := intOutput(t, outputs, "sent"); got != 4 { + t.Fatalf("sent = %d, want 4", got) + } +} diff --git a/internal/exec/runtime/robustness_addressed_via_bound_reference_test.go b/internal/exec/runtime/robustness_addressed_via_bound_reference_test.go new file mode 100644 index 0000000000..f5646627f6 --- /dev/null +++ b/internal/exec/runtime/robustness_addressed_via_bound_reference_test.go @@ -0,0 +1,94 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessAddressedViaBoundReference exercises the failure modes of +// a send addressed to a receiver over a via path rooted at a bound reference: the +// port and the receiver are the bound object's, and are refused under the path +// as written when that object lacks them. +func TestRuntimeRobustnessAddressedViaBoundReference(t *testing.T) { + t.Run("receiver_the_bound_object_lacks", testAddressedViaBoundReferenceMissingReceiver) + t.Run("port_the_bound_object_lacks", testAddressedViaBoundReferenceMissingPort) + t.Run("receiver_of_the_sender_alone", testAddressedViaBoundReferenceSenderReceiver) +} + +// hostAddressing is a host whose performed action sends the given addressed +// send with `dev` bound to its device, which listens on `rx` as `listener`. +func hostAddressing(send string) string { + return `package test { + item def Ping; + port def PingOut { out item ping : Ping; } + part def Device { + port tx : PingOut; + port rx : ~PingOut; + connect tx to rx; + exhibit state listener { + entry; then idle; + state idle; + transition first idle accept Ping via rx then idle; + } + } + part def Host { + part device : Device; + action def Fire { + in ref dev : Device; + first start; + then action go ` + send + ` + then done; + } + action def Round { + first start; + then action fire : Fire { in ref dev = this.device; } + then done; + } + perform action round : Round; + } + }` +} + +// testAddressedViaBoundReferenceMissingReceiver: the receiver is looked up on +// the bound device, so a name it runs no behavior under is unreachable. +func testAddressedViaBoundReferenceMissingReceiver(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostAddressing( + "send new Ping() via dev.tx to missing;", + ), "test::Host") + var typed *UnreachableSendReceiverError + if !errors.As(err, &typed) || typed.Port != "dev.tx" || typed.Receiver != "missing" { + t.Fatalf("error = %v, want UnreachableSendReceiverError for missing through dev.tx", err) + } + if !errors.Is(err, ErrUnreachableSendReceiver) || !strings.Contains(err.Error(), `"missing" is not reachable through port "dev.tx"`) { + t.Fatalf("error = %v, want the written path in the report", err) + } +} + +// testAddressedViaBoundReferenceMissingPort: the path names the bound device's +// port, so one it does not declare is refused under the path as written. +func testAddressedViaBoundReferenceMissingPort(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostAddressing( + "send new Ping() via dev.nope to listener;", + ), "test::Host") + var typed *UnknownSendPortError + if !errors.As(err, &typed) || typed.Port != "dev.nope" || typed.Receiver != "listener" { + t.Fatalf("error = %v, want UnknownSendPortError for dev.nope", err) + } + if !errors.Is(err, ErrSendViaUnknownPort) { + t.Fatalf("error = %v, want ErrSendViaUnknownPort", err) + } +} + +// testAddressedViaBoundReferenceSenderReceiver: a receiver only the sender runs +// is no node of the bound device the port belongs to, so it is unreachable +// rather than delivered to the sender's own behavior. +func testAddressedViaBoundReferenceSenderReceiver(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostAddressing( + "send new Ping() via dev.tx to round;", + ), "test::Host") + var typed *UnreachableSendReceiverError + if !errors.As(err, &typed) || typed.Port != "dev.tx" || typed.Receiver != "round" { + t.Fatalf("error = %v, want UnreachableSendReceiverError for round through dev.tx", err) + } +} diff --git a/internal/exec/runtime/robustness_behavior_connection_end_test.go b/internal/exec/runtime/robustness_behavior_connection_end_test.go new file mode 100644 index 0000000000..ae3a59bb46 --- /dev/null +++ b/internal/exec/runtime/robustness_behavior_connection_end_test.go @@ -0,0 +1,76 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessBehaviorConnectionEnd exercises the failure modes of a +// behavior's own connection whose end starts at a parameter the behavior binds: +// the end is read through the binding, so what the binding holds decides them. +func TestRuntimeRobustnessBehaviorConnectionEnd(t *testing.T) { + t.Run("end_rooted_at_a_value", testBehaviorConnectionEndRootedAtAValue) + t.Run("end_rooted_at_nothing", testBehaviorConnectionEndRootedAtNothing) +} + +// hostWiring is a host whose performed action connects `dev.p` to a listener +// through a parameter `snk` declared and bound as given, then sends via `dev.p`. +func hostWiring(param, binding string) string { + return `package test { + private import ScalarValues::*; + item def Ping; + port def PingOut { out item ping : Ping; } + part def Device { port p : PingOut; } + part def Listener { + port local : ~PingOut; + exhibit state listen { + entry; then idle; + state idle; + transition first idle accept Ping via local then idle; + } + } + part def Host { + part device : Device; + part listener : Listener; + action def Fire { + in ref dev : Device; + ` + param + ` + connect dev.p to snk.local; + first start; + then action go send new Ping() via dev.p; + then done; + } + action def Round { + first start; + then action fire : Fire { in ref dev = this.device; ` + binding + ` } + then done; + } + perform action round : Round; + } + }` +} + +// testBehaviorConnectionEndRootedAtAValue: a parameter holding a number holds no +// object whose port the end could name; the run refuses rather than resolving the +// end as a feature of the sender. +func testBehaviorConnectionEndRootedAtAValue(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostWiring( + "in snk : Integer;", "in snk = 4;", + ), "test::Host") + if !errors.Is(err, ErrSendTargetNotObject) || !strings.Contains(err.Error(), `"snk" holds 4, which is no object to address "snk.local" to`) { + t.Fatalf("error = %v, want ErrSendTargetNotObject over a number", err) + } +} + +// testBehaviorConnectionEndRootedAtNothing: an optional parameter bound to nothing +// holds no object whose port the end could name, so the send is refused as one +// addressing nothing instead of dropped or delivered to the sender. +func testBehaviorConnectionEndRootedAtNothing(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostWiring( + "in ref snk : Listener[0..1];", "in ref snk = null;", + ), "test::Host") + if !errors.Is(err, ErrSendTargetNotObject) || !strings.Contains(err.Error(), `"snk" holds null, which is no object to address "snk.local" to`) { + t.Fatalf("error = %v, want ErrSendTargetNotObject over an empty parameter", err) + } +} diff --git a/internal/exec/runtime/robustness_binding_end_names_test.go b/internal/exec/runtime/robustness_binding_end_names_test.go new file mode 100644 index 0000000000..94e4a72fff --- /dev/null +++ b/internal/exec/runtime/robustness_binding_end_names_test.go @@ -0,0 +1,112 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessBindingEndNames covers how a name at a binding end of a +// performed action resolves: parameter before performer feature, never a cycle. +func TestRuntimeRobustnessBindingEndNames(t *testing.T) { + t.Run("parameter_masks_the_performers_feature", testBindingEndParameterMasksFeature) + t.Run("pin_valued_by_itself_reads_what_it_masks", testBindingEndPinReadsWhatItMasks) + t.Run("name_resolving_to_nothing_is_refused", testBindingEndUnresolvedName) + t.Run("required_pin_given_none_is_refused", testBindingEndRequiredPinGivenNone) +} + +// bindingEndHost is a part whose performed action has a parameter named as the +// part's own attribute; the pin of a nested action is bound to that name. +func bindingEndHost(pin, binding string) string { + return ` + package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + part def Host { + attribute level : Integer = 7; + attribute seen : Integer = -1; + perform action relaying { + in level : Integer[0..1]; + first start; + action noting { + ` + pin + ` + assign seen := size(n); + } + ` + binding + ` + done; + succession first start then noting; + succession first noting then done; + } + } + }` +} + +// testBindingEndParameterMasksFeature: `bind noting.n = level` written in the +// action's body names the action's parameter, given nothing, not the part's 7. +func testBindingEndParameterMasksFeature(t *testing.T) { + ctx, inst, err := instantiateWithLibraries(t, bindingEndHost("in n : Integer[0..1];", "bind noting.n = level;"), "test::Host") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + if got := readInt(t, ctx, inst, "seen"); got != 0 { + t.Fatalf("seen = %v, want 0: the parameter admitting none masks the part's level", got) + } +} + +// testBindingEndPinReadsWhatItMasks: `inout n = n` reads the n around the usage, +// not the pin itself nor the parameter it redefines. +func testBindingEndPinReadsWhatItMasks(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + action def Noting { + inout n : Integer; + out twice : Integer; + first start; + action doubling { assign twice := 2 * n; } + done; + succession first start then doubling; + succession first doubling then done; + } + part def Host { + attribute n : Integer = 7; + attribute seen : Integer = -1; + perform action relaying { + first start; + action noting : Noting { inout n = n; } + action recording { assign seen := noting.twice; } + done; + succession first start then noting; + succession first noting then recording; + succession first recording then done; + } + } + }` + ctx, inst, err := instantiateWithLibraries(t, src, "test::Host") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + if got := readInt(t, ctx, inst, "seen"); got != 14 { + t.Fatalf("seen = %v, want 14: the pin reads the part's n", got) + } +} + +// testBindingEndUnresolvedName: a binding end naming nothing is ErrBindingEnd. +func testBindingEndUnresolvedName(t *testing.T) { + _, _, err := instantiateWithLibraries(t, bindingEndHost("in n : Integer[0..1];", "bind noting.n = nowhere;"), "test::Host") + if !errors.Is(err, ErrBindingEnd) || !strings.Contains(err.Error(), "nowhere") { + t.Fatalf("error = %v, want ErrBindingEnd naming nowhere", err) + } +} + +// testBindingEndRequiredPinGivenNone: a required pin bound to an empty parameter +// is refused, not filled from the part. +func testBindingEndRequiredPinGivenNone(t *testing.T) { + _, _, err := instantiateWithLibraries(t, bindingEndHost("in n : Integer;", "bind noting.n = level;"), "test::Host") + if err == nil { + t.Fatal("a required pin bound to an empty parameter was accepted") + } + if !errors.Is(err, ErrUnboundParameter) && !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("error = %v, want a typed refusal of the empty required pin", err) + } +} diff --git a/internal/exec/runtime/robustness_braced_block_test.go b/internal/exec/runtime/robustness_braced_block_test.go new file mode 100644 index 0000000000..ff5e0582df --- /dev/null +++ b/internal/exec/runtime/robustness_braced_block_test.go @@ -0,0 +1,112 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessBracedBlock exercises the edges of a braced `entry { … }`, +// `do { … }`, `exit { … }` or transition `do { … }`, each one anonymous action: +// an empty block, a block of one `terminate`, a name the block does not bind, and +// a block-local attribute reached from outside the block. Each is a typed error +// or a clean run, never a panic or a hang. +func TestRuntimeRobustnessBracedBlock(t *testing.T) { + t.Run("empty_blocks_run_to_completion", testEmptyBracedBlocksRunToCompletion) + t.Run("block_of_only_terminate_ends_the_block", testBlockOfOnlyTerminateEndsTheBlock) + t.Run("unbound_name_in_a_block", testUnboundNameInABracedBlock) + t.Run("block_local_attribute_reached_from_outside", testBlockLocalAttributeReachedFromOutside) +} + +// testEmptyBracedBlocksRunToCompletion: `entry { }`, `do { }`, `exit { }` and a +// transition's `do { }` declare an action of no statements; the state completes. +func testEmptyBracedBlocksRunToCompletion(t *testing.T) { + _, visited, err := executeStateSource(t, "Machine", `package test { + state def Machine { + entry; then s; + state s { + entry { } + do { } + exit { } + } + state t; + transition first s do { } then t; + } + }`) + if err != nil { + t.Fatalf("error = %v, want none", err) + } + if got := strings.Join(visited, ","); got != "s,t" { + t.Fatalf("visited = %q, want s,t", got) + } +} + +// testBlockOfOnlyTerminateEndsTheBlock: a block holding a lone `terminate;` ends +// itself and nothing else; the machine goes on to the next state. +func testBlockOfOnlyTerminateEndsTheBlock(t *testing.T) { + outputs, visited, err := executeStateSource(t, "Machine", `package test { + private import ScalarValues::*; + state def Machine { + attribute n : Integer = 0; + entry; then s; + state s { + entry { terminate; } + do { terminate; } + exit { terminate; } + } + state t { entry { assign n := 1; } } + transition first s do { terminate; } then t; + } + }`) + if err != nil { + t.Fatalf("error = %v, want none", err) + } + if got := strings.Join(visited, ","); got != "s,t" { + t.Fatalf("visited = %q, want s,t", got) + } + if got := outputs["n"]; got.Const.Int != 1 { + t.Fatalf("n = %v, want 1", got) + } +} + +// testUnboundNameInABracedBlock: a statement reading a name nothing declares is +// refused as an unresolved reference, the error naming the block's state. +func testUnboundNameInABracedBlock(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + private import ScalarValues::*; + state def Machine { + attribute n : Integer = 0; + entry; then s; + state s { + entry { assign n := nowhere + 1; } + } + } + }`) + if !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("error = %v, want ErrUnresolvedReference", err) + } + if !strings.Contains(err.Error(), "s") { + t.Fatalf("error = %v, want it to name the state", err) + } +} + +// testBlockLocalAttributeReachedFromOutside: an attribute a braced block declares +// is the anonymous action's, so a statement outside the block does not see it. +func testBlockLocalAttributeReachedFromOutside(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + private import ScalarValues::*; + state def Machine { + attribute n : Integer = 0; + entry; then s; + state s { + entry { attribute k : Integer = 2; } + exit { assign n := k; } + } + state t; + transition first s then t; + } + }`) + if !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("error = %v, want ErrUnresolvedReference", err) + } +} diff --git a/internal/exec/runtime/robustness_call_results_test.go b/internal/exec/runtime/robustness_call_results_test.go new file mode 100644 index 0000000000..6b0d144ae9 --- /dev/null +++ b/internal/exec/runtime/robustness_call_results_test.go @@ -0,0 +1,744 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessCallResults exercises the failure modes of a synchronous +// call (StateExecutor.Call): held, untaken, empty, repeated and erroring calls, +// and the caller released at its own step's end, ahead of later events and timers. +func TestRuntimeRobustnessCallResults(t *testing.T) { + t.Run("call_left_deferred", testCallResultsLeftDeferred) + t.Run("deferred_call_returns_once_recalled", testCallResultsDeferredRecalled) + t.Run("call_left_queued_behind_termination", testCallResultsLeftQueued) + t.Run("call_nothing_takes_returns_empty", testCallResultsNothingTakes) + t.Run("results_do_not_carry_over", testCallResultsDoNotCarryOver) + t.Run("dispatch_error_reaches_the_caller", testCallResultsDispatchError) + t.Run("released_before_the_completion_step", testCallResultsReleasedBeforeCompletion) + t.Run("timer_after_the_step_is_not_drained", testCallResultsTimerNotDrained) + t.Run("queued_signal_is_dispatched_before_the_call", testCallResultsQueuedSignalFirst) + t.Run("declared_operation_returns_its_parameters_only", testCallResultsDeclaredReturns) + t.Run("inout_argument_returns_as_passed_unless_written", testCallResultsInoutArgument) + t.Run("overloaded_operation_returns_the_selected_declaration", testCallResultsOverloaded) + t.Run("arguments_must_bind_the_declared_inputs", testCallResultsArguments) + t.Run("overload_fires_the_trigger_naming_its_declaration", testCallResultsOverloadDispatch) + t.Run("arguments_must_conform_to_their_parameters", testCallResultsArgumentTypes) + t.Run("omitted_input_carries_its_default", testCallResultsDefaults) + t.Run("calc_and_constraint_return_their_result_only", testCallResultsCalcConstraint) + t.Run("undone_move_restores_the_call", testCallResultsMoveRollback) +} + +const callResultsModel = `package test { + action def Answer { + out result : Integer; + first start; + action answering { assign result := 42; } + done; + succession first start then answering; + succession first answering then done; + } + state def Machine { + attribute result : Integer = 0; + attribute divisor : Integer = 0; + entry; then idle; + state idle; + state holding { defer ask(); } + state answered; + state quiet; + transition first idle accept ask() do perform Answer then answered; + transition first idle accept Hold then holding; + transition first idle accept Finish then done; + transition first answered accept ask() then quiet; + transition first quiet accept ask() do assign result := result / divisor then done; + } +}` + +// callDeferredModel: the step taking `Hold` sends `Release` behind the call and +// enters `holding`, which defers `ask` until `Release` returns the machine to `idle`. +const callDeferredModel = `package test { + private import ScalarValues::*; + attribute def Hold; + attribute def Release; + action def Answer { + out result : Integer; + first start; + action answering { assign result := 42; } + done; + succession first start then answering; + succession first answering then done; + } + state def Machine { + attribute result : Integer = 0; + entry; then idle; + state idle; + state holding { defer ask(); } + state answered; + transition first idle accept Hold do send new Release() then holding; + transition first holding accept Release then idle; + transition first idle accept ask() do perform Answer then answered; + } +}` + +// callBoundaryModel: the step answering `ask` enters `answered`, whose completion +// transition leads to `later`, whose timer fires an effect that fails. +const callBoundaryModel = `package test { + private import SI::*; + private import ScalarValues::*; + action def Answer { + out result : Integer; + first start; + action answering { assign result := 42; } + done; + succession first start then answering; + succession first answering then done; + } + state def Machine { + attribute result : Integer = 0; + attribute divisor : Integer = 0; + attribute poked : Boolean = false; + entry; then idle; + state idle; + state answered; + state later; + transition first idle accept ask() do perform Answer then answered; + transition first idle accept Poke do assign poked := true then idle; + transition first answered then later; + transition first later accept after 1 [s] do assign result := result / divisor then done; + } +}` + +// callOwnerModel: `Owner` declares `ask` returning `result` alone and `nudge` +// carrying `x` in and out; its machine answers `ask` and the undeclared `tell` +// with a helper that also writes `log`, takes `nudge` in `idle` without writing +// `x`, in `answered` with a helper that increments it, and never in `told`. +const callOwnerModel = `package P { + private import ScalarValues::*; + part def Owner { + attribute log : String = ""; + attribute result : Integer = 0; + action def ask { out result : Integer; } + action def nudge { inout x : Integer; } + action def Answer { + inout log : String; + out result : Integer; + first start; + action answering { assign result := 42; assign log := "answered"; } + done; + succession first start then answering; + succession first answering then done; + } + action def Increment { + inout x : Integer; + first start; + action incrementing { assign x := x + 1; } + done; + succession first start then incrementing; + succession first incrementing then done; + } + exhibit state sm { + entry; then idle; + state idle; + state answered; + state told; + transition first idle accept nudge(x) then idle; + transition first idle accept ask() do action : Answer { inout log = log; } then answered; + transition first answered accept nudge(x) do action : Increment { inout x = x; } then answered; + transition first answered accept tell() do action : Answer { inout log = log; } then told; + } + } + part owner : Owner; +}` + +// callOverloadedModel: the owner sees two operations named `compute`, told apart +// by their input's type and declaring different outputs; the step answering the +// call writes both outputs. +const callOverloadedModel = `package P { + private import ScalarValues::*; + part def Owner { + action def compute { in x : Integer; out n : Integer; } + action def compute { in x : String; out s : String; } + action def Both { + out n : Integer; + out s : String; + first start; + action writing { assign n := 1; assign s := "one"; } + done; + succession first start then writing; + succession first writing then done; + } + exhibit state sm { + entry; then idle; + state idle; + transition first idle accept compute(x) do perform Both then idle; + } + } + part owner : Owner; +}` + +// testCallResultsOverloaded: a call of an overloaded operation releases the +// outputs of the declaration its arguments select, and one telling the +// declarations apart by nothing is refused as ambiguous before it is queued. +func testCallResultsOverloaded(t *testing.T) { + exec := callMachineOwnedBy(t, callOverloadedModel) + got, err := exec.Call("compute", map[string]Value{"x": strValue("text")}) + if err != nil { + t.Fatal(err) + } + if s, ok := got["s"]; !ok || s.Str() != "one" || len(got) != 1 { + t.Errorf("compute(\"text\") returned %v, want s = \"one\" alone", got) + } + got, err = exec.Call("compute", map[string]Value{"x": constInt(3)}) + if err != nil { + t.Fatal(err) + } + if n, ok := got["n"]; !ok || n.Const.Int != 1 || len(got) != 1 { + t.Errorf("compute(3) returned %v, want n = 1 alone", got) + } + got, err = exec.Call("compute", nil) + if !errors.Is(err, ErrAmbiguousInvocation) { + t.Fatalf("compute() = %v, %v; want ErrAmbiguousInvocation", got, err) + } + if n := exec.eventQueue.Len(); n != 0 { + t.Errorf("%d event(s) queued, want the ambiguous call refused before it is queued", n) + } +} + +const callArgumentsModel = `package P { + private import ScalarValues::*; + part def Owner { + action def compute { in x : Integer; out n : Integer; } + action def One { + out n : Integer; + first start; + action writing { assign n := 1; } + done; + succession first start then writing; + succession first writing then done; + } + exhibit state sm { + entry; then idle; + state idle; + transition first idle accept compute() do perform One then idle; + } + } + part owner : Owner; +}` + +// testCallResultsArguments: a call of a declared operation is checked against +// its inputs as an invocation is — an unbound or unknown argument is refused +// before the call is queued, even where a trigger would accept the bare call. +func testCallResultsArguments(t *testing.T) { + exec := callMachineOwnedBy(t, callArgumentsModel) + for name, args := range map[string]map[string]Value{ + "none": nil, + "unknown": {"x": constInt(3), "y": constInt(4)}, + } { + got, err := exec.Call("compute", args) + if !errors.Is(err, ErrUnboundParameter) { + t.Errorf("compute with %s argument(s) = %v, %v; want ErrUnboundParameter", name, got, err) + } + if n := exec.eventQueue.Len(); n != 0 { + t.Errorf("%d event(s) queued after the %s call, want it refused before it is queued", n, name) + } + } + got, err := exec.Call("compute", map[string]Value{"x": constInt(3)}) + if err != nil { + t.Fatal(err) + } + if n, ok := got["n"]; !ok || n.Const.Int != 1 || len(got) != 1 { + t.Errorf("compute(3) returned %v, want n = 1 alone", got) + } +} + +// callDispatchModel: two `compute` overloads, and a trigger for each; the +// one-parameter trigger is declared first and would take any call carrying `x` +// were the call matched by its arguments alone. +const callDispatchModel = `package P { + private import ScalarValues::*; + part def Owner { + action def compute { in x : Integer; out n : Integer; } + action def compute { in x : Integer; in y : Integer; out n : Integer; } + action def One { + out n : Integer; + first start; + action writing { assign n := 1; } + done; + succession first start then writing; + succession first writing then done; + } + action def Two { + out n : Integer; + first start; + action writing { assign n := 2; } + done; + succession first start then writing; + succession first writing then done; + } + exhibit state sm { + entry; then idle; + state idle; + transition first idle accept compute(x) do perform One then idle; + transition first idle accept compute(x, y) do perform Two then idle; + } + } + part owner : Owner; +}` + +// testCallResultsOverloadDispatch: a call fires the trigger naming the +// declaration its arguments selected, not the first trigger its arguments would +// bind, so same-named overloads reach their own transitions. +func testCallResultsOverloadDispatch(t *testing.T) { + exec := callMachineOwnedBy(t, callDispatchModel) + for _, tc := range []struct { + args map[string]Value + want int64 + }{ + {map[string]Value{"x": constInt(3), "y": constInt(4)}, 2}, + {map[string]Value{"x": constInt(3)}, 1}, + } { + got, err := exec.Call("compute", tc.args) + if err != nil { + t.Fatal(err) + } + if n, ok := got["n"]; !ok || n.Const.Int != tc.want || len(got) != 1 { + t.Errorf("compute%v returned %v, want n = %d alone", tc.args, got, tc.want) + } + } + if n := exec.eventQueue.Len(); n != 0 { + t.Errorf("%d event(s) left queued, want every call dispatched", n) + } +} + +// callTypedModel: `compute` takes an Integer `x` and a `y` defaulting to 7 and +// answers with `y`; `half` and `even` are a calc and a constraint answered by a +// helper writing a same-named result beside an `extra` output. +const callTypedModel = `package P { + private import ScalarValues::*; + part def Owner { + action def compute { in x : Integer; in y : Integer = 7; out n : Integer; } + calc def half { in x : Integer; return : Integer = x / 2; } + constraint def even { in x : Integer; x == 2 * (x / 2) } + action def Echo { + in y : Integer; + out n : Integer; + first start; + action echoing { assign n := y; } + done; + succession first start then echoing; + succession first echoing then done; + } + action def Answer { + out result : Integer; + out extra : Integer; + first start; + action answering { assign result := 2; assign extra := 9; } + done; + succession first start then answering; + succession first answering then done; + } + action def Verdict { + out result : Boolean; + out extra : Integer; + first start; + action judging { assign result := true; assign extra := 9; } + done; + succession first start then judging; + succession first judging then done; + } + exhibit state sm { + entry; then idle; + state idle; + transition first idle accept compute(x, y) do action : Echo { in y = y; } then idle; + transition first idle accept half(x) do perform Answer then idle; + transition first idle accept even(x) do perform Verdict then idle; + } + } + part owner : Owner; +}` + +// testCallResultsArgumentTypes: a value of the wrong type for a declared input is +// refused as an invocation refuses it, before the call is queued, for an action, +// a calc and a constraint alike. +func testCallResultsArgumentTypes(t *testing.T) { + exec := callMachineOwnedBy(t, callTypedModel) + for _, operation := range []string{"compute", "half", "even"} { + got, err := exec.Call(operation, map[string]Value{"x": strValue("three")}) + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("%s(\"three\") = %v, %v; want ErrTypeMismatch", operation, got, err) + } + if n := exec.eventQueue.Len(); n != 0 { + t.Errorf("%d event(s) queued after %s, want the call refused before it is queued", n, operation) + } + } + if got, err := exec.Call("compute", map[string]Value{"x": constInt(3)}); err != nil || len(got) != 1 { + t.Errorf("compute(3) = %v, %v after the refusals, want one output", got, err) + } +} + +// testCallResultsDefaults: an input the call leaves unbound carries its default +// in the event, where the trigger binds it and the effect reads it. +func testCallResultsDefaults(t *testing.T) { + exec := callMachineOwnedBy(t, callTypedModel) + got, err := exec.Call("compute", map[string]Value{"x": constInt(3)}) + if err != nil { + t.Fatal(err) + } + if n, ok := got["n"]; !ok || n.Const.Int != 7 || len(got) != 1 { + t.Errorf("compute(3) returned %v, want n = 7 from y's default alone", got) + } + got, err = exec.Call("compute", map[string]Value{"x": constInt(3), "y": constInt(5)}) + if err != nil { + t.Fatal(err) + } + if n, ok := got["n"]; !ok || n.Const.Int != 5 || len(got) != 1 { + t.Errorf("compute(3, 5) returned %v, want n = 5 alone", got) + } +} + +// testCallResultsCalcConstraint: a call of a calc or a constraint returns the +// result alone; a helper's other outputs stay the machine's. +func testCallResultsCalcConstraint(t *testing.T) { + exec := callMachineOwnedBy(t, callTypedModel) + got, err := exec.Call("half", map[string]Value{"x": constInt(4)}) + if err != nil { + t.Fatal(err) + } + if r, ok := got["result"]; !ok || r.Const.Int != 2 || len(got) != 1 { + t.Errorf("half(4) returned %v, want result = 2 alone", got) + } + got, err = exec.Call("even", map[string]Value{"x": constInt(4)}) + if err != nil { + t.Fatal(err) + } + if r, ok := got["result"]; !ok || !r.Const.Bool || len(got) != 1 { + t.Errorf("even(4) returned %v, want result = true alone", got) + } +} + +// callRollbackModel: the transition answering `ask` writes `result` and then +// fails its second effect, so the move is undone and `idle` remains. +const callRollbackModel = `package P { + private import ScalarValues::*; + part def Owner { + attribute divisor : Integer = 0; + action def ask { out result : Integer; } + action def Answer { + out result : Integer; + first start; + action answering { assign result := 42; } + done; + succession first start then answering; + succession first answering then done; + } + exhibit state sm { + entry; then idle; + state idle; + state answered; + transition first idle accept ask() do action { perform Answer; assign divisor := divisor / divisor; } then answered; + } + } + part owner : Owner; +}` + +// testCallResultsMoveRollback: a move undone after the call's outputs were +// written restores the call as it stood, so a failed step returns nothing. +func testCallResultsMoveRollback(t *testing.T) { + exec := callMachineOwnedBy(t, callRollbackModel) + got, err := exec.Call("ask", nil) + if err == nil { + t.Fatalf("ask() = %v, nil; want the failing effect's error", got) + } + if len(got) != 0 { + t.Errorf("ask() returned %v with its error, want nothing", got) + } + if exec.pendingCall != nil { + t.Errorf("a call is still pending after the failed step") + } +} + +// callOwnerMachine creates an executor of callOwnerModel's machine on its owner. +func callOwnerMachine(t *testing.T) *StateExecutor { + t.Helper() + return callMachineOwnedBy(t, callOwnerModel) +} + +// callMachineOwnedBy creates an executor of `P::Owner::sm` on `P::owner` of model. +func callMachineOwnedBy(t *testing.T, model string) *StateExecutor { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, model)) + owner, err := ctx.Instantiate(oneSymbol(t, idx, "P::owner")) + if err != nil { + t.Fatalf("instantiate owner: %v", err) + } + exec, err := ctx.CreateStateExecutorFor(oneSymbol(t, idx, "P::Owner::sm"), owner) + if err != nil { + t.Fatalf("create state executor: %v", err) + } + t.Cleanup(exec.Release) + return exec +} + +// callResultsMachine creates an executor of the model's machine on a fresh context. +func callResultsMachine(t *testing.T) *StateExecutor { + t.Helper() + return callMachineOf(t, callResultsModel) +} + +// callMachineOf creates an executor of the model's `Machine` on a fresh context. +func callMachineOf(t *testing.T, model string) *StateExecutor { + t.Helper() + m := parseLibraryModel(t, model) + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + exec, err := ctx.CreateStateExecutorFor(m.state(t, "Machine"), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(exec.Release) + return exec +} + +// testCallResultsLeftDeferred: a call the active state defers holds its caller, +// so Call refuses to return and says the call is deferred. +func testCallResultsLeftDeferred(t *testing.T) { + exec := callResultsMachine(t) + if err := exec.Enqueue(QueuedEvent{Signal: "Hold"}); err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + _, err := exec.Call("ask", nil) + if !errors.Is(err, ErrCallNotReturned) || !strings.Contains(err.Error(), "ask is still deferred") { + t.Fatalf("Call = %v, want ErrCallNotReturned naming the deferred call", err) + } + if n := len(exec.DeferredEvents()); n != 1 { + t.Errorf("%d deferred events, want the call held", n) + } +} + +// testCallResultsDeferredRecalled: a call the active state defers holds its +// caller through the machine's later steps and returns once it is recalled. +func testCallResultsDeferredRecalled(t *testing.T) { + exec := callMachineOf(t, callDeferredModel) + if err := exec.Enqueue(QueuedEvent{Signal: "Hold"}); err != nil { + t.Fatal(err) + } + results, err := exec.Call("ask", nil) + if err != nil { + t.Fatalf("Call = %v, want the caller released once the deferred call was recalled", err) + } + if got, ok := results["result"]; !ok || got.Const.Int != 42 { + t.Errorf("Call returned %v, want result = 42", results) + } + if got := exec.Outcome().FinalState; got != "answered" { + t.Errorf("final state %q, want answered", got) + } + if n := len(exec.DeferredEvents()); n != 0 { + t.Errorf("%d deferred events, want none left", n) + } +} + +// testCallResultsLeftQueued: a call queued behind an event that terminates the +// machine is never dispatched, and Call says it is still queued. +func testCallResultsLeftQueued(t *testing.T) { + exec := callResultsMachine(t) + if err := exec.Enqueue(QueuedEvent{Signal: "Finish"}); err != nil { + t.Fatal(err) + } + _, err := exec.Call("ask", nil) + if !errors.Is(err, ErrCallNotReturned) || !strings.Contains(err.Error(), "ask is still queued") { + t.Fatalf("Call = %v, want ErrCallNotReturned naming the queued call", err) + } + if got := exec.Outcome().FinalState; got != "done" { + t.Errorf("final state %q, want done", got) + } +} + +// testCallResultsNothingTakes: a call no transition of the configuration takes +// is consumed and dropped; its caller is released with no results. +func testCallResultsNothingTakes(t *testing.T) { + exec := callResultsMachine(t) + results, err := exec.Call("other", nil) + if err != nil { + t.Fatalf("Call = %v, want the caller released", err) + } + if results == nil || len(results) != 0 { + t.Errorf("results = %v, want an empty map", results) + } + if got := exec.Outcome().FinalState; got != "idle" { + t.Errorf("final state %q, want idle", got) + } +} + +// testCallResultsDoNotCarryOver: the results of one call belong to it alone; a +// later call whose dispatch returns nothing gets nothing, though the machine's +// attribute still holds the earlier value. +func testCallResultsDoNotCarryOver(t *testing.T) { + exec := callResultsMachine(t) + first, err := exec.Call("ask", nil) + if err != nil { + t.Fatal(err) + } + if got, ok := first["result"]; !ok || got.Const.Int != 42 { + t.Fatalf("first call returned %v, want result = 42", first) + } + second, err := exec.Call("ask", nil) + if err != nil { + t.Fatal(err) + } + if len(second) != 0 { + t.Errorf("second call returned %v, want nothing", second) + } + if got := exec.Outcome().FinalState; got != "quiet" { + t.Errorf("final state %q, want quiet", got) + } + if got, ok := exec.Outcome().Outputs["result"]; !ok || got.Const.Int != 42 { + t.Errorf("machine result = %v, want 42 kept from the first call", got) + } +} + +// testCallResultsDispatchError: an error the effect of the call's transition +// raises ends the run and reaches the caller instead of any results. +func testCallResultsDispatchError(t *testing.T) { + exec := callResultsMachine(t) + for range 2 { + if _, err := exec.Call("ask", nil); err != nil { + t.Fatal(err) + } + } + results, err := exec.Call("ask", nil) + if err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("Call = (%v, %v), want the division by zero", results, err) + } +} + +// testCallResultsReleasedBeforeCompletion: the caller is released once the step +// dispatching its call ends; the completion event that step queued is a later step. +func testCallResultsReleasedBeforeCompletion(t *testing.T) { + exec := callMachineOf(t, callBoundaryModel) + results, err := exec.Call("ask", nil) + if err != nil { + t.Fatal(err) + } + if got, ok := results["result"]; !ok || got.Const.Int != 42 { + t.Fatalf("Call returned %v, want result = 42", results) + } + if got := exec.Outcome().FinalState; got != "answered" { + t.Errorf("state after the call %q, want answered with its completion still queued", got) + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatal(err) + } + if got := exec.Outcome().FinalState; got != "later" { + t.Errorf("state after the next run %q, want later", got) + } +} + +// testCallResultsTimerNotDrained: a timer the call's step arms does not run under +// the call; its later failure reaches the run that advances the clock, not the caller. +func testCallResultsTimerNotDrained(t *testing.T) { + exec := callMachineOf(t, callBoundaryModel) + results, err := exec.Call("ask", nil) + if err != nil { + t.Fatalf("Call = %v, want the result ahead of the timer's failure", err) + } + if got, ok := results["result"]; !ok || got.Const.Int != 42 { + t.Fatalf("Call returned %v, want result = 42", results) + } + err = exec.RunToCompletion() + if err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("RunToCompletion = %v, want the timer's division by zero", err) + } +} + +// testCallResultsQueuedSignalFirst: a signal queued ahead of the call is dispatched +// first, in its own step, and the call still returns its step's results. +func testCallResultsQueuedSignalFirst(t *testing.T) { + exec := callMachineOf(t, callBoundaryModel) + if err := exec.Enqueue(QueuedEvent{Signal: "Poke"}); err != nil { + t.Fatal(err) + } + results, err := exec.Call("ask", nil) + if err != nil { + t.Fatal(err) + } + if got, ok := results["result"]; !ok || got.Const.Int != 42 { + t.Fatalf("Call returned %v, want result = 42", results) + } + if len(results) != 1 { + t.Errorf("Call returned %v, want the call's own result alone", results) + } + if got := exec.StateData()["poked"]; got.Kind != ValConst || !got.Const.Bool { + t.Errorf("poked = %v, want the queued signal dispatched before the call", got) + } + if got := exec.Outcome().FinalState; got != "answered" { + t.Errorf("state after the call %q, want answered", got) + } +} + +// testCallResultsDeclaredReturns: a call of an operation the owner declares +// releases its out parameters alone, so the helper's `log` stays the object's; +// a call the owner declares nothing for releases every output the step returned. +func testCallResultsDeclaredReturns(t *testing.T) { + exec := callOwnerMachine(t) + owner, ctx := exec.self, exec.ctx + asked, err := exec.Call("ask", nil) + if err != nil { + t.Fatal(err) + } + if got, ok := asked["result"]; !ok || got.Const.Int != 42 || len(asked) != 1 { + t.Fatalf("ask returned %v, want result = 42 alone", asked) + } + if log, err := owner.GetFeatureValue(ctx, "log"); err != nil || log.Value.Str() != "answered" { + t.Errorf("log = %v, %v; want the helper's write kept by the object", log, err) + } + told, err := exec.Call("tell", nil) + if err != nil { + t.Fatal(err) + } + if got, ok := told["log"]; !ok || got.Str() != "answered" || len(told) != 2 { + t.Errorf("tell returned %v, want both outputs of the undeclared operation", told) + } +} + +// testCallResultsInoutArgument: a declared inout goes back to the caller as it +// was passed when the step writes nothing to it, as the step's value when a +// behavior writes it, and not at all when no transition takes the call. +func testCallResultsInoutArgument(t *testing.T) { + exec := callOwnerMachine(t) + nudged, err := exec.Call("nudge", map[string]Value{"x": constInt(7)}) + if err != nil { + t.Fatal(err) + } + if got, ok := nudged["x"]; !ok || got.Const.Int != 7 || len(nudged) != 1 { + t.Errorf("nudge in idle returned %v, want x = 7 as passed", nudged) + } + if _, err := exec.Call("ask", nil); err != nil { + t.Fatal(err) + } + nudged, err = exec.Call("nudge", map[string]Value{"x": constInt(7)}) + if err != nil { + t.Fatal(err) + } + if got, ok := nudged["x"]; !ok || got.Const.Int != 8 || len(nudged) != 1 { + t.Errorf("nudge in answered returned %v, want x = 8 from the helper", nudged) + } + if _, err := exec.Call("tell", nil); err != nil { + t.Fatal(err) + } + nudged, err = exec.Call("nudge", map[string]Value{"x": constInt(7)}) + if err != nil { + t.Fatal(err) + } + if len(nudged) != 0 { + t.Errorf("nudge in told returned %v, want nothing for a call no transition takes", nudged) + } + if got := exec.Outcome().FinalState; got != "told" { + t.Errorf("final state %q, want told", got) + } +} diff --git a/internal/exec/runtime/robustness_case_clock_wait_test.go b/internal/exec/runtime/robustness_case_clock_wait_test.go new file mode 100644 index 0000000000..bf4bc45cbd --- /dev/null +++ b/internal/exec/runtime/robustness_case_clock_wait_test.go @@ -0,0 +1,272 @@ +package runtime + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +// TestRuntimeRobustnessCaseClockWait exercises the failure modes of a case +// body's step waiting on the clock (`accept after`): a wait for a message +// nothing posts, a wait under a behavior that holds the clock, the clock +// letting go of the case's flow once its run ends, the same for a case an +// action body performs as a step, whose wait the action lists among its own, +// and the refusal of a wait under a case read as a feature of an expression. +func TestRuntimeRobustnessCaseClockWait(t *testing.T) { + t.Run("message_wait_still_deadlocks", testCaseClockWaitMessageStillDeadlocks) + t.Run("wait_under_a_held_clock", testCaseClockWaitUnderHeldClock) + t.Run("clock_lets_go_of_the_case_after_its_run", testCaseClockWaitDetachesAfterRun) + t.Run("failing_run_lets_go_of_the_case", testCaseClockWaitDetachesAfterFailure) + t.Run("performing_action_lists_the_wait_of_its_case_step", testCaseClockWaitListedByPerformingAction) + t.Run("message_wait_of_a_case_step_deadlocks_the_performer", testCaseClockWaitOfStepDeadlocksPerformer) + t.Run("case_read_as_a_feature_cannot_wait", testCaseClockWaitRefusedUnderRead) +} + +// caseWaitingModel declares a case whose steps wait on the clock, and one whose +// step waits for a message no behavior posts. +const caseWaitingModel = ` + package test { + private import ScalarValues::*; + private import SI::*; + attribute def Ping; + part def Ship { attribute log : Real default = 0.0; } + part ship : Ship; + analysis def Voyage { + subject boat : Ship; + action sail accept after 60.0 [s]; + then action arrive { assign boat.log := boat.log + 1.0; } + return total : Real = boat.log; + } + analysis def Stranded { + subject boat : Ship; + action sail accept after 60.0 [s]; + then action hail accept p : Ping; + then action arrive { assign boat.log := boat.log + 1.0; } + return total : Real = boat.log; + } + analysis def Broken { + subject boat : Ship; + action sail accept after 60.0 [s]; + then action arrive { assign boat.log := boat.log / 0.0; } + return total : Real = boat.log; + } + }` + +// testCaseClockWaitMessageStillDeadlocks: advancing the clock to a step's instant +// does not stand in for a message; a step waiting for one nothing posts deadlocks. +func testCaseClockWaitMessageStillDeadlocks(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, caseWaitingModel)) + ship := instanceOfUsage(t, ctx, idx, "test::ship") + _, err := ctx.RunAnalysis(oneSymbol(t, idx, "test::Stranded"), AnalysisArgs{Subject: ship}, nil, nil) + if !errors.Is(err, ErrAcceptDeadlock) { + t.Fatalf("error = %v, want ErrAcceptDeadlock", err) + } + if !strings.Contains(err.Error(), "test::Stranded") || !strings.Contains(err.Error(), "Ping") { + t.Errorf("error %q does not name the case and the message it waits for", err) + } + if ctx.clock.now != 60.0 { + t.Errorf("clock = %v after the deadlock, want 60.0: the timed step ran first", ctx.clock.now) + } +} + +// testCaseClockWaitUnderHeldClock: a state's entry behavior holds the clock, so a +// case it reads that waits on the clock is refused, not advanced. +func testCaseClockWaitUnderHeldClock(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + private import SI::*; + part def Ship { attribute log : Real default = 0.0; } + analysis def Pause { + subject boat : Ship; + action sail accept after 60.0 [s]; + then action arrive { assign boat.log := boat.log + 1.0; } + return total : Real = boat.log; + } + part def Station { + part boat : Ship; + attribute seen : Real default = -1.0; + analysis pause : Pause { subject boat = boat; } + exhibit state run { + entry; then observing; + state observing { entry assign seen := pause.total; } + } + } + }` + ctx, _, err := instantiateWithLibraries(t, src, "test::Station") + if !errors.Is(err, ErrStateBehaviorWaits) { + t.Fatalf("error = %v, want ErrStateBehaviorWaits", err) + } + if !strings.Contains(err.Error(), "test::Station::pause") || !strings.Contains(err.Error(), "t=60.0") { + t.Errorf("error %q does not name the case and the instant it waits for", err) + } + if ctx.clock.now != 0 { + t.Errorf("clock = %v, want 0: a held clock never advances", ctx.clock.now) + } +} + +// testCaseClockWaitDetachesAfterRun: the clock drives the case's flow while the +// case runs and lets go of it afterwards, so a later run finds no stale wait. +func testCaseClockWaitDetachesAfterRun(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, caseWaitingModel)) + ship := instanceOfUsage(t, ctx, idx, "test::ship") + waiters := len(ctx.clock.waiters) + for i := 1; i <= 2; i++ { + res, err := ctx.RunAnalysis(oneSymbol(t, idx, "test::Voyage"), AnalysisArgs{Subject: ship}, nil, nil) + if err != nil { + t.Fatalf("run %d: %v", i, err) + } + if want := fmt.Sprintf("%d.0", i); len(res.Outputs) != 1 || FormatValue(res.Outputs[0].Value) != want { + t.Errorf("run %d: outputs = %+v, want total = %s", i, res.Outputs, want) + } + if got := len(ctx.clock.waiters); got != waiters { + t.Errorf("run %d: the clock drives %d executor(s) after the run, want %d", i, got, waiters) + } + } + if ctx.clock.now != 120.0 { + t.Errorf("clock = %v after two runs, want 120.0", ctx.clock.now) + } +} + +// caseStepModel declares actions performing, as a step, a verification case whose +// steps wait on the clock, and one whose step waits for a message nothing posts. +const caseStepModel = ` + package test { + private import ScalarValues::*; + private import SI::*; + private import VerificationCases::*; + attribute def Ping; + part def Ship { attribute log : Real default = 0.0; } + verification def Voyage { + subject boat : Ship; + action sail accept after 60.0 [s]; + then action arrive { assign boat.log := boat.log + 1.0; } + return verdict : VerdictKind = VerdictKind::pass; + } + verification def Stranded { + subject boat : Ship; + action sail accept after 60.0 [s]; + then action hail accept p : Ping; + return verdict : VerdictKind = VerdictKind::pass; + } + action def Cruise { + part ship : Ship; + out ended : Real; + verification run : Voyage { subject boat = ship; } + then action after { assign ended := localClock.currentTime; } + } + action def Lost { + part ship : Ship; + verification run : Stranded { subject boat = ship; } + } + }` + +// testCaseClockWaitListedByPerformingAction: an action performing a case as a +// step lists the case's wait among its own while the step pauses, the clock +// advances to it, and the run leaves the clock driving no more than before. +func testCaseClockWaitListedByPerformingAction(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, caseStepModel)) + waiters := len(ctx.clock.waiters) + out, err := ctx.ExecuteAction(oneSymbol(t, idx, "test::Cruise")) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if got := FormatValue(out["ended"]); got != "60.0" { + t.Errorf("ended = %s, want 60.0: the step after the case runs at the case's instant", got) + } + if ctx.clock.now != 60.0 { + t.Errorf("clock = %v, want 60.0", ctx.clock.now) + } + if got := len(ctx.clock.waiters); got != waiters { + t.Errorf("the clock drives %d executor(s) after the run, want %d", got, waiters) + } +} + +// testCaseClockWaitOfStepDeadlocksPerformer: a case step waiting for a message +// nothing posts deadlocks the action performing it, after its timed step ran. +func testCaseClockWaitOfStepDeadlocksPerformer(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, caseStepModel)) + waiters := len(ctx.clock.waiters) + _, err := ctx.ExecuteAction(oneSymbol(t, idx, "test::Lost")) + if !errors.Is(err, ErrAcceptDeadlock) { + t.Fatalf("error = %v, want ErrAcceptDeadlock", err) + } + if !strings.Contains(err.Error(), "Lost") { + t.Errorf("error %q does not name the performing action", err) + } + if ctx.clock.now != 60.0 { + t.Errorf("clock = %v after the deadlock, want 60.0: the timed step ran first", ctx.clock.now) + } + if got := len(ctx.clock.waiters); got != waiters { + t.Errorf("the clock drives %d executor(s) after the deadlock, want %d", got, waiters) + } +} + +// caseReadModel declares an action reading, in an expression, an output of a case +// whose steps wait on the clock. +const caseReadModel = ` + package test { + private import ScalarValues::*; + private import SI::*; + part def Ship { attribute log : Real default = 0.0; } + analysis def Voyage { + subject boat : Ship; + action sail accept after 60.0 [s]; + then action arrive { assign boat.log := boat.log + 1.0; } + return total : Real = boat.log; + } + part def Station { + part boat : Ship; + analysis voyage : Voyage { subject boat = boat; } + } + action def Read { + part station : Station; + out total : Real; + action go { assign total := station.voyage.total; } + } + }` + +// testCaseClockWaitRefusedUnderRead: an expression reading a case's output takes +// the case whole, so a wait under it is refused, naming the wait, and leaves +// the clock where it was and the case neither running nor on the calc stack. +func testCaseClockWaitRefusedUnderRead(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, caseReadModel)) + waiters := len(ctx.clock.waiters) + _, err := ctx.ExecuteAction(oneSymbol(t, idx, "test::Read")) + if !errors.Is(err, ErrCaseReadWaits) { + t.Fatalf("error = %v, want ErrCaseReadWaits", err) + } + for _, want := range []string{"test::Station::voyage", "t=60.0"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } + if ctx.clock.now != 0.0 { + t.Errorf("clock = %v, want 0.0: the read does not advance the clock", ctx.clock.now) + } + if got := len(ctx.clock.waiters); got != waiters { + t.Errorf("the clock drives %d executor(s) after the refusal, want %d", got, waiters) + } + if ctx.calcDepth != 0 || len(ctx.calcUsageRunning) != 0 { + t.Errorf("calc depth %d, %d usage(s) running after the refusal, want none", ctx.calcDepth, len(ctx.calcUsageRunning)) + } +} + +// testCaseClockWaitDetachesAfterFailure: a run its step fails ends too, and the +// clock lets go of the case's flow all the same. +func testCaseClockWaitDetachesAfterFailure(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, caseWaitingModel)) + ship := instanceOfUsage(t, ctx, idx, "test::ship") + waiters := len(ctx.clock.waiters) + _, err := ctx.RunAnalysis(oneSymbol(t, idx, "test::Broken"), AnalysisArgs{Subject: ship}, nil, nil) + if err == nil { + t.Fatal("a step dividing by zero ran to completion") + } + if !strings.Contains(err.Error(), "test::Broken") { + t.Errorf("error %q does not name the case", err) + } + if got := len(ctx.clock.waiters); got != waiters { + t.Errorf("the clock drives %d executor(s) after the failed run, want %d", got, waiters) + } +} diff --git a/internal/exec/runtime/robustness_ceiling_test.go b/internal/exec/runtime/robustness_ceiling_test.go new file mode 100644 index 0000000000..554be6f5c1 --- /dev/null +++ b/internal/exec/runtime/robustness_ceiling_test.go @@ -0,0 +1,82 @@ +package runtime + +import ( + "errors" + "math" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestRuntimeRobustnessCeiling covers OpenSysMLMathFunctions::ceiling: a result +// outside the Integer range, a non-numeric operand, and the least Integer as a value. +func TestRuntimeRobustnessCeiling(t *testing.T) { + t.Run("ceiling_beyond_the_integer_range", testCeilingBeyondTheIntegerRange) + t.Run("ceiling_of_the_least_integer", testCeilingOfTheLeastInteger) + t.Run("ceiling_of_a_boolean", testCeilingOfABoolean) +} + +// ceilingCalc builds a calc that applies ceiling to a parameter of the given type. +func ceilingCalc(t *testing.T, xType string) func(x semantics.Value) (Value, error) { + src := ` + package test { + private import ScalarValues::*; + private import OpenSysMLMathFunctions::*; + calc whole { + in x : ` + xType + `; + return : Integer = ceiling(x); + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "whole", ast.DefCalc) + if sym == nil { + t.Fatal("whole calc not found") + } + return func(x semantics.Value) (Value, error) { + return ctx.InvokeCalc(sym, []Value{{Kind: ValConst, Const: x}}, rootScope) + } +} + +// A ceiling at or beyond 2^63, or below -2^63, is an overflow, never a wrap. +func testCeilingBeyondTheIntegerRange(t *testing.T) { + whole := ceilingCalc(t, "Real") + for _, x := range []float64{-float64(math.MinInt64), 1e20, -1e20, math.Inf(1)} { + got, err := whole(semantics.Value{Kind: semantics.ValReal, Real: x}) + if !errors.Is(err, semantics.ErrArithmeticOverflow) || !strings.Contains(err.Error(), "exceeds the Integer range") { + t.Fatalf("ceiling(%v) = %+v, %v; want an overflow error", x, got, err) + } + } +} + +// The least Integer and the values just above it have a ceiling, which a +// negated floor would lose to the overflow of positive 2^63. +func testCeilingOfTheLeastInteger(t *testing.T) { + whole := ceilingCalc(t, "Real") + for _, tc := range []struct { + x float64 + want int64 + }{ + {math.MinInt64, math.MinInt64}, + {-9223372036854774784, -9223372036854774784}, + {9223372036854774784, 9223372036854774784}, + {-0.5, 0}, + } { + got, err := whole(semantics.Value{Kind: semantics.ValReal, Real: tc.x}) + if err != nil || got.Const.Kind != semantics.ValInt || got.Const.Int != tc.want { + t.Fatalf("ceiling(%v) = %+v, %v; want %d", tc.x, got, err, tc.want) + } + } +} + +// A Boolean operand is a type mismatch, not a ceiling of 0 or 1. +func testCeilingOfABoolean(t *testing.T) { + whole := ceilingCalc(t, "Boolean") + got, err := whole(semantics.Value{Kind: semantics.ValBool, Bool: true}) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("ceiling(true) = %+v, %v; want ErrTypeMismatch", got, err) + } +} diff --git a/internal/exec/runtime/robustness_classifier_behavior_test.go b/internal/exec/runtime/robustness_classifier_behavior_test.go new file mode 100644 index 0000000000..190075511c --- /dev/null +++ b/internal/exec/runtime/robustness_classifier_behavior_test.go @@ -0,0 +1,382 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestRuntimeRobustnessClassifierBehaviorStart exercises `perform obj.beh.start`, the +// start of a behavior an object's type declares for its objects to start: constructing +// the object runs nothing, the start runs the behavior with the object as this and +// leaves the object once the behavior is done, a parked behavior is woken by a later +// message, a second start is no second execution, and a start naming no object, no +// behavior, or a behavior that fails is a typed error that leaves the object untouched. +func TestRuntimeRobustnessClassifierBehaviorStart(t *testing.T) { + t.Run("construction_starts_no_declared_behavior", testStartConstructionRunsNothing) + t.Run("start_runs_the_behavior_as_the_object", testStartRunsAsTheObject) + t.Run("started_behavior_is_woken_by_a_later_message", testStartWokenByMessage) + t.Run("a_second_start_runs_nothing_more", testStartTwice) + t.Run("an_inherited_behavior_starts_on_the_specialized_object", testStartInherited) + t.Run("start_on_no_object_is_refused", testStartOnNoObject) + t.Run("start_of_no_behavior_is_refused", testStartOfNoBehavior) + t.Run("an_action_declared_as_start_is_performed_not_started", testStartNamedActionPerformed) + t.Run("a_failing_start_is_undone_whole", testStartFailureRollsBack) + t.Run("a_behavior_woken_by_a_start_runs_once_the_start_stands", testStartWakesOlderBehaviorAfterCommit) + t.Run("start_is_traced_as_the_objects_own_execution", testStartTraced) +} + +// starterModel is a Counter whose declared behavior Count writes its own n, with the +// given body, and a Starter action creating one, starting it as `steps` say and +// handing it out. +func starterModel(body, steps string) string { + return `package test { + private import ScalarValues::*; + attribute def Tick; + part def Counter { + attribute n : Integer = 0; + attribute seen : Integer = 0; + action def Count { + ` + body + ` + } + action count : Count; + } + action def Starter { + out made : Counter; + action create { out result : Counter = new Counter(); } + ` + steps + ` + bind create.result = made; + } + }` +} + +// runStarter runs the Starter of src and hands back the context, the Counter it made +// and the error of the run, if any. +func runStarter(t *testing.T, src string) (*Context, *Instance, error) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Starter", ast.DefAction) + if sym == nil { + t.Fatal("action Starter not found") + } + results, err := ctx.ExecuteAction(sym) + if err != nil { + return ctx, nil, err + } + made, ok := results["made"] + if !ok || made.Kind != ValInstance { + t.Fatalf("made = %v, want the Counter", results) + } + inst, ok := ctx.Instance(made.Instance) + if !ok { + t.Fatalf("made names object #%d, which the context lacks", made.Instance) + } + return ctx, inst, nil +} + +const countOnce = `first start; then action tally { assign n := n + 1; } then done;` + +// testStartConstructionRunsNothing: `new Counter()` alone leaves Count unstarted; the +// object performs nothing and n keeps its default. +func testStartConstructionRunsNothing(t *testing.T) { + ctx, counter, err := runStarter(t, starterModel(countOnce, `first start then create; first create then done;`)) + if err != nil { + t.Fatalf("Starter: %v", err) + } + if bs := counter.Behaviors(); len(bs) != 0 { + t.Errorf("the new Counter performs %v, want nothing until a start", bs) + } + if got := featureInt(t, ctx, counter, "n"); got != 0 { + t.Errorf("n = %d after construction alone, want 0", got) + } +} + +// testStartRunsAsTheObject: the start runs Count once with the Counter as this, so its +// write lands on that object; the Counter outlives the behavior's completion. +func testStartRunsAsTheObject(t *testing.T) { + ctx, counter, err := runStarter(t, starterModel(countOnce, ` + action kick { in target : Counter; perform target.count.start; } + flow create.result to kick.target; + first start then create; first create then kick; first kick then done;`)) + if err != nil { + t.Fatalf("Starter: %v", err) + } + if got := featureInt(t, ctx, counter, "n"); got != 1 { + t.Errorf("n = %d after the start, want 1", got) + } + b, ok := counter.Behavior("count") + if !ok || b.Action == nil || b.Action.state != StateCompleted { + t.Fatalf("count = %+v, %v; want the behavior complete on the object", b, ok) + } + if l, ok := ctx.OccurrenceLife(counter.ID); !ok || !l.Alive() { + t.Errorf("OccurrenceLife(counter) = %v, %v; want the object alive once its behavior is done", l, ok) + } +} + +const countTicks = ` + first start; + then action heard accept t : Tick; + then action tally { assign seen := seen + 1; } + then done;` + +// testStartWokenByMessage: a started behavior parked at an accept is quiescence, not a +// deadlock; the Tick sent afterwards wakes it and it writes its object. +func testStartWokenByMessage(t *testing.T) { + ctx, counter, err := runStarter(t, starterModel(countTicks, ` + action kick { in target : Counter; perform target.count.start; } + action poke { in target : Counter; send new Tick() to target; } + flow create.result to kick.target; + flow create.result to poke.target; + first start then create; first create then kick; first kick then poke; first poke then done;`)) + if err != nil { + t.Fatalf("Starter: %v", err) + } + if got := featureInt(t, ctx, counter, "seen"); got != 1 { + t.Errorf("seen = %d, want 1 once the Tick woke the started behavior", got) + } + b, ok := counter.Behavior("count") + if !ok || b.Action == nil || b.Action.state != StateCompleted { + t.Errorf("count = %+v, %v; want the woken behavior complete", b, ok) + } +} + +// testStartTwice: a second start of a behavior the object already runs is no second +// execution, whether the first is still parked or done. +func testStartTwice(t *testing.T) { + ctx, counter, err := runStarter(t, starterModel(countTicks, ` + action kick { in target : Counter; perform target.count.start; } + action again { in target : Counter; perform target.count.start; } + action poke { in target : Counter; send new Tick() to target; } + action once { in target : Counter; perform target.count.start; } + flow create.result to kick.target; + flow create.result to again.target; + flow create.result to poke.target; + flow create.result to once.target; + first start then create; first create then kick; first kick then again; + first again then poke; first poke then once; first once then done;`)) + if err != nil { + t.Fatalf("Starter: %v", err) + } + if bs := counter.Behaviors(); len(bs) != 1 { + t.Errorf("the Counter performs %d behaviors after three starts, want the one", len(bs)) + } + if got := featureInt(t, ctx, counter, "seen"); got != 1 { + t.Errorf("seen = %d after three starts and one Tick, want 1", got) + } +} + +// testStartInherited: an object of a specialization starts the behavior its general +// declares, with itself as this, so the write lands on the specialized object. +func testStartInherited(t *testing.T) { + src := `package test { + private import ScalarValues::*; + part def Counter { + attribute n : Integer = 0; + action def Count { first start; then action tally { assign n := n + 1; } then done; } + action count : Count; + } + part def Clicker :> Counter { attribute label : String = "c"; } + action def Starter { + out made : Clicker; + action create { out result : Clicker = new Clicker(); } + action kick { in target : Clicker; perform target.count.start; } + flow create.result to kick.target; + first start then create; first create then kick; first kick then done; + bind create.result = made; + } + }` + ctx, clicker, err := runStarter(t, src) + if err != nil { + t.Fatalf("Starter: %v", err) + } + if clicker.Type == nil || clicker.Type.Name != "Clicker" { + t.Fatalf("made a %s, want a Clicker", symbolText(clicker.Type)) + } + if got := featureInt(t, ctx, clicker, "n"); got != 1 { + t.Errorf("n = %d after the inherited start, want 1", got) + } + if b, ok := clicker.Behavior("count"); !ok || b.Action == nil || b.Action.state != StateCompleted { + t.Errorf("count = %+v, %v; want the inherited behavior complete on the Clicker", b, ok) + } +} + +// testStartOnNoObject: a start whose object is an empty optional names no object to +// start the behavior on, and is refused saying so. +func testStartOnNoObject(t *testing.T) { + _, _, err := runStarter(t, starterModel(countOnce, ` + action kick { in target : Counter[0..1] = (); perform target.count.start; } + first start then create; first create then kick; first kick then done;`)) + if !errors.Is(err, ErrPerformerNotObject) || !strings.Contains(err.Error(), "target.count is started on null, which is no one object") { + t.Fatalf("error = %v, want ErrPerformerNotObject over an empty target", err) + } +} + +// testStartOfNoBehavior: a start naming an attribute of the object names no behavior +// of it, and is refused with the object and the member named. +func testStartOfNoBehavior(t *testing.T) { + _, _, err := runStarter(t, starterModel(countOnce, ` + action kick { in target : Counter; perform target.n.start; } + flow create.result to kick.target; + first start then create; first create then kick; first kick then done;`)) + if !errors.Is(err, ErrNoSuchBehavior) || !strings.Contains(err.Error(), "n is no behavior of object #") { + t.Fatalf("error = %v, want ErrNoSuchBehavior naming n", err) + } +} + +// testStartNamedActionPerformed: `perform target.start` naming an action the object's +// type declares under the name start performs that action as a step, once, as any other +// perform of it would; it is no start of a behavior called `target`. +func testStartNamedActionPerformed(t *testing.T) { + src := `package test { + private import ScalarValues::*; + part def Vehicle { + attribute n : Integer = 0; + action def Launch { first start; then action tally { assign n := n + 1; } then done; } + action start : Launch; + } + action def Starter { + out made : Vehicle; + action create { out result : Vehicle = new Vehicle(); } + action kick { in target : Vehicle; perform target.start; } + flow create.result to kick.target; + first start then create; first create then kick; first kick then done; + bind create.result = made; + } + }` + ctx, vehicle, err := runStarter(t, src) + if err != nil { + t.Fatalf("Starter: %v", err) + } + if got := featureInt(t, ctx, vehicle, "n"); got != 1 { + t.Errorf("n = %d after performing the action named start, want 1", got) + } + if bs := vehicle.Behaviors(); len(bs) != 0 { + t.Errorf("the Vehicle performs %v, want no behavior started on it by a perform of its start action", bs) + } +} + +// testStartFailureRollsBack: a behavior whose start fails is undone whole; the error +// names the failure, and no object of the run keeps a write of it. +func testStartFailureRollsBack(t *testing.T) { + ctx, _, err := runStarter(t, starterModel(` + first start; + then action tally { assign n := n + 1; } + then action blow { assign seen := 1 / 0; } + then done;`, ` + action kick { in target : Counter; perform target.count.start; } + flow create.result to kick.target; + first start then create; first create then kick; first kick then done;`)) + if err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("error = %v, want the division by zero of the started behavior", err) + } + for _, inst := range ctx.instances { + if inst.Type == nil || inst.Type.Name != "Counter" { + continue + } + if bs := inst.Behaviors(); len(bs) != 0 { + t.Errorf("the Counter performs %v after the failed start, want nothing", bs) + } + fv, err := inst.GetFeatureValue(ctx, "n") + if err != nil { + t.Fatalf("n after the failed start: %v", err) + } + if got := fv.HeldValue(); got.Kind == ValConst && got.Const.Int != 0 { + t.Errorf("n = %v after the failed start, want the write undone", FormatValue(got)) + } + } +} + +// testStartWakesOlderBehaviorAfterCommit: a message the started behavior sends wakes an +// older parked behavior only once the start is kept, so the older one's failure is the +// run's, not the start's: the start stands and the message is consumed, not restored. +func testStartWakesOlderBehaviorAfterCommit(t *testing.T) { + src := `package test { + private import ScalarValues::*; + attribute def Tick; + part def Listener { + attribute seen : Integer = 0; + action def Listen { + first start; + then action heard accept t : Tick; + then action blow { assign seen := 1 / 0; } + then done; + } + action listen : Listen; + } + part def Pinger { + ref part peer : Listener; + action def Ping { first start; then action fire send new Tick() to peer; then done; } + action ping : Ping; + } + action def Starter { + action makeA { out result : Listener = new Listener(); } + action makeB { in target : Listener; out result : Pinger = new Pinger(peer = target); } + action kickA { in target : Listener; perform target.listen.start; } + action kickB { in target : Pinger; perform target.ping.start; } + flow makeA.result to makeB.target; + flow makeA.result to kickA.target; + flow makeB.result to kickB.target; + first start then makeA; first makeA then kickA; first kickA then makeB; + first makeB then kickB; first kickB then done; + } + }` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Starter", ast.DefAction) + if sym == nil { + t.Fatal("action Starter not found") + } + _, err := ctx.ExecuteAction(sym) + if err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("error = %v, want the division by zero of the woken Listener", err) + } + for _, inst := range ctx.instances { + if inst.Type == nil { + continue + } + switch inst.Type.Name { + case "Pinger": + b, ok := inst.Behavior("ping") + if !ok || b.Action == nil || b.Action.state != StateCompleted { + t.Errorf("ping = %+v, %v; want the start kept and its behavior complete", b, ok) + } + case "Listener": + b, ok := inst.Behavior("listen") + if !ok || b.Action == nil || b.Action.state == StateWaiting { + t.Errorf("listen = %+v, %v; want the woken behavior past its accept", b, ok) + } + } + } + if n := len(ctx.messages); n != 0 { + t.Errorf("%d messages in flight, want the Tick consumed rather than restored", n) + } +} + +// testStartTraced: the trace records the start as the object's own execution of its +// performed action count, once, as it records a start at construction. +func testStartTraced(t *testing.T) { + src := starterModel(countOnce, ` + action kick { in target : Counter; perform target.count.start; } + flow create.result to kick.target; + first start then create; first create then kick; first kick then done;`) + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + tr := NewTraceRecorder() + ctx.SetTrace(tr) + sym := findSymbolByName(idx.DocumentRoot(""), "Starter", ast.DefAction) + if sym == nil { + t.Fatal("action Starter not found") + } + if _, err := ctx.ExecuteAction(sym); err != nil { + t.Fatalf("Starter: %v", err) + } + var starts []string + for _, line := range tr.Entries() { + if strings.HasPrefix(line, "start: ") { + starts = append(starts, line) + } + } + if len(starts) != 1 || !strings.HasPrefix(starts[0], "start: performed action count of #") { + t.Errorf("trace starts = %q, want one start of the performed action count on the object", starts) + } +} diff --git a/internal/exec/runtime/robustness_clock_read_test.go b/internal/exec/runtime/robustness_clock_read_test.go new file mode 100644 index 0000000000..bbc8026f0f --- /dev/null +++ b/internal/exec/runtime/robustness_clock_read_test.go @@ -0,0 +1,205 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessClockRead exercises the failure modes of reading the +// shared clock through an occurrence's `localClock.currentTime`: a write to the +// clock, and a read through a part that holds no object. +func TestRuntimeRobustnessClockRead(t *testing.T) { + t.Run("current_time_is_not_assigned", testClockReadCurrentTimeNotAssigned) + t.Run("own_current_time_is_not_assigned", testClockReadOwnCurrentTimeNotAssigned) + t.Run("redefined_current_time_is_not_assigned", testClockReadRedefinedCurrentTimeNotAssigned) + t.Run("redefined_current_time_is_not_assigned_through_a_part", testClockReadRedefinedCurrentTimeNotAssignedThroughPart) + t.Run("redefined_current_time_reads_the_run_clock", testClockReadRedefinedCurrentTimeFollowsTheRunClock) + t.Run("clock_of_a_part_holding_no_object", testClockReadOfPartHoldingNoObject) + t.Run("current_time_reads_the_run_clock", testClockReadFollowsTheRunClock) + t.Run("time_instant_is_no_point_on_a_named_scale", testClockReadInstantAgainstNamedScale) +} + +// stationObserving is a station whose exhibited machine performs Observe on +// entry, with the given body of Observe. +func stationObserving(body string) string { + return `package test { + private import ScalarValues::*; + part def Telescope { attribute azimuth : Real default = 0.0; } + part def Station { + part tel : Telescope[0..1]; + attribute now : Real default = -1.0; + action def Observe { + first start then a; + action a { ` + body + ` } + first a then done; + } + exhibit state run { + entry; then observing; + state observing { entry action observe : Observe; } + } + } + }` +} + +// testClockReadCurrentTimeNotAssigned: the clock advances with the run, so an +// assignment to its currentTime is refused with a typed error, not stored. +func testClockReadCurrentTimeNotAssigned(t *testing.T) { + _, _, err := instantiateWithLibraries(t, stationObserving("assign localClock.currentTime := 5.0;"), "test::Station") + if !errors.Is(err, ErrClockNotAssignable) || !strings.Contains(err.Error(), "assignment to localClock.currentTime") { + t.Fatalf("error = %v, want ErrClockNotAssignable over localClock.currentTime", err) + } +} + +// testClockReadOwnCurrentTimeNotAssigned: a clock's own body naming currentTime +// bare is refused the same way, not stored as data of the body. +func testClockReadOwnCurrentTimeNotAssigned(t *testing.T) { + src := `package test { + private import ScalarValues::*; + private import Clocks::*; + part def Chrono :> Clock { + attribute seen : Real default = -1.0; + action def Tick { + first start then a; + action a { assign currentTime := 5.0; assign seen := currentTime; } + first a then done; + } + exhibit state run { + entry; then ticking; + state ticking { entry action tick : Tick; } + } + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Chrono") + if !errors.Is(err, ErrClockNotAssignable) || !strings.Contains(err.Error(), "assignment to currentTime") { + t.Fatalf("error = %v, want ErrClockNotAssignable over a bare currentTime", err) + } +} + +// chronoStamping is a Clock whose `now` redefines currentTime, performing Tick +// with the tick body on entry, and a Station holding one, performing Observe. +func chronoStamping(tick, observe string) string { + return `package test { + private import ScalarValues::*; + private import Clocks::*; + part def Chrono :> Clock { + attribute now :>> currentTime; + attribute seen : Real default = -1.0; + action def Tick { + first start then a; + action a { ` + tick + ` } + first a then done; + } + exhibit state run { + entry; then ticking; + state ticking { entry action tick : Tick; } + } + } + part def Station { + part chrono : Chrono; + attribute seen : Real default = -1.0; + action def Observe { + first start then a; + action a { ` + observe + ` } + first a then done; + } + exhibit state run { + entry; then observing; + state observing { entry action observe : Observe; } + } + } + }` +} + +// testClockReadRedefinedCurrentTimeNotAssigned: a redefinition of currentTime is +// the same feature under another name, so a write to it is refused too. +func testClockReadRedefinedCurrentTimeNotAssigned(t *testing.T) { + _, _, err := instantiateWithLibraries(t, chronoStamping("assign now := 5.0;", ""), "test::Chrono") + if !errors.Is(err, ErrClockNotAssignable) || !strings.Contains(err.Error(), "assignment to now") { + t.Fatalf("error = %v, want ErrClockNotAssignable over a redefined currentTime", err) + } +} + +// testClockReadRedefinedCurrentTimeNotAssignedThroughPart: the same write through +// a part holding the clock is refused on the object the chain reaches. +func testClockReadRedefinedCurrentTimeNotAssignedThroughPart(t *testing.T) { + _, _, err := instantiateWithLibraries(t, chronoStamping("", "assign chrono.now := 5.0;"), "test::Station") + if !errors.Is(err, ErrClockNotAssignable) || !strings.Contains(err.Error(), "assignment to chrono.now") { + t.Fatalf("error = %v, want ErrClockNotAssignable over chrono.now", err) + } +} + +// testClockReadRedefinedCurrentTimeFollowsTheRunClock: a read of the redefining +// name, bare or through a part, is the run clock, not a stored default; the +// redefined name is no member of the redefining type any more. +func testClockReadRedefinedCurrentTimeFollowsTheRunClock(t *testing.T) { + for _, c := range []struct{ tick, observe, root string }{ + {"assign seen := now;", "", "test::Chrono"}, + {"", "assign seen := chrono.now;", "test::Station"}, + } { + body := c.tick + c.observe + ctx, inst, err := instantiateWithLibraries(t, chronoStamping(c.tick, c.observe), c.root) + if err != nil { + t.Fatalf("%s: %v", body, err) + } + fv, err := inst.GetFeatureValue(ctx, "seen") + if err != nil { + t.Fatal(err) + } + if got := FormatValue(fv.Value); got != "0.0" { + t.Fatalf("%s: seen = %v, want the clock's instant zero", body, got) + } + } +} + +// testClockReadOfPartHoldingNoObject: a part holding nothing has no clock to +// read, so the read yields no value and the assignment of it is refused. +func testClockReadOfPartHoldingNoObject(t *testing.T) { + _, _, err := instantiateWithLibraries(t, stationObserving("assign now := tel.localClock.currentTime;"), "test::Station") + if !errors.Is(err, ErrMultiplicityViolation) || !strings.Contains(err.Error(), "write now of object") { + t.Fatalf("error = %v, want ErrMultiplicityViolation writing an empty clock read to now", err) + } +} + +// testClockReadFollowsTheRunClock: the run starts at instant zero, so a read of +// the clock at the first step holds zero, not the wall clock. +func testClockReadFollowsTheRunClock(t *testing.T) { + ctx, inst, err := instantiateWithLibraries(t, stationObserving("assign now := localClock.currentTime;"), "test::Station") + if err != nil { + t.Fatal(err) + } + fv, err := inst.GetFeatureValue(ctx, "now") + if err != nil { + t.Fatal(err) + } + if got := FormatValue(fv.Value); got != "0.0" { + t.Fatalf("now = %v, want the clock's instant zero", got) + } +} + +// testClockReadInstantAgainstNamedScale: the Time clock's instant is on the +// clock's own scale, so comparing it with a point on UTC, which the library +// relates to no other reference, is refused rather than compared by magnitude. +func testClockReadInstantAgainstNamedScale(t *testing.T) { + src := `package test { + private import ScalarValues::*; + private import Time::*; + part def Station { + attribute epoch : TimeInstantValue = 0 [UTC]; + attribute late : Boolean default = false; + action def Observe { + first start then a; + action a { assign late := Time::universalClock.currentTime > epoch; } + first a then done; + } + exhibit state run { + entry; then observing; + state observing { entry action observe : Observe; } + } + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Station") + if !errors.Is(err, ErrUnevaluableLibraryFunction) || !strings.Contains(err.Error(), "Time::UTC") { + t.Fatalf("error = %v, want ErrUnevaluableLibraryFunction naming Time::UTC", err) + } +} diff --git a/internal/exec/runtime/robustness_clock_step_test.go b/internal/exec/runtime/robustness_clock_step_test.go new file mode 100644 index 0000000000..f0f0e90279 --- /dev/null +++ b/internal/exec/runtime/robustness_clock_step_test.go @@ -0,0 +1,223 @@ +package runtime + +import ( + "errors" + "math" + "testing" +) + +// TestRuntimeRobustnessClockStep exercises what a stepped clock refuses or must +// not change: a step that is no finite, non-negative number, a negative wait +// under a step, a step changed while waits are pending, an instant already past, +// a step too fine for the instant, and a tick past the last instant. Each is a +// typed error or the continuous clock's rule, never a panic or a wait that never +// comes due. +func TestRuntimeRobustnessClockStep(t *testing.T) { + t.Run("step_that_is_no_number_is_refused", testStepThatIsNoNumberIsRefused) + t.Run("negative_wait_is_refused_under_a_step", testNegativeWaitIsRefusedUnderAStep) + t.Run("step_set_mid_run_applies_to_the_waits_set_after_it", testStepSetMidRunAppliesToTheWaitsSetAfterIt) + t.Run("past_instant_fires_at_once_under_a_step", testPastInstantFiresAtOnceUnderAStep) + t.Run("past_instant_fires_at_once_off_the_grid", testPastInstantFiresAtOnceOffTheGrid) + t.Run("step_too_fine_to_tell_apart_leaves_the_wait_finite", testStepTooFineToTellApartLeavesTheWaitFinite) + t.Run("tick_past_the_last_instant_is_refused", testTickPastTheLastInstantIsRefused) +} + +// testStepThatIsNoNumberIsRefused: a negative, NaN or infinite step is ErrClockStep +// and leaves the clock's step as it was. +func testStepThatIsNoNumberIsRefused(t *testing.T) { + m := parseLibraryModel(t, steppedModel) + ctx, _ := m.fresh() + if err := ctx.SetClockStep(0.5); err != nil { + t.Fatal(err) + } + for _, step := range []float64{-0.5, math.NaN(), math.Inf(1), math.Inf(-1)} { + if err := ctx.SetClockStep(step); !errors.Is(err, ErrClockStep) { + t.Errorf("SetClockStep(%v) = %v, want ErrClockStep", step, err) + } + if ctx.ClockStep() != 0.5 { + t.Errorf("SetClockStep(%v) left the step at %v, want 0.5", step, ctx.ClockStep()) + } + } +} + +// testNegativeWaitIsRefusedUnderAStep: a step does not round a negative duration +// up to a tick; the wait stays ErrNegativeDuration. +func testNegativeWaitIsRefusedUnderAStep(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import SI::*; + action wrong { + attribute back : Real = -0.2; + first start; + then action w accept after back [s]; + then done; + } + }`) + _, _, err := runUnderStep(t, m, "wrong", 1.0) + if !errors.Is(err, ErrNegativeDuration) { + t.Fatalf("err = %v, want ErrNegativeDuration", err) + } +} + +// testStepSetMidRunAppliesToTheWaitsSetAfterIt: a wait already on the clock keeps +// the instant it was set for; the waits set after the change come due on the new +// grid. +func testStepSetMidRunAppliesToTheWaitsSetAfterIt(t *testing.T) { + m := parseLibraryModel(t, steppedModel) + ctx, _ := m.fresh() + exec, err := newActionExecutor(ctx, m.action(t, "Stepped"), nil) + if err != nil { + t.Fatal(err) + } + if err := exec.initialize(); err != nil { + t.Fatal(err) + } + for len(ctx.Clock().Waits()) == 0 { + if err := exec.Step(); err != nil && len(ctx.Clock().Waits()) == 0 { + t.Fatal(err) + } + } + if waits := ctx.Clock().Waits(); len(waits) != 1 || math.Abs(waits[0].Due-2.3) > 1e-12 { + t.Fatalf("the first wait is %+v, want one due at 2.3 on the continuous clock", waits) + } + if err := ctx.SetClockStep(1.0); err != nil { + t.Fatal(err) + } + if _, err := ctx.Advance(10); err != nil { + t.Fatal(err) + } + if !exec.State().Ended() { + t.Fatalf("the run is %v after 10 s, want ended", exec.State()) + } + got := realOutputs(t, exec.Results(), "t1", "t2", "t3") + for i, want := range []float64{2.3, 3.0, 6.0} { + if math.Abs(got[i]-want) > 1e-9 { + t.Errorf("t%d = %v, want %v: the pending wait keeps 2.3, the next tick from there is 3.0", i+1, got[i], want) + } + } +} + +// testPastInstantFiresAtOnceUnderAStep: an absolute instant the clock has passed +// is not waited for under a step either; the clock stays on the tick it reached. +func testPastInstantFiresAtOnceUnderAStep(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import SI::*; + private import Time::*; + action def Past { + out at : Real; + attribute early : TimeInstantValue = 1.0 [s]; + action w1 accept after 2.5 [s]; + then action w2 accept at early; + then action r { assign at := localClock.currentTime; } + } + }`) + ctx, out, err := runUnderStep(t, m, "Past", 2.0) + if err != nil { + t.Fatal(err) + } + if got := realOutputs(t, out, "at")[0]; got != 4.0 { + t.Errorf("at = %v, want 4.0: 2.5 rounds up to the tick 4.0 and the past instant waits no further", got) + } + if now := ctx.Clock().Now(); now != 4.0 { + t.Errorf("the clock is at %v, want 4.0", now) + } +} + +// testPastInstantFiresAtOnceOffTheGrid: a step set once the clock stands between +// two of its ticks does not put a passed absolute instant off to the next tick; +// only the waits for instants ahead come due on the grid. +func testPastInstantFiresAtOnceOffTheGrid(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import SI::*; + private import Time::*; + action def Past { + out at : Real; + out onward : Real; + attribute early : TimeInstantValue = 1.0 [s]; + attribute later : TimeInstantValue = 2.5 [s]; + action w1 accept after 2.3 [s]; + then action w2 accept at early; + then action r1 { assign at := localClock.currentTime; } + then action w3 accept at later; + then action r2 { assign onward := localClock.currentTime; } + } + }`) + ctx, _ := m.fresh() + exec, err := newActionExecutor(ctx, m.action(t, "Past"), nil) + if err != nil { + t.Fatal(err) + } + if err := exec.initialize(); err != nil { + t.Fatal(err) + } + for len(ctx.Clock().Waits()) == 0 { + if err := exec.Step(); err != nil && len(ctx.Clock().Waits()) == 0 { + t.Fatal(err) + } + } + if err := ctx.SetClockStep(1.0); err != nil { + t.Fatal(err) + } + if _, err := ctx.Advance(10); err != nil { + t.Fatal(err) + } + if !exec.State().Ended() { + t.Fatalf("the run is %v after 10 s, want ended", exec.State()) + } + got := realOutputs(t, exec.Results(), "at", "onward") + for i, want := range []float64{2.3, 3.0} { + if math.Abs(got[i]-want) > 1e-9 { + t.Errorf("%s = %v, want %v: the passed instant 1.0 waits no further at 2.3, and 2.5 ahead rounds up to the tick 3.0", []string{"at", "onward"}[i], got[i], want) + } + } +} + +// testStepTooFineToTellApartLeavesTheWaitFinite: a positive step so small that the +// instant counts more ticks than a float64 holds leaves the wait due at its instant, +// never at infinity. +func testStepTooFineToTellApartLeavesTheWaitFinite(t *testing.T) { + m := parseLibraryModel(t, steppedModel) + for _, step := range []float64{1e-320, 1e-300, 1e-17} { + ctx, out, err := runUnderStep(t, m, "Stepped", step) + if err != nil { + t.Fatalf("step %v: %v", step, err) + } + got := realOutputs(t, out, "t1", "t2", "t3") + for i, want := range []float64{2.3, 2.7, 6.0} { + if math.Abs(got[i]-want) > 1e-9 { + t.Errorf("step %v: t%d = %v, want %v", step, i+1, got[i], want) + } + } + if now := ctx.Clock().Now(); math.IsInf(now, 0) || math.IsNaN(now) { + t.Errorf("step %v: the clock is at %v, want a finite instant", step, now) + } + } +} + +// testTickPastTheLastInstantIsRefused: an instant whose next tick lies past the +// last float64 is refused as ErrNegativeDuration rather than waited for at infinity. +func testTickPastTheLastInstantIsRefused(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import SI::*; + private import Time::*; + action def Far { + attribute last : TimeInstantValue = 1.79e308 [s]; + action w accept at last; + then done; + } + }`) + ctx, _, err := runUnderStep(t, m, "Far", 1e307) + if !errors.Is(err, ErrNegativeDuration) { + t.Fatalf("err = %v, want ErrNegativeDuration", err) + } + if waits := ctx.Clock().Waits(); len(waits) != 0 { + t.Errorf("waits = %+v, want none queued", waits) + } +} diff --git a/internal/exec/runtime/robustness_completion_order_test.go b/internal/exec/runtime/robustness_completion_order_test.go new file mode 100644 index 0000000000..0fd679b156 --- /dev/null +++ b/internal/exec/runtime/robustness_completion_order_test.go @@ -0,0 +1,177 @@ +package runtime + +import ( + "context" + "errors" + "strings" + "testing" +) + +// completionOrderModel enters a parallel state on Go whose two regions start in +// states that complete at once and perform nothing; the completions' effects log +// the order the pool dispatched them, which is the order the entries were drawn. +const completionOrderModel = `package test { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute log : String = ""; + entry; then idle; + state idle; + transition first idle accept Go then work; + state work parallel { + state left { + entry; then l1; + state l1; + state l2; + transition first l1 do { assign log := log + "l "; } then l2; + } + state right { + entry; then r1; + state r1; + state r2; + transition first r1 do { assign log := log + "r "; } then r2; + } + } + } +}` + +// TestRuntimeRobustnessCompletionOrder: a completing entry is a drawn unit, so a +// witness naming one the front does not hold is a typed refusal before any +// completion is queued, a witness with a move the pool's order never offers is +// left over rather than followed, and a front of completing entries beyond the +// budget is no hang. +func TestRuntimeRobustnessCompletionOrder(t *testing.T) { + t.Run("entry_order_naming_a_completing_state_the_front_does_not_hold", testEntryOrderNamingACompletingStateTheFrontDoesNotHold) + t.Run("completions_dispatched_in_the_replayed_draws_order", testCompletionsDispatchedInTheReplayedDrawsOrder) + t.Run("witness_ordering_the_pool_after_the_draw_is_left_over", testWitnessOrderingThePoolAfterTheDrawIsLeftOver) + t.Run("wide_completing_front_beyond_the_run_budget", testWideCompletingFrontBeyondTheRunBudget) +} + +// completionOrderReplay drives completionOrderModel under the witness through one +// Go and returns the log, the run's error and the witness's unfollowed moves. +func completionOrderReplay(t *testing.T, lines string) (string, error, error) { + t.Helper() + m := parseExploreModel(t, completionOrderModel) + sym := m.state(t, "Machine") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices(lines) + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + exec.SendSignal("Go", nil) + err = exec.RunToCompletion() + return FormatValue(exec.StateData()["log"]), err, ctx.Unfollowed() +} + +// testEntryOrderNamingACompletingStateTheFrontDoesNotHold: an entry-order line +// naming a state no region is about to enter is refused at the draw, before +// either entry runs or either completion is queued. +func testEntryOrderNamingACompletingStateTheFrontDoesNotHold(t *testing.T) { + log, err, _ := completionOrderReplay(t, "entering work: zork(entry) first of l1(entry), zork(entry)\n") + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) { + t.Fatalf("error %T %v, want a ReplayError", err, err) + } + if refused.Move != 1 || !strings.Contains(refused.Error(), "zork(entry) is not enabled (enabled: l1(entry), r1(entry))") { + t.Errorf("refused %v, want move 1 naming zork(entry) as not enabled among l1(entry), r1(entry)", refused) + } + if log != `""` { + t.Errorf("log is %s after the refusal, want empty: no completion may dispatch on a refused draw", log) + } +} + +// testCompletionsDispatchedInTheReplayedDrawsOrder: the witness's entry draw +// decides the pool's order, so replaying either draw dispatches the completions +// in that draw's order, with no move left over — the pool's order is no choice. +func testCompletionsDispatchedInTheReplayedDrawsOrder(t *testing.T) { + for _, tc := range []struct{ first, log string }{ + {"l1", `"l r "`}, + {"r1", `"r l "`}, + } { + log, err, unfollowed := completionOrderReplay(t, "entering work: "+tc.first+"(entry) first of l1(entry), r1(entry)\n") + if err != nil || unfollowed != nil { + t.Fatalf("%s first: run %v, unfollowed %v, want the witness followed whole", tc.first, err, unfollowed) + } + if log != tc.log { + t.Errorf("%s first: log is %s, want %s: completions dispatch in the entry draw's order", tc.first, log, tc.log) + } + } +} + +// testWitnessOrderingThePoolAfterTheDrawIsLeftOver: a second entry-order line +// after the one draw the run makes names a choice the pool's fixed order never +// offers, so the run ends with it left over, having followed the first. +func testWitnessOrderingThePoolAfterTheDrawIsLeftOver(t *testing.T) { + log, err, unfollowed := completionOrderReplay(t, + "entering work: r1(entry) first of l1(entry), r1(entry)\n"+ + "entering work: l1(entry) first of l1(entry), r1(entry)\n") + if err != nil { + t.Fatalf("run: %v, want it to end on its own", err) + } + var refused *ReplayError + if !errors.As(unfollowed, &refused) || !errors.Is(unfollowed, ErrReplayRefused) { + t.Fatalf("unfollowed %T %v, want a ReplayError", unfollowed, unfollowed) + } + if refused.Move != 2 || !strings.Contains(refused.Error(), "the run ended") { + t.Errorf("refused %v, want move 2 left over when the run ended", refused) + } + if log != `"r l "` { + t.Errorf("log is %s, want the first draw followed: r1 entered first, its completion dispatched first", log) + } +} + +// testWideCompletingFrontBeyondTheRunBudget: a parallel state three deep and three +// wide whose leaves all complete at once has more entry linearizations than the +// budget's runs; the exploration reports that, the same way twice. +func testWideCompletingFrontBeyondTheRunBudget(t *testing.T) { + region := func(name string) string { + return "state " + name + " { entry; then s; state s; state d; transition first s do { assign n := n + 1; } then d; }\n" + } + wide := func(name string) string { + return "state " + name + " { entry; then p; state p parallel {\n" + region(name+"1") + region(name+"2") + region(name+"3") + "} }\n" + } + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute n : Integer = 0; + entry; then work; + state work parallel { + `+wide("a")+wide("b")+wide("c")+` + } + } + }`) + sym := m.state(t, "Machine") + run := stateRun(sym, "") + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + var first *Exploration + for i := 0; i < 2; i++ { + x, err := Explore(context.Background(), policy, m.fresh, run) + if err != nil { + t.Fatalf("explore: %v", err) + } + if x.Complete() || !strings.Contains(x.Status(), "runs budget") { + t.Fatalf("status %q, want the runs budget hit", x.Status()) + } + if x.Runs != DefaultExploreBudget.Runs { + t.Errorf("exploration made %d runs, want the budget's %d", x.Runs, DefaultExploreBudget.Runs) + } + if first == nil { + first = x + continue + } + if a, b := explored(t, first), explored(t, x); strings.Join(a, "\n") != strings.Join(b, "\n") { + t.Errorf("two explorations of one model differ") + } + } +} diff --git a/internal/exec/runtime/robustness_connector_objects_test.go b/internal/exec/runtime/robustness_connector_objects_test.go new file mode 100644 index 0000000000..390fd125cf --- /dev/null +++ b/internal/exec/runtime/robustness_connector_objects_test.go @@ -0,0 +1,95 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +func TestRuntimeRobustnessConnectorObjects(t *testing.T) { + t.Run("binding_end_names_no_feature", func(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + part def Sys { + attribute x : Integer = 1; + binding bnd bind x = missing; + } + }`) + before := len(ctx.instances) + _, err := inst.GetFeatureValue(ctx, "bnd") + if !errors.Is(err, ErrConnectorEnd) { + t.Fatalf("GetFeatureValue(bnd) = %v, want ErrConnectorEnd", err) + } + var endErr *ConnectorEndError + if !errors.As(err, &endErr) { + t.Fatalf("error = %T, want *ConnectorEndError", err) + } + if endErr.Location == "" || !strings.Contains(endErr.Location, "") { + t.Errorf("connector end error location = %q, want location", endErr.Location) + } + if len(ctx.instances) != before { + t.Errorf("failed binding materialized %d object(s)", len(ctx.instances)-before) + } + }) + + t.Run("flow_end_names_no_feature", func(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + port def P; + part def Sys { + port p : P; + flow f from p to missing; + } + }`) + before := len(ctx.instances) + _, err := inst.GetFeatureValue(ctx, "f") + if !errors.Is(err, ErrConnectorEnd) { + t.Fatalf("GetFeatureValue(f) = %v, want ErrConnectorEnd", err) + } + var endErr *ConnectorEndError + if !errors.As(err, &endErr) { + t.Fatalf("error = %T, want *ConnectorEndError", err) + } + if endErr.Location == "" || !strings.Contains(endErr.Location, "") { + t.Errorf("connector end error location = %q, want location", endErr.Location) + } + if len(ctx.instances) != before { + t.Errorf("failed flow materialized %d object(s)", len(ctx.instances)-before) + } + }) + + t.Run("binding_with_no_valued_end_as_object", func(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + part def Sys { + attribute x : Integer; + attribute y : Integer; + binding bnd bind x = y; + } + }`) + _, err := inst.GetFeatureValue(ctx, "bnd") + if !errors.Is(err, ErrConnectorEnd) && !errors.Is(err, ErrBindingEnd) { + t.Fatalf("GetFeatureValue(bnd) = %v, want connector or binding end error", err) + } + }) + + t.Run("one_ended_binding_is_no_connector_object", func(t *testing.T) { + inst, ctx := instantiatePart(t, "Sys", ` + package test { + part def Sys { + attribute x : Integer = 1; + binding b of x; + } + }`) + owned, err := inst.OwnedConnectors(ctx) + if err != nil { + t.Fatalf("OwnedConnectors: %v", err) + } + if len(owned) != 0 { + t.Fatalf("owned connectors = %d, want none", len(owned)) + } + if _, err := inst.GetFeatureValue(ctx, "b"); err != nil && errors.Is(err, ErrConnectorEnd) { + t.Fatalf("one-ended binding read as connector: %v", err) + } + }) +} diff --git a/internal/exec/runtime/robustness_do_step_entry_front_test.go b/internal/exec/runtime/robustness_do_step_entry_front_test.go new file mode 100644 index 0000000000..eb056df43f --- /dev/null +++ b/internal/exec/runtime/robustness_do_step_entry_front_test.go @@ -0,0 +1,463 @@ +package runtime + +import ( + "context" + "errors" + "strings" + "testing" +) + +// busyDoOnEntryFrontModel spins the left region's do flow forever from the moment l1 is +// entered, while the right region's entry is still left on the front; r1's completion +// then leaves work, cutting the flow, if its dispatch is drawn ahead of a move. +const busyDoOnEntryFrontModel = `package test { + private import ScalarValues::*; + state def Machine { + attribute n : Integer = 0; + entry; then work; + state work parallel { + state left { + entry; then l1; + state l1 { + do action spin { first start; then merge again; then action count assign n := n + 1; then again; } + } + } + state right { + entry; then r1; + state r1; + transition first r1 then idle; + } + } + state idle; + } +}` + +// busyCompositeDoModel spins work's own do flow forever from the moment work is entered, +// while its body's entry is still on the way down; w1's completion then leaves work. +const busyCompositeDoModel = `package test { + private import ScalarValues::*; + state def Machine { + attribute n : Integer = 0; + entry; then work; + state work { + do action spin { first start; then merge again; then action count assign n := n + 1; then again; } + entry; then w1; + state w1; + transition first w1 then idle; + } + state idle; + } +}` + +// TestRuntimeRobustnessDoStepEntryFront: a do step drawn against a sibling's entry unit +// ends at the entry or a named budget, is offered only once its state's entry has performed +// and only while a move is due, and never under a fixed policy; a composite's own step is +// drawn against its substates' entries the same way, on a front or down a serial body. +func TestRuntimeRobustnessDoStepEntryFront(t *testing.T) { + t.Run("busy_do_flow_against_a_sibling_entry_ends_at_the_entry_or_the_budget", testBusyDoFlowAgainstASiblingEntry) + t.Run("do_step_named_before_its_entry_has_performed_is_refused", testDoStepNamedBeforeItsEntryIsRefused) + t.Run("do_body_parked_at_an_accept_offers_no_draw_on_the_front", testParkedDoBodyOffersNoDrawOnTheFront) + t.Run("fixed_policies_run_the_entries_whole_and_the_do_round_after", testFixedPoliciesRunTheEntriesWhole) + t.Run("busy_composite_do_flow_against_its_own_body_entry_ends_at_the_entry_or_the_budget", testBusyCompositeDoFlowAgainstItsOwnBodyEntry) + t.Run("composite_do_step_named_before_the_composite_is_entered_is_refused", testCompositeDoStepNamedBeforeItsEntryIsRefused) + t.Run("composite_do_step_replays_before_between_and_after_its_own_body_entries", testCompositeDoStepReplaysAlongItsOwnBody) + t.Run("composite_do_behavior_ending_ahead_of_its_body_completes_nothing", testCompositeDoEndingAheadOfItsBodyCompletesNothing) + t.Run("fixed_policies_run_a_composite_body_whole_before_its_own_do_round", testFixedPoliciesRunACompositeBodyWhole) +} + +// testBusyDoFlowAgainstASiblingEntry: every explored run of an endless do flow on the entry +// front enters the sibling after some moves and is cut by its completion, or hits the +// do-step budget, a typed error. +func testBusyDoFlowAgainstASiblingEntry(t *testing.T) { + m := parseExploreModel(t, busyDoOnEntryFrontModel) + sym := m.state(t, "Machine") + run := func(ctx *Context) (Outcome, error) { + budgets := ctx.Budgets() + budgets.MaxDoSteps = 6 + if err := ctx.SetBudgets(budgets); err != nil { + return Outcome{}, err + } + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return Outcome{}, err + } + err = exec.RunToCompletion() + return exec.Outcome(), err + } + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil { + t.Fatalf("explore: %v", err) + } + if !x.Complete() || len(x.Outcomes) < 2 { + t.Fatalf("exploration %s with %d outcomes, want complete with entered and budgeted runs", x.Status(), len(x.Outcomes)) + } + var entered, budgeted int + for _, o := range x.Outcomes { + switch { + case errors.Is(o.Outcome.Err, ErrDoStepLimitExceeded): + budgeted++ + case o.Outcome.Err == nil && o.Outcome.FinalState == "idle": + entered++ + default: + t.Errorf("outcome %s: error %v, want the sibling's completion taken or the do-step budget", o.Outcome, o.Outcome.Err) + } + } + if entered == 0 || budgeted == 0 { + t.Errorf("%d entered, %d budgeted runs, want both", entered, budgeted) + } +} + +// testDoStepNamedBeforeItsEntryIsRefused: the front's first draw is between the two entries; +// a witness naming the do step there is refused, with the alternatives the run faced. +func testDoStepNamedBeforeItsEntryIsRefused(t *testing.T) { + m := parseExploreModel(t, busyDoOnEntryFrontModel) + sym := m.state(t, "Machine") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices("entering work: do l1 first of do l1, r1(entry)\n") + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err == nil { + err = exec.RunToCompletion() + } + var refused *ReplayError + if !errors.As(err, &refused) || refused.Move != 1 { + t.Fatalf("error %T %v, want the first line refused", err, err) + } + if got := refused.Error(); !strings.Contains(got, "l1(entry), r1(entry)") { + t.Errorf("refusal %q does not name the entries the run faced", got) + } +} + +// testParkedDoBodyOffersNoDrawOnTheFront: a do body's one move to an accept of a signal not +// in the pool parks it with no move due, so the front draws the sibling's entry alone and +// a third line naming the do step finds no draw: refused as unfollowed. +func testParkedDoBodyOffersNoDrawOnTheFront(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute log : String = ""; + entry; then work; + state work parallel { + state left { + entry; then l1; + state l1 { + do action wait { first start; then action w accept Go; then action mark assign log := log + "go "; then done; } + } + } + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry) "; } + } + } + } + } + }`) + sym := m.state(t, "Machine") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices("entering work: l1(entry) first of l1(entry), r1(entry)\nentering work: do l1 first of do l1, r1(entry)\nentering work: do l1 first of do l1, r1(entry)\n") + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if log := FormatValue(exec.StateData()["log"]); log != `"r1(entry) "` { + t.Errorf("log is %s, want the entry alone with the body parked", log) + } + err = ctx.Unfollowed() + var refused *ReplayError + if !errors.As(err, &refused) || refused.Move != 3 { + t.Fatalf("error %T %v, want the third line refused as unfollowed", err, err) + } +} + +// testFixedPoliciesRunTheEntriesWhole: declared, reverse and a seed run every entry before +// any do move, so the do flow, given a step budget of one round, never runs into it. +func testFixedPoliciesRunTheEntriesWhole(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute log : String = ""; + entry; then work; + state work parallel { + state left { + entry; then l1; + state l1 { + do { assign log := log + "did "; } + } + } + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry) "; } + } + } + } + } + }`) + sym := m.state(t, "Machine") + for _, policy := range []string{"declared", "reverse", "seed:1", "seed:7"} { + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, mustPolicy(t, policy)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("%s: run: %v", policy, err) + } + if log := FormatValue(exec.StateData()["log"]); log != `"r1(entry) did "` { + t.Errorf("%s: log is %s, want the entry before the do round", policy, log) + } + } +} + +// testBusyCompositeDoFlowAgainstItsOwnBodyEntry: every explored run of an endless do flow +// of the composite itself enters its body after some moves and is cut by the body's +// completion, or hits the do-step budget, a typed error. +func testBusyCompositeDoFlowAgainstItsOwnBodyEntry(t *testing.T) { + m := parseExploreModel(t, busyCompositeDoModel) + sym := m.state(t, "Machine") + run := func(ctx *Context) (Outcome, error) { + budgets := ctx.Budgets() + budgets.MaxDoSteps = 6 + if err := ctx.SetBudgets(budgets); err != nil { + return Outcome{}, err + } + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return Outcome{}, err + } + err = exec.RunToCompletion() + return exec.Outcome(), err + } + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil { + t.Fatalf("explore: %v", err) + } + if !x.Complete() || len(x.Outcomes) < 2 { + t.Fatalf("exploration %s with %d outcomes, want complete with entered and budgeted runs", x.Status(), len(x.Outcomes)) + } + var entered, budgeted int + for _, o := range x.Outcomes { + switch { + case errors.Is(o.Outcome.Err, ErrDoStepLimitExceeded): + budgeted++ + case o.Outcome.Err == nil && o.Outcome.FinalState == "idle": + entered++ + default: + t.Errorf("outcome %s: error %v, want the body's completion taken or the do-step budget", o.Outcome, o.Outcome.Err) + } + if len(o.Witness) > 0 && !strings.HasPrefix(o.Witness[0].String(), "entering work: ") { + t.Errorf("outcome %s: first draw %s, want it at work's entry", o.Outcome, o.Witness[0]) + } + } + if entered == 0 || budgeted == 0 { + t.Errorf("%d entered, %d budgeted runs, want both", entered, budgeted) + } +} + +// testCompositeDoStepNamedBeforeItsEntryIsRefused: the machine's entry of work draws +// nothing, no do behavior being due there; a witness naming work's step at that site is +// refused with the draw the run made instead, at work's own entry. +func testCompositeDoStepNamedBeforeItsEntryIsRefused(t *testing.T) { + m := parseExploreModel(t, busyCompositeDoModel) + sym := m.state(t, "Machine") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices("entering Machine: do work first of do work, work(entry)\n") + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err == nil { + err = exec.RunToCompletion() + } + var refused *ReplayError + if !errors.As(err, &refused) || refused.Move != 1 { + t.Fatalf("error %T %v, want the first line refused", err, err) + } + if got := refused.Error(); !strings.Contains(got, "entering work") || !strings.Contains(got, "do work, w1(entry)") { + t.Errorf("refusal %q does not name the draw the run faced", got) + } +} + +// testCompositeDoStepReplaysAlongItsOwnBody: down a serial body no front orders, each +// entry on the way is a draw against the composite's due step, and a witness places the +// step before, between or after the two entries, each replayed to its own log. +func testCompositeDoStepReplaysAlongItsOwnBody(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute log : String = ""; + entry; then work; + state work { + do { assign log := log + "did "; } + entry; then w1; + state w1 { + entry action { assign log := log + "w1(entry) "; } then w2; + state w2 { + entry { assign log := log + "w2(entry) "; } + } + } + } + } + }`) + sym := m.state(t, "Machine") + cases := []struct{ lines, log string }{ + {"entering work: do work first of do work, w1(entry)\n", `"did w1(entry) w2(entry) "`}, + {"entering work: w1(entry) first of do work, w1(entry)\nentering w1: do work first of do work, w2(entry)\n", `"w1(entry) did w2(entry) "`}, + {"entering work: w1(entry) first of do work, w1(entry)\nentering w1: w2(entry) first of do work, w2(entry)\n", `"w1(entry) w2(entry) did "`}, + } + for _, c := range cases { + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices(c.lines) + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("%q: run: %v", c.lines, err) + } + if err := ctx.Unfollowed(); err != nil { + t.Errorf("%q: %v, want every line followed", c.lines, err) + } + if log := FormatValue(exec.StateData()["log"]); log != c.log { + t.Errorf("%q: log is %s, want %s", c.lines, log, c.log) + } + } +} + +// testCompositeDoEndingAheadOfItsBodyCompletesNothing: a composite whose do behavior ends +// before its body is entered completes only once the body reaches `done`, not at the end +// of the do behavior. +func testCompositeDoEndingAheadOfItsBodyCompletesNothing(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute log : String = ""; + entry; then work; + state work { + do { assign log := log + "did "; } + entry; then w1; + state w1 { + entry { assign log := log + "w1(entry) "; } + } + transition first w1 accept Go then done; + } + state idle { + entry { assign log := log + "idle "; } + } + transition first work then idle; + } + }`) + sym := m.state(t, "Machine") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices("entering work: do work first of do work, w1(entry)\n") + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if err := ctx.Unfollowed(); err != nil { + t.Errorf("%v, want the do step drawn ahead of the body's entry", err) + } + if got := exec.FinalStateName(); got != "w1" { + t.Fatalf("work completed at the end of its do behavior: resting in %q, want w1", got) + } + exec.SendSignal("Go", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run after Go: %v", err) + } + if log, want := FormatValue(exec.StateData()["log"]), `"did w1(entry) idle "`; log != want { + t.Errorf("log is %s, want %s", log, want) + } +} + +// testFixedPoliciesRunACompositeBodyWhole: declared, reverse and a seed enter a composite's +// substates whole, on a front or down a serial body, and run its own do round after. +func testFixedPoliciesRunACompositeBodyWhole(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute log : String = ""; + entry; then work; + state work parallel { + do { assign log := log + "did "; } + state left { + entry; then l1; + state l1 { + entry action { assign log := log + "l1(entry) "; } then l2; + state l2 { + entry { assign log := log + "l2(entry) "; } + } + } + } + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry) "; } + } + } + } + } + }`) + sym := m.state(t, "Machine") + for _, policy := range []string{"declared", "reverse", "seed:1", "seed:7"} { + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, mustPolicy(t, policy)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("%s: run: %v", policy, err) + } + log := FormatValue(exec.StateData()["log"]) + if !strings.HasSuffix(log, `did "`) || strings.Count(log, "(entry) ") != 3 { + t.Errorf("%s: log is %s, want the three entries before the do round", policy, log) + } + } +} diff --git a/internal/exec/runtime/robustness_do_step_token_grain_test.go b/internal/exec/runtime/robustness_do_step_token_grain_test.go new file mode 100644 index 0000000000..f2261e9917 --- /dev/null +++ b/internal/exec/runtime/robustness_do_step_token_grain_test.go @@ -0,0 +1,150 @@ +package runtime + +import ( + "context" + "errors" + "slices" + "testing" +) + +// busyDoModel spins its do flow forever while Stop waits in the pool: under one-move +// scheduling the dispatch is drawn against every move of the loop. +const busyDoModel = `package test { + private import ScalarValues::*; + attribute def Stop; + state def Machine { + attribute n : Integer = 0; + entry; then top; + state top { + do action spin { first start; then merge again; then action count assign n := n + 1; then again; } + } + transition first top accept Stop then idle; + state idle; + } +}` + +// TestRuntimeRobustnessDoStepTokenGrain: a do flow drawn one move at a time against a +// dispatch ends at the dispatch or a named budget; a body parked at an accept offers no draw. +func TestRuntimeRobustnessDoStepTokenGrain(t *testing.T) { + t.Run("busy_do_flow_against_a_due_dispatch_ends_at_the_dispatch_or_the_budget", testBusyDoFlowAgainstADueDispatch) + t.Run("check_of_a_busy_do_flow_against_a_due_dispatch_names_its_bound", testCheckOfABusyDoFlowNamesItsBound) + t.Run("step_order_naming_a_do_body_parked_at_an_accept", testStepOrderNamingADoBodyParkedAtAnAccept) +} + +// testBusyDoFlowAgainstADueDispatch: every explored run of an endless do flow with a +// dispatch due takes the dispatch after some moves or hits the do-step budget, a typed error. +func testBusyDoFlowAgainstADueDispatch(t *testing.T) { + m := parseExploreModel(t, busyDoModel) + sym := m.state(t, "Machine") + run := func(ctx *Context) (Outcome, error) { + budgets := ctx.Budgets() + budgets.MaxDoSteps = 8 + if err := ctx.SetBudgets(budgets); err != nil { + return Outcome{}, err + } + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return Outcome{}, err + } + exec.SendSignal("Stop", nil) + err = exec.RunToCompletion() + return exec.Outcome(), err + } + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil { + t.Fatalf("explore: %v", err) + } + if !x.Complete() || len(x.Outcomes) < 2 { + t.Fatalf("exploration %s with %d outcomes, want complete with dispatched and budgeted runs", x.Status(), len(x.Outcomes)) + } + var dispatched, budgeted int + for _, o := range x.Outcomes { + switch { + case errors.Is(o.Outcome.Err, ErrDoStepLimitExceeded): + budgeted++ + case o.Outcome.Err == nil && o.Outcome.FinalState == "idle": + dispatched++ + default: + t.Errorf("outcome %s: error %v, want the dispatch taken or the do-step budget", o.Outcome, o.Outcome.Err) + } + } + if dispatched == 0 || budgeted == 0 { + t.Errorf("%d dispatched, %d budgeted runs, want both", dispatched, budgeted) + } +} + +// testCheckOfABusyDoFlowNamesItsBound: the schedule that keeps moving the do flow never +// dispatches, so the check runs into an executor budget and names it, not exhaustive. +func testCheckOfABusyDoFlowNamesItsBound(t *testing.T) { + m := parseExploreModel(t, busyDoModel) + sym := m.state(t, "Machine") + start := func(ctx *Context) (*Invocation, error) { + budgets := ctx.Budgets() + budgets.MaxSteps = 300 + if err := ctx.SetBudgets(budgets); err != nil { + return nil, err + } + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return nil, err + } + exec.SendSignal("Stop", nil) + return &Invocation{States: []*StateExecutor{exec}}, nil + } + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, unreduced(), nil) + if err != nil { + t.Fatal(err) + } + bounded := slices.ContainsFunc(report.BoundsHit, func(b string) bool { return slices.Contains(ExecutorBounds, b) }) + if report.Verdict == CheckExhaustive || !bounded { + t.Fatalf("check: %s, want bounded by an executor budget", report.Status()) + } + if !slices.ContainsFunc(report.Finals, func(f CheckFinal) bool { return f.Outcome == "finalState idle; visits top, idle; n = 0" }) { + t.Fatalf("finals %v, want the dispatch before any move of the do flow among them", finalOutcomes(report)) + } +} + +// testStepOrderNamingADoBodyParkedAtAnAccept: a do body parked at an accept of a signal not +// in the pool is not due, so a second step-order line finds no draw: refused as unfollowed. +func testStepOrderNamingADoBodyParkedAtAnAccept(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Stop; + attribute def Go; + state def Machine { + attribute log : String = ""; + entry; then top; + state top { + do action wait { first start; then action w accept Go; then action mark assign log := log + "go "; then done; } + } + transition first top accept Stop do assign log := log + "stop " then idle; + state idle; + } + }`) + sym := m.state(t, "Machine") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices("at t=0.0: do top first of do top, dispatch accept Stop\nat t=0.0: do top first of do top, dispatch accept Stop\n") + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + exec.SendSignal("Stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if log := FormatValue(exec.StateData()["log"]); log != `"stop "` { + t.Errorf("log is %s, want the dispatch alone after the move that parked the body", log) + } + err = ctx.Unfollowed() + var refused *ReplayError + if !errors.As(err, &refused) || refused.Move != 2 { + t.Fatalf("error %T %v, want the line refused as unfollowed", err, err) + } +} diff --git a/internal/exec/runtime/robustness_draw_policy_test.go b/internal/exec/runtime/robustness_draw_policy_test.go new file mode 100644 index 0000000000..d801f5be3d --- /dev/null +++ b/internal/exec/runtime/robustness_draw_policy_test.go @@ -0,0 +1,365 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessDrawPolicy exercises what a draw policy cannot resolve: a +// call with no point of the kind the policy names, a witness whose draws its +// policy could not have made, a policy that reads as none, and a run whose draws +// the policy fixes but whose weighted decision still needs a seed. Each is a typed +// error, never a panic, a silent default or a hang. +func TestRuntimeRobustnessDrawPolicy(t *testing.T) { + t.Run("normal_has_no_min_or_max", testNormalHasNoMinOrMax) + t.Run("degenerate_normal_is_its_mean_under_every_policy", testDegenerateNormalIsItsMeanUnderEveryPolicy) + t.Run("degenerate_normal_draws_nothing_at_random", testDegenerateNormalDrawsNothingAtRandom) + t.Run("policy_set_mid_run_waits_for_the_next_run", testPolicySetMidRunWaitsForTheNextRun) + t.Run("unbounded_timer_stops_the_run_where_it_parks", testUnboundedTimerStopsTheRunWhereItParks) + t.Run("unknown_policy_is_refused", testUnknownPolicyIsRefused) + t.Run("witness_draw_the_policy_cannot_make", testWitnessDrawThePolicyCannotMake) + t.Run("witness_policy_over_a_call_it_cannot_resolve", testWitnessPolicyOverACallItCannotResolve) + t.Run("witness_left_to_a_policy_over_a_call_it_cannot_resolve", testWitnessLeftToAPolicyOverACallItCannotResolve) + t.Run("fixed_policy_still_checks_the_domain", testFixedPolicyStillChecksTheDomain) + t.Run("fixed_policy_leaves_unseeded_decisions_most_probable", testFixedPolicyLeavesUnseededDecisionsMostProbable) +} + +// normalModel draws one normal as it starts. +const normalModel = ` +package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute g : Real = normal(12.0, 3.0); + first start; then done; + } +}` + +// testNormalHasNoMinOrMax: a normal under min or max is a DrawUnboundedError +// naming the call and the policy, seeded or not; under average it is its mean. +func testNormalHasNoMinOrMax(t *testing.T) { + m := parseLibraryModel(t, normalModel) + for _, policy := range []DrawPolicy{DrawMin, DrawMax} { + for _, seed := range [][]uint64{nil, {7}} { + _, _, err := runUnderDraws(t, m, "draw", policy, seed...) + var unbounded *DrawUnboundedError + if !errors.As(err, &unbounded) || !errors.Is(err, ErrDrawUnbounded) { + t.Fatalf("%s: error %T %v, want a DrawUnboundedError", policy, err, err) + } + if unbounded.What != "normal(12.0, 3.0)" || unbounded.Policy != policy { + t.Errorf("%s: the error names %s under %s", policy, unbounded.What, unbounded.Policy) + } + if !strings.Contains(err.Error(), "normal(12.0, 3.0) under "+policy.String()+": the distribution is unbounded") { + t.Errorf("%s: error %q does not name the call and the policy", policy, err) + } + } + } + _, out, err := runUnderDraws(t, m, "draw", DrawAverage) + if err != nil { + t.Fatal(err) + } + if got := realOut(t, out, "g"); got != 12 { + t.Errorf("g = %v under average, want the mean", got) + } +} + +// testDegenerateNormalIsItsMeanUnderEveryPolicy: normal(mean, 0) draws mean and +// nothing else, so min, max and average resolve it to mean, as a witness under min records. +func testDegenerateNormalIsItsMeanUnderEveryPolicy(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute g : Real = normal(5.0, 0.0); + first start; then done; + } + }`) + for _, policy := range []DrawPolicy{DrawRandom, DrawMin, DrawMax, DrawAverage} { + ctx, out, err := runUnderDraws(t, m, "draw", policy, 7) + if err != nil { + t.Fatalf("%s: %v", policy, err) + } + if got := realOut(t, out, "g"); got != 5 { + t.Errorf("g = %v under %s, want the mean", got, policy) + } + if draws := ctx.DrawsTaken(); len(draws) != 1 || draws[0].String() != "draw normal(5.0, 0.0) = 5.0" { + t.Errorf("%s recorded %v, want the one draw of the mean", policy, draws) + } + } + w, err := ParseWitness("draws by min\ndraw normal(5.0, 0.0) = 5.0\nno choice points\n") + if err != nil { + t.Fatal(err) + } + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(w)) + if _, err := replay.ExecuteAction(m.action(t, "draw")); err != nil { + t.Errorf("replaying the witness under min: %v", err) + } +} + +// testDegenerateNormalDrawsNothingAtRandom: normal(mean, 0) makes no random choice, +// so at random it needs no seed and leaves the seeded stream where the next draw finds it. +func testDegenerateNormalDrawsNothingAtRandom(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action unseeded { + attribute g : Real = normal(5.0, 0.0); + first start; then done; + } + action alone { + attribute u : Real = uniform(2.0, 6.0); + first start; then done; + } + action after { + attribute g : Real = normal(5.0, 0.0); + attribute u : Real = uniform(2.0, 6.0); + first start; then done; + } + }`) + ctx, out, err := runUnderDraws(t, m, "unseeded", DrawRandom) + if err != nil { + t.Fatalf("normal(5.0, 0.0) at random without a seed: %v", err) + } + if got := realOut(t, out, "g"); got != 5 { + t.Errorf("g = %v, want the mean", got) + } + if draws := formatDraws(ctx.DrawsTaken()); draws != "draw normal(5.0, 0.0) = 5.0\n" { + t.Errorf("recorded %q, want the one draw of the mean", draws) + } + _, alone, err := runUnderDraws(t, m, "alone", DrawRandom, 7) + if err != nil { + t.Fatal(err) + } + _, after, err := runUnderDraws(t, m, "after", DrawRandom, 7) + if err != nil { + t.Fatal(err) + } + if alone["u"].Const != after["u"].Const { + t.Errorf("u = %v after normal(5.0, 0.0), want %v: the degenerate normal consumed the stream", after["u"], alone["u"]) + } +} + +// testPolicySetMidRunWaitsForTheNextRun: a run keeps the policy it started under +// while paused, its witness names that policy, and the next run takes the new one. +func testPolicySetMidRunWaitsForTheNextRun(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute u : Real = uniform(2.0, 6.0); + attribute v : Real; + first start; + then assign v := uniform(2.0, 6.0); + then done; + } + }`) + ctx, _ := m.fresh() + ctx.SetDrawPolicy(DrawMax) + exec, err := newActionExecutor(ctx, m.action(t, "draw"), nil) + if err != nil { + t.Fatal(err) + } + if err := exec.initialize(); err != nil { + t.Fatal(err) + } + if draws := formatDraws(ctx.DrawsTaken()); draws != "draw uniform(2.0, 6.0) = 6.0\n" { + t.Fatalf("initializing drew\n%swant u at max", draws) + } + ctx.SetDrawPolicy(DrawMin) + for !exec.State().Ended() { + if err := exec.Step(); err != nil { + t.Fatal(err) + } + } + if draws := formatDraws(ctx.DrawsTaken()); draws != "draw uniform(2.0, 6.0) = 6.0\ndraw uniform(2.0, 6.0) = 6.0\n" { + t.Errorf("the paused run drew\n%swant both draws at max, the policy it started under", draws) + } + if taken := ctx.DrawPolicyTaken(); taken != DrawMax { + t.Errorf("the run's witness would name %s, want max", taken) + } + if ctx.DrawPolicy() != DrawMin { + t.Errorf("the next run's policy is %s, want min", ctx.DrawPolicy()) + } + out, err := ctx.ExecuteAction(m.action(t, "draw")) + if err != nil { + t.Fatal(err) + } + if u, v := realOut(t, out, "u"), realOut(t, out, "v"); u != 2 || v != 2 { + t.Errorf("the next run drew u = %v, v = %v, want both at min", u, v) + } + if taken := ctx.DrawPolicyTaken(); taken != DrawMin { + t.Errorf("the next run drew by %s, want min", taken) + } +} + +// testUnboundedTimerStopsTheRunWhereItParks: a timer drawn from a normal under +// max fails as the wait arms, not at the start, so the steps before it ran. +func testUnboundedTimerStopsTheRunWhereItParks(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import SI::*; + private import RandomFunctions::*; + action timed { + attribute reached : Integer = 0; + first start; + then action mark assign reached := 1; + then action wait accept after normal(30.0, 5.0) [s]; + then action late assign reached := 2; + then done; + } + }`) + ctx, _ := m.fresh() + ctx.SetDrawPolicy(DrawMax) + _, err := ctx.ExecuteAction(m.action(t, "timed")) + if !errors.Is(err, ErrDrawUnbounded) { + t.Fatalf("error %v, want ErrDrawUnbounded", err) + } + if ctx.Clock().Now() != 0 { + t.Errorf("the clock advanced to %v past a wait that never armed", ctx.Clock().Now()) + } + if reached := ctx.Clock().Waits(); len(reached) != 0 { + t.Errorf("a wait is armed past the refusal: %v", reached) + } +} + +// testUnknownPolicyIsRefused: a spelling that is no policy is ErrDrawPolicy, case +// included, and returns the random policy beside its error. +func testUnknownPolicyIsRefused(t *testing.T) { + for _, text := range []string{"Max", "mean", "minimum", "seed:3", "random,min"} { + policy, err := ParseDrawPolicy(text) + if !errors.Is(err, ErrDrawPolicy) { + t.Errorf("ParseDrawPolicy(%q) = %v, %v; want ErrDrawPolicy", text, policy, err) + } + if policy != DrawRandom { + t.Errorf("ParseDrawPolicy(%q) returned %s beside its error", text, policy) + } + } +} + +// testWitnessDrawThePolicyCannotMake: a witness under max whose draw is not the +// call's max is a WitnessDrawError naming the draw and the policy, at the draw. +func testWitnessDrawThePolicyCannotMake(t *testing.T) { + m := parseLibraryModel(t, drawingModel) + w, err := ParseWitness("draws by max\ndraw uniform(0.0, 1.0) = 0.25\ndraw uniformInteger(1, 6) = 6\nno choice points\n") + if err != nil { + t.Fatal(err) + } + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(w)) + _, err = replay.ExecuteAction(m.action(t, "draw")) + var refused *WitnessDrawError + if !errors.As(err, &refused) { + t.Fatalf("error %T %v, want a WitnessDrawError", err, err) + } + if refused.Draw != 1 || !strings.Contains(err.Error(), "records 0.25, which the call cannot draw under max") { + t.Errorf("error %q, want draw 1 refused under max", err) + } +} + +// testWitnessPolicyOverACallItCannotResolve: a witness under min recording a draw +// of a normal is refused — no min draw of a normal exists to have recorded. +func testWitnessPolicyOverACallItCannotResolve(t *testing.T) { + m := parseLibraryModel(t, normalModel) + w, err := ParseWitness("draws by min\ndraw normal(12.0, 3.0) = 12.0\nno choice points\n") + if err != nil { + t.Fatal(err) + } + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(w)) + _, err = replay.ExecuteAction(m.action(t, "draw")) + var refused *WitnessDrawError + if !errors.As(err, &refused) || !strings.Contains(err.Error(), "under min") { + t.Fatalf("error %T %v, want a WitnessDrawError under min", err, err) + } +} + +// testWitnessLeftToAPolicyOverACallItCannotResolve: a witness recording no draws +// under min meets a normal as the run would, a DrawUnboundedError naming the call. +func testWitnessLeftToAPolicyOverACallItCannotResolve(t *testing.T) { + m := parseLibraryModel(t, normalModel) + w, err := ParseWitness("draws by min\nno choice points\n") + if err != nil { + t.Fatal(err) + } + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(w)) + _, err = replay.ExecuteAction(m.action(t, "draw")) + var unbounded *DrawUnboundedError + if !errors.As(err, &unbounded) || unbounded.Policy != DrawMin || !strings.Contains(err.Error(), "normal(12.0, 3.0)") { + t.Fatalf("error %T %v, want a DrawUnboundedError naming the call under min", err, err) + } +} + +// testFixedPolicyStillChecksTheDomain: an empty or non-finite domain is refused +// under a fixed policy as under random, before any point is taken. +func testFixedPolicyStillChecksTheDomain(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action inverted { + attribute u : Real = uniform(6.0, 2.0); + first start; then done; + } + action narrowed { + attribute n : Integer = uniformInteger(9, 3); + first start; then done; + } + }`) + for _, name := range []string{"inverted", "narrowed"} { + for _, policy := range []DrawPolicy{DrawMin, DrawMax, DrawAverage} { + ctx, _, err := runUnderDraws(t, m, name, policy) + if !errors.Is(err, ErrRandomDomain) { + t.Errorf("%s under %s: error %v, want ErrRandomDomain", name, policy, err) + } + if draws := ctx.DrawsTaken(); len(draws) != 0 { + t.Errorf("%s under %s recorded %v past its refusal", name, policy, draws) + } + } + } +} + +// testFixedPolicyLeavesUnseededDecisionsMostProbable: a fixed policy resolves the +// durations, not the decision: unseeded, the most probable branch is taken and no +// draw is recorded for it, while the durations are fixed. +func testFixedPolicyLeavesUnseededDecisionsMostProbable(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import SI::*; + private import Stochastic::*; + private import RandomFunctions::*; + action route { + attribute taken : Integer = 0; + first start; + then decide select; + first select then slow { @Probability { p = 0.3; } } + first select then fast { @Probability { p = 0.7; } } + action slow { assign taken := 2; } + then action slowWait accept after uniform(10, 20) [s]; + then done; + action fast { assign taken := 1; } + then action fastWait accept after uniform(1, 5) [s]; + then done; + } + }`) + ctx, out, err := runUnderDraws(t, m, "route", DrawMax) + if err != nil { + t.Fatal(err) + } + if got := takenInt(t, out, "taken"); got != 1 { + t.Errorf("took %d, want the 0.7 branch", got) + } + if draws := ctx.DrawsTaken(); len(draws) != 1 || draws[0].String() != "draw uniform(1, 5) = 5.0" { + t.Errorf("recorded %v, want the one fixed duration", draws) + } + if ctx.Clock().Now() != 5 { + t.Errorf("the clock ended at %v, want 5", ctx.Clock().Now()) + } +} diff --git a/internal/exec/runtime/robustness_exhibit_state_self_test.go b/internal/exec/runtime/robustness_exhibit_state_self_test.go new file mode 100644 index 0000000000..37f2776848 --- /dev/null +++ b/internal/exec/runtime/robustness_exhibit_state_self_test.go @@ -0,0 +1,80 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessExhibitStateSelf exercises the failure modes of an +// `exhibit` declaration whose binding names an element holding no body: only a +// declaration naming no element at all is its own body. A name that resolves +// to nothing is a nameres diagnostic upstream, so each case resolves to an +// element that still states no body — or to none through an unresolvable +// clause, which the model builder tolerates while the analyzer reports it. +func TestRuntimeRobustnessExhibitStateSelf(t *testing.T) { + t.Run("reference_form_names_no_state", testExhibitReferenceNamesNoState) + t.Run("reference_subsetting_names_nothing", testExhibitSubsettingNamesNothing) + t.Run("typing_names_nothing", testExhibitTypingNamesNothing) +} + +// testExhibitReferenceNamesNoState: `exhibit modes;` binds the element +// `modes`, which here is an attribute stating no behavior body, so the chain +// between the two never ends at a state. (An unresolvable `modes` derives no +// member at all and is reported by the analyzer instead, so it cannot reach +// the runtime.) +func testExhibitReferenceNamesNoState(t *testing.T) { + src := ` + part def P { + attribute modes; + exhibit modes; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + _, err := ctx.Instantiate(resolveSymbol(t, root, "P")) + if !errors.Is(err, ErrUnresolvedClassifierBehavior) { + t.Fatalf("error = %v, want ErrUnresolvedClassifierBehavior", err) + } + if !strings.Contains(err.Error(), "modes") { + t.Errorf("error %q does not name the behavior", err) + } +} + +// testExhibitSubsettingNamesNothing: `exhibit state s ::> missing` states a +// reference subsetting whose target resolves to no element, so no body is +// reached and the usage is not its own body — it named one. +func testExhibitSubsettingNamesNothing(t *testing.T) { + src := ` + part def P { + state declared; + exhibit state s ::> missing; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + _, err := ctx.Instantiate(resolveSymbol(t, root, "P")) + if !errors.Is(err, ErrUnresolvedClassifierBehavior) { + t.Fatalf("error = %v, want ErrUnresolvedClassifierBehavior", err) + } +} + +// testExhibitTypingNamesNothing: `exhibit state s : Nothing` states a typing +// that resolves to no element, so no body is reached and the usage is not its +// own body — it named one. +func testExhibitTypingNamesNothing(t *testing.T) { + src := ` + part def P { + exhibit state s : Nothing; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + _, err := ctx.Instantiate(resolveSymbol(t, root, "P")) + if !errors.Is(err, ErrUnresolvedClassifierBehavior) { + t.Fatalf("error = %v, want ErrUnresolvedClassifierBehavior", err) + } +} diff --git a/internal/exec/runtime/robustness_exit_parameters_test.go b/internal/exec/runtime/robustness_exit_parameters_test.go new file mode 100644 index 0000000000..ccf306dcd2 --- /dev/null +++ b/internal/exec/runtime/robustness_exit_parameters_test.go @@ -0,0 +1,201 @@ +package runtime + +import ( + "errors" + "testing" +) + +// TestRuntimeRobustnessExitParameters exercises the failure modes of an exit +// action reading the leaving transition's payload, `T.d`: a payload of the wrong +// type, a transition not taken read by a parameter needing a value, a signal +// carrying no payload, a call missing a declared argument, and a transition not +// taken read with a fallback. Each is a typed error or an empty reading, never a +// panic, and a refused firing leaves the machine where it was. +func TestRuntimeRobustnessExitParameters(t *testing.T) { + t.Run("payload_of_the_wrong_type", testExitParametersTypeMismatch) + t.Run("transition_not_taken_binds_nothing", testExitParametersNotTakenNeedsOne) + t.Run("signal_carrying_no_payload", testExitParametersPayloadAbsent) + t.Run("call_missing_an_argument_is_not_taken", testExitParametersCallArity) + t.Run("transition_not_taken_reads_the_fallback", testExitParametersNotTakenFallback) +} + +// exitParametersModel: `idle` is left by `go` or `halt`, its exit reading whichever +// is taken; `typed` is left by `again`, whose Integer payload its exit binds to a +// Boolean; `strict` is left by `pass`, its exit reading `go`, which is not taken. +const exitParametersModel = `package test { + private import ScalarValues::*; + attribute def Go :> Integer; + attribute def Halt :> Integer; + attribute def Pass; + state def Machine { + attribute exited : Integer = -1; + attribute flagged : Boolean = false; + attribute strictly : Integer = -1; + entry; then idle; + state idle { + exit action { + in level : Integer = go.l ?? halt.h ?? 0; + assign exited := level; + } + } + state typed { + exit action { + in flag : Boolean = again.l; + assign flagged := flag; + } + } + state strict { + exit action { + in level : Integer = go.l; + assign strictly := level; + } + } + state passed; + transition go first idle accept l : Go then typed; + transition halt first idle accept h : Halt then strict; + transition again first typed accept l : Go then done; + transition pass first strict accept Pass then passed; + transition first passed then done; + } +}` + +// exitParametersCallModel: `set` takes a `setSpeed` call carrying both arguments; +// the exit of `idle` reads one of them. +const exitParametersCallModel = `package test { + private import ScalarValues::*; + state def Machine { + attribute exited : Integer = -1; + entry; then idle; + state idle { + exit action { + in v : Integer = set.speed; + assign exited := v; + } + } + transition set first idle accept setSpeed(speed, gear) then done; + } +}` + +// exitParametersMachine starts the model's machine and drives it to `idle`. +func exitParametersMachine(t *testing.T, model string) *StateExecutor { + t.Helper() + exec := callMachineOf(t, model) + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + return exec +} + +// exitParametersSend queues a signal carrying one value and runs the machine. +func exitParametersSend(t *testing.T, exec *StateExecutor, signal string, value Value) error { + t.Helper() + if err := exec.Enqueue(QueuedEvent{Signal: signal, Value: &value}); err != nil { + t.Fatal(err) + } + return exec.RunToCompletion() +} + +// testExitParametersTypeMismatch: the Integer payload `again` carries cannot bind +// the exit's Boolean parameter; the firing is refused and `typed` stays active. +func testExitParametersTypeMismatch(t *testing.T) { + exec := exitParametersMachine(t, exitParametersModel) + if err := exitParametersSend(t, exec, "Go", constInt(3)); err != nil { + t.Fatal(err) + } + err := exitParametersSend(t, exec, "Go", constInt(4)) + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("exit binding an Integer to a Boolean = %v; want ErrTypeMismatch", err) + } + if got := activeLeafName(exec); got != "typed" { + t.Errorf("active state after the refusal = %v, want typed", got) + } + if flagged := exec.stateData["flagged"]; flagged.Const.Bool { + t.Errorf("flagged = true after the refusal, want the exit's write undone") + } +} + +// testExitParametersNotTakenNeedsOne: `strict` is left by `pass`, so `go.l` reads +// empty, which its [1] parameter refuses as a multiplicity violation. +func testExitParametersNotTakenNeedsOne(t *testing.T) { + exec := exitParametersMachine(t, exitParametersModel) + if err := exitParametersSend(t, exec, "Halt", constInt(8)); err != nil { + t.Fatal(err) + } + if exited := exec.stateData["exited"]; exited.Const.Int != 8 { + t.Errorf("exited = %v after Halt, want 8 from halt.h", exited) + } + exec.SendSignal("Pass", nil) + err := exec.RunToCompletion() + if !errors.Is(err, ErrMultiplicityViolation) { + t.Errorf("exit reading a transition not taken = %v; want ErrMultiplicityViolation", err) + } + if got := activeLeafName(exec); got != "strict" { + t.Errorf("active state after the refusal = %v, want strict", got) + } +} + +// testExitParametersPayloadAbsent: a `Go` carrying no value cannot bind the +// trigger's payload, so the transition is refused before the exit is performed. +func testExitParametersPayloadAbsent(t *testing.T) { + exec := exitParametersMachine(t, exitParametersModel) + exec.SendSignal("Go", nil) + err := exec.RunToCompletion() + if !errors.Is(err, ErrNoValue) { + t.Errorf("Go without a payload = %v; want ErrNoValue", err) + } + if exited := exec.stateData["exited"]; exited.Const.Int != -1 { + t.Errorf("exited = %v, want the exit not performed", exited) + } +} + +// testExitParametersCallArity: a `setSpeed` call missing `gear` does not fire +// `set`, so the exit is not performed and `idle` stays active; a call carrying +// both fires it and the exit reads `speed`. +func testExitParametersCallArity(t *testing.T) { + exec := exitParametersMachine(t, exitParametersCallModel) + exec.InvokeOperation("setSpeed", map[string]Value{"speed": constInt(5)}) + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + if got := activeLeafName(exec); got != "idle" { + t.Errorf("active state after the partial call = %v, want idle", got) + } + if exited := exec.stateData["exited"]; exited.Const.Int != -1 { + t.Errorf("exited = %v after the partial call, want the exit not performed", exited) + } + exec.InvokeOperation("setSpeed", map[string]Value{"speed": constInt(5), "gear": constInt(2)}) + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + if exited := exec.stateData["exited"]; exited.Const.Int != 5 { + t.Errorf("exited = %v after the full call, want 5 from set.speed", exited) + } +} + +// testExitParametersNotTakenFallback: `idle` left by `halt` reads `go.l` as empty, +// so the exit's `??` chain falls through to `halt.h`. +func testExitParametersNotTakenFallback(t *testing.T) { + exec := exitParametersMachine(t, exitParametersModel) + if err := exitParametersSend(t, exec, "Go", constInt(3)); err != nil { + t.Fatal(err) + } + if exited := exec.stateData["exited"]; exited.Const.Int != 3 { + t.Errorf("exited = %v after Go, want 3 from go.l", exited) + } + exec = exitParametersMachine(t, exitParametersModel) + if err := exitParametersSend(t, exec, "Halt", constInt(8)); err != nil { + t.Fatal(err) + } + if exited := exec.stateData["exited"]; exited.Const.Int != 8 { + t.Errorf("exited = %v after Halt, want 8 from halt.h", exited) + } +} + +// activeLeafName is the machine's single active leaf, "" when none is. +func activeLeafName(exec *StateExecutor) string { + leaves := exec.ActiveLeaves() + if len(leaves) != 1 { + return "" + } + return leaves[0].Name +} diff --git a/internal/exec/runtime/robustness_feature_weights_test.go b/internal/exec/runtime/robustness_feature_weights_test.go new file mode 100644 index 0000000000..4079a82a10 --- /dev/null +++ b/internal/exec/runtime/robustness_feature_weights_test.go @@ -0,0 +1,178 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +// TestRuntimeRobustnessFeatureWeights exercises Probability weights read from +// features rather than written as constants: a weight of no numeric type is +// refused before the run, and the checks lowering makes of constants — the +// range and the sum to 1 — are made of the values read when the decision is +// reached, each a typed ErrBranchWeights, never a silent renormalization. +func TestRuntimeRobustnessFeatureWeights(t *testing.T) { + t.Run("weight_of_no_numeric_type_is_refused_before_the_run", testFeatureWeightOfNoNumericTypeIsRefusedBeforeTheRun) + t.Run("weights_read_must_sum_to_one", testFeatureWeightsReadMustSumToOne) + t.Run("weight_read_outside_the_unit_interval_is_refused", testFeatureWeightReadOutsideTheUnitIntervalIsRefused) + t.Run("weight_read_from_the_performer_is_refused_out_of_range", testFeatureWeightReadFromThePerformerIsRefusedOutOfRange) +} + +// testFeatureWeightOfNoNumericTypeIsRefusedBeforeTheRun: a p bound to a String +// or Boolean feature is a type error of the model, reported where it is written. +func testFeatureWeightOfNoNumericTypeIsRefusedBeforeTheRun(t *testing.T) { + for _, tc := range []struct{ typ, value, want string }{ + {"String", `"often"`, "cannot bind String value to a feature typed by Real"}, + {"Boolean", "true", "cannot bind Boolean value to a feature typed by Real"}, + } { + src := `package test { + private import ScalarValues::*; + private import Stochastic::*; + action route { + attribute w : ` + tc.typ + ` = ` + tc.value + `; + first start; then decide select; + first select then fast { @Probability { p = w; } } + first select then slow { @Probability { p = 0.5; } } + action fast; then done; + action slow; then done; + } + }` + file := parseAndBuild(t, src) + idx := libs.NewModelIndex() + idx.AddDocument("", file) + idx.ExpandWildcardImports() + var refusals []string + for _, d := range passes.Analyze("", file, nil, idx) { + if d.Severity == diag.SeverityError { + refusals = append(refusals, d.Message) + } + } + if len(refusals) != 1 || !strings.Contains(refusals[0], tc.want) { + t.Errorf("%s weight: errors %v, want one saying %q", tc.typ, refusals, tc.want) + } + } +} + +// testFeatureWeightsReadMustSumToOne: the weights read out of one decision are +// its whole distribution, a guard excluding a branch or not: 0.6 and 0.6 are +// refused for their sum, as constants are by lowering, whereas 0.6, 0.3 and 0.1 +// with the 0.3 branch guarded out leave the other two renormalized. +func testFeatureWeightsReadMustSumToOne(t *testing.T) { + err := weightedActionError(t, ` + attribute w : Real = 0.6; + first start; then decide select; + first select then fast { @Probability { p = w; } } + first select then slow { @Probability { p = w; } } + action fast; then done; + action slow; then done;`, 3) + if !errors.Is(err, ErrBranchWeights) || !strings.Contains(err.Error(), "sum to 1.2, not 1.0") { + t.Fatalf("error = %v, want ErrBranchWeights naming the sum 1.2", err) + } + err = weightedActionError(t, ` + attribute w : Real = 0.6; + attribute ready : Boolean = false; + first start; then decide select; + first select then fast { @Probability { p = w; } } + first select if ready then slow { @Probability { p = w / 2.0; } } + first select then steady { @Probability { p = 1.0 - w - w / 2.0; } } + action fast; then done; + action slow; then done; + action steady; then done;`, 3) + if err != nil { + t.Fatalf("a guard excluding a branch of a distribution summing to 1: %v, want the rest renormalized", err) + } + err = weightedActionError(t, ` + attribute w : Real = 0.6; + attribute ready : Boolean = false; + first start; then decide select; + first select then fast { @Probability { p = w; } } + first select if ready then slow { @Probability { p = w; } } + first select then steady { @Probability { p = 1.0 - w; } } + action fast; then done; + action slow; then done; + action steady; then done;`, 3) + if !errors.Is(err, ErrBranchWeights) || !strings.Contains(err.Error(), "sum to 1.6, not 1.0") { + t.Fatalf("a guarded-out branch still counts in the sum: error = %v, want ErrBranchWeights naming 1.6", err) + } +} + +// testFeatureWeightReadOutsideTheUnitIntervalIsRefused: a weight read as 1.3 or +// -0.3 is refused when the decision is reached, naming the branch and the value. +func testFeatureWeightReadOutsideTheUnitIntervalIsRefused(t *testing.T) { + for _, tc := range []struct{ w, want string }{ + {"1.3", "branch 0 weighs 1.3"}, + {"-0.3", "branch 0 weighs -0.3"}, + } { + err := weightedActionError(t, ` + attribute w : Real = `+tc.w+`; + first start; then decide select; + first select then fast { @Probability { p = w; } } + first select then slow { @Probability { p = 1.0 - w; } } + action fast; then done; + action slow; then done;`, 3) + if !errors.Is(err, ErrBranchWeights) || !strings.Contains(err.Error(), tc.want) { + t.Errorf("w = %s: error = %v, want ErrBranchWeights saying %q", tc.w, err, tc.want) + } + } +} + +// testFeatureWeightReadFromThePerformerIsRefusedOutOfRange: an action def +// nested in a part def weighs its branches by the part's feature as the performer +// redefines it: a performer weighing 1.0 or 0.0 decides the branch, and one whose +// redefinition puts the weight outside [0, 1] is refused when the decision is reached. +func testFeatureWeightReadFromThePerformerIsRefusedOutOfRange(t *testing.T) { + src := `package test { + private import ScalarValues::*; + private import Stochastic::*; + part def Analysis { + attribute pFast : Real default = 0.5; + action def Route { + attribute taken : Integer = 0; + first start; then decide select; + first select then fast { @Probability { p = pFast; } } + first select then slow { @Probability { p = 1.0 - pFast; } } + action fast { assign taken := 1; } then done; + action slow { assign taken := 2; } then done; + } + } + part def Fast :> Analysis { attribute :>> pFast = 1.0; } + part def Slow :> Analysis { attribute :>> pFast = 0.0; } + part def Over :> Analysis { attribute :>> pFast = 1.5; } + }` + m := parseLibraryModel(t, src) + root := m.idx.DocumentRoot(m.path) + route := namedOrFoundSymbol(t, m.idx, "test::Analysis::Route", root, ast.DefAction, ast.UsageAction) + for _, tc := range []struct { + performer string + taken int64 + }{{"Fast", 1}, {"Slow", 2}} { + ctx, _ := m.fresh() + ctx.SetModelSeed(3) + performer, err := ctx.Instantiate(findSymbolByName(root, tc.performer, ast.DefPart)) + if err != nil { + t.Fatal(err) + } + out, err := ctx.ExecuteActionPerformedBy(route, performer, nil) + if err != nil { + t.Fatalf("on %s: %v", tc.performer, err) + } + if got := takenInt(t, out, "taken"); got != tc.taken { + t.Errorf("taken = %d on %s, want %d, the branch its pFast weighs", got, tc.performer, tc.taken) + } + } + ctx, _ := m.fresh() + ctx.SetModelSeed(3) + over, err := ctx.Instantiate(findSymbolByName(root, "Over", ast.DefPart)) + if err != nil { + t.Fatal(err) + } + _, err = ctx.ExecuteActionPerformedBy(route, over, nil) + if !errors.Is(err, ErrBranchWeights) || !strings.Contains(err.Error(), "branch 0 weighs 1.5") { + t.Fatalf("on Over: error = %v, want ErrBranchWeights naming the weight 1.5", err) + } +} diff --git a/internal/exec/runtime/robustness_guard_payload_test.go b/internal/exec/runtime/robustness_guard_payload_test.go new file mode 100644 index 0000000000..9d8d1cd547 --- /dev/null +++ b/internal/exec/runtime/robustness_guard_payload_test.go @@ -0,0 +1,91 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + "time" +) + +// TestRuntimeRobustnessGuardPayload exercises the failure modes of a guard that +// reads a payload through its transition's name: a payload of the wrong type, a +// member the transition binds nothing under, a transition whose trigger binds +// no payload at all, and a read that leaves the guard non-Boolean. Each is a typed +// error; none panics or hangs. +func TestRuntimeRobustnessGuardPayload(t *testing.T) { + t.Run("payload_of_the_wrong_type", testGuardPayloadWrongType) + t.Run("unknown_payload_name_on_the_transition", testGuardPayloadUnknownName) + t.Run("transition_without_a_trigger", testGuardPayloadTransitionWithoutTrigger) + t.Run("guard_non_boolean_after_the_read", testGuardPayloadNonBoolean) +} + +// guardPayloadMachine is a machine whose transition `raise` out of idle carries +// the given trigger and guard, driven by one Level signal. +func guardPayloadMachine(trigger, guard string) string { + return `package test { + private import ScalarValues::*; + attribute def Level :> Integer; + state Machine { + entry; then idle; + state idle; + state high; + transition raise first idle ` + trigger + ` if ` + guard + ` then high; + } + }` +} + +// guardPayloadError sends one Level carrying value to the machine and returns +// the error its run ends with, failing the test if the run hangs or succeeds. +func guardPayloadError(t *testing.T, model string, value Value) error { + t.Helper() + exec := stateExecutorForSource(t, "Machine", model) + exec.enqueueSignal(Message{SignalType: "Level", Value: &value}) + done := make(chan error, 1) + go func() { done <- exec.RunToCompletion() }() + select { + case err := <-done: + if err == nil { + t.Fatal("expected the guard's payload read to fail the run") + } + return err + case <-watchdog(10 * time.Second): + t.Fatal("RunToCompletion hung on a guard reading its transition's payload") + } + return nil +} + +// testGuardPayloadWrongType: the payload arrives as a string, so comparing +// `raise.l` with an Integer is the operator's type error, not a silent false. +func testGuardPayloadWrongType(t *testing.T) { + err := guardPayloadError(t, guardPayloadMachine("accept l : Level", "raise.l > 5"), NewStringValue("eight")) + if !errors.Is(err, ErrTypeMismatch) || !strings.Contains(err.Error(), "eval guard of transition raise: type mismatch: operator '>' is not defined for a string and an Integer") { + t.Fatalf("error = %v, want ErrTypeMismatch naming the guard's operands", err) + } +} + +// testGuardPayloadUnknownName: `raise.level` names nothing the transition's +// trigger binds, so the read is the unresolved-reference error naming the member. +func testGuardPayloadUnknownName(t *testing.T) { + err := guardPayloadError(t, guardPayloadMachine("accept l : Level", "raise.level > 5"), intConst(8)) + if !errors.Is(err, ErrUnresolvedReference) || !strings.Contains(err.Error(), "eval guard of transition raise: unresolved reference: raise has no member level") { + t.Fatalf("error = %v, want ErrUnresolvedReference naming the member", err) + } +} + +// testGuardPayloadTransitionWithoutTrigger: a bare `accept Level` binds no +// payload name, so `raise.l` names no member of the transition. +func testGuardPayloadTransitionWithoutTrigger(t *testing.T) { + err := guardPayloadError(t, guardPayloadMachine("accept Level", "raise.l > 5"), intConst(8)) + if !errors.Is(err, ErrUnresolvedReference) || !strings.Contains(err.Error(), "eval guard of transition raise: unresolved reference: raise has no member l") { + t.Fatalf("error = %v, want ErrUnresolvedReference over a transition binding no payload", err) + } +} + +// testGuardPayloadNonBoolean: the read succeeds, but an Integer payload is no +// guard verdict; the guard's type error names what it got. +func testGuardPayloadNonBoolean(t *testing.T) { + err := guardPayloadError(t, guardPayloadMachine("accept l : Level", "raise.l"), intConst(8)) + if !errors.Is(err, ErrTypeMismatch) || !strings.Contains(err.Error(), "guard of transition raise must be boolean, got an Integer") { + t.Fatalf("error = %v, want ErrTypeMismatch over a non-Boolean guard", err) + } +} diff --git a/internal/exec/runtime/robustness_integer_quotient_test.go b/internal/exec/runtime/robustness_integer_quotient_test.go new file mode 100644 index 0000000000..40f7a1effd --- /dev/null +++ b/internal/exec/runtime/robustness_integer_quotient_test.go @@ -0,0 +1,80 @@ +package runtime + +import ( + "errors" + "math" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestRuntimeRobustnessIntegerQuotient covers the failure modes of +// OpenSysMLMathFunctions::quotient: zero divisor, overflow and a non-Integer operand. +func TestRuntimeRobustnessIntegerQuotient(t *testing.T) { + t.Run("quotient_by_zero", testIntegerQuotientByZero) + t.Run("quotient_of_the_least_integer_by_minus_one", testIntegerQuotientOfLeastIntegerByMinusOne) + t.Run("quotient_of_a_real", testIntegerQuotientOfAReal) +} + +// quotientCalc builds a calc that applies quotient to parameters of the given types. +func quotientCalc(t *testing.T, xType, yType string) (*Context, func(x, y semantics.Value) (Value, error)) { + src := ` + package test { + private import ScalarValues::*; + private import OpenSysMLMathFunctions::*; + calc divide { + in x : ` + xType + `; + in y : ` + yType + `; + return : Integer = quotient(x, y); + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "divide", ast.DefCalc) + if sym == nil { + t.Fatal("divide calc not found") + } + return ctx, func(x, y semantics.Value) (Value, error) { + return ctx.InvokeCalc(sym, []Value{{Kind: ValConst, Const: x}, {Kind: ValConst, Const: y}}, rootScope) + } +} + +// A zero divisor is ErrDivisionByZero, as for `/`. +func testIntegerQuotientByZero(t *testing.T) { + _, divide := quotientCalc(t, "Integer", "Integer") + got, err := divide(semantics.Value{Kind: semantics.ValInt, Int: 7}, semantics.Value{Kind: semantics.ValInt, Int: 0}) + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("quotient(7, 0) = %+v, %v; want ErrDivisionByZero", got, err) + } +} + +// MinInt64 / -1 is 2^63, outside the Integer range: an overflow, never a wrap. +func testIntegerQuotientOfLeastIntegerByMinusOne(t *testing.T) { + _, divide := quotientCalc(t, "Integer", "Integer") + got, err := divide(semantics.Value{Kind: semantics.ValInt, Int: math.MinInt64}, semantics.Value{Kind: semantics.ValInt, Int: -1}) + if !errors.Is(err, semantics.ErrArithmeticOverflow) || !strings.Contains(err.Error(), "exceeds the Integer range") { + t.Fatalf("quotient(MinInt64, -1) = %+v, %v; want an overflow error", got, err) + } + for _, tc := range []struct{ x, y, want int64 }{ + {math.MinInt64, 1, math.MinInt64}, + {math.MinInt64 + 1, -1, math.MaxInt64}, + {math.MaxInt64, -1, -math.MaxInt64}, + } { + got, err := divide(semantics.Value{Kind: semantics.ValInt, Int: tc.x}, semantics.Value{Kind: semantics.ValInt, Int: tc.y}) + if err != nil || got.Const.Kind != semantics.ValInt || got.Const.Int != tc.want { + t.Fatalf("quotient(%d, %d) = %+v, %v; want %d", tc.x, tc.y, got, err, tc.want) + } + } +} + +// A Real operand is a type mismatch, not a truncated quotient. +func testIntegerQuotientOfAReal(t *testing.T) { + _, divide := quotientCalc(t, "Real", "Integer") + got, err := divide(semantics.Value{Kind: semantics.ValReal, Real: 7.5}, semantics.Value{Kind: semantics.ValInt, Int: 2}) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("quotient(7.5, 2) = %+v, %v; want ErrTypeMismatch", got, err) + } +} diff --git a/internal/exec/runtime/robustness_junction_exit_route_test.go b/internal/exec/runtime/robustness_junction_exit_route_test.go new file mode 100644 index 0000000000..a1ddc1f8f7 --- /dev/null +++ b/internal/exec/runtime/robustness_junction_exit_route_test.go @@ -0,0 +1,118 @@ +package runtime + +import ( + "strings" + "testing" + "time" +) + +// TestRuntimeRobustnessJunctionExitRoute: a compound transition that leaves a +// composite state through a junction of its own fails with a typed error, never +// a hang or a panic, when the route past the junction cannot be taken. +func TestRuntimeRobustnessJunctionExitRoute(t *testing.T) { + t.Run("junction_of_the_left_state_with_no_outgoing_transition", testJunctionOfTheLeftStateWithNoOutgoingTransition) + t.Run("junction_of_the_left_state_leading_into_history", testJunctionOfTheLeftStateLeadingIntoHistory) + t.Run("junction_of_the_left_state_whose_every_branch_is_closed", testJunctionOfTheLeftStateWhoseEveryBranchIsClosed) +} + +// runRouteToError drives one Go through the machine and returns the error the +// run ends with, failing the test if the run hangs or succeeds. +func runRouteToError(t *testing.T, exec *StateExecutor) error { + t.Helper() + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + exec.SendSignal("Go", nil) + done := make(chan error, 1) + go func() { done <- exec.RunToCompletion() }() + select { + case err := <-done: + if err == nil { + t.Fatal("expected the run to fail at the junction the composite state is left through") + } + return err + case <-watchdog(10 * time.Second): + t.Fatal("RunToCompletion hung on a junction the composite state is left through") + } + return nil +} + +// testJunctionOfTheLeftStateWithNoOutgoingTransition: the nested source's exit +// leads to a junction of its owner that no transition leaves; the error names it. +func testJunctionOfTheLeftStateWithNoOutgoingTransition(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + attribute def Go; + state Machine { + entry; then S1; + state S1 { + entry; then A; + state A; + junction XP; + transition first A accept Go then XP; + } + state S2; + } + }`) + err := runRouteToError(t, exec) + if !strings.Contains(err.Error(), "junction XP has no outgoing transitions") { + t.Errorf("expected the error to name the junction, got %v", err) + } +} + +// testJunctionOfTheLeftStateLeadingIntoHistory: a junction the owner is left +// through cannot lead on into a sibling's history; the refusal names both. +func testJunctionOfTheLeftStateLeadingIntoHistory(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + attribute def Go; + state Machine { + entry; then S1; + state S1 { + entry; then A; + state A; + junction XP; + transition first A accept Go then XP; + } + state S2 { + entry; then B; + state B; + history H; + } + transition first S1::XP then S2::H; + } + }`) + err := runRouteToError(t, exec) + if !strings.Contains(err.Error(), "junction XP: a transition into shallow history H is not supported") { + t.Errorf("expected the refusal to name the junction and the history, got %v", err) + } +} + +// testJunctionOfTheLeftStateWhoseEveryBranchIsClosed: a junction's guards are +// read before the compound transition fires; when none holds the run fails at +// the junction before any exit or effect runs. +func testJunctionOfTheLeftStateWhoseEveryBranchIsClosed(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + attribute def Go; + state Machine { + attribute log : String = ""; + attribute x : Integer = 0; + entry; then S1; + state S1 { + exit action { assign log := log + "S1(exit);"; } + entry; then A; + state A { exit action { assign log := log + "A(exit);"; } } + junction XP; + transition first A accept Go do assign x := 2 then XP; + } + state S2; + transition first S1::XP if x == 1 then S2; + } + }`) + err := runRouteToError(t, exec) + if !strings.Contains(err.Error(), "junction XP: no guard evaluated to true") { + t.Errorf("expected the error to name the junction, got %v", err) + } + if log := FormatValue(exec.StateData()["log"]); log != `""` { + t.Errorf("log is %s, want empty: nothing is left when no branch out of the junction holds", log) + } +} diff --git a/internal/exec/runtime/robustness_nested_node_body_test.go b/internal/exec/runtime/robustness_nested_node_body_test.go new file mode 100644 index 0000000000..e9669efc93 --- /dev/null +++ b/internal/exec/runtime/robustness_nested_node_body_test.go @@ -0,0 +1,236 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +func TestRuntimeRobustnessNestedNodeInBody(t *testing.T) { + t.Run("state_body_flow_unsequenced_statement", func(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute n : Integer = 0; + entry; then active; + state active { + do action work { + for i in 1..1 { + action a { assign n := n + 1; } + action b { assign n := n + 10; } + assign n := n + 100; + first a then b; + succession a then b; + } + } + } + } + }`) + err := exec.RunToCompletion() + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("error = %v, want ErrInvalidActionFlow", err) + } + if !strings.Contains(err.Error(), "assignment written directly in an action body") { + t.Fatalf("error = %v, want the invalid stated-body flow diagnostic", err) + } + if n := exec.stateData["n"]; n.Kind != ValConst || n.Const.Int != 0 { + t.Fatalf("n = %v, want no action side effects", n) + } + }) + t.Run("state_body_flow_valid_nested_actions", func(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute n : Integer = 0; + entry; then active; + state active { + do action work { + for i in 1..1 { + action a { assign n := n + 1; } + action b { assign n := n + 10; } + first a then b; + } + } + } + } + }`) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("error = %v, want successful state body flow", err) + } + if n := exec.stateData["n"]; n.Kind != ValConst || n.Const.Int != 11 { + t.Fatalf("n = %v, want 11", n) + } + }) + t.Run("state_body_flow_result_parameter", func(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute n : Integer = 0; + entry; then active; + state active { + do action work { + for i in 1..1 { + action a { assign n := n + 1; } + action b { return r : Integer = 1; } + first a then b; + } + } + } + } + }`) + err := exec.RunToCompletion() + if !errors.Is(err, ErrActionResultParameter) { + t.Fatalf("error = %v, want ErrActionResultParameter", err) + } + if n := exec.stateData["n"]; n.Kind != ValConst || n.Const.Int != 0 { + t.Fatalf("n = %v, want no action side effects", n) + } + }) + t.Run("body_flow_with_no_start", func(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + action host { + first start; + then action iterate { + for i in 1..1 { + action a; + action b; + succession a then b; + succession b then a; + } + } + } + }`) + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("error = %v, want ErrInvalidActionFlow", err) + } + }) + t.Run("body_flow_two_starts", func(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + action host { + first start; + then action iterate { + for i in 1..1 { + action a; + action b; + first a; + first b; + } + } + } + }`) + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("error = %v, want ErrInvalidActionFlow", err) + } + }) + t.Run("body_flow_unsequenced_statement", func(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + private import ScalarValues::*; + action host { + attribute x : Integer = 0; + first start; + then action iterate { + for i in 1..1 { + action a; + action b; + assign x := 1; + succession a then b; + } + } + } + }`) + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("error = %v, want ErrInvalidActionFlow", err) + } + if errors.Is(err, ErrStatementNotExecutable) { + t.Fatalf("error = %v, want initialize-time invalid flow", err) + } + }) + t.Run("body_flow_pin_read_before_performed", func(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + private import ScalarValues::*; + action host { + attribute result : Integer = 0; + first start; + then action iterate { + for i in 1..1 { + action a { out v : Integer; assign v := 1; } + action b { assign result := a.v; } + first b; + succession b then a; + } + } + } + }`) + if !errors.Is(err, ErrNodeNotPerformed) { + t.Fatalf("error = %v, want ErrNodeNotPerformed", err) + } + }) + t.Run("body_flow_that_never_ends", func(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + private import ScalarValues::*; + action host { + attribute n : Integer = 0; + first start; + then action iterate { + for i in 1..1 { + action a { assign n := n + 1; } + first a; + succession a then a; + } + } + } + }`) + if !errors.Is(err, ErrStepLimitExceeded) && !errors.Is(err, ErrActionDeadlock) { + t.Fatalf("error = %v, want a step-limit or deadlock error", err) + } + }) + t.Run("body_flow_accept_deadlocks", func(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + attribute def Never; + action host { + first start; + then action iterate { + for i in 1..1 { + action a accept Never; + first a; + } + } + } + }`) + if !errors.Is(err, ErrActionDeadlock) && !errors.Is(err, ErrAcceptDeadlock) { + t.Fatalf("error = %v, want a typed deadlock error", err) + } + }) + t.Run("accept_in_declaration_order_body_not_executable", func(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + attribute def Never; + action host { + first start; + then action iterate { + for i in 1..1 { + accept Never; + } + } + } + }`) + if !errors.Is(err, ErrStatementNotExecutable) { + t.Fatalf("error = %v, want ErrStatementNotExecutable", err) + } + }) + t.Run("calc_body_flow_not_executable", func(t *testing.T) { + err := invokeCalcInSource(t, `package test { + private import ScalarValues::*; + calc host { + in n : Integer; + while n > 0 { + action a; + first a; + } + return : Integer = n; + } + }`, "host", 1, 100) + if !errors.Is(err, ErrStatementNotExecutable) { + t.Fatalf("error = %v, want ErrStatementNotExecutable", err) + } + }) +} diff --git a/internal/exec/runtime/robustness_object_lifecycle_test.go b/internal/exec/runtime/robustness_object_lifecycle_test.go new file mode 100644 index 0000000000..8d3ec3fa10 --- /dev/null +++ b/internal/exec/runtime/robustness_object_lifecycle_test.go @@ -0,0 +1,1315 @@ +package runtime + +import ( + "context" + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessObjectLifecycle exercises objects created and destroyed while a +// behavior runs: constructor expressions yield distinct live occurrences, in a loop too, +// held and classified by the feature written; destruction ends the object wherever it is +// referred to, releases it from the extent, ends the machine it exhibits, refuses a second +// destruction and later reads with typed errors, and stays deterministic under exploration. +func TestRuntimeRobustnessObjectLifecycle(t *testing.T) { + t.Run("two_objects_of_one_usage_are_distinct_and_held", testObjectLifecycleTwoOfOneUsage) + t.Run("creation_in_a_loop", testObjectLifecycleCreationInLoop) + t.Run("destroy_through_a_reference_ends_the_held_object", testObjectLifecycleDestroyThroughReference) + t.Run("destroy_ends_the_created_objects_machine", testObjectLifecycleDestroyEndsMachine) + t.Run("destroy_twice_is_refused", testObjectLifecycleDestroyTwice) + t.Run("destroy_of_the_whole_ends_the_created_parts", testObjectLifecycleDestroyWholeEndsCreatedParts) + t.Run("a_part_moved_between_wholes_ends_with_the_new_whole", testObjectLifecycleMovedPart) + t.Run("send_new_starts_the_message_objects_behaviors", testObjectLifecycleSendNew) + t.Run("a_failed_constructor_leaves_no_argument_object", testObjectLifecycleFailedConstructor) + t.Run("destroying_a_holder_leaves_what_it_referred_to_in_the_extent", testObjectLifecycleDestroyedHolderExtent) + t.Run("a_destroyed_object_is_no_subject_of_a_check", testObjectLifecycleDestroyedNotSubject) + t.Run("a_destroyed_part_a_live_whole_retains_is_no_subject_of_a_check", testObjectLifecycleDestroyedNestedNotSubject) + t.Run("a_failed_constructor_rolls_back_what_its_behaviors_wrote", testObjectLifecycleFailedConstructorWrites) + t.Run("a_refused_write_leaves_no_adoption_for_an_outer_rollback", testObjectLifecycleRefusedWriteJournal) + t.Run("a_rolled_back_store_or_constructor_leaves_no_trace_of_what_it_undid", testObjectLifecycleRolledBackTrace) + t.Run("a_failed_constructor_revives_what_its_behaviors_destroyed_whole", testObjectLifecycleFailedConstructorDestroy) + t.Run("a_part_declared_or_bound_to_a_new_object_ends_with_the_whole", testObjectLifecycleDeclaredPart) + t.Run("an_object_two_wholes_hold_composite_ends_with_either", testObjectLifecycleSharedPortion) + t.Run("an_object_its_home_drops_is_rehomed_to_the_whole_still_holding_it", testObjectLifecycleSharedPortionRehomed) + t.Run("a_composite_write_making_the_holder_its_own_portion_is_refused", testObjectLifecycleCompositeCycle) + t.Run("a_composite_write_giving_an_ended_whole_a_live_portion_is_refused", testObjectLifecycleEndedWholeAdoptsNothing) + t.Run("destroying_an_object_forgets_the_messages_addressed_to_it", testObjectLifecycleForgetsMessages) + t.Run("derivations_that_read_lifetimes_derive_again_when_they_change", testObjectLifecycleDerivationsFollowLives) + t.Run("an_imaged_derivation_that_read_lifetimes_follows_the_lives_where_materialized", testObjectLifecycleImagedDerivationsFollowLives) + t.Run("behaviors_a_write_starts_run_once_the_feature_holds_the_object", testObjectLifecycleStartsAfterStore) + t.Run("behaviors_a_constructor_argument_starts_run_once_every_argument_is_stored", testObjectLifecycleConstructorStartsAfterStores) + t.Run("behaviors_a_bound_default_starts_run_once_the_feature_holds_the_object", testObjectLifecycleBoundDefaultStartsAfterStore) + t.Run("explore_creating_objects_is_deterministic", testObjectLifecycleExploreCreation) + t.Run("explore_destroy_race_reaches_both_outcomes", testObjectLifecycleExploreDestroyRace) +} + +const objectLifecycleModel = ` + package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + private import SequenceFunctions::*; + part def Car { + attribute n : Integer; + exhibit state running { entry; then idle; state idle; } + } + part def Fleet { + part cars : Car[0..*]; + ref part spare : Car[0..1]; + attribute made : Integer; + perform action build { + first start; + then action make { + assign cars := addNew(cars, new Car(1)); + assign cars := addNew(cars, new Car(2)); + assign spare := cars#(2); + } + then done; + } + } + part def Lot { + part cars : Car[0..*]; + attribute made : Integer; + perform action fill { + attribute i : Integer = 0; + first start; + then action loop { + while i < 5 { + assign i := i + 1; + assign cars := addNew(cars, new Car(i)); + } + } + then action count { assign made := size(all Car); } + then done; + } + } + calc def Destroy { in c : Car[0..1]; return : Car[0..1] = destroy(c); } + calc def Extent { in f : Fleet; return : Integer = size(all Car); } + calc def SpareN { in f : Fleet; return : Integer = f.spare.n; } + }` + +// heldCars reads the cars a fleet or lot holds, failing the test when they cannot be read. +func heldCars(t *testing.T, ctx *Context, inst *Instance) []*Instance { + t.Helper() + return heldNamed(t, ctx, inst, "cars") +} + +// heldNamed reads the objects inst's feature holds, failing the test when they cannot be read. +func heldNamed(t *testing.T, ctx *Context, inst *Instance, name string) []*Instance { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + var out []*Instance + for _, el := range elementsOf(fv.HeldValue()) { + id, ok := el.Object() + if !ok { + t.Fatalf("%s holds %v, want objects", name, fv.HeldValue()) + } + out = append(out, ctx.instances[id]) + } + return out +} + +// testObjectLifecycleTwoOfOneUsage: the two constructed cars are distinct live objects, +// each performing its machine, classified by and owned through the feature written. +func testObjectLifecycleTwoOfOneUsage(t *testing.T) { + ctx, fleet, err := instantiateWithLibraries(t, objectLifecycleModel, "test::Fleet") + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + cars := heldCars(t, ctx, fleet) + if len(cars) != 2 || cars[0].ID == cars[1].ID { + t.Fatalf("cars = %v, want two distinct objects", cars) + } + carsFeature := fleet.FeatureValues["cars"].Feature.heldBy() + for _, car := range cars { + if l, ok := ctx.OccurrenceLife(car.ID); !ok || !l.Alive() { + t.Errorf("OccurrenceLife(#%d) = %v, %v; want alive", car.ID, l, ok) + } + if b, ok := car.Behavior("running"); !ok || b.State == nil || b.State.State().Ended() { + t.Errorf("#%d running = %v, %v; want the machine under way", car.ID, b, ok) + } + if car.owner != fleet || car.ownerFeature != "cars" { + t.Errorf("#%d owned by %v.%s; want the fleet's cars", car.ID, car.owner, car.ownerFeature) + } + if !ctx.isDirectTypeOf(car, carsFeature) { + t.Errorf("#%d classifiers = %v; want the cars feature among them", car.ID, car.classifiers) + } + } + held, err := ctx.HeldObjects(fleet) + if err != nil { + t.Fatalf("HeldObjects: %v", err) + } + var segments []string + for _, h := range held { + if h.Feature == "cars" || h.Feature == "spare" { + segments = append(segments, h.Segment) + } + } + if strings.Join(segments, " ") != "cars[1] spare" { + t.Errorf("held segments = %v; want the two cars, the second once under the scalar spare", segments) + } +} + +// testObjectLifecycleCreationInLoop: a `new` evaluated once per iteration yields one object +// per iteration, each in the extent of its definition. +func testObjectLifecycleCreationInLoop(t *testing.T) { + ctx, lot, err := instantiateWithLibraries(t, objectLifecycleModel, "test::Lot") + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + cars := heldCars(t, ctx, lot) + seen := make(map[int64]bool) + for i, car := range cars { + seen[car.ID] = true + n, err := car.GetFeatureValue(ctx, "n") + if err != nil || FormatValue(n.Value) != []string{"1", "2", "3", "4", "5"}[i] { + t.Errorf("cars[%d].n = %v, %v", i+1, n, err) + } + } + if len(cars) != 5 || len(seen) != 5 { + t.Fatalf("cars = %v, want five distinct objects", cars) + } + made, err := lot.GetFeatureValue(ctx, "made") + if err != nil || FormatValue(made.Value) != "5" { + t.Errorf("made = %v, %v; want all Car to count the five", made, err) + } +} + +// testObjectLifecycleDestroyThroughReference: destroying the car through `spare` ends the +// object `cars` still holds, drops it from `all Car`, keeps the stale references in place, +// and refuses a later feature read through either as a read of a destroyed occurrence. +func testObjectLifecycleDestroyThroughReference(t *testing.T) { + instantiate, invoke, ctx := lifetimeFixture(t, objectLifecycleModel) + fleet := instantiate("Fleet") + cars := heldCars(t, ctx, fleet) + spare, err := fleet.GetFeatureValue(ctx, "spare") + if err != nil { + t.Fatalf("spare: %v", err) + } + if id, _ := spare.Value.Object(); id != cars[1].ID { + t.Fatalf("spare = %v, want the second car #%d", spare.Value, cars[1].ID) + } + if got, err := invoke("Extent", objectValue(fleet)); err != nil || FormatValue(got) != "2" { + t.Fatalf("all Car = %v, %v; want the two cars", got, err) + } + if _, err := invoke("Destroy", spare.Value); err != nil { + t.Fatalf("destroy(spare) = %v", err) + } + if l, _ := ctx.OccurrenceLife(cars[1].ID); !l.Destroyed || l.Alive() { + t.Errorf("OccurrenceLife(spare) = %v; want destroyed", l) + } + if l, _ := ctx.OccurrenceLife(cars[0].ID); !l.Alive() { + t.Errorf("OccurrenceLife(first) = %v; want alive", l) + } + if got, err := invoke("Extent", objectValue(fleet)); err != nil || FormatValue(got) != "1" { + t.Errorf("all Car = %v, %v after destroy; want the first car alone", got, err) + } + if after := heldCars(t, ctx, fleet); len(after) != 2 || after[1] != cars[1] { + t.Errorf("cars = %v after destroy; want the stale reference kept", after) + } + _, err = invoke("SpareN", objectValue(fleet)) + if !errors.Is(err, ErrOccurrenceDestroyed) { + t.Errorf("spare.n = %v; want %v", err, ErrOccurrenceDestroyed) + } + if _, err := cars[1].GetFeatureValue(ctx, "n"); !errors.Is(err, ErrOccurrenceDestroyed) { + t.Errorf("cars[2].n = %v; want %v", err, ErrOccurrenceDestroyed) + } + if _, err := ctx.HeldObjects(cars[1]); !errors.Is(err, ErrOccurrenceDestroyed) { + t.Errorf("HeldObjects(destroyed) = %v; want %v", err, ErrOccurrenceDestroyed) + } +} + +// testObjectLifecycleDestroyEndsMachine: the machine a constructed car exhibits ends, +// terminated, with the car. +func testObjectLifecycleDestroyEndsMachine(t *testing.T) { + instantiate, invoke, ctx := lifetimeFixture(t, objectLifecycleModel) + fleet := instantiate("Fleet") + car := heldCars(t, ctx, fleet)[1] + running, ok := car.Behavior("running") + if !ok || running.State == nil || running.State.State().Ended() { + t.Fatalf("running = %v, %v; want the machine under way", running, ok) + } + if _, err := invoke("Destroy", objectValue(car)); err != nil { + t.Fatalf("destroy(car) = %v", err) + } + if running.State.State() != StateTerminated { + t.Errorf("running = %v after destroy; want terminated", running.State.State()) + } + if l, ok := ctx.OccurrenceLife(running.State.occurrence.ID); !ok || l.Alive() { + t.Errorf("OccurrenceLife(running) = %v, %v; want ended", l, ok) + } + if err := ctx.drainObjectBehaviors(); err != nil { + t.Errorf("drain after destroy = %v; want the ended machine left alone", err) + } +} + +// testObjectLifecycleDestroyTwice: a second destroy is refused, naming when the first ended it. +func testObjectLifecycleDestroyTwice(t *testing.T) { + instantiate, invoke, ctx := lifetimeFixture(t, objectLifecycleModel) + fleet := instantiate("Fleet") + car := heldCars(t, ctx, fleet)[0] + if _, err := invoke("Destroy", objectValue(car)); err != nil { + t.Fatalf("destroy(car) = %v", err) + } + _, err := invoke("Destroy", objectValue(car)) + if !errors.Is(err, ErrOccurrenceDestroyed) || !strings.Contains(err.Error(), "was destroyed at") { + t.Errorf("second destroy = %v; want %v naming the first", err, ErrOccurrenceDestroyed) + } +} + +// testObjectLifecycleDestroyWholeEndsCreatedParts: the cars a fleet adopted as its parts +// end with the fleet, each machine terminated, and none may end a second time. +func testObjectLifecycleDestroyWholeEndsCreatedParts(t *testing.T) { + instantiate, invoke, ctx := lifetimeFixture(t, objectLifecycleModel+` + package more { private import OccurrenceFunctions::*; private import test::*; + calc def DestroyFleet { in f : Fleet; return : Fleet = destroy(f); } + }`) + fleet := instantiate("Fleet") + cars := heldCars(t, ctx, fleet) + sym, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "more", "DestroyFleet") + if _, err := ctx.InvokeCalc(sym, []Value{objectValue(fleet)}, scope); err != nil { + t.Fatalf("destroy(fleet) = %v", err) + } + for _, car := range cars { + if l, _ := ctx.OccurrenceLife(car.ID); !l.Destroyed { + t.Errorf("OccurrenceLife(#%d) = %v; want destroyed with the fleet", car.ID, l) + } + if b, ok := car.Behavior("running"); !ok || b.State.State() != StateTerminated { + t.Errorf("#%d running = %v, %v; want terminated with the fleet", car.ID, b, ok) + } + if _, err := invoke("Destroy", objectValue(car)); !errors.Is(err, ErrOccurrenceDestroyed) { + t.Errorf("destroy(#%d) after the fleet = %v; want %v", car.ID, err, ErrOccurrenceDestroyed) + } + } +} + +// testObjectLifecycleMovedPart: a car dropped from one composite feature and written into +// another is owned by the second holder, so destroying the first spares it and the second ends it. +func testObjectLifecycleMovedPart(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, objectLifecycleModel+` + package more { private import OccurrenceFunctions::*; private import SequenceFunctions::*; private import test::*; + part def Garage { part cars : Car[0..*]; } + calc def DestroyFleet { in f : Fleet; return : Fleet = destroy(f); } + calc def DestroyGarage { in g : Garage; return : Garage = destroy(g); } + }`) + fleet := instantiate("Fleet") + idx := ctx.model.resolver.Index() + garageSyms := idx.LookupQualified("more::Garage") + if len(garageSyms) != 1 { + t.Fatalf("more::Garage: %d matching symbols, want 1", len(garageSyms)) + } + garage, err := ctx.Instantiate(garageSyms[0]) + if err != nil { + t.Fatalf("Instantiate(Garage): %v", err) + } + root := idx.DocumentRoot("") + destroyWith := func(name string, inst *Instance) { + sym, scope := calcByName(t, root, "more", name) + if _, err := ctx.InvokeCalc(sym, []Value{objectValue(inst)}, scope); err != nil { + t.Fatalf("%s = %v", name, err) + } + } + cars := heldCars(t, ctx, fleet) + moved := cars[0] + if err := fleet.SetFeatureValue(ctx, "cars", sequenceOf([]Value{objectValue(cars[1])})); err != nil { + t.Fatalf("fleet.cars := (second) = %v", err) + } + if moved.owner != nil { + t.Fatalf("#%d owned by %v.%s after being dropped; want no owner", moved.ID, moved.owner, moved.ownerFeature) + } + if err := garage.SetFeatureValue(ctx, "cars", sequenceOf([]Value{objectValue(moved)})); err != nil { + t.Fatalf("garage.cars := (moved) = %v", err) + } + if moved.owner != garage || moved.ownerFeature != "cars" { + t.Fatalf("#%d owned by %v.%s; want the garage's cars", moved.ID, moved.owner, moved.ownerFeature) + } + destroyWith("DestroyFleet", fleet) + if l, _ := ctx.OccurrenceLife(moved.ID); !l.Alive() { + t.Errorf("OccurrenceLife(moved) = %v after destroying the fleet; want alive", l) + } + if l, _ := ctx.OccurrenceLife(cars[1].ID); l.Alive() { + t.Errorf("OccurrenceLife(kept) = %v after destroying the fleet; want ended with it", l) + } + destroyWith("DestroyGarage", garage) + if l, _ := ctx.OccurrenceLife(moved.ID); l.Alive() { + t.Errorf("OccurrenceLife(moved) = %v after destroying the garage; want ended with it", l) + } +} + +// testObjectLifecycleSendNew: the object `send new Car(9)` constructs performs Car's machine +// like one any other expression constructs. +func testObjectLifecycleSendNew(t *testing.T) { + ctx, _, err := instantiateWithLibraries(t, objectLifecycleModel+` + package more { private import ScalarValues::*; private import test::*; + part def Sender { + perform action ship { first start; then send new Car(9); then done; } + } + }`, "more::Sender") + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + var sent *Instance + for _, inst := range ctx.instances { + if inst.Type != nil && inst.Type.Name == "Car" { + sent = inst + } + } + if sent == nil { + t.Fatal("no Car constructed by the send") + } + if l, ok := ctx.OccurrenceLife(sent.ID); !ok || !l.Alive() { + t.Errorf("OccurrenceLife(sent) = %v, %v; want alive", l, ok) + } + if b, ok := sent.Behavior("running"); !ok || b.State == nil || b.State.State().Ended() { + t.Errorf("sent running = %v, %v; want the machine under way", b, ok) + } +} + +// testObjectLifecycleFailedConstructor: a `new Pair(new Car(1), 1/0)` whose later argument +// fails constructs nothing, the car its earlier argument made included. +func testObjectLifecycleFailedConstructor(t *testing.T) { + instantiate, invoke, ctx := lifetimeFixture(t, objectLifecycleModel+` + package more { private import ScalarValues::*; private import test::*; + part def Pair { part c : Car[0..1]; attribute k : Integer; } + }`) + fleet := instantiate("Fleet") + before := len(ctx.created) + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Extent") + if _, err := evalIn(t, ctx, scope, "new more::Pair(new test::Car(1), 1/0)"); err == nil { + t.Fatal("new Pair(new Car(1), 1/0) succeeded; want the failing argument reported") + } + after := 0 + for _, id := range ctx.created { + if inst := ctx.instances[id]; inst != nil && inst.Type.Name == "Car" { + after++ + } + } + if after != 2 || len(ctx.created) != before { + t.Errorf("%d cars, %d objects after the failed constructor; want the fleet's two cars and %d objects", after, len(ctx.created), before) + } + if got, err := invoke("Extent", objectValue(fleet)); err != nil || FormatValue(got) != "2" { + t.Errorf("all Car = %v, %v after the failed constructor; want the fleet's two", got, err) + } +} + +// testObjectLifecycleDestroyedHolderExtent: destroying a fleet ends the cars it owns but not +// the car its `ref part spare` refers to, which `all Car` still reaches. +func testObjectLifecycleDestroyedHolderExtent(t *testing.T) { + instantiate, invoke, ctx := lifetimeFixture(t, objectLifecycleModel+` + package more { private import OccurrenceFunctions::*; private import test::*; + calc def DestroyFleet { in f : Fleet; return : Fleet = destroy(f); } + }`) + fleet := instantiate("Fleet") + cars := heldCars(t, ctx, fleet) + loose := cars[1] + if err := fleet.SetFeatureValue(ctx, "cars", sequenceOf([]Value{objectValue(cars[0])})); err != nil { + t.Fatalf("fleet.cars := (first) = %v", err) + } + if loose.owner != nil { + t.Fatalf("#%d owned by %v; want released", loose.ID, loose.owner) + } + sym, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "more", "DestroyFleet") + if _, err := ctx.InvokeCalc(sym, []Value{objectValue(fleet)}, scope); err != nil { + t.Fatalf("destroy(fleet) = %v", err) + } + if l, _ := ctx.OccurrenceLife(loose.ID); !l.Alive() { + t.Fatalf("OccurrenceLife(spare) = %v after destroying the fleet; want alive", l) + } + if got, err := invoke("Extent", objectValue(fleet)); err != nil || FormatValue(got) != "1" { + t.Errorf("all Car = %v, %v after destroying the fleet; want the spare alone", got, err) + } +} + +// testObjectLifecycleDestroyedNotSubject: of two standalone cars, the latest destroyed, the +// destroyed one is no carrier of Car's constraint, so the check is about the live one. +func testObjectLifecycleDestroyedNotSubject(t *testing.T) { + instantiate, invoke, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + part def Car { attribute n : Integer = 1; constraint small { n < 10 } } + calc def Destroy { in c : Car; return : Car[0..1] = destroy(c); } + }`) + live, doomed := instantiate("Car"), instantiate("Car") + small := memberPath(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Car", "small") + if _, err := invoke("Destroy", objectValue(doomed)); err != nil { + t.Fatalf("destroy = %v", err) + } + if l, _ := ctx.OccurrenceLife(live.ID); !l.Alive() { + t.Fatalf("the other car ended too") + } + satisfied, err := ctx.EvaluateConstraint(small, small.OwnerScope) + if err != nil || !satisfied { + t.Errorf("EvaluateConstraint after destroying one car = %t, %v; want the live car's verdict", satisfied, err) + } +} + +// testObjectLifecycleDestroyedNestedNotSubject: a live whole retains the destroyed part it +// held, but the destroyed part is no carrier of Part's constraint, so the check is about the live one. +func testObjectLifecycleDestroyedNestedNotSubject(t *testing.T) { + instantiate, invoke, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + part def Wheel { attribute n : Integer = 1; constraint small { n < 10 } } + part def Car { part front : Wheel; part rear : Wheel; } + part car : Car; + calc def Destroy { in w : Wheel; return : Wheel[0..1] = destroy(w); } + }`) + car := instantiate("car") + fv, err := car.GetFeatureValue(ctx, "rear") + if err != nil { + t.Fatalf("car.rear: %v", err) + } + rear := fv.HeldValue() + small := memberPath(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Wheel", "small") + if _, err := invoke("Destroy", rear); err != nil { + t.Fatalf("destroy = %v", err) + } + if _, ok := car.FeatureValues["rear"].HeldValue().Object(); !ok { + t.Fatalf("car.rear after destroy = %s; want the stale object kept", FormatValue(car.FeatureValues["rear"].HeldValue())) + } + satisfied, err := ctx.EvaluateConstraint(small, small.OwnerScope) + if err != nil || !satisfied { + t.Errorf("EvaluateConstraint after destroying the rear wheel = %t, %v; want the front wheel's verdict", satisfied, err) + } +} + +// testObjectLifecycleFailedConstructorWrites: the action a `new Worker(plant)` performs writes +// the plant's count before failing, and the failed construction leaves the count as it was. +func testObjectLifecycleFailedConstructorWrites(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; + part def Plant { attribute count : Integer = 0; } + part def Worker { + ref part p : Plant; + perform action go { + first start; + then action write { assign p.count := 1; } + then action fail { assign p.count := 1/0; } + then done; + } + } + part plant : Plant; + }`) + plant := instantiate("plant") + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Plant") + before := len(ctx.created) + if _, err := evalIn(t, ctx, scope, "new Worker(plant)"); err == nil { + t.Fatal("new Worker(plant) succeeded; want its failing action reported") + } + fv, err := plant.GetFeatureValue(ctx, "count") + if err != nil || FormatValue(fv.HeldValue()) != "0" { + t.Errorf("plant.count = %v, %v after the failed constructor; want 0, the write rolled back", fv, err) + } + if len(ctx.created) != before { + t.Errorf("%d objects after the failed constructor; want %d", len(ctx.created), before) + } +} + +// testObjectLifecycleFailedConstructorDestroy: a constructor whose behavior destroys a live object and +// then fails is rolled back whole: the object lives on and its machine stands where it stood. +func testObjectLifecycleFailedConstructorDestroy(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + part def Plant { + attribute count : Integer = 0; + exhibit state running { entry; then idle; state idle; } + } + part def Worker { + ref part p : Plant; + perform action go { + first start; + then action kill { assign p := destroy(p); } + then action fail { assign p.count := 1/0; } + then done; + } + } + part plant : Plant; + }`) + plant := instantiate("plant") + b, ok := plant.Behavior("running") + if !ok || b.State == nil || b.State.State().Ended() { + t.Fatalf("running = %v, %v; want the machine under way", b, ok) + } + was := b.State.State() + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Plant") + if _, err := evalIn(t, ctx, scope, "new Worker(plant)"); err == nil { + t.Fatal("new Worker(plant) succeeded; want its failing action reported") + } + if l, _ := ctx.OccurrenceLife(plant.ID); !l.Alive() { + t.Errorf("OccurrenceLife(plant) = %v after the failed constructor; want alive again", l) + } + if got := b.State.State(); got != was { + t.Errorf("running = %v after the failed constructor; want %v, as before", got, was) + } +} + +// testObjectLifecycleSharedPortion: an object two wholes hold in composite features (as a binding +// makes them) is a portion of both, ended with either, whichever is its home. +func testObjectLifecycleSharedPortion(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import OccurrenceFunctions::*; + part def Car; + part def Garage { part slot : Car[0..1]; } + part a : Garage; + part b : Garage; + calc def Scrap { in g : Garage; return : Garage = destroy(g); } + }`) + a, b := instantiate("a"), instantiate("b") + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Garage") + car, err := evalIn(t, ctx, scope, "new Car()") + if err != nil { + t.Fatalf("new Car(): %v", err) + } + for _, g := range []*Instance{a, b} { + if err := g.SetFeatureValue(ctx, "slot", car); err != nil { + t.Fatalf("#%d.slot := car: %v", g.ID, err) + } + } + id, _ := car.Object() + if owner := ctx.instances[id].owner; owner != a { + t.Fatalf("car's home is %v; want a, the first to hold it", owner) + } + if _, err := evalIn(t, ctx, scope, "Scrap(b)"); err != nil { + t.Fatalf("Scrap(b): %v", err) + } + if l, _ := ctx.OccurrenceLife(id); !l.Destroyed { + t.Errorf("OccurrenceLife(car) = %v after destroying b; want destroyed with the whole holding it", l) + } + if l, _ := ctx.OccurrenceLife(a.ID); !l.Alive() { + t.Errorf("OccurrenceLife(a) = %v; want alive, it was not destroyed", l) + } +} + +// testObjectLifecycleSharedPortionRehomed: when the home of an object two wholes hold drops it, +// the other whole still holding it becomes its home, so the object keeps its way to its whole. +func testObjectLifecycleSharedPortionRehomed(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + part def Car; + part def Garage { part slot : Car[0..1]; } + part a : Garage; + part b : Garage; + }`) + a, b := instantiate("a"), instantiate("b") + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Garage") + car, err := evalIn(t, ctx, scope, "new Car()") + if err != nil { + t.Fatalf("new Car(): %v", err) + } + for _, g := range []*Instance{a, b} { + if err := g.SetFeatureValue(ctx, "slot", car); err != nil { + t.Fatalf("#%d.slot := car: %v", g.ID, err) + } + } + id, _ := car.Object() + inst := ctx.instances[id] + _, rollback := ctx.beginJournal() + if err := a.SetFeatureValue(ctx, "slot", nullValue()); err != nil { + t.Fatalf("a.slot := null: %v", err) + } + if inst.owner != b || inst.ownerFeature != "slot" { + t.Errorf("car's home after a drops it = %v.%s; want b.slot, still holding it", inst.owner, inst.ownerFeature) + } + rollback() + if inst.owner != a || inst.ownerFeature != "slot" { + t.Errorf("car's home after rolling the drop back = %v.%s; want a.slot", inst.owner, inst.ownerFeature) + } + if err := a.SetFeatureValue(ctx, "slot", nullValue()); err != nil { + t.Fatalf("a.slot := null: %v", err) + } + if err := b.SetFeatureValue(ctx, "slot", nullValue()); err != nil { + t.Fatalf("b.slot := null: %v", err) + } + if inst.owner != nil { + t.Errorf("car's home after both drop it = %v; want none", inst.owner) + } +} + +// testObjectLifecycleEndedWholeAdoptsNothing: a composite write giving an ended whole a portion live or +// ended after it is refused with ErrOccurrenceLifetime and holds nothing; a reference to the live +// object, and a portion ended no later than the whole, are held. +func testObjectLifecycleEndedWholeAdoptsNothing(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import OccurrenceFunctions::*; + part def Car; + part def Garage { part slot : Car[0..1]; ref part seen : Car[0..1]; } + part garage : Garage; + part shed : Garage; + part barn : Garage; + }`) + garage, shed, barn := instantiate("garage"), instantiate("shed"), instantiate("barn") + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Garage") + car, err := evalIn(t, ctx, scope, "new Car()") + if err != nil { + t.Fatalf("new Car(): %v", err) + } + if _, err := ctx.endOccurrence(garage); err != nil { + t.Fatalf("endOccurrence(garage): %v", err) + } + if err := garage.SetFeatureValue(ctx, "slot", car); !errors.Is(err, ErrOccurrenceLifetime) { + t.Fatalf("ended garage.slot := live car = %v; want ErrOccurrenceLifetime", err) + } + id, _ := car.Object() + if owner := ctx.instances[id].owner; owner != nil { + t.Errorf("the car's home after the refused write = %v; want none", owner) + } + if fv, err := garage.GetFeatureValue(ctx, "slot"); err != nil || len(heldObjects(fv.HeldValue())) != 0 { + t.Errorf("ended garage.slot = %s, %v; want no object, the write refused", FormatValue(fv.HeldValue()), err) + } + if l, _ := ctx.OccurrenceLife(id); !l.Alive() { + t.Errorf("OccurrenceLife(car) = %v; want alive, the refused write ended nothing", l) + } + if err := garage.SetFeatureValue(ctx, "seen", car); err != nil { + t.Errorf("ended garage.seen := live car = %v; want held, a reference makes no portion", err) + } + if err := shed.SetFeatureValue(ctx, "slot", car); err != nil { + t.Fatalf("shed.slot := car: %v", err) + } + if _, err := ctx.endOccurrence(shed); err != nil { + t.Fatalf("endOccurrence(shed): %v", err) + } + if err := garage.SetFeatureValue(ctx, "slot", car); !errors.Is(err, ErrOccurrenceLifetime) { + t.Errorf("ended garage.slot := car ended after it = %v; want ErrOccurrenceLifetime", err) + } + if _, err := ctx.endOccurrence(barn); err != nil { + t.Fatalf("endOccurrence(barn): %v", err) + } + if err := barn.SetFeatureValue(ctx, "slot", car); err != nil { + t.Errorf("ended barn.slot := car ended before it = %v; want held, its life within the whole's", err) + } +} + +// testObjectLifecycleCompositeCycle: a composite write that would make the holder a portion of +// itself — holding itself, or a whole it is a portion of — is refused with ErrOccurrenceLifetime and +// holds nothing, so destroying the would-be portion cannot end its whole. +func testObjectLifecycleCompositeCycle(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import OccurrenceFunctions::*; + part def Node { part child : Node[0..1]; } + part root : Node; + calc def Drop { in n : Node; return : Node = destroy(n); } + }`) + root := instantiate("root") + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Node") + leaf, err := evalIn(t, ctx, scope, "new Node()") + if err != nil { + t.Fatalf("new Node(): %v", err) + } + if err := root.SetFeatureValue(ctx, "child", leaf); err != nil { + t.Fatalf("root.child := leaf: %v", err) + } + id, _ := leaf.Object() + leafInst := ctx.instances[id] + for _, whole := range []*Instance{root, leafInst} { + err := leafInst.SetFeatureValue(ctx, "child", objectValue(whole)) + if !errors.Is(err, ErrOccurrenceLifetime) { + t.Errorf("leaf.child := #%d = %v; want ErrOccurrenceLifetime, it would make the leaf a portion of itself", whole.ID, err) + } + } + if fv := leafInst.FeatureValues["child"]; fv != nil && fv.Written { + t.Errorf("leaf.child holds %v after the refused writes; want nothing written", fv.HeldValue()) + } + if _, err := evalIn(t, ctx, scope, "Drop(root.child)"); err != nil { + t.Fatalf("Drop(root.child): %v", err) + } + if l, _ := ctx.OccurrenceLife(root.ID); !l.Alive() { + t.Errorf("OccurrenceLife(root) = %v after destroying its leaf; want alive", l) + } +} + +// testObjectLifecycleForgetsMessages: the messages addressed to a destroyed object, or routed to +// a destroyed port of a live one, which no consumer can take, leave the bus with it, and come +// back when the destruction is rolled back. +func testObjectLifecycleForgetsMessages(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import OccurrenceFunctions::*; + attribute def Ping; + port def Ear; + part def Device { port ear : Ear; } + part def Fleet { part units : Device[0..*]; } + part fleet : Fleet; + part other : Device; + calc def Drop { in f : Fleet; return : Fleet = destroy(f); } + }`) + fleet, other := instantiate("fleet"), instantiate("other") + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Fleet") + unit, err := evalIn(t, ctx, scope, "new Device()") + if err != nil { + t.Fatalf("new Device(): %v", err) + } + if err := fleet.SetFeatureValue(ctx, "units", unit); err != nil { + t.Fatalf("fleet.units := unit: %v", err) + } + id, _ := unit.Object() + for _, to := range []int64{id, other.ID} { + ctx.PostMessage(Message{SignalType: "Ping", Object: to}) + } + _, rollback := ctx.beginJournal() + if _, err := evalIn(t, ctx, scope, "Drop(fleet)"); err != nil { + t.Fatalf("Drop(fleet): %v", err) + } + if got := ctx.PendingMessages(); len(got) != 1 || got[0].Object != other.ID { + t.Errorf("pending after destroying the fleet: %v; want the one Ping to other alone", got) + } + rollback() + if got := ctx.PendingMessages(); len(got) != 2 { + t.Errorf("pending after rolling the destruction back: %v; want both Pings", got) + } + + ear, err := ctx.portInstanceID(other, "ear") + if err != nil { + t.Fatalf("portInstanceID(other.ear): %v", err) + } + ctx.PostMessage(Message{SignalType: "Ping", Object: other.ID, Port: "ear", PortID: ear, Delivery: DeliverPort}) + if err := ctx.destroy(ctx.instances[ear]); err != nil { + t.Fatalf("destroy(other.ear): %v", err) + } + if l, _ := ctx.OccurrenceLife(other.ID); !l.Alive() { + t.Fatalf("OccurrenceLife(other) = %v after destroying its port; want alive", l) + } + for _, msg := range ctx.PendingMessages() { + if msg.PortID == ear { + t.Errorf("pending after destroying other.ear still routes to it: %v; want the Ping to the port gone", msg) + } + } + if got := ctx.PendingMessages(); len(got) != 2 { + t.Errorf("pending after destroying other.ear: %v; want the two Pings to the objects themselves", got) + } +} + +// testObjectLifecycleDerivationsFollowLives: a `=` value that read a car's feature, whether the +// car is alive, or the extent derives again once a car is created — by a `=` value deriving +// too — or destroyed, rather than answering from what it derived before. +func testObjectLifecycleDerivationsFollowLives(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + private import SequenceFunctions::*; + part def Car { attribute n : Integer = 2; } + part def Garage { + part slot : Car[0..1]; + attribute slotN : Integer = slot.n; + attribute slotAlive : Boolean = isDuring(slot); + attribute cars : Natural = (all Car)->size(); + attribute spareN : Integer = (new Car()).n; + } + part garage : Garage; + calc def Scrap { in c : Car; return : Car[0..1] = destroy(c); } + }`) + garage := instantiate("garage") + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Garage") + read := func(name string) (string, error) { + fv, err := garage.GetFeatureValue(ctx, name) + if err != nil { + return "", err + } + return FormatValue(fv.HeldValue()), nil + } + if got, err := read("cars"); err != nil || got != "0" { + t.Fatalf("garage.cars before any car = %s, %v; want 0", got, err) + } + car, err := evalIn(t, ctx, scope, "new Car()") + if err != nil { + t.Fatalf("new Car(): %v", err) + } + if got, err := read("cars"); err != nil || got != "1" { + t.Errorf("garage.cars after new Car() = %s, %v; want 1", got, err) + } + if _, err := read("spareN"); err != nil { + t.Fatalf("garage.spareN: %v", err) + } + if got, err := read("cars"); err != nil || got != "2" { + t.Errorf("garage.cars after the derived spareN's new Car() = %s, %v; want 2", got, err) + } + if err := garage.SetFeatureValue(ctx, "slot", car); err != nil { + t.Fatalf("garage.slot := car: %v", err) + } + if got, err := read("slotN"); err != nil || got != "2" { + t.Fatalf("garage.slotN = %s, %v; want 2", got, err) + } + if got, err := read("slotAlive"); err != nil || got != "true" { + t.Fatalf("garage.slotAlive = %s, %v; want true", got, err) + } + _, rollback := ctx.beginJournal() + if _, err := evalIn(t, ctx, scope, "Scrap(garage.slot)"); err != nil { + t.Fatalf("Scrap(garage.slot): %v", err) + } + if got, err := read("slotAlive"); err != nil || got != "false" { + t.Errorf("garage.slotAlive after scrapping the car = %s, %v; want false", got, err) + } + rollback() + if got, err := read("slotAlive"); err != nil || got != "true" { + t.Errorf("garage.slotAlive after rolling the scrapping back = %s, %v; want true", got, err) + } + if _, err := evalIn(t, ctx, scope, "Scrap(garage.slot)"); err != nil { + t.Fatalf("Scrap(garage.slot): %v", err) + } + if _, err := read("slotN"); !errors.Is(err, ErrOccurrenceDestroyed) { + t.Errorf("garage.slotN after scrapping the car = %v; want ErrOccurrenceDestroyed, derived again", err) + } + if got, err := read("slotAlive"); err != nil || got != "false" { + t.Errorf("garage.slotAlive after scrapping the car = %s, %v; want false", got, err) + } + if got, err := read("cars"); err != nil || got != "1" { + t.Errorf("garage.cars after scrapping the car = %s, %v; want 1, the spare", got, err) + } +} + +// testObjectLifecycleImagedDerivationsFollowLives: an image carries that a `=` value read the +// lives, so where it is materialized the value derives again once a car is created or destroyed. +func testObjectLifecycleImagedDerivationsFollowLives(t *testing.T) { + _, _, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + private import SequenceFunctions::*; + part def Car; + part def Garage { + part slot : Car[0..1]; + attribute cars : Natural = (all Car)->size(); + attribute slotAlive : Boolean = isDuring(slot); + } + part def Lot { attribute cars : Natural = (all Car)->size(); } + }`) + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Garage") + made, err := evalIn(t, ctx, scope, "new Garage()") + if err != nil { + t.Fatalf("new Garage(): %v", err) + } + garageID, _ := made.Object() + garage := ctx.instances[garageID] + car, err := evalIn(t, ctx, scope, "new Car()") + if err != nil { + t.Fatalf("new Car(): %v", err) + } + if err := garage.SetFeatureValue(ctx, "slot", car); err != nil { + t.Fatalf("garage.slot := car: %v", err) + } + read := func(ctx *Context, garage *Instance, name string) (string, error) { + fv, err := garage.GetFeatureValue(ctx, name) + if err != nil { + return "", err + } + return FormatValue(fv.HeldValue()), nil + } + if got, err := read(ctx, garage, "cars"); err != nil || got != "1" { + t.Fatalf("garage.cars = %s, %v; want 1", got, err) + } + if got, err := read(ctx, garage, "slotAlive"); err != nil || got != "true" { + t.Fatalf("garage.slotAlive = %s, %v; want true", got, err) + } + + img, err := ctx.Image(garage) + if err != nil { + t.Fatalf("Image: %v", err) + } + dst := NewContext(ctx.Model(), 10000) + dst.claimID(99) + own, err := evalIn(t, dst, scope, "new Lot()") + if err != nil { + t.Fatalf("new Lot() in the destination: %v", err) + } + ownID, _ := own.Object() + local, _ := dst.Instance(ownID) + if got, err := read(dst, local, "cars"); err != nil || got != "0" { + t.Fatalf("local.cars before the image = %s, %v; want 0", got, err) + } + if err := img.Materialize(dst); err != nil { + t.Fatalf("Materialize: %v", err) + } + if got, err := read(dst, local, "cars"); err != nil || got != "1" { + t.Errorf("local.cars after the image = %s, %v; want 1, derived again over the imaged car", got, err) + } + copied, _ := dst.Instance(garage.ID) + if got, err := read(dst, copied, "cars"); err != nil || got != "1" { + t.Fatalf("copy.cars as imaged = %s, %v; want 1", got, err) + } + if _, err := evalIn(t, dst, scope, "new Car()"); err != nil { + t.Fatalf("new Car() in the destination: %v", err) + } + if got, err := read(dst, copied, "cars"); err != nil || got != "2" { + t.Errorf("copy.cars after new Car() in the destination = %s, %v; want 2, derived again", got, err) + } + carID, _ := car.Object() + if err := dst.destroy(dst.instances[carID]); err != nil { + t.Fatalf("destroy(the copy's car) in the destination: %v", err) + } + if got, err := read(dst, copied, "slotAlive"); err != nil || got != "false" { + t.Errorf("copy.slotAlive after scrapping its car = %s, %v; want false, derived again", got, err) + } + if got, err := read(dst, copied, "cars"); err != nil || got != "1" { + t.Errorf("copy.cars after scrapping its car = %s, %v; want 1", got, err) + } + if got, err := read(ctx, garage, "cars"); err != nil || got != "1" { + t.Errorf("garage.cars in the source after the destination's moves = %s, %v; want 1, untouched", got, err) + } +} + +// testObjectLifecycleStartsAfterStore: the behaviors a write starts on the object written run once +// the feature holds it, so one reading the holder through the extent sees the object, not what was there. +func testObjectLifecycleStartsAfterStore(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; private import SequenceFunctions::*; + part def Device { attribute slotted : Integer; } + part def Rack { + part slot : Device[0..1] { + perform action look { first start; then action count { assign slotted := (all Rack).slot->size(); } then done; } + } + } + part rack : Rack; + }`) + rack := instantiate("rack") + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Rack") + device, err := evalIn(t, ctx, scope, "new Device()") + if err != nil { + t.Fatalf("new Device(): %v", err) + } + if err := rack.SetFeatureValue(ctx, "slot", device); err != nil { + t.Fatalf("rack.slot := device: %v", err) + } + id, _ := device.Object() + fv, err := ctx.instances[id].GetFeatureValue(ctx, "slotted") + if err != nil { + t.Fatalf("device.slotted: %v", err) + } + if got := FormatValue(fv.HeldValue()); got != "1" { + t.Errorf("device.slotted = %s after the write started look; want 1, the slot holding it", got) + } +} + +// testObjectLifecycleConstructorStartsAfterStores: the behavior a constructor's first argument starts +// through the feature holding it runs once the later arguments are stored too, so it reads them. +func testObjectLifecycleConstructorStartsAfterStores(t *testing.T) { + _, _, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; private import SequenceFunctions::*; + part def Device { attribute seen : Integer; } + part def Pair { + part lead : Device[0..1] { + perform action look { first start; then action count { assign seen := (all Pair).trail->size(); } then done; } + } + part trail : Device[0..1]; + } + }`) + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Pair") + pair, err := evalIn(t, ctx, scope, "new Pair(new Device(), new Device())") + if err != nil { + t.Fatalf("new Pair(...): %v", err) + } + id, _ := pair.Object() + lead := heldNamed(t, ctx, ctx.instances[id], "lead") + if len(lead) != 1 { + t.Fatalf("pair.lead holds %d objects; want 1", len(lead)) + } + fv, err := lead[0].GetFeatureValue(ctx, "seen") + if err != nil { + t.Fatalf("lead.seen: %v", err) + } + if got := FormatValue(fv.HeldValue()); got != "1" { + t.Errorf("lead.seen = %s after the construction started look; want 1, the trail stored before it ran", got) + } +} + +// testObjectLifecycleBoundDefaultStartsAfterStore: a default object reached through a binding's +// endpoint starts its behavior once the endpoint holds it, so the behavior reads it there. +func testObjectLifecycleBoundDefaultStartsAfterStore(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; private import SequenceFunctions::*; + part def Device { attribute seen : Integer; } + part def Rack { + part source : Device[0..1] = new Device() { + perform action look { first start; then action count { assign seen := (all Rack).source->size(); } then done; } + } + part slot : Device[0..1]; + bind slot = source; + } + part rack : Rack; + }`) + slot := heldNamed(t, ctx, instantiate("rack"), "slot") + if len(slot) != 1 { + t.Fatalf("rack.slot holds %d objects; want the bound default", len(slot)) + } + fv, err := slot[0].GetFeatureValue(ctx, "seen") + if err != nil { + t.Fatalf("slot.seen: %v", err) + } + if got := FormatValue(fv.HeldValue()); got != "1" { + t.Errorf("slot.seen = %s after the default started look; want 1, the source holding it", got) + } +} + +// testObjectLifecycleRefusedWriteJournal: a write refused when the behavior its feature adds fails +// restores ownership and leaves an enclosing journal nothing to undo, so a later move stands. +func testObjectLifecycleRefusedWriteJournal(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; + part def Device; + part def Rack { + part slot : Device[0..1] { + attribute bad : Integer; + perform action boom { first start; then action b { assign bad := 1/0; } then done; } + } + } + part def Shelf { part slot : Device[0..1]; } + }`) + rack, shelf, device := instantiate("Rack"), instantiate("Shelf"), instantiate("Device") + commit, _ := ctx.beginJournal() + defer commit() + undos := len(ctx.journalUndos) + if err := rack.SetFeatureValue(ctx, "slot", objectValue(device)); err == nil { + t.Fatal("rack.slot := device succeeded; want the slot's failing action reported") + } + if device.owner != nil || len(ctx.journalUndos) != undos { + t.Errorf("after the refused write: owner %v, %d journal entries added; want none of either", device.owner, len(ctx.journalUndos)-undos) + } + if err := shelf.SetFeatureValue(ctx, "slot", objectValue(device)); err != nil || device.owner != shelf { + t.Errorf("shelf.slot := device = %v, owner %v; want the shelf to own it", err, device.owner) + } +} + +// testObjectLifecycleRolledBackTrace: the behaviors a refused write or a failed constructor +// started are rolled back trace and all, so the trace reports no step the run does not show; +// the trace of a store that is kept stays. +func testObjectLifecycleRolledBackTrace(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; + part def Device; + part def Rack { + part slot : Device[0..1] { + attribute bad : Integer; + perform action boom { first start; then action b { assign bad := 1/0; } then done; } + } + } + part def Shelf { + part slot : Device[0..1] { + attribute n : Integer; + perform action fill { first start; then action f { assign n := 1; } then done; } + } + } + part def Plant { attribute count : Integer = 0; } + part def Worker { + ref part p : Plant; + perform action go { + first start; + then action write { assign p.count := 1; } + then action fail { assign p.count := 1/0; } + then done; + } + } + part plant : Plant; + }`) + tr := NewTraceRecorder() + ctx.SetTrace(tr) + rack, shelf, device, plant := instantiate("Rack"), instantiate("Shelf"), instantiate("Device"), instantiate("plant") + // stepsIn is what the records say of the behaviors: everything but the failing evaluation's own line. + stepsIn := func(records []TraceRecord) (steps []string) { + for _, r := range records { + if r.Kind == TraceLine && !strings.HasPrefix(r.text, "eval construct ") { + steps = append(steps, r.text) + } + } + return steps + } + before := len(tr.records) + if err := rack.SetFeatureValue(ctx, "slot", objectValue(device)); err == nil { + t.Fatal("rack.slot := device succeeded; want the slot's failing action reported") + } + if steps := stepsIn(tr.records[before:]); len(steps) != 0 { + t.Errorf("the refused write left %d step records in the trace: %q; want none", len(steps), steps) + } + _, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "Plant") + before = len(tr.records) + if _, err := evalIn(t, ctx, scope, "new Worker(plant)"); err == nil { + t.Fatal("new Worker(plant) succeeded; want its failing action reported") + } + if steps := stepsIn(tr.records[before:]); len(steps) != 0 { + t.Errorf("the failed constructor left %d step records in the trace: %q; want none", len(steps), steps) + } + if fv, err := plant.GetFeatureValue(ctx, "count"); err != nil || FormatValue(fv.HeldValue()) != "0" { + t.Errorf("plant.count = %v, %v after the failed constructor; want 0", fv, err) + } + before = len(tr.records) + if err := shelf.SetFeatureValue(ctx, "slot", objectValue(device)); err != nil { + t.Fatalf("shelf.slot := device: %v", err) + } + if steps := stepsIn(tr.records[before:]); len(steps) == 0 { + t.Error("the kept write left no step records in the trace; want the fill action's steps") + } +} + +// testObjectLifecycleDeclaredPart: an object a composite feature's default or a binding makes +// is the whole's portion just like a written one, so destroying the whole ends it. +// (A binding end names a feature, so the bound object is made by a reference's default.) +func testObjectLifecycleDeclaredPart(t *testing.T) { + instantiate, _, ctx := lifetimeFixture(t, ` + package test { + private import OccurrenceFunctions::*; + part def Child; + part def Whole { + part byDefault : Child = new Child(); + ref part made : Child = new Child(); + part byBinding : Child; + bind byBinding = made; + } + calc def DestroyWhole { in w : Whole; return : Whole = destroy(w); } + }`) + whole := instantiate("Whole") + children := map[string]*Instance{} + for _, name := range []string{"byDefault", "byBinding"} { + fv, err := whole.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + id, ok := fv.HeldValue().Object() + if !ok { + t.Fatalf("%s holds %v; want an object", name, fv.HeldValue()) + } + children[name] = ctx.instances[id] + if child := children[name]; child.owner != whole || child.ownerFeature != name { + t.Errorf("%s #%d owned by %v.%s; want the whole", name, child.ID, child.owner, child.ownerFeature) + } + } + sym, scope := calcByName(t, ctx.model.resolver.Index().DocumentRoot(""), "test", "DestroyWhole") + if _, err := ctx.InvokeCalc(sym, []Value{objectValue(whole)}, scope); err != nil { + t.Fatalf("destroy(whole) = %v", err) + } + for name, child := range children { + if l, _ := ctx.OccurrenceLife(child.ID); !l.Destroyed { + t.Errorf("%s #%d = %v; want destroyed with the whole", name, child.ID, l) + } + } +} + +const objectLifecycleExploreModel = ` + package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + private import SequenceFunctions::*; + part def Car { attribute n : Integer; } + part def Garage { + part left : Car[0..1]; + part right : Car[0..1]; + attribute count : Integer; + attribute distinct : Boolean; + perform action race { + first start; + fork split; + action a { assign left := new Car(1); } + action b { assign right := new Car(2); } + join sync; + action read { + assign count := size(all Car); + assign distinct := not (left === right); + } + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then read; + succession first read then done; + } + } + part def Yard { + part car : Car[0..1] = new Car(3); + attribute alive : Boolean; + perform action scrap { + first start; + fork split; + action a { assign car := destroy(car); } + action b { assign alive := isDuring(car); } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then done; + } + } + }` + +// exploreInstance explores the runs of instantiating the named definition; the +// outcome of a run is what the listed attributes of the object hold. +func (m *exploreModel) exploreInstance(t *testing.T, name string, attributes ...string) *Exploration { + t.Helper() + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatalf("policy explore: %v", err) + } + sym := lookupOne(t, m.idx, "test::"+name) + result, err := Explore(context.Background(), policy, m.fresh, func(ctx *Context) (Outcome, error) { + inst, err := ctx.Instantiate(sym) + if err != nil { + return Outcome{}, err + } + outputs := make(map[string]Value, len(attributes)) + for _, attribute := range attributes { + fv, err := inst.GetFeatureValue(ctx, attribute) + if err != nil { + return Outcome{}, err + } + outputs[attribute] = fv.HeldValue() + } + return ctx.ActionOutcome(outputs), nil + }) + if err != nil { + t.Fatalf("explore %s: %v", name, err) + } + return result +} + +// testObjectLifecycleExploreCreation: every linearization of two branches each constructing +// a car reaches one outcome, and two explorations agree move for move. +func testObjectLifecycleExploreCreation(t *testing.T) { + m := parseLibraryModel(t, objectLifecycleExploreModel) + first := m.exploreInstance(t, "Garage", "count", "distinct") + if !first.Complete() || first.Runs != 2 { + t.Fatalf("status %q after %d runs, want complete after the 2 orders of two tokens", first.Status(), first.Runs) + } + if got := outcomeTexts(first); len(got) != 1 || got[0] != "count = 2; distinct = true" { + t.Fatalf("outcomes %v, want one: two distinct cars", got) + } + second := m.exploreInstance(t, "Garage", "count", "distinct") + if first.Status() != second.Status() || outcomeTexts(first)[0] != outcomeTexts(second)[0] || + FormatChoices(first.Outcomes[0].Witness) != FormatChoices(second.Outcomes[0].Witness) { + t.Errorf("explorations differ: %+v then %+v", first.Outcomes, second.Outcomes) + } +} + +// testObjectLifecycleExploreDestroyRace: whether the car is alive when read depends on the +// order of the branches alone, so exploration reaches exactly the two outcomes. +func testObjectLifecycleExploreDestroyRace(t *testing.T) { + m := parseLibraryModel(t, objectLifecycleExploreModel) + x := m.exploreInstance(t, "Yard", "alive") + if !x.Complete() || x.Runs != 2 { + t.Fatalf("status %q after %d runs, want complete after the 2 orders of two tokens", x.Status(), x.Runs) + } + if got := outcomeTexts(x); strings.Join(got, "|") != "alive = false|alive = true" { + t.Fatalf("outcomes %v, want the read after and before the destroy", got) + } + for _, o := range x.Outcomes { + if o.Linearizations != 1 { + t.Errorf("%s reached by %d linearizations, want 1", o.Outcome, o.Linearizations) + } + } +} diff --git a/internal/exec/runtime/robustness_partial_binding_test.go b/internal/exec/runtime/robustness_partial_binding_test.go new file mode 100644 index 0000000000..5dd4e03eb8 --- /dev/null +++ b/internal/exec/runtime/robustness_partial_binding_test.go @@ -0,0 +1,91 @@ +package runtime + +import ( + "errors" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// TestRuntimeRobustnessPartialBinding covers the failure modes of a binding that +// does not determine an end whole — the connector's own `binding [n]` bounding +// the links included: typed errors, never a panic or a hang. +func TestRuntimeRobustnessPartialBinding(t *testing.T) { + t.Run("underdetermined end is a typed error", testPartialBindingUnderdeterminedEnd) + t.Run("lower bound above the link count", testPartialBindingLowerBoundAboveLinks) + t.Run("mutually partial bindings terminate", testPartialBindingMutual) + t.Run("reading through the partially bound end", testPartialBindingReadThrough) +} + +// rigScope parses a model around `part def Rig` and returns the scope of package +// test so `rig.` expressions evaluate against the rig instance. +func rigScope(t *testing.T, rig string) (*Context, *symbols.Scope) { + t.Helper() + ctx, idx := libraryShapeContext(t, `package test { + part def Thing { attribute mass : Real; } + `+rig+` + part rig : Rig; + }`) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + return ctx, pkg.Scope +} + +// `binding [1]` links one value of each end, so the end whose feature must hold +// two is undetermined rather than bound whole: reading it is ErrBindingEnd. +func testPartialBindingUnderdeterminedEnd(t *testing.T) { + ctx, scope := rigScope(t, `part def Rig { + part a { part xs : Thing [1]; } + part ys : Thing [2]; + binding [1] bind [0..*] a.xs = [0..*] ys; + }`) + if _, err := evalIn(t, ctx, scope, "rig.ys"); !errors.Is(err, ErrBindingEnd) { + t.Fatalf("rig.ys = %v, want ErrBindingEnd", err) + } +} + +// A feature declaring a lower bound above the connector's link count can never +// be linked whole: reading it is the typed error without reading its value, and +// the other end, some unspecified value of it, is the same error. +func testPartialBindingLowerBoundAboveLinks(t *testing.T) { + ctx, scope := rigScope(t, `part def Rig { + part a { part xs : Thing [1]; } + part ys3 : Thing [3..*]; + binding [1] bind [0..*] a.xs = [0..*] ys3; + }`) + for _, expr := range []string{"rig.ys3", "rig.a.xs"} { + if _, err := evalIn(t, ctx, scope, expr); !errors.Is(err, ErrBindingEnd) { + t.Fatalf("%s = %v, want ErrBindingEnd", expr, err) + } + } +} + +// Two partial bindings each waiting on the other end resolve to a typed error, +// never a hang. +func testPartialBindingMutual(t *testing.T) { + ctx, scope := rigScope(t, `part def Rig { + part ys : Thing [2]; + part zs : Thing [2]; + binding [1] bind [0..*] ys = [0..*] zs; + binding [1] bind [0..*] zs = [0..*] ys; + }`) + _, err := evalIn(t, ctx, scope, "rig.ys") + if !errors.Is(err, ErrBindingEnd) && !errors.Is(err, ErrBindingCycle) { + t.Fatalf("rig.ys = %v, want ErrBindingEnd or ErrBindingCycle", err) + } +} + +// Reading a member through a partially bound end is the same typed error, not +// an empty read of a value the binding did not determine. +func testPartialBindingReadThrough(t *testing.T) { + ctx, scope := rigScope(t, `part def Rig { + part a { part xs : Thing [1]; } + part ys : Thing [2]; + binding [1] bind [0..*] a.xs = [0..*] ys; + }`) + if _, err := evalIn(t, ctx, scope, "rig.ys.mass"); !errors.Is(err, ErrBindingEnd) { + t.Fatalf("rig.ys.mass = %v, want ErrBindingEnd", err) + } +} diff --git a/internal/exec/runtime/robustness_perform_action_self_test.go b/internal/exec/runtime/robustness_perform_action_self_test.go new file mode 100644 index 0000000000..cfb54d9534 --- /dev/null +++ b/internal/exec/runtime/robustness_perform_action_self_test.go @@ -0,0 +1,79 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessPerformActionSelf exercises the failure modes of a +// `perform` declaration whose binding names an element holding no body: only a +// declaration naming no element at all is its own body. A name that resolves +// to nothing is a nameres diagnostic upstream, so each case resolves to an +// element that still states no body — or to none through an unresolvable +// clause, which the model builder tolerates while the analyzer reports it. +func TestRuntimeRobustnessPerformActionSelf(t *testing.T) { + t.Run("reference_form_names_no_action", testPerformReferenceNamesNoAction) + t.Run("reference_subsetting_names_nothing", testPerformSubsettingNamesNothing) + t.Run("typing_names_nothing", testPerformTypingNamesNothing) +} + +// testPerformReferenceNamesNoAction: `perform go;` binds the element `go`, +// which here is an attribute stating no behavior body, so the chain between the +// two never ends at an action. (An unresolvable `go` derives no member at all +// and is reported by the analyzer instead, so it cannot reach the runtime.) +func testPerformReferenceNamesNoAction(t *testing.T) { + src := ` + part def P { + attribute go; + perform go; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + _, err := ctx.Instantiate(resolveSymbol(t, root, "P")) + if !errors.Is(err, ErrUnresolvedClassifierBehavior) { + t.Fatalf("error = %v, want ErrUnresolvedClassifierBehavior", err) + } + if !strings.Contains(err.Error(), "go") { + t.Errorf("error %q does not name the behavior", err) + } +} + +// testPerformSubsettingNamesNothing: `perform action go ::> missing` states a +// reference subsetting whose target resolves to no element, so no body is +// reached and the usage is not its own body — it named one. +func testPerformSubsettingNamesNothing(t *testing.T) { + src := ` + part def P { + action declared; + perform action go ::> missing; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + _, err := ctx.Instantiate(resolveSymbol(t, root, "P")) + if !errors.Is(err, ErrUnresolvedClassifierBehavior) { + t.Fatalf("error = %v, want ErrUnresolvedClassifierBehavior", err) + } +} + +// testPerformTypingNamesNothing: `perform action go : Nothing` states a typing +// that resolves to no element, so no body is reached and the usage is not its +// own body — it named one. +func testPerformTypingNamesNothing(t *testing.T) { + src := ` + part def P { + perform action go : Nothing; + } + ` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + + _, err := ctx.Instantiate(resolveSymbol(t, root, "P")) + if !errors.Is(err, ErrUnresolvedClassifierBehavior) { + t.Fatalf("error = %v, want ErrUnresolvedClassifierBehavior", err) + } +} diff --git a/internal/exec/runtime/robustness_perform_on_part_test.go b/internal/exec/runtime/robustness_perform_on_part_test.go new file mode 100644 index 0000000000..9bf41057d5 --- /dev/null +++ b/internal/exec/runtime/robustness_perform_on_part_test.go @@ -0,0 +1,98 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessPerformOnPart exercises the failure modes of a +// `perform action x ::> part.action` whose operand denotes no one object to +// perform the action, one destroyed already, or whose chain ends in no action. +func TestRuntimeRobustnessPerformOnPart(t *testing.T) { + t.Run("performer_holds_no_object", testPerformOnPartHoldingNoObject) + t.Run("performer_holds_several_objects", testPerformOnPartHoldingSeveralObjects) + t.Run("performer_was_destroyed", testPerformOnPartDestroyed) + t.Run("chain_ends_in_no_action", testPerformOnPartChainEndsInNoAction) +} + +// stationPerforming is a station whose exhibited machine performs Observe on +// entry, with the given telescope part and the given body of Observe. +func stationPerforming(part, body string) string { + return `package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + part def Telescope { + attribute azimuth : Real default = 0.0; + action def Point; + action point : Point; + } + part def Station { + ` + part + ` + action def Observe { + ` + body + ` + } + exhibit state run { + entry; then observing; + state observing { entry action observe : Observe; } + } + } + }` +} + +// performing is the body of Observe performing `pt` alone, as given. +func performing(pt string) string { + return "first start then pt; " + pt + " first pt then done;" +} + +// testPerformOnPartHoldingNoObject: an optional part holding nothing performs +// nothing; the run reports that instead of running the action as the caller. +func testPerformOnPartHoldingNoObject(t *testing.T) { + _, _, err := instantiateWithLibraries(t, stationPerforming( + "part tel : Telescope[0..1];", + performing("perform action pt ::> tel.point;"), + ), "test::Station") + if !errors.Is(err, ErrPerformerNotObject) || !strings.Contains(err.Error(), "tel.point is performed by [], which is no one object") { + t.Fatalf("error = %v, want ErrPerformerNotObject over an empty tel", err) + } +} + +// testPerformOnPartHoldingSeveralObjects: a part holding two objects names no +// single performer, so the run refuses rather than picking one. +func testPerformOnPartHoldingSeveralObjects(t *testing.T) { + _, _, err := instantiateWithLibraries(t, stationPerforming( + "part tels : Telescope[2];", + performing("perform action pt ::> tels.point;"), + ), "test::Station") + if !errors.Is(err, ErrPerformerNotObject) || !strings.Contains(err.Error(), "tels.point is performed by [instance(") { + t.Fatalf("error = %v, want ErrPerformerNotObject over two telescopes", err) + } +} + +// testPerformOnPartDestroyed: a part whose object was destroyed before the +// performance performs nothing; the run says the object ended, not that it ran. +func testPerformOnPartDestroyed(t *testing.T) { + _, _, err := instantiateWithLibraries(t, stationPerforming( + "part tel : Telescope;", + `first start then gone; + action gone { assign tel := destroy(tel); } + first gone then pt; + perform action pt ::> tel.point; + first pt then done;`, + ), "test::Station") + if !errors.Is(err, ErrOccurrenceDestroyed) || !strings.Contains(err.Error(), "(Telescope) was destroyed at") { + t.Fatalf("error = %v, want ErrOccurrenceDestroyed over the destroyed telescope", err) + } +} + +// testPerformOnPartChainEndsInNoAction: a chain ending in an attribute of the +// part performs nothing and says what the member is. +func testPerformOnPartChainEndsInNoAction(t *testing.T) { + _, _, err := instantiateWithLibraries(t, stationPerforming( + "part tel : Telescope;", + performing("perform action pt ::> tel.azimuth;"), + ), "test::Station") + if err == nil || !strings.Contains(err.Error(), "azimuth is not an action (attributeUsage)") { + t.Fatalf("error = %v, want the chain's last member reported as no action", err) + } +} diff --git a/internal/exec/runtime/robustness_perform_typed_on_part_test.go b/internal/exec/runtime/robustness_perform_typed_on_part_test.go new file mode 100644 index 0000000000..7c2cd4c8f9 --- /dev/null +++ b/internal/exec/runtime/robustness_perform_typed_on_part_test.go @@ -0,0 +1,74 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessPerformTypedOnPart exercises `perform action x : Def ::> part.action`: +// the typing must not make the caller perform Def itself, and a reference denoting no action is refused. +func TestRuntimeRobustnessPerformTypedOnPart(t *testing.T) { + t.Run("reference_names_the_performer", testPerformTypedOnPartPerformer) + t.Run("performer_holds_no_object", testPerformTypedOnPartHoldingNoObject) + t.Run("chain_ends_in_no_action", testPerformTypedOnPartChainEndsInNoAction) +} + +// testPerformTypedOnPartPerformer: the telescope, not the station, performs a +// typed perform that subsets its action, so the station's write of `azimuth` +// through the callee's `this` would fail were the station the performer. +func testPerformTypedOnPartPerformer(t *testing.T) { + _, _, err := instantiateWithLibraries(t, `package test { + private import ScalarValues::*; + part def Telescope { + attribute azimuth : Real default = 0.0; + action def Point { + first start then turn; + action turn { assign this.azimuth := 45.0; } + first turn then done; + } + action point : Point; + } + part def Station { + part tel : Telescope; + action def Observe { + first start then pt; + perform action pt : Telescope::Point ::> tel.point; + first pt then done; + } + exhibit state run { + entry; then observing; + state observing { entry action observe : Observe; } + state pointed; + transition first observing if tel.azimuth == 45.0 then pointed; + } + } + }`, "test::Station") + if err != nil { + t.Fatalf("the typed perform did not run as the telescope: %v", err) + } +} + +// testPerformTypedOnPartHoldingNoObject: an optional part holding nothing +// performs nothing, typing or not. +func testPerformTypedOnPartHoldingNoObject(t *testing.T) { + _, _, err := instantiateWithLibraries(t, stationPerforming( + "part tel : Telescope[0..1];", + performing("perform action pt : Telescope::Point ::> tel.point;"), + ), "test::Station") + if !errors.Is(err, ErrPerformerNotObject) || !strings.Contains(err.Error(), "tel.point is performed by [], which is no one object") { + t.Fatalf("error = %v, want ErrPerformerNotObject over an empty tel", err) + } +} + +// testPerformTypedOnPartChainEndsInNoAction: the reference's last member is what +// is performed, so an attribute there is refused even though the typing names an action. +func testPerformTypedOnPartChainEndsInNoAction(t *testing.T) { + _, _, err := instantiateWithLibraries(t, stationPerforming( + "part tel : Telescope;", + performing("perform action pt : Telescope::Point ::> tel.azimuth;"), + ), "test::Station") + if err == nil || !strings.Contains(err.Error(), "azimuth is not an action (attributeUsage)") { + t.Fatalf("error = %v, want the chain's last member reported as no action", err) + } +} diff --git a/internal/exec/runtime/robustness_positional_invoke_test.go b/internal/exec/runtime/robustness_positional_invoke_test.go new file mode 100644 index 0000000000..4c5038eaa1 --- /dev/null +++ b/internal/exec/runtime/robustness_positional_invoke_test.go @@ -0,0 +1,106 @@ +package runtime + +import ( + "errors" + "testing" +) + +// TestRuntimeRobustnessPositionalInvoke exercises the failure modes of an operation +// invoked with a positional argument list: a surplus argument, a list mixing the +// positional and the named form, and a list no same-named operation takes. +func TestRuntimeRobustnessPositionalInvoke(t *testing.T) { + t.Run("surplus_positional_argument", testPositionalInvokeSurplus) + t.Run("surplus_over_an_out_parameter", testPositionalInvokeSurplusOverOut) + t.Run("positional_and_named_mixed", testPositionalInvokeMixed) + t.Run("required_parameter_left_unbound", testPositionalInvokeTooFew) + t.Run("no_overload_of_that_arity", testPositionalInvokeNoOverload) +} + +// positionalInvokeSource declares operations with a defaulted, an `out` and an +// `inout` parameter, and two calcs of one name that differ in arity. +const positionalInvokeSource = ` + package test { + private import ScalarValues::*; + part def Adder { + attribute total : Integer = 0; + action add { in addend : Integer; in times : Integer = 1; out sum : Integer; + first apply; action apply { assign total := total + addend * times; assign sum := total; } } + action moveTo { in amount : Integer; inout sink : Integer; + first apply; action apply { assign sink := sink + amount; } } + calc scaled { in factor : Integer; return : Integer = total * factor; } + calc scaled { in factor : Integer; in offset : Integer; return : Integer = total * factor + offset; } + } + }` + +func positionalInvokeObject(t *testing.T) (*Context, *Instance) { + t.Helper() + ctx, inst, err := instantiateWithLibraries(t, positionalInvokeSource, "test::Adder") + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + return ctx, inst +} + +// testPositionalInvokeSurplus: a third argument to an operation of two input +// parameters is an arity error, not a value bound nowhere. +func testPositionalInvokeSurplus(t *testing.T) { + ctx, inst := positionalInvokeObject(t) + _, err := ctx.InvokeOperationWith(inst, "add", OperationArguments{ + Positional: []Value{intArgument(1), intArgument(2), intArgument(3)}, + }) + if !errors.Is(err, ErrOperationArity) { + t.Fatalf("add(1, 2, 3): %v, want ErrOperationArity", err) + } +} + +// testPositionalInvokeSurplusOverOut: an `out` parameter takes no position, so an +// argument aimed at it is a surplus. +func testPositionalInvokeSurplusOverOut(t *testing.T) { + ctx, inst := positionalInvokeObject(t) + _, err := ctx.InvokeOperationWith(inst, "moveTo", OperationArguments{ + Positional: []Value{intArgument(1), intArgument(2), intArgument(3)}, + }) + if !errors.Is(err, ErrOperationArity) { + t.Fatalf("moveTo(1, 2, 3): %v, want ErrOperationArity", err) + } +} + +// testPositionalInvokeMixed: an argument list is positional or named, never both, +// even when the two would bind different parameters. +func testPositionalInvokeMixed(t *testing.T) { + ctx, inst := positionalInvokeObject(t) + _, err := ctx.InvokeOperationWith(inst, "add", OperationArguments{ + Positional: []Value{intArgument(1)}, + Named: map[string]Value{"times": intArgument(2)}, + }) + if !errors.Is(err, ErrMixedArguments) { + t.Fatalf("add(1, times=2): %v, want ErrMixedArguments", err) + } + if fv, err := inst.GetFeatureValue(ctx, "total"); err != nil || fv.HeldValue().Const.Int != 0 { + t.Fatalf("total after a refused invocation = %v, %v, want 0", fv, err) + } +} + +// testPositionalInvokeTooFew: an empty positional list leaves the default-less +// first parameter unbound; only the trailing defaulted one may be omitted. +func testPositionalInvokeTooFew(t *testing.T) { + ctx, inst := positionalInvokeObject(t) + _, err := ctx.InvokeOperationWith(inst, "moveTo", OperationArguments{ + Positional: []Value{intArgument(1)}, + }) + if !errors.Is(err, ErrUnboundParameter) { + t.Fatalf("moveTo(1): %v, want ErrUnboundParameter", err) + } +} + +// testPositionalInvokeNoOverload: with two same-named calcs taking one and two +// arguments, three arguments select neither and report the arity. +func testPositionalInvokeNoOverload(t *testing.T) { + ctx, inst := positionalInvokeObject(t) + _, err := ctx.InvokeOperationWith(inst, "scaled", OperationArguments{ + Positional: []Value{intArgument(1), intArgument(2), intArgument(3)}, + }) + if !errors.Is(err, ErrOperationArity) { + t.Fatalf("scaled(1, 2, 3): %v, want ErrOperationArity", err) + } +} diff --git a/internal/exec/runtime/robustness_region_do_step_test.go b/internal/exec/runtime/robustness_region_do_step_test.go new file mode 100644 index 0000000000..6a455c71e8 --- /dev/null +++ b/internal/exec/runtime/robustness_region_do_step_test.go @@ -0,0 +1,162 @@ +package runtime + +import ( + "context" + "errors" + "strings" + "testing" +) + +// doStepModel has Stop in the pool as top is entered, whose do behavior logs +// once: the do step and the dispatch are due together at t=0. +const doStepModel = `package test { + private import ScalarValues::*; + attribute def Stop; + state def Machine { + attribute log : String = ""; + entry; then top; + state top { + do { assign log := log + "did "; } + } + transition first top accept Stop do assign log := log + "stop " then idle; + state idle; + } +}` + +// TestRuntimeRobustnessRegionDoStep: a step-order line naming a move the unit does not +// offer is a typed refusal leaving the data as the move found it; a do behavior that +// never ends is stopped by the do-step budget under one-move scheduling as under a round. +func TestRuntimeRobustnessRegionDoStep(t *testing.T) { + t.Run("step_order_naming_a_state_with_no_due_do_step", testStepOrderNamingAStateWithNoDueDoStep) + t.Run("step_order_naming_a_dispatch_not_at_the_head", testStepOrderNamingADispatchNotAtTheHead) + t.Run("step_order_at_a_unit_offering_no_draw", testStepOrderAtAUnitOfferingNoDraw) + t.Run("endless_do_behavior_under_explore_hits_the_do_step_budget", testEndlessDoBehaviorUnderExploreHitsTheDoStepBudget) +} + +// refusedDoStepReplay drives doStepModel under the witness with Stop in the pool and +// returns the refusal, with the log the refusal left behind. +func refusedDoStepReplay(t *testing.T, lines string) (*ReplayError, string) { + t.Helper() + m := parseExploreModel(t, doStepModel) + sym := m.state(t, "Machine") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices(lines) + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + exec.SendSignal("Stop", nil) + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) { + t.Fatalf("error %T %v, want a ReplayError", err, err) + } + return refused, FormatValue(exec.StateData()["log"]) +} + +// testStepOrderNamingAStateWithNoDueDoStep: a line naming a do step of a state with no +// due do behavior is refused at the draw, before the do behavior or the dispatch acts. +func testStepOrderNamingAStateWithNoDueDoStep(t *testing.T) { + refused, log := refusedDoStepReplay(t, "at t=0.0: do idle first of do idle, dispatch accept Stop\n") + if refused.Move != 1 || !strings.Contains(refused.Error(), "do idle is not enabled (enabled: do top, dispatch accept Stop)") { + t.Errorf("refused %v, want move 1 naming do idle as not enabled among do top, dispatch accept Stop", refused) + } + if log != `""` { + t.Errorf("log is %s after the refusal, want empty: neither the do step nor the dispatch may run on a refused draw", log) + } +} + +// testStepOrderNamingADispatchNotAtTheHead: a line naming a dispatch of an event not at +// the head of the pool is refused at the draw, the pool untouched. +func testStepOrderNamingADispatchNotAtTheHead(t *testing.T) { + refused, log := refusedDoStepReplay(t, "at t=0.0: dispatch accept Go first of do top, dispatch accept Go\n") + if refused.Move != 1 || !strings.Contains(refused.Error(), "dispatch accept Go is not enabled (enabled: do top, dispatch accept Stop)") { + t.Errorf("refused %v, want move 1 naming dispatch accept Go as not enabled", refused) + } + if log != `""` { + t.Errorf("log is %s after the refusal, want empty", log) + } +} + +// testStepOrderAtAUnitOfferingNoDraw: once the do step is taken the dispatch is owed and +// alone, so a second step-order line finds no draw and is refused as an unfollowed move +// after the run ends where the dispatch left it. +func testStepOrderAtAUnitOfferingNoDraw(t *testing.T) { + m := parseExploreModel(t, doStepModel) + sym := m.state(t, "Machine") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices("at t=0.0: do top first of do top, dispatch accept Stop\nat t=0.0: do top first of do top, dispatch accept Stop\n") + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + exec.SendSignal("Stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if log := FormatValue(exec.StateData()["log"]); log != `"did stop "` { + t.Errorf("log is %s, want the do step then the dispatch", log) + } + err = ctx.Unfollowed() + var refused *ReplayError + if !errors.As(err, &refused) || refused.Move != 2 { + t.Fatalf("error %T %v, want the second line refused as unfollowed", err, err) + } +} + +// testEndlessDoBehaviorUnderExploreHitsTheDoStepBudget: a do behavior looping forever is +// stopped by the do-step budget on every explored run, a typed error, no hang. +func testEndlessDoBehaviorUnderExploreHitsTheDoStepBudget(t *testing.T) { + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + attribute def Stop; + state def Machine { + attribute n : Integer = 0; + entry; then top; + state top { + do action spin { first start; then merge again; then action count assign n := n + 1; then again; } + } + transition first top accept Stop then idle; + state idle; + } + }`) + sym := m.state(t, "Machine") + run := func(ctx *Context) (Outcome, error) { + budgets := ctx.Budgets() + budgets.MaxDoSteps = 8 + if err := ctx.SetBudgets(budgets); err != nil { + return Outcome{}, err + } + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return Outcome{}, err + } + return exec.Outcome(), exec.RunToCompletion() + } + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil { + t.Fatalf("explore: %v", err) + } + if !x.Complete() || len(x.Outcomes) == 0 { + t.Fatalf("exploration %s with %d outcomes, want complete", x.Status(), len(x.Outcomes)) + } + for _, o := range x.Outcomes { + if !errors.Is(o.Outcome.Err, ErrDoStepLimitExceeded) { + t.Errorf("outcome %s: error %v, want the do-step budget", o.Outcome, o.Outcome.Err) + } + } +} diff --git a/internal/exec/runtime/robustness_region_order_test.go b/internal/exec/runtime/robustness_region_order_test.go new file mode 100644 index 0000000000..e192f7e8ee --- /dev/null +++ b/internal/exec/runtime/robustness_region_order_test.go @@ -0,0 +1,171 @@ +package runtime + +import ( + "context" + "errors" + "strings" + "testing" +) + +// regionOrderModel enters a parallel state on Go whose two regions log their +// entries and exits and each fire on the next Go, so every drawn unit shows in log. +const regionOrderModel = `package test { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute log : String = ""; + entry; then idle; + state idle; + transition first idle accept Go then work; + state work parallel { + state left { + entry; then l1; + state l1 { + entry { assign log := log + "l1(entry) "; } + exit { assign log := log + "l1(exit) "; } + } + state l2 { exit { assign log := log + "l2(exit) "; } } + transition first l1 accept Go do { assign log := log + "T1(effect) "; } then l2; + } + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry) "; } + exit { assign log := log + "r1(exit) "; } + } + state r2 { exit { assign log := log + "r2(exit) "; } } + transition first r1 accept Go do { assign log := log + "T2(effect) "; } then r2; + } + } + state rest; + transition first work accept Go then rest; + } +}` + +// TestRuntimeRobustnessRegionOrder: a witness naming a unit the front does not hold is a +// typed refusal leaving the data as the move found it; a front beyond the budget is no hang. +func TestRuntimeRobustnessRegionOrder(t *testing.T) { + t.Run("entry_order_naming_a_region_the_front_does_not_hold", testEntryOrderNamingARegionTheFrontDoesNotHold) + t.Run("firing_unit_order_naming_a_unit_of_no_firing", testFiringUnitOrderNamingAUnitOfNoFiring) + t.Run("exit_order_naming_a_state_not_being_left", testExitOrderNamingAStateNotBeingLeft) + t.Run("deep_wide_front_beyond_the_run_budget", testDeepWideFrontBeyondTheRunBudget) +} + +// refusedRegionOrderReplay drives regionOrderModel under the witness through the +// signals given and returns the refusal, with the log the refusal left behind. +func refusedRegionOrderReplay(t *testing.T, lines string, signals int) (*ReplayError, string) { + t.Helper() + m := parseExploreModel(t, regionOrderModel) + sym := m.state(t, "Machine") + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + witness, err := ParseChoices(lines) + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(witness)) + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatal(err) + } + for i := 0; i < signals; i++ { + exec.SendSignal("Go", nil) + } + err = exec.RunToCompletion() + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) { + t.Fatalf("error %T %v, want a ReplayError", err, err) + } + return refused, FormatValue(exec.StateData()["log"]) +} + +// testEntryOrderNamingARegionTheFrontDoesNotHold: an entry-order line naming a state no +// region is about to enter is refused at the draw, before either region's entry runs. +func testEntryOrderNamingARegionTheFrontDoesNotHold(t *testing.T) { + refused, log := refusedRegionOrderReplay(t, "entering work: zork(entry) first of l1(entry), zork(entry)\n", 1) + if refused.Move != 1 || !strings.Contains(refused.Error(), "zork(entry) is not enabled (enabled: l1(entry), r1(entry))") { + t.Errorf("refused %v, want move 1 naming zork(entry) as not enabled among l1(entry), r1(entry)", refused) + } + if log != `""` { + t.Errorf("log is %s after the refusal, want empty: no entry may run on a refused draw", log) + } +} + +// testFiringUnitOrderNamingAUnitOfNoFiring: a firing-unit line naming a unit of +// no selected firing is refused before either source is left. +func testFiringUnitOrderNamingAUnitOfNoFiring(t *testing.T) { + refused, log := refusedRegionOrderReplay(t, + "entering work: l1(entry) first of l1(entry), r1(entry)\n"+ + "on accept Go: T9(effect) first of l1(exit), T9(effect)\n", 2) + if refused.Move != 2 || !strings.Contains(refused.Error(), "T9(effect) is not enabled (enabled: l1(exit), r1(exit))") { + t.Errorf("refused %v, want move 2 naming T9(effect) as not enabled among l1(exit), r1(exit)", refused) + } + if log != `"l1(entry) r1(entry) "` { + t.Errorf("log is %s after the refusal, want the entries alone: no exit may run on a refused draw", log) + } +} + +// testExitOrderNamingAStateNotBeingLeft: an exit-order line naming a state the transition +// does not leave is refused before either region's exit runs (l2's silent entry has no line). +func testExitOrderNamingAStateNotBeingLeft(t *testing.T) { + refused, log := refusedRegionOrderReplay(t, + "entering work: l1(entry) first of l1(entry), r1(entry)\n"+ + "on accept Go: l1(exit) first of l1(exit), r1(exit)\n"+ + "on accept Go: l1->l2(effect) first of l1->l2(effect), r1(exit)\n"+ + "exiting work: rest(exit) first of l2(exit), rest(exit)\n", 3) + if refused.Move != 4 || !strings.Contains(refused.Error(), "rest(exit) is not enabled (enabled: l2(exit), r2(exit))") { + t.Errorf("refused %v, want move 4 naming rest(exit) as not enabled among l2(exit), r2(exit)", refused) + } + if log != `"l1(entry) r1(entry) l1(exit) T1(effect) r1(exit) T2(effect) "` { + t.Errorf("log is %s after the refusal, want no exit of l2 or r2: none may run on a refused draw", log) + } +} + +// testDeepWideFrontBeyondTheRunBudget: a parallel state three deep and three wide has more +// entry linearizations than the budget's runs; the exploration reports that, the same way twice. +func testDeepWideFrontBeyondTheRunBudget(t *testing.T) { + region := func(name string) string { + return "state " + name + " { entry; then s; state s { entry { assign n := n + 1; } } }\n" + } + wide := func(name string) string { + return "state " + name + " { entry; then p; state p parallel {\n" + region(name+"1") + region(name+"2") + region(name+"3") + "} }\n" + } + m := parseExploreModel(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute n : Integer = 0; + entry; then work; + state work parallel { + `+wide("a")+wide("b")+wide("c")+` + } + } + }`) + sym := m.state(t, "Machine") + run := stateRun(sym, "") + policy, err := ParseSchedulePolicy("explore") + if err != nil { + t.Fatal(err) + } + var first *Exploration + for i := 0; i < 2; i++ { + x, err := Explore(context.Background(), policy, m.fresh, run) + if err != nil { + t.Fatalf("explore: %v", err) + } + if x.Complete() || !strings.Contains(x.Status(), "runs budget") { + t.Fatalf("status %q, want the runs budget hit", x.Status()) + } + if x.Runs != DefaultExploreBudget.Runs { + t.Errorf("exploration made %d runs, want the budget's %d", x.Runs, DefaultExploreBudget.Runs) + } + if first == nil { + first = x + continue + } + if a, b := explored(t, first), explored(t, x); strings.Join(a, "\n") != strings.Join(b, "\n") { + t.Errorf("two explorations of one model differ") + } + } +} diff --git a/internal/exec/runtime/robustness_replay_test.go b/internal/exec/runtime/robustness_replay_test.go new file mode 100644 index 0000000000..b08ff70f86 --- /dev/null +++ b/internal/exec/runtime/robustness_replay_test.go @@ -0,0 +1,124 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// inlinePerformanceModel performs `sub`, whose two branches race, inline in `outer`: +// the inner order is a step of the same number as the performer's token move. +const inlinePerformanceModel = `package test { + action sub { + attribute y : Integer = 0; + first start; + then fork split; + succession first split then a; + succession first split then b; + action a { assign y := y + 1; } + action b { assign y := y + 10; } + join sync; + succession first a then sync; + succession first b then sync; + then done; + } + action outer { + attribute x : Integer = 0; + first start; + then action one assign x := 1; + then perform action nested : sub; + then done; + } +}` + +// TestRuntimeRobustnessReplay exercises the witness lines a replay cannot follow +// at a step several flows share: each is a typed refusal naming the move, never a +// move made against the wrong flow, a silent skip or a hang. +func TestRuntimeRobustnessReplay(t *testing.T) { + t.Run("inner_order_naming_a_token_of_no_flow", testInnerOrderNamingATokenOfNoFlow) + t.Run("inner_order_mixing_two_flows", testInnerOrderMixingTwoFlows) + t.Run("witness_ending_before_the_run_does", testWitnessEndingBeforeTheRunDoes) +} + +func refusedInlineReplay(t *testing.T, lines string) error { + t.Helper() + m := parseExploreModel(t, inlinePerformanceModel) + sym := m.action(t, "outer") + run := func(ctx *Context) (Outcome, error) { + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + return Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + } + witness, err := ParseChoices(lines) + if err != nil { + t.Fatal(err) + } + _, _, err = replayed(t, m.fresh, run, witness) + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) { + t.Fatalf("error %T %v, want a ReplayError", err, err) + } + if refused.Move != 1 || refused.Choice.String() != witness[0].String() { + t.Errorf("refused move %d (%s), want move 1 (%s)", refused.Move, refused.Choice, witness[0]) + } + return err +} + +// testInnerOrderNamingATokenOfNoFlow: an order over a token no flow holds is +// refused at the inner step, where the run must pick, naming the token absent. +func testInnerOrderNamingATokenOfNoFlow(t *testing.T) { + err := refusedInlineReplay(t, "step 3: 2@a first of 2@a, 9@zzz") + if !strings.Contains(err.Error(), "step 3: 9@zzz is not able to act (able to act: 2@a, 3@b)") { + t.Errorf("error %q does not name 9@zzz as absent at step 3", err) + } +} + +// testInnerOrderMixingTwoFlows: an order naming the performer's token beside an +// inner branch is no flow's step, and is refused where the branches race. +func testInnerOrderMixingTwoFlows(t *testing.T) { + err := refusedInlineReplay(t, "step 3: 2@a first of 1@nested, 2@a") + if !strings.Contains(err.Error(), "step 3: 1@nested is not able to act (able to act: 2@a, 3@b)") { + t.Errorf("error %q does not name 1@nested as unable at step 3", err) + } +} + +// testWitnessEndingBeforeTheRunDoes: a witness cut short leaves the run to end on +// its own as `reverse` picks, still one token a move — at the round both branches +// and the exit are due, the flow keeps moving, `b` then `a`, before the exit — without +// a refusal or a move left over. +func testWitnessEndingBeforeTheRunDoes(t *testing.T) { + m := loopingDoModel(t, false) + sym := m.state(t, "Machine") + start := stateStarterOf(sym, HorizonAt(5)) + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + mustSchedule(t, ctx, ReplayPolicy(nil)) + run, err := beginInvocation(ctx, start) + if err != nil { + t.Fatal(err) + } + defer run.inv.Release() + for { + if err := run.stabilize(); err != nil { + t.Fatalf("run: %v", err) + } + if run.terminal() { + break + } + if err := run.step(owners(run.enabledMoves())); err != nil { + t.Fatalf("step: %v", err) + } + } + if err := ctx.Unfollowed(); err != nil { + t.Fatalf("unfollowed: %v", err) + } + outcome := run.inv.Outcome() + want := "finalState heard+finished; visits waiting, looping, finished, heard; late = 1; left = 1; right = 1" + if got := outcome.String(); got != want { + t.Errorf("outcome %s, want %s", got, want) + } +} diff --git a/internal/exec/runtime/robustness_resumable_inline_do_body_test.go b/internal/exec/runtime/robustness_resumable_inline_do_body_test.go new file mode 100644 index 0000000000..47f06d2b18 --- /dev/null +++ b/internal/exec/runtime/robustness_resumable_inline_do_body_test.go @@ -0,0 +1,239 @@ +package runtime + +import ( + "errors" + goruntime "runtime" + "testing" +) + +// TestRuntimeRobustnessResumableInlineDoBody exercises an inline do body run one +// statement a round: what an exit leaves behind of a body paused mid-loop, and the +// budget a body that never ends runs into. +func TestRuntimeRobustnessResumableInlineDoBody(t *testing.T) { + t.Run("exit_mid_loop_drops_the_pending_iterations", testDoBodyExitMidLoopDropsThePendingIterations) + t.Run("exit_mid_iteration_drops_the_rest_of_the_iteration", testDoBodyExitMidIterationDropsTheRestOfTheIteration) + t.Run("empty_branch_is_a_round_of_its_own", testDoBodyEmptyBranchIsARoundOfItsOwn) + t.Run("exit_on_a_clock_wait_after_a_loop_leaves_no_timer", testDoBodyExitOnAClockWaitAfterALoopLeavesNoTimer) + t.Run("non_terminating_body_exceeds_the_step_limit", testDoBodyNonTerminatingExceedsTheStepLimit) + t.Run("non_terminating_flow_body_exceeds_the_step_limit", testDoBodyNonTerminatingFlowExceedsTheStepLimit) +} + +// doBodyMachine is a machine whose `active` state runs body as its do behavior +// until a Stop is dispatched to it. +func doBodyMachine(body string) string { + return `package test { + private import ScalarValues::*; + attribute def Stop; + state Machine { + attribute total : Integer = 0; + attribute after : Integer = 0; + entry; then active; + state active { + do action work { ` + body + ` } + exit action leave { assign after := total; } + } + state stopped; + transition first active accept Stop then stopped; + } + }` +} + +// assertDoBodyAbandoned checks the run of the do body exited with the state is +// ended as abandoned and nothing of it is left due, waiting, or running. +func assertDoBodyAbandoned(t *testing.T, exec *StateExecutor, run *doRun, goroutines int) { + t.Helper() + if !run.body.ended || !errors.Is(run.body.err, ErrActionDeadlock) { + t.Errorf("the paused body ended = %v with %v; want it abandoned with ErrActionDeadlock", run.body.ended, run.body.err) + } + if len(run.body.cursor) != 0 || len(run.body.resuming) != 0 { + t.Errorf("frames %d kept and %d resuming after the abandonment; want none", len(run.body.cursor), len(run.body.resuming)) + } + if exec.HasPendingDoWork() || len(exec.doActions) != 0 { + t.Errorf("%d do behaviors, pending work %v after the exit; want none", len(exec.doActions), exec.HasPendingDoWork()) + } + if waits := exec.ctx.Clock().Waits(); len(waits) != 0 { + t.Errorf("%d wait(s) left on the clock; want none", len(waits)) + } + if got := goruntime.NumGoroutine(); got > goroutines { + t.Errorf("%d goroutines after the run, %d before; want none left behind", got, goroutines) + } + if leaf := activeLeaf(exec); leaf != "stopped" { + t.Errorf("active state %s; want stopped", leaf) + } +} + +// begunDoRun runs the first do round and returns the one do behavior it began. +func begunDoRun(t *testing.T, exec *StateExecutor) *doRun { + t.Helper() + if ran, err := exec.RunDoRound(); err != nil || ran != 1 { + t.Fatalf("first do round ran %d with %v; want the one behavior begun", ran, err) + } + if len(exec.doActions) != 1 || exec.doActions[0].run == nil { + t.Fatalf("%d do behaviors after the first round; want the one under way", len(exec.doActions)) + } + return exec.doActions[0].run +} + +// pausedDoRun is begunDoRun for a body paused between statements. +func pausedDoRun(t *testing.T, exec *StateExecutor) *doRun { + t.Helper() + run := begunDoRun(t, exec) + if !run.body.paused.yielded { + t.Fatalf("the body paused %+v; want yielded between statements", run.body.paused) + } + return run +} + +// testDoBodyExitMidLoopDropsThePendingIterations: a `for` body of one statement +// yields after each iteration; the Stop, dispatched after the round that ran the +// third, drops the two left with the behavior, the exit behavior runs, and +// nothing of the loop stays behind. +func testDoBodyExitMidLoopDropsThePendingIterations(t *testing.T) { + goroutines := goruntime.NumGoroutine() + exec := stateExecutorForSource(t, "Machine", doBodyMachine(` + for i in 1..5 { + assign total := total + i; + } + `)) + run := pausedDoRun(t, exec) + if _, err := exec.RunDoRound(); err != nil { + t.Fatalf("second do round: %v", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(3)) { + t.Fatalf("total = %v after two rounds; want 3, one iteration a round", total) + } + exec.SendSignal("Stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to completion: %v", err) + } + data := exec.StateData() + if !valueEqual(data["total"], integerValue(6)) || !valueEqual(data["after"], integerValue(6)) { + t.Errorf("total = %v, after = %v; want 6 and 6: one iteration in the round before the Stop, none after, the exit behavior did run", data["total"], data["after"]) + } + assertDoBodyAbandoned(t, exec, run, goroutines) +} + +// testDoBodyExitMidIterationDropsTheRestOfTheIteration: an iteration of two +// statements is two rounds, so the Stop dispatched after the round that ran the +// second iteration's first statement leaves its second unrun with the iterations after. +func testDoBodyExitMidIterationDropsTheRestOfTheIteration(t *testing.T) { + goroutines := goruntime.NumGoroutine() + exec := stateExecutorForSource(t, "Machine", doBodyMachine(` + for i in 1..3 { + assign total := total + i; + assign total := total * 10; + } + `)) + run := pausedDoRun(t, exec) + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(1)) { + t.Fatalf("total = %v after one round; want 1, the first statement of the first iteration", total) + } + if _, err := exec.RunDoRound(); err != nil { + t.Fatalf("second do round: %v", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(10)) { + t.Fatalf("total = %v after two rounds; want 10, the first iteration's second statement", total) + } + exec.SendSignal("Stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to completion: %v", err) + } + data := exec.StateData() + if !valueEqual(data["total"], integerValue(12)) || !valueEqual(data["after"], integerValue(12)) { + t.Errorf("total = %v, after = %v; want 12 and 12: the round before the Stop ran the second iteration's `+ i`, its `* 10` and the third iteration never ran", data["total"], data["after"]) + } + assertDoBodyAbandoned(t, exec, run, goroutines) +} + +// testDoBodyEmptyBranchIsARoundOfItsOwn: a conditional whose branch holds nothing +// and a loop ended by its condition at once each spend a round; the assignment +// after them runs in the next. +func testDoBodyEmptyBranchIsARoundOfItsOwn(t *testing.T) { + for _, compound := range []string{"if true { }", "if false { assign total := 5; } else { }", "while false { assign total := 5; }"} { + exec := stateExecutorForSource(t, "Machine", doBodyMachine(compound+` assign total := 1;`)) + run := pausedDoRun(t, exec) + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Fatalf("%s: total = %v after one round; want 0, the assignment after it not yet run", compound, total) + } + exec.SendSignal("Stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("%s: run to completion: %v", compound, err) + } + data := exec.StateData() + if !valueEqual(data["total"], integerValue(1)) || !valueEqual(data["after"], integerValue(1)) { + t.Errorf("%s: total = %v, after = %v; want 1 and 1: the round before the Stop ran the assignment", compound, data["total"], data["after"]) + } + if !run.body.ended || exec.HasPendingDoWork() { + t.Errorf("%s: body ended = %v, pending do work %v; want the body ended with nothing due", compound, run.body.ended, exec.HasPendingDoWork()) + } + } +} + +// testDoBodyExitOnAClockWaitAfterALoopLeavesNoTimer: a body whose flow loops in +// one node, then waits on the clock at the next — the one round performs the node +// and parks the token at the wait — is exited by the Stop while the wait is armed; +// the wait goes with the behavior, so the clock holds nothing of the state left. +func testDoBodyExitOnAClockWaitAfterALoopLeavesNoTimer(t *testing.T) { + goroutines := goruntime.NumGoroutine() + exec := stateExecutorForSource(t, "Machine", doBodyMachine(` + first start; + then action sum { for i in 1..2 { assign total := total + i; } } + then action pause accept after 10; + then action reset assign total := 0; + then done; + `)) + run := begunDoRun(t, exec) + if !run.body.paused.onWait { + t.Fatalf("the body paused %+v; want a wait on the clock after the loop", run.body.paused) + } + if waits := exec.ctx.Clock().Waits(); len(waits) != 1 { + t.Fatalf("%d wait(s) on the clock; want the body's one", len(waits)) + } + if exec.HasPendingDoWork() { + t.Fatal("the body is due while its wait on the clock goes on") + } + exec.SendSignal("Stop", nil) + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("run to quiescence: %v", err) + } + data := exec.StateData() + if !valueEqual(data["total"], integerValue(3)) || !valueEqual(data["after"], integerValue(3)) { + t.Errorf("total = %v, after = %v; want 3 and 3: the body ended at its wait", data["total"], data["after"]) + } + assertDoBodyAbandoned(t, exec, run, goroutines) +} + +// testDoBodyNonTerminatingExceedsTheStepLimit: a loop that never ends spends the +// budget one iteration a round, and the run ends with the typed error rather than +// hanging. +func testDoBodyNonTerminatingExceedsTheStepLimit(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", doBodyMachine(` + loop { + assign total := total + 1; + } + `)) + pausedDoRun(t, exec) + err := exec.RunToCompletion() + if !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("run to completion = %v; want ErrStepLimitExceeded", err) + } + if total := exec.StateData()["total"]; total.Kind != ValConst || total.Const.Int < 2 { + t.Errorf("total = %v; want the iterations run one a round before the budget ran out", total) + } +} + +// testDoBodyNonTerminatingFlowExceedsTheStepLimit: a body stating a token flow +// that cycles through a merge never retires its token; one node a round, it +// ends with the typed error. +func testDoBodyNonTerminatingFlowExceedsTheStepLimit(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", doBodyMachine(` + first start; + then merge again; + then action bump { assign total := total + 1; } + then again; + `)) + err := exec.RunToCompletion() + if !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("run to completion = %v; want ErrStepLimitExceeded", err) + } +} diff --git a/internal/exec/runtime/robustness_run_to_completion_scope_test.go b/internal/exec/runtime/robustness_run_to_completion_scope_test.go new file mode 100644 index 0000000000..e7c0764ae4 --- /dev/null +++ b/internal/exec/runtime/robustness_run_to_completion_scope_test.go @@ -0,0 +1,69 @@ +package runtime + +import ( + "errors" + "os" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" +) + +func TestRuntimeRobustnessRunToCompletionScope(t *testing.T) { + t.Run("scope_not_ancestor", func(t *testing.T) { + err := stateExecutorError(t, readRunToCompletionFixture(t, "state_run_to_completion_scope_not_ancestor.sysml"), "Machine") + var refusal *lower.RunToCompletionRedefinition + if !errors.As(err, &refusal) { + t.Fatalf("error = %v, want run-to-completion redefinition", err) + } + if !errors.Is(err, lower.ErrUnsupportedStateContent) { + t.Fatalf("error = %v, want unsupported state content", err) + } + if !refusal.NotAncestor { + t.Fatalf("refusal = %+v, want non-ancestor scope", refusal) + } + }) + + t.Run("scope_no_occurrence", func(t *testing.T) { + err := stateExecutorError(t, readRunToCompletionFixture(t, "state_run_to_completion_scope_no_occurrence.sysml"), "Machine") + var refusal *lower.RunToCompletionRedefinition + if !errors.As(err, &refusal) { + t.Fatalf("error = %v, want run-to-completion redefinition", err) + } + if !errors.Is(err, lower.ErrUnsupportedStateContent) { + t.Fatalf("error = %v, want unsupported state content", err) + } + if refusal.NotAncestor { + t.Fatalf("refusal = %+v, want unresolved scope", refusal) + } + }) + + t.Run("value_not_boolean", func(t *testing.T) { + err := stateExecutorError(t, ` + package test { + state def Machine { + entry; then idle; + state idle parallel { + attribute :>> isRunToCompletion = 1; + state inner { + entry; then leaf; + state leaf; + } + } + } + } + `, "Machine") + var valueErr *RunToCompletionValueError + if !errors.As(err, &valueErr) { + t.Fatalf("error = %v, want run-to-completion value error", err) + } + }) +} + +func readRunToCompletionFixture(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile("testdata/robustness/" + name) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/internal/exec/runtime/robustness_send_target_test.go b/internal/exec/runtime/robustness_send_target_test.go new file mode 100644 index 0000000000..a21518142d --- /dev/null +++ b/internal/exec/runtime/robustness_send_target_test.go @@ -0,0 +1,80 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessSendTarget exercises the failure modes of a send +// addressing a parameter of the sending action that holds no object to +// deliver to, or an object with no feature the rest of the target names. +func TestRuntimeRobustnessSendTarget(t *testing.T) { + t.Run("bound_target_holds_a_value", testSendTargetHoldsAValue) + t.Run("bound_target_holds_nothing", testSendTargetHoldsNothing) + t.Run("bound_target_chain_names_no_feature", testSendTargetChainNamesNoFeature) +} + +// hostNotifying is a host whose performed action notifies through a parameter +// bound as given, sending to the given target. +func hostNotifying(param, binding, target string) string { + return `package test { + private import ScalarValues::*; + item def Go; + part def Worker { + exhibit state listen { + entry; then idle; + state idle; + transition first idle accept Go then idle; + } + } + part def Host { + part worker : Worker; + action def Notify { + ` + param + ` + first start; + then action poke send new Go() to ` + target + `; + then done; + } + action def Round { + first start; + then action notify : Notify { ` + binding + ` } + then done; + } + perform action round : Round; + } + }` +} + +// testSendTargetHoldsAValue: a parameter holding a number addresses no object; +// the run refuses rather than treating the name as a feature of the sender. +func testSendTargetHoldsAValue(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostNotifying( + "in recipient : Integer;", "in recipient = 4;", "recipient", + ), "test::Host") + if !errors.Is(err, ErrSendTargetNotObject) || !strings.Contains(err.Error(), `"recipient" holds 4, which is no object to address "recipient" to`) { + t.Fatalf("error = %v, want ErrSendTargetNotObject over a number", err) + } +} + +// testSendTargetHoldsNothing: an optional parameter bound to nothing addresses +// no object, so the send is refused instead of dropped. +func testSendTargetHoldsNothing(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostNotifying( + "in recipient : Worker[0..1];", "in recipient = null;", "recipient", + ), "test::Host") + if !errors.Is(err, ErrSendTargetNotObject) || !strings.Contains(err.Error(), `"recipient" holds null, which is no object`) { + t.Fatalf("error = %v, want ErrSendTargetNotObject over an empty parameter", err) + } +} + +// testSendTargetChainNamesNoFeature: a chain from a bound object reaching no +// port or object is unroutable, named as the target was written. +func testSendTargetChainNamesNoFeature(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostNotifying( + "in recipient : Worker;", "in recipient = this.worker;", "recipient.nowhere", + ), "test::Host") + if !errors.Is(err, ErrUnroutableSend) || !strings.Contains(err.Error(), `"recipient.nowhere" names no port of an object`) { + t.Fatalf("error = %v, want ErrUnroutableSend over a chain naming no feature", err) + } +} diff --git a/internal/exec/runtime/robustness_send_to_object_identity_test.go b/internal/exec/runtime/robustness_send_to_object_identity_test.go new file mode 100644 index 0000000000..1d82948b68 --- /dev/null +++ b/internal/exec/runtime/robustness_send_to_object_identity_test.go @@ -0,0 +1,192 @@ +package runtime + +import ( + "errors" + "fmt" + "testing" + "time" +) + +// TestRuntimeRobustnessSendToObjectIdentity exercises a send whose receiver is an +// expression rather than a name: evaluating it to no object, to a value that is +// none, or to a destroyed one is a typed error, and a routed send whose receiver +// object is reached by no connection is refused — never a panic or a hang. +func TestRuntimeRobustnessSendToObjectIdentity(t *testing.T) { + t.Run("target_expression_yielding_no_object", testSendToNoObject) + t.Run("target_expression_yielding_a_data_value", testSendToDataValue) + t.Run("target_expression_yielding_a_destroyed_object", testSendToDestroyedObject) + t.Run("via_receiver_object_no_connection_reaches", testSendViaUnreachedReceiver) + t.Run("via_constructed_receiver_abandoned_on_failure", testSendViaConstructedReceiverAbandoned) +} + +// sendToObjectModel is a fleet whose build action creates two cars and then runs +// ping, the statement under test. +func sendToObjectModel(ping string) string { + return `package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + private import SequenceFunctions::*; + private import ControlFunctions::*; + item def Ping; + part def Car { + exhibit state listening { + entry; then waiting; + state waiting; + accept Ping then heard; + state heard; + } + } + part def Fleet { + part cars : Car[0..*]; + ref part spare : Car[0..1]; + attribute sameObject : Boolean = false; + perform action build { + first start; + then action make { + assign cars := (cars, new Car()); + assign cars := (cars, new Car()); + assign spare := new Car(); + } + then action ping { + ` + ping + ` + } + then done; + } + } + }` +} + +// instantiateBounded runs Instantiate on its own goroutine so a hang fails the +// case instead of stalling the suite, and a panic in it fails the case. +func instantiateBounded(t *testing.T, src, fqn string) error { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := oneSymbol(t, idx, fqn) + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("Instantiate panicked: %v", r) + } + }() + _, err := ctx.Instantiate(sym) + done <- err + }() + select { + case err := <-done: + return err + case <-watchdog(30 * time.Second): + t.Fatalf("Instantiate(%s) did not return within 30s", fqn) + return nil + } +} + +// A selection admitting nothing yields no object, which is no target. +func testSendToNoObject(t *testing.T) { + err := instantiateBounded(t, sendToObjectModel(`send new Ping() to cars->select { in c; false };`), "test::Fleet") + if !errors.Is(err, ErrSendTargetNotObject) { + t.Fatalf("error = %v, want %v", err, ErrSendTargetNotObject) + } +} + +// An expression yielding a data value names no object to address. +func testSendToDataValue(t *testing.T) { + err := instantiateBounded(t, sendToObjectModel(`send new Ping() to 3;`), "test::Fleet") + if !errors.Is(err, ErrSendTargetNotObject) { + t.Fatalf("error = %v, want %v", err, ErrSendTargetNotObject) + } +} + +// An object destroyed before the send addresses it is refused as destroyed. +func testSendToDestroyedObject(t *testing.T) { + err := instantiateBounded(t, sendToObjectModel( + `assign sameObject := destroy(cars#(2)) === cars#(2); + send new Ping() to cars#(2);`), "test::Fleet") + if !errors.Is(err, ErrOccurrenceDestroyed) { + t.Fatalf("error = %v, want %v", err, ErrOccurrenceDestroyed) + } +} + +// A routed send whose receiver object holds no connected port reaches nothing. +func testSendViaUnreachedReceiver(t *testing.T) { + model := `package test { + item def Ping; + port def PingPort { in item ping : Ping; } + part def Car { + port p : PingPort; + exhibit state listening { + entry; then waiting; + state waiting; + accept Ping via p then heard; + state heard; + } + } + part def Fleet { + part cars : Car[0..*]; + ref part spare : Car[0..1]; + port out : ~PingPort; + connect out to cars.p; + perform action build { + first start; + then action make { + assign cars := (cars, new Car()); + assign spare := new Car(); + } + then action ping { + send new Ping() via out to spare; + } + then done; + } + } + }` + err := instantiateBounded(t, model, "test::Fleet") + if !errors.Is(err, ErrUnreachableSendReceiver) { + t.Fatalf("error = %v, want %v", err, ErrUnreachableSendReceiver) + } +} + +// A routed send whose receiver expression constructs an object and whose port is +// joined to nothing abandons that object and its behaviors with the failed send. +func testSendViaConstructedReceiverAbandoned(t *testing.T) { + model := `package test { + item def Ping; + port def PingPort { in item ping : Ping; } + part def Car { + port p : PingPort; + exhibit state listening { + entry; then waiting; + state waiting; + accept Ping via p then heard; + state heard; + } + } + part def Fleet { + port out : ~PingPort; + action ping { + first start; + then action go { + send new Ping() via out to new Car(); + } + then done; + } + } + part fleet : Fleet; + }` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, model)) + fleet, err := ctx.Instantiate(oneSymbol(t, idx, "test::fleet")) + if err != nil { + t.Fatalf("Instantiate(test::fleet): %v", err) + } + before, beforeBehaviors := len(ctx.created), len(ctx.objectBehaviors) + exec, err := ctx.CreateActionExecutorFor(oneSymbol(t, idx, "test::Fleet::ping"), fleet) + if err != nil { + t.Fatalf("CreateActionExecutorFor(test::Fleet::ping): %v", err) + } + if err := exec.RunToCompletion(); !errors.Is(err, ErrUnroutableSend) { + t.Fatalf("error = %v, want %v", err, ErrUnroutableSend) + } + if len(ctx.created) != before || len(ctx.objectBehaviors) != beforeBehaviors { + t.Errorf("after the failed send: %d objects, %d behaviors; want the %d and %d before it", + len(ctx.created), len(ctx.objectBehaviors), before, beforeBehaviors) + } +} diff --git a/internal/exec/runtime/robustness_state_event_queries_test.go b/internal/exec/runtime/robustness_state_event_queries_test.go new file mode 100644 index 0000000000..50c13ddc10 --- /dev/null +++ b/internal/exec/runtime/robustness_state_event_queries_test.go @@ -0,0 +1,116 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestRuntimeRobustnessStateEventQueries exercises what the state and event +// document queries read at a machine that is over: a terminated one holds no +// active configuration, a completed one keeps its final state, Destroyed +// reports the instant a `destroy` ended an object, and a bounded recorder +// reports what it dropped. +func TestRuntimeRobustnessStateEventQueries(t *testing.T) { + t.Run("terminated_machine_reports_no_active_state", testTerminatedMachineNoActiveState) + t.Run("completed_machine_reports_its_final_state", testCompletedMachineFinalState) + t.Run("destroyed_reports_the_instant", testDestroyedReportsTheInstant) + t.Run("bounded_recorder_drops_the_oldest_records", testBoundedRecorderDropsOldest) +} + +const endedMachineModel = `package test { + private import ScalarValues::*; + attribute def Abort; + state def FinishingMachine { + entry; then working; + state working; + transition first working accept Abort then done; + } + state def TerminatingMachine { + entry; then busy; + state busy; + transition first busy accept Abort then stop; + action stop terminate; + } +}` + +// A machine a `terminate` ended has no active states or leaves and no current +// state, so a reader enumerating them sees nothing rather than a stale state. +func testTerminatedMachineNoActiveState(t *testing.T) { + ctx, sym := loadState(t, endedMachineModel, "TerminatingMachine") + exec, err := ctx.PerformState(sym, nil, []QueuedEvent{{Signal: "Abort"}}) + if err != nil { + t.Fatalf("PerformState: %v", err) + } + if got := exec.ActiveStates(); len(got) != 0 { + t.Fatalf("ActiveStates of a terminated machine = %d, want none", len(got)) + } + if got := exec.ActiveLeaves(); len(got) != 0 { + t.Fatalf("ActiveLeaves of a terminated machine = %d, want none", len(got)) + } + if got := exec.CurrentState(); got != nil { + t.Fatalf("CurrentState of a terminated machine = %v, want nil", got) + } +} + +// A machine that reached `done` keeps it as the active configuration. +func testCompletedMachineFinalState(t *testing.T) { + ctx, sym := loadState(t, endedMachineModel, "FinishingMachine") + exec, err := ctx.PerformState(sym, nil, []QueuedEvent{{Signal: "Abort"}}) + if err != nil { + t.Fatalf("PerformState: %v", err) + } + leaves := exec.ActiveLeaves() + if len(leaves) != 1 || leaves[0].Name != "done" { + t.Fatalf("ActiveLeaves of a completed machine = %v, want [done]", leaves) + } + if exec.CurrentState() == nil { + t.Fatalf("CurrentState of a completed machine = nil, want done") + } +} + +// Destroyed reports nothing for a living object and the destruction instant +// after `destroy` ran. +func testDestroyedReportsTheInstant(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package test { + part def Widget { attribute n : Integer = 1; } + part w : Widget; + }`)) + sym := namedSymbol(idx, "test::w", ast.DefPart, ast.UsagePart) + if sym == nil { + t.Fatal("part w not found") + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate w: %v", err) + } + if at, destroyed := ctx.Destroyed(inst); destroyed || at != 0 { + t.Fatalf("Destroyed before destroy = (%d, %v), want (0, false)", at, destroyed) + } + if err := ctx.destroy(inst); err != nil { + t.Fatalf("destroy: %v", err) + } + at, destroyed := ctx.Destroyed(inst) + if !destroyed || at == 0 { + t.Fatalf("Destroyed after destroy = (%d, %v), want (instant, true)", at, destroyed) + } + if life, ok := ctx.OccurrenceLife(inst.ID); !ok || !life.Destroyed || life.Ended != at { + t.Fatalf("OccurrenceLife = %+v, %v; want destroyed at %d", life, ok, at) + } +} + +// A bounded recorder keeps the most recent records and reports how many it +// dropped and the instant history begins at. +func testBoundedRecorderDropsOldest(t *testing.T) { + recorder := NewEventRecorder(2) + for i, kind := range []TraceKind{TraceAccept, TraceEntry, TraceExit} { + recorder.add(TraceRecord{Kind: kind, Origin: TraceOrigin{At: float64(i)}}) + } + records := recorder.Records() + if len(records) != 2 || records[0].Kind != TraceEntry || records[1].Kind != TraceExit { + t.Fatalf("records = %v, want the 2 most recent", records) + } + if dropped, upTo := recorder.Dropped(); dropped != 1 || upTo != 0 { + t.Fatalf("Dropped = (%d, %v), want (1, 0)", dropped, upTo) + } +} diff --git a/internal/exec/runtime/robustness_streaming_flow_test.go b/internal/exec/runtime/robustness_streaming_flow_test.go new file mode 100644 index 0000000000..a074775b82 --- /dev/null +++ b/internal/exec/runtime/robustness_streaming_flow_test.go @@ -0,0 +1,173 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessStreamingFlow exercises the failure modes of a plain `flow`, +// which streams each value its source writes to the performances of its target under way. +func TestRuntimeRobustnessStreamingFlow(t *testing.T) { + t.Run("source_never_writes", testStreamingFlowSourceNeverWrites) + t.Run("target_completed_before_source_writes", testStreamingFlowTargetCompletedFirst) + t.Run("target_pin_not_declared", testStreamingFlowTargetPinNotDeclared) + t.Run("later_performance_takes_one_of_two_late_values", testStreamingFlowOneLateValueLeft) + t.Run("flows_form_a_cycle", testStreamingFlowCycle) +} + +// streamingPair is an action performing producer and consumer side by side, the +// producer's `out value` flowing to the consumer's pin named by target. +func streamingPair(producerBody, target string) string { + return `package test { + action stream { + attribute total : Integer = 0; + fork split; + action producer { + out value : Integer; + ` + producerBody + ` + succession first start then step; + succession first step then done; + } + action consumer { + in value : Integer; + action take { assign total := total + 1; } + succession first start then take; + succession first take then done; + } + join sync; + succession first start then split; + succession first split then producer; + succession first split then consumer; + succession first producer then sync; + succession first consumer then sync; + succession first sync then done; + flow producer.value to ` + target + `; + } + }` +} + +// testStreamingFlowSourceNeverWrites: a source that completes without ever writing +// the pin its flow streams from has carried nothing, which the run reports rather +// than leaving the target's pin silently empty. +func testStreamingFlowSourceNeverWrites(t *testing.T) { + _, err := executeActionSource(t, "stream", streamingPair( + "action step { assign total := total + 0; }", "consumer.value", + )) + if !errors.Is(err, ErrFlowSource) || !strings.Contains(err.Error(), "node producer produced no value at value") { + t.Fatalf("error = %v, want ErrFlowSource from a source that never wrote", err) + } +} + +// testStreamingFlowTargetCompletedFirst: a value streamed after the target's last +// performance ended reaches no performance of it, which the run reports once the +// action completes without a further performance having taken it. +func testStreamingFlowTargetCompletedFirst(t *testing.T) { + _, err := executeActionSource(t, "stream", `package test { + action stream { + attribute total : Integer = 0; + action consumer { + in value : Integer; + assign total := total + 1; + } + action producer { + out value : Integer; + assign value := 7; + } + succession first start then consumer; + succession first consumer then producer; + succession first producer then done; + flow producer.value to consumer.value; + } + }`) + if !errors.Is(err, ErrStreamUnreceived) || !strings.Contains(err.Error(), "node consumer completed before node producer wrote value") { + t.Fatalf("error = %v, want ErrStreamUnreceived from a target over before its source wrote", err) + } +} + +// testStreamingFlowTargetPinNotDeclared: a stream to a pin the target does not +// declare is refused at the first write, while the target is being performed. +func testStreamingFlowTargetPinNotDeclared(t *testing.T) { + _, err := executeActionSource(t, "stream", streamingPair( + "action step { assign value := 1; }", "consumer.missing", + )) + if !errors.Is(err, ErrNodePin) { + t.Fatalf("error = %v, want ErrNodePin from a stream to an undeclared pin", err) + } +} + +// testStreamingFlowOneLateValueLeft: two values streamed after the target's performance +// ended; one further performance of the target takes the first, and the second, which no +// performance took, is reported when the action completes. +func testStreamingFlowOneLateValueLeft(t *testing.T) { + _, err := executeActionSource(t, "stream", `package test { + action stream { + attribute total : Integer = 0; + merge again; + action consumer { + in value : Integer; + assign total := total + 1; + } + action producer { + out value : Integer; + assign value := 1; + } + action second { + out value : Integer; + assign value := 2; + } + decide choose; + succession first start then again; + succession first again then consumer; + succession first consumer then choose; + if total == 1 then producer; + else done; + succession first producer then second; + succession first second then again; + flow producer.value to consumer.value; + flow second.value to consumer.value; + } + }`) + if !errors.Is(err, ErrStreamUnreceived) || !strings.Contains(err.Error(), "node consumer completed before node second wrote value") { + t.Fatalf("error = %v, want ErrStreamUnreceived for the late value no performance took", err) + } +} + +// testStreamingFlowCycle: two ongoing nodes stream into each other, so a write to one +// pin is carried back to it; that is refused rather than carried on without end. +func testStreamingFlowCycle(t *testing.T) { + _, err := executeActionSource(t, "stream", `package test { + action stream { + attribute n : Integer = 0; + fork split; + action a { + inout v : Integer; + action wait { assign n := n + 1; } + action write { assign v := 1; } + succession first start then wait; + succession first wait then write; + succession first write then done; + } + action b { + inout v : Integer; + action idle { assign n := n + 1; } + action linger { assign n := n + 1; } + succession first start then idle; + succession first idle then linger; + succession first linger then done; + } + join sync; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then done; + flow a.v to b.v; + flow b.v to a.v; + } + }`) + if !errors.Is(err, ErrStreamCycle) { + t.Fatalf("error = %v, want ErrStreamCycle", err) + } +} diff --git a/internal/exec/runtime/robustness_stub_action_test.go b/internal/exec/runtime/robustness_stub_action_test.go new file mode 100644 index 0000000000..8806da3b32 --- /dev/null +++ b/internal/exec/runtime/robustness_stub_action_test.go @@ -0,0 +1,240 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestRuntimeRobustnessStubAction exercises a declared action that computes +// nothing — parameters and no body, as a v1 call action naming no behavior +// migrates to — whose outputs a flow reads after it fires. +func TestRuntimeRobustnessStubAction(t *testing.T) { + t.Run("optional_output_flows_no_value", testStubActionOptionalOutputFlowsNoValue) + t.Run("required_output_never_written", testStubActionRequiredOutputNeverWritten) + t.Run("required_target_gets_no_value", testStubActionRequiredTargetGetsNoValue) + t.Run("optional_output_read_by_path", testStubActionOptionalOutputReadByPath) + t.Run("optional_result_read_as_value", testStubActionOptionalResultReadAsValue) + t.Run("required_result_read_as_value", testStubActionRequiredResultReadAsValue) + t.Run("bound_output_clears_the_other_end", testStubActionBoundOutputClearsTheOtherEnd) + t.Run("bound_output_clears_a_node_pin", testStubActionBoundOutputClearsANodePin) + t.Run("bound_output_clears_a_chained_end", testStubActionBoundOutputClearsAChainedEnd) + t.Run("bound_output_refused_by_a_required_end", testStubActionBoundOutputRefusedByARequiredEnd) +} + +// executeChain builds src with the standard libraries and executes its action `chain`. +func executeChain(t *testing.T, src string) (map[string]Value, error) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "chain", ast.DefAction) + if sym == nil { + t.Fatal("action chain not found") + } + return ctx.ExecuteAction(sym) +} + +// stubChain is an action performing a stub with `out reading : Integer` of the +// given multiplicity ahead of a consumer with `in value : Integer` of another, the +// stub's output flowing to the consumer's input. +func stubChain(outMult, inMult, consumerBody string) string { + return stubChainNamed("reading", outMult, inMult, consumerBody) +} + +// stubChainNamed is stubChain with the stub's output named out; `result` makes +// it what the stub stands for when read as a value. +func stubChainNamed(out, outMult, inMult, consumerBody string) string { + return `package test { + private import SequenceFunctions::*; + action chain { + attribute seen : Integer = -1; + action stub { + in gain : Integer = 2; + out ` + out + ` : Integer` + outMult + `; + } + action consumer { + in value : Integer` + inMult + `; + ` + consumerBody + ` + } + succession first start then stub; + succession first stub then consumer; + succession first consumer then done; + flow stub.` + out + ` to consumer.value; + } + }` +} + +// An output declared admitting no value that the stub never writes flows nothing: +// the consumer runs with the input empty and the action completes. +func testStubActionOptionalOutputFlowsNoValue(t *testing.T) { + outputs, err := executeChain(t, stubChain("[0..1]", "[0..1]", + "assign seen := value->size();")) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if got := outputs["seen"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Fatalf("seen = %v, want 0 from an empty input", got) + } +} + +// An output declared holding a value that the stub never writes is a flow source +// with nothing to carry, which the run reports rather than passing silently. +func testStubActionRequiredOutputNeverWritten(t *testing.T) { + _, err := executeChain(t, stubChain("", "[0..1]", + "assign seen := value->size();")) + if !errors.Is(err, ErrFlowSource) || !strings.Contains(err.Error(), "node stub produced no value at reading") { + t.Fatalf("error = %v, want ErrFlowSource from an output never written", err) + } +} + +// A consumer input declared holding a value gets none when the stub's optional +// output is empty, which reading it reports as no value rather than as empty. +func testStubActionRequiredTargetGetsNoValue(t *testing.T) { + _, err := executeChain(t, stubChain("[0..1]", "", + "assign seen := value;")) + var noValue *NoValueError + if !errors.As(err, &noValue) || noValue.Feature != "value" { + t.Fatalf("error = %v, want NoValueError for value", err) + } +} + +// A sibling reading the stub's empty optional output by path, `stub.reading`, +// reads the empty sequence rather than failing. +func testStubActionOptionalOutputReadByPath(t *testing.T) { + outputs, err := executeChain(t, stubChain("[0..1]", "[0..1]", + "assign seen := stub.reading->size() + value->size();")) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if got := outputs["seen"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Fatalf("seen = %v, want 0 from two empty reads", got) + } +} + +// Reading the stub itself as a value reads its result: an optional result left +// empty reads as the empty sequence, just as `stub.result` does. +func testStubActionOptionalResultReadAsValue(t *testing.T) { + outputs, err := executeChain(t, stubChainNamed("result", "[0..1]", "[0..1]", + "assign seen := stub->size() + stub.result->size();")) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if got := outputs["seen"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Fatalf("seen = %v, want 0 from two empty reads of the result", got) + } +} + +// A required result left empty is still no value when read through the stub itself. +func testStubActionRequiredResultReadAsValue(t *testing.T) { + _, err := executeChain(t, `package test { + private import SequenceFunctions::*; + action chain { + attribute seen : Integer = -1; + action stub { out result : Integer; } + action consumer { assign seen := stub->size(); } + succession first start then stub; + succession first stub then consumer; + succession first consumer then done; + } + }`) + var noValue *NoValueError + if !errors.As(err, &noValue) || !strings.HasSuffix(noValue.Feature, "stub.result") { + t.Fatalf("error = %v, want NoValueError for stub.result", err) + } +} + +// boundStub is an action binding the stub's empty optional output to `sample`, +// declared with the given multiplicity and initial value, then reading sample. +func boundStub(sampleDecl string) string { + return `package test { + private import SequenceFunctions::*; + action chain { + ` + sampleDecl + ` + attribute seen : Integer = -1; + action stub { out reading : Integer[0..1]; } + action consumer { assign seen := sample->size(); } + succession first start then stub; + succession first stub then consumer; + succession first consumer then done; + bind sample = stub.reading; + } + }` +} + +// A binding holds both ends to the same value, absence included: an enclosing +// feature bound to the stub's empty output gives up the value it held before. +func testStubActionBoundOutputClearsTheOtherEnd(t *testing.T) { + outputs, err := executeChain(t, boundStub("attribute sample : Integer[0..1] = 5;")) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if got := outputs["seen"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Fatalf("seen = %v, want 0: the binding cleared sample", got) + } + if got, held := outputs["sample"]; !held || got.Sequence() == nil || got.Sequence().Size() != 0 { + t.Fatalf("sample = %v, want the empty sequence the binding carried", got) + } +} + +// The same through a binding to a sibling node's pin: the empty output is delivered +// ahead of the node like any bound value, so its pin reads empty, not its default. +func testStubActionBoundOutputClearsANodePin(t *testing.T) { + outputs, err := executeChain(t, `package test { + private import SequenceFunctions::*; + action chain { + attribute seen : Integer = -1; + action stub { out reading : Integer[0..1]; } + action consumer { + in value : Integer[0..1] = 5; + assign seen := value->size(); + } + succession first start then stub; + succession first stub then consumer; + succession first consumer then done; + bind consumer.value = stub.reading; + } + }`) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if got := outputs["seen"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Fatalf("seen = %v, want 0: the binding cleared consumer.value", got) + } + if got, held := outputs["consumer.value"]; !held || got.Sequence() == nil || got.Sequence().Size() != 0 { + t.Fatalf("consumer.value = %v, want the empty sequence the binding delivered", got) + } +} + +// The same through a feature chain: the attribute of a part the stub's output is bound +// to gives up its initial value when the stub ends holding none. +func testStubActionBoundOutputClearsAChainedEnd(t *testing.T) { + outputs, err := executeChain(t, `package test { + private import SequenceFunctions::*; + action chain { + attribute seen : Integer = -1; + part holder { attribute sample : Integer[0..1] = 5; } + action stub { out reading : Integer[0..1]; } + action consumer { assign seen := holder.sample->size(); } + succession first start then stub; + succession first stub then consumer; + succession first consumer then done; + bind holder.sample = stub.reading; + } + }`) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if got := outputs["seen"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Fatalf("seen = %v, want 0: the binding cleared holder.sample", got) + } +} + +// An end declared holding a value cannot be held to an absence: binding it to the +// stub's empty output is a multiplicity violation, not a silently kept value. +func testStubActionBoundOutputRefusedByARequiredEnd(t *testing.T) { + _, err := executeChain(t, boundStub("attribute sample : Integer = 5;")) + if !errors.Is(err, ErrMultiplicityViolation) || !strings.Contains(err.Error(), "sample") { + t.Fatalf("error = %v, want ErrMultiplicityViolation for sample", err) + } +} diff --git a/internal/exec/runtime/robustness_terminate_block_test.go b/internal/exec/runtime/robustness_terminate_block_test.go new file mode 100644 index 0000000000..f62b02b4db --- /dev/null +++ b/internal/exec/runtime/robustness_terminate_block_test.go @@ -0,0 +1,105 @@ +package runtime + +import ( + "errors" + "testing" +) + +// TestRuntimeRobustnessTerminateBlock exercises the edges of `terminate` in a +// braced entry/do/exit block, which the terminate ends as one performance. +func TestRuntimeRobustnessTerminateBlock(t *testing.T) { + t.Run("terminate_as_the_only_statement_of_a_block", testTerminateAsTheOnlyStatementOfABlock) + t.Run("second_terminate_of_an_ended_block_never_runs", testSecondTerminateOfAnEndedBlockNeverRuns) + t.Run("terminate_in_a_loop_ends_the_block_once", testTerminateInALoopEndsTheBlockOnce) + t.Run("terminate_of_a_state_name_in_a_block", testTerminateOfAStateNameInABlock) +} + +// A block made of one `terminate;` has nothing else to cut; the state stays +// active and its do block still runs. +func testTerminateAsTheOnlyStatementOfABlock(t *testing.T) { + outputs, visits, err := executeStateSource(t, "Machine", `package test { + private import ScalarValues::*; + state def Machine { + attribute d : Integer = 0; + entry; then s; + state s { + entry { terminate; } + do { assign d := 1; } + exit { terminate; } + } + } + }`) + if err != nil { + t.Fatalf("error = %v, want none", err) + } + if got := outputs["d"].Const.Int; got != 1 { + t.Fatalf("d = %d, want 1", got) + } + if len(visits) != 1 || visits[0] != "s" { + t.Fatalf("visits = %v, want [s]", visits) + } +} + +// The first `terminate;` ends the block, so a second one written after it is +// never reached: the block's performance is not ended twice. +func testSecondTerminateOfAnEndedBlockNeverRuns(t *testing.T) { + outputs, _, err := executeStateSource(t, "Machine", `package test { + private import ScalarValues::*; + state def Machine { + attribute e : Integer = 0; + entry; then s; + state s { + entry { assign e := 1; terminate; assign e := 5; terminate; assign e := 9; } + } + } + }`) + if err != nil { + t.Fatalf("error = %v, want none", err) + } + if got := outputs["e"].Const.Int; got != 1 { + t.Fatalf("e = %d, want 1", got) + } +} + +// A `terminate;` in a loop of a braced block ends the block at its first +// iteration; later iterations and the statements after the loop do not run. +func testTerminateInALoopEndsTheBlockOnce(t *testing.T) { + outputs, _, err := executeStateSource(t, "Machine", `package test { + private import ScalarValues::*; + state def Machine { + attribute e : Integer = 0; + entry; then s; + state s { + entry { + assign e := 1; + while e < 3 { assign e := e + 1; terminate; } + assign e := 9; + } + } + } + }`) + if err != nil { + t.Fatalf("error = %v, want none", err) + } + if got := outputs["e"].Const.Int; got != 2 { + t.Fatalf("e = %d, want 2", got) + } +} + +// `terminate s;` naming the state whose block it stands in names no performance +// a terminate can end, in a braced block as in a named action. +func testTerminateOfAStateNameInABlock(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + private import ScalarValues::*; + state def Machine { + attribute e : Integer = 0; + entry; then s; + state s { + entry { assign e := 1; terminate s; assign e := 9; } + } + } + }`) + if !errors.Is(err, ErrTerminateTarget) { + t.Fatalf("error = %v, want ErrTerminateTarget", err) + } +} diff --git a/internal/exec/runtime/robustness_terminate_test.go b/internal/exec/runtime/robustness_terminate_test.go new file mode 100644 index 0000000000..b9e69d50aa --- /dev/null +++ b/internal/exec/runtime/robustness_terminate_test.go @@ -0,0 +1,318 @@ +package runtime + +import ( + "errors" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestRuntimeRobustnessTerminate exercises the shapes a `terminate` refuses: each is +// a typed error naming what could not be ended, never a silent no-op or a panic. +// A calculation's refusal of `terminate` stays with the shared cases. +func TestRuntimeRobustnessTerminate(t *testing.T) { + t.Run("terminate_of_an_ended_performance", testTerminateOfAnEndedPerformance) + t.Run("terminate_of_an_unknown_name", testTerminateOfAnUnknownName) + t.Run("terminate_of_a_non_action_feature", testTerminateOfANonActionFeature) + t.Run("terminate_of_a_literal_value", testTerminateOfALiteralValue) + t.Run("terminate_of_a_qualified_occurrence", testTerminateOfAQualifiedOccurrence) + t.Run("terminate_of_a_destroyed_occurrence", testTerminateOfADestroyedOccurrence) + t.Run("terminate_of_a_part_reached_after_its_whole_ended", testTerminateOfAPartReachedAfterItsWholeEnded) + t.Run("terminate_of_an_occurrence_expression", testTerminateOfAnOccurrenceExpression) + t.Run("terminate_of_a_node_of_a_sibling_flow", testTerminateOfANodeOfASiblingFlow) + t.Run("terminate_usage_stating_a_flow_of_its_own", testTerminateUsageStatingAFlowOfItsOwn) + t.Run("terminate_of_an_unknown_name_in_a_state_body", testTerminateOfAnUnknownNameInAStateBody) + t.Run("terminate_of_an_ended_occurrence_in_a_state_body", testTerminateOfAnEndedOccurrenceInAStateBody) + t.Run("terminate_of_a_value_in_a_transition_effect", testTerminateOfAValueInATransitionEffect) + t.Run("behavior_of_an_ended_performer", testBehaviorOfAnEndedPerformer) +} + +// testTerminateOfAnEndedPerformance: `terminate c1;` after c1 completed names a +// performance that has already ended, which is reported rather than ended twice. +func testTerminateOfAnEndedPerformance(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + private import ScalarValues::*; + action host { + out attribute x : Integer = 0; + first start; + then action c1 { assign x := 1; } + then action c2 { terminate c1; } + then done; + } + }`) + if !errors.Is(err, ErrPerformanceEnded) { + t.Fatalf("error = %v, want ErrPerformanceEnded", err) + } +} + +// testTerminateOfAnUnknownName: a terminate naming nothing declared around it +// has no performance to end. +func testTerminateOfAnUnknownName(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + action host { + first start; + then action c1 { terminate nowhere; } + then done; + } + }`) + if !errors.Is(err, ErrTerminateTarget) { + t.Fatalf("error = %v, want ErrTerminateTarget", err) + } +} + +// testTerminateUsageStatingAFlowOfItsOwn: a terminate action usage whose body states +// a flow of its own is refused at initialize, not run with the flow dropped. +func testTerminateUsageStatingAFlowOfItsOwn(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + private import ScalarValues::*; + action host { + out attribute x : Integer = 0; + first start; + then stop; + action stop terminate { + first start; + then action inner { assign x := 1; } + then done; + } + then done; + } + }`) + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("error = %v, want ErrInvalidActionFlow", err) + } +} + +// testTerminateOfANonActionFeature: a terminate naming a feature that is no action +// node names an occurrence, which an action body does not end yet. +func testTerminateOfANonActionFeature(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + private import ScalarValues::*; + action host { + out attribute x : Integer = 0; + first start; + then action c1 { terminate x; } + then done; + } + }`) + if !errors.Is(err, ErrTerminateOccurrence) { + t.Fatalf("error = %v, want ErrTerminateOccurrence", err) + } +} + +// testTerminateOfAQualifiedOccurrence: a qualified name reaching an occurrence that is +// no action node ends that occurrence; naming it again is the occurrence refusal. +func testTerminateOfAQualifiedOccurrence(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + part def V; + part victim : V; + action host { + first start; + then action c1 { terminate test::victim; } + then action c2 { terminate test::victim; } + then done; + } + }`) + if !errors.Is(err, ErrTerminateOccurrence) || !errors.Is(err, ErrOccurrenceLifetime) { + t.Fatalf("error = %v, want ErrTerminateOccurrence wrapping ErrOccurrenceLifetime", err) + } +} + +// testTerminateOfAnOccurrenceExpression: a terminate whose target expression names +// no occurrence where it is stated — `this` in a body no object performs — ends nothing. +func testTerminateOfAnOccurrenceExpression(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + action host { + first start; + then action c1 { terminate this.self; } + then done; + } + }`) + if !errors.Is(err, ErrTerminateTarget) { + t.Fatalf("error = %v, want ErrTerminateTarget", err) + } +} + +// testTerminateOfANodeOfASiblingFlow: a terminate names action nodes of the flows +// around it only; a node nested in a sibling branch is out of its reach. +func testTerminateOfANodeOfASiblingFlow(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + private import ScalarValues::*; + action host { + fork split; + action slow { + action inner { + accept after 10 [s]; + assign x := 1; + } + } + action killer { terminate inner; } + join sync; + attribute x : Integer = 0; + succession first start then split; + succession first split then slow; + succession first split then killer; + succession first slow then sync; + succession first killer then sync; + succession first sync then done; + } + }`) + if !errors.Is(err, ErrTerminateTarget) { + t.Fatalf("error = %v, want ErrTerminateTarget", err) + } +} + +// testTerminateOfALiteralValue: a terminate whose expression evaluates to a value +// that is no occurrence has nothing whose lifetime could end. +func testTerminateOfALiteralValue(t *testing.T) { + _, err := executeActionSource(t, "host", `package test { + action host { + first start; + then action c1 { terminate 3 + 4; } + then done; + } + }`) + if !errors.Is(err, ErrTerminateOccurrence) || !errors.Is(err, ErrNotAnOccurrence) { + t.Fatalf("error = %v, want ErrTerminateOccurrence wrapping ErrNotAnOccurrence", err) + } +} + +// testTerminateOfADestroyedOccurrence: an occurrence `destroy` ended is refused as +// destroyed, not ended a second time. +func testTerminateOfADestroyedOccurrence(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, `package test { + private import OccurrenceFunctions::*; + part def V; + action host { + part victim : V; + first start; + then action c1 { assign victim := destroy(victim); } + then action c2 { terminate victim; } + then done; + } + }`)) + _, err := ctx.ExecuteAction(findSymbolByName(idx.DocumentRoot(""), "host", ast.DefAction)) + if !errors.Is(err, ErrTerminateOccurrence) || !errors.Is(err, ErrOccurrenceDestroyed) { + t.Fatalf("error = %v, want ErrTerminateOccurrence wrapping ErrOccurrenceDestroyed", err) + } +} + +// testTerminateOfAPartReachedAfterItsWholeEnded: a part first read after `terminate` +// ended its whole ended with the whole, so terminating it is refused as ended already. +func testTerminateOfAPartReachedAfterItsWholeEnded(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, `package test { + private import ScalarValues::*; + part def Engine { attribute rate : Integer = 60; } + part def Body { part engine : Engine[0..1] { :>> rate = 50; } } + part patient : Body; + action host { + out attribute rate : Integer = 0; + first start; + then action c1 { terminate patient; } + then action c2 assign rate := patient.engine.rate; + then action c3 { terminate patient.engine; } + then done; + } + }`)) + _, err := ctx.ExecuteAction(findSymbolByName(idx.DocumentRoot(""), "host", ast.DefAction)) + if !errors.Is(err, ErrTerminateOccurrence) || !errors.Is(err, ErrOccurrenceLifetime) { + t.Fatalf("error = %v, want ErrTerminateOccurrence wrapping ErrOccurrenceLifetime", err) + } + whole, ok := ctx.OccurrenceLife(1) + if !ok || whole.Ended == 0 { + t.Fatalf("whole's life = %v, %v; want ended", whole, ok) + } + part, ok := ctx.OccurrenceLife(2) + if !ok || part.Began != whole.Began || part.Ended != whole.Ended || part.Destroyed { + t.Fatalf("part's life = %v, %v; want the whole's, %v", part, ok, whole) + } +} + +// testTerminateOfAnUnknownNameInAStateBody: a state's entry behavior terminating a +// name nothing declares is the same refusal an action body gives. +func testTerminateOfAnUnknownNameInAStateBody(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state def Machine { + entry; then s; + state s { entry { terminate nowhere; } } + } + }`) + if !errors.Is(err, ErrTerminateTarget) { + t.Fatalf("error = %v, want ErrTerminateTarget", err) + } +} + +// testTerminateOfAnEndedOccurrenceInAStateBody: a do behavior ending an occurrence +// that ended already is refused by the occurrence's lifetime, in the state body too. +func testTerminateOfAnEndedOccurrenceInAStateBody(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + part def V; + part victim : V; + state def Machine { + entry; then s; + state s { + do action twice { + first start; + then action once { terminate test::victim; } + then action again { terminate test::victim; } + then done; + } + } + } + }`) + if !errors.Is(err, ErrTerminateOccurrence) || !errors.Is(err, ErrOccurrenceLifetime) { + t.Fatalf("error = %v, want ErrTerminateOccurrence wrapping ErrOccurrenceLifetime", err) + } +} + +// testTerminateOfAValueInATransitionEffect: a transition effect terminating an +// attribute names a value, which no lifetime ends. +func testTerminateOfAValueInATransitionEffect(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package test { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute n : Integer = 0; + entry; then s; + state s; + transition first s accept Go do action { terminate n; } then done; + } + }`)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + _, _, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + if !errors.Is(err, ErrTerminateOccurrence) || !errors.Is(err, ErrNotAnOccurrence) { + t.Fatalf("error = %v, want ErrTerminateOccurrence wrapping ErrNotAnOccurrence", err) + } +} + +// testBehaviorOfAnEndedPerformer: an object a terminate ended performs nothing +// further; a behavior begun for it afterwards is refused at creation. +func testBehaviorOfAnEndedPerformer(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package test { + private import ScalarValues::*; + part def Probe { + attribute n : Integer = 0; + action bump { assign n := n + 1; } + } + part p : Probe; + action reaper { first start; then action kill { terminate test::p; } then done; } + }`)) + p := idx.LookupQualified("test::p") + if len(p) != 1 { + t.Fatalf("test::p: %d matching symbols, want 1", len(p)) + } + inst, err := ctx.Instantiate(p[0]) + if err != nil { + t.Fatalf("Instantiate(p): %v", err) + } + if _, err := ctx.ExecuteAction(findSymbolByName(idx.DocumentRoot(""), "reaper", ast.DefAction)); err != nil { + t.Fatalf("reaper: %v", err) + } + bump := idx.LookupQualified("test::Probe::bump") + if len(bump) != 1 { + t.Fatalf("test::Probe::bump: %d matching symbols, want 1", len(bump)) + } + _, err = ctx.CreateActionExecutorFor(bump[0], inst) + if !errors.Is(err, ErrOccurrenceLifetime) { + t.Fatalf("error = %v, want ErrOccurrenceLifetime", err) + } +} diff --git a/internal/exec/runtime/robustness_test.go b/internal/exec/runtime/robustness_test.go new file mode 100644 index 0000000000..bb9d1332dc --- /dev/null +++ b/internal/exec/runtime/robustness_test.go @@ -0,0 +1,17216 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "math" + "reflect" + "strings" + "testing" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +// TestRuntimeRobustness exercises failure modes: graceful errors, no panics, no hangs. +// Each test must return a typed error, never panic or hang. + +func TestRuntimeRobustness(t *testing.T) { + t.Run("deadlock_join_starvation", testDeadlockJoinStarvation) + t.Run("deadlock_join_same_succession_twice", testDeadlockJoinSameSuccessionTwice) + t.Run("nested_flow_without_an_initial_node", testNestedFlowWithoutAnInitialNode) + t.Run("nested_flow_with_a_dangling_succession", testNestedFlowWithADanglingSuccession) + t.Run("nested_flow_that_cannot_progress", testNestedFlowThatCannotProgress) + t.Run("nested_flow_that_never_ends", testNestedFlowThatNeverEnds) + t.Run("node_pin_of_a_node_not_yet_performed", testNodePinOfANodeNotYetPerformed) + t.Run("node_pin_the_node_does_not_declare", testNodePinTheNodeDoesNotDeclare) + t.Run("node_read_as_a_value_without_a_result", testNodeReadAsAValueWithoutAResult) + t.Run("settled_node_read_as_a_value_by_another_candidates_result", testSettledNodeReadAsAValueByAnotherCandidatesResult) + t.Run("node_pin_member_through_a_scalar_pin", testNodePinMemberThroughAScalarPin) + t.Run("block_node_pin_of_a_node_not_yet_performed", testBlockNodePinOfANodeNotYetPerformed) + t.Run("block_node_pin_the_node_does_not_declare", testBlockNodePinTheNodeDoesNotDeclare) + t.Run("else_branch_node_read_before_it_performs", testElseBranchNodeReadBeforeItPerforms) + t.Run("typed_node_pin_of_a_node_the_callee_does_not_declare", testTypedNodePinOfANodeTheCalleeDoesNotDeclare) + t.Run("node_invocation_too_many_arguments", testNodeInvocationTooManyArguments) + t.Run("node_invocation_too_few_arguments", testNodeInvocationTooFewArguments) + t.Run("node_invocation_unknown_named_argument", testNodeInvocationUnknownNamedArgument) + t.Run("node_invocation_repeated_named_argument", testNodeInvocationRepeatedNamedArgument) + t.Run("node_invocation_argument_fails_before_defaults", testNodeInvocationArgumentFailsBeforeDefaults) + t.Run("performed_action_input_bound_by_nothing", testPerformedActionInputBoundByNothing) + t.Run("state_entry_action_input_bound_by_nothing", testStateEntryActionInputBoundByNothing) + t.Run("state_block_typed_node_input_bound_by_nothing", testStateBlockTypedNodeInputBoundByNothing) + t.Run("state_block_node_unvalued_pin_write_checked", testStateBlockNodeUnvaluedPinWriteChecked) + t.Run("state_block_node_pin_read_before_performed", testStateBlockNodePinReadBeforePerformed) + t.Run("state_block_node_bound_at_no_pin", testStateBlockNodeBoundAtNoPin) + t.Run("state_block_node_own_flow_runs", testStateBlockNodeOwnFlowRuns) + t.Run("state_do_body_dangling_succession", testStateDoBodyDanglingSuccession) + t.Run("state_do_body_first_then_undefined", testStateDoBodyFirstThenUndefined) + t.Run("state_do_body_flow_without_start", testStateDoBodyFlowWithoutStart) + t.Run("state_do_body_flow_with_two_starts", testStateDoBodyFlowWithTwoStarts) + t.Run("state_do_body_starts_at_its_unpreceded_step", testStateDoBodyStartsAtItsUnprecededStep) + t.Run("action_flow_starts_at_its_unpreceded_step", testActionFlowStartsAtItsUnprecededStep) + t.Run("action_flow_with_two_starts", testActionFlowWithTwoStarts) + t.Run("action_flow_cycle_without_start", testActionFlowCycleWithoutStart) + t.Run("state_do_body_nested_node_dangling_succession", testStateDoBodyNestedNodeDanglingSuccession) + t.Run("state_do_body_nested_node_starts_at_its_unpreceded_step", testStateDoBodyNestedNodeStartsAtItsUnprecededStep) + t.Run("action_nested_node_starts_at_its_unpreceded_step", testActionNestedNodeStartsAtItsUnprecededStep) + t.Run("action_nested_node_with_two_starts", testActionNestedNodeWithTwoStarts) + t.Run("state_entry_body_dangling_succession", testStateEntryBodyDanglingSuccession) + t.Run("state_do_body_accept_waits_for_the_message", testStateDoBodyAcceptWaitsForTheMessage) + t.Run("state_do_body_accept_is_decided_for_a_send", testStateDoBodyAcceptIsDecidedForASend) + t.Run("state_do_body_accept_yields_to_a_transition", testStateDoBodyAcceptYieldsToATransition) + t.Run("state_do_body_accept_goes_on_across_a_substate_transition", testStateDoBodyAcceptGoesOnAcrossASubstateTransition) + t.Run("state_do_body_accept_yields_to_a_substate_transition_leaving_it", testStateDoBodyAcceptYieldsToASubstateTransitionLeavingIt) + t.Run("state_do_body_accept_follows_the_transition_chosen", testStateDoBodyAcceptFollowsTheTransitionChosen) + t.Run("state_do_body_accept_yields_to_an_open_choice", testStateDoBodyAcceptYieldsToAnOpenChoice) + t.Run("state_do_body_accept_yields_to_a_transition_into_its_region", testStateDoBodyAcceptYieldsToATransitionIntoItsRegion) + t.Run("state_do_body_accept_runs_before_the_choice_reads", testStateDoBodyAcceptRunsBeforeTheChoiceReads) + t.Run("state_choice_route_reads_the_accepted_payload", testStateChoiceRouteReadsTheAcceptedPayload) + t.Run("state_do_body_accept_shares_the_dispatch_with_a_region", testStateDoBodyAcceptSharesTheDispatchWithARegion) + t.Run("state_do_body_accept_shares_the_dispatch_with_a_fork_in_a_region", testStateDoBodyAcceptSharesTheDispatchWithAForkInARegion) + t.Run("state_do_body_nested_accept_cancelled_on_exit", testStateDoBodyNestedAcceptCancelledOnExit) + t.Run("state_do_typed_action_input_unbound", testStateDoTypedActionInputUnbound) + t.Run("state_do_typed_action_pin_bound_to_missing_feature", testStateDoTypedActionPinBoundToMissingFeature) + t.Run("state_do_typed_action_inout_valued_by_an_imported_literal", testStateDoTypedActionInoutValuedByAnImportedLiteral) + t.Run("state_entry_body_waits_for_the_clock", testStateEntryBodyWaitsForTheClock) + t.Run("state_exit_body_waits_for_the_clock", testStateExitBodyWaitsForTheClock) + t.Run("transition_effect_body_waits_for_the_clock", testTransitionEffectBodyWaitsForTheClock) + t.Run("state_do_body_flow_that_never_ends", testStateDoBodyFlowThatNeverEnds) + t.Run("state_do_body_node_return_parameter", testStateDoBodyNodeReturnParameter) + t.Run("state_do_body_return_parameter", testStateDoBodyReturnParameter) + t.Run("calc_block_node_unvalued_pin_write_checked", testCalcBlockNodeUnvaluedPinWriteChecked) + t.Run("node_binding_to_a_non_parameter", testNodeBindingToANonParameter) + t.Run("node_undirected_binding_carried_to_a_non_parameter", testNodeUndirectedBindingCarriedToANonParameter) + t.Run("node_pin_bound_to_unequal_values", testNodePinBoundToUnequalValues) + t.Run("node_output_bound_to_a_nested_node_that_never_runs", testNodeOutputBoundToANestedNodeThatNeverRuns) + t.Run("block_node_binding_to_a_non_parameter", testBlockNodeBindingToANonParameter) + t.Run("block_node_binding_names_a_node_without_a_pin", testBlockNodeBindingNamesANodeWithoutAPin) + t.Run("block_node_pin_bound_where_nodes_are_not_performed", testBlockNodePinBoundWhereNodesAreNotPerformed) + t.Run("block_node_own_flow_malformed", testBlockNodeOwnFlowMalformed) + t.Run("block_node_own_flow_where_nodes_are_not_performed", testBlockNodeOwnFlowWhereNodesAreNotPerformed) + t.Run("block_node_own_flow_that_never_ends", testBlockNodeOwnFlowThatNeverEnds) + t.Run("inherited_binding_names_a_node_without_a_pin", testInheritedBindingNamesANodeWithoutAPin) + t.Run("inherited_binding_does_not_reach_a_masking_node", testInheritedBindingDoesNotReachAMaskingNode) + t.Run("inherited_binding_does_not_reach_through_a_replaced_other_end", testInheritedBindingDoesNotReachThroughAReplacedOtherEnd) + t.Run("node_inherited_default_that_cannot_be_evaluated", testNodeInheritedDefaultThatCannotBeEvaluated) + t.Run("node_binding_output_to_an_unknown_feature", testNodeBindingOutputToAnUnknownFeature) + t.Run("node_binding_output_through_a_scalar_chain", testNodeBindingOutputThroughAScalarChain) + t.Run("node_binding_output_through_a_chain_violates_target_type", testNodeBindingOutputThroughAChainViolatesTargetType) + t.Run("nested_pin_binding_into_a_node_performing_another_action", testNestedPinBindingIntoANodePerformingAnotherAction) + t.Run("nested_pin_binding_at_an_undeclared_pin", testNestedPinBindingAtAnUndeclaredPin) + t.Run("flow_reaching_into_a_nodes_own_flow", testFlowReachingIntoANodesOwnFlow) + t.Run("node_flow_into_a_pin_the_target_does_not_declare", testNodeFlowIntoAPinTheTargetDoesNotDeclare) + t.Run("fork_without_a_successor", testForkWithoutASuccessor) + t.Run("explicit_succession_missing_endpoint", testExplicitSuccessionMissingEndpoint) + t.Run("control_flow_missing_endpoint", testControlFlowMissingEndpoint) + t.Run("merge_without_a_successor", testMergeWithoutASuccessor) + t.Run("unguarded_loop_through_a_merge", testUnguardedLoopThroughAMerge) + t.Run("action_whose_last_node_has_no_succession", testActionWhoseLastNodeHasNoSuccession) + t.Run("first_node_with_a_second_succession", testFirstNodeWithASecondSuccession) + t.Run("first_beside_an_initial_node", testFirstBesideAnInitialNode) + t.Run("two_one_ended_firsts", testTwoOneEndedFirsts) + t.Run("first_naming_a_final_node", testFirstNamingAFinalNode) + t.Run("fork_branches_assigning_the_same_feature", testForkBranchesAssigningTheSameFeature) + t.Run("decision_no_satisfied_guard", testDecisionNoSatisfiedGuard) + t.Run("decision_all_guards_false", testDecisionAllGuardsFalse) + t.Run("state_dangling_transition", testStateDanglingTransition) + t.Run("state_transition_endpoint_misspelled", testStateTransitionEndpointMisspelled) + t.Run("state_transition_endpoint_in_another_machine", testStateTransitionEndpointInAnotherMachine) + t.Run("state_transition_endpoint_never_resolved", testStateTransitionEndpointNeverResolved) + t.Run("state_transition_endpoint_naming_a_first_marker", testStateTransitionEndpointNamingAFirstMarker) + t.Run("state_junction_without_an_outgoing_transition", testStateJunctionWithoutAnOutgoingTransition) + t.Run("state_choice_without_an_enabled_branch", testStateChoiceWithoutAnEnabledBranch) + t.Run("state_event_after_completion", testStateEventAfterCompletion) + t.Run("state_completion_rests_in_done", testStateCompletionRestsInDone) + t.Run("state_nested_region_completion_keeps_siblings_running", testStateNestedRegionCompletionKeepsSiblingsRunning) + t.Run("state_transition_without_a_target", testStateTransitionWithoutATarget) + t.Run("state_transition_effect_reads_an_unknown_feature", testStateTransitionEffectReadsAnUnknownFeature) + t.Run("state_cross_region_transitions_ping_pong", testStateCrossRegionTransitionsPingPong) + t.Run("parallel_state_body_unsupported_member", testParallelStateBodyUnsupportedMember) + t.Run("parallel_state_region_without_initial", testParallelStateRegionWithoutInitial) + t.Run("parallel_state_region_itself_parallel", testParallelStateRegionItselfParallel) + t.Run("state_usage_typed_by_itself", testStateUsageTypedByItself) + t.Run("state_usage_mutually_recursive_typing", testStateUsageMutuallyRecursiveTyping) + t.Run("state_def_specializing_the_library_state_action", testStateDefSpecializingTheLibraryStateAction) + t.Run("state_def_specializing_a_library_state_keeps_its_content", testStateDefSpecializingALibraryStateKeepsItsContent) + t.Run("exhibited_state_typed_by_the_library_state_action", testExhibitedStateTypedByTheLibraryStateAction) + t.Run("exhibited_state_typed_by_the_library_state_action_with_a_body", testExhibitedStateTypedByTheLibraryStateActionWithABody) + t.Run("exhibited_state_typed_by_a_state_action_specialization", testExhibitedStateTypedByAStateActionSpecialization) + t.Run("state_usage_inherits_unsupported_member", testStateUsageInheritsUnsupportedMember) + t.Run("run_to_completion_redefined_false", testRunToCompletionRedefinedFalse) + t.Run("run_to_completion_scope_narrowed", testRunToCompletionScopeNarrowed) + t.Run("run_to_completion_redefined_by_specialized_def", testRunToCompletionRedefinedBySpecializedDef) + t.Run("run_to_completion_redefined_in_orthogonal_region", testRunToCompletionRedefinedInOrthogonalRegion) + t.Run("run_to_completion_redefined_undecidably", testRunToCompletionRedefinedUndecidably) + t.Run("run_to_completion_redefined_through_alias", testRunToCompletionRedefinedThroughAlias) + t.Run("run_to_completion_redefined_through_redefining_feature", testRunToCompletionRedefinedThroughRedefiningFeature) + t.Run("run_to_completion_model_feature_under_library_name", testRunToCompletionModelFeatureUnderLibraryName) + t.Run("run_to_completion_defaults_restated", testRunToCompletionDefaultsRestated) + t.Run("run_to_completion_default_restored_by_specialization", testRunToCompletionDefaultRestoredBySpecialization) + t.Run("run_to_completion_default_masked_by_specialization", testRunToCompletionDefaultMaskedBySpecialization) + t.Run("sourceless_transition_with_nothing_before", testSourcelessTransitionWithNothingBefore) + t.Run("sourceless_transition_after_a_non_state", testSourcelessTransitionAfterANonState) + t.Run("no_entry_transition_guard_holds", testNoEntryTransitionGuardHolds) + t.Run("entry_transition_target_is_not_a_state", testEntryTransitionTargetIsNotAState) + t.Run("entry_transition_carries_a_trigger", testEntryTransitionCarriesATrigger) + t.Run("entry_transition_into_done_completes_at_initialize", testEntryTransitionIntoDoneCompletesAtInitialize) + t.Run("named_entry_action_transition_into_done_completes_at_initialize", testNamedEntryActionTransitionIntoDoneCompletesAtInitialize) + t.Run("own_entry_transitions_replace_inherited_ones", testOwnEntryTransitionsReplaceInheritedOnes) + t.Run("region_entry_transitions_into_done_complete_at_initialize", testRegionEntryTransitionsIntoDoneCompleteAtInitialize) + t.Run("nested_regions_into_done_complete_at_initialize", testNestedRegionsIntoDoneCompleteAtInitialize) + t.Run("nested_regions_into_done_without_completion_transition_stay_active", testNestedRegionsIntoDoneWithoutCompletionTransitionStayActive) + t.Run("transition_into_nested_regions_in_done_completes", testTransitionIntoNestedRegionsInDoneCompletes) + t.Run("region_start_descends_through_entry_transitions", testRegionStartDescendsThroughEntryTransitions) + t.Run("region_entry_guards_read_the_region_state_attributes", testRegionEntryGuardsReadTheRegionStateAttributes) + t.Run("leaving_regions_descends_through_entry_transitions", testLeavingRegionsDescendsThroughEntryTransitions) + t.Run("calc_unbound_parameter", testCalcUnboundParameter) + t.Run("calc_calls_an_unimported_extension_function", testCalcCallsAnUnimportedExtensionFunction) + t.Run("calc_calls_an_unimported_library_function", testCalcCallsAnUnimportedLibraryFunction) + t.Run("calc_unbound_keyword_named_parameter", testCalcUnboundKeywordNamedParameter) + t.Run("calc_too_many_arguments", testCalcTooManyArguments) + t.Run("calc_unknown_named_argument", testCalcUnknownNamedArgument) + t.Run("calc_parameter_named_twice", testCalcParameterNamedTwice) + t.Run("calc_without_result", testCalcWithoutResult) + t.Run("calc_states_second_result", testCalcStatesSecondResult) + t.Run("calc_body_states_two_results", testCalcBodyStatesTwoResults) + t.Run("calc_symbol_is_not_a_calc", testCalcSymbolIsNotACalc) + t.Run("calc_direct_recursion", testCalcDirectRecursion) + t.Run("calc_mutual_recursion", testCalcMutualRecursion) + t.Run("calc_default_recursion", testCalcDefaultRecursion) + t.Run("calc_library_default_recursion", testCalcLibraryDefaultRecursion) + t.Run("calc_library_default_failure_names_one_frame", testCalcLibraryDefaultFailureNamesOneFrame) + t.Run("calc_recursion_spends_step_budget", testCalcRecursionSpendsStepBudget) + t.Run("calc_recursion_at_depth_ceiling", testCalcRecursionAtDepthCeiling) + t.Run("calc_non_terminating_loop", testCalcNonTerminatingLoop) + t.Run("calc_body_never_returns", testCalcBodyNeverReturns) + t.Run("calc_send_is_rejected", testCalcSendIsRejected) + t.Run("calc_terminate_is_rejected", testCalcTerminateIsRejected) + t.Run("calc_assignment_outside_the_calc", testCalcAssignmentOutsideTheCalc) + t.Run("assign_chain_unknown_final_feature", testAssignChainUnknownFinalFeature) + t.Run("assign_chain_step_is_not_an_object", testAssignChainStepIsNotAnObject) + t.Run("assign_chain_step_holds_many_objects", testAssignChainStepHoldsManyObjects) + t.Run("assign_chain_multiplicity_violation", testAssignChainMultiplicityViolation) + t.Run("assign_chain_unset_step", testAssignChainUnsetStep) + t.Run("assign_chain_unreachable_base", testAssignChainUnreachableBase) + t.Run("assign_chain_rejected_in_calc_body", testAssignChainRejectedInCalcBody) + t.Run("calc_non_boolean_condition", testCalcNonBooleanCondition) + t.Run("calc_usage_unbound_input", testCalcUsageUnboundInput) + t.Run("calc_usage_unknown_output", testCalcUsageUnknownOutput) + t.Run("calc_usage_cyclic_outputs", testCalcUsageCyclicOutputs) + t.Run("calc_usage_specializes_a_non_calc", testCalcUsageSpecializesANonCalc) + t.Run("calc_usage_step_budget", testCalcUsageStepBudget) + t.Run("calc_usage_output_without_a_value", testCalcUsageOutputWithoutAValue) + t.Run("calc_output_never_assigned_by_the_body", testCalcOutputNeverAssignedByTheBody) + t.Run("calc_output_assigned_in_a_branch_not_taken", testCalcOutputAssignedInABranchNotTaken) + t.Run("calc_output_valued_and_assigned", testCalcOutputValuedAndAssigned) + t.Run("calc_output_assigned_twice", testCalcOutputAssignedTwice) + t.Run("calc_output_binding_violates_declared_type", testCalcOutputBindingViolatesDeclaredType) + t.Run("operation_constraint_body_cannot_be_evaluated", testOperationConstraintBodyCannotBeEvaluated) + t.Run("binding_conflict", testBindingConflict) + t.Run("binding_collection_conflicts_do_not_use_hashes", testBindingCollectionConflictsDoNotUseHashes) + t.Run("binding_multiple_scalar_contributors", testBindingMultipleScalarContributors) + t.Run("binding_multiple_collection_contributors", testBindingMultipleCollectionContributors) + t.Run("binding_propagation_spends_element_budget", testBindingPropagationSpendsElementBudget) + t.Run("binding_distinct_materialized_objects_conflict", testBindingDistinctMaterializedObjectsConflict) + t.Run("binding_bare_ends_bind", testBindingBareEndsBind) + t.Run("binding_incomplete_end_does_not_poison_read", testBindingIncompleteEndDoesNotPoisonRead) + t.Run("binding_single_valueless", testBindingSingleValueless) + t.Run("binding_cycle", testBindingCycle) + t.Run("binding_three_binding_ring", testBindingThreeBindingRing) + t.Run("binding_cycle_with_value", testBindingCycleWithValue) + t.Run("binding_unrelated_expression_does_not_poison_read", testBindingUnrelatedExpressionDoesNotPoisonRead) + t.Run("binding_result_tracks_later_mutation", testBindingResultTracksLaterMutation) + t.Run("binding_nested_container_is_not_a_cycle", testBindingNestedContainerIsNotACycle) + t.Run("nested_calc_usage_unbound_input", testNestedCalcUsageUnboundInput) + t.Run("nested_calc_usage_unknown_output", testNestedCalcUsageUnknownOutput) + t.Run("nested_calc_usage_self_cycle", testNestedCalcUsageSelfCycle) + t.Run("nested_calc_usage_recursion_depth", testNestedCalcUsageRecursionDepth) + t.Run("nested_calc_usage_step_budget", testNestedCalcUsageStepBudget) + t.Run("multiple_outputs_invoked_as_an_expression", testMultipleOutputsInvokedAsAnExpression) + t.Run("body_local_usage_of_a_non_calc", testBodyLocalUsageOfANonCalc) + t.Run("body_local_declaration_not_executable", testBodyLocalDeclarationNotExecutable) + t.Run("f99_body_member_without_value", testF99BodyMemberWithoutValue) + t.Run("f99_unsupported_body_member", testF99UnsupportedBodyMember) + t.Run("f99_cyclic_body_declaration", testF99CyclicBodyDeclaration) + t.Run("range_bound_is_not_an_integer", testRangeBoundIsNotAnInteger) + t.Run("range_spends_the_step_budget", testRangeSpendsTheStepBudget) + t.Run("collection_spends_the_element_budget", testCollectionSpendsTheElementBudget) + t.Run("usage_read_through_a_part_without_an_output", testUsageReadThroughAPartWithoutAnOutput) + t.Run("performed_action_binding_names_nothing", testPerformedActionBindingNamesNothing) + t.Run("no_flow_performed_action_checks_its_inputs", testNoFlowPerformedActionChecksItsInputs) + t.Run("no_flow_performed_action_refuses_return_parameter", testNoFlowPerformedActionRefusesReturnParameter) + t.Run("binding_end_of_a_destroyed_object", testBindingEndOfADestroyedObject) + t.Run("operation_of_a_destroyed_object", testOperationOfADestroyedObject) + t.Run("structured_attribute_chain_of_an_unknown_feature", testStructuredAttributeChainOfAnUnknownFeature) + t.Run("elements_chain_of_a_non_numeric_collection", testElementsChainOfANonNumericCollection) + t.Run("arithmetic_over_the_unbounded_value", testArithmeticOverTheUnboundedValue) + t.Run("unbounded_value_compared_with_a_string", testUnboundedValueComparedWithAString) + t.Run("metadata_of_a_value", testMetadataOfAValue) + t.Run("metadata_of_an_unresolved_name", testMetadataOfAnUnresolvedName) + t.Run("metadata_without_the_reflective_library", testMetadataWithoutTheReflectiveLibrary) + t.Run("constraint_missing_feature", testConstraintMissingFeature) + t.Run("nested_condition_subject_is_ambiguous", testNestedConditionSubjectIsAmbiguous) + t.Run("satisfaction_subject_is_ambiguous", testSatisfactionSubjectIsAmbiguous) + t.Run("recursive_composition_subject_search", testRecursiveCompositionSubjectSearch) + t.Run("duplicate_objects_of_one_declaration", testDuplicateObjectsOfOneDeclaration) + t.Run("duplicate_objects_holding_a_plain_part", testDuplicateObjectsHoldingAPlainPart) + t.Run("nested_part_held_with_a_multiplicity", testNestedPartHeldWithAMultiplicity) + t.Run("part_nested_inside_a_repeated_part", testPartNestedInsideARepeatedPart) + t.Run("parts_subsetting_one_collection", testPartsSubsettingOneCollection) + t.Run("requirement_feature_without_a_value", testRequirementFeatureWithoutAValue) + t.Run("requirement_features_valued_from_each_other", testRequirementFeaturesValuedFromEachOther) + t.Run("object_feature_without_a_value", testObjectFeatureWithoutAValue) + t.Run("step_budget_exceeded", testStepBudgetExceeded) + t.Run("eval_on_an_instance_spends_the_step_budget", testEvalOnAnInstanceSpendsTheStepBudget) + t.Run("non_terminating_loop_exhausts_step_budget", testNonTerminatingLoopExhaustsStepBudget) + t.Run("loop_body_declaration_does_not_leak", testLoopBodyDeclarationDoesNotLeak) + t.Run("loop_body_of_unexecutable_statement", testLoopBodyOfUnexecutableStatement) + t.Run("block_flow_of_unexecutable_member", testBlockFlowOfUnexecutableMember) + t.Run("non_terminating_loop_performing_an_action", testNonTerminatingLoopPerformingAnAction) + t.Run("for_over_a_value_no_expression_makes_iterable", testForOverAValueNoExpressionMakesIterable) + t.Run("for_over_a_scalar", testForOverAScalar) + t.Run("statement_directly_in_an_action_body", testStatementDirectlyInAnActionBody) + t.Run("flow_end_naming_no_node", testFlowEndNamingNoNode) + t.Run("flow_naming_no_pin", testFlowNamingNoPin) + t.Run("accept_payload_without_a_value", testAcceptPayloadWithoutAValue) + t.Run("accept_payload_read_before_it_is_bound", testAcceptPayloadReadBeforeItIsBound) + t.Run("flow_from_a_node_that_produced_nothing", testFlowFromANodeThatProducedNothing) + t.Run("action_accept_time_waits", testActionAcceptTimeWaits) + t.Run("clock_advance", testClockAdvance) + t.Run("action_accept_non_boolean_change_trigger", testActionAcceptNonBooleanChangeTrigger) + t.Run("action_body_unresolved_unit", testActionBodyUnresolvedUnit) + t.Run("action_body_unresolved_feature", testActionBodyUnresolvedFeature) + t.Run("state_body_unresolved_unit", testStateBodyUnresolvedUnit) + t.Run("fork_branches_share_region", testForkBranchesShareRegion) + t.Run("fork_leaves_a_region_without_a_way_in", testForkLeavesARegionWithoutAWayIn) + t.Run("fork_only_region_entered_by_default", testForkOnlyRegionEnteredByDefault) + t.Run("fork_branch_with_a_trigger", testForkBranchWithATrigger) + t.Run("nested_fork_starts_an_outer_region_by_default", testNestedForkStartsAnOuterRegionByDefault) + t.Run("join_with_one_incoming_branch", testJoinWithOneIncomingBranch) + t.Run("join_incoming_effect_that_fails", testJoinIncomingEffectThatFails) + t.Run("join_with_two_segments_from_one_source", testJoinWithTwoSegmentsFromOneSource) + t.Run("join_from_nested_states_wrapper_exit_that_fails", testJoinFromNestedStatesWrapperExitThatFails) + t.Run("join_from_composite_source_substate_exit_that_fails", testJoinFromCompositeSourceSubstateExitThatFails) + t.Run("join_of_machine_regions_nested_source_owner_exit_that_fails", testJoinOfMachineRegionsNestedSourceOwnerExitThatFails) + t.Run("join_time_segment_sibling_guard_that_fails", testJoinTimeSegmentSiblingGuardThatFails) + t.Run("region_pseudostate_without_satisfied_guard", testRegionPseudostateWithoutSatisfiedGuard) + t.Run("region_pseudostate_cycle", testRegionPseudostateCycle) + t.Run("non_numeric_time_trigger", testNonNumericTimeTrigger) + t.Run("time_trigger_of_a_non_time_dimension", testTimeTriggerOfANonTimeDimension) + t.Run("time_trigger_of_the_type_validation_refuses", testTimeTriggerOfTheTypeValidationRefuses) + t.Run("action_return_parameter_validation_refuses", testActionReturnParameterValidationRefuses) + t.Run("change_condition_that_never_holds", testChangeConditionThatNeverHolds) + t.Run("send_reaches_only_its_addressee", testSendReachesOnlyItsAddressee) + t.Run("accept_of_unsent_type", testAcceptOfUnsentTypeReports) + t.Run("send_via_unconnected_port", testSendViaUnconnectedPort) + t.Run("send_via_connector_into_an_empty_part", testSendViaConnectorIntoAnEmptyPart) + t.Run("send_via_bound_boundary_port_joined_to_nothing", testSendViaBoundBoundaryPortJoinedToNothing) + t.Run("send_fan_out_to_a_port_that_fails_to_materialize", testSendFanOutToAPortThatFailsToMaterialize) + t.Run("accept_via_a_port_that_fails_to_materialize", testAcceptViaAPortThatFailsToMaterialize) + t.Run("action_accept_via_a_port_that_fails_to_materialize", testActionAcceptViaAPortThatFailsToMaterialize) + t.Run("send_addressed_to_an_unreachable_target", testSendAddressedToAnUnreachableTarget) + t.Run("routed_send_via_unknown_port", testRoutedSendViaUnknownPort) + t.Run("routed_send_port_type_mismatch", testRoutedSendPortTypeMismatch) + t.Run("routed_send_port_type_match", testRoutedSendPortTypeMatch) + t.Run("routed_send_scalar_typed_flow_mismatch", testRoutedSendScalarTypedFlowMismatch) + t.Run("routed_send_unreachable_receiver", testRoutedSendUnreachableReceiver) + t.Run("routed_send_receiver_name_mismatch_deadlock", testRoutedSendReceiverNameMismatchDeadlock) + t.Run("type_classification_unresolved_type", testTypeClassificationUnresolvedType) + t.Run("two_valued_member_in_scalar_context", testTwoValuedMemberInScalarContext) + t.Run("body_local_outside_its_declaration", testBodyLocalOutsideItsDeclaration) + t.Run("type_classification_undetermined_value_type", testTypeClassificationUndeterminedValueType) + t.Run("cast_to_an_unresolved_type", testCastToAnUnresolvedType) + t.Run("extent_of_an_unresolved_or_unbounded_type", testExtentOfAnUnresolvedOrUnboundedType) + t.Run("extent_reaching_a_namespace_collection", testExtentReachingANamespaceCollection) + t.Run("namespace_collection_that_cannot_be_constructed", testNamespaceCollectionThatCannotBeConstructed) + t.Run("namespace_collection_of_unfixed_count", testNamespaceCollectionOfUnfixedCount) + t.Run("chained_write_through_a_namespace_collection", testChainedWriteThroughANamespaceCollection) + t.Run("namespace_collection_over_budget", testNamespaceCollectionOverBudget) + t.Run("extent_over_an_object_that_cannot_be_read", testExtentOverAnObjectThatCannotBeRead) + t.Run("extent_reaching_a_far_usage_that_cannot_be_read", testExtentReachingAFarUsageThatCannotBeRead) + t.Run("extent_over_far_usages_under_the_element_budget", testExtentOverFarUsagesUnderTheElementBudget) + t.Run("extent_over_recursive_composition", testExtentOverRecursiveComposition) + t.Run("extent_through_a_value_recursing_and_not", testExtentThroughAValueRecursingAndNot) + t.Run("cast_undecided_by_the_value", testCastUndecidedByTheValue) + t.Run("enumeration_typed_feature_holding_an_unenumerated_value", testEnumerationTypedFeatureHoldingAnUnenumeratedValue) + t.Run("enumeration_whose_literal_value_cannot_be_evaluated", testEnumerationWhoseLiteralValueCannotBeEvaluated) + t.Run("cast_of_a_quantity_to_a_constrained_subtype", testCastOfAQuantityToAConstrainedSubtype) + t.Run("difference_typed_feature_holding_a_subtracted_object", testDifferenceTypedFeatureHoldingASubtractedObject) + t.Run("send_addressed_through_several_occurrences", testSendAddressedThroughSeveralOccurrences) + t.Run("send_addressed_to_an_object_that_cannot_be_built", testSendAddressedToAnObjectThatCannotBeBuilt) + t.Run("send_addressed_to_a_part_no_sibling_takes", testSendAddressedToAPartNoSiblingTakes) + t.Run("injected_message_names_a_receiver_no_accept_has", testInjectedMessageNamesAReceiverNoAcceptHas) + t.Run("accept_deadlock_never_satisfied", testAcceptDeadlockNeverSatisfied) + t.Run("accept_deadlock_reports_every_waiting_accept", testAcceptDeadlockReportsEveryWaitingAccept) + t.Run("accept_statement_deadlock_in_a_loop", testAcceptStatementDeadlockInALoop) + t.Run("history_outside_composite_state", testHistoryOutsideCompositeState) + t.Run("history_without_record_default_or_entry", testHistoryWithoutRecordDefaultOrEntry) + t.Run("defer_of_non_deferrable_trigger", testDeferOfNonDeferrableTrigger) + t.Run("non_terminating_do_behavior", testNonTerminatingDoBehavior) + t.Run("empty_anonymous_action_body", testEmptyAnonymousActionBody) + t.Run("non_terminating_anonymous_do_body", testNonTerminatingAnonymousDoBody) + t.Run("behavior_performing_an_action_and_stating_a_body", testBehaviorPerformingAnActionAndStatingABody) + t.Run("qualified_assignment_target_in_a_state_effect", testQualifiedAssignmentTargetInAStateEffect) + t.Run("call_of_unhandled_operation", testCallOfUnhandledOperation) + t.Run("signal_no_level_of_a_composite_state_accepts", testSignalNoLevelOfACompositeStateAccepts) + t.Run("stale_composite_timer_in_a_region", testStaleCompositeTimerInARegion) + t.Run("composite_self_transition_with_no_substate_to_re_enter", testCompositeSelfTransitionWithNoSubstateToReEnter) + t.Run("exit_of_nested_regions_with_a_history_pseudostate", testExitOfNestedRegionsWithAHistoryPseudostate) + t.Run("call_argument_of_wrong_type", testCallArgumentOfWrongType) + t.Run("perform_of_missing_action", testPerformOfMissingAction) + t.Run("perform_reference_cycle", testPerformReferenceCycle) + t.Run("state_subaction_reference_of_missing_action", testStateSubactionReferenceOfMissingAction) + t.Run("state_subaction_reference_feature_chain", testStateSubactionReferenceFeatureChain) + t.Run("library_function_outside_its_domain", testLibraryFunctionOutsideItsDomain) + t.Run("library_function_wrong_arity", testLibraryFunctionWrongArity) + t.Run("extension_library_function_outside_its_domain", testExtensionLibraryFunctionOutsideItsDomain) + t.Run("exponentiation_integer_overflow", testExponentiationIntegerOverflow) + t.Run("quantity_incommensurable_comparison", testQuantityIncommensurableComparison) + t.Run("quantity_index_is_not_a_unit", testQuantityIndexIsNotAUnit) + t.Run("quantity_unit_shadowed_by_sibling", testQuantityUnitShadowedBySibling) + t.Run("quantity_qualified_unit_is_not_shadowing", testQuantityQualifiedUnitIsNotShadowing) + t.Run("quantity_shadowed_unit_without_a_qualifier", testQuantityShadowedUnitWithoutAQualifier) + t.Run("quantity_cyclic_unit_definition", testQuantityCyclicUnitDefinition) + t.Run("quantity_calculation_that_has_no_value", testQuantityCalculationThatHasNoValue) + t.Run("satisfy_unresolved_requirement", testSatisfyUnresolvedRequirement) + t.Run("satisfy_requirement_without_conditions", testSatisfyRequirementWithoutConditions) + t.Run("satisfy_bounded_by_the_step_budget", testSatisfyBoundedByTheStepBudget) + t.Run("cyclic_derived_feature_value", testCyclicDerivedFeatureValue) + t.Run("write_into_cyclic_derived_feature_values", testWriteIntoCyclicDerivedFeatureValues) + t.Run("cyclic_subsetting_of_default_collections", testCyclicSubsettingOfDefaultCollections) + t.Run("derived_feature_value_over_missing_feature", testDerivedFeatureValueOverMissingFeature) + t.Run("sequence_index_names_no_position", testSequenceIndexNamesNoPosition) + t.Run("collection_operand_of_the_wrong_kind", testCollectionOperandOfTheWrongKind) + t.Run("numeric_library_call_that_has_no_value", testNumericLibraryCallThatHasNoValue) + t.Run("named_library_call_that_has_no_value", testNamedLibraryCallThatHasNoValue) + t.Run("builtin_named_argument_that_binds_nothing", testBuiltinNamedArgumentThatBindsNothing) + t.Run("body_by_reference_that_cannot_be_applied", testBodyByReferenceThatCannotBeApplied) + t.Run("bodiless_model_calc_named_as_a_builtin", testBodilessModelCalcNamedAsABuiltin) + t.Run("data_equality_over_a_part", testDataEqualityOverAPart) + t.Run("base_index_with_several_indexes", testBaseIndexWithSeveralIndexes) + t.Run("structured_value_outside_the_declared_shape", testStructuredValueOutsideTheDeclaredShape) + t.Run("real_literal_that_underflows", testRealLiteralThatUnderflows) + t.Run("string_operand_of_the_wrong_kind", testStringOperandOfTheWrongKind) + t.Run("ordering_operand_with_no_library_ordering", testOrderingOperandWithNoLibraryOrdering) + t.Run("collection_body_of_the_wrong_arity", testCollectionBodyOfTheWrongArity) + t.Run("select_predicate_is_not_a_condition", testSelectPredicateIsNotACondition) + t.Run("collection_operation_step_budget", testCollectionOperationStepBudget) + t.Run("variation_without_a_selected_variant", testVariationWithoutASelectedVariant) + t.Run("variation_bound_to_what_is_not_a_variant", testVariationBoundToWhatIsNotAVariant) + t.Run("variation_bound_to_two_variants", testVariationBoundToTwoVariants) + t.Run("variation_read_through_its_declaration", testVariationReadThroughItsDeclaration) + t.Run("chain_through_an_unselected_variation_part", testChainThroughAnUnselectedVariationPart) + t.Run("classify_an_unselected_optional_variation", testClassifyAnUnselectedOptionalVariation) + t.Run("repeated_reads_of_a_variant_object", testRepeatedReadsOfAVariantObject) + t.Run("two_owners_selecting_one_variant", testTwoOwnersSelectingOneVariant) + t.Run("two_ownerless_selections_of_one_variant", testTwoOwnerlessSelectionsOfOneVariant) + t.Run("variant_outside_a_variation", testVariantOutsideAVariation) + t.Run("variant_under_a_redefined_variation", testVariantUnderARedefinedVariation) + t.Run("deep_specialization_chain_of_redefinitions", testDeepSpecializationChainOfRedefinitions) + t.Run("conflicting_redefinitions_at_several_levels", testConflictingRedefinitionsAtSeveralLevels) + t.Run("one_feature_valued_under_two_names", testOneFeatureValuedUnderTwoNames) + t.Run("valued_feature_restated_in_a_body", testValuedFeatureRestatedInABody) + t.Run("multiplicity_infinite_lower_bound", testMultiplicityInfiniteLowerBound) + t.Run("multiplicity_lower_bound_too_large", testMultiplicityLowerBoundTooLarge) + t.Run("default_not_conforming_to_multiplicity", testDefaultNotConformingToMultiplicity) + t.Run("default_against_an_undeclared_multiplicity", testDefaultAgainstAnUndeclaredMultiplicity) + t.Run("feature_chain_through_an_unset_feature_value", testFeatureChainThroughAnUnsetFeatureValue) + t.Run("feature_chain_spends_the_element_budget", testFeatureChainSpendsTheElementBudget) + t.Run("mutually_subsetting_features", testMutuallySubsettingFeatures) + t.Run("unattachable_connector_end", testUnattachableConnectorEnd) + t.Run("unattachable_connector_leaves_no_behavior", testUnattachableConnectorLeavesNoBehavior) + t.Run("unattachable_connector_abandons_what_its_ends_materialized", testUnattachableConnectorAbandonsWhatItsEndsMaterialized) + t.Run("unattachable_connector_touches_no_other_object", testUnattachableConnectorTouchesNoOtherObject) + t.Run("unattachable_connector_ends_run_nothing_early", testUnattachableConnectorEndsRunNothingEarly) + t.Run("connector_whose_start_fails_leaves_no_trace", testConnectorWhoseStartFailsLeavesNoTrace) + t.Run("connector_answered_by_a_failing_behavior_is_kept", testConnectorAnsweredByAFailingBehaviorIsKept) + t.Run("multiplicity_on_a_connector", testMultiplicityOnAConnector) + t.Run("connector_attached_to_itself", testConnectorAttachedToItself) + t.Run("mutually_attached_connectors", testMutuallyAttachedConnectors) + t.Run("enumeration_name_that_is_not_a_literal", testEnumerationNameThatIsNotALiteral) + t.Run("chain_through_a_literal_without_that_attribute", testChainThroughALiteralWithoutThatAttribute) + t.Run("classification_outside_the_evaluable_subset", testClassificationOutsideTheEvaluableSubset) + t.Run("meta_cast_failure_modes", testMetaCastFailureModes) + t.Run("expression_over_a_feature_value_holding_no_value", testExpressionOverAFeatureValueHoldingNoValue) + t.Run("succession_guard_failure_modes", testSuccessionGuardFailureModes) + t.Run("quantity_write_of_another_dimension", testQuantityWriteOfAnotherDimension) + t.Run("measurement_reference_failure_modes", testMeasurementReferenceFailureModes) + t.Run("tensor_quantity_failure_modes", testTensorQuantityFailureModes) + t.Run("coordinate_frame_failure_modes", testCoordinateFrameFailureModes) + t.Run("object_exhibited_machine_never_settles", testObjectExhibitedMachineNeverSettles) + t.Run("object_exhibited_machine_without_an_initial_state", testObjectExhibitedMachineWithoutAnInitialState) + t.Run("object_exhibited_machine_whose_only_edge_is_a_first_succession", testObjectExhibitedMachineWhoseOnlyEdgeIsAFirstSuccession) + t.Run("object_exhibited_machine_attribute_write_violates_multiplicity", testObjectExhibitedMachineAttributeWriteViolatesMultiplicity) + t.Run("object_performed_action_attribute_write_violates_multiplicity", testObjectPerformedActionAttributeWriteViolatesMultiplicity) + t.Run("object_performed_action_occurrence_holds_a_non_object", testObjectPerformedActionOccurrenceHoldsANonObject) + t.Run("operation_invoked_with_unbound_parameters", testOperationInvokedWithUnboundParameters) + t.Run("second_instantiation_of_one_type", testSecondInstantiationOfOneType) + t.Run("write_of_a_wrong_typed_value_leaves_the_feature", testWriteOfAWrongTypedValueLeavesTheFeature) + t.Run("write_of_too_many_values_leaves_the_feature", testWriteOfTooManyValuesLeavesTheFeature) + t.Run("write_of_a_repeated_value_leaves_the_feature", testWriteOfARepeatedValueLeavesTheFeature) + t.Run("write_of_no_value_where_one_is_required", testWriteOfNoValueWhereOneIsRequired) + t.Run("state_entry_write_of_a_wrong_typed_value", testStateEntryWriteOfAWrongTypedValue) + t.Run("performer_feature_write_of_a_wrong_typed_value", testPerformerFeatureWriteOfAWrongTypedValue) + t.Run("standalone_action_naming_a_performer_feature", testStandaloneActionNamingAPerformerFeature) + t.Run("standalone_action_writing_a_performer_feature", testStandaloneActionWritingAPerformerFeature) + t.Run("standalone_action_naming_this_of_an_unowned_performance", testStandaloneActionNamingThisOfAnUnownedPerformance) + t.Run("chained_write_of_a_wrong_typed_value", testChainedWriteOfAWrongTypedValue) + t.Run("calc_output_write_of_a_wrong_typed_value", testCalcOutputWriteOfAWrongTypedValue) + t.Run("action_local_write_of_a_wrong_typed_value", testActionLocalWriteOfAWrongTypedValue) + t.Run("action_output_write_of_a_wrong_typed_value", testActionOutputWriteOfAWrongTypedValue) + t.Run("performance_occurrence_write_of_a_wrong_typed_value", testPerformanceOccurrenceWriteOfAWrongTypedValue) + t.Run("function_value_call_of_a_non_function", testFunctionValueCallOfANonFunction) + t.Run("function_value_bound_to_a_non_function", testFunctionValueBoundToANonFunction) + t.Run("function_value_arity_mismatch", testFunctionValueArityMismatch) + t.Run("function_value_unknown_named_argument", testFunctionValueUnknownNamedArgument) + t.Run("function_value_unbound_calc_parameter", testFunctionValueUnboundCalcParameter) + t.Run("function_value_of_a_wrong_typed_calc", testFunctionValueOfAWrongTypedCalc) + t.Run("function_value_of_a_built_in", testFunctionValueOfABuiltIn) + t.Run("function_value_applied_to_itself_forever", testFunctionValueAppliedToItselfForever) + t.Run("function_value_inherited_body_outside_the_closure", testFunctionValueInheritedBodyOutsideTheClosure) + t.Run("function_value_nested_calc_outside_its_run", testFunctionValueNestedCalcOutsideItsRun) + t.Run("verification_body_that_cannot_run", testVerificationBodyThatCannotRun) + t.Run("verification_body_step_that_fails", testVerificationBodyStepThatFails) + t.Run("verification_subcase_that_cannot_run", testVerificationSubcaseThatCannotRun) + t.Run("verification_of_a_symbol_that_is_not_a_case", testVerificationOfASymbolThatIsNotACase) + t.Run("verification_with_an_argument_the_case_does_not_take", testVerificationWithAnArgumentTheCaseDoesNotTake) + t.Run("verification_objective_subject_of_another_type", testVerificationObjectiveSubjectOfAnotherType) + t.Run("verification_objective_subject_left_unbound", testVerificationObjectiveSubjectLeftUnbound) + t.Run("verification_objective_subject_rebound", testVerificationObjectiveSubjectRebound) + t.Run("trade_study_with_an_abstract_evaluation_function", testTradeStudyWithAnAbstractEvaluationFunction) + t.Run("trade_study_whose_evaluation_fails_for_one_alternative", testTradeStudyWhoseEvaluationFailsForOneAlternative) + t.Run("trade_study_with_an_empty_subject", testTradeStudyWithAnEmptySubject) + t.Run("trade_study_with_a_single_valued_subject", testTradeStudyWithASingleValuedSubject) + t.Run("trade_study_whose_alternatives_read_an_unbound_feature", testTradeStudyWhoseAlternativesReadAnUnboundFeature) + t.Run("sweep_over_a_boolean_parameter", testSweepOverABooleanParameter) + t.Run("sweep_over_a_parameter_typed_by_a_part", testSweepOverAParameterTypedByAPart) + t.Run("sweep_over_an_integer_parameter_by_a_fraction", testSweepOverAnIntegerParameterByAFraction) + t.Run("sweep_over_a_real_parameter_by_integers_no_real_holds", testSweepOverARealParameterByIntegersNoRealHolds) + t.Run("weighted_decision_whose_weights_do_not_sum_to_one", testWeightedDecisionWhoseWeightsDoNotSumToOne) + t.Run("weighted_decision_with_a_weight_outside_zero_to_one", testWeightedDecisionWithAWeightOutsideZeroToOne) + t.Run("decision_mixing_weighted_and_unweighted_successions", testDecisionMixingWeightedAndUnweightedSuccessions) + t.Run("weighted_decision_whose_read_weight_is_no_probability", testWeightedDecisionWhoseReadWeightIsNoProbability) + t.Run("random_draw_without_a_seed", testRandomDrawWithoutASeed) + t.Run("random_bounds_reversed", testRandomBoundsReversed) + t.Run("random_duration_without_a_seed", testRandomDurationWithoutASeed) + t.Run("monte_carlo_plan_without_runs", testMonteCarloPlanWithoutRuns) +} + +func testBindingConflict(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a = 1; + attribute b = 2; + binding bind b = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "b") + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("GetFeatureValue(b) = %v, want ErrBindingConflict", err) + } + if got, want := err.Error(), "binding conflict: b = 2, a = 1"; got != want { + t.Errorf("conflict error = %q, want %q", got, want) + } +} + +func testBindingCollectionConflictsDoNotUseHashes(t *testing.T) { + t.Run("strings", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a : ScalarValues::String[*] = ("a"); + attribute b : ScalarValues::String[*] = ("b"); + binding bind b = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "b") + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("GetFeatureValue(b) = %v, want ErrBindingConflict", err) + } + if got, want := err.Error(), + `binding conflict: b = ["b"], a = ["a"]`; got != want { + t.Errorf("conflict error = %q, want %q", got, want) + } + }) + + t.Run("integers", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a : Integer[*] = (1); + attribute b : Integer[*] = (65537); + binding bind b = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "b") + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("GetFeatureValue(b) = %v, want ErrBindingConflict", err) + } + if got, want := err.Error(), + "binding conflict: b = [65537], a = [1]"; got != want { + t.Errorf("conflict error = %q, want %q", got, want) + } + }) +} + +func testBindingMultipleScalarContributors(t *testing.T) { + t.Run("unequal", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a; + attribute b = 1; + attribute c = 2; + bind a = b; + bind a = c; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "a") + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("GetFeatureValue(a) = %v, want ErrBindingConflict", err) + } + if got, want := err.Error(), "binding conflict at Sys.a: b = 1, c = 2"; got != want { + t.Errorf("conflict error = %q, want %q", got, want) + } + }) + + t.Run("equal", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a; + attribute b = 1; + attribute c = 1; + bind a = b; + bind a = c; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "a") + if err != nil { + t.Fatalf("GetFeatureValue(a): %v", err) + } + if got := fv.HeldValue().Const.Int; got != 1 { + t.Errorf("a = %d, want 1", got) + } + }) +} + +func testBindingMultipleCollectionContributors(t *testing.T) { + t.Run("partial", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute edges : Integer[*]; + attribute leftEdge : Integer[0..1] = (1); + attribute rightEdge : Integer[0..1] = (2); + binding [1] bind [0..1] edges = [0..1] leftEdge; + binding [1] bind [0..1] edges = [0..1] rightEdge; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "edges") + if !errors.Is(err, ErrBindingEnd) { + t.Fatalf("GetFeatureValue(edges) = %v, want ErrBindingEnd", err) + } + if got, want := err.Error(), "binding end cannot be resolved: Sys.edges is bound by `binding [1] bind [0..1] edges = [0..1] leftEdge`, "+ + "which makes some value of edges a value of leftEdge without saying which value of either; the model does not state what edges holds"; got != want { + t.Errorf("error = %q, want %q", got, want) + } + fv := inst.FeatureValues["edges"] + if fv.Materialized || fv.Written || fv.BindingDerived || fv.HeldValue().Kind != ValInvalid { + t.Errorf("unsupported binding left an assignment behind: %+v", *fv) + } + }) + + // An end admitting one value links a feature holding one value whole, + // however wide the feature is declared; one holding more stays partial. + t.Run("partial_by_values_held", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute edges : Integer[*]; + attribute pick : Integer[1]; + binding [1] bind [0..1] edges = [0..1] pick; + } + part one : Sys { :>> edges = (7); } + part two : Sys { :>> edges = (7, 8); } + }`)) + one, err := ctx.Instantiate(oneSymbol(t, idx, "P::one")) + if err != nil { + t.Fatalf("instantiate one: %v", err) + } + fv, err := one.GetFeatureValue(ctx, "pick") + if err != nil { + t.Fatalf("one.pick: %v", err) + } + if got := fv.HeldValue().Const.Int; got != 7 { + t.Errorf("one.pick = %d (%s), want 7, the one value edges holds", got, FormatValue(fv.HeldValue())) + } + two, err := ctx.Instantiate(oneSymbol(t, idx, "P::two")) + if err != nil { + t.Fatalf("instantiate two: %v", err) + } + if _, err := two.GetFeatureValue(ctx, "pick"); !errors.Is(err, ErrBindingEnd) { + t.Fatalf("two.pick = %v, want ErrBindingEnd", err) + } + // An end valued on its own — written, as one valued by a default — keeps that value. + if err := two.SetFeatureValue(ctx, "pick", integerValue(8)); err != nil { + t.Fatalf("write two.pick: %v", err) + } + fv, err = two.GetFeatureValue(ctx, "pick") + if err != nil { + t.Fatalf("two.pick after the write: %v", err) + } + if fv.HeldValue().Const.Int != 8 { + t.Errorf("two.pick = %s after writing 8, want 8", FormatValue(fv.HeldValue())) + } + }) + + // A binding of the whole feature determines it whatever partial bindings it has + // besides, and in whatever order they are declared. + t.Run("whole_beside_partial", func(t *testing.T) { + for name, bindings := range map[string]string{ + "partial_first": "binding [1] bind [0..1] edges = [0..1] pick; bind edges = every;", + "whole_first": "bind edges = every; binding [1] bind [0..1] edges = [0..1] pick;", + } { + t.Run(name, func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute edges : Integer[*]; + attribute every : Integer[*] = (1, 2, 3); + attribute pick : Integer[0..1] = (2); + `+bindings+` + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "edges") + if err != nil { + t.Fatalf("edges: %v", err) + } + if got := FormatValue(fv.HeldValue()); got != "[1, 2, 3]" { + t.Errorf("edges = %s, want [1, 2, 3], what the whole binding determines", got) + } + }) + } + }) + + // An end of multiplicity [0] links no value: each feature reads what it holds on its own. + t.Run("zero_width_end", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute edges : Integer[*] = (7, 8); + attribute pick : Integer[0..1]; + binding [1] bind [0] edges = [0..1] pick; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "edges") + if err != nil { + t.Fatalf("edges: %v", err) + } + if got := len(elementsOf(fv.HeldValue())); got != 2 { + t.Errorf("edges holds %d values (%s), want the two it is valued with", got, FormatValue(fv.HeldValue())) + } + fv, err = inst.GetFeatureValue(ctx, "pick") + if err != nil { + t.Fatalf("pick: %v", err) + } + if got := elementsOf(fv.HeldValue()); len(got) != 0 { + t.Errorf("pick = %s, want nothing: the binding links no value to it", FormatValue(fv.HeldValue())) + } + }) + + // An end stating how many values it links is not met by a feature holding fewer: the + // binding is a multiplicity violation, not a whole binding of what there is — whichever + // end is read, whether the features admit more than the end links or exactly as many. + t.Run("under_lower_bound", func(t *testing.T) { + for name, c := range map[string]struct{ ends, same string }{ + "exact": {"[2]", "[0..2]"}, + "ranged": {"[2..3]", "[0..3]"}, + } { + ends := c.ends + for shape, declared := range map[string]string{"wider": "[*]", "same": c.same} { + t.Run(name+"_"+shape, func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute edges : Integer`+declared+` = (7); + attribute pair : Integer`+declared+`; + binding [1] bind `+ends+` edges = `+ends+` pair; + } + }`)) + want := "multiplicity violation: `binding [1] bind " + ends + " edges = " + ends + " pair` links " + + ends + " of edges, which holds 1 value(s)" + for _, order := range [][]string{{"pair", "edges", "pair"}, {"edges", "pair", "edges"}} { + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + for _, feature := range order { + _, err := inst.GetFeatureValue(ctx, feature) + if !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("%v: %s = %v, want ErrMultiplicityViolation", order, feature, err) + } + if got := err.Error(); got != want { + t.Errorf("%v: %s error = %q, want %q", order, feature, got, want) + } + } + fv := inst.FeatureValues["pair"] + if fv.Materialized || fv.Written || fv.BindingDerived || fv.HeldValue().Kind != ValInvalid { + t.Errorf("%v: the refused binding left an assignment behind: %+v", order, *fv) + } + } + }) + } + } + }) + + // An end that makes the binding partial does not excuse the other end from its + // lower bound, whichever end is read. + t.Run("under_lower_bound_beyond_partial_end", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute edges : Integer[*]; + attribute pair : Integer[0..3] = (5); + binding [1] bind [2] edges = [2] pair; + } + }`)) + want := "multiplicity violation: `binding [1] bind [2] edges = [2] pair` links [2] of pair, which holds 1 value(s)" + for _, order := range [][]string{{"edges", "pair"}, {"pair", "edges"}} { + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + for _, feature := range order { + _, err := inst.GetFeatureValue(ctx, feature) + if !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("%v: %s = %v, want ErrMultiplicityViolation", order, feature, err) + } + if got := err.Error(); got != want { + t.Errorf("%v: %s error = %q, want %q", order, feature, got, want) + } + } + fv := inst.FeatureValues["edges"] + if fv.Written || fv.BindingDerived || fv.HeldValue().Kind != ValInvalid { + t.Errorf("%v: the refused binding left an assignment behind: %+v", order, *fv) + } + } + }) + + // Ends requiring a value are not met by two optional features holding none: the + // binding links nothing, a multiplicity violation rather than an unknown value or a + // cycle. Once anything values the features — a default, another binding — it is whole. + t.Run("empty_required_ends", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Empty { + attribute a : Integer[0..1]; + attribute b : Integer[0..1]; + binding [1] bind [1] a = [1] b; + } + part def Defaulted { + attribute a : Integer[0..1] = 5; + attribute b : Integer[0..1]; + binding [1] bind [1] a = [1] b; + } + part def Joined { + attribute a : Integer[0..1]; + attribute b : Integer[0..1]; + attribute c : Integer[0..1] = 5; + binding [1] bind [1] a = [1] b; + binding [1] bind [1] a = [1] c; + } + }`)) + want := "multiplicity violation: `binding [1] bind [1] a = [1] b` links [1] of a, which holds 0 value(s)" + for _, order := range [][]string{{"a", "b"}, {"b", "a"}} { + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Empty")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + for _, feature := range order { + _, err := inst.GetFeatureValue(ctx, feature) + if !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("Empty %v: %s = %v, want ErrMultiplicityViolation", order, feature, err) + } + if got := err.Error(); got != want { + t.Errorf("Empty %v: %s error = %q, want %q", order, feature, got, want) + } + } + } + for def, orders := range map[string][][]string{ + "P::Defaulted": {{"a", "b"}, {"b", "a"}}, + "P::Joined": {{"a", "b", "c"}, {"b", "a", "c"}, {"c", "b", "a"}}, + } { + for _, order := range orders { + inst, err := ctx.Instantiate(oneSymbol(t, idx, def)) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + for _, feature := range order { + fv, err := inst.GetFeatureValue(ctx, feature) + if err != nil { + t.Fatalf("%s %v: %s: %v", def, order, feature, err) + } + if got := fv.HeldValue(); got.Kind != ValConst || got.Const.Int != 5 { + t.Errorf("%s %v: %s = %s, want 5", def, order, feature, FormatValue(got)) + } + } + } + } + }) + + t.Run("whole_unequal", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute edges : Integer[*]; + attribute left : Integer[*] = (1, 2); + attribute right : Integer[*] = (2, 1); + bind edges = left; + bind edges = right; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "edges") + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("GetFeatureValue(edges) = %v, want ErrBindingConflict", err) + } + if got, want := err.Error(), "binding conflict at Sys.edges: left = [1, 2], right = [2, 1]"; got != want { + t.Errorf("conflict error = %q, want %q", got, want) + } + }) + + t.Run("whole_equal", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute edges : Integer[*]; + attribute left : Integer[*] = (1, 2); + attribute right : Integer[*] = (1, 2); + bind edges = left; + bind edges = right; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "edges") + if err != nil { + t.Fatalf("GetFeatureValue(edges): %v", err) + } + if got := FormatValue(fv.HeldValue()); got != "[1, 2]" { + t.Errorf("edges = %s, want [1, 2]", got) + } + }) +} + +func testBindingPropagationSpendsElementBudget(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a : Integer[*] = (1, 2, 3); + attribute b : Integer[*]; + bind b = a; + } + }`)) + ctx.maxElements = 2 + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "b") + if !errors.Is(err, ErrElementLimitExceeded) { + t.Fatalf("GetFeatureValue(b) = %v, want ErrElementLimitExceeded", err) + } +} + +func testBindingDistinctMaterializedObjectsConflict(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def A { + attribute q = 1; + } + part def Sys { + part p1 : A; + part p2 : A; + binding bind p1 = p2; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + if _, err := inst.materializeFeatureValueIntrinsic(ctx, "p1"); err != nil { + t.Fatalf("materialize p1: %v", err) + } + if _, err := inst.materializeFeatureValueIntrinsic(ctx, "p2"); err != nil { + t.Fatalf("materialize p2: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "p1") + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("GetFeatureValue(p1) = %v, want ErrBindingConflict", err) + } +} + +// `binding bnd = a` states two ends (KerML.xtext BindingConnectorDeclaration); +// `bnd` is the first end, not the binding's name. +func testBindingBareEndsBind(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a = 5; + attribute bnd; + binding bnd = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "bnd") + if err != nil { + t.Fatalf("GetFeatureValue(bnd) = %v, want 5", err) + } + if got := fv.HeldValue(); got.Kind != ValConst || got.Const.Int != 5 { + t.Errorf("bnd = %#v, want integer 5", got) + } +} + +func testBindingCycle(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a; + attribute b; + binding bind a = b; + binding bind b = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "a") + if !errors.Is(err, ErrBindingCycle) { + t.Fatalf("GetFeatureValue(a) = %v, want ErrBindingCycle", err) + } + if !strings.Contains(err.Error(), "a") || !strings.Contains(err.Error(), "b") { + t.Errorf("cycle error %q does not name both ends", err) + } +} + +func testBindingSingleValueless(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a; + attribute b; + binding bind b = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + for _, name := range []string{"a", "b"} { + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("GetFeatureValue(%s): %v", name, err) + } + if got := fv.HeldValue(); got.Kind != ValInvalid { + t.Errorf("%s = %#v, want an unknown value", name, got) + } + } + sym := oneSymbol(t, idx, "P::Sys") + expr := parser.New(source.New("", []byte("b"))).ParseExpression() + if _, err := ctx.EvalWithScopeOn(expr, sym.Scope, inst); !errors.Is(err, ErrUninitializedFeatureValue) { + t.Fatalf("evaluating b = %v, want ErrUninitializedFeatureValue", err) + } +} + +func testBindingThreeBindingRing(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a; + attribute b; + attribute c; + binding bind a = b; + binding bind b = c; + binding bind c = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "a") + if !errors.Is(err, ErrBindingCycle) { + t.Fatalf("GetFeatureValue(a) = %v, want ErrBindingCycle", err) + } + for _, name := range []string{"a", "c"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("cycle error %q does not name %s", err, name) + } + } +} + +func testBindingCycleWithValue(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a = 4; + attribute b; + binding bind a = b; + binding bind b = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + for _, name := range []string{"a", "b"} { + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("GetFeatureValue(%s): %v", name, err) + } + if fv.HeldValue().Kind != ValConst || fv.HeldValue().Const.Int != 4 { + t.Errorf("%s = %#v, want integer 4", name, fv.HeldValue()) + } + } +} + +func testBindingUnrelatedExpressionDoesNotPoisonRead(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a = 5; + attribute b; + attribute sibling = 8; + attribute b2 = a + 1; + binding bind b = b2; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "sibling") + if err != nil { + t.Fatalf("GetFeatureValue(sibling): %v", err) + } + if got := fv.HeldValue().Const.Int; got != 8 { + t.Errorf("sibling = %d, want 8", got) + } + fv, err = inst.GetFeatureValue(ctx, "b") + if err != nil { + t.Fatalf("GetFeatureValue(b): %v", err) + } + if got := fv.HeldValue().Const.Int; got != 6 { + t.Errorf("b = %d, want 6", got) + } +} + +func testBindingResultTracksLaterMutation(t *testing.T) { + t.Run("initially_unresolved", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a; + attribute b; + binding bind b = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + if fv, err := inst.GetFeatureValue(ctx, "b"); err != nil { + t.Fatalf("initial GetFeatureValue(b): %v", err) + } else if got := fv.HeldValue(); got.Kind != ValInvalid { + t.Fatalf("initial b = %#v, want an unknown value", got) + } + if err := inst.SetFeatureValue(ctx, "a", constInt(9)); err != nil { + t.Fatalf("assign a: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "b") + if err != nil { + t.Fatalf("later GetFeatureValue(b): %v", err) + } + if got := fv.HeldValue().Const.Int; got != 9 { + t.Errorf("later b = %d, want 9", got) + } + }) + + t.Run("cached_value", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a = 3; + attribute b; + binding bind b = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + if fv, err := inst.GetFeatureValue(ctx, "b"); err != nil { + t.Fatalf("initial GetFeatureValue(b): %v", err) + } else if got := fv.HeldValue().Const.Int; got != 3 { + t.Fatalf("initial b = %d, want 3", got) + } + if err := inst.SetFeatureValue(ctx, "a", constInt(9)); err != nil { + t.Fatalf("assign a: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "b") + if err != nil { + t.Fatalf("later GetFeatureValue(b): %v", err) + } + if got := fv.HeldValue().Const.Int; got != 9 { + t.Errorf("later b = %d, want 9", got) + } + }) + + t.Run("derived_binding_chain", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a = 3; + attribute b; + attribute c; + binding bind b = a; + binding bind c = b; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + if fv, err := inst.GetFeatureValue(ctx, "c"); err != nil { + t.Fatalf("initial GetFeatureValue(c): %v", err) + } else if got := fv.HeldValue().Const.Int; got != 3 { + t.Fatalf("initial c = %d, want 3", got) + } + if err := inst.SetFeatureValue(ctx, "a", constInt(9)); err != nil { + t.Fatalf("assign a: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "c") + if err != nil { + t.Fatalf("later GetFeatureValue(c): %v", err) + } + if got := fv.HeldValue().Const.Int; got != 9 { + t.Errorf("later c = %d, want 9", got) + } + }) + + t.Run("written_both_ends_conflict", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute a; + attribute b; + binding bind b = a; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + if err := inst.SetFeatureValue(ctx, "a", constInt(1)); err != nil { + t.Fatalf("assign a: %v", err) + } + if err := inst.SetFeatureValue(ctx, "b", constInt(2)); err != nil { + t.Fatalf("assign b: %v", err) + } + _, err = inst.GetFeatureValue(ctx, "b") + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("GetFeatureValue(b) = %v, want ErrBindingConflict", err) + } + }) +} + +func testBindingIncompleteEndDoesNotPoisonRead(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Sys { + attribute x = 4; + bind x; + binding bb of x; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "x") + if err != nil { + t.Fatalf("GetFeatureValue(x) = %v, want 4", err) + } + if got := fv.HeldValue().Const.Int; got != 4 { + t.Errorf("x = %d, want 4", got) + } +} + +func testBindingNestedContainerIsNotACycle(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + part def Child { + attribute b; + } + part def Sys { + attribute x = 9; + part child : Child; + binding bind child.b = x; + } + }`)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "P::Sys")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + childValue, err := inst.GetFeatureValue(ctx, "child") + if err != nil { + t.Fatalf("GetFeatureValue(child) = %v, want no binding cycle", err) + } + id, isObject := childValue.HeldValue().Object() + if !isObject { + t.Fatalf("child holds %s, want an object", childValue.HeldValue().Kind) + } + child, ok := ctx.Instance(id) + if !ok { + t.Fatalf("child instance %d is not materialized", id) + } + b, err := child.GetFeatureValue(ctx, "b") + if err != nil { + t.Fatalf("GetFeatureValue(child.b) = %v, want 9", err) + } + if got := b.HeldValue().Const.Int; got != 9 { + t.Fatalf("child.b = %d, want 9", got) + } +} + +// testSuccessionGuardFailureModes: a guard on a succession leaving an ordinary +// action node is evaluated, so its failure modes — a value that is not Boolean, +// a guard nothing supplies a name for, and two guards holding at once — are each +// reported as a typed error rather than a panic, a hang or a chosen branch. +func testSuccessionGuardFailureModes(t *testing.T) { + for _, tc := range []struct { + name string + body string + want error + }{ + { + name: "guard is not a boolean", + body: ` + attribute x : Integer = 1; + attribute y : Integer = 0; + action s1 assign x := 7; + action s2 assign y := 9; + first s1 if x + 1 then s2; + `, + want: ErrTypeMismatch, + }, + { + name: "guard reads a name nothing supplies", + body: ` + attribute y : Integer = 0; + action s1; + action s2 assign y := 9; + first s1 if missing > 5 then s2; + `, + want: ErrUnresolvedReference, + }, + { + name: "two guards hold at once", + body: ` + attribute level : Integer = 12; + attribute low : Integer = 0; + attribute high : Integer = 0; + action check assign level := level; + action alert assign high := 1; + action idle assign low := 1; + first check; + succession first check if level > 10 then alert; + succession first check if level > 5 then idle; + `, + want: ErrAmbiguousSuccession, + }, + } { + t.Run(tc.name, func(t *testing.T) { + src := "package test {\n private import ScalarValues::*;\n action guarded {" + tc.body + "}\n}" + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "guarded", ast.DefAction) + if sym == nil { + t.Fatal("action guarded not found") + } + + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("panic: %v", r) + } + }() + _, err := ctx.ExecuteAction(sym) + done <- err + }() + select { + case err := <-done: + if !errors.Is(err, tc.want) { + t.Errorf("ExecuteAction err = %v, want %v", err, tc.want) + } + case <-watchdog(5 * time.Second): + t.Fatal("executing the guarded action did not terminate") + } + }) + } +} + +// testQuantityWriteOfAnotherDimension: a write whose quantity measures in a +// dimension the target's declared quantity value type does not is refused with +// a typed error, while a commensurable unit at another scale is written. +func testQuantityWriteOfAnotherDimension(t *testing.T) { + const duration = "ISQ::DurationValue" + for _, tc := range []struct { + name string + declared string + value string + want error + }{ + {"speed into a duration", duration, "3.0 [SI::m / SI::s]", ErrTypeMismatch}, + {"length into a duration", duration, "5.0 [SI::m]", ErrTypeMismatch}, + {"dimensionless into a duration", duration, "5.0 [MeasurementReferences::one]", ErrTypeMismatch}, + {"another scale of the same dimension", duration, "5.0 [SI::min]", nil}, + {"a unit the target fixes no dimension against", "Quantities::ScalarQuantityValue", "5.0 [SI::m]", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + src := fmt.Sprintf(` + package test { + private import SI::*; + action w { + attribute t : %s = 0.0 [s]; + first start; + action step { assign t := %s; } + done; + succession first start then step; + succession first step then done; + } + } + `, tc.declared, tc.value) + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "w", ast.DefAction) + if sym == nil { + t.Fatal("action w not found") + } + _, err := ctx.ExecuteAction(sym) + if tc.want == nil { + if err != nil { + t.Fatalf("ExecuteAction err = %v, want the write to conform", err) + } + return + } + if !errors.Is(err, tc.want) { + t.Fatalf("ExecuteAction err = %v, want %v", err, tc.want) + } + }) + } +} + +// testMeasurementReferenceFailureModes: a measurement reference the runtime +// cannot honestly compute with is a typed error at the write or the call, never a +// value: a unit of another dimension does not conform, a conversion between +// dimensions is incommensurable, a number is not a reference, a reference is not +// a number, a unit is not a scale, a scale the library places on no other +// reference converts to none, and the tensors the library declares have no value. +func testMeasurementReferenceFailureModes(t *testing.T) { + for _, tc := range []struct { + name string + declared string + value string + want error + }{ + {"speed unit into a length unit", "ISQ::LengthUnit", "SI::m / SI::s", ErrTypeMismatch}, + {"dimensionless unit into a length unit", "ISQ::LengthUnit", "MeasurementReferences::one", ErrTypeMismatch}, + {"unit into a length value", "ISQ::LengthValue", "SI::m", ErrTypeMismatch}, + {"composed unit of the same dimension", "ISQ::AreaUnit", "SI::m * SI::m", nil}, + {"composed unit as a derived unit", "MeasurementReferences::DerivedUnit", "SI::km / SI::L", nil}, + {"another scale of the same dimension", "ISQ::LengthUnit", "SI::km", nil}, + {"composed unit into a time scale", "Time::TimeScale", "SI::h * SI::s / SI::min", ErrTypeMismatch}, + {"composed unit into an interval scale", "MeasurementReferences::IntervalScale", "SI::h * SI::s / SI::min", ErrTypeMismatch}, + {"time scale as a value", "Time::TimeScale", "Time::UTC", nil}, + {"interval scale as a value", "MeasurementReferences::IntervalScale", "SI::'°C_abs'", nil}, + {"scale as a unit", "MeasurementReferences::IntervalScale", "SI::'°C'", ErrTypeMismatch}, + {"unit as a scale", "ISQ::LengthUnit", "Time::UTC", ErrTypeMismatch}, + {"conversion to a scale", "ISQ::ThermodynamicTemperatureValue", "QuantityCalculations::ConvertQuantity(300.0 [SI::K], SI::'°C_abs')", nil}, + {"conversion from a scale", "ISQ::ThermodynamicTemperatureValue", "QuantityCalculations::ConvertQuantity(26.85 [SI::'°C_abs'], SI::K)", nil}, + {"conversion to a scale placed on another dimension", "ISQ::LengthValue", "QuantityCalculations::ConvertQuantity(3.0 [SI::m], SI::'°C_abs')", ErrIncommensurableUnits}, + {"conversion to a scale with no mapping", "ISQ::DurationValue", "QuantityCalculations::ConvertQuantity(3.0 [SI::s], Time::UTC)", ErrUnevaluableLibraryFunction}, + {"conversion from a scale with no mapping", "ISQ::DurationValue", "QuantityCalculations::ConvertQuantity(3.0 [Time::UTC], SI::s)", ErrUnevaluableLibraryFunction}, + {"incommensurable conversion", "ISQ::LengthValue", "QuantityCalculations::ConvertQuantity(3.0 [SI::m], SI::s)", ErrIncommensurableUnits}, + {"conversion to a number", "ISQ::LengthValue", "QuantityCalculations::ConvertQuantity(3.0 [SI::m], 3)", ErrTypeMismatch}, + {"reference scaled by a number", "ISQ::LengthUnit", "SI::m * 3", ErrTypeMismatch}, + {"reference raised to a reference", "ISQ::LengthUnit", "SI::m ** SI::m", ErrTypeMismatch}, + {"declaration member of a reference", "Quantities::QuantityDimension", "SI::m.quantityDimension", nil}, + {"quantity dimension of a composed unit", "Quantities::QuantityDimension", "(SI::m / SI::s).quantityDimension", ErrUnevaluableLibraryFunction}, + {"unit conversion of a composed unit", "MeasurementReferences::UnitConversion", "(SI::m / SI::s).unitConversion", ErrUnevaluableLibraryFunction}, + {"unit power factors of a composed unit", "MeasurementReferences::UnitPowerFactor", "(SI::m ** 2).unitPowerFactors", ErrUnevaluableLibraryFunction}, + {"definitional quantity values of a composed unit", "MeasurementReferences::DefinitionalQuantityValue", "(SI::m / SI::s).definitionalQuantityValues", ErrUnevaluableLibraryFunction}, + {"vector reference over two units", "Quantities::VectorQuantityValue", "VectorCalculations::'['((1.0, 2.0), (SI::m, SI::s))", ErrTypeMismatch}, + {"vector reference over a unit", "Quantities::VectorQuantityValue", "VectorCalculations::'['((1.0, 2.0), SI::m)", ErrTypeMismatch}, + {"transformation that is a vector", "Quantities::VectorQuantityValue", "VectorCalculations::transform(VectorFunctions::VectorOf((1.0, 2.0)) [SI::m], VectorFunctions::VectorOf((0.0, 1.0)) [SI::m])", ErrTypeMismatch}, + {"scale scaled by a unit", "MeasurementReferences::CoordinateFrame", "Time::UTC / SI::s", ErrUnevaluableLibraryFunction}, + {"outer product", "Quantities::TensorQuantityValue", "VectorCalculations::outer((1.0, 2.0), (3.0, 4.0))", ErrUnevaluableLibraryFunction}, + {"tensor sum of number sequences", "Quantities::TensorQuantityValue", "TensorCalculations::'+'((1.0, 2.0), (3.0, 4.0))", ErrTypeMismatch}, + {"tensor product", "Quantities::TensorQuantityValue", "TensorCalculations::tensorTensorMult(VectorFunctions::VectorOf((1.0, 2.0)) [SI::m], VectorFunctions::VectorOf((3.0, 4.0)) [SI::m])", ErrUnevaluableLibraryFunction}, + } { + t.Run(tc.name, func(t *testing.T) { + src := fmt.Sprintf(` + package test { + part def Holder { + attribute value : %s = %s; + } + } + `, tc.declared, tc.value) + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Holder", ast.DefPart) + if sym == nil { + t.Fatal("part def Holder not found") + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate err = %v", err) + } + _, err = inst.GetFeatureValue(ctx, "value") + if tc.want == nil { + if err != nil { + t.Fatalf("value err = %v, want the reference to be held", err) + } + return + } + if !errors.Is(err, tc.want) { + t.Fatalf("value err = %v, want %v", err, tc.want) + } + }) + } +} + +// testTensorQuantityFailureModes: a tensor the library does not determine is a typed +// error at the call, never a value: components that do not fill the reference, +// operands of two shapes, incommensurable components, a unit predicate over a +// shape with no identity, and the five calculations the library leaves bodiless +// and underspecified, each naming itself. +func testTensorQuantityFailureModes(t *testing.T) { + for _, tc := range []struct { + name string + value string + want error + names string + }{ + {"element count", "TensorCalculations::'['((1.0, 2.0, 3.0), stressRef)", ErrMultiplicityViolation, "n = mRef.flattenedSize"}, + {"one reference for four components", "TensorCalculations::'['((1.0, 2.0, 3.0, 4.0), oneRef)", ErrMultiplicityViolation, "oneRef"}, + {"a unit as the reference", "TensorCalculations::'['((1.0, 2.0, 3.0, 4.0), (Pa, Pa, Pa, Pa))", ErrTypeMismatch, "TensorMeasurementReference"}, + {"shape mismatch", "stress + TensorCalculations::'['((1.0, 2.0, 3.0), rowRef)", ErrMultiplicityViolation, "dimensions [2, 2] and [3] differ"}, + {"tensor minus scalar", "stress - 2 [Pa]", ErrMultiplicityViolation, "dimensions [2, 2] and [] differ"}, + {"incommensurable components", "stress + TensorCalculations::'['((1.0, 2.0, 3.0, 4.0), lengthRef)", ErrIncommensurableUnits, ""}, + {"non-square unit predicate", "TensorCalculations::isUnitTensorQuantity(TensorCalculations::'['((1.0, 2.0, 3.0), rowRef))", ErrUnevaluableLibraryFunction, "only a square tensor of order two has an identity"}, + {"unset covariance", "stress.contravariantOrder", ErrUnevaluableLibraryFunction, "orderSum"}, + {"tensor times vector", "TensorCalculations::tensorVectorMult(stress, VectorFunctions::VectorOf((1.0, 2.0)) [Pa])", ErrUnevaluableLibraryFunction, "TensorCalculations::tensorVectorMult"}, + {"vector times tensor", "TensorCalculations::vectorTensorMult(VectorFunctions::VectorOf((1.0, 2.0)) [Pa], stress)", ErrUnevaluableLibraryFunction, "TensorCalculations::vectorTensorMult"}, + {"tensor times tensor", "TensorCalculations::tensorTensorMult(stress, stress)", ErrUnevaluableLibraryFunction, "TensorCalculations::tensorTensorMult"}, + {"tensor times tensor by operator", "stress * stress", ErrUnevaluableLibraryFunction, "TensorCalculations::tensorTensorMult"}, + {"outer product", "VectorCalculations::outer(VectorFunctions::VectorOf((1.0, 2.0)) [Pa], VectorFunctions::VectorOf((1.0, 2.0)) [Pa])", ErrUnevaluableLibraryFunction, "VectorCalculations::outer"}, + {"transform", "TensorCalculations::transform(stressRef, stress)", ErrUnevaluableLibraryFunction, "TensorCalculations::transform"}, + {"rank three, too few indexes", "cube#(1, 2)", ErrMultiplicityViolation, "2 indexes address an array of rank 3"}, + {"rank three, too many indexes", "cube#(1, 1, 1, 1)", ErrMultiplicityViolation, "4 indexes address an array of rank 3"}, + {"rank three, first index low", "cube#(0, 1, 1)", ErrIndexOutOfRange, "index 1 is 0, dimension 1 has 1..2"}, + {"rank three, middle index high", "cube#(1, 3, 1)", ErrIndexOutOfRange, "index 2 is 3, dimension 2 has 1..2"}, + {"rank three, last index high", "cube#(1, 1, 3)", ErrIndexOutOfRange, "index 3 is 3, dimension 3 has 1..2"}, + {"rank three, non-integer index", "cube#(1, 1.5, 1)", ErrTypeMismatch, "requires an Integer index"}, + {"rank three, too few components", "TensorCalculations::'['((1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0), cubeRef)", ErrMultiplicityViolation, "7 elements for a reference of dimensions [2, 2, 2]"}, + {"rank three, too many components", "TensorCalculations::'['((1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0), cubeRef)", ErrMultiplicityViolation, "9 elements for a reference of dimensions [2, 2, 2]"}, + {"rank three against rank two", "cube + stress", ErrMultiplicityViolation, "dimensions [2, 2, 2] and [2, 2] differ"}, + {"rank three shapes differ", "cube - TensorCalculations::'['((1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0), slabRef)", ErrMultiplicityViolation, "dimensions [2, 2, 2] and [2, 3, 2] differ"}, + {"rank three unit predicate", "TensorCalculations::isUnitTensorQuantity(cube)", ErrUnevaluableLibraryFunction, "only a square tensor of order two has an identity"}, + } { + t.Run(tc.name, func(t *testing.T) { + src := fmt.Sprintf(` + package test { + private import ISQ::*; + private import SI::*; + private import MeasurementReferences::*; + private import Quantities::*; + attribute stressRef : TensorMeasurementReference { :>> dimensions = (2, 2); :>> mRefs = (Pa, Pa, Pa, Pa); } + attribute cubeRef : TensorMeasurementReference { :>> dimensions = (2, 2, 2); :>> mRefs = (Pa, Pa, Pa, Pa, Pa, Pa, Pa, Pa); } + attribute slabRef : TensorMeasurementReference { :>> dimensions = (2, 3, 2); :>> mRefs = (Pa, Pa, Pa, Pa, Pa, Pa, Pa, Pa, Pa, Pa, Pa, Pa); } + attribute cube = TensorCalculations::'['((1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0), cubeRef); + attribute lengthRef : TensorMeasurementReference { :>> dimensions = (2, 2); :>> mRefs = (Pa, m, Pa, Pa); } + attribute rowRef : TensorMeasurementReference { :>> dimensions = (3); :>> mRefs = (Pa, Pa, Pa); } + attribute oneRef : TensorMeasurementReference { :>> dimensions = (2, 2); :>> mRefs = Pa; } + attribute stress = TensorCalculations::'['((1.0, 2.0, 3.0, 4.0), stressRef); + part def Holder { + attribute value : TensorQuantityValue = %s; + } + } + `, tc.value) + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Holder", ast.DefPart) + if sym == nil { + t.Fatal("part def Holder not found") + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate err = %v", err) + } + _, err = inst.GetFeatureValue(ctx, "value") + if !errors.Is(err, tc.want) { + t.Fatalf("value err = %v, want %v", err, tc.want) + } + if !strings.Contains(err.Error(), tc.names) { + t.Errorf("value err = %v, want it to state %q", err, tc.names) + } + }) + } +} + +// testCoordinateFrameFailureModes: what the runtime cannot honestly compute about +// a coordinate frame, a scale or a transformation is a typed error naming what is +// missing or malformed: a frame with no mRefs, a vector with a number per axis +// short, a transformation applied to a vector in another frame, a subtype of +// CoordinateTransformation the library gives no shape, a placement whose origin +// is no vector quantity or whose basis is singular, a translation in an +// incommensurable unit, a scale placed on two references at odds, and matrices, +// sequences and placements missing what they declare. A scalar written where a +// reference, vector or step is declared is refused by the write itself. +func testCoordinateFrameFailureModes(t *testing.T) { + for _, tc := range []struct { + name string + body string + declared string + value string + want error + message string + }{ + {"frame with no mRefs", `attribute wcf : CoordinateFrame;`, + "CoordinateFrame", "wcf", ErrNoValue, "coordinate frame wcf states no mRefs; TensorMeasurementReference declares mRefs: ScalarMeasurementReference[1..*], one per axis"}, + {"frame whose mRefs are not one per axis", `attribute bad : CartesianSpatial3dCoordinateFrame { :>> mRefs = (m, m); }`, + "CoordinateFrame", "bad", ErrMultiplicityViolation, "bad.mRefs: multiplicity violation: 2 value(s) bound to a feature with multiplicity lower bound 3"}, + {"frame whose mRefs are not one per stated dimension", `attribute bad : CoordinateFrame { :>> dimensions = 2; :>> mRefs = (m, m, m); }`, + "CoordinateFrame", "bad", ErrMultiplicityViolation, "bad states 3 mRefs for dimensions [2], whose flattenedSize is 2"}, + {"frame whose dimensions overflow", `attribute bad : CoordinateFrame { :>> dimensions : Positive[2] = (4611686018427387904, 4); :>> mRefs = (m, m, m); }`, + "CoordinateFrame", "bad", semantics.ErrArithmeticOverflow, "bad: flattenedSize of dimensions [4611686018427387904, 4] exceeds the Integer range"}, + {"frame whose mRef is a number", `attribute bad : CoordinateFrame { :>> mRefs = (m, 2); }`, + "CoordinateFrame", "bad", ErrTypeMismatch, "bad.mRefs: type mismatch: cannot write 2 (an Integer) to a feature typed by ScalarMeasurementReference"}, + {"vector short of an axis", ``, + "Position3dVector", "(1.0, 2.0) [spatialCF]", ErrMultiplicityViolation, "2 elements over the coordinate frame spatialCF, whose flattenedSize is 3; elements: Number[1..n] with n = mRef.flattenedSize"}, + {"vector of a number too many", ``, + "Position3dVector", "(1.0, 2.0, 3.0, 4.0) [spatialCF]", ErrMultiplicityViolation, "4 elements over the coordinate frame spatialCF, whose flattenedSize is 3"}, + {"vector of no numbers", ``, + "Position3dVector", "(m, m, m) [spatialCF]", ErrNotAQuantity, "want 3 numbers, one per axis"}, + {"scale composed with a unit", ``, + "CoordinateFrame", "Time::UTC * s", ErrUnevaluableLibraryFunction, "UTC is a measurement scale"}, + {"frame composed with a number", ``, + "CoordinateFrame", "spatialCF / 2", ErrTypeMismatch, "operator '/' is not defined for a coordinate frame and an Integer"}, + {"frame composed with a frame", ``, + "CoordinateFrame", "spatialCF * spatialCF", ErrTypeMismatch, "operator '*' is not defined for a coordinate frame and coordinate frame; MeasurementRefCalculations::'CoordinateFrame*' takes a MeasurementUnit"}, + {"composed frame into a frame of another dimension", ``, + "CartesianSpatial3dCoordinateFrame", "spatialCF / s", ErrTypeMismatch, "axis 1 measures in dimension L·T^-1, where CartesianSpatial3dCoordinateFrame admits L"}, + {"frame into a unit", ``, + "LengthUnit", "spatialCF", ErrTypeMismatch, "cannot write the coordinate frame spatialCF [m, m, m], a CartesianSpatial3dCoordinateFrame, to a feature typed by LengthUnit"}, + {"frame into a scale", ``, + "Time::TimeScale", "spatialCF", ErrTypeMismatch, "to a feature typed by TimeScale"}, + {"vector into the wrong frame's vector type", ``, + "ISQSpaceTime::CartesianVelocity3dCoordinateFrame", "spatialCF", ErrTypeMismatch, "a CartesianSpatial3dCoordinateFrame, to a feature typed by CartesianVelocity3dCoordinateFrame"}, + {"vector over a frame into a vector type admitting another", `attribute velocityCF : CartesianVelocity3dCoordinateFrame = spatialCF / s;`, + "CartesianPosition3dVector", "(1.0, 2.0, 3.0) [velocityCF]", ErrTypeMismatch, "cannot write ⟨1.0, 2.0, 3.0⟩ [velocityCF] (a vector quantity over the coordinate frame velocityCF [m/s, m/s, m/s], a CartesianVelocity3dCoordinateFrame) to a feature typed by CartesianPosition3dVector, whose mRef admits CartesianSpatial3dCoordinateFrame"}, + {"vector over a composed frame into a vector type admitting another", ``, + "CartesianPosition3dVector", "(1.0, 2.0, 3.0) [spatialCF / s]", ErrTypeMismatch, "cannot write ⟨1.0, 2.0, 3.0⟩ [spatialCF / s] (a vector quantity over the coordinate frame spatialCF / s [m/s, m/s, m/s], a coordinate frame whose axis 1 measures in dimension L·T^-1, where CartesianSpatial3dCoordinateFrame admits L) to a feature typed by CartesianPosition3dVector, whose mRef admits CartesianSpatial3dCoordinateFrame"}, + {"transformation applied to a vector in another frame", placementBody, + "Position3dVector", "transform(shifted.transformation, (1.0, 2.0, 3.0) [spatialCF])", ErrTypeMismatch, "sourceVector.mRef is spatialCF, not datum, the source of transformation"}, + {"transformation applied to a vector in a unit", placementBody, + "Position3dVector", "transform(shifted.transformation, VectorFunctions::VectorOf((1.0, 2.0, 3.0)) [mm])", ErrTypeMismatch, "written over the unit mm and no coordinate frame"}, + {"transformation applied to numbers", placementBody, + "Position3dVector", "transform(shifted.transformation, (1.0, 2.0, 3.0))", ErrTypeMismatch, `parameter "sourceVector" requires a vector quantity over datum`}, + {"transformation of no recognized shape", ` + attribute def Bespoke :> CoordinateTransformation; + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : Bespoke { :>> source = datum; } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrUnevaluableLibraryFunction, "transformation is a Bespoke, a CoordinateTransformation of no shape the library gives a meaning"}, + {"placement whose origin is a string", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : CoordinateFramePlacement { :>> source = datum; :>> origin = "2024-01-01T00:00:00Z"; } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrTypeMismatch, `transformation.origin: type mismatch: cannot write "2024-01-01T00:00:00Z" (string) to a feature typed by VectorQuantityValue`}, + {"placement whose origin is missing", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : CoordinateFramePlacement { :>> source = datum; } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrNoValue, "states no origin; CoordinateFramePlacement declares origin: VectorQuantityValue[1]"}, + {"placement whose origin is in another frame", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : CoordinateFramePlacement { :>> source = datum; :>> origin = (1.0, 2.0, 3.0) [spatialCF]; } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrTypeMismatch, "origin is a vector quantity in spatialCF, not a vector quantity over datum, its source"}, + {"placement whose origin has too few components", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : CoordinateFramePlacement { :>> source = datum; :>> origin = VectorFunctions::VectorOf((1.0, 2.0)) [mm]; } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrMultiplicityViolation, "origin has 2 components over datum of 3 axes"}, + {"placement of a singular basis", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : CoordinateFramePlacement { :>> source = datum; :>> origin = (0.0, 0.0, 0.0) [datum]; :>> basisDirections = ((1.0, 0.0, 0.0) [datum], (2.0, 0.0, 0.0) [datum], (0.0, 0.0, 1.0) [datum]); } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", semantics.ErrArithmeticDomain, "basis directions are linearly dependent and span no frame"}, + {"placement of a zero basis direction", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : CoordinateFramePlacement { :>> source = datum; :>> origin = (0.0, 0.0, 0.0) [datum]; :>> basisDirections = ((0.0, 0.0, 0.0) [datum], (0.0, 1.0, 0.0) [datum], (0.0, 0.0, 1.0) [datum]); } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", semantics.ErrArithmeticDomain, "basisDirections#(1) is the zero vector, which points nowhere"}, + {"placement of too few basis directions", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : CoordinateFramePlacement { :>> source = datum; :>> origin = (0.0, 0.0, 0.0) [datum]; :>> basisDirections = ((1.0, 0.0, 0.0) [datum], (0.0, 1.0, 0.0) [datum]); } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrMultiplicityViolation, "states 2 basisDirections over datum of 3 axes; a placement states none or one per axis"}, + {"placement reorienting axes in different units", ` + attribute mixed : CoordinateFrame { :>> mRefs = (m, mm, m); } + attribute odd : CoordinateFrame { :>> mRefs = (m, mm, m); :>> transformation : CoordinateFramePlacement { :>> source = mixed; :>> origin = (0.0, 0.0, 0.0) [mixed]; :>> basisDirections = ((0.0, 1.0, 0.0) [mixed], (1.0, 0.0, 0.0) [mixed], (0.0, 0.0, 1.0) [mixed]); } }`, + "Quantities::VectorQuantityValue", "transform(odd.transformation, (1.0, 2.0, 3.0) [mixed])", ErrIncommensurableUnits, "reorients mixed, whose axes m and mm are in different units and cannot mix"}, + {"translation in an incommensurable unit", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : TranslationRotationSequence { :>> source = datum; :>> elements = (new Translation(VectorFunctions::VectorOf((1.0, 0.0, 0.0)) [s])); } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrIncommensurableUnits, "elements#(1).translationVector: incommensurable units: cannot express s (second) in mm (0.001·metre)"}, + {"translation in another frame of the same units", ` + attribute other : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); } + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : TranslationRotationSequence { :>> source = datum; :>> elements = (new Translation((1.0, 0.0, 0.0) [other])); } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrTypeMismatch, "translationVector is a vector quantity in other, not a vector quantity over datum, its source"}, + {"translation in a commensurable unit converts", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : TranslationRotationSequence { :>> source = datum; :>> elements = (new Translation(VectorFunctions::VectorOf((1.0, 0.0, 0.0)) [m])); } }`, + "Position3dVector", "transform(odd.transformation, (1000.0, 2.0, 3.0) [datum])", nil, ""}, + {"sequence of no elements", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : TranslationRotationSequence { :>> source = datum; } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrNoValue, "states no elements; TranslationRotationSequence declares elements: TranslationOrRotation[1..*]"}, + {"sequence whose element is a number", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : TranslationRotationSequence { :>> source = datum; :>> elements = (1, 2); } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrTypeMismatch, "transformation.elements: type mismatch: cannot write 1 (an Integer) to a feature typed by TranslationOrRotation"}, + {"rotation about the zero vector", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : TranslationRotationSequence { :>> source = datum; :>> elements = (new Rotation((0.0, 0.0, 0.0) [datum], 90 ['°'])); } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", semantics.ErrArithmeticDomain, "axisDirection is the zero vector, which points nowhere"}, + {"rotation by a length", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : TranslationRotationSequence { :>> source = datum; :>> elements = (new Rotation((0.0, 0.0, 1.0) [datum], 90 [mm])); } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrTypeMismatch, "new Rotation: feature value Rotation.angle: type mismatch: cannot write 90 [mm] (dimension L) to a feature typed by AngularMeasureValue (dimensionless)"}, + {"rotation by a number", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : TranslationRotationSequence { :>> source = datum; :>> elements = (new Rotation((0.0, 0.0, 1.0) [datum], 90)); } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrTypeMismatch, "new Rotation: feature value Rotation.angle: type mismatch: cannot write 90 (an Integer) to a feature typed by AngularMeasureValue"}, + {"rotation of a plane frame", ` + attribute plane : CoordinateFrame { :>> mRefs = (mm, mm); } + attribute odd : CoordinateFrame { :>> mRefs = (mm, mm); :>> transformation : TranslationRotationSequence { :>> source = plane; :>> elements = (new Rotation((0.0, 1.0) [plane], 90 ['°'])); } }`, + "Quantities::VectorQuantityValue", "transform(odd.transformation, (1.0, 2.0) [plane])", ErrUnevaluableLibraryFunction, "rotates about an axis, which a frame of 2 axes has no meaning for"}, + {"affine matrix of too few elements", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : AffineTransformationMatrix3d { :>> source = datum; :>> rotationMatrix { :>> elements = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0); } :>> translationVector { :>> elements = (0.0, 0.0, 0.0); } } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrMultiplicityViolation, "rotationMatrix.elements: multiplicity violation: 6 value(s) bound to a feature with multiplicity lower bound 9"}, + {"affine matrix of strings", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : AffineTransformationMatrix3d { :>> source = datum; :>> rotationMatrix { :>> elements = ("a", "b", "c", "d", "e", "f", "g", "h", "i"); } :>> translationVector { :>> elements = (0.0, 0.0, 0.0); } } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrTypeMismatch, `rotationMatrix.elements: type mismatch: cannot write "a" (string) to a feature typed by Real`}, + {"affine matrix with no translation", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : AffineTransformationMatrix3d { :>> source = datum; :>> rotationMatrix { :>> elements = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0); } } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrNoValue, "translationVector states no elements"}, + {"affine matrix over a plane frame", ` + attribute plane : CoordinateFrame { :>> mRefs = (mm, mm); } + attribute odd : CoordinateFrame { :>> mRefs = (mm, mm); :>> transformation : AffineTransformationMatrix3d { :>> source = plane; :>> rotationMatrix { :>> elements = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0); } :>> translationVector { :>> elements = (0.0, 0.0, 0.0); } } }`, + "Quantities::VectorQuantityValue", "transform(odd.transformation, (1.0, 2.0) [plane])", ErrMultiplicityViolation, "is an AffineTransformationMatrix3d over plane of 2 axes; it asserts source.dimensions == 3"}, + {"transformation with no source", ` + attribute lone : NullTransformation { :>> target = datum; }`, + "Position3dVector", "transform(lone, (1.0, 2.0, 3.0) [datum])", ErrNoValue, "lone states no source or no target"}, + {"transformation with no target", ` + attribute lone : NullTransformation { :>> source = datum; }`, + "Position3dVector", "transform(lone, (1.0, 2.0, 3.0) [datum])", ErrNoValue, "lone states no source or no target"}, + {"transformation between frames of different dimensions", ` + attribute plane : CoordinateFrame { :>> mRefs = (mm, mm); } + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : NullTransformation { :>> source = plane; } }`, + "Quantities::VectorQuantityValue", "transform(odd.transformation, (1.0, 2.0) [plane])", ErrMultiplicityViolation, "relates plane of dimensions [2] (2 axes) to odd of dimensions [3] (3 axes); CoordinateTransformation asserts source.dimensions == target.dimensions"}, + {"transformation between frames of one axis but different dimensions", ` + attribute lineA : CoordinateFrame { :>> dimensions = 1; :>> mRefs = mm; } + attribute lineB : CoordinateFrame { :>> dimensions = (); :>> mRefs = mm; :>> transformation : NullTransformation { :>> source = lineA; } }`, + "Quantities::VectorQuantityValue", "transform(lineB.transformation, 3.0 [lineA])", ErrMultiplicityViolation, "relates lineA of dimensions [1] (1 axes) to lineB of dimensions [] (1 axes); CoordinateTransformation asserts source.dimensions == target.dimensions"}, + {"transformation whose target is another frame", ` + attribute odd : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); :>> transformation : NullTransformation { :>> source = datum; :>> target = datum; } }`, + "Position3dVector", "transform(odd.transformation, (1.0, 2.0, 3.0) [datum])", ErrTypeMismatch, "target is datum, but the transformation is odd's own, whose target it is"}, + {"scale whose placement and mapping disagree", ` + attribute def Muddled :> IntervalScale { + :>> unit = SI::'°C'; + private attribute triplePoint : DefinitionalQuantityValue { :>> num = 0.01; :>> definition = "triple point"; } + private attribute mapping : QuantityValueMapping { :>> mappedQuantityValue = triplePoint; :>> referenceQuantityValue = K.temperatureOfWaterAtTriplePointInK; } + :>> quantityValueMapping = mapping; + :>> transformation : CoordinateFramePlacement { :>> source = K; :>> origin = 300.0 [K]; } + } + attribute muddled : Muddled;`, + "ThermodynamicTemperatureValue", "ConvertQuantity(300.0 [K], muddled)", ErrUnevaluableLibraryFunction, "its transformation places zero at 300.0 [K] but its quantityValueMapping places it at 273.15"}, + {"scale whose mapping maps a quantity, not a definitional value", ` + attribute def Muddled :> IntervalScale { + :>> unit = SI::'°C'; + private attribute mapping : QuantityValueMapping { :>> mappedQuantityValue = 0.01 [SI::'°C']; :>> referenceQuantityValue = K.temperatureOfWaterAtTriplePointInK; } + :>> quantityValueMapping = mapping; + } + attribute muddled : Muddled;`, + "ThermodynamicTemperatureValue", "ConvertQuantity(300.0 [K], muddled)", ErrTypeMismatch, "measurement scale muddled: quantityValueMapping.mappedQuantityValue is a quantity in SI::'°C', want a DefinitionalQuantityValue"}, + {"scale placed on a reference of another dimension", ` + attribute def Muddled :> IntervalScale { :>> unit = SI::'°C'; :>> transformation : CoordinateFramePlacement { :>> source = m; :>> origin = 1.0 [m]; } } + attribute muddled : Muddled;`, + "ThermodynamicTemperatureValue", "ConvertQuantity(300.0 [K], muddled)", ErrIncommensurableUnits, ""}, + {"scale whose origin is a string", ` + attribute def Muddled :> Time::TimeScale { :>> unit = s; :>> transformation : CoordinateFramePlacement { :>> source = Time::UTC; :>> origin = "2024-01-01T00:00:00Z"; } } + attribute muddled : Muddled;`, + "ISQ::DurationValue", "ConvertQuantity(3.0 [Time::UTC], muddled)", ErrTypeMismatch, `transformation.origin: type mismatch: cannot write "2024-01-01T00:00:00Z" (string) to a feature typed by VectorQuantityValue`}, + {"scale whose one basis direction is the identity in another unit", ` + attribute shifted : IntervalScale { :>> unit = m; :>> transformation : CoordinateFramePlacement { :>> source = m; :>> origin = 10.0 [m]; :>> basisDirections = 1000.0 [mm]; } }`, + "LengthValue", "ConvertQuantity(3.0 [shifted], m)", nil, ""}, + {"scale whose basis direction scales the axis in another unit", ` + attribute shifted : IntervalScale { :>> unit = s; :>> transformation : CoordinateFramePlacement { :>> source = s; :>> origin = 10.0 [s]; :>> basisDirections = 1.0 [min]; } }`, + "DurationValue", "ConvertQuantity(3.0 [shifted], s)", ErrUnevaluableLibraryFunction, "the basisDirection 1.0 [min] of its transformation transformation is not the identity 1 [s]"}, + {"scale placed on a frame of two axes", ` + attribute plane : CoordinateFrame { :>> mRefs = (K, K); } + attribute shifted : IntervalScale { :>> unit = K; :>> transformation : CoordinateFramePlacement { :>> source = plane; :>> origin = 10.0 [K]; } }`, + "ThermodynamicTemperatureValue", "ConvertQuantity(3.0 [shifted], K)", ErrMultiplicityViolation, "relates plane of dimensions [2] (2 axes) to shifted of dimensions [] (1 axes); CoordinateTransformation asserts source.dimensions == target.dimensions"}, + {"scale with two basis directions", ` + attribute shifted : IntervalScale { :>> unit = K; :>> transformation : CoordinateFramePlacement { :>> source = K; :>> origin = 10.0 [K]; :>> basisDirections = (1.0 [K], 1.0 [K]); } }`, + "ThermodynamicTemperatureValue", "ConvertQuantity(3.0 [shifted], K)", ErrMultiplicityViolation, "states 2 basisDirections over K of one axis"}, + {"scale whose basis direction is in an incommensurable unit", ` + attribute shifted : IntervalScale { :>> unit = K; :>> transformation : CoordinateFramePlacement { :>> source = K; :>> origin = 10.0 [K]; :>> basisDirections = 1.0 [m]; } }`, + "ThermodynamicTemperatureValue", "ConvertQuantity(3.0 [shifted], K)", ErrIncommensurableUnits, "the basisDirection 1.0 [m] of its transformation transformation is not on K, its source"}, + {"scale whose basis direction is over another frame", ` + attribute line : CoordinateFrame { :>> mRefs = (K); } + attribute shifted : IntervalScale { :>> unit = K; :>> transformation : CoordinateFramePlacement { :>> source = K; :>> origin = 10.0 [K]; :>> basisDirections = (1.0) [line]; } }`, + "ThermodynamicTemperatureValue", "ConvertQuantity(3.0 [shifted], K)", ErrTypeMismatch, "is a vector quantity in line, not a quantity on K, its source"}, + {"scale whose basis direction scales the axis", ` + attribute shifted : IntervalScale { :>> unit = K; :>> transformation : CoordinateFramePlacement { :>> source = K; :>> origin = 10.0 [K]; :>> basisDirections = 2.0 [K]; } }`, + "ThermodynamicTemperatureValue", "ConvertQuantity(3.0 [shifted], K)", ErrUnevaluableLibraryFunction, "is not the identity 1 [K], and the library gives a scale no other basis"}, + {"scale with no unit", ` + attribute def Unitless :> IntervalScale; + attribute unitless : Unitless;`, + "IntervalScale", "unitless", ErrNoValue, "states no unit; MeasurementScale declares unit: MeasurementUnit"}, + {"scale placed on itself", ` + attribute loop : IntervalScale { :>> unit = K; :>> transformation : CoordinateFramePlacement { :>> source = loop; :>> origin = 1.0 [K]; } }`, + "ThermodynamicTemperatureValue", "ConvertQuantity(3.0 [loop], K)", ErrCyclicFeatureValue, "coordinate frame loop is defined in terms of itself"}, + {"scales placed on each other", ` + attribute a : IntervalScale { :>> unit = K; :>> transformation : CoordinateFramePlacement { :>> source = b; :>> origin = 1.0 [b]; } } + attribute b : IntervalScale { :>> unit = K; :>> transformation : CoordinateFramePlacement { :>> source = a; :>> origin = 2.0 [a]; } }`, + "ThermodynamicTemperatureValue", "ConvertQuantity(3.0 [a], K)", ErrCyclicFeatureValue, "coordinate frame a is defined in terms of itself"}, + {"scales mapped onto each other", ` + attribute a : IntervalScale { + :>> unit = K; + private attribute p : DefinitionalQuantityValue { :>> num = 1; :>> definition = "p"; } + private attribute m : QuantityValueMapping { :>> mappedQuantityValue = p; :>> referenceQuantityValue = 2.0 [b]; } + :>> quantityValueMapping = m; + } + attribute b : IntervalScale { + :>> unit = K; + private attribute p : DefinitionalQuantityValue { :>> num = 1; :>> definition = "p"; } + private attribute m : QuantityValueMapping { :>> mappedQuantityValue = p; :>> referenceQuantityValue = 2.0 [a]; } + :>> quantityValueMapping = m; + }`, + "ThermodynamicTemperatureValue", "ConvertQuantity(3.0 [K], a)", ErrCyclicFeatureValue, "coordinate frame a is defined in terms of itself"}, + // A point on an interval scale has the affine operations and no other: the + // refusal names the operation and the scale. + {"point added to a point", ``, + "ThermodynamicTemperatureValue", "warm + 10.0 [SI::'°C_abs']", ErrScalePoint, "operator '+' on a point of the interval scale SI::'°C_abs': two points have no sum"}, + {"time instant added to a time instant", ``, + "Time::TimeInstantValue", "5.0 [Time::UTC] + 3.0 [Time::UTC]", ErrScalePoint, "operator '+' on a point of the interval scale Time::UTC: two points have no sum; their difference `x - y` is a magnitude in s"}, + {"point taken from a magnitude", ``, + "ThermodynamicTemperatureValue", "300.0 [K] - warm", ErrScalePoint, "subtracting a point from a magnitude on a point of the interval scale SI::'°C_abs'"}, + {"point scaled by a number", ``, + "ThermodynamicTemperatureValue", "2 * warm", ErrScalePoint, "operator '*' on a point of the interval scale SI::'°C_abs': a point has no multiple"}, + {"number scaled by a point", ``, + "ThermodynamicTemperatureValue", "warm * 2.0", ErrScalePoint, "operator '*' on a point of the interval scale SI::'°C_abs'"}, + {"time instant scaled by a number", ``, + "Time::TimeInstantValue", "2 * 5.0 [Time::UTC]", ErrScalePoint, "operator '*' on a point of the interval scale Time::UTC"}, + {"point multiplied by a quantity", ``, + "ScalarQuantityValue", "warm * 2.0 [s]", ErrScalePoint, "operator '*' on a point of the interval scale SI::'°C_abs': a point has no multiple and its scale is no unit to compose"}, + {"quantity multiplied by a point", ``, + "ScalarQuantityValue", "2.0 [s] * warm", ErrScalePoint, "operator '*' on a point of the interval scale SI::'°C_abs'"}, + {"point divided by a number", ``, + "ThermodynamicTemperatureValue", "warm / 2", ErrScalePoint, "operator '/' on a point of the interval scale SI::'°C_abs'"}, + {"point divided by a quantity", ``, + "ScalarQuantityValue", "warm / 2.0 [s]", ErrScalePoint, "operator '/' on a point of the interval scale SI::'°C_abs'"}, + {"quantity divided by a point", ``, + "ScalarQuantityValue", "2.0 [s] / warm", ErrScalePoint, "operator '/' on a point of the interval scale SI::'°C_abs'"}, + {"point raised to a power", ``, + "ScalarQuantityValue", "warm ** 2", ErrScalePoint, "operator '**' on a point of the interval scale SI::'°C_abs'"}, + {"point raised to a power by exponentiation", ``, + "ScalarQuantityValue", "warm ^ 2", ErrScalePoint, "operator '**' on a point of the interval scale SI::'°C_abs'"}, + {"square root of a point", ``, + "ScalarQuantityValue", "sqrt(warm)", ErrScalePoint, "sqrt on a point of the interval scale SI::'°C_abs'"}, + {"negated point", ``, + "ThermodynamicTemperatureValue", "-warm", ErrScalePoint, "negation on a point of the interval scale SI::'°C_abs': a point has no negative"}, + {"points summed", ``, + "ThermodynamicTemperatureValue", "sum((warm, 20.0 [SI::'°C_abs']))", ErrScalePoint, "QuantityCalculations::sum on a point of the interval scale SI::'°C_abs': points have no sum or product to fold"}, + {"points multiplied together", ``, + "ScalarQuantityValue", "product((warm, 20.0 [SI::'°C_abs']))", ErrScalePoint, "QuantityCalculations::product on a point of the interval scale SI::'°C_abs'"}, + {"point moved by a magnitude of another dimension", ``, + "ThermodynamicTemperatureValue", "warm + 1.0 [m]", ErrIncommensurableUnits, "a point on the interval scale SI::'°C_abs' moves by a magnitude in its unit '°C'"}, + {"point compared with a magnitude of another dimension", ``, + "Boolean", "warm < 1.0 [m]", ErrIncommensurableUnits, ""}, + {"point equated with a magnitude of another dimension", ``, + "Boolean", "warm == 1.0 [m]", ErrIncommensurableUnits, ""}, + {"point on an unanchored scale compared with a magnitude", ``, + "Boolean", "5.0 [Time::UTC] == 5.0 [s]", ErrUnevaluableLibraryFunction, "Time::UTC states neither a transformation placing it on another reference nor a quantityValueMapping"}, + {"point on an ordinal scale moved by a magnitude", `attribute mohs : OrdinalScale { :>> unit = K; }`, + "ScalarQuantityValue", "7 [mohs] + 1 [K]", ErrScalePoint, "operator '+' on a point of the ordinal scale test::Holder::mohs: only an IntervalScale relates its points by differences in its unit"}, + {"points on an ordinal scale subtracted", `attribute mohs : OrdinalScale { :>> unit = K; }`, + "ScalarQuantityValue", "9 [mohs] - 7 [mohs]", ErrScalePoint, "operator '-' on a point of the ordinal scale test::Holder::mohs"}, + {"point on an ordinal scale compared with a magnitude", `attribute mohs : OrdinalScale { :>> unit = K; }`, + "Boolean", "7 [mohs] < 300.0 [K]", ErrScalePoint, "comparison with another reference on a point of the ordinal scale test::Holder::mohs"}, + {"point on a cyclic ratio scale scaled", `attribute compass : CyclicRatioScale { :>> unit = rad; :>> modulus = 6.283185307179586; }`, + "ScalarQuantityValue", "2 * 1.0 [compass]", ErrScalePoint, "operator '*' on a point of the cyclic ratio scale test::Holder::compass"}, + {"point on a logarithmic scale moved by a magnitude", `attribute decibel : LogarithmicScale { :>> unit = W; :>> logarithmBase = 10; :>> factor = 10; :>> exponent = 1; }`, + "ScalarQuantityValue", "30.0 [decibel] + 1.0 [W]", ErrScalePoint, "operator '+' on a point of the logarithmic scale test::Holder::decibel: only an IntervalScale relates its points by differences in its unit"}, + } { + t.Run(tc.name, func(t *testing.T) { + src := fmt.Sprintf(` + package test { + private import ISQ::*; + private import SI::*; + private import ISQSpaceTime::*; + private import MeasurementReferences::*; + private import QuantityCalculations::*; + private import VectorCalculations::*; + part def Holder { + attribute spatialCF : CartesianSpatial3dCoordinateFrame { :>> mRefs = (m, m, m); } + attribute datum : CartesianSpatial3dCoordinateFrame { :>> mRefs = (mm, mm, mm); } + attribute warm : ThermodynamicTemperatureValue = ConvertQuantity(300.0 [K], SI::'°C_abs'); + %s + attribute value : %s = %s; + } + } + `, tc.body, tc.declared, tc.value) + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Holder", ast.DefPart) + if sym == nil { + t.Fatal("part def Holder not found") + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate err = %v", err) + } + _, err = inst.GetFeatureValue(ctx, "value") + if tc.want == nil { + if err != nil { + t.Fatalf("value err = %v, want a value", err) + } + return + } + if !errors.Is(err, tc.want) { + t.Fatalf("value err = %v, want %v", err, tc.want) + } + if !strings.Contains(err.Error(), tc.message) { + t.Fatalf("value err = %v, want it to name %q", err, tc.message) + } + }) + } +} + +// placementBody declares a frame placed in datum, for the transform failure modes. +const placementBody = ` + attribute shifted : CartesianSpatial3dCoordinateFrame { + :>> mRefs = (mm, mm, mm); + :>> transformation : CoordinateFramePlacement { :>> source = datum; :>> origin = (10.0, 20.0, 30.0) [datum]; } + }` + +// testExpressionOverAFeatureValueHoldingNoValue: a valueless feature of a value type is +// read without an error and reports that it holds no value, while an expression +// computing over it reports which feature holds none rather than a number or a panic. +func testExpressionOverAFeatureValueHoldingNoValue(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Holder { + attribute d : Real; + attribute n : Real = d + 1.0; + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Holder", ast.DefPart) + if sym == nil { + t.Fatal("Holder part def not found") + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + + fv, err := inst.GetFeatureValue(ctx, "d") + if err != nil { + t.Fatalf("feature value d: %v", err) + } + if !ctx.HoldsNoValue(fv.HeldValue()) { + t.Errorf("feature value d holds %v, want no value", fv.HeldValue()) + } + + _, err = inst.GetFeatureValue(ctx, "n") + var noValue *NoValueError + if !errors.As(err, &noValue) { + t.Fatalf("feature value n err = %v, want *NoValueError", err) + } + if noValue.Feature != "d" || noValue.Symbol == nil || noValue.Symbol.Name != "d" { + t.Errorf("feature value n reports no value for %q (%v), want feature d", noValue.Feature, noValue.Symbol) + } + + // A value naming an object the context does not hold answers the question + // rather than panicking on the lookup. + if ctx.HoldsNoValue(Value{Kind: ValInstance, Instance: 1 << 30}) { + t.Error("a value naming no object reads as holding none") + } +} + +// testClassificationOutsideTheEvaluableSubset: a classification the evaluator +// cannot judge — no subject to classify, a subject that is a datum, an +// unresolved metadata type, or a subject naming nothing — reports +// ErrFilterUnevaluable rather than silently answering false. +func testClassificationOutsideTheEvaluableSubset(t *testing.T) { + const model = ` + metadata def Safety; + #Safety part def Belt; + attribute level = 3; + ` + for _, tc := range []struct{ name, cond string }{ + {"implicit subject outside an object", "@Safety"}, + {"self outside an object", "self @ Safety"}, + {"a datum subject", "42 @ Safety"}, + {"a string subject", `"belt" @ Safety`}, + {"an unresolved metadata type", "Belt @ Nonexistent"}, + } { + src := model + "\nconstraint c { " + tc.cond + " }" + got, err := constraintVerdict(t, src, "c") + if got { + t.Errorf("%s: `%s` was satisfied, want a report", tc.name, tc.cond) + } + if !errors.Is(err, semantics.ErrFilterUnevaluable) { + t.Errorf("%s: `%s` err = %v, want ErrFilterUnevaluable", tc.name, tc.cond, err) + } + } + // A subject naming nothing is the unresolved reference it is, not a verdict. + if got, err := constraintVerdict(t, model+"\nconstraint c { Missing @ Safety }", "c"); got || err == nil { + t.Errorf("`Missing @ Safety` = %v err=%v, want a report", got, err) + } +} + +// testMetaCastFailureModes: `x meta T` reflects on the element x names, so a +// datum, an unresolved type or a feature the metaclass lacks or does not derive +// each report a typed error naming what is wrong, never a guessed metaobject. +// A member read through a cast that matched nothing is the empty sequence +// every feature chain over `()` is. `Comment::body` of a model never given its +// notation (no SetSourceText) is underived, not an empty string. +func testMetaCastFailureModes(t *testing.T) { + const model = ` + package test { + part def Vehicle { + doc /* Carries. */ + } + part seatBelt : Vehicle; + }` + _, got, err := evalDeclaredExpr(t, model, "(test::seatBelt meta SysML::PartDefinition).declaredName") + if err != nil || got.Kind != ValSequence || got.Sequence().Size() != 0 { + t.Errorf("member of an empty cast = %s, %v; want ()", FormatValue(got), err) + } + for _, tc := range []struct { + name, expr, names string + want error + }{ + {"a datum subject", "42 meta KerML::Feature", "element", semantics.ErrFilterUnevaluable}, + {"a string subject", `"belt" meta KerML::Feature`, "element", semantics.ErrFilterUnevaluable}, + {"an unresolved subject", "test::nope meta KerML::Feature", "nope", ErrUnresolvedReference}, + {"an unresolved type", "test::seatBelt meta KerML::Nonexistent", "KerML::Nonexistent", ErrUnresolvedType}, + {"a feature the metaclass lacks", "(test::seatBelt meta KerML::Feature).wheels", "wheels", ErrNoSuchFeature}, + {"a feature the runtime does not derive", "(test::seatBelt meta KerML::Feature).ownedRelationship", "ownedRelationship", ErrReflectiveFeatureUnsupported}, + {"a documentation body without the notation", "(test::Vehicle meta KerML::Element).documentation.body", "body", ErrReflectiveFeatureUnsupported}, + } { + _, got, err := evalDeclaredExpr(t, model, tc.expr) + if err == nil { + t.Errorf("%s: `%s` = %s, want a typed error", tc.name, tc.expr, FormatValue(got)) + continue + } + if !errors.Is(err, tc.want) { + t.Errorf("%s: `%s` err = %v, want %v", tc.name, tc.expr, err, tc.want) + } + if !strings.Contains(err.Error(), tc.names) { + t.Errorf("%s: `%s` err = %q, want it to name %q", tc.name, tc.expr, err, tc.names) + } + } +} + +// testMultiplicityInfiniteLowerBound: `[*..*]` requires unboundedly many objects, +// which cannot be materialized, so the feature value reports a multiplicity violation +// rather than allocating until memory runs out. +func testMultiplicityInfiniteLowerBound(t *testing.T) { + inst, ctx := instantiateHolder(t, ` + package test { + private import ScalarValues::Real; + part def C { attribute m : Real = 1.0; } + part def Holder { part p : C[*..*]; } + } + `) + _, err := inst.GetFeatureValue(ctx, "p") + if err == nil { + t.Fatal("want a multiplicity violation, got a materialized feature value") + } + if !errors.Is(err, ErrMultiplicityViolation) { + t.Errorf("expected ErrMultiplicityViolation, got: %v", err) + } +} + +// testMultiplicityLowerBoundTooLarge: a lower bound past the materialization +// bound is reported instead of eagerly allocating that many objects. +func testMultiplicityLowerBoundTooLarge(t *testing.T) { + inst, ctx := instantiateHolder(t, ` + package test { + private import ScalarValues::Real; + part def C { attribute m : Real = 1.0; } + part def Holder { part p : C[5000]; } + } + `) + _, err := inst.GetFeatureValue(ctx, "p") + if err == nil { + t.Fatal("want a multiplicity violation, got a materialized feature value") + } + if !errors.Is(err, ErrMultiplicityViolation) { + t.Errorf("expected ErrMultiplicityViolation, got: %v", err) + } + if len(ctx.instances) > 100 { + t.Errorf("materialized %d instances before reporting the bound", len(ctx.instances)) + } +} + +// testDefaultNotConformingToMultiplicity: a default whose element count is +// outside the feature's multiplicity is reported, rather than broadcast to fill +// the lower bound, truncated to the upper one, or dropped. +func testDefaultNotConformingToMultiplicity(t *testing.T) { + for _, tc := range []struct { + name string + decl string + }{ + {"one value against three", "attribute xs : Real[3] = 1.0;"}, + {"four values against three", "attribute xs : Real[3] = (1.0, 2.0, 3.0, 4.0);"}, + {"no values against one or more", "attribute xs : Real[1..3] = ();"}, + {"an expression producing too few", "attribute m : Real = 1.0; attribute xs : Real[2] = m;"}, + } { + t.Run(tc.name, func(t *testing.T) { + inst, ctx := instantiateHolder(t, ` + package test { + private import ScalarValues::Real; + part def Holder { `+tc.decl+` } + } + `) + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("panic: %v", r) + } + }() + _, err := inst.GetFeatureValue(ctx, "xs") + done <- err + }() + select { + case err := <-done: + if err == nil { + t.Fatal("want a multiplicity violation, got a materialized feature value") + } + if !errors.Is(err, ErrMultiplicityViolation) { + t.Errorf("expected ErrMultiplicityViolation, got: %v", err) + } + case <-watchdog(5 * time.Second): + t.Fatal("materializing the default did not terminate") + } + }) + } +} + +// testDefaultAgainstAnUndeclaredMultiplicity: a feature that declares no +// multiplicity holds exactly one value, so a default of any other number of +// values is reported rather than held under an unconstrained bound. +func testDefaultAgainstAnUndeclaredMultiplicity(t *testing.T) { + for _, tc := range []struct { + name string + decl string + reported bool + }{ + {"one value", "attribute xs : Real = 1.0;", false}, + {"one value of an untyped feature", "attribute xs = 1.0;", false}, + {"two values", "attribute xs : Real = (1.0, 2.0);", true}, + {"two values of an untyped feature", "attribute xs = (1.0, 2.0);", true}, + {"no values", "attribute xs : Real = ();", true}, + {"an expression producing two", "attribute m : Real[2] = (1.0, 2.0); attribute xs : Real = m;", true}, + } { + t.Run(tc.name, func(t *testing.T) { + inst, ctx := instantiateHolder(t, ` + package test { + private import ScalarValues::Real; + part def Holder { `+tc.decl+` } + } + `) + _, err := inst.GetFeatureValue(ctx, "xs") + switch { + case tc.reported && err == nil: + t.Fatalf("%s was held, want a multiplicity violation", tc.decl) + case tc.reported && !errors.Is(err, ErrMultiplicityViolation): + t.Errorf("expected ErrMultiplicityViolation, got: %v", err) + case !tc.reported && err != nil: + t.Errorf("%s was reported: %v", tc.decl, err) + } + }) + } +} + +// testFeatureChainThroughAnUnsetFeatureValue: a chain over a collection whose objects +// hold no value for the last feature names that feature, rather than reading it +// as an empty collection. +func testFeatureChainThroughAnUnsetFeatureValue(t *testing.T) { + inst, ctx := instantiateHolder(t, ` + package test { + private import ScalarValues::Real; + private import RealFunctions::*; + part def Sub { attribute volume : Real; } + part def Holder { + part subs : Sub[2]; + attribute total : Real = sum(subs.volume); + } + } + `) + _, err := inst.GetFeatureValue(ctx, "total") + if err == nil { + t.Fatal("want the unset feature value's error, got a value") + } + if !errors.Is(err, ErrUninitializedFeatureValue) { + t.Errorf("expected ErrUninitializedFeatureValue, got: %v", err) + } + if !strings.Contains(err.Error(), "volume") { + t.Errorf("error %q does not name the unset feature", err) + } +} + +// testFeatureChainSpendsTheElementBudget: navigating a chain through a collection +// counts what it collects, so a chain over a large collection ends within the +// element budget rather than growing unbounded. +func testFeatureChainSpendsTheElementBudget(t *testing.T) { + inst, ctx := instantiateHolder(t, ` + package test { + private import ScalarValues::Real; + part def Sub { attribute volume : Real = 1.0; } + part def Holder { + part subs : Sub[10]; + attribute volumes : Real[*] = subs.volume; + } + } + `) + ctx.maxElements = 15 + _, err := inst.GetFeatureValue(ctx, "volumes") + if err == nil { + t.Fatal("want the element budget's error, got a value") + } + if !errors.Is(err, ErrElementLimitExceeded) { + t.Errorf("expected ErrElementLimitExceeded, got: %v", err) + } +} + +// testMutuallySubsettingFeatures: two features that subset each other have no +// well-founded set of values, so materializing one reports the cycle instead of +// recursing until the step budget runs out. +func testMutuallySubsettingFeatures(t *testing.T) { + inst, ctx := instantiateHolder(t, ` + package test { + private import ScalarValues::Real; + part def C { attribute m : Real = 1.0; } + part def Holder { + part a : C[*] :> b; + part b : C[*] :> a; + } + } + `) + done := make(chan struct{}) + var fvErr error + go func() { + defer close(done) + _, fvErr = inst.GetFeatureValue(ctx, "a") + }() + select { + case <-done: + case <-watchdog(5 * time.Second): + t.Fatal("GetFeatureValue hung on mutually subsetting features") + } + if fvErr == nil { + t.Fatal("want the cyclic feature value's error, got a materialized feature value") + } + if !errors.Is(fvErr, ErrCyclicFeatureValue) { + t.Errorf("expected ErrCyclicFeatureValue, got: %v", fvErr) + } +} + +// instantiateHolder instantiates the `Holder` part def the source declares, for +// a case whose failure surfaces when one of its feature values is read. +func instantiateHolder(t *testing.T, src string) (*Instance, *Context) { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Holder", ast.DefPart) + if sym == nil { + t.Fatal("Holder part def not found") + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + return inst, ctx +} + +// testSequenceIndexNamesNoPosition: an index outside the sequence, or one that +// is not a whole number, is reported. Answering nothing would make `seq#(i)` +// read as an empty value everywhere a model indexes past the end. +func testSequenceIndexNamesNoPosition(t *testing.T) { + for _, tt := range []struct { + expr string + want error + }{ + {"xs#(4)", ErrIndexOutOfRange}, + {"xs#(0)", ErrIndexOutOfRange}, + {"xs#(0 - 1)", ErrIndexOutOfRange}, + {"()#(1)", ErrIndexOutOfRange}, + {"xs#(1.5)", ErrTypeMismatch}, + {"xs#(ys)", ErrTypeMismatch}, + } { + got, err := evalCollectionExpr(t, tt.expr) + if !errors.Is(err, tt.want) { + t.Errorf("%s = (%v, %v), want %v", tt.expr, got, err, tt.want) + } + } +} + +// testNumericLibraryCallThatHasNoValue: a vector, Complex or sequence library +// declaration that cannot answer reports itself — a malformed argument by kind or +// dimension, an undefined result, or a declaration this runtime has no +// representation for the values of — rather than computing something else. +func testNumericLibraryCallThatHasNoValue(t *testing.T) { + for _, tt := range []struct { + expr string + want error + }{ + {"VectorFunctions::cartesianInner(xs, ys)", ErrTypeMismatch}, + {"VectorFunctions::'cartesian+'(xs, ys)", ErrTypeMismatch}, + {"VectorFunctions::cartesianNorm(flags)", ErrTypeMismatch}, + {"VectorFunctions::cartesianAngle(xs, (0.0, 0.0, 0.0))", semantics.ErrArithmeticDomain}, + {"VectorFunctions::vectorScalarDiv(xs, 0)", ErrDivisionByZero}, + {"VectorFunctions::cartesianInner(xs)", ErrCalcArity}, + // Flat numbers are no collection of vectors; unequal dimensions have no + // sum or inner product; Booleans or a two-component three-vector are no vector. + {"VectorFunctions::sum(xs)", ErrTypeMismatch}, + {"VectorFunctions::sum0(xs, VectorFunctions::VectorOf(xs))", ErrTypeMismatch}, + {"VectorFunctions::sum0((), VectorFunctions::VectorOf(xs))", ErrTypeMismatch}, + {"VectorFunctions::sum((VectorFunctions::VectorOf(xs), VectorFunctions::VectorOf(ys)))", ErrTypeMismatch}, + {"VectorFunctions::inner(VectorFunctions::VectorOf(xs), VectorFunctions::VectorOf(ys))", ErrTypeMismatch}, + {"VectorFunctions::VectorOf(xs) + VectorFunctions::VectorOf(ys)", ErrTypeMismatch}, + {"VectorFunctions::VectorOf(flags)", ErrTypeMismatch}, + {"VectorFunctions::CartesianThreeVectorOf(ys)", ErrMultiplicityViolation}, + {"VectorFunctions::angle(VectorFunctions::VectorOf((0.0, 0.0)), VectorFunctions::VectorOf(ys))", semantics.ErrArithmeticDomain}, + // A quantity's num is Number[1..*]: a vector of no components takes no unit, + // whether written `[m]`, scaled by a scalar quantity, or divided by one. + {"VectorFunctions::CartesianVectorOf(()) [SI::m]", ErrMultiplicityViolation}, + {"VectorFunctions::norm(VectorFunctions::CartesianVectorOf(()) [SI::m])", ErrMultiplicityViolation}, + {"VectorCalculations::scalarQuantityVectorMult(2 [SI::m], VectorFunctions::CartesianVectorOf(()))", ErrMultiplicityViolation}, + {"VectorCalculations::vectorScalarQuantityMult(VectorFunctions::VectorOf(()), 2 [SI::m])", ErrMultiplicityViolation}, + {"VectorCalculations::vectorScalarQuantityDiv(VectorFunctions::VectorOf(()), 2 [SI::s])", ErrMultiplicityViolation}, + {"VectorFunctions::VectorOf(xs) / 0", ErrDivisionByZero}, + {"VectorCalculations::vectorScalarQuantityDiv(VectorFunctions::VectorOf(xs) [SI::m], 0 [SI::s])", ErrDivisionByZero}, + {"VectorCalculations::scalarQuantityVectorMult(2 [SI::m], flags)", ErrTypeMismatch}, + {"ComplexFunctions::'/'(ComplexFunctions::rect(0.0, 1.0), ComplexFunctions::rect(0.0, 0.0))", ErrDivisionByZero}, + {"ComplexFunctions::re(xs)", ErrTypeMismatch}, + {"ComplexFunctions::re(ys)", ErrTypeMismatch}, + {"ComplexFunctions::ToString(ComplexFunctions::rect(0.0, 1.0))", ErrUnevaluableLibraryFunction}, + // includingAt inserts before a position of 1..size+1, so an index past the + // end of the sequence names no insertion point and is reported rather than + // appending or dropping the values. + {"SequenceFunctions::includingAt(xs, 9, 5)", ErrIndexOutOfRange}, + {"SequenceFunctions::includingAt(xs, 9, 0)", ErrIndexOutOfRange}, + {"SequenceFunctions::includingAt((), 9, 2)", ErrIndexOutOfRange}, + {"SequenceFunctions::includingAt(xs, 9, 1.5)", ErrTypeMismatch}, + {"SequenceFunctions::includingAt(xs, 9)", ErrCalcArity}, + } { + got, err := evalCollectionExpr(t, tt.expr) + if !errors.Is(err, tt.want) { + t.Errorf("%s = (%v, %v), want %v", tt.expr, got, err, tt.want) + } + } +} + +// testNamedLibraryCallThatHasNoValue: a conversion, operator-call form, control +// function, aggregation or unevaluable declaration called by name reports itself +// by a typed error — no panic, no zero, no answer of another kind. +func testNamedLibraryCallThatHasNoValue(t *testing.T) { + for _, tt := range []struct { + expr string + want error + }{ + {`RealFunctions::ToReal("1.5 meters")`, ErrInvalidNotation}, + {`RealFunctions::ToReal("NaN")`, ErrInvalidNotation}, + {`RealFunctions::ToReal(" 1.5 ")`, ErrInvalidNotation}, + {`IntegerFunctions::ToInteger(" 7")`, ErrInvalidNotation}, + {`RationalFunctions::ToRational("1/3")`, ErrInvalidNotation}, + {`IntegerFunctions::ToInteger("2.0")`, ErrInvalidNotation}, + {`IntegerFunctions::ToInteger("99999999999999999999")`, semantics.ErrArithmeticOverflow}, + {`RealFunctions::ToInteger(1.0e300)`, semantics.ErrArithmeticOverflow}, + {`BooleanFunctions::ToBoolean("yes")`, ErrInvalidNotation}, + {`IntegerFunctions::ToNatural(-1)`, semantics.ErrArithmeticDomain}, + {`NaturalFunctions::ToNatural("-1")`, semantics.ErrArithmeticDomain}, + {`RealFunctions::ToReal(xs)`, ErrTypeMismatch}, + {`RationalFunctions::gcd(1.5, 2)`, semantics.ErrArithmeticDomain}, + {`RationalFunctions::gcd("1", 2)`, ErrTypeMismatch}, + {`RationalFunctions::gcd(1.0e19, 1.0e19)`, semantics.ErrArithmeticOverflow}, + {`RationalFunctions::rat(1, 0)`, ErrDivisionByZero}, + {`RationalFunctions::rat(1.5, 3)`, ErrTypeMismatch}, + {`RationalFunctions::rat(xs, 3)`, ErrTypeMismatch}, + {`RationalFunctions::numer("0.5")`, ErrTypeMismatch}, + {`RationalFunctions::numer(1.0e19)`, semantics.ErrArithmeticOverflow}, + {`RationalFunctions::denom(0.0001)`, semantics.ErrArithmeticOverflow}, + {`CollectionFunctions::'array#'(xs, (1, 1))`, ErrTypeMismatch}, + {`OccurrenceFunctions::isDuring(xs)`, ErrMultiplicityViolation}, + {`OccurrenceFunctions::isDuring(factor)`, ErrNotAnOccurrence}, + {`OccurrenceFunctions::'==='(xs, xs)`, ErrMultiplicityViolation}, + {`OccurrenceFunctions::'==='(factor, factor)`, ErrNotAnOccurrence}, + {`OccurrenceFunctions::create(factor)`, ErrNotAnOccurrence}, + {`OccurrenceFunctions::destroy(factor)`, ErrNotAnOccurrence}, + {`OccurrenceFunctions::addNew(xs)`, ErrCalcArity}, + {`OccurrenceFunctions::addNew(occ = xs)`, ErrMultiplicityViolation}, + {`OccurrenceFunctions::addNew(xs, factor)`, ErrNotAnOccurrence}, + {`OccurrenceFunctions::addNewAt(xs, xs)`, ErrCalcArity}, + {`OccurrenceFunctions::addNewAt(occ = xs, index = 1)`, ErrMultiplicityViolation}, + {`OccurrenceFunctions::addNewAt((), factor, 0)`, ErrNotAnOccurrence}, + {`IntegerFunctions::'+'("a", 1)`, ErrTypeMismatch}, + {`IntegerFunctions::'/'(1, 0)`, ErrDivisionByZero}, + {`NaturalFunctions::'/'(7, 2)`, semantics.ErrArithmeticDomain}, + {`NaturalFunctions::'/'(6, 0)`, ErrDivisionByZero}, + {`NaturalFunctions::'/'(6, -3)`, ErrTypeMismatch}, + {`IntegerFunctions::'=='(2, 2.0)`, ErrTypeMismatch}, + {`BooleanFunctions::'=='(true, 1)`, ErrTypeMismatch}, + {`BaseFunctions::ToString(xs)`, ErrMultiplicityViolation}, + {`IntegerFunctions::'%'(1, 0)`, ErrDivisionByZero}, + {`IntegerFunctions::'*'(9223372036854775807, 2)`, semantics.ErrArithmeticOverflow}, + {`RealFunctions::'**'(-8.0, 0.5)`, semantics.ErrArithmeticDomain}, + {`ScalarFunctions::'<'("a", 1)`, ErrTypeMismatch}, + {`BooleanFunctions::'xor'(true, 1)`, ErrTypeMismatch}, + {`DataFunctions::max(true, false)`, ErrTypeMismatch}, + {`ScalarFunctions::min(xs, ys)`, ErrMultiplicityViolation}, + {`IntegerFunctions::'+'(xs, 1)`, ErrMultiplicityViolation}, + {`IntegerFunctions::'+'(1, xs)`, ErrMultiplicityViolation}, + {`IntegerFunctions::'+'((), 1)`, ErrMultiplicityViolation}, + {`IntegerFunctions::'-'(xs)`, ErrMultiplicityViolation}, + {`RealFunctions::'<'(xs, 2.0)`, ErrMultiplicityViolation}, + {`BooleanFunctions::'xor'(flags, true)`, ErrMultiplicityViolation}, + {`BooleanFunctions::'not'(flags)`, ErrMultiplicityViolation}, + {`BooleanFunctions::'not'(())`, ErrMultiplicityViolation}, + {`NaturalFunctions::'/'(xs, 2)`, ErrMultiplicityViolation}, + {`ScalarFunctions::'..'(1.5, 3)`, ErrTypeMismatch}, + {`BaseFunctions::'#'(xs, 0)`, ErrIndexOutOfRange}, + {`ControlFunctions::'if'(1, 2, 3)`, ErrTypeMismatch}, + {`ControlFunctions::'if'(true, {in x; x}, 3)`, ErrBodyArity}, + {`ControlFunctions::'and'(true, 1)`, ErrTypeMismatch}, + {`ControlFunctions::'and'(1, true)`, ErrTypeMismatch}, + {`ControlFunctions::'and'(true)`, ErrMultiplicityViolation}, + {`ControlFunctions::'or'(false)`, ErrMultiplicityViolation}, + {`ControlFunctions::'implies'(true)`, ErrMultiplicityViolation}, + {`ControlFunctions::'implies'(true, xs)`, ErrTypeMismatch}, + {`NumericalFunctions::sum0(xs, 1)`, ErrTypeMismatch}, + {`NumericalFunctions::product1(xs, 0)`, ErrTypeMismatch}, + {`NumericalFunctions::sum0(flags, 0)`, ErrTypeMismatch}, + {`NumericalFunctions::sum0((9223372036854775807, 1), 0)`, semantics.ErrArithmeticOverflow}, + {`NumericalFunctions::sum0(xs)`, ErrCalcArity}, + } { + got, err := evalCollectionExpr(t, tt.expr) + if !errors.Is(err, tt.want) { + t.Errorf("%s = (%v, %v), want %v", tt.expr, got, err, tt.want) + } + } +} + +// testBuiltinNamedArgumentThatBindsNothing: a named argument to a built-in that +// names no parameter, names one twice, sits beside a positional argument, or +// leaves a required parameter unbound is reported rather than bound by position; +// a body passed by reference that denotes no body is reported too. +func testBuiltinNamedArgumentThatBindsNothing(t *testing.T) { + for _, tt := range []struct { + expr string + want error + }{ + {`NumericalFunctions::sum0(zero = 0, elements = xs)`, ErrUnknownParameter}, + {`NumericalFunctions::sum0(zero = 0, zero = 1)`, ErrCalcArity}, + {`NumericalFunctions::sum0(zero = 0, zero = 1, elements = xs)`, ErrCalcArity}, + {`NumericalFunctions::sum0(collection = xs)`, ErrCalcArity}, + {`ControlFunctions::'if'(thenValue = 1, elseValue = 2)`, ErrCalcArity}, + {`ControlFunctions::'if'(test = true, thenValue = {in x; x})`, ErrBodyArity}, + {`ControlFunctions::'and'(secondValue = true)`, ErrCalcArity}, + {`ControlFunctions::'if'()`, ErrCalcArity}, + {`ControlFunctions::'and'()`, ErrCalcArity}, + {`SequenceFunctions::subsequence(xs)`, ErrCalcArity}, + {`SequenceFunctions::subsequence(seq = xs)`, ErrCalcArity}, + {`SequenceFunctions::size(xs, 1)`, ErrCalcArity}, + {`SequenceFunctions::'#'(xs, 1, 2)`, ErrCalcArity}, + {`SequenceFunctions::'#'(seq = xs, index = 0)`, ErrIndexOutOfRange}, + {`ControlFunctions::select(collection = xs, selector = factor)`, ErrTypeMismatch}, + {`xs->select factor`, ErrTypeMismatch}, + } { + got, err := evalCollectionExpr(t, tt.expr) + if !errors.Is(err, tt.want) { + t.Errorf("%s = (%v, %v), want %v", tt.expr, got, err, tt.want) + } + } +} + +// testBodilessModelCalcNamedAsABuiltin: a model's own calc declared under a +// library function's qualified name — a collection built-in, a conversion, an +// operator form — is the model's, so without a body it computes nothing rather +// than what the library's declaration of that name computes. +func testBodilessModelCalcNamedAsABuiltin(t *testing.T) { + src := ` + package NumericalFunctions { + private import ScalarValues::*; + calc def sum0 { in collection : Integer[*]; in zero : Integer; return : Integer; } + } + package RealFunctions { + private import ScalarValues::*; + calc def ToReal { in x : String; return : Real; } + calc def '+' { in x : Real; in y : Real; return : Real; } + } + package test { + private import ScalarValues::*; + calc def Total { return : Integer = NumericalFunctions::sum0((1, 2, 3), 0); } + calc def size { in seq : Integer[*]; return : Integer; } + calc def Size { return : Integer = size((1, 2, 3)); } + calc def Parsed { return : Real = RealFunctions::ToReal("1.5"); } + calc def Added { return : Real = RealFunctions::'+'(1.0, 2.0); } + } + ` + for _, calc := range []string{"Total", "Size", "Parsed", "Added"} { + err := calcErrorWithLibraries(t, src, calc, nil, 10000) + if !errors.Is(err, ErrNoResultExpression) { + t.Errorf("%s = %v, want %v", calc, err, ErrNoResultExpression) + } + } +} + +// testDataEqualityOverAPart: DataFunctions' `'=='` and `'==='` are declared over +// DataValue, so a part given to either is refused rather than compared. +func testDataEqualityOverAPart(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Widget; + attribute def Point { attribute x : Integer; } + calc def SameData { in a : Point; in b : Point; return : Boolean = DataFunctions::'=='(a, b); } + calc def SamePart { in a : Widget; in b : Widget; return : Boolean = DataFunctions::'=='(a, b); } + calc def IdenticalPart { in a : Widget; in b : Widget; return : Boolean = DataFunctions::'==='(a, b); } + calc def SameAnything { in a : Widget; in b : Widget; return : Boolean = BaseFunctions::'=='(a, b); } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + object := func(name string) Value { + matches := idx.LookupQualified("test::" + name) + if len(matches) != 1 { + t.Fatalf("test::%s: %d matching symbols, want 1", name, len(matches)) + } + inst, err := ctx.Instantiate(matches[0]) + if err != nil { + t.Fatalf("Instantiate(%s): %v", name, err) + } + return Value{Kind: ValInstance, Instance: inst.ID} + } + point, widget := object("Point"), object("Widget") + invoke := func(calc string, args ...Value) (Value, error) { + sym, scope := calcByName(t, idx.DocumentRoot(""), "test", calc) + return ctx.InvokeCalc(sym, args, scope) + } + for _, calc := range []string{"SamePart", "IdenticalPart"} { + _, err := invoke(calc, widget, widget) + if !errors.Is(err, ErrTypeMismatch) || !strings.Contains(err.Error(), "a DataValue") { + t.Errorf("%s = %v, want %v naming DataValue", calc, err, ErrTypeMismatch) + } + } + for calc, args := range map[string][]Value{"SameData": {point, point}, "SameAnything": {widget, widget}} { + got, err := invoke(calc, args...) + if err != nil || !valueIdentical(got, constBool(true)) { + t.Errorf("%s = %s, %v; want true", calc, FormatValue(got), err) + } + } +} + +// testBaseIndexWithSeveralIndexes: several indexes address an Array, so a flat +// sequence, a rank mismatch, an out-of-range, ragged or oversized Array is each +// reported. +func testBaseIndexWithSeveralIndexes(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + private import Collections::*; + attribute a : Array { :>> dimensions = (2, 3); :>> elements = (1, 2, 3, 4, 5, 6); } + attribute ragged : Array { :>> dimensions = (2, 2); :>> elements = (1, 2, 3); } + attribute vast : Array { :>> dimensions = (4611686018427387904, 4); :>> elements = (); } + calc def Cell { return : Integer = BaseFunctions::'#'((1, 2, 3, 4), (2, 2)); } + calc def NoIndex { return : Integer = BaseFunctions::'#'((1, 2, 3, 4), ()); } + calc def OneIndex { return : Integer = a#(4); } + calc def ThreeIndexes { return : Integer = a#(1, 1, 1); } + calc def PastRow { return : Integer = a#(3, 1); } + calc def PastColumn { return : Integer = a#(1, 4); } + calc def ZeroIndex { return : Integer = a#(0, 1); } + calc def Ragged { return : Integer = ragged#(1, 1); } + calc def Vast { return : Integer = vast#(4611686018427387904, 4); } + } + ` + for calc, want := range map[string]error{ + "Cell": ErrTypeMismatch, + "NoIndex": ErrMultiplicityViolation, + "OneIndex": ErrMultiplicityViolation, + "ThreeIndexes": ErrMultiplicityViolation, + "PastRow": ErrIndexOutOfRange, + "PastColumn": ErrIndexOutOfRange, + "ZeroIndex": ErrIndexOutOfRange, + } { + err := calcErrorWithLibraries(t, src, calc, nil, 10000) + if !errors.Is(err, want) || !strings.Contains(err.Error(), "BaseFunctions::'#'") { + t.Errorf("%s = %v, want %v naming BaseFunctions::'#'", calc, err, want) + } + } + err := calcErrorWithLibraries(t, src, "Ragged", nil, 10000) + if !errors.Is(err, ErrMultiplicityViolation) || !strings.Contains(err.Error(), "flattenedSize") { + t.Errorf("Ragged = %v, want %v naming flattenedSize", err, ErrMultiplicityViolation) + } + err = calcErrorWithLibraries(t, src, "Vast", nil, 10000) + if !errors.Is(err, semantics.ErrArithmeticOverflow) || !strings.Contains(err.Error(), "flattenedSize") { + t.Errorf("Vast = %v, want %v naming flattenedSize", err, semantics.ErrArithmeticOverflow) + } +} + +// testStructuredValueOutsideTheDeclaredShape: a type specializing Array or a +// vector type fixes a shape or element type, so a value of another shape or +// element type is refused, while one that fits is held — including by a +// NumericalVectorValue specialization beside the Cartesian ones. +func testStructuredValueOutsideTheDeclaredShape(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + private import Collections::*; + private import VectorValues::*; + private import VectorFunctions::*; + private import Quantities::*; + private import ISQ::*; + private import SI::*; + attribute def Grid :> Array { :>> dimensions = (2, 2); } + attribute def Row4 :> Array { :>> rank = 1; :>> flattenedSize = 4; } + attribute def IntArray :> Array { :>> elements : Integer; } + attribute def Fixed3 :> CartesianThreeVectorValue; + attribute def OneDim :> NumericalVectorValue; + attribute def IntVec :> NumericalVectorValue { :>> elements : Integer; } + attribute def IntThree :> ThreeVectorValue { :>> elements : Integer; } + attribute def Fixed2 :> NumericalVectorValue { :>> dimension = 2; } + attribute def Four :> Array { :>> elements : Integer[4]; } + attribute def Vel3 :> VectorQuantityValue { :>> num : Real[3]; } + attribute def IntVQ :> VectorQuantityValue { :>> num : Integer; } + attribute square : Array { :>> dimensions = (2, 2); :>> elements = (1, 2, 3, 4); } + attribute wide : Array { :>> dimensions = (2, 3); :>> elements = (1, 2, 3, 4, 5, 6); } + attribute row : Array { :>> dimensions = 4; :>> elements = (1, 2, 3, 4); } + attribute reals : Array { :>> dimensions = 2; :>> elements = (1.5, 2.5); } + calc def TwoAsThree { return : CartesianThreeVectorValue = VectorOf((1.0, 2.0)); } + calc def TwoAsFixed3 { return : Fixed3 = VectorOf((1.0, 2.0)); } + calc def ThreeAsThree { return : CartesianThreeVectorValue = VectorOf((1.0, 2.0, 3.0)); } + calc def ThreeAsFixed3 { return : Fixed3 = VectorOf((1.0, 2.0, 3.0)); } + calc def TwoAsThreeVector { return : ThreeVectorValue = VectorOf((1, 2)); } + calc def ThreeAsThreeVector { return : ThreeVectorValue = VectorOf((1, 2, 3)); } + calc def IntsAsIntVec { return : IntVec = VectorOf((1, 2)); } + calc def RealsAsIntVec { return : IntVec = VectorOf((1.5, 2.0)); } + calc def IntsAsIntThree { return : IntThree = VectorOf((1, 2, 3)); } + calc def TwoAsIntThree { return : IntThree = VectorOf((1, 2)); } + calc def RealsAsIntThree { return : IntThree = VectorOf((1.5, 2.0, 3.0)); } + calc def TwoAsFixed2 { return : Fixed2 = VectorOf((1.0, 2.0)); } + calc def ThreeAsFixed2 { return : Fixed2 = VectorOf((1.0, 2.0, 3.0)); } + calc def VectorAsGrid { return : Grid = VectorOf((1, 2, 3, 4)); } + calc def VectorAsString { return : String = VectorOf((1, 2)); } + calc def WideAsGrid { return : Grid = wide; } + calc def SquareAsGrid { return : Grid = square; } + calc def SquareAsRow4 { return : Row4 = square; } + calc def SquareAsOneDim { return : OneDim = square; } + calc def RowAsRow4 { return : Row4 = row; } + calc def RealsAsIntArray { return : IntArray = reals; } + calc def RowAsIntArray { return : IntArray = row; } + calc def RealsAsFour { return : Four = reals; } + calc def SquareAsFour { return : Four = square; } + calc def TwoAsVel3 { return : Vel3 = VectorOf((1.0, 2.0)) [m]; } + calc def ThreeAsVel3 { return : Vel3 = VectorOf((1.0, 2.0, 3.0)) [m]; } + calc def RealsAsIntVQ { return : IntVQ = VectorOf((1.5, 2.5)) [m]; } + calc def IntsAsIntVQ { return : IntVQ = VectorOf((1, 2)) [m]; } + calc def TwoAsScalar { return : ScalarQuantityValue = VectorOf((1.0, 2.0)) [m]; } + calc def TwoAsLength { return : LengthValue = VectorOf((1.0, 2.0)) [m]; } + } + ` + for calc, fixes := range map[string]string{ + "TwoAsThree": "it declares dimension = 3", + "TwoAsFixed3": "it declares dimension = 3", + "TwoAsThreeVector": "it declares dimension = 3", + "TwoAsIntThree": "it declares dimension = 3", + "ThreeAsFixed2": "it declares dimension = 2", + "RealsAsIntVec": "it declares elements : Integer, got element 1.5 (a Real)", + "RealsAsIntThree": "it declares elements : Integer, got element 1.5 (a Real)", + "VectorAsGrid": "cannot write ⟨1, 2, 3, 4⟩ (vector) to a feature typed by Grid", + "VectorAsString": "cannot write ⟨1, 2⟩ (vector) to a feature typed by String", + "WideAsGrid": "it declares dimensions = [2, 2]", + "SquareAsRow4": "it declares rank = 1", + "SquareAsOneDim": "it declares dimension : Positive[0..1], got 2 dimension(s)", + "RealsAsIntArray": "it declares elements : Integer, got element 1.5 (a Real)", + "RealsAsFour": "it declares elements : Integer[4], got 2 element(s)", + "TwoAsVel3": "it declares num : Real[3], got 2 element(s)", + "RealsAsIntVQ": "it declares num : Integer, got element 1.5 (a Real)", + "TwoAsScalar": "to a feature typed by ScalarQuantityValue: it is a ScalarValue, which holds one scalar", + "TwoAsLength": "to a feature typed by LengthValue: it is a ScalarValue, which holds one scalar", + } { + err := calcErrorWithLibraries(t, src, calc, nil, 10000) + if !errors.Is(err, ErrTypeMismatch) || !strings.Contains(err.Error(), fixes) { + t.Errorf("%s = %v, want %v naming %q", calc, err, ErrTypeMismatch, fixes) + } + } + for calc, want := range map[string]string{ + "ThreeAsThree": "⟨1.0, 2.0, 3.0⟩", + "ThreeAsFixed3": "⟨1.0, 2.0, 3.0⟩", + "ThreeAsThreeVector": "⟨1, 2, 3⟩", + "IntsAsIntVec": "⟨1, 2⟩", + "IntsAsIntThree": "⟨1, 2, 3⟩", + "TwoAsFixed2": "⟨1.0, 2.0⟩", + "SquareAsGrid": "Array(2, 2)[1, 2, 3, 4]", + "RowAsRow4": "Array(4)[1, 2, 3, 4]", + "RowAsIntArray": "Array(4)[1, 2, 3, 4]", + "SquareAsFour": "Array(2, 2)[1, 2, 3, 4]", + "ThreeAsVel3": "⟨1.0, 2.0, 3.0⟩ [m]", + "IntsAsIntVQ": "⟨1, 2⟩ [m]", + } { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym, scope := calcByName(t, idx.DocumentRoot(""), "test", calc) + got, err := ctx.InvokeCalc(sym, nil, scope) + if err != nil || FormatValue(got) != want { + t.Errorf("%s = (%s, %v), want %s", calc, FormatValue(got), err, want) + } + } +} + +// testBodyByReferenceThatCannotBeApplied: a body reaching a built-in through an +// `expr` parameter is applied in the scope it was written in, and one selected +// by a control function that declares parameters is reported. +func testBodyByReferenceThatCannotBeApplied(t *testing.T) { + src := ` + package test { + private import ControlFunctions::*; + calc def Pick { in expr chosen; return : Integer = ControlFunctions::'if'(true, chosen, 0); } + calc def Keep { in xs : Integer[*]; in expr pred; return : Integer[*] = xs->select pred; } + calc def PicksUnary { return : Integer = Pick({ in x; x }); } + calc def KeepsUnbound { in xs : Integer[*]; return : Integer[*] = Keep(xs, { in x; x > bound }); } + calc def KeepsPriorFrame { + in xs : Integer[*]; + in pred : Integer; + return : Integer[*] = Keep(xs, { in x; x > pred }); + } + } + ` + for _, tt := range []struct { + calc string + args []Value + want error + }{ + {"PicksUnary", nil, ErrBodyArity}, + {"KeepsUnbound", []Value{constSequence(1, 2)}, ErrUnresolvedReference}, + } { + err := calcErrorWithLibraries(t, src, tt.calc, tt.args, 10000) + if !errors.Is(err, tt.want) { + t.Errorf("%s = %v, want %v", tt.calc, err, tt.want) + } + } + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym, scope := calcByName(t, idx.DocumentRoot(""), "test", "KeepsPriorFrame") + got, err := ctx.InvokeCalc(sym, []Value{constSequence(1, 2, 3), constInt(1)}, scope) + if err != nil { + t.Fatalf("KeepsPriorFrame = %v", err) + } + if want := constSequence(2, 3); !valueIdentical(got, want) { + t.Errorf("KeepsPriorFrame = %s, want %s: the body's pred is its caller's Integer, not Keep's body", FormatValue(got), FormatValue(want)) + } +} + +// testRealLiteralThatUnderflows: a nonzero Real literal too small for a Real is +// reported rather than read as zero. +func testRealLiteralThatUnderflows(t *testing.T) { + for _, tt := range []struct { + expr string + want error + }{ + {`1.0e-400`, semantics.ErrArithmeticOverflow}, + {`1.0e400`, semantics.ErrArithmeticOverflow}, + {`RealFunctions::ToReal("1e-400")`, semantics.ErrArithmeticOverflow}, + } { + got, err := evalCollectionExpr(t, tt.expr) + if !errors.Is(err, tt.want) { + t.Errorf("%s = (%v, %v), want %v", tt.expr, got, err, tt.want) + } + } + got, err := evalCollectionExpr(t, `0.0e-400`) + if err != nil || got.Kind != ValConst || got.Const.Kind != semantics.ValReal || got.Const.Real != 0 { + t.Errorf("0.0e-400 = (%v, %v), want the Real 0", got, err) + } +} + +// testStringOperandOfTheWrongKind: an operator or StringFunctions call given a +// value that is not the String its signature declares is reported rather than +// coerced, and a Substring position naming no character is reported rather than +// clamped. +func testStringOperandOfTheWrongKind(t *testing.T) { + for _, tt := range []struct { + expr string + want error + }{ + {`"a" + 1`, ErrTypeMismatch}, + {`1 + "a"`, ErrTypeMismatch}, + {`"a" < 1`, ErrTypeMismatch}, + {`"a" >= factor`, ErrTypeMismatch}, + {`"a" < xs`, ErrTypeMismatch}, + {`"a" - "b"`, ErrTypeMismatch}, + {`StringFunctions::Length(1)`, ErrTypeMismatch}, + {`StringFunctions::Length(xs)`, ErrTypeMismatch}, + {`StringFunctions::Substring("abc", 1, 9)`, ErrIndexOutOfRange}, + {`StringFunctions::Substring("héllo", 1, 6)`, ErrIndexOutOfRange}, + {`StringFunctions::Substring("abc", 0, 2)`, ErrIndexOutOfRange}, + {`StringFunctions::Substring("abc", "1", 2)`, ErrTypeMismatch}, + {`StringFunctions::Substring("abc", 1)`, ErrCalcArity}, + } { + got, err := evalCollectionExpr(t, tt.expr) + if !errors.Is(err, tt.want) { + t.Errorf("%s = (%v, %v), want %v", tt.expr, got, err, tt.want) + } + } +} + +// testOrderingOperandWithNoLibraryOrdering: an ordering operator over a value +// the Kernel Function Library declares no ordering for — DataFunctions::'<' and +// ScalarFunctions::'<' are abstract; the numeric libraries and StringFunctions +// alone declare it — is a type mismatch naming the operator, both operands and +// the library function that would have to declare it, never a claim that the +// operands are not constants. Where the library does order (a numeric +// enumeration, Strings, quantities) the answer is unchanged. +func testOrderingOperandWithNoLibraryOrdering(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + private import ISQ::*; + private import SI::*; + enum def Color { red; green; blue; } + enum def Level :> Integer { low = 1; high = 3; } + part def Widget; + part widget : Widget; + attribute def Point { attribute x : Integer = 1; } + attribute point : Point; + metadata def Tag; + #Tag part tagged : Widget; + calc twice { in x : Integer; x * 2 } + attribute xs : Integer[*] = (1, 2); + part other : Widget; + attribute widgets : Widget[*] = (widget, other); + attribute nothing : Integer[*] = xs->excluding(1)->excluding(2); + attribute side : LengthValue = 2 [m]; + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + pkg, ok := idx.DocumentRoot("").LookupLocal("test") + if !ok || pkg.Scope == nil { + t.Fatal("test package not indexed") + } + const ( + color = "DataFunctions::'%s' is abstract and no library function declares '%s' for the enumeration Color, which is no ScalarValue" + oneVal = "DataFunctions::'%s' takes one DataValue per operand" + ) + refused := []struct { + expr, op string + what string + library string + }{ + {"Color::red < Color::blue", "<", "the enumeration literal Color::red and the enumeration literal Color::blue", color}, + {"Color::red > Color::blue", ">", "the enumeration literal Color::red and the enumeration literal Color::blue", color}, + {"Color::red <= Color::blue", "<=", "the enumeration literal Color::red and the enumeration literal Color::blue", color}, + {"Color::red >= Color::blue", ">=", "the enumeration literal Color::red and the enumeration literal Color::blue", color}, + {"1 < Color::red", "<", "an Integer and the enumeration literal Color::red", color}, + {"Level::low < Color::red", "<", "an Integer and the enumeration literal Color::red", color}, + {"widget < widget", "<", "an instance and an instance", "DataFunctions::'%s' takes DataValue operands and an instance of the part def Widget is none"}, + {"tagged.metadata#(1) < 1", "<", "an instance and an Integer", "DataFunctions::'%s' takes DataValue operands and an instance of the metadata def Tag is none"}, + {"(tagged meta Tag) < 1", "<", "an instance and an Integer", "DataFunctions::'%s' takes DataValue operands and an instance of the metadata def Tag is none"}, + {"tagged.metadata < 1", "<", "a sequence and an Integer", oneVal}, + {"point < point", "<", "an instance and an instance", "DataFunctions::'%s' is abstract and no library function declares '%s' for an instance of the attribute def Point, which is no ScalarValue"}, + {"twice < 1", "<", "the function test::twice and an Integer", "DataFunctions::'%s' takes DataValue operands and the function test::twice is none"}, + {"widgets < 1", "<", "a sequence and an Integer", oneVal}, + {"xs > 1", ">", "a sequence and an Integer", oneVal}, + {"1 <= xs->including(3)", "<=", "an Integer and a sequence", oneVal}, + {"nothing < 1", "<", "a sequence and an Integer", oneVal}, + {"null >= 1", ">=", "null and an Integer", oneVal}, + {"true < false", "<", "a Boolean and a Boolean", "ScalarFunctions::'%s' is abstract and BooleanFunctions declares no '%s' for Boolean"}, + {"1 < true", "<", "an Integer and a Boolean", "ScalarFunctions::'%s' is abstract and BooleanFunctions declares no '%s' for Boolean"}, + {"2 [m] < true", "<", "a quantity and a Boolean", "ScalarFunctions::'%s' is abstract and BooleanFunctions declares no '%s' for Boolean"}, + {"2 [m] < *", "<", "a quantity and an infinity", "QuantityCalculations::'%s' takes ScalarQuantityValue operands and an infinity is none"}, + {"* >= side", ">=", "an infinity and a quantity", "QuantityCalculations::'%s' takes ScalarQuantityValue operands and an infinity is none"}, + {"side < Color::red", "<", "a quantity and the enumeration literal Color::red", color}, + {"m < s", "<", "a measurement reference and a measurement reference", "DataFunctions::'%s' is abstract and no library function declares '%s' for a measurement reference, which is no ScalarValue"}, + } + for _, tt := range refused { + library := strings.ReplaceAll(tt.library, "%s", tt.op) + want := fmt.Sprintf("type mismatch: operator '%s' is not defined for %s; %s", tt.op, tt.what, library) + got, err := evalIn(t, ctx, pkg.Scope, tt.expr) + var opErr *OperandTypeError + if !errors.As(err, &opErr) || !errors.Is(err, ErrTypeMismatch) { + t.Errorf("%s = (%s, %v), want an OperandTypeError wrapping %v", tt.expr, FormatValue(got), err, ErrTypeMismatch) + continue + } + if err.Error() != want { + t.Errorf("%s:\n got %q\n want %q", tt.expr, err.Error(), want) + } + if strings.Contains(err.Error(), "must be constants") { + t.Errorf("%s: %q claims the operands are not constants", tt.expr, err) + } + if opErr.Span == (source.Span{}) { + t.Errorf("%s: the error carries no span to locate the operator", tt.expr) + } + } + ordered := map[string]bool{ + "Level::low < Level::high": true, + "Level::high <= Level::low": false, + `"a" < "b"`: true, + "2 [m] > 1 [m]": true, + "side >= 200 [cm]": true, + "1 < 2.5": true, + "3 > *": false, + } + for expr, want := range ordered { + got, err := evalIn(t, ctx, pkg.Scope, expr) + if err != nil || !valueIdentical(got, constBool(want)) { + t.Errorf("%s = %s, %v; want %v", expr, FormatValue(got), err, want) + } + } +} + +// testCollectionOperandOfTheWrongKind: an operation given something that is not +// the kind of value it operates on reports it rather than reading the value as a +// collection of itself or as nothing. +func testCollectionOperandOfTheWrongKind(t *testing.T) { + for _, tt := range []struct { + expr string + want error + }{ + {"xs->select(2)", ErrTypeMismatch}, + {"xs->collect(xs)", ErrTypeMismatch}, + {`sum((1, "a"))`, ErrTypeMismatch}, + {"product(flags)", ErrTypeMismatch}, + {"xs->subsequence(1, 4)", ErrIndexOutOfRange}, + } { + got, err := evalCollectionExpr(t, tt.expr) + if !errors.Is(err, tt.want) { + t.Errorf("%s = (%v, %v), want %v", tt.expr, got, err, tt.want) + } + } +} + +// testCollectionBodyOfTheWrongArity: a body an operation calls with one element +// but that declares no parameter, or two, is reported. Binding what it declares +// and dropping the rest would answer from a parameter that was never given a +// value. +func testCollectionBodyOfTheWrongArity(t *testing.T) { + for _, expr := range []string{ + "xs->collect {}", + "xs->collect {in x; in y; x}", + "xs->select {in x; in y; x > 0}", + "xs.{in x; in y; x}", + } { + got, err := evalCollectionExpr(t, expr) + if !errors.Is(err, ErrBodyArity) { + t.Errorf("%s = (%v, %v), want ErrBodyArity", expr, got, err) + } + } +} + +// testSelectPredicateIsNotACondition: a selector answering something that is not +// a boolean is reported. Reading a non-boolean as false would silently drop +// every element, which is a wrong answer rather than a failure. +func testSelectPredicateIsNotACondition(t *testing.T) { + for _, expr := range []string{ + "xs.?{in x; x + 1}", + "xs->select {in x; x * 2}", + "xs->reject {in x; 1}", + "xs->forAll {in x; x}", + `xs->exists {in x; "yes"}`, + } { + got, err := evalCollectionExpr(t, expr) + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("%s = (%v, %v), want ErrTypeMismatch", expr, got, err) + } + } +} + +// testCollectionOperationStepBudget: an operation calls its body once per +// element, and each call spends the context's budget, so a collection large +// enough for the budget fails the evaluation instead of running unbounded. +func testCollectionOperationStepBudget(t *testing.T) { + got, err := evalCollectionExprBounded(t, "xs.{in x; x * factor}", 3) + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("collect under a budget of 3 = (%v, %v), want ErrStepLimitExceeded", got, err) + } +} + +// testSatisfyUnresolvedRequirement: a satisfaction assertion whose requirement +// reference names nothing reports it, rather than evaluating the assertion's own +// empty body as a verdict about the model. +func testSatisfyUnresolvedRequirement(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + part lander; + part context { + assert satisfy nosuch by lander; + } + } + `)) + assertions := ctx.SatisfyAssertionsIn(idx.DocumentRoot("")) + if len(assertions) != 1 { + t.Fatalf("found %d satisfaction assertions, want 1", len(assertions)) + } + + satisfied, err := ctx.EvaluateSatisfaction(assertions[0]) + if err == nil { + t.Fatalf("expected an error, got satisfied = %v", satisfied) + } + if !errors.Is(err, ErrNoRequirement) { + t.Errorf("expected ErrNoRequirement, got: %v", err) + } + if errors.Is(err, ErrViolated) { + t.Error("an unresolved requirement reference is not a violation") + } + if !strings.Contains(err.Error(), "nosuch") { + t.Errorf("error does not name the reference: %v", err) + } +} + +// testSatisfyRequirementWithoutConditions: satisfying a requirement that states +// no condition is not a verdict, since no check ran. +func testSatisfyRequirementWithoutConditions(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + requirement def Documented { + doc /* stated in prose only */ + } + requirement documented : Documented; + part lander; + part context { + assert satisfy documented by lander; + } + } + `)) + assertions := ctx.SatisfyAssertionsIn(idx.DocumentRoot("")) + if len(assertions) != 1 { + t.Fatalf("found %d satisfaction assertions, want 1", len(assertions)) + } + + satisfied, err := ctx.EvaluateSatisfaction(assertions[0]) + if err == nil { + t.Fatalf("expected an error, got satisfied = %v", satisfied) + } + if !errors.Is(err, ErrNoConditions) { + t.Errorf("expected ErrNoConditions, got: %v", err) + } + if satisfied { + t.Error("a requirement with no condition must not report a verdict") + } +} + +// testSatisfyBoundedByTheStepBudget: a satisfaction check is one run, so its +// condition evaluation spends the run's budget instead of resetting it. +func testSatisfyBoundedByTheStepBudget(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + part def Lander { attribute verticalSpeed = 1.2; } + part lander : Lander; + requirement def TouchdownRequirement { + subject craft : Lander; + attribute maxVerticalSpeed = 1.5; + require constraint { craft.verticalSpeed <= maxVerticalSpeed } + } + requirement touchdown : TouchdownRequirement; + part context { + assert satisfy touchdown by lander; + } + } + `)) + assertions := ctx.SatisfyAssertionsIn(idx.DocumentRoot("")) + if len(assertions) != 1 { + t.Fatalf("found %d satisfaction assertions, want 1", len(assertions)) + } + ctx.maxSteps = 2 + + satisfied, err := ctx.EvaluateSatisfaction(assertions[0]) + if err == nil { + t.Fatalf("expected the step budget to bound the check, got satisfied = %v", satisfied) + } + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected ErrStepLimitExceeded, got: %v", err) + } + if errors.Is(err, ErrViolated) { + t.Error("an exhausted budget is not a verdict about the model") + } + + // The subject is built inside the same run, so a budget exhausted there is + // still reported as such rather than as a missing subject. + ctx.maxSteps = 0 + if _, err := ctx.EvaluateSatisfaction(assertions[0]); !errors.Is(err, ErrStepLimitExceeded) || !errors.Is(err, ErrNoSubject) { + t.Errorf("expected ErrStepLimitExceeded while building the subject, got: %v", err) + } +} + +// testCyclicDerivedFeatureValue: two derived defaults that read each other are reported +// as a cycle instead of recursing until the step budget runs out. +func testCyclicDerivedFeatureValue(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + part def Loop { + attribute a = b + 1.0; + attribute b = a + 1.0; + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "Loop", ast.DefPart) + if sym == nil { + t.Fatal("Loop part def not found") + } + + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + + done := make(chan struct{}) + var fvErr error + go func() { + defer close(done) + _, fvErr = inst.GetFeatureValue(ctx, "a") + }() + select { + case <-done: + case <-watchdog(5 * time.Second): + t.Fatal("GetFeatureValue hung on a cyclic derived feature value") + } + + if !errors.Is(fvErr, ErrCyclicFeatureValue) { + t.Fatalf("GetFeatureValue error = %v, want ErrCyclicFeatureValue", fvErr) + } +} + +// testWriteIntoCyclicDerivedFeatureValues: a write into a pair of values derived +// from each other neither hangs unmaterializing what read them nor unmaterializes +// the written value; it breaks the cycle, and a later write to the other end +// keeps the first write. +func testWriteIntoCyclicDerivedFeatureValues(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + part def Loop { + attribute a = b + 1.0; + attribute b = a + 1.0; + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "Loop", ast.DefPart) + if sym == nil { + t.Fatal("Loop part def not found") + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if _, err := inst.GetFeatureValue(ctx, "a"); !errors.Is(err, ErrCyclicFeatureValue) { + t.Fatalf("GetFeatureValue(a) error = %v, want ErrCyclicFeatureValue", err) + } + + done := make(chan error, 1) + go func() { done <- inst.SetFeatureValue(ctx, "b", constReal(1)) }() + select { + case err := <-done: + if err != nil { + t.Fatalf("SetFeatureValue(b): %v", err) + } + case <-watchdog(5 * time.Second): + t.Fatal("SetFeatureValue(b) hung unmaterializing a cycle of derived values") + } + if b := inst.FeatureValues["b"]; !b.Materialized || !b.Written { + t.Fatalf("the write to b left it materialized %t, written %t", b.Materialized, b.Written) + } + fv, err := inst.GetFeatureValue(ctx, "a") + if err != nil || !valueIdentical(fv.HeldValue(), constReal(2)) { + t.Fatalf("a after b := 1.0 = %v, %v; want 2.0", fv, err) + } + + if err := inst.SetFeatureValue(ctx, "a", constReal(5)); err != nil { + t.Fatalf("SetFeatureValue(a): %v", err) + } + fv, err = inst.GetFeatureValue(ctx, "b") + if err != nil || !valueIdentical(fv.HeldValue(), constReal(1)) { + t.Fatalf("b after a := 5.0 = %v, %v; want the written 1.0", fv, err) + } +} + +// testCyclicSubsettingOfDefaultCollections: two `default null` collections that +// subset each other are reported as a cycle when either is read, rather than +// populating each other until the stack runs out. +func testCyclicSubsettingOfDefaultCollections(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + part def Loop { + part xs : Loop [*] :> ys default null; + part ys : Loop [*] :> xs default null; + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "Loop", ast.DefPart) + if sym == nil { + t.Fatal("Loop part def not found") + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + + for _, name := range []string{"xs", "ys"} { + done := make(chan struct{}) + var fvErr error + go func() { + defer close(done) + _, fvErr = inst.GetFeatureValue(ctx, name) + }() + select { + case <-done: + case <-watchdog(5 * time.Second): + t.Fatalf("GetFeatureValue(%s) hung on collections subsetting each other", name) + } + if !errors.Is(fvErr, ErrCyclicFeatureValue) { + t.Fatalf("GetFeatureValue(%s) error = %v, want ErrCyclicFeatureValue", name, fvErr) + } + } +} + +// testDerivedFeatureValueOverMissingFeature: a derived default that names something the +// instance does not have fails with the feature value named, rather than silently +// leaving the feature value empty. +func testDerivedFeatureValueOverMissingFeature(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + part def Broken { + attribute derived = missing * 2.0; + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "Broken", ast.DefPart) + if sym == nil { + t.Fatal("Broken part def not found") + } + + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + + _, err = inst.GetFeatureValue(ctx, "derived") + if err == nil { + t.Fatal("GetFeatureValue succeeded on a default over an undeclared feature") + } + if !strings.Contains(err.Error(), "derived") { + t.Errorf("error %q does not name the feature value", err) + } +} + +// testStateSubactionReferenceOfMissingAction: an entry action given by +// reference to a name nothing declares fails at execution, naming the target. +func testStateSubactionReferenceOfMissingAction(t *testing.T) { + ctx, machine := loadState(t, `package test { + state Machine { + entry; then init; + state init; + state active { + entry noSuchAction; + } + + succession first init then active; + succession first active then done; + } + }`, "Machine") + + if _, _, err := ctx.ExecuteStateWithEvents(machine, nil); err == nil { + t.Fatal("expected an unresolved entry action reference to fail") + } else if !strings.Contains(err.Error(), "noSuchAction") { + t.Errorf("error should name the unresolved action, got: %v", err) + } +} + +// testStateSubactionReferenceFeatureChain: a feature-chain reference performs +// the action as the object the chain denotes, so a part holding none names the +// chained action in its error rather than running the action as the machine. +func testStateSubactionReferenceFeatureChain(t *testing.T) { + ctx, machine := loadState(t, `package test { + action def CoolDown { + first start; + done; + succession first start then done; + } + + state Machine { + part controller [0..1] { + action coolDown : CoolDown; + } + + entry; then init; + state init; + state active { + exit controller.coolDown; + } + + succession first init then active; + succession first active then done; + } + }`, "Machine") + + _, _, err := ctx.ExecuteStateWithEvents(machine, nil) + if !errors.Is(err, ErrPerformerNotObject) { + t.Fatalf("expected ErrPerformerNotObject over an empty controller, got: %v", err) + } + if !strings.Contains(err.Error(), "controller.coolDown") { + t.Errorf("error should name the chained action, got: %v", err) + } +} + +// testPerformOfMissingAction: a perform statement naming nothing resolvable is +// an error at execution, not a silently skipped node. +func testPerformOfMissingAction(t *testing.T) { + ctx, outer := loadAction(t, `package test { + action outer { + first start; + perform action doIt references missingAction; + done; + + succession first start then doIt; + succession first doIt then done; + } + }`, "outer") + + if _, err := ctx.ExecuteAction(outer); err == nil { + t.Fatal("expected performing an unresolved action to fail") + } else if !strings.Contains(err.Error(), "missingAction") { + t.Errorf("error should name the unresolved action, got: %v", err) + } +} + +// testPerformReferenceCycle: an action performing itself must be stopped by the +// nesting bound instead of recursing forever. +func testPerformReferenceCycle(t *testing.T) { + ctx, outer := loadAction(t, `package test { + action outer { + first start; + perform action doIt references outer; + done; + + succession first start then doIt; + succession first doIt then done; + } + }`, "outer") + + done := make(chan error, 1) + go func() { + _, err := ctx.ExecuteAction(outer) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected a self-performing action to be bounded, it completed") + } + case <-watchdog(10 * time.Second): + t.Fatal("self-performing action did not terminate") + } +} + +// testDeferOfNonDeferrableTrigger: only signals and calls are dispatched from +// the event pool, so a state deferring a time trigger is reported at lowering +// rather than deferring nothing at run time. +func testDeferOfNonDeferrableTrigger(t *testing.T) { + idx := symbols.NewIndex() + resolver := resolve.New(idx) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 1000) + + machine := &ast.Usage{ + Kind: ast.UsageState, + Ident: ast.Identification{Name: "Machine"}, + Members: []ast.Node{ + entryStart("init"), + &ast.StateNode{Name: "init"}, + &ast.StateNode{ + Name: "busy", + Defer: []ast.Node{&ast.TimeEvent{Duration: &ast.LiteralInteger{Value: "1"}}}, + }, + transitionMember("init", "busy"), + }, + } + + _, err := newStateExecutor(ctx, &symbols.Symbol{ + Kind: symbols.SymbolStateUsage, + Name: machine.Ident.Name, + Decl: machine, + }, nil) + if err == nil { + t.Fatal("expected an error for a state deferring a time trigger") + } + if !strings.Contains(err.Error(), "only signal and call triggers can be deferred") { + t.Errorf("expected a deferrability error, got: %v", err) + } +} + +// testStateTransitionEndpointMisspelled: a misspelled endpoint is a +// name-resolution diagnostic, so lowering leaves the edge out and the machine +// runs to a halt in the state it reached rather than panicking or hanging. +func testStateTransitionEndpointMisspelled(t *testing.T) { + src := `package test { + state Machine { + entry; then init; + state init; + state busy; + succession first init then busy; + transition first busy then donee; + } + }` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + ctx.model.resolver.ResolveDocument("", file) + + var endpoint *resolve.Diagnostic + for i, diag := range ctx.model.resolver.Diagnostics { + if strings.Contains(diag.Message, "donee") { + endpoint = &ctx.model.resolver.Diagnostics[i] + } + } + if endpoint == nil { + t.Fatalf("expected a name-resolution diagnostic for 'donee', got: %v", ctx.model.resolver.Diagnostics) + } + if endpoint.Code != "unresolved" { + t.Errorf("expected code %q, got %q", "unresolved", endpoint.Code) + } + + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("Machine not found") + } + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newStateExecutor: %v", err) + } + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + + done := make(chan error, 1) + go func() { done <- exec.RunToCompletion() }() + select { + case err := <-done: + if err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + case <-watchdog(10 * time.Second): + t.Fatal("RunToCompletion hung on a machine whose transition names nothing") + } + if got := exec.getCurrentState(); got == nil || got.Name != "busy" { + t.Errorf("expected the machine to halt in 'busy', got %v", got) + } +} + +// testStateTransitionEndpointNeverResolved: executed without a name-resolution +// pass, as the REPL and the service handlers do, an endpoint naming nothing +// leaves its edge out; the machine still runs, and the misspelling is reported +// by whoever resolves the document rather than by lowering. +func testStateTransitionEndpointNeverResolved(t *testing.T) { + src := `package test { + state Machine { + entry; then init; + state init; + state busy; + succession first init then busy; + transition first busy then donee; + } + }` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("Machine not found") + } + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newStateExecutor: %v", err) + } + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + + done := make(chan error, 1) + go func() { done <- exec.RunToCompletion() }() + select { + case err := <-done: + if err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + case <-watchdog(10 * time.Second): + t.Fatal("RunToCompletion hung on an endpoint no resolution pass reported") + } + if got := exec.getCurrentState(); got == nil || got.Name != "busy" { + t.Errorf("expected the machine to halt in 'busy', got %v", got) + } +} + +// testStateTransitionEndpointInAnotherMachine: an endpoint naming a state of a +// different machine resolves, so no name diagnostic reports it; the state +// transition check reports it, and lowering backstops the check with a typed +// error rather than dropping the edge. +func testStateTransitionEndpointInAnotherMachine(t *testing.T) { + src := `package test { + state Other { + entry; then start; + state start; + state running; + succession first start then running; + } + state Machine { + entry; then init; + state init; + state busy; + succession first init then busy; + transition first busy then Other::running; + } + }` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + ctx.model.resolver.ResolveDocument("", file) + + for _, diag := range ctx.model.resolver.Diagnostics { + if strings.Contains(diag.Message, "running") { + t.Fatalf("the endpoint resolves, so name resolution reports nothing: %v", diag) + } + } + + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("Machine not found") + } + _, err := newStateExecutor(ctx, sym, nil) + if err == nil { + t.Fatal("expected an error for an endpoint that is not a vertex of this machine") + } + if !strings.Contains(err.Error(), "not a vertex of this state machine") { + t.Errorf("expected the error to say the endpoint is not a vertex of this machine, got %v", err) + } + if strings.Contains(err.Error(), "*ast.") { + t.Errorf("the message a modeller reads names a Go type: %v", err) + } +} + +// testStateTransitionEndpointNamingAFirstMarker: a one-ended `first m;` marker is +// no vertex, so an endpoint naming one is reported by the state transition check +// and backstopped here with a typed error rather than a panic. +func testStateTransitionEndpointNamingAFirstMarker(t *testing.T) { + src := `package test { + state Machine { + entry; then init; + state init; + state busy; + state other; + first marker; + succession first init then busy; + transition first busy then marker; + } + }` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("Machine not found") + } + _, err := newStateExecutor(ctx, sym, nil) + if err == nil { + t.Fatal("expected an error for an endpoint naming a marker rather than a vertex") + } + if !strings.Contains(err.Error(), "not a vertex of this state machine") { + t.Errorf("expected the error to say the endpoint is not a vertex, got %v", err) + } + if strings.Contains(err.Error(), "*ast.") { + t.Errorf("the message a modeller reads names a Go type: %v", err) + } +} + +// testStateJunctionWithoutAnOutgoingTransition: a junction no transition leaves +// routes a transition reaching it nowhere, which the state transition check +// reports; reaching it at run time errors rather than panicking or hanging. +// testStateEventAfterCompletion: a signal sent to a machine that already reached +// `done` is discarded, leaving it completed rather than restarting or panicking. +func testStateEventAfterCompletion(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + transition first init accept stop then done; + } + }`) + exec.SendSignal("stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() != StateCompleted { + t.Fatalf("expected StateCompleted, got %s", exec.State()) + } + exec.SendSignal("stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run after completion: %v", err) + } + if exec.State() != StateCompleted { + t.Errorf("the machine left completion on a late signal, state %s", exec.State()) + } +} + +// testStateCompletionRestsInDone: a completed machine names `done` as the state +// it came to rest in, so a caller reading the configuration sees a state, not nil. +func testStateCompletionRestsInDone(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state busy; + succession first init then busy; + transition first busy accept stop then done; + } + }`) + exec.SendSignal("stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() != StateCompleted { + t.Fatalf("expected StateCompleted, got %s", exec.State()) + } + assertCurrentState(t, exec, ast.DoneFeature) +} + +// testStateNestedRegionCompletionKeepsSiblingsRunning: a region of a composite +// state reaching `done` leaves its sibling region running, and events it keeps +// answering are still delivered rather than dropped by an early completion. +func testStateNestedRegionCompletionKeepsSiblingsRunning(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then busy; + state busy parallel { + state left { + entry; then lstart; + state lstart; + transition first lstart accept stop then done; + } + state right { + entry; then rstart; + state rstart; + state rbusy; + transition first rstart accept go then rbusy; + } + } + } + }`) + exec.SendSignal("stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() == StateCompleted { + t.Fatalf("the machine completed with the sibling region still running") + } + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run after the first region completed: %v", err) + } + if exec.State() == StateCompleted { + t.Errorf("the machine completed while the sibling region rests outside `done`") + } + if got := exec.FinalStateName(); got != "done+rbusy" { + t.Errorf("expected the regions in done+rbusy, got %q", got) + } +} + +func testStateJunctionWithoutAnOutgoingTransition(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state busy; + junction stuck; + succession first init then busy; + transition first busy then stuck; + } + }`) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + done := make(chan error, 1) + go func() { done <- exec.RunToCompletion() }() + select { + case err := <-done: + if err == nil { + t.Fatal("expected an error for a junction no transition leaves") + } + if !strings.Contains(err.Error(), "junction stuck has no outgoing transitions") { + t.Errorf("expected the error to name the junction, got %v", err) + } + case <-watchdog(10 * time.Second): + t.Fatal("RunToCompletion hung on a junction no transition leaves") + } +} + +// testStateChoiceWithoutAnEnabledBranch: a choice's guards are read once the +// transition into it has run its effect; when none holds and no unguarded branch +// remains, the run fails at that instant with a typed error naming the choice. +func testStateChoiceWithoutAnEnabledBranch(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + attribute x : Integer = 0; + entry; then init; + state init; + state busy; + choice pick; + state seen; + succession first init then busy; + transition first busy do assign x := 2 then pick; + transition first pick if x == 1 then seen; + } + }`) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + done := make(chan error, 1) + go func() { done <- exec.RunToCompletion() }() + select { + case err := <-done: + if !errors.Is(err, ErrChoiceWithoutBranch) { + t.Fatalf("expected ErrChoiceWithoutBranch: the incoming effect wrote x := 2 and the only branch wants 1; got %v", err) + } + if !strings.Contains(err.Error(), "pick") { + t.Errorf("expected the error to name the choice, got %v", err) + } + if x := exec.StateData()["x"]; !valueEqual(x, integerValue(2)) { + t.Errorf("x = %v, want 2: the incoming effect had run when the choice was read", x) + } + case <-watchdog(10 * time.Second): + t.Fatal("RunToCompletion hung on a choice no branch leaves") + } +} + +// testStateTransitionWithoutATarget: a transition with no target names no edge, +// so lowering reports it rather than dereferencing the absent target. +func testStateTransitionWithoutATarget(t *testing.T) { + idx := symbols.NewIndex() + resolver := resolve.New(idx) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 1000) + + dangling := transitionMember("init", "busy") + dangling.Target = nil + machine := &ast.Usage{ + Kind: ast.UsageState, + Ident: ast.Identification{Name: "Machine"}, + Members: []ast.Node{ + entryStart("init"), + &ast.StateNode{Name: "init"}, + &ast.StateNode{Name: "busy"}, + dangling, + }, + } + + _, err := newStateExecutor(ctx, &symbols.Symbol{ + Kind: symbols.SymbolStateUsage, + Name: machine.Ident.Name, + Decl: machine, + }, nil) + if err == nil { + t.Fatal("expected an error for a transition without a target") + } + if !strings.Contains(err.Error(), "names no target") { + t.Errorf("expected a missing-target error, got: %v", err) + } +} + +// testStateCrossRegionTransitionsPingPong: guardless successions crossing back +// and forth between two regions never settle, so the event budget bounds the run +// with a typed error instead of hanging. +func testStateCrossRegionTransitionsPingPong(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state running parallel { + state left { + entry; then ls; + state ls; + state lidle; + succession first ls then lidle; + transition first lidle then rtarget; + } + state right { + entry; then rs; + state rs; + state ridle; + state rtarget; + succession first rs then ridle; + transition first rtarget then lidle; + } + } + succession first init then running; + } + }`) + exec.ctx.maxStateEvents = 50 + + done := make(chan error, 1) + go func() { done <- exec.RunToCompletion() }() + var err error + select { + case err = <-done: + case <-watchdog(30 * time.Second): + t.Fatal("run to completion hangs on successions crossing between regions") + } + if err == nil { + t.Fatal("expected a budget error for cross-region successions that never settle") + } + if !strings.Contains(err.Error(), MaxStateEventsEnvVar) { + t.Errorf("error %q does not name %s", err, MaxStateEventsEnvVar) + } +} + +// testStateTransitionEffectReadsAnUnknownFeature: a statement written as a +// transition's effect executes lowered like any other, so one reading a feature +// the machine does not declare reports rather than firing on a missing value. +func testStateTransitionEffectReadsAnUnknownFeature(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + attribute counter : Integer = 0; + entry; then init; + state init; + state active; + succession first init then active; + transition first active do assign counter := missingName + 1 then done; + } + }`) + err := exec.RunToCompletion() + if !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("err = %v; want ErrUnresolvedReference", err) + } + if !strings.Contains(err.Error(), "missingName") { + t.Errorf("err = %v; want it to name the unresolved feature", err) + } +} + +// testNonTerminatingDoBehavior: a do behavior whose state is re-entered every +// round never ends, so the run is bounded and reports instead of hanging. The +// self-transition restarts the do behavior, so either bound may report first. +func testNonTerminatingDoBehavior(t *testing.T) { + spin := &ast.StateNode{ + Name: "spin", + Do: []ast.Node{&ast.AssignmentActionNode{ + Target: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "ticks"}}}, + Value: &ast.LiteralInteger{Value: "1"}, + }}, + } + exec := stateExecutorFor(t, &ast.Usage{ + Kind: ast.UsageState, + Ident: ast.Identification{Name: "Machine"}, + Members: []ast.Node{ + entryStart("init"), + &ast.StateNode{Name: "init"}, + spin, + transitionMember("init", "spin"), + transitionMember("spin", "spin"), + }, + }) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + + err := exec.RunToCompletion() + if err == nil { + t.Fatal("expected a budget error for a machine that never settles") + } + if !strings.Contains(err.Error(), "exceeded max") && !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected a budget error, got: %v", err) + } +} + +// testEmptyAnonymousActionBody: entry, do and exit bodies stating no statement +// run the machine to completion rather than reporting an unexecutable behavior. +func testEmptyAnonymousActionBody(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then start; + state start; + state quiet { + entry action { } + do action { } + exit action { } + } + state done; + succession first start then quiet; + succession first quiet then done; + } + }`) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to completion: %v", err) + } + current, ok := exec.CurrentState().(*ast.StateNode) + if !ok || current.Name != "done" { + t.Errorf("expected the empty bodies to leave the machine in done, got %v", exec.CurrentState()) + } +} + +// testNonTerminatingAnonymousDoBody: a do body that never finishes spends the +// step budget instead of hanging the machine. +func testNonTerminatingAnonymousDoBody(t *testing.T) { + err := stateRunErrorForSource(t, "Machine", `package test { + state Machine { + attribute c : Integer = 0; + entry; then start; + state start; + state spin { + do action { + while true { + assign c := c + 1; + } + } + } + state done; + succession first start then spin; + succession first spin then done; + } + }`) + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected ErrStepLimitExceeded, got: %v", err) + } +} + +// testBehaviorPerformingAnActionAndStatingABody: a behavior that both performs +// an action and states a body of its own is reported rather than silently +// choosing one of the two. +func testBehaviorPerformingAnActionAndStatingABody(t *testing.T) { + err := stateRunErrorForSource(t, "Machine", `package test { + action def Bump; + state Machine { + attribute c : Integer = 0; + entry; then start; + state start; + state working { + entry action mixed : Bump { assign c := c + 1; } + } + state done; + succession first start then working; + succession first working then done; + } + }`) + if err == nil { + t.Fatal("expected a behavior stating a body and an action to be reported") + } + if !strings.Contains(err.Error(), "stating a body of its own") { + t.Errorf("expected the report to name the conflict, got: %v", err) + } +} + +// testQualifiedAssignmentTargetInAStateEffect: an assignment naming more than +// one segment is reported rather than writing the last segment. +func testQualifiedAssignmentTargetInAStateEffect(t *testing.T) { + err := stateRunErrorForSource(t, "Machine", `package test { + package other { attribute c : Integer = 0; } + state Machine { + attribute c : Integer = 0; + entry; then init; + state init; + state active; + succession first init then active; + transition first active do assign other::c := 1 then done; + } + }`) + if err == nil { + t.Fatal("expected a qualified assignment target to be reported") + } + if !strings.Contains(err.Error(), "assignment to a qualified target") { + t.Errorf("expected the report to name the unsupported target, got: %v", err) + } +} + +// stateRunErrorForSource runs the named machine in src to completion and answers +// the first error it reports, failing if the machine hangs. +func stateRunErrorForSource(t *testing.T, name, src string) error { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), name, ast.DefState) + if sym == nil { + t.Fatalf("state machine %s not found", name) + } + + done := make(chan error, 1) + go func() { + exec, err := newStateExecutor(ctx, sym, nil) + if err == nil { + err = exec.initialize() + } + if err == nil { + err = exec.RunToCompletion() + } + done <- err + }() + + select { + case err := <-done: + return err + case <-watchdog(10 * time.Second): + t.Fatalf("running %s did not terminate", name) + return nil + } +} + +// stateExecutorForSource builds an executor for the named machine in src, for +// tests that drive it event by event. +func stateExecutorForSource(t *testing.T, name, src string) *StateExecutor { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), name, ast.DefState) + if sym == nil { + t.Fatalf("state machine %s not found", name) + } + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newStateExecutor: %v", err) + } + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + return exec +} + +// testCallOfUnhandledOperation: an invocation no trigger names is discarded by +// run-to-completion, leaving the machine where it was rather than hanging. +func testCallOfUnhandledOperation(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state waiting; + state moving; + succession first init then waiting; + transition first waiting accept go() then moving; + } + }`) + exec.InvokeOperation("halt", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to completion: %v", err) + } + current, ok := exec.CurrentState().(*ast.StateNode) + if !ok || current.Name != "waiting" { + t.Errorf("expected the unhandled call to leave the machine in waiting, got %v", exec.CurrentState()) + } +} + +// testSignalNoLevelOfACompositeStateAccepts: a signal neither the active substate +// nor any composite state enclosing it accepts is dropped by run-to-completion, +// so walking outward for a trigger ends in the machine standing still rather than +// erroring or hanging. +func testSignalNoLevelOfACompositeStateAccepts(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state outer { + state middle { + state inner; + state other; + transition first inner accept step then other; + } + state recovered; + transition first middle accept abort then recovered; + } + state stopped; + succession first init then inner; + transition first outer accept shutdown then stopped; + } + }`) + exec.SendSignal("unknown", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to completion: %v", err) + } + current, ok := exec.CurrentState().(*ast.StateNode) + if !ok || current.Name != "inner" { + t.Errorf("expected the unaccepted signal to leave the machine in inner, got %v", exec.CurrentState()) + } +} + +// testCompositeSelfTransitionWithNoSubstateToReEnter: a composite state that +// declares no starting substate is re-entered by its own self-transition without +// erroring or hanging, and stays active with no substate of its own. +func testCompositeSelfTransitionWithNoSubstateToReEnter(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state Working { + state Step1; + } + succession first init then Working::Step1; + transition first Working accept restart then Working; + } + }`) + exec.SendSignal("restart", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to completion: %v", err) + } + current, ok := exec.CurrentState().(*ast.StateNode) + if !ok || current.Name != "Working" { + t.Errorf("expected the re-entered composite state to be active, got %v", exec.CurrentState()) + } +} + +// testStaleCompositeTimerInARegion: a time trigger on a composite state inside an +// orthogonal region whose composite is left before the timer expires is dropped, +// leaving the sibling region where it was rather than erroring or hanging. +func testStaleCompositeTimerInARegion(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state working parallel { + state left { + entry; then lstart; + state lstart; + state grouping { + state step1; + accept after 5 then late; + } + state moved; + state late; + transition first lstart then step1; + transition first grouping accept skip then moved; + } + state right { + entry; then rstart; + state rstart; + state watching; + succession first rstart then watching; + } + } + succession first init then working; + } + }`) + exec.SendSignal("skip", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to completion: %v", err) + } + active := make(map[string]bool) + for _, state := range exec.ActiveStates() { + active[state.Name] = true + } + if !active["moved"] || !active["watching"] || active["late"] { + t.Errorf("expected the stale composite timer to leave moved and watching active, got %v", active) + } +} + +// testExitOfNestedRegionsWithAHistoryPseudostate: leaving a composite state whose +// region holds another composite with a region of its own, then returning through a +// deep history, restores the recorded configuration rather than erroring or hanging. +func testExitOfNestedRegionsWithAHistoryPseudostate(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state outer parallel { + state left { + entry; then lstart; + state lstart; + state grouping parallel { + state inner { + entry; then gstart; + state gstart; + state g1; + state g2; + transition first gstart then g1; + transition first g1 accept advance then g2; + } + } + transition first lstart then grouping; + } + state right { + entry; then rstart; + state rstart; + state watching; + transition first rstart then watching; + } + deep history resume; + } + state away; + succession first init then outer; + transition first outer accept leave then away; + transition first away accept back then resume; + } + }`) + for _, signal := range []string{"advance", "leave", "back"} { + exec.SendSignal(signal, nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to completion after %s: %v", signal, err) + } + } + active := make(map[string]bool) + for _, state := range exec.ActiveStates() { + active[state.Name] = true + } + if !active["g2"] || !active["watching"] { + t.Errorf("expected the deep history to restore g2 and watching, got %v", active) + } +} + +// testCallArgumentOfWrongType: an argument the guard cannot compare reports +// rather than firing or dropping the transition on a wrong comparison. +func testCallArgumentOfWrongType(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state waiting; + state moving; + succession first init then waiting; + transition first waiting accept setSpeed(value) if value > 0 then moving; + } + }`) + exec.InvokeOperation("setSpeed", map[string]Value{ + "value": NewStringValue("fast"), + }) + err := exec.RunToCompletion() + if err == nil { + t.Fatal("expected an error: the guard compares a String argument with 0") + } + if !strings.Contains(err.Error(), "string") { + t.Errorf("expected the offending operand kind in the message, got: %v", err) + } +} + +// testHistoryOutsideCompositeState: a history in a parallel machine's own body +// belongs to no region and no composite state, so it must report, not guess. +func testHistoryOutsideCompositeState(t *testing.T) { + exec := stateExecutorFor(t, &ast.Usage{ + Kind: ast.UsageState, + Ident: ast.Identification{Name: "Machine"}, + IsParallel: true, + Members: []ast.Node{ + &ast.StateNode{Name: "left", Substates: []ast.Node{ + entryStart("init"), + &ast.StateNode{Name: "init"}, + &ast.StateNode{Name: "away"}, + transitionMember("init", "away"), + transitionMember("away", "H"), + }}, + &ast.StateNode{Name: "right", Substates: []ast.Node{ + entryStart("idle"), + &ast.StateNode{Name: "idle"}, + }}, + &ast.PseudostateNode{Kind: ast.PseudostateShallowHistory, Name: "H"}, + }, + }) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + fire(t, exec, "init", "away") + + _, err := exec.resolveAndFire(nil, transitionBetween(t, exec, "away", "H"), nil) + if err == nil { + t.Fatal("expected an error for a history outside any composite state") + } + if !strings.Contains(err.Error(), "must be declared inside the composite state") { + t.Errorf("expected an ownership error, got: %v", err) + } +} + +// testHistoryWithoutRecordDefaultOrEntry: before its composite state has ever +// been exited a history has nothing to restore; with no default transition it +// falls back on the owner's entry transition, and when the owner declares none +// either the run fails with a typed error at the transition, not silently. +func testHistoryWithoutRecordDefaultOrEntry(t *testing.T) { + history := &ast.PseudostateNode{Kind: ast.PseudostateShallowHistory, Name: "H"} + outer := &ast.StateNode{ + Name: "outer", + Substates: []ast.Node{&ast.StateNode{Name: "first"}, history}, + } + exec := stateExecutorFor(t, &ast.Usage{ + Kind: ast.UsageState, + Ident: ast.Identification{Name: "Machine"}, + Members: []ast.Node{ + entryStart("init"), + &ast.StateNode{Name: "init"}, + outer, + &ast.StateNode{Name: "away"}, + transitionMember("init", "away"), + transitionMember("away", "H"), + }, + }) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + fire(t, exec, "init", "away") + + _, err := exec.resolveAndFire(nil, transitionBetween(t, exec, "away", "H"), nil) + if !errors.Is(err, ErrHistoryWithoutEntry) { + t.Fatalf("expected ErrHistoryWithoutEntry: nothing recorded, no default transition and outer has no entry transition; got %v", err) + } + for _, name := range []string{"H", "outer"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("error should name %s, got: %v", name, err) + } + } + if current := exec.getCurrentState(); current == nil || current.Name != "away" { + t.Errorf("a failed history transition leaves the machine where it was, got %v", current) + } +} + +// testSendViaUnconnectedPort: a port with no connection reaches no one, so the +// send itself is undeliverable — which must be reported where it was written +// rather than left for the accept waiting on it to time out as a deadlock. +func testSendViaUnconnectedPort(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + action pipeline { + port outPort; + port inPort; + first start; + action sender { send 42 via outPort; } + action reader accept msg : Integer via inPort; + done; + succession first start then sender; + succession first sender then reader; + succession first reader then done; + } + }`) + if err == nil { + t.Fatal("expected an error: nothing connects outPort to inPort") + } + if !errors.Is(err, ErrUnroutableSend) { + t.Errorf("expected ErrUnroutableSend, got: %v", err) + } +} + +// testSendViaConnectorIntoAnEmptyPart: the connector joins the sender's port to +// the port of a part it holds, but the part holds no object this run, so nothing +// is behind the end and the send is reported rather than delivered to a port path +// no consumer reads. +func testSendViaConnectorIntoAnEmptyPart(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P {`+directedPorts+` + part def Unit { port command : ~Chan; } + part def Bay { + port command : Chan; + part unit : Unit[0]; + connect command to unit.command; + } + part bay : Bay { + action ship { + first start; + action sender { send 9 via command; } + done; + succession first start then sender; + succession first sender then done; + } + } + }`)) + bay, err := ctx.Instantiate(oneSymbol(t, idx, "P::bay")) + if err != nil { + t.Fatalf("instantiate bay: %v", err) + } + _, err = ctx.ExecuteActionPerformedBy(oneSymbol(t, idx, "P::bay::ship"), bay, nil) + if !errors.Is(err, ErrUnroutableSend) { + t.Fatalf("execute action: err = %v, want %v", err, ErrUnroutableSend) + } + if pending := ctx.PendingMessages(); len(pending) != 0 { + t.Errorf("pending messages = %+v, want none", pending) + } +} + +// testSendViaBoundBoundaryPortJoinedToNothing: the inner part's port is bound to +// the assembly's boundary port, but nothing in the context joins that boundary +// port, so the send reaches no receiving port. It is reported where the inner +// machine sent it, the same as an unconnected port of the sender's own, rather +// than dropped silently — and, as the inner part runs with the context it is +// created under, the context's creation is what reports it. +func testSendViaBoundBoundaryPortJoinedToNothing(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P {`+directedPorts+` + part def Inner { + port out : Chan; + exhibit state sm { + entry; then sending; + state sending { entry send 9 via out; } + } + } + part def Assembly { + port boundary : Chan; + part child : Inner; + bind boundary = child.out; + } + part def Env { port in : ~Chan; } + part ctx { + part asm : Assembly; + part env : Env; + } + }`)) + _, err := ctx.Instantiate(oneSymbol(t, idx, "P::ctx")) + if !errors.Is(err, ErrUnroutableSend) { + t.Fatalf("instantiate ctx: err = %v, want %v", err, ErrUnroutableSend) + } + if pending := ctx.PendingMessages(); len(pending) != 0 { + t.Errorf("pending messages = %+v, want none", pending) + } + if got := len(ctx.instances); got != 0 { + t.Errorf("%d object(s) survive the failed creation, want none", got) + } +} + +// testSendFanOutToAPortThatFailsToMaterialize: a send fanning out over two +// connectors, where the second receiving port cannot be materialized, is +// reported as that failure and leaves no copy queued for the first — a retry +// must not find the earlier copy already there. +func testSendFanOutToAPortThatFailsToMaterialize(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + private import ScalarValues::*;`+directedPorts+` + part def Good { port in : ~Chan; } + part def Bad { port in : ~Chan = 1 / 0; } + part def Hub { + port command : Chan; + part good : Good; + part bad : Bad; + connect command to good.in; + connect command to bad.in; + } + part hub : Hub { + action ship { + first start; + action sender { send 9 via command; } + done; + succession first start then sender; + succession first sender then done; + } + } + }`)) + hub, err := ctx.Instantiate(oneSymbol(t, idx, "P::hub")) + if err != nil { + t.Fatalf("instantiate hub: %v", err) + } + _, err = ctx.ExecuteActionPerformedBy(oneSymbol(t, idx, "P::hub::ship"), hub, nil) + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("execute action: err = %v, want the port's %v", err, ErrDivisionByZero) + } + if pending := ctx.PendingMessages(); len(pending) != 0 { + t.Errorf("pending messages = %+v, want none", pending) + } +} + +// testAcceptViaAPortThatFailsToMaterialize: a machine whose accept names a port +// that cannot be materialized leaves a message of another signal in flight +// untouched, and reports the port's failure when a message of its own signal +// arrives, rather than consuming either silently. +func testAcceptViaAPortThatFailsToMaterialize(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + private import ScalarValues::*;`+directedPorts+` + part def Listener { + port in : ~Chan = 1 / 0; + exhibit state sm { + entry; then Idle; + state Idle; + accept v : Integer via in then Got; + state Got; + } + } + part listener : Listener; + }`)) + listener, err := ctx.Instantiate(oneSymbol(t, idx, "P::listener")) + if err != nil { + t.Fatalf("instantiate listener: %v", err) + } + exec, err := ctx.CreateStateExecutorFor(oneSymbol(t, idx, "P::Listener::sm"), listener) + if err != nil { + t.Fatalf("create state executor: %v", err) + } + // Delivered to a port object by identity, under a name the accept does not + // use, so only materializing `in` can tell whether it is the same port. + viaPort := func(signal string, value Value) Message { + return Message{SignalType: signal, Port: "other", Object: listener.ID, PortID: -1, + Delivery: DeliverPort, Value: &value} + } + ctx.PostMessage(viaPort("String", NewStringValue("not for you"))) + if exec.HasPendingSignal() { + t.Fatal("a String is not the Integer the accept names, yet the machine claims it") + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run with only a String in flight: %v", err) + } + if pending := ctx.PendingMessages(); len(pending) != 1 || pending[0].SignalType != "String" { + t.Fatalf("pending messages = %+v, want the String still in flight", pending) + } + ctx.PostMessage(viaPort("Integer", Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 4}})) + if !exec.HasPendingSignal() { + t.Fatal("an Integer that may be for the accept is not claimed") + } + err = exec.RunToCompletion() + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("run with an Integer in flight: err = %v, want the port's %v", err, ErrDivisionByZero) + } + if visits := exec.GetStateVisits(); len(visits) != 1 || visits[0] != "Idle" { + t.Errorf("state visits = %v, want [Idle]", visits) + } + if pending := ctx.PendingMessages(); len(pending) != 2 || pending[0].SignalType != "String" || pending[1].SignalType != "Integer" { + t.Errorf("pending messages = %+v, want the String then the Integer still in flight", pending) + } +} + +// testActionAcceptViaAPortThatFailsToMaterialize: the action-node counterpart +// of the machine case above: a parked accept whose port cannot be materialized +// leaves a message of another signal in flight and stays parked, and reports +// the port's failure only for a message of its own signal. +func testActionAcceptViaAPortThatFailsToMaterialize(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + private import ScalarValues::*;`+directedPorts+` + part def Listener { + port in : ~Chan = 1 / 0; + action listen { + first start; + action reader accept v : Integer via in; + done; + succession first start then reader; + succession first reader then done; + } + } + part listener : Listener; + }`)) + listener, err := ctx.Instantiate(oneSymbol(t, idx, "P::listener")) + if err != nil { + t.Fatalf("instantiate listener: %v", err) + } + exec, err := ctx.CreateActionExecutorFor(oneSymbol(t, idx, "P::Listener::listen"), listener) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + for i := 0; i < 10 && exec.State() != StateWaiting; i++ { + if err := exec.Step(); err != nil { + t.Fatalf("step %d: %v", i, err) + } + } + if exec.State() != StateWaiting { + t.Fatalf("state = %v, want %v", exec.State(), StateWaiting) + } + viaPort := func(signal string, value Value) Message { + return Message{SignalType: signal, Target: "reader", Port: "other", Object: listener.ID, PortID: -1, + Delivery: DeliverPort, Value: &value} + } + ctx.PostMessage(viaPort("String", NewStringValue("not for you"))) + if exec.HasPendingSignal() { + t.Fatal("a String is not the Integer the accept names, yet the action claims it") + } + if err := exec.Step(); err != nil { + t.Fatalf("step with only a String in flight: %v", err) + } + if exec.State() != StateWaiting { + t.Fatalf("state = %v, want still %v", exec.State(), StateWaiting) + } + if pending := ctx.PendingMessages(); len(pending) != 1 || pending[0].SignalType != "String" { + t.Fatalf("pending messages = %+v, want the String still in flight", pending) + } + ctx.PostMessage(viaPort("Integer", Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 4}})) + err = exec.RunToCompletion() + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("run with an Integer in flight: err = %v, want the port's %v", err, ErrDivisionByZero) + } + if pending := ctx.PendingMessages(); len(pending) != 2 || pending[0].SignalType != "String" || pending[1].SignalType != "Integer" { + t.Errorf("pending messages = %+v, want the String then the Integer still in flight", pending) + } +} + +func testRoutedSendViaUnknownPort(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + action pipeline { + first start; + action sender { send 42 via missing to reader; } + action reader accept msg : Integer; + done; + succession first start then sender; + succession first sender then done; + } + }`) + var typed *UnknownSendPortError + if !errors.As(err, &typed) { + t.Fatalf("expected UnknownSendPortError, got: %v", err) + } + if !errors.Is(err, ErrSendViaUnknownPort) { + t.Errorf("expected ErrSendViaUnknownPort, got: %v", err) + } + if errors.Is(err, ErrUnroutableSend) { + t.Errorf("unknown routed port must not be ErrUnroutableSend: %v", err) + } + if !strings.Contains(err.Error(), `"missing"`) || !strings.Contains(err.Error(), `"reader"`) { + t.Errorf("error = %v, want port and receiver names", err) + } +} + +func testRoutedSendPortTypeMismatch(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + item def IntMessage; + item def TextMessage; + port def IntegerPort { in item text : TextMessage; } + action pipeline { + port outPort; + port inPort : IntegerPort; + connect outPort to inPort; + first start; + action sender { send IntMessage via outPort to reader; } + action reader accept msg : Integer via inPort; + done; + succession first start then sender; + succession first sender then reader; + succession first reader then done; + } + }`) + var typed *SendPortTypeMismatchError + if !errors.As(err, &typed) { + t.Fatalf("expected SendPortTypeMismatchError, got: %v", err) + } + if !errors.Is(err, ErrSendPortTypeMismatch) { + t.Errorf("expected ErrSendPortTypeMismatch, got: %v", err) + } + if !strings.Contains(err.Error(), `"outPort"`) || + !strings.Contains(err.Error(), `"reader"`) { + t.Errorf("error = %v, want port and receiver names", err) + } +} + +func testRoutedSendPortTypeMatch(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + item def IntMessage; + port def IntegerPort { in item value : IntMessage; } + action pipeline { + port outPort; + port inPort : IntegerPort; + connect outPort to inPort; + first start; + action sender { send IntMessage via outPort to reader; } + action reader accept : IntMessage via inPort; + done; + succession first start then sender; + succession first sender then reader; + succession first reader then done; + } + }`) + if err != nil { + t.Fatalf("execute routed send through matching typed flow: %v", err) + } +} + +func testRoutedSendScalarTypedFlowMismatch(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + item def Integer; + item def TextMessage; + port def TextPort { in item text : TextMessage; } + action pipeline { + port outPort; + port inPort : TextPort; + connect outPort to inPort; + first start; + action sender { send 42 via outPort to reader; } + action reader accept msg : Integer via inPort; + done; + succession first start then sender; + succession first sender then reader; + succession first reader then done; + } + }`) + var typed *SendPortTypeMismatchError + if !errors.As(err, &typed) { + t.Fatalf("expected scalar SendPortTypeMismatchError, got: %v", err) + } + if !errors.Is(err, ErrSendPortTypeMismatch) { + t.Errorf("expected ErrSendPortTypeMismatch for scalar message, got: %v", err) + } +} + +func testRoutedSendUnreachableReceiver(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + action pipeline { + port outPort; + port inPort; + connect outPort to inPort; + first start; + action sender { send 42 via outPort to missing; } + done; + succession first start then sender; + succession first sender then done; + } + }`) + var typed *UnreachableSendReceiverError + if !errors.As(err, &typed) { + t.Fatalf("expected UnreachableSendReceiverError, got: %v", err) + } + if !errors.Is(err, ErrUnreachableSendReceiver) { + t.Errorf("expected ErrUnreachableSendReceiver, got: %v", err) + } + if !strings.Contains(err.Error(), `"outPort"`) || + !strings.Contains(err.Error(), `"missing"`) { + t.Errorf("error = %v, want port and receiver names", err) + } +} + +func testRoutedSendReceiverNameMismatchDeadlock(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + action pipeline { + port outPort; + port inPort; + connect outPort to inPort; + first start; + action sender { send 42 via outPort to receiver; } + action receiver; + action sibling accept msg : Integer via inPort; + done; + succession first start then sender; + succession first sender then sibling; + succession first sibling then done; + } + }`) + if err == nil { + t.Fatal("expected a deadlock: sibling must not consume receiver's message") + } + if !errors.Is(err, ErrAcceptDeadlock) { + t.Fatalf("expected ErrAcceptDeadlock, got: %v", err) + } +} + +// testTwoValuedMemberInScalarContext: a `[0..*]` member holding two values is +// not the one value an operator, a `[1]` parameter or a library function takes; +// each refuses it with a typed error rather than a panic, a hang or a guess. +func testTwoValuedMemberInScalarContext(t *testing.T) { + for _, tc := range []struct { + name string + expr string + want error + }{ + {"arithmetic", "q.zs + 1.0", ErrTypeMismatch}, + {"negation", "-q.zs", ErrTypeMismatch}, + {"calc parameter", "Inc(q.zs)", ErrMultiplicityViolation}, + {"library function", "RealFunctions::sqrt(q.zs)", ErrTypeMismatch}, + } { + t.Run(tc.name, func(t *testing.T) { + src := `package test { + private import ScalarValues::*; + calc def Inc { in x : Real; x + 1.0 } + part def Holder { attribute zs : Real[0..*]; } + calc def Two { + attribute q : Holder = new Holder(zs = (1.0, 2.0)); + return r = ` + tc.expr + `; + } + }` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + pkg := resolveSymbol(t, idx.DocumentRoot(""), "test") + sym := resolveSymbol(t, pkg.Scope, "Two") + + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("panic: %v", r) + } + }() + _, err := ctx.InvokeCalc(sym, nil, pkg.Scope) + done <- err + }() + select { + case err := <-done: + if !errors.Is(err, tc.want) { + t.Errorf("InvokeCalc err = %v, want %v", err, tc.want) + } + case <-watchdog(5 * time.Second): + t.Fatal("evaluating the two-valued member did not terminate") + } + }) + } +} + +// testBodyLocalOutsideItsDeclaration: a body-local whose run-time value falls +// outside its declared type, multiplicity or uniqueness is refused with a typed +// error where it is declared - in the evaluator and, where eligible, in the +// compiled tier alike - rather than computed with, hung on or panicked over. +func testBodyLocalOutsideItsDeclaration(t *testing.T) { + for _, tc := range []struct { + name string + body string + want error + }{ + {"enumeration extent", "attribute l : Level = n; return : Integer = l + 0;", ErrTypeMismatch}, + {"scalar subtype", "attribute p : Positive = n - 3; return : Integer = p;", ErrTypeMismatch}, + {"stated multiplicity", "attribute xs : Integer[2] = (n, n + 1, n + 2); return : Integer = SequenceFunctions::size(xs);", ErrMultiplicityViolation}, + {"uniqueness", "attribute xs : Integer[*] = (n, n + 1, n); return : Integer = SequenceFunctions::size(xs);", ErrUniquenessViolation}, + {"nested block", "attribute r : Integer = 0; if n > 0 { attribute l : Level = n; assign r := l; } return : Integer = r;", ErrTypeMismatch}, + } { + t.Run(tc.name, func(t *testing.T) { + src := `package test { + private import ScalarValues::*; + enum def Level :> Integer { low = 1; high = 3; } + calc def Body { in n : Integer; ` + tc.body + ` } + }` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + pkg := resolveSymbol(t, idx.DocumentRoot(""), "test") + sym := resolveSymbol(t, pkg.Scope, "Body") + + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("panic: %v", r) + } + }() + _, err := ctx.InvokeCalc(sym, []Value{constInt(2)}, pkg.Scope) + done <- err + }() + select { + case err := <-done: + if !errors.Is(err, tc.want) { + t.Errorf("InvokeCalc err = %v, want %v", err, tc.want) + } + case <-watchdog(5 * time.Second): + t.Fatal("declaring the body-local did not terminate") + } + }) + } +} + +func testTypeClassificationUnresolvedType(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, `package P { + item def Integer; + calc classify { return : Boolean = 1 istype MissingType; } + }`) + pkg := resolveSymbol(t, root, "P") + calc := resolveSymbol(t, pkg.Scope, "classify") + _, err := NewContext(typedModel(model, resolver), 1000).InvokeCalc(calc, nil, pkg.Scope) + if err == nil { + t.Fatal("expected unresolved type classification to fail") + } + if !errors.Is(err, ErrUnresolvedType) { + t.Fatalf("expected ErrUnresolvedType, got: %v", err) + } + if !strings.Contains(err.Error(), "MissingType") { + t.Errorf("error = %v, want unresolved type name", err) + } +} + +// A value whose type the model cannot name is a typed error; `null` is the empty +// sequence (KerML 8.3.4.8.16), of every type, so it is not that value. +func testTypeClassificationUndeterminedValueType(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, `package P { + item def Integer; + calc classify { return : Boolean = 1.5 istype Integer; } + calc empty { return : Boolean = null istype Integer; } + }`) + pkg := resolveSymbol(t, root, "P") + calc := resolveSymbol(t, pkg.Scope, "classify") + _, err := NewContext(typedModel(model, resolver), 1000).InvokeCalc(calc, nil, pkg.Scope) + if err == nil { + t.Fatal("expected undetermined value type classification to fail") + } + if !errors.Is(err, ErrUndeterminedValueType) { + t.Fatalf("expected ErrUndeterminedValueType, got: %v", err) + } + if !strings.Contains(err.Error(), "Rational") { + t.Errorf("error = %v, want the type the model has no name for", err) + } + empty := resolveSymbol(t, pkg.Scope, "empty") + got, err := NewContext(typedModel(model, resolver), 1000).InvokeCalc(empty, nil, pkg.Scope) + if err != nil { + t.Fatalf("null istype Integer: %v", err) + } + if FormatValue(got) != "true" { + t.Errorf("null istype Integer = %s, want true", FormatValue(got)) + } +} + +func testCastToAnUnresolvedType(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, `package P { + item def Integer; + calc narrow { return : Integer = 1 as MissingType; } + }`) + pkg := resolveSymbol(t, root, "P") + calc := resolveSymbol(t, pkg.Scope, "narrow") + _, err := NewContext(typedModel(model, resolver), 1000).InvokeCalc(calc, nil, pkg.Scope) + if err == nil { + t.Fatal("expected a cast to an unresolved type to fail") + } + if !errors.Is(err, ErrUnresolvedType) { + t.Fatalf("expected ErrUnresolvedType, got: %v", err) + } + if !strings.Contains(err.Error(), "MissingType") { + t.Errorf("error = %v, want unresolved type name", err) + } +} + +// `all T` names a type: a name resolving to nothing, an operand that is no name, and a +// data type declaring no values are each a typed error, and none disturbs the run. +func testExtentOfAnUnresolvedOrUnboundedType(t *testing.T) { + model, resolver, root := parseAndBuildLibraryModel(t, `package P { + private import ScalarValues::*; + private import SequenceFunctions::size; + part def Wheel; + part def Car { part wheels : Wheel[2]; } + part car : Car; + package Spares; + dependency Supply from Spares to Car; + comment Note about Car /* wheels */ + calc missing { return : Natural = size(all MissingType); } + calc noName { return : Natural = size(all (1 + 2)); } + calc ofPackage { return : Natural = size(all Spares); } + calc ofQualifiedPackage { return : Natural = size(all P::Spares); } + calc ofRelationship { return : Natural = size(all Supply); } + calc ofComment { return : Natural = size(all Note); } + calc unbounded { return : Natural = size(all Integer); } + calc unboundedString { return : Natural = size(all String); } + attribute def Point { attribute x : Real; } + attribute origin : Point; + calc unboundedStructured { return : Natural = size(all Point); } + calc counted { return : Natural = size(all Wheel); } + }`) + pkg := resolveSymbol(t, root, "P") + ctx := NewContext(typedModel(model, resolver), 1000) + for _, tc := range []struct { + calc string + want error + name string + }{ + {"missing", ErrUnresolvedType, "MissingType"}, + {"noName", ErrTypeMismatch, "requires the name of a type"}, + {"ofPackage", ErrTypeMismatch, "Spares is a package"}, + {"ofQualifiedPackage", ErrTypeMismatch, "P::Spares is a package"}, + {"ofRelationship", ErrTypeMismatch, "Supply is a dependency"}, + {"ofComment", ErrTypeMismatch, "Note is a comment"}, + {"unbounded", ErrUnboundedExtent, "Integer"}, + {"unboundedString", ErrUnboundedExtent, "String"}, + {"unboundedStructured", ErrUnboundedExtent, "Point is a data type"}, + } { + _, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, tc.calc), nil, pkg.Scope) + if !errors.Is(err, tc.want) { + t.Fatalf("%s: err = %v, want %v", tc.calc, err, tc.want) + } + if !strings.Contains(err.Error(), tc.name) { + t.Errorf("%s: error = %v, want %q named", tc.calc, err, tc.name) + } + } + if _, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "car")); err != nil { + t.Fatal(err) + } + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "counted"), nil, pkg.Scope) + if err != nil || FormatValue(got) != "2" { + t.Errorf("size(all Wheel) = %s, %v; want 2", FormatValue(got), err) + } +} + +// testExtentReachingANamespaceCollection: a namespace-level usage of several occurrences +// (`part wheels : Wheel[2]` in a package) denotes its lower bound of objects for the run, +// created once, so an extent counts them, each once, along with the objects nested in them, +// wherever in the model the extent is taken and the usage declared; a `[0..*]` usage denotes +// none. Read directly, a usage of exact count is the sequence of its objects on every surface, +// one of open count undetermined of that count, as a nested collection reads. A namespace-level +// port denotes no object the run reaches, so an extent it may contribute to is refused rather +// than answered short, while one a port nested in a part contributes to is answered. +func testExtentReachingANamespaceCollection(t *testing.T) { + model, resolver, root := parseAndBuildLibraryModel(t, `package P { + private import ScalarValues::*; + private import SequenceFunctions::size; + part def Wheel; + part def Hub { part wheel : Wheel; } + part def Seat; + port def Link; + part wheels : Wheel[2]; + part hubs : Hub[1..*]; + part spares : Wheel[0..*]; + part seat : Seat; + port link : Link; + calc wheelCount { return : Natural = size(all Wheel); } + calc hubCount { return : Natural = size(all Hub); } + calc linkCount { return : Natural = size(all Link); } + calc seatCount { return : Natural = size(all Seat); } + package Q { + part seat2 : Seat; + part spares : Wheel[0..*]; + calc seatCount { return : Natural = size(all Seat); } + calc wheelCount { return : Natural = size(all Wheel); } + } + } + package R { + private import ScalarValues::*; + private import SequenceFunctions::size; + calc linkCount { return : Natural = size(all P::Link); } + package S { + port links : P::Link[2]; + calc linkCount { return : Natural = size(all P::Link); } + } + }`) + pkg := resolveSymbol(t, root, "P") + ctx := NewContext(typedModel(model, resolver), 1000) + for calc, want := range map[string]string{"wheelCount": "3", "hubCount": "1", "seatCount": "2"} { + for attempt := 1; attempt <= 2; attempt++ { + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, calc), nil, pkg.Scope) + if err != nil || FormatValue(got) != want { + t.Errorf("attempt %d: %s = %s, %v; want %s: the two wheels, the hub and its wheel, both packages' seats", attempt, calc, FormatValue(got), err, want) + } + } + } + wheels, ok := ctx.liveOccurrences(resolveSymbol(t, pkg.Scope, "wheels")) + if !ok || len(wheels) != 2 { + t.Fatalf("wheels denotes %d objects, want the two created once", len(wheels)) + } + want := fmt.Sprintf("[instance(%d), instance(%d)]", wheels[0].ID, wheels[1].ID) + val, err := ctx.EvalDeclaredValue(resolveSymbol(t, pkg.Scope, "wheels")) + wantFormatted(t, "declared wheels", val, err, want) + val, err = evalIn(t, ctx, pkg.Scope, "wheels") + wantFormatted(t, "wheels", val, err, want) + val, err = ctx.EvalDeclaredValue(resolveSymbol(t, pkg.Scope, "hubs")) + wantUndetermined(t, "declared hubs", val, err, "[1..*]") + for src, count := range map[string]string{"hubs": "[1..*]", "size(hubs)": "[1]", "hubs#(1)": "[1]", "hubs.wheel": "[1..*]"} { + val, err = evalIn(t, ctx, pkg.Scope, src) + wantUndetermined(t, src, val, err, count) + } + if _, ok := ctx.occurrences[resolveSymbol(t, pkg.Scope, "spares")]; ok { + t.Error("spares, a [0..*] usage, denotes objects; want none") + } + _, err = ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "linkCount"), nil, pkg.Scope) + if !errors.Is(err, ErrExtentUnavailable) || !strings.Contains(err.Error(), "link is a port") { + t.Errorf("size(all Link) = %v, want %v naming the port link", err, ErrExtentUnavailable) + } + q := resolveSymbol(t, pkg.Scope, "Q") + got, err := ctx.InvokeCalc(resolveSymbol(t, q.Scope, "seatCount"), nil, q.Scope) + if err != nil || FormatValue(got) != "2" { + t.Errorf("Q: size(all Seat) = %s, %v; want 2: both packages' seats, and the wheel collections hold no Seat", FormatValue(got), err) + } + got, err = ctx.InvokeCalc(resolveSymbol(t, q.Scope, "wheelCount"), nil, q.Scope) + if err != nil || FormatValue(got) != "3" { + t.Errorf("Q: size(all Wheel) = %s, %v; want 3: the enclosing package's wheels, once", FormatValue(got), err) + } + if got := len(ctx.instances); got != 6 { + t.Errorf("%d objects stand, want 6: two wheels, a hub and its wheel, two seats", got) + } + r := resolveSymbol(t, root, "R") + _, err = ctx.InvokeCalc(resolveSymbol(t, r.Scope, "linkCount"), nil, r.Scope) + if !errors.Is(err, ErrExtentUnavailable) || !strings.Contains(err.Error(), "link is a port") { + t.Errorf("R: size(all Link) = %v, want the other package's port refused", err) + } + s := resolveSymbol(t, r.Scope, "S") + _, err = ctx.InvokeCalc(resolveSymbol(t, s.Scope, "linkCount"), nil, s.Scope) + if !errors.Is(err, ErrExtentUnavailable) || !strings.Contains(err.Error(), "link is a port") { + t.Errorf("S: size(all Link) = %v, want the first unreadable usage of the model refused", err) + } + + model, resolver, root = parseAndBuildLibraryModel(t, `package R { + private import ScalarValues::*; + private import SequenceFunctions::size; + port def Link; + part def Rig { port p : Link; } + part rig : Rig; + port spare : Link[0..1]; + calc linkCount { return : Natural = size(all Link); } + }`) + r = resolveSymbol(t, root, "R") + ctx = NewContext(typedModel(model, resolver), 1000) + got, err = ctx.InvokeCalc(resolveSymbol(t, r.Scope, "linkCount"), nil, r.Scope) + if err != nil || FormatValue(got) != "1" { + t.Errorf("R: size(all Link) = %s, %v; want 1: the rig's port, an optional one holding nothing", FormatValue(got), err) + } + + model, resolver, root = parseAndBuildLibraryModel(t, `package S { + private import ScalarValues::*; + private import SequenceFunctions::size; + port def Link; + part def Rig { port p : Link; } + part rig : Rig; + calc linkCount { return : Natural = size(all Link); } + package T { + port links : Link[2]; + } + }`) + s = resolveSymbol(t, root, "S") + ctx = NewContext(typedModel(model, resolver), 1000) + _, err = ctx.InvokeCalc(resolveSymbol(t, s.Scope, "linkCount"), nil, s.Scope) + if !errors.Is(err, ErrExtentUnavailable) || !strings.Contains(err.Error(), "links") || !strings.Contains(err.Error(), "port") { + t.Errorf("S: size(all Link) = %v, want the nested package's two-port usage refused", err) + } +} + +// testNamespaceCollectionOfUnfixedCount: a namespace-level usage whose multiplicity bound the +// model does not evaluate (`[2..n]`, `[n]`) fixes no count, so it is never materialized — not +// as one object, not as its lower bound: an extent that may reach it is refused naming the usage +// and its bounds, and a model-level read is undetermined of the bounds the declaration does fix. +func testNamespaceCollectionOfUnfixedCount(t *testing.T) { + model, resolver, root := parseAndBuildLibraryModel(t, `package P { + private import ScalarValues::*; + private import SequenceFunctions::*; + part def Wheel; + part def Hub; + part def Seat; + attribute n : Natural; + part wheels : Wheel[2..n]; + part hubs : Hub[n]; + part seat : Seat; + calc wheelCount { return : Natural = size(all Wheel); } + calc hubCount { return : Natural = size(all Hub); } + calc seatCount { return : Natural = size(all Seat); } + }`) + pkg := resolveSymbol(t, root, "P") + ctx := NewContext(typedModel(model, resolver), 1000) + for calc, want := range map[string]string{"wheelCount": "wheels declares [2..?]", "hubCount": "hubs declares [?..?]"} { + _, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, calc), nil, pkg.Scope) + if !errors.Is(err, ErrExtentUnavailable) || !strings.Contains(err.Error(), want) { + t.Errorf("%s = %v, want %v naming %q", calc, err, ErrExtentUnavailable, want) + } + } + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "seatCount"), nil, pkg.Scope) + if err != nil || FormatValue(got) != "1" { + t.Errorf("seatCount = %s, %v; want 1: the usages of unfixed count hold no Seat", FormatValue(got), err) + } + for src, count := range map[string]string{"wheels": "[2..?]", "size(wheels)": "[1]", "hubs": "[?..?]", "hubs#(1)": "[1]"} { + val, err := evalIn(t, ctx, pkg.Scope, src) + wantUndetermined(t, src, val, err, count) + } + val, err := ctx.EvalDeclaredValue(resolveSymbol(t, pkg.Scope, "wheels")) + wantUndetermined(t, "declared wheels", val, err, "[2..?]") + val, err = evalIn(t, ctx, pkg.Scope, "notEmpty(wheels)") + wantFormatted(t, "notEmpty(wheels)", val, err, "true") + for _, name := range []string{"wheels", "hubs"} { + if _, ok := ctx.occurrences[resolveSymbol(t, pkg.Scope, name)]; ok { + t.Errorf("%s, a usage of unfixed count, denotes objects; want none", name) + } + } + if got := len(ctx.instances); got != 1 { + t.Errorf("%d objects stand, want 1: the seat alone", got) + } +} + +// testNamespaceCollectionThatCannotBeConstructed: a namespace-level usage of several +// occurrences whose type cannot be constructed — the behavior its definition exhibits fails on +// entry — is refused with that failure naming the usage, whether read or counted, and leaves no +// object behind: not a partial extent, not an occurrence for a later read to find. +func testNamespaceCollectionThatCannotBeConstructed(t *testing.T) { + model, resolver, root := parseAndBuildLibraryModel(t, `package P { + private import ScalarValues::*; + private import SequenceFunctions::size; + part def Wheel; + part def Bad { + attribute hits : Rational = 0; + exhibit state tally { + entry; then on; + state on { entry action bump { assign hits := 1 / hits; } } + } + } + part wheels : Wheel[2]; + part bads : Bad[3]; + calc badCount { return : Natural = size(all Bad); } + calc wheelCount { return : Natural = size(all Wheel); } + calc firstBad { return : Bad = bads#(1); } + }`) + pkg := resolveSymbol(t, root, "P") + ctx := NewContext(typedModel(model, resolver), 1000) + for attempt := 1; attempt <= 2; attempt++ { + for _, calc := range []string{"badCount", "firstBad"} { + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, calc), nil, pkg.Scope) + if !errors.Is(err, ErrDivisionByZero) || !strings.Contains(err.Error(), "bads") { + t.Fatalf("attempt %d: %s = %s, %v; want %v naming bads", attempt, calc, FormatValue(got), err, ErrDivisionByZero) + } + } + if _, ok := ctx.occurrences[resolveSymbol(t, pkg.Scope, "bads")]; ok { + t.Fatalf("attempt %d: bads denotes objects after a refused construction", attempt) + } + if got := len(ctx.instances); got != 0 { + t.Fatalf("attempt %d: a refused construction left %d objects standing", attempt, got) + } + if len(ctx.created) != 0 || len(ctx.objectBehaviors) != 0 { + t.Fatalf("attempt %d: a refused construction left %d creations and %d behaviors", attempt, len(ctx.created), len(ctx.objectBehaviors)) + } + } + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "wheelCount"), nil, pkg.Scope) + if err != nil || FormatValue(got) != "2" { + t.Errorf("size(all Wheel) = %s, %v; want 2: the bads hold no Wheel and are not read", FormatValue(got), err) + } +} + +// testChainedWriteThroughANamespaceCollection: a write chained from a namespace-level +// collection reaches several objects, so it is refused as a write through a nested +// collection is, and the objects the collection denotes are left as they were — +// no object is made or replaced for the write; a scalar usage beside it is written. +func testChainedWriteThroughANamespaceCollection(t *testing.T) { + model, resolver, root := parseAndBuildLibraryModel(t, `package P { + private import ScalarValues::*; + part def Sensor { attribute reading : Real = 0.0; } + part sensors : Sensor[2]; + part probe : Sensor; + action def Calibrate { action step { assign sensors.reading := 4.5; } first step; } + action def Tune { action step { assign probe.reading := 4.5; } first step; } + }`) + pkg := resolveSymbol(t, root, "P") + ctx := NewContext(typedModel(model, resolver), 1000) + sensors := resolveSymbol(t, pkg.Scope, "sensors") + before, err := ctx.occurrencesOf(sensors) + if err != nil || len(before) != 2 { + t.Fatalf("sensors denote %d objects, %v; want 2", len(before), err) + } + _, err = ctx.ExecuteAction(resolveSymbol(t, pkg.Scope, "Calibrate")) + if !errors.Is(err, ErrTypeMismatch) || !strings.Contains(err.Error(), "sensors") { + t.Fatalf("assign sensors.reading: %v; want %v naming sensors", err, ErrTypeMismatch) + } + after, err := ctx.occurrencesOf(sensors) + if err != nil || len(after) != 2 || after[0] != before[0] || after[1] != before[1] { + t.Fatalf("sensors denote %v, %v after a refused write; want the same two objects", after, err) + } + if got := len(ctx.instances); got != 2 { + t.Fatalf("a refused write left %d objects standing; want the 2 sensors", got) + } + for _, inst := range after { + if val, err := inst.FeatureValues["reading"].ReadValue("reading"); err != nil || FormatValue(val) != "0.0" { + t.Errorf("sensor #%d reading = %s, %v after a refused write; want 0.0", inst.ID, FormatValue(val), err) + } + } + if _, err := ctx.ExecuteAction(resolveSymbol(t, pkg.Scope, "Tune")); err != nil { + t.Fatalf("assign probe.reading: %v", err) + } + probe, err := ctx.occurrenceOf(resolveSymbol(t, pkg.Scope, "probe")) + if err != nil { + t.Fatal(err) + } + if val, err := probe.FeatureValues["reading"].ReadValue("reading"); err != nil || FormatValue(val) != "4.5" { + t.Errorf("probe reading = %s, %v; want 4.5", FormatValue(val), err) + } +} + +// testNamespaceCollectionOverBudget: a namespace-level usage of more occurrences than a run +// may materialize — past the element budget, or past the bound on a collection's lower bound — +// is refused with the typed limit naming the usage and leaves no partial extent, while an +// extent it cannot contribute to is answered. +func testNamespaceCollectionOverBudget(t *testing.T) { + model, resolver, root := parseAndBuildLibraryModel(t, `package P { + private import ScalarValues::*; + private import SequenceFunctions::size; + part def Wheel; + part def Seat; + part many : Wheel[10000]; + part seats : Seat[2]; + calc wheelCount { return : Natural = size(all Wheel); } + calc seatCount { return : Natural = size(all Seat); } + calc manyCount { return : Natural = size(many); } + } + package R { + private import ScalarValues::*; + private import SequenceFunctions::size; + part def Wheel; + part some : Wheel[5]; + calc wheelCount { return : Natural = size(all Wheel); } + }`) + pkg := resolveSymbol(t, root, "P") + ctx := NewContext(typedModel(model, resolver), 1000) + for _, calc := range []string{"wheelCount", "manyCount"} { + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, calc), nil, pkg.Scope) + if !errors.Is(err, ErrMultiplicityViolation) || !strings.Contains(err.Error(), "many") { + t.Fatalf("%s = %s, %v; want %v naming many", calc, FormatValue(got), err, ErrMultiplicityViolation) + } + } + if got := len(ctx.instances); got != 0 { + t.Fatalf("a refused collection of 10000 left %d objects standing", got) + } + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "seatCount"), nil, pkg.Scope) + if err != nil || FormatValue(got) != "2" { + t.Errorf("size(all Seat) = %s, %v; want 2: the wheels hold no Seat and are not read", FormatValue(got), err) + } + r := resolveSymbol(t, root, "R") + ctx = NewContext(typedModel(model, resolver), 1000) + ctx.maxElements = 4 + got, err = ctx.InvokeCalc(resolveSymbol(t, r.Scope, "wheelCount"), nil, r.Scope) + if !errors.Is(err, ErrElementLimitExceeded) || !strings.Contains(err.Error(), "some") { + t.Fatalf("R: size(all Wheel) = %s, %v; want %v naming some", FormatValue(got), err, ErrElementLimitExceeded) + } + if got := len(ctx.instances); got != 0 { + t.Errorf("R: a collection past the element budget left %d objects standing", got) + } +} + +// testExtentOverAnObjectThatCannotBeRead: an extent materializes the nested usages it walks that +// may hold an object of its type, and only those, so a usage that cannot be materialized — five +// wheels under a budget of four elements — ends the extent with that usage's typed error, never +// an extent short of what stands behind it, and leaves the extent of another type alone. +func testExtentOverAnObjectThatCannotBeRead(t *testing.T) { + model, resolver, root := parseAndBuildLibraryModel(t, `package P { + private import ScalarValues::*; + private import SequenceFunctions::size; + part def Wheel; + part def Seat; + part def Driver; + part def Car { + part seats : Seat[2]; + part wheels : Wheel[5]; + part driver : Driver; + } + part car : Car; + calc wheelCount { return : Natural = size(all Wheel); } + calc seatCount { return : Natural = size(all Seat); } + calc driverCount { return : Natural = size(all Driver); } + }`) + pkg := resolveSymbol(t, root, "P") + ctx := NewContext(typedModel(model, resolver), 1000) + ctx.maxElements = 4 + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "wheelCount"), nil, pkg.Scope) + if !errors.Is(err, ErrElementLimitExceeded) || !strings.Contains(err.Error(), "wheels") { + t.Fatalf("size(all Wheel) = %s, %v; want %v naming wheels", FormatValue(got), err, ErrElementLimitExceeded) + } + for calc, want := range map[string]string{"seatCount": "2", "driverCount": "1"} { + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, calc), nil, pkg.Scope) + if err != nil || FormatValue(got) != want { + t.Fatalf("%s = %s, %v; want %s: the wheels hold no such object and are not read", calc, FormatValue(got), err, want) + } + } +} + +// testExtentReachingAFarUsageThatCannotBeRead: a namespace usage of another document that cannot +// be read ends the extent with a typed error naming it; a type it cannot hold is still answered, +// as is one a far document's collection holds. +func testExtentReachingAFarUsageThatCannotBeRead(t *testing.T) { + ctx := contextOverDocs(t, [][2]string{ + {"model.sysml", `package P { + private import ScalarValues::*; + private import SequenceFunctions::size; + part def Wheel; + part def Seat; + part def Driver; + part def Car { part wheel : Wheel; } + part local : Car; + part driver : Driver; + calc carCount { return : Natural = size(all Car); } + calc wheelCount { return : Natural = size(all Wheel); } + calc seatCount { return : Natural = size(all Seat); } + calc driverCount { return : Natural = size(all Driver); } + }`}, + {"far.sysml", `package Far { + part def Boat; + ref part car : P::Car = new Boat(); + }`}, + {"spares.sysml", `package Spares { + part seats : P::Seat[2]; + }`}, + }) + pkg := lookupOne(t, ctx.Resolver().Index(), "P") + for _, tc := range []struct { + calc string + want error + name []string + }{ + {"carCount", ErrTypeMismatch, []string{"usage car", "Far::car"}}, + {"wheelCount", ErrTypeMismatch, []string{"usage car", "Far::car"}}, + } { + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, tc.calc), nil, pkg.Scope) + if !errors.Is(err, tc.want) { + t.Fatalf("%s = %s, %v; want %v", tc.calc, FormatValue(got), err, tc.want) + } + for _, want := range tc.name { + if !strings.Contains(err.Error(), want) { + t.Errorf("%s: error = %v, want %q named", tc.calc, err, want) + } + } + } + for calc, want := range map[string]string{"driverCount": "1", "seatCount": "2"} { + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, calc), nil, pkg.Scope) + if err != nil || FormatValue(got) != want { + t.Errorf("%s = %s, %v; want %s: the far car is not read, the far seats collection is counted", calc, FormatValue(got), err, want) + } + } +} + +// testExtentOverFarUsagesUnderTheElementBudget: far usages materialize in document order against +// the element budget, whose exhaustion names the usage it stopped at; a type they cannot hold reads none. +func testExtentOverFarUsagesUnderTheElementBudget(t *testing.T) { + docs := [][2]string{ + {"fleet_a.sysml", `package FleetA { part first : P::Car; }`}, + {"fleet_b.sysml", `package FleetB { part second : P::Car; }`}, + {"model.sysml", `package P { + private import ScalarValues::*; + private import SequenceFunctions::size; + part def Wheel; + part def Driver; + part def Car { part wheels : Wheel[2]; } + part driver : Driver; + calc wheelCount { return : Natural = size(all Wheel); } + calc driverCount { return : Natural = size(all Driver); } + }`}, + } + ctx := contextOverDocs(t, docs) + pkg := lookupOne(t, ctx.Resolver().Index(), "P") + ctx.maxElements = 3 + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "wheelCount"), nil, pkg.Scope) + if !errors.Is(err, ErrElementLimitExceeded) || !strings.Contains(err.Error(), "object of second: feature wheels") { + t.Fatalf("size(all Wheel) = %s, %v; want %v naming the second document's usage and its wheels", FormatValue(got), err, ErrElementLimitExceeded) + } + cars := 0 + for _, id := range ctx.InstanceIDs() { + if inst, _ := ctx.Instance(id); ctx.isOf(inst, lookupOne(t, ctx.Resolver().Index(), "P::Car")) { + cars++ + } + } + if cars != 2 { + t.Errorf("cars materialized before the budget ran out = %d, want both far usages read in document order", cars) + } + got, err = ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "driverCount"), nil, pkg.Scope) + if err != nil || FormatValue(got) != "1" { + t.Errorf("size(all Driver) = %s, %v; want 1: the cars hold no Driver and are not read", FormatValue(got), err) + } + + ctx = contextOverDocs(t, docs) + pkg = lookupOne(t, ctx.Resolver().Index(), "P") + ctx.maxElements = 8 + got, err = ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "wheelCount"), nil, pkg.Scope) + if err != nil || FormatValue(got) != "4" { + t.Errorf("size(all Wheel) under a budget of eight = %s, %v; want 4: both cars' wheels and the extent holding them", FormatValue(got), err) + } +} + +// testExtentOverRecursiveComposition: an extent walked into a composition recursing through one +// declaration — a part of its own type, a constructor of it, two types holding each other, a value +// choosing one at run time — ends, leaving unread only the feature that would create another object +// of a declaration on the path; every object it does create has its own wheel read, so none is +// answered short of it. +func testExtentOverRecursiveComposition(t *testing.T) { + model, resolver, root := parseAndBuildLibraryModel(t, `package P { + private import ScalarValues::*; + private import SequenceFunctions::size; + part def Wheel; + part def Tree { part left : Tree; part leaf : Wheel; } + part def Chain { ref part tail : Chain = new Chain(); part leaf : Wheel; } + part def Pair { part a : Half; part b : Half; } + part def Half { part back : Pair; part hub : Wheel; } + part def Fork { + part tine = if false ? new Fork() else new Wheel(); + part stem = if true ? new Fork() else new Wheel(); + } + part tree : Tree; + part chain : Chain; + part pair : Pair; + part fork : Fork; + calc wheelCount { return : Natural = size(all Wheel); } + }`) + pkg := resolveSymbol(t, root, "P") + ctx := NewContext(typedModel(model, resolver), 100000) + done := make(chan struct{}) + var got Value + var err error + go func() { + defer close(done) + got, err = ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "wheelCount"), nil, pkg.Scope) + }() + select { + case <-done: + case <-watchdog(20 * time.Second): + t.Fatal("the extent did not terminate on recursive composition") + } + if err != nil { + t.Fatalf("size(all Wheel): %v", err) + } + wheels := 0 + for _, inst := range ctx.instances { + if inst.Type == nil { + continue + } + switch inst.Type.Name { + case "Wheel": + wheels++ + case "tree", "Tree", "chain", "Chain", "Half", "fork", "Fork": + for _, name := range []string{"leaf", "hub", "tine"} { + if fv, has := inst.FeatureValues[name]; has && !fv.Materialized { + t.Errorf("object %d of %s: %s left unread, the extent is short of it", inst.ID, inst.Type.Name, name) + } + } + if fv, has := inst.FeatureValues["stem"]; has && fv.Materialized { + t.Errorf("object %d of %s: stem read, though what it makes is another Fork", inst.ID, inst.Type.Name) + } + } + } + if FormatValue(got) != fmt.Sprint(wheels) || wheels < 5 { + t.Errorf("size(all Wheel) = %s with %d wheels materialized; want every wheel of the objects there are, the tree's and chain's leaves, the pair's two hubs and the fork's tine at least", FormatValue(got), wheels) + } + if len(ctx.instances) > 20 { + t.Errorf("%d objects materialized: the walk is not bounded by the declarations on its path", len(ctx.instances)) + } +} + +// testExtentThroughAValueRecursingAndNot: a value that makes an object of a declaration on the path +// together with one the extent would reach can be kept neither whole (the walk would not end) nor +// in part, so the extent is refused with a typed error naming the usage, and the read is undone. +func testExtentThroughAValueRecursingAndNot(t *testing.T) { + model, resolver, root := parseAndBuildLibraryModel(t, `package P { + private import ScalarValues::*; + private import SequenceFunctions::size; + part def Wheel; + part def Seat; + part def Fork { + part mixed[*] = (new Wheel(), new Fork()); + part leaf : Wheel; + part seat : Seat; + } + part fork : Fork; + calc wheelCount { return : Natural = size(all Wheel); } + calc seatCount { return : Natural = size(all Seat); } + }`) + pkg := resolveSymbol(t, root, "P") + ctx := NewContext(typedModel(model, resolver), 100000) + _, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "wheelCount"), nil, pkg.Scope) + if !errors.Is(err, ErrExtentUnavailable) || !strings.Contains(err.Error(), "mixed") || !strings.Contains(err.Error(), "Fork") { + t.Fatalf("size(all Wheel) = %v, want ErrExtentUnavailable naming mixed and Fork", err) + } + if fv := ctx.instances[1].FeatureValues["mixed"]; fv != nil && fv.Materialized { + t.Error("mixed kept materialized after the refusal") + } + if n := len(ctx.instances); n != 1 { + t.Errorf("%d objects after the refusal, want the fork alone: what mixed made is undone", n) + } + got, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "seatCount"), nil, pkg.Scope) + if err != nil || FormatValue(got) != "1" { + t.Errorf("size(all Seat) = %s, %v; want 1: mixed holds no Seat and is left unread", FormatValue(got), err) + } +} + +// testCastUndecidedByTheValue: a target narrower than the value's own type that +// the value does not settle — 5 states nothing about being an Even — fails +// rather than dropping a value that may well be one of the target's. +func testCastUndecidedByTheValue(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, `package P { + attribute def Integer; + attribute def Even :> Integer; + calc narrow { return : Even = 5 as Even; } + }`) + pkg := resolveSymbol(t, root, "P") + calc := resolveSymbol(t, pkg.Scope, "narrow") + _, err := NewContext(typedModel(model, resolver), 1000).InvokeCalc(calc, nil, pkg.Scope) + if err == nil { + t.Fatal("expected an undecidable cast to fail") + } + if !errors.Is(err, ErrUndecidedClassification) { + t.Fatalf("expected ErrUndecidedClassification, got: %v", err) + } + if !strings.Contains(err.Error(), "Even") { + t.Errorf("error = %v, want the target type named", err) + } +} + +// testEnumerationTypedFeatureHoldingAnUnenumeratedValue: an enumeration-typed feature +// refuses a value equal to no enumerated one by the write-conformance rule, decidedly. +func testEnumerationTypedFeatureHoldingAnUnenumeratedValue(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, `package P { + attribute def Integer; + enum def Level :> Integer { low = 1; high = 3; } + attribute two : Integer = 2; + part def Dial { attribute setting : Level = two; } + part dial : Dial; + calc narrow { return : Level[0..1] = two as Level; } + }`) + pkg := resolveSymbol(t, root, "P") + ctx := NewContext(typedModel(model, resolver), 1000) + dial, err := ctx.Instantiate(resolveSymbol(t, pkg.Scope, "dial")) + if err == nil { + _, err = dial.GetFeatureValue(ctx, "setting") + } + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("expected ErrTypeMismatch, got: %v", err) + } + if !strings.Contains(err.Error(), "Level") { + t.Errorf("error = %v, want the feature's type named", err) + } + result, err := ctx.InvokeCalc(resolveSymbol(t, pkg.Scope, "narrow"), nil, pkg.Scope) + if err != nil { + t.Fatalf("2 as Level: %v, want the empty sequence", err) + } + if got := FormatValue(result); got != "[]" { + t.Errorf("2 as Level = %s, want the empty sequence", got) + } +} + +// testEnumerationWhoseLiteralValueCannotBeEvaluated: a literal whose value fails to +// evaluate fails the classification with that error rather than answering false. +func testEnumerationWhoseLiteralValueCannotBeEvaluated(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, `package P { + attribute def Integer; + enum def Level :> Integer { low = 1; high = 1 / 0; } + attribute isLevel = 2 istype Level; + }`) + pkg := resolveSymbol(t, root, "P") + sym := resolveSymbol(t, pkg.Scope, "isLevel") + _, err := NewContext(typedModel(model, resolver), 1000).EvalWithScope(sym.Decl.(*ast.Usage).Value, pkg.Scope) + if err == nil { + t.Fatal("expected the literal's failing value to fail the classification") + } + if !strings.Contains(err.Error(), "high") { + t.Errorf("error = %v, want the failing literal named", err) + } +} + +// testDifferenceTypedFeatureHoldingASubtractedObject: a feature typed by a +// difference refuses an object one of the subtracted types classifies, whether +// the object was declared by it or classified by it since. +func testDifferenceTypedFeatureHoldingASubtractedObject(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, ` + part def Vehicle; + part def Car :> Vehicle; + part def Electric; + part def ElectricCar :> Car, Electric; + part def CombustionVehicle differences Vehicle, Electric; + part sedan : Car; + part def Shop { part retrofit : ElectricCar = sedan; } + part shop : Shop; + part def Depot { part burner : CombustionVehicle = shop.retrofit; } + part depot : Depot; + attribute held = depot.burner istype Vehicle; + `) + sym := resolveSymbol(t, root, "held") + _, err := NewContext(typedModel(model, resolver), 10000).Eval(sym.Decl.(*ast.Usage).Value) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("expected ErrTypeMismatch, got: %v", err) + } + if !strings.Contains(err.Error(), "CombustionVehicle") { + t.Errorf("error = %v, want the feature's type named", err) + } +} + +// testCastOfAQuantityToAConstrainedSubtype: a quantity subtype inheriting its +// measurement reference narrows lengths by something a magnitude and a unit do +// not state, so a bare length is undecided rather than kept by its dimension. +func testCastOfAQuantityToAConstrainedSubtype(t *testing.T) { + err := calcErrorWithLibraries(t, ` + package test { + private import SI::*; + attribute def RoomLength :> ISQBase::LengthValue; + calc narrow { return : RoomLength = 5 [m] as RoomLength; } + }`, "narrow", nil, 1000) + if !errors.Is(err, ErrUndecidedClassification) { + t.Fatalf("expected ErrUndecidedClassification, got: %v", err) + } + if !strings.Contains(err.Error(), "RoomLength") { + t.Errorf("error = %v, want the target type named", err) + } +} + +// testSendAddressedToAnUnreachableTarget: a target reaching no port of an object +// the sender can address is reported where it was written rather than delivered +// to whatever else carries the last segment's name. +func testSendAddressedToAnUnreachableTarget(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + port def PingPort { in item ping : Integer; } + part def Leaf { attribute count : Integer = 0; } + part def Node { + port inPort : PingPort; + part leaf : Leaf; + } + part alpha : Node; + action pipeline { + first start; + action sender { send 42 to alpha.leaf.count; } + done; + succession first start then sender; + succession first sender then done; + } + }`) + if err == nil { + t.Fatal("expected an error: alpha.leaf.count is no port of any object") + } + if !errors.Is(err, ErrUnroutableSend) { + t.Errorf("expected ErrUnroutableSend, got: %v", err) + } +} + +// testSendAddressedThroughSeveralOccurrences: a path led by a part naming three +// occurrences reaches no one object, which must be reported rather than +// attributed to the sending object and delivered to nobody. +func testSendAddressedThroughSeveralOccurrences(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + port def PingPort { in item ping : Integer; } + part def Leaf { port inPort : PingPort; } + part nodes : Leaf[3]; + action pipeline { + first start; + action sender { send 42 to nodes.inPort; } + done; + succession first start then sender; + succession first sender then done; + } + }`) + if err == nil { + t.Fatal("expected an error: nodes names three occurrences, not one addressee") + } + if !errors.Is(err, ErrUnroutableSend) { + t.Errorf("expected ErrUnroutableSend, got: %v", err) + } +} + +// testSendAddressedToAPartNoSiblingTakes: a message addressed to a part belongs +// to that object, so a sibling accept of the sending behavior cannot take it and +// the run reports the accept it is left waiting on. +func testSendAddressedToAPartNoSiblingTakes(t *testing.T) { + _, err := executeActionSource(t, "main", `package P { + item def Ping; + part def R; + part receiver : R; + action main { + first start; + action s { send Ping() to receiver; } + action other accept p : Ping; + done; + succession first start then s; + succession first s then other; + succession first other then done; + } + }`) + if err == nil { + t.Fatal("expected an error: `other` is no addressee of the message sent to receiver") + } + if !errors.Is(err, ErrAcceptDeadlock) { + t.Errorf("expected ErrAcceptDeadlock, got: %v", err) + } +} + +// testInjectedMessageNamesAReceiverNoAcceptHas: a message injected from outside +// the model is held to the receiver it names, so an accept of another name waits +// on rather than consumes it, and the run reports that wait. +func testInjectedMessageNamesAReceiverNoAcceptHas(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + private import ScalarValues::*; + action pipeline { + first start; + action other accept n : Integer; + done; + succession first start then other; + succession first other then done; + } + }`)) + exec, err := ctx.CreateActionExecutor(oneSymbol(t, idx, "P::pipeline")) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + one := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}} + ctx.PostMessage(Message{SignalType: "Integer", Target: "reader", Value: &one}) + err = exec.RunToCompletion() + if err == nil { + t.Fatal("expected an error: `other` is not the receiver the message names") + } + if !errors.Is(err, ErrAcceptDeadlock) { + t.Errorf("expected ErrAcceptDeadlock, got: %v", err) + } +} + +// testSendAddressedToAnObjectThatCannotBeBuilt: locating an addressee can fail +// on its own terms — an exhausted budget, or a feature value the walk cannot read — and +// each must be reported as that rather than as an address naming no port. +func testSendAddressedToAnObjectThatCannotBeBuilt(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + port def PingPort { in item ping : Integer; } + part def Node { + port inPort : PingPort; + attribute a = b + 1.0; + attribute b = a + 1.0; + action listen { first start; done; succession first start then done; } + } + part alpha : Node; + } + `)) + scope := DeclScope(oneSymbol(t, idx, "test::Node::listen")) + + ctx.maxSteps = 0 + send := lower.Send{Target: "alpha.inPort", TargetPath: true, Scope: scope} + err := ctx.post(nil, nil, Message{SignalType: "Integer"}, send, nil, nil, nil) + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("budget exhausted while building alpha: %v, want ErrStepLimitExceeded", err) + } + if errors.Is(err, ErrUnroutableSend) { + t.Errorf("an exhausted budget was reported as a bad address: %v", err) + } + + ctx.maxSteps = DefaultMaxSteps + alpha := instanceOfUsage(t, ctx, idx, "test::alpha") + send = lower.Send{Target: "a.inPort", TargetPath: true, Scope: scope} + err = ctx.post(nil, nil, Message{SignalType: "Integer"}, send, nil, alpha, nil) + if !errors.Is(err, ErrCyclicFeatureValue) { + t.Errorf("walking through a cyclic derived feature value: %v, want ErrCyclicFeatureValue", err) + } + if errors.Is(err, ErrUnroutableSend) { + t.Errorf("an unreadable feature value was reported as a bad address: %v", err) + } + if len(ctx.PendingMessages()) != 0 { + t.Errorf("a send that never found its addressee posted %+v", ctx.PendingMessages()) + } +} + +// testAcceptDeadlockNeverSatisfied: an accept nothing can ever satisfy suspends +// the action, and a suspension that can never end must be reported as a typed +// deadlock rather than hanging. +func testAcceptDeadlockNeverSatisfied(t *testing.T) { + done := make(chan error, 1) + go func() { + _, err := executeActionSource(t, "pipeline", `package P { + action pipeline { + first start; + action reader accept n : Integer; + done; + succession first start then reader; + succession first reader then done; + } + }`) + done <- err + }() + + var err error + select { + case err = <-done: + case <-watchdog(10 * time.Second): + t.Fatal("an action waiting for a message that cannot arrive did not terminate") + } + + if err == nil { + t.Fatal("expected a deadlock error, the suspended accept completed") + } + if !errors.Is(err, ErrAcceptDeadlock) { + t.Errorf("expected ErrAcceptDeadlock, got: %v", err) + } + for _, want := range []string{"accept n", "Integer"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("expected %q in the deadlock report, got: %v", want, err) + } + } +} + +// testAcceptDeadlockReportsEveryWaitingAccept: with two accepts parked in +// parallel branches and only one message in flight, the accept that can proceed +// does, and the report names the one still waiting rather than the whole action. +func testAcceptDeadlockReportsEveryWaitingAccept(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + action pipeline { + attribute got : Integer = 0; + first start; + action sender { send 7 to reader; } + fork split; + action reader accept n : Integer; + action recorder { assign got := n; } + action listener accept text : String; + join sync; + done; + succession first start then sender; + succession first sender then split; + succession first split then reader; + succession first split then listener; + succession first reader then recorder; + succession first recorder then sync; + succession first listener then sync; + succession first sync then done; + } + }`) + if err == nil { + t.Fatal("expected a deadlock error: no String is ever sent") + } + if !errors.Is(err, ErrAcceptDeadlock) { + t.Fatalf("expected ErrAcceptDeadlock, got: %v", err) + } + if !strings.Contains(err.Error(), "accept text waiting since step 4 for a message of type String") { + t.Errorf("expected the still-waiting accept in the report, got: %v", err) + } + if strings.Contains(err.Error(), "accept n ") { + t.Errorf("the Integer accept was satisfied and must not be reported as waiting: %v", err) + } +} + +// testAcceptStatementDeadlockInALoop: an accept node written in a loop body would +// have to suspend a flow that has no token to park, so it is reported when reached +// rather than passed over or looped on forever. +func testAcceptStatementDeadlockInALoop(t *testing.T) { + done := make(chan error, 1) + go func() { + _, err := executeActionSource(t, "pipeline", `package P { + action pipeline { + first start; + action waiter { + loop { + accept n : Integer; + } + } + done; + succession first start then waiter; + succession first waiter then done; + } + }`) + done <- err + }() + + var err error + select { + case err = <-done: + case <-watchdog(10 * time.Second): + t.Fatal("a loop waiting for a message that cannot arrive did not terminate") + } + + if err == nil { + t.Fatal("expected an error, the accept in the loop body was passed over") + } + if !strings.Contains(err.Error(), "'accept' in a loop or branch body") { + t.Errorf("expected the accept in a loop body to be reported, got: %v", err) + } +} + +// testNonNumericTimeTrigger: a timed trigger whose duration is not a number +// cannot be scheduled and must be reported rather than silently dropped, even +// with no library loaded for the static judgement to name a type from. +func testNonNumericTimeTrigger(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state waiting; + accept at "noon" then done; + succession first init then waiting; + } + }`) + if err == nil { + t.Fatal("expected an error for a non-numeric time trigger") + } + if !strings.Contains(err.Error(), "time duration must be constant, got string") { + t.Errorf("expected a numeric-duration error, got: %v", err) + } +} + +// testTimeTriggerOfANonTimeDimension: a duration whose unit measures something +// other than time, held by a feature whose type does not resolve so only its +// value can tell, cannot be scheduled and fails as the typed error it is. +func testTimeTriggerOfANonTimeDimension(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + private import SI::*; + state Machine { + attribute load : Nowhere::Mass = 5 [kg]; + entry; then init; + state init; + state waiting; + accept after load then done; + state done; + succession first init then waiting; + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state Machine not found") + } + + _, err := ctx.ExecuteState(sym) + if !errors.Is(err, ErrIncommensurableUnits) { + t.Fatalf("err = %v; want ErrIncommensurableUnits", err) + } + if !strings.Contains(err.Error(), "5 [kg] is not a time") { + t.Errorf("err = %v; want it to name the quantity", err) + } +} + +// testTimeTriggerOfTheTypeValidationRefuses: an argument validation refuses is +// refused as one typed error before it is evaluated or converted, whatever +// evaluating it would have said. +func testTimeTriggerOfTheTypeValidationRefuses(t *testing.T) { + for _, tc := range []struct{ name, trigger string }{ + {"unitless after", "after 5"}, + {"duration at", "at 2 [min]"}, + {"mass after", "after 5 [kg]"}, + {"string at", `at "noon"`}, + } { + t.Run(tc.name, func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + private import SI::*; + state Machine { + entry; then init; + state init; + state waiting; + accept `+tc.trigger+` then done; + state done; + succession first init then waiting; + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state Machine not found") + } + + _, err := ctx.ExecuteState(sym) + if !errors.Is(err, ErrTimeTriggerType) { + t.Fatalf("err = %v; want ErrTimeTriggerType", err) + } + if !strings.Contains(err.Error(), "`"+tc.trigger+"`") { + t.Errorf("err = %v; want it to quote the trigger as written", err) + } + }) + } +} + +// testActionReturnParameterValidationRefuses: an action declaring a `return` +// parameter — which validation refuses, only a function or expression owning +// one — is refused at initialize with a typed error naming the parameter, +// whether the action, an action it specializes, or a node of its flow declares it. +func testActionReturnParameterValidationRefuses(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + {"own", ` + action def Run { + return total : Integer; + first start; + then action go { assign total := 1; } + then done; + }`, "action Run declares `return total`; write `out total`"}, + {"inherited", ` + action def Base { return total : Integer; } + action def Run :> Base { + first start; + then action go { assign total := 1; } + then done; + }`, "action Run declares `return total`; write `out total`"}, + {"node", ` + action def Run { + out total : Integer; + first start; + then action go { return partial : Integer; assign total := 1; } + then done; + }`, "action node go declares `return partial`; write `out partial`"}, + } { + t.Run(tc.name, func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + private import ScalarValues::*; + `+tc.src+` + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "Run", ast.DefAction) + if sym == nil { + t.Fatal("action Run not found") + } + + _, err := ctx.ExecuteAction(sym) + if !errors.Is(err, ErrActionResultParameter) { + t.Fatalf("err = %v; want ErrActionResultParameter", err) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v; want it to contain %q", err, tc.want) + } + }) + } +} + +// testChangeConditionThatNeverHolds: a machine whose only outgoing transition +// watches a false condition suspends within its budget and says what it waits +// on, rather than hanging or reporting silent completion. +func testChangeConditionThatNeverHolds(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + attribute ready : Boolean = false; + entry; then init; + state init; + state waiting; + accept when ready then done; + state done; + succession first init then waiting; + } + }`) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() != StateSuspended { + t.Fatalf("state = %v; want suspended", exec.State()) + } + if reason := exec.SuspendReason(); !strings.Contains(reason, "when ready") || + !strings.Contains(reason, "condition is false") { + t.Errorf("reason = %q; want the false condition it waits on", reason) + } +} + +// testForkBranchesShareRegion: a fork whose branches land in the same region +// cannot produce one active state per region. +func testForkBranchesShareRegion(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state ready; + state working parallel { + state left { + entry; then ls; + state ls; + state a; + state b; + succession first ls then a; + } + state right { + entry; then rs; + state rs; + state c; + succession first rs then c; + } + } + fork split; + + succession first init then ready; + transition first ready then split; + transition first split then a; + transition first split then b; + } + }`) + if err == nil { + t.Fatal("expected an error for fork branches in the same region") + } + if !strings.Contains(err.Error(), "in the same region") { + t.Errorf("expected a same-region error, got: %v", err) + } +} + +// testForkLeavesARegionWithoutAWayIn: a region a fork enters needs no entry +// transition, but a sibling region neither enters still has no way in. +func testForkLeavesARegionWithoutAWayIn(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state working parallel { + state left { state a; } + state right { state b; } + state third { state c; } + } + fork split; + + transition first init then split; + transition first split then a; + transition first split then b; + } + }`) + if err == nil { + t.Fatal("expected an error for the region no fork enters") + } + if !strings.Contains(err.Error(), "region third has no initial state") { + t.Errorf("expected the third region's missing initial, got: %v", err) + } + if strings.Contains(err.Error(), "region left") || strings.Contains(err.Error(), "region right") { + t.Errorf("the fork-entered regions were refused too: %v", err) + } +} + +// testForkOnlyRegionEnteredByDefault: a region only a fork enters has no +// default start, so another transition into its composite state is refused +// before the machine runs rather than failing when that transition fires. +func testForkOnlyRegionEnteredByDefault(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + attribute def Go; + state Machine { + entry; then init; + state init; + state working parallel { + state left { state a; } + state right { state b; } + } + fork split; + + transition first init accept Go then working; + transition first init then split; + transition first split then a; + transition first split then b; + } + }`) + if err == nil { + t.Fatal("expected an error for the transition entering the fork-only regions by default") + } + if !strings.Contains(err.Error(), "region left in state working has no initial state") || + !strings.Contains(err.Error(), "the transition from init to working") { + t.Errorf("expected the default entry into left to be named, got: %v", err) + } +} + +// testForkBranchWithATrigger: a fork's branches fire as one with the fork, so a +// branch that waits for an occurrence is refused before the machine runs rather +// than firing regardless of it. +func testForkBranchWithATrigger(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + attribute def Go; + state Machine { + entry; then init; + state init; + state working parallel { + state left { state a; } + state right { state b; } + } + fork split; + + transition first init then split; + transition first split accept Go then a; + transition first split then b; + } + }`) + if err == nil { + t.Fatal("expected an error for the triggered fork branch") + } + if !strings.Contains(err.Error(), "fork split: outgoing transitions cannot have triggers") { + t.Errorf("expected the triggered branch to be refused, got: %v", err) + } +} + +// testNestedForkStartsAnOuterRegionByDefault: a fork into a nested composite +// state enters the outer one on the way, starting its other region by default, +// so an outer region only another fork enters is refused before the machine runs. +func testNestedForkStartsAnOuterRegionByDefault(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state outer parallel { + state o1 { + entry; then hold; + state hold; + state inner parallel { + state left { state a; } + state right { state b; } + } + fork split; + transition first split then a; + transition first split then b; + } + state o2 { state c; } + } + fork split2; + + transition first init then split; + transition first split2 then hold; + transition first split2 then c; + } + }`) + if err == nil { + t.Fatal("expected an error for the nested fork starting o2 by default") + } + if !strings.Contains(err.Error(), "region o2 in state outer has no initial state") || + !strings.Contains(err.Error(), "the transition from init to split") { + t.Errorf("expected the nested fork's route to be named, got: %v", err) + } +} + +// testJoinWithOneIncomingBranch: a join synchronizes branches, so a single +// incoming transition is a modeling error rather than a pass-through. +func testJoinWithOneIncomingBranch(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine { + entry; then init; + state init; + state ready; + join sync; + + succession first init then ready; + transition first ready then sync; + transition first sync then done; + } + }`) + if err == nil { + t.Fatal("expected an error for a join with one incoming transition") + } + if !strings.Contains(err.Error(), "at least two incoming transitions") { + t.Errorf("expected an incoming-branch-count error, got: %v", err) + } +} + +// testJoinIncomingEffectThatFails: firing a join runs the effect of every +// transition into it, so an effect on a segment other than the firing one that +// fails surfaces as the step's error rather than being skipped. +// A source with two transitions into one join is refused when lowered: the +// alternative a trigger does not enable would otherwise fire with the join. +func testJoinWithTwoSegmentsFromOneSource(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + attribute def Go; + attribute def Stop; + state Machine parallel { + attribute log : String = ""; + + state left { + entry; then l1; + state l1; + transition first l1 accept Go do assign log := log + "go;" then sync; + transition first l1 accept Stop do assign log := log + "stop;" then sync; + } + state right { + entry; then r1; + state r1; + transition first r1 then sync; + } + join sync; + transition first sync then done; + } + }`) + if err == nil { + t.Fatal("expected an error for a join two transitions of one source enter") + } + if !strings.Contains(err.Error(), "two incoming transitions leave l1") { + t.Errorf("expected a shared-source error, got: %v", err) + } +} + +func testJoinIncomingEffectThatFails(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine parallel { + attribute x : Integer = 0; + attribute zero : Integer = 0; + + state left { + entry; then l1; + state l1; + transition first l1 do assign x := 1 then sync; + } + state right { + entry; then r1; + state r1; + transition first r1 do assign x := 1 / zero then sync; + } + join sync; + transition first sync then done; + } + }`) + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("error = %v, want the failing incoming effect's division by zero", err) + } +} + +// testJoinFromNestedStatesWrapperExitThatFails: a join whose sources lie nested +// below its owner's region states exits the composite states between each source +// and its region, so a failing exit action on one of them is the step's error. +func testJoinFromNestedStatesWrapperExitThatFails(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine { + attribute x : Integer = 0; + attribute zero : Integer = 0; + + entry; then work; + state work parallel { + state left { + entry; then il; + state il { + exit action { assign x := 1 / zero; } + entry; then l1; + state l1; + } + transition first l1 then sync; + } + state right { + entry; then ir; + state ir { + entry; then r1; + state r1; + } + transition first r1 then sync; + } + } + join sync; + transition first sync then done; + } + }`) + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("error = %v, want the nested wrapper's failing exit action's division by zero", err) + } +} + +// testJoinFromCompositeSourceSubstateExitThatFails: a join segment leaving a +// composite state whose substate is active exits that substate first, so its +// failing exit action is the step's error rather than the join never firing. +func testJoinFromCompositeSourceSubstateExitThatFails(t *testing.T) { + src := `package test { + attribute def Go; + state Machine { + attribute x : Integer = 0; + attribute zero : Integer = 0; + + entry; then work; + state work parallel { + state left { + entry; then il; + state il { + entry; then l1; + state l1 { + exit action { assign x := 1 / zero; } + } + } + transition first il accept Go then sync; + } + state right { + entry; then r1; + state r1; + transition first r1 accept Go then sync; + } + } + join sync; + transition first sync then done; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine Machine not found") + } + _, _, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("error = %v, want the active substate's failing exit action's division by zero", err) + } +} + +// testJoinOfMachineRegionsNestedSourceOwnerExitThatFails: a segment into a join of +// the machine's own regions leaves a state nested in an orthogonal state of one +// region, so that state is exited once, as part of the segment, and its failing +// exit action is the step's error rather than a second exit of a left state. +func testJoinOfMachineRegionsNestedSourceOwnerExitThatFails(t *testing.T) { + src := `package test { + attribute def Go; + state def Machine parallel { + attribute x : Integer = 0; + attribute zero : Integer = 0; + + state left { + entry; then inner; + state inner parallel { + exit action { assign x := 1 / zero; } + state l1 { entry; then a; state a; } + state l2 { entry; then c; state c; } + } + transition first a accept Go then sync; + } + state right { + entry; then b; + state b; + transition first b accept Go then sync; + } + join sync; + transition first sync then done; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine Machine not found") + } + _, _, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"}) + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("error = %v, want the nested orthogonal state's failing exit action's division by zero", err) + } +} + +// testJoinTimeSegmentSiblingGuardThatFails: a timer coming due on one segment +// into a join reads the other segments' guards to know whether the join is +// enabled, so one that cannot be evaluated then is the step's error. The guard +// read fine when its own completion came up and the timer segment held the join. +func testJoinTimeSegmentSiblingGuardThatFails(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine { + attribute zero : Integer = 1; + + entry; then work; + state work parallel { + state left { + entry; then l1; + state l1; + transition first l1 accept after 2 then sync; + } + state right { + entry; then r1; + state r1; + transition first r1 if 1 / zero > 0 then sync; + } + state aux { + entry; then c1; + state c1; + state c2; + transition first c1 accept after 1 do assign zero := 0 then c2; + } + } + join sync; + transition first sync then done; + } + }`) + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("error = %v, want the sibling segment's guard's division by zero", err) + } +} + +// testRegionPseudostateWithoutSatisfiedGuard: a junction reached from inside an +// orthogonal region whose branches are all guarded false has nowhere to go. The +// region set is left in place and the dead end reported, rather than the machine +// resting on a pseudostate. +func testRegionPseudostateWithoutSatisfiedGuard(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine parallel { + attribute x : Integer = 9; + + state left { + entry; then ls; + state ls; + state a; + state b; + succession first ls then a; + transition first a then merge; + } + state right { + entry; then rs; + state rs; + state c; + succession first rs then c; + } + junction merge; + + transition first merge if x == 1 then b; + } + }`) + if err == nil { + t.Fatal("expected an error for a junction with no satisfied guard") + } + if !strings.Contains(err.Error(), "no guard evaluated to true") { + t.Errorf("expected an unsatisfied-guard error, got: %v", err) + } +} + +// testRegionPseudostateCycle: pseudostates that route into each other never +// reach a state, so following the chain has to report the cycle instead of +// looping forever. +func testRegionPseudostateCycle(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine parallel { + state left { + entry; then ls; + state ls; + state a; + succession first ls then a; + transition first a then first; + } + state right { + entry; then rs; + state rs; + state c; + succession first rs then c; + } + junction first; + junction second; + + transition first first then second; + transition first second then first; + } + }`) + if err == nil { + t.Fatal("expected an error for pseudostates routing into each other") + } + if !strings.Contains(err.Error(), "form a cycle") { + t.Errorf("expected a cycle error, got: %v", err) + } +} + +// testDeadlockJoinStarvation: join awaiting token that never arrives. `stranded` +// has no incoming edge, so the join has two incoming edges but can only ever be +// reached by one token. +func testDeadlockJoinStarvation(t *testing.T) { + src := ` + package test { + action starve { + first start; + action stranded; + join sync; + done; + succession first start then sync; + succession first stranded then sync; + succession first sync then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + sym := findSymbolByName(idx.DocumentRoot(""), "starve", ast.DefAction) + if sym == nil { + t.Fatal("action starve not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + + err = exec.RunToCompletion() + if err == nil { + t.Fatal("expected a deadlock error, the starved join completed") + } + if !errors.Is(err, ErrActionDeadlock) { + t.Errorf("expected ErrActionDeadlock, got: %v", err) + } +} + +// testDeadlockJoinSameSuccessionTwice: two tokens reach the join over the one +// succession from the merge; they do not stand in for the succession from +// `stranded`, which no token can travel, so the join never fires. +func testDeadlockJoinSameSuccessionTwice(t *testing.T) { + src := ` + package test { + action starve { + first start; + fork split; + action a; + action b; + merge m; + action stranded; + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then m; + succession first b then m; + succession first m then sync; + succession first stranded then sync; + succession first sync then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + sym := findSymbolByName(idx.DocumentRoot(""), "starve", ast.DefAction) + if sym == nil { + t.Fatal("action starve not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + if err := exec.RunToCompletion(); !errors.Is(err, ErrActionDeadlock) { + t.Fatalf("error = %v, want ErrActionDeadlock", err) + } + for _, token := range exec.Tokens() { + if awaiting := exec.Awaiting(token); len(awaiting) != 1 { + t.Errorf("token %d awaits %d successions, want the one from stranded", token.ID, len(awaiting)) + } + } +} + +func testForkWithoutASuccessor(t *testing.T) { + src := ` + package test { + action broken { + first start; + fork split; + succession first start then split; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "broken", ast.DefAction) + if sym == nil { + t.Fatal("action broken not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + err = exec.RunToCompletion() + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("error = %v, want ErrInvalidActionFlow", err) + } +} + +func testExplicitSuccessionMissingEndpoint(t *testing.T) { + src := ` + package test { + action broken { + action compute; + succession first missing then compute; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "broken", ast.DefAction) + if sym == nil { + t.Fatal("action broken not found") + } + + _, err := ctx.CreateActionExecutor(sym) + if err == nil || !strings.Contains(err.Error(), "action succession references undefined source node") { + t.Fatalf("error = %v, want an explicit succession source diagnostic", err) + } +} + +func testControlFlowMissingEndpoint(t *testing.T) { + src := ` + package test { + action broken { + first start; + decide check; + succession first start then check; + if true then missing; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "broken", ast.DefAction) + if sym == nil { + t.Fatal("action broken not found") + } + + _, err := ctx.CreateActionExecutor(sym) + if err == nil || !strings.Contains(err.Error(), `control flow edge references undefined target "missing"`) { + t.Fatalf("error = %v, want an undefined control-flow target diagnostic", err) + } +} + +func testMergeWithoutASuccessor(t *testing.T) { + src := ` + package test { + action broken { + first start; + merge converge; + succession first start then converge; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "broken", ast.DefAction) + if sym == nil { + t.Fatal("action broken not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + err = exec.RunToCompletion() + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("error = %v, want ErrInvalidActionFlow", err) + } +} + +// testUnguardedLoopThroughAMerge: a merge passes every arrival, so a loop with no +// exit spins through it until the step budget stops the run with its typed error. +func testUnguardedLoopThroughAMerge(t *testing.T) { + src := ` + package test { + action spin { + first start; + merge m; + action a; + succession first start then m; + succession first m then a; + succession first a then m; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + ctx.maxActionSteps = 1000 + sym := findSymbolByName(idx.DocumentRoot(""), "spin", ast.DefAction) + if sym == nil { + t.Fatal("action spin not found") + } + + // RunToCompletion spends the whole budget on the loop and reports it. + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + err = exec.RunToCompletion() + if !errors.Is(err, ErrActionStepLimitExceeded) { + t.Fatalf("RunToCompletion() = %v, want ErrActionStepLimitExceeded", err) + } + if !strings.Contains(err.Error(), MaxActionStepsEnvVar) { + t.Errorf("error %q does not name %s", err, MaxActionStepsEnvVar) + } + + // A debugger steps the loop one node at a time: each step is one bounded unit + // of work, the token keeps circling m and a, and continuing from there hits the budget. + ctx.maxActionSteps = 20 + stepped, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + for i := 0; i < 2*int(ctx.maxActionSteps); i++ { + if err := stepped.Step(); err != nil { + t.Fatalf("Step() %d = %v, want nil", i, err) + } + tokens := stepped.Tokens() + if len(tokens) != 1 { + t.Fatalf("after step %d: %d tokens, want 1 circling the loop", i, len(tokens)) + } + _, atMerge := tokens[0].Location.(*ast.MergeNode) + if atMerge != (i%2 == 0) { + t.Fatalf("after step %d: token at %T, want it alternating between m and a", i, tokens[0].Location) + } + } + err = stepped.RunToCompletion() + if !errors.Is(err, ErrActionStepLimitExceeded) { + t.Fatalf("RunToCompletion() after stepping = %v, want ErrActionStepLimitExceeded", err) + } +} + +// testActionWhoseLastNodeHasNoSuccession: a node the flow leads no further from +// ends the flow, so an action declaring no `done` node completes instead of +// failing, and stepping past the end neither errors nor spins. +func testActionWhoseLastNodeHasNoSuccession(t *testing.T) { + src := ` + package test { + action ends { + first start; + then action a; + then action b; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + sym := findSymbolByName(idx.DocumentRoot(""), "ends", ast.DefAction) + if sym == nil { + t.Fatal("action ends not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run action whose last node has no succession: %v", err) + } + if exec.State() != StateCompleted { + t.Fatalf("expected the action to complete, got state %s", exec.State()) + } + + for i := 0; i < 3; i++ { + if err := exec.Step(); err != nil { + t.Fatalf("step %d past the end: %v", i+1, err) + } + if exec.State() != StateCompleted { + t.Fatalf("step %d past the end left state %s", i+1, exec.State()) + } + if len(exec.Tokens()) != 0 { + t.Fatalf("step %d past the end revived %d token(s)", i+1, len(exec.Tokens())) + } + } +} + +// testFirstNodeWithASecondSuccession: `first s1 then s2;` is a succession out of +// s1, so a second succession out of that node is ambiguous. +func testFirstNodeWithASecondSuccession(t *testing.T) { + src := ` + package test { + action seq { + action s1; + action s2; + action s3; + first s1 then s2; + succession first s1 then s3; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + sym := findSymbolByName(idx.DocumentRoot(""), "seq", ast.DefAction) + if sym == nil { + t.Fatal("action seq not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + + err = exec.RunToCompletion() + if err == nil { + t.Fatal("a first node with two successions ran to completion") + } + if !strings.Contains(err.Error(), "multiple successors") { + t.Fatalf("error = %q, want it to report multiple successors", err) + } +} + +// testFirstBesideAnInitialNode: `first s1 then s2;` beside `first start;` is the +// succession s1 -> s2, not a second start: start is the initial node and has no +// edge of its own. +func testFirstBesideAnInitialNode(t *testing.T) { + src := ` + package test { + action seq { + action s1; + action s2; + first start; + first s1 then s2; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + sym := findSymbolByName(idx.DocumentRoot(""), "seq", ast.DefAction) + if sym == nil { + t.Fatal("action seq not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + initial, ok := exec.graph.Initial.(*ast.InitialNode) + if !ok || initial.Name() != "start" { + t.Fatalf("initial node = %T, want the `first start;` marker", exec.graph.Initial) + } + if edges := exec.graph.Edges[initial]; len(edges) != 0 { + t.Fatalf("start has %d successions, want none", len(edges)) + } + var s1, s2 ast.Node + for _, node := range exec.graph.Nodes { + if u, ok := node.(*ast.Usage); ok { + switch u.Ident.Name { + case "s1": + s1 = node + case "s2": + s2 = node + } + } + } + if s1 == nil || s2 == nil { + t.Fatal("s1 and s2 are not both nodes of the graph") + } + if edges := exec.graph.Edges[s1]; len(edges) != 1 || edges[0].Target != s2 { + t.Fatalf("successions out of s1 = %d, want exactly s1 -> s2", len(edges)) + } +} + +// testTwoOneEndedFirsts: two one-ended `first` ends each mark a start, and a +// body has one, so lowering rejects them. +func testTwoOneEndedFirsts(t *testing.T) { + src := ` + package test { + action seq { + action a; + action b; + first a; + first b; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + sym := findSymbolByName(idx.DocumentRoot(""), "seq", ast.DefAction) + if sym == nil { + t.Fatal("action seq not found") + } + + if _, err := ctx.CreateActionExecutor(sym); err == nil { + t.Fatal("two starts lowered without an error") + } else if !strings.Contains(err.Error(), "multiple initial nodes") { + t.Fatalf("error = %q, want it to report multiple initial nodes", err) + } +} + +// testFirstNamingAFinalNode: a flow cannot start where it ends, so lowering +// rejects it rather than completing with the declared node never run. +func testFirstNamingAFinalNode(t *testing.T) { + src := ` + package test { + action seq { + attribute x = 0; + action s1 { assign x := 7; } + done; + first done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + sym := findSymbolByName(idx.DocumentRoot(""), "seq", ast.DefAction) + if sym == nil { + t.Fatal("action seq not found") + } + + if _, err := ctx.CreateActionExecutor(sym); err == nil { + t.Fatal("a first end naming a final node lowered without an error") + } else if !strings.Contains(err.Error(), "final node done") { + t.Fatalf("error = %q, want it to name the final node", err) + } +} + +// testForkBranchesAssigningTheSameFeature: concurrent branches writing one feature +// are unordered by the spec; the runtime resolves them by its own step order. +func testForkBranchesAssigningTheSameFeature(t *testing.T) { + src := ` + package test { + action clash { + attribute x : Integer = 0; + + first start; + fork split; + action left { assign x := 1; } + action right { assign x := 2; } + join sync; + done; + + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + sym := findSymbolByName(idx.DocumentRoot(""), "clash", ast.DefAction) + if sym == nil { + t.Fatal("action clash not found") + } + + var first semantics.Value + for run := 0; run < 3; run++ { + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("run %d create action executor: %v", run+1, err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run %d conflicting writes: %v", run+1, err) + } + if exec.State() != StateCompleted { + t.Fatalf("run %d left state %s", run+1, exec.State()) + } + + got, ok := exec.Results()["x"] + if !ok { + t.Fatalf("run %d lost the contested feature x", run+1) + } + if got.Kind != ValConst || got.Const.Kind != semantics.ValInt { + t.Fatalf("run %d gave x a non-integer value: %+v", run+1, got) + } + if got.Const.Int != 1 && got.Const.Int != 2 { + t.Fatalf("run %d gave x %d, which neither branch assigned", run+1, got.Const.Int) + } + if run == 0 { + first = got.Const + continue + } + if got.Const.Int != first.Int { + t.Fatalf("run %d gave x %d after run 1 gave %d: execution is not deterministic", + run+1, got.Const.Int, first.Int) + } + } +} + +// testDecisionNoSatisfiedGuard: a decision must select one outgoing succession. +func testDecisionNoSatisfiedGuard(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + + action noGuard { + attribute enabled : Boolean = false; + + first start; + action selected; + done; + + succession first start then choose; + succession first selected then done; + + decide choose; + if enabled then selected; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "noGuard", ast.DefAction) + if sym == nil { + t.Fatal("action noGuard not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + err = exec.RunToCompletion() + if !errors.Is(err, ErrNoEnabledSuccession) { + t.Fatalf("error = %v, want ErrNoEnabledSuccession", err) + } +} + +// testDecisionAllGuardsFalse: every guard of a decision is evaluated so that +// several holding at once can be reported; none holding is still the same +// error, recorded as no choice at all. +func testDecisionAllGuardsFalse(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + + action pick { + attribute level : Integer = 5; + + first start; + action low; + action high; + done; + + succession first start then choose; + succession first low then done; + succession first high then done; + + decide choose; + if level > 10 then low; + if level > 20 then high; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "pick", ast.DefAction) + if sym == nil { + t.Fatal("action pick not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + err = exec.RunToCompletion() + if !errors.Is(err, ErrNoEnabledSuccession) { + t.Fatalf("error = %v, want ErrNoEnabledSuccession", err) + } + if !strings.Contains(err.Error(), "decision node choose has no true guard") { + t.Fatalf("error = %v, want the decision named", err) + } + if choices := ctx.Choices(); len(choices) != 0 { + t.Fatalf("choices = %v, want none when no guard holds", choices) + } +} + +// testStateDanglingTransition: state with transition to nonexistent state +func testStateDanglingTransition(t *testing.T) { + src := ` + package test { + state Machine { + entry; then init; + state init; + succession first init then nowhere; // 'nowhere' state doesn't exist + } + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + + idx, model, ctx := buildRuntime(t, "", file) + + _ = model // silence unused + + // Check diagnostics (resolver should catch missing state) + // Note: resolver diagnostics accessed via resolver.Diagnostics field + + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "Machine", ast.DefState) + if sym == nil { + t.Fatal("Broken state not found") + } + + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Logf("CreateStateExecutor error (acceptable): %v", err) + return + } + + err = exec.ProcessNextEvent() + if err != nil { + t.Logf("ProcessNextEvent returned error (acceptable): %v", err) + return + } + + t.Log("ProcessNextEvent succeeded (dangling transition not exercised)") +} + +func testParallelStateBodyUnsupportedMember(t *testing.T) { + src := ` + package test { + action def Warm; + state Machine parallel { + state left; + perform Warm; + } + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("parallel state not found") + } + _, err := ctx.CreateStateExecutor(sym) + if err == nil { + t.Fatal("parallel state with an unsupported member succeeded") + } + if !strings.Contains(err.Error(), "parallel state body contains unsupported member") { + t.Fatalf("error = %v, want unsupported parallel-body member", err) + } +} + +// A region with substates of its own must name the one it starts in; a bare +// `state left;` is a region standing in that state and needs none. +func testParallelStateRegionWithoutInitial(t *testing.T) { + src := ` + package test { + state Machine parallel { + state left { + state a; + state b; + } + } + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("parallel state not found") + } + _, err := ctx.CreateStateExecutor(sym) + if err == nil { + t.Fatal("parallel state with no region initial succeeded") + } + if !strings.Contains(err.Error(), "region left has no initial state") { + t.Fatalf("error = %v, want missing region initial", err) + } + if !strings.Contains(err.Error(), "entry; then ;") { + t.Fatalf("error = %v, want initial-state notation guidance", err) + } +} + +// testParallelStateRegionItselfParallel: a region is a direct substate of a +// parallel body and starts in one of its own states, so a direct substate that +// is itself parallel has no state to start in and is refused, written inline or +// typed; the active configuration therefore never holds a region owned by a +// region's wrapper state. +func testParallelStateRegionItselfParallel(t *testing.T) { + for name, src := range map[string]string{ + "inline": ` + package test { + state def Machine { + entry; then work; + state work parallel { + state left parallel { + state r { entry; then r1; state r1; } + state s { entry; then s1; state s1; } + } + state right { entry; then b1; state b1; } + } + } + } + `, + "typed": ` + package test { + state def Nested parallel { + state r { entry; then r1; state r1; } + state s { entry; then s1; state s1; } + } + state def Machine { + entry; then work; + state work parallel { + state left : Nested; + state right { entry; then b1; state b1; } + } + } + } + `, + } { + err := stateExecutorError(t, src, "Machine") + if err == nil { + t.Fatalf("%s: a parallel region of a parallel state succeeded", name) + } + if !strings.Contains(err.Error(), "region left has no initial state") { + t.Fatalf("%s: error = %v, want missing region initial", name, err) + } + } +} + +// testStateUsageTypedByItself: a definition whose substate is typed by it has no +// finite materialization and must report that, not recurse. +func testStateUsageTypedByItself(t *testing.T) { + src := ` + package test { + state def A { + entry; then b; + state b : A; + } + } + ` + err := stateExecutorError(t, src, "A") + if err == nil { + t.Fatal("recursively typed state succeeded") + } + if !errors.Is(err, lower.ErrRecursiveStateTyping) { + t.Fatalf("error = %v, want recursive state typing", err) + } +} + +// testStateUsageMutuallyRecursiveTyping: two definitions reaching each other +// through their substates report the cycle rather than materializing forever. +func testStateUsageMutuallyRecursiveTyping(t *testing.T) { + src := ` + package test { + state def A { + entry; then b; + state b : B; + } + state def B { + entry; then a; + state a : A; + } + } + ` + err := stateExecutorError(t, src, "A") + if err == nil { + t.Fatal("mutually recursive state typing succeeded") + } + if !errors.Is(err, lower.ErrRecursiveStateTyping) { + t.Fatalf("error = %v, want recursive state typing", err) + } +} + +// testStateDefSpecializingTheLibraryStateAction: a state definition written +// `:> StateAction` inherits no content from the library (whose `ref state self` +// is typed by StateAction itself), the same as the implicit specialization. +func testStateDefSpecializingTheLibraryStateAction(t *testing.T) { + src := ` + package test { + private import States::*; + state def Phase :> StateAction; + state def Prep :> Phase; + state def Machine { + entry; then prep; + state prep : Prep; + state launch : Phase; + transition first prep then launch; + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state Machine not found") + } + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if got := activeStateNames(exec); got != "launch" { + t.Fatalf("active states = %q, want launch", got) + } +} + +// testStateDefSpecializingALibraryStateKeepsItsContent: only StateAction is +// withheld; a library's own state definition still contributes its substates, +// transitions, behaviors and attributes to what specializes it. +func testStateDefSpecializingALibraryStateKeepsItsContent(t *testing.T) { + lib := ` + package Cycles { + private import ScalarValues::*; + state def Cycle { + attribute seen : Integer = 0; + entry; then warm; + state warm; + state hot { + entry action mark { assign seen := seen + 1; } + } + transition first warm then hot; + } + } + ` + src := ` + package test { + private import Cycles::*; + state def Burn :> Cycle; + state def Machine { + entry; then burn; + state burn : Burn; + } + } + ` + idx := libs.NewModelIndex() + idx.AddDocument("", parser.New(source.New("", []byte(lib))).ParseFile()) + idx.MarkLibrary("") + idx.AddDocument("", parseAndBuild(t, src)) + idx.ExpandWildcardImports() + resolver := resolve.New(idx) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state Machine not found") + } + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if got := exec.FinalStateName(); got != "hot" { + t.Fatalf("final state = %q, want hot", got) + } + seen, ok := exec.StateData()["burn.seen"] + if !ok { + t.Fatalf("burn.seen missing from %v", exec.StateData()) + } + if got := FormatValue(seen); got != "1" { + t.Fatalf("burn.seen = %s, want 1", got) + } +} + +// A body-less usage typed by the library's StateAction, however named, lowers +// without recursing into `ref state self` and fails only for no initial state. +func testExhibitedStateTypedByTheLibraryStateAction(t *testing.T) { + for _, tc := range []struct{ name, typing string }{ + {"imported", "StateAction"}, + {"qualified", "States::StateAction"}, + } { + t.Run(tc.name, func(t *testing.T) { + src := ` + package test { + private import States::StateAction; + part def Mission { + attribute mass = 1; + exhibit state phases : ` + tc.typing + `; + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Mission") + if err == nil { + t.Fatal("expected the machine with no initial state to fail materialization") + } + if errors.Is(err, lower.ErrRecursiveStateTyping) { + t.Fatalf("error = %v, want StateAction's content withheld rather than recursed into", err) + } + if !errors.Is(err, ErrNoInitialState) { + t.Fatalf("error = %v, want ErrNoInitialState", err) + } + for _, want := range []string{"phases", "Mission", "StateAction"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %v, want it to name %s", err, want) + } + } + }) + } +} + +// testExhibitedStateTypedByTheLibraryStateActionWithABody: a state usage typed +// by StateAction and stating its own body runs that body, inheriting nothing. +func testExhibitedStateTypedByTheLibraryStateActionWithABody(t *testing.T) { + src := ` + package test { + private import States::StateAction; + part def Mission { + attribute mass = 2; + exhibit state phases : StateAction { entry; then x; state x; } + } + }` + ctx, inst, err := instantiateWithLibraries(t, src, "test::Mission") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + assertExhibitedMachineIn(t, ctx, inst, "mass", 2, "x") +} + +// A usage typed by a definition specializing StateAction inherits that +// definition's content and nothing of the library's. +func testExhibitedStateTypedByAStateActionSpecialization(t *testing.T) { + t.Run("with_content", func(t *testing.T) { + src := ` + package test { + private import States::StateAction; + state def Phase :> StateAction { entry; then x; state x; } + part def Mission { + attribute mass = 3; + exhibit state phases : Phase; + } + }` + ctx, inst, err := instantiateWithLibraries(t, src, "test::Mission") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + assertExhibitedMachineIn(t, ctx, inst, "mass", 3, "x") + }) + t.Run("own_body", func(t *testing.T) { + src := ` + package test { + private import States::StateAction; + state def Phase :> StateAction; + part def Mission { + attribute mass = 4; + exhibit state phases : Phase { entry; then x; state x; } + } + }` + ctx, inst, err := instantiateWithLibraries(t, src, "test::Mission") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + assertExhibitedMachineIn(t, ctx, inst, "mass", 4, "x") + }) + t.Run("empty", func(t *testing.T) { + src := ` + package test { + private import States::StateAction; + state def Phase :> StateAction; + part def Mission { + attribute mass = 5; + exhibit state phases : Phase; + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Mission") + if !errors.Is(err, ErrNoInitialState) { + t.Fatalf("error = %v, want ErrNoInitialState", err) + } + if !strings.Contains(err.Error(), "phases") || !strings.Contains(err.Error(), "Phase") { + t.Errorf("error = %v, want it to name the machine and its definition", err) + } + }) +} + +// assertExhibitedMachineIn checks that an object exhibits a machine resting in +// the named state, and that an ordinary attribute of it still reads. +func assertExhibitedMachineIn(t *testing.T, ctx *Context, inst *Instance, attr string, want int64, state string) { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, attr) + if err != nil { + t.Fatalf("%s: %v", attr, err) + } + if got := fv.HeldValue(); got.Kind != ValConst || got.Const.Int != want { + t.Errorf("%s = %s, want %d", attr, FormatValue(got), want) + } + machine, ok := inst.ExhibitedState() + if !ok || machine.State == nil { + t.Fatal("the object exhibits no machine") + } + if got := machine.State.FinalStateName(); got != state { + t.Errorf("final state = %q, want %q", got, state) + } +} + +// testStateUsageInheritsUnsupportedMember: content a usage inherits that state +// lowering cannot represent is reported, never dropped. +func testStateUsageInheritsUnsupportedMember(t *testing.T) { + src := ` + package test { + action def Warm; + state def Inner { + entry; then i1; + state i1; + perform Warm; + } + state def Machine { + entry; then nested; + state nested : Inner; + } + } + ` + err := stateExecutorError(t, src, "Machine") + if err == nil { + t.Fatal("unsupported inherited member succeeded") + } + if !errors.Is(err, lower.ErrUnsupportedStateContent) { + t.Fatalf("error = %v, want unsupported state content", err) + } + if !strings.Contains(err.Error(), "cannot be inherited by the state nested") { + t.Fatalf("error = %v, want the inheriting state named", err) + } +} + +// testRunToCompletionRedefinedFalse: a machine redefining isRunToCompletion to +// false executes under the declared non-run-to-completion behavior. +func testRunToCompletionRedefinedFalse(t *testing.T) { + if err := stateExecutorError(t, ` + package test { + state def Machine { + attribute :>> isRunToCompletion = false; + entry; then idle; + state idle; + } + } + `, "Machine"); err != nil { + t.Fatalf("false redefinition failed: %v", err) + } +} + +// testRunToCompletionScopeNarrowed: a substate redefining runToCompletionScope to +// itself executes with the declared state-local scope. +func testRunToCompletionScopeNarrowed(t *testing.T) { + if err := stateExecutorError(t, ` + package test { + state def Machine { + entry; then idle; + state idle { + ref :>> runToCompletionScope = self; + } + } + } + `, "Machine"); err != nil { + t.Fatalf("scope narrowing failed: %v", err) + } +} + +// testRunToCompletionRedefinedBySpecializedDef: the effective inherited +// redefinition on a specialized state definition executes on the machine. +func testRunToCompletionRedefinedBySpecializedDef(t *testing.T) { + if err := stateExecutorError(t, ` + package test { + state def Base { + attribute :>> isRunToCompletion = false; + entry; then idle; + state idle; + } + state def Machine :> Base; + } + `, "Machine"); err != nil { + t.Fatalf("inherited redefinition failed: %v", err) + } +} + +// testRunToCompletionRedefinedInOrthogonalRegion: a substate of an orthogonal +// region carrying the redefinition executes through the lowered state path. +func testRunToCompletionRedefinedInOrthogonalRegion(t *testing.T) { + if err := stateExecutorError(t, ` + package test { + state def Machine parallel { + state left { + entry; then l1; + state l1 { + attribute :>> isRunToCompletion = false; + } + } + state right { + entry; then r1; + state r1; + } + } + } + `, "Machine"); err != nil { + t.Fatalf("orthogonal redefinition failed: %v", err) + } +} + +// testRunToCompletionRedefinedUndecidably: a value lowering that cannot verify +// the library default is refused rather than assumed to restate it. +func testRunToCompletionRedefinedUndecidably(t *testing.T) { + if err := stateExecutorError(t, ` + package test { + state def Machine { + attribute strict : Boolean = true; + attribute :>> isRunToCompletion = strict or true; + entry; then idle; + state idle; + } + } + `, "Machine"); err != nil { + t.Fatalf("expression redefinition failed: %v", err) + } +} + +// testRunToCompletionDefaultsRestated: redefinitions restating the library +// defaults execute with the default scheduling behavior. +func testRunToCompletionDefaultsRestated(t *testing.T) { + err := stateExecutorError(t, ` + package test { + state def Machine { + attribute :>> isRunToCompletion = true; + ref :>> runToCompletionScope = self; + entry; then idle; + state idle { + attribute :>> isRunToCompletion = true; + } + } + } + `, "Machine") + if err != nil { + t.Fatalf("restating the defaults was refused: %v", err) + } +} + +// testRunToCompletionDefaultRestoredBySpecialization: a redefinition restating +// the default masks the inherited definition on the machine and substate. +func testRunToCompletionDefaultRestoredBySpecialization(t *testing.T) { + err := stateExecutorError(t, ` + package test { + state def Base { + attribute :>> isRunToCompletion = false; + entry; then idle; + state idle : Leaf { + attribute :>> isRunToCompletion = true; + } + } + state def Leaf { + attribute :>> isRunToCompletion = false; + } + state def Machine :> Base { + attribute :>> isRunToCompletion = true; + } + } + `, "Machine") + if err != nil { + t.Fatalf("restoring the default over an inherited redefinition was refused: %v", err) + } +} + +// testRunToCompletionDefaultMaskedBySpecialization: the machine's effective +// redefinition is judged over and executes instead of the inherited one it masks. +func testRunToCompletionDefaultMaskedBySpecialization(t *testing.T) { + if err := stateExecutorError(t, ` + package test { + state def Base { + attribute :>> isRunToCompletion = true; + entry; then idle; + state idle; + } + state def Machine :> Base { + attribute :>> isRunToCompletion = false; + } + } + `, "Machine"); err != nil { + t.Fatalf("specialized redefinition failed: %v", err) + } +} + +// testRunToCompletionRedefinedThroughAlias: a redefinition naming the library +// feature through an alias resolves to it and executes by its target. +func testRunToCompletionRedefinedThroughAlias(t *testing.T) { + err := libraryStateExecutorError(t, ` + package test { + alias Rtc for Occurrences::Occurrence::isRunToCompletion; + state def Machine { + attribute :>> Rtc = false; + entry; then idle; + state idle; + } + } + `, "Machine") + if err != nil { + t.Fatalf("alias redefinition failed: %v", err) + } +} + +// testRunToCompletionRedefinedThroughRedefiningFeature: a redefinition of a +// feature that itself redefines the library one reaches and executes it. +func testRunToCompletionRedefinedThroughRedefiningFeature(t *testing.T) { + err := libraryStateExecutorError(t, ` + package test { + state def Base { + attribute strict :>> isRunToCompletion; + entry; then idle; + state idle; + } + state def Machine :> Base { + attribute :>> strict = false; + } + } + `, "Machine") + if err != nil { + t.Fatalf("redefining-feature route failed: %v", err) + } +} + +// testRunToCompletionModelFeatureUnderLibraryName: a model's own feature +// declared under the library's qualified name is an ordinary attribute, so +// redefining it to false executes without changing library RTC semantics. +func testRunToCompletionModelFeatureUnderLibraryName(t *testing.T) { + const src = ` + package Occurrences { + state def Occurrence { + attribute isRunToCompletion = true; + entry; then idle; + state idle; + } + } + package test { + state def Machine :> Occurrences::Occurrence { + attribute :>> isRunToCompletion = false; + } + } + ` + for name, build := range map[string]func(*testing.T, string, string) error{ + "without library": stateExecutorError, + "with library": libraryStateExecutorError, + } { + if err := build(t, src, "Machine"); err != nil { + t.Fatalf("%s: a model's own isRunToCompletion was taken for the library's: %v", name, err) + } + } +} + +// stateExecutorError builds a state executor for a named state definition and +// returns what creating it reports. +func stateExecutorError(t *testing.T, src, name string) error { + t.Helper() + return stateExecutorErrorIn(t, src, name, buildRuntime) +} + +// libraryStateExecutorError is stateExecutorError over an index carrying the +// standard library, for a model that names library elements. +func libraryStateExecutorError(t *testing.T, src, name string) error { + t.Helper() + return stateExecutorErrorIn(t, src, name, buildRuntimeWithLibraries) +} + +func stateExecutorErrorIn(t *testing.T, src, name string, build func(*testing.T, string, *ast.RootNamespace) (*symbols.Index, *semantics.Model, *Context)) error { + t.Helper() + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := build(t, "", file) + sym := findSymbolByName(idx.DocumentRoot(""), name, ast.DefState) + if sym == nil { + t.Fatalf("state %s not found", name) + } + _, err := ctx.CreateStateExecutor(sym) + return err +} + +// testSourcelessTransitionWithNothingBefore: a transition written without a +// source leaves the state declared before it (SysML v2 7.18.3); as the first +// member of its body it has none, which lowering reports. +func testSourcelessTransitionWithNothingBefore(t *testing.T) { + err := stateExecutorError(t, ` + package test { + state Machine { + accept go then active; + entry; then init; + state init; + state active; + } + } + `, "Machine") + if !errors.Is(err, lower.ErrNoTransitionSource) { + t.Fatalf("expected ErrNoTransitionSource, got %v", err) + } + if err.Error() != "create state executor: lower state machine: "+lower.NoTransitionSourceMessage { + t.Fatalf("unexpected message: %v", err) + } +} + +// testSourcelessTransitionAfterANonState: the member before the shorthand is an +// entry action, a do action or an attribute rather than a state, which is not +// something a transition with a trigger can leave. +func testSourcelessTransitionAfterANonState(t *testing.T) { + cases := map[string]struct { + body string + want string + }{ + "do action": { + body: `entry; then init; + state init; + do action watch { } + accept go then active; + state active;`, + want: "the do action", + }, + "attribute": { + body: `entry; then init; + state init; + attribute count : Integer = 0; + accept go then active; + state active;`, + want: "the attribute usage count", + }, + "choice pseudostate": { + body: `entry; then init; + state init; + transition first init then pick; + choice pick; + accept go then active; + state active;`, + want: fmt.Sprintf(lower.TransitionSourcePseudostateFormat, "the choice pick", "pick"), + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := stateExecutorError(t, ` + package test { + state Machine { + `+tc.body+` + } + } + `, "Machine") + var sourceErr *lower.TransitionSourceError + if !errors.As(err, &sourceErr) { + t.Fatalf("expected TransitionSourceError, got %v", err) + } + want := "create state executor: lower state machine: " + fmt.Sprintf(lower.TransitionSourceNotVertexFormat, tc.want) + if _, ok := sourceErr.Source.(*ast.PseudostateNode); ok { + want = "create state executor: lower state machine: " + tc.want + } + if err.Error() != want { + t.Fatalf("message:\n got %q\nwant %q", err.Error(), want) + } + }) + } +} + +// testNoEntryTransitionGuardHolds: `entry; if c then s;` chooses the starting +// state by guard at initialize (SysML v2 7.18.3); when no guard holds the +// machine has nowhere to start, a typed error rather than a silent stall. +func testNoEntryTransitionGuardHolds(t *testing.T) { + src := ` + package test { + part def Heater { + attribute cold : Boolean = true; + exhibit state control { + entry; + if not cold then ready; + if cold and not cold then warming; + state warming; + state ready; + } + } + } + ` + _, _, err := instantiateWithLibraries(t, src, "test::Heater") + if !errors.Is(err, ErrNoEntryTransitionHolds) { + t.Fatalf("expected ErrNoEntryTransitionHolds, got %v", err) + } + want := "no entry transition holds: state machine control declares 2 transitions out of its entry action and the guard of none holds" + if !strings.Contains(err.Error(), want) { + t.Fatalf("message:\n got %q\nwant it to contain %q", err.Error(), want) + } +} + +// testEntryTransitionTargetIsNotAState: an entry transition starts its body in +// a state; reaching a pseudostate instead is a typed lowering error. +func testEntryTransitionTargetIsNotAState(t *testing.T) { + err := stateExecutorError(t, ` + package test { + state Machine { + entry; then pick; + choice pick; + transition first pick then idle; + state idle; + } + } + `, "Machine") + var targetErr *lower.EntryTransitionTargetError + if !errors.As(err, &targetErr) { + t.Fatalf("expected EntryTransitionTargetError, got %v", err) + } + want := "create state executor: lower state machine: " + fmt.Sprintf(lower.EntryTransitionTargetFormat, "the choice pick") + if err.Error() != want { + t.Fatalf("message:\n got %q\nwant %q", err.Error(), want) + } +} + +// testEntryTransitionCarriesATrigger: an entry transition chooses the start by +// its guard alone; a trigger on it is a typed lowering error. +func testEntryTransitionCarriesATrigger(t *testing.T) { + err := stateExecutorError(t, ` + package test { + state Machine { + entry; accept go then idle; + state idle; + } + } + `, "Machine") + var shapeErr *lower.EntryTransitionShapeError + if !errors.As(err, &shapeErr) { + t.Fatalf("expected EntryTransitionShapeError, got %v", err) + } + want := "create state executor: lower state machine: " + fmt.Sprintf(lower.EntryTransitionShapeFormat, "a trigger") + if err.Error() != want { + t.Fatalf("message:\n got %q\nwant %q", err.Error(), want) + } +} + +// testEntryTransitionIntoDoneCompletesAtInitialize: an entry transition whose +// guard chooses `done` completes the machine as it starts — its exit behavior +// runs and no event is left waiting — rather than leaving it running in `done`. +func testEntryTransitionIntoDoneCompletesAtInitialize(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute skip : Boolean = true; + attribute left : Boolean = false; + entry; if skip then done; + then busy; + exit action { assign left := true; } + state busy; + transition first busy accept after 1 [SI::s] then done; + } + }`) + if exec.State() != StateCompleted { + t.Fatalf("expected StateCompleted right after initialize, got %s", exec.State()) + } + assertCurrentState(t, exec, ast.DoneFeature) + if got := exec.StateData()["left"]; got.Kind != ValConst || !got.Const.Bool { + t.Errorf("the machine's exit action did not run, left = %v", got) + } + if exec.EventQueue().Len() != 0 { + t.Errorf("a completed machine keeps %d events waiting", exec.EventQueue().Len()) + } +} + +// testNamedEntryActionTransitionIntoDoneCompletesAtInitialize: a transition +// out of a named entry action into an undeclared `done` completes the machine as +// it starts, in each syntax the succession can be written in; a declared state +// named `done` is entered instead. +func testNamedEntryActionTransitionIntoDoneCompletesAtInitialize(t *testing.T) { + for name, successions := range map[string]string{ + "guarded transition": `transition begin if skip then done; + transition begin then busy;`, + "transition": `transition begin then done;`, + "succession": `succession first begin then done;`, + } { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute skip : Boolean = true; + attribute left : Boolean = false; + entry action begin { } + `+successions+` + exit action { assign left := true; } + state busy; + } + }`) + if exec.State() != StateCompleted { + t.Errorf("%s: expected StateCompleted right after initialize, got %s", name, exec.State()) + } + if got := exec.StateData()["left"]; got.Kind != ValConst || !got.Const.Bool { + t.Errorf("%s: the machine's exit action did not run, left = %v", name, got) + } + } + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + entry action begin { } + transition begin then done; + state done; + } + }`) + if exec.State() != StateRunning { + t.Errorf("expected the machine to be running in its declared state done, got %s", exec.State()) + } + assertCurrentState(t, exec, "done") +} + +// testOwnEntryTransitionsReplaceInheritedOnes: the entry transitions a state +// writes itself replace the ones it inherits, guarded or not, at the machine's +// top level, in a nested typed usage and in a typed orthogonal region; a state +// writing none keeps the inherited start. +func testOwnEntryTransitionsReplaceInheritedOnes(t *testing.T) { + const base = ` + state def Base { + attribute c : Boolean = true; + entry; if c then old; + then older; + state old; + state older; + }` + for name, tc := range map[string]struct { + machine string + want []string + }{ + "specializing machine": {machine: ` + state def Machine :> Base { + entry; then fresh; + state fresh; + }`, want: []string{"fresh"}}, + "typed usage": {machine: ` + state def Machine { + entry; then u; + state u : Base { + entry; then fresh; + state fresh; + } + }`, want: []string{"fresh"}}, + "guarded typed usage": {machine: ` + state def Machine { + entry; then u; + state u : Base { + entry; if not c then fresh; + then fresher; + state fresh; + state fresher; + } + }`, want: []string{"fresher"}}, + "redeclared entry behavior only": {machine: ` + state def Machine { + entry; then u; + state u : Base { + entry assign c := true; + } + }`, want: []string{"old"}}, + "typed region": {machine: ` + state def Machine parallel { + state left : Base { + entry; then fresh; + state fresh; + } + state right : Base; + }`, want: []string{"fresh", "old"}}, + } { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*;`+base+tc.machine+` + }`) + var got []string + for _, state := range exec.ActiveStates() { + got = append(got, state.Name) + } + if fmt.Sprint(got) != fmt.Sprint(tc.want) { + t.Errorf("%s: started in %v, want %v", name, got, tc.want) + } + } +} + +// testRegionEntryTransitionsIntoDoneCompleteAtInitialize: every orthogonal +// region starting in `done` completes the machine as it starts, exactly once. +func testRegionEntryTransitionsIntoDoneCompleteAtInitialize(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine parallel { + attribute exits : Integer = 0; + exit action { assign exits := exits + 1; } + state left { + entry; then done; + } + state right { + entry; then done; + } + } + }`) + if exec.State() != StateCompleted { + t.Fatalf("expected StateCompleted right after initialize, got %s", exec.State()) + } + if got := exec.StateData()["exits"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("the machine's exit action ran %v times, want once", got) + } +} + +// testNestedRegionsIntoDoneCompleteAtInitialize: a machine starting in a +// parallel state whose every region starts in `done` completes that state as +// it starts; its completion transition then completes the machine. +func testNestedRegionsIntoDoneCompleteAtInitialize(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute exits : Integer = 0; + exit action { assign exits := exits + 1; } + entry; then outer; + state outer parallel { + state left { + entry; then done; + } + state right { + entry; then done; + } + } + transition first outer then done; + } + }`) + if exec.State() != StateRunning { + t.Fatalf("expected the completion of outer pending right after initialize, got %s", exec.State()) + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("completion of outer: %v", err) + } + if exec.State() != StateCompleted { + t.Fatalf("expected StateCompleted once outer's completion transition fired, got %s", exec.State()) + } + if got := exec.StateData()["exits"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("the machine's exit action ran %v times, want once", got) + } +} + +// testNestedRegionsIntoDoneWithoutCompletionTransitionStayActive: a parallel +// state whose every region starts in `done` and which has no completion +// transition stays active and completed; the machine keeps running. +func testNestedRegionsIntoDoneWithoutCompletionTransitionStayActive(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute exits : Integer = 0; + exit action { assign exits := exits + 1; } + entry; then outer; + state outer parallel { + state left { + entry; then done; + } + state right { + entry; then done; + } + } + transition first outer accept again then outer; + } + }`) + if exec.State() != StateRunning { + t.Fatalf("expected the machine running with outer completed, got %s", exec.State()) + } + if exec.EventQueue().Len() != 0 { + t.Fatalf("%d events pending, want none: outer has no completion transition", exec.EventQueue().Len()) + } + if got := exec.StateData()["exits"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Errorf("the machine's exit action ran %v times, want never", got) + } + exec.SendSignal("again", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("again: %v", err) + } + dispatch, _ := exec.LastDispatch() + if !dispatch.Fired { + t.Error("the completed outer state no longer reacts to an event") + } + if exec.State() != StateRunning { + t.Errorf("expected the machine still running after re-entering outer, got %s", exec.State()) + } +} + +// testTransitionIntoNestedRegionsInDoneCompletes: a transition into a parallel +// state whose every region starts in `done` completes that state, and its +// completion transition the machine. +func testTransitionIntoNestedRegionsInDoneCompletes(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute exits : Integer = 0; + exit action { assign exits := exits + 1; } + entry; then idle; + state idle; + transition first idle accept go then outer; + state outer parallel { + state left { + entry; then done; + } + state right { + entry; then done; + } + } + transition first outer then done; + } + }`) + assertCurrentState(t, exec, "idle") + exec.SendSignal("go", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("go: %v", err) + } + if exec.State() != StateRunning { + t.Fatalf("expected the completion of outer pending after entering it, got %s", exec.State()) + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("completion of outer: %v", err) + } + if exec.State() != StateCompleted { + t.Fatalf("expected StateCompleted once outer's completion transition fired, got %s", exec.State()) + } + if got := exec.StateData()["exits"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("the machine's exit action ran %v times, want once", got) + } +} + +// testRegionStartDescendsThroughEntryTransitions: a region whose starting state +// is composite starts that state where its own entry transitions choose, and the +// nested state is the region's active state, so its transitions are armed. +func testRegionStartDescendsThroughEntryTransitions(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine parallel { + attribute cold : Boolean = true; + state control { + entry; then running; + state running { + entry; if cold then heating; + if not cold then idle; + state heating; + transition first heating accept warm then idle; + state idle; + } + } + state monitor { + entry; then watching; + state watching; + } + } + }`) + activeNames := func() map[string]bool { + active := make(map[string]bool) + for _, state := range exec.ActiveStates() { + active[state.Name] = true + } + return active + } + if active := activeNames(); !active["heating"] || !active["watching"] { + t.Fatalf("expected heating and watching active after initialize, got %v", active) + } + exec.SendSignal("warm", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if active := activeNames(); !active["idle"] || active["heating"] { + t.Errorf("warm did not move heating to idle, active states %v", active) + } +} + +// testRegionEntryGuardsReadTheRegionStateAttributes: the entry transitions of a +// region of a parallel state read the attributes that region's own state +// declares, after its entry behavior has run, whether the parallel state is the +// machine itself or a composite state entered below it. +func testRegionEntryGuardsReadTheRegionStateAttributes(t *testing.T) { + regions := ` + state left { + attribute cold : Boolean = true; + entry assign cold := false; + if cold then on; + then off; + state on; + state off; + } + state right { + attribute cold : Boolean = false; + entry assign cold := true; + if cold then on; + then off; + state on; + state off; + }` + machines := map[string]string{ + "parallel machine": `package test { + private import ScalarValues::*; + state Machine parallel {` + regions + ` + } + }`, + "parallel state": `package test { + private import ScalarValues::*; + state Machine { + entry; then outer; + state outer parallel {` + regions + ` + } + } + }`, + } + for name, src := range machines { + exec := stateExecutorForSource(t, "Machine", src) + active := make(map[string]string) + for _, state := range exec.ActiveStates() { + active[exec.graph.ParentState[state].Name] = state.Name + } + if active["left"] != "off" || active["right"] != "on" { + t.Errorf("%s: expected left in off and right in on after their entry behaviors, got %v", name, active) + } + } +} + +// testLeavingRegionsDescendsThroughEntryTransitions: a transition out of an +// orthogonal region into a composite state outside it starts that state where +// its own entry transitions choose, and the nested state's timer is armed. +func testLeavingRegionsDescendsThroughEntryTransitions(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, `package test { + private import ScalarValues::*; + state Machine { + entry; then both; + state both parallel { + state left { + entry; then l1; + state l1; + } + state right { + entry; then r1; + state r1; + } + } + transition first both.left.l1 accept leave then running; + state running { + entry; then waiting; + state waiting; + transition first waiting accept after 1 [SI::s] then finished; + state finished; + } + } + }`)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("Machine not found") + } + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newStateExecutor: %v", err) + } + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + exec.SendSignal("leave", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("leave: %v", err) + } + assertCurrentState(t, exec, "waiting") + if got := len(ctx.Clock().Waits()); got != 1 { + t.Fatalf("%d wait(s) on the clock after entering waiting; want its timer armed", got) + } + if _, err := ctx.Advance(1); err != nil { + t.Fatalf("advance: %v", err) + } + assertCurrentState(t, exec, "finished") +} + +// testCalcUnboundParameter: a parameter with neither an argument nor a default +// is a modeling error, not a null value. +func testCalcUnboundParameter(t *testing.T) { + src := ` + package test { + calc add { + in x: Integer; + in y: Integer; + x + y + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "add", ast.DefCalc) + if sym == nil { + t.Fatal("add calc not found") + } + + // Invoke with only 1 argument (missing y) + xVal := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 3}} + result, err := ctx.InvokeCalc(sym, []Value{xVal}, rootScope) + if err == nil { + t.Fatalf("expected an unbound parameter error, calc returned %+v", result) + } + if !errors.Is(err, ErrUnboundParameter) { + t.Errorf("expected ErrUnboundParameter, got: %v", err) + } +} + +// testCalcCallsAnUnimportedExtensionFunction: `exp(x)` with no import of the +// OpenSysML extension library is reported unresolved by name resolution, so the +// call fails with a typed error naming the declaration whose package an import +// would make visible, rather than being answered. +func testCalcCallsAnUnimportedExtensionFunction(t *testing.T) { + src := ` + package test { + calc grow { + in x: Real; + exp(x) + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "grow", ast.DefCalc) + if sym == nil { + t.Fatal("grow calc not found") + } + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: 1}} + result, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if err == nil { + t.Fatalf("expected an unresolved-reference error, calc returned %+v", result) + } + if !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("expected ErrUnresolvedReference, got: %v", err) + } + if want := ": unresolved reference: exp — did you mean OpenSysMLMathFunctions::exp?"; !strings.HasSuffix(err.Error(), want) { + t.Errorf("error %q does not end in %q", err, want) + } +} + +// testCalcCallsAnUnimportedLibraryFunction: `wheels->size()` in a model that +// imports no part of SequenceFunctions is reported unresolved by name +// resolution, so the call fails with a typed error carrying the validator's own +// hint — the library declarations of that name, whose package an import would +// make visible — rather than being answered by dispatch on the bare name. The +// same body evaluates once the import is written. +func testCalcCallsAnUnimportedLibraryFunction(t *testing.T) { + model := func(imports string) string { + return ` + package test { + ` + imports + ` + attribute wheels : Integer[*] = (1, 2, 3, 4); + calc count { + wheels->size() + } + } + ` + } + + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, model(""))) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "count", ast.DefCalc) + if sym == nil { + t.Fatal("count calc not found") + } + result, err := ctx.InvokeCalc(sym, nil, rootScope) + if err == nil { + t.Fatalf("expected an unresolved-reference error, calc returned %+v", result) + } + if !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("expected ErrUnresolvedReference, got: %v", err) + } + if want := ": unresolved reference: size — did you mean SequenceFunctions::size or CollectionFunctions::size?"; !strings.HasSuffix(err.Error(), want) { + t.Errorf("error %q does not end in %q", err, want) + } + + idx, _, ctx = buildRuntimeWithLibraries(t, "", parseAndBuild(t, model("private import SequenceFunctions::*;"))) + rootScope = idx.DocumentRoot("") + sym = findSymbolByName(rootScope, "count", ast.DefCalc) + if sym == nil { + t.Fatal("count calc not found") + } + result, err = ctx.InvokeCalc(sym, nil, rootScope) + if err != nil || result.Kind != ValConst || result.Const.Int != 4 { + t.Fatalf("wheels->size() under import SequenceFunctions::* = %+v, %v; want 4", result, err) + } +} + +// testCalcUnboundKeywordNamedParameter: a parameter named with a keyword is a +// parameter like any other, so leaving it unbound reports, never panics. +func testCalcUnboundKeywordNamedParameter(t *testing.T) { + src := ` + package test { + calc classify { + in 'type': Integer; + in 'state': Integer; + 'type' + 'state' + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "classify", ast.DefCalc) + if sym == nil { + t.Fatal("classify calc not found") + } + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 3}} + result, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if err == nil { + t.Fatalf("expected an unbound parameter error, calc returned %+v", result) + } + if !errors.Is(err, ErrUnboundParameter) { + t.Errorf("expected ErrUnboundParameter, got: %v", err) + } +} + +// testCalcTooManyArguments: more arguments than parameters has no binding, so it +// reports an arity error instead of dropping the extras. +func testCalcTooManyArguments(t *testing.T) { + src := ` + package test { + calc double { + in x: Integer; + x * 2 + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "double", ast.DefCalc) + if sym == nil { + t.Fatal("double calc not found") + } + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}} + _, err := ctx.InvokeCalc(sym, []Value{arg, arg}, rootScope) + if err == nil { + t.Fatal("expected an arity error, the calc accepted a surplus argument") + } + if !errors.Is(err, ErrCalcArity) { + t.Errorf("expected ErrCalcArity, got: %v", err) + } +} + +// testCalcUnknownNamedArgument: a named argument that matches no parameter is +// reported instead of silently leaving the parameter on its default. +func testCalcUnknownNamedArgument(t *testing.T) { + src := ` + package test { + calc scale { + in x: Integer = 1; + x * 2 + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "scale", ast.DefCalc) + if sym == nil { + t.Fatal("scale calc not found") + } + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 3}} + _, err := ctx.InvokeCalcNamed(sym, map[string]Value{"factor": arg}, rootScope) + if err == nil { + t.Fatal("expected an unknown parameter error, the invocation succeeded") + } + if !errors.Is(err, ErrUnknownParameter) { + t.Errorf("expected ErrUnknownParameter, got: %v", err) + } +} + +// testCalcParameterNamedTwice: an invocation naming one parameter twice is +// reported rather than binding the later value. +func testCalcParameterNamedTwice(t *testing.T) { + src := ` + package test { + calc def Scale { in x : Integer; in factor : Integer = 2; return : Integer = x * factor; } + calc twice { Scale(x = 1, x = 3, factor = 4) } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "twice", ast.DefCalc) + if sym == nil { + t.Fatal("twice calc not found") + } + _, err := ctx.InvokeCalc(sym, nil, rootScope) + if !errors.Is(err, ErrCalcArity) || !strings.Contains(err.Error(), `binds parameter "x" twice`) { + t.Errorf("expected ErrCalcArity naming x, got: %v", err) + } +} + +// testCalcWithoutResult: a calc body with no return expression has no value to +// produce, own or inherited. +func testCalcWithoutResult(t *testing.T) { + src := ` + package test { + calc empty { + in x: Integer; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "empty", ast.DefCalc) + if sym == nil { + t.Fatal("empty calc not found") + } + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}} + _, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if err == nil { + t.Fatal("expected a missing-result error, the calc returned a value") + } + if !errors.Is(err, ErrNoResultExpression) { + t.Errorf("expected ErrNoResultExpression, got: %v", err) + } +} + +// testCalcStatesSecondResult: a calc stating a body over an inherited result +// expression is refused, not computed from a body of the runtime's choosing. +func testCalcStatesSecondResult(t *testing.T) { + src := ` + package test { + calc def Plus { + in x: Integer; + x + 1 + } + calc def Twice :> Plus { + x + 2 + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "Twice", ast.DefCalc) + if sym == nil { + t.Fatal("Twice calc not found") + } + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}} + _, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if !errors.Is(err, ErrConflictingResultExpressions) { + t.Fatalf("expected ErrConflictingResultExpressions, got: %v", err) + } + for _, want := range []string{"test::Twice", "test::Plus"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name %s", err, want) + } + } +} + +// testCalcBodyStatesTwoResults: a calc body listing two bare expressions has +// two result expressions; neither it nor a calc inheriting them is computed +// from the first. +func testCalcBodyStatesTwoResults(t *testing.T) { + src := ` + package test { + calc def Twice { + in x: Integer; + x + 1 + x + 2 + } + calc def Inherited :> Twice; + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}} + for name, want := range map[string]string{ + "Twice": "calc test::Twice states 2 result expressions", + "Inherited": "from each of test::Twice, test::Twice", + } { + sym := findSymbolByName(rootScope, name, ast.DefCalc) + if sym == nil { + t.Fatalf("%s calc not found", name) + } + _, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if !errors.Is(err, ErrConflictingResultExpressions) { + t.Fatalf("%s: expected ErrConflictingResultExpressions, got: %v", name, err) + } + if !strings.Contains(err.Error(), want) { + t.Errorf("%s: error %q does not say %q", name, err, want) + } + } +} + +// testCalcSymbolIsNotACalc: invoking a non-calc symbol is rejected by kind +// rather than by whatever its body happens to contain. +func testCalcSymbolIsNotACalc(t *testing.T) { + src := ` + package test { + part def Engine { + attribute power : Integer; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "Engine", ast.DefPart) + if sym == nil { + t.Fatal("Engine part def not found") + } + + _, err := ctx.InvokeCalc(sym, nil, rootScope) + if err == nil { + t.Fatal("expected a not-a-calc error, the invocation succeeded") + } + if !errors.Is(err, ErrNotACalc) { + t.Errorf("expected ErrNotACalc, got: %v", err) + } +} + +// testCalcDirectRecursion: a calc that invokes itself unconditionally never +// terminates, so the run's calc depth budget must report it instead of the +// process exhausting its stack. +func testCalcDirectRecursion(t *testing.T) { + src := ` + package test { + calc countdown { + in n: Integer; + countdown(n - 1) + } + } + ` + assertCalcRecursionBounded(t, src, "countdown", ErrCalcRecursionLimit) +} + +// testCalcMutualRecursion: the budget is spent by nesting, so a cycle through +// another calc is reported the same way direct self-invocation is. +func testCalcMutualRecursion(t *testing.T) { + src := ` + package test { + calc ping { + in n: Integer; + pong(n) + } + + calc pong { + in n: Integer; + ping(n) + } + } + ` + assertCalcRecursionBounded(t, src, "ping", ErrCalcRecursionLimit) +} + +// testCalcDefaultRecursion: a default re-invoking its own calc nests through the +// binding rather than the body, and is bounded and collapsed the same way. +func testCalcDefaultRecursion(t *testing.T) { + src := ` + package test { + calc def f { + in x : Integer; + in y : Integer = f(x); + return : Integer = x; + } + } + ` + assertCalcRecursionBounded(t, src, "f", ErrCalcRecursionLimit) +} + +// testCalcLibraryDefaultFailureNamesOneFrame: a default failing once on a library +// specialization is reported under one calc name, as a calc with a body reports it. +func testCalcLibraryDefaultFailureNamesOneFrame(t *testing.T) { + src := ` + package test { + import ScalarValues::*; + import RealFunctions::*; + calc def again :> max { + in x :>> x; + in y :>> y = missing; + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "again", ast.DefCalc) + if sym == nil { + t.Fatal("calc again not found") + } + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 10}} + _, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if err == nil { + t.Fatal("expected the unresolved default to fail the invocation") + } + if got, prefix := err.Error(), `calc test::again: default for parameter "y": `; !strings.HasPrefix(got, prefix) || strings.Count(got, "calc test::again") != 1 { + t.Errorf("err = %q; want one frame %q", got, prefix) + } +} + +// testCalcLibraryDefaultRecursion: a library specialization whose default invokes +// itself nests through its own binding, so the depth budget reports it like any calc. +func testCalcLibraryDefaultRecursion(t *testing.T) { + src := ` + package test { + import ScalarValues::*; + import RealFunctions::*; + calc def again :> max { + in x :>> x; + in y :>> y = again(x); + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + assertCalcInvocationBounded(t, idx, ctx, "again", ErrCalcRecursionLimit) +} + +// testCalcRecursionSpendsStepBudget: the two bounds are independent, so a +// recursion whose evaluations run out first is reported by the step budget +// rather than running on until the depth bound. +func testCalcRecursionSpendsStepBudget(t *testing.T) { + src := ` + package test { + calc grow { + in n: Integer; + return : Integer = n + grow(n + 1); + } + } + ` + assertCalcRecursionBounded(t, src, "grow", ErrStepLimitExceeded, func(ctx *Context) { + // Room to recurse far deeper than the evaluations allow. + ctx.maxCalcDepth = MaxCalcDepthCeiling + ctx.maxSteps = 500 + }) +} + +// testCalcRecursionAtDepthCeiling: the highest depth budget a run may be given +// must still be reported rather than reached by exhausting the stack, which +// would be fatal. +func testCalcRecursionAtDepthCeiling(t *testing.T) { + src := ` + package test { + calc deep { + in n: Integer; + attribute acc : Integer = (n + 1) * (n + 2) - n * n; + return : Integer = acc + deep(n + 1); + } + } + ` + assertCalcRecursionBounded(t, src, "deep", ErrCalcRecursionLimit, func(ctx *Context) { + ctx.maxCalcDepth = MaxCalcDepthCeiling + }) +} + +// assertCalcRecursionBounded invokes calcName and requires the given budget +// error promptly: the invocation runs on its own goroutine so a hang fails the +// case instead of stalling the suite until the package timeout, and a panic in +// it fails the case rather than the package. +func assertCalcRecursionBounded(t *testing.T, src, calcName string, want error, budgets ...func(*Context)) { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + assertCalcInvocationBounded(t, idx, ctx, calcName, want, budgets...) +} + +func assertCalcInvocationBounded(t *testing.T, idx *symbols.Index, ctx *Context, calcName string, want error, budgets ...func(*Context)) { + t.Helper() + + // The default step budget, so a recursion bounded by depth reaches that bound + // rather than running out of evaluations first. + ctx.maxSteps = DefaultMaxSteps + for _, set := range budgets { + set(ctx) + } + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, calcName, ast.DefCalc) + if sym == nil { + t.Fatalf("calc %s not found", calcName) + } + + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("calc %s panicked: %v", calcName, r) + } + }() + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 10}} + _, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Fatalf("expected recursive calc %s to be bounded, it returned a value", calcName) + } + if !errors.Is(err, want) { + t.Errorf("expected %v, got: %v", want, err) + } + // One line per frame would make the message (and the memory building + // it) grow with the square of the depth. + if msg := err.Error(); len(msg) > 1024 { + t.Errorf("error for recursive calc %s is %d bytes; want frames collapsed: %.200s…", calcName, len(msg), msg) + } + case <-watchdog(30 * time.Second): + t.Fatalf("recursive calc %s did not terminate", calcName) + } +} + +// testConstraintMissingFeature: constraint references nonexistent feature +func testConstraintMissingFeature(t *testing.T) { + src := ` + package test { + constraint broken { + nonexistent > 0 // 'nonexistent' feature doesn't exist + } + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + + idx, model, ctx := buildRuntime(t, "", file) + + _ = model // silence unused + + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "broken", ast.DefConstraint) + if sym == nil { + t.Fatal("broken constraint not found") + } + + satisfied, err := ctx.EvaluateConstraint(sym, rootScope) + + if err != nil { + t.Logf("EvaluateConstraint returned error (expected): %v", err) + return + } + + if !satisfied { + t.Log("EvaluateConstraint returned false (missing feature treated as unsatisfied)") + return + } + + t.Log("EvaluateConstraint returned true (missing feature tolerated)") +} + +// testNestedConditionSubjectIsAmbiguous: two objects redefining the same nested +// feature differently make the subject of a check a question, reported as +// ErrAmbiguousSubject rather than answered from whichever object is found first. +func testNestedConditionSubjectIsAmbiguous(t *testing.T) { + src := ` + package test { + part def Leaf { + attribute value = 1.0; + constraint small { value < 10.0 } + } + part def Top { + part leaf : Leaf; + } + part slow : Top { + part :>> leaf { attribute :>> value = 2.0; } + } + part fast : Top { + part :>> leaf { attribute :>> value = 99.0; } + } + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + rootScope := idx.DocumentRoot("") + for _, name := range []string{"slow", "fast"} { + if _, err := ctx.Instantiate(memberPath(t, rootScope, "test", name)); err != nil { + t.Fatalf("instantiate %s: %v", name, err) + } + } + small := memberPath(t, rootScope, "test", "Leaf", "small") + satisfied, err := ctx.EvaluateConstraint(small, small.OwnerScope) + if !errors.Is(err, ErrAmbiguousSubject) { + t.Fatalf("satisfied = %t, err = %v, want ErrAmbiguousSubject", satisfied, err) + } + if satisfied { + t.Error("an ambiguous subject is no verdict") + } +} + +// testSatisfactionSubjectIsAmbiguous: a satisfaction assertion whose `by` object +// holds two objects of the requirement's owner has no one subject either, and +// reports it as ErrAmbiguousSubject rather than picking one. +func testSatisfactionSubjectIsAmbiguous(t *testing.T) { + src := ` + package test { + part def Leaf { + attribute value = 1.0; + requirement lim { require value < 10.0; } + } + part def Top { + part slow : Leaf { attribute :>> value = 2.0; } + part fast : Leaf { attribute :>> value = 99.0; } + } + part top : Top; + assert satisfy Leaf::lim by top; + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + rootScope := idx.DocumentRoot("") + pkg := memberPath(t, rootScope, "test") + assertions := ctx.SatisfyAssertionsIn(pkg.Scope) + if len(assertions) != 1 { + t.Fatalf("assertions = %d, want the one the package states", len(assertions)) + } + result, err := ctx.CheckSatisfactionOn(assertions[0], nil) + if !errors.Is(err, ErrAmbiguousSubject) { + t.Fatalf("holds = %t, err = %v, want ErrAmbiguousSubject", result.Holds, err) + } + if result.Holds { + t.Error("an ambiguous subject is no verdict") + } +} + +// testRecursiveCompositionSubjectSearch: searching for the object a check is +// about does not walk a design containing its own kind forever; it answers about +// the declaration, since no object of the checked type is there. +func testRecursiveCompositionSubjectSearch(t *testing.T) { + src := ` + package test { + part def Leaf { + attribute value = 1.0; + constraint small { value < 10.0 } + } + part def Node { + part next : Node; + } + part root : Node; + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + rootScope := idx.DocumentRoot("") + if _, err := ctx.Instantiate(memberPath(t, rootScope, "test", "root")); err != nil { + t.Fatalf("instantiate root: %v", err) + } + small := memberPath(t, rootScope, "test", "Leaf", "small") + done := make(chan struct{}) + var satisfied bool + var err error + go func() { + defer close(done) + satisfied, err = ctx.EvaluateConstraint(small, small.OwnerScope) + }() + select { + case <-done: + case <-watchdog(20 * time.Second): + t.Fatal("the subject search did not terminate on recursive composition") + } + if err != nil { + t.Fatalf("EvaluateConstraint: %v", err) + } + if !satisfied { + t.Error("satisfied = false, want the declaration's answer") + } + if len(ctx.instances) > 1000 { + t.Errorf("%d objects materialized: the search is not bounded", len(ctx.instances)) + } +} + +// testDuplicateObjectsOfOneDeclaration: materializing the same declaration twice +// is one object as far as a check is concerned, not an ambiguous subject. +func testDuplicateObjectsOfOneDeclaration(t *testing.T) { + src := ` + package test { + part def Leaf { + attribute value = 1.0; + constraint small { value < 10.0 } + } + part def Top { + part leaf : Leaf; + } + part o : Top { + part :>> leaf { attribute :>> value = 99.0; } + } + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + rootScope := idx.DocumentRoot("") + obj := memberPath(t, rootScope, "test", "o") + for range 2 { + if _, err := ctx.Instantiate(obj); err != nil { + t.Fatalf("instantiate o: %v", err) + } + } + small := memberPath(t, rootScope, "test", "Leaf", "small") + satisfied, err := ctx.EvaluateConstraint(small, small.OwnerScope) + if err != nil && !errors.Is(err, ErrViolated) { + t.Fatalf("EvaluateConstraint: %v", err) + } + if satisfied { + t.Error("satisfied = true, want the object's 99.0 to violate the constraint") + } +} + +// testDuplicateObjectsHoldingAPlainPart: a nested part typed by a definition +// rather than by a body of its own is reached through its holder, so what two +// materializations of that holder leave behind is no ambiguous subject. +func testDuplicateObjectsHoldingAPlainPart(t *testing.T) { + src := ` + package test { + part def Leaf { + attribute value = 99.0; + constraint small { value < 10.0 } + } + part def Top { + part leaf : Leaf; + } + part o : Top; + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + rootScope := idx.DocumentRoot("") + obj := memberPath(t, rootScope, "test", "o") + small := memberPath(t, rootScope, "test", "Leaf", "small") + for range 2 { + if _, err := ctx.Instantiate(obj); err != nil { + t.Fatalf("instantiate o: %v", err) + } + satisfied, err := ctx.EvaluateConstraint(small, small.OwnerScope) + if err != nil && !errors.Is(err, ErrViolated) { + t.Fatalf("EvaluateConstraint: %v", err) + } + if satisfied { + t.Error("satisfied = true, want the object's 99.0 to violate the constraint") + } + } +} + +// testNestedPartHeldWithAMultiplicity: the objects one feature value materializes for a +// multiplicity are occurrences of one declaration, so a check answers a verdict +// rather than calling its subject ambiguous. +func testNestedPartHeldWithAMultiplicity(t *testing.T) { + src := ` + package test { + part def Wheel { + attribute pressure = 99.0; + constraint inflated { pressure < 10.0 } + } + part def Car { + part wheels : Wheel[4]; + } + part car : Car; + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + rootScope := idx.DocumentRoot("") + if _, err := ctx.Instantiate(memberPath(t, rootScope, "test", "car")); err != nil { + t.Fatalf("instantiate car: %v", err) + } + inflated := memberPath(t, rootScope, "test", "Wheel", "inflated") + satisfied, err := ctx.EvaluateConstraint(inflated, inflated.OwnerScope) + if err != nil && !errors.Is(err, ErrViolated) { + t.Fatalf("EvaluateConstraint: %v", err) + } + if satisfied { + t.Error("satisfied = true, want the wheels' 99.0 to violate the constraint") + } +} + +// testPartNestedInsideARepeatedPart: the declaration a check names may sit +// deeper inside the part a multiplicity repeated, and the objects reached along +// one declaration path are still one subject rather than an ambiguity. +func testPartNestedInsideARepeatedPart(t *testing.T) { + src := ` + package test { + part def Bolt { + attribute torque = 99.0; + constraint tight { torque < 10.0 } + } + part def Wheel { + part bolt : Bolt; + } + part def Car { + part wheels : Wheel[4]; + } + part car : Car; + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + rootScope := idx.DocumentRoot("") + if _, err := ctx.Instantiate(memberPath(t, rootScope, "test", "car")); err != nil { + t.Fatalf("instantiate car: %v", err) + } + tight := memberPath(t, rootScope, "test", "Bolt", "tight") + satisfied, err := ctx.EvaluateConstraint(tight, tight.OwnerScope) + if err != nil && !errors.Is(err, ErrViolated) { + t.Fatalf("EvaluateConstraint: %v", err) + } + if satisfied { + t.Error("satisfied = true, want the bolts' 99.0 to violate the constraint") + } +} + +// testPartsSubsettingOneCollection: two declarations feeding one collection are +// two subjects, not repetitions of the collection, so the check reports the +// ambiguity rather than answering from whichever it reached first. +func testPartsSubsettingOneCollection(t *testing.T) { + src := ` + package test { + part def Component { + attribute v = 1.0; + constraint ok { v < 10.0 } + } + part def Assembly { + part subsystem : Component[*]; + part small : Component :> subsystem { + attribute :>> v = 5.0; + } + part large : Component :> subsystem { + attribute :>> v = 99.0; + } + } + part assembly : Assembly; + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + rootScope := idx.DocumentRoot("") + if _, err := ctx.Instantiate(memberPath(t, rootScope, "test", "assembly")); err != nil { + t.Fatalf("instantiate assembly: %v", err) + } + ok := memberPath(t, rootScope, "test", "Component", "ok") + satisfied, err := ctx.EvaluateConstraint(ok, ok.OwnerScope) + if !errors.Is(err, ErrAmbiguousSubject) { + t.Fatalf("satisfied = %t, err = %v, want ErrAmbiguousSubject", satisfied, err) + } + if satisfied { + t.Error("an ambiguous subject is no verdict") + } + // The two objects reached through one collection are told apart by the + // declaration each materializes, not by the feature holding both. + for _, want := range []string{"(small)", "(large)"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name a carrier %q", err, want) + } + } +} + +// testRequirementFeatureWithoutAValue: a condition naming a feature the +// requirement declares but nothing gives a value to reports ErrNoValue, naming +// the feature, rather than the unresolved-feature error of a name that is not +// declared at all. +func testRequirementFeatureWithoutAValue(t *testing.T) { + src := ` + package test { + requirement def TouchdownRequirement { + attribute actualVerticalSpeed; + attribute maxVerticalSpeed = 1.5; + require constraint { actualVerticalSpeed <= maxVerticalSpeed } + } + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "TouchdownRequirement", ast.DefRequirement) + if sym == nil { + t.Fatal("TouchdownRequirement not found") + } + + satisfied, err := ctx.EvaluateRequirement(sym, rootScope) + if err == nil { + t.Fatalf("expected an error, got satisfied = %v", satisfied) + } + if !errors.Is(err, ErrNoValue) { + t.Errorf("expected ErrNoValue, got: %v", err) + } + if errors.Is(err, ErrViolated) { + t.Error("a feature without a value is not a violation") + } + if !strings.Contains(err.Error(), "actualVerticalSpeed") { + t.Errorf("error does not name the feature: %v", err) + } +} + +// testRequirementFeaturesValuedFromEachOther: two features whose values name each +// other report a cycle promptly instead of recursing until the step budget runs out. +func testRequirementFeaturesValuedFromEachOther(t *testing.T) { + src := ` + package test { + requirement def R { + attribute a = b; + attribute b = a; + require constraint { a <= b } + } + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "R", ast.DefRequirement) + if sym == nil { + t.Fatal("R not found") + } + + satisfied, err := ctx.EvaluateRequirement(sym, rootScope) + if err == nil { + t.Fatalf("expected an error, got satisfied = %v", satisfied) + } + if !errors.Is(err, ErrCyclicFeatureValue) { + t.Errorf("expected ErrCyclicFeatureValue, got: %v", err) + } +} + +// testObjectFeatureWithoutAValue: an operation over a feature an object holds no value +// for reports ErrNoValue naming it; only the model-level read is undetermined. +func testObjectFeatureWithoutAValue(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Car { attribute mass : Real; } + part car : Car; + } + ` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntimeWithLibraries(t, "", file) + scope := oneSymbol(t, idx, "test").Scope + if val, err := evalIn(t, ctx, scope, "car.mass + 1.0"); err != nil || val.Undetermined() == nil { + t.Fatalf("car.mass + 1.0 at model level = %s, %v; want %s", FormatValue(val), err, UndeterminedText) + } + + inst, err := ctx.Instantiate(oneSymbol(t, idx, "test::car")) + if err != nil { + t.Fatalf("instantiate car: %v", err) + } + for _, expr := range []string{"mass + 1.0", "(mass > 1.0) and false", "car.mass + 1.0"} { + val, err := ctx.EvalWithScopeOn(parseExpr(t, expr), scope, inst) + if err == nil { + t.Errorf("%s on the object = %s, want an error", expr, FormatValue(val)) + continue + } + if !errors.Is(err, ErrNoValue) { + t.Errorf("%s on the object: expected ErrNoValue, got: %v", expr, err) + } + if !strings.Contains(err.Error(), "mass") { + t.Errorf("%s on the object: error does not name the feature: %v", expr, err) + } + } +} + +// testStepBudgetExceeded: evaluation exceeds maxSteps. Each Eval call spends one +// step, so an expression with more subexpressions than the budget must report +// ErrStepLimitExceeded rather than run to the end. The operands are a parameter +// rather than literals because a constant expression is folded in one step. +func testStepBudgetExceeded(t *testing.T) { + src := ` + package test { + calc deep { + in x : Integer; + x + x + x + x + x + x + x + x + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + ctx.maxSteps = 3 + ctx.run.steps = 0 + + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "deep", ast.DefCalc) + if sym == nil { + t.Fatal("calc deep not found") + } + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}} + _, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if err == nil { + t.Fatal("expected the step budget to be exceeded, the calc completed") + } + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected ErrStepLimitExceeded, got: %v", err) + } +} + +// testEvalOnAnInstanceSpendsTheStepBudget: an expression evaluated against an +// instance is one run, so reading a feature value inside it does not start a run of its +// own and reset the counter; an expression longer than the budget is refused. +func testEvalOnAnInstanceSpendsTheStepBudget(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + part def Car { attribute m = 5.0; } + } + `)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "Car", ast.DefPart) + if sym == nil { + t.Fatal("part def Car not found") + } + inst, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("instantiating Car: %v", err) + } + + // Nested to the right, so the feature value read - which brackets a run of its own when + // the evaluation is not already one - is reached on the second step. + expr := "m" + for i := 0; i < 60; i++ { + expr = "m + (" + expr + ")" + } + node := parser.New(source.New("", []byte(expr))).ParseExpression() + if node == nil { + t.Fatal("the expression did not parse") + } + + ctx.maxSteps = 6 + got, err := ctx.EvalWithScopeOn(node, sym.Scope, inst) + if err == nil { + t.Fatalf("expected the step budget to bound the evaluation, got %v", got) + } + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected ErrStepLimitExceeded, got: %v", err) + } + + // The budget bounds one run, not the session: a short expression is answered + // however many ran before it. + ctx.maxSteps = 20 + short := parser.New(source.New("", []byte("m + m"))).ParseExpression() + for i := 0; i < 5; i++ { + if _, err := ctx.EvalWithScopeOn(short, sym.Scope, inst); err != nil { + t.Fatalf("evaluation %d of m + m under a fresh run: %v", i+1, err) + } + } +} + +// testNonTerminatingLoopExhaustsStepBudget: a loop whose condition never fails +// spends a step per iteration, so it ends the execution with +// ErrStepLimitExceeded instead of hanging whoever drove it (a REPL or the LSP). +func testNonTerminatingLoopExhaustsStepBudget(t *testing.T) { + src := ` + package test { + action spinner { + attribute total : Integer = 0; + first start; + action spin { + while total >= 0 { + assign total := total + 1; + } + } + done; + succession first start then spin; + succession first spin then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + ctx.maxSteps = 20 + ctx.run.steps = 0 + + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "spinner", ast.DefAction) + if sym == nil { + t.Fatal("action spinner not found") + } + + _, err := ctx.ExecuteAction(sym) + if err == nil { + t.Fatal("expected the step budget to be exceeded, the action completed") + } + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected ErrStepLimitExceeded, got: %v", err) + } +} + +// testLoopBodyDeclarationDoesNotLeak: a loop body and an `if` branch body are +// namespaces of their own, so a name one of them declares is not a member of the +// action and does not appear among its results. +func testLoopBodyDeclarationDoesNotLeak(t *testing.T) { + src := ` + package test { + action counter { + attribute total : Integer = 0; + first start; + action accumulate { + while total < 3 { + attribute bump : Integer = 1; + assign total := total + bump; + if total == 2 { + attribute marker : Integer = 9; + assign total := total + marker; + } + } + } + done; + succession first start then accumulate; + succession first accumulate then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "counter", ast.DefAction) + if sym == nil { + t.Fatal("action counter not found") + } + + outputs, err := ctx.ExecuteAction(sym) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + // 1, then 2 which the conditional lifts to 11, which ends the loop. + total, ok := outputs["total"] + if !ok { + t.Fatal("total missing from the action's results") + } + if total.Const.Int != 11 { + t.Errorf("total = %v, want 11", FormatTraceValue(total)) + } + for _, local := range []string{"bump", "marker"} { + if _, ok := outputs[local]; ok { + t.Errorf("body-local %s leaked into the action's results: %v", local, outputs) + } + } +} + +// testLoopBodyOfUnexecutableStatement: a body member the lowering layer cannot +// turn into a statement is reported when it is reached, rather than skipped — +// silently dropping it would give a wrong answer with no diagnostic. +func testLoopBodyOfUnexecutableStatement(t *testing.T) { + src := ` + package test { + action counter { + attribute total : Integer = 0; + first start; + action accumulate { + while total < 3 { + part inner; + assign total := total + 1; + } + } + done; + succession first start then accumulate; + succession first accumulate then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "counter", ast.DefAction) + if sym == nil { + t.Fatal("action counter not found") + } + + _, err := ctx.ExecuteAction(sym) + if err == nil { + t.Fatal("expected an unexecutable loop body member to be reported") + } + if !strings.Contains(err.Error(), "not executable") { + t.Errorf("error does not name the unexecutable member: %v", err) + } +} + +// testBlockFlowOfUnexecutableMember: a member outside the semantics a block's own +// flow gives its members is still reported when reached, even in a block that +// does state a flow because a nested action is declared beside it. +func testBlockFlowOfUnexecutableMember(t *testing.T) { + src := ` + package test { + action counter { + attribute total : Integer = 0; + first start; + action accumulate { + while total < 3 { + action bump { + assign total := total + 1; + } + part inner; + } + } + done; + succession first start then accumulate; + succession first accumulate then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "counter", ast.DefAction) + if sym == nil { + t.Fatal("action counter not found") + } + + _, err := ctx.ExecuteAction(sym) + if err == nil { + t.Fatal("expected the unexecutable member of the block's flow to be reported") + } + if !strings.Contains(err.Error(), "not executable") { + t.Errorf("error does not name the unexecutable member: %v", err) + } +} + +// testNonTerminatingLoopPerformingAnAction: a loop whose body performs an action +// as a node of the block's own flow still spends a step per iteration, so it +// ends with ErrStepLimitExceeded rather than performing forever. +func testNonTerminatingLoopPerformingAnAction(t *testing.T) { + src := ` + package test { + action spinner { + attribute total : Integer = 0; + first start; + action spin { + while total >= 0 { + perform bump; + assign total := total + 1; + } + } + done; + succession first start then spin; + succession first spin then done; + } + + action bump { + out spun : Integer; + first begin; + action run { + assign spun := 1; + } + done; + succession first begin then run; + succession first run then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + ctx.maxSteps = 40 + ctx.run.steps = 0 + + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "spinner", ast.DefAction) + if sym == nil { + t.Fatal("action spinner not found") + } + + _, err := ctx.ExecuteAction(sym) + if err == nil { + t.Fatal("expected the step budget to be exceeded, the action completed") + } + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected ErrStepLimitExceeded, got: %v", err) + } +} + +// testForOverAValueNoExpressionMakesIterable: a value that states a computation +// rather than a collection is no collection in any order, so a `for` over it +// fails with a typed error naming it. +func testForOverAValueNoExpressionMakesIterable(t *testing.T) { + for _, value := range []Value{{Kind: ValExpr}, {Kind: ValInvalid}} { + elements, err := forElements(value) + if err == nil { + t.Errorf("forElements(%s) = %v, want a typed error", describeValue(value), elements) + continue + } + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("forElements(%s) failed with %v, want ErrTypeMismatch", describeValue(value), err) + } + if !strings.Contains(err.Error(), describeValue(value)) { + t.Errorf("error does not name the value: %v", err) + } + } +} + +// testForOverAScalar: a `for` whose input is a scalar fails with a typed error +// rather than iterating once over the coercion elementsOf would make of it. +func testForOverAScalar(t *testing.T) { + src := ` + package test { + action counter { + attribute single : Integer = 7; + attribute visited : Integer = 0; + first start; + action iterate { + for s in single { + assign visited := visited + 1; + } + } + done; + succession first start then iterate; + succession first iterate then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "counter", ast.DefAction) + if sym == nil { + t.Fatal("action counter not found") + } + + result, err := ctx.ExecuteAction(sym) + if err == nil { + t.Fatalf("the action completed with %v, want a typed error", result) + } + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("execution failed with %v, want ErrTypeMismatch", err) + } + if !strings.Contains(err.Error(), "an Integer is not one") { + t.Errorf("error does not name the value it was given: %v", err) + } +} + +// testStatementDirectlyInAnActionBody: a statement written among the action's +// own members has no name a succession can reach, so it is reported rather than +// ignored. +func testStatementDirectlyInAnActionBody(t *testing.T) { + cases := map[string]string{ + "while": "while total < 5 { assign total := total + 1; }", + "if": "if total < 5 { assign total := total + 1; }", + "assignment": "assign total := total + 1;", + } + + for name, stmt := range cases { + t.Run(name, func(t *testing.T) { + src := ` + package test { + action counter { + attribute total : Integer = 0; + first start; + ` + stmt + ` + done; + succession first start then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "counter", ast.DefAction) + if sym == nil { + t.Fatal("action counter not found") + } + + _, err := ctx.ExecuteAction(sym) + if err == nil { + t.Fatalf("expected a top-level %s to be reported", name) + } + if !strings.Contains(err.Error(), "no position in the token flow") { + t.Errorf("error does not explain why the statement cannot run: %v", err) + } + }) + } +} + +// testFlowEndNamingNoNode: a flow moves a value from one action node's output +// to another's input, so an end naming something that is not a node of the +// action is reported rather than dropped, which would leave the flow declared +// and carrying nothing. +func testFlowEndNamingNoNode(t *testing.T) { + cases := map[string]string{ + "source": "flow bad from missing.engineTorque to amplify.torqueIn;", + "target": "flow bad from generate.engineTorque to missing.torqueIn;", + } + + for name, flow := range cases { + t.Run(name, func(t *testing.T) { + src := ` + package test { + action driveTrain { + first start; + action generate { out engineTorque : Integer; assign engineTorque := 1; } + action amplify { in torqueIn : Integer; } + done; + succession first start then generate; + succession first generate then amplify; + succession first amplify then done; + ` + flow + ` + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "driveTrain", ast.DefAction) + if sym == nil { + t.Fatal("action driveTrain not found") + } + + _, err := ctx.ExecuteAction(sym) + if err == nil { + t.Fatalf("expected the flow's %s end to be reported", name) + } + if !strings.Contains(err.Error(), "flow bad") || + !strings.Contains(err.Error(), name) { + t.Errorf("error does not name the flow and the end at fault: %v", err) + } + }) + } +} + +// testFlowNamingNoPin: a flow carries the value of a feature, so a flow whose +// ends name nodes alone and which declares no payload identifies nothing to +// move, and is reported when the graph is built rather than mid-run. +func testFlowNamingNoPin(t *testing.T) { + _, err := executeActionSource(t, "driveTrain", `package test { + action driveTrain { + first start; + action generate { out engineTorque : Integer; assign engineTorque := 1; } + action amplify { in engineTorque : Integer; } + done; + succession first start then generate; + succession first generate then amplify; + succession first amplify then done; + flow generateToAmplify from generate to amplify; + } + }`) + if err == nil { + t.Fatal("expected the flow naming no feature to be reported") + } + if !strings.Contains(err.Error(), "generateToAmplify") || + !strings.Contains(err.Error(), "names no feature to carry") { + t.Errorf("error does not say the flow names no feature: %v", err) + } +} + +// testAcceptPayloadWithoutAValue: a message carrying no value and naming no +// signal definition gives the accept's payload name nothing to bind, which is +// reported rather than bound to nothing. +func testAcceptPayloadWithoutAValue(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package P { + item def Ping; + action pipeline { + first start; + action reader accept p : Ping; + done; + succession first start then reader; + succession first reader then done; + } + }`)) + exec, err := ctx.CreateActionExecutor(oneSymbol(t, idx, "P::pipeline")) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + ctx.PostMessage(Message{SignalType: "Ping", Target: "reader", Payload: map[string]Value{}}) + err = exec.RunToCompletion() + if err == nil { + t.Fatal("expected the payload-less message to be reported") + } + if !errors.Is(err, ErrNoValue) { + t.Errorf("expected ErrNoValue, got: %v", err) + } + if !strings.Contains(err.Error(), "Ping") { + t.Errorf("error does not name the accepted signal: %v", err) + } +} + +// testAcceptPayloadReadBeforeItIsBound: the payload is a declaration of the body +// wherever the body resolves, so a node running before the accept binds it +// resolves the name and finds no value — reported as a feature without a value, +// not read as an empty value and not as a name that fails to resolve. +func testAcceptPayloadReadBeforeItIsBound(t *testing.T) { + _, err := executeActionSource(t, "pipeline", `package P { + action pipeline { + attribute seen : Integer = 0; + first start; + action reader { assign seen := msg; } + action waiter accept msg : Integer; + done; + succession first start then reader; + succession first reader then waiter; + succession first waiter then done; + } + }`) + if !errors.Is(err, ErrNoValue) { + t.Fatalf("err = %v; want ErrNoValue", err) + } + if errors.Is(err, ErrUnresolvedReference) { + t.Errorf("a declared payload was reported as unresolved: %v", err) + } + if !strings.Contains(err.Error(), "msg") { + t.Errorf("error does not name the payload: %v", err) + } +} + +// testFlowFromANodeThatProducedNothing: a flow out of a node that left its +// source pin empty carries nothing, which is reported rather than silently +// leaving the target pin unwritten. +func testFlowFromANodeThatProducedNothing(t *testing.T) { + src := ` + package test { + action driveTrain { + first start; + action generate { out engineTorque : Integer; } + action amplify { in torqueIn : Integer; } + done; + succession first start then generate; + succession first generate then amplify; + succession first amplify then done; + flow generateToAmplify from generate.engineTorque to amplify.torqueIn; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "driveTrain", ast.DefAction) + if sym == nil { + t.Fatal("action driveTrain not found") + } + + _, err := ctx.ExecuteAction(sym) + if err == nil { + t.Fatal("expected the empty source pin to be reported") + } + if !strings.Contains(err.Error(), "generateToAmplify") || + !strings.Contains(err.Error(), "engineTorque") { + t.Errorf("error does not name the flow and the pin that stayed empty: %v", err) + } +} + +// testActionAcceptTimeWaits: an action's `accept after`/`accept at` waits on the +// context's clock; a past instant fires at once, bad arguments are typed errors. +func testActionAcceptTimeWaits(t *testing.T) { + run := func(t *testing.T, trigger string) (map[string]Value, *Context, error) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + private import SI::*; + private import ISQ::*; + private import Time::*; + action maintain { + attribute maintenanceTime : TimeInstantValue = 3 [s]; + attribute past : TimeInstantValue = 0 [s]; + attribute wrongWay : DurationValue = -2 [s]; + attribute load : MassValue = 5 [kg]; + attribute done : Integer = 0; + first start; + action waitForIt `+trigger+`; + action work { assign done := 1; } + done; + succession first start then waitForIt; + succession first waitForIt then work; + succession first work then done; + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "maintain", ast.DefAction) + if sym == nil { + t.Fatal("action maintain not found") + } + results, err := ctx.ExecuteAction(sym) + return results, ctx, err + } + fires := func(t *testing.T, trigger string, wantNow float64) { + results, ctx, err := run(t, trigger) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if got := results["done"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("done = %v; want 1: the accept fired", got) + } + if got := ctx.Clock().Now(); got != wantNow { + t.Errorf("clock = %v after the run; want %v", got, wantNow) + } + if waits := ctx.Clock().Waits(); len(waits) != 0 { + t.Errorf("%d wait(s) left on the clock after the run; want none", len(waits)) + } + } + t.Run("after advances the clock", func(t *testing.T) { fires(t, "accept after 5 [s]", 5) }) + t.Run("at advances the clock", func(t *testing.T) { fires(t, "accept at maintenanceTime", 3) }) + t.Run("at an instant already past fires at once", func(t *testing.T) { fires(t, "accept at past", 0) }) + t.Run("negative after", func(t *testing.T) { + _, _, err := run(t, "accept after wrongWay") + if !errors.Is(err, ErrNegativeDuration) { + t.Fatalf("err = %v; want ErrNegativeDuration", err) + } + }) + t.Run("non-time dimension", func(t *testing.T) { + _, _, err := run(t, "accept after load") + if !errors.Is(err, ErrTimeTriggerType) { + t.Fatalf("err = %v; want ErrTimeTriggerType", err) + } + if !strings.Contains(err.Error(), "`after load` must be a ISQBase::DurationValue, found MassValue") { + t.Errorf("err = %v; want it to name the type found", err) + } + }) +} + +// testClockAdvance: advancing the context's clock is bounded and total — zero, +// nothing waiting, a wait beyond the advance, a negative advance, a budget stop. +func testClockAdvance(t *testing.T) { + newRun := func(t *testing.T) (*Context, *ActionExecutor, *symbols.Scope) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + private import SI::*; + private import ScalarValues::*; + action patient { + attribute done : Integer = 0; + first start; + then action wait accept after 8 [s]; + then action work assign done := 1; + then done; + } + action tardy { + attribute two : Time::TimeInstantValue = 2 [s]; + attribute done : Integer = 0; + first start; + then action wait accept at two; + then action work assign done := 1; + then done; + } + state metronome { + attribute beats : Integer = 0; + entry; then ticking; + state ticking; + transition ticking then ticking accept after 1 [s] do assign beats := beats + 1; + } + } + `)) + root := idx.DocumentRoot("") + action := findSymbolByName(root, "patient", ast.DefAction) + machine := findSymbolByName(root, "metronome", ast.DefState) + if action == nil || machine == nil { + t.Fatal("behaviors not found") + } + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.Step(); err != nil { + t.Fatalf("Step: %v", err) + } + return ctx, exec, root + } + t.Run("zero", func(t *testing.T) { + ctx, exec, _ := newRun(t) + report, err := ctx.Advance(0) + if err != nil { + t.Fatalf("Advance(0): %v", err) + } + if report.From != 0 || report.To != 0 || report.Steps != 0 || report.Events != 0 { + t.Errorf("report = %+v; want nothing moved", report) + } + if got := len(ctx.Clock().Waits()); got != 1 { + t.Errorf("%d wait(s) on the clock; want the action's one, still queued", got) + } + if exec.State() == StateCompleted { + t.Error("the action completed without the clock moving") + } + }) + t.Run("nothing waiting", func(t *testing.T) { + _, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, `package test { part def P; }`)) + report, err := ctx.Advance(12.5) + if err != nil { + t.Fatalf("Advance: %v", err) + } + if report.To != 12.5 || ctx.Clock().Now() != 12.5 { + t.Errorf("clock at %v, report %+v; want 12.5 with nothing run", ctx.Clock().Now(), report) + } + }) + t.Run("wait beyond the advance stays queued", func(t *testing.T) { + ctx, exec, _ := newRun(t) + if _, err := ctx.Advance(5); err != nil { + t.Fatalf("Advance(5): %v", err) + } + if got := ctx.Clock().Now(); got != 5 { + t.Errorf("clock = %v; want 5", got) + } + waits := ctx.Clock().Waits() + if len(waits) != 1 || waits[0].Due != 8 { + t.Fatalf("waits = %+v; want the action's wait still due at 8", waits) + } + if err := exec.Step(); !errors.Is(err, ErrNothingDue) { + t.Errorf("Step at t=5 = %v; want ErrNothingDue, the token waits on the clock", err) + } + if _, err := ctx.Advance(3); err != nil { + t.Fatalf("Advance(3): %v", err) + } + if got := exec.Results()["done"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("done = %v after the clock reached 8; want 1", got) + } + if got := len(ctx.Clock().Waits()); got != 0 { + t.Errorf("%d wait(s) left after the action completed; want none", got) + } + }) + t.Run("at an instant the clock has passed fires at once", func(t *testing.T) { + ctx, _, root := newRun(t) + if _, err := ctx.Advance(4); err != nil { + t.Fatalf("Advance(4): %v", err) + } + tardy := findSymbolByName(root, "tardy", ast.DefAction) + if tardy == nil { + t.Fatal("action tardy not found") + } + results, err := ctx.ExecuteAction(tardy) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if got := results["done"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("done = %v; want 1: an instant already reached is not waited for", got) + } + if got := ctx.Clock().Now(); got != 4 { + t.Errorf("clock = %v; want 4, never moved back to 2", got) + } + }) + t.Run("negative", func(t *testing.T) { + ctx, _, _ := newRun(t) + _, err := ctx.Advance(-1) + if !errors.Is(err, ErrNegativeDuration) { + t.Fatalf("Advance(-1) = %v; want ErrNegativeDuration", err) + } + if got := ctx.Clock().Now(); got != 0 { + t.Errorf("clock moved to %v on a refused advance", got) + } + }) + t.Run("infinite", func(t *testing.T) { + ctx, exec, _ := newRun(t) + for _, duration := range []float64{math.Inf(1), math.Inf(-1), math.NaN()} { + _, err := ctx.Advance(duration) + if !errors.Is(err, ErrNegativeDuration) { + t.Errorf("Advance(%v) = %v; want ErrNegativeDuration", duration, err) + } + } + if got := ctx.Clock().Now(); got != 0 { + t.Errorf("clock moved to %v on a refused advance", got) + } + if exec.State() == StateCompleted { + t.Error("the action completed without the clock moving") + } + }) + t.Run("past the last instant the clock can hold", func(t *testing.T) { + ctx, exec, _ := newRun(t) + ctx.clock.now = math.MaxFloat64 + _, err := ctx.Advance(math.MaxFloat64) + if !errors.Is(err, ErrNegativeDuration) { + t.Fatalf("Advance(MaxFloat64) from MaxFloat64 = %v; want ErrNegativeDuration", err) + } + if got := ctx.Clock().Now(); got != math.MaxFloat64 { + t.Errorf("clock = %v on a refused advance; want it left at MaxFloat64", got) + } + if exec.State() == StateCompleted { + t.Error("the action completed on a refused advance") + } + }) + t.Run("a wait past the last instant the clock can hold", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + private import SI::*; + action eon { + first start; + then action wait accept after 1.0e308 [s]; + then done; + } + } + `)) + action := findSymbolByName(idx.DocumentRoot(""), "eon", ast.DefAction) + if action == nil { + t.Fatal("action eon not found") + } + ctx.clock.now = math.MaxFloat64 + _, err := ctx.ExecuteAction(action) + if !errors.Is(err, ErrNegativeDuration) { + t.Fatalf("ExecuteAction = %v; want ErrNegativeDuration for a wait the clock cannot reach", err) + } + if got := ctx.Clock().Now(); got != math.MaxFloat64 { + t.Errorf("clock = %v; want it left where it was", got) + } + if got := len(ctx.Clock().Waits()); got != 0 { + t.Errorf("%d wait(s) left on the clock by the refused accept; want none", got) + } + }) + t.Run("a nested flow's wait keeps to the advance", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, nestedWaitModel)) + action := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if action == nil { + t.Fatal("action outer not found") + } + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if got := ctx.Clock().Now(); got != 0 { + t.Fatalf("clock = %v after a run at the current instant; want 0", got) + } + if waits := ctx.Clock().Waits(); len(waits) != 1 || waits[0].Due != 5 { + t.Fatalf("waits = %+v; want the nested flow's wait due at 5", waits) + } + if err := exec.Step(); !errors.Is(err, ErrNothingDue) { + t.Errorf("Step = %v; want ErrNothingDue, the nested flow waits on the clock", err) + } + report, err := ctx.Advance(2) + if err != nil { + t.Fatalf("Advance(2): %v", err) + } + if got := ctx.Clock().Now(); got != 2 || report.To != 2 { + t.Errorf("clock = %v, report %+v; want the advance to stop at 2, short of the nested wait", got, report) + } + if got := exec.Results()["n"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Errorf("n = %v at t=2; want 0, the nested flow has not gone on", got) + } + if exec.State() != StateWaiting { + t.Errorf("state = %v at t=2; want Waiting", exec.State()) + } + if _, err := ctx.Advance(3); err != nil { + t.Fatalf("Advance(3): %v", err) + } + if got := exec.Results()["n"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("n = %v once the clock reached 5; want 1", got) + } + if exec.State() != StateCompleted || ctx.Clock().Now() != 5 { + t.Errorf("state = %v, clock = %v; want Completed at 5", exec.State(), ctx.Clock().Now()) + } + }) + t.Run("a nested flow's wait is advanced to by a run of its own", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, nestedWaitModel)) + action := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + results, err := ctx.ExecuteAction(action) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if got := results["n"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("n = %v; want 1", got) + } + if got := ctx.Clock().Now(); got != 5 { + t.Errorf("clock = %v; want 5, where the nested wait came due", got) + } + }) + t.Run("a performed action's wait keeps to the advance", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, performedWaitModel)) + action := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if action == nil { + t.Fatal("action outer not found") + } + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if _, err := ctx.Advance(2); err != nil { + t.Fatalf("Advance(2): %v", err) + } + if got := ctx.Clock().Now(); got != 2 { + t.Errorf("clock = %v; want the advance to stop at 2, short of the performed action's wait", got) + } + if got := exec.Results()["n"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Errorf("n = %v at t=2; want 0, the performed action has not gone on", got) + } + if waits := ctx.Clock().Waits(); len(waits) != 1 || waits[0].Due != 6 { + t.Errorf("waits = %+v; want the performed action's wait due at 6", waits) + } + if next, ok := exec.NextWait(); !ok || next != 6 { + t.Errorf("NextWait = %v, %v; want the performed action's wait at 6", next, ok) + } + if waits := exec.TimeWaits(); len(waits) != 1 || !strings.Contains(waits[0], "t=6.0") { + t.Errorf("TimeWaits = %v; want the performed action's wait", waits) + } + if err := exec.Step(); !errors.Is(err, ErrNothingDue) || !strings.Contains(err.Error(), "t=6.0") { + t.Errorf("Step = %v; want ErrNothingDue naming the performed action's wait", err) + } + if _, err := ctx.Advance(4); err != nil { + t.Fatalf("Advance(4): %v", err) + } + if got := exec.Results()["n"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("n = %v once the clock reached 6; want 1", got) + } + if exec.State() != StateCompleted || ctx.Clock().Now() != 6 { + t.Errorf("state = %v, clock = %v; want Completed at 6", exec.State(), ctx.Clock().Now()) + } + if got := len(ctx.Clock().Waits()); got != 0 { + t.Errorf("%d wait(s) left after the action completed; want none", got) + } + }) + t.Run("a performed action's wait is advanced to by a run of its own", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, performedWaitModel)) + action := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + results, err := ctx.ExecuteAction(action) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if got := results["n"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("n = %v; want 1", got) + } + if got := ctx.Clock().Now(); got != 6 { + t.Errorf("clock = %v; want 6, where the performed action's wait came due", got) + } + }) + t.Run("a token held at a join beside a wait is not work due", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + private import SI::*; + private import ScalarValues::*; + action outer { + attribute n : Integer = 0; + first start; + then fork split; + then quick; + then nap; + action quick assign n := n + 1; + then meet; + action nap accept after 5 [s]; + then meet; + join meet; + then done; + } + } + `)) + action := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if action == nil { + t.Fatal("action outer not found") + } + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + report, err := ctx.Advance(2) + if err != nil { + t.Fatalf("Advance(2): %v", err) + } + if report.To != 2 || len(report.Notes) != 0 { + t.Errorf("report = %+v; want the clock at 2 and no choice drawn for one action", report) + } + if exec.State() != StateWaiting { + t.Errorf("state = %v at t=2; want Waiting", exec.State()) + } + if _, err := ctx.Advance(3); err != nil { + t.Fatalf("Advance(3): %v", err) + } + if exec.State() != StateCompleted || ctx.Clock().Now() != 5 { + t.Errorf("state = %v, clock = %v; want Completed at 5", exec.State(), ctx.Clock().Now()) + } + if got := exec.Results()["n"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("n = %v; want 1", got) + } + }) + t.Run("a message for another flow does not wake a nested wait", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + private import SI::*; + private import ScalarValues::*; + action outer { + attribute n : Integer = 0; + attribute got : Integer = 0; + first start; + then fork split; + then reader; + then body; + action reader accept m : Integer; + then action record assign got := m; + then meet; + action body { + if true { + action inner { + first start; + then action nap accept after 5 [s]; + then action mark assign n := 1; + then done; + } + } + } + then meet; + join meet; + then done; + } + } + `)) + action := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if action == nil { + t.Fatal("action outer not found") + } + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + var nested *actionFrame + for _, token := range exec.tokens { + if token.Wait != nil && token.Wait.Timed { + nested = token.frame + } + } + if nested == nil { + t.Fatalf("tokens = %+v; want one parked on the clock in the nested flow", exec.tokens) + } + seven := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 7}} + ctx.PostMessage(Message{SignalType: "Integer", Target: "reader", Value: &seven}) + if !exec.HasPendingSignal() { + t.Error("HasPendingSignal = false; want the reader's message pending for the action") + } + if exec.hasPendingSignal(nested) || exec.dueNow(nested) { + t.Error("the nested flow counts the reader's message as its own") + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence after the message: %v", err) + } + if got := exec.Results()["got"]; got.Kind != ValConst || got.Const.Int != 7 { + t.Errorf("got = %v; want 7, the reader took its message", got) + } + if got := exec.Results()["n"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Errorf("n = %v at t=0; want 0, the nested flow still waits on the clock", got) + } + if got := ctx.Clock().Now(); got != 0 { + t.Errorf("clock = %v after a run at the current instant; want 0", got) + } + if _, err := ctx.Advance(5); err != nil { + t.Fatalf("Advance(5): %v", err) + } + if got := exec.Results()["n"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("n = %v once the clock reached 5; want 1", got) + } + if exec.State() != StateCompleted { + t.Errorf("state = %v; want Completed", exec.State()) + } + }) + t.Run("event budget bounds a machine that never settles", func(t *testing.T) { + ctx, _, root := newRun(t) + budgets := ctx.Budgets() + budgets.MaxStateEvents = 5 + if err := ctx.SetBudgets(budgets); err != nil { + t.Fatal(err) + } + machine := findSymbolByName(root, "metronome", ast.DefState) + if _, err := ctx.CreateStateExecutor(machine); err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + _, err := ctx.Advance(1000) + if !errors.Is(err, ErrStateEventLimitExceeded) { + t.Fatalf("Advance = %v; want ErrStateEventLimitExceeded", err) + } + if !strings.Contains(err.Error(), MaxStateEventsEnvVar) { + t.Errorf("err = %v; want it to say how to raise the budget", err) + } + if got := ctx.Clock().Now(); got > 6 { + t.Errorf("clock = %v; want it stopped where the budget ran out", got) + } + }) + t.Run("step budget spans every instant of one advance", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + private import SI::*; + private import ScalarValues::*; + action ticker { + attribute ticks : Integer = 0; + first start; + then action tick assign ticks := ticks + 1; + then action rest accept after 1 [s]; + then tick; + } + } + `)) + budgets := ctx.Budgets() + budgets.MaxActionSteps = 40 + if err := ctx.SetBudgets(budgets); err != nil { + t.Fatal(err) + } + action := findSymbolByName(idx.DocumentRoot(""), "ticker", ast.DefAction) + if action == nil { + t.Fatal("action ticker not found") + } + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.Step(); err != nil { + t.Fatalf("Step: %v", err) + } + report, err := ctx.Advance(1000) + if !errors.Is(err, ErrActionStepLimitExceeded) { + t.Fatalf("Advance = %v (report %+v); want ErrActionStepLimitExceeded once the steps of every wake add up", err, report) + } + if !strings.Contains(err.Error(), MaxActionStepsEnvVar) { + t.Errorf("err = %v; want it to say how to raise the budget", err) + } + if report.Steps > budgets.MaxActionSteps { + t.Errorf("report.Steps = %d; want at most the budget of %d", report.Steps, budgets.MaxActionSteps) + } + if got := ctx.Clock().Now(); got >= 1000 { + t.Errorf("clock = %v; want it stopped where the budget ran out", got) + } + if got := exec.Results()["ticks"]; got.Kind != ValConst || got.Const.Int >= budgets.MaxActionSteps { + t.Errorf("ticks = %v; want fewer than the %d steps of the budget", got, budgets.MaxActionSteps) + } + }) + t.Run("event budget of one lets a lone action wake", func(t *testing.T) { + ctx, exec, _ := newRun(t) + budgets := ctx.Budgets() + budgets.MaxStateEvents = 1 + if err := ctx.SetBudgets(budgets); err != nil { + t.Fatal(err) + } + report, err := ctx.Advance(10) + if err != nil { + t.Fatalf("Advance = %v (report %+v); want the wake of one action, which dispatches no state event, within the budget", err, report) + } + if report.Events != 0 { + t.Errorf("report.Events = %d; want none, an action step is no state event", report.Events) + } + if exec.State() != StateCompleted { + t.Errorf("state = %v; want Completed", exec.State()) + } + if got := exec.Results()["done"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("done = %v; want 1", got) + } + if got := ctx.Clock().Now(); got != 10 { + t.Errorf("clock = %v; want 10", got) + } + }) + t.Run("accept when beside a timer fires when another executor changes the value", func(t *testing.T) { + const model = ` + package test { + private import SI::*; + private import ScalarValues::*; + part def Worker { + attribute stage : Integer = 0; + exhibit state shift { + entry; then working; + state working; + accept after 2 [s] then armed; + state armed { entry assign stage := 1; } + accept after 1 [s] then later; + state later { entry assign stage := 2; } + } + } + part worker : Worker; + action watcher { + attribute seen : Integer = -1; + attribute timed : Integer = -1; + first start; + fork split; + action quick accept when worker.stage > 0; + action noteQuick assign seen := worker.stage; + action slow accept after 6 [s]; + action noteSlow assign timed := worker.stage; + join meet; + done; + succession first start then split; + succession first split then quick; + succession first split then slow; + succession first quick then noteQuick; + succession first noteQuick then meet; + succession first slow then noteSlow; + succession first noteSlow then meet; + succession first meet then done; + } + state lookout { + attribute seen : Integer = -1; + entry; then waiting; + state waiting; + accept when worker.stage > 0 then noticed; + accept after 6 [s] then late; + state noticed { entry assign seen := worker.stage; } + state late { entry assign seen := 100 + worker.stage; } + } + }` + // The change branch sees stage 1 at t=2 while the timer holds the run to t=6; + // seeing 2 means it was only re-tested once the timer woke the executor. + wantSeen := func(t *testing.T, what string, got Value) { + t.Helper() + if got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("%s saw stage %v; want 1, the value at the instant the condition rose", what, got) + } + } + t.Run("action driving the clock", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, model)) + action := findSymbolByName(idx.DocumentRoot(""), "watcher", ast.DefAction) + out, err := ctx.ExecuteAction(action) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + wantSeen(t, "the accept when branch", out["seen"]) + if got := out["timed"]; got.Kind != ValConst || got.Const.Int != 2 { + t.Errorf("the accept after branch saw stage %v; want 2 at t=6", got) + } + if got := ctx.Clock().Now(); got != 6 { + t.Errorf("clock = %v; want 6, where the timer branch woke", got) + } + }) + t.Run("action driven by an advance", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, model)) + action := findSymbolByName(idx.DocumentRoot(""), "watcher", ast.DefAction) + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if _, err := ctx.Advance(3); err != nil { + t.Fatalf("Advance(3): %v", err) + } + wantSeen(t, "the accept when branch", exec.Results()["seen"]) + if exec.State() == StateCompleted { + t.Error("the action completed before its timer branch was due") + } + if _, err := ctx.Advance(3); err != nil { + t.Fatalf("Advance(3): %v", err) + } + if exec.State() != StateCompleted { + t.Errorf("state = %v; want Completed once the timer branch woke", exec.State()) + } + }) + t.Run("state machine driving the clock", func(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, model)) + machine := findSymbolByName(idx.DocumentRoot(""), "lookout", ast.DefState) + exec, err := ctx.CreateStateExecutor(machine) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + wantSeen(t, "the change transition", exec.StateData()["seen"]) + if got := ctx.Clock().Now(); got != 2 { + t.Errorf("clock = %v; want 2, the instant the condition rose", got) + } + }) + }) + t.Run("behaviors of an object that failed to start leave the clock", func(t *testing.T) { + ctx, _, err := instantiateWithLibraries(t, ` + package test { + private import SI::*; + private import ScalarValues::*; + part def Rig { + attribute beats : Integer = 0; + exhibit state pulse { + entry; then idle; + state idle; + transition first idle accept after 1 [s] do assign beats := beats + 1 then idle; + } + perform action tick { + first start; + then action rest accept after 1 [s]; + then done; + } + exhibit state broken { + state lonely; + } + } + }`, "test::Rig") + if !errors.Is(err, ErrNoInitialState) { + t.Fatalf("instantiate = %v; want ErrNoInitialState from the machine with no initial state", err) + } + if got := len(ctx.clock.waiters); got != 0 { + t.Errorf("clock drives %d executors after the failed start; want none", got) + } + if waits := ctx.Clock().Waits(); len(waits) != 0 { + t.Errorf("clock waits = %v after the failed start; want none", waits) + } + if _, ok := ctx.Clock().NextDue(); ok { + t.Error("clock has a next due instant after the failed start; want none") + } + report, err := ctx.Advance(10) + if err != nil { + t.Fatalf("Advance: %v", err) + } + if report.Events != 0 || report.Steps != 0 || report.DoSteps != 0 { + t.Errorf("report = %+v; want nothing run for behaviors the failed start withdrew", report) + } + if got := ctx.Clock().Now(); got != 10 { + t.Errorf("clock = %v; want 10", got) + } + }) + t.Run("a do behavior paused in a timed action leaves the clock with its dropped machine", func(t *testing.T) { + ctx, _, err := instantiateWithLibraries(t, ` + package test { + private import SI::*; + private import ScalarValues::*; + action def Poll { inout n : Integer; action wait accept after 5 [s]; then assign n := n + 1; } + part def Rig { + attribute ticks : Integer = 0; + exhibit state pulse { + entry; then busy; + state busy { do action poll : Poll { inout n = ticks; } } + } + perform action bad { action crash assign ticks := ticks / 0; } + } + }`, "test::Rig") + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("instantiate = %v; want ErrDivisionByZero from the action that fails the start", err) + } + if got := len(ctx.clock.waiters); got != 0 { + t.Errorf("clock drives %d executors after the failed start; want none: the performed Poll left with its machine", got) + } + if waits := ctx.Clock().Waits(); len(waits) != 0 { + t.Errorf("clock waits = %v after the failed start; want none", waits) + } + if len(ctx.objectBehaviors) != 0 { + t.Errorf("%d behavior(s) still attached after the failed start; want none", len(ctx.objectBehaviors)) + } + report, err := ctx.Advance(10) + if err != nil { + t.Fatalf("Advance: %v", err) + } + if report.Events != 0 || report.Steps != 0 || report.DoSteps != 0 { + t.Errorf("report = %+v; want nothing run for the do behavior the failed start withdrew", report) + } + }) +} + +// nestedWaitModel waits on the clock in the flow of a block a body statement runs. +const nestedWaitModel = ` + package test { + private import SI::*; + private import ScalarValues::*; + action outer { + attribute n : Integer = 0; + first start; + then action body { + if true { + action inner { + first start; + then action nap accept after 5 [s]; + then action mark assign n := 1; + then done; + } + } + } + then done; + } + } +` + +// performedWaitModel waits on the clock in an action a node of the flow performs, +// after a wait of the flow's own. +const performedWaitModel = ` + package test { + private import SI::*; + private import ScalarValues::*; + action def Napper { + out attribute n : Integer = 0; + first start; + then action nap accept after 5 [s]; + then action mark assign n := 1; + then done; + } + action outer { + attribute n : Integer = 0; + first start; + then action early accept after 1 [s]; + then perform action call : Napper; + then action copy assign n := call.n; + then done; + } + } +` + +// testActionAcceptNonBooleanChangeTrigger: a change trigger states a condition, +// so one that evaluates to something else is reported rather than read as true. +func testActionAcceptNonBooleanChangeTrigger(t *testing.T) { + src := ` + package test { + action monitor { + attribute temp : Integer = 10; + first start; + action awaitWarm accept when temp; + done; + succession first start then awaitWarm; + succession first awaitWarm then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "monitor", ast.DefAction) + if sym == nil { + t.Fatal("action monitor not found") + } + + _, err := ctx.ExecuteAction(sym) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("ExecuteAction error = %v, want ErrTypeMismatch", err) + } +} + +// testActionBodyUnresolvedUnit: an action body is evaluated in the scope it was +// written in, and a unit that scope does not bring in resolves to nothing — the +// quantity is reported as such rather than evaluated as its bare magnitude. +func testActionBodyUnresolvedUnit(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + public import ScalarValues::*; + action descend { + attribute h : Real = 500.0 [furlong]; + first start; + done; + succession first start then done; + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "descend", ast.DefAction) + if sym == nil { + t.Fatal("action descend not found") + } + + out, err := ctx.ExecuteAction(sym) + if !errors.Is(err, ErrNotAQuantity) { + t.Fatalf("outputs = %v, err = %v; want ErrNotAQuantity", out, err) + } + if !strings.Contains(err.Error(), semantics.ErrNotAUnit.Error()) { + t.Errorf("err = %v; want it to report that the index names no measurement unit", err) + } +} + +// testActionBodyUnresolvedFeature: a name no frame, object or enclosing scope +// supplies is reported as unresolved, so giving a body its declaring scope does +// not turn a typo into a silent zero. +func testActionBodyUnresolvedFeature(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, ` + package test { + action counter { + attribute total : Integer = 0; + first start; + action bump { + assign total := missingName + 1; + } + done; + succession first start then bump; + succession first bump then done; + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "counter", ast.DefAction) + if sym == nil { + t.Fatal("action counter not found") + } + + out, err := ctx.ExecuteAction(sym) + if !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("outputs = %v, err = %v; want ErrUnresolvedReference", out, err) + } + if !strings.Contains(err.Error(), "missingName") { + t.Errorf("err = %v; want it to name the unresolved feature", err) + } +} + +// testStateBodyUnresolvedUnit: the same for a state machine's attribute default, +// which is evaluated when the machine initializes. +func testStateBodyUnresolvedUnit(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + public import ScalarValues::*; + state monitor { + attribute speed : Real = 1.5 [knot]; + entry; then start; + state start; + state running; + succession first start then running; + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "monitor", ast.DefState) + if sym == nil { + t.Fatal("state monitor not found") + } + + _, err := ctx.ExecuteState(sym) + if !errors.Is(err, ErrNotAQuantity) { + t.Fatalf("err = %v; want ErrNotAQuantity", err) + } +} + +// Helper: parse source into AST RootNamespace +func parseAndBuild(t *testing.T, src string) *ast.RootNamespace { + file := parser.New(source.New("", []byte(src))).ParseFile() + return file +} + +// testLibraryFunctionOutsideItsDomain: a library function whose argument has no +// result reports a domain error rather than returning a NaN. +func testLibraryFunctionOutsideItsDomain(t *testing.T) { + src := ` + package test { + private import RealFunctions::*; + calc root { + in x : Real; + return : Real = sqrt(x); + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "root", ast.DefCalc) + if sym == nil { + t.Fatal("root calc not found") + } + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: -1}} + got, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if !errors.Is(err, semantics.ErrArithmeticDomain) { + t.Fatalf("sqrt(-1.0) = %+v, %v; want a domain error", got, err) + } +} + +// testLibraryFunctionWrongArity: a library function called with the wrong number +// of arguments reports an arity error rather than reading past its arguments. +func testLibraryFunctionWrongArity(t *testing.T) { + fn, ok := libraryFunctionByName("RealFunctions::max") + if !ok { + t.Fatal("RealFunctions::max not registered") + } + _, _, ctx := buildRuntime(t, "", parseAndBuild(t, "package test { }")) + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: 1}} + if _, err := fn.invoke(ctx, calcArgs{positional: []Value{arg}}); !errors.Is(err, ErrCalcArity) { + t.Fatalf("max(1.0) error = %v, want ErrCalcArity", err) + } +} + +// testExtensionLibraryFunctionOutsideItsDomain: an OpenSysML extension library +// function reports a domain error the same way a vendored one does — the +// logarithm of zero has no Real value, and is not returned as an infinity. +func testExtensionLibraryFunctionOutsideItsDomain(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + private import OpenSysMLMathFunctions::*; + calc root { + in x : Real; + return : Real = ln(x); + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "root", ast.DefCalc) + if sym == nil { + t.Fatal("root calc not found") + } + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: 0}} + got, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if !errors.Is(err, semantics.ErrArithmeticDomain) { + t.Fatalf("ln(0.0) = %+v, %v; want a domain error", got, err) + } +} + +// testExponentiationIntegerOverflow: an exponentiation beyond the Integer range +// is reported rather than wrapping. +func testExponentiationIntegerOverflow(t *testing.T) { + src := ` + package test { + calc power { + in b : Integer; + in e : Integer; + return : Integer = b ** e; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "power", ast.DefCalc) + if sym == nil { + t.Fatal("power calc not found") + } + + base := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1 << 40}} + exp := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 3}} + got, err := ctx.InvokeCalc(sym, []Value{base, exp}, rootScope) + if !errors.Is(err, semantics.ErrArithmeticOverflow) { + t.Fatalf("(2**40) ** 3 = %+v, %v; want an overflow error", got, err) + } +} + +// testQuantityIncommensurableComparison: comparing quantities whose units +// measure different things reports ErrIncommensurableUnits instead of comparing +// the bare magnitudes, which would make 1.5 [m/s] <= 2.0 [s] true. +func testQuantityIncommensurableComparison(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + public import SI::*; + requirement def Touchdown { + attribute speed = 1.5 [m/s]; + attribute duration = 2.0 [s]; + require constraint { speed <= duration } + } + } + `)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "Touchdown", ast.DefRequirement) + if sym == nil { + t.Fatal("Touchdown requirement not found") + } + + satisfied, err := ctx.EvaluateRequirement(sym, rootScope) + if !errors.Is(err, ErrIncommensurableUnits) { + t.Fatalf("satisfied = %v, err = %v; want ErrIncommensurableUnits", satisfied, err) + } + if errors.Is(err, ErrViolated) { + t.Error("incommensurable units are not a violation: neither verdict is an answer") + } +} + +// testQuantityIndexIsNotAUnit: a bracketed expression whose index names +// something that is not a measurement unit reports ErrNotAQuantity rather than +// evaluating to the bare magnitude. +func testQuantityIndexIsNotAUnit(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + public import SI::*; + attribute notAUnit = 3.0; + constraint bogus { + 1.5 [test::notAUnit] <= 2.0 [m] + } + } + `)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "bogus", ast.DefConstraint) + if sym == nil { + t.Fatal("bogus constraint not found") + } + + satisfied, err := ctx.EvaluateConstraint(sym, rootScope) + if !errors.Is(err, ErrNotAQuantity) { + t.Fatalf("satisfied = %v, err = %v; want ErrNotAQuantity", satisfied, err) + } + if !strings.Contains(err.Error(), semantics.ErrNotAUnit.Error()) { + t.Errorf("err = %v; want it to report that the index names no measurement unit", err) + } +} + +// testQuantityUnitShadowedBySibling: a unit position naming a sibling that is +// not a measurement unit reports which declaration it resolved to and the unit +// that declaration hid, rather than a magnitude in the wrong unit. +func testQuantityUnitShadowedBySibling(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + public import SI::*; + constraint def Tall { + attribute m : ScalarValues::Real = 2.0; + 1.0 [m] > 500.0 [m] + } + } + `)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "Tall", ast.DefConstraint) + if sym == nil { + t.Fatal("Tall constraint not found") + } + + satisfied, err := ctx.EvaluateConstraint(sym, sym.OwnerScope) + if !errors.Is(err, ErrNotAQuantity) { + t.Fatalf("satisfied = %v, err = %v; want ErrNotAQuantity", satisfied, err) + } + if !errors.Is(err, semantics.ErrNotAUnit) { + t.Errorf("err = %v; want it to report that the name is no measurement unit", err) + } + var shadowed *semantics.ShadowedUnitError + if !errors.As(err, &shadowed) { + t.Fatalf("err = %v; want a *semantics.ShadowedUnitError", err) + } + if shadowed.Resolved == nil || shadowed.Namespace != "test::Tall" { + t.Errorf("error names %v in %q; want the sibling declared in test::Tall", shadowed.Resolved, shadowed.Namespace) + } + if shadowed.Shadowed == nil || shadowed.Suggestion != "SI::m" { + t.Errorf("error suggests %q; want the qualified spelling SI::m of the hidden unit", shadowed.Suggestion) + } +} + +// testQuantityQualifiedUnitIsNotShadowing: a qualified name in unit position +// resolves to what it names, so a non-unit is reported as one without a +// shadowing explanation or a spelling that would not resolve. +func testQuantityQualifiedUnitIsNotShadowing(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + public import SI::*; + attribute m : ScalarValues::Real = 2.0; + constraint def Tall { + 1.0 [test::m] > 500.0 [SI::m] + } + } + `)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "Tall", ast.DefConstraint) + if sym == nil { + t.Fatal("Tall constraint not found") + } + + satisfied, err := ctx.EvaluateConstraint(sym, sym.OwnerScope) + if !errors.Is(err, semantics.ErrNotAUnit) { + t.Fatalf("satisfied = %v, err = %v; want ErrNotAUnit", satisfied, err) + } + var shadowed *semantics.ShadowedUnitError + if !errors.As(err, &shadowed) { + t.Fatalf("err = %v; want a *semantics.ShadowedUnitError", err) + } + if shadowed.Shadowed != nil || shadowed.Suggestion != "" { + t.Errorf("error suggests %q for a qualified name; want no shadowing explanation", shadowed.Suggestion) + } + if !strings.Contains(err.Error(), "test::m resolves to") { + t.Errorf("err = %v; want it to name the declaration as written", err) + } +} + +// testQuantityShadowedUnitWithoutAQualifier: a hidden unit owned by no namespace +// has no qualified spelling to offer, so the diagnostic names it without +// advising the name that just failed. +func testQuantityShadowedUnitWithoutAQualifier(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + attribute u : ISQBase::LengthUnit = SI::m; + package test { + attribute u : ScalarValues::Real = 2.0; + constraint def Tall { + 1.0 [u] > 0.5 [SI::m] + } + } + `)) + sym := findSymbolByName(idx.DocumentRoot(""), "Tall", ast.DefConstraint) + if sym == nil { + t.Fatal("Tall constraint not found") + } + + satisfied, err := ctx.EvaluateConstraint(sym, sym.OwnerScope) + if !errors.Is(err, semantics.ErrNotAUnit) { + t.Fatalf("satisfied = %v, err = %v; want ErrNotAUnit", satisfied, err) + } + var shadowed *semantics.ShadowedUnitError + if !errors.As(err, &shadowed) { + t.Fatalf("err = %v; want a *semantics.ShadowedUnitError", err) + } + if shadowed.Shadowed == nil { + t.Fatalf("err = %v; want it to name the unit the declaration hid", err) + } + if shadowed.Suggestion != "" { + t.Errorf("error suggests %q; want no spelling when none qualifies the unit", shadowed.Suggestion) + } + if strings.Contains(err.Error(), "write u") { + t.Errorf("err = %v; want it not to advise the name that failed", err) + } +} + +// testQuantityCyclicUnitDefinition: two units defined in terms of each other are +// reported as a cycle instead of recursing until the stack or step budget runs +// out. +func testQuantityCyclicUnitDefinition(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + package test { + public import SI::*; + attribute unitA : ISQBase::LengthUnit = unitB; + attribute unitB : ISQBase::LengthUnit = unitA; + constraint cyclic { + 1.0 [test::unitA] <= 2.0 [test::unitA] + } + } + `)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "cyclic", ast.DefConstraint) + if sym == nil { + t.Fatal("cyclic constraint not found") + } + + done := make(chan error, 1) + go func() { + _, err := ctx.EvaluateConstraint(sym, rootScope) + done <- err + }() + select { + case err := <-done: + if !errors.Is(err, semantics.ErrUnitCycle) { + t.Fatalf("err = %v; want ErrUnitCycle", err) + } + case <-watchdog(10 * time.Second): + t.Fatal("evaluating a cyclic unit definition did not terminate") + } +} + +// Helper: build runtime context from file +func buildRuntime(t *testing.T, path string, file *ast.RootNamespace) (*symbols.Index, *semantics.Model, *Context) { + idx := symbols.NewIndex() + idx.AddDocument(path, file) + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + ctx := NewContext(typedModel(model, resolver), 10000) + return idx, model, ctx +} + +// buildRuntimeWithLibraries builds a runtime context over an index that carries +// the standard library, for a model that names its elements. +func buildRuntimeWithLibraries(t *testing.T, path string, file *ast.RootNamespace) (*symbols.Index, *semantics.Model, *Context) { + t.Helper() + idx := libs.NewModelIndex() + idx.AddDocument(path, file) + idx.ExpandWildcardImports() + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + return idx, model, NewContext(parsingModel(model, resolver), 10000) +} + +// Helper: find symbol by name and kind +func findSymbolByName(scope *symbols.Scope, name string, kind ast.DefinitionKind) *symbols.Symbol { + // Map DefKind to UsageKind + var usageKind ast.UsageKind + switch kind { + case ast.DefCalc: + usageKind = ast.UsageCalc + case ast.DefAction: + usageKind = ast.UsageAction + case ast.DefState: + usageKind = ast.UsageState + case ast.DefConstraint: + usageKind = ast.UsageConstraint + case ast.DefRequirement: + usageKind = ast.UsageRequirement + } + + // Check all child scopes (packages/namespaces) + for _, child := range scope.Children() { + for _, memberName := range child.MemberNames() { + sym, _ := child.LookupLocal(memberName) + if sym == nil { + continue + } + + if sym.Name == name { + switch decl := sym.Decl.(type) { + case *ast.Definition: + if decl.Kind == kind { + return sym + } + case *ast.Usage: + if decl.Kind == usageKind { + return sym + } + } + } + } + } + + // Also check root scope directly + for _, memberName := range scope.MemberNames() { + sym, _ := scope.LookupLocal(memberName) + if sym == nil { + continue + } + + if sym.Name == name { + switch decl := sym.Decl.(type) { + case *ast.Definition: + if decl.Kind == kind { + return sym + } + case *ast.Usage: + if decl.Kind == usageKind { + return sym + } + } + } + } + return nil +} + +// invokeCalcInSource invokes calcName with one Integer argument and returns the +// error, on its own goroutine so a body that never terminates fails the case +// instead of stalling the suite. maxSteps bounds the run. +func invokeCalcInSource(t *testing.T, src, calcName string, arg int64, maxSteps int64) error { + t.Helper() + + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + ctx.maxSteps = maxSteps + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, calcName, ast.DefCalc) + if sym == nil { + t.Fatalf("calc %s not found", calcName) + } + + done := make(chan error, 1) + go func() { + value := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: arg}} + result, err := ctx.InvokeCalc(sym, []Value{value}, rootScope) + if err == nil { + err = fmt.Errorf("calc %s returned %s, expected it to fail", calcName, FormatTraceValue(result)) + } + done <- err + }() + + select { + case err := <-done: + return err + case <-watchdog(10 * time.Second): + t.Fatalf("calc %s did not terminate", calcName) + return nil + } +} + +// testCalcNonTerminatingLoop: a calc loop whose condition always holds spends +// the context's step budget, so it fails the invocation instead of hanging the +// REPL, LSP or gRPC caller that drove it. +func testCalcNonTerminatingLoop(t *testing.T) { + src := ` + package test { + calc spin { + in n: Integer; + attribute i : Integer = 0; + while i >= 0 { + assign i := i + 1; + } + return : Integer = i; + } + } + ` + err := invokeCalcInSource(t, src, "spin", 1, 20) + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected ErrStepLimitExceeded, got: %v", err) + } +} + +// testCalcBodyNeverReturns: a body that computes but reaches no `return` states +// no result, which is an error rather than a null value. +func testCalcBodyNeverReturns(t *testing.T) { + src := ` + package test { + calc maybe { + in n: Integer; + attribute total : Integer = 0; + if n > 0 { + assign total := n; + } + if n < 0 { + return : Integer = total; + } + } + } + ` + err := invokeCalcInSource(t, src, "maybe", 5, 10000) + if !errors.Is(err, ErrCalcNoReturn) { + t.Errorf("expected ErrCalcNoReturn, got: %v", err) + } +} + +// testCalcSendIsRejected: a calculation computes a value and nothing else, so a +// send in its body is rejected rather than posted. +func testCalcSendIsRejected(t *testing.T) { + src := ` + package test { + attribute def Ping; + calc noisy { + in n: Integer; + send Ping() to listener; + return : Integer = n; + } + } + ` + err := invokeCalcInSource(t, src, "noisy", 1, 10000) + if !errors.Is(err, ErrCalcSideEffect) { + t.Errorf("expected ErrCalcSideEffect, got: %v", err) + } +} + +// testCalcTerminateIsRejected: `terminate` ends an execution, which a +// calculation has no business doing. +func testCalcTerminateIsRejected(t *testing.T) { + src := ` + package test { + calc halting { + in n: Integer; + terminate; + return : Integer = n; + } + } + ` + err := invokeCalcInSource(t, src, "halting", 1, 10000) + if !errors.Is(err, ErrCalcSideEffect) { + t.Errorf("expected ErrCalcSideEffect, got: %v", err) + } +} + +// testCalcAssignmentOutsideTheCalc: a calc may write its own parameters and +// locals; a name it does not declare belongs to the model around it and writing +// it would be an effect, so it is rejected. +func testCalcAssignmentOutsideTheCalc(t *testing.T) { + src := ` + package test { + attribute shared : Integer = 0; + calc leaky { + in n: Integer; + assign shared := n; + return : Integer = n; + } + } + ` + err := invokeCalcInSource(t, src, "leaky", 3, 10000) + if !errors.Is(err, ErrCalcExternalAssignment) { + t.Errorf("expected ErrCalcExternalAssignment, got: %v", err) + } +} + +// testCalcNonBooleanCondition: a condition that is not Boolean is a type error +// the typecheck pass reports; an execution that reaches one anyway says so +// rather than coercing the value. +func testCalcNonBooleanCondition(t *testing.T) { + src := ` + package test { + calc counting { + in n: Integer; + while n { + return : Integer = 1; + } + return : Integer = 0; + } + } + ` + err := invokeCalcInSource(t, src, "counting", 3, 10000) + if err == nil || !strings.Contains(err.Error(), "must evaluate to a Boolean") { + t.Errorf("expected a non-Boolean condition error, got: %v", err) + } +} + +// calcUsageOutputInSource reads one output feature of the named calc usage and +// returns the error the read reports, on its own goroutine so a body that never +// terminates fails the case instead of stalling the suite. maxSteps bounds the +// run. +func calcUsageOutputInSource(t *testing.T, src, usageName, output string, maxSteps int64, budgets ...func(*Context)) error { + t.Helper() + + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + ctx.maxSteps = maxSteps + for _, set := range budgets { + set(ctx) + } + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, usageName, ast.DefCalc) + if sym == nil { + t.Fatalf("calc usage %s not found", usageName) + } + + done := make(chan error, 1) + go func() { + value, err := ctx.CalcUsageOutput(sym, output, sym.OwnerScope, nil) + if err == nil { + err = fmt.Errorf("output %s of %s answered %s, expected the read to fail", + output, usageName, FormatTraceValue(value)) + } + done <- err + }() + + select { + case err := <-done: + return err + case <-watchdog(10 * time.Second): + t.Fatalf("reading output %s of %s did not terminate", output, usageName) + return nil + } +} + +// testCalcUsageUnboundInput: a usage that leaves an input of its calc with +// neither a value nor a default computes nothing, since a usage passes no +// arguments to stand in for one. +func testCalcUsageUnboundInput(t *testing.T) { + src := ` + package test { + calc def Two { + in n : Integer; + out a = n + 1; + out b = n * 2; + } + calc c : Two; + } + ` + err := calcUsageOutputInSource(t, src, "c", "a", 10000) + if !errors.Is(err, ErrUnboundParameter) { + t.Errorf("expected ErrUnboundParameter, got: %v", err) + } +} + +// testCalcUsageUnknownOutput: a name the calc declares no output for is a +// modeling error, not an empty value. +func testCalcUsageUnknownOutput(t *testing.T) { + src := ` + package test { + calc def Two { + in n : Integer; + out a = n + 1; + out b = n * 2; + } + calc c : Two { in n = 5; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "nope", 10000) + if !errors.Is(err, ErrUnknownOutput) { + t.Errorf("expected ErrUnknownOutput, got: %v", err) + } + if err != nil && !strings.Contains(err.Error(), "a, b") { + t.Errorf("error should name the outputs the calc does declare, got: %v", err) + } +} + +// testCalcUsageCyclicOutputs: outputs valued from each other have no value to +// compute, which is reported as the cycle it is rather than spending the step +// budget or hanging. +func testCalcUsageCyclicOutputs(t *testing.T) { + src := ` + package test { + calc def Knot { + in n : Integer; + out a = b + 1; + out b = a + n; + } + calc c : Knot { in n = 1; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "a", 10000) + if !errors.Is(err, ErrCyclicOutput) { + t.Errorf("expected ErrCyclicOutput, got: %v", err) + } +} + +// testCalcUsageSpecializesANonCalc: a calc usage typed by something that is not +// a calc inherits no parameters, outputs or body from it, so the specialization +// is reported rather than the outputs it appears to be missing. +func testCalcUsageSpecializesANonCalc(t *testing.T) { + src := ` + package test { + part def Chassis { + attribute mass : Integer = 4; + } + calc c : Chassis; + } + ` + err := calcUsageOutputInSource(t, src, "c", "mass", 10000) + if !errors.Is(err, ErrNotACalc) { + t.Errorf("expected ErrNotACalc, got: %v", err) + } +} + +// testCalcUsageStepBudget: a usage whose body never terminates spends the step +// budget of the run reading its output, so the read fails instead of hanging +// whoever drove it. +func testCalcUsageStepBudget(t *testing.T) { + src := ` + package test { + calc def Spin { + in n : Integer; + attribute i : Integer = 0; + while i >= 0 { + assign i := i + 1; + } + out reached = i; + } + calc c : Spin { in n = 1; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "reached", 20) + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected ErrStepLimitExceeded, got: %v", err) + } +} + +// testCalcUsageOutputWithoutAValue: an output the calc declares but binds no +// value to computes nothing, so reading it says so rather than answering null. +func testCalcUsageOutputWithoutAValue(t *testing.T) { + src := ` + package test { + calc def Half { + in n : Integer; + out a = n + 1; + out b : Integer; + } + calc c : Half { in n = 5; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "b", 10000) + if !errors.Is(err, ErrNoValue) { + t.Errorf("expected ErrNoValue, got: %v", err) + } +} + +// testCalcOutputNeverAssignedByTheBody: a body that assigns one of two declared +// outputs leaves the other unbound, and the read says that rather than blaming a +// missing result expression. +func testCalcOutputNeverAssignedByTheBody(t *testing.T) { + src := ` + package test { + calc def Two { + in n : Integer; + out a : Integer; + out b : Integer; + assign a := n + 1; + } + calc c : Two { in n = 5; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "b", 10000) + if !errors.Is(err, ErrOutputNotAssigned) { + t.Errorf("expected ErrOutputNotAssigned, got: %v", err) + } + if errors.Is(err, ErrNoResultExpression) { + t.Errorf("expected no result-expression blame, got: %v", err) + } +} + +// testCalcOutputAssignedInABranchNotTaken: an output only a branch that does not +// run would assign is unbound for that activation. +func testCalcOutputAssignedInABranchNotTaken(t *testing.T) { + src := ` + package test { + calc def Branch { + in n : Integer; + out a : Integer; + if n > 10 { + assign a := n; + } + } + calc c : Branch { in n = 5; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "a", 10000) + if !errors.Is(err, ErrOutputNotAssigned) { + t.Errorf("expected ErrOutputNotAssigned, got: %v", err) + } +} + +// testCalcOutputValuedAndAssigned: an output given a value two ways is reported +// rather than silently picking one (the precedent of #127/#131). +func testCalcOutputValuedAndAssigned(t *testing.T) { + src := ` + package test { + calc def Both { + in n : Integer; + out a : Integer = n; + assign a := n + 1; + } + calc c : Both { in n = 5; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "a", 10000) + if !errors.Is(err, ErrConflictingOutput) { + t.Errorf("expected ErrConflictingOutput, got: %v", err) + } +} + +// testCalcOutputBindingViolatesDeclaredType: an output whose declaration binds +// it a value of another type is rejected where a write of one is, the input it +// reads being untyped so only the run time can judge it. +func testCalcOutputBindingViolatesDeclaredType(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + calc def Bound { + in n; + out a : Integer = n; + } + calc c : Bound { in n = "seven"; } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + usage := oneSymbol(t, idx, "test::c") + _, err := ctx.CalcUsageOutput(usage, "a", idx.DocumentRoot(""), nil) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } + if !strings.Contains(err.Error(), "Integer") { + t.Errorf("error = %v, want it to name the declared type", err) + } +} + +// testCalcOutputAssignedTwice: a body assigning an output more than once leaves +// it bound to the last assignment that ran, the same as a body local, so an +// output may be initialized and then accumulated into. +func testCalcOutputAssignedTwice(t *testing.T) { + src := ` + package test { + calc def Twice { + in n : Integer; + out a : Integer; + assign a := n + 1; + assign a := a + 1; + } + calc c : Twice { in n = 5; } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + ctx.maxSteps = 10000 + sym := findSymbolByName(idx.DocumentRoot(""), "c", ast.DefCalc) + if sym == nil { + t.Fatal("calc usage c not found") + } + + value, err := ctx.CalcUsageOutput(sym, "a", sym.OwnerScope, nil) + if err != nil { + t.Fatalf("reading output a of c: %v", err) + } + if got := FormatTraceValue(value); got != "7" { + t.Errorf("output a = %s, want 7", got) + } +} + +// testMultipleOutputsInvokedAsAnExpression: an invocation of a function yields +// exactly one result (KerML 7.4.9), so invoking a calc that computes several +// outputs and designates no result is reported rather than answered with +// whichever output happens to come first. +func testMultipleOutputsInvokedAsAnExpression(t *testing.T) { + src := ` + package test { + calc def Two { + in n : Integer; + out a = n + 1; + out b = n * 2; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + rootScope := idx.DocumentRoot("") + sym := findSymbolByName(rootScope, "Two", ast.DefCalc) + if sym == nil { + t.Fatal("Two calc not found") + } + + arg := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 5}} + result, err := ctx.InvokeCalc(sym, []Value{arg}, rootScope) + if err == nil { + t.Fatalf("expected the invocation to be rejected, it answered %s", FormatTraceValue(result)) + } + if !errors.Is(err, ErrAmbiguousResult) { + t.Errorf("expected ErrAmbiguousResult, got: %v", err) + } + // The diagnostic has to teach the spelling that does work. + if err != nil && !strings.Contains(err.Error(), "calc c : test::Two { in n = ...; } then read c.a") { + t.Errorf("error should spell out the calc usage to declare, got: %v", err) + } +} + +// testNestedCalcUsageUnboundInput: a usage nested in a calc binds its inputs +// from the enclosing evaluation, and an input nothing there values is reported. +func testNestedCalcUsageUnboundInput(t *testing.T) { + src := ` + package test { + calc def Two { + in n : Integer; + out a = n + 1; + out b = n * 2; + } + calc def Outer { + in m : Integer; + calc inner : Two; + out d = inner.a; + } + calc c : Outer { in m = 5; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "d", 10000) + if !errors.Is(err, ErrUnboundParameter) { + t.Errorf("expected ErrUnboundParameter, got: %v", err) + } +} + +// testNestedCalcUsageUnknownOutput: reading a name the nested calc declares no +// output for is a modeling error wherever the usage is declared. +func testNestedCalcUsageUnknownOutput(t *testing.T) { + src := ` + package test { + calc def Two { + in n : Integer; + out a = n + 1; + out b = n * 2; + } + calc def Outer { + in m : Integer; + calc inner : Two { in n = m; } + out d = inner.nope; + } + calc c : Outer { in m = 5; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "d", 10000) + if !errors.Is(err, ErrUnknownOutput) { + t.Errorf("expected ErrUnknownOutput, got: %v", err) + } +} + +// testNestedCalcUsageSelfCycle: an input of a nested usage valued from its own +// name with nothing outside to resolve to stays the cycle it is, rather than +// being read as the shadowing binding it looks like. +func testNestedCalcUsageSelfCycle(t *testing.T) { + src := ` + package test { + calc def Two { + in n : Integer; + out a = n + 1; + out b = n * 2; + } + calc def Outer { + calc inner : Two { in n = n; } + out d = inner.a; + } + calc c : Outer; + } + ` + err := calcUsageOutputInSource(t, src, "c", "d", 10000) + if !errors.Is(err, ErrCyclicFeatureValue) { + t.Errorf("expected ErrCyclicFeatureValue, got: %v", err) + } +} + +// testNestedCalcUsageRecursionDepth: a calc whose nested usage is of itself +// never bottoms out, so the depth budget reports it instead of hanging. A usage +// frame costs far more than an invocation, so the case states a shallow budget. +func testNestedCalcUsageRecursionDepth(t *testing.T) { + src := ` + package test { + calc def Down { + in n : Integer; + calc next : Down { in n = n - 1; } + out a = next.a; + out b = n; + } + calc c : Down { in n = 3; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "a", 1000000, + func(ctx *Context) { ctx.maxCalcDepth = nestingProbeDepth }) + if !errors.Is(err, ErrCalcRecursionLimit) { + t.Errorf("expected ErrCalcRecursionLimit, got: %v", err) + } +} + +// testNestedCalcUsageStepBudget: the body of a nested usage spends the budget of +// the run reading it, so a body that never terminates fails the read. +func testNestedCalcUsageStepBudget(t *testing.T) { + src := ` + package test { + calc def Spin { + in n : Integer; + attribute i : Integer = 0; + while i >= 0 { + assign i := i + 1; + } + out reached = i; + } + calc def Outer { + in m : Integer; + calc inner : Spin { in n = m; } + out d = inner.reached; + } + calc c : Outer { in m = 1; } + } + ` + err := calcUsageOutputInSource(t, src, "c", "d", 20) + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected ErrStepLimitExceeded, got: %v", err) + } +} + +// variationFeatureValueInSource instantiates a usage and returns the value its named +// feature value holds, so a variation's failure modes are read where a model reads them. +func variationFeatureValueInSource(t *testing.T, src, usage, fv string) (Value, error) { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + matches := idx.LookupQualified(usage) + if len(matches) != 1 { + t.Fatalf("%s: %d matching symbols, want 1", usage, len(matches)) + } + inst, err := ctx.Instantiate(matches[0]) + if err != nil { + return Value{}, err + } + got, err := inst.GetFeatureValue(ctx, fv) + if err != nil { + return Value{}, err + } + return got.Value, nil +} + +// variationFamily is a variation point with three variants, over which the +// selection a specialization makes is varied per case. +const variationFamily = ` + package test { + part def Diamond { attribute cut; attribute color; } + abstract part family : Diamond { + variation attribute :>> cut { + variant attribute cutShallow { attribute cost = 200.0; } + variant attribute cutIdeal { attribute cost = 250.0; } + } + variation attribute :>> color { + variant attribute colorWhite { attribute cost = 100.0; } + } + } + %s + }` + +// testVariationWithoutASelectedVariant: a variation nothing selects a variant +// for has no value, so reading it says so rather than answering the variation's +// own empty object or one of the variants arbitrarily. +func testVariationWithoutASelectedVariant(t *testing.T) { + src := fmt.Sprintf(variationFamily, `part unconfigured :> family;`) + got, err := variationFeatureValueInSource(t, src, "test::unconfigured", "cut") + if !errors.Is(err, ErrVariationUnselected) { + t.Errorf("cut = (%v, %v), want ErrVariationUnselected", got, err) + } + // The failure names the feature, so a model with many variation points says + // which one is unconfigured. + if err != nil && !strings.Contains(err.Error(), "cut") { + t.Errorf("error %q does not name the variation", err) + } +} + +// variationReadFromDeclaration evaluates a usage's value with no bound object, +// so a variation is read through its declaration rather than through a feature value. +func variationReadFromDeclaration(t *testing.T, src, probe string) (Value, error) { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + matches := idx.LookupQualified(probe) + if len(matches) != 1 { + t.Fatalf("%s: %d matching symbols, want 1", probe, len(matches)) + } + usage, ok := matches[0].Decl.(*ast.Usage) + if !ok || usage.Value == nil { + t.Fatalf("%s: no value expression", probe) + } + return ctx.EvalWithScope(usage.Value, matches[0].OwnerScope) +} + +// testVariationReadThroughItsDeclaration: a variation read without a bound +// object is bound the same way as one read from a feature value, so what a legal +// selection is does not depend on how the model is inspected. +func testVariationReadThroughItsDeclaration(t *testing.T) { + for _, tt := range []struct { + name, decl, probe string + want error + }{ + { + "not_a_variant", + `part chosen :> family { attribute :>> cut = 250.0; attribute probe = cut; }`, + "test::chosen::probe", ErrNotAVariant, + }, + { + "two_variants", + `part chosen :> family { attribute :>> cut = (cut::cutIdeal, cut::cutShallow); attribute probe = cut; }`, + "test::chosen::probe", ErrMultipleVariants, + }, + { + "unselected", + `part chosen :> family { attribute probe = cut; }`, + "test::chosen::probe", ErrVariationUnselected, + }, + { + "qualified_not_a_variant", + `part chosen :> family { attribute :>> cut = 250.0; } + attribute probe = chosen::cut;`, + "test::probe", ErrNotAVariant, + }, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := variationReadFromDeclaration(t, fmt.Sprintf(variationFamily, tt.decl), tt.probe) + if !errors.Is(err, tt.want) { + t.Errorf("%s = (%v, %v), want %v", tt.probe, got, err, tt.want) + } + }) + } +} + +// testChainThroughAnUnselectedVariationPart: a variation part is no occurrence +// of itself, so a chain through one nothing selected a variant for reports that +// rather than reading an object of the variation. +func testChainThroughAnUnselectedVariationPart(t *testing.T) { + src := ` + package test { + private import ScalarValues::Real; + part def Engine { attribute power : Real; } + variation part engine : Engine { + variant part electric : Engine { attribute :>> power = 100.0; } + variant part diesel : Engine { attribute :>> power = 200.0; } + } + part probe { attribute p : Real = engine.power; } + }` + got, err := variationFeatureValueInSource(t, src, "test::probe", "p") + if !errors.Is(err, ErrVariationUnselected) { + t.Errorf("p = (%v, %v), want ErrVariationUnselected", got, err) + } +} + +// testClassifyAnUnselectedOptionalVariation: an optional variation nothing selects +// a variant for is a choice, not an empty collection, so `istype` reports that +// rather than vacuously matching every type. +func testClassifyAnUnselectedOptionalVariation(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Engine; + part def Motor :> Engine; + variation part engine : Engine[0..1] { + variant part electric : Motor; + variant part diesel : Engine; + } + attribute isMotor : Boolean = engine istype Motor; + attribute hasMotor : Boolean = engine hastype Motor; + }` + for _, probe := range []string{"test::isMotor", "test::hasMotor"} { + got, err := variationReadFromDeclaration(t, src, probe) + if !errors.Is(err, ErrVariationUnselected) { + t.Errorf("%s = (%v, %v), want ErrVariationUnselected", probe, got, err) + } + } +} + +// testVariationBoundToWhatIsNotAVariant: a selection naming something that is +// not a variant of the variation is reported, whether the name is unknown, a +// variant of another variation, or an ordinary value. +func testVariationBoundToWhatIsNotAVariant(t *testing.T) { + for _, tt := range []struct{ name, selection string }{ + {"unknown_name", `part chosen :> family { attribute :>> cut = cut::nope; }`}, + {"variant_of_another_variation", `part chosen :> family { attribute :>> cut = color::colorWhite; }`}, + {"ordinary_value", `part chosen :> family { attribute :>> cut = 250.0; }`}, + {"collection_of_ordinary_values", `part chosen :> family { attribute :>> cut = (250.0, 200.0); }`}, + {"variant_mixed_with_an_ordinary_value", `part chosen :> family { attribute :>> cut = (cut::cutIdeal, 250.0); }`}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := variationFeatureValueInSource(t, fmt.Sprintf(variationFamily, tt.selection), "test::chosen", "cut") + if !errors.Is(err, ErrNotAVariant) { + t.Errorf("cut = (%v, %v), want ErrNotAVariant", got, err) + } + }) + } +} + +// testVariationBoundToTwoVariants: a variation stands for one variant, so a +// selection of several is reported rather than silently taking the first. +func testVariationBoundToTwoVariants(t *testing.T) { + src := fmt.Sprintf(variationFamily, + `part chosen :> family { attribute :>> cut = (cut::cutIdeal, cut::cutShallow); }`) + got, err := variationFeatureValueInSource(t, src, "test::chosen", "cut") + if !errors.Is(err, ErrMultipleVariants) { + t.Fatalf("cut = (%v, %v), want ErrMultipleVariants", got, err) + } + for _, name := range []string{"cutIdeal", "cutShallow"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("error %q does not name the selection %s", err, name) + } + } +} + +// testRepeatedReadsOfAVariantObject: the object a selected variant stands for is +// materialized once, so evaluating a chain through it repeatedly neither piles up +// objects nor exhausts the step budget. +func testRepeatedReadsOfAVariantObject(t *testing.T) { + src := ` + package test { + part def Engine { attribute power; } + abstract part family { + variation part engine : Engine { + variant part electric : Engine { attribute :>> power = 150.0; } + variant part petrol : Engine { attribute :>> power = 120.0; } + } + } + part chosen :> family { part :>> engine = engine::electric; } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + variant := oneSymbol(t, idx, "test::family::engine::electric") + variation := oneSymbol(t, idx, "test::family::engine") + first, err := ctx.variantValue(variation, variant, 1) + if err != nil { + t.Fatalf("variantValue: %v", err) + } + count := len(ctx.instances) + for i := 0; i < 10; i++ { + again, err := ctx.variantValue(variation, variant, 1) + if err != nil { + t.Fatalf("variantValue (read %d): %v", i+2, err) + } + if again.Instance != first.Instance { + t.Fatalf("read %d gave instance %d, want %d", i+2, again.Instance, first.Instance) + } + } + if len(ctx.instances) != count { + t.Errorf("instances grew from %d to %d over repeated reads", count, len(ctx.instances)) + } +} + +// testTwoOwnersSelectingOneVariant: a variant is selected per owning object, so +// two owners of one variation each hold their own object of the variant rather +// than sharing one whose materialized feature values the other reads. +func testTwoOwnersSelectingOneVariant(t *testing.T) { + src := ` + package test { + part def Engine { attribute power; } + abstract part family { + variation part engine : Engine { + variant part electric : Engine { attribute :>> power = 150.0; } + variant part petrol : Engine { attribute :>> power = 120.0; } + } + } + part sedan :> family { part :>> engine = engine::electric; } + part coupe :> family { part :>> engine = engine::electric; } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + ids := make([]int64, 0, 2) + for _, usage := range []string{"test::sedan", "test::coupe"} { + inst, err := ctx.Instantiate(oneSymbol(t, idx, usage)) + if err != nil { + t.Fatalf("%s: %v", usage, err) + } + fv, err := inst.GetFeatureValue(ctx, "engine") + if err != nil { + t.Fatalf("%s.engine: %v", usage, err) + } + id, ok := fv.Value.Object() + if !ok { + t.Fatalf("%s.engine = %v, want an object of the selected variant", usage, fv.Value) + } + ids = append(ids, id) + } + if ids[0] == ids[1] { + t.Errorf("sedan and coupe share engine object %d", ids[0]) + } +} + +// testTwoOwnerlessSelectionsOfOneVariant: a variation read through its +// declaration has no owning object, so two variation points selecting one +// variant must still stand for an object each rather than share one. +func testTwoOwnerlessSelectionsOfOneVariant(t *testing.T) { + src := ` + package test { + part def Engine { attribute power; } + abstract part family { + variation part engine : Engine { + variant part electric : Engine { attribute :>> power = 150.0; } + } + } + part sedan :> family { part :>> engine = engine::electric; } + part coupe :> family { part :>> engine = engine::electric; } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + variant := oneSymbol(t, idx, "test::family::engine::electric") + ids := make([]int64, 0, 2) + for _, variation := range []string{"test::sedan::engine", "test::coupe::engine"} { + val, err := ctx.variantValue(oneSymbol(t, idx, variation), variant, 0) + if err != nil { + t.Fatalf("%s: %v", variation, err) + } + ids = append(ids, val.Instance) + } + if ids[0] == ids[1] { + t.Errorf("sedan.engine and coupe.engine share object %d", ids[0]) + } +} + +// testVariantOutsideAVariation: `variant` on a member whose owner is not a +// variation offers no choice, so the member stays an ordinary feature instead of +// silently holding no value. +func testVariantOutsideAVariation(t *testing.T) { + src := ` + package test { + part def Widget { variant attribute misplaced = 1.0; } + part widget : Widget; + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "test::widget")) + if err != nil { + t.Fatalf("Instantiate(test::widget): %v", err) + } + fv, err := inst.GetFeatureValue(ctx, "misplaced") + if err != nil { + t.Fatalf("widget.misplaced: %v", err) + } + if fv.Value.Kind != ValConst || fv.Value.Const.Real != 1.0 { + t.Errorf("widget.misplaced = %v, want 1", fv.Value) + } +} + +// testVariantUnderARedefinedVariation: a usage redefining a variation usage is a +// variation point without restating the modifier, so the variants under it stay +// choices that specialize it instead of materializing feature values. +func testVariantUnderARedefinedVariation(t *testing.T) { + src := ` + package test { + part def Engine { attribute power; } + abstract part family { + variation part engine : Engine { + variant part petrol : Engine { attribute :>> power = 90.0; } + } + } + abstract part refined :> family { + part :>> engine { + variant part electric { attribute :>> power = 150.0; } + } + } + part sedan :> refined { part :>> engine = engine::electric; } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, "test::sedan")) + if err != nil { + t.Fatalf("Instantiate(test::sedan): %v", err) + } + if fv, err := inst.GetFeatureValue(ctx, "electric"); err == nil { + t.Errorf("sedan.electric materialized a feature value: %v", fv.Value) + } + fv, err := inst.GetFeatureValue(ctx, "engine") + if err != nil { + t.Fatalf("sedan.engine: %v", err) + } + if fv.Value.Kind != ValVariant || fv.Value.Instance == 0 { + t.Fatalf("sedan.engine = %v, want the selected variant's object", fv.Value) + } + power, err := ctx.instances[fv.Value.Instance].GetFeatureValue(ctx, "power") + if err != nil { + t.Fatalf("sedan.engine.power: %v", err) + } + if power.Value.Kind != ValConst || power.Value.Const.Real != 150.0 { + t.Errorf("sedan.engine.power = %v, want 150", power.Value) + } +} + +// oneSymbol returns the single symbol a qualified name denotes. +func oneSymbol(t *testing.T, idx *symbols.Index, fqn string) *symbols.Symbol { + t.Helper() + matches := idx.LookupQualified(fqn) + if len(matches) != 1 { + t.Fatalf("%s: %d matching symbols, want 1", fqn, len(matches)) + } + return matches[0] +} + +// testDeepSpecializationChainOfRedefinitions: a redefinition specializes the +// usage it redefines, so a long chain of them keeps every level's values and +// terminates instead of recursing while looking for the base's members. +func testDeepSpecializationChainOfRedefinitions(t *testing.T) { + const depth = 60 + var b strings.Builder + b.WriteString("package test {\n") + b.WriteString("\tpart def Inner { attribute a; attribute b; }\n") + b.WriteString("\tpart def Outer { part inner : Inner; attribute t = inner.b; }\n") + b.WriteString("\tpart level0 : Outer { part :>> inner { attribute :>> b = 7.0; } }\n") + for i := 1; i <= depth; i++ { + fmt.Fprintf(&b, "\tpart level%d :> level%d { part :>> inner { attribute :>> a = %d.0; } }\n", i, i-1, i) + } + b.WriteString("}\n") + + done := make(chan struct{}) + var got Value + var err error + go func() { + defer close(done) + got, err = variationFeatureValueInSource(t, b.String(), fmt.Sprintf("test::level%d", depth), "t") + }() + select { + case <-done: + case <-watchdog(30 * time.Second): + t.Fatal("reading an inherited value through a deep specialization chain hung") + } + if err != nil { + t.Fatalf("t = %v", err) + } + if got.Kind != ValConst || got.Const.Real != 7.0 { + t.Errorf("t = %+v, want the base's 7.0", got) + } +} + +// testConflictingRedefinitionsAtSeveralLevels: when several levels restate the +// same nested feature, the innermost restatement is the value read, and the +// levels above still supply what they alone declare. +func testConflictingRedefinitionsAtSeveralLevels(t *testing.T) { + src := ` + package test { + part def Inner { attribute a; attribute b; attribute c; } + part def Outer { part inner : Inner; attribute t = inner.c + inner.b + inner.a; } + part base : Outer { part :>> inner { attribute :>> a = 1.0; attribute :>> c = 100.0; } } + part middle :> base { part :>> inner { attribute :>> b = 20.0; attribute :>> c = 200.0; } } + part leaf :> middle { part :>> inner { attribute :>> c = 300.0; } } + }` + got, err := variationFeatureValueInSource(t, src, "test::leaf", "t") + if err != nil { + t.Fatalf("t = %v", err) + } + if got.Kind != ValConst || got.Const.Real != 321.0 { + t.Errorf("t = %+v, want 321.0 (innermost c, middle b, base a)", got) + } +} + +// testOneFeatureValuedUnderTwoNames: a redefinition renames one feature, so a +// declaration valuing both names has to be reported instead of picking one. +func testOneFeatureValuedUnderTwoNames(t *testing.T) { + src := ` + package test { + part def Ring { attribute ringCost; } + part def Band :> Ring { attribute bandCost :>> ringCost; } + part conflicted : Band { + attribute :>> bandCost = 400.0; + attribute :>> Ring::ringCost = 500.0; + } + }` + got, err := variationFeatureValueInSource(t, src, "test::conflicted", "bandCost") + if !errors.Is(err, ErrConflictingRedefinition) { + t.Fatalf("ringCost = %+v, err = %v, want ErrConflictingRedefinition", got, err) + } +} + +// testValuedFeatureRestatedInABody: a feature bound to a value takes its own +// features from that value, so a body restating one of them is reported instead +// of being dropped. +func testValuedFeatureRestatedInABody(t *testing.T) { + src := ` + package test { + attribute def Cost { attribute v = 1.0; } + part def Ring { attribute ringCost : Cost; } + part conflicted : Ring { + attribute :>> ringCost = 400.0 { attribute :>> v = 9.0; } + } + }` + got, err := variationFeatureValueInSource(t, src, "test::conflicted", "ringCost") + if !errors.Is(err, ErrValuedFeatureRestated) { + t.Fatalf("ringCost = %+v, err = %v, want ErrValuedFeatureRestated", got, err) + } +} + +// calcErrorWithLibraries invokes the named calc of package test in src, with the +// standard library indexed, and answers the error it fails with. +func calcErrorWithLibraries(t *testing.T, src, calcName string, args []Value, maxSteps int64) error { + t.Helper() + + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + ctx.maxSteps = maxSteps + sym, scope := calcByName(t, idx.DocumentRoot(""), "test", calcName) + + done := make(chan error, 1) + go func() { + result, err := ctx.InvokeCalc(sym, args, scope) + if err == nil { + err = fmt.Errorf("calc %s returned %s, expected it to fail", calcName, FormatTraceValue(result)) + } + done <- err + }() + + select { + case err := <-done: + return err + case <-watchdog(10 * time.Second): + t.Fatalf("calc %s did not terminate", calcName) + return nil + } +} + +// testBodyLocalUsageOfANonCalc: a body-local usage typed by something that is no +// calc is reported when the declaration is reached, not skipped. +func testBodyLocalUsageOfANonCalc(t *testing.T) { + src := ` + package test { + part def Thing; + calc def Holder { + in n : Integer; + attribute i : Integer = 0; + while i < n { + calc r : Thing; + assign i := i + 1; + } + i + } + } + ` + err := calcErrorWithLibraries(t, src, "Holder", []Value{constInt(1)}, 10000) + if !errors.Is(err, ErrNotACalc) { + t.Errorf("expected ErrNotACalc, got: %v", err) + } +} + +// testBodyLocalDeclarationNotExecutable: a declaration in a body the runtime has +// no execution for names itself rather than passing silently. +func testBodyLocalDeclarationNotExecutable(t *testing.T) { + src := ` + package test { + calc def Holder { + in n : Integer; + if n > 0 { + part broken; + } + n + } + } + ` + err := calcErrorWithLibraries(t, src, "Holder", []Value{constInt(1)}, 10000) + if err == nil || !strings.Contains(err.Error(), "broken") { + t.Errorf("error should name the declaration it cannot execute, got: %v", err) + } +} + +func testF99BodyMemberWithoutValue(t *testing.T) { + _, err := evalCollectionExprBounded(t, + "xs->collect { in i; private missing : Integer; missing }", 10000) + if !errors.Is(err, ErrNoValue) { + t.Fatalf("error = %v, want ErrNoValue", err) + } +} + +func testF99UnsupportedBodyMember(t *testing.T) { + _, err := evalCollectionExprBounded(t, + "xs->select { in i; private import ScalarValues::*; i > 0 }", 10000) + if !errors.Is(err, ErrUnsupportedBodyDeclaration) { + t.Fatalf("error = %v, want ErrUnsupportedBodyDeclaration", err) + } + if !strings.Contains(err.Error(), "Import") { + t.Errorf("unsupported-member error = %q, want it to name the form", err) + } +} + +func testF99CyclicBodyDeclaration(t *testing.T) { + _, err := evalCollectionExprBounded(t, + "xs->collect { in i; private attribute cycleValue : Integer = cycleValue; cycleValue }", 10000) + if !errors.Is(err, ErrCyclicFeatureValue) { + t.Fatalf("error = %v, want ErrCyclicFeatureValue", err) + } +} + +// testRangeBoundIsNotAnInteger: `..` declares Integer bounds, so a Real bound is +// the type mismatch it is rather than a truncated range. +func testRangeBoundIsNotAnInteger(t *testing.T) { + src := ` + package test { + calc def Span { + in n : Integer; + attribute r = 1.5..n; + n + } + } + ` + err := calcErrorWithLibraries(t, src, "Span", []Value{constInt(3)}, 10000) + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("expected ErrTypeMismatch, got: %v", err) + } +} + +// testRangeSpendsTheStepBudget: each element a range generates costs a step, so +// a range too large to hold fails the run rather than exhausting memory. +func testRangeSpendsTheStepBudget(t *testing.T) { + src := ` + package test { + calc def Span { + in n : Integer; + attribute r = 1..1000000; + n + } + } + ` + err := calcErrorWithLibraries(t, src, "Span", []Value{constInt(3)}, 100) + if !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("expected ErrStepLimitExceeded, got: %v", err) + } +} + +// testCollectionSpendsTheElementBudget: a materialized element is memory the +// collection keeps, so it has its own ceiling and its own error rather than +// reading as the step budget's. +func testCollectionSpendsTheElementBudget(t *testing.T) { + src := ` + package test { + calc def Span { + in n : Integer; + attribute r = (1..1000)->collect{in i; i * i}; + n + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + ctx.maxElements = 100 + sym, scope := calcByName(t, idx.DocumentRoot(""), "test", "Span") + _, err := ctx.InvokeCalc(sym, []Value{constInt(3)}, scope) + if err == nil { + t.Fatal("want the element budget's error, got a value") + } + if !errors.Is(err, ErrElementLimitExceeded) { + t.Errorf("expected ErrElementLimitExceeded, got: %v", err) + } + if errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("error %q also reads as the step budget's", err) + } + if !strings.Contains(err.Error(), MaxElementsEnvVar) { + t.Errorf("error %q does not name %s", err, MaxElementsEnvVar) + } +} + +// testUsageReadThroughAPartWithoutAnOutput: a chain through a part that stops at +// a calc usage names the outputs to read instead of answering no value. +func testUsageReadThroughAPartWithoutAnOutput(t *testing.T) { + src := ` + package test { + calc def Two { + in n : Integer; + out a = n + 1; + out b = n * 2; + } + part holder { + calc c : Two { in n = 5; } + } + calc def Probe { + in n : Integer; + holder.c + } + } + ` + err := calcErrorWithLibraries(t, src, "Probe", []Value{constInt(1)}, 10000) + if !errors.Is(err, ErrNoValue) { + t.Errorf("expected ErrNoValue, got: %v", err) + } + if err != nil && !strings.Contains(err.Error(), "a, b") { + t.Errorf("error should name the outputs to read, got: %v", err) + } +} + +// testPerformedActionBindingNamesNothing: a performed action's binding names a +// part that does not exist, reported as the unresolved reference it is rather +// than materializing the performer without the action's input. +func testPerformedActionBindingNamesNothing(t *testing.T) { + src := ` + package test { + part def Counter { attribute count : Integer = 4; } + action def Bump { + in c : Counter; + out n : Integer = c.count + 1; + } + part machine { + perform action p : Bump { in c = nowhere; } + } + calc def Probe { + in n : Integer; + machine.p.n + } + } + ` + err := calcErrorWithLibraries(t, src, "Probe", []Value{constInt(1)}, 10000) + if !errors.Is(err, ErrUnresolvedReference) { + t.Errorf("expected ErrUnresolvedReference, got: %v", err) + } + if err != nil && !strings.Contains(err.Error(), "nowhere") { + t.Errorf("error should name the binding that resolves to nothing, got: %v", err) + } +} + +// testBindingEndOfADestroyedObject: a binding end naming a destroyed object's +// feature is ErrOccurrenceDestroyed, never a stale read of it or a write into it. +func testBindingEndOfADestroyedObject(t *testing.T) { + instantiate, invoke, ctx := lifetimeFixture(t, ` + package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + part def Widget { attribute n : Integer = 1; attribute m : Integer; } + part def Rig { + part w : Widget; + attribute shown : Integer; + bind shown = w.n; + attribute knob : Integer = 9; + bind w.m = knob; + } + calc def DestroyWidget { in w : Widget; return : Widget = destroy(w); } + } + `) + rig := instantiate("Rig") + fv, err := rig.GetFeatureValue(ctx, "w") + if err != nil { + t.Fatal(err) + } + id, _ := fv.HeldValue().Object() + w, _ := ctx.getInstance(id) + if _, err := invoke("DestroyWidget", objectValue(w)); err != nil { + t.Fatalf("destroy(w): %v", err) + } + for _, name := range []string{"shown", "knob"} { + _, err := rig.GetFeatureValue(ctx, name) + if !errors.Is(err, ErrOccurrenceDestroyed) || !errors.Is(err, ErrBindingEnd) { + t.Errorf("%s bound to a destroyed end: %v; want %v naming the binding end", name, err, ErrOccurrenceDestroyed) + } + } + if m := w.FeatureValues["m"]; m.Materialized { + t.Errorf("destroyed w.m = %s; want it left unwritten", FormatValue(m.HeldValue())) + } +} + +// testOperationOfADestroyedObject: an operation invoked on a destroyed object is +// ErrOccurrenceDestroyed before it runs, even one reading no feature of it. +func testOperationOfADestroyedObject(t *testing.T) { + instantiate, invoke, ctx := lifetimeFixture(t, ` + package test { + private import OccurrenceFunctions::*; + item def Ping; + part def Beacon { action ping { first start; action fire { send Ping to tower; } succession first start then fire; } } + calc def DestroyBeacon { in b : Beacon; return : Beacon = destroy(b); } + } + `) + beacon := instantiate("Beacon") + if _, err := invoke("DestroyBeacon", objectValue(beacon)); err != nil { + t.Fatalf("destroy(beacon): %v", err) + } + if _, err := ctx.InvokeOperation(beacon, "ping", nil); !errors.Is(err, ErrOccurrenceDestroyed) { + t.Errorf("invoke ping on a destroyed object = %v; want %v", err, ErrOccurrenceDestroyed) + } + if sent := len(ctx.PendingMessages()); sent != 0 { + t.Errorf("a destroyed object sent %d message(s); want none", sent) + } +} + +// testNoFlowPerformedActionChecksItsInputs: an action stating no flow takes its +// inputs as a flowed one does, so a binding it cannot take fails the performer. +func testNoFlowPerformedActionChecksItsInputs(t *testing.T) { + for name, tc := range map[string]struct { + binding string + want error + }{ + "output": {"in r = 5;", ErrOutputActionInput}, + "unknown": {"in bogus = 5;", ErrUnknownActionInput}, + } { + src := ` + package test { + private import ScalarValues::*; + action def Report { in n : Integer; out r : Integer; } + part def Camera { perform action report : Report { ` + tc.binding + ` } } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + _, err := ctx.Instantiate(idx.LookupQualified("test::Camera")[0]) + if !errors.Is(err, tc.want) { + t.Errorf("%s: Instantiate(Camera) = %v; want %v", name, err, tc.want) + } + } +} + +// testNoFlowPerformedActionRefusesReturnParameter: an action stating no flow is +// refused for a `return` parameter as a flowed one is, so its performer fails. +func testNoFlowPerformedActionRefusesReturnParameter(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + action def Report { return r : Integer; } + part def Camera { perform action report : Report; } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + _, err := ctx.Instantiate(idx.LookupQualified("test::Camera")[0]) + if !errors.Is(err, ErrActionResultParameter) { + t.Fatalf("Instantiate(Camera) = %v; want ErrActionResultParameter", err) + } + if !strings.Contains(err.Error(), "declares `return r`; write `out r`") { + t.Errorf("err = %v; want it to name the parameter and the fix", err) + } +} + +// testStructuredAttributeChainOfAnUnknownFeature: a structured attribute usage +// holds the features of its type, so a chain into one it does not have is +// reported rather than answered as an empty value. +func testStructuredAttributeChainOfAnUnknownFeature(t *testing.T) { + src := ` + package test { + attribute def Cost { attribute v : Real default 1.0; } + attribute template : Cost; + calc def Probe { + in n : Integer; + template.nope + } + } + ` + err := calcErrorWithLibraries(t, src, "Probe", []Value{constInt(1)}, 10000) + if err == nil { + t.Fatal("expected chaining into an unknown feature to fail") + } + if !strings.Contains(err.Error(), "nope") { + t.Errorf("error should name the feature that does not exist, got: %v", err) + } +} + +// testElementsChainOfANonNumericCollection: only a numerical vector is the +// sequence of its own elements, so `elements` of another collection is a member +// lookup on each of them, reported rather than answered with the collection. +func testElementsChainOfANonNumericCollection(t *testing.T) { + src := ` + package test { + calc def Probe { + in n : Integer; + return : String[*] = ("a", "b").elements; + } + } + ` + err := calcErrorWithLibraries(t, src, "Probe", []Value{constInt(1)}, 10000) + if err == nil { + t.Fatal("expected chaining elements of a non-numeric collection to fail") + } + if !strings.Contains(err.Error(), "cannot chain through non-instance member") { + t.Errorf("error should report the member lookup, got: %v", err) + } +} + +// testEnumerationNameThatIsNotALiteral: a name qualified by an enumeration +// designates one of its literals, so one it does not declare is reported with +// the literals it does, never answered as an empty value. +func testEnumerationNameThatIsNotALiteral(t *testing.T) { + src := ` + package test { + enum def Color { red; green; blue; } + part def Car { attribute c : Color = Color::purple; } + }` + got, err := variationFeatureValueInSource(t, src, "test::Car", "c") + if !errors.Is(err, ErrNotALiteral) { + t.Fatalf("c = (%v, %v), want ErrNotALiteral", got, err) + } + for _, name := range []string{"purple", "red", "green", "blue"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("error %q does not name %s", err, name) + } + } +} + +// testChainThroughALiteralWithoutThatAttribute: a literal carries only the +// features it declares, so reading another one off it is reported rather than +// materializing an empty feature value. +func testChainThroughALiteralWithoutThatAttribute(t *testing.T) { + src := ` + package test { + enum def Level { low { attribute n = 1; } high { attribute n = 9; } } + part def Sensor { attribute missing = Level::low.label; } + }` + got, err := variationFeatureValueInSource(t, src, "test::Sensor", "missing") + if err == nil { + t.Fatalf("missing = %v, want an error naming the unknown member", got) + } + if !strings.Contains(err.Error(), "label") { + t.Errorf("error %q does not name the unknown member", err) + } +} + +// testObjectExhibitedMachineNeverSettles: a machine an object exhibits is bounded +// by the same budgets as one run on its own, so materializing an object whose +// machine never settles reports a budget error rather than spinning. +func testObjectExhibitedMachineNeverSettles(t *testing.T) { + src := ` + package test { + part def Spinner { + attribute ticks : Integer = 0; + exhibit state modes { + entry; then spin; + state spin { + do action tick { assign ticks := ticks + 1; } + } + transition again first spin then spin; + } + } + }` + _, _, err := instantiateInSource(t, src, "test::Spinner") + if err == nil { + t.Fatal("expected a budget error for an exhibited machine that never settles") + } + if !strings.Contains(err.Error(), "exceeded max") && !errors.Is(err, ErrStepLimitExceeded) { + t.Errorf("error = %v, want a budget error", err) + } +} + +// testObjectExhibitedMachineWithoutAnInitialState: a machine stating states but no +// entry into them is reported when the object's execution of it initializes, with +// the behavior and the type named. +func testObjectExhibitedMachineWithoutAnInitialState(t *testing.T) { + src := ` + package test { + part def Controller { + exhibit state modes { + state off; + state on; + } + } + }` + _, _, err := instantiateInSource(t, src, "test::Controller") + if err == nil { + t.Fatal("expected an error for a machine with no initial state") + } + if !strings.Contains(err.Error(), "modes") || !strings.Contains(err.Error(), "initial") { + t.Errorf("error = %v, want one naming the machine and its missing initial state", err) + } +} + +// testObjectExhibitedMachineWhoseOnlyEdgeIsAFirstSuccession: `first b then c;` +// orders two states and enters neither, so a machine with no entry marker is +// reported as having no initial state rather than started in c. +func testObjectExhibitedMachineWhoseOnlyEdgeIsAFirstSuccession(t *testing.T) { + src := ` + package test { + part def Controller { + exhibit state modes { + state b; + state c; + first b then c; + } + } + }` + _, _, err := instantiateInSource(t, src, "test::Controller") + if err == nil { + t.Fatal("expected an error for a machine with no initial state") + } + if !strings.Contains(err.Error(), "modes") || !strings.Contains(err.Error(), "initial") { + t.Errorf("error = %v, want one naming the machine and its missing initial state", err) + } +} + +// testObjectExhibitedMachineAttributeWriteViolatesMultiplicity checks that an +// occurrence write failure remains typed and terminates normally. +func testObjectExhibitedMachineAttributeWriteViolatesMultiplicity(t *testing.T) { + src := ` + package test { + state def Modes { + attribute samples : Integer[2] nonunique = (0, 0); + entry; then active; + state active { + entry action record { + assign samples := 1; + } + } + } + part def Controller { + exhibit state modes : Modes; + } + }` + _, _, err := instantiateInSource(t, src, "test::Controller") + if !errors.Is(err, ErrStatePerformanceOccurrence) { + t.Fatalf("error = %v, want ErrStatePerformanceOccurrence", err) + } + if !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("error = %v, want ErrMultiplicityViolation", err) + } +} + +// testObjectPerformedActionAttributeWriteViolatesMultiplicity checks that a +// performed action's occurrence write failure remains typed. +func testObjectPerformedActionAttributeWriteViolatesMultiplicity(t *testing.T) { + src := ` + package test { + action def Record { + attribute samples : Integer[2] nonunique = (0, 0); + action step { + assign samples := 1; + } + first step; + } + part def Logger { + perform action recording : Record; + } + }` + _, _, err := instantiateInSource(t, src, "test::Logger") + if !errors.Is(err, ErrActionPerformanceOccurrence) { + t.Fatalf("error = %v, want ErrActionPerformanceOccurrence", err) + } + if !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("error = %v, want ErrMultiplicityViolation", err) + } +} + +// testObjectPerformedActionOccurrenceHoldsANonObject: a perform usage whose +// feature holds a value that is not an occurrence is reported, not performed. +func testObjectPerformedActionOccurrenceHoldsANonObject(t *testing.T) { + src := ` + package test { + action def Bump { + attribute count : Integer = 0; + action step { + assign count := count + 1; + } + first step; + } + part def Host { + perform action work : Bump; + } + }` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + inst, err := ctx.materialize(oneSymbol(t, idx, "test::Host"), 0, nil, "") + if err != nil { + t.Fatalf("materialize: %v", err) + } + fv, ok := inst.FeatureValues["work"] + if !ok { + t.Fatal("object has no work feature") + } + fv.Value = Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 5}} + fv.Materialized = true + + err = ctx.startClassifierBehaviors(inst, 0) + if !errors.Is(err, ErrActionPerformanceOccurrence) { + t.Fatalf("error = %v, want ErrActionPerformanceOccurrence", err) + } + if !strings.Contains(err.Error(), "not an occurrence") { + t.Errorf("error = %v, want one naming the value the feature holds", err) + } +} + +// testOperationInvokedWithUnboundParameters: an operation invoked without a value +// for a parameter, or with an argument naming none, is reported rather than run +// against values the invocation never stated. +func testOperationInvokedWithUnboundParameters(t *testing.T) { + src := ` + package test { + part def Adder { + attribute total : Integer = 0; + action add { + in addend : Integer; + assign total := total + addend; + } + } + }` + ctx, inst, err := instantiateInSource(t, src, "test::Adder") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + + if _, err := ctx.InvokeOperation(inst, "add", nil); !errors.Is(err, ErrUnboundParameter) { + t.Errorf("invoke without an argument = %v, want ErrUnboundParameter", err) + } + args := map[string]Value{"addend": integerValue(1), "extra": integerValue(2)} + if _, err := ctx.InvokeOperation(inst, "add", args); !errors.Is(err, ErrUnboundParameter) { + t.Errorf("invoke with an unknown argument = %v, want ErrUnboundParameter", err) + } + if _, err := ctx.InvokeOperation(inst, "missing", nil); !errors.Is(err, ErrNoSuchBehavior) { + t.Errorf("invoke of an unknown operation = %v, want ErrNoSuchBehavior", err) + } + if _, err := ctx.InvokeOperation(inst, "total", nil); !errors.Is(err, ErrNotABehavior) { + t.Errorf("invoke of an attribute = %v, want ErrNotABehavior", err) + } +} + +// testOperationConstraintBodyCannotBeEvaluated: invoking a constraint whose +// assertion names no feature returns its evaluation error without panicking. +func testOperationConstraintBodyCannotBeEvaluated(t *testing.T) { + src := ` + package test { + part def Tank { + constraint broken { missing > 0 } + } + }` + ctx, inst, err := instantiateInSource(t, src, "test::Tank") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + _, err = ctx.InvokeOperation(inst, "broken", nil) + if err == nil { + t.Fatal("expected an error for an unevaluable constraint body") + } + if !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("error = %v, want ErrUnresolvedReference", err) + } +} + +// testSecondInstantiationOfOneType: materializing a type twice builds two objects, +// each with its own execution of the machine the type exhibits, rather than reusing +// or replacing the first object's. +func testSecondInstantiationOfOneType(t *testing.T) { + src := ` + package test { + part def Light { + attribute lit : Integer = 0; + exhibit state modes { + entry; then on; + state on { entry action mark { assign lit := 1; } } + } + } + }` + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + sym := oneSymbol(t, idx, "test::Light") + + first, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("first instantiate: %v", err) + } + second, err := ctx.Instantiate(sym) + if err != nil { + t.Fatalf("second instantiate: %v", err) + } + if first.ID == second.ID { + t.Fatalf("both objects have identity %d", first.ID) + } + firstMachine, ok := first.ExhibitedState() + if !ok { + t.Fatal("first object exhibits no machine") + } + secondMachine, ok := second.ExhibitedState() + if !ok { + t.Fatal("second object exhibits no machine") + } + if firstMachine.State == secondMachine.State { + t.Error("both objects share one machine execution") + } + for _, obj := range []*Instance{first, second} { + fv, err := obj.GetFeatureValue(ctx, "lit") + if err != nil { + t.Fatalf("lit of object #%d: %v", obj.ID, err) + } + if fv.HeldValue().Const.Int != 1 { + t.Errorf("lit of object #%d = %v, want 1", obj.ID, fv.HeldValue().Const) + } + } +} + +// instantiateInSource materializes the named type declared in src, so a case can +// state the failure materializing an object reports. +func instantiateInSource(t *testing.T, src, fqn string) (*Context, *Instance, error) { + t.Helper() + file := parseAndBuild(t, src) + if file == nil { + t.Fatal("parse failed") + } + idx, _, ctx := buildRuntime(t, "", file) + inst, err := ctx.Instantiate(oneSymbol(t, idx, fqn)) + return ctx, inst, err +} + +// instantiateWithLibraries materializes the named type declared in src over an +// index carrying the standard library, so a case may name its scalar types. +func instantiateWithLibraries(t *testing.T, src, fqn string) (*Context, *Instance, error) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + inst, err := ctx.Instantiate(oneSymbol(t, idx, fqn)) + return ctx, inst, err +} + +// testWriteOfAWrongTypedValueLeavesTheFeature: a value the feature's type does +// not admit is rejected before the write, so the feature keeps what it held. +func testWriteOfAWrongTypedValueLeavesTheFeature(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Rig { + attribute reading : Real = 0.5; + } + }` + ctx, inst, err := instantiateWithLibraries(t, src, "test::Rig") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + err = inst.SetFeatureValue(ctx, "reading", NewStringValue("not a number")) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } + for _, want := range []string{"reading", "Real", "not a number"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %v, want it to name %s", err, want) + } + } + fv, err := inst.GetFeatureValue(ctx, "reading") + if err != nil { + t.Fatalf("read reading after the rejected write: %v", err) + } + if got := fv.HeldValue(); got.Kind != ValConst || got.Const.Real != 0.5 { + t.Errorf("reading = %v, want the 0.5 it held before the rejected write", FormatValue(got)) + } +} + +// testWriteOfTooManyValuesLeavesTheFeature: a written collection the target's +// multiplicity does not admit is rejected, and the feature keeps its values. +func testWriteOfTooManyValuesLeavesTheFeature(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Rig { + attribute samples : Integer[2] = (1, 2); + } + }` + ctx, inst, err := instantiateWithLibraries(t, src, "test::Rig") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + tooMany := sequenceOf([]Value{constInt(1), constInt(2), constInt(3)}) + if err := inst.SetFeatureValue(ctx, "samples", tooMany); !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("error = %v, want ErrMultiplicityViolation", err) + } + fv, err := inst.GetFeatureValue(ctx, "samples") + if err != nil { + t.Fatalf("read samples after the rejected write: %v", err) + } + if got := len(elementsOf(fv.HeldValue())); got != 2 { + t.Errorf("samples holds %d value(s), want the 2 it held before the rejected write", got) + } +} + +// testWriteOfARepeatedValueLeavesTheFeature: a repeat written to a unique feature +// is refused after count and type, leaving its value; nonunique takes it, a set drops it. +func testWriteOfARepeatedValueLeavesTheFeature(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + private import Collections::*; + part def Rig { + attribute xs : Integer[*] = (1, 2); + attribute ordered : Integer[*] ordered = (1, 2); + attribute bounded : Integer[0..2] = (1, 2); + attribute repeats : Integer[*] nonunique = (1, 1); + attribute members : Set { :>> elements = (1, 2); } + } + }` + ctx, inst, err := instantiateWithLibraries(t, src, "test::Rig") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + repeated := sequenceOf([]Value{constInt(3), constInt(4), constInt(3)}) + for _, feature := range []string{"xs", "ordered"} { + err := inst.SetFeatureValue(ctx, feature, repeated) + if !errors.Is(err, ErrUniquenessViolation) { + t.Fatalf("%s: error = %v, want ErrUniquenessViolation", feature, err) + } + if want := "3 (an Integer) is written at positions 1 and 3 of a unique feature"; !strings.Contains(err.Error(), want) { + t.Errorf("%s: error = %v, want it to say %q", feature, err, want) + } + fv, err := inst.GetFeatureValue(ctx, feature) + if err != nil { + t.Fatalf("read %s after the rejected write: %v", feature, err) + } + if got := intsOf(t, fv.HeldValue()); !equalInts(got, []int64{1, 2}) { + t.Errorf("%s = %v, want the (1, 2) it held before the rejected write", feature, got) + } + } + if err := inst.SetFeatureValue(ctx, "bounded", repeated); !errors.Is(err, ErrMultiplicityViolation) { + t.Errorf("bounded: error = %v, want ErrMultiplicityViolation before the repeat is judged", err) + } + wrongType := sequenceOf([]Value{NewStringValue("a"), NewStringValue("a")}) + if err := inst.SetFeatureValue(ctx, "xs", wrongType); !errors.Is(err, ErrTypeMismatch) { + t.Errorf("xs: error = %v, want ErrTypeMismatch before the repeat is judged", err) + } + if err := inst.SetFeatureValue(ctx, "repeats", repeated); err != nil { + t.Errorf("repeats: error = %v, want a nonunique feature to take the repeat", err) + } + if err := inst.SetFeatureValue(ctx, "ordered", sequenceOf([]Value{constInt(3), constInt(1), constInt(2)})); err != nil { + t.Fatalf("ordered: error = %v, want distinct values to be written", err) + } + if fv, _ := inst.GetFeatureValue(ctx, "ordered"); !equalInts(intsOf(t, fv.HeldValue()), []int64{3, 1, 2}) { + t.Errorf("ordered = %s, want (3, 1, 2) in the order written", FormatValue(fv.HeldValue())) + } + members, err := inst.GetFeatureValue(ctx, "members") + if err != nil { + t.Fatalf("members: %v", err) + } + set, ok := members.HeldValue().Object() + if !ok { + t.Fatalf("members = %s, want a Set object", FormatValue(members.HeldValue())) + } + if err := ctx.instances[set].SetFeatureValue(ctx, "elements", repeated); err != nil { + t.Errorf("members.elements: error = %v, want a set to drop the repeat", err) + } + if fv, _ := ctx.instances[set].GetFeatureValue(ctx, "elements"); fv.HeldValue().Kind != ValSet || len(elementsOf(fv.HeldValue())) != 2 { + t.Errorf("members.elements = %s, want the set {3, 4}", FormatValue(fv.HeldValue())) + } + + actionSrc := ` + package test { + private import ScalarValues::*; + action w { + attribute xs : Integer[*] ordered = (1, 2); + attribute n : Integer = 5; + first start; + action step { assign xs := (n, 6, n); } + done; + succession first start then step; + succession first step then done; + } + }` + idx, _, actx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, actionSrc)) + if _, err := actx.ExecuteAction(findSymbolByName(idx.DocumentRoot(""), "w", ast.DefAction)); !errors.Is(err, ErrUniquenessViolation) { + t.Errorf("assign: error = %v, want ErrUniquenessViolation", err) + } + + calcSrc := ` + package test { + private import ScalarValues::*; + private import SequenceFunctions::size; + calc def Pass { in xs : Integer[*]; return : Integer[*] = xs; } + calc def Twice { in x : Integer; return : Integer[*] = (x, x); } + calc def Local { in xs : Integer[*] nonunique; attribute ys : Integer[*] = xs; return : Integer = size(ys); } + calc def LocalRepeats { in xs : Integer[*] nonunique; attribute ys : Integer[*] nonunique = xs; return : Integer = size(ys); } + }` + for name, args := range map[string][]Value{"Pass": {repeated}, "Twice": {constInt(7)}, "Local": {repeated}} { + if err := calcErrorWithLibraries(t, calcSrc, name, args, 10000); !errors.Is(err, ErrUniquenessViolation) { + t.Errorf("%s: error = %v, want ErrUniquenessViolation", name, err) + } + } + cidx, _, cctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, calcSrc)) + sym, scope := calcByName(t, cidx.DocumentRoot(""), "test", "LocalRepeats") + if result, err := cctx.InvokeCalc(sym, []Value{repeated}, scope); err != nil || FormatTraceValue(result) != "3" { + t.Errorf("LocalRepeats = %s, %v; want 3: a nonunique local takes the repeat", FormatTraceValue(result), err) + } +} + +// testWriteOfNoValueWhereOneIsRequired: an empty collection written to a feature +// whose lower bound is one is a multiplicity violation, not an unset feature. +func testWriteOfNoValueWhereOneIsRequired(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Rig { + attribute reading : Real[1] = 0.5; + } + }` + ctx, inst, err := instantiateWithLibraries(t, src, "test::Rig") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + if err := inst.SetFeatureValue(ctx, "reading", sequenceOf(nil)); !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("error = %v, want ErrMultiplicityViolation", err) + } + fv, err := inst.GetFeatureValue(ctx, "reading") + if err != nil { + t.Fatalf("read reading after the rejected write: %v", err) + } + if got := fv.HeldValue(); got.Kind != ValConst || got.Const.Real != 0.5 { + t.Errorf("reading = %v, want the 0.5 it held before the rejected write", FormatValue(got)) + } +} + +// testStateEntryWriteOfAWrongTypedValue: a write in a state's entry action is +// judged against the feature it names, however deep in the machine it stands. +func testStateEntryWriteOfAWrongTypedValue(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Rig { + attribute reading : Real = 0.0; + exhibit state run { + entry; then go; + state go { + entry action set { + assign reading := "not a number"; + } + } + } + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Rig") + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } + if !strings.Contains(err.Error(), "Real") { + t.Errorf("error = %v, want it to name the declared type", err) + } +} + +// testPerformerFeatureWriteOfAWrongTypedValue: a write reaching a feature of the +// object performing the behavior is judged against that feature's declaration. +func testPerformerFeatureWriteOfAWrongTypedValue(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Rig { + attribute label : String = "a"; + perform action set { + action step { + assign label := 7; + } + first step; + } + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Rig") + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } + if !strings.Contains(err.Error(), "String") { + t.Errorf("error = %v, want it to name the declared type", err) + } +} + +// A body written on its own resolves in its own namespace, so a name only the +// performing object declares is unresolved at run time as it is to analysis. +func testStandaloneActionNamingAPerformerFeature(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + action def Touch { + action step { + assign touched := touched + 1; + } + first step; + } + part def Host { + attribute touched : Integer = 0; + perform action t : Touch; + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Host") + if !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("error = %v, want ErrUnresolvedReference", err) + } + if !strings.Contains(err.Error(), "touched") { + t.Errorf("error = %v, want it to name the unresolved feature", err) + } +} + +// The write side refuses the same name the read side refuses, rather than +// reaching into the performing object for a name the body cannot see. +func testStandaloneActionWritingAPerformerFeature(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + action def Touch { + action step { + assign touched := 1; + } + first step; + } + part def Host { + attribute touched : Integer = 0; + perform action t : Touch; + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Host") + if !errors.Is(err, ErrPerformerFeatureNotInScope) { + t.Fatalf("error = %v, want ErrPerformerFeatureNotInScope", err) + } + if !strings.Contains(err.Error(), "touched") { + t.Errorf("error = %v, want it to name the refused feature", err) + } +} + +// `this` in a body written on its own names the performance itself, which no +// object owns, so a feature of the performer is not reachable through it. +func testStandaloneActionNamingThisOfAnUnownedPerformance(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + action def Touch { + action step { + assign this.touched := 1; + } + first step; + } + part def Host { + attribute touched : Integer = 0; + perform action t : Touch; + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Host") + if !errors.Is(err, ErrThisNotAnObject) { + t.Fatalf("error = %v, want ErrThisNotAnObject", err) + } +} + +// testChainedWriteOfAWrongTypedValue: a write through a feature chain answers to +// the declaration of the feature the chain reaches. +func testChainedWriteOfAWrongTypedValue(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Cell { + attribute mark : Integer = 0; + } + part def Rig { + part cell : Cell; + exhibit state run { + entry; then marking; + state marking { + entry action set { + assign cell.mark := "seven"; + } + } + } + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Rig") + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } + if !strings.Contains(err.Error(), "mark") { + t.Errorf("error = %v, want it to name the feature written", err) + } +} + +// testCalcOutputWriteOfAWrongTypedValue: an output a calculation body binds is a +// feature too, so what it is bound to conforms to its declared type. +func testCalcOutputWriteOfAWrongTypedValue(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + calc def Label { + out tag : String; + assign tag := 7; + } + }` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + calc := oneSymbol(t, idx, "test::Label") + _, err := ctx.InvokeCalc(calc, nil, idx.DocumentRoot("")) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } +} + +// testActionLocalWriteOfAWrongTypedValue: a value a body declares of its own is +// declared with a type, so a write to it answers to that type. +func testActionLocalWriteOfAWrongTypedValue(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + action def Compute { + out total : Integer; + action step { + attribute held : Integer = 0; + assign held := "seven"; + assign total := held; + } + first step; + } + }` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + _, err := ctx.ExecuteAction(oneSymbol(t, idx, "test::Compute")) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } +} + +// testActionOutputWriteOfAWrongTypedValue: binding an output binds a feature, so +// the value bound conforms to the type the output was declared with. +func testActionOutputWriteOfAWrongTypedValue(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + action def Compute { + out total : Integer; + action step { + assign total := "seven"; + } + first step; + } + }` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + _, err := ctx.ExecuteAction(oneSymbol(t, idx, "test::Compute")) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } +} + +// testPerformanceOccurrenceWriteOfAWrongTypedValue: an attribute of a performed +// action is a feature of its performance occurrence, and a write to it is judged +// against that feature's declaration. +func testPerformanceOccurrenceWriteOfAWrongTypedValue(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + action def Record { + attribute sample : Integer = 0; + action step { + assign sample := "seven"; + } + first step; + } + part def Logger { + perform action recording : Record; + } + }` + _, _, err := instantiateWithLibraries(t, src, "test::Logger") + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } +} + +// testNestedFlowWithoutAnInitialNode: a node stating a flow with no node to +// start at — a cycle, every node preceded — is reported at initialize(), not +// run as a leaf. +func testNestedFlowWithoutAnInitialNode(t *testing.T) { + src := ` + package test { + action outer { + first leg; + action leg { + action a; + action b; + succession first a then b; + succession first b then a; + } + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + + // The constructor is permissive; the flow's failure surfaces at initialize(). + exec, err := newActionExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newActionExecutor: %v", err) + } + err = exec.initialize() + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("error = %v, want ErrInvalidActionFlow", err) + } + if !strings.Contains(err.Error(), "leg") { + t.Errorf("error = %v, want it to name the node whose flow cannot be built", err) + } +} + +// testNestedFlowWithADanglingSuccession: a succession inside a node's own flow +// naming nothing is that node's error, reported rather than dropped. +func testNestedFlowWithADanglingSuccession(t *testing.T) { + src := ` + package test { + action outer { + first leg; + action leg { + first a; + action a; + succession first a then missing; + } + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + + exec, err := newActionExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newActionExecutor: %v", err) + } + if err := exec.initialize(); !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("error = %v, want ErrInvalidActionFlow", err) + } +} + +// testNestedFlowThatCannotProgress: a join inside a node's own flow that can +// never be reached by both its tokens deadlocks the run, reported rather than hung. +func testNestedFlowThatCannotProgress(t *testing.T) { + src := ` + package test { + action outer { + first leg; + action leg { + first s; + action s; + action stranded; + join sync; + done; + succession first s then sync; + succession first stranded then sync; + succession first sync then done; + } + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + if err := exec.RunToCompletion(); !errors.Is(err, ErrActionDeadlock) { + t.Fatalf("error = %v, want ErrActionDeadlock", err) + } +} + +// testNestedFlowThatNeverEnds: a cycle inside a node's own flow spends the +// action's step budget rather than running forever. +func testNestedFlowThatNeverEnds(t *testing.T) { + src := ` + package test { + action outer { + first leg; + action leg { + first a; + action a; + action b; + succession first a then b; + succession first b then a; + } + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + ctx.maxActionSteps = 1000 + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + if err := exec.RunToCompletion(); !errors.Is(err, ErrActionStepLimitExceeded) { + t.Fatalf("error = %v, want ErrActionStepLimitExceeded", err) + } +} + +// runOuterAction runs action test::outer of src to completion and returns the +// error, for a case whose contract is the error a nested node reports. +func runOuterAction(t *testing.T, src string) error { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + return exec.RunToCompletion() +} + +const adderActionDef = ` + action def Adder { + in a : Integer; + in b : Integer; + out sum : Integer; + first step; + action step { assign sum := a + b; } + } +` + +// testNodePinOfANodeNotYetPerformed: a pin holds a value only once its node has +// been performed, so reading it earlier is reported rather than answered. +func testNodePinOfANodeNotYetPerformed(t *testing.T) { + src := ` + package test { + action outer { + attribute total : Integer = 0; + first start; + then action early { assign total := late.v; } + then action late { out v : Integer; assign v := 1; } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrNodeNotPerformed) { + t.Fatalf("error = %v, want ErrNodeNotPerformed", err) + } + if !strings.Contains(err.Error(), "late") { + t.Errorf("error %q does not name the node", err) + } +} + +// testNodeOutputBoundToANestedNodeThatNeverRuns: a node's output bound to a pin of one +// of its own nested nodes takes its value as that node ends, so where the nested node +// never runs the output is unvalued when its node ends, and reported so. +func testNodeOutputBoundToANestedNodeThatNeverRuns(t *testing.T) { + src := ` + package test { + action outer { + out attribute legV : Integer; + bind leg.inner.v = leg.v; + first start; + then action leg { + out v : Integer; + action inner { out v : Integer; assign v := 1; } + first start; + then action own { assign legV := 0; } + then done; + } + then action fin { assign legV := leg.v; } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrBindingEnd) { + t.Fatalf("error = %v, want ErrBindingEnd", err) + } + if !strings.Contains(err.Error(), "leg.inner.v") { + t.Errorf("error %q does not name the other end", err) + } +} + +// testNodePinTheNodeDoesNotDeclare: a chain through a node names one of its +// pins; a name it does not declare is reported with the node it was read from. +func testNodePinTheNodeDoesNotDeclare(t *testing.T) { + src := ` + package test { + action outer { + attribute total : Integer = 0; + first start; + then action p { out v : Integer; assign v := 1; } + then action fin { assign total := p.w; } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrNodePin) { + t.Fatalf("error = %v, want ErrNodePin", err) + } + if !strings.Contains(err.Error(), "p") || !strings.Contains(err.Error(), "w") { + t.Errorf("error %q does not name the node and the pin", err) + } +} + +// testBlockNodePinOfANodeNotYetPerformed: a node declared in a branch is a +// performance of its own like any other, so reading its pin from a sibling that +// runs before it is reported the same way. +func testBlockNodePinOfANodeNotYetPerformed(t *testing.T) { + src := ` + package test { + action outer { + attribute total : Integer = 0; + first start; + then action run { + if true { + action early { assign total := late.v; } + action late { out v : Integer; assign v := 1; } + } + } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrNodeNotPerformed) { + t.Fatalf("error = %v, want ErrNodeNotPerformed", err) + } + if !strings.Contains(err.Error(), "late") { + t.Errorf("error %q does not name the node", err) + } +} + +// testBlockNodePinTheNodeDoesNotDeclare: a pin read through a node declared in +// a loop body that the node does not declare is reported with the node. +func testBlockNodePinTheNodeDoesNotDeclare(t *testing.T) { + src := ` + package test { + action outer { + attribute total : Integer = 0; + first start; + then action run { + for i in 1..2 { + action p { out v : Integer; assign v := i; } + assign total := total + p.w; + } + } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrNodePin) { + t.Fatalf("error = %v, want ErrNodePin", err) + } + if !strings.Contains(err.Error(), "p") || !strings.Contains(err.Error(), "w") { + t.Errorf("error %q does not name the node and the pin", err) + } +} + +// testElseBranchNodeReadBeforeItPerforms: an else branch's `p` is the one its +// own reads name even where the then branch declares a `p` too, so a read ahead +// of it is not-yet-performed rather than a read of the other branch's node. +func testElseBranchNodeReadBeforeItPerforms(t *testing.T) { + src := ` + package test { + action outer { + attribute total : Integer = 0; + first start; + then action run { + if false { + action p { out v : Integer; assign v := 1; } + assign total := p.v; + } else { + assign total := p.v; + action p { out v : Integer; assign v := 2; } + } + } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrNodeNotPerformed) { + t.Fatalf("error = %v, want ErrNodeNotPerformed", err) + } + if !strings.Contains(err.Error(), "p") { + t.Errorf("error %q does not name the node", err) + } +} + +// testTypedNodePinOfANodeTheCalleeDoesNotDeclare: a typed node's subactions are +// those of the action it performed; a path through one it declares not is reported. +func testTypedNodePinOfANodeTheCalleeDoesNotDeclare(t *testing.T) { + src := ` + package test { + action def Seven { + out result : Integer; + first start; + then action inner { out v : Integer; assign v := 7; } + then action publish { assign result := inner.v; } + then done; + } + action outer { + attribute total : Integer = 0; + first start; + then action call : Seven; + then action read { assign total := call.other.v; } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrNodePin) { + t.Fatalf("error = %v, want ErrNodePin", err) + } + if !strings.Contains(err.Error(), "call") || !strings.Contains(err.Error(), "other") { + t.Errorf("error %q does not name the node and the missing one", err) + } +} + +// testNodeReadAsAValueWithoutAResult: a node read as a value stands for its +// performance's `result`; a node whose callee declares none is reported. +func testNodeReadAsAValueWithoutAResult(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + attribute total : Integer = 0; + first start; + then action add = Adder(1, 2); + then action fin { assign total := add; } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrNodePin) { + t.Fatalf("error = %v, want ErrNodePin", err) + } + if !strings.Contains(err.Error(), "result") { + t.Errorf("error %q does not name the missing result", err) + } +} + +// testSettledNodeReadAsAValueByAnotherCandidatesResult: a call its values settle holds +// the pins of the action performed alone, so it is not read as a value by a `result` only +// another candidate declares, which would leave the read waiting for a value never written. +func testSettledNodeReadAsAValueByAnotherCandidatesResult(t *testing.T) { + src := ` + package A { private import ScalarValues::*; + action def tag { in x : Integer; in y : Real; out mark : Integer; first step; action step { assign mark := 1; } } } + package B { private import ScalarValues::*; + action def tag { in x : Real; in y : Integer; out result : Integer; first step; action step { assign result := 2; } } } + package test { + private import ScalarValues::*; + private import A::*; + private import B::*; + calc def same { in v; v } + action outer { + attribute p = same(1); + attribute q = same(2.5); + attribute total : Integer = 0; + first start; + then action call = tag(x = p, y = q); + then action fin { assign total := call; } + then done; + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + outer := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if outer == nil { + t.Fatal("action outer not found") + } + _, err := ctx.ExecuteAction(outer) + if !errors.Is(err, ErrNodePin) { + t.Fatalf("error = %v, want ErrNodePin", err) + } + if !strings.Contains(err.Error(), "call") || !strings.Contains(err.Error(), "result") { + t.Errorf("error %q does not name the node and the missing result", err) + } +} + +// testNodePinMemberThroughAScalarPin: `node.pin.member` chains through the pin's +// value like any feature chain, so a pin holding no object cannot be read through. +func testNodePinMemberThroughAScalarPin(t *testing.T) { + src := ` + package test { + action outer { + attribute total : Integer = 0; + first start; + then action p { out v : Integer; assign v := 7; } + then action read { assign total := p.v.mark; } + then done; + } + } + ` + err := runOuterAction(t, src) + if err == nil { + t.Fatal("expected an error chaining a member through a scalar pin") + } + if !strings.Contains(err.Error(), "p.v") || !strings.Contains(err.Error(), "non-instance") { + t.Errorf("error %q does not name the pin read through", err) + } +} + +// testNodeInheritedDefaultThatCannotBeEvaluated: an inherited default is seeded +// when the node starts, so its failure is reported there, naming the parameter. +func testNodeInheritedDefaultThatCannotBeEvaluated(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + action def Base { + in divisor : Integer = 0; + in share : Integer = 6 / divisor; + out r : Integer; + } + action def Derived :> Base { + first start; + then assign r := 1; + then done; + } + action outer { + first start; + then action d : Derived; + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("error = %v, want ErrDivisionByZero", err) + } + if !strings.Contains(err.Error(), "share") { + t.Errorf("error %q does not name the parameter whose default failed", err) + } +} + +// testNodeInvocationTooManyArguments: positional arguments beyond the callee's +// input parameters are reported rather than dropped. +func testNodeInvocationTooManyArguments(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + first start; + then action add = Adder(1, 2, 3); + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrActionArity) { + t.Fatalf("error = %v, want ErrActionArity", err) + } +} + +// testNodeInvocationTooFewArguments: an input parameter that no argument and no +// default binds is reported before the callee runs, not when its body reads it. +func testNodeInvocationTooFewArguments(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + attribute b : Integer = 40; + first start; + then action add = Adder(1); + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrUnboundParameter) { + t.Fatalf("error = %v, want ErrUnboundParameter", err) + } + if !strings.Contains(err.Error(), "b") { + t.Errorf("error %q does not name the unbound parameter", err) + } +} + +// testNodeInvocationArgumentFailsBeforeDefaults: an argument that fails to evaluate +// is the error reported, not the default it replaces (which is never evaluated) nor +// a default reading the pin it would have bound. +func testNodeInvocationArgumentFailsBeforeDefaults(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + attribute zero : Integer = 0; + first start; + then action add = Adder(a = 1 / zero) { + in a = 5; + in b = a + 1; + } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("error = %v, want ErrDivisionByZero", err) + } + if !strings.Contains(err.Error(), `argument "a" of Adder`) { + t.Errorf("error %q does not name the argument that failed", err) + } +} + +// testNodeInvocationUnknownNamedArgument: a named argument must name an input +// parameter of the callee. +func testNodeInvocationUnknownNamedArgument(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + first start; + then action add = Adder(a = 1, c = 2); + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrUnknownParameter) { + t.Fatalf("error = %v, want ErrUnknownParameter", err) + } +} + +// testNodeInvocationRepeatedNamedArgument: a parameter named twice is rejected, not +// bound to whichever argument comes last. +func testNodeInvocationRepeatedNamedArgument(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + first start; + then action add = Adder(a = 1, b = 2, a = 3); + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrDuplicateArgument) { + t.Fatalf("error = %v, want ErrDuplicateArgument", err) + } + if !strings.Contains(err.Error(), `"a"`) { + t.Errorf("error %q does not name the parameter bound twice", err) + } +} + +// testPerformedActionInputBoundByNothing: a `perform` in statement form must bind +// every input without a default, by argument or by a caller value of its name. +func testPerformedActionInputBoundByNothing(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action def Defaulted { + in a : Integer = 1; + out doubled : Integer; + first step; + action step { assign doubled := a * 2; } + } + action adder : Adder; + action defaulted : Defaulted; + action outer { + attribute a : Integer = 1; + attribute doubled : Integer = 0; + first start; + then action run { + if a > 0 { + perform defaulted; + perform adder; + } + } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrUnboundParameter) { + t.Fatalf("error = %v, want ErrUnboundParameter", err) + } + if !strings.Contains(err.Error(), "adder") || !strings.Contains(err.Error(), "b") { + t.Errorf("error %q does not name the action and its unbound parameter", err) + } +} + +// testStateEntryActionInputBoundByNothing: a state's entry action is an +// invocation too, so an input it leaves unbound is reported before it runs. +func testStateEntryActionInputBoundByNothing(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test {`+adderActionDef+` + action adder : Adder; + state Machine { + attribute a : Integer = 1; + entry; then init; + state init; + state active { + entry adder; + } + succession first init then active; + succession first active then done; + } + }`) + err := exec.RunToCompletion() + if !errors.Is(err, ErrUnboundParameter) { + t.Fatalf("error = %v, want ErrUnboundParameter", err) + } + if !strings.Contains(err.Error(), "b") { + t.Errorf("error %q does not name the unbound parameter", err) + } +} + +// testStateBlockTypedNodeInputBoundByNothing: a typed node in a branch of a +// state's body binds the callee's inputs from the pins it declares and the +// values in scope, so one it leaves unbound is reported before its body runs. +func testStateBlockTypedNodeInputBoundByNothing(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test {`+adderActionDef+` + state Machine { + attribute total : Integer = 0; + entry; then init; + state init; + state active { + entry action { + if total == 0 { + action adding : Adder { + in a = 1; + assign total := sum; + } + } + } + } + succession first init then active; + succession first active then done; + } + }`) + err := exec.RunToCompletion() + if !errors.Is(err, ErrUnboundParameter) { + t.Fatalf("error = %v, want ErrUnboundParameter", err) + } + if !strings.Contains(err.Error(), "b") { + t.Errorf("error %q does not name the unbound parameter", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: the node's body must not run", total) + } +} + +// testStateBlockNodePinReadBeforePerformed: a state body's node has a frame of its +// own, so a sibling reading its pin before it performs is ErrNodeNotPerformed. +func testStateBlockNodePinReadBeforePerformed(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute total : Integer = 0; + entry; then init; + state init; + state active { + entry action { + if total == 0 { + action early { assign total := late.v; } + action late { out v : Integer; assign v := 1; } + } + } + } + succession first init then active; + succession first active then done; + } + }`) + err := exec.RunToCompletion() + if !errors.Is(err, ErrNodeNotPerformed) { + t.Fatalf("error = %v, want ErrNodeNotPerformed", err) + } + if !strings.Contains(err.Error(), "late") { + t.Errorf("error %q does not name the node", err) + } +} + +// testStateBlockNodeBoundAtNoPin: a binding in a state body at a pin the node does +// not declare is ErrBindingEnd, as it is in an action's flow. +func testStateBlockNodeBoundAtNoPin(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test {`+adderActionDef+` + state Machine { + attribute total : Integer = 0; + entry; then init; + state init; + state active { + entry action { + if total == 0 { + bind adding.c = total; + action adding : Adder { in a = 1; in b = 2; } + assign total := adding.sum; + } + } + } + succession first init then active; + succession first active then done; + } + }`) + err := exec.RunToCompletion() + if !errors.Is(err, ErrBindingEnd) { + t.Fatalf("error = %v, want ErrBindingEnd", err) + } + if !strings.Contains(err.Error(), "c") { + t.Errorf("error %q does not name the pin", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: the node must not run", total) + } +} + +// testStateBlockNodeOwnFlowRuns: a node of a state behavior's body stating a +// flow of its own runs that flow to its end when the block reaches it, as a node +// of an action body does. +func testStateBlockNodeOwnFlowRuns(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute total : Integer = 0; + entry; then init; + state init; + state active { + entry action { + if total == 0 { + action step { + first start; + then action one { assign total := total + 1; } + then action two { assign total := total * 10; } + then done; + } + } + } + } + succession first init then active; + succession first active then done; + } + }`) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(10)) { + t.Errorf("total = %v, want 10: the node's flow runs one then two", total) + } +} + +// stateWithDoBody is a machine whose state active runs body as its inline do +// action, counting in total what the body's nodes did. +func stateWithDoBody(t *testing.T, body string) *StateExecutor { + t.Helper() + return stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute total : Integer = 0; + entry; then init; + state init; + state active { do action ops { `+body+` } } + succession first init then active; + succession first active then done; + } + }`) +} + +// testStateDoBodyDanglingSuccession: a succession of an inline do body naming a +// node the body does not declare is reported as a flow that cannot be built, +// naming the target, and no node of the body runs. +func testStateDoBodyDanglingSuccession(t *testing.T) { + exec := stateWithDoBody(t, ` + first start; + then action a { assign total := total + 1; } + succession a then missing; + `) + err := exec.RunToCompletion() + if !errors.Is(err, ErrStatementNotExecutable) { + t.Fatalf("expected ErrStatementNotExecutable, got: %v", err) + } + if !strings.Contains(err.Error(), "state behavior ops") || !strings.Contains(err.Error(), `"missing"`) { + t.Errorf("error %q does not name the behavior and the undefined target", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: no node of the body must run", total) + } +} + +// testStateDoBodyFirstThenUndefined: `first a then b` in an inline do body where +// b is not declared is reported the same way. +func testStateDoBodyFirstThenUndefined(t *testing.T) { + exec := stateWithDoBody(t, ` + action a { assign total := total + 1; } + first a then b; + `) + err := exec.RunToCompletion() + if !errors.Is(err, ErrStatementNotExecutable) { + t.Fatalf("expected ErrStatementNotExecutable, got: %v", err) + } + if !strings.Contains(err.Error(), `"b"`) { + t.Errorf("error %q does not name the undefined target", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: no node of the body must run", total) + } +} + +// testStateDoBodyFlowWithoutStart: successions that give the flow no node to +// start at — a cycle and nothing first — are an invalid flow, not a hang. +func testStateDoBodyFlowWithoutStart(t *testing.T) { + exec := stateWithDoBody(t, ` + action a { assign total := total + 1; } + action b { assign total := total + 1; } + succession first a then b; + succession first b then a; + `) + err := exec.RunToCompletion() + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("expected ErrInvalidActionFlow, got: %v", err) + } + if !strings.Contains(err.Error(), "no node starts the flow") { + t.Errorf("error %q does not say what the flow lacks", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: no node of the body must run", total) + } +} + +// testStateDoBodyFlowWithTwoStarts: successions leaving two nodes unpreceded +// state no start either; the error names both and what would state one. +func testStateDoBodyFlowWithTwoStarts(t *testing.T) { + exec := stateWithDoBody(t, ` + action a { assign total := total + 1; } + action b { assign total := total + 1; } + action c { assign total := total + 1; } + succession first a then c; + succession first b then c; + `) + err := exec.RunToCompletion() + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("expected ErrInvalidActionFlow, got: %v", err) + } + for _, want := range []string{"no node starts the flow", `"a"`, `"b"`, "'first'"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not contain %q", err, want) + } + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: no node of the body must run", total) + } +} + +// testStateDoBodyStartsAtItsUnprecededStep: a do body written in declaration +// order, with no `first`, starts at the one node no succession leads to. +func testStateDoBodyStartsAtItsUnprecededStep(t *testing.T) { + exec := stateWithDoBody(t, ` + action a { assign total := total + 1; } + then action b { assign total := total * 10; } + `) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(10)) { + t.Errorf("total = %v, want 10: a then b", total) + } +} + +// testActionFlowStartsAtItsUnprecededStep: an action definition performed whole +// starts at its one unpreceded node the same way. +func testActionFlowStartsAtItsUnprecededStep(t *testing.T) { + outputs, err := executeActionSource(t, "Count", `package P { + private import ScalarValues::*; + action def Count { + attribute total : Integer = 1; + action a { assign total := total + 1; } + then action b { assign total := total * 10; } + } + }`) + if err != nil { + t.Fatalf("run: %v", err) + } + assertIntOutput(t, outputs, "total", 20) +} + +// testActionFlowWithTwoStarts: an action whose successions leave two nodes +// unpreceded is an invalid flow at initialization, not a bodiless action. +func testActionFlowWithTwoStarts(t *testing.T) { + _, err := executeActionSource(t, "Count", `package P { + private import ScalarValues::*; + action def Count { + attribute total : Integer = 0; + action a { assign total := total + 1; } + action b { assign total := total + 1; } + action c { assign total := total + 1; } + succession first a then c; + succession first b then c; + } + }`) + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("expected ErrInvalidActionFlow, got: %v", err) + } + for _, want := range []string{"no initial node found in action Count", `"a"`, `"b"`, "'first'"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not contain %q", err, want) + } + } +} + +// testActionFlowCycleWithoutStart: successions closing a cycle over every node +// leave nothing to start at; the error says so rather than the run hanging. +func testActionFlowCycleWithoutStart(t *testing.T) { + _, err := executeActionSource(t, "Loop", `package P { + private import ScalarValues::*; + action def Loop { + attribute total : Integer = 0; + action a { assign total := total + 1; } + action b { assign total := total + 1; } + succession first a then b; + succession first b then a; + } + }`) + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("expected ErrInvalidActionFlow, got: %v", err) + } + if !strings.Contains(err.Error(), "cycle") { + t.Errorf("error %q does not name the cycle", err) + } +} + +// testStateDoBodyNestedNodeDanglingSuccession: the flow a node of an inline do +// body states of its own is validated with the body's before any node runs, so +// a dangling succession in it is reported naming the node and the target. +func testStateDoBodyNestedNodeDanglingSuccession(t *testing.T) { + exec := stateWithDoBody(t, ` + first start; + then action a { assign total := total + 1; } + then action step { + first start; + then action one { assign total := total + 1; } + succession one then missing; + } + then done; + `) + err := exec.RunToCompletion() + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("expected ErrInvalidActionFlow, got: %v", err) + } + if !strings.Contains(err.Error(), "action node step") || !strings.Contains(err.Error(), `"missing"`) { + t.Errorf("error %q does not name the node and the undefined target", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: no node of the body must run", total) + } +} + +// testStateDoBodyNestedNodeStartsAtItsUnprecededStep: a node of an inline do +// body stating its flow in declaration order, with no `first`, starts at the one +// node no succession leads to, as the body itself does. +func testStateDoBodyNestedNodeStartsAtItsUnprecededStep(t *testing.T) { + exec := stateWithDoBody(t, ` + action inner { + action a { assign total := total + 1; } + action b { assign total := total * 10; } + succession first a then b; + } + `) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(10)) { + t.Errorf("total = %v, want 10: a then b", total) + } +} + +// testActionNestedNodeStartsAtItsUnprecededStep: a node of an action definition +// stating its flow the same way starts there too. +func testActionNestedNodeStartsAtItsUnprecededStep(t *testing.T) { + outputs, err := executeActionSource(t, "Count", `package P { + private import ScalarValues::*; + action def Count { + attribute total : Integer = 1; + action inner { + action a { assign total := total + 1; } + action b { assign total := total * 10; } + succession first a then b; + } + } + }`) + if err != nil { + t.Fatalf("run: %v", err) + } + assertIntOutput(t, outputs, "total", 20) +} + +// testActionNestedNodeWithTwoStarts: a nested flow leaving two nodes unpreceded +// states no start, and is an invalid flow at initialization naming the node. +func testActionNestedNodeWithTwoStarts(t *testing.T) { + _, err := executeActionSource(t, "Count", `package P { + private import ScalarValues::*; + action def Count { + attribute total : Integer = 0; + action inner { + action a { assign total := total + 1; } + action b { assign total := total + 1; } + action c { assign total := total + 1; } + succession first a then c; + succession first b then c; + } + } + }`) + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("expected ErrInvalidActionFlow, got: %v", err) + } + if !strings.Contains(err.Error(), "no initial node found in action node inner") { + t.Errorf("error %q does not name the node without a start", err) + } +} + +// testStateEntryBodyDanglingSuccession: an inline entry body's flow is built the +// same way, so its dangling succession is reported on entering the state. +func testStateEntryBodyDanglingSuccession(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + state Machine { + attribute total : Integer = 0; + entry; then init; + state init; + state active { + entry action prep { + first start; + then action a { assign total := total + 1; } + then missing; + } + } + succession first init then active; + succession first active then done; + } + }`) + err := exec.RunToCompletion() + if !errors.Is(err, ErrStatementNotExecutable) { + t.Fatalf("expected ErrStatementNotExecutable, got: %v", err) + } + if !strings.Contains(err.Error(), "state behavior prep") || !strings.Contains(err.Error(), `"missing"`) { + t.Errorf("error %q does not name the behavior and the undefined target", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: no node of the body must run", total) + } +} + +// testStateDoBodyAcceptWaitsForTheMessage: an accept in an inline do body's flow +// with no message in flight suspends the machine in its state, as a transition +// triggered by a signal does, rather than hanging or deadlocking; the message +// posted later lets the body finish and the state complete. +func testStateDoBodyAcceptWaitsForTheMessage(t *testing.T) { + var exec *StateExecutor + done := make(chan error, 1) + go func() { + exec = stateWithDoBody(t, ` + first start; + then action reader accept n : Integer; + then action count assign total := n; + then done; + `) + done <- exec.RunToCompletion() + }() + select { + case err := <-done: + if err != nil { + t.Fatalf("run: %v", err) + } + case <-watchdog(10 * time.Second): + t.Fatal("a do body waiting for a message did not suspend") + } + if exec.State() != StateSuspended || StateVertexName(exec.CurrentState()) != "active" { + t.Fatalf("state = %v in %s, want suspended in active", exec.State(), StateVertexName(exec.CurrentState())) + } + if exec.HasPendingDoWork() || exec.HasPendingSignal() { + t.Error("a do body parked at its accept must not be due with no message in flight") + } + nine := integerValue(9) + exec.ctx.PostMessage(Message{SignalType: "Integer", Target: "reader", Value: &nine}) + if exec.HasPendingDoWork() || !exec.HasPendingSignal() { + t.Fatal("the message in flight must be one the machine dispatches, to the parked do body") + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run after the message: %v", err) + } + if exec.State() != StateCompleted { + t.Errorf("state = %v in %s, want completed", exec.State(), StateVertexName(exec.CurrentState())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(9)) { + t.Errorf("total = %v, want 9: the accepted value", total) + } +} + +// testStateDoBodyAcceptIsDecidedForASend: a signal a do body is parked at an +// accept for is one the machine takes, though no transition fires on it: the +// previews say so, without moving the body, and the dispatch lets it go on. +func testStateDoBodyAcceptIsDecidedForASend(t *testing.T) { + src := ` + private import ScalarValues::*; + attribute def Go; + attribute def Other; + state def Waiter { + attribute total : Integer = 0; + entry; then active; + state active { + do action work { + first start; + then action reader accept Go; + then action count assign total := total + 1; + then done; + } + } + succession first active then finished; + state finished; + } + part def Box { exhibit state w : Waiter; } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "w.sysml", parseAndBuild(t, src)) + root := idx.DocumentRoot("w.sysml") + box, err := ctx.Instantiate(resolveSymbol(t, root, "Box")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + behavior, ok := box.ExhibitedState() + if !ok { + t.Fatal("the box exhibits no machine") + } + exec := behavior.State + if activeLeaf(exec) != "active" || exec.HasPendingDoWork() { + t.Fatalf("state %s with pending do work %v, want parked in active", activeLeaf(exec), exec.HasPendingDoWork()) + } + other, err := ctx.SignalMessage(resolveSymbol(t, root, "Other"), nil, box) + if err != nil { + t.Fatal(err) + } + if accepted, err := exec.AcceptsMessage(other); err != nil || accepted { + t.Errorf("AcceptsMessage(Other) = %v, %v; want false: the accept names Go", accepted, err) + } + goMsg, err := ctx.SignalMessage(resolveSymbol(t, root, "Go"), nil, box) + if err != nil { + t.Fatal(err) + } + if accepted, err := exec.AcceptsMessage(goMsg); err != nil || !accepted { + t.Errorf("AcceptsMessage(Go) = %v, %v; want true: the do body is parked at accept Go", accepted, err) + } + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("Decide(Go): %v", err) + } + if len(decision.Fires) != 0 || decision.Deferred || len(decision.Resumes) != 1 || decision.Resumes[0] != "do behavior of state active" { + t.Errorf("Decide(Go) = %+v, want only the do behavior of active resumed", decision) + } + if activeLeaf(exec) != "active" || exec.HasPendingDoWork() || len(ctx.PendingMessages()) != 0 { + t.Fatal("the previews must leave the machine, the body and the bus as they were") + } + ctx.PostMessage(goMsg) + if exec.HasPendingDoWork() || !exec.HasPendingSignal() { + t.Fatal("the message in flight must be one the machine dispatches, to the parked do body") + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch the message: %v", err) + } + dispatch, ok := exec.LastDispatch() + if !ok || dispatch.Fired || dispatch.Deferred || len(dispatch.Resumed) != 1 || dispatch.Resumed[0] != decision.Resumes[0] { + t.Errorf("dispatch = %+v, %v; want the do behavior of active resumed, as decided", dispatch, ok) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run after the message: %v", err) + } + if activeLeaf(exec) != "finished" || len(ctx.PendingMessages()) != 0 { + t.Errorf("state %s with %d messages in flight, want finished with the message consumed", activeLeaf(exec), len(ctx.PendingMessages())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(1)) { + t.Errorf("total = %v, want 1: the body went on past its accept once", total) + } +} + +// testStateDoBodyAcceptYieldsToATransition: a signal both a transition out of the +// active state and its do behavior, parked at an accept, would take goes to the +// transition alone — the behavior is abandoned when the state is left — and +// Decide reports just that, before. +func testStateDoBodyAcceptYieldsToATransition(t *testing.T) { + src := ` + private import ScalarValues::*; + attribute def Go; + state def Waiter { + attribute total : Integer = 0; + entry; then active; + state active { + do action work { + first start; + then action reader accept Go; + then action count assign total := total + 10; + then done; + } + } + transition leave first active accept Go then stopped; + state stopped { entry assign total := total + 1; } + } + part def Box { exhibit state w : Waiter; } + ` + exec, ctx, goMsg := boxDoBehaviorParkedAtGo(t, src) + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("Decide(Go): %v", err) + } + want := Decision{Fires: []string{"transition leave"}} + if !reflect.DeepEqual(decision, want) { + t.Errorf("Decide(Go) = %+v, want %+v: the transition takes it alone", decision, want) + } + ctx.PostMessage(goMsg) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch the message: %v", err) + } + dispatch, ok := exec.LastDispatch() + if !ok || !dispatch.Fired || dispatch.Deferred || len(dispatch.Resumed) != 0 { + t.Errorf("dispatch = %+v, %v; want the transition fired and no do behavior resumed, as decided", dispatch, ok) + } + if activeLeaf(exec) != "stopped" || len(ctx.PendingMessages()) != 0 { + t.Errorf("state %s with %d messages in flight, want stopped with the one message consumed", activeLeaf(exec), len(ctx.PendingMessages())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(1)) { + t.Errorf("total = %v, want 1: the entry of stopped alone, the do behavior abandoned at its accept", total) + } + if exec.HasPendingDoWork() || exec.HasPendingSignal() || len(ctx.Clock().Waits()) != 0 { + t.Error("nothing of the abandoned do behavior must remain due") + } +} + +// testStateDoBodyAcceptGoesOnAcrossASubstateTransition: a signal a transition +// between two substates of the active state accepts is one the state's own do +// behavior, parked at an accept for it, goes on with too — the state stays active +// across that transition — and Decide reports both, before. +func testStateDoBodyAcceptGoesOnAcrossASubstateTransition(t *testing.T) { + src := ` + private import ScalarValues::*; + attribute def Go; + state def Waiter { + attribute total : Integer = 0; + entry; then active; + state active { + do action work { + first start; + then action reader accept Go; + then action count assign total := total + 10; + then done; + } + entry; then left; + state left; + transition shift first left accept Go then right; + state right { entry assign total := total + 1; } + } + } + part def Box { exhibit state w : Waiter; } + ` + exec, ctx, goMsg := boxDoBehaviorParkedAtGo(t, src) + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("Decide(Go): %v", err) + } + want := Decision{Fires: []string{"transition shift"}, Resumes: []string{"do behavior of state active"}} + if !reflect.DeepEqual(decision, want) { + t.Errorf("Decide(Go) = %+v, want %+v: the transition and the do behavior of the state it stays in both take it", decision, want) + } + ctx.PostMessage(goMsg) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch the message: %v", err) + } + dispatch, ok := exec.LastDispatch() + if !ok || !dispatch.Fired || dispatch.Deferred || !reflect.DeepEqual(dispatch.Resumed, want.Resumes) { + t.Errorf("dispatch = %+v, %v; want the transition fired and the do behavior resumed, as decided", dispatch, ok) + } + if activeLeaf(exec) != "right" || len(ctx.PendingMessages()) != 0 { + t.Errorf("state %s with %d messages in flight, want right with the one message consumed", activeLeaf(exec), len(ctx.PendingMessages())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(11)) { + t.Errorf("total = %v, want 11: the do behavior's count, then the entry of right", total) + } + if exec.HasPendingDoWork() || exec.HasPendingSignal() { + t.Error("the do behavior has ended; nothing of it must remain due") + } +} + +// testStateDoBodyAcceptYieldsToASubstateTransitionLeavingIt: a signal a transition +// out of a substate accepts goes to the transition alone when its target lies +// outside the state whose do behavior is parked for it — that state is left, its +// behavior abandoned — and Decide reports just that, before. +func testStateDoBodyAcceptYieldsToASubstateTransitionLeavingIt(t *testing.T) { + src := ` + private import ScalarValues::*; + attribute def Go; + state def Waiter { + attribute total : Integer = 0; + entry; then active; + state active { + do action work { + first start; + then action reader accept Go; + then action count assign total := total + 10; + then done; + } + entry; then left; + state left; + transition leave first left accept Go then stopped; + } + state stopped { entry assign total := total + 1; } + } + part def Box { exhibit state w : Waiter; } + ` + exec, ctx, goMsg := boxDoBehaviorParkedAtGo(t, src) + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("Decide(Go): %v", err) + } + want := Decision{Fires: []string{"transition leave"}} + if !reflect.DeepEqual(decision, want) { + t.Errorf("Decide(Go) = %+v, want %+v: the transition leaving the state takes it alone", decision, want) + } + ctx.PostMessage(goMsg) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch the message: %v", err) + } + dispatch, ok := exec.LastDispatch() + if !ok || !dispatch.Fired || dispatch.Deferred || len(dispatch.Resumed) != 0 { + t.Errorf("dispatch = %+v, %v; want the transition fired and no do behavior resumed, as decided", dispatch, ok) + } + if activeLeaf(exec) != "stopped" || len(ctx.PendingMessages()) != 0 { + t.Errorf("state %s with %d messages in flight, want stopped with the one message consumed", activeLeaf(exec), len(ctx.PendingMessages())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(1)) { + t.Errorf("total = %v, want 1: the entry of stopped alone, the do behavior abandoned at its accept", total) + } + if exec.HasPendingDoWork() || exec.HasPendingSignal() || len(ctx.Clock().Waits()) != 0 { + t.Error("nothing of the abandoned do behavior must remain due") + } +} + +// testStateDoBodyAcceptFollowsTheTransitionChosen: a substate with two transitions +// enabled for the signal, one between the enclosing state's substates and one out +// of it. The one the policy chooses (the first declared) stays inside, so the +// enclosing do behavior goes on with the signal, the alternative leaving +// notwithstanding; Decide names the same transition and resume. +func testStateDoBodyAcceptFollowsTheTransitionChosen(t *testing.T) { + src := ` + private import ScalarValues::*; + attribute def Go; + state def Waiter { + attribute total : Integer = 0; + entry; then active; + state active { + do action work { + first start; + then action reader accept Go; + then action count assign total := total + 10; + then done; + } + entry; then left; + state left; + transition shift first left accept Go then right; + transition leave first left accept Go then stopped; + state right { entry assign total := total + 1; } + } + state stopped { entry assign total := total + 100; } + } + part def Box { exhibit state w : Waiter; } + ` + exec, ctx, goMsg := boxDoBehaviorParkedAtGo(t, src) + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("Decide(Go): %v", err) + } + want := Decision{Fires: []string{"transition shift"}, Resumes: []string{"do behavior of state active"}} + if !reflect.DeepEqual(decision, want) { + t.Errorf("Decide(Go) = %+v, want %+v: the transition chosen stays in the state, whose do behavior takes it too", decision, want) + } + ctx.PostMessage(goMsg) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch the message: %v", err) + } + dispatch, ok := exec.LastDispatch() + if !ok || !dispatch.Fired || dispatch.Deferred || !reflect.DeepEqual(dispatch.Resumed, want.Resumes) { + t.Errorf("dispatch = %+v, %v; want the transition fired and the do behavior resumed, as decided", dispatch, ok) + } + if activeLeaf(exec) != "right" || len(ctx.PendingMessages()) != 0 { + t.Errorf("state %s with %d messages in flight, want right with the one message consumed", activeLeaf(exec), len(ctx.PendingMessages())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(11)) { + t.Errorf("total = %v, want 11: the do behavior's count, then the entry of right", total) + } +} + +// testStateDoBodyAcceptYieldsToAnOpenChoice: the transition chosen targets a choice +// with a guarded branch to another substate and a default branch out of the +// enclosing state. The choice is read only once the transition is under way, so +// whichever branch it then takes, the enclosing do behavior — parked in a state a +// branch may leave — does not take the signal: the transition takes it alone. +func testStateDoBodyAcceptYieldsToAnOpenChoice(t *testing.T) { + model := func(stay string) string { + return ` + private import ScalarValues::*; + attribute def Go; + state def Waiter { + attribute total : Integer = 0; + attribute stay : Boolean = ` + stay + `; + entry; then active; + state active { + do action work { + first start; + then action reader accept Go; + then action count assign total := total + 10; + then done; + } + entry; then left; + state left; + choice pick; + transition route first left accept Go then pick; + transition first pick if stay then right; + transition first pick then stopped; + state right { entry assign total := total + 1; } + } + state stopped { entry assign total := total + 100; } + } + part def Box { exhibit state w : Waiter; } + ` + } + cases := []struct { + stay string + want Decision + leaf string + total int64 + }{ + {"true", Decision{Fires: []string{"transition route"}}, "right", 1}, + {"false", Decision{Fires: []string{"transition route"}}, "stopped", 100}, + } + for _, tc := range cases { + exec, ctx, goMsg := boxDoBehaviorParkedAtGo(t, model(tc.stay)) + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("stay = %s: Decide(Go): %v", tc.stay, err) + } + if !reflect.DeepEqual(decision, tc.want) { + t.Errorf("stay = %s: Decide(Go) = %+v, want %+v: a branch of the open choice may leave the do behavior's state", tc.stay, decision, tc.want) + } + ctx.PostMessage(goMsg) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("stay = %s: dispatch the message: %v", tc.stay, err) + } + dispatch, ok := exec.LastDispatch() + if !ok || !dispatch.Fired || dispatch.Deferred || !reflect.DeepEqual(dispatch.Resumed, tc.want.Resumes) { + t.Errorf("stay = %s: dispatch = %+v, %v; want the transition fired and the do behavior resumed as decided", tc.stay, dispatch, ok) + } + if activeLeaf(exec) != tc.leaf || len(ctx.PendingMessages()) != 0 { + t.Errorf("stay = %s: state %s with %d messages in flight, want %s with the one message consumed", tc.stay, activeLeaf(exec), len(ctx.PendingMessages()), tc.leaf) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(tc.total)) { + t.Errorf("stay = %s: total = %v, want %d", tc.stay, total, tc.total) + } + } +} + +// testStateDoBodyAcceptYieldsToATransitionIntoItsRegion: a transition out of one +// orthogonal region into a sibling region exits the state that region is in on the +// way, so the do behavior parked there does not take the signal the transition +// accepts, in Decide and in the dispatch alike; the signal is consumed once. +func testStateDoBodyAcceptYieldsToATransitionIntoItsRegion(t *testing.T) { + src := ` + private import ScalarValues::*; + attribute def Go; + state def Waiter parallel { + attribute total : Integer = 0; + state left { + entry; then l1; + state l1; + transition swap first l1 accept Go then r2; + } + state right { + entry; then r1; + state r1 { + do action work { + first start; + then action reader accept Go; + then action count assign total := total + 10; + then done; + } + } + state r2 { entry assign total := total + 1; } + } + } + part def Box { exhibit state w : Waiter; } + ` + exec, ctx, goMsg := boxDoBehaviorParkedAtGo(t, src) + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("Decide(Go): %v", err) + } + want := Decision{Fires: []string{"transition swap"}} + if !reflect.DeepEqual(decision, want) { + t.Errorf("Decide(Go) = %+v, want %+v: the transition replaces the state the do behavior runs in", decision, want) + } + ctx.PostMessage(goMsg) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch the message: %v", err) + } + dispatch, ok := exec.LastDispatch() + if !ok || !dispatch.Fired || dispatch.Deferred || len(dispatch.Resumed) != 0 { + t.Errorf("dispatch = %+v, %v; want the transition fired and no do behavior resumed", dispatch, ok) + } + assertRegionConfig(t, exec, map[string]string{"right": "r2"}) + if len(ctx.PendingMessages()) != 0 { + t.Errorf("%d messages in flight, want the one message consumed", len(ctx.PendingMessages())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(1)) { + t.Errorf("total = %v, want 1: the entry of r2 only, the do behavior of r1 cancelled at its accept", total) + } +} + +// testStateDoBodyAcceptRunsBeforeTheChoiceReads: the do behaviors go on with the +// signal (one node, then yield) before the chosen transition fires, and a choice +// on its route reads its guards only then, so a do behavior that rewrites the +// guard on its way sends the transition down the branch the rewritten data selects. +func testStateDoBodyAcceptRunsBeforeTheChoiceReads(t *testing.T) { + src := ` + private import ScalarValues::*; + attribute def Go; + state def Waiter { + attribute total : Integer = 0; + attribute stay : Boolean = true; + entry; then active; + state active { + do action work { + first start; + then action reader accept Go; + then action flip assign stay := false; + then action count assign total := total + 10; + then done; + } + entry; then left; + state left; + choice pick; + transition route first left accept Go then pick; + transition first pick if stay then right; + transition first pick then other; + state right { entry assign total := total + 1; } + state other { entry assign total := total + 100; } + } + } + part def Box { exhibit state w : Waiter; } + ` + exec, ctx, goMsg := boxDoBehaviorParkedAtGo(t, src) + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("Decide(Go): %v", err) + } + want := Decision{Fires: []string{"transition route"}, Resumes: []string{"do behavior of state active"}} + if !reflect.DeepEqual(decision, want) { + t.Errorf("Decide(Go) = %+v, want %+v", decision, want) + } + ctx.PostMessage(goMsg) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch the message: %v", err) + } + dispatch, ok := exec.LastDispatch() + if !ok || !dispatch.Fired || dispatch.Deferred || !reflect.DeepEqual(dispatch.Resumed, want.Resumes) { + t.Errorf("dispatch = %+v, %v; want the transition fired and the do behavior resumed as decided", dispatch, ok) + } + if activeLeaf(exec) != "other" || len(ctx.PendingMessages()) != 0 { + t.Errorf("state %s with %d messages in flight, want other with the one message consumed: the choice read stay after the do behavior cleared it", activeLeaf(exec), len(ctx.PendingMessages())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(100)) { + t.Errorf("total = %v, want 100: the entry of other, the do behavior yielded after flip with count still to run", total) + } + if !exec.HasPendingDoWork() { + t.Fatal("the do behavior must be due to go on with count") + } + if _, err := exec.RunDoRound(); err != nil { + t.Fatalf("run the do round: %v", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(110)) { + t.Errorf("total = %v, want 110: the do behavior's count in the round after the dispatch", total) + } +} + +// testStateDoBodyAcceptSharesTheDispatchWithARegion: a signal a do behavior in one +// orthogonal region is parked at an accept for and a transition in a sibling +// region accepts is dispatched once to both, the behavior going on from its accept +// before the transition fires, and Decide reports both, before. +func testStateDoBodyAcceptSharesTheDispatchWithARegion(t *testing.T) { + src := ` + private import ScalarValues::*; + attribute def Go; + state def Waiter parallel { + attribute total : Integer = 0; + state left { + entry; then lwork; + state lwork { + do action work { + first start; + then action reader accept Go; + then action count assign total := total + 10; + then done; + } + } + } + state right { + entry; then rwait; + state rwait; + transition leave first rwait accept Go then rdone; + state rdone { entry assign total := total + 1; } + } + } + part def Box { exhibit state w : Waiter; } + ` + exec, ctx, goMsg := boxDoBehaviorParkedAtGo(t, src) + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("Decide(Go): %v", err) + } + want := Decision{Fires: []string{"transition leave"}, Resumes: []string{"do behavior of state lwork"}} + if !reflect.DeepEqual(decision, want) { + t.Errorf("Decide(Go) = %+v, want %+v: the transition and the do behavior of the sibling region both take it", decision, want) + } + ctx.PostMessage(goMsg) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch the message: %v", err) + } + dispatch, ok := exec.LastDispatch() + if !ok || !dispatch.Fired || dispatch.Deferred || !reflect.DeepEqual(dispatch.Resumed, want.Resumes) { + t.Errorf("dispatch = %+v, %v; want the transition fired and the do behavior resumed, as decided", dispatch, ok) + } + if len(ctx.PendingMessages()) != 0 { + t.Errorf("%d messages in flight, want the one message consumed", len(ctx.PendingMessages())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(11)) { + t.Errorf("total = %v, want 11: the do behavior's count, then the entry of rdone", total) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run on: %v", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(11)) { + t.Errorf("total = %v after running on, want 11 still", total) + } +} + +// testStateDoBodyAcceptSharesTheDispatchWithAForkInARegion: the transition the +// sibling region takes leads into a fork whose branches stay within that region, +// so the do behavior's region is kept and the behavior goes on with the signal. +func testStateDoBodyAcceptSharesTheDispatchWithAForkInARegion(t *testing.T) { + src := ` + private import ScalarValues::*; + attribute def Go; + state def Waiter parallel { + attribute total : Integer = 0; + state left { + entry; then lwork; + state lwork { + do action work { + first start; + then action reader accept Go; + then action count assign total := total + 10; + then done; + } + } + } + state right { + entry; then rwait; + state rwait; + fork split; + transition leave first rwait accept Go then split; + state rwork parallel { + state ra { state a { entry assign total := total + 1; } } + state rb { state b { entry assign total := total + 2; } } + } + transition first split then a; + transition first split then b; + } + } + part def Box { exhibit state w : Waiter; } + ` + exec, ctx, goMsg := boxDoBehaviorParkedAtGo(t, src) + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("Decide(Go): %v", err) + } + want := Decision{Fires: []string{"transition leave"}, Resumes: []string{"do behavior of state lwork"}} + if !reflect.DeepEqual(decision, want) { + t.Errorf("Decide(Go) = %+v, want %+v: the fork leaves only its own region, so the sibling's do behavior takes the signal too", decision, want) + } + ctx.PostMessage(goMsg) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch the message: %v", err) + } + dispatch, ok := exec.LastDispatch() + if !ok || !dispatch.Fired || dispatch.Deferred || !reflect.DeepEqual(dispatch.Resumed, want.Resumes) { + t.Errorf("dispatch = %+v, %v; want the transition fired and the do behavior resumed, as decided", dispatch, ok) + } + if len(ctx.PendingMessages()) != 0 { + t.Errorf("%d messages in flight, want the one message consumed", len(ctx.PendingMessages())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(13)) { + t.Errorf("total = %v, want 13: the do behavior's count, then the entries of a and b", total) + } +} + +// testStateChoiceRouteReadsTheAcceptedPayload: a choice guard along the chosen +// transition's route reads the payload the accept binds, so the route is settled +// with the payload bound and the branch the payload selects is the one entered. +func testStateChoiceRouteReadsTheAcceptedPayload(t *testing.T) { + src := ` + private import ScalarValues::*; + attribute def Go { attribute level : Integer; } + state def Waiter { + attribute total : Integer = 0; + entry; then left; + state left; + choice pick; + transition route first left accept g : Go then pick; + transition first pick if g.level > 0 then right; + transition first pick then stopped; + state right { entry assign total := total + 1; } + state stopped { entry assign total := total + 100; } + } + part def Box { exhibit state w : Waiter; } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "w.sysml", parseAndBuild(t, src)) + root := idx.DocumentRoot("w.sysml") + box, err := ctx.Instantiate(resolveSymbol(t, root, "Box")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + behavior, ok := box.ExhibitedState() + if !ok { + t.Fatal("the box exhibits no machine") + } + exec := behavior.State + goMsg, err := ctx.SignalMessage(resolveSymbol(t, root, "Go"), map[string]Value{"level": integerValue(1)}, box) + if err != nil { + t.Fatal(err) + } + decision, err := exec.Decide(goMsg) + if err != nil { + t.Fatalf("Decide(Go): %v", err) + } + want := Decision{Fires: []string{"transition route"}} + if !reflect.DeepEqual(decision, want) { + t.Errorf("Decide(Go) = %+v, want %+v", decision, want) + } + ctx.PostMessage(goMsg) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch the message: %v", err) + } + if activeLeaf(exec) != "right" || len(ctx.PendingMessages()) != 0 { + t.Errorf("state %s with %d messages in flight, want right with the one message consumed: g.level was 1 when the choice was routed", activeLeaf(exec), len(ctx.PendingMessages())) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(1)) { + t.Errorf("total = %v, want 1: the entry of right", total) + } +} + +// boxDoBehaviorParkedAtGo instantiates Box from the source, whose exhibited machine +// starts a do behavior that parks at an accept of Go, and builds a Go for it. +func boxDoBehaviorParkedAtGo(t *testing.T, src string) (*StateExecutor, *Context, Message) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "w.sysml", parseAndBuild(t, src)) + root := idx.DocumentRoot("w.sysml") + box, err := ctx.Instantiate(resolveSymbol(t, root, "Box")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + behavior, ok := box.ExhibitedState() + if !ok { + t.Fatal("the box exhibits no machine") + } + exec := behavior.State + if exec.HasPendingDoWork() || exec.HasPendingSignal() { + t.Fatal("the do behavior must be parked at its accept with nothing due") + } + goMsg, err := ctx.SignalMessage(resolveSymbol(t, root, "Go"), nil, box) + if err != nil { + t.Fatal(err) + } + return exec, ctx, goMsg +} + +// testStateDoBodyNodeReturnParameter: an action node of an inline do body's flow +// declaring `return` is refused before any node runs, as in a standalone action. +func testStateDoBodyNodeReturnParameter(t *testing.T) { + exec := stateWithDoBody(t, ` + first start; + then action a { assign total := total + 1; } + then action b { return r : Integer = 1; } + then done; + `) + err := exec.RunToCompletion() + if !errors.Is(err, ErrActionResultParameter) { + t.Fatalf("expected ErrActionResultParameter, got: %v", err) + } + if !strings.Contains(err.Error(), "state behavior ops") || !strings.Contains(err.Error(), "action node b") { + t.Errorf("error %q does not name the behavior and the node", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: no node of the body must run", total) + } +} + +// testStateDoBodyReturnParameter: an inline do body stating a flow and declaring +// `return` itself is refused before any node runs, as a standalone action is. +func testStateDoBodyReturnParameter(t *testing.T) { + exec := stateWithDoBody(t, ` + return r : Integer = 1; + first start; + then action a { assign total := total + 1; } + then done; + `) + err := exec.RunToCompletion() + if !errors.Is(err, ErrActionResultParameter) { + t.Fatalf("expected ErrActionResultParameter, got: %v", err) + } + if !strings.Contains(err.Error(), "action ops declares `return r`") { + t.Errorf("error %q does not name the body's parameter", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: no node of the body must run", total) + } +} + +// testStateDoBodyFlowThatNeverEnds: a cycle of successions in an inline do body +// spends the step budget and is reported, rather than running forever. +func testStateDoBodyFlowThatNeverEnds(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package test { + private import ScalarValues::*; + state Machine { + attribute total : Integer = 0; + entry; then active; + state active { + do action ops { + first start; + then action a { assign total := total + 1; } + then action b { assign total := total + 1; } + succession first b then a; + } + } + succession first active then done; + } + }`)) + ctx.maxActionSteps = 50 + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine Machine not found") + } + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newStateExecutor: %v", err) + } + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + err = exec.RunToCompletion() + if !errors.Is(err, ErrActionStepLimitExceeded) { + t.Fatalf("expected ErrActionStepLimitExceeded, got: %v", err) + } + if !strings.Contains(err.Error(), "possible infinite loop") { + t.Errorf("error %q does not point at the loop", err) + } +} + +// testStateBlockNodeUnvaluedPinWriteChecked: a write to a pin a node in a state's +// body declares without a value is checked against that pin's declaration, and the +// machine's same-named attribute is left as it was. +func testStateBlockNodeUnvaluedPinWriteChecked(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, `package test { + private import ScalarValues::*; + state Machine { + attribute v : Integer = 100; + entry; then init; + state init; + state active { + entry action { + if v == 100 { + action p { + out v : Integer; + assign v := "one"; + } + } + } + } + succession first init then active; + succession first active then done; + } + }`)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("state machine Machine not found") + } + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newStateExecutor: %v", err) + } + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + err = exec.RunToCompletion() + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } + if !strings.Contains(err.Error(), "v") { + t.Errorf("error %q does not name the pin written", err) + } + if v := exec.StateData()["v"]; !valueEqual(v, integerValue(100)) { + t.Errorf("v = %v, want the machine's 100: the node's pin is not the machine's attribute", v) + } +} + +// testCalcBlockNodeUnvaluedPinWriteChecked: a pin a node in a calc's loop body +// declares without a value is the node's to write, so the write is judged against +// the pin's declaration, not refused as a name the calc never declared. +func testCalcBlockNodeUnvaluedPinWriteChecked(t *testing.T) { + err := calcErrorWithLibraries(t, `package test { + private import ScalarValues::*; + calc noted { + attribute v : Integer = 100; + for i in 1..1 { + action p { + out w : Integer; + assign w := "one"; + } + } + return : Integer = v; + } + }`, "noted", nil, 1000) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } + if !strings.Contains(err.Error(), "w") { + t.Errorf("error %q does not name the pin written", err) + } +} + +// testNodeBindingToANonParameter: a binding end at a node's pin must name a +// parameter or attribute the node's performance holds. +func testNodeBindingToANonParameter(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + attribute x : Integer = 1; + bind add.nope = x; + first start; + then action add : Adder { in a = 1; in b = 2; } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrBindingEnd) { + t.Fatalf("error = %v, want ErrBindingEnd", err) + } + if !strings.Contains(err.Error(), "nope") { + t.Errorf("error %q does not name the pin", err) + } +} + +// testNodeUndirectedBindingCarriedToANonParameter: a changed undirected attribute carried +// to a pin the downstream node does not declare is reported, not dropped. +func testNodeUndirectedBindingCarriedToANonParameter(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + bind acc.total = add.nope; + first start; + then action acc { attribute total : Integer; assign total := 3; } + then action add : Adder { in a = 1; in b = 2; } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrNodePin) { + t.Fatalf("error = %v, want ErrNodePin", err) + } + if !strings.Contains(err.Error(), "nope") { + t.Errorf("error %q does not name the pin", err) + } +} + +// testBlockNodeBindingToANonParameter: a binding in a branch at a pin of the +// branch's node is checked against that node's performance like any other. +func testBlockNodeBindingToANonParameter(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + attribute x : Integer = 1; + first start; + then action choose { + if x > 0 { + bind add.nope = x; + action add : Adder { in a = 1; in b = 2; } + } + } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrBindingEnd) { + t.Fatalf("error = %v, want ErrBindingEnd", err) + } + if !strings.Contains(err.Error(), "nope") { + t.Errorf("error %q does not name the pin", err) + } +} + +// testBlockNodeBindingNamesANodeWithoutAPin: a binding end in a branch that names +// one of the branch's nodes but no pin of it is reported, not run as a statement. +func testBlockNodeBindingNamesANodeWithoutAPin(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + attribute x : Integer = 1; + first start; + then action choose { + if x > 0 { + bind add = x; + action add : Adder { in a = 1; in b = 2; } + } + } + then done; + } + } + ` + err := runOuterAction(t, src) + if err == nil { + t.Fatal("expected the binding at the node itself to be reported") + } + if !strings.Contains(err.Error(), "names an action node but no pin of it") { + t.Errorf("error %q does not explain the binding end", err) + } +} + +// testBlockNodePinBoundWhereNodesAreNotPerformed: a calc body keeps no +// performances of its nested actions, so a binding at one of their pins is reported. +func testBlockNodePinBoundWhereNodesAreNotPerformed(t *testing.T) { + src := ` + package test { + calc c { + attribute x : Integer = 3; + attribute seen : Integer = 0; + if x > 0 { + action p { out v : Integer; assign v := x * 2; } + bind p.v = x; + assign seen := 1; + } + return : Integer = seen; + } + } + ` + err := calcUsageOutputInSource(t, src, "c", "result", 10000) + if err == nil || !strings.Contains(err.Error(), "a binding or flow at a pin of node p in a body is not executable") { + t.Errorf("expected the binding at p's pin to be reported, got: %v", err) + } +} + +// testBlockNodeOwnFlowMalformed: the flow an action in a loop body states of its +// own is validated with the action's, so a dangling succession in it is reported +// at initialize() as an invalid flow rather than when the loop reaches it. +func testBlockNodeOwnFlowMalformed(t *testing.T) { + src := ` + package test { + action outer { + attribute i : Integer = 0; + first start; + then action iterate { + while i < 2 { + action step { + first start; + then action one { assign i := i + 1; } + succession one then missing; + } + } + } + then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + _, err := ctx.CreateActionExecutor(sym) + if !errors.Is(err, ErrInvalidActionFlow) { + t.Fatalf("expected ErrInvalidActionFlow for step's dangling succession, got: %v", err) + } + if !strings.Contains(err.Error(), "action node step") || !strings.Contains(err.Error(), "missing") { + t.Errorf("error %q does not name the node and the undefined target", err) + } +} + +// testInheritedBindingNamesANodeWithoutAPin: a binding a base action states at an +// inherited node itself is reported when the derived action's flow is built. +func testInheritedBindingNamesANodeWithoutAPin(t *testing.T) { + src := ` + package test { + action def Base { + attribute x : Integer = 5; + action add { in a : Integer; } + bind add = x; + } + action def Derived :> Base { + first start then add; + succession add then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Derived", ast.DefAction) + if sym == nil { + t.Fatal("action Derived not found") + } + _, err := ctx.CreateActionExecutor(sym) + if err == nil { + t.Fatal("expected the inherited binding at the node itself to be reported") + } + if !strings.Contains(err.Error(), `binding end "add" names an action node but no pin of it`) { + t.Errorf("error %q does not explain the binding end", err) + } +} + +// testInheritedBindingDoesNotReachAMaskingNode: a base's binding at a node it +// declares does not bind the same-named pin of a node the derived action +// declares in its place, so that pin stays unvalued. +func testInheritedBindingDoesNotReachAMaskingNode(t *testing.T) { + src := ` + package test { + action def Base { + attribute x : Integer = 5; + action add { in a : Integer; out sum : Integer; assign sum := a; } + bind add.a = x; + } + action def Derived :> Base { + action add { in a : Integer; out sum : Integer; assign sum := a + 1; } + first start then add; + succession add then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Derived", ast.DefAction) + if sym == nil { + t.Fatal("action Derived not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + err = exec.RunToCompletion() + if err == nil { + t.Fatal("the masking node's pin took the base's binding") + } + var noValue *NoValueError + if !errors.As(err, &noValue) || noValue.Feature != "a" { + t.Errorf("error %v, want %T for a", err, noValue) + } + if v, ok := exec.Results()["add.sum"]; ok { + t.Errorf("add.sum = %v, want no value", v) + } +} + +// testInheritedBindingDoesNotReachThroughAReplacedOtherEnd: a base binding whose +// other end names a node the derived action replaced holds at neither end, so +// the inherited node's input is bound by nothing rather than by the replacement. +func testInheritedBindingDoesNotReachThroughAReplacedOtherEnd(t *testing.T) { + src := ` + package test { + action def Adder { + in a : Integer; + out sum : Integer; + first step; + action step { assign sum := a; } + } + action def Base { + action src { out n : Integer; assign n := 5; } + action add : Adder; + bind add.a = src.n; + } + action def Derived :> Base { + action src { out n : Integer; assign n := 1000; } + first start then src; + succession src then add; + succession add then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Derived", ast.DefAction) + if sym == nil { + t.Fatal("action Derived not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + err = exec.RunToCompletion() + if err == nil { + t.Fatal("the inherited node's pin took the replacement node's value") + } + if !errors.Is(err, ErrUnboundParameter) || !strings.Contains(err.Error(), "parameter a") { + t.Errorf("error %v, want ErrUnboundParameter for a", err) + } + if v, ok := exec.Results()["add.sum"]; ok { + t.Errorf("add.sum = %v, want no value", v) + } +} + +// testBlockNodeOwnFlowWhereNodesAreNotPerformed: a calc body keeps no performances +// of its nested actions, so one stating a flow of its own is reported, not run. +func testBlockNodeOwnFlowWhereNodesAreNotPerformed(t *testing.T) { + src := ` + package test { + calc c { + attribute x : Integer = 3; + attribute seen : Integer = 0; + if x > 0 { + action p { + first start; + then action bump { assign seen := 1; } + then done; + } + } + return : Integer = seen; + } + } + ` + err := calcUsageOutputInSource(t, src, "c", "result", 10000) + if err == nil || !strings.Contains(err.Error(), "the flow node p states of its own in a body is not executable") { + t.Errorf("expected p's own flow to be reported, got: %v", err) + } +} + +// testBlockNodeOwnFlowThatNeverEnds: a cycle in the flow a block-declared node +// states of its own spends the action's token-flow budget, run by a body statement +// as it is, and reports that budget's error. +func testBlockNodeOwnFlowThatNeverEnds(t *testing.T) { + src := ` + package test { + action outer { + attribute x : Integer = 3; + first start; + then action pick { + if x > 0 { + action leg { + first a; + action a; + action b; + succession first a then b; + succession first b then a; + } + } + } + then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + ctx.maxActionSteps = 50 + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + err = exec.RunToCompletion() + if !errors.Is(err, ErrActionStepLimitExceeded) { + t.Fatalf("error = %v, want ErrActionStepLimitExceeded", err) + } + if !strings.Contains(err.Error(), MaxActionStepsEnvVar) { + t.Errorf("error %q does not name %s", err, MaxActionStepsEnvVar) + } +} + +// testNodePinBoundToUnequalValues: two bindings at one input pin are two equalities, +// so unequal other ends are a binding conflict, not a declaration-order choice. +func testNodePinBoundToUnequalValues(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + attribute x : Integer = 1; + attribute y : Integer = 2; + bind add.a = x; + bind add.a = y; + first start; + then action add : Adder { in b = 2; } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("error = %v, want ErrBindingConflict", err) + } + if got, want := err.Error(), "binding conflict at add.a: x = 1, y = 2"; got != want { + t.Errorf("conflict error = %q, want %q", got, want) + } +} + +// testNestedPinBindingIntoANodePerformingAnotherAction: a node typed by an action def +// performs that action, whose nodes are its own, so a binding reaching into it is +// reported when the flow is built rather than routed to the node's like-named pin. +func testNestedPinBindingIntoANodePerformingAnotherAction(t *testing.T) { + src := ` + package test { + action def Leg { + in w : Integer; + first start; + then action inner { in w : Integer; } + then done; + } + action outer { + attribute x : Integer = 5; + bind leg.inner.w = x; + first start; + then action leg : Leg; + then done; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + _, err := ctx.CreateActionExecutor(sym) + want := `binding end "leg.inner.w" reaches into leg, which performs an action of its own rather than declaring inner; bind at a pin of leg itself` + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want %q", err, want) + } +} + +// testNestedPinBindingAtAnUndeclaredPin: a binding reaching a nested node must name a +// pin that node declares; the inner node reports it as it begins. +func testNestedPinBindingAtAnUndeclaredPin(t *testing.T) { + src := ` + package test { + action outer { + attribute x : Integer = 5; + bind leg.inner.nope = x; + first start; + then action leg { + first start; + then action inner { in w : Integer; } + then done; + } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrBindingEnd) { + t.Fatalf("error = %v, want ErrBindingEnd", err) + } + if !strings.Contains(err.Error(), "leg.inner.nope names no parameter or attribute of") { + t.Errorf("error %q does not name the nested pin path", err) + } +} + +// testFlowReachingIntoANodesOwnFlow: a flow joins pins of the nodes of one flow, so an +// end reaching into a node's own flow is reported when the flow is built. +func testFlowReachingIntoANodesOwnFlow(t *testing.T) { + src := ` + package test { + action outer { + first start; + then action leg { + first start; + then action inner { out v : Integer; assign v := 1; } + then done; + } + then action q { in n : Integer; } + then done; + flow leg.inner.v to q.n; + } + } + ` + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + _, err := ctx.CreateActionExecutor(sym) + want := `end "leg.inner.v" reaches into a node's own flow` + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want %q", err, want) + } +} + +// testNodeBindingOutputToAnUnknownFeature: what a node's output pin is bound to +// must be a feature the action holds, so the value has somewhere to go. +func testNodeBindingOutputToAnUnknownFeature(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + bind add.sum = nowhere; + first start; + then action add : Adder { in a = 1; in b = 2; } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrBindingEnd) { + t.Fatalf("error = %v, want ErrBindingEnd", err) + } +} + +// testNodeBindingOutputThroughAScalarChain: a chained other end must reach an +// object whose feature the output can be written to. +func testNodeBindingOutputThroughAScalarChain(t *testing.T) { + src := ` + package test {` + adderActionDef + ` + action outer { + attribute x : Integer = 1; + bind add.sum = x.value; + first start; + then action add : Adder { in a = 1; in b = 2; } + then done; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrBindingEnd) || !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrBindingEnd wrapping ErrTypeMismatch", err) + } + if !strings.Contains(err.Error(), "x.value") { + t.Errorf("error %q does not name the chained end x.value", err) + } +} + +// testNodeBindingOutputThroughAChainViolatesTargetType: an output written through a +// chain answers to the reached feature's declared type as a direct write does. +func testNodeBindingOutputThroughAChainViolatesTargetType(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + part def Holder { attribute label : String = "none"; } + action outer { + part holder : Holder; + bind num.v = holder.label; + first start; + then action num { out v : Integer; assign v := 3; } + then done; + } + } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action outer not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create action executor: %v", err) + } + err = exec.RunToCompletion() + if !errors.Is(err, ErrBindingEnd) || !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrBindingEnd wrapping ErrTypeMismatch", err) + } +} + +// testNodeFlowIntoAPinTheTargetDoesNotDeclare: a flow's target pin must be a +// feature of the node it reaches. +func testNodeFlowIntoAPinTheTargetDoesNotDeclare(t *testing.T) { + src := ` + package test { + action outer { + first start; + then action p { out v : Integer; assign v := 1; } + then action q { in w : Integer; } + then done; + flow p.v to q.nope; + } + } + ` + err := runOuterAction(t, src) + if !errors.Is(err, ErrNodePin) { + t.Fatalf("error = %v, want ErrNodePin", err) + } +} + +// functionValueFixture declares Fn, which applies its calc-typed parameter f to a. +const functionValueFixture = ` + private import ScalarValues::*; + calc def Sq { in v : Real; return : Real = v * v; } + calc def Add { in x : Real; in y : Real; return : Real = x + y; } + calc def Fn { in calc f { in v : Real; return : Real; } in a : Real; return : Real = f(a); } +` + +// invokeCalcExpecting evaluates the calc call expr against src with the standard library, +// on its own goroutine so a body that never terminates fails the case instead of stalling it. +func invokeCalcExpecting(t *testing.T, src, expr string) error { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + ctx.maxSteps = 100000 + scope := idx.DocumentRoot("") + + done := make(chan error, 1) + go func() { + node := parser.New(source.New("", []byte(expr))).ParseExpression() + result, err := ctx.EvalWithScopeOn(node, scope, nil) + if err == nil { + err = fmt.Errorf("%s = %s, expected it to fail", expr, FormatTraceValue(result)) + } + done <- err + }() + select { + case err := <-done: + return err + case <-watchdog(10 * time.Second): + t.Fatalf("%s did not terminate", expr) + return nil + } +} + +// testFunctionValueCallOfANonFunction: a scalar passed where a calc-typed parameter +// is declared is refused when it is bound, before the body calls it. +func testFunctionValueCallOfANonFunction(t *testing.T) { + err := invokeCalcExpecting(t, `package test {`+functionValueFixture+`}`, "test::Fn(3.0, 3.0)") + if !errors.Is(err, ErrNotAFunction) || !strings.Contains(err.Error(), `parameter "f"`) { + t.Fatalf("error = %v, want ErrNotAFunction naming f", err) + } +} + +// testFunctionValueBoundToANonFunction: a named argument binding a calc-typed +// parameter to an object is refused the same way. +func testFunctionValueBoundToANonFunction(t *testing.T) { + src := `package test {` + functionValueFixture + ` + part def Box; + part box : Box; + }` + err := invokeCalcExpecting(t, src, "test::Fn(a = 3.0, f = test::box)") + if !errors.Is(err, ErrNotAFunction) { + t.Fatalf("error = %v, want ErrNotAFunction", err) + } +} + +// testFunctionValueArityMismatch: applying a function value to more arguments +// than its calc declares is a calc arity error naming the calc the value is of. +func testFunctionValueArityMismatch(t *testing.T) { + src := `package test {` + functionValueFixture + ` + calc def Two { in calc f { in v : Real; return : Real; } return : Real = f(1.0, 2.0); } + }` + err := invokeCalcExpecting(t, src, "test::Two(test::Sq)") + if !errors.Is(err, ErrCalcArity) || !strings.Contains(err.Error(), "test::Sq") { + t.Fatalf("error = %v, want ErrCalcArity for test::Sq", err) + } +} + +// testFunctionValueUnknownNamedArgument: a named argument the applied calc does +// not declare is reported against that calc, not the parameter it was passed through. +func testFunctionValueUnknownNamedArgument(t *testing.T) { + src := `package test {` + functionValueFixture + ` + calc def Named { in calc f { in v : Real; return : Real; } return : Real = f(w = 1.0); } + }` + err := invokeCalcExpecting(t, src, "test::Named(test::Sq)") + if !errors.Is(err, ErrUnknownParameter) || !strings.Contains(err.Error(), "test::Sq") { + t.Fatalf("error = %v, want ErrUnknownParameter for test::Sq", err) + } +} + +// testFunctionValueUnboundCalcParameter: a calc-typed parameter no argument binds +// is reported as unbound when the calc is invoked, and applying a function value +// to fewer arguments than its calc needs is reported the same way. +func testFunctionValueUnboundCalcParameter(t *testing.T) { + src := `package test {` + functionValueFixture + ` + calc def Partial { in calc f { in x : Real; in y : Real; return : Real; } return : Real = f(1.0); } + }` + err := invokeCalcExpecting(t, src, "test::Fn(a = 3.0)") + if !errors.Is(err, ErrUnboundParameter) || !strings.Contains(err.Error(), `parameter "f"`) { + t.Fatalf("error = %v, want ErrUnboundParameter naming f", err) + } + err = invokeCalcExpecting(t, src, "test::Partial(test::Add)") + if !errors.Is(err, ErrUnboundParameter) || !strings.Contains(err.Error(), `parameter "y"`) { + t.Fatalf("error = %v, want ErrUnboundParameter naming y", err) + } +} + +// testFunctionValueOfAWrongTypedCalc: a calc-typed parameter typed by a calc def +// refuses a function value of an unrelated calc. +func testFunctionValueOfAWrongTypedCalc(t *testing.T) { + src := `package test {` + functionValueFixture + ` + calc def Typed { in calc f : Sq; return : Real = f(2.0); } + }` + err := invokeCalcExpecting(t, src, "test::Typed(test::Add)") + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } +} + +// testFunctionValueOfABuiltIn: a library function the runtime binds unevaluated +// has no value to pass on, and says so. +func testFunctionValueOfABuiltIn(t *testing.T) { + src := `package test {` + functionValueFixture + ` + private import ControlFunctions::*; + calc def PassIf { return : Real = Fn(ControlFunctions::'if', 3.0); } + }` + err := invokeCalcExpecting(t, src, "test::PassIf()") + if !errors.Is(err, ErrNotAFunction) { + t.Fatalf("error = %v, want ErrNotAFunction", err) + } +} + +// testFunctionValueAppliedToItselfForever: a calc passing itself as a function +// value to itself without end spends the recursion budget rather than hanging. +func testFunctionValueAppliedToItselfForever(t *testing.T) { + src := `package test {` + functionValueFixture + ` + calc def Loop { in calc f { in v : Real; return : Real; } in v : Real; return : Real = Loop(f, f(v)); } + }` + err := invokeCalcExpecting(t, src, "test::Loop(test::Sq, 1.0)") + if !errors.Is(err, ErrCalcRecursionLimit) && !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("error = %v, want the recursion or step budget spent", err) + } +} + +// testFunctionValueInheritedBodyOutsideTheClosure: a usage nested in a calc body +// closes over that body only for the code written there; the body it inherits from +// a calc declared outside reads no binding of the enclosing run, however it is applied. +func testFunctionValueInheritedBodyOutsideTheClosure(t *testing.T) { + src := `package test {` + functionValueFixture + ` + calc def Leaky { in v : Real; return : Real = v * k; } + calc def Bare { in k : Real; calc inner : Leaky { in v = 2.0; } return : Real = inner; } + calc def Called { in k : Real; calc inner : Leaky; return : Real = inner(2.0); } + calc def Passed { in k : Real; calc inner : Leaky; return : Real = Fn(inner, 2.0); } + }` + for _, expr := range []string{"test::Bare(3.0)", "test::Called(3.0)", "test::Passed(3.0)"} { + err := invokeCalcExpecting(t, src, expr) + if !errors.Is(err, ErrNoValue) && !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("%s: error = %v, want k unresolved in Leaky's body", expr, err) + } + } +} + +// testFunctionValueNestedCalcOutsideItsRun: a calc nested in another calc's body +// closes over a run of that calc alone; applied from a calc that binds the same +// parameter name while no such run is active, it reads no binding of the caller's. +func testFunctionValueNestedCalcOutsideItsRun(t *testing.T) { + src := `package test {` + functionValueFixture + ` + calc def Outer { in k : Real; calc inner { in v : Real; return : Real = v * k; } return : Real = inner(1.0); } + calc def Called { in k : Real; return : Real = Outer::inner(2.0); } + calc def Passed { in k : Real; return : Real = Fn(Outer::inner, 2.0); } + }` + for _, expr := range []string{"test::Called(3.0)", "test::Passed(3.0)"} { + err := invokeCalcExpecting(t, src, expr) + if !errors.Is(err, ErrNoValue) && !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("%s: error = %v, want k unresolved in inner's body", expr, err) + } + } +} + +// verificationRobustnessModel states a case whose subject nothing binds, one +// whose body reads a feature holding no value, and a part that is no case. +const verificationRobustnessModel = ` + package test { + part def Sensor { attribute reading : ScalarValues::Integer; } + part unread : Sensor; + + verification def Unbound { + subject sensor : Sensor; + VerificationCases::PassIf(sensor.reading == 0) + } + + action def Adder { + in a : ScalarValues::Integer; + in b : ScalarValues::Integer; + out sum : ScalarValues::Integer; + first step; + action step { assign sum := a + b; } + } + action adder : Adder; + + verification def Stepping { + subject sensor : Sensor; + first start; + then perform adder; + then done; + } + + verification stepping : Stepping { subject sensor = unread; } + + verification def Thresholded { + subject sensor : Sensor; + in threshold : ScalarValues::Integer; + VerificationCases::PassIf(sensor.reading == threshold) + } + + verification def Plan { + subject sensor : Sensor; + verification sub : Thresholded; + VerificationCases::PassIf(true) + } + + verification plan : Plan { subject sensor = unread; } + } +` + +// testVerificationBodyThatCannotRun: a body whose subject nothing binds is the +// case's error verdict carrying the typed error, not a failed call and not a +// panic. +func testVerificationBodyThatCannotRun(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, verificationRobustnessModel)) + scope := idx.DocumentRoot("") + result, err := ctx.RunVerification(lookupOne(t, idx, "test::Unbound"), AnalysisArgs{}, scope, nil) + if err != nil { + t.Fatalf("RunVerification error = %v, want an error verdict", err) + } + if result.Verdict.Kind != VerdictError { + t.Fatalf("verdict = %q, want error", result.Verdict.Kind) + } + if !strings.Contains(result.Verdict.Detail, "sensor") { + t.Errorf("verdict detail %q does not name the unbound subject", result.Verdict.Detail) + } +} + +// testVerificationBodyStepThatFails: a step that fails at run time — an action +// performed with an input nothing binds — ends the run in the same error verdict. +func testVerificationBodyStepThatFails(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, verificationRobustnessModel)) + scope := idx.DocumentRoot("") + result, err := ctx.RunVerification(lookupOne(t, idx, "test::stepping"), AnalysisArgs{}, scope, nil) + if err != nil { + t.Fatalf("RunVerification error = %v, want an error verdict", err) + } + if result.Verdict.Kind != VerdictError { + t.Fatalf("verdict = %q (%s), want error", result.Verdict.Kind, result.Verdict.Detail) + } + if !strings.Contains(result.Verdict.Detail, "adder") { + t.Errorf("verdict detail %q does not name the step that failed", result.Verdict.Detail) + } +} + +// testVerificationSubcaseThatCannotRun: a performed subcase whose input nothing +// binds ends the performing case's run, so its verdict is the error naming the +// subcase and the reason rather than a verdict of the body it never finished. +func testVerificationSubcaseThatCannotRun(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, verificationRobustnessModel)) + scope := idx.DocumentRoot("") + result, err := ctx.RunVerification(lookupOne(t, idx, "test::plan"), AnalysisArgs{}, scope, nil) + if err != nil { + t.Fatalf("RunVerification error = %v, want an error verdict", err) + } + if result.Verdict.Kind != VerdictError { + t.Fatalf("verdict = %q (%s), want error", result.Verdict.Kind, result.Verdict.Detail) + } + for _, want := range []string{"sub", "threshold"} { + if !strings.Contains(result.Verdict.Detail, want) { + t.Errorf("verdict detail %q does not name %q", result.Verdict.Detail, want) + } + } + if len(result.Subcases) != 0 { + t.Errorf("subcase verdicts = %v, want none from a body that did not finish", result.Subcases) + } +} + +// testVerificationOfASymbolThatIsNotACase: asking a part for a verdict faults +// the request, which is an error rather than a verdict. +func testVerificationOfASymbolThatIsNotACase(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, verificationRobustnessModel)) + scope := idx.DocumentRoot("") + _, err := ctx.RunVerification(lookupOne(t, idx, "test::Sensor"), AnalysisArgs{}, scope, nil) + if !errors.Is(err, ErrNotAVerification) { + t.Fatalf("error = %v, want ErrNotAVerification", err) + } +} + +// testVerificationWithAnArgumentTheCaseDoesNotTake: a named argument no +// parameter of the case declares faults the request too. +func testVerificationWithAnArgumentTheCaseDoesNotTake(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, verificationRobustnessModel)) + scope := idx.DocumentRoot("") + args := AnalysisArgs{Named: map[string]Value{"nope": integerValue(1)}} + _, err := ctx.RunVerification(lookupOne(t, idx, "test::stepping"), args, scope, nil) + if !errors.Is(err, ErrUnknownParameter) { + t.Fatalf("error = %v, want ErrUnknownParameter", err) + } +} + +// verificationObjectiveModel states a verification case whose objective is a +// requirement on a Rover while the case verifies a Lander, and one that +// verifies nothing bound. +const verificationObjectiveModel = ` + package test { + private import ScalarValues::*; + part def Lander { attribute touchdownSpeed : Real; } + part def Rover { attribute touchdownSpeed : Real; } + part scout : Lander { attribute :>> touchdownSpeed = 1.2; } + + requirement def SoftRoving { + subject rover : Rover; + in attribute limit : Real default = 1.5; + require constraint { rover.touchdownSpeed <= limit } + } + + verification def RoverCheck { + subject lander : Lander; + in attribute limit : Real = 1.5; + objective : SoftRoving { in limit = limit; } + VerificationCases::PassIf(lander.touchdownSpeed <= limit) + } + verification checkRover : RoverCheck { subject lander = scout; } + verification checkNothing : RoverCheck; + } +` + +// testVerificationObjectiveSubjectOfAnotherType: the case's subject, a Lander, is +// what the library binds the objective's subject to, so a requirement wanting a +// Rover leaves the objective undecided by a typed mismatch naming both types, +// while the body still answers. +func testVerificationObjectiveSubjectOfAnotherType(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, verificationObjectiveModel)) + scope := idx.DocumentRoot("") + result, err := ctx.RunVerification(lookupOne(t, idx, "test::checkRover"), AnalysisArgs{}, scope, nil) + if err != nil { + t.Fatalf("RunVerification error = %v, want the body's verdict", err) + } + if result.Verdict.Kind != VerdictPass { + t.Fatalf("verdict = %q (%s), want pass", result.Verdict.Kind, result.Verdict.Detail) + } + if len(result.Run.Verdicts) != 1 || result.Run.Verdicts[0].Status != VerdictUndecided { + t.Fatalf("verdicts = %+v, want the objective undecided", result.Run.Verdicts) + } + detail := result.Run.Verdicts[0].Detail + for _, want := range []string{"subject rover", "case's subject", "VerificationCases::VerificationCase::obj", ErrTypeMismatch.Error(), "Lander", "is not a Rover"} { + if !strings.Contains(detail, want) { + t.Errorf("detail %q does not say %q", detail, want) + } + } + if strings.Contains(detail, "Cases::Case::obj") || strings.Contains(detail, "result") { + t.Errorf("detail %q reports the analysis case's default, not the verification's binding", detail) + } +} + +// testVerificationObjectiveSubjectLeftUnbound: a verification binding no subject +// is refused by the typed error naming the verification's subject, not the +// objective's, on the run surface as on the verdict surface. +func testVerificationObjectiveSubjectLeftUnbound(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, verificationObjectiveModel)) + scope := idx.DocumentRoot("") + sym := lookupOne(t, idx, "test::checkNothing") + _, err := ctx.RunAnalysis(sym, AnalysisArgs{}, scope, nil) + var unbound *UnboundSubjectError + if !errors.As(err, &unbound) { + t.Fatalf("RunAnalysis error = %v, want an UnboundSubjectError", err) + } + if unbound.Kind != "verification" || unbound.Element != "test::checkNothing" || unbound.Subject != "lander" { + t.Errorf("error names %s %s: %s, want verification test::checkNothing: lander", unbound.Kind, unbound.Element, unbound.Subject) + } + result, err := ctx.RunVerification(sym, AnalysisArgs{}, scope, nil) + if err != nil { + t.Fatalf("RunVerification error = %v, want an error verdict", err) + } + if result.Verdict.Kind != VerdictError || result.Verdict.Detail != unbound.Error() { + t.Errorf("verdict = %q (%s), want error carrying %q", result.Verdict.Kind, result.Verdict.Detail, unbound.Error()) + } +} + +// testVerificationObjectiveSubjectRebound: the library binds a verification +// objective's subject with `=`, so a usage binding it itself is refused by the +// constraint tier, whichever way it names the subject. +func testVerificationObjectiveSubjectRebound(t *testing.T) { + const src = ` + package test { + private import ScalarValues::*; + part def Lander { attribute touchdownSpeed : Real; } + part scout : Lander { attribute :>> touchdownSpeed = 1.2; } + part other : Lander { attribute :>> touchdownSpeed = 1.3; } + + requirement def SoftLanding { + subject lander : Lander; + require constraint { lander.touchdownSpeed <= 1.5 } + } + + verification def Named { + subject lander : Lander; + objective : SoftLanding { subject lander = other; } + VerificationCases::PassIf(lander.touchdownSpeed <= 1.5) + } + verification def Anonymous { + subject lander : Lander; + objective : SoftLanding { subject = other; } + VerificationCases::PassIf(lander.touchdownSpeed <= 1.5) + } + verification def Redefining { + subject lander : Lander; + objective : SoftLanding { subject :>> subj = other; } + VerificationCases::PassIf(lander.touchdownSpeed <= 1.5) + } + } + ` + file := parseAndBuild(t, src) + idx := libs.NewModelIndex() + idx.AddDocument("", file) + idx.ExpandWildcardImports() + var refusals []string + for _, d := range passes.Analyze("", file, nil, idx) { + if d.Code == "feature-value-overriding" && d.Severity == diag.SeverityError { + refusals = append(refusals, d.Message) + } + } + if len(refusals) != 3 { + t.Fatalf("got %d refusals, want one per rebinding: %v", len(refusals), refusals) + } + for _, msg := range refusals { + if !strings.Contains(msg, "cannot override the binding value of VerificationCases::VerificationCase::obj::subj") { + t.Errorf("refusal %q does not name the library's binding", msg) + } + } +} + +// tradeStudyRobustnessModel states trade studies that cannot finish: an +// evaluation function left abstract, one that divides by an alternative's zero, +// a subject listing nothing, one redefined to a single value yet bound to two, +// and an evaluation reading a feature no alternative gives a value. +const tradeStudyRobustnessModel = ` + package test { + private import ScalarValues::*; + private import TradeStudies::*; + + part def Engine { attribute mass : Real; attribute cylinders : Integer; attribute cost : Real; } + part heavy : Engine { attribute :>> mass = 20.0; attribute :>> cylinders = 0; } + part light : Engine { attribute :>> mass = 10.0; attribute :>> cylinders = 2; } + + analysis abstractEval : TradeStudy { + subject : Engine[1..*] = (heavy, light); + objective : MinimizeObjective; + calc :>> evaluationFunction { in part e :>> alternative : Engine; return :>> result : Real; } + return part :>> selectedAlternative : Engine; + } + + analysis perCylinder : TradeStudy { + subject : Engine[1..*] = (light, heavy); + objective : MinimizeObjective; + calc :>> evaluationFunction { + in part e :>> alternative : Engine; + return :>> result : Real = e.mass / e.cylinders; + } + return part :>> selectedAlternative : Engine; + } + + analysis none : TradeStudy { + subject : Engine[1..*] = (); + objective : MinimizeObjective; + calc :>> evaluationFunction { in part e :>> alternative : Engine; return :>> result : Real = e.mass; } + return part :>> selectedAlternative : Engine; + } + + analysis single : TradeStudy { + subject : Engine[1] = (heavy, light); + objective : MinimizeObjective; + calc :>> evaluationFunction { in part e :>> alternative : Engine; return :>> result : Real = e.mass; } + return part :>> selectedAlternative : Engine; + } + + analysis unpriced : TradeStudy { + subject : Engine[1..*] = (heavy, light); + objective : MinimizeObjective; + calc :>> evaluationFunction { in part e :>> alternative : Engine; return :>> result : Real = e.cost; } + return part :>> selectedAlternative : Engine; + } + } +` + +// runTradeStudyExpecting runs the named analysis of tradeStudyRobustnessModel +// and returns what it reported; a run that does not terminate fails the test. +func runTradeStudyExpecting(t *testing.T, name string) (AnalysisResult, error) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, tradeStudyRobustnessModel)) + ctx.maxSteps = 100000 + scope := idx.DocumentRoot("") + sym := lookupOne(t, idx, name) + + type outcome struct { + result AnalysisResult + err error + } + done := make(chan outcome, 1) + go func() { + result, err := ctx.RunAnalysis(sym, AnalysisArgs{}, scope, nil) + done <- outcome{result, err} + }() + select { + case out := <-done: + if out.err == nil { + t.Fatalf("%s selected %s, expected the run to fail", name, FormatValue(out.result.Outputs[0].Value)) + } + return out.result, out.err + case <-watchdog(10 * time.Second): + t.Fatalf("%s did not terminate", name) + return AnalysisResult{}, nil + } +} + +// undecidedObjective asserts the result carries the trade study's objective as +// undecided and returns its detail. +func undecidedObjective(t *testing.T, result AnalysisResult) string { + t.Helper() + for _, v := range result.Verdicts { + if v.Kind == "objective" && v.Name == "tradeStudyObjective" { + if v.Status != VerdictUndecided { + t.Fatalf("objective %s = %s, want undecided", v.Name, v.Status) + } + return v.Detail + } + } + t.Fatalf("verdicts %v carry no tradeStudyObjective", result.Verdicts) + return "" +} + +// testTradeStudyWithAnAbstractEvaluationFunction: an evaluation function with no +// body is a typed missing-body error naming the calc, with the objective +// undecided and the one evaluation attempted recorded with its error; no pick +// is fabricated. +func testTradeStudyWithAnAbstractEvaluationFunction(t *testing.T) { + result, err := runTradeStudyExpecting(t, "test::abstractEval") + if !errors.Is(err, ErrNoResultExpression) || !strings.Contains(err.Error(), "test::abstractEval::evaluationFunction") { + t.Fatalf("error = %v, want ErrNoResultExpression naming the evaluation function", err) + } + undecidedObjective(t, result) + if len(result.Outputs) != 0 { + t.Errorf("outputs = %v, want none from a study that could not evaluate", result.Outputs) + } + if len(result.Evaluations) != 1 || result.Evaluations[0].Error == nil || result.Evaluations[0].Selected { + t.Fatalf("evaluations = %+v, want the one failed attempt and no selection", result.Evaluations) + } +} + +// testTradeStudyWhoseEvaluationFailsForOneAlternative: an alternative whose +// evaluation divides by zero fails the run with that typed error, the objective +// undecided, and the alternatives evaluated before it kept beside the failure. +func testTradeStudyWhoseEvaluationFailsForOneAlternative(t *testing.T) { + result, err := runTradeStudyExpecting(t, "test::perCylinder") + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("error = %v, want ErrDivisionByZero", err) + } + undecidedObjective(t, result) + if len(result.Evaluations) != 2 { + t.Fatalf("evaluations = %+v, want light's value and heavy's failure", result.Evaluations) + } + if result.Evaluations[0].Error != nil || result.Evaluations[0].Selected || result.Evaluations[0].Tied { + t.Errorf("light's evaluation = %+v, want a plain value", result.Evaluations[0]) + } + if !errors.Is(result.Evaluations[1].Error, ErrDivisionByZero) { + t.Errorf("heavy's evaluation error = %v, want ErrDivisionByZero", result.Evaluations[1].Error) + } +} + +// testTradeStudyWithAnEmptySubject: a subject listing no alternative violates the +// library's [1..*] before anything is evaluated. +func testTradeStudyWithAnEmptySubject(t *testing.T) { + result, err := runTradeStudyExpecting(t, "test::none") + if !errors.Is(err, ErrMultiplicityViolation) || !strings.Contains(err.Error(), "lower bound 1") { + t.Fatalf("error = %v, want ErrMultiplicityViolation against the lower bound", err) + } + undecidedObjective(t, result) + if len(result.Evaluations) != 0 { + t.Errorf("evaluations = %+v, want none", result.Evaluations) + } +} + +// testTradeStudyWithASingleValuedSubject: a subject redefined to [1] refuses the +// two alternatives bound to it as a multiplicity violation, not a study of one. +func testTradeStudyWithASingleValuedSubject(t *testing.T) { + result, err := runTradeStudyExpecting(t, "test::single") + if !errors.Is(err, ErrMultiplicityViolation) || !strings.Contains(err.Error(), "upper bound 1") { + t.Fatalf("error = %v, want ErrMultiplicityViolation against the upper bound", err) + } + undecidedObjective(t, result) + if len(result.Evaluations) != 0 { + t.Errorf("evaluations = %+v, want none", result.Evaluations) + } +} + +// testTradeStudyWhoseAlternativesReadAnUnboundFeature: an evaluation reading a +// feature the first alternative gives no value answers none, which minimize +// reports as the unset feature rather than as a value of the wrong kind; the +// objective is undecided and nothing is selected. +func testTradeStudyWhoseAlternativesReadAnUnboundFeature(t *testing.T) { + result, err := runTradeStudyExpecting(t, "test::unpriced") + var noValue *NoValueError + if !errors.As(err, &noValue) || noValue.Symbol == nil || noValue.Symbol.Name != "cost" { + t.Fatalf("error = %v, want a NoValueError naming cost", err) + } + undecidedObjective(t, result) + if len(result.Evaluations) != 1 || result.Evaluations[0].Selected { + t.Fatalf("evaluations = %+v, want heavy's valueless evaluation alone", result.Evaluations) + } +} + +// sweepRobustnessModel declares parameters no numeric range can bind, an +// Integer one a fractional step cannot step, and a Real one. +const sweepRobustnessModel = `package test { + private import ScalarValues::*; + part def Ship; + calc def Flag { in b : Boolean; return : Boolean = b; } + calc def Hull { in s : Ship; return : Ship = s; } + calc def Sq { in x : Integer; return : Integer = x * x; } + calc def Half { in x : Real; return : Real = x / 2.0; } +}` + +// refusedSweepPlan sweeps the named calc over the plan, both stepped and +// sampled, and returns each typed refusal, failing when a row was run. +func refusedSweepPlan(t *testing.T, name string, plan SweepPlan) []error { + t.Helper() + ctx, scope := analysisFixture(t, sweepRobustnessModel) + sym, ok := scope.LookupLocal(name) + if !ok { + t.Fatalf("calc %s not indexed", name) + } + sampled := plan + sampled.Sampled, sampled.Samples, sampled.Seed = true, 2, 1 + var errs []error + for _, p := range []SweepPlan{plan, sampled} { + resolved, err := ctx.ResolveSweepPlan(sym, p, 0, nil) + if err != nil { + t.Fatalf("%s refused the plan's parameter: %v", name, err) + } + runs := 0 + table, err := sweepIn(ctx, context.Background(), "test::"+name, resolved, 0, func(*Context, []SweepBinding) (SweepRunResult, error) { + runs++ + return SweepRunResult{}, nil + }) + if err == nil { + t.Fatalf("%s ran %d row(s); want a refusal", name, len(table.Rows)) + } + if !errors.Is(err, ErrSweepRange) { + t.Fatalf("error = %v, want ErrSweepRange", err) + } + if runs != 0 { + t.Fatalf("a refused plan made %d run(s)", runs) + } + errs = append(errs, err) + } + return errs +} + +// testSweepOverABooleanParameter: a Boolean takes no numeric range, so the plan +// is refused naming the parameter and its type, and no row is run. +func testSweepOverABooleanParameter(t *testing.T) { + plan := SweepPlan{Ranges: []SweepRange{{Param: "b", + From: Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 0}}, + To: Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}}}}} + for _, err := range refusedSweepPlan(t, "Flag", plan) { + if msg := err.Error(); !strings.Contains(msg, "b") || !strings.Contains(msg, "Boolean") { + t.Errorf("error = %v, want it to name b and Boolean", err) + } + } +} + +// testSweepOverAParameterTypedByAPart: a part definition is no scalar, so a +// range over a parameter it types is refused before any run. +func testSweepOverAParameterTypedByAPart(t *testing.T) { + plan := SweepPlan{Ranges: []SweepRange{{Param: "s", + From: Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 0}}, + To: Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}}}}} + for _, err := range refusedSweepPlan(t, "Hull", plan) { + if msg := err.Error(); !strings.Contains(msg, "s") || !strings.Contains(msg, "Ship") { + t.Errorf("error = %v, want it to name s and Ship", err) + } + } +} + +// testSweepOverAnIntegerParameterByAFraction: an Integer parameter takes no +// fractional step or endpoint, so the plan is refused rather than half its +// rows failing one by one. +func testSweepOverAnIntegerParameterByAFraction(t *testing.T) { + realVal := func(f float64) Value { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: f}} + } + ctx, scope := analysisFixture(t, sweepRobustnessModel) + sym, _ := scope.LookupLocal("Sq") + plan, err := ctx.ResolveSweepPlan(sym, SweepPlan{Ranges: []SweepRange{{ + Param: "x", From: realVal(1), To: realVal(3), Step: realVal(0.5), HasStep: true, + }}}, 0, nil) + if err != nil { + t.Fatalf("resolving x: %v", err) + } + _, err = sweepIn(ctx, context.Background(), "test::Sq", plan, 0, func(*Context, []SweepBinding) (SweepRunResult, error) { + t.Fatal("a row ran under a fractional step") + return SweepRunResult{}, nil + }) + if !errors.Is(err, ErrSweepRange) || !strings.Contains(err.Error(), "x : Integer") { + t.Fatalf("error = %v, want ErrSweepRange naming x : Integer", err) + } + for _, err := range refusedSweepPlan(t, "Sq", SweepPlan{Ranges: []SweepRange{{Param: "x", From: realVal(1.5), To: realVal(3)}}}) { + if !strings.Contains(err.Error(), "x : Integer") { + t.Errorf("error = %v, want it to name x : Integer", err) + } + } +} + +// testSweepOverARealParameterByIntegersNoRealHolds: a Real parameter takes the +// Integers of its range as Reals, so endpoints a Real rounds together are +// refused rather than collapsed onto one row, as is a step the reals cannot +// tell apart, before any row runs. +func testSweepOverARealParameterByIntegersNoRealHolds(t *testing.T) { + integer := func(n int64) Value { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: n}} + } + const big = int64(1) << 60 + for _, err := range refusedSweepPlan(t, "Half", SweepPlan{Ranges: []SweepRange{{Param: "x", From: integer(big), To: integer(big + 3)}}}) { + if msg := err.Error(); !strings.Contains(msg, "x : Real") || !strings.Contains(msg, "1152921504606846979") { + t.Errorf("error = %v, want it to name x : Real and the end 1152921504606846979", err) + } + } + ctx, scope := analysisFixture(t, sweepRobustnessModel) + sym, _ := scope.LookupLocal("Half") + plan, err := ctx.ResolveSweepPlan(sym, SweepPlan{Ranges: []SweepRange{{ + Param: "x", From: integer(big), To: integer(big + 512), + }}}, 0, nil) + if err != nil { + t.Fatalf("resolving x: %v", err) + } + _, err = sweepIn(ctx, context.Background(), "test::Half", plan, 0, func(*Context, []SweepBinding) (SweepRunResult, error) { + t.Fatal("a row ran under a step the reals cannot tell apart") + return SweepRunResult{}, nil + }) + if !errors.Is(err, ErrSweepRange) || !strings.Contains(err.Error(), "rows would repeat") { + t.Fatalf("error = %v, want ErrSweepRange refusing repeated rows", err) + } +} + +// testArithmeticOverTheUnboundedValue: `*` is no number, so arithmetic over it +// fails with a typed error naming the operation. +func testArithmeticOverTheUnboundedValue(t *testing.T) { + for _, expr := range []string{"* + 1", "1 - *", "2 * *", "* / 2", "* % 2", "* ** 2", "-*", "+*"} { + _, _, err := evalDeclaredExpr(t, "package test {}", expr) + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("%s: error = %v, want ErrTypeMismatch", expr, err) + } + } +} + +// testUnboundedValueComparedWithAString: nothing orders `*` against a string. +func testUnboundedValueComparedWithAString(t *testing.T) { + _, _, err := evalDeclaredExpr(t, "package test {}", `* > "a"`) + if !errors.Is(err, ErrTypeMismatch) { + t.Fatalf("error = %v, want ErrTypeMismatch", err) + } +} + +// testMetadataOfAValue: only an element carries metadata, so reading it from a +// scalar fails rather than answering with the empty sequence. +func testMetadataOfAValue(t *testing.T) { + for _, expr := range []string{"1.metadata", `"abc".metadata`} { + _, _, err := evalDeclaredExpr(t, "package test {}", expr) + if !errors.Is(err, ErrTypeMismatch) { + t.Errorf("%s: error = %v, want ErrTypeMismatch", expr, err) + } + } +} + +// testMetadataOfAnUnresolvedName: a name that names no element is refused. +func testMetadataOfAnUnresolvedName(t *testing.T) { + _, _, err := evalDeclaredExpr(t, "package test {}", "test::missing.metadata") + if !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("error = %v, want ErrUnresolvedReference", err) + } +} + +// testMetadataWithoutTheReflectiveLibrary: without the KerML library the reflective +// metaobject has no metaclass, so `.metadata` is refused whole; `@` still answers. +func testMetadataWithoutTheReflectiveLibrary(t *testing.T) { + const src = ` + package test { + metadata def Safety { attribute level = 4; } + part def Vehicle; + part seatBelt : Vehicle { @Safety; } + package probe { + attribute all [*] = test::seatBelt.metadata; + attribute safe = test::seatBelt @ Safety; + } + }` + model, resolver, root := parseAndBuildModel(t, src) + ctx := NewContext(typedModel(model, resolver), 10000) + probe := resolveSymbol(t, root, "test").Scope + probe = resolveSymbol(t, probe, "probe").Scope + valueOf := func(name string) (Value, error) { + decl := resolveSymbol(t, probe, name).Decl.(*ast.Usage) + return NewEvalContext(ctx, probe).Eval(decl.Value) + } + for range 2 { + got, err := valueOf("all") + if err == nil { + t.Fatalf("seatBelt.metadata = %s without the library, want ErrNoMetaclass", FormatValue(got)) + } + if !errors.Is(err, ErrNoMetaclass) || !strings.Contains(err.Error(), "test::seatBelt") { + t.Fatalf("seatBelt.metadata err = %v, want ErrNoMetaclass naming test::seatBelt", err) + } + } + if n := len(ctx.metadataObjects); n != 0 { + t.Errorf("%d annotation objects survive the refused read, want none", n) + } + if got, err := valueOf("safe"); err != nil || !got.isBool() || !got.Const.Bool { + t.Errorf("seatBelt @ Safety = %s, %v; want true", FormatValue(got), err) + } +} + +// stateMachineWithLibraries builds Machine from src over the standard library, +// for state bodies that name units, and returns it before initialization. +func stateMachineWithLibraries(t *testing.T, src string) *StateExecutor { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState) + if sym == nil { + t.Fatal("Machine not found") + } + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newStateExecutor: %v", err) + } + return exec +} + +// testStateDoTypedActionInputUnbound: a typed do usage that binds none of the +// action's input parameters is refused when the state's do behavior starts, +// naming the action and the parameter, and the action does not run. +func testStateDoTypedActionInputUnbound(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + action def Poll { in n : Integer; assign n := n + 1; } + state Machine { + attribute total : Integer = 0; + entry; then active; + state active { do action poll : Poll { } } + succession first active then done; + } + }`) + err := exec.RunToCompletion() + if !errors.Is(err, ErrUnboundParameter) { + t.Fatalf("expected ErrUnboundParameter, got: %v", err) + } + for _, want := range []string{"do action in state active", "action Poll", "input parameter n"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not say %q", err, want) + } + } + assertCurrentState(t, exec, "active") +} + +// testStateDoTypedActionPinBoundToMissingFeature: a typed do usage binding a pin +// to a feature the state does not declare is refused naming the pin, and the +// action does not run. +func testStateDoTypedActionPinBoundToMissingFeature(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + action def Poll { in n : Integer; assign n := n + 1; } + state Machine { + attribute total : Integer = 0; + entry; then active; + state active { do action poll : Poll { in n = nothing; } } + succession first active then done; + } + }`) + err := exec.RunToCompletion() + if !errors.Is(err, ErrUnresolvedReference) { + t.Fatalf("expected ErrUnresolvedReference, got: %v", err) + } + for _, want := range []string{"do action in state active", "n of node poll", "nothing"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not say %q", err, want) + } + } + assertCurrentState(t, exec, "active") +} + +// testStateDoTypedActionInoutValuedByAnImportedLiteral: an `inout` pin valued by +// an enumeration literal reached through an import holds the literal as its +// initial value; the performance ends without writing back to it, while the +// pin valued by a feature writes back to that feature. +func testStateDoTypedActionInoutValuedByAnImportedLiteral(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + private import ScalarValues::*; + enum def Mode { idle; busy; } + private import Mode::*; + action def Poll { + inout n : Integer; + inout mode : Mode; + first start; + then action count assign n := if mode == idle ? n + 1 else n + 100; + then action flip assign mode := busy; + then done; + } + state Machine { + attribute total : Integer = 0; + entry; then active; + state active { do action poll : Poll { inout n = total; inout mode = idle; } } + succession first active then done; + } + }`) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion: %v", err) + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(1)) { + t.Errorf("total = %v, want 1: n counted from mode idle and wrote back", total) + } + assertCurrentState(t, exec, "done") +} + +// testStateEntryBodyWaitsForTheClock: an entry body whose flow waits for the +// clock is refused when the state is entered, naming the wait; a state is +// entered at one instant, and only its do behavior may wait. +func testStateEntryBodyWaitsForTheClock(t *testing.T) { + exec := stateMachineWithLibraries(t, `package test { + private import ScalarValues::*; + private import SI::*; + state Machine { + attribute total : Integer = 0; + entry; then active; + state active { + entry action { + action w accept after 1 [s]; + then action c assign total := 1; + } + } + succession first active then done; + } + }`) + err := exec.initialize() + if !errors.Is(err, ErrStateBehaviorWaits) { + t.Fatalf("expected ErrStateBehaviorWaits, got: %v", err) + } + for _, want := range []string{"enter state active", "entry action", "t=1.0"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not say %q", err, want) + } + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: nothing after the wait must run", total) + } + if got := len(exec.ctx.Clock().Waits()); got != 0 { + t.Errorf("%d wait(s) left on the clock by the refused entry body", got) + } +} + +// testStateExitBodyWaitsForTheClock: an exit body whose flow waits for the clock +// is refused when the state is left, naming the wait, and time does not advance. +func testStateExitBodyWaitsForTheClock(t *testing.T) { + exec := stateMachineWithLibraries(t, `package test { + private import ScalarValues::*; + private import SI::*; + state Machine { + attribute total : Integer = 0; + entry; then active; + state active { + exit action { + action w accept after 1 [s]; + then action c assign total := 1; + } + } + succession first active then done; + } + }`) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + err := exec.RunToCompletion() + if !errors.Is(err, ErrStateBehaviorWaits) { + t.Fatalf("expected ErrStateBehaviorWaits, got: %v", err) + } + for _, want := range []string{"exit state", "exit action", "t=1.0"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not say %q", err, want) + } + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: nothing after the wait must run", total) + } + if now := exec.ctx.Clock().Now(); now != 0 { + t.Errorf("clock at %v, want 0: a refused exit body must not advance time", now) + } +} + +// testTransitionEffectBodyWaitsForTheClock: a transition effect whose flow waits +// for the clock is refused when the transition fires, naming the wait. +func testTransitionEffectBodyWaitsForTheClock(t *testing.T) { + exec := stateMachineWithLibraries(t, `package test { + private import ScalarValues::*; + private import SI::*; + state Machine { + attribute total : Integer = 0; + entry; then active; + state active; + transition first active then done do action { + action w accept after 1 [s]; + then action c assign total := 1; + } + } + }`) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + err := exec.RunToCompletion() + if !errors.Is(err, ErrStateBehaviorWaits) { + t.Fatalf("expected ErrStateBehaviorWaits, got: %v", err) + } + for _, want := range []string{"transition effect", "t=1.0"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not say %q", err, want) + } + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: nothing after the wait must run", total) + } +} + +// testStateDoBodyNestedAcceptCancelledOnExit: an accept nested one node deep in +// a do body, for a signal nothing sends, parks the body while the machine waits +// for its timed exit; leaving the state cancels the body, leaving no waiter +// behind and running nothing after the accept. +func testStateDoBodyNestedAcceptCancelledOnExit(t *testing.T) { + exec := stateMachineWithLibraries(t, `package test { + private import ScalarValues::*; + private import SI::*; + attribute def Go; + state Machine { + attribute total : Integer = 0; + entry; then active; + state active { + do action ops { + first start; + then action inner { + first start; + then action w accept g : Go; + then action c assign total := 1; + then done; + } + then done; + } + } + transition first active accept after 10 [s] then finished; + state finished; + } + }`) + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + assertCurrentState(t, exec, "finished") + if now := exec.ctx.Clock().Now(); now != 10 { + t.Errorf("clock at %v, want 10: the exit transition's instant", now) + } + if exec.HasPendingDoWork() { + t.Error("leaving the state must end its parked do body") + } + if got := len(exec.ctx.Clock().Waits()); got != 0 { + t.Errorf("%d wait(s) left on the clock after the state exited", got) + } + exec.ctx.PostMessage(Message{SignalType: "Go", Target: "w"}) + if exec.HasPendingDoWork() { + t.Error("a message after the exit must not revive the cancelled body") + } + if total := exec.StateData()["total"]; !valueEqual(total, integerValue(0)) { + t.Errorf("total = %v, want 0: the node after the cancelled accept must not run", total) + } +} + +// weightedActionError runs the action `route` of a body naming the Stochastic +// and RandomFunctions libraries and returns how it failed. +func weightedActionError(t *testing.T, body string, seed ...uint64) error { + t.Helper() + src := "package test {\n private import ScalarValues::*;\n private import SI::*;\n private import Stochastic::*;\n private import RandomFunctions::*;\n action route {" + body + "}\n}" + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "route", ast.DefAction) + if sym == nil { + t.Fatal("action route not found") + } + if len(seed) > 0 { + ctx.SetModelSeed(seed[0]) + } + _, err := ctx.ExecuteAction(sym) + return err +} + +// testWeightedDecisionWhoseWeightsDoNotSumToOne: constant weights out of one +// decision must sum to 1; the lowering refuses the action before it runs. +func testWeightedDecisionWhoseWeightsDoNotSumToOne(t *testing.T) { + err := weightedActionError(t, ` + first start; then decide d; + first d then fast { @Probability { p = 0.6; } } + first d then slow { @Probability { p = 0.6; } } + action fast; then done; + action slow; then done;`) + if !errors.Is(err, lower.ErrProbability) || !strings.Contains(err.Error(), "sum to 1.2") { + t.Fatalf("error = %v, want the Probability weights' sum refused", err) + } +} + +// testWeightedDecisionWithAWeightOutsideZeroToOne: a weight is a probability, +// so one above 1 or below 0 is refused where it is declared. +func testWeightedDecisionWithAWeightOutsideZeroToOne(t *testing.T) { + err := weightedActionError(t, ` + first start; then decide d; + first d then fast { @Probability { p = 1.2; } } + first d then slow { @Probability { p = -0.2; } } + action fast; then done; + action slow; then done;`) + if !errors.Is(err, lower.ErrProbability) || !strings.Contains(err.Error(), "outside 0.0..1.0") { + t.Fatalf("error = %v, want the out-of-range weight refused", err) + } +} + +// testDecisionMixingWeightedAndUnweightedSuccessions: every succession out of a +// decision is weighted or none is; a mix has no reading and is refused. +func testDecisionMixingWeightedAndUnweightedSuccessions(t *testing.T) { + err := weightedActionError(t, ` + first start; then decide d; + first d then fast { @Probability { p = 1.0; } } + first d then slow; + action fast; then done; + action slow; then done;`) + if !errors.Is(err, lower.ErrProbability) || !strings.Contains(err.Error(), "weights 1 of its 2 successions") { + t.Fatalf("error = %v, want the mixed decision refused", err) + } +} + +// testWeightedDecisionWhoseReadWeightIsNoProbability: a weight read from a +// feature is checked when read, whether one branch holds or several. +func testWeightedDecisionWhoseReadWeightIsNoProbability(t *testing.T) { + cases := []struct{ name, w, slowGuard, want string }{ + {"sole branch weighs zero", "0.0", "if not ready", "no holding branch has a positive weight"}, + {"sole branch weighs over one", "1.5", "if not ready", "branch 0 weighs 1.5, not a probability in [0, 1]"}, + {"sole branch weighs a negative", "-0.5", "if not ready", "branch 0 weighs -0.5, not a probability in [0, 1]"}, + {"sole branch weighs a boolean", "false", "if not ready", "weight of 1->fast is a Boolean, not a number"}, + {"both branches weigh zero", "0.0", "if ready", "no holding branch has a positive weight"}, + {"both branches weigh over one", "1.5", "if ready", "branch 0 weighs 1.5, not a probability in [0, 1]"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := weightedActionError(t, ` + attribute ready : Boolean = true; + attribute w = `+tc.w+`; + attribute taken : Integer = 0; + first start; then decide select; + first select then fast { @Probability { p = w; } } + first select `+tc.slowGuard+` then slow { @Probability { p = w; } } + action fast { assign taken := 1; } then done; + action slow { assign taken := 2; } then done;`, 3) + if !errors.Is(err, ErrBranchWeights) { + t.Fatalf("error = %v, want ErrBranchWeights", err) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %q, want it to say %q", err, tc.want) + } + }) + } +} + +// testRandomDrawWithoutASeed: a random function under no model seed and no +// `seed:` schedule refuses to draw, naming the call and the seed to give. +func testRandomDrawWithoutASeed(t *testing.T) { + err := weightedActionError(t, ` + attribute d : Real = uniform(0.0, 1.0); + first start; then done;`) + var unseeded *UnseededDrawError + if !errors.Is(err, ErrUnseededDraw) || !errors.As(err, &unseeded) { + t.Fatalf("error = %v, want ErrUnseededDraw", err) + } + if msg := err.Error(); !strings.Contains(msg, "uniform(0.0, 1.0)") || !strings.Contains(msg, "seed:") { + t.Fatalf("error = %q, want it to name the call and `seed:`", msg) + } +} + +// testRandomBoundsReversed: uniform(hi, lo) has an empty support and is refused +// before any draw, under a seed or not. +func testRandomBoundsReversed(t *testing.T) { + for _, call := range []string{"uniform(1.0, 0.0)", "uniformInteger(6, 1)", "triangular(1.0, 0.5, 0.0)", "normal(0.0, -1.0)"} { + err := weightedActionError(t, "attribute d = "+call+";\n first start; then done;", 3) + if !errors.Is(err, ErrRandomDomain) { + t.Errorf("%s: error = %v, want ErrRandomDomain", call, err) + } + } +} + +// testRandomDurationWithoutASeed: a wait whose duration is drawn refuses to park +// unseeded, with the same refusal a random value gets. +func testRandomDurationWithoutASeed(t *testing.T) { + err := weightedActionError(t, ` + first start; + then accept after uniform(1, 80) [s]; + then done;`) + if !errors.Is(err, ErrUnseededDraw) { + t.Fatalf("error = %v, want ErrUnseededDraw", err) + } +} + +// testMonteCarloPlanWithoutRuns: a Monte Carlo of fewer than one run, or one +// stating a range or sampling as well, is refused before any run is made. +func testMonteCarloPlanWithoutRuns(t *testing.T) { + ctx, _ := analysisFixture(t, sweepRobustnessModel) + one := Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}} + plans := map[string]SweepPlan{ + "none": MonteCarloPlan(0, 1), + "negative": MonteCarloPlan(-1, 1), + "ranged": {Runs: 2, Ranges: []SweepRange{{Param: "x", From: one, To: one}}}, + "sampled": {Runs: 2, Sampled: true, Samples: 2}, + } + for name, plan := range plans { + runs := 0 + table, err := sweepIn(ctx, context.Background(), "test::Sq", plan, 0, func(*Context, []SweepBinding) (SweepRunResult, error) { + runs++ + return SweepRunResult{}, nil + }) + if err == nil { + t.Fatalf("%s: ran %d row(s); want a refusal", name, len(table.Rows)) + } + if !errors.Is(err, ErrSweepRuns) { + t.Fatalf("%s: error = %v, want ErrSweepRuns", name, err) + } + if runs != 0 { + t.Fatalf("%s: a refused plan made %d run(s)", name, runs) + } + } +} diff --git a/internal/exec/runtime/robustness_transition_probability_test.go b/internal/exec/runtime/robustness_transition_probability_test.go new file mode 100644 index 0000000000..e82063102d --- /dev/null +++ b/internal/exec/runtime/robustness_transition_probability_test.go @@ -0,0 +1,663 @@ +package runtime + +import ( + "context" + "errors" + "math" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +// weightedMachine is a state def body importing ScalarValues and Stochastic, +// with the machine starting in a state the transitions under test leave. +func weightedMachine(body string) string { + return `package test { + private import ScalarValues::*; + private import Stochastic::*; + state def Machine { + entry; then a; + state a; + state b; + state c; +` + body + ` + } +}` +} + +// weightedStateRun lowers, creates and runs the machine of weightedMachine, +// sending it signal first, and answers the first error it reports. +func weightedStateRun(t *testing.T, body, signal string) error { + t.Helper() + m := parseLibraryModel(t, weightedMachine(body)) + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + exec, err := ctx.CreateStateExecutor(m.state(t, "Machine")) + if err != nil { + return err + } + if signal != "" { + exec.SendSignal(signal, nil) + } + return exec.RunToCompletion() +} + +// TestRuntimeRobustnessTransitionProbability exercises the failure modes of +// @Probability on state transitions: the checks lowering makes of constant +// weights — the range and the sum to 1 over the transitions competing for one +// event — and the checks made of the values read at dispatch, each a typed +// error naming the transition, never a silent renormalization. +func TestRuntimeRobustnessTransitionProbability(t *testing.T) { + t.Run("negative_weight", testTransitionNegativeWeight) + t.Run("weight_above_one", testTransitionWeightAboveOne) + t.Run("weights_not_summing_to_one", testTransitionWeightsNotSummingToOne) + t.Run("single_weighted_transition_not_weighing_one", testSingleWeightedTransitionNotWeighingOne) + t.Run("mixed_weighted_and_unweighted_siblings", testTransitionMixedWeightedAndUnweighted) + t.Run("non_constant_weights_not_summing_at_dispatch", testTransitionWeightsNotSummingAtDispatch) + t.Run("non_numeric_weight", testTransitionWeightOfNonNumericType) + t.Run("holding_total_zero_at_dispatch", testTransitionHoldingTotalZero) + t.Run("lone_enabled_zero_weight", testLoneEnabledZeroWeight) + t.Run("lone_enabled_weight_above_one", testLoneEnabledWeightAboveOne) + t.Run("trigger_argument_weight_out_of_range", testTriggerArgumentWeightOutOfRange) + t.Run("lone_completion_dynamic_weight_out_of_range", testLoneCompletionWeightOutOfRange) + t.Run("lone_completion_zero_weight", testLoneCompletionZeroWeight) + t.Run("lone_completion_guarded_off_skips_weight", testLoneCompletionGuardedOffSkipsWeightCheck) + t.Run("fork_branch_weight_refused", testForkBranchWeightRefused) + t.Run("same_named_signals_separate_groups", testSameNamedSignalsSeparateGroups) +} + +// testTransitionNegativeWeight: a probability is in [0, 1], so a constant +// weight below 0 is refused where it is written, naming the transition. +func testTransitionNegativeWeight(t *testing.T) { + err := libraryStateExecutorError(t, weightedMachine(` + transition first a accept go then b { @Probability { p = -0.2; } } + transition first a accept go then c { @Probability { p = 1.2; } }`), "Machine") + if !errors.Is(err, lower.ErrProbability) || !strings.Contains(err.Error(), "outside 0.0..1.0") { + t.Fatalf("error = %v, want the out-of-range weight refused", err) + } + if !strings.Contains(err.Error(), "1->b") || !strings.Contains(err.Error(), "state a") { + t.Fatalf("error = %v, want it to name the transition and its state", err) + } +} + +// testTransitionWeightAboveOne: a constant weight above 1 is refused the same way. +func testTransitionWeightAboveOne(t *testing.T) { + err := libraryStateExecutorError(t, weightedMachine(` + transition first a accept go then b { @Probability { p = 1.2; } } + transition first a accept go then c { @Probability { p = -0.2; } }`), "Machine") + if !errors.Is(err, lower.ErrProbability) || !strings.Contains(err.Error(), "outside 0.0..1.0") { + t.Fatalf("error = %v, want the out-of-range weight refused", err) + } +} + +// testTransitionWeightsNotSummingToOne: the constant weights of the transitions +// competing for one event must sum to 1; 0.3 and 0.3 are refused at lowering. +func testTransitionWeightsNotSummingToOne(t *testing.T) { + err := libraryStateExecutorError(t, weightedMachine(` + transition first a accept go then b { @Probability { p = 0.3; } } + transition first a accept go then c { @Probability { p = 0.3; } }`), "Machine") + if !errors.Is(err, lower.ErrProbability) || !strings.Contains(err.Error(), "sum to 0.6, not 1.0") { + t.Fatalf("error = %v, want the weights' sum refused", err) + } + if !strings.Contains(err.Error(), "state a") || !strings.Contains(err.Error(), "accept go") { + t.Fatalf("error = %v, want it to name the state and the event", err) + } +} + +// testSingleWeightedTransitionNotWeighingOne: the sum rule applies to a group +// of one, so a lone weighted transition whose constant weight is not 1 is refused. +func testSingleWeightedTransitionNotWeighingOne(t *testing.T) { + err := libraryStateExecutorError(t, weightedMachine(` + transition first a accept go then b { @Probability { p = 0.5; } }`), "Machine") + if !errors.Is(err, lower.ErrProbability) || !strings.Contains(err.Error(), "sum to 0.5, not 1.0") { + t.Fatalf("error = %v, want the lone weight's sum refused", err) + } +} + +// testTransitionMixedWeightedAndUnweighted: every transition competing for one +// event is weighted or none is; a mix has no reading and is refused, naming the +// unweighted one. +func testTransitionMixedWeightedAndUnweighted(t *testing.T) { + err := libraryStateExecutorError(t, weightedMachine(` + transition first a accept go then b { @Probability { p = 1.0; } } + transition first a accept go then c;`), "Machine") + if !errors.Is(err, lower.ErrProbability) || + !strings.Contains(err.Error(), "state a weights 1 of its 2 transitions on accept go") { + t.Fatalf("error = %v, want the mixed group refused", err) + } + if !strings.Contains(err.Error(), "2->c") { + t.Fatalf("error = %v, want it to name the unweighted transition", err) + } +} + +// testTransitionWeightsNotSummingAtDispatch: weights computed from features are +// read where the guard is, and the group's sum is checked when the dispatch +// draws: pA + pB = 0.5 is a typed ErrBranchWeights, not a renormalization. +func testTransitionWeightsNotSummingAtDispatch(t *testing.T) { + err := weightedStateRun(t, ` + attribute pA : Real = 0.3; + attribute pB : Real = 0.2; + transition first a accept go then b { @Probability { p = pA; } } + transition first a accept go then c { @Probability { p = pB; } }`, "go") + if !errors.Is(err, ErrBranchWeights) || + !strings.Contains(err.Error(), "state a on accept go: the weights of its transitions sum to 0.5, not 1.0") { + t.Fatalf("error = %v, want ErrBranchWeights naming the sum 0.5", err) + } +} + +// testTransitionWeightOfNonNumericType: a p bound to a String or Boolean +// feature is a type error of the model, reported where it is written, as it is +// for a decision's successions. +func testTransitionWeightOfNonNumericType(t *testing.T) { + for _, tc := range []struct{ typ, value, want string }{ + {"String", `"often"`, "cannot bind String value to a feature typed by Real"}, + {"Boolean", "true", "cannot bind Boolean value to a feature typed by Real"}, + } { + src := weightedMachine(` + attribute w : ` + tc.typ + ` = ` + tc.value + `; + transition first a accept go then b { @Probability { p = w; } } + transition first a accept go then c { @Probability { p = 0.5; } }`) + file := parseAndBuild(t, src) + idx := libs.NewModelIndex() + idx.AddDocument("", file) + idx.ExpandWildcardImports() + var refusals []string + for _, d := range passes.Analyze("", file, nil, idx) { + if d.Severity == diag.SeverityError { + refusals = append(refusals, d.Message) + } + } + if len(refusals) != 1 || !strings.Contains(refusals[0], tc.want) { + t.Errorf("%s weight: errors %v, want one saying %q", tc.typ, refusals, tc.want) + } + } +} + +// testTransitionHoldingTotalZero: a distribution is checked whole, but the draw +// is over the enabled transitions' weights: two enabled transitions each +// weighing 0 leave nothing to draw, which is a typed error rather than a pick +// of the guarded-out sibling's 1. +func testTransitionHoldingTotalZero(t *testing.T) { + err := weightedStateRun(t, ` + attribute ready : Boolean = false; + transition first a accept go then b { @Probability { p = 0.0; } } + transition first a accept go then c { @Probability { p = 0.0; } } + transition first a accept go if ready then b { @Probability { p = 1.0; } }`, "go") + if !errors.Is(err, ErrBranchWeights) || + !strings.Contains(err.Error(), "no holding branch has a positive weight") { + t.Fatalf("error = %v, want ErrBranchWeights for the zero total", err) + } +} + +// An explore of a weighted dispatch enumerates both weighted alternatives, and +// each witness replays to the state the draw picked. +func TestExploreEnumeratesWeightedTransitions(t *testing.T) { + m := parseLibraryModel(t, weightedMachine(` + transition first a accept go then b { @Probability { p = 0.4; } } + transition first a accept go then c { @Probability { p = 0.6; } }`)) + sym := m.state(t, "Machine") + run := stateRun(sym, "go") + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil || !x.Complete() || x.Runs != 2 { + t.Fatalf("explore: %v, %v", x, err) + } + finals := map[string]bool{} + for _, o := range x.Outcomes { + finals[o.Outcome.FinalState] = true + if len(o.Witness) != 1 || o.Witness[0].Kind != ChoiceTransition || !o.Witness[0].Weighted() { + t.Fatalf("witness of %s is %s, want one weighted transition draw", o.Outcome, FormatChoices(o.Witness)) + } + } + if !finals["b"] || !finals["c"] { + t.Fatalf("outcomes reach %v, want both weighted alternatives", finals) + } + assertWitnessesReplay(t, x, m.fresh, run) +} + +// An explore of a nested weighted dispatch enumerates both alternatives of the +// innermost state's weighted pair — the enclosing state's transition on the +// same event is outranked, not weighed against — and each witness replays to +// the configuration the draw picked. +func TestExploreEnumeratesNestedWeightedTransitions(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import Stochastic::*; + state def Machine { + entry; then work; + state work parallel { + state left { + entry; then l1; + state l1; + state l2; + state l3; + transition first l1 accept go then l2 { @Probability { p = 0.4; } } + transition first l1 accept go then l3 { @Probability { p = 0.6; } } + } + state right { + entry; then r1; + state r1; + state r2; + transition first r1 accept go then r2; + } + } + state escaped; + transition first work accept go then escaped; + } +}`) + sym := m.state(t, "Machine") + run := stateRun(sym, "go") + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, run) + if err != nil || !x.Complete() { + t.Fatalf("explore: %v, %v", x, err) + } + finals := map[string]bool{} + for _, o := range x.Outcomes { + finals[o.Outcome.FinalState] = true + } + if !finals["l2+r2"] || !finals["l3+r2"] || finals["escaped"] { + t.Fatalf("outcomes reach %v, want l2+r2 and l3+r2 and never escaped", finals) + } + // The notes a nested firing records list the region's exit order ahead of + // the transition it fired, weighted or not; what a replay must follow is + // the witness's weighted pick, which reaching its outcome proves. + for _, o := range x.Outcomes { + outcome, choices, err := replayed(t, m.fresh, run, o.Witness) + if err != nil { + t.Fatalf("%s: replaying %s: %v", o.Outcome, FormatChoices(o.Witness), err) + } + if outcome.String() != o.Outcome.String() { + t.Fatalf("replaying %s reached %s, want %s", FormatChoices(o.Witness), outcome, o.Outcome) + } + followed := slices.ContainsFunc(choices, func(c ChoiceTaken) bool { + return c.Kind == ChoiceTransition && c.Weighted() + }) + if !followed { + t.Fatalf("replaying %s made no weighted transition pick: %s", FormatChoices(o.Witness), FormatChoices(choices)) + } + } +} + +// Explore still enumerates every weighted alternative and each outcome carries +// the product of its run's pick shares: the stated weight for a weighted pick. +func TestExploreReportsTransitionProbabilities(t *testing.T) { + m := parseLibraryModel(t, weightedMachine(` + transition first a accept go then b { @Probability { p = 0.3; } } + transition first a accept go then c { @Probability { p = 0.7; } } + `)) + sym := m.state(t, "Machine") + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, stateRun(sym, "go")) + if err != nil || !x.Complete() || len(x.Outcomes) != 2 { + t.Fatalf("explore: %v, %v", x, err) + } + probs := map[string]float64{} + for _, o := range x.Outcomes { + probs[o.Outcome.FinalState] += o.Probability + } + if math.Abs(probs["b"]-0.3) > 1e-9 || math.Abs(probs["c"]-0.7) > 1e-9 { + t.Errorf("probabilities %v, want b=0.3, c=0.7", probs) + } + if p := x.Probability(); math.Abs(p-1) > 1e-9 || x.ProbabilitiesBounded() { + t.Errorf("a complete exploration covers %v, want 1 exact", p) + } +} + +// A check of a machine whose dispatch is a weighted pick charges each violation +// the share of the path that reaches it: 0.3 for the 0.3-weighted transition. +func TestCheckWeightsViolationMass(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import Stochastic::*; + state def Machine { + entry; then decide; + state decide; + state bad; + state good; + state later; + transition first decide then bad { @Probability { p = 0.3; } } + transition first decide then good { @Probability { p = 0.7; } } + transition first good then later; + } + }`) + inv := invocationOf(nil, []*symbols.Symbol{m.state(t, "Machine")}) + prop := CheckProperty{Name: "notBad", Holds: func(_ *Context, inv *Invocation) (bool, error) { + return inv.States[0].FinalStateName() != "bad", nil + }} + + report, err := Check(context.Background(), m.fresh, inv, CheckBudget{}, CheckOptions{}, []CheckProperty{prop}) + if err != nil || report.Verdict != CheckViolation || len(report.Violations) != 1 { + t.Fatalf("check: %v, %v", report, err) + } + v := report.Violations[0] + if v.Name != "notBad" || math.Abs(v.Mass-0.3) > 1e-9 { + t.Errorf("violation %s carries mass %v, want 0.3", v.Name, v.Mass) + } + if report.MassBounded { + t.Errorf("a tree-shaped search reports its masses as lower bounds") + } + + bounded, err := Check(context.Background(), m.fresh, inv, CheckBudget{Depth: 1}, CheckOptions{}, []CheckProperty{prop}) + if err != nil || bounded.Verdict != CheckViolation || len(bounded.Violations) != 1 { + t.Fatalf("bounded check: %v, %v", bounded, err) + } + if !bounded.MassBounded || len(bounded.BoundsHit) == 0 { + t.Errorf("a depth cut reports massLowerBound=%v bounds=%v, want the bound hit", bounded.MassBounded, bounded.BoundsHit) + } +} + +// testLoneEnabledZeroWeight: a guard leaving only a zero-weighted transition +// enabled leaves the draw nothing to take — a typed error even though no choice +// point is recorded. +func testLoneEnabledZeroWeight(t *testing.T) { + err := weightedStateRun(t, ` + attribute w : Real = 1.0; + transition first a accept go if w < 0.5 then b { @Probability { p = w; } } + transition first a accept go then c { @Probability { p = 1.0 - w; } }`, "go") + if !errors.Is(err, ErrBranchWeights) || + !strings.Contains(err.Error(), "no holding branch has a positive weight") { + t.Fatalf("error = %v, want ErrBranchWeights for the lone enabled zero weight", err) + } +} + +// testLoneEnabledWeightAboveOne: a lone enabled transition is validated too — +// a dynamic weight outside [0, 1] is a typed error, not a transition taken at +// face value. +func testLoneEnabledWeightAboveOne(t *testing.T) { + err := weightedStateRun(t, ` + attribute w : Real = 1.5; + transition first a accept go if w < 1.0 then b { @Probability { p = 2.0 - w; } } + transition first a accept go then c { @Probability { p = w; } }`, "go") + if !errors.Is(err, ErrBranchWeights) || + !strings.Contains(err.Error(), "not a probability in [0, 1]") { + t.Fatalf("error = %v, want ErrBranchWeights for the lone enabled weight 1.5", err) + } +} + +// testTriggerArgumentWeightOutOfRange: a weight reading the call's bound +// argument is judged by the value the invocation carried — 1.5 is a typed +// error, the same as a literal out of range. +func testTriggerArgumentWeightOutOfRange(t *testing.T) { + m := parseLibraryModel(t, weightedMachine(` + transition first a accept route(priority) then b { @Probability { p = priority; } } + transition first a accept route(priority) then c { @Probability { p = 1.0 - priority; } }`)) + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + exec, err := ctx.CreateStateExecutor(m.state(t, "Machine")) + if err != nil { + t.Fatal(err) + } + exec.InvokeOperation("route", map[string]Value{"priority": constReal(1.5)}) + err = exec.RunToCompletion() + if !errors.Is(err, ErrBranchWeights) || + !strings.Contains(err.Error(), "not a probability in [0, 1]") { + t.Fatalf("error = %v, want ErrBranchWeights for the bound weight 1.5", err) + } +} + +// TestExploreWeighsTransitionsByTriggerArguments: the weight expressions read +// the arguments the triggering call carried — a route at priority 0.25 draws +// the 0.75 branch three times as often, which explore reports back. +func TestExploreWeighsTransitionsByTriggerArguments(t *testing.T) { + m := parseLibraryModel(t, weightedMachine(` + transition first a accept route(priority) then b { @Probability { p = priority; } } + transition first a accept route(priority) then c { @Probability { p = 1.0 - priority; } }`)) + sym := m.state(t, "Machine") + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, func(ctx *Context) (Outcome, error) { + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return Outcome{}, err + } + exec.InvokeOperation("route", map[string]Value{"priority": constReal(0.25)}) + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + }) + if err != nil { + t.Fatal(err) + } + probs := make(map[string]float64) + for _, o := range x.Outcomes { + probs[o.Outcome.FinalState] = o.Probability + } + if math.Abs(probs["b"]-0.25) > 1e-9 || math.Abs(probs["c"]-0.75) > 1e-9 { + t.Errorf("probabilities %v, want b=0.25 c=0.75 from the call's priority", probs) + } + if math.Abs(x.Probability()-1) > 1e-9 || x.ProbabilitiesBounded() { + t.Errorf("a complete exploration's probabilities sum to %v, want 1 unbounded", x.Probability()) + } +} + +// Two transitions off one `after` spelling arm one timer: its expiry is the +// single occurrence they compete for, drawn by weight — explore reports the +// 0.9 branch nine times likelier than the 0.1 one. +func TestExploreWeighsTimedTransitionsAsOneOccurrence(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import SI::*; + private import Stochastic::*; + state def Machine { + entry; then a; + state a; + state b; + state c; + transition first a accept after 5 [s] then b { @Probability { p = 0.9; } } + transition first a accept after 5 [s] then c { @Probability { p = 0.1; } } + } + }`) + sym := m.state(t, "Machine") + x, err := Explore(context.Background(), mustPolicy(t, "explore"), m.fresh, func(ctx *Context) (Outcome, error) { + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return Outcome{}, err + } + if err := exec.RunToCompletion(); err != nil { + return Outcome{}, err + } + return exec.Outcome(), nil + }) + if err != nil || !x.Complete() || len(x.Outcomes) != 2 { + t.Fatalf("explore: %v, %v", x, err) + } + probs := map[string]float64{} + for _, o := range x.Outcomes { + probs[o.Outcome.FinalState] += o.Probability + } + if math.Abs(probs["b"]-0.9) > 1e-9 || math.Abs(probs["c"]-0.1) > 1e-9 { + t.Errorf("probabilities %v, want b=0.9, c=0.1", probs) + } +} + +// A check of the grouped timer charges each violation the weight its member +// was drawn at: the 0.1-weighted branch's failure carries mass 0.1. +func TestCheckWeighsTimedTransitionsAsOneOccurrence(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import SI::*; + private import Stochastic::*; + state def Machine { + entry; then a; + state a; + state bad; + state good; + transition first a accept after 5 [s] then bad { @Probability { p = 0.1; } } + transition first a accept after 5 [s] then good { @Probability { p = 0.9; } } + } + }`) + inv := invocationOf(nil, []*symbols.Symbol{m.state(t, "Machine")}) + prop := CheckProperty{Name: "notBad", Holds: func(_ *Context, inv *Invocation) (bool, error) { + return inv.States[0].FinalStateName() != "bad", nil + }} + report, err := Check(context.Background(), m.fresh, inv, CheckBudget{}, CheckOptions{}, []CheckProperty{prop}) + if err != nil || report.Verdict != CheckViolation || len(report.Violations) != 1 { + t.Fatalf("check: %v, %v", report, err) + } + v := report.Violations[0] + if v.Name != "notBad" || math.Abs(v.Mass-0.1) > 1e-9 { + t.Errorf("violation %s carries mass %v, want 0.1", v.Name, v.Mass) + } +} + +// Two differently-spelled invocation timers arm two timers — the duration +// spellings key the groups, so a `uniform` and a `normal` each get their own +// occurrence rather than collapsing into the first member's. +func TestDistinctInvocationTimersArmSeparately(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import SI::*; + private import RandomFunctions::*; + state def Machine { + entry; then a; + state a; + state b; + state c; + transition first a accept after uniform(1.0, 2.0) [s] then b; + transition first a accept after normal(10.0, 1.0) [s] then c; + } + }`) + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + ctx.SetModelSeed(7) + exec, err := ctx.CreateStateExecutor(m.state(t, "Machine")) + if err != nil { + t.Fatal(err) + } + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + var timers []Event + for _, event := range exec.eventQueue.events { + if event.Type == EventTime { + timers = append(timers, event) + } + } + if len(timers) != 2 { + t.Fatalf("%d timers armed, want one per spelling: %v", len(timers), timers) + } + if timers[0].Timestamp == timers[1].Timestamp { + t.Errorf("both timers due at %v, want each at its own draw", timers[0].Timestamp) + } +} + +// testLoneCompletionWeightOutOfRange: a lone completion transition draws +// nothing, but its weight is still validated — a dynamic p of 1.5 refuses at +// dispatch the same as beside a sibling. +func testLoneCompletionWeightOutOfRange(t *testing.T) { + err := weightedStateRun(t, ` + attribute w : Real = 1.5; + transition first a then b { @Probability { p = w; } }`, "") + if !errors.Is(err, ErrBranchWeights) || + !strings.Contains(err.Error(), "not a probability in [0, 1]") { + t.Fatalf("error = %v, want ErrBranchWeights for the lone completion's 1.5", err) + } +} + +// testLoneCompletionZeroWeight: a lone completion weighted zero has nothing +// positive to hold it — refused at dispatch. +func testLoneCompletionZeroWeight(t *testing.T) { + err := weightedStateRun(t, ` + attribute w : Real = 0.0; + transition first a then b { @Probability { p = w; } }`, "") + if !errors.Is(err, ErrBranchWeights) || + !strings.Contains(err.Error(), "sum to 0") { + t.Fatalf("error = %v, want ErrBranchWeights for the lone zero-weighted completion", err) + } +} + +// testLoneCompletionGuardedOffSkipsWeightCheck: a lone completion whose guard +// does not hold fires nothing — its weight is never evaluated, so a dynamic p +// of 1.5 is no error and the state stays put. +func testLoneCompletionGuardedOffSkipsWeightCheck(t *testing.T) { + m := parseLibraryModel(t, weightedMachine(` + attribute flag : Boolean = false; + attribute w : Real = 1.5; + transition first a if flag then b { @Probability { p = w; } }`)) + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + exec, err := ctx.CreateStateExecutor(m.state(t, "Machine")) + if err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("guarded-off lone completion: %v", err) + } + if got := exec.Outcome().FinalState; got != "a" { + t.Fatalf("final state %q, want a — the weighted completion never enabled", got) + } +} + +// testForkBranchWeightRefused: a fork fires every branch — no pick weighs them, +// so a weight there is a typed lowering refusal, never silently ignored. +func testForkBranchWeightRefused(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import Stochastic::*; + state def Machine { + entry; then a; + state a; + state work parallel { + state left { entry; then x; state x; } + state right { entry; then y; state y; } + } + fork split; + transition first a then split; + transition first split then x { @Probability { p = 0.5; } } + transition first split then y { @Probability { p = 0.5; } } + } + }`) + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + _, err = ctx.CreateStateExecutor(m.state(t, "Machine")) + if !errors.Is(err, lower.ErrProbability) || + !strings.Contains(err.Error(), "out of a fork cannot be weighted") { + t.Fatalf("error = %v, want ErrProbability naming the fork branch", err) + } +} + +// testSameNamedSignalsSeparateGroups: A::Go and B::Go are different groups, so +// weighting one trigger does not oblige the other — the model runs. +func testSameNamedSignalsSeparateGroups(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import Stochastic::*; + package sigs { + package a { item def Go; } + package b { item def Go; } + } + state def Machine { + entry; then wait; + state wait; state b; + transition first wait accept sigs::a::Go then b { @Probability { p = 1.0; } } + transition first wait accept sigs::b::Go then wait; + } + }`) + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + exec, err := ctx.CreateStateExecutor(m.state(t, "Machine")) + if err != nil { + t.Fatalf("different definitions group apart — create must not refuse the mix: %v", err) + } + // One occurrence matching both accepts still runs both enabled checks; the + // dispatch is not refused for grouping reasons alone. + exec.SendSignal("Other", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } +} diff --git a/internal/exec/runtime/robustness_transition_via_test.go b/internal/exec/runtime/robustness_transition_via_test.go new file mode 100644 index 0000000000..7d54d894a9 --- /dev/null +++ b/internal/exec/runtime/robustness_transition_via_test.go @@ -0,0 +1,96 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessTransitionVia exercises the failure modes of a transition +// whose `via` path starts at a reference parameter of the machine that holds no +// object, or an object with no port the rest of the path names. +func TestRuntimeRobustnessTransitionVia(t *testing.T) { + t.Run("bound_root_holds_nothing", testTransitionViaRootHoldsNothing) + t.Run("bound_chain_names_no_port", testTransitionViaChainNamesNoPort) +} + +// rigListeningVia is a listener whose machine takes a signal via the given path +// from a reference parameter bound as given, fed by a sender through its own port. +func rigListeningVia(param, binding, via string) string { + return `package test { + item def Go; + item def Ack; + port def AckOut { out item ack : Ack; } + port def AckIn { in item ack : Ack; } + part def Peer { port hears : AckIn; } + part def Listener { + port own : AckIn; + ref part partner : Peer; + state def Life { + ` + param + ` + entry; then waiting; + state waiting; + transition first waiting accept Ack via ` + via + ` then heard; + state heard; + } + exhibit state life : Life { ` + binding + ` } + } + part def Sender { + port tx : AckOut; + exhibit state life { + entry; then idle; + state idle; + transition first idle accept Go then sent; + state sent { entry send new Ack() via tx; } + } + } + part def Rig { + part listener : Listener { :>> partner = live; } + part live : Peer; + part sender : Sender; + connect sender.tx to listener.own; + } + }` +} + +// signalListener instantiates the rig and has the sender fire: delivering the +// acknowledgement resolves the listener's via path, whose failure the delivery reports. +func signalListener(t *testing.T, model string) error { + t.Helper() + ctx, rig, err := instantiateWithLibraries(t, model, "test::Rig") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + listener := objectMachine(t, instanceAtPath(t, ctx, rig, "listener"), "") + if err := listener.RunToCompletion(); err != nil { + t.Fatalf("run listener machine: %v", err) + } + sender := objectMachine(t, instanceAtPath(t, ctx, rig, "sender"), "") + if err := sender.RunToCompletion(); err != nil { + t.Fatalf("run sender machine: %v", err) + } + sender.SendSignal("Go", nil) + return sender.RunToCompletion() +} + +// testTransitionViaRootHoldsNothing: a parameter bound to nothing names no object +// whose port the path could reach, so the machine refuses rather than waiting forever. +func testTransitionViaRootHoldsNothing(t *testing.T) { + err := signalListener(t, rigListeningVia( + "in ref context : Peer[0..1];", "in :>> context = null;", "context.hears", + )) + if !errors.Is(err, ErrSendTargetNotObject) || !strings.Contains(err.Error(), `"context" holds null, which is no object`) { + t.Fatalf("error = %v, want ErrSendTargetNotObject over an empty parameter", err) + } +} + +// testTransitionViaChainNamesNoPort: a path from the bound object through a +// feature it has none of is refused, naming the missing feature. +func testTransitionViaChainNamesNoPort(t *testing.T) { + err := signalListener(t, rigListeningVia( + "in ref context : Peer;", "in :>> context = partner;", "context.nowhere.hears", + )) + if !errors.Is(err, ErrNoSuchFeature) || !strings.Contains(err.Error(), `feature "nowhere" not found`) { + t.Fatalf("error = %v, want ErrNoSuchFeature over a path naming no feature", err) + } +} diff --git a/internal/exec/runtime/robustness_untyped_port_test.go b/internal/exec/runtime/robustness_untyped_port_test.go new file mode 100644 index 0000000000..4a1607a8e5 --- /dev/null +++ b/internal/exec/runtime/robustness_untyped_port_test.go @@ -0,0 +1,85 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessUntypedPort exercises the failure modes of ports declared +// without a type, which materialize as Ports::Port objects so binding connectors join them. +func TestRuntimeRobustnessUntypedPort(t *testing.T) { + t.Run("joined_to_no_receiving_port", testUntypedPortJoinedToNothing) + t.Run("bound_to_no_feature", testUntypedPortBoundToNoFeature) + t.Run("bound_inward_delivers", testUntypedPortBoundInwardDelivers) +} + +// rigWithUntypedPorts is a sender wired to a box whose untyped boundary port is +// bound as given to the nested receiver; wiring names the rig's connector, if any. +func rigWithUntypedPorts(binding, wiring string) string { + return `package test { + item def Go; + part def Sender { + port tx; + action def Fire { + first start; + then action go send new Go() via this.tx; + then done; + } + perform action fire : Fire; + } + part def Receiver { + port rx; + exhibit state life { + entry; then idle; + state idle; + state got; + transition first idle accept Go via rx then got; + } + } + part def Box { + port cmd; + part r : Receiver; + ` + binding + ` + } + part def Rig { + part s : Sender; + part b : Box; + ` + wiring + ` + } + }` +} + +// testUntypedPortJoinedToNothing: a send via an untyped port no connector joins +// is refused as unroutable rather than dropped. +func testUntypedPortJoinedToNothing(t *testing.T) { + _, _, err := instantiateWithLibraries(t, rigWithUntypedPorts("bind cmd = r.rx;", ""), "test::Rig") + if !errors.Is(err, ErrUnroutableSend) || !strings.Contains(err.Error(), `port "tx" is joined to no port that can receive it`) { + t.Fatalf("error = %v, want ErrUnroutableSend over an unjoined untyped port", err) + } +} + +// testUntypedPortBoundToNoFeature: a binding whose end names no feature of the +// nested part fails as an unresolvable binding end when the send reaches it. +func testUntypedPortBoundToNoFeature(t *testing.T) { + _, _, err := instantiateWithLibraries(t, rigWithUntypedPorts("bind cmd = r.nowhere;", "connect s.tx to b.cmd;"), "test::Rig") + if !errors.Is(err, ErrBindingEnd) || !strings.Contains(err.Error(), `"r.nowhere"`) { + t.Fatalf("error = %v, want ErrBindingEnd naming r.nowhere", err) + } +} + +// testUntypedPortBoundInwardDelivers: the well-formed rig delivers the signal to the +// receiver through the bound untyped ports, so its machine leaves idle. +func testUntypedPortBoundInwardDelivers(t *testing.T) { + ctx, rig, err := instantiateWithLibraries(t, rigWithUntypedPorts("bind cmd = r.rx;", "connect s.tx to b.cmd;"), "test::Rig") + if err != nil { + t.Fatalf("instantiate: %v", err) + } + exec := objectMachine(t, instanceAtPath(t, ctx, rig, "b.r"), "") + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run receiver machine: %v", err) + } + if got := exec.FinalStateName(); got != "got" { + t.Fatalf("receiver state = %q, want got", got) + } +} diff --git a/internal/exec/runtime/robustness_via_bound_port_test.go b/internal/exec/runtime/robustness_via_bound_port_test.go new file mode 100644 index 0000000000..75bdfad2f9 --- /dev/null +++ b/internal/exec/runtime/robustness_via_bound_port_test.go @@ -0,0 +1,98 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessViaBoundPort exercises the failure modes of a via path that is +// a single name the behavior binds: the binding must hold exactly one port object. +func TestRuntimeRobustnessViaBoundPort(t *testing.T) { + t.Run("bound_reference_holds_a_part", testViaBoundReferenceHoldsAPart) + t.Run("bound_reference_holds_a_value", testViaBoundReferenceHoldsAValue) + t.Run("bound_reference_holds_two_ports", testViaBoundReferenceHoldsTwoPorts) +} + +// hostViaBinding is a host whose performed action sends `via` the given parameter, +// which the caller binds as given. +func hostViaBinding(parameter, binding string) string { + return `package test { + private import ScalarValues::*; + item def Ping; + port def PingOut { out item ping : Ping; } + part def Device { + port tx : PingOut; + port rx : ~PingOut; + connect tx to rx; + exhibit state listener { + entry; then idle; + state idle; + transition first idle accept Ping via rx then idle; + } + } + part def Host { + port tx : PingOut; + part device : Device; + part spare : Device; + attribute count : Integer = 3; + action def Fire { + ` + parameter + ` + first start; + then action go send new Ping() via tx; + then done; + } + action def Round { + first start; + then action fire : Fire { ` + binding + ` } + then done; + } + perform action round : Round; + } + }` +} + +// testViaBoundReferenceHoldsAPart: a bound name holding an object that is no port is +// refused rather than falling back to the performer's same-named port. +func testViaBoundReferenceHoldsAPart(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostViaBinding( + "in ref tx : Device;", "in ref :>> tx = device;", + ), "test::Host") + var typed *ViaNotPortError + if !errors.As(err, &typed) || typed.Via != "tx" { + t.Fatalf("error = %v, want ViaNotPortError for tx", err) + } + if !errors.Is(err, ErrSendViaNotPort) || !strings.Contains(err.Error(), `"tx" holds`) { + t.Fatalf("error = %v, want ErrSendViaNotPort naming the binding", err) + } +} + +// testViaBoundReferenceHoldsAValue: a bound name holding a value rather than an +// object cannot be sent via. +func testViaBoundReferenceHoldsAValue(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostViaBinding( + "in tx : Integer;", "in :>> tx = count;", + ), "test::Host") + var typed *SendTargetValueError + if !errors.As(err, &typed) || typed.Target != "tx" || typed.Name != "tx" || typed.Value != "3" { + t.Fatalf("error = %v, want SendTargetValueError for tx holding 3", err) + } + if !errors.Is(err, ErrSendTargetNotObject) { + t.Fatalf("error = %v, want ErrSendTargetNotObject", err) + } +} + +// testViaBoundReferenceHoldsTwoPorts: a bound name holding more than one port names no +// single port to send via. +func testViaBoundReferenceHoldsTwoPorts(t *testing.T) { + _, _, err := instantiateWithLibraries(t, hostViaBinding( + "in ref port tx : PingOut[2];", "in ref port :>> tx = (device.tx, spare.tx);", + ), "test::Host") + var typed *SendTargetValueError + if !errors.As(err, &typed) || typed.Target != "tx" || typed.Name != "tx" { + t.Fatalf("error = %v, want SendTargetValueError for tx holding two ports", err) + } + if !errors.Is(err, ErrSendTargetNotObject) { + t.Fatalf("error = %v, want ErrSendTargetNotObject", err) + } +} diff --git a/internal/core/runtime/routing.go b/internal/exec/runtime/routing.go similarity index 82% rename from internal/core/runtime/routing.go rename to internal/exec/runtime/routing.go index 21c02dc195..9b3d9eab03 100644 --- a/internal/core/runtime/routing.go +++ b/internal/exec/runtime/routing.go @@ -3,11 +3,12 @@ package runtime import ( "errors" "fmt" + "sort" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // ErrUnroutableSend is returned when a `send … via p` reaches no end able to @@ -25,6 +26,54 @@ var ErrSendPortTypeMismatch = errors.New("send message type is not carried by th // ErrUnreachableSendReceiver reports a routed receiver that cannot be resolved. var ErrUnreachableSendReceiver = errors.New("send receiver is unreachable") +// ErrSendTargetNotObject reports an addressed send whose target holds no object: +// a binding of the sending behavior holding none, or a receiver expression +// yielding none. +var ErrSendTargetNotObject = errors.New("send target holds no object") + +// SendTargetValueError gives the bound target that holds no object, and what it holds. +type SendTargetValueError struct { + Target string // the target as written + Name string // the binding leading it + Value string // what the binding holds, formatted +} + +func (e *SendTargetValueError) Error() string { + return fmt.Sprintf("%s: %q holds %s, which is no object to address %q to", + ErrSendTargetNotObject, e.Name, e.Value, e.Target) +} + +func (e *SendTargetValueError) Unwrap() error { return ErrSendTargetNotObject } + +// SendReceiverValueError gives a receiver expression that yields no object, and what it yielded. +type SendReceiverValueError struct { + Receiver string // the expression as written + Value string // what it yielded, formatted +} + +func (e *SendReceiverValueError) Error() string { + return fmt.Sprintf("%s: %q yields %s, which is no object to send to", + ErrSendTargetNotObject, e.Receiver, e.Value) +} + +func (e *SendReceiverValueError) Unwrap() error { return ErrSendTargetNotObject } + +// ErrSendViaNotPort reports a via path that is a binding of the sending behavior +// holding an object which is no port. +var ErrSendViaNotPort = errors.New("send via holds no port") + +// ViaNotPortError gives the bound via path holding an object that is no port, and what it holds. +type ViaNotPortError struct { + Via string // the via path as written + Value string // the object the binding holds, formatted +} + +func (e *ViaNotPortError) Error() string { + return fmt.Sprintf("%s: %q holds %s, which is no port to send via", ErrSendViaNotPort, e.Via, e.Value) +} + +func (e *ViaNotPortError) Unwrap() error { return ErrSendViaNotPort } + // UnknownSendPortError gives the routed send's invalid port and receiver. type UnknownSendPortError struct { Port string @@ -118,9 +167,7 @@ func (ctx *Context) ownerDeliveries( break } owner := child.owner - for i := range sendingPaths { - sendingPaths[i] = child.ownerFeature + "." + sendingPaths[i] - } + sendingPaths, targetSyms = ctx.heldPortPaths(owner, child.ownerFeature, sendingPaths, targetSyms) sendingPaths, targetSyms = ctx.boundPortPaths(owner, sendingPaths, targetSyms) conns := ctx.realizedConnections(ctx.connectionsOf(owner), owner) for _, conn := range conns { @@ -163,6 +210,30 @@ func (ctx *Context) ownerDeliveries( return out, mismatch, nil } +// heldPortPaths prefixes the paths a port is known by from a held object with each +// name the holder reads that object under, since a connector its type inherits +// names the part by the name it was declared with before redefinition. +func (ctx *Context) heldPortPaths(owner *Instance, feature string, paths []string, targets []*symbols.Symbol) ([]string, []*symbols.Symbol) { + names := []string{feature} + if owner.Type != nil { + for alias := range ctx.redefinitionAliases(owner.Type, feature) { + if alias != feature { + names = append(names, alias) + } + } + } + sort.Strings(names[1:]) + var outPaths []string + var outTargets []*symbols.Symbol + for _, name := range names { + for i, path := range paths { + outPaths = append(outPaths, name+"."+path) + outTargets = append(outTargets, targets[i]) + } + } + return outPaths, outTargets +} + // joinsAnyTarget reports whether a connection has an end naming one of the // paths a sending port is known by, each resolved against its own symbol. func (ctx *Context) joinsAnyTarget(conn lower.Connection, paths []string, targets []*symbols.Symbol) bool { @@ -231,10 +302,11 @@ func (ctx *Context) holdsViaPort(inst *Instance, segments []string, want *symbol // connectedDeliveries answers where a `send … via p` arrives through the // connections the sender routes over, each receiving end resolved to the object -// holding the port it names (SysML v2 §7.16). The later results are the ends -// that refused the message, which decide the error a send delivered nowhere gives. +// holding the port it names (SysML v2 §7.16) — through the behavior's bindings in +// ec where the end starts at one. The later results are the ends that refused +// the message, which decide the error a send delivered nowhere gives. func (ctx *Context) connectedDeliveries( - conns []lower.Connection, self *Instance, send lower.Send, msg Message, typed bool, + ec *EvalContext, conns []lower.Connection, self *Instance, send lower.Send, msg Message, typed bool, ) ([]ownerDelivery, []string, bool, error) { if send.Target == "" { return nil, nil, false, nil @@ -265,7 +337,7 @@ func (ctx *Context) connectedDeliveries( continue } seenEnd[end] = true - deliveries, err := ctx.endDeliveries(conn.Scope, self, end) + deliveries, err := ctx.endDeliveries(ec, conn, self, end) if err != nil { return nil, outbound, mismatch, err } @@ -281,11 +353,18 @@ func (ctx *Context) connectedDeliveries( return out, outbound, mismatch, nil } -// endDeliveries resolves the port an end names to the objects holding it. An end -// naming a port of a behavior, or one this run's instance graph does not reach, -// is delivered to the sender under the path as written. -func (ctx *Context) endDeliveries(scope *symbols.Scope, self *Instance, end string) ([]ownerDelivery, error) { - addrs, err := ctx.featureAddresses(scope, self, strings.Split(end, ".")) +// endDeliveries resolves the port an end names to the objects holding it: those a +// binding of the behavior declaring the connection leads to, else those reached +// from the sender. An end naming a port of a behavior, or one this run's instance +// graph does not reach, is delivered to the sender under the path as written. +func (ctx *Context) endDeliveries(ec *EvalContext, conn lower.Connection, self *Instance, end string) ([]ownerDelivery, error) { + if conn.Owner == lower.OwnerBehavior { + out, bound, err := ec.boundEndDeliveries(end) + if err != nil || bound { + return out, err + } + } + addrs, err := ctx.featureAddresses(conn.Scope, self, strings.Split(end, ".")) if err != nil { return nil, err } @@ -317,24 +396,10 @@ func (ctx *Context) endNamesAStructuralPath(self *Instance, end string) bool { return held && !isPortFeature(fv.Feature) && !isBehaviorFeature(fv.Feature) } -// routableConnections are the connections a `send … via p` of a behavior can -// travel over: the ones the behavior's own body declares, and the ones declared -// by the part performing it, whose ports the send names. The performer is the -// object when one performs the behavior, and otherwise the part the behavior was -// declared in, which performs it by owning it (SysML v2 §7.16). -func (ctx *Context) routableConnections(own []lower.Connection, self *Instance, scope *symbols.Scope) []lower.Connection { - performer := ctx.performerConnections(self, scope) - if len(performer) == 0 { - return own - } - out := make([]lower.Connection, 0, len(own)+len(performer)) - return append(append(out, own...), performer...) -} - -// performerConnections returns the connections of the part performing a -// behavior: those of the object's types when an object performs it, and those of -// the enclosing part when none does, so a behavior declared in a part reaches -// that part's own ports either way. +// performerConnections returns the connections a `send … via p` of a behavior +// travels beside the behavior's own: those of the part performing it, whose ports +// the send names — the object's types' when an object performs it, and the +// enclosing part's when none does, which performs it by owning it (SysML v2 §7.16). func (ctx *Context) performerConnections(self *Instance, scope *symbols.Scope) []lower.Connection { if self != nil { return ctx.connectionsOf(self) diff --git a/internal/exec/runtime/routing_differential_test.go b/internal/exec/runtime/routing_differential_test.go new file mode 100644 index 0000000000..294036f2fe --- /dev/null +++ b/internal/exec/runtime/routing_differential_test.go @@ -0,0 +1,66 @@ +package runtime + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +func TestRoutingUnchangedByConnectorObjects(t *testing.T) { + const conformanceDir = "testdata/conformance" + pattern := regexp.MustCompile(`^(send_|port_|.*_routing|action_port_communication|w7d_send_via_port_to_receiver|binding_)`) + entries, err := os.ReadDir(conformanceDir) + if err != nil { + t.Fatal(err) + } + var cases []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".expected.json") { + continue + } + name := strings.TrimSuffix(entry.Name(), ".expected.json") + if pattern.MatchString(name) || name == "connector_object_binding_flow" { + if _, err := os.Stat(filepath.Join(conformanceDir, name+".sysml")); err != nil { + continue + } + cases = append(cases, name) + } + } + sort.Strings(cases) + known := loadKnownFailures(t, conformanceDir) + forcedCases := make(map[string]bool) + for _, name := range cases { + name := name + t.Run(name, func(t *testing.T) { + if known[name] { + t.Skip("known conformance failure") + } + data, err := os.ReadFile(filepath.Join(conformanceDir, name+".expected.json")) + if err != nil { + t.Fatal(err) + } + var expected ExpectedOutcome + if err := json.Unmarshal(data, &expected); err != nil { + t.Fatal(err) + } + runConformanceCase(t, conformanceDir, name, DefaultSchedulePolicy) + // Only a case with an object before execution has connectors to force; + // the others skip inside the harness. + if expected.Type == "instance" || expected.Type == "state" && len(expected.Performers) > 0 { + forcedCases[name] = true + } + runConformanceCaseWithOwned(t, conformanceDir, name, DefaultSchedulePolicy, true) + }) + } + for _, name := range cases { + if strings.HasPrefix(name, "send_bind_relay_") || name == "connector_object_binding_flow" { + if !forcedCases[name] { + t.Fatalf("required routing differential case %q did not run under forceOwned", name) + } + } + } +} diff --git a/internal/core/runtime/routing_test.go b/internal/exec/runtime/routing_test.go similarity index 99% rename from internal/core/runtime/routing_test.go rename to internal/exec/runtime/routing_test.go index 1e73ae5fd0..2e9596aabd 100644 --- a/internal/core/runtime/routing_test.go +++ b/internal/exec/runtime/routing_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" ) // executePerformedAction executes the action named by action, performed by an diff --git a/internal/exec/runtime/run_state_test.go b/internal/exec/runtime/run_state_test.go new file mode 100644 index 0000000000..77b6809e63 --- /dev/null +++ b/internal/exec/runtime/run_state_test.go @@ -0,0 +1,356 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// runOutcome is what one run of choiceModel left on its context: the budget it +// spent and what it noted, as strings a run driven alone must match. +type runOutcome struct { + steps, elements int64 + notes string +} + +func notesOf(notes []RunNote) string { + var lines []string + for _, n := range notes { + lines = append(lines, n.String()) + } + return strings.Join(lines, "\n") +} + +// choiceExecutor builds an executor of choiceModel's action in a fresh context. +func choiceExecutor(t *testing.T) (*Context, *ActionExecutor) { + t.Helper() + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, choiceModel)) + sym := findSymbolByName(idx.DocumentRoot(""), "route", ast.DefAction) + if sym == nil { + t.Fatal("action not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create executor: %v", err) + } + return ctx, exec +} + +// stepPastChoice steps exec until its run has noted a choice point. +func stepPastChoice(t *testing.T, exec *ActionExecutor) { + t.Helper() + for i := 0; len(exec.Notes()) == 0; i++ { + if i > 10 || exec.State() == StateCompleted { + t.Fatal("the run noted no choice point") + } + if err := exec.Step(); err != nil { + t.Fatalf("step %d: %v", i, err) + } + } +} + +// finish steps exec to completion. +func finish(t *testing.T, exec *ActionExecutor) { + t.Helper() + for i := 0; exec.State() != StateCompleted; i++ { + if i > 50 { + t.Fatal("the run did not complete in fifty steps") + } + if err := exec.Step(); err != nil { + t.Fatalf("step %d: %v", i, err) + } + } +} + +// driveChoiceModel drives choiceModel to completion, calling between once the run +// has noted a choice point, and returns what the context reports of the run after. +func driveChoiceModel(t *testing.T, between func(ctx *Context, exec *ActionExecutor)) runOutcome { + t.Helper() + ctx, exec := choiceExecutor(t) + stepPastChoice(t, exec) + between(ctx, exec) + finish(t, exec) + return runOutcome{steps: ctx.run.steps, elements: ctx.run.elements, notes: notesOf(ctx.Notes())} +} + +// A run driven call by call resumes with its own budget and notes after another +// run completes on the same context in between, and that run's are not mixed in. +func TestDrivenRunResumesItsOwnStateAcrossAWholeRun(t *testing.T) { + alone := driveChoiceModel(t, func(*Context, *ActionExecutor) {}) + if alone.steps == 0 || !strings.Contains(alone.notes, "choice") { + t.Fatalf("the run alone spent %d steps and noted %q; want a run that spends and chooses", alone.steps, alone.notes) + } + interrupted := driveChoiceModel(t, func(ctx *Context, exec *ActionExecutor) { + if _, err := ctx.ExecuteAction(exec.action); err != nil { + t.Fatalf("run in between: %v", err) + } + }) + if interrupted != alone { + t.Errorf("driven across a whole run: %+v\nalone: %+v", interrupted, alone) + } +} + +// An expression evaluated between two calls into a driven run neither resets +// its budget nor spends it: the run alone and the run interrupted spend the same. +func TestDrivenRunResumesItsOwnStateAcrossAnEvaluation(t *testing.T) { + alone := driveChoiceModel(t, func(*Context, *ActionExecutor) {}) + interrupted := driveChoiceModel(t, func(ctx *Context, _ *ActionExecutor) { + if _, err := ctx.Eval(parseExpr(t, "1 + 2 + 3")); err != nil { + t.Fatalf("evaluation in between: %v", err) + } + }) + if interrupted != alone { + t.Errorf("driven across an evaluation: %+v\nalone: %+v", interrupted, alone) + } +} + +// Two runs driven turn and turn about each keep their own budget and notes. +func TestInterleavedDrivenRunsKeepTheirOwnState(t *testing.T) { + alone := driveChoiceModel(t, func(*Context, *ActionExecutor) {}) + ctx, first := choiceExecutor(t) + second, err := ctx.CreateActionExecutor(first.action) + if err != nil { + t.Fatalf("create second executor: %v", err) + } + runs := []struct { + name string + exec *ActionExecutor + }{{"first", first}, {"second", second}} + for i := 0; first.State() != StateCompleted || second.State() != StateCompleted; i++ { + if i > 50 { + t.Fatal("the runs did not complete in fifty rounds") + } + for _, run := range runs { + name, exec := run.name, run.exec + if exec.State() == StateCompleted { + continue + } + if err := exec.Step(); err != nil { + t.Fatalf("%s: step %d: %v", name, i, err) + } + if ctx.run.steps != exec.driven.state.steps || notesOf(ctx.Notes()) != notesOf(exec.Notes()) { + t.Fatalf("%s: after its step the context reports %d steps and %q, the run has %d and %q", + name, ctx.run.steps, notesOf(ctx.Notes()), exec.driven.state.steps, notesOf(exec.Notes())) + } + } + } + for _, run := range runs { + name, exec := run.name, run.exec + got := runOutcome{steps: exec.driven.state.steps, elements: exec.driven.state.elements, notes: notesOf(exec.Notes())} + if got != alone { + t.Errorf("%s interleaved: %+v\nalone: %+v", name, got, alone) + } + } +} + +// The step budget bounds each driven run on its own: a run that spent nearly +// all of it fails, not one resumed after it. +func TestStepBudgetIsTheDrivenRunsOwn(t *testing.T) { + alone := driveChoiceModel(t, func(*Context, *ActionExecutor) {}) + + // The whole budget spent by another run leaves the paused run its own. + ctx, exec := choiceExecutor(t) + ctx.maxSteps = alone.steps + stepPastChoice(t, exec) + spender, err := ctx.CreateActionExecutor(exec.action) + if err != nil { + t.Fatalf("create executor: %v", err) + } + if err := spender.RunToCompletion(); err != nil { + t.Fatalf("run in between: %v", err) + } + finish(t, exec) + if ctx.run.steps != alone.steps { + t.Errorf("the resumed run spent %d steps, want %d", ctx.run.steps, alone.steps) + } + + // A run that exhausted its budget fails even when a run in between spent nothing. + ctx, exec = choiceExecutor(t) + ctx.maxSteps = alone.steps - 1 + stepPastChoice(t, exec) + if _, err := ctx.Eval(parseExpr(t, "1")); err != nil { + t.Fatalf("evaluation in between: %v", err) + } + err = nil + for i := 0; err == nil && exec.State() != StateCompleted; i++ { + if i > 50 { + t.Fatal("the run neither completed nor failed in fifty steps") + } + err = exec.Step() + } + if !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("Step() = %v, want %v for the run that spent the budget", err, ErrStepLimitExceeded) + } + // The failed run's budget is its own: an evaluation after it has a fresh one. + if _, err := ctx.Eval(parseExpr(t, "1")); err != nil { + t.Errorf("evaluation after the failed run: %v", err) + } +} + +// A run begun inside another shares the enclosing run's state, as does one begun +// inside a probe, whose spending the probe undoes. +func TestNestedRunSharesTheEnclosingState(t *testing.T) { + ctx, outer := choiceExecutor(t) + stepPastChoice(t, outer) + enclosing := ctx.run + end := outer.ctx.beginExecutorRun(&outer.driven) + nested, err := ctx.CreateActionExecutor(outer.action) + if err != nil { + t.Fatalf("create nested executor: %v", err) + } + if err := nested.RunToCompletion(); err != nil { + t.Fatalf("nested run: %v", err) + } + end() + if nested.driven.state != enclosing || ctx.run != enclosing { + t.Fatal("a run begun inside another did not share its state") + } + if len(enclosing.notes) < 3 { + t.Errorf("the enclosing run holds %d notes, want its own and the nested run's", len(enclosing.notes)) + } + + ctx, outer = choiceExecutor(t) + stepPastChoice(t, outer) + enclosing = ctx.run + steps, notes := enclosing.steps, len(enclosing.notes) + endProbe := ctx.beginProbe() + probed, err := ctx.CreateActionExecutor(outer.action) + if err != nil { + t.Fatalf("create probed executor: %v", err) + } + if err := probed.RunToCompletion(); err != nil { + t.Fatalf("probed run: %v", err) + } + endProbe() + if probed.driven.state != enclosing || ctx.run != enclosing { + t.Fatal("a run begun inside a probe did not share the enclosing state") + } + if enclosing.steps != steps || len(enclosing.notes) != notes { + t.Errorf("the probe left %d steps and %d notes, want %d and %d", enclosing.steps, len(enclosing.notes), steps, notes) + } +} + +// memoModel reads a calc usage twice in one block, either side of a node a +// breakpoint can pause at, so the second read answers from the first's evaluation. +const memoModel = `package test { + private import ScalarValues::*; + calc def Twice { in k : Real; out d = k * 2.0; } + action outer { + attribute base : Real = 3.0; + calc t : Twice { in k = base; } + out attribute total : Real = 0.0; + first start; + then action choose { + if base > 0.0 { + assign total := total + t.d; + action q { assign total := total + 1.0; } + assign total := total + t.d; + } + } + then done; + } +}` + +// A calc usage read in an activation still open across a pause answers from the +// evaluation the paused run made, whatever ran on the context in between. +func TestPausedActivationKeepsItsCalcUsageEvaluations(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, memoModel)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create executor: %v", err) + } + trace := NewTraceRecorder() + exec.SetTrace(trace) + exec.SetBreakpoint("q") + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to the breakpoint: %v", err) + } + if got := exec.PausedAt(); got != "q" { + t.Fatalf("PausedAt() = %q, want q", got) + } + + ctx.SetTrace(nil) + if _, err := ctx.ExecuteAction(sym); err != nil { + t.Fatalf("run in between: %v", err) + } + exec.SetTrace(trace) + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("resume: %v", err) + } + if total := exec.Results()["total"]; total.Const.Real != 13.0 { + t.Errorf("total = %v, want 13", total) + } + if got := strings.Count(trace.String(), "enter calc test::outer::t"); got != 1 { + t.Errorf("the paused run ran the usage's body %d times, want once:\n%s", got, trace.String()) + } + if !strings.Contains(trace.String(), "reuse calc test::outer::t") { + t.Errorf("the read after the pause did not reuse the evaluation:\n%s", trace.String()) + } + if len(ctx.run.calcUsageRuns) != 0 { + t.Errorf("%d activation(s) still held after the run", len(ctx.run.calcUsageRuns)) + } +} + +// pausedAtActivation drives the memo model to its breakpoint, an activation open. +func pausedAtActivation(t *testing.T) (*Context, *ActionExecutor, *symbols.Symbol) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, memoModel)) + sym := findSymbolByName(idx.DocumentRoot(""), "outer", ast.DefAction) + if sym == nil { + t.Fatal("action not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("create executor: %v", err) + } + exec.SetBreakpoint("q") + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run to the breakpoint: %v", err) + } + if len(exec.driven.state.calcUsageRuns) == 0 { + t.Fatal("the paused run holds no activation") + } + return ctx, exec, sym +} + +// Releasing a paused run ends its open activations in its own state and leaves +// the run the context reports as it was. +func TestReleaseEndsThePausedRunsActivations(t *testing.T) { + ctx, exec, sym := pausedAtActivation(t) + if _, err := ctx.ExecuteAction(sym); err != nil { + t.Fatalf("run in between: %v", err) + } + between, notes := ctx.run, notesOf(ctx.Notes()) + exec.Release() + if n := len(exec.driven.state.calcUsageRuns); n != 0 { + t.Errorf("%d activation(s) still held after the release", n) + } + if ctx.run != between || notesOf(ctx.Notes()) != notes { + t.Errorf("the release changed the run the context reports: notes %q, want %q", notesOf(ctx.Notes()), notes) + } +} + +// A release inside another run still ends the paused run's own activations, not the +// enclosing run's. +func TestReleaseInsideAnotherRunEndsThePausedRunsOwn(t *testing.T) { + ctx, exec, _ := pausedAtActivation(t) + end := ctx.beginRun() + outer := ctx.run + outer.calcUsageRuns[1] = map[calcUsageKey]*calcRun{} + exec.Release() + if n := len(exec.driven.state.calcUsageRuns); n != 0 { + t.Errorf("%d activation(s) still held after the release", n) + } + if ctx.run != outer || len(outer.calcUsageRuns) != 1 { + t.Errorf("the release touched the enclosing run: %d activation(s) held, want 1", len(outer.calcUsageRuns)) + } + end() +} diff --git a/internal/core/runtime/runtime_integration_test.go b/internal/exec/runtime/runtime_integration_test.go similarity index 92% rename from internal/core/runtime/runtime_integration_test.go rename to internal/exec/runtime/runtime_integration_test.go index 0c0aef1efd..81b01b82f9 100644 --- a/internal/core/runtime/runtime_integration_test.go +++ b/internal/exec/runtime/runtime_integration_test.go @@ -13,7 +13,7 @@ func TestIntegration_ParseAndInstantiate(t *testing.T) { ` model, resolver, rootScope := parseAndBuildModel(t, src) - ctx := NewContext(NewModel(model, resolver), 100000) + ctx := NewContext(typedModel(model, resolver), 100000) wheelSym := resolveSymbol(t, rootScope, "Wheel") inst, err := ctx.Instantiate(wheelSym) diff --git a/internal/exec/runtime/satisfy.go b/internal/exec/runtime/satisfy.go new file mode 100644 index 0000000000..b5abce3d85 --- /dev/null +++ b/internal/exec/runtime/satisfy.go @@ -0,0 +1,389 @@ +package runtime + +import ( + "fmt" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// SatisfyAssertion is one satisfaction assertion an element states: +// `assert satisfy by ` (SysML v2 §8.3.17.15). The +// assertion is a requirement usage of its own — it reference-subsets the +// requirement it satisfies and binds that requirement's subject parameter to +// the feature named by `by` — so it carries a verdict the requirement alone +// does not: one about the values that feature actually holds. +type SatisfyAssertion struct { + // Symbol is the satisfy usage itself, which is anonymous in the usual + // `assert satisfy r by p;` form. + Symbol *symbols.Symbol + + // Owner is the element stating the assertion (the enclosing part or + // package), or nil at the root of a document. + Owner *symbols.Symbol + + // Requirement is the requirement the assertion satisfies: the target of the + // usage's reference subsetting. It is nil when the reference names nothing + // resolvable, and when the assertion declares the requirement itself + // (`satisfy requirement r by p { ... }`), which states its conditions. + Requirement *symbols.Symbol + + // RequirementRef is the requirement reference as written, so an unresolved + // one can be reported by name. + RequirementRef string + + // Subject is the feature named by `by`, whose values the requirement is + // evaluated against. It is nil when the assertion names no subject, and + // when the name resolves to nothing. For a feature chain (`config.child`) + // it is the chain's last feature. + Subject *symbols.Symbol + + // SubjectRef is the `by` operand as written, a chain with its dots. + SubjectRef string + + // SubjectChain is the `by` operand when it is a feature chain, whose object + // is reached through the objects of the features before its last; nil for a + // plain name. + SubjectChain *ast.FeatureChainExpr + + // SubjectRoot is the feature a chained `by` operand starts from (`config`), + // and SubjectPath the features walked from its object to the subject's + // (`child`). Both are empty for a plain name. + SubjectRoot *symbols.Symbol + SubjectPath []string + + // Negated is `assert not satisfy ...`: the assertion holds when the + // requirement is not satisfied. + Negated bool +} + +// AssertedRequirement is the requirement the assertion is about: the one it +// references, or its own usage where the assertion declares the requirement +// (`satisfy requirement r by p { ... }`). It is nil for a reference naming +// nothing resolvable. +func (a *SatisfyAssertion) AssertedRequirement() *symbols.Symbol { + if a.Requirement != nil { + return a.Requirement + } + if a.Symbol == nil { + return nil + } + if usage, ok := a.Symbol.Decl.(*ast.Usage); ok && usage.DeclaresRequirement { + return a.Symbol + } + return nil +} + +// Text renders the assertion as it was written, so an anonymous one can be +// named in a verdict. A `satisfy requirement r by p` form declares the +// requirement rather than referencing one, so it is named by the usage itself. +func (a *SatisfyAssertion) Text() string { + var b strings.Builder + if a.Negated { + b.WriteString("not ") + } + b.WriteString("satisfy ") + switch { + case a.RequirementRef != "": + b.WriteString(a.RequirementRef) + case a.Symbol != nil && a.Symbol.Name != "": + b.WriteString(a.Symbol.Name) + default: + b.WriteString("?") + } + if a.SubjectRef != "" { + b.WriteString(" by ") + b.WriteString(a.SubjectRef) + } + return b.String() +} + +// SatisfyAssertionsIn returns the satisfaction assertions stated in scope and, +// recursively, in the scopes nested within it, in declaration order. An +// assertion is anonymous in its usual form, so it is reached through the +// element that states it rather than by name. +func (ctx *Context) SatisfyAssertionsIn(scope *symbols.Scope) []*SatisfyAssertion { + if scope == nil { + return nil + } + var out []*SatisfyAssertion + for _, sym := range scopeMemberSymbols(scope) { + if a := ctx.satisfyAssertionOf(sym); a != nil { + out = append(out, a) + } + } + for _, child := range scope.Children() { + out = append(out, ctx.SatisfyAssertionsIn(child)...) + } + return out +} + +// scopeMemberSymbols returns the symbols declared directly in scope, named ones +// in declaration order followed by the anonymous ones, which is where an +// `assert satisfy ...` lives. +func scopeMemberSymbols(scope *symbols.Scope) []*symbols.Symbol { + var out []*symbols.Symbol + for _, name := range scope.MemberNames() { + out = append(out, scope.LookupLocalAll(name)...) + } + return append(out, scope.AnonymousMembers()...) +} + +// SatisfyAssertionOf returns the assertion sym declares, or an error when sym is +// not a satisfaction assertion. It names the one a `satisfy requirement r by p` +// form declares under a name. +func (ctx *Context) SatisfyAssertionOf(sym *symbols.Symbol) (*SatisfyAssertion, error) { + a := ctx.satisfyAssertionOf(sym) + if a == nil { + return nil, fmt.Errorf("%s: %w", symbolLabel(sym), ErrNotASatisfaction) + } + return a, nil +} + +// satisfyAssertionOf resolves the assertion sym declares, or nil when sym is not +// a satisfy usage. +func (ctx *Context) satisfyAssertionOf(sym *symbols.Symbol) *SatisfyAssertion { + if sym == nil { + return nil + } + usage, ok := sym.Decl.(*ast.Usage) + if !ok || usage.Kind != ast.UsageSatisfy { + return nil + } + a := &SatisfyAssertion{Symbol: sym, Negated: usage.IsNegated} + if sym.OwnerScope != nil { + a.Owner = sym.OwnerScope.Owner() + } + for _, rel := range usage.Relationships { + if rel == nil || rel.Target == nil { + continue + } + switch rel.Kind { + case ast.RelSubsets: + a.RequirementRef, a.Requirement = ctx.resolveRelationship(sym, rel) + case ast.RelSubject: + a.SubjectRef, a.Subject = ctx.resolveRelationship(sym, rel) + if chain, ok := rel.Target.(*ast.FeatureChainExpr); ok { + a.SubjectChain = chain + a.SubjectRoot, a.SubjectPath = ctx.chainRoot(sym, chain) + } + } + } + return a +} + +// resolveRelationship resolves a relationship target of sym, returning the name +// as written and the symbol it denotes, which is nil when it denotes none. A +// feature chain denotes its last feature, resolved through the ones before it. +func (ctx *Context) resolveRelationship(sym *symbols.Symbol, rel *ast.Relationship) (string, *symbols.Symbol) { + text := targetText(rel.Target) + if text == "" { + return "", nil + } + target, ok := ctx.resolveTarget(sym.OwnerScope, rel.Target) + if !ok { + target = nil + } + return text, target +} + +// chainRoot resolves the feature a chain starts from in sym's scope, nil when +// it resolves to nothing, with the names of the members walked after it. +func (ctx *Context) chainRoot(sym *symbols.Symbol, chain *ast.FeatureChainExpr) (*symbols.Symbol, []string) { + if chain.Member == nil { + return nil, nil + } + base, parts := chainBase(chain) + path := make([]string, 0, len(parts)) + for _, part := range parts { + path = append(path, part.Text) + } + root, ok := ctx.resolveTarget(sym.OwnerScope, base) + if !ok { + root = nil + } + return root, path +} + +// targetText renders a relationship target as written: a qualified name with +// its `::`, a feature chain with its dots; "" for a node naming nothing. +func targetText(node ast.Node) string { + chain, ok := node.(*ast.FeatureChainExpr) + if !ok { + return qualifiedNameToString(ast.AsQualifiedName(node)) + } + operand := targetText(chain.Operand) + member := qualifiedNameToString(chain.Member) + if operand == "" || member == "" { + return "" + } + return operand + "." + member +} + +// EvaluateSatisfaction evaluates a satisfaction assertion against a fresh +// instance of its subject, so that the values the subject declares supply the +// requirement's own. +func (ctx *Context) EvaluateSatisfaction(a *SatisfyAssertion) (bool, error) { + return ctx.EvaluateSatisfactionOn(a, nil) +} + +// EvaluateSatisfactionOn evaluates a satisfaction assertion against a given +// object as its subject: the requirement's subject parameter is bound to that +// object, so a feature the requirement's conditions reach through the subject +// reads the value that object holds. A nil subject instantiates the feature the +// assertion names with `by`. +// +// A false verdict is returned as a *ViolationError, which unwraps to +// ErrViolated: it is an answer about the model, not a failure to evaluate. +func (ctx *Context) EvaluateSatisfactionOn(a *SatisfyAssertion, subject *Instance) (bool, error) { + result, err := ctx.CheckSatisfactionOn(a, subject) + return result.Holds, err +} + +// CheckSatisfactionOn evaluates a satisfaction assertion as +// EvaluateSatisfactionOn does and also reports the object it turned out to be +// about. +func (ctx *Context) CheckSatisfactionOn(a *SatisfyAssertion, subject *Instance) (CheckResult, error) { + defer ctx.beginRun()() + + if a == nil || a.Symbol == nil { + return CheckResult{Subject: subject}, ErrNotASatisfaction + } + // The assertion is evaluated as the requirement usage it is: it inherits the + // conditions of the requirement it references and the values that + // requirement binds, plus any it rebinds itself. + target := a.Symbol + if a.Requirement == nil && a.RequirementRef != "" { + return CheckResult{Subject: subject}, fmt.Errorf("%s: %w: %s", a.Text(), ErrNoRequirement, a.RequirementRef) + } + if a.Requirement == nil && !ctx.declaresConditions(a.Symbol) { + return CheckResult{Subject: subject}, fmt.Errorf("%s: %w", a.Text(), ErrNoRequirement) + } + + if subject == nil && a.SubjectRef != "" { + inst, err := ctx.SatisfySubject(a) + if err != nil { + return CheckResult{}, err + } + subject = inst + } + + // The requirement being satisfied chooses the object its conditions read the + // same way `%requirement` does, so an object holding the carrier nested + // answers about that nested object rather than about the declaration. + // An assertion stating its own conditions has no requirement to resolve + // against, so it resolves against itself. + carrying := a.Requirement + if carrying == nil { + carrying = target + } + resolved, err := ctx.checkSubject("satisfaction", a.Text(), carrying, subject) + if err != nil { + return CheckResult{}, err + } + reached := resolved // the object resolved to, named by where it was reached from + subject = resolved.instance + + scope := target.OwnerScope + members := ctx.chainMembers(target, scope) + + // Every subject the chain declares names the object `by` supplies, which is + // what satisfies the requirement (SysML v2 §8.3.17.15); the other values the + // requirement binds by name are visible to its conditions here too, as they + // are when the requirement is evaluated directly. + bindings, err := ctx.memberBindings(target, "requirement", a.Text(), members, subject, subject, frame{}) + if err != nil { + return ctx.satisfactionResult(false, subject, reached), err + } + + conds := ctx.conditionsOf(target, members) + holds, err := ctx.evaluateConditions(conditionCheck{ + sym: target, + kind: "satisfaction", + what: "require condition", + // The object the assertion checks is the one `by` supplies, so a + // requirement feature carrying no value of its own reads that object's, + // the same way `%requirement` on an instance does. + self: subject, + element: a.Text(), + bindings: mapFrame(bindings), + negated: a.Negated, + }, conds) + return ctx.satisfactionResult(holds, subject, reached), err +} + +// satisfactionResult reports a verdict about subject, naming where a resolved +// nested one was reached from. An assertion whose requirement resolved nothing +// is still about the object `by` supplied. +func (ctx *Context) satisfactionResult(holds bool, subject *Instance, reached carrier) CheckResult { + if reached.instance == nil { + return CheckResult{Holds: holds, Subject: subject, SubjectRoot: subject} + } + return ctx.checkResultOf(holds, reached) +} + +// SatisfySubject returns an object of the feature a satisfaction assertion names +// with `by`: the object its requirement is evaluated against when the caller +// supplies none. A plain name is instantiated afresh; a feature chain is read +// as the expression it is, so `config.child` materializes the occurrence +// `config` and answers the object its `child` holds. +func (ctx *Context) SatisfySubject(a *SatisfyAssertion) (*Instance, error) { + if a == nil || a.Symbol == nil { + return nil, ErrNotASatisfaction + } + if a.Subject == nil { + return nil, fmt.Errorf("%s: %w: %s", a.Text(), ErrNoSubject, a.SubjectRef) + } + if a.SubjectChain != nil { + return ctx.chainSubject(a) + } + inst, err := ctx.Instantiate(a.Subject) + if err != nil { + return nil, fmt.Errorf("%s: %w: %w", a.Text(), ErrNoSubject, err) + } + return inst, nil +} + +// chainSubject evaluates a chained `by` operand in the assertion's scope, as +// one run bounded by the step budget, and returns the one object it denotes. +func (ctx *Context) chainSubject(a *SatisfyAssertion) (*Instance, error) { + value, err := ctx.EvalWithScope(a.SubjectChain, a.Symbol.OwnerScope) + if err != nil { + return nil, fmt.Errorf("%s: %w: %w", a.Text(), ErrNoSubject, err) + } + id, ok := value.Object() + if !ok { + return nil, fmt.Errorf("%s: %w: %s does not denote one object", a.Text(), ErrNoSubject, a.SubjectRef) + } + inst, ok := ctx.Instance(id) + if !ok { + return nil, fmt.Errorf("%s: %w: %s denotes object #%d, which no longer exists", a.Text(), ErrNoSubject, a.SubjectRef, id) + } + return inst, nil +} + +// declaresConditions reports whether sym's own declaration states any condition, +// which is how a `satisfy requirement r by p { require ... }` form carries one +// without referencing another requirement. +func (ctx *Context) declaresConditions(sym *symbols.Symbol) bool { + for _, node := range unwrappedDeclMembers(sym.Decl) { + if len(ctx.appendConditions(nil, node, nil, true, false, nil)) > 0 { + return true + } + } + return false +} + +// symbolLabel names a symbol in a message, falling back to its declaration kind +// for an anonymous one. +func symbolLabel(sym *symbols.Symbol) string { + switch { + case sym == nil: + return "" + case sym.Name != "": + return sym.Name + default: + return fmt.Sprintf("anonymous %T", sym.Decl) + } +} diff --git a/internal/core/runtime/satisfy_test.go b/internal/exec/runtime/satisfy_test.go similarity index 100% rename from internal/core/runtime/satisfy_test.go rename to internal/exec/runtime/satisfy_test.go diff --git a/internal/core/runtime/scale_arith.go b/internal/exec/runtime/scale_arith.go similarity index 98% rename from internal/core/runtime/scale_arith.go rename to internal/exec/runtime/scale_arith.go index d2fa483a03..26bf0cf55f 100644 --- a/internal/core/runtime/scale_arith.go +++ b/internal/exec/runtime/scale_arith.go @@ -3,8 +3,8 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // Operators over points on a measurement scale (`26.85 [SI::'°C_abs']`): the affine diff --git a/internal/core/runtime/scale_arith_test.go b/internal/exec/runtime/scale_arith_test.go similarity index 98% rename from internal/core/runtime/scale_arith_test.go rename to internal/exec/runtime/scale_arith_test.go index 87387e168a..b01861a89c 100644 --- a/internal/core/runtime/scale_arith_test.go +++ b/internal/exec/runtime/scale_arith_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // scaleContext is a runtime over the standard library whose scope imports the diff --git a/internal/core/runtime/scale_conversion.go b/internal/exec/runtime/scale_conversion.go similarity index 99% rename from internal/core/runtime/scale_conversion.go rename to internal/exec/runtime/scale_conversion.go index 80128dd85b..4f594abbdf 100644 --- a/internal/core/runtime/scale_conversion.go +++ b/internal/exec/runtime/scale_conversion.go @@ -4,8 +4,8 @@ import ( "fmt" "math" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // scaleAnchor places a scale's zero on the reference it is defined against: a diff --git a/internal/core/runtime/schedule_replay.go b/internal/exec/runtime/schedule_replay.go similarity index 92% rename from internal/core/runtime/schedule_replay.go rename to internal/exec/runtime/schedule_replay.go index a68c9e4c85..16bb2a7730 100644 --- a/internal/core/runtime/schedule_replay.go +++ b/internal/exec/runtime/schedule_replay.go @@ -52,7 +52,7 @@ func replaySchedule( if err != nil { return nil, err } - if err := ctx.SetSchedule(ReplayOf(Witness{Objects: w.Objects, Inputs: w.Inputs, Choices: choices})); err != nil { + if err := ctx.SetSchedule(ReplayOf(Witness{Objects: w.Objects, Inputs: w.Inputs, DrawPolicy: w.DrawPolicy, ClockStep: w.ClockStep, Draws: w.Draws, Choices: choices})); err != nil { return nil, err } if ctx.Trace() == nil { @@ -106,7 +106,7 @@ func replaySchedule( func (r *Replayed) failedOnSchedule(w Witness, err error, taken, at int) error { r.Err = err switch { - case errors.Is(err, ErrReplayRefused), errors.Is(err, ErrWitnessInput), errors.Is(err, ErrWitnessObject): + case errors.Is(err, ErrReplayRefused), errors.Is(err, ErrWitnessInput), errors.Is(err, ErrWitnessObject), errors.Is(err, ErrWitnessDraw): return r.disagreeOnSchedule(w, err.Error()) case at != ScheduleEnd: return r.disagreeOnSchedule(w, fmt.Sprintf("the run failed after %d moves, before move %d: %v", taken, at, err)) @@ -122,9 +122,9 @@ func (r *Replayed) disagreeOnSchedule(w Witness, reason string) error { return &ReplayDisagreement{Reason: reason, Witness: spellWitness(w), Trace: r.Ctx.Trace().String()} } -// spellWitness spells the witness's objects, inputs and schedule as its file holds them. +// spellWitness spells the witness's objects, inputs, draw policy, clock step, draws and schedule as its file holds them. func spellWitness(w Witness) string { - return Witness{Objects: w.Objects, Inputs: w.Inputs, Choices: w.Choices}.String() + return Witness{Objects: w.Objects, Inputs: w.Inputs, DrawPolicy: w.DrawPolicy, ClockStep: w.ClockStep, Draws: w.Draws, Choices: w.Choices}.String() } // Evaluate asks the property of the replayed run's state under a readiness probe, as the check did. diff --git a/internal/core/runtime/schedule_replay_test.go b/internal/exec/runtime/schedule_replay_test.go similarity index 100% rename from internal/core/runtime/schedule_replay_test.go rename to internal/exec/runtime/schedule_replay_test.go diff --git a/internal/core/runtime/scheduler.go b/internal/exec/runtime/scheduler.go similarity index 82% rename from internal/core/runtime/scheduler.go rename to internal/exec/runtime/scheduler.go index 5b6ebfde53..b409204cd2 100644 --- a/internal/core/runtime/scheduler.go +++ b/internal/exec/runtime/scheduler.go @@ -5,8 +5,11 @@ import ( "fmt" "math/rand/v2" "os" + "slices" "strconv" "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // A run's choice points — several steppable tokens in one step, several holding @@ -47,8 +50,8 @@ const ( // scheduleExplore replays runs under Explore, each following a recorded prefix of // choices and taking the first untried alternative at its frontier. scheduleExplore - // scheduleReplay follows a witness move for move, then behaves as reverse; a - // move the run cannot make is refused (replay.go). + // scheduleReplay follows a witness move for move, then picks as reverse does, still one + // token a step; a move the run cannot make is refused (replay.go). scheduleReplay // scheduleCheck makes the one move the model checker selected for the step; it // has no spelling and is constructed by the checker alone (check_schedule.go). @@ -249,14 +252,18 @@ func (p SchedulePolicy) start() *scheduler { // scheduler resolves the choice points of one run under a policy; a seeded one // carries the generator state the run consumes choice by choice, an exploring // one the exploration run the context takes part in, a replaying one its -// position in the witness, a checking one the checker's selected move. +// position in the witness, a checking one the checker's selected move. modeled +// is the stream the model's own draws come from (modeled.go), nil when none can. type scheduler struct { policy SchedulePolicy pcg *rand.PCG rng *rand.Rand + modeled *modeledSource explore *exploreRun replay *replayRun check *checkRun + // draws is the policy the run's random draws resolve under, fixed when it starts. + draws DrawPolicy } // stepTokens are the tokens one step may try, in spawn order; parked ones cannot @@ -269,6 +276,20 @@ type stepTokens struct { held map[int64]bool enabled func(id int64) bool label func(id int64) string + // stepped marks a step of a body run one token move at a time: a do flow's, + // whose order among the tokens able to act is the machine's to draw. + stepped bool +} + +// has reports whether the step has a token the label names. +func (t stepTokens) has(label string) bool { + return slices.ContainsFunc(t.ids, func(id int64) bool { return t.label(id) == label }) +} + +// hasAll reports whether the step has every token the labels name: the order +// of a witness's step is this flow's move only when it does. +func (t stepTokens) hasAll(labels []string) bool { + return len(labels) > 0 && !slices.ContainsFunc(labels, func(l string) bool { return !t.has(l) }) } // tokenSchedule hands a step its tokens one at a time in the order the policy @@ -340,7 +361,7 @@ func (ts *tokenSchedule) Choice() (alternatives []string, taken int, ok bool) { // every token able to act, the witness's or the checker's — rather than a sweep // giving each token its turn. func (s *scheduler) oneMove() bool { - return (s.policy.kind == scheduleExplore && s.explore != nil) || s.replaying() || s.checking() + return (s.policy.kind == scheduleExplore && s.explore != nil) || s.replay != nil || s.checking() } // checking reports whether the run makes the moves the model checker selects. @@ -355,8 +376,10 @@ func (s *scheduler) replaying() bool { // scheduleStep fixes how the step tries its tokens: reversed, declared, // seeded shuffle, or one at a time as the exploration or the witness picks them. +// A replay stays one move a step past its witness, so the run it re-makes goes +// on as the checker or exploration that wrote the witness stepped it. func (s *scheduler) scheduleStep(tokens stepTokens) *tokenSchedule { - if s.replaying() { + if s.replay != nil { return &tokenSchedule{replay: s.replay.beginStep(tokens)} } if s.checking() { @@ -433,6 +456,53 @@ func (s *scheduler) choose(c ChoicePoint, whereOf func(i int) string) int { return 0 } +// chooseWeighted resolves a decision whose branches carry weights, setting +// c.Taken and the draw: a weighted draw where the model can draw, the most probable +// branch under an unseeded declared or reverse run, the witness's move, weighed and +// drawn as the run weighs it; the exploration and the checker resolve it as every branch. +// whereOf, as for choose, is how the run reports Where once alternative i is taken. +func (s *scheduler) chooseWeighted(c *ChoicePoint, whereOf func(i int) string) error { + n := len(c.Alternatives) + if n < 2 || len(c.Weights) != n { + c.Taken = s.choose(*c, whereOf) + return nil + } + total, err := checkWeights(c.Where, c.Weights) + if err != nil { + return err + } + switch s.policy.kind { + case scheduleExplore, scheduleCheck: + c.Taken = s.choose(*c, whereOf) + return nil + case scheduleReplay: + if s.replaying() { + c.Taken = s.replay.chooseWeighted(c, whereOf) + return nil + } + } + if u, drawn := s.modeled.unit(); drawn { + c.Taken, c.Drew, c.Drawn = weightedPick(c.Weights, total, u), u, true + return nil + } + c.Taken = mostProbable(c.Weights) + return nil +} + +// draw is the value the call what draws: the policy's fixed point unless replaying recorded +// draws, else the modeled stream's; a fixed policy refuses an unbounded distribution. +func (s *scheduler) draw(what string, dist distribution) (semantics.Value, error) { + if !s.modeled.replays() || s.replay.drawsByPolicy() { + if val, ok := dist.fixedPoint(s.draws); ok { + return val, nil + } + if s.draws.Fixed() { + return semantics.Value{}, &DrawUnboundedError{What: what, Policy: s.draws} + } + } + return s.modeled.draw(what, dist) +} + // witnessInputs hands out the inputs the run's witness fixes, once, to the // performance beginning the run; nil for a run under any other policy. func (s *scheduler) witnessInputs() []InputTaken { @@ -464,27 +534,29 @@ func (s *scheduler) unfollowed(how string) error { } // mark returns the state a probe restores, so previewing a run does not move -// the seeded generator, the exploration's position or the witness's. +// the seeded generators, the exploration's position or the witness's. func (s *scheduler) mark() func() { if s == nil { return func() { // No scheduler drove the run, so there is no state to restore. } } + restoreModeled := s.modeled.mark() if s.explore != nil { - return s.explore.mark() + restore := s.explore.mark() + return func() { restore(); restoreModeled() } } if s.replay != nil { - return s.replay.mark() + restore := s.replay.mark() + return func() { restore(); restoreModeled() } } if s.check != nil { - return s.check.mark() + restore := s.check.mark() + return func() { restore(); restoreModeled() } } if s.pcg == nil { - return func() { - // An unseeded schedule draws nothing, so there is no state to restore. - } + return restoreModeled } saved := *s.pcg - return func() { *s.pcg = saved } + return func() { *s.pcg = saved; restoreModeled() } } diff --git a/internal/core/runtime/scheduler_test.go b/internal/exec/runtime/scheduler_test.go similarity index 95% rename from internal/core/runtime/scheduler_test.go rename to internal/exec/runtime/scheduler_test.go index b643f050e2..152e4867f4 100644 --- a/internal/core/runtime/scheduler_test.go +++ b/internal/exec/runtime/scheduler_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // Every accepted spelling reads back to itself; the empty spelling is the default. @@ -502,21 +502,28 @@ func TestSharedAncestorChoiceDrawsOnce(t *testing.T) { if err != nil { t.Fatalf("%s %s: %v", tc.name, policy, err) } - choices := ctx.Choices() - if len(choices) != 2 { - t.Fatalf("%s %s: choices = %v, want one out of busy and one after it (visited %v)", - tc.name, policy, choices, visited) - } - draws := policy.start() - for i, choice := range choices { - if choice.Kind != ChoiceTransition { - t.Fatalf("%s %s: choice %d is %v, want a transition choice", tc.name, policy, i, choice) - } - if want := draw(draws, 2); choice.Taken != want { - t.Errorf("%s %s: choice %d took %d, the seed's draw is %d (%v)", - tc.name, policy, i, choice.Taken, want, choice) - } - } + checkSharedAncestorDraws(t, tc.name, policy, ctx.Choices(), visited) + } + } +} + +// checkSharedAncestorDraws checks a run through busy drew its regions' entry, the transition +// out of busy, its regions' exit and the transition after, each taking the seed's next draw. +func checkSharedAncestorDraws(t *testing.T, name string, policy SchedulePolicy, choices []ChoicePoint, visited []string) { + t.Helper() + kinds := []ChoiceKind{ChoiceEntryOrder, ChoiceTransition, ChoiceExitOrder, ChoiceTransition} + if len(choices) != len(kinds) { + t.Fatalf("%s %s: choices = %v, want the entry, one out of busy, the exit and one after it (visited %v)", + name, policy, choices, visited) + } + draws := policy.start() + for i, choice := range choices { + if choice.Kind != kinds[i] { + t.Fatalf("%s %s: choice %d is %v, want a %s choice", name, policy, i, choice, kinds[i]) + } + if want := draw(draws, len(choice.Alternatives)); choice.Taken != want { + t.Errorf("%s %s: choice %d took %d, the seed's draw is %d (%v)", + name, policy, i, choice.Taken, want, choice) } } } @@ -585,21 +592,7 @@ func TestOutrankedChoiceDrawsNothing(t *testing.T) { if !slices.Contains(visited, "l2") { t.Fatalf("%s %s: the nested transition did not fire (visited %v)", tc.name, policy, visited) } - choices := ctx.Choices() - if len(choices) != 2 { - t.Fatalf("%s %s: choices = %v, want one out of busy and one after it (visited %v)", - tc.name, policy, choices, visited) - } - draws := policy.start() - for i, choice := range choices { - if choice.Kind != ChoiceTransition { - t.Fatalf("%s %s: choice %d is %v, want a transition choice", tc.name, policy, i, choice) - } - if want := draw(draws, 2); choice.Taken != want { - t.Errorf("%s %s: choice %d took %d, the seed's draw is %d (%v)", - tc.name, policy, i, choice.Taken, want, choice) - } - } + checkSharedAncestorDraws(t, tc.name, policy, ctx.Choices(), visited) } } } @@ -659,7 +652,7 @@ func dueOrderChoices(t *testing.T, policy SchedulePolicy, machines int) []Choice t.Errorf("%s: %d events dispatched, want one per machine (%d)", policy, report.Events, machines) } for i, exec := range execs { - if got := exec.CurrentState(); got == nil || getNodeName(got) != "ticked" { + if got := exec.CurrentState(); got == nil || StateVertexName(got) != "ticked" { t.Errorf("%s: machine %d in %v after the advance, want ticked", policy, i, got) } if got := exec.StateData()["beat"]; got.Kind != ValConst || got.Const.Int != 1 { @@ -755,7 +748,7 @@ func changeWatchOrder(t *testing.T, policy SchedulePolicy) ([]int64, []ChoicePoi } seen := make([]int64, len(takers)) for i, exec := range takers { - if got := exec.CurrentState(); got == nil || getNodeName(got) != "took" { + if got := exec.CurrentState(); got == nil || StateVertexName(got) != "took" { t.Errorf("%s: taker %d in %v after the advance, want took", policy, i, got) } v := exec.StateData()["seen"] diff --git a/internal/core/runtime/set_feature.go b/internal/exec/runtime/set_feature.go similarity index 94% rename from internal/core/runtime/set_feature.go rename to internal/exec/runtime/set_feature.go index 7e69688f02..14336b1b7c 100644 --- a/internal/core/runtime/set_feature.go +++ b/internal/exec/runtime/set_feature.go @@ -1,8 +1,8 @@ package runtime import ( - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // The library Collections declare `elements` once as nonunique, at the root, diff --git a/internal/core/runtime/set_feature_test.go b/internal/exec/runtime/set_feature_test.go similarity index 99% rename from internal/core/runtime/set_feature_test.go rename to internal/exec/runtime/set_feature_test.go index d78ccd151e..540803dac4 100644 --- a/internal/core/runtime/set_feature_test.go +++ b/internal/exec/runtime/set_feature_test.go @@ -5,8 +5,8 @@ import ( "math" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // setModel declares one of each library Collection, valued from the same diff --git a/internal/core/runtime/set_order.go b/internal/exec/runtime/set_order.go similarity index 98% rename from internal/core/runtime/set_order.go rename to internal/exec/runtime/set_order.go index 6ebee70893..ae45e187d0 100644 --- a/internal/core/runtime/set_order.go +++ b/internal/exec/runtime/set_order.go @@ -5,8 +5,8 @@ import ( "math" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // canonicalCompare is the total order a set enumerates its elements in, so that diff --git a/internal/exec/runtime/shape.go b/internal/exec/runtime/shape.go new file mode 100644 index 0000000000..02db7e7bfc --- /dev/null +++ b/internal/exec/runtime/shape.go @@ -0,0 +1,338 @@ +package runtime + +import ( + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// EffectiveFeature represents one feature value in a type's flattened schema: +// own + inherited − redefined/masked, carrying type + multiplicity + default. +type EffectiveFeature struct { + Name string + Symbol *symbols.Symbol // the declaring feature symbol + OwnerType *symbols.Symbol // type that declares this feature (may be supertype) + Type *symbols.Symbol // resolved type (nil if untyped) + Multiplicity semantics.Range // declared or inherited (default 1..1) + DefaultValue ast.Node // value-binding expression (nil if none) + DefaultDecl *symbols.Symbol // feature the DefaultValue was written on (nil if none) + HoldsSet bool // values form a set: a Collection's unordered unique elements + Unique bool // holds no two equal values (KerML isUnique, the default) +} + +// Scalar reports whether the feature holds at most one value. +func (f *EffectiveFeature) Scalar() bool { + return f.Multiplicity.AtMostOne() +} + +// DefaultIsFallback reports whether DefaultValue was written with `default`: a +// value the feature holds only when nothing else populates it. +func (f *EffectiveFeature) DefaultIsFallback() bool { + if f.DefaultDecl == nil { + return false + } + usage, ok := f.DefaultDecl.Decl.(*ast.Usage) + return ok && usage.ValueIsDefault +} + +// DefaultScope returns the scope DefaultValue resolves its names in, which for +// an inherited default is where the redefined declaration wrote it. +func (f *EffectiveFeature) DefaultScope() *symbols.Scope { + if f.DefaultDecl != nil { + return f.DefaultDecl.OwnerScope + } + return f.DeclScope() +} + +// DeclScope returns the scope the feature was declared in, which is the scope a +// default value written on it must be evaluated in: an inherited feature's +// default refers to names visible where the supertype was written, not where +// the instantiated type is. +func (f *EffectiveFeature) DeclScope() *symbols.Scope { + if f.Symbol == nil { + return nil + } + return f.Symbol.OwnerScope +} + +// DeclScope returns the scope a declaration's body was written in: the scope the +// declaration owns, in which its own members are visible to each other, falling +// back to the scope it was declared in when it owns none. It is the scope an +// expression written among its members resolves its names against — an +// attribute default, a guard, an assignment in a nested action body — and the +// scope the runtime lowers the declaration in. +func DeclScope(sym *symbols.Symbol) *symbols.Scope { + if sym == nil { + return nil + } + if sym.Scope != nil { + return sym.Scope + } + return sym.OwnerScope +} + +// FeaturesOf returns the ordered, deduplicated effective-feature list for the given type symbol. +// Result: own + inherited − redefined/masked, memoized per symbol. +func (ctx *Context) FeaturesOf(typeSym *symbols.Symbol) []EffectiveFeature { + if typeSym == nil { + return nil + } + + // Memoization + if cached, ok := ctx.model.features[typeSym]; ok { + return cached + } + + features := ctx.buildFeatures(typeSym) + ctx.model.features[typeSym] = features + return features +} + +// buildFeatures constructs the effective-feature list from the semantic shape. +func (ctx *Context) buildFeatures(typeSym *symbols.Symbol) []EffectiveFeature { + // Redefined features stay in the shape: a redefinition shares its target's + // feature value, which both names read (see subsetting_test.go). + shape := ctx.model.semantics.ShapeFeatures(typeSym) + result := make([]EffectiveFeature, 0, len(shape)) + seenNames := make(map[string]bool, len(shape)) + for _, f := range shape { + seenNames[f.Name] = true + result = append(result, ctx.effectiveFeature(f.Name, f.Symbol, typeSym)) + } + return append(result, ctx.connectorEndFeatures(typeSym, seenNames)...) +} + +// effectiveFeature is the member memberSym of typeSym as name, with the value it +// states or inherits from what it redefines. +func (ctx *Context) effectiveFeature(name string, memberSym, typeSym *symbols.Symbol) EffectiveFeature { + defaultVal := ctx.extractDefaultValue(memberSym) + defaultDecl := memberSym + if defaultVal == nil { + defaultVal, defaultDecl = ctx.redefinedDefault(memberSym, typeSym) + } + mult := ctx.featureMultiplicity(memberSym, typeSym) + return EffectiveFeature{ + Name: name, + Symbol: memberSym, + OwnerType: ctx.findOwnerType(memberSym), + Type: ctx.extractType(memberSym), + Multiplicity: mult, + DefaultValue: defaultVal, + DefaultDecl: defaultDecl, + HoldsSet: ctx.holdsSet(memberSym, typeSym, mult), + Unique: ctx.model.semantics.IsUnique(memberSym), + } +} + +// parameterFeatures are the input parameters of typeSym no object carries as a +// feature value, in member order, the most specific declaration of each name. +func (ctx *Context) parameterFeatures(typeSym *symbols.Symbol) []EffectiveFeature { + var order []string + byName := make(map[string]*symbols.Symbol) + for _, member := range ctx.model.semantics.MembersOfIncludingRedefined(typeSym) { + if member.Name == "" || !isInputParameter(member) || semantics.IsShapeFeature(member) && !ctx.model.semantics.FrameFeature(member) { + continue + } + if _, seen := byName[member.Name]; !seen { + order = append(order, member.Name) + } + byName[member.Name] = member + } + out := make([]EffectiveFeature, 0, len(order)) + for _, name := range order { + out = append(out, ctx.effectiveFeature(name, byName[name], typeSym)) + } + return out +} + +// isInputParameter reports an `in` or `inout` parameter usage. +func isInputParameter(sym *symbols.Symbol) bool { + usage, ok := sym.Decl.(*ast.Usage) + return ok && !usage.IsResult && (usage.Direction == ast.DirIn || usage.Direction == ast.DirInOut) +} + +// extractType resolves the type of a feature: the one it declares, or the one +// it inherits from what it redefines or subsets when it restates none +// (KerML 1.0 §7.4.7). +func (ctx *Context) extractType(featureSym *symbols.Symbol) *symbols.Symbol { + if typ := ctx.declaredType(featureSym); typ != nil { + return typ + } + for _, sup := range ctx.model.semantics.AllSupertypes(featureSym) { + if typ := ctx.declaredType(sup); typ != nil { + return typ + } + } + return nil +} + +// declaredType resolves the type a feature states itself, ignoring inheritance. +func (ctx *Context) declaredType(featureSym *symbols.Symbol) *symbols.Symbol { + // Check usage relationships for typing + rels := semantics.RelationshipsOf(featureSym) + for _, rel := range rels { + if rel.Kind == ast.RelTyping && rel.Target != nil { + // Unwrap FeatureReference if needed + target := rel.Target + if fr, ok := target.(*ast.FeatureReference); ok { + target = fr.Name + } + if qn, ok := target.(*ast.QualifiedName); ok { + if resolved, ok := ctx.resolveQualified(featureSym.OwnerScope, qn); ok { + return resolved + } + } + } + } + return nil +} + +// extractMultiplicity returns the multiplicity governing a feature. stated is +// false when it declares none and the assumed 1..1 governs it instead. +func (ctx *Context) extractMultiplicity(featureSym *symbols.Symbol) (r semantics.Range, stated bool) { + _, stated = ctx.model.semantics.MultiplicityOf(featureSym) + return ctx.model.semantics.EffectiveMultiplicityOf(featureSym), stated +} + +// extractDefaultValue returns the default-value expression for a feature (nil if none). +func (ctx *Context) extractDefaultValue(featureSym *symbols.Symbol) ast.Node { + if oc, ok := ast.OwnedConstraintOf(featureSym.Decl); ok { + return oc.Value + } + switch decl := featureSym.Decl.(type) { + case *ast.Usage: + return decl.Value // nil if no default + case *ast.SubjectMember: + return decl.BindingExpr + } + return nil +} + +// featureMultiplicity is the multiplicity a feature has on owner: as stated, else +// as inherited from what it redefines or subsets, else the assumed 1..1. +func (ctx *Context) featureMultiplicity(sym, owner *symbols.Symbol) semantics.Range { + mult, stated := ctx.extractMultiplicity(sym) + if stated { + return mult + } + if inherited, ok, _ := ctx.inheritedMultiplicity(sym, owner, map[*symbols.Symbol]bool{sym: true}); ok { + return inherited + } + return mult +} + +// statedMultiplicity is the multiplicity a feature states, itself or as inherited from the +// declarations it redefines or, abstract, subsets; a parameter stating none holds the assumed +// one value (KerML 1.0 §7.4.5), and stated is false only for a non-parameter none on that walk bounds. +func (ctx *Context) statedMultiplicity(sym *symbols.Symbol) (semantics.Range, bool) { + if mult, stated := ctx.extractMultiplicity(sym); stated { + return mult, true + } + var mult semantics.Range + stated := false + if owner := ctx.findOwnerType(sym); owner != nil { + mult, _, stated = ctx.inheritedMultiplicity(sym, owner, map[*symbols.Symbol]bool{sym: true}) + } + if !stated && semantics.IsParameter(sym) { + return semantics.AssumedRange(), true + } + return mult, stated +} + +// inheritedMultiplicity intersects the multiplicities a feature declaring none redefines, +// by name or as a parameter at the same position, and, if abstract, subsets (KerML 1.0 §8.4.4.12.1); +// path ends cycles, not shared ancestors. stated reports a general on the walk stating one. +func (ctx *Context) inheritedMultiplicity(sym, owner *symbols.Symbol, path map[*symbols.Symbol]bool) (mult semantics.Range, found, stated bool) { + generals := append(ctx.relatedFeatures(sym, owner, ast.RelRedefines), + ctx.model.semantics.ImplicitParameterRedefinitions(sym)...) + if symbols.IsAbstract(sym) { + generals = append(generals, ctx.relatedFeatures(sym, owner, ast.RelSubsets)...) + } + for _, general := range generals { + if path[general] { + continue + } + generalMult, generalStated := ctx.model.semantics.MultiplicityOf(general) + if !generalStated { + path[general] = true + inherited, ok, inheritedStated := ctx.inheritedMultiplicity(general, owner, path) + delete(path, general) + generalStated = inheritedStated + if ok { + generalMult = inherited + } else { + generalMult = semantics.AssumedRange() + } + } + stated = stated || generalStated + if found { + mult = mult.Intersect(generalMult) + } else { + mult, found = generalMult, true + } + } + return mult, found, stated +} + +// redefinedDefault returns the value a feature takes from the feature it +// redefines, by clause or by parameter position, and the declaration that wrote +// it: a redefining feature is the redefined feature declared again (KerML 1.0 §7.3.4.5). +func (ctx *Context) redefinedDefault(sym, owner *symbols.Symbol) (ast.Node, *symbols.Symbol) { + seen := map[*symbols.Symbol]bool{sym: true} + for queue := []*symbols.Symbol{sym}; len(queue) > 0; { + cur := queue[0] + queue = queue[1:] + targets := ctx.relatedFeatures(cur, owner, ast.RelRedefines) + targets = append(targets, ctx.model.semantics.ImplicitParameterRedefinitions(cur)...) + for _, redefined := range targets { + if seen[redefined] { + continue + } + seen[redefined] = true + if val := ctx.extractDefaultValue(redefined); val != nil { + return val, redefined + } + queue = append(queue, redefined) + } + } + return nil, nil +} + +// findOwnerType walks up the scope chain to find the type symbol that owns the feature's declaration. +func (ctx *Context) findOwnerType(featureSym *symbols.Symbol) *symbols.Symbol { + // Start from the feature's owner scope (the scope that contains the declaration) + ownerScope := featureSym.OwnerScope + if ownerScope == nil { + return nil + } + + // The owner scope's node is the definition/usage that contains the feature + ownerNode := ownerScope.Node() + if ownerNode == nil { + return nil + } + + // The scope records the symbol declaring it; a scope re-owned by another + // declaration (a metadata body owned by its definition) is searched below. + if owner := ownerScope.Owner(); owner != nil && owner.Decl == ownerNode { + return owner + } + + // Look up the symbol for the owner node in the parent scope + parentScope := ownerScope.Parent() + if parentScope == nil { + return nil + } + + // Find the symbol in the parent scope that declares the owner node + for _, name := range parentScope.MemberNames() { + syms := parentScope.LookupLocalAll(name) + for _, sym := range syms { + if sym.Decl == ownerNode { + return sym + } + } + } + + return nil +} diff --git a/internal/exec/runtime/shape_items_limits_test.go b/internal/exec/runtime/shape_items_limits_test.go new file mode 100644 index 0000000000..d0c14746b5 --- /dev/null +++ b/internal/exec/runtime/shape_items_limits_test.go @@ -0,0 +1,70 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// ShapeItems expressions the runtime cannot honestly compute fail with a typed error naming +// what stopped them: a missing Kernel-frame feature, a witnessless partial binding, a contradicted bound. +func TestShapeItemsUnsupportedExpressionsAreTypedErrors(t *testing.T) { + ctx, idx := libraryShapeContext(t, `package Geo { + private import ShapeItems::*; private import SI::*; + part box : Box { :>> length = 2 [m]; :>> width = 1 [m]; :>> height = 1 [m]; } + part rect : Rectangle { :>> length = 4 [m]; :>> width = 3 [m]; } + part ell : Ellipse { :>> semiMajorAxis = 4 [m]; :>> semiMinorAxis = 3 [m]; } + part cyl : Cylinder { :>> semiMajorAxis = 4 [m]; :>> semiMinorAxis = 3 [m]; :>> height = 2 [m]; } + }`) + pkg, ok := idx.DocumentRoot("").LookupLocal("Geo") + if !ok { + t.Fatal("package Geo not found") + } + cases := []struct { + expr string + want error + text string + }{ + {"box.matingOccurrences", ErrNoSuchFeature, "member matingOccurrences not found in instance"}, + {"rect.vertices#(1).matingOccurrences", ErrNoSuchFeature, "member matingOccurrences not found in instance"}, + {"box.spaceBoundary", ErrNoSuchFeature, "member spaceBoundary not found in instance"}, + {"box.tfe", ErrBindingEnd, "which makes some value of tfe a value of tf.edges without saying which value of either; the model does not state what tfe holds"}, + {"box.tfe.length", ErrBindingEnd, "binding [1] bind [0..1] tf.edges = [0..1] tfe"}, + {"box.tflv", ErrBindingEnd, "binding [1] bind [0..1] tf.edges = [0..1] tfe"}, + {"box.vertices", ErrBindingEnd, "subsetting feature tflv of vertices: binding end cannot be resolved: box.tfe is bound by `binding [1] bind [0..1] tf.edges = [0..1] tfe`"}, + {"cyl.edges", ErrNoSuchFeature, "member edges not found in instance"}, + {"cyl.faces.edges", ErrNoSuchFeature, "member edges not found in instance"}, + {"cyl.vertices", ErrNoSuchFeature, "member vertices not found in instance"}, + {"cyl.be", ErrBindingEnd, `"cf.edges": feature edges not found`}, + {"cyl.ae", ErrBindingEnd, `"cf.edges": feature edges not found`}, + } + for _, tc := range cases { + t.Run(tc.expr, func(t *testing.T) { + _, err := evalIn(t, ctx, pkg.Scope, tc.expr) + if !errors.Is(err, tc.want) { + t.Fatalf("%s = %v, want %v", tc.expr, err, tc.want) + } + if !strings.Contains(err.Error(), tc.text) { + t.Errorf("%s error %q does not name %q", tc.expr, err, tc.text) + } + }) + } + + // A curve the library declares without vertices holds none: that is a value, not a gap. + val, err := evalIn(t, ctx, pkg.Scope, "ell.vertices") + if err != nil || FormatValue(val) != "[]" { + t.Errorf("ell.vertices = %s, %v; want [] with no error", FormatValue(val), err) + } + + // `binding [1] bind [0..*] base.edges = [0..*] be` links one value of each end, so a face's + // `edges` keeps the edge its own `= shape` gives it instead of being bound to `be` whole. + for _, expr := range []string{"cyl.base.edges", "cyl.af.edges"} { + val, err := evalIn(t, ctx, pkg.Scope, expr) + if err != nil { + t.Fatalf("%s: %v", expr, err) + } + if val.Kind != ValInstance { + t.Errorf("%s = %s, want one Instance", expr, FormatValue(val)) + } + } +} diff --git a/internal/exec/runtime/shape_test.go b/internal/exec/runtime/shape_test.go new file mode 100644 index 0000000000..0c8c489c1f --- /dev/null +++ b/internal/exec/runtime/shape_test.go @@ -0,0 +1,154 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +const testMaxSteps = 10000 + +func TestFeaturesOf(t *testing.T) { + code := ` + part def Base { + attribute x : Integer; + } + part def Derived :> Base { + attribute y : Real; + } + ` + model, resolver, rootScope := parseAndBuildModel(t, code) + ctx := NewContext(typedModel(model, resolver), testMaxSteps) + + derivedSym := resolveSymbol(t, rootScope, "Derived") + features := ctx.FeaturesOf(derivedSym) + + if len(features) != 2 { + t.Fatalf("expected 2 features, got %d", len(features)) + } + + // MembersOf returns local first, then inherited + // features[0] = y (local), features[1] = x (inherited) + if features[0].Name != "y" { + t.Errorf("features[0].Name = %q, want %q", features[0].Name, "y") + } + if features[0].OwnerType.Name != "Derived" { + t.Errorf("features[0].OwnerType.Name = %q, want %q", features[0].OwnerType.Name, "Derived") + } + + if features[1].Name != "x" { + t.Errorf("features[1].Name = %q, want %q", features[1].Name, "x") + } + if features[1].OwnerType.Name != "Base" { + t.Errorf("features[1].OwnerType.Name = %q, want %q", features[1].OwnerType.Name, "Base") + } +} + +func TestFeaturesOf_Redefinition(t *testing.T) { + code := ` + attribute def MyInt; + attribute def MyReal; + part def Base { + attribute x : MyInt; + } + part def Derived :> Base { + attribute x : MyReal redefines Base::x; + } + ` + model, resolver, rootScope := parseAndBuildModel(t, code) + ctx := NewContext(typedModel(model, resolver), testMaxSteps) + + derivedSym := resolveSymbol(t, rootScope, "Derived") + features := ctx.FeaturesOf(derivedSym) + + if len(features) != 1 { + t.Fatalf("expected 1 feature (x redefined), got %d", len(features)) + } + + if features[0].Name != "x" { + t.Errorf("features[0].Name = %q, want %q", features[0].Name, "x") + } + if features[0].OwnerType.Name != "Derived" { + t.Errorf("features[0].OwnerType.Name = %q, want %q (redefining feature should win)", features[0].OwnerType.Name, "Derived") + } + + // Type should be MyReal (the redefining feature's type) + if features[0].Type == nil || features[0].Type.Name != "MyReal" { + t.Errorf("features[0].Type should be MyReal, got %v", features[0].Type) + } +} + +// A renaming redefinition that restates no type is typed by the feature it +// redefines, so it materializes the same objects under the new name. +func TestFeaturesOf_TypeInheritedThroughRedefinition(t *testing.T) { + code := ` + attribute def Cost { attribute v; } + part def Ring { attribute ringCost : Cost; } + part def Band :> Ring { attribute bandCost :>> ringCost; } + ` + model, resolver, rootScope := parseAndBuildModel(t, code) + ctx := NewContext(typedModel(model, resolver), testMaxSteps) + + features := ctx.FeaturesOf(resolveSymbol(t, rootScope, "Band")) + if len(features) == 0 || features[0].Name != "bandCost" { + t.Fatalf("FeaturesOf(Band) = %v, want bandCost first", features) + } + if features[0].Type == nil || features[0].Type.Name != "Cost" { + t.Errorf("bandCost.Type = %v, want Cost", features[0].Type) + } +} + +func TestFeaturesOf_Multiplicity(t *testing.T) { + code := ` + part def Thing { + attribute items : Integer [0..10]; + } + ` + model, resolver, rootScope := parseAndBuildModel(t, code) + ctx := NewContext(typedModel(model, resolver), testMaxSteps) + + thingSym := resolveSymbol(t, rootScope, "Thing") + features := ctx.FeaturesOf(thingSym) + + if len(features) != 1 { + t.Fatalf("expected 1 feature, got %d", len(features)) + } + + mult := features[0].Multiplicity + if !mult.Lower.Known || mult.Lower.Value != 0 { + t.Errorf("expected Lower=0, got %+v", mult.Lower) + } + if !mult.Upper.Known || mult.Upper.Value != 10 { + t.Errorf("expected Upper=10, got %+v", mult.Upper) + } +} + +func TestFeaturesOf_DefaultValue(t *testing.T) { + code := ` + part def Thing { + attribute count : Integer = 42; + } + ` + model, resolver, rootScope := parseAndBuildModel(t, code) + ctx := NewContext(typedModel(model, resolver), testMaxSteps) + + thingSym := resolveSymbol(t, rootScope, "Thing") + features := ctx.FeaturesOf(thingSym) + + if len(features) != 1 { + t.Fatalf("expected 1 feature, got %d", len(features)) + } + + if features[0].DefaultValue == nil { + t.Fatal("expected DefaultValue, got nil") + } + + // DefaultValue is ast.Node — should be *ast.LiteralInteger + litInt, ok := features[0].DefaultValue.(*ast.LiteralInteger) + if !ok { + t.Fatalf("expected *ast.LiteralInteger, got %T", features[0].DefaultValue) + } + if litInt.Value != "42" { + t.Errorf("expected literal value %q, got %q", "42", litInt.Value) + } +} diff --git a/internal/exec/runtime/signal.go b/internal/exec/runtime/signal.go new file mode 100644 index 0000000000..e67c597440 --- /dev/null +++ b/internal/exec/runtime/signal.go @@ -0,0 +1,1990 @@ +package runtime + +import ( + "errors" + "fmt" + "slices" + "sort" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// Message is a signal instance in flight. +// +// SignalType names the message's type: the type the send statement named, or +// the scalar type of the value it evaluated to. An accept whose parameter is +// typed consumes only messages of that type, so two sends of different types +// reach different accepts regardless of the order they were posted in. +// +// Target names the receiving node of the sending behavior a `send m to r` +// addressed; a consumer accepts a message addressed to itself or to no one. +// +// Port names the port the message reached — the peer end a `via p` send routed +// to, or the port an addressed send resolved to — and only an accept on that +// port consumes it, keeping port-routed and addressed traffic separate. +// +// Object identifies the object the message reached, 0 for none, and Delivery +// what of that destination a consumer must satisfy to take the message. +// +// PortID identifies the port object the message reached, 0 where the run holds +// none. A binding connector makes a boundary port and an inner port one object, +// so an accept on either port takes a message that reached the other. +type Message struct { + SignalType string + // Signal is the definition SignalType resolved to when the send was built, + // nil where the message's type is known only as a name. An accept matches it + // by conformance, so a subtype message satisfies a supertype accept and + // same-named definitions of different packages stay apart. + Signal *symbols.Symbol + // Event is the feature the message was sent from (`send shutDown to x`), nil + // where none or unresolved; EventName is its written name, the fallback then. + // EventObject is the occurrence that feature held when sent, or the object + // performing a behavioral feature (`send alert()`), 0 for none: two parts of + // one type hold their own occurrences of the one declared event. + Event *symbols.Symbol + EventName string + EventObject int64 + Target string + Port string + Object int64 + PortID int64 + Delivery DeliveryKind + // Payload binds features of the message's type by name, as the send's arguments did. + Payload map[string]Value + // Value is the one value a send of an expression carries (`send 7`, `send d`), + // the occurrence `send new T(…)` constructed, or the one an accept built from + // Payload; nil until any. + Value *Value +} + +// DeliveryKind is what a message's destination resolved to, and so what a +// consumer must match: an unaddressed message resolved nothing and any consumer +// may take it, while every addressed or routed one names a destination in full. +type DeliveryKind uint8 + +const ( + // DeliverAnyone is a message no send addressed, such as one injected from + // outside the model: it has no destination to hold a consumer to. + DeliverAnyone DeliveryKind = iota + // DeliverPort is the port of an object, reached by a connection or addressed. + DeliverPort + // DeliverPortReceiver is a receiver of an object reached through a port. + DeliverPortReceiver + // DeliverReceiver is the receiving node named within an object. + DeliverReceiver + // DeliverObject is an object itself, whichever of its consumers accepts. + DeliverObject +) + +// Call is the payload of an EventCall: the operation invoked, its declaration as +// a behavior member of the machine's owner (nil when it declares none) and the +// arguments. +type Call struct { + Operation string + Declared *symbols.Symbol + Args map[string]Value +} + +// PostMessage puts a message on the context-wide bus, where every executor +// sharing this context can see it. Actions and state machines communicate +// through this bus rather than through per-executor queues, so a message a +// state machine's entry action sends can be accepted by one of its transitions. +// +// A message posted with a destination but no Delivery — one injected from +// outside the model — is held to the destination it names. +func (ctx *Context) PostMessage(msg Message) { + ctx.postFrom(msg, nil, nil) +} + +// postFrom puts a message on the bus as PostMessage does, recording the object +// and behavior that sent it in the trace; nil is a message from outside the run. +func (ctx *Context) postFrom(msg Message, from *Instance, behavior *symbols.Symbol) { + if msg.Delivery == DeliverAnyone { + msg.Delivery = deliveryOf(msg) + } + ctx.messages = append(ctx.messages, msg) + ctx.bus.posts++ + ctx.workChanged() + if ctx.trace != nil { + target, _ := ctx.Instance(msg.Object) + ctx.trace.RecordSend(TraceOrigin{At: ctx.clock.now, Object: from, Behavior: behavior}, msg, target) + } +} + +// acceptedEventName names what an accepted message carries: the event feature +// it was sent from, else its signal type. +func acceptedEventName(msg Message) string { + if msg.EventName != "" { + return msg.EventName + } + return msg.SignalType +} + +// deliveryOf is what the fields of a message name as its destination, most +// specific first: only a message naming nothing is open to any consumer. +func deliveryOf(msg Message) DeliveryKind { + switch { + case msg.Port != "" && msg.Target != "": + return DeliverPortReceiver + case msg.Port != "": + return DeliverPort + case msg.Target != "": + return DeliverReceiver + case msg.Object != 0: + return DeliverObject + } + return DeliverAnyone +} + +// TakeMessage removes and returns the oldest message satisfying match. Messages +// that do not match keep their place in the queue, so a consumer looking for +// one type does not consume or reorder another's. A match probing the machines +// may post and drop messages behind the one it examines; those are visited too. +func (ctx *Context) TakeMessage(match func(Message) bool) (Message, bool) { + for i := 0; i < len(ctx.messages); i++ { + if msg := ctx.messages[i]; match(msg) { + ctx.messages = append(ctx.messages[:i], ctx.messages[i+1:]...) + ctx.bus.cuts++ + return msg, true + } + } + return Message{}, false +} + +// busSerials counts the messages posted to the bus and the cuts that removed or +// replaced messages: between two marks with equal cuts the bus has only grown. +type busSerials struct { + posts, cuts uint64 +} + +// pendingMemo is a machine's memoized poll of the bus for a message it takes: +// the answer, and the marks it holds under. It stands while nothing it depends on +// moved: the bus, the machine and what runs under it and, where the scan read +// them, the objects' data (a via path, an event subsetted, a sibling's guard). +type pendingMemo struct { + valid bool + bus busSerials + writes uint64 + machine uint64 + readsData bool + // scanned is how many messages the scan examined; a bus that only grew since + // needs the rest examined. + scanned int + msg Message + ok bool +} + +// notePollReadsData records that the poll under way, if any, read the objects' data. +func (ctx *Context) notePollReadsData() { + if ctx.polling != nil { + ctx.polling.readsData = true + } +} + +// PendingMessages returns the messages still in flight, oldest first. +func (ctx *Context) PendingMessages() []Message { + out := make([]Message, len(ctx.messages)) + copy(out, ctx.messages) + return out +} + +// readingMail has the accepts of the do behavior about to run read mail, its +// machine's dispatch to it, in place of the bus, until the returned func is called. +func (ctx *Context) readingMail(mail *[]Message) func() { + saved := ctx.mail + ctx.mail = mail + return func() { ctx.mail = saved } +} + +// acceptable returns the messages an accept may take now, oldest first: those +// in flight on the bus, or the mail a do behavior under way was dispatched. +func (ctx *Context) acceptable() []Message { + if ctx.mail != nil { + return slices.Clone(*ctx.mail) + } + return ctx.PendingMessages() +} + +// takeAcceptable is TakeMessage over the messages an accept may take now. +func (ctx *Context) takeAcceptable(match func(Message) bool) (Message, bool) { + if ctx.mail == nil { + return ctx.TakeMessage(match) + } + for i, msg := range *ctx.mail { + if match(msg) { + *ctx.mail = slices.Delete(*ctx.mail, i, i+1) + return msg, true + } + } + return Message{}, false +} + +// SignalDefinitionKinds are the definitions a signal is declared as: what an +// accept may be typed by and a send may carry. A behavior definition is neither. +var SignalDefinitionKinds = []symbols.SymbolKind{ + symbols.SymbolAttributeDef, + symbols.SymbolItemDef, + symbols.SymbolOccurrenceDef, + symbols.SymbolPartDef, + symbols.SymbolIndividualDef, + symbols.SymbolEnumerationDef, + symbols.SymbolMetadataDef, +} + +// IsSignalDefinition reports whether a symbol declares a definition a signal may +// be typed by, one of SignalDefinitionKinds. +func IsSignalDefinition(sym *symbols.Symbol) bool { + return isDefinitionSymbol(sym) && slices.Contains(SignalDefinitionKinds, sym.Kind) +} + +// SignalMessage builds the message `send Signal(args) to ` posts, for PostMessage; +// a nil object is one anyone may take, an argument no feature of the signal admits is refused. +// An element that is no signal definition (see SignalDefinitionKinds) is refused with +// ErrNotASignal. +func (ctx *Context) SignalMessage(signal *symbols.Symbol, args map[string]Value, to *Instance) (Message, error) { + if !IsSignalDefinition(signal) { + return Message{}, fmt.Errorf("%w: %s", ErrNotASignal, symbolText(signal)) + } + features := ctx.FeaturesOf(signal) + payload := make(map[string]Value, len(args)) + for _, name := range sortedArgNames(args) { + feat := carriedFeature(features, name) + if feat == nil { + return Message{}, fmt.Errorf("%w: %s carries no feature %q%s", + ErrSignalArgument, symbolText(signal), name, carriedFeaturesNote(features)) + } + arg := args[name] + what := func() string { return symbolText(signal) + "." + name } + if err := ctx.checkAdmits(feat, what, &arg, admitWritten); err != nil { + return Message{}, fmt.Errorf("%w: %w", ErrSignalArgument, err) + } + payload[name] = arg + } + msg := NamedSignalMessage(signal.Name, to) + msg.Signal, msg.Payload = signal, payload + return msg, nil +} + +// NamedSignalMessage builds the message a send of a signal no declaration types +// posts: matched by name alone, addressed to the object when there is one. +func NamedSignalMessage(name string, to *Instance) Message { + msg := Message{SignalType: name} + if addr, ok := objectAddress(objectID(to)); ok { + msg.Object, msg.Delivery = addr.Object, addr.Delivery + } + return msg +} + +// sortedArgNames lists the argument names in a stable order. +func sortedArgNames(args map[string]Value) []string { + names := make([]string, 0, len(args)) + for name := range args { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// carriedFeature is the feature of the name, nil when none has it. +func carriedFeature(features []EffectiveFeature, name string) *EffectiveFeature { + for i := range features { + if features[i].Name == name { + return &features[i] + } + } + return nil +} + +// carriedFeaturesNote names the features a signal carries, for an argument error. +func carriedFeaturesNote(features []EffectiveFeature) string { + if len(features) == 0 { + return " (it carries none)" + } + names := make([]string, 0, len(features)) + for _, feat := range features { + names = append(names, feat.Name) + } + return " (it carries " + strings.Join(names, ", ") + ")" +} + +// reaches reports whether a consumer named name, accepting on port and performed +// by object, may take this message: every part of the destination must hold, the +// ports always included, so port-routed and addressed traffic stay apart. A +// behavior no object performs has no identity to compare, so a message naming +// the receiver it is neither excludes it nor is excluded by it. +func (m Message) reaches(name, port string, object int64) bool { + if m.Port != port { + return false + } + switch m.Delivery { + case DeliverPort, DeliverObject: + return m.Object == object + case DeliverPortReceiver: + return m.Target == name && m.Object == object + case DeliverReceiver: + return m.Target == name && (m.Object == object || m.Object == 0 || object == 0) + } + return true +} + +// objectID is the identity of an object, 0 for none. +func objectID(inst *Instance) int64 { + if inst == nil { + return 0 + } + return inst.ID +} + +// messageReaches reports whether a consumer named name, accepting on port and +// performed by self, may take message m: by the destination as written, or by +// the identity of the port object it reached — a port a binding connector +// joins to another is that other port, whichever object's feature names it. +// Resolving the consumer's port may materialize it, which can fail. +func (ctx *Context) messageReaches(m Message, name, port string, self *Instance) (bool, error) { + if m.reaches(name, port, objectID(self)) { + return true, nil + } + if m.PortID == 0 || port == "" { + return false, nil + } + portID, err := ctx.portInstanceID(self, port) + if err != nil { + return false, err + } + if m.PortID != portID { + return false, nil + } + switch m.Delivery { + case DeliverPort: + return true, nil + case DeliverPortReceiver: + return m.Target == name, nil + } + return false, nil +} + +// portInstanceID is the identity of the port object a dotted port path of +// holder names, materializing it; 0 where the path names no port object. +func (ctx *Context) portInstanceID(holder *Instance, port string) (int64, error) { + if holder == nil || port == "" { + return 0, nil + } + current := holder + segments := strings.Split(port, ".") + for i, segment := range segments { + fv, held := current.FeatureValues[segment] + if !held || (i == len(segments)-1 && !isPortFeature(fv.Feature)) { + return 0, nil + } + next, ok, err := ctx.fvObject(current, segment) + if err != nil { + return 0, err + } + if !ok { + return 0, nil + } + current = next + } + return current.ID, nil +} + +// postVia routes a message out of a sending port: every port joined to it that +// can receive the message gets a copy, which is the ends whose flow features +// carry inward after conjugation. The behavior's own connections are read as +// written (ends it binds through ec); the port holder's under the path re-rooted +// there, where an addressed receiver is a node of that holder. A send that +// reaches none of them is delivered nowhere, which is a typed error rather than +// a message quietly dropped. +func (ctx *Context) postVia(ec *EvalContext, conns []lower.Connection, msg Message, send lower.Send, receivers []*Instance, self *Instance, behavior *symbols.Symbol) error { + routed, holder, err := ec.viaSender(send, self) + if err != nil { + return err + } + if send.Receiver != "" && send.Scope != nil && !ctx.sendsOwnPort(routed, holder, holder != self) { + return &UnknownSendPortError{Port: send.Target, Receiver: send.Receiver} + } + receiver, receiverObjects, err := ctx.sendReceiver(ec, send, routed, holder, receivers) + if err != nil { + return err + } + deliveries, own, err := ctx.viaRoutes(ec, conns, send, routed, holder, self, msg, receiver) + if err != nil { + return err + } + // A connection joins two objects, so each copy is held to the identity of the + // object whose port the end resolved to rather than to the sender's. Two + // destinations naming one port object (through a binding) get one copy. + // Every destination is resolved before any copy is queued, so a failure + // leaves nothing behind. + copies, err := ctx.deliveryCopies(deliveries, own, holder, self, receiver, receiverObjects, msg) + if err != nil { + return err + } + if receiverObjects != nil && len(copies) == 0 { + return &UnreachableSendReceiverError{Port: send.Target, Receiver: exprText(send.ReceiverExpr)} + } + for _, c := range copies { + ctx.postFrom(c.msg, c.from, behavior) + } + return nil +} + +// viaRoutes gathers the deliveries a `via` send reaches: the sender's own +// connections, then the holder's performer connections and owner crossings. own +// is how many of the first are the sender's, for the sender each copy leaves. +func (ctx *Context) viaRoutes(ec *EvalContext, conns []lower.Connection, send, routed lower.Send, holder, self *Instance, msg Message, receiver string) ([]ownerDelivery, int, error) { + typed := receiver != "" + own, outbound, typeMismatch, err := ctx.connectedDeliveries( + ec, ctx.realizedConnections(conns, self), self, send, msg, typed, + ) + if err != nil { + return nil, 0, err + } + performer := ctx.realizedConnections(ctx.performerConnections(holder, send.Scope), holder) + receiving, held, heldMismatch, err := ctx.connectedDeliveries( + nil, performer, holder, routed, msg, typed, + ) + if err != nil { + return nil, 0, err + } + crossing, crossMismatch, err := ctx.ownerDeliveries(holder, routed, msg, typed) + if err != nil { + return nil, 0, err + } + typeMismatch = typeMismatch || heldMismatch || crossMismatch + outbound = appendUnseen(outbound, held...) + if len(own) == 0 && len(receiving) == 0 && len(crossing) == 0 { + if typeMismatch && receiver != "" { + return nil, 0, &SendPortTypeMismatchError{ + Port: send.Target, Receiver: receiver, SignalType: msg.SignalType, + } + } + return nil, 0, &UnroutableSendError{Port: send.Target, Outbound: outbound} + } + return slices.Concat(own, receiving, crossing), len(own), nil +} + +// sendReceiver resolves a `via` send's receiver: the node name deliveries target, +// or the objects a `to` expression evaluated to, deliveries then held to those +// objects rather than to a node's name. +func (ctx *Context) sendReceiver(ec *EvalContext, send lower.Send, routed lower.Send, holder *Instance, receivers []*Instance) (string, map[int64]bool, error) { + receiver := send.Receiver + if receiver == "" { + if receivers != nil { + return "", objectSet(receivers), nil + } + return "", nil, nil + } + separator := "::" + if send.ReceiverPath { + separator = "." + } + segments := strings.Split(receiver, separator) + objects, err := ec.routedReceiverObjects(send, holder, segments, len(segments) > 1) + if err != nil { + return "", nil, err + } + if objects != nil { + return "", objects, nil + } + receiverSend := routed + receiverSend.Target = send.Receiver + receiverSend.TargetPath = send.ReceiverPath + receiverSend.IsVia = false + addr, err := ctx.resolveRoutedReceiver(receiverSend, holder) + if err != nil || (addr.Object != 0 && addr.Object != objectID(holder)) { + return "", nil, &UnreachableSendReceiverError{Port: send.Target, Receiver: send.Receiver} + } + return addr.Name, nil, nil +} + +// postedCopy is one message queued against a delivery, from the sender it +// leaves: self for the sender's own connections, holder for the rest. +type postedCopy struct { + msg Message + from *Instance +} + +// deliveryCopies makes one copy of msg per unseen delivery; a port object two +// destinations share (through a binding) gets one copy. Every destination is +// resolved before any copy is queued, so a failure leaves nothing behind. +func (ctx *Context) deliveryCopies(deliveries []ownerDelivery, own int, holder, self *Instance, receiver string, receiverObjects map[int64]bool, msg Message) ([]postedCopy, error) { + posted := map[ownerDelivery]bool{} + postedPorts := map[int64]bool{} + var copies []postedCopy + from := self + for i, delivery := range deliveries { + if i == own { + from = holder + } + if receiverObjects != nil && !receiverObjects[delivery.object] { + continue + } + if posted[delivery] { + continue + } + posted[delivery] = true + copied, ok, err := ctx.portCopy(delivery, receiver, msg, postedPorts) + if err != nil { + return nil, err + } + if ok { + copies = append(copies, postedCopy{copied, from}) + } + } + return copies, nil +} + +// portCopy makes delivery's copy of msg; a port object another delivery already +// copied to (through a binding) is skipped, reported by ok false. +func (ctx *Context) portCopy(delivery ownerDelivery, receiver string, msg Message, postedPorts map[int64]bool) (Message, bool, error) { + portID, err := ctx.portInstanceID(ctx.instances[delivery.object], delivery.port) + if err != nil { + return Message{}, false, err + } + if portID != 0 { + if postedPorts[portID] { + return Message{}, false, nil + } + postedPorts[portID] = true + } + copied := msg + copied.Target = receiver + copied.Port = delivery.port + copied.Object = delivery.object + copied.PortID = portID + copied.Delivery = DeliverPort + if receiver != "" { + copied.Delivery = DeliverPortReceiver + } + return copied, true, nil +} + +// sendsOwnPort reports whether a send's port is the sender's own: a port of the +// object the path was re-rooted to, or else one the sending behavior declares. +func (ctx *Context) sendsOwnPort(send lower.Send, holder *Instance, rerooted bool) bool { + if rerooted { + for _, of := range ctx.FeaturesOfObject(holder) { + if of.Name == send.Target && isPortFeature(of.Feature) { + return true + } + } + return false + } + sym, ok := ctx.portSymbol(send.Scope, send.Target) + return ok && sym != nil && sym.Kind == symbols.SymbolPortUsage && + ctx.ownPortPath(send.Scope, strings.Split(send.Target, ".")) +} + +// appendUnseen appends the ends of more that out does not already list. +func appendUnseen(out []string, more ...string) []string { + seen := make(map[string]bool, len(out)) + for _, end := range out { + seen[end] = true + } + for _, end := range more { + if !seen[end] { + seen[end] = true + out = append(out, end) + } + } + return out +} + +// resolveRoutedReceiver requires the named receiver to be an action or state +// reachable on the sending object before it can accept the routed message. +func (ctx *Context) resolveRoutedReceiver(send lower.Send, self *Instance) (messageAddress, error) { + separator := "::" + if send.TargetPath { + separator = "." + } + segments := strings.Split(send.Target, separator) + if !ctx.routedReceiverExists(send.Scope, segments, len(segments) > 1, self) { + return messageAddress{}, fmt.Errorf("receiver %q is unresolved", send.Target) + } + addrs, err := ctx.resolveAddresses(send, self) + if err != nil { + return messageAddress{}, err + } + // A routed receiver is a node of the sending object, so of the addresses + // resolved the one held to that object — or to none — is the receiver's. + for _, addr := range addrs { + if addr.Delivery == DeliverReceiver && (addr.Object == 0 || addr.Object == objectID(self)) { + return addr, nil + } + } + for _, addr := range addrs { + if addr.Delivery == DeliverReceiver { + return addr, nil + } + } + return messageAddress{}, fmt.Errorf("receiver %q is not a receiving node", send.Target) +} + +// routedReceiverExists prefers a directly declared receiving node over inherited +// feature names, then checks behavior features of the object being addressed. +func (ctx *Context) routedReceiverExists(scope *symbols.Scope, segments []string, path bool, self *Instance) bool { + if len(segments) == 0 || segments[0] == "" { + return false + } + if path { + sym, ok := ctx.pathSymbol(scope, segments) + return ok && isRoutedReceiverSymbol(sym) + } + name := segments[0] + for current := scope; current != nil; { + for _, sym := range symbols.PreferDeclared(current.LookupLocalAll(name)) { + if isRoutedReceiverSymbol(sym) { + return true + } + } + parent := current.Parent() + if parent == nil || !isRoutedReceiverSymbol(parent.Owner()) { + break + } + current = parent + } + if self == nil { + return false + } + for _, of := range ctx.FeaturesOfObject(self) { + if of.Name == name && isRoutedReceiverSymbol(of.Feature.Symbol) { + return true + } + } + return false +} + +// isRoutedReceiverSymbol accepts only actions and states as routed receivers, +// leaving other named members out of receiver address resolution. +func isRoutedReceiverSymbol(sym *symbols.Symbol) bool { + if sym == nil { + return false + } + switch sym.Kind { + case symbols.SymbolActionDef, symbols.SymbolActionUsage, + symbols.SymbolStateDef, symbols.SymbolStateUsage: + return true + default: + return false + } +} + +// messageAddress is where an addressed send delivers: what the address resolved +// to, and the object, port or receiving node naming it. Only the constructors +// below build one, so no address can name a destination in part. +type messageAddress struct { + Delivery DeliveryKind + Name string + Port string + Object int64 +} + +// portAddress is a port of an object, refused where no port was resolved. +func portAddress(port string, object int64) (messageAddress, bool) { + if port == "" { + return messageAddress{}, false + } + return messageAddress{Delivery: DeliverPort, Port: port, Object: object}, true +} + +// receiverAddress is a receiving node of an object, refused where it is unnamed. +func receiverAddress(name string, object int64) (messageAddress, bool) { + if name == "" { + return messageAddress{}, false + } + return messageAddress{Delivery: DeliverReceiver, Name: name, Object: object}, true +} + +// objectAddress is an object itself, refused where no object was reached: a +// destination confined to object 0 would confine the message to nothing. +func objectAddress(object int64) (messageAddress, bool) { + if object == 0 { + return messageAddress{}, false + } + return messageAddress{Delivery: DeliverObject, Object: object}, true +} + +// postTo delivers an addressed send to every object its target resolves to, +// one copy per address, each held to that object's own identity. +func (ctx *Context) postTo(msg Message, send lower.Send, self *Instance, behavior *symbols.Symbol) error { + addrs, err := ctx.resolveAddresses(send, self) + if err != nil { + return err + } + return ctx.postAt(msg, addrs, self, behavior) +} + +// postAt delivers one copy of msg to each address, held to its object's identity. +func (ctx *Context) postAt(msg Message, addrs []messageAddress, self *Instance, behavior *symbols.Symbol) error { + var err error + copies := make([]Message, 0, len(addrs)) + for _, addr := range addrs { + copied := msg + copied.Target, copied.Port, copied.Object = addr.Name, addr.Port, addr.Object + copied.Delivery = addr.Delivery + if addr.Delivery == DeliverPort { + copied.PortID, err = ctx.portInstanceID(ctx.instances[addr.Object], addr.Port) + if err != nil { + return err + } + } + copies = append(copies, copied) + } + for _, copied := range copies { + ctx.postFrom(copied, self, behavior) + } + return nil +} + +// resolveAddresses answers what a `send m to t` addressed: the objects t +// belongs to and the port path within them, resolved through the instance +// graph — several where the target reaches through a multi-valued feature. A +// chain the graph does not reach is a port of the sender itself, and +// unroutable if it is neither; a name reaching neither is the receiving node +// of that name. +func (ctx *Context) resolveAddresses(send lower.Send, self *Instance) ([]messageAddress, error) { + if send.Target == "" { + // A send addressing no one is for the sending object, or for whoever accepts + // it where no object sent it. + if addr, ok := objectAddress(objectID(self)); ok { + return []messageAddress{addr}, nil + } + return []messageAddress{{Delivery: DeliverAnyone}}, nil + } + if !send.TargetPath { + return ctx.namedAddresses(send, self) + } + segments := strings.Split(send.Target, ".") + addrs, err := ctx.featureAddresses(send.Scope, self, segments) + if err != nil { + return nil, err + } + if len(addrs) > 0 { + return addrs, nil + } + if sym, ok := ctx.portSymbol(send.Scope, send.Target); ok && + sym.Kind == symbols.SymbolPortUsage && ctx.ownPortPath(send.Scope, segments) { + if addr, ok := portAddress(send.Target, objectID(self)); ok { + return []messageAddress{addr}, nil + } + } + return nil, &UnroutableSendError{Port: send.Target, Address: true} +} + +// namedAddresses resolves a target named rather than chained (`R`, `P::R`): an +// unqualified name is a feature, port or receiving node of the sending object, +// and a qualified one is the element its path names, never a same-named element +// of the sender. +func (ctx *Context) namedAddresses(send lower.Send, self *Instance) ([]messageAddress, error) { + segments := strings.Split(send.Target, "::") + name := segments[len(segments)-1] + if len(segments) > 1 { + return ctx.qualifiedAddresses(send, self, segments) + } + addrs, err := ctx.featureAddresses(send.Scope, self, segments) + if err != nil { + return nil, err + } + if len(addrs) > 0 { + return addrs, nil + } + if sym, resolved := ctx.pathSymbol(send.Scope, segments); resolved && + sym.Kind == symbols.SymbolPortUsage { + if addr, built := portAddress(name, objectID(self)); built { + return []messageAddress{addr}, nil + } + return nil, &UnroutableSendError{Port: send.Target, Address: true} + } + if addr, built := receiverAddress(name, objectID(self)); built { + return []messageAddress{addr}, nil + } + return nil, &UnroutableSendError{Port: send.Target, Address: true} +} + +// qualifiedAddresses resolves a target naming a namespace path (`alpha::reader`, +// `P::Driver`) to the element that path names, never to a same-named feature of +// the sender: the qualifier chooses the object, so the address is the occurrence +// the path leads through, or unroutable where this run reaches none. +func (ctx *Context) qualifiedAddresses(send lower.Send, self *Instance, segments []string) ([]messageAddress, error) { + addrs, err := ctx.featureAddresses(send.Scope, nil, segments) + if err != nil { + return nil, err + } + if len(addrs) > 0 { + return addrs, nil + } + target, resolved := ctx.pathSymbol(send.Scope, segments) + if !resolved { + return nil, &UnroutableSendError{Port: send.Target, Address: true} + } + name := segments[len(segments)-1] + // A path leading through no occurrence names an element of the sending + // behavior's own namespace, where its bare name is that same element: + // `P::Node::Machine` is the sender's machine, `P::alpha::inPort` is alpha's. + if local, ok := ctx.pathSymbol(send.Scope, []string{name}); ok && local == target { + addrs, err := ctx.featureAddresses(send.Scope, self, []string{name}) + if err != nil { + return nil, err + } + if len(addrs) > 0 { + return addrs, nil + } + if target.Kind == symbols.SymbolPortUsage { + if addr, built := portAddress(name, objectID(self)); built { + return []messageAddress{addr}, nil + } + } else if addr, built := receiverAddress(name, objectID(self)); built { + return []messageAddress{addr}, nil + } + } + // A receiver no object owns has no identity of its own; a sender that has one + // cannot address it by name without reaching its own same-named element. + if self == nil && target.Kind != symbols.SymbolPortUsage { + if addr, built := receiverAddress(name, 0); built { + return []messageAddress{addr}, nil + } + } + return nil, &UnroutableSendError{Port: send.Target, Address: true} +} + +// featureAddresses walks a target through the instance graph from the object +// its first segment belongs to. A segment held as a collection denotes every +// element it holds (KerML §7.3.4.6), so the walk carries a set of objects and +// the target resolves to one address per object reached, without duplicates. +// No address is reported where a segment names no feature, or one that is +// neither a port nor an occurrence to descend into. A failure to read an +// object of the graph is that failure, not a bad address. +func (ctx *Context) featureAddresses(scope *symbols.Scope, self *Instance, segments []string) ([]messageAddress, error) { + owner, rest, ok, err := ctx.addressOwner(scope, self, segments) + if err != nil || !ok { + return nil, err + } + return ctx.addressesFrom([]*Instance{owner}, rest) +} + +// addressesFrom walks rest through the instance graph from owners, one address +// per port, receiving behavior or object reached, without duplicates. +func (ctx *Context) addressesFrom(owners []*Instance, rest []string) ([]messageAddress, error) { + var out []messageAddress + seen := map[messageAddress]bool{} + add := func(addr messageAddress, built bool) { + if built && !seen[addr] { + seen[addr] = true + out = append(out, addr) + } + } + for i, segment := range rest { + var next []*Instance + for _, owner := range owners { + fv, held := owner.FeatureValues[segment] + if !held { + continue + } + if isPortFeature(fv.Feature) { + add(portAddress(strings.Join(rest[i:], "."), owner.ID)) + continue + } + // A behavior of an object is a receiving node of it, addressed by name. + if i == len(rest)-1 && isBehaviorFeature(fv.Feature) { + add(receiverAddress(segment, owner.ID)) + continue + } + held2, err := ctx.fvObjects(owner, segment) + if err != nil { + return nil, err + } + next = append(next, held2...) + } + owners = next + if len(owners) == 0 { + return out, nil + } + } + for _, owner := range owners { + add(objectAddress(owner.ID)) + } + return out, nil +} + +// boundTargetAddresses resolves a target led by a binding of ec (a parameter, pin or +// local holding objects), walking further segments through them; false where none leads it. +func (ec *EvalContext) boundTargetAddresses(send lower.Send) ([]messageAddress, bool, error) { + if send.IsVia || send.Target == "" || strings.Contains(send.Target, "::") { + return nil, false, nil + } + segments := strings.Split(send.Target, ".") + root := segments[0] + if root == thisName { + return nil, false, nil + } + value, bound := ec.Lookup(root) + if !bound { + return nil, false, nil + } + var owners []*Instance + for _, held := range heldElements(value) { + if held.Kind != ValInstance { + return nil, true, &SendTargetValueError{Target: send.Target, Name: root, Value: FormatValue(value)} + } + if inst, ok := ec.ctx.instances[held.Instance]; ok { + owners = append(owners, inst) + } + } + if len(owners) == 0 { + return nil, true, &SendTargetValueError{Target: send.Target, Name: root, Value: FormatValue(value)} + } + addrs, err := ec.ctx.addressesFrom(owners, segments[1:]) + if err != nil { + return nil, true, err + } + if len(addrs) == 0 { + return nil, true, &UnroutableSendError{Port: send.Target, Address: true} + } + return addrs, true, nil +} + +// addressOwner answers which object a target's leading segments belong to: the +// sending object, or one holding it, where the first names a feature of it, else the occurrence +// the shortest prefix names in the send's scope — a prefix rather than one name, +// since a namespace qualifies the occurrence in `P::alpha.inPort`. +func (ctx *Context) addressOwner(scope *symbols.Scope, self *Instance, segments []string) (*Instance, []string, bool, error) { + // `this.…` reads from the sending object, the context occurrence of the send. + if segments[0] == thisName && len(segments) > 1 && self != nil { + return self, segments[1:], true, nil + } + // A name is a feature of the sending object, or of an object holding it: a + // nested object addresses a sibling through the object they belong to. + for up := self; up != nil; up = up.owner { + if fv, held := up.FeatureValues[segments[0]]; held && ctx.namesFeature(scope, up, fv, segments[0]) { + return up, segments, true, nil + } + } + if scope == nil || ctx.model.resolver == nil { + return nil, nil, false, nil + } + for n := 1; n <= len(segments); n++ { + sym, ok := ctx.pathSymbol(scope, segments[:n]) + if !ok || !isOccurrenceUsage(sym) || !ctx.occursOnce(sym) { + continue + } + if self != nil && self.Type == sym { + return self, segments[n:], true, nil + } + // A target that names an object this run cannot build fails as that, rather + // than being reported as an address naming nothing. + inst, err := ctx.occurrenceOf(sym) + if err != nil { + return nil, nil, false, err + } + return inst, segments[n:], true, nil + } + return nil, nil, false, nil +} + +// namesFeature reports whether a feature value of the sending object is what a name in +// the send's scope denotes: a nearer declaration, such as a node of the sending +// behavior, shadows the object's feature as name resolution has it. A redefined +// name reads the redefining feature's value, so it is what the name denotes too. +func (ctx *Context) namesFeature(scope *symbols.Scope, self *Instance, fv *FeatureValue, name string) bool { + sym, ok := ctx.pathSymbol(scope, []string{name}) + if !ok || (fv.Feature != nil && fv.Feature.Symbol == sym) { + return true + } + for _, of := range ctx.FeaturesOfObject(self) { + if of.Feature.Symbol == sym { + return true + } + } + for _, typ := range self.types() { + for _, feat := range ctx.FeaturesOf(typ) { + if feat.Name == name && feat.Symbol == sym { + return true + } + } + } + return false +} + +// ownPortPath reports whether a port path names a port of the sending behavior +// itself. A path led by a namespace or by an occurrence names a port of another +// object, which is unroutable where the instance graph did not reach it. +func (ctx *Context) ownPortPath(scope *symbols.Scope, segments []string) bool { + sym, ok := ctx.pathSymbol(scope, segments[:1]) + if !ok || isOccurrenceUsage(sym) { + return false + } + return sym.Kind != symbols.SymbolPackage && sym.Kind != symbols.SymbolNamespace +} + +// fvObject reads the object a feature of inst holds as its scalar value, +// materializing it, and reports whether the feature holds one at all. A feature +// value that cannot be read is that failure rather than a feature holding no object. +func (ctx *Context) fvObject(inst *Instance, name string) (*Instance, bool, error) { + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + return nil, false, err + } + if fv == nil || fv.Value.Kind != ValInstance { + return nil, false, nil + } + held, ok := ctx.instances[fv.Value.Instance] + return held, ok, nil +} + +// fvObjects reads every object a feature of inst holds, materializing it: the +// one its scalar value names, or each element of its collection in order. +func (ctx *Context) fvObjects(inst *Instance, name string) ([]*Instance, error) { + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil || fv == nil { + return nil, err + } + var out []*Instance + for _, v := range heldElements(fv.HeldValue()) { + if v.Kind != ValInstance { + continue + } + if held, ok := ctx.instances[v.Instance]; ok { + out = append(out, held) + } + } + return out, nil +} + +// heldElements flattens a held value to the values it holds: the elements of a +// collection, or the value itself. +func heldElements(v Value) []Value { + switch v.Kind { + case ValSequence: + if v.Sequence() == nil { + return nil + } + return v.Sequence().Elements() + case ValSet: + if v.Set() == nil { + return nil + } + return v.Set().Elements() + } + return []Value{v} +} + +// isPortFeature reports whether a feature is a port, where an address stops: the +// rest of its path is the port path within the object. +func isPortFeature(feature *EffectiveFeature) bool { + return feature != nil && feature.Symbol != nil && feature.Symbol.Kind == symbols.SymbolPortUsage +} + +// isBehaviorFeature reports whether a feature is a behavior an object performs, +// which receives by name rather than being an object of its own. +func isBehaviorFeature(feature *EffectiveFeature) bool { + return feature != nil && isBehaviorSymbol(feature.Symbol) +} + +// isBehaviorSymbol reports whether sym declares a behavior an object performs. +func isBehaviorSymbol(sym *symbols.Symbol) bool { + if sym == nil { + return false + } + switch sym.Kind { + case symbols.SymbolActionUsage, symbols.SymbolStateUsage: + return true + } + return false +} + +// isBehaviorType reports whether sym is a behavior, whose objects are its +// performances: an action or state, as a definition or a usage. +func isBehaviorType(sym *symbols.Symbol) bool { + if sym == nil { + return false + } + switch sym.Kind { + case symbols.SymbolActionDef, symbols.SymbolActionUsage, symbols.SymbolStateDef, symbols.SymbolStateUsage: + return true + } + return false +} + +// isPerformanceEvent reports an `event occurrence` a behavior declares: it occurs +// in each performance of the behavior rather than being an object of its own. +func isPerformanceEvent(sym *symbols.Symbol) bool { + if sym == nil || sym.Kind != symbols.SymbolOccurrenceUsage || sym.OwnerScope == nil { + return false + } + usage, ok := sym.Decl.(*ast.Usage) + if !ok || !usage.IsEvent { + return false + } + owner := sym.OwnerScope.Owner() + if owner == nil { + return false + } + switch owner.Kind { + case symbols.SymbolActionDef, symbols.SymbolStateDef: + return true + } + return isBehaviorSymbol(owner) +} + +// send builds and posts the message a send statement describes; a send that +// cannot deliver leaves nothing its payload or receiver expression created +// behind, while whatever posting itself materialized survives. +func (ctx *Context) send(ec *EvalContext, scope *symbols.Scope, conns []lower.Connection, s lower.Send, self *Instance, behavior *symbols.Symbol) error { + mark, attached := len(ctx.created), len(ctx.objectBehaviors) + msg, err := ec.buildMessage(scope, s) + if err != nil { + ctx.abandonCreationSince(mark, attached) + return err + } + receivers, err := ec.valuedReceivers(s) + if err != nil { + ctx.abandonCreationSince(mark, attached) + return err + } + built, started := len(ctx.created), len(ctx.objectBehaviors) + if err := ctx.postFor(ec, conns, msg, s, receivers, self, behavior); err != nil { + ctx.abandonCreationBetween(mark, built, attached, started) + return err + } + return nil +} + +// valuedReceivers evaluates the bare receiver expression of a send — its `to` +// clause that is no name or path — to the objects it denotes, nil where the +// send carries none. +func (ec *EvalContext) valuedReceivers(s lower.Send) ([]*Instance, error) { + switch { + case !s.IsVia && s.Target == "" && s.TargetExpr != nil: + return ec.receiverObjects(s.TargetExpr) + case s.IsVia && s.Receiver == "" && s.ReceiverExpr != nil: + return ec.receiverObjects(s.ReceiverExpr) + } + return nil, nil +} + +// postFor posts a message as its send addressed it: to the objects a target +// bound in ec or valued by its receiver expression holds, else as post routes it. +// receivers is what valuedReceivers evaluated, nil where the send carried none. +func (ctx *Context) postFor(ec *EvalContext, conns []lower.Connection, msg Message, s lower.Send, receivers []*Instance, self *Instance, behavior *symbols.Symbol) error { + if addrs, bound, err := ec.boundTargetAddresses(s); bound { + if err != nil { + return err + } + return ctx.postAt(msg, addrs, self, behavior) + } + if receivers != nil && !s.IsVia { + addrs, err := ctx.addressesFrom(receivers, nil) + if err != nil { + return err + } + return ctx.postAt(msg, addrs, self, behavior) + } + return ctx.post(ec, conns, msg, s, receivers, self, behavior) +} + +// receiverObjects evaluates node to the objects it yields: every element it +// holds must be a live object of this run, else the send's target is no object +// to address — the error names the expression and what it held. +func (ec *EvalContext) receiverObjects(node ast.Node) ([]*Instance, error) { + text := exprText(node) + value, err := ec.Eval(node) + if err != nil { + return nil, err + } + var out []*Instance + for _, held := range heldElements(value) { + id, isObject := held.Object() + inst, ok := ec.ctx.instances[id] + if !isObject || !ok { + return nil, &SendReceiverValueError{Receiver: text, Value: FormatValue(value)} + } + if err := ec.ctx.checkNotDestroyed(inst); err != nil { + return nil, err + } + out = append(out, inst) + } + if len(out) == 0 { + return nil, &SendReceiverValueError{Receiver: text, Value: FormatValue(value)} + } + return out, nil +} + +// routedReceiverObjects evaluates a routed send's `to` to the objects it +// yields where its name is no receiving node of the holder, nil then — an +// unresolved reference included, which the caller reports as unreachable. +func (ec *EvalContext) routedReceiverObjects(send lower.Send, holder *Instance, segments []string, path bool) (map[int64]bool, error) { + if send.ReceiverExpr == nil || ec.ctx.routedReceiverExists(send.Scope, segments, path, holder) { + return nil, nil + } + objects, err := ec.receiverObjects(send.ReceiverExpr) + if err != nil { + if errors.Is(err, ErrUnresolvedReference) { + return nil, nil + } + return nil, err + } + return objectSet(objects), nil +} + +// objectSet maps the objects a receiver expression yielded by identity. +func objectSet(objects []*Instance) map[int64]bool { + out := make(map[int64]bool, len(objects)) + for _, inst := range objects { + out[inst.ID] = true + } + return out +} + +// exprText renders an expression a send diagnostic names: a name or chain as +// written, and other forms by their shape since no printer renders them. +func exprText(node ast.Node) string { + switch n := node.(type) { + case nil: + return "" + case *ast.FeatureReference, *ast.QualifiedName, *ast.FeatureChainExpr: + return targetText(node) + case *ast.IndexExpr: + open, closing := "#(", ")" + if n.Bracket { + open, closing = "[", "]" + } + return exprText(n.Operand) + open + exprText(n.Index) + closing + case *ast.ConstructorExpr: + return "new " + ast.QualifiedText(n.Type) + case *ast.InvocationExpr: + return ast.QualifiedText(n.Type) + "()" + case *ast.SequenceExpr: + elements := make([]string, 0, len(n.Elements)) + for _, element := range n.Elements { + elements = append(elements, exprText(element)) + } + return "(" + strings.Join(elements, ", ") + ")" + case *ast.OperatorExpr: + if len(n.Operands) == 1 { + return n.Operator.String() + exprText(n.Operands[0]) + } + operands := make([]string, 0, len(n.Operands)) + for _, operand := range n.Operands { + operands = append(operands, exprText(operand)) + } + return strings.Join(operands, " "+n.Operator.String()+" ") + case *ast.LiteralInteger: + return n.Value + case *ast.LiteralReal: + return n.Value + case *ast.LiteralString: + return n.Value + case *ast.LiteralBool: + if n.Value { + return "true" + } + return "false" + } + return "the receiver expression" +} + +// post delivers a built message the way the send addressed it: routed through +// the connections of the sending port, or straight onto the bus. self is the +// object performing the behavior that sent it, nil for a behavior no object +// performs; behavior is that behavior, which the trace names; ec holds the +// behavior's bindings, nil where it has none. +func (ctx *Context) post(ec *EvalContext, conns []lower.Connection, msg Message, send lower.Send, receivers []*Instance, self *Instance, behavior *symbols.Symbol) error { + if send.IsVia { + return ctx.postVia(ec, conns, msg, send, receivers, self, behavior) + } + return ctx.postTo(msg, send, self, behavior) +} + +// carriesEvent reports whether m was sent from the event feature an accept +// subsets (`accept :> left.alert`), as evaluated in ec: the same declaration +// once it resolves, so a typed message of a same-named type never satisfies it, +// and the same occurrence where the message holds one, so a sibling part's event +// or an accept path evaluating to none never does; by name where it does not resolve. +func (ec *EvalContext) carriesEvent(m Message, subsets ast.Node) bool { + path := lower.FeaturePath(subsets) + if path == "" { + return true + } + ec.ctx.notePollReadsData() + want, ok := ec.ctx.featureSymbol(ec.scope, path) + if !ok { + name := lastSegment(path) + return name == m.EventName || name == m.SignalType + } + if m.Event == nil || want != m.Event { + return false + } + if m.EventObject == 0 { + return true + } + occurrence, ok := ec.eventOccurrence(subsets, want) + return ok && occurrence == m.EventObject +} + +// eventOccurrence is the occurrence an accept's event path names in ec: what the +// feature holds, the object performing a behavioral feature (`left.alert`, `alert`), +// or the performance an event of a behavior occurs in (`fall.touchdown`). +func (ec *EvalContext) eventOccurrence(subsets ast.Node, event *symbols.Symbol) (int64, bool) { + if isBehaviorSymbol(event) || isPerformanceEvent(event) { + chain, ok := subsets.(*ast.FeatureChainExpr) + if !ok { + return objectID(ec.self), ec.self != nil + } + subsets = chain.Operand + } + value, err := ec.eval(subsets) + if err != nil || value.Kind != ValInstance { + return 0, false + } + return value.Instance, true +} + +// lastSegment is the feature a dotted path ends at. +func lastSegment(path string) string { + return path[strings.LastIndex(path, ".")+1:] +} + +// featureSymbol resolves a dotted feature path written in scope to the feature +// it names, through an alias. +func (ctx *Context) featureSymbol(scope *symbols.Scope, path string) (*symbols.Symbol, bool) { + segments := strings.Split(path, ".") + if ctx.model.resolver == nil || (ctx.model.semantics == nil && len(segments) > 1) { + return nil, false + } + sym, ok := ctx.pathSymbol(scope, segments) + if !ok { + return nil, false + } + return ctx.model.resolver.AliasedElement(sym), true +} + +// messageMatches reports whether a message satisfies an accept whose parameter +// is typed as want, written in scope: the message's type must conform to the +// definition want resolves to, so a subtype message satisfies a supertype +// accept and same-named definitions of different packages stay apart. Where +// either side resolves to no symbol, the written names are compared instead. +func (ctx *Context) messageMatches(m Message, want *ast.QualifiedName, scope *symbols.Scope) bool { + if want == nil || len(want.Parts) == 0 { + return true + } + if m.Signal != nil && ctx.model.semantics != nil { + if wantSym := ctx.triggerType(scope, want); wantSym != nil { + return ctx.signalConforms(m.Signal, wantSym) + } + } + return m.SignalType == want.Parts[len(want.Parts)-1].Text +} + +// triggerTypeKey is a type reference as written in one scope; the model fixes what it denotes. +type triggerTypeKey struct { + scope *symbols.Scope + ref *ast.QualifiedName +} + +// signalMatchKey is a message's signal against the definition an accept names. +type signalMatchKey struct { + signal, want *symbols.Symbol +} + +// triggerType is resolveTypeRef memoized on the model; a binding under way resolves +// afresh so that what it read is noted for it. +func (ctx *Context) triggerType(scope *symbols.Scope, want *ast.QualifiedName) *symbols.Symbol { + if ctx.recordingReads() { + return ctx.resolveTypeRef(scope, want) + } + key := triggerTypeKey{scope: scope, ref: want} + if sym, ok := ctx.model.triggerTypes[key]; ok { + return sym + } + sym := ctx.resolveTypeRef(scope, want) + ctx.model.triggerTypes[key] = sym + return sym +} + +// signalConforms is conforms memoized on the model, as triggerType is. +func (ctx *Context) signalConforms(signal, want *symbols.Symbol) bool { + if ctx.recordingReads() { + return ctx.conforms(signal, want) + } + if signal == want { + return true + } + key := signalMatchKey{signal: signal, want: want} + if matches, ok := ctx.model.signalMatches[key]; ok { + return matches + } + matches := ctx.conforms(signal, want) + ctx.model.signalMatches[key] = matches + return matches +} + +// buildMessage evaluates a send statement into a message. +// +// `send Ping to m` (a type) sends it with no payload; `send new Ping(3) to m` +// sends it carrying the arguments; any other expression's value is sent as `value`. +// +// A via send keeps a receiver target when one was stated; postVia fills in the +// reached port and final delivery kind. +// +// Names resolve in the send's declaring scope, which sees what a nested block +// imports; scope is the fallback where the lowered send records none. +func (e *EvalContext) buildMessage(scope *symbols.Scope, send lower.Send) (Message, error) { + if send.Scope != nil { + scope = send.Scope + } + target := send.Target + if send.IsVia { + target = send.Receiver + } + if sym, ok := e.namedType(scope, send.Message); ok { + return Message{SignalType: sym.Name, Signal: sym, Target: target, Payload: map[string]Value{}}, nil + } + if constructor, ok := send.Message.(*ast.ConstructorExpr); ok { + return e.buildConstructedMessage(scope, constructor, target) + } + + // `send shutDown() to self` sends the invoked behavioral feature carrying its + // arguments, never calling it; a calculation is called and its value sent. + if invocation, ok := send.Message.(*ast.InvocationExpr); ok { + calls, err := e.invokesCalc(scope, invocation) + if err != nil { + return Message{}, err + } + if !calls { + msg, err := e.buildInvokedMessage(scope, invocation, target) + if err != nil { + return Message{}, err + } + msg.Event, msg.EventName = e.sentFeature(scope, invocation.Type) + if isBehaviorSymbol(msg.Event) { + msg.EventObject = objectID(e.self) + } + return msg, nil + } + } + + value, err := e.Eval(send.Message) + if err != nil { + return Message{}, fmt.Errorf("eval send message: %w", err) + } + signalType := valueTypeName(value) + var signal *symbols.Symbol + if signalType == "" && value.Kind == ValInstance { + signal = e.ctx.objectSignalSymbol(value.Instance) + if signal != nil { + signalType = signal.Name + } + } + if signalType == "" { + return Message{}, fmt.Errorf("send: message of kind %v has no signal type", value.Kind) + } + event, eventName := e.sentFeature(scope, send.Message) + msg := Message{ + SignalType: signalType, + Signal: signal, + Event: event, + EventName: eventName, + Target: target, + Value: &value, + } + if event != nil && value.Kind == ValInstance { + msg.EventObject = value.Instance + } + return msg, nil +} + +// sentFeature is the feature a send reads its message from (`send a.b via p`) +// and its written name; nil where unresolved, both empty for other messages. +func (e *EvalContext) sentFeature(scope *symbols.Scope, message ast.Node) (*symbols.Symbol, string) { + path := lower.FeaturePath(message) + if path == "" { + return nil, "" + } + name := lastSegment(path) + if scope == nil || e.ctx == nil { + return nil, name + } + sym, ok := e.ctx.featureSymbol(scope, path) + if !ok { + return nil, name + } + return sym, name +} + +// acceptedValue is the value an accept binds its payload name to: the single +// value the message carries, or an occurrence of its signal built from the +// arguments the send named, so `accept p : Ping` sees a Ping object either way. +// The occurrence is kept on the message: a guard evaluated during transition +// selection and the firing that follows read the same object. +func (ctx *Context) acceptedValue(msg *Message) (Value, error) { + if msg.Value != nil { + return *msg.Value, nil + } + if msg.Signal == nil { + return Value{}, fmt.Errorf("%w: %s carries no single value to bind", + ErrNoValue, orAnonymousSignal(msg.SignalType)) + } + value, err := ctx.materializeAccepted(*msg) + if err != nil { + return Value{}, err + } + msg.Value = &value + return value, nil +} + +// materializeAccepted builds the occurrence an accept binds a typed message as. +func (ctx *Context) materializeAccepted(msg Message) (Value, error) { + value, err := ctx.materializeMessage(msg) + if err != nil { + return Value{}, fmt.Errorf("accepted %s: %w", msg.SignalType, err) + } + return value, nil +} + +// materializeMessage builds the occurrence a typed message carries, leaving no +// instance behind when a payload entry names no feature of it, does not fit +// one, or two entries name one feature. +func (ctx *Context) materializeMessage(msg Message) (Value, error) { + mark := len(ctx.created) + inst, err := ctx.materialize(msg.Signal, 0, nil, "") + if err != nil { + ctx.abandonInstancesSince(mark) + return Value{}, fmt.Errorf("materialize: %w", err) + } + names := make([]string, 0, len(msg.Payload)) + for name := range msg.Payload { + names = append(names, name) + } + sort.Strings(names) + written := make(map[*FeatureValue]string, len(names)) + for _, name := range names { + fv, held := inst.FeatureValues[name] + if !held { + ctx.abandonInstancesSince(mark) + return Value{}, fmt.Errorf("%q names no feature it carries", name) + } + if fv != nil { + if earlier, twice := written[fv]; twice { + ctx.abandonInstancesSince(mark) + return Value{}, fmt.Errorf("%s and %s are one feature, bound twice", earlier, name) + } + written[fv] = name + } + if err := inst.SetFeatureValue(ctx, name, msg.Payload[name]); err != nil { + ctx.abandonInstancesSince(mark) + return Value{}, err + } + } + return Value{Kind: ValInstance, Instance: inst.ID}, nil +} + +// invokesCalc reports whether an invocation calls a calculation — the declaration +// evaluating it would select — rather than naming a signal to send. +func (e *EvalContext) invokesCalc(scope *symbols.Scope, invocation *ast.InvocationExpr) (bool, error) { + if invocation.Type == nil { + return false, nil + } + if e.ctx == nil || e.ctx.model.resolver == nil || e.ctx.model.semantics == nil || scope == nil { + return false, nil + } + sel, err := e.ctx.selectInvocation(scope, invocation, semantics.PerformsBehavior) + if err != nil { + return false, err + } + if sel.Ambiguous { + return true, nil + } + return e.ctx.model.semantics.Evaluates(sel.Called()), nil +} + +// buildInvokedMessage builds the message of `send shutDown(7) to self`: the +// invoked behavioral feature types it and the arguments are its payload, a lone +// positional one also as `value`, which an accept binds its parameter to. +func (e *EvalContext) buildInvokedMessage(scope *symbols.Scope, invocation *ast.InvocationExpr, target string) (Message, error) { + if invocation.Operand != nil { + return Message{}, fmt.Errorf("send %s: a message is not sent through a receiver", ast.SimpleName(invocation.Type)) + } + signalType, signal, err := e.messageType(scope, invocation.Type) + if err != nil { + return Message{}, err + } + return e.buildTypedMessage(scope, signalType, signal, target, + messageArgs{typeRef: invocation.Type, args: invocation.Args, named: invocation.NamedArgs, loneValue: true}) +} + +// buildConstructedMessage builds the message of `send new Telemetry(3) via +// antenna`: the constructed definition types it and the arguments bind its +// features, so an accept binds a Telemetry whose first feature is 3, never the 3. +// The occurrence is constructed at the send, so an argument no feature admits, or +// one beyond the features, is rejected there whether or not an accept consumes it. +func (e *EvalContext) buildConstructedMessage(scope *symbols.Scope, constructor *ast.ConstructorExpr, target string) (Message, error) { + signal, err := e.constructedType(scope, constructor.Type, "send") + if err != nil { + return Message{}, err + } + if err := e.checkConstructorArity(signal, constructor, "send "+signal.Name); err != nil { + return Message{}, err + } + msg, inst, err := e.constructObject(func() (Message, error) { + return e.buildTypedMessage(scope, signal.Name, signal, target, + messageArgs{typeRef: constructor.Type, args: constructor.Args, named: constructor.NamedArgs}) + }, "send new "+signal.Name) + if err != nil { + return Message{}, err + } + msg.Value = &Value{Kind: ValInstance, Instance: inst.ID} + return msg, nil +} + +// evalConstructor evaluates `new T(…)` as a value: the object of T whose +// constructible features the arguments bind, read as what it denotes. +func (e *EvalContext) evalConstructor(constructor *ast.ConstructorExpr) (Value, error) { + typ, err := e.constructedType(e.scope, constructor.Type, "") + if err != nil { + return Value{}, err + } + what := "new " + typ.Name + if err := e.checkConstructorArity(typ, constructor, what); err != nil { + return Value{}, err + } + _, inst, err := e.constructObject(func() (Message, error) { + return e.buildTypedMessage(e.scope, typ.Name, typ, "", + messageArgs{typeRef: constructor.Type, args: constructor.Args, named: constructor.NamedArgs, written: what}) + }, what) + if err != nil { + return Value{}, err + } + return e.ctx.objectValue(inst) +} + +// constructObject materializes the object `new T(…)` denotes (KerML §7.4.9): an occurrence +// whose life begins here and that performs T's behaviors. A construction that fails at any +// step — an argument, the materialization, a behavior's start — leaves nothing: the objects +// its arguments made, the values written and the messages sent along the way are rolled back. +func (e *EvalContext) constructObject(build func() (Message, error), what string) (Message, *Instance, error) { + ctx := e.ctx + commit, rollback := ctx.beginJournal() + msg, err := build() + if err != nil { + rollback() + return Message{}, nil, err + } + var value Value + err = ctx.storedTogether(func() (err error) { + value, err = ctx.materializeMessage(msg) + return err + }) + if err != nil { + rollback() + return Message{}, nil, fmt.Errorf("%s: %w", what, err) + } + inst := ctx.instances[value.Instance] + if err := ctx.startClassifierBehaviors(inst, len(ctx.created)); err != nil { + rollback() + return Message{}, nil, fmt.Errorf("%s: %w", what, err) + } + commit() + return msg, inst, nil +} + +// checkConstructorArity rejects positional arguments beyond the constructed +// type's constructible features. +func (e *EvalContext) checkConstructorArity(typ *symbols.Symbol, constructor *ast.ConstructorExpr, what string) error { + if e.ctx.model.semantics == nil { + return nil + } + if n := len(e.ctx.model.semantics.ConstructibleFeatures(typ)); len(constructor.Args) > n { + return fmt.Errorf("%s: new %s takes %d argument(s), found %d", what, typ.Name, n, len(constructor.Args)) + } + return nil +} + +// constructedType resolves the type `new T(…)` instantiates: a definition, or a +// usage, which is a type too. A name that resolves to nothing or to no type is +// an error, reported under the statement (`send`) the constructor is written in. +func (e *EvalContext) constructedType(scope *symbols.Scope, typeRef *ast.QualifiedName, statement string) (*symbols.Symbol, error) { + name := ast.QualifiedText(typeRef) + prefix := "new" + if statement != "" { + prefix = statement + " new" + } + if name == "" { + return nil, fmt.Errorf("%s: the constructor names no type", prefix) + } + if scope == nil || e.ctx == nil || e.ctx.model.resolver == nil { + return nil, fmt.Errorf("%s %s: no scope resolves the type", prefix, name) + } + sym, ok := e.ctx.resolveQualified(scope, typeRef) + if !ok || sym == nil { + return nil, fmt.Errorf("%s %s: unresolved reference: %s", prefix, name, name) + } + switch sym.Decl.(type) { + case *ast.Definition, *ast.Usage: + return sym, nil + } + return nil, fmt.Errorf("%s %s: %s is a %s, not a type", prefix, name, name, sym.Kind) +} + +// messageType names the type of an invoked message and resolves it to the +// definition it reaches, nil when it reaches none. +func (e *EvalContext) messageType(scope *symbols.Scope, typeRef *ast.QualifiedName) (string, *symbols.Symbol, error) { + signalType := ast.SimpleName(typeRef) + if signalType == "" { + return "", nil, fmt.Errorf("send: the message names no signal") + } + signal, ok := e.definitionNamed(scope, typeRef) + if !ok { + return signalType, nil, nil + } + return signal.Name, signal, nil +} + +// buildTypedMessage builds a message typed by signal, named typeRef, carrying each +// argument under the feature it binds: a positional one the feature at its position +// (`argN` where the type has none), a label the feature it names. Binding one feature +// twice, by position and label or by two labels, is an error rather than the last +// value. With loneValue a lone positional argument is also the message's Value. +// Errors name the construct as written, `send T` unless stated otherwise. +type messageArgs struct { + typeRef *ast.QualifiedName + args []ast.Node + named []ast.NamedArg + loneValue bool + written string +} + +func (e *EvalContext) buildTypedMessage(scope *symbols.Scope, signalType string, signal *symbols.Symbol, target string, in messageArgs) (Message, error) { + typeRef, args, named, loneValue := in.typeRef, in.args, in.named, in.loneValue + written := in.written + if written == "" { + written = "send " + signalType + } + msg := Message{SignalType: signalType, Signal: signal, Target: target, + Payload: make(map[string]Value, len(args)+len(named))} + var slots []*symbols.Symbol + if signal != nil && e.ctx != nil && e.ctx.model.semantics != nil { + slots = e.ctx.model.semantics.ConstructibleFeatures(signal) + } + bound := make(map[*symbols.Symbol]string, len(args)+len(named)) + for i, arg := range args { + value, err := e.Eval(arg) + if err != nil { + return Message{}, fmt.Errorf("eval argument %d of %s: %w", i+1, written, err) + } + name := fmt.Sprintf("arg%d", i+1) + if i < len(slots) { + name = slots[i].Name + bound[slots[i]] = name + } + msg.Payload[name] = value + if loneValue && len(args) == 1 && len(named) == 0 { + msg.Value = &value + } + } + for _, arg := range named { + label := ast.QualifiedText(arg.Name) + if label == "" { + return Message{}, fmt.Errorf("%s: an argument is named by nothing", written) + } + name, err := e.constructorLabel(scope, signal, typeRef, arg.Name, bound) + if err != nil { + return Message{}, fmt.Errorf("%s: %w", written, err) + } + if _, twice := msg.Payload[name]; twice { + return Message{}, fmt.Errorf("%s: %s is bound twice", written, label) + } + value, err := e.Eval(arg.Value) + if err != nil { + return Message{}, fmt.Errorf("eval argument %s of %s: %w", label, written, err) + } + msg.Payload[name] = value + } + return msg, nil +} + +// constructorLabel returns the shape name of the member of signal a constructor +// label names (resolved as the checker does); a foreign, masked or rebound feature is an error. +func (e *EvalContext) constructorLabel(scope *symbols.Scope, signal *symbols.Symbol, typeRef, qn *ast.QualifiedName, bound map[*symbols.Symbol]string) (string, error) { + label := ast.QualifiedText(qn) + if signal == nil || e.ctx == nil || e.ctx.model.resolver == nil || e.ctx.model.semantics == nil { + if len(qn.Parts) != 1 { + return "", fmt.Errorf("%s is not a feature of %s", label, ast.SimpleName(typeRef)) + } + return qn.Parts[0].Text, nil + } + feature, ok := e.ctx.resolveConstructorLabel(scope, typeRef, qn) + if !ok || feature == nil || !slices.Contains(e.ctx.model.semantics.MembersOf(signal), feature) { + return "", fmt.Errorf("%s is not a feature of %s", label, signal.Name) + } + shape := e.ctx.model.semantics.ShapeFeatures(signal) + i := slices.IndexFunc(shape, func(f semantics.ShapeFeature) bool { + return f.Declared == feature || f.Symbol == feature + }) + if i < 0 { + return "", fmt.Errorf("%s is not a feature of %s", label, signal.Name) + } + slot := e.ctx.model.semantics.ConstructibleFeatureFor(signal, shape[i].Declared) + if slot == nil { + return "", fmt.Errorf("%s is not a feature a constructor of %s binds", label, signal.Name) + } + name := shape[i].Name + if earlier, twice := bound[slot]; twice { + if earlier == name { + return "", fmt.Errorf("%s is bound twice", name) + } + return "", fmt.Errorf("%s and %s are one feature, bound twice", earlier, name) + } + bound[slot] = name + return name, nil +} + +// triggerName describes a transition's trigger for traces. Traces are compared +// against goldens, so the text has to be stable: printing the trigger node +// itself emits a pointer address. +func triggerName(trigger ast.Node) string { + return lower.TriggerName(trigger) +} + +// eventName names a dispatched occurrence as triggerName names the triggers it +// matches, from the occurrence itself so the name is the same whichever took it. +// A message sent from an event feature is that feature's occurrence, named as +// the accept subsetting it is written, so same-typed events stay apart. +func eventName(event *Event) string { + switch payload := event.Payload.(type) { + case Message: + if payload.EventName != "" { + return "accept :> " + payload.EventName + } + return "accept " + orAny(payload.SignalType) + case Call: + return "call " + orAny(payload.Operation) + } + switch event.Type { + case EventTime: + return "time" + case EventChange: + return "change" + default: + return event.Type.String() + } +} + +// triggerDescription describes the event an accept waits for in the notation it +// was written in, which is what an error about it, or a view of a suspended run, +// has to name. The expression a trigger waits on is named when it is a name; +// there is no printer for an arbitrary one, so the keyword alone stands for it. +func triggerDescription(trigger ast.Node) string { + switch t := trigger.(type) { + case *ast.TimeEvent: + keyword := "after" + if t.Absolute { + keyword = "at" + } + return joinWords("accept", keyword, ast.SimpleName(t.Duration)) + case *ast.ChangeEvent: + return joinWords("accept", "when", ast.SimpleName(t.Condition)) + default: + return triggerName(trigger) + } +} + +// joinWords joins the words of a description, dropping the ones that are empty. +func joinWords(words ...string) string { + kept := make([]string, 0, len(words)) + for _, w := range words { + if w != "" { + kept = append(kept, w) + } + } + return strings.Join(kept, " ") +} + +// viaSuffix describes the port an accept waits on, for an error message, or +// nothing when it waits on none. +func viaSuffix(port string) string { + if port == "" { + return "" + } + return " via " + port +} + +// orAny names a type or operation, or reports that any is accepted when the +// model named none. +func orAny(name string) string { + if name == "" { + return "any" + } + return name +} + +// namedType reports the definition expr names when it names a type definition +// rather than denoting a value. +func (e *EvalContext) namedType(scope *symbols.Scope, expr ast.Node) (*symbols.Symbol, bool) { + return e.definitionNamed(scope, ast.AsQualifiedName(expr)) +} + +// definitionNamed resolves a name to the definition it reaches (resolution +// already follows an alias to its element) or reports that it names none. +func (e *EvalContext) definitionNamed(scope *symbols.Scope, qname *ast.QualifiedName) (*symbols.Symbol, bool) { + if qname == nil || scope == nil || e.ctx == nil || e.ctx.model.resolver == nil { + return nil, false + } + sym, ok := e.ctx.resolveQualified(scope, qname) + if !ok || sym == nil { + return nil, false + } + if !isDefinitionSymbol(sym) { + return nil, false + } + return sym, true +} + +// objectSignalSymbol is the definition an object sent as a message +// materializes, which is the type an accept of it matches by conformance. +func (ctx *Context) objectSignalSymbol(id int64) *symbols.Symbol { + inst, ok := ctx.instances[id] + if !ok || inst == nil || ctx.model.semantics == nil { + return nil + } + if isDefinitionSymbol(inst.Type) { + return inst.Type + } + for _, sup := range ctx.model.semantics.AllSupertypes(inst.Type) { + if isDefinitionSymbol(sup) { + return sup + } + } + return nil +} + +// isDefinitionSymbol reports whether a symbol declares a definition, not a usage. +func isDefinitionSymbol(sym *symbols.Symbol) bool { + if sym == nil { + return false + } + _, isDef := sym.Decl.(*ast.Definition) + return isDef +} + +// valueTypeName names the type of a value, as a send statement's signal type. +func valueTypeName(v Value) string { + switch v.Kind { + case ValString: + return "String" + case ValConst: + switch v.Const.Kind { + case semantics.ValInt: + return "Integer" + case semantics.ValReal: + return "Real" + case semantics.ValBool: + return "Boolean" + } + } + return "" +} diff --git a/internal/core/runtime/signal_injection_test.go b/internal/exec/runtime/signal_injection_test.go similarity index 96% rename from internal/core/runtime/signal_injection_test.go rename to internal/exec/runtime/signal_injection_test.go index f0f1ef51d5..cde02abd18 100644 --- a/internal/core/runtime/signal_injection_test.go +++ b/internal/exec/runtime/signal_injection_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) const lampSource = ` @@ -204,7 +204,7 @@ func TestProcessNextEventTakesAPendingSignalBeforeALaterTimer(t *testing.T) { } attribute def Kick; `) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) exec, err := ctx.CreateStateExecutorFor(resolveSymbol(t, root, "Waiter"), nil) if err != nil { t.Fatalf("CreateStateExecutorFor: %v", err) @@ -245,7 +245,7 @@ func TestRunToCompletionTakesAPendingSignalBeforeALaterTimer(t *testing.T) { } attribute def Kick; `) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) exec, err := ctx.CreateStateExecutorFor(resolveSymbol(t, root, "Waiter"), nil) if err != nil { t.Fatalf("CreateStateExecutorFor: %v", err) @@ -945,6 +945,78 @@ func TestSignalGoesToTheSiblingMachineThatFiresOnIt(t *testing.T) { t.Fatalf("%d messages left in flight after the drain", len(ctx.PendingMessages())) } }) + t.Run(tc.name+"/previewed", func(t *testing.T) { + // Previewed, each machine says whether it would take the message as + // delivery decides; both are triggered by it, guards aside. + ctx, left, right, ping := twinWithPing(t, armed) + wantLeftTakes := !tc.leftStepLeavesItInFlight + wantRightTakes := tc.rightArmed || !tc.leftArmed + for _, probe := range []struct { + name string + machine *StateExecutor + wantTakes bool + }{{"left", left, wantLeftTakes}, {"right", right, wantRightTakes}} { + takes, err := probe.machine.TakesMessage(ping) + if err != nil || takes != probe.wantTakes { + t.Errorf("%s.TakesMessage(Ping) = %v, %v; want %v", probe.name, takes, err, probe.wantTakes) + } + triggered, err := probe.machine.TriggeredBy(ping) + if err != nil || !triggered { + t.Errorf("%s.TriggeredBy(Ping) = %v, %v; want true whatever the guard", probe.name, triggered, err) + } + } + if got := activeLeaf(left) + "/" + activeLeaf(right); got != "idle/idle" { + t.Fatalf("after the previews: left/right = %s, want idle/idle", got) + } + if len(ctx.PendingMessages()) != 0 { + t.Fatalf("%d messages in flight after previews of one never posted", len(ctx.PendingMessages())) + } + }) + } +} + +// A state that only defers a message takes it without any transition being +// triggered by it, which the two previews tell apart. +func TestADeferringMachineTakesWhatNoTransitionIsTriggeredBy(t *testing.T) { + src := ` + attribute def Ping; + attribute def Go; + part def Holder { + exhibit state main { + entry; then busy; + state busy { defer Ping; } + transition first busy accept Go then ready; + state ready; + transition first ready accept Ping then done; + state done; + } + } + part holder : Holder; + ` + idx, _, ctx := buildRuntimeWithLibraries(t, "holder.sysml", parseAndBuild(t, src)) + root := idx.DocumentRoot("holder.sysml") + holder, err := ctx.occurrenceOf(resolveSymbol(t, root, "holder")) + if err != nil { + t.Fatalf("occurrenceOf(holder): %v", err) + } + machines := holder.ExhibitedStates() + if len(machines) != 1 || machines[0].State == nil { + t.Fatalf("holder exhibits %d machines; want main", len(machines)) + } + main := machines[0].State + ping, err := ctx.SignalMessage(resolveSymbol(t, root, "Ping"), nil, holder) + if err != nil { + t.Fatalf("SignalMessage(Ping): %v", err) + } + if takes, err := main.TakesMessage(ping); err != nil || !takes { + t.Fatalf("main.TakesMessage(Ping) in busy = %v, %v; want true, busy defers it", takes, err) + } + if triggered, err := main.TriggeredBy(ping); err != nil || triggered { + t.Fatalf("main.TriggeredBy(Ping) in busy = %v, %v; want false, busy only defers it", triggered, err) + } + decision, err := main.Decide(ping) + if err != nil || !decision.Deferred || len(decision.Fires) != 0 { + t.Fatalf("main.Decide(Ping) in busy = %+v, %v; want deferred and nothing fired", decision, err) } } diff --git a/internal/core/runtime/signal_test.go b/internal/exec/runtime/signal_test.go similarity index 95% rename from internal/core/runtime/signal_test.go rename to internal/exec/runtime/signal_test.go index d41cd650ac..98628f6ce5 100644 --- a/internal/core/runtime/signal_test.go +++ b/internal/exec/runtime/signal_test.go @@ -6,10 +6,10 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // A message built outside a send carries its payload by feature name: a feature @@ -1057,7 +1057,7 @@ func TestPortRoutedMessageDoesNotReachStateMachine(t *testing.T) { } // The two judgements of whether a machine reacts to a message — matchesEvent, -// dispatching a queued occurrence to a transition, and acceptsSignalFrom, +// dispatching a queued occurrence to a transition, and acceptsSignalAlong, // deciding whether a state takes a message in flight — agree for a transfer // addressed to the performer, addressed to its port, and routed to its port: a // via-less accept receives only what is addressed to the performer itself. @@ -1163,12 +1163,12 @@ func TestAcceptRoutingAgreesBetweenDispatchAndAcceptance(t *testing.T) { if len(fired) != 1 || fired[0] != tc.fires { t.Errorf("%s: matchesEvent enables %v, want [%s]", tc.name, fired, tc.fires) } - accepts, err := exec.acceptsSignalFrom(waiting, tc.msg) + accepts, err := exec.acceptsSignalAlong(waiting, tc.msg) if err != nil { - t.Fatalf("%s: acceptsSignalFrom: %v", tc.name, err) + t.Fatalf("%s: acceptsSignalAlong: %v", tc.name, err) } if accepts != (len(fired) > 0) { - t.Errorf("%s: acceptsSignalFrom = %v while matchesEvent enables %v", tc.name, accepts, fired) + t.Errorf("%s: acceptsSignalAlong = %v while matchesEvent enables %v", tc.name, accepts, fired) } } } @@ -1394,7 +1394,7 @@ func TestRoutingHonorsTheSelectedVariantConnection(t *testing.T) { if tt.selected != "" { ctx.selectedVariants[variantSelection{variation: "link"}] = tt.selected } - if err := ctx.postVia(conns, Message{SignalType: "Ping"}, lower.Send{Target: "outPort", IsVia: true}, nil); err != nil { + if err := ctx.postVia(nil, conns, Message{SignalType: "Ping"}, lower.Send{Target: "outPort", IsVia: true}, nil, nil, nil); err != nil { t.Fatalf("selection %q: %v", tt.selected, err) } var got []string @@ -1437,7 +1437,7 @@ func TestRoutingIsPerOwnerVariantSelection(t *testing.T) { if err != nil { t.Fatalf("%s: %v", usage, err) } - if err := ctx.postVia(nil, Message{SignalType: "Ping"}, lower.Send{Target: "outPort", IsVia: true}, self); err != nil { + if err := ctx.postVia(nil, nil, Message{SignalType: "Ping"}, lower.Send{Target: "outPort", IsVia: true}, nil, self, nil); err != nil { t.Fatalf("%s: %v", usage, err) } var got []string @@ -1476,7 +1476,7 @@ func TestAddressedSendStaysWithinTheSendingObject(t *testing.T) { }`)) alpha, beta := instanceOfUsage(t, ctx, idx, "test::alpha"), instanceOfUsage(t, ctx, idx, "test::beta") send := lower.Send{Target: "reader", Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen"))} - if err := ctx.post(nil, Message{SignalType: "Ping"}, send, alpha); err != nil { + if err := ctx.post(nil, nil, Message{SignalType: "Ping"}, send, nil, alpha, nil); err != nil { t.Fatalf("post: %v", err) } pending := ctx.PendingMessages() @@ -1504,7 +1504,7 @@ func TestAddressedSendResolvesPortOfNamedObject(t *testing.T) { }`)) alpha, beta := instanceOfUsage(t, ctx, idx, "test::alpha"), instanceOfUsage(t, ctx, idx, "test::beta") send := lower.Send{Target: "alpha.inPort", TargetPath: true, Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen"))} - if err := ctx.post(nil, Message{SignalType: "Ping"}, send, beta); err != nil { + if err := ctx.post(nil, nil, Message{SignalType: "Ping"}, send, nil, beta, nil); err != nil { t.Fatalf("post: %v", err) } pending := ctx.PendingMessages() @@ -1536,7 +1536,7 @@ func TestAddressedSendDescendsToNestedPort(t *testing.T) { }`)) alpha := instanceOfUsage(t, ctx, idx, "test::alpha") send := lower.Send{Target: "inner.inPort", TargetPath: true, Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen"))} - if err := ctx.post(nil, Message{SignalType: "Ping"}, send, alpha); err != nil { + if err := ctx.post(nil, nil, Message{SignalType: "Ping"}, send, nil, alpha, nil); err != nil { t.Fatalf("post: %v", err) } inner, ok, err := ctx.fvObject(alpha, "inner") @@ -1566,7 +1566,7 @@ func TestAddressedSendToUnreachablePortIsTyped(t *testing.T) { part alpha : Node; }`)) send := lower.Send{Target: "alpha.count", TargetPath: true, Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen"))} - err := ctx.post(nil, Message{SignalType: "Ping"}, send, instanceOfUsage(t, ctx, idx, "test::alpha")) + err := ctx.post(nil, nil, Message{SignalType: "Ping"}, send, nil, instanceOfUsage(t, ctx, idx, "test::alpha"), nil) if !errors.Is(err, ErrUnroutableSend) { t.Fatalf("post to alpha.count: %v, want ErrUnroutableSend", err) } @@ -1624,7 +1624,7 @@ func TestAddressedSendToQualifiedNameSkipsSameNamedFeature(t *testing.T) { }`)) alpha := instanceOfUsage(t, ctx, idx, "test::alpha") send := lower.Send{Target: "Other::reader", Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen"))} - err := ctx.post(nil, Message{SignalType: "Integer"}, send, alpha) + err := ctx.post(nil, nil, Message{SignalType: "Integer"}, send, nil, alpha, nil) if !errors.Is(err, ErrUnroutableSend) { t.Errorf("`send to Other::reader` from an object: %v, want %v", err, ErrUnroutableSend) } @@ -1646,7 +1646,7 @@ func TestAddressedSendToQualifiedNameFromNoObjectIsDelivered(t *testing.T) { action listen { first start; done; succession first start then done; } }`)) send := lower.Send{Target: "Other::reader", Scope: DeclScope(oneSymbol(t, idx, "test::listen"))} - if err := ctx.post(nil, Message{SignalType: "Integer"}, send, nil); err != nil { + if err := ctx.post(nil, nil, Message{SignalType: "Integer"}, send, nil, nil, nil); err != nil { t.Fatalf("post: %v", err) } if got := ctx.PendingMessages()[0]; got.Target != "reader" || !got.reaches("reader", "", 0) { @@ -1668,7 +1668,7 @@ func TestAddressedSendToAnObjectNeedsThatObject(t *testing.T) { }`)) alpha := instanceOfUsage(t, ctx, idx, "test::alpha") send := lower.Send{Target: "leaf", Scope: DeclScope(oneSymbol(t, idx, "test::Node::talk"))} - if err := ctx.post(nil, Message{SignalType: "Integer"}, send, alpha); err != nil { + if err := ctx.post(nil, nil, Message{SignalType: "Integer"}, send, nil, alpha, nil); err != nil { t.Fatalf("post: %v", err) } got := ctx.PendingMessages()[0] @@ -1695,7 +1695,7 @@ func TestAddressedSendToReceiverOfAnotherObjectCarriesItsIdentity(t *testing.T) }`)) alpha, beta := instanceOfUsage(t, ctx, idx, "test::alpha"), instanceOfUsage(t, ctx, idx, "test::beta") send := lower.Send{Target: "alpha::reader", Scope: DeclScope(oneSymbol(t, idx, "test::Talker::talk"))} - if err := ctx.post(nil, Message{SignalType: "Integer"}, send, beta); err != nil { + if err := ctx.post(nil, nil, Message{SignalType: "Integer"}, send, nil, beta, nil); err != nil { t.Fatalf("post: %v", err) } got := ctx.PendingMessages()[0] @@ -1727,7 +1727,7 @@ func TestAddressedSendPrefersTheNearerDeclaration(t *testing.T) { }`)) alpha := instanceOfUsage(t, ctx, idx, "test::alpha") send := lower.Send{Target: "reader", Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen"))} - if err := ctx.post(nil, Message{SignalType: "Integer"}, send, alpha); err != nil { + if err := ctx.post(nil, nil, Message{SignalType: "Integer"}, send, nil, alpha, nil); err != nil { t.Fatalf("post: %v", err) } got := ctx.PendingMessages()[0] @@ -1750,7 +1750,7 @@ func TestAddressedSendToQualifiedPortOfAnotherTypeIsTyped(t *testing.T) { part alpha : Node; }`)) send := lower.Send{Target: "Other::inPort", Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen"))} - err := ctx.post(nil, Message{SignalType: "Ping"}, send, instanceOfUsage(t, ctx, idx, "test::alpha")) + err := ctx.post(nil, nil, Message{SignalType: "Ping"}, send, nil, instanceOfUsage(t, ctx, idx, "test::alpha"), nil) if !errors.Is(err, ErrUnroutableSend) { t.Fatalf("post to Other::inPort: %v, want ErrUnroutableSend", err) } @@ -1778,7 +1778,7 @@ func TestAddressedSendThroughNamespaceQualifiedPathReachesObject(t *testing.T) { TargetPath: true, Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen")), } - if err := ctx.post(nil, Message{SignalType: "Ping"}, send, beta); err != nil { + if err := ctx.post(nil, nil, Message{SignalType: "Ping"}, send, nil, beta, nil); err != nil { t.Fatalf("post: %v", err) } got := ctx.PendingMessages()[0] @@ -1805,7 +1805,7 @@ func TestAddressedSendReportsWhyTheObjectCouldNotBeBuilt(t *testing.T) { Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen")), } ctx.maxSteps = 0 - err := ctx.post(nil, Message{SignalType: "Ping"}, send, nil) + err := ctx.post(nil, nil, Message{SignalType: "Ping"}, send, nil, nil, nil) if !errors.Is(err, ErrStepLimitExceeded) { t.Fatalf("post to alpha.inPort: %v, want ErrStepLimitExceeded", err) } @@ -1832,7 +1832,7 @@ func TestAddressedSendThroughMultiplePartIsTyped(t *testing.T) { TargetPath: true, Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen")), } - err := ctx.post(nil, Message{SignalType: "Ping"}, send, nil) + err := ctx.post(nil, nil, Message{SignalType: "Ping"}, send, nil, nil, nil) if !errors.Is(err, ErrUnroutableSend) { t.Fatalf("post to nodes.inPort: %v, want ErrUnroutableSend", err) } @@ -1861,7 +1861,7 @@ func TestAddressedSendFansOutOverAMultiValuedFeature(t *testing.T) { TargetPath: true, Scope: DeclScope(oneSymbol(t, idx, "test::Node::listen")), } - if err := ctx.post(nil, Message{SignalType: "Ping"}, send, alpha); err != nil { + if err := ctx.post(nil, nil, Message{SignalType: "Ping"}, send, nil, alpha, nil); err != nil { t.Fatalf("post to nodes.inPort: %v", err) } elements, err := ctx.fvObjects(alpha, "nodes") @@ -2000,7 +2000,7 @@ func TestAddressedSendToQualifiedElementOfATwinObject(t *testing.T) { {"alpha::reader", "", "reader"}, } { ctx.messages = nil - if err := ctx.post(nil, Message{SignalType: "Integer"}, lower.Send{Target: tc.target, Scope: scope}, beta); err != nil { + if err := ctx.post(nil, nil, Message{SignalType: "Integer"}, lower.Send{Target: tc.target, Scope: scope}, nil, beta, nil); err != nil { t.Fatalf("post to %s: %v", tc.target, err) } got := ctx.PendingMessages()[0] @@ -2194,7 +2194,11 @@ func TestSendInvocationIsACallOnlyWhereItResolvesToACalc(t *testing.T) { if !ok { t.Fatalf("%s: value is %T, want an invocation", fqn, sym.Decl.(*ast.Usage).Value) } - if got := ec.invokesCalc(sym.Scope, inv); got != want { + got, err := ec.invokesCalc(sym.Scope, inv) + if err != nil { + t.Fatalf("%s: invokesCalc: %v", fqn, err) + } + if got != want { t.Errorf("%s: invokesCalc = %v, want %v", fqn, got, want) } } @@ -2381,3 +2385,62 @@ func TestStateSendCallsFunctionImportedByNestedBlock(t *testing.T) { } assertVisits(t, visits, "start", "waiting", "done") } + +// A send addressed to a constructor expression delivers to the object the +// expression built: that new occurrence's machine accepts the ping, while the +// declared part's — accepting the same signal — stays waiting. +func TestSendToConstructedObjectReachesIt(t *testing.T) { + ctx, fleet, err := instantiateWithLibraries(t, `package test { + item def Ping; + part def Car { + exhibit state listening { + entry; then waiting; + state waiting; + accept Ping then heard; + state heard; + } + } + part def Fleet { + part car : Car; + perform action build { + first start; + then action ping { send new Ping() to new Car(); } + then done; + } + } + }`, "test::Fleet") + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + declared, held, err := ctx.fvObject(fleet, "car") + if err != nil || !held { + t.Fatalf("fleet.car: %v, %v", held, err) + } + var constructed *Instance + for _, inst := range ctx.instances { + if inst.Type == nil || inst.Type.Name != "Car" { + continue + } + b, ok := inst.Behavior("listening") + if !ok || b.State == nil { + t.Fatalf("Car #%d runs no machine listening", inst.ID) + } + if inst.ID == declared.ID { + if got := b.State.FinalStateName(); got != "waiting" { + t.Errorf("declared car's machine = %s, want waiting", got) + } + continue + } + if constructed != nil { + t.Fatalf("a second constructed Car: #%d and #%d", constructed.ID, inst.ID) + } + constructed = inst + } + if constructed == nil { + t.Fatal("no Car constructed by the send's target") + } + b, _ := constructed.Behavior("listening") + if got := b.State.FinalStateName(); got != "heard" { + t.Errorf("constructed car's machine = %s, want heard", got) + } +} diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go new file mode 100644 index 0000000000..65222c276c --- /dev/null +++ b/internal/exec/runtime/snapshot.go @@ -0,0 +1,867 @@ +package runtime + +import ( + "errors" + "maps" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// ErrSnapshotMidRun reports a snapshot asked for from inside a step: a snapshot is +// taken between steps, where no run, body or evaluation is on the stack. +var ErrSnapshotMidRun = errors.New("snapshot inside a step") + +// ErrSnapshotPausedBody reports a body paused mid-statement — a token's step a +// breakpoint or a wait on the clock suspended, or a do behavior waiting — whose +// continuation points into its model's lowered statements: a Snapshot of the +// same context captures it, a portable image (HeldImage) does not. +var ErrSnapshotPausedBody = errors.New("snapshot of a body paused mid-statement") + +// Snapshot is the run-derived state of a Context at one point between steps: a +// mark in its journal, which Restore brings the run back to as often as asked +// until Release. The Model the context runs over is not part of it, nor are the +// breakpoints set on its executors: those are the debugger's, not the run's, and +// stay as set across a restore (a HeldImage, a copy, carries them). Every object +// the run had made keeps its identity across a restore; what it made after the +// mark is abandoned, and the identities it took are handed out again. +type Snapshot struct { + ctx *Context + journal journalMark + run runCapture + executorCaptures + released bool +} + +// executorCaptures is a set of executors captured by value, each once: those +// asked for, and those the paused bodies of these perform, with the runs they drive. +type executorCaptures struct { + actions []actionCapture + states []stateCapture + runStates []runStateCapture +} + +// journalMark is where in the journal a change began and what the journal holds +// by value: the bus, the clock, the objects made and behaviors attached so far. +type journalMark struct { + writes, undos int + created, attached int + messages []Message + clockNow float64 + clockWaiters []clockWaiter + trace *TraceRecorder + traced traceCapture +} + +// runCapture is the run bookkeeping the context keeps outside its journal. +type runCapture struct { + ids *idSequence + nextID int64 + activations, runs int64 + run *runState + trace *TraceRecorder + traced traceCapture + choices []ChoiceTaken + draws []DrawTaken + evaluations *evaluationLog + pendingBehaviors []*ObjectBehavior + heldBehaviors mapState[*ObjectBehavior, bool] + holdingDriven bool + clockRun *runState +} + +// traceCapture is a recorder's state at the mark. Records are only appended to, cut +// from the front or replaced wholesale, so the slice header at the mark still reads what they were. +type traceCapture struct { + records []TraceRecord + printed int + dropped int + horizon float64 + enabled bool + depth int +} + +func captureTrace(tr *TraceRecorder) traceCapture { + if tr == nil { + return traceCapture{} + } + return traceCapture{ + records: tr.records, printed: tr.printed, dropped: tr.dropped, horizon: tr.horizon, + enabled: tr.enabled, depth: tr.depth, + } +} + +func (c traceCapture) restore(tr *TraceRecorder) { + if tr != nil { + tr.records, tr.enabled, tr.depth = c.records, c.enabled, c.depth + tr.dropped, tr.horizon = c.dropped, c.horizon + tr.printed = min(c.printed, len(c.records)) + } +} + +// runStateCapture is one run's state by value: its budget spent, its notes, its +// scheduler's position and the calc usage evaluations of its open activations, +// with the outputs each had worked out. +type runStateCapture struct { + state *runState + steps, elements int64 + notes []RunNote + scheduler *scheduler + restoreSchedule func() + calcUsageRuns map[int64]map[calcUsageKey]*calcRun + calcOutputs []mapState[string, Value] +} + +// mapState is a map's identity and what it held at the mark, restored in place +// so every holder of the map sees the restored content. +type mapState[K comparable, V any] struct { + live, saved map[K]V +} + +func captureMap[K comparable, V any](m map[K]V) mapState[K, V] { + return mapState[K, V]{live: m, saved: maps.Clone(m)} +} + +// restore puts the saved content back into the live map and returns it, nil for +// a map that was nil at the mark. +func (s mapState[K, V]) restore() map[K]V { + if s.live == nil { + return nil + } + clear(s.live) + maps.Copy(s.live, s.saved) + return s.live +} + +// Snapshot captures the run-derived state of the context between steps: its +// objects and their values, the bus, the clock, its run bookkeeping and the state +// of every behavior an object of it runs. An executor driven over the context is +// captured through its own Snapshot. It fails with ErrSnapshotMidRun from inside +// a step; a body paused mid-statement is captured where it paused. +func (ctx *Context) Snapshot() (*Snapshot, error) { + return ctx.snapshotWith(nil, nil) +} + +// Snapshot captures the executor's state along with its context's (Context.Snapshot). +func (e *ActionExecutor) Snapshot() (*Snapshot, error) { + return e.ctx.snapshotWith([]*ActionExecutor{e}, nil) +} + +// Snapshot captures the executor's state along with its context's (Context.Snapshot). +func (e *StateExecutor) Snapshot() (*Snapshot, error) { + return e.ctx.snapshotWith(nil, []*StateExecutor{e}) +} + +func (ctx *Context) snapshotWith(actions []*ActionExecutor, states []*StateExecutor) (*Snapshot, error) { + if ctx.runDepth > 0 || ctx.actionDepth > 0 || ctx.calcDepth > 0 || ctx.body != nil || ctx.probes > 0 { + return nil, ErrSnapshotMidRun + } + for _, behavior := range ctx.objectBehaviors { + switch { + case behavior.State != nil && !slices.Contains(states, behavior.State): + states = append(states, behavior.State) + case behavior.Action != nil && !slices.Contains(actions, behavior.Action): + actions = append(actions, behavior.Action) + } + } + s := &Snapshot{ctx: ctx, journal: ctx.markJournal(), run: ctx.captureRun()} + s.captureRunState(ctx.run) + s.captureRunState(ctx.clockRun.state) + for _, exec := range actions { + s.captureAction(exec) + } + for _, exec := range states { + s.captureState(exec) + } + for _, capture := range s.actions { + s.captureRunState(capture.driven) + } + for _, capture := range s.states { + s.captureRunState(capture.driven) + } + ctx.journals++ + ctx.snapshots = append(ctx.snapshots, s) + return s, nil +} + +// Restore brings the run back to the snapshot: what was written since is undone, +// what was made since is abandoned, and every executor captured stands where it +// stood. Snapshots taken since are released. The snapshot stays live to be +// restored again. It panics on a released snapshot. +func (s *Snapshot) Restore() { + if s.released { + panic("runtime: Restore of a released snapshot") + } + ctx := s.ctx + at := slices.Index(ctx.snapshots, s) + for _, later := range ctx.snapshots[at+1:] { + later.released = true + ctx.journals-- + } + ctx.snapshots = ctx.snapshots[:at+1] + ctx.rollbackJournal(s.journal) + s.run.restore(ctx) + s.executorCaptures.restore() +} + +// captureAction captures an action executor once, with the executors the work of +// its paused bodies performs. +func (s *executorCaptures) captureAction(e *ActionExecutor) { + if slices.ContainsFunc(s.actions, func(c actionCapture) bool { return c.exec == e }) { + return + } + at := len(s.actions) + s.actions = append(s.actions, e.capture()) + for _, token := range e.tokens { + if token.body != nil { + s.actions[at].bodies = append(s.actions[at].bodies, s.captureBody(token.body)) + } + } +} + +// captureState captures a state executor once, with the do behaviors it has paused. +func (s *executorCaptures) captureState(e *StateExecutor) { + if slices.ContainsFunc(s.states, func(c stateCapture) bool { return c.exec == e }) { + return + } + at := len(s.states) + s.states = append(s.states, e.capture()) + for i, act := range e.doActions { + if act.run != nil { + s.states[at].doActions[i].body = s.captureBody(act.run.body) + } + } +} + +func (s *executorCaptures) restore() { + for _, capture := range s.runStates { + capture.restore() + } + for _, capture := range s.actions { + capture.restore() + } + for _, capture := range s.states { + capture.restore() + } +} + +// bodyCapture is a paused body's run by value: its work and the frames it paused +// at, cloned again at every restore so the capture stays as it was taken. +type bodyCapture struct { + run *bodyRun + saved bodyRun + frames []bodyFrame +} + +// captureBody captures the run and, into the set, the executors its paused work +// performs: the action it holds, the flow of a case it runs, and a do behavior's own flow. +func (s *executorCaptures) captureBody(run *bodyRun) *bodyCapture { + c := &bodyCapture{run: run, saved: *run} + c.saved.work, c.saved.cursor, c.saved.resuming = run.work.clone(), nil, nil + for _, f := range run.cursor { + c.frames = append(c.frames, f.clone()) + switch f := f.(type) { + case *calleeFrame: + s.captureAction(f.exec) + case *caseStepFrame: + if f.run == nil { + s.captureAction(f.start.host.flow) + } + } + } + if held := run.paused.wait.held; held != nil { + s.captureAction(held) + } + if host, ok := run.work.(*stateStmtHost); ok { + s.captureAction(host.flow) + } + return c +} + +func (c *bodyCapture) restore() { + *c.run = c.saved + c.run.work = c.saved.work.clone() + c.run.cursor = make([]bodyFrame, len(c.frames)) + for i, f := range c.frames { + c.run.cursor[i] = f.clone() + } +} + +// Release ends the snapshot: it is no longer restorable, and the journal keeps +// nothing for it. Releasing twice does nothing. +func (s *Snapshot) Release() { + if s.released { + return + } + s.released = true + ctx := s.ctx + at := slices.Index(ctx.snapshots, s) + ctx.snapshots = slices.Delete(ctx.snapshots, at, at+1) + ctx.journals-- + if ctx.journals == 0 { + // Snapshots release in any order; the last one out empties the journal. + ctx.journalWrites, ctx.journalUndos = ctx.journalWrites[:0], ctx.journalUndos[:0] + } +} + +// markJournal marks where a change begins in the journal, with what it keeps by value. +func (ctx *Context) markJournal() journalMark { + return journalMark{ + writes: len(ctx.journalWrites), undos: len(ctx.journalUndos), + created: len(ctx.created), attached: len(ctx.objectBehaviors), + messages: slices.Clone(ctx.messages), + clockNow: ctx.clock.now, + clockWaiters: slices.Clone(ctx.clock.waiters), + trace: ctx.trace, + traced: captureTrace(ctx.trace), + } +} + +// rollbackJournal undoes every change journaled since the mark: the feature +// values written, the other changes noted, the bus, the clock, the objects made, +// the behaviors attached and the trace recorded. The journal is cut back to the mark. +func (ctx *Context) rollbackJournal(mark journalMark) { + for i := len(ctx.journalWrites) - 1; i >= mark.writes; i-- { + *ctx.journalWrites[i].fv = ctx.journalWrites[i].prior + } + ctx.journalWrites = ctx.journalWrites[:mark.writes] + for i := len(ctx.journalUndos) - 1; i >= mark.undos; i-- { + ctx.journalUndos[i]() + } + ctx.journalUndos = ctx.journalUndos[:mark.undos] + ctx.messages = slices.Clone(mark.messages) + ctx.bus.cuts++ + ctx.writes++ + ctx.workChanged() + ctx.abandonCreationSince(mark.created, mark.attached) + ctx.clock.now, ctx.clock.waiters = mark.clockNow, slices.Clone(mark.clockWaiters) + mark.traced.restore(mark.trace) +} + +func (ctx *Context) captureRun() runCapture { + c := runCapture{ + ids: ctx.ids, nextID: ctx.ids.next, + activations: ctx.activations, runs: ctx.runs, + run: ctx.run, + trace: ctx.trace, + traced: captureTrace(ctx.trace), + choices: ctx.choices, + draws: ctx.draws, + evaluations: ctx.evaluations, + pendingBehaviors: slices.Clone(ctx.pendingBehaviors), + heldBehaviors: captureMap(ctx.heldBehaviors), + holdingDriven: ctx.holdingDriven, + clockRun: ctx.clockRun.state, + } + return c +} + +// restore rewinds the run bookkeeping to the capture. Identities stay monotone +// across contexts: a sequence handed to another context since (AdoptIdentities) +// is kept, and one is never rewound past an identity another context took from it. +func (c runCapture) restore(ctx *Context) { + if ctx.ids == c.ids { + c.ids.release(ctx, c.nextID) + } + ctx.activations, ctx.runs = c.activations, c.runs + ctx.run = c.run + ctx.trace = c.trace + c.traced.restore(c.trace) + ctx.choices, ctx.draws = c.choices, c.draws + ctx.evaluations = c.evaluations + ctx.pendingBehaviors = slices.Clone(c.pendingBehaviors) + ctx.heldBehaviors = c.heldBehaviors.restore() + ctx.holdingDriven = c.holdingDriven + ctx.clockRun.state = c.clockRun + ctx.workChanged() +} + +// captureRunState captures a run's state once, however many executors share it. +func (s *executorCaptures) captureRunState(state *runState) { + if state == nil || slices.ContainsFunc(s.runStates, func(c runStateCapture) bool { return c.state == state }) { + return + } + s.runStates = append(s.runStates, runStateCapture{ + state: state, steps: state.steps, elements: state.elements, + notes: slices.Clone(state.notes), + scheduler: state.scheduler, + restoreSchedule: state.scheduler.mark(), + calcUsageRuns: cloneCalcUsageRuns(state.calcUsageRuns), + calcOutputs: captureCalcOutputs(state.calcUsageRuns), + }) +} + +func (c runStateCapture) restore() { + c.state.steps, c.state.elements = c.steps, c.elements + c.state.notes = slices.Clone(c.notes) + c.state.scheduler = c.scheduler + c.restoreSchedule() + clear(c.state.calcUsageRuns) + maps.Copy(c.state.calcUsageRuns, cloneCalcUsageRuns(c.calcUsageRuns)) + for _, outputs := range c.calcOutputs { + outputs.restore() + } +} + +func cloneCalcUsageRuns(runs map[int64]map[calcUsageKey]*calcRun) map[int64]map[calcUsageKey]*calcRun { + cloned := make(map[int64]map[calcUsageKey]*calcRun, len(runs)) + for activation, evaluations := range runs { + cloned[activation] = maps.Clone(evaluations) + } + return cloned +} + +// captureCalcOutputs captures, in place, the outputs every open evaluation has +// worked out: the evaluation stays its activation's, and an output read since is +// worked out again once restored. +func captureCalcOutputs(runs map[int64]map[calcUsageKey]*calcRun) []mapState[string, Value] { + var outputs []mapState[string, Value] + seen := make(map[*calcRun]bool) + for _, evaluations := range runs { + for _, run := range evaluations { + if !seen[run] { + seen[run] = true + outputs = append(outputs, captureMap(run.outputs)) + } + } + } + return outputs +} + +// actionCapture is an action executor's state by value: its tokens, the frames +// of its performances, and its step, sweep and breakpoint bookkeeping. +type actionCapture struct { + exec *ActionExecutor + tokens []Token + state ExecutionState + nextTokenID int64 + stepCount int + sweep, sweeps uint64 + pausedAt breakpointStop + released bool + pauses int64 + steps, stepsSpent int64 + inRun, held bool + moved bool + awaiting *actionFrame + outputListeners []outputListener + firedBreakpoints mapState[breakpointVisit, bool] + traversals []Traversal + traversalBase int + driven *runState + dynamics *stateSpaceRun + frames []frameCapture + // bodies are the paused work of the tokens, which the tokens keep by identity. + bodies []*bodyCapture +} + +// capture is the executor's state between steps; the paused work of its tokens +// is captured beside it (Snapshot.captureAction). +func (e *ActionExecutor) capture() actionCapture { + c := actionCapture{ + exec: e, tokens: slices.Clone(e.tokens), state: e.state, + nextTokenID: e.nextTokenID, stepCount: e.stepCount, sweep: e.sweep, sweeps: e.sweeps, + pausedAt: e.pausedAt, released: e.released, pauses: e.pauses, + steps: e.steps, stepsSpent: e.stepsSpent, inRun: e.inRun, held: e.held, moved: e.moved, awaiting: e.awaiting, + outputListeners: slices.Clone(e.outputListeners), + firedBreakpoints: captureMap(e.firedBreakpoints), + traversals: cloneTraversals(e.traversals), + traversalBase: e.traversalBase, + driven: e.driven.state, + dynamics: e.dynamics.clone(), + } + for _, perf := range e.reachableFrames() { + c.frames = append(c.frames, captureFrame(perf)) + } + return c +} + +func (c actionCapture) restore() { + e := c.exec + e.tokens = slices.Clone(c.tokens) + e.state, e.nextTokenID, e.stepCount, e.sweep, e.sweeps = c.state, c.nextTokenID, c.stepCount, c.sweep, c.sweeps + e.pausedAt, e.released, e.pauses = c.pausedAt, c.released, c.pauses + e.steps, e.stepsSpent, e.inRun, e.held = c.steps, c.stepsSpent, c.inRun, c.held + e.moved, e.awaiting = c.moved, c.awaiting + e.outputListeners = slices.Clone(c.outputListeners) + e.firedBreakpoints = c.firedBreakpoints.restore() + e.traversals, e.traversalBase = cloneTraversals(c.traversals), c.traversalBase + e.driven.state = c.driven + e.driven.stir(0) + e.dynamics = c.dynamics.clone() + for _, perf := range c.frames { + perf.restore() + } + for _, body := range c.bodies { + body.restore() + } +} + +// reachableFrames lists every performance the executor's run may still touch: +// the root's tree of latest performances, those its tokens run in and those +// their paused bodies hold. +func (e *ActionExecutor) reachableFrames() []*actionFrame { + seen := make(map[*actionFrame]bool) + var frames []*actionFrame + var visit func(perf *actionFrame) + visit = func(perf *actionFrame) { + for ; perf != nil && !seen[perf]; perf = perf.parent { + seen[perf] = true + frames = append(frames, perf) + for _, sub := range perf.subactions { + visit(sub) + } + } + } + visit(e.root) + for _, token := range e.tokens { + visit(token.frame) + for _, perf := range token.performed() { + visit(perf) + } + } + visit(e.awaiting) + return frames +} + +// frameCapture is one performance's state by value; the maps it holds are +// restored in place, so a frame reading one of them as its own sees the restored values. +type frameCapture struct { + perf *actionFrame + saved actionFrame + locals []mapState[string, Value] + data mapState[string, Value] +} + +func captureFrame(perf *actionFrame) frameCapture { + c := frameCapture{perf: perf, saved: *perf, data: captureMap(perf.data)} + for _, local := range perf.locals { + c.locals = append(c.locals, captureMap(local)) + } + c.saved.outer = slices.Clone(perf.outer) + c.saved.connections = slices.Clone(perf.connections) + c.saved.features = maps.Clone(perf.features) + c.saved.aliases = maps.Clone(perf.aliases) + c.saved.outputs = slices.Clone(perf.outputs) + c.saved.subactions = maps.Clone(perf.subactions) + c.saved.pending = clonePending(perf.pending) + c.saved.staged = cloneStaged(perf.staged) + c.saved.nested = cloneNested(perf.nested) + c.saved.streamed = maps.Clone(perf.streamed) + c.saved.unreceived = cloneUnreceived(perf.unreceived) + c.saved.nodes = slices.Clone(perf.nodes) + return c +} + +func (c frameCapture) restore() { + perf := c.perf + *perf = c.saved + perf.locals = nil + for _, local := range c.locals { + perf.locals = append(perf.locals, local.restore()) + } + perf.data = c.data.restore() + perf.outer = slices.Clone(c.saved.outer) + perf.connections = slices.Clone(c.saved.connections) + perf.features = maps.Clone(c.saved.features) + perf.aliases = maps.Clone(c.saved.aliases) + perf.outputs = slices.Clone(c.saved.outputs) + perf.subactions = maps.Clone(c.saved.subactions) + perf.pending = clonePending(c.saved.pending) + perf.staged = cloneStaged(c.saved.staged) + perf.nested = cloneNested(c.saved.nested) + perf.streamed = maps.Clone(c.saved.streamed) + perf.unreceived = cloneUnreceived(c.saved.unreceived) + perf.nodes = slices.Clone(c.saved.nodes) +} + +func clonePending(pending map[ast.Node]map[string][]Value) map[ast.Node]map[string][]Value { + if pending == nil { + return nil + } + cloned := make(map[ast.Node]map[string][]Value, len(pending)) + for node, pins := range pending { + clonedPins := make(map[string][]Value, len(pins)) + for pin, values := range pins { + clonedPins[pin] = slices.Clone(values) + } + cloned[node] = clonedPins + } + return cloned +} + +func cloneStaged(staged map[ast.Node]map[string][]stagedStream) map[ast.Node]map[string][]stagedStream { + if staged == nil { + return nil + } + cloned := make(map[ast.Node]map[string][]stagedStream, len(staged)) + for node, pins := range staged { + clonedPins := make(map[string][]stagedStream, len(pins)) + for pin, entries := range pins { + clonedPins[pin] = slices.Clone(entries) + } + cloned[node] = clonedPins + } + return cloned +} + +func cloneNested(nested map[ast.Node][]nestedDelivery) map[ast.Node][]nestedDelivery { + if nested == nil { + return nil + } + cloned := make(map[ast.Node][]nestedDelivery, len(nested)) + for node, deliveries := range nested { + cloned[node] = slices.Clone(deliveries) + } + return cloned +} + +// stateCapture is a state executor's state by value: its configuration, the +// values its states hold, its events, timers, change triggers and do actions. +type stateCapture struct { + exec *StateExecutor + state ExecutionState + activeConfig *StateConfiguration + nextEventID int64 + events eventHeap + stateData mapState[string, Value] + stateAttrs map[*ast.StateNode]mapState[string, Value] + stateVisits []string + stateStack []*ast.StateNode + fired []FiredTransition + firedBase int + breakpointHit *ast.StateNode + pausedAt ast.Node + completionDue bool + history map[*ast.StateNode]historyRecord + deferred []Event + lastDispatch *Dispatch + lastEventAt float64 + doActions []doActionCapture + machineExited bool + driven *runState + inRun, moved bool + timerScheduled mapState[*lower.Transition, bool] + timeTriggerVerdict mapState[*lower.Transition, error] + changeFired mapState[*lower.Transition, bool] + firingChange *lower.Transition + firingNotes []RunNote + changeRearmed mapState[*lower.Transition, bool] + changeWaits []changeWait + // held contains entry cascades paused at RTC boundaries. + held []heldEntry + // entering marks states entered during the captured entry unit. + entering map[*ast.StateNode]bool + // enteringMachine reports whether the captured entry unit includes the machine. + enteringMachine bool + // activeAtEntry records states active before the captured entry unit. + activeAtEntry map[*ast.StateNode]bool + pendingCall *pendingCall +} + +// doActionCapture is one do action's progress: the behaviors it has still to run, +// the firing that entered its state, and the one paused under way, by identity, +// with its paused work by value. +type doActionCapture struct { + act *doAction + pending []lower.StateBehavior + firing *firing + run *doRun + body *bodyCapture +} + +// capture is the executor's state between steps; the paused work of its do +// behaviors is captured beside it (Snapshot.captureState). +func (e *StateExecutor) capture() stateCapture { + c := stateCapture{ + exec: e, state: e.state, activeConfig: cloneConfiguration(e.activeConfig), + nextEventID: e.nextEventID, + stateData: captureMap(e.stateData), + stateAttrs: make(map[*ast.StateNode]mapState[string, Value], len(e.stateAttrs)), + stateVisits: slices.Clone(e.stateVisits), + stateStack: slices.Clone(e.stateStack), + fired: slices.Clone(e.fired), + firedBase: e.firedBase, + breakpointHit: e.breakpointHit, + pausedAt: e.pausedAt, + completionDue: e.completionDue, + history: make(map[*ast.StateNode]historyRecord, len(e.history)), + deferred: slices.Clone(e.deferred), + lastDispatch: cloneDispatch(e.lastDispatch), + lastEventAt: e.lastEventAt, + machineExited: e.machineExited, + driven: e.driven.state, + inRun: e.inRun, + moved: e.moved, + timerScheduled: captureMap(e.timerScheduled), + timeTriggerVerdict: captureMap(e.timeTriggerVerdict), + changeFired: captureMap(e.changeFired), + firingChange: e.firingChange, + firingNotes: slices.Clone(e.firingNotes), + changeRearmed: captureMap(e.changeRearmed), + changeWaits: slices.Clone(e.changeWaits), + held: cloneHeldEntries(e.held), + entering: maps.Clone(e.entering), + enteringMachine: e.enteringMachine, + activeAtEntry: maps.Clone(e.activeAtEntry), + pendingCall: e.pendingCall.clone(), + } + if e.eventQueue != nil { + c.events = slices.Clone(e.eventQueue.events) + } + for node, attrs := range e.stateAttrs { + c.stateAttrs[node] = captureMap(attrs) + } + for node, record := range e.history { + c.history[node] = historyRecord{child: record.child, regions: maps.Clone(record.regions)} + } + for _, act := range e.doActions { + c.doActions = append(c.doActions, doActionCapture{act: act, pending: slices.Clone(act.pending), firing: act.firing.snapshot(), run: act.run}) + } + return c +} + +func (c stateCapture) restore() { + e := c.exec + e.state, e.activeConfig, e.nextEventID = c.state, cloneConfiguration(c.activeConfig), c.nextEventID + if e.eventQueue != nil { + e.eventQueue.events = slices.Clone(c.events) + } + e.stateData = c.stateData.restore() + if e.stateAttrs != nil { + clear(e.stateAttrs) + for node, attrs := range c.stateAttrs { + e.stateAttrs[node] = attrs.restore() + } + } + e.stateVisits, e.stateStack = slices.Clone(c.stateVisits), slices.Clone(c.stateStack) + e.fired, e.firedBase = slices.Clone(c.fired), c.firedBase + e.breakpointHit, e.pausedAt, e.completionDue = c.breakpointHit, c.pausedAt, c.completionDue + if e.history != nil { + clear(e.history) + for node, record := range c.history { + e.history[node] = &historyRecord{child: record.child, regions: maps.Clone(record.regions)} + } + } + e.deferred, e.lastDispatch, e.lastEventAt = slices.Clone(c.deferred), cloneDispatch(c.lastDispatch), c.lastEventAt + e.doActions = e.doActions[:0] + for _, act := range c.doActions { + act.act.pending, act.act.firing, act.act.run = slices.Clone(act.pending), act.firing.snapshot(), act.run + e.doActions = append(e.doActions, act.act) + if act.body != nil { + act.body.restore() + } + } + e.machineExited, e.driven.state, e.inRun, e.moved = c.machineExited, c.driven, c.inRun, c.moved + e.driven.stir(0) + e.timerScheduled = c.timerScheduled.restore() + e.timeTriggerVerdict = c.timeTriggerVerdict.restore() + e.changeFired = c.changeFired.restore() + e.firingChange, e.firingNotes = c.firingChange, slices.Clone(c.firingNotes) + e.changeRearmed = c.changeRearmed.restore() + e.changeWaits = slices.Clone(c.changeWaits) + e.held = cloneHeldEntries(c.held) + clear(e.entering) + for state, entering := range c.entering { + e.entering[state] = entering + } + e.enteringMachine = c.enteringMachine + e.activeAtEntry = maps.Clone(c.activeAtEntry) + e.pendingCall = c.pendingCall.clone() +} + +func cloneHeldEntries(entries []heldEntry) []heldEntry { + if entries == nil { + return nil + } + cloned := make([]heldEntry, len(entries)) + for i, entry := range entries { + cloned[i] = heldEntry{ + owner: entry.owner, + regions: slices.Clone(entry.regions), + branches: maps.Clone(entry.branches), + chain: slices.Clone(entry.chain), + scopes: slices.Clone(entry.scopes), + machine: entry.machine, + firing: entry.firing.snapshot(), + } + } + return cloned +} + +func cloneConfiguration(config *StateConfiguration) *StateConfiguration { + if config == nil { + return nil + } + return &StateConfiguration{simpleState: config.simpleState, regionStates: maps.Clone(config.regionStates)} +} + +func cloneDispatch(dispatch *Dispatch) *Dispatch { + if dispatch == nil { + return nil + } + cloned := *dispatch + cloned.Resumed = slices.Clone(dispatch.Resumed) + return &cloned +} + +// moveMark is where a compound transition began, for a witness refused at one of its +// choices to undo the move whole; the do behaviors its exits abandon end once it is +// kept. Marks nest: a move within a move hands what it ends to the enclosing one. +type moveMark struct { + exec *StateExecutor + outer *moveMark + commit, rollback func() + run *runState + steps, elements int64 + notes []RunNote + choices, draws int + trace traceCapture + ids *idSequence + nextID int64 + state executorCaptures + ended []*doRun +} + +// markMove marks the executor and its context where a compound transition begins. +func (e *StateExecutor) markMove() *moveMark { + ctx := e.ctx + m := &moveMark{ + exec: e, outer: e.moving, run: ctx.run, steps: ctx.run.steps, elements: ctx.run.elements, + notes: slices.Clone(ctx.run.notes), choices: len(ctx.choices), draws: len(ctx.draws), + trace: captureTrace(ctx.trace), + ids: ctx.ids, nextID: ctx.ids.next, + } + m.state.captureState(e) + m.commit, m.rollback = ctx.beginJournal() + e.moving = m + return m +} + +// keep lets the move stand and ends the do behaviors its exits abandoned. +func (m *moveMark) keep() { + m.exec.moving = m.outer + m.commit() + if m.outer != nil { + m.outer.ended = append(m.outer.ended, m.ended...) + return + } + for _, run := range m.ended { + run.end(m.exec.ctx) + } +} + +// undo brings the executor and its context back to the mark. +func (m *moveMark) undo() { + e := m.exec + e.moving = m.outer + m.rollback() + if e.ctx.ids == m.ids { + m.ids.release(e.ctx, m.nextID) + } + m.run.steps, m.run.elements, m.run.notes = m.steps, m.elements, m.notes + e.ctx.choices, e.ctx.draws = e.ctx.choices[:m.choices], e.ctx.draws[:m.draws] + m.trace.restore(e.ctx.trace) + m.state.restore() +} diff --git a/internal/exec/runtime/snapshot_test.go b/internal/exec/runtime/snapshot_test.go new file mode 100644 index 0000000000..e1d225f0eb --- /dev/null +++ b/internal/exec/runtime/snapshot_test.go @@ -0,0 +1,781 @@ +package runtime + +import ( + "errors" + "fmt" + "os" + "path/filepath" + goruntime "runtime" + "sort" + "strings" + "testing" + "weak" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// steppedRun drives one conformance case the way the trace harness does, one +// step at a time, so a snapshot can be taken between any two steps. +type steppedRun struct { + ctx *Context + trace *TraceRecorder + // snapshot captures the run: through the executor it steps, or the context. + snapshot func() (*Snapshot, error) + // step advances the run by one step; false when the run has no step left + // before finish takes it to completion. + step func() (bool, error) + // taken counts the steps taken since the run began, set back by restoreTo. + taken int + // stepErr is the error the step numbered errAt returned, part of the outcome. + stepErr error + errAt int + // finish runs the case to completion, as the trace harness does. + finish func() error + // executors are the ones the run stepped, whose state the digest reports. + actions []*ActionExecutor + states []*StateExecutor +} + +// roundTripOutcome is what a run left behind: its trace, its objects' values and its error. +type roundTripOutcome struct { + trace, values, err string +} + +// advance takes one step, counting it. +func (r *steppedRun) advance() (bool, error) { + r.taken++ + more, err := r.step() + if err != nil && r.stepErr == nil { + r.stepErr, r.errAt = err, r.taken + } + return more, err +} + +// restoreTo restores the run to a snapshot taken after `taken` steps. +func (r *steppedRun) restoreTo(snapshot *Snapshot, taken int) { + snapshot.Restore() + r.taken = taken + if taken < r.errAt { + r.stepErr, r.errAt = nil, 0 + } +} + +// resume takes the steps up to the one numbered taken, as the plain run did. +func (r *steppedRun) resume(taken int) { + for r.taken < taken { + _, _ = r.advance() + } +} + +// complete runs the case to completion; the outcome's error is the first the +// steps or the completion returned. +func (r *steppedRun) complete() roundTripOutcome { + err := r.finish() + if r.stepErr != nil { + err = r.stepErr + } + out := roundTripOutcome{trace: r.trace.String(), values: r.digest()} + if err != nil { + out.err = err.Error() + } + return out +} + +// digest renders every object the run made, its lifetime, the bus, the clock +// and the stepped executors' state, so two runs can be compared beyond their traces. +func (r *steppedRun) digest() string { + var b strings.Builder + ctx := r.ctx + for _, id := range ctx.created { + inst := ctx.instances[id] + if inst == nil { + continue + } + fmt.Fprintf(&b, "#%d %s life=%+v\n", id, symbolText(inst.Type), ctx.lives[id]) + names := make([]string, 0, len(inst.FeatureValues)) + for name := range inst.FeatureValues { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + fv := inst.FeatureValues[name] + fmt.Fprintf(&b, " %s = %s written=%t materialized=%t\n", name, FormatValue(fv.HeldValue()), fv.Written, fv.Materialized) + } + for _, behavior := range inst.behaviors { + fmt.Fprintf(&b, " behavior %s\n", behavior.Name) + } + } + fmt.Fprintf(&b, "clock=%v waiters=%d\n", ctx.clock.now, len(ctx.clock.waiters)) + for _, msg := range ctx.messages { + fmt.Fprintf(&b, "message %s -> %s\n", msg.SignalType, msg.Target) + } + fmt.Fprintf(&b, "activations=%d runs=%d steps=%d\n", ctx.activations, ctx.runs, ctx.run.steps) + for _, exec := range r.actions { + fmt.Fprintf(&b, "action %s state=%v tokens=%d results=%s\n", symbolText(exec.action), exec.State(), len(exec.tokens), formatValues(exec.Results())) + for _, token := range exec.tokens { + fmt.Fprintf(&b, " token %d at %s\n", token.ID, ActionNodeName(token.Location)) + } + } + for _, exec := range r.states { + var active []string + for _, state := range exec.ActiveStates() { + active = append(active, StateVertexName(state)) + } + fmt.Fprintf(&b, "state %s state=%v active=%v stack=%d queue=%d deferred=%d data=%s\n", + symbolText(exec.stateMachine), exec.State(), active, len(exec.stateStack), exec.eventQueue.Len(), len(exec.deferred), formatValues(exec.StateData())) + } + return b.String() +} + +func formatValues(values map[string]Value) string { + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + parts := make([]string, len(names)) + for i, name := range names { + parts[i] = name + "=" + FormatValue(values[name]) + } + return "{" + strings.Join(parts, " ") + "}" +} + +// noSteps is the step of a run whose case is taken in one call. +func noSteps() (bool, error) { return false, nil } + +// newSteppedRun loads a conformance case and sets up its run under the default +// policy, following runTraceTest's driving of each case kind. +func newSteppedRun(t *testing.T, conformanceDir, testName string, expected ExpectedOutcome) *steppedRun { + t.Helper() + ctx, idx, rootScope := loadTraceCase(t, conformanceDir, testName, expected, DefaultSchedulePolicy) + trace := NewTraceRecorder() + ctx.SetTrace(trace) + run := &steppedRun{ctx: ctx, trace: trace, snapshot: ctx.Snapshot, step: noSteps} + + var actionEntry, stateEntry string + switch expected.Type { + case "calc": + calcSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefCalc, ast.UsageCalc) + args := make([]Value, len(expected.Inputs)) + for i, input := range expected.Inputs { + args[i] = expectedToRuntimeValue(t, input) + } + run.finish = func() error { _, err := ctx.InvokeCalc(calcSym, args, rootScope); return err } + case "calcUsage": + usageSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefCalc, ast.UsageCalc) + run.finish = func() error { _, err := ctx.CalcUsageOutputs(usageSym, usageSym.OwnerScope, nil); return err } + case "analysis": + caseSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefAnalysisCase, ast.UsageAnalysisCase) + run.finish = func() error { + _, err := ctx.RunAnalysis(caseSym, analysisArgsOf(t, ctx, idx, expected), rootScope, nil) + return err + } + case "verification": + caseSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefVerificationCase, ast.UsageVerificationCase) + run.finish = func() error { + _, err := ctx.RunVerification(caseSym, analysisArgsOf(t, ctx, idx, expected), rootScope, nil) + return err + } + case "constraint": + constraintSym := namedOrFoundSymbol(t, idx, expected.Evaluate, rootScope, ast.DefConstraint, ast.UsageConstraint) + run.finish = func() error { _, err := ctx.EvaluateConstraint(constraintSym, rootScope); return err } + case "requirement", "satisfy": + // The conformance harness checks these cases' verdicts itself; the round + // trip compares what the checks leave behind. + sysmlPath := filepath.Join(conformanceDir, testName+".sysml") + check := runRequirementConformance + if expected.Type == "satisfy" { + check = runSatisfyConformance + } + run.finish = func() error { check(t, ctx, idx, sysmlPath, expected); return nil } + case "instance": + // Materializing the object is the one step; the machines of its objects + // run to completion after it. + typeSym := oneSymbol(t, idx, expected.Instantiate) + var inst *Instance + run.step = func() (bool, error) { + var err error + inst, err = ctx.Instantiate(typeSym) + return false, err + } + run.finish = func() error { + if run.taken == 0 { + var err error + if inst, err = ctx.Instantiate(typeSym); err != nil { + return err + } + } + return runObjectMachines(t, ctx, inst, expected) + } + case "action": + actionEntry = expected.Evaluate + case "state": + stateEntry = expected.Evaluate + } + actionSym := entryBehavior(idx, actionEntry, rootScope, ast.DefAction, ast.UsageAction) + stateSym := entryBehavior(idx, stateEntry, rootScope, ast.DefState, ast.UsageState) + + // The trace harness runs a behavior it finds beside a case of another kind + // after the case; here that run completes the case, and the steps are the case's. + if run.finish != nil { + after := run.finish + run.finish = func() error { + if err := after(); err != nil { + return err + } + if actionSym != nil { + if _, err := ctx.ExecuteAction(actionSym); err != nil { + return err + } + } + if stateSym != nil && len(expected.Performers) == 0 { + exec, err := ctx.CreateStateExecutor(stateSym) + if err != nil { + return err + } + injectEvents(t, exec, expected.Events) + return exec.RunToCompletion() + } + return nil + } + return run + } + + // The behavior of the case's kind is stepped; one of the other kind found + // beside it runs to completion after it, as the trace harness runs both. + var after func() error + switch { + case stateSym != nil && (expected.Type == "state" || actionSym == nil): + if actionSym != nil { + after = func() error { _, err := ctx.ExecuteAction(actionSym); return err } + } + run.stepState(t, idx, stateSym, expected) + case actionSym != nil: + if stateSym != nil { + after = func() error { return runStateEntry(t, ctx, stateSym, expected) } + } + run.stepAction(actionSym) + default: + t.Fatalf("%s drives no behavior", testName) + } + if after != nil { + stepped := run.finish + run.finish = func() error { + if err := stepped(); err != nil { + return err + } + return after() + } + } + return run +} + +// stepAction drives the action executor built for sym one step at a time; +// parked on the clock or a message, RunToCompletion moves it as the trace harness does. +func (r *steppedRun) stepAction(sym *symbols.Symbol) { + ctx := r.ctx + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + // A case whose executor fails to build is that failure, taken again. + r.finish = func() error { _, err := ctx.CreateActionExecutor(sym); return err } + return + } + exec.SetTrace(r.trace) + r.actions = append(r.actions, exec) + r.snapshot = exec.Snapshot + r.step = func() (bool, error) { + err := exec.Step() + return err == nil && exec.State() == StateRunning, err + } + r.finish = exec.RunToCompletion +} + +// stepState drives the machine built for sym one dispatched event at a time. A +// machine with no event left is Running still, so the step reports no event as +// the end of the steps. +func (r *steppedRun) stepState(t *testing.T, idx *symbols.Index, sym *symbols.Symbol, expected ExpectedOutcome) { + ctx := r.ctx + if len(expected.Performers) > 0 { + r.finish = func() error { return runPerformers(t, r, idx, sym, expected.Performers) } + return + } + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + r.finish = func() error { _, err := ctx.CreateStateExecutor(sym); return err } + return + } + exec.SetTrace(r.trace) + injectEvents(t, exec, expected.Events) + r.states = append(r.states, exec) + r.snapshot = exec.Snapshot + r.step = func() (bool, error) { + err := exec.ProcessNextEvent() + return err == nil && exec.State() == StateRunning && exec.eventQueue.Len() > 0, err + } + r.finish = exec.RunToCompletion +} + +// runStateEntry runs the machine built for sym to completion under the case's events. +func runStateEntry(t *testing.T, ctx *Context, sym *symbols.Symbol, expected ExpectedOutcome) error { + exec, err := ctx.CreateStateExecutor(sym) + if err != nil { + return err + } + injectEvents(t, exec, expected.Events) + return exec.RunToCompletion() +} + +// runObjectMachines runs the machines of the objects an instance case names, as +// traceObjectRuns does, returning the first error rather than failing the test. +func runObjectMachines(t *testing.T, ctx *Context, first *Instance, expected ExpectedOutcome) error { + t.Helper() + if len(expected.Objects) == 0 { + return nil + } + objects := materializations(t, ctx, first.Type, first, expected.Objects) + for _, run := range expected.Objects { + obj := objects[instanceIndexOf(run)] + if run.Path != "" { + obj = instanceAtPath(t, ctx, obj, run.Path) + } + exec := objectMachine(t, obj, run.Behavior) + injectEvents(t, exec, run.Events) + if err := exec.RunToCompletion(); err != nil { + return err + } + } + return nil +} + +// runPerformers runs a state machine once per performing object, as tracePerformers does. +func runPerformers(t *testing.T, run *steppedRun, idx *symbols.Index, stateSym *symbols.Symbol, performers []Performer) error { + t.Helper() + for _, performer := range performers { + self, err := run.ctx.Instantiate(oneSymbol(t, idx, performer.Object)) + if err != nil { + return err + } + exec, err := run.ctx.CreateStateExecutorFor(stateSym, self) + if err != nil { + return err + } + injectEvents(t, exec, performer.Events) + if err := exec.RunToCompletion(); err != nil { + return err + } + } + return nil +} + +// maxSnapshotSteps bounds the steps a case is snapshotted at, so a machine +// stepping through a long loop does not make the round trip quadratic in it. +const maxSnapshotSteps = 400 + +// forEachConformanceCase runs f for every conformance case the trace harness drives. +func forEachConformanceCase(t *testing.T, f func(t *testing.T, conformanceDir, testName string, expected ExpectedOutcome)) { + t.Helper() + conformanceDir := filepath.Join("testdata", "conformance") + entries, err := os.ReadDir(conformanceDir) + if err != nil { + t.Fatalf("read conformance dir: %v", err) + } + knownFailures := loadKnownFailures(t, conformanceDir) + cases := 0 + for _, entry := range entries { + if entry.IsDir() || !isConformanceCase(entry.Name()) { + continue + } + testName := strings.TrimSuffix(entry.Name(), ".expected.json") + if knownFailures[testName] { + continue + } + expected := loadExpectedOutcome(t, conformanceDir, testName) + cases++ + t.Run(testName, func(t *testing.T) { f(t, conformanceDir, testName, expected) }) + } + if cases == 0 { + t.Fatalf("no conformance cases in %s", conformanceDir) + } +} + +// TestSnapshotRoundTrip snapshots every conformance case at every step of its +// default run, runs it to completion, then restores each snapshot and runs to +// completion again: the same trace, the same values, the same error every time, +// and the same as a run never snapshotted. +func TestSnapshotRoundTrip(t *testing.T) { + forEachConformanceCase(t, func(t *testing.T, conformanceDir, testName string, expected ExpectedOutcome) { + // The plain run fixes how many steps are taken before completion: every + // step there is, the one that fails included, up to the bound. + plain := newSteppedRun(t, conformanceDir, testName, expected) + for more := true; more && plain.taken < maxSnapshotSteps; { + var err error + if more, err = plain.advance(); err != nil { + break + } + } + reference := plain.complete() + + run := newSteppedRun(t, conformanceDir, testName, expected) + snapshots := make(map[int]*Snapshot) + for i := 0; ; i++ { + snapshot, err := run.snapshot() + if err != nil { + t.Fatalf("snapshot at step %d: %v", i, err) + } + snapshots[i] = snapshot + if i == plain.taken { + break + } + run.resume(i + 1) + } + snapshotted := run.complete() + if snapshotted != reference { + t.Fatalf("taking snapshots changed the run\n%s", outcomeDiff(reference, snapshotted)) + } + for i := plain.taken; i >= 0; i-- { + snapshot := snapshots[i] + if snapshot == nil { + continue + } + run.restoreTo(snapshot, i) + run.resume(plain.taken) + if restored := run.complete(); restored != reference { + t.Fatalf("restored to snapshot at step %d of %d\n%s", i, plain.taken, outcomeDiff(reference, restored)) + } + } + for _, snapshot := range snapshots { + snapshot.Release() + } + if run.ctx.journals != 0 || len(run.ctx.journalWrites)+len(run.ctx.journalUndos) != 0 { + t.Fatalf("journal left open after every snapshot was released: %d journals, %d writes, %d undos", + run.ctx.journals, len(run.ctx.journalWrites), len(run.ctx.journalUndos)) + } + }) +} + +// TestSnapshotRestoresTwice restores one snapshot of every conformance case +// twice, running to completion from each: a snapshot is not consumed by a restore. +func TestSnapshotRestoresTwice(t *testing.T) { + forEachConformanceCase(t, func(t *testing.T, conformanceDir, testName string, expected ExpectedOutcome) { + run := newSteppedRun(t, conformanceDir, testName, expected) + // Snapshot before the first step, then every other step after it, so the + // snapshot kept is the latest one the run has state to restore to. + snapshot, err := run.snapshot() + if err != nil { + t.Fatalf("snapshot: %v", err) + } + at := 0 + for more := true; more && run.taken < maxSnapshotSteps; { + if more, err = run.advance(); err != nil { + break + } + if run.taken%2 != 0 { + continue + } + later, err := run.snapshot() + if err != nil { + t.Fatalf("snapshot at step %d: %v", run.taken, err) + } + snapshot.Release() + snapshot, at = later, run.taken + } + taken := run.taken + first := run.complete() + run.restoreTo(snapshot, at) + run.resume(taken) + second := run.complete() + run.restoreTo(snapshot, at) + run.resume(taken) + third := run.complete() + snapshot.Release() + if second != first { + t.Fatalf("first restore\n%s", outcomeDiff(first, second)) + } + if third != first { + t.Fatalf("second restore\n%s", outcomeDiff(first, third)) + } + }) +} + +// A usage instantiated again after the snapshot denotes the new object; restoring +// makes it denote the object it denoted at the snapshot, not none and not a third. +func TestSnapshotRestoresAnOverwrittenOccurrence(t *testing.T) { + ctx, idx := contextForSource(t, `package Demo { + part def Car { attribute wheels = 4; } + part car : Car; +}`) + car := lookupOne(t, idx, "Demo::car") + first, err := ctx.Instantiate(car) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + snapshot, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + second, err := ctx.Instantiate(car) + if err != nil { + t.Fatalf("Instantiate again: %v", err) + } + if second.ID == first.ID { + t.Fatalf("second Instantiate answered the first object #%d", first.ID) + } + for round := 1; round <= 2; round++ { + snapshot.Restore() + denoted, err := ctx.occurrenceOf(car) + if err != nil { + t.Fatalf("restore %d: occurrenceOf: %v", round, err) + } + if denoted != first { + t.Fatalf("restore %d: Demo::car denotes #%d, want the snapshotted #%d", round, denoted.ID, first.ID) + } + if _, live := ctx.instances[second.ID]; live { + t.Fatalf("restore %d: the object made after the snapshot, #%d, is still held", round, second.ID) + } + } + snapshot.Release() +} + +const sharedIdentitiesSrc = `package Demo { + part def Car; + part car : Car; +}` + +// instantiateCar materializes Demo::car in ctx and answers its identity. +func instantiateCar(t *testing.T, ctx *Context, idx *symbols.Index) int64 { + t.Helper() + inst, err := ctx.Instantiate(lookupOne(t, idx, "Demo::car")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + return inst.ID +} + +// Restoring a snapshot never hands out again an identity a context sharing the +// sequence took since: the other context keeps its object, so this one takes the next. +func TestSnapshotRestoreKeepsIdentitiesASharingContextTook(t *testing.T) { + prev, prevIdx := contextForSource(t, sharedIdentitiesSrc) + ctx, idx := contextForSource(t, sharedIdentitiesSrc) + ctx.AdoptIdentities(prev) + snapshot, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + taken := instantiateCar(t, prev, prevIdx) + snapshot.Restore() + if got := instantiateCar(t, ctx, idx); got <= taken { + t.Fatalf("after restore the context handed out #%d, which the sharing context took #%d at or past", got, taken) + } + snapshot.Release() +} + +// A context that takes over another's identity sequence after the snapshot keeps +// the shared sequence on restore rather than the one it had, so the two never +// name one identity for two objects. +func TestSnapshotRestoreKeepsAnAdoptedIdentitySequence(t *testing.T) { + prev, prevIdx := contextForSource(t, sharedIdentitiesSrc) + ctx, idx := contextForSource(t, sharedIdentitiesSrc) + own := instantiateCar(t, ctx, idx) + snapshot, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + made := instantiateCar(t, ctx, idx) + taken := instantiateCar(t, prev, prevIdx) + ctx.AdoptIdentities(prev) + snapshot.Restore() + if ctx.ids != prev.ids { + t.Fatalf("restore reinstalled the sequence the context had before it adopted the other's") + } + if _, live := ctx.instances[made]; live { + t.Fatalf("restore kept #%d, made after the snapshot", made) + } + got := instantiateCar(t, ctx, idx) + if got <= taken || got <= made { + t.Fatalf("after restore the context handed out #%d; it holds #%d, the other context #%d, and #%d was handed out since", got, own, taken, made) + } + if _, live := prev.instances[got]; live { + t.Fatalf("#%d names an object in both contexts", got) + } + snapshot.Release() +} + +// The sequence keeps no context it was shared with: a replaced context is +// collected once dropped, and what it took is still never handed out again. +func TestAdoptIdentitiesKeepsNoReplacedContext(t *testing.T) { + ctx, idx := contextForSource(t, sharedIdentitiesSrc) + replaced, taken, snapshot := adoptThenDrop(t, ctx) + goruntime.GC() + goruntime.GC() + if replaced.Value() != nil { + t.Fatalf("the replaced context is still held after it was dropped") + } + snapshot.Restore() + if got := instantiateCar(t, ctx, idx); got <= taken { + t.Fatalf("after restore the context handed out #%d, which the dropped context took #%d at or past", got, taken) + } + snapshot.Release() +} + +// adoptThenDrop has ctx adopt the identities of a fresh context, snapshots ctx, +// has the fresh context take an identity and drops it, keeping only a weak pointer. +func adoptThenDrop(t *testing.T, ctx *Context) (weak.Pointer[Context], int64, *Snapshot) { + t.Helper() + prev, prevIdx := contextForSource(t, sharedIdentitiesSrc) + ctx.AdoptIdentities(prev) + snapshot, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + taken := instantiateCar(t, prev, prevIdx) + return weak.Make(prev), taken, snapshot +} + +// Restoring a snapshot forgets the outputs an open calc usage evaluation worked +// out since: the evaluation stays its activation's, holding what it had at the mark. +func TestSnapshotRestoreForgetsCalcOutputsWorkedOutSince(t *testing.T) { + ctx, idx := libraryModelContext(t, `package Demo { + private import ScalarValues::*; + calc def Pair { in x : Integer; out a : Integer = x + 1; out b : Integer = x + 2; } + calc pair : Pair { in x = 1; } + }`) + pair := lookupOne(t, idx, "Demo::pair") + end := ctx.beginRun() + reader := NewEvalContextIn(ctx, pair.OwnerScope, nil) + reader.activation = ctx.newActivation() + run, err := ctx.calcUsageRun(reader, pair) + if err != nil { + t.Fatalf("calcUsageRun: %v", err) + } + if _, err := run.output(ctx, "a"); err != nil { + t.Fatalf("output a: %v", err) + } + end() + snapshot, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if _, err := run.output(ctx, "b"); err != nil { + t.Fatalf("output b: %v", err) + } + snapshot.Restore() + if ctx.run.calcUsageRuns[reader.activation][calcUsageKey{sym: pair}] != run { + t.Fatalf("restore replaced the activation's evaluation of Demo::pair") + } + if _, held := run.outputs["b"]; held { + t.Fatalf("restore kept output b, worked out after the snapshot") + } + if _, held := run.outputs["a"]; !held { + t.Fatalf("restore dropped output a, worked out before the snapshot") + } + snapshot.Release() +} + +// Restoring a snapshot brings the trace back to the mark: entries cleared since +// return, recording turned off since is on again, and the next entry is recorded +// at the nesting the statement open at the mark holds. +func TestSnapshotRestoreBringsTheTraceBackToTheMark(t *testing.T) { + ctx, _ := contextForSource(t, sharedIdentitiesSrc) + tr := NewTraceRecorder() + ctx.SetTrace(tr) + tr.RecordStatement("first") + snapshot, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + tr.RecordStatement("second") + tr.Clear() + tr.Disable() + snapshot.Restore() + tr.RecordStatement("third") + want := "stmt first\n" + traceIndent + "stmt third" + if got := tr.String(); got != want { + t.Fatalf("trace after restore:\n%s\nwant:\n%s", got, want) + } + snapshot.Release() +} + +// The round trips above snapshot every step of these cases, so they must reach a +// queued composite completion and a deferral holding back a sibling's transition. +func TestSnapshotStepsReachAPendingCompositeCompletionAndAHeldDeferral(t *testing.T) { + conformanceDir := filepath.Join("testdata", "conformance") + reaches := func(t *testing.T, testName string, at func(*StateExecutor) bool) { + t.Helper() + run := newSteppedRun(t, conformanceDir, testName, loadExpectedOutcome(t, conformanceDir, testName)) + for more := true; more && run.taken < maxSnapshotSteps; { + for _, exec := range run.states { + if at(exec) { + return + } + } + var err error + if more, err = run.advance(); err != nil { + t.Fatalf("step %d: %v", run.taken, err) + } + } + t.Fatalf("no step boundary of %s shows the state the round trips must carry", testName) + } + + t.Run("composite_completion_pending", func(t *testing.T) { + reaches(t, "state_composite_completion_then_machine_done", func(exec *StateExecutor) bool { + for _, event := range exec.eventQueue.events { + trans, ok := event.Payload.(*lower.Transition) + if ok && trans.Trigger == nil && StateVertexName(trans.Source) == "s1" && exec.stateComplete(trans.Source.(*ast.StateNode)) { + return true + } + } + return false + }) + }) + t.Run("deferral_held_over_a_sibling_transition", func(t *testing.T) { + reaches(t, "state_deferral_outranks_sibling_region", func(exec *StateExecutor) bool { + if len(exec.deferred) != 1 { + return false + } + if msg, ok := exec.deferred[0].Payload.(Message); !ok || msg.SignalType != "Ping" { + return false + } + candidates, err := exec.selectCandidates(func(source *ast.StateNode) ([]int, []RunNote, error) { + return exec.enabledTransitions(source, &exec.deferred[0]) + }) + return err == nil && len(candidates) == 1 && StateVertexName(candidates[0].source) == "idle" + }) + }) +} + +func TestSnapshotRefusesMidRun(t *testing.T) { + resolver := resolve.New(symbols.NewIndex()) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10) + ctx.runDepth++ + if _, err := ctx.Snapshot(); !errors.Is(err, ErrSnapshotMidRun) { + t.Fatalf("snapshot inside a run: got %v, want ErrSnapshotMidRun", err) + } +} + +func outcomeDiff(want, got roundTripOutcome) string { + var b strings.Builder + if want.err != got.err { + fmt.Fprintf(&b, "error: want %q, got %q\n", want.err, got.err) + } + if want.trace != got.trace { + fmt.Fprintf(&b, "trace differs at line %d\n=== WANT ===\n%s\n=== GOT ===\n%s\n", firstDifferingLine(want.trace, got.trace), want.trace, got.trace) + } + if want.values != got.values { + fmt.Fprintf(&b, "values differ at line %d\n=== WANT ===\n%s\n=== GOT ===\n%s\n", firstDifferingLine(want.values, got.values), want.values, got.values) + } + return b.String() +} + +func firstDifferingLine(a, b string) int { + as, bs := strings.Split(a, "\n"), strings.Split(b, "\n") + for i := range min(len(as), len(bs)) { + if as[i] != bs[i] { + return i + 1 + } + } + return min(len(as), len(bs)) + 1 +} diff --git a/internal/exec/runtime/start_behavior.go b/internal/exec/runtime/start_behavior.go new file mode 100644 index 0000000000..d6db7a6c4f --- /dev/null +++ b/internal/exec/runtime/start_behavior.go @@ -0,0 +1,136 @@ +package runtime + +import ( + "fmt" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// startEffect runs a `perform obj.beh.start`: the object named (self for a bare +// `beh.start`) gets its own execution of beh, run to quiescence, and the statement is done. +func (ctx *Context) startEffect(ec *EvalContext, s lower.Effect, self *Instance) error { + inst, member, err := ctx.startTarget(ec, s, self) + if err != nil { + return err + } + return ctx.startBehaviorOn(inst, member) +} + +// startTarget is the object a start's chain names and the member of its type the +// behavior is bound by, resolved where the statement was written. +func (ctx *Context) startTarget(ec *EvalContext, s lower.Effect, self *Instance) (*Instance, *symbols.Symbol, error) { + inst := self + if chain, ok := s.Target.(*ast.FeatureChainExpr); ok { + value, err := ec.Eval(chain.Operand) + if err != nil { + return nil, nil, fmt.Errorf("eval the object of %s: %w", exprText(s.TargetExpr), err) + } + if value.Kind != ValInstance { + return nil, nil, fmt.Errorf("%w: %s is started on %s, which is no one object", + ErrPerformerNotObject, exprText(s.Target), FormatValue(value)) + } + if inst, ok = ctx.Instance(value.Instance); !ok { + return nil, nil, fmt.Errorf("%w: %s is started on object #%d, which no longer exists", + ErrPerformerNotObject, exprText(s.Target), value.Instance) + } + } + if inst == nil { + return nil, nil, fmt.Errorf("%w: no object to start %s on", ErrNoSuchBehavior, exprText(s.Target)) + } + member, ok := ctx.resolveReferenceTarget(s.Scope, s.Node, s.Target) + if !ok || member == nil { + return nil, nil, fmt.Errorf("unresolved behavior reference: %s", exprText(s.Target)) + } + return inst, member, nil +} + +// startBehaviorOn attaches the object's own execution of the behavior member binds and +// runs it to quiescence; an object already running it is left as it is. A failed start is undone whole. +func (ctx *Context) startBehaviorOn(inst *Instance, member *symbols.Symbol) error { + decl, typ, ok := ctx.startableBehaviorOf(inst, member) + if !ok { + return fmt.Errorf("%w: %s is no behavior of object #%d (%s)", + ErrNoSuchBehavior, symbolText(member), inst.ID, symbolText(inst.Type)) + } + if err := ctx.checkPerformer(inst); err != nil { + return fmt.Errorf("start %s %s: %w", decl.behavior.Kind, decl.behavior.Name, err) + } + if ctx.declarative || ctx.runsBound(inst, decl.member, typ) { + return nil + } + defer ctx.beginRun()() + defer ctx.holdDrivenWork()() + commit, rollback := ctx.beginJournal() + if ctx.trace != nil { + ctx.trace.RecordBehaviorStart(decl.behavior.Kind.String(), decl.behavior.Name, inst.ID) + } + ctx.behaviorRunDepth++ + behavior, err := ctx.attachClassifierBehavior(inst, decl) + ctx.behaviorRunDepth-- + if err != nil { + rollback() + return err + } + behavior.binding = ctx.bindingIndex(typ, decl.member) + // Older behaviors the start wakes run once it is kept: what they do is no part of it. + endBoundary := ctx.beginRunBoundary() + inst.behaviors = append(inst.behaviors, behavior) + ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) + ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + ctx.workChanged() + err = ctx.runAttachedBehaviors() + endBoundary() + if err != nil { + rollback() + return err + } + commit() + return ctx.runAttachedBehaviors() +} + +// startableBehaviorOf is the behavior member binds on the object and the type of it +// declaring or redefining it: one exhibited or performed, or a usage declared for a start. +func (ctx *Context) startableBehaviorOf(inst *Instance, member *symbols.Symbol) (classifierBehaviorDecl, *symbols.Symbol, bool) { + for _, typ := range inst.types() { + for _, decl := range ctx.classifierBehaviorsOf(typ) { + if ctx.sameFeature(decl.member, member, typ) { + return decl, typ, true + } + } + for _, m := range ctx.model.semantics.MembersOf(typ) { + if m.Decl == nil || !ctx.sameFeature(m, member, typ) { + continue + } + if behavior, ok := lower.StartableBehaviorOf(m.Decl); ok { + return classifierBehaviorDecl{behavior: behavior, member: m}, typ, true + } + } + } + return classifierBehaviorDecl{}, nil, false +} + +// sameFeature reports whether m, a member of typ, is member itself or redefines it. +func (ctx *Context) sameFeature(m, member, typ *symbols.Symbol) bool { + return m == member || slices.Contains(ctx.redefinedFeatures(m, typ), member) +} + +// bindingIndex is member's position among the behaviors typ binds to every object of +// it, or -1 for one the type only declares for its objects to start. +func (ctx *Context) bindingIndex(typ, member *symbols.Symbol) int { + for i, decl := range ctx.classifierBehaviorsOf(typ) { + if decl.member == member { + return i + } + } + return -1 +} + +// startableDeclaration is the behavior member binds on the object, whether its type runs +// it on every object or declares it for a start to run. +func (ctx *Context) startableDeclaration(inst *Instance, member *symbols.Symbol) (classifierBehaviorDecl, bool) { + decl, _, ok := ctx.startableBehaviorOf(inst, member) + return decl, ok +} diff --git a/internal/core/runtime/state_behavior_test.go b/internal/exec/runtime/state_behavior_test.go similarity index 98% rename from internal/core/runtime/state_behavior_test.go rename to internal/exec/runtime/state_behavior_test.go index 480fd8e8ef..b5e26ba36b 100644 --- a/internal/core/runtime/state_behavior_test.go +++ b/internal/exec/runtime/state_behavior_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) func intValue(t *testing.T, data map[string]Value, name string) int64 { diff --git a/internal/exec/runtime/state_change_trigger.go b/internal/exec/runtime/state_change_trigger.go new file mode 100644 index 0000000000..bfbfd9fb6d --- /dev/null +++ b/internal/exec/runtime/state_change_trigger.go @@ -0,0 +1,379 @@ +package runtime + +import ( + "fmt" + "maps" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// changeWait is one change condition the active configuration is waiting on: the +// state watching it, the trigger as written, and why it did not fire. +type changeWait struct { + state string + trigger string + reason string +} + +// String renders one wait for a report of a machine that cannot progress. +func (w changeWait) String() string { + return fmt.Sprintf("%s: %s (%s)", w.state, w.trigger, w.reason) +} + +// changePoll is one poll of the change conditions the active configuration +// watches: a transition's condition and guard are evaluated once per poll, +// however many active leaves the state watching it encloses. +type changePoll struct { + condition map[*lower.Transition]bool + guard map[*lower.Transition]bool + blocked map[*lower.Transition]bool + unevaluable map[*lower.Transition]UnevaluableGuard + observed []*lower.Transition // in the order the poll read them + waits []changeWait + waited map[*lower.Transition]bool +} + +// pollChangeEvents re-tests the ChangeEvent conditions the active configuration +// watches and takes the transitions they enable, reporting whether the rise was +// dispatched: taken, or consumed by transitions it enabled without firing any, as +// a signal nothing takes is. It walks outward from each active leaf, and selects +// and resolves conflicts exactly as an event dispatch does. +// +// A trigger fires on the condition rising: one that stays true does not take the +// same edge again, and only a condition observed false re-arms it. +func (e *StateExecutor) pollChangeEvents() (bool, error) { + poll := newChangePoll() + e.changeRearmed = make(map[*lower.Transition]bool) + defer func() { e.changeRearmed = nil }() + + if err := e.observeChangeConditions(poll); err != nil { + return false, err + } + if len(poll.condition) == 0 { + e.changeWaits = nil + return false, nil + } + + occurrence := &Event{Type: EventChange, Payload: poll} + selected, err := e.selectCandidates(func(state *ast.StateNode) ([]int, []RunNote, error) { + return e.risenChangeTransitions(state, poll, occurrence) + }) + if err != nil { + e.changeWaits = poll.waits + return false, err + } + + candidates, err := e.chooseTransitions(selected, nil) + if err != nil { + e.changeWaits = poll.waits + return false, err + } + e.markDispatch() + armed := func(candidate dispatchCandidate) (bool, error) { return e.passesGuard(candidate.chosen) } + fired, err := e.dispatchInOrder(firingWherePrefix+"change", candidates, armed, func(candidate dispatchCandidate, trans *lower.Transition, notes []RunNote) (bool, error) { + // An earlier candidate's effect may have blocked this guard since the poll + // read it, and the fire path re-tests it: a transition that would not move + // the machine must stay armed rather than latch as fired. + pass, err := e.passesGuard(trans) + if err != nil { + return false, fmt.Errorf("eval change guard: %w", err) + } + if !pass { + poll.blocked[trans] = true + poll.wait(trans, candidate.source.Name, "guard is false") + return false, nil + } + // The edge is latched before it is taken: an effect that leaves the + // condition true must not enable the same edge again, and the exit this + // firing causes must not re-arm the edge that caused it. A join an earlier + // candidate disarmed fires nothing, which is no move. + e.changeFired[trans] = true + e.firingChange = trans + fired, err := e.firingOn(occurrence, func() (bool, error) { + return e.fireFrom(candidate.source, trans, notes, candidate.route) + }) + e.firingChange = nil + if err != nil { + return true, fmt.Errorf("fire transition out of %s: %w", candidate.source.Name, err) + } + return fired, nil + }) + e.moved = e.moved || fired + if err != nil { + return fired, err + } + if fired { + // The configuration changed, so a state left by this step no longer holds + // back the events it deferred. + e.recallDeferredEvents() + } + consumed := e.consumeRise(poll) + // A firing that ended the machine leaves nothing waiting on a condition. + if e.state.Ended() { + poll.waits = nil + } + e.changeWaits = poll.waits + dispatched := fired || consumed + e.moved = e.moved || dispatched + e.pauseAtBreakpoint() + return dispatched, nil +} + +func newChangePoll() *changePoll { + return &changePoll{ + condition: make(map[*lower.Transition]bool), + guard: make(map[*lower.Transition]bool), + blocked: make(map[*lower.Transition]bool), + unevaluable: make(map[*lower.Transition]UnevaluableGuard), + waited: make(map[*lower.Transition]bool), + } +} + +// risenChanges polls the change conditions under a probe, keeping the latches, +// and reports every transition whose rise would now dispatch, or fail (nil). +func (e *StateExecutor) risenChanges() ([]*lower.Transition, bool) { + defer e.ctx.beginProbe()() + fired := maps.Clone(e.changeFired) + defer func() { e.changeFired = fired }() + poll := newChangePoll() + e.changeRearmed = make(map[*lower.Transition]bool) + defer func() { e.changeRearmed = nil }() + if err := e.observeChangeConditions(poll); err != nil { + return nil, true + } + var risen []*lower.Transition + for _, trans := range poll.observed { + if e.riseEnables(poll, trans) { + risen = append(risen, trans) + } + } + return risen, len(risen) > 0 +} + +// risenChange polls the change conditions under a probe, keeping the latches, +// and reports the first transition whose rise would now dispatch, or fail (nil). +func (e *StateExecutor) risenChange() (*lower.Transition, bool) { + risen, ok := e.risenChanges() + if !ok { + return nil, false + } + if len(risen) == 0 { + return nil, true + } + return risen[0], true +} + +// riseEnables reports whether the poll's rise is an occurrence for trans, one a +// poll consumes: its condition observed risen with its guard passing and not +// blocked since, and its watch not re-armed by a state entry during the poll, +// which belongs to an activation later than the observation. +func (e *StateExecutor) riseEnables(poll *changePoll, trans *lower.Transition) bool { + return poll.condition[trans] && poll.guard[trans] && !poll.blocked[trans] && !e.changeRearmed[trans] +} + +// consumeRise latches every transition the rise enabled, not only the ones taken, +// reporting whether any was: one rise is one occurrence, so a transition that lost +// conflict resolution, or into a join the rise did not fire whole, waits for the +// next rise instead of firing on the next poll. +func (e *StateExecutor) consumeRise(poll *changePoll) bool { + consumed := false + for trans := range poll.condition { + if e.riseEnables(poll, trans) { + e.changeFired[trans] = true + consumed = true + } + } + return consumed +} + +// observeChangeConditions evaluates, once each, the change conditions of the +// transitions out of the active configuration. A false one re-arms its +// transition and is recorded as a wait. +func (e *StateExecutor) observeChangeConditions(poll *changePoll) error { + for _, leaf := range e.activeLeaves() { + for _, source := range e.getParentChain(leaf) { + transitions := e.graph.Transitions[source] + decided := false + for i, trans := range transitions { + changeEvent, ok := trans.Trigger.(*ast.ChangeEvent) + if !ok { + continue + } + if _, seen := poll.condition[trans]; seen { + continue + } + holds, err := e.changeConditionHolds(changeEvent, trans) + if err != nil { + return fmt.Errorf("state %s: %w", source.Name, err) + } + poll.condition[trans] = holds + poll.observed = append(poll.observed, trans) + if !holds { + delete(e.changeFired, trans) + poll.wait(trans, source.Name, "condition is false") + continue + } + if e.changeFired[trans] { + poll.wait(trans, source.Name, "condition has not changed since it fired") + continue + } + // The guard is read once per poll, alongside the condition, so which + // transitions this rise enables does not depend on selection order. + if decided { + poll.guard[trans] = e.probeChangeGuard(poll, source, transitions, i) + } else if poll.guard[trans], err = e.passesGuard(trans); err != nil { + return fmt.Errorf("state %s: eval change guard: %w", source.Name, err) + } + if poll.guard[trans] { + decided = true + } else if _, unevaluable := poll.unevaluable[trans]; !unevaluable { + poll.blocked[trans] = true + poll.wait(trans, source.Name, "guard is false") + } + } + } + } + return nil +} + +// probeChangeGuard reads the guard of the transition at position i out of state +// once an earlier one is enabled, as a probe the context undoes whole. One that +// cannot be evaluated is not enabled, consumes nothing and is noted on the poll. +func (e *StateExecutor) probeChangeGuard(poll *changePoll, state *ast.StateNode, transitions []*lower.Transition, i int) bool { + var pass bool + var err error + e.preview(func() { pass, err = e.passesGuard(transitions[i]) }) + if err != nil { + poll.unevaluable[transitions[i]] = e.unevaluableTransition(state, transitions, i, fmt.Errorf("eval change guard: %w", err)) + poll.wait(transitions[i], state.Name, "guard is not evaluable") + return false + } + return pass +} + +// changeConditionHolds evaluates one change condition in the scope the +// transition was written in, the machine's data shadowing it. +func (e *StateExecutor) changeConditionHolds(changeEvent *ast.ChangeEvent, trans *lower.Transition) (bool, error) { + condVal, err := e.evalStepOf(trans.Source, changeEvent.Condition, trans.Scope) + if err != nil { + return false, fmt.Errorf("eval change condition: %w", err) + } + if condVal.Kind != ValConst || condVal.Const.Kind != semantics.ValBool { + return false, fmt.Errorf("change condition must be boolean, got %v", condVal.Kind) + } + return condVal.Const.Bool, nil +} + +// risenChangeTransitions returns the positions of the state's change-triggered +// transitions whose condition has risen and whose guard does not block them, +// several enabled at once being a choice point. A blocked one stays armed for the +// next poll. One into a join the occurrence does not fire whole is not enabled, +// as a signal-triggered one is not, so nothing past the join is resolved. +func (e *StateExecutor) risenChangeTransitions(state *ast.StateNode, poll *changePoll, occurrence *Event) ([]int, []RunNote, error) { + var enabled []int + var notes []RunNote + transitions := e.graph.Transitions[state] + for i, trans := range transitions { + if _, ok := trans.Trigger.(*ast.ChangeEvent); !ok { + continue + } + if poll.condition[trans] && !e.changeFired[trans] && poll.guard[trans] { + ready, err := e.joinSynchronized(trans, occurrence) + if err != nil { + return nil, nil, err + } + if ready { + enabled = append(enabled, i) + } + } + if unevaluable, ok := poll.unevaluable[trans]; ok { + notes = append(notes, unevaluable) + } + } + if len(enabled) == 0 { + return nil, nil, nil + } + return enabled, notes, nil +} + +// wait records, once per transition, a change condition the configuration is +// waiting on. +func (p *changePoll) wait(trans *lower.Transition, state, reason string) { + if p.waited[trans] { + return + } + p.waited[trans] = true + p.waits = append(p.waits, changeWait{state: state, trigger: triggerDescription(trans.Trigger), reason: reason}) +} + +// PollChangeEvents re-tests the change conditions the active configuration +// watches and takes the transitions they enable, reporting whether a rise was +// dispatched: the step RunToCompletion takes, for a driver that steps the machine. +func (e *StateExecutor) PollChangeEvents() (dispatched bool, err error) { + defer e.ctx.beginExecutorRun(&e.driven)() + defer e.completedWhole(&err) + + if len(e.held) > 0 { + return false, nil + } + return e.pollChangeEvents() +} + +// ChangeWaits describes the change conditions the active configuration was +// waiting on as of the last poll, and nothing when it watches none. +func (e *StateExecutor) ChangeWaits() []string { + waits := make([]string, 0, len(e.changeWaits)) + for _, wait := range e.changeWaits { + waits = append(waits, wait.String()) + } + return waits +} + +// WatchesChangeCondition reports whether the active configuration watches a +// change condition, which data written outside the machine can make true. +func (e *StateExecutor) WatchesChangeCondition() bool { + for _, leaf := range e.activeLeaves() { + for _, source := range e.getParentChain(leaf) { + for _, trans := range e.graph.Transitions[source] { + if _, ok := trans.Trigger.(*ast.ChangeEvent); ok { + return true + } + } + } + } + return false +} + +// canStillProgress reports whether a step is left to take. A suspended machine's +// do activities are registered but exhausted, so what is left there is a queued +// event, a signal in flight or a do action still to run. +func (e *StateExecutor) canStillProgress() bool { + if e.state != StateSuspended { + return e.HasPendingWork() + } + return e.completionDue || e.eventQueue.Len() > 0 || e.hasPendingSignal() || e.HasPendingDoWork() +} + +// SuspendReason says why a machine that cannot progress cannot: the change +// conditions it waits on, or that nothing is left that could fire. +func (e *StateExecutor) SuspendReason() string { + if e.state.Ended() || e.canStillProgress() { + return "" + } + reason := "quiesced: nothing left can fire (no queued event, signal in flight, running do behavior or watched change condition)" + if waits := e.ChangeWaits(); len(waits) > 0 { + reason = "waiting on change condition: " + strings.Join(waits, "; ") + } + if due, waiting := e.NextWait(); waiting { + reason = fmt.Sprintf("waiting on the clock: the next timer is due at t=%s (advance the clock to reach it)", semantics.FormatReal(due)) + } + // An event the active states still defer cannot be dispatched here, but it is + // not gone either, so a stalled machine reports it rather than losing it. + if held := len(e.deferred); held > 0 { + reason += fmt.Sprintf("; %d event(s) still deferred by the active states", held) + } + return reason +} diff --git a/internal/core/runtime/state_change_trigger_test.go b/internal/exec/runtime/state_change_trigger_test.go similarity index 75% rename from internal/core/runtime/state_change_trigger_test.go rename to internal/exec/runtime/state_change_trigger_test.go index f18d2062da..43a6dc8995 100644 --- a/internal/core/runtime/state_change_trigger_test.go +++ b/internal/exec/runtime/state_change_trigger_test.go @@ -1,6 +1,7 @@ package runtime import ( + "context" "strings" "testing" ) @@ -583,6 +584,168 @@ func TestChangeTriggerRecallsDeferredEvents(t *testing.T) { } } +// A rise enabling one segment into a join whose other segment's condition is +// still false is an occurrence the join does not take: the poll fires nothing yet +// consumes the rise, as a dispatched event nothing takes is consumed, so a run +// stepped move by move sees the dispatch it was offered rather than a refusal. +func TestChangeTriggerConsumesARiseIntoAnUnsynchronizedJoin(t *testing.T) { + source := `package test { + private import ScalarValues::*; + state Machine { + attribute log : String = ""; + attribute level : Integer = 0; + entry; then start; + state start; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept when level > 1 do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept when level > 2 do assign log := log + "b " then sync; + } + } + join sync; + transition first start do assign level := 2 then Work; + transition first sync do assign log := log + "sync" then done; + } + }` + exec := stateExecutorForSource(t, "Machine", source) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if got := activeStateNames(exec); got != "a1|b1" { + t.Fatalf("configuration = %s, want a1|b1: the join waits for b's condition", got) + } + if !exec.changeFired[exec.graph.Transitions[stateNamed(t, exec, "a1")][0]] { + t.Error("a's rise is not latched: the next poll would offer the same occurrence again") + } + if reason := exec.SuspendReason(); !strings.Contains(reason, "a1: accept when (condition has not changed since it fired)") { + t.Errorf("reason = %q, want a's rise reported spent", reason) + } + + // b's condition rising later is a new occurrence, which a's segment does not + // take: the join never fires. + exec.stateData["level"] = integerValue(3) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("raise b's condition: %v", err) + } + if got := activeStateNames(exec); got != "a1|b1" { + t.Errorf("configuration = %s, want a1|b1: b's later rise fired the join", got) + } + if got := exec.stateData["log"]; got.Str() != "" { + t.Errorf("log = %q, want no segment's effect run", got.Str()) + } + + // Under the check policy the run is stepped move by move: the rise a poll + // consumes is a dispatch the machine makes, not one it refuses. + m := parseExploreModel(t, source) + report, err := Check(context.Background(), m.fresh, stateStarterOf(m.state(t, "Machine"), Horizon{}), CheckBudget{}, unreduced(), nil) + if err != nil { + t.Fatalf("check: %v", err) + } + if report.Verdict != CheckExhaustive { + t.Fatalf("check: %s, want exhaustive", report.Status()) + } + // Work's regions are entered in either order; each order reaches the same halt. + if len(report.Finals) != 2 { + t.Fatalf("finals %+v, want one per entry order at a1+b1 with nothing logged", report.Finals) + } + for _, final := range report.Finals { + if final.Values["finalState"] != "a1+b1" || final.Values["log"] != `""` { + t.Fatalf("final %+v, want a1+b1 with nothing logged", final) + } + } +} + +// A change-triggered segment into a join drawn after another region's firing +// disarmed the join fires nothing, and neither reports a move nor draws a choice +// the run made; its rise is consumed all the same, one rise being one occurrence. +func TestChangeTriggerJoinDisarmedBeforeItsTurnFiresNothing(t *testing.T) { + ctx, machine := loadState(t, `package test { + private import ScalarValues::*; + state Machine { + attribute armed : Boolean = true; + attribute level : Integer = 0; + entry; then start; + state start; + state running parallel { + state c { + entry; then c1; + state c1; + state c2; + transition first c1 accept when level > 1 do assign armed := false then c2; + } + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept when level > 1 then sync; + transition first a1 accept when level > 1 then a2; + } + state b { + entry; then b1; + state b1; + transition first b1 accept when level > 1 if armed then sync; + } + } + join sync; + transition first start do assign level := 2 then running; + transition first sync then done; + } +}`, "Machine") + // `declared` fires the regions in declaration order, c before a, and takes + // a1's first transition, the segment into the join. + policy, err := ParseSchedulePolicy("declared") + if err != nil { + t.Fatal(err) + } + if err := ctx.SetSchedule(policy); err != nil { + t.Fatal(err) + } + exec, err := ctx.CreateStateExecutor(machine) + if err != nil { + t.Fatalf("CreateStateExecutor: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if got := activeStateNames(exec); got != "c2|a1|b1" { + t.Fatalf("configuration = %s, want c2|a1|b1: c's effect disarmed b's segment, so a's fires nothing", got) + } + for _, c := range ctx.Choices() { + if c.Kind == ChoiceTransition { + t.Errorf("choices include %s: a's segment fired nothing, so its draw is no choice the run made", c) + } + } + + // The rise a's segment was drawn on is spent; b's, blocked by its guard, is + // not, so re-arming the guard offers b a join whose other segment's rise is gone. + exec.stateData["armed"] = boolValue(true) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("re-arm: %v", err) + } + if got := activeStateNames(exec); got != "c2|a1|b1" { + t.Errorf("configuration = %s, want c2|a1|b1: a spent rise fired the join", got) + } + + // The condition falling and rising again is one occurrence for both segments. + exec.stateData["level"] = integerValue(0) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("lower: %v", err) + } + exec.stateData["level"] = integerValue(2) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("raise again: %v", err) + } + if exec.State() != StateCompleted { + t.Errorf("machine %v at %s after the new rise, want completed through the join", exec.State(), activeStateNames(exec)) + } +} + // equalStrings compares two ordered lists of names. func equalStrings(got, want []string) bool { if len(got) != len(want) { diff --git a/internal/exec/runtime/state_completion_test.go b/internal/exec/runtime/state_completion_test.go new file mode 100644 index 0000000000..7b63bbf1af --- /dev/null +++ b/internal/exec/runtime/state_completion_test.go @@ -0,0 +1,374 @@ +package runtime + +import ( + "fmt" + "slices" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// A transition reaching `done` completes the machine: the state it leaves runs +// its exit action and the executor comes to rest completed. +func TestTransitionToDoneCompletesTheMachineAndRunsExitActions(t *testing.T) { + exec := stateExecutorForSource(t, "sm", `package test { + private import ScalarValues::*; + state sm { + attribute left : Integer = 0; + + entry; then start; + state start; + state working { + exit action { assign left := 1; } + } + succession first start then working; + transition first working accept stop then done; + } + }`) + + exec.SendSignal("stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() != StateCompleted { + t.Fatalf("expected StateCompleted, got %s", exec.State()) + } + if got := exec.stateData["left"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("the exit action of the completing state did not run, left = %v", got) + } +} + +// A machine whose states have no transition to `done` stays active, even where +// the state it rests in has no outgoing transition at all: a sink is not a +// completion, because an ancestor or cross-region transition may still leave it. +func TestMachineWithoutATransitionToDoneStaysActive(t *testing.T) { + exec := stateExecutorForSource(t, "sm", `package test { + state sm { + entry; then start; + state start; + state resting; + succession first start then resting; + } + }`) + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + assertCurrentState(t, exec, "resting") + if exec.State() == StateCompleted { + t.Errorf("the machine completed without reaching `done`") + } +} + +// An orthogonal machine completes only once every top-level region has reached +// `done`; one region completing leaves the machine running. +func TestOrthogonalMachineCompletesOnlyWhenEveryRegionDoes(t *testing.T) { + const src = `package test { + state sm parallel { + state left { + entry; then lstart; + state lstart; + transition first lstart accept first then done; + } + state right { + entry; then rstart; + state rstart; + transition first rstart accept second then done; + } + } + }` + + exec := stateExecutorForSource(t, "sm", src) + exec.SendSignal("first", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() == StateCompleted { + t.Fatalf("the machine completed with one region still running") + } + + exec.SendSignal("second", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() != StateCompleted { + t.Errorf("expected StateCompleted once both regions reached `done`, got %s", exec.State()) + } +} + +// A composite state completes once every one of its regions reached `done`, +// and its completion transition then fires: one region reaching `done` leaves +// the composite running, both reaching it takes the composite's completion +// transition, here into the machine's own `done`. +func TestNestedOrthogonalRegionsCompleteOnlyWhenEveryRegionDoes(t *testing.T) { + exec := stateExecutorForSource(t, "sm", `package test { + state sm { + entry; then busy; + state busy parallel { + state left { + entry; then lstart; + state lstart; + transition first lstart accept first then done; + } + state right { + entry; then rstart; + state rstart; + transition first rstart accept second then done; + } + } + transition first busy then done; + } + }`) + + exec.SendSignal("first", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() == StateCompleted { + t.Fatalf("the machine completed with one nested region still running") + } + + exec.SendSignal("second", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() != StateCompleted { + t.Errorf("expected StateCompleted once busy's completion transition fired, got %s", exec.State()) + } +} + +// A composite state whose regions all reached `done` but which has no +// completion transition stays completed and active: nothing propagates its +// completion to the machine, which keeps running and still reacts through it. +func TestCompletedCompositeWithoutCompletionTransitionStaysActive(t *testing.T) { + exec := stateExecutorForSource(t, "sm", `package test { + state sm { + entry; then busy; + state busy parallel { + state left { + entry; then lstart; + state lstart; + transition first lstart accept first then done; + } + state right { + entry; then rstart; + state rstart; + transition first rstart accept second then done; + } + } + state idle; + transition first busy accept reset then idle; + } + }`) + + exec.SendSignal("first", nil) + exec.SendSignal("second", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() == StateCompleted { + t.Fatalf("the machine completed although busy has no completion transition") + } + if got := exec.FinalStateName(); got != "done+done" { + t.Fatalf("active configuration = %q, want busy completed at done+done", got) + } + + exec.SendSignal("reset", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + assertCurrentState(t, exec, "idle") +} + +// A composite state running a do behavior completes only once both the +// behavior has finished and its body has reached `done`, in whichever order +// the two happen. +func TestCompositeCompletesOnceItsDoBehaviorAndItsBodyHaveBothEnded(t *testing.T) { + const src = `package test { + private import ScalarValues::*; + attribute def Finish; + state sm { + attribute worked : Integer = 0; + entry; then s1; + state s1 { + do action work { + first start; + then action wait accept Finish; + then action count assign worked := worked + 1; + then done; + } + entry; then wait; + state wait; + transition first wait accept go then done; + } + state s2; + transition first s1 then s2; + } + }` + + t.Run("body_done_before_the_do_behavior_ends", func(t *testing.T) { + exec := stateExecutorForSource(t, "sm", src) + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if got := exec.FinalStateName(); got != "done" { + t.Fatalf("configuration = %q after go, want s1 at done with its do behavior running", got) + } + exec.SendSignal("Finish", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + assertCurrentState(t, exec, "s2") + if got := exec.stateData["worked"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Errorf("the do behavior did not run to its end before s1 completed, worked = %v", got) + } + }) + + t.Run("do_behavior_ends_before_the_body_is_done", func(t *testing.T) { + exec := stateExecutorForSource(t, "sm", src) + exec.SendSignal("Finish", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if got := exec.FinalStateName(); got != "wait" { + t.Fatalf("configuration = %q after finish, want s1 still at wait", got) + } + exec.SendSignal("go", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + assertCurrentState(t, exec, "s2") + }) +} + +// A completed composite state queues its completion exactly as a leaf does: one +// event carrying its first completion transition, stamped with the clock instant +// of the completion; the guards are read when it is dispatched. +func TestCompositeCompletionQueuesItsTransitionsLikeALeaf(t *testing.T) { + const machine = `package test { + private import ScalarValues::*; + private import SI::*; + state sm { + attribute flag : Boolean = true; + %s + state a; + state b; + state c; + transition first s1 if flag then a; + transition first s1 if not flag then c; + transition first s1 then b; + } + }` + const composite = `entry; then s1; + state s1 { + entry; then wait; + state wait; + transition first wait accept after 3 [s] then done; + }` + const leaf = `entry; then s0; + state s0; + state s1; + transition first s0 accept after 3 [s] then s1;` + + start := func(t *testing.T, body string) *StateExecutor { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, fmt.Sprintf(machine, body))) + sym := findSymbolByName(idx.DocumentRoot(""), "sm", ast.DefState) + exec, err := newStateExecutor(ctx, sym, nil) + if err != nil { + t.Fatalf("newStateExecutor: %v", err) + } + if err := exec.initialize(); err != nil { + t.Fatalf("initialize: %v", err) + } + return exec + } + queued := func(t *testing.T, body string) []Event { + t.Helper() + exec := start(t, body) + if err := exec.processNextEvent(); err != nil { + t.Fatalf("dispatch the timed transition: %v", err) + } + var events []Event + for exec.eventQueue.Len() > 0 { + events = append(events, exec.eventQueue.Pop()) + } + return events + } + describe := func(events []Event) []string { + var out []string + for _, event := range events { + trans, ok := event.Payload.(*lower.Transition) + if !ok || trans.Trigger != nil { + out = append(out, "not a completion event") + continue + } + out = append(out, fmt.Sprintf("%s->%s@%g", StateVertexName(trans.Source), StateVertexName(trans.Target), event.Timestamp)) + } + return out + } + + want := []string{"s1->a@3"} + got := describe(queued(t, composite)) + if !slices.Equal(got, want) { + t.Fatalf("composite completion queued %v, want %v", got, want) + } + if fromLeaf := describe(queued(t, leaf)); !slices.Equal(fromLeaf, want) { + t.Fatalf("leaf completion queued %v, want %v", fromLeaf, want) + } + + exec := start(t, composite) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + assertCurrentState(t, exec, "a") +} + +// Completion is reached through a pseudostate as through any other path: the +// junction routes into `done` and the machine completes. +func TestCompletionReachedThroughAPseudostate(t *testing.T) { + exec := stateExecutorForSource(t, "sm", `package test { + state sm { + entry; then start; + state start; + state working; + junction meet; + succession first start then working; + transition first working accept stop then meet; + transition first meet then done; + } + }`) + + exec.SendSignal("stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + if exec.State() != StateCompleted { + t.Errorf("expected StateCompleted through the junction, got %s", exec.State()) + } +} + +// A state the machine declares itself as `done` is an ordinary state and wins: +// completion is the library feature the name reaches when nothing nearer +// declares it, so a machine naming its own state `done` keeps running. +func TestADeclaredDoneStateIsAnOrdinaryState(t *testing.T) { + exec := stateExecutorForSource(t, "sm", `package test { + state sm { + entry; then start; + state start; + state done; + transition first start accept stop then done; + } + }`) + + exec.SendSignal("stop", nil) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run: %v", err) + } + assertCurrentState(t, exec, "done") + if exec.State() == StateCompleted { + t.Errorf("the declared state completed the machine") + } +} diff --git a/internal/core/runtime/state_composite_transition_test.go b/internal/exec/runtime/state_composite_transition_test.go similarity index 99% rename from internal/core/runtime/state_composite_transition_test.go rename to internal/exec/runtime/state_composite_transition_test.go index da2c7ed0bf..abf5ba39d8 100644 --- a/internal/core/runtime/state_composite_transition_test.go +++ b/internal/exec/runtime/state_composite_transition_test.go @@ -3,7 +3,7 @@ package runtime import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // A transition out of a composite state is enabled while any of its substates is diff --git a/internal/core/runtime/state_deferred_test.go b/internal/exec/runtime/state_deferred_test.go similarity index 99% rename from internal/core/runtime/state_deferred_test.go rename to internal/exec/runtime/state_deferred_test.go index 69415fe11b..2a33f0d605 100644 --- a/internal/core/runtime/state_deferred_test.go +++ b/internal/exec/runtime/state_deferred_test.go @@ -3,7 +3,7 @@ package runtime import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // acceptTrigger builds the trigger of a transition or deferral that reacts to a diff --git a/internal/core/runtime/state_do_action_test.go b/internal/exec/runtime/state_do_action_test.go similarity index 98% rename from internal/core/runtime/state_do_action_test.go rename to internal/exec/runtime/state_do_action_test.go index 3e1f98298c..3a59f11071 100644 --- a/internal/core/runtime/state_do_action_test.go +++ b/internal/exec/runtime/state_do_action_test.go @@ -3,8 +3,8 @@ package runtime import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // zeroTicks seeds the counter the do actions below add to. diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go new file mode 100644 index 0000000000..9c4d10f21d --- /dev/null +++ b/internal/exec/runtime/state_executor.go @@ -0,0 +1,5505 @@ +package runtime + +import ( + "cmp" + "fmt" + "maps" + "math" + "slices" + "sort" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// StateConfiguration represents the active state configuration (simple or multi-region). +type StateConfiguration struct { + // For simple states (no regions): single active state + simpleState *ast.StateNode + + // For composite states with regions: map of region → active state in that region + regionStates map[*ast.StateRegion]*ast.StateNode +} + +// StateExecutor executes state machines using event-driven semantics. +type StateExecutor struct { + ctx *Context + stateMachine *symbols.Symbol + // self is the object performing the machine: its connections route what the + // machine sends, and its selections decide which variant's connection does. + self *Instance + // occurrence is the state performance materialized for an exhibited usage. + occurrence *Instance + state ExecutionState + + // Lowered graph (source of truth) + graph *lower.StateGraph + // acceptsAlong memoizes, per leaf state, the accept-triggered transitions out of it + // and its enclosing states, in the order a message is tried against them. + acceptsAlong map[*ast.StateNode][]*lower.Transition + // pending memoizes the machine's last poll of the bus for a message it takes. + pending pendingMemo + + // State machine execution state + activeConfig *StateConfiguration // Active state configuration (simple or multi-region) + nextEventID int64 // Monotonic counter for unique event IDs + // eventQueue holds this machine's events by instant of the context's clock; a + // timer set for later is this machine's wait on the clock. + eventQueue *EventQueue + stateData map[string]Value // State machine local variables + // stateAttrs holds the attributes each state owns, one map per state node, so + // two usages of one state definition keep separate values. + stateAttrs map[*ast.StateNode]map[string]Value + stateVisits []string // Ordered list of visited state names + stateStack []*ast.StateNode // Active state configuration (for nested states) + + // history records, per composite state, the configuration that state had when + // it was last exited. A history pseudostate re-enters that configuration + // instead of the composite state's initial one. + history map[*ast.StateNode]*historyRecord + + // deferred holds, in arrival order, the events an active state defers and no + // transition of the active configuration handled. + deferred []Event + // pendingCall is the synchronous Call the machine is running, if any. + pendingCall *pendingCall + // callTriggers memoizes the declared operations each call trigger names. + callTriggers map[*ast.CallEvent][]*symbols.Symbol + // lastDispatch is what became of the event the last step took off the queue, + // lastEventAt the instant it was dispatched at. + lastDispatch *Dispatch + lastEventAt float64 + + // fired are the transitions taken and still kept, in firing order, firedBase + // those released before them; a dispatch keeps its own unless keepFired is set. + fired []FiredTransition + firedBase int + keepFired bool + + // breakpointNodes are the vertices a run pauses on entering or passing through; + // breakpointHit the first state the dispatch under way entered, pausedAt the + // vertex the run paused at. + breakpointNodes map[ast.Node]bool + breakpointHit *ast.StateNode + pausedAt ast.Node + // dispatchMark is where the dispatch under way began in fired, -1 between + // dispatches; completionDue holds a completion reached under a breakpoint. + dispatchMark int + completionDue bool + + // doActions are the running do behaviors, in the order their states were + // entered. Concurrently active states interleave one action per round, so this + // order — not map iteration order — decides the interleaving. + doActions []*doAction + // dispatchAmong narrows the events nextEvent draws among to those a step order + // drew ahead of a due do step: the tied events whose dispatch acts. + dispatchAmong []Event + // machineExited prevents a parallel machine's root exit behavior from + // running more than once if completion is reported by multiple regions. + machineExited bool + + // driven is this executor's run over however many calls drive it: begun once, + // so the step budget is reset once, and keeping its scheduler throughout. + driven executorRun + // inRun is set while a run loop of this machine is on the stack. + inRun bool + // moved is set once an event was queued or dispatched, a transition fired, a do + // step ran or a value written, and cleared when the start attaching it settles. + moved bool + + // timerScheduled holds the time-triggered transitions whose timer is already + // running, so a state's timer is not restarted while it stays active. + timerScheduled map[*lower.Transition]bool + // timeTriggerVerdict caches, per time-triggered transition, whether its + // argument's static type was accepted (nil) or the error refusing it. + timeTriggerVerdict map[*lower.Transition]error + + // changeFired holds the change-triggered transitions already taken on a + // condition that has stayed true, so an unchanged one does not re-fire. + changeFired map[*lower.Transition]bool + + // firingChange is the change-triggered transition being taken, whose latch the + // state entries it causes must leave alone. + firingChange *lower.Transition + + // firingNotes is what selecting the transition being taken noted, recorded + // once its guard's final reading lets it fire (see transitionDecided). + firingNotes []RunNote + // firingEvent is the occurrence the transition being taken reacts to, for the + // other segments into a join it fires to bind their own trigger's arguments. + firingEvent *Event + // firingTrans is the transition being taken, within whose performance the + // exits, effect and entries it causes run and read what its trigger bound. It + // spans a compound transition: the segments past a pseudostate accept nothing. + firingTrans *lower.Transition + + // leftAhead are the states a compound transition under way left before its + // choice was resolved; exitingAhead is set while it leaves them. Both live + // within one firing, inside a step, so no snapshot sees them. + leftAhead map[*ast.StateNode]bool + exitingAhead bool + // enteredAhead are the states a move activated ahead of entering them, so a + // segment's effect could follow the entry of the state enclosing it. + enteredAhead map[*ast.StateNode]bool + // moving marks a compound transition under way that a refused witness may undo. + moving *moveMark + // front is the site under way whose regions' units are drawn one at a time; + // it lives within one move, so no snapshot sees it. + front *unitFront + // began are the do behaviors the move under way started, whose due steps its entry sites + // draw against the entries left; path is the draw along an entry path no front orders. + began []*doAction + path pathDraw + // progress is what the unit under way counts its do steps against, nil between units. + progress *dueProgress + // held contains entry cascades paused at RTC boundaries. + held []heldEntry + // entering marks states entered during the current entry unit. + entering map[*ast.StateNode]bool + // enteringMachine marks an entry cascade that includes the machine. + enteringMachine bool + // activeAtEntry records states active before the current entry unit. + activeAtEntry map[*ast.StateNode]bool + + // changeRearmed collects, while a poll runs, the watches a state entry armed + // for a new activation, so the poll's earlier observation does not latch them. + changeRearmed map[*lower.Transition]bool + + // changeWaits are the change conditions the last poll found could not fire, + // telling a machine waiting on one from a quiesced machine. + changeWaits []changeWait +} + +// doAction is the part of a state's do behavior that has still to run. The +// behavior runs while its state is active rather than at entry, and is abandoned +// when the state is exited. +type doAction struct { + state *ast.StateNode + pending []lower.StateBehavior + // firing is the transition that entered the state, whose payload the behavior + // reads for its whole run (`StatePerformance::incomingTransitionTrigger`). + firing *firing + // run is the behavior under way, paused between two statements or where its + // flow waits on the clock or for a message; nil between behaviors. + run *doRun +} + +// due reports work of the do behavior runnable now: a paused behavior with its +// next statement due, or the next behavior where none is under way. +func (act *doAction) due(ctx *Context) bool { + if act.run != nil { + return act.run.resumable(ctx) + } + return len(act.pending) > 0 +} + +// finished reports a do behavior with nothing left to run. +func (act *doAction) finished() bool { + return act.run == nil && len(act.pending) == 0 +} + +// historyRecord is the configuration one composite state was last left in. +type historyRecord struct { + // child is the substate that was active, whether it was declared directly or + // in one of the state's orthogonal regions. + child *ast.StateNode + // regions is the active state of each orthogonal region, empty for a state + // that has none. + regions map[*ast.StateRegion]*ast.StateNode +} + +// newStateExecutor creates a state executor. self is the object performing the +// machine, nil for a machine no object performs. +func newStateExecutor(ctx *Context, stateMachine *symbols.Symbol, self *Instance) (*StateExecutor, error) { + return newStateExecutorForOccurrence(ctx, stateMachine, self, nil) +} + +func newStateExecutorForOccurrence( + ctx *Context, + stateMachine *symbols.Symbol, + self *Instance, + occurrence *Instance, +) (*StateExecutor, error) { + if stateMachine.Kind != symbols.SymbolStateUsage && stateMachine.Kind != symbols.SymbolStateDef { + return nil, fmt.Errorf("symbol %s is not a state machine", stateMachine.Name) + } + if err := ctx.checkPerformer(self); err != nil { + return nil, err + } + + // Lower to StateGraph, in the scope the machine's body was written in, so + // that everything the graph carries is evaluated where it was declared. + // Endpoints come from the name-resolution tier, which reported on them already. + graph, err := lower.ToStateGraphWithEndpoints(stateMachine.Decl, DeclScope(stateMachine), lower.NewLibraryStateTypes(ctx.model.resolver)) + if err != nil { + return nil, fmt.Errorf("lower state machine: %w", err) + } + exec := newStateExecutorOn(ctx, stateMachine, self, occurrence, graph) + + // Initialize state machine attributes + if err := exec.initializeAttributes(); err != nil { + return nil, err + } + if err := exec.initializeStateAttributes(); err != nil { + return nil, err + } + ctx.clock.attach(exec) + + return exec, nil +} + +// newStateExecutorOn is an execution of graph, the lowering of stateMachine, holding +// no attribute values yet and not on ctx's clock. +func newStateExecutorOn( + ctx *Context, + stateMachine *symbols.Symbol, + self, occurrence *Instance, + graph *lower.StateGraph, +) *StateExecutor { + exec := &StateExecutor{ + ctx: ctx, + stateMachine: stateMachine, + self: self, + occurrence: occurrence, + state: StateReady, + graph: graph, + nextEventID: 1, + eventQueue: NewEventQueue(), + stateData: make(map[string]Value), + stateAttrs: make(map[*ast.StateNode]map[string]Value), + stateVisits: make([]string, 0), + stateStack: make([]*ast.StateNode, 0), + history: make(map[*ast.StateNode]*historyRecord), + deferred: make([]Event, 0), + timerScheduled: make(map[*lower.Transition]bool), + timeTriggerVerdict: make(map[*lower.Transition]error), + changeFired: make(map[*lower.Transition]bool), + breakpointNodes: make(map[ast.Node]bool), + dispatchMark: -1, + activeConfig: &StateConfiguration{ + regionStates: make(map[*ast.StateRegion]*ast.StateNode), + }, + entering: make(map[*ast.StateNode]bool), + } + exec.driven.exec = exec + return exec +} + +// initializeAttributes populates stateData from the exhibited occurrence, or +// from declared defaults when the machine has no occurrence. +func (e *StateExecutor) initializeAttributes() error { + if e.occurrence != nil { + for _, attr := range e.graph.Attributes { + fv, err := e.occurrence.GetFeatureValue(e.ctx, attr.Name) + if err != nil { + return fmt.Errorf("%w: read %s of object #%d: %w", + ErrStatePerformanceOccurrence, attr.Name, e.occurrence.ID, err) + } + if value := fv.HeldValue(); value.Kind != ValInvalid { + e.stateData[attr.Name] = value + } + } + return nil + } + + ec := NewEvalContextIn(e.ctx, e.graph.Scope, e.self) + defer ec.beginStep()() + for _, attr := range e.graph.Attributes { + if attr.Value == nil { + continue + } + value, err := ec.Eval(attr.Value) + if err != nil { + return fmt.Errorf("eval attribute default %s: %w", attr.Name, err) + } + e.stateData[attr.Name] = value + } + + return nil +} + +// initializeStateAttributes gives every state that owns attributes its own +// values, so two usages of one state definition never share them. +func (e *StateExecutor) initializeStateAttributes() error { + for state, attrs := range e.graph.StateAttributes { + if len(attrs) == 0 { + continue + } + data := make(map[string]Value, len(attrs)) + e.stateAttrs[state] = data + for _, attr := range attrs { + if attr.Value == nil { + continue + } + scope := attr.Scope + if scope == nil { + scope = e.graph.Scope + } + ec := NewEvalContextIn(e.ctx, scope, e.self) + end := ec.beginStep() + value, err := ec.Eval(attr.Value) + end() + if err != nil { + return fmt.Errorf("eval attribute default %s of state %s: %w", attr.Name, state.Name, err) + } + data[attr.Name] = value + } + } + return nil +} + +// attrFramesFor are the attribute values a behavior of state reads, outermost +// state first so an inner state's attribute shadows an enclosing one's. +func (e *StateExecutor) attrFramesFor(state *ast.StateNode) []map[string]Value { + if state == nil || len(e.stateAttrs) == 0 { + return nil + } + var frames []map[string]Value + chain := e.getParentChain(state) + for i := len(chain) - 1; i >= 0; i-- { + if data := e.stateAttrs[chain[i]]; data != nil { + frames = append(frames, data) + } + } + return frames +} + +// stateAttributeValues is the value map of the innermost state at or enclosing +// state that owns an attribute of this name, with the scope that attribute is +// declared in, so a write to it answers to its declaration. +func (e *StateExecutor) stateAttributeValues(state *ast.StateNode, name string) (map[string]Value, *symbols.Scope, bool) { + if state == nil { + return nil, nil, false + } + for _, ancestor := range e.getParentChain(state) { + data, ok := e.stateAttrs[ancestor] + if !ok { + continue + } + for _, attr := range e.graph.StateAttributes[ancestor] { + if attr.Name != name { + continue + } + scope := attr.Scope + if scope == nil { + scope = e.graph.Scope + } + return data, scope, true + } + } + return nil, nil, false +} + +func (e *StateExecutor) declaresAttribute(name string) bool { + for _, attr := range e.graph.Attributes { + if attr.Name == name { + return true + } + } + return false +} + +func (e *StateExecutor) assignAttribute(name string, value Value) error { + if e.occurrence != nil { + if err := e.occurrence.SetFeatureValue(e.ctx, name, value); err != nil { + return fmt.Errorf("%w: write %s of object #%d: %w", + ErrStatePerformanceOccurrence, name, e.occurrence.ID, err) + } + fv, err := e.occurrence.GetFeatureValue(e.ctx, name) + if err != nil { + return fmt.Errorf("%w: read %s of object #%d after write: %w", + ErrStatePerformanceOccurrence, name, e.occurrence.ID, err) + } + value = fv.HeldValue() + } else if err := e.ctx.checkNamedWrite(e.graph.Scope, "state machine "+symbolText(e.stateMachine), name, &value); err != nil { + // No occurrence holds this feature, so its declaration is checked here + // rather than by the write to that occurrence. + return err + } + e.stateData[name] = value + return nil +} + +// evalStepOf evaluates one expression of a step read ahead of any transition +// performance — a change condition, a duration, an entry guard, a run-to-completion +// value — in scope, in an activation of its own (see beginStep), with the +// machine's data and the attributes of the state the step leaves shadowing it. +func (e *StateExecutor) evalStepOf(owner ast.Node, node ast.Node, scope *symbols.Scope) (Value, error) { + return e.evalStepWithin(owner, nil, node, scope) +} + +// evalTransitionStep evaluates a transition's guard or weight within the firing +// it belongs to (see stepFiring), so `T.d` reads what the accepting segment bound. +func (e *StateExecutor) evalTransitionStep(trans *lower.Transition, node ast.Node, scope *symbols.Scope) (Value, error) { + return e.evalStepWithin(trans.Source, e.stepFiring(trans), node, scope) +} + +// stepFiring is the firing a step of trans is read within: trans's own, with the +// arguments its trigger bound, when it leaves a state; the compound transition +// under way when it is a segment past a pseudostate, which accepts nothing itself. +func (e *StateExecutor) stepFiring(trans *lower.Transition) *firing { + if _, segment := trans.Source.(*ast.PseudostateNode); segment { + return e.currentFiring() + } + return e.firingOf(trans) +} + +// evalStepWithin evaluates a step's expression over the machine's data read +// within f, nil for a step no transition performance carries. +func (e *StateExecutor) evalStepWithin(owner ast.Node, f *firing, node ast.Node, scope *symbols.Scope) (Value, error) { + ec := NewEvalContextIn(e.ctx, scope, e.self) + ec.inBehaviorBody = true + ec.pushFrame(frame{vars: e.stateData, firing: f}) + if state, ok := owner.(*ast.StateNode); ok { + for _, frame := range e.attrFramesFor(state) { + ec.Push(frame) + } + } + defer ec.beginStep()() + return ec.Eval(node) +} + +// StateVertexName returns the name of a StateNode or PseudostateNode, "" for +// any other node. +func StateVertexName(node ast.Node) string { + switch n := node.(type) { + case *ast.StateNode: + return n.Name + case *ast.PseudostateNode: + return n.Name + case *ast.Usage: + if lower.IsTerminateUsage(n) { + name, _ := ast.EffectiveName(n) + return name + } + return "" + default: + return "" + } +} + +// getParentChain returns all ancestor states from child to root (inclusive). +// Result is ordered: [child, parent, grandparent, ...] +func (e *StateExecutor) getParentChain(state *ast.StateNode) []*ast.StateNode { + chain := []*ast.StateNode{state} + current := state + for { + parent, hasParent := e.graph.ParentState[current] + if !hasParent { + break + } + chain = append(chain, parent) + current = parent + } + return chain +} + +// getLCA finds the lowest common ancestor of two states. +// Returns nil if states are in different hierarchies. +func (e *StateExecutor) getLCA(state1, state2 *ast.StateNode) *ast.StateNode { + chain1 := e.getParentChain(state1) + chain2 := e.getParentChain(state2) + + // Build set from chain1 + chain1Set := make(map[*ast.StateNode]bool) + for _, s := range chain1 { + chain1Set[s] = true + } + + // Find first common ancestor in chain2 + for _, s := range chain2 { + if chain1Set[s] { + return s + } + } + + return nil // No common ancestor +} + +// scheduleTransitionEvents schedules the time triggers of the active leaves and of the states +// enclosing them, in region declaration order; completions are queued at entry (enterStateInto). +func (e *StateExecutor) scheduleTransitionEvents() error { + for _, leaf := range e.activeLeaves() { + if err := e.scheduleFromLeaf(leaf); err != nil { + return err + } + } + return nil +} + +// scheduleFromLeaf schedules the time transitions of an active leaf and of the +// composite states enclosing it, innermost first. +func (e *StateExecutor) scheduleFromLeaf(leaf *ast.StateNode) error { + for _, state := range e.getParentChain(leaf) { + if err := e.scheduleTimeTransitions(state); err != nil { + return err + } + } + return nil +} + +// scheduleCompletionTransitions queues a state's completion as one event, carrying +// its first completion transition; the guards are read when the occurrence is +// dispatched (chooseCompletion), not now. A state completes only once its do +// behavior has finished, so a state still running one is skipped here and +// scheduled by settleDoActions when the behavior ends; a composite state's body +// reaching `done` schedules it through completeIfDone. +func (e *StateExecutor) scheduleCompletionTransitions(state *ast.StateNode) error { + if e.hasRunningDoAction(state) { + return nil + } + for _, trans := range e.graph.Transitions[state] { + if trans.Trigger != nil { + continue + } + e.enqueue(Event{ + ID: e.nextEventID, + Type: EventTime, // Use EventTime with nil trigger + Timestamp: e.ctx.clock.now, + Payload: trans, + }) + e.nextEventID++ + return nil + } + return nil +} + +// completesAtEntry reports whether entering state as the end of an entry path queues +// its completion at once: it has a completion transition and nothing below to enter. +func (e *StateExecutor) completesAtEntry(state *ast.StateNode) bool { + return !e.hasBody(state) && completionCount(e.graph.Transitions[state]) > 0 +} + +// hasBody reports whether state has substates to enter: orthogonal regions or a start. +func (e *StateExecutor) hasBody(state *ast.StateNode) bool { + _, orthogonal := e.graph.CompositeStates[state] + return orthogonal || len(e.graph.StartOf(state)) > 0 +} + +// scheduleTimeTransitions queues a time event per time-triggered transition out +// of the state whose timer is not running yet, due at the clock's instant. +func (e *StateExecutor) scheduleTimeTransitions(state *ast.StateNode) error { + transitions := e.graph.Transitions[state] + // Transitions sharing a trigger spelling compete for one occurrence, so a + // group of equal time triggers arms a single timer drawn among by weight. + for _, group := range lower.TransitionGroups(state, transitions) { + scheduled := false + for _, index := range group { + scheduled = scheduled || e.timerScheduled[transitions[index]] + } + if scheduled { + continue + } + first := transitions[group[0]] + if first.Trigger == nil { + continue // a completion transition, scheduled once the do behavior ends + } + timeEvent, ok := first.Trigger.(*ast.TimeEvent) + if !ok { + continue + } + for _, index := range group { + trans := transitions[index] + member, _ := trans.Trigger.(*ast.TimeEvent) + if member == nil { + continue + } + if err := e.checkTimeTriggerType(trans, member); err != nil { + return err + } + } + // Evaluate duration expression in the scope the transition was written + // in, the machine's data shadowing it. + durationVal, err := e.evalStepOf(first.Source, timeEvent.Duration, first.Scope) + if err != nil { + return fmt.Errorf("eval time duration: %w", err) + } + due, err := e.ctx.dueInstant(timeEvent, durationVal, "time duration") + if err != nil { + return err + } + + e.enqueue(Event{ + ID: e.nextEventID, + Type: EventTime, + Timestamp: due, + Payload: first, + }) + e.nextEventID++ + for _, index := range group { + e.timerScheduled[transitions[index]] = true + } + } + + return nil +} + +// checkTimeTriggerType refuses, before evaluating it, the trigger argument +// validation refuses. The verdict is static, so it is judged once per transition. +func (e *StateExecutor) checkTimeTriggerType(trans *lower.Transition, t *ast.TimeEvent) error { + if err, ok := e.timeTriggerVerdict[trans]; ok { + return err + } + err := e.ctx.judgeTimeTriggerType(trans.Scope, t) + e.timeTriggerVerdict[trans] = err + return err +} + +// processNextEvent pops and processes the next event from queue. It is one +// run-to-completion step: the event is dispatched, and only once it has been +// fully handled are events the new configuration no longer defers dispatched +// again. +func (e *StateExecutor) processNextEvent() error { + if e.eventQueue.Len() == 0 { + return fmt.Errorf("no events to process") + } + + event, err := e.nextEvent() + if err != nil { + return err + } + e.moved = true + // The clock never lags a dispatched event: a timer popped ahead of it moves it. + e.ctx.setClock(math.Max(e.ctx.clock.now, event.Timestamp)) + e.lastEventAt = e.ctx.clock.now + + e.markDispatch() + mark, at := e.traceMark() + dispatch, err := e.dispatchEvent(event) + if err != nil { + return err + } + e.lastDispatch = &dispatch + if !dispatch.Deferred { + e.recordAccept(event, mark, at) + } + e.recallDeferredEvents() + e.pauseAtBreakpoint() + return nil +} + +// traceMark is where the trace stands before a dispatch, and the clock's instant. +func (e *StateExecutor) traceMark() (int, float64) { + if tr := e.trace(); tr != nil { + return tr.Mark(), e.ctx.clock.now + } + return 0, e.ctx.clock.now +} + +// recordAccept records a dispatched signal or call ahead of what its dispatch +// recorded; time and change events are no accepts, their transition records the trigger. +func (e *StateExecutor) recordAccept(event Event, mark int, at float64) { + tr := e.trace() + if tr == nil { + return + } + origin := TraceOrigin{At: at, Object: e.self, Behavior: e.stateMachine} + switch payload := event.Payload.(type) { + case Message: + tr.RecordAcceptAt(mark, origin, acceptedEventName(payload), payload.Payload) + case Call: + tr.RecordAcceptAt(mark, origin, payload.Operation, payload.Args) + } +} + +// markDispatch opens a dispatch's account: where its fired transitions begin, +// with no state hit left staged by a dispatch that failed before pausing. +func (e *StateExecutor) markDispatch() { + e.breakpointHit, e.dispatchMark = nil, -1 + if !e.keepFired { + e.releaseFired(len(e.fired)) + } + e.dispatchMark = len(e.fired) +} + +// stagedBreakpoint is the breakpoint vertex the dispatch under way passed or +// entered, nil when none or no dispatch is under way. A pseudostate passed is +// read from the transitions the dispatch fired, so a failed firing stages none. +func (e *StateExecutor) stagedBreakpoint() ast.Node { + if e.dispatchMark < 0 { + return nil + } + for _, fired := range e.fired[min(e.dispatchMark, len(e.fired)):] { + if _, ok := fired.Target.(*ast.PseudostateNode); ok && e.breakpointNodes[fired.Target] { + return fired.Target + } + } + if e.breakpointHit != nil { + return e.breakpointHit + } + return nil +} + +// pauseAtBreakpoint closes the dispatch just done, suspending the machine at the +// breakpoint vertex it passed or entered and leaving whatever is still due — +// the machine's own completion included — to the next run. +func (e *StateExecutor) pauseAtBreakpoint() { + hit := e.stagedBreakpoint() + e.breakpointHit, e.dispatchMark = nil, -1 + if hit == nil || e.state != StateRunning { + return + } + e.pausedAt = hit + e.state = StateSuspended +} + +// nextEvent takes the event to dispatch off the queue: the earliest, unless the +// queue leaves several unordered at its head, when the policy draws which goes +// first and the draw is reported; a replay or check refusing the draw takes none. +func (e *StateExecutor) nextEvent() (Event, error) { + tied := e.eventQueue.Tied() + if e.dispatchAmong != nil { + tied = e.dispatchAmong + } + if len(tied) == 1 { + event, _ := e.eventQueue.Take(tied[0].ID) + return event, nil + } + if len(tied) < 2 { + return e.eventQueue.Pop(), nil + } + choice := ChoicePoint{ + Kind: ChoiceDispatchOrder, + Where: dispatchWhere(tied[0].Timestamp), + Alternatives: make([]string, len(tied)), + File: e.stateMachine.DocName, + Span: e.stateMachine.DeclSpan, + } + for i := range tied { + choice.Alternatives[i] = e.eventLabel(tied[i]) + } + scheduling := e.ctx.scheduling() + choice.Taken = scheduling.choose(choice, nil) + if err := scheduling.refusal(); err != nil { + return Event{}, err + } + e.noteChoice(choice) + event, _ := e.eventQueue.Take(tied[choice.Taken].ID) + return event, nil +} + +// dispatchWherePrefix opens where a dispatch order names its instant. +const dispatchWherePrefix = "events at t=" + +// dispatchWhere names the instant a dispatch order was drawn at. +func dispatchWhere(at float64) string { + return dispatchWherePrefix + semantics.FormatReal(at) +} + +// eventLabel names a queued event as a dispatch-order choice lists it: a time +// trigger or a completion by its state and the transition's declared position +// and target, as a transition choice names one; a pool event by what it accepts. +func (e *StateExecutor) eventLabel(event Event) string { + if trans, ok := event.Payload.(*lower.Transition); ok && event.Type == EventTime { + transitions := e.graph.Transitions[trans.Source] + if pos := slices.Index(transitions, trans); pos >= 0 { + kind := "time" + if trans.Trigger == nil { + kind = "completion" + } + return fmt.Sprintf("%s %s %s", kind, StateVertexName(trans.Source), transitionName(transitions, pos)) + } + return transitionDescription(trans) + } + return eventName(&event) +} + +// Dispatch is what became of an event a step took off the queue: a transition +// fired on it, a state deferred it, or nothing was enabled for it and it was +// dropped; Resumed names the states whose do behavior went on from an accept with it. +type Dispatch struct { + Event Event + Fired bool + Deferred bool + Resumed []string +} + +// LastDispatch returns what became of the event the last ProcessNextEvent +// dispatched, false when it dispatched none. +func (e *StateExecutor) LastDispatch() (Dispatch, bool) { + if e.lastDispatch == nil { + return Dispatch{}, false + } + return *e.lastDispatch, true +} + +// FiredTransition is one transition taken: where it was written and the vertices +// it joined. An entry transition has no Source; it leaves the start of Owner's +// body, the one keying it in the graph's EntryTransitions (nil: the machine's own). +type FiredTransition struct { + Decl ast.Node + Source ast.Node + Target ast.Node + Owner ast.Node +} + +// KeepFired has the machine keep its firings until FiredSince releases them, as +// a debugger reads them; off (the default), a dispatch keeps only its own. +func (e *StateExecutor) KeepFired(keep bool) { + e.keepFired = keep + if !keep { + e.releaseFired(len(e.fired)) + } +} + +// FiredTransitions returns the transitions kept, in firing order: compound +// transitions by segment, fork and join branches, and entry transitions. +func (e *StateExecutor) FiredTransitions() []FiredTransition { + return slices.Clone(e.fired) +} + +// FiredCount counts the transitions taken so far, released ones included: a mark +// to read what a later step fired from. +func (e *StateExecutor) FiredCount() int { return e.firedBase + len(e.fired) } + +// FiredSince returns the kept transitions taken since mark, a FiredCount read +// earlier, and releases those before it: the machine keeps only from the last mark. +func (e *StateExecutor) FiredSince(mark int) []FiredTransition { + e.releaseFired(mark - e.firedBase) + if len(e.fired) == 0 { + return nil + } + return slices.Clone(e.fired) +} + +// releaseFired forgets the oldest n kept transitions, none of the dispatch under +// way, still counting them in FiredCount. +func (e *StateExecutor) releaseFired(n int) { + n = min(n, len(e.fired)) + if e.dispatchMark >= 0 { + n = min(n, e.dispatchMark) + } + if n <= 0 { + return + } + kept := copy(e.fired, e.fired[n:]) + clear(e.fired[kept:]) + e.fired, e.firedBase = e.fired[:kept], e.firedBase+n + if e.dispatchMark >= 0 { + e.dispatchMark -= n + } +} + +// noteFired records transitions taken, skipping any without a declaration. +func (e *StateExecutor) noteFired(transitions ...*lower.Transition) { + for _, trans := range transitions { + if trans != nil && trans.Decl != nil { + e.fired = append(e.fired, FiredTransition{Decl: trans.Decl, Source: trans.Source, Target: trans.Target}) + } + } +} + +// unfireOnError drops the transitions logged since mark when *err is set: a +// firing that failed midway took none of them. +func (e *StateExecutor) unfireOnError(mark int, err *error) { + if *err != nil { + e.fired = e.fired[:mark] + } +} + +// dispatchEvent delivers one event to the active configuration and reports what +// became of it. +func (e *StateExecutor) dispatchEvent(event Event) (Dispatch, error) { + dispatch := Dispatch{Event: event} + switch event.Type { + case EventTime: + // Fire transition - handle both old (TransitionEdge) and new (lower.Transition) for backward compatibility + if lowerTrans, ok := event.Payload.(*lower.Transition); ok { + // The timer has expired, so it is no longer running: a transition that + // does not leave its source state re-arms it for the next round. + delete(e.timerScheduled, lowerTrans) + sourceState, _ := lowerTrans.Source.(*ast.StateNode) + if sourceState != nil && !e.inActiveConfiguration(sourceState) { + // The source was left before this event came up, so the transition + // it carries is stale: firing it would move a state machine that is + // no longer there. + return dispatch, nil + } + var notes []RunNote + var err error + if lowerTrans.Trigger == nil && sourceState != nil { + if lowerTrans, notes, err = e.chooseCompletion(sourceState, lowerTrans); err != nil || lowerTrans == nil { + return dispatch, err + } + } else if lowerTrans.Trigger != nil { + // The expiry is the one occurrence the trigger's group competes + // for: the weighted draw among the holding members picks it, as a + // signal dispatch would. + for _, groupMember := range e.graph.Transitions[sourceState] { + if sameTimerGroup(groupMember, lowerTrans) { + delete(e.timerScheduled, groupMember) + } + } + enabled, probeNotes, err := e.enabledTransitions(sourceState, &event) + if err != nil || len(enabled) == 0 { + return dispatch, err + } + notes = probeNotes + chosen, choiceNotes, err := e.chooseTransition(dispatchCandidate{ + leaf: sourceState, source: sourceState, + enabled: enabled, notes: notes, + }, &event) + if err != nil || chosen == nil { + return dispatch, err + } + notes = choiceNotes + lowerTrans = chosen + } + // A transition out of a state inside an orthogonal region is region-local: + // it must not tear down the sibling regions unless its target lies outside + // the region set. The source may be a composite state enclosing the + // region's active state, so the region is resolved by containment. + dispatch.Fired, err = e.firingOn(&event, func() (bool, error) { + return e.resolveAndFire(sourceState, lowerTrans, notes) + }) + return dispatch, err + } + + // Fallback for tests that use TransitionEdge directly + if edge, ok := event.Payload.(*ast.TransitionEdge); ok { + // Convert TransitionEdge to lower.Transition + // Need to find source/target states by name + var sourceState, targetState *ast.StateNode + for _, state := range e.graph.States { + if edge.Source != nil && len(edge.Source.Parts) > 0 { + if state.Name == edge.Source.Parts[len(edge.Source.Parts)-1].Text { + sourceState = state + } + } + if edge.Target != nil && len(edge.Target.Parts) > 0 { + if state.Name == edge.Target.Parts[len(edge.Target.Parts)-1].Text { + targetState = state + } + } + } + if sourceState == nil || targetState == nil { + return dispatch, fmt.Errorf("could not find source/target states for transition") + } + + lowerTrans := &lower.Transition{ + Source: sourceState, + Target: targetState, + Trigger: edge.Trigger, + Guard: edge.Guard, + Effect: lower.LowerBehaviors(edge.Effect, nil, e.stateMachine.Scope, e.ctx.Resolver()), + } + var err error + dispatch.Fired, err = e.fireTransition(lowerTrans, route{segments: []*lower.Transition{lowerTrans}, target: targetState}) + return dispatch, err + } + + return dispatch, fmt.Errorf("invalid TimeEvent payload: expected *lower.Transition or *ast.TransitionEdge") + default: + // For general events, broadcast to all active regions + consumed, resumed, err := e.broadcastEvent(&event) + if err != nil { + return dispatch, err + } + dispatch.Fired, dispatch.Resumed = consumed, resumed + if !consumed && len(resumed) == 0 && e.defersEvent(&event) { + dispatch.Deferred = true + e.deferred = append(e.deferred, event) + } + return dispatch, nil + } +} + +// defersEvent reports whether any state of the active configuration, or an +// ancestor of one, defers this event. A composite state's deferral holds while +// any of its substates is active. +func (e *StateExecutor) defersEvent(event *Event) bool { + return len(e.deferringStates(event)) > 0 +} + +// deferringStates lists the states of the active configuration, each active leaf +// and its ancestors, that defer this event; each is listed once. +func (e *StateExecutor) deferringStates(event *Event) []*ast.StateNode { + var deferring []*ast.StateNode + asked := make(map[*ast.StateNode]bool) + for _, state := range e.activeStates() { + for _, ancestor := range e.getParentChain(state) { + if asked[ancestor] { + continue + } + asked[ancestor] = true + for _, trigger := range e.graph.Deferred[ancestor] { + if e.triggerMatches(trigger, e.graph.StateScopes[ancestor], event) { + deferring = append(deferring, ancestor) + break + } + } + } + } + return deferring +} + +// deferralOutranks reports whether a deferring state of the configuration holds +// the event back from the selected transitions: only a transition out of that +// state, or out of a state nested in it, is nested deeply enough to override its +// deferral, and every deferring state must be overridden for any of them to fire. +func (e *StateExecutor) deferralOutranks(candidates []dispatchCandidate, event *Event) bool { + for _, deferring := range e.deferringStates(event) { + overridden := slices.ContainsFunc(candidates, func(candidate dispatchCandidate) bool { + return e.encloses(deferring, candidate.source) + }) + if !overridden { + return true + } + } + return false +} + +// recallDeferredEvents returns every deferred event the configuration reached by +// the step just finished no longer defers to the event pool. A recalled event +// keeps its ID, so it is dispatched ahead of whatever arrived while it was held +// back, but not its original timestamp, which would move virtual time backwards. +func (e *StateExecutor) recallDeferredEvents() { + if len(e.deferred) == 0 { + return + } + retained := make([]Event, 0, len(e.deferred)) + for _, event := range e.deferred { + if e.defersEvent(&event) { + retained = append(retained, event) + continue + } + event.Timestamp = e.ctx.clock.now + e.enqueue(event) + } + e.deferred = retained +} + +// broadcastEvent offers an event to the active configuration, reporting whether +// any transition consumed it and the states whose do behavior went on with it. +// An event nothing consumed is either deferred or dropped by the caller, so "a +// transition fired" and "nothing happened" must not look alike here. +// +// Dispatch selects the transitions to take against the configuration and data the +// event was taken off the queue for, so a state this event entered never reacts to +// it; the do behaviors parked at an accept for it then go on with it, and the +// selected transitions fire one at a time in the order the scheduling policy draws. +func (e *StateExecutor) broadcastEvent(event *Event) (bool, []string, error) { + selected, err := e.selectTransitions(event) + if err != nil { + return false, nil, err + } + candidates, err := e.chooseTransitions(selected, event) + if err != nil { + return false, nil, err + } + // A witness move refused while drawing must stop the dispatch before the do + // behaviors take the occurrence, so a refused replay changes nothing. + if err := e.ctx.scheduling().refusal(); err != nil { + return false, nil, err + } + var resumed []string + if msg, ok := event.Payload.(Message); ok { + taking, err := e.doBehaviorsTaking(msg, candidates) + if err != nil { + return false, nil, err + } + if resumed, err = e.resumeDoBehaviors(taking, msg); err != nil { + return false, resumed, err + } + } + armed := func(candidate dispatchCandidate) (bool, error) { + unbind, err := e.bindTriggerArguments(candidate.chosen, event) + defer unbind() + if err != nil { + return false, err + } + return e.passesGuard(candidate.chosen) + } + consumed, err := e.dispatchInOrder(firingWherePrefix+eventName(event), candidates, armed, func(candidate dispatchCandidate, trans *lower.Transition, notes []RunNote) (bool, error) { + // The guard ran against the pre-dispatch data, so the arguments it read were + // unbound again; the effect needs them bound. + unbind, err := e.bindTriggerArguments(trans, event) + if err != nil { + unbind() + return false, fmt.Errorf("state %s: %w", candidate.source.Name, err) + } + fired, err := e.firingOn(event, func() (bool, error) { + return e.fireFrom(candidate.source, trans, notes, candidate.route) + }) + if err != nil { + return false, fmt.Errorf("fire transition out of %s: %w", candidate.source.Name, err) + } + return fired, nil + }) + return consumed, resumed, err +} + +// firingOn runs fire with event as the occurrence the transition taken reacts to. +func (e *StateExecutor) firingOn(event *Event, fire func() (bool, error)) (bool, error) { + saved := e.firingEvent + e.firingEvent = event + defer func() { e.firingEvent = saved }() + fired, err := fire() + if fired { + e.callTaken(event) + } + return fired, err +} + +// dispatchInOrder fires the candidates as queues of one front, drawing which firing's next +// unit runs; a firing left or disarmed by an earlier unit is void. where names the occurrence. +func (e *StateExecutor) dispatchInOrder( + where string, + candidates []dispatchCandidate, + armed func(dispatchCandidate) (bool, error), + fire func(dispatchCandidate, *lower.Transition, []RunNote) (bool, error), +) (bool, error) { + acted := false + gone := func(candidate dispatchCandidate) bool { return !e.isActive(candidate.leaf) || e.state.Ended() } + // A guard that cannot be read is left to the firing, which reports the error. + void := func(candidate dispatchCandidate) bool { + if gone(candidate) { + return true + } + var pass bool + var err error + e.preview(func() { pass, err = armed(candidate) }) + return err == nil && !pass + } + firing := func(candidate dispatchCandidate) error { + if gone(candidate) { + return nil + } + fired, err := fire(candidate, candidate.chosen, candidate.notes) + acted = acted || fired + return err + } + firings := joinFirings(candidates) + if len(firings) < 2 { + for _, candidate := range firings { + if err := firing(candidate); err != nil { + return acted, err + } + } + return acted, nil + } + err := e.moveWhole(func() error { + f := e.openFront(ChoiceRegionOrder, where) + for _, candidate := range firings { + head := unitHead{ + label: e.exitLabel(candidate.leaf), at: candidate.leaf, void: func() bool { return void(candidate) }, + silent: e.exitIsUnit(candidate.leaf) && e.silentExit(candidate.leaf), + } + if join, ok := candidate.chosen.Target.(*ast.PseudostateNode); ok && join.Kind == ast.PseudostateJoin { + head.label, head.at, head.silent = join.Name+"(join)", join, false + } + f.spawnAt(head, func() error { return firing(candidate) }) + } + return f.drain() + }) + return acted, err +} + +// joinFirings is the candidates with those meeting at one join reduced to the +// first of them, which fires the join's every segment. +func joinFirings(candidates []dispatchCandidate) []dispatchCandidate { + firings := make([]dispatchCandidate, 0, len(candidates)) + joins := make(map[*ast.PseudostateNode]bool) + for _, candidate := range candidates { + if join, ok := candidate.chosen.Target.(*ast.PseudostateNode); ok && join.Kind == ast.PseudostateJoin { + if joins[join] { + continue + } + joins[join] = true + } + firings = append(firings, candidate) + } + return firings +} + +// dispatchCandidate is the state one active leaf selected for an event, the leaf +// itself or a composite state enclosing it, with the positions of the transitions +// out of it the event enables. Which of them fires, and the state it is routed to, +// is settled only once the candidate survives conflict resolution +// (chooseTransitions); what selecting and choosing found worth noting is recorded +// only if it fires. +type dispatchCandidate struct { + leaf *ast.StateNode + source *ast.StateNode + enabled []int + notes []RunNote + chosen *lower.Transition + route route +} + +// selectTransitions picks one source state per leaf active when the event is +// dispatched: a transition out of a composite state is enabled while any of its +// substates is active, so the walk goes outward from the leaf and stops at the +// innermost state with an enabled transition. A false guard does not consume +// the event, so the walk carries on past it. Leaves in sibling regions of one +// composite state select the same state, which the event still leaves only once. +// A state that defers the event outranks every transition not nested in it: it +// selects nothing, to defer the event, unless each deferring state is overridden. +func (e *StateExecutor) selectTransitions(event *Event) ([]dispatchCandidate, error) { + candidates, err := e.selectCandidates(func(source *ast.StateNode) ([]int, []RunNote, error) { + return e.enabledTransitions(source, event) + }) + if err != nil { + return nil, err + } + if e.deferralOutranks(candidates, event) { + return nil, nil + } + return candidates, nil +} + +// selectCandidates walks outward from every active leaf, asking enabled which +// transitions that state offers and what finding them noted, and collects one +// candidate per leaf. A state is asked once per dispatch, however many leaves +// reach it. +func (e *StateExecutor) selectCandidates( + enabled func(*ast.StateNode) ([]int, []RunNote, error), +) ([]dispatchCandidate, error) { + var candidates []dispatchCandidate + offered := make(map[*ast.StateNode][]int) + for _, leaf := range e.activeLeaves() { + for _, source := range e.getParentChain(leaf) { + if positions, asked := offered[source]; asked { + if len(positions) > 0 { + break + } + continue + } + positions, notes, err := enabled(source) + if err != nil { + return nil, fmt.Errorf("state %s: %w", source.Name, err) + } + offered[source] = positions + if len(positions) == 0 { + continue + } + candidates = append(candidates, dispatchCandidate{leaf: leaf, source: source, enabled: positions, notes: notes}) + break + } + } + return candidates, nil +} + +// chooseTransitions resolves the dispatch: the candidates not outranked by a nested +// one, each with the one of its enabled transitions that fires drawn once here and +// its route through any junctions read against the pre-dispatch data, for the do +// behaviors taking the occurrence, the firing and the preview alike; a junction +// several branches of which hold is drawn among only as the candidate fires. A +// state outranked by a nested one draws nothing. event is nil for a change poll. +func (e *StateExecutor) chooseTransitions(candidates []dispatchCandidate, event *Event) ([]dispatchCandidate, error) { + chosen := make([]dispatchCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if e.losesToNestedTransition(candidates, candidate) { + continue + } + var err error + candidate.chosen, candidate.notes, err = e.chooseTransition(candidate, event) + if err != nil { + return nil, err + } + route, err := e.resolveRouteFor(candidate.chosen, event) + if err != nil { + return nil, fmt.Errorf("transition out of %s: %w", candidate.source.Name, err) + } + candidate.route = route + chosen = append(chosen, candidate) + } + return chosen, nil +} + +// resolveRouteFor resolves trans's route with the trigger's arguments bound, so a +// pseudostate guard along it reads them as the transition's own guard does. +func (e *StateExecutor) resolveRouteFor(trans *lower.Transition, event *Event) (route, error) { + if event == nil { + return e.resolveRoute(trans) + } + unbind, err := e.bindTriggerArguments(trans, event) + defer unbind() + if err != nil { + return route{}, err + } + return e.resolveRoute(trans) +} + +// chooseTransition resolves which of the candidate's enabled transitions fires, +// with the choice point it makes ahead of the candidate's notes: a weighted +// enabled set is drawn by the weights its transitions state, the trigger's +// arguments bound for them as they are for a guard. event is nil for a +// completion, a change poll or a route's branch. +func (e *StateExecutor) chooseTransition(candidate dispatchCandidate, event *Event) (*lower.Transition, []RunNote, error) { + transitions := e.graph.Transitions[candidate.source] + notes := candidate.notes + pick := 0 + // Weights are validated for a lone enabled transition too, even though it + // records no choice point and fires with probability 1. + weights, err := e.transitionWeights(candidate.source, transitions, candidate.enabled, event) + if err != nil { + return nil, nil, err + } + if choice, ok := e.transitionChoice(candidate.source, transitions, candidate.enabled); ok { + whereOf := func(i int) string { return transitionWhere(candidate.source, transitions[candidate.enabled[i]]) } + pick, err = e.drawTransition(&choice, whereOf, weights) + if err != nil { + return nil, nil, err + } + choice.File, choice.Span = e.transitionLocation(candidate.source, transitions[candidate.enabled[pick]]) + notes = append([]RunNote{choice}, notes...) + } + return transitions[candidate.enabled[pick]], notes, nil +} + +// drawTransition resolves the choice point to the alternative taken: among the +// enabled weights where the set is weighted, by the policy where it is not; +// Where names the transition taken. +func (e *StateExecutor) drawTransition(choice *ChoicePoint, whereOf func(i int) string, weights []float64) (int, error) { + if weights == nil { + pick := e.ctx.scheduling().choose(*choice, whereOf) + choice.Taken, choice.Where = pick, whereOf(pick) + return pick, nil + } + choice.Weights = weights + choice.Where = whereOf(0) + if err := e.ctx.scheduling().chooseWeighted(choice, whereOf); err != nil { + return 0, err + } + choice.Where = whereOf(choice.Taken) + return choice.Taken, nil +} + +// regionOrderChoice is the choice among the states of several regions acting on +// one occasion, the one the policy picks first. +func (e *StateExecutor) regionOrderChoice(where string, states []*ast.StateNode) ChoicePoint { + choice := ChoicePoint{ + Kind: ChoiceRegionOrder, + Where: where, + Alternatives: e.stateNames(states), + File: e.stateMachine.DocName, + } + choice.Taken = e.ctx.scheduling().choose(choice, nil) + choice.Span = states[choice.Taken].Span() + return choice +} + +// stateNames spells each state, qualified by its region's name where two of the +// states share a name, as typed regions' states do. +func (e *StateExecutor) stateNames(states []*ast.StateNode) []string { + shared := make(map[string]int, len(states)) + for _, state := range states { + shared[state.Name]++ + } + names := make([]string, len(states)) + for i, state := range states { + names[i] = state.Name + if region := e.graph.RegionOf[state]; shared[names[i]] > 1 && region != nil && region.Name != "" { + names[i] = region.Name + "." + names[i] + } + } + return names +} + +// losesToNestedTransition reports whether another leaf selected a transition out +// of a state nested inside this candidate's source: two transitions leaving the +// same state are in conflict, and the innermost one wins. +func (e *StateExecutor) losesToNestedTransition(candidates []dispatchCandidate, candidate dispatchCandidate) bool { + for _, other := range candidates { + if other.source != candidate.source && e.nestedIn(other.source, candidate.source) { + return true + } + } + return false +} + +// nestedIn reports whether state lies inside the given composite state. +func (e *StateExecutor) nestedIn(state, composite *ast.StateNode) bool { + for _, ancestor := range e.getParentChain(state)[1:] { + if ancestor == composite { + return true + } + } + return false +} + +// encloses reports whether the source is the target or contains it, making the +// transition external: KerML exits the source of every transition, so a state +// transitioning to itself is left and entered afresh. +func (e *StateExecutor) encloses(source, target *ast.StateNode) bool { + if source == nil || target == nil { + return false + } + return source == target || e.nestedIn(target, source) +} + +// exitStates exits the states being left, innermost first. +func (e *StateExecutor) exitStates(leaving []*ast.StateNode) error { + for _, state := range leaving { + if e.exitedByAncestorRegion(state, leaving) { + continue + } + if err := e.exitState(state); err != nil { + return fmt.Errorf("exit state: %w", err) + } + } + return nil +} + +// exitedByAncestorRegion reports whether another state being left owns the region +// the state is active in, and so exits it recursively. +func (e *StateExecutor) exitedByAncestorRegion(state *ast.StateNode, leaving []*ast.StateNode) bool { + region := e.graph.RegionOf[state] + if region == nil { + return false + } + owner := e.graph.RegionOwner[region] + for _, other := range leaving { + if other == owner { + return true + } + } + return false +} + +// activeLeaves returns the innermost active states, ordered by the declaration of +// the regions they lie in rather than by their depth. A state owning an active +// orthogonal region is not a leaf: the event reaches it walking outward. Once +// every one of its regions rests at the state itself, it is the leaf, once. +func (e *StateExecutor) activeLeaves() []*ast.StateNode { + states := e.activeStates() + leaves := make([]*ast.StateNode, 0, len(states)) + seen := make(map[*ast.StateNode]bool, len(states)) + for _, state := range states { + if !e.enclosesActiveRegion(state) && !seen[state] { + seen[state] = true + leaves = append(leaves, state) + } + } + paths := make(map[*ast.StateNode][]int, len(leaves)) + for _, leaf := range leaves { + paths[leaf] = e.regionPath(leaf) + } + sort.SliceStable(leaves, func(i, j int) bool { + return lessPath(paths[leaves[i]], paths[leaves[j]]) + }) + return leaves +} + +// regionPath returns the declaration index of every region between the machine +// and the state, outermost first, which orders concurrent states. +func (e *StateExecutor) regionPath(state *ast.StateNode) []int { + chain := e.getParentChain(state) + path := make([]int, 0, len(chain)) + for i := len(chain) - 1; i >= 0; i-- { + region := e.graph.RegionOf[chain[i]] + if region == nil { + continue + } + siblings := e.graph.TopRegions + if owner := e.graph.RegionOwner[region]; owner != nil { + siblings = e.graph.CompositeStates[owner] + } + for index, sibling := range siblings { + if sibling == region { + path = append(path, index) + break + } + } + } + return path +} + +// lessPath orders two region paths lexicographically, a shorter path first where +// one prefixes the other. +func lessPath(a, b []int) bool { + for i := range a { + if i >= len(b) { + return false + } + if a[i] != b[i] { + return a[i] < b[i] + } + } + return len(a) < len(b) +} + +// fireFrom takes a transition whose source is the given active state, which is +// either the active leaf or a composite state enclosing it. A source lying in an +// active orthogonal region moves that region; one outside every active region +// moves the machine's single active hierarchy. notes are recorded only if it fires; +// r is the transition's route as resolveRoute settled it. +func (e *StateExecutor) fireFrom(source *ast.StateNode, trans *lower.Transition, notes []RunNote, r route) (bool, error) { + defer e.taking(trans, notes)() + if region := e.activeRegionOf(source); region != nil { + return e.fireTransitionInRegion(region, trans, r) + } + return e.fireTransition(trans, r) +} + +// taking puts the executor in the middle of taking trans, selected with notes, +// and returns the function putting it back where it was. +func (e *StateExecutor) taking(trans *lower.Transition, notes []RunNote) func() { + savedTrans, savedNotes := e.firingTrans, e.firingNotes + e.firingTrans, e.firingNotes = trans, notes + return func() { e.firingTrans, e.firingNotes = savedTrans, savedNotes } +} + +// resolveAndFire takes a transition outside a dispatch, a timer come due, resolving +// its route as it fires; source is as for fireFrom, nil for the single hierarchy. +func (e *StateExecutor) resolveAndFire(source *ast.StateNode, trans *lower.Transition, notes []RunNote) (bool, error) { + r, err := e.resolveRoute(trans) + if err != nil { + return false, err + } + if source != nil { + return e.fireFrom(source, trans, notes, r) + } + defer e.taking(trans, notes)() + return e.fireTransition(trans, r) +} + +// chooseCompletion resolves which completion transition out of source fires on +// the completion event dispatched carries: every completion transition of the +// state has its guard read now, the policy draws one of those enabled as a +// transition choice and the state's other completion events leave the queue, +// one completion occurrence firing one transition. None enabled fires nothing. +func (e *StateExecutor) chooseCompletion(source *ast.StateNode, dispatched *lower.Transition) (*lower.Transition, []RunNote, error) { + queued := e.eventQueue.CompletionsOf(source) + // The others leave the queue only once the draw stands: a refused replay changes nothing. + drain := func() { + for _, ev := range queued { + e.eventQueue.Take(ev.ID) + } + } + transitions := e.graph.Transitions[source] + if completionCount(transitions) < 2 { + // Nothing to choose among: firing reads the one guard, and a lone + // weighted completion has its weight validated only once its guard holds. + if dispatched.Probability != nil { + ok, err := e.completionEnabled(dispatched) + if err != nil { + return nil, nil, fmt.Errorf("eval completion guard: %w", err) + } + if !ok { + drain() + return nil, nil, nil + } + if _, err := e.transitionWeights(source, transitions, []int{slices.Index(transitions, dispatched)}, nil); err != nil { + return nil, nil, err + } + } + drain() + return dispatched, nil, nil + } + var enabled []int + var notes []RunNote + for pos, trans := range transitions { + if trans.Trigger != nil { + continue + } + var ok bool + var err error + if len(enabled) > 0 { + // As for a triggered event: once one is enabled, a later one whose guard + // cannot be read is noted as an alternative not taken, not an error. + e.preview(func() { ok, err = e.completionEnabled(trans) }) + if err != nil { + notes = append(notes, e.unevaluableTransition(source, transitions, pos, err)) + ok = false + } + } else if ok, err = e.completionEnabled(trans); err != nil { + return nil, nil, fmt.Errorf("eval completion guard: %w", err) + } + if ok { + enabled = append(enabled, pos) + } + } + if len(enabled) == 0 { + drain() + return nil, nil, nil + } + weights, err := e.transitionWeights(source, transitions, enabled, nil) + if err != nil { + return nil, nil, err + } + choice, ok := e.transitionChoice(source, transitions, enabled) + if !ok { + drain() + return transitions[enabled[0]], notes, nil + } + whereOf := func(i int) string { return transitionWhere(source, transitions[enabled[i]]) } + pick, err := e.drawTransition(&choice, whereOf, weights) + if err != nil { + return nil, nil, err + } + choice.File, choice.Span = e.transitionLocation(source, transitions[enabled[pick]]) + if err := e.ctx.scheduling().refusal(); err != nil { + return nil, nil, err + } + drain() + return transitions[enabled[pick]], append(notes, choice), nil +} + +// completionCount is how many of the transitions are completion transitions. +func completionCount(transitions []*lower.Transition) int { + n := 0 + for _, trans := range transitions { + if trans.Trigger == nil { + n++ + } + } + return n +} + +// completionEnabled reports whether a completion transition can fire now: its +// guard holds and the join it may lead into has every other branch in place. +func (e *StateExecutor) completionEnabled(trans *lower.Transition) (bool, error) { + pass, err := e.passesGuard(trans) + if err != nil || !pass { + return false, err + } + return e.joinSynchronized(trans, nil) +} + +// transitionDecided records what selecting the transition now firing noted, its +// guard having passed its final reading, then what settling its route r noted; +// the route is returned with its notes taken. +func (e *StateExecutor) transitionDecided(r route) route { + e.noteAll(e.firingNotes) + e.firingNotes = nil + e.noteAll(r.notes) + r.notes = nil + return r +} + +// activeRegionOf returns the innermost active orthogonal region the state is +// declared in, or nil when it lies outside every active region. +func (e *StateExecutor) activeRegionOf(state *ast.StateNode) *ast.StateRegion { + for _, ancestor := range e.getParentChain(state) { + region, inRegion := e.graph.RegionOf[ancestor] + if !inRegion { + continue + } + if _, active := e.activeConfig.regionStates[region]; active { + return region + } + } + return nil +} + +// enclosesActiveRegion reports whether the state owns an orthogonal region with +// an active state below it; a region resting at the state itself has none. +func (e *StateExecutor) enclosesActiveRegion(state *ast.StateNode) bool { + for _, region := range e.graph.CompositeStates[state] { + if active, ok := e.activeConfig.regionStates[region]; ok && active != state { + return true + } + } + return false +} + +// enabledTransitions returns the positions of the transitions out of state that +// this event triggers and whose guards hold, none when the state cannot react to +// it. A transition whose guard is false does not consume the event, so a later +// one still gets its chance. Selection leaves the machine's data as it was: the +// caller binds the trigger's arguments again before firing. Every transition is +// examined so that several enabled at once are a choice point; the notes are +// the caller's to record if a transition fires. +func (e *StateExecutor) enabledTransitions(state *ast.StateNode, event *Event) ([]int, []RunNote, error) { + var enabled []int + var notes []RunNote + transitions := e.graph.Transitions[state] + // Once one is enabled the transition is decided; the rest are probed only to + // report the choice, which leaves the run as it was. + for i, trans := range transitions { + var ok bool + if len(enabled) > 0 { + var unevaluable *UnevaluableGuard + if ok, unevaluable = e.probeTransition(state, transitions, i, event); unevaluable != nil { + notes = append(notes, *unevaluable) + } + } else { + var err error + if ok, err = e.transitionEnabled(trans, event); err != nil { + return nil, nil, err + } + } + if ok { + enabled = append(enabled, i) + } + } + return enabled, notes, nil +} + +// probeTransition reads whether the transition at position i out of state reacts +// to event once another already does, as a probe the context undoes whole. One +// that cannot be evaluated is not selected and is returned as the note to record. +func (e *StateExecutor) probeTransition(state *ast.StateNode, transitions []*lower.Transition, i int, event *Event) (bool, *UnevaluableGuard) { + var ok bool + var err error + e.preview(func() { ok, err = e.transitionEnabled(transitions[i], event) }) + if err != nil { + note := e.unevaluableTransition(state, transitions, i, err) + return false, ¬e + } + return ok, nil +} + +// transitionEnabled reports whether trans reacts to event: its trigger matches, +// its guard holds and the join it may lead into is ready to fire. +func (e *StateExecutor) transitionEnabled(trans *lower.Transition, event *Event) (bool, error) { + matches, err := e.matchesEvent(trans, event) + if err != nil || !matches { + return false, err + } + // A call trigger's arguments are bound before the guard runs: the guard is + // written against the parameters the trigger declares. A transition that + // does not fire must leave no trace of them in the machine's data. + unbind, err := e.bindTriggerArguments(trans, event) + if err != nil { + unbind() + return false, err + } + pass, err := e.passesGuard(trans) + unbind() + if err != nil || !pass { + return false, err + } + // A transition into a join whose other branches have not arrived is not + // enabled either: firing it would move nothing. + return e.joinSynchronized(trans, event) +} + +// transitionChoice is the transitions out of state enabled for one event, at +// their declared positions, as a choice point; there is none under two. pick is +// the position in enabled of the one that fires. +func (e *StateExecutor) transitionChoice(state *ast.StateNode, transitions []*lower.Transition, enabled []int) (ChoicePoint, bool) { + if len(enabled) < 2 { + return ChoicePoint{}, false + } + alts := make([]string, len(enabled)) + for i, pos := range enabled { + alts[i] = transitionName(transitions, pos) + } + return ChoicePoint{Kind: ChoiceTransition, Alternatives: alts}, true +} + +// transitionWeights evaluates the weight each transition out of source states +// for itself, read where its guard is, nil when none of the enabled is +// weighted: an unweighted set draws as it always has. A weighted transition +// enabled beside an unweighted one, an evaluated weight that is no probability, +// a group whose weights do not sum to 1 or no enabled weight positive at all is +// the typed error, mirroring what a decision reports. Every transition of a +// group an enabled transition belongs to is weighed, not only the enabled. +func (e *StateExecutor) transitionWeights(source ast.Node, transitions []*lower.Transition, enabled []int, event *Event) ([]float64, error) { + firstWeighted := -1 + for _, pos := range enabled { + if transitions[pos].Probability != nil { + firstWeighted = pos + break + } + } + if firstWeighted < 0 { + return nil, nil + } + evalWeight := func(pos int) (float64, error) { + trans := transitions[pos] + if event != nil && trans.Trigger != nil { + unbind, err := e.bindTriggerArguments(trans, event) + defer unbind() + if err != nil { + return 0, fmt.Errorf("%w: %s: weight of %s: %v", + ErrBranchWeights, weightWhere(source, transitions, pos), transitionName(transitions, pos), err) + } + } + val, err := e.evalTransitionStep(trans, trans.Probability.Expr, trans.BodyScope) + if err != nil { + return 0, fmt.Errorf("%w: %s: weight of %s: %v", + ErrBranchWeights, weightWhere(source, transitions, pos), transitionName(transitions, pos), err) + } + val = soleElement(val) + if val.Kind != ValConst || !val.Const.IsNumeric() { + return 0, fmt.Errorf("%w: %s: weight of %s is %s, not a number", + ErrBranchWeights, weightWhere(source, transitions, pos), transitionName(transitions, pos), describeValue(val)) + } + return asReal(val.Const), nil + } + unweighted := func(pos int) error { + return fmt.Errorf("%w: %s: %s is unweighted while %s carries a weight", + ErrBranchWeights, weightWhere(source, transitions, pos), + transitionName(transitions, pos), transitionName(transitions, firstWeighted)) + } + // The whole distribution of a group is checked, as a decision's is: every + // member's weight is a probability and they sum to 1, the enabled or not. + evaluated := make(map[int]float64) + for _, group := range lower.TransitionGroups(source, transitions) { + inSet := false + for _, pos := range group { + if slices.Contains(enabled, pos) { + inSet = true + break + } + } + if !inSet { + continue + } + total := 0.0 + for _, pos := range group { + if transitions[pos].Probability == nil { + return nil, unweighted(pos) + } + w, err := evalWeight(pos) + if err != nil { + return nil, err + } + if !lower.WeightInRange(w) { + return nil, fmt.Errorf("%w: %s: weight of %s is %s, not a probability in [0, 1]", + ErrBranchWeights, weightWhere(source, transitions, pos), transitionName(transitions, pos), FormatWeight(w)) + } + evaluated[pos] = w + total += w + } + if math.Abs(total-1) > lower.ProbabilityTolerance { + return nil, fmt.Errorf("%w: %s: the weights of its transitions sum to %s, not 1.0", + ErrBranchWeights, weightWhere(source, transitions, group[0]), FormatWeight(total)) + } + } + weights := make([]float64, len(enabled)) + for i, pos := range enabled { + if transitions[pos].Probability == nil { + return nil, unweighted(pos) + } + weights[i] = evaluated[pos] + } + if _, err := checkWeights(weightWhere(source, transitions, enabled[0]), weights); err != nil { + return nil, err + } + return weights, nil +} + +// weightWhere names the state and the event the transition at pos reacts to, +// or the pseudostate its branches leave, for a message about its weight. +func weightWhere(source ast.Node, transitions []*lower.Transition, pos int) string { + switch s := source.(type) { + case *ast.StateNode: + return transitionWhere(s, transitions[pos]) + case *ast.PseudostateNode: + return pseudostateWhere(s) + } + return "transitions" +} + +// unevaluableTransition is the transition at position pos out of state, probed +// once another was enabled, as the note that it cannot be evaluated. +func (e *StateExecutor) unevaluableTransition(state *ast.StateNode, transitions []*lower.Transition, pos int, err error) UnevaluableGuard { + trans := transitions[pos] + file, span := e.transitionLocation(state, trans) + if trans.Guard != nil { + span = trans.Guard.Span() + } + return UnevaluableGuard{ + Where: transitionWhere(state, trans), + Alternative: transitionName(transitions, pos), + Reason: err.Error(), + File: file, + Span: span, + } +} + +// transitionName names a transition out of a state by declared position and target. +func transitionName(transitions []*lower.Transition, pos int) string { + return fmt.Sprintf("%d->%s", pos+1, StateVertexName(transitions[pos].Target)) +} + +// transitionWhere names the state and the event trans reacts to, for a note. +func transitionWhere(state *ast.StateNode, trans *lower.Transition) string { + where := "state " + state.Name + if name := triggerName(trans.Trigger); name != "" { + where += " on " + name + } + return where +} + +// transitionLocation is where trans was declared, or its vertex when it has no declaration. +func (e *StateExecutor) transitionLocation(vertex ast.Node, trans *lower.Transition) (string, source.Span) { + file := e.stateMachine.DocName + if trans.Scope != nil && trans.Scope.DocName() != "" { + file = trans.Scope.DocName() + } + if trans.Decl != nil { + return file, trans.Decl.Span() + } + return file, vertex.Span() +} + +// bindTriggerArguments binds the parameters a call trigger declares to the +// arguments of the invocation, so the transition's guard and effect can read +// them. It returns the function restoring the machine's data to what it held +// before, for the caller to run when the transition does not fire. +func (e *StateExecutor) bindTriggerArguments(trans *lower.Transition, event *Event) (func(), error) { + if len(trans.Accepted) == 0 { + return func() { /* nothing was bound */ }, nil + } + unbind := e.restoreData(trans.Accepted) + if _, ok := trans.Trigger.(*ast.AcceptEvent); ok { + return unbind, e.bindAcceptPayload(trans.Accepted[0], event) + } + callEvent, ok := trans.Trigger.(*ast.CallEvent) + if !ok { + return unbind, fmt.Errorf("trigger binding %s: a %T trigger binds nothing", trans.Accepted[0], trans.Trigger) + } + call, ok := event.Payload.(Call) + if !ok { + return unbind, fmt.Errorf("call trigger %s: event carries %T, not an operation invocation", + ast.SimpleName(callEvent.Operation), event.Payload) + } + for _, param := range trans.Accepted { + value, ok := call.Args[param] + if !ok { + return unbind, fmt.Errorf("call trigger %s: invocation carries no argument %q", + call.Operation, param) + } + e.bindData(param, value) + } + return unbind, nil +} + +// bindAcceptPayload binds the name an accept gave its payload +// (`accept msg : Warning`) to the value the accepted occurrence carries, for the +// transition's guard, effect and the behaviors its firing performs to read. +func (e *StateExecutor) bindAcceptPayload(name string, event *Event) error { + msg, ok := event.Payload.(Message) + if !ok { + return fmt.Errorf("accept %s: event carries %T, not a message", name, event.Payload) + } + value, err := e.ctx.acceptedValue(&msg) + if err != nil { + return fmt.Errorf("accept %s: %w", name, err) + } + event.Payload = msg + e.bindData(name, value) + return nil +} + +// orAnonymousSignal names the signal a message carries for a diagnostic. +func orAnonymousSignal(signalType string) string { + if signalType == "" { + return "the accepted message" + } + return "the accepted " + signalType +} + +// restoreSharedData snapshots the named entries of the machine's data and returns +// the function putting them back, deleting the ones that were not there before. +func (e *StateExecutor) restoreSharedData(names []string) func() { + saved := make(map[string]Value, len(names)) + held := make(map[string]bool, len(names)) + for _, name := range names { + value, ok := e.stateData[name] + saved[name], held[name] = value, ok + } + return func() { + for name, wasHeld := range held { + if wasHeld { + e.stateData[name] = saved[name] + } else { + delete(e.stateData, name) + } + } + } +} + +// sameTimerGroup reports whether two transitions out of one source share a +// trigger spelling, so a timer queued for one is the occurrence both compete +// for. Completion transitions never share: each arms its own timer. +func sameTimerGroup(a, b *lower.Transition) bool { + if _, timed := b.Trigger.(*ast.TimeEvent); !timed { + return false + } + return a.Source == b.Source && lower.TriggerKey(a) == lower.TriggerKey(b) +} + +// matchesEvent checks if a transition matches the given event. Resolving the +// port a `via` names may materialize it, which can fail. +func (e *StateExecutor) matchesEvent(trans *lower.Transition, event *Event) (bool, error) { + // Completion transition (nil trigger) doesn't match external events + if trans.Trigger == nil { + return false, nil + } + + switch event.Type { + case EventChange: + // A change occurrence is the poll that observed the rise: it takes the + // change-triggered transitions whose condition rose in it, not yet latched. + poll, ok := event.Payload.(*changePoll) + return ok && e.triggerMatches(trans.Trigger, trans.Scope, event) && poll.condition[trans] && !e.changeFired[trans], nil + + case EventAccept, EventCall: + if !e.triggerMatches(trans.Trigger, trans.Scope, event) { + return false, nil + } + // A transfer is taken by the trigger whose receiver it reaches: the port + // a `via` names, or the performer itself when it names none. + msg, ok := event.Payload.(Message) + if !ok { + return trans.Via == "", nil + } + return e.transitionReached(trans, msg) + + case EventTime: + // A timer expiry is the one occurrence its whole same-spelled group + // competes for: the payload names the group's first member. + if transPayload, ok := event.Payload.(*lower.Transition); ok { + return trans == transPayload || sameTimerGroup(trans, transPayload), nil + } + return false, nil + + default: + return false, nil + } +} + +// triggerMatches reports whether a trigger reacts to an event, whether the +// trigger belongs to a transition or to a state's deferred set. scope is where +// the trigger was declared, in which the type it accepts resolves. +func (e *StateExecutor) triggerMatches(trigger ast.Node, scope *symbols.Scope, event *Event) bool { + switch event.Type { + case EventAccept: + acceptEvent, ok := trigger.(*ast.AcceptEvent) + if !ok { + return false + } + msg, ok := event.Payload.(Message) + if !ok { + return false + } + // The accept names the occurrence it takes either by its type + // (`accept Ping`) or by the event it subsets (`accept :> shutDown`). + if typed := ast.AsQualifiedName(acceptEvent.SignalType); typed != nil && len(typed.Parts) > 0 { + return e.ctx.messageMatches(msg, typed, scope) + } + if lower.FeaturePath(acceptEvent.Subsets) == "" { + return false + } + return e.triggerEval(scope).carriesEvent(msg, acceptEvent.Subsets) + + case EventCall: + callEvent, ok := trigger.(*ast.CallEvent) + if !ok { + return false + } + call, ok := event.Payload.(Call) + if !ok { + return false + } + // A trigger naming an operation fires only for that operation; a trigger + // naming none fires for any call. + expectedOp := ast.SimpleName(callEvent.Operation) + if expectedOp != "" && expectedOp != call.Operation { + return false + } + // A trigger declaring parameters fires only for a call carrying an + // argument of each declared name; `op()` takes the call whatever it carries. + for _, param := range callEvent.Parameters { + if _, ok := call.Args[param.Text]; !ok { + return false + } + } + // A call of a declared operation fires only the triggers naming that one. + return call.Declared == nil || slices.Contains(e.callTriggerOperations(callEvent), call.Declared) + + case EventChange: + // Re-evaluate condition (pollChangeEvents is the primary driver); here we + // just verify it is a change trigger with a condition. + changeEvent, ok := trigger.(*ast.ChangeEvent) + return ok && changeEvent.Condition != nil + + default: + return false + } +} + +// fireTransition takes a state transition, reporting whether it was taken: one +// whose guard is false leaves the machine where it is; one whose route is open +// at a junction draw has the draw made as the move begins. +func (e *StateExecutor) fireTransition(trans *lower.Transition, r route) (fired bool, err error) { + defer e.unfireOnError(len(e.fired), &err) + pass, err := e.passesGuard(trans) + if err != nil || !pass { + return false, err + } + // Fork, join and history reshape the active configuration rather than moving + // to a single state, so they are fired whole; a join decides itself once ready. + if ps, ok := trans.Target.(*ast.PseudostateNode); ok && isSynchronizationTarget(ps) { + switch ps.Kind { + case ast.PseudostateFork: + e.transitionDecided(r) + return true, e.fireForkTransition(trans, ps) + case ast.PseudostateJoin: + return e.fireJoinTransition(trans, ps, r) + default: + return true, e.fireHistoryTransition(trans, ps, e.transitionDecided(r)) + } + } + if !r.settled() { + return false, fmt.Errorf("transition target state not found") + } + return true, e.transitionTo(trans, e.transitionDecided(r)) +} + +// moveOrigin is the state a move of the single active hierarchy starts from: the +// active simple state, or the composite state whose regions hold the configuration. +func (e *StateExecutor) moveOrigin() *ast.StateNode { + if current := e.getCurrentState(); current != nil { + return current + } + return e.activeCompositeOwner() +} + +// moveBoundary is the state a move from current to target stops exiting at: their +// least common ancestor, or the parent of a source that encloses the target, which +// an external transition leaves even so. +func (e *StateExecutor) moveBoundary(current *ast.StateNode, trans *lower.Transition, target *ast.StateNode) *ast.StateNode { + lca := e.getLCA(current, target) + if source, isState := trans.Source.(*ast.StateNode); isState && e.encloses(source, target) { + lca = e.graph.ParentState[source] + } + return lca +} + +// exitPath lists from and its ancestors up to but excluding stop, innermost first; +// within a region it ends at the region's boundary as well. +func (e *StateExecutor) exitPath(from, stop *ast.StateNode, within *ast.StateRegion) []*ast.StateNode { + var path []*ast.StateNode + for current := from; current != nil && current != stop; current = e.graph.ParentState[current] { + if within != nil && !e.regionContains(within, current) { + break + } + path = append(path, current) + } + return path +} + +// transitionTo moves the active configuration from the current state along r: +// exit up to the least common ancestor, run the transition effects, then enter +// down to the target, resolving any choice on the way once the effects into it ran. +func (e *StateExecutor) transitionTo(trans *lower.Transition, r route) error { + return e.transitionToInto(trans, r, nil) +} + +// transitionToInto is transitionTo with branches naming the state each +// orthogonal region entered on the way must start in, which is how a history +// pseudostate restores a recorded configuration rather than the initial one. +func (e *StateExecutor) transitionToInto(trans *lower.Transition, r route, branches map[*ast.StateRegion]*ast.StateNode) error { + currentState := e.moveOrigin() + return e.travel(trans, currentState, r, + func(target *ast.StateNode) []*ast.StateNode { return e.exitedByMove(currentState, trans, target) }, + func(target *ast.StateNode) []*ast.StateNode { return e.enteredByMove(currentState, trans, target) }, + func(effects []routeEffect, target *ast.StateNode) error { + return e.moveTo(trans, currentState, effects, target, branches) + }) +} + +// moveTo finishes a move of the single active hierarchy from currentState to +// targetState: the exits still to make, the effects, then the entries. +func (e *StateExecutor) moveTo(trans *lower.Transition, currentState *ast.StateNode, effects []routeEffect, targetState *ast.StateNode, branches map[*ast.StateRegion]*ast.StateNode) error { + // The trace's source name has to be read before the move, not after it. + fromName := "" + if currentState != nil { + fromName = currentState.Name + } + lca := e.moveBoundary(currentState, trans, targetState) + + // Exit states (deepest to shallowest) + if err := e.exitStates(e.exitPath(currentState, lca, nil)); err != nil { + return err + } + + if err := e.runEffects(effects, e.descendantChain(lca, targetState)); err != nil { + return err + } + if lca == targetState { + return e.completeInto(trans, fromName, targetState) + } + return e.enterBelow(trans, fromName, lca, targetState, branches) +} + +// enterBelow finishes a move whose exits and effects are done: it enters the +// states below lca down to targetState, then the target's own start. +func (e *StateExecutor) enterBelow(trans *lower.Transition, fromName string, lca, targetState *ast.StateNode, branches map[*ast.StateRegion]*ast.StateNode) error { + _, leaf, err := e.enterToward(lca, targetState, branches) + if err != nil { + return err + } + + if err := e.settleEntered(leaf); err != nil { + return fmt.Errorf("complete state machine: %w", err) + } + + // Record trace + if e.trace() != nil { + eventName := triggerName(trans.Trigger) + e.trace().RecordStateTransition(e.traceOrigin(), fromName, targetState.Name, eventName) + } + + return nil +} + +// completeIfDone acts on what entering target completed: a composite state +// whose body reached `done` schedules its own completion transitions, and the +// machine completes once its own body or every top-level region has. +func (e *StateExecutor) completeIfDone(target *ast.StateNode) error { + if err := e.scheduleCompletedComposites(target); err != nil { + return err + } + if !e.machineComplete() { + return nil + } + // A dispatch pausing at a breakpoint shows the completion vertex it reached; + // the machine completes once resumed. + if e.stagedBreakpoint() != nil { + e.completionDue = true + return nil + } + return e.completeMachine() +} + +// completeMachine finishes the machine off its completion vertex: its exit +// behaviors run, it completes and its performance ends. +func (e *StateExecutor) completeMachine() error { + e.completionDue = false + if err := e.exitMachine(); err != nil { + return err + } + e.state = StateCompleted + e.ctx.endPerformanceLife(e.occurrence) + return nil +} + +// terminateMachine ends the machine's performance at the terminate action a +// transition reached (SysML v2 §7.18.3): no state is exited and no exit behavior +// runs; the do behaviors under way are abandoned, and no state stays active. +func (e *StateExecutor) terminateMachine(fromName string, trigger ast.Node, stop *ast.Usage) error { + name, _ := ast.EffectiveName(stop) + if e.trace() != nil { + e.trace().RecordStateTransition(e.traceOrigin(), fromName, name, triggerName(trigger)) + } + abandoned := e.abandonMachine() + if e.trace() != nil { + e.trace().RecordStateTerminate(name, abandoned) + } + e.state = StateTerminated + e.ctx.endPerformanceLife(e.occurrence) + return nil +} + +// abandonMachine leaves no state active without exiting any: the do behaviors under +// way end where they are, and their states are returned in entry order. +func (e *StateExecutor) abandonMachine() []string { + var abandoned []string + for _, act := range e.doActions { + abandoned = append(abandoned, act.state.Name) + if act.run != nil { + e.endDoRun(act.run) + act.run = nil + } + } + clear(e.doActions) + e.doActions = e.doActions[:0] + e.clearEntryState() + e.activeConfig.simpleState = nil + e.activeConfig.regionStates = make(map[*ast.StateRegion]*ast.StateNode) + e.stateStack = nil + e.completionDue = false + // Nothing dispatches on an ended machine: what it queued or deferred is discarded. + e.eventQueue.Withdraw(func(Event) bool { return true }) + e.deferred = e.deferred[:0] + clear(e.timerScheduled) + e.changeWaits = nil + e.machineExited = true + return abandoned +} + +// scheduleCompletedComposites schedules the completion transitions of each +// composite state that entering target completed, in region order. +func (e *StateExecutor) scheduleCompletedComposites(target *ast.StateNode) error { + var completed []*ast.StateNode + for _, leaf := range e.activeLeavesBelow(target) { + composite := e.completedComposite(leaf) + if composite != nil && !slices.Contains(completed, composite) { + completed = append(completed, composite) + } + } + for _, composite := range completed { + if err := e.scheduleCompletionTransitions(composite); err != nil { + return fmt.Errorf("schedule completion of state %s: %w", composite.Name, err) + } + } + return nil +} + +// completedComposite is the declared composite state that leaf, a completion +// vertex just entered, completed: the nearest one enclosing it whose body is +// now complete. It is nil when leaf completes the machine's own body or a +// region of a state still running. +func (e *StateExecutor) completedComposite(leaf *ast.StateNode) *ast.StateNode { + if !e.graph.Completes(leaf) { + return nil + } + for state := e.graph.ParentState[leaf]; state != nil; state = e.graph.ParentState[state] { + if !e.stateComplete(state) { + return nil + } + if !e.graph.HiddenStates[state] { + return state + } + } + return nil +} + +// machineComplete reports whether the machine's own body reached `done`, or +// every one of its top-level regions did. +func (e *StateExecutor) machineComplete() bool { + if len(e.graph.TopRegions) == 0 { + for _, active := range e.activeStates() { + if e.graph.Completes(active) && e.graph.ParentState[active] == nil { + return true + } + } + return false + } + for _, region := range e.graph.TopRegions { + if !e.regionComplete(region) { + return false + } + } + return true +} + +// completeInto finishes a transition into a still active ancestor: the target is +// not re-entered and the region the source left completes (PSSM 8.5.8). +func (e *StateExecutor) completeInto(trans *lower.Transition, fromName string, target *ast.StateNode) error { + e.stateStack = e.rootToLeaf(target) + if _, orthogonal := e.graph.CompositeStates[target]; orthogonal { + if e.stateComplete(target) { + if err := e.scheduleCompletionTransitions(target); err != nil { + return fmt.Errorf("schedule completion of state %s: %w", target.Name, err) + } + } + } else { + // The body completed: its history keeps no substate to restore. + if record := e.history[target]; record != nil { + record.child = nil + } + onPath := e.branchesTo(nil, target) + for region, state := range onPath { + e.activeConfig.regionStates[region] = state + } + if len(onPath) == 0 && len(e.activeConfig.regionStates) == 0 { + e.activeConfig.simpleState = target + } + if err := e.scheduleCompletionTransitions(target); err != nil { + return fmt.Errorf("schedule completion of state %s: %w", target.Name, err) + } + } + if e.trace() != nil { + e.trace().RecordStateTransition(e.traceOrigin(), fromName, target.Name, triggerName(trans.Trigger)) + } + return nil +} + +// regionComplete reports whether region rests at its own completion vertex (not a +// nested composite's `done`), or at its owner once a transition into the owner left it empty. +func (e *StateExecutor) regionComplete(region *ast.StateRegion) bool { + active, ok := e.activeConfig.regionStates[region] + if !ok { + return false + } + if e.graph.Completes(active) { + return e.graph.RegionOf[active] == region + } + return active == e.graph.RegionOwner[region] +} + +// stateComplete reports whether state's body has completed: it is a completion +// vertex, its substate rests at its `done`, or its every orthogonal region does. +func (e *StateExecutor) stateComplete(state *ast.StateNode) bool { + if state == nil { + return false + } + if e.graph.Completes(state) { + return true + } + regions := e.graph.CompositeStates[state] + if len(regions) == 0 { + for _, active := range e.activeStates() { + if e.graph.ParentState[active] == state && e.graph.Completes(active) { + return true + } + } + return false + } + for _, region := range regions { + if !e.regionComplete(region) { + return false + } + } + return true +} + +// stateCompleted reports whether an active state's completion transitions are +// enabled: its do behavior has finished and its body, where it runs one, is at `done`. +func (e *StateExecutor) stateCompleted(state *ast.StateNode) bool { + if e.hasRunningDoAction(state) { + return false + } + return !e.bodyRunning(state) || e.stateComplete(state) +} + +// bodyAhead reports whether the move entering state has yet to enter its body. +func (e *StateExecutor) bodyAhead(state *ast.StateNode) bool { + return e.entering[state] && e.hasBody(state) && !e.bodyRunning(state) +} + +// bodyRunning reports whether a state nested in state is active. +func (e *StateExecutor) bodyRunning(state *ast.StateNode) bool { + for _, active := range e.activeStates() { + if active != state && e.nestedIn(active, state) { + return true + } + } + return false +} + +// isSynchronizationTarget reports whether a transition target is a pseudostate +// that replaces the entire active configuration rather than moving one region: +// fork, join, and history. +func isSynchronizationTarget(target ast.Node) bool { + ps, ok := target.(*ast.PseudostateNode) + if !ok { + return false + } + switch ps.Kind { + case ast.PseudostateFork, ast.PseudostateJoin, + ast.PseudostateShallowHistory, ast.PseudostateDeepHistory: + return true + } + return false +} + +// passesGuard reports whether a transition's guard allows it to fire. A nil +// guard always passes. The guard resolves its names in the scope the transition +// was written in, with the machine's data shadowing it, so a live value wins +// over a same-named declaration; it is read within the transition's firing. +func (e *StateExecutor) passesGuard(trans *lower.Transition) (bool, error) { + if trans == nil || trans.Guard == nil { + return true, nil + } + val, err := e.evalTransitionStep(trans, trans.Guard, trans.BodyScope) + if err != nil { + return false, fmt.Errorf("eval guard of %s: %w", transitionDescription(trans), err) + } + if val.Kind != ValConst || val.Const.Kind != semantics.ValBool { + return false, fmt.Errorf("%w: guard of %s must be boolean, got %s", + ErrTypeMismatch, transitionDescription(trans), describeOperand(val)) + } + return val.Const.Bool, nil +} + +// transitionDescription names a transition for a diagnostic: by the name it was +// declared with, when it has one, and by the states it runs between otherwise. +func transitionDescription(trans *lower.Transition) string { + if trans.Name != "" { + return fmt.Sprintf("transition %s", trans.Name) + } + return fmt.Sprintf("transition %s -> %s", + orAny(StateVertexName(trans.Source)), orAny(StateVertexName(trans.Target))) +} + +// recordHistory returns state's history record, creating it on first use. +func (e *StateExecutor) recordHistory(state *ast.StateNode) *historyRecord { + record, ok := e.history[state] + if !ok { + record = &historyRecord{} + e.history[state] = record + } + return record +} + +// historyRecorded reports whether state was left in a configuration its history +// restores; a record emptied by completion holds none. +func (e *StateExecutor) historyRecorded(state *ast.StateNode) bool { + record := e.history[state] + return record != nil && (record.child != nil || len(record.regions) > 0) +} + +// recordChildHistory remembers the substate parent was left in for its history; +// a body left at `done` completed, and a completed configuration leaves none. +func (e *StateExecutor) recordChildHistory(parent, state *ast.StateNode) { + if e.graph.Completes(state) { + if record := e.history[parent]; record != nil { + record.child = nil + } + return + } + e.recordHistory(parent).child = state +} + +// recordRegionHistory remembers the state a region was left in for the owning +// state's history; a region left at `done` completed and is forgotten. +func (e *StateExecutor) recordRegionHistory(region *ast.StateRegion, state *ast.StateNode) { + owner := e.graph.RegionOwner[region] + if owner == nil { + return + } + if e.graph.Completes(state) { + e.forgetRegionHistory(region) + return + } + record := e.recordHistory(owner) + if record.regions == nil { + record.regions = make(map[*ast.StateRegion]*ast.StateNode) + } + record.regions[region] = state +} + +// forgetRegionHistory drops a region's recorded state, for a region left with no +// active state at all: there is nothing for a history pseudostate to restore. +func (e *StateExecutor) forgetRegionHistory(region *ast.StateRegion) { + owner := e.graph.RegionOwner[region] + if owner == nil { + return + } + if record := e.history[owner]; record != nil { + delete(record.regions, region) + } +} + +// fireHistoryTransition takes a transition into a history pseudostate: the +// composite state that owns it is re-entered in the configuration it was last +// left in. Before the state has ever been exited there is nothing to restore, so +// the history's own outgoing transition supplies the default target, as UML's +// default history transition does (UML is the reference: no SysML v2 notation); +// without one the owner is entered as any transition into it would enter it, +// through its entry transitions. +// +// A shallow history restores the substate that was active; a deep history keeps +// descending, restoring the innermost one. +func (e *StateExecutor) fireHistoryTransition(trans *lower.Transition, hist *ast.PseudostateNode, r route) error { + owner, err := e.historyOwner(hist) + if err != nil { + return err + } + currentState := e.moveOrigin() + return e.travel(trans, currentState, r, + func(*ast.StateNode) []*ast.StateNode { return e.exitedByMove(currentState, trans, owner) }, + func(*ast.StateNode) []*ast.StateNode { return e.enteredByMove(currentState, trans, owner) }, + func(effects []routeEffect, _ *ast.StateNode) error { + return e.moveToHistory(trans, currentState, effects, hist, owner) + }) +} + +// historyOwner is the composite state hist restores; a history in the machine's +// own body restores the top-level configuration, kept under the root state. +func (e *StateExecutor) historyOwner(hist *ast.PseudostateNode) (*ast.StateNode, error) { + if owner := e.graph.PseudostateOwner[hist]; owner != nil { + return owner, nil + } + if e.graph.Machine != nil && len(e.graph.TopRegions) == 0 { + return e.graph.Machine, nil + } + return nil, fmt.Errorf("history %s must be declared inside the composite state it restores", hist.Name) +} + +// historyBoundary is the state a move into owner's history stops exiting at and +// enters from: the owner's parent, or the root for the machine's own body. +func (e *StateExecutor) historyBoundary(currentState *ast.StateNode, trans *lower.Transition, owner *ast.StateNode) *ast.StateNode { + if owner == e.graph.Machine { + return nil + } + return e.moveBoundary(currentState, trans, owner) +} + +// moveToHistory finishes a move into hist: exits run first, since leaving the +// owner writes the record, then the record is read and the owner re-entered. An +// effect enclosed by a state on the way down to the owner runs as it is entered. +func (e *StateExecutor) moveToHistory(trans *lower.Transition, currentState *ast.StateNode, effects []routeEffect, hist *ast.PseudostateNode, owner *ast.StateNode) error { + fromName := "" + if currentState != nil { + fromName = currentState.Name + } + lca := e.historyBoundary(currentState, trans, owner) + leaving := e.exitPath(currentState, lca, nil) + // A record the exits leave as it is is checked before anything moves, so an + // unenterable history fails with the machine where it was. + if owner != e.graph.Machine && !slices.Contains(leaving, owner) { + if _, _, err := e.historyEntry(hist, owner); err != nil { + return err + } + } + if err := e.exitStates(leaving); err != nil { + return err + } + if err := e.runEffects(effects, e.descendantChain(lca, owner)); err != nil { + return err + } + + target, branches, err := e.historyEntry(hist, owner) + if err != nil { + return err + } + if target != nil { + return e.enterBelow(trans, fromName, lca, target, branches) + } + below := lca + if owner != e.graph.Machine { + for _, state := range e.descendantChain(lca, owner) { + if err := e.enterStateInto(state, nil, false); err != nil { + return fmt.Errorf("enter state: %w", err) + } + } + below = owner + } + r, err := e.defaultHistoryRoute(hist, below) + if err != nil { + return err + } + e.noteFired(r.segments...) + if r.terminate != nil { + return e.terminateAt(trans, fromName, r, r.effects(e.graph), e.descendantChain(below, e.graph.TerminateOwner[r.terminate])) + } + if err := e.runEffects(r.effects(e.graph), e.descendantChain(below, r.target)); err != nil { + return err + } + return e.enterBelow(trans, fromName, below, r.target, nil) +} + +// defaultHistoryRoute takes a history's default transition from inside its +// owner, drawing at once among several branches enabled, as what it notes is +// noted, and resolving any choice on the way once the effects into it have run; +// each stretch of segments is recorded as fired once its effects are done. The +// effects of the settled rest are the caller's to run and record as it enters +// below owner. +func (e *StateExecutor) defaultHistoryRoute(hist *ast.PseudostateNode, owner *ast.StateNode) (route, error) { + r, err := e.followOut(hist, route{}) + if err == nil { + r, err = e.settleDraws(r) + } + e.noteAll(r.notes) + r.notes = nil + if err != nil { + return route{}, fmt.Errorf("default transition of history %s: %w", hist.Name, err) + } + for r.choice != nil { + targets, stops, err := e.reachable(r) + if err != nil { + return route{}, err + } + var ends routeEnds + for _, target := range targets { + ends.entries = append(ends.entries, e.descendantChain(owner, target)) + } + for _, stop := range stops { + ends.entries = append(ends.entries, e.descendantChain(owner, e.graph.TerminateOwner[stop])) + } + certain := ends.certainEntries() + if err := e.runEffects(r.effects(e.graph), certain); err != nil { + return route{}, err + } + if err := e.enterOwnerOf(r.choice, certain); err != nil { + return route{}, err + } + e.noteFired(r.segments...) + if r, err = e.resolveChoice(r); err != nil { + return route{}, err + } + } + return r, nil +} + +// historyEntry is the state a move into owner's history enters and each region's +// branch, read after the exits; a nil state says to take the default transition. +func (e *StateExecutor) historyEntry(hist *ast.PseudostateNode, owner *ast.StateNode) (*ast.StateNode, map[*ast.StateRegion]*ast.StateNode, error) { + if !e.historyRecorded(owner) { + if len(e.graph.Transitions[hist]) > 0 { + return nil, nil, nil + } + if owner == e.graph.Machine || !e.hasDefaultEntry(owner) { + return nil, nil, fmt.Errorf("%w: history %s has no default transition, %s has no recorded configuration and declares no entry transition", + ErrHistoryWithoutEntry, hist.Name, owner.Name) + } + return owner, nil, nil + } + + record := e.history[owner] + deep := hist.Kind == ast.PseudostateDeepHistory + branches := make(map[*ast.StateRegion]*ast.StateNode) + + if len(record.regions) > 0 { + // The owner keeps its configuration in its regions, so it is re-entered + // with one branch per region rather than moved to a single state. + for _, region := range e.graph.CompositeStates[owner] { + active, recorded := record.regions[region] + if !recorded { + continue + } + if deep { + active = e.deepestRecorded(active, branches) + } + branches[region] = active + } + return owner, branches, nil + } + + target := record.child + if deep { + target = e.deepestRecorded(target, branches) + } + return target, branches, nil +} + +// hasDefaultEntry reports whether entering state with no branch chosen has a +// state to start in: an entry transition of its body, or of each of its regions. +func (e *StateExecutor) hasDefaultEntry(state *ast.StateNode) bool { + regions, orthogonal := e.graph.CompositeStates[state] + if !orthogonal { + return len(e.graph.StartOf(state)) > 0 + } + for _, region := range regions { + if e.graph.RegionState[region] == nil && len(e.graph.StartOf(region)) == 0 { + return false + } + } + return true +} + +// deepestRecorded follows the configuration recorded below state and returns the +// innermost state to enter, adding a branch for every orthogonal region it +// passes through so those regions are restored too. A state whose recorded +// configuration lives in regions is itself the state to enter, since its regions +// carry the rest. +func (e *StateExecutor) deepestRecorded(state *ast.StateNode, branches map[*ast.StateRegion]*ast.StateNode) *ast.StateNode { + for { + record := e.history[state] + if record == nil { + return state + } + if len(record.regions) > 0 { + for _, region := range e.graph.CompositeStates[state] { + if active, recorded := record.regions[region]; recorded { + branches[region] = e.deepestRecorded(active, branches) + } + } + return state + } + if record.child == nil { + return state + } + state = record.child + } +} + +// fireForkTransition takes a transition into a fork: every outgoing branch is +// taken at once, making one state active per orthogonal region of the composite +// state that owns them. +func (e *StateExecutor) fireForkTransition(trans *lower.Transition, fork *ast.PseudostateNode) error { + plan, err := e.forkPlan(fork) + if err != nil { + return err + } + owner := plan.Owner + e.noteFired(trans) + e.noteFired(e.graph.Transitions[fork]...) + + // Leave the source configuration down to the move's boundary, which stays + // active: states above it are neither exited nor entered again. + boundary, err := e.leaveForFork(trans, owner) + if err != nil { + return err + } + if err := e.executeBehaviors(trans.Effect); err != nil { + return fmt.Errorf("transition effect: %w", err) + } + + // The fork's own parent is entered before its branches fire; the branches + // enter the rest of the way down to the owner and their targets, bypassing + // the initial states of the regions they enter (PSSM §8.5.7). + above := e.forkEntry(boundary, owner) + if i := slices.Index(above.chain, e.graph.PseudostateOwner[fork]); i >= 0 { + if err := e.enterLazily(above, i+1); err != nil { + return err + } + } + if err := e.enterForkBranches(fork, plan, above); err != nil { + return err + } + // A region the move re-entered on its way down keeps the state of that path. + for region, state := range e.branchesTo(boundary, owner) { + if _, active := e.activeConfig.regionStates[region]; !active { + e.activeConfig.regionStates[region] = state + } + } + + if err := e.scheduleTransitionEvents(); err != nil { + return fmt.Errorf("schedule events: %w", err) + } + // Branches ending in `done` complete the owner, or the machine, at once. + if err := e.completeIfDone(owner); err != nil { + return fmt.Errorf("complete state machine: %w", err) + } + if e.trace() != nil { + e.trace().RecordStateTransition(e.traceOrigin(), StateVertexName(trans.Source), fork.Name, "") + } + return nil +} + +// leaveForFork exits the source configuration of a transition into a fork whose +// branches enter owner's regions, and returns the state the exits stopped at: the +// least common ancestor of source and owner, as for a move to a single state. +func (e *StateExecutor) leaveForFork(trans *lower.Transition, owner *ast.StateNode) (*ast.StateNode, error) { + source, _ := trans.Source.(*ast.StateNode) + region := e.activeRegionOf(source) + if region == nil { + origin := e.moveOrigin() + lca := e.moveBoundary(origin, trans, owner) + return lca, e.exitStates(e.exitPath(origin, lca, nil)) + } + sourceRegion, targetRegion := e.regionMove(region, owner) + if targetRegion == nil { + regionOwner := e.graph.RegionOwner[region] + if regionOwner == nil { + return nil, fmt.Errorf("fork into %s from region %s: the target lies outside the machine's regions", owner.Name, region.Name) + } + lca := e.getLCA(regionOwner, owner) + if lca == owner { + return owner, e.exitRegionsOf(owner) + } + return lca, e.exitRegionOwnerTo(regionOwner, lca) + } + keep := e.regionKeep(targetRegion, trans, owner) + if sourceRegion != targetRegion { + if err := e.exitRegionTo(sourceRegion, nil); err != nil { + return nil, err + } + } + return keep, e.exitRegionTo(targetRegion, keep) +} + +// exitRegionsOf leaves every region of owner, which stays active, drawn one unit +// at a time: a fork reached from inside them restarts them all from its branches. +func (e *StateExecutor) exitRegionsOf(owner *ast.StateNode) error { + var bodies []func() error + for _, region := range e.graph.CompositeStates[owner] { + if _, active := e.activeConfig.regionStates[region]; active { + bodies = append(bodies, func() error { return e.exitRegionTo(region, owner) }) + } + } + return e.performUnits(ChoiceExitOrder, exitingWherePrefix+owner.Name, bodies, true) +} + +// regionsExitPath lists the states exitRegionsOf exits. +func (e *StateExecutor) regionsExitPath(owner *ast.StateNode) []*ast.StateNode { + var exited []*ast.StateNode + for _, region := range e.graph.CompositeStates[owner] { + exited = append(exited, e.regionExitPath(region, owner)...) + } + return exited +} + +// forkPlan is where a fork's branches lead, as lowering checked and recorded it. +func (e *StateExecutor) forkPlan(fork *ast.PseudostateNode) (*lower.ForkPlan, error) { + plan := e.graph.ForkPlans[fork] + if plan == nil { + return nil, fmt.Errorf("fork %s has no lowered plan", fork.Name) + } + return plan, nil +} + +// fireJoinTransition takes a transition into a join, reporting whether the join +// fired. It fires only while the occurrence firing trans enables every other +// segment into the join, still, as it fires; until then the segment simply +// waits, and what selecting it noted is recorded only once it fires. +func (e *StateExecutor) fireJoinTransition(trans *lower.Transition, join *ast.PseudostateNode, r route) (bool, error) { + if !r.settled() { + return false, nil + } + if ready, err := e.joinSynchronized(trans, e.firingEvent); err != nil || !ready { + return false, err + } + plan, err := e.joinPlan(join) + if err != nil { + return false, err + } + + // Every incoming segment fires, then the move goes on from the state whose + // regions they left so the usual hierarchy walk exits it as well. + r.segments = r.segments[1:] + return true, e.moveWhole(func() error { + // Decided inside the move: a refused draw undoes the selection's records too. + r = e.transitionDecided(r) + if err := e.fireJoinIncoming(join, plan); err != nil { + return err + } + return e.leaveJoinOwner(plan.Owner, trans, r) + }) +} + +// joinPlan is where a join's segments come from, as lowering checked and recorded it. +func (e *StateExecutor) joinPlan(join *ast.PseudostateNode) (*lower.JoinPlan, error) { + plan := e.graph.JoinPlans[join] + if plan == nil { + return nil, fmt.Errorf("join %s has no lowered plan", join.Name) + } + return plan, nil +} + +// leaveJoinOwner finishes a join's compound transition once its segments have +// fired: the move goes on out of owner, whose regions they left, along r. +func (e *StateExecutor) leaveJoinOwner(owner *ast.StateNode, trans *lower.Transition, r route) error { + if owner == nil { + // The machine's own regions were joined: the rest of them are left too. + for _, region := range e.graph.TopRegions { + if err := e.exitRegionTo(region, nil); err != nil { + return err + } + } + e.activeConfig.simpleState = nil + return e.transitionTo(trans, r) + } + if region := e.activeRegionOf(owner); region != nil { + return e.travel(trans, owner, r, + func(target *ast.StateNode) []*ast.StateNode { return e.exitedInRegion(region, trans, target) }, + func(target *ast.StateNode) []*ast.StateNode { return e.enteredInRegion(region, trans, target) }, + func(effects []routeEffect, target *ast.StateNode) error { + return e.moveInRegion(region, owner, trans, effects, target) + }) + } + e.activeConfig.simpleState = owner + return e.transitionTo(trans, r) +} + +// joinExits lists the states firing join exits beyond its sources: the states +// between each source and the owner, then those the move out of the owner exits. +func (e *StateExecutor) joinExits(plan *lower.JoinPlan, trans *lower.Transition, r route) ([]*ast.StateNode, bool) { + var exited []*ast.StateNode + for _, segment := range e.joinIncoming(trans.Target.(*ast.PseudostateNode)) { + exited = append(exited, e.exitPath(e.joinSegmentLeaves(segment, plan), plan.Owner, nil)...) + } + if plan.Owner == nil { + for _, region := range e.graph.TopRegions { + exited = append(exited, e.regionExitPath(region, nil)...) + } + beyond, ok := e.mayExit(r, trans, nil, func(target *ast.StateNode) []*ast.StateNode { return e.exitedByMove(nil, trans, target) }) + return append(exited, beyond...), ok + } + if region := e.activeRegionOf(plan.Owner); region != nil { + beyond, ok := e.mayExit(r, trans, plan.Owner, func(target *ast.StateNode) []*ast.StateNode { return e.exitedInRegion(region, trans, target) }) + return append(exited, beyond...), ok + } + beyond, ok := e.mayExit(r, trans, plan.Owner, func(target *ast.StateNode) []*ast.StateNode { return e.exitedByMove(plan.Owner, trans, target) }) + return append(exited, beyond...), ok +} + +// fireJoinIncoming fires each transition into join whole — its source exited, +// then its effect — in an order the policy draws among their sources. +func (e *StateExecutor) fireJoinIncoming(join *ast.PseudostateNode, plan *lower.JoinPlan) error { + pending := e.joinIncoming(join) + for len(pending) > 0 { + next := 0 + if len(pending) > 1 { + sources := make([]*ast.StateNode, len(pending)) + for i, trans := range pending { + sources[i] = trans.Source.(*ast.StateNode) + } + choice := e.regionOrderChoice("join "+join.Name, sources) + if err := e.ctx.scheduling().refusal(); err != nil { + return err + } + e.noteChoice(choice) + next = choice.Taken + } + trans := pending[next] + pending = slices.Delete(pending, next, next+1) + if err := e.fireJoinSegment(trans, plan); err != nil { + return err + } + } + return nil +} + +// fireJoinSegment fires one transition into a join: its source and the states +// between it and the owner exited, innermost first, then its effect, with the +// arguments its own trigger takes from the occurrence bound; it is recorded as taken. +func (e *StateExecutor) fireJoinSegment(trans *lower.Transition, plan *lower.JoinPlan) error { + source := trans.Source.(*ast.StateNode) + defer e.taking(trans, e.firingNotes)() + if e.firingEvent != nil && trans.Trigger != nil { + unbind, err := e.bindTriggerArguments(trans, e.firingEvent) + if err != nil { + unbind() + return fmt.Errorf("state %s: %w", source.Name, err) + } + defer unbind() + } + leaving := e.joinSegmentLeaves(trans, plan) + // The region is left whole: its configuration is what a history of the owner + // restores, and the entry goes before the exits or the owner's exit walks it again. + if region := plan.Regions[trans]; region != nil { + if active, isActive := e.activeConfig.regionStates[region]; isActive { + e.recordRegionHistory(region, active) + delete(e.activeConfig.regionStates, region) + } + } + if err := e.exitStates(e.exitPath(leaving, plan.Owner, nil)); err != nil { + return err + } + if err := e.runBehaviors(trans.Effect); err != nil { + return err + } + e.noteFired(trans) + return nil +} + +// joinSegmentLeaves is the state a join segment's exit starts from: its region's +// active state when that lies below the segment's composite source, else the source. +func (e *StateExecutor) joinSegmentLeaves(segment *lower.Transition, plan *lower.JoinPlan) *ast.StateNode { + source := segment.Source.(*ast.StateNode) + if active, ok := e.activeConfig.regionStates[plan.Regions[segment]]; ok && e.isBelowOrEqual(active, source) { + return active + } + return source +} + +// joinIncoming lists the transitions into join, in source declaration order; +// joinSources has checked that each source is a state. +func (e *StateExecutor) joinIncoming(join *ast.PseudostateNode) []*lower.Transition { + var incoming []*lower.Transition + for _, state := range e.graph.States { + for _, trans := range e.graph.Transitions[state] { + if trans.Target == ast.Node(join) { + incoming = append(incoming, trans) + } + } + } + return incoming +} + +// joinSynchronized reports whether a transition is enabled as far as its target +// goes: one into a join only while every other segment into the join is enabled +// too — its source active, its guard holding, and its trigger, if it has one, +// taking the occurrence, or its source completed if it has none. Every path +// firing a join, dispatched on a signal, call, timer, completion or change, +// goes through this. +func (e *StateExecutor) joinSynchronized(trans *lower.Transition, event *Event) (bool, error) { + join, ok := trans.Target.(*ast.PseudostateNode) + if !ok || join.Kind != ast.PseudostateJoin { + return true, nil + } + if _, err := e.joinSources(join); err != nil { + return false, err + } + for _, segment := range e.joinIncoming(join) { + if segment == trans { + continue + } + if !e.inActiveConfiguration(segment.Source.(*ast.StateNode)) { + return false, nil + } + if segment.Trigger == nil { + if !e.stateCompleted(segment.Source.(*ast.StateNode)) { + return false, nil + } + } else { + takes, err := e.segmentTakes(segment, event) + if err != nil || !takes { + return false, err + } + } + pass, err := e.segmentGuardHolds(segment, event) + if err != nil || !pass { + return false, err + } + } + return true, nil +} + +// segmentTakes reports whether a join segment's trigger takes the dispatched +// occurrence. Each timer is its own occurrence, so a time-triggered segment takes +// another timer's expiry while its own timer is due: the expiries at one instant +// are one occurrence for the join, which a signal, call or completion dispatched +// then is not. +func (e *StateExecutor) segmentTakes(segment *lower.Transition, event *Event) (bool, error) { + if event == nil { + return false, nil + } + if _, isTime := segment.Trigger.(*ast.TimeEvent); isTime { + if !isTimerExpiry(*event) { + return false, nil + } + timer, running := e.eventQueue.TimerOf(segment) + return running && timer.Timestamp <= event.Timestamp, nil + } + return e.matchesEvent(segment, event) +} + +// segmentGuardHolds reads a join segment's guard with its trigger's arguments bound +// and unbound again, as transitionEnabled reads the selected transition's. +func (e *StateExecutor) segmentGuardHolds(segment *lower.Transition, event *Event) (bool, error) { + if segment.Trigger == nil || event == nil { + return e.passesGuard(segment) + } + unbind, err := e.bindTriggerArguments(segment, event) + defer unbind() + if err != nil { + return false, err + } + return e.passesGuard(segment) +} + +// allActive reports whether every state is part of the active configuration, +// itself active or enclosing an active state. +func (e *StateExecutor) allActive(states []*ast.StateNode) bool { + for _, state := range states { + if !e.inActiveConfiguration(state) { + return false + } + } + return true +} + +// joinSources returns the source state of every transition into join, in state +// declaration order. That order is observable — it is the order the branches are +// exited in — so it comes from graph.States rather than from the graph.Transitions +// map, whose iteration order varies between runs. +func (e *StateExecutor) joinSources(join *ast.PseudostateNode) ([]*ast.StateNode, error) { + incoming := e.joinIncoming(join) + sources := make([]*ast.StateNode, len(incoming)) + for i, trans := range incoming { + sources[i] = trans.Source.(*ast.StateNode) + } + for _, ps := range e.graph.Pseudostates { + for _, trans := range e.graph.Transitions[ps] { + if trans.Target == ast.Node(join) { + return nil, fmt.Errorf("join %s: incoming source must be a state, got %T", join.Name, trans.Source) + } + } + } + if len(sources) < 2 { + return nil, fmt.Errorf("join %s needs at least two incoming transitions, found %d", join.Name, len(sources)) + } + return sources, nil +} + +// isActive reports whether state is part of the active configuration. +func (e *StateExecutor) isActive(state *ast.StateNode) bool { + if e.activeConfig.simpleState == state { + return true + } + for _, active := range e.activeConfig.regionStates { + if active == state { + return true + } + } + return false +} + +// activeCompositeOwner returns the deepest composite state whose orthogonal +// regions hold the active configuration, or nil when no region is active. +func (e *StateExecutor) activeCompositeOwner() *ast.StateNode { + var deepest *ast.StateNode + depth := -1 + for _, state := range e.graph.CompositeStateOrder { + regions := e.graph.CompositeStates[state] + for _, region := range regions { + if _, active := e.activeConfig.regionStates[region]; active { + if d := len(e.getParentChain(state)); d > depth { + deepest, depth = state, d + } + break + } + } + } + return deepest +} + +// orderedRegionStates returns the active state of each orthogonal region in +// region declaration order, since exit behaviors run in the order returned. +func (e *StateExecutor) orderedRegionStates() []*ast.StateNode { + regions := e.orderedActiveRegions() + states := make([]*ast.StateNode, 0, len(regions)) + for _, region := range regions { + states = append(states, e.activeConfig.regionStates[region]) + } + return states +} + +// inActiveConfiguration reports whether state is active, either as an active +// state itself or as an ancestor of one. +func (e *StateExecutor) inActiveConfiguration(state *ast.StateNode) bool { + for _, active := range e.activeStates() { + for _, ancestor := range e.getParentChain(active) { + if ancestor == state { + return true + } + } + } + return false +} + +// RunToCompletion processes queued events until the machine completes or has no +// event or running do behavior left, at which point it suspends. A state's do +// behavior runs while the state is active: each run-to-completion step advances +// every active state's do behavior by one action and then dispatches one event, +// so concurrently active states interleave instead of one running to the end at +// entry, and leaving a state abandons the rest of its do behavior. +// +// Change conditions are re-tested per micro-step — after the do round, before +// the next queued event, and again at quiescence — a tool-defined cadence, since +// KerML has no clock (docs/project/spec-compliance.md). +// +// The run is bounded by the context's event and do action budgets +// (OPENSYSML_MAX_EVENTS, OPENSYSML_MAX_DO_STEPS), so a cyclic machine reports a typed +// error instead of spinning forever. A poll that fires nothing costs no budget; +// a change transition taken counts as one step, like a dispatched event. +func (e *StateExecutor) RunToCompletion() error { + return e.run(false) +} + +// RunToQuiescence runs the machine as RunToCompletion does, but leaves a timer +// set for later waiting on the clock rather than advancing to it: the +// configuration an object settles into is the one reached at the time it was +// materialized, and a timer it is waiting on is driven by advancing the clock. +func (e *StateExecutor) RunToQuiescence() error { + return e.run(true) +} + +// run is the run-to-completion loop; atCurrentTime holds the clock, otherwise +// once nothing is due it advances to the earliest wait, running whatever is due there. +func (e *StateExecutor) run(atCurrentTime bool) error { + var progress dueProgress + return e.runCounting(atCurrentTime, &progress) +} + +func (e *StateExecutor) runCounting(atCurrentTime bool, progress *dueProgress) (err error) { + defer e.ctx.beginExecutorRun(&e.driven)() + defer e.completedWhole(&err) + wasRunning := e.inRun + e.inRun = true + defer func() { e.inRun = wasRunning }() + + // Suspension is derived at quiescence, so re-running is allowed: a run that + // finds nothing to do suspends again. + if e.state == StateSuspended { + e.state, e.pausedAt = StateRunning, nil + } + + for e.state == StateRunning { + stepped, err := e.runUnit(progress) + if err != nil { + return err + } + if stepped { + progress.unsettle() + if e.callReleased() { + return nil + } + continue + } + if atCurrentTime { + e.state = StateSuspended + return nil + } + // Nothing left at this instant: the others due run, then the clock moves to the + // earliest wait. Only a machine with a timer of its own running moves the clock. + progress.settle(e) + for { + picked, err := e.ctx.runDue(e, progress) + if err != nil { + return err + } + // Its turn: work is due, or a change condition it watches is to be polled. + if picked || e.dueWork() { + break + } + if _, waiting := e.NextWait(); !waiting || !e.ctx.advanceToNextDue(progress) { + e.state = StateSuspended + return nil + } + } + } + return nil +} + +// dueLabel names the machine in a due-order choice. +func (e *StateExecutor) dueLabel() string { + return "state machine " + symbolText(e.stateMachine) + performerSuffix(e.self) +} + +// clockWaits lists the timers set for an instant the clock has not reached, and +// the waits the do behaviors under way are paused on. +func (e *StateExecutor) clockWaits() []ClockWait { + return notYetDue(e.armedWaits(), e.ctx.clock.now) +} + +// armedWaits lists the timers set and the do behaviors' waits on the clock, due +// or not, earliest first. +func (e *StateExecutor) armedWaits() []ClockWait { + return e.armed((*doRun).armedWaits) +} + +// visibleArmedWaits lists armedWaits and, through the do behaviors' paused work, +// the waits of the actions it performs. +func (e *StateExecutor) visibleArmedWaits() []ClockWait { + return e.armed((*doRun).visibleArmedWaits) +} + +// armed lists the timers set and, of each do behavior under way, the waits ofRun lists. +func (e *StateExecutor) armed(ofRun func(*doRun) []ClockWait) []ClockWait { + var waits []ClockWait + for _, event := range e.eventQueue.events { + trans, ok := event.Payload.(*lower.Transition) + if !ok { + continue + } + waits = append(waits, ClockWait{Due: event.Timestamp, holder: e, what: transitionWait{trans}}) + } + for _, act := range e.doActions { + if act.run == nil { + continue + } + for _, wait := range ofRun(act.run) { + waits = append(waits, ClockWait{Due: wait.Due, holder: e, what: wait.what}) + } + } + slices.SortStableFunc(waits, func(a, b ClockWait) int { return cmp.Compare(a.Due, b.Due) }) + return waits +} + +// transitionWait describes a timed transition armed on the clock. +type transitionWait struct { + trans *lower.Transition +} + +func (w transitionWait) String() string { + return fmt.Sprintf("%s -> %s", triggerName(w.trans.Trigger), StateVertexName(w.trans.Target)) +} + +// dueWork reports an event due, a signal in flight this machine takes, or a do +// action left to run, on a machine the clock may drive. +func (e *StateExecutor) dueWork() bool { + if !e.drivable() { + return false + } + return e.completionDue || len(e.held) > 0 || e.hasDueEvent() || e.hasPendingSignal() || e.HasPendingDoWork() +} + +// watchesChange reports a change condition the active configuration waits on. +func (e *StateExecutor) watchesChange() bool { + return e.drivable() && e.WatchesChangeCondition() +} + +// drivable is a machine the clock may run: initialized, not completed, and not +// paused at a breakpoint, which holds its work until its driver resumes it. +func (e *StateExecutor) drivable() bool { + return (e.state == StateRunning || e.state == StateSuspended) && e.pausedAt == nil +} + +// runDue runs the machine to quiescence at the current instant. +func (e *StateExecutor) runDue(progress *dueProgress) (bool, error) { + before := *progress + err := e.runCounting(true, progress) + return progress.events > before.events || progress.doSteps > before.doSteps, err +} + +// runOne is one unit (oneUnit) as the checker steps a machine: a run of its own, +// left suspended when nothing was there to do. +func (e *StateExecutor) runOne(progress *dueProgress) (moved bool, err error) { + defer e.ctx.beginExecutorRun(&e.driven)() + defer e.completedWhole(&err) + wasRunning := e.inRun + e.inRun = true + defer func() { e.inRun = wasRunning }() + if e.state == StateSuspended { + e.state, e.pausedAt = StateRunning, nil + } + if e.state != StateRunning { + return false, nil + } + moved, err = e.oneUnit(progress) + if err == nil && !moved { + e.state = StateSuspended + } + return moved, err +} + +// oneUnit runs one atomic unit at the current instant: one token move of a due do +// behavior, or the dispatch drawn against it (stepDue); the dispatch alone once no +// do behavior is due; false when nothing was left. +func (e *StateExecutor) oneUnit(progress *dueProgress) (moved bool, err error) { + defer e.counting(progress)() + e.resetEntering() + if len(e.held) > 0 { + return e.entryStep(progress) + } + if e.completionDue { + return true, e.completeMachine() + } + due := e.dueDoActions() + if len(due) == 0 { + return e.dispatchOne(progress) + } + return e.stepDue(due, progress) +} + +// dueDoActions lists the do actions due at the instant, in the order their states +// were entered — as runDoRound sweeps them. +func (e *StateExecutor) dueDoActions() []*doAction { + return e.dueAmong(e.doActions) +} + +// dueAmong lists those of the do actions still registered and due at the instant. +func (e *StateExecutor) dueAmong(acts []*doAction) []*doAction { + var due []*doAction + for _, act := range acts { + if act.due(e.ctx) && slices.Contains(e.doActions, act) { + due = append(due, act) + } + } + return due +} + +// counting makes progress what the unit under way counts its do steps against. +func (e *StateExecutor) counting(progress *dueProgress) func() { + prior := e.progress + e.progress = progress + return func() { e.progress = prior } +} + +// countDoStep counts one token move of a do behavior against the run's do-step budget. +func (e *StateExecutor) countDoStep() error { + e.progress.doSteps++ + if e.progress.doSteps >= e.ctx.maxDoSteps { + return budgetExceeded(ErrDoStepLimitExceeded, + fmt.Sprintf("state machine exceeded max do action steps (%d steps; raise %s to allow more), possible non-terminating do behavior", + e.ctx.maxDoSteps, MaxDoStepsEnvVar)) + } + return nil +} + +// stepDue moves one token of a due do behavior, drawn among the due ones, or — where +// a dispatch that acts is due, drawn against the move under ChoiceStepOrder — dispatches. +func (e *StateExecutor) stepDue(due []*doAction, progress *dueProgress) (bool, error) { + if dispatch := e.dueDispatch(); dispatch.acts { + dispatchNow, err := e.chooseStepOrder(due, dispatch.step) + if err != nil { + return false, err + } + if dispatchNow { + e.dispatchAmong = dispatch.among + defer func() { e.dispatchAmong = nil }() + return e.dispatchOne(progress) + } + } + next, err := e.chooseDoAction(due) + if err != nil { + return false, err + } + if err := e.stepDoAction(due[next], func(run *doRun) (*doRun, error) { return run.resume(e.ctx) }); err != nil { + return false, err + } + if err := e.countDoStep(); err != nil { + return false, err + } + return true, e.settleDoActions() +} + +// stepWherePrefix opens where a step order names its instant; dispatchTiedLabel is +// its dispatch alternative where events tied at the head get a draw of their own. +const ( + stepWherePrefix = "at t=" + dispatchTiedLabel = "dispatch" + dispatchPrefix = "dispatch " +) + +// dueDispatch is the dispatch dispatchOne would make now: a risen change, else the +// head of the queue once the signal in flight is queued, a draw of its own where +// events are tied there. +type dueDispatch struct { + due bool + label string // the dispatch as dispatchOne makes it; dispatchTiedLabel over tied events + step string // the dispatch as a step order offers it: bare over the acting tied events, else one + event *Event // the queued event at the head, when dispatching one + tied []Event // the events tied at the head, the dispatch being the draw among them + among []Event // the tied events whose dispatch acts: what a step order draws among + acts bool // whether the dispatch takes its occurrence (eventActs) +} + +// dueDispatch describes the dispatch due now; due is false when none is. +func (e *StateExecutor) dueDispatch() dueDispatch { + one := func(label string, acts bool) dueDispatch { + return dueDispatch{due: true, label: label, step: label, acts: acts} + } + if trans, risen := e.risenChange(); risen { + if trans == nil { + return one("dispatch change", true) + } + return one(dispatchPrefix+e.changeLabel(trans), true) + } + queue := e.eventQueue + if msg, ok := e.pendingSignal(); ok { + queue = e.eventQueue.With(e.signalEvent(msg)) + } else if !e.hasDueEvent() { + return dueDispatch{} + } + if tied := queue.Tied(); len(tied) >= 2 { + d := dueDispatch{due: true, label: dispatchTiedLabel, step: dispatchTiedLabel, tied: tied, among: e.actingEvents(tied)} + d.acts = len(d.among) > 0 + if len(d.among) == 1 { + d.step = dispatchPrefix + e.eventLabel(d.among[0]) + } + return d + } + head := queue.Peek() + d := one(dispatchPrefix+e.eventLabel(head), len(e.actingEvents([]Event{head})) > 0) + d.event = &head + return d +} + +// actingEvents previews which of the events a dispatch now would take (eventActs), in +// order; an error in the preview counts as acting, the dispatch being where it surfaces. +func (e *StateExecutor) actingEvents(events []Event) []Event { + var acting []Event + e.preview(func() { + for _, event := range events { + if ok, err := e.eventActs(event); err != nil || ok { + acting = append(acting, event) + } + } + }) + return acting +} + +// eventActs reports whether dispatching the event now would take it — fire a +// transition or let a do behavior parked at an accept go on — not defer or drop it. +func (e *StateExecutor) eventActs(event Event) (bool, error) { + if trans, ok := event.Payload.(*lower.Transition); ok && event.Type == EventTime { + source, _ := trans.Source.(*ast.StateNode) + if source != nil && !e.inActiveConfiguration(source) { + return false, nil + } + if trans.Trigger != nil { + if source == nil { + return e.transitionEnabled(trans, &event) + } + // The expiry acts when any member of the payload's timer group + // holds, as dispatchEvent would draw among them. + enabled, _, err := e.enabledTransitions(source, &event) + return len(enabled) > 0, err + } + for _, completion := range e.graph.Transitions[source] { + if completion.Trigger != nil { + continue + } + if ok, err := e.completionEnabled(completion); err != nil || ok { + return ok, err + } + } + return false, nil + } + selected, err := e.selectTransitions(&event) + if err != nil || len(selected) > 0 { + return len(selected) > 0, err + } + msg, ok := event.Payload.(Message) + if !ok { + return false, nil + } + taking, err := e.doBehaviorsTaking(msg, nil) + return len(taking) > 0, err +} + +// changeLabel names a change-triggered transition as eventLabel names a time event. +func (e *StateExecutor) changeLabel(trans *lower.Transition) string { + transitions := e.graph.Transitions[trans.Source] + if pos := slices.Index(transitions, trans); pos >= 0 { + return fmt.Sprintf("change %s %s", StateVertexName(trans.Source), transitionName(transitions, pos)) + } + return transitionDescription(trans) +} + +// chooseStepOrder draws what goes first under ChoiceStepOrder — the move of a due do +// behavior, or the dispatch — and reports whether the dispatch does. +func (e *StateExecutor) chooseStepOrder(due []*doAction, dispatch string) (bool, error) { + choice := ChoicePoint{ + Kind: ChoiceStepOrder, + Where: stepWherePrefix + semantics.FormatReal(e.ctx.clock.now), + Alternatives: []string{doStepLabel(e.stateNames(statesOf(due))), dispatch}, + File: e.stateMachine.DocName, + Span: e.stateMachine.DeclSpan, + } + scheduling := e.ctx.scheduling() + choice.Taken = scheduling.choose(choice, nil) + if err := scheduling.refusal(); err != nil { + return false, err + } + if choice.Taken == 0 && len(due) == 1 { + choice.Span = due[0].state.Span() + } + e.noteChoice(choice) + return choice.Taken == 1, nil +} + +// doStepLabel names the move of one of the states' do behaviors as a step order's alternative. +func doStepLabel(states []string) string { + return "do " + strings.Join(states, " or ") +} + +// dispatchOne is runStep's dispatch phase: a risen change condition fires, else +// the next due event is dispatched; false when neither is there. +func (e *StateExecutor) dispatchOne(progress *dueProgress) (bool, error) { + maxStateEvents := e.ctx.maxStateEvents + fired, err := e.pollChangeEvents() + if err != nil { + return false, fmt.Errorf("poll change conditions: %w", err) + } + if fired { + if progress.events >= maxStateEvents { + return false, e.eventBudgetExceeded(maxStateEvents) + } + progress.events++ + return true, nil + } + delivered, err := e.deliverPendingSignal() + if err != nil || !delivered { + return false, err + } + if progress.events >= maxStateEvents { + return false, e.eventBudgetExceeded(maxStateEvents) + } + progress.events++ + if err := e.processNextEvent(); err != nil { + return false, fmt.Errorf("process event: %w", err) + } + progress.noteDispatch(*e.lastDispatch) + return true, nil +} + +func (e *StateExecutor) finished() bool { return e.state.Ended() } +func (e *StateExecutor) running() bool { return e.inRun } + +// runUnit is one step of the machine's run: one unit (runOne) where a step is one +// move — under `check`, `replay` and `explore` — else one run-to-completion step. +func (e *StateExecutor) runUnit(progress *dueProgress) (bool, error) { + if e.ctx.scheduling().oneMove() { + return e.oneUnit(progress) + } + return e.runStep(progress) +} + +// runStep is one run-to-completion step (a do round, a risen change condition, +// else the next due event); false when nothing was left to do at this instant. +func (e *StateExecutor) runStep(progress *dueProgress) (bool, error) { + e.resetEntering() + if len(e.held) > 0 { + return e.entryStep(progress) + } + if e.completionDue { + return true, e.completeMachine() + } + maxStateEvents, maxDoSteps := e.ctx.maxStateEvents, e.ctx.maxDoSteps + ran, err := e.runDoRound() + if err != nil { + return false, err + } + progress.doSteps += int64(ran) + if progress.doSteps >= maxDoSteps { + return false, budgetExceeded(ErrDoStepLimitExceeded, + fmt.Sprintf("state machine exceeded max do action steps (%d steps; raise %s to allow more), possible non-terminating do behavior", + maxDoSteps, MaxDoStepsEnvVar)) + } + fired, err := e.pollChangeEvents() + if err != nil { + return false, fmt.Errorf("poll change conditions: %w", err) + } + if fired { + if progress.events >= maxStateEvents { + return false, e.eventBudgetExceeded(maxStateEvents) + } + progress.events++ + return true, nil + } + delivered, err := e.deliverPendingSignal() + if err != nil { + return false, err + } + if !delivered { + return ran > 0, nil // do behaviors still running may yet queue events + } + if progress.events >= maxStateEvents { + return false, e.eventBudgetExceeded(maxStateEvents) + } + progress.events++ + if err := e.processNextEvent(); err != nil { + return false, fmt.Errorf("process event: %w", err) + } + progress.noteDispatch(*e.lastDispatch) + return true, nil +} + +func (e *StateExecutor) eventBudgetExceeded(maxStateEvents int64) error { + return budgetExceeded(ErrStateEventLimitExceeded, + fmt.Sprintf("state machine exceeded max events (%d events; raise %s to allow more), possible infinite loop", + maxStateEvents, MaxStateEventsEnvVar)) +} + +// startDoAction registers a state's do behavior as running. Re-entering a +// state restarts its do behavior rather than resuming the abandoned one; the entry +// site under way offers the behavior's due steps against the entries left in the move. +func (e *StateExecutor) startDoAction(state *ast.StateNode) { + doBehaviors := e.behaviorsOf(state).Do + if len(doBehaviors) == 0 { + return + } + e.stopDoAction(state) + act := &doAction{ + state: state, + pending: append([]lower.StateBehavior(nil), doBehaviors...), + firing: e.currentFiring(), + } + e.doActions = append(e.doActions, act) + e.began = append(e.began, act) + if e.inFront(ChoiceEntryOrder) { + e.front.offer(act) + } +} + +// resumeEntering counts the running do behaviors of the states as begun by the move, which +// goes on entering below them. +func (e *StateExecutor) resumeEntering(states []*ast.StateNode) { + for _, act := range e.doActions { + if slices.Contains(states, act.state) && !slices.Contains(e.began, act) { + e.began = append(e.began, act) + } + } +} + +// behaviorsOf returns the lowered entry, do and exit behaviors of a state, and +// an empty set for a state lowering recorded none for. +func (e *StateExecutor) behaviorsOf(state *ast.StateNode) *lower.StateBehaviors { + if behaviors, ok := e.graph.Behaviors[state]; ok && behaviors != nil { + return behaviors + } + return &lower.StateBehaviors{} +} + +// stopDoAction abandons whatever is left of a state's do behavior, which is +// what exiting the state does to it: a behavior paused on the clock ends there. +func (e *StateExecutor) stopDoAction(state *ast.StateNode) { + kept := e.doActions[:0] + for _, act := range e.doActions { + if act.state != state { + kept = append(kept, act) + continue + } + if act.run != nil { + e.endDoRun(act.run) + act.run = nil + } + } + for i := len(kept); i < len(e.doActions); i++ { + e.doActions[i] = nil + } + e.doActions = kept +} + +// endDoRun ends a do behavior an exit abandons; while a move a refused witness +// may undo is under way, the end waits for the move to be kept (see moveMark). +func (e *StateExecutor) endDoRun(run *doRun) { + if e.moving != nil { + e.moving.ended = append(e.moving.ended, run) + return + } + run.end(e.ctx) +} + +// runDoRound advances every running do behavior with an action due by one action +// and returns how many ran. One round is how concurrently active states share +// the machine: each performs one action before any performs its next, in an +// order the policy draws (entry order by default), reported as a choice where +// two or more are due. A behavior that waits on the clock or for a message +// pauses there and is a state's action for the round its wait ends in. +func (e *StateExecutor) runDoRound() (int, error) { + if len(e.doActions) == 0 { + return 0, nil + } + due := make([]*doAction, 0, len(e.doActions)) + for _, act := range e.doActions { + if act.due(e.ctx) { + due = append(due, act) + } + } + + ran := 0 + for len(due) > 0 { + due = slices.DeleteFunc(due, func(act *doAction) bool { return !e.isRunningDoAction(act) }) + if len(due) == 0 { + break + } + next, err := e.chooseDoAction(due) + if err != nil { + return ran, err + } + act := due[next] + due = slices.Delete(due, next, next+1) + if err := e.stepDoAction(act, func(run *doRun) (*doRun, error) { return run.resume(e.ctx) }); err != nil { + return ran, err + } + ran++ + } + return ran, e.settleDoActions() +} + +// stepDoAction performs one action of a do behavior: the behavior under way goes +// on as told, else the next behavior begins. +func (e *StateExecutor) stepDoAction(act *doAction, goOn func(*doRun) (*doRun, error)) error { + e.moved = true + if e.trace() != nil { + e.trace().RecordDoStep(e.traceOrigin(), act.state.Name) + } + run := act.run + if run == nil { + behavior := act.pending[0] + act.pending = act.pending[1:] + if run = e.newDoRun(behavior, act.firing); run == nil { + return nil + } + goOn = func(run *doRun) (*doRun, error) { return run.resume(e.ctx) } + } + var err error + act.run, err = goOn(run) + if err != nil { + return fmt.Errorf("do action in state %s: %w", act.state.Name, err) + } + return nil +} + +// doBehaviorsTaking lists the do behaviors the message being dispatched lets go on: +// those parked at an accept for it, except in a state a transition chosen for it +// leaves — the transition ending the state is the message's only taker there, as +// the innermost enabled transition is among transitions, while one moving between +// the state's own substates leaves its do behavior to go on. A port failing to +// resolve on the way is the error. +func (e *StateExecutor) doBehaviorsTaking(m Message, candidates []dispatchCandidate) ([]*doAction, error) { + var taking []*doAction + for _, act := range e.doActions { + if act.run == nil || e.leftByChosen(act.state, candidates) { + continue + } + accepted, err := act.run.acceptsMessage(m) + if err != nil { + return nil, fmt.Errorf("do action in state %s: %w", act.state.Name, err) + } + if accepted { + taking = append(taking, act) + } + } + return taking, nil +} + +// leftByChosen reports whether firing the transition chosen for a candidate exits +// the state, itself or a state enclosing it; a firing that would fail counts as one. +func (e *StateExecutor) leftByChosen(state *ast.StateNode, candidates []dispatchCandidate) bool { + for _, candidate := range candidates { + exited, ok := e.exitedBy(candidate) + if !ok { + return true + } + for _, left := range exited { + if e.isBelowOrEqual(state, left) { + return true + } + } + } + return false +} + +// exitedBy lists the states firing the candidate's chosen transition along its +// route exits, or may exit while the route is open at a choice, as fireFrom exits +// them; false where the firing would fail. +func (e *StateExecutor) exitedBy(candidate dispatchCandidate) ([]*ast.StateNode, bool) { + trans, r := candidate.chosen, candidate.route + if ps, ok := trans.Target.(*ast.PseudostateNode); ok && isSynchronizationTarget(ps) { + return e.exitedBySynchronization(trans, ps, r) + } + if !r.settled() { + return nil, false + } + if region := e.activeRegionOf(candidate.source); region != nil { + source := e.activeConfig.regionStates[region] + return e.mayExit(r, trans, source, func(target *ast.StateNode) []*ast.StateNode { return e.exitedInRegion(region, trans, target) }) + } + origin := e.moveOrigin() + return e.mayExit(r, trans, origin, func(target *ast.StateNode) []*ast.StateNode { return e.exitedByMove(origin, trans, target) }) +} + +// exitedInRegion lists the states a transition out of region's active state exits +// on its way to route, as fireTransitionInRegion exits them. +func (e *StateExecutor) exitedInRegion(region *ast.StateRegion, trans *lower.Transition, route *ast.StateNode) []*ast.StateNode { + sourceRegion, targetRegion := e.regionMove(region, route) + if targetRegion == nil { + owner := e.graph.RegionOwner[region] + if owner == nil { + var exited []*ast.StateNode + for _, top := range e.graph.TopRegions { + if active, ok := e.activeConfig.regionStates[top]; ok { + exited = append(exited, e.exitPath(active, nil, nil)...) + } + } + return exited + } + lca := e.getLCA(owner, route) + if lca == route { + return e.regionsExitPath(route) + } + return e.exitPath(owner, lca, nil) + } + keep := e.regionKeep(targetRegion, trans, route) + if sourceRegion == targetRegion { + return e.regionExitPath(sourceRegion, keep) + } + return append(e.regionExitPath(sourceRegion, nil), e.regionExitPath(targetRegion, keep)...) +} + +// exitedBySynchronization lists the states a transition into a fork, join or +// history exits, as fireTransition exits them; a join not yet synchronized exits none. +func (e *StateExecutor) exitedBySynchronization(trans *lower.Transition, ps *ast.PseudostateNode, r route) ([]*ast.StateNode, bool) { + switch ps.Kind { + case ast.PseudostateFork: + plan, err := e.forkPlan(ps) + if err != nil { + return nil, false + } + return e.exitedForFork(trans, plan.Owner) + case ast.PseudostateJoin: + if !r.settled() { + return nil, true + } + if _, err := e.joinSources(ps); err != nil { + return nil, false + } + plan, err := e.joinPlan(ps) + if err != nil { + return nil, false + } + return e.joinExits(plan, trans, r) + default: + owner, err := e.historyOwner(ps) + if err != nil { + return nil, false + } + return e.exitedByMove(e.moveOrigin(), trans, owner), true + } +} + +// exitedForFork lists the states a transition into a fork whose branches enter +// owner's regions exits, as leaveForFork exits them; false where it would fail. +func (e *StateExecutor) exitedForFork(trans *lower.Transition, owner *ast.StateNode) ([]*ast.StateNode, bool) { + source, _ := trans.Source.(*ast.StateNode) + region := e.activeRegionOf(source) + if region == nil { + return e.exitedByMove(e.moveOrigin(), trans, owner), true + } + if _, targetRegion := e.regionMove(region, owner); targetRegion == nil && e.graph.RegionOwner[region] == nil { + return nil, false + } + return e.exitedInRegion(region, trans, owner), true +} + +// exitedByMove lists the states a move from current to target exits, as +// transitionToInto exits them. +func (e *StateExecutor) exitedByMove(current *ast.StateNode, trans *lower.Transition, target *ast.StateNode) []*ast.StateNode { + return e.exitPath(current, e.moveBoundary(current, trans, target), nil) +} + +// enteredByMove lists the states a move of the single active hierarchy from +// current to target enters, outermost first. +func (e *StateExecutor) enteredByMove(current *ast.StateNode, trans *lower.Transition, target *ast.StateNode) []*ast.StateNode { + return e.descendantChain(e.moveBoundary(current, trans, target), target) +} + +// enteredInRegion lists the states a transition out of region's active state +// enters on its way to target, outermost first, as moveInRegion enters them. +func (e *StateExecutor) enteredInRegion(region *ast.StateRegion, trans *lower.Transition, target *ast.StateNode) []*ast.StateNode { + _, targetRegion := e.regionMove(region, target) + if targetRegion == nil { + owner := e.graph.RegionOwner[region] + if owner == nil { + return e.rootToLeaf(target) + } + return e.descendantChain(e.getLCA(owner, target), target) + } + return e.descendantChain(e.regionKeep(targetRegion, trans, target), target) +} + +// resumeDoBehaviors lets the do behaviors taking the message being dispatched go +// on with it, before the transitions selected fire, and names the states whose +// behavior did. One an earlier behavior's step has already ended is skipped. +func (e *StateExecutor) resumeDoBehaviors(taking []*doAction, m Message) ([]string, error) { + var resumed []string + for _, act := range taking { + if !e.isRunningDoAction(act) { + continue + } + if err := e.stepDoAction(act, func(run *doRun) (*doRun, error) { return run.offer(e.ctx, m) }); err != nil { + return resumed, err + } + resumed = append(resumed, doBehaviorDescription(act.state)) + } + if len(resumed) == 0 { + return nil, nil + } + return resumed, e.settleDoActions() +} + +// doBehaviorDescription names a state's do behavior as decisions and dispatches report it. +func doBehaviorDescription(state *ast.StateNode) string { + return "do behavior of state " + StateVertexName(state) +} + +// settleDoActions drops the do behaviors that have finished and schedules the +// completion of their states; a do action whose state was exited is already gone. +func (e *StateExecutor) settleDoActions() error { + finished := make([]*ast.StateNode, 0, len(e.doActions)) + kept := e.doActions[:0] + for _, act := range e.doActions { + if !act.finished() { + kept = append(kept, act) + continue + } + finished = append(finished, act.state) + } + for i := len(kept); i < len(e.doActions); i++ { + e.doActions[i] = nil + } + e.doActions = kept + + // A state completes once its do behavior has finished and its body, where it + // runs one, has reached `done`; completeIfDone schedules the latter case, and a + // body the move has yet to enter completes nothing. + for _, state := range finished { + if e.bodyAhead(state) || (e.bodyRunning(state) && !e.stateComplete(state)) { + continue + } + if err := e.scheduleCompletionTransitions(state); err != nil { + return fmt.Errorf("schedule completion of state %s: %w", state.Name, err) + } + } + return nil +} + +// chooseDoAction resolves which of the do behaviors due in a round acts next: the +// policy draws the pick and, with several due, the choice is reported; a replay +// that cannot follow its witness here is its refusal, and none acts. +func (e *StateExecutor) chooseDoAction(due []*doAction) (int, error) { + if len(due) < 2 { + return 0, nil + } + states := make([]*ast.StateNode, len(due)) + for i, act := range due { + states[i] = act.state + } + choice := e.regionOrderChoice("do round at t="+semantics.FormatReal(e.ctx.clock.now), states) + if err := e.ctx.scheduling().refusal(); err != nil { + return 0, err + } + e.noteChoice(choice) + return choice.Taken, nil +} + +// isRunningDoAction reports whether a do action is still registered, which it +// is not once its state has been exited. +func (e *StateExecutor) isRunningDoAction(act *doAction) bool { + for _, running := range e.doActions { + if running == act { + return true + } + } + return false +} + +// hasRunningDoAction reports whether a state's do behavior is still running. +func (e *StateExecutor) hasRunningDoAction(state *ast.StateNode) bool { + for _, act := range e.doActions { + if act.state == state { + return true + } + } + return false +} + +// SendSignal injects a signal event into the state machine. +// This is the primary API for driving state machines with external signals. +// The signal is enqueued and will be processed on the next ProcessNextEvent call. +func (e *StateExecutor) SendSignal(signalType string, args map[string]Value) { + e.enqueueSignal(Message{SignalType: signalType, Payload: args}) +} + +// InvokeOperation injects a call event for the named operation, as given and +// bound to no declaration. Transitions triggered by that operation fire; +// transitions triggered by another do not. +func (e *StateExecutor) InvokeOperation(operation string, args map[string]Value) { + e.queueCall(Call{Operation: operation, Args: args}) +} + +// queueCall queues a call event carrying the payload. +func (e *StateExecutor) queueCall(payload Call) { + e.moved = true + e.enqueue(Event{ + ID: e.nextEventID, + Type: EventCall, + Timestamp: e.ctx.clock.now, + Payload: payload, + }) + e.nextEventID++ +} + +// enqueueSignal queues a message as an accept event, to fire immediately. +func (e *StateExecutor) enqueueSignal(msg Message) { + e.moved = true + e.enqueue(e.signalEvent(msg)) + e.nextEventID++ +} + +// enqueue queues ev as work the machine's next run takes. +func (e *StateExecutor) enqueue(ev Event) { + e.eventQueue.Push(ev) + e.ctx.workChanged() +} + +// signalEvent is the event enqueueSignal queues for a message in flight. +func (e *StateExecutor) signalEvent(msg Message) Event { + return Event{ + ID: e.nextEventID, + Type: EventAccept, + Timestamp: e.ctx.clock.now, + Payload: msg, + } +} + +// deliverPendingSignal reports whether an event is due, first queueing a bus +// message this machine takes: one in flight is due now, so it goes ahead of a +// timer set for later rather than after the run has advanced to it. Messages +// this machine leaves, and one whose port fails to resolve, stay in flight. +func (e *StateExecutor) deliverPendingSignal() (bool, error) { + var failed error + msg, ok := e.ctx.TakeMessage(func(m Message) bool { + if failed != nil { + return false + } + takes, err := e.takesMessage(m) + if err != nil { + failed = err + return false + } + return takes + }) + if failed != nil { + return false, failed + } + if !ok { + return e.hasDueEvent(), nil + } + e.enqueueSignal(msg) + return true, nil +} + +// takesMessage reports whether this machine is the one to take a message in +// flight: one it can react to, unless its guards would drop it while a sibling +// machine of the same object would fire on or defer it. +func (e *StateExecutor) takesMessage(m Message) (bool, error) { + if reacts, err := e.reactsTo(m); err != nil || !reacts { + return reacts, err + } + return !e.yieldsTo(m), nil +} + +// yieldsTo reports whether a machine the performer also exhibits, one that +// would fire on or defer the message where this one would only drop it, should +// take it instead. A guard error is left for the dispatch of the machine whose +// guard it is to report: this machine takes the message when its own guard +// fails, and leaves it when a sibling's does. +func (e *StateExecutor) yieldsTo(m Message) bool { + siblings := e.siblingsAccepting(m) + if len(siblings) == 0 { + return false + } + e.ctx.notePollReadsData() + if own, err := e.Decide(m); err != nil || own.Enabled() { + return false + } + for _, sibling := range siblings { + if theirs, err := sibling.Decide(m); err != nil || theirs.Enabled() { + return true + } + } + return false +} + +// siblingsAccepting lists the other machines the performer exhibits whose active +// configuration accepts the message, in the order they were attached. One whose +// accept port fails to resolve is listed too: its own dispatch reports that. +func (e *StateExecutor) siblingsAccepting(m Message) []*StateExecutor { + if e.self == nil { + return nil + } + var siblings []*StateExecutor + for _, behavior := range e.ctx.objectBehaviors { + sibling := behavior.State + if sibling == nil || sibling == e || behavior.Object != e.self { + continue + } + if accepted, err := sibling.reactsTo(m); err != nil || accepted { + siblings = append(siblings, sibling) + } + } + return siblings +} + +// reactsTo reports whether a message in flight is one this machine would act on: +// its configuration accepts or defers it, or a do behavior under way is parked at +// an accept for it, which dispatching the message lets go on with it. +func (e *StateExecutor) reactsTo(m Message) (bool, error) { + if accepted, err := e.acceptableMessage(m); err != nil || accepted { + return accepted, err + } + taking, err := e.doBehaviorsTaking(m, nil) + return len(taking) > 0, err +} + +// acceptableMessage reports whether a message in flight is one this machine can +// react to now: a transition out of the active configuration accepts it (one +// routed to a port only `via` that port), or the message reaches this machine — +// addressed to it, or at a port of the object performing it — and a state of the +// configuration defers it, to be held until a transition accepts it. Resolving +// the port a `via` names may materialize it, which can fail. +func (e *StateExecutor) acceptableMessage(m Message) (bool, error) { + if accepted, err := e.acceptsSignal(m); err != nil || accepted { + return accepted, err + } + return m.reaches(e.stateMachine.Name, m.Port, objectID(e.self)) && e.defersMessage(m), nil +} + +// defersMessage reports whether the active configuration defers a message, +// whatever route it came by, as a trigger naming no port takes it. +func (e *StateExecutor) defersMessage(m Message) bool { + event := Event{Type: EventAccept, Timestamp: e.ctx.clock.now, Payload: m} + return e.defersEvent(&event) +} + +// HasPendingSignal reports whether a signal this machine reacts to is in flight: +// one its configuration accepts or defers, or a do behavior under way is parked at +// an accept for. Such a signal is due now, unlike a queued event's timestamp: the +// next step delivers and dispatches it. +func (e *StateExecutor) HasPendingSignal() bool { + return e.hasPendingSignal() +} + +// AcceptsMessage reports whether the machine would take a message in flight: it +// reaches this machine and triggers a transition out of an active state, is +// deferred by one, or lets a do behavior parked at an accept go on. Whether a +// transition fires is decided by its guard; see Decide. Resolving the port a +// trigger accepts `via` may fail; a port it materializes on the way is discarded, +// as everything the preview builds. +func (e *StateExecutor) AcceptsMessage(m Message) (accepted bool, err error) { + e.preview(func() { accepted, err = e.reactsTo(m) }) + return accepted, err +} + +// TakesMessage previews whether delivery would let this machine take the message: +// it reacts to it and does not yield it to a sibling machine that would fire or defer. +func (e *StateExecutor) TakesMessage(m Message) (takes bool, err error) { + defer e.ctx.previewExecutorRun(&e.driven)() + e.preview(func() { takes, err = e.takesMessage(m) }) + return takes, err +} + +// TriggeredBy previews whether a transition out of an active or enclosing state is +// triggered by the message, whatever its guard; deferral and do behaviors aside. +func (e *StateExecutor) TriggeredBy(m Message) (triggered bool, err error) { + e.preview(func() { triggered, err = e.acceptsSignal(m) }) + return triggered, err +} + +// preview runs fn as a probe of the context, which beginProbe undoes whole. +func (e *StateExecutor) preview(fn func()) { + defer e.ctx.beginProbe()() + fn() +} + +// Decision is what dispatching a message now would do: the transitions that +// would fire on it, that the active state would defer it, or the do behaviors +// parked at an accept it lets go on. +type Decision struct { + Fires []string + Deferred bool + Resumes []string +} + +// Enabled reports whether dispatching the message would do something with it. +func (d Decision) Enabled() bool { + return len(d.Fires) > 0 || d.Deferred || len(d.Resumes) > 0 +} + +// Decide decides a message as the step dispatching it would — the payload bound, +// the guards evaluated against the data as it stands — without taking it. The +// machine, its data, its budget and the message are left as they were found: a +// value a guard derives on the way is derived again when dispatch reads it, and an +// object materialized on the way — to bind the payload, or by a guard reaching an +// occurrence not yet built — starts its behaviors as under dispatch, so the guard +// reads what they make of it, then is discarded along with them and anything they +// sent. So is a port materialized to tell whether the message reaches the machine +// at all. A payload or guard error is returned. Among several enabled transitions +// it names the one this machine's own run would fire, its scheduler left in place. +// A do behavior parked at an accept the message would let go on is named too. +func (e *StateExecutor) Decide(m Message) (decision Decision, err error) { + defer e.ctx.previewExecutorRun(&e.driven)() + e.preview(func() { decision, _, err = e.decide(m) }) + return decision, err +} + +// DecideTransitions is Decide reporting alongside the decision the transitions +// its Fires describe, in the same order. +func (e *StateExecutor) DecideTransitions(m Message) (decision Decision, transitions []*lower.Transition, err error) { + defer e.ctx.previewExecutorRun(&e.driven)() + e.preview(func() { decision, transitions, err = e.decide(m) }) + return decision, transitions, err +} + +// decide is Decide under the preview that discards what it builds. It selects the +// message's takers as broadcastEvent does, so the two agree. +func (e *StateExecutor) decide(m Message) (Decision, []*lower.Transition, error) { + event := Event{Type: EventAccept, Timestamp: e.ctx.clock.now, Payload: m} + accepted, err := e.acceptableMessage(m) + if err != nil { + return Decision{}, nil, err + } + var candidates []dispatchCandidate + if accepted { + selected, err := e.selectTransitions(&event) + if err != nil { + return Decision{}, nil, err + } + if candidates, err = e.chooseTransitions(selected, &event); err != nil { + return Decision{}, nil, err + } + } + taking, err := e.doBehaviorsTaking(m, candidates) + if err != nil { + return Decision{}, nil, err + } + var decision Decision + var transitions []*lower.Transition + for _, candidate := range candidates { + decision.Fires = append(decision.Fires, transitionDescription(candidate.chosen)) + transitions = append(transitions, candidate.chosen) + } + for _, act := range taking { + decision.Resumes = append(decision.Resumes, doBehaviorDescription(act.state)) + } + if accepted && !decision.Enabled() { + decision.Deferred = e.defersEvent(&event) + } + return decision, transitions, nil +} + +// Performer is the object this machine is performed by, nil for none. +func (e *StateExecutor) Performer() *Instance { + return e.self +} + +// hasPendingSignal reports whether the next step would act on a message in +// flight, without consuming it: deliver one, or report an accept port that +// fails to resolve. +func (e *StateExecutor) hasPendingSignal() bool { + _, ok := e.pendingSignal() + return ok +} + +// pendingSignal is the message in flight deliverPendingSignal would take, the +// first the machine takes; ok as hasPendingSignal, so a failing port counts. +func (e *StateExecutor) pendingSignal() (Message, bool) { + if !e.driven.settled() { + // The machine, or a behavior under it, is mid-call: its state moves between polls. + e.pending.valid = false + return e.scanPending(nil, 0) + } + memo := &e.pending + if memo.holds(e) { + if memo.ok || memo.bus.posts == e.ctx.bus.posts { + if memo.readsData { + e.ctx.notePollReadsData() + } + return memo.msg, memo.ok + } + // The bus only grew since a negative answer: the messages added are examined. + return e.scanPending(memo, memo.scanned) + } + memo.readsData = false + return e.scanPending(memo, 0) +} + +// scanPending polls the bus from its from-th message for one the machine takes, +// recording in memo, if any, the marks the answer holds under and what it read. +func (e *StateExecutor) scanPending(memo *pendingMemo, from int) (Message, bool) { + if memo != nil { + memo.valid, memo.bus, memo.writes, memo.machine = true, e.ctx.bus, e.ctx.writes, e.driven.serial + memo.msg, memo.ok, memo.scanned = Message{}, false, from + } + saved := e.ctx.polling + e.ctx.polling = memo + defer func() { + e.ctx.polling = saved + // A poll within a poll reads for the poll enclosing it too. + if saved != nil && (memo == nil || memo.readsData) { + saved.readsData = true + } + }() + messages := e.ctx.PendingMessages() + for i := from; i < len(messages); i++ { + if memo != nil { + memo.scanned = i + 1 + } + if takes, err := e.takesMessage(messages[i]); err != nil || takes { + if memo != nil { + memo.msg, memo.ok = messages[i], true + } + return messages[i], true + } + } + return Message{}, false +} + +// holds reports whether the memo still answers for e: taken, and nothing it +// depends on moved since, the objects' data counting only where the scan read it. +func (memo *pendingMemo) holds(e *StateExecutor) bool { + return memo.valid && memo.machine == e.driven.serial && memo.bus.cuts == e.ctx.bus.cuts && + (!memo.readsData || memo.writes == e.ctx.writes) +} + +// acceptsSignal reports whether any transition out of the active configuration, +// or out of a composite state enclosing it, is triggered by this signal. +func (e *StateExecutor) acceptsSignal(msg Message) (bool, error) { + for _, leaf := range e.activeStates() { + if accepted, err := e.acceptsSignalAlong(leaf, msg); err != nil || accepted { + return accepted, err + } + } + return false, nil +} + +// acceptsSignalAlong reports whether a transition out of a leaf state or a state +// enclosing it is triggered by this message's signal. Only a message of the right +// signal resolves the transition's `via` port; a failure to do so is returned. +func (e *StateExecutor) acceptsSignalAlong(leaf *ast.StateNode, msg Message) (bool, error) { + for _, trans := range e.acceptTransitionsAlong(leaf) { + if !e.triggerSignalMatches(trans.Trigger.(*ast.AcceptEvent), trans.Scope, msg) { + continue + } + reaches, err := e.transitionReached(trans, msg) + if err != nil || reaches { + return reaches, err + } + } + return false, nil +} + +// acceptTransitionsAlong lists the accept-triggered transitions out of a leaf state +// and, after them, out of each state enclosing it; the graph fixes the list. +func (e *StateExecutor) acceptTransitionsAlong(leaf *ast.StateNode) []*lower.Transition { + if along, ok := e.acceptsAlong[leaf]; ok { + return along + } + var along []*lower.Transition + for _, state := range e.getParentChain(leaf) { + for _, trans := range e.graph.Transitions[state] { + if _, ok := trans.Trigger.(*ast.AcceptEvent); ok { + along = append(along, trans) + } + } + } + if e.acceptsAlong == nil { + e.acceptsAlong = make(map[*ast.StateNode][]*lower.Transition) + } + e.acceptsAlong[leaf] = along + return along +} + +// transitionReached reports whether a message arrives where a transition's +// `via` names: the performer's port, or the port of an object the machine's +// data binds the path's root to, as an action's accept resolves its via. +func (e *StateExecutor) transitionReached(trans *lower.Transition, msg Message) (bool, error) { + holder, port, err := e.triggerEval(trans.Scope).viaHolder(trans.Via, trans.ViaSelf, e.self) + if err != nil { + return false, err + } + return e.ctx.messageReaches(msg, e.stateMachine.Name, port, holder) +} + +// triggerSignalMatches reports whether the message carries the signal an +// accept trigger names, by type or by the event it subsets. +func (e *StateExecutor) triggerSignalMatches(accept *ast.AcceptEvent, scope *symbols.Scope, msg Message) bool { + if typed := ast.AsQualifiedName(accept.SignalType); typed != nil && len(typed.Parts) > 0 { + return e.ctx.messageMatches(msg, typed, scope) + } + return lower.FeaturePath(accept.Subsets) != "" && e.triggerEval(scope).carriesEvent(msg, accept.Subsets) +} + +// triggerEval evaluates what a trigger declared in scope names, over the +// machine's data. +func (e *StateExecutor) triggerEval(scope *symbols.Scope) *EvalContext { + ec := NewEvalContextIn(e.ctx, scope, e.self) + ec.inBehaviorBody = true + ec.Push(e.stateData) + return ec +} + +// activeStates returns the states currently active, in region declaration +// order: one per region for a composite configuration, otherwise the single +// active state. +func (e *StateExecutor) activeStates() []*ast.StateNode { + if len(e.activeConfig.regionStates) > 0 { + return e.orderedRegionStates() + } + if current := e.getCurrentState(); current != nil { + return []*ast.StateNode{current} + } + return nil +} + +// initialize sets current state to initial state and enters it. +func (e *StateExecutor) initialize() (err error) { + defer e.ctx.beginExecutorRun(&e.driven)() + defer e.completedWhole(&err) + defer e.unfireOnError(len(e.fired), &err) + defer e.counting(&dueProgress{})() + e.ctx.beginPerformanceLife(e.occurrence, e.ctx.newActivation()) + e.resetEntering() + e.enteringMachine = true + + // A machine without orthogonal regions of its own starts in the state its + // body's entry transitions choose, then in that state's own start, and so on. + if len(e.graph.TopRegions) == 0 { + if len(e.graph.StartOf(nil)) == 0 { + return fmt.Errorf("%w in state machine %s", ErrNoInitialState, e.stateMachine.Name) + } + if err := e.enterMachine(); err != nil { + return fmt.Errorf("enter state machine: %w", err) + } + e.state = StateRunning + return e.enterMachineStart() + } + + if e.graph.Machine != nil { + if err := e.enterMachine(); err != nil { + return fmt.Errorf("enter state machine: %w", err) + } + } + + e.state = StateRunning + e.activeConfig.regionStates = make(map[*ast.StateRegion]*ast.StateNode) + e.activeConfig.simpleState = nil + return e.enterMachineRegions() +} + +// enterMachine runs the machine's own entry behaviors and starts its do behavior. +func (e *StateExecutor) enterMachine() error { + if err := e.executeBehaviors(e.behaviorsOf(e.graph.Machine).Entry); err != nil { + return fmt.Errorf("entry action: %w", err) + } + e.startDoAction(e.graph.Machine) + return nil +} + +// enterMachineStart enters the machine body's serial start cascade. +func (e *StateExecutor) enterMachineStart() error { + start, err := e.startIn(nil) + if err != nil { + return err + } + e.setCurrentState(start) + e.stateStack = e.rootToLeaf(start) + for _, state := range e.stateStack { + if err := e.enterStateInto(state, nil, state == start); err != nil { + return fmt.Errorf("enter state %s: %w", state.Name, err) + } + } + leaf, err := e.enterStartOf(start) + if err != nil { + return err + } + return e.settleEntered(leaf) +} + +// enterMachineRegions enters the machine's orthogonal top-level regions. +func (e *StateExecutor) enterMachineRegions() error { + if err := e.enterRegionsInto(nil, e.graph.TopRegions, nil); err != nil { + return err + } + if err := e.scheduleTransitionEvents(); err != nil { + return fmt.Errorf("schedule events: %w", err) + } + for _, region := range e.graph.TopRegions { + if err := e.completeIfDone(e.activeConfig.regionStates[region]); err != nil { + return fmt.Errorf("complete state machine: %w", err) + } + if e.state.Ended() { + break + } + } + return nil +} + +// startIn chooses the state owner's body starts in: the target of the first +// transition out of its entry action whose guard holds, in declaration order. +// It is nil when the body declares none; when none holds, that is an error. +func (e *StateExecutor) startIn(owner ast.Node) (*ast.StateNode, error) { + transitions := e.graph.StartOf(owner) + for _, entry := range transitions { + holds, err := e.entryGuardHolds(owner, entry) + if err != nil { + return nil, err + } + if holds { + if entry.Decl != nil { + e.fired = append(e.fired, FiredTransition{Decl: entry.Decl, Target: entry.Target, Owner: e.graph.EntryOwner(owner)}) + } + return entry.Target, nil + } + } + if len(transitions) > 0 { + return nil, fmt.Errorf("%w: %s declares %d transitions out of its entry action and the guard of none holds", + ErrNoEntryTransitionHolds, e.describeBody(owner), len(transitions)) + } + return nil, nil +} + +// entryGuardHolds evaluates an entry transition's guard in the body it is +// written in, reading the attributes of the state that owns that body. +func (e *StateExecutor) entryGuardHolds(owner ast.Node, entry *lower.EntryTransition) (bool, error) { + if entry.Guard == nil { + return true, nil + } + val, err := e.evalStepOf(e.bodyState(owner), entry.Guard, entry.Scope) + if err != nil { + return false, fmt.Errorf("eval guard of the entry transition into %s: %w", entry.Target.Name, err) + } + if val.Kind != ValConst || val.Const.Kind != semantics.ValBool { + return false, fmt.Errorf("guard of the entry transition into %s must be boolean, got %v", entry.Target.Name, val.Kind) + } + return val.Const.Bool, nil +} + +// bodyState is the state whose attributes a body's entry transitions read: the +// state itself, a region's owner, or nil for the machine's own body. +func (e *StateExecutor) bodyState(owner ast.Node) ast.Node { + switch body := owner.(type) { + case *ast.StateNode: + return body + case *ast.StateRegion: + if state := e.graph.RegionOwner[body]; state != nil { + return state + } + } + return nil +} + +// describeBody names the body an entry transition is written in for a diagnostic. +func (e *StateExecutor) describeBody(owner ast.Node) string { + switch body := owner.(type) { + case *ast.StateNode: + return fmt.Sprintf("state %s", body.Name) + case *ast.StateRegion: + if state := e.graph.RegionOwner[body]; state != nil { + return fmt.Sprintf("region %s of state %s", body.Name, state.Name) + } + return fmt.Sprintf("region %s", body.Name) + } + return fmt.Sprintf("state machine %s", e.stateMachine.Name) +} + +// enterStartOf enters the state a just-entered state's body starts in, and that +// state's own start below it, returning the innermost state entered. A state +// whose substates are orthogonal regions has entered them already. +func (e *StateExecutor) enterStartOf(state *ast.StateNode) (*ast.StateNode, error) { + leaf := state + for { + if _, orthogonal := e.graph.CompositeStates[leaf]; orthogonal { + return leaf, nil + } + if e.heldOwner(leaf) != nil { + return leaf, nil + } + if len(e.graph.StartOf(leaf)) > 0 { + held, err := e.holdEntry(leaf, nil, nil) + if err != nil { + return nil, err + } + if held { + return leaf, nil + } + if err := e.unitAhead(ChoiceEntryOrder, e.startHead(leaf, leaf)); err != nil { + return nil, err + } + } + start, err := e.startIn(leaf) + if err != nil { + return nil, err + } + if start == nil { + return leaf, nil + } + for _, descendant := range e.descendantChain(leaf, start) { + if err := e.enterStateInto(descendant, nil, descendant == start); err != nil { + return nil, fmt.Errorf("enter state %s: %w", descendant.Name, err) + } + } + leaf = start + } +} + +// exitMachine stops the machine's own do behavior and runs its exit behaviors +// once, when the machine completes. +func (e *StateExecutor) exitMachine() error { + if e.graph.Machine == nil || e.machineExited { + return nil + } + e.machineExited = true + e.clearEntryState() + e.stopDoAction(e.graph.Machine) + if err := e.executeBehaviors(e.behaviorsOf(e.graph.Machine).Exit); err != nil { + return fmt.Errorf("exit action: %w", err) + } + return nil +} + +func (e *StateExecutor) clearEntryState() { + e.held = e.held[:0] + clear(e.entering) + e.enteringMachine = false + clear(e.activeAtEntry) +} + +// descendantChain returns the states from ancestor's child down to leaf, +// outermost first, excluding ancestor itself. A nil ancestor returns the whole +// chain from leaf's outermost ancestor down; leaf alone is returned when +// ancestor is neither nil nor one of its ancestors. +func (e *StateExecutor) descendantChain(ancestor, leaf *ast.StateNode) []*ast.StateNode { + if leaf == nil { + return nil + } + chain := e.getParentChain(leaf) // leaf .. root + if ancestor == nil { + return e.rootToLeaf(leaf) + } + for i, state := range chain { + if state == ancestor { + descendants := make([]*ast.StateNode, 0, i) + for j := i - 1; j >= 0; j-- { + descendants = append(descendants, chain[j]) + } + return descendants + } + } + return []*ast.StateNode{leaf} +} + +// enterStateInto enters state, starting each of its orthogonal regions at branches[region] or at +// its initial state; last says the entry path ends at state, whose completion its entry unit queues. +func (e *StateExecutor) enterStateInto(state *ast.StateNode, branches map[*ast.StateRegion]*ast.StateNode, last bool) error { + if state == nil { + return nil + } + leaf := last && e.completesAtEntry(state) + if e.entryIsUnit(state) && !e.enteredAhead[state] { + if _, err := e.unit(ChoiceEntryOrder, e.entryHead(state, leaf)); err != nil { + return err + } + } + if err := e.activateState(state); err != nil { + return err + } + + // The do behavior runs while the state is active, from its entry on: alongside the + // entries of its substates and the do behaviors of the states active with it. + e.startDoAction(state) + + if regions, isComposite := e.graph.CompositeStates[state]; isComposite { + if held, err := e.holdEntry(state, regions, branches); err != nil { + return err + } else if held { + return nil + } + if err := e.enterRegionsInto(state, regions, branches); err != nil { + return err + } + } + + // The completion goes into the pool behind those queued by the entries performed + // before this one (PSSM §8.5.9); time triggers are scheduled once the move settles. + if leaf { + if err := e.scheduleCompletionTransitions(state); err != nil { + return fmt.Errorf("schedule completion of state %s: %w", state.Name, err) + } + } + return nil +} + +// activateState makes state active and runs its entry behaviors, leaving its +// orthogonal regions, if any, to be entered and its do behavior to be started. +func (e *StateExecutor) activateState(state *ast.StateNode) error { + if !e.activatedAhead(state) { + if err := e.performEntry(state); err != nil { + return err + } + } + + // A composite of orthogonal regions is represented by their active states; + // entries of other composites' regions are left alone. A simple state is the + // single active state only outside any region. + if _, isComposite := e.graph.CompositeStates[state]; isComposite { + e.activeConfig.simpleState = nil + } else if len(e.activeConfig.regionStates) == 0 { + e.activeConfig.simpleState = state + } + return nil +} + +// resetEntering starts an entry unit and snapshots the configuration active before it. +func (e *StateExecutor) resetEntering() { + e.entering = make(map[*ast.StateNode]bool) + e.enteringMachine = false + e.began, e.path = nil, pathDraw{} + e.activeAtEntry = make(map[*ast.StateNode]bool) + for _, active := range e.activeStates() { + for _, state := range e.getParentChain(active) { + e.activeAtEntry[state] = true + } + } +} + +// performEntry records the entry of state and performs its entry behaviors. +func (e *StateExecutor) performEntry(state *ast.StateNode) error { + if !e.activeAtEntry[state] { + e.entering[state] = true + } + // Change watches are created fresh per activation, so a condition that stayed + // true rises again; the firing transition keeps its latch so the entry it + // caused does not re-enable it. + for _, trans := range e.graph.Transitions[state] { + if trans != e.firingChange { + delete(e.changeFired, trans) + if e.changeRearmed != nil { + e.changeRearmed[trans] = true + } + } + } + + if e.breakpointNodes[state] && e.breakpointHit == nil { + e.breakpointHit = state + } + + if !e.graph.HiddenStates[state] { + // Track state visit + e.stateVisits = append(e.stateVisits, state.Name) + + // Record trace + if e.trace() != nil { + e.trace().RecordStateEntry(e.traceOrigin(), state.Name, len(e.behaviorsOf(state).Entry) > 0) + } + } + + // Execute entry actions + if err := e.executeBehaviors(e.behaviorsOf(state).Entry); err != nil { + return fmt.Errorf("entry action: %w", err) + } + return nil +} + +// exitState executes exit behaviors when leaving a state. +func (e *StateExecutor) exitState(state *ast.StateNode) error { + if state == nil { + return nil + } + e.held = slices.DeleteFunc(e.held, func(item heldEntry) bool { + if item.owner == state { + delete(e.entering, item.owner) + return true + } + for current := item.owner; current != nil; current = e.graph.ParentState[current] { + if current == state { + delete(e.entering, item.owner) + return true + } + } + return false + }) + + // A state's timers are destroyed when it is left: the event one already queued + // is withdrawn, so re-entering the state times a fresh interval. + timed := make(map[*lower.Transition]bool) + for _, trans := range e.graph.Transitions[state] { + delete(e.timerScheduled, trans) + if _, isTime := trans.Trigger.(*ast.TimeEvent); isTime { + timed[trans] = true + } + } + if len(timed) > 0 { + e.eventQueue.Withdraw(func(event Event) bool { + trans, ok := event.Payload.(*lower.Transition) + return ok && timed[trans] + }) + } + + // Check if this state is a composite state with regions + regions, isComposite := e.graph.CompositeStates[state] + + // The active configuration of every composite state lives in one map, so only + // this state's own regions may be recorded or torn down here: touching the + // whole map would stop the regions of an enclosing composite state too. + active := make(map[*ast.StateRegion]*ast.StateNode, len(regions)) + for _, region := range regions { + if regionState, isActive := e.activeConfig.regionStates[region]; isActive { + active[region] = regionState + } + } + + // Remember the configuration being left, so a history pseudostate owned by + // this state or by its parent can restore it. + for region, regionState := range active { + if regionState != state { + e.recordRegionHistory(region, regionState) + } + } + if parent := e.graph.ParentState[state]; parent != nil { + e.recordChildHistory(parent, state) + } else if e.graph.Machine != nil && e.graph.RegionOf[state] == nil && !e.graph.HiddenStates[state] { + e.recordChildHistory(e.graph.Machine, state) + } + + // Exit the active state of each of this state's regions, drawn one unit at a time. + if isComposite { + if err := e.exitRegionsBelow(state, regions, active); err != nil { + return err + } + } + + if e.leftAhead[state] { + e.activeConfig.simpleState = nil + return nil + } + if e.exitingAhead { + e.leftAhead[state] = true + } + if e.exitIsUnit(state) { + if _, err := e.unit(ChoiceExitOrder, unitHead{label: e.exitLabel(state), at: state, silent: e.silentExit(state)}); err != nil { + return err + } + } + + // Leaving the state abandons whatever is left of its do behavior, before the + // exit behavior runs. + e.stopDoAction(state) + + // Record trace + if !e.graph.HiddenStates[state] && e.trace() != nil { + e.trace().RecordStateExit(e.traceOrigin(), state.Name, len(e.behaviorsOf(state).Exit) > 0) + } + + // Execute exit actions + if err := e.executeBehaviors(e.behaviorsOf(state).Exit); err != nil { + return fmt.Errorf("exit action: %w", err) + } + + // Clear simple state + e.activeConfig.simpleState = nil + + return nil +} + +// exitRegionsBelow exits each region's active state and the states up to owner as queues of +// an exit front, drawn one unit at a time, each region's innermost first. +func (e *StateExecutor) exitRegionsBelow(owner *ast.StateNode, regions []*ast.StateRegion, active map[*ast.StateRegion]*ast.StateNode) error { + var bodies []func() error + for _, region := range regions { + regionState, isActive := active[region] + if !isActive { + continue + } + // Clear the entry first: the recursive exit walks the same map, and the + // region still pointing at regionState would exit it a second time. + delete(e.activeConfig.regionStates, region) + bodies = append(bodies, func() error { + for current := regionState; current != nil && current != owner; current = e.graph.ParentState[current] { + if err := e.exitState(current); err != nil { + return fmt.Errorf("exit region state: %w", err) + } + } + return nil + }) + } + return e.performUnits(ChoiceExitOrder, exitingWherePrefix+owner.Name, bodies, true) +} + +// invokeNested performs an action from a state's entry/exit/effect behavior, +// passing state data in through the callee's input parameters and merging its +// output parameters back into state data. +func (e *StateExecutor) invokeNested(inv actionInvocation) error { + _, outputs, err := invokeAction(e.ctx, e.stateMachine.Scope, inv, e.stateData, e.self) + if err != nil { + return err + } + for _, name := range slices.Sorted(maps.Keys(outputs)) { + e.recordCallOutput(name, outputs[name]) + if err := e.writeStateValue(name, outputs[name]); err != nil { + return err + } + } + return nil +} + +// writeStateValue writes a value a performed action returned to the machine's +// attribute of that name, or to its state data where it declares none. +func (e *StateExecutor) writeStateValue(name string, value Value) error { + e.moved = true + if e.declaresAttribute(name) { + return e.assignAttribute(name, value) + } + e.stateData[name] = value + return nil +} + +// stateActionName names a state action in diagnostics, falling back to what it +// references when the usage is anonymous (`entry a.b;`). This is deliberately +// not ast.EffectiveName: a diagnostic names the whole path written, `a.b`, +// where the effective name is just the feature named, `b`. +func stateActionName(u *ast.Usage) string { + if u.Ident.Name != "" { + return u.Ident.Name + } + for _, rel := range u.Relationships { + if rel.Kind != ast.RelReferences && rel.Kind != ast.RelTyping { + continue + } + switch target := rel.Target.(type) { + case *ast.QualifiedName: + return target.Text() + case *ast.FeatureChainExpr: + return "feature chain " + target.Member.Text() + } + } + return "" +} + +// --- Public accessor methods for REPL debugging --- + +// CurrentState returns the current active state node. +func (e *StateExecutor) CurrentState() ast.Node { + // For backward compatibility: return simple state if no regions active + if e.activeConfig.simpleState != nil { + return e.activeConfig.simpleState + } + // If multi-region, return nil (caller should check regions individually) + return nil +} + +// ActiveStates returns the machine's active state configuration: the single +// active state, or one state per orthogonal region, in declaration order. +func (e *StateExecutor) ActiveStates() []*ast.StateNode { + return e.activeStates() +} + +// ActiveLeaves returns the innermost active states, one per active region, in +// region order; the composite states enclosing them are active too. +func (e *StateExecutor) ActiveLeaves() []*ast.StateNode { + return e.activeLeaves() +} + +// OutgoingTransitions lists the transitions dispatch could select now: those +// out of each active leaf and then of each state enclosing it, innermost first +// as dispatch tries them, each in declaration order and a shared enclosing +// state's once. +func (e *StateExecutor) OutgoingTransitions() []*lower.Transition { + var out []*lower.Transition + listed := make(map[*ast.StateNode]bool) + for _, leaf := range e.activeLeaves() { + for _, source := range e.getParentChain(leaf) { + if listed[source] { + continue + } + listed[source] = true + out = append(out, e.graph.Transitions[source]...) + } + } + return out +} + +// GetStateVisits returns the ordered list of visited state names. +func (e *StateExecutor) GetStateVisits() []string { + return e.stateVisits +} + +// FinalStateName is the active configuration as a conformance case writes it: +// the orthogonal regions' active states joined by "+" in region name order, +// regions of one name in declaration order. +func (e *StateExecutor) FinalStateName() string { + if regions := e.orderedActiveRegions(); len(regions) > 0 { + sort.SliceStable(regions, func(i, j int) bool { return regions[i].Name < regions[j].Name }) + names := make([]string, len(regions)) + for i, region := range regions { + names[i] = e.activeConfig.regionStates[region].Name + } + return strings.Join(names, "+") + } + if state, ok := e.CurrentState().(*ast.StateNode); ok { + return state.Name + } + return "" +} + +// getCurrentState returns the active simple state (nil if multi-region). +func (e *StateExecutor) getCurrentState() *ast.StateNode { + return e.activeConfig.simpleState +} + +// setCurrentState sets the active simple state (for non-region states). +func (e *StateExecutor) setCurrentState(state *ast.StateNode) { + e.activeConfig.simpleState = state + e.activeConfig.regionStates = make(map[*ast.StateRegion]*ast.StateNode) // Clear regions +} + +// StateStack returns a copy of the state stack (active configuration). +func (e *StateExecutor) StateStack() []*ast.StateNode { + stack := make([]*ast.StateNode, len(e.stateStack)) + copy(stack, e.stateStack) + return stack +} + +// StateData returns a copy of state machine local data, together with the +// attributes each state owns under that state's path (`nested.hits`), which two +// usages of one state definition hold separately. +func (e *StateExecutor) StateData() map[string]Value { + data := make(map[string]Value, len(e.stateData)) + for k, v := range e.stateData { + data[k] = v + } + for state, attrs := range e.stateAttrs { + prefix := e.statePath(state) + "." + for name, value := range attrs { + data[prefix+name] = value + } + } + return data +} + +// statePath is a state's name qualified by the states enclosing it. +func (e *StateExecutor) statePath(state *ast.StateNode) string { + chain := e.getParentChain(state) + parts := make([]string, 0, len(chain)) + for i := len(chain) - 1; i >= 0; i-- { + parts = append(parts, chain[i].Name) + } + return strings.Join(parts, ".") +} + +// EnclosingStates returns the states written around state, outermost first; +// the owners lowering synthesizes for regions are not among them. +func (e *StateExecutor) EnclosingStates(state *ast.StateNode) []*ast.StateNode { + chain := e.getParentChain(state) + out := make([]*ast.StateNode, 0, len(chain)) + for i := len(chain) - 1; i >= 1; i-- { + if e.graph.HiddenStates[chain[i]] { + continue + } + out = append(out, chain[i]) + } + return out +} + +// StatePath is state's name qualified by the states written around it (`on.run`). +func (e *StateExecutor) StatePath(state *ast.StateNode) string { + enclosing := e.EnclosingStates(state) + parts := make([]string, 0, len(enclosing)+1) + for _, s := range enclosing { + parts = append(parts, s.Name) + } + return strings.Join(append(parts, state.Name), ".") +} + +// trace returns the recorder this executor's context is attached to, so turning +// reporting on or off reaches an execution already under way. +func (e *StateExecutor) trace() *TraceRecorder { + return e.ctx.trace +} + +// noteChoice keeps a choice point this machine drew, as made by its object. +func (e *StateExecutor) noteChoice(choice ChoicePoint) { + e.ctx.noteFrom(choice, e.self, e.stateMachine) +} + +// noteAll keeps notes this machine made, in order, as made by its object. +func (e *StateExecutor) noteAll(notes []RunNote) { + for _, n := range notes { + e.ctx.noteFrom(n, e.self, e.stateMachine) + } +} + +// traceOrigin is where this machine's trace records are made: the clock now, the +// object performing the machine and the machine itself. +func (e *StateExecutor) traceOrigin() TraceOrigin { + return TraceOrigin{At: e.ctx.clock.now, Object: e.self, Behavior: e.stateMachine} +} + +// SetTrace sets the trace recorder for this executor and the context it +// evaluates in. +func (e *StateExecutor) SetTrace(trace *TraceRecorder) { + e.ctx.SetTrace(trace) +} + +// EventQueue returns the event queue (not copied - read-only access). +func (e *StateExecutor) EventQueue() *EventQueue { + return e.eventQueue +} + +// DeferredEvents returns the events the active configuration holds deferred, in +// the order they were deferred; they return to the queue once no active state +// defers them. +func (e *StateExecutor) DeferredEvents() []Event { + return append([]Event(nil), e.deferred...) +} + +// CurrentTime returns the current instant of the clock this machine shares with +// every executor of its context, in seconds. +func (e *StateExecutor) CurrentTime() float64 { + return e.ctx.clock.now +} + +// LastEventAt returns the instant the machine last dispatched an event at, zero +// before it dispatched any. +func (e *StateExecutor) LastEventAt() float64 { + return e.lastEventAt +} + +// NextWait returns the instant the machine's earliest wait — a timer, or a do +// behavior paused on the clock — comes due at, false when none is set for later +// than the clock's instant. +func (e *StateExecutor) NextWait() (float64, bool) { + waits := e.clockWaits() + if len(waits) == 0 { + return 0, false + } + return waits[0].Due, true +} + +// Release withdraws a machine its driver is done with from the clock, ending the +// do behaviors paused on it; safe to call more than once. +func (e *StateExecutor) Release() { + for _, act := range e.doActions { + if act.run != nil { + act.run.end(e.ctx) + act.run = nil + } + } + e.ctx.clock.detach(e) +} + +// Resume returns a machine suspended at quiescence to running, so a driver that +// makes work available — advancing time, or delivering an event — can step it +// again. A completed or failed machine is left as it is; one paused on its +// completion vertex completes as the next step. +func (e *StateExecutor) Resume() bool { + if e.state != StateSuspended { + return false + } + e.state, e.pausedAt = StateRunning, nil + return true +} + +// SetBreakpointAt pauses a run once the dispatch entering the state, or passing +// through the pseudostate, completes, even one leaving the state again; what is +// still due then waits until the machine resumes. Other nodes are ignored. +func (e *StateExecutor) SetBreakpointAt(vertex ast.Node) { + switch v := vertex.(type) { + case *ast.StateNode: + if v != nil { + e.breakpointNodes[v] = true + } + case *ast.PseudostateNode: + if v != nil { + e.breakpointNodes[v] = true + } + } +} + +// ClearBreakpoints removes every breakpoint; a pause already reached stands. +func (e *StateExecutor) ClearBreakpoints() { + e.breakpointNodes = make(map[ast.Node]bool) +} + +// PausedAt is the breakpoint state or pseudostate the last run paused at, nil +// when none did. +func (e *StateExecutor) PausedAt() ast.Node { + return e.pausedAt +} + +// CompletionDue reports a machine paused on its completion vertex by a +// breakpoint: entering it completed the machine, which the next step finishes. +func (e *StateExecutor) CompletionDue() bool { + return e.completionDue +} + +// Suspend parks a running machine back at quiescence, for a driver that resumed +// it, found nothing to do and must not report it as running. +func (e *StateExecutor) Suspend() bool { + if e.state != StateRunning { + return false + } + e.state = StateSuspended + return true +} + +// State returns current execution state. +func (e *StateExecutor) State() ExecutionState { + return e.state +} + +// StateMachineSymbol returns the state machine being executed. +func (e *StateExecutor) StateMachineSymbol() *symbols.Symbol { + return e.stateMachine +} + +// Graph is the lowered state graph the executor runs, which a debugger reads to +// place the active configuration and the transitions it fired in a rendering. +func (e *StateExecutor) Graph() *lower.StateGraph { + return e.graph +} + +// ProcessNextEvent processes the next event from the queue (for REPL stepping). +// It is the same step RunToCompletion repeats: every active state's do behavior +// advances by one action, then the next event is dispatched. Advancing the do +// behaviors is progress in itself, so a step that ran one and found no event to +// dispatch succeeds — the completion transition it enables is queued next. +// With nothing due but a timer running, the shared clock advances to the earliest +// wait (running whatever else is due there) until this machine's next event is due. +func (e *StateExecutor) ProcessNextEvent() (err error) { + defer e.ctx.beginExecutorRun(&e.driven)() + defer e.completedWhole(&err) + + e.lastDispatch = nil + var progress dueProgress + defer e.counting(&progress)() + e.resetEntering() + if len(e.held) > 0 { + _, err := e.entryStep(&progress) + return err + } + if e.completionDue { + return e.completeMachine() + } + for { + ran, err := e.runDoRound() + if err != nil { + return err + } + // A condition risen in the do round is taken here, as RunToCompletion does. + fired, err := e.pollChangeEvents() + if err != nil { + return fmt.Errorf("poll change conditions: %w", err) + } + if fired { + return nil + } + // A signal sent by a behavior sharing this context is dispatched by the same + // step RunToCompletion takes, so stepping and running agree. + delivered, err := e.deliverPendingSignal() + if err != nil { + return err + } + if !delivered && ran > 0 { + return nil + } + if _, waiting := e.NextWait(); delivered || !waiting { + return e.processNextEvent() + } + // Polled and found nothing: settled until another executor gets somewhere. + progress.settle(e) + if err := e.awaitClock(&progress); err != nil { + return err + } + } +} + +// completedWhole makes a call of its own run that completed the machine return +// the refusal of the witness moves left over, when the call itself did not fail. +func (e *StateExecutor) completedWhole(err *error) { + e.endedByOccurrence(err) + if *err == nil && e.state.Ended() { + *err = e.ctx.endedWhole(&e.driven) + } +} + +// awaitClock runs what is due, then moves the clock to the earliest wait, until this +// machine's turn comes (work due, or its change condition to poll) or no wait is left. +func (e *StateExecutor) awaitClock(progress *dueProgress) error { + for { + picked, err := e.ctx.runDue(e, progress) + if err != nil { + return err + } + if picked || e.dueWork() || !e.ctx.advanceToNextDue(progress) { + return nil + } + } +} + +// hasDueEvent reports whether an event the run may dispatch at the current +// instant is queued: one set for later is not yet due. +func (e *StateExecutor) hasDueEvent() bool { + return e.eventQueue.Len() > 0 && e.eventQueue.Peek().Timestamp <= e.ctx.clock.now +} + +// HasDueEvent reports whether an event due at the current instant is queued, +// which a run holding time where it is dispatches. +func (e *StateExecutor) HasDueEvent() bool { + return e.hasDueEvent() +} + +// HasPendingWork reports whether stepping the machine can still make progress: +// an event is queued, a signal this machine accepts is in flight, or a state's +// do behavior has actions left to run. +func (e *StateExecutor) HasPendingWork() bool { + return e.completionDue || len(e.held) > 0 || e.eventQueue.Len() > 0 || len(e.doActions) > 0 || e.hasPendingSignal() +} + +// RunDoRound advances every active state's do behavior by one action, without +// dispatching any event, and reports how many actions ran. +func (e *StateExecutor) RunDoRound() (ran int, err error) { + defer e.ctx.beginExecutorRun(&e.driven)() + defer e.completedWhole(&err) + + if len(e.held) > 0 { + return 0, nil + } + return e.runDoRound() +} + +// HasPendingDoWork reports whether some active state's do behavior still has an +// action to run. Such work is due now, unlike a queued event's timestamp. +func (e *StateExecutor) HasPendingDoWork() bool { + for _, act := range e.doActions { + if act.due(e.ctx) { + return true + } + } + return false +} diff --git a/internal/core/runtime/state_executor_test.go b/internal/exec/runtime/state_executor_test.go similarity index 96% rename from internal/core/runtime/state_executor_test.go rename to internal/exec/runtime/state_executor_test.go index 2dfc065306..0a167c0268 100644 --- a/internal/core/runtime/state_executor_test.go +++ b/internal/exec/runtime/state_executor_test.go @@ -3,13 +3,13 @@ package runtime import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) func TestStateExecutor_Creation(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Create minimal state machine symbol stateMachine := &symbols.Symbol{ @@ -45,7 +45,7 @@ func TestStateExecutor_Creation(t *testing.T) { } func TestStateExecutor_Initialize(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build state machine: initialState → finalState initialState := &ast.StateNode{Name: "initial"} @@ -90,7 +90,7 @@ func TestStateExecutor_Initialize(t *testing.T) { } func TestStateExecutor_Initialize_NoInitialState(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // State machine without initial state stateMachine := &symbols.Symbol{ @@ -121,7 +121,7 @@ func TestStateExecutor_Initialize_NoInitialState(t *testing.T) { } func TestStateExecutor_EntryBehavior(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // State with entry action: entry { x = 42 } initialState := &ast.StateNode{ @@ -160,7 +160,7 @@ func TestStateExecutor_EntryBehavior(t *testing.T) { } func TestStateExecutor_ExitBehavior(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Two states with exit behavior on first stateA := &ast.StateNode{ @@ -210,7 +210,7 @@ func TestStateExecutor_ExitBehavior(t *testing.T) { } func TestStateExecutor_TimeEvent(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // State machine: stateA --[after 10]-> stateB stateA := &ast.StateNode{ @@ -288,7 +288,7 @@ func TestStateExecutor_TimeEvent(t *testing.T) { // `accept at t` names an instant, so its event fires at t rather than t after // the state was entered. func TestStateExecutor_AbsoluteTimeEvent(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // stateA --[after 10]-> stateB --[at 15]-> stateC stateA := &ast.StateNode{Name: "stateA"} @@ -339,7 +339,7 @@ func TestStateExecutor_AbsoluteTimeEvent(t *testing.T) { } func TestStateExecutor_ChangeEvent(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // State machine: stateA --[when x > 5]-> stateB stateA := &ast.StateNode{ @@ -408,7 +408,7 @@ func TestStateExecutor_ChangeEvent(t *testing.T) { } func TestStateExecutor_GuardCondition(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // State machine: stateA --[after 1][x > 5]-> stateB stateA := &ast.StateNode{ @@ -492,7 +492,7 @@ func TestStateExecutor_GuardCondition(t *testing.T) { // Integration Tests func TestStateExecutor_Integration_SimpleTransitions(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // State machine: idle → working → done, where `done` completes the machine idle := &ast.StateNode{Name: "idle"} @@ -565,7 +565,7 @@ func TestStateExecutor_Integration_SimpleTransitions(t *testing.T) { } func TestStateExecutor_Integration_TransitionEffects(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // State machine with transition effect: stateA --[after 1 / counter++]-> stateB stateA := &ast.StateNode{Name: "stateA"} @@ -623,7 +623,7 @@ func TestStateExecutor_Integration_TransitionEffects(t *testing.T) { } func TestStateExecutor_HierarchicalStates(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build hierarchical state machine: // composite (parent) @@ -704,7 +704,7 @@ func TestStateExecutor_HierarchicalStates(t *testing.T) { } func TestStateExecutor_HierarchicalEntryExit(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build state machine: // parentState (entry: val=1, exit: val=10) @@ -820,7 +820,7 @@ func TestStateExecutor_HierarchicalEntryExit(t *testing.T) { } func TestStateExecutor_StateStackTracking(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Build state machine: // composite @@ -907,7 +907,7 @@ func TestStateExecutor_StateStackTracking(t *testing.T) { } func TestStateExecutor_Integration_HierarchicalWorkflow(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // Complex hierarchical state machine: // workflow (composite) @@ -1119,7 +1119,7 @@ func TestStateExecutor_Integration_HierarchicalWorkflow(t *testing.T) { // Task 47: Traffic light state machine - realistic TimeEvent demo func TestStateExecutor_Integration_TrafficLight(t *testing.T) { - ctx := NewContext(NewModel(semantics.NewModel(nil), nil), 1000) + ctx := NewContext(typedModel(semantics.NewModel(nil), nil), 1000) // States: red (initial, 30s) → green (25s) → yellow (5s) → off (final) // Total cycle: 30 + 25 + 5 = 60 seconds diff --git a/internal/exec/runtime/state_region_entry.go b/internal/exec/runtime/state_region_entry.go new file mode 100644 index 0000000000..4dd12b09e0 --- /dev/null +++ b/internal/exec/runtime/state_region_entry.go @@ -0,0 +1,211 @@ +package runtime + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// regionEntry is one orthogonal region's share of entering its container. +type regionEntry struct { + region *ast.StateRegion + container *ast.StateNode + branches map[*ast.StateRegion]*ast.StateNode + target *ast.StateNode // where the region starts instead of its own start, if anywhere +} + +// lazyEntry is the chain of states a fork's branches still have to enter down to +// owner, the composite whose regions they enter. +type lazyEntry struct { + chain []*ast.StateNode + next int + owner *ast.StateNode + branches map[*ast.StateRegion]*ast.StateNode // region the chain passes through → the state it passes into +} + +// forkEntry describes the way from boundary, which stays active, down to the +// composite whose regions a fork's branches enter. +func (e *StateExecutor) forkEntry(boundary, owner *ast.StateNode) *lazyEntry { + return &lazyEntry{ + chain: e.descendantChain(boundary, owner), + owner: owner, + branches: e.branchesTo(boundary, owner), + } +} + +// enterRegionsInto activates one state per orthogonal region of container: the +// state branches names for that region, or the region's own start. container is +// nil for the machine's own regions. +func (e *StateExecutor) enterRegionsInto(container *ast.StateNode, regions []*ast.StateRegion, branches map[*ast.StateRegion]*ast.StateNode) error { + entries := make([]*regionEntry, 0, len(regions)) + for _, region := range regions { + entries = append(entries, ®ionEntry{region: region, container: container, branches: branches, target: branches[region]}) + } + return e.enterRegions(container, entries, true) +} + +// enterRegions enters the regions as queues of an entry front (the one under way, waited +// for when wait says, or a front of their own), one unit drawn at a time. +func (e *StateExecutor) enterRegions(container *ast.StateNode, entries []*regionEntry, wait bool) error { + bodies := make([]func() error, len(entries)) + for i, entry := range entries { + bodies[i] = func() error { return e.enterRegion(entry) } + } + where := enteringWherePrefix + e.stateMachine.Name + if container != nil { + where = enteringWherePrefix + container.Name + } + return e.performUnits(ChoiceEntryOrder, where, bodies, wait) +} + +// enterForkBranches enters the owner's regions as one queue per region: the branch's effect, +// the way down (entered once, by the first drawn), then its target; unentered regions start as usual. +func (e *StateExecutor) enterForkBranches(fork *ast.PseudostateNode, plan *lower.ForkPlan, above *lazyEntry) error { + targets := plan.Targets() + regions := e.graph.CompositeStates[plan.Owner] + bodies := make([]func() error, 0, len(regions)) + for _, region := range regions { + entry := ®ionEntry{region: region, container: plan.Owner, branches: targets} + branch := plan.Branches[region] + if branch != nil { + entry.target = branch.Target.(*ast.StateNode) + } + bodies = append(bodies, func() error { + if branch == nil { + if err := e.await(ChoiceEntryOrder, func() bool { return above.next >= len(above.chain) }); err != nil { + return err + } + } else if err := e.runBranchEffect(branch); err != nil { + return err + } + if err := e.enterShared(above); err != nil { + return err + } + return e.enterRegion(entry) + }) + } + return e.drawUnits(ChoiceEntryOrder, forkWherePrefix+fork.Name, bodies) +} + +// enterShared enters the rest of the way down the fork's branches share, each +// state a unit every branch heads until one of them enters it. +func (e *StateExecutor) enterShared(l *lazyEntry) error { + for i := l.next; i < len(l.chain); i++ { + state := l.chain[i] + perform := true + if e.entryIsUnit(state) { + head := e.entryHead(state, false) + head.shared, head.dropped = state, func() bool { return l.next > i } + var err error + if perform, err = e.unit(ChoiceEntryOrder, head); err != nil { + return err + } + } + if perform && l.next <= i { + if err := e.enterLazily(l, i+1); err != nil { + return err + } + } + } + return nil +} + +// runBranchEffect executes a fork branch's effect, if any, as one unit. +func (e *StateExecutor) runBranchEffect(branch *lower.Transition) error { + if branch == nil { + return nil + } + if _, err := e.unit(ChoiceEntryOrder, unitHead{label: e.effectLabel(branch), at: branch.Decl, silent: len(branch.Effect) == 0}); err != nil { + return err + } + if err := e.executeBehaviors(branch.Effect); err != nil { + return fmt.Errorf("fork branch effect: %w", err) + } + return nil +} + +// enterRegion enters one region down to the state it starts in, as a transition does; a start +// its guards decide is drawn before they are read, so they read what earlier units wrote. +func (e *StateExecutor) enterRegion(w *regionEntry) error { + if w.target == nil && e.graph.RegionState[w.region] == nil && len(e.graph.StartOf(w.region)) > 0 { + if err := e.unitAhead(ChoiceEntryOrder, e.startHead(w.region, w.container)); err != nil { + return err + } + } + entry, err := e.regionStart(w) + if err != nil { + return err + } + e.activeConfig.regionStates[w.region] = entry + _, deepest, err := e.enterToward(w.container, entry, w.branches) + if err != nil { + return fmt.Errorf("enter starting state in region %s: %w", w.region.Name, err) + } + // The deepest state the region keeps active is the one on the way down that + // its own substates declare. + if branch, ok := e.branchesTo(nil, deepest)[w.region]; ok { + e.activeConfig.regionStates[w.region] = branch + } + return nil +} + +// startHead names the unit entering where body starts: the first state below above when the +// start transition has no guard, else the start itself, its state known once guards are read. +func (e *StateExecutor) startHead(body ast.Node, above *ast.StateNode) unitHead { + starts := e.graph.StartOf(body) + if len(starts) > 0 && starts[0].Guard == nil { + target := starts[0].Target + for _, state := range e.descendantChain(above, target) { + if e.entryIsUnit(state) { + return e.entryHead(state, state == target && e.completesAtEntry(state)) + } + } + } + return unitHead{label: "start of " + e.describeBody(body), at: body, site: e.bodySite(body)} +} + +// regionStart is the state a region starts in: the target it was given, the +// substate of a parallel state it stands for, or its own entry transitions' choice. +func (e *StateExecutor) regionStart(w *regionEntry) (*ast.StateNode, error) { + if w.target != nil { + return w.target, nil + } + if owner := e.graph.RegionState[w.region]; owner != nil { + return owner, nil + } + entry, err := e.startIn(w.region) + if err != nil { + return nil, err + } + if entry == nil { + return nil, fmt.Errorf("region %s has no initial state", w.region.Name) + } + return entry, nil +} + +// enterLazily enters the way down as far as the first upto states of the chain, each do +// behavior begun at its state's entry; the regions off the chain start as usual, on a front. +func (e *StateExecutor) enterLazily(l *lazyEntry, upto int) error { + for ; l.next < upto; l.next++ { + state := l.chain[l.next] + if err := e.activateState(state); err != nil { + return fmt.Errorf("enter state %s: %w", state.Name, err) + } + e.startDoAction(state) + if state == l.owner { + continue + } + var others []*regionEntry + for _, region := range e.graph.CompositeStates[state] { + if _, onWay := l.branches[region]; onWay { + continue + } + others = append(others, ®ionEntry{region: region, container: state, branches: l.branches}) + } + if err := e.enterRegions(state, others, false); err != nil { + return fmt.Errorf("enter state %s: %w", state.Name, err) + } + } + return nil +} diff --git a/internal/core/runtime/state_region_transition.go b/internal/exec/runtime/state_region_transition.go similarity index 80% rename from internal/core/runtime/state_region_transition.go rename to internal/exec/runtime/state_region_transition.go index d65b50c7e9..95e56410bc 100644 --- a/internal/core/runtime/state_region_transition.go +++ b/internal/exec/runtime/state_region_transition.go @@ -3,8 +3,8 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // regionContains reports whether state is declared in region or nested below a @@ -62,6 +62,33 @@ func (e *StateExecutor) entryPlan(lca, target *ast.StateNode) (*ast.StateNode, m return target, nil } +// enterToward enters the states below lca down to target and target's start; a +// region below target starts where branches says. Returns the entered and deepest states. +func (e *StateExecutor) enterToward(lca, target *ast.StateNode, branches map[*ast.StateRegion]*ast.StateNode) (entered, deepest *ast.StateNode, err error) { + enter, plan := e.entryPlan(lca, target) + if plan == nil { + plan = branches + } else { + for region, state := range branches { + plan[region] = state + } + } + for _, state := range e.descendantChain(lca, enter) { + if err := e.enterStateInto(state, plan, state == enter); err != nil { + return nil, nil, fmt.Errorf("enter state: %w", err) + } + } + if enter != target { + if e.heldOwner(enter) != nil { + return enter, enter, nil + } + // Entering enter's regions entered target and its start; record the deepest. + return enter, e.activeConfig.regionStates[e.enclosingRegion(target)], nil + } + deepest, err = e.enterStartOf(target) + return deepest, deepest, err +} + // activeLeavesBelow returns the deepest active states inside state — one per // active orthogonal region, recursively — which are the states whose outgoing // transitions have to be scheduled after entering it. @@ -74,6 +101,9 @@ func (e *StateExecutor) activeLeavesBelow(state *ast.StateNode) []*ast.StateNode for _, region := range regions { active, ok := e.activeConfig.regionStates[region] if !ok || active == state { + if !ok { + return []*ast.StateNode{state} + } continue } leaves = append(leaves, e.activeLeavesBelow(active)...) @@ -96,34 +126,36 @@ func (e *StateExecutor) scheduleFromEntered(state *ast.StateNode) error { // an orthogonal region. A target inside the same region moves only that region; // a target outside it leaves the whole region set, which is what makes a // transition through a choice or junction reachable from inside a region. -func (e *StateExecutor) fireTransitionInRegion(region *ast.StateRegion, trans *lower.Transition, r route) (bool, error) { +func (e *StateExecutor) fireTransitionInRegion(region *ast.StateRegion, trans *lower.Transition, r route) (fired bool, err error) { // Fork, join and history replace the entire active configuration rather than // move one region, so they are fired whole. if isSynchronizationTarget(trans.Target) { return e.fireTransition(trans, r) } + defer e.unfireOnError(len(e.fired), &err) pass, err := e.passesGuard(trans) if err != nil || !pass { return false, err } - e.transitionDecided() + r = e.transitionDecided(r) if !r.settled() { return false, fmt.Errorf("transition out of region %s has no target state", region.Name) } source := e.activeConfig.regionStates[region] - return true, e.travel(r, + return true, e.travel(trans, source, r, func(target *ast.StateNode) []*ast.StateNode { return e.exitedInRegion(region, trans, target) }, - func(effects []lower.StateBehavior, target *ast.StateNode) error { + func(target *ast.StateNode) []*ast.StateNode { return e.enteredInRegion(region, trans, target) }, + func(effects []routeEffect, target *ast.StateNode) error { return e.moveInRegion(region, source, trans, effects, target) }) } // moveInRegion finishes a move out of region's active state source: within the // region, across to a concurrent one, or out of the whole region set. -func (e *StateExecutor) moveInRegion(region *ast.StateRegion, source *ast.StateNode, trans *lower.Transition, effects []lower.StateBehavior, target *ast.StateNode) error { +func (e *StateExecutor) moveInRegion(region *ast.StateRegion, source *ast.StateNode, trans *lower.Transition, effects []routeEffect, target *ast.StateNode) error { sourceRegion, targetRegion := e.regionMove(region, target) if targetRegion == nil { return e.leaveRegion(region, trans, effects, target) @@ -236,7 +268,7 @@ func (e *StateExecutor) moveBetweenRegions( sourceRegion, targetRegion *ast.StateRegion, source *ast.StateNode, trans *lower.Transition, - effects []lower.StateBehavior, + effects []routeEffect, target *ast.StateNode, ) error { keep := e.regionKeep(targetRegion, trans, target) @@ -256,11 +288,13 @@ func (e *StateExecutor) moveBetweenRegions( } } - if err := e.runBehaviors(effects); err != nil { + if err := e.runEffects(effects, e.descendantChain(keep, target)); err != nil { return err } + if keep == target { + return e.completeInto(trans, source.Name, target) + } - enter, branches := e.entryPlan(keep, target) // The region's active state is the deepest state on the path to target that the // region itself declares, which is a composite state above target when the // target is nested inside one. @@ -271,14 +305,9 @@ func (e *StateExecutor) moveBetweenRegions( // The region's own entry is recorded before entering, so a state entered // inside it is not mistaken for the single active state of a simple machine. e.activeConfig.regionStates[targetRegion] = leaf - for _, state := range e.descendantChain(keep, enter) { - if err := e.enterStateInto(state, branches); err != nil { - return fmt.Errorf("enter state: %w", err) - } - } // The target's own entry transitions may start it in a nested state, which // then is the deepest state the region keeps active. - deepest, err := e.enterStartOf(target) + _, deepest, err := e.enterToward(keep, target, nil) if err != nil { return err } @@ -306,12 +335,21 @@ func (e *StateExecutor) regionKeep(targetRegion *ast.StateRegion, trans *lower.T if declared, isState := trans.Source.(*ast.StateNode); isState && e.encloses(declared, target) { keep = e.graph.ParentState[declared] } - if !e.regionContains(targetRegion, keep) { + if !e.regionKeeps(targetRegion, keep) { keep = e.graph.RegionOwner[targetRegion] } return keep } +// regionKeeps reports whether state stays active when a move happens within +// region: a state inside it, or the graph-only owner standing for the region. +func (e *StateExecutor) regionKeeps(region *ast.StateRegion, state *ast.StateNode) bool { + if state == nil { + return false + } + return e.regionContains(region, state) || e.graph.RegionState[region] == state +} + // regionExitPath lists the states exitRegionTo exits, innermost first: region's // active state and its ancestors up to stop, within the region. func (e *StateExecutor) regionExitPath(region *ast.StateRegion, stop *ast.StateNode) []*ast.StateNode { @@ -347,7 +385,7 @@ func (e *StateExecutor) exitRegionTo(region *ast.StateRegion, stop *ast.StateNod // source is active in — outside the composite state that owns the regions. The // whole set is left: every sibling region is exited, recording its configuration // for history, before the target is entered. -func (e *StateExecutor) leaveRegion(region *ast.StateRegion, trans *lower.Transition, effects []lower.StateBehavior, target *ast.StateNode) error { +func (e *StateExecutor) leaveRegion(region *ast.StateRegion, trans *lower.Transition, effects []routeEffect, target *ast.StateNode) error { source := e.activeConfig.regionStates[region] owner := e.graph.RegionOwner[region] if owner == nil { @@ -359,6 +397,37 @@ func (e *StateExecutor) leaveRegion(region *ast.StateRegion, trans *lower.Transi // exits its regions' active states, as exiting a KerML StatePerformance ends // its subperformances. lca := e.getLCA(owner, target) + between := e.exitPath(owner, lca, nil) + if lca == target { + // The target is an active ancestor: the region is left, the states between + // are exited, and the target's region they were in rests at the target. + if err := e.exitRegionTo(region, nil); err != nil { + return err + } + e.activeConfig.regionStates[region] = owner + } + if err := e.exitRegionOwnerTo(owner, lca); err != nil { + return err + } + if lca == target && len(between) > 0 { + if declaring := e.declaringRegion(between[len(between)-1]); declaring != nil { + // The region completed: its history keeps no state to restore. + e.forgetRegionHistory(declaring) + e.activeConfig.regionStates[declaring] = target + } + } + if err := e.runEffects(effects, e.descendantChain(lca, target)); err != nil { + return err + } + if lca == target { + return e.completeInto(trans, source.Name, target) + } + return e.enterOutside(trans, source, lca, target) +} + +// exitRegionOwnerTo exits owner, whose regions hold the active configuration, +// and its ancestors up to lca, which stays active. +func (e *StateExecutor) exitRegionOwnerTo(owner, lca *ast.StateNode) error { for _, current := range e.exitPath(owner, lca, nil) { // Clear the region current is active in first — a region's active state may // be nested below current — or an enclosing state exits current again. @@ -372,34 +441,35 @@ func (e *StateExecutor) leaveRegion(region *ast.StateRegion, trans *lower.Transi return fmt.Errorf("exit state: %w", err) } } - if err := e.runBehaviors(effects); err != nil { - return err - } - return e.enterOutside(trans, source, lca, target) + return nil } // leaveTopRegions leaves the machine's own orthogonal regions, which no state // owns: every region is exited in declaration order and the target — outside // all of them — is then entered as the machine's single active state. -func (e *StateExecutor) leaveTopRegions(trans *lower.Transition, effects []lower.StateBehavior, source, target *ast.StateNode) error { +func (e *StateExecutor) leaveTopRegions(trans *lower.Transition, effects []routeEffect, source, target *ast.StateNode) error { + var bodies []func() error for _, region := range e.graph.TopRegions { active, ok := e.activeConfig.regionStates[region] if !ok { continue } delete(e.activeConfig.regionStates, region) - leaving := make([]*ast.StateNode, 0) - for current := active; current != nil; current = e.graph.ParentState[current] { - leaving = append(leaving, current) - } - if err := e.exitStates(leaving); err != nil { - return err - } + bodies = append(bodies, func() error { + leaving := make([]*ast.StateNode, 0) + for current := active; current != nil; current = e.graph.ParentState[current] { + leaving = append(leaving, current) + } + return e.exitStates(leaving) + }) + } + if err := e.performUnits(ChoiceExitOrder, exitingWherePrefix+e.stateMachine.Name, bodies, true); err != nil { + return err } e.activeConfig.regionStates = make(map[*ast.StateRegion]*ast.StateNode) e.activeConfig.simpleState = nil - if err := e.runBehaviors(effects); err != nil { + if err := e.runEffects(effects, e.descendantChain(nil, target)); err != nil { return err } @@ -409,13 +479,7 @@ func (e *StateExecutor) leaveTopRegions(trans *lower.Transition, effects []lower // enterOutside enters target below lca after the region set the transition left // has been torn down, and finishes the move. func (e *StateExecutor) enterOutside(trans *lower.Transition, source, lca, target *ast.StateNode) error { - enter, branches := e.entryPlan(lca, target) - for _, state := range e.descendantChain(lca, enter) { - if err := e.enterStateInto(state, branches); err != nil { - return fmt.Errorf("enter state: %w", err) - } - } - deepest, err := e.enterStartOf(target) + entered, deepest, err := e.enterToward(lca, target, nil) if err != nil { return err } @@ -432,13 +496,7 @@ func (e *StateExecutor) enterOutside(trans *lower.Transition, source, lca, targe } e.stateStack = e.rootToLeaf(deepest) - // Scheduling starts from the deepest state entered when the path descends - // through no orthogonal regions; otherwise their recorded leaves are used. - scheduleFrom := enter - if enter == target { - scheduleFrom = deepest - } - if err := e.scheduleFromEntered(scheduleFrom); err != nil { + if err := e.scheduleFromEntered(entered); err != nil { return err } if err := e.completeIfDone(deepest); err != nil { @@ -467,7 +525,7 @@ func (e *StateExecutor) recordTransitionTrace(trans *lower.Transition, source, t if source != nil { from = source.Name } - e.trace().RecordStateTransition(from, target.Name, triggerName(trans.Trigger)) + e.trace().RecordStateTransition(e.traceOrigin(), from, target.Name, triggerName(trans.Trigger)) } // orderedActiveRegions returns the active orthogonal regions in declaration diff --git a/internal/exec/runtime/state_route.go b/internal/exec/runtime/state_route.go new file mode 100644 index 0000000000..e972a9fbde --- /dev/null +++ b/internal/exec/runtime/state_route.go @@ -0,0 +1,902 @@ +package runtime + +import ( + "fmt" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// transientPseudostate reports whether a pseudostate merely routes a transition +// onwards — choice and junction — as opposed to fork, join and history, which +// rewrite the whole active configuration. +func transientPseudostate(kind ast.PseudostateKind) bool { + switch kind { + case ast.PseudostateChoice, ast.PseudostateJunction: + return true + } + return false +} + +// route is a compound transition's path as far as it is settled: the segments to +// run, the transition first, ending at a state, open at a choice whose guards +// are read only once those segments' effects have run, or open at a junction +// several of whose branches its guards enabled, drawn among once the transition +// fires. None is set for a fork, a waiting join or a history restoring. +type route struct { + segments []*lower.Transition + target *ast.StateNode + choice *ast.PseudostateNode + draw *junctionDraw + // crossed are the pseudostates passed, so a path back into one is a cycle. + crossed []*ast.PseudostateNode + // notes is what settling the route noted — a branch guard it could not read, + // a junction drawn among several enabled — recorded once the transition is taken. + notes []RunNote + // terminate is the terminate action usage the route ends at, which ends the + // machine's performance in place of entering a state (SysML v2 §7.18.3). + terminate *ast.Usage +} + +// junctionDraw is a junction the route reached with several branches enabled, as +// their guards read when the transition was selected, and the route on from each +// as settled then; the policy draws among them only once the transition is +// committed to fire, so a transition another region's reaction leaves behind +// draws nothing. +type junctionDraw struct { + at *ast.PseudostateNode + outgoing []*lower.Transition + enabled []int + // beyond is the route on from each enabled branch, in enabled's order. + beyond []branchBeyond +} + +// branchBeyond is the route on from one enabled branch, or why it could not be +// settled — a dead end only the run that draws the branch runs into. +type branchBeyond struct { + route route + err error +} + +// settled reports whether the route has somewhere to move to. +func (r route) settled() bool { + return r.target != nil || r.choice != nil || r.draw != nil || r.terminate != nil +} + +// routeEffect is one effect of a compound transition and the state declaring the +// pseudostate its segment leaves; nil for a segment out of a state or the machine's body. +type routeEffect struct { + behavior lower.StateBehavior + within *ast.StateNode + segment *lower.Transition +} + +// effects are the behaviors the route's segments perform, in path order, each +// with the state enclosing it. +func (r route) effects(g *lower.StateGraph) []routeEffect { + var effects []routeEffect + for _, seg := range r.segments { + var within *ast.StateNode + if ps, isPseudostate := seg.Source.(*ast.PseudostateNode); isPseudostate { + within = g.PseudostateOwner[ps] + } + for _, behavior := range seg.Effect { + effects = append(effects, routeEffect{behavior: behavior, within: within, segment: seg}) + } + } + return effects +} + +// resolveRoute settles a transition's route before anything moves: its target, or +// on through junctions or a join, up to the first choice; a history stays unsettled. +// The guards along it are read within trans's performance, as its own guard is. +func (e *StateExecutor) resolveRoute(trans *lower.Transition) (route, error) { + defer e.taking(trans, e.firingNotes)() + r := route{segments: []*lower.Transition{trans}} + switch target := trans.Target.(type) { + case *ast.StateNode: + r.target = target + return r, nil + case *ast.PseudostateNode: + switch target.Kind { + case ast.PseudostateFork, ast.PseudostateShallowHistory, ast.PseudostateDeepHistory: + return r, nil + case ast.PseudostateJoin: + sources, err := e.joinSources(target) + if err != nil { + return route{}, err + } + if !e.allActive(sources) { + return r, nil + } + } + r, err := e.followOut(target, r) + if err != nil { + return route{}, fmt.Errorf("evaluate pseudostate: %w", err) + } + return r, nil + case *ast.Usage: + if lower.IsTerminateUsage(target) { + r.terminate = target + return r, nil + } + } + return route{}, fmt.Errorf("transition target must be a state, pseudostate or terminate action, got %T", trans.Target) +} + +// followOut goes on from a pseudostate the route has reached: a choice leaves the +// route open there; out of any other — junction, join or history — the branches +// are read against the data as they stand, the one enabled is taken and several +// leave the route open at the draw among them. Exactly one succession is taken, +// as KerML `DecisionPerformance::outgoingHBLink: HappensBefore[1]` requires. +// A route that cannot be settled is returned as far as it got, with its notes. +func (e *StateExecutor) followOut(ps *ast.PseudostateNode, r route) (route, error) { + if slices.Contains(r.crossed, ps) { + return r, fmt.Errorf("%s %s: outgoing transitions form a cycle between pseudostates", ps.Kind, ps.Name) + } + r.crossed = append(r.crossed, ps) + if ps.Kind == ast.PseudostateChoice { + r.choice = ps + return r, nil + } + outgoing := e.graph.Transitions[ps] + if len(outgoing) == 0 { + return r, fmt.Errorf("%s %s has no outgoing transitions", ps.Kind, ps.Name) + } + enabled, notes, err := e.enabledBranches(ps, outgoing) + if err != nil { + return r, err + } + r.notes = append(r.notes, notes...) + if len(enabled) == 0 { + return r, fmt.Errorf("%s %s: no guard evaluated to true", ps.Kind, ps.Name) + } + if len(enabled) > 1 { + draw := &junctionDraw{at: ps, outgoing: outgoing, enabled: enabled, beyond: make([]branchBeyond, len(enabled))} + for i, pos := range enabled { + beyond, err := e.follow(ps, outgoing[pos], route{crossed: slices.Clone(r.crossed)}) + draw.beyond[i] = branchBeyond{route: beyond, err: err} + } + r.draw = draw + return r, nil + } + return e.follow(ps, outgoing[enabled[0]], r) +} + +// settleDraws makes the draws the route is open at, in turn, once the transition +// is committed to fire and nothing has moved yet: the policy draws among the +// enabled branches, as a choice point among the route's notes, and the route goes +// on along the one drawn as it was settled when the transition was selected, no +// guard beyond read again; a branch that could not be settled fails the run +// that draws it, the draw and what the branch noted on its way among the notes. +// A draw the witness refuses is the refusal, and the route is left where it was. +func (e *StateExecutor) settleDraws(r route) (route, error) { + for r.draw != nil { + draw := r.draw + r.draw = nil + pick, err := e.pickBranch(draw.at, draw.outgoing, draw.enabled, func(n RunNote) { r.notes = append(r.notes, n) }) + if err != nil { + return route{}, err + } + beyond := draw.beyond[pick] + if beyond.err != nil { + r.notes = append(r.notes, beyond.route.notes...) + return r, fmt.Errorf("evaluate pseudostate: %w", beyond.err) + } + r = r.onward(beyond.route) + } + return r, nil +} + +// onward is the route continued along beyond, settled from the pseudostate this +// route is open at: its segments follow, and it ends where beyond does. +func (r route) onward(beyond route) route { + r.segments = append(r.segments, beyond.segments...) + r.target, r.choice, r.draw, r.terminate = beyond.target, beyond.choice, beyond.draw, beyond.terminate + r.crossed = beyond.crossed + r.notes = append(r.notes, beyond.notes...) + return r +} + +// follow takes a segment out of the pseudostate from and goes on from its target. +func (e *StateExecutor) follow(from *ast.PseudostateNode, seg *lower.Transition, r route) (route, error) { + r.segments = append(r.segments, seg) + switch target := seg.Target.(type) { + case *ast.StateNode: + r.target = target + return r, nil + case *ast.PseudostateNode: + if !transientPseudostate(target.Kind) { + return r, fmt.Errorf("%s %s: a transition into %s %s is not supported", from.Kind, from.Name, target.Kind, target.Name) + } + return e.followOut(target, r) + case *ast.Usage: + if lower.IsTerminateUsage(target) { + r.terminate = target + return r, nil + } + } + return r, fmt.Errorf("%s %s: target must be a state, pseudostate or terminate action, got %T", from.Kind, from.Name, seg.Target) +} + +// resolveChoice reads the guards of the choice the route is open at against the +// data as it now stands and goes on from the branch taken: the policy draws among +// several enabled, as a recorded choice point; none enabled is the typed error. +func (e *StateExecutor) resolveChoice(r route) (route, error) { + choice := r.choice + outgoing := e.graph.Transitions[choice] + enabled, notes, err := e.enabledBranches(choice, outgoing) + if err != nil { + return route{}, err + } + e.noteAll(notes) + if len(enabled) == 0 { + return route{}, fmt.Errorf("%w: choice %s: no guard evaluated to true", ErrChoiceWithoutBranch, choice.Name) + } + pick, err := e.pickBranch(choice, outgoing, enabled, e.ctx.note) + if err != nil { + return route{}, err + } + // A branch past the first was only probed; its guard's final reading is made now. + if pick > 0 { + if _, err := e.passesGuard(outgoing[enabled[pick]]); err != nil { + return route{}, fmt.Errorf("choice %s: %w", choice.Name, err) + } + } + // The route past a choice is followed while firing, its draws made at once, so + // what it notes is noted now. + r, err = e.follow(choice, outgoing[enabled[pick]], route{crossed: r.crossed}) + if err == nil { + r, err = e.settleDraws(r) + } + e.noteAll(r.notes) + r.notes = nil + return r, err +} + +// enabledBranches reads the guards of the branches out of ps against the data as +// it stands: the positions of those that hold, else of the unguarded ones, the +// else branches. Once a branch holds the rest are probed only to report the +// choice; one with no result is not a branch, and is returned as a note. +func (e *StateExecutor) enabledBranches(ps *ast.PseudostateNode, outgoing []*lower.Transition) ([]int, []RunNote, error) { + var enabled, unguarded []int + var notes []RunNote + for i, trans := range outgoing { + if trans.Guard == nil { + unguarded = append(unguarded, i) + continue + } + var pass bool + if len(enabled) > 0 { + var unevaluable *UnevaluableGuard + if pass, unevaluable = e.probeBranch(ps, outgoing, i); unevaluable != nil { + notes = append(notes, *unevaluable) + } + } else { + var err error + if pass, err = e.passesGuard(trans); err != nil { + return nil, nil, fmt.Errorf("%s %s: %w", ps.Kind, ps.Name, err) + } + } + if pass { + enabled = append(enabled, i) + } + } + if len(enabled) == 0 { + enabled = unguarded + } + return enabled, notes, nil +} + +// pickBranch is the index into enabled of the branch out of ps taken: the only +// one, or the one the policy draws among several, handed to note as the choice +// point taken; a draw the witness refuses is the refusal. The guards are not +// read again: a junction's were read once, when its transition was selected. +func (e *StateExecutor) pickBranch(ps *ast.PseudostateNode, outgoing []*lower.Transition, enabled []int, note func(RunNote)) (int, error) { + // Weights are validated for a lone enabled branch too, even though it + // records no choice point and is taken with probability 1. + weights, err := e.transitionWeights(ps, outgoing, enabled, nil) + if err != nil { + return 0, err + } + point, ok := e.branchPoint(ps, outgoing, enabled) + if !ok { + return 0, nil + } + var pick int + if weights != nil { + point.Weights = weights + if err := e.ctx.scheduling().chooseWeighted(&point, nil); err != nil { + return 0, err + } + pick = point.Taken + } else { + pick = e.ctx.scheduling().choose(point, nil) + } + if err := e.ctx.scheduling().refusal(); err != nil { + return 0, err + } + point.Taken = pick + point.File, point.Span = e.transitionLocation(ps, outgoing[enabled[pick]]) + note(point) + return pick, nil +} + +// probeBranch reads whether the branch at position i out of ps holds once +// another already does, as a probe the context undoes whole; one that cannot be +// evaluated is not enabled and is returned as the note to record. +func (e *StateExecutor) probeBranch(ps *ast.PseudostateNode, outgoing []*lower.Transition, i int) (bool, *UnevaluableGuard) { + var pass bool + var err error + e.preview(func() { pass, err = e.passesGuard(outgoing[i]) }) + if err != nil { + file, _ := e.transitionLocation(ps, outgoing[i]) + return false, &UnevaluableGuard{ + Where: pseudostateWhere(ps), + Alternative: transitionName(outgoing, i), + Reason: err.Error(), + File: file, + Span: outgoing[i].Guard.Span(), + } + } + return pass, nil +} + +// branchPoint is the branches out of ps enabled, at their declared positions, as +// a choice point not yet taken; there is none under two. +func (e *StateExecutor) branchPoint(ps *ast.PseudostateNode, outgoing []*lower.Transition, enabled []int) (ChoicePoint, bool) { + if len(enabled) < 2 { + return ChoicePoint{}, false + } + alts := make([]string, len(enabled)) + for i, pos := range enabled { + alts[i] = transitionName(outgoing, pos) + } + return ChoicePoint{ + Kind: ChoiceTransition, + Where: pseudostateWhere(ps), + Alternatives: alts, + }, true +} + +// pseudostateWhere names a pseudostate for a note, `choice pick` or `junction split`. +func pseudostateWhere(ps *ast.PseudostateNode) string { + return fmt.Sprintf("%s %s", ps.Kind, ps.Name) +} + +// reachable lists the states, and the terminate actions, the route open at a +// choice or a draw can end at: along the route settled beyond each branch of the +// junction enabled (one that could not be settled ends nowhere), or through every +// branch of the choice and whatever pseudostates lie beyond, each once. +func (e *StateExecutor) reachable(r route) (states []*ast.StateNode, stops []*ast.Usage, err error) { + reach := &reachSet{graph: e.graph, seen: make(map[*ast.PseudostateNode]bool)} + for _, ps := range r.crossed { + reach.seen[ps] = true + } + err = reach.settled(r) + return reach.states, reach.stops, err +} + +// reachSet gathers the states and terminate actions a route can end at, each +// once, crossing each transient pseudostate once. +type reachSet struct { + graph *lower.StateGraph + seen map[*ast.PseudostateNode]bool + states []*ast.StateNode + stops []*ast.Usage +} + +func (s *reachSet) add(target *ast.StateNode) { + if !slices.Contains(s.states, target) { + s.states = append(s.states, target) + } +} + +func (s *reachSet) stop(target *ast.Usage) { + if !slices.Contains(s.stops, target) { + s.stops = append(s.stops, target) + } +} + +// visit follows every branch out of ps and whatever pseudostates lie beyond. +func (s *reachSet) visit(ps *ast.PseudostateNode, branches []*lower.Transition) error { + for _, branch := range branches { + switch target := branch.Target.(type) { + case *ast.StateNode: + s.add(target) + case *ast.PseudostateNode: + if !transientPseudostate(target.Kind) { + return fmt.Errorf("%s %s: a transition into %s %s is not supported", ps.Kind, ps.Name, target.Kind, target.Name) + } + if s.seen[target] { + continue + } + s.seen[target] = true + if err := s.visit(target, s.graph.Transitions[target]); err != nil { + return err + } + case *ast.Usage: + if !lower.IsTerminateUsage(target) { + return fmt.Errorf("%s %s: target must be a state, pseudostate or terminate action, got %T", ps.Kind, ps.Name, branch.Target) + } + s.stop(target) + default: + return fmt.Errorf("%s %s: target must be a state, pseudostate or terminate action, got %T", ps.Kind, ps.Name, branch.Target) + } + } + return nil +} + +// settled follows the route where it is settled, and every branch of the +// choice where it is not. +func (s *reachSet) settled(r route) error { + if r.target != nil { + s.add(r.target) + return nil + } + if r.terminate != nil { + s.stop(r.terminate) + return nil + } + if r.draw != nil { + for _, beyond := range r.draw.beyond { + if beyond.err != nil { + continue + } + if err := s.settled(beyond.route); err != nil { + return err + } + } + return nil + } + return s.visit(r.choice, s.graph.Transitions[r.choice]) +} + +// terminateBoundary is the state a move from `from` into stop's owner stays inside +// of, as moveBoundary finds it for a state target: nil for the machine's body. +func (e *StateExecutor) terminateBoundary(from *ast.StateNode, trans *lower.Transition, stop *ast.Usage) *ast.StateNode { + owner := e.graph.TerminateOwner[stop] + if source, isState := trans.Source.(*ast.StateNode); isState && e.encloses(source, owner) { + return e.graph.ParentState[source] + } + if owner == nil || from == nil { + return nil + } + return e.getLCA(from, owner) +} + +// terminateExits lists the states a move from `from` to stop exits, innermost +// first: those below the boundary, as a move to a state in stop's body would. +func (e *StateExecutor) terminateExits(from *ast.StateNode, trans *lower.Transition, stop *ast.Usage) []*ast.StateNode { + return e.exitPath(from, e.terminateBoundary(from, trans, stop), nil) +} + +// terminateEntries lists the states a move from `from` to stop enters, outermost +// first: the chain from the boundary down to the state declaring stop. +func (e *StateExecutor) terminateEntries(from *ast.StateNode, trans *lower.Transition, stop *ast.Usage) []*ast.StateNode { + return e.descendantChain(e.terminateBoundary(from, trans, stop), e.graph.TerminateOwner[stop]) +} + +// exitPlan lists the states a move to target exits, against the configuration +// as it stands. +type exitPlan func(target *ast.StateNode) []*ast.StateNode + +// entryPlan lists the states a move to target enters, outermost first, against +// the configuration as it stands. +type entryPlan func(target *ast.StateNode) []*ast.StateNode + +// routeEnds are the moves to the states and terminate actions the branches +// beyond a choice may end at: the exits and entries each makes. +type routeEnds struct { + exits [][]*ast.StateNode + entries [][]*ast.StateNode +} + +// endsOf plans the move to every end the route open at a choice may reach. +func (e *StateExecutor) endsOf(r route, trans *lower.Transition, from *ast.StateNode, exits exitPlan, enters entryPlan) (routeEnds, error) { + targets, stops, err := e.reachable(r) + if err != nil { + return routeEnds{}, err + } + var ends routeEnds + for _, target := range targets { + ends.exits = append(ends.exits, e.expandExits(exits(target))) + ends.entries = append(ends.entries, enters(target)) + } + for _, stop := range stops { + ends.exits = append(ends.exits, e.expandExits(e.terminateExits(from, trans, stop))) + ends.entries = append(ends.entries, e.terminateEntries(from, trans, stop)) + } + return ends, nil +} + +// certainExits lists the states a move to every one of the ends exits — in a +// sibling region as well as above the source — in the order the first end's +// move leaves them, so they can be left before the branch is known. +func (ends routeEnds) certainExits() []*ast.StateNode { + return certainStates(ends.exits) +} + +// certainEntries lists the states a move to every one of the ends enters, in +// the order the first end's move enters them, before the branch is known. +func (ends routeEnds) certainEntries() []*ast.StateNode { + return certainStates(ends.entries) +} + +// certainStates lists the states every list holds, in the first list's order. +func certainStates(lists [][]*ast.StateNode) []*ast.StateNode { + if len(lists) == 0 { + return nil + } + certain := slices.Clone(lists[0]) + for _, other := range lists[1:] { + certain = slices.DeleteFunc(certain, func(state *ast.StateNode) bool { + return !slices.Contains(other, state) + }) + } + return certain +} + +// runEffects performs a compound transition's effects in path order, activating the +// chain down to the state enclosing each first: a segment is a performance of its owner. +// A `terminate` ending a step of a transition body ends the body's later steps (endedBefore). +func (e *StateExecutor) runEffects(effects []routeEffect, chain []*ast.StateNode) error { + var ended []ast.Node + for i, effect := range effects { + if e.endedBefore(ended, effect.behavior) { + continue + } + if upto := e.enclosingIndex(chain, effect.within); upto >= 0 { + if err := e.enterAhead(chain[:upto+1]); err != nil { + return err + } + } + // A segment's effects are one unit of the firing, run in their order. + if i == 0 || effects[i-1].segment != effect.segment { + if _, err := e.unit(ChoiceRegionOrder, unitHead{label: e.effectLabel(effect.segment), at: effect.segment.Decl}); err != nil { + return err + } + } + terminated, err := e.executeBehavior(effect.behavior) + if err != nil { + return fmt.Errorf("transition effect: %w", err) + } + if terminated { + ended = append(ended, effect.behavior.Block) + } + } + return nil +} + +// enclosingIndex is where the state enclosing an effect, or the parallel state +// whose region it stands for, lies in chain; -1 when the chain never enters it. +func (e *StateExecutor) enclosingIndex(chain []*ast.StateNode, state *ast.StateNode) int { + if state == nil || state == e.graph.Machine { + return -1 + } + if i := slices.Index(chain, state); i >= 0 { + return i + } + if region := e.graph.HiddenRegionOf[state]; region != nil { + return slices.Index(chain, e.graph.RegionOwner[region]) + } + return -1 +} + +// enterOwnerOf activates the chain down to the state declaring ps, when the move +// enters it: the guards of a choice are read once its owner is entered. +func (e *StateExecutor) enterOwnerOf(ps *ast.PseudostateNode, chain []*ast.StateNode) error { + upto := e.enclosingIndex(chain, e.graph.PseudostateOwner[ps]) + if upto < 0 { + return nil + } + return e.enterAhead(chain[:upto+1]) +} + +// enterAhead activates the states of chain not yet activated, outermost first; the +// move entering them later finds them activated and goes on with their regions. +func (e *StateExecutor) enterAhead(chain []*ast.StateNode) error { + simple := e.activeConfig.simpleState + defer func() { e.activeConfig.simpleState = simple }() + for _, state := range chain { + if _, ahead := e.enteredAhead[state]; ahead { + continue + } + if e.entryIsUnit(state) { + if _, err := e.unit(ChoiceEntryOrder, e.entryHead(state, false)); err != nil { + return err + } + } + if err := e.activateState(state); err != nil { + return fmt.Errorf("enter state %s: %w", state.Name, err) + } + if e.enteredAhead == nil { + e.enteredAhead = make(map[*ast.StateNode]bool) + } + e.enteredAhead[state] = true + } + return nil +} + +// activatedAhead reports, and takes, an activation the move already made ahead. +func (e *StateExecutor) activatedAhead(state *ast.StateNode) bool { + if !e.enteredAhead[state] { + return false + } + e.enteredAhead[state] = false + return true +} + +// entriesSettled reports a move that never entered a state activated ahead of it. +func (e *StateExecutor) entriesSettled() error { + for state, waiting := range e.enteredAhead { + if waiting { + return fmt.Errorf("state %s was entered ahead of the transition but the transition does not enter it", state.Name) + } + } + return nil +} + +// expandExits lists the states an exit list leaves, innermost first: every active +// state below each listed state, which exiting a composite state exits with it, +// then the state itself. +func (e *StateExecutor) expandExits(listed []*ast.StateNode) []*ast.StateNode { + var left []*ast.StateNode + for _, state := range listed { + for _, active := range e.activeLeavesBelow(state) { + for _, below := range e.exitPath(active, state, nil) { + if !slices.Contains(left, below) { + left = append(left, below) + } + } + } + if !slices.Contains(left, state) { + left = append(left, state) + } + } + return left +} + +// exitAhead exits the states a compound transition leaves whichever branch it +// takes, keeping the configuration as it was so the move finishing it plans its +// exits from it and finds these states left already. +func (e *StateExecutor) exitAhead(states []*ast.StateNode) error { + if len(states) == 0 { + return nil + } + simple, regions := e.activeConfig.simpleState, e.activeConfig.regionStates + e.activeConfig.regionStates = make(map[*ast.StateRegion]*ast.StateNode, len(regions)) + for region, active := range regions { + e.activeConfig.regionStates[region] = active + } + if e.leftAhead == nil { + e.leftAhead = make(map[*ast.StateNode]bool) + } + e.exitingAhead = true + err := e.exitStates(states) + e.exitingAhead = false + e.activeConfig.simpleState, e.activeConfig.regionStates = simple, regions + return err +} + +// travel takes a compound transition along r as one move: at each choice it leaves what every +// branch leaves, runs the effects into it and reads its guards; move finishes the settled rest. +func (e *StateExecutor) travel(trans *lower.Transition, from *ast.StateNode, r route, exits exitPlan, enters entryPlan, move func([]routeEffect, *ast.StateNode) error) error { + savedLeft, savedEntered := e.leftAhead, e.enteredAhead + e.leftAhead, e.enteredAhead = nil, nil + defer func() { e.leftAhead, e.enteredAhead = savedLeft, savedEntered }() + if err := e.moveWhole(func() error { return e.travelResolving(trans, from, r, exits, enters, move) }); err != nil { + return err + } + return e.entriesSettled() +} + +// moveWhole makes move as one compound transition. Only a replay refuses a move, +// at a draw the draws, exits, effects and entries ahead of it have been made for; +// a refused move is undone whole, the moves nested in it with it. +func (e *StateExecutor) moveWhole(move func() error) error { + if !e.ctx.scheduling().replaying() { + return move() + } + mark := e.markMove() + err := move() + if e.ctx.scheduling().refusal() != nil { + mark.undo() + } else { + mark.keep() + } + return err +} + +// travelResolving is travel's course: the draw the route is open at made, then +// each choice on the way resolved once the exits every branch makes and the +// effects into it are done. trans is the compound transition's first segment and +// from the state the move leaves, for the trace. +func (e *StateExecutor) travelResolving(trans *lower.Transition, from *ast.StateNode, r route, exits exitPlan, enters entryPlan, move func([]routeEffect, *ast.StateNode) error) error { + if r.draw != nil { + var err error + r, err = e.settleDraws(r) + e.noteAll(r.notes) + r.notes = nil + if err != nil { + return err + } + } + for r.choice != nil { + ends, err := e.endsOf(r, trans, from, exits, enters) + if err != nil { + return err + } + certainExits, certainEntries := ends.certainExits(), ends.certainEntries() + if _, err := e.leaveAlong(r, certainExits, certainEntries, nil); err != nil { + return err + } + if err := e.exitAhead(certainExits); err != nil { + return err + } + if err := e.enterOwnerOf(r.choice, certainEntries); err != nil { + return err + } + e.noteFired(r.segments...) + if r, err = e.resolveChoice(r); err != nil { + return err + } + } + var leaving, entering []*ast.StateNode + if r.terminate != nil { + leaving = e.expandExits(e.terminateExits(from, trans, r.terminate)) + entering = e.terminateEntries(from, trans, r.terminate) + } else { + leaving, entering = e.expandExits(exits(r.target)), enters(r.target) + } + effects, err := e.leaveAlong(r, leaving, entering, r.segments[len(r.segments)-1]) + if err != nil { + return err + } + e.noteFired(r.segments...) + if r.terminate != nil { + return e.terminateAlong(trans, from, r, effects) + } + return move(effects, r.target) +} + +// leaveAlong runs the segments before upto, exiting what each has left before its effects +// (UML 14.2.3.8.4), and returns the effects from upto on, which run after the move's exits. +func (e *StateExecutor) leaveAlong(r route, leaving, entering []*ast.StateNode, upto *lower.Transition) ([]routeEffect, error) { + effects := r.effects(e.graph) + var left []*ast.StateNode + for _, seg := range r.segments { + if seg == upto { + break + } + left = append(left, e.leftBySegment(leaving, seg)...) + n := 0 + for n < len(effects) && effects[n].segment == seg { + n++ + } + if n == 0 { + continue + } + if err := e.exitAhead(left); err != nil { + return nil, err + } + left = nil + if err := e.runEffects(effects[:n], entering); err != nil { + return nil, err + } + effects = effects[n:] + } + return effects, nil +} + +// leftBySegment lists, among the states the move leaves, those seg leaves: the ones from its +// source up to its boundary and their contents; from the machine's body, all still to exit. +func (e *StateExecutor) leftBySegment(leaving []*ast.StateNode, seg *lower.Transition) []*ast.StateNode { + source := e.vertexState(seg.Source) + if source == nil { + return leaving + } + boundary := e.segmentBoundary(seg) + if boundary != nil && e.isBelowOrEqual(boundary, source) { + return nil + } + top := source + for e.graph.ParentState[top] != boundary && e.graph.ParentState[top] != nil { + top = e.graph.ParentState[top] + } + var left []*ast.StateNode + for _, state := range leaving { + if e.isBelowOrEqual(state, top) { + left = append(left, state) + } + } + return left +} + +// segmentBoundary is the state seg stays inside of: the LCA of its ends (a pseudostate standing +// in its declaring state), or the parent of a source that encloses the target; nil is the body. +func (e *StateExecutor) segmentBoundary(seg *lower.Transition) *ast.StateNode { + source, target := e.vertexState(seg.Source), e.vertexState(seg.Target) + if declared, isState := seg.Source.(*ast.StateNode); isState && e.encloses(declared, target) { + return e.graph.ParentState[declared] + } + return e.getLCA(source, target) +} + +// vertexState is the state a transition end lies in; nil for the machine's body. +func (e *StateExecutor) vertexState(end ast.Node) *ast.StateNode { + switch v := end.(type) { + case *ast.StateNode: + return v + case *ast.PseudostateNode: + return e.graph.PseudostateOwner[v] + case *ast.Usage: + return e.graph.TerminateOwner[v] + } + return nil +} + +// terminateAlong finishes a transition at the terminate action its route reaches +// (SysML v2 §7.18.3): the states the move leaves are exited and the ones down to +// the action's owner entered, as for a state beside it, then the machine ends. +func (e *StateExecutor) terminateAlong(trans *lower.Transition, from *ast.StateNode, r route, effects []routeEffect) error { + if err := e.exitStates(e.terminateExits(from, trans, r.terminate)); err != nil { + return err + } + return e.terminateAt(trans, StateVertexName(from), r, effects, e.terminateEntries(from, trans, r.terminate)) +} + +// terminateAt ends the machine's performance at the terminate action r reaches, +// the move's exits done: the effects run entering the chain down to the action's +// owner, then no further state is exited, no exit behavior runs, and the do +// behaviors still under way are abandoned where they stand. +func (e *StateExecutor) terminateAt(trans *lower.Transition, fromName string, r route, effects []routeEffect, entering []*ast.StateNode) error { + if err := e.runEffects(effects, entering); err != nil { + return err + } + if err := e.enterAhead(entering); err != nil { + return err + } + // The machine ends here: the entries made ahead are the move's own. + clear(e.enteredAhead) + return e.terminateMachine(fromName, trans.Trigger, r.terminate) +} + +// runBehaviors performs a transition's effects, in order. +func (e *StateExecutor) runBehaviors(effects []lower.StateBehavior) error { + if err := e.executeBehaviors(effects); err != nil { + return fmt.Errorf("transition effect: %w", err) + } + return nil +} + +// mayExit lists the states a compound transition along r from `from` may leave, +// whichever end it can reach; false where the route cannot be followed. +func (e *StateExecutor) mayExit(r route, trans *lower.Transition, from *ast.StateNode, exits exitPlan) ([]*ast.StateNode, bool) { + if r.target != nil { + return exits(r.target), true + } + if r.terminate != nil { + return e.terminateExits(from, trans, r.terminate), true + } + targets, stops, err := e.reachable(r) + if err != nil { + return nil, false + } + var states []*ast.StateNode + add := func(exited []*ast.StateNode) { + for _, state := range exited { + if !slices.Contains(states, state) { + states = append(states, state) + } + } + } + for _, target := range targets { + add(exits(target)) + } + for _, stop := range stops { + add(e.terminateExits(from, trans, stop)) + } + return states, true +} diff --git a/internal/core/runtime/state_run_one_test.go b/internal/exec/runtime/state_run_one_test.go similarity index 95% rename from internal/core/runtime/state_run_one_test.go rename to internal/exec/runtime/state_run_one_test.go index 1d3fc8a04f..b55fb05ce8 100644 --- a/internal/core/runtime/state_run_one_test.go +++ b/internal/exec/runtime/state_run_one_test.go @@ -4,7 +4,7 @@ import ( "slices" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // runOneMachine creates a machine whose two regions each run a do body that waits @@ -81,6 +81,8 @@ func TestRunOneMatchesRunToQuiescence(t *testing.T) { ctx, exec = runOneMachine(t) var progress dueProgress units := 0 + // Note into the clock's run, as Advance does, so the two lists start alike. + leave := ctx.beginExecutorRun(&ctx.clockRun) for { moved, err := exec.runOne(&progress) if err != nil { @@ -97,6 +99,7 @@ func TestRunOneMatchesRunToQuiescence(t *testing.T) { break } } + leave() ctx.clock.now = 3 if got := exec.StateData()["log"]; got.Const.Int != wantLog.Const.Int { diff --git a/internal/exec/runtime/state_run_to_completion.go b/internal/exec/runtime/state_run_to_completion.go new file mode 100644 index 0000000000..a6ef402935 --- /dev/null +++ b/internal/exec/runtime/state_run_to_completion.go @@ -0,0 +1,347 @@ +package runtime + +import ( + "fmt" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// entryStepWherePrefix names entry-step choices at an instant. +const entryStepWherePrefix = "entry at t=" // #nosec G101 -- a trace label, not a credential + +// HoldsEntry reports whether an entry cascade is waiting for its next step. +func (e *StateExecutor) HoldsEntry() bool { return len(e.held) > 0 } + +// heldEntry is the unfinished portion of a state entry cascade. +type heldEntry struct { + // owner is the composite body whose entry is paused. + owner *ast.StateNode + // regions are orthogonal regions still awaiting entry. + regions []*ast.StateRegion + // branches maps each held region to its selected branch. + branches map[*ast.StateRegion]*ast.StateNode + // chain lists states entered by this cascade, outermost first. + chain []*ast.StateNode + // scopes are the RTC scopes guarding dispatch while this cascade is held. + scopes []*ast.StateNode + // machine reports that the machine body is part of this cascade. + machine bool + // firing is the transition whose entry the cascade is, with the payload it + // bound; the entries and do behaviors performed on resumption read it. + firing *firing +} + +// RunToCompletionValueError reports an unevaluable or non-Boolean RTC value. +type RunToCompletionValueError struct { + State *ast.StateNode + Value ast.Node + Err error +} + +func (e *RunToCompletionValueError) Error() string { + if e.Err != nil { + return fmt.Sprintf("evaluate run-to-completion value of %s: %v", StateVertexName(e.State), e.Err) + } + return fmt.Sprintf("run-to-completion value of %s must be boolean", StateVertexName(e.State)) +} + +func (e *RunToCompletionValueError) Unwrap() error { return e.Err } + +// rtcOf evaluates the effective isRunToCompletion value for state. +func (e *StateExecutor) rtcOf(state *ast.StateNode) (bool, error) { + config := e.graph.RunToCompletionOf(state) + if config.Value == nil { + return true, nil + } + if literal, ok := config.Value.(*ast.LiteralBool); ok { + return literal.Value, nil + } + value, err := e.evalStepOf(ast.Node(config.ValueOwner), config.Value, config.ValueScope) + if err != nil { + return false, &RunToCompletionValueError{State: state, Value: config.Value, Err: err} + } + if value.Kind != ValConst || value.Const.Kind != semantics.ValBool { + return false, &RunToCompletionValueError{State: state, Value: config.Value} + } + return value.Const.Bool, nil +} + +// enteringChain returns the active entry chain, outermost state first. +func (e *StateExecutor) enteringChain(owner *ast.StateNode) []*ast.StateNode { + if owner == nil { + return nil + } + var chain []*ast.StateNode + for _, state := range e.rootToLeaf(owner) { + if e.entering[state] { + chain = append(chain, state) + } + } + return chain +} + +// holdEntry records an entry cascade when no whole-machine RTC member is entering. +func (e *StateExecutor) holdEntry(owner *ast.StateNode, regions []*ast.StateRegion, branches map[*ast.StateRegion]*ast.StateNode) (bool, error) { + chain := e.enteringChain(owner) + machine := e.enteringMachine + if !machine && len(chain) == 0 { + return false, nil + } + if owner != nil && e.activeAtEntry[owner] && e.entering[owner] { + return false, nil + } + members := slices.Clone(chain) + if !slices.Contains(members, owner) { + members = append(members, owner) + } + if machine { + members = append(members, nil) + } + var scopes []*ast.StateNode + for _, state := range members { + rtc, err := e.rtcOf(state) + if err != nil { + return false, err + } + if rtc && e.graph.RunToCompletionOf(state).Scope == nil { + return false, nil + } + if rtc { + scopes = append(scopes, e.graph.RunToCompletionOf(state).Scope) + } + } + e.held = append(e.held, heldEntry{ + owner: owner, regions: regions, branches: branches, + chain: chain, scopes: scopes, machine: machine, + firing: e.currentFiring(), + }) + return true, nil +} + +func (e *StateExecutor) heldOwner(state *ast.StateNode) *heldEntry { + for i := range e.held { + if e.held[i].owner == state { + return &e.held[i] + } + } + return nil +} + +// performHeld resumes one held entry cascade within the firing that began it. +func (e *StateExecutor) performHeld(item heldEntry) (err error) { + defer e.resumeFiring(item.firing)() + clear(e.entering) + for _, state := range item.chain { + e.entering[state] = true + } + e.enteringMachine = item.machine + begun := append(slices.Clone(item.chain), item.owner) + if item.machine { + begun = append(begun, e.graph.Machine) + } + e.resumeEntering(begun) + defer e.unfireOnError(len(e.fired), &err) + if item.regions != nil { + if e.activeConfig.simpleState == item.owner { + e.activeConfig.simpleState = nil + } + if err = e.enterRegionsInto(item.owner, item.regions, item.branches); err == nil { + err = e.settleEntered(item.owner) + } + } else { + var leaf *ast.StateNode + leaf, err = e.enterStartOf(item.owner) + if err == nil { + err = e.settleEntered(leaf) + } + } + return err +} + +// resumeFiring puts the executor back within a firing whose entry cascade was +// held, its payload bound again; the function returned takes both out. +func (e *StateExecutor) resumeFiring(f *firing) func() { + if f == nil || f.taken == nil { + return func() { /* the cascade began outside a firing */ } + } + untake := e.taking(f.taken, e.firingNotes) + unbind := e.restoreData(f.taken.Accepted) + for _, name := range f.taken.Accepted { + if value, ok := f.payload[name]; ok { + e.bindData(name, value) + } + } + return func() { + unbind() + untake() + } +} + +// settleEntered records the active configuration and schedules its transitions. +func (e *StateExecutor) settleEntered(leaf *ast.StateNode) error { + if leaf == nil { + return nil + } + onPath := e.branchesTo(nil, leaf) + for region, state := range onPath { + e.activeConfig.regionStates[region] = state + } + if len(onPath) == 0 && len(e.activeConfig.regionStates) == 0 { + e.activeConfig.simpleState = leaf + } + e.stateStack = e.rootToLeaf(leaf) + if err := e.scheduleTransitionEvents(); err != nil { + return fmt.Errorf("schedule events: %w", err) + } + return e.completeIfDone(leaf) +} + +// entryStep chooses between free dispatch and held entry work. +func (e *StateExecutor) entryStep(progress *dueProgress) (bool, error) { + dispatch, free := e.dispatchFree(e.dueDispatch()) + if !free && len(e.held) == 1 { + item := e.held[0] + e.held = slices.Delete(e.held, 0, 1) + return true, e.performHeld(item) + } + alternatives := make([]string, 0, len(e.held)+1) + if free { + alternatives = append(alternatives, dispatch.label) + } + for _, item := range e.held { + alternatives = append(alternatives, e.entryLabel(item.owner)) + } + if len(alternatives) == 1 { + if free { + e.dispatchAmong = dispatch.among + defer func() { e.dispatchAmong = nil }() + return e.dispatchOne(progress) + } + item := e.held[0] + e.held = slices.Delete(e.held, 0, 1) + return true, e.performHeld(item) + } + choice := ChoicePoint{ + Kind: ChoiceEntryStep, + Where: entryStepWherePrefix + semantics.FormatReal(e.ctx.clock.now), + Alternatives: alternatives, + File: e.stateMachine.DocName, + Span: e.stateMachine.DeclSpan, + } + choice.Taken = e.ctx.scheduling().choose(choice, nil) + if err := e.ctx.scheduling().refusal(); err != nil { + return false, err + } + e.noteChoice(choice) + if free && choice.Taken == 0 { + e.dispatchAmong = dispatch.among + defer func() { e.dispatchAmong = nil }() + return e.dispatchOne(progress) + } + index := choice.Taken + if free { + index-- + } + item := e.held[index] + e.held = slices.Delete(e.held, index, index+1) + return true, e.performHeld(item) +} + +// heldScopes returns RTC scopes currently guarding dispatch. +func (e *StateExecutor) heldScopes() []*ast.StateNode { + var scopes []*ast.StateNode + for _, item := range e.held { + scopes = append(scopes, item.scopes...) + } + return scopes +} + +// dispatchFree filters a due dispatch to transitions outside held RTC scopes. +func (e *StateExecutor) dispatchFree(d dueDispatch) (dueDispatch, bool) { + if !d.due || !d.acts { + return d, false + } + scopes := e.heldScopes() + if len(scopes) == 0 { + return d, true + } + if len(d.among) == 0 { + return e.noAmongFree(d, scopes) + } + free := make([]Event, 0, len(d.among)) + for _, event := range d.among { + if !e.eventHeld(scopes, event) { + free = append(free, event) + } + } + if len(free) == 0 { + return d, false + } + d.among = free + if len(free) == 1 { + d.label = dispatchPrefix + e.eventLabel(free[0]) + d.step = d.label + } + return d, true +} + +// noAmongFree reports whether a dispatch naming no candidate events may run: +// its own event and every risen change must be free of the held scopes. +func (e *StateExecutor) noAmongFree(d dueDispatch, scopes []*ast.StateNode) (dueDispatch, bool) { + if d.event != nil && e.eventHeld(scopes, *d.event) { + return d, false + } + if risen, ok := e.risenChanges(); ok { + for _, trans := range risen { + if trans != nil && e.eventHeld(scopes, Event{Payload: trans}) { + return d, false + } + } + } + return d, true +} + +// eventHeld reports whether event's transitions fall inside a held scope; +// a selection that fails to preview is held rather than risked. +func (e *StateExecutor) eventHeld(scopes []*ast.StateNode, event Event) bool { + if trans, ok := event.Payload.(*lower.Transition); ok { + return scopeContains(e.graph, scopes, e.transitionOwner(trans)) + } + var candidates []dispatchCandidate + var err error + e.preview(func() { + candidates, err = e.selectTransitions(&event) + }) + if err != nil { + return true + } + for _, candidate := range candidates { + for _, index := range candidate.enabled { + if scopeContains(e.graph, scopes, e.graph.Transitions[candidate.source][index].Owner) { + return true + } + } + } + return false +} + +func (e *StateExecutor) transitionOwner(trans *lower.Transition) *ast.StateNode { + return trans.Owner +} + +func scopeContains(graph *lower.StateGraph, scopes []*ast.StateNode, owner *ast.StateNode) bool { + for _, scope := range scopes { + if scope == nil { + return true + } + for current := owner; current != nil; current = graph.ParentState[current] { + if current == scope { + return true + } + } + } + return false +} diff --git a/internal/exec/runtime/state_space.go b/internal/exec/runtime/state_space.go new file mode 100644 index 0000000000..3a47bf981f --- /dev/null +++ b/internal/exec/runtime/state_space.go @@ -0,0 +1,622 @@ +package runtime + +import ( + "errors" + "fmt" + "math" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// Errors a state-space run reports, each wrapping the detail of what went wrong. +var ( + // ErrStateSpaceValue reports a state, input, derivative, difference or output + // that is not the vector the protocol asks for, or one the action does not bind. + ErrStateSpaceValue = errors.New("state-space value") + // ErrStateSpaceStep reports a time step the dynamics cannot advance by: none + // stated, not a duration, or not positive. + ErrStateSpaceStep = errors.New("state-space time step") + // ErrStateSpaceDiverged reports a step that left the state with a component + // that is no longer a finite number. + ErrStateSpaceDiverged = errors.New("state-space dynamics diverged") +) + +// stateSpaceSemantics answers lowering's semantic questions from the context's model. +type stateSpaceSemantics struct{ ctx *Context } + +func (m stateSpaceSemantics) LibrarySymbol(fqn string) *symbols.Symbol { + return m.ctx.librarySymbol(fqn) +} +func (m stateSpaceSemantics) Specializes(sym, general *symbols.Symbol) bool { + return m.ctx.conforms(sym, general) +} +func (m stateSpaceSemantics) MembersOf(sym *symbols.Symbol) []*symbols.Symbol { + return m.ctx.model.semantics.MembersOf(sym) +} +func (m stateSpaceSemantics) FeatureTypes(sym *symbols.Symbol) []*symbols.Symbol { + return m.ctx.model.semantics.FeatureTypes(sym) +} +func (m stateSpaceSemantics) ParameterDefault(sym *symbols.Symbol) (ast.Node, *symbols.Scope) { + return m.ctx.model.semantics.ParameterDefault(sym) +} +func (m stateSpaceSemantics) LibraryDeclared(sym *symbols.Symbol) bool { + return m.ctx.libraryDeclared(sym) +} + +// stateSpaceKindOf classifies an action by the library dynamics it specializes. +func (ctx *Context) stateSpaceKindOf(action *symbols.Symbol) lower.StateSpaceKind { + if ctx.model.semantics == nil { + return lower.NotStateSpace + } + return lower.StateSpaceKindOf(action, stateSpaceSemantics{ctx}) +} + +// stateSpaceRun is the progress of an action run as state-space dynamics: one +// token stands at the action itself, parked on the clock between steps. +type stateSpaceRun struct { + dyn *lower.StateSpaceDynamics + // start is the clock's instant the run began at, steps the steps taken since; + // the next step is due at start + (steps+1)*step, so the instants do not drift. + start float64 + steps int + step float64 + // stop is the instant the run stops at, stops false where it runs while driven. + stop float64 + stops bool + // stepValue is timeStep as the action holds it, what the state's rate is scaled by. + stepValue Value + // guards holds each crossing's guard after the latest step, for its sign; + // nil until the first step settles the start. + guards []float64 +} + +// settled reports whether the run has sampled its start, which its first step does. +func (run *stateSpaceRun) settled() bool { return run.guards != nil } + +// ownsFeature reports a feature the run writes itself, whose declared default the +// action leaves unevaluated: the output, sampled by getOutput as each step settles. +func (run *stateSpaceRun) ownsFeature(name string) bool { + return run != nil && run.dyn.Output != nil && name == run.dyn.Output.Name +} + +// clone is the run's progress by value, nil for an action running no dynamics; +// a snapshot keeps one and hands a fresh one back at each restore. +func (run *stateSpaceRun) clone() *stateSpaceRun { + if run == nil { + return nil + } + saved := *run + saved.guards = slices.Clone(run.guards) + return &saved +} + +// dynamicsNode is the node the run's one token stands at: the action itself. +func (e *ActionExecutor) dynamicsNode() ast.Node { return e.action.Decl } + +// initializeDynamics starts a state-space run: binds the action's features, checks +// the state and step, computes the output at the start and parks a token until the first step. +func (e *ActionExecutor) initializeDynamics() error { + dyn, err := lower.ToStateSpaceDynamics(e.action, e.graph.Scope, stateSpaceSemantics{e.ctx}) + if err != nil { + return err + } + if err := e.checkResultParameters(); err != nil { + return err + } + run := &stateSpaceRun{dyn: dyn} + e.dynamics = run + + e.ctx.beginPerformanceLife(e.occurrence, e.ctx.newActivation()) + if err := e.bindInputs(); err != nil { + return err + } + if err := e.readStep(run); err != nil { + return err + } + if _, err := e.stateVector(); err != nil { + return err + } + run.start = e.ctx.clock.now + e.tokens = append(e.tokens, Token{ID: e.nextTokenID, Location: e.dynamicsNode(), frame: e.root}) + e.nextTokenID++ + e.state = StateRunning + return nil +} + +// readStep reads the step the dynamics advance by and the instant they stop at, in clock units. +func (e *ActionExecutor) readStep(run *stateSpaceRun) error { + dyn := run.dyn + what := fmt.Sprintf("%s of action %s", lower.TimeStepFeature, symbolText(e.action)) + step, ok := e.root.data[e.root.key(lower.TimeStepFeature)] + if dyn.TimeStep == nil || !ok { + return fmt.Errorf("%w: action %s states no %s; specialize %s or declare one", + ErrStateSpaceStep, symbolText(e.action), lower.TimeStepFeature, lower.FixedStepDynamicsFQN) + } + magnitude, err := e.ctx.timeMagnitude(step, what) + if err != nil { + return fmt.Errorf("%w: %w", ErrStateSpaceStep, err) + } + if math.IsNaN(magnitude) || math.IsInf(magnitude, 0) || magnitude <= 0 { + return fmt.Errorf("%w: %s is %s, not a positive duration", ErrStateSpaceStep, what, FormatValue(step)) + } + run.step, run.stepValue = magnitude, step + if dyn.StopTime == nil { + return nil + } + stop, ok := e.root.data[e.root.key(lower.StopTimeFeature)] + if !ok || stop.Kind == ValNull { + return nil + } + what = fmt.Sprintf("%s of action %s", lower.StopTimeFeature, symbolText(e.action)) + if run.stop, err = e.ctx.timeMagnitude(stop, what); err != nil { + return fmt.Errorf("%w: %w", ErrStateSpaceStep, err) + } + if math.IsNaN(run.stop) { + return fmt.Errorf("%w: %s is not a number", ErrStateSpaceStep, what) + } + run.stops = true + return nil +} + +// stateVector is the state the action holds, checked to be a vector. +func (e *ActionExecutor) stateVector() (Value, error) { + state, ok := e.root.data[e.root.key(lower.StateSpaceFeature)] + if !ok || state.Kind == ValNull { + return Value{}, fmt.Errorf("%w: action %s binds no %s; give it an initial value", + ErrStateSpaceValue, symbolText(e.action), lower.StateSpaceFeature) + } + return e.checkVector(lower.StateSpaceFeature, state) +} + +// dynamicsSteps is the number of steps the dynamics took, for telling progress; 0 for other actions. +func (e *ActionExecutor) dynamicsSteps() int { + if e.dynamics == nil { + return 0 + } + return e.dynamics.steps +} + +// inputVector is the input the action holds; nil when it binds none, so a calc +// that does not need one runs and one that does reports its parameter unbound. +func (e *ActionExecutor) inputVector() (*Value, error) { + input, ok := e.root.data[e.root.key(lower.InputFeature)] + if !ok || input.Kind == ValNull { + return nil, nil + } + checked, err := e.checkVector(lower.InputFeature, input) + if err != nil { + return nil, err + } + return &checked, nil +} + +// checkVector refuses a value that is no vector, naming the protocol feature it stood for. +func (e *ActionExecutor) checkVector(what string, val Value) (Value, error) { + if !isVectorKind(val) { + return Value{}, fmt.Errorf("%w: %s of action %s is %s, not a vector", + ErrStateSpaceValue, what, symbolText(e.action), describeValue(val)) + } + return val, nil +} + +// dynamicsFootprint is what a step of the dynamics may touch: the model's calcs +// read what they name, so the checker holds the step dependent on every move. +func dynamicsFootprint() lower.Footprint { return lower.Footprint{Dynamic: true} } + +// dynamicsDue reports whether the run's token may step at this instant: not +// parked, or parked until an instant the clock has reached. +func (e *ActionExecutor) dynamicsDue(token Token) bool { + return token.Wait == nil || token.Wait.Due <= e.ctx.clock.now +} + +// stepDynamics samples the start on the token's first step, then performs the +// step the token is due for, or leaves it parked. +func (e *ActionExecutor) stepDynamics(tokenIdx int) error { + token := &e.tokens[tokenIdx] + run := e.dynamics + if !e.dynamicsDue(*token) { + return nil + } + token.Wait = nil + token.moved = e.sweep + + state, err := e.stateVector() + if err != nil { + return err + } + input, err := e.inputVector() + if err != nil { + return err + } + if !run.settled() { + ended, err := e.settleStep(run, state, input) + if err != nil { + return err + } + if ended || (run.stops && run.due() > run.stop+run.step*1e-9) { + return e.retireToken(tokenIdx) + } + return e.parkDynamics(run, &e.tokens[tokenIdx]) + } + next, err := e.nextState(run, state, input) + if err != nil { + return e.divergence(run, err) + } + run.steps++ + if err := e.checkFinite(next); err != nil { + return err + } + if err := e.setFeature(lower.StateSpaceFeature, next); err != nil { + return err + } + ended, err := e.settleStep(run, next, input) + if err != nil { + return err + } + if ended || (run.stops && run.due() > run.stop+run.step*1e-9) { + return e.retireToken(tokenIdx) + } + return e.parkDynamics(run, &e.tokens[tokenIdx]) +} + +// due is the instant the run's next step is due at. +func (run *stateSpaceRun) due() float64 { + return run.start + float64(run.steps+1)*run.step +} + +// stepStart is the instant the run's next step advances from: where its latest settled. +func (run *stateSpaceRun) stepStart() float64 { + return run.start + float64(run.steps)*run.step +} + +// parkDynamics parks the run's token on the clock until its next step is due; a +// step due past the last instant a float64 holds is refused, so the clock stays finite. +func (e *ActionExecutor) parkDynamics(run *stateSpaceRun, token *Token) error { + due := run.due() + if math.IsInf(due, 0) { + return fmt.Errorf("%w: step %d of action %s from t=%s leads past the last instant the clock can hold", + ErrStateSpaceStep, run.steps+1, symbolText(e.action), semantics.FormatReal(run.stepStart())) + } + token.Wait = &AcceptWait{ + Trigger: fmt.Sprintf("step %d of %s", run.steps+1, symbolText(e.action)), + Since: e.stepCount + 1, + Timed: true, + Due: due, + } + return nil +} + +// settleStep records the state reached at the clock's instant: the time feature, +// the output, the trace line and the zero crossings, true when one ends the run. +func (e *ActionExecutor) settleStep(run *stateSpaceRun, state Value, input *Value) (bool, error) { + now := e.ctx.clock.now + if run.dyn.Time != nil { + if err := e.setFeature(lower.TimeFeature, e.instantValue(now)); err != nil { + return false, err + } + } + if input == nil { + var err error + if input, err = e.inputVector(); err != nil { + return false, err + } + } + output, err := e.invokeProtocolCalc(run.dyn.OutputCalc, lower.GetOutputCalc, input, state, nil) + if err != nil { + return false, err + } + if _, err := e.checkVector(lower.OutputFeature, output); err != nil { + return false, err + } + if err := e.setFeature(lower.OutputFeature, output); err != nil { + return false, err + } + if tr := e.trace(); tr != nil { + tr.RecordStateSpaceStep(symbolText(e.action), now, state, output) + } + return e.watchCrossings(run, now) +} + +// instantValue is the clock's instant t as the context spells one. +func (e *ActionExecutor) instantValue(t float64) Value { + return e.ctx.instantValue(t) +} + +// nextState is the state one step on: the library's getNextState integrates the +// derivative or adds the difference, one the model bodies itself is called as written. +func (e *ActionExecutor) nextState(run *stateSpaceRun, state Value, input *Value) (Value, error) { + dyn := run.dyn + if dyn.NextState != nil { + next, err := e.invokeProtocolCalc(dyn.NextState, lower.GetNextStateCalc, input, state, &run.stepValue) + if err != nil { + return Value{}, err + } + return e.checkVector(lower.GetNextStateCalc, next) + } + if dyn.Kind == lower.DiscreteDynamics { + diff, err := e.invokeProtocolCalc(dyn.Difference, lower.GetDifferenceCalc, input, state, nil) + if err != nil { + return Value{}, err + } + if _, err := e.checkVector(lower.GetDifferenceCalc, diff); err != nil { + return Value{}, err + } + return e.vectorOp(ast.OpAdd, state, diff) + } + switch dyn.Integrator { + case lower.IntegratorEuler: + return e.eulerStep(run, state, input) + default: + return e.rk4Step(run, state, input) + } +} + +// eulerStep advances the state by the step times the derivative at its start. +func (e *ActionExecutor) eulerStep(run *stateSpaceRun, state Value, input *Value) (Value, error) { + k, err := e.derivative(run, input, state) + if err != nil { + return Value{}, err + } + rate, err := e.vectorOp(ast.OpMul, k, run.stepValue) + if err != nil { + return Value{}, err + } + return e.vectorOp(ast.OpAdd, state, rate) +} + +// rk4Step advances the state by the classical Runge-Kutta scheme: the derivative +// at the start, twice at the midpoint and at the end, each stage at its own +// instant of the step, weighted 1:2:2:1. +func (e *ActionExecutor) rk4Step(run *stateSpaceRun, state Value, input *Value) (Value, error) { + half, err := e.vectorOp(ast.OpMul, run.stepValue, realConst(0.5)) + if err != nil { + return Value{}, err + } + start := run.stepStart() + mid, end := start+run.step/2, start+run.step + k1, err := e.derivative(run, input, state) + if err != nil { + return Value{}, err + } + k2, err := e.derivativeAt(run, input, state, k1, half, mid) + if err != nil { + return Value{}, err + } + k3, err := e.derivativeAt(run, input, state, k2, half, mid) + if err != nil { + return Value{}, err + } + k4, err := e.derivativeAt(run, input, state, k3, run.stepValue, end) + if err != nil { + return Value{}, err + } + sum := k1 + for _, term := range []struct { + k Value + weight float64 + }{{k2, 2}, {k3, 2}, {k4, 1}} { + weighted, err := e.vectorOp(ast.OpMul, term.k, realConst(term.weight)) + if err != nil { + return Value{}, err + } + if sum, err = e.vectorOp(ast.OpAdd, sum, weighted); err != nil { + return Value{}, err + } + } + sixth, err := e.vectorOp(ast.OpDiv, run.stepValue, realConst(6)) + if err != nil { + return Value{}, err + } + rate, err := e.vectorOp(ast.OpMul, sum, sixth) + if err != nil { + return Value{}, err + } + return e.vectorOp(ast.OpAdd, state, rate) +} + +// derivativeAt is the derivative at the instant t and the state reached from +// state by advancing along slope for a span of the step. +func (e *ActionExecutor) derivativeAt(run *stateSpaceRun, input *Value, state, slope, span Value, t float64) (Value, error) { + advance, err := e.vectorOp(ast.OpMul, slope, span) + if err != nil { + return Value{}, err + } + at, err := e.vectorOp(ast.OpAdd, state, advance) + if err != nil { + return Value{}, err + } + return e.atInstant(run, t, func() (Value, error) { return e.derivative(run, input, at) }) +} + +// atInstant evaluates a stage with the time feature holding instant t, then +// restores the instant the step started from; without a time feature it just evaluates. +func (e *ActionExecutor) atInstant(run *stateSpaceRun, t float64, stage func() (Value, error)) (Value, error) { + if run.dyn.Time == nil { + return stage() + } + saved, held := e.root.data[e.root.key(lower.TimeFeature)] + if !held { + saved = e.instantValue(run.stepStart()) + } + if err := e.setFeature(lower.TimeFeature, e.instantValue(t)); err != nil { + return Value{}, err + } + val, err := stage() + if restoreErr := e.setFeature(lower.TimeFeature, saved); restoreErr != nil && err == nil { + err = restoreErr + } + return val, err +} + +// derivative is the model's getDerivative at a state, checked to be a vector. +func (e *ActionExecutor) derivative(run *stateSpaceRun, input *Value, state Value) (Value, error) { + k, err := e.invokeProtocolCalc(run.dyn.Derivative, lower.GetDerivativeCalc, input, state, nil) + if err != nil { + return Value{}, err + } + return e.checkVector(lower.GetDerivativeCalc, k) +} + +// vectorOp applies an arithmetic operator over vectors and scalars, as the +// VectorFunctions and VectorCalculations libraries define it; a scalar pair +// multiplies or divides as quantities do. +func (e *ActionExecutor) vectorOp(op ast.OperatorKind, left, right Value) (Value, error) { + if !isVectorKind(left) && !isVectorKind(right) { + val, err := e.ctx.arithmeticValues(op, left, right, source.Span{}) + if err != nil { + return Value{}, fmt.Errorf("%w: step of action %s: %w", ErrStateSpaceValue, symbolText(e.action), err) + } + return val, nil + } + val, handled, err := e.ctx.vectorArithmetic(op, left, right) + if err != nil { + return Value{}, fmt.Errorf("%w: step of action %s: %w", ErrStateSpaceValue, symbolText(e.action), err) + } + if !handled { + return Value{}, fmt.Errorf("%w: step of action %s: %s %s %s is not defined over these values", + ErrStateSpaceValue, symbolText(e.action), describeValue(left), op, describeValue(right)) + } + return val, nil +} + +// divergence reports a step whose arithmetic overflowed as the dynamics diverging, +// with the step and instant it happened at; any other error passes through. +func (e *ActionExecutor) divergence(run *stateSpaceRun, err error) error { + if !errors.Is(err, semantics.ErrArithmeticOverflow) { + return err + } + return fmt.Errorf("%w: step %d of action %s at t=%s: %w", ErrStateSpaceDiverged, + run.steps+1, symbolText(e.action), semantics.FormatReal(e.ctx.clock.now), err) +} + +// checkFinite refuses a state with a component that is not a finite number. +func (e *ActionExecutor) checkFinite(state Value) error { + v, err := readVector(lower.StateSpaceFeature, lower.StateSpaceFeature, state) + if err != nil { + return fmt.Errorf("%w: %w", ErrStateSpaceValue, err) + } + for i, n := range v.num { + if x := asReal(n); math.IsNaN(x) || math.IsInf(x, 0) { + return fmt.Errorf("%w: component %d of %s of action %s is %s after step %d at t=%s", + ErrStateSpaceDiverged, i+1, lower.StateSpaceFeature, symbolText(e.action), + semantics.FormatReal(x), e.dynamics.steps, semantics.FormatReal(e.ctx.clock.now)) + } + } + return nil +} + +// invokeProtocolCalc calls one of the protocol's calcs as the action provides it, +// binding input, stateSpace and, for getNextState, timeStep by name; the calc runs +// over the action's own frame, so it reads the action's other features. +func (e *ActionExecutor) invokeProtocolCalc(calc *symbols.Symbol, what string, input *Value, state Value, step *Value) (Value, error) { + shape, err := e.ctx.calcShapeOf(calc) + if err != nil { + return Value{}, fmt.Errorf("%w: %s of action %s: %w", lower.ErrUnsupportedStateSpace, what, symbolText(e.action), err) + } + named := map[string]Value{lower.StateSpaceFeature: state} + if input != nil { + named[lower.InputFeature] = *input + } + if step != nil { + named[lower.TimeStepFeature] = *step + } + ec := e.evalContextFor(e.root, e.graph.Scope) + defer ec.beginStep()() + result, err := e.ctx.invokeCalcShapeIn(shape, calcArgs{named: named}, ec.scope, ec.self, ec.enclosingRun(shape)) + if err != nil { + return Value{}, fmt.Errorf("%s of action %s: %w", what, symbolText(e.action), err) + } + return result, nil +} + +// crosses reports a guard that changed sign or arrived at zero over a step; one +// resting at zero, or leaving it, crossed at its arrival and does not again. +func crosses(was, guard float64) bool { + if guard == 0 { + return was != 0 + } + return was != 0 && math.Signbit(guard) != math.Signbit(was) +} + +// watchCrossings evaluates every guard after a step and raises the event of each +// that crosses zero; true when a terminal one fired. +func (e *ActionExecutor) watchCrossings(run *stateSpaceRun, now float64) (bool, error) { + first := run.guards == nil + if first { + run.guards = make([]float64, len(run.dyn.Crossings)) + } + ended := false + for i, crossing := range run.dyn.Crossings { + guard, err := e.evalGuard(crossing) + if err != nil { + return false, err + } + was := run.guards[i] + run.guards[i] = guard + if first || !crosses(was, guard) { + continue + } + terminal, err := e.crossingTerminal(crossing) + if err != nil { + return false, err + } + e.ctx.PostMessage(Message{ + SignalType: crossing.EventType.Name, + Signal: crossing.EventType, + Event: crossing.Event, + EventName: crossing.Name, + EventObject: objectID(e.occurrence), + Payload: map[string]Value{}, + }) + if tr := e.trace(); tr != nil { + tr.RecordEvent("zero crossing "+crossing.Name+" of "+symbolText(e.action), now) + } + ended = ended || terminal + } + return ended, nil +} + +// evalGuard is a crossing's guard as a real number, over the action's features. +func (e *ActionExecutor) evalGuard(crossing lower.ZeroCrossing) (float64, error) { + val, err := e.evalCrossingExpr(crossing.Guard, crossing.GuardScope) + if err != nil { + return 0, fmt.Errorf("guard of zero crossing %s of action %s: %w", crossing.Name, symbolText(e.action), err) + } + if q, ok := asQuantity(val); ok { + return asReal(q.Num), nil + } + return 0, fmt.Errorf("%w: guard of zero crossing %s of action %s is %s, not a number", + ErrStateSpaceValue, crossing.Name, symbolText(e.action), describeValue(val)) +} + +// crossingTerminal reads whether a crossing ends the run, false where it states nothing. +func (e *ActionExecutor) crossingTerminal(crossing lower.ZeroCrossing) (bool, error) { + if crossing.Terminal == nil { + return false, nil + } + val, err := e.evalCrossingExpr(crossing.Terminal, crossing.TerminalScope) + if err != nil { + return false, fmt.Errorf("terminal of zero crossing %s of action %s: %w", crossing.Name, symbolText(e.action), err) + } + if val.Kind != ValConst || val.Const.Kind != semantics.ValBool { + return false, fmt.Errorf("%w: terminal of zero crossing %s of action %s is %s, not a Boolean", + ErrStateSpaceValue, crossing.Name, symbolText(e.action), describeValue(val)) + } + return val.Const.Bool, nil +} + +// evalCrossingExpr evaluates an expression of a crossing in the scope it was +// written, over the action's own frame. +func (e *ActionExecutor) evalCrossingExpr(expr ast.Node, scope *symbols.Scope) (Value, error) { + if scope == nil { + scope = e.graph.Scope + } + ec := e.evalContextFor(e.root, scope) + defer ec.beginStep()() + return ec.Eval(expr) +} diff --git a/internal/exec/runtime/state_space_accuracy_test.go b/internal/exec/runtime/state_space_accuracy_test.go new file mode 100644 index 0000000000..5d311f5617 --- /dev/null +++ b/internal/exec/runtime/state_space_accuracy_test.go @@ -0,0 +1,95 @@ +package runtime + +import ( + "math" + "os" + "path/filepath" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestStateSpaceIntegratorsAgainstClosedForm runs the first-order decay fixtures +// and compares each integrator's x(2) with e^-1: RK4 lands within its +// fourth-order error, Euler a first-order error away, visibly worse. +func TestStateSpaceIntegratorsAgainstClosedForm(t *testing.T) { + const exact = 1 / math.E + rk4 := stateSpaceFinalState(t, "state_space_rk4_first_order.sysml") + euler := stateSpaceFinalState(t, "state_space_euler_first_order.sysml") + + if err := math.Abs(rk4 - exact); err > 1e-6 { + t.Errorf("RK4 x(2) = %v, %v from e^-1; want within 1e-6", rk4, err) + } + eulerErr := math.Abs(euler - exact) + if eulerErr < 1e-3 { + t.Errorf("Euler x(2) = %v, only %v from e^-1; a step of 0.1 should err by about 1e-2", euler, eulerErr) + } + if eulerErr <= math.Abs(rk4-exact) { + t.Errorf("Euler's error %v is not worse than RK4's %v", eulerErr, math.Abs(rk4-exact)) + } +} + +// stateSpaceFinalState runs the conformance fixture's action test::decay to +// completion and returns the one component of its final state. +func stateSpaceFinalState(t *testing.T, fixture string) float64 { + t.Helper() + src, err := os.ReadFile(filepath.Join("testdata", "conformance", fixture)) + if err != nil { + t.Fatal(err) + } + idx, _, ctx := buildRuntimeWithLibraries(t, fixture, parseAndBuild(t, string(src))) + sym := findSymbolByName(idx.DocumentRoot(fixture), "decay", ast.DefAction) + if sym == nil { + t.Fatalf("%s: action decay not found", fixture) + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + t.Fatalf("%s: %v", fixture, err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("%s: %v", fixture, err) + } + state := exec.Results()["stateSpace"] + vq := state.VectorQuantity() + if vq == nil || len(vq.Num) != 1 || vq.Num[0].Kind != semantics.ValReal { + t.Fatalf("%s: stateSpace = %s, want a one-component real vector quantity", fixture, FormatValue(state)) + } + return vq.Num[0].Real +} + +// TestZeroCrossingPredicate pins when a guard's sample after a step raises the +// event: a change of sign or the first arrival at zero, once — not while the guard +// rests at zero, nor as it leaves zero. +func TestZeroCrossingPredicate(t *testing.T) { + cases := []struct { + was, guard float64 + want bool + }{ + {1, -1, true}, + {-1, 1, true}, + {1, 0, true}, + {-1, 0, true}, + {0, 0, false}, + {0, 1, false}, + {0, -1, false}, + {1, 2, false}, + {-2, -1, false}, + } + for _, c := range cases { + if got := crosses(c.was, c.guard); got != c.want { + t.Errorf("crosses(%v, %v) = %v, want %v", c.was, c.guard, got, c.want) + } + } + for i, guards := range [][]float64{{1, 0, 0, 0, -1}, {1, 0, 0, 1, 2}} { + var raised int + for j := 1; j < len(guards); j++ { + if crosses(guards[j-1], guards[j]) { + raised++ + } + } + if raised != 1 { + t.Errorf("guards %v (case %d) raised %d crossings, want 1", guards, i, raised) + } + } +} diff --git a/internal/exec/runtime/state_space_robustness_test.go b/internal/exec/runtime/state_space_robustness_test.go new file mode 100644 index 0000000000..064ce05111 --- /dev/null +++ b/internal/exec/runtime/state_space_robustness_test.go @@ -0,0 +1,283 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestStateSpaceRobustness exercises the shapes a state-space run refuses: each is +// a typed error naming the action and the protocol member at fault, never a wrong result. +func TestStateSpaceRobustness(t *testing.T) { + t.Run("state_that_is_not_a_vector", testStateSpaceStateNotAVector) + t.Run("state_without_an_initial_value", testStateSpaceStateWithoutAnInitialValue) + t.Run("derivative_left_abstract", testStateSpaceDerivativeLeftAbstract) + t.Run("difference_left_abstract", testStateSpaceDifferenceLeftAbstract) + t.Run("derivative_that_is_not_a_vector", testStateSpaceDerivativeNotAVector) + t.Run("output_that_is_not_a_vector", testStateSpaceOutputNotAVector) + t.Run("step_not_stated", testStateSpaceStepNotStated) + t.Run("step_that_is_zero", testStateSpaceStepZero) + t.Run("step_that_is_negative", testStateSpaceStepNegative) + t.Run("integrator_the_runtime_does_not_provide", testStateSpaceUnknownIntegrator) + t.Run("divergent_state", testStateSpaceDivergentState) + t.Run("step_past_the_last_instant", testStateSpaceStepPastLastInstant) + t.Run("guard_that_is_not_a_number", testStateSpaceGuardNotANumber) + t.Run("return_parameter", testStateSpaceReturnParameter) +} + +// stateSpaceSource wraps an action body in a package importing what a +// state-space model needs, the action named `dyn`. +func stateSpaceSource(header, body string) string { + return `package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + action dyn : ` + header + ` { + in :>> input = VectorOf((0.0)); + ` + body + ` + } + }` +} + +const decayCalcs = ` + calc :>> getDerivative { + in input : Input; + in stateSpace : StateSpace; + return : StateDerivative = (0.0 - 0.5) * stateSpace / 1 [s]; + } + calc :>> getOutput { + in input : Input; + in stateSpace : StateSpace; + return : Output = stateSpace; + } +` + +// runStateSpace runs action test::dyn of src to completion, returning the error +// its start or its run reports. +func runStateSpace(t *testing.T, src string) error { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), "dyn", ast.DefAction) + if sym == nil { + t.Fatal("action dyn not found") + } + exec, err := ctx.CreateActionExecutor(sym) + if err != nil { + return err + } + return exec.RunToCompletion() +} + +func expectStateSpaceError(t *testing.T, err, want error, says ...string) { + t.Helper() + if !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } + for _, s := range says { + if !strings.Contains(err.Error(), s) { + t.Errorf("error %q does not say %q", err, s) + } + } +} + +func testStateSpaceStateNotAVector(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + :>> stateSpace = 1.0; + :>> timeStep = 0.1 [s]; + :>> stopTime = 1 [s]; + `+decayCalcs)) + expectStateSpaceError(t, err, ErrStateSpaceValue, "stateSpace of action dyn", "not a vector") +} + +func testStateSpaceReturnParameter(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + return result : StateSpace; + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = 0.1 [s]; + :>> stopTime = 1 [s]; + `+decayCalcs)) + expectStateSpaceError(t, err, ErrActionResultParameter, "action dyn declares `return result`", "write `out result`") +} + +func testStateSpaceStateWithoutAnInitialValue(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + :>> timeStep = 0.1 [s]; + :>> stopTime = 1 [s]; + `+decayCalcs)) + expectStateSpaceError(t, err, ErrStateSpaceValue, "action dyn binds no stateSpace") +} + +func testStateSpaceDerivativeLeftAbstract(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = 0.1 [s]; + :>> stopTime = 1 [s]; + calc :>> getOutput { + in input : Input; + in stateSpace : StateSpace; + return : Output = stateSpace; + } + `)) + expectStateSpaceError(t, err, lower.ErrUnsupportedStateSpace, "action dyn leaves getDerivative abstract") +} + +func testStateSpaceDifferenceLeftAbstract(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("DiscreteStateSpaceDynamics, FixedStepDynamics", ` + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = 1 [s]; + :>> stopTime = 3 [s]; + calc :>> getOutput { + in input : Input; + in stateSpace : StateSpace; + return : Output = stateSpace; + } + `)) + expectStateSpaceError(t, err, lower.ErrUnsupportedStateSpace, "action dyn leaves getDifference abstract") +} + +func testStateSpaceDerivativeNotAVector(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = 0.1 [s]; + :>> stopTime = 1 [s]; + calc :>> getDerivative { + in input : Input; + in stateSpace : StateSpace; + return : Real = 0.0 - 0.5; + } + calc :>> getOutput { + in input : Input; + in stateSpace : StateSpace; + return : Output = stateSpace; + } + `)) + expectStateSpaceError(t, err, ErrStateSpaceValue, "getDerivative of action dyn", "not a vector") +} + +func testStateSpaceOutputNotAVector(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = 0.1 [s]; + :>> stopTime = 1 [s]; + calc :>> getDerivative { + in input : Input; + in stateSpace : StateSpace; + return : StateDerivative = (0.0 - 0.5) * stateSpace / 1 [s]; + } + calc :>> getOutput { + in input : Input; + in stateSpace : StateSpace; + return : Real = stateSpace.elements#(1); + } + `)) + expectStateSpaceError(t, err, ErrStateSpaceValue, "output of action dyn", "not a vector") +} + +func testStateSpaceStepNotStated(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics", ` + :>> stateSpace = VectorOf((1.0)); + `+decayCalcs)) + expectStateSpaceError(t, err, ErrStateSpaceStep, "action dyn states no timeStep", lower.FixedStepDynamicsFQN) +} + +func testStateSpaceStepZero(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = 0 [s]; + :>> stopTime = 1 [s]; + `+decayCalcs)) + expectStateSpaceError(t, err, ErrStateSpaceStep, "timeStep of action dyn", "not a positive duration") +} + +func testStateSpaceStepNegative(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = -0.1 [s]; + :>> stopTime = 1 [s]; + `+decayCalcs)) + expectStateSpaceError(t, err, ErrStateSpaceStep, "timeStep of action dyn", "not a positive duration") +} + +func testStateSpaceUnknownIntegrator(t *testing.T) { + err := runStateSpace(t, `package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + calc def Heun :> Integrate; + + action dyn : ContinuousStateSpaceDynamics, FixedStepDynamics { + in :>> input = VectorOf((0.0)); + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = 0.1 [s]; + :>> stopTime = 1 [s]; + `+decayCalcs+` + calc :>> getNextState { + calc :>> integrate : Heun; + } + } + }`) + expectStateSpaceError(t, err, lower.ErrUnsupportedStateSpace, "binds integrate to Heun", "Euler", "RK4") +} + +// A derivative growing faster than the state overflows within a few steps; the +// step that leaves a component non-finite is refused with the instant it happened at. +func testStateSpaceDivergentState(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + :>> stateSpace = VectorOf((10.0)); + :>> timeStep = 1 [s]; + :>> stopTime = 100 [s]; + calc :>> getDerivative { + in input : Input; + in stateSpace : StateSpace; + return : StateDerivative = stateSpace * stateSpace.elements#(1) * stateSpace.elements#(1) / 1 [s]; + } + calc :>> getOutput { + in input : Input; + in stateSpace : StateSpace; + return : Output = stateSpace; + } + `)) + expectStateSpaceError(t, err, ErrStateSpaceDiverged, "of action dyn at t=", "not a finite Real") +} + +// A step so long that the next instant is not a finite number is refused before +// the token is parked, so the clock never advances to infinity; the state itself +// stays finite here, the derivative being zero. +func testStateSpaceStepPastLastInstant(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = 1.0e308 [s]; + calc :>> getDerivative { + in input : Input; + in stateSpace : StateSpace; + return : StateDerivative = 0.0 * stateSpace / 1 [s]; + } + calc :>> getOutput { + in input : Input; + in stateSpace : StateSpace; + return : Output = stateSpace; + } + `)) + expectStateSpaceError(t, err, ErrStateSpaceStep, "step 2 of action dyn", "last instant the clock can hold") +} + +func testStateSpaceGuardNotANumber(t *testing.T) { + err := runStateSpace(t, stateSpaceSource("ContinuousStateSpaceDynamics, FixedStepDynamics", ` + :>> stateSpace = VectorOf((1.0)); + :>> timeStep = 0.1 [s]; + :>> stopTime = 1 [s]; + event occurrence low : ZeroCrossing { + :>> guard = stateSpace.elements#(1) < 0.5; + } + `+decayCalcs)) + expectStateSpaceError(t, err, ErrStateSpaceValue, "guard of zero crossing low of action dyn", "not a number") +} diff --git a/internal/exec/runtime/state_statements.go b/internal/exec/runtime/state_statements.go new file mode 100644 index 0000000000..f0a02a40d3 --- /dev/null +++ b/internal/exec/runtime/state_statements.go @@ -0,0 +1,538 @@ +package runtime + +import ( + "fmt" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// stateStmtHost runs the statements of a state machine behavior written as an +// inline action body: an assignment to a name the body does not declare reaches +// the machine's state data, and a send posts through the machine's connections. +// The behavior's execution is a performance whose nodes the body's blocks declare. +type stateStmtHost struct { + exec *StateExecutor + behavior lower.StateBehavior + // flow runs the token flow the body or one of its nodes states, as the + // standalone action executor does; perfs are its performances. + flow *ActionExecutor + perfs *performances + // attrs are the attributes of the state the behavior belongs to and of the + // states enclosing it, innermost first. + attrs []map[string]Value + // firing is the transition the behavior reads as being taken: the one under way + // for an entry, exit or effect, the one that entered the state for a do behavior. + firing *firing + // terminated: the run ended by a `terminate` of the behavior's own performance. + terminated bool +} + +// executeBehaviors runs behaviors in order, each to its end at the instant. One a +// `terminate` ends takes the rest of its block with it; the others run as written. +func (e *StateExecutor) executeBehaviors(behaviors []lower.StateBehavior) error { + var ended []ast.Node + for _, behavior := range behaviors { + if e.endedBefore(ended, behavior) { + continue + } + terminated, err := e.executeBehavior(behavior) + if err != nil { + return err + } + if terminated { + ended = append(ended, behavior.Block) + } + } + return nil +} + +// executeBehavior runs one behavior to its end at the instant it is triggered (one +// waiting on the clock is an error; a do behavior runs as a doRun instead) and +// reports a run a `terminate` of the behavior's own performance ended. +func (e *StateExecutor) executeBehavior(behavior lower.StateBehavior) (bool, error) { + if len(behavior.Body) == 0 { + return false, nil + } + host := e.behaviorHost(behavior, e.currentFiring()) + defer e.ctx.holdClock(host.describe())() + if err := host.run(); err != nil { + return false, err + } + return host.terminated, nil +} + +// endedBefore reports whether a `terminate` ended behavior's block already: the +// behavior then ends before it begins, which the trace records. +func (e *StateExecutor) endedBefore(ended []ast.Node, behavior lower.StateBehavior) bool { + if behavior.Block == nil || !slices.Contains(ended, behavior.Block) { + return false + } + if tr := e.trace(); tr != nil { + tr.RecordActionTerminatePending(describeBehavior(behavior), false) + } + return true +} + +// behaviorHost prepares one execution of a behavior: its performance over the +// machine's data and the attributes of the states around it, within firing. +func (e *StateExecutor) behaviorHost(behavior lower.StateBehavior, firing *firing) *stateStmtHost { + host := &stateStmtHost{exec: e, behavior: behavior, attrs: e.attrFramesFor(behavior.Owner), firing: firing} + host.flow = &ActionExecutor{ + performances: performances{ctx: e.ctx, self: e.self, root: host.rootFrame(host.attrs), owner: host, behavior: e.stateMachine}, + action: behaviorSymbol(behavior), + state: StateRunning, + nextTokenID: 1, + breakpoints: make(map[string]bool), + firedBreakpoints: make(map[breakpointVisit]bool), + } + host.flow.flow = host.flow + host.flow.driven.exec = host.flow + host.flow.driven.caller = &e.driven + host.perfs = &host.flow.performances + return host +} + +// currentFiring is the transition being taken with its payload copied, so a +// behavior performed or resumed after the firing still reads them. +func (e *StateExecutor) currentFiring() *firing { + return e.firingOf(e.firingTrans) +} + +// firingOf is the firing of t with the payload its trigger bound, copied from +// the machine's data as it stands; nil t is a firing of no transition. +func (e *StateExecutor) firingOf(t *lower.Transition) *firing { + f := &firing{taken: t} + if t != nil && len(t.Accepted) > 0 { + f.payload = make(map[string]Value, len(t.Accepted)) + for _, name := range t.Accepted { + if v, ok := e.stateData[name]; ok { + f.payload[name] = v + } + } + } + return f +} + +// dataFrame is the machine's data as the behavior reads it, within its firing. +func (h *stateStmtHost) dataFrame() frame { + return frame{vars: h.exec.stateData, firing: h.firing} +} + +// run executes the behavior's statements; a do behavior's pause where they wait +// and are re-entered (perform). +func (h *stateStmtHost) run() error { + _, err := h.exec.ctx.runStatements(func() *stmtEngine { + engine := newStmtEngineOver(h.exec.ctx, h, h.dataFrame(), h.attrs) + engine.env.perf = h.perfs.root + return engine + }, h.behavior.Body) + return h.ended(err) +} + +// ended settles a run of the behavior that err ended: a terminate of the behavior's +// own performance is its end, dropping what its flow still ran and ending the +// performances nested in it; any other err is returned as is. +func (h *stateStmtHost) ended(err error) error { + root := h.perfs.root + if !terminates(err, root) { + return err + } + if !root.inBody || len(h.flow.tokensIn(root)) > 0 { + h.flow.dropTokensIn(root, 0) + } + endNested(root) + root.live = 0 + h.flow.state = StateCompleted + h.terminated = true + if t := unwound(err); t != nil { + return h.flow.endAlongside(t) + } + return nil +} + +// endNested marks the performances nested in perf ended, perf itself kept. +func endNested(perf *actionFrame) { + for _, sub := range perf.subactions { + sub.ended, sub.live = true, 0 + endNested(sub) + } +} + +func (h *stateStmtHost) perform() error { return h.run() } + +// clone is the host itself: what its run changes is the flow's, captured with it. +func (h *stateStmtHost) clone() bodyWork { return h } + +// doRun is a do behavior under way as a body run: it yields after each statement +// of its body, and a wait on the clock or for a message in its flow pauses it +// there, to be resumed in a later round or ended when the state is exited, while +// the machine goes on around it. +type doRun struct { + host *stateStmtHost + body *bodyRun + // mail is what the run's accepts read in place of the bus: the message the + // machine is dispatching to it, none between dispatches. + mail []Message +} + +// newDoRun prepares a do behavior to run within the firing that entered its state, +// its first statement yet to be performed; nil for a behavior with no statement to run. +func (e *StateExecutor) newDoRun(behavior lower.StateBehavior, firing *firing) *doRun { + if len(behavior.Body) == 0 { + return nil + } + host := e.behaviorHost(behavior, firing) + body := &bodyRun{work: host, awaitsMessages: true, yields: true, steps: e.ctx.scheduling().oneMove()} + return &doRun{host: host, body: body} +} + +// resume lets the run go on to its next statement boundary or wait, or to its end. +func (run *doRun) resume(ctx *Context) (*doRun, error) { + defer ctx.readingMail(&run.mail)() + defer func() { run.mail = nil }() + run.host.terminated = false + for { + pause, paused := run.body.resume(ctx) + if !paused { + return nil, run.body.err + } + if pause.tokenStep || pause.onWait || pause.yielded { + return run, nil + } + } +} + +// offer resumes the run with the message its machine dispatches to it, which the +// accept it is parked at takes; the run goes on to where it next waits. +func (run *doRun) offer(ctx *Context, m Message) (*doRun, error) { + run.mail = []Message{m} + return run.resume(ctx) +} + +// resumable reports a run due to go on: one yielded between statements or between +// two moves of its flow, or one whose wait on the clock has ended; a run parked +// for a message stays until its machine dispatches one to it. +func (run *doRun) resumable(ctx *Context) bool { + if run.body.paused.yielded || run.body.paused.tokenStep { + return true + } + defer ctx.readingMail(&run.mail)() + return !run.body.paused.wait.goesOn() +} + +// end abandons the run for good: nothing of the behavior runs after it. +func (run *doRun) end(ctx *Context) { + run.body.end(ctx) +} + +// messageAcceptor is an executor a message in flight may let go on from an accept +// it is parked at. +type messageAcceptor interface { + // acceptTaking lists the accepts parked for m, placed in the performance parked there. + acceptTaking(m Message) ([]TakingAccept, error) + // performanceName names the performance the accepts are placed in. + performanceName() string +} + +// acceptsMessage reports whether the run, paused at an accept of its flow or of +// the action it performs, would take m; a port failing to resolve is the error. +func (run *doRun) acceptsMessage(m Message) (bool, error) { + if accepted, err := run.host.flow.acceptsMessage(m); err != nil || accepted { + return accepted, err + } + if held := run.body.paused.wait.held; held != nil { + return held.acceptsMessage(m) + } + return false, nil +} + +// armedWaits lists the waits on the clock the run is paused on, due or not: its +// flow's, and those of the action it performs where that is what waits. +func (run *doRun) armedWaits() []ClockWait { + waits := run.host.flow.armedWaits() + if held := run.body.paused.wait.held; held != nil { + waits = append(waits, held.armedWaits()...) + } + return waits +} + +// visibleArmedWaits lists armedWaits and the waits of the actions performed, in +// turn, for the paused work of the flow and of the action performed. +func (run *doRun) visibleArmedWaits() []ClockWait { + waits := run.host.flow.visibleArmedWaits() + if held := run.body.paused.wait.held; held != nil { + waits = append(waits, held.visibleArmedWaits()...) + } + return waits +} + +// rootFrame is the performance of the behavior itself: it holds no feature of its +// own, and reads the machine's data and the enclosing states' attributes around it. +func (h *stateStmtHost) rootFrame(attrs []map[string]Value) *actionFrame { + root := &actionFrame{ + scope: h.behavior.Scope, + connections: h.exec.graph.Connections, + data: make(map[string]Value), + features: make(map[string]ast.FeatureDirection), + subactions: make(map[ast.Node]*actionFrame), + nodes: h.behavior.Nodes, + label: h.describe(), + outer: []frame{h.dataFrame()}, + run: h.exec.ctx.newRun(), + } + if root.scope == nil { + root.scope = h.exec.stateMachine.Scope + } + for _, attr := range attrs { + root.outer = append(root.outer, mapFrame(attr)) + } + return root +} + +// behaviorSymbol is the symbol of the action a behavior's inline body declares, +// nil for a behavior written in another form. +func behaviorSymbol(behavior lower.StateBehavior) *symbols.Symbol { + for _, stmt := range behavior.Body { + block, ok := stmt.(lower.Block) + if !ok || block.Scope == nil || block.Scope.Node() != block.Node { + continue + } + return block.Scope.Owner() + } + return nil +} + +func (h *stateStmtHost) describe() string { return describeBehavior(h.behavior) } + +// describeBehavior names a behavior as the trace and errors report it. +func describeBehavior(behavior lower.StateBehavior) string { + if behavior.Name != "" { + return "state behavior " + behavior.Name + } + if usage, ok := behavior.Node.(*ast.Usage); ok { + return "state behavior " + stateActionName(usage) + } + return "anonymous state behavior" +} + +func (h *stateStmtHost) send(ec *EvalContext, s lower.Send) error { + return h.exec.ctx.send(ec, h.exec.stateMachine.Scope, h.exec.graph.Connections, s, h.exec.self, h.exec.stateMachine) +} + +// assignOuter writes a name the machine does not declare to the object +// exhibiting it, falling back to executor-local data. +func (h *stateStmtHost) assignOuter(env *stmtEnv, name string, value Value, s lower.Assign) error { + if written, err := h.assignStateAttribute(name, value); written || err != nil { + return err + } + if h.exec.declaresAttribute(name) { + return h.exec.assignAttribute(name, value) + } + if written, err := assignPerformerFeature(h.exec.ctx, h.exec.self, s.Scope, name, value); written || err != nil { + return err + } + return storeBodyValue(h.exec.ctx, h, env, name, value, s) +} + +func (h *stateStmtHost) assignData(env *stmtEnv, name string, value Value, s lower.Assign) error { + if written, err := h.assignStateAttribute(name, value); written || err != nil { + return err + } + if h.exec.declaresAttribute(name) { + return h.exec.assignAttribute(name, value) + } + return storeBodyValue(h.exec.ctx, h, env, name, value, s) +} + +// assignChain writes the feature a chained target names on the object its chain +// reaches, which is the machine's state data for no chained target. +func (h *stateStmtHost) assignChain(ec *EvalContext, s lower.Assign, value Value) error { + return assignThroughChain(ec, h.describe(), s, value) +} + +// assignStateAttribute writes an attribute owned by the state running this +// behavior, or by one enclosing it, and reports whether it did. The value +// answers to the attribute's declaration as every other write does. +func (h *stateStmtHost) assignStateAttribute(name string, value Value) (bool, error) { + data, scope, ok := h.exec.stateAttributeValues(h.behavior.Owner, name) + if !ok { + return false, nil + } + if err := h.exec.ctx.checkNamedWrite(scope, h.describe(), name, &value); err != nil { + return true, err + } + data[name] = value + return true, nil +} + +// performer is the object exhibiting the machine this behavior belongs to. +func (h *stateStmtHost) performer() *Instance { + return h.exec.self +} + +// acceptReturn rejects a `return`: a state behavior computes no result. +func (h *stateStmtHost) acceptReturn(Value, lower.Return) error { + return fmt.Errorf("%w: %s", ErrReturnOutsideCalc, h.describe()) +} + +// effect performs the action a `perform` names, or ends the performance a +// `terminate` names; every other effect a body may state has no execution in a +// state behavior. +func (h *stateStmtHost) effect(engine *stmtEngine, s lower.Effect) error { + if s.Kind == lower.EffectTerminate { + return h.perfs.terminate(engine, h.perfs.root, s) + } + if s.Kind == lower.EffectStart { + if err := h.exec.ctx.startEffect(engine.evalIn(s.Scope), s, h.exec.self); err != nil { + return fmt.Errorf("%s: %w", h.describe(), err) + } + return nil + } + if s.Kind == lower.EffectPerform { + inv, ok := performedInvocation(s) + if !ok { + return fmt.Errorf("%s performs no action", h.describe()) + } + return h.exec.invokeNested(inv) + } + return fmt.Errorf("%s: '%s' in a body is not executable", h.describe(), s.Kind) +} + +// performNode performs a nested action of a block as a subperformance of the +// behavior's, in a frame of its own (performances.performNode). +func (h *stateStmtHost) performNode(engine *stmtEngine, graph *lower.ActionGraph, node *ast.Usage) (stmtFlow, error) { + return h.perfs.performNode(h.perfs.root, engine, graph, node) +} + +func (h *stateStmtHost) runBlockFlow(engine *stmtEngine, block lower.Block) (stmtFlow, error) { + return h.perfs.performBlockFlow(h.perfs.root, engine, block) +} + +// runFlow runs the token flow an inline body states with its successions and +// control nodes, as the behavior's own performance: the body's attributes are +// the performance's, initialized as a standalone action's are. +func (h *stateStmtHost) runFlow(block lower.Block) (stmtFlow, error) { + if resumingAt[*subflowFrame](h.exec.ctx) { + return flowNext, h.flow.runSubflow(h.flow.root) + } + if block.Graph.Initial == nil { + return flowNext, fmt.Errorf("%w: %s: no node starts the flow%s", + ErrInvalidActionFlow, h.describe(), noFlowStart(block.Graph)) + } + if err := h.flow.validateSubflows(block.Graph); err != nil { + return flowNext, fmt.Errorf("%s: %w", h.describe(), err) + } + h.flow.graph = block.Graph + if err := h.flow.checkResultParameters(); err != nil { + return flowNext, fmt.Errorf("%s: %w", h.describe(), err) + } + root := h.flow.root + h.flow.features = h.flow.performanceFeatures() + root.graph = block.Graph + root.connections = block.Graph.Connections + root.live = 1 + if block.Graph.Scope != nil { + root.scope = block.Graph.Scope + } + h.flow.declareRootFeatures(root) + h.flow.declareAcceptPayloads(root) + if err := h.flow.initializeAttributes(); err != nil { + return flowNext, fmt.Errorf("%s: %w", h.describe(), err) + } + return flowNext, h.flow.runSubflow(root) +} + +// setFeature writes a feature the behavior's performance holds: an attribute of +// the body's flow, else what is around it. +func (h *stateStmtHost) setFeature(name string, value Value) error { + if root := h.perfs.root; root.holds(name) { + if err := h.exec.ctx.checkNamedWrite(root.scope, h.describe(), name, &value); err != nil { + return err + } + root.data[root.key(name)] = value + return nil + } + if written, err := h.assignAround(name, value); written || err != nil { + return err + } + return fmt.Errorf("%w: %s holds no %s", ErrBindingEnd, h.describe(), name) +} + +// assignAround writes what a node's performance returns to the state's attribute, +// the machine's attribute or the state datum of that name, whichever exists. +func (h *stateStmtHost) assignAround(name string, value Value) (bool, error) { + if written, err := h.assignStateAttribute(name, value); written || err != nil { + return written, err + } + if h.exec.declaresAttribute(name) { + return true, h.exec.assignAttribute(name, value) + } + if _, ok := h.exec.stateData[name]; ok { + h.exec.stateData[name] = value + return true, nil + } + return assignPerformerFeature(h.exec.ctx, h.exec.self, h.behavior.Scope, name, value) +} + +// returnAround writes a returned output as assignAround does and keeps it for the +// caller when a call event's transition is firing. +func (h *stateStmtHost) returnAround(name string, value Value) (bool, error) { + h.exec.recordCallOutput(name, value) + return h.assignAround(name, value) +} + +// pauseAt sets no breakpoint: a state behavior's nodes are not stepped. +func (h *stateStmtHost) pauseAt([]ast.Node, ast.Node) error { + return nil +} + +// runOwnFlow runs the flow a nested node states of its own, as a subflow of the +// behavior's performance. +func (h *stateStmtHost) runOwnFlow(perf *actionFrame) error { + return h.flow.runSubflow(perf) +} + +// endsOwn allows a terminate to end the behavior's own performance (SysML v2 +// §7.17.10): the behavior ends at the statement and the state it belongs to stays. +func (h *stateStmtHost) endsOwn() bool { return true } + +// performedInvocation reports the action a `perform` statement declared in scope +// names, in either form the parser produces for one. +func performedInvocation(s lower.Effect) (actionInvocation, bool) { + inv, ok := statementInvocation(s.Node) + if ok { + inv.step = memberSymbol(s.Scope, s.Node) + } + return inv, ok +} + +// statementInvocation reads the action a `perform` statement names. +func statementInvocation(node ast.Node) (actionInvocation, bool) { + switch n := node.(type) { + case *ast.PerformActionNode: + if inv := n.PerformedInvocation(); inv != nil { + return expressionInvocation(inv), true + } + switch ref := n.ActionRef.(type) { + case *ast.QualifiedName: + return actionInvocation{target: ref}, true + case *ast.FeatureChainExpr: + return chainedInvocation(ref, nil) + } + case *ast.Usage: + return nestedInvocation(n) + case *ast.ActionExecutionNode: + if n.ActionRef != nil { + return actionInvocation{target: n.ActionRef}, true + } + } + return actionInvocation{}, false +} + +// declaredOutput reports no output features: a state behavior computes none. +func (h *stateStmtHost) declaredOutput(string) bool { + return false +} diff --git a/internal/exec/runtime/state_terminate_test.go b/internal/exec/runtime/state_terminate_test.go new file mode 100644 index 0000000000..99ea6006e4 --- /dev/null +++ b/internal/exec/runtime/state_terminate_test.go @@ -0,0 +1,174 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// TestTerminatedMachineHoldsNoPendingWork: a transition to a terminate action +// nested in a composite state leaves the other region's timer, the event it still +// defers and the change condition it watched with nothing to act on, so the ended +// machine holds none of them. +func TestTerminatedMachineHoldsNoPendingWork(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, `package test { + private import SI::*; + private import Time::*; + attribute def Abort; + attribute def Later; + state def Machine { + attribute level : Integer = 0; + entry; then busy; + state busy parallel { + state r1 { + entry; then a; + state a { defer Later; } + transition first a accept Abort then stop; + } + state r2 { + entry; then b; + state b { defer Later; } + transition first b accept after 10 [s] then c; + transition first b accept when level > 0 then c; + state c; + } + action stop terminate; + } + } + }`)) + exec, err := ctx.CreateStateExecutor(findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState)) + if err != nil { + t.Fatal(err) + } + exec.SendSignal("Later", nil) + exec.SendSignal("Abort", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch Later: %v", err) + } + if got := exec.DeferredEvents(); len(got) != 1 { + t.Fatalf("deferred = %v; want the Later event held by a", got) + } + if fired, err := exec.PollChangeEvents(); err != nil || fired { + t.Fatalf("poll = %v, %v; want nothing fired with level at 0", fired, err) + } + if got := exec.ChangeWaits(); len(got) != 1 { + t.Fatalf("ChangeWaits() = %v; want the level condition b waits on", got) + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("dispatch Abort: %v", err) + } + if exec.State() != StateTerminated { + t.Fatalf("state = %v; want Terminated", exec.State()) + } + if exec.HasPendingWork() { + t.Errorf("HasPendingWork() = true on a terminated machine") + } + if got := exec.EventQueue().Events(); len(got) != 0 { + t.Errorf("queue = %v after termination; want empty", got) + } + if got := exec.DeferredEvents(); len(got) != 0 { + t.Errorf("deferred = %v after termination; want none", got) + } + if _, waiting := exec.NextWait(); waiting { + t.Errorf("NextWait() reports a timer on a terminated machine") + } + if got := exec.ChangeWaits(); len(got) != 0 { + t.Errorf("ChangeWaits() = %v after termination; want none", got) + } +} + +func TestTerminatedMachineDropsHeldEntry(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, `package test { + private import ScalarValues::*; + item def Start; + item def Go; + state Machine parallel { + attribute hits : Integer = 0; + state left { + entry; then start; + state start; + state work { + attribute :>> runToCompletionScope default = self; + entry action { send new Go() to Machine; } then step; + state step; + } + transition first start accept Start then work; + } + state right { + entry; then idle; + state idle; + transition first idle accept Go then stop; + action stop terminate; + } + } + }`)) + exec, err := ctx.CreateStateExecutor(findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState)) + if err != nil { + t.Fatal(err) + } + exec.SendSignal("Start", nil) + if err := exec.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if exec.State() != StateTerminated { + t.Fatalf("state = %v; want Terminated", exec.State()) + } + if exec.HoldsEntry() { + t.Error("HoldsEntry() = true after termination") + } + if exec.HasPendingWork() { + t.Error("HasPendingWork() = true after termination") + } +} + +// TestChangeTriggeredTerminationHoldsNoWaits: a change condition rising in one +// region routes to the terminate action while the other region's condition stays +// false; the poll that ended the machine publishes no wait for it. +func TestChangeTriggeredTerminationHoldsNoWaits(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, `package test { + private import ScalarValues::*; + state def Machine { + attribute abort : Boolean = false; + attribute level : Integer = 0; + entry; then busy; + state busy parallel { + state r1 { + entry; then a; + state a; + transition first a accept when abort then stop; + } + state r2 { + entry; then b; + state b; + transition first b accept when level > 0 then c; + state c; + } + action stop terminate; + } + } + }`)) + exec, err := ctx.CreateStateExecutor(findSymbolByName(idx.DocumentRoot(""), "Machine", ast.DefState)) + if err != nil { + t.Fatal(err) + } + if fired, err := exec.PollChangeEvents(); err != nil || fired { + t.Fatalf("poll = %v, %v; want nothing fired while both conditions are false", fired, err) + } + if got := exec.ChangeWaits(); len(got) != 2 { + t.Fatalf("ChangeWaits() = %v; want both conditions", got) + } + exec.stateData["abort"] = boolValue(true) + fired, err := exec.PollChangeEvents() + if err != nil || !fired { + t.Fatalf("poll = %v, %v; want the abort condition to fire", fired, err) + } + if exec.State() != StateTerminated { + t.Fatalf("state = %v; want Terminated", exec.State()) + } + if got := exec.ChangeWaits(); len(got) != 0 { + t.Errorf("ChangeWaits() = %v after termination; want none", got) + } + if exec.HasPendingWork() { + t.Errorf("HasPendingWork() = true on a terminated machine") + } +} diff --git a/internal/core/runtime/state_time_trigger_test.go b/internal/exec/runtime/state_time_trigger_test.go similarity index 99% rename from internal/core/runtime/state_time_trigger_test.go rename to internal/exec/runtime/state_time_trigger_test.go index 90a0f50a84..7940404ceb 100644 --- a/internal/core/runtime/state_time_trigger_test.go +++ b/internal/exec/runtime/state_time_trigger_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // libStateExecutor builds an initialized executor over the named machine in src, diff --git a/internal/exec/runtime/state_unit_front.go b/internal/exec/runtime/state_unit_front.go new file mode 100644 index 0000000000..63cd57ef79 --- /dev/null +++ b/internal/exec/runtime/state_unit_front.go @@ -0,0 +1,732 @@ +package runtime + +import ( + "errors" + "fmt" + "iter" + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// A front performs the units of several regions one at a time, drawing which advances while two +// or more have one; each region is a coroutine yielding before every unit. It lives within one move. + +// errFrontClosed is what a queue's unit returns once its front closed under it. +var errFrontClosed = errors.New("front closed") + +// An entry order's Where names the composite or fork entered, an exit order's the composite +// exited, and a region order among firing units the occurrence they react to. +const ( + firingWherePrefix = "on " + enteringWherePrefix = "entering " + forkWherePrefix = "fork " + exitingWherePrefix = "exiting " +) + +// entryLabel, exitLabel and effectLabel spell a unit as a PSSM trace does; a state sharing +// its name with another region's is told apart by its region, as stateNames does. +func (e *StateExecutor) entryLabel(state *ast.StateNode) string { + return e.stateName(state) + "(entry)" +} +func (e *StateExecutor) exitLabel(state *ast.StateNode) string { return e.stateName(state) + "(exit)" } +func (e *StateExecutor) effectLabel(trans *lower.Transition) string { + return e.transitionLabel(trans) + "(effect)" +} + +// stateName is the state's name, qualified by its region where another region's state shares it. +func (e *StateExecutor) stateName(state *ast.StateNode) string { + if region := e.graph.RegionOf[state]; region != nil && region.Name != "" && e.nameShared(state) { + return region.Name + "." + state.Name + } + return state.Name +} + +// vertexName is StateVertexName with a state's name qualified as stateName does. +func (e *StateExecutor) vertexName(node ast.Node) string { + if state, isState := node.(*ast.StateNode); isState { + return e.stateName(state) + } + return StateVertexName(node) +} + +// entryIsUnit: every visible state's entry is a unit; a hidden owner's only when it performs. +func (e *StateExecutor) entryIsUnit(state *ast.StateNode) bool { + return !e.graph.HiddenStates[state] || len(e.behaviorsOf(state).Entry) > 0 +} + +// exitIsUnit is entryIsUnit for leaving state. +func (e *StateExecutor) exitIsUnit(state *ast.StateNode) bool { + return !e.graph.HiddenStates[state] || len(e.behaviorsOf(state).Exit) > 0 +} + +// silentEntry reports whether entering state performs no behavior: nothing a +// sibling region's unit could observe or be observed by. +func (e *StateExecutor) silentEntry(state *ast.StateNode) bool { + behaviors := e.behaviorsOf(state) + return len(behaviors.Entry) == 0 && len(behaviors.Do) == 0 +} + +// entryHead is the unit entering state; one queuing a completion (leaf) is observable through +// the pool's order, so it is drawn even when the state performs nothing. +func (e *StateExecutor) entryHead(state *ast.StateNode, leaf bool) unitHead { + var body ast.Node + if parent := e.graph.ParentState[state]; parent != nil { + body = parent + } + return unitHead{label: e.entryLabel(state), at: state, site: e.bodySite(body), silent: !leaf && e.silentEntry(state)} +} + +// bodySite names the entry site of a body's units as a front entering it would: the state +// whose body it is, or the machine. +func (e *StateExecutor) bodySite(body ast.Node) string { + switch body := body.(type) { + case *ast.StateNode: + return enteringWherePrefix + body.Name + case *ast.StateRegion: + if owner := e.graph.RegionOwner[body]; owner != nil { + return enteringWherePrefix + owner.Name + } + } + return enteringWherePrefix + e.stateMachine.Name +} + +// silentExit is silentEntry for leaving state. +func (e *StateExecutor) silentExit(state *ast.StateNode) bool { + behaviors := e.behaviorsOf(state) + return len(behaviors.Exit) == 0 && len(behaviors.Do) == 0 +} + +// transitionLabel names a transition by its own name, or by its ends when it has none. +func (e *StateExecutor) transitionLabel(trans *lower.Transition) string { + if trans.Name != "" { + return trans.Name + } + return e.vertexName(trans.Source) + "->" + e.vertexName(trans.Target) +} + +// unitFront is one such site under way: its queues in canonical order, the queue +// whose coroutine is running, and the front it is nested in. +type unitFront struct { + exec *StateExecutor + kind ChoiceKind + where string + queues []*unitQueue + current *unitQueue + outer *unitFront +} + +// unitQueue is one region's remaining units: the coroutine performing them and the head it +// yielded. A queue drawn for a unit it has yet to name is prepaid; one spawned at a head starts when drawn. +type unitQueue struct { + front *unitFront + head unitHead + done bool + closed bool + err error + body func() error + next func() (unitHead, bool) + stop func() + yield func(unitHead) bool + perform bool + prepaid bool + firing firingScope +} + +// unitHead is a queue's next unit: shared (performed once, by the first drawn), waiting (until +// holds), void (a disabled firing, run last performing nothing), silent (rides with the next +// performing unit) or a do step (one token move, offered while a sibling has a unit left). +// site names the body the unit enters, the Where of its draw when no front orders it. +type unitHead struct { + label string + at ast.Node + site string + shared *ast.StateNode + dropped func() bool + until func() bool + void func() bool + silent bool + doStep bool +} + +// pathDraw is the draw along an entry path no front orders: once the path is drawn over the +// due do steps it rides its silent units, as a front's queue does, until it has performed one. +type pathDraw struct { + drawn bool + performed bool + prepaid bool + due []*doAction +} + +// firingScope is the state of a queue's firing (occurrence, what it left and entered ahead, +// mark, bound trigger arguments), kept aside while another queue's units run. +type firingScope struct { + event *Event + taken *lower.Transition + change *lower.Transition + notes []RunNote + leftAhead map[*ast.StateNode]bool + exitingAhead bool + enteredAhead map[*ast.StateNode]bool + moving *moveMark + // bound are the machine's data the firing bound, installed while its units + // run; shadowed what the data held under each while installed. + bound map[string]Value + shadowed map[string]dataSlot +} + +// dataSlot is one entry of the machine's data as it was, or that it was absent. +type dataSlot struct { + value Value + held bool +} + +// firing captures the firing the executor is in the middle of. +func (e *StateExecutor) firing() firingScope { + return firingScope{ + event: e.firingEvent, taken: e.firingTrans, change: e.firingChange, notes: e.firingNotes, + leftAhead: e.leftAhead, exitingAhead: e.exitingAhead, enteredAhead: e.enteredAhead, + moving: e.moving, + } +} + +// setFiring puts the executor back in the middle of the firing, keeping what the +// firing had bound as it is. +func (e *StateExecutor) setFiring(s *firingScope) { + e.firingEvent, e.firingTrans, e.firingChange, e.firingNotes = s.event, s.taken, s.change, s.notes + e.leftAhead, e.exitingAhead, e.enteredAhead = s.leftAhead, s.exitingAhead, s.enteredAhead + e.moving = s.moving +} + +// install puts the data the firing bound in place, remembering what was under it. +func (s *firingScope) install(e *StateExecutor) { + for name, value := range s.bound { + prior, held := e.stateData[name] + s.shadowed[name] = dataSlot{value: prior, held: held} + e.stateData[name] = value + } +} + +// uninstall takes the data the firing bound out, putting back what was under it. +func (s *firingScope) uninstall(e *StateExecutor) { + for name := range s.bound { + s.bound[name] = e.stateData[name] + s.restore(e, name) + } +} + +// restore puts back what the data held under the firing's binding of name. +func (s *firingScope) restore(e *StateExecutor, name string) { + if slot := s.shadowed[name]; slot.held { + e.stateData[name] = slot.value + } else { + delete(e.stateData, name) + } +} + +// runningQueue is the queue whose units are running, nil outside a front. +func (e *StateExecutor) runningQueue() *unitQueue { + if e.front == nil { + return nil + } + return e.front.current +} + +// bindData binds one of the machine's data for the firing under way to read. +func (e *StateExecutor) bindData(name string, value Value) { + e.stateData[name] = value + if q := e.runningQueue(); q != nil { + q.firing.bound[name] = value + } +} + +// restoreData snapshots the named data entries and returns the function putting them back, +// deleting the ones that were absent; a front swaps a firing's bindings in and out this way. +func (e *StateExecutor) restoreData(names []string) func() { + q := e.runningQueue() + if q == nil { + return e.restoreSharedData(names) + } + s := &q.firing + for _, name := range names { + if _, bound := s.bound[name]; bound { + continue + } + prior, held := e.stateData[name] + s.shadowed[name] = dataSlot{value: prior, held: held} + s.bound[name] = prior + } + return func() { + for _, name := range names { + if _, bound := s.bound[name]; !bound { + continue + } + s.restore(e, name) + delete(s.bound, name) + delete(s.shadowed, name) + } + } +} + +// accepts reports whether a front of the kind orders units of the given kind: a +// firing's units are entries, exits and effects alike. +func (f *unitFront) accepts(kind ChoiceKind) bool { + return f.kind == kind || f.kind == ChoiceRegionOrder +} + +// openFront begins a site nested in whatever front is under way. +func (e *StateExecutor) openFront(kind ChoiceKind, where string) *unitFront { + f := &unitFront{exec: e, kind: kind, where: where, outer: e.front} + e.front = f + return f +} + +// performUnits performs the bodies as queues of a front of the kind (the one under way, waited +// for when wait says, or a front of their own at where); one body alone runs as it stands. +func (e *StateExecutor) performUnits(kind ChoiceKind, where string, bodies []func() error, wait bool) error { + if e.inFront(kind) { + return e.spawnUnits(kind, bodies, wait) + } + if len(bodies) < 2 { + for _, body := range bodies { + if err := body(); err != nil { + return err + } + } + return nil + } + return e.drawUnits(kind, where, bodies) +} + +// drawUnits performs the bodies as the queues of a front of their own; an entry front also +// offers the due steps of the do behaviors the move began before it opened. +func (e *StateExecutor) drawUnits(kind ChoiceKind, where string, bodies []func() error) error { + f := e.openFront(kind, where) + if f.accepts(ChoiceEntryOrder) { + e.path = pathDraw{} + for _, act := range e.dueAmong(e.began) { + f.offer(act) + } + } + for _, body := range bodies { + f.spawn(body) + } + return f.drain() +} + +// spawnUnits adds the bodies as queues of the front under way, and parks the +// running queue until they are done when wait says. +func (e *StateExecutor) spawnUnits(kind ChoiceKind, bodies []func() error, wait bool) error { + queues := make([]*unitQueue, 0, len(bodies)) + for _, body := range bodies { + queues = append(queues, e.front.spawn(body)) + } + if !wait { + return nil + } + return e.await(kind, func() bool { + for _, q := range queues { + if !q.done { + return false + } + } + return true + }) +} + +// close ends the front, stopping whatever its queues had left. +func (f *unitFront) close() { + for _, q := range f.queues { + q.closed = true + if !q.done && q.stop != nil { + q.stop() + } + } + f.exec.front = f.outer +} + +// spawn adds a queue performing body and runs it to its first head; a queue spawned by a +// running one goes right before it, as the nested region was entered before its spawner went on. +func (f *unitFront) spawn(body func() error) *unitQueue { + q := f.add(body) + q.start() + f.resume(q, false) + return q +} + +// spawnAt adds a queue whose first unit head names; body runs when the queue is first drawn, +// performing that unit without a draw of its own. +func (f *unitFront) spawnAt(head unitHead, body func() error) *unitQueue { + q := f.add(body) + q.head = head + return q +} + +// offer adds a queue drawing each due step of a do behavior begun in the move against the +// other queues' units; a step is never silent, and only the one-move engines step this fine. +func (f *unitFront) offer(act *doAction) { + if !f.exec.ctx.scheduling().oneMove() { + return + } + var q *unitQueue + q = f.spawnAt(f.doStepHead(act), func() error { return f.offerDoSteps(q, act) }) + q.head.void = func() bool { return !f.offering(q, act) } +} + +// doStepHead is the unit moving one token of the do behavior. +func (f *unitFront) doStepHead(act *doAction) unitHead { + return unitHead{label: doStepLabel([]string{f.exec.stateName(act.state)}), at: act.state, doStep: true} +} + +// add places a queue for body in the front, before the running queue. +func (f *unitFront) add(body func() error) *unitQueue { + q := &unitQueue{front: f, body: body, firing: f.exec.firing()} + q.firing.bound, q.firing.shadowed = make(map[string]Value), make(map[string]dataSlot) + at := len(f.queues) + if i := slices.Index(f.queues, f.current); i >= 0 { + at = i + } + f.queues = slices.Insert(f.queues, at, q) + return q +} + +// start begins the queue's coroutine. +func (q *unitQueue) start() { + q.next, q.stop = iter.Pull(func(yield func(unitHead) bool) { + q.yield = yield + q.err = q.body() + }) +} + +// resume runs the queue to its next head, performing the unit at its head or +// dropping it as perform says, in the firing the queue's units belong to. +func (f *unitFront) resume(q *unitQueue, perform bool) { + prev := f.current + f.current, q.perform = q, perform + if q.next == nil { + q.start() + q.prepaid = true + } + outer := f.exec.firing() + f.exec.setFiring(&q.firing) + q.firing.install(f.exec) + head, ok := q.next() + q.firing.uninstall(f.exec) + bound, shadowed := q.firing.bound, q.firing.shadowed + q.firing = f.exec.firing() + q.firing.bound, q.firing.shadowed = bound, shadowed + f.exec.setFiring(&outer) + f.current = prev + if !ok { + q.done = true + return + } + q.head = head +} + +// unit yields before a unit of the running queue and reports whether to perform it; a unit +// no front of this kind orders is performed at once, an entry drawn against the due do steps. +func (e *StateExecutor) unit(kind ChoiceKind, head unitHead) (bool, error) { + f := e.front + if f == nil || !f.accepts(kind) || f.current == nil { + if kind == ChoiceEntryOrder { + return true, e.drawOnPath(head) + } + return true, nil + } + q := f.current + if q.closed { + return false, errFrontClosed + } + if q.prepaid { + q.prepaid = false + return q.perform, nil + } + if !q.yield(head) { + return false, errFrontClosed + } + return q.perform, nil +} + +// unitAhead draws a unit whose name the run learns only by performing it: the +// draw is made under the given label and pays for the unit's own draw. +func (e *StateExecutor) unitAhead(kind ChoiceKind, head unitHead) error { + f := e.front + if f == nil || !f.accepts(kind) || f.current == nil { + if kind != ChoiceEntryOrder { + return nil + } + if err := e.drawOnPath(head); err != nil { + return err + } + e.path.prepaid = true + return nil + } + if _, err := e.unit(kind, head); err != nil { + return err + } + f.current.prepaid = true + return nil +} + +// drawOnPath draws a unit of an entry path no front orders against the due do steps the move +// began, moving the steps drawn until the unit is; a unit riding a drawn one is not drawn. +func (e *StateExecutor) drawOnPath(head unitHead) error { + p := &e.path + if p.prepaid { + p.prepaid = false + return nil + } + if !e.ctx.scheduling().oneMove() { + return nil + } + for { + due := e.dueAmong(e.began) + if len(due) == 0 { + *p = pathDraw{} + return nil + } + if p.drawn && slices.Equal(due, p.due) && !(p.performed && !head.silent) { + p.performed = p.performed || !head.silent + return nil + } + alternatives := make([]string, 0, len(due)+1) + for _, act := range due { + alternatives = append(alternatives, doStepLabel([]string{e.stateName(act.state)})) + } + alternatives = append(alternatives, head.label) + choice := ChoicePoint{Kind: ChoiceEntryOrder, Where: head.site, Alternatives: alternatives, File: e.stateMachine.DocName} + pick := e.ctx.scheduling().choose(choice, nil) + if err := e.ctx.scheduling().refusal(); err != nil { + return err + } + choice.Taken = pick + at := head.at + if pick < len(due) { + at = due[pick].state + } + if at != nil { + choice.Span = at.Span() + } + e.noteChoice(choice) + if pick == len(due) { + *p = pathDraw{drawn: true, performed: !head.silent, due: due} + return nil + } + if err := e.moveDoStep(due[pick]); err != nil { + return err + } + } +} + +// inFront reports whether a queue of a front of the given kind is running, so a +// site nested in it adds its queues to that front rather than opening one. +func (e *StateExecutor) inFront(kind ChoiceKind) bool { + return e.front != nil && e.front.accepts(kind) && e.front.current != nil +} + +// await parks the running queue until the condition holds; no unit of its own +// is performed until then. +func (e *StateExecutor) await(kind ChoiceKind, until func() bool) error { + f := e.front + if f == nil || !f.accepts(kind) || f.current == nil || until() { + return nil + } + if f.current.closed { + return errFrontClosed + } + if !f.current.yield(unitHead{until: until}) { + return errFrontClosed + } + return nil +} + +// drain performs the units of every queue, one at a time, drawing the queue that +// advances while two or more have a unit, and closes the front. +func (f *unitFront) drain() (err error) { + defer f.close() + for { + f.settle() + for _, q := range f.queues { + if q.done && q.err != nil { + return q.err + } + } + ready := f.ready() + if len(ready) == 0 { + if f.drainVoid() { + continue + } + if f.finished() { + return nil + } + return fmt.Errorf("%s: every region waits on another", f.where) + } + pick := 0 + if len(ready) >= 2 { + choice := ChoicePoint{Kind: f.kind, Where: f.where, Alternatives: f.labels(ready), File: f.exec.stateMachine.DocName} + pick = f.exec.ctx.scheduling().choose(choice, nil) + if err := f.exec.ctx.scheduling().refusal(); err != nil { + return err + } + choice.Taken = pick + if at := ready[pick].head.at; at != nil { + choice.Span = at.Span() + } + f.exec.noteChoice(choice) + } + f.advance(ready[pick]) + } +} + +// advance performs the drawn queue's next performing unit and the silent units around it, +// stopping early where a sibling's readiness changes so `declared` keeps its sequence. +func (f *unitFront) advance(q *unitQueue) { + others := f.readyExcept(q) + performed := false + for { + silent := q.head.silent + f.resume(q, true) + performed = performed || !silent + f.settle() + if q.done || !slices.Contains(f.ready(), q) || !slices.Equal(others, f.readyExcept(q)) { + return + } + if performed && !q.head.silent { + return + } + } +} + +// offerDoSteps yields each due token move of the do behavior as a unit of q while a sibling has a +// unit left. +func (f *unitFront) offerDoSteps(q *unitQueue, act *doAction) error { + e := f.exec + for f.offering(q, act) { + head := f.doStepHead(act) + head.void = func() bool { return !f.offering(q, act) } + perform, err := e.unit(f.kind, head) + if err != nil || !perform { + return err + } + if err := e.moveDoStep(act); err != nil { + return err + } + } + return nil +} + +// offering reports whether q still has a step of the do behavior to offer: the behavior is +// running and due, and a sibling has a unit left. +func (f *unitFront) offering(q *unitQueue, act *doAction) bool { + return len(f.exec.dueAmong([]*doAction{act})) > 0 && f.unitsPending(q) +} + +// moveDoStep moves one token of the do behavior as a unit of an entry site does. +func (e *StateExecutor) moveDoStep(act *doAction) error { + if err := e.stepDoAction(act, func(run *doRun) (*doRun, error) { return run.resume(e.ctx) }); err != nil { + return err + } + if err := e.countDoStep(); err != nil { + return err + } + return e.settleDoActions() +} + +// unitsPending reports a queue other than q with a unit of its own left: neither done, waiting, +// void, nor offering a do step. +func (f *unitFront) unitsPending(q *unitQueue) bool { + for _, r := range f.queues { + if r == q || r.done || r.head.until != nil || r.head.doStep { + continue + } + if r.head.void != nil && r.head.void() { + continue + } + return true + } + return false +} + +// readyExcept lists the ready queues other than q. +func (f *unitFront) readyExcept(q *unitQueue) []*unitQueue { + return slices.DeleteFunc(f.ready(), func(r *unitQueue) bool { return r == q }) +} + +// settle resumes the queues whose wait is over and drops the shared units a +// sibling performed, until every head is a unit to perform or a wait still on. +func (f *unitFront) settle() { + for moved := true; moved; { + moved = false + for _, q := range f.queues { + switch { + case q.done: + case q.head.until != nil && q.head.until(): + f.resume(q, true) + moved = true + case q.head.dropped != nil && q.head.dropped(): + f.resume(q, false) + moved = true + } + } + } +} + +// ready lists the queues with a unit to perform, a shared unit once, by the +// first queue heading it. +func (f *unitFront) ready() []*unitQueue { + var ready []*unitQueue + for _, q := range f.queues { + if q.done || q.head.until != nil || (q.head.void != nil && q.head.void()) { + continue + } + if q.head.shared != nil && slices.ContainsFunc(ready, func(r *unitQueue) bool { return r.head.shared == q.head.shared }) { + continue + } + ready = append(ready, q) + } + return ready +} + +// drainVoid runs the queues void of a unit to their ends, performing nothing, +// and reports whether there were any. +func (f *unitFront) drainVoid() bool { + drained := false + for _, q := range f.queues { + if !q.done && q.head.void != nil && q.head.void() { + f.resume(q, false) + drained = true + } + } + return drained +} + +// finished reports whether every queue ran to its end. +func (f *unitFront) finished() bool { + for _, q := range f.queues { + if !q.done { + return false + } + } + return true +} + +// labels spells the ready queues' next units, the draw's alternatives. +func (f *unitFront) labels(ready []*unitQueue) []string { + labels := make([]string, len(ready)) + for i, q := range ready { + labels[i] = q.head.label + } + return labels +} + +// nameShared reports whether a state of another region bears this state's name. +func (e *StateExecutor) nameShared(state *ast.StateNode) bool { + for other, region := range e.graph.RegionOf { + if other != state && other.Name == state.Name && region != e.graph.RegionOf[state] { + return true + } + } + return false +} diff --git a/internal/core/runtime/statements.go b/internal/exec/runtime/statements.go similarity index 93% rename from internal/core/runtime/statements.go rename to internal/exec/runtime/statements.go index daa2d1540d..a6d881a861 100644 --- a/internal/core/runtime/statements.go +++ b/internal/exec/runtime/statements.go @@ -5,10 +5,10 @@ import ( "maps" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // stmtEnv is the environment a body's statements execute in: the behavior's own @@ -159,14 +159,16 @@ type stmtHost interface { declaredOutput(name string) bool // acceptReturn takes the value a `return` yields. acceptReturn(value Value, s lower.Return) error - // effect states an effect on the world outside the body, over env's values. - effect(env *stmtEnv, s lower.Effect) error + // effect states an effect on the world outside the body, over engine's values. + effect(engine *stmtEngine, s lower.Effect) error // performNode runs a nested action a block's flow declares, node of graph, // as a performance of its own with engine's block-locals in reach. performNode(engine *stmtEngine, graph *lower.ActionGraph, node *ast.Usage) (stmtFlow, error) // runFlow runs the token flow a body states of its own (lower.Block.Stated): // its successions and control nodes, as the host's own performance. runFlow(block lower.Block) (stmtFlow, error) + // runBlockFlow runs the token flow a loop or branch body states of its own. + runBlockFlow(engine *stmtEngine, block lower.Block) (stmtFlow, error) // performer is the object running the behavior, nil when it runs outside any // object: what the body's names read and write through. performer() *Instance @@ -189,18 +191,10 @@ type stmtEngine struct { frameBuf []frame } -// newStmtEngine returns an engine running statements against data — the -// behavior's own values, which its statements read and write. -func newStmtEngine(ctx *Context, host stmtHost, data map[string]Value) *stmtEngine { - return &stmtEngine{ctx: ctx, host: host, env: &stmtEnv{data: mapFrame(data)}, activation: ctx.newActivation()} -} - -// newStmtEngineOver returns an engine whose statements also read outer value -// maps — the attributes of the states enclosing the behavior — innermost last. -func newStmtEngineOver(ctx *Context, host stmtHost, data map[string]Value, outer []map[string]Value) *stmtEngine { - engine := newStmtEngine(ctx, host, data) - engine.env.outer = outer - return engine +// newStmtEngineOver returns an engine running statements against data, which also +// read outer value maps — the attributes of the enclosing states — innermost last. +func newStmtEngineOver(ctx *Context, host stmtHost, data frame, outer []map[string]Value) *stmtEngine { + return &stmtEngine{ctx: ctx, host: host, env: &stmtEnv{data: data, outer: outer}, activation: ctx.newActivation()} } // newStmtEngineIn returns an engine running statements against data, a frame @@ -295,7 +289,8 @@ func (f *stmtListFrame) abandon(*Context) { f.run.elements = f.elements } func (f *stmtListFrame) clone() bodyFrame { c := *f; return &c } // run executes statements in declaration order, stopping at a `return`; a body -// pausing in one is re-entered at that statement. +// pausing in one is re-entered at that statement, one yielding between two at +// the next. func (e *stmtEngine) run(stmts []lower.Statement) (stmtFlow, error) { f, resumed, err := popFrame[*stmtListFrame](e.ctx) if err != nil { @@ -304,12 +299,17 @@ func (e *stmtEngine) run(stmts []lower.Statement) (stmtFlow, error) { if !resumed { f = &stmtListFrame{} } + resumed = resumed && !e.ctx.yieldedHere() for ; f.i < len(stmts); f.i++ { + if err := e.ctx.yieldBody(); err != nil { + return flowNext, e.ctx.pausing(f, err) + } flow, err := e.statement(stmts[f.i], f, resumed) resumed = false if err != nil || flow == flowReturn { return flow, e.ctx.pausing(f, err) } + e.ctx.bodyPerformed() } return flowNext, nil } @@ -409,7 +409,7 @@ func (e *stmtEngine) execute(stmt lower.Statement) (stmtFlow, error) { } return e.block(s) case lower.Effect: - return flowNext, e.host.effect(e.env, s) + return flowNext, e.host.effect(e, s) case lower.Unsupported: return flowNext, fmt.Errorf("%w: %s: %s in a body is not executable", ErrStatementNotExecutable, e.host.describe(), s.Description) default: @@ -526,8 +526,10 @@ func (e *stmtEngine) runBlock(block lower.Block) (stmtFlow, error) { switch { case block.Graph == nil: return e.run(block.Statements) - case block.Stated: + case block.Stated && block.Own: return e.host.runFlow(block) + case block.Stated: + return e.host.runBlockFlow(e, block) } return e.blockFlow(block) } @@ -541,7 +543,8 @@ func (f *flowNodeFrame) clone() bodyFrame { c := *f; return &c } // blockFlow runs a block that is a token flow of its own (lower/block_graph.go): // a token starts at the block's initial node and passes along the successions the -// block states, running each node it reaches until one succeeds to none. +// block states, running each node it reaches until one succeeds to none; a body +// run one statement at a time yields between two nodes. func (e *stmtEngine) blockFlow(block lower.Block) (stmtFlow, error) { graph := block.Graph f, resumed, err := popFrame[*flowNodeFrame](e.ctx) @@ -551,7 +554,11 @@ func (e *stmtEngine) blockFlow(block lower.Block) (stmtFlow, error) { if !resumed { f = &flowNodeFrame{node: graph.Initial} } + resumed = resumed && !e.ctx.yieldedHere() for f.node != nil { + if err := e.ctx.yieldBody(); err != nil { + return flowNext, e.ctx.pausing(f, err) + } // A node reached spends a step, so a flow that does not end fails the run. if !resumed { if err := e.ctx.incrementStep(); err != nil { @@ -563,6 +570,7 @@ func (e *stmtEngine) blockFlow(block lower.Block) (stmtFlow, error) { if err != nil || flow == flowReturn { return flow, e.ctx.pausing(f, err) } + e.ctx.bodyPerformed() successors := graph.Edges[f.node] if len(successors) == 0 { return flowNext, nil @@ -677,7 +685,8 @@ func (e *stmtEngine) endIteration(f *loopFrame, err error) { // loop runs a loop to termination or to the `return` its body reaches. Every // iteration spends one step of the budget, so a non-terminating loop fails with -// ErrStepLimitExceeded instead of hanging its caller. +// ErrStepLimitExceeded instead of hanging its caller. A body run one statement +// at a time yields between two iterations as between two statements of one. func (e *stmtEngine) loop(stmt lower.Loop) (stmtFlow, error) { if stmt.Kind == ast.LoopFor { return e.forLoop(stmt) @@ -689,10 +698,14 @@ func (e *stmtEngine) loop(stmt lower.Loop) (stmtFlow, error) { if !resumed { f = &loopFrame{} } + resumed = resumed && !e.ctx.yieldedHere() leave := e.enterLoop(f) defer leave() for { + if err := e.ctx.yieldBody(); err != nil { + return flowNext, e.ctx.pausing(f, err) + } if !resumed { if err := e.ctx.incrementStep(); err != nil { return flowNext, err @@ -703,6 +716,7 @@ func (e *stmtEngine) loop(stmt lower.Loop) (stmtFlow, error) { if err != nil || done || flow == flowReturn { return flow, e.ctx.pausing(f, err) } + e.ctx.bodyPerformed() } } @@ -767,11 +781,18 @@ func (e *stmtEngine) forLoop(stmt lower.Loop) (stmtFlow, error) { return flowNext, fmt.Errorf("%s: %w", e.host.describe(), err) } f = &loopFrame{elements: elements} + if len(elements) == 0 { + e.ctx.bodyPerformed() + } } + resumed = resumed && !e.ctx.yieldedHere() leave := e.enterLoop(f) defer leave() for f.iteration < len(f.elements) || resumed { + if err := e.ctx.yieldBody(); err != nil { + return flowNext, e.ctx.pausing(f, err) + } if !resumed { if err := e.ctx.incrementStep(); err != nil { return flowNext, err @@ -782,6 +803,7 @@ func (e *stmtEngine) forLoop(stmt lower.Loop) (stmtFlow, error) { if err != nil || flow == flowReturn { return flow, e.ctx.pausing(f, err) } + e.ctx.bodyPerformed() } return flowNext, nil } diff --git a/internal/core/runtime/statements_test.go b/internal/exec/runtime/statements_test.go similarity index 97% rename from internal/core/runtime/statements_test.go rename to internal/exec/runtime/statements_test.go index 696632f5c0..c96272d86d 100644 --- a/internal/core/runtime/statements_test.go +++ b/internal/exec/runtime/statements_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // A `for` visits a sequence in the order the expression that built it produced, diff --git a/internal/exec/runtime/step_order_pending_signal_test.go b/internal/exec/runtime/step_order_pending_signal_test.go new file mode 100644 index 0000000000..129c1483e1 --- /dev/null +++ b/internal/exec/runtime/step_order_pending_signal_test.go @@ -0,0 +1,106 @@ +package runtime + +import ( + "context" + "slices" + "testing" +) + +// pendingSignalModel is a machine whose do step is due as Stop or Go would leave +// the state, with Noise a signal nothing accepts. +func pendingSignalModel(t *testing.T) *exploreModel { + t.Helper() + return parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + + attribute def Stop; + attribute def Go; + attribute def Noise; + + state Machine { + attribute log : String = ""; + + entry; then top; + state top { + do { assign log := log + "did "; } + } + transition first top accept Stop do assign log := log + "stop " then idle; + transition first top accept Go do assign log := log + "go " then idle; + state idle; + } + } + `) +} + +// A signal in flight is delivered behind the events already queued, so the dispatch +// drawn against a due do step is the one the queue then dispatches: a queued event +// nothing accepts hides the signal until the round closes, a queued event that acts +// is drawn by name, and the signal is drawn by name only once nothing is ahead of it. +func TestStepOrderDrawsThePendingSignalAsQueued(t *testing.T) { + m := pendingSignalModel(t) + cases := []struct { + name string + queued string + finals []string + choices []string + }{ + { + name: "a queued event nothing accepts goes first, undrawn", + queued: "Noise", + finals: []string{`finalState idle; visits top, idle; log = "did stop "`}, + }, + { + name: "a queued event that acts is drawn, not the signal behind it", + queued: "Go", + finals: []string{`finalState idle; visits top, idle; log = "did go "`, `finalState idle; visits top, idle; log = "go "`}, + choices: []string{"do top", "dispatch accept Go"}, + }, + { + name: "the signal alone is drawn by name", + finals: []string{`finalState idle; visits top, idle; log = "did stop "`, `finalState idle; visits top, idle; log = "stop "`}, + choices: []string{"do top", "dispatch accept Stop"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + machine := m.state(t, "Machine") + start := func(ctx *Context) (*Invocation, error) { + exec, err := ctx.CreateStateExecutor(machine) + if err != nil { + return nil, err + } + if c.queued != "" { + exec.SendSignal(c.queued, nil) + } + ctx.PostMessage(Message{SignalType: "Stop"}) + return &Invocation{States: []*StateExecutor{exec}, Horizon: HorizonAt(1)}, nil + } + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, unreduced(), nil) + if err != nil { + t.Fatal(err) + } + if report.Verdict == CheckViolation || len(report.BoundsHit) != 0 { + t.Fatalf("check: %s, want a complete search", report.Status()) + } + if got := finalOutcomes(report); !slices.Equal(got, c.finals) { + t.Fatalf("finals %v, want %v", got, c.finals) + } + for _, final := range report.Finals { + steps := slices.DeleteFunc(slices.Clone(final.Witness.Choices), func(ch ChoiceTaken) bool { + return ch.Kind != ChoiceStepOrder + }) + switch { + case c.choices == nil && len(steps) != 0: + t.Fatalf("%s: step orders %v, want none while the queued event is ahead", final.Outcome, steps) + case c.choices != nil && (len(steps) != 1 || !slices.Equal(steps[0].Among, c.choices)): + t.Fatalf("%s: step orders %v, want one among %v", final.Outcome, steps, c.choices) + } + r := replayWitness(t, m, start, final.Witness, final.Outcome) + if got := r.Ctx.Trace().String(); got != final.Witness.Trace { + t.Errorf("replay of %s left the trace\n%s\nwant the witness's\n%s", final.Outcome, got, final.Witness.Trace) + } + } + }) + } +} diff --git a/internal/exec/runtime/step_order_tied_test.go b/internal/exec/runtime/step_order_tied_test.go new file mode 100644 index 0000000000..1f126a5cc5 --- /dev/null +++ b/internal/exec/runtime/step_order_tied_test.go @@ -0,0 +1,54 @@ +package runtime + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// Two same-spelled time triggers arm one timer, whose dispatch is drawn against each +// move of a due do step by name; no dispatch order is drawn; every witness replays. +func TestStepOrderDrawsTheActingTiedEventAlone(t *testing.T) { + text, err := os.ReadFile(filepath.Join("testdata", "conformance", "state_do_step_or_tied_dispatch.sysml")) + if err != nil { + t.Fatal(err) + } + m := parseLibraryModel(t, strings.Replace(string(text), "package Test", "package test", 1)) + start := stateStarterOf(m.state(t, "Machine"), HorizonAt(3)) + report, err := Check(context.Background(), m.fresh, start, CheckBudget{}, unreduced(), nil) + if err != nil { + t.Fatal(err) + } + if report.Verdict != CheckDivergent || len(report.Violations) != 0 || len(report.BoundsHit) != 0 { + t.Fatalf("check: %s, want a complete divergent search", report.Status()) + } + want := []string{`finalState idle; visits top, idle; log = "did one "`, `finalState idle; visits top, idle; log = "did two "`, `finalState idle; visits top, idle; log = "two "`} + if got := finalOutcomes(report); !slices.Equal(got, want) { + t.Fatalf("finals %v, want the step first under either trigger and the unguarded trigger first", got) + } + for _, final := range report.Finals { + steps := slices.DeleteFunc(slices.Clone(final.Witness.Choices), func(c ChoiceTaken) bool { return c.Kind != ChoiceStepOrder }) + if len(steps) == 0 { + t.Fatalf("%s: no step order drawn, want one between the step and the group's timer", final.Outcome) + } + for i, step := range steps { + if !slices.Equal(step.Among, []string{"do top", "dispatch time top 1->idle"}) { + t.Fatalf("%s: step order %d is %v, want between the step and the group's timer", final.Outcome, i, step) + } + if i < len(steps)-1 && step.Took != "do top" { + t.Fatalf("%s: step order %d took the dispatch before the last, got %s", final.Outcome, i, FormatChoices(final.Witness.Choices)) + } + } + dispatched := slices.ContainsFunc(final.Witness.Choices, func(c ChoiceTaken) bool { return c.Kind == ChoiceDispatchOrder }) + if dispatched { + t.Fatalf("%s: one timer is one occurrence, no dispatch order drawn, got %s", final.Outcome, FormatChoices(final.Witness.Choices)) + } + r := replayWitness(t, m, start, final.Witness, final.Outcome) + if got := r.Ctx.Trace().String(); got != final.Witness.Trace { + t.Errorf("replay of %s left the trace\n%s\nwant the witness's\n%s", final.Outcome, got, final.Witness.Trace) + } + } +} diff --git a/internal/exec/runtime/stochastic_test.go b/internal/exec/runtime/stochastic_test.go new file mode 100644 index 0000000000..d909abd2d0 --- /dev/null +++ b/internal/exec/runtime/stochastic_test.go @@ -0,0 +1,1159 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "math" + "math/rand/v2" + "reflect" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// weightedRouteModel decides between two weighted branches, the less probable +// declared first, and records which one ran. +const weightedRouteModel = ` +package test { + private import ScalarValues::*; + private import Stochastic::*; + action route { + attribute taken : Integer = 0; + first start; + then decide select; + first select then slow { @Probability { p = 0.3; } } + first select then fast { @Probability { p = 0.7; } } + action slow { assign taken := 2; } + then done; + action fast { assign taken := 1; } + then done; + } +}` + +// drawingModel draws a Real and an Integer as it starts. +const drawingModel = ` +package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute d : Real = uniform(0.0, 1.0); + attribute n : Integer = uniformInteger(1, 6); + first start; then done; + } +}` + +// runAction runs the action named to completion on a fresh context under policy, +// with the model seed when one is given. +func runAction(t *testing.T, m *exploreModel, name, policy string, seed ...uint64) (*Context, map[string]Value, error) { + t.Helper() + ctx, _ := m.fresh() + if policy != "" { + mustSchedule(t, ctx, mustPolicy(t, policy)) + } + if len(seed) > 0 { + ctx.SetModelSeed(seed[0]) + } + out, err := ctx.ExecuteAction(m.action(t, name)) + return ctx, out, err +} + +// takenInt is the Integer an output holds. +func takenInt(t *testing.T, out map[string]Value, name string) int64 { + t.Helper() + v, ok := out[name] + if !ok || v.Const.Kind != semantics.ValInt { + t.Fatalf("%s = %v, want an Integer", name, v) + } + return v.Const.Int +} + +// A weighted decision under a seed draws a branch; the same seed draws the same +// branch, and the trace records the weights and the draw. +func TestWeightedDecisionDrawsUnderASeed(t *testing.T) { + m := parseLibraryModel(t, weightedRouteModel) + picks := map[int64]int{} + for seed := uint64(1); seed <= 40; seed++ { + policy := fmt.Sprintf("seed:%d", seed) + _, first, err := runAction(t, m, "route", policy) + if err != nil { + t.Fatalf("seed %d: %v", seed, err) + } + _, again, err := runAction(t, m, "route", policy) + if err != nil { + t.Fatalf("seed %d again: %v", seed, err) + } + if a, b := takenInt(t, first, "taken"), takenInt(t, again, "taken"); a != b { + t.Fatalf("seed %d took %d then %d", seed, a, b) + } + picks[takenInt(t, first, "taken")]++ + } + if picks[1] == 0 || picks[2] == 0 { + t.Errorf("40 seeds took only one branch: %v", picks) + } + if picks[1] < picks[2] { + t.Errorf("the 0.7 branch was taken %d times against %d for the 0.3 one", picks[1], picks[2]) + } + + ctx, _, err := runAction(t, m, "route", "seed:7") + if err != nil { + t.Fatal(err) + } + choices := ctx.Choices() + if len(choices) != 1 || !choices[0].Weighted() || !choices[0].Drawn { + t.Fatalf("choices %v, want one weighted, drawn decision", choices) + } + desc := choices[0].Describe() + if !strings.Contains(desc, "1->slow p=0.3, 2->fast p=0.7") || !strings.Contains(desc, "weighted; drew ") { + t.Errorf("trace line %q, want the weights and the draw", desc) + } + taken := ctx.ChoicesTaken() + if len(taken) != 1 || !taken[0].Weighted() || taken[0].Weights[0] != 0.3 || taken[0].Weights[1] != 0.7 { + t.Errorf("witness %v, want the weights carried", taken) + } +} + +// guardedRouteModel weights three branches, the most probable of them guarded off. +const guardedRouteModel = ` +package test { + private import ScalarValues::*; + private import Stochastic::*; + action route { + attribute ready : Boolean = false; + attribute taken : Integer = 0; + first start; + then decide select; + first select if ready then quick { @Probability { p = 0.6; } } + first select then slow { @Probability { p = 0.1; } } + first select then fast { @Probability { p = 0.3; } } + action quick { assign taken := 3; } + then done; + action slow { assign taken := 2; } + then done; + action fast { assign taken := 1; } + then done; + } +}` + +// A weighted branch whose guard does not hold is out of the draw: the holding +// branches are drawn by their weights renormalized, and the most probable of +// them is taken unseeded. +func TestWeightedDecisionDrawsAmongTheHoldingBranches(t *testing.T) { + m := parseLibraryModel(t, guardedRouteModel) + picks := map[int64]int{} + for seed := uint64(1); seed <= 40; seed++ { + ctx, out, err := runAction(t, m, "route", fmt.Sprintf("seed:%d", seed)) + if err != nil { + t.Fatalf("seed %d: %v", seed, err) + } + picks[takenInt(t, out, "taken")]++ + if choices := ctx.Choices(); len(choices) != 1 || !strings.Contains(choices[0].Describe(), "2->slow p=0.1, 3->fast p=0.3 hold") { + t.Fatalf("seed %d: choices %v, want the two holding weighted branches", seed, choices) + } + } + if picks[3] != 0 { + t.Errorf("the guarded-off branch was taken %d times", picks[3]) + } + if picks[1] == 0 || picks[2] == 0 || picks[1] < picks[2] { + t.Errorf("picks %v, want both holding branches taken, the 0.3 one oftener", picks) + } + _, out, err := runAction(t, m, "route", "declared") + if err != nil { + t.Fatal(err) + } + if got := takenInt(t, out, "taken"); got != 1 { + t.Errorf("unseeded took %d, want the most probable holding branch", got) + } +} + +// A model seed fixes the draws under any policy, `seed:` alone seeds the +// modeled stream with n, and a model seed set overrides the schedule's. +// A weighted branch left holding alone is taken without a choice or a draw, its +// weight read all the same. +func TestSoleHoldingWeightedBranchIsTakenWithoutADraw(t *testing.T) { + m := parseLibraryModel(t, ` +package test { + private import ScalarValues::*; + private import Stochastic::*; + action route { + attribute w : Real = 0.2; + attribute taken : Integer = 0; + first start; + then decide select; + first select if w < 0.5 then slow { @Probability { p = w; } } + first select if w >= 0.5 then fast { @Probability { p = 1.0 - w; } } + action slow { assign taken := 2; } + then done; + action fast { assign taken := 1; } + then done; + } +}`) + for _, policy := range []string{"declared", "seed:3"} { + ctx, out, err := runAction(t, m, "route", policy) + if err != nil { + t.Fatalf("%s: %v", policy, err) + } + if got := takenInt(t, out, "taken"); got != 2 { + t.Errorf("%s took %d, want the holding branch", policy, got) + } + if choices := ctx.Choices(); len(choices) != 0 { + t.Errorf("%s: choices %v, want none for one holding branch", policy, choices) + } + if draws := ctx.DrawsTaken(); len(draws) != 0 { + t.Errorf("%s drew %v", policy, draws) + } + } +} + +func TestModelSeedIsIndependentOfTheScheduleSeed(t *testing.T) { + m := parseLibraryModel(t, drawingModel) + _, a, err := runAction(t, m, "draw", "seed:1", 7) + if err != nil { + t.Fatal(err) + } + _, b, err := runAction(t, m, "draw", "seed:2", 7) + if err != nil { + t.Fatal(err) + } + _, c, err := runAction(t, m, "draw", "declared", 7) + if err != nil { + t.Fatal(err) + } + if a["d"].Const.Real != b["d"].Const.Real || a["d"].Const.Real != c["d"].Const.Real { + t.Errorf("model seed 7 drew %v, %v, %v under three schedules", a["d"], b["d"], c["d"]) + } + _, d, err := runAction(t, m, "draw", "seed:7") + if err != nil { + t.Fatal(err) + } + if d["d"].Const.Real != a["d"].Const.Real { + t.Errorf("`seed:7` drew %v, model seed 7 %v; want one modeled stream per seed", d["d"], a["d"]) + } +} + +// Unseeded, a declared or reverse run takes the most probable branch, whatever +// the declaration order, and never draws. +func TestWeightedDecisionTakesTheMostProbableBranchUnseeded(t *testing.T) { + m := parseLibraryModel(t, weightedRouteModel) + for _, policy := range []string{"declared", "reverse"} { + ctx, out, err := runAction(t, m, "route", policy) + if err != nil { + t.Fatalf("%s: %v", policy, err) + } + if got := takenInt(t, out, "taken"); got != 1 { + t.Errorf("%s took %d, want the 0.7 branch", policy, got) + } + if choices := ctx.Choices(); len(choices) != 1 || choices[0].Drawn { + t.Errorf("%s: choices %v, want one undrawn decision", policy, choices) + } + if draws := ctx.DrawsTaken(); len(draws) != 0 { + t.Errorf("%s drew %v", policy, draws) + } + } +} + +// weightedCasesModel weights the decision among the steps of a verification case +// and of an analysis case, the more probable branch written first each time: read +// unweighted, the last unguarded succession would be taken as the else branch. +const weightedCasesModel = ` +package test { + private import ScalarValues::*; + private import Stochastic::*; + + part def Sensor { + attribute reading : Integer default = 0; + } + part zeroed : Sensor; + part drifted : Sensor { + attribute :>> reading = 4; + } + + verification def ZeroCheck { + subject sensor : Sensor; + VerificationCases::PassIf(sensor.reading == 0) + } + + verification plan { + subject sensor = zeroed; + action start; + then decide route; + first route then checkZeroed { @Probability { p = 0.8; } } + first route then checkDrifted { @Probability { p = 0.2; } } + verification checkZeroed : ZeroCheck { + subject sensor = test::zeroed; + } + verification checkDrifted : ZeroCheck { + subject sensor = test::drifted; + } + } + + analysis sizing { + subject s = zeroed; + attribute taken : Integer = 0; + action start; + then decide route; + first route then fast { @Probability { p = 0.8; } } + first route then slow { @Probability { p = 0.2; } } + action fast { assign taken := 1; } + action slow { assign taken := 2; } + return : Integer = taken; + } +}` + +// A weighted decision among a case's steps is weighted as an action's is: unseeded +// it takes the most probable branch whatever the schedule order, and under a seed +// it is drawn, the same seed drawing the same branch. +func TestWeightedCaseStepsKeepTheirWeights(t *testing.T) { + verify := func(t *testing.T, policy string) (string, ChoicePoint) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, weightedCasesModel)) + mustSchedule(t, ctx, mustPolicy(t, policy)) + result, err := ctx.RunVerification(oneSymbol(t, idx, "test::plan"), AnalysisArgs{}, nil, nil) + if err != nil { + t.Fatalf("RunVerification: %v", err) + } + if len(result.Subcases) != 1 { + t.Fatalf("subcases = %+v, want the one branch performed", result.Subcases) + } + choices := ctx.Choices() + if len(choices) != 1 || !choices[0].Weighted() { + t.Fatalf("choices %v, want one weighted decision", choices) + } + return result.Subcases[0].Case, choices[0] + } + analyze := func(t *testing.T, policy string) (int64, ChoicePoint) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, weightedCasesModel)) + mustSchedule(t, ctx, mustPolicy(t, policy)) + run, err := ctx.RunAnalysis(oneSymbol(t, idx, "test::sizing"), AnalysisArgs{}, nil, nil) + if err != nil { + t.Fatalf("RunAnalysis: %v", err) + } + if len(run.Outputs) != 1 || run.Outputs[0].Value.Const.Kind != semantics.ValInt { + t.Fatalf("outputs = %+v, want the one Integer result", run.Outputs) + } + choices := ctx.Choices() + if len(choices) != 1 || !choices[0].Weighted() { + t.Fatalf("choices %v, want one weighted decision", choices) + } + return run.Outputs[0].Value.Const.Int, choices[0] + } + + for _, policy := range []string{"declared", "reverse"} { + sub, choice := verify(t, policy) + if !strings.HasSuffix(sub, "checkZeroed") || choice.Drawn { + t.Errorf("%s performed %s (drawn %v), want the 0.8 branch undrawn", policy, sub, choice.Drawn) + } + taken, choice := analyze(t, policy) + if taken != 1 || choice.Drawn { + t.Errorf("%s computed %d (drawn %v), want the 0.8 branch's 1 undrawn", policy, taken, choice.Drawn) + } + } + sub, choice := verify(t, "seed:3") + if again, _ := verify(t, "seed:3"); again != sub || !choice.Drawn { + t.Errorf("seed:3 performed %s then %s (drawn %v), want one drawn branch reproduced", sub, again, choice.Drawn) + } + taken, choice := analyze(t, "seed:3") + if again, _ := analyze(t, "seed:3"); again != taken || !choice.Drawn { + t.Errorf("seed:3 computed %d then %d (drawn %v), want one drawn branch reproduced", taken, again, choice.Drawn) + } +} + +// Explore still enumerates every branch of a weighted decision, weights notwithstanding. +func TestExploreEnumeratesWeightedBranches(t *testing.T) { + m := parseLibraryModel(t, weightedRouteModel) + x := m.exploreAction(t, "explore", "route") + if !x.Complete() || len(x.Outcomes) != 2 { + t.Fatalf("explore found %v, want both branches", outcomeTexts(x)) + } + for _, o := range x.Outcomes { + if len(o.Witness) != 1 || !o.Witness[0].Weighted() { + t.Errorf("witness %v, want the weighted decision", o.Witness) + } + } +} + +// Each outcome of a complete exploration carries the probability of its +// linearizations: the product of the run's picks' shares, weighted ones by the +// stated weight, unweighted ones the uniform share a seed draws each with. +func TestExploreWeighsLinearizations(t *testing.T) { + m := parseLibraryModel(t, weightedRouteModel) + x := m.exploreAction(t, "explore", "route") + if !x.Complete() || len(x.Outcomes) != 2 { + t.Fatalf("explore found %v, want both branches", outcomeTexts(x)) + } + for _, o := range x.Outcomes { + want := 0.7 + if takenInt(t, o.Outcome.Outputs, "taken") == 2 { + want = 0.3 + } + if math.Abs(o.Probability-want) > 1e-9 { + t.Errorf("%s: probability %v, want %v", o.Outcome, o.Probability, want) + } + } + if p := x.Probability(); math.Abs(p-1) > 1e-9 || x.ProbabilitiesBounded() { + t.Errorf("a complete exploration covers %v, want 1 exact", p) + } +} + +// An unweighted choice point contributes the uniform share of its alternatives: +// two token orders against the 0.3/0.7 decision leave every outcome at 0.15 or 0.35. +func TestExploreWeighsUnweightedChoicesUniformly(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import Stochastic::*; + action mix { + attribute x : Integer = 0; + attribute y : Integer = 0; + first start; + fork split; + action a { assign x := 1; } + action b { assign x := 2; } + join sync; + then decide select; + first select then slow { @Probability { p = 0.3; } } + first select then fast { @Probability { p = 0.7; } } + action slow { assign y := 1; } + then done; + action fast { assign y := 2; } + then done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + } + }`) + x := m.exploreAction(t, "explore", "mix") + if !x.Complete() || len(x.Outcomes) != 4 { + t.Fatalf("explore found %v, want the four orders and picks", outcomeTexts(x)) + } + got := map[[2]int64]float64{} + for _, o := range x.Outcomes { + got[[2]int64{takenInt(t, o.Outcome.Outputs, "x"), takenInt(t, o.Outcome.Outputs, "y")}] += o.Probability + } + want := map[[2]int64]float64{{1, 1}: 0.15, {1, 2}: 0.35, {2, 1}: 0.15, {2, 2}: 0.35} + for xy, p := range want { + if math.Abs(got[xy]-p) > 1e-9 { + t.Errorf("x=%d, y=%d: probability %v, want %v", xy[0], xy[1], got[xy], p) + } + } + if p := x.Probability(); math.Abs(p-1) > 1e-9 { + t.Errorf("linearizations carry %v, want 1", p) + } +} + +// A random function call without a seed, model or schedule, is a typed refusal +// naming the call and the seed it needs; a probe of it draws nothing. +func TestRandomFunctionRefusesToDrawUnseeded(t *testing.T) { + m := parseLibraryModel(t, drawingModel) + for _, policy := range []string{"", "declared", "reverse"} { + ctx, _, err := runAction(t, m, "draw", policy) + var unseeded *UnseededDrawError + if !errors.As(err, &unseeded) || !errors.Is(err, ErrUnseededDraw) { + t.Fatalf("%q: error %T %v, want an UnseededDrawError", policy, err, err) + } + if unseeded.What != "uniform(0.0, 1.0)" || !strings.Contains(err.Error(), "seed:") { + t.Errorf("%q: error %q, want it to name the call and the seed", policy, err) + } + if len(ctx.DrawsTaken()) != 0 { + t.Errorf("%q: draws %v recorded by a refused run", policy, ctx.DrawsTaken()) + } + } +} + +// Every random function refuses arguments bounding no distribution with a typed +// error, before any draw is made. +func TestRandomFunctionsRefuseAnEmptyDomain(t *testing.T) { + cases := []struct{ name, expr, want string }{ + {"uniform reversed", "uniform(80.0, 1.0)", "lo exceeds hi"}, + {"uniformInteger reversed", "uniformInteger(6, 1)", "lo exceeds hi"}, + {"triangular mode outside", "triangular(0.0, 2.0, 1.0)", "needs lo <= mode <= hi"}, + {"triangular flat", "triangular(1.0, 1.0, 1.0)", "needs lo <= mode <= hi with lo < hi"}, + {"normal negative sd", "normal(0.0, -1.0)", "sd is negative"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute d : Real = `+tc.expr+`; + first start; then done; + } + }`) + ctx, _, err := runAction(t, m, "draw", "seed:1") + if !errors.Is(err, ErrRandomDomain) { + t.Fatalf("error %v, want ErrRandomDomain", err) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error %q, want it to say %q", err, tc.want) + } + if len(ctx.DrawsTaken()) != 0 { + t.Errorf("draws %v made by a refused call", ctx.DrawsTaken()) + } + }) + } +} + +// A bound that is not a finite number, which no expression the evaluator accepts +// produces but a library caller may hand in, is refused before any draw. +func TestRandomFunctionsRefuseANonFiniteBound(t *testing.T) { + m := parseLibraryModel(t, drawingModel) + ctx, _ := m.fresh() + ctx.SetModelSeed(1) + ctx.run.scheduler = ctx.schedulerUnder(ctx.schedule) + for _, bad := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + args := []semantics.Value{drawnReal(0), drawnReal(bad)} + if _, err := drawUniform(ctx, RandomFunctionsFQN+"::uniform", args); !errors.Is(err, ErrRandomDomain) || !strings.Contains(err.Error(), "is not a finite number") { + t.Errorf("uniform(0, %v) = %v, want ErrRandomDomain naming the bound", bad, err) + } + if _, err := drawNormal(ctx, RandomFunctionsFQN+"::normal", args); !errors.Is(err, ErrRandomDomain) { + t.Errorf("normal(0, %v) = %v, want ErrRandomDomain", bad, err) + } + if _, err := drawTriangular(ctx, RandomFunctionsFQN+"::triangular", []semantics.Value{drawnReal(0), drawnReal(bad), drawnReal(1)}); !errors.Is(err, ErrRandomDomain) { + t.Errorf("triangular(0, %v, 1) = %v, want ErrRandomDomain", bad, err) + } + } + if len(ctx.DrawsTaken()) != 0 { + t.Errorf("draws %v made by refused calls", ctx.DrawsTaken()) + } +} + +// uniformInteger draws both ends of its range, exactly the one value of a +// zero-width range, and the whole Integer range without overflowing. +func TestUniformIntegerCoversItsRangeInclusively(t *testing.T) { + draw := func(t *testing.T, call string, seeds int) []int64 { + t.Helper() + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute n : Integer = `+call+`; + first start; then done; + } + }`) + values := make([]int64, 0, seeds) + for seed := 1; seed <= seeds; seed++ { + _, out, err := runAction(t, m, "draw", "declared", uint64(seed)) + if err != nil { + t.Fatalf("%s under seed %d: %v", call, seed, err) + } + values = append(values, takenInt(t, out, "n")) + } + return values + } + + seen := map[int64]bool{} + for _, n := range draw(t, "uniformInteger(1, 3)", 60) { + if n < 1 || n > 3 { + t.Fatalf("uniformInteger(1, 3) drew %d", n) + } + seen[n] = true + } + if !seen[1] || !seen[2] || !seen[3] { + t.Errorf("60 draws of uniformInteger(1, 3) saw %v, want both ends", seen) + } + for _, n := range draw(t, "uniformInteger(5, 5)", 3) { + if n != 5 { + t.Errorf("uniformInteger(5, 5) drew %d", n) + } + } + for _, n := range draw(t, "uniformInteger(-9223372036854775807 - 1, 9223372036854775807)", 8) { + _ = n // any Integer is in range; the draw must not fail or overflow + } + negative := draw(t, "uniformInteger(-3, -1)", 20) + for _, n := range negative { + if n < -3 || n > -1 { + t.Errorf("uniformInteger(-3, -1) drew %d", n) + } + } + if a, b := draw(t, "uniformInteger(1, 1000000)", 1), draw(t, "uniformInteger(1, 1000000)", 1); a[0] != b[0] { + t.Errorf("seed 1 drew %d then %d", a[0], b[0]) + } +} + +// Each random function draws within its support and, under a fixed seed, the same value. +func TestRandomFunctionsDrawWithinTheirSupport(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute u : Real = uniform(2.0, 3.0); + attribute tri : Real = triangular(0.0, 1.0, 4.0); + attribute flat : Real = normal(5.0, 0.0); + attribute g : Real = normal(0.0, 1.0); + first start; then done; + } + }`) + var last map[string]Value + for seed := uint64(1); seed <= 30; seed++ { + _, out, err := runAction(t, m, "draw", "declared", seed) + if err != nil { + t.Fatal(err) + } + if u := out["u"].Const.Real; u < 2 || u >= 3 { + t.Errorf("uniform(2.0, 3.0) drew %v", u) + } + if x := out["tri"].Const.Real; x < 0 || x > 4 { + t.Errorf("triangular(0.0, 1.0, 4.0) drew %v", x) + } + if x := out["flat"].Const.Real; x != 5 { + t.Errorf("normal(5.0, 0.0) drew %v, want the mean", x) + } + if x := out["g"].Const.Real; math.IsNaN(x) || math.IsInf(x, 0) { + t.Errorf("normal(0.0, 1.0) drew %v", x) + } + last = out + } + _, again, err := runAction(t, m, "draw", "declared", 30) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"u", "tri", "g"} { + if again[name].Const.Real != last[name].Const.Real { + t.Errorf("seed 30 drew %s = %v then %v", name, last[name], again[name]) + } + } +} + +// normal never draws what its witness would refuse: with parameters near the +// largest Real the raw draw overflows to infinity on some seeds, and each such +// run still yields a finite value that replays. +func TestNormalDrawsStayFiniteNearTheLargestReal(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute x : Real = normal(1.0e308, 1.0e308); + first start; then done; + } + }`) + overflowed := 0 + for seed := uint64(1); seed <= 40; seed++ { + if raw := 1.0e308 + 1.0e308*newModeledSource(seed).rng.NormFloat64(); math.IsInf(raw, 0) { + overflowed++ + } + ctx, out, err := runAction(t, m, "draw", "declared", seed) + if err != nil { + t.Fatalf("seed %d: %v", seed, err) + } + x := out["x"].Const.Real + if math.IsInf(x, 0) || math.IsNaN(x) { + t.Fatalf("seed %d drew normal(1.0e308, 1.0e308) = %v", seed, x) + } + w := Witness{Draws: ctx.DrawsTaken()} + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(w)) + got, err := replay.ExecuteAction(m.action(t, "draw")) + if err == nil { + err = replay.Unfollowed() + } + if err != nil { + t.Fatalf("seed %d: replay: %v\n%s", seed, err, w) + } + if got["x"].Const.Real != x { + t.Errorf("seed %d: replay gave x = %v, want %v", seed, got["x"].Const.Real, x) + } + } + if overflowed == 0 { + t.Fatal("no seed's raw draw overflowed; the test exercises nothing") + } +} + +// Bounds a float apart — where hi-lo, or the products the triangular inverse +// forms, overflow — still bound the draws: every one is finite, within the +// interval, spread over it rather than piled at hi, and replays. +func TestWideBoundsStillBoundTheDraws(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute u : Real = uniform(-1.0e308, 1.0e308); + attribute t : Real = triangular(-1.0e308, 0.0, 1.0e308); + attribute s : Real = triangular(0.0, 1.0e200, 1.0e200); + first start; then done; + } + }`) + bounds := map[string][2]float64{ + "u": {-1.0e308, 1.0e308}, + "t": {-1.0e308, 1.0e308}, + "s": {0, 1.0e200}, + } + signs := map[string]map[bool]int{"u": {}, "t": {}} + for seed := uint64(1); seed <= 40; seed++ { + ctx, out, err := runAction(t, m, "draw", "declared", seed) + if err != nil { + t.Fatalf("seed %d: %v", seed, err) + } + for name, bound := range bounds { + x := out[name].Const.Real + if math.IsInf(x, 0) || math.IsNaN(x) || x < bound[0] || x > bound[1] || (name == "u" && x == bound[1]) { + t.Fatalf("seed %d drew %s = %v, outside its bounds %v", seed, name, x, bound) + } + if counts, spread := signs[name]; spread { + counts[x < 0]++ + } + } + w := Witness{Draws: ctx.DrawsTaken()} + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(w)) + got, err := replay.ExecuteAction(m.action(t, "draw")) + if err == nil { + err = replay.Unfollowed() + } + if err != nil { + t.Fatalf("seed %d: replay: %v\n%s", seed, err, w) + } + for name := range bounds { + if got[name].Const.Real != out[name].Const.Real { + t.Errorf("seed %d: replay gave %s = %v, want %v", seed, name, got[name].Const.Real, out[name].Const.Real) + } + } + } + for name, counts := range signs { + if counts[true] == 0 || counts[false] == 0 { + t.Errorf("%s drew %d negative and %d non-negative values over 40 seeds: not spread over its interval", name, counts[true], counts[false]) + } + } +} + +// between and fractionOf are the arithmetic the wide draws rest on: exact at the +// ends, finite and within the interval wherever the span itself is not a float. +func TestBetweenAndFractionOfWideSpans(t *testing.T) { + lo, hi := -math.MaxFloat64, math.MaxFloat64 + for _, tc := range []struct { + t, want float64 + }{{0, lo}, {1, hi}, {0.5, 0}} { + if got := between(lo, hi, tc.t); got != tc.want { + t.Errorf("between(-max, max, %v) = %v, want %v", tc.t, got, tc.want) + } + } + if q := between(lo, hi, 0.25); math.IsInf(q, 0) || q >= 0 || q <= lo { + t.Errorf("between(-max, max, 0.25) = %v, want a finite negative", q) + } + if got := between(1, 80, 0.5); got != 1+0.5*79 { + t.Errorf("between(1, 80, 0.5) = %v, want the plain interpolation %v", got, 1+0.5*79) + } + for _, tc := range []struct { + lo, x, hi, want float64 + }{{lo, 0, hi, 0.5}, {lo, lo, hi, 0}, {lo, hi, hi, 1}, {0, 1, 4, 0.25}, {0, 1.0e200, 1.0e200, 1}} { + if got := fractionOf(tc.lo, tc.x, tc.hi); got != tc.want { + t.Errorf("fractionOf(%v, %v, %v) = %v, want %v", tc.lo, tc.x, tc.hi, got, tc.want) + } + } +} + +// A run's witness records its draws, and replaying it reproduces the run: the same +// values, the same branch, every draw consumed, no generator consulted. +func TestReplayReproducesTheDrawsAndTheWeightedBranch(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import Stochastic::*; + private import RandomFunctions::*; + action route { + attribute taken : Integer = 0; + attribute d : Real = uniform(0.0, 10.0); + attribute n : Integer = uniformInteger(1, 6); + first start; + then decide select; + first select then slow { @Probability { p = 0.3; } } + first select then fast { @Probability { p = 0.7; } } + action slow { assign taken := 2; } + then done; + action fast { assign taken := 1; } + then done; + } + }`) + for seed := uint64(1); seed <= 6; seed++ { + ctx, out, err := runAction(t, m, "route", fmt.Sprintf("seed:%d", seed)) + if err != nil { + t.Fatal(err) + } + w := Witness{Draws: ctx.DrawsTaken(), Choices: ctx.ChoicesTaken()} + if len(w.Draws) != 2 || w.Draws[0].What != "uniform(0.0, 10.0)" || w.Draws[1].What != "uniformInteger(1, 6)" { + t.Fatalf("seed %d recorded %v, want the two draws in order", seed, w.Draws) + } + parsed, err := ParseWitness(w.String()) + if err != nil { + t.Fatalf("seed %d: the witness does not read back: %v\n%s", seed, err, w) + } + if len(w.Choices) != 1 || !w.Choices[0].Drawn || !reflect.DeepEqual(parsed.Choices, w.Choices) { + t.Fatalf("seed %d: the witness reads back as %+v, want the drawn weighted choice %+v", seed, parsed.Choices, w.Choices) + } + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(parsed)) + got, err := replay.ExecuteAction(m.action(t, "route")) + if err == nil { + err = replay.Unfollowed() + } + if err != nil { + t.Fatalf("seed %d: replay: %v\n%s", seed, err, w) + } + for _, name := range []string{"taken", "d", "n"} { + if got[name].Const != out[name].Const { + t.Errorf("seed %d: replay gave %s = %v, want %v", seed, name, got[name], out[name]) + } + } + if again := replay.DrawsTaken(); len(again) != 2 || again[0] != w.Draws[0] || again[1] != w.Draws[1] { + t.Errorf("seed %d: the replay recorded %v, want the witness's draws", seed, again) + } + } +} + +// A witness whose draws the run cannot consume is refused with a typed error: +// one drawn for another call, one missing, one left over, one the call could not +// have drawn, and one spelt unreadably. +func TestReplayRefusesDrawsItCannotConsume(t *testing.T) { + m := parseLibraryModel(t, drawingModel) + ctx, _, err := runAction(t, m, "draw", "seed:3") + if err != nil { + t.Fatal(err) + } + good := Witness{Draws: ctx.DrawsTaken()} + realOf := func(x float64) semantics.Value { return semantics.Value{Kind: semantics.ValReal, Real: x} } + integer := func(n int64) semantics.Value { return semantics.Value{Kind: semantics.ValInt, Int: n} } + cases := []struct { + name string + draws []DrawTaken + draw int + want string + }{ + {"another call", []DrawTaken{{What: "normal(0.0, 1.0)", Value: realOf(0.5)}, good.Draws[1]}, 1, "the run drew uniform(0.0, 1.0) instead"}, + {"missing", good.Draws[:1], 0, "records no draw left for it"}, + {"left over", append(append([]DrawTaken{}, good.Draws...), DrawTaken{What: "uniform(0.0, 1.0)", Value: realOf(0.25)}), 3, "the run ended without drawing it"}, + {"none at all", nil, 0, "records no draw left for it"}, + {"real above hi", []DrawTaken{{What: good.Draws[0].What, Value: realOf(2)}, good.Draws[1]}, 1, "records 2.0, which the call cannot draw"}, + {"real below lo", []DrawTaken{{What: good.Draws[0].What, Value: realOf(-0.5)}, good.Draws[1]}, 1, "records -0.5, which the call cannot draw"}, + {"real for an integer", []DrawTaken{good.Draws[0], {What: good.Draws[1].What, Value: realOf(3.5)}}, 2, "records 3.5, which the call cannot draw"}, + {"integer past hi", []DrawTaken{good.Draws[0], {What: good.Draws[1].What, Value: integer(7)}}, 2, "records 7, which the call cannot draw"}, + {"integer for a real", []DrawTaken{{What: good.Draws[0].What, Value: integer(0)}, good.Draws[1]}, 1, "records 0, which the call cannot draw"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(Witness{Draws: tc.draws})) + _, err := replay.ExecuteAction(m.action(t, "draw")) + if err == nil { + err = replay.Unfollowed() + } + var refused *WitnessDrawError + if !errors.As(err, &refused) || !errors.Is(err, ErrWitnessDraw) { + t.Fatalf("error %T %v, want a WitnessDrawError", err, err) + } + if refused.Draw != tc.draw || !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = draw %d %q, want draw %d saying %q", refused.Draw, err, tc.draw, tc.want) + } + }) + } + + for _, line := range []string{"draw uniform(0.0, 1.0)", "draw = 0.5", "draw uniform(0.0, 1.0) = half", "drew uniform(0.0, 1.0) = 0.5"} { + if _, err := ParseDraw(line); !errors.Is(err, ErrInvalidDraw) { + t.Errorf("ParseDraw(%q) = %v, want ErrInvalidDraw", line, err) + } + } + if _, err := ParseWitness("draw uniform(0.0, 1.0) = half\n"); !errors.Is(err, ErrInvalidDraw) { + t.Errorf("a witness with a malformed draw parsed: %v", err) + } + for _, d := range good.Draws { + back, err := ParseDraw(d.String()) + if err != nil || back != d { + t.Errorf("ParseDraw(%q) = %v, %v; want the draw back", d, back, err) + } + } +} + +// A replayed draw must lie where its call's distribution puts it: a triangular draw +// within its bounds, a normal draw finite and, at zero deviation, at the mean. +func TestReplayRefusesDrawsOutsideTheCallsDistribution(t *testing.T) { + m := parseLibraryModel(t, ` + package test { + private import ScalarValues::*; + private import RandomFunctions::*; + action draw { + attribute a : Real = triangular(0.0, 1.0, 2.0); + attribute b : Real = normal(5.0, 0.0); + attribute c : Real = normal(0.0, 1.0); + attribute u : Real = uniform(0.0, 1.0); + attribute z : Real = uniform(3.0, 3.0); + first start; then done; + } + }`) + ctx, _, err := runAction(t, m, "draw", "seed:3") + if err != nil { + t.Fatal(err) + } + good := ctx.DrawsTaken() + if len(good) != 5 { + t.Fatalf("recorded %v, want five draws", good) + } + realOf := func(x float64) semantics.Value { return semantics.Value{Kind: semantics.ValReal, Real: x} } + with := func(i int, v semantics.Value) []DrawTaken { + draws := append([]DrawTaken{}, good...) + draws[i].Value = v + return draws + } + cases := []struct { + name string + draws []DrawTaken + ok bool + }{ + {"triangular at lo", with(0, realOf(0)), true}, + {"triangular at hi", with(0, realOf(2)), true}, + {"triangular past hi", with(0, realOf(2.5)), false}, + {"zero deviation at the mean", with(1, realOf(5)), true}, + {"zero deviation off the mean", with(1, realOf(5.1)), false}, + {"normal far out", with(2, realOf(-40)), true}, + {"normal infinite", with(2, realOf(math.Inf(1))), false}, + {"uniform at lo", with(3, realOf(0)), true}, + {"uniform just below hi", with(3, realOf(math.Nextafter(1, 0))), true}, + {"uniform at hi", with(3, realOf(1)), false}, + {"uniform below lo", with(3, realOf(-0.1)), false}, + {"zero-width uniform at its one value", with(4, realOf(3)), true}, + {"zero-width uniform off its one value", with(4, realOf(3.1)), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(Witness{Draws: tc.draws})) + _, err := replay.ExecuteAction(m.action(t, "draw")) + if err == nil { + err = replay.Unfollowed() + } + if tc.ok { + if err != nil { + t.Fatalf("replay refused a draw the call could make: %v", err) + } + return + } + if !errors.Is(err, ErrWitnessDraw) || !strings.Contains(err.Error(), "which the call cannot draw") { + t.Fatalf("error = %v, want a WitnessDrawError naming the draw the call cannot make", err) + } + }) + } +} + +// A replayed witness's weighted choice is followed as recorded, even where a seed +// would draw the other branch, and a choice not among the holding branches is refused. +func TestReplayFollowsTheRecordedWeightedBranch(t *testing.T) { + m := parseLibraryModel(t, weightedRouteModel) + x := m.exploreAction(t, "explore", "route") + for _, o := range x.Outcomes { + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(Witness{Choices: o.Witness})) + replay.SetModelSeed(7) + out, err := replay.ExecuteAction(m.action(t, "route")) + if err == nil { + err = replay.Unfollowed() + } + if err != nil { + t.Fatalf("replay of %s: %v", FormatChoices(o.Witness), err) + } + if got := fmt.Sprintf("taken = %d", takenInt(t, out, "taken")); got != o.Outcome.String() { + t.Errorf("replay of %s reached %s, want %s", FormatChoices(o.Witness), got, o.Outcome) + } + if choices := replay.Choices(); len(choices) != 1 || choices[0].Drawn { + t.Errorf("the replay drew a branch: %v", choices) + } + } +} + +// A weighted witness is followed only where it fits the decision the run faces: the +// same branches weighed the same, and a recorded draw that selects the branch it took. +// One weighed otherwise, drawn impossibly, or drawn for the other branch is refused +// as a stale witness is, whatever the model seed would draw. +func TestReplayRefusesAWeightedMoveTheDecisionDoesNotFit(t *testing.T) { + m := parseLibraryModel(t, weightedRouteModel) + ctx, _, err := runAction(t, m, "route", "declared", 7) + if err != nil { + t.Fatal(err) + } + good := ctx.ChoicesTaken() + if len(good) != 1 || !good[0].Drawn || good[0].Took != "2->fast" { + t.Fatalf("seed 7 recorded %v, want one drawn choice of 2->fast", FormatChoices(good)) + } + replayOf := func(w ChoiceTaken) error { + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(Witness{Choices: []ChoiceTaken{w}})) + replay.SetModelSeed(7) + if _, err := replay.ExecuteAction(m.action(t, "route")); err != nil { + return err + } + return replay.Unfollowed() + } + if err := replayOf(good[0]); err != nil { + t.Fatalf("the run's own witness is refused: %v", err) + } + reweighed := func(alter func(w *ChoiceTaken)) ChoiceTaken { + w := good[0] + w.Among, w.Weights = slices.Clone(w.Among), slices.Clone(w.Weights) + alter(&w) + return w + } + cases := []struct { + name string + move ChoiceTaken + faced string + }{ + {"a weight changed", reweighed(func(w *ChoiceTaken) { w.Weights[0], w.Weights[1] = 0.6, 0.4 }), "1->slow weighs p=0.3, not p=0.6"}, + {"a branch fewer", reweighed(func(w *ChoiceTaken) { + w.Among, w.Weights, w.Alternatives, w.Taken = w.Among[1:], w.Weights[1:], 1, 0 + }), "the run's decision weighs every branch: 1->slow p=0.3, 2->fast p=0.7"}, + {"no weights recorded", reweighed(func(w *ChoiceTaken) { w.Weights, w.Drawn = nil, false }), "the run's decision weighs every branch"}, + {"a branch weighed twice", reweighed(func(w *ChoiceTaken) { + w.Among[0], w.Weights[0] = "2->fast", 0.7 + }), "the move weighs 2->fast twice: 2->fast, 2->fast"}, + {"a branch the run does not face", reweighed(func(w *ChoiceTaken) { + w.Among[0] = "3->never" + }), "3->never is not enabled (enabled: 1->slow, 2->fast)"}, + {"a draw of one", reweighed(func(w *ChoiceTaken) { w.Drew = 1 }), "the draw 1 is no unit draw in [0, 1)"}, + {"a negative draw", reweighed(func(w *ChoiceTaken) { w.Drew = -0.5 }), "the draw -0.5 is no unit draw in [0, 1)"}, + {"a draw that is not a number", reweighed(func(w *ChoiceTaken) { w.Drew = math.NaN() }), "is no unit draw in [0, 1)"}, + {"a draw selecting the other branch", reweighed(func(w *ChoiceTaken) { w.Drew = 0.1 }), "the draw 0.1 selects 1->slow, not 2->fast"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := replayOf(c.move) + var refused *ReplayError + if !errors.As(err, &refused) || !errors.Is(err, ErrReplayRefused) { + t.Fatalf("error %T %v, want a ReplayError", err, err) + } + if refused.Move != 1 || !strings.Contains(err.Error(), c.faced) { + t.Errorf("error %q, want move 1 refused as %q", err, c.faced) + } + }) + } + unweighted, err := ParseChoices(fmt.Sprintf("step %d: decision select -> 1->slow", good[0].Step)) + if err != nil { + t.Fatal(err) + } + if err = replayOf(unweighted[0]); !errors.Is(err, ErrReplayRefused) || !strings.Contains(err.Error(), "the run's decision weighs every branch") { + t.Fatalf("an unweighted move at the weighted decision: %v, want it refused", err) + } +} + +// A replayed weighted move carries the witness's draw, so the replay's own witness +// reads as the run's did. +func TestReplayCarriesTheWitnessedDraw(t *testing.T) { + m := parseLibraryModel(t, weightedRouteModel) + ctx, _, err := runAction(t, m, "route", "declared", 7) + if err != nil { + t.Fatal(err) + } + w := Witness{Choices: ctx.ChoicesTaken()} + replay, _ := m.fresh() + mustSchedule(t, replay, ReplayOf(w)) + if _, err := replay.ExecuteAction(m.action(t, "route")); err != nil { + t.Fatal(err) + } + if err := replay.Unfollowed(); err != nil { + t.Fatal(err) + } + if again := FormatChoices(replay.ChoicesTaken()); again != FormatChoices(w.Choices) { + t.Errorf("the replay recorded %s, want the witness %s", again, FormatChoices(w.Choices)) + } +} + +// A probe of a run that draws — a checker's or explorer's preview — leaves the +// modeled stream where it was: the run after it draws what it would have. +func TestProbeRestoresTheModeledStream(t *testing.T) { + m := parseLibraryModel(t, drawingModel) + ctx, _ := m.fresh() + mustSchedule(t, ctx, mustPolicy(t, "seed:5")) + ctx.SetModelSeed(11) + ctx.run.scheduler = ctx.schedulerUnder(ctx.schedule) + unit := distribution{ + draw: func(rng *rand.Rand) semantics.Value { return drawnReal(rng.Float64()) }, + admits: realWithin(0, 1), + } + peek := func() semantics.Value { + saved := *ctx.run.scheduler.modeled.pcg + v, err := ctx.run.scheduler.draw("peek", unit) + if err != nil { + t.Fatal(err) + } + *ctx.run.scheduler.modeled.pcg = saved + return v + } + before := peek() + + end := ctx.beginProbe() + if _, err := ctx.draw("probe", unit); err != nil { + t.Fatal(err) + } + if len(ctx.DrawsTaken()) != 0 { + t.Errorf("a probe's draw was recorded: %v", ctx.DrawsTaken()) + } + end() + if after := peek(); after != before { + t.Errorf("the probe moved the modeled stream: %v then %v", before, after) + } +} + +// Under check, a weighted decision is searched like any other: both branches +// are reached and reported as finals, the weights only recorded in the witness. +func TestCheckSearchesWeightedBranchesAsASet(t *testing.T) { + m := parseLibraryModel(t, weightedRouteModel) + report := checkModel(t, m, "route", CheckBudget{}, CheckOptions{}) + if len(report.Finals) != 2 { + t.Fatalf("check found %d final(s), want the two branches: %s", len(report.Finals), report.Status()) + } + for _, final := range report.Finals { + choices := final.Witness.Choices + if len(choices) != 1 || !choices[0].Weighted() { + t.Errorf("final %v carries the witness %v, want the weighted decision", final.Outcome, choices) + } + } +} + +// Two check paths that consume different numbers of draws before reaching the +// same configuration and values are different states: the seeded stream stands +// elsewhere on each, so what the run draws next differs. +func TestCheckKeepsPathsApartByTheirModeledStream(t *testing.T) { + m := parseLibraryModel(t, ` +package test { + private import ScalarValues::*; + private import RandomFunctions::*; + state def Machine { + attribute x : Real = 0.0; + attribute seen : Real = 0.0; + entry; then a; + state a; + transition first a if true do { assign x := uniform(0.0, 1.0); } then b; + transition first a if true do { assign x := 0.0; } then b; + state b { entry { assign x := 0.0; } } + transition first b if true do { assign seen := uniform(0.0, 1.0); } then c; + state c; + } +}`) + seeded := func() (*Context, error) { + ctx, err := m.fresh() + if err == nil { + ctx.SetModelSeed(3) + } + return ctx, err + } + start := invocationOf(nil, []*symbols.Symbol{m.state(t, "Machine")}) + report, err := Check(context.Background(), seeded, start, CheckBudget{}, CheckOptions{}, nil) + if err != nil { + t.Fatal(err) + } + if len(report.Violations) > 0 { + t.Fatalf("check failed: %v", report.Violations) + } + if got := divergentValues(report, "seen"); len(got) != 2 || got[0] == got[1] { + t.Fatalf("seen takes %v over the two paths, want a value per stream position: %s", got, report.Status()) + } + if len(report.Finals) != 2 { + t.Errorf("check found %d final(s), want one per path", len(report.Finals)) + } +} diff --git a/internal/exec/runtime/stream_stage_test.go b/internal/exec/runtime/stream_stage_test.go new file mode 100644 index 0000000000..397a56a472 --- /dev/null +++ b/internal/exec/runtime/stream_stage_test.go @@ -0,0 +1,240 @@ +package runtime + +import ( + "slices" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +func intConst(i int64) Value { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: i}} +} + +// Streaming writes staged ahead of a target's performance are kept per source pin and +// flow: a source's next write to a pin replaces its own earlier value however many other +// sources wrote in between, and taking a delivery shifts what every source left waiting. +func TestStagedStreamsAreKeptPerSource(t *testing.T) { + target := &ast.Usage{NodeBase: ast.NodeBase{NodeSpan: source.Span{Offset: 10, Len: 1}}, Kind: ast.UsageAction} + flow := &ast.Usage{NodeBase: ast.NodeBase{NodeSpan: source.Span{Offset: 20, Len: 1}}, Kind: ast.UsageFlow} + frame, a, b := &actionFrame{}, &actionFrame{}, &actionFrame{} + queue := func() []int64 { + var values []int64 + for _, v := range frame.pending[target]["v"] { + values = append(values, v.Const.Int) + } + return values + } + appended := []bool{ + frame.stage(target, "v", a, "out", flow, intConst(1)), + frame.stage(target, "v", b, "out", flow, intConst(2)), + frame.stage(target, "v", a, "out", flow, intConst(3)), + frame.stage(target, "v", b, "out", flow, intConst(4)), + } + if want := []bool{true, true, false, false}; !slices.Equal(appended, want) { + t.Fatalf("appended = %v, want %v", appended, want) + } + if got := queue(); len(got) != 2 || got[0] != 3 || got[1] != 4 { + t.Fatalf("queue = %v, want [3 4]: each source's latest value in its own place", got) + } + take := func() { + frame.shiftStaged(target, "v") + frame.pending[target]["v"] = frame.pending[target]["v"][1:] + } + take() + if !frame.stage(target, "v", a, "out", flow, intConst(5)) { + t.Fatalf("a's write after its value was taken was not appended") + } + if got := queue(); !slices.Equal(got, []int64{4, 5}) { + t.Fatalf("queue = %v, want [4 5]: a's value gone with the delivery, its next write queued last", got) + } + take() + if !frame.stage(target, "v", b, "out", flow, intConst(6)) || frame.stage(target, "v", a, "out", flow, intConst(7)) { + t.Fatalf("after the second delivery, want b's write appended and a's replacing its own") + } + if got := queue(); !slices.Equal(got, []int64{7, 6}) { + t.Fatalf("queue = %v, want [7 6]", got) + } + if !frame.stage(target, "v", a, "other", flow, intConst(8)) || frame.stage(target, "v", a, "other", flow, intConst(9)) { + t.Fatalf("want a's write to another pin appended, and its next write there replacing it") + } + if got := queue(); !slices.Equal(got, []int64{7, 6, 9}) { + t.Fatalf("queue = %v, want [7 6 9]: a's two pins each hold their own place", got) + } + second := &ast.Usage{NodeBase: ast.NodeBase{NodeSpan: source.Span{Offset: 30, Len: 1}}, Kind: ast.UsageFlow} + if !frame.stage(target, "v", a, "out", second, intConst(10)) || frame.stage(target, "v", a, "out", second, intConst(11)) { + t.Fatalf("want a second flow from a's pin appended beside the first, and its next write replacing its own") + } + if got := queue(); !slices.Equal(got, []int64{7, 6, 9, 11}) { + t.Fatalf("queue = %v, want [7 6 9 11]: two flows out of one pin each hold their own place", got) + } +} + +const stagedStreamSource = ` + package test { + part def Streamer { + attribute got : Integer = 0; + perform action run { + first start; + action producer { + out value : Integer; + action once { assign value := 1; } + action heard accept g : Integer; + action again { assign value := g; } + succession first start then once; + succession first once then heard; + succession first heard then again; + succession first again then done; + } + action consumer { + in value : Integer; + action take { assign got := value; } + succession first start then take; + succession first take then done; + } + done; + succession first start then producer; + succession first producer then consumer; + succession first consumer then done; + flow producer.value to consumer.value; + } + } + } +` + +// A held image carries what a streaming source staged ahead of its target: once the +// copy's source resumes and writes again, that write replaces the value imaged +// waiting for the consumer rather than queuing behind it. +func TestHeldImageCarriesStagedStreams(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, stagedStreamSource) + src := NewContext(typedModel(model, resolver), 10000) + streamer, err := src.Instantiate(resolveSymbol(t, resolveSymbol(t, root, "test").Scope, "Streamer")) + if err != nil { + t.Fatalf("Instantiate Streamer: %v", err) + } + run := func(t *testing.T, obj *Instance) *ActionExecutor { + t.Helper() + behavior, ok := obj.Behavior("run") + if !ok || behavior.Action == nil { + t.Fatalf("object #%d performs no run action, behaviors: %v", obj.ID, obj.Behaviors()) + } + return behavior.Action + } + if got := run(t, streamer).State(); got != StateWaiting { + t.Fatalf("the source is %v, want parked at its accept after the first write", got) + } + + dst := imageInto(t, src, streamer) + copied, _ := dst.Instance(streamer.ID) + nine := intConst(9) + dst.PostMessage(Message{SignalType: "Integer", Object: copied.ID, Value: &nine}) + if err := run(t, copied).RunToCompletion(); err != nil { + t.Fatalf("RunToCompletion(copy): %v", err) + } + if got := run(t, copied).State(); got != StateCompleted { + t.Fatalf("the copy is %v, want completed", got) + } + if got := featureInt(t, dst, copied, "got"); got != 9 { + t.Errorf("the copy's consumer read %d, want 9: the resumed producer's write replaced the staged 1", got) + } +} + +// The outputs of a performance write to every listening node's pins as they are +// written, in listening order, and no longer once the node stops listening. +func TestOutputListenersTakeEachWrite(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, stagedStreamSource) + ctx := NewContext(typedModel(model, resolver), 10000) + streamer, err := ctx.Instantiate(resolveSymbol(t, resolveSymbol(t, root, "test").Scope, "Streamer")) + if err != nil { + t.Fatalf("Instantiate Streamer: %v", err) + } + behavior, _ := streamer.Behavior("run") + exec := behavior.Action + var heard []string + record := func(tag string) func(string, Value) error { + return func(name string, value Value) error { + heard = append(heard, tag+":"+name+"="+FormatValue(value)) + return nil + } + } + first, second := &actionFrame{}, &actionFrame{} + exec.listen(first, record("first")) + exec.listen(second, record("second")) + exec.listen(first, record("first'")) + if err := exec.setFeature("got", intConst(4)); err != nil { + t.Fatalf("setFeature: %v", err) + } + exec.unlisten(second) + if err := exec.setFeature("got", intConst(5)); err != nil { + t.Fatalf("setFeature: %v", err) + } + exec.unlisten(first) + if err := exec.setFeature("got", intConst(6)); err != nil { + t.Fatalf("setFeature: %v", err) + } + want := []string{"second:got=4", "first':got=4", "first':got=5"} + if !slices.Equal(heard, want) { + t.Fatalf("heard = %v, want %v", heard, want) + } +} + +const performedOutputSource = ` + package test { + action def Base { + out result : Integer; + } + action def Producer :> Base { + out value : Integer redefines result = 5; + attribute i : Integer = 0; + action again { assign result := i; } + succession first start then again; + succession first again then done; + } + } +` + +// The value an action's declaration gives an output reaches the node performing it +// as the performance begins, before any node runs; an attribute's does not. +func TestPerformedOutputDefaultStreamsAtOnce(t *testing.T) { + model, resolver, root := parseAndBuildModel(t, performedOutputSource) + ctx := NewContext(typedModel(model, resolver), 10000) + producer := resolveSymbol(t, resolveSymbol(t, root, "test").Scope, "Producer") + var heard []string + listener := &outputListener{perf: &actionFrame{}, take: func(name string, value Value) error { + heard = append(heard, name+"="+FormatValue(value)) + return nil + }} + if _, err := ctx.beginCallee(producer, producer, nil, nil, listener); err != nil { + t.Fatalf("beginCallee: %v", err) + } + if want := []string{"value=5"}; !slices.Equal(heard, want) { + t.Fatalf("heard %v on beginning the performance, want %v", heard, want) + } +} + +// A node takes the writes of the action it performs by whatever name the action +// spells them, the name a pin redefines included; writes to other features it drops. +func TestCalleeOutputsLandByRedefinedName(t *testing.T) { + model, resolver, _ := parseAndBuildModel(t, performedOutputSource) + ctx := NewContext(typedModel(model, resolver), 10000) + e := &performances{ctx: ctx} + perf := &actionFrame{ + data: make(map[string]Value), + features: map[string]ast.FeatureDirection{"value": ast.DirOut}, + aliases: map[string]string{"result": "value"}, + } + take := e.streamCalleeOutput(perf, []string{"value"}) + for name, value := range map[string]int64{"result": 9, "i": 1} { + if err := take(name, intConst(value)); err != nil { + t.Fatalf("take(%s): %v", name, err) + } + } + if got, ok := perf.data["value"]; !ok || got.Const.Int != 9 { + t.Errorf("value = %v, %v; want 9 from the write spelled result", got, ok) + } + if len(perf.data) != 1 { + t.Errorf("data = %v, want only value: a write to an attribute is not an output", perf.data) + } +} diff --git a/internal/core/runtime/subject_redefinition_test.go b/internal/exec/runtime/subject_redefinition_test.go similarity index 99% rename from internal/core/runtime/subject_redefinition_test.go rename to internal/exec/runtime/subject_redefinition_test.go index eef3c6f02a..dcd09851f6 100644 --- a/internal/core/runtime/subject_redefinition_test.go +++ b/internal/exec/runtime/subject_redefinition_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // A subject or actor a derived requirement redeclares is one feature with the diff --git a/internal/core/runtime/subject_report_test.go b/internal/exec/runtime/subject_report_test.go similarity index 100% rename from internal/core/runtime/subject_report_test.go rename to internal/exec/runtime/subject_report_test.go diff --git a/internal/core/runtime/subject_short_name_test.go b/internal/exec/runtime/subject_short_name_test.go similarity index 98% rename from internal/core/runtime/subject_short_name_test.go rename to internal/exec/runtime/subject_short_name_test.go index b7ef034f3c..33c3f1ff6c 100644 --- a/internal/core/runtime/subject_short_name_test.go +++ b/internal/exec/runtime/subject_short_name_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // A condition reading a subject by its short name sees the object the subject diff --git a/internal/core/runtime/subject_value_test.go b/internal/exec/runtime/subject_value_test.go similarity index 96% rename from internal/core/runtime/subject_value_test.go rename to internal/exec/runtime/subject_value_test.go index 4aaa043451..dd03620fd8 100644 --- a/internal/core/runtime/subject_value_test.go +++ b/internal/exec/runtime/subject_value_test.go @@ -5,11 +5,11 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // A declared subject's value part binds the subject for the check, whether it is @@ -30,7 +30,7 @@ func TestRequirementSubjectDeclarationValue(t *testing.T) { idx := symbols.NewIndex() idx.AddDocument("test.sysml", file) resolver := resolve.New(idx) - ctx := NewContext(NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := NewContext(typedModel(semantics.NewModel(resolver), resolver), 10000) testPkg := idx.DocumentRoot("test.sysml").Children()[0] for _, tt := range []struct { @@ -98,7 +98,7 @@ func nestedSubjectFixture(t *testing.T, src string) (*Context, *symbols.Scope) { if len(root.Children()) == 0 { t.Fatal("no package indexed") } - return NewContext(NewModel(semantics.NewModel(resolver), resolver), 100000), root.Children()[0] + return NewContext(typedModel(semantics.NewModel(resolver), resolver), 100000), root.Children()[0] } // memberPath looks a member up along a path of names, as `Leaf::small` is. diff --git a/internal/core/runtime/subsetting.go b/internal/exec/runtime/subsetting.go similarity index 94% rename from internal/core/runtime/subsetting.go rename to internal/exec/runtime/subsetting.go index 891e5f06d3..dc960fe588 100644 --- a/internal/core/runtime/subsetting.go +++ b/internal/exec/runtime/subsetting.go @@ -3,9 +3,9 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // This file materializes the two ways a feature of an object takes its values @@ -331,27 +331,51 @@ func (ctx *Context) collectRedefinedFeatures(sym, owner *symbols.Symbol) []*symb // subsettedNames returns the features of owner sym subsets, itself or through a feature it // redefines, which subsets what the redefined one subsets (KerML 1.0 §7.3.4.5). func (ctx *Context) subsettedNames(sym, owner *symbols.Symbol) []string { + key := featureOfType{feature: sym, owner: owner} + if names, ok := ctx.model.subsetted[key]; ok { + return names + } names := ctx.relatedFeatureNames(sym, owner, ast.RelSubsets) for _, redefined := range ctx.redefinedFeatures(sym, owner) { names = append(names, ctx.relatedFeatureNames(redefined, owner, ast.RelSubsets)...) } + ctx.model.subsetted[key] = names return names } // SubsettingFeatures returns the features of typ subsetting the named feature under any // of its redefinition names, in declaration order, reading nothing; inst is nil for a type alone. func (ctx *Context) SubsettingFeatures(inst *Instance, typ *symbols.Symbol, name string) []EffectiveFeature { + ofType := ctx.subsettersOf(typ, name) + if inst == nil { + return ofType + } + var subsetting []EffectiveFeature + for _, feat := range ofType { + if _, ok := inst.FeatureValues[feat.Name]; ok { + subsetting = append(subsetting, feat) + } + } + return subsetting +} + +// subsettersOf is SubsettingFeatures for the type alone, memoized per type and name; +// callers read the shared slice and never append to it. +func (ctx *Context) subsettersOf(typ *symbols.Symbol, name string) []EffectiveFeature { + byName, ok := ctx.model.subsetters[typ] + if !ok { + byName = make(map[string][]EffectiveFeature) + ctx.model.subsetters[typ] = byName + } + if subsetting, ok := byName[name]; ok { + return subsetting + } aliases := ctx.redefinitionAliases(typ, name) var subsetting []EffectiveFeature for _, feat := range ctx.FeaturesOf(typ) { if aliases[feat.Name] || feat.Symbol == nil { continue } - if inst != nil { - if _, ok := inst.FeatureValues[feat.Name]; !ok { - continue - } - } for _, subsetted := range ctx.subsettedNames(feat.Symbol, typ) { if aliases[subsetted] { subsetting = append(subsetting, feat) @@ -359,6 +383,7 @@ func (ctx *Context) SubsettingFeatures(inst *Instance, typ *symbols.Symbol, name } } } + byName[name] = subsetting return subsetting } @@ -443,7 +468,7 @@ func fewestOf(mult semantics.Range) int64 { func (ctx *Context) fillsFromSubsetted(feat *EffectiveFeature) bool { lower := feat.Multiplicity.Lower return lower.Known && !lower.Infinite && lower.Value == 0 && - !ctx.model.semantics.IsConnectorUsage(feat.Symbol) && ctx.CompositeTypeOf(feat) != nil + !ctx.model.semantics.IsConnectorObjectUsage(feat.Symbol) && ctx.CompositeTypeOf(feat) != nil } // materializeSubsettedCollections reads the collections an optional feature subsets before the diff --git a/internal/core/runtime/subsetting_test.go b/internal/exec/runtime/subsetting_test.go similarity index 100% rename from internal/core/runtime/subsetting_test.go rename to internal/exec/runtime/subsetting_test.go diff --git a/internal/exec/runtime/sweep.go b/internal/exec/runtime/sweep.go new file mode 100644 index 0000000000..5c380e1b0c --- /dev/null +++ b/internal/exec/runtime/sweep.go @@ -0,0 +1,1045 @@ +package runtime + +import ( + "errors" + "fmt" + "math" + "math/bits" + "math/rand/v2" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// A sweep is tool-defined orchestration: it makes one ordinary analysis or calc +// run per row with one parameter bound to that row's value, and reports what +// each run produced. The language states nothing about it. + +// Typed refusals a sweep reports rather than running anything. +var ( + // ErrSweepRange reports a range no sequence of values follows from: a + // non-numeric endpoint, endpoints measuring different things, a zero step, a + // step whose sign never reaches the endpoint, a real range stating none, or an + // endpoint or step that is no value of the swept parameter's type. + ErrSweepRange = errors.New("invalid sweep range") + // ErrSweepParameter reports a parameter the target declares none of, or one + // the invocation already binds. + ErrSweepParameter = errors.New("invalid sweep parameter") + // ErrSweepSamples reports a sample request that draws nothing, states a step, + // or asks for a distribution no library in this build states. + ErrSweepSamples = errors.New("invalid sample request") + // ErrSweepBudget reports a sweep asking for more runs than its budget allows. + ErrSweepBudget = errors.New("sweep run budget exceeded") + // ErrSweepEmpty reports a sweep or sample naming no range at all. + ErrSweepEmpty = errors.New("no sweep range") + // ErrSweepDistribution reports a distribution asked for by name. Sampling is + // uniform over a range: the bundled library states no probability + // distributions, so nothing in the model gives a named one a meaning. + ErrSweepDistribution = errors.New("no distribution library") +) + +// SweepRange is one parameter's range as values: its endpoints, the step a +// sampled range leaves unstated, and the type ResolveSweepPlan produces them in. +type SweepRange struct { + Param string + From Value + To Value + Step Value + HasStep bool + Type SweepType +} + +// SweepNumbers is which numbers a swept parameter's type takes, which decides +// the values its range produces and how a sampled range draws them. +type SweepNumbers uint8 + +const ( + // SweepAsWritten produces values as the endpoints are written — Integers + // between Integers, reals otherwise — for a parameter that takes both or no type. + SweepAsWritten SweepNumbers = iota + // SweepIntegers produces Integers however the endpoints are written, and + // refuses an endpoint or step that is not one. + SweepIntegers + // SweepReals produces reals however the endpoints are written. + SweepReals +) + +// SweepType is a swept parameter's declared type, which its range's values are +// produced in and each endpoint is admitted to as an argument is. +type SweepType struct { + Numbers SweepNumbers + // Untyped reports a parameter declaring no type, whose range is read as written. + Untyped bool + // Positive excludes zero, which no scalar type's numbers alone rule out. + Positive bool + decl calcMemberDecl + // num is the feature a quantity type's magnitude is bound to, nil for a scalar. + num *symbols.Symbol +} + +// Declared is the type the parameter declares, nil where it declares none or +// the range was never resolved. +func (t SweepType) Declared() *symbols.Symbol { + if t.decl.Target == nil { + return nil + } + return t.decl.Target.typ +} + +// SweepBinding is one parameter bound to the value one row runs with. +type SweepBinding struct { + Param string + Value Value +} + +// SweepPlan is what a sweep asks for: the ranges its parameters take, for a +// sampled sweep the number of draws and the seed they are drawn from, and for +// a Monte Carlo the number of runs, each seeded by RunSeed of Seed, and no range. +type SweepPlan struct { + Ranges []SweepRange + Sampled bool + Samples int64 + Seed uint64 + Runs int64 + // MonteCarlo marks a plan made by MonteCarloPlan, whatever number of runs it asks for. + MonteCarlo bool + // Seedless marks a Monte Carlo whose runs derive no model seed: made by SeedlessMonteCarloPlan. + Seedless bool +} + +// IsMonteCarlo reports whether the plan runs one behavior repeatedly: made by +// MonteCarloPlan, or stating runs. +func (p SweepPlan) IsMonteCarlo() bool { return p.MonteCarlo || p.Runs != 0 } + +// Drawn reports whether the plan's rows come from Seed: a sampled sweep or a seeded +// Monte Carlo; a swept table steps its ranges, a seedless Monte Carlo derives no seed. +func (p SweepPlan) Drawn() bool { return p.Sampled || (p.IsMonteCarlo() && !p.Seedless) } + +// SweepRunResult is what one run of a sweep produced. A calc's returned value is +// reported as an output named "result", so a calc row and a case row read alike. +type SweepRunResult struct { + Outputs []CalcOutputValue + Verdicts []AnalysisVerdict + // Inputs are the values the run bound the case's input parameters to, in + // declaration order, the row's own binding included. + Inputs []InputBinding + // The object the run was about, where a case ran on one. + Subject *Instance + // Evaluations are the applications the run made of a calc held as a value, + // in the order made. + Evaluations []AnalysisEvaluation +} + +// SweepRun makes one run of a sweep in ctx, the row's own context, with the +// parameters bound as the row states. An error is that row's failure, not the +// table's; what the run produced before failing may be returned beside it. +type SweepRun func(ctx *Context, bindings []SweepBinding) (SweepRunResult, error) + +// SweepRow is one run of a sweep: what it was given, what it produced, how long +// it took, and what stopped it when it failed. +type SweepRow struct { + Bindings []SweepBinding + Outputs []CalcOutputValue + Verdicts []AnalysisVerdict + // The object this run's verdicts are about, where a case ran on one. + Subject *Instance + Evaluations []AnalysisEvaluation + Inputs []InputBinding + Elapsed time.Duration + Err error + // Context is the context the run was made in, which its outputs, subject and + // evaluations are read through: no other context knows the objects they name. + Context *Context +} + +// SweepTable is every run of one sweep, in the order they were made: a swept +// table runs lexicographically over its parameters in the order they were +// given, each from its first endpoint; a sampled table runs in draw order; a +// Monte Carlo in run order, its one parameter the run's number. +type SweepTable struct { + Target string + Params []string + // Types are the parameters' types as the plan resolved them, one per Param. + Types []SweepType + Sampled bool + Seed uint64 + Runs int64 + // Seedless marks a Monte Carlo whose runs derived no model seed from Seed. + Seedless bool + Rows []SweepRow +} + +// NewSweepTable is the table of a plan before any row is run: the target, the +// parameters and their types in plan order, and how the rows are drawn. +func NewSweepTable(target string, plan SweepPlan) SweepTable { + table := SweepTable{ + Target: target, + Params: make([]string, 0, len(plan.Ranges)), + Types: make([]SweepType, 0, len(plan.Ranges)), + Sampled: plan.Sampled, + Seed: plan.Seed, + Runs: plan.Runs, + Seedless: plan.Seedless, + } + if plan.IsMonteCarlo() { + table.Params = append(table.Params, RunParam) + table.Types = append(table.Types, SweepType{Numbers: SweepIntegers}) + } + for _, r := range plan.Ranges { + table.Params = append(table.Params, r.Param) + table.Types = append(table.Types, r.Type) + } + return table +} + +// sampleStream separates the generator's two seed words, so one seed still +// selects one whole PCG state. +const sampleStream uint64 = 0x9E3779B97F4A7C15 + +// NewSampleSource is the generator a sampled sweep draws from: math/rand/v2's +// PCG seeded from the given seed alone, so one seed draws one sequence of +// values on every platform and every build. +func NewSampleSource(seed uint64) *rand.Rand { + // #nosec G404 -- a reproducible table needs a stated generator, not a cryptographic one. + return rand.New(rand.NewPCG(seed, seed^sampleStream)) +} + +// runSweepRow makes one run of a sweep in ctx and tables it: the run's outputs +// and error, how long it took, and the context they are read through. +func runSweepRow(ctx *Context, bindings []SweepBinding, run SweepRun) SweepRow { + row := SweepRow{Bindings: bindings, Context: ctx} + started := time.Now() + result, err := run(ctx, bindings) + row.Elapsed = time.Since(started) + row.Err = err + row.Outputs, row.Verdicts = result.Outputs, result.Verdicts + row.Subject, row.Evaluations = result.Subject, result.Evaluations + row.Inputs = result.Inputs + return row +} + +// SweepRunBudget is the number of runs one sweep may ask for when none is stated. +func (ctx *Context) SweepRunBudget() int64 { return ctx.maxSweepRuns } + +// sweepRunLimit is the rows a sweep may make: runs when stated, else the context's. +func (ctx *Context) sweepRunLimit(runs int64) int64 { + if runs > 0 { + return runs + } + return ctx.maxSweepRuns +} + +// sweepBindings is every row of a plan, in the order it is run: the cartesian +// product of the swept ranges, one row per draw of a sampled one, or one per +// run of a Monte Carlo, at most limit. +func (ctx *Context) sweepBindings(plan SweepPlan, limit int64) ([][]SweepBinding, error) { + if plan.IsMonteCarlo() { + return ctx.runBindings(plan, limit) + } + if len(plan.Ranges) == 0 { + return nil, fmt.Errorf("%w: name a range as =..", ErrSweepEmpty) + } + seen := make(map[string]bool, len(plan.Ranges)) + for _, r := range plan.Ranges { + if r.Param == "" { + return nil, fmt.Errorf("%w: a range names no parameter", ErrSweepParameter) + } + if seen[r.Param] { + return nil, fmt.Errorf("%w: %s is swept twice", ErrSweepParameter, r.Param) + } + seen[r.Param] = true + } + if plan.Sampled { + return ctx.sampledBindings(plan, limit) + } + return ctx.sweptBindings(plan, limit) +} + +// sweptBindings enumerates each range and takes the cartesian product, the +// first parameter varying slowest so the rows read in the order given. +func (ctx *Context) sweptBindings(plan SweepPlan, limit int64) ([][]SweepBinding, error) { + columns := make([][]Value, len(plan.Ranges)) + total := int64(1) + for i, r := range plan.Ranges { + values, err := r.enumerate(ctx, limit) + if err != nil { + return nil, err + } + columns[i] = values + if total > limit/int64(len(values)) { + return nil, ctx.sweepBudgetError(limit) + } + total *= int64(len(values)) + } + rows := make([][]SweepBinding, 0, total) + index := make([]int, len(columns)) + for { + row := make([]SweepBinding, len(columns)) + for i := range columns { + row[i] = SweepBinding{Param: plan.Ranges[i].Param, Value: columns[i][index[i]]} + } + rows = append(rows, row) + pos := len(columns) - 1 + for pos >= 0 { + index[pos]++ + if index[pos] < len(columns[pos]) { + break + } + index[pos] = 0 + pos-- + } + if pos < 0 { + return rows, nil + } + } +} + +// sampledBindings draws one value per parameter per row, the parameters in the +// order they were given, so the draws pair up into rows rather than multiplying. +func (ctx *Context) sampledBindings(plan SweepPlan, limit int64) ([][]SweepBinding, error) { + if plan.Samples <= 0 { + return nil, fmt.Errorf("%w: draw at least one sample, got %d", ErrSweepSamples, plan.Samples) + } + if plan.Samples > limit { + return nil, ctx.sweepBudgetError(limit) + } + prepared := make([]sweepBounds, len(plan.Ranges)) + for i, r := range plan.Ranges { + if r.HasStep { + return nil, fmt.Errorf( + "%w: sampled range %s states a step, which only a swept range advances by", + ErrSweepSamples, r.Param, + ) + } + bounds, err := r.endpoints(ctx) + if err != nil { + return nil, err + } + prepared[i] = bounds + } + source := NewSampleSource(plan.Seed) + rows := make([][]SweepBinding, 0, plan.Samples) + for range plan.Samples { + row := make([]SweepBinding, len(prepared)) + for i := range prepared { + row[i] = SweepBinding{Param: plan.Ranges[i].Param, Value: prepared[i].draw(source)} + } + rows = append(rows, row) + } + return rows, nil +} + +// sweepBudgetError is the refusal of a plan asking for more runs than limit allows. +func (ctx *Context) sweepBudgetError(limit int64) error { + return fmt.Errorf("%w: at most %d run(s) per sweep%s", ErrSweepBudget, limit, ctx.raiseSweepRuns(limit)) +} + +// raiseSweepRuns names the variable that raises limit, when limit is the context's own. +func (ctx *Context) raiseSweepRuns(limit int64) string { + if limit != ctx.maxSweepRuns { + return "" + } + return " (raise " + MaxSweepRunsEnvVar + ")" +} + +// sweepBounds is a range as arithmetic reads it: magnitudes in its first +// endpoint's unit, and whether it takes Integers, kept exactly beyond 2^53. +type sweepBounds struct { + from, to float64 + step float64 + isInt bool + intFrom int64 + intTo int64 + intStep int64 + // whole reports both endpoints are whole numbers, which step by one unstated. + whole bool + unit semantics.Unit + quantity bool +} + +// exactFloatInt is the largest magnitude float64 counts by ones through. +const exactFloatInt = 1 << 53 + +// scalar is one endpoint's magnitude and unit. +type sweepScalar struct { + num semantics.Value + unit semantics.Unit + quantity bool +} + +// sweepScalarOf reads an endpoint: a bare number or a quantity, never anything +// a range cannot advance through. +func sweepScalarOf(v Value, what string) (sweepScalar, error) { + switch v.Kind { + case ValConst: + if !v.Const.IsNumeric() { + return sweepScalar{}, fmt.Errorf("%w: %s is %s, not a number", + ErrSweepRange, what, semantics.FormatConst(v.Const)) + } + return sweepScalar{num: v.Const}, nil + case ValQuantity: + q := v.Quantity() + if q == nil || !q.Num.IsNumeric() { + return sweepScalar{}, fmt.Errorf("%w: %s is not a numeric quantity", ErrSweepRange, what) + } + return sweepScalar{num: q.Num, unit: q.Unit, quantity: true}, nil + } + return sweepScalar{}, fmt.Errorf("%w: %s is a %s, not a number or a quantity", + ErrSweepRange, what, v.Kind) +} + +// magnitudeIn expresses the scalar in unit, which requires the two to measure +// the same thing: a bare number and a quantity never do. +func (s sweepScalar) magnitudeIn(unit semantics.Unit, quantity bool, what string) (float64, error) { + if s.quantity != quantity { + return 0, fmt.Errorf("%w: %s and the first endpoint do not both carry a unit", ErrSweepRange, what) + } + if !quantity { + return finiteMagnitude(s.num.AsReal(), what) + } + q := Quantity{Num: s.num, Unit: s.unit} + m, err := q.ConvertTo(unit) + if err != nil { + return 0, fmt.Errorf("%w: %s is expressed in %s, not in %s", ErrSweepRange, what, s.unit, unit) + } + return finiteMagnitude(m, what) +} + +// finiteMagnitude refuses a magnitude no sequence of values runs between. +func finiteMagnitude(m float64, what string) (float64, error) { + if math.IsNaN(m) || math.IsInf(m, 0) { + return 0, fmt.Errorf("%w: %s is not a finite number", ErrSweepRange, what) + } + return m, nil +} + +// endpoints reads a range's endpoints in the unit its first one carries and in +// the swept parameter's type, each admitted as an argument would be. +func (r SweepRange) endpoints(ctx *Context) (sweepBounds, error) { + fromWhat, toWhat := "range start "+FormatValue(r.From), "range end "+FormatValue(r.To) + from, err := sweepScalarOf(r.From, fromWhat) + if err != nil { + return sweepBounds{}, err + } + to, err := sweepScalarOf(r.To, toWhat) + if err != nil { + return sweepBounds{}, err + } + bounds := sweepBounds{unit: from.unit, quantity: from.quantity} + if bounds.from, err = finiteMagnitude(from.num.AsReal(), fromWhat); err != nil { + return sweepBounds{}, err + } + if bounds.to, err = to.magnitudeIn(from.unit, from.quantity, toWhat); err != nil { + return sweepBounds{}, err + } + fromInt, fromWhole := r.Type.integer(from, bounds.unit, bounds.from) + toInt, toWhole := r.Type.integer(to, bounds.unit, bounds.to) + bounds.isInt = fromWhole && toWhole + bounds.intFrom, bounds.intTo = fromInt, toInt + bounds.whole = bounds.from == math.Trunc(bounds.from) && bounds.to == math.Trunc(bounds.to) + if r.Type.Numbers == SweepIntegers { + if !fromWhole { + return sweepBounds{}, r.Type.notAnInteger(r.Param, fromWhat) + } + if !toWhole { + return sweepBounds{}, r.Type.notAnInteger(r.Param, toWhat) + } + } + if !bounds.isInt { + if err := r.exactRealEndpoints(); err != nil { + return sweepBounds{}, err + } + } + if err := r.Type.admit(ctx, r.Param, fromWhat, bounds.value(bounds.from, bounds.intFrom)); err != nil { + return sweepBounds{}, err + } + if err := r.Type.admit(ctx, r.Param, toWhat, bounds.value(bounds.to, bounds.intTo)); err != nil { + return sweepBounds{}, err + } + return bounds, nil +} + +// bounds reads a range a sweep steps through: its endpoints and its step, which +// only a range between whole numbers may leave unstated, stepping by one. +func (r SweepRange) bounds(ctx *Context) (sweepBounds, error) { + bounds, err := r.endpoints(ctx) + if err != nil { + return sweepBounds{}, err + } + if !r.HasStep { + if !bounds.whole { + return sweepBounds{}, fmt.Errorf( + "%w: %s=%s..%s states no step; only a range between whole numbers steps by one, one with a fractional endpoint needs `:`", + ErrSweepRange, r.Param, FormatValue(r.From), FormatValue(r.To), + ) + } + bounds.step, bounds.intStep = 1, 1 + if bounds.descends() { + bounds.step, bounds.intStep = -1, -1 + } + return bounds, nil + } + stepWhat := "step " + FormatValue(r.Step) + step, err := sweepScalarOf(r.Step, stepWhat) + if err != nil { + return sweepBounds{}, err + } + if bounds.step, err = step.magnitudeIn(bounds.unit, bounds.quantity, stepWhat); err != nil { + return sweepBounds{}, err + } + stepInt, stepWhole := r.Type.integer(step, bounds.unit, bounds.step) + if r.Type.Numbers == SweepIntegers && !stepWhole { + return sweepBounds{}, r.Type.notAnInteger(r.Param, stepWhat) + } + if !stepWhole { + if bounds.isInt { + if err := r.exactRealEndpoints(); err != nil { + return sweepBounds{}, err + } + } + if err := r.Type.exactReal(r.Param, stepWhat, r.Step); err != nil { + return sweepBounds{}, err + } + } + bounds.isInt = bounds.isInt && stepWhole + bounds.intStep = stepInt + if bounds.step == 0 { + return sweepBounds{}, fmt.Errorf("%w: %s steps by zero, which never reaches %s", + ErrSweepRange, r.Param, FormatValue(r.To)) + } + if bounds.stepsAway() { + return sweepBounds{}, fmt.Errorf("%w: %s steps by %s away from %s", + ErrSweepRange, r.Param, FormatValue(r.Step), FormatValue(r.To)) + } + return bounds, nil +} + +// descends reports a range whose end lies below its start. +func (b sweepBounds) descends() bool { + if b.isInt { + return b.intTo < b.intFrom + } + return b.to < b.from +} + +// stepsAway reports whether the step leads away from the range's end. +func (b sweepBounds) stepsAway() bool { + if b.isInt { + return b.intTo != b.intFrom && (b.intTo > b.intFrom) != (b.intStep > 0) + } + return b.to != b.from && (b.to-b.from)*b.step < 0 +} + +// integer is the scalar as an Integer in the range's unit: any whole number for +// a parameter taking Integers, only one written as an Integer where read as written. +func (t SweepType) integer(s sweepScalar, unit semantics.Unit, magnitude float64) (int64, bool) { + switch t.Numbers { + case SweepReals: + return 0, false + case SweepIntegers: + if n, ok := s.exactInt(unit, magnitude); ok { + return n, true + } + return (semantics.Value{Kind: semantics.ValReal, Real: magnitude}).WholeNumber() + } + return s.exactInt(unit, magnitude) +} + +// notAnInteger refuses an endpoint or step of an Integer-typed parameter that +// is not whole. +func (t SweepType) notAnInteger(param, what string) error { + return fmt.Errorf("%w: %s is not an Integer, which %s : %s takes", + ErrSweepRange, what, param, symbolText(t.Declared())) +} + +// exactReal refuses an endpoint or step written as an Integer no Real holds +// exactly, where the range is read as reals. +func (t SweepType) exactReal(param, what string, value Value) error { + n, ok := sweepInteger(value) + if !ok || realHolds(n) { + return nil + } + reads := "the range is read as reals" + if t.Numbers == SweepReals { + reads = param + " : " + symbolText(t.Declared()) + " takes Reals" + } + return fmt.Errorf("%w: %s is an Integer beyond what a Real holds exactly, and %s", + ErrSweepRange, what, reads) +} + +// exactRealEndpoints refuses either endpoint an Integer no Real holds exactly. +func (r SweepRange) exactRealEndpoints() error { + if err := r.Type.exactReal(r.Param, "range start "+FormatValue(r.From), r.From); err != nil { + return err + } + return r.Type.exactReal(r.Param, "range end "+FormatValue(r.To), r.To) +} + +// sweepInteger is the Integer an endpoint or step was written as, a quantity's +// magnitude included. +func sweepInteger(value Value) (int64, bool) { + switch value.Kind { + case ValConst: + return value.Const.Int, value.Const.Kind == semantics.ValInt + case ValQuantity: + if q := value.Quantity(); q != nil && q.Num.Kind == semantics.ValInt { + return q.Num.Int, true + } + } + return 0, false +} + +// realHolds reports whether a Real holds the Integer without rounding it. +func realHolds(n int64) bool { + f := float64(n) + return f >= math.MinInt64 && f < -math.MinInt64 && int64(f) == n +} + +// admit refuses an endpoint the parameter would refuse as an argument, before +// any row runs. +func (t SweepType) admit(ctx *Context, param, what string, value Value) error { + if t.Positive { + if n, ok := Magnitude(value); ok && n <= 0 { + return fmt.Errorf("%w: %s is not Positive, which %s : %s takes", + ErrSweepRange, what, param, symbolText(t.Declared())) + } + } + if err := t.decl.check(ctx, &value, func() string { return what + " of " + param }); err != nil { + return fmt.Errorf("%w: %w", ErrSweepRange, err) + } + return t.admitMagnitude(ctx, param, what, value) +} + +// admitMagnitude refuses a quantity whose magnitude the quantity type's `num` excludes +// (classifyValue), which its dimension alone does not judge. +func (t SweepType) admitMagnitude(ctx *Context, param, what string, value Value) error { + q := value.Quantity() + if t.num == nil || q == nil { + return nil + } + magnitude := constValue(q.Num) + prim := ctx.model.semantics.PrimTypeOf(t.num) + refusal := fmt.Errorf("%w: %s is %s, which num : %s of %s : %s cannot hold", + ErrSweepRange, what, describeValue(magnitude), ctx.numTypeText(t.num, prim), param, symbolText(t.Declared())) + if !prim.IsNumeric() { + return refusal + } + for _, typ := range ctx.model.semantics.FeatureTypes(t.num) { + verdict, err := ctx.classifyValue(DeclScope(t.decl.Owner), magnitude, typ, nil, byAnyType) + if err != nil { + return fmt.Errorf("%w: %w", ErrSweepRange, err) + } + if verdict == semantics.ClassifiesNone { + return refusal + } + } + return nil +} + +// numTypeText names a quantity's `num` type as a refusal reads it. +func (ctx *Context) numTypeText(num *symbols.Symbol, prim semantics.PrimType) string { + if prim != semantics.PrimUnknown { + return prim.String() + } + if types := ctx.model.semantics.FeatureTypes(num); len(types) > 0 { + return symbolText(types[0]) + } + return unknownText +} + +// Magnitude is the number a value is, a quantity's magnitude included, and whether it is one. +func Magnitude(value Value) (float64, bool) { + n, ok := MagnitudeValue(value) + return n.AsReal(), ok +} + +// MagnitudeValue is the number a value is as written, Integer or Real, a +// quantity's magnitude included, and whether it is one. +func MagnitudeValue(value Value) (semantics.Value, bool) { + switch value.Kind { + case ValConst: + return value.Const, value.Const.IsNumeric() + case ValQuantity: + if q := value.Quantity(); q != nil && q.Num.IsNumeric() { + return q.Num, true + } + } + return semantics.Value{}, false +} + +// exactInt is the scalar as the Integer it was written as, expressed in the +// range's unit, where that Integer is exact. +func (s sweepScalar) exactInt(unit semantics.Unit, magnitude float64) (int64, bool) { + if s.num.Kind != semantics.ValInt { + return 0, false + } + if !s.quantity { + return s.num.Int, true + } + if mul, div, ok := scaleRatio(s.unit.Term.Scale, unit.Term.Scale); ok { + return exactScaled(s.num.Int, mul, div) + } + if magnitude != math.Trunc(magnitude) || math.Abs(magnitude) > exactFloatInt { + return 0, false + } + return int64(magnitude), true +} + +// exactScaleFactor bounds a scale factor's parts, so that the ratio between two +// of them is a product float64 holds exactly. +const exactScaleFactor = 1 << 26 + +// scaleRatio expresses a magnitude given over from in to, as the whole ratio +// mul/div where both scale factors are whole numbers of that size. +func scaleRatio(from, to semantics.Scale) (mul, div int64, ok bool) { + for _, part := range [...]float64{from.Num, from.Den, to.Num, to.Den} { + if part != math.Trunc(part) || part <= 0 || part > exactScaleFactor { + return 0, 0, false + } + } + return int64(from.Num * to.Den), int64(from.Den * to.Num), true +} + +// exactScaled is n scaled by mul/div, where that leaves an Integer exactly: +// the product must fit and the division must come out even. +func exactScaled(n, mul, div int64) (int64, bool) { + magnitude := unsignedInt(n) + if n < 0 { + magnitude = -magnitude + } + hi, lo := bits.Mul64(magnitude, unsignedInt(mul)) + if hi != 0 || lo%unsignedInt(div) != 0 { + return 0, false + } + scaled := lo / unsignedInt(div) + if n < 0 { + if scaled > 1<<63 { + return 0, false + } + return signedInt(-scaled), true + } + if scaled > math.MaxInt64 { + return 0, false + } + return signedInt(scaled), true +} + +// enumerate is every value of a swept range, from its start towards its end, +// including the end where a step lands on it. Values are computed from the +// start rather than accumulated, so a real step does not drift. +func (r SweepRange) enumerate(ctx *Context, limit int64) ([]Value, error) { + bounds, err := r.bounds(ctx) + if err != nil { + return nil, err + } + count := bounds.count() + if count > unsignedInt(limit) { + return nil, fmt.Errorf("%w: %s=%s..%s takes %d run(s), at most %d allowed%s", + ErrSweepBudget, r.Param, FormatValue(r.From), FormatValue(r.To), count, limit, ctx.raiseSweepRuns(limit)) + } + values := make([]Value, 0, count) + for i := int64(0); i < signedInt(count); i++ { + values = append(values, bounds.at(i)) + } + if !bounds.isInt { + for i := 1; i < len(values); i++ { + at, _ := Magnitude(values[i]) + if before, _ := Magnitude(values[i-1]); at == before { + return nil, fmt.Errorf("%w: %s steps by %s, finer than a Real tells apart near %s, so its rows would repeat", + ErrSweepRange, r.Param, FormatValue(bounds.valueReal(bounds.step)), FormatValue(values[i])) + } + } + } + return values, nil +} + +// count is how many values the range takes, its end included where a step +// lands on it. An Integer range counts exactly; a real one counts within a +// rounding error, since 0.0..1.0:0.1 has eleven values and binary reals do not. +func (b sweepBounds) count() uint64 { + if b.isInt { + span := unsignedInt(b.intTo) - unsignedInt(b.intFrom) + if b.intStep < 0 { + span = unsignedInt(b.intFrom) - unsignedInt(b.intTo) + } + if span == math.MaxUint64 && b.intStepMagnitude() == 1 { + return math.MaxUint64 + } + return span/b.intStepMagnitude() + 1 + } + steps := math.Floor((b.to - b.from) / b.step) + if steps >= math.MaxInt64 { + return math.MaxInt64 + } + if steps <= 0 { + return 1 + } + // The quotient rounds either way, so the value the last step reaches decides. + n := uint64(steps) + switch { + case b.within(n + 1): + n++ + case !b.within(n): + n-- + } + return n + 1 +} + +// within reports whether the value the given number of steps from the start +// stays within the range's end, allowing a step landing on it to drift there. +func (b sweepBounds) within(steps uint64) bool { + value := b.from + float64(steps)*b.step + if math.IsInf(value, 0) { + return false + } + // The drift is compared as the distance it is, which no endpoint overflows. + slack := math.Min(1e-12*math.Max(math.Abs(b.to), math.Abs(value)), math.Abs(b.step)/2) + if b.step > 0 { + return value-b.to <= slack + } + return b.to-value <= slack +} + +// at is the range's value the given number of steps from its start. An Integer +// range steps in unsigned arithmetic, which reaches its endpoints exactly. +func (b sweepBounds) at(i int64) Value { + if b.isInt { + offset := unsignedInt(i) * b.intStepMagnitude() + if b.intStep < 0 { + return b.valueInt(signedInt(unsignedInt(b.intFrom) - offset)) + } + return b.valueInt(signedInt(unsignedInt(b.intFrom) + offset)) + } + return b.valueReal(b.from + float64(i)*b.step) +} + +// intStepMagnitude is how far one Integer step reaches, which the widest step +// only states unsigned. +func (b sweepBounds) intStepMagnitude() uint64 { + if b.intStep < 0 { + return -unsignedInt(b.intStep) + } + return unsignedInt(b.intStep) +} + +// unsignedInt and signedInt are the two views of one Integer. A sweep counts and +// steps unsigned, so a range as wide as Integer arithmetic stays exact. +func unsignedInt(n int64) uint64 { + // #nosec G115 -- the two's-complement image is the value meant, not an overflow. + return uint64(n) +} + +func signedInt(n uint64) int64 { + // #nosec G115 -- the two's-complement image is the value meant, not an overflow. + return int64(n) +} + +// draw is one uniform value of a sampled range: an Integer range draws over its +// endpoints inclusively, a real one over [from, to). +func (b sweepBounds) draw(source *rand.Rand) Value { + if b.isInt { + lo, hi := b.intFrom, b.intTo + if hi < lo { + lo, hi = hi, lo + } + return b.valueInt(signedInt(unsignedInt(lo) + drawOffset(source, unsignedInt(hi)-unsignedInt(lo)))) + } + lo, hi := b.from, b.to + if hi < lo { + lo, hi = hi, lo + } + u := source.Float64() + var drawn float64 + if width := hi - lo; !math.IsInf(width, 0) { + drawn = lo + u*width + } else { + // A range whose width overflows is drawn from as the two endpoints + // weighted, which stays between them however wide they are. + drawn = lo*(1-u) + hi*u + } + // Either arithmetic can round up to the end, which is not drawn. + if drawn >= hi { + drawn = math.Nextafter(hi, lo) + } + return b.valueReal(drawn) +} + +// drawOffset is a uniform offset from zero to width inclusive, drawn as an +// Int64N while the width leaves room for it so a wider range costs nothing. +func drawOffset(source *rand.Rand, width uint64) uint64 { + switch { + case width < math.MaxInt64: + return unsignedInt(source.Int64N(signedInt(width) + 1)) + case width == math.MaxUint64: + return source.Uint64() + default: + return source.Uint64N(width + 1) + } +} + +// value is an endpoint as the range's kind of value: n where it takes Integers, +// the magnitude as a real otherwise. +func (b sweepBounds) value(magnitude float64, n int64) Value { + if b.isInt { + return b.valueInt(n) + } + return b.valueReal(magnitude) +} + +// valueReal is a real of the range, carrying its unit. +func (b sweepBounds) valueReal(magnitude float64) Value { + return b.carry(semantics.Value{Kind: semantics.ValReal, Real: magnitude}) +} + +// valueInt is an Integer of the range, carrying its unit. +func (b sweepBounds) valueInt(n int64) Value { + return b.carry(semantics.Value{Kind: semantics.ValInt, Int: n}) +} + +// carry is the number in the unit the range's first endpoint was expressed in. +func (b sweepBounds) carry(num semantics.Value) Value { + if !b.quantity { + return Value{Kind: ValConst, Const: num} + } + return NewQuantityValue(&Quantity{Num: num, Unit: b.unit.Clone()}) +} + +// InputParameterNames reports the input parameters a calc or analysis case +// declares, in the order an invocation's positional arguments bind them. +func (ctx *Context) InputParameterNames(sym *symbols.Symbol) ([]string, error) { + shape, err := ctx.calcShapeOf(sym) + if err != nil { + return nil, err + } + return shape.parameterNames(), nil +} + +// ResolveSweepPlan refuses a range over a parameter the target does not declare, +// its subject or one the invocation binds, and types each range by its parameter. +func (ctx *Context) ResolveSweepPlan(sym *symbols.Symbol, plan SweepPlan, positional int, named []string) (SweepPlan, error) { + shape, err := ctx.calcShapeOf(sym) + if err != nil { + return SweepPlan{}, err + } + declared := shape.parameterNames() + bound := make(map[string]bool, len(named)+positional) + for _, name := range named { + bound[name] = true + } + for i, name := range shape.positionalOrder(IsRunnableCaseSymbol(sym), bound) { + if i >= positional { + break + } + bound[name] = true + } + resolved := plan + resolved.Ranges = make([]SweepRange, len(plan.Ranges)) + for i, r := range plan.Ranges { + param := shape.parameterNamed(r.Param) + if param == nil { + return SweepPlan{}, fmt.Errorf("%w: %s declares no input parameter %q (it declares %v)", + ErrSweepParameter, ctx.qualifiedSymbolName(sym), r.Param, declared) + } + if param.IsSubject { + return SweepPlan{}, fmt.Errorf("%w: %s is the subject of %s, which an object binds, not a range", + ErrSweepParameter, r.Param, ctx.qualifiedSymbolName(sym)) + } + if bound[r.Param] { + return SweepPlan{}, fmt.Errorf("%w: %s is both an argument of the invocation and swept", + ErrSweepParameter, r.Param) + } + r.Type = ctx.sweepTypeOf(param.Decl) + resolved.Ranges[i] = r + } + return resolved, nil +} + +// sweepTypeOf types a range by its parameter: a scalar, or a quantity through its +// number, takes Integers or reals; any other type, and none, reads it as written. +func (ctx *Context) sweepTypeOf(decl calcMemberDecl) SweepType { + t := SweepType{decl: decl} + typ := t.Declared() + if typ == nil { + t.Untyped = true + return t + } + prim := ctx.model.semantics.PrimTypeOf(typ) + if prim == semantics.PrimUnknown { + num, ok := ctx.quantityNumber(typ) + if !ok { + return t + } + typ, prim = num, ctx.model.semantics.PrimTypeOf(num) + t.num = num + } + t.Numbers = sweepNumbersOf(prim) + t.Positive = ctx.positiveScalar(typ) + return t +} + +// sweepNumbersOf is how a scalar lattice element counts: Naturals and Integers +// by Integers, Rationals and Reals by reals, anything else as written. +func sweepNumbersOf(prim semantics.PrimType) SweepNumbers { + switch prim { + case semantics.PrimNatural, semantics.PrimInteger: + return SweepIntegers + case semantics.PrimRational, semantics.PrimReal: + return SweepReals + } + return SweepAsWritten +} + +// positiveScalar reports a type that is, or specializes, ScalarValues::Positive. +func (ctx *Context) positiveScalar(typ *symbols.Symbol) bool { + positive := ctx.librarySymbol("ScalarValues::Positive") + return positive != nil && ctx.modelConforms(typ, positive) +} + +// quantityNumber is the feature holding a scalar quantity type's magnitude, the +// `num` a quantity value's number is bound to; false for any other type. +func (ctx *Context) quantityNumber(typ *symbols.Symbol) (*symbols.Symbol, bool) { + scalar := ctx.librarySymbol(scalarQuantityTypeFQN) + if scalar == nil || !ctx.modelConforms(typ, scalar) { + return nil, false + } + num, ok := ctx.model.semantics.LookupMember(typ, vectorQuantityNumFeature) + if !ok || num == nil { + return nil, false + } + return num, true +} + +// parameterNamed is the target's parameter of that name, nil for none. +func (shape *calcShape) parameterNamed(name string) *calcParameter { + for i := range shape.Params { + if shape.Params[i].Name == name { + return &shape.Params[i] + } + } + return nil +} + +// positionalOrder is the parameters the invocation's positional arguments bind, +// in order: a case skips its subject and the parameters bound by name, as an +// analysis run binds them, while a calc binds every parameter by position. +func (shape *calcShape) positionalOrder(analysis bool, named map[string]bool) []string { + names := make([]string, 0, len(shape.Params)) + for i := range shape.Params { + param := &shape.Params[i] + if analysis && (param.IsSubject || named[param.Name]) { + continue + } + names = append(names, param.Name) + } + return names +} + +// parameterNames is the parameters the target declares, in declaration order. +func (shape *calcShape) parameterNames() []string { + names := make([]string, 0, len(shape.Params)) + for i := range shape.Params { + names = append(names, shape.Params[i].Name) + } + return names +} diff --git a/internal/core/runtime/sweep_queue.go b/internal/exec/runtime/sweep_queue.go similarity index 100% rename from internal/core/runtime/sweep_queue.go rename to internal/exec/runtime/sweep_queue.go diff --git a/internal/core/runtime/sweep_queue_test.go b/internal/exec/runtime/sweep_queue_test.go similarity index 97% rename from internal/core/runtime/sweep_queue_test.go rename to internal/exec/runtime/sweep_queue_test.go index 1644c67dce..cbe618a584 100644 --- a/internal/core/runtime/sweep_queue_test.go +++ b/internal/exec/runtime/sweep_queue_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // sweepJobs is the job count the sweep determinism tests run beside one job. @@ -50,7 +50,7 @@ func sweepJobsFixture(t *testing.T) (func(job int) (*Context, error), *symbols.S idx.ExpandWildcardImports() fresh := func(int) (*Context, error) { resolver := resolve.New(idx) - return NewContext(NewModel(semantics.NewModel(resolver), resolver), 50_000_000), nil + return NewContext(typedModel(semantics.NewModel(resolver), resolver), 50_000_000), nil } pkg, ok := idx.DocumentRoot("").LookupLocal("test") if !ok || pkg.Scope == nil { diff --git a/internal/exec/runtime/sweep_test.go b/internal/exec/runtime/sweep_test.go new file mode 100644 index 0000000000..4ec9c9da42 --- /dev/null +++ b/internal/exec/runtime/sweep_test.go @@ -0,0 +1,1484 @@ +package runtime + +import ( + "context" + "errors" + "math" + "strings" + "testing" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// sweepModel is the model the sweep tests run over: calcs whose values follow +// straight from their inputs, one of which fails on a particular input, and +// one parameter of each type a range may or may not be swept over. +const sweepModel = ` + package test { + private import ScalarValues::*; + private import Quantities::ScalarQuantityValue; + private import ISQ::LengthValue; + calc def Twice { + in n : Integer; + return : Integer = n * 2; + } + calc def Plus { + in a : Integer; + in b : Integer; + return : Integer = a + b; + } + calc def Ratio { + in a : Real; + in b : Real; + return : Real = a / b; + } + calc def Count { in n : Natural; return : Natural = n; } + calc def Rank { in n : Positive; return : Positive = n; } + calc def Frac { in q : Rational; return : Rational = q; } + calc def Any { in v; return r = v; } + calc def Num { in n : Number; return : Number = n; } + calc def Flag { in b : Boolean; return : Boolean = b; } + calc def Label { in s : String; return : String = s; } + enum def Colour { red; green; } + calc def Paint { in c : Colour; return : Colour = c; } + attribute def Mass :> Real; + attribute def Tally :> Integer; + calc def Weigh { in m : Mass; return : Mass = m; } + calc def Score { in t : Tally; return : Tally = t; } + calc def Stretch { in l : LengthValue; return : LengthValue = l; } + attribute def CountValue :> ScalarQuantityValue { attribute :>> num : Integer; } + calc def Heap { in n : CountValue; return : CountValue = n; } + attribute def HeadcountValue :> ScalarQuantityValue { attribute :>> num : Natural; } + calc def Crew { in n : HeadcountValue; return : HeadcountValue = n; } + attribute def RankValue :> ScalarQuantityValue { attribute :>> num : Positive; } + calc def Grade { in r : RankValue; return : RankValue = r; } + attribute def TagValue :> ScalarQuantityValue { attribute :>> num : String; } + calc def Tag { in t : TagValue; return : TagValue = t; } + calc def Doubled :> Twice; + calc def Exact :> Ratio { in :>> a : Integer; } + part def Ship { attribute hullMass : Real; } + part ship : Ship { attribute :>> hullMass = 1000.0; } + calc def Hull { in s : Ship; return : Real = s.hullMass; } + analysis def Margin { + subject s : Ship; + in load : Real; + in factor : Real; + return : Real = (s.hullMass + load) * factor; + } + } +` + +// sweepFixture builds a runtime over the sweep model and returns the package +// scope its calcs are declared in. +func sweepFixture(t *testing.T) (*Context, *symbols.Scope) { + t.Helper() + return analysisFixture(t, sweepModel) +} + +// calcNamed is the calc definition of that name in the scope. +func calcNamed(t *testing.T, scope *symbols.Scope, name string) *symbols.Symbol { + t.Helper() + sym, ok := scope.LookupLocal(name) + if !ok { + t.Fatalf("calc %s not indexed", name) + } + return sym +} + +// intOf and realOf are endpoints as an argument carries them. +func intOf(n int64) Value { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: n}} +} + +func realOf(f float64) Value { + return Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: f}} +} + +// rangeOf is a range stating no step; steppedRange states one. +func rangeOf(param string, from, to Value) SweepRange { + return SweepRange{Param: param, From: from, To: to} +} + +func steppedRange(param string, from, to, step Value) SweepRange { + return SweepRange{Param: param, From: from, To: to, Step: step, HasStep: true} +} + +// sweepCalcRun makes one ordinary calc run per row, in the row's context, with the +// swept parameters bound. +func sweepCalcRun(sym *symbols.Symbol, scope *symbols.Scope) SweepRun { + return func(ctx *Context, bindings []SweepBinding) (SweepRunResult, error) { + bound := make(map[string]Value, len(bindings)) + for _, b := range bindings { + bound[b.Param] = b.Value + } + value, err := ctx.InvokeCalcWith(sym, nil, bound, scope) + if err != nil { + return SweepRunResult{}, err + } + return SweepRunResult{Outputs: []CalcOutputValue{{Name: "result", Value: value}}}, nil + } +} + +// sweepIn is the one-job sweep with every row in ctx, as a test of a plan's rows takes it. +func sweepIn(ctx *Context, stop context.Context, target string, plan SweepPlan, runs int64, run SweepRun) (SweepTable, error) { + return RunSweepWith(stop, SweepWorkers{First: ctx, Jobs: 1, Fresh: func(int) (*Context, error) { return ctx, nil }}, target, plan, runs, run) +} + +// resolvedPlan is the plan as the named calc's parameters type it, failing the +// test when a parameter was refused. +func resolvedPlan(t *testing.T, ctx *Context, scope *symbols.Scope, name string, plan SweepPlan) SweepPlan { + t.Helper() + resolved, err := ctx.ResolveSweepPlan(calcNamed(t, scope, name), plan, 0, nil) + if err != nil { + t.Fatalf("resolving the sweep of %s: %v", name, err) + } + return resolved +} + +// runSweepOver sweeps the named calc over the plan, failing the test when the +// plan itself was refused. +func runSweepOver(t *testing.T, ctx *Context, scope *symbols.Scope, name string, plan SweepPlan) SweepTable { + t.Helper() + sym := calcNamed(t, scope, name) + plan = resolvedPlan(t, ctx, scope, name, plan) + table, err := sweepIn(ctx, context.Background(), "test::"+name, plan, 0, sweepCalcRun(sym, scope)) + if err != nil { + t.Fatalf("sweep of %s: %v", name, err) + } + return table +} + +// refuseSweep sweeps and reports the refusal, failing the test when the plan +// ran anyway or any row was run before it was refused. +func refuseSweep(t *testing.T, ctx *Context, scope *symbols.Scope, name string, plan SweepPlan) error { + t.Helper() + sym := calcNamed(t, scope, name) + plan, err := ctx.ResolveSweepPlan(sym, plan, 0, nil) + if err != nil { + return err + } + runs := 0 + run := sweepCalcRun(sym, scope) + table, err := sweepIn(ctx, context.Background(), "test::"+name, plan, 0, func(ctx *Context, bindings []SweepBinding) (SweepRunResult, error) { + runs++ + return run(ctx, bindings) + }) + if err == nil { + t.Fatalf("sweep of %s ran %d row(s); want a refusal", name, len(table.Rows)) + } + if len(table.Rows) != 0 || runs != 0 { + t.Fatalf("refused sweep reported %d row(s) and made %d run(s); want none", len(table.Rows), runs) + } + return err +} + +// refusedNaming checks a plan is refused as an ErrSweepRange whose message +// names each of the given words. +func refusedNaming(t *testing.T, err error, words ...string) { + t.Helper() + if !errors.Is(err, ErrSweepRange) { + t.Fatalf("err = %v; want an ErrSweepRange", err) + } + for _, word := range words { + if !strings.Contains(err.Error(), word) { + t.Errorf("err = %v; want it to name %q", err, word) + } + } +} + +// tableText renders a table as one line per row — its inputs, its outputs and +// its failure — so one comparison covers the rows, their order and their +// values. Times are left out, since they differ from run to run. +func tableText(table SweepTable) string { + lines := make([]string, 0, len(table.Rows)) + for _, row := range table.Rows { + parts := make([]string, 0, len(row.Bindings)+len(row.Outputs)+1) + for _, b := range row.Bindings { + parts = append(parts, b.Param+"="+FormatValue(b.Value)) + } + for _, out := range row.Outputs { + parts = append(parts, out.Name+" -> "+FormatValue(out.Value)) + } + if row.Err != nil { + parts = append(parts, "error: "+strings.Join(strings.Fields(row.Err.Error()), " ")) + } + lines = append(lines, strings.Join(parts, " ")) + } + return strings.Join(lines, "\n") +} + +// inputsOf lists what each row bound, which is what a sampled table is judged +// on. +func inputsOf(table SweepTable) []string { + values := make([]string, 0, len(table.Rows)) + for _, row := range table.Rows { + parts := make([]string, 0, len(row.Bindings)) + for _, b := range row.Bindings { + parts = append(parts, b.Param+"="+FormatValue(b.Value)) + } + values = append(values, strings.Join(parts, " ")) + } + return values +} + +// A range between Integers stating no step advances by one, from its start +// through its end, and each row is one ordinary run of the calc. +func TestSweepIntegerRangeStepsByOne(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(1), intOf(4))}, + }) + got := tableText(table) + want := strings.Join([]string{ + "n=1 result -> 2", + "n=2 result -> 4", + "n=3 result -> 6", + "n=4 result -> 8", + }, "\n") + if got != want { + t.Errorf("table is\n%s\nwant\n%s", got, want) + } + if table.Params[0] != "n" || table.Sampled { + t.Errorf("table reports params %v, sampled %v; want [n], false", table.Params, table.Sampled) + } + for i, row := range table.Rows { + if row.Elapsed < 0 { + t.Errorf("row %d took %s", i, row.Elapsed) + } + } +} + +// A range whose end lies against the direction of its Integer default steps +// towards it rather than refusing. +func TestSweepIntegerRangeDescends(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(2), intOf(0))}, + }) + if got, want := inputsOf(table), []string{"n=2", "n=1", "n=0"}; !equalStrings(got, want) { + t.Errorf("rows bound %v; want %v", got, want) + } +} + +// A stated step advances by it, and the end is a row where the step lands on it. +func TestSweepStepIncludesEndpointItLandsOn(t *testing.T) { + ctx, scope := sweepFixture(t) + on := runSweepOver(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{steppedRange("n", intOf(0), intOf(6), intOf(3))}, + }) + if got, want := inputsOf(on), []string{"n=0", "n=3", "n=6"}; !equalStrings(got, want) { + t.Errorf("rows bound %v; want %v", got, want) + } + past := runSweepOver(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{steppedRange("n", intOf(0), intOf(7), intOf(3))}, + }) + if got, want := inputsOf(past), []string{"n=0", "n=3", "n=6"}; !equalStrings(got, want) { + t.Errorf("rows bound %v; want %v", got, want) + } +} + +// A real range steps by the step it states, and reaches its end rather than +// stopping a rounding error short of it. +func TestSweepRealRangeReachesItsEnd(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{ + steppedRange("a", realOf(0), realOf(1), realOf(0.1)), + rangeOf("b", intOf(1), intOf(1)), + }, + }) + if len(table.Rows) != 11 { + t.Fatalf("0.0..1.0:0.1 ran %d row(s); want 11", len(table.Rows)) + } + last := table.Rows[len(table.Rows)-1] + if got := FormatValue(last.Bindings[0].Value); got != "1.0" { + t.Errorf("last row bound a=%s; want 1.0", got) + } +} + +// Several ranges run their cartesian product, the first parameter given varying +// slowest, so the rows read in the order the ranges were written. +func TestSweepSeveralRangesRunTheirCartesianProduct(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Plus", SweepPlan{ + Ranges: []SweepRange{ + rangeOf("a", intOf(1), intOf(3)), + rangeOf("b", intOf(10), intOf(11)), + }, + }) + got := tableText(table) + want := strings.Join([]string{ + "a=1 b=10 result -> 11", + "a=1 b=11 result -> 12", + "a=2 b=10 result -> 12", + "a=2 b=11 result -> 13", + "a=3 b=10 result -> 13", + "a=3 b=11 result -> 14", + }, "\n") + if got != want { + t.Errorf("table is\n%s\nwant\n%s", got, want) + } +} + +// A run that failed is that row's typed error, and the runs after it are made +// all the same. +func TestSweepFailedRunIsARowAndTheTableGoesOn(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{ + rangeOf("a", intOf(4), intOf(4)), + rangeOf("b", intOf(-1), intOf(1)), + }, + }) + if len(table.Rows) != 3 { + t.Fatalf("table has %d row(s); want 3", len(table.Rows)) + } + failed := table.Rows[1] + if failed.Err == nil { + t.Fatalf("dividing by zero produced %s; want a failed row", tableText(table)) + } + if !errors.Is(failed.Err, semantics.ErrDivisionByZero) { + t.Errorf("row error = %v; want a division by zero", failed.Err) + } + if len(failed.Outputs) != 0 { + t.Errorf("failed row reported outputs %v; want none", failed.Outputs) + } + for _, i := range []int{0, 2} { + if table.Rows[i].Err != nil || len(table.Rows[i].Outputs) != 1 { + t.Errorf("row %d = %+v; want one output and no error", i, table.Rows[i]) + } + } +} + +// A range with a fractional endpoint states its step: no step is the obvious +// one there, so leaving it out is refused rather than guessed at. Between whole +// numbers the step is one, however the parameter types them. +func TestSweepFractionalRangeWithoutAStepIsRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + for _, r := range []SweepRange{ + rangeOf("a", realOf(0.5), realOf(1)), + rangeOf("a", realOf(0), realOf(1.5)), + } { + err := refuseSweep(t, ctx, scope, "Ratio", SweepPlan{Ranges: []SweepRange{r}}) + if !errors.Is(err, ErrSweepRange) || !strings.Contains(err.Error(), "step") { + t.Errorf("%s..%s: err = %v; want an ErrSweepRange naming the step", FormatValue(r.From), FormatValue(r.To), err) + } + } + for _, r := range []SweepRange{ + rangeOf("a", intOf(0), intOf(2)), + rangeOf("a", realOf(0), realOf(2)), + } { + table := runSweepOver(t, ctx, scope, "Ratio", SweepPlan{Ranges: []SweepRange{r, rangeOf("b", intOf(1), intOf(1))}}) + if got, want := inputsOf(table), []string{"a=0.0 b=1.0", "a=1.0 b=1.0", "a=2.0 b=1.0"}; !equalStrings(got, want) { + t.Errorf("%s..%s bound %v; want %v", FormatValue(r.From), FormatValue(r.To), got, want) + } + } +} + +// A step of zero never reaches the end of its range. +func TestSweepZeroStepIsRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{steppedRange("n", intOf(1), intOf(4), intOf(0))}, + }) + if !errors.Is(err, ErrSweepRange) { + t.Errorf("err = %v; want ErrSweepRange", err) + } +} + +// A step whose sign leads away from the end of its range never reaches it. +func TestSweepStepAwayFromTheEndIsRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{steppedRange("n", intOf(1), intOf(4), intOf(-1))}, + }) + if !errors.Is(err, ErrSweepRange) { + t.Errorf("err = %v; want ErrSweepRange", err) + } +} + +// An endpoint that is no number at all states no range. +func TestSweepNonNumericEndpointIsRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", + Value{Kind: ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: true}}, + intOf(4))}, + }) + if !errors.Is(err, ErrSweepRange) { + t.Errorf("err = %v; want ErrSweepRange", err) + } +} + +// A range between an endpoint carrying a unit and one that carries none +// measures two different things, and is refused. +func TestSweepEndpointsMustBothCarryAUnit(t *testing.T) { + ctx, scope := sweepFixture(t) + metre := NewQuantityValue(&Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: 0}, + Unit: Unit{Text: "SI::m"}, + }) + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{steppedRange("n", metre, realOf(10), realOf(2))}, + }) + if !errors.Is(err, ErrSweepRange) || !strings.Contains(err.Error(), "unit") { + t.Errorf("err = %v; want an ErrSweepRange naming the unit", err) + } +} + +// A sweep naming no range at all runs nothing. +func TestSweepWithoutARangeIsRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{}) + if !errors.Is(err, ErrSweepEmpty) { + t.Errorf("err = %v; want ErrSweepEmpty", err) + } +} + +// One parameter takes one range: sweeping it twice states two. +func TestSweepParameterSweptTwiceIsRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{ + rangeOf("n", intOf(1), intOf(2)), + rangeOf("n", intOf(3), intOf(4)), + }, + }) + if !errors.Is(err, ErrSweepParameter) { + t.Errorf("err = %v; want ErrSweepParameter", err) + } +} + +// A sweep asking for more runs than its budget allows is refused before any run +// is made, and says which variable raises the budget. +func TestSweepBudgetIsRefusedBeforeRunning(t *testing.T) { + ctx, scope := sweepFixture(t) + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(1), intOf(ctx.SweepRunBudget()+1))}, + }) + if !errors.Is(err, ErrSweepBudget) || !strings.Contains(err.Error(), MaxSweepRunsEnvVar) { + t.Errorf("err = %v; want an ErrSweepBudget naming %s", err, MaxSweepRunsEnvVar) + } +} + +// A run limit stated to RunSweep bounds the sweep in place of the context's, +// and the refusal does not name the variable, which would not raise it. +func TestSweepTakesTheStatedRunLimit(t *testing.T) { + ctx, scope := sweepFixture(t) + sym := calcNamed(t, scope, "Twice") + plan := resolvedPlan(t, ctx, scope, "Twice", SweepPlan{Ranges: []SweepRange{rangeOf("n", intOf(1), intOf(3))}}) + runs := 0 + counted := func(ctx *Context, bindings []SweepBinding) (SweepRunResult, error) { + runs++ + return sweepCalcRun(sym, scope)(ctx, bindings) + } + _, err := sweepIn(ctx, context.Background(), "test::Twice", plan, 2, counted) + if !errors.Is(err, ErrSweepBudget) || strings.Contains(err.Error(), MaxSweepRunsEnvVar) || runs != 0 { + t.Errorf("err = %v after %d run(s); want an ErrSweepBudget of 2 before any run, not naming %s", err, runs, MaxSweepRunsEnvVar) + } + table, err := sweepIn(ctx, context.Background(), "test::Twice", plan, 3, counted) + if err != nil || len(table.Rows) != 3 { + t.Fatalf("sweep within 3 runs: %v, %d row(s); want the 3 rows", err, len(table.Rows)) + } + sampled := resolvedPlan(t, ctx, scope, "Twice", SweepPlan{Ranges: []SweepRange{rangeOf("n", intOf(1), intOf(9))}, Sampled: true, Samples: 3, Seed: 1}) + if _, err := sweepIn(ctx, context.Background(), "test::Twice", sampled, 2, counted); !errors.Is(err, ErrSweepBudget) { + t.Errorf("3 samples within 2 runs: %v; want ErrSweepBudget", err) + } +} + +// The budget bounds the product of the ranges, not each range on its own. +func TestSweepBudgetBoundsTheProduct(t *testing.T) { + ctx, scope := sweepFixture(t) + half := ctx.SweepRunBudget()/2 + 1 + err := refuseSweep(t, ctx, scope, "Plus", SweepPlan{ + Ranges: []SweepRange{ + rangeOf("a", intOf(1), intOf(half)), + rangeOf("b", intOf(1), intOf(3)), + }, + }) + if !errors.Is(err, ErrSweepBudget) { + t.Errorf("err = %v; want ErrSweepBudget", err) + } +} + +// A parameter the target declares none of, and one the invocation already binds, +// are both refused before anything runs. +func TestResolveSweepPlanRefusesUnknownAndBoundParameters(t *testing.T) { + ctx, scope := sweepFixture(t) + sym := calcNamed(t, scope, "Plus") + plan := SweepPlan{Ranges: []SweepRange{rangeOf("a", intOf(1), intOf(2))}} + + if _, err := ctx.ResolveSweepPlan(sym, plan, 0, []string{"b"}); err != nil { + t.Fatalf("sweeping a while b is bound: %v", err) + } + _, err := ctx.ResolveSweepPlan(sym, SweepPlan{ + Ranges: []SweepRange{rangeOf("nope", intOf(1), intOf(2))}, + }, 0, nil) + if !errors.Is(err, ErrSweepParameter) || !strings.Contains(err.Error(), "nope") { + t.Errorf("err = %v; want an ErrSweepParameter naming nope", err) + } + if _, err := ctx.ResolveSweepPlan(sym, plan, 0, []string{"a"}); !errors.Is(err, ErrSweepParameter) { + t.Errorf("sweeping a parameter bound by name: err = %v; want ErrSweepParameter", err) + } + if _, err := ctx.ResolveSweepPlan(sym, plan, 1, nil); !errors.Is(err, ErrSweepParameter) { + t.Errorf("sweeping a parameter bound by position: err = %v; want ErrSweepParameter", err) + } +} + +// Resolving a plan types each range by the parameter it sweeps, and the table +// reports those types beside its parameters; a plan never resolved is untyped. +func TestResolveSweepPlanTypesEachRangeByItsParameter(t *testing.T) { + ctx, scope := sweepFixture(t) + plan := SweepPlan{Ranges: []SweepRange{ + rangeOf("a", intOf(1), intOf(2)), + rangeOf("b", intOf(1), intOf(2)), + }} + resolved := resolvedPlan(t, ctx, scope, "Exact", plan) + if got := resolved.Ranges[0].Type; got.Numbers != SweepIntegers || symbolText(got.Declared()) != "Integer" { + t.Errorf("a resolved as %+v; want Integers typed by Integer", got) + } + if got := resolved.Ranges[1].Type; got.Numbers != SweepReals || symbolText(got.Declared()) != "Real" { + t.Errorf("b resolved as %+v; want reals typed by Real", got) + } + if plan.Ranges[0].Type.Declared() != nil { + t.Error("resolving typed the plan given rather than the one returned") + } + table := runSweepOver(t, ctx, scope, "Exact", plan) + if len(table.Types) != 2 || table.Types[0].Numbers != SweepIntegers || table.Types[1].Numbers != SweepReals { + t.Errorf("table reports types %+v; want Integers then reals", table.Types) + } + if got, want := tableText(table), strings.Join([]string{ + "a=1 b=1.0 result -> 1.0", + "a=1 b=2.0 result -> 0.5", + "a=2 b=1.0 result -> 2.0", + "a=2 b=2.0 result -> 1.0", + }, "\n"); got != want { + t.Errorf("table is\n%s\nwant\n%s", got, want) + } +} + +// A sampled range draws one value per row per parameter, so the draws pair up +// into rows rather than multiplying into a product. +func TestSamplesDrawOneRowPerDraw(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Plus", SweepPlan{ + Ranges: []SweepRange{ + rangeOf("a", intOf(0), intOf(100)), + rangeOf("b", intOf(0), intOf(100)), + }, + Sampled: true, Samples: 5, Seed: 11, + }) + if len(table.Rows) != 5 { + t.Fatalf("5 samples ran %d row(s)", len(table.Rows)) + } + if !table.Sampled || table.Seed != 11 { + t.Errorf("table reports sampled %v, seed %d; want true, 11", table.Sampled, table.Seed) + } + for _, row := range table.Rows { + if len(row.Bindings) != 2 || row.Err != nil { + t.Errorf("row %+v; want both parameters bound and no error", row) + } + } +} + +// The same seed draws the same table, and a different seed draws a different +// one: the generator is seeded from the request alone. +func TestSamplesAreReproducibleFromTheirSeed(t *testing.T) { + ctx, scope := sweepFixture(t) + plan := func(seed uint64) SweepPlan { + return SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(0), intOf(1_000_000))}, + Sampled: true, Samples: 8, Seed: seed, + } + } + first := tableText(runSweepOver(t, ctx, scope, "Twice", plan(7))) + again := tableText(runSweepOver(t, ctx, scope, "Twice", plan(7))) + other := tableText(runSweepOver(t, ctx, scope, "Twice", plan(8))) + if first != again { + t.Errorf("seed 7 drew\n%s\nthen\n%s", first, again) + } + if first == other { + t.Errorf("seed 8 drew what seed 7 did:\n%s", first) + } +} + +// The values a seed draws are pinned, so the same table comes out on every +// platform and every build. +func TestSamplesDrawnValuesArePinned(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(0), intOf(999))}, + Sampled: true, Samples: 6, Seed: 42, + }) + got := strings.Join(inputsOf(table), " ") + want := "n=454 n=972 n=719 n=345 n=838 n=944" + if got != want { + t.Errorf("seed 42 drew %s; want %s", got, want) + } + // The same range over a Real parameter draws reals over [0, 999), not the + // Integers its endpoints are written as. + table = runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{rangeOf("a", intOf(0), intOf(999)), rangeOf("b", intOf(1), intOf(1))}, + Sampled: true, Samples: 6, Seed: 42, + }) + got = strings.Join(inputsOf(table), " ") + want = "a=824.6470344910468 b=1.0 a=775.2969766622421 b=1.0 a=978.9358272925347 b=1.0 " + + "a=574.0664359686494 b=1.0 a=903.0937975136397 b=1.0 a=422.5590152588013 b=1.0" + if got != want { + t.Errorf("seed 42 drew %s; want %s", got, want) + } +} + +// An Integer parameter takes Integers however its range is written: whole +// endpoints and step written as reals are the Integers they are, and the calc +// runs on Integers. +func TestSweepIntegerParameterTakesIntegersHoweverTheRangeIsWritten(t *testing.T) { + ctx, scope := sweepFixture(t) + for _, name := range []string{"Twice", "Doubled"} { + table := runSweepOver(t, ctx, scope, name, SweepPlan{ + Ranges: []SweepRange{steppedRange("n", realOf(1), realOf(3), realOf(1))}, + }) + if got, want := tableText(table), "n=1 result -> 2\nn=2 result -> 4\nn=3 result -> 6"; got != want { + t.Errorf("%s over 1.0..3.0:1.0 is\n%s\nwant\n%s", name, got, want) + } + } + for name, param := range map[string]string{"Count": "n", "Rank": "n", "Score": "t"} { + table := runSweepOver(t, ctx, scope, name, SweepPlan{ + Ranges: []SweepRange{rangeOf(param, realOf(1), realOf(3))}, + }) + want := []string{param + "=1", param + "=2", param + "=3"} + if got := inputsOf(table); !equalStrings(got, want) { + t.Errorf("%s over 1.0..3.0 bound %v; want %v", name, got, want) + } + for _, row := range table.Rows { + if row.Err != nil || row.Outputs[0].Value.Kind != ValConst || row.Outputs[0].Value.Const.Kind != semantics.ValInt { + t.Errorf("%s row %+v; want an Integer result and no error", name, row) + } + } + } +} + +// A range over an Integer parameter is refused before any run when an endpoint +// or the step is not an Integer, naming the parameter and its type. +func TestSweepIntegerParameterRefusesFractionalEndpointsAndSteps(t *testing.T) { + ctx, scope := sweepFixture(t) + for _, tc := range []struct { + name, param, typ string + r SweepRange + }{ + {"Twice", "n", "Integer", steppedRange("n", realOf(1), realOf(3), realOf(0.5))}, + {"Twice", "n", "Integer", rangeOf("n", realOf(1.5), intOf(3))}, + {"Twice", "n", "Integer", rangeOf("n", intOf(1), realOf(3.5))}, + {"Doubled", "n", "Integer", steppedRange("n", intOf(1), intOf(3), realOf(0.5))}, + {"Count", "n", "Natural", rangeOf("n", realOf(0.5), intOf(3))}, + {"Rank", "n", "Positive", steppedRange("n", intOf(1), intOf(3), realOf(0.25))}, + {"Score", "t", "Tally", rangeOf("t", intOf(1), realOf(2.5))}, + {"Exact", "a", "Integer", steppedRange("a", intOf(1), intOf(2), realOf(0.5))}, + } { + plan := SweepPlan{Ranges: []SweepRange{tc.r}} + if tc.name == "Exact" { + plan.Ranges = append(plan.Ranges, rangeOf("b", intOf(1), intOf(1))) + } + refusedNaming(t, refuseSweep(t, ctx, scope, tc.name, plan), tc.param, tc.typ) + plan.Sampled, plan.Samples, plan.Seed = true, 3, 1 + plan.Ranges[0].Step, plan.Ranges[0].HasStep = Value{}, false + if tc.r.HasStep { + continue + } + refusedNaming(t, refuseSweep(t, ctx, scope, tc.name, plan), tc.param, tc.typ) + } +} + +// Natural and Positive parameters take only the Integers they can hold, so a +// range reaching below them is refused before any run. +func TestSweepNaturalAndPositiveParametersRefuseWhatTheyCannotHold(t *testing.T) { + ctx, scope := sweepFixture(t) + refusedNaming(t, refuseSweep(t, ctx, scope, "Count", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(-1), intOf(1))}, + }), "n", "Natural", "-1") + refusedNaming(t, refuseSweep(t, ctx, scope, "Rank", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(0), intOf(2))}, + }), "n", "Positive", "0") + table := runSweepOver(t, ctx, scope, "Rank", SweepPlan{Ranges: []SweepRange{rangeOf("n", intOf(1), intOf(2))}}) + if got, want := inputsOf(table), []string{"n=1", "n=2"}; !equalStrings(got, want) { + t.Errorf("Rank over 1..2 bound %v; want %v", got, want) + } + // The step is a difference between rows, not a value the parameter takes, so + // a Natural or Positive parameter is swept downwards by a negative step. + for _, name := range []string{"Count", "Rank"} { + table := runSweepOver(t, ctx, scope, name, SweepPlan{ + Ranges: []SweepRange{steppedRange("n", intOf(3), intOf(1), intOf(-1))}, + }) + if got, want := inputsOf(table), []string{"n=3", "n=2", "n=1"}; !equalStrings(got, want) { + t.Errorf("%s over 3..1:-1 bound %v; want %v", name, got, want) + } + for _, row := range table.Rows { + if row.Err != nil { + t.Errorf("%s row %+v failed", name, row) + } + } + } +} + +// A Real parameter takes reals however its range is written: Integer endpoints +// and step produce the reals 1.0, 2.0, …, and the table shows them as bound. +func TestSweepRealParameterTakesRealsHoweverTheRangeIsWritten(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{ + steppedRange("a", intOf(1), intOf(4), intOf(1)), + rangeOf("b", intOf(2), intOf(2)), + }, + }) + want := strings.Join([]string{ + "a=1.0 b=2.0 result -> 0.5", + "a=2.0 b=2.0 result -> 1.0", + "a=3.0 b=2.0 result -> 1.5", + "a=4.0 b=2.0 result -> 2.0", + }, "\n") + if got := tableText(table); got != want { + t.Errorf("Ratio over 1..4:1 is\n%s\nwant\n%s", got, want) + } + for name, param := range map[string]string{"Weigh": "m", "Frac": "q"} { + table := runSweepOver(t, ctx, scope, name, SweepPlan{ + Ranges: []SweepRange{steppedRange(param, intOf(1), intOf(2), realOf(0.5))}, + }) + want := []string{param + "=1.0", param + "=1.5", param + "=2.0"} + if got := inputsOf(table); !equalStrings(got, want) { + t.Errorf("%s over 1..2:0.5 bound %v; want %v", name, got, want) + } + for _, row := range table.Rows { + if row.Err != nil { + t.Errorf("%s row %+v failed", name, row) + } + } + } +} + +// A sampled range over an Integer parameter draws Integers inclusively however +// its endpoints are written; over a Real parameter it draws reals in [from, to) +// however they are written. +func TestSamplesDrawInTheParameterTypeNotTheLiteralType(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", realOf(0), realOf(1))}, + Sampled: true, Samples: 40, Seed: 3, + }) + seen := map[string]int{} + for _, row := range table.Rows { + seen[FormatValue(row.Bindings[0].Value)]++ + } + if len(seen) != 2 || seen["0"] == 0 || seen["1"] == 0 { + t.Errorf("Integer draws over 0.0..1.0 were %v; want both endpoints and nothing else", seen) + } + + table = runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{rangeOf("a", intOf(1), intOf(4)), rangeOf("b", intOf(1), intOf(1))}, + Sampled: true, Samples: 25, Seed: 7, + }) + reals := 0 + for _, row := range table.Rows { + a := row.Bindings[0].Value + if a.Kind != ValConst || a.Const.Kind != semantics.ValReal { + t.Errorf("drew a=%s, not a Real", FormatValue(a)) + } + if drawn := a.Const.AsReal(); drawn < 1 || drawn >= 4 { + t.Errorf("drew a=%v, which is outside [1.0, 4.0)", drawn) + } + if a.Const.AsReal() != math.Trunc(a.Const.AsReal()) { + reals++ + } + if row.Err != nil { + t.Errorf("row %+v failed", row) + } + } + if reals == 0 { + t.Error("25 draws over a Real parameter were all whole numbers") + } +} + +// A real draw stays below the range's end however narrow the range: over two +// adjacent reals only the lower is ever drawn. +func TestSamplesNeverDrawTheEndOfARealRange(t *testing.T) { + ctx, scope := sweepFixture(t) + lo, hi := 1.0, math.Nextafter(1.0, 2.0) + for _, seed := range []uint64{1, 2, 3} { + table := runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{rangeOf("a", realOf(lo), realOf(hi)), rangeOf("b", intOf(1), intOf(1))}, + Sampled: true, Samples: 32, Seed: seed, + }) + for _, row := range table.Rows { + if drawn := row.Bindings[0].Value.Const.AsReal(); drawn != lo { + t.Errorf("seed %d drew a=%v; want %v, the one real in [%v, %v)", seed, drawn, lo, lo, hi) + } + } + } +} + +// A parameter typed by neither a scalar nor a quantity — Boolean, String, an +// enumeration or a part — takes no numeric range: the plan is refused before +// any run, naming the parameter's type. +func TestSweepOverANonNumericParameterIsRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + for _, tc := range []struct{ name, param, typ string }{ + {"Flag", "b", "Boolean"}, + {"Label", "s", "String"}, + {"Paint", "c", "Colour"}, + {"Hull", "s", "Ship"}, + } { + plan := SweepPlan{Ranges: []SweepRange{rangeOf(tc.param, intOf(0), intOf(1))}} + refusedNaming(t, refuseSweep(t, ctx, scope, tc.name, plan), tc.param, tc.typ) + plan.Sampled, plan.Samples, plan.Seed = true, 2, 1 + refusedNaming(t, refuseSweep(t, ctx, scope, tc.name, plan), tc.param, tc.typ) + } +} + +// A parameter declaring no type takes the range as written: Integer endpoints +// step by Integers and real ones by reals, as the literal-driven rule reads them. +func TestSweepOverAnUntypedParameterFollowsTheLiterals(t *testing.T) { + ctx, scope := sweepFixture(t) + plan := resolvedPlan(t, ctx, scope, "Any", SweepPlan{Ranges: []SweepRange{rangeOf("v", intOf(1), intOf(3))}}) + if typ := plan.Ranges[0].Type; !typ.Untyped || typ.Numbers != SweepAsWritten || typ.Declared() != nil { + t.Errorf("v resolved as %+v; want untyped, as written", typ) + } + table := runSweepOver(t, ctx, scope, "Any", SweepPlan{Ranges: []SweepRange{rangeOf("v", intOf(1), intOf(3))}}) + if got, want := tableText(table), "v=1 result -> 1\nv=2 result -> 2\nv=3 result -> 3"; got != want { + t.Errorf("Any over 1..3 is\n%s\nwant\n%s", got, want) + } + table = runSweepOver(t, ctx, scope, "Any", SweepPlan{ + Ranges: []SweepRange{steppedRange("v", realOf(1), realOf(2), realOf(0.5))}, + }) + if got, want := tableText(table), "v=1.0 result -> 1.0\nv=1.5 result -> 1.5\nv=2.0 result -> 2.0"; got != want { + t.Errorf("Any over 1.0..2.0:0.5 is\n%s\nwant\n%s", got, want) + } + table = runSweepOver(t, ctx, scope, "Any", SweepPlan{ + Ranges: []SweepRange{rangeOf("v", intOf(0), intOf(1))}, + Sampled: true, Samples: 20, Seed: 3, + }) + for _, row := range table.Rows { + if v := row.Bindings[0].Value; v.Kind != ValConst || v.Const.Kind != semantics.ValInt { + t.Errorf("drew v=%s over Integer literals; want an Integer", FormatValue(v)) + } + } +} + +// A parameter typed by Number, which Integers and reals both are, takes the +// range as written too, but is typed rather than untyped. +func TestSweepOverANumberParameterFollowsTheLiterals(t *testing.T) { + ctx, scope := sweepFixture(t) + plan := resolvedPlan(t, ctx, scope, "Num", SweepPlan{Ranges: []SweepRange{rangeOf("n", intOf(1), intOf(2))}}) + if typ := plan.Ranges[0].Type; typ.Untyped || typ.Numbers != SweepAsWritten || symbolText(typ.Declared()) != "Number" { + t.Errorf("n resolved as %+v; want as written, typed by Number", typ) + } + table := runSweepOver(t, ctx, scope, "Num", SweepPlan{Ranges: []SweepRange{rangeOf("n", intOf(1), intOf(2))}}) + if got, want := tableText(table), "n=1 result -> 1\nn=2 result -> 2"; got != want { + t.Errorf("Num over 1..2 is\n%s\nwant\n%s", got, want) + } + table = runSweepOver(t, ctx, scope, "Num", SweepPlan{Ranges: []SweepRange{steppedRange("n", realOf(1), realOf(2), realOf(1))}}) + if got, want := tableText(table), "n=1.0 result -> 1.0\nn=2.0 result -> 2.0"; got != want { + t.Errorf("Num over 1.0..2.0:1.0 is\n%s\nwant\n%s", got, want) + } +} + +// A quantity-typed parameter takes a range of quantities expressed in the +// first endpoint's unit, magnitudes typed by the quantity's `num`; a range of +// bare numbers is refused before any run, naming the parameter's type. +func TestSweepOverAQuantityParameterConvertsToTheFirstEndpointsUnit(t *testing.T) { + ctx, scope := sweepFixture(t) + metre := ctx.librarySymbol("SI::m") + if metre == nil { + t.Fatal("SI::m is not in the library") + } + length := func(num semantics.Value, text string, scale float64) Value { + return NewQuantityValue(&Quantity{Num: num, Unit: Unit{Text: text, Term: semantics.UnitTerm{ + Scale: semantics.UnitScale(scale), Factors: []semantics.UnitFactor{{Unit: metre, Exponent: 1}}, + }}}) + } + integer := func(n int64) semantics.Value { return semantics.Value{Kind: semantics.ValInt, Int: n} } + table := runSweepOver(t, ctx, scope, "Stretch", SweepPlan{ + Ranges: []SweepRange{steppedRange("l", + length(integer(1), "SI::km", 1000), length(integer(2000), "SI::m", 1), length(integer(500), "SI::m", 1))}, + }) + want := []float64{1, 1.5, 2} + if len(table.Rows) != len(want) { + t.Fatalf("rows = %d; want %d", len(table.Rows), len(want)) + } + for i, m := range want { + q := table.Rows[i].Bindings[0].Value.Quantity() + if q == nil || q.Num.Kind != semantics.ValReal || q.Num.Real != m || q.Unit.Text != "SI::km" { + t.Errorf("row %d bound %s; want the Real %v [SI::km]", i, FormatValue(table.Rows[i].Bindings[0].Value), m) + } + if table.Rows[i].Err != nil { + t.Errorf("row %d failed: %v", i, table.Rows[i].Err) + } + } + refusedNaming(t, refuseSweep(t, ctx, scope, "Stretch", SweepPlan{ + Ranges: []SweepRange{rangeOf("l", intOf(1), intOf(4))}, + }), "l", "LengthValue") + refusedNaming(t, refuseSweep(t, ctx, scope, "Stretch", SweepPlan{ + Ranges: []SweepRange{rangeOf("l", quantityInt(1, "SI::s", 1), quantityInt(4, "SI::s", 1))}, + }), "l", "LengthValue") + refusedNaming(t, refuseSweep(t, ctx, scope, "Heap", SweepPlan{ + Ranges: []SweepRange{steppedRange("n", length(integer(1), "SI::m", 1), length(integer(2), "SI::m", 1), + length(semantics.Value{Kind: semantics.ValReal, Real: 0.5}, "SI::m", 1))}, + }), "n", "CountValue") +} + +// A quantity's `num` bounds its magnitudes: Natural/Positive refuse below what they +// hold, a num holding no number refuses every range, all before any run. +func TestSweepOverAQuantityParameterIsBoundedByItsNum(t *testing.T) { + ctx, scope := sweepFixture(t) + metres := func(n int64) Value { return quantityInt(n, "SI::m", 1) } + table := runSweepOver(t, ctx, scope, "Crew", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", metres(0), metres(2))}, + }) + if got, want := tableText(table), "n=0 [SI::m] result -> 0 [SI::m]\nn=1 [SI::m] result -> 1 [SI::m]\nn=2 [SI::m] result -> 2 [SI::m]"; got != want { + t.Errorf("Crew over 0..2 [SI::m] is\n%s\nwant\n%s", got, want) + } + for _, tc := range []struct { + name, param, typ string + from, to Value + }{ + {"Crew", "n", "Natural", metres(-1), metres(1)}, + {"Crew", "n", "Natural", metres(1), metres(-1)}, + {"Grade", "r", "Positive", metres(0), metres(2)}, + {"Tag", "t", "String", metres(0), metres(2)}, + } { + plan := SweepPlan{Ranges: []SweepRange{rangeOf(tc.param, tc.from, tc.to)}} + refusedNaming(t, refuseSweep(t, ctx, scope, tc.name, plan), tc.param, tc.typ) + plan.Sampled, plan.Samples, plan.Seed = true, 2, 1 + refusedNaming(t, refuseSweep(t, ctx, scope, tc.name, plan), tc.param, tc.typ) + } +} + +// A sampled Integer range draws over its endpoints inclusively, so both of a +// range of two values are drawn and nothing outside it is. +func TestSamplesOverIntegersIncludeBothEndpoints(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(0), intOf(1))}, + Sampled: true, Samples: 40, Seed: 3, + }) + seen := map[string]int{} + for _, row := range table.Rows { + seen[FormatValue(row.Bindings[0].Value)]++ + } + if len(seen) != 2 || seen["0"] == 0 || seen["1"] == 0 { + t.Errorf("draws over 0..1 were %v; want both endpoints and nothing else", seen) + } +} + +// A sampled real range draws over [from, to), and needs no step to do it. +func TestSamplesOverRealsStayWithinTheirRange(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{ + rangeOf("a", realOf(1), realOf(2)), + rangeOf("b", intOf(1), intOf(1)), + }, + Sampled: true, Samples: 25, Seed: 5, + }) + for _, row := range table.Rows { + drawn := row.Bindings[0].Value.Const.AsReal() + if drawn < 1 || drawn >= 2 { + t.Errorf("drew a=%v, which is outside [1.0, 2.0)", drawn) + } + } +} + +// A step is what a swept range advances by; a sampled range draws instead, so +// stating one is refused rather than ignored. +func TestSamplesWithAStepAreRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{steppedRange("n", intOf(1), intOf(9), intOf(2))}, + Sampled: true, Samples: 3, Seed: 1, + }) + if !errors.Is(err, ErrSweepSamples) { + t.Errorf("err = %v; want ErrSweepSamples", err) + } +} + +// A sample count that draws nothing is refused. +func TestSamplesWithoutADrawAreRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + for _, count := range []int64{0, -3} { + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(1), intOf(9))}, + Sampled: true, Samples: count, Seed: 1, + }) + if !errors.Is(err, ErrSweepSamples) { + t.Errorf("%d samples: err = %v; want ErrSweepSamples", count, err) + } + } +} + +// The budget bounds a sampled sweep as it bounds a swept one. +func TestSamplesBeyondTheBudgetAreRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(1), intOf(9))}, + Sampled: true, Samples: ctx.SweepRunBudget() + 1, Seed: 1, + }) + if !errors.Is(err, ErrSweepBudget) { + t.Errorf("err = %v; want ErrSweepBudget", err) + } +} + +// The generator is the one documented: one seed selects one PCG state, and the +// sequence it draws is the same on every platform. +func TestNewSampleSourceIsSeededFromTheSeedAlone(t *testing.T) { + first, again := NewSampleSource(9), NewSampleSource(9) + other := NewSampleSource(10) + for i := range 4 { + a, b, c := first.Uint64(), again.Uint64(), other.Uint64() + if a != b { + t.Fatalf("draw %d from seed 9 is %d then %d", i, a, b) + } + if a == c { + t.Fatalf("draw %d from seed 10 is what seed 9 drew: %d", i, a) + } + } +} + +// A range between Integers too large for float64 to count by ones through +// still takes every value between its endpoints. +func TestSweepIntegerRangeBeyondFloatPrecisionStepsExactly(t *testing.T) { + ctx, scope := sweepFixture(t) + const big = int64(1) << 60 + table := runSweepOver(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(big), intOf(big+3))}, + }) + want := []string{"n=1152921504606846976", "n=1152921504606846977", "n=1152921504606846978", "n=1152921504606846979"} + if got := inputsOf(table); !equalStrings(got, want) { + t.Errorf("rows bound %v; want %v", got, want) + } + stepped := runSweepOver(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{steppedRange("n", intOf(1<<53), intOf(1<<53+4), intOf(2))}, + }) + wantStepped := []string{"n=9007199254740992", "n=9007199254740994", "n=9007199254740996"} + if got := inputsOf(stepped); !equalStrings(got, wantStepped) { + t.Errorf("rows bound %v; want %v", got, wantStepped) + } +} + +// A range against the ends of Integer arithmetic reaches its endpoints rather +// than wrapping past them, in either direction and by a step of any width. +func TestSweepIntegerRangeAtTheIntegerExtremes(t *testing.T) { + ctx, scope := sweepFixture(t) + cases := []struct { + name string + plan SweepRange + bound []string + }{ + {"top", rangeOf("n", intOf(math.MaxInt64-2), intOf(math.MaxInt64)), + []string{"n=9223372036854775805", "n=9223372036854775806", "n=9223372036854775807"}}, + {"bottom", rangeOf("n", intOf(math.MinInt64), intOf(math.MinInt64+2)), + []string{"n=-9223372036854775808", "n=-9223372036854775807", "n=-9223372036854775806"}}, + {"descending", rangeOf("n", intOf(math.MinInt64+2), intOf(math.MinInt64)), + []string{"n=-9223372036854775806", "n=-9223372036854775807", "n=-9223372036854775808"}}, + {"widest step", steppedRange("n", intOf(math.MinInt64), intOf(math.MaxInt64), intOf(math.MaxInt64)), + []string{"n=-9223372036854775808", "n=-1", "n=9223372036854775806"}}, + {"singleton", rangeOf("n", intOf(math.MaxInt64), intOf(math.MaxInt64)), + []string{"n=9223372036854775807"}}, + } + for _, c := range cases { + table := runSweepOver(t, ctx, scope, "Twice", SweepPlan{Ranges: []SweepRange{c.plan}}) + if got := inputsOf(table); !equalStrings(got, c.bound) { + t.Errorf("%s: rows bound %v; want %v", c.name, got, c.bound) + } + } +} + +// A range spanning every Integer takes more runs than any budget allows, and +// is refused by the budget rather than counted wrongly. +func TestSweepOverEveryIntegerIsRefusedByTheBudget(t *testing.T) { + ctx, scope := sweepFixture(t) + err := refuseSweep(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(math.MinInt64), intOf(math.MaxInt64))}, + }) + if !errors.Is(err, ErrSweepBudget) { + t.Errorf("err = %v; want ErrSweepBudget", err) + } +} + +// Sampling a range float64 cannot count through draws Integers spread over it, +// rather than collapsing onto one endpoint. +func TestSamplesOverWideIntegerRangesSpreadOverThem(t *testing.T) { + ctx, scope := sweepFixture(t) + const big = int64(1) << 60 + cases := []struct { + name string + from, to int64 + }{ + {"beyond float precision", big, big + 4}, + {"every integer", math.MinInt64, math.MaxInt64}, + } + for _, c := range cases { + table := runSweepOver(t, ctx, scope, "Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(c.from), intOf(c.to))}, + Sampled: true, Samples: 12, Seed: 11, + }) + seen := map[int64]bool{} + for _, row := range table.Rows { + drawn := row.Bindings[0].Value.Const.Int + if drawn < c.from || drawn > c.to { + t.Errorf("%s: drew n=%d, which is outside %d..%d", c.name, drawn, c.from, c.to) + } + seen[drawn] = true + } + if len(seen) < 2 { + t.Errorf("%s: 12 draws took %d value(s); want a spread", c.name, len(seen)) + } + } +} + +// A sweep stops between runs once its caller has gone away, and reports why +// rather than a table. +func TestSweepStopsWhenItsCallerGoesAway(t *testing.T) { + ctx, scope := sweepFixture(t) + sym := calcNamed(t, scope, "Twice") + run := sweepCalcRun(sym, scope) + stop, cancel := context.WithCancel(context.Background()) + defer cancel() + ran := 0 + counted := func(ctx *Context, bindings []SweepBinding) (SweepRunResult, error) { + ran++ + if ran == 2 { + cancel() + } + return run(ctx, bindings) + } + table, err := sweepIn(ctx, stop, "test::Twice", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", intOf(1), intOf(20))}, + }, 0, counted) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v; want context.Canceled", err) + } + if ran != 2 { + t.Errorf("%d run(s) were made; want the sweep to stop after the second", ran) + } + if len(table.Rows) != 0 { + t.Errorf("a stopped sweep reported %d row(s); want none", len(table.Rows)) + } +} + +// quantityInt is an Integer magnitude in the given unit, as an argument carries it. +func quantityInt(n int64, unit string, scale float64) Value { + return NewQuantityValue(&Quantity{ + Num: semantics.Value{Kind: semantics.ValInt, Int: n}, + Unit: Unit{Text: unit, Term: semantics.UnitTerm{Scale: semantics.UnitScale(scale)}}, + }) +} + +// An Integer range carrying a unit keeps its endpoints exactly past the +// magnitudes float64 counts by ones through, so it still steps by one. +func TestSweepQuantityIntegerRangeBeyondFloatPrecisionStepsExactly(t *testing.T) { + ctx, scope := sweepFixture(t) + base := int64(1) << 60 + table := runSweepOver(t, ctx, scope, "Heap", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", + quantityInt(base, "SI::m", 1), quantityInt(base+2, "SI::m", 1))}, + }) + want := []int64{base, base + 1, base + 2} + if len(table.Rows) != len(want) { + t.Fatalf("rows = %d; want %d", len(table.Rows), len(want)) + } + for i, n := range want { + q := table.Rows[i].Bindings[0].Value.Quantity() + if q == nil || q.Num.Kind != semantics.ValInt || q.Num.Int != n { + t.Errorf("row %d bound %s; want %d [SI::m]", i, FormatValue(table.Rows[i].Bindings[0].Value), n) + } + } +} + +// A range whose endpoints are expressed in units a whole factor apart converts +// them exactly, so it stays a range between Integers past that precision too. +func TestSweepQuantityRangeConvertsIntegerEndpointsExactly(t *testing.T) { + ctx, scope := sweepFixture(t) + const kilometres = 1152921504606844 + metres := int64(kilometres) * 1000 + table := runSweepOver(t, ctx, scope, "Heap", SweepPlan{ + Ranges: []SweepRange{steppedRange("n", + quantityInt(metres, "SI::m", 1), + quantityInt(kilometres+2, "SI::km", 1000), + quantityInt(1000, "SI::m", 1))}, + }) + want := []int64{metres, metres + 1000, metres + 2000} + if len(table.Rows) != len(want) { + t.Fatalf("rows = %d; want %d", len(table.Rows), len(want)) + } + for i, n := range want { + q := table.Rows[i].Bindings[0].Value.Quantity() + if q == nil || q.Num.Kind != semantics.ValInt || q.Num.Int != n { + t.Errorf("row %d bound %s; want %d [SI::m]", i, FormatValue(table.Rows[i].Bindings[0].Value), n) + } + } +} + +// A case's positional arguments bind the inputs its run binds them to, the +// subject skipped, so a parameter one of them binds cannot also be swept. +func TestResolveSweepPlanFollowsAnalysisPositionalBinding(t *testing.T) { + ctx, scope := sweepFixture(t) + sym := calcNamed(t, scope, "Margin") + + load := SweepPlan{Ranges: []SweepRange{rangeOf("load", realOf(0), realOf(1))}} + if _, err := ctx.ResolveSweepPlan(sym, load, 1, nil); !errors.Is(err, ErrSweepParameter) { + t.Errorf("sweeping the input the positional argument binds: err = %v; want ErrSweepParameter", err) + } + factor := SweepPlan{Ranges: []SweepRange{rangeOf("factor", realOf(1), realOf(2))}} + if _, err := ctx.ResolveSweepPlan(sym, factor, 1, nil); err != nil { + t.Errorf("sweeping the input no argument binds: %v", err) + } + if _, err := ctx.ResolveSweepPlan(sym, factor, 2, nil); !errors.Is(err, ErrSweepParameter) { + t.Errorf("sweeping the second input two arguments bind: err = %v; want ErrSweepParameter", err) + } +} + +// A case's subject is an object an instantiation binds, which no range of +// values stands for, so sweeping it is refused rather than run. +func TestResolveSweepPlanRefusesTheSubject(t *testing.T) { + ctx, scope := sweepFixture(t) + sym := calcNamed(t, scope, "Margin") + _, err := ctx.ResolveSweepPlan(sym, SweepPlan{ + Ranges: []SweepRange{rangeOf("s", intOf(1), intOf(2))}, + }, 0, nil) + if !errors.Is(err, ErrSweepParameter) || !strings.Contains(err.Error(), "subject") { + t.Errorf("err = %v; want an ErrSweepParameter naming the subject", err) + } +} + +// A sampled range carrying a unit draws Integers over the whole of it, past the +// magnitudes float64 counts by ones through. +func TestSamplesOverAQuantityIntegerRangeStayIntegers(t *testing.T) { + ctx, scope := sweepFixture(t) + base := int64(1) << 60 + table := runSweepOver(t, ctx, scope, "Heap", SweepPlan{ + Ranges: []SweepRange{rangeOf("n", + quantityInt(base, "SI::m", 1), quantityInt(base+1000, "SI::m", 1))}, + Sampled: true, Samples: 16, Seed: 7, + }) + spread := make(map[int64]bool, len(table.Rows)) + for i := range table.Rows { + q := table.Rows[i].Bindings[0].Value.Quantity() + if q == nil || q.Num.Kind != semantics.ValInt { + t.Fatalf("row %d drew %s; want an Integer quantity", + i, FormatValue(table.Rows[i].Bindings[0].Value)) + } + if q.Num.Int < base || q.Num.Int > base+1000 { + t.Errorf("row %d drew %d; want a value in the range", i, q.Num.Int) + } + spread[q.Num.Int] = true + } + if len(spread) < 2 { + t.Errorf("16 draws took %d distinct value(s); want them spread over the range", len(spread)) + } +} + +// A range whose endpoint or step is not a finite number states no run to make, +// stepped or drawn, so it is refused before any row is counted. +func TestSweepNonFiniteRangeIsRefused(t *testing.T) { + ctx, scope := sweepFixture(t) + quantity := func(m float64) Value { + return NewQuantityValue(&Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: m}, + Unit: Unit{Text: "SI::m", Term: semantics.UnitTerm{Scale: semantics.UnitScale(1)}}, + }) + } + kilometre := NewQuantityValue(&Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: 1}, + Unit: Unit{Text: "SI::km", Term: semantics.UnitTerm{Scale: semantics.UnitScale(1000)}}, + }) + cases := []struct { + name string + plan SweepPlan + }{ + {"start is not a number", SweepPlan{ + Ranges: []SweepRange{steppedRange("a", realOf(math.NaN()), realOf(4), realOf(1))}}}, + {"end is not a number", SweepPlan{ + Ranges: []SweepRange{steppedRange("a", realOf(0), realOf(math.NaN()), realOf(1))}}}, + {"step is not a number", SweepPlan{ + Ranges: []SweepRange{steppedRange("a", realOf(0), realOf(4), realOf(math.NaN()))}}}, + {"start is infinite", SweepPlan{ + Ranges: []SweepRange{steppedRange("a", realOf(math.Inf(-1)), realOf(4), realOf(1))}}}, + {"end is infinite", SweepPlan{ + Ranges: []SweepRange{steppedRange("a", realOf(0), realOf(math.Inf(1)), realOf(1))}}}, + {"step is infinite", SweepPlan{ + Ranges: []SweepRange{steppedRange("a", realOf(0), realOf(4), realOf(math.Inf(1)))}}}, + {"a drawn range is infinite", SweepPlan{ + Ranges: []SweepRange{rangeOf("a", realOf(0), realOf(math.Inf(1)))}, + Sampled: true, Samples: 3, Seed: 7}}, + {"an endpoint carrying a unit is infinite", SweepPlan{ + Ranges: []SweepRange{steppedRange("a", quantity(0), quantity(math.Inf(1)), quantity(1))}}}, + {"a converted endpoint is not a number", SweepPlan{ + Ranges: []SweepRange{steppedRange("a", quantity(math.NaN()), kilometre, quantity(1))}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := refuseSweep(t, ctx, scope, "Ratio", tc.plan) + if !errors.Is(err, ErrSweepRange) || !strings.Contains(err.Error(), "finite") { + t.Errorf("err = %v; want an ErrSweepRange naming a non-finite number", err) + } + }) + } +} + +// A range whose end lies just short of the next step stops at the end: the +// rounding error a count of steps is allowed never buys a whole extra run. +func TestSweepRealRangeStopsAtItsEnd(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{ + steppedRange("a", realOf(0), realOf(2e9-0.5), realOf(1e9)), + steppedRange("b", realOf(1), realOf(1), realOf(1)), + }, + }) + want := []float64{0, 1e9} + if len(table.Rows) != len(want) { + t.Fatalf("the range took %d run(s); want %d, the end being short of the next step", + len(table.Rows), len(want)) + } + for i, value := range want { + if got := table.Rows[i].Bindings[0].Value.Const.Real; got != value { + t.Errorf("row %d ran a = %v; want %v", i, got, value) + } + } +} + +// A range as wide as the reals reach draws values inside it: the width of the +// range overflows, which no drawn value may. +func TestSamplesOverTheWidestRealRangeStayInIt(t *testing.T) { + ctx, scope := sweepFixture(t) + table := runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{ + rangeOf("a", realOf(-math.MaxFloat64), realOf(math.MaxFloat64)), + rangeOf("b", realOf(1), realOf(1)), + }, + Sampled: true, Samples: 32, Seed: 5, + }) + for i := range table.Rows { + drawn := table.Rows[i].Bindings[0].Value.Const.Real + if math.IsNaN(drawn) || math.IsInf(drawn, 0) { + t.Fatalf("row %d drew %v; want a finite value", i, drawn) + } + if drawn < -math.MaxFloat64 || drawn >= math.MaxFloat64 { + t.Errorf("row %d drew %v; want a value in [-MaxFloat64, MaxFloat64)", i, drawn) + } + } +} + +// A range asking for more runs than any budget allows is refused promptly: how +// many runs it takes is counted, not enumerated. +func TestSweepRefusesWideRealRangesPromptly(t *testing.T) { + ctx, scope := sweepFixture(t) + sym := calcNamed(t, scope, "Ratio") + for _, r := range []SweepRange{ + steppedRange("a", realOf(0), realOf(1e18), realOf(1)), + steppedRange("a", realOf(-math.MaxFloat64), realOf(math.MaxFloat64), realOf(1e-3)), + } { + plan := resolvedPlan(t, ctx, scope, "Ratio", + SweepPlan{Ranges: []SweepRange{r, steppedRange("b", realOf(1), realOf(1), realOf(1))}}) + done := make(chan error, 1) + go func() { + _, err := sweepIn(ctx, context.Background(), "test::Ratio", plan, 0, sweepCalcRun(sym, scope)) + done <- err + }() + select { + case err := <-done: + if !errors.Is(err, ErrSweepBudget) { + t.Errorf("range ending at %s: got %v; want a budget refusal", FormatValue(r.To), err) + } + case <-watchdog(10 * time.Second): + t.Fatalf("range ending at %s was still being counted after ten seconds", FormatValue(r.To)) + } + } +} + +// A range whose end is as far out as the reals reach still runs to it, in +// either direction: no room is left for a rounding error there. +func TestSweepRunsToTheOutermostRealEndpoints(t *testing.T) { + ctx, scope := sweepFixture(t) + for _, tc := range []struct { + name string + r SweepRange + want []float64 + }{ + { + "ascending", + steppedRange("a", realOf(0), realOf(math.MaxFloat64), realOf(math.MaxFloat64/2)), + []float64{0, math.MaxFloat64 / 2, math.MaxFloat64}, + }, + { + "descending", + steppedRange("a", realOf(0), realOf(-math.MaxFloat64), realOf(-math.MaxFloat64/2)), + []float64{0, -math.MaxFloat64 / 2, -math.MaxFloat64}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + table := runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{tc.r, steppedRange("b", realOf(1), realOf(1), realOf(1))}, + }) + if len(table.Rows) != len(tc.want) { + t.Fatalf("the range took %d run(s); want %d", len(table.Rows), len(tc.want)) + } + for i, value := range tc.want { + if got := table.Rows[i].Bindings[0].Value.Const.Real; got != value { + t.Errorf("row %d ran a = %v; want %v", i, got, value) + } + } + }) + } +} + +// A range read as reals takes its Integers as the Reals they are: one no Real +// holds exactly is refused, as is a step finer than the reals tell apart, so +// no row binds a value the range did not state and no two rows bind the same. +func TestSweepRealRangeRefusesWhatARealCannotHoldApart(t *testing.T) { + ctx, scope := sweepFixture(t) + const big = int64(1) << 60 + one := SweepRange{Param: "b", From: realOf(1), To: realOf(1)} + table := runSweepOver(t, ctx, scope, "Ratio", SweepPlan{ + Ranges: []SweepRange{steppedRange("a", intOf(big), intOf(big+512), intOf(256)), one}, + }) + if got, want := inputsOf(table), []string{"a=1152921504606847000.0 b=1.0", "a=1152921504606847200.0 b=1.0", "a=1152921504606847500.0 b=1.0"}; !equalStrings(got, want) { + t.Errorf("Ratio over %d..%d:256 bound %v; want %v", big, big+512, got, want) + } + for _, tc := range []struct { + name string + calc string + r SweepRange + words []string + }{ + {"end beyond exactness", "Ratio", rangeOf("a", intOf(big), intOf(big+3)), + []string{"range end 1152921504606846979", "a : Real takes Reals"}}, + {"start beyond exactness", "Ratio", rangeOf("a", intOf(big+1), intOf(big)), + []string{"range start 1152921504606846977", "a : Real"}}, + {"step beyond exactness", "Ratio", steppedRange("a", intOf(0), intOf(big), intOf(big+1)), + []string{"step 1152921504606846977", "a : Real"}}, + {"a Rational the same", "Frac", rangeOf("q", intOf(big), intOf(big+3)), + []string{"range end 1152921504606846979", "q : Rational"}}, + {"read as written by a fractional step", "Any", steppedRange("v", intOf(big), intOf(big+3), realOf(0.5)), + []string{"range end 1152921504606846979", "the range is read as reals"}}, + {"a step finer than the reals", "Ratio", steppedRange("a", realOf(1e16), realOf(1e16+4), realOf(1)), + []string{"a steps by 1.0", "finer than a Real tells apart", "rows would repeat"}}, + {"a step of one finer than the reals", "Ratio", rangeOf("a", intOf(big), intOf(big+512)), + []string{"a steps by 1.0", "finer than a Real tells apart"}}, + } { + plan := SweepPlan{Ranges: []SweepRange{tc.r}} + if tc.calc == "Ratio" { + plan.Ranges = append(plan.Ranges, one) + } + refusedNaming(t, refuseSweep(t, ctx, scope, tc.calc, plan), tc.words...) + if !tc.r.HasStep { + plan.Ranges = []SweepRange{tc.r} + plan.Sampled, plan.Samples, plan.Seed = true, 2, 1 + if strings.Contains(tc.name, "finer") { + runSweepOver(t, ctx, scope, tc.calc, plan) + continue + } + refusedNaming(t, refuseSweep(t, ctx, scope, tc.calc, plan), tc.words...) + } + } +} diff --git a/internal/core/runtime/tensor.go b/internal/exec/runtime/tensor.go similarity index 98% rename from internal/core/runtime/tensor.go rename to internal/exec/runtime/tensor.go index b16373848a..b2dba1fa92 100644 --- a/internal/core/runtime/tensor.go +++ b/internal/exec/runtime/tensor.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // TensorQuantity is a Quantities::TensorQuantityValue of order two or more: row-major diff --git a/internal/core/runtime/tensor_functions.go b/internal/exec/runtime/tensor_functions.go similarity index 99% rename from internal/core/runtime/tensor_functions.go rename to internal/exec/runtime/tensor_functions.go index e529113b36..ea1b43647f 100644 --- a/internal/core/runtime/tensor_functions.go +++ b/internal/exec/runtime/tensor_functions.go @@ -3,8 +3,8 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // TensorCalculations: the calculations the library defines over a TensorQuantityValue's diff --git a/internal/core/runtime/tensor_test.go b/internal/exec/runtime/tensor_test.go similarity index 99% rename from internal/core/runtime/tensor_test.go rename to internal/exec/runtime/tensor_test.go index 213e863bb0..43e2a01518 100644 --- a/internal/core/runtime/tensor_test.go +++ b/internal/exec/runtime/tensor_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // tensorContext declares a 2×2 stress reference and the values the tests read. diff --git a/internal/core/runtime/testdata/check/por_address.sysml b/internal/exec/runtime/testdata/check/por_address.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_address.sysml rename to internal/exec/runtime/testdata/check/por_address.sysml diff --git a/internal/core/runtime/testdata/check/por_alias.sysml b/internal/exec/runtime/testdata/check/por_alias.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_alias.sysml rename to internal/exec/runtime/testdata/check/por_alias.sysml diff --git a/internal/core/runtime/testdata/check/por_constructor.sysml b/internal/exec/runtime/testdata/check/por_constructor.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_constructor.sysml rename to internal/exec/runtime/testdata/check/por_constructor.sysml diff --git a/internal/core/runtime/testdata/check/por_dynamic_target.sysml b/internal/exec/runtime/testdata/check/por_dynamic_target.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_dynamic_target.sysml rename to internal/exec/runtime/testdata/check/por_dynamic_target.sysml diff --git a/internal/core/runtime/testdata/check/por_guard_read.sysml b/internal/exec/runtime/testdata/check/por_guard_read.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_guard_read.sysml rename to internal/exec/runtime/testdata/check/por_guard_read.sysml diff --git a/internal/core/runtime/testdata/check/por_independent_branches.sysml b/internal/exec/runtime/testdata/check/por_independent_branches.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_independent_branches.sysml rename to internal/exec/runtime/testdata/check/por_independent_branches.sysml diff --git a/internal/core/runtime/testdata/check/por_join.sysml b/internal/exec/runtime/testdata/check/por_join.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_join.sysml rename to internal/exec/runtime/testdata/check/por_join.sysml diff --git a/internal/core/runtime/testdata/check/por_send_accept.sysml b/internal/exec/runtime/testdata/check/por_send_accept.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_send_accept.sysml rename to internal/exec/runtime/testdata/check/por_send_accept.sysml diff --git a/internal/core/runtime/testdata/check/por_shared_write.sysml b/internal/exec/runtime/testdata/check/por_shared_write.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_shared_write.sysml rename to internal/exec/runtime/testdata/check/por_shared_write.sysml diff --git a/internal/core/runtime/testdata/check/por_state_do_write.sysml b/internal/exec/runtime/testdata/check/por_state_do_write.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_state_do_write.sysml rename to internal/exec/runtime/testdata/check/por_state_do_write.sysml diff --git a/internal/core/runtime/testdata/check/por_state_effect_write.sysml b/internal/exec/runtime/testdata/check/por_state_effect_write.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_state_effect_write.sysml rename to internal/exec/runtime/testdata/check/por_state_effect_write.sysml diff --git a/internal/core/runtime/testdata/check/por_state_guard_read.sysml b/internal/exec/runtime/testdata/check/por_state_guard_read.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_state_guard_read.sysml rename to internal/exec/runtime/testdata/check/por_state_guard_read.sysml diff --git a/internal/core/runtime/testdata/check/por_state_independent.sysml b/internal/exec/runtime/testdata/check/por_state_independent.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_state_independent.sysml rename to internal/exec/runtime/testdata/check/por_state_independent.sysml diff --git a/internal/exec/runtime/testdata/check/por_state_join_exit.sysml b/internal/exec/runtime/testdata/check/por_state_join_exit.sysml new file mode 100644 index 0000000000..c01432be53 --- /dev/null +++ b/internal/exec/runtime/testdata/check/por_state_join_exit.sysml @@ -0,0 +1,42 @@ +// A join of the machine's own regions leaves every region, the one with no +// segment into it too: its state's exit writes a feature an action reads after +// sending the join its signal, so the dispatch is dependent on the read, and each +// reading is reached. +package test { + private import ScalarValues::*; + + item def Go; + + part def Meter { + attribute count : Integer = 0; + } + + part meter : Meter; + + action reader { + attribute saw : Integer = -1; + first start; + then action go { send Go() to gather; } + then action look { assign saw := meter.count; } + then done; + } + + state gather parallel { + state left { + entry; then l1; + state l1; + transition first l1 accept Go then sync; + } + state right { + entry; then r1; + state r1; + transition first r1 then sync; + } + state third { + entry; then t1; + state t1 { exit { assign meter.count := 1; } } + } + join sync; + transition first sync then done; + } +} diff --git a/internal/exec/runtime/testdata/check/por_state_join_guard.sysml b/internal/exec/runtime/testdata/check/por_state_join_guard.sysml new file mode 100644 index 0000000000..62413aa449 --- /dev/null +++ b/internal/exec/runtime/testdata/check/por_state_join_guard.sysml @@ -0,0 +1,38 @@ +// An action's write to a feature a sibling join segment's guard reads, after +// sending the other segment its signal: whether the join fires on that signal +// depends on whether the write went first, so the dispatch is dependent on the +// write, and both are reached. +package test { + private import ScalarValues::*; + + item def Go; + + part def Dial { + attribute armed : Boolean = false; + } + + part dial : Dial; + + action arm { + first start; + then action go { send Go() to gather; } + then action set { assign dial.armed := true; } + then done; + } + + state gather parallel { + attribute fired : Integer = 0; + state left { + entry; then l1; + state l1; + transition first l1 accept Go then sync; + } + state right { + entry; then r1; + state r1; + transition first r1 if dial.armed then sync; + } + join sync; + transition first sync do assign fired := 1 then done; + } +} diff --git a/internal/core/runtime/testdata/check/por_state_send_accept.sysml b/internal/exec/runtime/testdata/check/por_state_send_accept.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_state_send_accept.sysml rename to internal/exec/runtime/testdata/check/por_state_send_accept.sysml diff --git a/internal/exec/runtime/testdata/check/por_streaming_flow.sysml b/internal/exec/runtime/testdata/check/por_streaming_flow.sysml new file mode 100644 index 0000000000..93d150b8e3 --- /dev/null +++ b/internal/exec/runtime/testdata/check/por_streaming_flow.sysml @@ -0,0 +1,46 @@ +// Streaming flow: a producer's nested step writes its out pin, which streams at +// once to the in pin of a consumer under way beside it; the consumer's nested +// steps read that pin, so they do not commute with the write — the pins are named +// differently and each held per performance, so only the flow relates them. The +// consumer waits for the producer's word before completing, so no value comes late. +package test { + private import ScalarValues::*; + + action stream { + attribute seen : Integer = -1; + attribute final : Integer = -1; + first start; + fork split; + action producer { + out value : Integer = 0; + first start; + action emit { assign value := 7; } + action tell send 1; + done; + succession first start then emit; + succession first emit then tell; + succession first tell then done; + } + action consumer { + in got : Integer = -1; + first start; + action take { assign seen := got; } + action wait accept go : Integer; + action retake { assign final := got; } + done; + succession first start then take; + succession first take then wait; + succession first wait then retake; + succession first retake then done; + } + join sync; + done; + succession first start then split; + succession first split then producer; + succession first split then consumer; + succession first producer then sync; + succession first consumer then sync; + succession first sync then done; + flow producer.value to consumer.got; + } +} diff --git a/internal/core/runtime/testdata/check/por_trigger_read.sysml b/internal/exec/runtime/testdata/check/por_trigger_read.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_trigger_read.sysml rename to internal/exec/runtime/testdata/check/por_trigger_read.sysml diff --git a/internal/core/runtime/testdata/check/por_two_machines.sysml b/internal/exec/runtime/testdata/check/por_two_machines.sysml similarity index 100% rename from internal/core/runtime/testdata/check/por_two_machines.sysml rename to internal/exec/runtime/testdata/check/por_two_machines.sysml diff --git a/internal/core/runtime/testdata/check/reduction_expected.txt b/internal/exec/runtime/testdata/check/reduction_expected.txt similarity index 88% rename from internal/core/runtime/testdata/check/reduction_expected.txt rename to internal/exec/runtime/testdata/check/reduction_expected.txt index 4d36b0eaa7..ba8ef25bcc 100644 --- a/internal/core/runtime/testdata/check/reduction_expected.txt +++ b/internal/exec/runtime/testdata/check/reduction_expected.txt @@ -11,10 +11,13 @@ 24 40 85 220 por_independent_branches 44 78 123 306 por_alias 18 19 19 22 por_address +49 51 58 79 por_streaming_flow 19 21 19 22 por_constructor 13 13 30 38 por_state_independent 17 18 17 18 por_state_effect_write 17 18 17 18 por_state_guard_read 21 22 21 22 por_state_do_write 9 9 9 9 por_state_send_accept +72 84 72 84 por_state_join_exit +24 28 24 28 por_state_join_guard 7 6 15 18 por_two_machines diff --git a/internal/core/runtime/testdata/compiled/body_locals.sysml b/internal/exec/runtime/testdata/compiled/body_locals.sysml similarity index 100% rename from internal/core/runtime/testdata/compiled/body_locals.sysml rename to internal/exec/runtime/testdata/compiled/body_locals.sysml diff --git a/internal/core/runtime/testdata/compiled/intrinsics.sysml b/internal/exec/runtime/testdata/compiled/intrinsics.sysml similarity index 100% rename from internal/core/runtime/testdata/compiled/intrinsics.sysml rename to internal/exec/runtime/testdata/compiled/intrinsics.sysml diff --git a/internal/core/runtime/testdata/compiled/named_arguments.sysml b/internal/exec/runtime/testdata/compiled/named_arguments.sysml similarity index 100% rename from internal/core/runtime/testdata/compiled/named_arguments.sysml rename to internal/exec/runtime/testdata/compiled/named_arguments.sysml diff --git a/internal/core/runtime/testdata/compiled/specialization.sysml b/internal/exec/runtime/testdata/compiled/specialization.sysml similarity index 100% rename from internal/core/runtime/testdata/compiled/specialization.sysml rename to internal/exec/runtime/testdata/compiled/specialization.sysml diff --git a/internal/exec/runtime/testdata/conformance/README.md b/internal/exec/runtime/testdata/conformance/README.md new file mode 100644 index 0000000000..b8d72139d9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/README.md @@ -0,0 +1,629 @@ +# Execution Conformance Schema + +This directory contains behavioral execution conformance tests. Each test consists of: + +1. **`.sysml`** - The behavioral model (action/state/calc/constraint/requirement) +2. **`.expected.json`** - Expected execution outcome +3. **`.trace.golden`** - Optional ordered execution trace (see [Golden Traces](#golden-traces)) +4. **`.trace.order`** - Optional partial-order constraints the trace must satisfy (see [Order Constraints](#order-constraints)) + +## Entry Points + +A case is driven through one behavior. Without `evaluate` the harness searches +the document for the first action, state, calc, constraint or requirement it +declares — which only reaches an element declared by the document's own packages, +not one nested deeper. A case whose subject is nested — `part p { action a { … } }` +— names it by qualified path instead, and the named element must be of the kind +the case's `type` asks for: + +```json +{ + "type": "action", + "evaluate": "test::p::a", + "outputs": {"total": {"type": "Real", "value": 6.5}} +} +``` + +The path is the element's fully-qualified name, `::`-separated from the outermost +package down through every owner, and must name exactly one element. It selects +the entry behavior for the golden trace too, so a nested behavior is traced as a +top-level one is. `instantiate` names an instance case's type the same way. + +## Schema Format + +### For Actions (`ExecuteAction`) + +```json +{ + "type": "action", + "outputs": { + "paramName": {"type": "Integer", "value": 42}, + "result": {"type": "Real", "value": 3.14} + }, + "tokenCount": 5 +} +``` + +- `outputs`: map of output parameter names to their final values. The action's + own parameters and attributes are keyed by their bare names. A nested action + node performs in a frame of its own, so its pins — the parameters and + attributes it declares, and those of the action it performs — are keyed by + the node's path from the action, `p.v` or `leg.inner.v`, holding what the + node's latest performance left there. A value a bare `perform` in a node's + body returns to no declared feature is reported under that node the same way. +- `tokenCount`: number of tokens processed (optional, for regression detection) +- `evaluate`: qualified path of the action to execute (see [Entry Points](#entry-points)) +- `error`: text the execution must fail with, for a case whose contract is a + diagnostic rather than a result — a loop that never terminates must end with + the step budget's error. Set it instead of `outputs`; a case without it must + run to completion. Such a case has no golden trace, since the trace harness + drives the same execution to the end. +- `schedule`: the scheduling policy the case was recorded under (see + [Scheduling Policy](#scheduling-policy)); omitted means the default. + +### For States (`ExecuteState`) + +```json +{ + "type": "state", + "events": [{"signal": "sigB"}], + "finalState": "Active.Cruising", + "stateVisits": ["Off", "Idle", "Active", "Active.Accelerating", "Active.Cruising"], + "outputs": { + "stateData": {"type": "String", "value": "cruising"} + } +} +``` + +- `events`: ordered list of events to inject into the machine. Each entry names + either a signal (`{"signal": "sigB", "args": {...}}`, driving + `AcceptEvent`-triggered transitions; `args` bind the signal's features by name, + or `"value": {"type": "Integer", "value": 3}` carries one bare value as + `send 3` would) or an operation invocation + (`{"call": "setSpeed", "args": {"value": {"type": "Integer", "value": 55}}}`, + driving `CallEvent`-triggered transitions), with `args` optional. Events are + delivered in order. Optional; omit for autonomous (time/completion-driven) + machines. A call may state `results` (`{"call": "compute", "results": {"result": + {"type": "Integer", "value": 6}}}`): the outputs the behaviors its dispatch + triggers return to the machine, which a synchronous caller is released with + (`StateExecutor.Call`). A case stating any `results` is driven one event at a + time, each run to completion before the next, and a call that returns an output + the case does not list fails it. +- `finalState`: qualified name of final reached state; for a machine ending in + orthogonal regions, their active states joined by `+` in region name order + (`d2+deep+r2`) +- `stateVisits`: ordered list of states visited (optional, for golden trace verification) +- `evaluate`: qualified path of the state machine to execute (see + [Entry Points](#entry-points)) +- `outputs`: map of state machine outputs +- `error`: text the performance must fail with, for a case whose contract is a + diagnostic rather than a result — a guard comparing a null payload read must + fail with the operator's type error. Set it instead of `finalState`, + `stateVisits` and `outputs`; a case without it must run to completion. +- `performers`: objects that each perform the machine, for a case whose contract + depends on which object performs it (two objects selecting different variants + of one variation route over their own connections). Each entry names the + object's usage plus the `events` / `finalState` / `stateVisits` / `outputs` + expected of that object's performance: + `{"object": "P::alpha", "finalState": "arrived", "stateVisits": ["start", "sending", "arrived"]}`. + Omit for a machine performed by no object. Such a case may carry a golden + trace too: it records each object's materialization followed by its own + performance, in the order the performers are listed. + +### Admissible Outcomes + +An action or state case whose model leaves more than one result open — two +concurrent branches writing one feature, whose order the Kernel Semantic Library +does not fix — lists every result it admits under `outcomes` instead of stating +one: + +```json +{ + "type": "action", + "outcomes": [ + {"outputs": {"x": {"type": "Integer", "value": 1}, "leftRan": {"type": "Boolean", "value": true}}}, + {"outputs": {"x": {"type": "Integer", "value": 2}, "leftRan": {"type": "Boolean", "value": true}}} + ], + "admissible": "Concurrent branches writing one feature: the value is open, the writes are not" +} +``` + +- `outcomes`: at least two complete results. Each entry carries the `outputs` of + an action case, or the `finalState` / `stateVisits` / `outputs` of a state + case, with the meaning those keys have above, plus an optional `probability`: + the share of the schedule space the explore pass expects the linearizations + reaching that entry to carry (a weighted pick its stated weight's share, an + unweighted choice's uniform share), checked against the sum the exploration + reports for it within `1e-9`. The observed run must match + exactly one entry: matching none fails the case as inadmissible, matching + several fails it because the set is not distinct. `-v` output names the entry + matched (`matched admissible outcome 1 of 2`). +- `admissible`: required beside `outcomes`. The exact title of the section of + `docs/project/behavior-semantic-oracle.md` deriving that every listed outcome is + valid under the Kernel Semantic Library. A missing citation, or one no section + carries, fails the case — an admissible set exists to state what the library + leaves open, never to accommodate a result the executor should not produce. + +A case states either `outcomes` or the single `outputs` / `finalState` / +`stateVisits`; stating both is a schema error the test reports, as is `outcomes` +beside `performers`, on a calc, constraint, requirement or instance case, or +with an entry that states nothing. The `events` a state case injects are its +input and stay at the top level: every outcome is a result of the same run. + +The set is exact. Besides checking the default run, `TestExecutionConformance` +explores every case with `outcomes` under the `explore` policy: the case is +replayed from the start once per linearization the library admits, each run on a +fresh context over the same lowering, and the case fails when a listed outcome +no run reaches (`admissible outcome 2 of 3 is unreachable`), when a run reaches +an outcome the set does not list or fails with an error — named with the choice +sequence of a witness run (`step 1: t2 first of t1, t2, t3; step 2: t1 first of +t1, t3`) — or when the exploration is incomplete. Exploration is bounded by a +budget of runs and of choice points per run, `runs: 1024, depth: 64` by default; +a case that hits it fails with a message telling the author to raise it: + +```json +{ + "type": "action", + "exploreBudget": {"runs": 4096, "depth": 128}, + "outcomes": [ ... ], + "admissible": "..." +} +``` + +- `exploreBudget`: optional beside `outcomes`; each of `runs` and `depth` defaults + to the default budget's when omitted. A budget without `outcomes`, a `runs` + below 1 or a `depth` below 0 is a schema error the test reports. Raising a + budget is the answer to a model with more linearizations than the default + covers, never to an outcome the set is missing: an unlisted outcome is a + derivation to add to the oracle or a bug to fix. + +Cases without `outcomes` are not explored by the harness. The default schedule is +deterministic, so a case with an admissible set still keeps its exact golden trace. + +### Scheduling Policy + +The executor resolves the choice points a run reports — several steppable +tokens in one step, several holding decision guards, several transitions out of +one state enabled by one event, several orthogonal regions reacting to one event, +several orthogonal regions or fork branches each with an entry or exit unit left, +several executors due at one instant of the clock — under a scheduling policy, +spelled the same way everywhere (`sysml -schedule`, `%schedule`, the `schedule` +request field): + +| Policy | Resolution | +|--------|------------| +| `reverse` | The default: tokens in reverse spawn order, the first holding guard, the first enabled transition, regions in declaration order, the executor started last first | +| `declared` | Tokens in spawn order, the first holding guard, the first enabled transition, regions in declaration order, the executor started first first | +| `seed:` | Every resolution drawn from a pseudo-random sequence the non-negative integer `n` fixes; the same seed replays the same run | +| `replay:` | The choice lines of a witness (as an `outcomes` entry's `witness` spells them, one per line up to the first blank line) followed move for move, then `reverse`'s picks one token a step; a move the run cannot make is a typed `replay refused` error naming it | + +Which of two same-step writes to one feature stands follows from the token order +the policy chose; a write conflict is reported, not resolved on its own. + +A case may pin the policy it was recorded under with `"schedule": ""`; +the harness then runs it under that policy in every test, whatever policy the +test asked for. Omitted or empty means the default. A pin that names no policy +is a schema error the test reports. + +A case whose model draws — a decision weighted by `Probability`, a call of a +`RandomFunctions` function — states the seed its draws come from with +`"modelSeed": `, which the harness sets as `%seed`/`-seed` would under +every policy of the sweep; the modeled stream is separate from the one +`seed:` shuffles tokens with, so the case's draws are the same under +`reverse`, `declared` and `seed:1`. Without it such a case refuses to draw +(the `stochastic_*` cases). + +A case may instead pin the draw policy its `RandomFunctions` calls resolve under +with `"draws": "min"`, `"max"` or `"average"`, as `%draws`/`-draws` would set it; +a fixed policy takes the point of each call's distribution it names and needs no +`modelSeed`, and the trace records the value taken as `draw = ` +(the `stochastic_draws_*` cases). Omitted or `"random"` draws from the seed. + +A case may pin the step its clock ticks by with `"clockStep": `, as +`%clock-step`/`-clock-step` would set it; every wait then comes due at the first +multiple of the step not before its end (the `clock_step_*` cases). Omitted or `0` +is the continuous clock. + +`TestExecutionConformanceUnderPolicies` runs every case under `declared` and +under `seed:1`. A case pinning no policy was recorded under the default, so its +stated result, or one of its `outcomes`, must hold under any policy; one that +differs has been pinning a scheduling artefact as *the* result. Such a case is +pinned to `reverse` — never removed from the sweep — until either the outcomes +the library admits are derived in `docs/project/behavior-semantic-oracle.md` and +the case restated as an admissible set (`action_choice_shared_message_accept`, +two accepts racing for two sends, was), or the difference is found to be a bug +and the pin stays until the fix lands (`send_identity_same_named_ports` was +pinned while the via-less `accept Ping` over-matched a transfer addressed to +`alpha.inPort`; with a via-less accept held to the receiver the transfer reaches, +`waiting` has one enabled transition and the case runs unpinned). + +The order in which orthogonal regions are entered, exited and stepped is a +drawn choice point too (`ChoiceEntryOrder`, `ChoiceExitOrder`, the per-unit +`ChoiceRegionOrder`; see `docs/internals/design/region-order-scheduling.md`), so +`seed:1` also varies the region order a `stateVisits` list or a log pins. The +cases *about* that order state the exact set of linearizations the library +admits (`state_change_region_order`, `state_parallel_broadcast`, +`state_composite_region_depth_order`, `state_fork_enters_regions_without_initial`, +…); the cases whose subject is something else — a fork through inactive +ancestors, deferral outranking a sibling region, a transition into an active +parallel ancestor, history, terminate inside a region — pin `reverse` (which, +like `declared`, takes the regions in declaration order), because their subject +is not the order and an exact set for a nested fork or an orthogonal exit runs +to dozens of interleavings once entry, exit and firing units are all drawn. +Such a pin records the declared order as the run the case describes, not as +the only run the library admits. + +A case with an admissible set also owns a `..trace.golden` for +each sweep policy (`declared`, `seed-1` — a colon is not a portable file-name +character), recording the linearization that policy takes; `-update-traces` +regenerates them beside the default golden. + +### Checking Every Schedule (`.check.expected.json`) + +An action case with an admissible set also owns a `.check.expected.json`: +what the explicit-state checker (`runtime.CheckAction`, the `check` engine) +finds when it searches every schedule of the action, derived from the library +text as the admissible set was: + +```json +{ + "verdict": "divergent", + "divergent": {"x": ["1", "2"]}, + "agreed": {"leftRan": "true", "rightRan": "true"} +} +``` + +| Field | Meaning | +|-------|---------| +| `verdict` | `no violation, exhaustive`, `no violation within bounds`, `violation` or `divergent`, as the check reports it | +| `divergent` | Every feature whose final value the schedule decides, with every value it takes, in canonical order; a feature the check finds divergent and this does not list fails the case | +| `agreed` | Features every schedule leaves with one value, and that value, checked at every final state | + +`TestCheckConformanceOracles` checks each case reduced and unreduced against +this file; `TestCheckAgreesWithExploreOverTheConformanceCorpus` compares the +final states the check reaches with the complete table `explore` tabled, and +`TestCheckWitnessesReplayOverTheConformanceCorpus` replays every witness the +check writes. A case the check refuses with a typed reason — a body paused +mid-statement, a state and an action due together — is listed in +`check_corpus_test.go` and owns no expectation. These files are not execution +fixtures: the execution harness skips them. + +### For Calculations (`InvokeCalc`) + +```json +{ + "type": "calc", + "inputs": [ + {"type": "Real", "value": 10.0}, + {"type": "Real", "value": 2.0} + ], + "result": {"type": "Real", "value": 12.0} +} +``` + +- `inputs`: ordered list of input arguments +- `result`: returned value +- `evaluate`: qualified path of the calc to invoke (see [Entry Points](#entry-points)) + +### For Constraints (`EvaluateConstraint`) + +```json +{ + "type": "constraint", + "bindings": { + "pressure": {"type": "Real", "value": 50.0}, + "temp": {"type": "Real", "value": 100.0} + }, + "satisfied": true +} +``` + +- `bindings`: variable bindings for constraint evaluation +- `satisfied`: boolean, whether constraint is satisfied +- `evaluate`: qualified path of the constraint to evaluate, for a case declaring + more than one. Omit to search the model for the first one (see + [Entry Points](#entry-points)) +- `instantiate`: qualified name of an object to materialize before evaluating, + for a case whose contract is the subject the runtime picks — a condition of a + nested definition is about the object redefining that nested feature. Omit for + a case about the declaration. + +### For Requirements (`EvaluateRequirement`) + +```json +{ + "type": "requirement", + "bindings": { + "vehicle.speed": {"type": "Real", "value": 120.0} + }, + "satisfied": true +} +``` + +- `bindings`: variable bindings for requirement evaluation +- `satisfied`: boolean, whether requirement is satisfied. `false` means a + condition evaluated to false (`ErrViolated`), not that evaluation failed. +- `evaluate`: qualified name of the element to evaluate, for a case declaring + more than one — a usage and the definition it is typed by. Omit to search the + model for the first requirement (or constraint) it declares. + +### For Satisfaction Assertions (`EvaluateSatisfaction`) + +```json +{ + "type": "satisfy", + "evaluate": "test::analysisContext", + "assertions": { + "satisfy touchdown by slowLander": true, + "not satisfy touchdown by fastLander": true + } +} +``` + +- `evaluate`: qualified name of the element stating the assertions, since + `assert satisfy r by p;` is anonymous and is reached through its owner. Omit + to evaluate every assertion in the model. +- `assertions`: expected verdict per assertion, keyed by the assertion as + written (`not ` prefixed for a negated one). `false` means the requirement + evaluated to false against the object its subject binds (`ErrViolated`), not + that evaluation failed. +- `satisfied`: the verdict, for a case stating exactly one assertion. +- `error`: text the evaluation must fail with, for a case whose contract is a + diagnostic — satisfying a requirement that states no condition. Set it + instead of a verdict. + +### For Verification Cases (`RunVerification`) + +```json +{ + "libraries": true, + "type": "verification", + "evaluate": "test::checkZeroed", + "verdict": "pass", + "subcases": {"test::plan::checkDrifted": "fail"}, + "verdicts": {"obj": "satisfied"} +} +``` + +- `verdict`: the `VerdictKind` the run of the case's body produced — `pass` or + `fail` as the library's `PassIf` calculation computed it, `inconclusive` for a + body that bound no verdict value, `error` for a body whose run could not be + carried out. +- `verdictDetail`: text the verdict carries, matched as a substring — the + message of an `error` verdict, or why an `inconclusive` one decided nothing. +- `subcases`: the verdict of each verification case the body performs, by + qualified name. The library states no roll-up of a subcase's verdict into its + parent's, so each is stated on its own. +- `evaluate`, `subject`, `inputs`, `bindings`, `outputs`, `verdicts` and `reads` + mean what they mean for an analysis case: a verification case runs the same + body and reports the same objective and assertion verdicts beside its own. + A case whose body could not run states no outputs or verdicts. + +### For Instances (`Instantiate`) + +```json +{ + "type": "instance", + "instantiate": "test::Vehicle", + "slots": { + "mass": {"type": "Real", "value": 1500.0}, + "doubled": {"type": "Real", "value": 3000.0} + }, + "constraints": { + "withinLimit": true, + "overLimit": false + } +} +``` + +- `instantiate`: qualified name of the type to instantiate +- `slots`: expected values of the instance's own slots, materialized on demand + — a default expression that reads sibling features (including through a + nested part, `mass + engine.derated`) is evaluated against this object rather + than constant-folded. Keys are slot names, not paths. +- `constraints`: expected verdict per constraint feature the instance carries, + evaluated bound to the instance. `false` means the assertion evaluated to + false (`ErrViolated`), not that evaluation failed. +- `error`: text the instantiation must fail with, for a case whose contract is a + diagnostic — a declaration valuing one feature under two of its names. Set it + instead of `slots`. +- `materialization`: what reading every feature value of the instance, and of + the objects those hold, reports — the check `-instantiate` makes over an + object. `errors` lists the text of each error in order (matched as a + substring) and `bounded` whether the walk left nesting unchecked; `{}` states + a clean, complete read. Omit it for a case whose contract is its slots alone. +- `validation`: what validating the object as a whole reports — the check + `-validate=` and `%validate` make. `verdicts` lists every verdict in + report order, root first then each held object as the walk reaches it: + + ```json + "validation": { + "verdicts": [ + {"kind": "constraint", "assertion": "assert constraint massOk", "status": "holds"}, + {"kind": "requirement", "assertion": "requirement lightEnough", "status": "holds"}, + {"kind": "constraint", "assertion": "assert constraint pressureOk", "object": "wheels[2]", + "status": "violated", "error": "evaluated to false"}, + {"kind": "satisfaction", "assertion": "satisfy strongEngine by car.engine", "object": "engine", + "status": "holds"} + ], + "valid": false + } + ``` + + `kind` is `constraint` (an asserted constraint or invariant the object + carries), `requirement` (a requirement usage it carries) or `satisfaction` (a + satisfaction assertion whose subject is an object in the tree). `assertion` is + the assertion as declared; `object` the path from the root to the object the + verdict is about, one-based for a collection element and omitted for the root. + `status` is `holds`, `violated` (the condition evaluated to false) or + `undecided` (it could not be evaluated); `error` is text the verdict's error + must carry, matched as a substring. `valid`, when stated, is whether the object + is shown valid: at least one assertion, every one holding, and a complete walk. + `bounded` states that the walk stopped at its depth bound before reaching every + held object and `unread` the text of each feature value it could not read; a + report with either is not valid even when every verdict holds, and so is one + with no verdict at all, which decided nothing. An unasserted named constraint + is not swept. + +## Diagnostics + +```json +{"diagnostics": ["expected ';' after transition"]} +``` + +A case fails on any diagnostic its model reports that it does not declare here, +matched as a substring, and on a declaration nothing reported. Omit the field for +a model that parses clean, which is what a case asserting a result should be: +declare a diagnostic only when reporting it is part of the case's contract. + +## Standard Library + +```json +{"libraries": true} +``` + +Loads the standard library into the case's index, for a case whose model names +library elements the runtime resolves — the measurement unit of a quantity +expression (`1.5 [m/s]`) is one. Omit it otherwise: a case that needs no library +is indexed from its own source alone. + +## Further Documents + +```json +{"documents": ["extent_across_documents.depot.sysml"]} +``` + +Indexes the listed `.sysml` files of this directory beside the case's own, each +as a document of its own name, for a case whose contract spans documents — an +extent reaching a usage another file declares. A listed file belongs to the case +that lists it and is no case itself; name it `..sysml`. + +## Value Format + +All values use this format: + +```json +{"type": "TypeName", "value": } +``` + +Supported types: +- `Integer`: JSON number (no decimals) +- `Real`: JSON number (may have decimals) +- `Boolean`: JSON boolean +- `String`: JSON string +- `Null`: JSON null +- `Infinity`: the unbounded `*`, which is no number and carries no `value` + (`{"type": "Infinity"}`) +- `Quantity`: JSON number, with the `unit` the magnitude is written in +- `MeasurementRef`: the `unit` a measurement reference names, and no `value` + (`{"type": "MeasurementRef", "unit": "m**2"}`) +- `CoordinateFrame`: the `text` a coordinate frame or measurement scale prints + as, its name over its axes, and no `value` + (`{"type": "CoordinateFrame", "text": "spatialCF [m, m, m]"}`) +- `CoordinateTransformation`: the `text` a transformation prints as, its name + over the frames it relates (`{"type": "CoordinateTransformation", "text": "trs (datum → lbcf)"}`) +- `Complex`: JSON number, the real part, with the imaginary part as `im` + (`{"type": "Complex", "value": 0.0, "im": 1.0}`) +- `Sequence`: the `elements` it holds, in order, instead of `value` — for a + multi-valued feature, whose order is part of its contract +- `Set`: the distinct `elements` it holds, in the canonical order a set + enumerates in (booleans, numbers, strings, quantities, enumeration literals, + objects; each class in its own order) — for a `Collections::Set`'s elements, + or any other feature the library declares unique and unordered +- `Instance`: an object, whose identity a case does not pin (no `value`) +- `Unset`: a valueless feature of a value type, holding no value (no `value`) +- `Variant`: the name of the variant a variation feature is bound to, as a JSON + string (`{"type": "Variant", "value": "cutIdeal"}`) +- `EnumLiteral`: the enumeration literal a value is, written as the enumeration + declaring it qualifies it (`{"type": "EnumLiteral", "value": "Color::red"}`) +- `Function`: the qualified name of the calc a function value is a value of + (`{"type": "Function", "value": "test::Sq"}`) +- `Metaobject`: the reflective metaobject of an element, optionally pinned to the + text it prints as, the element's qualified name and its metaclass + (`{"type": "Metaobject", "value": "meta(test::seatBelt : SysML::Systems::PartUsage)"}`) + +In place of a value, `error` states the text producing that value must fail with, +for a slot or result whose contract is a diagnostic (`{"error": "not a +measurement unit: …"}`). + +## Adding New Cases + +1. Create `.sysml` with the behavioral model +2. Create `.expected.json` with expected outcome +3. Run `go test ./internal/exec/runtime/ -run TestExecutionConformance -v` +4. If test fails but behavior is correct, verify and update expected file +5. If behavior is unimplemented, add case name to `known_failures.txt` + +## Golden Traces + +A case may also carry a `.trace.golden` recording the order in which the +case executes, checked by `TestExecutionTrace`. Calc and constraint cases record +calc evaluation: parameter binding, every sub-expression, and results. + +``` +enter calc test::scale + bind x = 3 [argument] # argument, or default when none is passed + bind factor = 4 [default] + eval feature x -> 3 # indentation is sub-expression nesting + eval feature factor -> 4 + eval operator * -> 12 +exit calc test::scale -> 12 # or `-> error: ` +``` + +Entries are canonical, never positions or addresses: parameters bind in +declaration order (inherited parameters first, at the position the declaring calc +gives them), and an unordered value such as a set renders sorted. + +`go test ./internal/exec/runtime -run TestExecutionTrace -update-traces` +regenerates every golden the harness owns in one run, so an intentional ordering +change needs no hand-editing; review every diff — a reordered entry is a +behavior change, not noise. A no-op run rewrites the same bytes and leaves +`git status` clean. + +The harness owns a golden for a case that already carries one, and for a case +whose expectation sets `"trace": true`, which is how a new case asks for one to +be written. Every owned case must execute and produce a trace: an update run +reports one that does not rather than leaving a stale golden behind. State cases +that broadcast an event over orthogonal regions have no order-stable trace yet, +so they neither carry a golden nor opt in. + +## Order Constraints + +A case may carry a `.trace.order` stating the partial order its trace must +respect, checked by `TestExecutionTrace` beside the exact golden, or instead of +one for a case that carries no golden and does not opt into one. Each line is +`a < b`: the first entry mentioning label `a` comes strictly before the first +entry mentioning label `b`. Blank lines and `#` comments are skipped; a file +with no constraint, or a line of any other shape, fails the case. + +``` +# The fork precedes both branches; the join waits for both. +split < left +split < right +left < sync +right < sync +``` + +A label is the text a trace entry names a performance or statement by, exactly +as it appears in `.trace.golden`: + +- a **performance label** is a node identifier — `split` in + `step 1: token 1@split`, `inner` in `enter action node: inner`, or a state name + `Idle` in `enter: Idle` / `enter: Idle (entry action)`. A step entry mentions + every node a token is at, so `left` and `right` are both mentioned by + `step 2: token 2@left, token 3@right`; +- a **statement label** is the text after `stmt `, `assign x` in `stmt assign x`. + +Token numbers, `eval` entries and values are not labels. Two labels first +mentioned by the same entry — two tokens stepped together — are unordered, so a +constraint between them fails in both directions; a constraint naming a label no +entry mentions fails as well. The failure reports which entries mention each +label. + +## Known Failures + +Cases in `known_failures.txt` are skipped (logged as `SKIP`). Remove from file when implemented. + +## Provenance + +Where possible, expected outcomes are derived from the OMG SysML v2 Pilot Implementation (commit 4c289b926). Cases with pilot-derived expectations are marked in comments. diff --git a/internal/core/runtime/testdata/conformance/accept_binds_signal_occurrence.expected.json b/internal/exec/runtime/testdata/conformance/accept_binds_signal_occurrence.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_binds_signal_occurrence.expected.json rename to internal/exec/runtime/testdata/conformance/accept_binds_signal_occurrence.expected.json diff --git a/internal/core/runtime/testdata/conformance/accept_binds_signal_occurrence.sysml b/internal/exec/runtime/testdata/conformance/accept_binds_signal_occurrence.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_binds_signal_occurrence.sysml rename to internal/exec/runtime/testdata/conformance/accept_binds_signal_occurrence.sysml diff --git a/internal/core/runtime/testdata/conformance/accept_part_addressed_receiver_only.expected.json b/internal/exec/runtime/testdata/conformance/accept_part_addressed_receiver_only.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_part_addressed_receiver_only.expected.json rename to internal/exec/runtime/testdata/conformance/accept_part_addressed_receiver_only.expected.json diff --git a/internal/core/runtime/testdata/conformance/accept_part_addressed_receiver_only.sysml b/internal/exec/runtime/testdata/conformance/accept_part_addressed_receiver_only.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_part_addressed_receiver_only.sysml rename to internal/exec/runtime/testdata/conformance/accept_part_addressed_receiver_only.sysml diff --git a/internal/core/runtime/testdata/conformance/accept_part_addressed_receiver_only.trace.golden b/internal/exec/runtime/testdata/conformance/accept_part_addressed_receiver_only.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_part_addressed_receiver_only.trace.golden rename to internal/exec/runtime/testdata/conformance/accept_part_addressed_receiver_only.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/accept_payload_bound_into_typed_action.expected.json b/internal/exec/runtime/testdata/conformance/accept_payload_bound_into_typed_action.expected.json new file mode 100644 index 0000000000..40fbd31b15 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/accept_payload_bound_into_typed_action.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "evaluate": "test::watcher", + "libraries": true, + "trace": true, + "outputs": { + "total": {"type": "Real", "value": 5.0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/accept_payload_bound_into_typed_action.sysml b/internal/exec/runtime/testdata/conformance/accept_payload_bound_into_typed_action.sysml new file mode 100644 index 0000000000..7c306b704f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/accept_payload_bound_into_typed_action.sysml @@ -0,0 +1,47 @@ +// The payload an accept node binds is the argument of a sibling typed action: +// `in level = msg.level` resolves msg through the enclosing action body, the +// way a nested if or loop body does, and the typed action computes from it. +package test { + private import ScalarValues::*; + + item def Warning { attribute level : Real; } + + action def Handle { + in level : Real; + out doubled : Real; + first start then compute; + action compute { + in x : Real; + out result = x * 2.0; + } + bind compute.x = level; + bind doubled = compute.result; + first compute then done; + } + + action watcher { + attribute total : Real = 0.0; + + first start; + + action sender { + send new Warning(level = 2.5) to receiver; + } + + action receiver accept msg : Warning; + + action run : Handle { in level = msg.level; } + + action keep { + assign total := run.doubled; + } + + done; + + succession first start then sender; + succession first sender then receiver; + succession first receiver then run; + succession first run then keep; + succession first keep then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/accept_payload_bound_into_typed_action.trace.golden b/internal/exec/runtime/testdata/conformance/accept_payload_bound_into_typed_action.trace.golden new file mode 100644 index 0000000000..65bcd4c757 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/accept_payload_bound_into_typed_action.trace.golden @@ -0,0 +1,20 @@ +step 1: token 1@sender +stmt send + eval literal 2.5 -> 2.5 +materialize: Warning #1 +step 2: token 1@receiver +step 3: token 1@run + eval feature msg -> instance#1 +eval chain level -> 2.5 +step 1: token 1@compute +eval feature level -> 2.5 + eval feature x -> 2.5 + eval literal 2.0 -> 2.0 +eval operator * -> 5.0 +step 2: token 1@done +step 3: no active tokens +step 4: token 1@keep +stmt assign total + eval chain doubled -> 5.0 +step 5: token 1@done +step 6: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/accept_payload_flows_from_pin.expected.json b/internal/exec/runtime/testdata/conformance/accept_payload_flows_from_pin.expected.json new file mode 100644 index 0000000000..73e24f7a27 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/accept_payload_flows_from_pin.expected.json @@ -0,0 +1,6 @@ +{ + "type": "action", + "outputs": { + "total": {"type": "Integer", "value": 16} + } +} diff --git a/internal/exec/runtime/testdata/conformance/accept_payload_flows_from_pin.sysml b/internal/exec/runtime/testdata/conformance/accept_payload_flows_from_pin.sysml new file mode 100644 index 0000000000..178fc92de4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/accept_payload_flows_from_pin.sysml @@ -0,0 +1,34 @@ +package test { + private import ScalarValues::*; + + // The payload an accept node binds is the node's own output pin: a flow out + // of `receiver.msg` delivers the accepted value to the pin it names, so a + // consumer reads it through its input rather than through the shared name. + attribute def Reading { attribute level : Integer; } + attribute def Calibrated :> Reading; + + action communicator { + attribute total : Integer = 0; + + first start; + + action sender { + send new Calibrated(level = 8) to receiver; + } + + action receiver accept msg : Reading; + + action consumer { + in value : Reading; + assign total := value.level * 2; + } + + done; + + flow receiver.msg to consumer.value; + succession first start then sender; + succession first sender then receiver; + succession first receiver then consumer; + succession first consumer then done; + } +} diff --git a/internal/core/runtime/testdata/conformance/accept_payload_nested_body.expected.json b/internal/exec/runtime/testdata/conformance/accept_payload_nested_body.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_payload_nested_body.expected.json rename to internal/exec/runtime/testdata/conformance/accept_payload_nested_body.expected.json diff --git a/internal/core/runtime/testdata/conformance/accept_payload_nested_body.sysml b/internal/exec/runtime/testdata/conformance/accept_payload_nested_body.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_payload_nested_body.sysml rename to internal/exec/runtime/testdata/conformance/accept_payload_nested_body.sysml diff --git a/internal/core/runtime/testdata/conformance/accept_payload_read_before_accept.expected.json b/internal/exec/runtime/testdata/conformance/accept_payload_read_before_accept.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_payload_read_before_accept.expected.json rename to internal/exec/runtime/testdata/conformance/accept_payload_read_before_accept.expected.json diff --git a/internal/core/runtime/testdata/conformance/accept_payload_read_before_accept.sysml b/internal/exec/runtime/testdata/conformance/accept_payload_read_before_accept.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_payload_read_before_accept.sysml rename to internal/exec/runtime/testdata/conformance/accept_payload_read_before_accept.sysml diff --git a/internal/core/runtime/testdata/conformance/accept_payload_read_before_accept.trace.golden b/internal/exec/runtime/testdata/conformance/accept_payload_read_before_accept.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_payload_read_before_accept.trace.golden rename to internal/exec/runtime/testdata/conformance/accept_payload_read_before_accept.trace.golden diff --git a/internal/core/runtime/testdata/conformance/accept_payload_shadows_outer_feature.expected.json b/internal/exec/runtime/testdata/conformance/accept_payload_shadows_outer_feature.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_payload_shadows_outer_feature.expected.json rename to internal/exec/runtime/testdata/conformance/accept_payload_shadows_outer_feature.expected.json diff --git a/internal/core/runtime/testdata/conformance/accept_payload_shadows_outer_feature.sysml b/internal/exec/runtime/testdata/conformance/accept_payload_shadows_outer_feature.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_payload_shadows_outer_feature.sysml rename to internal/exec/runtime/testdata/conformance/accept_payload_shadows_outer_feature.sysml diff --git a/internal/core/runtime/testdata/conformance/accept_port_addressed_via_only.expected.json b/internal/exec/runtime/testdata/conformance/accept_port_addressed_via_only.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_port_addressed_via_only.expected.json rename to internal/exec/runtime/testdata/conformance/accept_port_addressed_via_only.expected.json diff --git a/internal/core/runtime/testdata/conformance/accept_port_addressed_via_only.sysml b/internal/exec/runtime/testdata/conformance/accept_port_addressed_via_only.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_port_addressed_via_only.sysml rename to internal/exec/runtime/testdata/conformance/accept_port_addressed_via_only.sysml diff --git a/internal/core/runtime/testdata/conformance/accept_port_addressed_via_only.trace.golden b/internal/exec/runtime/testdata/conformance/accept_port_addressed_via_only.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_port_addressed_via_only.trace.golden rename to internal/exec/runtime/testdata/conformance/accept_port_addressed_via_only.trace.golden diff --git a/internal/core/runtime/testdata/conformance/accept_qualified_signal_type.expected.json b/internal/exec/runtime/testdata/conformance/accept_qualified_signal_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_qualified_signal_type.expected.json rename to internal/exec/runtime/testdata/conformance/accept_qualified_signal_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/accept_qualified_signal_type.sysml b/internal/exec/runtime/testdata/conformance/accept_qualified_signal_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_qualified_signal_type.sysml rename to internal/exec/runtime/testdata/conformance/accept_qualified_signal_type.sysml diff --git a/internal/core/runtime/testdata/conformance/accept_statement_via_port.expected.json b/internal/exec/runtime/testdata/conformance/accept_statement_via_port.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_statement_via_port.expected.json rename to internal/exec/runtime/testdata/conformance/accept_statement_via_port.expected.json diff --git a/internal/core/runtime/testdata/conformance/accept_statement_via_port.sysml b/internal/exec/runtime/testdata/conformance/accept_statement_via_port.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_statement_via_port.sysml rename to internal/exec/runtime/testdata/conformance/accept_statement_via_port.sysml diff --git a/internal/core/runtime/testdata/conformance/accept_then_transition.expected.json b/internal/exec/runtime/testdata/conformance/accept_then_transition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_then_transition.expected.json rename to internal/exec/runtime/testdata/conformance/accept_then_transition.expected.json diff --git a/internal/core/runtime/testdata/conformance/accept_then_transition.sysml b/internal/exec/runtime/testdata/conformance/accept_then_transition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/accept_then_transition.sysml rename to internal/exec/runtime/testdata/conformance/accept_then_transition.sysml diff --git a/internal/exec/runtime/testdata/conformance/accept_via_bound_context_port.expected.json b/internal/exec/runtime/testdata/conformance/accept_via_bound_context_port.expected.json new file mode 100644 index 0000000000..c1c01fdf28 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/accept_via_bound_context_port.expected.json @@ -0,0 +1,22 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "AcceptViaBoundContext::Rig", + "materialization": {}, + "objects": [ + { + "path": "box.peas", + "events": [{"signal": "Go"}], + "finalState": "ready", + "stateVisits": ["idle", "commanding", "ready"], + "slots": { + "lastAck": {"type": "Instance"} + } + }, + { + "path": "m1cs", + "finalState": "installed", + "stateVisits": ["idle", "installed"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/accept_via_bound_context_port.sysml b/internal/exec/runtime/testdata/conformance/accept_via_bound_context_port.sysml new file mode 100644 index 0000000000..9c29a23e30 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/accept_via_bound_context_port.sysml @@ -0,0 +1,95 @@ +// A query goes out `via context.toM1` and its acknowledgement is taken `accept ... via context.toM1`, +// context being a `ref` parameter bound to the performing object (SysML v2 §7.16, §7.17). +package AcceptViaBoundContext { + item def Go; + item def Query; + item def Ack; + + port def QueryOut { + out item query : Query; + in item ack : Ack; + } + + port def QueryIn { + in item query : Query; + out item ack : Ack; + } + + part def Peas { + port toM1 : QueryOut; + } + + action def Handshake { + in ref context : Peas; + out ref item ack : Ack; + first start then split; + fork split; + first split then ask; + first split then heard; + action ask { + send new Query() via context.toM1; + } + action heard accept msg : Ack via context.toM1; + first ask then meet; + first heard then meet; + join meet; + first meet then keep; + action keep { + assign ack := msg; + } + first keep then done; + } + + part def Commander :> Peas { + ref item lastAck : Ack[0..1]; + + state def Life { + entry; then idle; + state idle; + transition first idle accept Go then commanding; + state commanding { + do action run { + first start then call; + action call : Handshake; + bind call.context = this; + first call then keep; + action keep { + assign lastAck := call.ack; + } + first keep then done; + } + } + state ready; + transition first commanding then ready; + } + + exhibit state life : Life; + } + + part def Box { + port toM1 : QueryOut; + part peas : Commander; + bind toM1 = peas.toM1; + } + + part def M1CS { + port cmd : QueryIn; + + exhibit state life { + entry; then idle; + state idle; + state installed; + + transition first idle + accept Query via cmd + do send new Ack() via cmd + then installed; + } + } + + part def Rig { + part box : Box; + part m1cs : M1CS; + connect box.toM1 to m1cs.cmd; + } +} diff --git a/internal/core/runtime/testdata/conformance/action_accept_message.expected.json b/internal/exec/runtime/testdata/conformance/action_accept_message.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_message.expected.json rename to internal/exec/runtime/testdata/conformance/action_accept_message.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_accept_message.sysml b/internal/exec/runtime/testdata/conformance/action_accept_message.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_message.sysml rename to internal/exec/runtime/testdata/conformance/action_accept_message.sysml diff --git a/internal/core/runtime/testdata/conformance/action_accept_subsets_event.expected.json b/internal/exec/runtime/testdata/conformance/action_accept_subsets_event.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_subsets_event.expected.json rename to internal/exec/runtime/testdata/conformance/action_accept_subsets_event.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_accept_subsets_event.sysml b/internal/exec/runtime/testdata/conformance/action_accept_subsets_event.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_subsets_event.sysml rename to internal/exec/runtime/testdata/conformance/action_accept_subsets_event.sysml diff --git a/internal/core/runtime/testdata/conformance/action_accept_subsets_event.trace.golden b/internal/exec/runtime/testdata/conformance/action_accept_subsets_event.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_subsets_event.trace.golden rename to internal/exec/runtime/testdata/conformance/action_accept_subsets_event.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_accept_suspends_until_message.expected.json b/internal/exec/runtime/testdata/conformance/action_accept_suspends_until_message.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_suspends_until_message.expected.json rename to internal/exec/runtime/testdata/conformance/action_accept_suspends_until_message.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_accept_suspends_until_message.sysml b/internal/exec/runtime/testdata/conformance/action_accept_suspends_until_message.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_suspends_until_message.sysml rename to internal/exec/runtime/testdata/conformance/action_accept_suspends_until_message.sysml diff --git a/internal/core/runtime/testdata/conformance/action_accept_suspends_until_message.trace.golden b/internal/exec/runtime/testdata/conformance/action_accept_suspends_until_message.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_suspends_until_message.trace.golden rename to internal/exec/runtime/testdata/conformance/action_accept_suspends_until_message.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_accept_two_waiters.expected.json b/internal/exec/runtime/testdata/conformance/action_accept_two_waiters.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_two_waiters.expected.json rename to internal/exec/runtime/testdata/conformance/action_accept_two_waiters.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_accept_two_waiters.sysml b/internal/exec/runtime/testdata/conformance/action_accept_two_waiters.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_two_waiters.sysml rename to internal/exec/runtime/testdata/conformance/action_accept_two_waiters.sysml diff --git a/internal/core/runtime/testdata/conformance/action_accept_two_waiters.trace.golden b/internal/exec/runtime/testdata/conformance/action_accept_two_waiters.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_two_waiters.trace.golden rename to internal/exec/runtime/testdata/conformance/action_accept_two_waiters.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_accept_when_trigger.expected.json b/internal/exec/runtime/testdata/conformance/action_accept_when_trigger.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_when_trigger.expected.json rename to internal/exec/runtime/testdata/conformance/action_accept_when_trigger.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_accept_when_trigger.sysml b/internal/exec/runtime/testdata/conformance/action_accept_when_trigger.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_when_trigger.sysml rename to internal/exec/runtime/testdata/conformance/action_accept_when_trigger.sysml diff --git a/internal/core/runtime/testdata/conformance/action_accept_when_trigger.trace.golden b/internal/exec/runtime/testdata/conformance/action_accept_when_trigger.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_accept_when_trigger.trace.golden rename to internal/exec/runtime/testdata/conformance/action_accept_when_trigger.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_attribute_without_performer.expected.json b/internal/exec/runtime/testdata/conformance/action_attribute_without_performer.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_attribute_without_performer.expected.json rename to internal/exec/runtime/testdata/conformance/action_attribute_without_performer.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_attribute_without_performer.sysml b/internal/exec/runtime/testdata/conformance/action_attribute_without_performer.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_attribute_without_performer.sysml rename to internal/exec/runtime/testdata/conformance/action_attribute_without_performer.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_alternating_branch_nodes.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_alternating_branch_nodes.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_alternating_branch_nodes.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_alternating_branch_nodes.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_alternating_branch_nodes.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_alternating_branch_nodes.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_alternating_branch_nodes.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_alternating_branch_nodes.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_else_branch_same_name.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_else_branch_same_name.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_else_branch_same_name.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_else_branch_same_name.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_else_branch_same_name.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_else_branch_same_name.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_else_branch_same_name.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_else_branch_same_name.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_if_branch.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_if_branch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_if_branch.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_if_branch.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_if_branch.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_if_branch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_if_branch.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_if_branch.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_if_branch.trace.golden b/internal/exec/runtime/testdata/conformance/action_block_flow_if_branch.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_if_branch.trace.golden rename to internal/exec/runtime/testdata/conformance/action_block_flow_if_branch.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_if_branch_bindings.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_if_branch_bindings.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_if_branch_bindings.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_if_branch_bindings.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_if_branch_bindings.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_if_branch_bindings.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_if_branch_bindings.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_if_branch_bindings.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.trace.golden b/internal/exec/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.trace.golden rename to internal/exec/runtime/testdata/conformance/action_block_flow_if_branch_own_flow.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_loop_bindings.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_loop_bindings.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_loop_bindings.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_loop_bindings.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_loop_bindings.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_loop_bindings.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_loop_bindings.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_loop_bindings.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_loop_bindings.trace.golden b/internal/exec/runtime/testdata/conformance/action_block_flow_loop_bindings.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_loop_bindings.trace.golden rename to internal/exec/runtime/testdata/conformance/action_block_flow_loop_bindings.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_loop_node_frames.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_loop_node_frames.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_loop_node_frames.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_loop_node_frames.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_loop_node_frames.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_loop_node_frames.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_loop_node_frames.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_loop_node_frames.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_nested_action.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_nested_action.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_nested_action.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_nested_action.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_nested_action.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_nested_action.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_nested_action.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_nested_action.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_nested_action.trace.golden b/internal/exec/runtime/testdata/conformance/action_block_flow_nested_action.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_nested_action.trace.golden rename to internal/exec/runtime/testdata/conformance/action_block_flow_nested_action.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_nested_pins.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_nested_pins.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_nested_pins.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_nested_pins.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_nested_pins.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_nested_pins.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_nested_pins.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_nested_pins.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_node_own_flow.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_node_own_flow.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_node_own_flow.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_node_own_flow.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_node_own_flow.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_node_own_flow.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_node_own_flow.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_node_own_flow.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_perform_in_loop.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_perform_in_loop.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_perform_in_loop.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_perform_in_loop.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_perform_in_loop.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_perform_in_loop.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_perform_in_loop.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_perform_in_loop.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_perform_in_loop.trace.golden b/internal/exec/runtime/testdata/conformance/action_block_flow_perform_in_loop.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_perform_in_loop.trace.golden rename to internal/exec/runtime/testdata/conformance/action_block_flow_perform_in_loop.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_sibling_pins.expected.json b/internal/exec/runtime/testdata/conformance/action_block_flow_sibling_pins.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_sibling_pins.expected.json rename to internal/exec/runtime/testdata/conformance/action_block_flow_sibling_pins.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_sibling_pins.sysml b/internal/exec/runtime/testdata/conformance/action_block_flow_sibling_pins.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_sibling_pins.sysml rename to internal/exec/runtime/testdata/conformance/action_block_flow_sibling_pins.sysml diff --git a/internal/core/runtime/testdata/conformance/action_block_flow_sibling_pins.trace.golden b/internal/exec/runtime/testdata/conformance/action_block_flow_sibling_pins.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_block_flow_sibling_pins.trace.golden rename to internal/exec/runtime/testdata/conformance/action_block_flow_sibling_pins.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_body_declarer_scope.expected.json b/internal/exec/runtime/testdata/conformance/action_body_declarer_scope.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_declarer_scope.expected.json rename to internal/exec/runtime/testdata/conformance/action_body_declarer_scope.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_body_declarer_scope.sysml b/internal/exec/runtime/testdata/conformance/action_body_declarer_scope.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_declarer_scope.sysml rename to internal/exec/runtime/testdata/conformance/action_body_declarer_scope.sysml diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_branch_in_loop.expected.json b/internal/exec/runtime/testdata/conformance/action_body_flow_branch_in_loop.expected.json new file mode 100644 index 0000000000..9ca97d70af --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_branch_in_loop.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "total": {"type": "Integer", "value": 50} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_branch_in_loop.sysml b/internal/exec/runtime/testdata/conformance/action_body_flow_branch_in_loop.sysml new file mode 100644 index 0000000000..dd9fb5f9d5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_branch_in_loop.sysml @@ -0,0 +1,20 @@ +package test { + private import ScalarValues::*; + + action run { + attribute total : Integer = 0; + first start; + action iterate { + for i in 1..3 { + if i > 1 { + action d { assign total := total + e.v; } + action e { out v : Integer; assign v := i * 10; } + succession e then d; + } + } + } + done; + succession first start then iterate; + succession first iterate then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_branch_in_loop.trace.golden b/internal/exec/runtime/testdata/conformance/action_body_flow_branch_in_loop.trace.golden new file mode 100644 index 0000000000..07b969d706 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_branch_in_loop.trace.golden @@ -0,0 +1,42 @@ +step 1: token 1@iterate +stmt for i + eval literal 1 -> 1 + eval literal 3 -> 3 + eval operator .. -> (1, 2, 3) + iteration 1 + stmt if + eval feature i -> 1 + eval literal 1 -> 1 + eval operator > -> false + iteration 2 + stmt if + eval feature i -> 2 + eval literal 1 -> 1 + eval operator > -> true +enter action node: branch body of action node iterate + stmt assign v + eval feature i -> 2 + eval literal 10 -> 10 + eval operator * -> 20 + stmt assign total + eval feature total -> 0 + eval chain v -> 20 + eval operator + -> 20 +leave action node: branch body of action node iterate + iteration 3 + stmt if + eval feature i -> 3 + eval literal 1 -> 1 + eval operator > -> true +enter action node: branch body of action node iterate + stmt assign v + eval feature i -> 3 + eval literal 10 -> 10 + eval operator * -> 30 + stmt assign total + eval feature total -> 20 + eval chain v -> 30 + eval operator + -> 50 +leave action node: branch body of action node iterate +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_fork_join.expected.json b/internal/exec/runtime/testdata/conformance/action_body_flow_fork_join.expected.json new file mode 100644 index 0000000000..61bac14943 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_fork_join.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "total": {"type": "Integer", "value": 33} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_fork_join.sysml b/internal/exec/runtime/testdata/conformance/action_body_flow_fork_join.sysml new file mode 100644 index 0000000000..7b09de87df --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_fork_join.sysml @@ -0,0 +1,26 @@ +package test { + private import ScalarValues::*; + + action run { + attribute total : Integer = 0; + first start; + action iterate { + for i in 1..2 { + action b { out v : Integer; assign v := i * 10; } + action a { out v : Integer; assign v := i; } + first f; + fork f; + succession f then a; + succession f then b; + join j; + succession a then j; + succession b then j; + succession j then c; + action c { assign total := total + a.v + b.v; } + } + } + done; + succession first start then iterate; + succession first iterate then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_fork_join.trace.golden b/internal/exec/runtime/testdata/conformance/action_body_flow_fork_join.trace.golden new file mode 100644 index 0000000000..1c0f1aba38 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_fork_join.trace.golden @@ -0,0 +1,39 @@ +step 1: token 1@iterate +stmt for i + eval literal 1 -> 1 + eval literal 2 -> 2 + eval operator .. -> (1, 2) + iteration 1 +enter action node: loop body of action node iterate + stmt assign v + eval feature i -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + stmt assign v + eval feature i -> 1 +choice step 2: tokens 3@a, 4@b (unordered; took 4@b first) + stmt assign total + eval feature total -> 0 + eval chain v -> 1 + eval operator + -> 1 + eval chain v -> 10 + eval operator + -> 11 +leave action node: loop body of action node iterate + iteration 2 +enter action node: loop body of action node iterate + stmt assign v + eval feature i -> 2 + eval literal 10 -> 10 + eval operator * -> 20 + stmt assign v + eval feature i -> 2 +choice step 2: tokens 7@a, 8@b (unordered; took 8@b first) + stmt assign total + eval feature total -> 11 + eval chain v -> 2 + eval operator + -> 13 + eval chain v -> 20 + eval operator + -> 33 +leave action node: loop body of action node iterate +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_succession.expected.json b/internal/exec/runtime/testdata/conformance/action_body_flow_succession.expected.json new file mode 100644 index 0000000000..77281d11f7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_succession.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "log": {"type": "Integer", "value": 1212} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_succession.sysml b/internal/exec/runtime/testdata/conformance/action_body_flow_succession.sysml new file mode 100644 index 0000000000..519ec97578 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_succession.sysml @@ -0,0 +1,22 @@ +package test { + private import ScalarValues::*; + + action run { + attribute log : Integer = 0; + + first start; + + action iterate { + for i in 1..2 { + action b { assign log := log * 10 + 2; } + action a { assign log := log * 10 + 1; } + succession a then b; + } + } + + done; + + succession first start then iterate; + succession first iterate then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_succession.trace.golden b/internal/exec/runtime/testdata/conformance/action_body_flow_succession.trace.golden new file mode 100644 index 0000000000..e30172b8fa --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_succession.trace.golden @@ -0,0 +1,37 @@ +step 1: token 1@iterate +stmt for i + eval literal 1 -> 1 + eval literal 2 -> 2 + eval operator .. -> (1, 2) + iteration 1 +enter action node: loop body of action node iterate + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +leave action node: loop body of action node iterate + iteration 2 +enter action node: loop body of action node iterate + stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 1 -> 1 + eval operator + -> 121 + stmt assign log + eval feature log -> 121 + eval literal 10 -> 10 + eval operator * -> 1210 + eval literal 2 -> 2 + eval operator + -> 1212 +leave action node: loop body of action node iterate +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_node.expected.json b/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_node.expected.json new file mode 100644 index 0000000000..b52aba7f14 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_node.expected.json @@ -0,0 +1,6 @@ +{ + "type": "action", + "outputs": { + "sum": {"type": "Integer", "value": 9} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_node.sysml b/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_node.sysml new file mode 100644 index 0000000000..986673407c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_node.sysml @@ -0,0 +1,22 @@ +package test { + private import ScalarValues::*; + + action run { + attribute sum : Integer = 0; + first start; + action iterate { + for i in 1..3 { + action a { + assign sum := sum + i; + terminate; + assign sum := sum + 1000; + } + action b { assign sum := sum + 1; } + succession a then b; + } + } + done; + succession first start then iterate; + succession first iterate then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_statement.expected.json b/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_statement.expected.json new file mode 100644 index 0000000000..5ad417e5cc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_statement.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "outputs": { + "sum": {"type": "Integer", "value": 1}, + "after": {"type": "Integer", "value": 0}, + "completed": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_statement.sysml b/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_statement.sysml new file mode 100644 index 0000000000..504ecaa5d2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_terminate_statement.sysml @@ -0,0 +1,18 @@ +package test { + private import ScalarValues::*; + + action run { + attribute sum : Integer = 0; + attribute after : Integer = 0; + attribute completed : Integer = 0; + first start; + then action iterate { + for i in 1..3 { + action a { assign sum := sum + i; } then terminate; + } + assign after := 1; + } + then action finish { assign completed := 1; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_while_attribute.expected.json b/internal/exec/runtime/testdata/conformance/action_body_flow_while_attribute.expected.json new file mode 100644 index 0000000000..3b3b407f86 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_while_attribute.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "outputs": { + "total": {"type": "Integer", "value": 12}, + "i": {"type": "Integer", "value": 4} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_flow_while_attribute.sysml b/internal/exec/runtime/testdata/conformance/action_body_flow_while_attribute.sysml new file mode 100644 index 0000000000..deda3a9041 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_flow_while_attribute.sysml @@ -0,0 +1,23 @@ +package test { + private import ScalarValues::*; + + action run { + attribute i : Integer = 1; + attribute total : Integer = 0; + first start; + action iterate { + while i <= 3 { + attribute step : Integer = i * 2; + action a { out v : Integer; assign v := step; } + action b { + assign total := total + a.v; + assign i := i + 1; + } + succession a then b; + } + } + done; + succession first start then iterate; + succession first iterate then done; + } +} diff --git a/internal/core/runtime/testdata/conformance/action_body_local_calc_usage.expected.json b/internal/exec/runtime/testdata/conformance/action_body_local_calc_usage.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_local_calc_usage.expected.json rename to internal/exec/runtime/testdata/conformance/action_body_local_calc_usage.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_body_local_calc_usage.sysml b/internal/exec/runtime/testdata/conformance/action_body_local_calc_usage.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_local_calc_usage.sysml rename to internal/exec/runtime/testdata/conformance/action_body_local_calc_usage.sysml diff --git a/internal/core/runtime/testdata/conformance/action_body_local_declared_type.expected.json b/internal/exec/runtime/testdata/conformance/action_body_local_declared_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_local_declared_type.expected.json rename to internal/exec/runtime/testdata/conformance/action_body_local_declared_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_body_local_declared_type.sysml b/internal/exec/runtime/testdata/conformance/action_body_local_declared_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_local_declared_type.sysml rename to internal/exec/runtime/testdata/conformance/action_body_local_declared_type.sysml diff --git a/internal/core/runtime/testdata/conformance/action_body_local_redefined_count.expected.json b/internal/exec/runtime/testdata/conformance/action_body_local_redefined_count.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_local_redefined_count.expected.json rename to internal/exec/runtime/testdata/conformance/action_body_local_redefined_count.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_body_local_redefined_count.sysml b/internal/exec/runtime/testdata/conformance/action_body_local_redefined_count.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_local_redefined_count.sysml rename to internal/exec/runtime/testdata/conformance/action_body_local_redefined_count.sysml diff --git a/internal/exec/runtime/testdata/conformance/action_body_node_pins_from_loop_variable.expected.json b/internal/exec/runtime/testdata/conformance/action_body_node_pins_from_loop_variable.expected.json new file mode 100644 index 0000000000..61d5c24106 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_node_pins_from_loop_variable.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "outputs": { + "sum": {"type": "Integer", "value": 106}, + "log": {"type": "Integer", "value": 123} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_node_pins_from_loop_variable.sysml b/internal/exec/runtime/testdata/conformance/action_body_node_pins_from_loop_variable.sysml new file mode 100644 index 0000000000..efe005505b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_node_pins_from_loop_variable.sysml @@ -0,0 +1,34 @@ +package test { + private import ScalarValues::*; + + action run { + attribute sum : Integer = 0; + attribute log : Integer = 0; + + first start; + + action iterate { + for i in 1..3 { + action a { + in factor = i; + assign sum := sum + factor; + } + then action b { + assign log := log * 10 + i; + } + then action c { + if i == 2 { + action d { + assign sum := sum + 100; + } + } + } + } + } + + done; + + succession first start then iterate; + succession first iterate then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_node_terminate.expected.json b/internal/exec/runtime/testdata/conformance/action_body_node_terminate.expected.json new file mode 100644 index 0000000000..3a592882d8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_node_terminate.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "outputs": { + "sum": {"type": "Integer", "value": 6}, + "after": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_body_node_terminate.sysml b/internal/exec/runtime/testdata/conformance/action_body_node_terminate.sysml new file mode 100644 index 0000000000..1ade2b136f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_body_node_terminate.sysml @@ -0,0 +1,26 @@ +package test { + private import ScalarValues::*; + + action run { + attribute sum : Integer = 0; + attribute after : Integer = 0; + + first start; + + action iterate { + for i in 1..3 { + action a { + assign sum := sum + i; + terminate; + assign sum := sum + 1000; + } + assign after := after + 1; + } + } + + done; + + succession first start then iterate; + succession first iterate then done; + } +} diff --git a/internal/core/runtime/testdata/conformance/action_body_package_member.expected.json b/internal/exec/runtime/testdata/conformance/action_body_package_member.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_package_member.expected.json rename to internal/exec/runtime/testdata/conformance/action_body_package_member.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_body_package_member.sysml b/internal/exec/runtime/testdata/conformance/action_body_package_member.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_package_member.sysml rename to internal/exec/runtime/testdata/conformance/action_body_package_member.sysml diff --git a/internal/core/runtime/testdata/conformance/action_body_quantity_descent.expected.json b/internal/exec/runtime/testdata/conformance/action_body_quantity_descent.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_quantity_descent.expected.json rename to internal/exec/runtime/testdata/conformance/action_body_quantity_descent.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_body_quantity_descent.sysml b/internal/exec/runtime/testdata/conformance/action_body_quantity_descent.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_quantity_descent.sysml rename to internal/exec/runtime/testdata/conformance/action_body_quantity_descent.sysml diff --git a/internal/core/runtime/testdata/conformance/action_body_shadows_enclosing_scope.expected.json b/internal/exec/runtime/testdata/conformance/action_body_shadows_enclosing_scope.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_shadows_enclosing_scope.expected.json rename to internal/exec/runtime/testdata/conformance/action_body_shadows_enclosing_scope.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_body_shadows_enclosing_scope.sysml b/internal/exec/runtime/testdata/conformance/action_body_shadows_enclosing_scope.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_body_shadows_enclosing_scope.sysml rename to internal/exec/runtime/testdata/conformance/action_body_shadows_enclosing_scope.sysml diff --git a/internal/exec/runtime/testdata/conformance/action_case_step_waits_on_clock.expected.json b/internal/exec/runtime/testdata/conformance/action_case_step_waits_on_clock.expected.json new file mode 100644 index 0000000000..1e88090ec2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_case_step_waits_on_clock.expected.json @@ -0,0 +1,10 @@ +{ + "libraries": true, + "type": "action", + "evaluate": "test::Config", + "trace": true, + "outputs": { + "verdict": {"type": "EnumLiteral", "value": "VerdictKind::pass"}, + "ended": {"type": "Real", "value": 60.0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_case_step_waits_on_clock.sysml b/internal/exec/runtime/testdata/conformance/action_case_step_waits_on_clock.sysml new file mode 100644 index 0000000000..1d9d7d6911 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_case_step_waits_on_clock.sysml @@ -0,0 +1,35 @@ +// A case performed as a step of an action body may itself wait on the clock +// (`accept after`): the performing action lists the case's wait among its own, +// so the clock advances to the instant and the case's later steps run there, +// then the action goes on. +package test { + private import ScalarValues::*; + private import SI::*; + private import VerificationCases::*; + + part def Scenario { + attribute log : Real default = 0.0; + attribute arrived : Real default = -1.0; + } + + verification def Scene { + subject context : Scenario; + action wait accept after 60.0 [s]; + then action mark { + assign context.log := context.log + 1.0; + assign context.arrived := localClock.currentTime; + } + return verdict : VerdictKind = VerdictKind::pass; + } + + action def Config { + part target : Scenario; + out verdict : VerdictKind; + out ended : Real; + verification run : Scene { subject context = target; } + then action after { + assign verdict := run.verdict; + assign ended := localClock.currentTime; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_case_step_waits_on_clock.trace.golden b/internal/exec/runtime/testdata/conformance/action_case_step_waits_on_clock.trace.golden new file mode 100644 index 0000000000..ec670ec2c2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_case_step_waits_on_clock.trace.golden @@ -0,0 +1,32 @@ +enter verification test::Config::run +materialize: target #1 + eval feature target -> instance#1 + bind context = instance#1 [default] + stmt action body +enter action node: verification test::Config::run + eval literal 60.0 -> 60.0 + eval index -> 60.0 [s] + stmt assign context.log + eval feature context -> instance#1 + eval chain log -> 0.0 + eval literal 1.0 -> 1.0 + eval operator + -> 1.0 + eval feature context -> instance#1 + stmt assign context.arrived +materialize: universalClock #2 + eval feature universalClock -> instance#2 + eval feature localClock -> instance#2 + eval chain currentTime -> 60.0 + eval feature context -> instance#1 +leave action node: verification test::Config::run + stmt return + eval feature VerdictKind::pass -> VerdictKind::pass +exit verification test::Config::run -> VerdictKind::pass +step 1: token 1@after +stmt assign verdict + eval chain verdict -> VerdictKind::pass +stmt assign ended + eval feature universalClock -> instance#2 + eval feature localClock -> instance#2 + eval chain currentTime -> 60.0 +step 2: no active tokens diff --git a/internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.check.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.sysml b/internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.sysml rename to internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.sysml diff --git a/internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_chained_write_conflict.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_chained_write_conflict.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.check.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.sysml b/internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.sysml rename to internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.sysml diff --git a/internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_decision_overlapping_guards.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_decision_overlapping_guards.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_fork_token_order.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_fork_token_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_fork_token_order.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_fork_token_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_fork_token_order.sysml b/internal/exec/runtime/testdata/conformance/action_choice_fork_token_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_fork_token_order.sysml rename to internal/exec/runtime/testdata/conformance/action_choice_fork_token_order.sysml diff --git a/internal/core/runtime/testdata/conformance/action_choice_fork_token_order.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_fork_token_order.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_fork_token_order.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_fork_token_order.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_fork_token_order.trace.order b/internal/exec/runtime/testdata/conformance/action_choice_fork_token_order.trace.order similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_fork_token_order.trace.order rename to internal/exec/runtime/testdata/conformance/action_choice_fork_token_order.trace.order diff --git a/internal/core/runtime/testdata/conformance/action_choice_performer_write_conflict.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_performer_write_conflict.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_performer_write_conflict.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_performer_write_conflict.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_performer_write_conflict.sysml b/internal/exec/runtime/testdata/conformance/action_choice_performer_write_conflict.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_performer_write_conflict.sysml rename to internal/exec/runtime/testdata/conformance/action_choice_performer_write_conflict.sysml diff --git a/internal/core/runtime/testdata/conformance/action_choice_performer_write_conflict.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_performer_write_conflict.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_performer_write_conflict.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_performer_write_conflict.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.check.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.sysml b/internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.sysml rename to internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.sysml diff --git a/internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_same_step_write_conflict.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_same_step_write_conflict.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.check.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.sysml b/internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.sysml rename to internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.sysml diff --git a/internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.trace.order b/internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.trace.order similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_shared_message_accept.trace.order rename to internal/exec/runtime/testdata/conformance/action_choice_shared_message_accept.trace.order diff --git a/internal/core/runtime/testdata/conformance/action_choice_unevaluable_guard.expected.json b/internal/exec/runtime/testdata/conformance/action_choice_unevaluable_guard.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_unevaluable_guard.expected.json rename to internal/exec/runtime/testdata/conformance/action_choice_unevaluable_guard.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_choice_unevaluable_guard.sysml b/internal/exec/runtime/testdata/conformance/action_choice_unevaluable_guard.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_unevaluable_guard.sysml rename to internal/exec/runtime/testdata/conformance/action_choice_unevaluable_guard.sysml diff --git a/internal/core/runtime/testdata/conformance/action_choice_unevaluable_guard.trace.golden b/internal/exec/runtime/testdata/conformance/action_choice_unevaluable_guard.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_choice_unevaluable_guard.trace.golden rename to internal/exec/runtime/testdata/conformance/action_choice_unevaluable_guard.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_decision_else_branch.expected.json b/internal/exec/runtime/testdata/conformance/action_decision_else_branch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_else_branch.expected.json rename to internal/exec/runtime/testdata/conformance/action_decision_else_branch.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_decision_else_branch.sysml b/internal/exec/runtime/testdata/conformance/action_decision_else_branch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_else_branch.sysml rename to internal/exec/runtime/testdata/conformance/action_decision_else_branch.sysml diff --git a/internal/core/runtime/testdata/conformance/action_decision_else_branch.trace.golden b/internal/exec/runtime/testdata/conformance/action_decision_else_branch.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_else_branch.trace.golden rename to internal/exec/runtime/testdata/conformance/action_decision_else_branch.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_decision_else_done.expected.json b/internal/exec/runtime/testdata/conformance/action_decision_else_done.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_else_done.expected.json rename to internal/exec/runtime/testdata/conformance/action_decision_else_done.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_decision_else_done.sysml b/internal/exec/runtime/testdata/conformance/action_decision_else_done.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_else_done.sysml rename to internal/exec/runtime/testdata/conformance/action_decision_else_done.sysml diff --git a/internal/core/runtime/testdata/conformance/action_decision_guarded_branch.expected.json b/internal/exec/runtime/testdata/conformance/action_decision_guarded_branch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_guarded_branch.expected.json rename to internal/exec/runtime/testdata/conformance/action_decision_guarded_branch.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_decision_guarded_branch.sysml b/internal/exec/runtime/testdata/conformance/action_decision_guarded_branch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_guarded_branch.sysml rename to internal/exec/runtime/testdata/conformance/action_decision_guarded_branch.sysml diff --git a/internal/core/runtime/testdata/conformance/action_decision_guarded_branch.trace.golden b/internal/exec/runtime/testdata/conformance/action_decision_guarded_branch.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_guarded_branch.trace.golden rename to internal/exec/runtime/testdata/conformance/action_decision_guarded_branch.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_decision_implied_done_parallel.expected.json b/internal/exec/runtime/testdata/conformance/action_decision_implied_done_parallel.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_implied_done_parallel.expected.json rename to internal/exec/runtime/testdata/conformance/action_decision_implied_done_parallel.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_decision_implied_done_parallel.sysml b/internal/exec/runtime/testdata/conformance/action_decision_implied_done_parallel.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_implied_done_parallel.sysml rename to internal/exec/runtime/testdata/conformance/action_decision_implied_done_parallel.sysml diff --git a/internal/core/runtime/testdata/conformance/action_decision_merge_guarded_branch.expected.json b/internal/exec/runtime/testdata/conformance/action_decision_merge_guarded_branch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_merge_guarded_branch.expected.json rename to internal/exec/runtime/testdata/conformance/action_decision_merge_guarded_branch.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_decision_merge_guarded_branch.sysml b/internal/exec/runtime/testdata/conformance/action_decision_merge_guarded_branch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_merge_guarded_branch.sysml rename to internal/exec/runtime/testdata/conformance/action_decision_merge_guarded_branch.sysml diff --git a/internal/core/runtime/testdata/conformance/action_decision_merge_guarded_branch.trace.golden b/internal/exec/runtime/testdata/conformance/action_decision_merge_guarded_branch.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_merge_guarded_branch.trace.golden rename to internal/exec/runtime/testdata/conformance/action_decision_merge_guarded_branch.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_decision_same_declared_target.expected.json b/internal/exec/runtime/testdata/conformance/action_decision_same_declared_target.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_same_declared_target.expected.json rename to internal/exec/runtime/testdata/conformance/action_decision_same_declared_target.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_decision_same_declared_target.sysml b/internal/exec/runtime/testdata/conformance/action_decision_same_declared_target.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_decision_same_declared_target.sysml rename to internal/exec/runtime/testdata/conformance/action_decision_same_declared_target.sysml diff --git a/internal/core/runtime/testdata/conformance/action_explicit_succession_chain.expected.json b/internal/exec/runtime/testdata/conformance/action_explicit_succession_chain.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explicit_succession_chain.expected.json rename to internal/exec/runtime/testdata/conformance/action_explicit_succession_chain.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explicit_succession_chain.sysml b/internal/exec/runtime/testdata/conformance/action_explicit_succession_chain.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explicit_succession_chain.sysml rename to internal/exec/runtime/testdata/conformance/action_explicit_succession_chain.sysml diff --git a/internal/core/runtime/testdata/conformance/action_explicit_succession_feature_chain.expected.json b/internal/exec/runtime/testdata/conformance/action_explicit_succession_feature_chain.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explicit_succession_feature_chain.expected.json rename to internal/exec/runtime/testdata/conformance/action_explicit_succession_feature_chain.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explicit_succession_feature_chain.sysml b/internal/exec/runtime/testdata/conformance/action_explicit_succession_feature_chain.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explicit_succession_feature_chain.sysml rename to internal/exec/runtime/testdata/conformance/action_explicit_succession_feature_chain.sysml diff --git a/internal/core/runtime/testdata/conformance/action_explicit_succession_fork_join.expected.json b/internal/exec/runtime/testdata/conformance/action_explicit_succession_fork_join.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explicit_succession_fork_join.expected.json rename to internal/exec/runtime/testdata/conformance/action_explicit_succession_fork_join.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explicit_succession_fork_join.sysml b/internal/exec/runtime/testdata/conformance/action_explicit_succession_fork_join.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explicit_succession_fork_join.sysml rename to internal/exec/runtime/testdata/conformance/action_explicit_succession_fork_join.sysml diff --git a/internal/core/runtime/testdata/conformance/action_explicit_succession_fork_join.trace.golden b/internal/exec/runtime/testdata/conformance/action_explicit_succession_fork_join.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explicit_succession_fork_join.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explicit_succession_fork_join.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.check.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.expected.json rename to internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.sysml b/internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.sysml rename to internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.sysml diff --git a/internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_decision_in_loop.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_decision_in_loop.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/action_explore_early_race_long_tail.check.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_early_race_long_tail.check.expected.json new file mode 100644 index 0000000000..65e3b081fe --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_explore_early_race_long_tail.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"x": ["1", "2"]}, + "agreed": {"p": "2", "q": "2"} +} diff --git a/internal/exec/runtime/testdata/conformance/action_explore_early_race_long_tail.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_early_race_long_tail.expected.json new file mode 100644 index 0000000000..73611ccd0d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_explore_early_race_long_tail.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "outcomes": [ + {"outputs": {"x": {"type": "Integer", "value": 1}, "p": {"type": "Integer", "value": 2}, "q": {"type": "Integer", "value": 2}}}, + {"outputs": {"x": {"type": "Integer", "value": 2}, "p": {"type": "Integer", "value": 2}, "q": {"type": "Integer", "value": 2}}} + ], + "admissible": "Two writers of one feature before a long tail of closed choices: two values" +} diff --git a/internal/exec/runtime/testdata/conformance/action_explore_early_race_long_tail.sysml b/internal/exec/runtime/testdata/conformance/action_explore_early_race_long_tail.sysml new file mode 100644 index 0000000000..21d81309b2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_explore_early_race_long_tail.sysml @@ -0,0 +1,36 @@ +package test { + private import ScalarValues::*; + + // Oracle (docs/project/behavior-semantic-oracle.md): two branches between one + // fork and one join each open by writing `x`, the last write standing, then go + // on for two steps writing their own feature in turn; the library orders neither + // branch, so the two orders of the writes leave two values open while every order + // of the steps after them ends the same. A run meets the open choice first, closed ones after. + action race { + attribute x : Integer = 0; + attribute p : Integer = 0; + attribute q : Integer = 0; + + first start; + fork split; + action a { assign x := 1; } + action p1 { assign p := 1; } + action p2 { assign p := 2; } + action b { assign x := 2; } + action q1 { assign q := 1; } + action q2 { assign q := 2; } + join sync; + done; + + succession first start then split; + succession first split then a; + succession first a then p1; + succession first p1 then p2; + succession first p2 then sync; + succession first split then b; + succession first b then q1; + succession first q1 then q2; + succession first q2 then sync; + succession first sync then done; + } +} diff --git a/internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.check.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.expected.json rename to internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.sysml b/internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.sysml rename to internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.sysml diff --git a/internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.trace.order b/internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.trace.order similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.trace.order rename to internal/exec/runtime/testdata/conformance/action_explore_performed_and_accept_due_together.trace.order diff --git a/internal/core/runtime/testdata/conformance/action_explore_slow_first_writer.check.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_slow_first_writer.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_slow_first_writer.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_explore_slow_first_writer.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explore_slow_first_writer.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_slow_first_writer.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_slow_first_writer.expected.json rename to internal/exec/runtime/testdata/conformance/action_explore_slow_first_writer.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explore_slow_first_writer.sysml b/internal/exec/runtime/testdata/conformance/action_explore_slow_first_writer.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_slow_first_writer.sysml rename to internal/exec/runtime/testdata/conformance/action_explore_slow_first_writer.sysml diff --git a/internal/core/runtime/testdata/conformance/action_explore_three_writers.check.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_three_writers.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_three_writers.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_explore_three_writers.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explore_three_writers.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_three_writers.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_three_writers.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_three_writers.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_three_writers.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_three_writers.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_three_writers.expected.json rename to internal/exec/runtime/testdata/conformance/action_explore_three_writers.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explore_three_writers.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_three_writers.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_three_writers.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_three_writers.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_three_writers.sysml b/internal/exec/runtime/testdata/conformance/action_explore_three_writers.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_three_writers.sysml rename to internal/exec/runtime/testdata/conformance/action_explore_three_writers.sysml diff --git a/internal/core/runtime/testdata/conformance/action_explore_three_writers.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_three_writers.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_three_writers.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_three_writers.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_three_writers.trace.order b/internal/exec/runtime/testdata/conformance/action_explore_three_writers.trace.order similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_three_writers.trace.order rename to internal/exec/runtime/testdata/conformance/action_explore_three_writers.trace.order diff --git a/internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.check.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.expected.json b/internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.expected.json rename to internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.sysml b/internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.sysml rename to internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.sysml diff --git a/internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.trace.golden b/internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.trace.golden rename to internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.trace.order b/internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.trace.order similarity index 100% rename from internal/core/runtime/testdata/conformance/action_explore_write_between_branch_nodes.trace.order rename to internal/exec/runtime/testdata/conformance/action_explore_write_between_branch_nodes.trace.order diff --git a/internal/core/runtime/testdata/conformance/action_first_names_a_declared_node.expected.json b/internal/exec/runtime/testdata/conformance/action_first_names_a_declared_node.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_first_names_a_declared_node.expected.json rename to internal/exec/runtime/testdata/conformance/action_first_names_a_declared_node.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_first_names_a_declared_node.sysml b/internal/exec/runtime/testdata/conformance/action_first_names_a_declared_node.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_first_names_a_declared_node.sysml rename to internal/exec/runtime/testdata/conformance/action_first_names_a_declared_node.sysml diff --git a/internal/core/runtime/testdata/conformance/action_first_names_a_declared_node.trace.golden b/internal/exec/runtime/testdata/conformance/action_first_names_a_declared_node.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_first_names_a_declared_node.trace.golden rename to internal/exec/runtime/testdata/conformance/action_first_names_a_declared_node.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_first_then_fork_join.expected.json b/internal/exec/runtime/testdata/conformance/action_first_then_fork_join.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_first_then_fork_join.expected.json rename to internal/exec/runtime/testdata/conformance/action_first_then_fork_join.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_first_then_fork_join.sysml b/internal/exec/runtime/testdata/conformance/action_first_then_fork_join.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_first_then_fork_join.sysml rename to internal/exec/runtime/testdata/conformance/action_first_then_fork_join.sysml diff --git a/internal/core/runtime/testdata/conformance/action_first_then_fork_join.trace.golden b/internal/exec/runtime/testdata/conformance/action_first_then_fork_join.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_first_then_fork_join.trace.golden rename to internal/exec/runtime/testdata/conformance/action_first_then_fork_join.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_first_then_fork_join.trace.order b/internal/exec/runtime/testdata/conformance/action_first_then_fork_join.trace.order similarity index 100% rename from internal/core/runtime/testdata/conformance/action_first_then_fork_join.trace.order rename to internal/exec/runtime/testdata/conformance/action_first_then_fork_join.trace.order diff --git a/internal/core/runtime/testdata/conformance/action_first_then_succession.expected.json b/internal/exec/runtime/testdata/conformance/action_first_then_succession.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_first_then_succession.expected.json rename to internal/exec/runtime/testdata/conformance/action_first_then_succession.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_first_then_succession.sysml b/internal/exec/runtime/testdata/conformance/action_first_then_succession.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_first_then_succession.sysml rename to internal/exec/runtime/testdata/conformance/action_first_then_succession.sysml diff --git a/internal/core/runtime/testdata/conformance/action_first_then_succession.trace.golden b/internal/exec/runtime/testdata/conformance/action_first_then_succession.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_first_then_succession.trace.golden rename to internal/exec/runtime/testdata/conformance/action_first_then_succession.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_flow_between_same_named_pins.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_between_same_named_pins.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_flow_between_same_named_pins.expected.json rename to internal/exec/runtime/testdata/conformance/action_flow_between_same_named_pins.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_flow_between_same_named_pins.sysml b/internal/exec/runtime/testdata/conformance/action_flow_between_same_named_pins.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_flow_between_same_named_pins.sysml rename to internal/exec/runtime/testdata/conformance/action_flow_between_same_named_pins.sysml diff --git a/internal/core/runtime/testdata/conformance/action_flow_ending_at_a_statement.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_ending_at_a_statement.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_flow_ending_at_a_statement.expected.json rename to internal/exec/runtime/testdata/conformance/action_flow_ending_at_a_statement.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_flow_ending_at_a_statement.sysml b/internal/exec/runtime/testdata/conformance/action_flow_ending_at_a_statement.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_flow_ending_at_a_statement.sysml rename to internal/exec/runtime/testdata/conformance/action_flow_ending_at_a_statement.sysml diff --git a/internal/core/runtime/testdata/conformance/action_flow_named_from.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_named_from.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_flow_named_from.expected.json rename to internal/exec/runtime/testdata/conformance/action_flow_named_from.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_flow_named_from.sysml b/internal/exec/runtime/testdata/conformance/action_flow_named_from.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_flow_named_from.sysml rename to internal/exec/runtime/testdata/conformance/action_flow_named_from.sysml diff --git a/internal/core/runtime/testdata/conformance/action_flow_named_from.trace.golden b/internal/exec/runtime/testdata/conformance/action_flow_named_from.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_flow_named_from.trace.golden rename to internal/exec/runtime/testdata/conformance/action_flow_named_from.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_aliased_source_pin.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_aliased_source_pin.expected.json new file mode 100644 index 0000000000..2af64695b8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_aliased_source_pin.expected.json @@ -0,0 +1,8 @@ +{ + "libraries": true, + "type": "action", + "evaluate": "test::stream", + "outputs": { + "received": {"type": "Integer", "value": 2} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_aliased_source_pin.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_aliased_source_pin.sysml new file mode 100644 index 0000000000..f3dacf4988 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_aliased_source_pin.sysml @@ -0,0 +1,40 @@ +package test { + private import ScalarValues::*; + + // Two flows may name one source pin by its inherited and by its redefining name; + // each flow declaration is a transfer of its own, so the pin's one write is staged + // twice for a target begun afterwards: each of the target's two performances takes one. + action def Producer { + out result : Integer[0..1] = (); + } + + action stream { + attribute received : Integer = 0; + + action producer : Producer { + out value : Integer[0..1] redefines result; + action write { assign value := 5; } + succession first start then write; + succession first write then done; + } + + action consumer { + in v : Integer[0..1]; + action count { assign received := received + SequenceFunctions::size(v); } + succession first start then count; + succession first count then done; + } + + fork twice; + merge again; + succession first start then producer; + succession first producer then twice; + succession first twice then again; + succession first twice then again; + succession first again then consumer; + succession first consumer then done; + + flow producer.result to consumer.v; + flow producer.value to consumer.v; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_before_target_begins.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_before_target_begins.expected.json new file mode 100644 index 0000000000..1c14a9043d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_before_target_begins.expected.json @@ -0,0 +1,6 @@ +{ + "type": "action", + "outputs": { + "total": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_before_target_begins.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_before_target_begins.sysml new file mode 100644 index 0000000000..3e6638a150 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_before_target_begins.sysml @@ -0,0 +1,27 @@ +package test { + private import ScalarValues::*; + + // A value streamed while no performance of its target is under way waits at the + // target's pin: the consumer begins after the producer is over and reads 5. + action stream { + attribute total : Integer = 0; + + action producer { + out value : Integer; + assign value := 5; + } + + action consumer { + in value : Integer; + action take { assign total := total + value; } + succession first start then take; + succession first take then done; + } + + succession first start then producer; + succession first producer then consumer; + succession first consumer then done; + + flow producer.value to consumer.value; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_declared_value.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_declared_value.expected.json new file mode 100644 index 0000000000..1c14a9043d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_declared_value.expected.json @@ -0,0 +1,6 @@ +{ + "type": "action", + "outputs": { + "total": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_declared_value.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_declared_value.sysml new file mode 100644 index 0000000000..d599ded5ec --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_declared_value.sysml @@ -0,0 +1,33 @@ +package test { + private import ScalarValues::*; + + // The value a source's declaration gives its out pin (`out value = 5`) streams as the + // performance begins, so the consumer forked beside it reads 5. + action stream { + attribute total : Integer = 0; + + fork split; + action producer { + out value : Integer = 5; + action wait { assign total := total + 0; } + succession first start then wait; + succession first wait then done; + } + action consumer { + in value : Integer; + action take { assign total := total + value; } + succession first start then take; + succession first take then done; + } + join sync; + + succession first start then split; + succession first split then producer; + succession first split then consumer; + succession first producer then sync; + succession first consumer then sync; + succession first sync then done; + + flow producer.value to consumer.value; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_joined_performance.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_joined_performance.expected.json new file mode 100644 index 0000000000..6951e98c32 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_joined_performance.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "evaluate": "test::Cfg", + "outputs": { + "total": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_joined_performance.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_joined_performance.sysml new file mode 100644 index 0000000000..c1c3780819 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_joined_performance.sysml @@ -0,0 +1,40 @@ +// A node joining the performance an object already runs of its action streams the +// outputs written while it performs it, as a node beginning a performance does: the +// consumer begun afterwards reads the pin's value, the last of them. +package test { + private import ScalarValues::*; + + action def Count { + out value : Integer; + attribute i : Integer = 0; + + action emit { assign i := i + 1; assign value := i; } + decide again; + + succession first start then emit; + succession first emit then again; + succession first again if i < 3 then emit; + succession first again if i >= 3 then done; + } + + part def Host { + perform action go : Count; + } + + action def Cfg { + part target : Host; + out total : Integer = 0; + + perform action run ::> target.go; + action consumer { + in value : Integer; + assign total := total + value; + } + + succession first start then run; + succession first run then consumer; + succession first consumer then done; + + flow run.value to consumer.value; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_performed_action.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_performed_action.expected.json new file mode 100644 index 0000000000..5794950b71 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_performed_action.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "evaluate": "test::stream", + "outputs": { + "total": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_performed_action.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_performed_action.sysml new file mode 100644 index 0000000000..a58a9d9e8e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_from_performed_action.sysml @@ -0,0 +1,34 @@ +package test { + private import ScalarValues::*; + + action def Producer { + out value : Integer; + attribute i : Integer = 0; + + action emit { assign i := i + 1; assign value := i; } + decide again; + + succession first start then emit; + succession first emit then again; + succession first again if i < 3 then emit; + succession first again if i >= 3 then done; + } + + // The outputs an action performed by a node writes stream from the node as they are + // written; a consumer begun afterwards reads the pin's value, the last of them. + action stream { + attribute total : Integer = 0; + + action producer : Producer; + action consumer { + in value : Integer; + assign total := total + value; + } + + succession first start then producer; + succession first producer then consumer; + succession first consumer then done; + + flow producer.value to consumer.value; + } +} diff --git a/internal/core/runtime/testdata/conformance/f63_for_typed_variable.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_in_loop_body.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f63_for_typed_variable.expected.json rename to internal/exec/runtime/testdata/conformance/action_flow_streaming_in_loop_body.expected.json diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_in_loop_body.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_in_loop_body.sysml new file mode 100644 index 0000000000..27fe46fde0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_in_loop_body.sysml @@ -0,0 +1,19 @@ +package test { + private import ScalarValues::*; + + // A streaming flow in a loop body joins the body's nodes each pass: the value src writes + // on pass i is what dst reads on that pass, so total = 1 + 2 + 3. + action outer { + out attribute total : Integer = 0; + + first start; + then action iterate { + for i in 1..3 { + action src { out v : Integer; assign v := i; } + flow src.v to dst.v; + action dst { in v : Integer; assign total := total + v; } + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_last_write_before_target_begins.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_last_write_before_target_begins.expected.json new file mode 100644 index 0000000000..a3b1034053 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_last_write_before_target_begins.expected.json @@ -0,0 +1,8 @@ +{ + "libraries": true, + "type": "action", + "evaluate": "test::stream", + "outputs": { + "count": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_last_write_before_target_begins.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_last_write_before_target_begins.sysml new file mode 100644 index 0000000000..8c24522a60 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_last_write_before_target_begins.sysml @@ -0,0 +1,34 @@ +package test { + private import ScalarValues::*; + + // A source that writes its pin several times before any performance of the target is + // under way leaves the target the pin's value: its latest write. The list is built up + // element by element and the consumer, begun after the producer, reads all three. + action stream { + attribute count : Integer = 0; + + action producer { + out list : Integer[0..*] nonunique = (); + action one { assign list := (list, 1); } + action two { assign list := (list, 2); } + action three { assign list := (list, 3); } + succession first start then one; + succession first one then two; + succession first two then three; + succession first three then done; + } + + action consumer { + in list : Integer[0..*] nonunique; + action take { assign count := SequenceFunctions::size(list); } + succession first start then take; + succession first take then done; + } + + succession first start then producer; + succession first producer then consumer; + succession first consumer then done; + + flow producer.list to consumer.list; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_performed_declared_value.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_performed_declared_value.expected.json new file mode 100644 index 0000000000..e36744f76e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_performed_declared_value.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "evaluate": "test::stream", + "outputs": { + "total": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_performed_declared_value.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_performed_declared_value.sysml new file mode 100644 index 0000000000..38533821e9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_performed_declared_value.sysml @@ -0,0 +1,39 @@ +package test { + private import ScalarValues::*; + + action def Producer { + out value : Integer = 5; + attribute i : Integer = 0; + + action wait { assign i := i + 1; } + + succession first start then wait; + succession first wait then done; + } + + // The value a performed action's declaration gives its out pin streams from the + // performing node as the performance begins, so the consumer forked beside it + // reads 5 whichever of the two the fork begins first. + action stream { + attribute total : Integer = 0; + + fork split; + action producer : Producer; + action consumer { + in value : Integer; + action take { assign total := total + value; } + succession first start then take; + succession first take then done; + } + join sync; + + succession first start then split; + succession first split then producer; + succession first split then consumer; + succession first producer then sync; + succession first consumer then sync; + succession first sync then done; + + flow producer.value to consumer.value; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_producer_consumer.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_producer_consumer.expected.json new file mode 100644 index 0000000000..300aedb70d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_producer_consumer.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "total": {"type": "Integer", "value": 6}, + "i": {"type": "Integer", "value": 3}, + "reads": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_producer_consumer.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_producer_consumer.sysml new file mode 100644 index 0000000000..7e05723ae9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_producer_consumer.sysml @@ -0,0 +1,50 @@ +package test { + private import ScalarValues::*; + + // A plain `flow` streams (SysML v2 §7.16): each value the producer writes while the + // consumer, forked beside it, is being performed reaches it at once, so total = 1+2+3. + action stream { + attribute total : Integer = 0; + attribute i : Integer = 0; + attribute reads : Integer = 0; + + fork split; + + action producer { + out value : Integer; + + action emit { assign i := i + 1; assign value := i; } + decide again; + + succession first start then emit; + succession first emit then again; + succession first again if i < 3 then emit; + succession first again if i >= 3 then done; + } + + action consumer { + in value : Integer; + + action prime { assign reads := 0; } + action take { assign total := total + value; assign reads := reads + 1; } + decide more; + + succession first start then prime; + succession first prime then take; + succession first take then more; + succession first more if reads < 3 then take; + succession first more if reads >= 3 then done; + } + + join sync; + + succession first start then split; + succession first split then producer; + succession first split then consumer; + succession first producer then sync; + succession first consumer then sync; + succession first sync then done; + + flow producer.value to consumer.value; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_producer_consumer.trace.golden b/internal/exec/runtime/testdata/conformance/action_flow_streaming_producer_consumer.trace.golden new file mode 100644 index 0000000000..a100b229a5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_producer_consumer.trace.golden @@ -0,0 +1,95 @@ +step 1: token 1@split +step 2: token 2@producer, token 3@consumer +enter action node: consumer +enter action node: producer +choice step 3: tokens 2@producer, 3@consumer (unordered; took 3@consumer first) +step 3: token 2@start, token 3@start +choice step 4: tokens 2@start, 3@start (unordered; took 3@start first) +step 4: token 2@emit, token 3@prime +stmt assign reads + eval literal 0 -> 0 +stmt assign i + eval feature i -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign value + eval feature i -> 1 +choice step 5: tokens 2@emit, 3@prime (unordered; took 3@prime first) +step 5: token 2@again, token 3@take +stmt assign total + eval feature total -> 0 + eval feature value -> 1 + eval operator + -> 1 +stmt assign reads + eval feature reads -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + eval feature i -> 1 + eval literal 3 -> 3 +eval operator < -> true +choice step 6: tokens 2@again, 3@take (unordered; took 3@take first) +step 6: token 2@emit, token 3@more + eval feature reads -> 1 + eval literal 3 -> 3 +eval operator < -> true +stmt assign i + eval feature i -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign value + eval feature i -> 2 +choice step 7: tokens 2@emit, 3@more (unordered; took 3@more first) +step 7: token 2@again, token 3@take +stmt assign total + eval feature total -> 1 + eval feature value -> 2 + eval operator + -> 3 +stmt assign reads + eval feature reads -> 1 + eval literal 1 -> 1 + eval operator + -> 2 + eval feature i -> 2 + eval literal 3 -> 3 +eval operator < -> true +choice step 8: tokens 2@again, 3@take (unordered; took 3@take first) +step 8: token 2@emit, token 3@more + eval feature reads -> 2 + eval literal 3 -> 3 +eval operator < -> true +stmt assign i + eval feature i -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +stmt assign value + eval feature i -> 3 +choice step 9: tokens 2@emit, 3@more (unordered; took 3@more first) +step 9: token 2@again, token 3@take +stmt assign total + eval feature total -> 3 + eval feature value -> 3 + eval operator + -> 6 +stmt assign reads + eval feature reads -> 2 + eval literal 1 -> 1 + eval operator + -> 3 + eval feature i -> 3 + eval literal 3 -> 3 +eval operator < -> false + eval feature i -> 3 + eval literal 3 -> 3 +eval operator >= -> true +choice step 10: tokens 2@again, 3@take (unordered; took 3@take first) +step 10: token 2@done, token 3@more + eval feature reads -> 3 + eval literal 3 -> 3 +eval operator < -> false + eval feature reads -> 3 + eval literal 3 -> 3 +eval operator >= -> true +leave action node: producer +choice step 11: tokens 2@done, 3@more (unordered; took 3@more first) +step 11: token 2@sync, token 3@done +leave action node: consumer +step 12: token 2@sync, token 3@sync +step 13: token 4@done +step 14: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_returns.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_returns.expected.json new file mode 100644 index 0000000000..beeb3e45d2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_returns.expected.json @@ -0,0 +1,8 @@ +{ + "libraries": true, + "type": "action", + "evaluate": "test::stream", + "outputs": { + "total": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_returns.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_returns.sysml new file mode 100644 index 0000000000..69785dba67 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_returns.sysml @@ -0,0 +1,54 @@ +package test { + private import ScalarValues::*; + + // A value taken as a performance begins streams on while that performance is already + // ongoing: carried through the echo beside it and back to another pin of the relay, + // it reaches the relay that just began rather than waiting for a later one. + action stream { + attribute total : Integer = 0; + + action producer { + out value : Integer; + action write { assign value := 5; } + succession first start then write; + succession first write then done; + } + + fork split; + + action echo { + in value : Integer; + action hold { assign total := total + 0; } + action linger { assign total := total + 0; } + action wait { assign total := total + 0; } + action rest { assign total := total + 0; } + succession first start then hold; + succession first hold then linger; + succession first linger then wait; + succession first wait then rest; + succession first rest then done; + } + + action relay { + in value : Integer; + in back : Integer; + action take { assign total := total + back; } + succession first start then take; + succession first take then done; + } + + join sync; + + succession first start then split; + succession first split then echo; + succession first split then producer; + succession first producer then relay; + succession first echo then sync; + succession first relay then sync; + succession first sync then done; + + flow producer.value to relay.value; + flow relay.value to echo.value; + flow echo.value to relay.back; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_streams_on.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_streams_on.expected.json new file mode 100644 index 0000000000..beeb3e45d2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_streams_on.expected.json @@ -0,0 +1,8 @@ +{ + "libraries": true, + "type": "action", + "evaluate": "test::stream", + "outputs": { + "total": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_streams_on.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_streams_on.sysml new file mode 100644 index 0000000000..7599ba0ee8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_taken_value_streams_on.sysml @@ -0,0 +1,48 @@ +package test { + private import ScalarValues::*; + + // A value waiting at a pin when its node's performance begins streams on along the + // flows out of that pin at once: the relay's input, taken as the relay begins, reaches + // the consumer beside it before the relay completes. + action stream { + attribute total : Integer = 0; + + action producer { + out value : Integer; + action write { assign value := 5; } + succession first start then write; + succession first write then done; + } + + fork split; + + action relay { + in value : Integer; + action hold { assign total := total + 0; } + action linger { assign total := total + 0; } + succession first start then hold; + succession first hold then linger; + succession first linger then done; + } + + action consumer { + in value : Integer; + action take { assign total := total + value; } + succession first start then take; + succession first take then done; + } + + join sync; + + succession first start then producer; + succession first producer then split; + succession first split then relay; + succession first split then consumer; + succession first relay then sync; + succession first consumer then sync; + succession first sync then done; + + flow producer.value to relay.value; + flow relay.value to consumer.value; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_flows_one_pin_to_call.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_flows_one_pin_to_call.expected.json new file mode 100644 index 0000000000..659e1fd7f1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_flows_one_pin_to_call.expected.json @@ -0,0 +1,11 @@ +{ + "libraries": true, + "type": "action", + "evaluate": "test::stream", + "outputs": { + "total": {"type": "Sequence", "elements": [ + {"type": "Integer", "value": 7}, + {"type": "Integer", "value": 7} + ]} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_flows_one_pin_to_call.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_flows_one_pin_to_call.sysml new file mode 100644 index 0000000000..6cfd28b962 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_flows_one_pin_to_call.sysml @@ -0,0 +1,39 @@ +package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + + // Two flows out of one source pin each carry the pin's value to the target, so a + // target performed once per enabling succession is bound on both performances, + // the second write of the same pin replacing only what its own flow left waiting. + action def Copier { + in input : Integer; + out copied : Integer; + action read { out v : Integer = input; } + action write { in v : Integer[0..1]; assign copied := v; } + flow read.v to write.v; + succession first start then read; + succession first read then write; + succession first write then done; + } + + action stream { + attribute total : Integer[0..*] nonunique = (); + + action producer { out result : Integer = 7; } + fork twice; + merge again; + action copier : Copier; + action gather { in v : Integer[0..*] nonunique; assign total := (total, v); } + + flow producer.result to copier.input; + flow producer.result to copier.input; + flow copier.copied to gather.v; + + succession first start then producer; + succession first producer then twice; + succession first twice then again; + succession first twice then again; + succession first again then copier; + succession first copier then gather; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_source_pins_one_target.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_source_pins_one_target.expected.json new file mode 100644 index 0000000000..9951b27e46 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_source_pins_one_target.expected.json @@ -0,0 +1,8 @@ +{ + "libraries": true, + "type": "action", + "evaluate": "test::stream", + "outputs": { + "total": {"type": "Integer", "value": 11} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_source_pins_one_target.sysml b/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_source_pins_one_target.sysml new file mode 100644 index 0000000000..e90a683fe7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_streaming_two_source_pins_one_target.sysml @@ -0,0 +1,39 @@ +package test { + private import ScalarValues::*; + + // Two pins of one source stream to one pin of a target begun after it: each pin's + // latest write waits in its own place, so the target's two performances, one per + // enabling succession, take the source's low then its high. + action stream { + attribute total : Integer = 0; + + action producer { + out low : Integer[0..1] = (); + out high : Integer[0..1] = (); + action low1 { assign low := 1; } + action high10 { assign high := 10; } + succession first start then low1; + succession first low1 then high10; + succession first high10 then done; + } + + action consumer { + in v : Integer[0..1]; + action add { assign total := total + v; } + succession first start then add; + succession first add then done; + } + + fork twice; + merge again; + succession first start then producer; + succession first producer then twice; + succession first twice then again; + succession first twice then again; + succession first again then consumer; + succession first consumer then done; + + flow producer.low to consumer.v; + flow producer.high to consumer.v; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_succession_producer_consumer.expected.json b/internal/exec/runtime/testdata/conformance/action_flow_succession_producer_consumer.expected.json new file mode 100644 index 0000000000..91c9c45204 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_succession_producer_consumer.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "total": {"type": "Integer", "value": 9}, + "i": {"type": "Integer", "value": 3}, + "reads": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_succession_producer_consumer.sysml b/internal/exec/runtime/testdata/conformance/action_flow_succession_producer_consumer.sysml new file mode 100644 index 0000000000..26c0dc5c7a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_succession_producer_consumer.sysml @@ -0,0 +1,42 @@ +package test { + private import ScalarValues::*; + + // A `succession flow` carries the value its source holds once the source completes, and + // the target begins no sooner: the consumer reads the last value, 3, three times. + action handoff { + attribute total : Integer = 0; + attribute i : Integer = 0; + attribute reads : Integer = 0; + + action producer { + out value : Integer; + + action emit { assign i := i + 1; assign value := i; } + decide again; + + succession first start then emit; + succession first emit then again; + succession first again if i < 3 then emit; + succession first again if i >= 3 then done; + } + + action consumer { + in value : Integer; + + action prime { assign reads := 0; } + action take { assign total := total + value; assign reads := reads + 1; } + decide more; + + succession first start then prime; + succession first prime then take; + succession first take then more; + succession first more if reads < 3 then take; + succession first more if reads >= 3 then done; + } + + succession first start then producer; + succession first consumer then done; + + succession flow producer.value to consumer.value; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_flow_succession_producer_consumer.trace.golden b/internal/exec/runtime/testdata/conformance/action_flow_succession_producer_consumer.trace.golden new file mode 100644 index 0000000000..e517e1bb5d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_flow_succession_producer_consumer.trace.golden @@ -0,0 +1,93 @@ +step 1: token 1@producer +enter action node: producer +step 2: token 1@start +step 3: token 1@emit +stmt assign i + eval feature i -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign value + eval feature i -> 1 +step 4: token 1@again + eval feature i -> 1 + eval literal 3 -> 3 +eval operator < -> true +step 5: token 1@emit +stmt assign i + eval feature i -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign value + eval feature i -> 2 +step 6: token 1@again + eval feature i -> 2 + eval literal 3 -> 3 +eval operator < -> true +step 7: token 1@emit +stmt assign i + eval feature i -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +stmt assign value + eval feature i -> 3 +step 8: token 1@again + eval feature i -> 3 + eval literal 3 -> 3 +eval operator < -> false + eval feature i -> 3 + eval literal 3 -> 3 +eval operator >= -> true +step 9: token 1@done +leave action node: producer +step 10: token 1@consumer +enter action node: consumer +step 11: token 1@start +step 12: token 1@prime +stmt assign reads + eval literal 0 -> 0 +step 13: token 1@take +stmt assign total + eval feature total -> 0 + eval feature value -> 3 + eval operator + -> 3 +stmt assign reads + eval feature reads -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +step 14: token 1@more + eval feature reads -> 1 + eval literal 3 -> 3 +eval operator < -> true +step 15: token 1@take +stmt assign total + eval feature total -> 3 + eval feature value -> 3 + eval operator + -> 6 +stmt assign reads + eval feature reads -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +step 16: token 1@more + eval feature reads -> 2 + eval literal 3 -> 3 +eval operator < -> true +step 17: token 1@take +stmt assign total + eval feature total -> 6 + eval feature value -> 3 + eval operator + -> 9 +stmt assign reads + eval feature reads -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +step 18: token 1@more + eval feature reads -> 3 + eval literal 3 -> 3 +eval operator < -> false + eval feature reads -> 3 + eval literal 3 -> 3 +eval operator >= -> true +step 19: token 1@done +leave action node: consumer +step 20: token 1@done +step 21: no active tokens diff --git a/internal/core/runtime/testdata/conformance/action_for_loop.expected.json b/internal/exec/runtime/testdata/conformance/action_for_loop.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_loop.expected.json rename to internal/exec/runtime/testdata/conformance/action_for_loop.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_for_loop.sysml b/internal/exec/runtime/testdata/conformance/action_for_loop.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_loop.sysml rename to internal/exec/runtime/testdata/conformance/action_for_loop.sysml diff --git a/internal/core/runtime/testdata/conformance/action_for_loop.trace.golden b/internal/exec/runtime/testdata/conformance/action_for_loop.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_loop.trace.golden rename to internal/exec/runtime/testdata/conformance/action_for_loop.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_for_over_a_part_collection.expected.json b/internal/exec/runtime/testdata/conformance/action_for_over_a_part_collection.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_over_a_part_collection.expected.json rename to internal/exec/runtime/testdata/conformance/action_for_over_a_part_collection.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_for_over_a_part_collection.sysml b/internal/exec/runtime/testdata/conformance/action_for_over_a_part_collection.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_over_a_part_collection.sysml rename to internal/exec/runtime/testdata/conformance/action_for_over_a_part_collection.sysml diff --git a/internal/core/runtime/testdata/conformance/action_for_over_a_part_collection.trace.golden b/internal/exec/runtime/testdata/conformance/action_for_over_a_part_collection.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_over_a_part_collection.trace.golden rename to internal/exec/runtime/testdata/conformance/action_for_over_a_part_collection.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_for_over_a_scalar.expected.json b/internal/exec/runtime/testdata/conformance/action_for_over_a_scalar.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_over_a_scalar.expected.json rename to internal/exec/runtime/testdata/conformance/action_for_over_a_scalar.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_for_over_a_scalar.sysml b/internal/exec/runtime/testdata/conformance/action_for_over_a_scalar.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_over_a_scalar.sysml rename to internal/exec/runtime/testdata/conformance/action_for_over_a_scalar.sysml diff --git a/internal/core/runtime/testdata/conformance/action_for_over_produced_collections.expected.json b/internal/exec/runtime/testdata/conformance/action_for_over_produced_collections.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_over_produced_collections.expected.json rename to internal/exec/runtime/testdata/conformance/action_for_over_produced_collections.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_for_over_produced_collections.sysml b/internal/exec/runtime/testdata/conformance/action_for_over_produced_collections.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_over_produced_collections.sysml rename to internal/exec/runtime/testdata/conformance/action_for_over_produced_collections.sysml diff --git a/internal/core/runtime/testdata/conformance/action_for_over_produced_collections.trace.golden b/internal/exec/runtime/testdata/conformance/action_for_over_produced_collections.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_for_over_produced_collections.trace.golden rename to internal/exec/runtime/testdata/conformance/action_for_over_produced_collections.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_fork_branches_share_features.expected.json b/internal/exec/runtime/testdata/conformance/action_fork_branches_share_features.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_fork_branches_share_features.expected.json rename to internal/exec/runtime/testdata/conformance/action_fork_branches_share_features.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_fork_branches_share_features.sysml b/internal/exec/runtime/testdata/conformance/action_fork_branches_share_features.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_fork_branches_share_features.sysml rename to internal/exec/runtime/testdata/conformance/action_fork_branches_share_features.sysml diff --git a/internal/core/runtime/testdata/conformance/action_fork_branches_share_features.trace.golden b/internal/exec/runtime/testdata/conformance/action_fork_branches_share_features.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_fork_branches_share_features.trace.golden rename to internal/exec/runtime/testdata/conformance/action_fork_branches_share_features.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.check.expected.json b/internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.expected.json b/internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.expected.json rename to internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.sysml b/internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.sysml rename to internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.sysml diff --git a/internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.trace.golden b/internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.trace.golden rename to internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.trace.order b/internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.trace.order similarity index 100% rename from internal/core/runtime/testdata/conformance/action_fork_branches_write_one_feature.trace.order rename to internal/exec/runtime/testdata/conformance/action_fork_branches_write_one_feature.trace.order diff --git a/internal/core/runtime/testdata/conformance/action_guard_reads_calc_usage.expected.json b/internal/exec/runtime/testdata/conformance/action_guard_reads_calc_usage.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_guard_reads_calc_usage.expected.json rename to internal/exec/runtime/testdata/conformance/action_guard_reads_calc_usage.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_guard_reads_calc_usage.sysml b/internal/exec/runtime/testdata/conformance/action_guard_reads_calc_usage.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_guard_reads_calc_usage.sysml rename to internal/exec/runtime/testdata/conformance/action_guard_reads_calc_usage.sysml diff --git a/internal/core/runtime/testdata/conformance/action_if_else_else_branch.expected.json b/internal/exec/runtime/testdata/conformance/action_if_else_else_branch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_if_else_else_branch.expected.json rename to internal/exec/runtime/testdata/conformance/action_if_else_else_branch.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_if_else_else_branch.sysml b/internal/exec/runtime/testdata/conformance/action_if_else_else_branch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_if_else_else_branch.sysml rename to internal/exec/runtime/testdata/conformance/action_if_else_else_branch.sysml diff --git a/internal/core/runtime/testdata/conformance/action_if_else_else_branch.trace.golden b/internal/exec/runtime/testdata/conformance/action_if_else_else_branch.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_if_else_else_branch.trace.golden rename to internal/exec/runtime/testdata/conformance/action_if_else_else_branch.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_if_else_then_branch.expected.json b/internal/exec/runtime/testdata/conformance/action_if_else_then_branch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_if_else_then_branch.expected.json rename to internal/exec/runtime/testdata/conformance/action_if_else_then_branch.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_if_else_then_branch.sysml b/internal/exec/runtime/testdata/conformance/action_if_else_then_branch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_if_else_then_branch.sysml rename to internal/exec/runtime/testdata/conformance/action_if_else_then_branch.sysml diff --git a/internal/core/runtime/testdata/conformance/action_if_else_then_branch.trace.golden b/internal/exec/runtime/testdata/conformance/action_if_else_then_branch.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_if_else_then_branch.trace.golden rename to internal/exec/runtime/testdata/conformance/action_if_else_then_branch.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_if_no_else.expected.json b/internal/exec/runtime/testdata/conformance/action_if_no_else.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_if_no_else.expected.json rename to internal/exec/runtime/testdata/conformance/action_if_no_else.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_if_no_else.sysml b/internal/exec/runtime/testdata/conformance/action_if_no_else.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_if_no_else.sysml rename to internal/exec/runtime/testdata/conformance/action_if_no_else.sysml diff --git a/internal/core/runtime/testdata/conformance/action_if_no_else.trace.golden b/internal/exec/runtime/testdata/conformance/action_if_no_else.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_if_no_else.trace.golden rename to internal/exec/runtime/testdata/conformance/action_if_no_else.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_inherited_default_root.expected.json b/internal/exec/runtime/testdata/conformance/action_inherited_default_root.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_default_root.expected.json rename to internal/exec/runtime/testdata/conformance/action_inherited_default_root.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_inherited_default_root.sysml b/internal/exec/runtime/testdata/conformance/action_inherited_default_root.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_default_root.sysml rename to internal/exec/runtime/testdata/conformance/action_inherited_default_root.sysml diff --git a/internal/core/runtime/testdata/conformance/action_inherited_endpoint.expected.json b/internal/exec/runtime/testdata/conformance/action_inherited_endpoint.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_endpoint.expected.json rename to internal/exec/runtime/testdata/conformance/action_inherited_endpoint.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_inherited_endpoint.sysml b/internal/exec/runtime/testdata/conformance/action_inherited_endpoint.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_endpoint.sysml rename to internal/exec/runtime/testdata/conformance/action_inherited_endpoint.sysml diff --git a/internal/core/runtime/testdata/conformance/action_inherited_node_binding_other_end_replaced.expected.json b/internal/exec/runtime/testdata/conformance/action_inherited_node_binding_other_end_replaced.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_node_binding_other_end_replaced.expected.json rename to internal/exec/runtime/testdata/conformance/action_inherited_node_binding_other_end_replaced.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_inherited_node_binding_other_end_replaced.sysml b/internal/exec/runtime/testdata/conformance/action_inherited_node_binding_other_end_replaced.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_node_binding_other_end_replaced.sysml rename to internal/exec/runtime/testdata/conformance/action_inherited_node_binding_other_end_replaced.sysml diff --git a/internal/core/runtime/testdata/conformance/action_inherited_node_bindings.expected.json b/internal/exec/runtime/testdata/conformance/action_inherited_node_bindings.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_node_bindings.expected.json rename to internal/exec/runtime/testdata/conformance/action_inherited_node_bindings.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_inherited_node_bindings.sysml b/internal/exec/runtime/testdata/conformance/action_inherited_node_bindings.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_node_bindings.sysml rename to internal/exec/runtime/testdata/conformance/action_inherited_node_bindings.sysml diff --git a/internal/core/runtime/testdata/conformance/action_inherited_node_masked.expected.json b/internal/exec/runtime/testdata/conformance/action_inherited_node_masked.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_node_masked.expected.json rename to internal/exec/runtime/testdata/conformance/action_inherited_node_masked.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_inherited_node_masked.sysml b/internal/exec/runtime/testdata/conformance/action_inherited_node_masked.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_node_masked.sysml rename to internal/exec/runtime/testdata/conformance/action_inherited_node_masked.sysml diff --git a/internal/core/runtime/testdata/conformance/action_inherited_node_redefined.expected.json b/internal/exec/runtime/testdata/conformance/action_inherited_node_redefined.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_node_redefined.expected.json rename to internal/exec/runtime/testdata/conformance/action_inherited_node_redefined.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_inherited_node_redefined.sysml b/internal/exec/runtime/testdata/conformance/action_inherited_node_redefined.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_node_redefined.sysml rename to internal/exec/runtime/testdata/conformance/action_inherited_node_redefined.sysml diff --git a/internal/core/runtime/testdata/conformance/action_inherited_typed_node_scope.expected.json b/internal/exec/runtime/testdata/conformance/action_inherited_typed_node_scope.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_typed_node_scope.expected.json rename to internal/exec/runtime/testdata/conformance/action_inherited_typed_node_scope.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_inherited_typed_node_scope.sysml b/internal/exec/runtime/testdata/conformance/action_inherited_typed_node_scope.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_inherited_typed_node_scope.sysml rename to internal/exec/runtime/testdata/conformance/action_inherited_typed_node_scope.sysml diff --git a/internal/core/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json rename to internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml rename to internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml diff --git a/internal/core/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.expected.json b/internal/exec/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.expected.json rename to internal/exec/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.sysml b/internal/exec/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.sysml rename to internal/exec/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.sysml diff --git a/internal/core/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.trace.golden b/internal/exec/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.trace.golden rename to internal/exec/runtime/testdata/conformance/action_join_one_token_per_incoming_succession.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_join_same_succession_twice.expected.json b/internal/exec/runtime/testdata/conformance/action_join_same_succession_twice.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_same_succession_twice.expected.json rename to internal/exec/runtime/testdata/conformance/action_join_same_succession_twice.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_join_same_succession_twice.sysml b/internal/exec/runtime/testdata/conformance/action_join_same_succession_twice.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_same_succession_twice.sysml rename to internal/exec/runtime/testdata/conformance/action_join_same_succession_twice.sysml diff --git a/internal/core/runtime/testdata/conformance/action_join_same_succession_twice.trace.golden b/internal/exec/runtime/testdata/conformance/action_join_same_succession_twice.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_same_succession_twice.trace.golden rename to internal/exec/runtime/testdata/conformance/action_join_same_succession_twice.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_join_three_two_arrive_together.expected.json b/internal/exec/runtime/testdata/conformance/action_join_three_two_arrive_together.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_three_two_arrive_together.expected.json rename to internal/exec/runtime/testdata/conformance/action_join_three_two_arrive_together.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_join_three_two_arrive_together.sysml b/internal/exec/runtime/testdata/conformance/action_join_three_two_arrive_together.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_three_two_arrive_together.sysml rename to internal/exec/runtime/testdata/conformance/action_join_three_two_arrive_together.sysml diff --git a/internal/core/runtime/testdata/conformance/action_join_three_two_arrive_together.trace.golden b/internal/exec/runtime/testdata/conformance/action_join_three_two_arrive_together.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_three_two_arrive_together.trace.golden rename to internal/exec/runtime/testdata/conformance/action_join_three_two_arrive_together.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_join_waits_for_slowest_branch.check.expected.json b/internal/exec/runtime/testdata/conformance/action_join_waits_for_slowest_branch.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_waits_for_slowest_branch.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_join_waits_for_slowest_branch.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_join_waits_for_slowest_branch.expected.json b/internal/exec/runtime/testdata/conformance/action_join_waits_for_slowest_branch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_waits_for_slowest_branch.expected.json rename to internal/exec/runtime/testdata/conformance/action_join_waits_for_slowest_branch.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_join_waits_for_slowest_branch.sysml b/internal/exec/runtime/testdata/conformance/action_join_waits_for_slowest_branch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_waits_for_slowest_branch.sysml rename to internal/exec/runtime/testdata/conformance/action_join_waits_for_slowest_branch.sysml diff --git a/internal/core/runtime/testdata/conformance/action_join_waits_for_slowest_branch.trace.golden b/internal/exec/runtime/testdata/conformance/action_join_waits_for_slowest_branch.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_join_waits_for_slowest_branch.trace.golden rename to internal/exec/runtime/testdata/conformance/action_join_waits_for_slowest_branch.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_last_node_without_a_succession.expected.json b/internal/exec/runtime/testdata/conformance/action_last_node_without_a_succession.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_last_node_without_a_succession.expected.json rename to internal/exec/runtime/testdata/conformance/action_last_node_without_a_succession.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_last_node_without_a_succession.sysml b/internal/exec/runtime/testdata/conformance/action_last_node_without_a_succession.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_last_node_without_a_succession.sysml rename to internal/exec/runtime/testdata/conformance/action_last_node_without_a_succession.sysml diff --git a/internal/core/runtime/testdata/conformance/action_last_node_without_a_succession.trace.golden b/internal/exec/runtime/testdata/conformance/action_last_node_without_a_succession.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_last_node_without_a_succession.trace.golden rename to internal/exec/runtime/testdata/conformance/action_last_node_without_a_succession.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_library_function_assign.expected.json b/internal/exec/runtime/testdata/conformance/action_library_function_assign.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_library_function_assign.expected.json rename to internal/exec/runtime/testdata/conformance/action_library_function_assign.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_library_function_assign.sysml b/internal/exec/runtime/testdata/conformance/action_library_function_assign.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_library_function_assign.sysml rename to internal/exec/runtime/testdata/conformance/action_library_function_assign.sysml diff --git a/internal/core/runtime/testdata/conformance/action_library_function_assign.trace.golden b/internal/exec/runtime/testdata/conformance/action_library_function_assign.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_library_function_assign.trace.golden rename to internal/exec/runtime/testdata/conformance/action_library_function_assign.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_loop_step_budget.expected.json b/internal/exec/runtime/testdata/conformance/action_loop_step_budget.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_loop_step_budget.expected.json rename to internal/exec/runtime/testdata/conformance/action_loop_step_budget.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_loop_step_budget.sysml b/internal/exec/runtime/testdata/conformance/action_loop_step_budget.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_loop_step_budget.sysml rename to internal/exec/runtime/testdata/conformance/action_loop_step_budget.sysml diff --git a/internal/core/runtime/testdata/conformance/action_loop_until.expected.json b/internal/exec/runtime/testdata/conformance/action_loop_until.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_loop_until.expected.json rename to internal/exec/runtime/testdata/conformance/action_loop_until.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_loop_until.sysml b/internal/exec/runtime/testdata/conformance/action_loop_until.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_loop_until.sysml rename to internal/exec/runtime/testdata/conformance/action_loop_until.sysml diff --git a/internal/core/runtime/testdata/conformance/action_loop_until.trace.golden b/internal/exec/runtime/testdata/conformance/action_loop_until.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_loop_until.trace.golden rename to internal/exec/runtime/testdata/conformance/action_loop_until.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_loop_until_repeats.expected.json b/internal/exec/runtime/testdata/conformance/action_loop_until_repeats.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_loop_until_repeats.expected.json rename to internal/exec/runtime/testdata/conformance/action_loop_until_repeats.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_loop_until_repeats.sysml b/internal/exec/runtime/testdata/conformance/action_loop_until_repeats.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_loop_until_repeats.sysml rename to internal/exec/runtime/testdata/conformance/action_loop_until_repeats.sysml diff --git a/internal/core/runtime/testdata/conformance/action_loop_until_repeats.trace.golden b/internal/exec/runtime/testdata/conformance/action_loop_until_repeats.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_loop_until_repeats.trace.golden rename to internal/exec/runtime/testdata/conformance/action_loop_until_repeats.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_member_then_order.expected.json b/internal/exec/runtime/testdata/conformance/action_member_then_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_member_then_order.expected.json rename to internal/exec/runtime/testdata/conformance/action_member_then_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_member_then_order.sysml b/internal/exec/runtime/testdata/conformance/action_member_then_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_member_then_order.sysml rename to internal/exec/runtime/testdata/conformance/action_member_then_order.sysml diff --git a/internal/core/runtime/testdata/conformance/action_member_then_order.trace.golden b/internal/exec/runtime/testdata/conformance/action_member_then_order.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_member_then_order.trace.golden rename to internal/exec/runtime/testdata/conformance/action_member_then_order.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_merge_body_flips_own_guard.expected.json b/internal/exec/runtime/testdata/conformance/action_merge_body_flips_own_guard.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_body_flips_own_guard.expected.json rename to internal/exec/runtime/testdata/conformance/action_merge_body_flips_own_guard.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_merge_body_flips_own_guard.sysml b/internal/exec/runtime/testdata/conformance/action_merge_body_flips_own_guard.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_body_flips_own_guard.sysml rename to internal/exec/runtime/testdata/conformance/action_merge_body_flips_own_guard.sysml diff --git a/internal/core/runtime/testdata/conformance/action_merge_body_flips_own_guard.trace.golden b/internal/exec/runtime/testdata/conformance/action_merge_body_flips_own_guard.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_body_flips_own_guard.trace.golden rename to internal/exec/runtime/testdata/conformance/action_merge_body_flips_own_guard.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_merge_fork_branch_and_loop.expected.json b/internal/exec/runtime/testdata/conformance/action_merge_fork_branch_and_loop.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_fork_branch_and_loop.expected.json rename to internal/exec/runtime/testdata/conformance/action_merge_fork_branch_and_loop.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_merge_fork_branch_and_loop.sysml b/internal/exec/runtime/testdata/conformance/action_merge_fork_branch_and_loop.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_fork_branch_and_loop.sysml rename to internal/exec/runtime/testdata/conformance/action_merge_fork_branch_and_loop.sysml diff --git a/internal/core/runtime/testdata/conformance/action_merge_fork_branch_and_loop.trace.golden b/internal/exec/runtime/testdata/conformance/action_merge_fork_branch_and_loop.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_fork_branch_and_loop.trace.golden rename to internal/exec/runtime/testdata/conformance/action_merge_fork_branch_and_loop.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_merge_loop_reenters.expected.json b/internal/exec/runtime/testdata/conformance/action_merge_loop_reenters.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_loop_reenters.expected.json rename to internal/exec/runtime/testdata/conformance/action_merge_loop_reenters.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_merge_loop_reenters.sysml b/internal/exec/runtime/testdata/conformance/action_merge_loop_reenters.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_loop_reenters.sysml rename to internal/exec/runtime/testdata/conformance/action_merge_loop_reenters.sysml diff --git a/internal/core/runtime/testdata/conformance/action_merge_loop_reenters.trace.golden b/internal/exec/runtime/testdata/conformance/action_merge_loop_reenters.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_loop_reenters.trace.golden rename to internal/exec/runtime/testdata/conformance/action_merge_loop_reenters.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_merge_loop_three_passes.expected.json b/internal/exec/runtime/testdata/conformance/action_merge_loop_three_passes.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_loop_three_passes.expected.json rename to internal/exec/runtime/testdata/conformance/action_merge_loop_three_passes.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_merge_loop_three_passes.sysml b/internal/exec/runtime/testdata/conformance/action_merge_loop_three_passes.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_loop_three_passes.sysml rename to internal/exec/runtime/testdata/conformance/action_merge_loop_three_passes.sysml diff --git a/internal/core/runtime/testdata/conformance/action_merge_loop_three_passes.trace.golden b/internal/exec/runtime/testdata/conformance/action_merge_loop_three_passes.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_merge_loop_three_passes.trace.golden rename to internal/exec/runtime/testdata/conformance/action_merge_loop_three_passes.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_named_loop_body_parameter.expected.json b/internal/exec/runtime/testdata/conformance/action_named_loop_body_parameter.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_named_loop_body_parameter.expected.json rename to internal/exec/runtime/testdata/conformance/action_named_loop_body_parameter.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_named_loop_body_parameter.sysml b/internal/exec/runtime/testdata/conformance/action_named_loop_body_parameter.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_named_loop_body_parameter.sysml rename to internal/exec/runtime/testdata/conformance/action_named_loop_body_parameter.sysml diff --git a/internal/core/runtime/testdata/conformance/action_nested_calc_reads_performance.expected.json b/internal/exec/runtime/testdata/conformance/action_nested_calc_reads_performance.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_calc_reads_performance.expected.json rename to internal/exec/runtime/testdata/conformance/action_nested_calc_reads_performance.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_nested_calc_reads_performance.sysml b/internal/exec/runtime/testdata/conformance/action_nested_calc_reads_performance.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_calc_reads_performance.sysml rename to internal/exec/runtime/testdata/conformance/action_nested_calc_reads_performance.sysml diff --git a/internal/core/runtime/testdata/conformance/action_nested_flow_guarded_inner_succession.expected.json b/internal/exec/runtime/testdata/conformance/action_nested_flow_guarded_inner_succession.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_flow_guarded_inner_succession.expected.json rename to internal/exec/runtime/testdata/conformance/action_nested_flow_guarded_inner_succession.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_nested_flow_guarded_inner_succession.sysml b/internal/exec/runtime/testdata/conformance/action_nested_flow_guarded_inner_succession.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_flow_guarded_inner_succession.sysml rename to internal/exec/runtime/testdata/conformance/action_nested_flow_guarded_inner_succession.sysml diff --git a/internal/core/runtime/testdata/conformance/action_nested_flow_in_fork_join.expected.json b/internal/exec/runtime/testdata/conformance/action_nested_flow_in_fork_join.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_flow_in_fork_join.expected.json rename to internal/exec/runtime/testdata/conformance/action_nested_flow_in_fork_join.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_nested_flow_in_fork_join.sysml b/internal/exec/runtime/testdata/conformance/action_nested_flow_in_fork_join.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_flow_in_fork_join.sysml rename to internal/exec/runtime/testdata/conformance/action_nested_flow_in_fork_join.sysml diff --git a/internal/core/runtime/testdata/conformance/action_nested_flow_in_fork_join.trace.golden b/internal/exec/runtime/testdata/conformance/action_nested_flow_in_fork_join.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_flow_in_fork_join.trace.golden rename to internal/exec/runtime/testdata/conformance/action_nested_flow_in_fork_join.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_nested_flow_two_levels.expected.json b/internal/exec/runtime/testdata/conformance/action_nested_flow_two_levels.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_flow_two_levels.expected.json rename to internal/exec/runtime/testdata/conformance/action_nested_flow_two_levels.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_nested_flow_two_levels.sysml b/internal/exec/runtime/testdata/conformance/action_nested_flow_two_levels.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_flow_two_levels.sysml rename to internal/exec/runtime/testdata/conformance/action_nested_flow_two_levels.sysml diff --git a/internal/core/runtime/testdata/conformance/action_nested_flow_two_levels.trace.golden b/internal/exec/runtime/testdata/conformance/action_nested_flow_two_levels.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_flow_two_levels.trace.golden rename to internal/exec/runtime/testdata/conformance/action_nested_flow_two_levels.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_nested_flow_writes_value.expected.json b/internal/exec/runtime/testdata/conformance/action_nested_flow_writes_value.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_flow_writes_value.expected.json rename to internal/exec/runtime/testdata/conformance/action_nested_flow_writes_value.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_nested_flow_writes_value.sysml b/internal/exec/runtime/testdata/conformance/action_nested_flow_writes_value.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_flow_writes_value.sysml rename to internal/exec/runtime/testdata/conformance/action_nested_flow_writes_value.sysml diff --git a/internal/core/runtime/testdata/conformance/action_nested_invocation.expected.json b/internal/exec/runtime/testdata/conformance/action_nested_invocation.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_invocation.expected.json rename to internal/exec/runtime/testdata/conformance/action_nested_invocation.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_nested_invocation.sysml b/internal/exec/runtime/testdata/conformance/action_nested_invocation.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_invocation.sysml rename to internal/exec/runtime/testdata/conformance/action_nested_invocation.sysml diff --git a/internal/core/runtime/testdata/conformance/action_nested_leaf_body_preserved.expected.json b/internal/exec/runtime/testdata/conformance/action_nested_leaf_body_preserved.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_leaf_body_preserved.expected.json rename to internal/exec/runtime/testdata/conformance/action_nested_leaf_body_preserved.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_nested_leaf_body_preserved.sysml b/internal/exec/runtime/testdata/conformance/action_nested_leaf_body_preserved.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_leaf_body_preserved.sysml rename to internal/exec/runtime/testdata/conformance/action_nested_leaf_body_preserved.sysml diff --git a/internal/core/runtime/testdata/conformance/action_nested_loop_if.expected.json b/internal/exec/runtime/testdata/conformance/action_nested_loop_if.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_loop_if.expected.json rename to internal/exec/runtime/testdata/conformance/action_nested_loop_if.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_nested_loop_if.sysml b/internal/exec/runtime/testdata/conformance/action_nested_loop_if.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_loop_if.sysml rename to internal/exec/runtime/testdata/conformance/action_nested_loop_if.sysml diff --git a/internal/core/runtime/testdata/conformance/action_nested_loop_if.trace.golden b/internal/exec/runtime/testdata/conformance/action_nested_loop_if.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_loop_if.trace.golden rename to internal/exec/runtime/testdata/conformance/action_nested_loop_if.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.expected.json b/internal/exec/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.expected.json rename to internal/exec/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.sysml b/internal/exec/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.sysml rename to internal/exec/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.sysml diff --git a/internal/core/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.trace.golden b/internal/exec/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.trace.golden rename to internal/exec/runtime/testdata/conformance/action_nested_node_two_successions_per_performance.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_node_arguments_before_defaults.expected.json b/internal/exec/runtime/testdata/conformance/action_node_arguments_before_defaults.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_arguments_before_defaults.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_arguments_before_defaults.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_arguments_before_defaults.sysml b/internal/exec/runtime/testdata/conformance/action_node_arguments_before_defaults.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_arguments_before_defaults.sysml rename to internal/exec/runtime/testdata/conformance/action_node_arguments_before_defaults.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_arguments_read_caller.expected.json b/internal/exec/runtime/testdata/conformance/action_node_arguments_read_caller.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_arguments_read_caller.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_arguments_read_caller.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_arguments_read_caller.sysml b/internal/exec/runtime/testdata/conformance/action_node_arguments_read_caller.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_arguments_read_caller.sysml rename to internal/exec/runtime/testdata/conformance/action_node_arguments_read_caller.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_input.expected.json b/internal/exec/runtime/testdata/conformance/action_node_bind_input.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_input.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_bind_input.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_input.sysml b/internal/exec/runtime/testdata/conformance/action_node_bind_input.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_input.sysml rename to internal/exec/runtime/testdata/conformance/action_node_bind_input.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_input_agreeing.expected.json b/internal/exec/runtime/testdata/conformance/action_node_bind_input_agreeing.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_input_agreeing.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_bind_input_agreeing.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_input_agreeing.sysml b/internal/exec/runtime/testdata/conformance/action_node_bind_input_agreeing.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_input_agreeing.sysml rename to internal/exec/runtime/testdata/conformance/action_node_bind_input_agreeing.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_nested_pin_path.expected.json b/internal/exec/runtime/testdata/conformance/action_node_bind_nested_pin_path.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_nested_pin_path.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_bind_nested_pin_path.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_nested_pin_path.sysml b/internal/exec/runtime/testdata/conformance/action_node_bind_nested_pin_path.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_nested_pin_path.sysml rename to internal/exec/runtime/testdata/conformance/action_node_bind_nested_pin_path.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_nested_pin_quantity_kind.expected.json b/internal/exec/runtime/testdata/conformance/action_node_bind_nested_pin_quantity_kind.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_nested_pin_quantity_kind.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_bind_nested_pin_quantity_kind.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_nested_pin_quantity_kind.sysml b/internal/exec/runtime/testdata/conformance/action_node_bind_nested_pin_quantity_kind.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_nested_pin_quantity_kind.sysml rename to internal/exec/runtime/testdata/conformance/action_node_bind_nested_pin_quantity_kind.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_nested_to_enclosing.expected.json b/internal/exec/runtime/testdata/conformance/action_node_bind_nested_to_enclosing.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_nested_to_enclosing.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_bind_nested_to_enclosing.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_nested_to_enclosing.sysml b/internal/exec/runtime/testdata/conformance/action_node_bind_nested_to_enclosing.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_nested_to_enclosing.sysml rename to internal/exec/runtime/testdata/conformance/action_node_bind_nested_to_enclosing.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_output.expected.json b/internal/exec/runtime/testdata/conformance/action_node_bind_output.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_output.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_bind_output.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_output.sysml b/internal/exec/runtime/testdata/conformance/action_node_bind_output.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_output.sysml rename to internal/exec/runtime/testdata/conformance/action_node_bind_output.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_output_through_chain.expected.json b/internal/exec/runtime/testdata/conformance/action_node_bind_output_through_chain.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_output_through_chain.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_bind_output_through_chain.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_output_through_chain.sysml b/internal/exec/runtime/testdata/conformance/action_node_bind_output_through_chain.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_output_through_chain.sysml rename to internal/exec/runtime/testdata/conformance/action_node_bind_output_through_chain.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_overrides_default.expected.json b/internal/exec/runtime/testdata/conformance/action_node_bind_overrides_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_overrides_default.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_bind_overrides_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_overrides_default.sysml b/internal/exec/runtime/testdata/conformance/action_node_bind_overrides_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_overrides_default.sysml rename to internal/exec/runtime/testdata/conformance/action_node_bind_overrides_default.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_undirected_attribute.expected.json b/internal/exec/runtime/testdata/conformance/action_node_bind_undirected_attribute.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_undirected_attribute.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_bind_undirected_attribute.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_undirected_attribute.sysml b/internal/exec/runtime/testdata/conformance/action_node_bind_undirected_attribute.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_undirected_attribute.sysml rename to internal/exec/runtime/testdata/conformance/action_node_bind_undirected_attribute.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_undirected_through_chain.expected.json b/internal/exec/runtime/testdata/conformance/action_node_bind_undirected_through_chain.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_undirected_through_chain.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_bind_undirected_through_chain.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_bind_undirected_through_chain.sysml b/internal/exec/runtime/testdata/conformance/action_node_bind_undirected_through_chain.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_bind_undirected_through_chain.sysml rename to internal/exec/runtime/testdata/conformance/action_node_bind_undirected_through_chain.sysml diff --git a/internal/exec/runtime/testdata/conformance/action_node_body_performs_action.expected.json b/internal/exec/runtime/testdata/conformance/action_node_body_performs_action.expected.json new file mode 100644 index 0000000000..a9109fd785 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_node_body_performs_action.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "evaluate": "test::host", + "libraries": true, + "outputs": { + "x": {"type": "Integer", "value": 60}, + "cleaned": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_node_body_performs_action.sysml b/internal/exec/runtime/testdata/conformance/action_node_body_performs_action.sysml new file mode 100644 index 0000000000..60a2e01066 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_node_body_performs_action.sysml @@ -0,0 +1,27 @@ +package test { + private import ScalarValues::*; + + action def Cleanup { + out cleaned : Integer; + first start; + then action wipe { assign cleaned := 5; } + then done; + } + + // A leaf action node's body performs an action among its statements: the + // performance runs where it is written, its `out cleaned` comes back to the + // host's own, and the statement after it reads what came back. + action host { + out attribute x : Integer = 0; + out attribute cleaned : Integer = 0; + + first start; + then action a1 { + assign x := 1; + perform action cleanup : Cleanup; + assign x := x + cleaned; + } + then action a2 { assign x := x * 10; } + then done; + } +} diff --git a/internal/core/runtime/testdata/conformance/action_node_body_writes_enclosing.expected.json b/internal/exec/runtime/testdata/conformance/action_node_body_writes_enclosing.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_body_writes_enclosing.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_body_writes_enclosing.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_body_writes_enclosing.sysml b/internal/exec/runtime/testdata/conformance/action_node_body_writes_enclosing.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_body_writes_enclosing.sysml rename to internal/exec/runtime/testdata/conformance/action_node_body_writes_enclosing.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_concurrent_nested_bindings.expected.json b/internal/exec/runtime/testdata/conformance/action_node_concurrent_nested_bindings.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_concurrent_nested_bindings.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_concurrent_nested_bindings.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_concurrent_nested_bindings.sysml b/internal/exec/runtime/testdata/conformance/action_node_concurrent_nested_bindings.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_concurrent_nested_bindings.sysml rename to internal/exec/runtime/testdata/conformance/action_node_concurrent_nested_bindings.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_concurrent_nested_bindings.trace.golden b/internal/exec/runtime/testdata/conformance/action_node_concurrent_nested_bindings.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_concurrent_nested_bindings.trace.golden rename to internal/exec/runtime/testdata/conformance/action_node_concurrent_nested_bindings.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_node_concurrent_performances.expected.json b/internal/exec/runtime/testdata/conformance/action_node_concurrent_performances.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_concurrent_performances.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_concurrent_performances.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_concurrent_performances.sysml b/internal/exec/runtime/testdata/conformance/action_node_concurrent_performances.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_concurrent_performances.sysml rename to internal/exec/runtime/testdata/conformance/action_node_concurrent_performances.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_concurrent_performances.trace.golden b/internal/exec/runtime/testdata/conformance/action_node_concurrent_performances.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_concurrent_performances.trace.golden rename to internal/exec/runtime/testdata/conformance/action_node_concurrent_performances.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_node_converges_after_decision.expected.json b/internal/exec/runtime/testdata/conformance/action_node_converges_after_decision.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_converges_after_decision.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_converges_after_decision.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_converges_after_decision.sysml b/internal/exec/runtime/testdata/conformance/action_node_converges_after_decision.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_converges_after_decision.sysml rename to internal/exec/runtime/testdata/conformance/action_node_converges_after_decision.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_converges_after_decision.trace.golden b/internal/exec/runtime/testdata/conformance/action_node_converges_after_decision.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_converges_after_decision.trace.golden rename to internal/exec/runtime/testdata/conformance/action_node_converges_after_decision.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_node_default_reads_calc_per_performance.expected.json b/internal/exec/runtime/testdata/conformance/action_node_default_reads_calc_per_performance.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_default_reads_calc_per_performance.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_default_reads_calc_per_performance.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_default_reads_calc_per_performance.sysml b/internal/exec/runtime/testdata/conformance/action_node_default_reads_calc_per_performance.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_default_reads_calc_per_performance.sysml rename to internal/exec/runtime/testdata/conformance/action_node_default_reads_calc_per_performance.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_dependent_default.expected.json b/internal/exec/runtime/testdata/conformance/action_node_dependent_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_dependent_default.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_dependent_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_dependent_default.sysml b/internal/exec/runtime/testdata/conformance/action_node_dependent_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_dependent_default.sysml rename to internal/exec/runtime/testdata/conformance/action_node_dependent_default.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_feature_shadows_sibling_node.expected.json b/internal/exec/runtime/testdata/conformance/action_node_feature_shadows_sibling_node.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_feature_shadows_sibling_node.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_feature_shadows_sibling_node.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_feature_shadows_sibling_node.sysml b/internal/exec/runtime/testdata/conformance/action_node_feature_shadows_sibling_node.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_feature_shadows_sibling_node.sysml rename to internal/exec/runtime/testdata/conformance/action_node_feature_shadows_sibling_node.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_inherited_default.expected.json b/internal/exec/runtime/testdata/conformance/action_node_inherited_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_inherited_default.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_inherited_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_inherited_default.sysml b/internal/exec/runtime/testdata/conformance/action_node_inherited_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_inherited_default.sysml rename to internal/exec/runtime/testdata/conformance/action_node_inherited_default.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_inherited_parameters.expected.json b/internal/exec/runtime/testdata/conformance/action_node_inherited_parameters.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_inherited_parameters.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_inherited_parameters.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_inherited_parameters.sysml b/internal/exec/runtime/testdata/conformance/action_node_inherited_parameters.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_inherited_parameters.sysml rename to internal/exec/runtime/testdata/conformance/action_node_inherited_parameters.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_empty.expected.json b/internal/exec/runtime/testdata/conformance/action_node_invocation_empty.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_empty.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_invocation_empty.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_empty.sysml b/internal/exec/runtime/testdata/conformance/action_node_invocation_empty.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_empty.sysml rename to internal/exec/runtime/testdata/conformance/action_node_invocation_empty.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_named.expected.json b/internal/exec/runtime/testdata/conformance/action_node_invocation_named.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_named.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_invocation_named.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_named.sysml b/internal/exec/runtime/testdata/conformance/action_node_invocation_named.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_named.sysml rename to internal/exec/runtime/testdata/conformance/action_node_invocation_named.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_overload_inherited_input.expected.json b/internal/exec/runtime/testdata/conformance/action_node_invocation_overload_inherited_input.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_overload_inherited_input.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_invocation_overload_inherited_input.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_overload_inherited_input.sysml b/internal/exec/runtime/testdata/conformance/action_node_invocation_overload_inherited_input.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_overload_inherited_input.sysml rename to internal/exec/runtime/testdata/conformance/action_node_invocation_overload_inherited_input.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_overload_undetermined_argument.expected.json b/internal/exec/runtime/testdata/conformance/action_node_invocation_overload_undetermined_argument.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_overload_undetermined_argument.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_invocation_overload_undetermined_argument.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_overload_undetermined_argument.sysml b/internal/exec/runtime/testdata/conformance/action_node_invocation_overload_undetermined_argument.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_overload_undetermined_argument.sysml rename to internal/exec/runtime/testdata/conformance/action_node_invocation_overload_undetermined_argument.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_overload_undetermined_result_name.expected.json b/internal/exec/runtime/testdata/conformance/action_node_invocation_overload_undetermined_result_name.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_overload_undetermined_result_name.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_invocation_overload_undetermined_result_name.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_overload_undetermined_result_name.sysml b/internal/exec/runtime/testdata/conformance/action_node_invocation_overload_undetermined_result_name.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_overload_undetermined_result_name.sysml rename to internal/exec/runtime/testdata/conformance/action_node_invocation_overload_undetermined_result_name.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_positional.expected.json b/internal/exec/runtime/testdata/conformance/action_node_invocation_positional.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_positional.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_invocation_positional.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_invocation_positional.sysml b/internal/exec/runtime/testdata/conformance/action_node_invocation_positional.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_invocation_positional.sysml rename to internal/exec/runtime/testdata/conformance/action_node_invocation_positional.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_loop_back_reperforms.expected.json b/internal/exec/runtime/testdata/conformance/action_node_loop_back_reperforms.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_loop_back_reperforms.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_loop_back_reperforms.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_loop_back_reperforms.sysml b/internal/exec/runtime/testdata/conformance/action_node_loop_back_reperforms.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_loop_back_reperforms.sysml rename to internal/exec/runtime/testdata/conformance/action_node_loop_back_reperforms.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_loop_back_reperforms.trace.golden b/internal/exec/runtime/testdata/conformance/action_node_loop_back_reperforms.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_loop_back_reperforms.trace.golden rename to internal/exec/runtime/testdata/conformance/action_node_loop_back_reperforms.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_node_pin_object_member.expected.json b/internal/exec/runtime/testdata/conformance/action_node_pin_object_member.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_pin_object_member.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_pin_object_member.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_pin_object_member.sysml b/internal/exec/runtime/testdata/conformance/action_node_pin_object_member.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_pin_object_member.sysml rename to internal/exec/runtime/testdata/conformance/action_node_pin_object_member.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_pin_read_before_performed.expected.json b/internal/exec/runtime/testdata/conformance/action_node_pin_read_before_performed.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_pin_read_before_performed.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_pin_read_before_performed.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_pin_read_before_performed.sysml b/internal/exec/runtime/testdata/conformance/action_node_pin_read_before_performed.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_pin_read_before_performed.sysml rename to internal/exec/runtime/testdata/conformance/action_node_pin_read_before_performed.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_pins_isolated.expected.json b/internal/exec/runtime/testdata/conformance/action_node_pins_isolated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_pins_isolated.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_pins_isolated.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_pins_isolated.sysml b/internal/exec/runtime/testdata/conformance/action_node_pins_isolated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_pins_isolated.sysml rename to internal/exec/runtime/testdata/conformance/action_node_pins_isolated.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_pins_isolated.trace.golden b/internal/exec/runtime/testdata/conformance/action_node_pins_isolated.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_pins_isolated.trace.golden rename to internal/exec/runtime/testdata/conformance/action_node_pins_isolated.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_node_pins_two_levels.expected.json b/internal/exec/runtime/testdata/conformance/action_node_pins_two_levels.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_pins_two_levels.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_pins_two_levels.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_pins_two_levels.sysml b/internal/exec/runtime/testdata/conformance/action_node_pins_two_levels.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_pins_two_levels.sysml rename to internal/exec/runtime/testdata/conformance/action_node_pins_two_levels.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_pins_two_levels.trace.golden b/internal/exec/runtime/testdata/conformance/action_node_pins_two_levels.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_pins_two_levels.trace.golden rename to internal/exec/runtime/testdata/conformance/action_node_pins_two_levels.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_node_typed_body_inputs.expected.json b/internal/exec/runtime/testdata/conformance/action_node_typed_body_inputs.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_typed_body_inputs.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_typed_body_inputs.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_typed_body_inputs.sysml b/internal/exec/runtime/testdata/conformance/action_node_typed_body_inputs.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_typed_body_inputs.sysml rename to internal/exec/runtime/testdata/conformance/action_node_typed_body_inputs.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_typed_nested_pins.expected.json b/internal/exec/runtime/testdata/conformance/action_node_typed_nested_pins.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_typed_nested_pins.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_typed_nested_pins.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_typed_nested_pins.sysml b/internal/exec/runtime/testdata/conformance/action_node_typed_nested_pins.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_typed_nested_pins.sysml rename to internal/exec/runtime/testdata/conformance/action_node_typed_nested_pins.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.expected.json b/internal/exec/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.expected.json rename to internal/exec/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.sysml b/internal/exec/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.sysml rename to internal/exec/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.sysml diff --git a/internal/core/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.trace.golden b/internal/exec/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.trace.golden rename to internal/exec/runtime/testdata/conformance/action_node_with_two_incoming_successions_runs_once.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_output.expected.json b/internal/exec/runtime/testdata/conformance/action_output.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_output.expected.json rename to internal/exec/runtime/testdata/conformance/action_output.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_output.sysml b/internal/exec/runtime/testdata/conformance/action_output.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_output.sysml rename to internal/exec/runtime/testdata/conformance/action_output.sysml diff --git a/internal/core/runtime/testdata/conformance/action_param_default_scalar_count.expected.json b/internal/exec/runtime/testdata/conformance/action_param_default_scalar_count.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_param_default_scalar_count.expected.json rename to internal/exec/runtime/testdata/conformance/action_param_default_scalar_count.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_param_default_scalar_count.sysml b/internal/exec/runtime/testdata/conformance/action_param_default_scalar_count.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_param_default_scalar_count.sysml rename to internal/exec/runtime/testdata/conformance/action_param_default_scalar_count.sysml diff --git a/internal/exec/runtime/testdata/conformance/action_part_attributes_in_outcome.expected.json b/internal/exec/runtime/testdata/conformance/action_part_attributes_in_outcome.expected.json new file mode 100644 index 0000000000..40aa4cd533 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_part_attributes_in_outcome.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "evaluate": "test::Cfg", + "outputs": { + "target.total": {"type": "Real", "value": 2.5}, + "target.tries": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_part_attributes_in_outcome.sysml b/internal/exec/runtime/testdata/conformance/action_part_attributes_in_outcome.sysml new file mode 100644 index 0000000000..97a2effd5b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_part_attributes_in_outcome.sysml @@ -0,0 +1,22 @@ +// The outcome of an action holds, under `part.attribute`, what the one object each +// of its own parts denotes holds when it completes — the values a performed behavior +// left on `target` — so a run configuration's observables are read off its target; +// a part of several objects is not spelled, having no one value per attribute. +package test { + private import ScalarValues::*; + + part def Probe { + attribute total : Real = 0.0; + attribute tries : Integer = 0; + perform action measure { + action step { assign total := total + 2.5; assign tries := tries + 1; } + first step; + } + } + + action def Cfg { + part target : Probe; + part spares : Probe[2]; + perform action run ::> target.measure; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_perform_chain_joins_object_performance.expected.json b/internal/exec/runtime/testdata/conformance/action_perform_chain_joins_object_performance.expected.json new file mode 100644 index 0000000000..76717fb0a2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_perform_chain_joins_object_performance.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "evaluate": "test::Cfg", + "outputs": { + "result": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_perform_chain_joins_object_performance.sysml b/internal/exec/runtime/testdata/conformance/action_perform_chain_joins_object_performance.sysml new file mode 100644 index 0000000000..5cf7571218 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_perform_chain_joins_object_performance.sysml @@ -0,0 +1,22 @@ +// `perform action run ::> target.go` performs `go` on the object `target` holds; an +// object whose type performs `go` already runs it once, so the caller joins that +// performance rather than starting a second, and reads what it left on the object. +package test { + private import ScalarValues::*; + + part def Host { + attribute count : Integer = 0; + perform action go { + action step { assign count := count + 1; } + first step; + } + } + + action def Cfg { + part target : Host; + out result : Integer; + perform action run ::> target.go; + action read { assign result := target.count; } + first run then read; + } +} diff --git a/internal/core/runtime/testdata/conformance/action_perform_reference.expected.json b/internal/exec/runtime/testdata/conformance/action_perform_reference.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_perform_reference.expected.json rename to internal/exec/runtime/testdata/conformance/action_perform_reference.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_perform_reference.sysml b/internal/exec/runtime/testdata/conformance/action_perform_reference.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_perform_reference.sysml rename to internal/exec/runtime/testdata/conformance/action_perform_reference.sysml diff --git a/internal/core/runtime/testdata/conformance/action_perform_shorthand.expected.json b/internal/exec/runtime/testdata/conformance/action_perform_shorthand.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_perform_shorthand.expected.json rename to internal/exec/runtime/testdata/conformance/action_perform_shorthand.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_perform_shorthand.sysml b/internal/exec/runtime/testdata/conformance/action_perform_shorthand.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_perform_shorthand.sysml rename to internal/exec/runtime/testdata/conformance/action_perform_shorthand.sysml diff --git a/internal/core/runtime/testdata/conformance/action_port_communication.expected.json b/internal/exec/runtime/testdata/conformance/action_port_communication.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_port_communication.expected.json rename to internal/exec/runtime/testdata/conformance/action_port_communication.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_port_communication.sysml b/internal/exec/runtime/testdata/conformance/action_port_communication.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_port_communication.sysml rename to internal/exec/runtime/testdata/conformance/action_port_communication.sysml diff --git a/internal/core/runtime/testdata/conformance/action_port_communication.trace.golden b/internal/exec/runtime/testdata/conformance/action_port_communication.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_port_communication.trace.golden rename to internal/exec/runtime/testdata/conformance/action_port_communication.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_port_communication_nary.expected.json b/internal/exec/runtime/testdata/conformance/action_port_communication_nary.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_port_communication_nary.expected.json rename to internal/exec/runtime/testdata/conformance/action_port_communication_nary.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_port_communication_nary.sysml b/internal/exec/runtime/testdata/conformance/action_port_communication_nary.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_port_communication_nary.sysml rename to internal/exec/runtime/testdata/conformance/action_port_communication_nary.sysml diff --git a/internal/core/runtime/testdata/conformance/action_port_communication_nary_anonymous.expected.json b/internal/exec/runtime/testdata/conformance/action_port_communication_nary_anonymous.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_port_communication_nary_anonymous.expected.json rename to internal/exec/runtime/testdata/conformance/action_port_communication_nary_anonymous.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_port_communication_nary_anonymous.sysml b/internal/exec/runtime/testdata/conformance/action_port_communication_nary_anonymous.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_port_communication_nary_anonymous.sysml rename to internal/exec/runtime/testdata/conformance/action_port_communication_nary_anonymous.sysml diff --git a/internal/core/runtime/testdata/conformance/action_quantity_assign.expected.json b/internal/exec/runtime/testdata/conformance/action_quantity_assign.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_quantity_assign.expected.json rename to internal/exec/runtime/testdata/conformance/action_quantity_assign.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_quantity_assign.sysml b/internal/exec/runtime/testdata/conformance/action_quantity_assign.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_quantity_assign.sysml rename to internal/exec/runtime/testdata/conformance/action_quantity_assign.sysml diff --git a/internal/core/runtime/testdata/conformance/action_quantity_assign.trace.golden b/internal/exec/runtime/testdata/conformance/action_quantity_assign.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_quantity_assign.trace.golden rename to internal/exec/runtime/testdata/conformance/action_quantity_assign.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_quantity_dimension_inferred_mismatch.expected.json b/internal/exec/runtime/testdata/conformance/action_quantity_dimension_inferred_mismatch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_quantity_dimension_inferred_mismatch.expected.json rename to internal/exec/runtime/testdata/conformance/action_quantity_dimension_inferred_mismatch.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_quantity_dimension_inferred_mismatch.sysml b/internal/exec/runtime/testdata/conformance/action_quantity_dimension_inferred_mismatch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_quantity_dimension_inferred_mismatch.sysml rename to internal/exec/runtime/testdata/conformance/action_quantity_dimension_inferred_mismatch.sysml diff --git a/internal/core/runtime/testdata/conformance/action_quantity_dimension_scaled.expected.json b/internal/exec/runtime/testdata/conformance/action_quantity_dimension_scaled.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_quantity_dimension_scaled.expected.json rename to internal/exec/runtime/testdata/conformance/action_quantity_dimension_scaled.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_quantity_dimension_scaled.sysml b/internal/exec/runtime/testdata/conformance/action_quantity_dimension_scaled.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_quantity_dimension_scaled.sysml rename to internal/exec/runtime/testdata/conformance/action_quantity_dimension_scaled.sysml diff --git a/internal/core/runtime/testdata/conformance/action_redefined_attribute_default.expected.json b/internal/exec/runtime/testdata/conformance/action_redefined_attribute_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_redefined_attribute_default.expected.json rename to internal/exec/runtime/testdata/conformance/action_redefined_attribute_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_redefined_attribute_default.sysml b/internal/exec/runtime/testdata/conformance/action_redefined_attribute_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_redefined_attribute_default.sysml rename to internal/exec/runtime/testdata/conformance/action_redefined_attribute_default.sysml diff --git a/internal/core/runtime/testdata/conformance/action_redefined_attribute_default_symbol.expected.json b/internal/exec/runtime/testdata/conformance/action_redefined_attribute_default_symbol.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_redefined_attribute_default_symbol.expected.json rename to internal/exec/runtime/testdata/conformance/action_redefined_attribute_default_symbol.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_redefined_attribute_default_symbol.sysml b/internal/exec/runtime/testdata/conformance/action_redefined_attribute_default_symbol.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_redefined_attribute_default_symbol.sysml rename to internal/exec/runtime/testdata/conformance/action_redefined_attribute_default_symbol.sysml diff --git a/internal/core/runtime/testdata/conformance/action_redefined_attribute_short_name.expected.json b/internal/exec/runtime/testdata/conformance/action_redefined_attribute_short_name.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_redefined_attribute_short_name.expected.json rename to internal/exec/runtime/testdata/conformance/action_redefined_attribute_short_name.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_redefined_attribute_short_name.sysml b/internal/exec/runtime/testdata/conformance/action_redefined_attribute_short_name.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_redefined_attribute_short_name.sysml rename to internal/exec/runtime/testdata/conformance/action_redefined_attribute_short_name.sysml diff --git a/internal/core/runtime/testdata/conformance/action_redefined_step_ordering.expected.json b/internal/exec/runtime/testdata/conformance/action_redefined_step_ordering.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_redefined_step_ordering.expected.json rename to internal/exec/runtime/testdata/conformance/action_redefined_step_ordering.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_redefined_step_ordering.sysml b/internal/exec/runtime/testdata/conformance/action_redefined_step_ordering.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_redefined_step_ordering.sysml rename to internal/exec/runtime/testdata/conformance/action_redefined_step_ordering.sysml diff --git a/internal/core/runtime/testdata/conformance/action_redefined_step_ordering.trace.golden b/internal/exec/runtime/testdata/conformance/action_redefined_step_ordering.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_redefined_step_ordering.trace.golden rename to internal/exec/runtime/testdata/conformance/action_redefined_step_ordering.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_send_accept.expected.json b/internal/exec/runtime/testdata/conformance/action_send_accept.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_send_accept.expected.json rename to internal/exec/runtime/testdata/conformance/action_send_accept.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_send_accept.sysml b/internal/exec/runtime/testdata/conformance/action_send_accept.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_send_accept.sysml rename to internal/exec/runtime/testdata/conformance/action_send_accept.sysml diff --git a/internal/core/runtime/testdata/conformance/action_send_accept.trace.golden b/internal/exec/runtime/testdata/conformance/action_send_accept.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_send_accept.trace.golden rename to internal/exec/runtime/testdata/conformance/action_send_accept.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_send_invocation_via_port.expected.json b/internal/exec/runtime/testdata/conformance/action_send_invocation_via_port.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_send_invocation_via_port.expected.json rename to internal/exec/runtime/testdata/conformance/action_send_invocation_via_port.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_send_invocation_via_port.sysml b/internal/exec/runtime/testdata/conformance/action_send_invocation_via_port.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_send_invocation_via_port.sysml rename to internal/exec/runtime/testdata/conformance/action_send_invocation_via_port.sysml diff --git a/internal/core/runtime/testdata/conformance/action_send_invocation_via_port.trace.golden b/internal/exec/runtime/testdata/conformance/action_send_invocation_via_port.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_send_invocation_via_port.trace.golden rename to internal/exec/runtime/testdata/conformance/action_send_invocation_via_port.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_send_library_typed_feature.expected.json b/internal/exec/runtime/testdata/conformance/action_send_library_typed_feature.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_send_library_typed_feature.expected.json rename to internal/exec/runtime/testdata/conformance/action_send_library_typed_feature.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_send_library_typed_feature.sysml b/internal/exec/runtime/testdata/conformance/action_send_library_typed_feature.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_send_library_typed_feature.sysml rename to internal/exec/runtime/testdata/conformance/action_send_library_typed_feature.sysml diff --git a/internal/core/runtime/testdata/conformance/action_standard_loop_until_then_done.expected.json b/internal/exec/runtime/testdata/conformance/action_standard_loop_until_then_done.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_standard_loop_until_then_done.expected.json rename to internal/exec/runtime/testdata/conformance/action_standard_loop_until_then_done.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_standard_loop_until_then_done.sysml b/internal/exec/runtime/testdata/conformance/action_standard_loop_until_then_done.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_standard_loop_until_then_done.sysml rename to internal/exec/runtime/testdata/conformance/action_standard_loop_until_then_done.sysml diff --git a/internal/core/runtime/testdata/conformance/action_standard_loop_until_then_done.trace.golden b/internal/exec/runtime/testdata/conformance/action_standard_loop_until_then_done.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_standard_loop_until_then_done.trace.golden rename to internal/exec/runtime/testdata/conformance/action_standard_loop_until_then_done.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_fails.expected.json b/internal/exec/runtime/testdata/conformance/action_succession_guard_fails.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_fails.expected.json rename to internal/exec/runtime/testdata/conformance/action_succession_guard_fails.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_fails.sysml b/internal/exec/runtime/testdata/conformance/action_succession_guard_fails.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_fails.sysml rename to internal/exec/runtime/testdata/conformance/action_succession_guard_fails.sysml diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_fails.trace.golden b/internal/exec/runtime/testdata/conformance/action_succession_guard_fails.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_fails.trace.golden rename to internal/exec/runtime/testdata/conformance/action_succession_guard_fails.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.expected.json b/internal/exec/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.expected.json rename to internal/exec/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.sysml b/internal/exec/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.sysml rename to internal/exec/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.sysml diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.trace.golden b/internal/exec/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.trace.golden rename to internal/exec/runtime/testdata/conformance/action_succession_guard_fork_branch_pruned.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_holds.expected.json b/internal/exec/runtime/testdata/conformance/action_succession_guard_holds.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_holds.expected.json rename to internal/exec/runtime/testdata/conformance/action_succession_guard_holds.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_holds.sysml b/internal/exec/runtime/testdata/conformance/action_succession_guard_holds.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_holds.sysml rename to internal/exec/runtime/testdata/conformance/action_succession_guard_holds.sysml diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_holds.trace.golden b/internal/exec/runtime/testdata/conformance/action_succession_guard_holds.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_holds.trace.golden rename to internal/exec/runtime/testdata/conformance/action_succession_guard_holds.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_not_boolean.expected.json b/internal/exec/runtime/testdata/conformance/action_succession_guard_not_boolean.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_not_boolean.expected.json rename to internal/exec/runtime/testdata/conformance/action_succession_guard_not_boolean.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_not_boolean.sysml b/internal/exec/runtime/testdata/conformance/action_succession_guard_not_boolean.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_not_boolean.sysml rename to internal/exec/runtime/testdata/conformance/action_succession_guard_not_boolean.sysml diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_two_branches.expected.json b/internal/exec/runtime/testdata/conformance/action_succession_guard_two_branches.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_two_branches.expected.json rename to internal/exec/runtime/testdata/conformance/action_succession_guard_two_branches.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_two_branches.sysml b/internal/exec/runtime/testdata/conformance/action_succession_guard_two_branches.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_two_branches.sysml rename to internal/exec/runtime/testdata/conformance/action_succession_guard_two_branches.sysml diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_two_branches.trace.golden b/internal/exec/runtime/testdata/conformance/action_succession_guard_two_branches.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_two_branches.trace.golden rename to internal/exec/runtime/testdata/conformance/action_succession_guard_two_branches.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_two_hold.expected.json b/internal/exec/runtime/testdata/conformance/action_succession_guard_two_hold.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_two_hold.expected.json rename to internal/exec/runtime/testdata/conformance/action_succession_guard_two_hold.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_succession_guard_two_hold.sysml b/internal/exec/runtime/testdata/conformance/action_succession_guard_two_hold.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_guard_two_hold.sysml rename to internal/exec/runtime/testdata/conformance/action_succession_guard_two_hold.sysml diff --git a/internal/core/runtime/testdata/conformance/action_succession_unknown_target.expected.json b/internal/exec/runtime/testdata/conformance/action_succession_unknown_target.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_unknown_target.expected.json rename to internal/exec/runtime/testdata/conformance/action_succession_unknown_target.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_succession_unknown_target.sysml b/internal/exec/runtime/testdata/conformance/action_succession_unknown_target.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_succession_unknown_target.sysml rename to internal/exec/runtime/testdata/conformance/action_succession_unknown_target.sysml diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_flow_node.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_flow_node.expected.json new file mode 100644 index 0000000000..11dcb6d203 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_flow_node.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_flow_node.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_flow_node.sysml new file mode 100644 index 0000000000..c7e8b09513 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_flow_node.sysml @@ -0,0 +1,16 @@ +package test { + private import ScalarValues::*; + + // `then terminate;` written in the flow ends the action itself: the outputs + // assigned so far stand, and the nodes after it never run. + action guarded { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + + first start; + then action a1 { assign x := 1; } + then terminate; + then action a2 { assign y := 2; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_fork_drops_sibling.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_fork_drops_sibling.expected.json new file mode 100644 index 0000000000..6916cab29b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_fork_drops_sibling.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_fork_drops_sibling.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_fork_drops_sibling.sysml new file mode 100644 index 0000000000..bafbf1d04e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_fork_drops_sibling.sysml @@ -0,0 +1,22 @@ +package test { + private import ScalarValues::*; + + // One fork branch terminates the action while the other is parked at an + // accept no send ever satisfies: the parked token is dropped with the action, + // and the node after the accept never runs. + action racer { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + + first start; + fork split; + action waiter accept sig : Integer; + action tally { assign y := 1; } + action quit { assign x := 1; } + then terminate; + succession first start then split; + succession first split then waiter; + succession first waiter then tally; + succession first split then quit; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_fork_drops_sibling.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_fork_drops_sibling.trace.golden new file mode 100644 index 0000000000..69545e54df --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_fork_drops_sibling.trace.golden @@ -0,0 +1,8 @@ +step 1: token 1@split +step 2: token 2@waiter, token 3@quit +stmt assign x + eval literal 1 -> 1 +step 3: token 2@waiter, token 3@*ast.TerminateStatement +stmt terminate +terminate action: dropped token 2@waiter +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_from_block_node_flow.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_from_block_node_flow.expected.json new file mode 100644 index 0000000000..d9100bdc8e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_from_block_node_flow.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "before": {"type": "Integer", "value": 1}, + "inner": {"type": "Integer", "value": 2}, + "skipped": {"type": "Integer", "value": 0}, + "after": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_from_block_node_flow.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_from_block_node_flow.sysml new file mode 100644 index 0000000000..4b3c47ef33 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_from_block_node_flow.sysml @@ -0,0 +1,38 @@ +package test { + private import ScalarValues::*; + + // A node declared in an `if` branch is a node of the branch's own flow, and here + // owns a flow that forks. Its `terminate node;` names the flow node the branch + // runs in: the branch node's performance ends with what it assigned so far, the + // waiter parked in the forked branch is dropped with it, the rest of the branch + // and of the body are skipped, and the parent takes node's succession. + action host { + out attribute before : Integer = 0; + out attribute inner : Integer = 0; + out attribute skipped : Integer = 0; + out attribute after : Integer = 0; + + action node { + assign before := 1; + if before == 1 { + action forked { + fork split; + action waiter accept sig : Integer; + action quit { assign inner := 2; terminate node; } + first start; + succession first start then split; + succession first split then waiter; + succession first split then quit; + succession first waiter then done; + succession first quit then done; + } + action later { assign skipped := 3; } + } + assign skipped := 4; + } + action tail { assign after := 5; } + succession first start then node; + succession first node then tail; + succession first tail then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_from_block_node_flow.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_from_block_node_flow.trace.golden new file mode 100644 index 0000000000..70d2ad85d7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_from_block_node_flow.trace.golden @@ -0,0 +1,18 @@ +step 1: token 1@node +stmt assign before + eval literal 1 -> 1 +stmt if + eval feature before -> 1 + eval literal 1 -> 1 + eval operator == -> true + stmt node forked +enter action node: forked + stmt assign inner + eval literal 2 -> 2 + stmt terminate +terminate action node node: dropped token 3@waiter, token 4@quit +step 2: token 1@tail +stmt assign after + eval literal 5 -> 5 +step 3: token 1@done +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_joined_usage.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_joined_usage.expected.json new file mode 100644 index 0000000000..8f0c034f13 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_joined_usage.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 2}, + "z": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_joined_usage.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_joined_usage.sysml new file mode 100644 index 0000000000..35cbd69506 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_joined_usage.sysml @@ -0,0 +1,23 @@ +package test { + private import ScalarValues::*; + + // Two fork branches both lead to the terminate action usage `stop`, which + // synchronizes their arrivals; the token that then performs it ends the action. + action joined { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + out attribute z : Integer = 0; + + first start; + then fork split; + then a1; + then a2; + action a1 { assign x := 1; } + then stop; + action a2 { assign y := 2; } + then stop; + action stop terminate; + then action after { assign z := 3; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_joined_usage.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_joined_usage.trace.golden new file mode 100644 index 0000000000..f13010411b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_joined_usage.trace.golden @@ -0,0 +1,11 @@ +step 1: token 1@split +step 2: token 2@a1, token 3@a2 +stmt assign y + eval literal 2 -> 2 +stmt assign x + eval literal 1 -> 1 +choice step 3: tokens 2@a1, 3@a2 (unordered; took 3@a2 first) +step 3: token 2@stop, token 3@stop +stmt terminate +terminate action: no token dropped +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_named_usage.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_named_usage.expected.json new file mode 100644 index 0000000000..11dcb6d203 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_named_usage.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_named_usage.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_named_usage.sysml new file mode 100644 index 0000000000..5a1b97f4ac --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_named_usage.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + // `action stop terminate;` declares a terminate action usage; a token reaching + // it through `then stop` ends the action as `then terminate;` would. + action stopper { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + + first start; + then action a1 { assign x := 1; } + then stop; + action stop terminate; + then action a2 { assign y := 2; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_block_node.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_block_node.expected.json new file mode 100644 index 0000000000..adca9f3f42 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_block_node.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "inner": {"type": "Integer", "value": 1}, + "skipped": {"type": "Integer", "value": 0}, + "after": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_block_node.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_block_node.sysml new file mode 100644 index 0000000000..50680d2804 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_block_node.sysml @@ -0,0 +1,35 @@ +package test { + private import ScalarValues::*; + + // `outer`, declared in an `if` branch, is a node of the branch's flow whose own + // members are nodes in turn: a flow of their own, run one after another. The + // `terminate outer;` in forked's forked branch ends outer where it stands: the + // waiter is dropped, `later` never runs, and the branch goes on past outer. + action host { + out attribute inner : Integer = 0; + out attribute skipped : Integer = 0; + out attribute after : Integer = 0; + + action node { + if true { + action outer { + action forked { + fork split; + action waiter accept sig : Integer; + action quit { assign inner := 1; terminate outer; } + first start; + succession first start then split; + succession first split then waiter; + succession first split then quit; + succession first waiter then done; + succession first quit then done; + } + action later { assign skipped := 2; } + } + action next { assign after := 3; } + } + } + succession first start then node; + succession first node then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_block_node.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_block_node.trace.golden new file mode 100644 index 0000000000..e7fe9170d1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_block_node.trace.golden @@ -0,0 +1,16 @@ +step 1: token 1@node +stmt if + eval literal true -> true + stmt node outer + stmt action body + stmt node forked +enter action node: forked + stmt assign inner + eval literal 1 -> 1 + stmt terminate +terminate action node outer: dropped token 3@waiter, token 4@quit + stmt node next + stmt assign after + eval literal 3 -> 3 +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_concurrent_performances.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_concurrent_performances.expected.json new file mode 100644 index 0000000000..4fc8e5cdd1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_concurrent_performances.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "outputs": { + "x": {"type": "Integer", "value": 0}, + "y": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_concurrent_performances.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_concurrent_performances.sysml new file mode 100644 index 0000000000..602f3ca7ea --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_concurrent_performances.sysml @@ -0,0 +1,43 @@ +package test { + private import ScalarValues::*; + private import ISQ::*; + private import SI::*; + + // Two tokens reach slow through the merge one step apart, so two performances of + // slow are paused at once, each holding its own nap. `terminate slow;` names the + // node, so it ends every ongoing performance of it, the earlier begun first: both + // naps are dropped, neither assignment runs, and both tokens take slow's succession. + action host { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + + fork split; + action pre; + merge gate; + action slow { + if x == 0 { + action inner { + first start; + then action nap accept after 10 [s]; + then done; + } + assign x := x + 1; + } + } + action pre1; + action pre2; + action pre3; + action killer { terminate slow; assign y := 1; } + succession first start then split; + succession first split then gate; + succession first split then pre; + succession first pre then gate; + succession first gate then slow; + succession first slow then done; + succession first split then pre1; + succession first pre1 then pre2; + succession first pre2 then pre3; + succession first pre3 then killer; + succession first killer then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_concurrent_performances.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_concurrent_performances.trace.golden new file mode 100644 index 0000000000..740c144af0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_concurrent_performances.trace.golden @@ -0,0 +1,32 @@ +step 1: token 1@split +step 2: token 2@gate, token 3@pre, token 4@pre1 +choice step 3: tokens 2@gate, 3@pre, 4@pre1 (unordered; took 4@pre1 first) +step 3: token 2@slow, token 3@gate, token 4@pre2 +stmt if + eval feature x -> 0 + eval literal 0 -> 0 + eval operator == -> true + stmt node inner +enter action node: inner + eval literal 10 -> 10 + eval index -> 10 [s] +choice step 4: tokens 2@slow, 3@gate, 4@pre2 (unordered; took 4@pre2 first) +step 4: token 2@slow, token 3@slow, token 4@pre3, token 5@nap +stmt if + eval feature x -> 0 + eval literal 0 -> 0 + eval operator == -> true + stmt node inner +enter action node: inner + eval literal 10 -> 10 + eval index -> 10 [s] +choice step 5: tokens 2@slow, 3@slow, 4@pre3 (unordered; took 4@pre3 first) +step 5: token 2@slow, token 3@slow, token 4@killer, token 5@nap, token 6@nap +stmt terminate +terminate action node slow: dropped token 5@nap +terminate action node slow: dropped token 6@nap +stmt assign y + eval literal 1 -> 1 +step 6: token 2@done, token 3@done, token 4@done +choice step 7: tokens 3@done, 4@done (unordered; took 4@done first) +step 7: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_enclosing_node.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_enclosing_node.expected.json new file mode 100644 index 0000000000..75783628b9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_enclosing_node.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 0}, + "z": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_enclosing_node.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_enclosing_node.sysml new file mode 100644 index 0000000000..d550946910 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_enclosing_node.sysml @@ -0,0 +1,24 @@ +package test { + private import ScalarValues::*; + + // `terminate outer;` from a node nested two deep ends the enclosing node it + // names: outer's later node never runs, and the parent continues after outer. + action host { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + out attribute z : Integer = 0; + + first start; + then action outer { + action inner { + assign x := 1; + terminate outer; + } + action late { assign y := 2; } + first inner; + succession first inner then late; + } + then action after { assign z := 3; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.expected.json new file mode 100644 index 0000000000..e312850431 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "before": {"type": "Integer", "value": 1}, + "after": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.sysml new file mode 100644 index 0000000000..cbe9ebd79e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.sysml @@ -0,0 +1,43 @@ +// `terminate ;` naming an object that is no node of the action's +// flow ends that object (SysML v2 §7.17.10): the machine it exhibits is +// terminated where it stands, its do behavior abandoned, while the action stating +// the terminate runs on to its own end. +package test { + private import ScalarValues::*; + + attribute def Never; + + state def Beating { + attribute beats : Integer = 0; + entry; then alive; + state alive { + do action beat { + first start; + then action one assign beats := 1; + then action wait accept Never; + then action two assign beats := 2; + then done; + } + } + } + + part def Heart { + exhibit state rhythm : Beating; + } + + part def Body { + part heart : Heart; + } + + part patient : Body; + + action host { + out attribute before : Integer = 0; + out attribute after : Integer = 0; + first start; + then action check assign before := patient.heart.rhythm.beats; + then action stop { terminate patient.heart; } + then action tail assign after := patient.heart.rhythm.beats; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden new file mode 100644 index 0000000000..352b1f1705 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_exhibiting_part.trace.golden @@ -0,0 +1,31 @@ +step 1: token 1@check +stmt assign before +materialize: patient #1 +materialize: Heart #2 +start: exhibited state machine rhythm of #2 +materialize: Beating #3 +enter: alive +run: exhibited state machine rhythm of #2 +do: alive + stmt action body +enter action node: state behavior beat + stmt assign beats + eval literal 1 -> 1 + eval chain beats -> 1 +step 2: token 1@stop +stmt terminate + eval chain heart -> instance#2 +terminate: Heart #2 +terminated with occurrence: Beating (do behavior abandoned: alive) +step 3: token 1@tail +stmt assign after + eval chain beats -> 1 +step 4: token 1@done +step 5: no active tokens +eval literal 0 -> 0 +enter: alive +do: alive +stmt action body +enter action node: state behavior beat + stmt assign beats + eval literal 1 -> 1 diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_node_of_paused_bodies.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_of_paused_bodies.expected.json new file mode 100644 index 0000000000..e5f0731ecb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_of_paused_bodies.expected.json @@ -0,0 +1,11 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "outputs": { + "entered": {"type": "Integer", "value": 3}, + "napped": {"type": "Integer", "value": 0}, + "passed": {"type": "Integer", "value": 3}, + "later": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_node_of_paused_bodies.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_of_paused_bodies.sysml new file mode 100644 index 0000000000..1852771779 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_of_paused_bodies.sysml @@ -0,0 +1,49 @@ +package test { + private import ScalarValues::*; + private import ISQ::*; + private import SI::*; + + // Three tokens reach the loop node through the merge a step apart, each running its + // body. The first two nap in a flow inside slow, so two performances of slow are + // paused at once, each held where its token's body paused, while the third skips + // the nap and its `terminate slow;` names the node: both paused performances end, + // their naps dropped and neither later assignment run, and both bodies go on past slow. + action host { + out attribute entered : Integer = 0; + out attribute napped : Integer = 0; + out attribute passed : Integer = 0; + out attribute later : Integer = 0; + + fork split; + action pre; + action pre1; + action pre2; + succession first start then split; + succession first split then gate; + succession first split then pre; + succession first pre then gate; + succession first split then pre1; + succession first pre1 then pre2; + succession first pre2 then gate; + merge gate; + then loop { + action slow { + assign entered := entered + 1; + if entered <= 2 { + action inner { + first start; + then action nap accept after 10 [s]; + then done; + } + assign napped := napped + 1; + } + } + assign passed := passed + 1; + if passed == 1 { + terminate slow; + } + assign later := later + 1; + } until true; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_node_of_paused_bodies.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_of_paused_bodies.trace.golden new file mode 100644 index 0000000000..d11486b5a7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_of_paused_bodies.trace.golden @@ -0,0 +1,97 @@ +step 1: token 1@split +step 2: token 2@gate, token 3@pre, token 4@pre1 +choice step 3: tokens 2@gate, 3@pre, 4@pre1 (unordered; took 4@pre1 first) +step 3: token 2@*ast.WhileLoopActionNode, token 3@gate, token 4@pre2 +stmt loop until + iteration 1 + stmt node slow + stmt assign entered + eval feature entered -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + stmt if + eval feature entered -> 1 + eval literal 2 -> 2 + eval operator <= -> true + stmt node inner +enter action node: inner + eval literal 10 -> 10 + eval index -> 10 [s] +choice step 4: tokens 2@*ast.WhileLoopActionNode, 3@gate, 4@pre2 (unordered; took 4@pre2 first) +step 4: token 2@*ast.WhileLoopActionNode, token 3@*ast.WhileLoopActionNode, token 4@gate, token 5@nap +stmt loop until + iteration 1 + stmt node slow + stmt assign entered + eval feature entered -> 1 + eval literal 1 -> 1 + eval operator + -> 2 + stmt if + eval feature entered -> 2 + eval literal 2 -> 2 + eval operator <= -> true + stmt node inner +enter action node: inner + eval literal 10 -> 10 + eval index -> 10 [s] +choice step 5: tokens 2@*ast.WhileLoopActionNode, 3@*ast.WhileLoopActionNode, 4@gate (unordered; took 4@gate first) +step 5: token 2@*ast.WhileLoopActionNode, token 3@*ast.WhileLoopActionNode, token 4@*ast.WhileLoopActionNode, token 5@nap, token 6@nap +stmt loop until + iteration 1 + stmt node slow + stmt assign entered + eval feature entered -> 2 + eval literal 1 -> 1 + eval operator + -> 3 + stmt if + eval feature entered -> 3 + eval literal 2 -> 2 + eval operator <= -> false + stmt assign passed + eval feature passed -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + stmt if + eval feature passed -> 1 + eval literal 1 -> 1 + eval operator == -> true + stmt terminate +terminate action node slow: dropped token 5@nap +terminate action node slow: dropped token 6@nap + stmt assign later + eval feature later -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + eval literal true -> true + stmt assign passed + eval feature passed -> 1 + eval literal 1 -> 1 + eval operator + -> 2 + stmt if + eval feature passed -> 2 + eval literal 1 -> 1 + eval operator == -> false + stmt assign later + eval feature later -> 1 + eval literal 1 -> 1 + eval operator + -> 2 + eval literal true -> true + stmt assign passed + eval feature passed -> 2 + eval literal 1 -> 1 + eval operator + -> 3 + stmt if + eval feature passed -> 3 + eval literal 1 -> 1 + eval operator == -> false + stmt assign later + eval feature later -> 2 + eval literal 1 -> 1 + eval operator + -> 3 + eval literal true -> true +choice step 6: writes passed := 3 by token 2, passed := 2 by token 3, passed := 1 by token 4 (unordered; passed := 3 by token 2 stood) +choice step 6: writes later := 3 by token 2, later := 2 by token 3, later := 1 by token 4 (unordered; later := 3 by token 2 stood) +choice step 6: tokens 2@*ast.WhileLoopActionNode, 3@*ast.WhileLoopActionNode, 4@*ast.WhileLoopActionNode (unordered; took 4@*ast.WhileLoopActionNode first) +step 6: token 2@done, token 3@done, token 4@done +choice step 7: tokens 2@done, 3@done, 4@done (unordered; took 4@done first) +step 7: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_node_performing_action.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_performing_action.expected.json new file mode 100644 index 0000000000..80f7b9be8e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_performing_action.expected.json @@ -0,0 +1,12 @@ +{ + "type": "action", + "evaluate": "test::host", + "libraries": true, + "trace": true, + "outputs": { + "entered": {"type": "Integer", "value": 3}, + "napped": {"type": "Integer", "value": 0}, + "passed": {"type": "Integer", "value": 3}, + "later": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_node_performing_action.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_performing_action.sysml new file mode 100644 index 0000000000..96e300bd15 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_performing_action.sysml @@ -0,0 +1,50 @@ +package test { + private import ScalarValues::*; + private import ISQ::*; + private import SI::*; + + action def Nap { + first start; + then action rest accept after 10 [s]; + then done; + } + + // As action_terminate_names_node_of_paused_bodies, but slow naps by performing an + // action of its own rather than running a flow: no token runs inside slow, so a + // paused performance of it is held only where its token's body paused. Both still + // end at `terminate slow;`, their naps abandoned, and both bodies go on past slow. + action host { + out attribute entered : Integer = 0; + out attribute napped : Integer = 0; + out attribute passed : Integer = 0; + out attribute later : Integer = 0; + + fork split; + action pre; + action pre1; + action pre2; + succession first start then split; + succession first split then gate; + succession first split then pre; + succession first pre then gate; + succession first split then pre1; + succession first pre1 then pre2; + succession first pre2 then gate; + merge gate; + then loop { + action slow { + assign entered := entered + 1; + if entered <= 2 { + perform action nap : Nap; + assign napped := napped + 1; + } + } + assign passed := passed + 1; + if passed == 1 { + terminate slow; + } + assign later := later + 1; + } until true; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_node_performing_action.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_performing_action.trace.golden new file mode 100644 index 0000000000..3200037a03 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_node_performing_action.trace.golden @@ -0,0 +1,97 @@ +step 1: token 1@split +step 2: token 2@gate, token 3@pre, token 4@pre1 +choice step 3: tokens 2@gate, 3@pre, 4@pre1 (unordered; took 4@pre1 first) +step 3: token 2@*ast.WhileLoopActionNode, token 3@gate, token 4@pre2 +stmt loop until + iteration 1 + stmt node slow + stmt assign entered + eval feature entered -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + stmt if + eval feature entered -> 1 + eval literal 2 -> 2 + eval operator <= -> true + stmt node nap +step 1: token 1@rest + eval literal 10 -> 10 + eval index -> 10 [s] +choice step 4: tokens 3@gate, 4@pre2 (unordered; took 4@pre2 first) +step 4: token 2@*ast.WhileLoopActionNode, token 3@*ast.WhileLoopActionNode, token 4@gate +stmt loop until + iteration 1 + stmt node slow + stmt assign entered + eval feature entered -> 1 + eval literal 1 -> 1 + eval operator + -> 2 + stmt if + eval feature entered -> 2 + eval literal 2 -> 2 + eval operator <= -> true + stmt node nap +step 1: token 1@rest + eval literal 10 -> 10 + eval index -> 10 [s] +choice step 5: tokens 2@*ast.WhileLoopActionNode, 4@gate (unordered; took 4@gate first) +step 5: token 2@*ast.WhileLoopActionNode, token 3@*ast.WhileLoopActionNode, token 4@*ast.WhileLoopActionNode +stmt loop until + iteration 1 + stmt node slow + stmt assign entered + eval feature entered -> 2 + eval literal 1 -> 1 + eval operator + -> 3 + stmt if + eval feature entered -> 3 + eval literal 2 -> 2 + eval operator <= -> false + stmt assign passed + eval feature passed -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + stmt if + eval feature passed -> 1 + eval literal 1 -> 1 + eval operator == -> true + stmt terminate +terminate action node slow: no token dropped +terminate action node slow: no token dropped + stmt assign later + eval feature later -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + eval literal true -> true + stmt assign passed + eval feature passed -> 1 + eval literal 1 -> 1 + eval operator + -> 2 + stmt if + eval feature passed -> 2 + eval literal 1 -> 1 + eval operator == -> false + stmt assign later + eval feature later -> 1 + eval literal 1 -> 1 + eval operator + -> 2 + eval literal true -> true + stmt assign passed + eval feature passed -> 2 + eval literal 1 -> 1 + eval operator + -> 3 + stmt if + eval feature passed -> 3 + eval literal 1 -> 1 + eval operator == -> false + stmt assign later + eval feature later -> 2 + eval literal 1 -> 1 + eval operator + -> 3 + eval literal true -> true +choice step 6: writes passed := 3 by token 2, passed := 2 by token 3, passed := 1 by token 4 (unordered; passed := 3 by token 2 stood) +choice step 6: writes later := 3 by token 2, later := 2 by token 3, later := 1 by token 4 (unordered; later := 3 by token 2 stood) +choice step 6: tokens 2@*ast.WhileLoopActionNode, 3@*ast.WhileLoopActionNode, 4@*ast.WhileLoopActionNode (unordered; took 4@*ast.WhileLoopActionNode first) +step 6: token 2@done, token 3@done, token 4@done +choice step 7: tokens 2@done, 3@done, 4@done (unordered; took 4@done first) +step 7: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node.expected.json new file mode 100644 index 0000000000..9a82d02665 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 2} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node.sysml new file mode 100644 index 0000000000..bd885f8e35 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node.sysml @@ -0,0 +1,18 @@ +package test { + private import ScalarValues::*; + + // `terminate c1;` written in c1's own body names the node it is in: c1 ends + // and the parent continues along c1's succession. + action host { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + + first start; + then action c1 { + assign x := 1; + terminate c1; + } + then action c2 { assign y := 2; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_concurrently.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_concurrently.expected.json new file mode 100644 index 0000000000..b5b5037599 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_concurrently.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "outputs": { + "n": {"type": "Integer", "value": 2}, + "y": {"type": "Integer", "value": 2}, + "late": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_concurrently.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_concurrently.sysml new file mode 100644 index 0000000000..02b2e8d77b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_concurrently.sysml @@ -0,0 +1,41 @@ +package test { + private import ScalarValues::*; + private import ISQ::*; + private import SI::*; + + // Two tokens reach slow through the merge one step apart, so the first performance + // of slow is paused in its nap when the second runs `terminate slow;`. Naming its + // own node, the statement ends every ongoing performance of it: the paused one is + // dropped with its nap, its own unwinds, and both tokens take slow's succession. + action host { + out attribute n : Integer = 0; + out attribute y : Integer = 0; + out attribute late : Integer = 0; + + fork split; + action pre; + merge gate; + action slow { + assign n := n + 1; + if n == 1 { + action inner { + first start; + then action nap accept after 10 [s]; + then done; + } + assign late := late + 1; + } else { + terminate slow; + assign late := late + 10; + } + } + action after { assign y := y + 1; } + succession first start then split; + succession first split then gate; + succession first split then pre; + succession first pre then gate; + succession first gate then slow; + succession first slow then after; + succession first after then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_concurrently.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_concurrently.trace.golden new file mode 100644 index 0000000000..7ab8a47aef --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_concurrently.trace.golden @@ -0,0 +1,41 @@ +step 1: token 1@split +step 2: token 2@gate, token 3@pre +choice step 3: tokens 2@gate, 3@pre (unordered; took 3@pre first) +step 3: token 2@slow, token 3@gate +stmt assign n + eval feature n -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt if + eval feature n -> 1 + eval literal 1 -> 1 + eval operator == -> true + stmt node inner +enter action node: inner + eval literal 10 -> 10 + eval index -> 10 [s] +choice step 4: tokens 2@slow, 3@gate (unordered; took 3@gate first) +step 4: token 2@slow, token 3@slow, token 4@nap +stmt assign n + eval feature n -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt if + eval feature n -> 2 + eval literal 1 -> 1 + eval operator == -> false + stmt terminate +terminate action node slow: dropped token 4@nap +terminate action node slow: no token dropped +stmt assign y + eval feature y -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +step 5: token 2@done, token 3@after +stmt assign y + eval feature y -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +choice step 6: tokens 2@done, 3@after (unordered; took 3@after first) +step 6: token 3@done +step 7: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_from_the_earlier.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_from_the_earlier.expected.json new file mode 100644 index 0000000000..b5b5037599 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_from_the_earlier.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "outputs": { + "n": {"type": "Integer", "value": 2}, + "y": {"type": "Integer", "value": 2}, + "late": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_from_the_earlier.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_from_the_earlier.sysml new file mode 100644 index 0000000000..8bcebfd559 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_from_the_earlier.sysml @@ -0,0 +1,46 @@ +package test { + private import ScalarValues::*; + private import ISQ::*; + private import SI::*; + + // The first performance of slow naps, the second reaches slow and naps longer; the + // first wakes and runs `terminate slow;`. Naming its own node ends the ongoing + // performances earliest first: the first unwinds and completes, then the second is + // ended in place with its nap dropped, and both tokens take slow's succession. + action host { + out attribute n : Integer = 0; + out attribute y : Integer = 0; + out attribute late : Integer = 0; + + fork split; + action pre; + merge gate; + action slow { + assign n := n + 1; + if n == 1 { + action inner { + first start; + then action nap accept after 10 [s]; + then done; + } + terminate slow; + assign late := late + 1; + } else { + action inner2 { + first start; + then action nap2 accept after 100 [s]; + then done; + } + assign late := late + 10; + } + } + action after { assign y := y + 1; } + succession first start then split; + succession first split then gate; + succession first split then pre; + succession first pre then gate; + succession first gate then slow; + succession first slow then after; + succession first after then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_from_the_earlier.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_from_the_earlier.trace.golden new file mode 100644 index 0000000000..1eecc15532 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_own_node_from_the_earlier.trace.golden @@ -0,0 +1,52 @@ +step 1: token 1@split +step 2: token 2@gate, token 3@pre +choice step 3: tokens 2@gate, 3@pre (unordered; took 3@pre first) +step 3: token 2@slow, token 3@gate +stmt assign n + eval feature n -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt if + eval feature n -> 1 + eval literal 1 -> 1 + eval operator == -> true + stmt node inner +enter action node: inner + eval literal 10 -> 10 + eval index -> 10 [s] +choice step 4: tokens 2@slow, 3@gate (unordered; took 3@gate first) +step 4: token 2@slow, token 3@slow, token 4@nap +stmt assign n + eval feature n -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt if + eval feature n -> 2 + eval literal 1 -> 1 + eval operator == -> false + stmt node inner2 +enter action node: inner2 + eval literal 100 -> 100 + eval index -> 100 [s] +choice step 5: tokens 2@slow, 3@slow (unordered; took 3@slow first) +step 5: token 2@slow, token 3@slow, token 4@nap, token 5@nap2 +choice step 6: tokens 2@slow, 3@slow (unordered; took 3@slow first) +leave action node: inner + stmt terminate +terminate action node slow: no token dropped +terminate action node slow: dropped token 5@nap2 +choice step 6: tokens 2@slow, 3@slow (unordered; took 3@slow first) +step 6: token 2@after, token 3@after +stmt assign y + eval feature y -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign y + eval feature y -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +choice step 7: writes y := 2 by token 2, y := 1 by token 3 (unordered; y := 2 by token 2 stood) +choice step 7: tokens 2@after, 3@after (unordered; took 3@after first) +step 7: token 2@done, token 3@done +choice step 8: tokens 2@done, 3@done (unordered; took 3@done first) +step 8: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_before_it_begins.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_before_it_begins.expected.json new file mode 100644 index 0000000000..cdea54d79b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_before_it_begins.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "steps": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_before_it_begins.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_before_it_begins.sysml new file mode 100644 index 0000000000..bb972c2336 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_before_it_begins.sysml @@ -0,0 +1,30 @@ +// The §7.17.10 MonitoredActivity shape: a forked branch terminates the performed +// action its sibling is, which the schedule has not begun yet. The sibling's step ends +// before it starts — its body never runs — and the succession out of it is taken. +package test { + private import ScalarValues::*; + + attribute def TimeOut; + + action def MonitoredActivity { + out attribute steps : Integer = 0; + + first start; + then fork; + then performCriticalActivity; + then waitForTimeOut; + action performCriticalActivity { + first start; + then action monitor accept TimeOut; + then action critical assign steps := steps + 100; + then terminate; + } + then stop; + action waitForTimeOut { + assign steps := steps + 1; + terminate performCriticalActivity; + } + then stop; + action stop terminate; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_before_it_begins.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_before_it_begins.trace.golden new file mode 100644 index 0000000000..af945308f2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_before_it_begins.trace.golden @@ -0,0 +1,12 @@ +step 1: token 1@fork +step 2: token 2@performCriticalActivity, token 3@waitForTimeOut +stmt assign steps + eval feature steps -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt terminate +terminate action node performCriticalActivity: ended before it began +step 3: token 2@stop, token 3@stop +stmt terminate +terminate action: no token dropped +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_flow.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_flow.expected.json new file mode 100644 index 0000000000..82005c10ff --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_flow.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 0}, + "z": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_flow.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_flow.sysml new file mode 100644 index 0000000000..8369289f70 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_flow.sysml @@ -0,0 +1,35 @@ +package test { + private import ScalarValues::*; + + // One fork branch names the node running in the other: `terminate slow;` drops + // the token parked inside slow's own flow, slow completes with what it assigned + // so far, and the join fires as after a normal completion. + action host { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + out attribute z : Integer = 0; + + fork split; + action slow { + first start; + then action begun { assign x := 1; } + then action waiter accept sig : Integer; + then action late { assign y := 2; } + then done; + } + action pre1 { assign z := 3; } + action pre2; + action pre3; + action killer { terminate slow; } + join sync; + succession first start then split; + succession first split then slow; + succession first split then pre1; + succession first pre1 then pre2; + succession first pre2 then pre3; + succession first pre3 then killer; + succession first slow then sync; + succession first killer then sync; + succession first sync then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_flow.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_flow.trace.golden new file mode 100644 index 0000000000..cace9dfcfd --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_sibling_flow.trace.golden @@ -0,0 +1,19 @@ +step 1: token 1@split +step 2: token 2@slow, token 3@pre1 +stmt assign z + eval literal 3 -> 3 +enter action node: slow +choice step 3: tokens 2@slow, 3@pre1 (unordered; took 3@pre1 first) +step 3: token 2@start, token 3@pre2 +choice step 4: tokens 2@start, 3@pre2 (unordered; took 3@pre2 first) +step 4: token 2@begun, token 3@pre3 +stmt assign x + eval literal 1 -> 1 +choice step 5: tokens 2@begun, 3@pre3 (unordered; took 3@pre3 first) +step 5: token 2@waiter, token 3@killer +stmt terminate +terminate action node slow: no token dropped +leave action node: slow +step 6: token 2@sync, token 3@sync +step 7: token 4@done +step 8: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_waiting_accept.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_names_waiting_accept.expected.json new file mode 100644 index 0000000000..de19a06432 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_waiting_accept.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "outputs": { + "steps": {"type": "Integer", "value": 11} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_waiting_accept.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_names_waiting_accept.sysml new file mode 100644 index 0000000000..d71d3d1159 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_waiting_accept.sysml @@ -0,0 +1,27 @@ +// A terminate naming an accept node parked for a signal ends it waiting: the payload +// never arrives, the node after it runs, and the join meets both branches. +package test { + private import ScalarValues::*; + private import ISQ::*; + private import SI::*; + + attribute def TimeOut; + + action host { + out attribute steps : Integer = 0; + + first start; + then fork; + then listen; + then quit; + action listen accept TimeOut; + then action later assign steps := steps + 10; + then meet; + action quit assign steps := steps + 1; + then action pause accept after 2 [s]; + then action kill { terminate listen; } + then meet; + join meet; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_names_waiting_accept.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_names_waiting_accept.trace.golden new file mode 100644 index 0000000000..4050a492bc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_names_waiting_accept.trace.golden @@ -0,0 +1,21 @@ +step 1: token 1@fork +step 2: token 2@listen, token 3@quit +stmt assign steps + eval feature steps -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +step 3: token 2@listen, token 3@pause + eval literal 2 -> 2 +eval index -> 2 [s] +step 4: token 2@listen, token 3@pause +step 5: token 2@listen, token 3@kill +stmt terminate +terminate action node listen: ended waiting +step 6: token 2@later, token 3@meet +stmt assign steps + eval feature steps -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +step 7: token 2@meet, token 3@meet +step 8: token 4@done +step 9: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_nested_body.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_nested_body.expected.json new file mode 100644 index 0000000000..75783628b9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_nested_body.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 0}, + "z": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_nested_body.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_nested_body.sysml new file mode 100644 index 0000000000..92b1e79d5b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_nested_body.sysml @@ -0,0 +1,20 @@ +package test { + private import ScalarValues::*; + + // A `terminate;` in a nested node's body ends that node only: the statement + // after it does not run, and the parent continues along the node's succession. + action host { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + out attribute z : Integer = 0; + + first start; + then action c1 { + assign x := 1; + terminate; + assign y := 2; + } + then action after { assign z := 3; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_nested_fork.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_nested_fork.expected.json new file mode 100644 index 0000000000..0e5638ae13 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_nested_fork.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "inner": {"type": "Integer", "value": 1}, + "outer": {"type": "Integer", "value": 2}, + "after": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_nested_fork.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_nested_fork.sysml new file mode 100644 index 0000000000..a7c07e1ccb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_nested_fork.sysml @@ -0,0 +1,33 @@ +package test { + private import ScalarValues::*; + + // A nested node forks in turn; its `terminate` drops the node's own parked + // branch and ends the node, while the parent's other branch runs on and the + // join fires as after a normal completion of the node. + action host { + out attribute inner : Integer = 0; + out attribute outer : Integer = 0; + out attribute after : Integer = 0; + + fork split; + action node { + fork nsplit; + action waiter accept sig : Integer; + action quit { assign inner := 1; } + then terminate; + first nsplit; + succession first nsplit then waiter; + succession first nsplit then quit; + } + action other { assign outer := 2; } + join sync; + action tail { assign after := 3; } + succession first start then split; + succession first split then node; + succession first split then other; + succession first node then sync; + succession first other then sync; + succession first sync then tail; + succession first tail then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_nested_fork.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_nested_fork.trace.golden new file mode 100644 index 0000000000..84b57b063b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_nested_fork.trace.golden @@ -0,0 +1,20 @@ +step 1: token 1@split +step 2: token 2@node, token 3@other +stmt assign outer + eval literal 2 -> 2 +enter action node: node +choice step 3: tokens 2@node, 3@other (unordered; took 3@other first) +step 3: token 2@nsplit, token 3@sync +step 4: token 3@sync, token 4@waiter, token 5@quit +stmt assign inner + eval literal 1 -> 1 +step 5: token 3@sync, token 4@waiter, token 5@*ast.TerminateStatement +stmt terminate +terminate action node node: dropped token 4@waiter +leave action node: node +step 6: token 3@sync, token 5@sync +step 7: token 6@tail +stmt assign after + eval literal 3 -> 3 +step 8: token 6@done +step 9: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_owner_before_lazy_part.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_owner_before_lazy_part.expected.json new file mode 100644 index 0000000000..f6f3064c5d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_owner_before_lazy_part.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "trace": true, + "outputs": { + "rate": {"type": "Integer", "value": 50}, + "fired": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_owner_before_lazy_part.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_owner_before_lazy_part.sysml new file mode 100644 index 0000000000..d092144c31 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_owner_before_lazy_part.sysml @@ -0,0 +1,34 @@ +// A part of an object exists as long as its whole does (SysML v2 §7.17.10): when +// `terminate` ends the whole before an owned part is first read, the read still +// answers the part's declared values but reaches an object that ended with its +// whole — no behavior of it starts, so the engine's machine never fires. +package test { + private import ScalarValues::*; + + part def Engine { + attribute rate : Integer = 60; + attribute fired : Integer = 0; + exhibit state life { + entry; then on; + state on { entry action ignite assign fired := 1; } + } + } + + part def Body { + // Optional, so nothing reads it when the body is created; the binding + // makes it hold one engine once read. + part engine : Engine[0..1] { :>> rate = 50; } + } + + part patient : Body; + + action host { + out attribute rate : Integer = 0; + out attribute fired : Integer = 0; + first start; + then action stop { terminate patient; } + then action read assign rate := patient.engine.rate; + then action check assign fired := patient.engine.fired; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_owner_before_lazy_part.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_owner_before_lazy_part.trace.golden new file mode 100644 index 0000000000..1494538a8f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_owner_before_lazy_part.trace.golden @@ -0,0 +1,14 @@ +step 1: token 1@stop +stmt terminate +materialize: patient #1 + eval feature patient -> instance#1 +terminate: patient #1 +step 2: token 1@read +stmt assign rate +materialize: engine #2 + eval chain rate -> 50 +step 3: token 1@check +stmt assign fired + eval chain fired -> 0 +step 4: token 1@done +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_this_ends_part.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_this_ends_part.expected.json new file mode 100644 index 0000000000..6378b02198 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_this_ends_part.expected.json @@ -0,0 +1,18 @@ +{ + "type": "instance", + "libraries": true, + "trace": true, + "instantiate": "test::Probe", + "slots": { + "seen": {"type": "Integer", "value": 1}, + "beats": {"type": "Integer", "value": 1}, + "exits": {"type": "Integer", "value": 0} + }, + "objects": [ + { + "behavior": "life", + "terminated": true, + "stateVisits": ["alive"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_this_ends_part.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_this_ends_part.sysml new file mode 100644 index 0000000000..973dc330c9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_this_ends_part.sysml @@ -0,0 +1,38 @@ +// `terminate this;` in an action a part performs ends the part (SysML v2 §7.17.10): +// the action's own performance ends at that statement with the writes made so far, +// and so does every other behavior the part performs — the machine it exhibits is +// terminated where it stands, its do behavior abandoned, no state exited. +package test { + private import ScalarValues::*; + + attribute def Never; + + part def Probe { + attribute seen : Integer = 0; + attribute beats : Integer = 0; + attribute exits : Integer = 0; + + exhibit state life { + entry; then alive; + state alive { + do action beat { + first start; + then action one assign beats := 1; + then action wait accept Never; + then action two assign beats := 2; + then done; + } + exit action leave assign exits := 1; + } + } + + perform action selfDestruct { + action step { + assign this.seen := 1; + terminate this; + assign this.seen := 2; + } + first step; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_this_ends_part.trace.golden b/internal/exec/runtime/testdata/conformance/action_terminate_this_ends_part.trace.golden new file mode 100644 index 0000000000..1511ecf436 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_this_ends_part.trace.golden @@ -0,0 +1,22 @@ +materialize: Probe #1 +start: exhibited state machine life of #1 +materialize: life #2 +enter: alive +start: performed action selfDestruct of #1 +materialize: selfDestruct #3 +run: exhibited state machine life of #1 +do: alive +stmt action body +enter action node: state behavior beat + stmt assign beats + eval literal 1 -> 1 +run: performed action selfDestruct of #1 +stmt assign this.seen + eval literal 1 -> 1 + eval feature this -> instance#1 +stmt terminate + eval feature this -> instance#1 +terminate: Probe #1 +terminated with occurrence: life (do behavior abandoned: alive) +terminate action: dropped token 1@step +step 1: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_binds_output_pin.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_usage_binds_output_pin.expected.json new file mode 100644 index 0000000000..a03f0b26c2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_binds_output_pin.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "evaluate": "test::stopper", + "libraries": true, + "outputs": { + "x": {"type": "Integer", "value": 1}, + "result": {"type": "Integer", "value": 42}, + "issued": {"type": "Integer", "value": 100} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_binds_output_pin.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_usage_binds_output_pin.sysml new file mode 100644 index 0000000000..b05a03c7ae --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_binds_output_pin.sysml @@ -0,0 +1,32 @@ +package test { + private import ScalarValues::*; + + // A terminate action usage's out pin is bound as the usage completes, before it ends + // the action: `code`, assigned in stop's body, reaches `result` through the binding, + // as does the output an action it performs returns; a2 does not run. + action def Report { + out issued : Integer; + first start; + then action issue { assign issued := 100; } + then done; + } + + action stopper { + out attribute x : Integer = 0; + out attribute result : Integer = 0; + out attribute issued : Integer = 0; + + bind stop.code = result; + + first start; + then action a1 { assign x := 1; } + then stop; + action stop terminate { + out code : Integer; + perform action report : Report; + assign code := 42; + } + then action a2 { assign x := 2; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself.expected.json new file mode 100644 index 0000000000..e3a176b488 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "x": {"type": "Integer", "value": 2}, + "y": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself.sysml new file mode 100644 index 0000000000..4f3a34ee88 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself.sysml @@ -0,0 +1,22 @@ +package test { + private import ScalarValues::*; + + // A `terminate;` in a terminate action usage's body ends the usage's own performance + // where it stands — the assignment after it is skipped — and the usage still ends the + // action it is a step of: a2 does not run. + action stopper { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + + first start; + then action a1 { assign x := 1; } + then stop; + action stop terminate { + assign x := x + 1; + terminate; + assign y := 5; + } + then action a2 { assign y := 2; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself_binds_pin.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself_binds_pin.expected.json new file mode 100644 index 0000000000..ba51cc1fd4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself_binds_pin.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "evaluate": "test::stopper", + "libraries": true, + "outputs": { + "x": {"type": "Integer", "value": 1}, + "result": {"type": "Integer", "value": 42}, + "other": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself_binds_pin.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself_binds_pin.sysml new file mode 100644 index 0000000000..7308890473 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_ends_itself_binds_pin.sysml @@ -0,0 +1,27 @@ +package test { + private import ScalarValues::*; + + // A `terminate;` in a terminate action usage's body ends the usage with the pins it + // has assigned so far: `code` reaches `result` through the binding, `other` is never + // assigned so the enclosing one keeps its value, and the usage still ends the action. + action stopper { + out attribute x : Integer = 0; + out attribute result : Integer = 0; + out attribute other : Integer = 0; + + bind stop.code = result; + + first start; + then action a1 { assign x := 1; } + then stop; + action stop terminate { + out code : Integer; + out other : Integer; + assign code := 42; + terminate; + assign other := 7; + } + then action a2 { assign x := 2; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_performs_action.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_performs_action.expected.json new file mode 100644 index 0000000000..2bb79bb3c5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_performs_action.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "evaluate": "test::stopper", + "libraries": true, + "outputs": { + "x": {"type": "Integer", "value": 6}, + "y": {"type": "Integer", "value": 0}, + "cleaned": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_performs_action.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_performs_action.sysml new file mode 100644 index 0000000000..a6834f6803 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_body_performs_action.sysml @@ -0,0 +1,29 @@ +package test { + private import ScalarValues::*; + + action def Cleanup { + out cleaned : Integer; + first start; + then action wipe { assign cleaned := 5; } + then done; + } + + // A terminate action usage's body performs an action before the node ends the + // action: the performance runs to completion, its `out cleaned` comes back to + // stopper's own, and the statement after it still runs. y is not assigned. + action stopper { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + out attribute cleaned : Integer = 0; + + first start; + then action a1 { assign x := 1; } + then stop; + action stop terminate { + perform action cleanup : Cleanup; + assign x := x + cleaned; + } + then action a2 { assign y := 2; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_binds_pin.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_binds_pin.expected.json new file mode 100644 index 0000000000..32cc59d5de --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_binds_pin.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "result": {"type": "Integer", "value": 42}, + "y": {"type": "Integer", "value": 0}, + "z": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_binds_pin.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_binds_pin.sysml new file mode 100644 index 0000000000..2ead918dd3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_binds_pin.sysml @@ -0,0 +1,26 @@ +package test { + private import ScalarValues::*; + + // `stop`, a terminate action usage performed as a statement of node's body, completes + // with its pins before it ends node: `code` reaches host's `result` through the branch's + // binding, `later` does not run, and the action goes on to tail. + action host { + out attribute result : Integer = 0; + out attribute y : Integer = 0; + out attribute z : Integer = 0; + + first start; + then action node { + if true { + bind stop.code = result; + action stop terminate { + out code : Integer; + assign code := 42; + } + action later { assign y := 2; } + } + } + then action tail { assign z := 3; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_names_itself.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_names_itself.expected.json new file mode 100644 index 0000000000..41172e7be9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_names_itself.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 0}, + "z": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_names_itself.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_names_itself.sysml new file mode 100644 index 0000000000..96f12e7f5b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_in_block_names_itself.sysml @@ -0,0 +1,26 @@ +package test { + private import ScalarValues::*; + + // `stop`, a terminate action usage performed as a statement of node's body, names + // itself in its own body: its later assignment is skipped, and as a terminate action + // usage it ends node, so `later` does not run; the action goes on to tail. + action host { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + out attribute z : Integer = 0; + + first start; + then action node { + if true { + action stop terminate { + assign x := 1; + terminate stop; + assign y := 5; + } + action later { assign y := 2; } + } + } + then action tail { assign z := 3; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_body.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_body.expected.json new file mode 100644 index 0000000000..adf0b601ff --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_body.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "x": {"type": "Integer", "value": 2}, + "y": {"type": "Integer", "value": 0}, + "code": {"type": "Integer", "value": 42} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_body.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_body.sysml new file mode 100644 index 0000000000..545600096f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_body.sysml @@ -0,0 +1,21 @@ +package test { + private import ScalarValues::*; + + // A terminate action usage's body runs as a leaf node's before the node ends the + // action: its assignment and the branch it takes are observed, y is not. + action stopper { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + out attribute code : Integer = 0; + + first start; + then action a1 { assign x := 1; } + then stop; + action stop terminate { + assign code := x + 41; + if code == 42 { assign x := x + 1; } + } + then action a2 { assign y := 2; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_pins.expected.json b/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_pins.expected.json new file mode 100644 index 0000000000..11dcb6d203 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_pins.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_pins.sysml b/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_pins.sysml new file mode 100644 index 0000000000..e6057d9894 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_terminate_usage_with_pins.sysml @@ -0,0 +1,20 @@ +package test { + private import ScalarValues::*; + + // A terminate action usage's body may declare the node's pins, which a flow into it + // targets as into any node: the flow into `stop.reason` is bound as the token reaches + // stop, and stop then ends the action with x assigned and y not. + action stopper { + out attribute x : Integer = 0; + out attribute y : Integer = 0; + + first start; + then action a1 { out code : Integer; assign x := 1; assign code := 7; } + then stop; + action stop terminate { in reason : Integer; } + then action a2 { assign y := 2; } + then done; + + flow a1.code to stop.reason; + } +} diff --git a/internal/core/runtime/testdata/conformance/action_then_after_allocate.expected.json b/internal/exec/runtime/testdata/conformance/action_then_after_allocate.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_then_after_allocate.expected.json rename to internal/exec/runtime/testdata/conformance/action_then_after_allocate.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_then_after_allocate.sysml b/internal/exec/runtime/testdata/conformance/action_then_after_allocate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_then_after_allocate.sysml rename to internal/exec/runtime/testdata/conformance/action_then_after_allocate.sysml diff --git a/internal/core/runtime/testdata/conformance/action_then_skips_non_feature_members.expected.json b/internal/exec/runtime/testdata/conformance/action_then_skips_non_feature_members.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_then_skips_non_feature_members.expected.json rename to internal/exec/runtime/testdata/conformance/action_then_skips_non_feature_members.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_then_skips_non_feature_members.sysml b/internal/exec/runtime/testdata/conformance/action_then_skips_non_feature_members.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_then_skips_non_feature_members.sysml rename to internal/exec/runtime/testdata/conformance/action_then_skips_non_feature_members.sysml diff --git a/internal/core/runtime/testdata/conformance/action_then_skips_non_feature_members.trace.golden b/internal/exec/runtime/testdata/conformance/action_then_skips_non_feature_members.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_then_skips_non_feature_members.trace.golden rename to internal/exec/runtime/testdata/conformance/action_then_skips_non_feature_members.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_unbraced_body_edges_first.expected.json b/internal/exec/runtime/testdata/conformance/action_unbraced_body_edges_first.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_unbraced_body_edges_first.expected.json rename to internal/exec/runtime/testdata/conformance/action_unbraced_body_edges_first.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_unbraced_body_edges_first.sysml b/internal/exec/runtime/testdata/conformance/action_unbraced_body_edges_first.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_unbraced_body_edges_first.sysml rename to internal/exec/runtime/testdata/conformance/action_unbraced_body_edges_first.sysml diff --git a/internal/core/runtime/testdata/conformance/action_unbraced_body_successions.expected.json b/internal/exec/runtime/testdata/conformance/action_unbraced_body_successions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_unbraced_body_successions.expected.json rename to internal/exec/runtime/testdata/conformance/action_unbraced_body_successions.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_unbraced_body_successions.sysml b/internal/exec/runtime/testdata/conformance/action_unbraced_body_successions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_unbraced_body_successions.sysml rename to internal/exec/runtime/testdata/conformance/action_unbraced_body_successions.sysml diff --git a/internal/core/runtime/testdata/conformance/action_while_loop.expected.json b/internal/exec/runtime/testdata/conformance/action_while_loop.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_while_loop.expected.json rename to internal/exec/runtime/testdata/conformance/action_while_loop.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_while_loop.sysml b/internal/exec/runtime/testdata/conformance/action_while_loop.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_while_loop.sysml rename to internal/exec/runtime/testdata/conformance/action_while_loop.sysml diff --git a/internal/core/runtime/testdata/conformance/action_while_loop.trace.golden b/internal/exec/runtime/testdata/conformance/action_while_loop.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_while_loop.trace.golden rename to internal/exec/runtime/testdata/conformance/action_while_loop.trace.golden diff --git a/internal/core/runtime/testdata/conformance/action_while_loop_zero_iterations.expected.json b/internal/exec/runtime/testdata/conformance/action_while_loop_zero_iterations.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/action_while_loop_zero_iterations.expected.json rename to internal/exec/runtime/testdata/conformance/action_while_loop_zero_iterations.expected.json diff --git a/internal/core/runtime/testdata/conformance/action_while_loop_zero_iterations.sysml b/internal/exec/runtime/testdata/conformance/action_while_loop_zero_iterations.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/action_while_loop_zero_iterations.sysml rename to internal/exec/runtime/testdata/conformance/action_while_loop_zero_iterations.sysml diff --git a/internal/core/runtime/testdata/conformance/action_while_loop_zero_iterations.trace.golden b/internal/exec/runtime/testdata/conformance/action_while_loop_zero_iterations.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/action_while_loop_zero_iterations.trace.golden rename to internal/exec/runtime/testdata/conformance/action_while_loop_zero_iterations.trace.golden diff --git a/internal/core/runtime/testdata/conformance/analysis_assert_constraint.expected.json b/internal/exec/runtime/testdata/conformance/analysis_assert_constraint.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_assert_constraint.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_assert_constraint.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_assert_constraint.sysml b/internal/exec/runtime/testdata/conformance/analysis_assert_constraint.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_assert_constraint.sysml rename to internal/exec/runtime/testdata/conformance/analysis_assert_constraint.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_evaluations_argument_identity.expected.json b/internal/exec/runtime/testdata/conformance/analysis_evaluations_argument_identity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_evaluations_argument_identity.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_evaluations_argument_identity.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_evaluations_argument_identity.sysml b/internal/exec/runtime/testdata/conformance/analysis_evaluations_argument_identity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_evaluations_argument_identity.sysml rename to internal/exec/runtime/testdata/conformance/analysis_evaluations_argument_identity.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_evaluations_named_arguments.expected.json b/internal/exec/runtime/testdata/conformance/analysis_evaluations_named_arguments.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_evaluations_named_arguments.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_evaluations_named_arguments.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_evaluations_named_arguments.sysml b/internal/exec/runtime/testdata/conformance/analysis_evaluations_named_arguments.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_evaluations_named_arguments.sysml rename to internal/exec/runtime/testdata/conformance/analysis_evaluations_named_arguments.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_evaluations_result_coincides.expected.json b/internal/exec/runtime/testdata/conformance/analysis_evaluations_result_coincides.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_evaluations_result_coincides.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_evaluations_result_coincides.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_evaluations_result_coincides.sysml b/internal/exec/runtime/testdata/conformance/analysis_evaluations_result_coincides.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_evaluations_result_coincides.sysml rename to internal/exec/runtime/testdata/conformance/analysis_evaluations_result_coincides.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_in_parameter_named.expected.json b/internal/exec/runtime/testdata/conformance/analysis_in_parameter_named.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_in_parameter_named.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_in_parameter_named.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_in_parameter_named.sysml b/internal/exec/runtime/testdata/conformance/analysis_in_parameter_named.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_in_parameter_named.sysml rename to internal/exec/runtime/testdata/conformance/analysis_in_parameter_named.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_in_parameter_positional.expected.json b/internal/exec/runtime/testdata/conformance/analysis_in_parameter_positional.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_in_parameter_positional.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_in_parameter_positional.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_in_parameter_positional.sysml b/internal/exec/runtime/testdata/conformance/analysis_in_parameter_positional.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_in_parameter_positional.sysml rename to internal/exec/runtime/testdata/conformance/analysis_in_parameter_positional.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_loop_performs_flowless_action.expected.json b/internal/exec/runtime/testdata/conformance/analysis_loop_performs_flowless_action.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_loop_performs_flowless_action.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_loop_performs_flowless_action.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_loop_performs_flowless_action.sysml b/internal/exec/runtime/testdata/conformance/analysis_loop_performs_flowless_action.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_loop_performs_flowless_action.sysml rename to internal/exec/runtime/testdata/conformance/analysis_loop_performs_flowless_action.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_named_result_of_trailing_expression.expected.json b/internal/exec/runtime/testdata/conformance/analysis_named_result_of_trailing_expression.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_named_result_of_trailing_expression.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_named_result_of_trailing_expression.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_named_result_of_trailing_expression.sysml b/internal/exec/runtime/testdata/conformance/analysis_named_result_of_trailing_expression.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_named_result_of_trailing_expression.sysml rename to internal/exec/runtime/testdata/conformance/analysis_named_result_of_trailing_expression.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_nested_default_subject.expected.json b/internal/exec/runtime/testdata/conformance/analysis_nested_default_subject.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_nested_default_subject.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_nested_default_subject.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_nested_default_subject.sysml b/internal/exec/runtime/testdata/conformance/analysis_nested_default_subject.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_nested_default_subject.sysml rename to internal/exec/runtime/testdata/conformance/analysis_nested_default_subject.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_nested_step_implicit_result.expected.json b/internal/exec/runtime/testdata/conformance/analysis_nested_step_implicit_result.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_nested_step_implicit_result.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_nested_step_implicit_result.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_nested_step_implicit_result.sysml b/internal/exec/runtime/testdata/conformance/analysis_nested_step_implicit_result.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_nested_step_implicit_result.sysml rename to internal/exec/runtime/testdata/conformance/analysis_nested_step_implicit_result.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_nested_step_then.expected.json b/internal/exec/runtime/testdata/conformance/analysis_nested_step_then.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_nested_step_then.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_nested_step_then.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_nested_step_then.sysml b/internal/exec/runtime/testdata/conformance/analysis_nested_step_then.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_nested_step_then.sysml rename to internal/exec/runtime/testdata/conformance/analysis_nested_step_then.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_actor_implicit_redefinition.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_actor_implicit_redefinition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_actor_implicit_redefinition.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_actor_implicit_redefinition.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_actor_implicit_redefinition.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_actor_implicit_redefinition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_actor_implicit_redefinition.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_actor_implicit_redefinition.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_actor_implicit_refused.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_actor_implicit_refused.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_actor_implicit_refused.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_actor_implicit_refused.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_actor_implicit_refused.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_actor_implicit_refused.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_actor_implicit_refused.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_actor_implicit_refused.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_binding_transactional.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_binding_transactional.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_binding_transactional.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_binding_transactional.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_binding_transactional.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_binding_transactional.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_binding_transactional.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_binding_transactional.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_not_satisfied.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_not_satisfied.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_not_satisfied.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_not_satisfied.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_not_satisfied.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_not_satisfied.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_not_satisfied.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_not_satisfied.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_satisfied.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_satisfied.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_satisfied.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_satisfied.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_satisfied.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_satisfied.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_satisfied.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_satisfied.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_action_step_output.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_action_step_output.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_action_step_output.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_action_step_output.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_action_step_output.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_action_step_output.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_action_step_output.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_action_step_output.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_bound_multiplicity.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_bound_multiplicity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_bound_multiplicity.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_bound_multiplicity.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_bound_multiplicity.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_bound_multiplicity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_bound_multiplicity.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_bound_multiplicity.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_bound_pair.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_bound_pair.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_bound_pair.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_bound_pair.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_bound_pair.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_bound_pair.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_bound_pair.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_bound_pair.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_classified.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_classified.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_classified.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_classified.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_classified.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_classified.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_classified.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_classified.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_default.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_default.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_default.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_default.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_default.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_default_mismatch.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_mismatch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_default_mismatch.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_mismatch.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_default_mismatch.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_mismatch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_default_mismatch.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_mismatch.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_default_multiplicity.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_multiplicity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_default_multiplicity.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_multiplicity.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_default_multiplicity.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_multiplicity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_default_multiplicity.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_multiplicity.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_default_over_case_subject.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_over_case_subject.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_default_over_case_subject.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_over_case_subject.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_default_over_case_subject.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_over_case_subject.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_default_over_case_subject.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_default_over_case_subject.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_keyword.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_keyword.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_keyword.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_keyword.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_keyword.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_keyword.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_keyword.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_keyword.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_keyword_violated.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_keyword_violated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_keyword_violated.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_keyword_violated.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_keyword_violated.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_keyword_violated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_keyword_violated.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_keyword_violated.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_qualified_result.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_qualified_result.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_qualified_result.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_qualified_result.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_qualified_result.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_qualified_result.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_qualified_result.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_qualified_result.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_qualified_result_violated.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_qualified_result_violated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_qualified_result_violated.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_qualified_result_violated.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_qualified_result_violated.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_qualified_result_violated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_qualified_result_violated.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_qualified_result_violated.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_redeclared_multiplicity.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_redeclared_multiplicity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_redeclared_multiplicity.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_redeclared_multiplicity.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_redeclared_multiplicity.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_redeclared_multiplicity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_redeclared_multiplicity.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_redeclared_multiplicity.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_sibling_result.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_sibling_result.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_sibling_result.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_sibling_result.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_subject_sibling_result.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_subject_sibling_result.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_subject_sibling_result.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_subject_sibling_result.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_undecided.expected.json b/internal/exec/runtime/testdata/conformance/analysis_objective_undecided.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_undecided.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_objective_undecided.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_objective_undecided.sysml b/internal/exec/runtime/testdata/conformance/analysis_objective_undecided.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_objective_undecided.sysml rename to internal/exec/runtime/testdata/conformance/analysis_objective_undecided.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_output_fails_verdicts_undecided.expected.json b/internal/exec/runtime/testdata/conformance/analysis_output_fails_verdicts_undecided.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_output_fails_verdicts_undecided.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_output_fails_verdicts_undecided.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_output_fails_verdicts_undecided.sysml b/internal/exec/runtime/testdata/conformance/analysis_output_fails_verdicts_undecided.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_output_fails_verdicts_undecided.sysml rename to internal/exec/runtime/testdata/conformance/analysis_output_fails_verdicts_undecided.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_read_from_attribute_redefinition.expected.json b/internal/exec/runtime/testdata/conformance/analysis_read_from_attribute_redefinition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_read_from_attribute_redefinition.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_read_from_attribute_redefinition.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_read_from_attribute_redefinition.sysml b/internal/exec/runtime/testdata/conformance/analysis_read_from_attribute_redefinition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_read_from_attribute_redefinition.sysml rename to internal/exec/runtime/testdata/conformance/analysis_read_from_attribute_redefinition.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_return_vs_out.expected.json b/internal/exec/runtime/testdata/conformance/analysis_return_vs_out.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_return_vs_out.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_return_vs_out.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_return_vs_out.sysml b/internal/exec/runtime/testdata/conformance/analysis_return_vs_out.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_return_vs_out.sysml rename to internal/exec/runtime/testdata/conformance/analysis_return_vs_out.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_step_perform.expected.json b/internal/exec/runtime/testdata/conformance/analysis_step_perform.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_step_perform.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_step_perform.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_step_perform.sysml b/internal/exec/runtime/testdata/conformance/analysis_step_perform.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_step_perform.sysml rename to internal/exec/runtime/testdata/conformance/analysis_step_perform.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_step_typed_action.expected.json b/internal/exec/runtime/testdata/conformance/analysis_step_typed_action.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_step_typed_action.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_step_typed_action.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_step_typed_action.sysml b/internal/exec/runtime/testdata/conformance/analysis_step_typed_action.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_step_typed_action.sysml rename to internal/exec/runtime/testdata/conformance/analysis_step_typed_action.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_steps_first_then_reorders.expected.json b/internal/exec/runtime/testdata/conformance/analysis_steps_first_then_reorders.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_steps_first_then_reorders.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_steps_first_then_reorders.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_steps_first_then_reorders.sysml b/internal/exec/runtime/testdata/conformance/analysis_steps_first_then_reorders.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_steps_first_then_reorders.sysml rename to internal/exec/runtime/testdata/conformance/analysis_steps_first_then_reorders.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_steps_first_then_reorders.trace.golden b/internal/exec/runtime/testdata/conformance/analysis_steps_first_then_reorders.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_steps_first_then_reorders.trace.golden rename to internal/exec/runtime/testdata/conformance/analysis_steps_first_then_reorders.trace.golden diff --git a/internal/core/runtime/testdata/conformance/analysis_steps_then_sequenced.expected.json b/internal/exec/runtime/testdata/conformance/analysis_steps_then_sequenced.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_steps_then_sequenced.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_steps_then_sequenced.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_steps_then_sequenced.sysml b/internal/exec/runtime/testdata/conformance/analysis_steps_then_sequenced.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_steps_then_sequenced.sysml rename to internal/exec/runtime/testdata/conformance/analysis_steps_then_sequenced.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_steps_then_sequenced.trace.golden b/internal/exec/runtime/testdata/conformance/analysis_steps_then_sequenced.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_steps_then_sequenced.trace.golden rename to internal/exec/runtime/testdata/conformance/analysis_steps_then_sequenced.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/analysis_steps_wait_on_clock.expected.json b/internal/exec/runtime/testdata/conformance/analysis_steps_wait_on_clock.expected.json new file mode 100644 index 0000000000..edeb1ed876 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/analysis_steps_wait_on_clock.expected.json @@ -0,0 +1,10 @@ +{ + "libraries": true, + "type": "analysis", + "evaluate": "test::voyage", + "trace": true, + "outputs": { + "total": {"type": "Real", "value": 2.0}, + "arrival": {"type": "Real", "value": 60.0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/analysis_steps_wait_on_clock.sysml b/internal/exec/runtime/testdata/conformance/analysis_steps_wait_on_clock.sysml new file mode 100644 index 0000000000..484294c592 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/analysis_steps_wait_on_clock.sysml @@ -0,0 +1,31 @@ +// A step of a case body may wait on the clock (`accept after`): the case's own +// flow advances the clock to the instant, as a performed action's does, so the +// steps after the wait run at that instant instead of the body deadlocking. +package test { + private import ScalarValues::*; + private import SI::*; + + part def Ship { + attribute log : Real default = 0.0; + attribute arrived : Real default = -1.0; + } + part ship : Ship; + + analysis def Voyage { + subject boat : Ship; + in leg : Real; + action depart { assign boat.log := boat.log + 1.0; } + then action sail accept after leg [s]; + then action arrive { + assign boat.log := boat.log + 1.0; + assign boat.arrived := localClock.currentTime; + } + then action rest accept after 30.0 [s]; + return total : Real = boat.log; + out arrival : Real = boat.arrived; + } + analysis voyage : Voyage { + subject boat = ship; + in leg = 60.0; + } +} diff --git a/internal/exec/runtime/testdata/conformance/analysis_steps_wait_on_clock.trace.golden b/internal/exec/runtime/testdata/conformance/analysis_steps_wait_on_clock.trace.golden new file mode 100644 index 0000000000..e98a9f3afd --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/analysis_steps_wait_on_clock.trace.golden @@ -0,0 +1,38 @@ +enter analysis test::voyage +materialize: ship #1 + eval feature ship -> instance#1 + bind boat = instance#1 [default] + eval literal 60.0 -> 60.0 + bind leg = 60.0 [default] + stmt action body +enter action node: analysis test::voyage + stmt assign boat.log + eval feature boat -> instance#1 + eval chain log -> 0.0 + eval literal 1.0 -> 1.0 + eval operator + -> 1.0 + eval feature boat -> instance#1 + eval feature leg -> 60.0 + eval index -> 60.0 [s] + stmt assign boat.log + eval feature boat -> instance#1 + eval chain log -> 1.0 + eval literal 1.0 -> 1.0 + eval operator + -> 2.0 + eval feature boat -> instance#1 + stmt assign boat.arrived +materialize: universalClock #2 + eval feature universalClock -> instance#2 + eval feature localClock -> instance#2 + eval chain currentTime -> 60.0 + eval feature boat -> instance#1 + eval literal 30.0 -> 30.0 + eval index -> 30.0 [s] +leave action node: analysis test::voyage + stmt return + eval feature boat -> instance#1 + eval chain log -> 2.0 +exit analysis test::voyage -> 2.0 + eval feature boat -> instance#1 +eval chain arrived -> 60.0 +output test::voyage.arrival = 60.0 diff --git a/internal/core/runtime/testdata/conformance/analysis_subject_at_run_time.expected.json b/internal/exec/runtime/testdata/conformance/analysis_subject_at_run_time.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_subject_at_run_time.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_subject_at_run_time.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_subject_at_run_time.sysml b/internal/exec/runtime/testdata/conformance/analysis_subject_at_run_time.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_subject_at_run_time.sysml rename to internal/exec/runtime/testdata/conformance/analysis_subject_at_run_time.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_subject_bound_in_usage.expected.json b/internal/exec/runtime/testdata/conformance/analysis_subject_bound_in_usage.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_subject_bound_in_usage.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_subject_bound_in_usage.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_subject_bound_in_usage.sysml b/internal/exec/runtime/testdata/conformance/analysis_subject_bound_in_usage.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_subject_bound_in_usage.sysml rename to internal/exec/runtime/testdata/conformance/analysis_subject_bound_in_usage.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_abstract_evaluation.expected.json b/internal/exec/runtime/testdata/conformance/analysis_trade_study_abstract_evaluation.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_abstract_evaluation.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_abstract_evaluation.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_abstract_evaluation.sysml b/internal/exec/runtime/testdata/conformance/analysis_trade_study_abstract_evaluation.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_abstract_evaluation.sysml rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_abstract_evaluation.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_alternative_fails.expected.json b/internal/exec/runtime/testdata/conformance/analysis_trade_study_alternative_fails.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_alternative_fails.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_alternative_fails.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_alternative_fails.sysml b/internal/exec/runtime/testdata/conformance/analysis_trade_study_alternative_fails.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_alternative_fails.sysml rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_alternative_fails.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_case_parameter.expected.json b/internal/exec/runtime/testdata/conformance/analysis_trade_study_case_parameter.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_case_parameter.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_case_parameter.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_case_parameter.sysml b/internal/exec/runtime/testdata/conformance/analysis_trade_study_case_parameter.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_case_parameter.sysml rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_case_parameter.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_maximize_definition.expected.json b/internal/exec/runtime/testdata/conformance/analysis_trade_study_maximize_definition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_maximize_definition.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_maximize_definition.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_maximize_definition.sysml b/internal/exec/runtime/testdata/conformance/analysis_trade_study_maximize_definition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_maximize_definition.sysml rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_maximize_definition.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_minimize.expected.json b/internal/exec/runtime/testdata/conformance/analysis_trade_study_minimize.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_minimize.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_minimize.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_minimize.sysml b/internal/exec/runtime/testdata/conformance/analysis_trade_study_minimize.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_minimize.sysml rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_minimize.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_minimize.trace.golden b/internal/exec/runtime/testdata/conformance/analysis_trade_study_minimize.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_minimize.trace.golden rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_minimize.trace.golden diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_subject_empty.expected.json b/internal/exec/runtime/testdata/conformance/analysis_trade_study_subject_empty.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_subject_empty.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_subject_empty.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_subject_empty.sysml b/internal/exec/runtime/testdata/conformance/analysis_trade_study_subject_empty.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_subject_empty.sysml rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_subject_empty.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_subject_one.expected.json b/internal/exec/runtime/testdata/conformance/analysis_trade_study_subject_one.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_subject_one.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_subject_one.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_subject_one.sysml b/internal/exec/runtime/testdata/conformance/analysis_trade_study_subject_one.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_subject_one.sysml rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_subject_one.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_tie.expected.json b/internal/exec/runtime/testdata/conformance/analysis_trade_study_tie.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_tie.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_tie.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_trade_study_tie.sysml b/internal/exec/runtime/testdata/conformance/analysis_trade_study_tie.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_trade_study_tie.sysml rename to internal/exec/runtime/testdata/conformance/analysis_trade_study_tie.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_units_preserved.expected.json b/internal/exec/runtime/testdata/conformance/analysis_units_preserved.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_units_preserved.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_units_preserved.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_units_preserved.sysml b/internal/exec/runtime/testdata/conformance/analysis_units_preserved.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_units_preserved.sysml rename to internal/exec/runtime/testdata/conformance/analysis_units_preserved.sysml diff --git a/internal/core/runtime/testdata/conformance/analysis_verdict_only.expected.json b/internal/exec/runtime/testdata/conformance/analysis_verdict_only.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_verdict_only.expected.json rename to internal/exec/runtime/testdata/conformance/analysis_verdict_only.expected.json diff --git a/internal/core/runtime/testdata/conformance/analysis_verdict_only.sysml b/internal/exec/runtime/testdata/conformance/analysis_verdict_only.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/analysis_verdict_only.sysml rename to internal/exec/runtime/testdata/conformance/analysis_verdict_only.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_chain_aliased_object.expected.json b/internal/exec/runtime/testdata/conformance/assign_chain_aliased_object.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_aliased_object.expected.json rename to internal/exec/runtime/testdata/conformance/assign_chain_aliased_object.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_chain_aliased_object.sysml b/internal/exec/runtime/testdata/conformance/assign_chain_aliased_object.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_aliased_object.sysml rename to internal/exec/runtime/testdata/conformance/assign_chain_aliased_object.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_chain_calc_body_rejected.expected.json b/internal/exec/runtime/testdata/conformance/assign_chain_calc_body_rejected.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_calc_body_rejected.expected.json rename to internal/exec/runtime/testdata/conformance/assign_chain_calc_body_rejected.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_chain_calc_body_rejected.sysml b/internal/exec/runtime/testdata/conformance/assign_chain_calc_body_rejected.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_calc_body_rejected.sysml rename to internal/exec/runtime/testdata/conformance/assign_chain_calc_body_rejected.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_chain_depth_three.expected.json b/internal/exec/runtime/testdata/conformance/assign_chain_depth_three.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_depth_three.expected.json rename to internal/exec/runtime/testdata/conformance/assign_chain_depth_three.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_chain_depth_three.sysml b/internal/exec/runtime/testdata/conformance/assign_chain_depth_three.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_depth_three.sysml rename to internal/exec/runtime/testdata/conformance/assign_chain_depth_three.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_chain_depth_two.expected.json b/internal/exec/runtime/testdata/conformance/assign_chain_depth_two.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_depth_two.expected.json rename to internal/exec/runtime/testdata/conformance/assign_chain_depth_two.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_chain_depth_two.sysml b/internal/exec/runtime/testdata/conformance/assign_chain_depth_two.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_depth_two.sysml rename to internal/exec/runtime/testdata/conformance/assign_chain_depth_two.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_chain_depth_two.trace.golden b/internal/exec/runtime/testdata/conformance/assign_chain_depth_two.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_depth_two.trace.golden rename to internal/exec/runtime/testdata/conformance/assign_chain_depth_two.trace.golden diff --git a/internal/core/runtime/testdata/conformance/assign_chain_inherited_feature.expected.json b/internal/exec/runtime/testdata/conformance/assign_chain_inherited_feature.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_inherited_feature.expected.json rename to internal/exec/runtime/testdata/conformance/assign_chain_inherited_feature.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_chain_inherited_feature.sysml b/internal/exec/runtime/testdata/conformance/assign_chain_inherited_feature.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_inherited_feature.sysml rename to internal/exec/runtime/testdata/conformance/assign_chain_inherited_feature.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_chain_state_do_and_exit.expected.json b/internal/exec/runtime/testdata/conformance/assign_chain_state_do_and_exit.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_state_do_and_exit.expected.json rename to internal/exec/runtime/testdata/conformance/assign_chain_state_do_and_exit.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_chain_state_do_and_exit.sysml b/internal/exec/runtime/testdata/conformance/assign_chain_state_do_and_exit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_state_do_and_exit.sysml rename to internal/exec/runtime/testdata/conformance/assign_chain_state_do_and_exit.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_chain_state_entry_guard_reads.expected.json b/internal/exec/runtime/testdata/conformance/assign_chain_state_entry_guard_reads.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_state_entry_guard_reads.expected.json rename to internal/exec/runtime/testdata/conformance/assign_chain_state_entry_guard_reads.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_chain_state_entry_guard_reads.sysml b/internal/exec/runtime/testdata/conformance/assign_chain_state_entry_guard_reads.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_state_entry_guard_reads.sysml rename to internal/exec/runtime/testdata/conformance/assign_chain_state_entry_guard_reads.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_chain_through_port.expected.json b/internal/exec/runtime/testdata/conformance/assign_chain_through_port.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_through_port.expected.json rename to internal/exec/runtime/testdata/conformance/assign_chain_through_port.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_chain_through_port.sysml b/internal/exec/runtime/testdata/conformance/assign_chain_through_port.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_through_port.sysml rename to internal/exec/runtime/testdata/conformance/assign_chain_through_port.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_chain_transition_effect.expected.json b/internal/exec/runtime/testdata/conformance/assign_chain_transition_effect.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_transition_effect.expected.json rename to internal/exec/runtime/testdata/conformance/assign_chain_transition_effect.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_chain_transition_effect.sysml b/internal/exec/runtime/testdata/conformance/assign_chain_transition_effect.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_chain_transition_effect.sysml rename to internal/exec/runtime/testdata/conformance/assign_chain_transition_effect.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_write_conforms_to_target_type.expected.json b/internal/exec/runtime/testdata/conformance/assign_write_conforms_to_target_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_conforms_to_target_type.expected.json rename to internal/exec/runtime/testdata/conformance/assign_write_conforms_to_target_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_write_conforms_to_target_type.sysml b/internal/exec/runtime/testdata/conformance/assign_write_conforms_to_target_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_conforms_to_target_type.sysml rename to internal/exec/runtime/testdata/conformance/assign_write_conforms_to_target_type.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_write_of_no_value_where_one_is_required.expected.json b/internal/exec/runtime/testdata/conformance/assign_write_of_no_value_where_one_is_required.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_of_no_value_where_one_is_required.expected.json rename to internal/exec/runtime/testdata/conformance/assign_write_of_no_value_where_one_is_required.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_write_of_no_value_where_one_is_required.sysml b/internal/exec/runtime/testdata/conformance/assign_write_of_no_value_where_one_is_required.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_of_no_value_where_one_is_required.sysml rename to internal/exec/runtime/testdata/conformance/assign_write_of_no_value_where_one_is_required.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_write_through_chain_violates_target_type.expected.json b/internal/exec/runtime/testdata/conformance/assign_write_through_chain_violates_target_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_through_chain_violates_target_type.expected.json rename to internal/exec/runtime/testdata/conformance/assign_write_through_chain_violates_target_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_write_through_chain_violates_target_type.sysml b/internal/exec/runtime/testdata/conformance/assign_write_through_chain_violates_target_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_through_chain_violates_target_type.sysml rename to internal/exec/runtime/testdata/conformance/assign_write_through_chain_violates_target_type.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_multiplicity.expected.json b/internal/exec/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_multiplicity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_multiplicity.expected.json rename to internal/exec/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_multiplicity.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_multiplicity.sysml b/internal/exec/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_multiplicity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_multiplicity.sysml rename to internal/exec/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_multiplicity.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_type.expected.json b/internal/exec/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_type.expected.json rename to internal/exec/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_type.sysml b/internal/exec/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_type.sysml rename to internal/exec/runtime/testdata/conformance/assign_write_to_state_attribute_violates_target_type.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_write_violates_target_multiplicity.expected.json b/internal/exec/runtime/testdata/conformance/assign_write_violates_target_multiplicity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_violates_target_multiplicity.expected.json rename to internal/exec/runtime/testdata/conformance/assign_write_violates_target_multiplicity.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_write_violates_target_multiplicity.sysml b/internal/exec/runtime/testdata/conformance/assign_write_violates_target_multiplicity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_violates_target_multiplicity.sysml rename to internal/exec/runtime/testdata/conformance/assign_write_violates_target_multiplicity.sysml diff --git a/internal/core/runtime/testdata/conformance/assign_write_violates_target_type.expected.json b/internal/exec/runtime/testdata/conformance/assign_write_violates_target_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_violates_target_type.expected.json rename to internal/exec/runtime/testdata/conformance/assign_write_violates_target_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/assign_write_violates_target_type.sysml b/internal/exec/runtime/testdata/conformance/assign_write_violates_target_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/assign_write_violates_target_type.sysml rename to internal/exec/runtime/testdata/conformance/assign_write_violates_target_type.sysml diff --git a/internal/core/runtime/testdata/conformance/attribute_body_over_inherited_value.expected.json b/internal/exec/runtime/testdata/conformance/attribute_body_over_inherited_value.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/attribute_body_over_inherited_value.expected.json rename to internal/exec/runtime/testdata/conformance/attribute_body_over_inherited_value.expected.json diff --git a/internal/core/runtime/testdata/conformance/attribute_body_over_inherited_value.sysml b/internal/exec/runtime/testdata/conformance/attribute_body_over_inherited_value.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/attribute_body_over_inherited_value.sysml rename to internal/exec/runtime/testdata/conformance/attribute_body_over_inherited_value.sysml diff --git a/internal/core/runtime/testdata/conformance/attribute_body_over_inherited_value_chain.expected.json b/internal/exec/runtime/testdata/conformance/attribute_body_over_inherited_value_chain.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/attribute_body_over_inherited_value_chain.expected.json rename to internal/exec/runtime/testdata/conformance/attribute_body_over_inherited_value_chain.expected.json diff --git a/internal/core/runtime/testdata/conformance/attribute_body_over_inherited_value_chain.sysml b/internal/exec/runtime/testdata/conformance/attribute_body_over_inherited_value_chain.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/attribute_body_over_inherited_value_chain.sysml rename to internal/exec/runtime/testdata/conformance/attribute_body_over_inherited_value_chain.sysml diff --git a/internal/core/runtime/testdata/conformance/attribute_nested_value_body.expected.json b/internal/exec/runtime/testdata/conformance/attribute_nested_value_body.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/attribute_nested_value_body.expected.json rename to internal/exec/runtime/testdata/conformance/attribute_nested_value_body.expected.json diff --git a/internal/core/runtime/testdata/conformance/attribute_nested_value_body.sysml b/internal/exec/runtime/testdata/conformance/attribute_nested_value_body.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/attribute_nested_value_body.sysml rename to internal/exec/runtime/testdata/conformance/attribute_nested_value_body.sysml diff --git a/internal/core/runtime/testdata/conformance/ballandchain_interface_connected.expected.json b/internal/exec/runtime/testdata/conformance/ballandchain_interface_connected.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/ballandchain_interface_connected.expected.json rename to internal/exec/runtime/testdata/conformance/ballandchain_interface_connected.expected.json diff --git a/internal/core/runtime/testdata/conformance/ballandchain_interface_connected.sysml b/internal/exec/runtime/testdata/conformance/ballandchain_interface_connected.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/ballandchain_interface_connected.sysml rename to internal/exec/runtime/testdata/conformance/ballandchain_interface_connected.sysml diff --git a/internal/core/runtime/testdata/conformance/ballandchain_interface_disconnected.expected.json b/internal/exec/runtime/testdata/conformance/ballandchain_interface_disconnected.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/ballandchain_interface_disconnected.expected.json rename to internal/exec/runtime/testdata/conformance/ballandchain_interface_disconnected.expected.json diff --git a/internal/core/runtime/testdata/conformance/ballandchain_interface_disconnected.sysml b/internal/exec/runtime/testdata/conformance/ballandchain_interface_disconnected.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/ballandchain_interface_disconnected.sysml rename to internal/exec/runtime/testdata/conformance/ballandchain_interface_disconnected.sysml diff --git a/internal/core/runtime/testdata/conformance/ballandchain_variant_configuration.expected.json b/internal/exec/runtime/testdata/conformance/ballandchain_variant_configuration.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/ballandchain_variant_configuration.expected.json rename to internal/exec/runtime/testdata/conformance/ballandchain_variant_configuration.expected.json diff --git a/internal/core/runtime/testdata/conformance/ballandchain_variant_configuration.sysml b/internal/exec/runtime/testdata/conformance/ballandchain_variant_configuration.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/ballandchain_variant_configuration.sysml rename to internal/exec/runtime/testdata/conformance/ballandchain_variant_configuration.sysml diff --git a/internal/exec/runtime/testdata/conformance/behavior_connection_bound_port_end.expected.json b/internal/exec/runtime/testdata/conformance/behavior_connection_bound_port_end.expected.json new file mode 100644 index 0000000000..eb78aa3dfa --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/behavior_connection_bound_port_end.expected.json @@ -0,0 +1,19 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "BehaviorConnectionBoundPortEnd::Rig", + "materialization": {}, + "objects": [ + { + "path": "", + "events": [{"signal": "Go"}], + "finalState": "fired", + "stateVisits": ["idle", "firing", "fired"] + }, + { + "path": "bound", + "finalState": "heard", + "stateVisits": ["idle", "heard"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/behavior_connection_bound_port_end.sysml b/internal/exec/runtime/testdata/conformance/behavior_connection_bound_port_end.sysml new file mode 100644 index 0000000000..8fe78f0d2d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/behavior_connection_bound_port_end.sysml @@ -0,0 +1,60 @@ +// An action's own connector may end at a bound port reference by its bare name: +// `connect src.tx to rx` with `rx` bound to `bound.local` joins the source's port to +// the bound listener's, so a send over it reaches that listener and never the +// performer's same-named port. +package BehaviorConnectionBoundPortEnd { + item def Go; + item def Ping; + + port def PingOut { + out item ping : Ping; + } + + part def Device { + port tx : PingOut; + } + + part def Listener { + port local : ~PingOut; + + exhibit state life { + entry; then idle; + state idle; + transition first idle accept Ping via local then heard; + state heard; + } + } + + action def Fire { + in ref src : Device; + in ref port rx : ~PingOut; + connect src.tx to rx; + first start then go; + action go { + send new Ping() via src.tx; + } + first go then done; + } + + part def Rig { + port rx : ~PingOut; + part device : Device; + part bound : Listener; + + exhibit state life { + entry; then idle; + state idle; + transition first idle accept Go then firing; + state firing { + do action fire : Fire { + in ref :>> src = device; + in ref port :>> rx = bound.local; + } + } + state fired; + state misfired; + transition first firing accept Ping via rx then misfired; + transition first firing then fired; + } + } +} diff --git a/internal/core/runtime/testdata/conformance/binding_calc_result.expected.json b/internal/exec/runtime/testdata/conformance/binding_calc_result.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_calc_result.expected.json rename to internal/exec/runtime/testdata/conformance/binding_calc_result.expected.json diff --git a/internal/core/runtime/testdata/conformance/binding_calc_result.sysml b/internal/exec/runtime/testdata/conformance/binding_calc_result.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_calc_result.sysml rename to internal/exec/runtime/testdata/conformance/binding_calc_result.sysml diff --git a/internal/core/runtime/testdata/conformance/binding_calc_result_reverse.expected.json b/internal/exec/runtime/testdata/conformance/binding_calc_result_reverse.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_calc_result_reverse.expected.json rename to internal/exec/runtime/testdata/conformance/binding_calc_result_reverse.expected.json diff --git a/internal/core/runtime/testdata/conformance/binding_calc_result_reverse.sysml b/internal/exec/runtime/testdata/conformance/binding_calc_result_reverse.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_calc_result_reverse.sysml rename to internal/exec/runtime/testdata/conformance/binding_calc_result_reverse.sysml diff --git a/internal/core/runtime/testdata/conformance/binding_chained_object_ends.expected.json b/internal/exec/runtime/testdata/conformance/binding_chained_object_ends.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_chained_object_ends.expected.json rename to internal/exec/runtime/testdata/conformance/binding_chained_object_ends.expected.json diff --git a/internal/core/runtime/testdata/conformance/binding_chained_object_ends.sysml b/internal/exec/runtime/testdata/conformance/binding_chained_object_ends.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_chained_object_ends.sysml rename to internal/exec/runtime/testdata/conformance/binding_chained_object_ends.sysml diff --git a/internal/core/runtime/testdata/conformance/binding_expression_end.expected.json b/internal/exec/runtime/testdata/conformance/binding_expression_end.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_expression_end.expected.json rename to internal/exec/runtime/testdata/conformance/binding_expression_end.expected.json diff --git a/internal/core/runtime/testdata/conformance/binding_expression_end.sysml b/internal/exec/runtime/testdata/conformance/binding_expression_end.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_expression_end.sysml rename to internal/exec/runtime/testdata/conformance/binding_expression_end.sysml diff --git a/internal/core/runtime/testdata/conformance/binding_multivalued.expected.json b/internal/exec/runtime/testdata/conformance/binding_multivalued.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_multivalued.expected.json rename to internal/exec/runtime/testdata/conformance/binding_multivalued.expected.json diff --git a/internal/core/runtime/testdata/conformance/binding_multivalued.sysml b/internal/exec/runtime/testdata/conformance/binding_multivalued.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_multivalued.sysml rename to internal/exec/runtime/testdata/conformance/binding_multivalued.sysml diff --git a/internal/core/runtime/testdata/conformance/binding_nested_end.expected.json b/internal/exec/runtime/testdata/conformance/binding_nested_end.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_nested_end.expected.json rename to internal/exec/runtime/testdata/conformance/binding_nested_end.expected.json diff --git a/internal/core/runtime/testdata/conformance/binding_nested_end.sysml b/internal/exec/runtime/testdata/conformance/binding_nested_end.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_nested_end.sysml rename to internal/exec/runtime/testdata/conformance/binding_nested_end.sysml diff --git a/internal/core/runtime/testdata/conformance/binding_nested_end_reverse.expected.json b/internal/exec/runtime/testdata/conformance/binding_nested_end_reverse.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_nested_end_reverse.expected.json rename to internal/exec/runtime/testdata/conformance/binding_nested_end_reverse.expected.json diff --git a/internal/core/runtime/testdata/conformance/binding_nested_end_reverse.sysml b/internal/exec/runtime/testdata/conformance/binding_nested_end_reverse.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_nested_end_reverse.sysml rename to internal/exec/runtime/testdata/conformance/binding_nested_end_reverse.sysml diff --git a/internal/core/runtime/testdata/conformance/binding_object_end.expected.json b/internal/exec/runtime/testdata/conformance/binding_object_end.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_object_end.expected.json rename to internal/exec/runtime/testdata/conformance/binding_object_end.expected.json diff --git a/internal/core/runtime/testdata/conformance/binding_object_end.sysml b/internal/exec/runtime/testdata/conformance/binding_object_end.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_object_end.sysml rename to internal/exec/runtime/testdata/conformance/binding_object_end.sysml diff --git a/internal/core/runtime/testdata/conformance/binding_value_forward.expected.json b/internal/exec/runtime/testdata/conformance/binding_value_forward.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_value_forward.expected.json rename to internal/exec/runtime/testdata/conformance/binding_value_forward.expected.json diff --git a/internal/core/runtime/testdata/conformance/binding_value_forward.sysml b/internal/exec/runtime/testdata/conformance/binding_value_forward.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_value_forward.sysml rename to internal/exec/runtime/testdata/conformance/binding_value_forward.sysml diff --git a/internal/core/runtime/testdata/conformance/binding_value_reverse.expected.json b/internal/exec/runtime/testdata/conformance/binding_value_reverse.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_value_reverse.expected.json rename to internal/exec/runtime/testdata/conformance/binding_value_reverse.expected.json diff --git a/internal/core/runtime/testdata/conformance/binding_value_reverse.sysml b/internal/exec/runtime/testdata/conformance/binding_value_reverse.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/binding_value_reverse.sysml rename to internal/exec/runtime/testdata/conformance/binding_value_reverse.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_block_flow_assigns_output.expected.json b/internal/exec/runtime/testdata/conformance/calc_block_flow_assigns_output.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_block_flow_assigns_output.expected.json rename to internal/exec/runtime/testdata/conformance/calc_block_flow_assigns_output.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_block_flow_assigns_output.sysml b/internal/exec/runtime/testdata/conformance/calc_block_flow_assigns_output.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_block_flow_assigns_output.sysml rename to internal/exec/runtime/testdata/conformance/calc_block_flow_assigns_output.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_block_flow_early_return.expected.json b/internal/exec/runtime/testdata/conformance/calc_block_flow_early_return.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_block_flow_early_return.expected.json rename to internal/exec/runtime/testdata/conformance/calc_block_flow_early_return.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_block_flow_early_return.sysml b/internal/exec/runtime/testdata/conformance/calc_block_flow_early_return.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_block_flow_early_return.sysml rename to internal/exec/runtime/testdata/conformance/calc_block_flow_early_return.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_block_flow_early_return.trace.golden b/internal/exec/runtime/testdata/conformance/calc_block_flow_early_return.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_block_flow_early_return.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_block_flow_early_return.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_block_flow_node_unvalued_pin.expected.json b/internal/exec/runtime/testdata/conformance/calc_block_flow_node_unvalued_pin.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_block_flow_node_unvalued_pin.expected.json rename to internal/exec/runtime/testdata/conformance/calc_block_flow_node_unvalued_pin.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_block_flow_node_unvalued_pin.sysml b/internal/exec/runtime/testdata/conformance/calc_block_flow_node_unvalued_pin.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_block_flow_node_unvalued_pin.sysml rename to internal/exec/runtime/testdata/conformance/calc_block_flow_node_unvalued_pin.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_block_flow_result_in_block.expected.json b/internal/exec/runtime/testdata/conformance/calc_block_flow_result_in_block.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_block_flow_result_in_block.expected.json rename to internal/exec/runtime/testdata/conformance/calc_block_flow_result_in_block.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_block_flow_result_in_block.sysml b/internal/exec/runtime/testdata/conformance/calc_block_flow_result_in_block.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_block_flow_result_in_block.sysml rename to internal/exec/runtime/testdata/conformance/calc_block_flow_result_in_block.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_body_default_reference_usage.expected.json b/internal/exec/runtime/testdata/conformance/calc_body_default_reference_usage.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_body_default_reference_usage.expected.json rename to internal/exec/runtime/testdata/conformance/calc_body_default_reference_usage.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_body_default_reference_usage.sysml b/internal/exec/runtime/testdata/conformance/calc_body_default_reference_usage.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_body_default_reference_usage.sysml rename to internal/exec/runtime/testdata/conformance/calc_body_default_reference_usage.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_body_local_declared_type.expected.json b/internal/exec/runtime/testdata/conformance/calc_body_local_declared_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_body_local_declared_type.expected.json rename to internal/exec/runtime/testdata/conformance/calc_body_local_declared_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_body_local_declared_type.sysml b/internal/exec/runtime/testdata/conformance/calc_body_local_declared_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_body_local_declared_type.sysml rename to internal/exec/runtime/testdata/conformance/calc_body_local_declared_type.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_boolean_operators.expected.json b/internal/exec/runtime/testdata/conformance/calc_boolean_operators.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_boolean_operators.expected.json rename to internal/exec/runtime/testdata/conformance/calc_boolean_operators.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_boolean_operators.sysml b/internal/exec/runtime/testdata/conformance/calc_boolean_operators.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_boolean_operators.sysml rename to internal/exec/runtime/testdata/conformance/calc_boolean_operators.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_boolean_operators.trace.golden b/internal/exec/runtime/testdata/conformance/calc_boolean_operators.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_boolean_operators.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_boolean_operators.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_cast_complex_real_axis.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_complex_real_axis.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_complex_real_axis.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_complex_real_axis.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_complex_real_axis.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_complex_real_axis.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_complex_real_axis.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_complex_real_axis.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_cast_composed_operands.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_composed_operands.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_composed_operands.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_composed_operands.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_composed_operands.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_composed_operands.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_composed_operands.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_composed_operands.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_cast_declared_types.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_declared_types.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_declared_types.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_declared_types.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_declared_types.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_declared_types.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_declared_types.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_declared_types.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_cast_enumeration.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_enumeration.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_enumeration.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_enumeration.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_enumeration.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_enumeration.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_enumeration.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_enumeration.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_cast_expression_value.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_expression_value.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_expression_value.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_expression_value.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_expression_value.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_expression_value.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_expression_value.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_expression_value.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_cast_instances.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_instances.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_instances.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_instances.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_instances.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_instances.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_instances.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_instances.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_cast_qualified_target.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_qualified_target.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_qualified_target.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_qualified_target.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_qualified_target.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_qualified_target.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_qualified_target.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_qualified_target.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_cast_quantity.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_quantity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_quantity.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_quantity.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_quantity.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_quantity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_quantity.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_quantity.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_cast_scalar_values.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_scalar_values.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_scalar_values.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_scalar_values.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_scalar_values.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_scalar_values.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_scalar_values.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_scalar_values.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_cast_sequence_elementwise.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_sequence_elementwise.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_sequence_elementwise.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_sequence_elementwise.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_sequence_elementwise.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_sequence_elementwise.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_sequence_elementwise.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_sequence_elementwise.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_cast_structured.expected.json b/internal/exec/runtime/testdata/conformance/calc_cast_structured.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_structured.expected.json rename to internal/exec/runtime/testdata/conformance/calc_cast_structured.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_cast_structured.sysml b/internal/exec/runtime/testdata/conformance/calc_cast_structured.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_cast_structured.sysml rename to internal/exec/runtime/testdata/conformance/calc_cast_structured.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_collect_names_outer_variable.expected.json b/internal/exec/runtime/testdata/conformance/calc_collect_names_outer_variable.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collect_names_outer_variable.expected.json rename to internal/exec/runtime/testdata/conformance/calc_collect_names_outer_variable.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_collect_names_outer_variable.sysml b/internal/exec/runtime/testdata/conformance/calc_collect_names_outer_variable.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collect_names_outer_variable.sysml rename to internal/exec/runtime/testdata/conformance/calc_collect_names_outer_variable.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_collect_over_sequence.expected.json b/internal/exec/runtime/testdata/conformance/calc_collect_over_sequence.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collect_over_sequence.expected.json rename to internal/exec/runtime/testdata/conformance/calc_collect_over_sequence.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_collect_over_sequence.sysml b/internal/exec/runtime/testdata/conformance/calc_collect_over_sequence.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collect_over_sequence.sysml rename to internal/exec/runtime/testdata/conformance/calc_collect_over_sequence.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_collect_untyped_body_reads_element.expected.json b/internal/exec/runtime/testdata/conformance/calc_collect_untyped_body_reads_element.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collect_untyped_body_reads_element.expected.json rename to internal/exec/runtime/testdata/conformance/calc_collect_untyped_body_reads_element.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_collect_untyped_body_reads_element.sysml b/internal/exec/runtime/testdata/conformance/calc_collect_untyped_body_reads_element.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collect_untyped_body_reads_element.sysml rename to internal/exec/runtime/testdata/conformance/calc_collect_untyped_body_reads_element.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_collection_aggregators.expected.json b/internal/exec/runtime/testdata/conformance/calc_collection_aggregators.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_aggregators.expected.json rename to internal/exec/runtime/testdata/conformance/calc_collection_aggregators.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_collection_aggregators.sysml b/internal/exec/runtime/testdata/conformance/calc_collection_aggregators.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_aggregators.sysml rename to internal/exec/runtime/testdata/conformance/calc_collection_aggregators.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_collection_body_wrong_arity.expected.json b/internal/exec/runtime/testdata/conformance/calc_collection_body_wrong_arity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_body_wrong_arity.expected.json rename to internal/exec/runtime/testdata/conformance/calc_collection_body_wrong_arity.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_collection_body_wrong_arity.sysml b/internal/exec/runtime/testdata/conformance/calc_collection_body_wrong_arity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_body_wrong_arity.sysml rename to internal/exec/runtime/testdata/conformance/calc_collection_body_wrong_arity.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_collection_ops_in_for_loop.expected.json b/internal/exec/runtime/testdata/conformance/calc_collection_ops_in_for_loop.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_ops_in_for_loop.expected.json rename to internal/exec/runtime/testdata/conformance/calc_collection_ops_in_for_loop.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_collection_ops_in_for_loop.sysml b/internal/exec/runtime/testdata/conformance/calc_collection_ops_in_for_loop.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_ops_in_for_loop.sysml rename to internal/exec/runtime/testdata/conformance/calc_collection_ops_in_for_loop.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_collection_ops_in_while_loop.expected.json b/internal/exec/runtime/testdata/conformance/calc_collection_ops_in_while_loop.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_ops_in_while_loop.expected.json rename to internal/exec/runtime/testdata/conformance/calc_collection_ops_in_while_loop.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_collection_ops_in_while_loop.sysml b/internal/exec/runtime/testdata/conformance/calc_collection_ops_in_while_loop.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_ops_in_while_loop.sysml rename to internal/exec/runtime/testdata/conformance/calc_collection_ops_in_while_loop.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_collection_ops_over_empty.expected.json b/internal/exec/runtime/testdata/conformance/calc_collection_ops_over_empty.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_ops_over_empty.expected.json rename to internal/exec/runtime/testdata/conformance/calc_collection_ops_over_empty.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_collection_ops_over_empty.sysml b/internal/exec/runtime/testdata/conformance/calc_collection_ops_over_empty.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_ops_over_empty.sysml rename to internal/exec/runtime/testdata/conformance/calc_collection_ops_over_empty.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_collection_receiver_form.expected.json b/internal/exec/runtime/testdata/conformance/calc_collection_receiver_form.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_receiver_form.expected.json rename to internal/exec/runtime/testdata/conformance/calc_collection_receiver_form.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_collection_receiver_form.sysml b/internal/exec/runtime/testdata/conformance/calc_collection_receiver_form.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_collection_receiver_form.sysml rename to internal/exec/runtime/testdata/conformance/calc_collection_receiver_form.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_conditional_branch.expected.json b/internal/exec/runtime/testdata/conformance/calc_conditional_branch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_conditional_branch.expected.json rename to internal/exec/runtime/testdata/conformance/calc_conditional_branch.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_conditional_branch.sysml b/internal/exec/runtime/testdata/conformance/calc_conditional_branch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_conditional_branch.sysml rename to internal/exec/runtime/testdata/conformance/calc_conditional_branch.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_conditional_branch.trace.golden b/internal/exec/runtime/testdata/conformance/calc_conditional_branch.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_conditional_branch.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_conditional_branch.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_conditional_operator_base_case.expected.json b/internal/exec/runtime/testdata/conformance/calc_conditional_operator_base_case.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_conditional_operator_base_case.expected.json rename to internal/exec/runtime/testdata/conformance/calc_conditional_operator_base_case.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_conditional_operator_base_case.sysml b/internal/exec/runtime/testdata/conformance/calc_conditional_operator_base_case.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_conditional_operator_base_case.sysml rename to internal/exec/runtime/testdata/conformance/calc_conditional_operator_base_case.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_early_return_from_loop.expected.json b/internal/exec/runtime/testdata/conformance/calc_early_return_from_loop.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_early_return_from_loop.expected.json rename to internal/exec/runtime/testdata/conformance/calc_early_return_from_loop.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_early_return_from_loop.sysml b/internal/exec/runtime/testdata/conformance/calc_early_return_from_loop.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_early_return_from_loop.sysml rename to internal/exec/runtime/testdata/conformance/calc_early_return_from_loop.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_early_return_from_loop.trace.golden b/internal/exec/runtime/testdata/conformance/calc_early_return_from_loop.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_early_return_from_loop.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_early_return_from_loop.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_empty_collection_aggregation.expected.json b/internal/exec/runtime/testdata/conformance/calc_empty_collection_aggregation.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_empty_collection_aggregation.expected.json rename to internal/exec/runtime/testdata/conformance/calc_empty_collection_aggregation.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_empty_collection_aggregation.sysml b/internal/exec/runtime/testdata/conformance/calc_empty_collection_aggregation.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_empty_collection_aggregation.sysml rename to internal/exec/runtime/testdata/conformance/calc_empty_collection_aggregation.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_for_over_sequence.expected.json b/internal/exec/runtime/testdata/conformance/calc_for_over_sequence.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_for_over_sequence.expected.json rename to internal/exec/runtime/testdata/conformance/calc_for_over_sequence.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_for_over_sequence.sysml b/internal/exec/runtime/testdata/conformance/calc_for_over_sequence.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_for_over_sequence.sysml rename to internal/exec/runtime/testdata/conformance/calc_for_over_sequence.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_from_constraint.expected.json b/internal/exec/runtime/testdata/conformance/calc_from_constraint.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_from_constraint.expected.json rename to internal/exec/runtime/testdata/conformance/calc_from_constraint.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_from_constraint.sysml b/internal/exec/runtime/testdata/conformance/calc_from_constraint.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_from_constraint.sysml rename to internal/exec/runtime/testdata/conformance/calc_from_constraint.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_from_constraint.trace.golden b/internal/exec/runtime/testdata/conformance/calc_from_constraint.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_from_constraint.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_from_constraint.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_identity_operators.expected.json b/internal/exec/runtime/testdata/conformance/calc_identity_operators.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_identity_operators.expected.json rename to internal/exec/runtime/testdata/conformance/calc_identity_operators.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_identity_operators.sysml b/internal/exec/runtime/testdata/conformance/calc_identity_operators.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_identity_operators.sysml rename to internal/exec/runtime/testdata/conformance/calc_identity_operators.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_inherited_member_redefined_scalar.expected.json b/internal/exec/runtime/testdata/conformance/calc_inherited_member_redefined_scalar.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_inherited_member_redefined_scalar.expected.json rename to internal/exec/runtime/testdata/conformance/calc_inherited_member_redefined_scalar.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_inherited_member_redefined_scalar.sysml b/internal/exec/runtime/testdata/conformance/calc_inherited_member_redefined_scalar.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_inherited_member_redefined_scalar.sysml rename to internal/exec/runtime/testdata/conformance/calc_inherited_member_redefined_scalar.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_inherited_parameters.expected.json b/internal/exec/runtime/testdata/conformance/calc_inherited_parameters.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_inherited_parameters.expected.json rename to internal/exec/runtime/testdata/conformance/calc_inherited_parameters.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_inherited_parameters.sysml b/internal/exec/runtime/testdata/conformance/calc_inherited_parameters.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_inherited_parameters.sysml rename to internal/exec/runtime/testdata/conformance/calc_inherited_parameters.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_inherited_parameters.trace.golden b/internal/exec/runtime/testdata/conformance/calc_inherited_parameters.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_inherited_parameters.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_inherited_parameters.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/calc_integer_ceiling.expected.json b/internal/exec/runtime/testdata/conformance/calc_integer_ceiling.expected.json new file mode 100644 index 0000000000..f6713a0cc3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/calc_integer_ceiling.expected.json @@ -0,0 +1,9 @@ +{ + "type": "calc", + "libraries": true, + "inputs": [ + {"type": "Real", "value": 7.5}, + {"type": "Real", "value": 2.0} + ], + "result": {"type": "Integer", "value": -1022} +} diff --git a/internal/exec/runtime/testdata/conformance/calc_integer_ceiling.sysml b/internal/exec/runtime/testdata/conformance/calc_integer_ceiling.sysml new file mode 100644 index 0000000000..bb69654dbf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/calc_integer_ceiling.sysml @@ -0,0 +1,12 @@ +// Rounding up through the extension library's ceiling: -2.5 is -2, and the +// least Integer is a value rather than the overflow -floor(-x) reports. +package test { + private import ScalarValues::*; + private import OpenSysMLMathFunctions::*; + + calc def slotsFor { + in load : Real; + in perSlot : Real; + return : Integer = ceiling(load / perSlot) + ceiling(-2.5) + ceiling(-9223372036854775808.0) - ceiling(-9223372036854774784.0); + } +} diff --git a/internal/exec/runtime/testdata/conformance/calc_integer_quotient.expected.json b/internal/exec/runtime/testdata/conformance/calc_integer_quotient.expected.json new file mode 100644 index 0000000000..6685f9f8bd --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/calc_integer_quotient.expected.json @@ -0,0 +1,9 @@ +{ + "type": "calc", + "libraries": true, + "inputs": [ + {"type": "Integer", "value": 17}, + {"type": "Integer", "value": -5} + ], + "result": {"type": "Integer", "value": 9007199254740987} +} diff --git a/internal/exec/runtime/testdata/conformance/calc_integer_quotient.sysml b/internal/exec/runtime/testdata/conformance/calc_integer_quotient.sysml new file mode 100644 index 0000000000..975b540ad5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/calc_integer_quotient.sysml @@ -0,0 +1,13 @@ +// A calc body dividing Integers through the non-normative OpenSysML extension +// library's quotient: the exact ratio truncated toward zero, so -7 by 2 is -3, +// and exact above 2^53 where a Real quotient would round. +package test { + private import ScalarValues::*; + private import OpenSysMLMathFunctions::*; + + calc def splitLoad { + in load : Integer; + in ways : Integer; + return : Integer = quotient(load, ways) + quotient(-7, 2) + quotient(27021597764222979, 3); + } +} diff --git a/internal/core/runtime/testdata/conformance/calc_integer_range.expected.json b/internal/exec/runtime/testdata/conformance/calc_integer_range.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_integer_range.expected.json rename to internal/exec/runtime/testdata/conformance/calc_integer_range.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_integer_range.sysml b/internal/exec/runtime/testdata/conformance/calc_integer_range.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_integer_range.sysml rename to internal/exec/runtime/testdata/conformance/calc_integer_range.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_iterative_factorial.expected.json b/internal/exec/runtime/testdata/conformance/calc_iterative_factorial.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_iterative_factorial.expected.json rename to internal/exec/runtime/testdata/conformance/calc_iterative_factorial.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_iterative_factorial.sysml b/internal/exec/runtime/testdata/conformance/calc_iterative_factorial.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_iterative_factorial.sysml rename to internal/exec/runtime/testdata/conformance/calc_iterative_factorial.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_iterative_factorial.trace.golden b/internal/exec/runtime/testdata/conformance/calc_iterative_factorial.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_iterative_factorial.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_iterative_factorial.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_keyword_named_parameters.expected.json b/internal/exec/runtime/testdata/conformance/calc_keyword_named_parameters.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_keyword_named_parameters.expected.json rename to internal/exec/runtime/testdata/conformance/calc_keyword_named_parameters.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_keyword_named_parameters.sysml b/internal/exec/runtime/testdata/conformance/calc_keyword_named_parameters.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_keyword_named_parameters.sysml rename to internal/exec/runtime/testdata/conformance/calc_keyword_named_parameters.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_aggregation_identity.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_aggregation_identity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_aggregation_identity.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_aggregation_identity.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_aggregation_identity.sysml b/internal/exec/runtime/testdata/conformance/calc_library_aggregation_identity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_aggregation_identity.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_aggregation_identity.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_empty_rank_zero.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_array_empty_rank_zero.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_empty_rank_zero.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_array_empty_rank_zero.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_empty_rank_zero.sysml b/internal/exec/runtime/testdata/conformance/calc_library_array_empty_rank_zero.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_empty_rank_zero.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_array_empty_rank_zero.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_features.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_array_features.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_features.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_array_features.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_features.sysml b/internal/exec/runtime/testdata/conformance/calc_library_array_features.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_features.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_array_features.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_index.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_array_index.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_index.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_array_index.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_index.sysml b/internal/exec/runtime/testdata/conformance/calc_library_array_index.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_index.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_array_index.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_index_out_of_range.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_array_index_out_of_range.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_index_out_of_range.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_array_index_out_of_range.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_index_out_of_range.sysml b/internal/exec/runtime/testdata/conformance/calc_library_array_index_out_of_range.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_index_out_of_range.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_array_index_out_of_range.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_index_rank_mismatch.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_array_index_rank_mismatch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_index_rank_mismatch.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_array_index_rank_mismatch.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_index_rank_mismatch.sysml b/internal/exec/runtime/testdata/conformance/calc_library_array_index_rank_mismatch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_index_rank_mismatch.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_array_index_rank_mismatch.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_specialization_fixed_shape_underfilled.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_array_specialization_fixed_shape_underfilled.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_specialization_fixed_shape_underfilled.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_array_specialization_fixed_shape_underfilled.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_specialization_fixed_shape_underfilled.sysml b/internal/exec/runtime/testdata/conformance/calc_library_array_specialization_fixed_shape_underfilled.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_specialization_fixed_shape_underfilled.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_array_specialization_fixed_shape_underfilled.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_specialization_members.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_array_specialization_members.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_specialization_members.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_array_specialization_members.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_specialization_members.sysml b/internal/exec/runtime/testdata/conformance/calc_library_array_specialization_members.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_specialization_members.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_array_specialization_members.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_specialization_order.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_array_specialization_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_specialization_order.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_array_specialization_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_specialization_order.sysml b/internal/exec/runtime/testdata/conformance/calc_library_array_specialization_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_specialization_order.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_array_specialization_order.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_specialization_through_calc.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_array_specialization_through_calc.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_specialization_through_calc.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_array_specialization_through_calc.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_specialization_through_calc.sysml b/internal/exec/runtime/testdata/conformance/calc_library_array_specialization_through_calc.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_specialization_through_calc.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_array_specialization_through_calc.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_value.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_array_value.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_value.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_array_value.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_array_value.sysml b/internal/exec/runtime/testdata/conformance/calc_library_array_value.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_array_value.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_array_value.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_base_index_many.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_base_index_many.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_base_index_many.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_base_index_many.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_base_index_many.sysml b/internal/exec/runtime/testdata/conformance/calc_library_base_index_many.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_base_index_many.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_base_index_many.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_base_index_many_sequence.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_base_index_many_sequence.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_base_index_many_sequence.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_base_index_many_sequence.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_base_index_many_sequence.sysml b/internal/exec/runtime/testdata/conformance/calc_library_base_index_many_sequence.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_base_index_many_sequence.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_base_index_many_sequence.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_body_by_reference.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_body_by_reference.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_body_by_reference.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_body_by_reference.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_body_by_reference.sysml b/internal/exec/runtime/testdata/conformance/calc_library_body_by_reference.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_body_by_reference.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_body_by_reference.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_body_keeps_declaring_scope.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_body_keeps_declaring_scope.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_body_keeps_declaring_scope.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_body_keeps_declaring_scope.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_body_keeps_declaring_scope.sysml b/internal/exec/runtime/testdata/conformance/calc_library_body_keeps_declaring_scope.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_body_keeps_declaring_scope.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_body_keeps_declaring_scope.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_body_outlives_its_calc.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_body_outlives_its_calc.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_body_outlives_its_calc.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_body_outlives_its_calc.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_body_outlives_its_calc.sysml b/internal/exec/runtime/testdata/conformance/calc_library_body_outlives_its_calc.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_body_outlives_its_calc.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_body_outlives_its_calc.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_body_reads_calc_output.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_body_reads_calc_output.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_body_reads_calc_output.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_body_reads_calc_output.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_body_reads_calc_output.sysml b/internal/exec/runtime/testdata/conformance/calc_library_body_reads_calc_output.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_body_reads_calc_output.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_body_reads_calc_output.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_builtin_named_arguments.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_builtin_named_arguments.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_builtin_named_arguments.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_builtin_named_arguments.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_builtin_named_arguments.sysml b/internal/exec/runtime/testdata/conformance/calc_library_builtin_named_arguments.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_builtin_named_arguments.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_builtin_named_arguments.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_builtin_omitted_arguments.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_builtin_omitted_arguments.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_builtin_omitted_arguments.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_builtin_omitted_arguments.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_builtin_omitted_arguments.sysml b/internal/exec/runtime/testdata/conformance/calc_library_builtin_omitted_arguments.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_builtin_omitted_arguments.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_builtin_omitted_arguments.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_builtin_shadowed_by_model.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_builtin_shadowed_by_model.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_builtin_shadowed_by_model.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_builtin_shadowed_by_model.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_builtin_shadowed_by_model.sysml b/internal/exec/runtime/testdata/conformance/calc_library_builtin_shadowed_by_model.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_builtin_shadowed_by_model.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_builtin_shadowed_by_model.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_complex_functions.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_complex_functions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_complex_functions.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_complex_functions.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_complex_functions.sysml b/internal/exec/runtime/testdata/conformance/calc_library_complex_functions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_complex_functions.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_complex_functions.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_complex_functions.trace.golden b/internal/exec/runtime/testdata/conformance/calc_library_complex_functions.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_complex_functions.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_library_complex_functions.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_library_complex_sum_real_axis.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_complex_sum_real_axis.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_complex_sum_real_axis.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_complex_sum_real_axis.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_complex_sum_real_axis.sysml b/internal/exec/runtime/testdata/conformance/calc_library_complex_sum_real_axis.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_complex_sum_real_axis.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_complex_sum_real_axis.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_control_body_by_reference.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_control_body_by_reference.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_control_body_by_reference.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_control_body_by_reference.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_control_body_by_reference.sysml b/internal/exec/runtime/testdata/conformance/calc_library_control_body_by_reference.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_control_body_by_reference.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_control_body_by_reference.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_control_functions.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_control_functions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_control_functions.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_control_functions.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_control_functions.sysml b/internal/exec/runtime/testdata/conformance/calc_library_control_functions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_control_functions.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_control_functions.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_conversions.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_conversions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_conversions.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_conversions.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_conversions.sysml b/internal/exec/runtime/testdata/conformance/calc_library_conversions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_conversions.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_conversions.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_empty_equality.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_empty_equality.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_empty_equality.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_empty_equality.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_empty_equality.sysml b/internal/exec/runtime/testdata/conformance/calc_library_empty_equality.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_empty_equality.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_empty_equality.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_feature_imaginary_unit.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_feature_imaginary_unit.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_feature_imaginary_unit.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_feature_imaginary_unit.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_feature_imaginary_unit.sysml b/internal/exec/runtime/testdata/conformance/calc_library_feature_imaginary_unit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_feature_imaginary_unit.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_feature_imaginary_unit.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_feature_pi.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_feature_pi.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_feature_pi.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_feature_pi.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_feature_pi.sysml b/internal/exec/runtime/testdata/conformance/calc_library_feature_pi.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_feature_pi.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_feature_pi.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_feature_shadowed_by_model.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_feature_shadowed_by_model.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_feature_shadowed_by_model.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_feature_shadowed_by_model.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_feature_shadowed_by_model.sysml b/internal/exec/runtime/testdata/conformance/calc_library_feature_shadowed_by_model.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_feature_shadowed_by_model.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_feature_shadowed_by_model.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_feature_zero_vector.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_feature_zero_vector.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_feature_zero_vector.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_feature_zero_vector.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_feature_zero_vector.sysml b/internal/exec/runtime/testdata/conformance/calc_library_feature_zero_vector.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_feature_zero_vector.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_feature_zero_vector.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_feature_zero_vectors.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_feature_zero_vectors.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_feature_zero_vectors.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_feature_zero_vectors.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_feature_zero_vectors.sysml b/internal/exec/runtime/testdata/conformance/calc_library_feature_zero_vectors.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_feature_zero_vectors.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_feature_zero_vectors.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_function_imported.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_function_imported.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_function_imported.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_function_imported.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_function_imported.sysml b/internal/exec/runtime/testdata/conformance/calc_library_function_imported.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_function_imported.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_function_imported.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_function_other_import.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_function_other_import.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_function_other_import.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_function_other_import.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_function_other_import.sysml b/internal/exec/runtime/testdata/conformance/calc_library_function_other_import.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_function_other_import.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_function_other_import.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_function_shadowed_by_model.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_function_shadowed_by_model.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_function_shadowed_by_model.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_function_shadowed_by_model.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_function_shadowed_by_model.sysml b/internal/exec/runtime/testdata/conformance/calc_library_function_shadowed_by_model.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_function_shadowed_by_model.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_function_shadowed_by_model.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_function_shadowed_by_own_calc.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_function_shadowed_by_own_calc.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_function_shadowed_by_own_calc.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_function_shadowed_by_own_calc.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_function_shadowed_by_own_calc.sysml b/internal/exec/runtime/testdata/conformance/calc_library_function_shadowed_by_own_calc.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_function_shadowed_by_own_calc.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_function_shadowed_by_own_calc.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_function_unimported.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_function_unimported.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_function_unimported.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_function_unimported.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_function_unimported.sysml b/internal/exec/runtime/testdata/conformance/calc_library_function_unimported.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_function_unimported.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_function_unimported.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_functions.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_functions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_functions.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_functions.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_functions.sysml b/internal/exec/runtime/testdata/conformance/calc_library_functions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_functions.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_functions.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_functions.trace.golden b/internal/exec/runtime/testdata/conformance/calc_library_functions.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_functions.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_library_functions.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_library_natural_quotient_not_whole.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_natural_quotient_not_whole.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_natural_quotient_not_whole.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_natural_quotient_not_whole.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_natural_quotient_not_whole.sysml b/internal/exec/runtime/testdata/conformance/calc_library_natural_quotient_not_whole.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_natural_quotient_not_whole.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_natural_quotient_not_whole.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_operator_call_forms.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_operator_call_forms.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_operator_call_forms.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_operator_call_forms.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_operator_call_forms.sysml b/internal/exec/runtime/testdata/conformance/calc_library_operator_call_forms.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_operator_call_forms.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_operator_call_forms.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_operator_many_operands.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_operator_many_operands.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_operator_many_operands.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_operator_many_operands.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_operator_many_operands.sysml b/internal/exec/runtime/testdata/conformance/calc_library_operator_many_operands.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_operator_many_operands.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_operator_many_operands.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_operator_singleton_operands.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_operator_singleton_operands.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_operator_singleton_operands.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_operator_singleton_operands.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_operator_singleton_operands.sysml b/internal/exec/runtime/testdata/conformance/calc_library_operator_singleton_operands.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_operator_singleton_operands.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_operator_singleton_operands.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_overload_ambiguous.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_overload_ambiguous.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_overload_ambiguous.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_overload_ambiguous.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_overload_ambiguous.sysml b/internal/exec/runtime/testdata/conformance/calc_library_overload_ambiguous.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_overload_ambiguous.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_overload_ambiguous.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_overload_by_argument_type.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_overload_by_argument_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_overload_by_argument_type.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_overload_by_argument_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_overload_by_argument_type.sysml b/internal/exec/runtime/testdata/conformance/calc_library_overload_by_argument_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_overload_by_argument_type.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_overload_by_argument_type.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_overload_partial_import.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_overload_partial_import.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_overload_partial_import.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_overload_partial_import.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_overload_partial_import.sysml b/internal/exec/runtime/testdata/conformance/calc_library_overload_partial_import.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_overload_partial_import.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_overload_partial_import.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_overload_undetermined_argument.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_overload_undetermined_argument.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_overload_undetermined_argument.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_overload_undetermined_argument.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_overload_undetermined_argument.sysml b/internal/exec/runtime/testdata/conformance/calc_library_overload_undetermined_argument.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_overload_undetermined_argument.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_overload_undetermined_argument.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_rational_denominator_overflow.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_rational_denominator_overflow.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_rational_denominator_overflow.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_rational_denominator_overflow.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_rational_denominator_overflow.sysml b/internal/exec/runtime/testdata/conformance/calc_library_rational_denominator_overflow.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_rational_denominator_overflow.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_rational_denominator_overflow.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_rational_terms.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_rational_terms.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_rational_terms.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_rational_terms.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_rational_terms.sysml b/internal/exec/runtime/testdata/conformance/calc_library_rational_terms.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_rational_terms.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_rational_terms.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_rational_zero_denominator.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_rational_zero_denominator.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_rational_zero_denominator.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_rational_zero_denominator.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_rational_zero_denominator.sysml b/internal/exec/runtime/testdata/conformance/calc_library_rational_zero_denominator.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_rational_zero_denominator.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_rational_zero_denominator.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_sum0_wrong_identity.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_sum0_wrong_identity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_sum0_wrong_identity.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_sum0_wrong_identity.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_sum0_wrong_identity.sysml b/internal/exec/runtime/testdata/conformance/calc_library_sum0_wrong_identity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_sum0_wrong_identity.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_sum0_wrong_identity.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_to_natural_negative.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_to_natural_negative.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_to_natural_negative.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_to_natural_negative.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_to_natural_negative.sysml b/internal/exec/runtime/testdata/conformance/calc_library_to_natural_negative.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_to_natural_negative.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_to_natural_negative.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_to_real_invalid_string.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_to_real_invalid_string.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_to_real_invalid_string.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_to_real_invalid_string.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_to_real_invalid_string.sysml b/internal/exec/runtime/testdata/conformance/calc_library_to_real_invalid_string.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_to_real_invalid_string.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_to_real_invalid_string.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_trig_degrees.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_trig_degrees.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_trig_degrees.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_trig_degrees.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_trig_degrees.sysml b/internal/exec/runtime/testdata/conformance/calc_library_trig_degrees.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_trig_degrees.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_trig_degrees.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_trig_degrees.trace.golden b/internal/exec/runtime/testdata/conformance/calc_library_trig_degrees.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_trig_degrees.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_library_trig_degrees.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_library_unevaluable_function.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_unevaluable_function.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_unevaluable_function.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_unevaluable_function.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_unevaluable_function.sysml b/internal/exec/runtime/testdata/conformance/calc_library_unevaluable_function.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_unevaluable_function.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_unevaluable_function.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_custom_specialization.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_custom_specialization.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_custom_specialization.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_custom_specialization.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_custom_specialization.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_custom_specialization.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_custom_specialization.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_custom_specialization.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_custom_specialization_element.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_custom_specialization_element.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_custom_specialization_element.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_custom_specialization_element.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_custom_specialization_element.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_custom_specialization_element.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_custom_specialization_element.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_custom_specialization_element.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_dimension_mismatch.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_dimension_mismatch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_dimension_mismatch.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_dimension_mismatch.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_dimension_mismatch.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_dimension_mismatch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_dimension_mismatch.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_dimension_mismatch.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_functions.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_functions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_functions.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_functions.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_functions.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_functions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_functions.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_functions.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_functions.trace.golden b/internal/exec/runtime/testdata/conformance/calc_library_vector_functions.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_functions.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_library_vector_functions.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_norm.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_norm.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_norm.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_norm.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_norm.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_norm.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_norm.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_norm.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_norm.trace.golden b/internal/exec/runtime/testdata/conformance/calc_library_vector_norm.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_norm.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_library_vector_norm.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity_as_scalar.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_as_scalar.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity_as_scalar.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_as_scalar.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity_as_scalar.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_as_scalar.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity_as_scalar.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_as_scalar.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity_empty.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_empty.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity_empty.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_empty.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity_empty.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_empty.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity_empty.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_empty.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity_norm.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_norm.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity_norm.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_norm.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity_norm.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_norm.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity_norm.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_norm.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity_num_count.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_num_count.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity_num_count.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_num_count.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity_num_count.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_num_count.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity_num_count.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_num_count.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity_num_type.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_num_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity_num_type.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_num_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_quantity_num_type.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_num_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_quantity_num_type.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_quantity_num_type.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension_overfilled.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension_overfilled.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension_overfilled.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension_overfilled.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension_overfilled.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension_overfilled.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension_overfilled.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_dimension_overfilled.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_element_type.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_element_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_element_type.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_element_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_element_type.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_element_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_specialization_fixed_element_type.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_fixed_element_type.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_specialization_members.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_members.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_specialization_members.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_members.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_specialization_members.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_members.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_specialization_members.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_specialization_members.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_sum.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_sum.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_sum.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_sum.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_sum.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_sum.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_sum.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_sum.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_sum_flat_sequence.expected.json b/internal/exec/runtime/testdata/conformance/calc_library_vector_sum_flat_sequence.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_sum_flat_sequence.expected.json rename to internal/exec/runtime/testdata/conformance/calc_library_vector_sum_flat_sequence.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_library_vector_sum_flat_sequence.sysml b/internal/exec/runtime/testdata/conformance/calc_library_vector_sum_flat_sequence.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_library_vector_sum_flat_sequence.sysml rename to internal/exec/runtime/testdata/conformance/calc_library_vector_sum_flat_sequence.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_loop_until_body.expected.json b/internal/exec/runtime/testdata/conformance/calc_loop_until_body.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_loop_until_body.expected.json rename to internal/exec/runtime/testdata/conformance/calc_loop_until_body.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_loop_until_body.sysml b/internal/exec/runtime/testdata/conformance/calc_loop_until_body.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_loop_until_body.sysml rename to internal/exec/runtime/testdata/conformance/calc_loop_until_body.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_loop_until_body.trace.golden b/internal/exec/runtime/testdata/conformance/calc_loop_until_body.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_loop_until_body.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_loop_until_body.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_modulo_operator.expected.json b/internal/exec/runtime/testdata/conformance/calc_modulo_operator.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_modulo_operator.expected.json rename to internal/exec/runtime/testdata/conformance/calc_modulo_operator.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_modulo_operator.sysml b/internal/exec/runtime/testdata/conformance/calc_modulo_operator.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_modulo_operator.sysml rename to internal/exec/runtime/testdata/conformance/calc_modulo_operator.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_named_arguments.expected.json b/internal/exec/runtime/testdata/conformance/calc_named_arguments.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_named_arguments.expected.json rename to internal/exec/runtime/testdata/conformance/calc_named_arguments.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_named_arguments.sysml b/internal/exec/runtime/testdata/conformance/calc_named_arguments.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_named_arguments.sysml rename to internal/exec/runtime/testdata/conformance/calc_named_arguments.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_named_arguments.trace.golden b/internal/exec/runtime/testdata/conformance/calc_named_arguments.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_named_arguments.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_named_arguments.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_natural_quotient.expected.json b/internal/exec/runtime/testdata/conformance/calc_natural_quotient.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_natural_quotient.expected.json rename to internal/exec/runtime/testdata/conformance/calc_natural_quotient.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_natural_quotient.sysml b/internal/exec/runtime/testdata/conformance/calc_natural_quotient.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_natural_quotient.sysml rename to internal/exec/runtime/testdata/conformance/calc_natural_quotient.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_nested_collection_operations.expected.json b/internal/exec/runtime/testdata/conformance/calc_nested_collection_operations.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_nested_collection_operations.expected.json rename to internal/exec/runtime/testdata/conformance/calc_nested_collection_operations.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_nested_collection_operations.sysml b/internal/exec/runtime/testdata/conformance/calc_nested_collection_operations.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_nested_collection_operations.sysml rename to internal/exec/runtime/testdata/conformance/calc_nested_collection_operations.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_nested_invocation.expected.json b/internal/exec/runtime/testdata/conformance/calc_nested_invocation.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_nested_invocation.expected.json rename to internal/exec/runtime/testdata/conformance/calc_nested_invocation.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_nested_invocation.sysml b/internal/exec/runtime/testdata/conformance/calc_nested_invocation.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_nested_invocation.sysml rename to internal/exec/runtime/testdata/conformance/calc_nested_invocation.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_nested_invocation.trace.golden b/internal/exec/runtime/testdata/conformance/calc_nested_invocation.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_nested_invocation.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_nested_invocation.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_null_coalesce.expected.json b/internal/exec/runtime/testdata/conformance/calc_null_coalesce.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_null_coalesce.expected.json rename to internal/exec/runtime/testdata/conformance/calc_null_coalesce.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_null_coalesce.sysml b/internal/exec/runtime/testdata/conformance/calc_null_coalesce.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_null_coalesce.sysml rename to internal/exec/runtime/testdata/conformance/calc_null_coalesce.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_opensysml_math_functions.expected.json b/internal/exec/runtime/testdata/conformance/calc_opensysml_math_functions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_opensysml_math_functions.expected.json rename to internal/exec/runtime/testdata/conformance/calc_opensysml_math_functions.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_opensysml_math_functions.sysml b/internal/exec/runtime/testdata/conformance/calc_opensysml_math_functions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_opensysml_math_functions.sysml rename to internal/exec/runtime/testdata/conformance/calc_opensysml_math_functions.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_opensysml_math_functions.trace.golden b/internal/exec/runtime/testdata/conformance/calc_opensysml_math_functions.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_opensysml_math_functions.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_opensysml_math_functions.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_ordering_without_library_function.expected.json b/internal/exec/runtime/testdata/conformance/calc_ordering_without_library_function.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_ordering_without_library_function.expected.json rename to internal/exec/runtime/testdata/conformance/calc_ordering_without_library_function.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_ordering_without_library_function.sysml b/internal/exec/runtime/testdata/conformance/calc_ordering_without_library_function.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_ordering_without_library_function.sysml rename to internal/exec/runtime/testdata/conformance/calc_ordering_without_library_function.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_output_assigned_in_body.expected.json b/internal/exec/runtime/testdata/conformance/calc_output_assigned_in_body.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_output_assigned_in_body.expected.json rename to internal/exec/runtime/testdata/conformance/calc_output_assigned_in_body.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_output_assigned_in_body.sysml b/internal/exec/runtime/testdata/conformance/calc_output_assigned_in_body.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_output_assigned_in_body.sysml rename to internal/exec/runtime/testdata/conformance/calc_output_assigned_in_body.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_output_initial_value_assigned.expected.json b/internal/exec/runtime/testdata/conformance/calc_output_initial_value_assigned.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_output_initial_value_assigned.expected.json rename to internal/exec/runtime/testdata/conformance/calc_output_initial_value_assigned.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_output_initial_value_assigned.sysml b/internal/exec/runtime/testdata/conformance/calc_output_initial_value_assigned.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_output_initial_value_assigned.sysml rename to internal/exec/runtime/testdata/conformance/calc_output_initial_value_assigned.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_parameter_defaults.expected.json b/internal/exec/runtime/testdata/conformance/calc_parameter_defaults.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_parameter_defaults.expected.json rename to internal/exec/runtime/testdata/conformance/calc_parameter_defaults.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_parameter_defaults.sysml b/internal/exec/runtime/testdata/conformance/calc_parameter_defaults.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_parameter_defaults.sysml rename to internal/exec/runtime/testdata/conformance/calc_parameter_defaults.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_parameter_defaults.trace.golden b/internal/exec/runtime/testdata/conformance/calc_parameter_defaults.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_parameter_defaults.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_parameter_defaults.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_parameter_optional_omitted.expected.json b/internal/exec/runtime/testdata/conformance/calc_parameter_optional_omitted.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_parameter_optional_omitted.expected.json rename to internal/exec/runtime/testdata/conformance/calc_parameter_optional_omitted.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_parameter_optional_omitted.sysml b/internal/exec/runtime/testdata/conformance/calc_parameter_optional_omitted.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_parameter_optional_omitted.sysml rename to internal/exec/runtime/testdata/conformance/calc_parameter_optional_omitted.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_predicate_defaulted_subject.expected.json b/internal/exec/runtime/testdata/conformance/calc_predicate_defaulted_subject.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_predicate_defaulted_subject.expected.json rename to internal/exec/runtime/testdata/conformance/calc_predicate_defaulted_subject.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_predicate_defaulted_subject.sysml b/internal/exec/runtime/testdata/conformance/calc_predicate_defaulted_subject.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_predicate_defaulted_subject.sysml rename to internal/exec/runtime/testdata/conformance/calc_predicate_defaulted_subject.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_qualified_name_ambiguous.expected.json b/internal/exec/runtime/testdata/conformance/calc_qualified_name_ambiguous.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_qualified_name_ambiguous.expected.json rename to internal/exec/runtime/testdata/conformance/calc_qualified_name_ambiguous.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_qualified_name_ambiguous.sysml b/internal/exec/runtime/testdata/conformance/calc_qualified_name_ambiguous.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_qualified_name_ambiguous.sysml rename to internal/exec/runtime/testdata/conformance/calc_qualified_name_ambiguous.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_qualified_name_private_import.expected.json b/internal/exec/runtime/testdata/conformance/calc_qualified_name_private_import.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_qualified_name_private_import.expected.json rename to internal/exec/runtime/testdata/conformance/calc_qualified_name_private_import.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_qualified_name_private_import.sysml b/internal/exec/runtime/testdata/conformance/calc_qualified_name_private_import.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_qualified_name_private_import.sysml rename to internal/exec/runtime/testdata/conformance/calc_qualified_name_private_import.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_qualified_name_through_import.expected.json b/internal/exec/runtime/testdata/conformance/calc_qualified_name_through_import.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_qualified_name_through_import.expected.json rename to internal/exec/runtime/testdata/conformance/calc_qualified_name_through_import.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_qualified_name_through_import.sysml b/internal/exec/runtime/testdata/conformance/calc_qualified_name_through_import.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_qualified_name_through_import.sysml rename to internal/exec/runtime/testdata/conformance/calc_qualified_name_through_import.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_qualified_names.expected.json b/internal/exec/runtime/testdata/conformance/calc_qualified_names.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_qualified_names.expected.json rename to internal/exec/runtime/testdata/conformance/calc_qualified_names.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_qualified_names.sysml b/internal/exec/runtime/testdata/conformance/calc_qualified_names.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_qualified_names.sysml rename to internal/exec/runtime/testdata/conformance/calc_qualified_names.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_qualified_names.trace.golden b/internal/exec/runtime/testdata/conformance/calc_qualified_names.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_qualified_names.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_qualified_names.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_coherent_mismatch.expected.json b/internal/exec/runtime/testdata/conformance/calc_quantity_coherent_mismatch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_coherent_mismatch.expected.json rename to internal/exec/runtime/testdata/conformance/calc_quantity_coherent_mismatch.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_coherent_mismatch.sysml b/internal/exec/runtime/testdata/conformance/calc_quantity_coherent_mismatch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_coherent_mismatch.sysml rename to internal/exec/runtime/testdata/conformance/calc_quantity_coherent_mismatch.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_coherent_result.expected.json b/internal/exec/runtime/testdata/conformance/calc_quantity_coherent_result.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_coherent_result.expected.json rename to internal/exec/runtime/testdata/conformance/calc_quantity_coherent_result.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_coherent_result.sysml b/internal/exec/runtime/testdata/conformance/calc_quantity_coherent_result.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_coherent_result.sysml rename to internal/exec/runtime/testdata/conformance/calc_quantity_coherent_result.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_ratio.expected.json b/internal/exec/runtime/testdata/conformance/calc_quantity_ratio.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_ratio.expected.json rename to internal/exec/runtime/testdata/conformance/calc_quantity_ratio.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_ratio.sysml b/internal/exec/runtime/testdata/conformance/calc_quantity_ratio.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_ratio.sysml rename to internal/exec/runtime/testdata/conformance/calc_quantity_ratio.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_scale_affine.expected.json b/internal/exec/runtime/testdata/conformance/calc_quantity_scale_affine.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_scale_affine.expected.json rename to internal/exec/runtime/testdata/conformance/calc_quantity_scale_affine.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_scale_affine.sysml b/internal/exec/runtime/testdata/conformance/calc_quantity_scale_affine.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_scale_affine.sysml rename to internal/exec/runtime/testdata/conformance/calc_quantity_scale_affine.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_scale_compare.expected.json b/internal/exec/runtime/testdata/conformance/calc_quantity_scale_compare.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_scale_compare.expected.json rename to internal/exec/runtime/testdata/conformance/calc_quantity_scale_compare.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_scale_compare.sysml b/internal/exec/runtime/testdata/conformance/calc_quantity_scale_compare.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_scale_compare.sysml rename to internal/exec/runtime/testdata/conformance/calc_quantity_scale_compare.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_sum_imported.expected.json b/internal/exec/runtime/testdata/conformance/calc_quantity_sum_imported.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_sum_imported.expected.json rename to internal/exec/runtime/testdata/conformance/calc_quantity_sum_imported.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_sum_imported.sysml b/internal/exec/runtime/testdata/conformance/calc_quantity_sum_imported.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_sum_imported.sysml rename to internal/exec/runtime/testdata/conformance/calc_quantity_sum_imported.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_sum_unimported.expected.json b/internal/exec/runtime/testdata/conformance/calc_quantity_sum_unimported.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_sum_unimported.expected.json rename to internal/exec/runtime/testdata/conformance/calc_quantity_sum_unimported.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_quantity_sum_unimported.sysml b/internal/exec/runtime/testdata/conformance/calc_quantity_sum_unimported.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_quantity_sum_unimported.sysml rename to internal/exec/runtime/testdata/conformance/calc_quantity_sum_unimported.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_beyond_nesting_bound.expected.json b/internal/exec/runtime/testdata/conformance/calc_recursion_beyond_nesting_bound.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_beyond_nesting_bound.expected.json rename to internal/exec/runtime/testdata/conformance/calc_recursion_beyond_nesting_bound.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_beyond_nesting_bound.sysml b/internal/exec/runtime/testdata/conformance/calc_recursion_beyond_nesting_bound.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_beyond_nesting_bound.sysml rename to internal/exec/runtime/testdata/conformance/calc_recursion_beyond_nesting_bound.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_descends_sequence.expected.json b/internal/exec/runtime/testdata/conformance/calc_recursion_descends_sequence.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_descends_sequence.expected.json rename to internal/exec/runtime/testdata/conformance/calc_recursion_descends_sequence.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_descends_sequence.sysml b/internal/exec/runtime/testdata/conformance/calc_recursion_descends_sequence.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_descends_sequence.sysml rename to internal/exec/runtime/testdata/conformance/calc_recursion_descends_sequence.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_factorial.expected.json b/internal/exec/runtime/testdata/conformance/calc_recursion_factorial.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_factorial.expected.json rename to internal/exec/runtime/testdata/conformance/calc_recursion_factorial.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_factorial.sysml b/internal/exec/runtime/testdata/conformance/calc_recursion_factorial.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_factorial.sysml rename to internal/exec/runtime/testdata/conformance/calc_recursion_factorial.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_fibonacci.expected.json b/internal/exec/runtime/testdata/conformance/calc_recursion_fibonacci.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_fibonacci.expected.json rename to internal/exec/runtime/testdata/conformance/calc_recursion_fibonacci.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_fibonacci.sysml b/internal/exec/runtime/testdata/conformance/calc_recursion_fibonacci.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_fibonacci.sysml rename to internal/exec/runtime/testdata/conformance/calc_recursion_fibonacci.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_implicit_result.expected.json b/internal/exec/runtime/testdata/conformance/calc_recursion_implicit_result.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_implicit_result.expected.json rename to internal/exec/runtime/testdata/conformance/calc_recursion_implicit_result.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_implicit_result.sysml b/internal/exec/runtime/testdata/conformance/calc_recursion_implicit_result.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_implicit_result.sysml rename to internal/exec/runtime/testdata/conformance/calc_recursion_implicit_result.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_mutual.expected.json b/internal/exec/runtime/testdata/conformance/calc_recursion_mutual.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_mutual.expected.json rename to internal/exec/runtime/testdata/conformance/calc_recursion_mutual.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_recursion_mutual.sysml b/internal/exec/runtime/testdata/conformance/calc_recursion_mutual.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursion_mutual.sysml rename to internal/exec/runtime/testdata/conformance/calc_recursion_mutual.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_recursive_base_case.expected.json b/internal/exec/runtime/testdata/conformance/calc_recursive_base_case.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursive_base_case.expected.json rename to internal/exec/runtime/testdata/conformance/calc_recursive_base_case.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_recursive_base_case.sysml b/internal/exec/runtime/testdata/conformance/calc_recursive_base_case.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursive_base_case.sysml rename to internal/exec/runtime/testdata/conformance/calc_recursive_base_case.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_recursive_base_case.trace.golden b/internal/exec/runtime/testdata/conformance/calc_recursive_base_case.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_recursive_base_case.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_recursive_base_case.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_redefined_parameter.expected.json b/internal/exec/runtime/testdata/conformance/calc_redefined_parameter.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_redefined_parameter.expected.json rename to internal/exec/runtime/testdata/conformance/calc_redefined_parameter.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_redefined_parameter.sysml b/internal/exec/runtime/testdata/conformance/calc_redefined_parameter.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_redefined_parameter.sysml rename to internal/exec/runtime/testdata/conformance/calc_redefined_parameter.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_redefined_parameter_optional.expected.json b/internal/exec/runtime/testdata/conformance/calc_redefined_parameter_optional.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_redefined_parameter_optional.expected.json rename to internal/exec/runtime/testdata/conformance/calc_redefined_parameter_optional.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_redefined_parameter_optional.sysml b/internal/exec/runtime/testdata/conformance/calc_redefined_parameter_optional.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_redefined_parameter_optional.sysml rename to internal/exec/runtime/testdata/conformance/calc_redefined_parameter_optional.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_redefined_parameter_symbol.expected.json b/internal/exec/runtime/testdata/conformance/calc_redefined_parameter_symbol.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_redefined_parameter_symbol.expected.json rename to internal/exec/runtime/testdata/conformance/calc_redefined_parameter_symbol.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_redefined_parameter_symbol.sysml b/internal/exec/runtime/testdata/conformance/calc_redefined_parameter_symbol.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_redefined_parameter_symbol.sysml rename to internal/exec/runtime/testdata/conformance/calc_redefined_parameter_symbol.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_return_parameter.expected.json b/internal/exec/runtime/testdata/conformance/calc_return_parameter.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_return_parameter.expected.json rename to internal/exec/runtime/testdata/conformance/calc_return_parameter.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_return_parameter.sysml b/internal/exec/runtime/testdata/conformance/calc_return_parameter.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_return_parameter.sysml rename to internal/exec/runtime/testdata/conformance/calc_return_parameter.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_return_parameter.trace.golden b/internal/exec/runtime/testdata/conformance/calc_return_parameter.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_return_parameter.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_return_parameter.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_rk4_lunar_descent.expected.json b/internal/exec/runtime/testdata/conformance/calc_rk4_lunar_descent.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_rk4_lunar_descent.expected.json rename to internal/exec/runtime/testdata/conformance/calc_rk4_lunar_descent.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_rk4_lunar_descent.sysml b/internal/exec/runtime/testdata/conformance/calc_rk4_lunar_descent.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_rk4_lunar_descent.sysml rename to internal/exec/runtime/testdata/conformance/calc_rk4_lunar_descent.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_sample_pair_arithmetic.expected.json b/internal/exec/runtime/testdata/conformance/calc_sample_pair_arithmetic.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sample_pair_arithmetic.expected.json rename to internal/exec/runtime/testdata/conformance/calc_sample_pair_arithmetic.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_sample_pair_arithmetic.sysml b/internal/exec/runtime/testdata/conformance/calc_sample_pair_arithmetic.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sample_pair_arithmetic.sysml rename to internal/exec/runtime/testdata/conformance/calc_sample_pair_arithmetic.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_select_over_sequence.expected.json b/internal/exec/runtime/testdata/conformance/calc_select_over_sequence.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_select_over_sequence.expected.json rename to internal/exec/runtime/testdata/conformance/calc_select_over_sequence.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_select_over_sequence.sysml b/internal/exec/runtime/testdata/conformance/calc_select_over_sequence.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_select_over_sequence.sysml rename to internal/exec/runtime/testdata/conformance/calc_select_over_sequence.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_select_predicate_not_boolean.expected.json b/internal/exec/runtime/testdata/conformance/calc_select_predicate_not_boolean.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_select_predicate_not_boolean.expected.json rename to internal/exec/runtime/testdata/conformance/calc_select_predicate_not_boolean.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_select_predicate_not_boolean.sysml b/internal/exec/runtime/testdata/conformance/calc_select_predicate_not_boolean.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_select_predicate_not_boolean.sysml rename to internal/exec/runtime/testdata/conformance/calc_select_predicate_not_boolean.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_expression_is_flat.expected.json b/internal/exec/runtime/testdata/conformance/calc_sequence_expression_is_flat.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_expression_is_flat.expected.json rename to internal/exec/runtime/testdata/conformance/calc_sequence_expression_is_flat.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_expression_is_flat.sysml b/internal/exec/runtime/testdata/conformance/calc_sequence_expression_is_flat.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_expression_is_flat.sysml rename to internal/exec/runtime/testdata/conformance/calc_sequence_expression_is_flat.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_including_at.expected.json b/internal/exec/runtime/testdata/conformance/calc_sequence_including_at.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_including_at.expected.json rename to internal/exec/runtime/testdata/conformance/calc_sequence_including_at.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_including_at.sysml b/internal/exec/runtime/testdata/conformance/calc_sequence_including_at.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_including_at.sysml rename to internal/exec/runtime/testdata/conformance/calc_sequence_including_at.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_including_at_appends.expected.json b/internal/exec/runtime/testdata/conformance/calc_sequence_including_at_appends.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_including_at_appends.expected.json rename to internal/exec/runtime/testdata/conformance/calc_sequence_including_at_appends.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_including_at_appends.sysml b/internal/exec/runtime/testdata/conformance/calc_sequence_including_at_appends.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_including_at_appends.sysml rename to internal/exec/runtime/testdata/conformance/calc_sequence_including_at_appends.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_including_at_out_of_range.expected.json b/internal/exec/runtime/testdata/conformance/calc_sequence_including_at_out_of_range.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_including_at_out_of_range.expected.json rename to internal/exec/runtime/testdata/conformance/calc_sequence_including_at_out_of_range.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_including_at_out_of_range.sysml b/internal/exec/runtime/testdata/conformance/calc_sequence_including_at_out_of_range.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_including_at_out_of_range.sysml rename to internal/exec/runtime/testdata/conformance/calc_sequence_including_at_out_of_range.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_index.expected.json b/internal/exec/runtime/testdata/conformance/calc_sequence_index.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_index.expected.json rename to internal/exec/runtime/testdata/conformance/calc_sequence_index.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_index.sysml b/internal/exec/runtime/testdata/conformance/calc_sequence_index.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_index.sysml rename to internal/exec/runtime/testdata/conformance/calc_sequence_index.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_index_and_quantity_form.expected.json b/internal/exec/runtime/testdata/conformance/calc_sequence_index_and_quantity_form.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_index_and_quantity_form.expected.json rename to internal/exec/runtime/testdata/conformance/calc_sequence_index_and_quantity_form.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_index_and_quantity_form.sysml b/internal/exec/runtime/testdata/conformance/calc_sequence_index_and_quantity_form.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_index_and_quantity_form.sysml rename to internal/exec/runtime/testdata/conformance/calc_sequence_index_and_quantity_form.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_index_non_integer.expected.json b/internal/exec/runtime/testdata/conformance/calc_sequence_index_non_integer.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_index_non_integer.expected.json rename to internal/exec/runtime/testdata/conformance/calc_sequence_index_non_integer.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_index_non_integer.sysml b/internal/exec/runtime/testdata/conformance/calc_sequence_index_non_integer.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_index_non_integer.sysml rename to internal/exec/runtime/testdata/conformance/calc_sequence_index_non_integer.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_index_out_of_range.expected.json b/internal/exec/runtime/testdata/conformance/calc_sequence_index_out_of_range.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_index_out_of_range.expected.json rename to internal/exec/runtime/testdata/conformance/calc_sequence_index_out_of_range.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_index_out_of_range.sysml b/internal/exec/runtime/testdata/conformance/calc_sequence_index_out_of_range.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_index_out_of_range.sysml rename to internal/exec/runtime/testdata/conformance/calc_sequence_index_out_of_range.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_index_zero.expected.json b/internal/exec/runtime/testdata/conformance/calc_sequence_index_zero.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_index_zero.expected.json rename to internal/exec/runtime/testdata/conformance/calc_sequence_index_zero.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_sequence_index_zero.sysml b/internal/exec/runtime/testdata/conformance/calc_sequence_index_zero.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_sequence_index_zero.sysml rename to internal/exec/runtime/testdata/conformance/calc_sequence_index_zero.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.expected.json b/internal/exec/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.expected.json rename to internal/exec/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.sysml b/internal/exec/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.sysml rename to internal/exec/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.trace.golden b/internal/exec/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_set_consumed_by_ordered_operations.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_simple_add.expected.json b/internal/exec/runtime/testdata/conformance/calc_simple_add.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_simple_add.expected.json rename to internal/exec/runtime/testdata/conformance/calc_simple_add.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_simple_add.sysml b/internal/exec/runtime/testdata/conformance/calc_simple_add.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_simple_add.sysml rename to internal/exec/runtime/testdata/conformance/calc_simple_add.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_simple_add.trace.golden b/internal/exec/runtime/testdata/conformance/calc_simple_add.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_simple_add.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_simple_add.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_singleton_member_binds_scalar.expected.json b/internal/exec/runtime/testdata/conformance/calc_singleton_member_binds_scalar.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_singleton_member_binds_scalar.expected.json rename to internal/exec/runtime/testdata/conformance/calc_singleton_member_binds_scalar.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_singleton_member_binds_scalar.sysml b/internal/exec/runtime/testdata/conformance/calc_singleton_member_binds_scalar.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_singleton_member_binds_scalar.sysml rename to internal/exec/runtime/testdata/conformance/calc_singleton_member_binds_scalar.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_two_valued_member_refused_scalar.expected.json b/internal/exec/runtime/testdata/conformance/calc_two_valued_member_refused_scalar.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_two_valued_member_refused_scalar.expected.json rename to internal/exec/runtime/testdata/conformance/calc_two_valued_member_refused_scalar.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_two_valued_member_refused_scalar.sysml b/internal/exec/runtime/testdata/conformance/calc_two_valued_member_refused_scalar.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_two_valued_member_refused_scalar.sysml rename to internal/exec/runtime/testdata/conformance/calc_two_valued_member_refused_scalar.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_type_coercion.expected.json b/internal/exec/runtime/testdata/conformance/calc_type_coercion.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_type_coercion.expected.json rename to internal/exec/runtime/testdata/conformance/calc_type_coercion.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_type_coercion.sysml b/internal/exec/runtime/testdata/conformance/calc_type_coercion.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_type_coercion.sysml rename to internal/exec/runtime/testdata/conformance/calc_type_coercion.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_type_coercion.trace.golden b/internal/exec/runtime/testdata/conformance/calc_type_coercion.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_type_coercion.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_type_coercion.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_unary_operators.expected.json b/internal/exec/runtime/testdata/conformance/calc_unary_operators.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_unary_operators.expected.json rename to internal/exec/runtime/testdata/conformance/calc_unary_operators.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_unary_operators.sysml b/internal/exec/runtime/testdata/conformance/calc_unary_operators.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_unary_operators.sysml rename to internal/exec/runtime/testdata/conformance/calc_unary_operators.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_unary_operators.trace.golden b/internal/exec/runtime/testdata/conformance/calc_unary_operators.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_unary_operators.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_unary_operators.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_unique_ordered_keeps_order.expected.json b/internal/exec/runtime/testdata/conformance/calc_unique_ordered_keeps_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_unique_ordered_keeps_order.expected.json rename to internal/exec/runtime/testdata/conformance/calc_unique_ordered_keeps_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_unique_ordered_keeps_order.sysml b/internal/exec/runtime/testdata/conformance/calc_unique_ordered_keeps_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_unique_ordered_keeps_order.sysml rename to internal/exec/runtime/testdata/conformance/calc_unique_ordered_keeps_order.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_unique_ordered_keeps_order.trace.golden b/internal/exec/runtime/testdata/conformance/calc_unique_ordered_keeps_order.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_unique_ordered_keeps_order.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_unique_ordered_keeps_order.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_usage_inherited_parameters.expected.json b/internal/exec/runtime/testdata/conformance/calc_usage_inherited_parameters.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_inherited_parameters.expected.json rename to internal/exec/runtime/testdata/conformance/calc_usage_inherited_parameters.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_usage_inherited_parameters.sysml b/internal/exec/runtime/testdata/conformance/calc_usage_inherited_parameters.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_inherited_parameters.sysml rename to internal/exec/runtime/testdata/conformance/calc_usage_inherited_parameters.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_usage_inherited_parameters.trace.golden b/internal/exec/runtime/testdata/conformance/calc_usage_inherited_parameters.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_inherited_parameters.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_usage_inherited_parameters.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_usage_instance_slots.expected.json b/internal/exec/runtime/testdata/conformance/calc_usage_instance_slots.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_instance_slots.expected.json rename to internal/exec/runtime/testdata/conformance/calc_usage_instance_slots.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_usage_instance_slots.sysml b/internal/exec/runtime/testdata/conformance/calc_usage_instance_slots.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_instance_slots.sysml rename to internal/exec/runtime/testdata/conformance/calc_usage_instance_slots.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_usage_multiple_outputs.expected.json b/internal/exec/runtime/testdata/conformance/calc_usage_multiple_outputs.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_multiple_outputs.expected.json rename to internal/exec/runtime/testdata/conformance/calc_usage_multiple_outputs.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_usage_multiple_outputs.sysml b/internal/exec/runtime/testdata/conformance/calc_usage_multiple_outputs.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_multiple_outputs.sysml rename to internal/exec/runtime/testdata/conformance/calc_usage_multiple_outputs.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_usage_multiple_outputs.trace.golden b/internal/exec/runtime/testdata/conformance/calc_usage_multiple_outputs.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_multiple_outputs.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_usage_multiple_outputs.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_usage_nested_in_calc.expected.json b/internal/exec/runtime/testdata/conformance/calc_usage_nested_in_calc.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_nested_in_calc.expected.json rename to internal/exec/runtime/testdata/conformance/calc_usage_nested_in_calc.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_usage_nested_in_calc.sysml b/internal/exec/runtime/testdata/conformance/calc_usage_nested_in_calc.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_nested_in_calc.sysml rename to internal/exec/runtime/testdata/conformance/calc_usage_nested_in_calc.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_usage_nested_in_calc.trace.golden b/internal/exec/runtime/testdata/conformance/calc_usage_nested_in_calc.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_nested_in_calc.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_usage_nested_in_calc.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_usage_nested_shadowed_input.expected.json b/internal/exec/runtime/testdata/conformance/calc_usage_nested_shadowed_input.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_nested_shadowed_input.expected.json rename to internal/exec/runtime/testdata/conformance/calc_usage_nested_shadowed_input.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_usage_nested_shadowed_input.sysml b/internal/exec/runtime/testdata/conformance/calc_usage_nested_shadowed_input.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_nested_shadowed_input.sysml rename to internal/exec/runtime/testdata/conformance/calc_usage_nested_shadowed_input.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_usage_nested_shadowed_input.trace.golden b/internal/exec/runtime/testdata/conformance/calc_usage_nested_shadowed_input.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_nested_shadowed_input.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_usage_nested_shadowed_input.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_usage_outputs_one_binding.expected.json b/internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_outputs_one_binding.expected.json rename to internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_usage_outputs_one_binding.sysml b/internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_outputs_one_binding.sysml rename to internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_usage_outputs_one_binding.trace.golden b/internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_outputs_one_binding.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_usage_outputs_one_binding.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_usage_single_output.expected.json b/internal/exec/runtime/testdata/conformance/calc_usage_single_output.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_single_output.expected.json rename to internal/exec/runtime/testdata/conformance/calc_usage_single_output.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_usage_single_output.sysml b/internal/exec/runtime/testdata/conformance/calc_usage_single_output.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_single_output.sysml rename to internal/exec/runtime/testdata/conformance/calc_usage_single_output.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_usage_single_output.trace.golden b/internal/exec/runtime/testdata/conformance/calc_usage_single_output.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_single_output.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_usage_single_output.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_usage_statement_body.expected.json b/internal/exec/runtime/testdata/conformance/calc_usage_statement_body.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_statement_body.expected.json rename to internal/exec/runtime/testdata/conformance/calc_usage_statement_body.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_usage_statement_body.sysml b/internal/exec/runtime/testdata/conformance/calc_usage_statement_body.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_statement_body.sysml rename to internal/exec/runtime/testdata/conformance/calc_usage_statement_body.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_usage_statement_body.trace.golden b/internal/exec/runtime/testdata/conformance/calc_usage_statement_body.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_usage_statement_body.trace.golden rename to internal/exec/runtime/testdata/conformance/calc_usage_statement_body.trace.golden diff --git a/internal/core/runtime/testdata/conformance/calc_valued_output_with_return.expected.json b/internal/exec/runtime/testdata/conformance/calc_valued_output_with_return.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_valued_output_with_return.expected.json rename to internal/exec/runtime/testdata/conformance/calc_valued_output_with_return.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_valued_output_with_return.sysml b/internal/exec/runtime/testdata/conformance/calc_valued_output_with_return.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_valued_output_with_return.sysml rename to internal/exec/runtime/testdata/conformance/calc_valued_output_with_return.sysml diff --git a/internal/core/runtime/testdata/conformance/calc_vector_elements_chain.expected.json b/internal/exec/runtime/testdata/conformance/calc_vector_elements_chain.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_vector_elements_chain.expected.json rename to internal/exec/runtime/testdata/conformance/calc_vector_elements_chain.expected.json diff --git a/internal/core/runtime/testdata/conformance/calc_vector_elements_chain.sysml b/internal/exec/runtime/testdata/conformance/calc_vector_elements_chain.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/calc_vector_elements_chain.sysml rename to internal/exec/runtime/testdata/conformance/calc_vector_elements_chain.sysml diff --git a/internal/core/runtime/testdata/conformance/clock_action_accept_after.expected.json b/internal/exec/runtime/testdata/conformance/clock_action_accept_after.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_accept_after.expected.json rename to internal/exec/runtime/testdata/conformance/clock_action_accept_after.expected.json diff --git a/internal/core/runtime/testdata/conformance/clock_action_accept_after.sysml b/internal/exec/runtime/testdata/conformance/clock_action_accept_after.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_accept_after.sysml rename to internal/exec/runtime/testdata/conformance/clock_action_accept_after.sysml diff --git a/internal/core/runtime/testdata/conformance/clock_action_accept_after.trace.golden b/internal/exec/runtime/testdata/conformance/clock_action_accept_after.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_accept_after.trace.golden rename to internal/exec/runtime/testdata/conformance/clock_action_accept_after.trace.golden diff --git a/internal/core/runtime/testdata/conformance/clock_action_accept_at.expected.json b/internal/exec/runtime/testdata/conformance/clock_action_accept_at.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_accept_at.expected.json rename to internal/exec/runtime/testdata/conformance/clock_action_accept_at.expected.json diff --git a/internal/core/runtime/testdata/conformance/clock_action_accept_at.sysml b/internal/exec/runtime/testdata/conformance/clock_action_accept_at.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_accept_at.sysml rename to internal/exec/runtime/testdata/conformance/clock_action_accept_at.sysml diff --git a/internal/core/runtime/testdata/conformance/clock_action_accept_at.trace.golden b/internal/exec/runtime/testdata/conformance/clock_action_accept_at.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_accept_at.trace.golden rename to internal/exec/runtime/testdata/conformance/clock_action_accept_at.trace.golden diff --git a/internal/core/runtime/testdata/conformance/clock_action_signal_delayed_state.expected.json b/internal/exec/runtime/testdata/conformance/clock_action_signal_delayed_state.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_signal_delayed_state.expected.json rename to internal/exec/runtime/testdata/conformance/clock_action_signal_delayed_state.expected.json diff --git a/internal/core/runtime/testdata/conformance/clock_action_signal_delayed_state.sysml b/internal/exec/runtime/testdata/conformance/clock_action_signal_delayed_state.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_signal_delayed_state.sysml rename to internal/exec/runtime/testdata/conformance/clock_action_signal_delayed_state.sysml diff --git a/internal/core/runtime/testdata/conformance/clock_action_signal_delayed_state.trace.golden b/internal/exec/runtime/testdata/conformance/clock_action_signal_delayed_state.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_signal_delayed_state.trace.golden rename to internal/exec/runtime/testdata/conformance/clock_action_signal_delayed_state.trace.golden diff --git a/internal/core/runtime/testdata/conformance/clock_action_state_due_together.check.expected.json b/internal/exec/runtime/testdata/conformance/clock_action_state_due_together.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_state_due_together.check.expected.json rename to internal/exec/runtime/testdata/conformance/clock_action_state_due_together.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/clock_action_state_due_together.declared.trace.golden b/internal/exec/runtime/testdata/conformance/clock_action_state_due_together.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_state_due_together.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/clock_action_state_due_together.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/clock_action_state_due_together.expected.json b/internal/exec/runtime/testdata/conformance/clock_action_state_due_together.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_state_due_together.expected.json rename to internal/exec/runtime/testdata/conformance/clock_action_state_due_together.expected.json diff --git a/internal/core/runtime/testdata/conformance/clock_action_state_due_together.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/clock_action_state_due_together.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_state_due_together.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/clock_action_state_due_together.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/clock_action_state_due_together.sysml b/internal/exec/runtime/testdata/conformance/clock_action_state_due_together.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_state_due_together.sysml rename to internal/exec/runtime/testdata/conformance/clock_action_state_due_together.sysml diff --git a/internal/core/runtime/testdata/conformance/clock_action_state_due_together.trace.golden b/internal/exec/runtime/testdata/conformance/clock_action_state_due_together.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_action_state_due_together.trace.golden rename to internal/exec/runtime/testdata/conformance/clock_action_state_due_together.trace.golden diff --git a/internal/core/runtime/testdata/conformance/clock_do_action_while_token_waits.expected.json b/internal/exec/runtime/testdata/conformance/clock_do_action_while_token_waits.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_do_action_while_token_waits.expected.json rename to internal/exec/runtime/testdata/conformance/clock_do_action_while_token_waits.expected.json diff --git a/internal/core/runtime/testdata/conformance/clock_do_action_while_token_waits.sysml b/internal/exec/runtime/testdata/conformance/clock_do_action_while_token_waits.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_do_action_while_token_waits.sysml rename to internal/exec/runtime/testdata/conformance/clock_do_action_while_token_waits.sysml diff --git a/internal/core/runtime/testdata/conformance/clock_do_action_while_token_waits.trace.golden b/internal/exec/runtime/testdata/conformance/clock_do_action_while_token_waits.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_do_action_while_token_waits.trace.golden rename to internal/exec/runtime/testdata/conformance/clock_do_action_while_token_waits.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/clock_nested_fork_join_waits.expected.json b/internal/exec/runtime/testdata/conformance/clock_nested_fork_join_waits.expected.json new file mode 100644 index 0000000000..e66df22930 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_nested_fork_join_waits.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "evaluate": "test::Caller", + "outputs": { + "total": {"type": "Integer", "value": 111}, + "legs.reached": {"type": "Integer", "value": 11} + } +} diff --git a/internal/exec/runtime/testdata/conformance/clock_nested_fork_join_waits.sysml b/internal/exec/runtime/testdata/conformance/clock_nested_fork_join_waits.sysml new file mode 100644 index 0000000000..c3f79b94c4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_nested_fork_join_waits.sysml @@ -0,0 +1,37 @@ +// A performed action whose fork branches wait on the clock for unequal delays: +// the branch done first is held at the join while the other still waits, and +// the caller advances the clock past that wait rather than resuming the callee +// on a join it cannot pass. The join fires at t=2, the caller's own wait at t=3. +package test { + private import SI::*; + private import ScalarValues::*; + + action def Legs { + out reached : Integer = 0; + first start then f; + fork f; + first f then a; + first f then b; + action a accept after 1 [s]; + first a then a2; + action a2 assign reached := reached + 1; + first a2 then j; + action b accept after 2 [s]; + first b then b2; + action b2 assign reached := reached + 10; + first b2 then j; + join j; + first j then done; + } + + action def Caller { + out total : Integer = 0; + first start then legs; + action legs : Legs; + first legs then tail; + action tail accept after 1 [s]; + first tail then sum; + action sum assign total := legs.reached + 100; + first sum then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/clock_nested_fork_join_waits.trace.golden b/internal/exec/runtime/testdata/conformance/clock_nested_fork_join_waits.trace.golden new file mode 100644 index 0000000000..51fa7bd16c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_nested_fork_join_waits.trace.golden @@ -0,0 +1,33 @@ +step 1: token 1@legs +eval literal 0 -> 0 +step 1: token 1@f +step 2: token 2@a, token 3@b + eval literal 2 -> 2 +eval index -> 2 [s] + eval literal 1 -> 1 +eval index -> 1 [s] +step 3: token 2@a2, token 3@b +stmt assign reached + eval feature reached -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +step 4: token 2@j, token 3@b +step 5: token 2@j, token 3@b +step 6: token 2@j, token 3@b2 +stmt assign reached + eval feature reached -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +step 7: token 2@j, token 3@j +step 8: token 4@done +step 9: no active tokens +step 2: token 1@tail + eval literal 1 -> 1 +eval index -> 1 [s] +step 3: token 1@sum +stmt assign total + eval chain reached -> 11 + eval literal 100 -> 100 + eval operator + -> 111 +step 4: token 1@done +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/clock_read_local_clock_elapsed.expected.json b/internal/exec/runtime/testdata/conformance/clock_read_local_clock_elapsed.expected.json new file mode 100644 index 0000000000..de3c6060c1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_read_local_clock_elapsed.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::supervisor", + "outputs": { + "started": {"type": "Real", "value": 1.0}, + "elapsed": {"type": "Real", "value": 2.5}, + "now": {"type": "Real", "value": 3.5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/clock_read_local_clock_elapsed.sysml b/internal/exec/runtime/testdata/conformance/clock_read_local_clock_elapsed.sysml new file mode 100644 index 0000000000..832fd46225 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_read_local_clock_elapsed.sysml @@ -0,0 +1,43 @@ +// A performed action reads the clock the run advances through the performer's +// `localClock.currentTime`: the Kernel gives every occurrence a localClock that +// defaults to the universal clock, whose currentTime is the shared clock in +// seconds. Stamping it before and after a timed wait leaves the elapsed time in +// the part, and a later read of the clock sees the same instant. +package test { + private import ScalarValues::*; + + part def Timer { + attribute started : Real = 0.0; + attribute elapsed : Real = 0.0; + + action measure { + action stamp { + assign started := localClock.currentTime; + } + action wait accept after 2.5 [SI::s]; + action finish { + assign elapsed := localClock.currentTime - started; + } + first stamp then wait; + first wait then finish; + } + } + + part timer : Timer; + + action supervisor { + attribute started : Real = -1.0; + attribute elapsed : Real = -1.0; + attribute now : Real = -1.0; + + first start; + then action delay accept after 1.0 [SI::s]; + then perform action run ::> timer.measure; + then action read { + assign started := timer.started; + assign elapsed := timer.elapsed; + assign now := localClock.currentTime; + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/clock_read_redefined_current_time.expected.json b/internal/exec/runtime/testdata/conformance/clock_read_redefined_current_time.expected.json new file mode 100644 index 0000000000..a51197e3ef --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_read_redefined_current_time.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::supervisor", + "outputs": { + "before": {"type": "Real", "value": 0.0}, + "later": {"type": "Real", "value": 1.5}, + "stamped": {"type": "Real", "value": 1.5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/clock_read_redefined_current_time.sysml b/internal/exec/runtime/testdata/conformance/clock_read_redefined_current_time.sysml new file mode 100644 index 0000000000..5aba5713c8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_read_redefined_current_time.sysml @@ -0,0 +1,41 @@ +// A redefinition of a Clock's currentTime is the same feature under another +// name: reading it, bare in the clock's own body or through a part holding +// the clock, is the run's shared clock, and it advances with the timed waits +// of the run rather than holding a stored value. +package test { + private import ScalarValues::*; + private import Clocks::*; + + part def Chrono :> Clock { + attribute now :>> currentTime; + attribute stamped : Real = -1.0; + + action stamp { + action mark { + assign stamped := now; + } + first start then mark; + first mark then done; + } + } + + part chrono : Chrono; + + action supervisor { + attribute before : Real = -1.0; + attribute later : Real = -1.0; + attribute stamped : Real = -1.0; + + first start; + then action read1 { + assign before := chrono.now; + } + then action delay accept after 1.5 [SI::s]; + then perform action run ::> chrono.stamp; + then action read2 { + assign later := chrono.now; + assign stamped := chrono.stamped; + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/clock_read_time_clock_instant.expected.json b/internal/exec/runtime/testdata/conformance/clock_read_time_clock_instant.expected.json new file mode 100644 index 0000000000..9044b8f5ad --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_read_time_clock_instant.expected.json @@ -0,0 +1,11 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::stopwatch", + "outputs": { + "started": {"type": "Quantity", "value": 0.0, "unit": "s"}, + "due": {"type": "Quantity", "value": 5.0, "unit": "s"}, + "elapsed": {"type": "Quantity", "value": 5.0, "unit": "s"}, + "later": {"type": "Boolean", "value": true} + } +} diff --git a/internal/exec/runtime/testdata/conformance/clock_read_time_clock_instant.sysml b/internal/exec/runtime/testdata/conformance/clock_read_time_clock_instant.sysml new file mode 100644 index 0000000000..ccd8d94673 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_read_time_clock_instant.sysml @@ -0,0 +1,28 @@ +// The Time library's universal clock reads the shared clock as a +// TimeInstantValue: a point on the clock's own time scale, whose magnitude is +// the seconds since the run began — the instant `accept at` waits for. The read +// stores into a TimeInstantValue, two reads differ by a DurationValue, an +// instant derived from one is waited for, and two instants order. +package test { + private import SI::*; + private import Time::*; + private import ScalarValues::*; + + action stopwatch { + attribute started : TimeInstantValue; + attribute due : TimeInstantValue; + attribute elapsed : DurationValue; + attribute later : Boolean = false; + + first start; + then action stamp assign started := Time::universalClock.currentTime; + then action wait accept after 2 [s]; + then action schedule assign due := Time::universalClock.currentTime + 3 [s]; + then action hold accept at due; + then action measure { + assign elapsed := Time::universalClock.currentTime - started; + assign later := Time::universalClock.currentTime > started; + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/clock_step_waits_come_due_on_ticks.expected.json b/internal/exec/runtime/testdata/conformance/clock_step_waits_come_due_on_ticks.expected.json new file mode 100644 index 0000000000..b961b06115 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_step_waits_come_due_on_ticks.expected.json @@ -0,0 +1,12 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Stepped", + "clockStep": 1.0, + "trace": true, + "outputs": { + "t1": {"type": "Real", "value": 3.0}, + "t2": {"type": "Real", "value": 4.0}, + "t3": {"type": "Real", "value": 6.0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/clock_step_waits_come_due_on_ticks.sysml b/internal/exec/runtime/testdata/conformance/clock_step_waits_come_due_on_ticks.sysml new file mode 100644 index 0000000000..539ff3cfd2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_step_waits_come_due_on_ticks.sysml @@ -0,0 +1,21 @@ +// Under a clock stepping by 1.0 s (the case pins "clockStep": 1.0), a wait comes +// due at the first tick not before it ends: 2.3 s from 0 is read at 3.0, a +// further 0.4 s at 4.0 (not 3.4), and a wait ending on a tick stays there. On a +// continuous clock the same run would read 2.3, 2.7 and 4.7. +package test { + private import ScalarValues::*; + private import SI::*; + + action def Stepped { + out t1 : Real; + out t2 : Real; + out t3 : Real; + + action w1 accept after 2.3 [s]; + then action r1 { assign t1 := localClock.currentTime; } + then action w2 accept after 0.4 [s]; + then action r2 { assign t2 := localClock.currentTime; } + then action w3 accept after 2.0 [s]; + then action r3 { assign t3 := localClock.currentTime; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/clock_step_waits_come_due_on_ticks.trace.golden b/internal/exec/runtime/testdata/conformance/clock_step_waits_come_due_on_ticks.trace.golden new file mode 100644 index 0000000000..543b5729dc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/clock_step_waits_come_due_on_ticks.trace.golden @@ -0,0 +1,25 @@ + eval literal 2.3 -> 2.3 +eval index -> 2.3 [s] +step 1: token 1@r1 +stmt assign t1 +materialize: universalClock #1 + eval feature universalClock -> instance#1 + eval feature localClock -> instance#1 + eval chain currentTime -> 3.0 +step 2: token 1@w2 + eval literal 0.4 -> 0.4 +eval index -> 0.4 [s] +step 3: token 1@r2 +stmt assign t2 + eval feature universalClock -> instance#1 + eval feature localClock -> instance#1 + eval chain currentTime -> 4.0 +step 4: token 1@w3 + eval literal 2.0 -> 2.0 +eval index -> 2.0 [s] +step 5: token 1@r3 +stmt assign t3 + eval feature universalClock -> instance#1 + eval feature localClock -> instance#1 + eval chain currentTime -> 6.0 +step 6: no active tokens diff --git a/internal/core/runtime/testdata/conformance/clock_two_machines_share_clock.expected.json b/internal/exec/runtime/testdata/conformance/clock_two_machines_share_clock.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_two_machines_share_clock.expected.json rename to internal/exec/runtime/testdata/conformance/clock_two_machines_share_clock.expected.json diff --git a/internal/core/runtime/testdata/conformance/clock_two_machines_share_clock.sysml b/internal/exec/runtime/testdata/conformance/clock_two_machines_share_clock.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_two_machines_share_clock.sysml rename to internal/exec/runtime/testdata/conformance/clock_two_machines_share_clock.sysml diff --git a/internal/core/runtime/testdata/conformance/clock_two_machines_share_clock.trace.golden b/internal/exec/runtime/testdata/conformance/clock_two_machines_share_clock.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/clock_two_machines_share_clock.trace.golden rename to internal/exec/runtime/testdata/conformance/clock_two_machines_share_clock.trace.golden diff --git a/internal/core/runtime/testdata/conformance/connector_end_identity.expected.json b/internal/exec/runtime/testdata/conformance/connector_end_identity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/connector_end_identity.expected.json rename to internal/exec/runtime/testdata/conformance/connector_end_identity.expected.json diff --git a/internal/core/runtime/testdata/conformance/connector_end_identity.sysml b/internal/exec/runtime/testdata/conformance/connector_end_identity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/connector_end_identity.sysml rename to internal/exec/runtime/testdata/conformance/connector_end_identity.sysml diff --git a/internal/exec/runtime/testdata/conformance/connector_object_binding_flow.expected.json b/internal/exec/runtime/testdata/conformance/connector_object_binding_flow.expected.json new file mode 100644 index 0000000000..39d3d0f216 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/connector_object_binding_flow.expected.json @@ -0,0 +1,14 @@ +{ + "type": "instance", + "instantiate": "test::Sys", + "slots": { + "bnd": {"type": "Instance"}, + "f": {"type": "Instance"} + }, + "identical": [ + ["bnd.source", "a.p"], + ["bnd.target", "b.q"], + ["f.source", "a.p"], + ["f.target", "b.q"] + ] +} diff --git a/internal/exec/runtime/testdata/conformance/connector_object_binding_flow.sysml b/internal/exec/runtime/testdata/conformance/connector_object_binding_flow.sysml new file mode 100644 index 0000000000..9ef758d2ca --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/connector_object_binding_flow.sysml @@ -0,0 +1,14 @@ +package test { + port def P; + part def A { port p : P; } + part def B { port q : P; } + + part def Sys { + part a : A; + part b : B; + binding bnd bind a.p = b.q; + flow f from a.p to b.q; + } + + part sys : Sys; +} diff --git a/internal/core/runtime/testdata/conformance/constraint_assume.expected.json b/internal/exec/runtime/testdata/conformance/constraint_assume.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_assume.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_assume.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_assume.sysml b/internal/exec/runtime/testdata/conformance/constraint_assume.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_assume.sysml rename to internal/exec/runtime/testdata/conformance/constraint_assume.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_assume.trace.golden b/internal/exec/runtime/testdata/conformance/constraint_assume.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_assume.trace.golden rename to internal/exec/runtime/testdata/conformance/constraint_assume.trace.golden diff --git a/internal/core/runtime/testdata/conformance/constraint_body_local_count.expected.json b/internal/exec/runtime/testdata/conformance/constraint_body_local_count.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_body_local_count.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_body_local_count.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_body_local_count.sysml b/internal/exec/runtime/testdata/conformance/constraint_body_local_count.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_body_local_count.sysml rename to internal/exec/runtime/testdata/conformance/constraint_body_local_count.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_body_local_declared_type.expected.json b/internal/exec/runtime/testdata/conformance/constraint_body_local_declared_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_body_local_declared_type.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_body_local_declared_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_body_local_declared_type.sysml b/internal/exec/runtime/testdata/conformance/constraint_body_local_declared_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_body_local_declared_type.sysml rename to internal/exec/runtime/testdata/conformance/constraint_body_local_declared_type.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_body_local_redefined_count.expected.json b/internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_count.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_body_local_redefined_count.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_count.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_body_local_redefined_count.sysml b/internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_count.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_body_local_redefined_count.sysml rename to internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_count.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_body_local_redefined_type.expected.json b/internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_body_local_redefined_type.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_body_local_redefined_type.sysml b/internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_body_local_redefined_type.sysml rename to internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_type.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_body_local_redefined_unique.expected.json b/internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_unique.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_body_local_redefined_unique.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_unique.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_body_local_redefined_unique.sysml b/internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_unique.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_body_local_redefined_unique.sysml rename to internal/exec/runtime/testdata/conformance/constraint_body_local_redefined_unique.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_library_function.expected.json b/internal/exec/runtime/testdata/conformance/constraint_library_function.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_library_function.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_library_function.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_library_function.sysml b/internal/exec/runtime/testdata/conformance/constraint_library_function.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_library_function.sysml rename to internal/exec/runtime/testdata/conformance/constraint_library_function.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_library_function.trace.golden b/internal/exec/runtime/testdata/conformance/constraint_library_function.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_library_function.trace.golden rename to internal/exec/runtime/testdata/conformance/constraint_library_function.trace.golden diff --git a/internal/core/runtime/testdata/conformance/constraint_literal.expected.json b/internal/exec/runtime/testdata/conformance/constraint_literal.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_literal.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_literal.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_literal.sysml b/internal/exec/runtime/testdata/conformance/constraint_literal.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_literal.sysml rename to internal/exec/runtime/testdata/conformance/constraint_literal.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_literal.trace.golden b/internal/exec/runtime/testdata/conformance/constraint_literal.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_literal.trace.golden rename to internal/exec/runtime/testdata/conformance/constraint_literal.trace.golden diff --git a/internal/core/runtime/testdata/conformance/constraint_negated_group.expected.json b/internal/exec/runtime/testdata/conformance/constraint_negated_group.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_negated_group.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_negated_group.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_negated_group.sysml b/internal/exec/runtime/testdata/conformance/constraint_negated_group.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_negated_group.sysml rename to internal/exec/runtime/testdata/conformance/constraint_negated_group.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_negation.expected.json b/internal/exec/runtime/testdata/conformance/constraint_negation.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_negation.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_negation.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_negation.sysml b/internal/exec/runtime/testdata/conformance/constraint_negation.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_negation.sysml rename to internal/exec/runtime/testdata/conformance/constraint_negation.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_negation.trace.golden b/internal/exec/runtime/testdata/conformance/constraint_negation.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_negation.trace.golden rename to internal/exec/runtime/testdata/conformance/constraint_negation.trace.golden diff --git a/internal/core/runtime/testdata/conformance/constraint_param_scalar_count.expected.json b/internal/exec/runtime/testdata/conformance/constraint_param_scalar_count.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_param_scalar_count.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_param_scalar_count.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_param_scalar_count.sysml b/internal/exec/runtime/testdata/conformance/constraint_param_scalar_count.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_param_scalar_count.sysml rename to internal/exec/runtime/testdata/conformance/constraint_param_scalar_count.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_quantity_against_zero.expected.json b/internal/exec/runtime/testdata/conformance/constraint_quantity_against_zero.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_quantity_against_zero.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_quantity_against_zero.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_quantity_against_zero.sysml b/internal/exec/runtime/testdata/conformance/constraint_quantity_against_zero.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_quantity_against_zero.sysml rename to internal/exec/runtime/testdata/conformance/constraint_quantity_against_zero.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_quantity_quotient.expected.json b/internal/exec/runtime/testdata/conformance/constraint_quantity_quotient.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_quantity_quotient.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_quantity_quotient.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_quantity_quotient.sysml b/internal/exec/runtime/testdata/conformance/constraint_quantity_quotient.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_quantity_quotient.sysml rename to internal/exec/runtime/testdata/conformance/constraint_quantity_quotient.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_quantity_sum.expected.json b/internal/exec/runtime/testdata/conformance/constraint_quantity_sum.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_quantity_sum.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_quantity_sum.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_quantity_sum.sysml b/internal/exec/runtime/testdata/conformance/constraint_quantity_sum.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_quantity_sum.sysml rename to internal/exec/runtime/testdata/conformance/constraint_quantity_sum.sysml diff --git a/internal/core/runtime/testdata/conformance/constraint_rational_quotient.expected.json b/internal/exec/runtime/testdata/conformance/constraint_rational_quotient.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_rational_quotient.expected.json rename to internal/exec/runtime/testdata/conformance/constraint_rational_quotient.expected.json diff --git a/internal/core/runtime/testdata/conformance/constraint_rational_quotient.sysml b/internal/exec/runtime/testdata/conformance/constraint_rational_quotient.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/constraint_rational_quotient.sysml rename to internal/exec/runtime/testdata/conformance/constraint_rational_quotient.sysml diff --git a/internal/core/runtime/testdata/conformance/cubesat_mass_rollup.expected.json b/internal/exec/runtime/testdata/conformance/cubesat_mass_rollup.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/cubesat_mass_rollup.expected.json rename to internal/exec/runtime/testdata/conformance/cubesat_mass_rollup.expected.json diff --git a/internal/core/runtime/testdata/conformance/cubesat_mass_rollup.sysml b/internal/exec/runtime/testdata/conformance/cubesat_mass_rollup.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/cubesat_mass_rollup.sysml rename to internal/exec/runtime/testdata/conformance/cubesat_mass_rollup.sysml diff --git a/internal/core/runtime/testdata/conformance/derived_value_follows_assignment.expected.json b/internal/exec/runtime/testdata/conformance/derived_value_follows_assignment.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/derived_value_follows_assignment.expected.json rename to internal/exec/runtime/testdata/conformance/derived_value_follows_assignment.expected.json diff --git a/internal/core/runtime/testdata/conformance/derived_value_follows_assignment.sysml b/internal/exec/runtime/testdata/conformance/derived_value_follows_assignment.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/derived_value_follows_assignment.sysml rename to internal/exec/runtime/testdata/conformance/derived_value_follows_assignment.sysml diff --git a/internal/core/runtime/testdata/conformance/enum_literal_default_slot.expected.json b/internal/exec/runtime/testdata/conformance/enum_literal_default_slot.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/enum_literal_default_slot.expected.json rename to internal/exec/runtime/testdata/conformance/enum_literal_default_slot.expected.json diff --git a/internal/core/runtime/testdata/conformance/enum_literal_default_slot.sysml b/internal/exec/runtime/testdata/conformance/enum_literal_default_slot.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/enum_literal_default_slot.sysml rename to internal/exec/runtime/testdata/conformance/enum_literal_default_slot.sysml diff --git a/internal/core/runtime/testdata/conformance/enum_literal_own_attributes.expected.json b/internal/exec/runtime/testdata/conformance/enum_literal_own_attributes.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/enum_literal_own_attributes.expected.json rename to internal/exec/runtime/testdata/conformance/enum_literal_own_attributes.expected.json diff --git a/internal/core/runtime/testdata/conformance/enum_literal_own_attributes.sysml b/internal/exec/runtime/testdata/conformance/enum_literal_own_attributes.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/enum_literal_own_attributes.sysml rename to internal/exec/runtime/testdata/conformance/enum_literal_own_attributes.sysml diff --git a/internal/core/runtime/testdata/conformance/enum_literal_scalar_valued.expected.json b/internal/exec/runtime/testdata/conformance/enum_literal_scalar_valued.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/enum_literal_scalar_valued.expected.json rename to internal/exec/runtime/testdata/conformance/enum_literal_scalar_valued.expected.json diff --git a/internal/core/runtime/testdata/conformance/enum_literal_scalar_valued.sysml b/internal/exec/runtime/testdata/conformance/enum_literal_scalar_valued.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/enum_literal_scalar_valued.sysml rename to internal/exec/runtime/testdata/conformance/enum_literal_scalar_valued.sysml diff --git a/internal/core/runtime/testdata/conformance/enumeration_value_classification.expected.json b/internal/exec/runtime/testdata/conformance/enumeration_value_classification.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/enumeration_value_classification.expected.json rename to internal/exec/runtime/testdata/conformance/enumeration_value_classification.expected.json diff --git a/internal/core/runtime/testdata/conformance/enumeration_value_classification.sysml b/internal/exec/runtime/testdata/conformance/enumeration_value_classification.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/enumeration_value_classification.sysml rename to internal/exec/runtime/testdata/conformance/enumeration_value_classification.sysml diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.expected.json rename to internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.expected.json diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.sysml rename to internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.sysml diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden b/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden similarity index 82% rename from internal/core/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden rename to internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden index 86f518bddf..aee31851f5 100644 --- a/internal/core/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden +++ b/internal/exec/runtime/testdata/conformance/exhibited_state_guard_reads_attribute.trace.golden @@ -7,9 +7,6 @@ stmt action body eval feature attempts -> 0 eval literal 1 -> 1 eval operator + -> 1 - eval feature attempts -> 1 - eval literal 0 -> 0 -eval operator > -> true run: exhibited state machine modes of #1 eval feature attempts -> 1 eval literal 0 -> 0 @@ -26,9 +23,6 @@ stmt action body eval operator + -> 1 eval feature attempts -> 1 eval literal 0 -> 0 -eval operator > -> true - eval feature attempts -> 1 - eval literal 0 -> 0 eval operator > -> true exit: armed enter: ready diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_nested_attribute.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_nested_attribute.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_nested_attribute.expected.json rename to internal/exec/runtime/testdata/conformance/exhibited_state_nested_attribute.expected.json diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_nested_attribute.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_nested_attribute.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_nested_attribute.sysml rename to internal/exec/runtime/testdata/conformance/exhibited_state_nested_attribute.sysml diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_occurrence_default.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_occurrence_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_occurrence_default.expected.json rename to internal/exec/runtime/testdata/conformance/exhibited_state_occurrence_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_occurrence_default.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_occurrence_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_occurrence_default.sysml rename to internal/exec/runtime/testdata/conformance/exhibited_state_occurrence_default.sysml diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_own_attribute.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_own_attribute.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_own_attribute.expected.json rename to internal/exec/runtime/testdata/conformance/exhibited_state_own_attribute.expected.json diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_own_attribute.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_own_attribute.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_own_attribute.sysml rename to internal/exec/runtime/testdata/conformance/exhibited_state_own_attribute.sysml diff --git a/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.expected.json new file mode 100644 index 0000000000..973a43046a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.expected.json @@ -0,0 +1,13 @@ +{ + "type": "instance", + "instantiate": "test::engine", + "trace": true, + "objects": [ + { + "instance": 1, + "events": [{"signal": "EngineCommand"}], + "finalState": "done", + "stateVisits": ["start", "waiting", "done"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.sysml new file mode 100644 index 0000000000..04e131555d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.sysml @@ -0,0 +1,31 @@ +// An `exhibit state` usage naming nothing — no reference subsetting, no typing +// — exhibits itself (SysML v2 §8.3.17, exhibitedState), and its `in` member +// binds the machine's parameter from the object's port. +package test { + item def EngineCommand; + + port def CmdPort { + in item cmd : EngineCommand; + } + + part def Engine { + port engineControl : CmdPort; + + exhibit state modes { + in cmd : EngineCommand = engineControl.cmd; + + entry; then start; + state start; + state waiting; + state done; + + succession first start then waiting; + + transition first waiting + accept EngineCommand + then done; + } + } + + part engine : Engine; +} diff --git a/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden new file mode 100644 index 0000000000..8b06be640e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target.trace.golden @@ -0,0 +1,17 @@ +materialize: engine #1 +start: exhibited state machine modes of #1 +materialize: modes #2 +materialize: CmdPort #3 + eval feature engineControl -> instance#3 +materialize: EngineCommand #4 +eval chain cmd -> instance#4 + eval feature engineControl -> instance#3 +eval chain cmd -> instance#4 +enter: start +run: exhibited state machine modes of #1 +exit: start +enter: waiting +transition: start -> waiting +exit: waiting +enter: done +transition: waiting -> done (event: accept EngineCommand) diff --git a/internal/exec/runtime/testdata/conformance/exhibited_state_self_target_empty.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target_empty.expected.json new file mode 100644 index 0000000000..67bb53e374 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target_empty.expected.json @@ -0,0 +1,5 @@ +{ + "type": "instance", + "instantiate": "test::Idle", + "error": "no initial state found in state machine idle" +} diff --git a/internal/exec/runtime/testdata/conformance/exhibited_state_self_target_empty.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target_empty.sysml new file mode 100644 index 0000000000..feaca21f86 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/exhibited_state_self_target_empty.sysml @@ -0,0 +1,9 @@ +// An `exhibit state` usage with an empty body names nothing and exhibits +// itself (SysML v2 §8.3.17, exhibitedState): the machine is its own body, so +// instantiation fails like any machine declaring no initial state, not with +// `classifier behavior names no body`. +package test { + part def Idle { + exhibit state idle; + } +} diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_two_objects.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_two_objects.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_two_objects.expected.json rename to internal/exec/runtime/testdata/conformance/exhibited_state_two_objects.expected.json diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_two_objects.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_two_objects.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_two_objects.sysml rename to internal/exec/runtime/testdata/conformance/exhibited_state_two_objects.sysml diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action.expected.json rename to internal/exec/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action.expected.json diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action.sysml rename to internal/exec/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action.sysml diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action_bodiless.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action_bodiless.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action_bodiless.expected.json rename to internal/exec/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action_bodiless.expected.json diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action_bodiless.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action_bodiless.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action_bodiless.sysml rename to internal/exec/runtime/testdata/conformance/exhibited_state_typed_by_library_state_action_bodiless.sysml diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_typed_usage.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_typed_usage.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_typed_usage.expected.json rename to internal/exec/runtime/testdata/conformance/exhibited_state_typed_usage.expected.json diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_typed_usage.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_typed_usage.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_typed_usage.sysml rename to internal/exec/runtime/testdata/conformance/exhibited_state_typed_usage.sysml diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_writes_this_feature.expected.json b/internal/exec/runtime/testdata/conformance/exhibited_state_writes_this_feature.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_writes_this_feature.expected.json rename to internal/exec/runtime/testdata/conformance/exhibited_state_writes_this_feature.expected.json diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_writes_this_feature.sysml b/internal/exec/runtime/testdata/conformance/exhibited_state_writes_this_feature.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_writes_this_feature.sysml rename to internal/exec/runtime/testdata/conformance/exhibited_state_writes_this_feature.sysml diff --git a/internal/core/runtime/testdata/conformance/exhibited_state_writes_this_feature.trace.golden b/internal/exec/runtime/testdata/conformance/exhibited_state_writes_this_feature.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/exhibited_state_writes_this_feature.trace.golden rename to internal/exec/runtime/testdata/conformance/exhibited_state_writes_this_feature.trace.golden diff --git a/internal/core/runtime/testdata/conformance/extent_across_documents.depot.sysml b/internal/exec/runtime/testdata/conformance/extent_across_documents.depot.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_across_documents.depot.sysml rename to internal/exec/runtime/testdata/conformance/extent_across_documents.depot.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_across_documents.expected.json b/internal/exec/runtime/testdata/conformance/extent_across_documents.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_across_documents.expected.json rename to internal/exec/runtime/testdata/conformance/extent_across_documents.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_across_documents.sysml b/internal/exec/runtime/testdata/conformance/extent_across_documents.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_across_documents.sysml rename to internal/exec/runtime/testdata/conformance/extent_across_documents.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_across_documents.trace.golden b/internal/exec/runtime/testdata/conformance/extent_across_documents.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_across_documents.trace.golden rename to internal/exec/runtime/testdata/conformance/extent_across_documents.trace.golden diff --git a/internal/core/runtime/testdata/conformance/extent_bound_namespace_collection.expected.json b/internal/exec/runtime/testdata/conformance/extent_bound_namespace_collection.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_bound_namespace_collection.expected.json rename to internal/exec/runtime/testdata/conformance/extent_bound_namespace_collection.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_bound_namespace_collection.sysml b/internal/exec/runtime/testdata/conformance/extent_bound_namespace_collection.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_bound_namespace_collection.sysml rename to internal/exec/runtime/testdata/conformance/extent_bound_namespace_collection.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_bound_namespace_collection.trace.golden b/internal/exec/runtime/testdata/conformance/extent_bound_namespace_collection.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_bound_namespace_collection.trace.golden rename to internal/exec/runtime/testdata/conformance/extent_bound_namespace_collection.trace.golden diff --git a/internal/core/runtime/testdata/conformance/extent_bound_namespace_collection_count.expected.json b/internal/exec/runtime/testdata/conformance/extent_bound_namespace_collection_count.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_bound_namespace_collection_count.expected.json rename to internal/exec/runtime/testdata/conformance/extent_bound_namespace_collection_count.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_bound_namespace_collection_count.sysml b/internal/exec/runtime/testdata/conformance/extent_bound_namespace_collection_count.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_bound_namespace_collection_count.sysml rename to internal/exec/runtime/testdata/conformance/extent_bound_namespace_collection_count.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_bound_namespace_objects.expected.json b/internal/exec/runtime/testdata/conformance/extent_bound_namespace_objects.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_bound_namespace_objects.expected.json rename to internal/exec/runtime/testdata/conformance/extent_bound_namespace_objects.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_bound_namespace_objects.sysml b/internal/exec/runtime/testdata/conformance/extent_bound_namespace_objects.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_bound_namespace_objects.sysml rename to internal/exec/runtime/testdata/conformance/extent_bound_namespace_objects.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_bound_namespace_objects.trace.golden b/internal/exec/runtime/testdata/conformance/extent_bound_namespace_objects.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_bound_namespace_objects.trace.golden rename to internal/exec/runtime/testdata/conformance/extent_bound_namespace_objects.trace.golden diff --git a/internal/core/runtime/testdata/conformance/extent_declaration_order.expected.json b/internal/exec/runtime/testdata/conformance/extent_declaration_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_declaration_order.expected.json rename to internal/exec/runtime/testdata/conformance/extent_declaration_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_declaration_order.sysml b/internal/exec/runtime/testdata/conformance/extent_declaration_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_declaration_order.sysml rename to internal/exec/runtime/testdata/conformance/extent_declaration_order.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_declaration_order.trace.golden b/internal/exec/runtime/testdata/conformance/extent_declaration_order.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_declaration_order.trace.golden rename to internal/exec/runtime/testdata/conformance/extent_declaration_order.trace.golden diff --git a/internal/core/runtime/testdata/conformance/extent_definition_objects.expected.json b/internal/exec/runtime/testdata/conformance/extent_definition_objects.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_definition_objects.expected.json rename to internal/exec/runtime/testdata/conformance/extent_definition_objects.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_definition_objects.sysml b/internal/exec/runtime/testdata/conformance/extent_definition_objects.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_definition_objects.sysml rename to internal/exec/runtime/testdata/conformance/extent_definition_objects.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_enclosing_namespaces.expected.json b/internal/exec/runtime/testdata/conformance/extent_enclosing_namespaces.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_enclosing_namespaces.expected.json rename to internal/exec/runtime/testdata/conformance/extent_enclosing_namespaces.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_enclosing_namespaces.sysml b/internal/exec/runtime/testdata/conformance/extent_enclosing_namespaces.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_enclosing_namespaces.sysml rename to internal/exec/runtime/testdata/conformance/extent_enclosing_namespaces.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_enumeration_values.expected.json b/internal/exec/runtime/testdata/conformance/extent_enumeration_values.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_enumeration_values.expected.json rename to internal/exec/runtime/testdata/conformance/extent_enumeration_values.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_enumeration_values.sysml b/internal/exec/runtime/testdata/conformance/extent_enumeration_values.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_enumeration_values.sysml rename to internal/exec/runtime/testdata/conformance/extent_enumeration_values.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_imported_and_unimported_packages.expected.json b/internal/exec/runtime/testdata/conformance/extent_imported_and_unimported_packages.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_imported_and_unimported_packages.expected.json rename to internal/exec/runtime/testdata/conformance/extent_imported_and_unimported_packages.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_imported_and_unimported_packages.sysml b/internal/exec/runtime/testdata/conformance/extent_imported_and_unimported_packages.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_imported_and_unimported_packages.sysml rename to internal/exec/runtime/testdata/conformance/extent_imported_and_unimported_packages.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_library_clock.expected.json b/internal/exec/runtime/testdata/conformance/extent_library_clock.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_library_clock.expected.json rename to internal/exec/runtime/testdata/conformance/extent_library_clock.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_library_clock.sysml b/internal/exec/runtime/testdata/conformance/extent_library_clock.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_library_clock.sysml rename to internal/exec/runtime/testdata/conformance/extent_library_clock.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_linked_objects.expected.json b/internal/exec/runtime/testdata/conformance/extent_linked_objects.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_linked_objects.expected.json rename to internal/exec/runtime/testdata/conformance/extent_linked_objects.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_linked_objects.sysml b/internal/exec/runtime/testdata/conformance/extent_linked_objects.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_linked_objects.sysml rename to internal/exec/runtime/testdata/conformance/extent_linked_objects.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_linked_repeated_declaration.expected.json b/internal/exec/runtime/testdata/conformance/extent_linked_repeated_declaration.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_linked_repeated_declaration.expected.json rename to internal/exec/runtime/testdata/conformance/extent_linked_repeated_declaration.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_linked_repeated_declaration.sysml b/internal/exec/runtime/testdata/conformance/extent_linked_repeated_declaration.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_linked_repeated_declaration.sysml rename to internal/exec/runtime/testdata/conformance/extent_linked_repeated_declaration.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_namespace_collection.expected.json b/internal/exec/runtime/testdata/conformance/extent_namespace_collection.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_namespace_collection.expected.json rename to internal/exec/runtime/testdata/conformance/extent_namespace_collection.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_namespace_collection.sysml b/internal/exec/runtime/testdata/conformance/extent_namespace_collection.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_namespace_collection.sysml rename to internal/exec/runtime/testdata/conformance/extent_namespace_collection.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_namespace_collection.trace.golden b/internal/exec/runtime/testdata/conformance/extent_namespace_collection.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_namespace_collection.trace.golden rename to internal/exec/runtime/testdata/conformance/extent_namespace_collection.trace.golden diff --git a/internal/core/runtime/testdata/conformance/extent_namespace_objects.expected.json b/internal/exec/runtime/testdata/conformance/extent_namespace_objects.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_namespace_objects.expected.json rename to internal/exec/runtime/testdata/conformance/extent_namespace_objects.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_namespace_objects.sysml b/internal/exec/runtime/testdata/conformance/extent_namespace_objects.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_namespace_objects.sysml rename to internal/exec/runtime/testdata/conformance/extent_namespace_objects.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_namespace_objects.trace.golden b/internal/exec/runtime/testdata/conformance/extent_namespace_objects.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_namespace_objects.trace.golden rename to internal/exec/runtime/testdata/conformance/extent_namespace_objects.trace.golden diff --git a/internal/core/runtime/testdata/conformance/extent_unbounded_data_type.expected.json b/internal/exec/runtime/testdata/conformance/extent_unbounded_data_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_unbounded_data_type.expected.json rename to internal/exec/runtime/testdata/conformance/extent_unbounded_data_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_unbounded_data_type.sysml b/internal/exec/runtime/testdata/conformance/extent_unbounded_data_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_unbounded_data_type.sysml rename to internal/exec/runtime/testdata/conformance/extent_unbounded_data_type.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_value_chosen_at_run_time.expected.json b/internal/exec/runtime/testdata/conformance/extent_value_chosen_at_run_time.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_value_chosen_at_run_time.expected.json rename to internal/exec/runtime/testdata/conformance/extent_value_chosen_at_run_time.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_value_chosen_at_run_time.sysml b/internal/exec/runtime/testdata/conformance/extent_value_chosen_at_run_time.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_value_chosen_at_run_time.sysml rename to internal/exec/runtime/testdata/conformance/extent_value_chosen_at_run_time.sysml diff --git a/internal/core/runtime/testdata/conformance/extent_variation_variants.expected.json b/internal/exec/runtime/testdata/conformance/extent_variation_variants.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_variation_variants.expected.json rename to internal/exec/runtime/testdata/conformance/extent_variation_variants.expected.json diff --git a/internal/core/runtime/testdata/conformance/extent_variation_variants.sysml b/internal/exec/runtime/testdata/conformance/extent_variation_variants.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/extent_variation_variants.sysml rename to internal/exec/runtime/testdata/conformance/extent_variation_variants.sysml diff --git a/internal/core/runtime/testdata/conformance/f62_send_body_payload.expected.json b/internal/exec/runtime/testdata/conformance/f62_send_body_payload.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f62_send_body_payload.expected.json rename to internal/exec/runtime/testdata/conformance/f62_send_body_payload.expected.json diff --git a/internal/core/runtime/testdata/conformance/f62_send_body_payload.sysml b/internal/exec/runtime/testdata/conformance/f62_send_body_payload.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f62_send_body_payload.sysml rename to internal/exec/runtime/testdata/conformance/f62_send_body_payload.sysml diff --git a/internal/core/runtime/testdata/conformance/f62_transition_body_dotted_target.expected.json b/internal/exec/runtime/testdata/conformance/f62_transition_body_dotted_target.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f62_transition_body_dotted_target.expected.json rename to internal/exec/runtime/testdata/conformance/f62_transition_body_dotted_target.expected.json diff --git a/internal/core/runtime/testdata/conformance/f62_transition_body_dotted_target.sysml b/internal/exec/runtime/testdata/conformance/f62_transition_body_dotted_target.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f62_transition_body_dotted_target.sysml rename to internal/exec/runtime/testdata/conformance/f62_transition_body_dotted_target.sysml diff --git a/internal/core/runtime/testdata/conformance/f62_transition_body_dotted_target.trace.golden b/internal/exec/runtime/testdata/conformance/f62_transition_body_dotted_target.trace.golden similarity index 76% rename from internal/core/runtime/testdata/conformance/f62_transition_body_dotted_target.trace.golden rename to internal/exec/runtime/testdata/conformance/f62_transition_body_dotted_target.trace.golden index c50820a244..cd659e5c2c 100644 --- a/internal/core/runtime/testdata/conformance/f62_transition_body_dotted_target.trace.golden +++ b/internal/exec/runtime/testdata/conformance/f62_transition_body_dotted_target.trace.golden @@ -3,8 +3,9 @@ stmt assign stepped eval literal 1 -> 1 enter: beta enter: work (entry action) -stmt assign marker - eval literal 2 -> 2 +stmt action body + stmt assign marker + eval literal 2 -> 2 transition: start -> work exit: work exit: beta diff --git a/internal/core/runtime/testdata/conformance/f63_control_node_body.expected.json b/internal/exec/runtime/testdata/conformance/f63_control_node_body.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f63_control_node_body.expected.json rename to internal/exec/runtime/testdata/conformance/f63_control_node_body.expected.json diff --git a/internal/core/runtime/testdata/conformance/f63_control_node_body.sysml b/internal/exec/runtime/testdata/conformance/f63_control_node_body.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f63_control_node_body.sysml rename to internal/exec/runtime/testdata/conformance/f63_control_node_body.sysml diff --git a/internal/core/runtime/testdata/conformance/f63_control_node_body.trace.golden b/internal/exec/runtime/testdata/conformance/f63_control_node_body.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/f63_control_node_body.trace.golden rename to internal/exec/runtime/testdata/conformance/f63_control_node_body.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/f63_for_typed_variable.expected.json b/internal/exec/runtime/testdata/conformance/f63_for_typed_variable.expected.json new file mode 100644 index 0000000000..859a38ab7d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/f63_for_typed_variable.expected.json @@ -0,0 +1,6 @@ +{ + "type": "action", + "outputs": { + "total": {"type": "Integer", "value": 6} + } +} diff --git a/internal/core/runtime/testdata/conformance/f63_for_typed_variable.sysml b/internal/exec/runtime/testdata/conformance/f63_for_typed_variable.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f63_for_typed_variable.sysml rename to internal/exec/runtime/testdata/conformance/f63_for_typed_variable.sysml diff --git a/internal/core/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.expected.json b/internal/exec/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.expected.json rename to internal/exec/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.expected.json diff --git a/internal/core/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.sysml b/internal/exec/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.sysml rename to internal/exec/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.sysml diff --git a/internal/core/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.trace.golden b/internal/exec/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.trace.golden rename to internal/exec/runtime/testdata/conformance/f63_merge_body_runs_on_traversal.trace.golden diff --git a/internal/core/runtime/testdata/conformance/f64_calc_return_usage.expected.json b/internal/exec/runtime/testdata/conformance/f64_calc_return_usage.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f64_calc_return_usage.expected.json rename to internal/exec/runtime/testdata/conformance/f64_calc_return_usage.expected.json diff --git a/internal/core/runtime/testdata/conformance/f64_calc_return_usage.sysml b/internal/exec/runtime/testdata/conformance/f64_calc_return_usage.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f64_calc_return_usage.sysml rename to internal/exec/runtime/testdata/conformance/f64_calc_return_usage.sysml diff --git a/internal/core/runtime/testdata/conformance/f64_calc_return_usage_without_value.expected.json b/internal/exec/runtime/testdata/conformance/f64_calc_return_usage_without_value.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f64_calc_return_usage_without_value.expected.json rename to internal/exec/runtime/testdata/conformance/f64_calc_return_usage_without_value.expected.json diff --git a/internal/core/runtime/testdata/conformance/f64_calc_return_usage_without_value.sysml b/internal/exec/runtime/testdata/conformance/f64_calc_return_usage_without_value.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f64_calc_return_usage_without_value.sysml rename to internal/exec/runtime/testdata/conformance/f64_calc_return_usage_without_value.sysml diff --git a/internal/core/runtime/testdata/conformance/f99_body_bare_result.expected.json b/internal/exec/runtime/testdata/conformance/f99_body_bare_result.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f99_body_bare_result.expected.json rename to internal/exec/runtime/testdata/conformance/f99_body_bare_result.expected.json diff --git a/internal/core/runtime/testdata/conformance/f99_body_bare_result.sysml b/internal/exec/runtime/testdata/conformance/f99_body_bare_result.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f99_body_bare_result.sysml rename to internal/exec/runtime/testdata/conformance/f99_body_bare_result.sysml diff --git a/internal/core/runtime/testdata/conformance/f99_body_forward_reference.expected.json b/internal/exec/runtime/testdata/conformance/f99_body_forward_reference.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f99_body_forward_reference.expected.json rename to internal/exec/runtime/testdata/conformance/f99_body_forward_reference.expected.json diff --git a/internal/core/runtime/testdata/conformance/f99_body_forward_reference.sysml b/internal/exec/runtime/testdata/conformance/f99_body_forward_reference.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f99_body_forward_reference.sysml rename to internal/exec/runtime/testdata/conformance/f99_body_forward_reference.sysml diff --git a/internal/core/runtime/testdata/conformance/f99_body_local_result.expected.json b/internal/exec/runtime/testdata/conformance/f99_body_local_result.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f99_body_local_result.expected.json rename to internal/exec/runtime/testdata/conformance/f99_body_local_result.expected.json diff --git a/internal/core/runtime/testdata/conformance/f99_body_local_result.sysml b/internal/exec/runtime/testdata/conformance/f99_body_local_result.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f99_body_local_result.sysml rename to internal/exec/runtime/testdata/conformance/f99_body_local_result.sysml diff --git a/internal/core/runtime/testdata/conformance/f99_body_shadowing.expected.json b/internal/exec/runtime/testdata/conformance/f99_body_shadowing.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/f99_body_shadowing.expected.json rename to internal/exec/runtime/testdata/conformance/f99_body_shadowing.expected.json diff --git a/internal/core/runtime/testdata/conformance/f99_body_shadowing.sysml b/internal/exec/runtime/testdata/conformance/f99_body_shadowing.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/f99_body_shadowing.sysml rename to internal/exec/runtime/testdata/conformance/f99_body_shadowing.sysml diff --git a/internal/core/runtime/testdata/conformance/feature_chain_empty_collection.expected.json b/internal/exec/runtime/testdata/conformance/feature_chain_empty_collection.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/feature_chain_empty_collection.expected.json rename to internal/exec/runtime/testdata/conformance/feature_chain_empty_collection.expected.json diff --git a/internal/core/runtime/testdata/conformance/feature_chain_empty_collection.sysml b/internal/exec/runtime/testdata/conformance/feature_chain_empty_collection.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/feature_chain_empty_collection.sysml rename to internal/exec/runtime/testdata/conformance/feature_chain_empty_collection.sysml diff --git a/internal/core/runtime/testdata/conformance/feature_chain_nested_multivalued.expected.json b/internal/exec/runtime/testdata/conformance/feature_chain_nested_multivalued.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/feature_chain_nested_multivalued.expected.json rename to internal/exec/runtime/testdata/conformance/feature_chain_nested_multivalued.expected.json diff --git a/internal/core/runtime/testdata/conformance/feature_chain_nested_multivalued.sysml b/internal/exec/runtime/testdata/conformance/feature_chain_nested_multivalued.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/feature_chain_nested_multivalued.sysml rename to internal/exec/runtime/testdata/conformance/feature_chain_nested_multivalued.sysml diff --git a/internal/core/runtime/testdata/conformance/feature_chain_rollup_over_subsets.expected.json b/internal/exec/runtime/testdata/conformance/feature_chain_rollup_over_subsets.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/feature_chain_rollup_over_subsets.expected.json rename to internal/exec/runtime/testdata/conformance/feature_chain_rollup_over_subsets.expected.json diff --git a/internal/core/runtime/testdata/conformance/feature_chain_rollup_over_subsets.sysml b/internal/exec/runtime/testdata/conformance/feature_chain_rollup_over_subsets.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/feature_chain_rollup_over_subsets.sysml rename to internal/exec/runtime/testdata/conformance/feature_chain_rollup_over_subsets.sysml diff --git a/internal/core/runtime/testdata/conformance/filter_classification_annotation_forms.expected.json b/internal/exec/runtime/testdata/conformance/filter_classification_annotation_forms.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/filter_classification_annotation_forms.expected.json rename to internal/exec/runtime/testdata/conformance/filter_classification_annotation_forms.expected.json diff --git a/internal/core/runtime/testdata/conformance/filter_classification_annotation_forms.sysml b/internal/exec/runtime/testdata/conformance/filter_classification_annotation_forms.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/filter_classification_annotation_forms.sysml rename to internal/exec/runtime/testdata/conformance/filter_classification_annotation_forms.sysml diff --git a/internal/core/runtime/testdata/conformance/filter_classification_implicit_subject.expected.json b/internal/exec/runtime/testdata/conformance/filter_classification_implicit_subject.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/filter_classification_implicit_subject.expected.json rename to internal/exec/runtime/testdata/conformance/filter_classification_implicit_subject.expected.json diff --git a/internal/core/runtime/testdata/conformance/filter_classification_implicit_subject.sysml b/internal/exec/runtime/testdata/conformance/filter_classification_implicit_subject.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/filter_classification_implicit_subject.sysml rename to internal/exec/runtime/testdata/conformance/filter_classification_implicit_subject.sysml diff --git a/internal/core/runtime/testdata/conformance/filter_classification_meta_versus_annotation.expected.json b/internal/exec/runtime/testdata/conformance/filter_classification_meta_versus_annotation.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/filter_classification_meta_versus_annotation.expected.json rename to internal/exec/runtime/testdata/conformance/filter_classification_meta_versus_annotation.expected.json diff --git a/internal/core/runtime/testdata/conformance/filter_classification_meta_versus_annotation.sysml b/internal/exec/runtime/testdata/conformance/filter_classification_meta_versus_annotation.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/filter_classification_meta_versus_annotation.sysml rename to internal/exec/runtime/testdata/conformance/filter_classification_meta_versus_annotation.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_action_closure.expected.json b/internal/exec/runtime/testdata/conformance/function_value_action_closure.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_action_closure.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_action_closure.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_action_closure.sysml b/internal/exec/runtime/testdata/conformance/function_value_action_closure.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_action_closure.sysml rename to internal/exec/runtime/testdata/conformance/function_value_action_closure.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_action_parameter.expected.json b/internal/exec/runtime/testdata/conformance/function_value_action_parameter.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_action_parameter.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_action_parameter.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_action_parameter.sysml b/internal/exec/runtime/testdata/conformance/function_value_action_parameter.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_action_parameter.sysml rename to internal/exec/runtime/testdata/conformance/function_value_action_parameter.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_body_closure.expected.json b/internal/exec/runtime/testdata/conformance/function_value_body_closure.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_body_closure.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_body_closure.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_body_closure.sysml b/internal/exec/runtime/testdata/conformance/function_value_body_closure.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_body_closure.sysml rename to internal/exec/runtime/testdata/conformance/function_value_body_closure.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_calc_usage.expected.json b/internal/exec/runtime/testdata/conformance/function_value_calc_usage.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_calc_usage.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_calc_usage.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_calc_usage.sysml b/internal/exec/runtime/testdata/conformance/function_value_calc_usage.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_calc_usage.sysml rename to internal/exec/runtime/testdata/conformance/function_value_calc_usage.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_chain_call.expected.json b/internal/exec/runtime/testdata/conformance/function_value_chain_call.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_chain_call.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_chain_call.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_chain_call.sysml b/internal/exec/runtime/testdata/conformance/function_value_chain_call.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_chain_call.sysml rename to internal/exec/runtime/testdata/conformance/function_value_chain_call.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_chain_defaults.expected.json b/internal/exec/runtime/testdata/conformance/function_value_chain_defaults.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_chain_defaults.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_chain_defaults.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_chain_defaults.sysml b/internal/exec/runtime/testdata/conformance/function_value_chain_defaults.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_chain_defaults.sysml rename to internal/exec/runtime/testdata/conformance/function_value_chain_defaults.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_chain_holder.expected.json b/internal/exec/runtime/testdata/conformance/function_value_chain_holder.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_chain_holder.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_chain_holder.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_chain_holder.sysml b/internal/exec/runtime/testdata/conformance/function_value_chain_holder.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_chain_holder.sysml rename to internal/exec/runtime/testdata/conformance/function_value_chain_holder.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_closure_equality.expected.json b/internal/exec/runtime/testdata/conformance/function_value_closure_equality.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_closure_equality.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_closure_equality.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_closure_equality.sysml b/internal/exec/runtime/testdata/conformance/function_value_closure_equality.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_closure_equality.sysml rename to internal/exec/runtime/testdata/conformance/function_value_closure_equality.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_feature_closure.expected.json b/internal/exec/runtime/testdata/conformance/function_value_feature_closure.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_feature_closure.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_feature_closure.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_feature_closure.sysml b/internal/exec/runtime/testdata/conformance/function_value_feature_closure.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_feature_closure.sysml rename to internal/exec/runtime/testdata/conformance/function_value_feature_closure.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_library.expected.json b/internal/exec/runtime/testdata/conformance/function_value_library.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_library.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_library.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_library.sysml b/internal/exec/runtime/testdata/conformance/function_value_library.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_library.sysml rename to internal/exec/runtime/testdata/conformance/function_value_library.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_named_args.expected.json b/internal/exec/runtime/testdata/conformance/function_value_named_args.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_named_args.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_named_args.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_named_args.sysml b/internal/exec/runtime/testdata/conformance/function_value_named_args.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_named_args.sysml rename to internal/exec/runtime/testdata/conformance/function_value_named_args.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_probe.expected.json b/internal/exec/runtime/testdata/conformance/function_value_probe.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_probe.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_probe.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_probe.sysml b/internal/exec/runtime/testdata/conformance/function_value_probe.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_probe.sysml rename to internal/exec/runtime/testdata/conformance/function_value_probe.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_probe.trace.golden b/internal/exec/runtime/testdata/conformance/function_value_probe.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_probe.trace.golden rename to internal/exec/runtime/testdata/conformance/function_value_probe.trace.golden diff --git a/internal/core/runtime/testdata/conformance/function_value_qualified_call.expected.json b/internal/exec/runtime/testdata/conformance/function_value_qualified_call.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_qualified_call.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_qualified_call.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_qualified_call.sysml b/internal/exec/runtime/testdata/conformance/function_value_qualified_call.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_qualified_call.sysml rename to internal/exec/runtime/testdata/conformance/function_value_qualified_call.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_qualified_default.expected.json b/internal/exec/runtime/testdata/conformance/function_value_qualified_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_qualified_default.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_qualified_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_qualified_default.sysml b/internal/exec/runtime/testdata/conformance/function_value_qualified_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_qualified_default.sysml rename to internal/exec/runtime/testdata/conformance/function_value_qualified_default.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_read.expected.json b/internal/exec/runtime/testdata/conformance/function_value_read.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_read.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_read.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_read.sysml b/internal/exec/runtime/testdata/conformance/function_value_read.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_read.sysml rename to internal/exec/runtime/testdata/conformance/function_value_read.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_sampled.expected.json b/internal/exec/runtime/testdata/conformance/function_value_sampled.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_sampled.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_sampled.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_sampled.sysml b/internal/exec/runtime/testdata/conformance/function_value_sampled.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_sampled.sysml rename to internal/exec/runtime/testdata/conformance/function_value_sampled.sysml diff --git a/internal/core/runtime/testdata/conformance/function_value_sampled_closure.expected.json b/internal/exec/runtime/testdata/conformance/function_value_sampled_closure.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_sampled_closure.expected.json rename to internal/exec/runtime/testdata/conformance/function_value_sampled_closure.expected.json diff --git a/internal/core/runtime/testdata/conformance/function_value_sampled_closure.sysml b/internal/exec/runtime/testdata/conformance/function_value_sampled_closure.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/function_value_sampled_closure.sysml rename to internal/exec/runtime/testdata/conformance/function_value_sampled_closure.sysml diff --git a/internal/core/runtime/testdata/conformance/inherited_feature_value_no_body.expected.json b/internal/exec/runtime/testdata/conformance/inherited_feature_value_no_body.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/inherited_feature_value_no_body.expected.json rename to internal/exec/runtime/testdata/conformance/inherited_feature_value_no_body.expected.json diff --git a/internal/core/runtime/testdata/conformance/inherited_feature_value_no_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_feature_value_no_body.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/inherited_feature_value_no_body.sysml rename to internal/exec/runtime/testdata/conformance/inherited_feature_value_no_body.sysml diff --git a/internal/core/runtime/testdata/conformance/inherited_feature_value_redefined.expected.json b/internal/exec/runtime/testdata/conformance/inherited_feature_value_redefined.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/inherited_feature_value_redefined.expected.json rename to internal/exec/runtime/testdata/conformance/inherited_feature_value_redefined.expected.json diff --git a/internal/core/runtime/testdata/conformance/inherited_feature_value_redefined.sysml b/internal/exec/runtime/testdata/conformance/inherited_feature_value_redefined.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/inherited_feature_value_redefined.sysml rename to internal/exec/runtime/testdata/conformance/inherited_feature_value_redefined.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_binding_classifies_value.expected.json b/internal/exec/runtime/testdata/conformance/instance_binding_classifies_value.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_binding_classifies_value.expected.json rename to internal/exec/runtime/testdata/conformance/instance_binding_classifies_value.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_binding_classifies_value.sysml b/internal/exec/runtime/testdata/conformance/instance_binding_classifies_value.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_binding_classifies_value.sysml rename to internal/exec/runtime/testdata/conformance/instance_binding_classifies_value.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_chain_valued_subsetting.expected.json b/internal/exec/runtime/testdata/conformance/instance_chain_valued_subsetting.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_chain_valued_subsetting.expected.json rename to internal/exec/runtime/testdata/conformance/instance_chain_valued_subsetting.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_chain_valued_subsetting.sysml b/internal/exec/runtime/testdata/conformance/instance_chain_valued_subsetting.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_chain_valued_subsetting.sysml rename to internal/exec/runtime/testdata/conformance/instance_chain_valued_subsetting.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_classifier_relationships.expected.json b/internal/exec/runtime/testdata/conformance/instance_classifier_relationships.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_classifier_relationships.expected.json rename to internal/exec/runtime/testdata/conformance/instance_classifier_relationships.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_classifier_relationships.sysml b/internal/exec/runtime/testdata/conformance/instance_classifier_relationships.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_classifier_relationships.sysml rename to internal/exec/runtime/testdata/conformance/instance_classifier_relationships.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_constraint_binding.expected.json b/internal/exec/runtime/testdata/conformance/instance_constraint_binding.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_constraint_binding.expected.json rename to internal/exec/runtime/testdata/conformance/instance_constraint_binding.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_constraint_binding.sysml b/internal/exec/runtime/testdata/conformance/instance_constraint_binding.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_constraint_binding.sysml rename to internal/exec/runtime/testdata/conformance/instance_constraint_binding.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_constraint_bound_parameter.expected.json b/internal/exec/runtime/testdata/conformance/instance_constraint_bound_parameter.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_constraint_bound_parameter.expected.json rename to internal/exec/runtime/testdata/conformance/instance_constraint_bound_parameter.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_constraint_bound_parameter.sysml b/internal/exec/runtime/testdata/conformance/instance_constraint_bound_parameter.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_constraint_bound_parameter.sysml rename to internal/exec/runtime/testdata/conformance/instance_constraint_bound_parameter.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_constraint_parameter_name_collision.expected.json b/internal/exec/runtime/testdata/conformance/instance_constraint_parameter_name_collision.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_constraint_parameter_name_collision.expected.json rename to internal/exec/runtime/testdata/conformance/instance_constraint_parameter_name_collision.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_constraint_parameter_name_collision.sysml b/internal/exec/runtime/testdata/conformance/instance_constraint_parameter_name_collision.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_constraint_parameter_name_collision.sysml rename to internal/exec/runtime/testdata/conformance/instance_constraint_parameter_name_collision.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_coordinate_frames.expected.json b/internal/exec/runtime/testdata/conformance/instance_coordinate_frames.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_coordinate_frames.expected.json rename to internal/exec/runtime/testdata/conformance/instance_coordinate_frames.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_coordinate_frames.sysml b/internal/exec/runtime/testdata/conformance/instance_coordinate_frames.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_coordinate_frames.sysml rename to internal/exec/runtime/testdata/conformance/instance_coordinate_frames.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_default_reference_usage.expected.json b/internal/exec/runtime/testdata/conformance/instance_default_reference_usage.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_default_reference_usage.expected.json rename to internal/exec/runtime/testdata/conformance/instance_default_reference_usage.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_default_reference_usage.sysml b/internal/exec/runtime/testdata/conformance/instance_default_reference_usage.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_default_reference_usage.sysml rename to internal/exec/runtime/testdata/conformance/instance_default_reference_usage.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_derived_slots.expected.json b/internal/exec/runtime/testdata/conformance/instance_derived_slots.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_derived_slots.expected.json rename to internal/exec/runtime/testdata/conformance/instance_derived_slots.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_derived_slots.sysml b/internal/exec/runtime/testdata/conformance/instance_derived_slots.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_derived_slots.sysml rename to internal/exec/runtime/testdata/conformance/instance_derived_slots.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_empty_aggregate_subsetting_rollup.expected.json b/internal/exec/runtime/testdata/conformance/instance_empty_aggregate_subsetting_rollup.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_empty_aggregate_subsetting_rollup.expected.json rename to internal/exec/runtime/testdata/conformance/instance_empty_aggregate_subsetting_rollup.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_empty_aggregate_subsetting_rollup.sysml b/internal/exec/runtime/testdata/conformance/instance_empty_aggregate_subsetting_rollup.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_empty_aggregate_subsetting_rollup.sysml rename to internal/exec/runtime/testdata/conformance/instance_empty_aggregate_subsetting_rollup.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_failing_default_beside_unset_quantity.expected.json b/internal/exec/runtime/testdata/conformance/instance_failing_default_beside_unset_quantity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_failing_default_beside_unset_quantity.expected.json rename to internal/exec/runtime/testdata/conformance/instance_failing_default_beside_unset_quantity.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_failing_default_beside_unset_quantity.sysml b/internal/exec/runtime/testdata/conformance/instance_failing_default_beside_unset_quantity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_failing_default_beside_unset_quantity.sysml rename to internal/exec/runtime/testdata/conformance/instance_failing_default_beside_unset_quantity.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_inherited_constraint.expected.json b/internal/exec/runtime/testdata/conformance/instance_inherited_constraint.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_inherited_constraint.expected.json rename to internal/exec/runtime/testdata/conformance/instance_inherited_constraint.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_inherited_constraint.sysml b/internal/exec/runtime/testdata/conformance/instance_inherited_constraint.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_inherited_constraint.sysml rename to internal/exec/runtime/testdata/conformance/instance_inherited_constraint.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_library_function_default.expected.json b/internal/exec/runtime/testdata/conformance/instance_library_function_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_function_default.expected.json rename to internal/exec/runtime/testdata/conformance/instance_library_function_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_library_function_default.sysml b/internal/exec/runtime/testdata/conformance/instance_library_function_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_function_default.sysml rename to internal/exec/runtime/testdata/conformance/instance_library_function_default.sysml diff --git a/internal/exec/runtime/testdata/conformance/instance_library_geometry_box.expected.json b/internal/exec/runtime/testdata/conformance/instance_library_geometry_box.expected.json new file mode 100644 index 0000000000..2588fcd5e2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_library_geometry_box.expected.json @@ -0,0 +1,61 @@ +{ + "type": "instance", + "instantiate": "test::Crate", + "libraries": true, + "slots": { + "box.length": {"type": "Quantity", "value": 2, "unit": "m"}, + "box.faces": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"} + ]}, + "box.tf.length": {"type": "Quantity", "value": 2, "unit": "m"}, + "box.tf.width": {"type": "Quantity", "value": 1, "unit": "m"}, + "box.ff.length": {"type": "Quantity", "value": 2, "unit": "m"}, + "box.ff.width": {"type": "Quantity", "value": 1, "unit": "m"}, + "box.slf.length": {"type": "Quantity", "value": 1, "unit": "m"}, + "box.slf.width": {"type": "Quantity", "value": 1, "unit": "m"}, + "box.tf.e1.length": {"type": "Quantity", "value": 2, "unit": "m"}, + "box.tf.e2.length": {"type": "Quantity", "value": 1, "unit": "m"}, + "box.tf.vertices": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"} + ]}, + "box.tf.e1.vertices": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"} + ]}, + "box.srf.length": {"type": "Quantity", "value": 1, "unit": "m"}, + "box.srf.width": {"type": "Quantity", "value": 1, "unit": "m"}, + "box.height": {"type": "Quantity", "value": 1, "unit": "m"}, + "box.tf.edges": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"} + ]}, + "box.edges": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"} + ]}, + "box.tfe": {"error": "binding end cannot be resolved: box.tfe is bound by `binding [1] bind [0..1] tf.edges = [0..1] tfe`, which makes some value of tfe a value of tf.edges without saying which value of either; the model does not state what tfe holds"}, + "box.tfe.length": {"error": "binding end cannot be resolved: box.tfe is bound by `binding [1] bind [0..1] tf.edges = [0..1] tfe`, which makes some value of tfe a value of tf.edges without saying which value of either; the model does not state what tfe holds"}, + "box.tflv": {"error": "binding end cannot be resolved: box.tfe is bound by `binding [1] bind [0..1] tf.edges = [0..1] tfe`, which makes some value of tfe a value of tf.edges without saying which value of either; the model does not state what tfe holds"}, + "box.vertices": {"error": "subsetting feature tflv of vertices: binding end cannot be resolved: box.tfe is bound by `binding [1] bind [0..1] tf.edges = [0..1] tfe`, which makes some value of tfe a value of tf.edges without saying which value of either; the model does not state what tfe holds"}, + "box.isSolid": {"type": "Boolean", "value": true}, + "box.voids": {"type": "Sequence", "elements": []}, + "box.shape": {"type": "Sequence", "elements": []}, + "box.subitems": {"type": "Sequence", "elements": []}, + "box.self": {"error": "feature \"self\" not found"}, + "box.timeSlices": {"error": "feature \"timeSlices\" not found"}, + "box.startShot": {"error": "feature \"startShot\" not found"}, + "box.start": {"error": "feature \"start\" not found"} + }, + "identical": [ + ["box.faces.1", "box.tf"], + ["box.edges.1", "box.tf.e1"], + ["box.edges.24", "box.srf.e4"] + ], + "distinct": [ + ["box.edges.1", "box.edges.5"] + ] +} diff --git a/internal/core/runtime/testdata/conformance/instance_library_geometry_box.sysml b/internal/exec/runtime/testdata/conformance/instance_library_geometry_box.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_geometry_box.sysml rename to internal/exec/runtime/testdata/conformance/instance_library_geometry_box.sysml diff --git a/internal/exec/runtime/testdata/conformance/instance_library_geometry_box_void.expected.json b/internal/exec/runtime/testdata/conformance/instance_library_geometry_box_void.expected.json new file mode 100644 index 0000000000..1bf1a2aed5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_library_geometry_box_void.expected.json @@ -0,0 +1,35 @@ +{ + "type": "instance", + "instantiate": "test::Casting", + "libraries": true, + "slots": { + "block.isSolid": {"type": "Boolean", "value": false}, + "block.voids": {"type": "Instance"}, + "block.voids.height": {"type": "Quantity", "value": 3, "unit": "m"}, + "block.voids.faces": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"} + ]}, + "block.voids.base.edges": {"type": "Instance"}, + "block.voids.af.edges": {"type": "Instance"}, + "block.voids.be": {"error": "binding end cannot be resolved \"cf.edges\": feature edges not found"}, + "block.faces": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"} + ]}, + "block.tf.edges": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"} + ]}, + "block.edges": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"} + ]}, + "block.tf.vertices": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"} + ]} + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_library_geometry_box_void.sysml b/internal/exec/runtime/testdata/conformance/instance_library_geometry_box_void.sysml new file mode 100644 index 0000000000..8e11109134 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_library_geometry_box_void.sysml @@ -0,0 +1,20 @@ +// A Box carrying a Cylinder void: isSolid derives false from the one void, and +// the void's faces resolve through the same partial bindings as a plain one. +package test { + private import ShapeItems::*; + private import ISQ::*; + private import SI::*; + + part def Casting { + part block : Box { + :>> length = 2 [m]; + :>> width = 1 [m]; + :>> height = 3 [m]; + item :>> voids : Cylinder [1] { + :>> semiMajorAxis = 0.5 [m]; + :>> semiMinorAxis = 0.5 [m]; + :>> height = 3 [m]; + } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_library_geometry_cylinder.expected.json b/internal/exec/runtime/testdata/conformance/instance_library_geometry_cylinder.expected.json new file mode 100644 index 0000000000..d17b66be76 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_library_geometry_cylinder.expected.json @@ -0,0 +1,19 @@ +{ + "type": "instance", + "instantiate": "test::Drum", + "libraries": true, + "slots": { + "cyl.height": {"type": "Quantity", "value": 2, "unit": "m"}, + "cyl.faces": {"type": "Sequence", "elements": [ + {"type": "Instance"}, {"type": "Instance"}, {"type": "Instance"} + ]}, + "cyl.base.edges": {"type": "Instance"}, + "cyl.af.edges": {"type": "Instance"}, + "cyl.be": {"error": "binding end cannot be resolved \"cf.edges\": feature edges not found"}, + "cyl.ae": {"error": "binding end cannot be resolved \"cf.edges\": feature edges not found"}, + "cyl.edges": {"error": "member edges not found in instance"}, + "cyl.vertices": {"error": "member vertices not found in instance"}, + "cyl.isSolid": {"type": "Boolean", "value": true}, + "cyl.voids": {"type": "Sequence", "elements": []} + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_library_geometry_cylinder.sysml b/internal/exec/runtime/testdata/conformance/instance_library_geometry_cylinder.sysml new file mode 100644 index 0000000000..76908dab16 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_library_geometry_cylinder.sysml @@ -0,0 +1,15 @@ +// A Cylinder inherits Items::Item through the Geometry domain library: the base +// and lateral face edges resolve through the `binding [1]` partial bindings. +package test { + private import ShapeItems::*; + private import ISQ::*; + private import SI::*; + + part def Drum { + part cyl : Cylinder { + :>> semiMajorAxis = 4 [m]; + :>> semiMinorAxis = 3 [m]; + :>> height = 2 [m]; + } + } +} diff --git a/internal/core/runtime/testdata/conformance/instance_library_geometry_rectangle.expected.json b/internal/exec/runtime/testdata/conformance/instance_library_geometry_rectangle.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_geometry_rectangle.expected.json rename to internal/exec/runtime/testdata/conformance/instance_library_geometry_rectangle.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_library_geometry_rectangle.sysml b/internal/exec/runtime/testdata/conformance/instance_library_geometry_rectangle.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_geometry_rectangle.sysml rename to internal/exec/runtime/testdata/conformance/instance_library_geometry_rectangle.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_library_geometry_triangle.expected.json b/internal/exec/runtime/testdata/conformance/instance_library_geometry_triangle.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_geometry_triangle.expected.json rename to internal/exec/runtime/testdata/conformance/instance_library_geometry_triangle.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_library_geometry_triangle.sysml b/internal/exec/runtime/testdata/conformance/instance_library_geometry_triangle.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_geometry_triangle.sysml rename to internal/exec/runtime/testdata/conformance/instance_library_geometry_triangle.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_library_item_part_features.expected.json b/internal/exec/runtime/testdata/conformance/instance_library_item_part_features.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_item_part_features.expected.json rename to internal/exec/runtime/testdata/conformance/instance_library_item_part_features.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_library_item_part_features.sysml b/internal/exec/runtime/testdata/conformance/instance_library_item_part_features.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_item_part_features.sysml rename to internal/exec/runtime/testdata/conformance/instance_library_item_part_features.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_library_requirement_features.expected.json b/internal/exec/runtime/testdata/conformance/instance_library_requirement_features.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_requirement_features.expected.json rename to internal/exec/runtime/testdata/conformance/instance_library_requirement_features.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_library_requirement_features.sysml b/internal/exec/runtime/testdata/conformance/instance_library_requirement_features.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_requirement_features.sysml rename to internal/exec/runtime/testdata/conformance/instance_library_requirement_features.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_library_requirement_subject_binding.expected.json b/internal/exec/runtime/testdata/conformance/instance_library_requirement_subject_binding.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_requirement_subject_binding.expected.json rename to internal/exec/runtime/testdata/conformance/instance_library_requirement_subject_binding.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_library_requirement_subject_binding.sysml b/internal/exec/runtime/testdata/conformance/instance_library_requirement_subject_binding.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_library_requirement_subject_binding.sysml rename to internal/exec/runtime/testdata/conformance/instance_library_requirement_subject_binding.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_measurement_reference_failures.expected.json b/internal/exec/runtime/testdata/conformance/instance_measurement_reference_failures.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_measurement_reference_failures.expected.json rename to internal/exec/runtime/testdata/conformance/instance_measurement_reference_failures.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_measurement_reference_failures.sysml b/internal/exec/runtime/testdata/conformance/instance_measurement_reference_failures.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_measurement_reference_failures.sysml rename to internal/exec/runtime/testdata/conformance/instance_measurement_reference_failures.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_measurement_reference_members.expected.json b/internal/exec/runtime/testdata/conformance/instance_measurement_reference_members.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_measurement_reference_members.expected.json rename to internal/exec/runtime/testdata/conformance/instance_measurement_reference_members.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_measurement_reference_members.sysml b/internal/exec/runtime/testdata/conformance/instance_measurement_reference_members.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_measurement_reference_members.sysml rename to internal/exec/runtime/testdata/conformance/instance_measurement_reference_members.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_measurement_references.expected.json b/internal/exec/runtime/testdata/conformance/instance_measurement_references.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_measurement_references.expected.json rename to internal/exec/runtime/testdata/conformance/instance_measurement_references.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_measurement_references.sysml b/internal/exec/runtime/testdata/conformance/instance_measurement_references.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_measurement_references.sysml rename to internal/exec/runtime/testdata/conformance/instance_measurement_references.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_measurement_scale_kinds.expected.json b/internal/exec/runtime/testdata/conformance/instance_measurement_scale_kinds.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_measurement_scale_kinds.expected.json rename to internal/exec/runtime/testdata/conformance/instance_measurement_scale_kinds.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_measurement_scale_kinds.sysml b/internal/exec/runtime/testdata/conformance/instance_measurement_scale_kinds.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_measurement_scale_kinds.sysml rename to internal/exec/runtime/testdata/conformance/instance_measurement_scale_kinds.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_nested_parts_run_to_quiescence.expected.json b/internal/exec/runtime/testdata/conformance/instance_nested_parts_run_to_quiescence.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_nested_parts_run_to_quiescence.expected.json rename to internal/exec/runtime/testdata/conformance/instance_nested_parts_run_to_quiescence.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_nested_parts_run_to_quiescence.sysml b/internal/exec/runtime/testdata/conformance/instance_nested_parts_run_to_quiescence.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_nested_parts_run_to_quiescence.sysml rename to internal/exec/runtime/testdata/conformance/instance_nested_parts_run_to_quiescence.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_nested_usage_body.expected.json b/internal/exec/runtime/testdata/conformance/instance_nested_usage_body.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_nested_usage_body.expected.json rename to internal/exec/runtime/testdata/conformance/instance_nested_usage_body.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_nested_usage_body.sysml b/internal/exec/runtime/testdata/conformance/instance_nested_usage_body.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_nested_usage_body.sysml rename to internal/exec/runtime/testdata/conformance/instance_nested_usage_body.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_nested_usage_outer_feature.expected.json b/internal/exec/runtime/testdata/conformance/instance_nested_usage_outer_feature.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_nested_usage_outer_feature.expected.json rename to internal/exec/runtime/testdata/conformance/instance_nested_usage_outer_feature.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_nested_usage_outer_feature.sysml b/internal/exec/runtime/testdata/conformance/instance_nested_usage_outer_feature.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_nested_usage_outer_feature.sysml rename to internal/exec/runtime/testdata/conformance/instance_nested_usage_outer_feature.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_quantity_calculation_failures.expected.json b/internal/exec/runtime/testdata/conformance/instance_quantity_calculation_failures.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_quantity_calculation_failures.expected.json rename to internal/exec/runtime/testdata/conformance/instance_quantity_calculation_failures.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_quantity_calculation_failures.sysml b/internal/exec/runtime/testdata/conformance/instance_quantity_calculation_failures.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_quantity_calculation_failures.sysml rename to internal/exec/runtime/testdata/conformance/instance_quantity_calculation_failures.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_quantity_calculations.expected.json b/internal/exec/runtime/testdata/conformance/instance_quantity_calculations.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_quantity_calculations.expected.json rename to internal/exec/runtime/testdata/conformance/instance_quantity_calculations.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_quantity_calculations.sysml b/internal/exec/runtime/testdata/conformance/instance_quantity_calculations.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_quantity_calculations.sysml rename to internal/exec/runtime/testdata/conformance/instance_quantity_calculations.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_quantity_coherent_units.expected.json b/internal/exec/runtime/testdata/conformance/instance_quantity_coherent_units.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_quantity_coherent_units.expected.json rename to internal/exec/runtime/testdata/conformance/instance_quantity_coherent_units.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_quantity_coherent_units.sysml b/internal/exec/runtime/testdata/conformance/instance_quantity_coherent_units.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_quantity_coherent_units.sysml rename to internal/exec/runtime/testdata/conformance/instance_quantity_coherent_units.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_quantity_dimension_mismatch.expected.json b/internal/exec/runtime/testdata/conformance/instance_quantity_dimension_mismatch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_quantity_dimension_mismatch.expected.json rename to internal/exec/runtime/testdata/conformance/instance_quantity_dimension_mismatch.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_quantity_dimension_mismatch.sysml b/internal/exec/runtime/testdata/conformance/instance_quantity_dimension_mismatch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_quantity_dimension_mismatch.sysml rename to internal/exec/runtime/testdata/conformance/instance_quantity_dimension_mismatch.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_quantity_trigonometry.expected.json b/internal/exec/runtime/testdata/conformance/instance_quantity_trigonometry.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_quantity_trigonometry.expected.json rename to internal/exec/runtime/testdata/conformance/instance_quantity_trigonometry.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_quantity_trigonometry.sysml b/internal/exec/runtime/testdata/conformance/instance_quantity_trigonometry.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_quantity_trigonometry.sysml rename to internal/exec/runtime/testdata/conformance/instance_quantity_trigonometry.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_redefined_attribute_default.expected.json b/internal/exec/runtime/testdata/conformance/instance_redefined_attribute_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_redefined_attribute_default.expected.json rename to internal/exec/runtime/testdata/conformance/instance_redefined_attribute_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_redefined_attribute_default.sysml b/internal/exec/runtime/testdata/conformance/instance_redefined_attribute_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_redefined_attribute_default.sysml rename to internal/exec/runtime/testdata/conformance/instance_redefined_attribute_default.sysml diff --git a/internal/exec/runtime/testdata/conformance/instance_reference_usage_not_materialized.expected.json b/internal/exec/runtime/testdata/conformance/instance_reference_usage_not_materialized.expected.json new file mode 100644 index 0000000000..20bf154f23 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_reference_usage_not_materialized.expected.json @@ -0,0 +1,12 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "ReferenceUsage::Rig", + "slots": { + "own.id": {"type": "Integer", "value": 1}, + "peer": {"error": "uninitialized feature value: peer"}, + "chosen": {"type": "Instance"}, + "chosen.id": {"type": "Integer", "value": 1} + }, + "materialization": {} +} diff --git a/internal/exec/runtime/testdata/conformance/instance_reference_usage_not_materialized.sysml b/internal/exec/runtime/testdata/conformance/instance_reference_usage_not_materialized.sysml new file mode 100644 index 0000000000..d721063500 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_reference_usage_not_materialized.sysml @@ -0,0 +1,13 @@ +package ReferenceUsage { + part def Node { + attribute id : ScalarValues::Integer; + } + + part def Rig { + part own : Node { + :>> id = 1; + } + ref part peer : Node; + ref part chosen : Node = own; + } +} diff --git a/internal/core/runtime/testdata/conformance/instance_set_quantity_attribute_derives_dimensions.expected.json b/internal/exec/runtime/testdata/conformance/instance_set_quantity_attribute_derives_dimensions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_set_quantity_attribute_derives_dimensions.expected.json rename to internal/exec/runtime/testdata/conformance/instance_set_quantity_attribute_derives_dimensions.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_set_quantity_attribute_derives_dimensions.sysml b/internal/exec/runtime/testdata/conformance/instance_set_quantity_attribute_derives_dimensions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_set_quantity_attribute_derives_dimensions.sysml rename to internal/exec/runtime/testdata/conformance/instance_set_quantity_attribute_derives_dimensions.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_tensor_quantity.expected.json b/internal/exec/runtime/testdata/conformance/instance_tensor_quantity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_tensor_quantity.expected.json rename to internal/exec/runtime/testdata/conformance/instance_tensor_quantity.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_tensor_quantity.sysml b/internal/exec/runtime/testdata/conformance/instance_tensor_quantity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_tensor_quantity.sysml rename to internal/exec/runtime/testdata/conformance/instance_tensor_quantity.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_tensor_quantity_failures.expected.json b/internal/exec/runtime/testdata/conformance/instance_tensor_quantity_failures.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_tensor_quantity_failures.expected.json rename to internal/exec/runtime/testdata/conformance/instance_tensor_quantity_failures.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_tensor_quantity_failures.sysml b/internal/exec/runtime/testdata/conformance/instance_tensor_quantity_failures.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_tensor_quantity_failures.sysml rename to internal/exec/runtime/testdata/conformance/instance_tensor_quantity_failures.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_tensor_rank_three.expected.json b/internal/exec/runtime/testdata/conformance/instance_tensor_rank_three.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_tensor_rank_three.expected.json rename to internal/exec/runtime/testdata/conformance/instance_tensor_rank_three.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_tensor_rank_three.sysml b/internal/exec/runtime/testdata/conformance/instance_tensor_rank_three.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_tensor_rank_three.sysml rename to internal/exec/runtime/testdata/conformance/instance_tensor_rank_three.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_tensor_rank_three_failures.expected.json b/internal/exec/runtime/testdata/conformance/instance_tensor_rank_three_failures.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_tensor_rank_three_failures.expected.json rename to internal/exec/runtime/testdata/conformance/instance_tensor_rank_three_failures.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_tensor_rank_three_failures.sysml b/internal/exec/runtime/testdata/conformance/instance_tensor_rank_three_failures.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_tensor_rank_three_failures.sysml rename to internal/exec/runtime/testdata/conformance/instance_tensor_rank_three_failures.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_time_scale_arithmetic.expected.json b/internal/exec/runtime/testdata/conformance/instance_time_scale_arithmetic.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_time_scale_arithmetic.expected.json rename to internal/exec/runtime/testdata/conformance/instance_time_scale_arithmetic.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_time_scale_arithmetic.sysml b/internal/exec/runtime/testdata/conformance/instance_time_scale_arithmetic.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_time_scale_arithmetic.sysml rename to internal/exec/runtime/testdata/conformance/instance_time_scale_arithmetic.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_unnamed_redefinition.expected.json b/internal/exec/runtime/testdata/conformance/instance_unnamed_redefinition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_unnamed_redefinition.expected.json rename to internal/exec/runtime/testdata/conformance/instance_unnamed_redefinition.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_unnamed_redefinition.sysml b/internal/exec/runtime/testdata/conformance/instance_unnamed_redefinition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_unnamed_redefinition.sysml rename to internal/exec/runtime/testdata/conformance/instance_unnamed_redefinition.sysml diff --git a/internal/core/runtime/testdata/conformance/instance_unset_quantity_attribute_materializes.expected.json b/internal/exec/runtime/testdata/conformance/instance_unset_quantity_attribute_materializes.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_unset_quantity_attribute_materializes.expected.json rename to internal/exec/runtime/testdata/conformance/instance_unset_quantity_attribute_materializes.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_unset_quantity_attribute_materializes.sysml b/internal/exec/runtime/testdata/conformance/instance_unset_quantity_attribute_materializes.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_unset_quantity_attribute_materializes.sysml rename to internal/exec/runtime/testdata/conformance/instance_unset_quantity_attribute_materializes.sysml diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_bounded.expected.json b/internal/exec/runtime/testdata/conformance/instance_validate_bounded.expected.json new file mode 100644 index 0000000000..081e293492 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_bounded.expected.json @@ -0,0 +1,13 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::node", + "validation": { + "bounded": true, + "verdicts": [ + {"kind": "constraint", "assertion": "assert constraint shallow", "status": "holds"}, + {"kind": "constraint", "assertion": "assert constraint shallow", "object": "next", "status": "holds"} + ], + "valid": false + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_bounded.sysml b/internal/exec/runtime/testdata/conformance/instance_validate_bounded.sysml new file mode 100644 index 0000000000..781ad2b52b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_bounded.sysml @@ -0,0 +1,15 @@ +// A type holding a part of its own type nests without end, so a validation +// stops at the part that would begin a type already being expanded, reporting +// the walk as bounded: the unreached nesting is unvalidated rather than valid. +// The assertions of the objects reached before the cut are still checked. +package test { + private import ScalarValues::Integer; + + part def Node { + attribute depth : Integer = 1; + part next : Node; + assert constraint shallow { depth < 3 } + } + + part node : Node; +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_holds.expected.json b/internal/exec/runtime/testdata/conformance/instance_validate_holds.expected.json new file mode 100644 index 0000000000..b437dab9c9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_holds.expected.json @@ -0,0 +1,11 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::spare", + "validation": { + "verdicts": [ + {"kind": "constraint", "assertion": "assert constraint pressureOk", "status": "holds"} + ], + "valid": true + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_holds.sysml b/internal/exec/runtime/testdata/conformance/instance_validate_holds.sysml new file mode 100644 index 0000000000..5bff6e3a4a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_holds.sysml @@ -0,0 +1,12 @@ +// An object every assertion about which holds is valid: the asserted constraint +// its type declares holds on the object's own value, and the walk is complete. +package test { + private import ScalarValues::Real; + + part def Wheel { + attribute pressure : Real default = 32.0; + assert constraint pressureOk { pressure >= 30.0 } + } + + part spare : Wheel; +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_nested_subject_order.expected.json b/internal/exec/runtime/testdata/conformance/instance_validate_nested_subject_order.expected.json new file mode 100644 index 0000000000..24887cd713 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_nested_subject_order.expected.json @@ -0,0 +1,14 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::big", + "validation": { + "verdicts": [ + {"kind": "constraint", "assertion": "assert constraint one", "status": "holds"}, + {"kind": "constraint", "assertion": "assert constraint positive", "object": "inner", "status": "holds"}, + {"kind": "requirement", "assertion": "requirement lim", "object": "inner", "status": "holds"}, + {"kind": "satisfaction", "assertion": "satisfy Inner::lim by big", "object": "inner", "status": "holds"} + ], + "valid": true + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_nested_subject_order.sysml b/internal/exec/runtime/testdata/conformance/instance_validate_nested_subject_order.sysml new file mode 100644 index 0000000000..1c077624cc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_nested_subject_order.sysml @@ -0,0 +1,23 @@ +// A satisfaction asserted `by` an object whose nested part carries the requirement +// is about that nested object, and is reported with it: after the holder's own +// verdicts and after the nested object's carried ones, not in the holder's group. +package test { + private import ScalarValues::Real; + + part def Inner { + attribute c : Real = 2.0; + assert constraint positive { c > 0.0 } + requirement lim { + require constraint { c < 5.0 } + } + } + + part def Outer { + attribute n : Real = 1.0; + assert constraint one { n == 1.0 } + part inner : Inner; + } + + part big : Outer; + satisfy Inner::lim by big; +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_nested_tree.expected.json b/internal/exec/runtime/testdata/conformance/instance_validate_nested_tree.expected.json new file mode 100644 index 0000000000..6a391e42be --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_nested_tree.expected.json @@ -0,0 +1,18 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::car", + "validation": { + "verdicts": [ + {"kind": "constraint", "assertion": "assert constraint massOk", "status": "holds"}, + {"kind": "requirement", "assertion": "requirement lightEnough", "status": "holds"}, + {"kind": "constraint", "assertion": "assert constraint", "object": "engine", "status": "violated", "error": "evaluated to false"}, + {"kind": "satisfaction", "assertion": "satisfy strongEngine by car.engine", "object": "engine", "status": "holds"}, + {"kind": "constraint", "assertion": "assert constraint ratePositive", "object": "engine.injector", "status": "holds"}, + {"kind": "constraint", "assertion": "assert constraint pressureOk", "object": "wheels[1]", "status": "violated", "error": "evaluated to false"}, + {"kind": "constraint", "assertion": "assert constraint pressureOk", "object": "wheels[2]", "status": "violated", "error": "evaluated to false"}, + {"kind": "constraint", "assertion": "assert constraint pressureOk", "object": "wheels[3]", "status": "violated", "error": "evaluated to false"} + ], + "valid": false + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_nested_tree.sysml b/internal/exec/runtime/testdata/conformance/instance_validate_nested_tree.sysml new file mode 100644 index 0000000000..1c5542a180 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_nested_tree.sysml @@ -0,0 +1,49 @@ +// Validating an object as a whole checks every assertion about it and about the +// objects it holds, to any depth: the asserted constraints of each object's type, +// the requirement usages it carries, and the satisfaction assertions whose +// subject is one of those objects. Each verdict names the object it is about by +// the path walked to it, a collection element indexed. Here the car's own +// assertion holds, the engine's fails at depth two, the collection wheel that +// redefines its pressure fails at depth two, the injector at depth three holds, +// and the satisfaction stated about the engine — a nested subject — holds. +package test { + private import ScalarValues::Real; + + part def Injector { + attribute rate : Real = 2.0; + assert constraint ratePositive { rate > 0.0 } + } + + part def Engine { + attribute power : Real = 300.0; + part injector : Injector; + assert constraint { power < 200.0 } + } + + part def Wheel { + attribute pressure : Real default = 32.0; + assert constraint pressureOk { pressure >= 30.0 } + } + + part def Car { + attribute mass : Real = 1500.0; + part engine : Engine; + part wheels : Wheel[3] { + attribute :>> pressure = 20.0; + } + assert constraint massOk { mass < 2000.0 } + requirement lightEnough { + attribute m : Real = mass; + require constraint { m < 1600.0 } + } + } + + requirement def PowerReq { + subject e : Engine; + require constraint { e.power > 100.0 } + } + + part car : Car; + requirement strongEngine : PowerReq; + satisfy strongEngine by car.engine; +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_no_assertion.expected.json b/internal/exec/runtime/testdata/conformance/instance_validate_no_assertion.expected.json new file mode 100644 index 0000000000..5ef8ba836d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_no_assertion.expected.json @@ -0,0 +1,9 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::crate", + "validation": { + "verdicts": [], + "valid": false + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_no_assertion.sysml b/internal/exec/runtime/testdata/conformance/instance_validate_no_assertion.sysml new file mode 100644 index 0000000000..f09b243ee1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_no_assertion.sysml @@ -0,0 +1,13 @@ +// An object no assertion is about is not shown valid: there is nothing to hold, +// so the validation decides nothing rather than answering valid. A named +// constraint declared without `assert` states no invariant and is not swept. +package test { + private import ScalarValues::Real; + + part def Crate { + attribute mass : Real = 4.0; + constraint light { mass < 1.0 } + } + + part crate : Crate; +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_optional_recursion.expected.json b/internal/exec/runtime/testdata/conformance/instance_validate_optional_recursion.expected.json new file mode 100644 index 0000000000..383b530a4c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_optional_recursion.expected.json @@ -0,0 +1,11 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::chain", + "validation": { + "verdicts": [ + {"kind": "constraint", "assertion": "assert constraint shallow", "object": "head", "status": "holds"} + ], + "valid": true + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_optional_recursion.sysml b/internal/exec/runtime/testdata/conformance/instance_validate_optional_recursion.sysml new file mode 100644 index 0000000000..71378d716a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_optional_recursion.sysml @@ -0,0 +1,19 @@ +// A part of the type being expanded above it is cut only where reading it would +// begin objects without end. An optional one holds nothing of itself, so the walk +// reads it, finds no nesting to leave unreached, and shows the finite object valid. +package test { + private import ScalarValues::Integer; + + part def Node { + attribute depth : Integer = 1; + part next : Node[0..1]; + part more : Node[0..*]; + assert constraint shallow { depth < 3 } + } + + part def Chain { + part head : Node; + } + + part chain : Chain; +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_same_type_parts.expected.json b/internal/exec/runtime/testdata/conformance/instance_validate_same_type_parts.expected.json new file mode 100644 index 0000000000..be80c7d6ef --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_same_type_parts.expected.json @@ -0,0 +1,15 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::rig", + "validation": { + "verdicts": [ + {"kind": "constraint", "assertion": "assert constraint loadOk", "object": "front", "status": "holds"}, + {"kind": "constraint", "assertion": "assert constraint loadOk", "object": "rear", "status": "violated", "error": "evaluated to false"}, + {"kind": "satisfaction", "assertion": "satisfy rearLoad by rig.rear", "object": "rear", "status": "violated", "error": "evaluated to false"}, + {"kind": "constraint", "assertion": "inv heightPositive", "object": "body", "status": "holds"}, + {"kind": "constraint", "assertion": "assert not constraint", "object": "body", "status": "holds"} + ], + "valid": false + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_same_type_parts.sysml b/internal/exec/runtime/testdata/conformance/instance_validate_same_type_parts.sysml new file mode 100644 index 0000000000..1b8da7743b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_same_type_parts.sysml @@ -0,0 +1,41 @@ +// Two parts of one type each carry that type's assertions, and a validation +// tells them apart by path: the rear axle redefines its load over the limit, so +// only its verdict is violated. An inherited assertion is checked on the object +// once, an unasserted constraint usage is a definition rather than an assertion +// and goes unchecked, and a negated assertion holds when its condition is +// false. The satisfaction stated about the rear axle is about that object alone. +package test { + private import ScalarValues::Real; + + part def Axle { + attribute load : Real default = 50.0; + assert constraint loadOk { load < 100.0 } + } + + part def Body { + attribute height : Real = 1.0; + inv heightPositive { height > 0.0 } + } + + part def LowBody :> Body { + assert not constraint { height > 2.0 } + constraint unasserted { height > 100.0 } + } + + requirement def LoadReq { + subject a : Axle; + require constraint { a.load <= 120.0 } + } + + part def Rig { + part front : Axle; + part rear : Axle { + attribute :>> load = 150.0; + } + part body : LowBody; + } + + part rig : Rig; + requirement rearLoad : LoadReq; + satisfy rearLoad by rig.rear; +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_shared_object.expected.json b/internal/exec/runtime/testdata/conformance/instance_validate_shared_object.expected.json new file mode 100644 index 0000000000..f898e10f88 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_shared_object.expected.json @@ -0,0 +1,13 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::car", + "validation": { + "verdicts": [ + {"kind": "constraint", "assertion": "assert constraint", "object": "wheel", "status": "holds"}, + {"kind": "satisfaction", "assertion": "satisfy inflated by car.spare", "object": "wheel", "status": "violated", "error": "evaluated to false"}, + {"kind": "satisfaction", "assertion": "satisfy torqued by car.spare.hub", "object": "wheel.hub", "status": "holds"} + ], + "valid": false + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_shared_object.sysml b/internal/exec/runtime/testdata/conformance/instance_validate_shared_object.sysml new file mode 100644 index 0000000000..a3caa13b36 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_shared_object.sysml @@ -0,0 +1,38 @@ +// One object two features hold is validated once, under the path it was first +// reached by, but a satisfaction stated about either feature is about it: the +// reference `spare` is bound to the composite `wheel`, and both assertions +// name the reference, one of them chaining on through it to the hub. +package test { + private import ScalarValues::Real; + + part def Hub { + attribute torque : Real = 90.0; + } + + part def Wheel { + attribute pressure : Real default = 20.0; + part hub : Hub; + assert constraint { pressure > 0.0 } + } + + requirement def Inflated { + subject w : Wheel; + require constraint { w.pressure >= 30.0 } + } + + requirement def Torqued { + subject h : Hub; + require constraint { h.torque >= 80.0 } + } + + part def Car { + part wheel : Wheel; + ref part spare : Wheel = wheel; + } + + part car : Car; + requirement inflated : Inflated; + requirement torqued : Torqued; + satisfy inflated by car.spare; + satisfy torqued by car.spare.hub; +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_undecided.expected.json b/internal/exec/runtime/testdata/conformance/instance_validate_undecided.expected.json new file mode 100644 index 0000000000..ae6d8d0085 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_undecided.expected.json @@ -0,0 +1,13 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::tank", + "validation": { + "verdicts": [ + {"kind": "constraint", "assertion": "assert constraint levelPositive", "status": "holds"}, + {"kind": "constraint", "assertion": "assert constraint fits", "status": "undecided", "error": "capacity"}, + {"kind": "requirement", "assertion": "requirement roomLeft", "status": "undecided", "error": "no value for feature c"} + ], + "valid": false + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_undecided.sysml b/internal/exec/runtime/testdata/conformance/instance_validate_undecided.sysml new file mode 100644 index 0000000000..c77250d4d6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_undecided.sysml @@ -0,0 +1,20 @@ +// An assertion that cannot be evaluated is undecided, not violated: the tank's +// capacity has no value, so the constraint reading it and the requirement bound +// to it decide nothing, while the assertion reading a valued feature holds. An +// undecided verdict leaves the object not valid, since nothing was shown. +package test { + private import ScalarValues::Real; + + part def Tank { + attribute capacity : Real; + attribute level : Real = 10.0; + assert constraint levelPositive { level > 0.0 } + assert constraint fits { level <= capacity } + requirement roomLeft { + attribute c : Real = capacity; + require constraint { c > 0.0 } + } + } + + part tank : Tank; +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_verdict_order.expected.json b/internal/exec/runtime/testdata/conformance/instance_validate_verdict_order.expected.json new file mode 100644 index 0000000000..7f9e9c6531 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_verdict_order.expected.json @@ -0,0 +1,15 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::car", + "validation": { + "verdicts": [ + {"kind": "requirement", "assertion": "requirement light", "status": "holds"}, + {"kind": "satisfaction", "assertion": "satisfy light", "status": "holds"}, + {"kind": "satisfaction", "assertion": "satisfy alsoLight by car", "status": "holds"}, + {"kind": "constraint", "assertion": "assert constraint powered", "object": "engine", "status": "holds"}, + {"kind": "satisfaction", "assertion": "satisfy strong by car.engine", "object": "engine", "status": "holds"} + ], + "valid": true + } +} diff --git a/internal/exec/runtime/testdata/conformance/instance_validate_verdict_order.sysml b/internal/exec/runtime/testdata/conformance/instance_validate_verdict_order.sysml new file mode 100644 index 0000000000..a2abf4204e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/instance_validate_verdict_order.sysml @@ -0,0 +1,37 @@ +// Verdicts go out object by object, root first and then each held object as the +// walk reaches it, whatever kind of assertion each is: the satisfactions about +// the root — one its type carries, one stated in the scope — precede the +// constraint asserted on the engine it holds, and the one about the engine follows it. +package test { + private import ScalarValues::Real; + + part def Engine { + attribute power : Real = 300.0; + assert constraint powered { power > 0.0 } + } + + requirement def Light { + subject c : Car; + require constraint { c.mass < 2000.0 } + } + + requirement def Strong { + subject e : Engine; + require constraint { e.power > 100.0 } + } + + part def Car { + attribute mass : Real = 1500.0; + part engine : Engine; + requirement light { + require constraint { mass < 1600.0 } + } + assert satisfy light; + } + + part car : Car; + requirement strong : Strong; + requirement alsoLight : Light; + satisfy alsoLight by car; + satisfy strong by car.engine; +} diff --git a/internal/core/runtime/testdata/conformance/instance_valueless_value_typed_attribute.expected.json b/internal/exec/runtime/testdata/conformance/instance_valueless_value_typed_attribute.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_valueless_value_typed_attribute.expected.json rename to internal/exec/runtime/testdata/conformance/instance_valueless_value_typed_attribute.expected.json diff --git a/internal/core/runtime/testdata/conformance/instance_valueless_value_typed_attribute.sysml b/internal/exec/runtime/testdata/conformance/instance_valueless_value_typed_attribute.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/instance_valueless_value_typed_attribute.sysml rename to internal/exec/runtime/testdata/conformance/instance_valueless_value_typed_attribute.sysml diff --git a/internal/core/runtime/testdata/conformance/known_failures.txt b/internal/exec/runtime/testdata/conformance/known_failures.txt similarity index 100% rename from internal/core/runtime/testdata/conformance/known_failures.txt rename to internal/exec/runtime/testdata/conformance/known_failures.txt diff --git a/internal/core/runtime/testdata/conformance/library_bag_elements.expected.json b/internal/exec/runtime/testdata/conformance/library_bag_elements.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_bag_elements.expected.json rename to internal/exec/runtime/testdata/conformance/library_bag_elements.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_bag_elements.sysml b/internal/exec/runtime/testdata/conformance/library_bag_elements.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_bag_elements.sysml rename to internal/exec/runtime/testdata/conformance/library_bag_elements.sysml diff --git a/internal/core/runtime/testdata/conformance/library_map_elements.expected.json b/internal/exec/runtime/testdata/conformance/library_map_elements.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_map_elements.expected.json rename to internal/exec/runtime/testdata/conformance/library_map_elements.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_map_elements.sysml b/internal/exec/runtime/testdata/conformance/library_map_elements.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_map_elements.sysml rename to internal/exec/runtime/testdata/conformance/library_map_elements.sysml diff --git a/internal/core/runtime/testdata/conformance/library_ordered_map_elements_repeated.expected.json b/internal/exec/runtime/testdata/conformance/library_ordered_map_elements_repeated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_ordered_map_elements_repeated.expected.json rename to internal/exec/runtime/testdata/conformance/library_ordered_map_elements_repeated.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_ordered_map_elements_repeated.sysml b/internal/exec/runtime/testdata/conformance/library_ordered_map_elements_repeated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_ordered_map_elements_repeated.sysml rename to internal/exec/runtime/testdata/conformance/library_ordered_map_elements_repeated.sysml diff --git a/internal/core/runtime/testdata/conformance/library_ordered_set_elements.expected.json b/internal/exec/runtime/testdata/conformance/library_ordered_set_elements.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_ordered_set_elements.expected.json rename to internal/exec/runtime/testdata/conformance/library_ordered_set_elements.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_ordered_set_elements.sysml b/internal/exec/runtime/testdata/conformance/library_ordered_set_elements.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_ordered_set_elements.sysml rename to internal/exec/runtime/testdata/conformance/library_ordered_set_elements.sysml diff --git a/internal/core/runtime/testdata/conformance/library_ordered_set_elements_repeated.expected.json b/internal/exec/runtime/testdata/conformance/library_ordered_set_elements_repeated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_ordered_set_elements_repeated.expected.json rename to internal/exec/runtime/testdata/conformance/library_ordered_set_elements_repeated.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_ordered_set_elements_repeated.sysml b/internal/exec/runtime/testdata/conformance/library_ordered_set_elements_repeated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_ordered_set_elements_repeated.sysml rename to internal/exec/runtime/testdata/conformance/library_ordered_set_elements_repeated.sysml diff --git a/internal/core/runtime/testdata/conformance/library_set_elements.expected.json b/internal/exec/runtime/testdata/conformance/library_set_elements.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_set_elements.expected.json rename to internal/exec/runtime/testdata/conformance/library_set_elements.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_set_elements.sysml b/internal/exec/runtime/testdata/conformance/library_set_elements.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_set_elements.sysml rename to internal/exec/runtime/testdata/conformance/library_set_elements.sysml diff --git a/internal/core/runtime/testdata/conformance/library_set_elements_already_distinct.expected.json b/internal/exec/runtime/testdata/conformance/library_set_elements_already_distinct.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_set_elements_already_distinct.expected.json rename to internal/exec/runtime/testdata/conformance/library_set_elements_already_distinct.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_set_elements_already_distinct.sysml b/internal/exec/runtime/testdata/conformance/library_set_elements_already_distinct.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_set_elements_already_distinct.sysml rename to internal/exec/runtime/testdata/conformance/library_set_elements_already_distinct.sysml diff --git a/internal/core/runtime/testdata/conformance/library_set_elements_empty.expected.json b/internal/exec/runtime/testdata/conformance/library_set_elements_empty.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_set_elements_empty.expected.json rename to internal/exec/runtime/testdata/conformance/library_set_elements_empty.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_set_elements_empty.sysml b/internal/exec/runtime/testdata/conformance/library_set_elements_empty.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_set_elements_empty.sysml rename to internal/exec/runtime/testdata/conformance/library_set_elements_empty.sysml diff --git a/internal/core/runtime/testdata/conformance/library_set_operations.expected.json b/internal/exec/runtime/testdata/conformance/library_set_operations.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_set_operations.expected.json rename to internal/exec/runtime/testdata/conformance/library_set_operations.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_set_operations.sysml b/internal/exec/runtime/testdata/conformance/library_set_operations.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_set_operations.sysml rename to internal/exec/runtime/testdata/conformance/library_set_operations.sysml diff --git a/internal/core/runtime/testdata/conformance/library_set_sequence_functions.expected.json b/internal/exec/runtime/testdata/conformance/library_set_sequence_functions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_set_sequence_functions.expected.json rename to internal/exec/runtime/testdata/conformance/library_set_sequence_functions.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_set_sequence_functions.sysml b/internal/exec/runtime/testdata/conformance/library_set_sequence_functions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_set_sequence_functions.sysml rename to internal/exec/runtime/testdata/conformance/library_set_sequence_functions.sysml diff --git a/internal/core/runtime/testdata/conformance/library_unique_collection_elements.expected.json b/internal/exec/runtime/testdata/conformance/library_unique_collection_elements.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/library_unique_collection_elements.expected.json rename to internal/exec/runtime/testdata/conformance/library_unique_collection_elements.expected.json diff --git a/internal/core/runtime/testdata/conformance/library_unique_collection_elements.sysml b/internal/exec/runtime/testdata/conformance/library_unique_collection_elements.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/library_unique_collection_elements.sysml rename to internal/exec/runtime/testdata/conformance/library_unique_collection_elements.sysml diff --git a/internal/core/runtime/testdata/conformance/meta_cast.expected.json b/internal/exec/runtime/testdata/conformance/meta_cast.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/meta_cast.expected.json rename to internal/exec/runtime/testdata/conformance/meta_cast.expected.json diff --git a/internal/core/runtime/testdata/conformance/meta_cast.sysml b/internal/exec/runtime/testdata/conformance/meta_cast.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/meta_cast.sysml rename to internal/exec/runtime/testdata/conformance/meta_cast.sysml diff --git a/internal/core/runtime/testdata/conformance/meta_cast_trace.expected.json b/internal/exec/runtime/testdata/conformance/meta_cast_trace.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/meta_cast_trace.expected.json rename to internal/exec/runtime/testdata/conformance/meta_cast_trace.expected.json diff --git a/internal/core/runtime/testdata/conformance/meta_cast_trace.sysml b/internal/exec/runtime/testdata/conformance/meta_cast_trace.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/meta_cast_trace.sysml rename to internal/exec/runtime/testdata/conformance/meta_cast_trace.sysml diff --git a/internal/core/runtime/testdata/conformance/meta_cast_trace.trace.golden b/internal/exec/runtime/testdata/conformance/meta_cast_trace.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/meta_cast_trace.trace.golden rename to internal/exec/runtime/testdata/conformance/meta_cast_trace.trace.golden diff --git a/internal/core/runtime/testdata/conformance/metadata_access_annotations.expected.json b/internal/exec/runtime/testdata/conformance/metadata_access_annotations.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/metadata_access_annotations.expected.json rename to internal/exec/runtime/testdata/conformance/metadata_access_annotations.expected.json diff --git a/internal/core/runtime/testdata/conformance/metadata_access_annotations.sysml b/internal/exec/runtime/testdata/conformance/metadata_access_annotations.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/metadata_access_annotations.sysml rename to internal/exec/runtime/testdata/conformance/metadata_access_annotations.sysml diff --git a/internal/core/runtime/testdata/conformance/metadata_access_textual_order.expected.json b/internal/exec/runtime/testdata/conformance/metadata_access_textual_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/metadata_access_textual_order.expected.json rename to internal/exec/runtime/testdata/conformance/metadata_access_textual_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/metadata_access_textual_order.sysml b/internal/exec/runtime/testdata/conformance/metadata_access_textual_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/metadata_access_textual_order.sysml rename to internal/exec/runtime/testdata/conformance/metadata_access_textual_order.sysml diff --git a/internal/core/runtime/testdata/conformance/multiplicity_default_assumed.expected.json b/internal/exec/runtime/testdata/conformance/multiplicity_default_assumed.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_default_assumed.expected.json rename to internal/exec/runtime/testdata/conformance/multiplicity_default_assumed.expected.json diff --git a/internal/core/runtime/testdata/conformance/multiplicity_default_assumed.sysml b/internal/exec/runtime/testdata/conformance/multiplicity_default_assumed.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_default_assumed.sysml rename to internal/exec/runtime/testdata/conformance/multiplicity_default_assumed.sysml diff --git a/internal/core/runtime/testdata/conformance/multiplicity_default_composite.expected.json b/internal/exec/runtime/testdata/conformance/multiplicity_default_composite.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_default_composite.expected.json rename to internal/exec/runtime/testdata/conformance/multiplicity_default_composite.expected.json diff --git a/internal/core/runtime/testdata/conformance/multiplicity_default_composite.sysml b/internal/exec/runtime/testdata/conformance/multiplicity_default_composite.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_default_composite.sysml rename to internal/exec/runtime/testdata/conformance/multiplicity_default_composite.sysml diff --git a/internal/core/runtime/testdata/conformance/multiplicity_default_merged.expected.json b/internal/exec/runtime/testdata/conformance/multiplicity_default_merged.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_default_merged.expected.json rename to internal/exec/runtime/testdata/conformance/multiplicity_default_merged.expected.json diff --git a/internal/core/runtime/testdata/conformance/multiplicity_default_merged.sysml b/internal/exec/runtime/testdata/conformance/multiplicity_default_merged.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_default_merged.sysml rename to internal/exec/runtime/testdata/conformance/multiplicity_default_merged.sysml diff --git a/internal/core/runtime/testdata/conformance/multiplicity_default_nonconforming.expected.json b/internal/exec/runtime/testdata/conformance/multiplicity_default_nonconforming.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_default_nonconforming.expected.json rename to internal/exec/runtime/testdata/conformance/multiplicity_default_nonconforming.expected.json diff --git a/internal/core/runtime/testdata/conformance/multiplicity_default_nonconforming.sysml b/internal/exec/runtime/testdata/conformance/multiplicity_default_nonconforming.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_default_nonconforming.sysml rename to internal/exec/runtime/testdata/conformance/multiplicity_default_nonconforming.sysml diff --git a/internal/core/runtime/testdata/conformance/multiplicity_default_redefinition.expected.json b/internal/exec/runtime/testdata/conformance/multiplicity_default_redefinition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_default_redefinition.expected.json rename to internal/exec/runtime/testdata/conformance/multiplicity_default_redefinition.expected.json diff --git a/internal/core/runtime/testdata/conformance/multiplicity_default_redefinition.sysml b/internal/exec/runtime/testdata/conformance/multiplicity_default_redefinition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_default_redefinition.sysml rename to internal/exec/runtime/testdata/conformance/multiplicity_default_redefinition.sysml diff --git a/internal/core/runtime/testdata/conformance/multiplicity_rollup_counts_each_instance_once.expected.json b/internal/exec/runtime/testdata/conformance/multiplicity_rollup_counts_each_instance_once.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_rollup_counts_each_instance_once.expected.json rename to internal/exec/runtime/testdata/conformance/multiplicity_rollup_counts_each_instance_once.expected.json diff --git a/internal/core/runtime/testdata/conformance/multiplicity_rollup_counts_each_instance_once.sysml b/internal/exec/runtime/testdata/conformance/multiplicity_rollup_counts_each_instance_once.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_rollup_counts_each_instance_once.sysml rename to internal/exec/runtime/testdata/conformance/multiplicity_rollup_counts_each_instance_once.sysml diff --git a/internal/core/runtime/testdata/conformance/multiplicity_unbounded_single_bound.expected.json b/internal/exec/runtime/testdata/conformance/multiplicity_unbounded_single_bound.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_unbounded_single_bound.expected.json rename to internal/exec/runtime/testdata/conformance/multiplicity_unbounded_single_bound.expected.json diff --git a/internal/core/runtime/testdata/conformance/multiplicity_unbounded_single_bound.sysml b/internal/exec/runtime/testdata/conformance/multiplicity_unbounded_single_bound.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/multiplicity_unbounded_single_bound.sysml rename to internal/exec/runtime/testdata/conformance/multiplicity_unbounded_single_bound.sysml diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_inherited_leaf.expected.json b/internal/exec/runtime/testdata/conformance/nested_redefinition_inherited_leaf.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_inherited_leaf.expected.json rename to internal/exec/runtime/testdata/conformance/nested_redefinition_inherited_leaf.expected.json diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_inherited_leaf.sysml b/internal/exec/runtime/testdata/conformance/nested_redefinition_inherited_leaf.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_inherited_leaf.sysml rename to internal/exec/runtime/testdata/conformance/nested_redefinition_inherited_leaf.sysml diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_inherited_leaf_subject.expected.json b/internal/exec/runtime/testdata/conformance/nested_redefinition_inherited_leaf_subject.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_inherited_leaf_subject.expected.json rename to internal/exec/runtime/testdata/conformance/nested_redefinition_inherited_leaf_subject.expected.json diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_inherited_leaf_subject.sysml b/internal/exec/runtime/testdata/conformance/nested_redefinition_inherited_leaf_subject.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_inherited_leaf_subject.sysml rename to internal/exec/runtime/testdata/conformance/nested_redefinition_inherited_leaf_subject.sysml diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_no_object.expected.json b/internal/exec/runtime/testdata/conformance/nested_redefinition_no_object.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_no_object.expected.json rename to internal/exec/runtime/testdata/conformance/nested_redefinition_no_object.expected.json diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_no_object.sysml b/internal/exec/runtime/testdata/conformance/nested_redefinition_no_object.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_no_object.sysml rename to internal/exec/runtime/testdata/conformance/nested_redefinition_no_object.sysml diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_object_sibling.expected.json b/internal/exec/runtime/testdata/conformance/nested_redefinition_object_sibling.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_object_sibling.expected.json rename to internal/exec/runtime/testdata/conformance/nested_redefinition_object_sibling.expected.json diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_object_sibling.sysml b/internal/exec/runtime/testdata/conformance/nested_redefinition_object_sibling.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_object_sibling.sysml rename to internal/exec/runtime/testdata/conformance/nested_redefinition_object_sibling.sysml diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_object_subject.expected.json b/internal/exec/runtime/testdata/conformance/nested_redefinition_object_subject.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_object_subject.expected.json rename to internal/exec/runtime/testdata/conformance/nested_redefinition_object_subject.expected.json diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_object_subject.sysml b/internal/exec/runtime/testdata/conformance/nested_redefinition_object_subject.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_object_subject.sysml rename to internal/exec/runtime/testdata/conformance/nested_redefinition_object_subject.sysml diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_two_levels.expected.json b/internal/exec/runtime/testdata/conformance/nested_redefinition_two_levels.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_two_levels.expected.json rename to internal/exec/runtime/testdata/conformance/nested_redefinition_two_levels.expected.json diff --git a/internal/core/runtime/testdata/conformance/nested_redefinition_two_levels.sysml b/internal/exec/runtime/testdata/conformance/nested_redefinition_two_levels.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/nested_redefinition_two_levels.sysml rename to internal/exec/runtime/testdata/conformance/nested_redefinition_two_levels.sysml diff --git a/internal/core/runtime/testdata/conformance/object_addressed_send_one_sibling.expected.json b/internal/exec/runtime/testdata/conformance/object_addressed_send_one_sibling.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/object_addressed_send_one_sibling.expected.json rename to internal/exec/runtime/testdata/conformance/object_addressed_send_one_sibling.expected.json diff --git a/internal/core/runtime/testdata/conformance/object_addressed_send_one_sibling.sysml b/internal/exec/runtime/testdata/conformance/object_addressed_send_one_sibling.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/object_addressed_send_one_sibling.sysml rename to internal/exec/runtime/testdata/conformance/object_addressed_send_one_sibling.sysml diff --git a/internal/exec/runtime/testdata/conformance/object_created_by_constructor.expected.json b/internal/exec/runtime/testdata/conformance/object_created_by_constructor.expected.json new file mode 100644 index 0000000000..587fb0ca8f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/object_created_by_constructor.expected.json @@ -0,0 +1,15 @@ +{ + "type": "instance", + "libraries": true, + "trace": true, + "instantiate": "test::Fleet", + "slots": { + "count": {"type": "Integer", "value": 2}, + "extent": {"type": "Integer", "value": 3}, + "distinct": {"type": "Boolean", "value": true}, + "secondN": {"type": "Integer", "value": 2}, + "secondWheels": {"type": "Integer", "value": 4}, + "secondAlive": {"type": "Boolean", "value": true}, + "secondIsCar": {"type": "Boolean", "value": true} + } +} diff --git a/internal/exec/runtime/testdata/conformance/object_created_by_constructor.sysml b/internal/exec/runtime/testdata/conformance/object_created_by_constructor.sysml new file mode 100644 index 0000000000..aad4acf4b8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/object_created_by_constructor.sysml @@ -0,0 +1,46 @@ +// A constructor expression `new T(...)` (KerML §7.4.9) materializes a fresh object +// each time it is evaluated: two objects of one definition coexist in one context, +// distinct from each other and from any declared part, each a live occurrence that +// starts its classifier's behaviors, held by the feature the action writes it into +// and in the extent of its definition. +package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + private import SequenceFunctions::*; + + part def Car { + attribute n : Integer; + attribute wheels : Integer = 4; + exhibit state running { entry; then idle; state idle; } + } + + part def Fleet { + part cars : Car[0..*]; + part flagship : Car; + attribute count : Integer; + attribute extent : Integer; + attribute distinct : Boolean; + attribute secondN : Integer; + attribute secondWheels : Integer; + attribute secondAlive : Boolean; + attribute secondIsCar : Boolean; + + perform action build { + first start; + then action make { + assign cars := addNew(cars, new Car(1)); + assign cars := addNew(cars, new Car(2)); + } + then action measure { + assign count := size(cars); + assign extent := size(all Car); + assign distinct := not (cars#(1) === cars#(2)) and not (cars#(1) === flagship); + assign secondN := cars#(2).n; + assign secondWheels := cars#(2).wheels; + assign secondAlive := isDuring(cars#(2)); + assign secondIsCar := cars#(2) istype Car; + } + then done; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/object_created_by_constructor.trace.golden b/internal/exec/runtime/testdata/conformance/object_created_by_constructor.trace.golden new file mode 100644 index 0000000000..0454f6ba4d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/object_created_by_constructor.trace.golden @@ -0,0 +1,94 @@ +materialize: Fleet #1 +start: performed action build of #1 +materialize: build #2 +materialize: Car #3 +start: exhibited state machine running of #3 +materialize: running #4 +enter: idle +run: performed action build of #1 +step 1: token 1@make +stmt assign cars + enter calc OccurrenceFunctions::addNew + eval feature cars -> () + eval literal 1 -> 1 +materialize: Car #5 +start: exhibited state machine running of #5 +materialize: running #6 +enter: idle + eval construct Car -> instance#5 + bind group = () [argument] + bind occ = instance#5 [argument] +create: Car #5 + exit calc OccurrenceFunctions::addNew -> (instance#5) + eval invoke addNew -> (instance#5) +stmt assign cars + enter calc OccurrenceFunctions::addNew + eval feature cars -> (instance#5) + eval literal 2 -> 2 +materialize: Car #7 +start: exhibited state machine running of #7 +materialize: running #8 +enter: idle + eval construct Car -> instance#7 + bind group = (instance#5) [argument] + bind occ = instance#7 [argument] +create: Car #7 + exit calc OccurrenceFunctions::addNew -> (instance#5, instance#7) + eval invoke addNew -> (instance#5, instance#7) +step 2: token 1@measure +stmt assign count + enter calc SequenceFunctions::size + eval feature cars -> (instance#5, instance#7) + bind seq = (instance#5, instance#7) [argument] + exit calc SequenceFunctions::size -> 2 + eval invoke size -> 2 +stmt assign extent + enter calc SequenceFunctions::size + eval operator all -> (instance#5, instance#7, instance#3) + bind seq = (instance#5, instance#7, instance#3) [argument] + exit calc SequenceFunctions::size -> 3 + eval invoke size -> 3 +stmt assign distinct + eval feature cars -> (instance#5, instance#7) + eval literal 1 -> 1 + eval index -> instance#5 + eval feature cars -> (instance#5, instance#7) + eval literal 2 -> 2 + eval index -> instance#7 + eval operator === -> false + eval operator not -> true + eval feature cars -> (instance#5, instance#7) + eval literal 1 -> 1 + eval index -> instance#5 + eval feature flagship -> instance#3 + eval operator === -> false + eval operator not -> true + eval operator and -> true +stmt assign secondN + eval feature cars -> (instance#5, instance#7) + eval literal 2 -> 2 + eval index -> instance#7 + eval chain n -> 2 +stmt assign secondWheels + eval feature cars -> (instance#5, instance#7) + eval literal 2 -> 2 + eval index -> instance#7 + eval chain wheels -> 4 +stmt assign secondAlive + enter calc OccurrenceFunctions::isDuring + eval feature cars -> (instance#5, instance#7) + eval literal 2 -> 2 + eval index -> instance#7 + bind occ = instance#7 [argument] + exit calc OccurrenceFunctions::isDuring -> true + eval invoke isDuring -> true +stmt assign secondIsCar + eval feature cars -> (instance#5, instance#7) + eval literal 2 -> 2 + eval index -> instance#7 + eval operator istype -> true +step 3: token 1@done +step 4: no active tokens +run: exhibited state machine running of #3 +run: exhibited state machine running of #5 +run: exhibited state machine running of #7 diff --git a/internal/exec/runtime/testdata/conformance/object_destroyed_at_runtime.expected.json b/internal/exec/runtime/testdata/conformance/object_destroyed_at_runtime.expected.json new file mode 100644 index 0000000000..0661f3d664 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/object_destroyed_at_runtime.expected.json @@ -0,0 +1,15 @@ +{ + "type": "instance", + "libraries": true, + "trace": true, + "instantiate": "test::Fleet", + "slots": { + "extentBefore": {"type": "Integer", "value": 2}, + "sameObject": {"type": "Boolean", "value": true}, + "extentAfter": {"type": "Integer", "value": 1}, + "heldAfter": {"type": "Integer", "value": 2}, + "spareAlive": {"type": "Boolean", "value": false}, + "firstAlive": {"type": "Boolean", "value": true}, + "readAfter": {"error": "occurrence was destroyed"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/object_destroyed_at_runtime.sysml b/internal/exec/runtime/testdata/conformance/object_destroyed_at_runtime.sysml new file mode 100644 index 0000000000..a65564e97c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/object_destroyed_at_runtime.sysml @@ -0,0 +1,45 @@ +// Destroying an object created at runtime ends its life wherever it is referred to: +// `destroy` through one reference ends the object the group still holds, it leaves +// the extent of its definition while the group keeps the stale reference, its +// exhibited state machine ends with it, nothing happens during it any more, and a +// feature read through the stale reference is refused as a read of a destroyed occurrence. +package test { + private import ScalarValues::*; + private import OccurrenceFunctions::*; + private import SequenceFunctions::*; + + part def Car { + attribute n : Integer; + exhibit state running { entry; then idle; state idle; } + } + + part def Fleet { + part cars : Car[0..*]; + ref part spare : Car[0..1]; + attribute extentBefore : Integer; + attribute extentAfter : Integer; + attribute heldAfter : Integer; + attribute spareAlive : Boolean; + attribute firstAlive : Boolean; + attribute sameObject : Boolean; + attribute readAfter : Integer = cars#(2).n; + + perform action cycle { + first start; + then action make { + assign cars := addNew(cars, new Car(1)); + assign cars := addNew(cars, new Car(2)); + assign spare := cars#(2); + assign extentBefore := size(all Car); + } + then action scrap { + assign sameObject := destroy(spare) === cars#(2); + assign extentAfter := size(all Car); + assign heldAfter := size(cars); + assign spareAlive := isDuring(spare); + assign firstAlive := isDuring(cars#(1)); + } + then done; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/object_destroyed_at_runtime.trace.golden b/internal/exec/runtime/testdata/conformance/object_destroyed_at_runtime.trace.golden new file mode 100644 index 0000000000..5c4c249a1a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/object_destroyed_at_runtime.trace.golden @@ -0,0 +1,86 @@ +materialize: Fleet #1 +start: performed action cycle of #1 +materialize: cycle #2 +run: performed action cycle of #1 +step 1: token 1@make +stmt assign cars + enter calc OccurrenceFunctions::addNew + eval feature cars -> () + eval literal 1 -> 1 +materialize: Car #3 +start: exhibited state machine running of #3 +materialize: running #4 +enter: idle + eval construct Car -> instance#3 + bind group = () [argument] + bind occ = instance#3 [argument] +create: Car #3 + exit calc OccurrenceFunctions::addNew -> (instance#3) + eval invoke addNew -> (instance#3) +stmt assign cars + enter calc OccurrenceFunctions::addNew + eval feature cars -> (instance#3) + eval literal 2 -> 2 +materialize: Car #5 +start: exhibited state machine running of #5 +materialize: running #6 +enter: idle + eval construct Car -> instance#5 + bind group = (instance#3) [argument] + bind occ = instance#5 [argument] +create: Car #5 + exit calc OccurrenceFunctions::addNew -> (instance#3, instance#5) + eval invoke addNew -> (instance#3, instance#5) +stmt assign spare + eval feature cars -> (instance#3, instance#5) + eval literal 2 -> 2 + eval index -> instance#5 +stmt assign extentBefore + enter calc SequenceFunctions::size + eval operator all -> (instance#3, instance#5) + bind seq = (instance#3, instance#5) [argument] + exit calc SequenceFunctions::size -> 2 + eval invoke size -> 2 +step 2: token 1@scrap +stmt assign sameObject + enter calc OccurrenceFunctions::destroy + eval feature spare -> instance#5 + bind occ = instance#5 [argument] +destroy: Car #5 +destroy: running #6 +terminated with occurrence: running + exit calc OccurrenceFunctions::destroy -> instance#5 + eval invoke destroy -> instance#5 + eval feature cars -> (instance#3, instance#5) + eval literal 2 -> 2 + eval index -> instance#5 + eval operator === -> true +stmt assign extentAfter + enter calc SequenceFunctions::size + eval operator all -> (instance#3) + bind seq = (instance#3) [argument] + exit calc SequenceFunctions::size -> 1 + eval invoke size -> 1 +stmt assign heldAfter + enter calc SequenceFunctions::size + eval feature cars -> (instance#3, instance#5) + bind seq = (instance#3, instance#5) [argument] + exit calc SequenceFunctions::size -> 2 + eval invoke size -> 2 +stmt assign spareAlive + enter calc OccurrenceFunctions::isDuring + eval feature spare -> instance#5 + bind occ = instance#5 [argument] + exit calc OccurrenceFunctions::isDuring -> false + eval invoke isDuring -> false +stmt assign firstAlive + enter calc OccurrenceFunctions::isDuring + eval feature cars -> (instance#3, instance#5) + eval literal 1 -> 1 + eval index -> instance#3 + bind occ = instance#3 [argument] + exit calc OccurrenceFunctions::isDuring -> true + eval invoke isDuring -> true +step 3: token 1@done +step 4: no active tokens +run: exhibited state machine running of #3 diff --git a/internal/core/runtime/testdata/conformance/object_documented_behaviors.expected.json b/internal/exec/runtime/testdata/conformance/object_documented_behaviors.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/object_documented_behaviors.expected.json rename to internal/exec/runtime/testdata/conformance/object_documented_behaviors.expected.json diff --git a/internal/core/runtime/testdata/conformance/object_documented_behaviors.sysml b/internal/exec/runtime/testdata/conformance/object_documented_behaviors.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/object_documented_behaviors.sysml rename to internal/exec/runtime/testdata/conformance/object_documented_behaviors.sysml diff --git a/internal/core/runtime/testdata/conformance/object_exhibits_state_machine.expected.json b/internal/exec/runtime/testdata/conformance/object_exhibits_state_machine.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/object_exhibits_state_machine.expected.json rename to internal/exec/runtime/testdata/conformance/object_exhibits_state_machine.expected.json diff --git a/internal/core/runtime/testdata/conformance/object_exhibits_state_machine.sysml b/internal/exec/runtime/testdata/conformance/object_exhibits_state_machine.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/object_exhibits_state_machine.sysml rename to internal/exec/runtime/testdata/conformance/object_exhibits_state_machine.sysml diff --git a/internal/core/runtime/testdata/conformance/object_exhibits_state_machine.trace.golden b/internal/exec/runtime/testdata/conformance/object_exhibits_state_machine.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/object_exhibits_state_machine.trace.golden rename to internal/exec/runtime/testdata/conformance/object_exhibits_state_machine.trace.golden diff --git a/internal/core/runtime/testdata/conformance/object_machine_guard_reads_written_feature.expected.json b/internal/exec/runtime/testdata/conformance/object_machine_guard_reads_written_feature.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/object_machine_guard_reads_written_feature.expected.json rename to internal/exec/runtime/testdata/conformance/object_machine_guard_reads_written_feature.expected.json diff --git a/internal/core/runtime/testdata/conformance/object_machine_guard_reads_written_feature.sysml b/internal/exec/runtime/testdata/conformance/object_machine_guard_reads_written_feature.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/object_machine_guard_reads_written_feature.sysml rename to internal/exec/runtime/testdata/conformance/object_machine_guard_reads_written_feature.sysml diff --git a/internal/core/runtime/testdata/conformance/object_machine_writes_own_features.expected.json b/internal/exec/runtime/testdata/conformance/object_machine_writes_own_features.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/object_machine_writes_own_features.expected.json rename to internal/exec/runtime/testdata/conformance/object_machine_writes_own_features.expected.json diff --git a/internal/core/runtime/testdata/conformance/object_machine_writes_own_features.sysml b/internal/exec/runtime/testdata/conformance/object_machine_writes_own_features.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/object_machine_writes_own_features.sysml rename to internal/exec/runtime/testdata/conformance/object_machine_writes_own_features.sysml diff --git a/internal/core/runtime/testdata/conformance/object_mutual_addressed_send.expected.json b/internal/exec/runtime/testdata/conformance/object_mutual_addressed_send.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/object_mutual_addressed_send.expected.json rename to internal/exec/runtime/testdata/conformance/object_mutual_addressed_send.expected.json diff --git a/internal/core/runtime/testdata/conformance/object_mutual_addressed_send.sysml b/internal/exec/runtime/testdata/conformance/object_mutual_addressed_send.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/object_mutual_addressed_send.sysml rename to internal/exec/runtime/testdata/conformance/object_mutual_addressed_send.sysml diff --git a/internal/core/runtime/testdata/conformance/occurrence_is_during_completed_performance.expected.json b/internal/exec/runtime/testdata/conformance/occurrence_is_during_completed_performance.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_is_during_completed_performance.expected.json rename to internal/exec/runtime/testdata/conformance/occurrence_is_during_completed_performance.expected.json diff --git a/internal/core/runtime/testdata/conformance/occurrence_is_during_completed_performance.sysml b/internal/exec/runtime/testdata/conformance/occurrence_is_during_completed_performance.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_is_during_completed_performance.sysml rename to internal/exec/runtime/testdata/conformance/occurrence_is_during_completed_performance.sysml diff --git a/internal/core/runtime/testdata/conformance/occurrence_is_during_in_exhibited_state.expected.json b/internal/exec/runtime/testdata/conformance/occurrence_is_during_in_exhibited_state.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_is_during_in_exhibited_state.expected.json rename to internal/exec/runtime/testdata/conformance/occurrence_is_during_in_exhibited_state.expected.json diff --git a/internal/core/runtime/testdata/conformance/occurrence_is_during_in_exhibited_state.sysml b/internal/exec/runtime/testdata/conformance/occurrence_is_during_in_exhibited_state.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_is_during_in_exhibited_state.sysml rename to internal/exec/runtime/testdata/conformance/occurrence_is_during_in_exhibited_state.sysml diff --git a/internal/core/runtime/testdata/conformance/occurrence_lifecycle_errors.expected.json b/internal/exec/runtime/testdata/conformance/occurrence_lifecycle_errors.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_lifecycle_errors.expected.json rename to internal/exec/runtime/testdata/conformance/occurrence_lifecycle_errors.expected.json diff --git a/internal/core/runtime/testdata/conformance/occurrence_lifecycle_errors.sysml b/internal/exec/runtime/testdata/conformance/occurrence_lifecycle_errors.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_lifecycle_errors.sysml rename to internal/exec/runtime/testdata/conformance/occurrence_lifecycle_errors.sysml diff --git a/internal/core/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.expected.json b/internal/exec/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.expected.json rename to internal/exec/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.expected.json diff --git a/internal/core/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.sysml b/internal/exec/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.sysml rename to internal/exec/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.sysml diff --git a/internal/core/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.trace.golden b/internal/exec/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.trace.golden rename to internal/exec/runtime/testdata/conformance/occurrence_lifecycle_in_performed_action.trace.golden diff --git a/internal/core/runtime/testdata/conformance/occurrence_same_distinguishes_equal_parts.expected.json b/internal/exec/runtime/testdata/conformance/occurrence_same_distinguishes_equal_parts.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_same_distinguishes_equal_parts.expected.json rename to internal/exec/runtime/testdata/conformance/occurrence_same_distinguishes_equal_parts.expected.json diff --git a/internal/core/runtime/testdata/conformance/occurrence_same_distinguishes_equal_parts.sysml b/internal/exec/runtime/testdata/conformance/occurrence_same_distinguishes_equal_parts.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/occurrence_same_distinguishes_equal_parts.sysml rename to internal/exec/runtime/testdata/conformance/occurrence_same_distinguishes_equal_parts.sysml diff --git a/internal/core/runtime/testdata/conformance/perform_action_binding_package_sibling.expected.json b/internal/exec/runtime/testdata/conformance/perform_action_binding_package_sibling.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/perform_action_binding_package_sibling.expected.json rename to internal/exec/runtime/testdata/conformance/perform_action_binding_package_sibling.expected.json diff --git a/internal/core/runtime/testdata/conformance/perform_action_binding_package_sibling.sysml b/internal/exec/runtime/testdata/conformance/perform_action_binding_package_sibling.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/perform_action_binding_package_sibling.sysml rename to internal/exec/runtime/testdata/conformance/perform_action_binding_package_sibling.sysml diff --git a/internal/exec/runtime/testdata/conformance/perform_action_on_part.expected.json b/internal/exec/runtime/testdata/conformance/perform_action_on_part.expected.json new file mode 100644 index 0000000000..e8c204304b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/perform_action_on_part.expected.json @@ -0,0 +1,16 @@ +{ + "type": "instance", + "libraries": true, + "trace": true, + "instantiate": "test::Station", + "objects": [ + { + "finalState": "pointed", + "stateVisits": ["observing", "pointed"], + "slots": { + "tel.azimuth": {"type": "Real", "value": 45.0}, + "azimuth": {"type": "Real", "value": 90.0} + } + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/perform_action_on_part.sysml b/internal/exec/runtime/testdata/conformance/perform_action_on_part.sysml new file mode 100644 index 0000000000..1a4e4396bc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/perform_action_on_part.sysml @@ -0,0 +1,66 @@ +// `perform action pt ::> tel.point` performs the telescope's action as the +// telescope: the chain's operand is evaluated in the caller and the callee's +// `this` is the object it denotes, so the telescope's azimuth moves. The plain +// nested call beside it keeps the station as `this`, so the station's own +// same-named attribute takes the other write and the machine's guard sees both. +package test { + private import ScalarValues::*; + + part def Telescope { + attribute azimuth : Real default = 0.0; + + action def Point { + in az : Real; + first start then 'set azimuth'; + action 'set azimuth' { + in value; + assign this.azimuth := value; + } + bind 'set azimuth'.value = az; + first 'set azimuth' then done; + } + + action point : Point; + } + + part def Station { + part tel : Telescope; + attribute azimuth : Real default = 0.0; + + action def Face { + in az : Real; + first start then turn; + action turn { + assign this.azimuth := az; + } + first turn then done; + } + + action def Observe { + first start then pick; + action pick { + out result : Real; + assign result := 45.0; + } + first pick then pt; + perform action pt ::> tel.point; + flow pick.result to pt.az; + first pt then own; + action own : Face { + in az = 90.0; + } + first own then done; + } + + exhibit state run { + entry; then observing; + + state observing { + entry action observe : Observe; + } + state pointed; + + transition first observing if tel.azimuth == 45.0 and azimuth == 90.0 then pointed; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/perform_action_on_part.trace.golden b/internal/exec/runtime/testdata/conformance/perform_action_on_part.trace.golden new file mode 100644 index 0000000000..58f0ae98c6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/perform_action_on_part.trace.golden @@ -0,0 +1,40 @@ +materialize: Station #1 +start: exhibited state machine run of #1 +materialize: run #2 +enter: observing (entry action) +stmt perform +step 1: token 1@pick + stmt assign result + eval literal 45.0 -> 45.0 +step 2: token 1@pt +materialize: Telescope #3 + eval feature tel -> instance#3 +step 1: token 1@set azimuth + eval feature az -> 45.0 + stmt assign this.azimuth + eval feature value -> 45.0 + eval feature this -> instance#3 +step 2: token 1@done +step 3: no active tokens +step 3: token 1@own + eval literal 90.0 -> 90.0 +step 1: token 1@turn + stmt assign this.azimuth + eval feature az -> 90.0 + eval feature this -> instance#1 +step 2: token 1@done +step 3: no active tokens +step 4: token 1@done +step 5: no active tokens +run: exhibited state machine run of #1 + eval feature tel -> instance#3 + eval chain azimuth -> 45.0 + eval literal 45.0 -> 45.0 + eval operator == -> true + eval feature azimuth -> 90.0 + eval literal 90.0 -> 90.0 + eval operator == -> true +eval operator and -> true +exit: observing +enter: pointed +transition: observing -> pointed diff --git a/internal/exec/runtime/testdata/conformance/perform_typed_action_on_nested_part.expected.json b/internal/exec/runtime/testdata/conformance/perform_typed_action_on_nested_part.expected.json new file mode 100644 index 0000000000..920666e4e8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/perform_typed_action_on_nested_part.expected.json @@ -0,0 +1,16 @@ +{ + "type": "instance", + "libraries": true, + "trace": true, + "instantiate": "test::Rig", + "objects": [ + { + "finalState": "spun", + "stateVisits": ["spinning", "spun"], + "slots": { + "drive.motor.speed": {"type": "Real", "value": 30.0}, + "got": {"type": "Real", "value": 30.0} + } + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/perform_typed_action_on_nested_part.sysml b/internal/exec/runtime/testdata/conformance/perform_typed_action_on_nested_part.sysml new file mode 100644 index 0000000000..f71a146748 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/perform_typed_action_on_nested_part.sysml @@ -0,0 +1,61 @@ +// `perform action sp : Spin ::> drive.motor.spin` names the action's definition +// and the usage it performs: the typing lets the caller read `sp.result`, while +// the reference makes the motor two parts down the callee's `this`. Without the +// reference the typed nested usage would perform Spin as the rig, which has no +// speed to set. +package test { + private import ScalarValues::*; + + part def Motor { + attribute speed : Real default = 0.0; + + action def Spin { + in rpm : Real; + out result : Real; + first start then 'set speed'; + action 'set speed' { + in value; + assign this.speed := value; + } + bind 'set speed'.value = rpm; + first 'set speed' then 'read speed'; + action 'read speed' { + out result = this.speed; + } + bind result = 'read speed'.result; + first 'read speed' then done; + } + + action spin : Spin; + } + + part def Drive { + part motor : Motor; + } + + part def Rig { + part drive : Drive; + attribute got : Real default = 0.0; + + action def Spinup { + first start then sp; + perform action sp : Motor::Spin ::> drive.motor.spin { in rpm = 30.0; } + first sp then keep; + action keep { + assign this.got := sp.result; + } + first keep then done; + } + + exhibit state run { + entry; then spinning; + + state spinning { + entry action spinup : Spinup; + } + state spun; + + transition first spinning if drive.motor.speed == 30.0 and got == 30.0 then spun; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/perform_typed_action_on_nested_part.trace.golden b/internal/exec/runtime/testdata/conformance/perform_typed_action_on_nested_part.trace.golden new file mode 100644 index 0000000000..86837d5b03 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/perform_typed_action_on_nested_part.trace.golden @@ -0,0 +1,40 @@ +materialize: Rig #1 +start: exhibited state machine run of #1 +materialize: run #2 +enter: spinning (entry action) +stmt perform +step 1: token 1@sp + eval literal 30.0 -> 30.0 +materialize: Drive #3 + eval feature drive -> instance#3 +materialize: Motor #4 + eval chain motor -> instance#4 +step 1: token 1@set speed + eval feature rpm -> 30.0 + stmt assign this.speed + eval feature value -> 30.0 + eval feature this -> instance#4 +step 2: token 1@read speed + eval feature this -> instance#4 + eval chain speed -> 30.0 +step 3: token 1@done +step 4: no active tokens +step 2: token 1@keep + stmt assign this.got + eval chain result -> 30.0 + eval feature this -> instance#1 +step 3: token 1@done +step 4: no active tokens +run: exhibited state machine run of #1 + eval feature drive -> instance#3 + eval chain motor -> instance#4 + eval chain speed -> 30.0 + eval literal 30.0 -> 30.0 + eval operator == -> true + eval feature got -> 30.0 + eval literal 30.0 -> 30.0 + eval operator == -> true +eval operator and -> true +exit: spinning +enter: spun +transition: spinning -> spun diff --git a/internal/exec/runtime/testdata/conformance/performed_action_binding_end_names_parameter.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_binding_end_names_parameter.expected.json new file mode 100644 index 0000000000..9425d9033f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/performed_action_binding_end_names_parameter.expected.json @@ -0,0 +1,8 @@ +{ + "libraries": true, + "type": "instance", + "instantiate": "test::Host", + "slots": { + "seen": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/performed_action_binding_end_names_parameter.sysml b/internal/exec/runtime/testdata/conformance/performed_action_binding_end_names_parameter.sysml new file mode 100644 index 0000000000..4cbccca1eb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/performed_action_binding_end_names_parameter.sysml @@ -0,0 +1,30 @@ +// A binding end written at a node of a performed action is a statement of that +// action's body: a name there resolves to the action's own parameter before a +// same-named feature of the part performing it. A parameter admitting no value +// and given none is read as empty, not as the part's value. +package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + + part def Host { + attribute level : Integer = 7; + attribute seen : Integer = 0; + + perform action relaying { + in level : Integer[0..1]; + + first start; + + action noting { + in n : Integer[0..1]; + assign seen := size(n); + } + bind noting.n = level; + + done; + + succession first start then noting; + succession first noting then done; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/performed_action_def_perform_members_are_steps.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_def_perform_members_are_steps.expected.json new file mode 100644 index 0000000000..4d8e0602f3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/performed_action_def_perform_members_are_steps.expected.json @@ -0,0 +1,10 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::Station", + "slots": { + "total": {"type": "Real", "value": 1.5}, + "last": {"type": "Real", "value": 1.5}, + "cycles": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/performed_action_def_perform_members_are_steps.sysml b/internal/exec/runtime/testdata/conformance/performed_action_def_perform_members_are_steps.sysml new file mode 100644 index 0000000000..688d19ab7a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/performed_action_def_perform_members_are_steps.sysml @@ -0,0 +1,49 @@ +// An action def's `perform` members and nested typed actions are steps of its +// execution, not behaviors of the performance occurrence: when a part performs +// the def, each step runs in the part's frame, so `this` names the part and the +// steps write its features — as they would written directly in the part. +package test { + private import ScalarValues::*; + + part def Station { + attribute total : Real = 0.0; + attribute last : Real = 0.0; + attribute cycles : Integer = 0; + + part sensor : Sensor; + + action def Cycle { + action def Sample { + perform action read ::> sensor.sample; + action sum { + assign this.last := this.sensor.reading; + assign this.total := this.total + this.last; + } + first start then read; + first read then sum; + } + + action sample : Sample; + action count { + assign this.cycles := this.cycles + 1; + } + first start then sample; + first sample then count; + } + + perform action cycle : Cycle; + } + + part def Sensor { + attribute reading : Real = 0.0; + + action def Read { + action bump { + assign this.reading := this.reading + 1.5; + } + first start then bump; + } + + action sample : Read; + } +} diff --git a/internal/core/runtime/testdata/conformance/performed_action_later_node_reads.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_later_node_reads.expected.json rename to internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.expected.json diff --git a/internal/core/runtime/testdata/conformance/performed_action_later_node_reads.sysml b/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_later_node_reads.sysml rename to internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.sysml diff --git a/internal/core/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden b/internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden rename to internal/exec/runtime/testdata/conformance/performed_action_later_node_reads.trace.golden diff --git a/internal/core/runtime/testdata/conformance/performed_action_nested_node.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_nested_node.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_nested_node.expected.json rename to internal/exec/runtime/testdata/conformance/performed_action_nested_node.expected.json diff --git a/internal/core/runtime/testdata/conformance/performed_action_nested_node.sysml b/internal/exec/runtime/testdata/conformance/performed_action_nested_node.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_nested_node.sysml rename to internal/exec/runtime/testdata/conformance/performed_action_nested_node.sysml diff --git a/internal/core/runtime/testdata/conformance/performed_action_occurrence_default.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_occurrence_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_occurrence_default.expected.json rename to internal/exec/runtime/testdata/conformance/performed_action_occurrence_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/performed_action_occurrence_default.sysml b/internal/exec/runtime/testdata/conformance/performed_action_occurrence_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_occurrence_default.sysml rename to internal/exec/runtime/testdata/conformance/performed_action_occurrence_default.sysml diff --git a/internal/core/runtime/testdata/conformance/performed_action_out_parameter.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_out_parameter.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_out_parameter.expected.json rename to internal/exec/runtime/testdata/conformance/performed_action_out_parameter.expected.json diff --git a/internal/core/runtime/testdata/conformance/performed_action_out_parameter.sysml b/internal/exec/runtime/testdata/conformance/performed_action_out_parameter.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_out_parameter.sysml rename to internal/exec/runtime/testdata/conformance/performed_action_out_parameter.sysml diff --git a/internal/core/runtime/testdata/conformance/performed_action_own_attribute.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_own_attribute.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_own_attribute.expected.json rename to internal/exec/runtime/testdata/conformance/performed_action_own_attribute.expected.json diff --git a/internal/core/runtime/testdata/conformance/performed_action_own_attribute.sysml b/internal/exec/runtime/testdata/conformance/performed_action_own_attribute.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_own_attribute.sysml rename to internal/exec/runtime/testdata/conformance/performed_action_own_attribute.sysml diff --git a/internal/exec/runtime/testdata/conformance/performed_action_self_target.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_self_target.expected.json new file mode 100644 index 0000000000..8ecc995c05 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/performed_action_self_target.expected.json @@ -0,0 +1,9 @@ +{ + "type": "instance", + "instantiate": "test::Host", + "slots": { + "level": {"type": "Integer", "value": 3}, + "boost": {"type": "Instance"} + }, + "materialization": {} +} diff --git a/internal/exec/runtime/testdata/conformance/performed_action_self_target.sysml b/internal/exec/runtime/testdata/conformance/performed_action_self_target.sysml new file mode 100644 index 0000000000..8e864f8ce1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/performed_action_self_target.sysml @@ -0,0 +1,12 @@ +// A `perform action` usage naming nothing — no reference subsetting, no typing +// — performs itself (SysML v2 §8.3.16, eventOccurrence), and its `in` member +// binds the performance's parameter from the object's attribute. +package test { + part def Host { + attribute level : Integer = 3; + + perform action boost { + in amount : Integer = level; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/performed_action_self_target_empty.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_self_target_empty.expected.json new file mode 100644 index 0000000000..32428f6a13 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/performed_action_self_target_empty.expected.json @@ -0,0 +1,8 @@ +{ + "type": "instance", + "instantiate": "test::Idle", + "slots": { + "idle": {"type": "Instance"} + }, + "materialization": {} +} diff --git a/internal/exec/runtime/testdata/conformance/performed_action_self_target_empty.sysml b/internal/exec/runtime/testdata/conformance/performed_action_self_target_empty.sysml new file mode 100644 index 0000000000..1baa452e4b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/performed_action_self_target_empty.sysml @@ -0,0 +1,9 @@ +// A `perform action` usage with an empty body names nothing and performs +// itself: the PerformActionUsage is its own event occurrence +// (SysML v2 §8.3.16, eventOccurrence), so the object is created rather than +// refused. +package test { + part def Idle { + perform action idle; + } +} diff --git a/internal/core/runtime/testdata/conformance/performed_action_two_objects.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_two_objects.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_two_objects.expected.json rename to internal/exec/runtime/testdata/conformance/performed_action_two_objects.expected.json diff --git a/internal/core/runtime/testdata/conformance/performed_action_two_objects.sysml b/internal/exec/runtime/testdata/conformance/performed_action_two_objects.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_two_objects.sysml rename to internal/exec/runtime/testdata/conformance/performed_action_two_objects.sysml diff --git a/internal/core/runtime/testdata/conformance/performed_action_writes_performer_feature.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_writes_performer_feature.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_writes_performer_feature.expected.json rename to internal/exec/runtime/testdata/conformance/performed_action_writes_performer_feature.expected.json diff --git a/internal/core/runtime/testdata/conformance/performed_action_writes_performer_feature.sysml b/internal/exec/runtime/testdata/conformance/performed_action_writes_performer_feature.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_writes_performer_feature.sysml rename to internal/exec/runtime/testdata/conformance/performed_action_writes_performer_feature.sysml diff --git a/internal/core/runtime/testdata/conformance/performed_action_writes_this_feature.expected.json b/internal/exec/runtime/testdata/conformance/performed_action_writes_this_feature.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_writes_this_feature.expected.json rename to internal/exec/runtime/testdata/conformance/performed_action_writes_this_feature.expected.json diff --git a/internal/core/runtime/testdata/conformance/performed_action_writes_this_feature.sysml b/internal/exec/runtime/testdata/conformance/performed_action_writes_this_feature.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/performed_action_writes_this_feature.sysml rename to internal/exec/runtime/testdata/conformance/performed_action_writes_this_feature.sysml diff --git a/internal/core/runtime/testdata/conformance/port_direction_conjugation.expected.json b/internal/exec/runtime/testdata/conformance/port_direction_conjugation.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/port_direction_conjugation.expected.json rename to internal/exec/runtime/testdata/conformance/port_direction_conjugation.expected.json diff --git a/internal/core/runtime/testdata/conformance/port_direction_conjugation.sysml b/internal/exec/runtime/testdata/conformance/port_direction_conjugation.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/port_direction_conjugation.sysml rename to internal/exec/runtime/testdata/conformance/port_direction_conjugation.sysml diff --git a/internal/core/runtime/testdata/conformance/port_identity_own_port.expected.json b/internal/exec/runtime/testdata/conformance/port_identity_own_port.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/port_identity_own_port.expected.json rename to internal/exec/runtime/testdata/conformance/port_identity_own_port.expected.json diff --git a/internal/core/runtime/testdata/conformance/port_identity_own_port.sysml b/internal/exec/runtime/testdata/conformance/port_identity_own_port.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/port_identity_own_port.sysml rename to internal/exec/runtime/testdata/conformance/port_identity_own_port.sysml diff --git a/internal/core/runtime/testdata/conformance/port_interface_typed_connection.expected.json b/internal/exec/runtime/testdata/conformance/port_interface_typed_connection.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/port_interface_typed_connection.expected.json rename to internal/exec/runtime/testdata/conformance/port_interface_typed_connection.expected.json diff --git a/internal/core/runtime/testdata/conformance/port_interface_typed_connection.sysml b/internal/exec/runtime/testdata/conformance/port_interface_typed_connection.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/port_interface_typed_connection.sysml rename to internal/exec/runtime/testdata/conformance/port_interface_typed_connection.sysml diff --git a/internal/core/runtime/testdata/conformance/port_nested_port_path.expected.json b/internal/exec/runtime/testdata/conformance/port_nested_port_path.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/port_nested_port_path.expected.json rename to internal/exec/runtime/testdata/conformance/port_nested_port_path.expected.json diff --git a/internal/core/runtime/testdata/conformance/port_nested_port_path.sysml b/internal/exec/runtime/testdata/conformance/port_nested_port_path.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/port_nested_port_path.sysml rename to internal/exec/runtime/testdata/conformance/port_nested_port_path.sysml diff --git a/internal/core/runtime/testdata/conformance/redefinition_inherited_nested_values.expected.json b/internal/exec/runtime/testdata/conformance/redefinition_inherited_nested_values.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_inherited_nested_values.expected.json rename to internal/exec/runtime/testdata/conformance/redefinition_inherited_nested_values.expected.json diff --git a/internal/core/runtime/testdata/conformance/redefinition_inherited_nested_values.sysml b/internal/exec/runtime/testdata/conformance/redefinition_inherited_nested_values.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_inherited_nested_values.sysml rename to internal/exec/runtime/testdata/conformance/redefinition_inherited_nested_values.sysml diff --git a/internal/core/runtime/testdata/conformance/redefinition_multilevel_base_name.expected.json b/internal/exec/runtime/testdata/conformance/redefinition_multilevel_base_name.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_multilevel_base_name.expected.json rename to internal/exec/runtime/testdata/conformance/redefinition_multilevel_base_name.expected.json diff --git a/internal/core/runtime/testdata/conformance/redefinition_multilevel_base_name.sysml b/internal/exec/runtime/testdata/conformance/redefinition_multilevel_base_name.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_multilevel_base_name.sysml rename to internal/exec/runtime/testdata/conformance/redefinition_multilevel_base_name.sysml diff --git a/internal/core/runtime/testdata/conformance/redefinition_renamed_argument_object.expected.json b/internal/exec/runtime/testdata/conformance/redefinition_renamed_argument_object.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_renamed_argument_object.expected.json rename to internal/exec/runtime/testdata/conformance/redefinition_renamed_argument_object.expected.json diff --git a/internal/core/runtime/testdata/conformance/redefinition_renamed_argument_object.sysml b/internal/exec/runtime/testdata/conformance/redefinition_renamed_argument_object.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_renamed_argument_object.sysml rename to internal/exec/runtime/testdata/conformance/redefinition_renamed_argument_object.sysml diff --git a/internal/core/runtime/testdata/conformance/redefinition_renamed_argument_state.expected.json b/internal/exec/runtime/testdata/conformance/redefinition_renamed_argument_state.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_renamed_argument_state.expected.json rename to internal/exec/runtime/testdata/conformance/redefinition_renamed_argument_state.expected.json diff --git a/internal/core/runtime/testdata/conformance/redefinition_renamed_argument_state.sysml b/internal/exec/runtime/testdata/conformance/redefinition_renamed_argument_state.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_renamed_argument_state.sysml rename to internal/exec/runtime/testdata/conformance/redefinition_renamed_argument_state.sysml diff --git a/internal/core/runtime/testdata/conformance/redefinition_renamed_parameter.expected.json b/internal/exec/runtime/testdata/conformance/redefinition_renamed_parameter.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_renamed_parameter.expected.json rename to internal/exec/runtime/testdata/conformance/redefinition_renamed_parameter.expected.json diff --git a/internal/core/runtime/testdata/conformance/redefinition_renamed_parameter.sysml b/internal/exec/runtime/testdata/conformance/redefinition_renamed_parameter.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_renamed_parameter.sysml rename to internal/exec/runtime/testdata/conformance/redefinition_renamed_parameter.sysml diff --git a/internal/core/runtime/testdata/conformance/redefinition_restated_in_a_usage.expected.json b/internal/exec/runtime/testdata/conformance/redefinition_restated_in_a_usage.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_restated_in_a_usage.expected.json rename to internal/exec/runtime/testdata/conformance/redefinition_restated_in_a_usage.expected.json diff --git a/internal/core/runtime/testdata/conformance/redefinition_restated_in_a_usage.sysml b/internal/exec/runtime/testdata/conformance/redefinition_restated_in_a_usage.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_restated_in_a_usage.sysml rename to internal/exec/runtime/testdata/conformance/redefinition_restated_in_a_usage.sysml diff --git a/internal/core/runtime/testdata/conformance/redefinition_value_under_either_name.expected.json b/internal/exec/runtime/testdata/conformance/redefinition_value_under_either_name.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_value_under_either_name.expected.json rename to internal/exec/runtime/testdata/conformance/redefinition_value_under_either_name.expected.json diff --git a/internal/core/runtime/testdata/conformance/redefinition_value_under_either_name.sysml b/internal/exec/runtime/testdata/conformance/redefinition_value_under_either_name.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_value_under_either_name.sysml rename to internal/exec/runtime/testdata/conformance/redefinition_value_under_either_name.sysml diff --git a/internal/core/runtime/testdata/conformance/redefinition_valued_under_two_names.expected.json b/internal/exec/runtime/testdata/conformance/redefinition_valued_under_two_names.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_valued_under_two_names.expected.json rename to internal/exec/runtime/testdata/conformance/redefinition_valued_under_two_names.expected.json diff --git a/internal/core/runtime/testdata/conformance/redefinition_valued_under_two_names.sysml b/internal/exec/runtime/testdata/conformance/redefinition_valued_under_two_names.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/redefinition_valued_under_two_names.sysml rename to internal/exec/runtime/testdata/conformance/redefinition_valued_under_two_names.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_actor.expected.json b/internal/exec/runtime/testdata/conformance/requirement_actor.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_actor.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_actor.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_actor.sysml b/internal/exec/runtime/testdata/conformance/requirement_actor.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_actor.sysml rename to internal/exec/runtime/testdata/conformance/requirement_actor.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_assume.expected.json b/internal/exec/runtime/testdata/conformance/requirement_assume.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_assume.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_assume.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_assume.sysml b/internal/exec/runtime/testdata/conformance/requirement_assume.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_assume.sysml rename to internal/exec/runtime/testdata/conformance/requirement_assume.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_bare_reference.expected.json b/internal/exec/runtime/testdata/conformance/requirement_bare_reference.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_bare_reference.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_bare_reference.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_bare_reference.sysml b/internal/exec/runtime/testdata/conformance/requirement_bare_reference.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_bare_reference.sysml rename to internal/exec/runtime/testdata/conformance/requirement_bare_reference.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_chain_reference.expected.json b/internal/exec/runtime/testdata/conformance/requirement_chain_reference.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_chain_reference.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_chain_reference.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_chain_reference.sysml b/internal/exec/runtime/testdata/conformance/requirement_chain_reference.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_chain_reference.sysml rename to internal/exec/runtime/testdata/conformance/requirement_chain_reference.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_def_body_require.expected.json b/internal/exec/runtime/testdata/conformance/requirement_def_body_require.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_def_body_require.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_def_body_require.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_def_body_require.sysml b/internal/exec/runtime/testdata/conformance/requirement_def_body_require.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_def_body_require.sysml rename to internal/exec/runtime/testdata/conformance/requirement_def_body_require.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_literal.expected.json b/internal/exec/runtime/testdata/conformance/requirement_literal.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_literal.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_literal.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_literal.sysml b/internal/exec/runtime/testdata/conformance/requirement_literal.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_literal.sysml rename to internal/exec/runtime/testdata/conformance/requirement_literal.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_nested.expected.json b/internal/exec/runtime/testdata/conformance/requirement_nested.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_nested.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_nested.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_nested.sysml b/internal/exec/runtime/testdata/conformance/requirement_nested.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_nested.sysml rename to internal/exec/runtime/testdata/conformance/requirement_nested.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_nested_constraint.expected.json b/internal/exec/runtime/testdata/conformance/requirement_nested_constraint.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_nested_constraint.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_nested_constraint.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_nested_constraint.sysml b/internal/exec/runtime/testdata/conformance/requirement_nested_constraint.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_nested_constraint.sysml rename to internal/exec/runtime/testdata/conformance/requirement_nested_constraint.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_own_attribute.expected.json b/internal/exec/runtime/testdata/conformance/requirement_own_attribute.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_own_attribute.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_own_attribute.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_own_attribute.sysml b/internal/exec/runtime/testdata/conformance/requirement_own_attribute.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_own_attribute.sysml rename to internal/exec/runtime/testdata/conformance/requirement_own_attribute.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_quantity_converted_unit.expected.json b/internal/exec/runtime/testdata/conformance/requirement_quantity_converted_unit.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_quantity_converted_unit.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_quantity_converted_unit.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_quantity_converted_unit.sysml b/internal/exec/runtime/testdata/conformance/requirement_quantity_converted_unit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_quantity_converted_unit.sysml rename to internal/exec/runtime/testdata/conformance/requirement_quantity_converted_unit.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_quantity_same_unit.expected.json b/internal/exec/runtime/testdata/conformance/requirement_quantity_same_unit.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_quantity_same_unit.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_quantity_same_unit.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_quantity_same_unit.sysml b/internal/exec/runtime/testdata/conformance/requirement_quantity_same_unit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_quantity_same_unit.sysml rename to internal/exec/runtime/testdata/conformance/requirement_quantity_same_unit.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_quantity_violated.expected.json b/internal/exec/runtime/testdata/conformance/requirement_quantity_violated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_quantity_violated.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_quantity_violated.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_quantity_violated.sysml b/internal/exec/runtime/testdata/conformance/requirement_quantity_violated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_quantity_violated.sysml rename to internal/exec/runtime/testdata/conformance/requirement_quantity_violated.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_rational_quotient.expected.json b/internal/exec/runtime/testdata/conformance/requirement_rational_quotient.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_rational_quotient.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_rational_quotient.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_rational_quotient.sysml b/internal/exec/runtime/testdata/conformance/requirement_rational_quotient.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_rational_quotient.sysml rename to internal/exec/runtime/testdata/conformance/requirement_rational_quotient.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_reference_subsetting.expected.json b/internal/exec/runtime/testdata/conformance/requirement_reference_subsetting.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_reference_subsetting.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_reference_subsetting.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_reference_subsetting.sysml b/internal/exec/runtime/testdata/conformance/requirement_reference_subsetting.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_reference_subsetting.sysml rename to internal/exec/runtime/testdata/conformance/requirement_reference_subsetting.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_subject.expected.json b/internal/exec/runtime/testdata/conformance/requirement_subject.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_subject.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_subject.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_subject.sysml b/internal/exec/runtime/testdata/conformance/requirement_subject.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_subject.sysml rename to internal/exec/runtime/testdata/conformance/requirement_subject.sysml diff --git a/internal/core/runtime/testdata/conformance/requirement_violated.expected.json b/internal/exec/runtime/testdata/conformance/requirement_violated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_violated.expected.json rename to internal/exec/runtime/testdata/conformance/requirement_violated.expected.json diff --git a/internal/core/runtime/testdata/conformance/requirement_violated.sysml b/internal/exec/runtime/testdata/conformance/requirement_violated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/requirement_violated.sysml rename to internal/exec/runtime/testdata/conformance/requirement_violated.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_chain_subject.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_chain_subject.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_chain_subject.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_chain_subject.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_chain_subject.sysml b/internal/exec/runtime/testdata/conformance/satisfy_chain_subject.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_chain_subject.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_chain_subject.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_chain_unresolved.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_chain_unresolved.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_chain_unresolved.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_chain_unresolved.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_chain_unresolved.sysml b/internal/exec/runtime/testdata/conformance/satisfy_chain_unresolved.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_chain_unresolved.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_chain_unresolved.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_inherited_conditions.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_inherited_conditions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_inherited_conditions.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_inherited_conditions.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_inherited_conditions.sysml b/internal/exec/runtime/testdata/conformance/satisfy_inherited_conditions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_inherited_conditions.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_inherited_conditions.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_negated.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_negated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_negated.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_negated.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_negated.sysml b/internal/exec/runtime/testdata/conformance/satisfy_negated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_negated.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_negated.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_nested_subject.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_nested_subject.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_nested_subject.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_nested_subject.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_nested_subject.sysml b/internal/exec/runtime/testdata/conformance/satisfy_nested_subject.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_nested_subject.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_nested_subject.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_subject_binding.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_subject_binding.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_subject_binding.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_subject_binding.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_subject_binding.sysml b/internal/exec/runtime/testdata/conformance/satisfy_subject_binding.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_subject_binding.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_subject_binding.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_subject_classified.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_subject_classified.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_subject_classified.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_subject_classified.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_subject_classified.sysml b/internal/exec/runtime/testdata/conformance/satisfy_subject_classified.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_subject_classified.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_subject_classified.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_subject_features.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_subject_features.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_subject_features.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_subject_features.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_subject_features.sysml b/internal/exec/runtime/testdata/conformance/satisfy_subject_features.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_subject_features.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_subject_features.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_subject_redefined_multiplicity.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_subject_redefined_multiplicity.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_subject_redefined_multiplicity.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_subject_redefined_multiplicity.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_subject_redefined_multiplicity.sysml b/internal/exec/runtime/testdata/conformance/satisfy_subject_redefined_multiplicity.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_subject_redefined_multiplicity.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_subject_redefined_multiplicity.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_subject_refused.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_subject_refused.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_subject_refused.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_subject_refused.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_subject_refused.sysml b/internal/exec/runtime/testdata/conformance/satisfy_subject_refused.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_subject_refused.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_subject_refused.sysml diff --git a/internal/core/runtime/testdata/conformance/satisfy_without_conditions.expected.json b/internal/exec/runtime/testdata/conformance/satisfy_without_conditions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_without_conditions.expected.json rename to internal/exec/runtime/testdata/conformance/satisfy_without_conditions.expected.json diff --git a/internal/core/runtime/testdata/conformance/satisfy_without_conditions.sysml b/internal/exec/runtime/testdata/conformance/satisfy_without_conditions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/satisfy_without_conditions.sysml rename to internal/exec/runtime/testdata/conformance/satisfy_without_conditions.sysml diff --git a/internal/exec/runtime/testdata/conformance/send_addressed_via_bound_reference.expected.json b/internal/exec/runtime/testdata/conformance/send_addressed_via_bound_reference.expected.json new file mode 100644 index 0000000000..2a218c761b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_addressed_via_bound_reference.expected.json @@ -0,0 +1,32 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "SendAddressedViaBoundReference::Rig", + "materialization": {}, + "objects": [ + { + "path": "", + "events": [{"signal": "Go"}], + "finalState": "fired", + "stateVisits": ["idle", "firing", "fired"] + }, + { + "path": "device", + "behavior": "listener", + "finalState": "heard", + "stateVisits": ["idle", "heard"] + }, + { + "path": "device", + "behavior": "other", + "finalState": "idle", + "stateVisits": ["idle"] + }, + { + "path": "dev", + "behavior": "listener", + "finalState": "idle", + "stateVisits": ["idle"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_addressed_via_bound_reference.sysml b/internal/exec/runtime/testdata/conformance/send_addressed_via_bound_reference.sysml new file mode 100644 index 0000000000..e2e6324a32 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_addressed_via_bound_reference.sysml @@ -0,0 +1,58 @@ +// An addressed send over a via path rooted at a bound reference leaves the bound +// object's port and names one of that object's receivers: with `dev` bound to +// `device`, `via dev.tx to listener` reaches the device's `listener` machine alone. +package SendAddressedViaBoundReference { + item def Go; + item def Ping; + + port def PingOut { + out item ping : Ping; + } + + part def Device { + port tx : PingOut; + port rx : ~PingOut; + connect tx to rx; + + exhibit state listener { + entry; then idle; + state idle; + transition first idle accept Ping via rx then heard; + state heard; + } + + exhibit state other { + entry; then idle; + state idle; + transition first idle accept Ping via rx then heard; + state heard; + } + } + + action def Fire { + in ref dev : Device; + first start then go; + action go { + send new Ping() via dev.tx to listener; + } + first go then done; + } + + part def Rig { + part dev : Device; + part device : Device; + + exhibit state life { + entry; then idle; + state idle; + transition first idle accept Go then firing; + state firing { + do action fire : Fire { + in ref :>> dev = device; + } + } + state fired; + transition first firing then fired; + } + } +} diff --git a/internal/core/runtime/testdata/conformance/send_bind_relay_aliased_ends.expected.json b/internal/exec/runtime/testdata/conformance/send_bind_relay_aliased_ends.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_bind_relay_aliased_ends.expected.json rename to internal/exec/runtime/testdata/conformance/send_bind_relay_aliased_ends.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_bind_relay_aliased_ends.sysml b/internal/exec/runtime/testdata/conformance/send_bind_relay_aliased_ends.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_bind_relay_aliased_ends.sysml rename to internal/exec/runtime/testdata/conformance/send_bind_relay_aliased_ends.sysml diff --git a/internal/core/runtime/testdata/conformance/send_bind_relay_connector_order.expected.json b/internal/exec/runtime/testdata/conformance/send_bind_relay_connector_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_bind_relay_connector_order.expected.json rename to internal/exec/runtime/testdata/conformance/send_bind_relay_connector_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_bind_relay_connector_order.sysml b/internal/exec/runtime/testdata/conformance/send_bind_relay_connector_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_bind_relay_connector_order.sysml rename to internal/exec/runtime/testdata/conformance/send_bind_relay_connector_order.sysml diff --git a/internal/core/runtime/testdata/conformance/send_bind_relay_inbound.expected.json b/internal/exec/runtime/testdata/conformance/send_bind_relay_inbound.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_bind_relay_inbound.expected.json rename to internal/exec/runtime/testdata/conformance/send_bind_relay_inbound.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_bind_relay_inbound.sysml b/internal/exec/runtime/testdata/conformance/send_bind_relay_inbound.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_bind_relay_inbound.sysml rename to internal/exec/runtime/testdata/conformance/send_bind_relay_inbound.sysml diff --git a/internal/core/runtime/testdata/conformance/send_bind_relay_nested.expected.json b/internal/exec/runtime/testdata/conformance/send_bind_relay_nested.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_bind_relay_nested.expected.json rename to internal/exec/runtime/testdata/conformance/send_bind_relay_nested.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_bind_relay_nested.sysml b/internal/exec/runtime/testdata/conformance/send_bind_relay_nested.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_bind_relay_nested.sysml rename to internal/exec/runtime/testdata/conformance/send_bind_relay_nested.sysml diff --git a/internal/core/runtime/testdata/conformance/send_bind_relay_outbound.expected.json b/internal/exec/runtime/testdata/conformance/send_bind_relay_outbound.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_bind_relay_outbound.expected.json rename to internal/exec/runtime/testdata/conformance/send_bind_relay_outbound.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_bind_relay_outbound.sysml b/internal/exec/runtime/testdata/conformance/send_bind_relay_outbound.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_bind_relay_outbound.sysml rename to internal/exec/runtime/testdata/conformance/send_bind_relay_outbound.sysml diff --git a/internal/core/runtime/testdata/conformance/send_body_input_is_not_payload.expected.json b/internal/exec/runtime/testdata/conformance/send_body_input_is_not_payload.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_body_input_is_not_payload.expected.json rename to internal/exec/runtime/testdata/conformance/send_body_input_is_not_payload.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_body_input_is_not_payload.sysml b/internal/exec/runtime/testdata/conformance/send_body_input_is_not_payload.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_body_input_is_not_payload.sysml rename to internal/exec/runtime/testdata/conformance/send_body_input_is_not_payload.sysml diff --git a/internal/core/runtime/testdata/conformance/send_connection_into_nested_part.expected.json b/internal/exec/runtime/testdata/conformance/send_connection_into_nested_part.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_connection_into_nested_part.expected.json rename to internal/exec/runtime/testdata/conformance/send_connection_into_nested_part.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_connection_into_nested_part.sysml b/internal/exec/runtime/testdata/conformance/send_connection_into_nested_part.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_connection_into_nested_part.sysml rename to internal/exec/runtime/testdata/conformance/send_connection_into_nested_part.sysml diff --git a/internal/core/runtime/testdata/conformance/send_connection_multivalued_end_fans_out.expected.json b/internal/exec/runtime/testdata/conformance/send_connection_multivalued_end_fans_out.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_connection_multivalued_end_fans_out.expected.json rename to internal/exec/runtime/testdata/conformance/send_connection_multivalued_end_fans_out.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_connection_multivalued_end_fans_out.sysml b/internal/exec/runtime/testdata/conformance/send_connection_multivalued_end_fans_out.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_connection_multivalued_end_fans_out.sysml rename to internal/exec/runtime/testdata/conformance/send_connection_multivalued_end_fans_out.sysml diff --git a/internal/exec/runtime/testdata/conformance/send_delegated_port_nested_binding.expected.json b/internal/exec/runtime/testdata/conformance/send_delegated_port_nested_binding.expected.json new file mode 100644 index 0000000000..8e52474246 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_delegated_port_nested_binding.expected.json @@ -0,0 +1,12 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "SendDelegatedPort::Rig", + "objects": [ + { + "path": "m1cs", + "finalState": "installed", + "stateVisits": ["idle", "installed"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_delegated_port_nested_binding.sysml b/internal/exec/runtime/testdata/conformance/send_delegated_port_nested_binding.sysml new file mode 100644 index 0000000000..fed4c78ac6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_delegated_port_nested_binding.sysml @@ -0,0 +1,64 @@ +// A send through a parameter bound to an object leaves that object's port, follows its binding to +// the owner's boundary port and the connector on to the receiver (SysML v2 §7.16). +package SendDelegatedPort { + item def Go; + + port def CmdPort { + out item go : Go; + } + + port def CmdIn { + in item go : Go; + } + + part def Peas { + port toM1 : CmdPort; + + action def Command { + in context : Peas; + first start; + then action go send new Go() via context.toM1; + then done; + } + + state def Life { + entry; then commanding; + state commanding { + do action run { + first start then call; + action call : Command; + bind call.context = this; + first call then done; + } + } + } + + exhibit state life : Life; + } + + part def Box { + port toM1 : CmdPort; + part peas : Peas; + bind toM1 = peas.toM1; + } + + part def M1CS { + port cmd : CmdIn; + + exhibit state life { + entry; then idle; + state idle; + state installed; + + transition first idle + accept Go via cmd + then installed; + } + } + + part def Rig { + part box : Box; + part m1cs : M1CS; + connect box.toM1 to m1cs.cmd; + } +} diff --git a/internal/core/runtime/testdata/conformance/send_identity_addressed_part.expected.json b/internal/exec/runtime/testdata/conformance/send_identity_addressed_part.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_addressed_part.expected.json rename to internal/exec/runtime/testdata/conformance/send_identity_addressed_part.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_identity_addressed_part.sysml b/internal/exec/runtime/testdata/conformance/send_identity_addressed_part.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_addressed_part.sysml rename to internal/exec/runtime/testdata/conformance/send_identity_addressed_part.sysml diff --git a/internal/core/runtime/testdata/conformance/send_identity_nested_performer.expected.json b/internal/exec/runtime/testdata/conformance/send_identity_nested_performer.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_nested_performer.expected.json rename to internal/exec/runtime/testdata/conformance/send_identity_nested_performer.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_identity_nested_performer.sysml b/internal/exec/runtime/testdata/conformance/send_identity_nested_performer.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_nested_performer.sysml rename to internal/exec/runtime/testdata/conformance/send_identity_nested_performer.sysml diff --git a/internal/core/runtime/testdata/conformance/send_identity_performed_object.expected.json b/internal/exec/runtime/testdata/conformance/send_identity_performed_object.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_performed_object.expected.json rename to internal/exec/runtime/testdata/conformance/send_identity_performed_object.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_identity_performed_object.sysml b/internal/exec/runtime/testdata/conformance/send_identity_performed_object.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_performed_object.sysml rename to internal/exec/runtime/testdata/conformance/send_identity_performed_object.sysml diff --git a/internal/core/runtime/testdata/conformance/send_identity_qualified_receiver.expected.json b/internal/exec/runtime/testdata/conformance/send_identity_qualified_receiver.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_qualified_receiver.expected.json rename to internal/exec/runtime/testdata/conformance/send_identity_qualified_receiver.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_identity_qualified_receiver.sysml b/internal/exec/runtime/testdata/conformance/send_identity_qualified_receiver.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_qualified_receiver.sysml rename to internal/exec/runtime/testdata/conformance/send_identity_qualified_receiver.sysml diff --git a/internal/core/runtime/testdata/conformance/send_identity_same_named_ports.expected.json b/internal/exec/runtime/testdata/conformance/send_identity_same_named_ports.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_same_named_ports.expected.json rename to internal/exec/runtime/testdata/conformance/send_identity_same_named_ports.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_identity_same_named_ports.sysml b/internal/exec/runtime/testdata/conformance/send_identity_same_named_ports.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_same_named_ports.sysml rename to internal/exec/runtime/testdata/conformance/send_identity_same_named_ports.sysml diff --git a/internal/core/runtime/testdata/conformance/send_identity_same_named_ports.trace.golden b/internal/exec/runtime/testdata/conformance/send_identity_same_named_ports.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_same_named_ports.trace.golden rename to internal/exec/runtime/testdata/conformance/send_identity_same_named_ports.trace.golden diff --git a/internal/core/runtime/testdata/conformance/send_identity_unroutable_target.expected.json b/internal/exec/runtime/testdata/conformance/send_identity_unroutable_target.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_unroutable_target.expected.json rename to internal/exec/runtime/testdata/conformance/send_identity_unroutable_target.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_identity_unroutable_target.sysml b/internal/exec/runtime/testdata/conformance/send_identity_unroutable_target.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_identity_unroutable_target.sysml rename to internal/exec/runtime/testdata/conformance/send_identity_unroutable_target.sysml diff --git a/internal/core/runtime/testdata/conformance/send_into_outbound_only_end.expected.json b/internal/exec/runtime/testdata/conformance/send_into_outbound_only_end.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_into_outbound_only_end.expected.json rename to internal/exec/runtime/testdata/conformance/send_into_outbound_only_end.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_into_outbound_only_end.sysml b/internal/exec/runtime/testdata/conformance/send_into_outbound_only_end.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_into_outbound_only_end.sysml rename to internal/exec/runtime/testdata/conformance/send_into_outbound_only_end.sysml diff --git a/internal/core/runtime/testdata/conformance/send_new_library_type.expected.json b/internal/exec/runtime/testdata/conformance/send_new_library_type.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_new_library_type.expected.json rename to internal/exec/runtime/testdata/conformance/send_new_library_type.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_new_library_type.sysml b/internal/exec/runtime/testdata/conformance/send_new_library_type.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_new_library_type.sysml rename to internal/exec/runtime/testdata/conformance/send_new_library_type.sysml diff --git a/internal/core/runtime/testdata/conformance/send_new_positional_skips_calc.expected.json b/internal/exec/runtime/testdata/conformance/send_new_positional_skips_calc.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_new_positional_skips_calc.expected.json rename to internal/exec/runtime/testdata/conformance/send_new_positional_skips_calc.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_new_positional_skips_calc.sysml b/internal/exec/runtime/testdata/conformance/send_new_positional_skips_calc.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_new_positional_skips_calc.sysml rename to internal/exec/runtime/testdata/conformance/send_new_positional_skips_calc.sysml diff --git a/internal/core/runtime/testdata/conformance/send_new_redefined_payload_feature.expected.json b/internal/exec/runtime/testdata/conformance/send_new_redefined_payload_feature.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_new_redefined_payload_feature.expected.json rename to internal/exec/runtime/testdata/conformance/send_new_redefined_payload_feature.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_new_redefined_payload_feature.sysml b/internal/exec/runtime/testdata/conformance/send_new_redefined_payload_feature.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_new_redefined_payload_feature.sysml rename to internal/exec/runtime/testdata/conformance/send_new_redefined_payload_feature.sysml diff --git a/internal/core/runtime/testdata/conformance/send_new_through_alias.expected.json b/internal/exec/runtime/testdata/conformance/send_new_through_alias.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_new_through_alias.expected.json rename to internal/exec/runtime/testdata/conformance/send_new_through_alias.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_new_through_alias.sysml b/internal/exec/runtime/testdata/conformance/send_new_through_alias.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_new_through_alias.sysml rename to internal/exec/runtime/testdata/conformance/send_new_through_alias.sysml diff --git a/internal/core/runtime/testdata/conformance/send_no_reachable_receiver.expected.json b/internal/exec/runtime/testdata/conformance/send_no_reachable_receiver.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_no_reachable_receiver.expected.json rename to internal/exec/runtime/testdata/conformance/send_no_reachable_receiver.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_no_reachable_receiver.sysml b/internal/exec/runtime/testdata/conformance/send_no_reachable_receiver.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_no_reachable_receiver.sysml rename to internal/exec/runtime/testdata/conformance/send_no_reachable_receiver.sysml diff --git a/internal/core/runtime/testdata/conformance/send_own_invoked_event.expected.json b/internal/exec/runtime/testdata/conformance/send_own_invoked_event.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_own_invoked_event.expected.json rename to internal/exec/runtime/testdata/conformance/send_own_invoked_event.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_own_invoked_event.sysml b/internal/exec/runtime/testdata/conformance/send_own_invoked_event.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_own_invoked_event.sysml rename to internal/exec/runtime/testdata/conformance/send_own_invoked_event.sysml diff --git a/internal/exec/runtime/testdata/conformance/send_redefined_part_port_end.expected.json b/internal/exec/runtime/testdata/conformance/send_redefined_part_port_end.expected.json new file mode 100644 index 0000000000..a50a14b631 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_redefined_part_port_end.expected.json @@ -0,0 +1,22 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "RedefinedPartEnd::Rig", + "materialization": {}, + "objects": [ + { + "path": "logical.peas", + "events": [{"signal": "Go"}], + "finalState": "ready", + "stateVisits": ["idle", "commanding", "ready"], + "slots": { + "lastAck": {"type": "Instance"} + } + }, + { + "path": "m1cs", + "finalState": "installed", + "stateVisits": ["idle", "installed"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_redefined_part_port_end.sysml b/internal/exec/runtime/testdata/conformance/send_redefined_part_port_end.sysml new file mode 100644 index 0000000000..85e58ccbd5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_redefined_part_port_end.sysml @@ -0,0 +1,98 @@ +// A connector inherited from a supertype names a part by its declared name; the subtype +// redefines the part under another name, so the end reaches the redefining part's port and, +// through the binding, the machine of the part nested inside it (SysML v2 §7.16, §8.3.9.5). +package RedefinedPartEnd { + item def Go; + item def Query; + item def Ack; + + part def Peas { + port toM1In; + port toM1Out; + } + + action def Handshake { + in ref context : Peas; + out ref item ack : Ack; + first start then split; + fork split; + first split then ask; + first split then heard; + action ask { + send new Query() via context.toM1Out; + } + action heard accept msg : Ack via context.toM1In; + first ask then meet; + first heard then meet; + join meet; + first meet then keep; + action keep { + assign ack := msg; + } + first keep then done; + } + + part def Commander :> Peas { + ref item lastAck : Ack[0..1]; + + state def Life { + entry; then idle; + state idle; + transition first idle accept Go then commanding; + state commanding { + do action run { + first start then call; + action call : Handshake; + bind call.context = this; + first call then keep; + action keep { + assign lastAck := call.ack; + } + first keep then done; + } + } + state ready; + transition first commanding then ready; + } + + exhibit state life : Life; + } + + part def BoxSpec { + port toM1In; + port toM1Out; + } + + part def Box :> BoxSpec { + part peas : Commander; + bind toM1In = peas.toM1In; + bind peas.toM1Out = toM1Out; + } + + part def M1CS { + port cmdIn; + port cmdOut; + + exhibit state life { + entry; then idle; + state idle; + state installed; + + transition first idle + accept Query via cmdIn + do send new Ack() via cmdOut + then installed; + } + } + + part def RigBase { + part box : BoxSpec; + part m1cs : M1CS; + connect box.toM1Out to m1cs.cmdIn; + connect box.toM1In to m1cs.cmdOut; + } + + part def Rig :> RigBase { + part logical : Box :>> box; + } +} diff --git a/internal/core/runtime/testdata/conformance/send_same_name_distinct_events.expected.json b/internal/exec/runtime/testdata/conformance/send_same_name_distinct_events.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_same_name_distinct_events.expected.json rename to internal/exec/runtime/testdata/conformance/send_same_name_distinct_events.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_same_name_distinct_events.sysml b/internal/exec/runtime/testdata/conformance/send_same_name_distinct_events.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_same_name_distinct_events.sysml rename to internal/exec/runtime/testdata/conformance/send_same_name_distinct_events.sysml diff --git a/internal/core/runtime/testdata/conformance/send_same_name_distinct_packages.expected.json b/internal/exec/runtime/testdata/conformance/send_same_name_distinct_packages.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_same_name_distinct_packages.expected.json rename to internal/exec/runtime/testdata/conformance/send_same_name_distinct_packages.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_same_name_distinct_packages.sysml b/internal/exec/runtime/testdata/conformance/send_same_name_distinct_packages.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_same_name_distinct_packages.sysml rename to internal/exec/runtime/testdata/conformance/send_same_name_distinct_packages.sysml diff --git a/internal/core/runtime/testdata/conformance/send_sibling_event_not_taken.expected.json b/internal/exec/runtime/testdata/conformance/send_sibling_event_not_taken.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_sibling_event_not_taken.expected.json rename to internal/exec/runtime/testdata/conformance/send_sibling_event_not_taken.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_sibling_event_not_taken.sysml b/internal/exec/runtime/testdata/conformance/send_sibling_event_not_taken.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_sibling_event_not_taken.sysml rename to internal/exec/runtime/testdata/conformance/send_sibling_event_not_taken.sysml diff --git a/internal/core/runtime/testdata/conformance/send_sibling_event_taken.expected.json b/internal/exec/runtime/testdata/conformance/send_sibling_event_taken.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_sibling_event_taken.expected.json rename to internal/exec/runtime/testdata/conformance/send_sibling_event_taken.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_sibling_event_taken.sysml b/internal/exec/runtime/testdata/conformance/send_sibling_event_taken.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_sibling_event_taken.sysml rename to internal/exec/runtime/testdata/conformance/send_sibling_event_taken.sysml diff --git a/internal/core/runtime/testdata/conformance/send_sibling_event_unselected_accept.expected.json b/internal/exec/runtime/testdata/conformance/send_sibling_event_unselected_accept.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_sibling_event_unselected_accept.expected.json rename to internal/exec/runtime/testdata/conformance/send_sibling_event_unselected_accept.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_sibling_event_unselected_accept.sysml b/internal/exec/runtime/testdata/conformance/send_sibling_event_unselected_accept.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_sibling_event_unselected_accept.sysml rename to internal/exec/runtime/testdata/conformance/send_sibling_event_unselected_accept.sysml diff --git a/internal/core/runtime/testdata/conformance/send_sibling_invoked_event_not_taken.expected.json b/internal/exec/runtime/testdata/conformance/send_sibling_invoked_event_not_taken.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_sibling_invoked_event_not_taken.expected.json rename to internal/exec/runtime/testdata/conformance/send_sibling_invoked_event_not_taken.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_sibling_invoked_event_not_taken.sysml b/internal/exec/runtime/testdata/conformance/send_sibling_invoked_event_not_taken.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_sibling_invoked_event_not_taken.sysml rename to internal/exec/runtime/testdata/conformance/send_sibling_invoked_event_not_taken.sysml diff --git a/internal/core/runtime/testdata/conformance/send_sibling_invoked_event_taken.expected.json b/internal/exec/runtime/testdata/conformance/send_sibling_invoked_event_taken.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_sibling_invoked_event_taken.expected.json rename to internal/exec/runtime/testdata/conformance/send_sibling_invoked_event_taken.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_sibling_invoked_event_taken.sysml b/internal/exec/runtime/testdata/conformance/send_sibling_invoked_event_taken.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_sibling_invoked_event_taken.sysml rename to internal/exec/runtime/testdata/conformance/send_sibling_invoked_event_taken.sysml diff --git a/internal/core/runtime/testdata/conformance/send_subtype_matches_supertype_accept.expected.json b/internal/exec/runtime/testdata/conformance/send_subtype_matches_supertype_accept.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_subtype_matches_supertype_accept.expected.json rename to internal/exec/runtime/testdata/conformance/send_subtype_matches_supertype_accept.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_subtype_matches_supertype_accept.sysml b/internal/exec/runtime/testdata/conformance/send_subtype_matches_supertype_accept.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_subtype_matches_supertype_accept.sysml rename to internal/exec/runtime/testdata/conformance/send_subtype_matches_supertype_accept.sysml diff --git a/internal/exec/runtime/testdata/conformance/send_this_part_addressed.expected.json b/internal/exec/runtime/testdata/conformance/send_this_part_addressed.expected.json new file mode 100644 index 0000000000..326a57dcfe --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_this_part_addressed.expected.json @@ -0,0 +1,15 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "SendThisPart::Host", + "slots": { + "mark": { + "type": "Integer", + "value": 1 + }, + "worker.got": { + "type": "Integer", + "value": 7 + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/send_this_part_addressed.sysml b/internal/exec/runtime/testdata/conformance/send_this_part_addressed.sysml new file mode 100644 index 0000000000..b0e25d7c15 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_this_part_addressed.sysml @@ -0,0 +1,38 @@ +// A send addressing `this.` from an action def owned by a part def +// delivers to that part of the performing object: `this` is the context +// occurrence the performance runs for, so the path is read from the host. +package SendThisPart { + private import ScalarValues::*; + + item def Go { + attribute n : Integer; + } + + part def Worker { + attribute got : Integer = 0; + + state def Listen { + entry; then idle; + state idle; + transition first idle accept g : Go do assign this.got := g.n then idle; + } + + exhibit state listen : Listen; + } + + part def Host { + attribute mark : Integer = 0; + part worker : Worker; + + action def Notify { + first start; + then action poke send new Go(n = 7) to this.worker; + then action note assign this.mark := 1; + then done; + } + + perform action notify : Notify; + } + + part host : Host; +} diff --git a/internal/exec/runtime/testdata/conformance/send_to_bound_parameter.expected.json b/internal/exec/runtime/testdata/conformance/send_to_bound_parameter.expected.json new file mode 100644 index 0000000000..69b1f3d076 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_bound_parameter.expected.json @@ -0,0 +1,19 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "SendToBoundParameter::Host", + "slots": { + "left.got": { + "type": "Integer", + "value": 3 + }, + "right.got": { + "type": "Integer", + "value": 7 + }, + "crew.lead.got": { + "type": "Integer", + "value": 5 + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/send_to_bound_parameter.sysml b/internal/exec/runtime/testdata/conformance/send_to_bound_parameter.sysml new file mode 100644 index 0000000000..70fe045a9e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_bound_parameter.sysml @@ -0,0 +1,60 @@ +// A send addressing a parameter of the sending action delivers to the object +// the caller bound to it: the target is read from the performance's own frame, +// not walked as a feature of the performing object, so one action notifies +// whichever part it is handed, and a chain from the parameter reaches through it. +package SendToBoundParameter { + private import ScalarValues::*; + + item def Go { + attribute n : Integer; + } + + part def Worker { + attribute got : Integer = 0; + + state def Listen { + entry; then idle; + state idle; + transition first idle accept g : Go do assign this.got := g.n then idle; + } + + exhibit state listen : Listen; + } + + part def Crew { + part lead : Worker; + } + + part def Host { + part left : Worker; + part right : Worker; + part crew : Crew; + + action def Notify { + in recipient : Worker; + in n : Integer; + first start; + then action poke send new Go(n = n) to recipient; + then done; + } + + action def Brief { + in team : Crew; + first start; + then action poke send new Go(n = 5) to team.lead; + then done; + } + + action def Round { + first start; + then action toRight : Notify { in recipient = this.right; in n = 7; } + then action toLeft : Notify { in recipient = this.left; in n = 3; } + then action toCrew : Brief { in team = this.crew; } + then done; + } + + perform action round : Round; + } + + part host : Host; +} diff --git a/internal/exec/runtime/testdata/conformance/send_to_object_held_in_feature.expected.json b/internal/exec/runtime/testdata/conformance/send_to_object_held_in_feature.expected.json new file mode 100644 index 0000000000..b8c9de0f2a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_object_held_in_feature.expected.json @@ -0,0 +1,8 @@ +{ + "type": "instance", + "instantiate": "test::Fleet", + "objects": [ + {"path": "car", "finalState": "waiting"}, + {"path": "second", "finalState": "heard"} + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_to_object_held_in_feature.sysml b/internal/exec/runtime/testdata/conformance/send_to_object_held_in_feature.sysml new file mode 100644 index 0000000000..82dc867a5b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_object_held_in_feature.sysml @@ -0,0 +1,32 @@ +// A send addressed to a feature holding an object reaches the object it holds, +// not the feature's usage: `second` yields the Car the action wrote into it, so +// its machine hears the ping while the declared `car`'s stays waiting. +package test { + item def Ping; + + part def Car { + exhibit state listening { + entry; then waiting; + + state waiting; + accept Ping then heard; + state heard; + } + } + + part def Fleet { + part car : Car; + part second : Car[0..1]; + + perform action build { + first start; + then action make { + assign second := new Car(); + } + then action ping { + send new Ping() to second; + } + then done; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/send_to_object_through_chain.expected.json b/internal/exec/runtime/testdata/conformance/send_to_object_through_chain.expected.json new file mode 100644 index 0000000000..790f3b5cc8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_object_through_chain.expected.json @@ -0,0 +1,8 @@ +{ + "type": "instance", + "instantiate": "test::Fleet", + "objects": [ + {"path": "garage.cars.1", "finalState": "waiting"}, + {"path": "garage.cars.2", "finalState": "heard"} + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_to_object_through_chain.sysml b/internal/exec/runtime/testdata/conformance/send_to_object_through_chain.sysml new file mode 100644 index 0000000000..aaf29ca1de --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_object_through_chain.sysml @@ -0,0 +1,36 @@ +// A send addressed through a chain reaches the object the chain ends at: +// `garage.cars#(2)` walks the held garage to the second object its cars hold, +// so that car's machine hears the ping while the first's stays waiting. +package test { + item def Ping; + + part def Car { + exhibit state listening { + entry; then waiting; + + state waiting; + accept Ping then heard; + state heard; + } + } + + part def Garage { + part cars : Car[0..*]; + } + + part def Fleet { + part garage : Garage; + + perform action build { + first start; + then action make { + assign garage.cars := (garage.cars, new Car()); + assign garage.cars := (garage.cars, new Car()); + } + then action ping { + send new Ping() to garage.cars#(2); + } + then done; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/send_to_second_object_of_usage.expected.json b/internal/exec/runtime/testdata/conformance/send_to_second_object_of_usage.expected.json new file mode 100644 index 0000000000..abae2ce99b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_second_object_of_usage.expected.json @@ -0,0 +1,9 @@ +{ + "type": "instance", + "trace": true, + "instantiate": "test::Fleet", + "objects": [ + {"path": "cars.1", "finalState": "waiting"}, + {"path": "cars.2", "finalState": "heard"} + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_to_second_object_of_usage.sysml b/internal/exec/runtime/testdata/conformance/send_to_second_object_of_usage.sysml new file mode 100644 index 0000000000..7628457af8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_second_object_of_usage.sysml @@ -0,0 +1,32 @@ +// A send addressed by an expression reaches the object the expression yields: +// `cars#(2)` is the second object `cars` holds, so its machine hears the ping +// while the first's, accepting the same signal, stays waiting. +package test { + item def Ping; + + part def Car { + exhibit state listening { + entry; then waiting; + + state waiting; + accept Ping then heard; + state heard; + } + } + + part def Fleet { + part cars : Car[0..*]; + + perform action build { + first start; + then action make { + assign cars := (cars, new Car()); + assign cars := (cars, new Car()); + } + then action ping { + send new Ping() to cars#(2); + } + then done; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/send_to_second_object_of_usage.trace.golden b/internal/exec/runtime/testdata/conformance/send_to_second_object_of_usage.trace.golden new file mode 100644 index 0000000000..66c34f490d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_second_object_of_usage.trace.golden @@ -0,0 +1,34 @@ +materialize: Fleet #1 +start: performed action build of #1 +materialize: build #2 +run: performed action build of #1 +step 1: token 1@make +stmt assign cars + eval feature cars -> () +materialize: Car #3 +start: exhibited state machine listening of #3 +materialize: listening #4 +enter: waiting + eval construct Car -> instance#3 + eval sequence of 2 -> (instance#3) +stmt assign cars + eval feature cars -> (instance#3) +materialize: Car #5 +start: exhibited state machine listening of #5 +materialize: listening #6 +enter: waiting + eval construct Car -> instance#5 + eval sequence of 2 -> (instance#3, instance#5) +step 2: token 1@ping +stmt send +materialize: Ping #7 + eval feature cars -> (instance#3, instance#5) + eval literal 2 -> 2 + eval index -> instance#5 +step 3: token 1@done +step 4: no active tokens +run: exhibited state machine listening of #3 +run: exhibited state machine listening of #5 +exit: waiting +enter: heard +transition: waiting -> heard (event: accept Ping) diff --git a/internal/exec/runtime/testdata/conformance/send_to_selected_variant.expected.json b/internal/exec/runtime/testdata/conformance/send_to_selected_variant.expected.json new file mode 100644 index 0000000000..38d6e9c726 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_selected_variant.expected.json @@ -0,0 +1,8 @@ +{ + "type": "instance", + "instantiate": "test::sedan", + "libraries": true, + "objects": [ + {"path": "engine", "finalState": "heard"} + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_to_selected_variant.sysml b/internal/exec/runtime/testdata/conformance/send_to_selected_variant.sysml new file mode 100644 index 0000000000..1b76701fc1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_to_selected_variant.sysml @@ -0,0 +1,39 @@ +// A send addressed by an expression reaches the object a selected variant +// materialized: `engine` is a variation whose `electric` variant `sedan` +// selects, so its value is a variant rather than a plain object — the send +// still addresses the object that variant materializes, whose machine hears +// the ping. +package test { + private import ControlFunctions::*; + + item def Ping; + + part def Engine { + exhibit state listening { + entry; then waiting; + + state waiting; + accept Ping then heard; + state heard; + } + } + + abstract part family { + variation part engine : Engine { + variant part petrol : Engine; + variant part electric : Engine; + } + } + + part sedan :> family { + part :>> engine = engine::electric; + + perform action run { + first start; + then action ping { + send new Ping() to engine->select { in e; true }; + } + then done; + } + } +} diff --git a/internal/core/runtime/testdata/conformance/send_typed_message_is_not_event.expected.json b/internal/exec/runtime/testdata/conformance/send_typed_message_is_not_event.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_typed_message_is_not_event.expected.json rename to internal/exec/runtime/testdata/conformance/send_typed_message_is_not_event.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_typed_message_is_not_event.sysml b/internal/exec/runtime/testdata/conformance/send_typed_message_is_not_event.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_typed_message_is_not_event.sysml rename to internal/exec/runtime/testdata/conformance/send_typed_message_is_not_event.sysml diff --git a/internal/exec/runtime/testdata/conformance/send_untyped_port_inward_binding.expected.json b/internal/exec/runtime/testdata/conformance/send_untyped_port_inward_binding.expected.json new file mode 100644 index 0000000000..0e50b40be5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_untyped_port_inward_binding.expected.json @@ -0,0 +1,12 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "SendUntypedPort::Rig", + "objects": [ + { + "path": "b.r", + "finalState": "got", + "stateVisits": ["idle", "got"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_untyped_port_inward_binding.sysml b/internal/exec/runtime/testdata/conformance/send_untyped_port_inward_binding.sysml new file mode 100644 index 0000000000..75deeb41b9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_untyped_port_inward_binding.sysml @@ -0,0 +1,43 @@ +// Untyped ports are Ports::Port objects, so a binding joins the box's boundary port and the nested +// part's port as one: a send arriving at the box reaches the machine accepting via the nested port. +package SendUntypedPort { + item def Go; + + part def Sender { + port tx; + + action def Fire { + first start; + then action go send new Go() via this.tx; + then done; + } + + perform action fire : Fire; + } + + part def Receiver { + port rx; + + exhibit state life { + entry; then idle; + state idle; + state got; + + transition first idle + accept Go via rx + then got; + } + } + + part def Box { + port cmd; + part r : Receiver; + bind cmd = r.rx; + } + + part def Rig { + part s : Sender; + part b : Box; + connect s.tx to b.cmd; + } +} diff --git a/internal/exec/runtime/testdata/conformance/send_untyped_port_inward_binding.trace.golden b/internal/exec/runtime/testdata/conformance/send_untyped_port_inward_binding.trace.golden new file mode 100644 index 0000000000..1d11fdf53b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_untyped_port_inward_binding.trace.golden @@ -0,0 +1,20 @@ +materialize: Rig #1 +materialize: Sender #2 +start: performed action fire of #2 +materialize: Fire #3 +materialize: Box #4 +materialize: Receiver #5 +start: exhibited state machine life of #5 +materialize: life #6 +enter: idle +run: performed action fire of #2 +step 1: token 1@go +stmt send +materialize: Go #7 +materialize: cmd #8 +step 2: token 1@done +step 3: no active tokens +run: exhibited state machine life of #5 +exit: idle +enter: got +transition: idle -> got (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/send_via_bound_port_reference.expected.json b/internal/exec/runtime/testdata/conformance/send_via_bound_port_reference.expected.json new file mode 100644 index 0000000000..1c1a949f41 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_via_bound_port_reference.expected.json @@ -0,0 +1,27 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "SendViaBoundPortReference::Rig", + "materialization": {}, + "objects": [ + { + "path": "", + "events": [{"signal": "Go"}], + "finalState": "fired", + "stateVisits": ["idle", "firing", "fired"], + "slots": {"lastReply": {"type": "Instance"}} + }, + { + "path": "wired", + "finalState": "done", + "stateVisits": ["idle", "done"], + "slots": {"heard": {"type": "Boolean", "value": true}} + }, + { + "path": "decoy", + "finalState": "idle", + "stateVisits": ["idle"], + "slots": {"heard": {"type": "Boolean", "value": false}} + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_via_bound_port_reference.sysml b/internal/exec/runtime/testdata/conformance/send_via_bound_port_reference.sysml new file mode 100644 index 0000000000..a14af6d3ff --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_via_bound_port_reference.sysml @@ -0,0 +1,78 @@ +// A via path that is just a bound reference to a port leaves that port: with `tx` +// bound to `device.tx`, `via tx` sends from the device's port, not from the +// performer's own same-named port, and `accept … via tx` hears at the device's port. +package SendViaBoundPortReference { + item def Go; + item def Ping; + item def Pong; + + port def PingOut { + out item ping : Ping; + in item pong : Pong; + } + + part def Device { + port tx : PingOut; + } + + part def Listener { + port local : ~PingOut; + attribute heard : Boolean = false; + + exhibit state life { + entry; then idle; + state idle; + transition first idle accept Ping via local do send new Pong() via local then done; + state done { + entry assign heard := true; + } + } + } + + action def Fire { + in ref port tx : PingOut; + out ref item reply : Pong; + first start then go; + action go { + send new Ping() via tx; + } + first go then wait; + action wait accept msg : Pong via tx; + first wait then keep; + action keep { + assign reply := msg; + } + first keep then done; + } + + part def Rig { + port tx : PingOut; + ref item lastReply : Pong[0..1]; + part device : Device; + part wired : Listener; + part decoy : Listener; + connect device.tx to wired.local; + connect tx to decoy.local; + + exhibit state life { + entry; then idle; + state idle; + transition first idle accept Go then firing; + state firing { + do action run { + first start then fire; + action fire : Fire { + in ref port :>> tx = device.tx; + } + first fire then keep; + action keep { + assign lastReply := fire.reply; + } + first keep then done; + } + } + state fired; + transition first firing then fired; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/send_via_bound_reference_behavior_connection.expected.json b/internal/exec/runtime/testdata/conformance/send_via_bound_reference_behavior_connection.expected.json new file mode 100644 index 0000000000..e7cbaf8e56 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_via_bound_reference_behavior_connection.expected.json @@ -0,0 +1,29 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "SendViaBoundReferenceBehaviorConnection::Rig", + "materialization": {}, + "objects": [ + { + "path": "", + "events": [{"signal": "Go"}], + "finalState": "fired", + "stateVisits": ["idle", "firing", "fired"] + }, + { + "path": "bound", + "finalState": "heard", + "stateVisits": ["idle", "heard"] + }, + { + "path": "wired", + "finalState": "heard", + "stateVisits": ["idle", "heard"] + }, + { + "path": "decoy", + "finalState": "idle", + "stateVisits": ["idle"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_via_bound_reference_behavior_connection.sysml b/internal/exec/runtime/testdata/conformance/send_via_bound_reference_behavior_connection.sysml new file mode 100644 index 0000000000..2fe5e973cf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_via_bound_reference_behavior_connection.sysml @@ -0,0 +1,64 @@ +// An action's own connector joins the ports of two objects its reference parameters +// are bound to: `connect dev.p to snk.local` keeps its ends as written, so a +// `send … via dev.p` travels it to the bound sink's port. The send leaves the bound +// device's port too, so a connection the performer declares from that port carries a +// copy as well: both listeners hear it, and the sender's own same-named part is never +// consulted (SysML v2 §7.16). +package SendViaBoundReferenceBehaviorConnection { + item def Go; + item def Ping; + + port def PingOut { + out item ping : Ping; + } + + part def Device { + port p : PingOut; + } + + part def Listener { + port local : ~PingOut; + + exhibit state life { + entry; then idle; + state idle; + transition first idle accept Ping via local then heard; + state heard; + } + } + + action def Fire { + in ref dev : Device; + in ref snk : Listener; + connect dev.p to snk.local; + first start then go; + action go { + send new Ping() via dev.p; + } + first go then done; + } + + part def Rig { + part dev : Device; + part device : Device; + part bound : Listener; + part wired : Listener; + part decoy : Listener; + connect device.p to wired.local; + connect dev.p to decoy.local; + + exhibit state life { + entry; then idle; + state idle; + transition first idle accept Go then firing; + state firing { + do action fire : Fire { + in ref :>> dev = device; + in ref :>> snk = bound; + } + } + state fired; + transition first firing then fired; + } + } +} diff --git a/internal/core/runtime/testdata/conformance/send_via_owner_connection.expected.json b/internal/exec/runtime/testdata/conformance/send_via_owner_connection.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_via_owner_connection.expected.json rename to internal/exec/runtime/testdata/conformance/send_via_owner_connection.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_via_owner_connection.sysml b/internal/exec/runtime/testdata/conformance/send_via_owner_connection.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_via_owner_connection.sysml rename to internal/exec/runtime/testdata/conformance/send_via_owner_connection.sysml diff --git a/internal/core/runtime/testdata/conformance/send_via_owner_nested_port_path.expected.json b/internal/exec/runtime/testdata/conformance/send_via_owner_nested_port_path.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_via_owner_nested_port_path.expected.json rename to internal/exec/runtime/testdata/conformance/send_via_owner_nested_port_path.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_via_owner_nested_port_path.sysml b/internal/exec/runtime/testdata/conformance/send_via_owner_nested_port_path.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_via_owner_nested_port_path.sysml rename to internal/exec/runtime/testdata/conformance/send_via_owner_nested_port_path.sysml diff --git a/internal/core/runtime/testdata/conformance/send_via_owner_port_from_nested_part.expected.json b/internal/exec/runtime/testdata/conformance/send_via_owner_port_from_nested_part.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/send_via_owner_port_from_nested_part.expected.json rename to internal/exec/runtime/testdata/conformance/send_via_owner_port_from_nested_part.expected.json diff --git a/internal/core/runtime/testdata/conformance/send_via_owner_port_from_nested_part.sysml b/internal/exec/runtime/testdata/conformance/send_via_owner_port_from_nested_part.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/send_via_owner_port_from_nested_part.sysml rename to internal/exec/runtime/testdata/conformance/send_via_owner_port_from_nested_part.sysml diff --git a/internal/exec/runtime/testdata/conformance/send_via_this_port.expected.json b/internal/exec/runtime/testdata/conformance/send_via_this_port.expected.json new file mode 100644 index 0000000000..b96941532d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_via_this_port.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "evaluate": "SendViaThisPort::Node::Machine", + "performers": [ + { + "object": "SendViaThisPort::solo", + "finalState": "heard", + "stateVisits": ["begin", "sending", "heard"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_via_this_port.sysml b/internal/exec/runtime/testdata/conformance/send_via_this_port.sysml new file mode 100644 index 0000000000..41bc073cd2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_via_this_port.sysml @@ -0,0 +1,36 @@ +// `via this.tx` names the performing part's own port tx, as `via tx` does: the send +// crosses that port's connector rather than looking for a nested feature `this.tx`. +package SendViaThisPort { + item def Ping; + + port def PingOut { + out item ping : Ping; + } + port def PingIn { + in item ping : Ping; + } + + part def Node { + port tx : PingOut; + port rx : PingIn; + connect tx to rx; + + state Machine { + entry; then begin; + state begin; + + state sending { + entry send new Ping() via this.tx; + } + state heard; + + succession first begin then sending; + + transition first sending + accept Ping via rx + then heard; + } + } + + part solo : Node; +} diff --git a/internal/exec/runtime/testdata/conformance/send_via_to_second_object_of_usage.expected.json b/internal/exec/runtime/testdata/conformance/send_via_to_second_object_of_usage.expected.json new file mode 100644 index 0000000000..abae2ce99b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_via_to_second_object_of_usage.expected.json @@ -0,0 +1,9 @@ +{ + "type": "instance", + "trace": true, + "instantiate": "test::Fleet", + "objects": [ + {"path": "cars.1", "finalState": "waiting"}, + {"path": "cars.2", "finalState": "heard"} + ] +} diff --git a/internal/exec/runtime/testdata/conformance/send_via_to_second_object_of_usage.sysml b/internal/exec/runtime/testdata/conformance/send_via_to_second_object_of_usage.sysml new file mode 100644 index 0000000000..8a1e4a6370 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_via_to_second_object_of_usage.sysml @@ -0,0 +1,42 @@ +// A routed send whose `to` clause is an expression reaches the object it +// yields: the port `out` joins every car's `p`, and `cars#(2)` confines the +// copies to the second, so its machine hears the ping while the first's stays +// waiting. +package test { + item def Ping; + + port def PingPort { + in item ping : Ping; + } + + part def Car { + port p : PingPort; + + exhibit state listening { + entry; then waiting; + + state waiting; + accept Ping via p then heard; + state heard; + } + } + + part def Fleet { + part cars : Car[0..*]; + port out : ~PingPort; + + connect out to cars.p; + + perform action build { + first start; + then action make { + assign cars := (cars, new Car()); + assign cars := (cars, new Car()); + } + then action ping { + send new Ping() via out to cars#(2); + } + then done; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/send_via_to_second_object_of_usage.trace.golden b/internal/exec/runtime/testdata/conformance/send_via_to_second_object_of_usage.trace.golden new file mode 100644 index 0000000000..08ba75685a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/send_via_to_second_object_of_usage.trace.golden @@ -0,0 +1,36 @@ +materialize: Fleet #1 +start: performed action build of #1 +materialize: build #2 +run: performed action build of #1 +step 1: token 1@make +stmt assign cars + eval feature cars -> () +materialize: Car #3 +start: exhibited state machine listening of #3 +materialize: listening #4 +enter: waiting + eval construct Car -> instance#3 + eval sequence of 2 -> (instance#3) +stmt assign cars + eval feature cars -> (instance#3) +materialize: Car #5 +start: exhibited state machine listening of #5 +materialize: listening #6 +enter: waiting + eval construct Car -> instance#5 + eval sequence of 2 -> (instance#3, instance#5) +step 2: token 1@ping +stmt send +materialize: Ping #7 + eval feature cars -> (instance#3, instance#5) + eval literal 2 -> 2 + eval index -> instance#5 +materialize: PingPort #8 +step 3: token 1@done +step 4: no active tokens +run: exhibited state machine listening of #3 +materialize: PingPort #9 +run: exhibited state machine listening of #5 +exit: waiting +enter: heard +transition: waiting -> heard (event: accept Ping) diff --git a/internal/core/runtime/testdata/conformance/snapshot_succession_portion_reads.expected.json b/internal/exec/runtime/testdata/conformance/snapshot_succession_portion_reads.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/snapshot_succession_portion_reads.expected.json rename to internal/exec/runtime/testdata/conformance/snapshot_succession_portion_reads.expected.json diff --git a/internal/core/runtime/testdata/conformance/snapshot_succession_portion_reads.sysml b/internal/exec/runtime/testdata/conformance/snapshot_succession_portion_reads.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/snapshot_succession_portion_reads.sysml rename to internal/exec/runtime/testdata/conformance/snapshot_succession_portion_reads.sysml diff --git a/internal/core/runtime/testdata/conformance/standalone_action_exchanges_values_through_parameters.expected.json b/internal/exec/runtime/testdata/conformance/standalone_action_exchanges_values_through_parameters.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/standalone_action_exchanges_values_through_parameters.expected.json rename to internal/exec/runtime/testdata/conformance/standalone_action_exchanges_values_through_parameters.expected.json diff --git a/internal/core/runtime/testdata/conformance/standalone_action_exchanges_values_through_parameters.sysml b/internal/exec/runtime/testdata/conformance/standalone_action_exchanges_values_through_parameters.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/standalone_action_exchanges_values_through_parameters.sysml rename to internal/exec/runtime/testdata/conformance/standalone_action_exchanges_values_through_parameters.sysml diff --git a/internal/core/runtime/testdata/conformance/state_anonymous_action_body.expected.json b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_anonymous_action_body.expected.json rename to internal/exec/runtime/testdata/conformance/state_anonymous_action_body.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_anonymous_action_body.sysml b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_anonymous_action_body.sysml rename to internal/exec/runtime/testdata/conformance/state_anonymous_action_body.sysml diff --git a/internal/core/runtime/testdata/conformance/state_anonymous_action_body.trace.golden b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_anonymous_action_body.trace.golden rename to internal/exec/runtime/testdata/conformance/state_anonymous_action_body.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_anonymous_body_perform.expected.json b/internal/exec/runtime/testdata/conformance/state_anonymous_body_perform.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_anonymous_body_perform.expected.json rename to internal/exec/runtime/testdata/conformance/state_anonymous_body_perform.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_anonymous_body_perform.sysml b/internal/exec/runtime/testdata/conformance/state_anonymous_body_perform.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_anonymous_body_perform.sysml rename to internal/exec/runtime/testdata/conformance/state_anonymous_body_perform.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.check.expected.json b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.check.expected.json new file mode 100644 index 0000000000..56e6386767 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"seq": ["123456", "124356", "124536", "124563", "142356", "142536", "142563", "145236", "145263", "145623", "412356", "412536", "412563", "415236", "415263", "415623", "451236", "451263", "451623", "456123"]}, + "agreed": {"finalState": "ldone+rdone"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.declared.trace.golden new file mode 100644 index 0000000000..b2c41ce010 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.declared.trace.golden @@ -0,0 +1,58 @@ +exit: lstart +enter: lwork +transition: lstart -> lwork +do: lwork +stmt action body + stmt assign seq + eval feature seq -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: rstart +enter: rwork +transition: rstart -> rwork +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt assign seq + eval feature seq -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +do: rwork +stmt action body + stmt assign seq + eval feature seq -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 4 -> 4 + eval operator + -> 124 +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt assign seq + eval feature seq -> 124 + eval literal 10 -> 10 + eval operator * -> 1240 + eval literal 3 -> 3 + eval operator + -> 1243 +do: rwork + stmt assign seq + eval feature seq -> 1243 + eval literal 10 -> 10 + eval operator * -> 12430 + eval literal 5 -> 5 + eval operator + -> 12435 +exit: lwork +enter: ldone +transition: lwork -> ldone +do: rwork + stmt assign seq + eval feature seq -> 12435 + eval literal 10 -> 10 + eval operator * -> 124350 + eval literal 6 -> 6 + eval operator + -> 124356 +exit: rwork +enter: rdone +transition: rwork -> rdone diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.expected.json b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.expected.json new file mode 100644 index 0000000000..2c9af354e8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.expected.json @@ -0,0 +1,86 @@ +{ + "type": "state", + "outcomes": [ + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 123456 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 124356 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 124536 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 124563 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 142356 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 142536 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 142563 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 145236 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 145263 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 145623 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 412356 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 412536 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 412563 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 415236 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 415263 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 415623 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 451236 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 451263 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 451623 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 456123 } } + } + ], + "admissible": "Do behaviors of sibling regions active at one instant: each proceeds, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.seed-1.trace.golden new file mode 100644 index 0000000000..9d125119cd --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.seed-1.trace.golden @@ -0,0 +1,58 @@ +exit: rstart +enter: rwork +transition: rstart -> rwork +do: rwork +stmt action body + stmt assign seq + eval feature seq -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 4 -> 4 + eval operator + -> 4 +exit: lstart +enter: lwork +transition: lstart -> lwork +choice do round at t=0.0: states rwork, lwork react (unordered; took rwork first) +do: rwork + stmt assign seq + eval feature seq -> 4 + eval literal 10 -> 10 + eval operator * -> 40 + eval literal 5 -> 5 + eval operator + -> 45 +do: lwork +stmt action body + stmt assign seq + eval feature seq -> 45 + eval literal 10 -> 10 + eval operator * -> 450 + eval literal 1 -> 1 + eval operator + -> 451 +choice do round at t=0.0: states rwork, lwork react (unordered; took rwork first) +do: rwork + stmt assign seq + eval feature seq -> 451 + eval literal 10 -> 10 + eval operator * -> 4510 + eval literal 6 -> 6 + eval operator + -> 4516 +do: lwork + stmt assign seq + eval feature seq -> 4516 + eval literal 10 -> 10 + eval operator * -> 45160 + eval literal 2 -> 2 + eval operator + -> 45162 +exit: rwork +enter: rdone +transition: rwork -> rdone +do: lwork + stmt assign seq + eval feature seq -> 45162 + eval literal 10 -> 10 + eval operator * -> 451620 + eval literal 3 -> 3 + eval operator + -> 451623 +exit: lwork +enter: ldone +transition: lwork -> ldone diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.sysml b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.sysml new file mode 100644 index 0000000000..f7399e008e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.sysml @@ -0,0 +1,42 @@ +package Test { + // An anonymous do body written as one action yields after each statement, as + // state_concurrent_do's one-action-per-statement `do { … }` form does, so the + // two regions interleave inside it statement by statement: 124356 with the + // entry draw in declaration order, the same digits in the same set of orders + // as that form. The body is not one atomic action of a round. + state def AtomicDo parallel { + attribute seq : Integer = 0; + + state left { + entry; then lstart; + state lstart; + state lwork { + do action { + assign seq := seq * 10 + 1; + assign seq := seq * 10 + 2; + assign seq := seq * 10 + 3; + } + } + state ldone; + + succession first lstart then lwork; + succession first lwork then ldone; + } + + state right { + entry; then rstart; + state rstart; + state rwork { + do action { + assign seq := seq * 10 + 4; + assign seq := seq * 10 + 5; + assign seq := seq * 10 + 6; + } + } + state rdone; + + succession first rstart then rwork; + succession first rwork then rdone; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.trace.golden b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.trace.golden new file mode 100644 index 0000000000..b2c41ce010 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_do_atomic.trace.golden @@ -0,0 +1,58 @@ +exit: lstart +enter: lwork +transition: lstart -> lwork +do: lwork +stmt action body + stmt assign seq + eval feature seq -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: rstart +enter: rwork +transition: rstart -> rwork +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt assign seq + eval feature seq -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +do: rwork +stmt action body + stmt assign seq + eval feature seq -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 4 -> 4 + eval operator + -> 124 +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt assign seq + eval feature seq -> 124 + eval literal 10 -> 10 + eval operator * -> 1240 + eval literal 3 -> 3 + eval operator + -> 1243 +do: rwork + stmt assign seq + eval feature seq -> 1243 + eval literal 10 -> 10 + eval operator * -> 12430 + eval literal 5 -> 5 + eval operator + -> 12435 +exit: lwork +enter: ldone +transition: lwork -> ldone +do: rwork + stmt assign seq + eval feature seq -> 12435 + eval literal 10 -> 10 + eval operator * -> 124350 + eval literal 6 -> 6 + eval operator + -> 124356 +exit: rwork +enter: rdone +transition: rwork -> rdone diff --git a/internal/core/runtime/testdata/conformance/state_attribute_without_performer.expected.json b/internal/exec/runtime/testdata/conformance/state_attribute_without_performer.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_attribute_without_performer.expected.json rename to internal/exec/runtime/testdata/conformance/state_attribute_without_performer.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_attribute_without_performer.sysml b/internal/exec/runtime/testdata/conformance/state_attribute_without_performer.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_attribute_without_performer.sysml rename to internal/exec/runtime/testdata/conformance/state_attribute_without_performer.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_behavior_action_of_no_content.expected.json b/internal/exec/runtime/testdata/conformance/state_behavior_action_of_no_content.expected.json new file mode 100644 index 0000000000..23752fca32 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_behavior_action_of_no_content.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "events": [{"signal": "Go"}], + "finalState": "work", + "stateVisits": ["idle", "work"], + "outputs": { + "counter": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_behavior_action_of_no_content.sysml b/internal/exec/runtime/testdata/conformance/state_behavior_action_of_no_content.sysml new file mode 100644 index 0000000000..c66be32e8d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_behavior_action_of_no_content.sysml @@ -0,0 +1,29 @@ +package test { + private import ScalarValues::*; + + // A behavior written as an action usage with neither a body nor an action + // performed (`entry action hello;`) names an action of no content: it + // executes as nothing, so the machine passes through and the assignment + // beside it still runs. + item def Go; + + state TestActionOfNoContent { + attribute counter : Integer = 0; + + entry; then idle; + state idle { + entry action hello; + do action log; + exit action bye; + } + state work { + entry action tick { + assign counter := counter + 1; + } + } + + transition first idle accept Go + do action note + then work; + } +} diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_node_arguments_before_defaults.expected.json b/internal/exec/runtime/testdata/conformance/state_block_flow_node_arguments_before_defaults.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_node_arguments_before_defaults.expected.json rename to internal/exec/runtime/testdata/conformance/state_block_flow_node_arguments_before_defaults.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_node_arguments_before_defaults.sysml b/internal/exec/runtime/testdata/conformance/state_block_flow_node_arguments_before_defaults.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_node_arguments_before_defaults.sysml rename to internal/exec/runtime/testdata/conformance/state_block_flow_node_arguments_before_defaults.sysml diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_node_pin_bindings.expected.json b/internal/exec/runtime/testdata/conformance/state_block_flow_node_pin_bindings.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_node_pin_bindings.expected.json rename to internal/exec/runtime/testdata/conformance/state_block_flow_node_pin_bindings.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_node_pin_bindings.sysml b/internal/exec/runtime/testdata/conformance/state_block_flow_node_pin_bindings.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_node_pin_bindings.sysml rename to internal/exec/runtime/testdata/conformance/state_block_flow_node_pin_bindings.sysml diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_node_pins_in_exit_and_transition.expected.json b/internal/exec/runtime/testdata/conformance/state_block_flow_node_pins_in_exit_and_transition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_node_pins_in_exit_and_transition.expected.json rename to internal/exec/runtime/testdata/conformance/state_block_flow_node_pins_in_exit_and_transition.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_node_pins_in_exit_and_transition.sysml b/internal/exec/runtime/testdata/conformance/state_block_flow_node_pins_in_exit_and_transition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_node_pins_in_exit_and_transition.sysml rename to internal/exec/runtime/testdata/conformance/state_block_flow_node_pins_in_exit_and_transition.sysml diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_node_unvalued_pin.expected.json b/internal/exec/runtime/testdata/conformance/state_block_flow_node_unvalued_pin.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_node_unvalued_pin.expected.json rename to internal/exec/runtime/testdata/conformance/state_block_flow_node_unvalued_pin.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_node_unvalued_pin.sysml b/internal/exec/runtime/testdata/conformance/state_block_flow_node_unvalued_pin.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_node_unvalued_pin.sysml rename to internal/exec/runtime/testdata/conformance/state_block_flow_node_unvalued_pin.sysml diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_sibling_pins.expected.json b/internal/exec/runtime/testdata/conformance/state_block_flow_sibling_pins.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_sibling_pins.expected.json rename to internal/exec/runtime/testdata/conformance/state_block_flow_sibling_pins.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_sibling_pins.sysml b/internal/exec/runtime/testdata/conformance/state_block_flow_sibling_pins.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_sibling_pins.sysml rename to internal/exec/runtime/testdata/conformance/state_block_flow_sibling_pins.sysml diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_sibling_pins.trace.golden b/internal/exec/runtime/testdata/conformance/state_block_flow_sibling_pins.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_sibling_pins.trace.golden rename to internal/exec/runtime/testdata/conformance/state_block_flow_sibling_pins.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_typed_node.expected.json b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_typed_node.expected.json rename to internal/exec/runtime/testdata/conformance/state_block_flow_typed_node.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_typed_node.sysml b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_typed_node.sysml rename to internal/exec/runtime/testdata/conformance/state_block_flow_typed_node.sysml diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.expected.json b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.expected.json rename to internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.sysml b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.sysml rename to internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.sysml diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_typed_node_pins.expected.json b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_pins.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_typed_node_pins.expected.json rename to internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_pins.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_block_flow_typed_node_pins.sysml b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_pins.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_block_flow_typed_node_pins.sysml rename to internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_pins.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_body_flow_succession.expected.json b/internal/exec/runtime/testdata/conformance/state_body_flow_succession.expected.json new file mode 100644 index 0000000000..01763ae433 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_body_flow_succession.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "finalState": "done", + "stateVisits": ["init", "active", "done"], + "outputs": { + "total": {"type": "Integer", "value": 22} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_body_flow_succession.sysml b/internal/exec/runtime/testdata/conformance/state_body_flow_succession.sysml new file mode 100644 index 0000000000..9bbe33f27e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_body_flow_succession.sysml @@ -0,0 +1,21 @@ +package test { + private import ScalarValues::*; + + state def S { + attribute total : Integer = 0; + entry; then init; + state init; + state active { + do action work { + for i in 1..2 { + action b { assign total := total + 10; } + action a { assign total := total + 1; } + succession a then b; + } + } + } + state done; + succession first init then active; + succession first active then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_body_flow_succession.trace.golden b/internal/exec/runtime/testdata/conformance/state_body_flow_succession.trace.golden new file mode 100644 index 0000000000..4bb07aef5a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_body_flow_succession.trace.golden @@ -0,0 +1,37 @@ +exit: init +enter: active +transition: init -> active +do: active +stmt action body + stmt for i + eval literal 1 -> 1 + eval literal 2 -> 2 + eval operator .. -> (1, 2) + iteration 1 +enter action node: loop body of state behavior work + stmt assign total + eval feature total -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +do: active + stmt assign total + eval feature total -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +leave action node: loop body of state behavior work +do: active + iteration 2 +enter action node: loop body of state behavior work + stmt assign total + eval feature total -> 11 + eval literal 1 -> 1 + eval operator + -> 12 +do: active + stmt assign total + eval feature total -> 12 + eval literal 10 -> 10 + eval operator + -> 22 +leave action node: loop body of state behavior work +exit: active +enter: done +transition: active -> done diff --git a/internal/core/runtime/testdata/conformance/state_body_quantity_scope.expected.json b/internal/exec/runtime/testdata/conformance/state_body_quantity_scope.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_body_quantity_scope.expected.json rename to internal/exec/runtime/testdata/conformance/state_body_quantity_scope.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_body_quantity_scope.sysml b/internal/exec/runtime/testdata/conformance/state_body_quantity_scope.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_body_quantity_scope.sysml rename to internal/exec/runtime/testdata/conformance/state_body_quantity_scope.sysml diff --git a/internal/core/runtime/testdata/conformance/state_body_state_local_member.expected.json b/internal/exec/runtime/testdata/conformance/state_body_state_local_member.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_body_state_local_member.expected.json rename to internal/exec/runtime/testdata/conformance/state_body_state_local_member.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_body_state_local_member.sysml b/internal/exec/runtime/testdata/conformance/state_body_state_local_member.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_body_state_local_member.sysml rename to internal/exec/runtime/testdata/conformance/state_body_state_local_member.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_braced_block_local_attribute.expected.json b/internal/exec/runtime/testdata/conformance/state_braced_block_local_attribute.expected.json new file mode 100644 index 0000000000..1c4c32698e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_braced_block_local_attribute.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "finalState": "finished", + "outputs": { + "log": { "type": "Integer", "value": 321579 }, + "k": { "type": "Integer", "value": 100 } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_braced_block_local_attribute.sysml b/internal/exec/runtime/testdata/conformance/state_braced_block_local_attribute.sysml new file mode 100644 index 0000000000..32a0bdd4df --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_braced_block_local_attribute.sysml @@ -0,0 +1,43 @@ +package Test { + // A braced `entry { … }`, `do { … }`, `exit { … }` or transition `do { … }` + // is one anonymous action, so an attribute declared inside it is local to + // the block: each block declares its own `k`, shadowing the machine's, and + // the machine's `k` is what the blocks leave untouched. Each digit records + // one statement: the entry loop (3, 2, 1), the do block (5), the exit block + // (7), then the transition's effect (9). + private import ScalarValues::*; + + state def Blocks { + attribute log : Integer = 0; + attribute k : Integer = 100; + + entry; then start; + state start; + + state working { + entry { + attribute k : Integer = 3; + while k > 0 { + assign log := log * 10 + k; + assign k := k - 1; + } + } + do { + attribute k : Integer = 5; + assign log := log * 10 + k; + } + exit { + attribute k : Integer = 7; + assign log := log * 10 + k; + } + } + + state finished; + + succession first start then working; + transition first working do { + attribute k : Integer = 9; + assign log := log * 10 + k; + } then finished; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_braced_block_local_attribute.trace.golden b/internal/exec/runtime/testdata/conformance/state_braced_block_local_attribute.trace.golden new file mode 100644 index 0000000000..6d47d88673 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_braced_block_local_attribute.trace.golden @@ -0,0 +1,85 @@ +exit: start +enter: working (entry action) +stmt action body + stmt declare k + eval literal 3 -> 3 + stmt while + iteration 1 + eval feature k -> 3 + eval literal 0 -> 0 + eval operator > -> true + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval feature k -> 3 + eval operator + -> 3 + stmt assign k + eval feature k -> 3 + eval literal 1 -> 1 + eval operator - -> 2 + iteration 2 + eval feature k -> 2 + eval literal 0 -> 0 + eval operator > -> true + stmt assign log + eval feature log -> 3 + eval literal 10 -> 10 + eval operator * -> 30 + eval feature k -> 2 + eval operator + -> 32 + stmt assign k + eval feature k -> 2 + eval literal 1 -> 1 + eval operator - -> 1 + iteration 3 + eval feature k -> 1 + eval literal 0 -> 0 + eval operator > -> true + stmt assign log + eval feature log -> 32 + eval literal 10 -> 10 + eval operator * -> 320 + eval feature k -> 1 + eval operator + -> 321 + stmt assign k + eval feature k -> 1 + eval literal 1 -> 1 + eval operator - -> 0 + iteration 4 + eval feature k -> 0 + eval literal 0 -> 0 + eval operator > -> false +transition: start -> working +do: working +stmt action body + stmt declare k + eval literal 5 -> 5 +do: working + stmt assign log + eval feature log -> 321 + eval literal 10 -> 10 + eval operator * -> 3210 + eval feature k -> 5 + eval operator + -> 3215 +exit: working (exit action) +stmt action body + stmt declare k + eval literal 7 -> 7 + stmt assign log + eval feature log -> 3215 + eval literal 10 -> 10 + eval operator * -> 32150 + eval feature k -> 7 + eval operator + -> 32157 +stmt action body + stmt declare k + eval literal 9 -> 9 + stmt assign log + eval feature log -> 32157 + eval literal 10 -> 10 + eval operator * -> 321570 + eval feature k -> 9 + eval operator + -> 321579 +enter: finished +transition: working -> finished diff --git a/internal/core/runtime/testdata/conformance/state_call_trigger.expected.json b/internal/exec/runtime/testdata/conformance/state_call_trigger.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_call_trigger.expected.json rename to internal/exec/runtime/testdata/conformance/state_call_trigger.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_call_trigger.sysml b/internal/exec/runtime/testdata/conformance/state_call_trigger.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_call_trigger.sysml rename to internal/exec/runtime/testdata/conformance/state_call_trigger.sysml diff --git a/internal/core/runtime/testdata/conformance/state_call_trigger_guard.expected.json b/internal/exec/runtime/testdata/conformance/state_call_trigger_guard.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_call_trigger_guard.expected.json rename to internal/exec/runtime/testdata/conformance/state_call_trigger_guard.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_call_trigger_guard.sysml b/internal/exec/runtime/testdata/conformance/state_call_trigger_guard.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_call_trigger_guard.sysml rename to internal/exec/runtime/testdata/conformance/state_call_trigger_guard.sysml diff --git a/internal/core/runtime/testdata/conformance/state_call_trigger_nested.expected.json b/internal/exec/runtime/testdata/conformance/state_call_trigger_nested.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_call_trigger_nested.expected.json rename to internal/exec/runtime/testdata/conformance/state_call_trigger_nested.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_call_trigger_nested.sysml b/internal/exec/runtime/testdata/conformance/state_call_trigger_nested.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_call_trigger_nested.sysml rename to internal/exec/runtime/testdata/conformance/state_call_trigger_nested.sysml diff --git a/internal/core/runtime/testdata/conformance/state_call_trigger_regions.check.expected.json b/internal/exec/runtime/testdata/conformance/state_call_trigger_regions.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_call_trigger_regions.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_call_trigger_regions.check.expected.json diff --git a/internal/exec/runtime/testdata/conformance/state_call_trigger_regions.expected.json b/internal/exec/runtime/testdata/conformance/state_call_trigger_regions.expected.json new file mode 100644 index 0000000000..63f1288b08 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_call_trigger_regions.expected.json @@ -0,0 +1,25 @@ +{ + "type": "state", + "events": [ + {"call": "wake"} + ], + "outcomes": [ + { + "finalState": "Chiming+Lit", + "stateVisits": ["start", "start", "Dimmed", "Muted", "Chiming", "Lit"] + }, + { + "finalState": "Chiming+Lit", + "stateVisits": ["start", "start", "Dimmed", "Muted", "Lit", "Chiming"] + }, + { + "finalState": "Chiming+Lit", + "stateVisits": ["start", "start", "Muted", "Dimmed", "Chiming", "Lit"] + }, + { + "finalState": "Chiming+Lit", + "stateVisits": ["start", "start", "Muted", "Dimmed", "Lit", "Chiming"] + } + ], + "admissible": "Transitions in sibling regions enabled by one event: each fires, in which order is open" +} diff --git a/internal/core/runtime/testdata/conformance/state_call_trigger_regions.sysml b/internal/exec/runtime/testdata/conformance/state_call_trigger_regions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_call_trigger_regions.sysml rename to internal/exec/runtime/testdata/conformance/state_call_trigger_regions.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_call_trigger_results.expected.json b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.expected.json new file mode 100644 index 0000000000..e57d757814 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.expected.json @@ -0,0 +1,32 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "call": "compute", + "args": { + "value": {"type": "Integer", "value": 3} + }, + "results": { + "result": {"type": "Integer", "value": -6} + } + }, + { + "call": "compute", + "args": { + "value": {"type": "Integer", "value": 5} + }, + "results": { + "result": {"type": "Integer", "value": 10} + } + }, + { + "signal": "Settle" + } + ], + "finalState": "done", + "stateVisits": ["idle", "doubled", "settled", "done"], + "outputs": { + "result": {"type": "Integer", "value": 10} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_call_trigger_results.sysml b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.sysml new file mode 100644 index 0000000000..1ec5920e3f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.sysml @@ -0,0 +1,47 @@ +package CallTriggerResults { + // A caller of an operation the machine handles as a call event is released + // once the run-to-completion step dispatching it ends, with the outputs the + // behaviors that step triggered returned to the machine: the first call's + // effect writes `result` and the entry of the state it enters negates it, so + // the entry's value is the one that call returns; the second call's effect + // alone writes it. + state Calculator { + action def Double { + in value : Integer; + out result : Integer; + + first start; + action doubling { + assign result := value * 2; + } + done; + succession first start then doubling; + succession first doubling then done; + } + + action def Negate { + inout result : Integer; + + first start; + action negating { + assign result := 0 - result; + } + done; + succession first start then negating; + succession first negating then done; + } + + attribute result : Integer = 0; + + entry; then idle; + state idle; + state doubled { + entry perform Negate; + } + state settled; + + transition first idle accept compute(value) do action doubling : Double { in value = value; } then doubled; + transition first doubled accept compute(value) do action doubling : Double { in value = value; } then settled; + transition first settled accept Settle then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_call_trigger_results.trace.golden b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.trace.golden new file mode 100644 index 0000000000..817f8e07eb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.trace.golden @@ -0,0 +1,37 @@ +exit: idle +stmt action body + stmt node doubling + eval feature value -> 3 +step 1: token 1@doubling + stmt assign result + eval feature value -> 3 + eval literal 2 -> 2 + eval operator * -> 6 +step 2: token 1@done +step 3: no active tokens +enter: doubled (entry action) +stmt perform +step 1: token 1@negating + stmt assign result + eval literal 0 -> 0 + eval feature result -> 6 + eval operator - -> -6 +step 2: token 1@done +step 3: no active tokens +transition: idle -> doubled (event: call compute) +exit: doubled +stmt action body + stmt node doubling + eval feature value -> 5 +step 1: token 1@doubling + stmt assign result + eval feature value -> 5 + eval literal 2 -> 2 + eval operator * -> 10 +step 2: token 1@done +step 3: no active tokens +enter: settled +transition: doubled -> settled (event: call compute) +exit: settled +enter: done +transition: settled -> done (event: accept Settle) diff --git a/internal/core/runtime/testdata/conformance/state_change_region_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_change_region_order.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_change_region_order.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_change_region_order.check.expected.json diff --git a/internal/exec/runtime/testdata/conformance/state_change_region_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_change_region_order.declared.trace.golden new file mode 100644 index 0000000000..f93053d61b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_region_order.declared.trace.golden @@ -0,0 +1,25 @@ +exit: start +stmt assign temp + eval literal 30 -> 30 +enter: work +choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first) +enter: a1 +enter: b1 +transition: start -> work + eval feature temp -> 30 + eval literal 20 -> 20 +eval operator > -> true + eval feature temp -> 30 + eval literal 20 -> 20 +eval operator > -> true +choice on change: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign last + eval literal 1 -> 1 +enter: a2 +transition: a1 -> a2 (event: change) +exit: b1 +stmt assign last + eval literal 2 -> 2 +enter: b2 +transition: b1 -> b2 (event: change) diff --git a/internal/exec/runtime/testdata/conformance/state_change_region_order.expected.json b/internal/exec/runtime/testdata/conformance/state_change_region_order.expected.json new file mode 100644 index 0000000000..a55e15a3b4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_region_order.expected.json @@ -0,0 +1,39 @@ +{ + "type": "state", + "trace": true, + "outcomes": [ + { + "finalState": "a2+b2", + "stateVisits": ["start", "work", "a1", "b1", "a2", "b2"], + "outputs": { + "temp": {"type": "Integer", "value": 30}, + "last": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "a2+b2", + "stateVisits": ["start", "work", "a1", "b1", "b2", "a2"], + "outputs": { + "temp": {"type": "Integer", "value": 30}, + "last": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "a2+b2", + "stateVisits": ["start", "work", "b1", "a1", "a2", "b2"], + "outputs": { + "temp": {"type": "Integer", "value": 30}, + "last": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "a2+b2", + "stateVisits": ["start", "work", "b1", "a1", "b2", "a2"], + "outputs": { + "temp": {"type": "Integer", "value": 30}, + "last": {"type": "Integer", "value": 1} + } + } + ], + "admissible": "Transitions in sibling regions enabled by one event: each fires, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_change_region_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_change_region_order.seed-1.trace.golden new file mode 100644 index 0000000000..94dae390f4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_region_order.seed-1.trace.golden @@ -0,0 +1,25 @@ +exit: start +stmt assign temp + eval literal 30 -> 30 +enter: work +choice entering work: next a1(entry), b1(entry) (unordered; took b1(entry) first) +enter: b1 +enter: a1 +transition: start -> work + eval feature temp -> 30 + eval literal 20 -> 20 +eval operator > -> true + eval feature temp -> 30 + eval literal 20 -> 20 +eval operator > -> true +choice on change: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign last + eval literal 1 -> 1 +enter: a2 +transition: a1 -> a2 (event: change) +exit: b1 +stmt assign last + eval literal 2 -> 2 +enter: b2 +transition: b1 -> b2 (event: change) diff --git a/internal/core/runtime/testdata/conformance/state_change_region_order.sysml b/internal/exec/runtime/testdata/conformance/state_change_region_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_change_region_order.sysml rename to internal/exec/runtime/testdata/conformance/state_change_region_order.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_change_region_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_change_region_order.trace.golden new file mode 100644 index 0000000000..f93053d61b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_region_order.trace.golden @@ -0,0 +1,25 @@ +exit: start +stmt assign temp + eval literal 30 -> 30 +enter: work +choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first) +enter: a1 +enter: b1 +transition: start -> work + eval feature temp -> 30 + eval literal 20 -> 20 +eval operator > -> true + eval feature temp -> 30 + eval literal 20 -> 20 +eval operator > -> true +choice on change: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign last + eval literal 1 -> 1 +enter: a2 +transition: a1 -> a2 (event: change) +exit: b1 +stmt assign last + eval literal 2 -> 2 +enter: b2 +transition: b1 -> b2 (event: change) diff --git a/internal/core/runtime/testdata/conformance/state_change_trigger_autonomous.expected.json b/internal/exec/runtime/testdata/conformance/state_change_trigger_autonomous.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_change_trigger_autonomous.expected.json rename to internal/exec/runtime/testdata/conformance/state_change_trigger_autonomous.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_change_trigger_autonomous.sysml b/internal/exec/runtime/testdata/conformance/state_change_trigger_autonomous.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_change_trigger_autonomous.sysml rename to internal/exec/runtime/testdata/conformance/state_change_trigger_autonomous.sysml diff --git a/internal/core/runtime/testdata/conformance/state_change_trigger_event_order.expected.json b/internal/exec/runtime/testdata/conformance/state_change_trigger_event_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_change_trigger_event_order.expected.json rename to internal/exec/runtime/testdata/conformance/state_change_trigger_event_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_change_trigger_event_order.sysml b/internal/exec/runtime/testdata/conformance/state_change_trigger_event_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_change_trigger_event_order.sysml rename to internal/exec/runtime/testdata/conformance/state_change_trigger_event_order.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_change_trigger_event_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_change_trigger_event_order.trace.golden new file mode 100644 index 0000000000..afd0ef377d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_trigger_event_order.trace.golden @@ -0,0 +1,31 @@ +exit: start +enter: A + eval literal 5 -> 5 +eval index -> 5 [s] +transition: start -> A +eval feature ready -> true +exit: A +stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +enter: B +transition: A -> B (event: change) +exit: B +stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +enter: C (entry action) +stmt action body + stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 3 -> 3 + eval operator + -> 123 +transition: B -> C (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_change_trigger_rising_edge.expected.json b/internal/exec/runtime/testdata/conformance/state_change_trigger_rising_edge.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_change_trigger_rising_edge.expected.json rename to internal/exec/runtime/testdata/conformance/state_change_trigger_rising_edge.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_change_trigger_rising_edge.sysml b/internal/exec/runtime/testdata/conformance/state_change_trigger_rising_edge.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_change_trigger_rising_edge.sysml rename to internal/exec/runtime/testdata/conformance/state_change_trigger_rising_edge.sysml diff --git a/internal/core/runtime/testdata/conformance/state_choice_after_incoming_effect.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_after_incoming_effect.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_after_incoming_effect.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_after_incoming_effect.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_after_incoming_effect.sysml b/internal/exec/runtime/testdata/conformance/state_choice_after_incoming_effect.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_after_incoming_effect.sysml rename to internal/exec/runtime/testdata/conformance/state_choice_after_incoming_effect.sysml diff --git a/internal/core/runtime/testdata/conformance/state_choice_after_incoming_effect.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_after_incoming_effect.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_after_incoming_effect.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_after_incoming_effect.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.expected.json similarity index 90% rename from internal/core/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.expected.json index c5ba4ac7b6..fa000165df 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "trace": true, "events": [{"signal": "Go"}], "finalState": "a2+b1", diff --git a/internal/core/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.sysml b/internal/exec/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.sysml rename to internal/exec/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.trace.golden new file mode 100644 index 0000000000..d5b593157f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_ancestor_outranked_not_reported.trace.golden @@ -0,0 +1,9 @@ + eval feature level -> 8 + eval literal 5 -> 5 +eval operator > -> true +exit: a1 +enter: a2 (entry action) +stmt action body + stmt assign route + eval literal 3 -> 3 +transition: a1 -> a2 (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.sysml b/internal/exec/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.sysml rename to internal/exec/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.sysml diff --git a/internal/core/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.trace.golden similarity index 79% rename from internal/core/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.trace.golden index f48919d0a9..847a978c00 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_ancestor_priority_not_reported.trace.golden @@ -6,6 +6,7 @@ eval operator > -> true eval operator > -> true exit: fine enter: warm (entry action) -stmt assign path - eval literal 1 -> 1 +stmt action body + stmt assign path + eval literal 1 -> 1 transition: fine -> warm (event: accept Tick) diff --git a/internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.check.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.declared.trace.golden similarity index 87% rename from internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.declared.trace.golden index 73ee5e6026..c4c1f932be 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.declared.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.declared.trace.golden @@ -12,6 +12,7 @@ eval operator > -> true choice state watching on change: transitions 1->cool, 2->hot (unordered; took 1->cool) exit: watching enter: cool (entry action) -stmt assign route - eval literal 1 -> 1 +stmt action body + stmt assign route + eval literal 1 -> 1 transition: watching -> cool (event: change) diff --git a/internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.seed-1.trace.golden similarity index 87% rename from internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.seed-1.trace.golden index 19254d1d6f..68ffcd9f74 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.seed-1.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.seed-1.trace.golden @@ -12,6 +12,7 @@ eval operator > -> true choice state watching on change: transitions 1->cool, 2->hot (unordered; took 2->hot) exit: watching enter: hot (entry action) -stmt assign route - eval literal 2 -> 2 +stmt action body + stmt assign route + eval literal 2 -> 2 transition: watching -> hot (event: change) diff --git a/internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.sysml b/internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.sysml rename to internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.sysml diff --git a/internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.trace.golden similarity index 87% rename from internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.trace.golden index 73ee5e6026..c4c1f932be 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_change_transition_conflict.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_change_transition_conflict.trace.golden @@ -12,6 +12,7 @@ eval operator > -> true choice state watching on change: transitions 1->cool, 2->hot (unordered; took 1->cool) exit: watching enter: cool (entry action) -stmt assign route - eval literal 1 -> 1 +stmt action body + stmt assign route + eval literal 1 -> 1 transition: watching -> cool (event: change) diff --git a/internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.check.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.declared.trace.golden similarity index 81% rename from internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.declared.trace.golden index bd436ee8cd..20008729b2 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.declared.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.declared.trace.golden @@ -6,6 +6,7 @@ stmt assign level eval operator > -> true choice choice pick: transitions 1->low, 2->high (unordered; took 1->low) enter: low (entry action) -stmt assign route - eval literal 1 -> 1 +stmt action body + stmt assign route + eval literal 1 -> 1 transition: idle -> low (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.seed-1.trace.golden similarity index 84% rename from internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.seed-1.trace.golden index 2d1cb29e81..9da4a76c99 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.seed-1.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.seed-1.trace.golden @@ -9,6 +9,7 @@ choice choice pick: transitions 1->low, 2->high (unordered; took 2->high) eval literal 7 -> 7 eval operator > -> true enter: high (entry action) -stmt assign route - eval literal 2 -> 2 +stmt action body + stmt assign route + eval literal 2 -> 2 transition: idle -> high (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.sysml b/internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.sysml rename to internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.sysml diff --git a/internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.trace.golden similarity index 81% rename from internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.trace.golden index bd436ee8cd..20008729b2 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_dynamic_conflict.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_dynamic_conflict.trace.golden @@ -6,6 +6,7 @@ stmt assign level eval operator > -> true choice choice pick: transitions 1->low, 2->high (unordered; took 1->low) enter: low (entry action) -stmt assign route - eval literal 1 -> 1 +stmt action body + stmt assign route + eval literal 1 -> 1 transition: idle -> low (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_accepting_segment.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_accepting_segment.expected.json new file mode 100644 index 0000000000..32c3a25320 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_accepting_segment.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "events": [{"signal": "Level", "value": {"type": "Integer", "value": 8}}], + "finalState": "done", + "stateVisits": ["idle", "high", "done"], + "outputs": { + "chosen": {"type": "Integer", "value": 8} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_accepting_segment.sysml b/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_accepting_segment.sysml new file mode 100644 index 0000000000..c78775e58f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_accepting_segment.sysml @@ -0,0 +1,24 @@ +// A guard on a segment out of a choice is read within the compound transition's +// performance: `raise.l` names the payload the accepting segment bound, as the +// segment's effect does, so the branch is chosen by what the occurrence carried. +package StateChoiceGuardReadsAcceptingSegment { + private import ScalarValues::*; + + attribute def Level :> Integer; + + state Machine { + attribute chosen : Integer = 0; + + entry; then idle; + state idle; + choice pick; + state high; + state low; + + transition raise first idle accept l : Level then pick; + transition up first pick if raise.l > 5 do assign chosen := raise.l then high; + transition down first pick if raise.l <= 5 do assign chosen := -raise.l then low; + transition first high then done; + transition first low then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_owner_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_owner_entry.expected.json new file mode 100644 index 0000000000..8bbd5fc4b2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_owner_entry.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}, {"signal": "Next"}, {"signal": "Stop"}], + "finalState": "done", + "stateVisits": ["idle", "work", "w1", "rest", "inner", "r1", "done"], + "outputs": { + "x": {"type": "Integer", "value": 1}, + "y": {"type": "Integer", "value": 1}, + "log": {"type": "String", "value": "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);h(effect);inner(entry);toR1(effect);r1(entry);rest(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_owner_entry.sysml b/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_owner_entry.sysml new file mode 100644 index 0000000000..98961ff051 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_owner_entry.sysml @@ -0,0 +1,47 @@ +// A choice declared inside a composite state reads its guards once the composite +// is entered: the composite's entry behavior sets the value the guard tests, so +// the branch its entry enables is the one taken, for a transition into the choice +// and for a history's default transition into a choice of a nested composite. +package Test { + state Machine { + attribute log : String = ""; + attribute x : Integer = 0; + attribute y : Integer = 0; + + entry; then idle; + state idle; + state work { + entry { assign x := 1; assign log := log + "work(entry);"; } + exit { assign log := log + "work(exit);"; } + choice pick; + state w1 { entry { assign log := log + "w1(entry);"; } } + state w2 { entry { assign log := log + "w2(entry);"; } } + + transition first pick if x == 1 do { assign log := log + "toW1(effect);"; } then w1; + transition first pick do { assign log := log + "toW2(effect);"; } then w2; + } + state rest { + entry { assign log := log + "rest(entry);"; } + exit { assign log := log + "rest(exit);"; } + history hist; + state inner { + entry { assign y := 1; assign log := log + "inner(entry);"; } + choice which; + state r1 { entry { assign log := log + "r1(entry);"; } } + state r2 { entry { assign log := log + "r2(entry);"; } } + + transition first which if y == 1 do { assign log := log + "toR1(effect);"; } then r1; + transition first which do { assign log := log + "toR2(effect);"; } then r2; + } + + transition first hist do { assign log := log + "h(effect);"; } then inner.which; + } + + transition first idle accept Go do { assign log := log + "go(effect);"; } then pick; + transition first work accept Next do { assign log := log + "next(effect);"; } then rest.hist; + transition first rest accept Stop then done; + } + attribute def Go; + attribute def Next; + attribute def Stop; +} diff --git a/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_owner_entry.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_owner_entry.trace.golden new file mode 100644 index 0000000000..badf447bf9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_guard_reads_owner_entry.trace.golden @@ -0,0 +1,85 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "go(effect);" -> "go(effect);" + eval operator + -> "go(effect);" +enter: work (entry action) +stmt action body + stmt assign x + eval literal 1 -> 1 + stmt assign log + eval feature log -> "go(effect);" + eval literal "work(entry);" -> "work(entry);" + eval operator + -> "go(effect);work(entry);" + eval feature x -> 1 + eval literal 1 -> 1 +eval operator == -> true +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);" + eval literal "toW1(effect);" -> "toW1(effect);" + eval operator + -> "go(effect);work(entry);toW1(effect);" +enter: w1 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);" + eval literal "w1(entry);" -> "w1(entry);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);" +transition: idle -> w1 (event: accept Go) +exit: w1 +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);w1(entry);" + eval literal "work(exit);" -> "work(exit);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);" +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);" + eval literal "next(effect);" -> "next(effect);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);" +enter: rest (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);" + eval literal "rest(entry);" -> "rest(entry);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);" +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);" + eval literal "h(effect);" -> "h(effect);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);h(effect);" +enter: inner (entry action) +stmt action body + stmt assign y + eval literal 1 -> 1 + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);h(effect);" + eval literal "inner(entry);" -> "inner(entry);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);h(effect);inner(entry);" + eval feature y -> 1 + eval literal 1 -> 1 +eval operator == -> true +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);h(effect);inner(entry);" + eval literal "toR1(effect);" -> "toR1(effect);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);h(effect);inner(entry);toR1(effect);" +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);h(effect);inner(entry);toR1(effect);" + eval literal "r1(entry);" -> "r1(entry);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);h(effect);inner(entry);toR1(effect);r1(entry);" +transition: w1 -> r1 (event: accept Next) +exit: r1 +exit: inner +exit: rest (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);h(effect);inner(entry);toR1(effect);r1(entry);" + eval literal "rest(exit);" -> "rest(exit);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);next(effect);rest(entry);h(effect);inner(entry);toR1(effect);r1(entry);rest(exit);" +enter: done +transition: r1 -> done (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_choice_inside_composite.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_inside_composite.expected.json new file mode 100644 index 0000000000..e1b7d1e9e6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_inside_composite.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}, {"signal": "Stop"}], + "finalState": "done", + "stateVisits": ["idle", "work", "w1", "done"], + "outputs": { + "x": {"type": "Integer", "value": 1}, + "log": {"type": "String", "value": "go(effect);work(entry);toW1(effect);w1(entry);work(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_choice_inside_composite.sysml b/internal/exec/runtime/testdata/conformance/state_choice_inside_composite.sysml new file mode 100644 index 0000000000..6c752deee2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_inside_composite.sysml @@ -0,0 +1,28 @@ +// A transition into a choice declared inside a composite state enters the +// composite before the branch out of the choice runs its effect, as for a +// junction; the choice's guards are still read once the transition into it has +// run its effect, so the branch the incoming effect enables is the one taken. +package Test { + state Machine { + attribute log : String = ""; + attribute x : Integer = 0; + + entry; then idle; + state idle; + state work { + entry { assign log := log + "work(entry);"; } + exit { assign log := log + "work(exit);"; } + choice pick; + state w1 { entry { assign log := log + "w1(entry);"; } } + state w2 { entry { assign log := log + "w2(entry);"; } } + + transition first pick if x == 1 do { assign log := log + "toW1(effect);"; } then w1; + transition first pick do { assign log := log + "toW2(effect);"; } then w2; + } + + transition first idle accept Go do { assign x := 1; assign log := log + "go(effect);"; } then pick; + transition first work accept Stop then done; + } + attribute def Go; + attribute def Stop; +} diff --git a/internal/exec/runtime/testdata/conformance/state_choice_inside_composite.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_inside_composite.trace.golden new file mode 100644 index 0000000000..c6eb563bc3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_inside_composite.trace.golden @@ -0,0 +1,38 @@ +exit: idle +stmt action body + stmt assign x + eval literal 1 -> 1 + stmt assign log + eval feature log -> "" + eval literal "go(effect);" -> "go(effect);" + eval operator + -> "go(effect);" +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);" + eval literal "work(entry);" -> "work(entry);" + eval operator + -> "go(effect);work(entry);" + eval feature x -> 1 + eval literal 1 -> 1 +eval operator == -> true +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);" + eval literal "toW1(effect);" -> "toW1(effect);" + eval operator + -> "go(effect);work(entry);toW1(effect);" +enter: w1 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);" + eval literal "w1(entry);" -> "w1(entry);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);" +transition: idle -> w1 (event: accept Go) +exit: w1 +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW1(effect);w1(entry);" + eval literal "work(exit);" -> "work(exit);" + eval operator + -> "go(effect);work(entry);toW1(effect);w1(entry);work(exit);" +enter: done +transition: w1 -> done (event: accept Stop) diff --git a/internal/core/runtime/testdata/conformance/state_choice_pseudostate.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_pseudostate.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_pseudostate.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_pseudostate.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_pseudostate.sysml b/internal/exec/runtime/testdata/conformance/state_choice_pseudostate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_pseudostate.sysml rename to internal/exec/runtime/testdata/conformance/state_choice_pseudostate.sysml diff --git a/internal/core/runtime/testdata/conformance/state_choice_shared_ancestor_regions.check.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_shared_ancestor_regions.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.check.expected.json diff --git a/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.declared.trace.golden new file mode 100644 index 0000000000..9e27bd16c7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.declared.trace.golden @@ -0,0 +1,16 @@ + eval feature level -> 8 + eval literal 5 -> 5 +eval operator > -> true + eval feature level -> 8 + eval literal 5 -> 5 +eval operator > -> true +choice state work on accept Go: transitions 1->low, 2->high (unordered; took 1->low) +choice exiting work: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +exit: b1 +exit: work +enter: low (entry action) +stmt action body + stmt assign route + eval literal 1 -> 1 +transition: work -> low (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.expected.json new file mode 100644 index 0000000000..fcd07c0156 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.expected.json @@ -0,0 +1,40 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "high", + "stateVisits": ["work", "a1", "b1", "high"], + "outputs": { + "level": {"type": "Integer", "value": 8}, + "route": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "high", + "stateVisits": ["work", "b1", "a1", "high"], + "outputs": { + "level": {"type": "Integer", "value": 8}, + "route": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "low", + "stateVisits": ["work", "a1", "b1", "low"], + "outputs": { + "level": {"type": "Integer", "value": 8}, + "route": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "low", + "stateVisits": ["work", "b1", "a1", "low"], + "outputs": { + "level": {"type": "Integer", "value": 8}, + "route": {"type": "Integer", "value": 1} + } + } + ], + "admissible": "Two transitions out of one state enabled by one event: exactly one fires, which one is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.seed-1.trace.golden new file mode 100644 index 0000000000..9e27bd16c7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.seed-1.trace.golden @@ -0,0 +1,16 @@ + eval feature level -> 8 + eval literal 5 -> 5 +eval operator > -> true + eval feature level -> 8 + eval literal 5 -> 5 +eval operator > -> true +choice state work on accept Go: transitions 1->low, 2->high (unordered; took 1->low) +choice exiting work: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +exit: b1 +exit: work +enter: low (entry action) +stmt action body + stmt assign route + eval literal 1 -> 1 +transition: work -> low (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_choice_shared_ancestor_regions.sysml b/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_shared_ancestor_regions.sysml rename to internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.trace.golden new file mode 100644 index 0000000000..9e27bd16c7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_choice_shared_ancestor_regions.trace.golden @@ -0,0 +1,16 @@ + eval feature level -> 8 + eval literal 5 -> 5 +eval operator > -> true + eval feature level -> 8 + eval literal 5 -> 5 +eval operator > -> true +choice state work on accept Go: transitions 1->low, 2->high (unordered; took 1->low) +choice exiting work: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +exit: b1 +exit: work +enter: low (entry action) +stmt action body + stmt assign route + eval literal 1 -> 1 +transition: work -> low (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_choice_transition_conflict.check.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_transition_conflict.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_transition_conflict.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.declared.trace.golden similarity index 83% rename from internal/core/runtime/testdata/conformance/state_choice_transition_conflict.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.declared.trace.golden index f022b5a622..9a33f949b7 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_transition_conflict.declared.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.declared.trace.golden @@ -7,6 +7,7 @@ eval operator > -> true choice state idle on accept Go: transitions 1->low, 2->high (unordered; took 1->low) exit: idle enter: low (entry action) -stmt assign route - eval literal 1 -> 1 +stmt action body + stmt assign route + eval literal 1 -> 1 transition: idle -> low (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_choice_transition_conflict.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_transition_conflict.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_transition_conflict.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.seed-1.trace.golden similarity index 83% rename from internal/core/runtime/testdata/conformance/state_choice_transition_conflict.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.seed-1.trace.golden index 4d002d0c7e..55041d15b2 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_transition_conflict.seed-1.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.seed-1.trace.golden @@ -7,6 +7,7 @@ eval operator > -> true choice state idle on accept Go: transitions 1->low, 2->high (unordered; took 2->high) exit: idle enter: high (entry action) -stmt assign route - eval literal 2 -> 2 +stmt action body + stmt assign route + eval literal 2 -> 2 transition: idle -> high (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_choice_transition_conflict.sysml b/internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_transition_conflict.sysml rename to internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.sysml diff --git a/internal/core/runtime/testdata/conformance/state_choice_transition_conflict.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.trace.golden similarity index 83% rename from internal/core/runtime/testdata/conformance/state_choice_transition_conflict.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.trace.golden index f022b5a622..9a33f949b7 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_transition_conflict.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_transition_conflict.trace.golden @@ -7,6 +7,7 @@ eval operator > -> true choice state idle on accept Go: transitions 1->low, 2->high (unordered; took 1->low) exit: idle enter: low (entry action) -stmt assign route - eval literal 1 -> 1 +stmt action body + stmt assign route + eval literal 1 -> 1 transition: idle -> low (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_choice_unevaluable_branch.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_unevaluable_branch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_unevaluable_branch.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_unevaluable_branch.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_unevaluable_branch.sysml b/internal/exec/runtime/testdata/conformance/state_choice_unevaluable_branch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_unevaluable_branch.sysml rename to internal/exec/runtime/testdata/conformance/state_choice_unevaluable_branch.sysml diff --git a/internal/core/runtime/testdata/conformance/state_choice_unevaluable_branch.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_unevaluable_branch.trace.golden similarity index 83% rename from internal/core/runtime/testdata/conformance/state_choice_unevaluable_branch.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_unevaluable_branch.trace.golden index 247389d983..f397ecc072 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_unevaluable_branch.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_unevaluable_branch.trace.golden @@ -6,6 +6,7 @@ stmt assign level eval operator > -> true unevaluable guard choice pick: transition 2->high: eval guard of transition pick -> high: division by zero (not selected) enter: low (entry action) -stmt assign route - eval literal 1 -> 1 +stmt action body + stmt assign route + eval literal 1 -> 1 transition: idle -> low (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_choice_unevaluable_transition.expected.json b/internal/exec/runtime/testdata/conformance/state_choice_unevaluable_transition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_unevaluable_transition.expected.json rename to internal/exec/runtime/testdata/conformance/state_choice_unevaluable_transition.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_choice_unevaluable_transition.sysml b/internal/exec/runtime/testdata/conformance/state_choice_unevaluable_transition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_choice_unevaluable_transition.sysml rename to internal/exec/runtime/testdata/conformance/state_choice_unevaluable_transition.sysml diff --git a/internal/core/runtime/testdata/conformance/state_choice_unevaluable_transition.trace.golden b/internal/exec/runtime/testdata/conformance/state_choice_unevaluable_transition.trace.golden similarity index 85% rename from internal/core/runtime/testdata/conformance/state_choice_unevaluable_transition.trace.golden rename to internal/exec/runtime/testdata/conformance/state_choice_unevaluable_transition.trace.golden index 329a11ba65..c44a9361e1 100644 --- a/internal/core/runtime/testdata/conformance/state_choice_unevaluable_transition.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_choice_unevaluable_transition.trace.golden @@ -7,6 +7,7 @@ eval operator > -> true unevaluable guard state idle on accept Go: transition 2->high: eval guard of transition idle -> high: division by zero (not selected) exit: idle enter: low (entry action) -stmt assign route - eval literal 1 -> 1 +stmt action body + stmt assign route + eval literal 1 -> 1 transition: idle -> low (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_completion_absent.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_absent.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_absent.expected.json rename to internal/exec/runtime/testdata/conformance/state_completion_absent.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_completion_absent.sysml b/internal/exec/runtime/testdata/conformance/state_completion_absent.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_absent.sysml rename to internal/exec/runtime/testdata/conformance/state_completion_absent.sysml diff --git a/internal/core/runtime/testdata/conformance/state_completion_all_regions.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_all_regions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_all_regions.expected.json rename to internal/exec/runtime/testdata/conformance/state_completion_all_regions.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_completion_all_regions.sysml b/internal/exec/runtime/testdata/conformance/state_completion_all_regions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_all_regions.sysml rename to internal/exec/runtime/testdata/conformance/state_completion_all_regions.sysml diff --git a/internal/core/runtime/testdata/conformance/state_completion_all_regions.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_all_regions.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_all_regions.trace.golden rename to internal/exec/runtime/testdata/conformance/state_completion_all_regions.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_completion_done.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_done.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_done.expected.json rename to internal/exec/runtime/testdata/conformance/state_completion_done.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_completion_done.sysml b/internal/exec/runtime/testdata/conformance/state_completion_done.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_done.sysml rename to internal/exec/runtime/testdata/conformance/state_completion_done.sysml diff --git a/internal/core/runtime/testdata/conformance/state_completion_done.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_done.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_done.trace.golden rename to internal/exec/runtime/testdata/conformance/state_completion_done.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_completion_nested_regions.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_nested_regions.expected.json similarity index 93% rename from internal/core/runtime/testdata/conformance/state_completion_nested_regions.expected.json rename to internal/exec/runtime/testdata/conformance/state_completion_nested_regions.expected.json index 6c243b9be0..8abea7373a 100644 --- a/internal/core/runtime/testdata/conformance/state_completion_nested_regions.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_completion_nested_regions.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ { "signal": "First" diff --git a/internal/core/runtime/testdata/conformance/state_completion_nested_regions.sysml b/internal/exec/runtime/testdata/conformance/state_completion_nested_regions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_nested_regions.sysml rename to internal/exec/runtime/testdata/conformance/state_completion_nested_regions.sysml diff --git a/internal/core/runtime/testdata/conformance/state_completion_nested_regions.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_nested_regions.trace.golden similarity index 86% rename from internal/core/runtime/testdata/conformance/state_completion_nested_regions.trace.golden rename to internal/exec/runtime/testdata/conformance/state_completion_nested_regions.trace.golden index 6fbe32b6f2..b2c47a3592 100644 --- a/internal/core/runtime/testdata/conformance/state_completion_nested_regions.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_completion_nested_regions.trace.golden @@ -14,6 +14,7 @@ stmt action body eval operator + -> "l;r;" enter: done transition: rstart -> done (event: accept Second) +choice exiting busy: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) exit: done exit: done exit: busy (exit action) diff --git a/internal/core/runtime/testdata/conformance/state_completion_nested_regions_stay_active.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_nested_regions_stay_active.expected.json similarity index 93% rename from internal/core/runtime/testdata/conformance/state_completion_nested_regions_stay_active.expected.json rename to internal/exec/runtime/testdata/conformance/state_completion_nested_regions_stay_active.expected.json index 6cf2a69ce5..f146c9bb95 100644 --- a/internal/core/runtime/testdata/conformance/state_completion_nested_regions_stay_active.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_completion_nested_regions_stay_active.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ { "signal": "First" diff --git a/internal/core/runtime/testdata/conformance/state_completion_nested_regions_stay_active.sysml b/internal/exec/runtime/testdata/conformance/state_completion_nested_regions_stay_active.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_nested_regions_stay_active.sysml rename to internal/exec/runtime/testdata/conformance/state_completion_nested_regions_stay_active.sysml diff --git a/internal/core/runtime/testdata/conformance/state_completion_nested_regions_stay_active.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_nested_regions_stay_active.trace.golden similarity index 87% rename from internal/core/runtime/testdata/conformance/state_completion_nested_regions_stay_active.trace.golden rename to internal/exec/runtime/testdata/conformance/state_completion_nested_regions_stay_active.trace.golden index d3c911a1c5..7aa375fbb2 100644 --- a/internal/core/runtime/testdata/conformance/state_completion_nested_regions_stay_active.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_completion_nested_regions_stay_active.trace.golden @@ -14,6 +14,7 @@ stmt action body eval operator + -> "l;r;" enter: done transition: rstart -> done (event: accept Second) +choice exiting busy: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) exit: done exit: done exit: busy (exit action) diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.check.expected.json new file mode 100644 index 0000000000..5a48ea9f3c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.check.expected.json @@ -0,0 +1,12 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"l r \"", + "\"r l \"" + ] + }, + "agreed": { + "finalState": "lc3+rc3" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.declared.trace.golden new file mode 100644 index 0000000000..1d2593da0d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.declared.trace.golden @@ -0,0 +1,54 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: lc +enter: lc1 +transition: l1 -> lc (event: accept Go) +exit: r1 +enter: rc +enter: rc1 +transition: r1 -> rc (event: accept Go) +exit: lc1 +enter: lc2 +transition: lc1 -> lc2 +exit: rc1 +enter: rc2 +transition: rc1 -> rc2 +eval feature resumed -> false +eval feature resumed -> false +choice exiting work: next lc2(exit), rc2(exit) (unordered; took lc2(exit) first) +exit: lc2 +exit: lc +exit: rc2 +exit: rc +exit: work +enter: paused (entry action) +stmt action body + stmt assign resumed + eval literal true -> true +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next lc(entry), rc(entry) (unordered; took lc(entry) first) +enter: lc +enter: lc2 +enter: rc +enter: rc2 +transition: paused -> work (event: accept Resume) +eval feature resumed -> true +exit: lc2 +exit: lc +stmt assign log + eval feature log -> "" + eval literal "l " -> "l " + eval operator + -> "l " +enter: lc3 +transition: lc2 -> lc3 +eval feature resumed -> true +exit: rc2 +exit: rc +stmt assign log + eval feature log -> "l " + eval literal "r " -> "r " + eval operator + -> "l r " +enter: rc3 +transition: rc2 -> rc3 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.expected.json new file mode 100644 index 0000000000..9bcfc310de --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.expected.json @@ -0,0 +1,36 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + }, + { + "signal": "Pause" + }, + { + "signal": "Resume" + } + ], + "outcomes": [ + { + "finalState": "lc3+rc3", + "outputs": { + "log": { + "type": "String", + "value": "l r " + } + } + }, + { + "finalState": "lc3+rc3", + "outputs": { + "log": { + "type": "String", + "value": "r l " + } + } + } + ], + "admissible": "Regions of a parallel state entered on one occurrence: each is entered, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.seed-1.trace.golden new file mode 100644 index 0000000000..1d2593da0d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.seed-1.trace.golden @@ -0,0 +1,54 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: lc +enter: lc1 +transition: l1 -> lc (event: accept Go) +exit: r1 +enter: rc +enter: rc1 +transition: r1 -> rc (event: accept Go) +exit: lc1 +enter: lc2 +transition: lc1 -> lc2 +exit: rc1 +enter: rc2 +transition: rc1 -> rc2 +eval feature resumed -> false +eval feature resumed -> false +choice exiting work: next lc2(exit), rc2(exit) (unordered; took lc2(exit) first) +exit: lc2 +exit: lc +exit: rc2 +exit: rc +exit: work +enter: paused (entry action) +stmt action body + stmt assign resumed + eval literal true -> true +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next lc(entry), rc(entry) (unordered; took lc(entry) first) +enter: lc +enter: lc2 +enter: rc +enter: rc2 +transition: paused -> work (event: accept Resume) +eval feature resumed -> true +exit: lc2 +exit: lc +stmt assign log + eval feature log -> "" + eval literal "l " -> "l " + eval operator + -> "l " +enter: lc3 +transition: lc2 -> lc3 +eval feature resumed -> true +exit: rc2 +exit: rc +stmt assign log + eval feature log -> "l " + eval literal "r " -> "r " + eval operator + -> "l r " +enter: rc3 +transition: rc2 -> rc3 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.sysml b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.sysml new file mode 100644 index 0000000000..94ca079c23 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.sysml @@ -0,0 +1,53 @@ +// A deep history restores two regions' recorded leaves, each nested in a +// composite state and each completing at once when restored: its completion +// transition is guarded on a flag the pause raised, so the completion event +// generated at the first entry was dropped and the one generated at the restore +// fires. The composites' and the leaves' entries perform nothing, yet each leaf's +// entry generates a completion event, so the restore's entry draw decides the +// pool's order and the effects log it; both orders are admissible. +package Test { + state Machine { + attribute log : String = ""; + attribute resumed : Boolean = false; + + entry; then work; + state work parallel { + deep history resume; + state left { + entry; then l1; + state l1; + state lc { + entry; then lc1; + state lc1; + state lc2; + transition first lc1 then lc2; + transition first lc2 if resumed do assign log := log + "l " then lc3; + } + state lc3; + transition first l1 accept Go then lc; + } + state right { + entry; then r1; + state r1; + state rc { + entry; then rc1; + state rc1; + state rc2; + transition first rc1 then rc2; + transition first rc2 if resumed do assign log := log + "r " then rc3; + } + state rc3; + transition first r1 accept Go then rc; + } + } + state paused { + entry { assign resumed := true; } + } + + transition first work accept Pause then paused; + transition first paused accept Resume then resume; + } + attribute def Go; + attribute def Pause; + attribute def Resume; +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.trace.golden new file mode 100644 index 0000000000..1d2593da0d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_deep_history_order.trace.golden @@ -0,0 +1,54 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: lc +enter: lc1 +transition: l1 -> lc (event: accept Go) +exit: r1 +enter: rc +enter: rc1 +transition: r1 -> rc (event: accept Go) +exit: lc1 +enter: lc2 +transition: lc1 -> lc2 +exit: rc1 +enter: rc2 +transition: rc1 -> rc2 +eval feature resumed -> false +eval feature resumed -> false +choice exiting work: next lc2(exit), rc2(exit) (unordered; took lc2(exit) first) +exit: lc2 +exit: lc +exit: rc2 +exit: rc +exit: work +enter: paused (entry action) +stmt action body + stmt assign resumed + eval literal true -> true +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next lc(entry), rc(entry) (unordered; took lc(entry) first) +enter: lc +enter: lc2 +enter: rc +enter: rc2 +transition: paused -> work (event: accept Resume) +eval feature resumed -> true +exit: lc2 +exit: lc +stmt assign log + eval feature log -> "" + eval literal "l " -> "l " + eval operator + -> "l " +enter: lc3 +transition: lc2 -> lc3 +eval feature resumed -> true +exit: rc2 +exit: rc +stmt assign log + eval feature log -> "l " + eval literal "r " -> "r " + eval operator + -> "l r " +enter: rc3 +transition: rc2 -> rc3 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.check.expected.json new file mode 100644 index 0000000000..b71df6f915 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.check.expected.json @@ -0,0 +1,12 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"l r \"", + "\"r l \"" + ] + }, + "agreed": { + "finalState": "l2+r2" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.declared.trace.golden new file mode 100644 index 0000000000..dd6b542042 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.declared.trace.golden @@ -0,0 +1,20 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 +transition: idle -> work (event: accept Go) +exit: l1 +stmt assign log + eval feature log -> "" + eval literal "l " -> "l " + eval operator + -> "l " +enter: l2 +transition: l1 -> l2 +exit: r1 +stmt assign log + eval feature log -> "l " + eval literal "r " -> "r " + eval operator + -> "l r " +enter: r2 +transition: r1 -> r2 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.expected.json new file mode 100644 index 0000000000..cf5d9a676c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.expected.json @@ -0,0 +1,30 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + } + ], + "outcomes": [ + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "l r " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r l " + } + } + } + ], + "admissible": "Regions of a parallel state entered on one occurrence: each is entered, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.seed-1.trace.golden new file mode 100644 index 0000000000..82033732c3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.seed-1.trace.golden @@ -0,0 +1,20 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took r1(entry) first) +enter: r1 +enter: l1 +transition: idle -> work (event: accept Go) +exit: r1 +stmt assign log + eval feature log -> "" + eval literal "r " -> "r " + eval operator + -> "r " +enter: r2 +transition: r1 -> r2 +exit: l1 +stmt assign log + eval feature log -> "r " + eval literal "l " -> "l " + eval operator + -> "r l " +enter: l2 +transition: l1 -> l2 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.sysml b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.sysml new file mode 100644 index 0000000000..7a539c7802 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.sysml @@ -0,0 +1,31 @@ +// Two orthogonal regions whose initial states each complete at once, silently: +// neither performs anything, yet each entry generates a completion event, so the +// entry draw is observable through the pool's order — a completion event goes +// behind those already in the pool, so the completions dispatch in the order the +// entries were performed. The effects log that order; the two orders are the +// admissible set, and the entry draw is the run's one choice. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + entry; then l1; + state l1; + state l2; + transition first l1 do assign log := log + "l " then l2; + } + state right { + entry; then r1; + state r1; + state r2; + transition first r1 do assign log := log + "r " then r2; + } + } + + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.trace.golden new file mode 100644 index 0000000000..dd6b542042 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_entry_order.trace.golden @@ -0,0 +1,20 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 +transition: idle -> work (event: accept Go) +exit: l1 +stmt assign log + eval feature log -> "" + eval literal "l " -> "l " + eval operator + -> "l " +enter: l2 +transition: l1 -> l2 +exit: r1 +stmt assign log + eval feature log -> "l " + eval literal "r " -> "r " + eval operator + -> "l r " +enter: r2 +transition: r1 -> r2 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.check.expected.json new file mode 100644 index 0000000000..30cec21a74 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.check.expected.json @@ -0,0 +1,12 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"a b \"", + "\"b a \"" + ] + }, + "agreed": { + "finalState": "a2+b2" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.declared.trace.golden new file mode 100644 index 0000000000..92cd7ab590 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.declared.trace.golden @@ -0,0 +1,20 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +enter: work +enter: a +enter: b +transition: idle -> split +exit: a +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +enter: a2 +transition: a -> a2 +exit: b +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +enter: b2 +transition: b -> b2 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.expected.json new file mode 100644 index 0000000000..d085df7ae6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.expected.json @@ -0,0 +1,30 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + } + ], + "outcomes": [ + { + "finalState": "a2+b2", + "outputs": { + "log": { + "type": "String", + "value": "a b " + } + } + }, + { + "finalState": "a2+b2", + "outputs": { + "log": { + "type": "String", + "value": "b a " + } + } + } + ], + "admissible": "Regions of a parallel state entered on one occurrence: each is entered, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.seed-1.trace.golden new file mode 100644 index 0000000000..f76791aeeb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.seed-1.trace.golden @@ -0,0 +1,20 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->b(effect) first) +enter: work +enter: b +enter: a +transition: idle -> split +exit: b +stmt assign log + eval feature log -> "" + eval literal "b " -> "b " + eval operator + -> "b " +enter: b2 +transition: b -> b2 +exit: a +stmt assign log + eval feature log -> "b " + eval literal "a " -> "a " + eval operator + -> "b a " +enter: a2 +transition: a -> a2 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.sysml b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.sysml new file mode 100644 index 0000000000..a2d87c72c2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.sysml @@ -0,0 +1,31 @@ +// A fork enters two regions at states that complete at once, silently: neither +// branch has an effect and neither target performs anything, yet each target's +// entry generates a completion event, so the fork's entry draw decides the +// pool's order and the completions dispatch in the order the entries were +// performed. The effects log that order; both orders are admissible. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + state a; + state a2; + transition first a do assign log := log + "a " then a2; + } + state right { + state b; + state b2; + transition first b do assign log := log + "b " then b2; + } + } + fork split; + + transition first idle accept Go then split; + transition first split then a; + transition first split then b; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.trace.golden new file mode 100644 index 0000000000..92cd7ab590 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_fork_order.trace.golden @@ -0,0 +1,20 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +enter: work +enter: a +enter: b +transition: idle -> split +exit: a +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +enter: a2 +transition: a -> a2 +exit: b +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +enter: b2 +transition: b -> b2 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.check.expected.json new file mode 100644 index 0000000000..6f2319e9aa --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.check.expected.json @@ -0,0 +1,12 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"l r \"", + "\"r l \"" + ] + }, + "agreed": { + "finalState": "l3+r3" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.declared.trace.golden new file mode 100644 index 0000000000..4ddab726a9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.declared.trace.golden @@ -0,0 +1,40 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: l2 +transition: l1 -> l2 (event: accept Go) +exit: r1 +enter: r2 +transition: r1 -> r2 (event: accept Go) +eval feature resumed -> false +eval feature resumed -> false +choice exiting work: next l2(exit), r2(exit) (unordered; took l2(exit) first) +exit: l2 +exit: r2 +exit: work +enter: paused (entry action) +stmt action body + stmt assign resumed + eval literal true -> true +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next l2(entry), r2(entry) (unordered; took l2(entry) first) +enter: l2 +enter: r2 +transition: paused -> work (event: accept Resume) +eval feature resumed -> true +exit: l2 +stmt assign log + eval feature log -> "" + eval literal "l " -> "l " + eval operator + -> "l " +enter: l3 +transition: l2 -> l3 +eval feature resumed -> true +exit: r2 +stmt assign log + eval feature log -> "l " + eval literal "r " -> "r " + eval operator + -> "l r " +enter: r3 +transition: r2 -> r3 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.expected.json new file mode 100644 index 0000000000..6f4fa7807c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.expected.json @@ -0,0 +1,36 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + }, + { + "signal": "Pause" + }, + { + "signal": "Resume" + } + ], + "outcomes": [ + { + "finalState": "l3+r3", + "outputs": { + "log": { + "type": "String", + "value": "l r " + } + } + }, + { + "finalState": "l3+r3", + "outputs": { + "log": { + "type": "String", + "value": "r l " + } + } + } + ], + "admissible": "Regions of a parallel state entered on one occurrence: each is entered, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.seed-1.trace.golden new file mode 100644 index 0000000000..4ddab726a9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.seed-1.trace.golden @@ -0,0 +1,40 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: l2 +transition: l1 -> l2 (event: accept Go) +exit: r1 +enter: r2 +transition: r1 -> r2 (event: accept Go) +eval feature resumed -> false +eval feature resumed -> false +choice exiting work: next l2(exit), r2(exit) (unordered; took l2(exit) first) +exit: l2 +exit: r2 +exit: work +enter: paused (entry action) +stmt action body + stmt assign resumed + eval literal true -> true +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next l2(entry), r2(entry) (unordered; took l2(entry) first) +enter: l2 +enter: r2 +transition: paused -> work (event: accept Resume) +eval feature resumed -> true +exit: l2 +stmt assign log + eval feature log -> "" + eval literal "l " -> "l " + eval operator + -> "l " +enter: l3 +transition: l2 -> l3 +eval feature resumed -> true +exit: r2 +stmt assign log + eval feature log -> "l " + eval literal "r " -> "r " + eval operator + -> "l r " +enter: r3 +transition: r2 -> r3 diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.sysml b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.sysml new file mode 100644 index 0000000000..1ac91aef82 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.sysml @@ -0,0 +1,42 @@ +// A shallow history restores two regions' recorded states, each of which +// completes at once when restored: its completion transition is guarded on a +// flag the pause raised, so the completion event generated at the first entry +// was dropped and the one generated at the restore fires. The entries perform +// nothing, yet each generates a completion event, so the restore's entry draw +// decides the pool's order and the effects log it; both orders are admissible. +package Test { + state Machine { + attribute log : String = ""; + attribute resumed : Boolean = false; + + entry; then work; + state work parallel { + shallow history resume; + state left { + entry; then l1; + state l1; + state l2; + state l3; + transition first l1 accept Go then l2; + transition first l2 if resumed do assign log := log + "l " then l3; + } + state right { + entry; then r1; + state r1; + state r2; + state r3; + transition first r1 accept Go then r2; + transition first r2 if resumed do assign log := log + "r " then r3; + } + } + state paused { + entry { assign resumed := true; } + } + + transition first work accept Pause then paused; + transition first paused accept Resume then resume; + } + attribute def Go; + attribute def Pause; + attribute def Resume; +} diff --git a/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.trace.golden new file mode 100644 index 0000000000..4ddab726a9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_completion_pool_history_order.trace.golden @@ -0,0 +1,40 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: l2 +transition: l1 -> l2 (event: accept Go) +exit: r1 +enter: r2 +transition: r1 -> r2 (event: accept Go) +eval feature resumed -> false +eval feature resumed -> false +choice exiting work: next l2(exit), r2(exit) (unordered; took l2(exit) first) +exit: l2 +exit: r2 +exit: work +enter: paused (entry action) +stmt action body + stmt assign resumed + eval literal true -> true +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next l2(entry), r2(entry) (unordered; took l2(entry) first) +enter: l2 +enter: r2 +transition: paused -> work (event: accept Resume) +eval feature resumed -> true +exit: l2 +stmt assign log + eval feature log -> "" + eval literal "l " -> "l " + eval operator + -> "l " +enter: l3 +transition: l2 -> l3 +eval feature resumed -> true +exit: r2 +stmt assign log + eval feature log -> "l " + eval literal "r " -> "r " + eval operator + -> "l r " +enter: r3 +transition: r2 -> r3 diff --git a/internal/core/runtime/testdata/conformance/state_completion_through_pseudostate.expected.json b/internal/exec/runtime/testdata/conformance/state_completion_through_pseudostate.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_through_pseudostate.expected.json rename to internal/exec/runtime/testdata/conformance/state_completion_through_pseudostate.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_completion_through_pseudostate.sysml b/internal/exec/runtime/testdata/conformance/state_completion_through_pseudostate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_through_pseudostate.sysml rename to internal/exec/runtime/testdata/conformance/state_completion_through_pseudostate.sysml diff --git a/internal/core/runtime/testdata/conformance/state_completion_through_pseudostate.trace.golden b/internal/exec/runtime/testdata/conformance/state_completion_through_pseudostate.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_completion_through_pseudostate.trace.golden rename to internal/exec/runtime/testdata/conformance/state_completion_through_pseudostate.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_composite_completion_inside_region.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_completion_inside_region.expected.json similarity index 92% rename from internal/core/runtime/testdata/conformance/state_composite_completion_inside_region.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_completion_inside_region.expected.json index 5753145c77..e7b119b870 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_completion_inside_region.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_composite_completion_inside_region.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ { "signal": "Go" diff --git a/internal/core/runtime/testdata/conformance/state_composite_completion_inside_region.sysml b/internal/exec/runtime/testdata/conformance/state_composite_completion_inside_region.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_completion_inside_region.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_completion_inside_region.sysml diff --git a/internal/core/runtime/testdata/conformance/state_composite_completion_inside_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_completion_inside_region.trace.golden similarity index 87% rename from internal/core/runtime/testdata/conformance/state_composite_completion_inside_region.trace.golden rename to internal/exec/runtime/testdata/conformance/state_composite_completion_inside_region.trace.golden index c0bd6569e9..b21d30585a 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_completion_inside_region.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_composite_completion_inside_region.trace.golden @@ -10,6 +10,7 @@ stmt action body eval operator + -> "/inner;" enter: done transition: done -> done +choice exiting outer: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) exit: done exit: done exit: outer (exit action) diff --git a/internal/core/runtime/testdata/conformance/state_composite_completion_nested.check.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_completion_nested.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_completion_nested.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_completion_nested.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_completion_nested.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_completion_nested.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_completion_nested.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_completion_nested.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_completion_nested.sysml b/internal/exec/runtime/testdata/conformance/state_composite_completion_nested.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_completion_nested.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_completion_nested.sysml diff --git a/internal/core/runtime/testdata/conformance/state_composite_completion_nested.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_completion_nested.trace.golden similarity index 93% rename from internal/core/runtime/testdata/conformance/state_composite_completion_nested.trace.golden rename to internal/exec/runtime/testdata/conformance/state_composite_completion_nested.trace.golden index 4a2bf45ad9..376b16e26d 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_completion_nested.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_composite_completion_nested.trace.golden @@ -18,13 +18,10 @@ stmt action body eval literal "/inner;" -> "/inner;" eval operator + -> "/leaf;/inner;" enter: done +transition: done -> done eval feature rounds -> 1 eval literal 1 -> 1 eval operator == -> true - eval feature rounds -> 1 - eval literal 0 -> 0 -eval operator == -> false -transition: done -> done eval feature rounds -> 1 eval literal 1 -> 1 eval operator == -> true diff --git a/internal/core/runtime/testdata/conformance/state_composite_completion_then_machine_done.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_completion_then_machine_done.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_completion_then_machine_done.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_completion_then_machine_done.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_completion_then_machine_done.sysml b/internal/exec/runtime/testdata/conformance/state_composite_completion_then_machine_done.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_completion_then_machine_done.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_completion_then_machine_done.sysml diff --git a/internal/core/runtime/testdata/conformance/state_composite_completion_then_machine_done.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_completion_then_machine_done.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_completion_then_machine_done.trace.golden rename to internal/exec/runtime/testdata/conformance/state_composite_completion_then_machine_done.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_composite_exit_order.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_exit_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_exit_order.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_exit_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_exit_order.sysml b/internal/exec/runtime/testdata/conformance/state_composite_exit_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_exit_order.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_exit_order.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_composite_exit_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_exit_order.trace.golden new file mode 100644 index 0000000000..fcb5d8c0e2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_exit_order.trace.golden @@ -0,0 +1,44 @@ +exit: start +enter: Outer +enter: Middle +enter: Inner +transition: start -> Inner +exit: Inner (exit action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: Middle (exit action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +exit: Outer (exit action) +stmt action body + stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 3 -> 3 + eval operator + -> 123 +stmt assign log + eval feature log -> 123 + eval literal 10 -> 10 + eval operator * -> 1230 + eval literal 4 -> 4 + eval operator + -> 1234 +enter: Done (entry action) +stmt action body + stmt assign log + eval feature log -> 1234 + eval literal 10 -> 10 + eval operator * -> 12340 + eval literal 5 -> 5 + eval operator + -> 12345 +transition: Inner -> Done (event: accept Abort) diff --git a/internal/core/runtime/testdata/conformance/state_composite_inner_priority.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_inner_priority.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_inner_priority.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_inner_priority.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_inner_priority.sysml b/internal/exec/runtime/testdata/conformance/state_composite_inner_priority.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_inner_priority.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_inner_priority.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_composite_inner_priority.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_inner_priority.trace.golden new file mode 100644 index 0000000000..50e98295f6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_inner_priority.trace.golden @@ -0,0 +1,21 @@ +exit: start +enter: Working +enter: Step1 +transition: start -> Step1 +exit: Step1 (exit action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +enter: Step2 (entry action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +transition: Step1 -> Step2 (event: accept Abort) diff --git a/internal/core/runtime/testdata/conformance/state_composite_nested_regions_exit_once.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_nested_regions_exit_once.expected.json similarity index 91% rename from internal/core/runtime/testdata/conformance/state_composite_nested_regions_exit_once.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_nested_regions_exit_once.expected.json index f5a8f71e86..96663fe062 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_nested_regions_exit_once.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_composite_nested_regions_exit_once.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ {"signal": "stop", "args": null} ], diff --git a/internal/core/runtime/testdata/conformance/state_composite_nested_regions_exit_once.sysml b/internal/exec/runtime/testdata/conformance/state_composite_nested_regions_exit_once.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_nested_regions_exit_once.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_nested_regions_exit_once.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_composite_nested_regions_exit_once.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_nested_regions_exit_once.trace.golden new file mode 100644 index 0000000000..2f289d690c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_nested_regions_exit_once.trace.golden @@ -0,0 +1,58 @@ +exit: start +enter: Outer +choice entering Outer: next lstart(entry), rstart(entry) (unordered; took lstart(entry) first) +enter: lstart +enter: rstart +transition: start -> Outer +exit: lstart +enter: Grouping +enter: gstart +transition: lstart -> Grouping +exit: rstart +enter: Watching +transition: rstart -> Watching +exit: gstart +enter: g1 +transition: gstart -> g1 +choice exiting Outer: next g1(exit), Watching(exit) (unordered; took g1(exit) first) +exit: g1 (exit action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 3 -> 3 + eval operator + -> 3 +choice exiting Outer: next Grouping(exit), Watching(exit) (unordered; took Grouping(exit) first) +exit: Grouping (exit action) +stmt action body + stmt assign log + eval feature log -> 3 + eval literal 10 -> 10 + eval operator * -> 30 + eval literal 2 -> 2 + eval operator + -> 32 +exit: Watching (exit action) +stmt action body + stmt assign log + eval feature log -> 32 + eval literal 10 -> 10 + eval operator * -> 320 + eval literal 4 -> 4 + eval operator + -> 324 +exit: Outer (exit action) +stmt action body + stmt assign log + eval feature log -> 324 + eval literal 10 -> 10 + eval operator * -> 3240 + eval literal 1 -> 1 + eval operator + -> 3241 +stmt assign log + eval feature log -> 3241 + eval literal 10 -> 10 + eval operator * -> 32410 + eval literal 9 -> 9 + eval operator + -> 32419 +enter: Done +transition: Grouping -> Done (event: accept stop) diff --git a/internal/core/runtime/testdata/conformance/state_composite_orthogonal_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_orthogonal_exit.expected.json similarity index 91% rename from internal/core/runtime/testdata/conformance/state_composite_orthogonal_exit.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_orthogonal_exit.expected.json index b7087fc19b..1f65e47a5f 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_orthogonal_exit.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_composite_orthogonal_exit.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ {"signal": "Abort", "args": null} ], diff --git a/internal/core/runtime/testdata/conformance/state_composite_orthogonal_exit.sysml b/internal/exec/runtime/testdata/conformance/state_composite_orthogonal_exit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_orthogonal_exit.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_orthogonal_exit.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_composite_orthogonal_exit.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_orthogonal_exit.trace.golden new file mode 100644 index 0000000000..2cb027cab8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_orthogonal_exit.trace.golden @@ -0,0 +1,52 @@ +exit: start +enter: Working +choice entering Working: next lstart(entry), rstart(entry) (unordered; took lstart(entry) first) +enter: lstart +enter: rstart +transition: start -> Working +exit: lstart +enter: lwork +transition: lstart -> lwork +exit: rstart +enter: rwork +transition: rstart -> rwork +choice exiting Working: next lwork(exit), rwork(exit) (unordered; took lwork(exit) first) +exit: lwork (exit action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: rwork (exit action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +exit: Working (exit action) +stmt action body + stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 3 -> 3 + eval operator + -> 123 +stmt assign log + eval feature log -> 123 + eval literal 10 -> 10 + eval operator * -> 1230 + eval literal 4 -> 4 + eval operator + -> 1234 +enter: Done (entry action) +stmt action body + stmt assign log + eval feature log -> 1234 + eval literal 10 -> 10 + eval operator * -> 12340 + eval literal 5 -> 5 + eval operator + -> 12345 +transition: Working -> Done (event: accept Abort) diff --git a/internal/core/runtime/testdata/conformance/state_composite_outer_time_trigger.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_outer_time_trigger.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_outer_time_trigger.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_outer_time_trigger.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_outer_time_trigger.sysml b/internal/exec/runtime/testdata/conformance/state_composite_outer_time_trigger.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_outer_time_trigger.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_outer_time_trigger.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_composite_outer_time_trigger.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_outer_time_trigger.trace.golden new file mode 100644 index 0000000000..e152139d71 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_outer_time_trigger.trace.golden @@ -0,0 +1,31 @@ +exit: start +enter: Working +enter: Step1 + eval literal 5 -> 5 +eval index -> 5 [s] +transition: start -> Step1 +exit: Step1 (exit action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: Working (exit action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +enter: Done (entry action) +stmt action body + stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 3 -> 3 + eval operator + -> 123 +transition: Step1 -> Done (event: time) diff --git a/internal/core/runtime/testdata/conformance/state_composite_outer_transition.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_outer_transition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_outer_transition.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_outer_transition.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_outer_transition.sysml b/internal/exec/runtime/testdata/conformance/state_composite_outer_transition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_outer_transition.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_outer_transition.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_composite_outer_transition.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_outer_transition.trace.golden new file mode 100644 index 0000000000..b2927b0f5a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_outer_transition.trace.golden @@ -0,0 +1,35 @@ +exit: start +enter: Working +enter: Step1 +transition: start -> Step1 +exit: Step1 (exit action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: Working (exit action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 3 -> 3 + eval operator + -> 123 +enter: Done (entry action) +stmt action body + stmt assign log + eval feature log -> 123 + eval literal 10 -> 10 + eval operator * -> 1230 + eval literal 4 -> 4 + eval operator + -> 1234 +transition: Step1 -> Done (event: accept Abort) diff --git a/internal/core/runtime/testdata/conformance/state_composite_region_deeper_first.check.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_region_deeper_first.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_region_deeper_first.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.declared.trace.golden similarity index 78% rename from internal/core/runtime/testdata/conformance/state_composite_region_deeper_first.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.declared.trace.golden index 73c5715bb1..9fc4f7cba6 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_region_deeper_first.declared.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.declared.trace.golden @@ -1,5 +1,6 @@ exit: start enter: Working +choice entering Working: next lstart(entry), rstart(entry) (unordered; took lstart(entry) first) enter: lstart enter: rstart transition: start -> Working @@ -13,7 +14,7 @@ transition: rstart -> r1 exit: dstart enter: d1 transition: dstart -> d1 -choice on accept Tick: states d1, r1 react (unordered; took d1 first) +choice on accept Tick: next d1(exit), r1(exit) (unordered; took d1(exit) first) exit: d1 stmt assign log eval feature log -> 0 diff --git a/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.expected.json new file mode 100644 index 0000000000..cb2b5b5fba --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.expected.json @@ -0,0 +1,37 @@ +{ + "type": "state", + "events": [ + {"signal": "Tick", "args": null} + ], + "outcomes": [ + { + "finalState": "d2+deep+r2", + "stateVisits": ["start", "Working", "lstart", "rstart", "deep", "dstart", "r1", "d1", "d2", "r2"], + "outputs": { + "log": {"type": "Integer", "value": 12} + } + }, + { + "finalState": "d2+deep+r2", + "stateVisits": ["start", "Working", "lstart", "rstart", "deep", "dstart", "r1", "d1", "r2", "d2"], + "outputs": { + "log": {"type": "Integer", "value": 21} + } + }, + { + "finalState": "d2+deep+r2", + "stateVisits": ["start", "Working", "rstart", "lstart", "r1", "deep", "dstart", "d1", "d2", "r2"], + "outputs": { + "log": {"type": "Integer", "value": 12} + } + }, + { + "finalState": "d2+deep+r2", + "stateVisits": ["start", "Working", "rstart", "lstart", "r1", "deep", "dstart", "d1", "r2", "d2"], + "outputs": { + "log": {"type": "Integer", "value": 21} + } + } + ], + "admissible": "Transitions in sibling regions enabled by one event: each fires, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.seed-1.trace.golden new file mode 100644 index 0000000000..84b2ca8048 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.seed-1.trace.golden @@ -0,0 +1,35 @@ +exit: start +enter: Working +choice entering Working: next lstart(entry), rstart(entry) (unordered; took rstart(entry) first) +enter: rstart +enter: lstart +transition: start -> Working +exit: rstart +enter: r1 +transition: rstart -> r1 +exit: lstart +enter: deep +enter: dstart +transition: lstart -> deep +exit: dstart +enter: d1 +transition: dstart -> d1 +choice on accept Tick: next d1(exit), r1(exit) (unordered; took d1(exit) first) +exit: d1 +stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +enter: d2 +transition: d1 -> d2 (event: accept Tick) +exit: r1 +stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +enter: r2 +transition: r1 -> r2 (event: accept Tick) diff --git a/internal/core/runtime/testdata/conformance/state_composite_region_deeper_first.sysml b/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_region_deeper_first.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.sysml diff --git a/internal/core/runtime/testdata/conformance/state_composite_region_deeper_first.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.trace.golden similarity index 78% rename from internal/core/runtime/testdata/conformance/state_composite_region_deeper_first.trace.golden rename to internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.trace.golden index 73c5715bb1..9fc4f7cba6 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_region_deeper_first.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_composite_region_deeper_first.trace.golden @@ -1,5 +1,6 @@ exit: start enter: Working +choice entering Working: next lstart(entry), rstart(entry) (unordered; took lstart(entry) first) enter: lstart enter: rstart transition: start -> Working @@ -13,7 +14,7 @@ transition: rstart -> r1 exit: dstart enter: d1 transition: dstart -> d1 -choice on accept Tick: states d1, r1 react (unordered; took d1 first) +choice on accept Tick: next d1(exit), r1(exit) (unordered; took d1(exit) first) exit: d1 stmt assign log eval feature log -> 0 diff --git a/internal/core/runtime/testdata/conformance/state_composite_region_depth_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_region_depth_order.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_region_depth_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.declared.trace.golden similarity index 78% rename from internal/core/runtime/testdata/conformance/state_composite_region_depth_order.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.declared.trace.golden index 4845ae8554..71aed3cb35 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_region_depth_order.declared.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.declared.trace.golden @@ -1,5 +1,6 @@ exit: start enter: Working +choice entering Working: next lstart(entry), rstart(entry) (unordered; took lstart(entry) first) enter: lstart enter: rstart transition: start -> Working @@ -13,7 +14,7 @@ transition: rstart -> deep exit: dstart enter: d1 transition: dstart -> d1 -choice on accept Tick: states l1, d1 react (unordered; took l1 first) +choice on accept Tick: next l1(exit), d1(exit) (unordered; took l1(exit) first) exit: l1 stmt assign log eval feature log -> 0 diff --git a/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.expected.json new file mode 100644 index 0000000000..6b1f0b6567 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.expected.json @@ -0,0 +1,37 @@ +{ + "type": "state", + "events": [ + {"signal": "Tick", "args": null} + ], + "outcomes": [ + { + "finalState": "d2+l2+deep", + "stateVisits": ["start", "Working", "lstart", "rstart", "l1", "deep", "dstart", "d1", "d2", "l2"], + "outputs": { + "log": {"type": "Integer", "value": 21} + } + }, + { + "finalState": "d2+l2+deep", + "stateVisits": ["start", "Working", "lstart", "rstart", "l1", "deep", "dstart", "d1", "l2", "d2"], + "outputs": { + "log": {"type": "Integer", "value": 12} + } + }, + { + "finalState": "d2+l2+deep", + "stateVisits": ["start", "Working", "rstart", "lstart", "deep", "dstart", "l1", "d1", "d2", "l2"], + "outputs": { + "log": {"type": "Integer", "value": 21} + } + }, + { + "finalState": "d2+l2+deep", + "stateVisits": ["start", "Working", "rstart", "lstart", "deep", "dstart", "l1", "d1", "l2", "d2"], + "outputs": { + "log": {"type": "Integer", "value": 12} + } + } + ], + "admissible": "Transitions in sibling regions enabled by one event: each fires, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.seed-1.trace.golden new file mode 100644 index 0000000000..a75f0c542e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.seed-1.trace.golden @@ -0,0 +1,35 @@ +exit: start +enter: Working +choice entering Working: next lstart(entry), rstart(entry) (unordered; took rstart(entry) first) +enter: rstart +enter: lstart +transition: start -> Working +exit: rstart +enter: deep +enter: dstart +transition: rstart -> deep +exit: lstart +enter: l1 +transition: lstart -> l1 +exit: dstart +enter: d1 +transition: dstart -> d1 +choice on accept Tick: next l1(exit), d1(exit) (unordered; took l1(exit) first) +exit: l1 +stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +enter: l2 +transition: l1 -> l2 (event: accept Tick) +exit: d1 +stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +enter: d2 +transition: d1 -> d2 (event: accept Tick) diff --git a/internal/core/runtime/testdata/conformance/state_composite_region_depth_order.sysml b/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_region_depth_order.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.sysml diff --git a/internal/core/runtime/testdata/conformance/state_composite_region_depth_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.trace.golden similarity index 78% rename from internal/core/runtime/testdata/conformance/state_composite_region_depth_order.trace.golden rename to internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.trace.golden index 4845ae8554..71aed3cb35 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_region_depth_order.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_composite_region_depth_order.trace.golden @@ -1,5 +1,6 @@ exit: start enter: Working +choice entering Working: next lstart(entry), rstart(entry) (unordered; took lstart(entry) first) enter: lstart enter: rstart transition: start -> Working @@ -13,7 +14,7 @@ transition: rstart -> deep exit: dstart enter: d1 transition: dstart -> d1 -choice on accept Tick: states l1, d1 react (unordered; took l1 first) +choice on accept Tick: next l1(exit), d1(exit) (unordered; took l1(exit) first) exit: l1 stmt assign log eval feature log -> 0 diff --git a/internal/core/runtime/testdata/conformance/state_composite_region_time_trigger.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_region_time_trigger.expected.json similarity index 90% rename from internal/core/runtime/testdata/conformance/state_composite_region_time_trigger.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_region_time_trigger.expected.json index d38e1e2a82..87433f1b2f 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_region_time_trigger.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_composite_region_time_trigger.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "libraries": true, "finalState": "Recovered+Watching", "stateVisits": ["start", "Working", "lstart", "rstart", "Grouping", "Step1", "Watching", "Recovered"], diff --git a/internal/core/runtime/testdata/conformance/state_composite_region_time_trigger.sysml b/internal/exec/runtime/testdata/conformance/state_composite_region_time_trigger.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_region_time_trigger.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_region_time_trigger.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_composite_region_time_trigger.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_region_time_trigger.trace.golden new file mode 100644 index 0000000000..79f18923b8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_region_time_trigger.trace.golden @@ -0,0 +1,40 @@ +exit: start +enter: Working +choice entering Working: next lstart(entry), rstart(entry) (unordered; took lstart(entry) first) +enter: lstart +enter: rstart +transition: start -> Working +exit: lstart +enter: Grouping +enter: Step1 + eval literal 5 -> 5 +eval index -> 5 [s] +transition: lstart -> Step1 +exit: rstart +enter: Watching +transition: rstart -> Watching +exit: Step1 (exit action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: Grouping (exit action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +enter: Recovered (entry action) +stmt action body + stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 3 -> 3 + eval operator + -> 123 +transition: Step1 -> Recovered (event: time) diff --git a/internal/core/runtime/testdata/conformance/state_composite_self_transition.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_self_transition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_self_transition.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_self_transition.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_self_transition.sysml b/internal/exec/runtime/testdata/conformance/state_composite_self_transition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_self_transition.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_self_transition.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_composite_self_transition.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_self_transition.trace.golden new file mode 100644 index 0000000000..867dfa4034 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_self_transition.trace.golden @@ -0,0 +1,63 @@ +exit: start +enter: Working (entry action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +enter: lstart +transition: start -> Working +exit: lstart +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 3 -> 3 + eval operator + -> 13 +transition: lstart -> l1 +exit: l1 (exit action) +stmt action body + stmt assign log + eval feature log -> 13 + eval literal 10 -> 10 + eval operator * -> 130 + eval literal 4 -> 4 + eval operator + -> 134 +exit: Working (exit action) +stmt action body + stmt assign log + eval feature log -> 134 + eval literal 10 -> 10 + eval operator * -> 1340 + eval literal 2 -> 2 + eval operator + -> 1342 +stmt assign log + eval feature log -> 1342 + eval literal 10 -> 10 + eval operator * -> 13420 + eval literal 9 -> 9 + eval operator + -> 13429 +enter: Working (entry action) +stmt action body + stmt assign log + eval feature log -> 13429 + eval literal 10 -> 10 + eval operator * -> 134290 + eval literal 1 -> 1 + eval operator + -> 134291 +enter: lstart +transition: Working -> Working (event: accept restart) +exit: lstart +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> 134291 + eval literal 10 -> 10 + eval operator * -> 1342910 + eval literal 3 -> 3 + eval operator + -> 1342913 +transition: lstart -> l1 diff --git a/internal/core/runtime/testdata/conformance/state_composite_self_transition_in_region.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_self_transition_in_region.expected.json similarity index 92% rename from internal/core/runtime/testdata/conformance/state_composite_self_transition_in_region.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_self_transition_in_region.expected.json index 212e76a4d9..0b033da9b2 100644 --- a/internal/core/runtime/testdata/conformance/state_composite_self_transition_in_region.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_composite_self_transition_in_region.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ {"signal": "restart", "args": null} ], diff --git a/internal/core/runtime/testdata/conformance/state_composite_self_transition_in_region.sysml b/internal/exec/runtime/testdata/conformance/state_composite_self_transition_in_region.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_self_transition_in_region.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_self_transition_in_region.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_composite_self_transition_in_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_self_transition_in_region.trace.golden new file mode 100644 index 0000000000..3bb5dfcdd8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_self_transition_in_region.trace.golden @@ -0,0 +1,79 @@ +exit: start +enter: Outer +choice entering Outer: next lstart(entry), rstart(entry) (unordered; took lstart(entry) first) +enter: lstart +enter: rstart +transition: start -> Outer +exit: lstart +enter: Grouping (entry action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +enter: gstart +transition: lstart -> Grouping +exit: rstart +enter: Watching (entry action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 7 -> 7 + eval operator + -> 17 +transition: rstart -> Watching +exit: gstart +enter: g1 (entry action) +stmt action body + stmt assign log + eval feature log -> 17 + eval literal 10 -> 10 + eval operator * -> 170 + eval literal 3 -> 3 + eval operator + -> 173 +transition: gstart -> g1 +exit: g1 (exit action) +stmt action body + stmt assign log + eval feature log -> 173 + eval literal 10 -> 10 + eval operator * -> 1730 + eval literal 4 -> 4 + eval operator + -> 1734 +exit: Grouping (exit action) +stmt action body + stmt assign log + eval feature log -> 1734 + eval literal 10 -> 10 + eval operator * -> 17340 + eval literal 2 -> 2 + eval operator + -> 17342 +stmt assign log + eval feature log -> 17342 + eval literal 10 -> 10 + eval operator * -> 173420 + eval literal 9 -> 9 + eval operator + -> 173429 +enter: Grouping (entry action) +stmt action body + stmt assign log + eval feature log -> 173429 + eval literal 10 -> 10 + eval operator * -> 1734290 + eval literal 1 -> 1 + eval operator + -> 1734291 +enter: gstart +transition: Grouping -> Grouping (event: accept restart) +exit: gstart +enter: g1 (entry action) +stmt action body + stmt assign log + eval feature log -> 1734291 + eval literal 10 -> 10 + eval operator * -> 17342910 + eval literal 3 -> 3 + eval operator + -> 17342913 +transition: gstart -> g1 diff --git a/internal/core/runtime/testdata/conformance/state_composite_to_substate.expected.json b/internal/exec/runtime/testdata/conformance/state_composite_to_substate.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_to_substate.expected.json rename to internal/exec/runtime/testdata/conformance/state_composite_to_substate.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_composite_to_substate.sysml b/internal/exec/runtime/testdata/conformance/state_composite_to_substate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_composite_to_substate.sysml rename to internal/exec/runtime/testdata/conformance/state_composite_to_substate.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_composite_to_substate.trace.golden b/internal/exec/runtime/testdata/conformance/state_composite_to_substate.trace.golden new file mode 100644 index 0000000000..d596ac33d4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_composite_to_substate.trace.golden @@ -0,0 +1,57 @@ +exit: start +enter: Working (entry action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +enter: Step1 (entry action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 3 -> 3 + eval operator + -> 13 +transition: start -> Step1 +exit: Step1 (exit action) +stmt action body + stmt assign log + eval feature log -> 13 + eval literal 10 -> 10 + eval operator * -> 130 + eval literal 4 -> 4 + eval operator + -> 134 +exit: Working (exit action) +stmt action body + stmt assign log + eval feature log -> 134 + eval literal 10 -> 10 + eval operator * -> 1340 + eval literal 2 -> 2 + eval operator + -> 1342 +stmt assign log + eval feature log -> 1342 + eval literal 10 -> 10 + eval operator * -> 13420 + eval literal 9 -> 9 + eval operator + -> 13429 +enter: Working (entry action) +stmt action body + stmt assign log + eval feature log -> 13429 + eval literal 10 -> 10 + eval operator * -> 134290 + eval literal 1 -> 1 + eval operator + -> 134291 +enter: Step2 (entry action) +stmt action body + stmt assign log + eval feature log -> 134291 + eval literal 10 -> 10 + eval operator * -> 1342910 + eval literal 5 -> 5 + eval operator + -> 1342915 +transition: Step1 -> Step2 (event: accept advance) diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_do.check.expected.json b/internal/exec/runtime/testdata/conformance/state_concurrent_do.check.expected.json new file mode 100644 index 0000000000..56e6386767 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_do.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"seq": ["123456", "124356", "124536", "124563", "142356", "142536", "142563", "145236", "145263", "145623", "412356", "412536", "412563", "415236", "415263", "415623", "451236", "451263", "451623", "456123"]}, + "agreed": {"finalState": "ldone+rdone"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_do.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_concurrent_do.declared.trace.golden new file mode 100644 index 0000000000..b2c41ce010 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_do.declared.trace.golden @@ -0,0 +1,58 @@ +exit: lstart +enter: lwork +transition: lstart -> lwork +do: lwork +stmt action body + stmt assign seq + eval feature seq -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: rstart +enter: rwork +transition: rstart -> rwork +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt assign seq + eval feature seq -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +do: rwork +stmt action body + stmt assign seq + eval feature seq -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 4 -> 4 + eval operator + -> 124 +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt assign seq + eval feature seq -> 124 + eval literal 10 -> 10 + eval operator * -> 1240 + eval literal 3 -> 3 + eval operator + -> 1243 +do: rwork + stmt assign seq + eval feature seq -> 1243 + eval literal 10 -> 10 + eval operator * -> 12430 + eval literal 5 -> 5 + eval operator + -> 12435 +exit: lwork +enter: ldone +transition: lwork -> ldone +do: rwork + stmt assign seq + eval feature seq -> 12435 + eval literal 10 -> 10 + eval operator * -> 124350 + eval literal 6 -> 6 + eval operator + -> 124356 +exit: rwork +enter: rdone +transition: rwork -> rdone diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_do.expected.json b/internal/exec/runtime/testdata/conformance/state_concurrent_do.expected.json new file mode 100644 index 0000000000..2c9af354e8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_do.expected.json @@ -0,0 +1,86 @@ +{ + "type": "state", + "outcomes": [ + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 123456 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 124356 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 124536 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 124563 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 142356 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 142536 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 142563 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 145236 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 145263 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 145623 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 412356 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 412536 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 412563 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 415236 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 415263 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 415623 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 451236 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 451263 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 451623 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 456123 } } + } + ], + "admissible": "Do behaviors of sibling regions active at one instant: each proceeds, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_do.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_concurrent_do.seed-1.trace.golden new file mode 100644 index 0000000000..9d125119cd --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_do.seed-1.trace.golden @@ -0,0 +1,58 @@ +exit: rstart +enter: rwork +transition: rstart -> rwork +do: rwork +stmt action body + stmt assign seq + eval feature seq -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 4 -> 4 + eval operator + -> 4 +exit: lstart +enter: lwork +transition: lstart -> lwork +choice do round at t=0.0: states rwork, lwork react (unordered; took rwork first) +do: rwork + stmt assign seq + eval feature seq -> 4 + eval literal 10 -> 10 + eval operator * -> 40 + eval literal 5 -> 5 + eval operator + -> 45 +do: lwork +stmt action body + stmt assign seq + eval feature seq -> 45 + eval literal 10 -> 10 + eval operator * -> 450 + eval literal 1 -> 1 + eval operator + -> 451 +choice do round at t=0.0: states rwork, lwork react (unordered; took rwork first) +do: rwork + stmt assign seq + eval feature seq -> 451 + eval literal 10 -> 10 + eval operator * -> 4510 + eval literal 6 -> 6 + eval operator + -> 4516 +do: lwork + stmt assign seq + eval feature seq -> 4516 + eval literal 10 -> 10 + eval operator * -> 45160 + eval literal 2 -> 2 + eval operator + -> 45162 +exit: rwork +enter: rdone +transition: rwork -> rdone +do: lwork + stmt assign seq + eval feature seq -> 45162 + eval literal 10 -> 10 + eval operator * -> 451620 + eval literal 3 -> 3 + eval operator + -> 451623 +exit: lwork +enter: ldone +transition: lwork -> ldone diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_do.sysml b/internal/exec/runtime/testdata/conformance/state_concurrent_do.sysml new file mode 100644 index 0000000000..f676e09971 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_do.sysml @@ -0,0 +1,45 @@ +package Test { + // Two orthogonal regions whose do behaviors run while their states are + // active. Each braced `do { … }` is one anonymous action run one statement + // a round, and each digit records one statement, so the resulting number + // shows the interleaving: the region whose start the entry draw entered + // first completes first and enters its work one step before the other, then + // the two share the machine one statement at a time, the order within a + // round a choice the policy draws (1,2,4,3,5,6 in declaration order). A do + // behavior run to its end at entry would give 1,2,3,4,5,6 instead. + state def Interleave parallel { + state left { + entry; then lstart; + state lstart; + state lwork { + do { + assign seq := seq * 10 + 1; + assign seq := seq * 10 + 2; + assign seq := seq * 10 + 3; + } + } + state ldone; + + succession first lstart then lwork; + succession first lwork then ldone; + } + + state right { + entry; then rstart; + state rstart; + state rwork { + do { + assign seq := seq * 10 + 4; + assign seq := seq * 10 + 5; + assign seq := seq * 10 + 6; + } + } + state rdone; + + succession first rstart then rwork; + succession first rwork then rdone; + } + + attribute seq : Integer = 0; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_do.trace.golden b/internal/exec/runtime/testdata/conformance/state_concurrent_do.trace.golden new file mode 100644 index 0000000000..b2c41ce010 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_do.trace.golden @@ -0,0 +1,58 @@ +exit: lstart +enter: lwork +transition: lstart -> lwork +do: lwork +stmt action body + stmt assign seq + eval feature seq -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: rstart +enter: rwork +transition: rstart -> rwork +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt assign seq + eval feature seq -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +do: rwork +stmt action body + stmt assign seq + eval feature seq -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 4 -> 4 + eval operator + -> 124 +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt assign seq + eval feature seq -> 124 + eval literal 10 -> 10 + eval operator * -> 1240 + eval literal 3 -> 3 + eval operator + -> 1243 +do: rwork + stmt assign seq + eval feature seq -> 1243 + eval literal 10 -> 10 + eval operator * -> 12430 + eval literal 5 -> 5 + eval operator + -> 12435 +exit: lwork +enter: ldone +transition: lwork -> ldone +do: rwork + stmt assign seq + eval feature seq -> 12435 + eval literal 10 -> 10 + eval operator * -> 124350 + eval literal 6 -> 6 + eval operator + -> 124356 +exit: rwork +enter: rdone +transition: rwork -> rdone diff --git a/internal/core/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.check.expected.json b/internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.expected.json b/internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.expected.json rename to internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.expected.json diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.seed-1.trace.golden new file mode 100644 index 0000000000..ae552fe512 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.seed-1.trace.golden @@ -0,0 +1,52 @@ +choice do round at t=0.0: states rwork, lwork react (unordered; took rwork first) +do: rwork +stmt action body +enter action node: state behavior rpoll + eval literal 2 -> 2 + eval index -> 2 [s] +do: lwork +stmt action body +enter action node: state behavior lpoll + eval literal 2 -> 2 + eval index -> 2 [s] +choice do round at t=2.0: states rwork, lwork react (unordered; took rwork first) +do: rwork + stmt assign seq + eval feature seq -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 3 -> 3 + eval operator + -> 3 + eval literal 3 -> 3 + eval index -> 3 [s] +do: lwork + stmt assign seq + eval feature seq -> 3 + eval literal 10 -> 10 + eval operator * -> 30 + eval literal 1 -> 1 + eval operator + -> 31 + eval literal 2 -> 2 + eval index -> 2 [s] +do: lwork + stmt assign seq + eval feature seq -> 31 + eval literal 10 -> 10 + eval operator * -> 310 + eval literal 2 -> 2 + eval operator + -> 312 +leave action node: state behavior lpoll +exit: lwork +enter: ldone +transition: lwork -> ldone +do: rwork + stmt assign seq + eval feature seq -> 312 + eval literal 10 -> 10 + eval operator * -> 3120 + eval literal 4 -> 4 + eval operator + -> 3124 +leave action node: state behavior rpoll +exit: rwork +enter: rdone +transition: rwork -> rdone diff --git a/internal/core/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.sysml b/internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.sysml rename to internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.sysml diff --git a/internal/core/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.trace.golden b/internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.trace.golden rename to internal/exec/runtime/testdata/conformance/state_concurrent_do_action_bodies_timed.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.check.expected.json b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.check.expected.json new file mode 100644 index 0000000000..56e6386767 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"seq": ["123456", "124356", "124536", "124563", "142356", "142536", "142563", "145236", "145263", "145623", "412356", "412536", "412563", "415236", "415263", "415623", "451236", "451263", "451623", "456123"]}, + "agreed": {"finalState": "ldone+rdone"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.declared.trace.golden new file mode 100644 index 0000000000..6c026245d4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.declared.trace.golden @@ -0,0 +1,68 @@ +exit: lstart +enter: lwork +transition: lstart -> lwork +do: lwork +stmt action body + stmt for i + eval literal 1 -> 1 + eval literal 2 -> 2 + eval operator .. -> (1, 2) + iteration 1 + stmt assign seq + eval feature seq -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval feature i -> 1 + eval operator + -> 1 +exit: rstart +enter: rwork +transition: rstart -> rwork +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + iteration 2 + stmt assign seq + eval feature seq -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval feature i -> 2 + eval operator + -> 12 +do: rwork +stmt action body + stmt assign seq + eval feature seq -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 4 -> 4 + eval operator + -> 124 +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt assign seq + eval feature seq -> 124 + eval literal 10 -> 10 + eval operator * -> 1240 + eval literal 3 -> 3 + eval operator + -> 1243 +do: rwork + stmt if + eval feature seq -> 1243 + eval literal 0 -> 0 + eval operator > -> true + stmt assign seq + eval feature seq -> 1243 + eval literal 10 -> 10 + eval operator * -> 12430 + eval literal 5 -> 5 + eval operator + -> 12435 +exit: lwork +enter: ldone +transition: lwork -> ldone +do: rwork + stmt assign seq + eval feature seq -> 12435 + eval literal 10 -> 10 + eval operator * -> 124350 + eval literal 6 -> 6 + eval operator + -> 124356 +exit: rwork +enter: rdone +transition: rwork -> rdone diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.expected.json b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.expected.json new file mode 100644 index 0000000000..d12b68692f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.expected.json @@ -0,0 +1,87 @@ +{ + "type": "state", + "trace": true, + "outcomes": [ + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 123456 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 124356 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 124536 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 124563 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 142356 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 142536 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 142563 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 145236 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 145263 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 145623 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 412356 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 412536 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 412563 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 415236 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 415263 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 415623 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 451236 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 451263 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 451623 } } + }, + { + "finalState": "ldone+rdone", + "outputs": { "seq": { "type": "Integer", "value": 456123 } } + } + ], + "admissible": "Do behaviors of sibling regions active at one instant: each proceeds, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.seed-1.trace.golden new file mode 100644 index 0000000000..a0617c1fc1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.seed-1.trace.golden @@ -0,0 +1,68 @@ +exit: rstart +enter: rwork +transition: rstart -> rwork +do: rwork +stmt action body + stmt assign seq + eval feature seq -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 4 -> 4 + eval operator + -> 4 +exit: lstart +enter: lwork +transition: lstart -> lwork +choice do round at t=0.0: states rwork, lwork react (unordered; took rwork first) +do: rwork + stmt if + eval feature seq -> 4 + eval literal 0 -> 0 + eval operator > -> true + stmt assign seq + eval feature seq -> 4 + eval literal 10 -> 10 + eval operator * -> 40 + eval literal 5 -> 5 + eval operator + -> 45 +do: lwork +stmt action body + stmt for i + eval literal 1 -> 1 + eval literal 2 -> 2 + eval operator .. -> (1, 2) + iteration 1 + stmt assign seq + eval feature seq -> 45 + eval literal 10 -> 10 + eval operator * -> 450 + eval feature i -> 1 + eval operator + -> 451 +choice do round at t=0.0: states rwork, lwork react (unordered; took rwork first) +do: rwork + stmt assign seq + eval feature seq -> 451 + eval literal 10 -> 10 + eval operator * -> 4510 + eval literal 6 -> 6 + eval operator + -> 4516 +do: lwork + iteration 2 + stmt assign seq + eval feature seq -> 4516 + eval literal 10 -> 10 + eval operator * -> 45160 + eval feature i -> 2 + eval operator + -> 45162 +exit: rwork +enter: rdone +transition: rwork -> rdone +do: lwork + stmt assign seq + eval feature seq -> 45162 + eval literal 10 -> 10 + eval operator * -> 451620 + eval literal 3 -> 3 + eval operator + -> 451623 +exit: lwork +enter: ldone +transition: lwork -> ldone diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.sysml b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.sysml new file mode 100644 index 0000000000..6e7a6f1ee0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.sysml @@ -0,0 +1,50 @@ +package Test { + // Two orthogonal regions whose inline do bodies loop and branch. The bodies + // yield after each statement — a `for` body after each iteration, a nested + // block after each of its statements — so the regions share the machine one + // statement at a time as state_concurrent_do's one-action-per-statement form + // does: the region the entry draw entered first enters its work one step + // before the other, then the two interleave, the order within a round a + // choice the policy draws (1,2,4,3,5,6 in declaration order). A body run to + // its end at entry would give 1,2,3,4,5,6 instead. + private import ScalarValues::*; + + state def Interleave parallel { + state left { + entry; then lstart; + state lstart; + state lwork { + do action { + for i in 1..2 { + assign seq := seq * 10 + i; + } + assign seq := seq * 10 + 3; + } + } + state ldone; + + succession first lstart then lwork; + succession first lwork then ldone; + } + + state right { + entry; then rstart; + state rstart; + state rwork { + do action { + assign seq := seq * 10 + 4; + if seq > 0 { + assign seq := seq * 10 + 5; + assign seq := seq * 10 + 6; + } + } + } + state rdone; + + succession first rstart then rwork; + succession first rwork then rdone; + } + + attribute seq : Integer = 0; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.trace.golden b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.trace.golden new file mode 100644 index 0000000000..6c026245d4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_concurrent_inline_do_bodies.trace.golden @@ -0,0 +1,68 @@ +exit: lstart +enter: lwork +transition: lstart -> lwork +do: lwork +stmt action body + stmt for i + eval literal 1 -> 1 + eval literal 2 -> 2 + eval operator .. -> (1, 2) + iteration 1 + stmt assign seq + eval feature seq -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval feature i -> 1 + eval operator + -> 1 +exit: rstart +enter: rwork +transition: rstart -> rwork +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + iteration 2 + stmt assign seq + eval feature seq -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval feature i -> 2 + eval operator + -> 12 +do: rwork +stmt action body + stmt assign seq + eval feature seq -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 4 -> 4 + eval operator + -> 124 +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt assign seq + eval feature seq -> 124 + eval literal 10 -> 10 + eval operator * -> 1240 + eval literal 3 -> 3 + eval operator + -> 1243 +do: rwork + stmt if + eval feature seq -> 1243 + eval literal 0 -> 0 + eval operator > -> true + stmt assign seq + eval feature seq -> 1243 + eval literal 10 -> 10 + eval operator * -> 12430 + eval literal 5 -> 5 + eval operator + -> 12435 +exit: lwork +enter: ldone +transition: lwork -> ldone +do: rwork + stmt assign seq + eval feature seq -> 12435 + eval literal 10 -> 10 + eval operator * -> 124350 + eval literal 6 -> 6 + eval operator + -> 124356 +exit: rwork +enter: rdone +transition: rwork -> rdone diff --git a/internal/core/runtime/testdata/conformance/state_deep_history.expected.json b/internal/exec/runtime/testdata/conformance/state_deep_history.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deep_history.expected.json rename to internal/exec/runtime/testdata/conformance/state_deep_history.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_deep_history.sysml b/internal/exec/runtime/testdata/conformance/state_deep_history.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deep_history.sysml rename to internal/exec/runtime/testdata/conformance/state_deep_history.sysml diff --git a/internal/core/runtime/testdata/conformance/state_deep_history_empty_default_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_deep_history_empty_default_entry.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deep_history_empty_default_entry.expected.json rename to internal/exec/runtime/testdata/conformance/state_deep_history_empty_default_entry.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_deep_history_empty_default_entry.sysml b/internal/exec/runtime/testdata/conformance/state_deep_history_empty_default_entry.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deep_history_empty_default_entry.sysml rename to internal/exec/runtime/testdata/conformance/state_deep_history_empty_default_entry.sysml diff --git a/internal/core/runtime/testdata/conformance/state_deep_history_empty_default_entry.trace.golden b/internal/exec/runtime/testdata/conformance/state_deep_history_empty_default_entry.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deep_history_empty_default_entry.trace.golden rename to internal/exec/runtime/testdata/conformance/state_deep_history_empty_default_entry.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_deep_history_region_composite.expected.json b/internal/exec/runtime/testdata/conformance/state_deep_history_region_composite.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deep_history_region_composite.expected.json rename to internal/exec/runtime/testdata/conformance/state_deep_history_region_composite.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_deep_history_region_composite.sysml b/internal/exec/runtime/testdata/conformance/state_deep_history_region_composite.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deep_history_region_composite.sysml rename to internal/exec/runtime/testdata/conformance/state_deep_history_region_composite.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_deep_history_self_transition.expected.json b/internal/exec/runtime/testdata/conformance/state_deep_history_self_transition.expected.json new file mode 100644 index 0000000000..00d9abcf36 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_deep_history_self_transition.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go", "args": null}, + {"signal": "Leave", "args": null}, + {"signal": "Back", "args": null}, + {"signal": "Next", "args": null}, + {"signal": "Reset", "args": null}, + {"signal": "Finish", "args": null} + ], + "finalState": "done", + "stateVisits": ["start", "outer", "middle", "inner", "away", "outer", "middle", "inner", "innerTwo", "outer", "middle", "innerTwo", "done"], + "outputs": { + "log": {"type": "String", "value": "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);reset(effect);outer(entry);middle(entry);innerTwo(entry);outer(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_deep_history_self_transition.sysml b/internal/exec/runtime/testdata/conformance/state_deep_history_self_transition.sysml new file mode 100644 index 0000000000..23ff5e75e7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_deep_history_self_transition.sysml @@ -0,0 +1,34 @@ +// A transition out of a composite state into its own deep history restores the +// configuration that very transition leaves, not the one recorded by an earlier +// exit: `outer` left from `innerTwo` on Reset re-enters `innerTwo`, though it +// was first left from `inner`. The log pins that `outer`'s exit and entry +// behaviors run around the effect, with `middle` and `innerTwo` re-entered. +package Test { + state DeepHistorySelfTransition { + attribute log : String = ""; + entry; then start; + state start; + state outer { + entry action { assign log := log + "outer(entry);"; } + exit action { assign log := log + "outer(exit);"; } + entry; then middle; + state middle { + entry action { assign log := log + "middle(entry);"; } + entry; then inner; + state inner; + state innerTwo { + entry action { assign log := log + "innerTwo(entry);"; } + } + transition first inner when Next then innerTwo; + } + deep history resume; + } + state away; + + transition first start when Go then outer; + transition first inner when Leave then away; + transition first away when Back then resume; + transition first outer when Reset do { assign log := log + "reset(effect);"; } then resume; + transition first innerTwo when Finish then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_deep_history_self_transition.trace.golden b/internal/exec/runtime/testdata/conformance/state_deep_history_self_transition.trace.golden new file mode 100644 index 0000000000..033b54aaa1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_deep_history_self_transition.trace.golden @@ -0,0 +1,90 @@ +exit: start +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);" +enter: middle (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);" + eval literal "middle(entry);" -> "middle(entry);" + eval operator + -> "outer(entry);middle(entry);" +enter: inner +transition: start -> outer (event: accept Go) +exit: inner +exit: middle +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);middle(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);middle(entry);outer(exit);" +enter: away +transition: inner -> away (event: accept Leave) +exit: away +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);middle(entry);outer(exit);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);middle(entry);outer(exit);outer(entry);" +enter: middle (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);middle(entry);outer(exit);outer(entry);" + eval literal "middle(entry);" -> "middle(entry);" + eval operator + -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);" +enter: inner +transition: away -> inner (event: accept Back) +exit: inner +enter: innerTwo (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);" + eval literal "innerTwo(entry);" -> "innerTwo(entry);" + eval operator + -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);" +transition: inner -> innerTwo (event: accept Next) +exit: innerTwo +exit: middle +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);" + eval literal "reset(effect);" -> "reset(effect);" + eval operator + -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);reset(effect);" +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);reset(effect);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);reset(effect);outer(entry);" +enter: middle (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);reset(effect);outer(entry);" + eval literal "middle(entry);" -> "middle(entry);" + eval operator + -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);reset(effect);outer(entry);middle(entry);" +enter: innerTwo (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);reset(effect);outer(entry);middle(entry);" + eval literal "innerTwo(entry);" -> "innerTwo(entry);" + eval operator + -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);reset(effect);outer(entry);middle(entry);innerTwo(entry);" +transition: innerTwo -> innerTwo (event: accept Reset) +exit: innerTwo +exit: middle +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);reset(effect);outer(entry);middle(entry);innerTwo(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);middle(entry);outer(exit);outer(entry);middle(entry);innerTwo(entry);outer(exit);reset(effect);outer(entry);middle(entry);innerTwo(entry);outer(exit);" +enter: done +transition: innerTwo -> done (event: accept Finish) diff --git a/internal/core/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.expected.json b/internal/exec/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.expected.json similarity index 90% rename from internal/core/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.expected.json rename to internal/exec/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.expected.json index 571d345879..215ab12704 100644 --- a/internal/core/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ {"signal": "Ping", "args": null}, {"signal": "Go", "args": null} diff --git a/internal/core/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.sysml b/internal/exec/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.sysml rename to internal/exec/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.sysml diff --git a/internal/core/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.trace.golden rename to internal/exec/runtime/testdata/conformance/state_deferral_nested_outranks_sibling_region.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_deferral_nested_override.expected.json b/internal/exec/runtime/testdata/conformance/state_deferral_nested_override.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferral_nested_override.expected.json rename to internal/exec/runtime/testdata/conformance/state_deferral_nested_override.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_deferral_nested_override.sysml b/internal/exec/runtime/testdata/conformance/state_deferral_nested_override.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferral_nested_override.sysml rename to internal/exec/runtime/testdata/conformance/state_deferral_nested_override.sysml diff --git a/internal/core/runtime/testdata/conformance/state_deferral_nested_override.trace.golden b/internal/exec/runtime/testdata/conformance/state_deferral_nested_override.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferral_nested_override.trace.golden rename to internal/exec/runtime/testdata/conformance/state_deferral_nested_override.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.expected.json b/internal/exec/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.expected.json rename to internal/exec/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.sysml b/internal/exec/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.sysml rename to internal/exec/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.sysml diff --git a/internal/core/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.trace.golden b/internal/exec/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.trace.golden rename to internal/exec/runtime/testdata/conformance/state_deferral_outranks_enclosing_state.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_deferral_outranks_sibling_region.expected.json b/internal/exec/runtime/testdata/conformance/state_deferral_outranks_sibling_region.expected.json similarity index 89% rename from internal/core/runtime/testdata/conformance/state_deferral_outranks_sibling_region.expected.json rename to internal/exec/runtime/testdata/conformance/state_deferral_outranks_sibling_region.expected.json index 71aaf7dae8..6474d91c6d 100644 --- a/internal/core/runtime/testdata/conformance/state_deferral_outranks_sibling_region.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_deferral_outranks_sibling_region.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ {"signal": "Ping", "args": null}, {"signal": "Go", "args": null} diff --git a/internal/core/runtime/testdata/conformance/state_deferral_outranks_sibling_region.sysml b/internal/exec/runtime/testdata/conformance/state_deferral_outranks_sibling_region.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferral_outranks_sibling_region.sysml rename to internal/exec/runtime/testdata/conformance/state_deferral_outranks_sibling_region.sysml diff --git a/internal/core/runtime/testdata/conformance/state_deferral_outranks_sibling_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_deferral_outranks_sibling_region.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferral_outranks_sibling_region.trace.golden rename to internal/exec/runtime/testdata/conformance/state_deferral_outranks_sibling_region.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_deferred_event.check.expected.json b/internal/exec/runtime/testdata/conformance/state_deferred_event.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferred_event.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_deferred_event.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_deferred_event.expected.json b/internal/exec/runtime/testdata/conformance/state_deferred_event.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferred_event.expected.json rename to internal/exec/runtime/testdata/conformance/state_deferred_event.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_deferred_event.sysml b/internal/exec/runtime/testdata/conformance/state_deferred_event.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_deferred_event.sysml rename to internal/exec/runtime/testdata/conformance/state_deferred_event.sysml diff --git a/internal/core/runtime/testdata/conformance/state_do_action_body_attributes.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_body_attributes.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_body_attributes.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_action_body_attributes.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_action_body_attributes.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_body_attributes.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_body_attributes.sysml rename to internal/exec/runtime/testdata/conformance/state_do_action_body_attributes.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_do_action_branch_enables_other_before_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_branch_enables_other_before_exit.expected.json new file mode 100644 index 0000000000..1911a875b3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_action_branch_enables_other_before_exit.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "libraries": true, + "finalState": "finished", + "outputs": { + "flag": { "type": "Integer", "value": 1 }, + "seen": { "type": "Integer", "value": 0 } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_action_branch_enables_other_before_exit.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_branch_enables_other_before_exit.sysml new file mode 100644 index 0000000000..9e7c4b4b76 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_action_branch_enables_other_before_exit.sysml @@ -0,0 +1,33 @@ +package test { + private import ScalarValues::*; + private import SI::*; + + state def Machine { + attribute flag : Integer = 0; + attribute seen : Integer = 0; + + entry; then running; + state running { + do action poll { + first start; + then fork split; + succession first split then setter; + succession first split then watcher; + + action setter { + first start; + then action wait accept after 1 [s]; + then action raise assign flag := flag + 1; + } + + action watcher { + first start; + then action watch accept when flag > seen; + then action note assign seen := seen + 1; + } + } + } + transition first running accept when flag > 0 then finished; + state finished; + } +} diff --git a/internal/core/runtime/testdata/conformance/state_do_action_declaration_order.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_declaration_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_declaration_order.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_action_declaration_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_action_declaration_order.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_declaration_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_declaration_order.sysml rename to internal/exec/runtime/testdata/conformance/state_do_action_declaration_order.sysml diff --git a/internal/core/runtime/testdata/conformance/state_do_action_declaration_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_action_declaration_order.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_declaration_order.trace.golden rename to internal/exec/runtime/testdata/conformance/state_do_action_declaration_order.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_do_action_fork_join_decision.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_fork_join_decision.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_fork_join_decision.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_action_fork_join_decision.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_action_fork_join_decision.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_fork_join_decision.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_fork_join_decision.sysml rename to internal/exec/runtime/testdata/conformance/state_do_action_fork_join_decision.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_do_action_loop_timed_exit.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_loop_timed_exit.check.expected.json new file mode 100644 index 0000000000..eeda3faa14 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_action_loop_timed_exit.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"left": ["0", "1"], "right": ["0", "1"]}, + "agreed": {"finalState": "heard+finished", "late": "1"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_action_loop_timed_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_loop_timed_exit.expected.json new file mode 100644 index 0000000000..bc8a672c6b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_action_loop_timed_exit.expected.json @@ -0,0 +1,40 @@ +{ + "type": "state", + "libraries": true, + "exploreBudget": {"runs": 8192}, + "outcomes": [ + { + "finalState": "heard+finished", + "outputs": { + "left": { "type": "Integer", "value": 0 }, + "right": { "type": "Integer", "value": 0 }, + "late": { "type": "Integer", "value": 1 } + } + }, + { + "finalState": "heard+finished", + "outputs": { + "left": { "type": "Integer", "value": 0 }, + "right": { "type": "Integer", "value": 1 }, + "late": { "type": "Integer", "value": 1 } + } + }, + { + "finalState": "heard+finished", + "outputs": { + "left": { "type": "Integer", "value": 1 }, + "right": { "type": "Integer", "value": 0 }, + "late": { "type": "Integer", "value": 1 } + } + }, + { + "finalState": "heard+finished", + "outputs": { + "left": { "type": "Integer", "value": 1 }, + "right": { "type": "Integer", "value": 1 }, + "late": { "type": "Integer", "value": 1 } + } + } + ], + "admissible": "A do step and a dispatch due at one instant: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_action_loop_timed_exit.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_loop_timed_exit.sysml new file mode 100644 index 0000000000..1f30ecb692 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_action_loop_timed_exit.sysml @@ -0,0 +1,54 @@ +package test { + private import ScalarValues::*; + private import SI::*; + + state def Machine parallel { + attribute left : Integer = 0; + attribute right : Integer = 0; + attribute late : Integer = 0; + + state listener { + entry; then waiting; + state waiting { + do action listen { + first start; + then action wait accept after 2 [s]; + then action mark assign late := 1; + then done; + } + } + state heard; + succession first waiting then heard; + } + + state worker { + entry; then looping; + state looping { + do action poll { + first start; + then fork split; + succession first split then a; + succession first split then b; + + action a { + first start; + then merge again; + then action wait accept after 1 [s]; + then action write assign left := left + 1; + then again; + } + + action b { + first start; + then merge again; + then action wait accept after 1 [s]; + then action write assign right := right + 1; + then again; + } + } + } + transition first looping accept after 1 [s] then finished; + state finished; + } + } +} diff --git a/internal/core/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.sysml rename to internal/exec/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.sysml diff --git a/internal/core/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.trace.golden rename to internal/exec/runtime/testdata/conformance/state_do_action_signal_accept_cancelled_on_exit.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_do_action_successions_first_start.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_successions_first_start.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_successions_first_start.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_action_successions_first_start.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_action_successions_first_start.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_successions_first_start.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_successions_first_start.sysml rename to internal/exec/runtime/testdata/conformance/state_do_action_successions_first_start.sysml diff --git a/internal/core/runtime/testdata/conformance/state_do_action_successions_first_start.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_action_successions_first_start.trace.golden similarity index 97% rename from internal/core/runtime/testdata/conformance/state_do_action_successions_first_start.trace.golden rename to internal/exec/runtime/testdata/conformance/state_do_action_successions_first_start.trace.golden index f5cab54ff6..6fac803725 100644 --- a/internal/core/runtime/testdata/conformance/state_do_action_successions_first_start.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_do_action_successions_first_start.trace.golden @@ -7,6 +7,7 @@ enter action node: state behavior ops eval operator * -> 0 eval literal 1 -> 1 eval operator + -> 1 +do: working stmt assign seq eval feature seq -> 1 eval literal 10 -> 10 diff --git a/internal/core/runtime/testdata/conformance/state_do_action_successions_named_first.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_successions_named_first.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_successions_named_first.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_action_successions_named_first.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_action_successions_named_first.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_successions_named_first.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_successions_named_first.sysml rename to internal/exec/runtime/testdata/conformance/state_do_action_successions_named_first.sysml diff --git a/internal/core/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.sysml rename to internal/exec/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.sysml diff --git a/internal/core/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.trace.golden rename to internal/exec/runtime/testdata/conformance/state_do_action_timed_accept_cancelled_on_exit.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.sysml rename to internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.sysml diff --git a/internal/core/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.trace.golden rename to internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_cancelled_on_exit.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_do_action_typed_inout_valued_by_a_literal.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_valued_by_a_literal.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_typed_inout_valued_by_a_literal.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_valued_by_a_literal.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_action_typed_inout_valued_by_a_literal.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_valued_by_a_literal.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_typed_inout_valued_by_a_literal.sysml rename to internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_valued_by_a_literal.sysml diff --git a/internal/core/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.expected.json b/internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.sysml b/internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.sysml rename to internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.sysml diff --git a/internal/core/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.trace.golden rename to internal/exec/runtime/testdata/conformance/state_do_action_typed_inout_writes_back.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_do_behavior.expected.json b/internal/exec/runtime/testdata/conformance/state_do_behavior.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_behavior.expected.json rename to internal/exec/runtime/testdata/conformance/state_do_behavior.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_do_behavior.sysml b/internal/exec/runtime/testdata/conformance/state_do_behavior.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_do_behavior.sysml rename to internal/exec/runtime/testdata/conformance/state_do_behavior.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_do_body_interrupted_by_signal.expected.json b/internal/exec/runtime/testdata/conformance/state_do_body_interrupted_by_signal.expected.json new file mode 100644 index 0000000000..cc26ad75b9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_body_interrupted_by_signal.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Stop"}], + "finalState": "halted", + "stateVisits": ["busy", "halted"], + "outputs": { + "log": { "type": "String", "value": "s1 exit " } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_body_interrupted_by_signal.sysml b/internal/exec/runtime/testdata/conformance/state_do_body_interrupted_by_signal.sysml new file mode 100644 index 0000000000..8d984a9e7d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_body_interrupted_by_signal.sysml @@ -0,0 +1,28 @@ +package Test { + private import ScalarValues::*; + + attribute def Stop; + + // A state's inline do body is interrupted where a transition out of the state + // is triggered: the body yields after each statement, the Stop queued is + // dispatched after its first, and the transition drops the two statements + // still to run with the behavior — the exit behavior runs, s2 and s3 never do. + state def Machine { + attribute log : String = ""; + + entry; then busy; + state busy { + do action work { + assign log := log + "s1 "; + assign log := log + "s2 "; + assign log := log + "s3 "; + } + exit action leave { + assign log := log + "exit "; + } + } + state halted; + + transition first busy accept Stop then halted; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_body_interrupted_by_signal.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_body_interrupted_by_signal.trace.golden new file mode 100644 index 0000000000..df0ba901ca --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_body_interrupted_by_signal.trace.golden @@ -0,0 +1,14 @@ +do: busy +stmt action body + stmt assign log + eval feature log -> "" + eval literal "s1 " -> "s1 " + eval operator + -> "s1 " +exit: busy (exit action) +stmt action body + stmt assign log + eval feature log -> "s1 " + eval literal "exit " -> "exit " + eval operator + -> "s1 exit " +enter: halted +transition: busy -> halted (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_reads_entering_transition.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_reads_entering_transition.check.expected.json new file mode 100644 index 0000000000..cb3ae57d2d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_reads_entering_transition.check.expected.json @@ -0,0 +1,17 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"did l1(entry) r1(entry) \"", + "\"did r1(entry) l1(entry) \"", + "\"l1(entry) did r1(entry) \"", + "\"l1(entry) r1(entry) did \"", + "\"r1(entry) did l1(entry) \"", + "\"r1(entry) l1(entry) did \"" + ] + }, + "agreed": { + "finalState": "l1+r1", + "seen": "7" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_reads_entering_transition.expected.json b/internal/exec/runtime/testdata/conformance/state_do_reads_entering_transition.expected.json new file mode 100644 index 0000000000..1bb3bfc85e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_reads_entering_transition.expected.json @@ -0,0 +1,15 @@ +{ + "type": "state", + "events": [ + {"signal": "Level", "value": {"type": "Integer", "value": 7}} + ], + "outcomes": [ + {"finalState": "l1+r1", "outputs": {"seen": {"type": "Integer", "value": 7}, "log": {"type": "String", "value": "did l1(entry) r1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"seen": {"type": "Integer", "value": 7}, "log": {"type": "String", "value": "did r1(entry) l1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"seen": {"type": "Integer", "value": 7}, "log": {"type": "String", "value": "l1(entry) did r1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"seen": {"type": "Integer", "value": 7}, "log": {"type": "String", "value": "r1(entry) did l1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"seen": {"type": "Integer", "value": 7}, "log": {"type": "String", "value": "l1(entry) r1(entry) did "}}}, + {"finalState": "l1+r1", "outputs": {"seen": {"type": "Integer", "value": 7}, "log": {"type": "String", "value": "r1(entry) l1(entry) did "}}} + ], + "admissible": "A composite's own do step and its substates' entries due inside its entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_reads_entering_transition.sysml b/internal/exec/runtime/testdata/conformance/state_do_reads_entering_transition.sysml new file mode 100644 index 0000000000..966d325098 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_reads_entering_transition.sysml @@ -0,0 +1,29 @@ +// A state's do behavior reads the payload of the transition that entered it, +// `raise.l`, whichever draw the entry front makes: the do step may run before or +// after the substates' entries, and reads the same transition either way. +package StateDoReadsEnteringTransition { + private import ScalarValues::*; + + attribute def Level :> Integer; + + state Monitor { + attribute log : String = ""; + attribute seen : Integer = 0; + + entry; then idle; + state idle; + state work parallel { + do { in level : Integer = raise.l ?? 99; assign seen := level; assign log := log + "did "; } + state left { + entry; then l1; + state l1 { entry { assign log := log + "l1(entry) "; } } + } + state right { + entry; then r1; + state r1 { entry { assign log := log + "r1(entry) "; } } + } + } + + transition raise first idle accept l : Level then work; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.check.expected.json new file mode 100644 index 0000000000..ea81a2b7eb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"did m r \"", + "\"did r m \"", + "\"m did r \"", + "\"m r did \"", + "\"r did m \"", + "\"r m did \"" + ] + }, + "agreed": { + "finalState": "l1+m2+r2" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.declared.trace.golden new file mode 100644 index 0000000000..5e605da532 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.declared.trace.golden @@ -0,0 +1,20 @@ +do: l1 +stmt action body + stmt assign log + eval feature log -> "" + eval literal "did " -> "did " + eval operator + -> "did " +exit: m1 +stmt assign log + eval feature log -> "did " + eval literal "m " -> "m " + eval operator + -> "did m " +enter: m2 +transition: m1 -> m2 +exit: r1 +stmt assign log + eval feature log -> "did m " + eval literal "r " -> "r " + eval operator + -> "did m r " +enter: r2 +transition: r1 -> r2 diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.expected.json new file mode 100644 index 0000000000..707764ec0b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "trace": true, + "outcomes": [ + {"finalState": "l1+m2+r2", "outputs": {"log": {"type": "String", "value": "did m r "}}}, + {"finalState": "l1+m2+r2", "outputs": {"log": {"type": "String", "value": "m did r "}}}, + {"finalState": "l1+m2+r2", "outputs": {"log": {"type": "String", "value": "m r did "}}}, + {"finalState": "l1+m2+r2", "outputs": {"log": {"type": "String", "value": "did r m "}}}, + {"finalState": "l1+m2+r2", "outputs": {"log": {"type": "String", "value": "r did m "}}}, + {"finalState": "l1+m2+r2", "outputs": {"log": {"type": "String", "value": "r m did "}}} + ], + "admissible": "A do step and a dispatch due at one instant: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.seed-1.trace.golden new file mode 100644 index 0000000000..5e605da532 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.seed-1.trace.golden @@ -0,0 +1,20 @@ +do: l1 +stmt action body + stmt assign log + eval feature log -> "" + eval literal "did " -> "did " + eval operator + -> "did " +exit: m1 +stmt assign log + eval feature log -> "did " + eval literal "m " -> "m " + eval operator + -> "did m " +enter: m2 +transition: m1 -> m2 +exit: r1 +stmt assign log + eval feature log -> "did m " + eval literal "r " -> "r " + eval operator + -> "did m r " +enter: r2 +transition: r1 -> r2 diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.sysml new file mode 100644 index 0000000000..8016420921 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.sysml @@ -0,0 +1,36 @@ +// A region's do behavior and the completions of two sibling regions due at one +// instant: as work is entered, l1's do behavior is due and m1's and r1's +// completion events are in the pool, queued in the order the entry draw entered +// them. Under check and explore each instant both are due draws the do step against +// the completion at the head of the pool, so the do step falls before, between +// or after the two completion effects; the fixed policies run the do round to +// its end and dispatch after it. +package Test { + private import ScalarValues::*; + + state Machine { + attribute log : String = ""; + + entry; then work; + state work parallel { + state left { + entry; then l1; + state l1 { + do { assign log := log + "did "; } + } + } + state middle { + entry; then m1; + state m1; + transition first m1 do assign log := log + "m " then m2; + state m2; + } + state right { + entry; then r1; + state r1; + transition first r1 do assign log := log + "r " then r2; + state r2; + } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.trace.golden new file mode 100644 index 0000000000..5e605da532 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_among_completions.trace.golden @@ -0,0 +1,20 @@ +do: l1 +stmt action body + stmt assign log + eval feature log -> "" + eval literal "did " -> "did " + eval operator + -> "did " +exit: m1 +stmt assign log + eval feature log -> "did " + eval literal "m " -> "m " + eval operator + -> "did m " +enter: m2 +transition: m1 -> m2 +exit: r1 +stmt assign log + eval feature log -> "did m " + eval literal "r " -> "r " + eval operator + -> "did m r " +enter: r2 +transition: r1 -> r2 diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.check.expected.json new file mode 100644 index 0000000000..91eff76ca1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.check.expected.json @@ -0,0 +1,13 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"T1.2(effect) b(entry) did \"", + "\"T1.2(effect) did b(entry) \"", + "\"did T1.2(effect) b(entry) \"" + ] + }, + "agreed": { + "finalState": "a+b" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.declared.trace.golden new file mode 100644 index 0000000000..e67b88c8ea --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.declared.trace.golden @@ -0,0 +1,22 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +enter: work +enter: a +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1.2(effect) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "T1.2(effect) b(entry) " +transition: idle -> split +do: a +stmt action body + stmt assign log + eval feature log -> "T1.2(effect) b(entry) " + eval literal "did " -> "did " + eval operator + -> "T1.2(effect) b(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.expected.json new file mode 100644 index 0000000000..3b2fe15dae --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + {"finalState": "a+b", "outputs": {"log": {"type": "String", "value": "did T1.2(effect) b(entry) "}}}, + {"finalState": "a+b", "outputs": {"log": {"type": "String", "value": "T1.2(effect) did b(entry) "}}}, + {"finalState": "a+b", "outputs": {"log": {"type": "String", "value": "T1.2(effect) b(entry) did "}}} + ], + "admissible": "A do step and a sibling region's entry due inside one entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.seed-1.trace.golden new file mode 100644 index 0000000000..34f1ada351 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.seed-1.trace.golden @@ -0,0 +1,23 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->b(effect) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1.2(effect) " +enter: work +choice fork split: next split->a(effect), b(entry) (unordered; took split->a(effect) first) +enter: a +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "T1.2(effect) b(entry) " +transition: idle -> split +do: a +stmt action body + stmt assign log + eval feature log -> "T1.2(effect) b(entry) " + eval literal "did " -> "did " + eval operator + -> "T1.2(effect) b(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.sysml new file mode 100644 index 0000000000..8efa4a9b5c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.sysml @@ -0,0 +1,29 @@ +// A fork's branches enter two regions: the do behavior of the target one +// branch reaches is drawn against the other branch's effect and target entry +// while either is left, as a region's do step is drawn against a sibling's. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + state a { + do { assign log := log + "did "; } + } + } + state right { + state b { + entry { assign log := log + "b(entry) "; } + } + } + } + fork split; + + transition first idle accept Go then split; + transition first split then a; + transition first split do { assign log := log + "T1.2(effect) "; } then b; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.trace.golden new file mode 100644 index 0000000000..e67b88c8ea --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_fork_branch.trace.golden @@ -0,0 +1,22 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +enter: work +enter: a +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1.2(effect) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "T1.2(effect) b(entry) " +transition: idle -> split +do: a +stmt action body + stmt assign log + eval feature log -> "T1.2(effect) b(entry) " + eval literal "did " -> "did " + eval operator + -> "T1.2(effect) b(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.check.expected.json new file mode 100644 index 0000000000..173a068a69 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.check.expected.json @@ -0,0 +1,22 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"did r2(entry) l2(exit) r2(exit) did r2(entry) \"", + "\"did r2(entry) l2(exit) r2(exit) r2(entry) did \"", + "\"did r2(entry) r2(exit) l2(exit) did r2(entry) \"", + "\"did r2(entry) r2(exit) l2(exit) r2(entry) did \"", + "\"r2(entry) did l2(exit) r2(exit) did r2(entry) \"", + "\"r2(entry) did l2(exit) r2(exit) r2(entry) did \"", + "\"r2(entry) did r2(exit) l2(exit) did r2(entry) \"", + "\"r2(entry) did r2(exit) l2(exit) r2(entry) did \"", + "\"r2(entry) l2(exit) r2(exit) did r2(entry) \"", + "\"r2(entry) l2(exit) r2(exit) r2(entry) did \"", + "\"r2(entry) r2(exit) l2(exit) did r2(entry) \"", + "\"r2(entry) r2(exit) l2(exit) r2(entry) did \"" + ] + }, + "agreed": { + "finalState": "l2+r2" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.declared.trace.golden new file mode 100644 index 0000000000..fdb1ebf5f6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.declared.trace.golden @@ -0,0 +1,51 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: l2 +transition: l1 -> l2 (event: accept Go) +exit: r1 +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "r2(entry) " +transition: r1 -> r2 (event: accept Go) +do: l2 +stmt action body + stmt assign log + eval feature log -> "r2(entry) " + eval literal "did " -> "did " + eval operator + -> "r2(entry) did " +choice exiting work: next l2(exit), r2(exit) (unordered; took l2(exit) first) +exit: l2 (exit action) +stmt action body + stmt assign log + eval feature log -> "r2(entry) did " + eval literal "l2(exit) " -> "l2(exit) " + eval operator + -> "r2(entry) did l2(exit) " +exit: r2 (exit action) +stmt action body + stmt assign log + eval feature log -> "r2(entry) did l2(exit) " + eval literal "r2(exit) " -> "r2(exit) " + eval operator + -> "r2(entry) did l2(exit) r2(exit) " +exit: work +enter: paused +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next l2(entry), r2(entry) (unordered; took l2(entry) first) +enter: l2 +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "r2(entry) did l2(exit) r2(exit) " + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "r2(entry) did l2(exit) r2(exit) r2(entry) " +transition: paused -> work (event: accept Resume) +do: l2 +stmt action body + stmt assign log + eval feature log -> "r2(entry) did l2(exit) r2(exit) r2(entry) " + eval literal "did " -> "did " + eval operator + -> "r2(entry) did l2(exit) r2(exit) r2(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.expected.json new file mode 100644 index 0000000000..6c0f8fa772 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.expected.json @@ -0,0 +1,126 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + }, + { + "signal": "Pause" + }, + { + "signal": "Resume" + } + ], + "outcomes": [ + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "did r2(entry) l2(exit) r2(exit) did r2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "did r2(entry) l2(exit) r2(exit) r2(entry) did " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "did r2(entry) r2(exit) l2(exit) did r2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "did r2(entry) r2(exit) l2(exit) r2(entry) did " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) did l2(exit) r2(exit) did r2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) did l2(exit) r2(exit) r2(entry) did " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) did r2(exit) l2(exit) did r2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) did r2(exit) l2(exit) r2(entry) did " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) l2(exit) r2(exit) did r2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) l2(exit) r2(exit) r2(entry) did " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) r2(exit) l2(exit) did r2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) r2(exit) l2(exit) r2(entry) did " + } + } + } + ], + "admissible": "A do step and a sibling region's entry due inside one entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.seed-1.trace.golden new file mode 100644 index 0000000000..fdb1ebf5f6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.seed-1.trace.golden @@ -0,0 +1,51 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: l2 +transition: l1 -> l2 (event: accept Go) +exit: r1 +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "r2(entry) " +transition: r1 -> r2 (event: accept Go) +do: l2 +stmt action body + stmt assign log + eval feature log -> "r2(entry) " + eval literal "did " -> "did " + eval operator + -> "r2(entry) did " +choice exiting work: next l2(exit), r2(exit) (unordered; took l2(exit) first) +exit: l2 (exit action) +stmt action body + stmt assign log + eval feature log -> "r2(entry) did " + eval literal "l2(exit) " -> "l2(exit) " + eval operator + -> "r2(entry) did l2(exit) " +exit: r2 (exit action) +stmt action body + stmt assign log + eval feature log -> "r2(entry) did l2(exit) " + eval literal "r2(exit) " -> "r2(exit) " + eval operator + -> "r2(entry) did l2(exit) r2(exit) " +exit: work +enter: paused +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next l2(entry), r2(entry) (unordered; took l2(entry) first) +enter: l2 +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "r2(entry) did l2(exit) r2(exit) " + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "r2(entry) did l2(exit) r2(exit) r2(entry) " +transition: paused -> work (event: accept Resume) +do: l2 +stmt action body + stmt assign log + eval feature log -> "r2(entry) did l2(exit) r2(exit) r2(entry) " + eval literal "did " -> "did " + eval operator + -> "r2(entry) did l2(exit) r2(exit) r2(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.sysml new file mode 100644 index 0000000000..a7a7b7c7ae --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.sysml @@ -0,0 +1,40 @@ +// A deep history restores two regions as one front: the do behavior of the +// state one region is restored into starts as that state is re-entered, and its +// step is drawn against the other region's restored entry as it was against the +// other region's firing on the first occurrence, where the Pause already in the +// pool may also cut it off before it moves. +package Test { + state Machine { + attribute log : String = ""; + + entry; then work; + state work parallel { + deep history resume; + state left { + entry; then l1; + state l1; + state l2 { + do { assign log := log + "did "; } + exit { assign log := log + "l2(exit) "; } + } + transition first l1 accept Go then l2; + } + state right { + entry; then r1; + state r1; + state r2 { + entry { assign log := log + "r2(entry) "; } + exit { assign log := log + "r2(exit) "; } + } + transition first r1 accept Go then r2; + } + } + state paused; + + transition first work accept Pause then paused; + transition first paused accept Resume then resume; + } + attribute def Go; + attribute def Pause; + attribute def Resume; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.trace.golden new file mode 100644 index 0000000000..fdb1ebf5f6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_history_restore.trace.golden @@ -0,0 +1,51 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: l2 +transition: l1 -> l2 (event: accept Go) +exit: r1 +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "r2(entry) " +transition: r1 -> r2 (event: accept Go) +do: l2 +stmt action body + stmt assign log + eval feature log -> "r2(entry) " + eval literal "did " -> "did " + eval operator + -> "r2(entry) did " +choice exiting work: next l2(exit), r2(exit) (unordered; took l2(exit) first) +exit: l2 (exit action) +stmt action body + stmt assign log + eval feature log -> "r2(entry) did " + eval literal "l2(exit) " -> "l2(exit) " + eval operator + -> "r2(entry) did l2(exit) " +exit: r2 (exit action) +stmt action body + stmt assign log + eval feature log -> "r2(entry) did l2(exit) " + eval literal "r2(exit) " -> "r2(exit) " + eval operator + -> "r2(entry) did l2(exit) r2(exit) " +exit: work +enter: paused +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next l2(entry), r2(entry) (unordered; took l2(entry) first) +enter: l2 +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "r2(entry) did l2(exit) r2(exit) " + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "r2(entry) did l2(exit) r2(exit) r2(entry) " +transition: paused -> work (event: accept Resume) +do: l2 +stmt action body + stmt assign log + eval feature log -> "r2(entry) did l2(exit) r2(exit) r2(entry) " + eval literal "did " -> "did " + eval operator + -> "r2(entry) did l2(exit) r2(exit) r2(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.check.expected.json new file mode 100644 index 0000000000..7aff2ba9d2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"a1(entry) b1(entry) did \"", + "\"a1(entry) did b1(entry) \"", + "\"b1(entry) a1(entry) did \"", + "\"b1(entry) did a1(entry) \"", + "\"did a1(entry) b1(entry) \"", + "\"did b1(entry) a1(entry) \"" + ] + }, + "agreed": { + "finalState": "l1+a1+b1+r1" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.declared.trace.golden new file mode 100644 index 0000000000..925c39fb1c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.declared.trace.golden @@ -0,0 +1,25 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 +choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first) +enter: a1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a1(entry) " -> "a1(entry) " + eval operator + -> "a1(entry) " +enter: b1 (entry action) +stmt action body + stmt assign log + eval feature log -> "a1(entry) " + eval literal "b1(entry) " -> "b1(entry) " + eval operator + -> "a1(entry) b1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "a1(entry) b1(entry) " + eval literal "did " -> "did " + eval operator + -> "a1(entry) b1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.expected.json new file mode 100644 index 0000000000..b57aafccdc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.expected.json @@ -0,0 +1,16 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "did a1(entry) b1(entry) "}}}, + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "did b1(entry) a1(entry) "}}}, + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "a1(entry) did b1(entry) "}}}, + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "b1(entry) did a1(entry) "}}}, + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "a1(entry) b1(entry) did "}}}, + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "b1(entry) a1(entry) did "}}} + ], + "admissible": "A do step and a sibling region's entry due inside one entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.seed-1.trace.golden new file mode 100644 index 0000000000..0b21039b75 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.seed-1.trace.golden @@ -0,0 +1,26 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took r1(entry) first) +enter: r1 +choice entering work: next l1(entry), a1(entry), b1(entry) (unordered; took l1(entry) first) +enter: l1 +choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first) +enter: a1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a1(entry) " -> "a1(entry) " + eval operator + -> "a1(entry) " +enter: b1 (entry action) +stmt action body + stmt assign log + eval feature log -> "a1(entry) " + eval literal "b1(entry) " -> "b1(entry) " + eval operator + -> "a1(entry) b1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "a1(entry) b1(entry) " + eval literal "did " -> "did " + eval operator + -> "a1(entry) b1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.sysml new file mode 100644 index 0000000000..0de3e2dcc8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.sysml @@ -0,0 +1,38 @@ +// A do step against a sibling region whose start state is itself parallel: the +// nested regions' entries join the front the do step is drawn from, so the step +// falls before, between or after them in either of their orders. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + entry; then l1; + state l1 { + do { assign log := log + "did "; } + } + } + state right { + entry; then r1; + state r1 parallel { + state ra { + entry; then a1; + state a1 { + entry { assign log := log + "a1(entry) "; } + } + } + state rb { + entry; then b1; + state b1 { + entry { assign log := log + "b1(entry) "; } + } + } + } + } + } + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.trace.golden new file mode 100644 index 0000000000..925c39fb1c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_nested_entries.trace.golden @@ -0,0 +1,25 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 +choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first) +enter: a1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a1(entry) " -> "a1(entry) " + eval operator + -> "a1(entry) " +enter: b1 (entry action) +stmt action body + stmt assign log + eval feature log -> "a1(entry) " + eval literal "b1(entry) " -> "b1(entry) " + eval operator + -> "a1(entry) b1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "a1(entry) b1(entry) " + eval literal "did " -> "did " + eval operator + -> "a1(entry) b1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.check.expected.json new file mode 100644 index 0000000000..ba0751394f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.check.expected.json @@ -0,0 +1,13 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"did w1(entry) w2(entry) \"", + "\"w1(entry) did w2(entry) \"", + "\"w1(entry) w2(entry) did \"" + ] + }, + "agreed": { + "finalState": "w2" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.declared.trace.golden new file mode 100644 index 0000000000..ade69cfc9e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.declared.trace.golden @@ -0,0 +1,21 @@ +exit: idle +enter: work +enter: w1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "w1(entry) " -> "w1(entry) " + eval operator + -> "w1(entry) " +enter: w2 (entry action) +stmt action body + stmt assign log + eval feature log -> "w1(entry) " + eval literal "w2(entry) " -> "w2(entry) " + eval operator + -> "w1(entry) w2(entry) " +transition: idle -> work (event: accept Go) +do: work +stmt action body + stmt assign log + eval feature log -> "w1(entry) w2(entry) " + eval literal "did " -> "did " + eval operator + -> "w1(entry) w2(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.expected.json new file mode 100644 index 0000000000..7d7181a679 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + {"finalState": "w2", "outputs": {"log": {"type": "String", "value": "did w1(entry) w2(entry) "}}}, + {"finalState": "w2", "outputs": {"log": {"type": "String", "value": "w1(entry) did w2(entry) "}}}, + {"finalState": "w2", "outputs": {"log": {"type": "String", "value": "w1(entry) w2(entry) did "}}} + ], + "admissible": "A composite's own do step and its substates' entries due inside its entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.seed-1.trace.golden new file mode 100644 index 0000000000..ade69cfc9e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.seed-1.trace.golden @@ -0,0 +1,21 @@ +exit: idle +enter: work +enter: w1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "w1(entry) " -> "w1(entry) " + eval operator + -> "w1(entry) " +enter: w2 (entry action) +stmt action body + stmt assign log + eval feature log -> "w1(entry) " + eval literal "w2(entry) " -> "w2(entry) " + eval operator + -> "w1(entry) w2(entry) " +transition: idle -> work (event: accept Go) +do: work +stmt action body + stmt assign log + eval feature log -> "w1(entry) w2(entry) " + eval literal "did " -> "did " + eval operator + -> "w1(entry) w2(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.sysml new file mode 100644 index 0000000000..a2e07c0e53 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.sysml @@ -0,0 +1,24 @@ +// A composite's own do behavior against the entries of its serial body, which no +// front orders: the way down from the composite to its start state's own start +// is drawn against the step, each entry a unit, so the step falls before, +// between or after the two entries. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work { + do { assign log := log + "did "; } + entry; then w1; + state w1 { + entry action { assign log := log + "w1(entry) "; } then w2; + state w2 { + entry { assign log := log + "w2(entry) "; } + } + } + } + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.trace.golden new file mode 100644 index 0000000000..ade69cfc9e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_body_entry.trace.golden @@ -0,0 +1,21 @@ +exit: idle +enter: work +enter: w1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "w1(entry) " -> "w1(entry) " + eval operator + -> "w1(entry) " +enter: w2 (entry action) +stmt action body + stmt assign log + eval feature log -> "w1(entry) " + eval literal "w2(entry) " -> "w2(entry) " + eval operator + -> "w1(entry) w2(entry) " +transition: idle -> work (event: accept Go) +do: work +stmt action body + stmt assign log + eval feature log -> "w1(entry) w2(entry) " + eval literal "did " -> "did " + eval operator + -> "w1(entry) w2(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.check.expected.json new file mode 100644 index 0000000000..619682ac58 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"did l1(entry) r1(entry) \"", + "\"did r1(entry) l1(entry) \"", + "\"l1(entry) did r1(entry) \"", + "\"l1(entry) r1(entry) did \"", + "\"r1(entry) did l1(entry) \"", + "\"r1(entry) l1(entry) did \"" + ] + }, + "agreed": { + "finalState": "l1+r1" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.declared.trace.golden new file mode 100644 index 0000000000..e4b138b84d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.declared.trace.golden @@ -0,0 +1,22 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l1(entry) " -> "l1(entry) " + eval operator + -> "l1(entry) " +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "l1(entry) " + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "l1(entry) r1(entry) " +transition: idle -> work (event: accept Go) +do: work +stmt action body + stmt assign log + eval feature log -> "l1(entry) r1(entry) " + eval literal "did " -> "did " + eval operator + -> "l1(entry) r1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.expected.json new file mode 100644 index 0000000000..8041fdd43f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.expected.json @@ -0,0 +1,16 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "did l1(entry) r1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "did r1(entry) l1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "l1(entry) did r1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "r1(entry) did l1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "l1(entry) r1(entry) did "}}}, + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "r1(entry) l1(entry) did "}}} + ], + "admissible": "A composite's own do step and its substates' entries due inside its entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.seed-1.trace.golden new file mode 100644 index 0000000000..b3228aa717 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.seed-1.trace.golden @@ -0,0 +1,22 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took r1(entry) first) +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "r1(entry) " +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "r1(entry) " + eval literal "l1(entry) " -> "l1(entry) " + eval operator + -> "r1(entry) l1(entry) " +transition: idle -> work (event: accept Go) +do: work +stmt action body + stmt assign log + eval feature log -> "r1(entry) l1(entry) " + eval literal "did " -> "did " + eval operator + -> "r1(entry) l1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.sysml new file mode 100644 index 0000000000..a36326e535 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.sysml @@ -0,0 +1,28 @@ +// A composite's own do behavior begins at its entry: its step is drawn against +// the entries of its own orthogonal substates on the front entering them, so it +// falls before, between or after them in either of their orders. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + do { assign log := log + "did "; } + state left { + entry; then l1; + state l1 { + entry { assign log := log + "l1(entry) "; } + } + } + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry) "; } + } + } + } + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.trace.golden new file mode 100644 index 0000000000..e4b138b84d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_own_substate_entries.trace.golden @@ -0,0 +1,22 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l1(entry) " -> "l1(entry) " + eval operator + -> "l1(entry) " +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "l1(entry) " + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "l1(entry) r1(entry) " +transition: idle -> work (event: accept Go) +do: work +stmt action body + stmt assign log + eval feature log -> "l1(entry) r1(entry) " + eval literal "did " -> "did " + eval operator + -> "l1(entry) r1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.check.expected.json new file mode 100644 index 0000000000..9ba27f6c72 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"did m1(entry) r1(entry) \"", + "\"did r1(entry) m1(entry) \"", + "\"m1(entry) did r1(entry) \"", + "\"m1(entry) r1(entry) did \"", + "\"r1(entry) did m1(entry) \"", + "\"r1(entry) m1(entry) did \"" + ] + }, + "agreed": { + "finalState": "l1+m1+r1" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.declared.trace.golden new file mode 100644 index 0000000000..ab7e2694f5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.declared.trace.golden @@ -0,0 +1,24 @@ +exit: idle +enter: work +choice entering work: next l1(entry), m1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +choice entering work: next m1(entry), r1(entry) (unordered; took m1(entry) first) +enter: m1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "m1(entry) " -> "m1(entry) " + eval operator + -> "m1(entry) " +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "m1(entry) " + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "m1(entry) r1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "m1(entry) r1(entry) " + eval literal "did " -> "did " + eval operator + -> "m1(entry) r1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.expected.json new file mode 100644 index 0000000000..2ff241e4fa --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.expected.json @@ -0,0 +1,16 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + {"finalState": "l1+m1+r1", "outputs": {"log": {"type": "String", "value": "did m1(entry) r1(entry) "}}}, + {"finalState": "l1+m1+r1", "outputs": {"log": {"type": "String", "value": "did r1(entry) m1(entry) "}}}, + {"finalState": "l1+m1+r1", "outputs": {"log": {"type": "String", "value": "m1(entry) did r1(entry) "}}}, + {"finalState": "l1+m1+r1", "outputs": {"log": {"type": "String", "value": "r1(entry) did m1(entry) "}}}, + {"finalState": "l1+m1+r1", "outputs": {"log": {"type": "String", "value": "m1(entry) r1(entry) did "}}}, + {"finalState": "l1+m1+r1", "outputs": {"log": {"type": "String", "value": "r1(entry) m1(entry) did "}}} + ], + "admissible": "A do step and a sibling region's entry due inside one entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.seed-1.trace.golden new file mode 100644 index 0000000000..b028d796e3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.seed-1.trace.golden @@ -0,0 +1,24 @@ +exit: idle +enter: work +choice entering work: next l1(entry), m1(entry), r1(entry) (unordered; took m1(entry) first) +enter: m1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "m1(entry) " -> "m1(entry) " + eval operator + -> "m1(entry) " +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "m1(entry) " + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "m1(entry) r1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "m1(entry) r1(entry) " + eval literal "did " -> "did " + eval operator + -> "m1(entry) r1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.sysml new file mode 100644 index 0000000000..cd8d986d69 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.sysml @@ -0,0 +1,34 @@ +// A do step against several remaining sibling entries: the do behavior of the +// first region's state is drawn against each of the two other regions' entries +// while one is left, so it falls before, between or after them in either of +// their orders. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + entry; then l1; + state l1 { + do { assign log := log + "did "; } + } + } + state middle { + entry; then m1; + state m1 { + entry { assign log := log + "m1(entry) "; } + } + } + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry) "; } + } + } + } + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.trace.golden new file mode 100644 index 0000000000..ab7e2694f5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entries.trace.golden @@ -0,0 +1,24 @@ +exit: idle +enter: work +choice entering work: next l1(entry), m1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +choice entering work: next m1(entry), r1(entry) (unordered; took m1(entry) first) +enter: m1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "m1(entry) " -> "m1(entry) " + eval operator + -> "m1(entry) " +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "m1(entry) " + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "m1(entry) r1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "m1(entry) r1(entry) " + eval literal "did " -> "did " + eval operator + -> "m1(entry) r1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.check.expected.json new file mode 100644 index 0000000000..b9a9833057 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.check.expected.json @@ -0,0 +1,12 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"did r1(entry) \"", + "\"r1(entry) did \"" + ] + }, + "agreed": { + "finalState": "l1+r1" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.declared.trace.golden new file mode 100644 index 0000000000..7bd2d19af0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.declared.trace.golden @@ -0,0 +1,17 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "r1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "r1(entry) " + eval literal "did " -> "did " + eval operator + -> "r1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.expected.json new file mode 100644 index 0000000000..987b876065 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.expected.json @@ -0,0 +1,22 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + { + "finalState": "l1+r1", + "outputs": { + "log": {"type": "String", "value": "did r1(entry) "} + } + }, + { + "finalState": "l1+r1", + "outputs": { + "log": {"type": "String", "value": "r1(entry) did "} + } + } + ], + "admissible": "A do step and a sibling region's entry due inside one entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.seed-1.trace.golden new file mode 100644 index 0000000000..e287faa4e3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.seed-1.trace.golden @@ -0,0 +1,17 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took r1(entry) first) +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "r1(entry) " +enter: l1 +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "r1(entry) " + eval literal "did " -> "did " + eval operator + -> "r1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.sysml new file mode 100644 index 0000000000..a4f32ac686 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.sysml @@ -0,0 +1,28 @@ +// A state's do behavior starts as the state is entered, and while a sibling +// region of the same entry still has an entry unit left, whether the do +// behavior's next move or that entry goes first is open: the do step is drawn +// against the sibling's entry as it is drawn against a dispatch. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + entry; then l1; + state l1 { + do { assign log := log + "did "; } + } + } + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry) "; } + } + } + } + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.trace.golden new file mode 100644 index 0000000000..7bd2d19af0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_before_sibling_entry.trace.golden @@ -0,0 +1,17 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "r1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "r1(entry) " + eval literal "did " -> "did " + eval operator + -> "r1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.check.expected.json new file mode 100644 index 0000000000..7b76fa0b2d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"a b r1(entry) \"", + "\"a r1(entry) \"", + "\"a r1(entry) b \"", + "\"r1(entry) \"", + "\"r1(entry) a \"", + "\"r1(entry) a b \"" + ] + }, + "agreed": { + "finalState": "stopped" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.declared.trace.golden new file mode 100644 index 0000000000..1a13ddbe7e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.declared.trace.golden @@ -0,0 +1,23 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "r1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "r1(entry) " + eval literal "a " -> "a " + eval operator + -> "r1(entry) a " +choice exiting work: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +exit: r1 +exit: work +enter: stopped +transition: r1 -> stopped diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.expected.json new file mode 100644 index 0000000000..b7a9166188 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.expected.json @@ -0,0 +1,16 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + {"finalState": "stopped", "outputs": {"log": {"type": "String", "value": "a b r1(entry) "}}}, + {"finalState": "stopped", "outputs": {"log": {"type": "String", "value": "a r1(entry) b "}}}, + {"finalState": "stopped", "outputs": {"log": {"type": "String", "value": "a r1(entry) "}}}, + {"finalState": "stopped", "outputs": {"log": {"type": "String", "value": "r1(entry) a b "}}}, + {"finalState": "stopped", "outputs": {"log": {"type": "String", "value": "r1(entry) a "}}}, + {"finalState": "stopped", "outputs": {"log": {"type": "String", "value": "r1(entry) "}}} + ], + "admissible": "A do step and a sibling region's entry due inside one entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.seed-1.trace.golden new file mode 100644 index 0000000000..89892e5e69 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.seed-1.trace.golden @@ -0,0 +1,23 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took r1(entry) first) +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "r1(entry) " +enter: l1 +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "r1(entry) " + eval literal "a " -> "a " + eval operator + -> "r1(entry) a " +choice exiting work: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +exit: r1 +exit: work +enter: stopped +transition: r1 -> stopped diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.sysml new file mode 100644 index 0000000000..87bfa56ae6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.sysml @@ -0,0 +1,30 @@ +// The sibling region's state completes into a transition that leaves the +// parallel state: the do behavior's two steps are drawn against the sibling's +// entry while it is left, then against the completion's dispatch, which exits +// the do behavior's state and cuts it off wherever it stands. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + entry; then l1; + state l1 { + do { assign log := log + "a "; assign log := log + "b "; } + } + } + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry) "; } + } + transition first r1 then stopped; + } + } + state stopped; + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.trace.golden new file mode 100644 index 0000000000..1a13ddbe7e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_completion.trace.golden @@ -0,0 +1,23 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "r1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt assign log + eval feature log -> "r1(entry) " + eval literal "a " -> "a " + eval operator + -> "r1(entry) a " +choice exiting work: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +exit: r1 +exit: work +enter: stopped +transition: r1 -> stopped diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.check.expected.json new file mode 100644 index 0000000000..32479dbc41 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.check.expected.json @@ -0,0 +1,13 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"work(entry) l1(entry) l1(partI) r1(entry) \"", + "\"work(entry) l1(entry) r1(entry) \"", + "\"work(entry) l1(entry) r1(entry) l1(partI) \"", + "\"work(entry) r1(entry) l1(entry) \"", + "\"work(entry) r1(entry) l1(entry) l1(partI) \"" + ] + }, + "agreed": {} +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.declared.trace.golden new file mode 100644 index 0000000000..546eafe804 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.declared.trace.golden @@ -0,0 +1,31 @@ +exit: idle +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "work(entry) " +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "work(entry) " + eval literal "l1(entry) " -> "l1(entry) " + eval operator + -> "work(entry) l1(entry) " +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "work(entry) l1(entry) " + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "work(entry) l1(entry) r1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body +enter action node: state behavior + stmt assign log + eval feature log -> "work(entry) l1(entry) r1(entry) " + eval literal "l1(partI) " -> "l1(partI) " + eval operator + -> "work(entry) l1(entry) r1(entry) l1(partI) " +exit: r1 +transition: r1 -> stop +terminate: stop (do behavior abandoned: l1) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.expected.json new file mode 100644 index 0000000000..c996eff5bf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.expected.json @@ -0,0 +1,15 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + {"terminated": true, "outputs": {"log": {"type": "String", "value": "work(entry) l1(entry) l1(partI) r1(entry) "}}}, + {"terminated": true, "outputs": {"log": {"type": "String", "value": "work(entry) l1(entry) r1(entry) l1(partI) "}}}, + {"terminated": true, "outputs": {"log": {"type": "String", "value": "work(entry) l1(entry) r1(entry) "}}}, + {"terminated": true, "outputs": {"log": {"type": "String", "value": "work(entry) r1(entry) l1(entry) l1(partI) "}}}, + {"terminated": true, "outputs": {"log": {"type": "String", "value": "work(entry) r1(entry) l1(entry) "}}} + ], + "admissible": "A do step and a sibling region's entry due inside one entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.seed-1.trace.golden new file mode 100644 index 0000000000..2af29353b9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.seed-1.trace.golden @@ -0,0 +1,31 @@ +exit: idle +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "work(entry) " +choice entering work: next l1(entry), r1(entry) (unordered; took r1(entry) first) +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "work(entry) " + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "work(entry) r1(entry) " +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "work(entry) r1(entry) " + eval literal "l1(entry) " -> "l1(entry) " + eval operator + -> "work(entry) r1(entry) l1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body +enter action node: state behavior + stmt assign log + eval feature log -> "work(entry) r1(entry) l1(entry) " + eval literal "l1(partI) " -> "l1(partI) " + eval operator + -> "work(entry) r1(entry) l1(entry) l1(partI) " +exit: r1 +transition: r1 -> stop +terminate: stop (do behavior abandoned: l1) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.sysml new file mode 100644 index 0000000000..7bdf5bd1c7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.sysml @@ -0,0 +1,40 @@ +// PSSM Terminate 002's shape: two regions, the first's state with a do activity +// of two segments around an accept, the second's state completing into a +// terminate. The first segment is drawn against the sibling's entry and then +// against the completion's dispatch; the terminate ends the machine before the +// accept is fed, so the second segment never runs. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + entry { assign log := log + "work(entry) "; } + state left { + entry; then l1; + state l1 { + entry { assign log := log + "l1(entry) "; } + do action { + first start; + then action partI assign log := log + "l1(partI) "; + then action wait accept Continue; + then action partII assign log := log + "l1(partII) "; + then done; + } + } + } + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry) "; } + } + action stop terminate; + transition first r1 then stop; + } + } + transition first idle accept Go then work; + } + attribute def Go; + attribute def Continue; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.trace.golden new file mode 100644 index 0000000000..546eafe804 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cut_by_sibling_terminate.trace.golden @@ -0,0 +1,31 @@ +exit: idle +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "work(entry) " +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "work(entry) " + eval literal "l1(entry) " -> "l1(entry) " + eval operator + -> "work(entry) l1(entry) " +enter: r1 (entry action) +stmt action body + stmt assign log + eval feature log -> "work(entry) l1(entry) " + eval literal "r1(entry) " -> "r1(entry) " + eval operator + -> "work(entry) l1(entry) r1(entry) " +transition: idle -> work (event: accept Go) +do: l1 +stmt action body +enter action node: state behavior + stmt assign log + eval feature log -> "work(entry) l1(entry) r1(entry) " + eval literal "l1(partI) " -> "l1(partI) " + eval operator + -> "work(entry) l1(entry) r1(entry) l1(partI) " +exit: r1 +transition: r1 -> stop +terminate: stop (do behavior abandoned: l1) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.check.expected.json new file mode 100644 index 0000000000..1ba6af7b31 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"count": ["1000", "1001", "1011", "1101", "1111"]}, + "agreed": {"finalState": "idle"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.declared.trace.golden new file mode 100644 index 0000000000..7ac14c0e42 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.declared.trace.golden @@ -0,0 +1,14 @@ +do: top +stmt action body +enter action node: state behavior run + stmt assign count + eval feature count -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: top +stmt assign count + eval feature count -> 1 + eval literal 1000 -> 1000 + eval operator + -> 1001 +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.expected.json new file mode 100644 index 0000000000..2010616217 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Stop" + } + ], + "outcomes": [ + {"finalState": "idle", "outputs": {"count": {"type": "Integer", "value": 1000}}}, + {"finalState": "idle", "outputs": {"count": {"type": "Integer", "value": 1001}}}, + {"finalState": "idle", "outputs": {"count": {"type": "Integer", "value": 1011}}}, + {"finalState": "idle", "outputs": {"count": {"type": "Integer", "value": 1101}}}, + {"finalState": "idle", "outputs": {"count": {"type": "Integer", "value": 1111}}} + ], + "admissible": "A do step and a dispatch due at one instant: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.seed-1.trace.golden new file mode 100644 index 0000000000..7ac14c0e42 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.seed-1.trace.golden @@ -0,0 +1,14 @@ +do: top +stmt action body +enter action node: state behavior run + stmt assign count + eval feature count -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: top +stmt assign count + eval feature count -> 1 + eval literal 1000 -> 1000 + eval operator + -> 1001 +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.sysml new file mode 100644 index 0000000000..46041ceec7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.sysml @@ -0,0 +1,27 @@ +package Test { + private import ScalarValues::*; + + attribute def Stop; + + // A do behavior's flow routes through a fork with a body of its own. A control + // node's body is a step the token passing through performs, so with `Stop` in + // the pool as `top` is entered the dispatch may cut the flow before the fork, + // after its body, after either branch, or after both. + state Machine { + attribute count : Integer = 0; + + entry; then top; + state top { + do action run { + first start; + then fork split { assign count := count + 1; } + succession first split then a; + succession first split then b; + action a assign count := count + 10; + action b assign count := count + 100; + } + } + transition first top accept Stop do assign count := count + 1000 then idle; + state idle; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.trace.golden new file mode 100644 index 0000000000..7ac14c0e42 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_control_node_body.trace.golden @@ -0,0 +1,14 @@ +do: top +stmt action body +enter action node: state behavior run + stmt assign count + eval feature count -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: top +stmt assign count + eval feature count -> 1 + eval literal 1000 -> 1000 + eval operator + -> 1001 +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.check.expected.json new file mode 100644 index 0000000000..8bbe52e2e5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"count": ["1000", "1001", "1012", "1112"]}, + "agreed": {"finalState": "idle"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.declared.trace.golden new file mode 100644 index 0000000000..7594e724a1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.declared.trace.golden @@ -0,0 +1,12 @@ +do: top +stmt action body +enter action node: state behavior run + stmt assign count + eval literal 1 -> 1 +exit: top +stmt assign count + eval feature count -> 1 + eval literal 1000 -> 1000 + eval operator + -> 1001 +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.expected.json new file mode 100644 index 0000000000..77affcc192 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.expected.json @@ -0,0 +1,48 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Stop" + } + ], + "outcomes": [ + { + "finalState": "idle", + "outputs": { + "count": { + "type": "Integer", + "value": 1000 + } + } + }, + { + "finalState": "idle", + "outputs": { + "count": { + "type": "Integer", + "value": 1001 + } + } + }, + { + "finalState": "idle", + "outputs": { + "count": { + "type": "Integer", + "value": 1012 + } + } + }, + { + "finalState": "idle", + "outputs": { + "count": { + "type": "Integer", + "value": 1112 + } + } + } + ], + "admissible": "A do step and a dispatch due at one instant: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.seed-1.trace.golden new file mode 100644 index 0000000000..7594e724a1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.seed-1.trace.golden @@ -0,0 +1,12 @@ +do: top +stmt action body +enter action node: state behavior run + stmt assign count + eval literal 1 -> 1 +exit: top +stmt assign count + eval feature count -> 1 + eval literal 1000 -> 1000 + eval operator + -> 1001 +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.sysml new file mode 100644 index 0000000000..786fe20a7a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.sysml @@ -0,0 +1,35 @@ +package Test { + private import ScalarValues::*; + + attribute def Stop; + + // A do behavior's flow performs a typed action, `w : Work`, whose own flow is + // two steps. With `Stop` in the pool as `top` is entered, the dispatch may cut + // the do behavior before `mark`, between `mark` and the performance, at any + // move inside `Work` (whose `inout` writes back only when it ends, so `count` + // still reads 1), after it, or after `tail`. + state Machine { + attribute count : Integer = 0; + + action def Work { + inout n : Integer; + first start; + then action a assign n := n + 1; + then action b assign n := n + 10; + then done; + } + + entry; then top; + state top { + do action run { + first start; + then action mark assign count := 1; + then perform action w : Work { inout n = count; } + then action tail assign count := count + 100; + then done; + } + } + transition first top accept Stop do assign count := count + 1000 then idle; + state idle; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.trace.golden new file mode 100644 index 0000000000..7594e724a1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_nested_perform.trace.golden @@ -0,0 +1,12 @@ +do: top +stmt action body +enter action node: state behavior run + stmt assign count + eval literal 1 -> 1 +exit: top +stmt assign count + eval feature count -> 1 + eval literal 1000 -> 1000 + eval operator + -> 1001 +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.check.expected.json new file mode 100644 index 0000000000..7d9e73f972 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"count": ["100", "111"]}, + "agreed": {"finalState": "idle"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.declared.trace.golden new file mode 100644 index 0000000000..df255c2305 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.declared.trace.golden @@ -0,0 +1,23 @@ +do: top +stmt action body + stmt node work + eval feature count -> 0 +step 1: token 1@a + stmt assign n + eval feature n -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +step 2: token 1@b + stmt assign n + eval feature n -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +step 3: token 1@done +step 4: no active tokens +exit: top +stmt assign count + eval feature count -> 11 + eval literal 100 -> 100 + eval operator + -> 111 +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.expected.json new file mode 100644 index 0000000000..a3885c1d93 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.expected.json @@ -0,0 +1,30 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Stop" + } + ], + "outcomes": [ + { + "finalState": "idle", + "outputs": { + "count": { + "type": "Integer", + "value": 100 + } + } + }, + { + "finalState": "idle", + "outputs": { + "count": { + "type": "Integer", + "value": 111 + } + } + } + ], + "admissible": "A do step and a dispatch due at one instant: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.seed-1.trace.golden new file mode 100644 index 0000000000..df255c2305 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.seed-1.trace.golden @@ -0,0 +1,23 @@ +do: top +stmt action body + stmt node work + eval feature count -> 0 +step 1: token 1@a + stmt assign n + eval feature n -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +step 2: token 1@b + stmt assign n + eval feature n -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +step 3: token 1@done +step 4: no active tokens +exit: top +stmt assign count + eval feature count -> 11 + eval literal 100 -> 100 + eval operator + -> 111 +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.sysml new file mode 100644 index 0000000000..97ca14a95c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.sysml @@ -0,0 +1,27 @@ +package Test { + private import ScalarValues::*; + + attribute def Stop; + + // The do behavior is a typed action whose flow is two steps. With `Stop` in the + // pool as `top` is entered, the dispatch may cut the performance at any of its + // moves, before its `inout` writes back, or take it after it ended. + state Machine { + attribute count : Integer = 0; + + action def Work { + inout n : Integer; + first start; + then action a assign n := n + 1; + then action b assign n := n + 10; + then done; + } + + entry; then top; + state top { + do action work : Work { inout n = count; } + } + transition first top accept Stop do assign count := count + 100 then idle; + state idle; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.trace.golden new file mode 100644 index 0000000000..df255c2305 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_cuts_typed_do.trace.golden @@ -0,0 +1,23 @@ +do: top +stmt action body + stmt node work + eval feature count -> 0 +step 1: token 1@a + stmt assign n + eval feature n -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +step 2: token 1@b + stmt assign n + eval feature n -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +step 3: token 1@done +step 4: no active tokens +exit: top +stmt assign count + eval feature count -> 11 + eval literal 100 -> 100 + eval operator + -> 111 +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.check.expected.json new file mode 100644 index 0000000000..619682ac58 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"did l1(entry) r1(entry) \"", + "\"did r1(entry) l1(entry) \"", + "\"l1(entry) did r1(entry) \"", + "\"l1(entry) r1(entry) did \"", + "\"r1(entry) did l1(entry) \"", + "\"r1(entry) l1(entry) did \"" + ] + }, + "agreed": { + "finalState": "l1+r1" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.declared.trace.golden new file mode 100644 index 0000000000..561e233cb7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.declared.trace.golden @@ -0,0 +1,6 @@ +do: Machine +stmt action body + stmt assign log + eval feature log -> "l1(entry) r1(entry) " + eval literal "did " -> "did " + eval operator + -> "l1(entry) r1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.expected.json new file mode 100644 index 0000000000..56cb33d4e9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.expected.json @@ -0,0 +1,14 @@ +{ + "type": "state", + "trace": true, + "events": [], + "outcomes": [ + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "did l1(entry) r1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "did r1(entry) l1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "l1(entry) did r1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "r1(entry) did l1(entry) "}}}, + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "l1(entry) r1(entry) did "}}}, + {"finalState": "l1+r1", "outputs": {"log": {"type": "String", "value": "r1(entry) l1(entry) did "}}} + ], + "admissible": "A composite's own do step and its substates' entries due inside its entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.seed-1.trace.golden new file mode 100644 index 0000000000..e3274485ff --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.seed-1.trace.golden @@ -0,0 +1,6 @@ +do: Machine +stmt action body + stmt assign log + eval feature log -> "r1(entry) l1(entry) " + eval literal "did " -> "did " + eval operator + -> "r1(entry) l1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.sysml new file mode 100644 index 0000000000..882dedef36 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.sysml @@ -0,0 +1,22 @@ +// The machine's own do behavior begins before its regions are entered: its step is +// drawn against the top regions' entries on the front entering them, before, +// between or after them in either of their orders. +package Test { + state Machine parallel { + attribute log : String = ""; + + do { assign log := log + "did "; } + state left { + entry; then l1; + state l1 { + entry { assign log := log + "l1(entry) "; } + } + } + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry) "; } + } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.trace.golden new file mode 100644 index 0000000000..561e233cb7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_machine_before_top_entries.trace.golden @@ -0,0 +1,6 @@ +do: Machine +stmt action body + stmt assign log + eval feature log -> "l1(entry) r1(entry) " + eval literal "did " -> "did " + eval operator + -> "l1(entry) r1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.check.expected.json new file mode 100644 index 0000000000..b67ee4abc3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"b1(entry) did l1(entry) \"", + "\"b1(entry) l1(entry) did \"", + "\"did b1(entry) l1(entry) \"", + "\"did l1(entry) b1(entry) \"", + "\"l1(entry) b1(entry) did \"", + "\"l1(entry) did b1(entry) \"" + ] + }, + "agreed": { + "finalState": "l1+a1+b1+r1" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.declared.trace.golden new file mode 100644 index 0000000000..4298e4600e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.declared.trace.golden @@ -0,0 +1,25 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l1(entry) " -> "l1(entry) " + eval operator + -> "l1(entry) " +enter: r1 +choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first) +enter: a1 +enter: b1 (entry action) +stmt action body + stmt assign log + eval feature log -> "l1(entry) " + eval literal "b1(entry) " -> "b1(entry) " + eval operator + -> "l1(entry) b1(entry) " +transition: idle -> work (event: accept Go) +do: a1 +stmt action body + stmt assign log + eval feature log -> "l1(entry) b1(entry) " + eval literal "did " -> "did " + eval operator + -> "l1(entry) b1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.expected.json new file mode 100644 index 0000000000..e3ff2b3800 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.expected.json @@ -0,0 +1,16 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "did l1(entry) b1(entry) "}}}, + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "did b1(entry) l1(entry) "}}}, + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "l1(entry) did b1(entry) "}}}, + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "b1(entry) did l1(entry) "}}}, + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "l1(entry) b1(entry) did "}}}, + {"finalState": "l1+a1+b1+r1", "outputs": {"log": {"type": "String", "value": "b1(entry) l1(entry) did "}}} + ], + "admissible": "A do step and a sibling region's entry due inside one entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.seed-1.trace.golden new file mode 100644 index 0000000000..2b22156606 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.seed-1.trace.golden @@ -0,0 +1,26 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took r1(entry) first) +enter: r1 +choice entering work: next l1(entry), a1(entry), b1(entry) (unordered; took l1(entry) first) +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l1(entry) " -> "l1(entry) " + eval operator + -> "l1(entry) " +choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first) +enter: a1 +enter: b1 (entry action) +stmt action body + stmt assign log + eval feature log -> "l1(entry) " + eval literal "b1(entry) " -> "b1(entry) " + eval operator + -> "l1(entry) b1(entry) " +transition: idle -> work (event: accept Go) +do: a1 +stmt action body + stmt assign log + eval feature log -> "l1(entry) b1(entry) " + eval literal "did " -> "did " + eval operator + -> "l1(entry) b1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.sysml new file mode 100644 index 0000000000..01a4c682b4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.sysml @@ -0,0 +1,38 @@ +// A do step of a state nested inside one region's parallel start state is +// drawn against the outer sibling region's entry as well as against its own +// sibling's: the nested front's units are units of the outer front. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + entry; then l1; + state l1 { + entry { assign log := log + "l1(entry) "; } + } + } + state right { + entry; then r1; + state r1 parallel { + state ra { + entry; then a1; + state a1 { + do { assign log := log + "did "; } + } + } + state rb { + entry; then b1; + state b1 { + entry { assign log := log + "b1(entry) "; } + } + } + } + } + } + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.trace.golden new file mode 100644 index 0000000000..4298e4600e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_nested_before_outer_entry.trace.golden @@ -0,0 +1,25 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l1(entry) " -> "l1(entry) " + eval operator + -> "l1(entry) " +enter: r1 +choice entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first) +enter: a1 +enter: b1 (entry action) +stmt action body + stmt assign log + eval feature log -> "l1(entry) " + eval literal "b1(entry) " -> "b1(entry) " + eval operator + -> "l1(entry) b1(entry) " +transition: idle -> work (event: accept Go) +do: a1 +stmt action body + stmt assign log + eval feature log -> "l1(entry) b1(entry) " + eval literal "did " -> "did " + eval operator + -> "l1(entry) b1(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.check.expected.json new file mode 100644 index 0000000000..b00f7c54a4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.check.expected.json @@ -0,0 +1,12 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"did stop \"", + "\"stop \"" + ] + }, + "agreed": { + "finalState": "idle" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.declared.trace.golden new file mode 100644 index 0000000000..03ac80a7c9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.declared.trace.golden @@ -0,0 +1,13 @@ +do: top +stmt action body + stmt assign log + eval feature log -> "" + eval literal "did " -> "did " + eval operator + -> "did " +exit: top +stmt assign log + eval feature log -> "did " + eval literal "stop " -> "stop " + eval operator + -> "did stop " +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.expected.json new file mode 100644 index 0000000000..b13523c7b9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.expected.json @@ -0,0 +1,22 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Stop"} + ], + "outcomes": [ + { + "finalState": "idle", + "outputs": { + "log": {"type": "String", "value": "did stop "} + } + }, + { + "finalState": "idle", + "outputs": { + "log": {"type": "String", "value": "stop "} + } + } + ], + "admissible": "A do step and a dispatch due at one instant: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.seed-1.trace.golden new file mode 100644 index 0000000000..03ac80a7c9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.seed-1.trace.golden @@ -0,0 +1,13 @@ +do: top +stmt action body + stmt assign log + eval feature log -> "" + eval literal "did " -> "did " + eval operator + -> "did " +exit: top +stmt assign log + eval feature log -> "did " + eval literal "stop " -> "stop " + eval operator + -> "did stop " +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.sysml new file mode 100644 index 0000000000..34d70d1b26 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.sysml @@ -0,0 +1,20 @@ +// A state's do behavior and a dispatch due at one instant: Stop is in the pool as +// top is entered, and whether the do behavior acts before the dispatch leaves the +// state is open. Under check and explore the order is a draw at each such +// instant; the fixed policies run the do round to its end and dispatch after it. +package Test { + private import ScalarValues::*; + + attribute def Stop; + + state Machine { + attribute log : String = ""; + + entry; then top; + state top { + do { assign log := log + "did "; } + } + transition first top accept Stop do assign log := log + "stop " then idle; + state idle; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.trace.golden new file mode 100644 index 0000000000..03ac80a7c9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_dispatch.trace.golden @@ -0,0 +1,13 @@ +do: top +stmt action body + stmt assign log + eval feature log -> "" + eval literal "did " -> "did " + eval operator + -> "did " +exit: top +stmt assign log + eval feature log -> "did " + eval literal "stop " -> "stop " + eval operator + -> "did stop " +enter: idle +transition: top -> idle (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.check.expected.json new file mode 100644 index 0000000000..68172b425e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.check.expected.json @@ -0,0 +1,13 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"did one \"", + "\"did two \"", + "\"two \"" + ] + }, + "agreed": { + "finalState": "idle" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.declared.trace.golden new file mode 100644 index 0000000000..708af79542 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.declared.trace.golden @@ -0,0 +1,25 @@ +do: top +stmt action body +enter action node: state behavior mark + eval literal 2 -> 2 + eval index -> 2 [s] +do: top + stmt assign log + eval feature log -> "" + eval literal "did " -> "did " + eval operator + -> "did " +leave action node: state behavior mark + eval feature log -> "did " + eval literal "" -> "" +eval operator != -> true + eval feature log -> "did " + eval literal "" -> "" +eval operator != -> true +choice state top on time: transitions 1->idle, 2->idle (unordered; took 1->idle) +exit: top +stmt assign log + eval feature log -> "did " + eval literal "one " -> "one " + eval operator + -> "did one " +enter: idle +transition: top -> idle (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.expected.json new file mode 100644 index 0000000000..dd2ce0d47a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.expected.json @@ -0,0 +1,26 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "outcomes": [ + { + "finalState": "idle", + "outputs": { + "log": {"type": "String", "value": "did one "} + } + }, + { + "finalState": "idle", + "outputs": { + "log": {"type": "String", "value": "did two "} + } + }, + { + "finalState": "idle", + "outputs": { + "log": {"type": "String", "value": "two "} + } + } + ], + "admissible": "A do step and a dispatch due at one instant: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.seed-1.trace.golden new file mode 100644 index 0000000000..47ec3d071f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.seed-1.trace.golden @@ -0,0 +1,22 @@ +do: top +stmt action body +enter action node: state behavior mark + eval literal 2 -> 2 + eval index -> 2 [s] +do: top + stmt assign log + eval feature log -> "" + eval literal "did " -> "did " + eval operator + -> "did " +leave action node: state behavior mark + eval feature log -> "did " + eval literal "" -> "" +eval operator != -> true +choice state top on time: transitions 1->idle, 2->idle (unordered; took 2->idle) +exit: top +stmt assign log + eval feature log -> "did " + eval literal "two " -> "two " + eval operator + -> "did two " +enter: idle +transition: top -> idle (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.sysml new file mode 100644 index 0000000000..aea2ba16d0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.sysml @@ -0,0 +1,26 @@ +// A do step and two time triggers due at one instant, one of them guarded on what +// the do step writes: before the step only the unguarded trigger would fire, so +// under check and explore it alone is drawn against the step; the guarded trigger, +// which the dispatch would drop, waits for the round to close as under the fixed +// policies, where the round runs first and the tied dispatch is drawn after it. +package Test { + private import ScalarValues::*; + private import SI::*; + + state Machine { + attribute log : String = ""; + + entry; then top; + state top { + do action mark { + first start; + then action wait accept after 2 [s]; + then action write assign log := log + "did "; + then done; + } + } + transition first top accept after 2 [s] if log != "" do assign log := log + "one " then idle; + transition first top accept after 2 [s] do assign log := log + "two " then idle; + state idle; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.trace.golden new file mode 100644 index 0000000000..708af79542 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_or_tied_dispatch.trace.golden @@ -0,0 +1,25 @@ +do: top +stmt action body +enter action node: state behavior mark + eval literal 2 -> 2 + eval index -> 2 [s] +do: top + stmt assign log + eval feature log -> "" + eval literal "did " -> "did " + eval operator + -> "did " +leave action node: state behavior mark + eval feature log -> "did " + eval literal "" -> "" +eval operator != -> true + eval feature log -> "did " + eval literal "" -> "" +eval operator != -> true +choice state top on time: transitions 1->idle, 2->idle (unordered; took 1->idle) +exit: top +stmt assign log + eval feature log -> "did " + eval literal "one " -> "one " + eval operator + -> "did one " +enter: idle +transition: top -> idle (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.check.expected.json new file mode 100644 index 0000000000..4fde06fa9c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.check.expected.json @@ -0,0 +1,12 @@ +{ + "verdict": "divergent", + "divergent": { + "count": [ + "11", + "111" + ] + }, + "agreed": { + "finalState": "l1+r1" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.declared.trace.golden new file mode 100644 index 0000000000..2250609f5a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.declared.trace.golden @@ -0,0 +1,27 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 (entry action) +stmt action body + stmt assign count + eval feature count -> 0 + eval literal 100 -> 100 + eval operator + -> 100 +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt node job + eval feature count -> 100 +step 1: token 1@a + stmt assign n + eval feature n -> 100 + eval literal 1 -> 1 + eval operator + -> 101 +step 2: token 1@b + stmt assign n + eval feature n -> 101 + eval literal 10 -> 10 + eval operator + -> 111 +step 3: token 1@done +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.expected.json new file mode 100644 index 0000000000..9c70be5ae5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + {"finalState": "l1+r1", "outputs": {"count": {"type": "Integer", "value": 11}}}, + {"finalState": "l1+r1", "outputs": {"count": {"type": "Integer", "value": 111}}} + ], + "admissible": "A do step and a sibling region's entry due inside one entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.seed-1.trace.golden new file mode 100644 index 0000000000..8bc21ab22c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.seed-1.trace.golden @@ -0,0 +1,27 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took r1(entry) first) +enter: r1 (entry action) +stmt action body + stmt assign count + eval feature count -> 0 + eval literal 100 -> 100 + eval operator + -> 100 +enter: l1 +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt node job + eval feature count -> 100 +step 1: token 1@a + stmt assign n + eval feature n -> 100 + eval literal 1 -> 1 + eval operator + -> 101 +step 2: token 1@b + stmt assign n + eval feature n -> 101 + eval literal 10 -> 10 + eval operator + -> 111 +step 3: token 1@done +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.sysml new file mode 100644 index 0000000000..395ed8553e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.sysml @@ -0,0 +1,38 @@ +// The do behavior is a typed action of two steps whose inout writes back as it +// ends, started as its state is entered: each step is one move drawn against +// the sibling region's remaining entry while it is left, so the entry's write +// falls before the write-back, which overwrites it, or after it. +package Test { + private import ScalarValues::*; + + state Machine { + attribute count : Integer = 0; + + action def Work { + inout n : Integer; + first start; + then action a assign n := n + 1; + then action b assign n := n + 10; + then done; + } + + entry; then idle; + state idle; + state work parallel { + state left { + entry; then l1; + state l1 { + do action job : Work { inout n = count; } + } + } + state right { + entry; then r1; + state r1 { + entry { assign count := count + 100; } + } + } + } + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.trace.golden new file mode 100644 index 0000000000..2250609f5a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_typed_before_sibling_entry.trace.golden @@ -0,0 +1,27 @@ +exit: idle +enter: work +choice entering work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 (entry action) +stmt action body + stmt assign count + eval feature count -> 0 + eval literal 100 -> 100 + eval operator + -> 100 +transition: idle -> work (event: accept Go) +do: l1 +stmt action body + stmt node job + eval feature count -> 100 +step 1: token 1@a + stmt assign n + eval feature n -> 100 + eval literal 1 -> 1 + eval operator + -> 101 +step 2: token 1@b + stmt assign n + eval feature n -> 101 + eval literal 10 -> 10 + eval operator + -> 111 +step 3: token 1@done +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.check.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.check.expected.json new file mode 100644 index 0000000000..cd542eff0c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"a(entry) b(entry) did \"", + "\"a(entry) did b(entry) \"", + "\"b(entry) a(entry) did \"", + "\"b(entry) did a(entry) \"", + "\"did a(entry) b(entry) \"", + "\"did b(entry) a(entry) \"" + ] + }, + "agreed": { + "finalState": "a+b" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.declared.trace.golden new file mode 100644 index 0000000000..8cf2997c54 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.declared.trace.golden @@ -0,0 +1,23 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +enter: work +choice fork split: next a(entry), split->b(effect) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "a(entry) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "a(entry) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "a(entry) b(entry) " +transition: idle -> split +do: work +stmt action body + stmt assign log + eval feature log -> "a(entry) b(entry) " + eval literal "did " -> "did " + eval operator + -> "a(entry) b(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.expected.json b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.expected.json new file mode 100644 index 0000000000..9cb782d229 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.expected.json @@ -0,0 +1,16 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go"} + ], + "outcomes": [ + {"finalState": "a+b", "outputs": {"log": {"type": "String", "value": "did a(entry) b(entry) "}}}, + {"finalState": "a+b", "outputs": {"log": {"type": "String", "value": "did b(entry) a(entry) "}}}, + {"finalState": "a+b", "outputs": {"log": {"type": "String", "value": "a(entry) did b(entry) "}}}, + {"finalState": "a+b", "outputs": {"log": {"type": "String", "value": "b(entry) did a(entry) "}}}, + {"finalState": "a+b", "outputs": {"log": {"type": "String", "value": "a(entry) b(entry) did "}}}, + {"finalState": "a+b", "outputs": {"log": {"type": "String", "value": "b(entry) a(entry) did "}}} + ], + "admissible": "A composite's own do step and its substates' entries due inside its entry: which goes first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.seed-1.trace.golden new file mode 100644 index 0000000000..5bb7154a18 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.seed-1.trace.golden @@ -0,0 +1,23 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->b(effect) first) +enter: work +choice fork split: next split->a(effect), b(entry) (unordered; took split->a(effect) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "a(entry) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "a(entry) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "a(entry) b(entry) " +transition: idle -> split +do: work +stmt action body + stmt assign log + eval feature log -> "a(entry) b(entry) " + eval literal "did " -> "did " + eval operator + -> "a(entry) b(entry) did " diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.sysml b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.sysml new file mode 100644 index 0000000000..d48294c466 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.sysml @@ -0,0 +1,30 @@ +// A fork's branches enter a composite on their way down: the composite's own do +// behavior begins when the first branch enters it and its step is drawn against +// the branches' remaining effect and target entries. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + do { assign log := log + "did "; } + state left { + state a { + entry { assign log := log + "a(entry) "; } + } + } + state right { + state b { + entry { assign log := log + "b(entry) "; } + } + } + } + fork split; + + transition first idle accept Go then split; + transition first split then a; + transition first split then b; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.trace.golden b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.trace.golden new file mode 100644 index 0000000000..8cf2997c54 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_do_step_way_down_before_fork_branch.trace.golden @@ -0,0 +1,23 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +enter: work +choice fork split: next a(entry), split->b(effect) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "a(entry) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "a(entry) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "a(entry) b(entry) " +transition: idle -> split +do: work +stmt action body + stmt assign log + eval feature log -> "a(entry) b(entry) " + eval literal "did " -> "did " + eval operator + -> "a(entry) b(entry) did " diff --git a/internal/core/runtime/testdata/conformance/state_entry_action_transition_guarded.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_action_transition_guarded.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_action_transition_guarded.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_action_transition_guarded.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_action_transition_guarded.sysml b/internal/exec/runtime/testdata/conformance/state_entry_action_transition_guarded.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_action_transition_guarded.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_action_transition_guarded.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_action_transition_initial.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_action_transition_initial.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_action_transition_initial.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_action_transition_initial.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_action_transition_initial.sysml b/internal/exec/runtime/testdata/conformance/state_entry_action_transition_initial.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_action_transition_initial.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_action_transition_initial.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_exit_action_successions.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_exit_action_successions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_exit_action_successions.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_exit_action_successions.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_exit_action_successions.sysml b/internal/exec/runtime/testdata/conformance/state_entry_exit_action_successions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_exit_action_successions.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_exit_action_successions.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_exit_action_successions.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_exit_action_successions.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_exit_action_successions.trace.golden rename to internal/exec/runtime/testdata/conformance/state_entry_exit_action_successions.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/state_entry_payload_resumed_cascade.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_payload_resumed_cascade.expected.json new file mode 100644 index 0000000000..d44c22a546 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_payload_resumed_cascade.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "events": [ + {"signal": "Level", "value": {"type": "Integer", "value": 7}} + ], + "finalState": "inner", + "stateVisits": ["idle", "work", "inner"], + "outputs": { + "entered": {"type": "Integer", "value": 7}, + "did": {"type": "Integer", "value": 7} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_payload_resumed_cascade.sysml b/internal/exec/runtime/testdata/conformance/state_entry_payload_resumed_cascade.sysml new file mode 100644 index 0000000000..fa432bc859 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_payload_resumed_cascade.sysml @@ -0,0 +1,27 @@ +// A composite that is not run-to-completion pauses its entry cascade at the +// boundary; the substate entered when the cascade resumes still reads the +// payload of the transition that entered the composite, `raise.l`, in its entry +// and its do behavior: both are performed within that transition's firing. +package StateEntryPayloadResumedCascade { + private import ScalarValues::*; + + attribute def Level :> Integer; + + state Monitor { + attribute entered : Integer = 0; + attribute did : Integer = 0; + + entry; then idle; + state idle; + state work { + attribute :>> isRunToCompletion = false; + entry; then inner; + state inner { + entry action { in level : Integer = raise.l ?? 99; assign entered := level; } + do { in level : Integer = raise.l ?? 99; assign did := level; } + } + } + + transition raise first idle accept l : Level then work; + } +} diff --git a/internal/core/runtime/testdata/conformance/state_entry_succession_initial.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_succession_initial.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_succession_initial.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_succession_initial.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_succession_initial.sysml b/internal/exec/runtime/testdata/conformance/state_entry_succession_initial.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_succession_initial.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_succession_initial.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_default.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_default.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_default.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_transition_default.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_default.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_default.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_default.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_transition_default.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_done.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_done.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_done.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_transition_done.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_done.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_done.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_done.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_transition_done.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_guard_first.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_guard_first.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_guard_first.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_transition_guard_first.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_guard_first.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_guard_first.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_guard_first.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_transition_guard_first.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_guard_second.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_guard_second.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_guard_second.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_transition_guard_second.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_guard_second.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_guard_second.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_guard_second.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_transition_guard_second.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_nested_done.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_nested_done.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_nested_done.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_nested_done.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done.trace.golden new file mode 100644 index 0000000000..c61f712def --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done.trace.golden @@ -0,0 +1,22 @@ +exit: idle +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "in;" + eval literal "outer;" -> "outer;" + eval operator + -> "in;outer;" +choice entering outer: next left.done(entry), right.done(entry) (unordered; took left.done(entry) first) +enter: done +enter: done +transition: idle -> outer (event: accept Go) +choice exiting outer: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) +exit: done +exit: done +exit: outer +enter: done +stmt action body + stmt assign log + eval feature log -> "in;outer;" + eval literal "out;" -> "out;" + eval operator + -> "in;outer;out;" +transition: outer -> done diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.trace.golden new file mode 100644 index 0000000000..67d178ac3a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_done_stay_active.trace.golden @@ -0,0 +1,17 @@ +exit: idle +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "in;" + eval literal "outer;" -> "outer;" + eval operator + -> "in;outer;" +choice entering outer: next left.done(entry), right.done(entry) (unordered; took left.done(entry) first) +enter: done +enter: done +transition: idle -> outer (event: accept Go) +choice exiting outer: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) +exit: done +exit: done +exit: outer +enter: idle +transition: outer -> idle (event: accept Again) diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_nested_regions.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_regions.expected.json similarity index 93% rename from internal/core/runtime/testdata/conformance/state_entry_transition_nested_regions.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_transition_nested_regions.expected.json index d6a4d1ca14..8f382db806 100644 --- a/internal/core/runtime/testdata/conformance/state_entry_transition_nested_regions.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_regions.expected.json @@ -1,5 +1,6 @@ { "type": "instance", + "schedule": "reverse", "libraries": true, "trace": true, "instantiate": "test::unit", diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_nested_regions.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_regions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_nested_regions.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_transition_nested_regions.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_nested_regions.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_regions.trace.golden similarity index 87% rename from internal/core/runtime/testdata/conformance/state_entry_transition_nested_regions.trace.golden rename to internal/exec/runtime/testdata/conformance/state_entry_transition_nested_regions.trace.golden index 671d7bef7d..eb7a3e18a8 100644 --- a/internal/core/runtime/testdata/conformance/state_entry_transition_nested_regions.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_nested_regions.trace.golden @@ -23,6 +23,7 @@ stmt assign fastEntries eval feature fastEntries -> 0 eval literal 1 -> 1 eval operator + -> 1 +choice entering fast: next start of state left, start of state right (unordered; took start of state left first) eval feature mode -> 2 eval literal 1 -> 1 eval operator == -> false diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_reentry.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_reentry.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_reentry.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_transition_reentry.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_reentry.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_reentry.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_reentry.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_transition_reentry.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_reentry.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_reentry.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_reentry.trace.golden rename to internal/exec/runtime/testdata/conformance/state_entry_transition_reentry.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_region_composite.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_region_composite.expected.json similarity index 92% rename from internal/core/runtime/testdata/conformance/state_entry_transition_region_composite.expected.json rename to internal/exec/runtime/testdata/conformance/state_entry_transition_region_composite.expected.json index aebc6cde26..5e616afa6d 100644 --- a/internal/core/runtime/testdata/conformance/state_entry_transition_region_composite.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_region_composite.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ { "signal": "Start" diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_region_composite.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_region_composite.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_region_composite.sysml rename to internal/exec/runtime/testdata/conformance/state_entry_transition_region_composite.sysml diff --git a/internal/core/runtime/testdata/conformance/state_entry_transition_region_composite.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_region_composite.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_entry_transition_region_composite.trace.golden rename to internal/exec/runtime/testdata/conformance/state_entry_transition_region_composite.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/state_exit_call_arguments.expected.json b/internal/exec/runtime/testdata/conformance/state_exit_call_arguments.expected.json new file mode 100644 index 0000000000..1e89198141 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_call_arguments.expected.json @@ -0,0 +1,15 @@ +{ + "type": "state", + "events": [ + {"call": "setSpeed", "args": { + "speed": {"type": "Integer", "value": 55}, + "gear": {"type": "Integer", "value": 3} + }} + ], + "finalState": "done", + "stateVisits": ["idle", "cruising", "done"], + "outputs": { + "exitedSpeed": {"type": "Integer", "value": 55}, + "exitedGear": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_call_arguments.sysml b/internal/exec/runtime/testdata/conformance/state_exit_call_arguments.sysml new file mode 100644 index 0000000000..87395ea60a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_call_arguments.sysml @@ -0,0 +1,24 @@ +// A state's exit action reads the arguments of the operation call whose trigger +// fires the leaving transition, each by the trigger's parameter name. +package StateExitCallArguments { + private import ScalarValues::*; + + state Controller { + attribute exitedSpeed : Integer = 0; + attribute exitedGear : Integer = 0; + + entry; then idle; + state idle { + exit action { + in v : Integer = set.speed; + in g : Integer = set.gear; + assign exitedSpeed := v; + assign exitedGear := g; + } + } + state cruising; + + transition set first idle accept setSpeed(speed, gear) then cruising; + transition first cruising then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_completion_binds_nothing.expected.json b/internal/exec/runtime/testdata/conformance/state_exit_completion_binds_nothing.expected.json new file mode 100644 index 0000000000..e4eed8060b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_completion_binds_nothing.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "events": [{"signal": "Go", "value": {"type": "Integer", "value": 5}}], + "finalState": "done", + "stateVisits": ["idle", "passing", "done"], + "outputs": { + "entered": {"type": "Integer", "value": 0}, + "exited": {"type": "Integer", "value": 5}, + "second": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_completion_binds_nothing.sysml b/internal/exec/runtime/testdata/conformance/state_exit_completion_binds_nothing.sysml new file mode 100644 index 0000000000..ad71001057 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_completion_binds_nothing.sysml @@ -0,0 +1,35 @@ +// A transition with no trigger carries no payload: an exit or entry action +// performed by it, or by the machine's own start, reads the transition's payload +// as empty and keeps its parameter's fallback. +package StateExitCompletionBindsNothing { + private import ScalarValues::*; + + attribute def Go :> Integer; + + state Machine { + attribute entered : Integer = -1; + attribute exited : Integer = -1; + attribute second : Integer = -1; + + entry; then idle; + state idle { + entry action { + in level : Integer = go.l ?? 0; + assign entered := level; + } + exit action { + in level : Integer = go.l ?? 0; + assign exited := level; + } + } + state passing { + exit action { + in level : Integer = go.l ?? 0; + assign second := level; + } + } + + transition go first idle accept l : Go then passing; + transition first passing then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_nested_reads_outer_transition.expected.json b/internal/exec/runtime/testdata/conformance/state_exit_nested_reads_outer_transition.expected.json new file mode 100644 index 0000000000..891fc62847 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_nested_reads_outer_transition.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "events": [{"signal": "Stop", "value": {"type": "Integer", "value": 4}}], + "finalState": "done", + "stateVisits": ["running", "fast", "stopped", "done"], + "outputs": { + "innerExit": {"type": "Integer", "value": 40}, + "outerExit": {"type": "Integer", "value": 4} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_nested_reads_outer_transition.sysml b/internal/exec/runtime/testdata/conformance/state_exit_nested_reads_outer_transition.sysml new file mode 100644 index 0000000000..bb0bd06f59 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_nested_reads_outer_transition.sysml @@ -0,0 +1,31 @@ +// A transition leaving a composite state exits its active substate first; both +// exit actions read the payload of that one transition. +package StateExitNestedReadsOuterTransition { + private import ScalarValues::*; + + attribute def Stop :> Integer; + + state Machine { + attribute innerExit : Integer = 0; + attribute outerExit : Integer = 0; + + entry; then running; + state running { + exit action { + in code : Integer = halt.c; + assign outerExit := code; + } + entry; then fast; + state fast { + exit action { + in code : Integer = halt.c; + assign innerExit := code * 10; + } + } + } + state stopped; + + transition halt first running accept c : Stop then stopped; + transition first stopped then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_payload_deferred_read.expected.json b/internal/exec/runtime/testdata/conformance/state_exit_payload_deferred_read.expected.json new file mode 100644 index 0000000000..b92c74ea90 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_payload_deferred_read.expected.json @@ -0,0 +1,11 @@ +{ + "libraries": true, + "type": "state", + "events": [{"signal": "Level", "value": {"type": "Integer", "value": 7}}], + "finalState": "done", + "stateVisits": ["idle", "alerting", "done"], + "outputs": { + "collected": {"type": "Integer", "value": 17}, + "chosen": {"type": "Integer", "value": 14} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_payload_deferred_read.sysml b/internal/exec/runtime/testdata/conformance/state_exit_payload_deferred_read.sysml new file mode 100644 index 0000000000..9f71c2f511 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_payload_deferred_read.sysml @@ -0,0 +1,28 @@ +// A state's exit action reads the leaving transition's payload, `raise.l`, from +// expressions evaluated later than they are written: a body closed over the exit's +// environment and an `expr` argument the callee evaluates. +package StateExitPayloadDeferredRead { + private import ScalarValues::*; + private import ControlFunctions::*; + + attribute def Level :> Integer; + + state Monitor { + attribute collected : Integer = 0; + attribute chosen : Integer = 0; + + entry; then idle; + state idle { + exit action { + in viaBody : Integer = ((10)->collect { in x : Integer; x + raise.l })#(1); + in viaExpr : Integer = 'if'(true, raise.l * 2, 0); + assign collected := viaBody; + assign chosen := viaExpr; + } + } + state alerting; + + transition raise first idle accept l : Level then alerting; + transition first alerting then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_payload_nested_predicate.expected.json b/internal/exec/runtime/testdata/conformance/state_exit_payload_nested_predicate.expected.json new file mode 100644 index 0000000000..8733275c27 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_payload_nested_predicate.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "events": [{"signal": "Level", "value": {"type": "Integer", "value": 7}}], + "finalState": "done", + "stateVisits": ["idle", "alerting", "done"], + "outputs": { + "high": {"type": "Boolean", "value": true}, + "exited": {"type": "Integer", "value": 7} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_payload_nested_predicate.sysml b/internal/exec/runtime/testdata/conformance/state_exit_payload_nested_predicate.sysml new file mode 100644 index 0000000000..900a13ac78 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_payload_nested_predicate.sysml @@ -0,0 +1,27 @@ +// A constraint a state declares reads the payload of the transition leaving it, +// `raise.l`, when the state's exit action applies it: the predicate closes over +// the state's performance, which is read within the firing. +package StateExitPayloadNestedPredicate { + private import ScalarValues::*; + + attribute def Level :> Integer; + + state Monitor { + attribute high : Boolean = false; + attribute exited : Integer = 0; + + entry; then idle; + state idle { + constraint aboveFive { raise.l > 5 } + exit action { + in level : Integer = raise.l; + assign high := aboveFive(); + assign exited := level; + } + } + state alerting; + + transition raise first idle accept l : Level then alerting; + transition first alerting then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_payload_optional_input.expected.json b/internal/exec/runtime/testdata/conformance/state_exit_payload_optional_input.expected.json new file mode 100644 index 0000000000..8727e43654 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_payload_optional_input.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "libraries": true, + "events": [ + {"signal": "Warning", "value": {"type": "Integer", "value": 3}}, + {"signal": "Reset"}, + {"signal": "Reset"}, + {"signal": "Reset"}, + {"signal": "Warning", "value": {"type": "Integer", "value": 7}} + ], + "finalState": "warned", + "stateVisits": ["idle", "warned", "idle", "warned", "idle", "warned"], + "outputs": { + "exits": {"type": "Integer", "value": 3}, + "levels": {"type": "Integer", "value": 37} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_payload_optional_input.sysml b/internal/exec/runtime/testdata/conformance/state_exit_payload_optional_input.sysml new file mode 100644 index 0000000000..b1c3ce7faa --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_payload_optional_input.sysml @@ -0,0 +1,31 @@ +// An exit whose input is optional runs on every leaving transition: a firing +// carrying the payload binds it, a data-less one leaves it empty, and only the +// statements reading it are skipped then. +package StateExitPayloadOptionalInput { + private import ScalarValues::*; + private import SequenceFunctions::*; + + attribute def Warning :> Integer; + attribute def Reset; + + state Monitor { + attribute exits : Integer = 0; + attribute levels : Integer = 0; + + entry; then idle; + state idle { + exit action { + in level : Integer[0..1] = warn.w; + assign exits := exits + 1; + if notEmpty(level) { + assign levels := levels * 10 + level; + } + } + } + state warned; + + transition warn first idle accept w : Warning then warned; + transition reset first idle accept Reset then warned; + transition first warned accept Reset then idle; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_shadowed_transition_name.expected.json b/internal/exec/runtime/testdata/conformance/state_exit_shadowed_transition_name.expected.json new file mode 100644 index 0000000000..6aa7b121ce --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_shadowed_transition_name.expected.json @@ -0,0 +1,14 @@ +{ + "type": "state", + "libraries": true, + "events": [ + {"signal": "Step", "value": {"type": "Integer", "value": 2}}, + {"signal": "Stop", "value": {"type": "Integer", "value": 5}} + ], + "finalState": "done", + "stateVisits": ["running", "fast", "slow", "fast", "stopped", "done"], + "outputs": { + "exits": {"type": "Integer", "value": 2}, + "seen": {"type": "Integer", "value": 25} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_shadowed_transition_name.sysml b/internal/exec/runtime/testdata/conformance/state_exit_shadowed_transition_name.sysml new file mode 100644 index 0000000000..d4abeef8d5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_shadowed_transition_name.sysml @@ -0,0 +1,35 @@ +// Two leaving transitions share a name at different depths: the nearer one is +// read bare, the outer one by qualified name, each binding only when it fires. +package StateExitShadowedTransitionName { + private import ScalarValues::*; + private import SequenceFunctions::*; + + attribute def Step :> Integer; + attribute def Stop :> Integer; + + state Machine { + attribute seen : Integer = 0; + attribute exits : Integer = 0; + + entry; then running; + state running { + entry; then fast; + state fast { + exit action { + in code : Integer[0..1] = T.c ?? Machine::T.c; + assign exits := exits + 1; + if notEmpty(code) { + assign seen := seen * 10 + code; + } + } + } + state slow; + transition T first fast accept c : Step then slow; + transition first slow then fast; + } + state stopped; + + transition T first running accept c : Stop then stopped; + transition first stopped then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_shared_by_two_transitions.expected.json b/internal/exec/runtime/testdata/conformance/state_exit_shared_by_two_transitions.expected.json new file mode 100644 index 0000000000..9927bb9ec7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_shared_by_two_transitions.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "events": [ + {"signal": "Warning", "value": {"type": "Integer", "value": 2}}, + {"signal": "Reset"}, + {"signal": "Alarm", "value": {"type": "Integer", "value": 9}} + ], + "finalState": "done", + "stateVisits": ["idle", "warned", "idle", "alarmed", "done"], + "outputs": { + "exits": {"type": "Integer", "value": 209} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_shared_by_two_transitions.sysml b/internal/exec/runtime/testdata/conformance/state_exit_shared_by_two_transitions.sysml new file mode 100644 index 0000000000..19f32d3a3e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_shared_by_two_transitions.sysml @@ -0,0 +1,28 @@ +// One exit action is performed by each transition leaving its state, and reads +// the payload of the one being taken: a transition not taken carries nothing. +package StateExitSharedByTwoTransitions { + private import ScalarValues::*; + + attribute def Warning :> Integer; + attribute def Alarm :> Integer; + attribute def Reset; + + state Monitor { + attribute exits : Integer = 0; + + entry; then idle; + state idle { + exit action { + in level : Integer = warn.w ?? alarm.a; + assign exits := exits * 100 + level; + } + } + state warned; + state alarmed; + + transition warn first idle accept w : Warning then warned; + transition alarm first idle accept a : Alarm then alarmed; + transition first warned accept Reset then idle; + transition first alarmed then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_signal_payload.expected.json b/internal/exec/runtime/testdata/conformance/state_exit_signal_payload.expected.json new file mode 100644 index 0000000000..4b1a13dc28 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_signal_payload.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "events": [{"signal": "Level", "value": {"type": "Integer", "value": 7}}], + "finalState": "done", + "stateVisits": ["idle", "alerting", "done"], + "outputs": { + "exited": {"type": "Integer", "value": 7}, + "effect": {"type": "Integer", "value": 7} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_exit_signal_payload.sysml b/internal/exec/runtime/testdata/conformance/state_exit_signal_payload.sysml new file mode 100644 index 0000000000..b21210b00c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_exit_signal_payload.sysml @@ -0,0 +1,24 @@ +// A state's exit action reads the payload of the transition leaving it, `raise.l`, +// which the trigger bound before the source state is exited. +package StateExitSignalPayload { + private import ScalarValues::*; + + attribute def Level :> Integer; + + state Monitor { + attribute exited : Integer = 0; + attribute effect : Integer = 0; + + entry; then idle; + state idle { + exit action { + in level : Integer = raise.l; + assign exited := level; + } + } + state alerting; + + transition raise first idle accept l : Level do assign effect := l then alerting; + transition first alerting then done; + } +} diff --git a/internal/core/runtime/testdata/conformance/state_explore_transition_conflict.check.expected.json b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_explore_transition_conflict.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_explore_completion_choice.check.expected.json diff --git a/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.declared.trace.golden new file mode 100644 index 0000000000..f0b1d65332 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.declared.trace.golden @@ -0,0 +1,10 @@ +choice state ready: transitions 1->left, 2->right (unordered; took 1->left) +exit: ready +enter: left (entry action) +stmt action body + stmt assign side + eval literal 1 -> 1 +transition: ready -> left +exit: left +enter: settled +transition: left -> settled diff --git a/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.expected.json b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.expected.json new file mode 100644 index 0000000000..da60d2534a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.expected.json @@ -0,0 +1,21 @@ +{ + "type": "state", + "trace": true, + "outcomes": [ + { + "finalState": "settled", + "stateVisits": ["ready", "left", "settled"], + "outputs": { + "side": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "settled", + "stateVisits": ["ready", "right", "settled"], + "outputs": { + "side": {"type": "Integer", "value": 2} + } + } + ], + "admissible": "Two transitions out of one state enabled by one event: exactly one fires, which one is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.seed-1.trace.golden new file mode 100644 index 0000000000..fd3fa29119 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.seed-1.trace.golden @@ -0,0 +1,10 @@ +choice state ready: transitions 1->left, 2->right (unordered; took 2->right) +exit: ready +enter: right (entry action) +stmt action body + stmt assign side + eval literal 2 -> 2 +transition: ready -> right +exit: right +enter: settled +transition: right -> settled diff --git a/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.sysml b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.sysml new file mode 100644 index 0000000000..148975468f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.sysml @@ -0,0 +1,26 @@ +package test { + private import ScalarValues::*; + + // Oracle (docs/project/behavior-semantic-oracle.md): two unguarded + // completion transitions out of `ready` are enabled by its one completion; + // the library says one fires, not which. Both sides complete into `settled`, + // so the final state is fixed and the state visited between, and `side`, are open. + state Switch { + attribute side : Integer = 0; + + entry; then ready; + state ready; + state left { + entry { assign side := 1; } + } + state right { + entry { assign side := 2; } + } + state settled; + + transition first ready then left; + transition first ready then right; + transition first left then settled; + transition first right then settled; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.trace.golden new file mode 100644 index 0000000000..f0b1d65332 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_completion_choice.trace.golden @@ -0,0 +1,10 @@ +choice state ready: transitions 1->left, 2->right (unordered; took 1->left) +exit: ready +enter: left (entry action) +stmt action body + stmt assign side + eval literal 1 -> 1 +transition: ready -> left +exit: left +enter: settled +transition: left -> settled diff --git a/internal/core/runtime/testdata/conformance/state_explore_region_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_explore_region_order.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_explore_region_order.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_explore_region_order.check.expected.json diff --git a/internal/exec/runtime/testdata/conformance/state_explore_region_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_region_order.declared.trace.golden new file mode 100644 index 0000000000..d3574c1b86 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_region_order.declared.trace.golden @@ -0,0 +1,11 @@ +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign last + eval literal 1 -> 1 +enter: a2 +transition: a1 -> a2 (event: accept Go) +exit: b1 +stmt assign last + eval literal 2 -> 2 +enter: b2 +transition: b1 -> b2 (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_explore_region_order.expected.json b/internal/exec/runtime/testdata/conformance/state_explore_region_order.expected.json new file mode 100644 index 0000000000..94e54d8121 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_region_order.expected.json @@ -0,0 +1,36 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "a2+b2", + "stateVisits": ["work", "a1", "b1", "a2", "b2"], + "outputs": { + "last": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "a2+b2", + "stateVisits": ["work", "a1", "b1", "b2", "a2"], + "outputs": { + "last": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "a2+b2", + "stateVisits": ["work", "b1", "a1", "a2", "b2"], + "outputs": { + "last": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "a2+b2", + "stateVisits": ["work", "b1", "a1", "b2", "a2"], + "outputs": { + "last": {"type": "Integer", "value": 1} + } + } + ], + "admissible": "Transitions in sibling regions enabled by one event: each fires, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_explore_region_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_region_order.seed-1.trace.golden new file mode 100644 index 0000000000..d3574c1b86 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_region_order.seed-1.trace.golden @@ -0,0 +1,11 @@ +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign last + eval literal 1 -> 1 +enter: a2 +transition: a1 -> a2 (event: accept Go) +exit: b1 +stmt assign last + eval literal 2 -> 2 +enter: b2 +transition: b1 -> b2 (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_explore_region_order.sysml b/internal/exec/runtime/testdata/conformance/state_explore_region_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_explore_region_order.sysml rename to internal/exec/runtime/testdata/conformance/state_explore_region_order.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_explore_region_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_region_order.trace.golden new file mode 100644 index 0000000000..d3574c1b86 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_region_order.trace.golden @@ -0,0 +1,11 @@ +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign last + eval literal 1 -> 1 +enter: a2 +transition: a1 -> a2 (event: accept Go) +exit: b1 +stmt assign last + eval literal 2 -> 2 +enter: b2 +transition: b1 -> b2 (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_explore_time_trigger_tie.check.expected.json b/internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_explore_time_trigger_tie.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_explore_time_trigger_tie.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_explore_time_trigger_tie.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.declared.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.expected.json b/internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.expected.json new file mode 100644 index 0000000000..8a767f6ade --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.expected.json @@ -0,0 +1,36 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "outcomes": [ + { + "finalState": "a2+b2", + "stateVisits": ["work", "a1", "b1", "a2", "b2"], + "outputs": { + "last": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "a2+b2", + "stateVisits": ["work", "a1", "b1", "b2", "a2"], + "outputs": { + "last": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "a2+b2", + "stateVisits": ["work", "b1", "a1", "a2", "b2"], + "outputs": { + "last": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "a2+b2", + "stateVisits": ["work", "b1", "a1", "b2", "a2"], + "outputs": { + "last": {"type": "Integer", "value": 1} + } + } + ], + "admissible": "Two time events due at one instant: each dispatches, in which order is open" +} diff --git a/internal/core/runtime/testdata/conformance/state_explore_time_trigger_tie.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_explore_time_trigger_tie.trace.golden rename to internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_explore_time_trigger_tie.sysml b/internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_explore_time_trigger_tie.sysml rename to internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.trace.golden new file mode 100644 index 0000000000..2be112b181 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_time_trigger_tie.trace.golden @@ -0,0 +1,11 @@ +choice events at t=2.0: time a1 1->a2, time b1 1->b2 (unordered; dispatched time a1 1->a2 first) +exit: a1 +stmt assign last + eval literal 1 -> 1 +enter: a2 +transition: a1 -> a2 (event: time) +exit: b1 +stmt assign last + eval literal 2 -> 2 +enter: b2 +transition: b1 -> b2 (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.check.expected.json b/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.check.expected.json new file mode 100644 index 0000000000..ef19ea7e3d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"side": ["1", "2"]}, + "agreed": {"finalState": "settled"} +} diff --git a/internal/core/runtime/testdata/conformance/state_explore_transition_conflict.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.declared.trace.golden similarity index 80% rename from internal/core/runtime/testdata/conformance/state_explore_transition_conflict.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.declared.trace.golden index 59188fb528..ab1c0fffe0 100644 --- a/internal/core/runtime/testdata/conformance/state_explore_transition_conflict.declared.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.declared.trace.golden @@ -1,8 +1,9 @@ choice state idle on accept Go: transitions 1->left, 2->right (unordered; took 1->left) exit: idle enter: left (entry action) -stmt assign side - eval literal 1 -> 1 +stmt action body + stmt assign side + eval literal 1 -> 1 transition: idle -> left (event: accept Go) exit: left enter: settled diff --git a/internal/core/runtime/testdata/conformance/state_explore_transition_conflict.expected.json b/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_explore_transition_conflict.expected.json rename to internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_explore_transition_conflict.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.seed-1.trace.golden similarity index 80% rename from internal/core/runtime/testdata/conformance/state_explore_transition_conflict.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.seed-1.trace.golden index de2b3ef869..85eafe1d6e 100644 --- a/internal/core/runtime/testdata/conformance/state_explore_transition_conflict.seed-1.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.seed-1.trace.golden @@ -1,8 +1,9 @@ choice state idle on accept Go: transitions 1->left, 2->right (unordered; took 2->right) exit: idle enter: right (entry action) -stmt assign side - eval literal 2 -> 2 +stmt action body + stmt assign side + eval literal 2 -> 2 transition: idle -> right (event: accept Go) exit: right enter: settled diff --git a/internal/core/runtime/testdata/conformance/state_explore_transition_conflict.sysml b/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_explore_transition_conflict.sysml rename to internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.sysml diff --git a/internal/core/runtime/testdata/conformance/state_explore_transition_conflict.trace.golden b/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.trace.golden similarity index 80% rename from internal/core/runtime/testdata/conformance/state_explore_transition_conflict.trace.golden rename to internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.trace.golden index 59188fb528..ab1c0fffe0 100644 --- a/internal/core/runtime/testdata/conformance/state_explore_transition_conflict.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_explore_transition_conflict.trace.golden @@ -1,8 +1,9 @@ choice state idle on accept Go: transitions 1->left, 2->right (unordered; took 1->left) exit: idle enter: left (entry action) -stmt assign side - eval literal 1 -> 1 +stmt action body + stmt assign side + eval literal 1 -> 1 transition: idle -> left (event: accept Go) exit: left enter: settled diff --git a/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.check.expected.json b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.check.expected.json new file mode 100644 index 0000000000..95520423ad --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"l1(exit) T1(effect) r1(exit) T2(effect) T3(effect) \"", + "\"l1(exit) r1(exit) T1(effect) T2(effect) T3(effect) \"", + "\"l1(exit) r1(exit) T2(effect) T1(effect) T3(effect) \"", + "\"r1(exit) T2(effect) l1(exit) T1(effect) T3(effect) \"", + "\"r1(exit) l1(exit) T1(effect) T2(effect) T3(effect) \"", + "\"r1(exit) l1(exit) T2(effect) T1(effect) T3(effect) \"" + ] + }, + "agreed": { + "finalState": "done" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.declared.trace.golden new file mode 100644 index 0000000000..cfb0b1649e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.declared.trace.golden @@ -0,0 +1,40 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l1(exit) " -> "l1(exit) " + eval operator + -> "l1(exit) " +choice on accept Go: next l1->l2(effect), r1(exit) (unordered; took l1->l2(effect) first) +stmt action body + stmt assign log + eval feature log -> "l1(exit) " + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "l1(exit) T1(effect) " +choice on accept Go: next l2(entry), r1(exit) (unordered; took l2(entry) first) +enter: l2 +transition: l1 -> l2 (event: accept Go) +exit: r1 (exit action) +stmt action body + stmt assign log + eval feature log -> "l1(exit) T1(effect) " + eval literal "r1(exit) " -> "r1(exit) " + eval operator + -> "l1(exit) T1(effect) r1(exit) " +stmt action body + stmt assign log + eval feature log -> "l1(exit) T1(effect) r1(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "l1(exit) T1(effect) r1(exit) T2(effect) " +enter: r2 +transition: r1 -> r2 (event: accept Go) +choice join sync: states l2, r2 react (unordered; took l2 first) +exit: l2 +exit: r2 +exit: work +stmt action body + stmt assign log + eval feature log -> "l1(exit) T1(effect) r1(exit) T2(effect) " + eval literal "T3(effect) " -> "T3(effect) " + eval operator + -> "l1(exit) T1(effect) r1(exit) T2(effect) T3(effect) " +enter: done +transition: work -> done diff --git a/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.expected.json b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.expected.json new file mode 100644 index 0000000000..e1ed21df14 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.expected.json @@ -0,0 +1,66 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + } + ], + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "l1(exit) T1(effect) r1(exit) T2(effect) T3(effect) " + } + } + }, + { + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "l1(exit) r1(exit) T1(effect) T2(effect) T3(effect) " + } + } + }, + { + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "l1(exit) r1(exit) T2(effect) T1(effect) T3(effect) " + } + } + }, + { + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "r1(exit) T2(effect) l1(exit) T1(effect) T3(effect) " + } + } + }, + { + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "r1(exit) l1(exit) T1(effect) T2(effect) T3(effect) " + } + } + }, + { + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "r1(exit) l1(exit) T2(effect) T1(effect) T3(effect) " + } + } + } + ], + "admissible": "Transitions in sibling regions enabled by one event: each fires, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.seed-1.trace.golden new file mode 100644 index 0000000000..cfb0b1649e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.seed-1.trace.golden @@ -0,0 +1,40 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l1(exit) " -> "l1(exit) " + eval operator + -> "l1(exit) " +choice on accept Go: next l1->l2(effect), r1(exit) (unordered; took l1->l2(effect) first) +stmt action body + stmt assign log + eval feature log -> "l1(exit) " + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "l1(exit) T1(effect) " +choice on accept Go: next l2(entry), r1(exit) (unordered; took l2(entry) first) +enter: l2 +transition: l1 -> l2 (event: accept Go) +exit: r1 (exit action) +stmt action body + stmt assign log + eval feature log -> "l1(exit) T1(effect) " + eval literal "r1(exit) " -> "r1(exit) " + eval operator + -> "l1(exit) T1(effect) r1(exit) " +stmt action body + stmt assign log + eval feature log -> "l1(exit) T1(effect) r1(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "l1(exit) T1(effect) r1(exit) T2(effect) " +enter: r2 +transition: r1 -> r2 (event: accept Go) +choice join sync: states l2, r2 react (unordered; took l2 first) +exit: l2 +exit: r2 +exit: work +stmt action body + stmt assign log + eval feature log -> "l1(exit) T1(effect) r1(exit) T2(effect) " + eval literal "T3(effect) " -> "T3(effect) " + eval operator + -> "l1(exit) T1(effect) r1(exit) T2(effect) T3(effect) " +enter: done +transition: work -> done diff --git a/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.sysml b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.sysml new file mode 100644 index 0000000000..4d39fe6b48 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.sysml @@ -0,0 +1,33 @@ +// One occurrence selects a transition in each region of a parallel state; the +// two firings are drawn one unit at a time — each region's source exit, then +// its effect — so a source's exit may fall between the other firing's exit and +// effect. The six linearizations of two chains of two are the admissible set. +// The targets then complete into a join, whose incoming segments are drawn +// whole, one at a time. +package Test { + state Machine { + attribute log : String = ""; + + entry; then work; + state work parallel { + state left { + entry; then l1; + state l1 { exit { assign log := log + "l1(exit) "; } } + state l2; + transition first l1 accept Go do { assign log := log + "T1(effect) "; } then l2; + } + state right { + entry; then r1; + state r1 { exit { assign log := log + "r1(exit) "; } } + state r2; + transition first r1 accept Go do { assign log := log + "T2(effect) "; } then r2; + } + } + join sync; + + transition first l2 then sync; + transition first r2 then sync; + transition first sync do { assign log := log + "T3(effect) "; } then done; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.trace.golden b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.trace.golden new file mode 100644 index 0000000000..cfb0b1649e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_firing_units_interleaved.trace.golden @@ -0,0 +1,40 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l1(exit) " -> "l1(exit) " + eval operator + -> "l1(exit) " +choice on accept Go: next l1->l2(effect), r1(exit) (unordered; took l1->l2(effect) first) +stmt action body + stmt assign log + eval feature log -> "l1(exit) " + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "l1(exit) T1(effect) " +choice on accept Go: next l2(entry), r1(exit) (unordered; took l2(entry) first) +enter: l2 +transition: l1 -> l2 (event: accept Go) +exit: r1 (exit action) +stmt action body + stmt assign log + eval feature log -> "l1(exit) T1(effect) " + eval literal "r1(exit) " -> "r1(exit) " + eval operator + -> "l1(exit) T1(effect) r1(exit) " +stmt action body + stmt assign log + eval feature log -> "l1(exit) T1(effect) r1(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "l1(exit) T1(effect) r1(exit) T2(effect) " +enter: r2 +transition: r1 -> r2 (event: accept Go) +choice join sync: states l2, r2 react (unordered; took l2 first) +exit: l2 +exit: r2 +exit: work +stmt action body + stmt assign log + eval feature log -> "l1(exit) T1(effect) r1(exit) T2(effect) " + eval literal "T3(effect) " -> "T3(effect) " + eval operator + -> "l1(exit) T1(effect) r1(exit) T2(effect) T3(effect) " +enter: done +transition: work -> done diff --git a/internal/core/runtime/testdata/conformance/state_first_succession_chain.expected.json b/internal/exec/runtime/testdata/conformance/state_first_succession_chain.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_first_succession_chain.expected.json rename to internal/exec/runtime/testdata/conformance/state_first_succession_chain.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_first_succession_chain.sysml b/internal/exec/runtime/testdata/conformance/state_first_succession_chain.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_first_succession_chain.sysml rename to internal/exec/runtime/testdata/conformance/state_first_succession_chain.sysml diff --git a/internal/core/runtime/testdata/conformance/state_first_succession_chained_endpoint.expected.json b/internal/exec/runtime/testdata/conformance/state_first_succession_chained_endpoint.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_first_succession_chained_endpoint.expected.json rename to internal/exec/runtime/testdata/conformance/state_first_succession_chained_endpoint.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_first_succession_chained_endpoint.sysml b/internal/exec/runtime/testdata/conformance/state_first_succession_chained_endpoint.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_first_succession_chained_endpoint.sysml rename to internal/exec/runtime/testdata/conformance/state_first_succession_chained_endpoint.sysml diff --git a/internal/core/runtime/testdata/conformance/state_first_succession_without_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_first_succession_without_entry.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_first_succession_without_entry.expected.json rename to internal/exec/runtime/testdata/conformance/state_first_succession_without_entry.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_first_succession_without_entry.sysml b/internal/exec/runtime/testdata/conformance/state_first_succession_without_entry.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_first_succession_without_entry.sysml rename to internal/exec/runtime/testdata/conformance/state_first_succession_without_entry.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_fork_branch_effect_owns_state_attribute.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_branch_effect_owns_state_attribute.expected.json new file mode 100644 index 0000000000..deba1a27cb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_branch_effect_owns_state_attribute.expected.json @@ -0,0 +1,20 @@ +{ + "type": "state", + "schedule": "reverse", + "finalState": "done", + "stateVisits": [ + "outer", + "init", + "work", + "a", + "b", + "done", + "done", + "done", + "done" + ], + "outputs": { + "outer.count": {"type": "Integer", "value": 11}, + "seen": {"type": "Integer", "value": 11} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_branch_effect_owns_state_attribute.sysml b/internal/exec/runtime/testdata/conformance/state_fork_branch_effect_owns_state_attribute.sysml new file mode 100644 index 0000000000..cb7651af79 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_branch_effect_owns_state_attribute.sysml @@ -0,0 +1,37 @@ +// A fork branch's effect reads and writes the attributes of the state declaring +// the fork, as a transition leaving one of its substates does: `count` here is +// `outer`'s, not the machine's, and `outer`'s exit sees what the branches wrote. +package ForkBranchEffectOwnsStateAttribute { + private import ScalarValues::*; + + state Machine { + attribute seen : Integer = 0; + + entry; then outer; + state outer { + attribute count : Integer = 0; + + entry; then init; + state init; + state work parallel { + state left { + state a; + transition first a then done; + } + state right { + state b; + transition first b then done; + } + } + fork split; + + transition first init then split; + transition first split do { assign count := count + 1; } then a; + transition first split do { assign count := count + 10; } then b; + transition first work then done; + exit { assign seen := count; } + } + + transition first outer then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_branch_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.check.expected.json new file mode 100644 index 0000000000..aa809abf55 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.check.expected.json @@ -0,0 +1,14 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"T1.1(effect) T1.2(effect) work(entry) \"", + "\"T1.1(effect) work(entry) T1.2(effect) \"", + "\"T1.2(effect) T1.1(effect) work(entry) \"", + "\"T1.2(effect) work(entry) T1.1(effect) \"" + ] + }, + "agreed": { + "finalState": "a+b" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_branch_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.declared.trace.golden new file mode 100644 index 0000000000..dfbec59d20 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.declared.trace.golden @@ -0,0 +1,22 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1.1(effect) " +choice fork split: next work(entry), split->b(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1.1(effect) work(entry) " +enter: a +stmt action body + stmt assign log + eval feature log -> "T1.1(effect) work(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1.1(effect) work(entry) T1.2(effect) " +enter: b +transition: idle -> split diff --git a/internal/exec/runtime/testdata/conformance/state_fork_branch_order.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.expected.json new file mode 100644 index 0000000000..37b1026fc0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.expected.json @@ -0,0 +1,48 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + } + ], + "outcomes": [ + { + "finalState": "a+b", + "outputs": { + "log": { + "type": "String", + "value": "T1.1(effect) T1.2(effect) work(entry) " + } + } + }, + { + "finalState": "a+b", + "outputs": { + "log": { + "type": "String", + "value": "T1.1(effect) work(entry) T1.2(effect) " + } + } + }, + { + "finalState": "a+b", + "outputs": { + "log": { + "type": "String", + "value": "T1.2(effect) T1.1(effect) work(entry) " + } + } + }, + { + "finalState": "a+b", + "outputs": { + "log": { + "type": "String", + "value": "T1.2(effect) work(entry) T1.1(effect) " + } + } + } + ], + "admissible": "Regions of a parallel state entered on one occurrence: each is entered, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_branch_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.seed-1.trace.golden new file mode 100644 index 0000000000..f615edfaed --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.seed-1.trace.golden @@ -0,0 +1,23 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->b(effect) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1.2(effect) " +choice fork split: next split->a(effect), work(entry) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1.2(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1.2(effect) T1.1(effect) " +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1.2(effect) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1.2(effect) T1.1(effect) work(entry) " +choice fork split: next a(entry), b(entry) (unordered; took a(entry) first) +enter: a +enter: b +transition: idle -> split diff --git a/internal/exec/runtime/testdata/conformance/state_fork_branch_order.sysml b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.sysml new file mode 100644 index 0000000000..a8fc3297d8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.sysml @@ -0,0 +1,28 @@ +// A fork's branches into the regions of one parallel state are drawn one unit +// at a time: each branch's effect, then the owner's entry — performed once, by +// whichever branch is drawn first — then the branch's target. The owner's +// entry follows the first branch effect and precedes both targets, so the four +// linearizations are the admissible set. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + entry { assign log := log + "work(entry) "; } + state left { + state a; + } + state right { + state b; + } + } + fork split; + + transition first idle accept Go then split; + transition first split do { assign log := log + "T1.1(effect) "; } then a; + transition first split do { assign log := log + "T1.2(effect) "; } then b; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_branch_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.trace.golden new file mode 100644 index 0000000000..dfbec59d20 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_branch_order.trace.golden @@ -0,0 +1,22 @@ +exit: idle +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1.1(effect) " +choice fork split: next work(entry), split->b(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1.1(effect) work(entry) " +enter: a +stmt action body + stmt assign log + eval feature log -> "T1.1(effect) work(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1.1(effect) work(entry) T1.2(effect) " +enter: b +transition: idle -> split diff --git a/internal/exec/runtime/testdata/conformance/state_fork_completes_nested_owner.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_completes_nested_owner.expected.json new file mode 100644 index 0000000000..38e64cfa12 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_completes_nested_owner.expected.json @@ -0,0 +1,21 @@ +{ + "type": "state", + "schedule": "reverse", + "finalState": "done", + "stateVisits": [ + "idle", + "stage", + "work", + "done", + "done", + "done", + "done" + ], + "outputs": { + "log": { + "type": "String", + "value": "T1(effect) T1.1(effect) stage(entry) work(entry) T1.2(effect) work(exit) T2(effect) stage(exit) T3(effect) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_completes_nested_owner.sysml b/internal/exec/runtime/testdata/conformance/state_fork_completes_nested_owner.sysml new file mode 100644 index 0000000000..fbb5357007 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_completes_nested_owner.sysml @@ -0,0 +1,32 @@ +// A fork entered from outside an enclosing composite state completes the +// regions' owner inside it: the owner's completion transition fires and reaches +// the enclosing state's `done`, whose own completion transition then fires. +package ForkCompletesNestedOwner { + private import ScalarValues::*; + + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state stage { + entry { assign log := log + "stage(entry) "; } + exit { assign log := log + "stage(exit) "; } + state work parallel { + entry { assign log := log + "work(entry) "; } + exit { assign log := log + "work(exit) "; } + state left { + fork split; + transition first split do { assign log := log + "T1.1(effect) "; } then done; + } + state right { + transition first split do { assign log := log + "T1.2(effect) "; } then done; + } + } + transition first work do { assign log := log + "T2(effect) "; } then done; + } + + transition first idle do { assign log := log + "T1(effect) "; } then split; + transition first stage do { assign log := log + "T3(effect) "; } then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_completes_nested_owner.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_completes_nested_owner.trace.golden new file mode 100644 index 0000000000..ca722ae80e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_completes_nested_owner.trace.golden @@ -0,0 +1,64 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "T1(effect) " +choice fork split: next split->left.done(effect), split->right.done(effect) (unordered; took split->left.done(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1(effect) T1.1(effect) " +choice fork split: next stage(entry), split->right.done(effect) (unordered; took stage(entry) first) +enter: stage (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) " + eval literal "stage(entry) " -> "stage(entry) " + eval operator + -> "T1(effect) T1.1(effect) stage(entry) " +choice fork split: next work(entry), split->right.done(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) stage(entry) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1(effect) T1.1(effect) stage(entry) work(entry) " +enter: done +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) stage(entry) work(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1(effect) T1.1(effect) stage(entry) work(entry) T1.2(effect) " +enter: done +transition: idle -> split +choice exiting work: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) +exit: done +exit: done +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) stage(entry) work(entry) T1.2(effect) " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "T1(effect) T1.1(effect) stage(entry) work(entry) T1.2(effect) work(exit) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) stage(entry) work(entry) T1.2(effect) work(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "T1(effect) T1.1(effect) stage(entry) work(entry) T1.2(effect) work(exit) T2(effect) " +enter: done +transition: work -> done +exit: done +exit: stage (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) stage(entry) work(entry) T1.2(effect) work(exit) T2(effect) " + eval literal "stage(exit) " -> "stage(exit) " + eval operator + -> "T1(effect) T1.1(effect) stage(entry) work(entry) T1.2(effect) work(exit) T2(effect) stage(exit) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) stage(entry) work(entry) T1.2(effect) work(exit) T2(effect) stage(exit) " + eval literal "T3(effect) " -> "T3(effect) " + eval operator + -> "T1(effect) T1.1(effect) stage(entry) work(entry) T1.2(effect) work(exit) T2(effect) stage(exit) T3(effect) " +enter: done +transition: done -> done diff --git a/internal/exec/runtime/testdata/conformance/state_fork_completes_owner.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_completes_owner.expected.json new file mode 100644 index 0000000000..0802bdf3ba --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_completes_owner.expected.json @@ -0,0 +1,19 @@ +{ + "type": "state", + "schedule": "reverse", + "finalState": "done", + "stateVisits": [ + "idle", + "work", + "done", + "done", + "done" + ], + "outputs": { + "log": { + "type": "String", + "value": "T1(effect) T1.1(effect) work(entry) T1.2(effect) work(exit) T2(effect) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_completes_owner.sysml b/internal/exec/runtime/testdata/conformance/state_fork_completes_owner.sysml new file mode 100644 index 0000000000..eb0cb0abc9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_completes_owner.sysml @@ -0,0 +1,27 @@ +// A fork whose branches all end in `done` completes the composite state that +// owns the regions as the branches enter: its completion transition fires +// without any further occurrence, and the machine completes through its `done`. +package ForkCompletesOwner { + private import ScalarValues::*; + + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + entry { assign log := log + "work(entry) "; } + exit { assign log := log + "work(exit) "; } + state left { + fork split; + transition first split do { assign log := log + "T1.1(effect) "; } then done; + } + state right { + transition first split do { assign log := log + "T1.2(effect) "; } then done; + } + } + + transition first idle do { assign log := log + "T1(effect) "; } then split; + transition first work do { assign log := log + "T2(effect) "; } then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_completes_owner.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_completes_owner.trace.golden new file mode 100644 index 0000000000..8f72652428 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_completes_owner.trace.golden @@ -0,0 +1,43 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "T1(effect) " +choice fork split: next split->left.done(effect), split->right.done(effect) (unordered; took split->left.done(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1(effect) T1.1(effect) " +choice fork split: next work(entry), split->right.done(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) " +enter: done +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) T1.2(effect) " +enter: done +transition: idle -> split +choice exiting work: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) +exit: done +exit: done +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) T1.2(effect) " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) T1.2(effect) work(exit) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) T1.2(effect) work(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) T1.2(effect) work(exit) T2(effect) " +enter: done +transition: work -> done diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_parallel_state.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_parallel_state.expected.json new file mode 100644 index 0000000000..7a37a9f024 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_parallel_state.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "schedule": "reverse", + "finalState": "inner+a+q0+b", + "stateVisits": ["idle", "work", "inner", "a", "q0", "b"], + "outputs": { + "log": { + "type": "String", + "value": "T1(effect) T1.1(effect) work(entry) inner(entry) a(entry) q0(entry) T1.2(effect) b(entry) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_parallel_state.sysml b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_parallel_state.sysml new file mode 100644 index 0000000000..836e4c4724 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_parallel_state.sysml @@ -0,0 +1,42 @@ +// A fork's branch may target a state inside a parallel state nested in the +// region it enters: the nested state's region the target lies in starts there, +// its initial state bypassed, and its other regions start as usual. +package ForkEntersNestedParallelState { + private import ScalarValues::*; + + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + entry { assign log := log + "work(entry) "; } + state left { + entry; then l0; + state l0 { entry { assign log := log + "l0(entry) "; } } + state inner parallel { + entry { assign log := log + "inner(entry) "; } + state p { + entry; then p0; + state p0 { entry { assign log := log + "p0(entry) "; } } + state a { entry { assign log := log + "a(entry) "; } } + } + state q { + entry; then q0; + state q0 { entry { assign log := log + "q0(entry) "; } } + } + } + } + state right { + entry; then r0; + state r0 { entry { assign log := log + "r0(entry) "; } } + state b { entry { assign log := log + "b(entry) "; } } + } + } + fork split; + + transition first idle do { assign log := log + "T1(effect) "; } then split; + transition first split do { assign log := log + "T1.1(effect) "; } then a; + transition first split do { assign log := log + "T1.2(effect) "; } then b; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_parallel_state.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_parallel_state.trace.golden new file mode 100644 index 0000000000..557351e656 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_parallel_state.trace.golden @@ -0,0 +1,52 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "T1(effect) " +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1(effect) T1.1(effect) " +choice fork split: next work(entry), split->b(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) " +choice fork split: next inner(entry), split->b(effect) (unordered; took inner(entry) first) +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) " + eval literal "inner(entry) " -> "inner(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) inner(entry) " +choice fork split: next a(entry), q0(entry), split->b(effect) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) inner(entry) " + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) inner(entry) a(entry) " +choice fork split: next q0(entry), split->b(effect) (unordered; took q0(entry) first) +enter: q0 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) inner(entry) a(entry) " + eval literal "q0(entry) " -> "q0(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) inner(entry) a(entry) q0(entry) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) inner(entry) a(entry) q0(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) inner(entry) a(entry) q0(entry) T1.2(effect) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) inner(entry) a(entry) q0(entry) T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) inner(entry) a(entry) q0(entry) T1.2(effect) b(entry) " +transition: idle -> split diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_region_states.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_region_states.expected.json new file mode 100644 index 0000000000..3e1486d1f9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_region_states.expected.json @@ -0,0 +1,20 @@ +{ + "type": "state", + "schedule": "reverse", + "finalState": "x0+a+b", + "stateVisits": [ + "idle", + "work", + "wrapper", + "a", + "b", + "x0" + ], + "outputs": { + "log": { + "type": "String", + "value": "T1(effect) T1.1(effect) work(entry) wrapper(entry) a(entry) T1.2(effect) b(entry) x0(entry) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_region_states.sysml b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_region_states.sysml new file mode 100644 index 0000000000..fc2c2c521f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_region_states.sysml @@ -0,0 +1,41 @@ +// A fork's branches may target states nested below a region's own substates: +// the region is the one the target lies in, transitively, and the branch enters +// every state on the way down to it, each initial state on that way bypassed. A +// region no branch names still starts at its own initial state. +package ForkEntersNestedRegionStates { + private import ScalarValues::*; + + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + entry { assign log := log + "work(entry) "; } + state left { + entry; then l0; + state l0 { entry { assign log := log + "l0(entry) "; } } + state wrapper { + entry { assign log := log + "wrapper(entry) "; } + entry; then w0; + state w0 { entry { assign log := log + "w0(entry) "; } } + state a { entry { assign log := log + "a(entry) "; } } + } + } + state right { + entry; then r0; + state r0 { entry { assign log := log + "r0(entry) "; } } + state b { entry { assign log := log + "b(entry) "; } } + } + state aux { + entry; then x0; + state x0 { entry { assign log := log + "x0(entry) "; } } + } + } + fork split; + + transition first idle do { assign log := log + "T1(effect) "; } then split; + transition first split do { assign log := log + "T1.1(effect) "; } then a; + transition first split do { assign log := log + "T1.2(effect) "; } then b; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_region_states.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_region_states.trace.golden new file mode 100644 index 0000000000..bca4325cf3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_nested_region_states.trace.golden @@ -0,0 +1,53 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "T1(effect) " +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1(effect) T1.1(effect) " +choice fork split: next work(entry), split->b(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) " +choice fork split: next wrapper(entry), split->b(effect), x0(entry) (unordered; took wrapper(entry) first) +enter: wrapper (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) " + eval literal "wrapper(entry) " -> "wrapper(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) wrapper(entry) " +choice fork split: next a(entry), split->b(effect), x0(entry) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) wrapper(entry) " + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) wrapper(entry) a(entry) " +choice fork split: next split->b(effect), x0(entry) (unordered; took split->b(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) wrapper(entry) a(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) wrapper(entry) a(entry) T1.2(effect) " +choice fork split: next b(entry), x0(entry) (unordered; took b(entry) first) +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) wrapper(entry) a(entry) T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) wrapper(entry) a(entry) T1.2(effect) b(entry) " +enter: x0 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) wrapper(entry) a(entry) T1.2(effect) b(entry) " + eval literal "x0(entry) " -> "x0(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) wrapper(entry) a(entry) T1.2(effect) b(entry) x0(entry) " +transition: idle -> split diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.check.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.check.expected.json new file mode 100644 index 0000000000..aca4b349bd --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.check.expected.json @@ -0,0 +1,26 @@ +{ + "verdict": "divergent", + "divergent": {"log": [ + "\"T1(effect) T1.1(effect) T1.2(effect) work(entry) a(entry) b(entry) a(exit) b(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.1(effect) T1.2(effect) work(entry) a(entry) b(entry) b(exit) a(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.1(effect) T1.2(effect) work(entry) b(entry) a(entry) a(exit) b(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.1(effect) T1.2(effect) work(entry) b(entry) a(entry) b(exit) a(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.1(effect) work(entry) T1.2(effect) a(entry) b(entry) a(exit) b(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.1(effect) work(entry) T1.2(effect) a(entry) b(entry) b(exit) a(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.1(effect) work(entry) T1.2(effect) b(entry) a(entry) a(exit) b(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.1(effect) work(entry) T1.2(effect) b(entry) a(entry) b(exit) a(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) b(exit) a(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) a(exit) b(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) b(exit) a(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.2(effect) T1.1(effect) work(entry) b(entry) a(entry) a(exit) b(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.2(effect) T1.1(effect) work(entry) b(entry) a(entry) b(exit) a(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.2(effect) work(entry) T1.1(effect) a(entry) b(entry) a(exit) b(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.2(effect) work(entry) T1.1(effect) a(entry) b(entry) b(exit) a(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.2(effect) work(entry) T1.1(effect) b(entry) a(entry) a(exit) b(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.2(effect) work(entry) T1.1(effect) b(entry) a(entry) b(exit) a(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.2(effect) work(entry) b(entry) T1.1(effect) a(entry) a(exit) b(exit) work(exit) T2(effect) \"", + "\"T1(effect) T1.2(effect) work(entry) b(entry) T1.1(effect) a(entry) b(exit) a(exit) work(exit) T2(effect) \"" + ]}, + "agreed": {"finalState": "done"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.declared.trace.golden new file mode 100644 index 0000000000..5df1ef7742 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.declared.trace.golden @@ -0,0 +1,64 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "T1(effect) " +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1(effect) T1.1(effect) " +choice fork split: next work(entry), split->b(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) " +choice fork split: next a(entry), split->b(effect) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) " + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) " +transition: idle -> split +choice join sync: states a, b react (unordered; took a first) +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) " + eval literal "a(exit) " -> "a(exit) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) " +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) " + eval literal "b(exit) " -> "b(exit) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) " +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) work(exit) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) work(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) work(exit) T2(effect) " +enter: done +transition: work -> done diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.expected.json new file mode 100644 index 0000000000..6b244c25be --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.expected.json @@ -0,0 +1,127 @@ +{ + "type": "state", + "trace": true, + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.1(effect) T1.2(effect) work(entry) a(entry) b(entry) a(exit) b(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.1(effect) T1.2(effect) work(entry) a(entry) b(entry) b(exit) a(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.1(effect) T1.2(effect) work(entry) b(entry) a(entry) a(exit) b(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.1(effect) T1.2(effect) work(entry) b(entry) a(entry) b(exit) a(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.1(effect) work(entry) T1.2(effect) a(entry) b(entry) a(exit) b(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.1(effect) work(entry) T1.2(effect) a(entry) b(entry) b(exit) a(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.1(effect) work(entry) T1.2(effect) b(entry) a(entry) a(exit) b(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.1(effect) work(entry) T1.2(effect) b(entry) a(entry) b(exit) a(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) b(exit) a(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) a(exit) b(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) b(exit) a(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.2(effect) T1.1(effect) work(entry) b(entry) a(entry) a(exit) b(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.2(effect) T1.1(effect) work(entry) b(entry) a(entry) b(exit) a(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.2(effect) work(entry) T1.1(effect) a(entry) b(entry) a(exit) b(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.2(effect) work(entry) T1.1(effect) a(entry) b(entry) b(exit) a(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.2(effect) work(entry) T1.1(effect) b(entry) a(entry) a(exit) b(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.2(effect) work(entry) T1.1(effect) b(entry) a(entry) b(exit) a(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.2(effect) work(entry) b(entry) T1.1(effect) a(entry) a(exit) b(exit) work(exit) T2(effect) "} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "T1(effect) T1.2(effect) work(entry) b(entry) T1.1(effect) a(entry) b(exit) a(exit) work(exit) T2(effect) "} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.seed-1.trace.golden new file mode 100644 index 0000000000..eb6e526517 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.seed-1.trace.golden @@ -0,0 +1,64 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "T1(effect) " +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->b(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1(effect) T1.2(effect) " +choice fork split: next split->a(effect), work(entry) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.2(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1(effect) T1.2(effect) T1.1(effect) " +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.2(effect) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) " +choice fork split: next a(entry), b(entry) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) " + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) " +transition: idle -> split +choice join sync: states a, b react (unordered; took a first) +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) " + eval literal "a(exit) " -> "a(exit) " + eval operator + -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) a(exit) " +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) a(exit) " + eval literal "b(exit) " -> "b(exit) " + eval operator + -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) a(exit) b(exit) " +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) a(exit) b(exit) " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) a(exit) b(exit) work(exit) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) a(exit) b(exit) work(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "T1(effect) T1.2(effect) T1.1(effect) work(entry) a(entry) b(entry) a(exit) b(exit) work(exit) T2(effect) " +enter: done +transition: work -> done diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.sysml b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.sysml new file mode 100644 index 0000000000..baade62700 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.sysml @@ -0,0 +1,27 @@ +package ForkEntersRegionsWithoutInitial { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + entry { assign log := log + "work(entry) "; } + exit { assign log := log + "work(exit) "; } + state left { + state a { entry { assign log := log + "a(entry) "; } exit { assign log := log + "a(exit) "; } } + } + state right { + state b { entry { assign log := log + "b(entry) "; } exit { assign log := log + "b(exit) "; } } + } + } + fork split; + join sync; + + transition first idle do { assign log := log + "T1(effect) "; } then split; + transition first split do { assign log := log + "T1.1(effect) "; } then a; + transition first split do { assign log := log + "T1.2(effect) "; } then b; + transition first a then sync; + transition first b then sync; + transition first sync do { assign log := log + "T2(effect) "; } then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.trace.golden new file mode 100644 index 0000000000..5df1ef7742 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_enters_regions_without_initial.trace.golden @@ -0,0 +1,64 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "T1(effect) " +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1(effect) T1.1(effect) " +choice fork split: next work(entry), split->b(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) " +choice fork split: next a(entry), split->b(effect) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) " + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) " +transition: idle -> split +choice join sync: states a, b react (unordered; took a first) +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) " + eval literal "a(exit) " -> "a(exit) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) " +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) " + eval literal "b(exit) " -> "b(exit) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) " +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) work(exit) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) work(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) a(exit) b(exit) work(exit) T2(effect) " +enter: done +transition: work -> done diff --git a/internal/exec/runtime/testdata/conformance/state_fork_from_within_nested_owner_regions.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_from_within_nested_owner_regions.expected.json new file mode 100644 index 0000000000..d381dba9e3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_from_within_nested_owner_regions.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "schedule": "reverse", + "events": [{"signal": "Go"}], + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) work(exit) T2(effect) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_from_within_nested_owner_regions.sysml b/internal/exec/runtime/testdata/conformance/state_fork_from_within_nested_owner_regions.sysml new file mode 100644 index 0000000000..fb24077a82 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_from_within_nested_owner_regions.sysml @@ -0,0 +1,55 @@ +// The fork fires from `a`, active inside the parallel state `inner` in `work`'s +// region `left`, back into `work`'s own regions: `work` stays active while +// `inner` and its substates exit with `b`, and the branches enter `c` and `d`. +package ForkFromWithinNestedOwnerRegions { + private import ScalarValues::*; + + state Machine { + attribute log : String = ""; + + entry; then work; + state work parallel { + entry { assign log := log + "work(entry) "; } + exit { assign log := log + "work(exit) "; } + state left { + entry; then inner; + state inner parallel { + entry { assign log := log + "inner(entry) "; } + exit { assign log := log + "inner(exit) "; } + state p { + entry; then a; + state a { + entry { assign log := log + "a(entry) "; } + exit { assign log := log + "a(exit) "; } + } + } + state q { + entry; then a2; + state a2 { + entry { assign log := log + "a2(entry) "; } + exit { assign log := log + "a2(exit) "; } + } + } + } + state c { entry { assign log := log + "c(entry) "; } } + transition first c then done; + } + state right { + entry; then b; + state b { + entry { assign log := log + "b(entry) "; } + exit { assign log := log + "b(exit) "; } + } + state d { entry { assign log := log + "d(entry) "; } } + transition first d then done; + } + } + fork split; + + transition first a accept Go do { assign log := log + "T1(effect) "; } then split; + transition first split do { assign log := log + "T1.1(effect) "; } then c; + transition first split do { assign log := log + "T1.2(effect) "; } then d; + transition first work do { assign log := log + "T2(effect) "; } then done; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_from_within_nested_owner_regions.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_from_within_nested_owner_regions.trace.golden new file mode 100644 index 0000000000..e34bc16fa7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_from_within_nested_owner_regions.trace.golden @@ -0,0 +1,79 @@ +choice exiting work: next a(exit), a2(exit), b(exit) (unordered; took a(exit) first) +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) " + eval literal "a(exit) " -> "a(exit) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) " +choice exiting work: next a2(exit), b(exit) (unordered; took a2(exit) first) +exit: a2 (exit action) +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) " + eval literal "a2(exit) " -> "a2(exit) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) " +choice exiting work: next inner(exit), b(exit) (unordered; took inner(exit) first) +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) " + eval literal "inner(exit) " -> "inner(exit) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) " +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) " + eval literal "b(exit) " -> "b(exit) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) " +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) " + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) " +choice fork split: next split->c(effect), split->d(effect) (unordered; took split->c(effect) first) +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) " +choice fork split: next c(entry), split->d(effect) (unordered; took c(entry) first) +enter: c (entry action) +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) " + eval literal "c(entry) " -> "c(entry) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) c(entry) " +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) c(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) " +enter: d (entry action) +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) " + eval literal "d(entry) " -> "d(entry) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) " +transition: a -> split +exit: c +enter: done +transition: c -> done +exit: d +enter: done +transition: d -> done +choice exiting work: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) +exit: done +exit: done +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) work(exit) " +stmt action body + stmt assign log + eval feature log -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) work(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "work(entry) inner(entry) a(entry) a2(entry) b(entry) a(exit) a2(exit) inner(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) work(exit) T2(effect) " +enter: done +transition: work -> done diff --git a/internal/exec/runtime/testdata/conformance/state_fork_from_within_owner_regions.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_from_within_owner_regions.expected.json new file mode 100644 index 0000000000..e853db0ce9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_from_within_owner_regions.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "schedule": "reverse", + "events": [{"signal": "Go"}], + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) work(exit) T2(effect) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_from_within_owner_regions.sysml b/internal/exec/runtime/testdata/conformance/state_fork_from_within_owner_regions.sysml new file mode 100644 index 0000000000..11ab684499 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_from_within_owner_regions.sysml @@ -0,0 +1,48 @@ +// The fork fires from state `a` of region `left` back into `work`'s own regions: +// `work` stays active, every active state its regions hold exits — `a` and `b`, +// in region order, `b`'s do behavior with it — and the branches enter `c` and `d`. +package ForkFromWithinOwnerRegions { + private import ScalarValues::*; + + state Machine { + attribute log : String = ""; + + entry; then work; + state work parallel { + entry { assign log := log + "work(entry) "; } + exit { assign log := log + "work(exit) "; } + state left { + entry; then a; + state a { + entry { assign log := log + "a(entry) "; } + exit { assign log := log + "a(exit) "; } + } + state c { entry { assign log := log + "c(entry) "; } } + transition first c then done; + } + state right { + entry; then b; + state b { + entry { assign log := log + "b(entry) "; } + do action poll { + first start; + then action wait accept n : Never; + then action note assign log := log + "b(do) "; + then done; + } + exit { assign log := log + "b(exit) "; } + } + state d { entry { assign log := log + "d(entry) "; } } + transition first d then done; + } + } + fork split; + + transition first a accept Go do { assign log := log + "T1(effect) "; } then split; + transition first split do { assign log := log + "T1.1(effect) "; } then c; + transition first split do { assign log := log + "T1.2(effect) "; } then d; + transition first work do { assign log := log + "T2(effect) "; } then done; + } + attribute def Go; + attribute def Never; +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_from_within_owner_regions.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_from_within_owner_regions.trace.golden new file mode 100644 index 0000000000..a39ff6f3d7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_from_within_owner_regions.trace.golden @@ -0,0 +1,68 @@ +do: b +stmt action body +enter action node: state behavior poll +choice exiting work: next a(exit), b(exit) (unordered; took a(exit) first) +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "work(entry) a(entry) b(entry) " + eval literal "a(exit) " -> "a(exit) " + eval operator + -> "work(entry) a(entry) b(entry) a(exit) " +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "work(entry) a(entry) b(entry) a(exit) " + eval literal "b(exit) " -> "b(exit) " + eval operator + -> "work(entry) a(entry) b(entry) a(exit) b(exit) " +stmt action body + stmt assign log + eval feature log -> "work(entry) a(entry) b(entry) a(exit) b(exit) " + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) " +choice fork split: next split->c(effect), split->d(effect) (unordered; took split->c(effect) first) +stmt action body + stmt assign log + eval feature log -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) " +choice fork split: next c(entry), split->d(effect) (unordered; took c(entry) first) +enter: c (entry action) +stmt action body + stmt assign log + eval feature log -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) " + eval literal "c(entry) " -> "c(entry) " + eval operator + -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) c(entry) " +stmt action body + stmt assign log + eval feature log -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) c(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) " +enter: d (entry action) +stmt action body + stmt assign log + eval feature log -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) " + eval literal "d(entry) " -> "d(entry) " + eval operator + -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) " +transition: a -> split +exit: c +enter: done +transition: c -> done +exit: d +enter: done +transition: d -> done +choice exiting work: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) +exit: done +exit: done +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) work(exit) " +stmt action body + stmt assign log + eval feature log -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) work(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "work(entry) a(entry) b(entry) a(exit) b(exit) T1(effect) T1.1(effect) c(entry) T1.2(effect) d(entry) work(exit) T2(effect) " +enter: done +transition: work -> done diff --git a/internal/exec/runtime/testdata/conformance/state_fork_in_composite_enters_parallel_substate.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_in_composite_enters_parallel_substate.expected.json new file mode 100644 index 0000000000..900cf6e6b1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_in_composite_enters_parallel_substate.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "schedule": "reverse", + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "T1(effect) outer(entry) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) T2(effect) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_in_composite_enters_parallel_substate.sysml b/internal/exec/runtime/testdata/conformance/state_fork_in_composite_enters_parallel_substate.sysml new file mode 100644 index 0000000000..119e83f397 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_in_composite_enters_parallel_substate.sysml @@ -0,0 +1,32 @@ +package ForkInCompositeEntersParallelSubstate { + // The fork's own parent `outer` is entered before its branches fire; the + // parallel state `work` below it is entered by the first branch, after that + // branch's effect, and the regions start at the branch targets (no `entry`). + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state outer { + entry { assign log := log + "outer(entry) "; } + fork split; + state work parallel { + entry { assign log := log + "work(entry) "; } + state left { + state a { entry { assign log := log + "a(entry) "; } } + transition first a then done; + } + state right { + state b { entry { assign log := log + "b(entry) "; } } + transition first b then done; + } + } + transition first split do { assign log := log + "T1.1(effect) "; } then a; + transition first split do { assign log := log + "T1.2(effect) "; } then b; + transition first work then done; + } + + transition first idle do { assign log := log + "T1(effect) "; } then split; + transition first outer do { assign log := log + "T2(effect) "; } then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_in_composite_enters_parallel_substate.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_in_composite_enters_parallel_substate.trace.golden new file mode 100644 index 0000000000..3286dbf4e6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_in_composite_enters_parallel_substate.trace.golden @@ -0,0 +1,65 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "T1(effect) " +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) " + eval literal "outer(entry) " -> "outer(entry) " + eval operator + -> "T1(effect) outer(entry) " +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) outer(entry) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1(effect) outer(entry) T1.1(effect) " +choice fork split: next work(entry), split->b(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) outer(entry) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1(effect) outer(entry) T1.1(effect) work(entry) " +choice fork split: next a(entry), split->b(effect) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) outer(entry) T1.1(effect) work(entry) " + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "T1(effect) outer(entry) T1.1(effect) work(entry) a(entry) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) outer(entry) T1.1(effect) work(entry) a(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1(effect) outer(entry) T1.1(effect) work(entry) a(entry) T1.2(effect) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) outer(entry) T1.1(effect) work(entry) a(entry) T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "T1(effect) outer(entry) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) " +transition: idle -> split +exit: a +enter: done +transition: a -> done +exit: b +enter: done +transition: b -> done +choice exiting work: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) +exit: done +exit: done +exit: work +enter: done +transition: work -> done +exit: done +exit: outer +stmt action body + stmt assign log + eval feature log -> "T1(effect) outer(entry) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "T1(effect) outer(entry) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) T2(effect) " +enter: done +transition: done -> done diff --git a/internal/core/runtime/testdata/conformance/state_fork_join_pseudostate.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_join_pseudostate.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_fork_join_pseudostate.expected.json rename to internal/exec/runtime/testdata/conformance/state_fork_join_pseudostate.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_fork_join_pseudostate.sysml b/internal/exec/runtime/testdata/conformance/state_fork_join_pseudostate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_fork_join_pseudostate.sysml rename to internal/exec/runtime/testdata/conformance/state_fork_join_pseudostate.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_fork_join_pseudostate.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_join_pseudostate.trace.golden new file mode 100644 index 0000000000..a657d8bdd2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_join_pseudostate.trace.golden @@ -0,0 +1,21 @@ +exit: init +enter: ready +transition: init -> ready +exit: ready +choice fork split: next split->building(effect), split->checking(effect) (unordered; took split->building(effect) first) +enter: working +enter: building (entry action) +stmt action body + stmt assign leftDone + eval literal 1 -> 1 +enter: checking (entry action) +stmt action body + stmt assign rightDone + eval literal 1 -> 1 +transition: ready -> split +choice join sync: states building, checking react (unordered; took building first) +exit: building +exit: checking +exit: working +enter: done +transition: working -> done diff --git a/internal/exec/runtime/testdata/conformance/state_fork_omitted_region_declared_first.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_omitted_region_declared_first.expected.json new file mode 100644 index 0000000000..d04ac7e3f9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_omitted_region_declared_first.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "schedule": "reverse", + "finalState": "x0+a+b", + "stateVisits": ["idle", "work", "x0", "a", "b"], + "outputs": { + "log": { + "type": "String", + "value": "T1(effect) T1.1(effect) work(entry) x0(entry) a(entry) T1.2(effect) b(entry) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_omitted_region_declared_first.sysml b/internal/exec/runtime/testdata/conformance/state_fork_omitted_region_declared_first.sysml new file mode 100644 index 0000000000..096506860c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_omitted_region_declared_first.sysml @@ -0,0 +1,31 @@ +// A region a fork leaves out, declared before the regions its branches enter, +// does not bring the composite in ahead of the branches: the first branch's +// effect runs, then the composite and its regions enter in declaration order. +package ForkOmittedRegionDeclaredFirst { + private import ScalarValues::*; + + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + entry { assign log := log + "work(entry) "; } + state aux { + entry; then x0; + state x0 { entry { assign log := log + "x0(entry) "; } } + } + state left { + state a { entry { assign log := log + "a(entry) "; } } + } + state right { + state b { entry { assign log := log + "b(entry) "; } } + } + } + fork split; + + transition first idle do { assign log := log + "T1(effect) "; } then split; + transition first split do { assign log := log + "T1.1(effect) "; } then a; + transition first split do { assign log := log + "T1.2(effect) "; } then b; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_omitted_region_declared_first.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_omitted_region_declared_first.trace.golden new file mode 100644 index 0000000000..daec92a06b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_omitted_region_declared_first.trace.golden @@ -0,0 +1,45 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "T1(effect) " +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "T1(effect) T1.1(effect) " +choice fork split: next work(entry), split->b(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) " +choice fork split: next x0(entry), a(entry), split->b(effect) (unordered; took x0(entry) first) +enter: x0 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) " + eval literal "x0(entry) " -> "x0(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) x0(entry) " +choice fork split: next a(entry), split->b(effect) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) x0(entry) " + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) x0(entry) a(entry) " +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) x0(entry) a(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) x0(entry) a(entry) T1.2(effect) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect) T1.1(effect) work(entry) x0(entry) a(entry) T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "T1(effect) T1.1(effect) work(entry) x0(entry) a(entry) T1.2(effect) b(entry) " +transition: idle -> split diff --git a/internal/exec/runtime/testdata/conformance/state_fork_through_inactive_ancestors.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_through_inactive_ancestors.expected.json new file mode 100644 index 0000000000..6b29edc674 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_through_inactive_ancestors.expected.json @@ -0,0 +1,15 @@ +{ + "type": "state", + "schedule": "reverse", + "events": [ + {"signal": "Stop"} + ], + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) T1.2(effect) b(entry) p(exit) outer(exit) T2(effect) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_through_inactive_ancestors.sysml b/internal/exec/runtime/testdata/conformance/state_fork_through_inactive_ancestors.sysml new file mode 100644 index 0000000000..e85308632e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_through_inactive_ancestors.sysml @@ -0,0 +1,51 @@ +package ForkThroughInactiveAncestors { + // A machine-level fork enters `outer.work`, two states below the source's level, + // while `outer` is not active: `outer` is entered on the way without starting + // `waiting`, the state its entry transition names, and its sibling region + // `aside` starts as usual; `work` starts at the branch targets. + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle { + exit { assign log := log + "idle(exit) "; } + } + fork split; + state outer parallel { + entry { assign log := log + "outer(entry) "; } + exit { assign log := log + "outer(exit) "; } + state main { + entry; then waiting; + state waiting { + entry { assign log := log + "waiting(entry) "; } + exit { assign log := log + "waiting(exit) "; } + } + state work parallel { + entry { assign log := log + "work(entry) "; } + state left { + state a { entry { assign log := log + "a(entry) "; } } + transition first a then done; + } + state right { + state b { entry { assign log := log + "b(entry) "; } } + transition first b then done; + } + } + transition first work then done; + } + state aside { + entry; then p; + state p { + entry { assign log := log + "p(entry) "; } + exit { assign log := log + "p(exit) "; } + } + transition first p accept Stop then done; + } + } + transition first idle do { assign log := log + "T1(effect) "; } then split; + transition first split do { assign log := log + "T1.1(effect) "; } then a; + transition first split do { assign log := log + "T1.2(effect) "; } then b; + transition first outer do { assign log := log + "T2(effect) "; } then done; + } + attribute def Stop; +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_through_inactive_ancestors.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_through_inactive_ancestors.trace.golden new file mode 100644 index 0000000000..fd2cdbf3ea --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_through_inactive_ancestors.trace.golden @@ -0,0 +1,93 @@ +exit: idle (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "idle(exit) " -> "idle(exit) " + eval operator + -> "idle(exit) " +stmt action body + stmt assign log + eval feature log -> "idle(exit) " + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "idle(exit) T1(effect) " +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "idle(exit) T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "idle(exit) T1(effect) T1.1(effect) " +choice fork split: next outer(entry), split->b(effect) (unordered; took outer(entry) first) +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "idle(exit) T1(effect) T1.1(effect) " + eval literal "outer(entry) " -> "outer(entry) " + eval operator + -> "idle(exit) T1(effect) T1.1(effect) outer(entry) " +choice fork split: next p(entry), work(entry), split->b(effect) (unordered; took p(entry) first) +enter: p (entry action) +stmt action body + stmt assign log + eval feature log -> "idle(exit) T1(effect) T1.1(effect) outer(entry) " + eval literal "p(entry) " -> "p(entry) " + eval operator + -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) " +choice fork split: next work(entry), split->b(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) " +choice fork split: next a(entry), split->b(effect) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) " + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) " +stmt action body + stmt assign log + eval feature log -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) T1.2(effect) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) T1.2(effect) b(entry) " +transition: idle -> split +exit: a +enter: done +transition: a -> done +exit: b +enter: done +transition: b -> done +choice exiting work: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) +exit: done +exit: done +exit: work +enter: done +transition: work -> done +exit: p (exit action) +stmt action body + stmt assign log + eval feature log -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) T1.2(effect) b(entry) " + eval literal "p(exit) " -> "p(exit) " + eval operator + -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) T1.2(effect) b(entry) p(exit) " +enter: done +transition: p -> done (event: accept Stop) +choice exiting outer: next main.done(exit), aside.done(exit) (unordered; took main.done(exit) first) +exit: done +exit: done +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) T1.2(effect) b(entry) p(exit) " + eval literal "outer(exit) " -> "outer(exit) " + eval operator + -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) T1.2(effect) b(entry) p(exit) outer(exit) " +stmt action body + stmt assign log + eval feature log -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) T1.2(effect) b(entry) p(exit) outer(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "idle(exit) T1(effect) T1.1(effect) outer(entry) p(entry) work(entry) a(entry) T1.2(effect) b(entry) p(exit) outer(exit) T2(effect) " +enter: done +transition: outer -> done diff --git a/internal/exec/runtime/testdata/conformance/state_fork_within_active_ancestor.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_within_active_ancestor.expected.json new file mode 100644 index 0000000000..60aa5bdb69 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_within_active_ancestor.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "schedule": "reverse", + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) outer(exit) T2(effect) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_within_active_ancestor.sysml b/internal/exec/runtime/testdata/conformance/state_fork_within_active_ancestor.sysml new file mode 100644 index 0000000000..e3f29f4fed --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_within_active_ancestor.sysml @@ -0,0 +1,35 @@ +package ForkWithinActiveAncestor { + // The fork fires while `outer`, which declares it, is already active: only + // `idle` is exited, `outer` neither exits nor re-enters, and `work` is + // entered by the first branch with its regions starting at the branch targets. + state Machine { + attribute log : String = ""; + + entry; then outer; + state outer { + entry { assign log := log + "outer(entry) "; } + exit { assign log := log + "outer(exit) "; } + entry; then idle; + state idle { + exit { assign log := log + "idle(exit) "; } + } + fork split; + state work parallel { + entry { assign log := log + "work(entry) "; } + state left { + state a { entry { assign log := log + "a(entry) "; } } + transition first a then done; + } + state right { + state b { entry { assign log := log + "b(entry) "; } } + transition first b then done; + } + } + transition first idle do { assign log := log + "T1(effect) "; } then split; + transition first split do { assign log := log + "T1.1(effect) "; } then a; + transition first split do { assign log := log + "T1.2(effect) "; } then b; + transition first work then done; + } + transition first outer do { assign log := log + "T2(effect) "; } then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_within_active_ancestor.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_within_active_ancestor.trace.golden new file mode 100644 index 0000000000..321b102b18 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_within_active_ancestor.trace.golden @@ -0,0 +1,69 @@ +exit: idle (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) " + eval literal "idle(exit) " -> "idle(exit) " + eval operator + -> "outer(entry) idle(exit) " +stmt action body + stmt assign log + eval feature log -> "outer(entry) idle(exit) " + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "outer(entry) idle(exit) T1(effect) " +choice fork split: next split->a(effect), split->b(effect) (unordered; took split->a(effect) first) +stmt action body + stmt assign log + eval feature log -> "outer(entry) idle(exit) T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "outer(entry) idle(exit) T1(effect) T1.1(effect) " +choice fork split: next work(entry), split->b(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) idle(exit) T1(effect) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) " +choice fork split: next a(entry), split->b(effect) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) " + eval literal "a(entry) " -> "a(entry) " + eval operator + -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) a(entry) " +stmt action body + stmt assign log + eval feature log -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) a(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) " +enter: b (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) " + eval literal "b(entry) " -> "b(entry) " + eval operator + -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) " +transition: idle -> split +exit: a +enter: done +transition: a -> done +exit: b +enter: done +transition: b -> done +choice exiting work: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) +exit: done +exit: done +exit: work +enter: done +transition: work -> done +exit: done +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) " + eval literal "outer(exit) " -> "outer(exit) " + eval operator + -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) outer(exit) " +stmt action body + stmt assign log + eval feature log -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) outer(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "outer(entry) idle(exit) T1(effect) T1.1(effect) work(entry) a(entry) T1.2(effect) b(entry) outer(exit) T2(effect) " +enter: done +transition: done -> done diff --git a/internal/exec/runtime/testdata/conformance/state_fork_within_active_region.expected.json b/internal/exec/runtime/testdata/conformance/state_fork_within_active_region.expected.json new file mode 100644 index 0000000000..3796f10d70 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_within_active_region.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "events": [{"signal": "Go"}], + "finalState": "done", + "outputs": { + "log": { + "type": "String", + "value": "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) T1.2(effect) y(entry) p(exit) outer(exit) T2(effect) " + } + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_within_active_region.sysml b/internal/exec/runtime/testdata/conformance/state_fork_within_active_region.sysml new file mode 100644 index 0000000000..13672aca26 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_within_active_region.sysml @@ -0,0 +1,46 @@ +package ForkWithinActiveRegion { + // The fork fires from state `a` of region `left`, into the parallel state + // `work` declared in the same region: only `a` is exited, the concurrent + // region `right` keeps `p` active and `outer` is neither exited nor re-entered. + state Machine { + attribute log : String = ""; + + entry; then outer; + state outer parallel { + entry { assign log := log + "outer(entry) "; } + exit { assign log := log + "outer(exit) "; } + state left { + entry; then a; + state a { + exit { assign log := log + "a(exit) "; } + } + fork split; + state work parallel { + entry { assign log := log + "work(entry) "; } + state one { + state x { entry { assign log := log + "x(entry) "; } } + transition first x then done; + } + state two { + state y { entry { assign log := log + "y(entry) "; } } + transition first y then done; + } + } + transition first a do { assign log := log + "T1(effect) "; } then split; + transition first split do { assign log := log + "T1.1(effect) "; } then x; + transition first split do { assign log := log + "T1.2(effect) "; } then y; + transition first work then done; + } + state right { + entry; then p; + state p { + entry { assign log := log + "p(entry) "; } + exit { assign log := log + "p(exit) "; } + } + transition first p accept Go then done; + } + } + transition first outer do { assign log := log + "T2(effect) "; } then done; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_fork_within_active_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_fork_within_active_region.trace.golden new file mode 100644 index 0000000000..bc2fa0edda --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_fork_within_active_region.trace.golden @@ -0,0 +1,79 @@ +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) p(entry) " + eval literal "a(exit) " -> "a(exit) " + eval operator + -> "outer(entry) p(entry) a(exit) " +stmt action body + stmt assign log + eval feature log -> "outer(entry) p(entry) a(exit) " + eval literal "T1(effect) " -> "T1(effect) " + eval operator + -> "outer(entry) p(entry) a(exit) T1(effect) " +choice fork split: next split->x(effect), split->y(effect) (unordered; took split->x(effect) first) +stmt action body + stmt assign log + eval feature log -> "outer(entry) p(entry) a(exit) T1(effect) " + eval literal "T1.1(effect) " -> "T1.1(effect) " + eval operator + -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) " +choice fork split: next work(entry), split->y(effect) (unordered; took work(entry) first) +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) " + eval literal "work(entry) " -> "work(entry) " + eval operator + -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) " +choice fork split: next x(entry), split->y(effect) (unordered; took x(entry) first) +enter: x (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) " + eval literal "x(entry) " -> "x(entry) " + eval operator + -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) " +stmt action body + stmt assign log + eval feature log -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) " + eval literal "T1.2(effect) " -> "T1.2(effect) " + eval operator + -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) T1.2(effect) " +enter: y (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) T1.2(effect) " + eval literal "y(entry) " -> "y(entry) " + eval operator + -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) T1.2(effect) y(entry) " +transition: a -> split +exit: x +enter: done +transition: x -> done +exit: y +enter: done +transition: y -> done +choice exiting work: next one.done(exit), two.done(exit) (unordered; took one.done(exit) first) +exit: done +exit: done +exit: work +enter: done +transition: work -> done +exit: p (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) T1.2(effect) y(entry) " + eval literal "p(exit) " -> "p(exit) " + eval operator + -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) T1.2(effect) y(entry) p(exit) " +enter: done +transition: p -> done (event: accept Go) +choice exiting outer: next left.done(exit), right.done(exit) (unordered; took left.done(exit) first) +exit: done +exit: done +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) T1.2(effect) y(entry) p(exit) " + eval literal "outer(exit) " -> "outer(exit) " + eval operator + -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) T1.2(effect) y(entry) p(exit) outer(exit) " +stmt action body + stmt assign log + eval feature log -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) T1.2(effect) y(entry) p(exit) outer(exit) " + eval literal "T2(effect) " -> "T2(effect) " + eval operator + -> "outer(entry) p(entry) a(exit) T1(effect) T1.1(effect) work(entry) x(entry) T1.2(effect) y(entry) p(exit) outer(exit) T2(effect) " +enter: done +transition: outer -> done diff --git a/internal/exec/runtime/testdata/conformance/state_guard_names_transition_not_taken.expected.json b/internal/exec/runtime/testdata/conformance/state_guard_names_transition_not_taken.expected.json new file mode 100644 index 0000000000..1dea9ee69e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_guard_names_transition_not_taken.expected.json @@ -0,0 +1,5 @@ +{ + "type": "state", + "events": [{"signal": "Level", "value": {"type": "Integer", "value": 8}}], + "error": "eval guard of transition raise: type mismatch: operator '<' is not defined for null and an Integer" +} diff --git a/internal/exec/runtime/testdata/conformance/state_guard_names_transition_not_taken.sysml b/internal/exec/runtime/testdata/conformance/state_guard_names_transition_not_taken.sysml new file mode 100644 index 0000000000..30b5f70e5b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_guard_names_transition_not_taken.sysml @@ -0,0 +1,19 @@ +// A guard naming a transition other than the one it guards reads nothing: `lower` +// is not being taken while `raise` is, so `lower.l` is null and comparing it is +// the typed error a null operand raises, rather than a silent false. +package StateGuardNamesTransitionNotTaken { + private import ScalarValues::*; + + attribute def Level :> Integer; + + state Machine { + entry; then idle; + state idle; + state high; + state low; + + transition raise first idle accept l : Level if lower.l < 0 then high; + transition lower first high accept l : Level then low; + transition first low then done; + } +} diff --git a/internal/core/runtime/testdata/conformance/state_guard_reads_calc_usage.expected.json b/internal/exec/runtime/testdata/conformance/state_guard_reads_calc_usage.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_guard_reads_calc_usage.expected.json rename to internal/exec/runtime/testdata/conformance/state_guard_reads_calc_usage.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_guard_reads_calc_usage.sysml b/internal/exec/runtime/testdata/conformance/state_guard_reads_calc_usage.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_guard_reads_calc_usage.sysml rename to internal/exec/runtime/testdata/conformance/state_guard_reads_calc_usage.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_guard_reads_deferred_payload.expected.json b/internal/exec/runtime/testdata/conformance/state_guard_reads_deferred_payload.expected.json new file mode 100644 index 0000000000..8b7cdf31fa --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_guard_reads_deferred_payload.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "events": [ + {"signal": "Level", "value": {"type": "Integer", "value": 8}}, + {"signal": "Go", "args": null} + ], + "finalState": "done", + "stateVisits": ["busy", "ready", "high", "done"], + "outputs": { + "chosen": {"type": "Integer", "value": 8} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_guard_reads_deferred_payload.sysml b/internal/exec/runtime/testdata/conformance/state_guard_reads_deferred_payload.sysml new file mode 100644 index 0000000000..4475e2a2fc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_guard_reads_deferred_payload.sysml @@ -0,0 +1,26 @@ +// A deferred occurrence is dispatched once a state that handles it is active, and +// the guard of the transition it then fires reads its payload through the +// transition's name, `raise.l`, as it does for an occurrence never deferred. +package StateGuardReadsDeferredPayload { + private import ScalarValues::*; + + attribute def Level :> Integer; + + state Machine { + attribute chosen : Integer = 0; + + entry; then busy; + state busy { + defer Level; + } + state ready; + state high; + state low; + + transition first busy when Go then ready; + transition raise first ready accept l : Level if raise.l > 5 do assign chosen := raise.l then high; + transition drop first ready accept l : Level if raise.l <= 5 then low; + transition first high then done; + transition first low then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_guard_reads_own_payload_member.expected.json b/internal/exec/runtime/testdata/conformance/state_guard_reads_own_payload_member.expected.json new file mode 100644 index 0000000000..a00300105f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_guard_reads_own_payload_member.expected.json @@ -0,0 +1,19 @@ +{ + "type": "instance", + "instantiate": "StateGuardReadsOwnPayloadMember::rig", + "objects": [ + { + "path": "sensor", + "finalState": "sending", + "stateVisits": ["sending"] + }, + { + "path": "monitor", + "finalState": "alerting", + "stateVisits": ["idle", "alerting"], + "slots": { + "origin": {"type": "String", "value": "probe"} + } + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/state_guard_reads_own_payload_member.sysml b/internal/exec/runtime/testdata/conformance/state_guard_reads_own_payload_member.sysml new file mode 100644 index 0000000000..761e31ba98 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_guard_reads_own_payload_member.sysml @@ -0,0 +1,60 @@ +// A transition's own guard reads a member of the payload its trigger bound +// through the transition's name, `raise.d.level`: the guard is evaluated within +// the transition's performance, after the accepter accepted the occurrence. +package StateGuardReadsOwnPayloadMember { + private import ScalarValues::*; + + attribute def Reading { + attribute level : Integer; + attribute origin : String; + } + + port def Link; + + part def Sensor { + port out : Link; + + exhibit state report { + entry; then sending; + + state sending { + entry action emit { + send new Reading(level = 9, origin = "probe") via out; + } + } + } + } + + part def Monitor { + port in : Link; + attribute origin : String = ""; + + exhibit state watch { + entry; then idle; + + state idle; + state alerting; + state ignored; + + transition raise first idle + accept d : Reading via in + if raise.d.level > 5 + do assign origin := raise.d.origin + then alerting; + + transition first idle + accept d : Reading via in + if d.level <= 5 + then ignored; + } + } + + part def Rig { + part sensor : Sensor; + part monitor : Monitor; + + connect sensor.out to monitor.in; + } + + part rig : Rig; +} diff --git a/internal/core/runtime/testdata/conformance/state_history_after_completion_default_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_history_after_completion_default_entry.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_history_after_completion_default_entry.expected.json rename to internal/exec/runtime/testdata/conformance/state_history_after_completion_default_entry.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_history_after_completion_default_entry.sysml b/internal/exec/runtime/testdata/conformance/state_history_after_completion_default_entry.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_history_after_completion_default_entry.sysml rename to internal/exec/runtime/testdata/conformance/state_history_after_completion_default_entry.sysml diff --git a/internal/core/runtime/testdata/conformance/state_history_after_completion_default_entry.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_after_completion_default_entry.trace.golden similarity index 82% rename from internal/core/runtime/testdata/conformance/state_history_after_completion_default_entry.trace.golden rename to internal/exec/runtime/testdata/conformance/state_history_after_completion_default_entry.trace.golden index 001676c8a0..9d2da110a4 100644 --- a/internal/core/runtime/testdata/conformance/state_history_after_completion_default_entry.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_history_after_completion_default_entry.trace.golden @@ -7,10 +7,11 @@ enter: done transition: first -> done (event: accept Finish) exit: done exit: outer -stmt assign completions - eval feature completions -> 0 - eval literal 1 -> 1 - eval operator + -> 1 +stmt action body + stmt assign completions + eval feature completions -> 0 + eval literal 1 -> 1 + eval operator + -> 1 enter: away transition: done -> away exit: away diff --git a/internal/core/runtime/testdata/conformance/state_history_after_completion_default_transition.expected.json b/internal/exec/runtime/testdata/conformance/state_history_after_completion_default_transition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_history_after_completion_default_transition.expected.json rename to internal/exec/runtime/testdata/conformance/state_history_after_completion_default_transition.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_history_after_completion_default_transition.sysml b/internal/exec/runtime/testdata/conformance/state_history_after_completion_default_transition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_history_after_completion_default_transition.sysml rename to internal/exec/runtime/testdata/conformance/state_history_after_completion_default_transition.sysml diff --git a/internal/core/runtime/testdata/conformance/state_history_after_completion_default_transition.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_after_completion_default_transition.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_history_after_completion_default_transition.trace.golden rename to internal/exec/runtime/testdata/conformance/state_history_after_completion_default_transition.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/state_history_default_junction_inside_nested.expected.json b/internal/exec/runtime/testdata/conformance/state_history_default_junction_inside_nested.expected.json new file mode 100644 index 0000000000..0c5a399ef8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_default_junction_inside_nested.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}, {"signal": "Out"}, {"signal": "Go"}, {"signal": "Stop"}], + "finalState": "done", + "stateVisits": ["idle", "outer", "inner", "leaf", "idle", "outer", "inner", "leaf", "done"], + "outputs": { + "log": {"type": "String", "value": "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);go(effect);outer(entry);inner(entry);leaf(entry);outer(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_history_default_junction_inside_nested.sysml b/internal/exec/runtime/testdata/conformance/state_history_default_junction_inside_nested.sysml new file mode 100644 index 0000000000..7a12a83f5a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_default_junction_inside_nested.sysml @@ -0,0 +1,35 @@ +// A deep history's default transition leads through a junction declared in a nested +// composite: the nested composite is entered before the junction's segment runs +// its effect, as the history's owner was before the default transition's. The +// second visit restores the leaf and takes neither segment. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state outer { + entry { assign log := log + "outer(entry);"; } + exit { assign log := log + "outer(exit);"; } + deep history hist; + junction j; + state inner { + entry { assign log := log + "inner(entry);"; } + junction ij; + state leaf { entry { assign log := log + "leaf(entry);"; } } + + transition first ij do { assign log := log + "ij(effect);"; } then leaf; + } + + transition first hist do { assign log := log + "h(effect);"; } then j; + transition first j do { assign log := log + "j(effect);"; } then inner.ij; + } + + transition first idle accept Go do { assign log := log + "go(effect);"; } then outer.hist; + transition first outer accept Out then idle; + transition first outer accept Stop then done; + } + attribute def Go; + attribute def Out; + attribute def Stop; +} diff --git a/internal/exec/runtime/testdata/conformance/state_history_default_junction_inside_nested.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_default_junction_inside_nested.trace.golden new file mode 100644 index 0000000000..b40285bda2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_default_junction_inside_nested.trace.golden @@ -0,0 +1,85 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "go(effect);" -> "go(effect);" + eval operator + -> "go(effect);" +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "go(effect);outer(entry);" +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);" + eval literal "h(effect);" -> "h(effect);" + eval operator + -> "go(effect);outer(entry);h(effect);" +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);h(effect);" + eval literal "j(effect);" -> "j(effect);" + eval operator + -> "go(effect);outer(entry);h(effect);j(effect);" +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);h(effect);j(effect);" + eval literal "inner(entry);" -> "inner(entry);" + eval operator + -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);" +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);" + eval literal "ij(effect);" -> "ij(effect);" + eval operator + -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);" +enter: leaf (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);" + eval literal "leaf(entry);" -> "leaf(entry);" + eval operator + -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);" +transition: idle -> leaf (event: accept Go) +exit: leaf +exit: inner +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);" +enter: idle +transition: leaf -> idle (event: accept Out) +exit: idle +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);" + eval literal "go(effect);" -> "go(effect);" + eval operator + -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);go(effect);" +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);go(effect);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);go(effect);outer(entry);" +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);go(effect);outer(entry);" + eval literal "inner(entry);" -> "inner(entry);" + eval operator + -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);go(effect);outer(entry);inner(entry);" +enter: leaf (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);go(effect);outer(entry);inner(entry);" + eval literal "leaf(entry);" -> "leaf(entry);" + eval operator + -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);go(effect);outer(entry);inner(entry);leaf(entry);" +transition: idle -> leaf (event: accept Go) +exit: leaf +exit: inner +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);go(effect);outer(entry);inner(entry);leaf(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "go(effect);outer(entry);h(effect);j(effect);inner(entry);ij(effect);leaf(entry);outer(exit);go(effect);outer(entry);inner(entry);leaf(entry);outer(exit);" +enter: done +transition: leaf -> done (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.check.expected.json b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.check.expected.json new file mode 100644 index 0000000000..4877a8ed84 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.check.expected.json @@ -0,0 +1,4 @@ +{ + "verdict": "divergent", + "divergent": {"finalState": ["w1", "w2"], "route": ["1", "2"]} +} diff --git a/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.declared.trace.golden new file mode 100644 index 0000000000..fa02f0f9bf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.declared.trace.golden @@ -0,0 +1,7 @@ +exit: idle +enter: work +choice junction split: transitions 1->w1, 2->w2 (unordered; took 1->w1) +stmt assign route + eval literal 1 -> 1 +enter: w1 +transition: idle -> w1 (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.expected.json b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.expected.json new file mode 100644 index 0000000000..d98550268f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.expected.json @@ -0,0 +1,22 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "w1", + "stateVisits": ["idle", "work", "w1"], + "outputs": { + "route": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "w2", + "stateVisits": ["idle", "work", "w2"], + "outputs": { + "route": {"type": "Integer", "value": 2} + } + } + ], + "admissible": "A history without a record takes its default transition through a junction with two branches enabled: exactly one is taken, which one is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.seed-1.trace.golden new file mode 100644 index 0000000000..dcfe680151 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.seed-1.trace.golden @@ -0,0 +1,7 @@ +exit: idle +enter: work +choice junction split: transitions 1->w1, 2->w2 (unordered; took 2->w2) +stmt assign route + eval literal 2 -> 2 +enter: w2 +transition: idle -> w2 (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.sysml b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.sysml new file mode 100644 index 0000000000..46d2f11d45 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.sysml @@ -0,0 +1,25 @@ +// A history reached before its composite state was ever left takes its default +// transition, here into a junction both of whose branches are enabled: exactly +// one is taken, which one is open, and the draw is recorded as a choice point +// at the junction like any other, so a sweep enumerates both entries. +package Test { + state Machine { + attribute route : Integer = 0; + + entry; then idle; + state idle; + state work { + entry; then w1; + state w1; + state w2; + history resume; + junction split; + + transition first resume then split; + transition first split do assign route := 1 then w1; + transition first split do assign route := 2 then w2; + } + + transition first idle accept Go then resume; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.trace.golden new file mode 100644 index 0000000000..fa02f0f9bf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_default_through_junction.trace.golden @@ -0,0 +1,7 @@ +exit: idle +enter: work +choice junction split: transitions 1->w1, 2->w2 (unordered; took 1->w1) +stmt assign route + eval literal 1 -> 1 +enter: w1 +transition: idle -> w1 (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_history_empty_default_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_history_empty_default_entry.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_history_empty_default_entry.expected.json rename to internal/exec/runtime/testdata/conformance/state_history_empty_default_entry.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_history_empty_default_entry.sysml b/internal/exec/runtime/testdata/conformance/state_history_empty_default_entry.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_history_empty_default_entry.sysml rename to internal/exec/runtime/testdata/conformance/state_history_empty_default_entry.sysml diff --git a/internal/core/runtime/testdata/conformance/state_history_empty_default_entry.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_empty_default_entry.trace.golden similarity index 80% rename from internal/core/runtime/testdata/conformance/state_history_empty_default_entry.trace.golden rename to internal/exec/runtime/testdata/conformance/state_history_empty_default_entry.trace.golden index 9fd85f09a2..41c5c6e915 100644 --- a/internal/core/runtime/testdata/conformance/state_history_empty_default_entry.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_history_empty_default_entry.trace.golden @@ -7,34 +7,32 @@ enter: first transition: away -> outer exit: first enter: second +transition: first -> second eval feature departures -> 0 eval literal 0 -> 0 eval operator == -> true eval feature departures -> 0 eval literal 0 -> 0 -eval operator > -> false -transition: first -> second - eval feature departures -> 0 - eval literal 0 -> 0 eval operator == -> true exit: second exit: outer -stmt assign departures - eval feature departures -> 0 - eval literal 1 -> 1 - eval operator + -> 1 +stmt action body + stmt assign departures + eval feature departures -> 0 + eval literal 1 -> 1 + eval operator + -> 1 enter: away transition: second -> away exit: away enter: outer enter: second +transition: away -> second eval feature departures -> 1 eval literal 0 -> 0 eval operator == -> false eval feature departures -> 1 eval literal 0 -> 0 eval operator > -> true -transition: away -> second eval feature departures -> 1 eval literal 0 -> 0 eval operator > -> true diff --git a/internal/exec/runtime/testdata/conformance/state_history_restore_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_history_restore_order.check.expected.json new file mode 100644 index 0000000000..11e71324eb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_restore_order.check.expected.json @@ -0,0 +1,18 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) r2(entry) \"", + "\"l2(entry) r2(entry) l2(exit) r2(exit) r2(entry) l2(entry) \"", + "\"l2(entry) r2(entry) r2(exit) l2(exit) l2(entry) r2(entry) \"", + "\"l2(entry) r2(entry) r2(exit) l2(exit) r2(entry) l2(entry) \"", + "\"r2(entry) l2(entry) l2(exit) r2(exit) l2(entry) r2(entry) \"", + "\"r2(entry) l2(entry) l2(exit) r2(exit) r2(entry) l2(entry) \"", + "\"r2(entry) l2(entry) r2(exit) l2(exit) l2(entry) r2(entry) \"", + "\"r2(entry) l2(entry) r2(exit) l2(exit) r2(entry) l2(entry) \"" + ] + }, + "agreed": { + "finalState": "l2+r2" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_history_restore_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_restore_order.declared.trace.golden new file mode 100644 index 0000000000..86382e161c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_restore_order.declared.trace.golden @@ -0,0 +1,49 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: l2 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l2(entry) " -> "l2(entry) " + eval operator + -> "l2(entry) " +transition: l1 -> l2 (event: accept Go) +exit: r1 +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) " + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "l2(entry) r2(entry) " +transition: r1 -> r2 (event: accept Go) +choice exiting work: next l2(exit), r2(exit) (unordered; took l2(exit) first) +exit: l2 (exit action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) " + eval literal "l2(exit) " -> "l2(exit) " + eval operator + -> "l2(entry) r2(entry) l2(exit) " +exit: r2 (exit action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) l2(exit) " + eval literal "r2(exit) " -> "r2(exit) " + eval operator + -> "l2(entry) r2(entry) l2(exit) r2(exit) " +exit: work +enter: paused +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next l2(entry), r2(entry) (unordered; took l2(entry) first) +enter: l2 (entry action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) l2(exit) r2(exit) " + eval literal "l2(entry) " -> "l2(entry) " + eval operator + -> "l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) " +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) " + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) r2(entry) " +transition: paused -> work (event: accept Resume) diff --git a/internal/exec/runtime/testdata/conformance/state_history_restore_order.expected.json b/internal/exec/runtime/testdata/conformance/state_history_restore_order.expected.json new file mode 100644 index 0000000000..8bc6446914 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_restore_order.expected.json @@ -0,0 +1,90 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + }, + { + "signal": "Pause" + }, + { + "signal": "Resume" + } + ], + "outcomes": [ + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) r2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "l2(entry) r2(entry) l2(exit) r2(exit) r2(entry) l2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "l2(entry) r2(entry) r2(exit) l2(exit) l2(entry) r2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "l2(entry) r2(entry) r2(exit) l2(exit) r2(entry) l2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) l2(entry) l2(exit) r2(exit) l2(entry) r2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) l2(entry) l2(exit) r2(exit) r2(entry) l2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) l2(entry) r2(exit) l2(exit) l2(entry) r2(entry) " + } + } + }, + { + "finalState": "l2+r2", + "outputs": { + "log": { + "type": "String", + "value": "r2(entry) l2(entry) r2(exit) l2(exit) r2(entry) l2(entry) " + } + } + } + ], + "admissible": "Regions of a parallel state entered on one occurrence: each is entered, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_history_restore_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_restore_order.seed-1.trace.golden new file mode 100644 index 0000000000..86382e161c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_restore_order.seed-1.trace.golden @@ -0,0 +1,49 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: l2 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l2(entry) " -> "l2(entry) " + eval operator + -> "l2(entry) " +transition: l1 -> l2 (event: accept Go) +exit: r1 +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) " + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "l2(entry) r2(entry) " +transition: r1 -> r2 (event: accept Go) +choice exiting work: next l2(exit), r2(exit) (unordered; took l2(exit) first) +exit: l2 (exit action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) " + eval literal "l2(exit) " -> "l2(exit) " + eval operator + -> "l2(entry) r2(entry) l2(exit) " +exit: r2 (exit action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) l2(exit) " + eval literal "r2(exit) " -> "r2(exit) " + eval operator + -> "l2(entry) r2(entry) l2(exit) r2(exit) " +exit: work +enter: paused +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next l2(entry), r2(entry) (unordered; took l2(entry) first) +enter: l2 (entry action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) l2(exit) r2(exit) " + eval literal "l2(entry) " -> "l2(entry) " + eval operator + -> "l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) " +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) " + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) r2(entry) " +transition: paused -> work (event: accept Resume) diff --git a/internal/exec/runtime/testdata/conformance/state_history_restore_order.sysml b/internal/exec/runtime/testdata/conformance/state_history_restore_order.sysml new file mode 100644 index 0000000000..10a3452773 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_restore_order.sysml @@ -0,0 +1,40 @@ +// A deep history restores the regions it recorded as one front drawn one unit +// at a time, as entering them afresh does. Each draw the machine makes is +// logged: the two regions' second states are entered on one occurrence in +// either order, exited in either order when the owner is left, and restored in +// either order; the eight combinations are the admissible set. +package Test { + state Machine { + attribute log : String = ""; + + entry; then work; + state work parallel { + deep history resume; + state left { + entry; then l1; + state l1; + state l2 { + entry { assign log := log + "l2(entry) "; } + exit { assign log := log + "l2(exit) "; } + } + transition first l1 accept Go then l2; + } + state right { + entry; then r1; + state r1; + state r2 { + entry { assign log := log + "r2(entry) "; } + exit { assign log := log + "r2(exit) "; } + } + transition first r1 accept Go then r2; + } + } + state paused; + + transition first work accept Pause then paused; + transition first paused accept Resume then resume; + } + attribute def Go; + attribute def Pause; + attribute def Resume; +} diff --git a/internal/exec/runtime/testdata/conformance/state_history_restore_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_restore_order.trace.golden new file mode 100644 index 0000000000..86382e161c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_history_restore_order.trace.golden @@ -0,0 +1,49 @@ +choice on accept Go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +exit: l1 +enter: l2 (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l2(entry) " -> "l2(entry) " + eval operator + -> "l2(entry) " +transition: l1 -> l2 (event: accept Go) +exit: r1 +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) " + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "l2(entry) r2(entry) " +transition: r1 -> r2 (event: accept Go) +choice exiting work: next l2(exit), r2(exit) (unordered; took l2(exit) first) +exit: l2 (exit action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) " + eval literal "l2(exit) " -> "l2(exit) " + eval operator + -> "l2(entry) r2(entry) l2(exit) " +exit: r2 (exit action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) l2(exit) " + eval literal "r2(exit) " -> "r2(exit) " + eval operator + -> "l2(entry) r2(entry) l2(exit) r2(exit) " +exit: work +enter: paused +transition: work -> paused (event: accept Pause) +exit: paused +enter: work +choice entering work: next l2(entry), r2(entry) (unordered; took l2(entry) first) +enter: l2 (entry action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) l2(exit) r2(exit) " + eval literal "l2(entry) " -> "l2(entry) " + eval operator + -> "l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) " +enter: r2 (entry action) +stmt action body + stmt assign log + eval feature log -> "l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) " + eval literal "r2(entry) " -> "r2(entry) " + eval operator + -> "l2(entry) r2(entry) l2(exit) r2(exit) l2(entry) r2(entry) " +transition: paused -> work (event: accept Resume) diff --git a/internal/core/runtime/testdata/conformance/state_history_revisit.expected.json b/internal/exec/runtime/testdata/conformance/state_history_revisit.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_history_revisit.expected.json rename to internal/exec/runtime/testdata/conformance/state_history_revisit.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_history_revisit.sysml b/internal/exec/runtime/testdata/conformance/state_history_revisit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_history_revisit.sysml rename to internal/exec/runtime/testdata/conformance/state_history_revisit.sysml diff --git a/internal/core/runtime/testdata/conformance/state_history_revisit.trace.golden b/internal/exec/runtime/testdata/conformance/state_history_revisit.trace.golden similarity index 79% rename from internal/core/runtime/testdata/conformance/state_history_revisit.trace.golden rename to internal/exec/runtime/testdata/conformance/state_history_revisit.trace.golden index c10240edea..241c4eace2 100644 --- a/internal/core/runtime/testdata/conformance/state_history_revisit.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_history_revisit.trace.golden @@ -4,34 +4,32 @@ enter: first transition: start -> first exit: first enter: second +transition: first -> second eval feature departures -> 0 eval literal 0 -> 0 eval operator == -> true eval feature departures -> 0 eval literal 0 -> 0 -eval operator > -> false -transition: first -> second - eval feature departures -> 0 - eval literal 0 -> 0 eval operator == -> true exit: second exit: outer -stmt assign departures - eval feature departures -> 0 - eval literal 1 -> 1 - eval operator + -> 1 +stmt action body + stmt assign departures + eval feature departures -> 0 + eval literal 1 -> 1 + eval operator + -> 1 enter: away transition: second -> away exit: away enter: outer enter: second +transition: away -> second eval feature departures -> 1 eval literal 0 -> 0 eval operator == -> false eval feature departures -> 1 eval literal 0 -> 0 eval operator > -> true -transition: away -> second eval feature departures -> 1 eval literal 0 -> 0 eval operator > -> true diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segment_rises_alone.expected.json b/internal/exec/runtime/testdata/conformance/state_join_change_segment_rises_alone.expected.json new file mode 100644 index 0000000000..b894dbe098 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segment_rises_alone.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "events": [ + {"signal": "Bump"} + ], + "finalState": "a1+b1+c1", + "outputs": { + "log": {"type": "String", "value": ""}, + "level": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segment_rises_alone.sysml b/internal/exec/runtime/testdata/conformance/state_join_change_segment_rises_alone.sysml new file mode 100644 index 0000000000..2481b430f9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segment_rises_alone.sysml @@ -0,0 +1,37 @@ +// A change condition rising is one occurrence: a change-triggered segment whose +// condition rose holds the join while the other segment's condition is still +// false, and a later rise of the other condition is a different occurrence, so +// the join never fires and no segment's effect runs. +package Test { + private import ScalarValues::*; + + attribute def Bump; + + state Machine { + attribute log : String = ""; + attribute level : Integer = 0; + + entry; then start; + state start; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept when level > 1 do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept when level > 2 do assign log := log + "b " then sync; + } + state c { + entry; then c1; + state c1; + transition first c1 accept Bump do assign level := 3 then c1; + } + } + join sync; + transition first start do assign level := 2 then Work; + transition first sync do assign log := log + "sync" then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.check.expected.json new file mode 100644 index 0000000000..e68e990b43 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"a b sync onward\"", "\"b a sync onward\""]}, + "agreed": {"finalState": "done", "level": "3"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.declared.trace.golden new file mode 100644 index 0000000000..be9c01cfe6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.declared.trace.golden @@ -0,0 +1,73 @@ +exit: start +stmt assign level + eval literal 2 -> 2 +enter: Work +choice entering Work: next a1(entry), b1(entry), c1(entry) (unordered; took a1(entry) first) +enter: a1 +choice entering Work: next b1(entry), c1(entry) (unordered; took b1(entry) first) +enter: b1 +enter: c1 +transition: start -> Work + eval feature level -> 2 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 2 + eval literal 2 -> 2 +eval operator > -> false + eval feature level -> 2 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 2 + eval literal 2 -> 2 +eval operator > -> false +exit: c1 +stmt assign level + eval literal 0 -> 0 +enter: c2 +transition: c1 -> c2 (event: accept Bump) + eval feature level -> 0 + eval literal 1 -> 1 +eval operator > -> false + eval feature level -> 0 + eval literal 2 -> 2 +eval operator > -> false +exit: c2 +stmt assign level + eval literal 3 -> 3 +enter: c2 +transition: c2 -> c2 (event: accept Bump) + eval feature level -> 3 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +exit: b1 +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +exit: c2 +exit: Work +stmt assign log + eval feature log -> "a b " + eval literal "sync " -> "sync " + eval operator + -> "a b sync " +stmt assign log + eval feature log -> "a b sync " + eval literal "onward" -> "onward" + eval operator + -> "a b sync onward" +enter: done +transition: Work -> done (event: change) diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.expected.json b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.expected.json new file mode 100644 index 0000000000..60b16c5b72 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.expected.json @@ -0,0 +1,25 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Bump"}, + {"signal": "Bump"} + ], + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "a b sync onward"}, + "level": {"type": "Integer", "value": 3} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "b a sync onward"}, + "level": {"type": "Integer", "value": 3} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.seed-1.trace.golden new file mode 100644 index 0000000000..362e7496cd --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.seed-1.trace.golden @@ -0,0 +1,73 @@ +exit: start +stmt assign level + eval literal 2 -> 2 +enter: Work +choice entering Work: next a1(entry), b1(entry), c1(entry) (unordered; took b1(entry) first) +enter: b1 +choice entering Work: next a1(entry), c1(entry) (unordered; took a1(entry) first) +enter: a1 +enter: c1 +transition: start -> Work + eval feature level -> 2 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 2 + eval literal 2 -> 2 +eval operator > -> false + eval feature level -> 2 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 2 + eval literal 2 -> 2 +eval operator > -> false +exit: c1 +stmt assign level + eval literal 0 -> 0 +enter: c2 +transition: c1 -> c2 (event: accept Bump) + eval feature level -> 0 + eval literal 1 -> 1 +eval operator > -> false + eval feature level -> 0 + eval literal 2 -> 2 +eval operator > -> false +exit: c2 +stmt assign level + eval literal 3 -> 3 +enter: c2 +transition: c2 -> c2 (event: accept Bump) + eval feature level -> 3 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +exit: b1 +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +exit: c2 +exit: Work +stmt assign log + eval feature log -> "a b " + eval literal "sync " -> "sync " + eval operator + -> "a b sync " +stmt assign log + eval feature log -> "a b sync " + eval literal "onward" -> "onward" + eval operator + -> "a b sync onward" +enter: done +transition: Work -> done (event: change) diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.sysml b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.sysml new file mode 100644 index 0000000000..8f20582973 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.sysml @@ -0,0 +1,42 @@ +// A change-triggered segment whose condition rose alone holds the join without +// looking past it: the junction the join leads to has no branch enabled while +// only `a`'s condition holds, and that is not a dead end since the join does +// not fire. Once the level falls and rises again for both segments at once, the +// join fires and the junction's branch is enabled. +package Test { + private import ScalarValues::*; + + attribute def Bump; + + state Machine { + attribute log : String = ""; + attribute level : Integer = 0; + + entry; then start; + state start; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept when level > 1 do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept when level > 2 do assign log := log + "b " then sync; + } + state c { + entry; then c1; + state c1; + state c2; + transition first c1 accept Bump do assign level := 0 then c2; + transition first c2 accept Bump do assign level := 3 then c2; + } + } + join sync; + junction onward; + transition first start do assign level := 2 then Work; + transition first sync do assign log := log + "sync " then onward; + transition first onward if level > 2 do assign log := log + "onward" then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.trace.golden new file mode 100644 index 0000000000..be9c01cfe6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segment_waits_before_junction.trace.golden @@ -0,0 +1,73 @@ +exit: start +stmt assign level + eval literal 2 -> 2 +enter: Work +choice entering Work: next a1(entry), b1(entry), c1(entry) (unordered; took a1(entry) first) +enter: a1 +choice entering Work: next b1(entry), c1(entry) (unordered; took b1(entry) first) +enter: b1 +enter: c1 +transition: start -> Work + eval feature level -> 2 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 2 + eval literal 2 -> 2 +eval operator > -> false + eval feature level -> 2 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 2 + eval literal 2 -> 2 +eval operator > -> false +exit: c1 +stmt assign level + eval literal 0 -> 0 +enter: c2 +transition: c1 -> c2 (event: accept Bump) + eval feature level -> 0 + eval literal 1 -> 1 +eval operator > -> false + eval feature level -> 0 + eval literal 2 -> 2 +eval operator > -> false +exit: c2 +stmt assign level + eval literal 3 -> 3 +enter: c2 +transition: c2 -> c2 (event: accept Bump) + eval feature level -> 3 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +exit: b1 +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +exit: c2 +exit: Work +stmt assign log + eval feature log -> "a b " + eval literal "sync " -> "sync " + eval operator + -> "a b sync " +stmt assign log + eval feature log -> "a b sync " + eval literal "onward" -> "onward" + eval operator + -> "a b sync onward" +enter: done +transition: Work -> done (event: change) diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.check.expected.json new file mode 100644 index 0000000000..6a794867d1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"a b sync\"", "\"b a sync\""]}, + "agreed": {"finalState": "done", "level": "3"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.declared.trace.golden new file mode 100644 index 0000000000..2e8f0dcadc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.declared.trace.golden @@ -0,0 +1,32 @@ +exit: start +stmt assign level + eval literal 3 -> 3 +enter: Work +choice entering Work: next a1(entry), b1(entry) (unordered; took a1(entry) first) +enter: a1 +enter: b1 +transition: start -> Work + eval feature level -> 3 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +exit: b1 +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +exit: Work +stmt assign log + eval feature log -> "a b " + eval literal "sync" -> "sync" + eval operator + -> "a b sync" +enter: done +transition: Work -> done (event: change) diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.expected.json b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.expected.json new file mode 100644 index 0000000000..7c39f510af --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.expected.json @@ -0,0 +1,21 @@ +{ + "type": "state", + "trace": true, + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "a b sync"}, + "level": {"type": "Integer", "value": 3} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "b a sync"}, + "level": {"type": "Integer", "value": 3} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.seed-1.trace.golden new file mode 100644 index 0000000000..7208587fe3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.seed-1.trace.golden @@ -0,0 +1,32 @@ +exit: start +stmt assign level + eval literal 3 -> 3 +enter: Work +choice entering Work: next a1(entry), b1(entry) (unordered; took b1(entry) first) +enter: b1 +enter: a1 +transition: start -> Work + eval feature level -> 3 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +exit: b1 +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +exit: Work +stmt assign log + eval feature log -> "a b " + eval literal "sync" -> "sync" + eval operator + -> "a b sync" +enter: done +transition: Work -> done (event: change) diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.sysml b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.sysml new file mode 100644 index 0000000000..058594230c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.sysml @@ -0,0 +1,30 @@ +// One write raising the change conditions of every segment into a join is one +// occurrence enabling them all: the join fires, each segment's effect runs +// before the owner is left, in either region order, and the outgoing effect +// runs last. +package Test { + private import ScalarValues::*; + + state Machine { + attribute log : String = ""; + attribute level : Integer = 0; + + entry; then start; + state start; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept when level > 1 do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept when level > 2 do assign log := log + "b " then sync; + } + } + join sync; + transition first start do assign level := 3 then Work; + transition first sync do assign log := log + "sync" then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.trace.golden new file mode 100644 index 0000000000..2e8f0dcadc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_change_segments_rise_together.trace.golden @@ -0,0 +1,32 @@ +exit: start +stmt assign level + eval literal 3 -> 3 +enter: Work +choice entering Work: next a1(entry), b1(entry) (unordered; took a1(entry) first) +enter: a1 +enter: b1 +transition: start -> Work + eval feature level -> 3 + eval literal 1 -> 1 +eval operator > -> true + eval feature level -> 3 + eval literal 2 -> 2 +eval operator > -> true +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +exit: b1 +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +exit: Work +stmt assign log + eval feature log -> "a b " + eval literal "sync" -> "sync" + eval operator + -> "a b sync" +enter: done +transition: Work -> done (event: change) diff --git a/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.check.expected.json new file mode 100644 index 0000000000..b8eedb557d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"busy a b sync\"", "\"busy b a sync\""]}, + "agreed": {"finalState": "done"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.declared.trace.golden new file mode 100644 index 0000000000..775565b70e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.declared.trace.golden @@ -0,0 +1,29 @@ +do: a1 +stmt action body +enter action node: state behavior busy + eval literal 1 -> 1 + eval index -> 1 [s] +do: a1 + stmt assign log + eval feature log -> "" + eval literal "busy " -> "busy " + eval operator + -> "busy " +leave action node: state behavior busy +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "busy " + eval literal "a " -> "a " + eval operator + -> "busy a " +exit: b1 +stmt assign log + eval feature log -> "busy a " + eval literal "b " -> "b " + eval operator + -> "busy a b " +exit: Work +stmt assign log + eval feature log -> "busy a b " + eval literal "sync" -> "sync" + eval operator + -> "busy a b sync" +enter: done +transition: Work -> done (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.expected.json b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.expected.json new file mode 100644 index 0000000000..73431dde00 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.expected.json @@ -0,0 +1,20 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "busy a b sync"} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "busy b a sync"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.seed-1.trace.golden new file mode 100644 index 0000000000..775565b70e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.seed-1.trace.golden @@ -0,0 +1,29 @@ +do: a1 +stmt action body +enter action node: state behavior busy + eval literal 1 -> 1 + eval index -> 1 [s] +do: a1 + stmt assign log + eval feature log -> "" + eval literal "busy " -> "busy " + eval operator + -> "busy " +leave action node: state behavior busy +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "busy " + eval literal "a " -> "a " + eval operator + -> "busy a " +exit: b1 +stmt assign log + eval feature log -> "busy a " + eval literal "b " -> "b " + eval operator + -> "busy a b " +exit: Work +stmt assign log + eval feature log -> "busy a b " + eval literal "sync" -> "sync" + eval operator + -> "busy a b sync" +enter: done +transition: Work -> done (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.sysml b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.sysml new file mode 100644 index 0000000000..6bdf6087e7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.sysml @@ -0,0 +1,37 @@ +// A completion event dispatched at the instant a time-triggered join segment's +// timer is due is not that timer's expiry: a1's do behavior ends at t=1, and +// its completion, dispatched before b1's timer due then, enables a1's segment +// but not b1's, so it fires nothing; b1's expiry then finds a1 completed and +// fires the join. +package Test { + private import ScalarValues::*; + private import SI::*; + + state Machine { + attribute log : String = ""; + + entry; then Work; + state Work parallel { + state a { + entry; then a1; + state a1 { + do action busy { + first start; + then action wait accept after 1 [s]; + then action mark assign log := log + "busy "; + then done; + } + } + transition first a1 do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept after 1 [s] do assign log := log + "b " then sync; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + state done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.trace.golden new file mode 100644 index 0000000000..775565b70e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_completion_is_not_a_timers_expiry.trace.golden @@ -0,0 +1,29 @@ +do: a1 +stmt action body +enter action node: state behavior busy + eval literal 1 -> 1 + eval index -> 1 [s] +do: a1 + stmt assign log + eval feature log -> "" + eval literal "busy " -> "busy " + eval operator + -> "busy " +leave action node: state behavior busy +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "busy " + eval literal "a " -> "a " + eval operator + -> "busy a " +exit: b1 +stmt assign log + eval feature log -> "busy a " + eval literal "b " -> "b " + eval operator + -> "busy a b " +exit: Work +stmt assign log + eval feature log -> "busy a b " + eval literal "sync" -> "sync" + eval operator + -> "busy a b sync" +enter: done +transition: Work -> done (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_join_completion_segment_waits_for_do_behavior.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_completion_segment_waits_for_do_behavior.check.expected.json new file mode 100644 index 0000000000..04c5bc2731 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_completion_segment_waits_for_do_behavior.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"tick a b sync\"", "\"tick b a sync\""]}, + "agreed": {"finalState": "done"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_completion_segment_waits_for_do_behavior.expected.json b/internal/exec/runtime/testdata/conformance/state_join_completion_segment_waits_for_do_behavior.expected.json new file mode 100644 index 0000000000..363f54b3e7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_completion_segment_waits_for_do_behavior.expected.json @@ -0,0 +1,23 @@ +{ + "type": "state", + "events": [ + {"signal": "Go"}, + {"signal": "Tick"}, + {"signal": "Go"} + ], + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "tick a b sync"} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "tick b a sync"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_completion_segment_waits_for_do_behavior.sysml b/internal/exec/runtime/testdata/conformance/state_join_completion_segment_waits_for_do_behavior.sysml new file mode 100644 index 0000000000..5afc13bebf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_completion_segment_waits_for_do_behavior.sysml @@ -0,0 +1,37 @@ +// A completion segment into a join is enabled once its source has completed, +// its do behavior finished included: a Go arriving while b1's body still waits +// for a Tick does not fire the join, and neither does b1's completion alone; the +// Go that comes once b1 has completed does. +package Test { + private import ScalarValues::*; + + attribute def Go; + attribute def Tick; + + state Machine { + attribute log : String = ""; + + entry; then Work; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept Go do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1 { + do action busy { + first start; + then action wait accept Tick; + then action mark assign log := log + "tick "; + then done; + } + } + transition first b1 do assign log := log + "b " then sync; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.check.expected.json new file mode 100644 index 0000000000..77cc55ad12 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.check.expected.json @@ -0,0 +1,12 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);S2(entry);\"", + "\"B1(exit);T22(effect);A1(exit);T12(effect);S1(exit);T3(effect);S2(entry);\"" + ] + }, + "agreed": { + "finalState": "S2" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.declared.trace.golden new file mode 100644 index 0000000000..b8846c8d74 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.declared.trace.golden @@ -0,0 +1,38 @@ +choice join XP: states A1, B1 react (unordered; took A1 first) +exit: A1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "A1(exit);" -> "A1(exit);" + eval operator + -> "A1(exit);" +stmt assign log + eval feature log -> "A1(exit);" + eval literal "T12(effect);" -> "T12(effect);" + eval operator + -> "A1(exit);T12(effect);" +exit: B1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);" + eval literal "B1(exit);" -> "B1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);" + eval literal "T22(effect);" -> "T22(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);" +exit: S1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);" + eval literal "S1(exit);" -> "S1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" + eval literal "T3(effect);" -> "T3(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" +enter: S2 (entry action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" + eval literal "S2(entry);" -> "S2(entry);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);S2(entry);" +transition: S1 -> S2 (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.expected.json new file mode 100644 index 0000000000..1f949cd8e9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.expected.json @@ -0,0 +1,20 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "S2", + "outputs": { + "log": {"type": "String", "value": "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);S2(entry);"} + } + }, + { + "finalState": "S2", + "outputs": { + "log": {"type": "String", "value": "B1(exit);T22(effect);A1(exit);T12(effect);S1(exit);T3(effect);S2(entry);"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.seed-1.trace.golden new file mode 100644 index 0000000000..b8846c8d74 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.seed-1.trace.golden @@ -0,0 +1,38 @@ +choice join XP: states A1, B1 react (unordered; took A1 first) +exit: A1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "A1(exit);" -> "A1(exit);" + eval operator + -> "A1(exit);" +stmt assign log + eval feature log -> "A1(exit);" + eval literal "T12(effect);" -> "T12(effect);" + eval operator + -> "A1(exit);T12(effect);" +exit: B1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);" + eval literal "B1(exit);" -> "B1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);" + eval literal "T22(effect);" -> "T22(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);" +exit: S1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);" + eval literal "S1(exit);" -> "S1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" + eval literal "T3(effect);" -> "T3(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" +enter: S2 (entry action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" + eval literal "S2(entry);" -> "S2(entry);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);S2(entry);" +transition: S1 -> S2 (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.sysml b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.sysml new file mode 100644 index 0000000000..8596309f7e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.sysml @@ -0,0 +1,32 @@ +// A join inside a composite state stands for an exit point reached from each +// orthogonal region: each incoming segment exits its region and runs its +// effect, then the owner's exit action runs, then the outgoing segment fires. +package Test { + private import ScalarValues::*; + + attribute def Go; + + state Machine { + attribute log : String = ""; + + entry; then S1; + state S1 parallel { + exit action { assign log := log + "S1(exit);"; } + state R1 { + entry; then A1; + state A1 { exit action { assign log := log + "A1(exit);"; } } + transition first A1 accept Go do assign log := log + "T12(effect);" then XP; + } + state R2 { + entry; then B1; + state B1 { exit action { assign log := log + "B1(exit);"; } } + transition first B1 accept Go do assign log := log + "T22(effect);" then XP; + } + join XP; + } + transition first S1::XP do assign log := log + "T3(effect);" then S2; + state S2 { + entry action { assign log := log + "S2(entry);"; } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.trace.golden new file mode 100644 index 0000000000..b8846c8d74 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_exit_from_regions_before_owner_exit.trace.golden @@ -0,0 +1,38 @@ +choice join XP: states A1, B1 react (unordered; took A1 first) +exit: A1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "A1(exit);" -> "A1(exit);" + eval operator + -> "A1(exit);" +stmt assign log + eval feature log -> "A1(exit);" + eval literal "T12(effect);" -> "T12(effect);" + eval operator + -> "A1(exit);T12(effect);" +exit: B1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);" + eval literal "B1(exit);" -> "B1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);" + eval literal "T22(effect);" -> "T22(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);" +exit: S1 (exit action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);" + eval literal "S1(exit);" -> "S1(exit);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" +stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);" + eval literal "T3(effect);" -> "T3(effect);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" +enter: S2 (entry action) +stmt action body + stmt assign log + eval feature log -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);" + eval literal "S2(entry);" -> "S2(entry);" + eval operator + -> "A1(exit);T12(effect);B1(exit);T22(effect);S1(exit);T3(effect);S2(entry);" +transition: S1 -> S2 (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.check.expected.json new file mode 100644 index 0000000000..93d2c14757 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"a1(exit) ia(exit) a b1(exit) b work(exit) sync\"", "\"b1(exit) b a1(exit) ia(exit) a work(exit) sync\""]}, + "agreed": {"finalState": "done"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.declared.trace.golden new file mode 100644 index 0000000000..e633737e80 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.declared.trace.golden @@ -0,0 +1,39 @@ +choice join sync: states ia, b1 react (unordered; took ia first) +exit: a1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a1(exit) " -> "a1(exit) " + eval operator + -> "a1(exit) " +exit: ia (exit action) +stmt action body + stmt assign log + eval feature log -> "a1(exit) " + eval literal "ia(exit) " -> "ia(exit) " + eval operator + -> "a1(exit) ia(exit) " +stmt assign log + eval feature log -> "a1(exit) ia(exit) " + eval literal "a " -> "a " + eval operator + -> "a1(exit) ia(exit) a " +exit: b1 (exit action) +stmt action body + stmt assign log + eval feature log -> "a1(exit) ia(exit) a " + eval literal "b1(exit) " -> "b1(exit) " + eval operator + -> "a1(exit) ia(exit) a b1(exit) " +stmt assign log + eval feature log -> "a1(exit) ia(exit) a b1(exit) " + eval literal "b " -> "b " + eval operator + -> "a1(exit) ia(exit) a b1(exit) b " +exit: Work (exit action) +stmt action body + stmt assign log + eval feature log -> "a1(exit) ia(exit) a b1(exit) b " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "a1(exit) ia(exit) a b1(exit) b work(exit) " +stmt assign log + eval feature log -> "a1(exit) ia(exit) a b1(exit) b work(exit) " + eval literal "sync" -> "sync" + eval operator + -> "a1(exit) ia(exit) a b1(exit) b work(exit) sync" +enter: done +transition: Work -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.expected.json b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.expected.json new file mode 100644 index 0000000000..89b614dfe8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.expected.json @@ -0,0 +1,20 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "a1(exit) ia(exit) a b1(exit) b work(exit) sync"} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "b1(exit) b a1(exit) ia(exit) a work(exit) sync"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.seed-1.trace.golden new file mode 100644 index 0000000000..e633737e80 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.seed-1.trace.golden @@ -0,0 +1,39 @@ +choice join sync: states ia, b1 react (unordered; took ia first) +exit: a1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a1(exit) " -> "a1(exit) " + eval operator + -> "a1(exit) " +exit: ia (exit action) +stmt action body + stmt assign log + eval feature log -> "a1(exit) " + eval literal "ia(exit) " -> "ia(exit) " + eval operator + -> "a1(exit) ia(exit) " +stmt assign log + eval feature log -> "a1(exit) ia(exit) " + eval literal "a " -> "a " + eval operator + -> "a1(exit) ia(exit) a " +exit: b1 (exit action) +stmt action body + stmt assign log + eval feature log -> "a1(exit) ia(exit) a " + eval literal "b1(exit) " -> "b1(exit) " + eval operator + -> "a1(exit) ia(exit) a b1(exit) " +stmt assign log + eval feature log -> "a1(exit) ia(exit) a b1(exit) " + eval literal "b " -> "b " + eval operator + -> "a1(exit) ia(exit) a b1(exit) b " +exit: Work (exit action) +stmt action body + stmt assign log + eval feature log -> "a1(exit) ia(exit) a b1(exit) b " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "a1(exit) ia(exit) a b1(exit) b work(exit) " +stmt assign log + eval feature log -> "a1(exit) ia(exit) a b1(exit) b work(exit) " + eval literal "sync" -> "sync" + eval operator + -> "a1(exit) ia(exit) a b1(exit) b work(exit) sync" +enter: done +transition: Work -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.sysml b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.sysml new file mode 100644 index 0000000000..77fab3184f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.sysml @@ -0,0 +1,38 @@ +// The transitions into a join leave composite states whose substates are active: +// each segment is enabled by the occurrence reaching its source from within, and +// firing the join exits the active substate, then the source, then its effect. +package Test { + private import ScalarValues::*; + + attribute def Go; + + state Machine { + attribute log : String = ""; + + entry; then Work; + state Work parallel { + exit action { assign log := log + "work(exit) "; } + state a { + entry; then ia; + state ia { + exit action { assign log := log + "ia(exit) "; } + entry; then a1; + state a1 { + exit action { assign log := log + "a1(exit) "; } + } + } + transition first ia accept Go do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1 { + exit action { assign log := log + "b1(exit) "; } + } + transition first b1 accept Go do assign log := log + "b " then sync; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + state done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.trace.golden new file mode 100644 index 0000000000..e633737e80 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_composite_sources.trace.golden @@ -0,0 +1,39 @@ +choice join sync: states ia, b1 react (unordered; took ia first) +exit: a1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a1(exit) " -> "a1(exit) " + eval operator + -> "a1(exit) " +exit: ia (exit action) +stmt action body + stmt assign log + eval feature log -> "a1(exit) " + eval literal "ia(exit) " -> "ia(exit) " + eval operator + -> "a1(exit) ia(exit) " +stmt assign log + eval feature log -> "a1(exit) ia(exit) " + eval literal "a " -> "a " + eval operator + -> "a1(exit) ia(exit) a " +exit: b1 (exit action) +stmt action body + stmt assign log + eval feature log -> "a1(exit) ia(exit) a " + eval literal "b1(exit) " -> "b1(exit) " + eval operator + -> "a1(exit) ia(exit) a b1(exit) " +stmt assign log + eval feature log -> "a1(exit) ia(exit) a b1(exit) " + eval literal "b " -> "b " + eval operator + -> "a1(exit) ia(exit) a b1(exit) b " +exit: Work (exit action) +stmt action body + stmt assign log + eval feature log -> "a1(exit) ia(exit) a b1(exit) b " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "a1(exit) ia(exit) a b1(exit) b work(exit) " +stmt assign log + eval feature log -> "a1(exit) ia(exit) a b1(exit) b work(exit) " + eval literal "sync" -> "sync" + eval operator + -> "a1(exit) ia(exit) a b1(exit) b work(exit) sync" +enter: done +transition: Work -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.check.expected.json new file mode 100644 index 0000000000..efd5c19b87 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"ia(exit) a1 ib(exit) b1 work(exit) sync\"", "\"ib(exit) b1 ia(exit) a1 work(exit) sync\""]}, + "agreed": {"finalState": "done"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.declared.trace.golden new file mode 100644 index 0000000000..914f90a00b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.declared.trace.golden @@ -0,0 +1,35 @@ +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +exit: ia (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "ia(exit) " -> "ia(exit) " + eval operator + -> "ia(exit) " +stmt assign log + eval feature log -> "ia(exit) " + eval literal "a1 " -> "a1 " + eval operator + -> "ia(exit) a1 " +exit: b1 +exit: ib (exit action) +stmt action body + stmt assign log + eval feature log -> "ia(exit) a1 " + eval literal "ib(exit) " -> "ib(exit) " + eval operator + -> "ia(exit) a1 ib(exit) " +stmt assign log + eval feature log -> "ia(exit) a1 ib(exit) " + eval literal "b1 " -> "b1 " + eval operator + -> "ia(exit) a1 ib(exit) b1 " +exit: Work (exit action) +stmt action body + stmt assign log + eval feature log -> "ia(exit) a1 ib(exit) b1 " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "ia(exit) a1 ib(exit) b1 work(exit) " +stmt assign log + eval feature log -> "ia(exit) a1 ib(exit) b1 work(exit) " + eval literal "sync" -> "sync" + eval operator + -> "ia(exit) a1 ib(exit) b1 work(exit) sync" +enter: done +transition: Work -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.expected.json b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.expected.json new file mode 100644 index 0000000000..da9fb3bddc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.expected.json @@ -0,0 +1,20 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "ia(exit) a1 ib(exit) b1 work(exit) sync"} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "ib(exit) b1 ia(exit) a1 work(exit) sync"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.seed-1.trace.golden new file mode 100644 index 0000000000..914f90a00b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.seed-1.trace.golden @@ -0,0 +1,35 @@ +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +exit: ia (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "ia(exit) " -> "ia(exit) " + eval operator + -> "ia(exit) " +stmt assign log + eval feature log -> "ia(exit) " + eval literal "a1 " -> "a1 " + eval operator + -> "ia(exit) a1 " +exit: b1 +exit: ib (exit action) +stmt action body + stmt assign log + eval feature log -> "ia(exit) a1 " + eval literal "ib(exit) " -> "ib(exit) " + eval operator + -> "ia(exit) a1 ib(exit) " +stmt assign log + eval feature log -> "ia(exit) a1 ib(exit) " + eval literal "b1 " -> "b1 " + eval operator + -> "ia(exit) a1 ib(exit) b1 " +exit: Work (exit action) +stmt action body + stmt assign log + eval feature log -> "ia(exit) a1 ib(exit) b1 " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "ia(exit) a1 ib(exit) b1 work(exit) " +stmt assign log + eval feature log -> "ia(exit) a1 ib(exit) b1 work(exit) " + eval literal "sync" -> "sync" + eval operator + -> "ia(exit) a1 ib(exit) b1 work(exit) sync" +enter: done +transition: Work -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.sysml b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.sysml new file mode 100644 index 0000000000..a84e2cc968 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.sysml @@ -0,0 +1,39 @@ +// The transitions into a join leave states nested below the direct members of +// Work's regions. Firing the join exits each source and the composite states +// between it and Work, innermost first, before the segment's effect, then Work +// itself: every exit action runs. +package Test { + private import ScalarValues::*; + + attribute def Go; + + state Machine { + attribute log : String = ""; + + entry; then Work; + state Work parallel { + exit action { assign log := log + "work(exit) "; } + state a { + entry; then ia; + state ia { + exit action { assign log := log + "ia(exit) "; } + entry; then a1; + state a1; + } + transition first a1 accept Go do assign log := log + "a1 " then sync; + } + state b { + entry; then ib; + state ib { + exit action { assign log := log + "ib(exit) "; } + entry; then b1; + state b1; + } + transition first b1 accept Go do assign log := log + "b1 " then sync; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + state done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.trace.golden new file mode 100644 index 0000000000..914f90a00b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_from_nested_states.trace.golden @@ -0,0 +1,35 @@ +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +exit: ia (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "ia(exit) " -> "ia(exit) " + eval operator + -> "ia(exit) " +stmt assign log + eval feature log -> "ia(exit) " + eval literal "a1 " -> "a1 " + eval operator + -> "ia(exit) a1 " +exit: b1 +exit: ib (exit action) +stmt action body + stmt assign log + eval feature log -> "ia(exit) a1 " + eval literal "ib(exit) " -> "ib(exit) " + eval operator + -> "ia(exit) a1 ib(exit) " +stmt assign log + eval feature log -> "ia(exit) a1 ib(exit) " + eval literal "b1 " -> "b1 " + eval operator + -> "ia(exit) a1 ib(exit) b1 " +exit: Work (exit action) +stmt action body + stmt assign log + eval feature log -> "ia(exit) a1 ib(exit) b1 " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "ia(exit) a1 ib(exit) b1 work(exit) " +stmt assign log + eval feature log -> "ia(exit) a1 ib(exit) b1 work(exit) " + eval literal "sync" -> "sync" + eval operator + -> "ia(exit) a1 ib(exit) b1 work(exit) sync" +enter: done +transition: Work -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.check.expected.json new file mode 100644 index 0000000000..c1ac0eb4cb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.check.expected.json @@ -0,0 +1,10 @@ +{ + "verdict": "divergent", + "divergent": {"log": [ + "\"a(exit) c(exit) inner(exit) a b(exit) b sync\"", + "\"b(exit) b a(exit) c(exit) inner(exit) a sync\"", + "\"b(exit) b c(exit) a(exit) inner(exit) a sync\"", + "\"c(exit) a(exit) inner(exit) a b(exit) b sync\"" + ]}, + "agreed": {"finalState": "done"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.declared.trace.golden new file mode 100644 index 0000000000..d4fdd2ba46 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.declared.trace.golden @@ -0,0 +1,40 @@ +choice join sync: states a, b react (unordered; took a first) +choice exiting inner: next a(exit), c(exit) (unordered; took a(exit) first) +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a(exit) " -> "a(exit) " + eval operator + -> "a(exit) " +exit: c (exit action) +stmt action body + stmt assign log + eval feature log -> "a(exit) " + eval literal "c(exit) " -> "c(exit) " + eval operator + -> "a(exit) c(exit) " +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "a(exit) c(exit) " + eval literal "inner(exit) " -> "inner(exit) " + eval operator + -> "a(exit) c(exit) inner(exit) " +stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) " + eval literal "a " -> "a " + eval operator + -> "a(exit) c(exit) inner(exit) a " +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) a " + eval literal "b(exit) " -> "b(exit) " + eval operator + -> "a(exit) c(exit) inner(exit) a b(exit) " +stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) a b(exit) " + eval literal "b " -> "b " + eval operator + -> "a(exit) c(exit) inner(exit) a b(exit) b " +stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) a b(exit) b " + eval literal "sync" -> "sync" + eval operator + -> "a(exit) c(exit) inner(exit) a b(exit) b sync" +enter: done +transition: -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.expected.json b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.expected.json new file mode 100644 index 0000000000..df85a1d8b6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.expected.json @@ -0,0 +1,32 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "a(exit) c(exit) inner(exit) a b(exit) b sync"} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "b(exit) b a(exit) c(exit) inner(exit) a sync"} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "b(exit) b c(exit) a(exit) inner(exit) a sync"} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "c(exit) a(exit) inner(exit) a b(exit) b sync"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.seed-1.trace.golden new file mode 100644 index 0000000000..d4fdd2ba46 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.seed-1.trace.golden @@ -0,0 +1,40 @@ +choice join sync: states a, b react (unordered; took a first) +choice exiting inner: next a(exit), c(exit) (unordered; took a(exit) first) +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a(exit) " -> "a(exit) " + eval operator + -> "a(exit) " +exit: c (exit action) +stmt action body + stmt assign log + eval feature log -> "a(exit) " + eval literal "c(exit) " -> "c(exit) " + eval operator + -> "a(exit) c(exit) " +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "a(exit) c(exit) " + eval literal "inner(exit) " -> "inner(exit) " + eval operator + -> "a(exit) c(exit) inner(exit) " +stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) " + eval literal "a " -> "a " + eval operator + -> "a(exit) c(exit) inner(exit) a " +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) a " + eval literal "b(exit) " -> "b(exit) " + eval operator + -> "a(exit) c(exit) inner(exit) a b(exit) " +stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) a b(exit) " + eval literal "b " -> "b " + eval operator + -> "a(exit) c(exit) inner(exit) a b(exit) b " +stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) a b(exit) b " + eval literal "sync" -> "sync" + eval operator + -> "a(exit) c(exit) inner(exit) a b(exit) b sync" +enter: done +transition: -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.sysml b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.sysml new file mode 100644 index 0000000000..87127abdb1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.sysml @@ -0,0 +1,42 @@ +// A join of the machine's own orthogonal regions, one segment leaving a state +// nested in an orthogonal state of a region: the segment exits its source, the +// orthogonal state's other region and the orthogonal state itself, once each, and +// the machine's regions are left with no state exited a second time. +package Test { + private import ScalarValues::*; + + attribute def Go; + + state def Machine parallel { + attribute log : String = ""; + + state left { + entry; then inner; + state inner parallel { + exit action { assign log := log + "inner(exit) "; } + state l1 { + entry; then a; + state a { + exit action { assign log := log + "a(exit) "; } + } + } + state l2 { + entry; then c; + state c { + exit action { assign log := log + "c(exit) "; } + } + } + } + transition first a accept Go do assign log := log + "a " then sync; + } + state right { + entry; then b; + state b { + exit action { assign log := log + "b(exit) "; } + } + transition first b accept Go do assign log := log + "b " then sync; + } + join sync; + transition first sync do assign log := log + "sync" then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.trace.golden new file mode 100644 index 0000000000..d4fdd2ba46 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_of_machine_regions_from_nested_source.trace.golden @@ -0,0 +1,40 @@ +choice join sync: states a, b react (unordered; took a first) +choice exiting inner: next a(exit), c(exit) (unordered; took a(exit) first) +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a(exit) " -> "a(exit) " + eval operator + -> "a(exit) " +exit: c (exit action) +stmt action body + stmt assign log + eval feature log -> "a(exit) " + eval literal "c(exit) " -> "c(exit) " + eval operator + -> "a(exit) c(exit) " +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "a(exit) c(exit) " + eval literal "inner(exit) " -> "inner(exit) " + eval operator + -> "a(exit) c(exit) inner(exit) " +stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) " + eval literal "a " -> "a " + eval operator + -> "a(exit) c(exit) inner(exit) a " +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) a " + eval literal "b(exit) " -> "b(exit) " + eval operator + -> "a(exit) c(exit) inner(exit) a b(exit) " +stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) a b(exit) " + eval literal "b " -> "b " + eval operator + -> "a(exit) c(exit) inner(exit) a b(exit) b " +stmt assign log + eval feature log -> "a(exit) c(exit) inner(exit) a b(exit) b " + eval literal "sync" -> "sync" + eval operator + -> "a(exit) c(exit) inner(exit) a b(exit) b sync" +enter: done +transition: -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.check.expected.json new file mode 100644 index 0000000000..5a0a7ff38f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"left(effect) right(effect) outer(exit) sync(effect) rest(entry)\"", "\"right(effect) left(effect) outer(exit) sync(effect) rest(entry)\""]}, + "agreed": {"finalState": "done"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.declared.trace.golden new file mode 100644 index 0000000000..dc5ef20e92 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.declared.trace.golden @@ -0,0 +1,39 @@ +exit: start +enter: Outer +enter: Work +choice entering Work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 +transition: start -> Work (event: accept go) +choice join sync: states l1, r1 react (unordered; took l1 first) +exit: l1 +stmt assign log + eval feature log -> "" + eval literal "left(effect) " -> "left(effect) " + eval operator + -> "left(effect) " +exit: r1 +stmt assign log + eval feature log -> "left(effect) " + eval literal "right(effect) " -> "right(effect) " + eval operator + -> "left(effect) right(effect) " +exit: Work +exit: Outer (exit action) +stmt action body + stmt assign log + eval feature log -> "left(effect) right(effect) " + eval literal "outer(exit) " -> "outer(exit) " + eval operator + -> "left(effect) right(effect) outer(exit) " +stmt assign log + eval feature log -> "left(effect) right(effect) outer(exit) " + eval literal "sync(effect) " -> "sync(effect) " + eval operator + -> "left(effect) right(effect) outer(exit) sync(effect) " +enter: Rest (entry action) +stmt action body + stmt assign log + eval feature log -> "left(effect) right(effect) outer(exit) sync(effect) " + eval literal "rest(entry)" -> "rest(entry)" + eval operator + -> "left(effect) right(effect) outer(exit) sync(effect) rest(entry)" +transition: Work -> Rest +exit: Rest +enter: done +transition: Rest -> done diff --git a/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.expected.json b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.expected.json new file mode 100644 index 0000000000..46b46b151a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.expected.json @@ -0,0 +1,22 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "go", "args": null} + ], + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "left(effect) right(effect) outer(exit) sync(effect) rest(entry)"} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "right(effect) left(effect) outer(exit) sync(effect) rest(entry)"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.seed-1.trace.golden new file mode 100644 index 0000000000..d0e59a2150 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.seed-1.trace.golden @@ -0,0 +1,39 @@ +exit: start +enter: Outer +enter: Work +choice entering Work: next l1(entry), r1(entry) (unordered; took r1(entry) first) +enter: r1 +enter: l1 +transition: start -> Work (event: accept go) +choice join sync: states l1, r1 react (unordered; took l1 first) +exit: l1 +stmt assign log + eval feature log -> "" + eval literal "left(effect) " -> "left(effect) " + eval operator + -> "left(effect) " +exit: r1 +stmt assign log + eval feature log -> "left(effect) " + eval literal "right(effect) " -> "right(effect) " + eval operator + -> "left(effect) right(effect) " +exit: Work +exit: Outer (exit action) +stmt action body + stmt assign log + eval feature log -> "left(effect) right(effect) " + eval literal "outer(exit) " -> "outer(exit) " + eval operator + -> "left(effect) right(effect) outer(exit) " +stmt assign log + eval feature log -> "left(effect) right(effect) outer(exit) " + eval literal "sync(effect) " -> "sync(effect) " + eval operator + -> "left(effect) right(effect) outer(exit) sync(effect) " +enter: Rest (entry action) +stmt action body + stmt assign log + eval feature log -> "left(effect) right(effect) outer(exit) sync(effect) " + eval literal "rest(entry)" -> "rest(entry)" + eval operator + -> "left(effect) right(effect) outer(exit) sync(effect) rest(entry)" +transition: Work -> Rest +exit: Rest +enter: done +transition: Rest -> done diff --git a/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.sysml b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.sysml new file mode 100644 index 0000000000..9f9b56cf52 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.sysml @@ -0,0 +1,35 @@ +// A join completes one compound transition: the effect of every transition +// into it runs once the sources are exited, before the state owning the join +// is exited and the join's outgoing effect runs. +package Test { + state Machine { + attribute log : String = ""; + + entry; then start; + state start; + state Outer { + exit { assign log := log + "outer(exit) "; } + + state Work parallel { + state left { + entry; then l1; + state l1; + transition first l1 do assign log := log + "left(effect) " then sync; + } + state right { + entry; then r1; + state r1; + transition first r1 do assign log := log + "right(effect) " then sync; + } + } + join sync; + transition first sync do assign log := log + "sync(effect) " then Rest; + } + state Rest { + entry { assign log := log + "rest(entry)"; } + } + + transition first start when go then Work; + transition first Rest then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.trace.golden new file mode 100644 index 0000000000..dc5ef20e92 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_runs_every_incoming_effect.trace.golden @@ -0,0 +1,39 @@ +exit: start +enter: Outer +enter: Work +choice entering Work: next l1(entry), r1(entry) (unordered; took l1(entry) first) +enter: l1 +enter: r1 +transition: start -> Work (event: accept go) +choice join sync: states l1, r1 react (unordered; took l1 first) +exit: l1 +stmt assign log + eval feature log -> "" + eval literal "left(effect) " -> "left(effect) " + eval operator + -> "left(effect) " +exit: r1 +stmt assign log + eval feature log -> "left(effect) " + eval literal "right(effect) " -> "right(effect) " + eval operator + -> "left(effect) right(effect) " +exit: Work +exit: Outer (exit action) +stmt action body + stmt assign log + eval feature log -> "left(effect) right(effect) " + eval literal "outer(exit) " -> "outer(exit) " + eval operator + -> "left(effect) right(effect) outer(exit) " +stmt assign log + eval feature log -> "left(effect) right(effect) outer(exit) " + eval literal "sync(effect) " -> "sync(effect) " + eval operator + -> "left(effect) right(effect) outer(exit) sync(effect) " +enter: Rest (entry action) +stmt action body + stmt assign log + eval feature log -> "left(effect) right(effect) outer(exit) sync(effect) " + eval literal "rest(entry)" -> "rest(entry)" + eval operator + -> "left(effect) right(effect) outer(exit) sync(effect) rest(entry)" +transition: Work -> Rest +exit: Rest +enter: done +transition: Rest -> done diff --git a/internal/exec/runtime/testdata/conformance/state_join_segment_reads_its_payload.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_segment_reads_its_payload.check.expected.json new file mode 100644 index 0000000000..8b7f8773bf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_segment_reads_its_payload.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"a b sync\"", "\"b a sync\""]}, + "agreed": {"finalState": "done", "left": "3", "right": "4"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_segment_reads_its_payload.expected.json b/internal/exec/runtime/testdata/conformance/state_join_segment_reads_its_payload.expected.json new file mode 100644 index 0000000000..e6af542ee4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_segment_reads_its_payload.expected.json @@ -0,0 +1,25 @@ +{ + "type": "state", + "events": [ + {"signal": "Level", "value": {"type": "Integer", "value": 3}} + ], + "outcomes": [ + { + "finalState": "done", + "outputs": { + "left": {"type": "Integer", "value": 3}, + "right": {"type": "Integer", "value": 4}, + "log": {"type": "String", "value": "a b sync"} + } + }, + { + "finalState": "done", + "outputs": { + "left": {"type": "Integer", "value": 3}, + "right": {"type": "Integer", "value": 4}, + "log": {"type": "String", "value": "b a sync"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_segment_reads_its_payload.sysml b/internal/exec/runtime/testdata/conformance/state_join_segment_reads_its_payload.sysml new file mode 100644 index 0000000000..66bd5a4b8a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_segment_reads_its_payload.sysml @@ -0,0 +1,33 @@ +// Each transition into a join reads the payload its own accept names, whichever +// region's transition the dispatch draws first to fire the join. +package Test { + private import ScalarValues::*; + + attribute def Level :> Integer; + + state Machine { + attribute log : String = ""; + attribute left : Integer = 0; + attribute right : Integer = 0; + + entry; then Work; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept l : Level + do action { assign left := l; assign log := log + "a "; } + then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept r : Level + do action { assign right := r + 1; assign log := log + "b "; } + then sync; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_segment_trigger_unmatched.expected.json b/internal/exec/runtime/testdata/conformance/state_join_segment_trigger_unmatched.expected.json new file mode 100644 index 0000000000..07c72accd8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_segment_trigger_unmatched.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "events": [ + {"signal": "Go"}, + {"signal": "Nudge"} + ], + "finalState": "a1+b1", + "outputs": { + "log": {"type": "String", "value": ""} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_segment_trigger_unmatched.sysml b/internal/exec/runtime/testdata/conformance/state_join_segment_trigger_unmatched.sysml new file mode 100644 index 0000000000..4228e19560 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_segment_trigger_unmatched.sysml @@ -0,0 +1,28 @@ +// A segment into a join whose trigger the occurrence does not match holds the +// join: the other segment, though enabled, fires nothing and runs no effect. +package Test { + private import ScalarValues::*; + + attribute def Go; + attribute def Nudge; + + state Machine { + attribute log : String = ""; + + entry; then Work; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept Go do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept Nudge do assign log := log + "b " then sync; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segment_needs_same_occurrence.expected.json b/internal/exec/runtime/testdata/conformance/state_join_time_segment_needs_same_occurrence.expected.json new file mode 100644 index 0000000000..31f99d5804 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segment_needs_same_occurrence.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "libraries": true, + "events": [ + {"signal": "Go"} + ], + "finalState": "a1+b1", + "outputs": { + "log": {"type": "String", "value": ""} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segment_needs_same_occurrence.sysml b/internal/exec/runtime/testdata/conformance/state_join_time_segment_needs_same_occurrence.sysml new file mode 100644 index 0000000000..a274784041 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segment_needs_same_occurrence.sysml @@ -0,0 +1,30 @@ +// A join fires on one occurrence enabling every segment into it, a timer's +// expiry included: the time-triggered segment holds the join while the other +// segment waits for a signal, and the signal holds it while the other waits +// for the clock. Neither segment's effect runs. +package Test { + private import ScalarValues::*; + private import SI::*; + + attribute def Go; + + state Machine { + attribute log : String = ""; + + entry; then Work; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept after 1 [s] do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept Go do assign log := log + "b " then sync; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.check.expected.json new file mode 100644 index 0000000000..b31f483086 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"c d \"", "\"d c \""]}, + "agreed": {"finalState": "a1+b1+c2+d2"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.declared.trace.golden new file mode 100644 index 0000000000..95d707b88f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.declared.trace.golden @@ -0,0 +1,20 @@ +choice events at t=1.0: time b1 1->sync, time c1 1->c2, time d1 1->d2 (unordered; dispatched time b1 1->sync first) +choice events at t=1.0: time c1 1->c2, time d1 1->d2 (unordered; dispatched time c1 1->c2 first) +exit: c1 +stmt action body + stmt assign log + eval feature log -> "" + eval literal "c " -> "c " + eval operator + -> "c " + stmt send +materialize: Go #1 +enter: c2 +transition: c1 -> c2 (event: time) +choice events at t=1.0: time d1 1->d2, accept Go (unordered; dispatched time d1 1->d2 first) +exit: d1 +stmt assign log + eval feature log -> "c " + eval literal "d " -> "d " + eval operator + -> "c d " +enter: d2 +transition: d1 -> d2 (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.expected.json b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.expected.json new file mode 100644 index 0000000000..1727284a85 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.expected.json @@ -0,0 +1,20 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "outcomes": [ + { + "finalState": "a1+b1+c2+d2", + "outputs": { + "log": {"type": "String", "value": "c d "} + } + }, + { + "finalState": "a1+b1+c2+d2", + "outputs": { + "log": {"type": "String", "value": "d c "} + } + } + ], + "admissible": "Two time events due at one instant: each dispatches, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.seed-1.trace.golden new file mode 100644 index 0000000000..95d707b88f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.seed-1.trace.golden @@ -0,0 +1,20 @@ +choice events at t=1.0: time b1 1->sync, time c1 1->c2, time d1 1->d2 (unordered; dispatched time b1 1->sync first) +choice events at t=1.0: time c1 1->c2, time d1 1->d2 (unordered; dispatched time c1 1->c2 first) +exit: c1 +stmt action body + stmt assign log + eval feature log -> "" + eval literal "c " -> "c " + eval operator + -> "c " + stmt send +materialize: Go #1 +enter: c2 +transition: c1 -> c2 (event: time) +choice events at t=1.0: time d1 1->d2, accept Go (unordered; dispatched time d1 1->d2 first) +exit: d1 +stmt assign log + eval feature log -> "c " + eval literal "d " -> "d " + eval operator + -> "c d " +enter: d2 +transition: d1 -> d2 (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.sysml b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.sysml new file mode 100644 index 0000000000..2cad9739a2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.sysml @@ -0,0 +1,45 @@ +// A signal dispatched at the instant a time-triggered join segment's timer is +// due is not that timer's expiry: `Go`, sent by `c`'s timer at t=1 and so tied +// with `b`'s timer due then, enables `a`'s segment but not `b`'s, whichever of +// the two is dispatched first; the segment dispatched second finds the other's +// occurrence gone. The join never fires; only the order of `c`'s and `d`'s +// timers is open. +package Test { + private import ScalarValues::*; + private import SI::*; + + item def Go; + + state Machine { + attribute log : String = ""; + + entry; then Work; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept Go do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept after 1 [s] do assign log := log + "b " then sync; + } + state c { + entry; then c1; + state c1; + state c2; + transition first c1 accept after 1 [s] do action { assign log := log + "c "; send new Go() to Machine; } then c2; + } + state d { + entry; then d1; + state d1; + state d2; + transition first d1 accept after 1 [s] do assign log := log + "d " then d2; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + state done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.trace.golden new file mode 100644 index 0000000000..95d707b88f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segment_not_taken_by_signal.trace.golden @@ -0,0 +1,20 @@ +choice events at t=1.0: time b1 1->sync, time c1 1->c2, time d1 1->d2 (unordered; dispatched time b1 1->sync first) +choice events at t=1.0: time c1 1->c2, time d1 1->d2 (unordered; dispatched time c1 1->c2 first) +exit: c1 +stmt action body + stmt assign log + eval feature log -> "" + eval literal "c " -> "c " + eval operator + -> "c " + stmt send +materialize: Go #1 +enter: c2 +transition: c1 -> c2 (event: time) +choice events at t=1.0: time d1 1->d2, accept Go (unordered; dispatched time d1 1->d2 first) +exit: d1 +stmt assign log + eval feature log -> "c " + eval literal "d " -> "d " + eval operator + -> "c d " +enter: d2 +transition: d1 -> d2 (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segment_unsynchronized_reads_no_route.expected.json b/internal/exec/runtime/testdata/conformance/state_join_time_segment_unsynchronized_reads_no_route.expected.json new file mode 100644 index 0000000000..31f99d5804 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segment_unsynchronized_reads_no_route.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "libraries": true, + "events": [ + {"signal": "Go"} + ], + "finalState": "a1+b1", + "outputs": { + "log": {"type": "String", "value": ""} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segment_unsynchronized_reads_no_route.sysml b/internal/exec/runtime/testdata/conformance/state_join_time_segment_unsynchronized_reads_no_route.sysml new file mode 100644 index 0000000000..94b2a85be3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segment_unsynchronized_reads_no_route.sysml @@ -0,0 +1,34 @@ +// A timer's expiry enables one segment into a join but not the other, which +// waits for a signal: the join does not fire, and the route out of it is not +// resolved, so the junction beyond it, whose only guard is false, is not read +// and the expiry aborts nothing. The signal holds the join the same way. +package Test { + private import ScalarValues::*; + private import SI::*; + + attribute def Go; + + state Machine { + attribute log : String = ""; + attribute ready : Boolean = false; + + entry; then Work; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept after 1 [s] do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept Go do assign log := log + "b " then sync; + } + } + join sync; + junction pick; + transition first sync do assign log := log + "sync " then pick; + transition first pick if ready then done; + state done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_apart.expected.json b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_apart.expected.json new file mode 100644 index 0000000000..6d19ac651e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_apart.expected.json @@ -0,0 +1,8 @@ +{ + "type": "state", + "libraries": true, + "finalState": "a1+b1", + "outputs": { + "log": {"type": "String", "value": ""} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_apart.sysml b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_apart.sysml new file mode 100644 index 0000000000..8998a315c4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_apart.sysml @@ -0,0 +1,29 @@ +// Two time-triggered segments into a join whose timers are due at different +// instants are never one occurrence: the first expiry finds the other timer +// not yet due and the second finds the first already spent, so the join does +// not fire and neither segment's effect runs. +package Test { + private import ScalarValues::*; + private import SI::*; + + state Machine { + attribute log : String = ""; + + entry; then Work; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept after 1 [s] do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept after 3 [s] do assign log := log + "b " then sync; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + state done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.check.expected.json new file mode 100644 index 0000000000..64b2765efb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"a b sync\"", "\"b a sync\""]}, + "agreed": {"finalState": "done"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.declared.trace.golden new file mode 100644 index 0000000000..4d28876562 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.declared.trace.golden @@ -0,0 +1,19 @@ +choice events at t=1.0: time a1 1->sync, time b1 1->sync (unordered; dispatched time a1 1->sync first) +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +exit: b1 +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +exit: Work +stmt assign log + eval feature log -> "a b " + eval literal "sync" -> "sync" + eval operator + -> "a b sync" +enter: done +transition: Work -> done (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.expected.json b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.expected.json new file mode 100644 index 0000000000..14aa49c8bb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.expected.json @@ -0,0 +1,20 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "outcomes": [ + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "a b sync"} + } + }, + { + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "b a sync"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.seed-1.trace.golden new file mode 100644 index 0000000000..4d28876562 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.seed-1.trace.golden @@ -0,0 +1,19 @@ +choice events at t=1.0: time a1 1->sync, time b1 1->sync (unordered; dispatched time a1 1->sync first) +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +exit: b1 +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +exit: Work +stmt assign log + eval feature log -> "a b " + eval literal "sync" -> "sync" + eval operator + -> "a b sync" +enter: done +transition: Work -> done (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.sysml b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.sysml new file mode 100644 index 0000000000..4e4f1ced05 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.sysml @@ -0,0 +1,29 @@ +// Two time-triggered segments into a join whose timers are due at one instant: +// their expiries are one occurrence for the join, whichever is dispatched +// first, so the join fires, each segment's effect running before the owner is +// left, in either order, and the other timer is withdrawn with its source. +package Test { + private import ScalarValues::*; + private import SI::*; + + state Machine { + attribute log : String = ""; + + entry; then Work; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept after 1 [s] do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept after 1 [s] do assign log := log + "b " then sync; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + state done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.trace.golden b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.trace.golden new file mode 100644 index 0000000000..4d28876562 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_time_segments_expire_together.trace.golden @@ -0,0 +1,19 @@ +choice events at t=1.0: time a1 1->sync, time b1 1->sync (unordered; dispatched time a1 1->sync first) +choice join sync: states a1, b1 react (unordered; took a1 first) +exit: a1 +stmt assign log + eval feature log -> "" + eval literal "a " -> "a " + eval operator + -> "a " +exit: b1 +stmt assign log + eval feature log -> "a " + eval literal "b " -> "b " + eval operator + -> "a b " +exit: Work +stmt assign log + eval feature log -> "a b " + eval literal "sync" -> "sync" + eval operator + -> "a b sync" +enter: done +transition: Work -> done (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_join_waits_for_every_segment_enabled.check.expected.json b/internal/exec/runtime/testdata/conformance/state_join_waits_for_every_segment_enabled.check.expected.json new file mode 100644 index 0000000000..a2bbc777e3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_waits_for_every_segment_enabled.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"log": ["\"a b sync\"", "\"b a sync\""]}, + "agreed": {"finalState": "done", "armed": "true"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_waits_for_every_segment_enabled.expected.json b/internal/exec/runtime/testdata/conformance/state_join_waits_for_every_segment_enabled.expected.json new file mode 100644 index 0000000000..ef49ea8ed4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_waits_for_every_segment_enabled.expected.json @@ -0,0 +1,25 @@ +{ + "type": "state", + "events": [ + {"signal": "Go"}, + {"signal": "Arm"}, + {"signal": "Go"} + ], + "outcomes": [ + { + "finalState": "done", + "outputs": { + "armed": {"type": "Boolean", "value": true}, + "log": {"type": "String", "value": "a b sync"} + } + }, + { + "finalState": "done", + "outputs": { + "armed": {"type": "Boolean", "value": true}, + "log": {"type": "String", "value": "b a sync"} + } + } + ], + "admissible": "Transitions into a join: each exits its source and runs its effect before the owner is left, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_join_waits_for_every_segment_enabled.sysml b/internal/exec/runtime/testdata/conformance/state_join_waits_for_every_segment_enabled.sysml new file mode 100644 index 0000000000..ac93c47c9d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_join_waits_for_every_segment_enabled.sysml @@ -0,0 +1,35 @@ +// A join fires only when the occurrence enables every segment into it: one +// whose trigger the occurrence does not match, or whose guard is false, holds +// the join, and no segment's effect runs meanwhile. +package Test { + private import ScalarValues::*; + + attribute def Go; + attribute def Arm; + + state Machine { + attribute log : String = ""; + attribute armed : Boolean = false; + + entry; then Work; + state Work parallel { + state a { + entry; then a1; + state a1; + transition first a1 accept Go if armed do assign log := log + "a " then sync; + } + state b { + entry; then b1; + state b1; + transition first b1 accept Go do assign log := log + "b " then sync; + } + state c { + entry; then c1; + state c1; + transition first c1 accept Arm do assign armed := true then c1; + } + } + join sync; + transition first sync do assign log := log + "sync" then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.check.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.check.expected.json new file mode 100644 index 0000000000..c5cbc88a99 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"route": ["11", "12"]}, + "agreed": {"d": "0", "finalState": "a2+left"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.declared.trace.golden new file mode 100644 index 0000000000..a33a6b9b40 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.declared.trace.golden @@ -0,0 +1,26 @@ + eval literal 4 -> 4 + eval feature d -> 2 + eval operator / -> 2.0 + eval literal 1 -> 1 +eval operator > -> true + eval literal 4 -> 4 + eval feature d -> 2 + eval operator / -> 2.0 + eval literal 1 -> 1 +eval operator > -> true +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign d + eval literal 0 -> 0 +enter: a2 +transition: a1 -> a2 (event: accept Go) +choice junction split: transitions 1->again, 2->again (unordered; took 1->again) +exit: b1 +stmt assign route + eval literal 1 -> 1 +stmt assign route + eval feature route -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +enter: left +transition: b1 -> left (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.expected.json new file mode 100644 index 0000000000..d40a966bd8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.expected.json @@ -0,0 +1,72 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "a2+left", + "stateVisits": ["work", "a1", "b1", "a2", "left"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 11} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "a1", "b1", "a2", "left"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 12} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "a1", "b1", "left", "a2"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 11} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "a1", "b1", "left", "a2"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 12} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "b1", "a1", "a2", "left"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 11} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "b1", "a1", "a2", "left"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 12} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "b1", "a1", "left", "a2"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 11} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "b1", "a1", "left", "a2"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 12} + } + } + ], + "admissible": "Every junction guard on a route is read once, as its transition is selected: a junction beyond a draw takes the branch enabled then though another region's effect since changed what its guards read" +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.seed-1.trace.golden new file mode 100644 index 0000000000..a33a6b9b40 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.seed-1.trace.golden @@ -0,0 +1,26 @@ + eval literal 4 -> 4 + eval feature d -> 2 + eval operator / -> 2.0 + eval literal 1 -> 1 +eval operator > -> true + eval literal 4 -> 4 + eval feature d -> 2 + eval operator / -> 2.0 + eval literal 1 -> 1 +eval operator > -> true +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign d + eval literal 0 -> 0 +enter: a2 +transition: a1 -> a2 (event: accept Go) +choice junction split: transitions 1->again, 2->again (unordered; took 1->again) +exit: b1 +stmt assign route + eval literal 1 -> 1 +stmt assign route + eval feature route -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +enter: left +transition: b1 -> left (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.sysml b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.sysml new file mode 100644 index 0000000000..fe7002bc31 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.sysml @@ -0,0 +1,35 @@ +// One Go enables a transition in each region of `work`. Region b's targets a +// junction with two enabled branches, each leading on to a second junction whose +// guards read `d`, which region a's effect zeroes. Every guard on the route is read +// once, as b's transition is selected, so whichever branch is drawn and whichever +// region fires first, the second junction takes the branch d = 2 enabled then: +// the one dividing by d, whose guard would be unevaluable read against d = 0. +package Test { + state Machine { + attribute d : Integer = 2; + attribute route : Integer = 0; + + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept Go do assign d := 0 then a2; + } + state b { + entry; then b1; + state b1; + junction split; + junction again; + state left; + state right; + transition first b1 accept Go then split; + transition first split do assign route := 1 then again; + transition first split do assign route := 2 then again; + transition first again if 4 / d > 1 do assign route := route + 10 then left; + transition first again if d == 0 do assign route := route + 20 then right; + } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.trace.golden new file mode 100644 index 0000000000..a33a6b9b40 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_beyond_a_draw_read_once.trace.golden @@ -0,0 +1,26 @@ + eval literal 4 -> 4 + eval feature d -> 2 + eval operator / -> 2.0 + eval literal 1 -> 1 +eval operator > -> true + eval literal 4 -> 4 + eval feature d -> 2 + eval operator / -> 2.0 + eval literal 1 -> 1 +eval operator > -> true +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign d + eval literal 0 -> 0 +enter: a2 +transition: a1 -> a2 (event: accept Go) +choice junction split: transitions 1->again, 2->again (unordered; took 1->again) +exit: b1 +stmt assign route + eval literal 1 -> 1 +stmt assign route + eval feature route -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +enter: left +transition: b1 -> left (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.check.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.check.expected.json new file mode 100644 index 0000000000..c49801d2c7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"finalState": ["a2+b1", "a2+left", "a2+right"], "route": ["0", "1", "2"]}, + "agreed": {"armed": "false"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.declared.trace.golden new file mode 100644 index 0000000000..ae9a245273 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.declared.trace.golden @@ -0,0 +1,8 @@ +eval feature armed -> true +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign armed + eval literal false -> false +enter: a2 +transition: a1 -> a2 (event: accept Go) +eval feature armed -> false diff --git a/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.expected.json new file mode 100644 index 0000000000..0fa0cbe2d1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.expected.json @@ -0,0 +1,56 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "a2+b1", + "stateVisits": ["work", "a1", "b1", "a2"], + "outputs": { + "armed": {"type": "Boolean", "value": false}, + "route": {"type": "Integer", "value": 0} + } + }, + { + "finalState": "a2+b1", + "stateVisits": ["work", "b1", "a1", "a2"], + "outputs": { + "armed": {"type": "Boolean", "value": false}, + "route": {"type": "Integer", "value": 0} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "a1", "b1", "left", "a2"], + "outputs": { + "armed": {"type": "Boolean", "value": false}, + "route": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "b1", "a1", "left", "a2"], + "outputs": { + "armed": {"type": "Boolean", "value": false}, + "route": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "a2+right", + "stateVisits": ["work", "a1", "b1", "right", "a2"], + "outputs": { + "armed": {"type": "Boolean", "value": false}, + "route": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "a2+right", + "stateVisits": ["work", "b1", "a1", "right", "a2"], + "outputs": { + "armed": {"type": "Boolean", "value": false}, + "route": {"type": "Integer", "value": 2} + } + } + ], + "admissible": "A junction with two branches enabled in a region another region's reaction may disarm: drawn only as its transition fires" +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.seed-1.trace.golden new file mode 100644 index 0000000000..ae9a245273 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.seed-1.trace.golden @@ -0,0 +1,8 @@ +eval feature armed -> true +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign armed + eval literal false -> false +enter: a2 +transition: a1 -> a2 (event: accept Go) +eval feature armed -> false diff --git a/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.sysml b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.sysml new file mode 100644 index 0000000000..b325f3cd12 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.sysml @@ -0,0 +1,31 @@ +// One Go enables a transition in each region of `work`. Region a's disarms the +// guard of region b's, whose target is a junction with both branches enabled +// when the transitions are selected. The regions fire in an open order: b first +// takes the junction, drawn among its two branches once b's transition fires; +// a first leaves b's guard false, so b stays and nothing is drawn at the junction. +package Test { + state Machine { + attribute armed : Boolean = true; + attribute route : Integer = 0; + + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept Go do assign armed := false then a2; + } + state b { + entry; then b1; + state b1; + junction split; + state left; + state right; + transition first b1 accept Go if armed then split; + transition first split do assign route := 1 then left; + transition first split do assign route := 2 then right; + } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.trace.golden new file mode 100644 index 0000000000..ae9a245273 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_drawn_as_its_transition_fires.trace.golden @@ -0,0 +1,8 @@ +eval feature armed -> true +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign armed + eval literal false -> false +enter: a2 +transition: a1 -> a2 (event: accept Go) +eval feature armed -> false diff --git a/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.expected.json new file mode 100644 index 0000000000..bd291ba8a5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}, {"signal": "Back"}, {"signal": "Again"}], + "finalState": "A2", + "outputs": { + "log": {"type": "String", "value": "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);S1(entry);A2(entry);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.sysml b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.sysml new file mode 100644 index 0000000000..50a611bd22 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.sysml @@ -0,0 +1,32 @@ +// A junction inside a composite state stands for an entry point: entering +// through it runs the owner's entry action, then the junction's outgoing +// effect, then the target's entry, and the owner's default initial does not +// fire. A later entry through history restores the state the junction reached. +package Test { + private import ScalarValues::*; + + attribute def Go; attribute def Back; attribute def Again; + + state Machine { + attribute log : String = ""; + + entry; then S0; + state S0; + state S1 { + entry action { assign log := log + "S1(entry);"; } + exit action { assign log := log + "S1(exit);"; } + then A1; + junction EP; + history H; + state A1 { entry action { assign log := log + "A1(entry);"; } } + state A2 { + entry action { assign log := log + "A2(entry);"; } + exit action { assign log := log + "A2(exit);"; } + } + transition first EP do assign log := log + "EP(effect);" then A2; + } + transition first S0 accept Go do assign log := log + "T1(effect);" then S1::EP; + transition first S1 accept Back do assign log := log + "T2(effect);" then S0; + transition first S0 accept Again do assign log := log + "T3(effect);" then S1::H; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.trace.golden new file mode 100644 index 0000000000..5f95e3187a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_entry_skips_default_initial.trace.golden @@ -0,0 +1,58 @@ +exit: S0 +stmt assign log + eval feature log -> "" + eval literal "T1(effect);" -> "T1(effect);" + eval operator + -> "T1(effect);" +enter: S1 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);" + eval literal "S1(entry);" -> "S1(entry);" + eval operator + -> "T1(effect);S1(entry);" +stmt assign log + eval feature log -> "T1(effect);S1(entry);" + eval literal "EP(effect);" -> "EP(effect);" + eval operator + -> "T1(effect);S1(entry);EP(effect);" +enter: A2 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);" + eval literal "A2(entry);" -> "A2(entry);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);" +transition: S0 -> A2 (event: accept Go) +exit: A2 (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);" + eval literal "A2(exit);" -> "A2(exit);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);" +exit: S1 (exit action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);" + eval literal "S1(exit);" -> "S1(exit);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);" +stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);" + eval literal "T2(effect);" -> "T2(effect);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);" +enter: S0 +transition: A2 -> S0 (event: accept Back) +exit: S0 +stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);" + eval literal "T3(effect);" -> "T3(effect);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);" +enter: S1 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);" + eval literal "S1(entry);" -> "S1(entry);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);S1(entry);" +enter: A2 (entry action) +stmt action body + stmt assign log + eval feature log -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);S1(entry);" + eval literal "A2(entry);" -> "A2(entry);" + eval operator + -> "T1(effect);S1(entry);EP(effect);A2(entry);A2(exit);S1(exit);T2(effect);T3(effect);S1(entry);A2(entry);" +transition: S0 -> A2 (event: accept Again) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.expected.json new file mode 100644 index 0000000000..272ab7cbb5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "finalState": "S2", + "outputs": { + "log": {"type": "String", "value": "S111(exit);S11(exit);T12(effect);S1(exit);T3(effect);S2(entry);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.sysml b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.sysml new file mode 100644 index 0000000000..dc4c1ef769 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.sysml @@ -0,0 +1,31 @@ +// A junction inside a composite state stands for an exit point: the transition +// into it from a nested state runs its effect after the nested exits and +// before the owner's exit action, then the outgoing segment's effect runs. +package Test { + private import ScalarValues::*; + + attribute def Go; + + state Machine { + attribute log : String = ""; + + entry; then S1; + state S1 { + exit action { assign log := log + "S1(exit);"; } + entry; then S11; + state S11 { + exit action { assign log := log + "S11(exit);"; } + entry; then S111; + state S111 { + exit action { assign log := log + "S111(exit);"; } + } + } + junction XP; + transition first S111 accept Go do assign log := log + "T12(effect);" then XP; + } + transition first S1::XP do assign log := log + "T3(effect);" then S2; + state S2 { + entry action { assign log := log + "S2(entry);"; } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.trace.golden new file mode 100644 index 0000000000..6cbaca1253 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_exit_effect_before_owner_exit.trace.golden @@ -0,0 +1,33 @@ +exit: S111 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "S111(exit);" -> "S111(exit);" + eval operator + -> "S111(exit);" +exit: S11 (exit action) +stmt action body + stmt assign log + eval feature log -> "S111(exit);" + eval literal "S11(exit);" -> "S11(exit);" + eval operator + -> "S111(exit);S11(exit);" +stmt assign log + eval feature log -> "S111(exit);S11(exit);" + eval literal "T12(effect);" -> "T12(effect);" + eval operator + -> "S111(exit);S11(exit);T12(effect);" +exit: S1 (exit action) +stmt action body + stmt assign log + eval feature log -> "S111(exit);S11(exit);T12(effect);" + eval literal "S1(exit);" -> "S1(exit);" + eval operator + -> "S111(exit);S11(exit);T12(effect);S1(exit);" +stmt assign log + eval feature log -> "S111(exit);S11(exit);T12(effect);S1(exit);" + eval literal "T3(effect);" -> "T3(effect);" + eval operator + -> "S111(exit);S11(exit);T12(effect);S1(exit);T3(effect);" +enter: S2 (entry action) +stmt action body + stmt assign log + eval feature log -> "S111(exit);S11(exit);T12(effect);S1(exit);T3(effect);" + eval literal "S2(entry);" -> "S2(entry);" + eval operator + -> "S111(exit);S11(exit);T12(effect);S1(exit);T3(effect);S2(entry);" +transition: S111 -> S2 (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_guard_reads_call_argument.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_guard_reads_call_argument.expected.json new file mode 100644 index 0000000000..c9620336ee --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_guard_reads_call_argument.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "events": [ + {"call": "setSpeed", "args": {"value": {"type": "Integer", "value": 55}}} + ], + "finalState": "done", + "stateVisits": ["idle", "cruising", "done"], + "outputs": { + "speed": {"type": "Integer", "value": 55} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_guard_reads_call_argument.sysml b/internal/exec/runtime/testdata/conformance/state_junction_guard_reads_call_argument.sysml new file mode 100644 index 0000000000..09874f0ee4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_guard_reads_call_argument.sysml @@ -0,0 +1,22 @@ +// A guard on a segment out of a junction reads a call trigger's argument by the +// accepting transition's name, `set.value`, as its effect does: the junction is +// resolved within the performance of the transition whose trigger bound it. +package StateJunctionGuardReadsCallArgument { + private import ScalarValues::*; + + state Controller { + attribute speed : Integer = 0; + + entry; then idle; + state idle; + junction route; + state cruising; + state stopped; + + transition set first idle accept setSpeed(value) then route; + transition go first route if set.value > 0 do assign speed := set.value then cruising; + transition halt first route if set.value <= 0 then stopped; + transition first cruising then done; + transition first stopped then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.check.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.check.expected.json new file mode 100644 index 0000000000..650097e775 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"finalState": ["a2+left", "a2+right"], "route": ["1", "2"]}, + "agreed": {"d": "0", "v": "4"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.declared.trace.golden new file mode 100644 index 0000000000..e847d59575 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.declared.trace.golden @@ -0,0 +1,15 @@ + eval feature v -> 4 + eval literal 0 -> 0 +eval operator > -> true +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign d + eval literal 0 -> 0 +enter: a2 +transition: a1 -> a2 (event: accept Go) +choice junction split: transitions 1->left, 2->right (unordered; took 1->left) +exit: b1 +stmt assign route + eval literal 1 -> 1 +enter: left +transition: b1 -> left (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.expected.json new file mode 100644 index 0000000000..f94cac7252 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.expected.json @@ -0,0 +1,72 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "a2+left", + "stateVisits": ["work", "a1", "b1", "a2", "left"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "a1", "b1", "left", "a2"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "b1", "a1", "a2", "left"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "a2+left", + "stateVisits": ["work", "b1", "a1", "left", "a2"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "a2+right", + "stateVisits": ["work", "a1", "b1", "a2", "right"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "a2+right", + "stateVisits": ["work", "a1", "b1", "right", "a2"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "a2+right", + "stateVisits": ["work", "b1", "a1", "a2", "right"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 2} + } + }, + { + "finalState": "a2+right", + "stateVisits": ["work", "b1", "a1", "right", "a2"], + "outputs": { + "d": {"type": "Integer", "value": 0}, + "route": {"type": "Integer", "value": 2} + } + } + ], + "admissible": "A junction's guards are read once, as its incoming transition is selected: a branch enabled then is taken though another region's effect since made its guard unevaluable" +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.seed-1.trace.golden new file mode 100644 index 0000000000..e847d59575 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.seed-1.trace.golden @@ -0,0 +1,15 @@ + eval feature v -> 4 + eval literal 0 -> 0 +eval operator > -> true +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign d + eval literal 0 -> 0 +enter: a2 +transition: a1 -> a2 (event: accept Go) +choice junction split: transitions 1->left, 2->right (unordered; took 1->left) +exit: b1 +stmt assign route + eval literal 1 -> 1 +enter: left +transition: b1 -> left (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.sysml b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.sysml new file mode 100644 index 0000000000..1014233821 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.sysml @@ -0,0 +1,32 @@ +// One Go enables a transition in each region of `work`. Region b's targets a +// junction whose second branch's guard divides by `d`, which region a's effect +// zeroes. The junction's guards are read once, as b's transition is selected, +// so when a fires first the branch drawn for b is the one enabled then: the +// second is taken without its guard being read again against d = 0. +package Test { + state Machine { + attribute v : Integer = 4; + attribute d : Integer = 2; + attribute route : Integer = 0; + + entry; then work; + state work parallel { + state a { + entry; then a1; + state a1; + state a2; + transition first a1 accept Go do assign d := 0 then a2; + } + state b { + entry; then b1; + state b1; + junction split; + state left; + state right; + transition first b1 accept Go then split; + transition first split if v > 0 do assign route := 1 then left; + transition first split if v / d > 1 do assign route := 2 then right; + } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.trace.golden new file mode 100644 index 0000000000..e847d59575 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_guards_read_once.trace.golden @@ -0,0 +1,15 @@ + eval feature v -> 4 + eval literal 0 -> 0 +eval operator > -> true +choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first) +exit: a1 +stmt assign d + eval literal 0 -> 0 +enter: a2 +transition: a1 -> a2 (event: accept Go) +choice junction split: transitions 1->left, 2->right (unordered; took 1->left) +exit: b1 +stmt assign route + eval literal 1 -> 1 +enter: left +transition: b1 -> left (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_composite.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_inside_composite.expected.json new file mode 100644 index 0000000000..5776d0b1db --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_composite.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}, {"signal": "Stop"}], + "finalState": "done", + "stateVisits": ["idle", "work", "w2", "done"], + "outputs": { + "x": {"type": "Integer", "value": 0}, + "log": {"type": "String", "value": "go(effect);work(entry);toW2(effect);w2(entry);work(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_composite.sysml b/internal/exec/runtime/testdata/conformance/state_junction_inside_composite.sysml new file mode 100644 index 0000000000..dfd914087b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_composite.sysml @@ -0,0 +1,28 @@ +// A transition into a junction declared inside a composite state enters the +// composite on its way to the junction: the segment out of the junction is a +// performance enclosed by the composite, so its effect runs after the +// composite's entry behavior and before the target's. +package Test { + state Machine { + attribute log : String = ""; + attribute x : Integer = 0; + + entry; then idle; + state idle; + state work { + entry { assign log := log + "work(entry);"; } + exit { assign log := log + "work(exit);"; } + junction split; + state w1 { entry { assign log := log + "w1(entry);"; } } + state w2 { entry { assign log := log + "w2(entry);"; } } + + transition first split if x == 1 do { assign log := log + "toW1(effect);"; } then w1; + transition first split do { assign log := log + "toW2(effect);"; } then w2; + } + + transition first idle accept Go do { assign log := log + "go(effect);"; } then split; + transition first work accept Stop then done; + } + attribute def Go; + attribute def Stop; +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_composite.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_inside_composite.trace.golden new file mode 100644 index 0000000000..823010a8e9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_composite.trace.golden @@ -0,0 +1,36 @@ + eval feature x -> 0 + eval literal 1 -> 1 +eval operator == -> false +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "go(effect);" -> "go(effect);" + eval operator + -> "go(effect);" +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);" + eval literal "work(entry);" -> "work(entry);" + eval operator + -> "go(effect);work(entry);" +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);" + eval literal "toW2(effect);" -> "toW2(effect);" + eval operator + -> "go(effect);work(entry);toW2(effect);" +enter: w2 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW2(effect);" + eval literal "w2(entry);" -> "w2(entry);" + eval operator + -> "go(effect);work(entry);toW2(effect);w2(entry);" +transition: idle -> w2 (event: accept Go) +exit: w2 +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);toW2(effect);w2(entry);" + eval literal "work(exit);" -> "work(exit);" + eval operator + -> "go(effect);work(entry);toW2(effect);w2(entry);work(exit);" +enter: done +transition: w2 -> done (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_nested_composite.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_inside_nested_composite.expected.json new file mode 100644 index 0000000000..41b13ecc39 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_nested_composite.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}, {"signal": "Stop"}], + "finalState": "done", + "stateVisits": ["idle", "outer", "inner", "leaf", "done"], + "outputs": { + "log": {"type": "String", "value": "go(effect);outer(entry);j1(effect);inner(entry);j2(effect);leaf(entry);inner(exit);outer(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_nested_composite.sysml b/internal/exec/runtime/testdata/conformance/state_junction_inside_nested_composite.sysml new file mode 100644 index 0000000000..29f914f003 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_nested_composite.sysml @@ -0,0 +1,32 @@ +// A route through two junctions, each declared in a composite state nested one +// level deeper, runs each segment's effect after the entry of the composite +// declaring the junction it leaves: outer's entry, the segment out of its +// junction, inner's entry, the segment out of inner's junction, then the target. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state outer { + entry { assign log := log + "outer(entry);"; } + exit { assign log := log + "outer(exit);"; } + junction j1; + state inner { + entry { assign log := log + "inner(entry);"; } + exit { assign log := log + "inner(exit);"; } + junction j2; + state leaf { entry { assign log := log + "leaf(entry);"; } } + + transition first j2 do { assign log := log + "j2(effect);"; } then leaf; + } + + transition first j1 do { assign log := log + "j1(effect);"; } then j2; + } + + transition first idle accept Go do { assign log := log + "go(effect);"; } then j1; + transition first outer accept Stop then done; + } + attribute def Go; + attribute def Stop; +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_nested_composite.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_inside_nested_composite.trace.golden new file mode 100644 index 0000000000..dac329ce6c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_nested_composite.trace.golden @@ -0,0 +1,50 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "go(effect);" -> "go(effect);" + eval operator + -> "go(effect);" +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "go(effect);outer(entry);" +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);" + eval literal "j1(effect);" -> "j1(effect);" + eval operator + -> "go(effect);outer(entry);j1(effect);" +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);j1(effect);" + eval literal "inner(entry);" -> "inner(entry);" + eval operator + -> "go(effect);outer(entry);j1(effect);inner(entry);" +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);j1(effect);inner(entry);" + eval literal "j2(effect);" -> "j2(effect);" + eval operator + -> "go(effect);outer(entry);j1(effect);inner(entry);j2(effect);" +enter: leaf (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);j1(effect);inner(entry);j2(effect);" + eval literal "leaf(entry);" -> "leaf(entry);" + eval operator + -> "go(effect);outer(entry);j1(effect);inner(entry);j2(effect);leaf(entry);" +transition: idle -> leaf (event: accept Go) +exit: leaf +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);j1(effect);inner(entry);j2(effect);leaf(entry);" + eval literal "inner(exit);" -> "inner(exit);" + eval operator + -> "go(effect);outer(entry);j1(effect);inner(entry);j2(effect);leaf(entry);inner(exit);" +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);outer(entry);j1(effect);inner(entry);j2(effect);leaf(entry);inner(exit);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "go(effect);outer(entry);j1(effect);inner(entry);j2(effect);leaf(entry);inner(exit);outer(exit);" +enter: done +transition: leaf -> done (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.expected.json new file mode 100644 index 0000000000..490c9886f2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "schedule": "reverse", + "trace": true, + "events": [{"signal": "Go"}, {"signal": "Stop"}], + "finalState": "done", + "stateVisits": ["idle", "both", "l1", "r0", "done"], + "outputs": { + "x": {"type": "Integer", "value": 0}, + "log": {"type": "String", "value": "go(effect);both(entry);split(effect);l1(entry);r0(entry);both(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.sysml b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.sysml new file mode 100644 index 0000000000..e6d1b9b6a4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.sysml @@ -0,0 +1,35 @@ +// A transition into a junction declared in one region of a parallel state enters +// the parallel state first: the segment out of the junction runs its effect +// after the parallel state's entry, as that region is entered, while the other +// region starts as usual. +package Test { + state Machine { + attribute log : String = ""; + attribute x : Integer = 0; + + entry; then idle; + state idle; + state both parallel { + entry { assign log := log + "both(entry);"; } + exit { assign log := log + "both(exit);"; } + state left { + entry; then l0; + state l0 { entry { assign log := log + "l0(entry);"; } } + junction split; + state l1 { entry { assign log := log + "l1(entry);"; } } + + transition first split if x == 1 then l0; + transition first split do { assign log := log + "split(effect);"; } then l1; + } + state right { + entry; then r0; + state r0 { entry { assign log := log + "r0(entry);"; } } + } + } + + transition first idle accept Go do { assign log := log + "go(effect);"; } then split; + transition first both accept Stop then done; + } + attribute def Go; + attribute def Stop; +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.trace.golden new file mode 100644 index 0000000000..54fab84e62 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.trace.golden @@ -0,0 +1,45 @@ + eval feature x -> 0 + eval literal 1 -> 1 +eval operator == -> false +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "go(effect);" -> "go(effect);" + eval operator + -> "go(effect);" +enter: both (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);" + eval literal "both(entry);" -> "both(entry);" + eval operator + -> "go(effect);both(entry);" +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);" + eval literal "split(effect);" -> "split(effect);" + eval operator + -> "go(effect);both(entry);split(effect);" +choice entering both: next l1(entry), r0(entry) (unordered; took l1(entry) first) +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);split(effect);" + eval literal "l1(entry);" -> "l1(entry);" + eval operator + -> "go(effect);both(entry);split(effect);l1(entry);" +enter: r0 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);split(effect);l1(entry);" + eval literal "r0(entry);" -> "r0(entry);" + eval operator + -> "go(effect);both(entry);split(effect);l1(entry);r0(entry);" +transition: idle -> l1 (event: accept Go) +choice exiting both: next l1(exit), r0(exit) (unordered; took l1(exit) first) +exit: l1 +exit: r0 +exit: both (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);split(effect);l1(entry);r0(entry);" + eval literal "both(exit);" -> "both(exit);" + eval operator + -> "go(effect);both(entry);split(effect);l1(entry);r0(entry);both(exit);" +enter: done +transition: both -> done (event: accept Stop) diff --git a/internal/core/runtime/testdata/conformance/state_junction_pseudostate.check.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_pseudostate.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_junction_pseudostate.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_junction_pseudostate.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_junction_pseudostate.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_pseudostate.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_junction_pseudostate.expected.json rename to internal/exec/runtime/testdata/conformance/state_junction_pseudostate.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_junction_pseudostate.sysml b/internal/exec/runtime/testdata/conformance/state_junction_pseudostate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_junction_pseudostate.sysml rename to internal/exec/runtime/testdata/conformance/state_junction_pseudostate.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.check.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.check.expected.json new file mode 100644 index 0000000000..af93fc3551 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.check.expected.json @@ -0,0 +1,4 @@ +{ + "verdict": "divergent", + "divergent": {"finalState": ["left", "right"], "route": ["1", "2"]} +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.declared.trace.golden new file mode 100644 index 0000000000..81af9dd669 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.declared.trace.golden @@ -0,0 +1,6 @@ +choice junction split: transitions 1->left, 2->right (unordered; took 1->left) +exit: idle +stmt assign route + eval literal 1 -> 1 +enter: left +transition: idle -> left (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.expected.json new file mode 100644 index 0000000000..12ced213c4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.expected.json @@ -0,0 +1,22 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "left", + "stateVisits": ["idle", "left"], + "outputs": { + "route": {"type": "Integer", "value": 1} + } + }, + { + "finalState": "right", + "stateVisits": ["idle", "right"], + "outputs": { + "route": {"type": "Integer", "value": 2} + } + } + ], + "admissible": "Two branches of a junction enabled when the transition is selected: exactly one is taken, which one is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.seed-1.trace.golden new file mode 100644 index 0000000000..11e1ad5a67 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.seed-1.trace.golden @@ -0,0 +1,6 @@ +choice junction split: transitions 1->left, 2->right (unordered; took 2->right) +exit: idle +stmt assign route + eval literal 2 -> 2 +enter: right +transition: idle -> right (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.sysml b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.sysml new file mode 100644 index 0000000000..da16eee708 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.sysml @@ -0,0 +1,19 @@ +// Two branches out of a junction are enabled at once: both unguarded, so both +// hold when the compound transition is selected. Exactly one is taken, and +// which one is open: the junction is reported as a choice point, so a sweep +// enumerates both runs, each taking a different branch effect with it. +package Test { + state Machine { + attribute route : Integer = 0; + + entry; then idle; + state idle; + junction split; + state left; + state right; + + transition first idle accept Go then split; + transition first split do assign route := 1 then left; + transition first split do assign route := 2 then right; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.trace.golden new file mode 100644 index 0000000000..81af9dd669 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_several_enabled_branches.trace.golden @@ -0,0 +1,6 @@ +choice junction split: transitions 1->left, 2->right (unordered; took 1->left) +exit: idle +stmt assign route + eval literal 1 -> 1 +enter: left +transition: idle -> left (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_then_choice_inside_composite.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_then_choice_inside_composite.expected.json new file mode 100644 index 0000000000..8071049874 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_then_choice_inside_composite.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}, {"signal": "Stop"}], + "finalState": "done", + "stateVisits": ["idle", "work", "w1", "done"], + "outputs": { + "x": {"type": "Integer", "value": 1}, + "log": {"type": "String", "value": "go(effect);work(entry);j(effect);c1(effect);w1(entry);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_then_choice_inside_composite.sysml b/internal/exec/runtime/testdata/conformance/state_junction_then_choice_inside_composite.sysml new file mode 100644 index 0000000000..5888674404 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_then_choice_inside_composite.sysml @@ -0,0 +1,28 @@ +// A junction and a choice declared inside a composite state, on one compound +// transition into it: the composite is entered before the junction's segment +// runs its effect, and the choice's guards read what that effect wrote. +package Test { + state Machine { + attribute log : String = ""; + attribute x : Integer = 0; + + entry; then idle; + state idle; + state work { + entry { assign log := log + "work(entry);"; } + junction j; + choice c; + state w1 { entry { assign log := log + "w1(entry);"; } } + state w2 { entry { assign log := log + "w2(entry);"; } } + + transition first j do { assign x := 1; assign log := log + "j(effect);"; } then c; + transition first c if x == 1 do { assign log := log + "c1(effect);"; } then w1; + transition first c do { assign log := log + "c2(effect);"; } then w2; + } + + transition first idle accept Go do { assign log := log + "go(effect);"; } then j; + transition first work accept Stop then done; + } + attribute def Go; + attribute def Stop; +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_then_choice_inside_composite.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_then_choice_inside_composite.trace.golden new file mode 100644 index 0000000000..74720b4e1b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_then_choice_inside_composite.trace.golden @@ -0,0 +1,38 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "go(effect);" -> "go(effect);" + eval operator + -> "go(effect);" +enter: work (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);" + eval literal "work(entry);" -> "work(entry);" + eval operator + -> "go(effect);work(entry);" +stmt action body + stmt assign x + eval literal 1 -> 1 + stmt assign log + eval feature log -> "go(effect);work(entry);" + eval literal "j(effect);" -> "j(effect);" + eval operator + -> "go(effect);work(entry);j(effect);" + eval feature x -> 1 + eval literal 1 -> 1 +eval operator == -> true +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);j(effect);" + eval literal "c1(effect);" -> "c1(effect);" + eval operator + -> "go(effect);work(entry);j(effect);c1(effect);" +enter: w1 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);work(entry);j(effect);c1(effect);" + eval literal "w1(entry);" -> "w1(entry);" + eval operator + -> "go(effect);work(entry);j(effect);c1(effect);w1(entry);" +transition: idle -> w1 (event: accept Go) +exit: w1 +exit: work +enter: done +transition: w1 -> done (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_machine_body_deep_history.expected.json b/internal/exec/runtime/testdata/conformance/state_machine_body_deep_history.expected.json new file mode 100644 index 0000000000..9a44c61d65 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_machine_body_deep_history.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go", "args": null}, + {"signal": "Next", "args": null}, + {"signal": "Again", "args": null}, + {"signal": "Shallow", "args": null}, + {"signal": "Next", "args": null}, + {"signal": "Finish", "args": null} + ], + "finalState": "done", + "stateVisits": ["start", "outer", "first", "inner", "outer", "inner", "outer", "first", "inner", "done"], + "outputs": { + "log": {"type": "String", "value": "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);outer(exit);shallow(effect);outer(entry);inner(entry);outer(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_machine_body_deep_history.sysml b/internal/exec/runtime/testdata/conformance/state_machine_body_deep_history.sysml new file mode 100644 index 0000000000..ba11ee5e9b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_machine_body_deep_history.sysml @@ -0,0 +1,29 @@ +// A deep history declared in the machine's own body restores the machine's +// top-level configuration, which is the one the transition into it leaves: +// Again out of `inner` exits `outer` and re-enters it at `inner`, not at +// `outer`'s initial state `first`. A shallow history in the same body restores +// `outer` and lets it start afresh. +package Test { + state MachineBodyDeepHistory { + attribute log : String = ""; + entry; then start; + state start; + deep history resume; + shallow history back; + state outer { + entry action { assign log := log + "outer(entry);"; } + exit action { assign log := log + "outer(exit);"; } + entry; then first; + state first; + state inner { + entry action { assign log := log + "inner(entry);"; } + } + transition first first when Next then inner; + } + + transition first start when Go then outer; + transition first inner when Again do { assign log := log + "again(effect);"; } then resume; + transition first inner when Shallow do { assign log := log + "shallow(effect);"; } then back; + transition first inner when Finish then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_machine_body_deep_history.trace.golden b/internal/exec/runtime/testdata/conformance/state_machine_body_deep_history.trace.golden new file mode 100644 index 0000000000..866c8b4fd6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_machine_body_deep_history.trace.golden @@ -0,0 +1,79 @@ +exit: start +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);" +enter: first +transition: start -> outer (event: accept Go) +exit: first +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);" + eval literal "inner(entry);" -> "inner(entry);" + eval operator + -> "outer(entry);inner(entry);" +transition: first -> inner (event: accept Next) +exit: inner +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);inner(entry);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);outer(exit);" + eval literal "again(effect);" -> "again(effect);" + eval operator + -> "outer(entry);inner(entry);outer(exit);again(effect);" +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);outer(exit);again(effect);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);" +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);" + eval literal "inner(entry);" -> "inner(entry);" + eval operator + -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);" +transition: inner -> inner (event: accept Again) +exit: inner +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);outer(exit);" + eval literal "shallow(effect);" -> "shallow(effect);" + eval operator + -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);outer(exit);shallow(effect);" +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);outer(exit);shallow(effect);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);outer(exit);shallow(effect);outer(entry);" +enter: first +transition: inner -> outer (event: accept Shallow) +exit: first +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);outer(exit);shallow(effect);outer(entry);" + eval literal "inner(entry);" -> "inner(entry);" + eval operator + -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);outer(exit);shallow(effect);outer(entry);inner(entry);" +transition: first -> inner (event: accept Next) +exit: inner +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);outer(exit);shallow(effect);outer(entry);inner(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);inner(entry);outer(exit);again(effect);outer(entry);inner(entry);outer(exit);shallow(effect);outer(entry);inner(entry);outer(exit);" +enter: done +transition: inner -> done (event: accept Finish) diff --git a/internal/core/runtime/testdata/conformance/state_machine_own_behaviors.expected.json b/internal/exec/runtime/testdata/conformance/state_machine_own_behaviors.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_machine_own_behaviors.expected.json rename to internal/exec/runtime/testdata/conformance/state_machine_own_behaviors.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_machine_own_behaviors.sysml b/internal/exec/runtime/testdata/conformance/state_machine_own_behaviors.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_machine_own_behaviors.sysml rename to internal/exec/runtime/testdata/conformance/state_machine_own_behaviors.sysml diff --git a/internal/core/runtime/testdata/conformance/state_member_then_order.expected.json b/internal/exec/runtime/testdata/conformance/state_member_then_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_member_then_order.expected.json rename to internal/exec/runtime/testdata/conformance/state_member_then_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_member_then_order.sysml b/internal/exec/runtime/testdata/conformance/state_member_then_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_member_then_order.sysml rename to internal/exec/runtime/testdata/conformance/state_member_then_order.sysml diff --git a/internal/core/runtime/testdata/conformance/state_nested_parallel_entry_exit_behavior.expected.json b/internal/exec/runtime/testdata/conformance/state_nested_parallel_entry_exit_behavior.expected.json similarity index 91% rename from internal/core/runtime/testdata/conformance/state_nested_parallel_entry_exit_behavior.expected.json rename to internal/exec/runtime/testdata/conformance/state_nested_parallel_entry_exit_behavior.expected.json index 69371b74d4..80d54de954 100644 --- a/internal/core/runtime/testdata/conformance/state_nested_parallel_entry_exit_behavior.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_nested_parallel_entry_exit_behavior.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ {"signal": "Finish", "args": null} ], diff --git a/internal/core/runtime/testdata/conformance/state_nested_parallel_entry_exit_behavior.sysml b/internal/exec/runtime/testdata/conformance/state_nested_parallel_entry_exit_behavior.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_nested_parallel_entry_exit_behavior.sysml rename to internal/exec/runtime/testdata/conformance/state_nested_parallel_entry_exit_behavior.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_nested_parallel_owner_entry_once_intra_region.expected.json b/internal/exec/runtime/testdata/conformance/state_nested_parallel_owner_entry_once_intra_region.expected.json new file mode 100644 index 0000000000..495d46174f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_nested_parallel_owner_entry_once_intra_region.expected.json @@ -0,0 +1,15 @@ +{ + "type": "state", + "schedule": "reverse", + "events": [ + {"signal": "Go", "args": null}, + {"signal": "Leave", "args": null} + ], + "finalState": "waited", + "stateVisits": ["outer", "prep", "ridle", "work", "waited"], + "outputs": { + "outerEntered": {"type": "Integer", "value": 1}, + "entered": {"type": "Integer", "value": 1}, + "exited": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_nested_parallel_owner_entry_once_intra_region.sysml b/internal/exec/runtime/testdata/conformance/state_nested_parallel_owner_entry_once_intra_region.sysml new file mode 100644 index 0000000000..07fdfd1350 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_nested_parallel_owner_entry_once_intra_region.sysml @@ -0,0 +1,35 @@ +package NestedParallelOwnerEntryOnceIntraRegion { + private import ScalarValues::*; + + state def Machine { + attribute outerEntered : Integer = 0; + attribute entered : Integer = 0; + attribute exited : Integer = 0; + + entry; then outer; + state outer parallel { + entry action beginOuter { + assign outerEntered := outerEntered + 1; + } + state left { + entry action begin { + assign entered := entered + 1; + } + exit action finish { + assign exited := exited + 1; + } + entry; then prep; + state prep; + state work; + transition first prep when Go then work; + } + state right { + entry; then ridle; + state ridle; + } + } + state waited; + + transition first outer when Leave then waited; + } +} diff --git a/internal/core/runtime/testdata/conformance/state_nested_parallel_region_owner_behavior.expected.json b/internal/exec/runtime/testdata/conformance/state_nested_parallel_region_owner_behavior.expected.json similarity index 91% rename from internal/core/runtime/testdata/conformance/state_nested_parallel_region_owner_behavior.expected.json rename to internal/exec/runtime/testdata/conformance/state_nested_parallel_region_owner_behavior.expected.json index 67c0006bb2..631652acdd 100644 --- a/internal/core/runtime/testdata/conformance/state_nested_parallel_region_owner_behavior.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_nested_parallel_region_owner_behavior.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ {"signal": "Ping", "args": null}, {"signal": "Leave", "args": null} diff --git a/internal/core/runtime/testdata/conformance/state_nested_parallel_region_owner_behavior.sysml b/internal/exec/runtime/testdata/conformance/state_nested_parallel_region_owner_behavior.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_nested_parallel_region_owner_behavior.sysml rename to internal/exec/runtime/testdata/conformance/state_nested_parallel_region_owner_behavior.sysml diff --git a/internal/core/runtime/testdata/conformance/state_orthogonal_regions.expected.json b/internal/exec/runtime/testdata/conformance/state_orthogonal_regions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_orthogonal_regions.expected.json rename to internal/exec/runtime/testdata/conformance/state_orthogonal_regions.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_orthogonal_regions.sysml b/internal/exec/runtime/testdata/conformance/state_orthogonal_regions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_orthogonal_regions.sysml rename to internal/exec/runtime/testdata/conformance/state_orthogonal_regions.sysml diff --git a/internal/core/runtime/testdata/conformance/state_outer_completion_to_next.expected.json b/internal/exec/runtime/testdata/conformance/state_outer_completion_to_next.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_outer_completion_to_next.expected.json rename to internal/exec/runtime/testdata/conformance/state_outer_completion_to_next.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_outer_completion_to_next.sysml b/internal/exec/runtime/testdata/conformance/state_outer_completion_to_next.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_outer_completion_to_next.sysml rename to internal/exec/runtime/testdata/conformance/state_outer_completion_to_next.sysml diff --git a/internal/core/runtime/testdata/conformance/state_outer_completion_to_next.trace.golden b/internal/exec/runtime/testdata/conformance/state_outer_completion_to_next.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_outer_completion_to_next.trace.golden rename to internal/exec/runtime/testdata/conformance/state_outer_completion_to_next.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_parallel_accept_after.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_accept_after.expected.json similarity index 83% rename from internal/core/runtime/testdata/conformance/state_parallel_accept_after.expected.json rename to internal/exec/runtime/testdata/conformance/state_parallel_accept_after.expected.json index 87f4b77f4a..89a13b649d 100644 --- a/internal/core/runtime/testdata/conformance/state_parallel_accept_after.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_parallel_accept_after.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "libraries": true, "finalState": "ldone+active", "stateVisits": ["waiting", "active", "ldone"] diff --git a/internal/core/runtime/testdata/conformance/state_parallel_accept_after.sysml b/internal/exec/runtime/testdata/conformance/state_parallel_accept_after.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_parallel_accept_after.sysml rename to internal/exec/runtime/testdata/conformance/state_parallel_accept_after.sysml diff --git a/internal/core/runtime/testdata/conformance/state_parallel_body_region_definition.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_body_region_definition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_parallel_body_region_definition.expected.json rename to internal/exec/runtime/testdata/conformance/state_parallel_body_region_definition.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_parallel_body_region_definition.sysml b/internal/exec/runtime/testdata/conformance/state_parallel_body_region_definition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_parallel_body_region_definition.sysml rename to internal/exec/runtime/testdata/conformance/state_parallel_body_region_definition.sysml diff --git a/internal/core/runtime/testdata/conformance/state_parallel_broadcast.check.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_broadcast.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_parallel_broadcast.check.expected.json rename to internal/exec/runtime/testdata/conformance/state_parallel_broadcast.check.expected.json diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_broadcast.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_broadcast.expected.json new file mode 100644 index 0000000000..9c10b01758 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_broadcast.expected.json @@ -0,0 +1,23 @@ +{ + "type": "state", + "events": [{"signal": "Tick"}], + "outcomes": [ + { + "finalState": "ldone+rdone", + "stateVisits": ["lidle", "ridle", "ldone", "rdone"] + }, + { + "finalState": "ldone+rdone", + "stateVisits": ["lidle", "ridle", "rdone", "ldone"] + }, + { + "finalState": "ldone+rdone", + "stateVisits": ["ridle", "lidle", "ldone", "rdone"] + }, + { + "finalState": "ldone+rdone", + "stateVisits": ["ridle", "lidle", "rdone", "ldone"] + } + ], + "admissible": "Transitions in sibling regions enabled by one event: each fires, in which order is open" +} diff --git a/internal/core/runtime/testdata/conformance/state_parallel_broadcast.sysml b/internal/exec/runtime/testdata/conformance/state_parallel_broadcast.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_parallel_broadcast.sysml rename to internal/exec/runtime/testdata/conformance/state_parallel_broadcast.sysml diff --git a/internal/core/runtime/testdata/conformance/state_parallel_completion.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_completion.expected.json similarity index 89% rename from internal/core/runtime/testdata/conformance/state_parallel_completion.expected.json rename to internal/exec/runtime/testdata/conformance/state_parallel_completion.expected.json index 2c8c4f371c..f2d01d0e99 100644 --- a/internal/core/runtime/testdata/conformance/state_parallel_completion.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_parallel_completion.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ {"signal": "First", "args": null}, {"signal": "Second", "args": null} diff --git a/internal/core/runtime/testdata/conformance/state_parallel_completion.sysml b/internal/exec/runtime/testdata/conformance/state_parallel_completion.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_parallel_completion.sysml rename to internal/exec/runtime/testdata/conformance/state_parallel_completion.sysml diff --git a/internal/core/runtime/testdata/conformance/state_parallel_entry_behavior.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_entry_behavior.expected.json similarity index 86% rename from internal/core/runtime/testdata/conformance/state_parallel_entry_behavior.expected.json rename to internal/exec/runtime/testdata/conformance/state_parallel_entry_behavior.expected.json index a13fa665d2..19e00cece7 100644 --- a/internal/core/runtime/testdata/conformance/state_parallel_entry_behavior.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_parallel_entry_behavior.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "finalState": "lidle+ridle", "stateVisits": ["lidle", "ridle"], "outputs": { diff --git a/internal/core/runtime/testdata/conformance/state_parallel_entry_behavior.sysml b/internal/exec/runtime/testdata/conformance/state_parallel_entry_behavior.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_parallel_entry_behavior.sysml rename to internal/exec/runtime/testdata/conformance/state_parallel_entry_behavior.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_owner_entry_once_intra_region.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_owner_entry_once_intra_region.expected.json new file mode 100644 index 0000000000..000d83e26a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_owner_entry_once_intra_region.expected.json @@ -0,0 +1,14 @@ +{ + "type": "state", + "schedule": "reverse", + "events": [ + {"signal": "Go", "args": null} + ], + "finalState": "work+ridle", + "stateVisits": ["prep", "ridle", "work"], + "outputs": { + "entered": {"type": "Integer", "value": 1}, + "exited": {"type": "Integer", "value": 0}, + "worked": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_owner_entry_once_intra_region.sysml b/internal/exec/runtime/testdata/conformance/state_parallel_owner_entry_once_intra_region.sysml new file mode 100644 index 0000000000..88c908e4da --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_owner_entry_once_intra_region.sysml @@ -0,0 +1,28 @@ +package ParallelOwnerEntryOnceIntraRegion { + private import ScalarValues::*; + + state def Machine parallel { + attribute entered : Integer = 0; + attribute exited : Integer = 0; + attribute worked : Integer = 0; + state left { + entry action begin { + assign entered := entered + 1; + } + do action work { + assign worked := worked + 1; + } + exit action finish { + assign exited := exited + 1; + } + entry; then prep; + state prep; + state work; + transition first prep when Go then work; + } + state right { + entry; then ridle; + state ridle; + } + } +} diff --git a/internal/core/runtime/testdata/conformance/state_parallel_standard.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_standard.expected.json similarity index 83% rename from internal/core/runtime/testdata/conformance/state_parallel_standard.expected.json rename to internal/exec/runtime/testdata/conformance/state_parallel_standard.expected.json index 376365ac37..baa1e8f2dd 100644 --- a/internal/core/runtime/testdata/conformance/state_parallel_standard.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_parallel_standard.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "trace": true, "finalState": "ldone+rdone", "stateVisits": ["lstart", "rstart", "ldone", "rdone"] diff --git a/internal/core/runtime/testdata/conformance/state_parallel_standard.sysml b/internal/exec/runtime/testdata/conformance/state_parallel_standard.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_parallel_standard.sysml rename to internal/exec/runtime/testdata/conformance/state_parallel_standard.sysml diff --git a/internal/core/runtime/testdata/conformance/state_parallel_standard.trace.golden b/internal/exec/runtime/testdata/conformance/state_parallel_standard.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_parallel_standard.trace.golden rename to internal/exec/runtime/testdata/conformance/state_parallel_standard.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region.expected.json new file mode 100644 index 0000000000..0ca4997d15 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "events": [ + { + "signal": "Go" + }, + { + "signal": "Stop" + } + ], + "finalState": "done", + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region.sysml b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region.sysml new file mode 100644 index 0000000000..8a96e6bbab --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region.sysml @@ -0,0 +1,20 @@ +// A region of a parallel state stood for by a state with no substates needs no +// initial: it starts in that state and stays there while its sibling region runs. +package test { + attribute def Go; + attribute def Stop; + + state sm { + entry; then busy; + state busy parallel { + state work { + entry; then wstart; + state wstart; + state wdone; + transition first wstart accept Go then wdone; + } + state idle; + } + transition first busy accept Stop then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region.trace.golden new file mode 100644 index 0000000000..e72e418871 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region.trace.golden @@ -0,0 +1,7 @@ +exit: wstart +enter: wdone +transition: wstart -> wdone (event: accept Go) +exit: wdone +exit: busy +enter: done +transition: busy -> done (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region_with_behaviors.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region_with_behaviors.expected.json new file mode 100644 index 0000000000..cdfa51b2e5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region_with_behaviors.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "events": [ + {"signal": "Go"}, + {"signal": "Stop"} + ], + "finalState": "done", + "outputs": { + "log": {"type": "Integer", "value": 12} + }, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region_with_behaviors.sysml b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region_with_behaviors.sysml new file mode 100644 index 0000000000..c1095dfb87 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region_with_behaviors.sysml @@ -0,0 +1,25 @@ +// A region of a parallel state stood for by a state declaring entry and exit +// behaviors, but no substates, needs no initial: it starts in that state, runs +// its entry, and runs its exit when the parallel state is left. +package test { + attribute def Go; + attribute def Stop; + + state sm { + attribute log : Integer = 0; + + entry; then busy; + state busy parallel { + state work { + entry; then wstart; + state wstart; + transition first wstart accept Go then done; + } + state idle { + entry { assign log := log * 10 + 1; } + exit { assign log := log * 10 + 2; } + } + } + transition first busy accept Stop then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region_with_behaviors.trace.golden b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region_with_behaviors.trace.golden new file mode 100644 index 0000000000..63f78db798 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_region_with_behaviors.trace.golden @@ -0,0 +1,15 @@ +exit: wstart +enter: done +transition: wstart -> done (event: accept Go) +choice exiting busy: next done(exit), idle(exit) (unordered; took done(exit) first) +exit: done +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +exit: busy +enter: done +transition: busy -> done (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_stateless_top_region.expected.json b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_top_region.expected.json new file mode 100644 index 0000000000..3dff5669bf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_top_region.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "events": [ + { + "signal": "Go" + } + ], + "finalState": "idle+done", + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_stateless_top_region.sysml b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_top_region.sysml new file mode 100644 index 0000000000..fd97f16653 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_top_region.sysml @@ -0,0 +1,15 @@ +// A region of a parallel machine stood for by a state with no substates needs +// no initial, as one of a parallel state does not: it starts in that state and +// stays there while its sibling region runs to done. +package test { + attribute def Go; + + state def Machine parallel { + state work { + entry; then wstart; + state wstart; + transition first wstart accept Go then done; + } + state idle; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_parallel_stateless_top_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_top_region.trace.golden new file mode 100644 index 0000000000..8bb99708af --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_parallel_stateless_top_region.trace.golden @@ -0,0 +1,3 @@ +exit: wstart +enter: done +transition: wstart -> done (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.expected.json b/internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.expected.json rename to internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.sysml b/internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.sysml rename to internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.sysml diff --git a/internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.trace.golden b/internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.trace.golden rename to internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_choice.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.expected.json b/internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.expected.json rename to internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.sysml b/internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.sysml rename to internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.sysml diff --git a/internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.trace.golden b/internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.trace.golden rename to internal/exec/runtime/testdata/conformance/state_pseudostate_chain_choice_junction.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.expected.json b/internal/exec/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.expected.json rename to internal/exec/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.sysml b/internal/exec/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.sysml rename to internal/exec/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.sysml diff --git a/internal/core/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.trace.golden b/internal/exec/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.trace.golden rename to internal/exec/runtime/testdata/conformance/state_pseudostate_chain_junction_choice.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_redefined_state_accept.expected.json b/internal/exec/runtime/testdata/conformance/state_redefined_state_accept.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_redefined_state_accept.expected.json rename to internal/exec/runtime/testdata/conformance/state_redefined_state_accept.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_redefined_state_accept.sysml b/internal/exec/runtime/testdata/conformance/state_redefined_state_accept.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_redefined_state_accept.sysml rename to internal/exec/runtime/testdata/conformance/state_redefined_state_accept.sysml diff --git a/internal/core/runtime/testdata/conformance/state_redefined_state_accept_symbol.expected.json b/internal/exec/runtime/testdata/conformance/state_redefined_state_accept_symbol.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_redefined_state_accept_symbol.expected.json rename to internal/exec/runtime/testdata/conformance/state_redefined_state_accept_symbol.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_redefined_state_accept_symbol.sysml b/internal/exec/runtime/testdata/conformance/state_redefined_state_accept_symbol.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_redefined_state_accept_symbol.sysml rename to internal/exec/runtime/testdata/conformance/state_redefined_state_accept_symbol.sysml diff --git a/internal/core/runtime/testdata/conformance/state_region_choice.expected.json b/internal/exec/runtime/testdata/conformance/state_region_choice.expected.json similarity index 84% rename from internal/core/runtime/testdata/conformance/state_region_choice.expected.json rename to internal/exec/runtime/testdata/conformance/state_region_choice.expected.json index 29e0f065e3..9700f72a8d 100644 --- a/internal/core/runtime/testdata/conformance/state_region_choice.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_region_choice.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "finalState": "lfast+rwatch", "stateVisits": ["lstart", "rstart", "lidle", "rwatch", "lfast"], "outputs": {} diff --git a/internal/core/runtime/testdata/conformance/state_region_choice.sysml b/internal/exec/runtime/testdata/conformance/state_region_choice.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_region_choice.sysml rename to internal/exec/runtime/testdata/conformance/state_region_choice.sysml diff --git a/internal/core/runtime/testdata/conformance/state_region_choice.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_choice.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_region_choice.trace.golden rename to internal/exec/runtime/testdata/conformance/state_region_choice.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.check.expected.json b/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.check.expected.json new file mode 100644 index 0000000000..c50d743fc0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.check.expected.json @@ -0,0 +1,4 @@ +{ + "verdict": "no violation, exhaustive", + "agreed": {"finalState": "fin", "log": "\"waited C-done S1-done \""} +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.expected.json b/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.expected.json new file mode 100644 index 0000000000..e1690fe136 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.expected.json @@ -0,0 +1,22 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "B" + }, + { + "signal": "A" + }, + { + "signal": "Never" + } + ], + "finalState": "fin", + "outputs": { + "log": { + "type": "String", + "value": "waited C-done S1-done " + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.sysml b/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.sysml new file mode 100644 index 0000000000..4e23be22c0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.sysml @@ -0,0 +1,37 @@ +// A region completes at its own `done`, not at the `done` of a composite nested in +// it: `C`'s body reaching `done` completes `C`'s body while `C` stays active for its +// do behavior, so region `r3` — and with it `S1` — is not complete once `r2` is. +// `S1` completes only after `C` leaves for `r3`'s `done`, which `Never` lets it. +package Test { + state Machine { + attribute log : String = ""; + + entry; then S1; + state S1 parallel { + state r2 { + entry; then x; + state x; + transition first x accept A then done; + } + state r3 { + entry; then C; + state C { + do action wait { + first start; + then action w accept Never; + then action mark assign log := log + "waited "; + } + entry; then c1; + state c1; + transition first c1 accept B then done; + } + transition first C do assign log := log + "C-done " then done; + } + } + transition first S1 do assign log := log + "S1-done " then fin; + state fin; + } + attribute def A; + attribute def B; + attribute def Never; +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.trace.golden new file mode 100644 index 0000000000..6b656a6e59 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_completes_at_own_done.trace.golden @@ -0,0 +1,33 @@ +do: C +stmt action body +enter action node: state behavior wait +exit: c1 +enter: done +transition: c1 -> done (event: accept B) +exit: x +enter: done +transition: x -> done (event: accept A) +do: C + stmt assign log + eval feature log -> "" + eval literal "waited " -> "waited " + eval operator + -> "waited " +leave action node: state behavior wait +exit: done +exit: C +stmt assign log + eval feature log -> "waited " + eval literal "C-done " -> "C-done " + eval operator + -> "waited C-done " +enter: done +transition: done -> done +choice exiting S1: next r2.done(exit), r3.done(exit) (unordered; took r2.done(exit) first) +exit: done +exit: done +exit: S1 +stmt assign log + eval feature log -> "waited C-done " + eval literal "S1-done " -> "S1-done " + eval operator + -> "waited C-done S1-done " +enter: fin +transition: S1 -> fin diff --git a/internal/core/runtime/testdata/conformance/state_region_completion.expected.json b/internal/exec/runtime/testdata/conformance/state_region_completion.expected.json similarity index 89% rename from internal/core/runtime/testdata/conformance/state_region_completion.expected.json rename to internal/exec/runtime/testdata/conformance/state_region_completion.expected.json index 2c8c4f371c..f2d01d0e99 100644 --- a/internal/core/runtime/testdata/conformance/state_region_completion.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_region_completion.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "events": [ {"signal": "First", "args": null}, {"signal": "Second", "args": null} diff --git a/internal/core/runtime/testdata/conformance/state_region_completion.sysml b/internal/exec/runtime/testdata/conformance/state_region_completion.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_region_completion.sysml rename to internal/exec/runtime/testdata/conformance/state_region_completion.sysml diff --git a/internal/core/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.expected.json b/internal/exec/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.expected.json similarity index 89% rename from internal/core/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.expected.json rename to internal/exec/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.expected.json index 993b49de98..9c74dd9ace 100644 --- a/internal/core/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "trace": true, "finalState": "seen", "stateVisits": ["outer", "l", "r", "seen"], diff --git a/internal/core/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.sysml b/internal/exec/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.sysml rename to internal/exec/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.trace.golden new file mode 100644 index 0000000000..762ff5360a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_cross_choice_sibling_exit.trace.golden @@ -0,0 +1,13 @@ +exit: l +exit: r (exit action) +stmt action body + stmt assign x + eval literal 1 -> 1 + eval feature x -> 1 + eval literal 1 -> 1 +eval operator == -> true +enter: seen (entry action) +stmt action body + stmt assign picked + eval literal 1 -> 1 +transition: l -> seen diff --git a/internal/core/runtime/testdata/conformance/state_region_cross_pseudostate.expected.json b/internal/exec/runtime/testdata/conformance/state_region_cross_pseudostate.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_region_cross_pseudostate.expected.json rename to internal/exec/runtime/testdata/conformance/state_region_cross_pseudostate.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_region_cross_pseudostate.sysml b/internal/exec/runtime/testdata/conformance/state_region_cross_pseudostate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_region_cross_pseudostate.sysml rename to internal/exec/runtime/testdata/conformance/state_region_cross_pseudostate.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_region_cross_pseudostate.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_cross_pseudostate.trace.golden new file mode 100644 index 0000000000..beea3fe190 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_cross_pseudostate.trace.golden @@ -0,0 +1,25 @@ +exit: start +enter: running +choice entering running: next lstart(entry), rstart(entry) (unordered; took lstart(entry) first) +enter: lstart +enter: rstart +transition: start -> running +exit: lstart +enter: lidle +transition: lstart -> lidle +exit: rstart +enter: ridle +transition: rstart -> ridle + eval feature crossed -> 0 + eval literal 0 -> 0 +eval operator == -> true +exit: lidle +exit: ridle + eval feature crossed -> 0 + eval literal 0 -> 0 +eval operator == -> true +enter: rtarget (entry action) +stmt action body + stmt assign crossed + eval literal 1 -> 1 +transition: lidle -> rtarget diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.check.expected.json b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.check.expected.json new file mode 100644 index 0000000000..f79a661ff1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"p(entry) q(entry) right(entry) \"", + "\"p(entry) right(entry) q(entry) \"", + "\"q(entry) p(entry) right(entry) \"", + "\"q(entry) right(entry) p(entry) \"", + "\"right(entry) p(entry) q(entry) \"", + "\"right(entry) q(entry) p(entry) \"" + ] + }, + "agreed": { + "finalState": "inner+p1+q1+r" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.declared.trace.golden new file mode 100644 index 0000000000..ba4726dd32 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.declared.trace.golden @@ -0,0 +1,25 @@ +exit: idle +enter: work +choice entering work: next inner(entry), right(entry) (unordered; took inner(entry) first) +enter: inner +choice entering work: next p(entry), q(entry), right(entry) (unordered; took p(entry) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "p(entry) " -> "p(entry) " + eval operator + -> "p(entry) " +enter: p1 +choice entering work: next q(entry), right(entry) (unordered; took q(entry) first) +stmt action body + stmt assign log + eval feature log -> "p(entry) " + eval literal "q(entry) " -> "q(entry) " + eval operator + -> "p(entry) q(entry) " +enter: q1 +stmt action body + stmt assign log + eval feature log -> "p(entry) q(entry) " + eval literal "right(entry) " -> "right(entry) " + eval operator + -> "p(entry) q(entry) right(entry) " +enter: r +transition: idle -> work (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.expected.json b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.expected.json new file mode 100644 index 0000000000..9b9a96ebcf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.expected.json @@ -0,0 +1,66 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + } + ], + "outcomes": [ + { + "finalState": "inner+p1+q1+r", + "outputs": { + "log": { + "type": "String", + "value": "p(entry) q(entry) right(entry) " + } + } + }, + { + "finalState": "inner+p1+q1+r", + "outputs": { + "log": { + "type": "String", + "value": "p(entry) right(entry) q(entry) " + } + } + }, + { + "finalState": "inner+p1+q1+r", + "outputs": { + "log": { + "type": "String", + "value": "q(entry) p(entry) right(entry) " + } + } + }, + { + "finalState": "inner+p1+q1+r", + "outputs": { + "log": { + "type": "String", + "value": "q(entry) right(entry) p(entry) " + } + } + }, + { + "finalState": "inner+p1+q1+r", + "outputs": { + "log": { + "type": "String", + "value": "right(entry) p(entry) q(entry) " + } + } + }, + { + "finalState": "inner+p1+q1+r", + "outputs": { + "log": { + "type": "String", + "value": "right(entry) q(entry) p(entry) " + } + } + } + ], + "admissible": "Regions of a parallel state entered on one occurrence: each is entered, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.seed-1.trace.golden new file mode 100644 index 0000000000..a2e17b2d45 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.seed-1.trace.golden @@ -0,0 +1,24 @@ +exit: idle +enter: work +choice entering work: next inner(entry), right(entry) (unordered; took right(entry) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "right(entry) " -> "right(entry) " + eval operator + -> "right(entry) " +enter: r +enter: inner +choice entering work: next p(entry), q(entry) (unordered; took p(entry) first) +stmt action body + stmt assign log + eval feature log -> "right(entry) " + eval literal "p(entry) " -> "p(entry) " + eval operator + -> "right(entry) p(entry) " +enter: p1 +stmt action body + stmt assign log + eval feature log -> "right(entry) p(entry) " + eval literal "q(entry) " -> "q(entry) " + eval operator + -> "right(entry) p(entry) q(entry) " +enter: q1 +transition: idle -> work (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.sysml b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.sysml new file mode 100644 index 0000000000..c321fbe9b9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.sysml @@ -0,0 +1,37 @@ +// A region whose start state is itself parallel appends that state's regions to +// the front being drawn: its two regions' entries interleave with the sibling +// region's entry as three queues of one front, not as one unit. The six +// linearizations are the admissible set. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + entry; then inner; + state inner parallel { + state p { + entry { assign log := log + "p(entry) "; } + entry; then p1; + state p1; + } + state q { + entry { assign log := log + "q(entry) "; } + entry; then q1; + state q1; + } + } + } + state right { + entry { assign log := log + "right(entry) "; } + entry; then r; + state r; + } + } + + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.trace.golden new file mode 100644 index 0000000000..ba4726dd32 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_nested_front.trace.golden @@ -0,0 +1,25 @@ +exit: idle +enter: work +choice entering work: next inner(entry), right(entry) (unordered; took inner(entry) first) +enter: inner +choice entering work: next p(entry), q(entry), right(entry) (unordered; took p(entry) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "p(entry) " -> "p(entry) " + eval operator + -> "p(entry) " +enter: p1 +choice entering work: next q(entry), right(entry) (unordered; took q(entry) first) +stmt action body + stmt assign log + eval feature log -> "p(entry) " + eval literal "q(entry) " -> "q(entry) " + eval operator + -> "p(entry) q(entry) " +enter: q1 +stmt action body + stmt assign log + eval feature log -> "p(entry) q(entry) " + eval literal "right(entry) " -> "right(entry) " + eval operator + -> "p(entry) q(entry) right(entry) " +enter: r +transition: idle -> work (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_region_entry_order.check.expected.json new file mode 100644 index 0000000000..b09f3b4f75 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order.check.expected.json @@ -0,0 +1,16 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"left(entry) l(entry) right(entry) r(entry) \"", + "\"left(entry) right(entry) l(entry) r(entry) \"", + "\"left(entry) right(entry) r(entry) l(entry) \"", + "\"right(entry) left(entry) l(entry) r(entry) \"", + "\"right(entry) left(entry) r(entry) l(entry) \"", + "\"right(entry) r(entry) left(entry) l(entry) \"" + ] + }, + "agreed": { + "finalState": "l+r" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_entry_order.declared.trace.golden new file mode 100644 index 0000000000..5c9e3a4b19 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order.declared.trace.golden @@ -0,0 +1,27 @@ +exit: idle +enter: work +choice entering work: next left(entry), right(entry) (unordered; took left(entry) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "left(entry) " -> "left(entry) " + eval operator + -> "left(entry) " +choice entering work: next l(entry), right(entry) (unordered; took l(entry) first) +enter: l (entry action) +stmt action body + stmt assign log + eval feature log -> "left(entry) " + eval literal "l(entry) " -> "l(entry) " + eval operator + -> "left(entry) l(entry) " +stmt action body + stmt assign log + eval feature log -> "left(entry) l(entry) " + eval literal "right(entry) " -> "right(entry) " + eval operator + -> "left(entry) l(entry) right(entry) " +enter: r (entry action) +stmt action body + stmt assign log + eval feature log -> "left(entry) l(entry) right(entry) " + eval literal "r(entry) " -> "r(entry) " + eval operator + -> "left(entry) l(entry) right(entry) r(entry) " +transition: idle -> work (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order.expected.json b/internal/exec/runtime/testdata/conformance/state_region_entry_order.expected.json new file mode 100644 index 0000000000..717a66c334 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order.expected.json @@ -0,0 +1,66 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + } + ], + "outcomes": [ + { + "finalState": "l+r", + "outputs": { + "log": { + "type": "String", + "value": "left(entry) l(entry) right(entry) r(entry) " + } + } + }, + { + "finalState": "l+r", + "outputs": { + "log": { + "type": "String", + "value": "left(entry) right(entry) l(entry) r(entry) " + } + } + }, + { + "finalState": "l+r", + "outputs": { + "log": { + "type": "String", + "value": "left(entry) right(entry) r(entry) l(entry) " + } + } + }, + { + "finalState": "l+r", + "outputs": { + "log": { + "type": "String", + "value": "right(entry) left(entry) l(entry) r(entry) " + } + } + }, + { + "finalState": "l+r", + "outputs": { + "log": { + "type": "String", + "value": "right(entry) left(entry) r(entry) l(entry) " + } + } + }, + { + "finalState": "l+r", + "outputs": { + "log": { + "type": "String", + "value": "right(entry) r(entry) left(entry) l(entry) " + } + } + } + ], + "admissible": "Regions of a parallel state entered on one occurrence: each is entered, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_entry_order.seed-1.trace.golden new file mode 100644 index 0000000000..f8197aa9b0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order.seed-1.trace.golden @@ -0,0 +1,28 @@ +exit: idle +enter: work +choice entering work: next left(entry), right(entry) (unordered; took right(entry) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "right(entry) " -> "right(entry) " + eval operator + -> "right(entry) " +choice entering work: next left(entry), r(entry) (unordered; took left(entry) first) +stmt action body + stmt assign log + eval feature log -> "right(entry) " + eval literal "left(entry) " -> "left(entry) " + eval operator + -> "right(entry) left(entry) " +choice entering work: next l(entry), r(entry) (unordered; took l(entry) first) +enter: l (entry action) +stmt action body + stmt assign log + eval feature log -> "right(entry) left(entry) " + eval literal "l(entry) " -> "l(entry) " + eval operator + -> "right(entry) left(entry) l(entry) " +enter: r (entry action) +stmt action body + stmt assign log + eval feature log -> "right(entry) left(entry) l(entry) " + eval literal "r(entry) " -> "r(entry) " + eval operator + -> "right(entry) left(entry) l(entry) r(entry) " +transition: idle -> work (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order.sysml b/internal/exec/runtime/testdata/conformance/state_region_entry_order.sysml new file mode 100644 index 0000000000..84e01c2328 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order.sysml @@ -0,0 +1,27 @@ +// Entering a parallel state enters its regions as one front of units drawn +// one at a time: each region logs its own entry and then its initial state's, +// and the library orders nothing between one region's units and the other's. +// The six linearizations of two chains of two are the admissible set. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + entry { assign log := log + "left(entry) "; } + entry; then l; + state l { entry { assign log := log + "l(entry) "; } } + } + state right { + entry { assign log := log + "right(entry) "; } + entry; then r; + state r { entry { assign log := log + "r(entry) "; } } + } + } + + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_entry_order.trace.golden new file mode 100644 index 0000000000..5c9e3a4b19 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order.trace.golden @@ -0,0 +1,27 @@ +exit: idle +enter: work +choice entering work: next left(entry), right(entry) (unordered; took left(entry) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "left(entry) " -> "left(entry) " + eval operator + -> "left(entry) " +choice entering work: next l(entry), right(entry) (unordered; took l(entry) first) +enter: l (entry action) +stmt action body + stmt assign log + eval feature log -> "left(entry) " + eval literal "l(entry) " -> "l(entry) " + eval operator + -> "left(entry) l(entry) " +stmt action body + stmt assign log + eval feature log -> "left(entry) l(entry) " + eval literal "right(entry) " -> "right(entry) " + eval operator + -> "left(entry) l(entry) right(entry) " +enter: r (entry action) +stmt action body + stmt assign log + eval feature log -> "left(entry) l(entry) right(entry) " + eval literal "r(entry) " -> "r(entry) " + eval operator + -> "left(entry) l(entry) right(entry) r(entry) " +transition: idle -> work (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.check.expected.json b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.check.expected.json new file mode 100644 index 0000000000..3b15d2e017 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.check.expected.json @@ -0,0 +1,13 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"left(entry) right(entry) r(entry) \"", + "\"right(entry) left(entry) r(entry) \"", + "\"right(entry) r(entry) left(entry) \"" + ] + }, + "agreed": { + "finalState": "l+r" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.declared.trace.golden new file mode 100644 index 0000000000..9a32bcedde --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.declared.trace.golden @@ -0,0 +1,21 @@ +exit: idle +enter: work +choice entering work: next left(entry), right(entry) (unordered; took left(entry) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "left(entry) " -> "left(entry) " + eval operator + -> "left(entry) " +enter: l +stmt action body + stmt assign log + eval feature log -> "left(entry) " + eval literal "right(entry) " -> "right(entry) " + eval operator + -> "left(entry) right(entry) " +enter: r (entry action) +stmt action body + stmt assign log + eval feature log -> "left(entry) right(entry) " + eval literal "r(entry) " -> "r(entry) " + eval operator + -> "left(entry) right(entry) r(entry) " +transition: idle -> work (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.expected.json b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.expected.json new file mode 100644 index 0000000000..da4c2cd034 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.expected.json @@ -0,0 +1,39 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + } + ], + "outcomes": [ + { + "finalState": "l+r", + "outputs": { + "log": { + "type": "String", + "value": "left(entry) right(entry) r(entry) " + } + } + }, + { + "finalState": "l+r", + "outputs": { + "log": { + "type": "String", + "value": "right(entry) left(entry) r(entry) " + } + } + }, + { + "finalState": "l+r", + "outputs": { + "log": { + "type": "String", + "value": "right(entry) r(entry) left(entry) " + } + } + } + ], + "admissible": "Regions of a parallel state entered on one occurrence: each is entered, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.seed-1.trace.golden new file mode 100644 index 0000000000..3fb6cb99a7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.seed-1.trace.golden @@ -0,0 +1,22 @@ +exit: idle +enter: work +choice entering work: next left(entry), right(entry) (unordered; took right(entry) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "right(entry) " -> "right(entry) " + eval operator + -> "right(entry) " +choice entering work: next left(entry), r(entry) (unordered; took left(entry) first) +stmt action body + stmt assign log + eval feature log -> "right(entry) " + eval literal "left(entry) " -> "left(entry) " + eval operator + -> "right(entry) left(entry) " +enter: l +enter: r (entry action) +stmt action body + stmt assign log + eval feature log -> "right(entry) left(entry) " + eval literal "r(entry) " -> "r(entry) " + eval operator + -> "right(entry) left(entry) r(entry) " +transition: idle -> work (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.sysml b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.sysml new file mode 100644 index 0000000000..f0f15de266 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.sysml @@ -0,0 +1,28 @@ +// The regions of a parallel state are entered as one front of units drawn one +// at a time, whatever their lengths: one region logs a single entry, the other +// its own entry and then its initial state's, and the library orders nothing +// between the two chains. The three linearizations of a chain of one and a +// chain of two are the admissible set. +package Test { + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state work parallel { + state left { + entry { assign log := log + "left(entry) "; } + entry; then l; + state l; + } + state right { + entry { assign log := log + "right(entry) "; } + entry; then r; + state r { entry { assign log := log + "r(entry) "; } } + } + } + + transition first idle accept Go then work; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.trace.golden new file mode 100644 index 0000000000..9a32bcedde --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_entry_order_uneven.trace.golden @@ -0,0 +1,21 @@ +exit: idle +enter: work +choice entering work: next left(entry), right(entry) (unordered; took left(entry) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "left(entry) " -> "left(entry) " + eval operator + -> "left(entry) " +enter: l +stmt action body + stmt assign log + eval feature log -> "left(entry) " + eval literal "right(entry) " -> "right(entry) " + eval operator + -> "left(entry) right(entry) " +enter: r (entry action) +stmt action body + stmt assign log + eval feature log -> "left(entry) right(entry) " + eval literal "r(entry) " -> "r(entry) " + eval operator + -> "left(entry) right(entry) r(entry) " +transition: idle -> work (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_exit_order.check.expected.json b/internal/exec/runtime/testdata/conformance/state_region_exit_order.check.expected.json new file mode 100644 index 0000000000..5a2d9d5799 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_exit_order.check.expected.json @@ -0,0 +1,13 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"l(exit) r(exit) outer(exit) work(exit) \"", + "\"r(exit) l(exit) outer(exit) work(exit) \"", + "\"r(exit) outer(exit) l(exit) work(exit) \"" + ] + }, + "agreed": { + "finalState": "rest" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_exit_order.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_exit_order.declared.trace.golden new file mode 100644 index 0000000000..6ea2275764 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_exit_order.declared.trace.golden @@ -0,0 +1,27 @@ +choice exiting work: next l(exit), r(exit) (unordered; took l(exit) first) +exit: l (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l(exit) " -> "l(exit) " + eval operator + -> "l(exit) " +exit: r (exit action) +stmt action body + stmt assign log + eval feature log -> "l(exit) " + eval literal "r(exit) " -> "r(exit) " + eval operator + -> "l(exit) r(exit) " +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "l(exit) r(exit) " + eval literal "outer(exit) " -> "outer(exit) " + eval operator + -> "l(exit) r(exit) outer(exit) " +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "l(exit) r(exit) outer(exit) " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "l(exit) r(exit) outer(exit) work(exit) " +enter: rest +transition: work -> rest (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_exit_order.expected.json b/internal/exec/runtime/testdata/conformance/state_region_exit_order.expected.json new file mode 100644 index 0000000000..d8b35f137d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_exit_order.expected.json @@ -0,0 +1,39 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "signal": "Go" + } + ], + "outcomes": [ + { + "finalState": "rest", + "outputs": { + "log": { + "type": "String", + "value": "l(exit) r(exit) outer(exit) work(exit) " + } + } + }, + { + "finalState": "rest", + "outputs": { + "log": { + "type": "String", + "value": "r(exit) l(exit) outer(exit) work(exit) " + } + } + }, + { + "finalState": "rest", + "outputs": { + "log": { + "type": "String", + "value": "r(exit) outer(exit) l(exit) work(exit) " + } + } + } + ], + "admissible": "Regions of a parallel state left on one occurrence: each is exited, in which order is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_exit_order.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_exit_order.seed-1.trace.golden new file mode 100644 index 0000000000..6ea2275764 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_exit_order.seed-1.trace.golden @@ -0,0 +1,27 @@ +choice exiting work: next l(exit), r(exit) (unordered; took l(exit) first) +exit: l (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l(exit) " -> "l(exit) " + eval operator + -> "l(exit) " +exit: r (exit action) +stmt action body + stmt assign log + eval feature log -> "l(exit) " + eval literal "r(exit) " -> "r(exit) " + eval operator + -> "l(exit) r(exit) " +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "l(exit) r(exit) " + eval literal "outer(exit) " -> "outer(exit) " + eval operator + -> "l(exit) r(exit) outer(exit) " +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "l(exit) r(exit) outer(exit) " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "l(exit) r(exit) outer(exit) work(exit) " +enter: rest +transition: work -> rest (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_region_exit_order.sysml b/internal/exec/runtime/testdata/conformance/state_region_exit_order.sysml new file mode 100644 index 0000000000..e9d8a6eec5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_exit_order.sysml @@ -0,0 +1,31 @@ +// Leaving a parallel state exits its regions as one front of units drawn one +// at a time: a nested state's exit precedes its parent's, and the library +// orders nothing between one region's exits and the other's. The three +// linearizations of a chain of one and a chain of two are the admissible set, +// each followed by the owner's exit. +package Test { + state Machine { + attribute log : String = ""; + + entry; then work; + state work parallel { + exit { assign log := log + "work(exit) "; } + state left { + entry; then l; + state l { exit { assign log := log + "l(exit) "; } } + } + state right { + entry; then outer; + state outer { + exit { assign log := log + "outer(exit) "; } + entry; then r; + state r { exit { assign log := log + "r(exit) "; } } + } + } + } + state rest; + + transition first work accept Go then rest; + } + attribute def Go; +} diff --git a/internal/exec/runtime/testdata/conformance/state_region_exit_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_exit_order.trace.golden new file mode 100644 index 0000000000..6ea2275764 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_exit_order.trace.golden @@ -0,0 +1,27 @@ +choice exiting work: next l(exit), r(exit) (unordered; took l(exit) first) +exit: l (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l(exit) " -> "l(exit) " + eval operator + -> "l(exit) " +exit: r (exit action) +stmt action body + stmt assign log + eval feature log -> "l(exit) " + eval literal "r(exit) " -> "r(exit) " + eval operator + -> "l(exit) r(exit) " +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "l(exit) r(exit) " + eval literal "outer(exit) " -> "outer(exit) " + eval operator + -> "l(exit) r(exit) outer(exit) " +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "l(exit) r(exit) outer(exit) " + eval literal "work(exit) " -> "work(exit) " + eval operator + -> "l(exit) r(exit) outer(exit) work(exit) " +enter: rest +transition: work -> rest (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_region_exit_pseudostate.expected.json b/internal/exec/runtime/testdata/conformance/state_region_exit_pseudostate.expected.json similarity index 88% rename from internal/core/runtime/testdata/conformance/state_region_exit_pseudostate.expected.json rename to internal/exec/runtime/testdata/conformance/state_region_exit_pseudostate.expected.json index 13c7c54e43..1063f9ebce 100644 --- a/internal/core/runtime/testdata/conformance/state_region_exit_pseudostate.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_region_exit_pseudostate.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "finalState": "stopped", "stateVisits": ["working", "lstart", "rstart", "lwork", "rwork", "stopped"], "outputs": { diff --git a/internal/core/runtime/testdata/conformance/state_region_exit_pseudostate.sysml b/internal/exec/runtime/testdata/conformance/state_region_exit_pseudostate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_region_exit_pseudostate.sysml rename to internal/exec/runtime/testdata/conformance/state_region_exit_pseudostate.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_region_exit_pseudostate.trace.golden b/internal/exec/runtime/testdata/conformance/state_region_exit_pseudostate.trace.golden new file mode 100644 index 0000000000..2d2d2558d5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_region_exit_pseudostate.trace.golden @@ -0,0 +1,25 @@ +exit: lstart +enter: lwork +transition: lstart -> lwork +exit: rstart +enter: rwork +transition: rstart -> rwork +choice exiting working: next lwork(exit), rwork(exit) (unordered; took lwork(exit) first) +exit: lwork (exit action) +stmt action body + stmt assign exits + eval feature exits -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: rwork (exit action) +stmt action body + stmt assign exits + eval feature exits -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +exit: working + eval feature exits -> 11 + eval literal 11 -> 11 +eval operator == -> true +enter: stopped +transition: lwork -> stopped diff --git a/internal/exec/runtime/testdata/conformance/state_route_effect_reads_accepting_segment.expected.json b/internal/exec/runtime/testdata/conformance/state_route_effect_reads_accepting_segment.expected.json new file mode 100644 index 0000000000..d2d547747c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_route_effect_reads_accepting_segment.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "events": [{"signal": "Level", "value": {"type": "Integer", "value": 8}}], + "finalState": "done", + "stateVisits": ["idle", "high", "done"], + "outputs": { + "left": {"type": "Integer", "value": 8}, + "chosen": {"type": "Integer", "value": 8}, + "joined": {"type": "Integer", "value": 8}, + "entered": {"type": "Integer", "value": 8} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_route_effect_reads_accepting_segment.sysml b/internal/exec/runtime/testdata/conformance/state_route_effect_reads_accepting_segment.sysml new file mode 100644 index 0000000000..3fe9bcd60c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_route_effect_reads_accepting_segment.sysml @@ -0,0 +1,39 @@ +// A compound transition's later segments run within the performance that +// accepted the occurrence: the effects past a choice and a junction, like the exit +// before them and the entry they reach, read the payload off the accepting segment. +package StateRouteEffectReadsAcceptingSegment { + private import ScalarValues::*; + + attribute def Level :> Integer; + + state Machine { + attribute left : Integer = 0; + attribute chosen : Integer = 0; + attribute joined : Integer = 0; + attribute entered : Integer = 0; + + entry; then idle; + state idle { + exit action { + in level : Integer = raise.l; + assign left := level; + } + } + choice pick; + junction meet; + state high { + entry action { + in level : Integer = raise.l; + assign entered := level; + } + } + state low; + + transition raise first idle accept l : Level then pick; + transition branch first pick if left > 5 do assign chosen := raise.l then meet; + transition first pick then low; + transition onward first meet do assign joined := raise.l then high; + transition first high then done; + transition first low then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_alias_redefinition.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_alias_redefinition.expected.json new file mode 100644 index 0000000000..8162172804 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_alias_redefinition.expected.json @@ -0,0 +1,8 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::ctl", + "events": [{"signal": "Go"}], + "finalState": "done", + "stateVisits": ["idle", "done"] +} diff --git a/internal/core/runtime/testdata/conformance/state_run_to_completion_alias_redefinition.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_alias_redefinition.sysml similarity index 85% rename from internal/core/runtime/testdata/conformance/state_run_to_completion_alias_redefinition.sysml rename to internal/exec/runtime/testdata/conformance/state_run_to_completion_alias_redefinition.sysml index 3a0a14289b..02c96f4c8d 100644 --- a/internal/core/runtime/testdata/conformance/state_run_to_completion_alias_redefinition.sysml +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_alias_redefinition.sysml @@ -1,6 +1,6 @@ // A redefinition names the library feature through an alias: lowering resolves -// the target to Occurrences::Occurrence::isRunToCompletion and refuses the -// machine like one redefining the feature by its own name. +// the target to Occurrences::Occurrence::isRunToCompletion and executes the +// machine with the declared non-run-to-completion behavior. package test { attribute def Go; alias Rtc for Occurrences::Occurrence::isRunToCompletion; diff --git a/internal/core/runtime/testdata/conformance/state_run_to_completion_default_restored.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_restored.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_run_to_completion_default_restored.expected.json rename to internal/exec/runtime/testdata/conformance/state_run_to_completion_default_restored.expected.json diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_restored.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_restored.sysml new file mode 100644 index 0000000000..595914be68 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_restored.sysml @@ -0,0 +1,31 @@ +// A redefinition restating the library default masks the redefinition it +// inherits, on the machine and on a substate: the inherited `false` is not what +// the machine runs under, so the machine is lowered and runs unchanged. +package test { + private import ScalarValues::*; + + attribute def Go; + + state def Relaxed { + attribute :>> isRunToCompletion default = false; + } + + state def Base { + attribute :>> isRunToCompletion default = false; + attribute hits : Integer = 0; + + entry; then idle; + state idle : Relaxed { + attribute :>> isRunToCompletion default = true; + entry action bump { + assign hits := hits + 1; + } + } + transition first idle accept Go then done; + state done; + } + + state def Machine :> Base { + attribute :>> isRunToCompletion default = true; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_self_signal.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_self_signal.expected.json new file mode 100644 index 0000000000..19cfbcbfa9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_self_signal.expected.json @@ -0,0 +1,7 @@ +{ + "type": "state", + "finalState": "done", + "stateVisits": ["start", "working", "step", "done"], + "outputs": {"hits": {"type": "Integer", "value": 1}}, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_self_signal.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_self_signal.sysml new file mode 100644 index 0000000000..9838dfc165 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_self_signal.sysml @@ -0,0 +1,23 @@ +// The library defaults keep the self-signal dispatch after the entry cascade, +// providing the default twin for the explicitly non-run-to-completion case. +package test { + private import ScalarValues::*; + + item def Ping; + + state Machine { + attribute hits : Integer = 0; + entry; then start; + state start; + + state working { + state step { + entry action count { assign hits := hits + 1; } + } + entry action { send new Ping() to Machine; } then step; + } + state done; + transition first working accept Ping then done; + succession first start then working; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_self_signal.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_self_signal.trace.golden new file mode 100644 index 0000000000..2371d04d91 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_default_self_signal.trace.golden @@ -0,0 +1,16 @@ +exit: start +enter: working (entry action) +stmt action body + stmt send +materialize: Ping #1 +enter: step (entry action) +stmt action body + stmt assign hits + eval feature hits -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +transition: start -> working +exit: step +exit: working +enter: done +transition: step -> done (event: accept Ping) diff --git a/internal/core/runtime/testdata/conformance/state_run_to_completion_defaults_restated.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_defaults_restated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_run_to_completion_defaults_restated.expected.json rename to internal/exec/runtime/testdata/conformance/state_run_to_completion_defaults_restated.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_run_to_completion_defaults_restated.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_defaults_restated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_run_to_completion_defaults_restated.sysml rename to internal/exec/runtime/testdata/conformance/state_run_to_completion_defaults_restated.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.check.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.check.expected.json new file mode 100644 index 0000000000..f267ed62c1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.check.expected.json @@ -0,0 +1,9 @@ +{ + "verdict": "divergent", + "divergent": { + "hits": ["0", "1"] + }, + "agreed": { + "finalState": "done" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.declared.trace.golden new file mode 100644 index 0000000000..72432172c5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.declared.trace.golden @@ -0,0 +1,4 @@ +choice entry at t=0.0: next dispatch accept Go, working(entry) (unordered; took dispatch accept Go first) +exit: working +enter: done +transition: working -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.expected.json new file mode 100644 index 0000000000..21d7fd3723 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "outcomes": [ + { + "finalState": "done", + "stateVisits": ["working", "done"], + "outputs": {"hits": {"type": "Integer", "value": 0}} + }, + { + "finalState": "done", + "stateVisits": ["working", "step", "done"], + "outputs": {"hits": {"type": "Integer", "value": 1}} + } + ], + "admissible": "Dispatch during an entry the model does not run to completion", + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.seed-1.trace.golden new file mode 100644 index 0000000000..821003243a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.seed-1.trace.golden @@ -0,0 +1,11 @@ +choice entry at t=0.0: next dispatch accept Go, working(entry) (unordered; took working(entry) first) +enter: step (entry action) +stmt action body + stmt assign hits + eval feature hits -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: step +exit: working +enter: done +transition: step -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.sysml new file mode 100644 index 0000000000..ad7a75aaf4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.sysml @@ -0,0 +1,21 @@ +// A machine-level false redefinition applies at initialization, where the +// queued Go signal can dispatch before the working entry cascade completes. +package test { + private import ScalarValues::*; + + item def Go; + + state Machine { + attribute :>> isRunToCompletion = false; + attribute hits : Integer = 0; + entry action { send new Go() to Machine; } then working; + state working { + state step { + entry action count { assign hits := hits + 1; } + } + entry; then step; + } + state done; + transition first working accept Go then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.trace.golden new file mode 100644 index 0000000000..72432172c5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_machine.trace.golden @@ -0,0 +1,4 @@ +choice entry at t=0.0: next dispatch accept Go, working(entry) (unordered; took dispatch accept Go first) +exit: working +enter: done +transition: working -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.check.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.check.expected.json new file mode 100644 index 0000000000..2bb0d346bb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.check.expected.json @@ -0,0 +1,4 @@ +{ + "verdict": "divergent", + "divergent": {"hits": ["0", "1"]} +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.declared.trace.golden new file mode 100644 index 0000000000..3cb3d5cd93 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.declared.trace.golden @@ -0,0 +1,10 @@ +exit: start +enter: working (entry action) +stmt action body + stmt send +materialize: Ping #1 +transition: start -> working +choice entry at t=0.0: next dispatch accept Ping, working(entry) (unordered; took dispatch accept Ping first) +exit: working +enter: done +transition: working -> done (event: accept Ping) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.expected.json new file mode 100644 index 0000000000..d105992353 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "outcomes": [ + { + "finalState": "done", + "stateVisits": ["start", "working", "done"], + "outputs": {"hits": {"type": "Integer", "value": 0}} + }, + { + "finalState": "done", + "stateVisits": ["start", "working", "step", "done"], + "outputs": {"hits": {"type": "Integer", "value": 1}} + } + ], + "admissible": "Dispatch during an entry the model does not run to completion", + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.seed-1.trace.golden new file mode 100644 index 0000000000..aed6bc684d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.seed-1.trace.golden @@ -0,0 +1,17 @@ +exit: start +enter: working (entry action) +stmt action body + stmt send +materialize: Ping #1 +transition: start -> working +choice entry at t=0.0: next dispatch accept Ping, working(entry) (unordered; took working(entry) first) +enter: step (entry action) +stmt action body + stmt assign hits + eval feature hits -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: step +exit: working +enter: done +transition: step -> done (event: accept Ping) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.sysml new file mode 100644 index 0000000000..badfbc28f3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.sysml @@ -0,0 +1,24 @@ +// A state-level false redefinition permits the queued self-signal to dispatch +// before the nested step entry completes, exposing both scheduling orders. +package test { + private import ScalarValues::*; + + item def Ping; + + state Machine { + attribute hits : Integer = 0; + entry; then start; + state start; + + state working { + attribute :>> isRunToCompletion = false; + state step { + entry action count { assign hits := hits + 1; } + } + entry action { send new Ping() to Machine; } then step; + } + state done; + transition first working accept Ping then done; + succession first start then working; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.trace.golden new file mode 100644 index 0000000000..3cb3d5cd93 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_false_self_signal.trace.golden @@ -0,0 +1,10 @@ +exit: start +enter: working (entry action) +stmt action body + stmt send +materialize: Ping #1 +transition: start -> working +choice entry at t=0.0: next dispatch accept Ping, working(entry) (unordered; took dispatch accept Ping first) +exit: working +enter: done +transition: working -> done (event: accept Ping) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_inherited_redefinition.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_inherited_redefinition.expected.json new file mode 100644 index 0000000000..7293b4e4ab --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_inherited_redefinition.expected.json @@ -0,0 +1,7 @@ +{ + "type": "instance", + "instantiate": "test::ctl", + "events": [{"signal": "Go"}], + "finalState": "done", + "stateVisits": ["idle", "done"] +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_inherited_redefinition.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_inherited_redefinition.sysml new file mode 100644 index 0000000000..4ab5e856df --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_inherited_redefinition.sysml @@ -0,0 +1,19 @@ +// The redefinition sits on the state definition the exhibited machine is typed +// by, so the machine inherits it and executes with the lowered effective value. +package test { + attribute def Go; + + state def Base { + attribute :>> isRunToCompletion = false; + entry; then idle; + state idle; + accept Go then done; + state done; + } + + part def Controller { + exhibit state mode : Base; + } + + part ctl : Controller; +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_redefined_false.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_redefined_false.expected.json new file mode 100644 index 0000000000..7293b4e4ab --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_redefined_false.expected.json @@ -0,0 +1,7 @@ +{ + "type": "instance", + "instantiate": "test::ctl", + "events": [{"signal": "Go"}], + "finalState": "done", + "stateVisits": ["idle", "done"] +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_redefined_false.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_redefined_false.sysml new file mode 100644 index 0000000000..9f5502aecf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_redefined_false.sysml @@ -0,0 +1,18 @@ +// A machine redefining isRunToCompletion to false opts out of the default +// entry boundary, so the lowered runtime executes the machine under that +// declared scheduling behavior. +package test { + attribute def Go; + + part def Controller { + exhibit state mode { + attribute :>> isRunToCompletion = false; + entry; then idle; + state idle; + accept Go then done; + state done; + } + } + + part ctl : Controller; +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_region_redefinition.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_region_redefinition.expected.json new file mode 100644 index 0000000000..29e0ad3fd9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_region_redefinition.expected.json @@ -0,0 +1,5 @@ +{ + "type": "instance", + "instantiate": "test::ctl", + "stateVisits": ["left", "l1", "right", "r1"] +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_region_redefinition.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_region_redefinition.sysml new file mode 100644 index 0000000000..c5d3b97417 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_region_redefinition.sysml @@ -0,0 +1,22 @@ +// A substate of an orthogonal region carries a non-run-to-completion +// redefinition; the region content executes through the same lowered state path. +package test { + attribute def Go; + + part def Controller { + exhibit state mode parallel { + state left { + entry; then l1; + state l1 { + attribute :>> isRunToCompletion = false; + } + } + state right { + entry; then r1; + state r1; + } + } + } + + part ctl : Controller; +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.check.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.check.expected.json new file mode 100644 index 0000000000..6a3928c461 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"order": ["123", "132", "213"]}, + "agreed": {"finalState": "done+b"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.declared.trace.golden new file mode 100644 index 0000000000..50a928ec52 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.declared.trace.golden @@ -0,0 +1,38 @@ +eval feature flag -> false +exit: prep +enter: work (entry action) +stmt action body + stmt assign flag + eval literal true -> true +transition: prep -> work (event: accept Start) +choice entry at t=0.0: next dispatch change a 1->b, work(entry) (unordered; took dispatch change a 1->b first) +eval feature flag -> true +exit: a +enter: b (entry action) +stmt action body + stmt assign order + eval feature order -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 2 -> 2 + eval operator + -> 2 +transition: a -> b (event: change) +enter: step (entry action) +stmt action body + stmt assign order + eval feature order -> 2 + eval literal 10 -> 10 + eval operator * -> 20 + eval literal 1 -> 1 + eval operator + -> 21 +eval feature flag -> true +exit: step +enter: done (entry action) +stmt action body + stmt assign order + eval feature order -> 21 + eval literal 10 -> 10 + eval operator * -> 210 + eval literal 3 -> 3 + eval operator + -> 213 +transition: step -> done (event: change) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.expected.json new file mode 100644 index 0000000000..3e3746f3e2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.expected.json @@ -0,0 +1,20 @@ +{ + "type": "state", + "events": [{"signal": "Start"}], + "outcomes": [ + { + "finalState": "done+b", + "outputs": {"order": {"type": "Integer", "value": 213}} + }, + { + "finalState": "done+b", + "outputs": {"order": {"type": "Integer", "value": 123}} + }, + { + "finalState": "done+b", + "outputs": {"order": {"type": "Integer", "value": 132}} + } + ], + "admissible": "Dispatch during an entry the model does not run to completion", + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.seed-1.trace.golden new file mode 100644 index 0000000000..50a928ec52 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.seed-1.trace.golden @@ -0,0 +1,38 @@ +eval feature flag -> false +exit: prep +enter: work (entry action) +stmt action body + stmt assign flag + eval literal true -> true +transition: prep -> work (event: accept Start) +choice entry at t=0.0: next dispatch change a 1->b, work(entry) (unordered; took dispatch change a 1->b first) +eval feature flag -> true +exit: a +enter: b (entry action) +stmt action body + stmt assign order + eval feature order -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 2 -> 2 + eval operator + -> 2 +transition: a -> b (event: change) +enter: step (entry action) +stmt action body + stmt assign order + eval feature order -> 2 + eval literal 10 -> 10 + eval operator * -> 20 + eval literal 1 -> 1 + eval operator + -> 21 +eval feature flag -> true +exit: step +enter: done (entry action) +stmt action body + stmt assign order + eval feature order -> 21 + eval literal 10 -> 10 + eval operator * -> 210 + eval literal 3 -> 3 + eval operator + -> 213 +transition: step -> done (event: change) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.sysml new file mode 100644 index 0000000000..a6f13ea0c9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.sysml @@ -0,0 +1,36 @@ +// A held entry may coexist with a sibling change transition, but the +// transition inside the held scope fires only after its source is entered. +package test { + private import ScalarValues::*; + + item def Start; + + state Machine parallel { + attribute flag : Boolean = false; + attribute order : Integer = 0; + state left { + entry; then prep; + state prep; + state work { + ref :>> runToCompletionScope = self; + state step { + entry action { assign order := order * 10 + 1; } + } + state done { + entry action { assign order := order * 10 + 3; } + } + entry action { assign flag := true; } then step; + transition first step accept when flag then done; + } + transition first prep accept Start then work; + } + state right { + entry; then a; + state a; + state b { + entry action { assign order := order * 10 + 2; } + } + transition first a accept when flag then b; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.trace.golden new file mode 100644 index 0000000000..50a928ec52 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_change_sibling.trace.golden @@ -0,0 +1,38 @@ +eval feature flag -> false +exit: prep +enter: work (entry action) +stmt action body + stmt assign flag + eval literal true -> true +transition: prep -> work (event: accept Start) +choice entry at t=0.0: next dispatch change a 1->b, work(entry) (unordered; took dispatch change a 1->b first) +eval feature flag -> true +exit: a +enter: b (entry action) +stmt action body + stmt assign order + eval feature order -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 2 -> 2 + eval operator + -> 2 +transition: a -> b (event: change) +enter: step (entry action) +stmt action body + stmt assign order + eval feature order -> 2 + eval literal 10 -> 10 + eval operator * -> 20 + eval literal 1 -> 1 + eval operator + -> 21 +eval feature flag -> true +exit: step +enter: done (entry action) +stmt action body + stmt assign order + eval feature order -> 21 + eval literal 10 -> 10 + eval operator * -> 210 + eval literal 3 -> 3 + eval operator + -> 213 +transition: step -> done (event: change) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_narrowed.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_narrowed.expected.json new file mode 100644 index 0000000000..7293b4e4ab --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_narrowed.expected.json @@ -0,0 +1,7 @@ +{ + "type": "instance", + "instantiate": "test::ctl", + "events": [{"signal": "Go"}], + "finalState": "done", + "stateVisits": ["idle", "done"] +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_narrowed.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_narrowed.sysml new file mode 100644 index 0000000000..37ae306646 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_narrowed.sysml @@ -0,0 +1,19 @@ +// A substate redefining runToCompletionScope to itself narrows the scope from +// the whole machine (Occurrences::Occurrence::runToCompletionScope default +// self); entry scheduling follows that lowered state-local scope. +package test { + attribute def Go; + + part def Controller { + exhibit state mode { + entry; then idle; + state idle { + ref :>> runToCompletionScope = self; + } + accept Go then done; + state done; + } + } + + part ctl : Controller; +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.check.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.check.expected.json new file mode 100644 index 0000000000..ad501187be --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.check.expected.json @@ -0,0 +1,7 @@ +{ + "verdict": "divergent", + "divergent": { + "hits": ["0", "1"] + }, + "agreed": {} +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.declared.trace.golden new file mode 100644 index 0000000000..946883637a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.declared.trace.golden @@ -0,0 +1,10 @@ +exit: start +enter: work (entry action) +stmt action body + stmt send +materialize: Go #1 +transition: start -> work +choice entry at t=0.0: next dispatch accept Go, work(entry) (unordered; took dispatch accept Go first) +exit: work +enter: done +transition: work -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.expected.json new file mode 100644 index 0000000000..263fdddf85 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "outcomes": [ + { + "finalState": "done", + "stateVisits": ["start", "work", "done"], + "outputs": {"hits": {"type": "Integer", "value": 0}} + }, + { + "finalState": "done", + "stateVisits": ["start", "work", "step", "done"], + "outputs": {"hits": {"type": "Integer", "value": 1}} + } + ], + "admissible": "Dispatch during an entry the model does not run to completion", + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.seed-1.trace.golden new file mode 100644 index 0000000000..ea3f03a4b5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.seed-1.trace.golden @@ -0,0 +1,17 @@ +exit: start +enter: work (entry action) +stmt action body + stmt send +materialize: Go #1 +transition: start -> work +choice entry at t=0.0: next dispatch accept Go, work(entry) (unordered; took work(entry) first) +enter: step (entry action) +stmt action body + stmt assign hits + eval feature hits -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: step +exit: work +enter: done +transition: step -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.sysml new file mode 100644 index 0000000000..08d5dfbba6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.sysml @@ -0,0 +1,23 @@ +// A transition declared by the machine body is outside a nested held scope. +package test { + private import ScalarValues::*; + + item def Start; + item def Go; + + state Machine { + attribute hits : Integer = 0; + entry; then start; + state start; + state work { + attribute :>> runToCompletionScope default = self; + state step { + entry action count { assign hits := hits + 1; } + } + entry action { send new Go() to Machine; } then step; + } + state done; + succession first start then work; + transition first work accept Go then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.trace.golden new file mode 100644 index 0000000000..946883637a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_parent_transition.trace.golden @@ -0,0 +1,10 @@ +exit: start +enter: work (entry action) +stmt action body + stmt send +materialize: Go #1 +transition: start -> work +choice entry at t=0.0: next dispatch accept Go, work(entry) (unordered; took dispatch accept Go first) +exit: work +enter: done +transition: work -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.check.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.check.expected.json new file mode 100644 index 0000000000..ad501187be --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.check.expected.json @@ -0,0 +1,7 @@ +{ + "verdict": "divergent", + "divergent": { + "hits": ["0", "1"] + }, + "agreed": {} +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.declared.trace.golden new file mode 100644 index 0000000000..f94cd21e63 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.declared.trace.golden @@ -0,0 +1,12 @@ +exit: start +enter: work +transition: start -> work +enter: inner (entry action) +stmt action body + stmt send +materialize: Go #1 +choice entry at t=0.0: next dispatch accept Go, inner(entry) (unordered; took dispatch accept Go first) +exit: inner +exit: work +enter: done +transition: inner -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.expected.json new file mode 100644 index 0000000000..bfc5641571 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "outcomes": [ + { + "finalState": "done", + "stateVisits": ["start", "work", "inner", "done"], + "outputs": {"hits": {"type": "Integer", "value": 0}} + }, + { + "finalState": "done", + "stateVisits": ["start", "work", "inner", "leaf", "done"], + "outputs": {"hits": {"type": "Integer", "value": 1}} + } + ], + "admissible": "Dispatch during an entry the model does not run to completion", + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.seed-1.trace.golden new file mode 100644 index 0000000000..e2192af8a5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.seed-1.trace.golden @@ -0,0 +1,19 @@ +exit: start +enter: work +transition: start -> work +enter: inner (entry action) +stmt action body + stmt send +materialize: Go #1 +choice entry at t=0.0: next dispatch accept Go, inner(entry) (unordered; took inner(entry) first) +enter: leaf (entry action) +stmt action body + stmt assign hits + eval feature hits -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: leaf +exit: inner +exit: work +enter: done +transition: leaf -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.sysml new file mode 100644 index 0000000000..85f0756fdf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.sysml @@ -0,0 +1,26 @@ +// A machine-body transition may name a nested source without becoming part of +// the nested state's run-to-completion scope. +package test { + private import ScalarValues::*; + + item def Go; + + state Machine { + attribute hits : Integer = 0; + entry; then start; + state start; + state work { + attribute :>> runToCompletionScope default = self; + entry; then inner; + state inner { + state leaf { + entry action count { assign hits := hits + 1; } + } + entry action { send new Go() to Machine; } then leaf; + } + } + state done; + succession first start then work; + transition first work.inner accept Go then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.trace.golden new file mode 100644 index 0000000000..f94cd21e63 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_qualified_source.trace.golden @@ -0,0 +1,12 @@ +exit: start +enter: work +transition: start -> work +enter: inner (entry action) +stmt action body + stmt send +materialize: Go #1 +choice entry at t=0.0: next dispatch accept Go, inner(entry) (unordered; took dispatch accept Go first) +exit: inner +exit: work +enter: done +transition: inner -> done (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.check.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.check.expected.json new file mode 100644 index 0000000000..76b589ab6e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.check.expected.json @@ -0,0 +1,7 @@ +{ + "verdict": "no violation, exhaustive", + "agreed": { + "finalState": "done", + "hits": "1" + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.expected.json new file mode 100644 index 0000000000..952be0e114 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.expected.json @@ -0,0 +1,8 @@ +{ + "type": "state", + "events": [{"signal": "Ping"}], + "finalState": "done", + "stateVisits": ["start", "work", "step", "done"], + "outputs": {"hits": {"type": "Integer", "value": 1}}, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.sysml new file mode 100644 index 0000000000..b0de828660 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.sysml @@ -0,0 +1,23 @@ +// A scoped self boundary holds the queued dispatch while the composite entry +// cascade is incomplete, so the nested transition runs after step is entered. +package test { + private import ScalarValues::*; + + item def Ping; + + state Machine { + attribute hits : Integer = 0; + entry; then start; + state start; + state work { + attribute :>> runToCompletionScope default = self; + state step { + entry action count { assign hits := hits + 1; } + } + entry action { send new Ping() to Machine; } then step; + state done; + transition first step accept Ping then done; + } + succession first start then work; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.trace.golden new file mode 100644 index 0000000000..fdd2ecf14e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_self_held.trace.golden @@ -0,0 +1,15 @@ +exit: start +enter: work (entry action) +stmt action body + stmt send +materialize: Ping #1 +transition: start -> work +enter: step (entry action) +stmt action body + stmt assign hits + eval feature hits -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +exit: step +enter: done +transition: step -> done (event: accept Ping) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.check.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.check.expected.json new file mode 100644 index 0000000000..7be4566d56 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"order": ["12", "21"]}, + "agreed": {"finalState": "inner+b"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.declared.trace.golden new file mode 100644 index 0000000000..f0d45467a2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.declared.trace.golden @@ -0,0 +1,25 @@ +exit: prep +enter: work (entry action) +stmt action body + stmt send +materialize: Go #1 +transition: prep -> work (event: accept Start) +choice entry at t=0.0: next dispatch accept Go, work(entry) (unordered; took dispatch accept Go first) +exit: a +enter: b (entry action) +stmt action body + stmt assign order + eval feature order -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 2 -> 2 + eval operator + -> 2 +transition: a -> b (event: accept Go) +enter: inner (entry action) +stmt action body + stmt assign order + eval feature order -> 2 + eval literal 10 -> 10 + eval operator * -> 20 + eval literal 1 -> 1 + eval operator + -> 21 diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.expected.json new file mode 100644 index 0000000000..01a6c41073 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.expected.json @@ -0,0 +1,16 @@ +{ + "type": "state", + "events": [{"signal": "Start"}], + "outcomes": [ + { + "finalState": "inner+b", + "outputs": {"order": {"type": "Integer", "value": 21}} + }, + { + "finalState": "inner+b", + "outputs": {"order": {"type": "Integer", "value": 12}} + } + ], + "admissible": "Dispatch during an entry the model does not run to completion", + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.seed-1.trace.golden new file mode 100644 index 0000000000..f0d45467a2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.seed-1.trace.golden @@ -0,0 +1,25 @@ +exit: prep +enter: work (entry action) +stmt action body + stmt send +materialize: Go #1 +transition: prep -> work (event: accept Start) +choice entry at t=0.0: next dispatch accept Go, work(entry) (unordered; took dispatch accept Go first) +exit: a +enter: b (entry action) +stmt action body + stmt assign order + eval feature order -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 2 -> 2 + eval operator + -> 2 +transition: a -> b (event: accept Go) +enter: inner (entry action) +stmt action body + stmt assign order + eval feature order -> 2 + eval literal 10 -> 10 + eval operator * -> 20 + eval literal 1 -> 1 + eval operator + -> 21 diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.sysml new file mode 100644 index 0000000000..6193355da1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.sysml @@ -0,0 +1,33 @@ +// A scoped false redefinition on work leaves the sibling region's Go +// transition free while work's entry cascade is held. +package test { + private import ScalarValues::*; + + item def Ping; + item def Start; + item def Go; + + state Machine parallel { + attribute order : Integer = 0; + state left { + entry; then prep; + state prep; + state work { + ref :>> runToCompletionScope = self; + state inner { + entry action { assign order := order * 10 + 1; } + } + entry action { send new Go() to Machine; } then inner; + } + transition first prep accept Start then work; + } + state right { + entry; then a; + state a; + state b { + entry action { assign order := order * 10 + 2; } + } + transition first a accept Go then b; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.trace.golden new file mode 100644 index 0000000000..f0d45467a2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region.trace.golden @@ -0,0 +1,25 @@ +exit: prep +enter: work (entry action) +stmt action body + stmt send +materialize: Go #1 +transition: prep -> work (event: accept Start) +choice entry at t=0.0: next dispatch accept Go, work(entry) (unordered; took dispatch accept Go first) +exit: a +enter: b (entry action) +stmt action body + stmt assign order + eval feature order -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 2 -> 2 + eval operator + -> 2 +transition: a -> b (event: accept Go) +enter: inner (entry action) +stmt action body + stmt assign order + eval feature order -> 2 + eval literal 10 -> 10 + eval operator * -> 20 + eval literal 1 -> 1 + eval operator + -> 21 diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region_default.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region_default.expected.json new file mode 100644 index 0000000000..57bc8565e4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region_default.expected.json @@ -0,0 +1,7 @@ +{ + "type": "state", + "events": [{"signal": "Start"}], + "finalState": "inner+b", + "outputs": {"order": {"type": "Integer", "value": 12}}, + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region_default.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region_default.sysml new file mode 100644 index 0000000000..b6956e4afb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region_default.sysml @@ -0,0 +1,32 @@ +// The default twin keeps the whole-machine entry boundary, while its parallel +// initialization visits remain policy-dependent. +package test { + private import ScalarValues::*; + + item def Ping; + item def Start; + item def Go; + + state Machine parallel { + attribute order : Integer = 0; + state left { + entry; then prep; + state prep; + state work { + state inner { + entry action { assign order := order * 10 + 1; } + } + entry action { send new Go() to Machine; } then inner; + } + transition first prep accept Start then work; + } + state right { + entry; then a; + state a; + state b { + entry action { assign order := order * 10 + 2; } + } + transition first a accept Go then b; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region_default.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region_default.trace.golden new file mode 100644 index 0000000000..500e3a0a84 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_scope_sibling_region_default.trace.golden @@ -0,0 +1,24 @@ +exit: prep +enter: work (entry action) +stmt action body + stmt send +materialize: Go #1 +enter: inner (entry action) +stmt action body + stmt assign order + eval feature order -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +transition: prep -> work (event: accept Start) +exit: a +enter: b (entry action) +stmt action body + stmt assign order + eval feature order -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +transition: a -> b (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.check.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.check.expected.json new file mode 100644 index 0000000000..ad501187be --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.check.expected.json @@ -0,0 +1,7 @@ +{ + "verdict": "divergent", + "divergent": { + "hits": ["0", "1"] + }, + "agreed": {} +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.declared.trace.golden new file mode 100644 index 0000000000..26e36edd1a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.declared.trace.golden @@ -0,0 +1,10 @@ +exit: start +enter: work (entry action) +stmt action body + stmt send +materialize: Go #1 +transition: start -> work (event: accept Start) +choice entry at t=0.0: next dispatch accept Go, work(entry) (unordered; took dispatch accept Go first) +exit: idle +transition: idle -> stop (event: accept Go) +terminate: stop diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.expected.json new file mode 100644 index 0000000000..bceca9725e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.expected.json @@ -0,0 +1,16 @@ +{ + "type": "state", + "events": [{"signal": "Start"}], + "outcomes": [ + { + "terminated": true, + "outputs": {"hits": {"type": "Integer", "value": 0}} + }, + { + "terminated": true, + "outputs": {"hits": {"type": "Integer", "value": 1}} + } + ], + "admissible": "Dispatch during an entry the model does not run to completion", + "trace": true +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.seed-1.trace.golden new file mode 100644 index 0000000000..26e36edd1a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.seed-1.trace.golden @@ -0,0 +1,10 @@ +exit: start +enter: work (entry action) +stmt action body + stmt send +materialize: Go #1 +transition: start -> work (event: accept Start) +choice entry at t=0.0: next dispatch accept Go, work(entry) (unordered; took dispatch accept Go first) +exit: idle +transition: idle -> stop (event: accept Go) +terminate: stop diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.sysml new file mode 100644 index 0000000000..257a36b90f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.sysml @@ -0,0 +1,31 @@ +package Test { + private import ScalarValues::*; + + item def Start; + item def Go; + + state Machine parallel { + attribute hits : Integer = 0; + + state left { + entry; then start; + state start; + state work { + ref :>> runToCompletionScope = self; + state step { + entry action count { assign hits := hits + 1; } + } + entry action { send new Go() to Machine; } then step; + } + transition first start accept Start then work; + } + + state right { + entry; then idle; + state idle; + transition first idle accept Go then stop; + } + + action stop terminate; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.trace.golden b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.trace.golden new file mode 100644 index 0000000000..26e36edd1a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_terminate_during_held_entry.trace.golden @@ -0,0 +1,10 @@ +exit: start +enter: work (entry action) +stmt action body + stmt send +materialize: Go #1 +transition: start -> work (event: accept Start) +choice entry at t=0.0: next dispatch accept Go, work(entry) (unordered; took dispatch accept Go first) +exit: idle +transition: idle -> stop (event: accept Go) +terminate: stop diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_unverified.expected.json b/internal/exec/runtime/testdata/conformance/state_run_to_completion_unverified.expected.json new file mode 100644 index 0000000000..7293b4e4ab --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_unverified.expected.json @@ -0,0 +1,7 @@ +{ + "type": "instance", + "instantiate": "test::ctl", + "events": [{"signal": "Go"}], + "finalState": "done", + "stateVisits": ["idle", "done"] +} diff --git a/internal/exec/runtime/testdata/conformance/state_run_to_completion_unverified.sysml b/internal/exec/runtime/testdata/conformance/state_run_to_completion_unverified.sysml new file mode 100644 index 0000000000..b9b770f3b7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_run_to_completion_unverified.sysml @@ -0,0 +1,20 @@ +// A nonliteral redefinition is evaluated in its lowered scope; this expression +// resolves to true and proves dynamic values do not add a literal-evaluation trace. +package test { + private import ScalarValues::*; + + attribute def Go; + + part def Controller { + attribute strict : Boolean = true; + exhibit state mode { + attribute :>> isRunToCompletion = strict or true; + entry; then idle; + state idle; + accept Go then done; + state done; + } + } + + part ctl : Controller; +} diff --git a/internal/core/runtime/testdata/conformance/state_send_self_signal.expected.json b/internal/exec/runtime/testdata/conformance/state_send_self_signal.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_send_self_signal.expected.json rename to internal/exec/runtime/testdata/conformance/state_send_self_signal.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_send_self_signal.sysml b/internal/exec/runtime/testdata/conformance/state_send_self_signal.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_send_self_signal.sysml rename to internal/exec/runtime/testdata/conformance/state_send_self_signal.sysml diff --git a/internal/core/runtime/testdata/conformance/state_send_self_signal.trace.golden b/internal/exec/runtime/testdata/conformance/state_send_self_signal.trace.golden similarity index 83% rename from internal/core/runtime/testdata/conformance/state_send_self_signal.trace.golden rename to internal/exec/runtime/testdata/conformance/state_send_self_signal.trace.golden index 9069cfd469..18b7a87b09 100644 --- a/internal/core/runtime/testdata/conformance/state_send_self_signal.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_send_self_signal.trace.golden @@ -1,6 +1,7 @@ exit: start enter: waiting (entry action) -stmt send +stmt action body + stmt send transition: start -> waiting exit: waiting enter: done diff --git a/internal/core/runtime/testdata/conformance/state_shallow_history.expected.json b/internal/exec/runtime/testdata/conformance/state_shallow_history.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_shallow_history.expected.json rename to internal/exec/runtime/testdata/conformance/state_shallow_history.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_shallow_history.sysml b/internal/exec/runtime/testdata/conformance/state_shallow_history.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_shallow_history.sysml rename to internal/exec/runtime/testdata/conformance/state_shallow_history.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_shallow_history_completion_default.expected.json b/internal/exec/runtime/testdata/conformance/state_shallow_history_completion_default.expected.json new file mode 100644 index 0000000000..f66959a2b6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_shallow_history_completion_default.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go", "args": null}, + {"signal": "Leave", "args": null}, + {"signal": "Back", "args": null}, + {"signal": "Next", "args": null}, + {"signal": "Finish", "args": null}, + {"signal": "End", "args": null} + ], + "finalState": "done", + "stateVisits": ["start", "outer", "one", "away", "outer", "one", "second", "done", "outer", "third", "done"], + "outputs": { + "log": {"type": "String", "value": "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);completion(effect);outer(entry);default(effect);third(entry);outer(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_shallow_history_completion_default.sysml b/internal/exec/runtime/testdata/conformance/state_shallow_history_completion_default.sysml new file mode 100644 index 0000000000..28e464f670 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_shallow_history_completion_default.sysml @@ -0,0 +1,35 @@ +// A completion transition out of a composite state into its own shallow history +// reads the record the completion writes: `outer` completing from `second` +// leaves an empty history, so the history's default `then third` is taken after +// `outer`'s entry behavior, and `outer`'s `entry; then one` is not. Leaving +// `outer` from `one` on Leave and coming back restores `one` as before. +package Test { + state ShallowHistoryCompletionDefault { + attribute log : String = ""; + entry; then start; + state start; + state outer { + entry action { assign log := log + "outer(entry);"; } + exit action { assign log := log + "outer(exit);"; } + entry; then one; + state one { + entry action { assign log := log + "one(entry);"; } + } + state second; + state third { + entry action { assign log := log + "third(entry);"; } + } + history resume; + transition first resume do { assign log := log + "default(effect);"; } then third; + transition first one when Next then second; + transition first second when Finish then done; + } + state away; + + transition first start when Go then outer; + transition first one when Leave then away; + transition first away when Back then resume; + transition first outer do { assign log := log + "completion(effect);"; } then resume; + transition first third when End then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_shallow_history_completion_default.trace.golden b/internal/exec/runtime/testdata/conformance/state_shallow_history_completion_default.trace.golden new file mode 100644 index 0000000000..eb1979676e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_shallow_history_completion_default.trace.golden @@ -0,0 +1,82 @@ +exit: start +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);" +enter: one (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);" + eval literal "one(entry);" -> "one(entry);" + eval operator + -> "outer(entry);one(entry);" +transition: start -> outer (event: accept Go) +exit: one +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);one(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);one(entry);outer(exit);" +enter: away +transition: one -> away (event: accept Leave) +exit: away +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);one(entry);outer(exit);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);one(entry);outer(exit);outer(entry);" +enter: one (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);one(entry);outer(exit);outer(entry);" + eval literal "one(entry);" -> "one(entry);" + eval operator + -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);" +transition: away -> one (event: accept Back) +exit: one +enter: second +transition: one -> second (event: accept Next) +exit: second +enter: done +transition: second -> done (event: accept Finish) +exit: done +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);" + eval literal "completion(effect);" -> "completion(effect);" + eval operator + -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);completion(effect);" +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);completion(effect);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);completion(effect);outer(entry);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);completion(effect);outer(entry);" + eval literal "default(effect);" -> "default(effect);" + eval operator + -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);completion(effect);outer(entry);default(effect);" +enter: third (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);completion(effect);outer(entry);default(effect);" + eval literal "third(entry);" -> "third(entry);" + eval operator + -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);completion(effect);outer(entry);default(effect);third(entry);" +transition: done -> third +exit: third +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);completion(effect);outer(entry);default(effect);third(entry);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);one(entry);outer(exit);outer(entry);one(entry);outer(exit);completion(effect);outer(entry);default(effect);third(entry);outer(exit);" +enter: done +transition: third -> done (event: accept End) diff --git a/internal/core/runtime/testdata/conformance/state_signal_discriminate.expected.json b/internal/exec/runtime/testdata/conformance/state_signal_discriminate.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_signal_discriminate.expected.json rename to internal/exec/runtime/testdata/conformance/state_signal_discriminate.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_signal_discriminate.sysml b/internal/exec/runtime/testdata/conformance/state_signal_discriminate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_signal_discriminate.sysml rename to internal/exec/runtime/testdata/conformance/state_signal_discriminate.sysml diff --git a/internal/core/runtime/testdata/conformance/state_signal_unmatched.expected.json b/internal/exec/runtime/testdata/conformance/state_signal_unmatched.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_signal_unmatched.expected.json rename to internal/exec/runtime/testdata/conformance/state_signal_unmatched.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_signal_unmatched.sysml b/internal/exec/runtime/testdata/conformance/state_signal_unmatched.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_signal_unmatched.sysml rename to internal/exec/runtime/testdata/conformance/state_signal_unmatched.sysml diff --git a/internal/core/runtime/testdata/conformance/state_simple.expected.json b/internal/exec/runtime/testdata/conformance/state_simple.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_simple.expected.json rename to internal/exec/runtime/testdata/conformance/state_simple.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_simple.sysml b/internal/exec/runtime/testdata/conformance/state_simple.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_simple.sysml rename to internal/exec/runtime/testdata/conformance/state_simple.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.check.expected.json b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.check.expected.json new file mode 100644 index 0000000000..cd5784117f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.check.expected.json @@ -0,0 +1,5 @@ +{ + "verdict": "divergent", + "divergent": {"late": ["0.125", "0.25"]}, + "agreed": {"early": "0.5", "final": "0.0625"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden new file mode 100644 index 0000000000..132a9d4ae9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.declared.trace.golden @@ -0,0 +1,187 @@ +step 1: token 1@arm +stmt assign early +materialize: tank #1 +start: performed action cool of #1 +materialize: cool #2 + eval literal 0.0 -> 0.0 + enter calc VectorFunctions::VectorOf + bind components = 0.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.0⟩ + eval invoke VectorOf -> ⟨0.0⟩ + eval literal 1.0 -> 1.0 + enter calc VectorFunctions::VectorOf + bind components = 1.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ + eval invoke VectorOf -> ⟨1.0⟩ + eval literal 0.25 -> 0.25 + eval index -> 0.25 [s] + eval literal 1 -> 1 + eval index -> 1 [s] +start: exhibited state machine monitor of #1 +materialize: monitor #3 +enter: waiting + eval literal 0.375 -> 0.375 + eval index -> 0.375 [s] +run: performed action cool of #1 + enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨1.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨1.0⟩ + eval chain elements -> (1.0) + eval literal 1 -> 1 + eval index -> 1.0 + enter calc VectorFunctions::VectorOf + bind components = 1.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ + eval invoke VectorOf -> ⟨1.0⟩ + exit calc test::Tank::cool::getOutput -> ⟨1.0⟩ +state: cool t=0.0 x=⟨1.0⟩ y=⟨1.0⟩ +step 1: token 1@cool +run: exhibited state machine monitor of #1 + eval chain early -> 0.0 +step 2: token 1@wait + eval literal 1.1 -> 1.1 +eval index -> 1.1 [s] +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨1.0⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨1.0⟩ + eval operator * -> ⟨-2.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-2.0⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-2.0⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.5⟩ + eval chain elements -> (0.5) + eval literal 1 -> 1 + eval index -> 0.5 + enter calc VectorFunctions::VectorOf + bind components = 0.5 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.5⟩ + eval invoke VectorOf -> ⟨0.5⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.5⟩ +state: cool t=0.25 x=⟨0.5⟩ y=⟨0.5⟩ +step 2: token 1@cool +run: exhibited state machine monitor of #1 +exit: waiting +enter: sampledEarly (entry action) +stmt assign early + eval feature cool -> instance#2 + eval chain output -> ⟨0.5⟩ + eval chain elements -> (0.5) + eval literal 1 -> 1 + eval index -> 0.5 + eval literal 0.375 -> 0.375 +eval index -> 0.375 [s] +transition: waiting -> sampledEarly (event: time) +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.5⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨0.5⟩ + eval operator * -> ⟨-1.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-1.0⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-1.0⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.25⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.25⟩ + eval chain elements -> (0.25) + eval literal 1 -> 1 + eval index -> 0.25 + enter calc VectorFunctions::VectorOf + bind components = 0.25 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.25⟩ + eval invoke VectorOf -> ⟨0.25⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.25⟩ +state: cool t=0.5 x=⟨0.25⟩ y=⟨0.25⟩ +step 3: token 1@cool +choice at t=0.75: due action cool of object #1, state machine monitor of object #1 (unordered; ran action cool of object #1 first) +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.25⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨0.25⟩ + eval operator * -> ⟨-0.5⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-0.5⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-0.5⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.125⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.125⟩ + eval chain elements -> (0.125) + eval literal 1 -> 1 + eval index -> 0.125 + enter calc VectorFunctions::VectorOf + bind components = 0.125 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.125⟩ + eval invoke VectorOf -> ⟨0.125⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.125⟩ +state: cool t=0.75 x=⟨0.125⟩ y=⟨0.125⟩ +step 4: token 1@cool +run: exhibited state machine monitor of #1 +exit: sampledEarly +enter: sampledLate (entry action) +stmt assign late + eval feature cool -> instance#2 + eval chain output -> ⟨0.125⟩ + eval chain elements -> (0.125) + eval literal 1 -> 1 + eval index -> 0.125 +transition: sampledEarly -> sampledLate (event: time) +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.125⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨0.125⟩ + eval operator * -> ⟨-0.25⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-0.25⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-0.25⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.0625⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.0625⟩ + eval chain elements -> (0.0625) + eval literal 1 -> 1 + eval index -> 0.0625 + enter calc VectorFunctions::VectorOf + bind components = 0.0625 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.0625⟩ + eval invoke VectorOf -> ⟨0.0625⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.0625⟩ +state: cool t=1.0 x=⟨0.0625⟩ y=⟨0.0625⟩ +step 5: no active tokens +step 3: token 1@read +stmt assign early + eval chain early -> 0.5 +stmt assign late + eval chain late -> 0.125 +stmt assign final + eval chain elements -> (0.0625) + eval literal 1 -> 1 + eval index -> 0.0625 +step 4: token 1@done +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.expected.json b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.expected.json new file mode 100644 index 0000000000..c66fdf117a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.expected.json @@ -0,0 +1,11 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "evaluate": "test::probe", + "outcomes": [ + {"outputs": {"early": {"type": "Real", "value": 0.5}, "late": {"type": "Real", "value": 0.25}, "final": {"type": "Real", "value": 0.0625}}}, + {"outputs": {"early": {"type": "Real", "value": 0.5}, "late": {"type": "Real", "value": 0.125}, "final": {"type": "Real", "value": 0.0625}}} + ], + "admissible": "An action token and a state transition due at one instant: which runs first is open" +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden new file mode 100644 index 0000000000..8a89a3274b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.seed-1.trace.golden @@ -0,0 +1,187 @@ +step 1: token 1@arm +stmt assign early +materialize: tank #1 +start: performed action cool of #1 +materialize: cool #2 + eval literal 0.0 -> 0.0 + enter calc VectorFunctions::VectorOf + bind components = 0.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.0⟩ + eval invoke VectorOf -> ⟨0.0⟩ + eval literal 1.0 -> 1.0 + enter calc VectorFunctions::VectorOf + bind components = 1.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ + eval invoke VectorOf -> ⟨1.0⟩ + eval literal 0.25 -> 0.25 + eval index -> 0.25 [s] + eval literal 1 -> 1 + eval index -> 1 [s] +start: exhibited state machine monitor of #1 +materialize: monitor #3 +enter: waiting + eval literal 0.375 -> 0.375 + eval index -> 0.375 [s] +run: performed action cool of #1 + enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨1.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨1.0⟩ + eval chain elements -> (1.0) + eval literal 1 -> 1 + eval index -> 1.0 + enter calc VectorFunctions::VectorOf + bind components = 1.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ + eval invoke VectorOf -> ⟨1.0⟩ + exit calc test::Tank::cool::getOutput -> ⟨1.0⟩ +state: cool t=0.0 x=⟨1.0⟩ y=⟨1.0⟩ +step 1: token 1@cool +run: exhibited state machine monitor of #1 + eval chain early -> 0.0 +step 2: token 1@wait + eval literal 1.1 -> 1.1 +eval index -> 1.1 [s] +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨1.0⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨1.0⟩ + eval operator * -> ⟨-2.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-2.0⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-2.0⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.5⟩ + eval chain elements -> (0.5) + eval literal 1 -> 1 + eval index -> 0.5 + enter calc VectorFunctions::VectorOf + bind components = 0.5 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.5⟩ + eval invoke VectorOf -> ⟨0.5⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.5⟩ +state: cool t=0.25 x=⟨0.5⟩ y=⟨0.5⟩ +step 2: token 1@cool +run: exhibited state machine monitor of #1 +exit: waiting +enter: sampledEarly (entry action) +stmt assign early + eval feature cool -> instance#2 + eval chain output -> ⟨0.5⟩ + eval chain elements -> (0.5) + eval literal 1 -> 1 + eval index -> 0.5 + eval literal 0.375 -> 0.375 +eval index -> 0.375 [s] +transition: waiting -> sampledEarly (event: time) +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.5⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨0.5⟩ + eval operator * -> ⟨-1.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-1.0⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-1.0⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.25⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.25⟩ + eval chain elements -> (0.25) + eval literal 1 -> 1 + eval index -> 0.25 + enter calc VectorFunctions::VectorOf + bind components = 0.25 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.25⟩ + eval invoke VectorOf -> ⟨0.25⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.25⟩ +state: cool t=0.5 x=⟨0.25⟩ y=⟨0.25⟩ +step 3: token 1@cool +choice at t=0.75: due action cool of object #1, state machine monitor of object #1 (unordered; ran state machine monitor of object #1 first) +run: exhibited state machine monitor of #1 +exit: sampledEarly +enter: sampledLate (entry action) +stmt assign late + eval feature cool -> instance#2 + eval chain output -> ⟨0.25⟩ + eval chain elements -> (0.25) + eval literal 1 -> 1 + eval index -> 0.25 +transition: sampledEarly -> sampledLate (event: time) +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.25⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨0.25⟩ + eval operator * -> ⟨-0.5⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-0.5⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-0.5⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.125⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.125⟩ + eval chain elements -> (0.125) + eval literal 1 -> 1 + eval index -> 0.125 + enter calc VectorFunctions::VectorOf + bind components = 0.125 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.125⟩ + eval invoke VectorOf -> ⟨0.125⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.125⟩ +state: cool t=0.75 x=⟨0.125⟩ y=⟨0.125⟩ +step 4: token 1@cool +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.125⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨0.125⟩ + eval operator * -> ⟨-0.25⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-0.25⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-0.25⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.0625⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.0625⟩ + eval chain elements -> (0.0625) + eval literal 1 -> 1 + eval index -> 0.0625 + enter calc VectorFunctions::VectorOf + bind components = 0.0625 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.0625⟩ + eval invoke VectorOf -> ⟨0.0625⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.0625⟩ +state: cool t=1.0 x=⟨0.0625⟩ y=⟨0.0625⟩ +step 5: no active tokens +step 3: token 1@read +stmt assign early + eval chain early -> 0.5 +stmt assign late + eval chain late -> 0.25 +stmt assign final + eval chain elements -> (0.0625) + eval literal 1 -> 1 + eval index -> 0.0625 +step 4: token 1@done +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.sysml b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.sysml new file mode 100644 index 0000000000..1d9f734d08 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.sysml @@ -0,0 +1,77 @@ +// A tank performs first-order cooling dx/dt = -2 x by forward Euler in 0.25 s +// steps and exhibits a machine sampling the temperature on timed accepts, all on +// one clock. The sample at 0.375 s reads the state after the step at 0.25 s. At +// 0.75 s the machine's accept and a step are due together: a due-order choice +// decides which runs first, so the late sample is x(0.5) = 0.25 or x(0.75) = +// 0.125 — under the default policy the machine, created after the probe, runs +// first. The probe reads the state after the dynamics stop at 1 s. +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def Temperature :> StateSpace; + attribute def Ambient :> Input; + attribute def Reading :> Output; + + part def Tank { + attribute early : Real = 0.0; + attribute late : Real = 0.0; + + perform action cool : ContinuousStateSpaceDynamics, FixedStepDynamics { + in :>> input : Ambient = VectorOf((0.0)); + :>> stateSpace : Temperature = VectorOf((1.0)); + out :>> output : Reading; + :>> timeStep = 0.25 [s]; + :>> stopTime = 1 [s]; + + calc :>> getDerivative { + in input : Ambient; + in stateSpace : Temperature; + return : StateDerivative = (0.0 - 2.0) * stateSpace / 1 [s]; + } + calc :>> getOutput { + in input : Ambient; + in stateSpace : Temperature; + return : Reading = VectorOf((stateSpace.elements#(1))); + } + calc :>> getNextState { + calc :>> integrate : Euler; + } + } + + exhibit state monitor { + entry; then waiting; + + state waiting; + accept after 0.375 [s] then sampledEarly; + state sampledEarly { + entry assign early := cool.output.elements#(1); + } + transition first sampledEarly accept after 0.375 [s] then sampledLate; + state sampledLate { + entry assign late := cool.output.elements#(1); + } + } + } + + part tank : Tank; + + action probe { + attribute early : Real = 0.0; + attribute late : Real = 0.0; + attribute final : Real = 0.0; + + first start; + then action arm assign early := tank.early; + then action wait accept after 1.1 [s]; + then action read { + assign early := tank.early; + assign late := tank.late; + assign final := tank.cool.output.elements#(1); + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden new file mode 100644 index 0000000000..8a89a3274b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_clock_state_interleave.trace.golden @@ -0,0 +1,187 @@ +step 1: token 1@arm +stmt assign early +materialize: tank #1 +start: performed action cool of #1 +materialize: cool #2 + eval literal 0.0 -> 0.0 + enter calc VectorFunctions::VectorOf + bind components = 0.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.0⟩ + eval invoke VectorOf -> ⟨0.0⟩ + eval literal 1.0 -> 1.0 + enter calc VectorFunctions::VectorOf + bind components = 1.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ + eval invoke VectorOf -> ⟨1.0⟩ + eval literal 0.25 -> 0.25 + eval index -> 0.25 [s] + eval literal 1 -> 1 + eval index -> 1 [s] +start: exhibited state machine monitor of #1 +materialize: monitor #3 +enter: waiting + eval literal 0.375 -> 0.375 + eval index -> 0.375 [s] +run: performed action cool of #1 + enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨1.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨1.0⟩ + eval chain elements -> (1.0) + eval literal 1 -> 1 + eval index -> 1.0 + enter calc VectorFunctions::VectorOf + bind components = 1.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨1.0⟩ + eval invoke VectorOf -> ⟨1.0⟩ + exit calc test::Tank::cool::getOutput -> ⟨1.0⟩ +state: cool t=0.0 x=⟨1.0⟩ y=⟨1.0⟩ +step 1: token 1@cool +run: exhibited state machine monitor of #1 + eval chain early -> 0.0 +step 2: token 1@wait + eval literal 1.1 -> 1.1 +eval index -> 1.1 [s] +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨1.0⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨1.0⟩ + eval operator * -> ⟨-2.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-2.0⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-2.0⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.5⟩ + eval chain elements -> (0.5) + eval literal 1 -> 1 + eval index -> 0.5 + enter calc VectorFunctions::VectorOf + bind components = 0.5 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.5⟩ + eval invoke VectorOf -> ⟨0.5⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.5⟩ +state: cool t=0.25 x=⟨0.5⟩ y=⟨0.5⟩ +step 2: token 1@cool +run: exhibited state machine monitor of #1 +exit: waiting +enter: sampledEarly (entry action) +stmt assign early + eval feature cool -> instance#2 + eval chain output -> ⟨0.5⟩ + eval chain elements -> (0.5) + eval literal 1 -> 1 + eval index -> 0.5 + eval literal 0.375 -> 0.375 +eval index -> 0.375 [s] +transition: waiting -> sampledEarly (event: time) +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.5⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨0.5⟩ + eval operator * -> ⟨-1.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-1.0⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-1.0⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.25⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.25⟩ + eval chain elements -> (0.25) + eval literal 1 -> 1 + eval index -> 0.25 + enter calc VectorFunctions::VectorOf + bind components = 0.25 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.25⟩ + eval invoke VectorOf -> ⟨0.25⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.25⟩ +state: cool t=0.5 x=⟨0.25⟩ y=⟨0.25⟩ +step 3: token 1@cool +choice at t=0.75: due action cool of object #1, state machine monitor of object #1 (unordered; ran state machine monitor of object #1 first) +run: exhibited state machine monitor of #1 +exit: sampledEarly +enter: sampledLate (entry action) +stmt assign late + eval feature cool -> instance#2 + eval chain output -> ⟨0.25⟩ + eval chain elements -> (0.25) + eval literal 1 -> 1 + eval index -> 0.25 +transition: sampledEarly -> sampledLate (event: time) +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.25⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨0.25⟩ + eval operator * -> ⟨-0.5⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-0.5⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-0.5⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.125⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.125⟩ + eval chain elements -> (0.125) + eval literal 1 -> 1 + eval index -> 0.125 + enter calc VectorFunctions::VectorOf + bind components = 0.125 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.125⟩ + eval invoke VectorOf -> ⟨0.125⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.125⟩ +state: cool t=0.75 x=⟨0.125⟩ y=⟨0.125⟩ +step 4: token 1@cool +run: performed action cool of #1 +enter calc test::Tank::cool::getDerivative + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.125⟩ [argument] + stmt return + eval operator - -> -2.0 + eval feature stateSpace -> ⟨0.125⟩ + eval operator * -> ⟨-0.25⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-0.25⟩ [1/s] +exit calc test::Tank::cool::getDerivative -> ⟨-0.25⟩ [1/s] +enter calc test::Tank::cool::getOutput + bind input = ⟨0.0⟩ [argument] + bind stateSpace = ⟨0.0625⟩ [argument] + stmt return + eval feature stateSpace -> ⟨0.0625⟩ + eval chain elements -> (0.0625) + eval literal 1 -> 1 + eval index -> 0.0625 + enter calc VectorFunctions::VectorOf + bind components = 0.0625 [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.0625⟩ + eval invoke VectorOf -> ⟨0.0625⟩ +exit calc test::Tank::cool::getOutput -> ⟨0.0625⟩ +state: cool t=1.0 x=⟨0.0625⟩ y=⟨0.0625⟩ +step 5: no active tokens +step 3: token 1@read +stmt assign early + eval chain early -> 0.5 +stmt assign late + eval chain late -> 0.25 +stmt assign final + eval chain elements -> (0.0625) + eval literal 1 -> 1 + eval index -> 0.0625 +step 4: token 1@done +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/state_space_custom_next_state.expected.json b/internal/exec/runtime/testdata/conformance/state_space_custom_next_state.expected.json new file mode 100644 index 0000000000..b10476f98c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_custom_next_state.expected.json @@ -0,0 +1,14 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::doubling", + "outputs": { + "stateSpace": {"type": "VectorQuantity", "elements": [ + {"type": "Quantity", "value": 8.0, "unit": "m"} + ]}, + "output": {"type": "VectorQuantity", "elements": [ + {"type": "Quantity", "value": 8.0, "unit": "m"} + ]}, + "time": {"type": "Quantity", "value": 3.0, "unit": "s"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_custom_next_state.sysml b/internal/exec/runtime/testdata/conformance/state_space_custom_next_state.sysml new file mode 100644 index 0000000000..03b329829b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_custom_next_state.sysml @@ -0,0 +1,40 @@ +// A getNextState the model bodies itself is the step, whatever its integrate is +// bound to: this one doubles the state each step and binds Euler, whose +// integration of the zero derivative would leave the state at 1. Three steps +// reach 8, so the body ran and the binding did not choose the scheme. +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def GrowthState :> StateSpace; + attribute def GrowthInput :> Input; + attribute def GrowthOutput :> Output; + + action doubling : ContinuousStateSpaceDynamics, FixedStepDynamics { + in :>> input : GrowthInput = 0 [m] * VectorOf((0.0)); + :>> stateSpace : GrowthState = 1 [m] * VectorOf((1.0)); + :>> timeStep = 1 [s]; + :>> stopTime = 3 [s]; + + calc :>> getDerivative { + in input : GrowthInput; + in stateSpace : GrowthState; + return : StateDerivative = 0.0 * stateSpace / 1 [s]; + } + calc :>> getOutput { + in input : GrowthInput; + in stateSpace : GrowthState; + return : GrowthOutput = 1.0 * stateSpace; + } + calc :>> getNextState { + in input : GrowthInput; + in stateSpace : GrowthState; + in timeStep : DurationValue; + calc :>> integrate : Euler; + return : GrowthState = 2.0 * stateSpace; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_discrete_difference.expected.json b/internal/exec/runtime/testdata/conformance/state_space_discrete_difference.expected.json new file mode 100644 index 0000000000..5e8344b75a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_discrete_difference.expected.json @@ -0,0 +1,15 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::account", + "outputs": { + "stateSpace": {"type": "Vector", "elements": [ + {"type": "Real", "value": 1628.894626777441}, + {"type": "Real", "value": 10.0} + ]}, + "output": {"type": "Vector", "elements": [ + {"type": "Real", "value": 1628.894626777441} + ]}, + "time": {"type": "Quantity", "value": 10.0, "unit": "s"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_discrete_difference.sysml b/internal/exec/runtime/testdata/conformance/state_space_discrete_difference.sysml new file mode 100644 index 0000000000..08715a0c71 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_discrete_difference.sysml @@ -0,0 +1,32 @@ +// Discrete dynamics over the library's protocol: each sample adds getDifference +// to the state. A balance compounding 5% a step reaches 1000 * 1.05^10 after ten +// samples, and a second component counts the samples taken. +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def AccountState :> StateSpace; + attribute def AccountInput :> Input; + attribute def AccountOutput :> Output; + + action account : DiscreteStateSpaceDynamics, FixedStepDynamics { + in :>> input : AccountInput = VectorOf((0.05)); + :>> stateSpace : AccountState = VectorOf((1000.0, 0.0)); + :>> timeStep = 1 [s]; + :>> stopTime = 10 [s]; + + calc :>> getDifference { + in input : AccountInput; + in stateSpace : AccountState; + return : StateSpace = VectorOf((input.elements#(1) * stateSpace.elements#(1), 1.0)); + } + calc :>> getOutput { + in input : AccountInput; + in stateSpace : AccountState; + return : AccountOutput = VectorOf((stateSpace.elements#(1))); + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_euler_first_order.expected.json b/internal/exec/runtime/testdata/conformance/state_space_euler_first_order.expected.json new file mode 100644 index 0000000000..687bcd736a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_euler_first_order.expected.json @@ -0,0 +1,14 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::decay", + "outputs": { + "stateSpace": {"type": "VectorQuantity", "elements": [ + {"type": "Quantity", "value": 0.3584859224085422, "unit": "m"} + ]}, + "output": {"type": "VectorQuantity", "elements": [ + {"type": "Quantity", "value": 0.7169718448170844, "unit": "m"} + ]}, + "time": {"type": "Quantity", "value": 2.0, "unit": "s"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_euler_first_order.sysml b/internal/exec/runtime/testdata/conformance/state_space_euler_first_order.sysml new file mode 100644 index 0000000000..070b090f70 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_euler_first_order.sysml @@ -0,0 +1,36 @@ +// A first-order linear system dx/dt = -k x stepped by forward Euler over the +// library's ContinuousStateSpaceDynamics: x(t) = e^(-k t), so x(2) with k = 0.5 +// is e^-1 = 0.3679; Euler with h = 0.1 reaches 0.95^20 = 0.3585, a first-order error. +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def DecayState :> StateSpace; + attribute def DecayInput :> Input; + attribute def DecayOutput :> Output; + + action decay : ContinuousStateSpaceDynamics, FixedStepDynamics { + attribute rate : Real = 0.5; + in :>> input : DecayInput = 0 [m] * VectorOf((0.0)); + :>> stateSpace : DecayState = 1 [m] * VectorOf((1.0)); + :>> timeStep = 0.1 [s]; + :>> stopTime = 2 [s]; + + calc :>> getDerivative { + in input : DecayInput; + in stateSpace : DecayState; + return : StateDerivative = (0.0 - rate) * stateSpace / 1 [s]; + } + calc :>> getOutput { + in input : DecayInput; + in stateSpace : DecayState; + return : DecayOutput = 2.0 * stateSpace; + } + calc :>> getNextState { + calc :>> integrate : Euler; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_rk4_first_order.expected.json b/internal/exec/runtime/testdata/conformance/state_space_rk4_first_order.expected.json new file mode 100644 index 0000000000..bf1721f169 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_rk4_first_order.expected.json @@ -0,0 +1,14 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::decay", + "outputs": { + "stateSpace": {"type": "VectorQuantity", "elements": [ + {"type": "Quantity", "value": 0.36787946114753967, "unit": "m"} + ]}, + "output": {"type": "VectorQuantity", "elements": [ + {"type": "Quantity", "value": 0.7357589222950793, "unit": "m"} + ]}, + "time": {"type": "Quantity", "value": 2.0, "unit": "s"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_rk4_first_order.sysml b/internal/exec/runtime/testdata/conformance/state_space_rk4_first_order.sysml new file mode 100644 index 0000000000..1dcf289b79 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_rk4_first_order.sysml @@ -0,0 +1,33 @@ +// The first-order linear system dx/dt = -k x under the default integrator: a +// model that binds no integrate steps by classical Runge-Kutta, and x(2) with +// k = 0.5, h = 0.1 lands within 3e-8 of e^-1 = 0.36787944, where Euler misses by 9e-3. +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def DecayState :> StateSpace; + attribute def DecayInput :> Input; + attribute def DecayOutput :> Output; + + action decay : ContinuousStateSpaceDynamics, FixedStepDynamics { + attribute rate : Real = 0.5; + in :>> input : DecayInput = 0 [m] * VectorOf((0.0)); + :>> stateSpace : DecayState = 1 [m] * VectorOf((1.0)); + :>> timeStep = 0.1 [s]; + :>> stopTime = 2 [s]; + + calc :>> getDerivative { + in input : DecayInput; + in stateSpace : DecayState; + return : StateDerivative = (0.0 - rate) * stateSpace / 1 [s]; + } + calc :>> getOutput { + in input : DecayInput; + in stateSpace : DecayState; + return : DecayOutput = 2.0 * stateSpace; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_rk4_lunar_descent.expected.json b/internal/exec/runtime/testdata/conformance/state_space_rk4_lunar_descent.expected.json new file mode 100644 index 0000000000..a16be8c6cf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_rk4_lunar_descent.expected.json @@ -0,0 +1,11 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::descent", + "outputs": { + "output": {"type": "Vector", "elements": [ + {"type": "Real", "value": 15001.719185373526} + ]}, + "time": {"type": "Quantity", "value": 3.0, "unit": "s"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_rk4_lunar_descent.sysml b/internal/exec/runtime/testdata/conformance/state_space_rk4_lunar_descent.sysml new file mode 100644 index 0000000000..b7ae36be1e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_rk4_lunar_descent.sysml @@ -0,0 +1,50 @@ +// The lunar descent of calc_rk4_lunar_descent expressed over the library's +// protocol: the state [x, h, vx, vh, m] in SI base magnitudes, the steering law +// in getDerivative, and RK4 bound to integrate. Three 1 s steps land on the +// altitude the hand-rolled RK4 computes, 15001.719185373526. +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def LanderState :> StateSpace; + attribute def LanderInput :> Input; + attribute def LanderOutput :> Output; + + calc def Steering { + in h : Real; + out ax = 2000.0 * (1.0 - h / 15000.0); + out ah = 2000.0 * (h / 15000.0); + } + + action descent : ContinuousStateSpaceDynamics, FixedStepDynamics { + attribute lunarGravity : Real = 1.62; + in :>> input : LanderInput = VectorOf((0.0)); + :>> stateSpace : LanderState = VectorOf((0.0, 15000.0, 1600.0, 0.0, 1000.0)); + :>> timeStep = 1 [s]; + :>> stopTime = 3 [s]; + + calc :>> getDerivative { + in input : LanderInput; + in stateSpace : LanderState; + calc law : Steering { in h = stateSpace.elements#(2); } + return : StateDerivative = VectorOf(( + stateSpace.elements#(3), + stateSpace.elements#(4), + law.ax / stateSpace.elements#(5), + law.ah / stateSpace.elements#(5) - lunarGravity, + 0.0 - 1.0 + )) / 1 [s]; + } + calc :>> getOutput { + in input : LanderInput; + in stateSpace : LanderState; + return : LanderOutput = VectorOf((stateSpace.elements#(2))); + } + calc :>> getNextState { + calc :>> integrate : RK4; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_rk4_nonautonomous.expected.json b/internal/exec/runtime/testdata/conformance/state_space_rk4_nonautonomous.expected.json new file mode 100644 index 0000000000..f9b9ff68c7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_rk4_nonautonomous.expected.json @@ -0,0 +1,14 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::ramp", + "outputs": { + "stateSpace": {"type": "VectorQuantity", "elements": [ + {"type": "Quantity", "value": 2.0, "unit": "m"} + ]}, + "output": {"type": "VectorQuantity", "elements": [ + {"type": "Quantity", "value": 2.0, "unit": "m"} + ]}, + "time": {"type": "Quantity", "value": 2.0, "unit": "s"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_rk4_nonautonomous.sysml b/internal/exec/runtime/testdata/conformance/state_space_rk4_nonautonomous.sysml new file mode 100644 index 0000000000..e67bf789af --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_rk4_nonautonomous.sysml @@ -0,0 +1,32 @@ +// A non-autonomous system dx/dt = t stepped by RK4 with h = 1 s: x(t) = t^2/2, so +// x(2) = 2 exactly, which RK4 reaches only when its midpoint stages read t + h/2 +// and its last reads t + h; stages all reading the step's start would give 1. +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def RampState :> StateSpace; + attribute def RampInput :> Input; + attribute def RampOutput :> Output; + + action ramp : ContinuousStateSpaceDynamics, FixedStepDynamics { + in :>> input : RampInput = 0 [m] * VectorOf((0.0)); + :>> stateSpace : RampState = 0 [m] * VectorOf((0.0)); + :>> timeStep = 1 [s]; + :>> stopTime = 2 [s]; + + calc :>> getDerivative { + in input : RampInput; + in stateSpace : RampState; + return : StateDerivative = 1 [m] * VectorOf((time / 1 [s])) / 1 [s]; + } + calc :>> getOutput { + in input : RampInput; + in stateSpace : RampState; + return : RampOutput = 1.0 * stateSpace; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_performer.expected.json b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_performer.expected.json new file mode 100644 index 0000000000..0f001e9da7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_performer.expected.json @@ -0,0 +1,19 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::fleet", + "objects": [ + { + "path": "high", + "finalState": "lost", + "stateVisits": ["falling", "lost"], + "slots": {"landings": {"type": "Integer", "value": 0}} + }, + { + "path": "low", + "finalState": "landed", + "stateVisits": ["falling", "landed"], + "slots": {"landings": {"type": "Integer", "value": 1}} + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_performer.sysml b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_performer.sysml new file mode 100644 index 0000000000..a31bee46c8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_performer.sysml @@ -0,0 +1,68 @@ +// Two landers perform the same falling dynamics from different heights, and each +// exhibits a machine accepting its own performance's touchdown event. The low +// lander touches down at 2 s; the high one, dropped from 1000 m, has not by the +// time its 10 s timeout fires. The high machine runs first and drives the clock +// past the low lander's crossing: the crossing it sees is not its own, so it is +// lost, and the low machine lands on the touchdown of its own fall. +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def FallState :> StateSpace; + attribute def FallInput :> Input; + attribute def FallOutput :> Output; + part def Lander { + action def Touchdown :> ZeroCrossing; + + attribute gravity : Real = 10.0; + attribute height : Real default = 19.0; + attribute landings : Integer = 0; + + perform action fall : ContinuousStateSpaceDynamics, FixedStepDynamics { + in :>> input : FallInput = VectorOf((gravity)); + :>> stateSpace : FallState = VectorOf((height, 0.0)); + out :>> output : FallOutput; + :>> timeStep = 0.5 [s]; + + event occurrence touchdown : Touchdown { + :>> guard = stateSpace.elements#(1); + :>> terminal = true; + } + + calc :>> getDerivative { + in input : FallInput; + in stateSpace : FallState; + return : StateDerivative = VectorOf((stateSpace.elements#(2), 0.0 - input.elements#(1))) / 1 [s]; + } + calc :>> getOutput { + in input : FallInput; + in stateSpace : FallState; + return : FallOutput = VectorOf((stateSpace.elements#(1))); + } + } + + exhibit state flight { + entry; then falling; + + state falling; + transition first falling accept :> fall.touchdown then landed; + transition first falling accept after 10 [s] then lost; + state lost; + state landed { + entry action { + assign landings := landings + 1; + } + } + } + } + + part fleet { + part low : Lander; + part high : Lander { + :>> height = 1000.0; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.expected.json b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.expected.json new file mode 100644 index 0000000000..1438fb03ab --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "evaluate": "test::Lander::flight", + "performers": [ + { + "object": "test::lander", + "finalState": "landed", + "stateVisits": ["falling", "landed"], + "outputs": { + "landings": {"type": "Integer", "value": 1}, + "height": {"type": "Real", "value": -1.0} + } + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.sysml b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.sysml new file mode 100644 index 0000000000..0c8dc97a16 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.sysml @@ -0,0 +1,65 @@ +// A lander performs the dynamics of a body dropped from 19 m under 10 m/s^2 and +// runs a state machine on the same clock. The machine's own 10 s timeout drives +// the clock, so the dynamics step beside it. The height crosses zero between the +// steps at 1.5 s and 2 s: the crossing raises Touchdown at the end of the +// detecting step, the machine's accept takes it before the timeout, and the +// terminal crossing ends the dynamics there. +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def FallState :> StateSpace; + attribute def FallInput :> Input; + attribute def FallOutput :> Output; + part def Lander { + action def Touchdown :> ZeroCrossing; + + attribute gravity : Real = 10.0; + + perform action fall : ContinuousStateSpaceDynamics, FixedStepDynamics { + in :>> input : FallInput = VectorOf((gravity)); + :>> stateSpace : FallState = VectorOf((19.0, 0.0)); + out :>> output : FallOutput; + :>> timeStep = 0.5 [s]; + + event occurrence touchdown : Touchdown { + :>> guard = stateSpace.elements#(1); + :>> terminal = true; + } + + calc :>> getDerivative { + in input : FallInput; + in stateSpace : FallState; + return : StateDerivative = VectorOf((stateSpace.elements#(2), 0.0 - input.elements#(1))) / 1 [s]; + } + calc :>> getOutput { + in input : FallInput; + in stateSpace : FallState; + return : FallOutput = VectorOf((stateSpace.elements#(1))); + } + } + + state flight { + attribute landings : Integer = 0; + attribute height : Real = 0.0; + + entry; then falling; + + state falling; + accept Touchdown then landed; + transition first falling accept after 10 [s] then lost; + state lost; + state landed { + entry action { + assign landings := landings + 1; + assign height := fall.output.elements#(1); + } + } + } + } + + part lander : Lander; +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden new file mode 100644 index 0000000000..11d982ca0e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_state.trace.golden @@ -0,0 +1,506 @@ +materialize: lander #1 +start: performed action fall of #1 +materialize: fall #2 + eval feature gravity -> 10.0 + enter calc VectorFunctions::VectorOf + bind components = 10.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨10.0⟩ +eval invoke VectorOf -> ⟨10.0⟩ + eval literal 19.0 -> 19.0 + eval literal 0.0 -> 0.0 + eval sequence of 2 -> (19.0, 0.0) + enter calc VectorFunctions::VectorOf + bind components = (19.0, 0.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨19.0, 0.0⟩ +eval invoke VectorOf -> ⟨19.0, 0.0⟩ + eval literal 0.5 -> 0.5 +eval index -> 0.5 [s] +run: performed action fall of #1 +enter calc test::Lander::fall::getOutput + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨19.0, 0.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨19.0, 0.0⟩ + eval chain elements -> (19.0, 0.0) + eval literal 1 -> 1 + eval index -> 19.0 + enter calc VectorFunctions::VectorOf + bind components = 19.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨19.0⟩ + eval invoke VectorOf -> ⟨19.0⟩ +exit calc test::Lander::fall::getOutput -> ⟨19.0⟩ +state: fall t=0.0 x=⟨19.0, 0.0⟩ y=⟨19.0⟩ + eval feature stateSpace -> ⟨19.0, 0.0⟩ + eval chain elements -> (19.0, 0.0) + eval literal 1 -> 1 +eval index -> 19.0 +step 1: token 1@fall +eval literal 0 -> 0 +eval literal 0.0 -> 0.0 +enter: falling + eval literal 10 -> 10 +eval index -> 10 [s] +run: performed action fall of #1 +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨19.0, 0.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨19.0, 0.0⟩ + eval chain elements -> (19.0, 0.0) + eval literal 2 -> 2 + eval index -> 0.0 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (0.0, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (0.0, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨0.0, -10.0⟩ + eval invoke VectorOf -> ⟨0.0, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨0.0, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨0.0, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨19.0, -2.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨19.0, -2.5⟩ + eval chain elements -> (19.0, -2.5) + eval literal 2 -> 2 + eval index -> -2.5 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-2.5, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-2.5, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-2.5, -10.0⟩ + eval invoke VectorOf -> ⟨-2.5, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-2.5, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-2.5, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨18.375, -2.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨18.375, -2.5⟩ + eval chain elements -> (18.375, -2.5) + eval literal 2 -> 2 + eval index -> -2.5 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-2.5, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-2.5, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-2.5, -10.0⟩ + eval invoke VectorOf -> ⟨-2.5, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-2.5, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-2.5, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨17.75, -5.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨17.75, -5.0⟩ + eval chain elements -> (17.75, -5.0) + eval literal 2 -> 2 + eval index -> -5.0 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-5.0, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-5.0, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-5.0, -10.0⟩ + eval invoke VectorOf -> ⟨-5.0, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-5.0, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-5.0, -10.0⟩ [1/s] +enter calc test::Lander::fall::getOutput + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨17.75, -5.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨17.75, -5.0⟩ + eval chain elements -> (17.75, -5.0) + eval literal 1 -> 1 + eval index -> 17.75 + enter calc VectorFunctions::VectorOf + bind components = 17.75 [argument] + exit calc VectorFunctions::VectorOf -> ⟨17.75⟩ + eval invoke VectorOf -> ⟨17.75⟩ +exit calc test::Lander::fall::getOutput -> ⟨17.75⟩ +state: fall t=0.5 x=⟨17.75, -5.0⟩ y=⟨17.75⟩ + eval feature stateSpace -> ⟨17.75, -5.0⟩ + eval chain elements -> (17.75, -5.0) + eval literal 1 -> 1 +eval index -> 17.75 +step 2: token 1@fall +run: performed action fall of #1 +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨17.75, -5.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨17.75, -5.0⟩ + eval chain elements -> (17.75, -5.0) + eval literal 2 -> 2 + eval index -> -5.0 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-5.0, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-5.0, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-5.0, -10.0⟩ + eval invoke VectorOf -> ⟨-5.0, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-5.0, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-5.0, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨16.5, -7.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨16.5, -7.5⟩ + eval chain elements -> (16.5, -7.5) + eval literal 2 -> 2 + eval index -> -7.5 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-7.5, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-7.5, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-7.5, -10.0⟩ + eval invoke VectorOf -> ⟨-7.5, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-7.5, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-7.5, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨15.875, -7.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨15.875, -7.5⟩ + eval chain elements -> (15.875, -7.5) + eval literal 2 -> 2 + eval index -> -7.5 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-7.5, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-7.5, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-7.5, -10.0⟩ + eval invoke VectorOf -> ⟨-7.5, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-7.5, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-7.5, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨14.0, -10.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨14.0, -10.0⟩ + eval chain elements -> (14.0, -10.0) + eval literal 2 -> 2 + eval index -> -10.0 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-10.0, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-10.0, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-10.0, -10.0⟩ + eval invoke VectorOf -> ⟨-10.0, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-10.0, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-10.0, -10.0⟩ [1/s] +enter calc test::Lander::fall::getOutput + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨14.0, -10.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨14.0, -10.0⟩ + eval chain elements -> (14.0, -10.0) + eval literal 1 -> 1 + eval index -> 14.0 + enter calc VectorFunctions::VectorOf + bind components = 14.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨14.0⟩ + eval invoke VectorOf -> ⟨14.0⟩ +exit calc test::Lander::fall::getOutput -> ⟨14.0⟩ +state: fall t=1.0 x=⟨14.0, -10.0⟩ y=⟨14.0⟩ + eval feature stateSpace -> ⟨14.0, -10.0⟩ + eval chain elements -> (14.0, -10.0) + eval literal 1 -> 1 +eval index -> 14.0 +step 3: token 1@fall +run: performed action fall of #1 +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨14.0, -10.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨14.0, -10.0⟩ + eval chain elements -> (14.0, -10.0) + eval literal 2 -> 2 + eval index -> -10.0 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-10.0, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-10.0, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-10.0, -10.0⟩ + eval invoke VectorOf -> ⟨-10.0, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-10.0, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-10.0, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨11.5, -12.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨11.5, -12.5⟩ + eval chain elements -> (11.5, -12.5) + eval literal 2 -> 2 + eval index -> -12.5 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-12.5, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-12.5, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-12.5, -10.0⟩ + eval invoke VectorOf -> ⟨-12.5, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-12.5, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-12.5, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨10.875, -12.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨10.875, -12.5⟩ + eval chain elements -> (10.875, -12.5) + eval literal 2 -> 2 + eval index -> -12.5 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-12.5, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-12.5, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-12.5, -10.0⟩ + eval invoke VectorOf -> ⟨-12.5, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-12.5, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-12.5, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨7.75, -15.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨7.75, -15.0⟩ + eval chain elements -> (7.75, -15.0) + eval literal 2 -> 2 + eval index -> -15.0 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-15.0, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-15.0, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-15.0, -10.0⟩ + eval invoke VectorOf -> ⟨-15.0, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-15.0, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-15.0, -10.0⟩ [1/s] +enter calc test::Lander::fall::getOutput + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨7.75, -15.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨7.75, -15.0⟩ + eval chain elements -> (7.75, -15.0) + eval literal 1 -> 1 + eval index -> 7.75 + enter calc VectorFunctions::VectorOf + bind components = 7.75 [argument] + exit calc VectorFunctions::VectorOf -> ⟨7.75⟩ + eval invoke VectorOf -> ⟨7.75⟩ +exit calc test::Lander::fall::getOutput -> ⟨7.75⟩ +state: fall t=1.5 x=⟨7.75, -15.0⟩ y=⟨7.75⟩ + eval feature stateSpace -> ⟨7.75, -15.0⟩ + eval chain elements -> (7.75, -15.0) + eval literal 1 -> 1 +eval index -> 7.75 +step 4: token 1@fall +run: performed action fall of #1 +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨7.75, -15.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨7.75, -15.0⟩ + eval chain elements -> (7.75, -15.0) + eval literal 2 -> 2 + eval index -> -15.0 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-15.0, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-15.0, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-15.0, -10.0⟩ + eval invoke VectorOf -> ⟨-15.0, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-15.0, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-15.0, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨4.0, -17.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨4.0, -17.5⟩ + eval chain elements -> (4.0, -17.5) + eval literal 2 -> 2 + eval index -> -17.5 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-17.5, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-17.5, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-17.5, -10.0⟩ + eval invoke VectorOf -> ⟨-17.5, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-17.5, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-17.5, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨3.375, -17.5⟩ [argument] + stmt return + eval feature stateSpace -> ⟨3.375, -17.5⟩ + eval chain elements -> (3.375, -17.5) + eval literal 2 -> 2 + eval index -> -17.5 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-17.5, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-17.5, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-17.5, -10.0⟩ + eval invoke VectorOf -> ⟨-17.5, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-17.5, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-17.5, -10.0⟩ [1/s] +enter calc test::Lander::fall::getDerivative + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨-1.0, -20.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨-1.0, -20.0⟩ + eval chain elements -> (-1.0, -20.0) + eval literal 2 -> 2 + eval index -> -20.0 + eval literal 0.0 -> 0.0 + eval feature input -> ⟨10.0⟩ + eval chain elements -> (10.0) + eval literal 1 -> 1 + eval index -> 10.0 + eval operator - -> -10.0 + eval sequence of 2 -> (-20.0, -10.0) + enter calc VectorFunctions::VectorOf + bind components = (-20.0, -10.0) [argument] + exit calc VectorFunctions::VectorOf -> ⟨-20.0, -10.0⟩ + eval invoke VectorOf -> ⟨-20.0, -10.0⟩ + eval literal 1 -> 1 + eval index -> 1 [s] + eval operator / -> ⟨-20.0, -10.0⟩ [1/s] +exit calc test::Lander::fall::getDerivative -> ⟨-20.0, -10.0⟩ [1/s] +enter calc test::Lander::fall::getOutput + bind input = ⟨10.0⟩ [argument] + bind stateSpace = ⟨-1.0, -20.0⟩ [argument] + stmt return + eval feature stateSpace -> ⟨-1.0, -20.0⟩ + eval chain elements -> (-1.0, -20.0) + eval literal 1 -> 1 + eval index -> -1.0 + enter calc VectorFunctions::VectorOf + bind components = -1.0 [argument] + exit calc VectorFunctions::VectorOf -> ⟨-1.0⟩ + eval invoke VectorOf -> ⟨-1.0⟩ +exit calc test::Lander::fall::getOutput -> ⟨-1.0⟩ +state: fall t=2.0 x=⟨-1.0, -20.0⟩ y=⟨-1.0⟩ + eval feature stateSpace -> ⟨-1.0, -20.0⟩ + eval chain elements -> (-1.0, -20.0) + eval literal 1 -> 1 +eval index -> -1.0 +eval literal true -> true +event: zero crossing touchdown of fall (t=2.0) +step 5: no active tokens +exit: falling +enter: landed (entry action) +stmt action body + stmt assign landings + eval feature landings -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + stmt assign height + eval feature fall -> instance#2 + eval chain output -> ⟨-1.0⟩ + eval chain elements -> (-1.0) + eval literal 1 -> 1 + eval index -> -1.0 +transition: falling -> landed (event: accept Touchdown) diff --git a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_stationary.expected.json b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_stationary.expected.json new file mode 100644 index 0000000000..1c8774f853 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_stationary.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "libraries": true, + "evaluate": "test::Gauge::counter", + "performers": [ + { + "object": "test::gauge", + "finalState": "settled", + "stateVisits": ["idle", "once", "settled"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_stationary.sysml b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_stationary.sysml new file mode 100644 index 0000000000..72bc3c0854 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_space_zero_crossing_stationary.sysml @@ -0,0 +1,65 @@ +// A guard that reaches zero, stays there a step, then goes negative crosses +// once: stepped by Euler, the level falls 1, 0, 0, -1, -2 over four samples +// (the rate is 0 during the second only) and Grounded is raised on the arrival +// at zero only, not while the guard rests at zero nor when it leaves. The +// machine's timeouts drive the clock: no event by 10 s would leave it in +// `missed`, a second event after the first would send it to `twice`; the one +// event takes it to `once` and the timeout from there to `settled`. +package test { + private import ScalarValues::*; + private import SI::*; + private import VectorFunctions::*; + private import StateSpaceRepresentation::*; + private import StateSpaceIntegration::*; + + attribute def LevelState :> StateSpace; + attribute def LevelInput :> Input; + attribute def LevelOutput :> Output; + part def Gauge { + action def Grounded :> ZeroCrossing; + + perform action level : ContinuousStateSpaceDynamics, FixedStepDynamics { + in :>> input : LevelInput = VectorOf((1.0)); + :>> stateSpace : LevelState = VectorOf((1.0, 0.0)); + :>> timeStep = 1 [s]; + :>> stopTime = 4 [s]; + + event occurrence grounded : Grounded { + :>> guard = stateSpace.elements#(1); + } + + calc :>> getNextState { + calc :>> integrate : Euler; + } + calc :>> getDerivative { + in input : LevelInput; + in stateSpace : LevelState; + return : StateDerivative = VectorOf(( + if stateSpace.elements#(2) == 1.0 ? 0.0 else 0.0 - input.elements#(1), + 1.0 + )) / 1 [s]; + } + calc :>> getOutput { + in input : LevelInput; + in stateSpace : LevelState; + return : LevelOutput = VectorOf((stateSpace.elements#(1))); + } + } + + state counter { + entry; then idle; + + state idle; + transition idle accept Grounded then once; + transition idle accept after 10 [s] then missed; + state missed; + state once; + transition once accept Grounded then twice; + transition once accept after 10 [s] then settled; + state twice; + state settled; + } + } + + part gauge : Gauge; +} diff --git a/internal/exec/runtime/testdata/conformance/state_subaction_on_part.expected.json b/internal/exec/runtime/testdata/conformance/state_subaction_on_part.expected.json new file mode 100644 index 0000000000..6b7899460f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_subaction_on_part.expected.json @@ -0,0 +1,15 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::Machine", + "objects": [ + { + "finalState": "cooled", + "stateVisits": ["active", "cooled"], + "slots": { + "controller.cooled": {"type": "Boolean", "value": true}, + "cooled": {"type": "Boolean", "value": false} + } + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/state_subaction_on_part.sysml b/internal/exec/runtime/testdata/conformance/state_subaction_on_part.sysml new file mode 100644 index 0000000000..12291113a1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_subaction_on_part.sysml @@ -0,0 +1,37 @@ +// An entry, do or exit action given by a feature chain performs the action of +// the object the chain denotes, as that object: `exit controller.coolDown;` +// runs the controller's coolDown with the controller as `this`, so its own +// `cooled` flips while the machine's same-named attribute is untouched. +package test { + private import ScalarValues::*; + + part def Controller { + attribute cooled : Boolean default = false; + + action def CoolDown { + first start then flip; + action flip { + assign this.cooled := true; + } + first flip then done; + } + + action coolDown : CoolDown; + } + + part def Machine { + part controller : Controller; + attribute cooled : Boolean default = false; + + exhibit state run { + entry; then active; + + state active { + exit controller.coolDown; + } + state cooled; + + transition first active if not controller.cooled then cooled; + } + } +} diff --git a/internal/core/runtime/testdata/conformance/state_subaction_reference.expected.json b/internal/exec/runtime/testdata/conformance/state_subaction_reference.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_subaction_reference.expected.json rename to internal/exec/runtime/testdata/conformance/state_subaction_reference.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_subaction_reference.sysml b/internal/exec/runtime/testdata/conformance/state_subaction_reference.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_subaction_reference.sysml rename to internal/exec/runtime/testdata/conformance/state_subaction_reference.sysml diff --git a/internal/core/runtime/testdata/conformance/state_target_transition_after_do_action.expected.json b/internal/exec/runtime/testdata/conformance/state_target_transition_after_do_action.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_target_transition_after_do_action.expected.json rename to internal/exec/runtime/testdata/conformance/state_target_transition_after_do_action.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_target_transition_after_do_action.sysml b/internal/exec/runtime/testdata/conformance/state_target_transition_after_do_action.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_target_transition_after_do_action.sysml rename to internal/exec/runtime/testdata/conformance/state_target_transition_after_do_action.sysml diff --git a/internal/core/runtime/testdata/conformance/state_target_transition_guard.expected.json b/internal/exec/runtime/testdata/conformance/state_target_transition_guard.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_target_transition_guard.expected.json rename to internal/exec/runtime/testdata/conformance/state_target_transition_guard.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_target_transition_guard.sysml b/internal/exec/runtime/testdata/conformance/state_target_transition_guard.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_target_transition_guard.sysml rename to internal/exec/runtime/testdata/conformance/state_target_transition_guard.sysml diff --git a/internal/core/runtime/testdata/conformance/state_target_transition_nested_timed.expected.json b/internal/exec/runtime/testdata/conformance/state_target_transition_nested_timed.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_target_transition_nested_timed.expected.json rename to internal/exec/runtime/testdata/conformance/state_target_transition_nested_timed.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_target_transition_nested_timed.sysml b/internal/exec/runtime/testdata/conformance/state_target_transition_nested_timed.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_target_transition_nested_timed.sysml rename to internal/exec/runtime/testdata/conformance/state_target_transition_nested_timed.sysml diff --git a/internal/core/runtime/testdata/conformance/state_target_transition_nested_timed.trace.golden b/internal/exec/runtime/testdata/conformance/state_target_transition_nested_timed.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_target_transition_nested_timed.trace.golden rename to internal/exec/runtime/testdata/conformance/state_target_transition_nested_timed.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_target_transition_top_level_timed.expected.json b/internal/exec/runtime/testdata/conformance/state_target_transition_top_level_timed.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_target_transition_top_level_timed.expected.json rename to internal/exec/runtime/testdata/conformance/state_target_transition_top_level_timed.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_target_transition_top_level_timed.sysml b/internal/exec/runtime/testdata/conformance/state_target_transition_top_level_timed.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_target_transition_top_level_timed.sysml rename to internal/exec/runtime/testdata/conformance/state_target_transition_top_level_timed.sysml diff --git a/internal/core/runtime/testdata/conformance/state_target_transition_top_level_timed.trace.golden b/internal/exec/runtime/testdata/conformance/state_target_transition_top_level_timed.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_target_transition_top_level_timed.trace.golden rename to internal/exec/runtime/testdata/conformance/state_target_transition_top_level_timed.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_after_accept.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_after_accept.expected.json new file mode 100644 index 0000000000..c90e4958a1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_after_accept.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "events": [{"signal": "Go"}], + "finalState": "u", + "stateVisits": ["s", "t", "u"], + "outputs": { + "d": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_after_accept.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_after_accept.sysml new file mode 100644 index 0000000000..939a547ff4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_after_accept.sysml @@ -0,0 +1,33 @@ +// A braced `do { … }` runs one statement at a time. Its first statement parks at +// an accept until Go arrives; the `terminate` reached after it ends the whole +// block, so the second accept the block would have parked at never begins and +// the assignment after it never runs. With its do behavior over the state +// completes to `t`, and the machine keeps dispatching: the timed transition +// takes `t` to `u` a second later. +package Test { + private import ScalarValues::*; + private import SI::*; + + attribute def Go; + + state def Machine { + attribute d : Integer = 0; + + entry; then s; + + state s { + do { + action wait { first start; then action w accept Go; then done; } + assign d := 1; + terminate; + action again { first start; then action w accept Go; then done; } + assign d := 9; + } + } + state t; + state u; + + transition first s then t; + transition first t accept after 1 [SI::s] then u; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_after_accept.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_after_accept.trace.golden new file mode 100644 index 0000000000..2f880e2b27 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_after_accept.trace.golden @@ -0,0 +1,20 @@ +do: s +stmt action body + stmt node wait +enter action node: wait +do: s +leave action node: wait +do: s + stmt assign d + eval literal 1 -> 1 +do: s + stmt terminate +terminate state behavior : no token dropped +exit: s +enter: t + eval literal 1 -> 1 +eval index -> 1 [SI::s] +transition: s -> t +exit: t +enter: u +transition: t -> u (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.check.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.check.expected.json new file mode 100644 index 0000000000..c114d38a71 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.check.expected.json @@ -0,0 +1,4 @@ +{ + "verdict": "no violation, exhaustive", + "agreed": {"finalState": "ldone+rdone", "a": "1", "b": "3"} +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.expected.json new file mode 100644 index 0000000000..e03a399d78 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "finalState": "ldone+rdone", + "outputs": { + "a": {"type": "Integer", "value": 1}, + "b": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.sysml new file mode 100644 index 0000000000..d1716bad86 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.sysml @@ -0,0 +1,37 @@ +// A `terminate` in one region's braced `do { … }` ends that block only: the +// sibling region's do block runs every statement of its own, each region's +// state completes when its do behavior is over, and both reach their final states. +package Test { + private import ScalarValues::*; + + state def Machine parallel { + attribute a : Integer = 0; + attribute b : Integer = 0; + + state left { + entry; then lwork; + state lwork { + do { + assign a := 1; + terminate; + assign a := 9; + } + } + state ldone; + succession first lwork then ldone; + } + + state right { + entry; then rwork; + state rwork { + do { + assign b := 1; + assign b := b + 1; + assign b := b + 1; + } + } + state rdone; + succession first rwork then rdone; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.trace.golden new file mode 100644 index 0000000000..bf56f54b52 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_do_in_one_region.trace.golden @@ -0,0 +1,29 @@ +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork +stmt action body + stmt assign a + eval literal 1 -> 1 +do: rwork +stmt action body + stmt assign b + eval literal 1 -> 1 +choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first) +do: lwork + stmt terminate +terminate state behavior : no token dropped +do: rwork + stmt assign b + eval feature b -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +exit: lwork +enter: ldone +transition: lwork -> ldone +do: rwork + stmt assign b + eval feature b -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +exit: rwork +enter: rdone +transition: rwork -> rdone diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_among_named.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_among_named.expected.json new file mode 100644 index 0000000000..f8e825ce9f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_among_named.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "finalState": "s", + "stateVisits": ["s"], + "outputs": { + "a": {"type": "Integer", "value": 1}, + "log": {"type": "String", "value": "head;tail;"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_among_named.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_among_named.sysml new file mode 100644 index 0000000000..3e9279204a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_among_named.sysml @@ -0,0 +1,19 @@ +// A `terminate` in a braced `entry { … }` ends that block alone: the named entry +// actions declared before and after it run to their ends, so `head` and `tail` +// both write and only the braced block's trailing assignment is cut. +package Test { + private import ScalarValues::*; + + state def Machine { + attribute a : Integer = 0; + attribute log : String = ""; + + entry; then s; + + state s { + entry action head { assign log := log + "head;"; } + entry { assign a := 1; terminate; assign a := 9; } + entry action tail { assign log := log + "tail;"; } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_do_exit_effect.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_do_exit_effect.expected.json new file mode 100644 index 0000000000..3f9ed61aed --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_do_exit_effect.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "finalState": "t", + "stateVisits": ["s", "t"], + "outputs": { + "e": {"type": "Integer", "value": 1}, + "d": {"type": "Integer", "value": 1}, + "x": {"type": "Integer", "value": 1}, + "n": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_do_exit_effect.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_do_exit_effect.sysml new file mode 100644 index 0000000000..71edfc0363 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_do_exit_effect.sysml @@ -0,0 +1,29 @@ +// `terminate` written in a braced `entry { … }`, `do { … }`, `exit { … }` or +// transition `do { … }` block ends the whole block, not just the statement it +// stands in: the assignments after it never run, the state stays active, its do +// behavior still starts after the entry block ends, and the transition whose exit +// and effect blocks end early still completes to its target. +package test { + private import ScalarValues::*; + private import SI::*; + + state def Machine { + attribute e : Integer = 0; + attribute d : Integer = 0; + attribute x : Integer = 0; + attribute n : Integer = 0; + + entry; then s; + + state s { + entry { assign e := 1; terminate; assign e := 9; } + do { assign d := 1; terminate; assign d := 9; } + exit { assign x := 1; terminate; assign x := 9; } + } + state t; + + transition first s accept after 1 [SI::s] + do { assign n := 1; terminate; assign n := 9; } + then t; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_do_exit_effect.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_do_exit_effect.trace.golden new file mode 100644 index 0000000000..84db0bbc28 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_entry_do_exit_effect.trace.golden @@ -0,0 +1,20 @@ +do: s +stmt action body + stmt assign d + eval literal 1 -> 1 +do: s + stmt terminate +terminate state behavior : no token dropped +exit: s (exit action) +stmt action body + stmt assign x + eval literal 1 -> 1 + stmt terminate +terminate state behavior : no token dropped +stmt action body + stmt assign n + eval literal 1 -> 1 + stmt terminate +terminate state behavior : no token dropped +enter: t +transition: s -> t (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_inherited_by_two_usages.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_braced_inherited_by_two_usages.expected.json new file mode 100644 index 0000000000..2823f3123d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_inherited_by_two_usages.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "evaluate": "Test::Machine", + "events": [{"signal": "Next"}, {"signal": "Go"}, {"signal": "Next"}], + "finalState": "done", + "stateVisits": ["one", "idle", "done", "two", "idle", "done"], + "outputs": { + "one.log": {"type": "String", "value": "e1;d1;x1;n1;"}, + "two.log": {"type": "String", "value": "e1;d1;x1;n1;"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_braced_inherited_by_two_usages.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_braced_inherited_by_two_usages.sysml new file mode 100644 index 0000000000..fb7e7e5b9b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_braced_inherited_by_two_usages.sysml @@ -0,0 +1,32 @@ +// Two usages of one state definition each run their own copy of the braced +// entry, do, exit and effect blocks the definition declares, and a `terminate` +// in each copy ends that copy's block: both usages log the first assignment of +// every block and none of the trailing ones. +package Test { + private import ScalarValues::*; + + attribute def Next; + attribute def Go; + + state def Inner { + attribute log : String = ""; + + entry; then idle; + state idle { + entry { assign log := log + "e1;"; terminate; assign log := log + "e9;"; } + do { assign log := log + "d1;"; terminate; assign log := log + "d9;"; } + exit { assign log := log + "x1;"; terminate; assign log := log + "x9;"; } + } + state done; + transition first idle accept Next + do { assign log := log + "n1;"; terminate; assign log := log + "n9;"; } + then done; + } + + state def Machine { + entry; then one; + state one : Inner; + state two : Inner; + transition first one accept Go then two; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_entering_composite.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_entering_composite.expected.json new file mode 100644 index 0000000000..4509c32e51 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_entering_composite.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Start"}], + "terminated": true, + "stateVisits": ["wait", "s1"], + "outputs": { + "log": {"type": "String", "value": "wait(exit);start(effect);s1(entry);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_entering_composite.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_entering_composite.sysml new file mode 100644 index 0000000000..86d6e59605 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_entering_composite.sysml @@ -0,0 +1,31 @@ +// A transition from outside a composite state into a terminate action the +// composite declares leaves the source, performs its effect, enters the +// composite on its way (its entry behavior runs), then ends the machine there: +// the composite's own entry transition never fires and nothing is exited. +package Test { + private import ScalarValues::*; + + attribute def Start; + + state def Machine { + attribute log : String = ""; + + entry; then wait; + state wait { + exit { assign log := log + "wait(exit);"; } + } + + state s1 { + entry { assign log := log + "s1(entry);"; } + exit { assign log := log + "s1(exit);"; } + + entry; then inner; + state inner { + entry { assign log := log + "inner(entry);"; } + } + action stop terminate; + } + + transition first wait accept Start do { assign log := log + "start(effect);"; } then s1.stop; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_entering_composite.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_entering_composite.trace.golden new file mode 100644 index 0000000000..d71185f689 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_entering_composite.trace.golden @@ -0,0 +1,19 @@ +exit: wait (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "wait(exit);" -> "wait(exit);" + eval operator + -> "wait(exit);" +stmt action body + stmt assign log + eval feature log -> "wait(exit);" + eval literal "start(effect);" -> "start(effect);" + eval operator + -> "wait(exit);start(effect);" +enter: s1 (entry action) +stmt action body + stmt assign log + eval feature log -> "wait(exit);start(effect);" + eval literal "s1(entry);" -> "s1(entry);" + eval operator + -> "wait(exit);start(effect);s1(entry);" +transition: wait -> stop (event: accept Start) +terminate: stop diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_entry_do_exit_behaviors.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_entry_do_exit_behaviors.expected.json new file mode 100644 index 0000000000..95681b9e58 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_entry_do_exit_behaviors.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "trace": true, + "finalState": "next", + "stateVisits": ["idle", "next"], + "outputs": { + "count": {"type": "Integer", "value": 11}, + "later": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_entry_do_exit_behaviors.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_entry_do_exit_behaviors.sysml new file mode 100644 index 0000000000..1ed15b207a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_entry_do_exit_behaviors.sysml @@ -0,0 +1,34 @@ +// `terminate` in a state's entry, do or exit behavior ends that behavior at the +// statement: the state stays active and the machine keeps dispatching. The +// transition out is timed, so the do body reaches its `terminate` first. +package test { + private import ScalarValues::*; + + state def Machine { + attribute count : Integer = 0; + attribute later : Integer = 0; + + entry; then idle; + + state idle { + entry action begin { + assign count := 1; + terminate; + assign count := 99; + } + do action tick { + assign later := 5; + terminate; + assign later := 77; + } + exit action leave { + assign count := count + 10; + terminate; + assign count := 0; + } + } + state next; + + transition first idle accept after 1 then next; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_entry_do_exit_behaviors.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_entry_do_exit_behaviors.trace.golden new file mode 100644 index 0000000000..6a77c9f46d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_entry_do_exit_behaviors.trace.golden @@ -0,0 +1,17 @@ +do: idle +stmt action body + stmt assign later + eval literal 5 -> 5 +do: idle + stmt terminate +terminate state behavior tick: no token dropped +exit: idle (exit action) +stmt action body + stmt assign count + eval feature count -> 1 + eval literal 10 -> 10 + eval operator + -> 11 + stmt terminate +terminate state behavior leave: no token dropped +enter: next +transition: idle -> next (event: time) diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_inherited_by_two_usages.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_inherited_by_two_usages.expected.json new file mode 100644 index 0000000000..df546a35e4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_inherited_by_two_usages.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "evaluate": "Test::Machine", + "trace": true, + "events": [{"signal": "Next"}, {"signal": "Go"}, {"signal": "Next"}, {"signal": "Abort"}], + "terminated": true, + "stateVisits": ["one", "idle", "work", "w1", "two", "idle", "work", "w1"], + "outputs": { + "one.log": {"type": "String", "value": "idle(exit);w1(exit);work(exit);"}, + "two.log": {"type": "String", "value": "idle(exit);w1(exit);abort(effect);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_inherited_by_two_usages.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_inherited_by_two_usages.sysml new file mode 100644 index 0000000000..e0880b16e3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_inherited_by_two_usages.sysml @@ -0,0 +1,43 @@ +// Two usages of one state definition each own a copy of the terminate action and +// the junction the definition declares, the copy nested in the composite the usage +// inherits included: the first usage's junction routes within that usage, and the +// second usage's transition terminates the machine through its own copy, so the +// exits run are the second usage's and no vertex of the first is touched. +package Test { + private import ScalarValues::*; + + attribute def Next; + attribute def Go; + attribute def Abort; + + state def Inner { + attribute log : String = ""; + + entry; then idle; + state idle { + exit { assign log := log + "idle(exit);"; } + } + transition first idle accept Next then relay; + junction relay; + transition first relay then work; + state work { + exit { assign log := log + "work(exit);"; } + + entry; then w1; + state w1 { + exit { assign log := log + "w1(exit);"; } + } + transition first w1 accept Abort do { assign log := log + "abort(effect);"; } then halt; + action halt terminate; + } + action stop terminate; + transition first idle accept Abort then stop; + } + + state def Machine { + entry; then one; + state one : Inner; + state two : Inner; + transition first one accept Go then two; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_inherited_by_two_usages.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_inherited_by_two_usages.trace.golden new file mode 100644 index 0000000000..feb2d20458 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_inherited_by_two_usages.trace.golden @@ -0,0 +1,47 @@ +exit: idle (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "idle(exit);" -> "idle(exit);" + eval operator + -> "idle(exit);" +enter: work +enter: w1 +transition: idle -> work (event: accept Next) +exit: w1 (exit action) +stmt action body + stmt assign log + eval feature log -> "idle(exit);" + eval literal "w1(exit);" -> "w1(exit);" + eval operator + -> "idle(exit);w1(exit);" +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "idle(exit);w1(exit);" + eval literal "work(exit);" -> "work(exit);" + eval operator + -> "idle(exit);w1(exit);work(exit);" +exit: one +enter: two +enter: idle +transition: w1 -> two (event: accept Go) +exit: idle (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "idle(exit);" -> "idle(exit);" + eval operator + -> "idle(exit);" +enter: work +enter: w1 +transition: idle -> work (event: accept Next) +exit: w1 (exit action) +stmt action body + stmt assign log + eval feature log -> "idle(exit);" + eval literal "w1(exit);" -> "w1(exit);" + eval operator + -> "idle(exit);w1(exit);" +stmt action body + stmt assign log + eval feature log -> "idle(exit);w1(exit);" + eval literal "abort(effect);" -> "abort(effect);" + eval operator + -> "idle(exit);w1(exit);abort(effect);" +transition: w1 -> halt (event: accept Abort) +terminate: halt diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_inside_composite.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_inside_composite.expected.json new file mode 100644 index 0000000000..de3d84a777 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_inside_composite.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Abort"}], + "terminated": true, + "stateVisits": ["work", "inner"], + "outputs": { + "log": {"type": "String", "value": "work(entry);inner(entry);watch;inner(exit);abort(effect);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_inside_composite.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_inside_composite.sysml new file mode 100644 index 0000000000..4d06756790 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_inside_composite.sysml @@ -0,0 +1,38 @@ +// A transition inside a composite state into a terminate action the composite +// declares leaves the substate as any transition does (its exit behavior runs), +// performs its effect, then ends the whole machine's performance: the composite +// itself is not exited and its do behavior is abandoned where it waits. +package Test { + private import ScalarValues::*; + + attribute def Abort; + attribute def Never; + + state def Machine { + attribute log : String = ""; + + entry; then work; + state work { + entry { assign log := log + "work(entry);"; } + do action watch { + first start; + then action w1 assign log := log + "watch;"; + then action wait accept Never; + then action w2 assign log := log + "never;"; + then done; + } + exit { assign log := log + "work(exit);"; } + + entry; then inner; + state inner { + entry { assign log := log + "inner(entry);"; } + exit { assign log := log + "inner(exit);"; } + } + action stop terminate; + + transition first inner accept Abort do { assign log := log + "abort(effect);"; } then stop; + } + state after; + transition first work accept Never then after; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_inside_composite.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_inside_composite.trace.golden new file mode 100644 index 0000000000..0085c1dade --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_inside_composite.trace.golden @@ -0,0 +1,20 @@ +do: work +stmt action body +enter action node: state behavior watch + stmt assign log + eval feature log -> "work(entry);inner(entry);" + eval literal "watch;" -> "watch;" + eval operator + -> "work(entry);inner(entry);watch;" +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "work(entry);inner(entry);watch;" + eval literal "inner(exit);" -> "inner(exit);" + eval operator + -> "work(entry);inner(entry);watch;inner(exit);" +stmt action body + stmt assign log + eval feature log -> "work(entry);inner(entry);watch;inner(exit);" + eval literal "abort(effect);" -> "abort(effect);" + eval operator + -> "work(entry);inner(entry);watch;inner(exit);abort(effect);" +transition: inner -> stop (event: accept Abort) +terminate: stop (do behavior abandoned: work) diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_inside_region.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_inside_region.expected.json new file mode 100644 index 0000000000..e77faec97e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_inside_region.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "schedule": "reverse", + "trace": true, + "events": [{"signal": "Abort"}], + "terminated": true, + "stateVisits": ["l1", "r1"], + "outputs": { + "log": {"type": "String", "value": "l1(entry);r1(entry);hold;l1(exit);abort(effect);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_inside_region.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_inside_region.sysml new file mode 100644 index 0000000000..d6f23ceab2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_inside_region.sysml @@ -0,0 +1,40 @@ +// A transition in one region of an orthogonal state into a terminate action +// leaves its own source (exit behavior and all), then ends the whole machine: +// the sibling region's state is not exited and its do behavior is abandoned. +package Test { + private import ScalarValues::*; + + attribute def Abort; + attribute def Never; + + state def Machine parallel { + attribute log : String = ""; + + state left { + entry; then l1; + state l1 { + entry { assign log := log + "l1(entry);"; } + exit { assign log := log + "l1(exit);"; } + } + action stop terminate; + transition first l1 accept Abort do { assign log := log + "abort(effect);"; } then stop; + } + + state right { + entry; then r1; + state r1 { + entry { assign log := log + "r1(entry);"; } + do action hold { + first start; + then action h1 assign log := log + "hold;"; + then action wait accept Never; + then action h2 assign log := log + "never;"; + then done; + } + exit { assign log := log + "r1(exit);"; } + } + state r2; + transition first r1 accept Never then r2; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_inside_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_inside_region.trace.golden new file mode 100644 index 0000000000..57ca32db78 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_inside_region.trace.golden @@ -0,0 +1,20 @@ +do: r1 +stmt action body +enter action node: state behavior hold + stmt assign log + eval feature log -> "l1(entry);r1(entry);" + eval literal "hold;" -> "hold;" + eval operator + -> "l1(entry);r1(entry);hold;" +exit: l1 (exit action) +stmt action body + stmt assign log + eval feature log -> "l1(entry);r1(entry);hold;" + eval literal "l1(exit);" -> "l1(exit);" + eval operator + -> "l1(entry);r1(entry);hold;l1(exit);" +stmt action body + stmt assign log + eval feature log -> "l1(entry);r1(entry);hold;l1(exit);" + eval literal "abort(effect);" -> "abort(effect);" + eval operator + -> "l1(entry);r1(entry);hold;l1(exit);abort(effect);" +transition: l1 -> stop (event: accept Abort) +terminate: stop (do behavior abandoned: r1) diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_this_ends_performer.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_this_ends_performer.expected.json new file mode 100644 index 0000000000..922a3b3ce4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_this_ends_performer.expected.json @@ -0,0 +1,16 @@ +{ + "type": "instance", + "libraries": true, + "trace": true, + "instantiate": "test::Worker", + "objects": [ + { + "terminated": true, + "stateVisits": ["busy"], + "slots": { + "count": {"type": "Integer", "value": 1}, + "exits": {"type": "Integer", "value": 0} + } + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_this_ends_performer.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_this_ends_performer.sysml new file mode 100644 index 0000000000..ebdfba7caf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_this_ends_performer.sysml @@ -0,0 +1,32 @@ +// `terminate this;` in the do behavior of a state a part exhibits ends the part +// (SysML v2 §7.17.10; an exhibited state is a performance the object owns, so +// `this` there is the object), and with it every behavior the part performs: the +// machine is terminated where it stands, no state exited, rather than completing. +package test { + private import ScalarValues::*; + + attribute def Never; + + part def Worker { + attribute count : Integer = 0; + attribute exits : Integer = 0; + + exhibit state cycle { + entry; then busy; + + state busy { + do action work { + assign count := 1; + terminate this; + assign count := 2; + } + exit action leave { + assign exits := 1; + } + } + + transition first busy accept Never then idle; + state idle; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_this_ends_performer.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_this_ends_performer.trace.golden new file mode 100644 index 0000000000..14c36d0169 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_this_ends_performer.trace.golden @@ -0,0 +1,14 @@ +materialize: Worker #1 +start: exhibited state machine cycle of #1 +materialize: cycle #2 +enter: busy +run: exhibited state machine cycle of #1 +do: busy +stmt action body + stmt assign count + eval literal 1 -> 1 +do: busy + stmt terminate + eval feature this -> instance#1 +terminate: Worker #1 +terminated with occurrence: cycle (do behavior abandoned: busy) diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_through_choice.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_through_choice.expected.json new file mode 100644 index 0000000000..fda8794224 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_through_choice.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Check"}], + "terminated": true, + "stateVisits": ["idle"], + "outputs": { + "x": {"type": "Integer", "value": 2}, + "log": {"type": "String", "value": "idle(exit);check;bad;"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_through_choice.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_through_choice.sysml new file mode 100644 index 0000000000..976245290b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_through_choice.sysml @@ -0,0 +1,28 @@ +// A choice whose enabled branch leads into a terminate action ends the machine +// once the branch is taken: the source is exited before the choice, since every +// branch leaves it, the segments' effects run in order, and the other branch's +// target is never entered. +package Test { + private import ScalarValues::*; + + attribute def Check; + + state def Machine { + attribute x : Integer = 0; + attribute log : String = ""; + + entry; then idle; + state idle { + exit { assign log := log + "idle(exit);"; } + } + state fine { + entry { assign log := log + "fine(entry);"; } + } + choice verdict; + action stop terminate; + + transition first idle accept Check do { assign x := 2; assign log := log + "check;"; } then verdict; + transition first verdict if x > 1 do { assign log := log + "bad;"; } then stop; + transition first verdict do { assign log := log + "good;"; } then fine; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_through_choice.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_through_choice.trace.golden new file mode 100644 index 0000000000..f32a05ce90 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_through_choice.trace.golden @@ -0,0 +1,23 @@ +exit: idle (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "idle(exit);" -> "idle(exit);" + eval operator + -> "idle(exit);" +stmt action body + stmt assign x + eval literal 2 -> 2 + stmt assign log + eval feature log -> "idle(exit);" + eval literal "check;" -> "check;" + eval operator + -> "idle(exit);check;" + eval feature x -> 2 + eval literal 1 -> 1 +eval operator > -> true +stmt action body + stmt assign log + eval feature log -> "idle(exit);check;" + eval literal "bad;" -> "bad;" + eval operator + -> "idle(exit);check;bad;" +transition: idle -> stop (event: accept Check) +terminate: stop diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_through_join.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_through_join.expected.json new file mode 100644 index 0000000000..f4f353c363 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_through_join.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "schedule": "reverse", + "trace": true, + "events": [{"signal": "Check"}], + "terminated": true, + "stateVisits": ["work", "l1", "r1"], + "outputs": { + "log": {"type": "String", "value": "l1(exit);r1(exit);work(exit);meet;"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_through_join.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_through_join.sysml new file mode 100644 index 0000000000..3080927e97 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_through_join.sysml @@ -0,0 +1,36 @@ +// A join whose outgoing segment leads into a terminate action declared beside the +// orthogonal state ends the machine once every source has arrived: the sources and +// the composite they leave are exited, as for any transition out of it, the segment's +// effect runs, and the terminate is reached; the run is terminated, at no final state. +package Test { + private import ScalarValues::*; + + attribute def Check; + + state def Machine { + attribute log : String = ""; + + entry; then work; + state work parallel { + exit { assign log := log + "work(exit);"; } + state left { + entry; then l1; + state l1 { + exit { assign log := log + "l1(exit);"; } + } + transition first l1 accept Check then meet; + } + state right { + entry; then r1; + state r1 { + exit { assign log := log + "r1(exit);"; } + } + transition first r1 accept Check then meet; + } + } + join meet; + action stop terminate; + + transition first meet do { assign log := log + "meet;"; } then stop; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_through_join.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_through_join.trace.golden new file mode 100644 index 0000000000..f2a8ff68cc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_through_join.trace.golden @@ -0,0 +1,26 @@ +choice join meet: states l1, r1 react (unordered; took l1 first) +exit: l1 (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "l1(exit);" -> "l1(exit);" + eval operator + -> "l1(exit);" +exit: r1 (exit action) +stmt action body + stmt assign log + eval feature log -> "l1(exit);" + eval literal "r1(exit);" -> "r1(exit);" + eval operator + -> "l1(exit);r1(exit);" +exit: work (exit action) +stmt action body + stmt assign log + eval feature log -> "l1(exit);r1(exit);" + eval literal "work(exit);" -> "work(exit);" + eval operator + -> "l1(exit);r1(exit);work(exit);" +stmt action body + stmt assign log + eval feature log -> "l1(exit);r1(exit);work(exit);" + eval literal "meet;" -> "meet;" + eval operator + -> "l1(exit);r1(exit);work(exit);meet;" +transition: work -> stop (event: accept Check) +terminate: stop diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_through_junction.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_through_junction.expected.json new file mode 100644 index 0000000000..63a5ee4a2f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_through_junction.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Check"}], + "terminated": true, + "stateVisits": ["idle"], + "outputs": { + "log": {"type": "String", "value": "idle(exit);check;relay;"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_through_junction.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_through_junction.sysml new file mode 100644 index 0000000000..5f2e00d28a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_through_junction.sysml @@ -0,0 +1,22 @@ +// A junction whose only way on leads into a terminate action ends the machine as the +// compound transition completes: the source is exited, the segments' effects run in +// order, and the terminate is reached with no other state entered or exited. +package Test { + private import ScalarValues::*; + + attribute def Check; + + state def Machine { + attribute log : String = ""; + + entry; then idle; + state idle { + exit { assign log := log + "idle(exit);"; } + } + junction relay; + action stop terminate; + + transition first idle accept Check do { assign log := log + "check;"; } then relay; + transition first relay do { assign log := log + "relay;"; } then stop; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_through_junction.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_through_junction.trace.golden new file mode 100644 index 0000000000..c8942a8188 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_through_junction.trace.golden @@ -0,0 +1,18 @@ +exit: idle (exit action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "idle(exit);" -> "idle(exit);" + eval operator + -> "idle(exit);" +stmt action body + stmt assign log + eval feature log -> "idle(exit);" + eval literal "check;" -> "check;" + eval operator + -> "idle(exit);check;" +stmt action body + stmt assign log + eval feature log -> "idle(exit);check;" + eval literal "relay;" -> "relay;" + eval operator + -> "idle(exit);check;relay;" +transition: idle -> stop (event: accept Check) +terminate: stop diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_transition_body.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_transition_body.expected.json new file mode 100644 index 0000000000..1b18eb3f22 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_transition_body.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "finalState": "t", + "stateVisits": ["s", "t"], + "outputs": { + "n": { "type": "Integer", "value": 1 } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_transition_body.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_transition_body.sysml new file mode 100644 index 0000000000..a777512e83 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_transition_body.sysml @@ -0,0 +1,21 @@ +package Test { + // A transition's own body, `then t { … }`, is the transition usage's action + // body: its statements are the steps of the one firing, so a `terminate` + // among them ends the steps after it — `n` is left at 1, not 9 — while the + // transition still completes and the target is entered. + private import ScalarValues::*; + + state def Machine { + attribute n : Integer = 0; + + entry; then s; + state s; + state t; + + transition first s then t { + assign n := 1; + terminate; + assign n := 9; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_transition_body.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_transition_body.trace.golden new file mode 100644 index 0000000000..0bc84c9878 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_transition_body.trace.golden @@ -0,0 +1,8 @@ +exit: s +stmt assign n + eval literal 1 -> 1 +stmt terminate +terminate anonymous state behavior: no token dropped +terminate anonymous state behavior: ended before it began +enter: t +transition: s -> t diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_transition_ends_machine.expected.json b/internal/exec/runtime/testdata/conformance/state_terminate_transition_ends_machine.expected.json new file mode 100644 index 0000000000..2c212ec187 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_transition_ends_machine.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Abort"}], + "terminated": true, + "stateVisits": ["busy"], + "outputs": { + "count": {"type": "Integer", "value": 1}, + "exits": {"type": "Integer", "value": 1}, + "effects": {"type": "Integer", "value": 7} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_transition_ends_machine.sysml b/internal/exec/runtime/testdata/conformance/state_terminate_transition_ends_machine.sysml new file mode 100644 index 0000000000..908f51f4af --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_transition_ends_machine.sysml @@ -0,0 +1,35 @@ +// A transition to a terminate action leaves its source as any transition does +// (its exit behavior runs), performs its effect, then ends the state machine +// performance at once: nothing else is exited and the machine's own do behavior +// is aborted where it waits. +package test { + private import ScalarValues::*; + + attribute def Never; + attribute def Abort; + + state def Machine { + attribute count : Integer = 0; + attribute exits : Integer = 0; + attribute effects : Integer = 0; + + do action tick { + first start; + then action a assign count := 1; + then action wait accept Never; + then action b assign count := 2; + then done; + } + + entry; then busy; + + state busy { + exit action leave { + assign exits := 1; + } + } + + transition first busy accept Abort do assign effects := 7 then stop; + action stop terminate; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_terminate_transition_ends_machine.trace.golden b/internal/exec/runtime/testdata/conformance/state_terminate_transition_ends_machine.trace.golden new file mode 100644 index 0000000000..21e2f50f77 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_terminate_transition_ends_machine.trace.golden @@ -0,0 +1,13 @@ +do: Machine +stmt action body +enter action node: state behavior tick + stmt assign count + eval literal 1 -> 1 +exit: busy (exit action) +stmt action body + stmt assign exits + eval literal 1 -> 1 +stmt assign effects + eval literal 7 -> 7 +transition: busy -> stop (event: accept Abort) +terminate: stop (do behavior abandoned: Machine) diff --git a/internal/core/runtime/testdata/conformance/state_then_skips_non_feature_members.expected.json b/internal/exec/runtime/testdata/conformance/state_then_skips_non_feature_members.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_then_skips_non_feature_members.expected.json rename to internal/exec/runtime/testdata/conformance/state_then_skips_non_feature_members.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_then_skips_non_feature_members.sysml b/internal/exec/runtime/testdata/conformance/state_then_skips_non_feature_members.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_then_skips_non_feature_members.sysml rename to internal/exec/runtime/testdata/conformance/state_then_skips_non_feature_members.sysml diff --git a/internal/core/runtime/testdata/conformance/state_time_quantity_instant.expected.json b/internal/exec/runtime/testdata/conformance/state_time_quantity_instant.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_time_quantity_instant.expected.json rename to internal/exec/runtime/testdata/conformance/state_time_quantity_instant.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_time_quantity_instant.sysml b/internal/exec/runtime/testdata/conformance/state_time_quantity_instant.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_time_quantity_instant.sysml rename to internal/exec/runtime/testdata/conformance/state_time_quantity_instant.sysml diff --git a/internal/core/runtime/testdata/conformance/state_time_quantity_seconds.expected.json b/internal/exec/runtime/testdata/conformance/state_time_quantity_seconds.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_time_quantity_seconds.expected.json rename to internal/exec/runtime/testdata/conformance/state_time_quantity_seconds.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_time_quantity_seconds.sysml b/internal/exec/runtime/testdata/conformance/state_time_quantity_seconds.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_time_quantity_seconds.sysml rename to internal/exec/runtime/testdata/conformance/state_time_quantity_seconds.sysml diff --git a/internal/core/runtime/testdata/conformance/state_time_quantity_unit_ordering.expected.json b/internal/exec/runtime/testdata/conformance/state_time_quantity_unit_ordering.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_time_quantity_unit_ordering.expected.json rename to internal/exec/runtime/testdata/conformance/state_time_quantity_unit_ordering.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_time_quantity_unit_ordering.sysml b/internal/exec/runtime/testdata/conformance/state_time_quantity_unit_ordering.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_time_quantity_unit_ordering.sysml rename to internal/exec/runtime/testdata/conformance/state_time_quantity_unit_ordering.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_time_quantity_unit_ordering.trace.golden b/internal/exec/runtime/testdata/conformance/state_time_quantity_unit_ordering.trace.golden new file mode 100644 index 0000000000..f6d07f1fcb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_time_quantity_unit_ordering.trace.golden @@ -0,0 +1,36 @@ +exit: start +enter: Working +choice entering Working: next sstart(entry), fstart(entry) (unordered; took sstart(entry) first) +enter: sstart +enter: fstart +transition: start -> Working +exit: sstart +enter: sWaiting + eval literal 1 -> 1 +eval index -> 1 [min] +transition: sstart -> sWaiting +exit: fstart +enter: fWaiting + eval literal 30 -> 30 +eval index -> 30 [s] +transition: fstart -> fWaiting +exit: fWaiting +enter: fDone (entry action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +transition: fWaiting -> fDone (event: time) +exit: sWaiting +enter: sDone (entry action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +transition: sWaiting -> sDone (event: time) diff --git a/internal/core/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.expected.json b/internal/exec/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.expected.json similarity index 91% rename from internal/core/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.expected.json rename to internal/exec/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.expected.json index b2741b1e10..db3e1e9db5 100644 --- a/internal/core/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.expected.json @@ -1,5 +1,6 @@ { "type": "state", + "schedule": "reverse", "libraries": true, "events": [ {"signal": "pause", "args": null} diff --git a/internal/core/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.sysml b/internal/exec/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.sysml rename to internal/exec/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.trace.golden b/internal/exec/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.trace.golden new file mode 100644 index 0000000000..2df2c6303b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_time_trigger_restarts_on_re_entry.trace.golden @@ -0,0 +1,40 @@ +exit: wstart +enter: waiting + eval literal 10 -> 10 +eval index -> 10 [s] +transition: wstart -> waiting +exit: cstart +enter: c0 + eval literal 12 -> 12 +eval index -> 12 [s] +transition: cstart -> c0 +exit: waiting +enter: away + eval literal 4 -> 4 +eval index -> 4 [s] +transition: waiting -> away (event: accept pause) +exit: away +enter: waiting + eval literal 10 -> 10 +eval index -> 10 [s] +transition: away -> waiting (event: time) +exit: c0 +enter: c1 (entry action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 2 -> 2 + eval operator + -> 2 +transition: c0 -> c1 (event: time) +exit: waiting +enter: late (entry action) +stmt action body + stmt assign log + eval feature log -> 2 + eval literal 10 -> 10 + eval operator * -> 20 + eval literal 1 -> 1 + eval operator + -> 21 +transition: waiting -> late (event: time) diff --git a/internal/core/runtime/testdata/conformance/state_timed_transitions.expected.json b/internal/exec/runtime/testdata/conformance/state_timed_transitions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_timed_transitions.expected.json rename to internal/exec/runtime/testdata/conformance/state_timed_transitions.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_timed_transitions.sysml b/internal/exec/runtime/testdata/conformance/state_timed_transitions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_timed_transitions.sysml rename to internal/exec/runtime/testdata/conformance/state_timed_transitions.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_accept_payload.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_accept_payload.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_accept_payload.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_accept_payload.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_accept_payload.sysml b/internal/exec/runtime/testdata/conformance/state_transition_accept_payload.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_accept_payload.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_accept_payload.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_accept_via_port.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_accept_via_port.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_accept_via_port.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_accept_via_port.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_accept_via_port.sysml b/internal/exec/runtime/testdata/conformance/state_transition_accept_via_port.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_accept_via_port.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_accept_via_port.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_accept_via_port.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_accept_via_port.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_accept_via_port.trace.golden rename to internal/exec/runtime/testdata/conformance/state_transition_accept_via_port.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/state_transition_braced_do_then_accept.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_braced_do_then_accept.expected.json new file mode 100644 index 0000000000..7612adecc5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_braced_do_then_accept.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}, {"signal": "Go"}], + "finalState": "done", + "stateVisits": ["idle", "busy", "done"], + "outputs": { + "log": { "type": "Integer", "value": 12345 } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_braced_do_then_accept.sysml b/internal/exec/runtime/testdata/conformance/state_transition_braced_do_then_accept.sysml new file mode 100644 index 0000000000..771c013da9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_braced_do_then_accept.sysml @@ -0,0 +1,28 @@ +package Test { + // A transition's braced `do { … }` is one anonymous action: its statements + // run in order, to the end, at the instant the transition fires. Each digit + // records one statement; the follow-on transition the next Go triggers runs + // its own block after the first block is over. + private import ScalarValues::*; + + attribute def Go; + + state def Machine { + attribute log : Integer = 0; + + entry; then idle; + state idle; + state busy; + state done; + + transition first idle accept Go do { + assign log := log * 10 + 1; + assign log := log * 10 + 2; + assign log := log * 10 + 3; + } then busy; + transition first busy accept Go do { + assign log := log * 10 + 4; + assign log := log * 10 + 5; + } then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_braced_do_then_accept.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_braced_do_then_accept.trace.golden new file mode 100644 index 0000000000..6fd7da773b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_braced_do_then_accept.trace.golden @@ -0,0 +1,38 @@ +exit: idle +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 + stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 3 -> 3 + eval operator + -> 123 +enter: busy +transition: idle -> busy (event: accept Go) +exit: busy +stmt action body + stmt assign log + eval feature log -> 123 + eval literal 10 -> 10 + eval operator * -> 1230 + eval literal 4 -> 4 + eval operator + -> 1234 + stmt assign log + eval feature log -> 1234 + eval literal 10 -> 10 + eval operator * -> 12340 + eval literal 5 -> 5 + eval operator + -> 12345 +enter: done +transition: busy -> done (event: accept Go) diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_cross_region.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region.sysml b/internal/exec/runtime/testdata/conformance/state_transition_cross_region.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_transition_cross_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_cross_region.trace.golden new file mode 100644 index 0000000000..ffa094df50 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_cross_region.trace.golden @@ -0,0 +1,26 @@ +exit: start +enter: running (entry action) +stmt action body + stmt assign reentered + eval feature reentered -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +choice entering running: next lstart(entry), rstart(entry) (unordered; took lstart(entry) first) +enter: lstart +enter: rstart +transition: start -> running +exit: lstart +enter: lidle +transition: lstart -> lidle +exit: rstart +enter: ridle +transition: rstart -> ridle +exit: lidle +exit: ridle +enter: rtarget (entry action) +stmt action body + stmt assign crossed + eval feature crossed -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +transition: lidle -> rtarget diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region_deep_source.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_cross_region_deep_source.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region_deep_source.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region_deep_source.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region_deep_source.sysml b/internal/exec/runtime/testdata/conformance/state_transition_cross_region_deep_source.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region_deep_source.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region_deep_source.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region_inactive_wrapper.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_cross_region_inactive_wrapper.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region_inactive_wrapper.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region_inactive_wrapper.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region_inactive_wrapper.sysml b/internal/exec/runtime/testdata/conformance/state_transition_cross_region_inactive_wrapper.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region_inactive_wrapper.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region_inactive_wrapper.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region_nested_target.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_cross_region_nested_target.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region_nested_target.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region_nested_target.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region_nested_target.sysml b/internal/exec/runtime/testdata/conformance/state_transition_cross_region_nested_target.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region_nested_target.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region_nested_target.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region_substate_owner.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_cross_region_substate_owner.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region_substate_owner.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region_substate_owner.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region_substate_owner.sysml b/internal/exec/runtime/testdata/conformance/state_transition_cross_region_substate_owner.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region_substate_owner.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region_substate_owner.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region_third_region.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_cross_region_third_region.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region_third_region.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region_third_region.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_cross_region_third_region.sysml b/internal/exec/runtime/testdata/conformance/state_transition_cross_region_third_region.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_cross_region_third_region.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_cross_region_third_region.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_effect.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_effect.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_effect.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_effect.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_effect.sysml b/internal/exec/runtime/testdata/conformance/state_transition_effect.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_effect.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_effect.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_effect_assign.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_effect_assign.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_effect_assign.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_effect_assign.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_effect_assign.sysml b/internal/exec/runtime/testdata/conformance/state_transition_effect_assign.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_effect_assign.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_effect_assign.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_effect_assign.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_effect_assign.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_effect_assign.trace.golden rename to internal/exec/runtime/testdata/conformance/state_transition_effect_assign.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_transition_effect_assign_first_then.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_effect_assign_first_then.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_effect_assign_first_then.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_effect_assign_first_then.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_effect_assign_first_then.sysml b/internal/exec/runtime/testdata/conformance/state_transition_effect_assign_first_then.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_effect_assign_first_then.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_effect_assign_first_then.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_effect_assign_first_then.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_effect_assign_first_then.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_effect_assign_first_then.trace.golden rename to internal/exec/runtime/testdata/conformance/state_transition_effect_assign_first_then.trace.golden diff --git a/internal/core/runtime/testdata/conformance/state_transition_effect_perform.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_effect_perform.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_effect_perform.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_effect_perform.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_effect_perform.sysml b/internal/exec/runtime/testdata/conformance/state_transition_effect_perform.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_effect_perform.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_effect_perform.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_endpoint_qualified.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_endpoint_qualified.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_endpoint_qualified.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_endpoint_qualified.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_endpoint_qualified.sysml b/internal/exec/runtime/testdata/conformance/state_transition_endpoint_qualified.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_endpoint_qualified.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_endpoint_qualified.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.sysml b/internal/exec/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.trace.golden new file mode 100644 index 0000000000..e54c99bbc3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_guard_exit_effect_entry_order.trace.golden @@ -0,0 +1,19 @@ + eval feature x -> 5 + eval literal 0 -> 0 +eval operator > -> true + eval feature x -> 5 + eval literal 0 -> 0 +eval operator > -> true +exit: active (exit action) +stmt action body + stmt assign x + eval literal 0 -> 0 +stmt assign y + eval feature x -> 0 +enter: finished (entry action) +stmt action body + stmt assign z + eval feature y -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +transition: active -> finished (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor.expected.json new file mode 100644 index 0000000000..faa7f153fa --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go", "args": null}, + {"signal": "Up", "args": null} + ], + "finalState": "done", + "stateVisits": ["wait", "outer", "inner", "done"], + "outputs": { + "log": {"type": "String", "value": "outer(entry);inner(entry);inner(exit);up(effect);outer(exit);completion(effect);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor.sysml b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor.sysml new file mode 100644 index 0000000000..b6bf100c85 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor.sysml @@ -0,0 +1,27 @@ +// A transition from a substate into the composite state enclosing it does not +// re-enter the composite, which is already active: the substate is exited, the +// effect runs, and the body the substate left is complete, so the composite's +// completion transition fires. The log pins that `outer` runs neither its +// entry nor its own start again. +package Test { + state TransitionIntoActiveAncestor { + attribute log : String = ""; + entry; then wait; + state wait; + state outer { + entry action { assign log := log + "outer(entry);"; } + exit action { assign log := log + "outer(exit);"; } + entry; then inner; + state inner { + entry action { assign log := log + "inner(entry);"; } + exit action { assign log := log + "inner(exit);"; } + } + transition first inner accept Up do { assign log := log + "up(effect);"; } then outer; + } + + transition first wait accept Go then outer; + transition first outer do { assign log := log + "completion(effect);"; } then done; + } + attribute def Go; + attribute def Up; +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor.trace.golden new file mode 100644 index 0000000000..f5afceabbf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor.trace.golden @@ -0,0 +1,39 @@ +exit: wait +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);" +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);" + eval literal "inner(entry);" -> "inner(entry);" + eval operator + -> "outer(entry);inner(entry);" +transition: wait -> outer (event: accept Go) +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);" + eval literal "inner(exit);" -> "inner(exit);" + eval operator + -> "outer(entry);inner(entry);inner(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);inner(exit);" + eval literal "up(effect);" -> "up(effect);" + eval operator + -> "outer(entry);inner(entry);inner(exit);up(effect);" +transition: inner -> outer (event: accept Up) +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);inner(exit);up(effect);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);inner(entry);inner(exit);up(effect);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);inner(exit);up(effect);outer(exit);" + eval literal "completion(effect);" -> "completion(effect);" + eval operator + -> "outer(entry);inner(entry);inner(exit);up(effect);outer(exit);completion(effect);" +enter: done +transition: outer -> done diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_history.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_history.expected.json new file mode 100644 index 0000000000..bf39afcc70 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_history.expected.json @@ -0,0 +1,15 @@ +{ + "type": "state", + "trace": true, + "events": [ + {"signal": "Go", "args": null}, + {"signal": "Up", "args": null}, + {"signal": "Leave", "args": null}, + {"signal": "Back", "args": null}, + {"signal": "Finish", "args": null} + ], + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);outer(entry);default(effect);start(entry);start(exit);outer(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_history.sysml b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_history.sysml new file mode 100644 index 0000000000..202509317f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_history.sysml @@ -0,0 +1,38 @@ +// A transition from a substate into the composite state enclosing it completes +// the composite's body, so the composite's history keeps no substate: leaving +// the composite afterwards and re-entering it through its history performs the +// history's default transition, not the substate the transition exited. +package Test { + state TransitionIntoActiveAncestorHistory { + attribute log : String = ""; + entry; then outer; + state outer { + entry action { assign log := log + "outer(entry);"; } + exit action { assign log := log + "outer(exit);"; } + history resume; + entry; then start; + state start { + entry action { assign log := log + "start(entry);"; } + exit action { assign log := log + "start(exit);"; } + } + state inner { + entry action { assign log := log + "inner(entry);"; } + exit action { assign log := log + "inner(exit);"; } + } + transition first start accept Go then inner; + transition first inner accept Up do { assign log := log + "up(effect);"; } then outer; + transition first resume do { assign log := log + "default(effect);"; } then start; + } + state away { + entry action { assign log := log + "away(entry);"; } + } + transition first outer accept Leave do { assign log := log + "leave(effect);"; } then away; + transition first away accept Back do { assign log := log + "back(effect);"; } then resume; + transition first outer accept Finish then done; + } + attribute def Go; + attribute def Up; + attribute def Leave; + attribute def Back; + attribute def Finish; +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_history.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_history.trace.golden new file mode 100644 index 0000000000..8c9496c0ce --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_history.trace.golden @@ -0,0 +1,81 @@ +exit: start (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);" + eval literal "start(exit);" -> "start(exit);" + eval operator + -> "outer(entry);start(entry);start(exit);" +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);" + eval literal "inner(entry);" -> "inner(entry);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);" +transition: start -> inner (event: accept Go) +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);" + eval literal "inner(exit);" -> "inner(exit);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);" + eval literal "up(effect);" -> "up(effect);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);" +transition: inner -> outer (event: accept Up) +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);" + eval literal "leave(effect);" -> "leave(effect);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);" +enter: away (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);" + eval literal "away(entry);" -> "away(entry);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);" +transition: outer -> away (event: accept Leave) +exit: away +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);" + eval literal "back(effect);" -> "back(effect);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);" +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);outer(entry);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);outer(entry);" + eval literal "default(effect);" -> "default(effect);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);outer(entry);default(effect);" +enter: start (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);outer(entry);default(effect);" + eval literal "start(entry);" -> "start(entry);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);outer(entry);default(effect);start(entry);" +transition: away -> start (event: accept Back) +exit: start (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);outer(entry);default(effect);start(entry);" + eval literal "start(exit);" -> "start(exit);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);outer(entry);default(effect);start(entry);start(exit);" +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);outer(entry);default(effect);start(entry);start(exit);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);start(entry);start(exit);inner(entry);inner(exit);up(effect);outer(exit);leave(effect);away(entry);back(effect);outer(entry);default(effect);start(entry);start(exit);outer(exit);" +enter: done +transition: start -> done (event: accept Finish) diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_in_region.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_in_region.expected.json new file mode 100644 index 0000000000..7fdcb00b79 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_in_region.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "schedule": "reverse", + "trace": true, + "events": [ + {"signal": "Up", "args": null}, + {"signal": "Leave", "args": null} + ], + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "mid(entry);inner(entry);b(entry);inner(exit);up(effect);mid(exit);completion(effect);leftDone(entry);b(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_in_region.sysml b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_in_region.sysml new file mode 100644 index 0000000000..fe55de590a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_in_region.sysml @@ -0,0 +1,38 @@ +// A substate transitioning into the composite enclosing it, both inside an +// orthogonal region: the composite is not re-entered, its body completes and +// its completion transition fires while the sibling region stays as it was. +package Test { + state TransitionIntoActiveAncestorInRegion { + attribute log : String = ""; + entry; then outer; + state outer parallel { + state left { + entry; then mid; + state mid { + entry { assign log := log + "mid(entry);"; } + exit { assign log := log + "mid(exit);"; } + entry; then inner; + state inner { + entry { assign log := log + "inner(entry);"; } + exit { assign log := log + "inner(exit);"; } + } + transition first inner accept Up do { assign log := log + "up(effect);"; } then mid; + } + state leftDone { + entry { assign log := log + "leftDone(entry);"; } + } + transition first mid do { assign log := log + "completion(effect);"; } then leftDone; + } + state right { + entry; then b; + state b { + entry { assign log := log + "b(entry);"; } + exit { assign log := log + "b(exit);"; } + } + } + } + transition first outer accept Leave then done; + } + attribute def Up; + attribute def Leave; +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_in_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_in_region.trace.golden new file mode 100644 index 0000000000..84b74d801f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_ancestor_in_region.trace.golden @@ -0,0 +1,41 @@ +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "mid(entry);inner(entry);b(entry);" + eval literal "inner(exit);" -> "inner(exit);" + eval operator + -> "mid(entry);inner(entry);b(entry);inner(exit);" +stmt action body + stmt assign log + eval feature log -> "mid(entry);inner(entry);b(entry);inner(exit);" + eval literal "up(effect);" -> "up(effect);" + eval operator + -> "mid(entry);inner(entry);b(entry);inner(exit);up(effect);" +transition: inner -> mid (event: accept Up) +exit: mid (exit action) +stmt action body + stmt assign log + eval feature log -> "mid(entry);inner(entry);b(entry);inner(exit);up(effect);" + eval literal "mid(exit);" -> "mid(exit);" + eval operator + -> "mid(entry);inner(entry);b(entry);inner(exit);up(effect);mid(exit);" +stmt action body + stmt assign log + eval feature log -> "mid(entry);inner(entry);b(entry);inner(exit);up(effect);mid(exit);" + eval literal "completion(effect);" -> "completion(effect);" + eval operator + -> "mid(entry);inner(entry);b(entry);inner(exit);up(effect);mid(exit);completion(effect);" +enter: leftDone (entry action) +stmt action body + stmt assign log + eval feature log -> "mid(entry);inner(entry);b(entry);inner(exit);up(effect);mid(exit);completion(effect);" + eval literal "leftDone(entry);" -> "leftDone(entry);" + eval operator + -> "mid(entry);inner(entry);b(entry);inner(exit);up(effect);mid(exit);completion(effect);leftDone(entry);" +transition: mid -> leftDone +choice exiting outer: next leftDone(exit), b(exit) (unordered; took leftDone(exit) first) +exit: leftDone +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "mid(entry);inner(entry);b(entry);inner(exit);up(effect);mid(exit);completion(effect);leftDone(entry);" + eval literal "b(exit);" -> "b(exit);" + eval operator + -> "mid(entry);inner(entry);b(entry);inner(exit);up(effect);mid(exit);completion(effect);leftDone(entry);b(exit);" +exit: outer +enter: done +transition: outer -> done (event: accept Leave) diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor.expected.json new file mode 100644 index 0000000000..3f6782b936 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "schedule": "reverse", + "trace": true, + "events": [ + {"signal": "Up", "args": null}, + {"signal": "Finish", "args": null} + ], + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);outer(exit);completion(effect);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor.sysml b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor.sysml new file mode 100644 index 0000000000..f13993f9bf --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor.sysml @@ -0,0 +1,33 @@ +// A transition from a state in one region of a parallel state into that +// parallel state leaves its region without re-entering the parallel state, +// which stays active with its other region; the parallel state completes once +// the other region reaches `done`, so its completion transition fires then. +package Test { + state TransitionIntoActiveParallelAncestor { + attribute log : String = ""; + entry; then outer; + state outer parallel { + entry { assign log := log + "outer(entry);"; } + exit { assign log := log + "outer(exit);"; } + state left { + entry; then a; + state a { + entry { assign log := log + "a(entry);"; } + exit { assign log := log + "a(exit);"; } + } + transition first a accept Up do { assign log := log + "up(effect);"; } then outer; + } + state right { + entry; then b; + state b { + entry { assign log := log + "b(entry);"; } + exit { assign log := log + "b(exit);"; } + } + transition first b accept Finish then done; + } + } + transition first outer do { assign log := log + "completion(effect);"; } then done; + } + attribute def Up; + attribute def Finish; +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor.trace.golden new file mode 100644 index 0000000000..267602ad9e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor.trace.golden @@ -0,0 +1,34 @@ +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);" + eval literal "a(exit);" -> "a(exit);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);" + eval literal "up(effect);" -> "up(effect);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);" +transition: a -> outer (event: accept Up) +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);" + eval literal "b(exit);" -> "b(exit);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);" +enter: done +transition: b -> done (event: accept Finish) +exit: done +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);outer(exit);" + eval literal "completion(effect);" -> "completion(effect);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);outer(exit);completion(effect);" +enter: done +transition: outer -> done diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_all_rested.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_all_rested.expected.json new file mode 100644 index 0000000000..2a94c4470e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_all_rested.expected.json @@ -0,0 +1,15 @@ +{ + "type": "state", + "schedule": "reverse", + "trace": true, + "events": [ + {"signal": "Up", "args": null}, + {"signal": "Down", "args": null}, + {"signal": "Leave", "args": null}, + {"signal": "Finish", "args": null} + ], + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);down(effect);outer(exit);leave(effect);later(entry);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_all_rested.sysml b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_all_rested.sysml new file mode 100644 index 0000000000..534c5b7016 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_all_rested.sysml @@ -0,0 +1,38 @@ +// Once a transition from every region into their parallel owner has left each +// region resting at the owner, a triggered transition out of the owner still +// fires: the owner is the active leaf, and it is exited once. +package Test { + state TransitionIntoActiveParallelAncestorAllRested { + attribute log : String = ""; + entry; then outer; + state outer parallel { + entry { assign log := log + "outer(entry);"; } + exit { assign log := log + "outer(exit);"; } + state left { + entry; then a; + state a { + entry { assign log := log + "a(entry);"; } + exit { assign log := log + "a(exit);"; } + } + transition first a accept Up do { assign log := log + "up(effect);"; } then outer; + } + state right { + entry; then b; + state b { + entry { assign log := log + "b(entry);"; } + exit { assign log := log + "b(exit);"; } + } + transition first b accept Down do { assign log := log + "down(effect);"; } then outer; + } + } + state later { + entry { assign log := log + "later(entry);"; } + } + transition first outer accept Leave do { assign log := log + "leave(effect);"; } then later; + transition first later accept Finish then done; + } + attribute def Up; + attribute def Down; + attribute def Leave; + attribute def Finish; +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_all_rested.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_all_rested.trace.golden new file mode 100644 index 0000000000..6715bfddba --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_all_rested.trace.golden @@ -0,0 +1,45 @@ +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);" + eval literal "a(exit);" -> "a(exit);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);" + eval literal "up(effect);" -> "up(effect);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);" +transition: a -> outer (event: accept Up) +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);" + eval literal "b(exit);" -> "b(exit);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);" + eval literal "down(effect);" -> "down(effect);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);down(effect);" +transition: b -> outer (event: accept Down) +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);down(effect);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);down(effect);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);down(effect);outer(exit);" + eval literal "leave(effect);" -> "leave(effect);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);down(effect);outer(exit);leave(effect);" +enter: later (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);down(effect);outer(exit);leave(effect);" + eval literal "later(entry);" -> "later(entry);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);down(effect);outer(exit);leave(effect);later(entry);" +transition: outer -> later (event: accept Leave) +exit: later +enter: done +transition: later -> done (event: accept Finish) diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_then_out.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_then_out.expected.json new file mode 100644 index 0000000000..7ec0d7de30 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_then_out.expected.json @@ -0,0 +1,14 @@ +{ + "type": "state", + "schedule": "reverse", + "trace": true, + "events": [ + {"signal": "Up", "args": null}, + {"signal": "Leave", "args": null}, + {"signal": "Finish", "args": null} + ], + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);outer(exit);leave(effect);after(entry);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_then_out.sysml b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_then_out.sysml new file mode 100644 index 0000000000..e2aa5b48c6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_then_out.sysml @@ -0,0 +1,36 @@ +// After a transition from one region into its parallel owner left that region +// resting, a triggered transition out of the owner exits the other region's +// active state and the owner once each, and the rested region nothing. +package Test { + state TransitionIntoActiveParallelAncestorThenOut { + attribute log : String = ""; + entry; then outer; + state outer parallel { + entry { assign log := log + "outer(entry);"; } + exit { assign log := log + "outer(exit);"; } + state left { + entry; then a; + state a { + entry { assign log := log + "a(entry);"; } + exit { assign log := log + "a(exit);"; } + } + transition first a accept Up do { assign log := log + "up(effect);"; } then outer; + } + state right { + entry; then b; + state b { + entry { assign log := log + "b(entry);"; } + exit { assign log := log + "b(exit);"; } + } + } + } + state after { + entry { assign log := log + "after(entry);"; } + } + transition first outer accept Leave do { assign log := log + "leave(effect);"; } then after; + transition first after accept Finish then done; + } + attribute def Up; + attribute def Leave; + attribute def Finish; +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_then_out.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_then_out.trace.golden new file mode 100644 index 0000000000..90fe0dddd6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_ancestor_then_out.trace.golden @@ -0,0 +1,39 @@ +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);" + eval literal "a(exit);" -> "a(exit);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);" + eval literal "up(effect);" -> "up(effect);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);" +transition: a -> outer (event: accept Up) +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);" + eval literal "b(exit);" -> "b(exit);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);" +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);outer(exit);" + eval literal "leave(effect);" -> "leave(effect);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);outer(exit);leave(effect);" +enter: after (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);outer(exit);leave(effect);" + eval literal "after(entry);" -> "after(entry);" + eval operator + -> "outer(entry);a(entry);b(entry);a(exit);up(effect);b(exit);outer(exit);leave(effect);after(entry);" +transition: outer -> after (event: accept Leave) +exit: after +enter: done +transition: after -> done (event: accept Finish) diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent.expected.json new file mode 100644 index 0000000000..007b1c7f4a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent.expected.json @@ -0,0 +1,13 @@ +{ + "type": "state", + "schedule": "reverse", + "trace": true, + "events": [ + {"signal": "Up", "args": null}, + {"signal": "Finish", "args": null} + ], + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);inner(exit);up(effect);b(exit);outer(exit);completion(effect);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent.sysml b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent.sysml new file mode 100644 index 0000000000..0ef727b8e5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent.sysml @@ -0,0 +1,48 @@ +// A transition from a state nested in a parallel state inside one region of an +// outer parallel state into the outer parallel state exits the inner parallel +// state and leaves the outer region resting at the outer state; the outer state +// completes once its other region reaches `done`, so its completion transition +// fires then. +package Test { + state TransitionIntoActiveParallelGrandparent { + attribute log : String = ""; + entry; then outer; + state outer parallel { + entry { assign log := log + "outer(entry);"; } + exit { assign log := log + "outer(exit);"; } + state left { + entry; then inner; + state inner parallel { + entry { assign log := log + "inner(entry);"; } + exit { assign log := log + "inner(exit);"; } + state p { + entry; then a; + state a { + entry { assign log := log + "a(entry);"; } + exit { assign log := log + "a(exit);"; } + } + transition first a accept Up do { assign log := log + "up(effect);"; } then outer; + } + state q { + entry; then c; + state c { + entry { assign log := log + "c(entry);"; } + exit { assign log := log + "c(exit);"; } + } + } + } + } + state right { + entry; then b; + state b { + entry { assign log := log + "b(entry);"; } + exit { assign log := log + "b(exit);"; } + } + transition first b accept Finish then done; + } + } + transition first outer do { assign log := log + "completion(effect);"; } then done; + } + attribute def Up; + attribute def Finish; +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent.trace.golden new file mode 100644 index 0000000000..e8467b483e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent.trace.golden @@ -0,0 +1,46 @@ +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);" + eval literal "a(exit);" -> "a(exit);" + eval operator + -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);" +exit: c (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);" + eval literal "c(exit);" -> "c(exit);" + eval operator + -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);" +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);" + eval literal "inner(exit);" -> "inner(exit);" + eval operator + -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);inner(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);inner(exit);" + eval literal "up(effect);" -> "up(effect);" + eval operator + -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);inner(exit);up(effect);" +transition: a -> outer (event: accept Up) +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);inner(exit);up(effect);" + eval literal "b(exit);" -> "b(exit);" + eval operator + -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);inner(exit);up(effect);b(exit);" +enter: done +transition: b -> done (event: accept Finish) +exit: done +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);inner(exit);up(effect);b(exit);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);inner(exit);up(effect);b(exit);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);inner(exit);up(effect);b(exit);outer(exit);" + eval literal "completion(effect);" -> "completion(effect);" + eval operator + -> "outer(entry);inner(entry);a(entry);c(entry);b(entry);a(exit);c(exit);inner(exit);up(effect);b(exit);outer(exit);completion(effect);" +enter: done +transition: outer -> done diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent_history.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent_history.expected.json new file mode 100644 index 0000000000..04d4e5000c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent_history.expected.json @@ -0,0 +1,17 @@ +{ + "type": "state", + "schedule": "reverse", + "trace": true, + "events": [ + {"signal": "Go", "args": null}, + {"signal": "Next", "args": null}, + {"signal": "Up", "args": null}, + {"signal": "Leave", "args": null}, + {"signal": "Back", "args": null}, + {"signal": "Finish", "args": null} + ], + "finalState": "done", + "outputs": { + "log": {"type": "String", "value": "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);outer(entry);idle(entry);d(entry);d(exit);outer(exit);"} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent_history.sysml b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent_history.sysml new file mode 100644 index 0000000000..c298420d83 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent_history.sysml @@ -0,0 +1,66 @@ +// A transition from inside a parallel state nested in one region of an outer +// parallel state into the outer state completes that region, so the owner's +// history keeps no configuration for it: re-entering the owner through its +// history starts the region at its initial state, not at the nested state the +// transition exited, while the other region restores the state it was left in. +package Test { + state TransitionIntoActiveParallelGrandparentHistory { + attribute log : String = ""; + entry; then outer; + state outer parallel { + history resume; + entry { assign log := log + "outer(entry);"; } + exit { assign log := log + "outer(exit);"; } + state left { + entry; then idle; + state idle { + entry { assign log := log + "idle(entry);"; } + } + transition first idle accept Go then inner; + state inner parallel { + entry { assign log := log + "inner(entry);"; } + exit { assign log := log + "inner(exit);"; } + state p { + entry; then a; + state a { + entry { assign log := log + "a(entry);"; } + exit { assign log := log + "a(exit);"; } + } + transition first a accept Up do { assign log := log + "up(effect);"; } then outer; + } + state q { + entry; then c; + state c { + entry { assign log := log + "c(entry);"; } + exit { assign log := log + "c(exit);"; } + } + } + } + } + state right { + entry; then b; + state b { + entry { assign log := log + "b(entry);"; } + exit { assign log := log + "b(exit);"; } + } + transition first b accept Next then d; + state d { + entry { assign log := log + "d(entry);"; } + exit { assign log := log + "d(exit);"; } + } + } + } + state away { + entry { assign log := log + "away(entry);"; } + } + transition first outer accept Leave do { assign log := log + "leave(effect);"; } then away; + transition first away accept Back do { assign log := log + "back(effect);"; } then resume; + transition first outer accept Finish then done; + } + attribute def Go; + attribute def Up; + attribute def Next; + attribute def Leave; + attribute def Back; + attribute def Finish; +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent_history.trace.golden b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent_history.trace.golden new file mode 100644 index 0000000000..625ea952ec --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_into_active_parallel_grandparent_history.trace.golden @@ -0,0 +1,124 @@ +exit: idle +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);" + eval literal "inner(entry);" -> "inner(entry);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);" +choice entering inner: next a(entry), c(entry) (unordered; took a(entry) first) +enter: a (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);" + eval literal "a(entry);" -> "a(entry);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);" +enter: c (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);" + eval literal "c(entry);" -> "c(entry);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);" +transition: idle -> inner (event: accept Go) +exit: b (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);" + eval literal "b(exit);" -> "b(exit);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);" +enter: d (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);" + eval literal "d(entry);" -> "d(entry);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);" +transition: b -> d (event: accept Next) +exit: a (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);" + eval literal "a(exit);" -> "a(exit);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);" +exit: c (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);" + eval literal "c(exit);" -> "c(exit);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);" +exit: inner (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);" + eval literal "inner(exit);" -> "inner(exit);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);" + eval literal "up(effect);" -> "up(effect);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);" +transition: a -> outer (event: accept Up) +exit: d (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);" + eval literal "d(exit);" -> "d(exit);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);" +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);" +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);" + eval literal "leave(effect);" -> "leave(effect);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);" +enter: away (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);" + eval literal "away(entry);" -> "away(entry);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);" +transition: outer -> away (event: accept Leave) +exit: away +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);" + eval literal "back(effect);" -> "back(effect);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);" +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);" + eval literal "outer(entry);" -> "outer(entry);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);outer(entry);" +choice entering outer: next idle(entry), d(entry) (unordered; took idle(entry) first) +enter: idle (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);outer(entry);" + eval literal "idle(entry);" -> "idle(entry);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);outer(entry);idle(entry);" +enter: d (entry action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);outer(entry);idle(entry);" + eval literal "d(entry);" -> "d(entry);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);outer(entry);idle(entry);d(entry);" +transition: away -> outer (event: accept Back) +choice exiting outer: next idle(exit), d(exit) (unordered; took idle(exit) first) +exit: idle +exit: d (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);outer(entry);idle(entry);d(entry);" + eval literal "d(exit);" -> "d(exit);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);outer(entry);idle(entry);d(entry);d(exit);" +exit: outer (exit action) +stmt action body + stmt assign log + eval feature log -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);outer(entry);idle(entry);d(entry);d(exit);" + eval literal "outer(exit);" -> "outer(exit);" + eval operator + -> "outer(entry);idle(entry);b(entry);inner(entry);a(entry);c(entry);b(exit);d(entry);a(exit);c(exit);inner(exit);up(effect);d(exit);outer(exit);leave(effect);away(entry);back(effect);outer(entry);idle(entry);d(entry);d(exit);outer(exit);" +enter: done +transition: outer -> done (event: accept Finish) diff --git a/internal/core/runtime/testdata/conformance/state_transition_into_inherited_substate.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_into_inherited_substate.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_into_inherited_substate.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_into_inherited_substate.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_into_inherited_substate.sysml b/internal/exec/runtime/testdata/conformance/state_transition_into_inherited_substate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_into_inherited_substate.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_into_inherited_substate.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_leave_composite_substate_region.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_leave_composite_substate_region.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_leave_composite_substate_region.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_leave_composite_substate_region.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_leave_composite_substate_region.sysml b/internal/exec/runtime/testdata/conformance/state_transition_leave_composite_substate_region.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_leave_composite_substate_region.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_leave_composite_substate_region.sysml diff --git a/internal/core/runtime/testdata/conformance/state_transition_sibling_region.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_sibling_region.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_sibling_region.expected.json rename to internal/exec/runtime/testdata/conformance/state_transition_sibling_region.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_transition_sibling_region.sysml b/internal/exec/runtime/testdata/conformance/state_transition_sibling_region.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_transition_sibling_region.sysml rename to internal/exec/runtime/testdata/conformance/state_transition_sibling_region.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_transition_via_bound_reference.expected.json b/internal/exec/runtime/testdata/conformance/state_transition_via_bound_reference.expected.json new file mode 100644 index 0000000000..a3b6817df5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_via_bound_reference.expected.json @@ -0,0 +1,24 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "StateViaBoundReference::Rig", + "materialization": {}, + "objects": [ + { + "path": "sender", + "events": [{"signal": "Go"}], + "finalState": "sent", + "stateVisits": ["idle", "toLiveStep", "toPartStep", "sent"] + }, + { + "path": "commander", + "finalState": "bound", + "stateVisits": ["waiting", "bound"] + }, + { + "path": "owner", + "finalState": "own", + "stateVisits": ["waiting", "own"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/state_transition_via_bound_reference.sysml b/internal/exec/runtime/testdata/conformance/state_transition_via_bound_reference.sysml new file mode 100644 index 0000000000..a62a8e33d7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_transition_via_bound_reference.sysml @@ -0,0 +1,84 @@ +// A transition's `via context.hears` names the port of whatever object the machine's +// reference parameter `context` is bound to, shadowing the performer's part of the +// same name as it does in any expression; `via this.context.hears` roots the path at +// the performer whatever the parameter holds. Each machine therefore fires on the +// acknowledgement crossing its own port and leaves the other pending. +package StateViaBoundReference { + item def Go; + item def Ack; + + port def AckOut { + out item ack : Ack; + } + + port def AckIn { + in item ack : Ack; + } + + part def Peer { + port hears : AckIn; + } + + part def Commander { + part context : Peer; + ref part partner : Peer; + + state def Life { + in ref context : Peer; + entry; then waiting; + state waiting; + transition first waiting accept Ack via context.hears then bound; + state bound; + } + + exhibit state life : Life { in :>> context = partner; } + } + + part def Owner { + part context : Peer; + ref part partner : Peer; + + state def Watch { + in ref context : Peer; + entry; then waiting; + state waiting; + transition first waiting accept Ack via this.context.hears then own; + state own; + } + + exhibit state watch : Watch { in :>> context = partner; } + } + + part def Sender { + port toLive : AckOut; + port toOwnerPart : AckOut; + + exhibit state life { + entry; then idle; + state idle; + transition first idle accept Go then toLiveStep; + state toLiveStep { + entry send new Ack() via toLive; + } + transition first toLiveStep then toPartStep; + state toPartStep { + entry send new Ack() via toOwnerPart; + } + transition first toPartStep then sent; + state sent; + } + } + + part def Rig { + part commander : Commander { + :>> partner = live; + } + part owner : Owner { + :>> partner = live; + } + part live : Peer; + part sender : Sender; + connect sender.toLive to live.hears; + connect sender.toOwnerPart to owner.context.hears; + } +} diff --git a/internal/core/runtime/testdata/conformance/state_typed_parallel_regions.expected.json b/internal/exec/runtime/testdata/conformance/state_typed_parallel_regions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_typed_parallel_regions.expected.json rename to internal/exec/runtime/testdata/conformance/state_typed_parallel_regions.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_typed_parallel_regions.sysml b/internal/exec/runtime/testdata/conformance/state_typed_parallel_regions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_typed_parallel_regions.sysml rename to internal/exec/runtime/testdata/conformance/state_typed_parallel_regions.sysml diff --git a/internal/core/runtime/testdata/conformance/state_typed_region_order.expected.json b/internal/exec/runtime/testdata/conformance/state_typed_region_order.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_typed_region_order.expected.json rename to internal/exec/runtime/testdata/conformance/state_typed_region_order.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_typed_region_order.sysml b/internal/exec/runtime/testdata/conformance/state_typed_region_order.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_typed_region_order.sysml rename to internal/exec/runtime/testdata/conformance/state_typed_region_order.sysml diff --git a/internal/exec/runtime/testdata/conformance/state_typed_region_order.trace.golden b/internal/exec/runtime/testdata/conformance/state_typed_region_order.trace.golden new file mode 100644 index 0000000000..c1c2779332 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_typed_region_order.trace.golden @@ -0,0 +1,19 @@ +exit: start +enter: running +choice entering running: next a.r1(entry), b.r1(entry) (unordered; took a.r1(entry) first) +enter: r1 +enter: r1 +transition: start -> running +choice on accept Step: next a.r1(exit), b.r1(exit) (unordered; took a.r1(exit) first) +exit: r1 +enter: r2 +transition: r1 -> r2 (event: accept Step) +exit: r1 +enter: r2 +transition: r1 -> r2 (event: accept Step) +choice exiting running: next a.r2(exit), b.r2(exit) (unordered; took a.r2(exit) first) +exit: r2 +exit: r2 +exit: running +enter: done +transition: running -> done (event: accept Abort) diff --git a/internal/core/runtime/testdata/conformance/state_undeferred_event.expected.json b/internal/exec/runtime/testdata/conformance/state_undeferred_event.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_undeferred_event.expected.json rename to internal/exec/runtime/testdata/conformance/state_undeferred_event.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_undeferred_event.sysml b/internal/exec/runtime/testdata/conformance/state_undeferred_event.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_undeferred_event.sysml rename to internal/exec/runtime/testdata/conformance/state_undeferred_event.sysml diff --git a/internal/core/runtime/testdata/conformance/state_usage_body_redefines.expected.json b/internal/exec/runtime/testdata/conformance/state_usage_body_redefines.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_usage_body_redefines.expected.json rename to internal/exec/runtime/testdata/conformance/state_usage_body_redefines.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_usage_body_redefines.sysml b/internal/exec/runtime/testdata/conformance/state_usage_body_redefines.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_usage_body_redefines.sysml rename to internal/exec/runtime/testdata/conformance/state_usage_body_redefines.sysml diff --git a/internal/core/runtime/testdata/conformance/state_usage_inheritance_two_levels.expected.json b/internal/exec/runtime/testdata/conformance/state_usage_inheritance_two_levels.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_usage_inheritance_two_levels.expected.json rename to internal/exec/runtime/testdata/conformance/state_usage_inheritance_two_levels.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_usage_inheritance_two_levels.sysml b/internal/exec/runtime/testdata/conformance/state_usage_inheritance_two_levels.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_usage_inheritance_two_levels.sysml rename to internal/exec/runtime/testdata/conformance/state_usage_inheritance_two_levels.sysml diff --git a/internal/core/runtime/testdata/conformance/state_usage_inherits_definition.expected.json b/internal/exec/runtime/testdata/conformance/state_usage_inherits_definition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_usage_inherits_definition.expected.json rename to internal/exec/runtime/testdata/conformance/state_usage_inherits_definition.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_usage_inherits_definition.sysml b/internal/exec/runtime/testdata/conformance/state_usage_inherits_definition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_usage_inherits_definition.sysml rename to internal/exec/runtime/testdata/conformance/state_usage_inherits_definition.sysml diff --git a/internal/core/runtime/testdata/conformance/state_usage_redeclares_substate.expected.json b/internal/exec/runtime/testdata/conformance/state_usage_redeclares_substate.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_usage_redeclares_substate.expected.json rename to internal/exec/runtime/testdata/conformance/state_usage_redeclares_substate.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_usage_redeclares_substate.sysml b/internal/exec/runtime/testdata/conformance/state_usage_redeclares_substate.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_usage_redeclares_substate.sysml rename to internal/exec/runtime/testdata/conformance/state_usage_redeclares_substate.sysml diff --git a/internal/core/runtime/testdata/conformance/state_usages_independent.expected.json b/internal/exec/runtime/testdata/conformance/state_usages_independent.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/state_usages_independent.expected.json rename to internal/exec/runtime/testdata/conformance/state_usages_independent.expected.json diff --git a/internal/core/runtime/testdata/conformance/state_usages_independent.sysml b/internal/exec/runtime/testdata/conformance/state_usages_independent.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/state_usages_independent.sysml rename to internal/exec/runtime/testdata/conformance/state_usages_independent.sysml diff --git a/internal/exec/runtime/testdata/conformance/stochastic_draws_average.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_draws_average.expected.json new file mode 100644 index 0000000000..458d17c7a3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_draws_average.expected.json @@ -0,0 +1,11 @@ +{ + "type": "action", + "libraries": true, + "draws": "average", + "outputs": { + "d": {"type": "Real", "value": 4.5}, + "n": {"type": "Integer", "value": 4}, + "t": {"type": "Real", "value": 5.0}, + "g": {"type": "Real", "value": 12.0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_draws_average.sysml b/internal/exec/runtime/testdata/conformance/stochastic_draws_average.sysml new file mode 100644 index 0000000000..8f703a7063 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_draws_average.sysml @@ -0,0 +1,18 @@ +// Every RandomFunctions call resolved at its mean under the average draw policy, +// with no seed set: the midpoint of a uniform, the mean (lo + mode + hi) / 3 of a +// triangular, the mean of a normal, and the midpoint of a uniformInteger rounded +// with a half rounded toward hi. +package test { + private import ScalarValues::*; + private import RandomFunctions::*; + + action mean { + attribute d : Real = uniform(2.0, 7.0); + attribute n : Integer = uniformInteger(1, 6); + attribute t : Real = triangular(1.0, 4.0, 10.0); + attribute g : Real = normal(12.0, 3.0); + + first start; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_draws_max.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_draws_max.expected.json new file mode 100644 index 0000000000..833b4e23a1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_draws_max.expected.json @@ -0,0 +1,11 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "draws": "max", + "outputs": { + "d": {"type": "Real", "value": 1.0}, + "n": {"type": "Integer", "value": 6}, + "t": {"type": "Real", "value": 10.0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_draws_max.sysml b/internal/exec/runtime/testdata/conformance/stochastic_draws_max.sysml new file mode 100644 index 0000000000..b25c99323d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_draws_max.sysml @@ -0,0 +1,20 @@ +// Every RandomFunctions call resolved at its max under the max draw policy: a +// uniform at its upper bound, a uniformInteger at its greatest value, a triangular +// at its upper bound, and a timer drawn from uniform(1, 80) [s] waits 80 [s]. No +// seed is set: a fixed policy draws without one. +package test { + private import ScalarValues::*; + private import ISQ::*; + private import SI::*; + private import RandomFunctions::*; + + action wait { + attribute d : Real = uniform(0.0, 1.0); + attribute n : Integer = uniformInteger(1, 6); + attribute t : Real = triangular(1.0, 4.0, 10.0); + + first start; + then action wait accept after uniform(1, 80) [s]; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_draws_max.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_draws_max.trace.golden new file mode 100644 index 0000000000..f2c117de17 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_draws_max.trace.golden @@ -0,0 +1,12 @@ +step 1: token 1@wait + eval literal 1 -> 1 + eval literal 80 -> 80 + enter calc RandomFunctions::uniform + bind lo = 1 [argument] + bind hi = 80 [argument] +draw uniform(1, 80) = 80.0 + exit calc RandomFunctions::uniform -> 80.0 + eval invoke uniform -> 80.0 +eval index -> 80.0 [s] +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/stochastic_draws_max_normal_unbounded.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_draws_max_normal_unbounded.expected.json new file mode 100644 index 0000000000..575900b3b7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_draws_max_normal_unbounded.expected.json @@ -0,0 +1,6 @@ +{ + "type": "action", + "libraries": true, + "draws": "max", + "error": "under max: the distribution is unbounded" +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_draws_max_normal_unbounded.sysml b/internal/exec/runtime/testdata/conformance/stochastic_draws_max_normal_unbounded.sysml new file mode 100644 index 0000000000..03808d172a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_draws_max_normal_unbounded.sysml @@ -0,0 +1,13 @@ +// A normal has no greatest value: a run that calls it under the max draw policy +// stops with an error naming the call and the policy. +package test { + private import ScalarValues::*; + private import RandomFunctions::*; + + action unbounded { + attribute g : Real = normal(12.0, 3.0); + + first start; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_draws_min.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_draws_min.expected.json new file mode 100644 index 0000000000..077ab5b571 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_draws_min.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "draws": "min", + "outputs": { + "d": {"type": "Real", "value": 2.5}, + "n": {"type": "Integer", "value": 3}, + "t": {"type": "Real", "value": 1.0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_draws_min.sysml b/internal/exec/runtime/testdata/conformance/stochastic_draws_min.sysml new file mode 100644 index 0000000000..4afba78334 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_draws_min.sysml @@ -0,0 +1,16 @@ +// Every RandomFunctions call resolved at its min under the min draw policy, with +// no seed set: the lower bound of a uniform and a triangular, the least value of +// a uniformInteger. +package test { + private import ScalarValues::*; + private import RandomFunctions::*; + + action least { + attribute d : Real = uniform(2.5, 7.5); + attribute n : Integer = uniformInteger(3, 9); + attribute t : Real = triangular(1.0, 4.0, 10.0); + + first start; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_most_probable_unseeded.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_most_probable_unseeded.expected.json new file mode 100644 index 0000000000..f8d29e9b2e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_most_probable_unseeded.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "schedule": "declared", + "outputs": { + "taken": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_most_probable_unseeded.sysml b/internal/exec/runtime/testdata/conformance/stochastic_most_probable_unseeded.sysml new file mode 100644 index 0000000000..21c05e0eef --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_most_probable_unseeded.sysml @@ -0,0 +1,21 @@ +package test { + private import ScalarValues::*; + private import Stochastic::*; + + // The same weighted decision under a policy that draws nothing: the run takes + // the most probable branch, so an unseeded run stays deterministic. + action route { + attribute taken : Integer = 0; + + first start; + then decide select; + first select then slow { @Probability { p = 0.3; } } + first select then fast { @Probability { p = 0.7; } } + + action fast { assign taken := 1; } + then done; + + action slow { assign taken := 2; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_most_probable_unseeded.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_most_probable_unseeded.trace.golden new file mode 100644 index 0000000000..8c0489d51e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_most_probable_unseeded.trace.golden @@ -0,0 +1,9 @@ +step 1: token 1@select +eval literal 0.3 -> 0.3 +eval literal 0.7 -> 0.7 +choice step 2: decision select branches 1->slow p=0.3, 2->fast p=0.7 hold (weighted; took 2->fast) +step 2: token 1@fast +stmt assign taken + eval literal 1 -> 1 +step 3: token 1@done +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/stochastic_random_duration.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_random_duration.expected.json new file mode 100644 index 0000000000..91be281572 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_random_duration.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "modelSeed": 7, + "outputs": { + "d": {"type": "Real", "value": 0.774817894359002}, + "n": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_random_duration.sysml b/internal/exec/runtime/testdata/conformance/stochastic_random_duration.sysml new file mode 100644 index 0000000000..692679696a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_random_duration.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + private import ISQ::*; + private import SI::*; + private import RandomFunctions::*; + + // Random values and a random duration under a fixed model seed: each call draws + // once from the modeled stream, and the wait draws its duration when it parks. + action wait { + attribute d : Real = uniform(0.0, 1.0); + attribute n : Integer = uniformInteger(1, 6); + + first start; + then accept after uniform(1, 80) [s]; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_random_duration.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_random_duration.trace.golden new file mode 100644 index 0000000000..2e2bfe1983 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_random_duration.trace.golden @@ -0,0 +1,12 @@ +step 1: token 1@usage_action + eval literal 1 -> 1 + eval literal 80 -> 80 + enter calc RandomFunctions::uniform + bind lo = 1 [argument] + bind hi = 80 [argument] +draw uniform(1, 80) = 47.88520359371734 + exit calc RandomFunctions::uniform -> 47.88520359371734 + eval invoke uniform -> 47.88520359371734 +eval index -> 47.88520359371734 [s] +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/stochastic_random_timer_order.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_random_timer_order.expected.json new file mode 100644 index 0000000000..c9a26a71fc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_random_timer_order.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "modelSeed": 7, + "finalState": "fDone+rDone", + "outputs": { + "log": {"type": "Integer", "value": 12} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_random_timer_order.sysml b/internal/exec/runtime/testdata/conformance/stochastic_random_timer_order.sysml new file mode 100644 index 0000000000..5e4e648af0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_random_timer_order.sysml @@ -0,0 +1,41 @@ +// Two timers in orthogonal regions, one fixed at 30 [s] and one drawn from +// uniform(1, 80) [s] when its state is entered: the drawn duration orders +// them, and a fixed model seed fixes the draw. +package test { + private import SI::*; + private import RandomFunctions::*; + + state Machine { + attribute log : Integer = 0; + + entry; then start; + state start; + state Working parallel { + state random { + entry; then rstart; + state rstart; + state rWaiting; + accept after uniform(1, 80) [s] then rDone; + state rDone { + entry { assign log := log * 10 + 2; } + } + + transition first rstart then rWaiting; + } + + state fixed { + entry; then fstart; + state fstart; + state fWaiting; + accept after 30 [s] then fDone; + state fDone { + entry { assign log := log * 10 + 1; } + } + + transition first fstart then fWaiting; + } + } + + succession first start then Working; + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_random_timer_order.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_random_timer_order.trace.golden new file mode 100644 index 0000000000..ba67e98f96 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_random_timer_order.trace.golden @@ -0,0 +1,43 @@ +exit: start +enter: Working +choice entering Working: next rstart(entry), fstart(entry) (unordered; took rstart(entry) first) +enter: rstart +enter: fstart +transition: start -> Working +exit: rstart +enter: rWaiting + eval literal 1 -> 1 + eval literal 80 -> 80 + enter calc RandomFunctions::uniform + bind lo = 1 [argument] + bind hi = 80 [argument] +draw uniform(1, 80) = 62.210613654361154 + exit calc RandomFunctions::uniform -> 62.210613654361154 + eval invoke uniform -> 62.210613654361154 +eval index -> 62.210613654361154 [s] +transition: rstart -> rWaiting +exit: fstart +enter: fWaiting + eval literal 30 -> 30 +eval index -> 30 [s] +transition: fstart -> fWaiting +exit: fWaiting +enter: fDone (entry action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +transition: fWaiting -> fDone (event: time) +exit: rWaiting +enter: rDone (entry action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +transition: rWaiting -> rDone (event: time) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.check.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.check.expected.json new file mode 100644 index 0000000000..bf585b72de --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.check.expected.json @@ -0,0 +1 @@ +{"verdict": "divergent", "divergent": {"finalState": ["fast", "slow"], "log": ["1.0", "2.0"]}} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.declared.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.declared.trace.golden new file mode 100644 index 0000000000..9ae636ed6b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.declared.trace.golden @@ -0,0 +1,15 @@ +eval feature priority -> 0.25 + eval literal 1.0 -> 1.0 + eval feature priority -> 0.25 +eval operator - -> 0.75 +choice state idle on call route: transitions 1->slow p=0.25, 2->fast p=0.75 (weighted; drew 0.774817894359002, took 2->fast) +exit: idle +enter: fast (entry action) +stmt action body + stmt assign log + eval literal 10.0 -> 10.0 + eval feature log -> 0.0 + eval operator * -> 0.0 + eval literal 2.0 -> 2.0 + eval operator + -> 2.0 +transition: idle -> fast (event: call route) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.expected.json new file mode 100644 index 0000000000..828a8f233b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "modelSeed": 7, + "events": [{"call": "route", "args": {"priority": {"type": "Real", "value": 0.25}}}], + "outcomes": [ + {"finalState": "slow", "outputs": {"log": {"type": "Real", "value": 1.0}}, "probability": 0.25}, + {"finalState": "fast", "outputs": {"log": {"type": "Real", "value": 2.0}}, "probability": 0.75} + ], + "admissible": "Two transitions out of one state enabled by one event: exactly one fires, which one is open" +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.seed-1.trace.golden new file mode 100644 index 0000000000..9ae636ed6b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.seed-1.trace.golden @@ -0,0 +1,15 @@ +eval feature priority -> 0.25 + eval literal 1.0 -> 1.0 + eval feature priority -> 0.25 +eval operator - -> 0.75 +choice state idle on call route: transitions 1->slow p=0.25, 2->fast p=0.75 (weighted; drew 0.774817894359002, took 2->fast) +exit: idle +enter: fast (entry action) +stmt action body + stmt assign log + eval literal 10.0 -> 10.0 + eval feature log -> 0.0 + eval operator * -> 0.0 + eval literal 2.0 -> 2.0 + eval operator + -> 2.0 +transition: idle -> fast (event: call route) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.sysml b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.sysml new file mode 100644 index 0000000000..678b888684 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.sysml @@ -0,0 +1,19 @@ +// Two transitions out of idle wait on the same call, weighted by the priority +// the invocation carries: the draw reads the trigger's bound argument. +package test { + private import ScalarValues::*; + private import Stochastic::*; + state def Machine { + attribute log : Real = 0.0; + entry; then idle; + state idle; + state slow { + entry { assign log := 10.0 * log + 1.0; } + } + state fast { + entry { assign log := 10.0 * log + 2.0; } + } + transition first idle accept route(priority) then slow { @Probability { p = priority; } } + transition first idle accept route(priority) then fast { @Probability { p = 1.0 - priority; } } + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.trace.golden new file mode 100644 index 0000000000..9ae636ed6b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_call_trigger.trace.golden @@ -0,0 +1,15 @@ +eval feature priority -> 0.25 + eval literal 1.0 -> 1.0 + eval feature priority -> 0.25 +eval operator - -> 0.75 +choice state idle on call route: transitions 1->slow p=0.25, 2->fast p=0.75 (weighted; drew 0.774817894359002, took 2->fast) +exit: idle +enter: fast (entry action) +stmt action body + stmt assign log + eval literal 10.0 -> 10.0 + eval feature log -> 0.0 + eval operator * -> 0.0 + eval literal 2.0 -> 2.0 + eval operator + -> 2.0 +transition: idle -> fast (event: call route) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_case_steps.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_case_steps.expected.json new file mode 100644 index 0000000000..7ae27584eb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_case_steps.expected.json @@ -0,0 +1,11 @@ +{ + "libraries": true, + "type": "verification", + "evaluate": "test::plan", + "schedule": "declared", + "verdict": "inconclusive", + "verdictDetail": "no VerdictKind value", + "subcases": { + "test::plan::checkZeroed": "pass" + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_case_steps.sysml b/internal/exec/runtime/testdata/conformance/stochastic_weighted_case_steps.sysml new file mode 100644 index 0000000000..4e56795b7f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_case_steps.sysml @@ -0,0 +1,35 @@ +// A verification case whose flow weights the decision among its subcases: the +// weights are read as an action's are, so unseeded under `declared` the most +// probable branch is performed; read unweighted, the last unguarded succession +// would be taken as the else branch instead. +package test { + private import ScalarValues::*; + private import Stochastic::*; + + part def Sensor { + attribute reading : Integer default = 0; + } + part zeroed : Sensor; + part drifted : Sensor { + attribute :>> reading = 4; + } + + verification def ZeroCheck { + subject sensor : Sensor; + VerificationCases::PassIf(sensor.reading == 0) + } + + verification plan { + subject sensor = zeroed; + action start; + then decide route; + first route then checkZeroed { @Probability { p = 0.8; } } + first route then checkDrifted { @Probability { p = 0.2; } } + verification checkZeroed : ZeroCheck { + subject sensor = test::zeroed; + } + verification checkDrifted : ZeroCheck { + subject sensor = test::drifted; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_choice_pseudostate.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_choice_pseudostate.expected.json new file mode 100644 index 0000000000..7dd5919f2e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_choice_pseudostate.expected.json @@ -0,0 +1,7 @@ +{ + "type": "state", + "libraries": true, + "modelSeed": 7, + "events": [{"signal": "go"}], + "finalState": "right" +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_choice_pseudostate.sysml b/internal/exec/runtime/testdata/conformance/stochastic_weighted_choice_pseudostate.sysml new file mode 100644 index 0000000000..30fed3e0e0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_choice_pseudostate.sysml @@ -0,0 +1,21 @@ +// The branches out of a choice pseudostate are weighted too: once the incoming +// transition fires, the choice is drawn by the weights its branches state. +package test { + private import ScalarValues::*; + private import Stochastic::*; + state def Machine { + attribute log : Integer = 0; + entry; then idle; + state idle; + choice pick; + state left { + entry { assign log := 10 * log + 3; } + } + state right { + entry { assign log := 10 * log + 4; } + } + transition first idle accept go then pick; + transition first pick then left { @Probability { p = 0.6; } } + transition first pick then right { @Probability { p = 0.4; } } + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_completion.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_completion.expected.json new file mode 100644 index 0000000000..fad375565c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_completion.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "libraries": true, + "modelSeed": 7, + "finalState": "right", + "outputs": { + "log": {"type": "Integer", "value": 14} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_completion.sysml b/internal/exec/runtime/testdata/conformance/stochastic_weighted_completion.sysml new file mode 100644 index 0000000000..070561e352 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_completion.sysml @@ -0,0 +1,22 @@ +// The two completion transitions out of decide are weighted: entering it runs +// its entry, queues the one completion event, and the dispatch of that event +// draws the branch by weight once, under run-to-completion. +package test { + private import ScalarValues::*; + private import Stochastic::*; + state def Machine { + attribute log : Integer = 0; + entry; then decide; + state decide { + entry { assign log := 10 * log + 1; } + } + state left { + entry { assign log := 10 * log + 3; } + } + state right { + entry { assign log := 10 * log + 4; } + } + transition first decide then left { @Probability { p = 0.4; } } + transition first decide then right { @Probability { p = 0.6; } } + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.check.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.check.expected.json new file mode 100644 index 0000000000..b21fb4be34 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.check.expected.json @@ -0,0 +1,4 @@ +{ + "verdict": "divergent", + "divergent": {"taken": ["1", "2"]} +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.expected.json new file mode 100644 index 0000000000..6bac90b729 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "trace": true, + "modelSeed": 7, + "outputs": { + "taken": {"type": "Integer", "value": 2} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.sysml b/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.sysml new file mode 100644 index 0000000000..d7778b8b3d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.sysml @@ -0,0 +1,21 @@ +package test { + private import ScalarValues::*; + private import Stochastic::*; + + // A decision whose branches carry Probability weights: a seeded run draws the + // branch by weight from the modeled stream, so a fixed model seed fixes the pick. + action route { + attribute taken : Integer = 0; + + first start; + then decide select; + first select then fast { @Probability { p = 0.7; } } + first select then slow { @Probability { p = 0.3; } } + + action fast { assign taken := 1; } + then done; + + action slow { assign taken := 2; } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.trace.golden new file mode 100644 index 0000000000..c00c7a5f49 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_decision_seeded.trace.golden @@ -0,0 +1,9 @@ +step 1: token 1@select +eval literal 0.7 -> 0.7 +eval literal 0.3 -> 0.3 +choice step 2: decision select branches 1->fast p=0.7, 2->slow p=0.3 hold (weighted; drew 0.774817894359002, took 2->slow) +step 2: token 1@slow +stmt assign taken + eval literal 2 -> 2 +step 3: token 1@done +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.check.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.check.expected.json new file mode 100644 index 0000000000..2453322492 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.check.expected.json @@ -0,0 +1 @@ +{"verdict": "divergent", "divergent": {"finalState": ["fast", "slow"], "log": ["1", "2"]}} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.declared.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.declared.trace.golden new file mode 100644 index 0000000000..44168dd4fc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.declared.trace.golden @@ -0,0 +1,13 @@ +eval literal 0.9 -> 0.9 +eval literal 0.1 -> 0.1 +choice state idle on time: transitions 1->slow p=0.9, 2->fast p=0.1 (weighted; drew 0.774817894359002, took 1->slow) +exit: idle +enter: slow (entry action) +stmt action body + stmt assign log + eval literal 10 -> 10 + eval feature log -> 0 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +transition: idle -> slow (event: time) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.expected.json new file mode 100644 index 0000000000..54cb3a9807 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "modelSeed": 7, + "events": [], + "outcomes": [ + {"finalState": "slow", "outputs": {"log": {"type": "Integer", "value": 1}}, "probability": 0.9}, + {"finalState": "fast", "outputs": {"log": {"type": "Integer", "value": 2}}, "probability": 0.1} + ], + "admissible": "Two transitions out of one state enabled by one event: exactly one fires, which one is open" +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.seed-1.trace.golden new file mode 100644 index 0000000000..44168dd4fc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.seed-1.trace.golden @@ -0,0 +1,13 @@ +eval literal 0.9 -> 0.9 +eval literal 0.1 -> 0.1 +choice state idle on time: transitions 1->slow p=0.9, 2->fast p=0.1 (weighted; drew 0.774817894359002, took 1->slow) +exit: idle +enter: slow (entry action) +stmt action body + stmt assign log + eval literal 10 -> 10 + eval feature log -> 0 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +transition: idle -> slow (event: time) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.sysml b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.sysml new file mode 100644 index 0000000000..953c1c00e1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.sysml @@ -0,0 +1,20 @@ +// Two transitions out of idle wait on the same interval, weighted 0.9 and 0.1: +// one timer fires and the branch is drawn by weight. +package test { + private import ScalarValues::*; + private import SI::*; + private import Stochastic::*; + state def Machine { + attribute log : Integer = 0; + entry; then idle; + state idle; + state slow { + entry { assign log := 10 * log + 1; } + } + state fast { + entry { assign log := 10 * log + 2; } + } + transition first idle accept after 5 [s] then slow { @Probability { p = 0.9; } } + transition first idle accept after 5 [s] then fast { @Probability { p = 0.1; } } + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.trace.golden new file mode 100644 index 0000000000..44168dd4fc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_time_transition.trace.golden @@ -0,0 +1,13 @@ +eval literal 0.9 -> 0.9 +eval literal 0.1 -> 0.1 +choice state idle on time: transitions 1->slow p=0.9, 2->fast p=0.1 (weighted; drew 0.774817894359002, took 1->slow) +exit: idle +enter: slow (entry action) +stmt action body + stmt assign log + eval literal 10 -> 10 + eval feature log -> 0 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +transition: idle -> slow (event: time) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.check.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.check.expected.json new file mode 100644 index 0000000000..14a107e101 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.check.expected.json @@ -0,0 +1,4 @@ +{ + "verdict": "divergent", + "divergent": {"finalState": ["fast", "slow"], "log": ["1", "2"]} +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.declared.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.declared.trace.golden new file mode 100644 index 0000000000..0b812bbf99 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.declared.trace.golden @@ -0,0 +1,13 @@ +eval literal 0.3 -> 0.3 +eval literal 0.7 -> 0.7 +choice state idle on accept go: transitions 1->slow p=0.3, 2->fast p=0.7 (weighted; drew 0.774817894359002, took 2->fast) +exit: idle +enter: fast (entry action) +stmt action body + stmt assign log + eval literal 10 -> 10 + eval feature log -> 0 + eval operator * -> 0 + eval literal 2 -> 2 + eval operator + -> 2 +transition: idle -> fast (event: accept go) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.expected.json new file mode 100644 index 0000000000..10b5ac13ce --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.expected.json @@ -0,0 +1,12 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "modelSeed": 7, + "events": [{"signal": "go"}], + "outcomes": [ + {"finalState": "slow", "outputs": {"log": {"type": "Integer", "value": 1}}, "probability": 0.3}, + {"finalState": "fast", "outputs": {"log": {"type": "Integer", "value": 2}}, "probability": 0.7} + ], + "admissible": "Two transitions out of one state enabled by one event: exactly one fires, which one is open" +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.seed-1.trace.golden new file mode 100644 index 0000000000..0b812bbf99 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.seed-1.trace.golden @@ -0,0 +1,13 @@ +eval literal 0.3 -> 0.3 +eval literal 0.7 -> 0.7 +choice state idle on accept go: transitions 1->slow p=0.3, 2->fast p=0.7 (weighted; drew 0.774817894359002, took 2->fast) +exit: idle +enter: fast (entry action) +stmt action body + stmt assign log + eval literal 10 -> 10 + eval feature log -> 0 + eval operator * -> 0 + eval literal 2 -> 2 + eval operator + -> 2 +transition: idle -> fast (event: accept go) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.sysml b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.sysml new file mode 100644 index 0000000000..478d748c2c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.sysml @@ -0,0 +1,19 @@ +// Two transitions out of idle wait on the same signal, weighted 0.3 and 0.7: +// a seeded run draws the branch by weight from the modeled stream. +package test { + private import ScalarValues::*; + private import Stochastic::*; + state def Machine { + attribute log : Integer = 0; + entry; then idle; + state idle; + state slow { + entry { assign log := 10 * log + 1; } + } + state fast { + entry { assign log := 10 * log + 2; } + } + transition first idle accept go then slow { @Probability { p = 0.3; } } + transition first idle accept go then fast { @Probability { p = 0.7; } } + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.trace.golden new file mode 100644 index 0000000000..0b812bbf99 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition.trace.golden @@ -0,0 +1,13 @@ +eval literal 0.3 -> 0.3 +eval literal 0.7 -> 0.7 +choice state idle on accept go: transitions 1->slow p=0.3, 2->fast p=0.7 (weighted; drew 0.774817894359002, took 2->fast) +exit: idle +enter: fast (entry action) +stmt action body + stmt assign log + eval literal 10 -> 10 + eval feature log -> 0 + eval operator * -> 0 + eval literal 2 -> 2 + eval operator + -> 2 +transition: idle -> fast (event: accept go) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_nested.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_nested.expected.json new file mode 100644 index 0000000000..ada7f2ce0d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_nested.expected.json @@ -0,0 +1,8 @@ +{ + "type": "state", + "libraries": true, + "trace": true, + "modelSeed": 7, + "events": [{"signal": "go"}], + "finalState": "l3+r2" +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_nested.sysml b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_nested.sysml new file mode 100644 index 0000000000..2dcca68514 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_nested.sysml @@ -0,0 +1,35 @@ +// The weighted pair out of l1 competes for go inside its own region: the +// enclosing work transition on the same event loses to the nested candidates, +// as an unweighted dispatch's would, and the sibling region reacts unweighted. +package test { + private import ScalarValues::*; + private import Stochastic::*; + state def Machine { + attribute log : Integer = 0; + entry; then work; + state work parallel { + state left { + entry; then l1; + state l1; + state l2 { + entry { assign log := 10 * log + 2; } + } + state l3 { + entry { assign log := 10 * log + 3; } + } + transition first l1 accept go then l2 { @Probability { p = 0.4; } } + transition first l1 accept go then l3 { @Probability { p = 0.6; } } + } + state right { + entry; then r1; + state r1; + state r2 { + entry { assign log := 10 * log + 1; } + } + transition first r1 accept go then r2; + } + } + state escaped; + transition first work accept go then escaped; + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_nested.trace.golden b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_nested.trace.golden new file mode 100644 index 0000000000..7dca59e15e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_nested.trace.golden @@ -0,0 +1,24 @@ +eval literal 0.4 -> 0.4 +eval literal 0.6 -> 0.6 +choice on accept go: next l1(exit), r1(exit) (unordered; took l1(exit) first) +choice state l1 on accept go: transitions 1->l2 p=0.4, 2->l3 p=0.6 (weighted; drew 0.774817894359002, took 2->l3) +exit: l1 +enter: l3 (entry action) +stmt action body + stmt assign log + eval literal 10 -> 10 + eval feature log -> 0 + eval operator * -> 0 + eval literal 3 -> 3 + eval operator + -> 3 +transition: l1 -> l3 (event: accept go) +exit: r1 +enter: r2 (entry action) +stmt action body + stmt assign log + eval literal 10 -> 10 + eval feature log -> 3 + eval operator * -> 30 + eval literal 1 -> 1 + eval operator + -> 31 +transition: r1 -> r2 (event: accept go) diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_unseeded.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_unseeded.expected.json new file mode 100644 index 0000000000..24b7b633f9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_unseeded.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "libraries": true, + "schedule": "declared", + "events": [{"signal": "go"}], + "finalState": "fast", + "outputs": { + "log": {"type": "Integer", "value": 2} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_unseeded.sysml b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_unseeded.sysml new file mode 100644 index 0000000000..edea048156 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weighted_transition_unseeded.sysml @@ -0,0 +1,19 @@ +// The same weighted pair under a policy that draws nothing: the run takes the +// most probable transition, so an unseeded run stays deterministic. +package test { + private import ScalarValues::*; + private import Stochastic::*; + state def Machine { + attribute log : Integer = 0; + entry; then idle; + state idle; + state slow { + entry { assign log := 10 * log + 1; } + } + state fast { + entry { assign log := 10 * log + 2; } + } + transition first idle accept go then slow { @Probability { p = 0.3; } } + transition first idle accept go then fast { @Probability { p = 0.7; } } + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weights_read_from_performer.expected.json b/internal/exec/runtime/testdata/conformance/stochastic_weights_read_from_performer.expected.json new file mode 100644 index 0000000000..d71c2b3392 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weights_read_from_performer.expected.json @@ -0,0 +1,10 @@ +{ + "type": "instance", + "libraries": true, + "modelSeed": 7, + "instantiate": "test::Study", + "slots": { + "fast.taken": {"type": "Integer", "value": 1}, + "slow.taken": {"type": "Integer", "value": 2} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stochastic_weights_read_from_performer.sysml b/internal/exec/runtime/testdata/conformance/stochastic_weights_read_from_performer.sysml new file mode 100644 index 0000000000..fb02c684a5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stochastic_weights_read_from_performer.sysml @@ -0,0 +1,29 @@ +// A Probability weight naming a feature is read from the performer when the decision +// is reached: parts redefining it to 1.0 and 0.0 take the branches those weigh, whatever the seed draws. +package test { + private import ScalarValues::*; + private import Stochastic::*; + + part def Analysis { + attribute pFast : Real default = 0.5; + attribute taken : Integer = 0; + + perform action route { + first start; + then decide select; + first select then fast { @Probability { p = pFast; } } + first select then slow { @Probability { p = 1.0 - pFast; } } + + action fast { assign taken := 1; } + then done; + + action slow { assign taken := 2; } + then done; + } + } + + part def Study { + part fast : Analysis { attribute :>> pFast = 1.0; } + part slow : Analysis { attribute :>> pFast = 0.0; } + } +} diff --git a/internal/core/runtime/testdata/conformance/string_compared_with_a_number.expected.json b/internal/exec/runtime/testdata/conformance/string_compared_with_a_number.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/string_compared_with_a_number.expected.json rename to internal/exec/runtime/testdata/conformance/string_compared_with_a_number.expected.json diff --git a/internal/core/runtime/testdata/conformance/string_compared_with_a_number.sysml b/internal/exec/runtime/testdata/conformance/string_compared_with_a_number.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/string_compared_with_a_number.sysml rename to internal/exec/runtime/testdata/conformance/string_compared_with_a_number.sysml diff --git a/internal/core/runtime/testdata/conformance/string_comparison.expected.json b/internal/exec/runtime/testdata/conformance/string_comparison.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/string_comparison.expected.json rename to internal/exec/runtime/testdata/conformance/string_comparison.expected.json diff --git a/internal/core/runtime/testdata/conformance/string_comparison.sysml b/internal/exec/runtime/testdata/conformance/string_comparison.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/string_comparison.sysml rename to internal/exec/runtime/testdata/conformance/string_comparison.sysml diff --git a/internal/core/runtime/testdata/conformance/string_comparison.trace.golden b/internal/exec/runtime/testdata/conformance/string_comparison.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/string_comparison.trace.golden rename to internal/exec/runtime/testdata/conformance/string_comparison.trace.golden diff --git a/internal/core/runtime/testdata/conformance/string_empty.expected.json b/internal/exec/runtime/testdata/conformance/string_empty.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/string_empty.expected.json rename to internal/exec/runtime/testdata/conformance/string_empty.expected.json diff --git a/internal/core/runtime/testdata/conformance/string_empty.sysml b/internal/exec/runtime/testdata/conformance/string_empty.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/string_empty.sysml rename to internal/exec/runtime/testdata/conformance/string_empty.sysml diff --git a/internal/core/runtime/testdata/conformance/string_empty.trace.golden b/internal/exec/runtime/testdata/conformance/string_empty.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/string_empty.trace.golden rename to internal/exec/runtime/testdata/conformance/string_empty.trace.golden diff --git a/internal/core/runtime/testdata/conformance/string_functions.expected.json b/internal/exec/runtime/testdata/conformance/string_functions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/string_functions.expected.json rename to internal/exec/runtime/testdata/conformance/string_functions.expected.json diff --git a/internal/core/runtime/testdata/conformance/string_functions.sysml b/internal/exec/runtime/testdata/conformance/string_functions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/string_functions.sysml rename to internal/exec/runtime/testdata/conformance/string_functions.sysml diff --git a/internal/core/runtime/testdata/conformance/string_functions.trace.golden b/internal/exec/runtime/testdata/conformance/string_functions.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/string_functions.trace.golden rename to internal/exec/runtime/testdata/conformance/string_functions.trace.golden diff --git a/internal/core/runtime/testdata/conformance/string_operators.expected.json b/internal/exec/runtime/testdata/conformance/string_operators.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/string_operators.expected.json rename to internal/exec/runtime/testdata/conformance/string_operators.expected.json diff --git a/internal/core/runtime/testdata/conformance/string_operators.sysml b/internal/exec/runtime/testdata/conformance/string_operators.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/string_operators.sysml rename to internal/exec/runtime/testdata/conformance/string_operators.sysml diff --git a/internal/core/runtime/testdata/conformance/string_operators.trace.golden b/internal/exec/runtime/testdata/conformance/string_operators.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/string_operators.trace.golden rename to internal/exec/runtime/testdata/conformance/string_operators.trace.golden diff --git a/internal/core/runtime/testdata/conformance/string_substring_out_of_range.expected.json b/internal/exec/runtime/testdata/conformance/string_substring_out_of_range.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/string_substring_out_of_range.expected.json rename to internal/exec/runtime/testdata/conformance/string_substring_out_of_range.expected.json diff --git a/internal/core/runtime/testdata/conformance/string_substring_out_of_range.sysml b/internal/exec/runtime/testdata/conformance/string_substring_out_of_range.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/string_substring_out_of_range.sysml rename to internal/exec/runtime/testdata/conformance/string_substring_out_of_range.sysml diff --git a/internal/core/runtime/testdata/conformance/structured_attribute_own_features.expected.json b/internal/exec/runtime/testdata/conformance/structured_attribute_own_features.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/structured_attribute_own_features.expected.json rename to internal/exec/runtime/testdata/conformance/structured_attribute_own_features.expected.json diff --git a/internal/core/runtime/testdata/conformance/structured_attribute_own_features.sysml b/internal/exec/runtime/testdata/conformance/structured_attribute_own_features.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/structured_attribute_own_features.sysml rename to internal/exec/runtime/testdata/conformance/structured_attribute_own_features.sysml diff --git a/internal/exec/runtime/testdata/conformance/stub_action_output_holds_no_value.expected.json b/internal/exec/runtime/testdata/conformance/stub_action_output_holds_no_value.expected.json new file mode 100644 index 0000000000..56cb254f51 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stub_action_output_holds_no_value.expected.json @@ -0,0 +1,11 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Sampling", + "outputs": { + "recorded": {"type": "Boolean", "value": false}, + "readings": {"type": "Integer", "value": 0}, + "measure.gain": {"type": "Real", "value": 2.5}, + "record.written": {"type": "Boolean", "value": false} + } +} diff --git a/internal/exec/runtime/testdata/conformance/stub_action_output_holds_no_value.sysml b/internal/exec/runtime/testdata/conformance/stub_action_output_holds_no_value.sysml new file mode 100644 index 0000000000..1276e04319 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/stub_action_output_holds_no_value.sysml @@ -0,0 +1,49 @@ +// A declared action that computes nothing — `measure` has parameters and no +// body, as a v1 call action naming no behavior migrates to — fires, passes the +// token on and leaves its `out reading [0..1]` empty. The flow from it carries +// no value to `record.value`, which is declared admitting none, so `record` +// runs with the parameter empty: the guarded assignment is skipped, `last` is +// never written, and the activity completes rather than failing or waiting. Its +// own `out sample [0..1]`, bound to the empty output, stays empty too. +package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + + action def Record { + in value : Real[0..1]; + out written : Boolean = false; + first start then step; + action step { + if value->notEmpty() { + assign written := true; + } + } + } + + action def Sampling { + out sample : Real[0..1]; + attribute last : Real[0..1]; + attribute recorded : Boolean = false; + attribute readings : Integer = 0; + + first start then gain; + action gain { out result : Real = 2.5; } + first gain then measure; + action measure { + in gain : Real; + out reading : Real[0..1]; + } + first measure then record; + action record : Record; + first record then tally; + action tally { + assign readings := measure.reading->size(); + assign recorded := record.written; + } + first tally then done; + + flow gain.result to measure.gain; + flow measure.reading to record.value; + bind sample = measure.reading; + } +} diff --git a/internal/core/runtime/testdata/conformance/two_objects_exhibit_independently.expected.json b/internal/exec/runtime/testdata/conformance/two_objects_exhibit_independently.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/two_objects_exhibit_independently.expected.json rename to internal/exec/runtime/testdata/conformance/two_objects_exhibit_independently.expected.json diff --git a/internal/core/runtime/testdata/conformance/two_objects_exhibit_independently.sysml b/internal/exec/runtime/testdata/conformance/two_objects_exhibit_independently.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/two_objects_exhibit_independently.sysml rename to internal/exec/runtime/testdata/conformance/two_objects_exhibit_independently.sysml diff --git a/internal/core/runtime/testdata/conformance/two_objects_exhibit_independently.trace.golden b/internal/exec/runtime/testdata/conformance/two_objects_exhibit_independently.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/two_objects_exhibit_independently.trace.golden rename to internal/exec/runtime/testdata/conformance/two_objects_exhibit_independently.trace.golden diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_local_unit.expected.json b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_local_unit.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_local_unit.expected.json rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_local_unit.expected.json diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_local_unit.sysml b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_local_unit.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_local_unit.sysml rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_local_unit.sysml diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_package_member.expected.json b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_package_member.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_package_member.expected.json rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_package_member.expected.json diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_package_member.sysml b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_package_member.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_package_member.sysml rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_package_member.sysml diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_action.expected.json b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_action.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_action.expected.json rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_action.expected.json diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_action.sysml b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_action.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_action.sysml rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_action.sysml diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_calc.expected.json b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_calc.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_calc.expected.json rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_calc.expected.json diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_calc.sysml b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_calc.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_calc.sysml rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_calc.sysml diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_constraint.expected.json b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_constraint.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_constraint.expected.json rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_constraint.expected.json diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_constraint.sysml b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_constraint.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_constraint.sysml rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_constraint.sysml diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_slot.expected.json b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_slot.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_slot.expected.json rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_slot.expected.json diff --git a/internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_slot.sysml b/internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_slot.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_shadowed_by_sibling_slot.sysml rename to internal/exec/runtime/testdata/conformance/unit_shadowed_by_sibling_slot.sysml diff --git a/internal/core/runtime/testdata/conformance/unit_undeclared.expected.json b/internal/exec/runtime/testdata/conformance/unit_undeclared.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_undeclared.expected.json rename to internal/exec/runtime/testdata/conformance/unit_undeclared.expected.json diff --git a/internal/core/runtime/testdata/conformance/unit_undeclared.sysml b/internal/exec/runtime/testdata/conformance/unit_undeclared.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/unit_undeclared.sysml rename to internal/exec/runtime/testdata/conformance/unit_undeclared.sysml diff --git a/internal/core/runtime/testdata/conformance/value_classification_at.expected.json b/internal/exec/runtime/testdata/conformance/value_classification_at.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/value_classification_at.expected.json rename to internal/exec/runtime/testdata/conformance/value_classification_at.expected.json diff --git a/internal/core/runtime/testdata/conformance/value_classification_at.sysml b/internal/exec/runtime/testdata/conformance/value_classification_at.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/value_classification_at.sysml rename to internal/exec/runtime/testdata/conformance/value_classification_at.sysml diff --git a/internal/core/runtime/testdata/conformance/value_classification_shared_rule.expected.json b/internal/exec/runtime/testdata/conformance/value_classification_shared_rule.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/value_classification_shared_rule.expected.json rename to internal/exec/runtime/testdata/conformance/value_classification_shared_rule.expected.json diff --git a/internal/core/runtime/testdata/conformance/value_classification_shared_rule.sysml b/internal/exec/runtime/testdata/conformance/value_classification_shared_rule.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/value_classification_shared_rule.sysml rename to internal/exec/runtime/testdata/conformance/value_classification_shared_rule.sysml diff --git a/internal/core/runtime/testdata/conformance/value_unbounded_arithmetic_refused.expected.json b/internal/exec/runtime/testdata/conformance/value_unbounded_arithmetic_refused.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/value_unbounded_arithmetic_refused.expected.json rename to internal/exec/runtime/testdata/conformance/value_unbounded_arithmetic_refused.expected.json diff --git a/internal/core/runtime/testdata/conformance/value_unbounded_arithmetic_refused.sysml b/internal/exec/runtime/testdata/conformance/value_unbounded_arithmetic_refused.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/value_unbounded_arithmetic_refused.sysml rename to internal/exec/runtime/testdata/conformance/value_unbounded_arithmetic_refused.sysml diff --git a/internal/core/runtime/testdata/conformance/value_unbounded_comparison.expected.json b/internal/exec/runtime/testdata/conformance/value_unbounded_comparison.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/value_unbounded_comparison.expected.json rename to internal/exec/runtime/testdata/conformance/value_unbounded_comparison.expected.json diff --git a/internal/core/runtime/testdata/conformance/value_unbounded_comparison.sysml b/internal/exec/runtime/testdata/conformance/value_unbounded_comparison.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/value_unbounded_comparison.sysml rename to internal/exec/runtime/testdata/conformance/value_unbounded_comparison.sysml diff --git a/internal/core/runtime/testdata/conformance/value_unique_user_features.expected.json b/internal/exec/runtime/testdata/conformance/value_unique_user_features.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/value_unique_user_features.expected.json rename to internal/exec/runtime/testdata/conformance/value_unique_user_features.expected.json diff --git a/internal/core/runtime/testdata/conformance/value_unique_user_features.sysml b/internal/exec/runtime/testdata/conformance/value_unique_user_features.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/value_unique_user_features.sysml rename to internal/exec/runtime/testdata/conformance/value_unique_user_features.sysml diff --git a/internal/core/runtime/testdata/conformance/variant_connection_per_owner.expected.json b/internal/exec/runtime/testdata/conformance/variant_connection_per_owner.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/variant_connection_per_owner.expected.json rename to internal/exec/runtime/testdata/conformance/variant_connection_per_owner.expected.json diff --git a/internal/core/runtime/testdata/conformance/variant_connection_per_owner.sysml b/internal/exec/runtime/testdata/conformance/variant_connection_per_owner.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/variant_connection_per_owner.sysml rename to internal/exec/runtime/testdata/conformance/variant_connection_per_owner.sysml diff --git a/internal/core/runtime/testdata/conformance/variant_outside_a_variation.expected.json b/internal/exec/runtime/testdata/conformance/variant_outside_a_variation.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/variant_outside_a_variation.expected.json rename to internal/exec/runtime/testdata/conformance/variant_outside_a_variation.expected.json diff --git a/internal/core/runtime/testdata/conformance/variant_outside_a_variation.sysml b/internal/exec/runtime/testdata/conformance/variant_outside_a_variation.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/variant_outside_a_variation.sysml rename to internal/exec/runtime/testdata/conformance/variant_outside_a_variation.sysml diff --git a/internal/core/runtime/testdata/conformance/variant_under_an_inherited_variation.expected.json b/internal/exec/runtime/testdata/conformance/variant_under_an_inherited_variation.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/variant_under_an_inherited_variation.expected.json rename to internal/exec/runtime/testdata/conformance/variant_under_an_inherited_variation.expected.json diff --git a/internal/core/runtime/testdata/conformance/variant_under_an_inherited_variation.sysml b/internal/exec/runtime/testdata/conformance/variant_under_an_inherited_variation.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/variant_under_an_inherited_variation.sysml rename to internal/exec/runtime/testdata/conformance/variant_under_an_inherited_variation.sysml diff --git a/internal/core/runtime/testdata/conformance/variation_attribute_selection.expected.json b/internal/exec/runtime/testdata/conformance/variation_attribute_selection.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/variation_attribute_selection.expected.json rename to internal/exec/runtime/testdata/conformance/variation_attribute_selection.expected.json diff --git a/internal/core/runtime/testdata/conformance/variation_attribute_selection.sysml b/internal/exec/runtime/testdata/conformance/variation_attribute_selection.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/variation_attribute_selection.sysml rename to internal/exec/runtime/testdata/conformance/variation_attribute_selection.sysml diff --git a/internal/core/runtime/testdata/conformance/variation_interface_mismatch.expected.json b/internal/exec/runtime/testdata/conformance/variation_interface_mismatch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/variation_interface_mismatch.expected.json rename to internal/exec/runtime/testdata/conformance/variation_interface_mismatch.expected.json diff --git a/internal/core/runtime/testdata/conformance/variation_interface_mismatch.sysml b/internal/exec/runtime/testdata/conformance/variation_interface_mismatch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/variation_interface_mismatch.sysml rename to internal/exec/runtime/testdata/conformance/variation_interface_mismatch.sysml diff --git a/internal/core/runtime/testdata/conformance/variation_interface_selection.expected.json b/internal/exec/runtime/testdata/conformance/variation_interface_selection.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/variation_interface_selection.expected.json rename to internal/exec/runtime/testdata/conformance/variation_interface_selection.expected.json diff --git a/internal/core/runtime/testdata/conformance/variation_interface_selection.sysml b/internal/exec/runtime/testdata/conformance/variation_interface_selection.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/variation_interface_selection.sysml rename to internal/exec/runtime/testdata/conformance/variation_interface_selection.sysml diff --git a/internal/core/runtime/testdata/conformance/variation_part_selection.expected.json b/internal/exec/runtime/testdata/conformance/variation_part_selection.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/variation_part_selection.expected.json rename to internal/exec/runtime/testdata/conformance/variation_part_selection.expected.json diff --git a/internal/core/runtime/testdata/conformance/variation_part_selection.sysml b/internal/exec/runtime/testdata/conformance/variation_part_selection.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/variation_part_selection.sysml rename to internal/exec/runtime/testdata/conformance/variation_part_selection.sysml diff --git a/internal/core/runtime/testdata/conformance/variation_unselected.expected.json b/internal/exec/runtime/testdata/conformance/variation_unselected.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/variation_unselected.expected.json rename to internal/exec/runtime/testdata/conformance/variation_unselected.expected.json diff --git a/internal/core/runtime/testdata/conformance/variation_unselected.sysml b/internal/exec/runtime/testdata/conformance/variation_unselected.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/variation_unselected.sysml rename to internal/exec/runtime/testdata/conformance/variation_unselected.sysml diff --git a/internal/core/runtime/testdata/conformance/verification_objective_subject.expected.json b/internal/exec/runtime/testdata/conformance/verification_objective_subject.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_objective_subject.expected.json rename to internal/exec/runtime/testdata/conformance/verification_objective_subject.expected.json diff --git a/internal/core/runtime/testdata/conformance/verification_objective_subject.sysml b/internal/exec/runtime/testdata/conformance/verification_objective_subject.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_objective_subject.sysml rename to internal/exec/runtime/testdata/conformance/verification_objective_subject.sysml diff --git a/internal/core/runtime/testdata/conformance/verification_objective_subject.trace.golden b/internal/exec/runtime/testdata/conformance/verification_objective_subject.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_objective_subject.trace.golden rename to internal/exec/runtime/testdata/conformance/verification_objective_subject.trace.golden diff --git a/internal/core/runtime/testdata/conformance/verification_objective_subject_unbound.expected.json b/internal/exec/runtime/testdata/conformance/verification_objective_subject_unbound.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_objective_subject_unbound.expected.json rename to internal/exec/runtime/testdata/conformance/verification_objective_subject_unbound.expected.json diff --git a/internal/core/runtime/testdata/conformance/verification_objective_subject_unbound.sysml b/internal/exec/runtime/testdata/conformance/verification_objective_subject_unbound.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_objective_subject_unbound.sysml rename to internal/exec/runtime/testdata/conformance/verification_objective_subject_unbound.sysml diff --git a/internal/core/runtime/testdata/conformance/verification_objective_subject_unstated.expected.json b/internal/exec/runtime/testdata/conformance/verification_objective_subject_unstated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_objective_subject_unstated.expected.json rename to internal/exec/runtime/testdata/conformance/verification_objective_subject_unstated.expected.json diff --git a/internal/core/runtime/testdata/conformance/verification_objective_subject_unstated.sysml b/internal/exec/runtime/testdata/conformance/verification_objective_subject_unstated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_objective_subject_unstated.sysml rename to internal/exec/runtime/testdata/conformance/verification_objective_subject_unstated.sysml diff --git a/internal/core/runtime/testdata/conformance/verification_objective_subject_violated.expected.json b/internal/exec/runtime/testdata/conformance/verification_objective_subject_violated.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_objective_subject_violated.expected.json rename to internal/exec/runtime/testdata/conformance/verification_objective_subject_violated.expected.json diff --git a/internal/core/runtime/testdata/conformance/verification_objective_subject_violated.sysml b/internal/exec/runtime/testdata/conformance/verification_objective_subject_violated.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_objective_subject_violated.sysml rename to internal/exec/runtime/testdata/conformance/verification_objective_subject_violated.sysml diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_error.expected.json b/internal/exec/runtime/testdata/conformance/verification_verdict_error.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_error.expected.json rename to internal/exec/runtime/testdata/conformance/verification_verdict_error.expected.json diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_error.sysml b/internal/exec/runtime/testdata/conformance/verification_verdict_error.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_error.sysml rename to internal/exec/runtime/testdata/conformance/verification_verdict_error.sysml diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_fail.expected.json b/internal/exec/runtime/testdata/conformance/verification_verdict_fail.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_fail.expected.json rename to internal/exec/runtime/testdata/conformance/verification_verdict_fail.expected.json diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_fail.sysml b/internal/exec/runtime/testdata/conformance/verification_verdict_fail.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_fail.sysml rename to internal/exec/runtime/testdata/conformance/verification_verdict_fail.sysml diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_inconclusive.expected.json b/internal/exec/runtime/testdata/conformance/verification_verdict_inconclusive.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_inconclusive.expected.json rename to internal/exec/runtime/testdata/conformance/verification_verdict_inconclusive.expected.json diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_inconclusive.sysml b/internal/exec/runtime/testdata/conformance/verification_verdict_inconclusive.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_inconclusive.sysml rename to internal/exec/runtime/testdata/conformance/verification_verdict_inconclusive.sysml diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_pass.expected.json b/internal/exec/runtime/testdata/conformance/verification_verdict_pass.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_pass.expected.json rename to internal/exec/runtime/testdata/conformance/verification_verdict_pass.expected.json diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_pass.sysml b/internal/exec/runtime/testdata/conformance/verification_verdict_pass.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_pass.sysml rename to internal/exec/runtime/testdata/conformance/verification_verdict_pass.sysml diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_pass.trace.golden b/internal/exec/runtime/testdata/conformance/verification_verdict_pass.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_pass.trace.golden rename to internal/exec/runtime/testdata/conformance/verification_verdict_pass.trace.golden diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_subcase_branch.expected.json b/internal/exec/runtime/testdata/conformance/verification_verdict_subcase_branch.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_subcase_branch.expected.json rename to internal/exec/runtime/testdata/conformance/verification_verdict_subcase_branch.expected.json diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_subcase_branch.sysml b/internal/exec/runtime/testdata/conformance/verification_verdict_subcase_branch.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_subcase_branch.sysml rename to internal/exec/runtime/testdata/conformance/verification_verdict_subcase_branch.sysml diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_subcases.expected.json b/internal/exec/runtime/testdata/conformance/verification_verdict_subcases.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_subcases.expected.json rename to internal/exec/runtime/testdata/conformance/verification_verdict_subcases.expected.json diff --git a/internal/core/runtime/testdata/conformance/verification_verdict_subcases.sysml b/internal/exec/runtime/testdata/conformance/verification_verdict_subcases.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/verification_verdict_subcases.sysml rename to internal/exec/runtime/testdata/conformance/verification_verdict_subcases.sysml diff --git a/internal/exec/runtime/testdata/conformance/via_bound_reference_shadows_part.expected.json b/internal/exec/runtime/testdata/conformance/via_bound_reference_shadows_part.expected.json new file mode 100644 index 0000000000..b6cbcba79b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/via_bound_reference_shadows_part.expected.json @@ -0,0 +1,27 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "ViaBoundReferenceShadowsPart::Rig", + "materialization": {}, + "objects": [ + { + "path": "commander", + "events": [{"signal": "Go"}], + "finalState": "ready", + "stateVisits": ["idle", "commanding", "ready"], + "slots": { + "lastAck": {"type": "Instance"} + } + }, + { + "path": "m1cs", + "finalState": "installed", + "stateVisits": ["idle", "installed"] + }, + { + "path": "decoy", + "finalState": "idle", + "stateVisits": ["idle"] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/via_bound_reference_shadows_part.sysml b/internal/exec/runtime/testdata/conformance/via_bound_reference_shadows_part.sysml new file mode 100644 index 0000000000..b1cc256ca1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/via_bound_reference_shadows_part.sysml @@ -0,0 +1,98 @@ +// The performer owns a part named `context` and the action binds a reference parameter of +// the same name to another object: `via context.toM1` follows the parameter, as any +// expression naming `context` would, so the query and its acknowledgement cross the +// partner's port, not the same-named part's (SysML v2 §7.16, §7.17). +package ViaBoundReferenceShadowsPart { + item def Go; + item def Query; + item def Ack; + + port def QueryOut { + out item query : Query; + in item ack : Ack; + } + + port def QueryIn { + in item query : Query; + out item ack : Ack; + } + + part def Peer { + port toM1 : QueryOut; + } + + action def Handshake { + in ref context : Peer; + out ref item ack : Ack; + first start then split; + fork split; + first split then ask; + first split then heard; + action ask { + send new Query() via context.toM1; + } + action heard accept msg : Ack via context.toM1; + first ask then meet; + first heard then meet; + join meet; + first meet then keep; + action keep { + assign ack := msg; + } + first keep then done; + } + + part def Commander { + part context : Peer; + ref part partner : Peer; + ref item lastAck : Ack[0..1]; + + state def Life { + entry; then idle; + state idle; + transition first idle accept Go then commanding; + state commanding { + do action run { + first start then call; + action call : Handshake; + bind call.context = partner; + first call then keep; + action keep { + assign lastAck := call.ack; + } + first keep then done; + } + } + state ready; + transition first commanding then ready; + } + + exhibit state life : Life; + } + + part def M1CS { + port cmd : QueryIn; + + exhibit state life { + entry; then idle; + state idle; + state installed; + + transition first idle + accept Query via cmd + do send new Ack() via cmd + then installed; + } + } + + part def Rig { + part commander : Commander { + :>> partner = live; + } + part live : Peer; + part m1cs : M1CS; + part decoy : M1CS; + connect live.toM1 to m1cs.cmd; + connect commander.context.toM1 to decoy.cmd; + } +} diff --git a/internal/exec/runtime/testdata/conformance/via_this_keeps_performer.expected.json b/internal/exec/runtime/testdata/conformance/via_this_keeps_performer.expected.json new file mode 100644 index 0000000000..c94e1dd45d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/via_this_keeps_performer.expected.json @@ -0,0 +1,42 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "ViaThisKeepsPerformer::Rig", + "materialization": {}, + "objects": [ + { + "path": "commander", + "events": [ + { + "signal": "Go" + } + ], + "finalState": "ready", + "stateVisits": [ + "idle", + "commanding", + "ready" + ], + "slots": { + "lastAck": { + "type": "Instance" + } + } + }, + { + "path": "m1cs", + "finalState": "idle", + "stateVisits": [ + "idle" + ] + }, + { + "path": "decoy", + "finalState": "installed", + "stateVisits": [ + "idle", + "installed" + ] + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/via_this_keeps_performer.sysml b/internal/exec/runtime/testdata/conformance/via_this_keeps_performer.sysml new file mode 100644 index 0000000000..36aab38745 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/via_this_keeps_performer.sysml @@ -0,0 +1,98 @@ +// The performer owns a part named `context` and the action binds a reference parameter of +// the same name to another object: `via this.context.toM1` roots the path at the performer, +// so the query and its acknowledgement cross the performer's own part's port, not the +// parameter's, however the name would resolve in an expression. +package ViaThisKeepsPerformer { + item def Go; + item def Query; + item def Ack; + + port def QueryOut { + out item query : Query; + in item ack : Ack; + } + + port def QueryIn { + in item query : Query; + out item ack : Ack; + } + + part def Peer { + port toM1 : QueryOut; + } + + action def Handshake { + in ref context : Peer; + out ref item ack : Ack; + first start then split; + fork split; + first split then ask; + first split then heard; + action ask { + send new Query() via this.context.toM1; + } + action heard accept msg : Ack via this.context.toM1; + first ask then meet; + first heard then meet; + join meet; + first meet then keep; + action keep { + assign ack := msg; + } + first keep then done; + } + + part def Commander { + part context : Peer; + ref part partner : Peer; + ref item lastAck : Ack[0..1]; + + state def Life { + entry; then idle; + state idle; + transition first idle accept Go then commanding; + state commanding { + do action run { + first start then call; + action call : Handshake; + bind call.context = partner; + first call then keep; + action keep { + assign lastAck := call.ack; + } + first keep then done; + } + } + state ready; + transition first commanding then ready; + } + + exhibit state life : Life; + } + + part def M1CS { + port cmd : QueryIn; + + exhibit state life { + entry; then idle; + state idle; + state installed; + + transition first idle + accept Query via cmd + do send new Ack() via cmd + then installed; + } + } + + part def Rig { + part commander : Commander { + :>> partner = live; + } + part live : Peer; + part m1cs : M1CS; + part decoy : M1CS; + connect live.toM1 to m1cs.cmd; + connect commander.context.toM1 to decoy.cmd; + } +} diff --git a/internal/core/runtime/testdata/conformance/view_exposed_element_classification.expected.json b/internal/exec/runtime/testdata/conformance/view_exposed_element_classification.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/view_exposed_element_classification.expected.json rename to internal/exec/runtime/testdata/conformance/view_exposed_element_classification.expected.json diff --git a/internal/core/runtime/testdata/conformance/view_exposed_element_classification.sysml b/internal/exec/runtime/testdata/conformance/view_exposed_element_classification.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/view_exposed_element_classification.sysml rename to internal/exec/runtime/testdata/conformance/view_exposed_element_classification.sysml diff --git a/internal/core/runtime/testdata/conformance/viewpoint_concern_without_condition.expected.json b/internal/exec/runtime/testdata/conformance/viewpoint_concern_without_condition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/viewpoint_concern_without_condition.expected.json rename to internal/exec/runtime/testdata/conformance/viewpoint_concern_without_condition.expected.json diff --git a/internal/core/runtime/testdata/conformance/viewpoint_concern_without_condition.sysml b/internal/exec/runtime/testdata/conformance/viewpoint_concern_without_condition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/viewpoint_concern_without_condition.sysml rename to internal/exec/runtime/testdata/conformance/viewpoint_concern_without_condition.sysml diff --git a/internal/core/runtime/testdata/conformance/viewpoint_framed_concern_conditions.expected.json b/internal/exec/runtime/testdata/conformance/viewpoint_framed_concern_conditions.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/viewpoint_framed_concern_conditions.expected.json rename to internal/exec/runtime/testdata/conformance/viewpoint_framed_concern_conditions.expected.json diff --git a/internal/core/runtime/testdata/conformance/viewpoint_framed_concern_conditions.sysml b/internal/exec/runtime/testdata/conformance/viewpoint_framed_concern_conditions.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/viewpoint_framed_concern_conditions.sysml rename to internal/exec/runtime/testdata/conformance/viewpoint_framed_concern_conditions.sysml diff --git a/internal/core/runtime/testdata/conformance/w6e_state_simple_self_transition.expected.json b/internal/exec/runtime/testdata/conformance/w6e_state_simple_self_transition.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/w6e_state_simple_self_transition.expected.json rename to internal/exec/runtime/testdata/conformance/w6e_state_simple_self_transition.expected.json diff --git a/internal/core/runtime/testdata/conformance/w6e_state_simple_self_transition.sysml b/internal/exec/runtime/testdata/conformance/w6e_state_simple_self_transition.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/w6e_state_simple_self_transition.sysml rename to internal/exec/runtime/testdata/conformance/w6e_state_simple_self_transition.sysml diff --git a/internal/exec/runtime/testdata/conformance/w6e_state_simple_self_transition.trace.golden b/internal/exec/runtime/testdata/conformance/w6e_state_simple_self_transition.trace.golden new file mode 100644 index 0000000000..f5803a4c89 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/w6e_state_simple_self_transition.trace.golden @@ -0,0 +1,23 @@ +exit: s (exit action) +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 9 -> 9 + eval operator + -> 129 +enter: s (entry action) +stmt action body + stmt assign log + eval feature log -> 129 + eval literal 10 -> 10 + eval operator * -> 1290 + eval literal 1 -> 1 + eval operator + -> 1291 +transition: s -> s (event: accept again) diff --git a/internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.check.expected.json b/internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.check.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.check.expected.json rename to internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.check.expected.json diff --git a/internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.declared.trace.golden b/internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.declared.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.declared.trace.golden rename to internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.declared.trace.golden diff --git a/internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.expected.json b/internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.expected.json rename to internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.expected.json diff --git a/internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.seed-1.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.seed-1.trace.golden rename to internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.seed-1.trace.golden diff --git a/internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.sysml b/internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.sysml rename to internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.sysml diff --git a/internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.trace.golden b/internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.trace.golden similarity index 100% rename from internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.trace.golden rename to internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.trace.golden diff --git a/internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.trace.order b/internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.trace.order similarity index 100% rename from internal/core/runtime/testdata/conformance/w7d_send_via_port_to_receiver.trace.order rename to internal/exec/runtime/testdata/conformance/w7d_send_via_port_to_receiver.trace.order diff --git a/internal/core/runtime/testdata/conformance/w7d_type_classification.expected.json b/internal/exec/runtime/testdata/conformance/w7d_type_classification.expected.json similarity index 100% rename from internal/core/runtime/testdata/conformance/w7d_type_classification.expected.json rename to internal/exec/runtime/testdata/conformance/w7d_type_classification.expected.json diff --git a/internal/core/runtime/testdata/conformance/w7d_type_classification.sysml b/internal/exec/runtime/testdata/conformance/w7d_type_classification.sysml similarity index 100% rename from internal/core/runtime/testdata/conformance/w7d_type_classification.sysml rename to internal/exec/runtime/testdata/conformance/w7d_type_classification.sysml diff --git a/internal/core/runtime/testdata/later_prefix_violates_faster.sysml b/internal/exec/runtime/testdata/later_prefix_violates_faster.sysml similarity index 100% rename from internal/core/runtime/testdata/later_prefix_violates_faster.sysml rename to internal/exec/runtime/testdata/later_prefix_violates_faster.sysml diff --git a/internal/exec/runtime/testdata/robustness/state_run_to_completion_scope_no_occurrence.sysml b/internal/exec/runtime/testdata/robustness/state_run_to_completion_scope_no_occurrence.sysml new file mode 100644 index 0000000000..12941b9f44 --- /dev/null +++ b/internal/exec/runtime/testdata/robustness/state_run_to_completion_scope_no_occurrence.sysml @@ -0,0 +1,8 @@ +// A scope redefinition that names no occurrence is rejected with a typed refusal. +package test { + state def Machine { + ref :>> runToCompletionScope = Missing; + entry; then idle; + state idle; + } +} diff --git a/internal/exec/runtime/testdata/robustness/state_run_to_completion_scope_not_ancestor.sysml b/internal/exec/runtime/testdata/robustness/state_run_to_completion_scope_not_ancestor.sysml new file mode 100644 index 0000000000..3af5d693fa --- /dev/null +++ b/internal/exec/runtime/testdata/robustness/state_run_to_completion_scope_not_ancestor.sysml @@ -0,0 +1,15 @@ +// A scope redefinition naming a sibling rather than an ancestor is rejected +// with a typed non-ancestor refusal. +package test { + state def Machine parallel { + state left { + entry; then l; + state l; + } + state right { + ref :>> runToCompletionScope = Machine::left::l; + entry; then r; + state r; + } + } +} diff --git a/internal/core/runtime/testdata/simple_calc.sysml b/internal/exec/runtime/testdata/simple_calc.sysml similarity index 100% rename from internal/core/runtime/testdata/simple_calc.sysml rename to internal/exec/runtime/testdata/simple_calc.sysml diff --git a/internal/exec/runtime/that_test.go b/internal/exec/runtime/that_test.go new file mode 100644 index 0000000000..753badba86 --- /dev/null +++ b/internal/exec/runtime/that_test.go @@ -0,0 +1,78 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// realValue reports the real a feature value holds. +func realValue(t *testing.T, v Value) float64 { + t.Helper() + if v.Kind != ValConst || v.Const.Kind != semantics.ValReal { + t.Fatalf("value is %v, want a real", v) + } + return v.Const.Real +} + +// A usage's `that` names the object featuring the usage's values, so a chain +// from it reads that object's feature values ([KerML, 8.4.2]). +func TestThatReadsTheFeaturingObject(t *testing.T) { + const src = ` + package test { + private import ScalarValues::Real; + part def P { attribute a : Real = 1.5; } + part def Holder { part p : P { attribute b : Real = that.a; } } + }` + inst, ctx := instantiatePart(t, "Holder", src) + p := fvInstance(t, ctx, inst, "p") + fv, err := p.GetFeatureValue(ctx, "b") + if err != nil { + t.Fatalf("GetFeatureValue b: %v", err) + } + if got := realValue(t, fv.HeldValue()); got != 1.5 { + t.Errorf("b = %v, want the featuring object's a (1.5)", got) + } +} + +// The innermost usage features the value, so `that` in a nested usage's body +// reads that usage's object rather than an outer one. +func TestThatReadsTheInnermostFeaturingObject(t *testing.T) { + const src = ` + package test { + private import ScalarValues::Real; + part def Inner { attribute a : Real = 2.0; } + part def Outer { attribute a : Real = 9.0; part i : Inner { attribute b : Real = that.a; } } + part def Holder { part o : Outer; } + }` + inst, ctx := instantiatePart(t, "Holder", src) + i := fvInstance(t, ctx, inst, "o", "i") + fv, err := i.GetFeatureValue(ctx, "b") + if err != nil { + t.Fatalf("GetFeatureValue b: %v", err) + } + if got := realValue(t, fv.HeldValue()); got != 2.0 { + t.Errorf("b = %v, want the inner object's a (2.0)", got) + } +} + +// `self` names the object itself ([KerML] Base::Anything::self), as the library's +// `ScalarMeasurementReference::mRefs = self` relies on. +func TestSelfReadsTheObjectItself(t *testing.T) { + const src = ` + package test { + private import Base::Anything; + part def P { ref me : Anything = self; } + part holder { part p : P; } + }` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + inst := instantiateNamed(t, ctx, idx, "test::holder") + p := fvInstance(t, ctx, inst, "p") + fv, err := p.GetFeatureValue(ctx, "me") + if err != nil { + t.Fatalf("GetFeatureValue me: %v", err) + } + if got := fv.HeldValue(); got.Kind != ValInstance || got.Instance != p.ID { + t.Errorf("me = %v, want the object p itself (instance %d)", got, p.ID) + } +} diff --git a/internal/exec/runtime/tool.go b/internal/exec/runtime/tool.go new file mode 100644 index 0000000000..05d3d412ff --- /dev/null +++ b/internal/exec/runtime/tool.go @@ -0,0 +1,666 @@ +package runtime + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// The standard library's AnalysisTooling metadata a tool-computed action carries. +const ( + fqnToolExecution = "AnalysisTooling::ToolExecution" + fqnToolVariable = "AnalysisTooling::ToolVariable" + fqnSIPackage = "SI" + toolExecutionURI = "uri" + toolExecutionTool = "toolName" + toolVariableName = "name" +) + +// ToolCall is one performance of an action annotated ToolExecution, as the external tool +// sees it: the tool and URI the metadata names, the inputs read from the run keyed by +// their ToolVariable names, and the outputs the tool is to answer. +type ToolCall struct { + // Action is the annotated action definition or usage performed. + Action *symbols.Symbol + ToolName string + URI string + Inputs []ToolInput + Outputs []ToolOutput + + exec *ActionExecutor +} + +// ToolInput is one `in` or `inout` parameter's value, under its ToolVariable name. +type ToolInput struct { + Variable string + Parameter string + Value ToolValue +} + +// ToolOutput is one `out` or `inout` parameter the tool answers, under its ToolVariable name. +type ToolOutput struct { + Variable string + Parameter string + // Declared is the parameter's declaration, whose unit the tool's answer is converted to. + Declared *symbols.Symbol +} + +// ToolValue is one value as the tool protocol carries it: a number or truth in Value, or a +// string in Text when Value is invalid, and for a quantity the unit expression it is measured in. +type ToolValue struct { + Value semantics.Value + Text string + Unit string +} + +// ToolAnswer is what one invocation established: the values bound to the call's outputs by +// parameter name, and whether an earlier invocation with equal inputs answered differently. +type ToolAnswer struct { + Outputs map[string]Value + Diverged bool +} + +// ToolRunner runs the tool a ToolExecution names for one performance of the action, +// binding the tool's outputs through ToolCall.Bind. A context with no runner attached +// refuses every tool-computed action as not registered. +type ToolRunner interface { + RunTool(call *ToolCall) (ToolAnswer, error) +} + +// ErrToolNotRegistered is the typed error for a ToolExecution naming a tool no manifest entry registers. +var ErrToolNotRegistered = errors.New("tool is not registered") + +// ToolNotRegisteredError reports the tool a performance named and nothing answers to. +type ToolNotRegisteredError struct { + Tool string +} + +// Error is the refusal, naming the environment variable that registers tools. +func (e *ToolNotRegisteredError) Error() string { + return fmt.Sprintf("tool '%s' is not registered; set OPENSYSML_TOOLS", e.Tool) +} + +// Is matches ErrToolNotRegistered. +func (e *ToolNotRegisteredError) Is(target error) bool { return target == ErrToolNotRegistered } + +// ErrTool is the typed error every failure of a tool invocation unwraps to. +var ErrTool = errors.New("tool failed") + +// ToolErrorKind is what failed in one tool invocation. +type ToolErrorKind int + +const ( + // ToolProcessFailed is a process that could not be started or exited non-zero. + ToolProcessFailed ToolErrorKind = iota + // ToolMalformed is a reply that is not one JSON object of the protocol, or a value in it + // the receiving parameter cannot read. + ToolMalformed + // ToolMissingOutput is an `out` parameter the reply gave no value for. + ToolMissingOutput + // ToolUnknownOutput is a reply value no ToolVariable of the action receives. + ToolUnknownOutput + // ToolTimeout is a process that outlived OPENSYSML_TOOL_TIMEOUT. + ToolTimeout + // ToolRefused is a reply carrying the tool's own error message. + ToolRefused + // ToolUnsentInput is an input the protocol carries no value of. + ToolUnsentInput + // ToolAmbiguousVariable is a ToolVariable name two parameters of the action carry. + ToolAmbiguousVariable +) + +// String names the kind as the error spells it. +func (k ToolErrorKind) String() string { + switch k { + case ToolProcessFailed: + return "process failed" + case ToolMalformed: + return "malformed output" + case ToolMissingOutput: + return "missing output" + case ToolUnknownOutput: + return "unknown output" + case ToolTimeout: + return "timeout" + case ToolRefused: + return "tool error" + case ToolUnsentInput: + return "input not carried" + case ToolAmbiguousVariable: + return "ambiguous variable" + } + return "unknown failure" +} + +// ToolError reports one tool invocation's failure: the tool, what failed, and the detail. +type ToolError struct { + Tool string + Kind ToolErrorKind + Detail string +} + +// Error names the tool, the kind and the detail. +func (e *ToolError) Error() string { + return fmt.Sprintf("tool '%s': %s: %s", e.Tool, e.Kind, e.Detail) +} + +// Is matches ErrTool. +func (e *ToolError) Is(target error) bool { return target == ErrTool } + +// ToolDivergenceCode is the diagnostic code of a ToolDivergence note. +const ToolDivergenceCode = "tool-divergence" + +// ToolDivergence is a tool answering two invocations with equal inputs differently: the +// outcome table over it is not reproducible, and the run says so without changing. +type ToolDivergence struct { + Tool string + Action string + File string + Span source.Span +} + +// Describe renders the divergence for a diagnostic. +func (d ToolDivergence) Describe() string { + return fmt.Sprintf("tool '%s' answered differently for equal inputs at %s", d.Tool, d.Action) +} + +// String is the trace line the divergence is recorded as. +func (d ToolDivergence) String() string { + return "tool divergence: " + d.Describe() +} + +// Location is the annotated action's declaration. +func (d ToolDivergence) Location() (string, source.Span) { + return d.File, d.Span +} + +// Diagnostic is the divergence as a finding about the run, a warning since the +// results resting on the tool are not reproducible. +func (d ToolDivergence) Diagnostic() diag.Diagnostic { + return diag.Diagnostic{ + Severity: diag.SeverityWarning, + Span: d.Span, + Message: d.Describe(), + Code: ToolDivergenceCode, + Source: "runtime", + } +} + +// SetToolRunner attaches the runner tool-computed actions of this context's runs invoke. +func (ctx *Context) SetToolRunner(runner ToolRunner) { + ctx.tools = runner +} + +// ToolRunner is the runner attached, nil when none is. +func (ctx *Context) ToolRunner() ToolRunner { + return ctx.tools +} + +// toolExecution is the ToolExecution an action carries, as its performances read it: +// the tool and URI its bindings state, and the action or supertype annotated. +type toolExecution struct { + tool, uri string + on *symbols.Symbol +} + +// toolExecutionOf reads the ToolExecution annotating an action, or the definition it is +// typed by. Nil for an action carrying none; an annotation is one whatever its toolName. +func (ctx *Context) toolExecutionOf(action *symbols.Symbol) (*toolExecution, error) { + if ctx.model == nil || action == nil { + return nil, nil + } + if held, ok := ctx.model.toolExecutions[action]; ok { + return held, nil + } + inst, typ, on, err := ctx.annotationObject(action, fqnToolExecution) + if err != nil { + return nil, err + } + var held *toolExecution + if inst != nil { + held = &toolExecution{on: on} + if held.tool, err = ctx.metadataString(inst, typ, toolExecutionTool); err != nil { + return nil, err + } + if held.uri, err = ctx.metadataString(inst, typ, toolExecutionURI); err != nil { + return nil, err + } + } + ctx.model.toolExecutions[action] = held + return held, nil +} + +// toolVariableOf reads the ToolVariable annotating a parameter, or one it redefines: the +// name the tool calls it. False for a parameter carrying none. +func (ctx *Context) toolVariableOf(param *symbols.Symbol) (string, bool, error) { + inst, typ, _, err := ctx.annotationObject(param, fqnToolVariable) + if err != nil || inst == nil { + return "", false, err + } + name, err := ctx.metadataString(inst, typ, toolVariableName) + if err != nil { + return "", false, err + } + return name, true, nil +} + +// annotationObject is the object of the first annotation of the library metadata type fqn +// on an element, else on its supertypes nearest first, with the annotation's type and the +// element carrying it; nil when none does. +func (ctx *Context) annotationObject(element *symbols.Symbol, fqn string) (*Instance, *symbols.Symbol, *symbols.Symbol, error) { + elements := append([]*symbols.Symbol{element}, ctx.model.semantics.AllSupertypes(element)...) + for _, sym := range elements { + for i, annotation := range ctx.model.semantics.ElementMetadataOf(sym) { + if !ctx.metadataIs(annotation.Type, fqn) { + continue + } + inst, err := ctx.metadataObject(sym, i, annotation) + if err != nil { + return nil, nil, nil, err + } + return inst, annotation.Type, sym, nil + } + } + return nil, nil, nil, nil +} + +// metadataIs reports whether a metadata type is, or specializes, the library type named. +func (ctx *Context) metadataIs(typ *symbols.Symbol, fqn string) bool { + if typ == nil { + return false + } + if symbols.FQNOf(typ) == fqn { + return true + } + for _, super := range ctx.model.semantics.AllSupertypes(typ) { + if symbols.FQNOf(super) == fqn { + return true + } + } + return false +} + +// metadataObject is the object one annotation of an element denotes, as `.metadata` reads it. +func (ctx *Context) metadataObject(element *symbols.Symbol, index int, annotation semantics.ElementMetadata) (*Instance, error) { + val, err := NewEvalContextIn(ctx, element.OwnerScope, nil).metadataInstance(metadataAnnotation{element: element, index: index}, annotation) + if err != nil { + return nil, err + } + id, ok := val.Object() + if !ok { + return nil, fmt.Errorf("%w: metadata %s denotes no object", ErrTypeMismatch, ctx.qualifiedSymbolName(annotation.Type)) + } + inst, live := ctx.instances[id] + if !live || inst == nil { + return nil, fmt.Errorf("%w: metadata %s denotes no object", ErrTypeMismatch, ctx.qualifiedSymbolName(annotation.Type)) + } + return inst, nil +} + +// metadataString reads the string a metadata object's feature holds; one holding no string +// is a type mismatch, since the tool protocol has nothing else to pass through. +func (ctx *Context) metadataString(inst *Instance, typ *symbols.Symbol, feature string) (string, error) { + fv, err := inst.GetFeatureValue(ctx, feature) + if err != nil { + return "", fmt.Errorf("metadata %s: %s: %w", ctx.qualifiedSymbolName(typ), feature, err) + } + held := fv.Value + if held.Kind != ValString { + return "", fmt.Errorf("%w: metadata %s: %s holds %s, not a String", + ErrTypeMismatch, ctx.qualifiedSymbolName(typ), feature, describeValue(held)) + } + return held.Str(), nil +} + +// performByTool performs an action a ToolExecution annotates: its inputs are bound as any +// performance's are, the tool the metadata names is invoked once with them, and its outputs +// stand as the action's; the action's own flow is never run. +func (e *ActionExecutor) performByTool(execution *toolExecution) error { + defer e.ctx.beginExecutorRun(&e.driven)() + tool := execution.tool + // An annotation naming no tool names none registered; the body never stands in. + if tool == "" { + return &ToolNotRegisteredError{Tool: tool} + } + if err := e.checkResultParameters(); err != nil { + return err + } + e.ctx.beginPerformanceLife(e.occurrence, e.ctx.newActivation()) + defer e.ctx.endPerformanceLife(e.occurrence) + if err := e.bindInputs(); err != nil { + return err + } + call, err := e.toolCall(execution) + if err != nil { + return err + } + if e.ctx.tools == nil { + return &ToolNotRegisteredError{Tool: tool} + } + answer, err := e.ctx.tools.RunTool(call) + if err != nil { + return err + } + if err := e.setFrameFeatures(e.root, answer.Outputs); err != nil { + return err + } + if answer.Diverged { + e.ctx.note(ToolDivergence{ + Tool: tool, + Action: e.ctx.qualifiedSymbolName(execution.on), + File: execution.on.DocName, + Span: execution.on.DeclSpan, + }) + } + e.state = StateCompleted + return e.ctx.endedWhole(&e.driven) +} + +// toolCall is the performance as the tool sees it: of the action performed, every `in`/`inout` +// parameter carrying a ToolVariable is an input, every `out`/`inout` one an output. An unbound +// input is ErrUnboundParameter unless optional, which the call omits; a ToolVariable name two +// parameters carry is a ToolError, since the protocol keys by it. The declaration the tool +// binds (e.action) names and types each parameter, and the performance holds it under that name. +func (e *ActionExecutor) toolCall(execution *toolExecution) (*ToolCall, error) { + tool := execution.tool + call := &ToolCall{Action: execution.on, ToolName: tool, URI: execution.uri, exec: e} + namedBy := make(map[string]string) + for _, param := range e.ctx.model.semantics.BehaviorParametersOf(e.action) { + if param.Symbol == nil || param.Symbol.Name == "" { + continue + } + variable, named, err := e.ctx.toolVariableOf(param.Symbol) + if err != nil { + return nil, err + } + if !named { + continue + } + if other, taken := namedBy[variable]; taken { + return nil, &ToolError{Tool: tool, Kind: ToolAmbiguousVariable, + Detail: fmt.Sprintf("%s names both %s and %s of %s", variable, other, param.Symbol.Name, symbolText(e.performed))} + } + namedBy[variable] = param.Symbol.Name + name := param.Symbol.Name + reads := param.Direction == ast.DirIn || param.Direction == ast.DirInOut + writes := param.Direction == ast.DirOut || param.Direction == ast.DirInOut + if reads { + held, bound := e.root.data[e.root.key(name)] + if !bound && !e.ctx.model.semantics.OptionalParameter(param.Symbol) { + return nil, fmt.Errorf("%w: action %s: input parameter %s is bound by no argument", + ErrUnboundParameter, symbolText(e.performed), name) + } + if bound { + sent, err := toolInput(tool, param.Symbol, held) + if err != nil { + return nil, err + } + call.Inputs = append(call.Inputs, ToolInput{Variable: variable, Parameter: name, Value: sent}) + } + } + if writes { + call.Outputs = append(call.Outputs, ToolOutput{Variable: variable, Parameter: name, Declared: param.Symbol}) + } + } + sort.Slice(call.Inputs, func(i, j int) bool { return call.Inputs[i].Variable < call.Inputs[j].Variable }) + sort.Slice(call.Outputs, func(i, j int) bool { return call.Outputs[i].Variable < call.Outputs[j].Variable }) + return call, nil +} + +// toolPerformance is the declaration a tool binds for a performance of performed, of callee: +// performed where it or a supertype carries the ToolExecution, else callee where it does; nil without one. +func (ctx *Context) toolPerformance(performed, callee *symbols.Symbol) (*symbols.Symbol, *toolExecution, error) { + tool, err := ctx.toolExecutionOf(performed) + if err != nil { + return nil, nil, err + } + if tool != nil { + return performed, tool, nil + } + if performed != callee { + if tool, err = ctx.toolExecutionOf(callee); err != nil { + return nil, nil, err + } + if tool != nil { + return callee, tool, nil + } + } + return nil, nil, nil +} + +// performanceBody is the action a performance of performed, of callee, holds the features +// of: the body callee states, or under a tool, which runs no body, the declaration it binds. +func (ctx *Context) performanceBody(performed, callee *symbols.Symbol) (*symbols.Symbol, *toolExecution, error) { + held, tool, err := ctx.toolPerformance(performed, callee) + if err != nil { + return nil, nil, err + } + if tool != nil { + return held, tool, nil + } + return ctx.actionBodySymbol(callee), nil, nil +} + +// performanceInterface is the declaration whose parameters a performance of performed, of +// callee, takes and binds arguments by: the tool's under a tool, else callee. +func (ctx *Context) performanceInterface(performed, callee *symbols.Symbol) (*symbols.Symbol, error) { + held, tool, err := ctx.toolPerformance(performed, callee) + if err != nil { + return nil, err + } + if tool != nil { + return held, nil + } + return callee, nil +} + +// performanceParameters are the parameters of performanceInterface(performed, callee). +func (ctx *Context) performanceParameters(performed, callee *symbols.Symbol) ([]actionParameter, error) { + held, err := ctx.performanceInterface(performed, callee) + if err != nil { + return nil, err + } + return ctx.actionParametersOf(held), nil +} + +// toolInput is one parameter's value as the protocol carries it: a number, truth or string +// as is, a quantity as the run holds it, its unit spelt by short names (`km/h`). +func toolInput(tool string, param *symbols.Symbol, held Value) (ToolValue, error) { + if v, ok := ToolValueOf(held); ok { + return v, nil + } + return ToolValue{}, &ToolError{Tool: tool, Kind: ToolUnsentInput, + Detail: fmt.Sprintf("%s holds %s, which the protocol does not carry", param.Name, describeValue(held))} +} + +// ToolValueOf is a value as the tool protocol carries it; false for one it does not carry. +func ToolValueOf(held Value) (ToolValue, bool) { + switch held.Kind { + case ValConst: + if held.Const.Kind == semantics.ValInvalid || held.Const.Kind == semantics.ValInfinity { + break + } + return ToolValue{Value: held.Const}, true + case ValString: + return ToolValue{Text: held.Str()}, true + case ValQuantity: + q := held.Quantity() + return ToolValue{Value: q.Num, Unit: q.Unit.Product.ShortSpelling().String()}, true + } + return ToolValue{}, false +} + +// Bind reads the tool's outputs, keyed by ToolVariable name, as the values of the call's +// output parameters: each quantity converted to its parameter's declared unit. An output +// missing, unknown, or not readable as its parameter's value is a ToolError. +func (c *ToolCall) Bind(outputs map[string]ToolValue) (map[string]Value, error) { + byVariable := make(map[string]ToolOutput, len(c.Outputs)) + for _, out := range c.Outputs { + byVariable[out.Variable] = out + } + var unknown []string + for variable := range outputs { + if _, ok := byVariable[variable]; !ok { + unknown = append(unknown, variable) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return nil, &ToolError{Tool: c.ToolName, Kind: ToolUnknownOutput, + Detail: fmt.Sprintf("%s: no ToolVariable of %s receives it", strings.Join(unknown, ", "), symbolText(c.Action))} + } + bound := make(map[string]Value, len(c.Outputs)) + for _, out := range c.Outputs { + answered, ok := outputs[out.Variable] + if !ok { + return nil, &ToolError{Tool: c.ToolName, Kind: ToolMissingOutput, + Detail: fmt.Sprintf("%s (%s of %s) was not answered", out.Variable, out.Parameter, symbolText(c.Action))} + } + value, err := c.exec.toolOutput(c.ToolName, out, answered) + if err != nil { + return nil, err + } + bound[out.Parameter] = value + } + return bound, nil +} + +// toolOutput reads one answered value as the parameter's: a string or bare number as is, +// a quantity converted to the coherent unit of the parameter's declared quantity kind, +// spelt as the declared type prefers. A unit is refused unless the parameter is a quantity, +// and a value the parameter's declaration cannot hold is malformed. +func (e *ActionExecutor) toolOutput(tool string, out ToolOutput, answered ToolValue) (Value, error) { + malformed := func(format string, args ...any) error { + return &ToolError{Tool: tool, Kind: ToolMalformed, + Detail: out.Variable + ": " + fmt.Sprintf(format, args...)} + } + value, err := e.toolOutputValue(malformed, out, answered) + if err != nil { + return Value{}, err + } + mult, _ := e.ctx.extractMultiplicity(out.Declared) + target := &writeTarget{name: out.Parameter, typ: e.ctx.extractType(out.Declared), mult: mult} + if err := e.ctx.checkWrite(e.ctx.protocolScope(e.root.scope), out.Parameter, target, &value); err != nil { + return Value{}, malformed("%v", err) + } + return value, nil +} + +// protocolScope is the scope a tool's literals are typed in: the scalar library's, since a +// JSON number, boolean or string is its Integer, Real, Boolean or String whatever the model imports. +func (ctx *Context) protocolScope(fallback *symbols.Scope) *symbols.Scope { + if pkg := ctx.librarySymbol(scalarValuesPackageFQN); pkg != nil && pkg.Scope != nil { + return pkg.Scope + } + return fallback +} + +// toolOutputValue converts one answered value to the run's, by its unit and the +// parameter's declared quantity kind; malformed builds the refusal of one that cannot be. +func (e *ActionExecutor) toolOutputValue(malformed func(string, ...any) error, out ToolOutput, answered ToolValue) (Value, error) { + if answered.Value.Kind == semantics.ValInvalid { + if answered.Unit != "" { + return Value{}, malformed("text %q is measured in %s", answered.Text, answered.Unit) + } + return NewStringValue(answered.Text), nil + } + if answered.Unit == "" { + return Value{Kind: ValConst, Const: answered.Value}, nil + } + if !answered.Value.IsNumeric() { + return Value{}, malformed("a truth is measured in %s", answered.Unit) + } + unit, err := e.toolUnit(answered.Unit) + switch { + case errors.Is(err, ErrNoExpressionParser): + return Value{}, err + case err != nil: + return Value{}, malformed("%v", err) + } + q := Quantity{Num: answered.Value, Unit: unit} + dim, ok := e.ctx.model.semantics.DimensionOfFeature(out.Declared) + if !ok { + if !e.ctx.quantityTyped(out.Declared) { + return Value{}, malformed("%s is not a quantity to be measured in %s", out.Parameter, answered.Unit) + } + return quantityResult(q, nil) + } + coherent, ok := e.ctx.model.semantics.CoherentUnitFor(dim, out.Declared) + if !ok { + return NewQuantityValue(&q), nil + } + converted, err := semantics.ConvertQuantity(q, coherent) + if err != nil { + return Value{}, malformed("%s does not measure %s: %v", answered.Unit, out.Parameter, err) + } + return quantityResult(converted, nil) +} + +// quantityTyped reports a feature one of whose types is a scalar quantity value type, so it +// holds a measured number; ScalarQuantityValue itself counts, fixing no dimension. +func (ctx *Context) quantityTyped(feature *symbols.Symbol) bool { + scalar := ctx.librarySymbol(scalarQuantityTypeFQN) + if scalar == nil { + return false + } + for _, typ := range ctx.model.semantics.FeatureTypes(feature) { + if ctx.modelConforms(typ, scalar) { + return true + } + } + return false +} + +// toolUnitKey names a unit spelling read in one scope. +type toolUnitKey struct { + scope *symbols.Scope + text string +} + +// toolUnit reads a unit the protocol spells, in the action's scope, else in the library's +// SI package so a tool's `m/s**2` reads whatever the model imports; the reading is +// memoized per scope since resolution memoizes per parsed name. +func (e *ActionExecutor) toolUnit(text string) (semantics.Unit, error) { + return e.ctx.UnitOf(e.root.scope, text) +} + +// UnitOf reads a unit spelled as expression text in scope, else in the library's SI +// package, which a nil scope reads alone; the reading is memoized per scope. +func (ctx *Context) UnitOf(scope *symbols.Scope, text string) (semantics.Unit, error) { + key := toolUnitKey{scope: scope, text: text} + if unit, ok := ctx.model.toolUnits[key]; ok { + return unit, nil + } + expr, ok, err := ctx.model.parseOneExpression("", text) + if err != nil { + return semantics.Unit{}, err + } + if !ok { + return semantics.Unit{}, fmt.Errorf("%q is not a unit expression", text) + } + var unit semantics.Unit + err = fmt.Errorf("%w: no scope reads %s", semantics.ErrNotAUnit, text) + if scope != nil { + unit, err = ctx.model.semantics.UnitOfExpr(scope, expr) + } + if errors.Is(err, semantics.ErrNotAUnit) { + if si := ctx.librarySymbol(fqnSIPackage); si != nil && si.Scope != nil { + expr, _, _ = ctx.model.parseOneExpression("", text) + if inSI, siErr := ctx.model.semantics.UnitOfExpr(si.Scope, expr); siErr == nil { + unit, err = inSI, nil + } + } + } + if err != nil { + return semantics.Unit{}, fmt.Errorf("%q is not a unit: %w", text, err) + } + ctx.model.toolUnits[key] = unit + return unit, nil +} diff --git a/internal/exec/runtime/tool_test.go b/internal/exec/runtime/tool_test.go new file mode 100644 index 0000000000..7710bc536b --- /dev/null +++ b/internal/exec/runtime/tool_test.go @@ -0,0 +1,893 @@ +package runtime + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// toolModel is the pilot's AnalysisAnnotation fixture, which imports no units, with a driver that performs the +// annotated action with fixed inputs and exposes what it answered. +const toolModel = `package test { + private import ScalarValues::Real; + private import AnalysisTooling::*; + private import ISQ::*; + + action def ComputeDynamics { + metadata ToolExecution { + toolName = "ModelCenter"; + uri = "aserv://localhost/Vehicle/Equation1"; + } + + in dt : TimeValue { @ToolVariable { name = "deltaT"; } } + in whlpwr : PowerValue { @ToolVariable { name = "power"; } } + in Cd : Real { @ToolVariable { name = "C_D"; } } + in Cf: Real { @ToolVariable { name = "C_F"; } } + in tm : MassValue { @ToolVariable { name = "mass"; } } + in v_in : SpeedValue { @ToolVariable { name = "v0"; } } + in x_in : LengthValue { @ToolVariable { name = "x0"; } } + + out a_out : AccelerationValue { @ToolVariable { name = "a"; } } + out v_out : SpeedValue { @ToolVariable { name = "v"; } } + out x_out : LengthValue { @ToolVariable { name = "x"; } } + } + + action def Drive { + out a : AccelerationValue; + out v : SpeedValue; + out x : LengthValue; + action step : ComputeDynamics { + in dt = 1 [SI::s]; + in whlpwr = 2 [SI::kW]; + in Cd = 0.3; + in Cf = 0.01; + in tm = 1500 [SI::kg]; + in v_in = 36 [SI::km / SI::h]; + in x_in = 100 [SI::m]; + } + bind a = step.a_out; + bind v = step.v_out; + bind x = step.x_out; + } +}` + +// recordingRunner answers every call from a table, keeping the calls it saw. +type recordingRunner struct { + calls []*ToolCall + answer map[string]ToolValue + err error +} + +func (r *recordingRunner) RunTool(call *ToolCall) (ToolAnswer, error) { + r.calls = append(r.calls, call) + if r.err != nil { + return ToolAnswer{}, r.err + } + outputs, err := call.Bind(r.answer) + return ToolAnswer{Outputs: outputs}, err +} + +func toolReal(x float64) semantics.Value { return semantics.Value{Kind: semantics.ValReal, Real: x} } + +// The annotated action's performance goes to the tool: inputs in the coherent unit of their +// parameter's dimension under their ToolVariable names, outputs converted to the parameters' +// declared units and propagated to the caller; its body is never run. +func TestToolExecutionPerformsThroughTheRunner(t *testing.T) { + ctx, scope := analysisFixture(t, toolModel) + runner := &recordingRunner{answer: map[string]ToolValue{ + "a": {Value: toolReal(2), Unit: "m/s**2"}, + "v": {Value: toolReal(43.2), Unit: "km/h"}, + "x": {Value: toolReal(11000), Unit: "cm"}, + }} + ctx.SetToolRunner(runner) + drive := calcNamed(t, scope, "Drive") + + out, err := ctx.ExecuteAction(drive) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if len(runner.calls) != 1 { + t.Fatalf("tool invoked %d times, want once", len(runner.calls)) + } + call := runner.calls[0] + if call.ToolName != "ModelCenter" || call.URI != "aserv://localhost/Vehicle/Equation1" { + t.Fatalf("call names %q at %q", call.ToolName, call.URI) + } + inputs := map[string]ToolValue{} + for _, in := range call.Inputs { + inputs[in.Variable] = in.Value + } + want := map[string]ToolValue{ + "deltaT": {Value: toolReal(1), Unit: "s"}, + "power": {Value: toolReal(2), Unit: "kW"}, + "C_D": {Value: toolReal(0.3)}, + "C_F": {Value: toolReal(0.01)}, + "mass": {Value: toolReal(1500), Unit: "kg"}, + "v0": {Value: toolReal(36), Unit: "km/h"}, + "x0": {Value: toolReal(100), Unit: "m"}, + } + for name, w := range want { + got, ok := inputs[name] + if !ok { + t.Fatalf("input %s not sent; sent %v", name, inputs) + } + if got.Unit != w.Unit || !nearly(got.Value, w.Value) { + t.Errorf("input %s = %v [%s], want %v [%s]", name, got.Value, got.Unit, w.Value, w.Unit) + } + } + if len(call.Outputs) != 3 { + t.Fatalf("outputs asked: %v", call.Outputs) + } + for name, w := range map[string]string{"a": "2.0 [SI::'m⋅s⁻²']", "v": "12.0 [SI::'m/s']", "x": "110.0 [SI::m]"} { + got, ok := out[name] + if !ok { + t.Fatalf("output %s not propagated; got %v", name, out) + } + if FormatValue(got) != w { + t.Errorf("%s = %s, want %s", name, FormatValue(got), w) + } + } +} + +// nearly compares two numbers as the protocol carries them. +func nearly(a, b semantics.Value) bool { + af, bf := a.Real, b.Real + if a.Kind == semantics.ValInt { + af = float64(a.Int) + } + if b.Kind == semantics.ValInt { + bf = float64(b.Int) + } + d := af - bf + return d < 1e-9 && d > -1e-9 +} + +// A context with no runner refuses the performance as the registry would: the named +// tool is not registered, and the body is not run in its place. +func TestToolExecutionWithoutRunnerIsNotRegistered(t *testing.T) { + ctx, scope := analysisFixture(t, toolModel) + _, err := ctx.ExecuteAction(calcNamed(t, scope, "Drive")) + var refusal *ToolNotRegisteredError + if !errors.As(err, &refusal) || !errors.Is(err, ErrToolNotRegistered) { + t.Fatalf("ExecuteAction = %v, want ToolNotRegisteredError", err) + } + if want := "tool 'ModelCenter' is not registered; set OPENSYSML_TOOLS"; !strings.Contains(err.Error(), want) { + t.Fatalf("error %q does not carry %q", err, want) + } +} + +// What the tool answers is checked against the action: an output missing, one no +// ToolVariable receives, or one in a unit the parameter does not measure is a ToolError +// of its kind, and the runner's own failure is the performance's. +func TestToolExecutionRefusesBadAnswers(t *testing.T) { + good := map[string]ToolValue{ + "a": {Value: toolReal(2), Unit: "m/s**2"}, + "v": {Value: toolReal(12), Unit: "m/s"}, + "x": {Value: toolReal(110), Unit: "m"}, + } + cases := []struct { + name string + answer map[string]ToolValue + kind ToolErrorKind + }{ + {"missing", map[string]ToolValue{"a": good["a"], "v": good["v"]}, ToolMissingOutput}, + {"unknown", map[string]ToolValue{"a": good["a"], "v": good["v"], "x": good["x"], "y": good["x"]}, ToolUnknownOutput}, + {"wrong dimension", map[string]ToolValue{"a": {Value: toolReal(2), Unit: "kg"}, "v": good["v"], "x": good["x"]}, ToolMalformed}, + {"not a unit", map[string]ToolValue{"a": {Value: toolReal(2), Unit: "m/s**"}, "v": good["v"], "x": good["x"]}, ToolMalformed}, + {"text measured", map[string]ToolValue{"a": {Text: "fast", Unit: "m"}, "v": good["v"], "x": good["x"]}, ToolMalformed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, scope := analysisFixture(t, toolModel) + ctx.SetToolRunner(&recordingRunner{answer: tc.answer}) + _, err := ctx.ExecuteAction(calcNamed(t, scope, "Drive")) + var failure *ToolError + if !errors.As(err, &failure) || !errors.Is(err, ErrTool) { + t.Fatalf("ExecuteAction = %v, want ToolError", err) + } + if failure.Kind != tc.kind || failure.Tool != "ModelCenter" { + t.Fatalf("ToolError = %v, want kind %s", failure, tc.kind) + } + }) + } + t.Run("runner failure", func(t *testing.T) { + ctx, scope := analysisFixture(t, toolModel) + fault := &ToolError{Tool: "ModelCenter", Kind: ToolTimeout, Detail: "10s"} + ctx.SetToolRunner(&recordingRunner{err: fault}) + _, err := ctx.ExecuteAction(calcNamed(t, scope, "Drive")) + if !errors.Is(err, fault) { + t.Fatalf("ExecuteAction = %v, want %v", err, fault) + } + }) +} + +// A unit on an answer is read against the parameter: a Real is no quantity, so a measured +// number is a malformed answer, while a ScalarQuantityValue of no fixed dimension keeps the +// unit as answered. +func TestToolOutputUnitsFollowTheParameterType(t *testing.T) { + t.Run("real measured", func(t *testing.T) { + ctx, scope := analysisFixture(t, scaleModel) + ctx.SetToolRunner(&recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4), Unit: "kg"}}}) + _, err := ctx.ExecuteAction(calcNamed(t, scope, "Scaled")) + var failure *ToolError + if !errors.As(err, &failure) || failure.Kind != ToolMalformed { + t.Fatalf("ExecuteAction = %v, want a malformed ToolError", err) + } + if want := "y: y is not a quantity to be measured in kg"; failure.Detail != want { + t.Fatalf("detail %q, want %q", failure.Detail, want) + } + }) + t.Run("any quantity", func(t *testing.T) { + ctx, scope := analysisFixture(t, quantityOutputModel) + ctx.SetToolRunner(&recordingRunner{answer: map[string]ToolValue{"q": {Value: toolReal(4), Unit: "km"}}}) + out, err := ctx.ExecuteAction(calcNamed(t, scope, "Measured")) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if got := FormatValue(out["q"]); got != "4.0 [km]" { + t.Fatalf("q = %s, want 4.0 [km]", got) + } + }) +} + +const quantityOutputModel = `package test { + private import Quantities::ScalarQuantityValue; + private import AnalysisTooling::*; + + action def Measure { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + out q : ScalarQuantityValue { @ToolVariable { name = "q"; } } + } + action def Measured { out q : ScalarQuantityValue; action s : Measure; bind q = s.q; } +}` + +// A runner reporting that equal inputs were answered differently leaves the run a note. +func TestToolExecutionNotesDivergence(t *testing.T) { + ctx, scope := analysisFixture(t, toolModel) + ctx.SetToolRunner(divergingRunner{}) + if _, err := ctx.ExecuteAction(calcNamed(t, scope, "Drive")); err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + notes := ctx.Notes() + if len(notes) != 1 { + t.Fatalf("notes = %v, want one divergence", notes) + } + d, ok := notes[0].(ToolDivergence) + if !ok || d.Tool != "ModelCenter" || !strings.Contains(d.Describe(), "answered differently for equal inputs") { + t.Fatalf("note = %#v", notes[0]) + } + if diag := d.Diagnostic(); diag.Code != ToolDivergenceCode { + t.Fatalf("diagnostic code %q", diag.Code) + } +} + +// divergingRunner answers well, spelling units as the run prints them, and reports the +// answer as having changed. +type divergingRunner struct{} + +func (divergingRunner) RunTool(call *ToolCall) (ToolAnswer, error) { + outputs, err := call.Bind(map[string]ToolValue{ + "a": {Value: toolReal(2), Unit: "SI::'m⋅s⁻²'"}, + "v": {Value: toolReal(12), Unit: "SI::'m/s'"}, + "x": {Value: toolReal(110), Unit: "m"}, + }) + return ToolAnswer{Outputs: outputs, Diverged: true}, err +} + +// scaleModel is a tool-computed action with a required and an optional input, driven with +// and without each, and variants whose ToolVariable names collide. +const scaleModel = `package test { + private import ScalarValues::Real; + private import AnalysisTooling::*; + + action def Scale { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in k : Real { @ToolVariable { name = "k"; } } + in bias : Real [0..1] { @ToolVariable { name = "bias"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + action def Scaled { out y : Real; action s : Scale { in k = 2.0; } bind y = s.y; } + action def Biased { out y : Real; action s : Scale { in k = 2.0; in bias = 1.0; } bind y = s.y; } + action def Unscaled { out y : Real; action s : Scale { in bias :>> bias = 1.0; } bind y = s.y; } + + action def TwoInputs { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in a : Real { @ToolVariable { name = "x"; } } + in b : Real { @ToolVariable { name = "x"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + action def TwoOutputs { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in a : Real { @ToolVariable { name = "x"; } } + out y : Real { @ToolVariable { name = "y"; } } + out z : Real { @ToolVariable { name = "y"; } } + } + action def InAndOut { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in a : Real { @ToolVariable { name = "x"; } } + out y : Real { @ToolVariable { name = "x"; } } + } + action def Collides { out y : Real; action s : InAndOut { in a = 1.0; } bind y = s.y; } + + action def Doubling { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in k : Real = 2.0 { @ToolVariable { name = "k"; } } + out y : Real { @ToolVariable { name = "y"; } } + } +}` + +// An input carrying a ToolVariable that no argument binds is ErrUnboundParameter before +// any tool runs, as it is for an action run by its body; an optional one is omitted from +// the call, never sent as an invented value. +func TestToolCallInputsFollowTheParameters(t *testing.T) { + t.Run("optional omitted", func(t *testing.T) { + ctx, scope := analysisFixture(t, scaleModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + if _, err := ctx.ExecuteAction(calcNamed(t, scope, "Scaled")); err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if len(runner.calls) != 1 || len(runner.calls[0].Inputs) != 1 || runner.calls[0].Inputs[0].Variable != "k" { + t.Fatalf("calls %+v, want one sending k alone", runner.calls) + } + }) + t.Run("optional sent", func(t *testing.T) { + ctx, scope := analysisFixture(t, scaleModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + if _, err := ctx.ExecuteAction(calcNamed(t, scope, "Biased")); err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if len(runner.calls) != 1 || len(runner.calls[0].Inputs) != 2 { + t.Fatalf("calls %+v, want one sending k and bias", runner.calls) + } + }) + t.Run("required unbound", func(t *testing.T) { + ctx, scope := analysisFixture(t, scaleModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + _, err := ctx.ExecuteAction(calcNamed(t, scope, "Unscaled")) + if !errors.Is(err, ErrUnboundParameter) || !strings.Contains(err.Error(), "input parameter k is bound by no argument") { + t.Fatalf("ExecuteAction = %v, want ErrUnboundParameter for k", err) + } + if len(runner.calls) != 0 { + t.Fatalf("tool ran %d times for an action missing a required input", len(runner.calls)) + } + }) +} + +// Two parameters carrying one ToolVariable name would share a key on the wire: the +// performance is a ToolError naming both, and the tool is not run. +func TestToolCallRefusesAmbiguousVariables(t *testing.T) { + cases := map[string]struct { + action, detail string + inputs map[string]Value + }{ + "two inputs": {"TwoInputs", "x names both a and b", map[string]Value{"a": realOf(1), "b": realOf(2)}}, + "two outputs": {"TwoOutputs", "y names both y and z", map[string]Value{"a": realOf(1)}}, + "in and out": {"InAndOut", "x names both a and y", map[string]Value{"a": realOf(1)}}, + "via a driver": {"Collides", "x names both a and y", nil}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ctx, scope := analysisFixture(t, scaleModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(1)}}} + ctx.SetToolRunner(runner) + _, err := ctx.ExecuteActionWithInputs(calcNamed(t, scope, tc.action), tc.inputs) + var fault *ToolError + if !errors.As(err, &fault) || fault.Kind != ToolAmbiguousVariable || !strings.Contains(fault.Detail, tc.detail) { + t.Fatalf("ExecuteAction = %v, want ToolError %q", err, tc.detail) + } + if len(runner.calls) != 0 { + t.Fatalf("tool ran %d times under an ambiguous variable", len(runner.calls)) + } + }) + } +} + +// A debugger's executor of a tool-computed action is the same performance: created, the tool +// has run once and the executor is completed with its outputs, with no flow to step; without +// a runner its creation is the same refusal a run's performance is. +func TestToolExecutionThroughTheDebuggerExecutor(t *testing.T) { + ctx, scope := analysisFixture(t, scaleModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + scale := calcNamed(t, scope, "Doubling") + + exec, err := ctx.CreateActionExecutor(scale) + if err != nil { + t.Fatalf("CreateActionExecutor: %v", err) + } + defer exec.Release() + if exec.State() != StateCompleted || len(exec.Tokens()) != 0 { + t.Fatalf("state %s with %d tokens, want completed with none", exec.State(), len(exec.Tokens())) + } + if len(runner.calls) != 1 { + t.Fatalf("tool invoked %d times, want once", len(runner.calls)) + } + if got := FormatValue(exec.Results()["y"]); got != "4.0" { + t.Fatalf("y = %s, want 4.0", got) + } + if err := exec.Step(); err != nil || len(runner.calls) != 1 { + t.Fatalf("stepping a completed executor: %v, %d invocations", err, len(runner.calls)) + } + + bare, _ := analysisFixture(t, scaleModel) + _, err = bare.CreateActionExecutor(scale) + if !errors.Is(err, ErrToolNotRegistered) { + t.Fatalf("CreateActionExecutor without a runner = %v, want ErrToolNotRegistered", err) + } +} + +// annotatedUsageModel carries ToolExecution on performed usages rather than definitions, +// on a definition whose toolName is empty, and on the action an object performs. +const annotatedUsageModel = `package test { + private import ScalarValues::Real; + private import AnalysisTooling::*; + + action def Plain { in k : Real; out y : Real; } + action def Stepped { in k : Real; out y : Real; first start; then done; } + + action def UsageOfPlain { + out y : Real; + action s : Plain { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in k :>> k = 2.0 { @ToolVariable { name = "k"; } } + out y :>> y { @ToolVariable { name = "y"; } } + } + bind y = s.y; + } + action def UsageOfStepped { + out y : Real; + action s : Stepped { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in k :>> k = 2.0 { @ToolVariable { name = "k"; } } + out y :>> y { @ToolVariable { name = "y"; } } + } + bind y = s.y; + } + + action def Nameless { + metadata ToolExecution { toolName = ""; uri = "u"; } + out y : Real = 1.0; + first start; then done; + } + + action def Doubling { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in k : Real = 2.0 { @ToolVariable { name = "k"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + action def Renamed { + out y : Real; + action s : Doubling { in kk :>> k = 3.0; out yy :>> y; } + bind y = s.yy; + } + action def RenamedAndRenamedVariables { + out y : Real; + action s : Stepped { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in kk :>> k = 3.0 { @ToolVariable { name = "kay"; } } + out yy :>> y { @ToolVariable { name = "why"; } } + } + bind y = s.y; + } + part def Rig { perform action scale : Doubling; } + part def AnnotatedRig { + perform action scale : Stepped { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in k :>> k = 2.0 { @ToolVariable { name = "k"; } } + out y :>> y { @ToolVariable { name = "y"; } } + } + } +}` + +// A ToolExecution on a usage typed by a definition holds whether the definition states a +// body or not: the usage's own parameters name the variables, and the body never runs. +func TestToolExecutionOnATypedUsage(t *testing.T) { + for _, driver := range []string{"UsageOfPlain", "UsageOfStepped"} { + t.Run(driver, func(t *testing.T) { + ctx, scope := analysisFixture(t, annotatedUsageModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + out, err := ctx.ExecuteAction(calcNamed(t, scope, driver)) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if len(runner.calls) != 1 { + t.Fatalf("tool invoked %d times, want once", len(runner.calls)) + } + call := runner.calls[0] + if call.Action.Name != "s" || len(call.Inputs) != 1 || call.Inputs[0].Variable != "k" || !nearly(call.Inputs[0].Value.Value, toolReal(2)) { + t.Fatalf("call %+v, want s sending k = 2.0", call) + } + if got := FormatValue(out["y"]); got != "4.0" { + t.Fatalf("y = %s, want 4.0", got) + } + + bare, bareScope := analysisFixture(t, annotatedUsageModel) + if _, err := bare.ExecuteAction(calcNamed(t, bareScope, driver)); !errors.Is(err, ErrToolNotRegistered) { + t.Fatalf("ExecuteAction without a runner = %v, want ErrToolNotRegistered", err) + } + }) + } +} + +// A usage redefining the parameters under new names (`in kk :>> k`) holds them under its +// own names: the tool receives the redefined input's value under the variable the +// redefined parameter names, and its answer reaches the caller under either name. +func TestToolExecutionOnRenamedParameters(t *testing.T) { + cases := []struct { + driver, input, output string + }{ + {"Renamed", "k", "y"}, + {"RenamedAndRenamedVariables", "kay", "why"}, + } + for _, tc := range cases { + t.Run(tc.driver, func(t *testing.T) { + ctx, scope := analysisFixture(t, annotatedUsageModel) + runner := &recordingRunner{answer: map[string]ToolValue{tc.output: {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + out, err := ctx.ExecuteAction(calcNamed(t, scope, tc.driver)) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if len(runner.calls) != 1 { + t.Fatalf("tool invoked %d times, want once", len(runner.calls)) + } + call := runner.calls[0] + if len(call.Inputs) != 1 || call.Inputs[0].Variable != tc.input || call.Inputs[0].Parameter != "kk" || + !nearly(call.Inputs[0].Value.Value, toolReal(3)) { + t.Fatalf("inputs %+v, want %s = 3.0 held as kk", call.Inputs, tc.input) + } + if len(call.Outputs) != 1 || call.Outputs[0].Variable != tc.output || call.Outputs[0].Parameter != "yy" || + call.Outputs[0].Declared == nil || call.Outputs[0].Declared.Name != "yy" { + t.Fatalf("outputs %+v, want %s held as yy, declared by yy", call.Outputs, tc.output) + } + for _, name := range []string{"y", "s.yy"} { + if got := FormatValue(out[name]); got != "4.0" { + t.Fatalf("%s = %s, want 4.0", name, got) + } + } + }) + } +} + +// specializedModel annotates specializations of actions stating a body, each adding +// parameters of its own after the inherited ones it restates. +const specializedModel = `package test { + private import ScalarValues::Real; + private import AnalysisTooling::*; + + action def Base { first start; then done; } + action def Sized { in n : Real { @ToolVariable { name = "n"; } } first start; then done; } + + action def Run : Base { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in x : Real { @ToolVariable { name = "x"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + action def RunSized : Sized { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in n :>> n; + in x : Real { @ToolVariable { name = "x"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + action def Driving { + in a : Real = 2.0; + out y : Real; + action s : RunSized { in n = 7.0; in x = a; } + bind y = s.y; + } + action def DrivingUsage { + in a : Real = 2.0; + out y : Real; + action s : Base { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in x : Real = a { @ToolVariable { name = "x"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + bind y = s.y; + } +}` + +// An annotated action inheriting its body from a general one performs its own interface, +// the parameters it adds included: directly and as a step, each is bound, sent to the tool +// and answered under its own name. +func TestToolExecutionOnASpecializationAddingParameters(t *testing.T) { + cases := map[string]struct { + driver string + inputs map[string]Value + sent []string + }{ + "direct": {"Run", map[string]Value{"x": realOf(2)}, []string{"x"}}, + "direct inherited": {"RunSized", map[string]Value{"x": realOf(2), "n": realOf(7)}, []string{"n", "x"}}, + "nested": {"Driving", nil, []string{"n", "x"}}, + "nested usage": {"DrivingUsage", nil, []string{"x"}}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ctx, scope := analysisFixture(t, specializedModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + out, err := ctx.ExecuteActionWithInputs(calcNamed(t, scope, tc.driver), tc.inputs) + if err != nil { + t.Fatalf("ExecuteActionWithInputs: %v", err) + } + if len(runner.calls) != 1 { + t.Fatalf("tool invoked %d times, want once", len(runner.calls)) + } + call := runner.calls[0] + var sent []string + for _, in := range call.Inputs { + if in.Variable != in.Parameter { + t.Fatalf("input %+v held under another name than its own", in) + } + sent = append(sent, in.Variable) + } + if !slices.Equal(sent, tc.sent) { + t.Fatalf("inputs %+v, want %v", call.Inputs, tc.sent) + } + if len(call.Outputs) != 1 || call.Outputs[0].Variable != "y" || call.Outputs[0].Parameter != "y" { + t.Fatalf("outputs %+v, want y held as y", call.Outputs) + } + if got := FormatValue(out["y"]); got != "4.0" { + t.Fatalf("y = %s, want 4.0", got) + } + }) + } + t.Run("added input unbound", func(t *testing.T) { + ctx, scope := analysisFixture(t, specializedModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + _, err := ctx.ExecuteActionWithInputs(calcNamed(t, scope, "RunSized"), map[string]Value{"n": realOf(7)}) + if !errors.Is(err, ErrUnboundParameter) || !strings.Contains(err.Error(), "input parameter x is bound by no argument") { + t.Fatalf("ExecuteActionWithInputs = %v, want ErrUnboundParameter for x", err) + } + if len(runner.calls) != 0 { + t.Fatalf("tool ran %d times for an action missing a required input", len(runner.calls)) + } + }) +} + +// expressionCallModel performs annotated actions as `Callee(...)` invocations: of an +// annotated definition, and of a usage carrying the annotation and parameters of its own. +const expressionCallModel = `package test { + private import ScalarValues::Real; + private import AnalysisTooling::*; + + action def Doubling { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in k : Real { @ToolVariable { name = "k"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + action def Base { first start; then done; } + + action def CallingTheDefinition { + out y : Real; + action s = Doubling(3.0); + bind y = s.y; + } + action def CallingWithAnAddedInput { + out y : Real; + action s = Base(5.0) { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in x : Real { @ToolVariable { name = "x"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + bind y = s.y; + } + action def CallingWithAnAddedInputByName { + out y : Real; + action s = Base(x = 5.0) { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in x : Real { @ToolVariable { name = "x"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + bind y = s.y; + } + action def CallingWithTooManyArguments { + out y : Real; + action s = Base(5.0, 6.0) { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in x : Real { @ToolVariable { name = "x"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + bind y = s.y; + } + action def CallingAnUnknownParameter { + out y : Real; + action s = Base(z = 5.0) { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in x : Real { @ToolVariable { name = "x"; } } + out y : Real { @ToolVariable { name = "y"; } } + } + bind y = s.y; + } +}` + +// A `Callee(...)` step binds its arguments to the parameters the tool binds: the callee's +// where the callee is annotated, the step's own where the step is — so an input the step +// adds takes a positional or named argument the callee has no parameter for. +func TestToolExecutionOnAnExpressionCall(t *testing.T) { + cases := map[string]struct { + driver, variable string + value float64 + }{ + "annotated definition": {"CallingTheDefinition", "k", 3}, + "added input": {"CallingWithAnAddedInput", "x", 5}, + "added input by name": {"CallingWithAnAddedInputByName", "x", 5}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ctx, scope := analysisFixture(t, expressionCallModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + out, err := ctx.ExecuteAction(calcNamed(t, scope, tc.driver)) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if len(runner.calls) != 1 { + t.Fatalf("tool invoked %d times, want once", len(runner.calls)) + } + call := runner.calls[0] + if len(call.Inputs) != 1 || call.Inputs[0].Variable != tc.variable || call.Inputs[0].Parameter != tc.variable || + !nearly(call.Inputs[0].Value.Value, toolReal(tc.value)) { + t.Fatalf("inputs %+v, want %s = %v", call.Inputs, tc.variable, tc.value) + } + if len(call.Outputs) != 1 || call.Outputs[0].Variable != "y" || call.Outputs[0].Parameter != "y" { + t.Fatalf("outputs %+v, want y held as y", call.Outputs) + } + for _, name := range []string{"y", "s.y"} { + if got := FormatValue(out[name]); got != "4.0" { + t.Fatalf("%s = %s, want 4.0", name, got) + } + } + }) + } + refusals := map[string]struct { + driver string + want error + }{ + "too many arguments": {"CallingWithTooManyArguments", ErrActionArity}, + "unknown parameter": {"CallingAnUnknownParameter", ErrUnknownParameter}, + } + for name, tc := range refusals { + t.Run(name, func(t *testing.T) { + ctx, scope := analysisFixture(t, expressionCallModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + _, err := ctx.ExecuteAction(calcNamed(t, scope, tc.driver)) + if !errors.Is(err, tc.want) { + t.Fatalf("ExecuteAction = %v, want %v", err, tc.want) + } + if len(runner.calls) != 0 { + t.Fatalf("tool ran %d times for a call binding no argument list", len(runner.calls)) + } + }) + } +} + +// An answered value the parameter's declaration cannot hold is a malformed answer, whatever +// its kind: the refusal is a ToolError, not the frame's type error. +func TestToolOutputMustFitTheParameter(t *testing.T) { + cases := map[string]ToolValue{ + "truth": {Value: semantics.Value{Kind: semantics.ValBool, Bool: true}}, + "text": {Text: "four"}, + "real": {Value: toolReal(4.5)}, + } + for name, answered := range cases { + t.Run(name, func(t *testing.T) { + ctx, scope := analysisFixture(t, typedOutputsModel) + ctx.SetToolRunner(&recordingRunner{answer: map[string]ToolValue{"n": answered, "label": {Text: "ok"}}}) + _, err := ctx.ExecuteAction(calcNamed(t, scope, "Count")) + var failure *ToolError + if !errors.As(err, &failure) || !errors.Is(err, ErrTool) || failure.Kind != ToolMalformed { + t.Fatalf("ExecuteAction = %v, want a malformed ToolError", err) + } + if !strings.HasPrefix(failure.Detail, "n: ") { + t.Fatalf("detail %q, want it to name the output n", failure.Detail) + } + }) + } + t.Run("integer for a string", func(t *testing.T) { + ctx, scope := analysisFixture(t, typedOutputsModel) + ctx.SetToolRunner(&recordingRunner{answer: map[string]ToolValue{ + "n": {Value: semantics.Value{Kind: semantics.ValInt, Int: 4}}, "label": {Value: toolReal(1)}, + }}) + _, err := ctx.ExecuteAction(calcNamed(t, scope, "Count")) + var failure *ToolError + if !errors.As(err, &failure) || failure.Kind != ToolMalformed || !strings.HasPrefix(failure.Detail, "label: ") { + t.Fatalf("ExecuteAction = %v, want a malformed ToolError naming label", err) + } + }) + t.Run("fitting", func(t *testing.T) { + ctx, scope := analysisFixture(t, typedOutputsModel) + ctx.SetToolRunner(&recordingRunner{answer: map[string]ToolValue{ + "n": {Value: semantics.Value{Kind: semantics.ValInt, Int: 4}}, "label": {Text: "ok"}, + }}) + out, err := ctx.ExecuteAction(calcNamed(t, scope, "Count")) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if got := FormatValue(out["n"]); got != "4" { + t.Fatalf("n = %s, want 4", got) + } + if got := FormatValue(out["label"]); got != `"ok"` { + t.Fatalf("label = %s, want \"ok\"", got) + } + }) +} + +const typedOutputsModel = `package test { + private import ScalarValues::*; + private import AnalysisTooling::*; + + action def Counter { + metadata ToolExecution { toolName = "MC"; uri = "u"; } + in k : Real { @ToolVariable { name = "k"; } } + out n : Integer { @ToolVariable { name = "n"; } } + out label : String { @ToolVariable { name = "label"; } } + } + action def Count { + out n : Integer; + out label : String; + action s : Counter { in k = 2.0; } + bind n = s.n; + bind label = s.label; + } +}` + +// A ToolExecution naming no tool is still one: the action is refused as not registered, +// with or without a runner, and its body does not stand in. +func TestToolExecutionWithAnEmptyNameRunsNoBody(t *testing.T) { + for _, withRunner := range []bool{true, false} { + ctx, scope := analysisFixture(t, annotatedUsageModel) + runner := &recordingRunner{answer: map[string]ToolValue{}} + if withRunner { + ctx.SetToolRunner(runner) + } + out, err := ctx.ExecuteAction(calcNamed(t, scope, "Nameless")) + var refused *ToolNotRegisteredError + if !errors.As(err, &refused) || refused.Tool != "" { + t.Fatalf("runner attached %v: ExecuteAction = %v, %v; want tool '' not registered", withRunner, out, err) + } + if len(runner.calls) != 0 { + t.Fatalf("tool invoked %d times for an empty toolName", len(runner.calls)) + } + } +} + +// An object performing an annotated action performs it by the tool, the annotation on the +// definition it names or on the performed usage itself; without a runner it is not created. +func TestToolExecutionOfAPerformedAction(t *testing.T) { + for _, rig := range []string{"Rig", "AnnotatedRig"} { + t.Run(rig, func(t *testing.T) { + ctx, scope := analysisFixture(t, annotatedUsageModel) + runner := &recordingRunner{answer: map[string]ToolValue{"y": {Value: toolReal(4)}}} + ctx.SetToolRunner(runner) + inst, err := ctx.Instantiate(calcNamed(t, scope, rig)) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if len(runner.calls) != 1 { + t.Fatalf("tool invoked %d times, want once", len(runner.calls)) + } + behavior, ok := inst.Behavior("scale") + if !ok || behavior.Action == nil || behavior.Action.State() != StateCompleted { + t.Fatalf("scale is not a completed performance: %v", inst.Behaviors()) + } + if got := FormatValue(performedFeature(t, ctx, inst, "scale", "y")); got != "4.0" { + t.Fatalf("scale.y = %s, want 4.0", got) + } + + bare, bareScope := analysisFixture(t, annotatedUsageModel) + if _, err := bare.Instantiate(calcNamed(t, bareScope, rig)); !errors.Is(err, ErrToolNotRegistered) { + t.Fatalf("Instantiate without a runner = %v, want ErrToolNotRegistered", err) + } + }) + } +} diff --git a/internal/exec/runtime/trace.go b/internal/exec/runtime/trace.go new file mode 100644 index 0000000000..0409d1717a --- /dev/null +++ b/internal/exec/runtime/trace.go @@ -0,0 +1,800 @@ +package runtime + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// traceIndent is one nesting level of evaluation depth in a recorded trace. +const traceIndent = " " + +// TraceKind classifies a trace record. +type TraceKind int + +const ( + // TraceLine is a record the trace prints as a line and a query does not read + // past its text: steps, evaluations, calculations, object lifecycle. + TraceLine TraceKind = iota + // TraceAccept is an event a behavior took off its queue: a signal or an + // operation call. The trace prints no line for it. + TraceAccept + // TraceSend is a message posted onto the bus. The trace prints no line for it. + TraceSend + // TraceTransition is a transition fired. + TraceTransition + // TraceEntry is a state entered. + TraceEntry + // TraceExit is a state exited. + TraceExit + // TraceDo is one step of a state's do behavior. + TraceDo + // TraceChoice is a choice point the run drew. + TraceChoice + // TraceGuard is a guard the run read to report a choice and could not evaluate. + TraceGuard +) + +// String names the kind as a query reads it. +func (k TraceKind) String() string { + switch k { + case TraceLine: + return "line" + case TraceAccept: + return "accept" + case TraceSend: + return "send" + case TraceTransition: + return "transition" + case TraceEntry: + return "entry" + case TraceExit: + return "exit" + case TraceDo: + return "do" + case TraceChoice: + return "choice" + case TraceGuard: + return "guard" + } + return fmt.Sprintf("TraceKind(%d)", int(k)) +} + +// TraceOrigin is where a record was made: the clock's instant, the object whose +// behavior made it and that behavior, each nil where the run has none. +type TraceOrigin struct { + At float64 + Object *Instance + Behavior *symbols.Symbol +} + +// TraceRecord is one entry of a run's trace. The printed line is derived from +// the record, so what a query reads and what `-trace` prints cannot drift. +type TraceRecord struct { + Kind TraceKind + Origin TraceOrigin + // State is the state entered, exited or stepped; From and To are a fired + // transition's endpoints. + State, From, To string + // Event is the trigger a transition fired on, or the signal or operation an + // accept or send carries; Payload is the message's payload. + Event string + Payload map[string]Value + // Target is the object a send was addressed to, nil for a broadcast or a + // destination named only as text (kept in To). + Target *Instance + // Action reports an entry or exit behavior ran with the entry or exit. + Action bool + // Note is a TraceChoice's or TraceGuard's note. + Note RunNote + // text and depth are a TraceLine's line and nesting. + text string + depth int +} + +// Line is the line the trace prints for the record; printed is false for a +// record the trace keeps without printing. +func (r TraceRecord) Line() (line string, printed bool) { + switch r.Kind { + case TraceLine: + return strings.Repeat(traceIndent, r.depth) + r.text, true + case TraceAccept, TraceSend: + return "", false + case TraceTransition: + if r.Event == "" { + return fmt.Sprintf("transition: %s -> %s", r.From, r.To), true + } + return fmt.Sprintf("transition: %s -> %s (event: %s)", r.From, r.To, r.Event), true + case TraceEntry: + if r.Action { + return fmt.Sprintf("enter: %s (entry action)", r.State), true + } + return fmt.Sprintf("enter: %s", r.State), true + case TraceExit: + if r.Action { + return fmt.Sprintf("exit: %s (exit action)", r.State), true + } + return fmt.Sprintf("exit: %s", r.State), true + case TraceDo: + return fmt.Sprintf("do: %s", r.State), true + case TraceChoice, TraceGuard: + return r.Note.String(), true + } + return "", false +} + +// Text is what a query reads as the record's text: its printed line, or for an +// accept or send its kind and event. +func (r TraceRecord) Text() string { + if line, printed := r.Line(); printed { + return line + } + return r.Kind.String() + " " + r.Event +} + +// TraceRecorder keeps a run's trace as typed records, in the order they were +// made, and prints them as the deterministic lines the golden trace tests read. +// +// Evaluation entries are recorded in post-order: the sub-expressions of an +// expression appear before it, indented one level deeper, so sibling evaluation +// order and nesting are both readable off the trace. A constant sub-expression +// is answered by the semantic constant folder without evaluating its operands, +// so it appears with no children. +// +// Clear marks records printed rather than dropping them: a query reads the whole +// run, or the most recent limit records of one bounded by NewEventRecorder. +type TraceRecorder struct { + records []TraceRecord + printed int + enabled bool + depth int + // queryOnly keeps no TraceLine: nothing prints the recorder, queries read it. + queryOnly bool + // limit is the most records kept, 0 for all; dropped and horizon count and date the oldest discarded. + limit int + dropped int + horizon float64 +} + +// NewTraceRecorder creates a new trace recorder. +func NewTraceRecorder() *TraceRecorder { + return &TraceRecorder{ + records: make([]TraceRecord, 0), + enabled: true, + } +} + +// NewEventRecorder creates a recorder nothing prints, keeping the most recent limit records (all when 0). +func NewEventRecorder(limit int) *TraceRecorder { + return &TraceRecorder{ + records: make([]TraceRecord, 0), + enabled: true, + queryOnly: true, + limit: max(limit, 0), + } +} + +// Enable enables trace recording. +func (tr *TraceRecorder) Enable() { + tr.enabled = true +} + +// Disable disables trace recording. +func (tr *TraceRecorder) Disable() { + tr.enabled = false +} + +// add keeps one record. +func (tr *TraceRecorder) add(r TraceRecord) { + if !tr.enabled || (tr.queryOnly && r.Kind == TraceLine) { + return + } + tr.records = append(tr.records, r) + tr.trim() +} + +// trim drops the oldest records past the limit, remembering how many and up to when. +func (tr *TraceRecorder) trim() { + if tr.limit == 0 || len(tr.records) <= tr.limit { + return + } + n := len(tr.records) - tr.limit + tr.dropped += n + tr.horizon = tr.records[n-1].Origin.At + tr.printed = max(tr.printed-n, 0) + tr.records = tr.records[n:] +} + +// Dropped reports how many records the limit discarded and the instant of the last of them. +func (tr *TraceRecorder) Dropped() (count int, upTo float64) { + return tr.dropped, tr.horizon +} + +// line keeps a TraceLine at nesting depth 0. +func (tr *TraceRecorder) line(text string) { + tr.add(TraceRecord{Kind: TraceLine, text: text}) +} + +// RecordActionStep records an action executor step with active tokens. +// Tokens are sorted by ID for deterministic output. +func (tr *TraceRecorder) RecordActionStep(step int, tokens []Token) { + if !tr.enabled { + return + } + + if len(tokens) == 0 { + tr.line(fmt.Sprintf("step %d: no active tokens", step)) + return + } + + // Sort tokens by ID for determinism + sorted := make([]Token, len(tokens)) + copy(sorted, tokens) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].ID < sorted[j].ID + }) + + // Format: step N: token T1@node1, token T2@node2 + var parts []string + for _, t := range sorted { + nodeName := nodeIdentifier(t.Location) + parts = append(parts, fmt.Sprintf("token %d@%s", t.ID, nodeName)) + } + + tr.line(fmt.Sprintf("step %d: %s", step, strings.Join(parts, ", "))) +} + +// RecordNote records a run's note where it was made: before the step line of the +// action step it belongs to, or before the transition it decided. +func (tr *TraceRecorder) RecordNote(origin TraceOrigin, n RunNote) { + kind := TraceChoice + if _, isGuard := n.(UnevaluableGuard); isGuard { + kind = TraceGuard + } + tr.add(TraceRecord{Kind: kind, Origin: origin, Note: n}) +} + +// Mark is the whole-run position the next record takes, for RecordAcceptAt to insert there later. +func (tr *TraceRecorder) Mark() int { + return tr.dropped + len(tr.records) +} + +// RecordAcceptAt records an accept as RecordAccept does, placed at mark: before +// the records the dispatch of the event made. A mark already printed stays printed. +func (tr *TraceRecorder) RecordAcceptAt(mark int, origin TraceOrigin, event string, payload map[string]Value) { + if !tr.enabled { + return + } + record := TraceRecord{Kind: TraceAccept, Origin: origin, Event: event, Payload: payload} + mark = min(max(mark-tr.dropped, 0), len(tr.records)) + tr.records = append(tr.records, TraceRecord{}) + copy(tr.records[mark+1:], tr.records[mark:]) + tr.records[mark] = record + if mark < tr.printed { + tr.printed++ + } + tr.trim() +} + +// RecordStateTransition records a transition fired, with the trigger it fired on. +func (tr *TraceRecorder) RecordStateTransition(origin TraceOrigin, fromState, toState string, event string) { + tr.add(TraceRecord{Kind: TraceTransition, Origin: origin, From: fromState, To: toState, Event: event}) +} + +// RecordAccept records an event a behavior took off its queue: the signal or +// operation it names, with the payload it carries. +func (tr *TraceRecorder) RecordAccept(origin TraceOrigin, event string, payload map[string]Value) { + tr.add(TraceRecord{Kind: TraceAccept, Origin: origin, Event: event, Payload: payload}) +} + +// RecordSend records a message posted onto the bus by the object at origin, or +// from outside the run where it has none. +func (tr *TraceRecorder) RecordSend(origin TraceOrigin, msg Message, target *Instance) { + event := msg.SignalType + if msg.EventName != "" { + event = msg.EventName + } + tr.add(TraceRecord{Kind: TraceSend, Origin: origin, Event: event, To: msg.Target, Target: target, Payload: msg.Payload}) +} + +// RecordStateTerminate records the machine's performance ending at the terminate +// action stop, with the states whose do behaviors it abandoned. +func (tr *TraceRecorder) RecordStateTerminate(stop string, abandoned []string) { + if len(abandoned) == 0 { + tr.line(fmt.Sprintf("terminate: %s", stop)) + return + } + tr.line(fmt.Sprintf("terminate: %s (do behavior abandoned: %s)", stop, strings.Join(abandoned, ", "))) +} + +// RecordStateEndedWithOccurrence records the machine's performance ending with the +// occurrence a `terminate` named, with the states whose do behaviors it abandoned. +func (tr *TraceRecorder) RecordStateEndedWithOccurrence(machine string, abandoned []string) { + if len(abandoned) == 0 { + tr.line(fmt.Sprintf("terminated with occurrence: %s", machine)) + return + } + tr.line(fmt.Sprintf("terminated with occurrence: %s (do behavior abandoned: %s)", machine, strings.Join(abandoned, ", "))) +} + +// RecordStateEntry records entering a state with optional entry action execution. +func (tr *TraceRecorder) RecordStateEntry(origin TraceOrigin, state string, hasEntryAction bool) { + tr.add(TraceRecord{Kind: TraceEntry, Origin: origin, State: state, Action: hasEntryAction}) +} + +// RecordStateExit records exiting a state with optional exit action execution. +func (tr *TraceRecorder) RecordStateExit(origin TraceOrigin, state string, hasExitAction bool) { + tr.add(TraceRecord{Kind: TraceExit, Origin: origin, State: state, Action: hasExitAction}) +} + +// RecordActionNodeEnter records a token entering the flow an action node owns, +// whose steps are that node's subperformances. +func (tr *TraceRecorder) RecordActionNodeEnter(node string) { + tr.line(fmt.Sprintf("enter action node: %s", node)) +} + +// RecordActionNodeExit records the flow an action node owns having completed, +// which is when the node itself completes. +func (tr *TraceRecorder) RecordActionNodeExit(node string) { + tr.line(fmt.Sprintf("leave action node: %s", node)) +} + +// RecordActionTerminate records a performance ended by a terminate, with the tokens +// dropped from its flow in the order they were, lowest ID first. +func (tr *TraceRecorder) RecordActionTerminate(perf string, dropped []Token) { + if !tr.enabled { + return + } + if len(dropped) == 0 { + tr.line(fmt.Sprintf("terminate %s: no token dropped", perf)) + return + } + parts := make([]string, 0, len(dropped)) + for _, t := range dropped { + parts = append(parts, fmt.Sprintf("token %d@%s", t.ID, nodeIdentifier(t.Location))) + } + tr.line(fmt.Sprintf("terminate %s: dropped %s", perf, strings.Join(parts, ", "))) +} + +// RecordActionTerminatePending records a performance a terminate ended at a parked token: +// one waiting at an accept, or one whose step had yet to begin. +func (tr *TraceRecorder) RecordActionTerminatePending(perf string, waiting bool) { + how := "ended before it began" + if waiting { + how = "ended waiting" + } + tr.line(fmt.Sprintf("terminate %s: %s", perf, how)) +} + +// RecordCalcEnter records entering a calc invocation and opens a nesting level. +func (tr *TraceRecorder) RecordCalcEnter(name string) { + tr.RecordCalculationEnter("calc", name) +} + +// RecordCalculationEnter records entering a calculation of the given kind — a +// calc, or an analysis case — and opens a nesting level. +func (tr *TraceRecorder) RecordCalculationEnter(kind, name string) { + tr.record(fmt.Sprintf("enter %s %s", kind, name)) + tr.depth++ +} + +// RecordCalcBind records binding one calc input parameter. source names where +// the value came from ("argument" or "default"). +func (tr *TraceRecorder) RecordCalcBind(param string, value Value, source string) { + tr.record(fmt.Sprintf("bind %s = %s [%s]", param, FormatTraceValue(value), source)) +} + +// RecordStatement records one body statement about to run and opens a nesting +// level for the expressions it evaluates and the statements it contains. +func (tr *TraceRecorder) RecordStatement(label string) { + tr.record("stmt " + label) + tr.depth++ +} + +// RecordLoopIteration records one iteration of a loop and opens a nesting level +// for what that iteration does, which is how a loop's progress is readable off +// the trace. +func (tr *TraceRecorder) RecordLoopIteration(iteration int) { + tr.record(fmt.Sprintf("iteration %d", iteration)) + tr.depth++ +} + +// EndStatement closes the level RecordStatement or RecordLoopIteration opened. +func (tr *TraceRecorder) EndStatement() { + tr.closeLevel() +} + +// RecordCalcExit closes a calc invocation's nesting level and records its result. +func (tr *TraceRecorder) RecordCalcExit(name string, result Value) { + tr.RecordCalculationExit("calc", name, result) +} + +// RecordCalculationExit closes a calculation's nesting level and records its result. +func (tr *TraceRecorder) RecordCalculationExit(kind, name string, result Value) { + tr.closeLevel() + tr.record(fmt.Sprintf("exit %s %s -> %s", kind, name, FormatTraceValue(result))) +} + +// RecordCalcExitError closes a calc invocation that failed, recording why. +// The failure is part of the ordering contract: it says how far binding and +// evaluation got before the calc gave up. +func (tr *TraceRecorder) RecordCalcExitError(name string, err error) { + tr.RecordCalculationExitError("calc", name, err) +} + +// RecordCalculationExitError closes a calculation that failed, recording why. +func (tr *TraceRecorder) RecordCalculationExitError(kind, name string, err error) { + tr.closeLevel() + tr.record(fmt.Sprintf("exit %s %s -> error: %v", kind, name, err)) +} + +// BeginEval opens a nesting level for one expression's sub-expressions. +func (tr *TraceRecorder) BeginEval() { + tr.depth++ +} + +// EndEval closes the level BeginEval opened and records the expression's +// outcome, so the entry appears after the sub-expressions it consumed. +func (tr *TraceRecorder) EndEval(label string, value Value, err error) { + if tr.depth > 0 { + tr.depth-- + } + if err != nil { + tr.record(fmt.Sprintf("eval %s -> error: %v", label, err)) + return + } + tr.record(fmt.Sprintf("eval %s -> %s", label, FormatTraceValue(value))) +} + +// nesting is the depth the next entry is recorded at, 0 for no recorder. +func (tr *TraceRecorder) nesting() int { + if tr == nil { + return 0 + } + return tr.depth +} + +// setNesting sets the depth the next entry is recorded at: work pausing mid-entry +// closes the levels it holds open while other work records, reopening them when resumed. +func (tr *TraceRecorder) setNesting(depth int) { + if tr != nil { + tr.depth = max(depth, 0) + } +} + +// closeLevel closes the innermost nesting level an entry opened. +func (tr *TraceRecorder) closeLevel() { + if tr.depth > 0 { + tr.depth-- + } +} + +// record appends one entry at the current nesting depth. Depth is tracked +// whether or not recording is enabled, so nesting stays consistent across a +// recorder that is disabled and re-enabled mid-evaluation. +func (tr *TraceRecorder) record(entry string) { + tr.add(TraceRecord{Kind: TraceLine, text: entry, depth: tr.depth}) +} + +// RecordDoStep records one action of a state's do behavior, which is how the +// interleaving of concurrently active states' do behaviors becomes visible. +func (tr *TraceRecorder) RecordDoStep(origin TraceOrigin, state string) { + tr.add(TraceRecord{Kind: TraceDo, Origin: origin, State: state}) +} + +// RecordEvent records an event being processed. +func (tr *TraceRecorder) RecordEvent(event string, time float64) { + tr.line(fmt.Sprintf("event: %s (t=%.1f)", event, time)) +} + +// RecordStateSpaceStep records the state a dynamics holds at an instant, with the +// output it computes there: one `(t, x)` sample of the run. +func (tr *TraceRecorder) RecordStateSpaceStep(action string, time float64, state, output Value) { + if !tr.enabled { + return + } + tr.line(fmt.Sprintf("state: %s t=%s x=%s y=%s", + action, semantics.FormatReal(time), FormatValue(state), FormatValue(output))) +} + +// RecordObjectMaterialized records an object being materialized, before any +// behavior of it starts. +func (tr *TraceRecorder) RecordObjectMaterialized(typeName string, id int64) { + tr.line(fmt.Sprintf("materialize: %s #%d", typeName, id)) +} + +// RecordOccurrenceCreated records `create` starting an object during a call. +func (tr *TraceRecorder) RecordOccurrenceCreated(typeName string, id int64) { + tr.line(fmt.Sprintf("create: %s #%d", typeName, id)) +} + +// RecordOccurrenceDestroyed records an object ending by `destroy`. +func (tr *TraceRecorder) RecordOccurrenceDestroyed(typeName string, id int64) { + tr.line(fmt.Sprintf("destroy: %s #%d", typeName, id)) +} + +// RecordOccurrenceTerminated records an occurrence ending by `terminate`. +func (tr *TraceRecorder) RecordOccurrenceTerminated(typeName string, id int64) { + tr.line(fmt.Sprintf("terminate: %s #%d", typeName, id)) +} + +// RecordBehaviorStart records an object's own execution of a behavior its type +// exhibits or performs starting. +func (tr *TraceRecorder) RecordBehaviorStart(kind, name string, id int64) { + tr.line(fmt.Sprintf("start: %s %s of #%d", kind, name, id)) +} + +// RecordBehaviorRun records an object's behavior being advanced, which is how +// the interleaving of several objects' behaviors becomes visible. +func (tr *TraceRecorder) RecordBehaviorRun(kind, name string, id int64) { + tr.line(fmt.Sprintf("run: %s %s of #%d", kind, name, id)) +} + +// Entries returns the lines of the records made since the last Clear, in order. +func (tr *TraceRecorder) Entries() []string { + entries := make([]string, 0, len(tr.records)-tr.printed) + for _, r := range tr.records[tr.printed:] { + if line, printed := r.Line(); printed { + entries = append(entries, line) + } + } + return entries +} + +// Records returns the records kept in order, printed ones included. The slice is read-only. +func (tr *TraceRecorder) Records() []TraceRecord { + return tr.records +} + +// String returns the trace as a single string (newline-separated entries). +func (tr *TraceRecorder) String() string { + return strings.Join(tr.Entries(), "\n") +} + +// Clear marks every record printed, so Entries starts over, and resets nesting depth. +func (tr *TraceRecorder) Clear() { + tr.printed = len(tr.records) + tr.depth = 0 +} + +// FormatTraceValue renders a runtime value canonically for a trace. Set +// elements are sorted by their rendering, since a set has no order of its own +// and its backing map does not iterate in a stable one. +func FormatTraceValue(v Value) string { + switch v.Kind { + case ValConst: + return formatConst(v.Const) + case ValNull: + return "null" + case ValString: + return strconv.Quote(v.Str()) + case ValInstance: + return fmt.Sprintf("instance#%d", v.Instance) + case ValSequence: + if v.Sequence() == nil { + return "()" + } + parts := make([]string, 0, v.Sequence().Size()) + for _, elem := range v.Sequence().Elements() { + parts = append(parts, FormatTraceValue(elem)) + } + return "(" + strings.Join(parts, ", ") + ")" + case ValSet: + if v.Set() == nil { + return "{}" + } + parts := make([]string, 0, v.Set().Size()) + for _, elem := range v.Set().Elements() { + parts = append(parts, FormatTraceValue(elem)) + } + return "{" + strings.Join(parts, ", ") + "}" + case ValQuantity: + if v.Quantity() == nil { + return v.Kind.String() + } + // A unit-carrying value is rendered as the REPL renders it, with the + // magnitude in the trace's own convention for numbers. + return v.Quantity().TextWithMagnitude(formatConst(v.Quantity().Num)) + case ValVariant: + if v.Variant() == nil { + return v.Kind.String() + } + return v.Variant().Name + case ValEnumLiteral: + return v.LiteralText() + case ValComplex: + return FormatComplex(v.Complex()) + case ValArray: + if v.Array() == nil { + return v.Kind.String() + } + return v.Array().Format(FormatTraceValue) + case ValVector: + if v.Vector() == nil { + return v.Kind.String() + } + return v.Vector().format(formatConst) + case ValVectorQuantity: + if v.VectorQuantity() == nil { + return v.Kind.String() + } + return v.VectorQuantity().format(formatConst) + case ValTensorQuantity: + if v.TensorQuantity() == nil { + return v.Kind.String() + } + return v.TensorQuantity().format(formatConst) + case ValMeasurementRef: + if v.MeasurementRef() == nil { + return v.Kind.String() + } + return v.MeasurementRef().String() + case ValCoordinateFrame: + if v.CoordinateFrame() == nil { + return v.Kind.String() + } + return v.CoordinateFrame().String() + case ValCoordinateTransformation: + if v.CoordinateTransformation() == nil { + return v.Kind.String() + } + return v.CoordinateTransformation().String() + case ValExpr: + return fmt.Sprintf("expr(%s)", TraceLabel(v.Expr())) + case ValFunction: + return fmt.Sprintf("calc(%s)", v.FunctionName()) + case ValMetaobject: + return v.MetaobjectText() + case ValUndetermined: + return fmt.Sprintf("undetermined(%s)", v.Undetermined().Reason()) + default: + return v.Kind.String() + } +} + +// formatConst renders a folded constant. Reals print with the shortest form +// that round-trips, so the same value always renders the same way. +func formatConst(c semantics.Value) string { + switch c.Kind { + case semantics.ValInt: + return strconv.FormatInt(c.Int, 10) + case semantics.ValReal: + return semantics.FormatReal(c.Real) + case semantics.ValBool: + return strconv.FormatBool(c.Bool) + case semantics.ValInfinity: + return "*" + default: + return "invalid" + } +} + +// literalLabel prefixes the trace label of a literal expression. +const literalLabel = "literal " + +// TraceLabel names an expression node for a trace: its kind plus the token that +// identifies it, which is stable across reformatting of the source. +func TraceLabel(node ast.Node) string { + switch n := node.(type) { + case nil: + return "nil" + case *ast.LiteralInteger: + return literalLabel + n.Value + case *ast.LiteralReal: + return literalLabel + n.Value + case *ast.LiteralBool: + return literalLabel + strconv.FormatBool(n.Value) + case *ast.LiteralString: + return literalLabel + n.Value + case *ast.LiteralInfinity: + return "literal *" + case *ast.NullExpr: + return "null" + case *ast.FeatureReference: + return "feature " + qualifiedNameToString(n.Name) + case *ast.QualifiedName: + return "feature " + qualifiedNameToString(n) + case *ast.FeatureChainExpr: + return "chain " + qualifiedNameToString(n.Member) + case *ast.OperatorExpr: + return "operator " + n.Operator.String() + case *ast.InvocationExpr: + return "invoke " + qualifiedNameToString(n.Type) + case *ast.SequenceExpr: + return fmt.Sprintf("sequence of %d", len(n.Elements)) + case *ast.CollectExpr: + return "collect" + case *ast.SelectExpr: + return "select" + case *ast.IndexExpr: + return "index" + case *ast.BodyExpr: + return "body" + case *ast.ConstructorExpr: + return "construct " + qualifiedNameToString(n.Type) + case *ast.MetadataAccessExpr: + return "metadata" + default: + return fmt.Sprintf("%T", node) + } +} + +// nodeIdentifier returns a stable identifier for an AST node. +// Prefers named nodes (Ident.Name), falls back to node type. +func nodeIdentifier(node ast.Node) string { + if node == nil { + return "nil" + } + + switch n := node.(type) { + case *ast.Usage: + // A step written as a redefinition is traced under the name it + // answers to, the one it redefines. + if name, _ := ast.EffectiveName(n); name != "" { + return name + } + return fmt.Sprintf("usage_%s", n.Kind) + case *ast.Definition: + if n.Ident.Name != "" { + return n.Ident.Name + } + return fmt.Sprintf("def_%s", n.Kind) + case *ast.StateNode: + if n.Name != "" { + return n.Name + } + return "state_anonymous" + case *ast.InitialNode: + return controlNodeName(n.Name(), "initial") + case *ast.FinalNode: + return "done" + case *ast.ForkNode: + return controlNodeName(n.Name, "fork") + case *ast.JoinNode: + return controlNodeName(n.Name, "join") + case *ast.MergeNode: + return controlNodeName(n.Name, "merge") + case *ast.DecisionNode: + return controlNodeName(n.Name, "decision") + case *ast.ActionExecutionNode: + return controlNodeName(n.Name, "action") + default: + return fmt.Sprintf("%T", node) + } +} + +// controlNodeName names an unnamed control node by what it does, since a Go +// type name means nothing to someone reading a trace. +func controlNodeName(name, kind string) string { + if name != "" { + return name + } + return kind +} + +// RecordCalcUsageExit closes the nesting level of a calc usage's evaluation, +// which computes the usage's output features rather than one result, so there is +// no single value to record for it. +func (tr *TraceRecorder) RecordCalcUsageExit(kind, name string) { + tr.closeLevel() + tr.record(fmt.Sprintf("exit %s %s", kind, name)) +} + +// RecordCalcUsageReuse records a calc usage read again with the inputs it +// already ran over, whose values come from that one run. It opens no nesting +// level of its own, since nothing runs. +func (tr *TraceRecorder) RecordCalcUsageReuse(kind, name string) { + tr.record(fmt.Sprintf("reuse %s %s", kind, name)) +} + +// RecordCalcOutput records the value one output feature of a calc usage took. +// The outputs appear after the one evaluation of the usage's body they are read +// from, which is how the trace shows that reading several of them ran it once. +func (tr *TraceRecorder) RecordCalcOutput(calc, output string, value Value) { + tr.record(fmt.Sprintf("output %s.%s = %s", calc, output, FormatTraceValue(value))) +} diff --git a/internal/core/runtime/trace_calc_test.go b/internal/exec/runtime/trace_calc_test.go similarity index 98% rename from internal/core/runtime/trace_calc_test.go rename to internal/exec/runtime/trace_calc_test.go index e2a8c47b69..84f5e35b4c 100644 --- a/internal/core/runtime/trace_calc_test.go +++ b/internal/exec/runtime/trace_calc_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // TestCalcTraceIsStableAcrossRuns: the recorded trace is a contract, so diff --git a/internal/exec/runtime/trace_records_test.go b/internal/exec/runtime/trace_records_test.go new file mode 100644 index 0000000000..b81da7e437 --- /dev/null +++ b/internal/exec/runtime/trace_records_test.go @@ -0,0 +1,175 @@ +package runtime + +import ( + "strings" + "testing" +) + +// A run's records name the object and behavior that made them; a message posted +// from outside the run names neither. +func TestTraceRecordsCarryTheBehaviorThatMadeThem(t *testing.T) { + src := ` + package test { + private import ScalarValues::*; + attribute def Ping; + attribute def Report; + part def Sink; + state def Relay { + entry; then idle; + state idle; + transition first idle accept Ping then told; + state told { entry send new Report() to sink; } + } + part def Rig { + attribute mark : Integer = 0; + part sink : Sink; + part relay : Sink { exhibit state r : Relay; } + perform action marking { + first start; + then decide route; + if mark == 0 then split; + if 1 / mark > 0 then split; + fork split; + action low { assign mark := 1; } + action high { assign mark := 2; } + join sync; + action report send new Report() to sink; + done; + succession first split then low; + succession first split then high; + succession first low then sync; + succession first high then sync; + succession first sync then report; + succession first report then done; + } + } + }` + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + trace := NewTraceRecorder() + ctx.SetTrace(trace) + rig, err := ctx.Instantiate(oneSymbol(t, idx, "test::Rig")) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + relayID, ok := rig.FeatureValues["relay"].Value.Object() + if !ok { + t.Fatalf("relay = %v, want an object", rig.FeatureValues["relay"].Value) + } + relay, _ := ctx.Instance(relayID) + ping, err := ctx.SignalMessage(oneSymbol(t, idx, "test::Ping"), nil, relay) + if err != nil { + t.Fatalf("ping: %v", err) + } + ctx.PostMessage(ping) + if _, err := ctx.Advance(1); err != nil { + t.Fatalf("advance: %v", err) + } + + marking := oneSymbol(t, idx, "test::Rig::marking") + relayMachine := oneSymbol(t, idx, "test::Relay") + var got []string + for _, r := range trace.Records() { + switch r.Kind { + case TraceSend, TraceChoice, TraceGuard: + default: + continue + } + var origin strings.Builder + switch { + case r.Origin.Object == nil && r.Origin.Behavior == nil: + origin.WriteString("outside") + case r.Origin.Object == rig && r.Origin.Behavior == marking: + origin.WriteString("rig/marking") + case r.Origin.Object == relay && r.Origin.Behavior == relayMachine: + origin.WriteString("relay/Relay") + default: + origin.WriteString("?") + } + name := r.Event + switch n := r.Note.(type) { + case ChoicePoint: + name = n.Kind.String() + case UnevaluableGuard: + name = "guard " + n.Alternative + } + got = append(got, r.Kind.String()+" "+name+" from "+origin.String()) + } + want := []string{ + "guard guard 2->split from rig/marking", + "choice write order from rig/marking", + "choice token order from rig/marking", + "send Report from rig/marking", + "send Ping from outside", + "send Report from relay/Relay", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("origins:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// An event recorder keeps the most recent limit records, no printed lines, and +// places a late accept by its whole-run mark. +func TestEventRecorderKeepsTheMostRecentRecords(t *testing.T) { + tr := NewEventRecorder(3) + at := func(t float64, object *Instance) TraceOrigin { return TraceOrigin{At: t, Object: object} } + tr.line("printed only") + tr.RecordStateEntry(at(0, nil), "a", false) + tr.RecordStateEntry(at(1, nil), "b", false) + if dropped, _ := tr.Dropped(); dropped != 0 || len(tr.Records()) != 2 { + t.Fatalf("records = %d dropped = %d, want the two entries and no line", len(tr.Records()), dropped) + } + tr.Clear() + mark := tr.Mark() + tr.RecordStateEntry(at(2, nil), "c", false) + tr.RecordStateEntry(at(3, nil), "d", false) + tr.RecordAcceptAt(mark, at(2, nil), "Go", nil) + states := func() string { + var out []string + for _, r := range tr.Records() { + out = append(out, r.Kind.String()+":"+r.State+r.Event) + } + return strings.Join(out, " ") + } + if got := states(); got != "accept:Go entry:c entry:d" { + t.Fatalf("records = %s, want the accept placed at its mark and the oldest two dropped", got) + } + if dropped, upTo := tr.Dropped(); dropped != 2 || upTo != 1 { + t.Fatalf("dropped = %d up to %v, want 2 up to t = 1", dropped, upTo) + } + if entries := tr.Entries(); strings.Join(entries, ";") != "enter: c;enter: d" { + t.Fatalf("entries since Clear = %q, want the two printed lines made after it", entries) + } + if got := NewEventRecorder(0); got.limit != 0 { + t.Fatalf("limit 0 = %d, want unbounded", got.limit) + } +} + +// A capture of a bounded recorder restores what it kept and what it had dropped: +// records trimmed after the mark come back, and the horizon rewinds with them. +func TestEventRecorderCaptureRestoresTruncation(t *testing.T) { + tr := NewEventRecorder(3) + at := func(t float64) TraceOrigin { return TraceOrigin{At: t} } + for i, s := range []string{"a", "b", "c", "d"} { + tr.RecordStateEntry(at(float64(i)), s, false) + } + states := func() string { + var out []string + for _, r := range tr.Records() { + out = append(out, r.State) + } + return strings.Join(out, " ") + } + capture := captureTrace(tr) + tr.RecordStateEntry(at(4), "e", false) + tr.RecordStateEntry(at(5), "f", false) + if dropped, upTo := tr.Dropped(); states() != "d e f" || dropped != 3 || upTo != 2 { + t.Fatalf("after the mark: %s dropped %d up to %v", states(), dropped, upTo) + } + capture.restore(tr) + if dropped, upTo := tr.Dropped(); states() != "b c d" || dropped != 1 || upTo != 0 { + t.Fatalf("restored: %s dropped %d up to %v, want b c d with one dropped up to t = 0", states(), dropped, upTo) + } + if tr.Mark() != 4 { + t.Fatalf("mark after restore = %d, want 4", tr.Mark()) + } +} diff --git a/internal/core/runtime/trace_test.go b/internal/exec/runtime/trace_test.go similarity index 97% rename from internal/core/runtime/trace_test.go rename to internal/exec/runtime/trace_test.go index 28ca5df087..62e0034452 100644 --- a/internal/core/runtime/trace_test.go +++ b/internal/exec/runtime/trace_test.go @@ -10,12 +10,13 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/tests/fixtures" ) var updateTraces = flag.Bool("update-traces", false, "Update golden trace files") @@ -98,7 +99,7 @@ func fileExists(path string) bool { // policyFileTag spells a policy as a file-name segment: `seed:1` becomes `seed-1`. func policyFileTag(policy SchedulePolicy) string { - return strings.ReplaceAll(policy.String(), ":", "-") + return fixtures.PolicyFileTag(policy.String()) } // runTraceTest drives a case under policy (or the policy it pins) and, when @@ -269,7 +270,8 @@ func loadTraceCase(t *testing.T, conformanceDir, testName string, expected Expec idx, _ := indexCaseDocuments(t, conformanceDir, src, file, expected) resolver := resolve.New(idx) model := semantics.NewModel(resolver) - ctx := NewContext(NewModel(model, resolver), 10000) + ctx := NewContext(typedModel(model, resolver), 10000) + applyCaseDraws(t, ctx, expected) mustSchedule(t, ctx, casePolicy(t, expected, policy)) return ctx, idx, idx.DocumentRoot(sysmlPath) } diff --git a/internal/exec/runtime/transition_payload.go b/internal/exec/runtime/transition_payload.go new file mode 100644 index 0000000000..7b7d6eab51 --- /dev/null +++ b/internal/exec/runtime/transition_payload.go @@ -0,0 +1,53 @@ +package runtime + +import ( + "slices" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// transitionPayload reads `T.d`, a transition's accepted payload, from within a +// state machine's behavior: the value T's trigger bound when T is the transition +// being taken, and nothing — a transition not taken performs nothing — otherwise. +// It declines chains whose base is not a transition or read outside a machine. +func (ec *EvalContext) transitionPayload(base ast.Node, parts []ast.NameSegment) (Value, bool, error) { + f, ok := ec.machineFiring() + if !ok { + return Value{}, false, nil + } + sym, ok := ec.chainBaseSymbol(base) + if !ok { + return Value{}, false, nil + } + trans, ok := sym.Decl.(*ast.TransitionMember) + if !ok { + return Value{}, false, nil + } + if f.taken == nil || f.taken.Decl != ast.Node(trans) { + return nullValue(), true, nil + } + name := parts[0].Text + if !slices.Contains(f.taken.Accepted, name) { + return Value{}, false, nil + } + value, ok := f.payload[name] + if !ok { + return Value{}, true, &NoValueError{Feature: sym.Name + "." + name, Symbol: sym} + } + if len(parts) == 1 { + return value, true, nil + } + rest, err := ec.chainMemberValue(value, parts[1:], name) + return rest, true, err +} + +// machineFiring is the state machine firing the innermost frame read within one +// reports, if the evaluation is a machine behavior's. +func (ec *EvalContext) machineFiring() (*firing, bool) { + for i := len(ec.frames) - 1; i >= 0; i-- { + if f := ec.frames[i].firing; f != nil { + return f, true + } + } + return nil, false +} diff --git a/internal/exec/runtime/typed_model_test.go b/internal/exec/runtime/typed_model_test.go new file mode 100644 index 0000000000..dd6c2d6d94 --- /dev/null +++ b/internal/exec/runtime/typed_model_test.go @@ -0,0 +1,16 @@ +package runtime + +import ( + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// typedModel is NewModel over a semantic model with the checker's argument typing +// installed, as every product path builds one, so a run selects calls as validation did. +func typedModel(sem *semantics.Model, resolver *resolve.Resolver) *Model { + if sem != nil { + sem.SetArgumentTyper(passes.NewArgumentTyper(resolver, sem)) + } + return NewModel(sem, resolver) +} diff --git a/internal/core/runtime/unbound_subject_test.go b/internal/exec/runtime/unbound_subject_test.go similarity index 97% rename from internal/core/runtime/unbound_subject_test.go rename to internal/exec/runtime/unbound_subject_test.go index cb361b3b6b..91bb8b9c4a 100644 --- a/internal/core/runtime/unbound_subject_test.go +++ b/internal/exec/runtime/unbound_subject_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) const subjectModel = ` diff --git a/internal/core/runtime/undetermined.go b/internal/exec/runtime/undetermined.go similarity index 99% rename from internal/core/runtime/undetermined.go rename to internal/exec/runtime/undetermined.go index dfa9212f34..4dc86b3fe0 100644 --- a/internal/core/runtime/undetermined.go +++ b/internal/exec/runtime/undetermined.go @@ -3,9 +3,9 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // UndeterminedText is how every surface spells a result the model does not diff --git a/internal/core/runtime/undetermined_test.go b/internal/exec/runtime/undetermined_test.go similarity index 99% rename from internal/core/runtime/undetermined_test.go rename to internal/exec/runtime/undetermined_test.go index c7cb45ce8a..b5f79f53c1 100644 --- a/internal/core/runtime/undetermined_test.go +++ b/internal/exec/runtime/undetermined_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // wantUndetermined fails unless val is an undetermined result whose values @@ -947,7 +947,7 @@ func TestUndeterminedIsAValueKind(t *testing.T) { if got := val.Kind.String(); got != "undetermined" { t.Errorf("Kind.String() = %q, want undetermined", got) } - ctx := NewContext(NewModel(nil, nil), 100) + ctx := NewContext(typedModel(nil, nil), 100) if ctx.HoldsNoValue(val) { t.Error("HoldsNoValue(undetermined) = true; an undetermined result is a value, not a missing one") } diff --git a/internal/exec/runtime/uniqueness.go b/internal/exec/runtime/uniqueness.go new file mode 100644 index 0000000000..b4425e60c2 --- /dev/null +++ b/internal/exec/runtime/uniqueness.go @@ -0,0 +1,44 @@ +package runtime + +import ( + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// uniquenessRefusal says which value repeats in a sequence written to a unique +// feature, by set equality judged in the context, or is empty; a set-held +// feature drops repeats instead. +func (ctx *Context) uniquenessRefusal(unique, holdsSet bool, value *Value) string { + if !unique || holdsSet || value.Kind != ValSequence { + return "" + } + elements := elementsOf(*value) + seen := NewSetIn(ctx) + for i, element := range elements { + if !seen.Contains(element) { + seen.Add(element) + continue + } + first := 0 + for first < i && !ctx.valueEqual(elements[first], element) { + first++ + } + return semantics.UniquenessViolation(ctx.elementText(element), first+1, i+1) + } + return "" +} + +// multiValued reports whether a declared multiplicity admits more than one value. +func multiValued(mult semantics.Range) bool { + return !mult.AtMostOne() +} + +// declaredUniquenessRefusal is uniquenessRefusal for the value a standalone +// multi-valued feature declares, read outside any instance. +func (ctx *Context) declaredUniquenessRefusal(sym *symbols.Symbol, value *Value) string { + mult, stated := ctx.statedMultiplicity(sym) + if !stated || !multiValued(mult) { + return "" + } + return ctx.uniquenessRefusal(ctx.model.semantics.IsUnique(sym), ctx.holdsSet(sym, ctx.findOwnerType(sym), mult), value) +} diff --git a/internal/exec/runtime/validate.go b/internal/exec/runtime/validate.go new file mode 100644 index 0000000000..74c32ebcce --- /dev/null +++ b/internal/exec/runtime/validate.go @@ -0,0 +1,608 @@ +package runtime + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// ValidationStatus is what checking one assertion on one object decided. +type ValidationStatus int + +const ( + // ValidationHolds is the assertion evaluating to true on the object. + ValidationHolds ValidationStatus = iota + // ValidationViolated is the model answering the assertion false. + ValidationViolated + // ValidationUndecided is an assertion that could not be evaluated. + ValidationUndecided +) + +// String names the status for a report a machine reads. +func (s ValidationStatus) String() string { + switch s { + case ValidationHolds: + return "holds" + case ValidationViolated: + return "violated" + default: + return "undecided" + } +} + +// AssertionKind is the kind of assertion an object verdict is about. +type AssertionKind string + +const ( + AssertionConstraint AssertionKind = "constraint" + AssertionRequirement AssertionKind = "requirement" + AssertionSatisfaction AssertionKind = "satisfaction" +) + +// ObjectVerdict is one assertion checked on one object of a validated tree. +type ObjectVerdict struct { + Kind AssertionKind + // Element is the asserting element: the constraint, requirement or satisfy usage. + Element *symbols.Symbol + // Requirement is the requirement a requirement or satisfaction verdict is + // about, whose verification cases a caller may report beside it. + Requirement *symbols.Symbol + // Text is the assertion as written, so an anonymous one can be named. + Text string + // Subject is the object evaluated against and Path the features walked to it + // from the root, a collection element indexed as wheels[2]; empty for the root. + Subject *Instance + Path []string + Status ValidationStatus + // Err is the violation, or what left the assertion undecided; nil when it holds. + Err error +} + +// ValidationReport is what validating an object and the objects it holds found. +type ValidationReport struct { + Root *Instance + Verdicts []ObjectVerdict + // Bounded is true when the walk left nesting unreached — deeper than it + // descends, or past its budget — so the unreached objects are unvalidated. + Bounded bool + // Unread are the feature values that could not be read, whose objects went unvalidated. + Unread []error +} + +// Valid reports whether the object is shown valid: it states at least one assertion, +// every assertion holds and every held object was reached. +func (r ValidationReport) Valid() bool { + return len(r.Verdicts) > 0 && r.Status() == ValidationHolds && r.Complete() +} + +// Complete reports whether the walk reached every object the root holds. +func (r ValidationReport) Complete() bool { + return !r.Bounded && len(r.Unread) == 0 +} + +// Status is the status the report is judged by: one violation makes the object +// invalid whatever else is undecided, and one undecided assertion leaves it unshown. +func (r ValidationReport) Status() ValidationStatus { + switch { + case r.Count(ValidationViolated) > 0: + return ValidationViolated + case r.Count(ValidationUndecided) > 0: + return ValidationUndecided + } + return ValidationHolds +} + +// Count is how many verdicts have status. +func (r ValidationReport) Count(status ValidationStatus) int { + n := 0 + for _, v := range r.Verdicts { + if v.Status == status { + n++ + } + } + return n +} + +// validatedObject is one object the walk reached: its path from the root (a +// collection element indexed) the first time, and every feature it was read through. +type validatedObject struct { + inst *Instance + path []string + holdings []holding +} + +// holding is one feature an object was read through, by name and declaration, +// on the object holding it; a shared object has one per feature. +type holding struct { + parent *validatedObject + name string + through *symbols.Symbol + owner *symbols.Symbol +} + +// validationWalk collects the objects an object holds, depth first, under the +// bounds a materialization walk uses. +type validationWalk struct { + ctx *Context + onPath map[*symbols.Symbol]bool + visited map[int64]*validatedObject + read map[*FeatureValue]bool + budget int + bounded bool + unread []error + objects []*validatedObject +} + +// RequireObject is ErrNotAnObject unless sym has objects to validate: a definition +// or usage that is neither a namespace nor a data value (attribute, enumeration). +func RequireObject(sym *symbols.Symbol) error { + switch sym.Kind { + case symbols.SymbolAttributeDef, symbols.SymbolAttributeUsage, + symbols.SymbolEnumerationDef, symbols.SymbolEnumerationUsage, + symbols.SymbolConnectorEnd, symbols.SymbolCrossFeature, symbols.SymbolMultiplicity: + return notAnObject(sym) + } + if sym.Kind.IsDefinition() || sym.IsFeature() { + return nil + } + return notAnObject(sym) +} + +func notAnObject(sym *symbols.Symbol) error { + kind := sym.Notation() + return fmt.Errorf("%w: %s is %s %s, which has no object to validate", ErrNotAnObject, sym.Name, articleFor(kind), kind) +} + +// ValidateObject checks every assertion about root and the objects it holds: asserted +// constraints, carried requirements, and satisfactions (in scopes or the types) about them. +func (ctx *Context) ValidateObject(root *Instance, scopes []*symbols.Scope) (ValidationReport, error) { + return ctx.validateObjectWithin(root, scopes, maxMaterializeBudget) +} + +// validateObjectWithin is ValidateObject with room for budget feature reads and +// objects reached beyond the root. +func (ctx *Context) validateObjectWithin(root *Instance, scopes []*symbols.Scope, budget int) (ValidationReport, error) { + if root == nil { + return ValidationReport{}, errors.New("validate: no object") + } + if err := ctx.checkNotDestroyed(root); err != nil { + return ValidationReport{Root: root}, err + } + w := ctx.walkHeldObjects(root, budget) + report := ValidationReport{Root: root, Bounded: w.bounded, Unread: w.unread} + // Every object's carried assertions are read first, since a satisfaction one + // states may be about any object of the tree; verdicts then go out object by object. + carried := make([][]ObjectVerdict, len(w.objects)) + position := make(map[int64]int, len(w.objects)) + var stated []*SatisfyAssertion + for i, obj := range w.objects { + var assertions []*SatisfyAssertion + carried[i], assertions = ctx.carriedVerdicts(obj) + stated = append(stated, assertions...) + position[obj.inst.ID] = i + } + for _, scope := range scopes { + stated = append(stated, ctx.SatisfyAssertionsIn(scope)...) + } + stated = distinctAssertions(stated) + // A satisfaction resolving below the object `by` names is about that nested + // object, so it is filed with the object it turned out to be about. + satisfied := make([][]ObjectVerdict, len(w.objects)) + decided := map[satisfactionKey]bool{} + for i, obj := range w.objects { + for _, v := range ctx.satisfactionVerdicts(obj, stated) { + key := satisfactionKey{declarationKeyOf(v.Element), v.Subject.ID} + if decided[key] { + continue + } + decided[key] = true + at := i + if j, ok := position[v.Subject.ID]; ok { + at = j + } + satisfied[at] = append(satisfied[at], v) + } + } + for i := range w.objects { + report.Verdicts = append(report.Verdicts, carried[i]...) + report.Verdicts = append(report.Verdicts, satisfied[i]...) + } + return report, nil +} + +// satisfactionKey is one assertion decided about one object, so an assertion +// resolving to the same nested object from two holders is reported once. +type satisfactionKey struct { + assertion declarationKey + subject int64 +} + +// distinctAssertions keeps the first of each assertion, so one stated by a type +// and again in a scope is checked once, whichever scope tree each symbol is from. +func distinctAssertions(assertions []*SatisfyAssertion) []*SatisfyAssertion { + var out []*SatisfyAssertion + seen := map[declarationKey]bool{} + for _, a := range assertions { + if a == nil || a.Symbol == nil { + continue + } + key := declarationKeyOf(a.Symbol) + if seen[key] { + continue + } + seen[key] = true + out = append(out, a) + } + return out +} + +// declarationKey identifies a declaration the way sameDeclaration compares +// symbols: by its node when it has one, otherwise by the symbol itself. +type declarationKey struct { + decl ast.Node + symbol *symbols.Symbol +} + +func declarationKeyOf(sym *symbols.Symbol) declarationKey { + if sym.Decl != nil { + return declarationKey{decl: sym.Decl} + } + return declarationKey{symbol: sym} +} + +// walkHeldObjects reaches the objects root holds, directly or through them, with +// room for budget feature reads and objects reached beyond the root. +func (ctx *Context) walkHeldObjects(root *Instance, budget int) *validationWalk { + obj := &validatedObject{inst: root} + w := &validationWalk{ + ctx: ctx, + onPath: map[*symbols.Symbol]bool{root.Type: true}, + visited: map[int64]*validatedObject{root.ID: obj}, + read: map[*FeatureValue]bool{}, + budget: budget, + } + w.walk(obj, 0) + return w +} + +func (w *validationWalk) walk(obj *validatedObject, depth int) { + w.objects = append(w.objects, obj) + inst := obj.inst + if w.ctx.checkNotDestroyed(inst) != nil { + return + } + for _, of := range w.ctx.FeaturesOfObject(inst) { + feat := of.Feature + if of.Name == "" || !holdsObjects(feat) { + continue + } + // A part of a type being expanded above, or deeper than the walk descends, + // is cut only where reading it would begin objects of its own. + if held := w.ctx.CompositeTypeOf(feat); held != nil && (depth >= maxMaterializeDepth || w.onPath[held]) && w.makesObjects(inst, of) { + w.bounded = true + continue + } + if shared := inst.FeatureValues[of.Name]; shared != nil { + if w.read[shared] { + continue + } + w.read[shared] = true + } + // The budget is charged per read and per object reached, and checked only + // where the walk would spend it, so what was reached is validated whole. + if w.budget <= 0 { + w.bounded = true + return + } + w.budget-- + fv, err := inst.GetFeatureValue(w.ctx, of.Name) + if err != nil { + w.unread = append(w.unread, fmt.Errorf("%s: %w", strings.Join(append(obj.path, of.Name), "."), err)) + continue + } + for _, child := range w.heldChildren(fv, source.NameText(of.Name)) { + held := holding{parent: obj, name: of.Name, through: feat.Symbol, owner: feat.OwnerType} + if reached, ok := w.visited[child.inst.ID]; ok { + reached.holdings = append(reached.holdings, held) + continue + } + if w.budget <= 0 { + w.bounded = true + return + } + w.budget-- + reached := child.validatedObject + w.visited[reached.inst.ID] = reached + reached.holdings = []holding{held} + reached.path = append(append([]string(nil), obj.path...), child.segment) + w.onPath[reached.inst.Type] = true + w.walk(reached, depth+1) + delete(w.onPath, reached.inst.Type) + } + } +} + +// makesObjects reports whether reading a composite feature would materialize +// objects of its own: it is unread, and its lower bound or its body demands one. +func (w *validationWalk) makesObjects(inst *Instance, of ObjectFeature) bool { + if fv := inst.FeatureValues[of.Name]; fv != nil && fv.Materialized { + return false + } + lower := of.Feature.Multiplicity.Lower + return !lower.Known || lower.Infinite || lower.Value > 0 || w.ctx.bodyBindsAFeature(of.Feature) +} + +// heldChild is an object a feature value holds and the segment naming it there: +// the feature's name, indexed for a collection's element. +type heldChild struct { + *validatedObject + segment string +} + +// heldChildren lists the objects a feature value holds, each under its segment. +func (w *validationWalk) heldChildren(fv *FeatureValue, segment string) []heldChild { + var out []heldChild + reach := func(val Value, segment string) { + id, ok := val.Object() + if !ok || w.ctx.HoldsNoValue(val) { + return + } + if child, ok := w.ctx.Instance(id); ok { + out = append(out, heldChild{&validatedObject{inst: child}, segment}) + } + } + if fv.Values.Kind == ValInvalid { + reach(fv.Value, segment) + return out + } + var elements []Value + switch fv.Values.Kind { + case ValSequence: + if fv.Values.Sequence() != nil { + elements = fv.Values.Sequence().Elements() + } + case ValSet: + if fv.Values.Set() != nil { + elements = fv.Values.Set().Elements() + } + } + for i, val := range elements { + reach(val, fmt.Sprintf("%s[%d]", segment, i+1)) + } + return out +} + +// carriedVerdicts checks each assertion declaration the object's types state about it once, inherited +// ones included and masked named ones left out; satisfaction assertions are returned for the subject search. +func (ctx *Context) carriedVerdicts(obj *validatedObject) ([]ObjectVerdict, []*SatisfyAssertion) { + var verdicts []ObjectVerdict + var stated []*SatisfyAssertion + seen := map[*ast.Usage]bool{} + for _, typ := range obj.inst.types() { + typeVerdicts, typeStated := ctx.carriedVerdictsOf(typ, obj, seen) + verdicts = append(verdicts, typeVerdicts...) + stated = append(stated, typeStated...) + } + return verdicts, stated +} + +// carriedVerdictsOf checks the assertions one type of the object states about it, +// skipping those seen through an earlier type and recording the ones it checks. +func (ctx *Context) carriedVerdictsOf(typ *symbols.Symbol, obj *validatedObject, seen map[*ast.Usage]bool) ([]ObjectVerdict, []*SatisfyAssertion) { + var verdicts []ObjectVerdict + var stated []*SatisfyAssertion + var effective map[*symbols.Symbol]bool + for _, member := range ctx.chainMembers(typ, typ.OwnerScope) { + usage, ok := member.node.(*ast.Usage) + if !ok || seen[usage] { + continue + } + kind, asserted := assertionKindOf(usage) + if !asserted { + continue + } + sym := memberSymbol(member.scope, member.node) + if sym == nil { + continue + } + if sym.Name != "" { + if effective == nil { + effective = ctx.effectiveMembers(typ) + } + if !effective[sym] { + continue + } + } + seen[usage] = true + switch kind { + case AssertionSatisfaction: + if a := ctx.satisfyAssertionOf(sym); a != nil { + stated = append(stated, a) + } + case AssertionConstraint: + result, err := ctx.CheckConstraintOn(sym, member.scope, obj.inst) + verdicts = append(verdicts, ctx.objectVerdict(kind, sym, assertionText(usage, sym), obj, result, err)) + case AssertionRequirement: + result, err := ctx.CheckRequirementOn(sym, member.scope, obj.inst) + v := ctx.objectVerdict(kind, sym, assertionText(usage, sym), obj, result, err) + v.Requirement = sym + verdicts = append(verdicts, v) + } + } + return verdicts, stated +} + +// assertionKindOf classifies a member asserting something about the object carrying +// it; an unasserted constraint usage is checked by name only, an assumed one never. +func assertionKindOf(usage *ast.Usage) (AssertionKind, bool) { + switch usage.Kind { + case ast.UsageConstraint: + keyword := usage.PrefixKeyword + if keyword == "" { + keyword = usage.Keyword + } + switch keyword { + case "assert", "inv": + return AssertionConstraint, true + } + return "", false + case ast.UsageRequirement: + return AssertionRequirement, true + case ast.UsageSatisfy: + return AssertionSatisfaction, true + default: + return "", false + } +} + +// assertionText spells an asserting usage as written: its keywords and any name. +func assertionText(usage *ast.Usage, sym *symbols.Symbol) string { + var parts []string + if usage.PrefixKeyword != "" { + parts = append(parts, usage.PrefixKeyword) + } + if usage.IsNegated { + parts = append(parts, "not") + } + if usage.Keyword != "" { + parts = append(parts, usage.Keyword) + } + if sym != nil && sym.Name != "" { + parts = append(parts, source.NameText(sym.Name)) + } + return strings.Join(parts, " ") +} + +// satisfactionVerdicts checks each assertion obj is the subject of against it, +// in the order the assertions were stated. +func (ctx *Context) satisfactionVerdicts(obj *validatedObject, assertions []*SatisfyAssertion) []ObjectVerdict { + var verdicts []ObjectVerdict + for _, a := range assertions { + if !ctx.subjectOf(a, obj) { + continue + } + result, err := ctx.CheckSatisfactionOn(a, obj.inst) + v := ctx.objectVerdict(AssertionSatisfaction, a.Symbol, a.Text(), obj, result, err) + v.Requirement = a.AssertedRequirement() + verdicts = append(verdicts, v) + } + return verdicts +} + +// subjectOf reports whether obj is what an assertion's `by` names — an object of that +// feature, or one a chain reaches from its root — or, with no `by`, an object of the type stating it. +func (ctx *Context) subjectOf(a *SatisfyAssertion, obj *validatedObject) bool { + if a.SubjectRef == "" { + return a.Owner != nil && slices.ContainsFunc(obj.inst.types(), func(typ *symbols.Symbol) bool { + return sameDeclaration(typ, a.Owner) || ctx.modelConforms(typ, a.Owner) + }) + } + if a.Subject == nil { + return false + } + if a.SubjectChain == nil { + return ctx.occursAs(obj, a.Subject) + } + return a.SubjectRoot != nil && ctx.reachedBy(obj, a.SubjectPath, a.SubjectRoot) +} + +// reachedBy reports whether some chain of holdings named path leads up from obj +// to an object of root, trying every feature a shared object is held through. +func (ctx *Context) reachedBy(obj *validatedObject, path []string, root *symbols.Symbol) bool { + if len(path) == 0 { + return ctx.occursAs(obj, root) + } + if len(obj.holdings) == 0 { + obj.holdings = ctx.holdersOf(obj.inst) + } + last := path[len(path)-1] + for _, h := range obj.holdings { + if h.name == last && ctx.reachedBy(h.parent, path[:len(path)-1], root) { + return true + } + } + return false +} + +// holdersOf finds the features whose values hold inst, so a chain can be walked +// above a nested validated root. +func (ctx *Context) holdersOf(inst *Instance) []holding { + ids := make([]int64, 0, len(ctx.instances)) + for id := range ctx.instances { + ids = append(ids, id) + } + slices.Sort(ids) + var out []holding + for _, id := range ids { + candidate := ctx.instances[id] + if candidate == nil || candidate == inst { + continue + } + var holder *validatedObject + for _, of := range ctx.FeaturesOfObject(candidate) { + fv := candidate.FeatureValues[of.Name] + if of.Name == "" || fv == nil || !slices.Contains(heldObjects(fv.HeldValue()), inst.ID) { + continue + } + if holder == nil { + holder = &validatedObject{inst: candidate} + } + out = append(out, holding{parent: holder, name: of.Name, through: of.Feature.Symbol, owner: of.Feature.OwnerType}) + } + } + return out +} + +// occursAs reports whether obj is an object of sym: typed by it, or held by a +// feature declaring or redefining it. +func (ctx *Context) occursAs(obj *validatedObject, sym *symbols.Symbol) bool { + declares := func(s *symbols.Symbol) bool { return sameDeclaration(s, sym) } + if slices.ContainsFunc(obj.inst.types(), declares) { + return true + } + for _, h := range obj.holdings { + if declares(h.through) || slices.ContainsFunc(ctx.redefinedFeatures(h.through, h.owner), declares) { + return true + } + } + return false +} + +// sameDeclaration reports whether two symbols stand for one declaration: one symbol, +// or the symbols two scope trees (an index's and a document's) built for one node. +func sameDeclaration(a, b *symbols.Symbol) bool { + if a == b { + return a != nil + } + return a != nil && b != nil && a.Decl != nil && a.Decl == b.Decl +} + +// objectVerdict reports what a check on obj decided, about a nested object when +// the check resolved to one. +func (ctx *Context) objectVerdict(kind AssertionKind, sym *symbols.Symbol, text string, obj *validatedObject, result CheckResult, err error) ObjectVerdict { + v := ObjectVerdict{Kind: kind, Element: sym, Text: text, Subject: obj.inst, Path: obj.path, Err: err} + switch { + case err == nil && result.Holds: + v.Status = ValidationHolds + case err == nil || isViolation(err): + v.Status = ValidationViolated + default: + v.Status = ValidationUndecided + } + if result.Subject != nil && result.Subject != obj.inst && result.SubjectRoot == obj.inst { + v.Subject = result.Subject + v.Path = append(append([]string(nil), obj.path...), result.SubjectPath...) + } + return v +} + +// isViolation reports whether err is the model answering false, not a failure to evaluate. +func isViolation(err error) bool { + var violation *ViolationError + return errors.As(err, &violation) || errors.Is(err, ErrViolated) +} diff --git a/internal/exec/runtime/validate_test.go b/internal/exec/runtime/validate_test.go new file mode 100644 index 0000000000..f30b85309f --- /dev/null +++ b/internal/exec/runtime/validate_test.go @@ -0,0 +1,118 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +const exactBudgetSrc = `package test { + private import ScalarValues::Real; + + part def Engine { + attribute power : Real = 300.0; + assert constraint powered { power > 0.0 } + } + + part def Car { + attribute mass : Real = 1500.0; + part engine : Engine; + assert constraint massive { mass > 0.0 } + } + + part car : Car; +}` + +// A walk whose budget runs out on the last object it reaches has reached the +// whole graph: the object is validated whole, and only a walk with more to read +// is bounded. +func TestValidateObjectExactBudgetIsComplete(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, exactBudgetSrc)) + car, err := ctx.Instantiate(lookupOne(t, idx, "test::car")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + + w := ctx.walkHeldObjects(car, maxMaterializeBudget) + if w.bounded { + t.Fatal("default budget: bounded, want the finite graph walked whole") + } + spent := maxMaterializeBudget - w.budget + if spent < 2 { + t.Fatalf("walk spent %d, want at least the engine's read and object", spent) + } + + report, err := ctx.validateObjectWithin(car, nil, spent) + if err != nil { + t.Fatalf("validate: %v", err) + } + if report.Bounded { + t.Error("exact budget: bounded, want the finite graph walked whole") + } + if len(report.Verdicts) != 2 { + t.Errorf("exact budget: %d verdict(s), want 2", len(report.Verdicts)) + } + if !report.Valid() { + t.Error("exact budget: valid = false, want true") + } + + report, err = ctx.validateObjectWithin(car, nil, spent-1) + if err != nil { + t.Fatalf("validate: %v", err) + } + if !report.Bounded { + t.Error("budget one short: not bounded") + } + if report.Valid() { + t.Error("budget one short: valid = true, want false") + } +} + +const carriedSatisfactionSrc = `package test { + private import ScalarValues::Real; + + part def Bus { + attribute dryMass : Real; + } + requirement def MassLimit { + subject bus : Bus; + require constraint { bus.dryMass <= 1200.0 } + } + requirement massLimit : MassLimit; + + part spacecraft { + part bus : Bus { + attribute :>> dryMass = 1150.0; + } + satisfy massLimit by bus; + } +}` + +// A satisfaction the validated object's own type states is also found in the +// document scope it is stated in; when the two are symbols of different scope +// trees over one document, it is still one assertion, checked and reported once. +func TestValidateObjectReportsACarriedSatisfactionOnceAcrossScopeTrees(t *testing.T) { + file := parseAndBuild(t, carriedSatisfactionSrc) + idx, _, ctx := buildRuntimeWithLibraries(t, "", file) + spacecraft, err := ctx.Instantiate(lookupOne(t, idx, "test::spacecraft")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + // Re-indexing the document builds a second scope tree over the same nodes. + idx.AddDocument("", file) + idx.ExpandWildcardImports() + + report, err := ctx.ValidateObject(spacecraft, []*symbols.Scope{idx.DocumentRoot("")}) + if err != nil { + t.Fatalf("validate: %v", err) + } + if len(report.Verdicts) != 1 { + for _, v := range report.Verdicts { + t.Logf("verdict: %s on %v: %v", v.Text, v.Path, v.Status) + } + t.Fatalf("%d verdict(s), want the satisfaction once", len(report.Verdicts)) + } + if v := report.Verdicts[0]; v.Kind != AssertionSatisfaction || v.Status != ValidationHolds { + t.Errorf("verdict = %s %v, want a holding satisfaction", v.Kind, v.Status) + } +} diff --git a/internal/exec/runtime/value.go b/internal/exec/runtime/value.go new file mode 100644 index 0000000000..699bcf7e77 --- /dev/null +++ b/internal/exec/runtime/value.go @@ -0,0 +1,743 @@ +package runtime + +import ( + "fmt" + "math" + "sort" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// ValueKind distinguishes runtime value types. +type ValueKind int + +const ( + ValInvalid ValueKind = iota + ValConst // wraps semantics.Value (int/real/bool/infinity) + ValNull + ValString + ValInstance + ValSequence + ValSet + ValExpr // wraps unevaluated AST node for delayed evaluation (e.g., BodyExpr for select/collect) + ValQuantity // a magnitude and the measurement unit it is expressed in + ValVariant // the variant selected for a variation, and the object it materializes + ValEnumLiteral // one literal of an enumeration definition, identified by itself + ValComplex // one complex number, its real and imaginary parts together + ValArray // a Collections::Array: its dimensions and its row-major elements + ValVector // a NumericalVectorValue: the numbers of its one dimension + ValVectorQuantity // a VectorQuantityValue: a vector with a measurement unit per axis + ValMeasurementRef // a ScalarMeasurementReference: a unit by declaration and reduction + ValTensorQuantity // a TensorQuantityValue: an array of numbers with a measurement unit per component + ValCoordinateFrame // a VectorMeasurementReference: a frame's axes, or a measurement scale's one + ValCoordinateTransformation // a CoordinateTransformation: a placement of one frame in another + ValFunction // a calc as a value: its lowered shape closed over the environment it was read in + ValMetaobject // an element of the model as an instance of its reflective metaclass (`x meta T`) + ValUndetermined // a model-level result the model does not determine; see Undetermined + + // valueKindCount bounds the kinds; TestEveryValueKindIsDispatched walks them. + valueKindCount +) + +// unknownText stands for a value or reference that is absent where a name is rendered. +const unknownText = "" + +// unnamedText stands for an element that declares no name where a name is rendered. +const unnamedText = "" + +// FormatValue renders a value with the notation used by user-facing runtime +// results and diagnostics. +func FormatValue(v Value) string { + switch v.Kind { + case ValConst: + return semantics.FormatConst(v.Const) + case ValNull: + return "null" + case ValString: + return strconv.Quote(v.Str()) + case ValInstance: + return fmt.Sprintf("instance(%d)", v.Instance) + case ValSequence: + seq := v.Sequence() + if seq == nil { + return "[]" + } + return "[" + strings.Join(formatValueElements(seq.Elements()), ", ") + "]" + case ValSet: + set := v.Set() + if set == nil { + return "Set{}" + } + parts := formatValueElements(set.Elements()) + return "Set{" + strings.Join(parts, ", ") + "}" + case ValVariant: + variant := v.Variant() + if variant == nil { + return "" + } + if v.Instance != 0 { + return fmt.Sprintf("%s (Instance ID: %d)", variant.Name, v.Instance) + } + return variant.Name + case ValEnumLiteral: + return v.LiteralText() + case ValQuantity: + q := v.Quantity() + if q == nil { + return unknownText + } + return q.TextWithMagnitude(semantics.FormatConst(q.Num)) + case ValComplex: + return FormatComplex(v.Complex()) + case ValArray: + return v.Array().Format(FormatValue) + case ValVector: + return v.Vector().format(semantics.FormatConst) + case ValVectorQuantity: + return v.VectorQuantity().format(semantics.FormatConst) + case ValMeasurementRef: + return v.MeasurementRef().String() + case ValTensorQuantity: + return v.TensorQuantity().format(semantics.FormatConst) + case ValCoordinateFrame: + return v.CoordinateFrame().String() + case ValCoordinateTransformation: + return v.CoordinateTransformation().String() + case ValExpr: + return "" + case ValFunction: + return v.FunctionName() + case ValMetaobject: + return v.MetaobjectText() + case ValUndetermined: + return UndeterminedText + default: + return unknownText + } +} + +// FormatComplex renders a complex number as its parts read, `1.0 + 2.0i`, with +// the imaginary part's sign as the operator: `1.0 - 2.0i`. +func FormatComplex(z complex128) string { + re, im := real(z), imag(z) + sign := " + " + if math.Signbit(im) { + sign, im = " - ", -im + } + return semantics.FormatReal(re) + sign + semantics.FormatReal(im) + "i" +} + +func formatValueElements(elements []Value) []string { + parts := make([]string, len(elements)) + for i, element := range elements { + parts[i] = FormatValue(element) + } + return parts +} + +// String names the kind, so diagnostics quoting it read as more than an index. +func (k ValueKind) String() string { + switch k { + case ValConst: + return "constant" + case ValNull: + return "null" + case ValString: + return "string" + case ValInstance: + return "instance" + case ValSequence: + return "sequence" + case ValSet: + return "set" + case ValExpr: + return "expression" + case ValQuantity: + return "quantity" + case ValVariant: + return "variant" + case ValEnumLiteral: + return "enumeration literal" + case ValComplex: + return "complex number" + case ValArray: + return "array" + case ValVector: + return "vector" + case ValVectorQuantity: + return "vector quantity" + case ValMeasurementRef: + return "measurement reference" + case ValTensorQuantity: + return "tensor quantity" + case ValCoordinateFrame: + return "coordinate frame" + case ValCoordinateTransformation: + return "coordinate transformation" + case ValFunction: + return "function" + case ValMetaobject: + return "metaobject" + case ValUndetermined: + return "undetermined" + default: + return "invalid" + } +} + +// Value is a runtime-evaluable value. The scalar payload stays inline because +// arithmetic copies values through every evaluator frame; the rarer payloads +// share one slot so the struct stays at 64 bytes. +type Value struct { + Kind ValueKind + Const semantics.Value // ValConst: reuse static evaluator + Instance int64 // ValInstance: instance ID; ValVariant: materialized object, 0 for none + // ref holds the kind-specific payload of the remaining kinds: a string + // (ValString), *Sequence, *Set, *exprValue (ValExpr), *Quantity, a complex128 + // (ValComplex), *Array, *Vector, *VectorQuantity, *MeasurementRef, *TensorQuantity, + // *functionValue (ValFunction), *metaobjectValue (ValMetaobject), *Undetermined, or + // the *symbols.Symbol of a variant (ValVariant) or enumeration literal (ValEnumLiteral). + // A scalar that is the value of an enumeration literal holds an *enumerated wrapping its payload. + ref any +} + +// enumerated is the payload of a scalar evaluated from a scalar-valued enumeration +// literal, which is of its enumeration; a scalar written bare carries none. +type enumerated struct { + literal *symbols.Symbol + payload any +} + +// payload is the kind-specific payload, unwrapped from an enumerated scalar. +func (v Value) payload() any { + if e, ok := v.ref.(*enumerated); ok { + return e.payload + } + return v.ref +} + +// metaobjectValue is an element viewed as an instance of its reflective +// metaclass. Its identity is the element's: the metaclass only says what it is. +type metaobjectValue struct { + element *symbols.Symbol + metaclass *symbols.Symbol +} + +// NewMetaobject is the value `x meta T` yields for the element x names: that +// element as an instance of metaclass, the reflective metaclass of its declaration. +func NewMetaobject(element, metaclass *symbols.Symbol) Value { + return Value{Kind: ValMetaobject, ref: &metaobjectValue{element: element, metaclass: metaclass}} +} + +// MetaobjectElement is the element a ValMetaobject denotes; nil for every other kind. +func (v Value) MetaobjectElement() *symbols.Symbol { + if m, ok := v.ref.(*metaobjectValue); ok && v.Kind == ValMetaobject { + return m.element + } + return nil +} + +// MetaobjectClass is the reflective metaclass a ValMetaobject is an instance of; +// nil for every other kind. +func (v Value) MetaobjectClass() *symbols.Symbol { + if m, ok := v.ref.(*metaobjectValue); ok && v.Kind == ValMetaobject { + return m.metaclass + } + return nil +} + +// MetaobjectText renders a metaobject as the element it denotes and the +// metaclass it is an instance of: `meta(Pkg::x : KerML::Feature)`. +func (v Value) MetaobjectText() string { + element, metaclass := v.MetaobjectElement(), v.MetaobjectClass() + if element == nil { + return "" + } + return fmt.Sprintf("meta(%s : %s)", symbolQualifiedText(element), symbolQualifiedText(metaclass)) +} + +// symbolQualifiedText is a symbol's qualified name in its scope tree, else its own +// name, else unknownText. An unnamed symbol is `` under its owner's name. +func symbolQualifiedText(sym *symbols.Symbol) string { + if sym == nil { + return unknownText + } + if sym.Name == "" { + if sym.OwnerScope != nil && sym.OwnerScope.Owner() != nil { + if owner := symbols.FQNOf(sym.OwnerScope.Owner()); owner != "" { + return owner + "::" + unnamedText + } + } + return unnamedText + } + if fqn := symbols.FQNOf(sym); fqn != "" { + return fqn + } + return sym.Name +} + +// NewComplex is the value of one complex number. One with a zero imaginary part +// is the Real its real part is, as 4 / 2 is the Integer 2: equal to it, and +// classified as it is. +func NewComplex(z complex128) Value { + return Value{Kind: ValComplex, ref: z} +} + +// NewStringValue is the value of a string. +func NewStringValue(s string) Value { + return Value{Kind: ValString, ref: s} +} + +// NewSequenceValue wraps an ordered collection. A nil sequence is the empty one. +func NewSequenceValue(seq *Sequence) Value { + return Value{Kind: ValSequence, ref: seq} +} + +// NewSetValue wraps a unique collection. A nil set is the empty one. +func NewSetValue(set *Set) Value { + return Value{Kind: ValSet, ref: set} +} + +// exprValue is a deferred expression closed over the environment it was +// written in; a nil env evaluates it where it is applied. +type exprValue struct { + node ast.Node + env *EvalContext +} + +// NewExprValue defers evaluation of an expression, closing over env: the +// bindings and scope in force where it was written. +func NewExprValue(node ast.Node, env *EvalContext) Value { + return Value{Kind: ValExpr, ref: &exprValue{node: node, env: env}} +} + +// NewQuantityValue wraps a magnitude expressed in a measurement unit. +func NewQuantityValue(q *Quantity) Value { + return Value{Kind: ValQuantity, ref: q} +} + +// NewVariantValue is the variant a variation was bound to, with the object it +// materialized (0 when it materializes none). +func NewVariantValue(variant *symbols.Symbol, instance int64) Value { + return Value{Kind: ValVariant, ref: variant, Instance: instance} +} + +// NewEnumLiteral is the value an enumeration literal that declares no value of +// its own evaluates to: the identity of that literal. +func NewEnumLiteral(sym *symbols.Symbol) Value { + return Value{Kind: ValEnumLiteral, ref: sym} +} + +// ofLiteral is v as the value of a scalar-valued enumeration literal: equal to +// and computing as the scalar, and of the literal's enumeration. +func (v Value) ofLiteral(sym *symbols.Symbol) Value { + if !isScalar(v) { + return v + } + v.ref = &enumerated{literal: sym, payload: v.payload()} + return v +} + +// EnumeratedValue is scalar as the value of the enumeration literal sym: what +// `high = 3` evaluates to, rebuilt from a wire form that carries both. +func EnumeratedValue(sym *symbols.Symbol, scalar Value) Value { + return scalar.ofLiteral(sym) +} + +// Scalar is v shorn of any enumeration literal identity: the bare scalar a +// scalar-valued literal equals, or v itself for any other value. +func (v Value) Scalar() Value { + if e, ok := v.ref.(*enumerated); ok { + v.ref = e.payload + } + return v +} + +// EnumerationLiteral is the literal a value is: a ValEnumLiteral itself, or +// the literal a scalar was evaluated from. Nil for a value that is no literal. +func (v Value) EnumerationLiteral() *symbols.Symbol { + if v.Kind == ValEnumLiteral { + return v.Literal() + } + if e, ok := v.ref.(*enumerated); ok { + return e.literal + } + return nil +} + +// Str is the text of a ValString; "" for every other kind. +// isBool reports whether v is a boolean constant. +func (v Value) isBool() bool { + return v.Kind == ValConst && v.Const.Kind == semantics.ValBool +} + +func (v Value) Str() string { + if v.Kind != ValString { + return "" + } + s, _ := v.payload().(string) + return s +} + +// Sequence is the collection of a ValSequence; nil for every other kind. +func (v Value) Sequence() *Sequence { + if v.Kind != ValSequence { + return nil + } + seq, _ := v.ref.(*Sequence) + return seq +} + +// Set is the collection of a ValSet; nil for every other kind. +func (v Value) Set() *Set { + if v.Kind != ValSet { + return nil + } + set, _ := v.ref.(*Set) + return set +} + +// Expr is the deferred expression of a ValExpr; nil for every other kind. +func (v Value) Expr() ast.Node { + if v.Kind != ValExpr { + return nil + } + if closure, ok := v.ref.(*exprValue); ok { + return closure.node + } + return nil +} + +// exprEnv is the environment a ValExpr closes over, or in where it closes over +// none. Tracing is the applying context's: a body applied later is recorded +// as the evaluation reaching it is, not as its creation was. +func (v Value) exprEnv(in *EvalContext) *EvalContext { + closure, ok := v.ref.(*exprValue) + if !ok || v.Kind != ValExpr || closure.env == nil { + return in + } + if closure.env.trace == in.trace { + return closure.env + } + env := *closure.env + env.trace = in.trace + return &env +} + +// exprScope is the scope a ValExpr closes over, or in where it closes over none. +func (v Value) exprScope(in *symbols.Scope) *symbols.Scope { + closure, ok := v.ref.(*exprValue) + if !ok || v.Kind != ValExpr || closure.env == nil { + return in + } + return closure.env.scope +} + +// Quantity is the payload of a ValQuantity; nil for every other kind. +func (v Value) Quantity() *Quantity { + if v.Kind != ValQuantity { + return nil + } + q, _ := v.ref.(*Quantity) + return q +} + +// Array is the payload of a ValArray; nil for every other kind. +func (v Value) Array() *Array { + if v.Kind != ValArray { + return nil + } + a, _ := v.ref.(*Array) + return a +} + +// Vector is the payload of a ValVector; nil for every other kind. +func (v Value) Vector() *Vector { + if v.Kind != ValVector { + return nil + } + vec, _ := v.ref.(*Vector) + return vec +} + +// VectorQuantity is the payload of a ValVectorQuantity; nil for every other kind. +func (v Value) VectorQuantity() *VectorQuantity { + if v.Kind != ValVectorQuantity { + return nil + } + vq, _ := v.ref.(*VectorQuantity) + return vq +} + +// MeasurementRef is the payload of a ValMeasurementRef; nil for every other kind. +func (v Value) MeasurementRef() *MeasurementRef { + if v.Kind != ValMeasurementRef { + return nil + } + ref, _ := v.ref.(*MeasurementRef) + return ref +} + +// TensorQuantity is the payload of a ValTensorQuantity; nil for every other kind. +func (v Value) TensorQuantity() *TensorQuantity { + if v.Kind != ValTensorQuantity { + return nil + } + tq, _ := v.ref.(*TensorQuantity) + return tq +} + +// CoordinateFrame is the payload of a ValCoordinateFrame; nil for every other kind. +func (v Value) CoordinateFrame() *CoordinateFrame { + if v.Kind != ValCoordinateFrame { + return nil + } + frame, _ := v.ref.(*CoordinateFrame) + return frame +} + +// CoordinateTransformation is the payload of a ValCoordinateTransformation; nil +// for every other kind. +func (v Value) CoordinateTransformation() *CoordinateTransformation { + if v.Kind != ValCoordinateTransformation { + return nil + } + t, _ := v.ref.(*CoordinateTransformation) + return t +} + +// Complex is the number a ValComplex is; 0 for every other kind. +func (v Value) Complex() complex128 { + if v.Kind != ValComplex { + return 0 + } + z, _ := v.payload().(complex128) + return z +} + +// Variant is the variant a ValVariant was bound to; nil for every other kind. +func (v Value) Variant() *symbols.Symbol { + if v.Kind != ValVariant { + return nil + } + sym, _ := v.ref.(*symbols.Symbol) + return sym +} + +// Literal is the enumeration literal a ValEnumLiteral is: a literal is its own +// identity, so two values are the same literal exactly when they name the same +// declaration. Nil for every other kind. +func (v Value) Literal() *symbols.Symbol { + if v.Kind != ValEnumLiteral { + return nil + } + sym, _ := v.ref.(*symbols.Symbol) + return sym +} + +// LiteralText renders an enumeration literal as it is written, qualified by the +// enumeration it is a literal of: `Color::red`. +func (v Value) LiteralText() string { + lit := v.Literal() + if lit == nil { + return "" + } + if enum := semantics.EnumerationOwning(lit); enum != nil { + return enum.Name + "::" + lit.Name + } + return lit.Name +} + +// Object returns the object a value denotes: an instance, or the object a +// selected variant materialized. +func (v Value) Object() (int64, bool) { + switch v.Kind { + case ValInstance: + return v.Instance, true + case ValVariant: + return v.Instance, v.Instance != 0 + default: + return 0, false + } +} + +// Sequence is an ordered collection (slice-backed). One read empty from a +// quantity-typed declaration remembers the unit its elements would measure in. +type Sequence struct { + elements []Value + elementUnit *Unit +} + +// NewSequence creates an empty Sequence. +func NewSequence() *Sequence { + return &Sequence{elements: make([]Value, 0)} +} + +// NewEmptySequenceOf is the empty sequence of quantities measured in unit, which +// types the identity an aggregate of it yields. +func NewEmptySequenceOf(unit Unit) Value { + return NewSequenceValue(&Sequence{elements: make([]Value, 0), elementUnit: &unit}) +} + +// ElementUnit is the unit an empty sequence's elements are declared in; false for +// a sequence holding elements or read from no quantity-typed declaration. +func (s *Sequence) ElementUnit() (Unit, bool) { + if s == nil || s.elementUnit == nil || len(s.elements) != 0 { + return Unit{}, false + } + return *s.elementUnit, true +} + +// Append adds a value to the end of the sequence. +func (s *Sequence) Append(val Value) { + s.elements = append(s.elements, val) +} + +// At returns the element at the given index (0-based). +func (s *Sequence) At(index int) (Value, error) { + if index < 0 || index >= len(s.elements) { + return Value{}, fmt.Errorf("index %d out of range [0, %d)", index, len(s.elements)) + } + return s.elements[index], nil +} + +// Size returns the number of elements. +func (s *Sequence) Size() int { + return len(s.elements) +} + +// Elements returns the underlying slice (for iteration). +func (s *Sequence) Elements() []Value { + return s.elements +} + +// Set is a unique collection backed by hash buckets and exact comparisons. A set +// has no inherent order, but enumerating one has to answer in some order, and +// the one it answers in is the canonical order (see canonicalLess): equal sets +// enumerate alike, whatever order their elements were added in. +type Set struct { + elements map[valueKey][]Value + order []Value // insertion order, the tie-break canonical order falls back to + sorted []Value // canonical order, built on the first read after an Add + ctx *Context // judges membership; nil compares values with no context +} + +// NewSet creates an empty Set whose membership needs no context. +func NewSet() *Set { + return NewSetIn(nil) +} + +// NewSetIn creates an empty Set judging membership in ctx: a point on a scale is +// the member its magnitude on the reference is. A nil ctx judges with no context. +func NewSetIn(ctx *Context) *Set { + return &Set{elements: make(map[valueKey][]Value), ctx: ctx} +} + +// Add inserts a value into the set (deduplicates by exact value equality). +func (s *Set) Add(val Value) { + key := s.ctx.valueKey(val) + bucket := s.elements[key] + for _, elem := range bucket { + if s.ctx.valueEqual(elem, val) { + return + } + } + s.elements[key] = append(bucket, val) + s.order = append(s.order, val) + s.sorted = nil +} + +// Contains checks if the value is in the set. +func (s *Set) Contains(val Value) bool { + for _, elem := range s.elements[s.ctx.valueKey(val)] { + if s.ctx.valueEqual(elem, val) { + return true + } + } + return false +} + +// Size returns the number of unique elements. +func (s *Set) Size() int { + if s == nil { + return 0 + } + return len(s.order) +} + +// Elements returns all elements in canonical order. +func (s *Set) Elements() []Value { + if s.sorted == nil && len(s.order) > 0 { + s.sorted = append([]Value(nil), s.order...) + sort.SliceStable(s.sorted, func(i, j int) bool { return s.ctx.canonicalCompare(s.sorted[i], s.sorted[j]) < 0 }) + } + return append([]Value(nil), s.sorted...) +} + +// Equal holds when the sets have the same members, in whatever order. +func (s *Set) Equal(other *Set) bool { + if s == nil { + return other.Size() == 0 + } + return s.ctx.setsEqual(s, other) +} + +// setsEqual holds when the sets have the same members in the context. A set +// bucketed under another context may store two elements the context makes one, +// so members are counted as the context tells them apart, not as stored. +func (ctx *Context) setsEqual(s, other *Set) bool { + if s == nil || other == nil { + return s.Size() == 0 && other.Size() == 0 + } + if s.ctx == ctx && other.ctx == ctx { + return s.Size() == other.Size() && ctx.membersWithin(s, other) + } + return ctx.membersWithin(s, other) && ctx.membersWithin(other, s) +} + +// membersWithin holds when every member of s is one of other in the context. +func (ctx *Context) membersWithin(s, other *Set) bool { + for _, elem := range s.order { + if !ctx.contains(other, elem) { + return false + } + } + return true +} + +// contains is Set.Contains judged in the context rather than the set's own. +// A set bucketed under another context is scanned, since its keys may differ. +func (ctx *Context) contains(s *Set, val Value) bool { + if s.ctx != ctx { + for _, elem := range s.order { + if ctx.valueEqual(elem, val) { + return true + } + } + return false + } + for _, elem := range s.elements[ctx.valueKey(val)] { + if ctx.valueEqual(elem, val) { + return true + } + } + return false +} + +// setOf builds a set value holding the distinct elements, judged with no context. +func setOf(elements []Value) Value { + return (*Context)(nil).setOf(elements) +} + +// setOf builds a set value holding the elements distinct in the context. +func (ctx *Context) setOf(elements []Value) Value { + set := NewSetIn(ctx) + for _, elem := range elements { + set.Add(elem) + } + return NewSetValue(set) +} diff --git a/internal/core/runtime/value_conformance_test.go b/internal/exec/runtime/value_conformance_test.go similarity index 99% rename from internal/core/runtime/value_conformance_test.go rename to internal/exec/runtime/value_conformance_test.go index f8d43e84de..48789dcf98 100644 --- a/internal/core/runtime/value_conformance_test.go +++ b/internal/exec/runtime/value_conformance_test.go @@ -5,8 +5,8 @@ import ( "math" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // valueConformanceContext builds a runtime over a model whose feature values and calc diff --git a/internal/core/runtime/value_equality.go b/internal/exec/runtime/value_equality.go similarity index 96% rename from internal/core/runtime/value_equality.go rename to internal/exec/runtime/value_equality.go index 857e9566c0..3638f49c6b 100644 --- a/internal/core/runtime/value_equality.go +++ b/internal/exec/runtime/value_equality.go @@ -3,8 +3,8 @@ package runtime import ( "hash/fnv" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // valueKey is a comparable projection of Value for use as a map key. diff --git a/internal/core/runtime/value_kinds_test.go b/internal/exec/runtime/value_kinds_test.go similarity index 98% rename from internal/core/runtime/value_kinds_test.go rename to internal/exec/runtime/value_kinds_test.go index 4989c08be5..65a515cf14 100644 --- a/internal/core/runtime/value_kinds_test.go +++ b/internal/exec/runtime/value_kinds_test.go @@ -4,9 +4,9 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // kindSamples is two distinct values of every kind. A kind added to value.go diff --git a/internal/core/runtime/value_test.go b/internal/exec/runtime/value_test.go similarity index 98% rename from internal/core/runtime/value_test.go rename to internal/exec/runtime/value_test.go index 84e5bd5e2f..586f5fe3b7 100644 --- a/internal/core/runtime/value_test.go +++ b/internal/exec/runtime/value_test.go @@ -5,9 +5,9 @@ import ( "testing" "unsafe" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) func TestValueConstWrapping(t *testing.T) { diff --git a/internal/exec/runtime/variation.go b/internal/exec/runtime/variation.go new file mode 100644 index 0000000000..eaa6d85c1a --- /dev/null +++ b/internal/exec/runtime/variation.go @@ -0,0 +1,222 @@ +package runtime + +import ( + "fmt" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// variantReference is the value a name resolving to a variant evaluates to: the +// choice itself, which a variation is bound to and compared with. +func variantReference(sym *symbols.Symbol) Value { + return NewVariantValue(sym, 0) +} + +// IsVariationFeature reports whether a feature is a variation point, whose feature value +// holds the variant it is bound to rather than an object of itself. +func (ctx *Context) IsVariationFeature(feat *EffectiveFeature) bool { + return feat != nil && ctx.model.semantics.IsVariationFeature(feat.Symbol) +} + +// variantSegment reads a variant named through the variation feature it belongs +// to, as in `ring.nesting::nestingTrue`, returning it and the segments left. +func (ec *EvalContext) variantSegment(feat *EffectiveFeature, rest []ast.NameSegment) (*symbols.Symbol, []ast.NameSegment, bool) { + if len(rest) == 0 || feat == nil || !ec.ctx.model.semantics.IsVariationFeature(feat.Symbol) { + return nil, nil, false + } + variant, ok := ec.ctx.model.semantics.VariantOf(feat.Symbol, rest[0].Text) + if !ok { + return nil, nil, false + } + return variant, rest[1:], true +} + +// variantSummary names the variants a variation offers, for a report about a +// selection that is not one of them. +func (ctx *Context) variantSummary(variation *symbols.Symbol) string { + variants := ctx.model.semantics.VariantsOf(variation) + if len(variants) == 0 { + return "it declares no variants" + } + names := make([]string, 0, len(variants)) + for _, v := range variants { + names = append(names, v.Name) + } + return "variants: " + strings.Join(names, ", ") +} + +// bindVariation resolves what a variation feature is bound to into the value its +// feature value holds: the selected variant's value, or an object of it (SysML v2 §7.20). +// Anything that is not one of the feature's variants is reported. +func (ctx *Context) bindVariation(feat *EffectiveFeature, selection Value, owner int64) (Value, error) { + name := feat.Name + switch selection.Kind { + case ValSequence: + return ctx.bindOneVariant(feat, selection.Sequence().Elements(), owner) + case ValSet: + return ctx.bindOneVariant(feat, selection.Set().Elements(), owner) + case ValVariant: + default: + if ctx.model.semantics.IsVariationFeature(feat.Symbol) { + return Value{}, fmt.Errorf("%w: variation %s is bound to a %s", ErrNotAVariant, name, selection.Kind) + } + return selection, nil + } + + variant := selection.Variant() + if !ctx.model.semantics.SelectsVariantOf(feat.Symbol, variant) { + return Value{}, fmt.Errorf("%w: %s is not a variant of %s (%s)", + ErrNotAVariant, variant.Name, name, ctx.variantSummary(feat.Symbol)) + } + ctx.selectVariant(variantSelection{owner: owner, variation: name}, variant.Name) + return ctx.variantValue(feat.Symbol, variant, owner) +} + +// selectVariant records the variant a variation is bound to for routing to consult; +// a probe or transaction rolled back restores the selection as it was. +func (ctx *Context) selectVariant(selection variantSelection, variant string) { + prior, selected := ctx.selectedVariants[selection] + if selected && prior == variant { + return + } + ctx.noteProbeUndo(func() { + if selected { + ctx.selectedVariants[selection] = prior + } else { + delete(ctx.selectedVariants, selection) + } + }) + ctx.selectedVariants[selection] = variant +} + +// bindVariationOf binds a value read from a feature's declaration when that +// feature is a variation, so what a legal selection is does not depend on +// whether the variation is read through an object or through its declaration. +func (ec *EvalContext) bindVariationOf(sym *symbols.Symbol, val Value) (Value, error) { + if !ec.ctx.model.semantics.IsVariationFeature(sym) { + return val, nil + } + owner := int64(0) + if ec.self != nil { + owner = ec.self.ID + } + return ec.ctx.bindVariation(&EffectiveFeature{Name: sym.Name, Symbol: sym}, val, owner) +} + +// bindOneVariant binds a variation bound to a collection: exactly one variant +// may be selected, so selecting several, or anything that is not a variant, is +// reported rather than resolved. +func (ctx *Context) bindOneVariant(feat *EffectiveFeature, elements []Value, owner int64) (Value, error) { + var selected []Value + for _, el := range elements { + if el.Kind != ValVariant { + return Value{}, fmt.Errorf("%w: variation %s is bound to a collection holding a %s (%s)", + ErrNotAVariant, feat.Name, el.Kind, ctx.variantSummary(feat.Symbol)) + } + selected = append(selected, el) + } + switch { + case len(selected) > 1: + names := make([]string, 0, len(selected)) + for _, el := range selected { + names = append(names, el.Variant().Name) + } + return Value{}, fmt.Errorf("%w: variation %s selects %d variants (%s)", + ErrMultipleVariants, feat.Name, len(selected), strings.Join(names, ", ")) + case len(selected) == 1: + return ctx.bindVariation(feat, selected[0], owner) + default: + return Value{}, fmt.Errorf("%w: variation %s is bound to a collection naming no variant (%s)", + ErrNotAVariant, feat.Name, ctx.variantSummary(feat.Symbol)) + } +} + +// variantValue materializes a selected variant: the value it declares, or an +// object of it carrying its nested values and connections. The object belongs to +// the owner that selected it, materialized once for that owner. +func (ctx *Context) variantValue(variation, variant *symbols.Symbol, owner int64) (Value, error) { + if value := semantics.VariantValue(variant); value != nil { + ec := NewEvalContext(ctx, DeclScope(variant)) + val, err := ec.Eval(value) + if err != nil { + return Value{}, fmt.Errorf("variant %s: %w", variant.Name, err) + } + return val, nil + } + key := variantObject{owner: owner, variation: variation, variant: variant} + if id, ok := ctx.variantObjects[key]; ok { + if _, live := ctx.instances[id]; live { + return NewVariantValue(variant, id), nil + } + } + var inst *Instance + err := ctx.variantInstance(variant, owner, func(created *Instance) { + inst = created + ctx.variantObjects[key] = inst.ID + }) + if err != nil { + return Value{}, fmt.Errorf("variant %s: %w", variant.Name, err) + } + return NewVariantValue(variant, inst.ID), nil +} + +// variantInstance builds the object a selected variant stands for. A variant +// that is itself a connector — `variant interface engagementRingToBandConnected +// connect engagementRing.ringPort to band.ringPort` — is the connection the +// selection realizes, so it is materialized as a connector of the object that +// selected it, with its ends attached to that object's features. A variant of +// any other kind is an ordinary object of itself. keep receives the object once created. +func (ctx *Context) variantInstance(variant *symbols.Symbol, owner int64, keep func(*Instance)) error { + if !ctx.model.semantics.IsConnectorObjectUsage(variant) { + inst, err := ctx.instantiateAs(variant, 0) + if err != nil { + return err + } + keep(inst) + return nil + } + ownerInst, ok := ctx.Instance(owner) + if !ok { + return fmt.Errorf("%w: %s connects features of the object selecting it, and no object selected it", + ErrConnectorEnd, variant.Name) + } + return ctx.materializeConnector(ownerInst, variant, ctx.variantConnectorBase(variant), keep) +} + +// variantConnectorBase returns the type an object of a variant connector is +// materialized from: the definition the variant names, else the one its +// variation names, else the variant itself when neither is typed — a +// `variant interface … connect …` under an untyped `variation interface` is +// implicitly typed, exactly as a standalone untyped connector usage is. +func (ctx *Context) variantConnectorBase(variant *symbols.Symbol) *symbols.Symbol { + if base := ctx.CompositeTypeOf(&EffectiveFeature{Name: variant.Name, Symbol: variant}); base != nil { + return base + } + return variant +} + +// variantAsValue resolves a variant to the value it declares, so comparing a +// value with a variant compares what the variant stands for. A variant +// declaring no value is identified by the selection itself. +func (ctx *Context) variantAsValue(v Value) (Value, error) { + if v.Kind != ValVariant || v.Variant() == nil { + return v, nil + } + if semantics.VariantValue(v.Variant()) == nil { + return v, nil + } + // A variant reached this way declares a value, so no object of it is needed. + return ctx.variantValue(nil, v.Variant(), 0) +} + +// variantObject keys the object a variant stands for by the selection that made +// it: two owners, or two variation points read without an owner, each have their +// own object. +type variantObject struct { + owner int64 + variation *symbols.Symbol + variant *symbols.Symbol +} diff --git a/internal/core/runtime/vector_functions.go b/internal/exec/runtime/vector_functions.go similarity index 99% rename from internal/core/runtime/vector_functions.go rename to internal/exec/runtime/vector_functions.go index 62386a0cf2..fb0f4ff211 100644 --- a/internal/core/runtime/vector_functions.go +++ b/internal/exec/runtime/vector_functions.go @@ -4,8 +4,8 @@ import ( "fmt" "math" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // --------------------------------------------------------------------------- diff --git a/internal/core/runtime/verification_cases.go b/internal/exec/runtime/verification_cases.go similarity index 84% rename from internal/core/runtime/verification_cases.go rename to internal/exec/runtime/verification_cases.go index 8887f6be4f..35cf4fd422 100644 --- a/internal/core/runtime/verification_cases.go +++ b/internal/exec/runtime/verification_cases.go @@ -1,8 +1,8 @@ package runtime import ( - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // VerificationsOf returns the verification cases declared in scope, and in the @@ -11,32 +11,54 @@ import ( func (ctx *Context) VerificationsOf(scope *symbols.Scope, req *symbols.Symbol) []*symbols.Symbol { var out []*symbols.Symbol seen := make(map[*symbols.Symbol]bool) - ctx.collectVerifications(scope, req, seen, &out) + for _, sym := range ctx.model.verificationCasesIn(scope) { + if seen[sym] { + continue + } + if req == nil || verifies(ctx.VerifiedRequirements(sym), req) { + seen[sym] = true + out = append(out, sym) + } + } return out } -func (ctx *Context) collectVerifications(scope *symbols.Scope, req *symbols.Symbol, seen map[*symbols.Symbol]bool, out *[]*symbols.Symbol) { +// verificationCasesIn returns the verification cases declared in scope and the +// scopes nested within it, in declaration order, memoized per scope. +func (m *Model) verificationCasesIn(scope *symbols.Scope) []*symbols.Symbol { if scope == nil { - return + return nil + } + if m == nil { + var out []*symbols.Symbol + collectVerificationCases(scope, &out) + return out + } + if cases, done := m.verificationCases[scope]; done { + return cases } + var out []*symbols.Symbol + collectVerificationCases(scope, &out) + m.verificationCases[scope] = out + return out +} + +func collectVerificationCases(scope *symbols.Scope, out *[]*symbols.Symbol) { for _, sym := range scopeMemberSymbols(scope) { - if !IsVerificationCaseSymbol(sym) || seen[sym] { - continue - } - if req == nil || verifies(ctx.VerifiedRequirements(sym), req) { - seen[sym] = true + if IsVerificationCaseSymbol(sym) { *out = append(*out, sym) } } for _, child := range scope.Children() { - ctx.collectVerifications(child, req, seen, out) + collectVerificationCases(child, out) } } -// verifies reports whether req is among the requirements verified. +// verifies reports whether req is among the requirements verified, whichever +// scope tree each symbol was reached through. func verifies(verified []*symbols.Symbol, req *symbols.Symbol) bool { for _, sym := range verified { - if sym == req { + if symbols.SameElement(sym, req) { return true } } diff --git a/internal/core/runtime/verification_run.go b/internal/exec/runtime/verification_run.go similarity index 98% rename from internal/core/runtime/verification_run.go rename to internal/exec/runtime/verification_run.go index afbd4dc658..1e2625fc15 100644 --- a/internal/core/runtime/verification_run.go +++ b/internal/exec/runtime/verification_run.go @@ -4,9 +4,9 @@ import ( "errors" "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // verdictKindName is the enumeration a verification case's verdict is a literal diff --git a/internal/core/runtime/verification_verdict_test.go b/internal/exec/runtime/verification_verdict_test.go similarity index 78% rename from internal/core/runtime/verification_verdict_test.go rename to internal/exec/runtime/verification_verdict_test.go index 6ddc0b88bc..c8820ce090 100644 --- a/internal/core/runtime/verification_verdict_test.go +++ b/internal/exec/runtime/verification_verdict_test.go @@ -4,7 +4,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // verificationVerdictModel writes the four verdicts a body can produce: the @@ -168,3 +169,48 @@ func TestVerificationsOfRequirement(t *testing.T) { } } } + +// TestVerificationsOfRequirementAcrossScopeTrees pins that a requirement reached through +// another scope tree, as a session document binds it, is still found verified. +func TestVerificationsOfRequirementAcrossScopeTrees(t *testing.T) { + file := parseAndBuild(t, verificationVerdictModel) + idx, _, ctx := buildRuntimeWithLibraries(t, "", file) + root := idx.DocumentRoot("") + other := symbols.Build(file) + symbols.SetDocName(other, "") + for _, tc := range []struct { + requirement string + want []string + }{ + {requirement: "test::R", want: []string{"test::Case", "test::passing", "test::failing", "test::Plan::sub"}}, + {requirement: "test::r", want: []string{"test::Silent", "test::silent", "test::Retargeted", "test::Plan", "test::plan"}}, + } { + indexed := oneSymbol(t, idx, tc.requirement) + req := declaredIn(other, indexed.Decl) + if req == nil || req == indexed { + t.Fatalf("no distinct symbol for %s in the second tree", tc.requirement) + } + var got []string + for _, sym := range ctx.VerificationsOf(root, req) { + got = append(got, ctx.qualifiedSymbolName(sym)) + } + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("VerificationsOf(%s from another tree) = %v, want %v", tc.requirement, got, tc.want) + } + } +} + +// declaredIn is the symbol scope's tree declares for decl, or nil. +func declaredIn(scope *symbols.Scope, decl ast.Node) *symbols.Symbol { + for _, sym := range scope.AllMembers() { + if sym.Decl == decl { + return sym + } + } + for _, child := range scope.Children() { + if sym := declaredIn(child, decl); sym != nil { + return sym + } + } + return nil +} diff --git a/internal/exec/runtime/via_reference.go b/internal/exec/runtime/via_reference.go new file mode 100644 index 0000000000..fcc7f67d27 --- /dev/null +++ b/internal/exec/runtime/via_reference.go @@ -0,0 +1,133 @@ +package runtime + +import ( + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" +) + +// viaSender re-roots a send whose via path starts at a feature the behavior binds to an +// object, such as a reference parameter: the message leaves that object's port. +func (ec *EvalContext) viaSender(send lower.Send, self *Instance) (lower.Send, *Instance, error) { + if ec == nil || !send.IsVia || !send.TargetPath { + return send, self, nil + } + holder, port, err := ec.viaHolder(send.Target, send.ViaSelf, self) + if err != nil { + return send, self, err + } + if holder == self && port == send.Target { + return send, self, nil + } + send.Target, send.TargetPath = port, false + if fv, ok := holder.FeatureValues[port]; ok && fv.Feature != nil && fv.Feature.Symbol != nil { + send.TargetSym = fv.Feature.Symbol + } + return send, holder, nil +} + +// viaHolder returns the object whose port a via path names and the port's name: self and +// the path as written, unless the behavior binds the root, which shadows a same-named +// feature of self as it does in any expression. A path written from `this` is self's. +func (ec *EvalContext) viaHolder(path string, viaSelf bool, self *Instance) (*Instance, string, error) { + segments := strings.Split(path, ".") + root := segments[0] + if viaSelf { + return self, path, nil + } + ec.ctx.notePollReadsData() + held, bound, err := ec.boundHolders(root, path) + if err != nil || !bound { + return self, path, err + } + if len(held) != 1 { + value, _ := ec.Lookup(root) + return self, path, &SendTargetValueError{Target: path, Name: root, Value: FormatValue(value)} + } + holder := held[0] + if len(segments) == 1 { + owner, port, ok := portOwner(holder) + if !ok { + return self, path, &ViaNotPortError{Via: path, Value: FormatValue(Value{Kind: ValInstance, Instance: holder.ID})} + } + return owner, port, nil + } + for _, segment := range segments[1 : len(segments)-1] { + next, ok, err := ec.ctx.fvObject(holder, segment) + if err != nil { + return self, path, err + } + if !ok { + return self, path, &UnknownSendPortError{Port: path} + } + holder = next + } + return holder, segments[len(segments)-1], nil +} + +// portOwner answers the object a port object belongs to and the port's name in it, +// false for an object that is no port. +func portOwner(inst *Instance) (*Instance, string, bool) { + owner, feature := inst.Owner() + if owner == nil { + return nil, "", false + } + fv, ok := owner.FeatureValues[feature] + if !ok || !isPortFeature(fv.Feature) { + return nil, "", false + } + return owner, feature, true +} + +// boundEndDeliveries resolves a connection end whose root the behavior binds, as a via +// path is: to the port of each object the binding holds. false where it binds none. +func (ec *EvalContext) boundEndDeliveries(end string) ([]ownerDelivery, bool, error) { + segments := strings.Split(end, ".") + if ec == nil || segments[0] == thisName { + return nil, false, nil + } + held, bound, err := ec.boundHolders(segments[0], end) + if err != nil || !bound { + return nil, bound, err + } + if len(segments) == 1 { + var out []ownerDelivery + for _, inst := range held { + owner, port, ok := portOwner(inst) + if !ok { + return nil, true, &ViaNotPortError{Via: end, Value: FormatValue(Value{Kind: ValInstance, Instance: inst.ID})} + } + out = append(out, ownerDelivery{object: owner.ID, port: port}) + } + return out, true, nil + } + addrs, err := ec.ctx.addressesFrom(held, segments[1:]) + if err != nil { + return nil, true, err + } + var out []ownerDelivery + for _, addr := range addrs { + if addr.Delivery == DeliverPort && addr.Object != 0 { + out = append(out, ownerDelivery{object: addr.Object, port: addr.Port}) + } + } + return out, true, nil +} + +// boundHolders returns the objects a binding of ec holds and whether ec binds the name; +// a binding holding anything but objects is that error, naming the path it leads. +func (ec *EvalContext) boundHolders(name, path string) ([]*Instance, bool, error) { + value, bound := ec.Lookup(name) + if !bound { + return nil, false, nil + } + var out []*Instance + for _, held := range heldElements(value) { + inst, ok := ec.ctx.instances[held.Instance] + if held.Kind != ValInstance || !ok { + return nil, true, &SendTargetValueError{Target: path, Name: name, Value: FormatValue(value)} + } + out = append(out, inst) + } + return out, true, nil +} diff --git a/internal/core/runtime/w6e_robustness_test.go b/internal/exec/runtime/w6e_robustness_test.go similarity index 98% rename from internal/core/runtime/w6e_robustness_test.go rename to internal/exec/runtime/w6e_robustness_test.go index 44632256fe..c09fe74fe6 100644 --- a/internal/core/runtime/w6e_robustness_test.go +++ b/internal/exec/runtime/w6e_robustness_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // A completion self-transition on a simple state re-runs its exit and entry @@ -34,7 +34,7 @@ func TestSimpleSelfTransitionThatNeverSettlesIsBounded(t *testing.T) { var err error select { case err = <-done: - case <-time.After(30 * time.Second): + case <-watchdog(30 * time.Second): t.Fatal("run to completion hangs on a simple state transitioning to itself") } if err == nil { diff --git a/internal/core/runtime/w6e_state_self_transition_test.go b/internal/exec/runtime/w6e_state_self_transition_test.go similarity index 100% rename from internal/core/runtime/w6e_state_self_transition_test.go rename to internal/exec/runtime/w6e_state_self_transition_test.go diff --git a/internal/exec/runtime/watchdog_test.go b/internal/exec/runtime/watchdog_test.go new file mode 100644 index 0000000000..79497ba1d1 --- /dev/null +++ b/internal/exec/runtime/watchdog_test.go @@ -0,0 +1,12 @@ +package runtime + +import "time" + +// watchdog returns a channel that fires after d scaled for slow runs: the step +// budget bounds every evaluation, so the wall clock only catches a true hang, +// and under -race on a loaded CI executor honest work runs 10x slower. +func watchdog(d time.Duration) <-chan time.Time { + return time.After(d * watchdogScale) +} + +const watchdogScale = 6 diff --git a/internal/core/runtime/write_conformance.go b/internal/exec/runtime/write_conformance.go similarity index 98% rename from internal/core/runtime/write_conformance.go rename to internal/exec/runtime/write_conformance.go index eef100f056..7a5e6c7778 100644 --- a/internal/core/runtime/write_conformance.go +++ b/internal/exec/runtime/write_conformance.go @@ -3,10 +3,10 @@ package runtime import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // writeTarget is the declaration a written value answers to: the type the diff --git a/internal/core/smt/discipline_test.go b/internal/exec/smt/discipline_test.go similarity index 98% rename from internal/core/smt/discipline_test.go rename to internal/exec/smt/discipline_test.go index 382d6cc758..540f90fd06 100644 --- a/internal/core/smt/discipline_test.go +++ b/internal/exec/smt/discipline_test.go @@ -6,9 +6,9 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // The verdict discipline: what a bound cuts is bounded, never proved; what the diff --git a/internal/core/smt/encode.go b/internal/exec/smt/encode.go similarity index 85% rename from internal/core/smt/encode.go rename to internal/exec/smt/encode.go index 4665680eb6..2f54e9863f 100644 --- a/internal/core/smt/encode.go +++ b/internal/exec/smt/encode.go @@ -3,14 +3,15 @@ package smt import ( "errors" "fmt" + "maps" "math/big" "sort" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // Encoding is the transition relation of one action's flow over k moves: the @@ -53,6 +54,13 @@ type Encoding struct { // holds the delivery an object flow left at a pin, keyed by the pin. pins map[ast.Node][]pin pending map[string]*solve.Var + // marks are the Boolean features of the encoding's own bookkeeping, every one + // false at the start; waiting are those flagging a streamed value unreceived. + marks map[string]*solve.Var + waiting []*solve.Var + // streaming are the pins whose value is being carried over streaming flows, so + // a flow leading it back to one of them is the interpreter's cycle error. + streaming map[*solve.Var]bool // results are the features an inline expression node writes its value to. results map[ast.Node]*solve.Var // held are the values the performance holds at its start, ahead of its defaults; @@ -117,6 +125,7 @@ func Encode(ctx *runtime.Context, action *symbols.Symbol, graph *lower.ActionGra exprs: make(map[ast.Node]*solve.Expression), pins: make(map[ast.Node][]pin), pending: make(map[string]*solve.Var), + marks: make(map[string]*solve.Var), results: make(map[ast.Node]*solve.Var), held: held, unbound: unbound, @@ -358,9 +367,34 @@ func (e *Encoding) collectFlows(node ast.Node) error { e.features[p.Name] = p e.flagged[p.Name] = true } + for _, flow := range graph.DataFlows[node] { + if _, performs := flow.Target.(*ast.Usage); !performs || flow.Kind != lower.FlowStreaming { + continue + } + target, err := e.flowEnd(flow.Target, flow.TargetPin, flowLabel(flow), label) + if err != nil { + return err + } + e.mark(performedName(e.Flow.label(flow.Target))) + if v := e.mark(unreceivedName(target.Name)); v != nil { + e.waiting = append(e.waiting, v) + } + } return nil } +// mark declares a Boolean feature of the encoding's own bookkeeping, false at the +// start, reporting it when new. +func (e *Encoding) mark(name string) *solve.Var { + if _, ok := e.features[name]; ok { + return nil + } + v := boolVar(name) + e.features[name] = v + e.marks[name] = v + return v +} + // flowEnd is the variable an object flow reads or writes at node: the pin a // node performing in a frame of its own declares, else the action's feature. func (e *Encoding) flowEnd(node ast.Node, name, within, label string) (*solve.Var, error) { @@ -384,6 +418,16 @@ func flowLabel(flow lower.ObjectFlow) string { return fmt.Sprintf("flow from %s to %s", flow.SourcePin, flow.TargetPin) } +// stagedSlot keys the queue place a streaming flow's values hold at target: two flows +// out of one pin are two transfers, so each declaration keeps a place of its own. +func stagedSlot(flow lower.ObjectFlow, source, target *solve.Var) string { + slot := target.Name + " from " + source.Name + if flow.Decl != nil { + slot += fmt.Sprintf(" by %d", flow.Decl.Span().Offset) + } + return slot +} + // collectBody translates a body's expressions and registers what it declares. func (e *Encoding) collectBody(body []lower.Statement, label string, declared map[string]bool) error { for _, stmt := range body { @@ -633,6 +677,9 @@ func (e *Encoding) initial() error { if e.flagged[base.Name] && !open[base.Name] { env.has[base.Name] = solve.BoolTerm(false) } + if _, marked := e.marks[base.Name]; marked { + env.values[base.Name] = solve.BoolTerm(false) + } } failed, err := e.initialInputs(env) if err != nil { @@ -730,6 +777,8 @@ type nodeEffect struct { failed []*solve.Term overflow []*solve.Term loops map[int]*solve.Term + // staged holds, per pin queue, resolved source pin and flow this performance streamed along, when it did. + staged map[string]*solve.Term } // move ties state i to state i-1 by the choice of move i. @@ -782,6 +831,14 @@ func (e *Encoding) move(i int) error { } } } + if len(e.waiting) > 0 { + // A value streamed after its target's last performance fails the action as it completes. + waiting := make([]*solve.Term, len(e.waiting)) + for w, v := range e.waiting { + waiting[w] = solve.VarTerm(next.value(v)) + } + failed = append(failed, and(e.Completed[i], or(waiting...))) + } e.assert(eq(solve.VarTerm(next.Failed), or(failed...)), fmt.Sprintf("move %d fails", i)) if next.Overflow != nil { e.assert(eq(solve.VarTerm(next.Overflow), or(overflow...)), fmt.Sprintf("move %d overflows", i)) @@ -1195,9 +1252,11 @@ func undefinedGuards(defined []*solve.Term) []*solve.Term { // perform is one performance of node n in move i, in the interpreter's order: pins take their // deliveries or defaults, the body runs over prev, the guards are read, the object flows carry. func (e *Encoding) perform(i, n int, node ast.Node, prev *State) (*nodeEffect, error) { - effect := &nodeEffect{env: e.environment(prev), loops: make(map[int]*solve.Term)} + effect := &nodeEffect{env: e.environment(prev), loops: make(map[int]*solve.Term), staged: make(map[string]*solve.Term)} where := fmt.Sprintf("%d.%s", i, e.Flow.Labels[n]) - e.begin(effect, node, where) + if err := e.begin(effect, node, where); err != nil { + return nil, err + } body := e.Flow.graphOf(node).Bodies[node] if err := e.statements(effect, body, solve.BoolTerm(true), where, node); err != nil { return nil, err @@ -1206,6 +1265,9 @@ func (e *Encoding) perform(i, n int, node ast.Node, prev *State) (*nodeEffect, e value, defined := effect.env.evaluate(e.exprs[action.Expression]) effect.fail(solve.BoolTerm(true), defined) e.write(effect, solve.BoolTerm(true), e.results[node], value, where) + if err := e.stream(effect, solve.BoolTerm(true), node, e.results[node], effect.env.values[e.results[node].Name], where); err != nil { + return nil, err + } } effect.guards = effect.env.clone() if err := e.flows(effect, node, where); err != nil { @@ -1215,9 +1277,25 @@ func (e *Encoding) perform(i, n int, node ast.Node, prev *State) (*nodeEffect, e } // begin starts a performance of node: each pin holds the delivery queued for -// it, else the value its own declaration gives it, else none. -func (e *Encoding) begin(x *nodeEffect, node ast.Node, where string) { +// it, else the value its own declaration gives it, else none; then each pin's value +// streams, a stream back to a pin of the node standing over what it began with. +func (e *Encoding) begin(x *nodeEffect, node ast.Node, where string) error { always := solve.BoolTerm(true) + label := e.Flow.label(node) + if _, marked := e.marks[performedName(label)]; marked { + x.env.write(performedName(label), always) + } + for _, flow := range e.Flow.graphOf(node).DataFlows[node] { + if flow.Kind != lower.FlowStreaming { + continue + } + source, err := e.flowEnd(node, flow.SourcePin, flowLabel(flow), label) + if err != nil { + return err + } + x.env.has[streamedName(source.Name)] = solve.BoolTerm(false) + } + var started []*solve.Var for _, p := range e.pins[node] { name := p.v.Name value, has := x.env.values[name], solve.BoolTerm(false) @@ -1226,6 +1304,9 @@ func (e *Encoding) begin(x *nodeEffect, node ast.Node, where string) { if queued { seeded = not(x.env.has[pending.Name]) } + if _, marked := e.marks[unreceivedName(name)]; marked { + x.env.write(unreceivedName(name), solve.BoolTerm(false)) + } if p.feature.Value != nil { declared, defined := x.env.evaluate(e.exprs[p.feature.Value]) x.fail(seeded, defined) @@ -1246,6 +1327,122 @@ func (e *Encoding) begin(x *nodeEffect, node ast.Node, where string) { e.assert(eq(solve.VarTerm(v), value), "start of "+name) x.env.values[name] = solve.VarTerm(v) x.env.has[name] = has + if queued || p.feature.Value != nil { + started = append(started, p.v) + } + } + for _, pin := range started { + if err := e.stream(x, x.env.has[pin.Name], node, pin, x.env.values[pin.Name], where); err != nil { + return err + } + } + return nil +} + +// streamedName flags in a body's environment that a value written to pin already +// streamed over its flows, so completion does not carry it a second time. +func streamedName(pin string) string { return "streamed(" + pin + ")" } + +// stream carries a value written to pin of node over its streaming flows where +// cond holds, at the write rather than at completion as the interpreter does. +func (e *Encoding) stream(x *nodeEffect, cond *solve.Term, node ast.Node, pin *solve.Var, value *solve.Term, where string) error { + if e.streaming == nil { + e.streaming = make(map[*solve.Var]bool) + } + e.streaming[pin] = true + defer delete(e.streaming, pin) + label := e.Flow.label(node) + for _, flow := range e.Flow.graphOf(node).DataFlows[node] { + if flow.Kind != lower.FlowStreaming { + continue + } + source, err := e.flowEnd(node, flow.SourcePin, flowLabel(flow), label) + if err != nil { + return err + } + if source != pin { + continue + } + target, err := e.flowEnd(flow.Target, flow.TargetPin, flowLabel(flow), label) + if err != nil { + return err + } + streamed := cond + if held, ok := x.env.has[streamedName(pin.Name)]; ok { + streamed = or(held, cond) + } + x.env.has[streamedName(pin.Name)] = streamed + if flow.Target == node { + if err := e.place(x, cond, node, target, value, where); err != nil { + return err + } + continue + } + e.carry(x, cond, flow, pin, target, value, where) + } + return nil +} + +// place writes a flow's payload to the pin of the performance under way that reads it, +// where cond holds, and streams it on from there; a pin it is already streaming from +// is a cycle, which fails the action as the interpreter's does. +func (e *Encoding) place(x *nodeEffect, cond *solve.Term, node ast.Node, target *solve.Var, value *solve.Term, where string) error { + if e.streaming[target] { + x.fail(cond, solve.BoolTerm(false)) + return nil + } + if domain := e.domain(target.Name, value); domain != nil { + x.fail(cond, domain) + } + e.settle(x, cond, target, value, where) + return e.stream(x, cond, node, target, value, where) +} + +// performedName is the feature set once a node in a frame of its own has performed. +func performedName(label string) string { return "performed(" + label + ")" } + +// unreceivedName is the feature set while a streamed value queued at pin arrived +// after its target's last performance, which fails the action if none takes it. +func unreceivedName(pin string) string { return "unreceived(" + pin + ")" } + +// carry puts a flow's payload where its target reads it, where cond holds: the pin +// queue of a node in a frame of its own, else the action's feature. A queue holding a +// value from another performance, source pin or flow is an overflow; a streaming flow's +// own earlier value it replaces, and a value queued after its target performed is unreceived. +func (e *Encoding) carry(x *nodeEffect, cond *solve.Term, flow lower.ObjectFlow, source, target *solve.Var, value *solve.Term, where string) { + if domain := e.domain(target.Name, value); domain != nil { + x.fail(cond, domain) + } + if pending, queued := e.pending[target.Name]; queued { + full := x.env.has[pending.Name] + if flow.Kind == lower.FlowStreaming { + slot := stagedSlot(flow, source, target) + if staged := x.staged[slot]; staged != nil { + full = and(full, not(staged)) + x.staged[slot] = or(staged, cond) + } else { + x.staged[slot] = cond + } + unreceived := unreceivedName(target.Name) + late := and(cond, x.env.values[performedName(e.Flow.label(flow.Target))]) + x.env.write(unreceived, or(x.env.values[unreceived], late)) + } + x.overflow = append(x.overflow, and(cond, full)) + target = pending + } + e.settle(x, cond, target, value, where) +} + +// settle writes value to target where cond holds, leaving it as it was elsewhere. +func (e *Encoding) settle(x *nodeEffect, cond *solve.Term, target *solve.Var, value *solve.Term, where string) { + if cond.Op == solve.OpBool && cond.Bool { + e.write(x, cond, target, value, where) + return + } + had, flagged := x.env.has[target.Name] + e.write(x, cond, target, ite(cond, value, x.env.values[target.Name]), where) + if flagged { + x.env.has[target.Name] = or(cond, had) } } @@ -1266,17 +1463,13 @@ func (e *Encoding) flows(x *nodeEffect, node ast.Node, where string) error { if has, flagged := x.env.has[source.Name]; flagged { x.fail(always, has) } - value := x.env.values[source.Name] - if domain := e.domain(target.Name, value); domain != nil { - x.fail(always, domain) - } - pending, queued := e.pending[target.Name] - if !queued { - e.write(x, always, target, value, where) - continue + cond := always + if flow.Kind == lower.FlowStreaming { + if streamed, ok := x.env.has[streamedName(source.Name)]; ok { + cond = not(streamed) + } } - x.overflow = append(x.overflow, x.env.has[pending.Name]) - e.write(x, always, pending, value, where) + e.carry(x, cond, flow, source, target, x.env.values[source.Name], where) } return nil } @@ -1299,6 +1492,9 @@ func (e *Encoding) statements(x *nodeEffect, body []lower.Statement, path *solve value, defined := x.env.evaluate(e.exprs[s.Value]) x.fail(path, defined) e.write(x, path, target, value, where) + if err := e.stream(x, path, node, target, x.env.values[target.Name], where); err != nil { + return err + } case lower.Declare: target := e.features[e.translatedName(s.Name, s.Scope)] if s.Value == nil { @@ -1308,6 +1504,9 @@ func (e *Encoding) statements(x *nodeEffect, body []lower.Statement, path *solve value, defined := x.env.evaluate(e.exprs[s.Value]) x.fail(path, defined) e.write(x, path, target, value, where) + if err := e.stream(x, path, node, target, x.env.values[target.Name], where); err != nil { + return err + } case lower.Block: if err := e.statements(x, s.Statements, path, where, node); err != nil { return err @@ -1315,19 +1514,17 @@ func (e *Encoding) statements(x *nodeEffect, body []lower.Statement, path *solve case lower.If: cond, defined := x.env.evaluate(e.exprs[s.Condition]) x.fail(path, defined) - then := &nodeEffect{env: x.env.clone(), loops: make(map[int]*solve.Term)} + then := x.branch() if err := e.statements(then, s.Then.Statements, and(path, cond), where, node); err != nil { return err } - otherwise := &nodeEffect{env: x.env.clone(), loops: make(map[int]*solve.Term)} + otherwise := x.branch() if s.Else != nil { if err := e.statements(otherwise, s.Else.Statements, and(path, not(cond)), where, node); err != nil { return err } } x.env = e.merge(cond, then.env, otherwise.env, where) - x.failed = append(x.failed, then.failed...) - x.failed = append(x.failed, otherwise.failed...) x.absorb(then) x.absorb(otherwise) case lower.Loop: @@ -1341,8 +1538,17 @@ func (e *Encoding) statements(x *nodeEffect, body []lower.Statement, path *solve return nil } -// absorb takes the loop conditions of a branch's effect into x. +// branch is the effect of a body's branch or loop pass, over a copy of x's +// environment and what x has staged so far. +func (x *nodeEffect) branch() *nodeEffect { + return &nodeEffect{env: x.env.clone(), loops: make(map[int]*solve.Term), staged: maps.Clone(x.staged)} +} + +// absorb takes the failures, overflows, loop conditions and staging of a branch's +// effect into x; each is already conditioned on the branch's path. func (x *nodeEffect) absorb(branch *nodeEffect) { + x.failed = append(x.failed, branch.failed...) + x.overflow = append(x.overflow, branch.overflow...) for l, term := range branch.loops { if held, ok := x.loops[l]; ok { x.loops[l] = or(held, term) @@ -1350,6 +1556,13 @@ func (x *nodeEffect) absorb(branch *nodeEffect) { x.loops[l] = term } } + for pin, term := range branch.staged { + if held, ok := x.staged[pin]; ok && held != term { + x.staged[pin] = or(held, term) + } else { + x.staged[pin] = term + } + } } // write records a body's write of value to target in x's environment through a @@ -1419,12 +1632,11 @@ func (e *Encoding) loop(x *nodeEffect, s lower.Loop, path *solve.Term, where str x.fail(alive, defined) alive = and(alive, cond) } - body := &nodeEffect{env: x.env.clone(), loops: make(map[int]*solve.Term)} + body := x.branch() if err := e.statements(body, s.Body.Statements, alive, where, node); err != nil { return err } x.env = e.merge(alive, body.env, x.env, where) - x.failed = append(x.failed, body.failed...) x.absorb(body) if post != nil { cond, defined := x.env.evaluate(e.exprs[post]) diff --git a/internal/exec/smt/encode_test.go b/internal/exec/smt/encode_test.go new file mode 100644 index 0000000000..96fb1db338 --- /dev/null +++ b/internal/exec/smt/encode_test.go @@ -0,0 +1,511 @@ +package smt + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +const solverRequiredEnv = "OPENSYSML_REQUIRE_SMT" + +// requireSolver returns the discovered solver, skipping without one unless +// the environment declares one mandatory. +func requireSolver(t *testing.T) *solve.Solver { + t.Helper() + solver, err := solve.Discover() + if err == nil { + return solver + } + if !errors.Is(err, solve.ErrNoSolver) { + t.Fatalf("discover a solver: %v", err) + } + if os.Getenv(solverRequiredEnv) != "" { + t.Fatalf("%s=%s but %v", solverRequiredEnv, os.Getenv(solverRequiredEnv), err) + } + t.Skipf("no SMT solver installed: %v", err) + return nil +} + +// loweredAction indexes src and starts the action fqn declares, as the engine +// does: the flow it performs and what the performance holds at its start. +func loweredAction(t *testing.T, src, fqn string) (*runtime.Context, *symbols.Symbol, *lower.ActionGraph, runtime.Held) { + t.Helper() + return loweredDocument(t, "encode_test.sysml", src, fqn) +} + +// loweredConformanceAction starts the named action of a conformance case with +// a context to encode it in. +func loweredConformanceAction(t *testing.T, file, fqn string) (*runtime.Context, *symbols.Symbol, *lower.ActionGraph, runtime.Held) { + t.Helper() + src, err := os.ReadFile(filepath.Join(conformanceDir, file)) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + return loweredDocument(t, file, string(src), fqn) +} + +func loweredDocument(t *testing.T, path, src, fqn string) (*runtime.Context, *symbols.Symbol, *lower.ActionGraph, runtime.Held) { + t.Helper() + ctx, idx := fixture(t, path, src) + matches := idx.LookupQualified(fqn) + if len(matches) != 1 { + t.Fatalf("%s matched %d symbols, want 1", fqn, len(matches)) + } + graph, held := started(t, ctx, matches[0]) + return ctx, matches[0], graph, held +} + +// started begins one performance of action and reports the flow it performs +// and what it holds at its start, which is what Encode is given. +func started(t *testing.T, ctx *runtime.Context, action *symbols.Symbol) (*lower.ActionGraph, runtime.Held) { + t.Helper() + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("start %s: %v", action.Name, err) + } + defer exec.Release() + return exec.Graph(), exec.Held() +} + +// modelText renders a satisfying assignment, one variable per line, sorted. +func modelText(result *solve.Result) string { + lines := make([]string, 0, len(result.Model)) + for _, a := range result.Model { + lines = append(lines, a.Var.Name+" = "+a.Value) + } + sort.Strings(lines) + return strings.Join(lines, "\n") +} + +// assigned is the value the model gives the named variable. +func assigned(t *testing.T, result *solve.Result, name string) string { + t.Helper() + for _, a := range result.Model { + if a.Var.Name == name { + return a.Value + } + } + t.Fatalf("no assignment to %s in:\n%s", name, modelText(result)) + return "" +} + +// TestEncodeForkJoinCompletes: the fork/join case reaches its final node +// within six moves, with both flags true, and cannot do so within five. +func TestEncodeForkJoinCompletes(t *testing.T) { + solver := requireSolver(t) + src := `package test { + private import ScalarValues::*; + action clash { + attribute x : Integer = 0; + attribute leftRan : Boolean = false; + attribute rightRan : Boolean = false; + first start; + fork split; + action left { assign x := 1; assign leftRan := true; } + action right { assign x := 2; assign rightRan := true; } + join sync; + done; + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then done; + } +}` + for _, k := range []int{6, 7} { + t.Run(fmt.Sprintf("k=%d", k), func(t *testing.T) { + ctx, action, graph, held := loweredAction(t, src, "test::clash") + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + q := *enc.Query + q.Assertions = append(q.Assertions, solve.Assertion{Term: enc.Completed[k]}) + result, err := solver.Solve(context.Background(), &q) + if err != nil { + t.Fatalf("solve: %v\n%s", err, solve.Script(&q)) + } + if result.Status != solve.StatusSat { + t.Fatalf("status %v, want sat\n%s", result.Status, solve.Script(&q)) + } + t.Logf("model:\n%s", modelText(result)) + last := enc.States[k] + for _, base := range enc.Features { + v := last.value(base) + if strings.HasSuffix(base.Name, "Ran") && assigned(t, result, v.Name) != "true" { + t.Errorf("%s = %s at move %d, want true", v.Name, assigned(t, result, v.Name), k) + } + } + }) + } + ctx, action, graph, held := loweredAction(t, src, "test::clash") + enc, err := Encode(ctx, action, graph, held, nil, 5, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + q := *enc.Query + q.Assertions = append(q.Assertions, solve.Assertion{Term: enc.Completed[5]}) + result, err := solver.Solve(context.Background(), &q) + if err != nil { + t.Fatalf("solve: %v", err) + } + if result.Status != solve.StatusUnsat { + t.Fatalf("completes within 5 moves: %v\n%s", result.Status, modelText(result)) + } +} + +// status solves enc's query with the completion at k and extra asserted. +func status(t *testing.T, solver *solve.Solver, enc *Encoding, k int, extra *solve.Term) solve.Status { + t.Helper() + q := *enc.Query + q.Assertions = append(q.Assertions, solve.Assertion{Term: enc.Completed[k]}, solve.Assertion{Term: extra}) + result, err := solver.Solve(context.Background(), &q) + if err != nil { + t.Fatalf("solve: %v\n%s", err, solve.Script(&q)) + } + return result.Status +} + +// TestEncodePinsAndObjectFlows: the pin and object-flow conformance cases complete with the +// interpreter's values, and a node reading a pin not yet delivered fails as the interpreter does. +func TestEncodePinsAndObjectFlows(t *testing.T) { + solver := requireSolver(t) + const k = 8 + cases := []struct { + file, fqn string + fails bool + values map[string]int64 + }{ + {"action_flow_between_same_named_pins.sysml", "test::outer", false, + map[string]int64{"test::outer::result": 21, "test::outer::p::v": 21, "test::outer::q::v": 21, "test::outer::q::w": 42}}, + {"action_node_pins_isolated.sysml", "test::outer", false, + map[string]int64{"test::outer::total": 7, "test::outer::p::v": 3, "test::outer::q::v": 4}}, + {"action_flow_named_from.sysml", "test::driveTrain", false, + map[string]int64{"test::driveTrain::generateTorque::engineTorque": 21, "test::driveTrain::amplifyTorque::torqueIn": 21, "test::driveTrain::amplifyTorque::amplified": 42}}, + {"action_node_pin_read_before_performed.sysml", "test::outer", true, nil}, + } + for _, c := range cases { + t.Run(c.file, func(t *testing.T) { + ctx, action, graph, held := loweredConformanceAction(t, c.file, c.fqn) + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failed := solve.VarTerm(last.Failed) + if got := status(t, solver, enc, k, failed); (got == solve.StatusSat) != c.fails { + t.Errorf("completes failed: %v, want fails=%v", got, c.fails) + } + if got := status(t, solver, enc, k, solve.Not(failed)); (got == solve.StatusSat) == c.fails { + t.Errorf("completes unfailed: %v, want fails=%v", got, c.fails) + } + for name, want := range c.values { + v := last.Values[name] + if v == nil { + t.Errorf("no feature %s among %v", name, names(enc.Features)) + continue + } + is := eq(solve.VarTerm(v), solve.IntTerm(want)) + if got := status(t, solver, enc, k, is); got != solve.StatusSat { + t.Errorf("%s = %d on completion: %v, want sat", name, want, got) + } + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), solve.Not(is))); got != solve.StatusUnsat { + t.Errorf("%s != %d on completion: %v, want unsat", name, want, got) + } + } + }) + } +} + +// names lists the variables' names. +func names(vars []*solve.Var) []string { + out := make([]string, len(vars)) + for i, v := range vars { + out[i] = v.Name + } + return out +} + +// TestEncodeInlineExpressionFeedsFlow: an inline expression node holds its +// value in the feature it writes before the object flow out of it delivers, as +// the interpreter orders them, so the pin the flow feeds reads the value. The +// notation declares no such node, so the graph is lowered from one built as the +// interpreter's own tests build it, over the scope of a parsed action. +func TestEncodeInlineExpressionFeedsFlow(t *testing.T) { + solver := requireSolver(t) + const k = 8 + ctx, idx := fixture(t, "inline_test.sysml", `package test { + private import ScalarValues::*; + action outer { + attribute result : Integer; + first start; + action take { in v : Integer; } + done; + succession first start then compute; + succession first compute then take; + succession first take then done; + flow from compute.result to take.v; + } +}`) + matches := idx.LookupQualified("test::outer") + if len(matches) != 1 { + t.Fatalf("test::outer matched %d symbols, want 1", len(matches)) + } + parsed, ok := matches[0].Decl.(*ast.Usage) + if !ok { + t.Fatalf("test::outer declared by %T, want a usage", matches[0].Decl) + } + compute := &ast.ActionExecutionNode{ + Name: "compute", + Expression: &ast.OperatorExpr{ + Operator: ast.OpAdd, + Operands: []ast.Node{&ast.LiteralInteger{Value: "3"}, &ast.LiteralInteger{Value: "4"}}, + }, + } + decl := *parsed + decl.Members = append(slices.Clone(parsed.Members), compute) + action := &symbols.Symbol{Name: parsed.Ident.Name, Kind: symbols.SymbolActionUsage, Decl: &decl, Scope: matches[0].Scope} + + results, err := ctx.ExecuteAction(action) + if err != nil { + t.Fatalf("interpret: %v", err) + } + if got := results["result"]; got.Kind != runtime.ValConst || got.Const.Int != 7 { + t.Fatalf("interpreted result = %v, want 7", got) + } + + graph, held := started(t, ctx, action) + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failed := solve.VarTerm(last.Failed) + if got := status(t, solver, enc, k, solve.Not(failed)); got != solve.StatusSat { + t.Fatalf("completes unfailed: %v, want sat", got) + } + for name, want := range map[string]int64{"test::outer::result": 7, "test::outer::take::v": 7} { + v := last.Values[name] + if v == nil { + t.Errorf("no feature %s among %v", name, names(enc.Features)) + continue + } + is := eq(solve.VarTerm(v), solve.IntTerm(want)) + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), is)); got != solve.StatusSat { + t.Errorf("%s = %d on completion: %v, want sat", name, want, got) + } + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), solve.Not(is))); got != solve.StatusUnsat { + t.Errorf("%s != %d on completion: %v, want unsat", name, want, got) + } + } +} + +// TestEncodeFlowKindsDiffer: a plain flow streams the write to its source pin +// into a consumer performing beside the producer, which fails when the consumer +// completed first and reads 7 otherwise; a succession flow orders the consumer +// after the producer and hands it the value, so no schedule fails. +func TestEncodeFlowKindsDiffer(t *testing.T) { + solver := requireSolver(t) + const k = 12 + const forked = ` + fork split; + join sync; + succession first start then split; + succession first split then producer; + succession first split then consumer; + succession first producer then sync; + succession first consumer then sync; + succession first sync then done; + flow producer.value to consumer.got;` + const ordered = ` + succession first start then producer; + succession first consumer then done; + succession flow producer.value to consumer.got;` + model := func(wiring string) string { + return `package test { + private import ScalarValues::*; + action outer { + attribute seen : Integer = -1; + first start; + action producer { out value : Integer = 5; assign value := 7; } + action consumer { in got : Integer = 0; assign seen := got; } + done;` + wiring + ` + } +}` + } + cases := []struct { + name, wiring string + fails bool + }{ + {"flow", forked, true}, + {"succession flow", ordered, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ctx, action, graph, held := loweredDocument(t, "kinds_test.sysml", model(c.wiring), "test::outer") + exploration := explore(t, runtime.DefaultExploreBudget, func() (*runtime.Context, error) { + return runtime.NewContext(ctx.Model(), 10000), nil + }, action) + if !exploration.Complete() { + t.Fatalf("exploration %s", exploration.Status()) + } + failing, values := false, make(map[string]bool) + for _, o := range exploration.Outcomes { + if o.Outcome.Err != nil { + failing = true + continue + } + values[runtime.FormatValue(o.Outcome.Outputs["seen"])] = true + } + if failing != c.fails || len(values) != 1 || !values["7"] { + t.Fatalf("the interpreter's outcomes: fails=%v, seen in %v; want fails=%v, seen = 7 only", failing, values, c.fails) + } + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failed := solve.VarTerm(last.Failed) + seen := last.Values["test::outer::seen"] + if seen == nil { + t.Fatalf("no feature seen among %v", names(enc.Features)) + } + is := eq(solve.VarTerm(seen), solve.IntTerm(7)) + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), is)); got != solve.StatusSat { + t.Errorf("seen = 7 on unfailed completion: %v, want sat", got) + } + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), solve.Not(is))); got != solve.StatusUnsat { + t.Errorf("seen != 7 on unfailed completion: %v, want unsat", got) + } + if got := status(t, solver, enc, k, failed); (got == solve.StatusSat) != c.fails { + t.Errorf("completes failed: %v, want fails=%v", got, c.fails) + } + }) + } +} + +func TestEncodeStreamsConditionalWrite(t *testing.T) { + solver := requireSolver(t) + const k = 10 + ctx, action, graph, held := loweredDocument(t, "conditional_stream_test.sysml", `package test { + private import ScalarValues::*; + action outer { + in enabled : Boolean; + attribute seen : Integer = -1; + first start; + action producer { out value : Integer = 5; if enabled { assign value := 7; } } + action consumer { in got : Integer = 0; assign seen := got; } + done; + succession first start then producer; + succession first producer then consumer; + succession first consumer then done; + flow producer.value to consumer.got; + } +}`, "test::outer") + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failed := solve.VarTerm(last.Failed) + seen, enabled := last.Values["test::outer::seen"], last.Values["test::outer::enabled"] + if seen == nil || enabled == nil { + t.Fatalf("no features seen and enabled among %v", names(enc.Features)) + } + if got := status(t, solver, enc, k, failed); got != solve.StatusUnsat { + t.Errorf("completes failed: %v, want unsat", got) + } + agree := eq(solve.VarTerm(seen), solve.Ite(solve.VarTerm(enabled), solve.IntTerm(7), solve.IntTerm(5))) + if got := status(t, solver, enc, k, solve.Not(agree)); got != solve.StatusUnsat { + t.Errorf("seen disagrees with the branch taken: %v, want unsat", got) + } +} + +// TestEncodeStreamsBackToOwnPin: a plain flow from one pin of a node to another +// reaches the performance under way, whatever order the pins are declared in and +// whether the value it carries is the pin's declared one or a write in the body, +// and streams on from there; flows leading it back are the interpreter's cycle error. +func TestEncodeStreamsBackToOwnPin(t *testing.T) { + solver := requireSolver(t) + const k = 8 + cases := []struct { + name, node, flows string + want int64 + cycle bool + }{ + {"target declared first", `action n { in back : Integer; out value : Integer = 5; assign seen := back; }`, `flow n.value to n.back;`, 5, false}, + {"source declared first", `action n { out value : Integer = 5; in back : Integer = 0; assign seen := back; }`, `flow n.value to n.back;`, 5, false}, + {"written in the body", `action n { in back : Integer; out value : Integer = 5; assign value := 7; assign seen := back; }`, `flow n.value to n.back;`, 7, false}, + {"two hops", `action n { in far : Integer; in back : Integer; out value : Integer = 5; assign seen := far; }`, `flow n.value to n.back; flow n.back to n.far;`, 5, false}, + {"cycle", `action n { in back : Integer; out value : Integer = 5; assign seen := back; }`, `flow n.value to n.back; flow n.back to n.value;`, 5, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ctx, action, graph, held := loweredDocument(t, "own_pin_test.sysml", `package test { + private import ScalarValues::*; + action outer { + attribute seen : Integer = -1; + first start; + `+c.node+` + done; + succession first start then n; + succession first n then done; + `+c.flows+` + } +}`, "test::outer") + exploration := explore(t, runtime.DefaultExploreBudget, func() (*runtime.Context, error) { + return runtime.NewContext(ctx.Model(), 10000), nil + }, action) + if !exploration.Complete() { + t.Fatalf("exploration %s", exploration.Status()) + } + for _, o := range exploration.Outcomes { + if c.cycle { + if !errors.Is(o.Outcome.Err, runtime.ErrStreamCycle) { + t.Fatalf("the interpreter: err=%v, want %v", o.Outcome.Err, runtime.ErrStreamCycle) + } + continue + } + if o.Outcome.Err != nil || o.Outcome.Outputs["seen"].Const.Int != c.want { + t.Fatalf("the interpreter: err=%v seen=%s, want %d", o.Outcome.Err, runtime.FormatValue(o.Outcome.Outputs["seen"]), c.want) + } + } + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failed := solve.VarTerm(last.Failed) + if c.cycle { + if got := status(t, solver, enc, k, solve.Not(failed)); got != solve.StatusUnsat { + t.Errorf("unfailed: %v, want unsat", got) + } + return + } + seen := last.Values["test::outer::seen"] + if seen == nil { + t.Fatalf("no feature seen among %v", names(enc.Features)) + } + is := eq(solve.VarTerm(seen), solve.IntTerm(c.want)) + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), is)); got != solve.StatusSat { + t.Errorf("seen = %d on unfailed completion: %v, want sat", c.want, got) + } + if got := status(t, solver, enc, k, solve.Or(failed, solve.Not(is))); got != solve.StatusUnsat { + t.Errorf("fails or seen != %d: %v, want unsat", c.want, got) + } + }) + } +} diff --git a/internal/exec/smt/engine.go b/internal/exec/smt/engine.go new file mode 100644 index 0000000000..66db86d273 --- /dev/null +++ b/internal/exec/smt/engine.go @@ -0,0 +1,646 @@ +package smt + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" +) + +// EngineName is the name the engine registers under. +const EngineName = analysis.SMTEngineName + +// DefaultMoves is the move bound k when the budget names no depth. +const DefaultMoves = 40 + +// Engine is the `smt` analysis engine over the solver discover finds. +type Engine struct { + discover func() (*solve.Solver, error) +} + +// New returns the engine over the solver discover finds, nil discovering as +// solve.Discover does. +func New(discover func() (*solve.Solver, error)) *Engine { + if discover == nil { + discover = solve.Discover + } + return &Engine{discover: discover} +} + +// Name is `smt`. +func (*Engine) Name() string { return EngineName } + +// Describe: every schedule of at most k moves over the inputs free in their declared +// domains, decided by a solver; an `unsat` with no bound reachable is a proof, a +// witness is replayed before it is claimed. A Sensitive question is decided over two +// copies of the schedule sharing the initial state, both witnesses replayed. +func (*Engine) Describe() analysis.Description { + return analysis.Description{ + Questions: []analysis.Kind{analysis.Holds, analysis.Sensitive}, + Process: analysis.SolveProcess, + Bounds: []string{"moves", "unroll", "slots", "solver"}, + Replays: true, + Authority: analysis.Proved, + } +} + +// Process names the solver found, or its absence as the typed refusal. +func (e *Engine) Process() (string, error) { + solver, err := e.discover() + if err != nil { + return "", &analysis.ProcessAbsentError{Engine: e.Name(), Process: analysis.SolveProcess, Err: err} + } + return solver.Name + " at " + solver.Path, nil +} + +// Covers takes a Holds or Sensitive question with the schedule free, its inputs free or +// as written; a fixed schedule, another kind and a question without its ask are refused. +func (e *Engine) Covers(_ *analysis.Model, q analysis.Question) analysis.Coverage { + if q.Kind != analysis.Holds && q.Kind != analysis.Sensitive { + return analysis.Coverage{Refusal: &analysis.NotAskedError{Engine: e.Name(), Kind: q.Kind}} + } + if !q.Free.Has(analysis.FreeSchedule) { + return analysis.Coverage{Refusal: &ScheduleError{Engine: e.Name(), Schedule: q.Schedule}} + } + switch { + case q.Holds == nil: + return analysis.Coverage{Refusal: &analysis.MalformedQuestionError{Kind: q.Kind, Missing: "a Holds ask"}} + case q.Holds.Behavior == nil: + return analysis.Coverage{Refusal: &analysis.MalformedQuestionError{Kind: q.Kind, Missing: "a behavior"}} + case q.Holds.Start == nil: + return analysis.Coverage{Refusal: &analysis.MalformedQuestionError{Kind: q.Kind, Missing: "a Start"}} + } + if _, err := e.Process(); err != nil { + return analysis.Coverage{Refusal: err} + } + return analysis.Coverage{Covered: true} +} + +// ErrScheduleFixed is the typed error for a Holds question that fixes its schedule. +var ErrScheduleFixed = errors.New("smt decides every schedule, not one") + +// ScheduleError reports a question asking about one schedule of an engine that +// decides them all. +type ScheduleError struct { + Engine string + Schedule runtime.SchedulePolicy +} + +func (e *ScheduleError) Error() string { + return fmt.Sprintf("%s answers over every schedule; the question fixes %s", e.Engine, e.Schedule) +} + +// Is matches ErrScheduleFixed. +func (e *ScheduleError) Is(target error) bool { return target == ErrScheduleFixed } + +// run is one question's state: the encoding, its property, the bounds taken. +type run struct { + engine *Engine + model *analysis.Model + q analysis.Question + budget analysis.Budget + solver *solve.Solver + timeout time.Duration + moves int + unroll int + encoding *Encoding + property *Property + deadlock *Property + // compared tells whose feature each name a Sensitive question compares is, and + // performer names the performing object's attributes, compared absent names. + compared map[string]runtime.FeatureOwner + performer []string + started time.Time + timedOut bool +} + +// Run encodes the flow for Budget.Depth moves (DefaultMoves when none), body loops unrolled +// Budget.Unroll times (DefaultUnroll when none), and asks, in order, for a violation, a +// failure and a deadlock; a `sat` is replayed before it is claimed. A Sensitive question +// asks the two-copy query first, as decideSensitive spells. +func (e *Engine) Run(ctx context.Context, model *analysis.Model, q analysis.Question, budget analysis.Budget) (analysis.Result, error) { + if coverage := e.Covers(model, q); !coverage.Covered { + return analysis.Result{}, coverage.Refusal + } + solver, err := e.discover() + if err != nil { + return analysis.Result{}, &analysis.ProcessAbsentError{Engine: e.Name(), Process: analysis.SolveProcess, Err: err} + } + if budget.Solver > 0 { + solver.Timeout = budget.Solver + } + r := &run{engine: e, model: model, q: q, budget: budget, solver: solver, timeout: solver.Timeout, moves: budget.Depth, unroll: budget.Unroll, started: time.Now()} + if r.timeout <= 0 { + r.timeout = solve.DefaultTimeout + } + if r.moves <= 0 { + r.moves = DefaultMoves + } + if r.unroll <= 0 { + r.unroll = DefaultUnroll + } + if !modelBuilds(model) { + return analysis.Result{}, &analysis.NoRuntimeError{Engine: e.Name()} + } + if refusal, err := r.encode(); err != nil { + return analysis.Result{}, err + } else if refusal != nil { + return r.uncovered(refusal.Error()), nil + } + if q.Kind == analysis.Sensitive { + return r.decideSensitive(ctx) + } + return r.decide(ctx) +} + +// modelBuilds reports whether the model builds a context of a run's own. +func modelBuilds(m *analysis.Model) bool { + return m != nil && m.Semantics != nil && m.Fresh != nil +} + +// encode starts one run to take its lowered graph and encodes it with the +// question's property; a construct outside the encoding is the refusal returned, +// a context or start that cannot be made is the error. +func (r *run) encode() (refusal error, err error) { + ctx, err := r.model.NewContextOn(0, r.budget) + if err != nil { + return nil, err + } + exec, err := r.q.Holds.Start(ctx) + if err != nil { + return nil, err + } + defer exec.Release() + if r.q.Kind == analysis.Sensitive { + if r.compared, err = runtime.ResolveCheckFeatures(exec, r.q.Holds.Diverge); err != nil { + return nil, err + } + r.performer = exec.PerformerAttributes() + } + encoding, err := Encode(ctx, r.q.Holds.Behavior, exec.Graph(), exec.Held(), r.q.Holds.Inputs, r.moves, r.unroll) + if err != nil { + return refusalOf(err) + } + for _, assumption := range r.q.Holds.Assume { + if err := encoding.Assume(ctx, assumption, r.q.Holds.Scope); err != nil { + return refusalOf(err) + } + } + r.encoding = encoding + r.deadlock = encoding.Deadlock() + if len(r.q.Holds.Conditions) == 0 { + r.property = r.deadlock + return nil, nil + } + property, err := encoding.Conditions(ctx, r.q.Holds.Conditions, r.q.Holds.Scope) + if err != nil { + return refusalOf(err) + } + r.property = property + return nil, nil +} + +// refusalOf sorts an encoding error: a construct the engine does not encode or +// translate, or a flow the interpreter would refuse, is a refusal; anything +// else is a fault. +func refusalOf(err error) (error, error) { + switch { + case errors.Is(err, ErrNotEncoded), errors.Is(err, ErrMalformedFlow), errors.Is(err, ErrSlotOverflow), + errors.Is(err, solve.ErrNotTranslatable), errors.Is(err, runtime.ErrNoConditions), + errors.Is(err, analysis.ErrInput), errors.Is(err, analysis.ErrDomain): + return err, nil + } + var unsupported *solve.UnsupportedCapabilityError + if errors.As(err, &unsupported) { + return err, nil + } + return nil, err +} + +// outcome is what a witness claims the interpreter does at its marked state. +type outcome int + +const ( + // outcomeViolation: the condition evaluates false. + outcomeViolation outcome = iota + // outcomeFailure: a body raises a typed error, or the condition cannot be evaluated. + outcomeFailure + // outcomeDeadlock: no token can act and the flow is not complete. + outcomeDeadlock +) + +func (o outcome) String() string { + switch o { + case outcomeViolation: + return "a violation" + case outcomeFailure: + return "a failure" + case outcomeDeadlock: + return "a deadlock" + } + return "an outcome" +} + +// NoInitialState is the reason a run claims nothing when its assumptions admit +// no initial state: a property over no run is vacuous, not proved. +const NoInitialState = "assumptions admit no initial state" + +// decide asks the queries in order, stopping at the first `sat` whose witness +// replays or at the first answer that decides nothing. An `unsat` over arithmetic +// the interpreter rounds refutes nothing, so once every query is `unsat` it +// leaves the question not covered rather than held, and assumptions no exact +// initial state satisfies are contradictory only when nothing in them rounds. +func (r *run) decide(ctx context.Context) (analysis.Result, error) { + if out, err := r.consistent(ctx); out != nil || err != nil { + return orEmpty(out), err + } + found, rounded, err := r.findings(ctx, r.asks()) + if found != nil || err != nil { + return orEmpty(found), err + } + if rounded != nil { + return *rounded, nil + } + return r.completes(ctx, + func(cut Cut, _ *solve.Result) (analysis.Result, error) { return r.holds(analysis.Bounded, cut), nil }, + func() analysis.Result { return r.holds(analysis.Proved, Cut{}) }) +} + +// orEmpty is the result pointed at, or the zero result for nil. +func orEmpty(out *analysis.Result) analysis.Result { + if out == nil { + return analysis.Result{} + } + return *out +} + +// consistent asks whether the assumptions admit an initial state, when any are +// assumed: the answer when they do not or the solver did not decide, nil otherwise. +func (r *run) consistent(ctx context.Context) (*analysis.Result, error) { + if len(r.encoding.Assumptions) == 0 { + return nil, nil + } + consistency := r.encoding.Consistency() + result, err := r.solver.Solve(ctx, consistency) + if err != nil { + return nil, err + } + var out analysis.Result + switch result.Status { + case solve.StatusUnsat: + if consistency.Rounded() { + out = r.rounded(result, "whether the assumptions admit an initial state") + } else { + out = r.uncovered(NoInitialState) + } + case solve.StatusSat: + return nil, nil + default: + out = r.undecided(result, "whether the assumptions admit an initial state") + } + return &out, nil +} + +// ask is one query for a finding and the outcome its `sat` witnesses. +type ask struct { + query *solve.Query + outcome outcome +} + +// asks are the finding queries in the order decide asks them: a violation of the +// property, a failure, then a deadlock when the property is another. +func (r *run) asks() []ask { + asks := []ask{{r.encoding.Violation(r.property), outcomeViolation}, {r.encoding.Failure(r.property), outcomeFailure}} + if r.property == r.deadlock { + asks[0].outcome = outcomeDeadlock + } else { + asks = append(asks, ask{r.encoding.Violation(r.deadlock), outcomeDeadlock}) + } + return asks +} + +// findings asks each query in order: the first `sat` replayed is found, as is the +// first answer the solver did not decide; rounded is the first `unsat` that decides +// nothing for rounding, kept while the later queries are asked. +func (r *run) findings(ctx context.Context, asks []ask) (found, rounded *analysis.Result, err error) { + for _, a := range asks { + result, err := r.solver.Solve(ctx, a.query) + if err != nil { + return nil, nil, err + } + switch result.Status { + case solve.StatusUnsat: + if rounded == nil && a.query.Rounded() { + out := r.rounded(result, "whether a schedule reaches "+a.outcome.String()) + rounded = &out + } + case solve.StatusSat: + out, err := r.witnessed(result, a.outcome) + if err != nil { + return nil, nil, err + } + return &out, rounded, nil + default: + out := r.undecided(result, "whether a schedule reaches "+a.outcome.String()) + return &out, rounded, nil + } + } + return nil, rounded, nil +} + +// completes asks whether every schedule ends within the bounds: proved answers an +// `unsat` that rounds nothing, bounded a `sat` with the bounds its model reached. +func (r *run) completes(ctx context.Context, bounded func(Cut, *solve.Result) (analysis.Result, error), proved func() analysis.Result) (analysis.Result, error) { + uncertainty := r.encoding.Uncertainty() + result, err := r.solver.Solve(ctx, uncertainty) + if err != nil { + return analysis.Result{}, err + } + switch result.Status { + case solve.StatusUnsat: + if uncertainty.Rounded() { + return r.rounded(result, "whether every schedule ends within the bounds"), nil + } + return proved(), nil + case solve.StatusSat: + cut, err := r.encoding.Cuts(result) + if err != nil { + return analysis.Result{}, err + } + return bounded(cut, result) + default: + return r.undecided(result, "whether every schedule ends within the bounds"), nil + } +} + +// noteTimeout records a solver answer that ran out of time. +func (r *run) noteTimeout(result *solve.Result) { + if result != nil && result.TimedOut { + r.timedOut = true + } +} + +// bounds are the bounds the run took, marking the ones cut reached. +func (r *run) bounds(cut Cut) analysis.Bounds { + return analysis.Bounds{ + {Name: "moves", Limit: int64(r.moves), Reached: cut.Moves}, + {Name: "unroll", Limit: int64(r.encoding.Unroll), Reached: cut.Unroll}, + {Name: "slots", Limit: int64(r.encoding.Flow.Slots), Reached: cut.Slots}, + {Name: "solver", Limit: r.timeout.Milliseconds(), Reached: r.timedOut}, + } +} + +// result is the shape every answer shares. +func (r *run) result() analysis.Result { + result := analysis.Result{Question: r.q, Engine: r.engine.Name(), Elapsed: time.Since(r.started)} + if r.encoding != nil { + result.Bounds = r.bounds(Cut{}) + result.Inputs = r.inputs(nil) + result.Assumptions = r.encoding.Assumptions + if r.frees() { + result.Question.Free |= analysis.FreeInputs + } + } else { + result.Bounds = analysis.Bounds{{Name: "moves", Limit: int64(r.moves)}, {Name: "unroll", Limit: int64(r.unroll)}, + {Name: "solver", Limit: r.timeout.Milliseconds()}} + } + return result +} + +// frees reports whether the encoding ranged over any input, which the answer's +// question then says it did. +func (r *run) frees() bool { + for _, in := range r.encoding.Inputs { + if in.Free { + return true + } + } + return false +} + +// inputs lists the features the encoding ranged over or pinned, a free one's +// value the one the witness chose when there is one. +func (r *run) inputs(witness []runtime.InputTaken) []analysis.Input { + chosen := make(map[string]string, len(witness)) + for _, in := range witness { + chosen[in.Feature] = in.Written + } + inputs := make([]analysis.Input, 0, len(r.encoding.Inputs)) + for _, in := range r.encoding.Inputs { + out := analysis.Input{Name: in.Name, Type: in.Type, Sort: in.Var.Sort.Name, Domain: in.Domain, Free: in.Free, Optional: in.Optional} + switch { + case in.Free: + out.Value = chosen[in.Name] + case in.Value.Kind != runtime.ValInvalid: + out.Value = runtime.FormatValue(in.Value) + } + inputs = append(inputs, out) + } + return inputs +} + +// uncovered claims nothing, for the reason. +func (r *run) uncovered(reason string) analysis.Result { + result := r.result() + result.Claim, result.Strength, result.Reason = analysis.ClaimNone, analysis.NotCovered, reason + return result +} + +// rounded is the answer to an `unsat` asking what over exact reals where the +// interpreter rounds: a run may reach what no exact schedule does, with no +// witness to replay, so the `unsat` decides nothing about the run. +func (r *run) rounded(result *solve.Result, what string) analysis.Result { + out := r.uncovered(what + " rounds in floating point when evaluated, which an exact-real unsat does not decide") + out.Values = []analysis.Evaluation{{Name: what, Solved: result}} + return out +} + +// undecided is the answer to a query, asking what, that the solver did not decide. +func (r *run) undecided(result *solve.Result, what string) analysis.Result { + r.noteTimeout(result) + reason := "the solver did not decide " + what + if result.TimedOut { + reason = fmt.Sprintf("%s within %s", reason, r.timeout) + } else if result.Reason != "" { + reason += ": " + result.Reason + } + out := r.uncovered(reason) + out.Values = []analysis.Evaluation{{Name: what, Solved: result}} + return out +} + +// holds claims the property over every schedule: proved when no bound was +// reachable, bounded by the ones cut names otherwise. +func (r *run) holds(strength analysis.Strength, cut Cut) analysis.Result { + result := r.result() + result.Claim, result.Strength, result.Bounds = analysis.ClaimHolds, strength, r.bounds(cut) + return result +} + +// witnessed decodes a `sat` and replays it: the claim stands, and the witness is +// written, only when the interpreter reaches the outcome claimed by the step named. +func (r *run) witnessed(result *solve.Result, expected outcome) (analysis.Result, error) { + w, err := r.encoding.Decode(result) + if err != nil { + var malformed *WitnessError + if errors.As(err, &malformed) || errors.Is(err, ErrNoWitness) { + return r.uncovered("the solver's witness does not decode: " + err.Error()), nil + } + return analysis.Result{}, err + } + replayed, err := r.replay(w, expected) + if err != nil { + return analysis.Result{}, err + } + witness := &analysis.Witness{Schedule: w.policy(), Inputs: w.Inputs, Choices: w.Choices} + if replayed.disagreement != "" { + out := r.uncovered(replayed.disagreement) + out.Witness = witness + out.Inputs = r.inputs(w.Inputs) + return out, nil + } + if witness.Written, err = r.write(w, replayed); err != nil { + return analysis.Result{}, err + } + out := r.result() + out.Claim, out.Strength, out.Witness = analysis.ClaimViolated, analysis.Witnessed, witness + out.Inputs = r.inputs(w.Inputs) + out.Values = []analysis.Evaluation{{Name: r.property.Name, Err: replayed.err}} + out.Reason = replayed.describe() + return out, nil +} + +// write writes the witness as a file the replay policy reads, when the question +// names a directory: its inputs and choices, the trace the replay left, and the +// property or failure it claims. It returns the path, "" when none was written. +func (r *run) write(w *Witness, p replayed) (string, error) { + if r.q.Holds.WitnessDir == "" { + return "", nil + } + file := runtime.Witness{Inputs: w.Inputs, Choices: w.Choices, Trace: p.trace} + var violation *runtime.ViolationError + switch { + case errors.As(p.err, &violation): + file.Property = violation.Element + case p.err != nil: + file.Fails = p.err.Error() + } + path := filepath.Join(r.q.Holds.WitnessDir, analysis.ViolationFile(r.q.Subject, r.q.Holds.Performer, 1)) + if err := analysis.WriteWitness(path, file.String()); err != nil { + return "", err + } + return path, nil +} + +// replayed is what the interpreter did under a witness: the outcome it reached, +// the error that is that outcome, the trace it left, and the disagreement when +// it reached another outcome. +type replayed struct { + outcome outcome + step int + err error + trace string + disagreement string +} + +// describe is the outcome as the report prints it. +func (p replayed) describe() string { + return fmt.Sprintf("at step %d: %v", p.step, p.err) +} + +// replay runs the behavior afresh under the witness, one step at a time, and +// compares what the interpreter reaches with what the witness claims, tracing +// the run so the witness file records what a replay of it must leave. +func (r *run) replay(w *Witness, expected outcome) (replayed, error) { + ctx, err := r.model.NewContextOn(0, r.budget) + if err != nil { + return replayed{}, err + } + if err := ctx.SetSchedule(w.policy()); err != nil { + return replayed{}, err + } + if ctx.Trace() == nil { + ctx.SetTrace(runtime.NewTraceRecorder()) + } + p, err := r.follow(ctx, w, expected) + if err == nil { + p.trace = ctx.Trace().String() + } + return p, err +} + +// follow steps the behavior in ctx under the witness's schedule up to the state +// it marks, judging each step against the outcome claimed. +func (r *run) follow(ctx *runtime.Context, w *Witness, expected outcome) (replayed, error) { + claim := fmt.Sprintf("the solver claims %v at move %d", expected, w.Mark) + disagree := func(format string, args ...any) (replayed, error) { + return replayed{disagreement: claim + "; " + fmt.Sprintf(format, args...)}, nil + } + exec, err := r.q.Holds.Start(ctx) + if err != nil { + var refused *runtime.WitnessInputError + if errors.As(err, &refused) { + return disagree("the interpreter could not fix the witness's inputs: %v", err) + } + return replayed{}, err + } + defer exec.Release() + // A deadlock is reported by the step that finds no token to move, so one at + // the initial state is the first step's. + limit := w.Mark + if expected == outcomeDeadlock && limit == 0 { + limit = 1 + } + for step := 0; ; step++ { + if step > 0 { + if exec.State() == runtime.StateCompleted { + return disagree("the interpreter completed the run after %d steps", step-1) + } + if err := exec.Step(); err != nil { + var refused *runtime.ReplayError + if errors.As(err, &refused) { + return disagree("the interpreter could not follow the witness: %v", err) + } + return r.reached(ctx, limit, expected, step, err, disagree) + } + } + for _, condition := range r.property.Conditions { + if ok, err := exec.Holds(condition, r.q.Holds.Scope); err != nil { + return r.reached(ctx, limit, expected, step, err, disagree) + } else if !ok { + return disagree("the interpreter reports %s neither holding nor violated at step %d", condition.Name, step) + } + } + if step >= limit { + return disagree("the interpreter passed step %d without %v", limit, expected) + } + } +} + +// reached judges the outcome the interpreter's error is against the one claimed +// by the step limit. +func (r *run) reached(ctx *runtime.Context, limit int, expected outcome, step int, err error, disagree func(string, ...any) (replayed, error)) (replayed, error) { + got := outcomeFailure + var violation *runtime.ViolationError + switch { + case errors.As(err, &violation): + got = outcomeViolation + case errors.Is(err, runtime.ErrActionDeadlock): + got = outcomeDeadlock + } + if got != expected { + return disagree("the interpreter reached %v at step %d: %v", got, step, err) + } + if step > limit { + return disagree("the interpreter reached it at step %d", step) + } + if left := ctx.Unfollowed(); left != nil { + return disagree("the interpreter reached it at step %d with witness moves left: %v", step, left) + } + return replayed{outcome: got, step: step, err: err}, nil +} diff --git a/internal/core/smt/engine_test.go b/internal/exec/smt/engine_test.go similarity index 95% rename from internal/core/smt/engine_test.go rename to internal/exec/smt/engine_test.go index a04844a7ca..42f387194e 100644 --- a/internal/core/smt/engine_test.go +++ b/internal/exec/smt/engine_test.go @@ -11,15 +11,16 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // document is one indexed document the engine is asked about: its index and the @@ -39,7 +40,8 @@ func indexed(t *testing.T, path, src string) *document { model := &analysis.Model{ Semantics: func() (*runtime.Model, error) { resolver := resolve.New(idx) - m := runtime.NewModel(semantics.NewModel(resolver), resolver) + m := runtime.NewModel(passes.NewTypedModel(resolver), resolver) + m.SetExpressionParser(parser.ParseOneExpression) m.RegisterSource(sf) return m, nil }, @@ -309,22 +311,24 @@ func TestEngineReportsUndecidedUncertaintyAsNotCovered(t *testing.T) { if _, err := exec.LookPath("sh"); err != nil { t.Skipf("no sh to stand in for a solver: %v", err) } - // Each query is a fresh process; the count file has the fourth one hang. + // Each query is a fresh process; the count file has the fourth one hang. The budget + // bounds each process from launch to exit, so it is generous for a shell under load. count := filepath.Join(t.TempDir(), "asked") - script := `n=$(cat "$1" 2>/dev/null || echo 0); echo $((n+1)) > "$1" + script := `n=0; read -r n 2>/dev/null < "$1"; echo $((n+1)) > "$1" if [ "$n" -ge 3 ]; then exec sleep 30; fi while IFS= read -r line; do case "$line" in *"(check-sat)"*) echo unsat;; esac; done` unsure := &solve.Solver{Name: "unsure", Path: "sh", Args: []string{"-c", script, "unsure", count}, Declared: solve.DeclaredCapabilities("unsure", solve.AllCapabilities...)} e := New(func() (*solve.Solver, error) { return unsure, nil }) d := indexed(t, "unsure.sysml", conditionsSrc) - result := answer(t, e, d, d.holds(t, "test::A", "test::A::bounded"), analysis.Budget{Depth: 3, Solver: 100 * time.Millisecond}) + const budget = 2 * time.Second + result := answer(t, e, d, d.holds(t, "test::A", "test::A::bounded"), analysis.Budget{Depth: 3, Solver: budget}) expect(t, result, analysis.ClaimNone, analysis.NotCovered) if !strings.Contains(result.Reason, "did not decide whether every schedule ends within the bounds") { t.Errorf("reason %q", result.Reason) } - if solverBound := bound(t, result, "solver"); !solverBound.Reached || solverBound.Limit != 100 { - t.Errorf("solver bound %+v, want reached at 100ms", solverBound) + if solverBound := bound(t, result, "solver"); !solverBound.Reached || solverBound.Limit != budget.Milliseconds() { + t.Errorf("solver bound %+v, want reached at %s", solverBound, budget) } if len(result.Values) != 1 || result.Values[0].Solved == nil || !result.Values[0].Solved.TimedOut { t.Errorf("the undecided query is not reported: %+v", result.Values) diff --git a/internal/core/smt/errors.go b/internal/exec/smt/errors.go similarity index 100% rename from internal/core/smt/errors.go rename to internal/exec/smt/errors.go diff --git a/internal/core/smt/fold.go b/internal/exec/smt/fold.go similarity index 98% rename from internal/core/smt/fold.go rename to internal/exec/smt/fold.go index 26b11316f3..54e065efce 100644 --- a/internal/core/smt/fold.go +++ b/internal/exec/smt/fold.go @@ -3,7 +3,7 @@ package smt import ( "math" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // The encoder's constructors fold literal operands away: much of the relation diff --git a/internal/core/smt/fold_test.go b/internal/exec/smt/fold_test.go similarity index 98% rename from internal/core/smt/fold_test.go rename to internal/exec/smt/fold_test.go index a273b5b91e..e8a5869a31 100644 --- a/internal/core/smt/fold_test.go +++ b/internal/exec/smt/fold_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // render writes a term as an S-expression over the operators the folds emit. diff --git a/internal/core/smt/input.go b/internal/exec/smt/input.go similarity index 94% rename from internal/core/smt/input.go rename to internal/exec/smt/input.go index 6763ed2d8b..98962b79e2 100644 --- a/internal/core/smt/input.go +++ b/internal/exec/smt/input.go @@ -5,11 +5,11 @@ import ( "fmt" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // Input is one feature of the initial state as the encoding takes it: free in diff --git a/internal/core/smt/input_engine_test.go b/internal/exec/smt/input_engine_test.go similarity index 98% rename from internal/core/smt/input_engine_test.go rename to internal/exec/smt/input_engine_test.go index 136bf763c8..ee2b16684a 100644 --- a/internal/core/smt/input_engine_test.go +++ b/internal/exec/smt/input_engine_test.go @@ -7,10 +7,10 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // freeSrc is inputsSrc without the String attribute, plus the constraints an diff --git a/internal/core/smt/input_test.go b/internal/exec/smt/input_test.go similarity index 97% rename from internal/core/smt/input_test.go rename to internal/exec/smt/input_test.go index 197acc525c..d2b2996879 100644 --- a/internal/core/smt/input_test.go +++ b/internal/exec/smt/input_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // inputsSrc: n is bound by no default, x by one; the requirement holds at the diff --git a/internal/core/smt/property.go b/internal/exec/smt/property.go similarity index 98% rename from internal/core/smt/property.go rename to internal/exec/smt/property.go index 1600031118..cf7db5ee23 100644 --- a/internal/core/smt/property.go +++ b/internal/exec/smt/property.go @@ -5,9 +5,9 @@ import ( "slices" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // PropertyKind is what a property asks of every state of a run. diff --git a/internal/core/smt/property_test.go b/internal/exec/smt/property_test.go similarity index 97% rename from internal/core/smt/property_test.go rename to internal/exec/smt/property_test.go index f61b40c712..f4ab77015c 100644 --- a/internal/core/smt/property_test.go +++ b/internal/exec/smt/property_test.go @@ -5,10 +5,10 @@ import ( "errors" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // solveStatus answers one query, failing the test on a solver error. diff --git a/internal/exec/smt/referee_test.go b/internal/exec/smt/referee_test.go new file mode 100644 index 0000000000..e4b262b43b --- /dev/null +++ b/internal/exec/smt/referee_test.go @@ -0,0 +1,685 @@ +package smt + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +// The referee holds the encoding to the interpreter over every conformance action case with +// outcomes: same outcome set, every witness replays, same deadlock verdict; refusals are counted. +// Its fourth check, over the cases with free inputs: a violated witness with inputs, explored +// with those inputs pinned as a caller pins them, reproduces the violation. Its fifth, per +// feature of every case that completes: a sensitive verdict has both witnesses replayed and +// their two values among the case's outcomes; a feature not sensitive has one value there. + +// corpusCase is the part of a conformance case's expectation the referee reads. +type corpusCase struct { + Type string `json:"type"` + Evaluate string `json:"evaluate"` + Outcomes []json.RawMessage `json:"outcomes"` + ExploreBudget *struct { + Runs *int `json:"runs"` + Depth *int `json:"depth"` + } `json:"exploreBudget"` +} + +// refereeCases are the action cases of the corpus that list outcomes, by name. +func refereeCases(t *testing.T) map[string]corpusCase { + t.Helper() + entries, err := os.ReadDir(conformanceDir) + if err != nil { + t.Fatalf("read %s: %v", conformanceDir, err) + } + cases := make(map[string]corpusCase) + for _, entry := range entries { + name, ok := strings.CutSuffix(entry.Name(), ".expected.json") + if !ok { + continue + } + data, err := os.ReadFile(filepath.Join(conformanceDir, entry.Name())) + if err != nil { + t.Fatalf("read %s: %v", entry.Name(), err) + } + var c corpusCase + if err := json.Unmarshal(data, &c); err != nil { + t.Fatalf("parse %s: %v", entry.Name(), err) + } + if c.Type == "action" && len(c.Outcomes) > 0 { + cases[name] = c + } + } + if len(cases) == 0 { + t.Fatal("no action case with outcomes in the corpus") + } + return cases +} + +// corpusDocument indexes a conformance case over the standard library, which +// the encoding needs to sort its features, and finds the action it evaluates. +func corpusDocument(t *testing.T, name string, c corpusCase) (*document, *symbols.Symbol) { + t.Helper() + path := filepath.Join(conformanceDir, name+".sysml") + src, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + idx := libs.NewModelIndex() + sf := source.New(path, src) + idx.AddDocument(path, parser.New(sf).ParseFile()) + idx.ExpandWildcardImports() + var action *symbols.Symbol + if c.Evaluate != "" { + action = lookup(t, idx, c.Evaluate) + } else { + action = firstAction(idx.DocumentRoot(path)) + } + if action == nil { + t.Fatalf("%s: no action to evaluate", name) + } + model := &analysis.Model{ + Semantics: func() (*runtime.Model, error) { + resolver := resolve.New(idx) + m := runtime.NewModel(passes.NewTypedModel(resolver), resolver) + m.SetExpressionParser(parser.ParseOneExpression) + m.RegisterSource(sf) + return m, nil + }, + Fresh: func(w *analysis.Worker) (*runtime.Context, error) { + return runtime.NewContext(w.Model, 10000), nil + }, + } + return &document{idx: idx, model: model}, action +} + +// firstAction is the first action definition or usage declared under scope, +// searched as the conformance test searches when a case names none. +func firstAction(scope *symbols.Scope) *symbols.Symbol { + find := func(s *symbols.Scope) *symbols.Symbol { + for _, name := range s.MemberNames() { + sym, ok := s.LookupLocal(name) + if !ok { + continue + } + if def, ok := sym.Decl.(*ast.Definition); ok && def.Kind == ast.DefAction { + return sym + } + if usage, ok := sym.Decl.(*ast.Usage); ok && usage.Kind == ast.UsageAction { + return sym + } + } + return nil + } + for _, child := range scope.Children() { + if sym := find(child); sym != nil { + return sym + } + } + return find(scope) +} + +// refereeTally counts what the referee saw over the corpus. +type refereeTally struct { + mu sync.Mutex + encoded, refused, agreeing, witnesses, replayed int + // inputs counts the violated witnesses fixing inputs, reproduced those the + // exploration with the inputs pinned reaches the violation of. + inputs, reproduced int + // features counts the features asked about across schedules, sensitive those + // found sensitive with both witnesses replayed to two of the case's outcomes, + // insensitive those found not sensitive with one value across its outcomes. + features, sensitive, insensitive int + refusals []string +} + +func (tally *refereeTally) merge(other *refereeTally) { + tally.mu.Lock() + defer tally.mu.Unlock() + tally.encoded += other.encoded + tally.refused += other.refused + tally.agreeing += other.agreeing + tally.witnesses += other.witnesses + tally.replayed += other.replayed + tally.inputs += other.inputs + tally.reproduced += other.reproduced + tally.features += other.features + tally.sensitive += other.sensitive + tally.insensitive += other.insensitive + tally.refusals = append(tally.refusals, other.refusals...) +} + +// log reports the tally as the referee's columns. +func (tally *refereeTally) log(t *testing.T) { + t.Helper() + t.Logf("referee: %d cases encoded, %d refused, %d agreeing on outcomes and verdict; %d witnesses, %d replayed; %d witnesses with inputs, %d reproduced under explore; %d features compared across schedules, %d sensitive with both witnesses replayed, %d not sensitive", + tally.encoded, tally.refused, tally.agreeing, tally.witnesses, tally.replayed, tally.inputs, tally.reproduced, tally.features, tally.sensitive, tally.insensitive) + for _, refusal := range tally.refusals { + t.Logf("refused: %s", refusal) + } +} + +// refereeTimeout is how long the referee gives the solver per query: it judges +// faithfulness, not speed, so a slow machine must not turn a verdict undecided. +const refereeTimeout = 5 * time.Minute + +// TestRefereeCorpus runs checks 1–3 and 5 over the corpus and reports the counts. +func TestRefereeCorpus(t *testing.T) { + solver := *requireSolver(t) + solver.Timeout = refereeTimeout + cases := refereeCases(t) + names := make([]string, 0, len(cases)) + for name := range cases { + names = append(names, name) + } + sort.Strings(names) + tally := &refereeTally{} + t.Run("cases", func(t *testing.T) { + for _, name := range names { + c := cases[name] + t.Run(name, func(t *testing.T) { + t.Parallel() + s := solver + local := &refereeTally{} + refereeCase(t, &s, name, c, local) + tally.merge(local) + }) + } + }) + tally.log(t) + if tally.encoded == 0 { + t.Fatal("the referee encoded no case") + } +} + +// inputCase is one pinned model with free inputs the referee holds to the interpreter. +type inputCase struct { + name, src, action, condition string +} + +// inputCases are the free-input models of layer 4 whose verdict is violated: an +// Integer below the default, an Integer where Natural proves, an enumeration +// constructor, and a real. +func inputCases() []inputCase { + realSrc := strings.Replace(freeSrc, "attribute u : Natural;", "attribute u : Real;", 1) + return []inputCase{ + {"integer-input", freeSrc, "test::A", "test::A::positive"}, + {"integer-where-natural-proves", strings.Replace(freeSrc, "attribute u : Natural;", "attribute u : Integer;", 1), "test::A", "test::A::natural"}, + {"enumeration-input", freeSrc, "test::A", "test::A::fast"}, + {"real-input", realSrc, "test::A", "test::A::natural"}, + } +} + +// TestRefereeInputs runs check 4 over the free-input models: every violated +// witness fixes its inputs, and exploring with them pinned reproduces the violation. +func TestRefereeInputs(t *testing.T) { + solver := *requireSolver(t) + solver.Timeout = refereeTimeout + e := New(func() (*solve.Solver, error) { return &solver, nil }) + tally := &refereeTally{} + for _, c := range inputCases() { + t.Run(c.name, func(t *testing.T) { + d := indexed(t, c.name+".sysml", c.src) + q := d.holds(t, c.action, c.condition) + budget := analysis.Budget{Depth: DefaultMoves, Solver: solver.Timeout} + verdict := answer(t, e, d, q, budget) + tally.encoded++ + expect(t, verdict, analysis.ClaimViolated, analysis.Witnessed) + if len(verdict.Witness.Inputs) == 0 { + t.Fatalf("the witness fixes no input: %v", verdict.Witness) + } + if reproduceViolation(t, d, q.Holds, budget, verdict, tally) { + tally.agreeing++ + } + }) + } + tally.log(t) + if tally.reproduced != len(inputCases()) { + t.Errorf("%d of %d violations reproduced under explore", tally.reproduced, len(inputCases())) + } +} + +// refereeCase holds one case to the interpreter, adding to the tally. +func refereeCase(t *testing.T, solver *solve.Solver, name string, c corpusCase, tally *refereeTally) { + d, action := corpusDocument(t, name, c) + budget := analysis.Budget{Depth: DefaultMoves, Solver: solver.Timeout} + encoding, refusal := encodeDocument(t, d, action, budget) + if refusal != nil { + tally.refused++ + tally.refusals = append(tally.refusals, name+": "+refusal.Error()) + t.Logf("refused: %v", refusal) + return + } + tally.encoded++ + exploration := explore(t, c.exploreBudget(), d.fresh(budget), action) + outcomes := compareOutcomes(t, solver, encoding, d, action, budget, exploration) + tally.witnesses += outcomes.witnesses + tally.replayed += outcomes.replayed + ask := startAsk(action) + agreeing, verdict := refereeVerdict(t, solver, d, ask, budget, exploration) + if verdict.Claim == analysis.ClaimViolated && verdict.Witness != nil && len(verdict.Witness.Inputs) > 0 { + agreeing = reproduceViolation(t, d, ask, budget, verdict, tally) && agreeing + } + if agreeing { + tally.agreeing++ + } + if verdict.Claim == analysis.ClaimHolds { + r := refereed{d: d, encoding: encoding, ask: ask, budget: budget} + refereeSensitivity(t, solver, r, oracleOutcomes(t, name, c.Outcomes), tally) + } +} + +// refereed is one corpus case as check 5 sees it: its document, the encoding of its +// action, the ask starting the action and the budget the queries run under. +type refereed struct { + d *document + encoding *Encoding + ask *analysis.HoldsAsk + budget analysis.Budget +} + +// refereeSensitivity runs check 5 over a case every schedule of which completes: each +// of the action's own features is asked about across schedules; a sensitive answer +// replays both witnesses to two distinct values the case's outcomes list, and a +// feature not sensitive has one value across them. +func refereeSensitivity(t *testing.T, solver *solve.Solver, r refereed, o oracle, tally *refereeTally) { + t.Helper() + e := New(func() (*solve.Solver, error) { return solver, nil }) + d, ask, budget := r.d, r.ask, r.budget + for _, out := range r.encoding.Outputs() { + feature := out.Name + if o[feature] == nil { + t.Errorf("the encoding holds %s, which the case's outcomes do not list", feature) + continue + } + tally.features++ + q := analysis.Question{Kind: analysis.Sensitive, Subject: ask.Behavior.Name, Free: analysis.FreeSchedule, + Holds: &analysis.HoldsAsk{Behavior: ask.Behavior, Start: ask.Start, Diverge: []string{feature}}} + verdict := answer(t, e, d, q, budget) + listed := slices.Sorted(maps.Keys(o[feature])) + switch verdict.Claim { + case analysis.ClaimSensitive: + if verdict.Strength != analysis.Witnessed || verdict.Witness == nil || verdict.Contrast == nil { + t.Errorf("%s sensitive at %v with witnesses %v, %v", feature, verdict.Strength, verdict.Witness, verdict.Contrast) + continue + } + values := replayedValues(t, d, q, verdict, feature) + if values[0] == values[1] || !o[feature][values[0]] || !o[feature][values[1]] { + t.Errorf("%s sensitive between %v, the case's outcomes list %v", feature, values, listed) + continue + } + tally.sensitive++ + t.Logf("%s: sensitive between %v", feature, values) + case analysis.ClaimHolds: + if len(listed) != 1 { + t.Errorf("%s not sensitive (%v: %s), the case's outcomes list %v", feature, verdict.Strength, verdict.Reason, listed) + continue + } + tally.insensitive++ + t.Logf("%s: not sensitive (%v), %s throughout", feature, verdict.Strength, listed[0]) + default: + t.Errorf("%s: the engine answers %v/%v where the exploration completes: %s", feature, verdict.Claim, verdict.Strength, verdict.Reason) + } + } +} + +// startAsk asks after the action begun as the interpreter begins it, no condition named. +func startAsk(action *symbols.Symbol) *analysis.HoldsAsk { + return &analysis.HoldsAsk{ + Behavior: action, + Start: func(ctx *runtime.Context) (*runtime.ActionExecutor, error) { + return ctx.CreateActionExecutor(action) + }, + } +} + +// fresh builds one context of the document's model under the budget. +func (d *document) fresh(budget analysis.Budget) func() (*runtime.Context, error) { + return func() (*runtime.Context, error) { return d.model.NewContextOn(0, budget) } +} + +// encodeDocument encodes the action as the engine would, returning the refusal +// of a construct outside the stage, or failing on a fault. +func encodeDocument(t *testing.T, d *document, action *symbols.Symbol, budget analysis.Budget) (*Encoding, error) { + t.Helper() + ctx, err := d.fresh(budget)() + if err != nil { + t.Fatalf("context: %v", err) + } + exec, err := ctx.CreateActionExecutor(action) + if err != nil { + t.Fatalf("start: %v", err) + } + defer exec.Release() + encoding, err := Encode(ctx, action, exec.Graph(), exec.Held(), nil, budget.Depth, DefaultUnroll) + if err != nil { + refusal, fault := refusalOf(err) + if fault != nil { + t.Fatalf("encode: %v", fault) + } + return nil, refusal + } + return encoding, nil +} + +// comparedOutcomes is what checks 1 and 2 found over one action. +type comparedOutcomes struct { + explored, found []string + agreeing bool + witnesses, replayed int +} + +// compareOutcomes runs checks 1 and 2: the outcomes the solver enumerates are +// the completed outcomes the exploration reached, and one witness per outcome +// replays to it with the witness's own trace. +func compareOutcomes(t *testing.T, solver *solve.Solver, encoding *Encoding, d *document, action *symbols.Symbol, budget analysis.Budget, exploration *runtime.Exploration) comparedOutcomes { + t.Helper() + fresh := d.fresh(budget) + explored := make(map[string]string) + for _, o := range exploration.Outcomes { + if o.Outcome.Err == nil { + explored[rootOutputs(o.Outcome)] = runtime.FormatChoices(o.Witness) + } + } + + completion := encoding.Completion() + enumerated, err := solver.Enumerate(context.Background(), completion, encoding.OutputVars(), len(explored)+1) + if err != nil { + t.Fatalf("enumerate completions: %v", err) + } + if enumerated.Truncated { + t.Fatalf("the enumeration of completions stopped early: at bound %v, undecided %v", enumerated.AtBound, enumerated.Undecided) + } + found := make(map[string][]solve.Assignment) + for _, values := range enumerated.Solutions { + outputs, err := encoding.DecodeOutputs(values) + if err != nil { + t.Fatalf("decode outputs: %v", err) + } + found[spellOutputs(outputs)] = values + } + out := comparedOutcomes{explored: slices.Sorted(maps.Keys(explored)), found: slices.Sorted(maps.Keys(found)), agreeing: true} + for identity, witness := range explored { + if _, ok := found[identity]; !ok { + out.agreeing = false + t.Errorf("the exploration reached an outcome the solver does not: %s\n witness: %s", identity, witness) + } + } + for identity := range found { + if _, ok := explored[identity]; !ok { + out.agreeing = false + t.Errorf("the solver reaches an outcome the exploration does not: %s", identity) + } + } + + for _, identity := range out.found { + fixed, err := encoding.Fix(completion, found[identity]) + if err != nil { + t.Fatalf("fix outcome: %v", err) + } + result, err := solver.Solve(context.Background(), fixed) + if err != nil { + t.Fatalf("solve fixed outcome: %v", err) + } + if result.Status != solve.StatusSat { + t.Errorf("the outcome %s enumerated is not satisfiable on its own: %v", identity, result.Status) + continue + } + w, err := encoding.Decode(result) + if err != nil { + t.Fatalf("decode witness: %v", err) + } + out.witnesses++ + if replayOutcome(t, fresh, action, identity, w) { + out.replayed++ + } + } + t.Logf("%d outcomes explored (%s), %d enumerated, %d of %d witnesses replayed", len(explored), exploration.Status(), len(found), out.replayed, out.witnesses) + return out +} + +// refereeVerdict runs check 3: the engine's verdict on the action agrees with +// what the exhaustive exploration reached. The verdict is returned for check 4. +func refereeVerdict(t *testing.T, solver *solve.Solver, d *document, ask *analysis.HoldsAsk, budget analysis.Budget, exploration *runtime.Exploration) (bool, analysis.Result) { + t.Helper() + deadlocks, failures := 0, 0 + for _, o := range exploration.Outcomes { + switch { + case o.Outcome.Err == nil: + case errors.Is(o.Outcome.Err, runtime.ErrActionDeadlock): + deadlocks++ + default: + failures++ + } + } + q := analysis.Question{Kind: analysis.Holds, Subject: ask.Behavior.Name, Free: analysis.FreeSchedule, Holds: ask} + verdict := answer(t, New(func() (*solve.Solver, error) { return solver, nil }), d, q, budget) + agreeing := true + switch { + case deadlocks > 0: + if verdict.Claim != analysis.ClaimViolated || !errors.Is(verdict.Values[0].Err, runtime.ErrActionDeadlock) { + agreeing = false + t.Errorf("the exploration deadlocks but the engine answers %v/%v: %s", verdict.Claim, verdict.Strength, verdict.Reason) + } + case failures > 0: + if verdict.Claim != analysis.ClaimViolated { + agreeing = false + t.Errorf("the exploration fails but the engine answers %v/%v: %s", verdict.Claim, verdict.Strength, verdict.Reason) + } + default: + if verdict.Claim != analysis.ClaimHolds { + agreeing = false + t.Errorf("the exploration completes every run but the engine answers %v/%v: %s", verdict.Claim, verdict.Strength, verdict.Reason) + } + if verdict.Strength == analysis.Proved && !exploration.Complete() { + t.Logf("proved by the engine; the exploration was %s", exploration.Status()) + } + } + t.Logf("engine: %v/%v", verdict.Claim, verdict.Strength) + return agreeing, verdict +} + +// reproduceViolation runs check 4: the action is explored over every schedule +// with the witness's inputs pinned as a caller pins them, and some run reaches +// the violation the witness claims: the condition false, or the failure named. +func reproduceViolation(t *testing.T, d *document, ask *analysis.HoldsAsk, budget analysis.Budget, verdict analysis.Result, tally *refereeTally) bool { + t.Helper() + tally.inputs++ + claimed := verdict.Values[0].Err + policy, err := runtime.ExplorePolicy(runtime.DefaultExploreBudget) + if err != nil { + t.Fatalf("explore policy: %v", err) + } + exploration, err := runtime.Explore(context.Background(), policy, d.fresh(budget), func(ctx *runtime.Context) (runtime.Outcome, error) { + pinned, err := pinInputs(ctx, verdict.Witness.Inputs) + if err != nil { + return runtime.Outcome{}, err + } + exec, err := ctx.CreateActionExecutorWithInputs(ask.Behavior, nil, pinned) + if err != nil { + return runtime.Outcome{}, err + } + defer exec.Release() + for { + for _, condition := range ask.Conditions { + if ok, err := exec.Holds(condition, ask.Scope); err != nil { + return runtime.Outcome{}, err + } else if !ok { + return runtime.Outcome{}, fmt.Errorf("%s neither holds nor is violated", condition.Name) + } + } + if exec.State() == runtime.StateCompleted { + return ctx.ActionOutcome(exec.Results()), nil + } + if err := exec.Step(); err != nil { + return runtime.Outcome{}, err + } + } + }) + if err != nil { + t.Fatalf("explore with the inputs pinned: %v", err) + } + for _, o := range exploration.Outcomes { + if sameViolation(o.Outcome.Err, claimed) { + tally.reproduced++ + t.Logf("reproduced under explore with %v pinned: %v", verdict.Witness.Inputs, o.Outcome.Err) + return true + } + } + t.Errorf("exploring with %v pinned reaches no run ending as the witness claims (%v); %d outcomes, %s", + verdict.Witness.Inputs, claimed, len(exploration.Outcomes), exploration.Status()) + return false +} + +// pinInputs evaluates the values a witness spells, in the context the run is about +// to begin in, as a caller's inputs are evaluated. +func pinInputs(ctx *runtime.Context, inputs []runtime.InputTaken) (map[string]runtime.Value, error) { + pinned := make(map[string]runtime.Value, len(inputs)) + for _, in := range inputs { + p := parser.New(source.New("", []byte(in.Written))) + expr := p.ParseExpression() + if expr == nil || len(p.Diagnostics) > 0 { + return nil, fmt.Errorf("input %s = %q is not an expression", in.Feature, in.Written) + } + value, err := ctx.Eval(expr) + if err != nil { + return nil, fmt.Errorf("input %s = %q: %w", in.Feature, in.Written, err) + } + pinned[in.Feature] = value + } + return pinned, nil +} + +// sameViolation reports whether a run ended as the witness claims: in the +// violation of the same condition, or in a deadlock or failure alike. +func sameViolation(got, claimed error) bool { + var violation, claimedViolation *runtime.ViolationError + switch { + case got == nil || claimed == nil: + return false + case errors.As(claimed, &claimedViolation): + return errors.As(got, &violation) && violation.Element == claimedViolation.Element + case errors.Is(claimed, runtime.ErrActionDeadlock): + return errors.Is(got, runtime.ErrActionDeadlock) + default: + return !errors.As(got, &violation) && !errors.Is(got, runtime.ErrActionDeadlock) + } +} + +// exploreBudget is the budget a case's exploration runs under. +func (c corpusCase) exploreBudget() runtime.ExploreBudget { + budget := runtime.DefaultExploreBudget + if c.ExploreBudget != nil { + if c.ExploreBudget.Runs != nil { + budget.Runs = *c.ExploreBudget.Runs + } + if c.ExploreBudget.Depth != nil { + budget.Depth = *c.ExploreBudget.Depth + } + } + return budget +} + +// explore runs every linearization of the action under the budget. +func explore(t *testing.T, budget runtime.ExploreBudget, fresh func() (*runtime.Context, error), action *symbols.Symbol) *runtime.Exploration { + t.Helper() + policy, err := runtime.ExplorePolicy(budget) + if err != nil { + t.Fatalf("explore policy: %v", err) + } + exploration, err := runtime.Explore(context.Background(), policy, fresh, func(ctx *runtime.Context) (runtime.Outcome, error) { + outputs, err := ctx.ExecuteAction(action) + if err != nil { + return runtime.Outcome{}, err + } + return ctx.ActionOutcome(outputs), nil + }) + if err != nil { + t.Fatalf("explore: %v", err) + } + return exploration +} + +// replayOutcome replays w through the interpreter and checks it completes with +// the outcome named, having followed the witness whole and noted its choices. +func replayOutcome(t *testing.T, fresh func() (*runtime.Context, error), action *symbols.Symbol, identity string, w *Witness) bool { + t.Helper() + ctx, err := fresh() + if err != nil { + t.Fatalf("context: %v", err) + } + if err := ctx.SetSchedule(runtime.ReplayPolicy(w.Choices)); err != nil { + t.Fatalf("schedule: %v", err) + } + outputs, err := ctx.ExecuteAction(action) + if err != nil { + t.Errorf("the witness of %s does not replay: %v\n witness: %s", identity, err, runtime.FormatChoices(w.Choices)) + return false + } + if left := ctx.Unfollowed(); left != nil { + t.Errorf("the witness of %s was not followed whole: %v", identity, left) + return false + } + if got := rootOutputs(ctx.ActionOutcome(outputs)); got != identity { + t.Errorf("the witness of %s replays to %s\n witness: %s", identity, got, runtime.FormatChoices(w.Choices)) + return false + } + var trace []string + for _, c := range ctx.Choices() { + trace = append(trace, c.Choice().String()) + } + var claimed []string + for _, c := range w.Choices { + claimed = append(claimed, c.String()) + } + if !slices.Equal(trace, claimed) { + t.Errorf("the replay of %s traces\n %s\nbut the witness says\n %s", identity, strings.Join(trace, "\n "), strings.Join(claimed, "\n ")) + return false + } + return true +} + +// rootOutputs spells the action's own attributes of an outcome, as spellOutputs +// spells the solver's, so the two compare as text. +func rootOutputs(o runtime.Outcome) string { + outputs := make(map[string]string) + for name, value := range o.Outputs { + if strings.Contains(name, ".") { + continue + } + outputs[name] = runtime.FormatValue(value) + } + return spellOutputs(outputs) +} + +// spellOutputs renders outputs in name order as one identity. +func spellOutputs(outputs map[string]string) string { + if len(outputs) == 0 { + return "no outputs" + } + parts := make([]string, 0, len(outputs)) + for _, name := range slices.Sorted(maps.Keys(outputs)) { + parts = append(parts, fmt.Sprintf("%s = %s", name, outputs[name])) + } + return strings.Join(parts, "; ") +} diff --git a/internal/core/smt/sensitive.go b/internal/exec/smt/sensitive.go similarity index 98% rename from internal/core/smt/sensitive.go rename to internal/exec/smt/sensitive.go index 8490f967e9..d7220b4776 100644 --- a/internal/core/smt/sensitive.go +++ b/internal/exec/smt/sensitive.go @@ -8,9 +8,9 @@ import ( "slices" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // NoSensitivityWithin begins the reason of a bounded negative sensitivity answer: no diff --git a/internal/core/smt/sensitivity_test.go b/internal/exec/smt/sensitivity_test.go similarity index 98% rename from internal/core/smt/sensitivity_test.go rename to internal/exec/smt/sensitivity_test.go index 1720cc94c7..981280d656 100644 --- a/internal/core/smt/sensitivity_test.go +++ b/internal/exec/smt/sensitivity_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "os/exec" "path/filepath" @@ -15,10 +16,10 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // Layer 5 of the test contract: schedule sensitivity over the conformance corpus, @@ -153,7 +154,7 @@ func (o oracle) values(t *testing.T, feature string) []string { if o[feature] == nil { t.Fatalf("the oracle gives %s no value", feature) } - return sortedKeys(o[feature]) + return slices.Sorted(maps.Keys(o[feature])) } // replayedValues re-runs both witnesses of a sensitivity through the interpreter to diff --git a/internal/exec/smt/state.go b/internal/exec/smt/state.go new file mode 100644 index 0000000000..d20bf2290e --- /dev/null +++ b/internal/exec/smt/state.go @@ -0,0 +1,349 @@ +package smt + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// The constructors the finite sorts carry beside the flow's own nodes and edges. +const ( + // Absent is the node value of a slot no token occupies: one never filled, + // or one whose token retired or was consumed. + Absent = "Absent" + // NoEdge is the edge value of a token that arrived over no succession: the + // initial token, and a token a synchronization made. + NoEdge = "none" + // Stutter is the choice of a move in which no token acts: the flow is + // complete, or every token left is held. + Stutter = "stutter" +) + +// Sorts are the finite datatype sorts one encoding declares. +type Sorts struct { + // Node ranges over the flow's nodes and Absent. + Node solve.Sort + // Edge ranges over the flow's successions and NoEdge. + Edge solve.Sort + // Choice ranges over the token slots that may act in a move, and Stutter. + Choice solve.Sort + // Signal ranges over the signal types the sends post and NoSignal; its + // values are empty for a flow without sends. + Signal solve.Sort +} + +// NoSignal is the signal value of an empty bus slot. +const NoSignal = "none" + +// newSorts declares the sorts of a flow with the given token slots. Names are +// prefixed by the action so two encodings in one query stay distinct. +func newSorts(prefix string, f *Flow) Sorts { + nodes := make([]string, 0, len(f.Labels)+1) + nodes = append(nodes, f.Labels...) + nodes = append(nodes, Absent) + edges := make([]string, 0, len(f.Edges)+1) + for i := range f.Edges { + edges = append(edges, edgeLabel(f, i)) + } + edges = append(edges, NoEdge) + choices := make([]string, 0, f.Slots+1) + for t := 0; t < f.Slots; t++ { + choices = append(choices, slotLabel(t)) + } + choices = append(choices, Stutter) + sorts := Sorts{ + Node: solve.Sort{Kind: solve.SortDatatype, Name: prefix + "::Node", Values: nodes, Origin: prefix}, + Edge: solve.Sort{Kind: solve.SortDatatype, Name: prefix + "::Edge", Values: edges, Origin: prefix}, + Choice: solve.Sort{Kind: solve.SortDatatype, Name: prefix + "::Choice", Values: choices, Origin: prefix}, + } + if f.Bus > 0 { + signals := append(f.signalTypes(), NoSignal) + sorts.Signal = solve.Sort{Kind: solve.SortDatatype, Name: prefix + "::Signal", Values: signals, Origin: prefix} + } + return sorts +} + +// signalTypes lists, in first-met order, the signal type each send posts. +func (f *Flow) signalTypes() []string { + var types []string + seen := make(map[string]bool, len(f.Sends)) + for _, site := range f.Sends { + name := sendSignal(site.Send) + if !seen[name] { + seen[name] = true + types = append(types, name) + } + } + return types +} + +// sendSignal names the signal type a send posts: the type its message names, +// or the message text where it is a value. +func sendSignal(send lower.Send) string { + switch m := send.Message.(type) { + case *ast.QualifiedName: + return ast.QualifiedText(m) + case *ast.InvocationExpr: + return ast.QualifiedText(m.Type) + } + return nodeLabel(send.Message) +} + +// edgeLabel names succession i of the flow: source, target and its position +// among the source's successions, as a decision witness names a branch. +func edgeLabel(f *Flow, i int) string { + edge := f.Edges[i] + pos := 0 + for n, out := range f.Outgoing[edge.Source] { + if out == i { + pos = n + 1 + break + } + } + return fmt.Sprintf("%s>%d->%s", f.label(edge.Source), pos, f.label(edge.Target)) +} + +// slotLabel names token slot t as a choice. +func slotLabel(t int) string { return fmt.Sprintf("slot%d", t) } + +// Slot is one token slot at one state: where its token is, the succession it +// arrived over and the ID the interpreter gave it. +type Slot struct { + At *solve.Var + Via *solve.Var + ID *solve.Var + // Able holds when the slot's token may act from this state, as the + // interpreter's step would offer it. + Able *solve.Var + // Parked holds when the token waits at an accept: for a message, or for + // the clock to reach Due. Both are nil for a flow without accepts. + Parked *solve.Var + Due *solve.Var +} + +// BusSlot is one message slot of the bus at one state: whether a message sits +// in it, its signal type, its payload and the move that posted it. +type BusSlot struct { + Present *solve.Var + Signal *solve.Var + Payload *solve.Var + PostedAt *solve.Var +} + +// State is the symbolic state after move Move; move 0 is the initial state. +// Each variable is a copy of its own, named `@`. +type State struct { + Move int + Slots []Slot + NextID *solve.Var + // Values holds this state's copy of every feature the bodies read or + // write, keyed by the name the translator gives the feature. + Values map[string]*solve.Var + // Loop is set, per body loop, when the move reaching this state ran the + // loop past the unroll bound, so what follows is not known. + Loop []*solve.Var + // Failed is set when the move reaching this state met an error the + // evaluator reports: a division by zero, a guard that is not Boolean. + Failed *solve.Var + // Overflow is set when a move reaching this state exceeded what the state + // holds: a fork found no free slot, or a delivery found a pin's queue full. + Overflow *solve.Var + // Now is the clock in seconds; nil for a flow without timed accepts. + Now *solve.Var + // Bus holds the M message slots; BusOverflow is set when a send found none + // free. Both are empty for a flow without sends. + Bus []BusSlot + BusOverflow *solve.Var +} + +// Named is one variable of the state vector under the name it has at every +// move, so a query over two copies of the relation pairs them by name. +type Named struct { + Name string + Var *solve.Var +} + +// Move is the choice made in move Index, from state Index-1 to state Index. +type Move struct { + Index int + // Choice is the slot whose token acts, or Stutter. + Choice *solve.Var + // Travel is the succession the acting token left over — the decision + // witness where several guards held — or NoEdge for a move taking none. + Travel *solve.Var + // Held holds, per succession out of a decision node with a guard, when + // the acting token read that guard and it held; nil for every other succession. + Held []*solve.Var +} + +// newState declares the variables of the state after move i. +func newState(sorts Sorts, f *Flow, i int) *State { + s := &State{ + Move: i, + Slots: make([]Slot, f.Slots), + NextID: intVar(fmt.Sprintf("next@%d", i)), + Values: make(map[string]*solve.Var), + Loop: make([]*solve.Var, len(f.Loops)), + Failed: boolVar(fmt.Sprintf("failed@%d", i)), + } + for t := range s.Slots { + s.Slots[t] = Slot{ + At: sortedVar(fmt.Sprintf("at[%d]@%d", t, i), sorts.Node), + Via: sortedVar(fmt.Sprintf("via[%d]@%d", t, i), sorts.Edge), + ID: intVar(fmt.Sprintf("id[%d]@%d", t, i)), + Able: boolVar(fmt.Sprintf("able[%d]@%d", t, i)), + } + if len(f.Accepts) > 0 { + s.Slots[t].Parked = boolVar(fmt.Sprintf("parked[%d]@%d", t, i)) + } + if f.Timed { + s.Slots[t].Due = realVar(fmt.Sprintf("due[%d]@%d", t, i)) + } + } + for l := range s.Loop { + s.Loop[l] = boolVar(fmt.Sprintf("loop[%d]@%d", l, i)) + } + if f.Cyclic || f.Delivers { + s.Overflow = boolVar(fmt.Sprintf("overflow@%d", i)) + } + if f.Timed { + s.Now = realVar(fmt.Sprintf("now@%d", i)) + } + if f.Bus > 0 { + s.Bus = make([]BusSlot, f.Bus) + for b := range s.Bus { + s.Bus[b] = BusSlot{ + Present: boolVar(fmt.Sprintf("bus[%d].present@%d", b, i)), + Signal: sortedVar(fmt.Sprintf("bus[%d].signal@%d", b, i), sorts.Signal), + Payload: intVar(fmt.Sprintf("bus[%d].payload@%d", b, i)), + PostedAt: intVar(fmt.Sprintf("bus[%d].posted@%d", b, i)), + } + } + s.BusOverflow = boolVar(fmt.Sprintf("bus.overflow@%d", i)) + } + return s +} + +// newMove declares the choice variables of move i. +func newMove(sorts Sorts, f *Flow, i int) *Move { + m := &Move{ + Index: i, + Choice: sortedVar(fmt.Sprintf("choice@%d", i), sorts.Choice), + Travel: sortedVar(fmt.Sprintf("travel@%d", i), sorts.Edge), + Held: make([]*solve.Var, len(f.Edges)), + } + for _, node := range f.Nodes { + if _, decision := node.(*ast.DecisionNode); !decision { + continue + } + for _, edge := range f.Outgoing[node] { + if f.Edges[edge].Guard != nil { + m.Held[edge] = boolVar(fmt.Sprintf("held[%d]@%d", edge, i)) + } + } + } + return m +} + +// vars lists the move's variables in a stable order. +func (m *Move) vars() []*solve.Var { + vars := []*solve.Var{m.Choice, m.Travel} + for _, held := range m.Held { + if held != nil { + vars = append(vars, held) + } + } + return vars +} + +// value is this state's copy of the feature base stands for, declared on first use. +func (s *State) value(base *solve.Var) *solve.Var { + if v, ok := s.Values[base.Name]; ok { + return v + } + v := &solve.Var{ + Name: fmt.Sprintf("%s@%d", base.Name, s.Move), + Sort: base.Sort, + Symbol: base.Symbol, + Dimension: base.Dimension, + Unit: base.Unit, + File: base.File, + Span: base.Span, + Location: base.Location, + } + s.Values[base.Name] = v + return v +} + +// Vector lists the state's variables under their move-independent names: the +// slots, the clock, the bus, the flags, the feature copies and, for each +// flagged feature, the flag saying whether it holds a value. Two states of one +// encoding have vectors of the same names in the same order. +func (s *State) Vector(features []*solve.Var, flagged map[string]bool) []Named { + vector := make([]Named, 0, 6*len(s.Slots)+4*len(s.Bus)+len(s.Loop)+2*len(features)+5) + add := func(name string, v *solve.Var) { + if v != nil { + vector = append(vector, Named{Name: name, Var: v}) + } + } + for t, slot := range s.Slots { + add(fmt.Sprintf("at[%d]", t), slot.At) + add(fmt.Sprintf("via[%d]", t), slot.Via) + add(fmt.Sprintf("id[%d]", t), slot.ID) + add(fmt.Sprintf("able[%d]", t), slot.Able) + add(fmt.Sprintf("parked[%d]", t), slot.Parked) + add(fmt.Sprintf("due[%d]", t), slot.Due) + } + add("next", s.NextID) + add("failed", s.Failed) + add("overflow", s.Overflow) + add("now", s.Now) + for b, slot := range s.Bus { + add(fmt.Sprintf("bus[%d].present", b), slot.Present) + add(fmt.Sprintf("bus[%d].signal", b), slot.Signal) + add(fmt.Sprintf("bus[%d].payload", b), slot.Payload) + add(fmt.Sprintf("bus[%d].posted", b), slot.PostedAt) + } + add("bus.overflow", s.BusOverflow) + for l, loop := range s.Loop { + add(fmt.Sprintf("loop[%d]", l), loop) + } + for _, base := range features { + add(base.Name, s.value(base)) + } + for _, base := range features { + if flagged[base.Name] { + add(fmt.Sprintf("has(%s)", base.Name), s.has(base)) + } + } + return vector +} + +// vars lists the state's variables in the vector's order. +func (s *State) vars(features []*solve.Var, flagged map[string]bool) []*solve.Var { + vector := s.Vector(features, flagged) + vars := make([]*solve.Var, len(vector)) + for i, named := range vector { + vars[i] = named.Var + } + return vars +} + +func intVar(name string) *solve.Var { return &solve.Var{Name: name, Sort: solve.Int} } +func realVar(name string) *solve.Var { return &solve.Var{Name: name, Sort: solve.Real} } +func boolVar(name string) *solve.Var { return &solve.Var{Name: name, Sort: solve.Bool} } +func sortedVar(name string, sort solve.Sort) *solve.Var { + return &solve.Var{Name: name, Sort: sort} +} + +// nodeValue is the term of the node sort naming node i of the flow. +func nodeValue(sorts Sorts, f *Flow, i int) *solve.Term { + return solve.ValueTerm(sorts.Node, f.Labels[i]) +} + +// edgeValue is the term of the edge sort naming succession i of the flow. +func edgeValue(sorts Sorts, f *Flow, i int) *solve.Term { + return solve.ValueTerm(sorts.Edge, edgeLabel(f, i)) +} diff --git a/internal/core/smt/support.go b/internal/exec/smt/support.go similarity index 99% rename from internal/core/smt/support.go rename to internal/exec/smt/support.go index 3555d44dda..ed409b28b3 100644 --- a/internal/core/smt/support.go +++ b/internal/exec/smt/support.go @@ -4,9 +4,9 @@ import ( "fmt" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // DefaultUnroll is how many iterations of a body loop the encoding unrolls when @@ -582,6 +582,8 @@ func effectName(kind lower.EffectKind) string { return "accept" case lower.EffectTerminate: return "terminate" + case lower.EffectStart: + return "start" } return fmt.Sprintf("effect %d", kind) } diff --git a/internal/core/smt/support_test.go b/internal/exec/smt/support_test.go similarity index 94% rename from internal/core/smt/support_test.go rename to internal/exec/smt/support_test.go index ad3d19b3e1..751edd57cb 100644 --- a/internal/core/smt/support_test.go +++ b/internal/exec/smt/support_test.go @@ -6,15 +6,15 @@ import ( "path/filepath" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // conformanceDir is the interpreter's conformance corpus, the referee's cases. @@ -29,7 +29,9 @@ func fixture(t *testing.T, path, src string) (*runtime.Context, *symbols.Index) idx.AddDocument(path, parser.New(sf).ParseFile()) idx.ExpandWildcardImports() resolver := resolve.New(idx) - ctx := runtime.NewContext(runtime.NewModel(semantics.NewModel(resolver), resolver), 10000) + model := runtime.NewModel(passes.NewTypedModel(resolver), resolver) + model.SetExpressionParser(parser.ParseOneExpression) + ctx := runtime.NewContext(model, 10000) ctx.Model().RegisterSource(sf) return ctx, idx } diff --git a/internal/core/smt/twocopy.go b/internal/exec/smt/twocopy.go similarity index 98% rename from internal/core/smt/twocopy.go rename to internal/exec/smt/twocopy.go index e91e1d4d5e..89580851b9 100644 --- a/internal/core/smt/twocopy.go +++ b/internal/exec/smt/twocopy.go @@ -4,8 +4,8 @@ import ( "fmt" "slices" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" ) // CopyPrefix starts the name of every variable of the relation's second copy. diff --git a/internal/core/smt/witness.go b/internal/exec/smt/witness.go similarity index 98% rename from internal/core/smt/witness.go rename to internal/exec/smt/witness.go index 3059934ecf..c93564f950 100644 --- a/internal/core/smt/witness.go +++ b/internal/exec/smt/witness.go @@ -7,10 +7,10 @@ import ( "strconv" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/solve" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // ErrNoWitness is the typed error of a decode over a result that carries no model. diff --git a/internal/core/solve/agreement_test.go b/internal/exec/solve/agreement_test.go similarity index 98% rename from internal/core/solve/agreement_test.go rename to internal/exec/solve/agreement_test.go index 5679185556..04ed4b5296 100644 --- a/internal/core/solve/agreement_test.go +++ b/internal/exec/solve/agreement_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // solverRequiredEnv is set in CI so that an absent solver fails these tests @@ -285,7 +285,7 @@ func TestGuardedDivisionSatisfiableWithALiteralDivisor(t *testing.T) { solved(t, solver, q, StatusSat) } -// evaluatedDivision is what internal/core/runtime computes for `a / b` and `a % +// evaluatedDivision is what internal/exec/runtime computes for `a / b` and `a % // b`, the normative answer this encoding has to reproduce. func evaluatedDivision(t *testing.T, a, b int) (quotient, remainder string) { t.Helper() diff --git a/internal/core/solve/capability.go b/internal/exec/solve/capability.go similarity index 100% rename from internal/core/solve/capability.go rename to internal/exec/solve/capability.go diff --git a/internal/core/solve/capability_test.go b/internal/exec/solve/capability_test.go similarity index 100% rename from internal/core/solve/capability_test.go rename to internal/exec/solve/capability_test.go diff --git a/internal/core/solve/configure.go b/internal/exec/solve/configure.go similarity index 100% rename from internal/core/solve/configure.go rename to internal/exec/solve/configure.go diff --git a/internal/core/solve/configure_test.go b/internal/exec/solve/configure_test.go similarity index 99% rename from internal/core/solve/configure_test.go rename to internal/exec/solve/configure_test.go index d131506e76..844a22bd1d 100644 --- a/internal/core/solve/configure_test.go +++ b/internal/exec/solve/configure_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // variantQuery translates a constraint of the variant fixture. diff --git a/internal/core/solve/core.go b/internal/exec/solve/core.go similarity index 100% rename from internal/core/solve/core.go rename to internal/exec/solve/core.go diff --git a/internal/core/solve/core_test.go b/internal/exec/solve/core_test.go similarity index 99% rename from internal/core/solve/core_test.go rename to internal/exec/solve/core_test.go index 5fb74515b3..ede29860f2 100644 --- a/internal/core/solve/core_test.go +++ b/internal/exec/solve/core_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // coreLabels names the assertions a core reported, for comparing against what a diff --git a/internal/core/solve/differential_corpus_test.go b/internal/exec/solve/differential_corpus_test.go similarity index 94% rename from internal/core/solve/differential_corpus_test.go rename to internal/exec/solve/differential_corpus_test.go index c512e983ab..70ba09ca9c 100644 --- a/internal/core/solve/differential_corpus_test.go +++ b/internal/exec/solve/differential_corpus_test.go @@ -11,14 +11,14 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) const ( @@ -231,7 +231,7 @@ func modelOf(t *testing.T, path string, src []byte, libraries bool) (*runtime.Co idx.ExpandWildcardImports() } resolver := resolve.New(idx) - ctx := runtime.NewContext(runtime.NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := runtime.NewContext(runtime.NewModel(passes.NewTypedModel(resolver), resolver), 10000) ctx.Model().RegisterSource(sf) return ctx, idx } @@ -303,7 +303,7 @@ func TestDifferentialStandardLibrary(t *testing.T) { parseLibraries(t, idx) idx.ExpandWildcardImports() resolver := resolve.New(idx) - ctx := runtime.NewContext(runtime.NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := runtime.NewContext(runtime.NewModel(passes.NewTypedModel(resolver), resolver), 10000) for _, doc := range libraryDocuments(idx) { gate.summary.files++ diff --git a/internal/core/solve/differential_random_test.go b/internal/exec/solve/differential_random_test.go similarity index 100% rename from internal/core/solve/differential_random_test.go rename to internal/exec/solve/differential_random_test.go diff --git a/internal/core/solve/differential_test.go b/internal/exec/solve/differential_test.go similarity index 99% rename from internal/core/solve/differential_test.go rename to internal/exec/solve/differential_test.go index c965c9531c..2840c1c1fc 100644 --- a/internal/core/solve/differential_test.go +++ b/internal/exec/solve/differential_test.go @@ -17,10 +17,10 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // diffOutcome classifies one differential check. diff --git a/internal/exec/solve/doc.go b/internal/exec/solve/doc.go new file mode 100644 index 0000000000..6782d09fa2 --- /dev/null +++ b/internal/exec/solve/doc.go @@ -0,0 +1,316 @@ +// Package solve translates the conditions a constraint, requirement or +// satisfaction assertion states into a solver-independent term IR and writes +// that IR as an SMT-LIB2 script. +// +// It also runs an external solver over that script — z3 or cvc5, found on PATH +// or named by OPENSYSML_SMT — as a process speaking SMT-LIB2 on standard input, +// so no library is linked in and releases stay pure Go. The verdicts sat, unsat +// and unknown stay distinct: a timeout or arithmetic the solver gave up on is +// unknown, and a solver that crashes, is absent, or replies unusably is a typed +// error rather than a verdict. +// +// The runtime evaluator remains the normative semantics; SysML v2 defines no +// solving semantics, so this is an advertised extension, not a conformance +// claim. +// +// The capability set — satisfiability, unsat-core conflict explanation, value +// synthesis and objective optimization over an SMT solver — follows the design of +// OpenMBEE's HMF ConstraintSolverService (Apache 2.0); see README Acknowledgements. +// +// # Solver compatibility +// +// OPENSYSML_SMT names any executable that speaks SMT-LIB2 on standard input, so +// what a backend must support is stated rather than assumed of z3. +// +// Logic selection emits the narrowest logic of the SMT-LIB 2.6 logic list +// (https://smt-lib.org/logics.shtml) that covers what a query actually uses: +// QF_UF for constants alone, QF_LIA/QF_NIA over Int, QF_LRA/QF_NRA over Real, and +// AUFLIRA/AUFNIRA for a query over both, the list defining no quantifier-free +// mixed logic to narrow to. Truncating integer division by a literal divisor stays +// in the linear logic — `div` and `mod` are the Ints theory's, which those logics +// include — and a variable divisor selects the nonlinear one it really needs. +// Datatypes and strings have no logic in the list, so a query using either sets +// the non-standard NonStandardLogic ("ALL"), which both verified backends accept; +// the script says so in a comment, and LogicChoice.Standard reports it. No logic is +// widened to dodge a hard case. +// +// What a backend is required to support is a Capability, and a Solver is probed +// once per executable (cached, one small script per form of the feature the writer +// emits — QF_NIA and QF_NRA for nonlinear, AUFLIRA and AUFNIRA for mixed — and only +// for the capabilities a query and operation actually need) or declared capable by +// the caller (Solver.Declared). A capability the backend rejects — an `(error …)` +// reply or `unsupported` — makes the request an UnsupportedCapabilityError naming +// the backend, the feature and the operation, refused before any query is run: no +// silent degrade and no fabricated verdict. A check the backend neither answered +// nor rejected settles nothing, so the query proceeds and its own verdict or +// SolverProcessError is reported. +// +// Three answers to a check are told apart, so a backend is never blamed for the +// wrong thing: +// - `(error …)`, `unsupported`, or a defined reply that contradicts the check — +// the backend refuses the capability, reported as UnsupportedCapabilityError. +// - `unknown`, no reply, a closed pipe or the check's deadline — nothing was +// established, so the query runs and answers for itself. +// - a reply SMT-LIB does not define at all, such as `maybe` — the executable is +// not answering as a solver, reported as SolverProcessError rather than as a +// missing feature, which is also how the dialogue itself reports one. +// +// Probed against z3 4.8.12 and cvc5 1.3.4: both support models, unsat cores, +// incremental checks, datatypes, strings, div/mod, nonlinear and mixed arithmetic +// and the non-standard logic. cvc5 rejects `(maximize …)` as a parse error and +// answers `unsupported` to `:opt.priority`, both z3 extensions, so objective +// optimization is z3-only. internal/exec/solve's portability harness +// (portability_test.go, TestPortability) runs one query per feature against +// whatever OPENSYSML_SMT names and reports each as pass, refuse or fail, a +// rejected script being ours to fix in the writer. +// +// # Conflict explanation +// +// Explain answers an unsat verdict with the assertions that conflict. The script +// it writes (CoreScript) names each assertion, turns unsat cores on and asks for +// the core once the verdict is unsat; labels are the assertion's position, so a +// core reads back to the Assertion, and its Provenance, that produced it. Every +// role can appear in a core, a declared domain (RoleDomain) or a well-definedness +// guard (RoleDefined) included, and an inherited condition names the supertype +// that declared it. +// +// Minimality is established, not assumed. A solver's core is unsatisfiable but +// need not be irreducible, so reduction drops one member at a time, each round a +// fresh solver process, and Core.Minimal says every remaining member was shown to +// be needed: dropping any one left the rest satisfiable. Reduction is bounded, in +// the spirit of the runtime's step budgets, by DefaultMaxCoreMembers members and +// DefaultCoreBudget of wall time (OPENSYSML_SMT_CORE_BUDGET overrides it); a core +// too large, out of budget, or whose round the solver did not decide is reported +// as it stands with Minimal false and Core.Note saying why. A solver that refuses +// cores, names an assertion the query did not assert, answers unreadably or +// reports an empty core is a CoreError, never an empty or invented core. +// +// Conditions come from the evaluator's own collection +// (runtime.Context.ConditionsOf), keeping its order and its distinctions: +// `require` versus `assume`, negation, and a body meaning the conjunction of +// its conditions. +// +// # Differential agreement gate +// +// The translation is evidence-backed rather than asserted: for an element whose +// conditions translate, and for a concrete assignment of the features they read, +// the gate (differential_test.go and the corpus and randomized gates beside it) +// requires the query conjoined with that assignment to be sat exactly when the +// evaluator says the conditions hold, and unsat exactly when it says they do +// not. Every other outcome is classified, never averaged away: unknown is +// recorded, a typed evaluator error is no verdict, and ErrDivisionByZero is +// required to correspond to the guarded query being unsat for that assignment. +// +// It runs over the runtime conformance corpus, the bundled standard library, the +// OMG training corpus and deterministic randomized models, and reports how much +// of each it reached — translated, refused, agreed, disagreed, unknown — so +// coverage drift is reviewable. What it proves is that the translation is +// faithful to the evaluator on the cases it covered; it is not a conformance +// claim, and the evaluator remains normative where the two ever differ. +// +// # Translatable subset +// +// A condition is translatable when every part of it is: +// +// - Boolean operators: `not`, `and`, `&`, `or`, `|`, `xor`, `implies`, and the +// conditional expression `if c ? a else b`. +// - Equality `==` and `!=` between two values of the same sort. +// - Comparisons `<`, `<=`, `>`, `>=` between numbers of the same dimension. +// - Arithmetic `+`, `-`, `*`, unary `-` and `+`. +// - Division `/` and remainder `%`. Integer division truncates toward zero as +// the evaluator does, encoded as ite(a >= 0, div(a, b), -div(-a, b)), and the +// remainder as a - b*tdiv(a, b), which takes the dividend's sign. A literal +// divisor keeps this linear; a variable divisor sets Query.Nonlinear, so +// unknown is an expected verdict rather than a surprise. +// - Division by zero, which SMT-LIB leaves underspecified while the evaluator +// refuses it: a literal zero divisor refuses translation, and any other +// divisor, integer or real, is asserted non-zero as a RoleDefined side +// condition. That assertion constrains the whole query, so it is only made +// where the division is always evaluated and read unnegated; a computed +// divisor under `not`, `or`, `xor`, `implies`, a conditional branch or a +// denied element refuses instead, since the evaluator may never divide there. +// - Literals: boolean, integer, real, string. +// - Quantity expressions (`450.0 [km/h]`), normalized to the base units their +// unit reduces to through semantics.UnitTermOf — magnitudes are exact +// rationals, so a scale factor introduces no rounding. +// - References to scalar-valued features, resolved through the same names the +// evaluator resolves: Boolean, String, Natural (declared non-negative), +// Integer, Rational, Real and Number features, features typed by a quantity +// value type, enumeration-typed features and variation points. +// - Feature chains that ground in such a feature (`lander.verticalSpeed`). +// - Enumeration literals and variants, as constructors of a finite datatype +// sort declared per enumeration definition or variation point. +// +// A variable stands for the value a feature may take, constrained only by its +// sort: declared values are not asserted, so a query asks what the conditions +// permit rather than what one object holds. +// +// # Value synthesis +// +// TranslateWith (and ConstraintWith, RequirementWith, SatisfactionWith) takes a +// partial assignment: Pins fixing some features to the values the model already +// fixes, the rest left free for the solver to choose. A pin is read where the +// evaluator reads it — Fixed and FixedFor go through the runtime's objects, +// feature values and declared defaults — and carries its provenance: held by an +// object (PinHeld), declared by the model (PinDeclared) or chosen by the caller +// (PinChosen). Passing no pin translates exactly as before, so a query with no +// partial assignment is the same script it always was. +// +// A pin becomes an ordinary equality assertion in role RolePinned, asserted +// before the conditions and named in Query.Pinned with its assertion index, so it +// can appear in an unsat core like any other assertion: unsat under pins means no +// values exist consistent with what is already fixed, and the core says which +// fixed values conflict. Values are converted through the same machinery the +// translator uses — a quantity normalized to base units as an exact rational, an +// enumeration literal or variant as the datatype constructor the writer declares +// — and a value the subset cannot represent, or one whose dimension does not +// match its feature, is a PinError wrapping ErrNotPinnable, never a silent drop. +// Features read by the conditions but not readable as a value are reported as +// Unread rather than being fixed to something. +// +// Result.Model is one witness, not a canonical answer: a satisfiable query +// usually has many models and the solver may return any of them. Values are +// rendered in OpenSysML's terms where the sort allows (qualified feature names, +// declared units, enumeration and variant names) and flagged as the solver wrote +// them where it does not. +// +// # Variant configuration +// +// A variation point translates as a finite datatype sort (Sort.Variation), so +// Query.Variations are its variation variables. Query.FixValue chooses a variant +// (PinChosen), which Solve then checks like any other fixed value, and with none +// chosen Solve's model is a consistent selection. Configurations enumerates +// consistent selections: one fresh check-sat per solution, each asserting the +// negation of the complete previous assignment, built from the solver's own +// terms rather than from rendered text. Every variation variable is assigned in +// every solution, nested variation points and constrained variants included, +// since they are variables of the same query as any other condition. +// +// The enumeration is bounded, in the spirit of the runtime's step budgets, by +// DefaultMaxConfigurations solutions (OPENSYSML_SMT_MAX_CONFIGURATIONS overrides +// it). Result.Truncated says the enumeration was cut short and why: AtBound for +// the bound, Undecided for a solver that stopped deciding, with TimedOut when +// the run's deadline was what stopped it — a deadline reports the solutions +// already found rather than discarding them. Results are exhaustive only when a +// final check-sat answered unsat; nothing implies exhaustiveness that was not +// shown. A query reading no variation point is a NoVariationsError wrapping +// ErrNoVariations, not an empty enumeration. +// +// Known limitations: only variation points in the translatable subset are +// configured, so a variation whose variants carry collection-valued or otherwise +// untranslatable conditions refuses with ErrNotTranslatable; variants are +// configured as values of a variation point, not as objects, so nothing is +// materialized and features a variant would only have once bound are not +// constrained; and the enumeration order is the solver's, not a defined one. +// +// # Objective optimization +// +// Analysis and AnalysisWith translate an `analysis def` as an optimization query: +// what its conditions permit, and the objectives to improve within that. +// Solver.Optimize then asks a backend for each objective's optimum. +// +// SysML v2 states no direction, value or solving semantics for `objective`, so +// this layer states the contract it reads (an OpenSysML extension, not a +// conformance claim): +// +// - Direction comes from the trade-study definition the objective is typed by: +// TradeStudies::MinimizeObjective or MaximizeObjective, specializations +// included. An objective typed by neither refuses with ErrNotOptimizable. +// - The value to improve is the expression the objective's redefinition of +// the library's `eval` calculation returns — `objective o : +// MinimizeObjective { subject :>> selectedAlternative; in calc :>> eval { +// expression } }` — which is the library's own extension point: it derives +// `best` as `eval` over the alternatives. An objective giving the bound +// `best` a value of its own instead (`attribute :>> best = expression;`) is +// a validation error, and refuses here with ErrNotOptimizable pointing at +// the `eval` spelling rather than being read. A value bound directly to the +// objective is read too, where a model can write one. +// - What is feasible is the case's own conditions (CaseConditionsOf: `require`, +// `assume`, `assert`, `inv`, inherited ones included) together with the +// conditions each objective states: its own body's, and the ones it inherits +// from the model's own objective definitions, read where they are inherited. +// Only the trade-study library's own conditions are left out, being about +// choosing among alternatives rather than about which values are feasible. A +// condition an inherited definition states over `best` bounds the value +// improved, since the objective's `best` is asserted equal to what its `eval` +// returns, as the library derives it (RoleDefined). +// A case stating no condition is legitimately unbounded, not refused. +// - Objectives, values and conditions are read through the runtime's own +// surfaces (runtime.Context.ObjectivesOf), so what is optimized is what the +// evaluator would evaluate; no declaration is re-parsed and no AST mutated. +// +// The objective term must be numeric and linear: an optimizer improves a linear +// objective, so a product or quotient of two computed values refuses with +// ErrNotOptimizable rather than being sent and misread. Divisor guards therefore +// sit in the conditions, where a computed divisor is asserted non-zero as usual. +// +// # Multiple objectives, and backend requirements +// +// Several objectives are optimized lexicographically in declaration order: each +// within what the ones before it already settled. The script says so itself with +// `(set-option :opt.priority lex)` rather than relying on a backend default — +// z3's `box` mode reports each objective's optimum separately and returns a model +// attaining only one of them, which would make "the assignments achieving the +// optimum" untrue. +// +// `(minimize e)`/`(maximize e)`, `(get-objectives)` and `:opt.priority` are +// solver extensions rather than SMT-LIB2; cvc5 implements none of them. +// Solver.Optimize settles them through the capability model below before sending +// a query — CapOptimization and CapOptimizationPriority, probed once per backend +// and cached — and reports a backend without them as NoOptimizationError, which +// wraps both ErrNoOptimization and the ErrUnsupportedCapability refusal it was +// settled by. Nothing is ever degraded to a plain check-sat and presented as an +// optimum. +// +// # What an optimum is, and is not +// +// Optimum.Status keeps the cases apart, and no case fabricates a number: +// +// - OptimumAttained: the value reported is the optimum and an assignment +// attains it. Both are checked here rather than taken on the backend's word: +// the objective's value in the reported model is read back, and a further +// check asks whether any assignment does lexicographically better — better in +// an earlier objective, or equal there and better in a later one. Unsat is +// what makes the answer an optimum. +// - OptimumUnbounded: the conditions permit arbitrarily better values (`oo`). +// - OptimumBounded: the objective approaches a bound no assignment attains, as +// a strict inequality over the reals does. Backends report this as an +// infinitesimal (`(+ 10.5 (* (- 1.0) epsilon))`) or an interval; the bound is +// reported as a bound, never as an attained value. +// - OptimumUnverified: the backend reported a value a better feasible value +// refutes. z3 4.8.12 does this for open real suprema — maximizing x under +// x < 10.5 reports 9.5 — which is why every optimum is verified rather than +// trusted. +// - OptimumUndecided: verification did not decide, or the answer was no number. +// +// Optimum.Feasible is always a value the reported assignment attains: a witness +// the conditions permit, not an optimum. A solver answering sat but refusing to +// report its objectives readably is an OptimumError wrapping ErrNoOptimum and +// ErrSolverProcess; unsat and unknown stay the verdicts they are, with no optima +// invented for them, and a query stating no objective is a NoObjectiveError +// wrapping ErrNoObjective rather than a satisfiability check. +// +// # Deliberately out of subset +// +// Everything else refuses with ErrNotTranslatable, and one refused conjunct +// fails the whole query, so no partial script exists: +// +// - Collections and quantifiers: sequences, sets, `->select`, `->collect`, +// `->forAll`, `->exists`, `->size`, indexing `#(i)`, ranges `a..b`, and +// collection-valued features. Bounded expansion is not implemented. +// - Invocations of any kind, calc usages included: a calc body may be +// iterative or read state, and constant folding it is the evaluator's job. +// - Euclidean `div`/`mod` themselves, as SMT-LIB defines them: only the +// evaluator's truncating semantics are encoded. +// - Real remainder `%`, which the evaluator answers by floating-point +// remainder. +// - `**` and `^`: exponentiation is outside linear and polynomial arithmetic +// as encoded here. +// - Classification and metadata operators: `hastype`, `istype`, `@`, `@@`, +// `as`, `meta`, `all`, `===`, `!==`, `??`, `~`, and `null`. +// - Complex numbers, string operations other than equality, and features whose +// type determines no scalar sort. +// - Comparing or adding magnitudes of different dimensions, which the +// evaluator reports as incommensurable units. +// - Unresolved names and feature chains that ground in nothing. +package solve diff --git a/internal/exec/solve/errors.go b/internal/exec/solve/errors.go new file mode 100644 index 0000000000..172c74207d --- /dev/null +++ b/internal/exec/solve/errors.go @@ -0,0 +1,357 @@ +package solve + +import ( + "errors" + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// ErrNotTranslatable is returned when a condition uses a construct outside the +// translatable subset. A query that cannot encode one conjunct fails as a whole: +// a partial script would answer sat or unsat about conditions it does not hold. +var ErrNotTranslatable = errors.New("not translatable for solving") + +// ErrNoConditions is returned for an element that states no condition to +// translate, as evaluating it reports the same. +var ErrNoConditions = errors.New("states no condition") + +// ErrNoObjective is returned for an analysis case that states no objective, so +// there is nothing to optimize: what it permits is still a satisfiability +// question, which checking it plainly answers. +var ErrNoObjective = errors.New("states no objective") + +// ErrNotOptimizable is returned for an objective that cannot be optimized as +// written: it states no direction, no value, or a value an optimizer cannot +// improve. It is a refusal about the objective rather than about the subset a +// condition is translated in. +var ErrNotOptimizable = errors.New("not optimizable") + +// ErrNoOptimization is returned when the solver found does not implement +// optimization. `(maximize …)` is a z3 extension rather than SMT-LIB, so this is +// reported rather than degraded to a plain satisfiability check. +var ErrNoOptimization = errors.New("the SMT solver does not implement optimization") + +// ErrNoOptimum is returned when a solver answered sat but did not report the +// optimum readably. No optimum is invented in its place. +var ErrNoOptimum = errors.New("the SMT solver did not report the optimum") + +// NoObjectiveError says which analysis case states no objective. It unwraps to +// ErrNoObjective. +type NoObjectiveError struct { + // Element names the analysis case asked about. + Element string +} + +// Error reports that the case states no objective. +func (e *NoObjectiveError) Error() string { + return fmt.Sprintf("analysis %s: %s", e.Element, ErrNoObjective) +} + +// Unwrap returns ErrNoObjective. +func (e *NoObjectiveError) Unwrap() error { return ErrNoObjective } + +// ObjectiveError says which objective cannot be optimized, why, and where it was +// written. It unwraps to ErrNotOptimizable. +type ObjectiveError struct { + // Objective names the objective as the model writes it. + Objective string + + // Reason says what about it cannot be optimized. + Reason string + + // Remedy says what the model would have to state instead, empty when there + // is nothing to suggest. + Remedy string + + // Element is the analysis case stating the objective. + Element string + + // File is the document it was written in, empty when unknown. + File string + + // Span is where in File it was written. + Span source.Span + + // Location renders File and Span as `file:line:col`, empty when unknown. + Location string +} + +// Error reports the refusal, naming the objective and where it was written. +func (e *ObjectiveError) Error() string { + msg := fmt.Sprintf("%s: %s %s", e.Element, e.Objective, ErrNotOptimizable) + if e.Reason != "" { + msg += ": it " + e.Reason + } + if e.Remedy != "" { + msg += " (" + e.Remedy + ")" + } + if e.Location != "" { + msg += " at " + e.Location + } + return msg +} + +// Unwrap returns ErrNotOptimizable. +func (e *ObjectiveError) Unwrap() error { return ErrNotOptimizable } + +// NoOptimizationError names the solver that does not implement optimization and +// what to run instead. It unwraps to ErrNoOptimization. +type NoOptimizationError struct { + // Solver is the executable that was run. + Solver string + + // Detail says how it turned out not to implement optimization. + Detail string + + // Cause is the capability refusal this was settled by, when one settled it. + Cause error +} + +// Error reports that the solver cannot optimize, naming what can. +func (e *NoOptimizationError) Error() string { + msg := fmt.Sprintf("%s: %s", ErrNoOptimization, e.Solver) + if e.Detail != "" { + msg += " " + e.Detail + } + return msg + fmt.Sprintf("; install z3 or set %s to it", SolverEnv) +} + +// Unwrap returns ErrNoOptimization, and the capability refusal behind it when the +// capability model settled it. +func (e *NoOptimizationError) Unwrap() []error { + if e.Cause != nil { + return []error{ErrNoOptimization, e.Cause} + } + return []error{ErrNoOptimization} +} + +// OptimumError says which solver would not report an optimum it had found, and +// how. It unwraps to both ErrNoOptimum and ErrSolverProcess, as the solver did +// not answer what it was asked. +type OptimumError struct { + // Solver is the executable that was run. + Solver string + + // Objective names the objective it was asked about. + Objective string + + // Detail says what it answered instead of an optimum. + Detail string + + // Stderr is what the solver wrote on standard error, trimmed. + Stderr string +} + +// Error reports the failure, naming the solver and what it answered. +func (e *OptimumError) Error() string { + msg := fmt.Sprintf("%s: %s answered sat but %s", ErrNoOptimum, e.Solver, e.Detail) + if e.Objective != "" { + msg += " for " + e.Objective + } + if e.Stderr != "" { + msg += ": " + e.Stderr + } + return msg +} + +// Unwrap returns both kinds this failure is, so either is testable with +// errors.Is. +func (e *OptimumError) Unwrap() []error { return []error{ErrNoOptimum, ErrSolverProcess} } + +// ErrNoSolver is returned when no SMT solver could be found to run a query. +// Solving is optional, so its absence is reported rather than passed over. +var ErrNoSolver = errors.New("no SMT solver found") + +// ErrSolverProcess is returned when a solver was found but did not answer: it +// crashed, failed, or replied unintelligibly. Never a verdict, not even `unknown`. +var ErrSolverProcess = errors.New("the SMT solver did not answer") + +// ErrNoCore is returned when a solver answered unsat but would not report which +// assertions conflict, or named assertions the query never asserted. No core is +// invented in its place. +var ErrNoCore = errors.New("the SMT solver did not report an unsat core") + +// ErrUnsupportedCapability is returned when the backend found lacks a feature the +// query needs. It is a refusal, not a verdict: nothing is degraded and no answer +// is guessed from a script the backend would not accept. +var ErrUnsupportedCapability = errors.New("the SMT solver does not support a feature this query needs") + +// UnsupportedCapabilityError names the backend, the features it lacks, and what +// was being asked of it. It unwraps to ErrUnsupportedCapability. +type UnsupportedCapabilityError struct { + // Solver is the backend that lacks the capability. + Solver string + + // Operation is what was being asked of it: "solving", "explaining a + // conflict", "enumerating configurations". + Operation string + + // Missing are the capabilities it lacks, the first being the one reported. + Missing []Capability + + // Detail is what it answered when probed for the first missing capability. + Detail string +} + +// Error names the missing feature, the backend, and how to get a backend that has +// it, since the choice of solver is the operator's. +func (e *UnsupportedCapabilityError) Error() string { + msg := fmt.Sprintf("%s: %s does not support %s, which %s needs", + ErrUnsupportedCapability, e.Solver, e.feature(), e.Operation) + if e.Detail != "" { + msg += ": " + e.Detail + } + return msg + fmt.Sprintf("; install a solver that supports it or set %s to one", SolverEnv) +} + +// feature names the missing capabilities, the first with what SMT-LIB feature it +// is and the rest by name. +func (e *UnsupportedCapabilityError) feature() string { + if len(e.Missing) == 0 { + return "a feature it was not asked about" + } + out := e.Missing[0].Feature() + " (" + e.Missing[0].String() + ")" + for _, capability := range e.Missing[1:] { + out += ", nor " + capability.String() + } + return out +} + +// Unwrap returns ErrUnsupportedCapability. +func (e *UnsupportedCapabilityError) Unwrap() error { return ErrUnsupportedCapability } + +// CoreError says which solver would not explain its unsat verdict and how. It +// unwraps to both ErrNoCore and ErrSolverProcess, as the solver did not answer +// what it was asked. +type CoreError struct { + // Solver is the executable that was run. + Solver string + + // Detail says what it answered instead of a core. + Detail string + + // Stderr is what the solver wrote on standard error, trimmed. + Stderr string +} + +// Error reports the failure, naming the solver and what it answered. +func (e *CoreError) Error() string { + msg := fmt.Sprintf("%s: %s answered unsat but %s", ErrNoCore, e.Solver, e.Detail) + if e.Stderr != "" { + msg += ": " + e.Stderr + } + return msg +} + +// Unwrap returns both kinds this failure is, so either is testable with +// errors.Is. +func (e *CoreError) Unwrap() []error { return []error{ErrNoCore, ErrSolverProcess} } + +// NoSolverError names the candidates looked for and what to install. It unwraps +// to ErrNoSolver. +type NoSolverError struct { + // Override is the value of the OPENSYSML_SMT override, empty when unset. + Override string + + // Looked lists the executables looked for on PATH, in order. + Looked []string +} + +// Error reports that no solver was found, naming what would satisfy the search. +func (e *NoSolverError) Error() string { + if e.Override != "" { + return fmt.Sprintf("%s: %s names %q, which is not an executable file", + ErrNoSolver, SolverEnv, e.Override) + } + return fmt.Sprintf("%s: install z3 (`apt install z3`, `brew install z3`) or cvc5, "+ + "or set %s to a solver executable; looked for %v on PATH", + ErrNoSolver, SolverEnv, e.Looked) +} + +// Unwrap returns ErrNoSolver. +func (e *NoSolverError) Unwrap() error { return ErrNoSolver } + +// SolverProcessError says which solver failed, at which step, and what it wrote +// on standard error. It unwraps to ErrSolverProcess. +type SolverProcessError struct { + // Solver is the executable that was run. + Solver string + + // Stage names what was being done: "start", "write", "check-sat", + // "get-value", "capability check" or "exit". + Stage string + + // Detail says what went wrong. + Detail string + + // Stderr is what the solver wrote on standard error, trimmed. + Stderr string + + // Err is the underlying error, nil when the failure was the reply itself. + Err error +} + +// Error reports the failure, naming the solver and the step it failed at. +func (e *SolverProcessError) Error() string { + msg := fmt.Sprintf("%s: %s failed at %s: %s", ErrSolverProcess, e.Solver, e.Stage, e.Detail) + if e.Stderr != "" { + msg += ": " + e.Stderr + } + return msg +} + +// Unwrap returns ErrSolverProcess, and the underlying error when there is one, +// so both are testable with errors.Is. +func (e *SolverProcessError) Unwrap() []error { + if e.Err == nil { + return []error{ErrSolverProcess} + } + return []error{ErrSolverProcess, e.Err} +} + +// NotTranslatableError says which construct refused, why, and where it was +// written. It unwraps to ErrNotTranslatable. +type NotTranslatableError struct { + // Construct names the construct that refused, as the notation writes it. + Construct string + + // Reason says why it is outside the subset. + Reason string + + // Element is the element whose condition was being translated. + Element string + + // Condition is the condition as written, empty when the refusal is about a + // declaration rather than a condition. + Condition string + + // File is the document the construct was written in, empty when unknown. + File string + + // Span is where in File it was written. + Span source.Span + + // Location renders File and Span as `file:line:col`, empty when unknown. + Location string +} + +// Error reports the refusal, naming the construct, the condition it appeared in, +// and where it was written. +func (e *NotTranslatableError) Error() string { + msg := fmt.Sprintf("%s: %s %s", e.Element, e.Construct, ErrNotTranslatable) + if e.Reason != "" { + msg += ": " + e.Reason + } + if e.Condition != "" { + msg += fmt.Sprintf(" (in %s)", e.Condition) + } + if e.Location != "" { + msg += " at " + e.Location + } + return msg +} + +// Unwrap returns ErrNotTranslatable, so a caller tests the kind of failure +// rather than its text. +func (e *NotTranslatableError) Unwrap() error { return ErrNotTranslatable } diff --git a/internal/exec/solve/expression.go b/internal/exec/solve/expression.go new file mode 100644 index 0000000000..3da4dcd3e9 --- /dev/null +++ b/internal/exec/solve/expression.go @@ -0,0 +1,255 @@ +package solve + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// Expression is one translated expression: the term it yields, and the side +// conditions under which the evaluator computes that term rather than reporting +// an error — a computed divisor being non-zero, Integer arithmetic staying +// within int64. A consumer encoding a step of execution asserts Term where +// Defined all hold and the evaluator's error where one does not, since SMT-LIB's +// division is total and its integers unbounded while the evaluator's are not. +type Expression struct { + // Term is the value the expression yields. + Term *Term + + // Defined are the conditions the evaluator needs to compute Term, each a + // boolean term; empty for an expression it always computes. + Defined []*Term +} + +// Translator translates the expressions one element writes — the guards, +// assignments and conditions of a behavior — into terms over one set of +// variables, for a consumer assembling a Query of its own from them. Each +// feature read is one variable, named and sorted as a condition's translation +// names it, so a consumer substituting variables of its own for them (a copy per +// step, say) keys the substitution by the feature. +type Translator struct { + t *translator +} + +// NewTranslator starts translating the expressions subject writes. Names resolve +// as the evaluator resolves them: first among the features subject declares or +// inherits, then in the scope each expression is written in. Integer arithmetic +// is defined within int64, as the evaluator computes it. +func NewTranslator(ctx *runtime.Context, subject Subject) (*Translator, error) { + if ctx == nil { + return nil, fmt.Errorf("solve: no runtime context") + } + t := newTranslator(ctx, subject) + t.machine = true + if subject.Symbol != nil { + t.condFile = subject.Symbol.DocName + } + return &Translator{t: t}, nil +} + +// Expression translates one expression written in scope. within names the +// statement or guard the expression belongs to, which a refusal reports as the +// condition it appeared in. A construct outside the subset refuses with +// ErrNotTranslatable; nothing is degraded. +func (x *Translator) Expression(node ast.Node, scope *symbols.Scope, within string) (*Expression, error) { + t := x.t + t.condLabel = within + if node == nil { + return nil, t.refuse(nil, "empty expression", "it states no expression") + } + // Definedness is per expression here: what a guard hoisted over one + // expression says nothing about another, and a divisor read twice in two + // expressions guards both. + guards, guarded := t.guards, t.guarded + t.guards, t.guarded = nil, map[string]bool{} + defer func() { t.guards, t.guarded = guards, guarded }() + term, err := t.expr(node, scope) + if err != nil { + return nil, err + } + expr := &Expression{Term: term} + for _, guard := range t.guards { + expr.Defined = append(expr.Defined, guard.Term) + } + return expr, nil +} + +// Boolean translates an expression that must yield a boolean — a guard, a loop +// or branch condition — refusing one that yields another sort, as the executor +// reports a condition that is not Boolean. +func (x *Translator) Boolean(node ast.Node, scope *symbols.Scope, within string) (*Expression, error) { + expr, err := x.Expression(node, scope, within) + if err != nil { + return nil, err + } + if expr.Term.Sort.Kind != SortBool { + return nil, x.t.refuse(node, within, "it yields "+expr.Term.Sort.Name+" rather than a boolean") + } + return expr, nil +} + +// Condition translates one condition as the evaluator reads it: a group as the +// conjunction of its members, negated as written. Its Term is Boolean; Defined +// holds the conditions under which the evaluator computes it at all. +func (x *Translator) Condition(cond runtime.Condition) (*Expression, error) { + t := x.t + owner, _ := conditionOrigin(cond) + t.condLabel = cond.Label() + t.condFile = "" + if owner != nil { + t.condFile = owner.DocName + } + guards, guarded := t.guards, t.guarded + t.guards, t.guarded = nil, map[string]bool{} + defer func() { t.guards, t.guarded = guards, guarded }() + term, err := t.condition(cond) + if err != nil { + return nil, err + } + expr := &Expression{Term: term} + for _, guard := range t.guards { + expr.Defined = append(expr.Defined, guard.Term) + } + return expr, nil +} + +// Conditions translates what the subject's conditions establish as a set, the +// way the evaluator judges them: the required ones must hold, an assumed one is +// evaluated but binds nothing, and a negated subject denies the conjunction of +// its required ones. Its Term is that verdict; Defined holds the conditions +// under which the evaluator computes every one it reaches, assumed ones +// included — the evaluator stops at the first required one that fails, so a +// later one need be defined only while those before it hold. +func (x *Translator) Conditions(conds []runtime.Condition) (*Expression, error) { + t := x.t + if len(conds) == 0 { + return nil, fmt.Errorf("%s %s: %w", t.subject.Kind, t.subject.Name, ErrNoConditions) + } + var required []*Term + expr := &Expression{} + for _, cond := range conds { + one, err := x.Condition(cond) + if err != nil { + return nil, err + } + reached := And(required...) + for _, defined := range one.Defined { + if len(required) > 0 { + defined = Or(Not(reached), defined) + } + expr.Defined = append(expr.Defined, defined) + } + if cond.Required { + required = append(required, one.Term) + } + } + if t.subject.Negated { + if len(required) == 0 { + return nil, fmt.Errorf("%s %s: %w to deny", t.subject.Kind, t.subject.Name, ErrNoConditions) + } + expr.Term = Not(And(required...)) + return expr, nil + } + expr.Term = And(required...) + return expr, nil +} + +// Variable is the variable standing for the feature name resolves to where it is +// written in scope — the one a reference to the name in an expression reads — for +// a consumer encoding a write to it. within names the statement writing it. +func (x *Translator) Variable(name string, scope *symbols.Scope, within string) (*Var, error) { + t := x.t + t.condLabel = within + segments := []string{name} + chain, err := t.resolvePath(nil, scope, segments) + if err != nil { + return nil, err + } + if _, _, literal := t.valueOf(chain[len(chain)-1]); literal { + return nil, t.refuse(nil, "feature `"+name+"`", "it names a literal rather than a feature") + } + term, err := t.variableOf(nil, scope, chain, segments) + if err != nil { + return nil, err + } + return term.Var, nil +} + +// Vars are the variables the expressions translated so far read, ordered by +// name; each stands for one feature. +func (x *Translator) Vars() []*Var { + vars := make([]*Var, 0, len(x.t.vars)) + for _, v := range x.t.vars { + if v.Unit == "" { + v.Unit = x.t.baseUnits[v.Dimension] + } + vars = append(vars, v) + } + sortVars(vars) + return vars +} + +// Sorts are the finite datatype sorts the expressions translated so far range +// over, ordered by name. +func (x *Translator) Sorts() []Sort { + sorts := make([]Sort, 0, len(x.t.sorts)) + for _, s := range x.t.sorts { + sorts = append(sorts, s) + } + sortSorts(sorts) + return sorts +} + +// Domains are the bounds the declarations of the variables read put on their +// values (a Natural is not negative), in a deterministic order. A consumer +// asserts each one of every copy of the variable it declares. +func (x *Translator) Domains() []Assertion { + domains := append([]Assertion(nil), x.t.domains...) + sortAssertions(domains) + return domains +} + +// Literal renders a value the evaluator holds for v as a term of v's sort, as a +// pinned value is rendered. A value the term language has no literal for +// refuses with a PinError naming the feature. +func (x *Translator) Literal(v *Var, value runtime.Value) (*Term, error) { + if v == nil { + return nil, fmt.Errorf("solve: a literal of no variable") + } + term, _, err := x.t.pinTerm(Pin{Feature: v.Symbol, Name: v.Name, Value: value, Source: PinHeld}, v) + return term, err +} + +// Substitute returns t with every variable replaced by what replace returns for +// it, sharing the subterms that read no variable. A replacement must yield the +// variable's sort; replace returning nil keeps the variable. +func Substitute(t *Term, replace func(*Var) *Term) *Term { + if t == nil { + return nil + } + if t.Op == OpVar { + if replaced := replace(t.Var); replaced != nil { + return replaced + } + return t + } + if len(t.Args) == 0 { + return t + } + args := make([]*Term, len(t.Args)) + changed := false + for i, arg := range t.Args { + args[i] = Substitute(arg, replace) + if args[i] != arg { + changed = true + } + } + if !changed { + return t + } + copied := *t + copied.Args = args + return &copied +} diff --git a/internal/core/solve/expression_test.go b/internal/exec/solve/expression_test.go similarity index 99% rename from internal/core/solve/expression_test.go rename to internal/exec/solve/expression_test.go index c4880f2ed9..25a66fd199 100644 --- a/internal/core/solve/expression_test.go +++ b/internal/exec/solve/expression_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/lower" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" ) // actionBody lowers the named action and returns the translator for its subject diff --git a/internal/exec/solve/golden_test.go b/internal/exec/solve/golden_test.go new file mode 100644 index 0000000000..226d391889 --- /dev/null +++ b/internal/exec/solve/golden_test.go @@ -0,0 +1,126 @@ +package solve + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +var update = flag.Bool("update", false, "rewrite the .smt2 golden files") + +// TestGolden translates each fixture and compares the script it writes with the +// golden one, which locks both the translation and the writer's output. +func TestGolden(t *testing.T) { + cases := []struct { + fixture string + element string // qualified name of the element to translate + kind string // constraint or requirement + golden string + }{ + {"touchdown.sysml", "test::TouchdownRequirement", "requirement", "touchdown.smt2"}, + {"mission_budget.sysml", "test::BudgetConstraint", "constraint", "mission_budget.smt2"}, + {"ring_variants.sysml", "test::ringFamily::finishMatchesNesting", "constraint", "ring_variants.smt2"}, + {"safe_window.sysml", "test::SafeWindow", "constraint", "safe_window.smt2"}, + {"safe_window.sysml", "test::rig::safeWindow", "constraint", "safe_window_denied.smt2"}, + {"objectives.sysml", "test::MassBudget", "analysis", "objective_mass.smt2"}, + {"objectives.sysml", "test::CostThenMargin", "analysis", "objective_lexicographic.smt2"}, + {"objectives.sysml", "test::WheelChoice", "analysis", "objective_variants.smt2"}, + {"objectives.sysml", "test::GuardedRatio", "analysis", "objective_guarded.smt2"}, + // One per logic the feature set can select, so a change of logic shows here. + {"logic_selection.sysml", "test::CratesPerPallet", "constraint", "crates_per_pallet.smt2"}, + {"logic_selection.sysml", "test::CratesPerRun", "constraint", "crates_per_run.smt2"}, + {"logic_selection.sysml", "test::MassAndCrates", "constraint", "mass_and_crates.smt2"}, + {"logic_selection.sysml", "test::MassPerCrate", "constraint", "mass_per_crate.smt2"}, + } + for _, tc := range cases { + t.Run(tc.golden, func(t *testing.T) { + ctx, idx := fixtureFile(t, tc.fixture) + sym := symbolNamed(t, idx, tc.element) + query, err := translateElement(ctx, tc.kind, sym) + if err != nil { + t.Fatalf("translate %s: %v", tc.element, err) + } + compareGolden(t, tc.golden, Script(query)) + }) + } +} + +// translateElement translates one element of the kind named. +func translateElement(ctx *runtime.Context, kind string, sym *symbols.Symbol) (*Query, error) { + switch kind { + case "requirement": + return Requirement(ctx, sym, sym.OwnerScope) + case "analysis": + return Analysis(ctx, sym, sym.OwnerScope) + } + return Constraint(ctx, sym, sym.OwnerScope) +} + +// compareGolden compares a script with its golden file, rewriting it under +// -update. +func compareGolden(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", name) + if *update { + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden (run with -update to create it): %v", err) + } + if got != string(want) { + t.Errorf("script differs from %s:\n--- got ---\n%s\n--- want ---\n%s", name, got, want) + } +} + +// TestDeterministic: translating and writing the same element twice yields the +// same bytes, which is what makes a golden comparison meaningful. +func TestDeterministic(t *testing.T) { + ctx, idx := fixtureFile(t, "ring_variants.sysml") + sym := symbolNamed(t, idx, "test::ringFamily::finishMatchesNesting") + + first, err := Constraint(ctx, sym, sym.OwnerScope) + if err != nil { + t.Fatalf("first translation: %v", err) + } + second, err := Constraint(ctx, sym, sym.OwnerScope) + if err != nil { + t.Fatalf("second translation: %v", err) + } + if Script(first) != Script(second) { + t.Errorf("two translations wrote different scripts:\n%s\n%s", Script(first), Script(second)) + } +} + +// TestGoldenScriptsAreSelfDescribing: every golden script says what it came from +// and asks for an answer, so a solver run needs nothing added. +func TestGoldenScriptsAreSelfDescribing(t *testing.T) { + entries, err := filepath.Glob(filepath.Join("testdata", "*.smt2")) + if err != nil { + t.Fatalf("glob: %v", err) + } + if len(entries) == 0 { + t.Fatal("no golden scripts; run with -update") + } + for _, path := range entries { + text, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + script := string(text) + if !strings.HasPrefix(script, "; OpenSysML SMT-LIB2 translation of ") { + t.Errorf("%s does not name what it was translated from", path) + } + if !strings.Contains(script, "(set-logic ") || !strings.HasSuffix(script, "(check-sat)\n") { + t.Errorf("%s is not a complete script", path) + } + } +} diff --git a/internal/core/solve/logic.go b/internal/exec/solve/logic.go similarity index 100% rename from internal/core/solve/logic.go rename to internal/exec/solve/logic.go diff --git a/internal/exec/solve/model.go b/internal/exec/solve/model.go new file mode 100644 index 0000000000..2a36ecce35 --- /dev/null +++ b/internal/exec/solve/model.go @@ -0,0 +1,270 @@ +package solve + +import ( + "bufio" + "fmt" + "math/big" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// ModelValue is one variable's value in a model, read back exactly from the +// solver's own text: a consumer decoding a witness reads it rather than parsing +// the rendered Assignment. +type ModelValue struct { + // Kind is the sort of the value, the variable's own. + Kind SortKind + + // Bool holds a SortBool value. + Bool bool + + // Number holds a SortInt value, which is integral, or a SortReal value, exact. + Number *big.Rat + + // Text holds a SortString value, or the name of a SortDatatype value: the + // qualified name of the literal or variant, as the sort declares it. + Text string +} + +// DecodeValue reads an assignment back exactly. A value the variable's sort does +// not hold — a datatype value the sort does not declare, an algebraic number the +// solver writes as a root, a term rather than a literal — is an error. +func DecodeValue(a Assignment) (ModelValue, error) { + if a.Var == nil { + return ModelValue{}, fmt.Errorf("a value of no variable") + } + value, err := readSexpr(bufio.NewReader(strings.NewReader(a.Raw))) + if err != nil { + return ModelValue{}, fmt.Errorf("unreadable value %s", a.Raw) + } + return decodeSexpr(a.Var, value) +} + +// decodeSexpr reads a solver value of the variable's sort exactly. +func decodeSexpr(v *Var, value sexpr) (ModelValue, error) { + raw := value.String() + kind := v.Sort.Kind + switch kind { + case SortBool: + if !value.IsList && (value.Atom == "true" || value.Atom == "false") { + return ModelValue{Kind: kind, Bool: value.Atom == "true"}, nil + } + case SortString: + if !value.IsList && value.Quoted { + return ModelValue{Kind: kind, Text: value.Atom}, nil + } + case SortDatatype: + if value.IsList { + break + } + name := smtName(value.Atom) + if !contains(v.Sort.Values, name) { + return ModelValue{}, fmt.Errorf("%s is not a value of %s", name, v.Sort.Name) + } + return ModelValue{Kind: kind, Text: name}, nil + case SortInt: + rat, ok := ratOfSexpr(value) + if !ok || !rat.IsInt() { + return ModelValue{}, fmt.Errorf("no integer in %s", raw) + } + return ModelValue{Kind: kind, Number: rat}, nil + case SortReal: + rat, ok := ratOfSexpr(value) + if !ok { + return ModelValue{}, fmt.Errorf("no rational in %s", raw) + } + return ModelValue{Kind: kind, Number: rat}, nil + } + return ModelValue{}, fmt.Errorf("unreadable value %s", raw) +} + +// Render writes the value as the notation does for the variable: a quantity with +// its unit, an enumeration or variant by name, a number, a boolean or a string. +func (v ModelValue) Render(variable *Var) string { + switch v.Kind { + case SortBool: + return strconv.FormatBool(v.Bool) + case SortString: + return `"` + v.Text + `"` + case SortDatatype: + return v.Text + case SortInt: + return v.Number.Num().String() + case SortReal: + return withUnit(renderRat(v.Number), variable.Unit, variable.Dimension) + } + return "" +} + +// Written is the value as an expression the notation reads back to it, exact, +// for a witness to fix the variable's feature at. A magnitude in base units no +// unit names has no such spelling. +func (v ModelValue) Written(variable *Var) (string, error) { + if v.Kind == SortReal && variable.Unit == "" && variable.Dimension != "" { + return "", fmt.Errorf("its magnitude is in the base units of %s, which no unit names", variable.Dimension) + } + if text := v.Render(variable); text != "" { + return text, nil + } + return "", fmt.Errorf("a value of no sort") +} + +// Literal is the term denoting a decoded value, which is what denies a model in +// an enumeration. An integer outside int64 has no literal in the term language. +func (v ModelValue) Literal(sort Sort) (*Term, error) { + switch v.Kind { + case SortBool: + return BoolTerm(v.Bool), nil + case SortString: + return StringTerm(v.Text), nil + case SortDatatype: + return ValueTerm(sort, v.Text), nil + case SortInt: + if !v.Number.Num().IsInt64() { + return nil, fmt.Errorf("%s is outside the Integer range", v.Number.Num().String()) + } + return IntTerm(v.Number.Num().Int64()), nil + case SortReal: + return RealTerm(v.Number), nil + } + return nil, fmt.Errorf("a value of no sort") +} + +// Assign is the assignment a solver's model would carry for x holding v: the +// literal as the solver writes it, rendered as the notation writes it. +func (v ModelValue) Assign(x *Var) (Assignment, error) { + if v.Kind != x.Sort.Kind { + return Assignment{}, fmt.Errorf("a value of another sort for %s, whose sort is %s", x.Name, x.Sort.Name) + } + term, err := v.Literal(x.Sort) + if err != nil { + return Assignment{}, err + } + raw, err := readSexpr(bufio.NewReader(strings.NewReader(writeTerm(term)))) + if err != nil { + return Assignment{}, fmt.Errorf("unreadable value %s", writeTerm(term)) + } + return assign(x, raw), nil +} + +// ValueOf reads a value a witness states for v as a protocol spells one: a number, a +// boolean or a text, and the unit of a magnitude, which must be the base unit v's +// magnitudes are expressed in. A value v's sort does not hold is an error saying why. +func (v *Var) ValueOf(given runtime.ToolValue) (ModelValue, error) { + kind := v.Sort.Kind + if given.Value.Kind == semantics.ValInvalid { + switch kind { + case SortString: + return ModelValue{Kind: kind, Text: given.Text}, nil + case SortDatatype: + if !contains(v.Sort.Values, given.Text) { + return ModelValue{}, fmt.Errorf("%q is not a value of %s", given.Text, v.Sort.Name) + } + return ModelValue{Kind: kind, Text: given.Text}, nil + } + return ModelValue{}, fmt.Errorf("%q is a text, and %s", given.Text, msgValuesAre+v.Sort.Name) + } + if given.Unit != "" && (kind != SortReal || v.Dimension == "") { + return ModelValue{}, fmt.Errorf("%s is measured in %s, and %s", semantics.FormatConst(given.Value), given.Unit, msgValuesAre+v.Sort.Name) + } + switch kind { + case SortBool: + if given.Value.Kind == semantics.ValBool { + return ModelValue{Kind: kind, Bool: given.Value.Bool}, nil + } + case SortInt: + if given.Value.Kind == semantics.ValInt { + return ModelValue{Kind: kind, Number: new(big.Rat).SetInt64(given.Value.Int)}, nil + } + case SortReal: + rat, ok := ratOfConst(given.Value) + if !ok { + break + } + switch { + case v.Dimension != "" && given.Unit == "": + return ModelValue{}, fmt.Errorf("%s is a bare number, and %s", semantics.FormatConst(given.Value), msgValuesMeasuredIn+v.Dimension) + case v.Dimension != "" && given.Unit != v.Unit: + return ModelValue{}, fmt.Errorf("%s is measured in %s, and %s", semantics.FormatConst(given.Value), given.Unit, msgValuesMeasuredIn+v.Unit) + } + return ModelValue{Kind: kind, Number: rat}, nil + } + return ModelValue{}, fmt.Errorf("%s is not a value of %s", semantics.FormatConst(given.Value), v.Sort.Name) +} + +// assign renders one variable's solver value in the notation's own terms, keeping +// the solver's S-expression for a value the notation cannot write. +func assign(v *Var, value sexpr) Assignment { + raw := value.String() + decoded, err := decodeSexpr(v, value) + if err != nil { + return Assignment{Var: v, Value: raw, Raw: raw} + } + return Assignment{Var: v, Value: decoded.Render(v), Raw: raw, Rendered: true} +} + +// withUnit writes a magnitude in the base units it is expressed in, as a quantity +// is written; a dimension whose base units are unnamed is named as the dimension +// it is, since the magnitude is not in the unit any literal was written in. +func withUnit(magnitude, unit, dimension string) string { + switch { + case unit != "": + return magnitude + " [" + unit + "]" + case dimension != "": + return magnitude + " (in the base units of " + dimension + ")" + } + return magnitude +} + +// renderRat writes an exact rational as the notation writes a number: a decimal +// when it has a terminating one, else the quotient it is. +func renderRat(r *big.Rat) string { + if r.IsInt() { + return r.Num().String() + ".0" + } + if digits, ok := decimalDigits(r.Denom()); ok { + return r.FloatString(digits) + } + return r.Num().String() + "/" + r.Denom().String() +} + +// ratOfSexpr reads a numeral, a decimal, or the negation, quotient or widening a +// solver writes a numeric model value with. +func ratOfSexpr(value sexpr) (*big.Rat, bool) { + if !value.IsList { + if value.Quoted { + return nil, false + } + rat, ok := new(big.Rat).SetString(value.Atom) + if !ok || strings.ContainsAny(value.Atom, "eE/") { + // SetString accepts exponents and quotients, which SMT-LIB numerals + // and decimals are not. + return nil, false + } + return rat, true + } + switch { + case len(value.List) == 2 && value.List[0].Atom == "-": + inner, ok := ratOfSexpr(value.List[1]) + if !ok { + return nil, false + } + return inner.Neg(inner), true + case len(value.List) == 2 && value.List[0].Atom == "to_real": + return ratOfSexpr(value.List[1]) + case len(value.List) == 3 && value.List[0].Atom == "/": + num, ok := ratOfSexpr(value.List[1]) + if !ok { + return nil, false + } + den, okDen := ratOfSexpr(value.List[2]) + if !okDen || den.Sign() == 0 { + return nil, false + } + return num.Quo(num, den), true + } + return nil, false +} diff --git a/internal/core/solve/model_test.go b/internal/exec/solve/model_test.go similarity index 100% rename from internal/core/solve/model_test.go rename to internal/exec/solve/model_test.go diff --git a/internal/core/solve/objective.go b/internal/exec/solve/objective.go similarity index 98% rename from internal/core/solve/objective.go rename to internal/exec/solve/objective.go index 3f4e4c9eb5..1b2beaa753 100644 --- a/internal/core/solve/objective.go +++ b/internal/exec/solve/objective.go @@ -3,9 +3,9 @@ package solve import ( "fmt" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // Analysis translates an analysis case as an optimization query: what its diff --git a/internal/core/solve/objective_test.go b/internal/exec/solve/objective_test.go similarity index 99% rename from internal/core/solve/objective_test.go rename to internal/exec/solve/objective_test.go index 4fecd792de..c90029268a 100644 --- a/internal/core/solve/objective_test.go +++ b/internal/exec/solve/objective_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // analysisQuery translates one analysis case of the shared objectives fixture. diff --git a/internal/core/solve/optimize.go b/internal/exec/solve/optimize.go similarity index 100% rename from internal/core/solve/optimize.go rename to internal/exec/solve/optimize.go diff --git a/internal/core/solve/optimize_test.go b/internal/exec/solve/optimize_test.go similarity index 100% rename from internal/core/solve/optimize_test.go rename to internal/exec/solve/optimize_test.go diff --git a/internal/core/solve/optimum_test.go b/internal/exec/solve/optimum_test.go similarity index 100% rename from internal/core/solve/optimum_test.go rename to internal/exec/solve/optimum_test.go diff --git a/internal/exec/solve/pin.go b/internal/exec/solve/pin.go new file mode 100644 index 0000000000..e3b13932ba --- /dev/null +++ b/internal/exec/solve/pin.go @@ -0,0 +1,595 @@ +package solve + +import ( + "errors" + "fmt" + "math" + "math/big" + "strconv" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// ErrNotPinnable is returned for a value that cannot be fixed in a query: one +// the term language has no literal for, or one whose type or dimension does not +// match the variable it would fix. A pin is never dropped silently, since a +// query missing one would answer about values the model does not hold. +var ErrNotPinnable = errors.New("value cannot be fixed for solving") + +// PinSource says where a fixed value came from, so a report distinguishes what +// an object holds from what the model declares and from what a caller chose. +type PinSource int + +const ( + // PinHeld is a value an object holds. + PinHeld PinSource = iota + // PinDeclared is a value the model declares for a feature. + PinDeclared + // PinChosen is a value the caller supplied, such as a variant selection. + PinChosen +) + +// String names the source for a report a machine reads. +func (s PinSource) String() string { + switch s { + case PinHeld: + return "held" + case PinDeclared: + return "declared" + default: + return "chosen" + } +} + +// phrase says who fixed the value, as a line about it reads. +func (s PinSource) phrase() string { + switch s { + case PinHeld: + return "held by the object" + case PinDeclared: + return "declared by the model" + default: + return "chosen for this query" + } +} + +// Pin fixes one feature to a value a query asserts, rather than leaving the +// feature free for the solver to choose. +type Pin struct { + // Feature is the feature declaration whose value is fixed. + Feature *symbols.Symbol + + // Name is the feature as the element naming it writes it. + Name string + + // Value is the value it is fixed to, read where the evaluator reads it. + Value runtime.Value + + // Source says where the value came from. + Source PinSource + + // Object is the object holding the value, 0 for a value no object holds. + Object int64 +} + +// PinnedValue is a pin the query asserts: the variable it fixed, the value as +// the notation writes it, and where that assertion sits, so an unsat core naming +// the assertion names the pin. +type PinnedValue struct { + // Var is the variable fixed. + Var *Var + + // Value renders the fixed value as the notation writes it. + Value string + + // Source says where the value came from. + Source PinSource + + // Object is the object holding the value, 0 for a value no object holds. + Object int64 + + // Index is the assertion's position in Query.Assertions. + Index int +} + +// Unread is a fixed value no variable of the query reads. It is reported rather +// than dropped: the query answers about the conditions, which say nothing about +// this feature. +type Unread struct { + // Pin is the value that was fixed. + Pin Pin + + // Reason says why the query does not read it. + Reason string +} + +// Unfixed is a feature whose value could not be read, so the query leaves it +// free. Reading a declared value is an evaluation, and one that fails says +// nothing about the feature. +type Unfixed struct { + // Feature is the feature declaration whose value was not read. + Feature *symbols.Symbol + + // Name is the feature as the element naming it writes it. + Name string + + // Reason says why no value was read. + Reason string +} + +// PinError says which value could not be fixed, why, and where the feature was +// declared. It unwraps to ErrNotPinnable. +type PinError struct { + // Feature names the feature whose value was to be fixed. + Feature string + + // Value renders the value as the notation writes it. + Value string + + // Reason says why it cannot be fixed. + Reason string + + // Source says where the value came from. + Source PinSource + + // File and Span are where the feature was declared. + File string + Span source.Span + + // Location renders File and Span as `file:line:col`, empty when unknown. + Location string +} + +// Error reports the refusal, naming the feature, the value and where it was +// declared. +func (e *PinError) Error() string { + msg := fmt.Sprintf("%s = %s (%s): %s", e.Feature, e.Value, e.Source.phrase(), ErrNotPinnable) + if e.Reason != "" { + msg += ": " + e.Reason + } + if e.Location != "" { + msg += " at " + e.Location + } + return msg +} + +// Unwrap returns ErrNotPinnable, so a caller tests the kind of failure rather +// than its text. +func (e *PinError) Unwrap() error { return ErrNotPinnable } + +// Fixed reads the values the model already fixes for the features of sym: what +// inst holds where an object was created, else what the declaration states. The +// values are read through the evaluator, which is what a verdict about them +// reads, rather than through a second reader of the same declarations. +// +// The second result names the features whose value could not be read, which stay +// free rather than being reported as fixed to something. +func Fixed(ctx *runtime.Context, sym *symbols.Symbol, inst *runtime.Instance) ([]Pin, []Unfixed) { + if ctx == nil || sym == nil { + return nil, nil + } + var pins []Pin + var unfixed []Unfixed + for _, feat := range ctx.FeaturesOf(sym) { + feature := feat + if feature.Name == "" || feature.Symbol == nil { + continue + } + // A feature typed by a definition holds an object, which no variable of a + // query stands for; a variation holds the variant selected for it. + if ctx.CompositeTypeOf(&feature) != nil && !ctx.IsVariationFeature(&feature) { + continue + } + value, source, object, err := fixedValue(ctx, &feature, inst) + switch { + case err != nil: + unfixed = append(unfixed, Unfixed{Feature: feature.Symbol, Name: feature.Name, Reason: err.Error()}) + case value.Kind == runtime.ValInvalid, ctx.HoldsNoValue(value): + // No value is fixed, which is what leaves the feature free. + default: + pins = append(pins, Pin{ + Feature: feature.Symbol, + Name: feature.Name, + Value: value, + Source: source, + Object: object, + }) + } + } + return pins, unfixed +} + +// Fixing is what a query reads fixed values from: the element it is about, the +// element whose declarations its conditions were written among, and the object a +// verdict about it would be about, named by the definition this resolution +// declares — an object carried over a submission is bound to symbols of another +// scope tree, which are not the ones the query is translated from. +type Fixing struct { + Element *symbols.Symbol + Owner *symbols.Symbol + Object *runtime.Instance + ObjectType *symbols.Symbol +} + +// FixedFor gathers the values fixed for a query about the fixing's element: what +// its object holds for the features of its own definition, else what the owner — +// the element the conditions were written in — declares, and then what the +// element's own features declare. A feature fixed twice is fixed by the nearer +// source, which is what an evaluation about it would read. +func FixedFor(ctx *runtime.Context, f Fixing) ([]Pin, []Unfixed) { + var pins []Pin + var unfixed []Unfixed + fixed := make(map[string]bool) + inst := f.Object + for _, from := range fixedFrom(ctx, f) { + found, notRead := Fixed(ctx, from, inst) + for _, p := range found { + if !fixed[p.Name] { + fixed[p.Name] = true + pins = append(pins, p) + } + } + for _, u := range notRead { + if !fixed[u.Name] { + fixed[u.Name] = true + unfixed = append(unfixed, u) + } + } + } + return pins, unfixed +} + +// fixedFrom are the types whose features a query reads values of, nearest first: +// the object's own definition, the element the conditions were written in, then +// the element itself. A type the runtime knows no feature of is left out, so the +// values are read through the declarations this runtime holds. +func fixedFrom(ctx *runtime.Context, f Fixing) []*symbols.Symbol { + var froms []*symbols.Symbol + add := func(candidate *symbols.Symbol) { + if candidate == nil || len(ctx.FeaturesOf(candidate)) == 0 { + return + } + for _, already := range froms { + if already == candidate { + return + } + } + froms = append(froms, candidate) + } + if f.Object != nil { + if f.ObjectType != nil { + add(f.ObjectType) + } + add(f.Object.Type) + } + add(f.Owner) + add(f.Element) + return froms +} + +// fixedValue is the value the model fixes for one feature: the one an object +// holds, else the one its declaration states, evaluated where it was written. +func fixedValue( + ctx *runtime.Context, + feat *runtime.EffectiveFeature, + inst *runtime.Instance, +) (runtime.Value, PinSource, int64, error) { + if inst != nil { + if fv, ok := inst.FeatureValues[feat.Name]; ok && fv != nil { + if fv.Materialized { + return fv.HeldValue(), PinHeld, inst.ID, nil + } + // Reading a value feature is what evaluating it does; a variation is + // read instead from its declaration, since reading it would bind the + // variant and materialize the object of it. + if !ctx.IsVariationFeature(feat) { + read, err := inst.GetFeatureValue(ctx, feat.Name) + if err != nil { + return runtime.Value{}, PinHeld, inst.ID, err + } + return read.HeldValue(), PinHeld, inst.ID, nil + } + } + } + if feat.DefaultValue == nil { + return runtime.Value{}, PinDeclared, 0, nil + } + value, err := ctx.EvalWithScopeOn(feat.DefaultValue, feat.DefaultScope(), inst) + if err != nil { + return runtime.Value{}, PinDeclared, 0, err + } + return value, PinDeclared, 0, nil +} + +// fix asserts the fixed values the query's variables read, and records the ones +// no variable reads. One value that cannot be fixed fails the whole query, as one +// untranslatable condition does. +func (t *translator) fix(pins []Pin) error { + for _, p := range pins { + v := t.pinnedVar(p) + if v == nil { + t.unread = append(t.unread, Unread{ + Pin: p, + Reason: "no condition of " + t.subject.Kind + " " + t.subject.Name + " reads it", + }) + continue + } + term, text, err := t.pinTerm(p, v) + if err != nil { + return err + } + t.pins = append(t.pins, Assertion{ + Term: Binary(OpEq, Bool, VarTerm(v), term), + From: Provenance{ + Kind: "feature", + Element: v.Name, + Condition: v.Name + " == " + text + ", " + p.Source.phrase(), + Role: RolePinned, + Declared: p.Feature, + File: v.File, + Span: v.Span, + Location: v.Location, + }, + }) + t.pinned = append(t.pinned, PinnedValue{Var: v, Value: text, Source: p.Source, Object: p.Object}) + } + return nil +} + +// pinnedVar is the variable a fixed value fixes: the one standing for that very +// feature, read directly rather than through a chain from another object. +func (t *translator) pinnedVar(p Pin) *Var { + if p.Feature == nil { + return nil + } + v, ok := t.vars[t.fqn(p.Feature)] + if !ok || v.Symbol != p.Feature { + return nil + } + return v +} + +// Openings of the refusals that report what a pinned feature holds. +const ( + msgValuesAre = "the feature's values are " + msgValuesMeasuredIn = "the feature's values are measured in " +) + +// pinTerm builds the term a fixed value is asserted equal to, and the value as +// the notation writes it. A value the variable's sort cannot hold refuses. +func (t *translator) pinTerm(p Pin, v *Var) (*Term, string, error) { + text := pinText(t, p.Value) + // A literal of a scalar-valued enumeration pins a datatype variable by its + // identity, and an Integer or Real one by the scalar it equals. + if lit := p.Value.EnumerationLiteral(); lit != nil && v.Sort.Kind == SortDatatype { + term, err := t.pinDatatype(p, v, text, lit) + return term, text, err + } + switch p.Value.Kind { + case runtime.ValConst: + term, err := t.pinConst(p, v, text) + return term, text, err + case runtime.ValString: + if v.Sort.Kind != SortString { + return nil, text, t.pinRefusal(p, v, text, msgValuesAre+v.Sort.Name) + } + return StringTerm(p.Value.Str()), text, nil + case runtime.ValQuantity: + term, err := t.pinQuantity(p, v, text) + return term, text, err + case runtime.ValVariant: + term, err := t.pinDatatype(p, v, text, p.Value.Variant()) + return term, text, err + case runtime.ValEnumLiteral: + term, err := t.pinDatatype(p, v, text, p.Value.Literal()) + return term, text, err + case runtime.ValArray, runtime.ValVector, runtime.ValVectorQuantity, runtime.ValTensorQuantity: + return nil, text, t.pinRefusal(p, v, text, + "the term language has scalar variables only, and "+text+" is not a scalar") + case runtime.ValMeasurementRef: + return nil, text, t.pinRefusal(p, v, text, + scalarTermsOnly(text, "a measurement reference")) + case runtime.ValCoordinateFrame: + return nil, text, t.pinRefusal(p, v, text, + scalarTermsOnly(text, "a coordinate frame")) + case runtime.ValCoordinateTransformation: + return nil, text, t.pinRefusal(p, v, text, + scalarTermsOnly(text, "a coordinate transformation")) + } + return nil, text, t.pinRefusal(p, v, text, "a "+p.Value.Kind.String()+" has no literal in the term language") +} + +// scalarTermsOnly words the refusal of a value the term language has no literal for. +func scalarTermsOnly(text, kind string) string { + return "the term language ranges over numbers, strings and datatype literals, and " + text + " is " + kind +} + +// pinConst fixes a number or a boolean, widening an integer to the real sort the +// feature's values range over. +func (t *translator) pinConst(p Pin, v *Var, text string) (*Term, error) { + c := p.Value.Const + switch { + case c.Kind == semantics.ValBool && v.Sort.Kind == SortBool: + return BoolTerm(c.Bool), nil + case c.Kind == semantics.ValInt && v.Sort.Kind == SortInt: + return IntTerm(c.Int), nil + case c.Kind == semantics.ValInt && v.Sort.Kind == SortReal: + if v.Dimension != "" { + return nil, t.pinRefusal(p, v, text, + msgValuesMeasuredIn+v.Dimension+", and a bare number states no unit") + } + return RealTerm(new(big.Rat).SetInt64(c.Int)), nil + case c.Kind == semantics.ValReal && v.Sort.Kind == SortReal: + if v.Dimension != "" { + return nil, t.pinRefusal(p, v, text, + msgValuesMeasuredIn+v.Dimension+", and a bare number states no unit") + } + rat, ok := ratOfFloat(c.Real) + if !ok { + return nil, t.pinRefusal(p, v, text, "it is not an exact rational") + } + return RealTerm(rat), nil + case c.Kind == semantics.ValInfinity: + return nil, t.pinRefusal(p, v, text, "an infinite magnitude is outside the subset") + } + return nil, t.pinRefusal(p, v, text, msgValuesAre+v.Sort.Name) +} + +// pinQuantity fixes a quantity, scaled to the base units the variable's +// magnitudes are expressed in exactly as the translator scales a written one, so +// a value stated in another unit of the same dimension fixes the same magnitude. +func (t *translator) pinQuantity(p Pin, v *Var, text string) (*Term, error) { + if v.Sort.Kind != SortReal { + return nil, t.pinRefusal(p, v, text, msgValuesAre+v.Sort.Name+" rather than a magnitude") + } + quantity := p.Value.Quantity() + if quantity == nil { + return nil, t.pinRefusal(p, v, text, "it carries no magnitude") + } + unit := quantity.Unit.Term.Normalized() + if err := t.commensurable(p, v, unit, text); err != nil { + return nil, err + } + scale, ok := ratOfScale(unit.Scale) + if !ok { + return nil, t.pinRefusal(p, v, text, "its unit reduces to a scale factor that is not an exact ratio") + } + magnitude, ok := ratOfConst(quantity.Num) + if !ok { + return nil, t.pinRefusal(p, v, text, "its magnitude is not an exact rational") + } + if dim, ok := t.model.DimensionOfUnit(unit); ok { + t.recordBaseUnits(dim, unit) + } + return RealTerm(magnitude.Mul(magnitude, scale)), nil +} + +// commensurable refuses a quantity whose dimension is not the one the feature's +// values are measured in, which no scaling could reconcile. +func (t *translator) commensurable(p Pin, v *Var, unit semantics.UnitTerm, text string) error { + dim, known := t.model.DimensionOfUnit(unit) + if !known { + return t.pinRefusal(p, v, text, "the dimension of its unit is not determined statically") + } + if want, ok := t.model.DimensionOfFeature(v.Symbol); ok { + if !want.Term.Commensurable(dim.Term) { + return t.pinRefusal(p, v, text, + msgValuesMeasuredIn+dimensionUnits(want)+", not in "+dimensionUnits(dim)) + } + return nil + } + if v.Dimension != dimensionUnits(dim) { + return t.pinRefusal(p, v, text, + "the conditions read the feature as "+readAs(v.Dimension)+", not as "+readAs(dimensionUnits(dim))) + } + return nil +} + +// readAs names a dimension as a message about a mismatch reads it. +func readAs(dimension string) string { + if dimension == "" { + return "a plain number" + } + return "a magnitude in " + dimension +} + +// pinDatatype fixes an enumeration literal or a variant, as the value of the +// finite sort the writer declares for it. +func (t *translator) pinDatatype(p Pin, v *Var, text string, value *symbols.Symbol) (*Term, error) { + if value == nil { + return nil, t.pinRefusal(p, v, text, "it names no declaration") + } + if v.Sort.Kind != SortDatatype { + return nil, t.pinRefusal(p, v, text, msgValuesAre+v.Sort.Name) + } + name := t.fqn(value) + for _, candidate := range v.Sort.Values { + if candidate == name { + return ValueTerm(v.Sort, name), nil + } + } + return nil, t.pinRefusal(p, v, text, "it is not one of the values of "+v.Sort.Name) +} + +// pinRefusal builds the typed error a value that cannot be fixed refuses with. +func (t *translator) pinRefusal(p Pin, v *Var, text, reason string) error { + feature := p.Name + if v != nil && v.Name != "" { + feature = v.Name + } + err := &PinError{Feature: feature, Value: text, Reason: reason, Source: p.Source} + if v != nil { + err.File, err.Span, err.Location = v.File, v.Span, v.Location + } + return err +} + +// pinText renders a fixed value as the notation writes it. +func pinText(t *translator, val runtime.Value) string { + if lit := val.EnumerationLiteral(); lit != nil { + return runtime.NewEnumLiteral(lit).LiteralText() + } + switch val.Kind { + case runtime.ValConst: + return constText(val.Const) + case runtime.ValString: + return strconv.Quote(val.Str()) + case runtime.ValQuantity: + if val.Quantity() == nil { + return "" + } + return val.Quantity().String() + case runtime.ValVariant: + if val.Variant() == nil { + return "" + } + return t.fqn(val.Variant()) + case runtime.ValEnumLiteral: + return val.LiteralText() + case runtime.ValArray, runtime.ValVector, runtime.ValVectorQuantity, runtime.ValTensorQuantity, runtime.ValMeasurementRef, + runtime.ValCoordinateFrame, runtime.ValCoordinateTransformation: + return runtime.FormatValue(val) + default: + return "a " + val.Kind.String() + } +} + +// constText renders a numeric constant as the notation writes it, exactly. +func constText(c semantics.Value) string { + switch c.Kind { + case semantics.ValInt: + return strconv.FormatInt(c.Int, 10) + case semantics.ValReal: + return strconv.FormatFloat(c.Real, 'g', -1, 64) + case semantics.ValBool: + return strconv.FormatBool(c.Bool) + case semantics.ValInfinity: + return "*" + default: + return "" + } +} + +// ratOfConst reads a numeric constant as an exact rational. +func ratOfConst(c semantics.Value) (*big.Rat, bool) { + switch c.Kind { + case semantics.ValInt: + return new(big.Rat).SetInt64(c.Int), true + case semantics.ValReal: + return ratOfFloat(c.Real) + } + return nil, false +} + +// ratOfFloat reads a magnitude as the exact rational its shortest decimal form +// denotes, which is the decimal the notation wrote — 5.4 is 27/5, not the binary +// float nearest it, so scaling it by a unit's exact ratio stays exact. +func ratOfFloat(f float64) (*big.Rat, bool) { + if math.IsInf(f, 0) || math.IsNaN(f) { + return nil, false + } + return new(big.Rat).SetString(strconv.FormatFloat(f, 'g', -1, 64)) +} diff --git a/internal/exec/solve/pin_test.go b/internal/exec/solve/pin_test.go new file mode 100644 index 0000000000..c4cf7c12f2 --- /dev/null +++ b/internal/exec/solve/pin_test.go @@ -0,0 +1,394 @@ +package solve + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// pinnedQuery translates one constraint of the panel fixture with the values the +// model declares for the part fixed. +func fixedQuery(t *testing.T, element string) (*runtime.Context, *symbols.Index, *Query) { + t.Helper() + ctx, idx := fixtureFile(t, "panel_pins.sysml") + panel := symbolNamed(t, idx, "test::Panel") + pins, unfixed := Fixed(ctx, panel, nil) + if len(unfixed) != 0 { + t.Fatalf("declared values could not be read: %+v", unfixed) + } + sym := symbolNamed(t, idx, element) + q, err := ConstraintWith(ctx, sym, sym.OwnerScope, pins) + if err != nil { + t.Fatalf("translate %s with fixed values: %v", element, err) + } + return ctx, idx, q +} + +// TestGoldenWithFixedValues locks the script written for a query whose fixed +// values the model declares, translation and writer together. +func TestGoldenWithFixedValues(t *testing.T) { + cases := map[string]string{ + "panel_fits_pinned.smt2": "test::Panel::fits", + "panel_speed_pinned.smt2": "test::Panel::speedIsBounded", + "panel_finish_pinned.smt2": "test::Panel::polishedIsWide", + } + for golden, element := range cases { + t.Run(golden, func(t *testing.T) { + _, _, q := fixedQuery(t, element) + compareGolden(t, golden, Script(q)) + }) + } +} + +// TestFixedReadsTheValuesTheModelDeclares: every attribute stating a value is +// read, and one stating none stays free rather than being fixed to nothing. +func TestFixedReadsTheValuesTheModelDeclares(t *testing.T) { + ctx, idx := fixtureFile(t, "panel_pins.sysml") + pins, unfixed := Fixed(ctx, symbolNamed(t, idx, "test::Panel"), nil) + if len(unfixed) != 0 { + t.Fatalf("declared values could not be read: %+v", unfixed) + } + got := map[string]runtime.ValueKind{} + for _, p := range pins { + got[p.Name] = p.Value.Kind + if p.Source != PinDeclared { + t.Errorf("%s came from %s, want a declared value", p.Name, p.Source) + } + } + want := map[string]runtime.ValueKind{ + "width": runtime.ValConst, + "finish": runtime.ValEnumLiteral, + "maxSpeed": runtime.ValQuantity, + "label": runtime.ValString, + } + for name, kind := range want { + if got[name] != kind { + t.Errorf("%s was read as %v, want %v", name, got[name], kind) + } + } + if _, fixed := got["height"]; fixed { + t.Error("height states no value, so nothing fixes it") + } +} + +// TestNoPinsTranslateAsBefore: a translation given no fixed values writes the +// very script the plain translation writes, which is what keeps every existing +// golden and verdict standing. +func TestNoPinsTranslateAsBefore(t *testing.T) { + ctx, idx := fixtureFile(t, "panel_pins.sysml") + for _, element := range []string{"test::Panel::fits", "test::Panel::speedIsBounded"} { + sym := symbolNamed(t, idx, element) + plain, err := Constraint(ctx, sym, sym.OwnerScope) + if err != nil { + t.Fatalf("translate %s: %v", element, err) + } + with, err := ConstraintWith(ctx, sym, sym.OwnerScope, nil) + if err != nil { + t.Fatalf("translate %s with no fixed values: %v", element, err) + } + if Script(plain) != Script(with) { + t.Errorf("%s wrote a different script with no values fixed:\n%s\n%s", element, Script(plain), Script(with)) + } + if with.Fixes() || len(with.Pinned) != 0 { + t.Errorf("%s fixes %d values with none given", element, len(with.Pinned)) + } + } +} + +// TestPinnedValueIsAssertedAndReported: the value the model declares becomes an +// equality the query asserts, at the position the pin records, and the variable +// it fixes is no longer one the solver is free to choose. +func TestPinnedValueIsAssertedAndReported(t *testing.T) { + _, _, q := fixedQuery(t, "test::Panel::fits") + if len(q.Pinned) != 1 || q.Pinned[0].Var.Name != "test::Panel::width" { + t.Fatalf("fixed %+v, want the declared width alone:\n%s", q.Pinned, Script(q)) + } + pinned := q.Pinned[0] + if pinned.Value != "4" || pinned.Source != PinDeclared { + t.Errorf("width was fixed to %q from %s, want 4 declared", pinned.Value, pinned.Source) + } + if at := q.Assertions[pinned.Index]; at.From.Role != RolePinned { + t.Errorf("assertion %d is %s, want the fixed value's own", pinned.Index, at.From.Role) + } + for _, v := range q.Free() { + if v.Name == "test::Panel::width" { + t.Error("width is fixed, so it is not left for the solver to choose") + } + } + if !strings.Contains(Script(q), "(assert (= |test::Panel::width| 4))") { + t.Errorf("the script does not fix the width:\n%s", Script(q)) + } +} + +// TestPinnedQuantityIsScaledExactly: a value declared in km/h fixes the very +// magnitude the same quantity written in the condition scales to — 5.4 [km/h] is +// exactly 1.5 [m/s], with no rounding of the binary float it was read as. +func TestPinnedQuantityIsScaledExactly(t *testing.T) { + _, _, q := fixedQuery(t, "test::Panel::speedIsBounded") + script := Script(q) + if !strings.Contains(script, "(assert (= |test::Panel::maxSpeed| 1.5))") { + t.Errorf("the fixed speed is not exactly 1.5 in base units:\n%s", script) + } + if len(q.Pinned) != 1 || q.Pinned[0].Value != "5.4 [km/h]" { + t.Errorf("fixed %+v, want the speed as it was declared", q.Pinned) + } +} + +// TestPinnedEnumerationNamesItsConstructor: an enumeration literal fixes the +// value of the datatype sort the writer declares for it. +func TestPinnedEnumerationNamesItsConstructor(t *testing.T) { + _, _, q := fixedQuery(t, "test::Panel::polishedIsWide") + script := Script(q) + if !strings.Contains(script, "(assert (= |test::Panel::finish| |test::Finish::polished|))") { + t.Errorf("the fixed finish is not the datatype's own value:\n%s", script) + } +} + +// TestPinnedStringIsAsserted: a declared string fixes the string the writer +// quotes. +func TestPinnedStringIsAsserted(t *testing.T) { + _, _, q := fixedQuery(t, "test::Panel::labelled") + if !strings.Contains(Script(q), `(assert (= |test::Panel::label| "left"))`) { + t.Errorf("the fixed label is not asserted:\n%s", Script(q)) + } +} + +// TestPinnedValueNoConditionReadsIsReported: a value fixed for a feature no +// condition of the element reads is reported as unread rather than dropped, and +// asserts nothing about conditions that say nothing about it. +func TestPinnedValueNoConditionReadsIsReported(t *testing.T) { + _, _, q := fixedQuery(t, "test::Panel::labelled") + unread := map[string]bool{} + for _, u := range q.Unread { + unread[u.Pin.Name] = true + if u.Reason == "" { + t.Errorf("%s is unread for no stated reason", u.Pin.Name) + } + } + for _, name := range []string{"width", "finish", "maxSpeed"} { + if !unread[name] { + t.Errorf("%s is not read by this constraint, so it should be reported unread: %+v", name, q.Unread) + } + } +} + +// TestFixedValuesAreOrderedByTheirVariable: the fixed values a query reports are +// in the order of the assertions fixing them, so the position each records is the +// assertion an unsat core names. +func TestFixedValuesAreOrderedByTheirVariable(t *testing.T) { + ctx, idx := fixtureFile(t, "panel_pins.sysml") + panel := symbolNamed(t, idx, "test::Panel") + pins, _ := Fixed(ctx, panel, nil) + sym := symbolNamed(t, idx, "test::Panel::polishedIsWide") + q, err := ConstraintWith(ctx, sym, sym.OwnerScope, pins) + if err != nil { + t.Fatalf("translate with fixed values: %v", err) + } + if len(q.Pinned) != 2 { + t.Fatalf("fixed %+v, want the finish and the width", q.Pinned) + } + previous := "" + for _, p := range q.Pinned { + if p.Var.Name < previous { + t.Errorf("fixed values are out of order at %s", p.Var.Name) + } + previous = p.Var.Name + at := q.Assertions[p.Index] + if at.From.Role != RolePinned || at.From.Element != p.Var.Name { + t.Errorf("assertion %d is %s of %s, want the value fixed for %s", + p.Index, at.From.Role, at.From.Element, p.Var.Name) + } + } +} + +// TestPinRefusesAnIncommensurableQuantity: a value measured in another dimension +// fixes nothing, since no scaling reconciles it; the refusal is typed and names +// the feature. +func TestPinRefusesAnIncommensurableQuantity(t *testing.T) { + ctx, idx := fixtureFile(t, "panel_pins.sysml") + length := quantityValue(t, ctx, idx, "test::Panel::clearance") + sym := symbolNamed(t, idx, "test::Panel::speedIsBounded") + speed := symbolNamed(t, idx, "test::Panel::maxSpeed") + _, err := ConstraintWith(ctx, sym, sym.OwnerScope, []Pin{{ + Feature: speed, Name: "maxSpeed", Value: length, Source: PinHeld, Object: 7, + }}) + var refusal *PinError + if !errors.As(err, &refusal) || !errors.Is(err, ErrNotPinnable) { + t.Fatalf("error %v, want a typed refusal to fix the value", err) + } + if !strings.Contains(refusal.Feature, "maxSpeed") || refusal.Reason == "" { + t.Errorf("refusal %+v does not name the feature and why", refusal) + } +} + +// TestPinRefusesAValueWithNoLiteral: a value the term language cannot write is a +// refusal, never a query quietly missing what it was asked to fix. +func TestPinRefusesAValueWithNoLiteral(t *testing.T) { + ctx, idx := fixtureFile(t, "panel_pins.sysml") + sym := symbolNamed(t, idx, "test::Panel::fits") + width := symbolNamed(t, idx, "test::Panel::width") + _, err := ConstraintWith(ctx, sym, sym.OwnerScope, []Pin{{ + Feature: width, Name: "width", Value: runtime.Value{Kind: runtime.ValSequence}, Source: PinChosen, + }}) + if !errors.Is(err, ErrNotPinnable) { + t.Fatalf("error %v, want a refusal to fix a value with no literal", err) + } +} + +// TestPinRefusesATensorQuantity: the term language has scalar variables only, so +// a tensor is refused by name rather than flattened or silently dropped. +func TestPinRefusesATensorQuantity(t *testing.T) { + ctx, idx := fixtureFile(t, "panel_pins.sysml") + metre := quantityValue(t, ctx, idx, "test::Panel::clearance").Quantity().Unit + num := []semantics.Value{{Kind: semantics.ValReal, Real: 1}, {Kind: semantics.ValReal, Real: 0}, {Kind: semantics.ValReal, Real: 0}, {Kind: semantics.ValReal, Real: 1}} + tensor := runtime.NewTensorQuantityValue([]int64{2, 2}, num, []runtime.Unit{metre, metre, metre, metre}) + sym := symbolNamed(t, idx, "test::Panel::speedIsBounded") + speed := symbolNamed(t, idx, "test::Panel::maxSpeed") + _, err := ConstraintWith(ctx, sym, sym.OwnerScope, []Pin{{ + Feature: speed, Name: "maxSpeed", Value: tensor, Source: PinChosen, + }}) + var refusal *PinError + if !errors.As(err, &refusal) || !errors.Is(err, ErrNotPinnable) { + t.Fatalf("error %v, want a typed refusal to fix a tensor", err) + } + if !strings.Contains(refusal.Reason, "is not a scalar") || !strings.Contains(refusal.Value, "Tensor(2, 2)") { + t.Errorf("refusal %+v does not name the tensor and why", refusal) + } +} + +// TestPinRefusesAValueOfTheWrongType: a string does not fix a number, whatever +// the model states. +func TestPinRefusesAValueOfTheWrongType(t *testing.T) { + ctx, idx := fixtureFile(t, "panel_pins.sysml") + sym := symbolNamed(t, idx, "test::Panel::fits") + width := symbolNamed(t, idx, "test::Panel::width") + _, err := ConstraintWith(ctx, sym, sym.OwnerScope, []Pin{{ + Feature: width, Name: "width", Value: runtime.NewStringValue("wide"), Source: PinChosen, + }}) + if !errors.Is(err, ErrNotPinnable) { + t.Fatalf("error %v, want a refusal to fix a number to a string", err) + } +} + +// TestPinnedBareNumberRefusesAMeasuredFeature: a plain number states no unit, so +// it cannot fix a feature whose values are magnitudes of a dimension. +func TestPinnedBareNumberRefusesAMeasuredFeature(t *testing.T) { + ctx, idx := fixtureFile(t, "panel_pins.sysml") + sym := symbolNamed(t, idx, "test::Panel::speedIsBounded") + speed := symbolNamed(t, idx, "test::Panel::maxSpeed") + bare := runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: 1.5}} + _, err := ConstraintWith(ctx, sym, sym.OwnerScope, []Pin{{ + Feature: speed, Name: "maxSpeed", Value: bare, Source: PinChosen, + }}) + if !errors.Is(err, ErrNotPinnable) { + t.Fatalf("error %v, want a refusal to fix a magnitude to a bare number", err) + } +} + +// quantityValue evaluates the declared value of a feature of the fixture, which +// is how a test gets a runtime quantity without building one by hand. +func quantityValue(t *testing.T, ctx *runtime.Context, idx *symbols.Index, fqn string) runtime.Value { + t.Helper() + pins, unfixed := Fixed(ctx, symbolNamed(t, idx, "test::Panel"), nil) + if len(unfixed) != 0 { + t.Fatalf("declared values could not be read: %+v", unfixed) + } + for _, p := range pins { + if p.Feature != nil && strings.HasSuffix(fqn, "::"+p.Name) { + return p.Value + } + } + t.Fatalf("%s declares no value to read", fqn) + return runtime.Value{} +} + +// TestPinRefusesAMeasurementReference: a unit is not a number, so the reference +// its quantity was measured in fixes nothing; the refusal is typed and names it. +func TestPinRefusesAMeasurementReference(t *testing.T) { + ctx, idx := fixtureFile(t, "panel_pins.sysml") + length := quantityValue(t, ctx, idx, "test::Panel::clearance") + sym := symbolNamed(t, idx, "test::Panel::fits") + width := symbolNamed(t, idx, "test::Panel::width") + _, err := ConstraintWith(ctx, sym, sym.OwnerScope, []Pin{{ + Feature: width, Name: "width", Value: runtime.NewMeasurementRefValue(length.Quantity().Unit), Source: PinChosen, + }}) + var refusal *PinError + if !errors.As(err, &refusal) || !errors.Is(err, ErrNotPinnable) { + t.Fatalf("error %v, want a typed refusal to fix a measurement reference", err) + } + if !strings.Contains(refusal.Reason, "is a measurement reference") { + t.Errorf("refusal %+v does not say the value is a measurement reference", refusal) + } +} + +// TestPinRefusesACoordinateFrame: a frame and the transformation placing it are +// not numbers either; each refusal is typed and says what the value is. +func TestPinRefusesACoordinateFrame(t *testing.T) { + ctx, idx := fixtureFile(t, "panel_pins.sysml") + length := quantityValue(t, ctx, idx, "test::Panel::clearance") + sym := symbolNamed(t, idx, "test::Panel::fits") + width := symbolNamed(t, idx, "test::Panel::width") + frame := &runtime.CoordinateFrame{Dimensions: []int64{2}, Axes: []runtime.Unit{length.Quantity().Unit, length.Quantity().Unit}, Text: "panelCF"} + for _, tc := range []struct { + value runtime.Value + what string + }{ + {runtime.NewCoordinateFrameValue(frame), "is a coordinate frame"}, + {runtime.NewCoordinateTransformationValue(&runtime.CoordinateTransformation{Source: frame, Target: frame}), "is a coordinate transformation"}, + } { + _, err := ConstraintWith(ctx, sym, sym.OwnerScope, []Pin{{Feature: width, Name: "width", Value: tc.value, Source: PinChosen}}) + var refusal *PinError + if !errors.As(err, &refusal) || !errors.Is(err, ErrNotPinnable) { + t.Fatalf("error %v, want a typed refusal to fix %s", err, runtime.FormatValue(tc.value)) + } + if !strings.Contains(refusal.Reason, tc.what) { + t.Errorf("refusal %+v does not say the value %s", refusal, tc.what) + } + } +} + +// TestPinnedScalarValuedLiteralIsItsIdentity: a literal of an enumeration that +// specializes Integer fixes a datatype variable by the literal it is, while the +// scalar it equals still fixes an Integer feature holding it. +func TestPinnedScalarValuedLiteralIsItsIdentity(t *testing.T) { + ctx, idx := fixture(t, "levels.sysml", ` +package test { + private import ScalarValues::*; + enum def Level :> Integer { low = 1; high = 3; } + part def Rover { + attribute level : Level = Level::high; + attribute cast : Level[0..1] = 3 as Level; + attribute n : Integer = Level::high; + assert constraint highIsHigh { level == Level::high and cast == Level::high and n >= 3 } + } +}`) + rover := symbolNamed(t, idx, "test::Rover") + pins, unfixed := Fixed(ctx, rover, nil) + if len(unfixed) != 0 { + t.Fatalf("declared values could not be read: %+v", unfixed) + } + sym := symbolNamed(t, idx, "test::Rover::highIsHigh") + q, err := ConstraintWith(ctx, sym, sym.OwnerScope, pins) + if err != nil { + t.Fatalf("translate with fixed values: %v", err) + } + script := Script(q) + for _, want := range []string{ + "(assert (= |test::Rover::level| |test::Level::high|))", + "(assert (= |test::Rover::cast| |test::Level::high|))", + "(assert (= |test::Rover::n| 3))", + } { + if !strings.Contains(script, want) { + t.Errorf("the script lacks %s:\n%s", want, script) + } + } + for _, p := range q.Pinned { + if p.Var.Name != "test::Rover::n" && p.Value != "Level::high" { + t.Errorf("%s was fixed to %q, want the literal Level::high", p.Var.Name, p.Value) + } + } +} diff --git a/internal/core/solve/portability_test.go b/internal/exec/solve/portability_test.go similarity index 99% rename from internal/core/solve/portability_test.go rename to internal/exec/solve/portability_test.go index f561823dfa..2912e7abee 100644 --- a/internal/core/solve/portability_test.go +++ b/internal/exec/solve/portability_test.go @@ -22,7 +22,7 @@ import ( // // Run it against a backend with: // -// OPENSYSML_SMT=cvc5 go test ./internal/core/solve -run TestPortability -v +// OPENSYSML_SMT=cvc5 go test ./internal/exec/solve -run TestPortability -v // outcome is what a portability case reported. type outcome int diff --git a/internal/exec/solve/query.go b/internal/exec/solve/query.go new file mode 100644 index 0000000000..775ca21113 --- /dev/null +++ b/internal/exec/solve/query.go @@ -0,0 +1,272 @@ +package solve + +import ( + "sort" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// Role says why a query asserts a term. +type Role int + +const ( + // RoleRequired is a condition the element requires to hold (`require`, + // `assert`). + RoleRequired Role = iota + // RoleAssumed is a condition the element assumes (`assume`), trusted rather + // than required. + RoleAssumed + // RoleDenied is the assertion a negated element makes: that its required + // conditions do not all hold. + RoleDenied + // RoleDomain is a bound a declaration puts on a variable's values rather + // than a condition the model wrote, such as a Natural being non-negative. + RoleDomain + // RoleDefined is a side condition a condition needs for the solver to mean by + // it what the evaluator means, such as a divisor being non-zero. + RoleDefined + // RolePinned is a value the model already fixes — held by an object, declared + // by the model, or chosen by the caller — asserted so the solver synthesises + // only what is still free. + RolePinned + // RoleExcluded is an assignment already reported, denied so enumerating asks + // for a different one. + RoleExcluded + // RoleTransition is one step of a behavior's execution as the interpreter + // takes it, asserted by a model-checking query over a run. + RoleTransition +) + +var roleNames = map[Role]string{ + RoleRequired: "required condition", + RoleAssumed: "assumed condition", + RoleDenied: "denied conditions", + RoleDomain: "declared domain", + RoleDefined: "well-definedness", + RolePinned: "fixed value", + RoleExcluded: "excluded assignment", + RoleTransition: "transition", +} + +// String names the role as an assertion's comment reads it. +func (r Role) String() string { + if name, ok := roleNames[r]; ok { + return name + } + return "condition" +} + +// Var is one variable a query declares: the value a feature may take. It is +// unconstrained except by its sort and the domain assertions the query makes +// about it — values a model declares are not asserted here. +type Var struct { + // Name is the variable's name, the qualified name of the feature it stands + // for, with a feature chain's further steps appended with '.'. + Name string + + // Sort is the sort of its values. + Sort Sort + + // Symbol is the feature declaration it stands for. + Symbol *symbols.Symbol + + // Dimension is the quantity dimension its magnitude is expressed in, over + // base units, empty for a value that has none. + Dimension string + + // Unit names the base units its magnitude is expressed in ("g", "m·s^-1"), + // empty when no quantity of that dimension was written to name them. + Unit string + + // File and Span are where the feature was declared. + File string + Span source.Span + + // Location renders File and Span as `file:line:col`. + Location string +} + +// Provenance records what an assertion came from, so a later step can map an +// answer about the script back to what the user wrote. +type Provenance struct { + // Kind is the kind of element the assertion came from: "constraint", + // "requirement", "satisfaction", or "declaration" for a domain assertion. + Kind string + + // Element names that element as a verdict about it would. + Element string + + // Condition is the condition as written, negation and grouping included. + Condition string + + // Role says why the query asserts the term. + Role Role + + // Declared is the element that declared the condition, which is the + // supertype it was inherited from for an inherited one. + Declared *symbols.Symbol + + // File and Span are where the condition was written. + File string + Span source.Span + + // Location renders File and Span as `file:line:col`. + Location string +} + +// Direction is the way an objective's value is to be improved. +type Direction int + +const ( + // Minimize asks for the least value the conditions permit. + Minimize Direction = iota + // Maximize asks for the greatest value the conditions permit. + Maximize +) + +// String names the direction as the emitted objective form reads. +func (d Direction) String() string { + if d == Maximize { + return "maximize" + } + return "minimize" +} + +// Objective is one objective a query optimizes: which way its value is to be +// improved, the term stating that value, and where the objective was written. +type Objective struct { + // Direction is the way its value is to be improved. + Direction Direction + + // Term is the translated objective expression, of an arithmetic sort. + Term *Term + + // Name is the objective's name as the model wrote it, empty for an anonymous + // one. + Name string + + // Symbol is the objective usage it came from. + Symbol *symbols.Symbol + + // Expression is the objective expression as written. + Expression string + + // Dimension is the quantity dimension its value is expressed in over base + // units, empty for a value that has none. + Dimension string + + // Unit names the base units its magnitude is expressed in ("g", "m·s^-1"), + // empty when the value is no quantity or no quantity named them. + Unit string + + // File and Span are where the objective was written. + File string + Span source.Span + + // Location renders File and Span as `file:line:col`. + Location string +} + +// Assertion is one term the query asserts, with where it came from. +type Assertion struct { + // Term is the boolean term asserted. + Term *Term + + // From records the condition the term encodes. + From Provenance +} + +// Query is a translated element: the variables its conditions read, the finite +// sorts they range over, and the terms it asserts. It is what a solver-facing +// step consumes, and what the SMT-LIB2 writer writes. +type Query struct { + // Kind is the kind of element translated: "constraint", "requirement" or + // "satisfaction". + Kind string + + // Element names the translated element as a verdict about it would. + Element string + + // Negated is set for an element asserting that its conditions do not all + // hold (`assert not …`), which the query asserts as one denial. + Negated bool + + // Sorts are the datatype sorts the query declares, ordered by name. + Sorts []Sort + + // Vars are the variables the query declares, ordered by name. + Vars []*Var + + // Assertions are the terms asserted: declared domains first, then the + // well-definedness side conditions, then the conditions in the order the + // evaluator checks them. + Assertions []Assertion + + // Nonlinear is set when a product or a quotient of two non-literal terms was + // asserted, which is what a nonlinear logic is set for. + Nonlinear bool + + // IntegerDivision is set when integer division or remainder was encoded, + // which needs `div` and `mod` from the Ints theory of a backend. + IntegerDivision bool + + // Pinned are the values the query fixes rather than leaves free, each naming + // the assertion that fixes it; nil for a query that fixes none. + Pinned []PinnedValue + + // Unread are the values that were to be fixed but that no variable of the + // query reads, reported rather than dropped. + Unread []Unread + + // Objectives are the objectives to optimize, in the order the analysis case + // declares them, which is the order they are optimized in; nil for a query + // that only asks about satisfiability. + Objectives []Objective +} + +// Optimizes reports whether the query asks for an optimum rather than only for +// satisfiability. +func (q *Query) Optimizes() bool { return len(q.Objectives) > 0 } + +// Fixes reports whether the query fixes any value, which is what makes an unsat +// verdict about it a verdict about those values too. +func (q *Query) Fixes() bool { return len(q.Pinned) > 0 } + +// Free are the variables the query leaves for the solver to choose, in the order +// they are declared. +func (q *Query) Free() []*Var { + fixed := make(map[*Var]bool, len(q.Pinned)) + for _, p := range q.Pinned { + fixed[p.Var] = true + } + out := make([]*Var, 0, len(q.Vars)) + for _, v := range q.Vars { + if !fixed[v] { + out = append(out, v) + } + } + return out +} + +// sortVars orders variables by name, which is what makes a script deterministic. +func sortVars(vars []*Var) { + sort.Slice(vars, func(i, j int) bool { return vars[i].Name < vars[j].Name }) +} + +// sortSorts orders datatype sorts by name. +func sortSorts(sorts []Sort) { + sort.Slice(sorts, func(i, j int) bool { return sorts[i].Name < sorts[j].Name }) +} + +// sortAssertions orders assertions by what they came from, which is what makes +// declared domains, collected as variables appear, deterministic. +func sortAssertions(asserts []Assertion) { + sort.SliceStable(asserts, func(i, j int) bool { + a, b := asserts[i].From, asserts[j].From + if a.Element != b.Element { + return a.Element < b.Element + } + return a.Condition < b.Condition + }) +} diff --git a/internal/exec/solve/reference.go b/internal/exec/solve/reference.go new file mode 100644 index 0000000000..d19fbe7488 --- /dev/null +++ b/internal/exec/solve/reference.go @@ -0,0 +1,368 @@ +package solve + +import ( + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// reference translates a name or a feature chain: an enumeration literal or a +// variant becomes a value of its finite sort, and a scalar-valued feature becomes +// a variable standing for the values it may take. +func (t *translator) reference(node ast.Node, scope *symbols.Scope) (*Term, error) { + segments, ok := pathSegments(node) + if !ok || len(segments) == 0 { + return nil, t.refuse(node, "reference", "it names nothing this translation can ground") + } + chain, err := t.resolvePath(node, scope, segments) + if err != nil { + return nil, err + } + target := chain[len(chain)-1] + if sort, value, ok := t.valueOf(target); ok { + return ValueTerm(sort, value), nil + } + return t.variableOf(node, scope, chain, segments) +} + +// pathSegments flattens a name or a feature chain into the names it steps +// through, reporting false for anything else. +func pathSegments(node ast.Node) ([]string, bool) { + switch n := node.(type) { + case *ast.FeatureReference: + return nameSegments(n.Name) + case *ast.QualifiedName: + return nameSegments(n) + case *ast.FeatureChainExpr: + base, ok := pathSegments(n.Operand) + if !ok { + return nil, false + } + rest, ok := nameSegments(n.Member) + if !ok { + return nil, false + } + return append(base, rest...), true + } + return nil, false +} + +// nameSegments returns the names a qualified name steps through. +func nameSegments(qn *ast.QualifiedName) ([]string, bool) { + if qn == nil || len(qn.Parts) == 0 { + return nil, false + } + out := make([]string, 0, len(qn.Parts)) + for _, part := range qn.Parts { + out = append(out, part.Text) + } + return out, true +} + +// msgReferencePrefix names the reference a refusal is about. +const msgReferencePrefix = "reference `" + +// resolvePath resolves the names a reference steps through, the same way the +// evaluator resolves them: the first as an effective feature of the element the +// conditions are stated by, else in scope, each further one as a member or a +// variant of what the previous named. +func (t *translator) resolvePath(node ast.Node, scope *symbols.Scope, segments []string) ([]*symbols.Symbol, error) { + if scope == nil { + return nil, t.refuse(node, msgReferencePrefix+joinPath(segments)+"`", "it is written in no scope") + } + resolver := t.ctx.Resolver() + var chain []*symbols.Symbol + // A member the objective being translated owns or inherits shadows the case's + // and is read through it, so two objectives' `best` are two values. + sym, ok := t.objectiveLocal(scope, segments[0]) + if !ok { + if sym, ok = t.model.LookupMember(t.within, segments[0]); ok { + chain = append(chain, t.within) + } else if sym, ok = t.features[segments[0]]; !ok { + sym, ok = resolver.LookupName(scope, segments[0]) + } + } + if !ok { + // A qualified name may name a package member or a library element. + if whole, found := resolver.ResolveQualified(scope, ast.QualifiedNameOf(segments...)); found { + return []*symbols.Symbol{whole}, nil + } + return nil, t.refuse(node, msgReferencePrefix+joinPath(segments)+"`", "it resolves to nothing") + } + chain = append(chain, sym) + for _, segment := range segments[1:] { + next, ok := t.model.LookupMember(sym, segment) + if !ok { + next, ok = t.model.VariantOf(sym, segment) + } + if !ok { + return nil, t.refuse(node, msgReferencePrefix+joinPath(segments)+"`", + "`"+segment+"` names no member of `"+sym.Name+"`") + } + sym = next + chain = append(chain, sym) + } + return chain, nil +} + +// objectiveLocal resolves name to a declaration nested inside the objective being +// translated, or one of its types, that is not a member of the objective itself: +// a condition's own parameter or attribute, which the objective's members do not shadow. +func (t *translator) objectiveLocal(scope *symbols.Scope, name string) (*symbols.Symbol, bool) { + if t.within == nil { + return nil, false + } + sym, ok := t.ctx.Resolver().LookupName(scope, name) + if !ok { + return nil, false + } + if member, own := t.model.LookupMember(t.within, name); own && member == sym { + return nil, false + } + for s := sym.OwnerScope; s != nil; s = s.Parent() { + owner := s.Owner() + if owner == t.within || (owner != nil && t.model.Conforms(t.within, owner)) { + return sym, true + } + } + return nil, false +} + +// joinPath renders a reference as it was written. +func joinPath(segments []string) string { + out := "" + for i, segment := range segments { + if i > 0 { + out += "." + } + out += segment + } + return out +} + +// valueOf returns the finite sort and the value a reference to an enumeration +// literal or a variant stands for. An enumerated value is a variant of its +// enumeration, whose sort is the enumeration's rather than a variation point's. +func (t *translator) valueOf(sym *symbols.Symbol) (Sort, string, bool) { + if enum := semantics.EnumerationOwning(sym); enum != nil { + return t.datatype(enum, t.model.LiteralsOf(enum), false), t.fqn(sym), true + } + if variation := t.model.VariationPointOwning(sym); variation != nil { + return t.datatype(variation, t.model.VariantsOf(variation), true), t.fqn(sym), true + } + return Sort{}, "", false +} + +// datatype declares, once per definition, the finite sort its values range over; +// variation marks the sort of a variation point rather than of an enumeration. +func (t *translator) datatype(origin *symbols.Symbol, values []*symbols.Symbol, variation bool) Sort { + name := t.fqn(origin) + if sort, ok := t.sorts[name]; ok { + return sort + } + sort := Sort{ + Kind: SortDatatype, + Name: name, + Origin: t.fqn(origin), + Variation: variation, + } + for _, value := range values { + sort.Values = append(sort.Values, t.fqn(value)) + } + t.sorts[name] = sort + return sort +} + +// variationDeclaring is the variation point whose variants these are: a usage +// redefining a variation selects among the variants the redefined one declares, +// so both read the same finite sort a variant's name stands for. +func (t *translator) variationDeclaring(sym *symbols.Symbol, variants []*symbols.Symbol) *symbols.Symbol { + for _, variant := range variants { + if owner := t.model.VariationPointOwning(variant); owner != nil { + return owner + } + } + return sym +} + +// variableOf returns the variable a reference to a feature reads, declaring it +// the first time the feature is read. A feature whose type determines no scalar +// sort, or that holds more than one value, refuses. +func (t *translator) variableOf( + node ast.Node, + scope *symbols.Scope, + chain []*symbols.Symbol, + segments []string, +) (*Term, error) { + target := chain[len(chain)-1] + written := "feature `" + joinPath(segments) + "`" + if !featureDecl(target) { + return nil, t.refuse(node, written, "it names a definition rather than a value") + } + if target.Kind == symbols.SymbolCalcUsage { + return nil, t.refuse(node, written, "a calc's result is computed rather than encoded") + } + // Every step a chain reads through must hold one value, since a variable + // stands for one: `a.pressure` reads a pressure per `a` when `a` holds many. + for _, step := range chain { + if !featureDecl(step) { + continue + } + if !singleValued(t.model.EffectiveMultiplicityOf(step)) { + return nil, t.refuse(node, written, "it may hold more than one value") + } + } + sort, dimension, err := t.sortOf(node, scope, target, written) + if err != nil { + return nil, err + } + return VarTerm(t.declare(variableName(t, chain), sort, target, dimension)), nil +} + +// singleValued reports whether a multiplicity admits exactly one value, which is +// what a variable stands for. +func singleValued(r semantics.Range) bool { + if !r.Upper.Known || r.Upper.Infinite { + return false + } + return r.Upper.Value <= 1 +} + +// sortOf decides the sort a feature's values range over, from the type facts the +// semantic model holds rather than from any value written for it. +func (t *translator) sortOf( + node ast.Node, + scope *symbols.Scope, + sym *symbols.Symbol, + written string, +) (Sort, string, error) { + if t.model.IsVariationFeature(sym) { + variants := t.model.VariantsOf(sym) + if len(variants) == 0 { + return Sort{}, "", t.refuse(node, written, "the variation point offers no variant") + } + return t.datatype(t.variationDeclaring(sym, variants), variants, true), "", nil + } + if enum := t.enumerationTypeOf(sym); enum != nil { + literals := t.model.LiteralsOf(enum) + if len(literals) == 0 { + return Sort{}, "", t.refuse(node, written, "its enumeration declares no literal") + } + return t.datatype(enum, literals, false), "", nil + } + switch t.model.PrimTypeOf(sym) { + case semantics.PrimBoolean: + return Bool, "", nil + case semantics.PrimString: + return String, "", nil + case semantics.PrimNatural, semantics.PrimInteger: + return Int, "", nil + case semantics.PrimRational, semantics.PrimReal, semantics.PrimNumber: + return Real, t.dimensionText(scope, node), nil + case semantics.PrimComplex: + return Sort{}, "", t.refuse(node, written, "a complex number has no sort here") + } + if dim, ok := t.model.DimensionOfExpr(scope, node); ok { + return Real, dimensionUnits(dim), nil + } + return Sort{}, "", t.refuse(node, written, "its type determines no scalar sort") +} + +// enumerationTypeOf returns the enumeration definition typing sym, or nil. +func (t *translator) enumerationTypeOf(sym *symbols.Symbol) *symbols.Symbol { + for _, super := range t.model.AllSupertypes(sym) { + if super.Kind == symbols.SymbolEnumerationDef { + return super + } + } + return nil +} + +// dimensionText names the dimension a reference's magnitudes are expressed in, +// empty for a plain number. +func (t *translator) dimensionText(scope *symbols.Scope, node ast.Node) string { + dim, ok := t.model.DimensionOfExpr(scope, node) + if !ok { + return "" + } + return dimensionUnits(dim) +} + +// dimensionUnits renders a dimension's base units, empty for a dimensionless one. +func dimensionUnits(dim semantics.Dimension) string { + if dim.Term.Dimensionless() { + return "" + } + return dim.String() +} + +// declare returns the variable of the name given, declaring it and any bound its +// declaration puts on its values the first time it is read. +func (t *translator) declare(name string, sort Sort, sym *symbols.Symbol, dimension string) *Var { + if v, ok := t.vars[name]; ok { + return v + } + v := &Var{Name: name, Sort: sort, Symbol: sym, Dimension: dimension} + if sym != nil { + v.File = sym.DocName + v.Span = sym.DeclSpan + v.Location = t.ctx.SourceLocation(v.File, v.Span) + } + t.vars[name] = v + if t.model.PrimTypeOf(sym) == semantics.PrimNatural { + t.domains = append(t.domains, Assertion{ + Term: Binary(OpGe, Bool, VarTerm(v), IntTerm(0)), + From: Provenance{ + Kind: "declaration", + Element: name, + Condition: "a Natural is not negative", + Role: RoleDomain, + Declared: sym, + File: v.File, + Span: v.Span, + Location: v.Location, + }, + }) + } + return v +} + +// variableName names the variable a reference reads: the feature's own qualified +// name, extended by the features a chain steps through, since two objects hold +// two values. +func variableName(t *translator, chain []*symbols.Symbol) string { + first := len(chain) - 1 + for i, sym := range chain { + if featureDecl(sym) { + first = i + break + } + } + name := t.fqn(chain[first]) + for _, sym := range chain[first+1:] { + name += "." + sym.Name + } + return name +} + +// featureDecl reports whether sym declares a feature holding a value: a usage, or +// the subject a requirement declares. A definition or a package holds none. +func featureDecl(sym *symbols.Symbol) bool { + switch sym.Decl.(type) { + case *ast.Usage, *ast.SubjectMember: + return true + } + return false +} + +// fqn names a symbol as its qualified name, falling back to its own name for one +// no scope chain qualifies. +func (t *translator) fqn(sym *symbols.Symbol) string { + if sym == nil { + return "" + } + if name := symbols.FQNOf(sym); name != "" { + return name + } + return sym.Name +} diff --git a/internal/exec/solve/replay.go b/internal/exec/solve/replay.go new file mode 100644 index 0000000000..935eaf9489 --- /dev/null +++ b/internal/exec/solve/replay.go @@ -0,0 +1,554 @@ +package solve + +import ( + "fmt" + "math" + "math/big" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +// Rounded reports whether the query asserts or optimizes a value the evaluator +// computes in float64 — a real operation, a widened integer, or a real literal +// with no exact float64 — whose rounding the exact-real encoding does not model. +func (q *Query) Rounded() bool { + for _, a := range q.Assertions { + if roundedTerm(a.Term) { + return true + } + } + for _, o := range q.Objectives { + if roundedTerm(o.Term) { + return true + } + } + return false +} + +// Rounded reports whether the conflict rests on a condition the evaluator +// computes in float64; a conflict with no members to inspect counts. +func (c *Core) Rounded() bool { + if c == nil || len(c.Members) == 0 { + return true + } + for _, m := range c.Members { + if roundedTerm(m.Term) { + return true + } + } + return false +} + +// roundedTerm reports whether the term computes a value float64 may round: real +// arithmetic, a widened integer (rounded beyond 2^53), or a real literal whose +// decimal has no exact float64. +func roundedTerm(t *Term) bool { + switch t.Op { + case OpReal: + _, exact := t.Real.Float64() + if !exact { + return true + } + case OpAdd, OpSub, OpMul, OpDiv, OpNeg: + if t.Sort.Kind == SortReal { + return true + } + case OpToReal: + return true + } + for _, arg := range t.Args { + if roundedTerm(arg) { + return true + } + } + return false +} + +// replayValue is one value a replayed term yields, in the evaluator's own +// representations: int64, float64, bool, or a string naming a literal or a +// datatype value. +type replayValue struct { + kind SortKind + b bool + i int64 + f float64 + s string +} + +// replayWitness re-runs the query's assertions through the evaluator's own +// arithmetic on the witness, reporting ok=false with why when the evaluator +// does not confirm what the solver answered. +func replayWitness(q *Query, model []Assignment) (bool, string) { + values := make(map[string]ModelValue, len(model)) + for _, a := range model { + value, err := DecodeValue(a) + if err != nil { + return false, fmt.Sprintf("the evaluator cannot hold the value the solver chose for %s: %v", a.Var.Name, err) + } + values[a.Var.Name] = value + } + return q.Confirm(values) +} + +// Confirm re-runs the query's assertions through the evaluator's own arithmetic on +// values of its variables by name, as a solver's model is confirmed before `sat` is +// claimed: ok=false with why when a variable has no value, a value the evaluator +// cannot hold, or an assertion that does not evaluate to true. +func (q *Query) Confirm(values map[string]ModelValue) (bool, string) { + env := make(map[string]replayValue, len(values)) + for _, v := range q.Vars { + value, ok := values[v.Name] + if !ok { + return false, fmt.Sprintf("the witness gives %s no value", v.Name) + } + if value.Kind != v.Sort.Kind { + return false, fmt.Sprintf("the witness gives %s no value of sort %s", v.Name, v.Sort.Name) + } + if value.Kind == SortDatatype && !contains(v.Sort.Values, value.Text) { + return false, fmt.Sprintf("the witness gives %s %s, not a value of %s", v.Name, value.Text, v.Sort.Name) + } + val, err := evaluatorValue(value) + if err != nil { + return false, fmt.Sprintf("the evaluator cannot hold the value the solver chose for %s: %v", v.Name, err) + } + env[v.Name] = val + } + for _, assertion := range q.Assertions { + val, err := replayTerm(assertion.Term, env) + if err != nil { + return false, fmt.Sprintf("the evaluator does not decide the witness: %v (%s `%s`)", + err, assertion.From.Role, assertion.From.Condition) + } + if val.kind != SortBool { + return false, fmt.Sprintf("replaying the %s `%s` yielded no boolean", + assertion.From.Role, assertion.From.Condition) + } + if !val.b { + return false, fmt.Sprintf("the evaluator's floating-point arithmetic rejects the witness at the %s `%s`", + assertion.From.Role, assertion.From.Condition) + } + } + return true, "" +} + +// evaluatorValue holds one model value in the evaluator's representation, +// reporting a value the evaluator cannot hold — an Integer outside int64, a +// Real with no finite float64 — as an error. +func evaluatorValue(value ModelValue) (replayValue, error) { + switch value.Kind { + case SortBool: + return replayValue{kind: SortBool, b: value.Bool}, nil + case SortString, SortDatatype: + return replayValue{kind: value.Kind, s: value.Text}, nil + case SortInt: + if !value.Number.Num().IsInt64() { + return replayValue{}, fmt.Errorf("%s is outside the Integer range", value.Number.Num().String()) + } + return replayValue{kind: SortInt, i: value.Number.Num().Int64()}, nil + case SortReal: + // The evaluator holds the nearest float64, which is where a witness + // only the exact encoding can hold is caught by the replay. + f, _ := value.Number.Float64() + if math.IsInf(f, 0) || math.IsNaN(f) { + return replayValue{}, fmt.Errorf("%s is outside the Real range", value.Number.FloatString(6)) + } + return replayValue{kind: SortReal, f: f}, nil + } + return replayValue{}, fmt.Errorf("a value of no sort") +} + +// replayTerm evaluates a term as the runtime evaluator computes it: int64 and +// float64 arithmetic, integers widened to float64 where a real meets them. An +// evaluation the evaluator would report — a zero divisor, an overflow — is an +// error, never a verdict. +func replayTerm(t *Term, env map[string]replayValue) (replayValue, error) { + switch t.Op { + case OpBool: + return replayValue{kind: SortBool, b: t.Bool}, nil + case OpInt: + return replayValue{kind: SortInt, i: t.Int}, nil + case OpReal: + // The evaluator parses a real literal to the nearest float64, which + // Float64 on the exact rational also is. + f, _ := t.Real.Float64() + return replayValue{kind: SortReal, f: f}, nil + case OpString: + return replayValue{kind: SortString, s: t.Str}, nil + case OpValue: + return replayValue{kind: SortDatatype, s: t.Str}, nil + case OpVar: + val, ok := env[t.Var.Name] + if !ok { + return replayValue{}, fmt.Errorf("no value for %s", t.Var.Name) + } + return val, nil + case OpNot: + val, err := replayBool(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + return replayValue{kind: SortBool, b: !val}, nil + case OpAnd, OpOr: + return replayJunction(t, env) + case OpXor, OpImplies: + left, err := replayBool(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + if t.Op == OpImplies && !left { + return replayValue{kind: SortBool, b: true}, nil + } + right, err := replayBool(t.Args[1], env) + if err != nil { + return replayValue{}, err + } + if t.Op == OpXor { + return replayValue{kind: SortBool, b: left != right}, nil + } + return replayValue{kind: SortBool, b: right}, nil + case OpEq, OpNe: + return replayEquality(t, env) + case OpLt, OpLe, OpGt, OpGe: + return replayComparison(t, env) + case OpAdd, OpSub, OpMul, OpDiv: + return replayArithmetic(t, env) + case OpIntDiv: + return replayIntDiv(t, env) + case OpNeg: + val, err := replayTerm(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + if val.kind == SortInt { + if val.i == math.MinInt64 { + return replayValue{}, fmt.Errorf("-%d exceeds the Integer range", val.i) + } + return replayValue{kind: SortInt, i: -val.i}, nil + } + if val.kind == SortReal { + return replayValue{kind: SortReal, f: -val.f}, nil + } + case OpIte: + cond, err := replayBool(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + if cond { + return replayTerm(t.Args[1], env) + } + return replayTerm(t.Args[2], env) + case OpToReal: + val, err := replayTerm(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + if val.kind != SortInt { + return replayValue{}, fmt.Errorf("widening a non-integer") + } + // float64(int64) is the widening the evaluator applies where a real + // meets an integer, which rounds beyond 2^53. + return replayValue{kind: SortReal, f: float64(val.i)}, nil + case OpInt64: + exact, err := exactInt(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + return replayValue{kind: SortBool, b: exact.IsInt64()}, nil + } + return replayValue{}, fmt.Errorf("the replay does not define this term") +} + +// exactInt computes an integer term without bounds, deciding the range an +// OpInt64 asks about where the evaluator's own int64 arithmetic would overflow. +func exactInt(t *Term, env map[string]replayValue) (*big.Int, error) { + switch t.Op { + case OpAdd, OpSub, OpMul, OpIntDiv: + left, err := exactInt(t.Args[0], env) + if err != nil { + return nil, err + } + right, err := exactInt(t.Args[1], env) + if err != nil { + return nil, err + } + switch t.Op { + case OpAdd: + return new(big.Int).Add(left, right), nil + case OpSub: + return new(big.Int).Sub(left, right), nil + case OpMul: + return new(big.Int).Mul(left, right), nil + } + if right.Sign() == 0 { + return nil, fmt.Errorf("division by zero") + } + return new(big.Int).Div(left, right), nil + case OpNeg: + arg, err := exactInt(t.Args[0], env) + if err != nil { + return nil, err + } + return new(big.Int).Neg(arg), nil + case OpIte: + cond, err := replayBool(t.Args[0], env) + if err != nil { + return nil, err + } + if cond { + return exactInt(t.Args[1], env) + } + return exactInt(t.Args[2], env) + } + val, err := replayTerm(t, env) + if err != nil { + return nil, err + } + if val.kind != SortInt { + return nil, fmt.Errorf("an integer operand yielded none") + } + return big.NewInt(val.i), nil +} + +// replayBool evaluates a term that must yield a boolean. +func replayBool(t *Term, env map[string]replayValue) (bool, error) { + val, err := replayTerm(t, env) + if err != nil { + return false, err + } + if val.kind != SortBool { + return false, fmt.Errorf("a boolean operand yielded none") + } + return val.b, nil +} + +// replayJunction evaluates a conjunction or disjunction as the evaluator does, +// deciding on an earlier operand where it can so a guarded operand it rules out +// is never evaluated. +func replayJunction(t *Term, env map[string]replayValue) (replayValue, error) { + deciding := t.Op == OpOr + for _, arg := range t.Args { + val, err := replayBool(arg, env) + if err != nil { + return replayValue{}, err + } + if val == deciding { + return replayValue{kind: SortBool, b: deciding}, nil + } + } + return replayValue{kind: SortBool, b: !deciding}, nil +} + +// replayEquality compares as the evaluator compares: numbers through float64, +// which is what `valueEqual` delegates to, and everything else by identity. +func replayEquality(t *Term, env map[string]replayValue) (replayValue, error) { + left, err := replayTerm(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + right, err := replayTerm(t.Args[1], env) + if err != nil { + return replayValue{}, err + } + var eq bool + switch { + case left.numeric() && right.numeric(): + eq = left.asReal() == right.asReal() + case left.kind == SortBool && right.kind == SortBool: + eq = left.b == right.b + case left.kind == right.kind: + eq = left.s == right.s + default: + return replayValue{}, fmt.Errorf("comparing values of different kinds") + } + if t.Op == OpNe { + eq = !eq + } + return replayValue{kind: SortBool, b: eq}, nil +} + +// replayComparison orders as the evaluator orders: two integers exactly, a +// mixed pair through float64. +func replayComparison(t *Term, env map[string]replayValue) (replayValue, error) { + left, err := replayTerm(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + right, err := replayTerm(t.Args[1], env) + if err != nil { + return replayValue{}, err + } + if left.kind == SortString && right.kind == SortString { + var res bool + switch t.Op { + case OpLt: + res = left.s < right.s + case OpLe: + res = left.s <= right.s + case OpGt: + res = left.s > right.s + case OpGe: + res = left.s >= right.s + } + return replayValue{kind: SortBool, b: res}, nil + } + if !left.numeric() || !right.numeric() { + return replayValue{}, fmt.Errorf("ordering non-numbers") + } + var res bool + if left.kind == SortInt && right.kind == SortInt { + switch t.Op { + case OpLt: + res = left.i < right.i + case OpLe: + res = left.i <= right.i + case OpGt: + res = left.i > right.i + case OpGe: + res = left.i >= right.i + } + return replayValue{kind: SortBool, b: res}, nil + } + lf, rf := left.asReal(), right.asReal() + switch t.Op { + case OpLt: + res = lf < rf + case OpLe: + res = lf <= rf + case OpGt: + res = lf > rf + case OpGe: + res = lf >= rf + } + return replayValue{kind: SortBool, b: res}, nil +} + +// replayArithmetic computes as the evaluator computes: int64 with a reported +// overflow, an integer quotient as the exact ratio rounded once, and float64 +// everywhere a real takes part, with a non-finite result reported. +func replayArithmetic(t *Term, env map[string]replayValue) (replayValue, error) { + // A whole-number quotient is the exact ratio rounded once to float64, not + // float64 division of widened operands. + if t.Op == OpDiv && t.IntRatio { + return replayRatio(t, env) + } + left, err := replayTerm(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + right, err := replayTerm(t.Args[1], env) + if err != nil { + return replayValue{}, err + } + if !left.numeric() || !right.numeric() { + return replayValue{}, fmt.Errorf("arithmetic on non-numbers") + } + astOp := map[Op]ast.OperatorKind{OpAdd: ast.OpAdd, OpSub: ast.OpSub, OpMul: ast.OpMul, OpDiv: ast.OpDiv}[t.Op] + if left.kind == SortInt && right.kind == SortInt { + if t.Op == OpDiv { + q, ok := semantics.IntQuotient(left.i, right.i) + if !ok { + return replayValue{}, fmt.Errorf("division by zero") + } + return replayValue{kind: SortReal, f: q}, nil + } + res, ok := semantics.IntArith(astOp, left.i, right.i) + if !ok { + return replayValue{}, fmt.Errorf("%d %s %d exceeds the Integer range", left.i, smtOps[t.Op], right.i) + } + return replayValue{kind: SortInt, i: res}, nil + } + res, ok := semantics.RealArith(astOp, left.asReal(), right.asReal()) + if !ok { + return replayValue{}, fmt.Errorf("division by zero") + } + if math.IsInf(res, 0) || math.IsNaN(res) { + return replayValue{}, fmt.Errorf("the result is not a finite Real") + } + return replayValue{kind: SortReal, f: res}, nil +} + +// replayIntDiv computes SMT-LIB's Euclidean integer division, which TruncDiv +// only applies to a non-negative dividend it builds itself. +func replayIntDiv(t *Term, env map[string]replayValue) (replayValue, error) { + left, err := replayTerm(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + right, err := replayTerm(t.Args[1], env) + if err != nil { + return replayValue{}, err + } + if left.kind != SortInt || right.kind != SortInt { + return replayValue{}, fmt.Errorf("integer division on non-integers") + } + if right.i == 0 { + return replayValue{}, fmt.Errorf("division by zero") + } + if left.i == math.MinInt64 && right.i == -1 { + return replayValue{}, fmt.Errorf("%d div %d exceeds the Integer range", left.i, right.i) + } + q := left.i / right.i + if left.i%right.i < 0 { + if right.i > 0 { + q-- + } else { + q++ + } + } + return replayValue{kind: SortInt, i: q}, nil +} + +// replayRatio computes a whole-number quotient as the evaluator does: the exact +// ratio of its integer operands, rounded once to the nearest float64. +func replayRatio(t *Term, env map[string]replayValue) (replayValue, error) { + left, err := exactRat(t.Args[0], env) + if err != nil { + return replayValue{}, err + } + right, err := exactRat(t.Args[1], env) + if err != nil { + return replayValue{}, err + } + if right.Sign() == 0 { + return replayValue{}, fmt.Errorf("division by zero") + } + f, _ := new(big.Rat).Quo(left, right).Float64() + if math.IsInf(f, 0) || math.IsNaN(f) { + return replayValue{}, fmt.Errorf("the quotient is not a finite Real") + } + return replayValue{kind: SortReal, f: f}, nil +} + +// exactRat reads a ratio operand exactly, stripping the encoding's Int-to-Real +// widening so no float64 rounding precedes the quotient's own. +func exactRat(t *Term, env map[string]replayValue) (*big.Rat, error) { + if t.Op == OpToReal { + t = t.Args[0] + } + if t.Op == OpReal { + return t.Real, nil + } + val, err := replayTerm(t, env) + if err != nil { + return nil, err + } + if val.kind == SortInt { + return new(big.Rat).SetInt64(val.i), nil + } + if val.kind == SortReal { + return new(big.Rat).SetFloat64(val.f), nil + } + return nil, fmt.Errorf("a quotient operand yielded no number") +} + +// numeric reports whether the value is an integer or a real. +func (v replayValue) numeric() bool { return v.kind == SortInt || v.kind == SortReal } + +// asReal is the value as the evaluator widens it where a real meets it. +func (v replayValue) asReal() float64 { + if v.kind == SortInt { + return float64(v.i) + } + return v.f +} diff --git a/internal/core/solve/replay_test.go b/internal/exec/solve/replay_test.go similarity index 100% rename from internal/core/solve/replay_test.go rename to internal/exec/solve/replay_test.go diff --git a/internal/core/solve/rounded_census_test.go b/internal/exec/solve/rounded_census_test.go similarity index 95% rename from internal/core/solve/rounded_census_test.go rename to internal/exec/solve/rounded_census_test.go index d07f49e879..d0915af49c 100644 --- a/internal/core/solve/rounded_census_test.go +++ b/internal/exec/solve/rounded_census_test.go @@ -4,8 +4,8 @@ package solve // sweeps in queries that are in fact provably exact — the false-undecided rate // that narrowing the marker per term would recover. Reproduce it with: // -// OPENSYSML_SMT=/usr/bin/z3 go test -count=1 -run TestRoundedCensus -v ./internal/core/solve -// OPENSYSML_SMT=/usr/local/bin/cvc5 go test -count=1 -run TestRoundedCensus -v ./internal/core/solve +// OPENSYSML_SMT=/usr/bin/z3 go test -count=1 -run TestRoundedCensus -v ./internal/exec/solve +// OPENSYSML_SMT=/usr/local/bin/cvc5 go test -count=1 -run TestRoundedCensus -v ./internal/exec/solve // // The population is every element the solver surfaces translate — constraints, // requirements, satisfaction assertions and analysis cases — over the corpora @@ -22,12 +22,12 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // censusRow is one translated query's classification. @@ -171,7 +171,7 @@ func workspaceOf(t *testing.T, paths []string, libraries bool) (*runtime.Context idx.ExpandWildcardImports() } resolver := resolve.New(idx) - ctx := runtime.NewContext(runtime.NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := runtime.NewContext(runtime.NewModel(passes.NewTypedModel(resolver), resolver), 10000) for _, sf := range sources { ctx.Model().RegisterSource(sf) } @@ -185,7 +185,7 @@ func censusLibrary(t *testing.T, solver *Solver, s *censusSummary) { parseLibraries(t, idx) idx.ExpandWildcardImports() resolver := resolve.New(idx) - ctx := runtime.NewContext(runtime.NewModel(semantics.NewModel(resolver), resolver), 10000) + ctx := runtime.NewContext(runtime.NewModel(passes.NewTypedModel(resolver), resolver), 10000) for _, doc := range libraryDocuments(idx) { s.files++ censusDocument(t, solver, s, ctx, idx, doc) diff --git a/internal/core/solve/satisfy_test.go b/internal/exec/solve/satisfy_test.go similarity index 100% rename from internal/core/solve/satisfy_test.go rename to internal/exec/solve/satisfy_test.go diff --git a/internal/core/solve/sexpr.go b/internal/exec/solve/sexpr.go similarity index 100% rename from internal/core/solve/sexpr.go rename to internal/exec/solve/sexpr.go diff --git a/internal/core/solve/sexpr_test.go b/internal/exec/solve/sexpr_test.go similarity index 100% rename from internal/core/solve/sexpr_test.go rename to internal/exec/solve/sexpr_test.go diff --git a/internal/core/solve/smtlib.go b/internal/exec/solve/smtlib.go similarity index 100% rename from internal/core/solve/smtlib.go rename to internal/exec/solve/smtlib.go diff --git a/internal/core/solve/smtlib_unit_test.go b/internal/exec/solve/smtlib_unit_test.go similarity index 100% rename from internal/core/solve/smtlib_unit_test.go rename to internal/exec/solve/smtlib_unit_test.go diff --git a/internal/exec/solve/solve_test.go b/internal/exec/solve/solve_test.go new file mode 100644 index 0000000000..1844b90d3e --- /dev/null +++ b/internal/exec/solve/solve_test.go @@ -0,0 +1,75 @@ +package solve + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +// document is a named source a fixture indexes. +type document struct { + path, src string +} + +// fixture indexes a model over the standard library and returns a runtime +// context and the index to look symbols up in. path names the document, which +// appears in the provenance a script records. +func fixture(t *testing.T, path, src string) (*runtime.Context, *symbols.Index) { + t.Helper() + return fixtureDocuments(t, document{path, src}) +} + +// fixtureDocuments indexes several documents over the standard library, in the +// order given. +func fixtureDocuments(t *testing.T, docs ...document) (*runtime.Context, *symbols.Index) { + t.Helper() + idx := libraryIndex() + sources := make([]*source.SourceFile, 0, len(docs)) + for _, doc := range docs { + sf := source.New(doc.path, []byte(doc.src)) + idx.AddDocument(doc.path, parser.New(sf).ParseFile()) + sources = append(sources, sf) + } + idx.ExpandWildcardImports() + resolver := resolve.New(idx) + ctx := runtime.NewContext(runtime.NewModel(passes.NewTypedModel(resolver), resolver), 10000) + for _, sf := range sources { + ctx.Model().RegisterSource(sf) + } + return ctx, idx +} + +// fixtureFile indexes a .sysml file from testdata, named by its base name so a +// script's provenance does not carry the checkout's path. +func fixtureFile(t *testing.T, name string) (*runtime.Context, *symbols.Index) { + t.Helper() + src, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + return fixture(t, name, string(src)) +} + +// libraryIndex is an index over the process-wide frozen standard library, which +// is what makes units, quantity value types and the scalar value types resolve. +func libraryIndex() *symbols.Index { + return libs.NewModelIndex() +} + +// symbolNamed returns the single symbol with that qualified name. +func symbolNamed(t *testing.T, idx *symbols.Index, fqn string) *symbols.Symbol { + t.Helper() + matches := idx.LookupQualified(fqn) + if len(matches) != 1 { + t.Fatalf("%s matched %d symbols, want 1", fqn, len(matches)) + } + return matches[0] +} diff --git a/internal/core/solve/solver.go b/internal/exec/solve/solver.go similarity index 100% rename from internal/core/solve/solver.go rename to internal/exec/solve/solver.go diff --git a/internal/core/solve/solver_test.go b/internal/exec/solve/solver_test.go similarity index 100% rename from internal/core/solve/solver_test.go rename to internal/exec/solve/solver_test.go diff --git a/internal/core/solve/sort.go b/internal/exec/solve/sort.go similarity index 100% rename from internal/core/solve/sort.go rename to internal/exec/solve/sort.go diff --git a/internal/core/solve/synthesis_test.go b/internal/exec/solve/synthesis_test.go similarity index 98% rename from internal/core/solve/synthesis_test.go rename to internal/exec/solve/synthesis_test.go index 2c915a850d..86c0f8478f 100644 --- a/internal/core/solve/synthesis_test.go +++ b/internal/exec/solve/synthesis_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // synthesised is the witness a solver answered with, by qualified feature name. diff --git a/internal/core/solve/term.go b/internal/exec/solve/term.go similarity index 100% rename from internal/core/solve/term.go rename to internal/exec/solve/term.go diff --git a/internal/core/solve/testdata/core/mission_budget.smt2 b/internal/exec/solve/testdata/core/mission_budget.smt2 similarity index 100% rename from internal/core/solve/testdata/core/mission_budget.smt2 rename to internal/exec/solve/testdata/core/mission_budget.smt2 diff --git a/internal/core/solve/testdata/core/safe_window_denied.smt2 b/internal/exec/solve/testdata/core/safe_window_denied.smt2 similarity index 100% rename from internal/core/solve/testdata/core/safe_window_denied.smt2 rename to internal/exec/solve/testdata/core/safe_window_denied.smt2 diff --git a/internal/core/solve/testdata/core/touchdown.smt2 b/internal/exec/solve/testdata/core/touchdown.smt2 similarity index 100% rename from internal/core/solve/testdata/core/touchdown.smt2 rename to internal/exec/solve/testdata/core/touchdown.smt2 diff --git a/internal/core/solve/testdata/crates_per_pallet.smt2 b/internal/exec/solve/testdata/crates_per_pallet.smt2 similarity index 100% rename from internal/core/solve/testdata/crates_per_pallet.smt2 rename to internal/exec/solve/testdata/crates_per_pallet.smt2 diff --git a/internal/core/solve/testdata/crates_per_run.smt2 b/internal/exec/solve/testdata/crates_per_run.smt2 similarity index 100% rename from internal/core/solve/testdata/crates_per_run.smt2 rename to internal/exec/solve/testdata/crates_per_run.smt2 diff --git a/internal/core/solve/testdata/logic_selection.sysml b/internal/exec/solve/testdata/logic_selection.sysml similarity index 100% rename from internal/core/solve/testdata/logic_selection.sysml rename to internal/exec/solve/testdata/logic_selection.sysml diff --git a/internal/core/solve/testdata/mass_and_crates.smt2 b/internal/exec/solve/testdata/mass_and_crates.smt2 similarity index 100% rename from internal/core/solve/testdata/mass_and_crates.smt2 rename to internal/exec/solve/testdata/mass_and_crates.smt2 diff --git a/internal/core/solve/testdata/mass_per_crate.smt2 b/internal/exec/solve/testdata/mass_per_crate.smt2 similarity index 100% rename from internal/core/solve/testdata/mass_per_crate.smt2 rename to internal/exec/solve/testdata/mass_per_crate.smt2 diff --git a/internal/core/solve/testdata/mission_budget.smt2 b/internal/exec/solve/testdata/mission_budget.smt2 similarity index 100% rename from internal/core/solve/testdata/mission_budget.smt2 rename to internal/exec/solve/testdata/mission_budget.smt2 diff --git a/internal/core/solve/testdata/mission_budget.sysml b/internal/exec/solve/testdata/mission_budget.sysml similarity index 100% rename from internal/core/solve/testdata/mission_budget.sysml rename to internal/exec/solve/testdata/mission_budget.sysml diff --git a/internal/core/solve/testdata/nested_variants.sysml b/internal/exec/solve/testdata/nested_variants.sysml similarity index 100% rename from internal/core/solve/testdata/nested_variants.sysml rename to internal/exec/solve/testdata/nested_variants.sysml diff --git a/internal/core/solve/testdata/objective_guarded.smt2 b/internal/exec/solve/testdata/objective_guarded.smt2 similarity index 100% rename from internal/core/solve/testdata/objective_guarded.smt2 rename to internal/exec/solve/testdata/objective_guarded.smt2 diff --git a/internal/core/solve/testdata/objective_lexicographic.smt2 b/internal/exec/solve/testdata/objective_lexicographic.smt2 similarity index 100% rename from internal/core/solve/testdata/objective_lexicographic.smt2 rename to internal/exec/solve/testdata/objective_lexicographic.smt2 diff --git a/internal/core/solve/testdata/objective_mass.smt2 b/internal/exec/solve/testdata/objective_mass.smt2 similarity index 100% rename from internal/core/solve/testdata/objective_mass.smt2 rename to internal/exec/solve/testdata/objective_mass.smt2 diff --git a/internal/core/solve/testdata/objective_variants.smt2 b/internal/exec/solve/testdata/objective_variants.smt2 similarity index 100% rename from internal/core/solve/testdata/objective_variants.smt2 rename to internal/exec/solve/testdata/objective_variants.smt2 diff --git a/internal/core/solve/testdata/objectives.sysml b/internal/exec/solve/testdata/objectives.sysml similarity index 100% rename from internal/core/solve/testdata/objectives.sysml rename to internal/exec/solve/testdata/objectives.sysml diff --git a/internal/core/solve/testdata/panel_finish_pinned.smt2 b/internal/exec/solve/testdata/panel_finish_pinned.smt2 similarity index 100% rename from internal/core/solve/testdata/panel_finish_pinned.smt2 rename to internal/exec/solve/testdata/panel_finish_pinned.smt2 diff --git a/internal/core/solve/testdata/panel_fits_pinned.smt2 b/internal/exec/solve/testdata/panel_fits_pinned.smt2 similarity index 100% rename from internal/core/solve/testdata/panel_fits_pinned.smt2 rename to internal/exec/solve/testdata/panel_fits_pinned.smt2 diff --git a/internal/core/solve/testdata/panel_pins.sysml b/internal/exec/solve/testdata/panel_pins.sysml similarity index 100% rename from internal/core/solve/testdata/panel_pins.sysml rename to internal/exec/solve/testdata/panel_pins.sysml diff --git a/internal/core/solve/testdata/panel_speed_pinned.smt2 b/internal/exec/solve/testdata/panel_speed_pinned.smt2 similarity index 100% rename from internal/core/solve/testdata/panel_speed_pinned.smt2 rename to internal/exec/solve/testdata/panel_speed_pinned.smt2 diff --git a/internal/core/solve/testdata/rig_pins.sysml b/internal/exec/solve/testdata/rig_pins.sysml similarity index 100% rename from internal/core/solve/testdata/rig_pins.sysml rename to internal/exec/solve/testdata/rig_pins.sysml diff --git a/internal/core/solve/testdata/ring_variants.smt2 b/internal/exec/solve/testdata/ring_variants.smt2 similarity index 100% rename from internal/core/solve/testdata/ring_variants.smt2 rename to internal/exec/solve/testdata/ring_variants.smt2 diff --git a/internal/core/solve/testdata/ring_variants.sysml b/internal/exec/solve/testdata/ring_variants.sysml similarity index 100% rename from internal/core/solve/testdata/ring_variants.sysml rename to internal/exec/solve/testdata/ring_variants.sysml diff --git a/internal/core/solve/testdata/safe_window.smt2 b/internal/exec/solve/testdata/safe_window.smt2 similarity index 100% rename from internal/core/solve/testdata/safe_window.smt2 rename to internal/exec/solve/testdata/safe_window.smt2 diff --git a/internal/core/solve/testdata/safe_window.sysml b/internal/exec/solve/testdata/safe_window.sysml similarity index 100% rename from internal/core/solve/testdata/safe_window.sysml rename to internal/exec/solve/testdata/safe_window.sysml diff --git a/internal/core/solve/testdata/safe_window_denied.smt2 b/internal/exec/solve/testdata/safe_window_denied.smt2 similarity index 100% rename from internal/core/solve/testdata/safe_window_denied.smt2 rename to internal/exec/solve/testdata/safe_window_denied.smt2 diff --git a/internal/core/solve/testdata/satisfy_touchdown.smt2 b/internal/exec/solve/testdata/satisfy_touchdown.smt2 similarity index 100% rename from internal/core/solve/testdata/satisfy_touchdown.smt2 rename to internal/exec/solve/testdata/satisfy_touchdown.smt2 diff --git a/internal/core/solve/testdata/satisfy_touchdown.sysml b/internal/exec/solve/testdata/satisfy_touchdown.sysml similarity index 100% rename from internal/core/solve/testdata/satisfy_touchdown.sysml rename to internal/exec/solve/testdata/satisfy_touchdown.sysml diff --git a/internal/core/solve/testdata/touchdown.smt2 b/internal/exec/solve/testdata/touchdown.smt2 similarity index 100% rename from internal/core/solve/testdata/touchdown.smt2 rename to internal/exec/solve/testdata/touchdown.smt2 diff --git a/internal/core/solve/testdata/touchdown.sysml b/internal/exec/solve/testdata/touchdown.sysml similarity index 100% rename from internal/core/solve/testdata/touchdown.sysml rename to internal/exec/solve/testdata/touchdown.sysml diff --git a/internal/exec/solve/translate.go b/internal/exec/solve/translate.go new file mode 100644 index 0000000000..fc41222401 --- /dev/null +++ b/internal/exec/solve/translate.go @@ -0,0 +1,1008 @@ +package solve + +import ( + "fmt" + "math" + "math/big" + "sort" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// Subject is the element a query is about: what a verdict on it would name, and +// whether it asserts that its conditions do not hold. +type Subject struct { + // Kind is the kind of element: "constraint", "requirement" or + // "satisfaction". + Kind string + + // Name is the element as a verdict about it would name it. + Name string + + // Symbol is the element's declaration, used for provenance. + Symbol *symbols.Symbol + + // Negated is set for `assert not …`, which denies the conjunction of the + // required conditions rather than asserting each one. + Negated bool +} + +// Constraint translates the conditions sym states as a constraint, inherited ones +// included, in the order the evaluator checks them. scope stands in for sym's +// own scope when sym declares none. +func Constraint(ctx *runtime.Context, sym *symbols.Symbol, scope *symbols.Scope) (*Query, error) { + return ConstraintWith(ctx, sym, scope, nil) +} + +// ConstraintWith translates a constraint with values already fixed, so the solver +// synthesises only what the model leaves free. With no pins it is Constraint. +func ConstraintWith(ctx *runtime.Context, sym *symbols.Symbol, scope *symbols.Scope, pins []Pin) (*Query, error) { + if err := runtime.RequireConstraint(sym); err != nil { + return nil, err + } + subject := Subject{ + Kind: "constraint", + Name: sym.Name, + Symbol: sym, + Negated: runtime.NegatedDecl(sym), + } + return TranslateWith(ctx, subject, ctx.ConditionsOf(sym, scope), pins) +} + +// Requirement translates the conditions sym states as a requirement, its +// assumptions included as assumed rather than required. +func Requirement(ctx *runtime.Context, sym *symbols.Symbol, scope *symbols.Scope) (*Query, error) { + return RequirementWith(ctx, sym, scope, nil) +} + +// RequirementWith translates a requirement with values already fixed. With no +// pins it is Requirement. +func RequirementWith(ctx *runtime.Context, sym *symbols.Symbol, scope *symbols.Scope, pins []Pin) (*Query, error) { + if err := runtime.RequireRequirement(sym); err != nil { + return nil, err + } + subject := Subject{ + Kind: "requirement", + Name: sym.Name, + Symbol: sym, + Negated: runtime.NegatedDecl(sym), + } + return TranslateWith(ctx, subject, ctx.ConditionsOf(sym, scope), pins) +} + +// Satisfaction translates the conditions an `assert satisfy` checks: those of the +// requirement it names, read through that requirement's own parameters, since the +// query asks what they permit rather than what the subject holds. +func Satisfaction(ctx *runtime.Context, assertion *runtime.SatisfyAssertion) (*Query, error) { + return SatisfactionWith(ctx, assertion, nil) +} + +// SatisfactionWith translates an `assert satisfy` with values already fixed. With +// no pins it is Satisfaction. +func SatisfactionWith(ctx *runtime.Context, assertion *runtime.SatisfyAssertion, pins []Pin) (*Query, error) { + if assertion == nil || assertion.Symbol == nil { + return nil, fmt.Errorf("satisfaction: %w", ErrNoConditions) + } + subject := Subject{ + Kind: "satisfaction", + Name: assertion.Text(), + Symbol: assertion.Symbol, + Negated: assertion.Negated, + } + return TranslateWith(ctx, subject, ctx.ConditionsOf(assertion.Symbol, assertion.Symbol.OwnerScope), pins) +} + +// TranslateWith builds a query whose pinned features are asserted equal to the +// values given, leaving the rest free. A value that cannot be fixed refuses, as +// an untranslatable condition does; a value no condition reads is reported in +// Query.Unread rather than dropped. One refusal fails the whole translation, +// since a query missing a conjunct would answer about conditions it does not +// hold. +func TranslateWith(ctx *runtime.Context, subject Subject, conds []runtime.Condition, pins []Pin) (*Query, error) { + if ctx == nil { + return nil, fmt.Errorf("solve: no runtime context") + } + if len(conds) == 0 { + return nil, fmt.Errorf("%s %s: %w", subject.Kind, subject.Name, ErrNoConditions) + } + t := newTranslator(ctx, subject) + if err := t.translate(conds); err != nil { + return nil, err + } + if err := t.fix(pins); err != nil { + return nil, err + } + return t.query(), nil +} + +// translator holds the state of one translation: the variables and sorts the +// conditions turned out to need, the assertions built so far, and which +// condition is being translated, for a refusal's provenance. +type translator struct { + ctx *runtime.Context + model *semantics.Model + subject Subject + + // features are the features a condition may name, as the evaluator sees them: + // a redefinition masks what it redefines, so both read one value. + features map[string]*symbols.Symbol + + vars map[string]*Var + sorts map[string]Sort + domains []Assertion + guards []Assertion + asserts []Assertion + + // pins are the equalities fixing values the model already holds, and pinned + // records them for a report; unread holds the values no variable reads. + pins []Assertion + pinned []PinnedValue + unread []Unread + + // guarded remembers the divisors already asserted non-zero, by their term, so + // a divisor read twice is guarded once. + guarded map[string]bool + + // baseUnits names the base units magnitudes of a dimension are scaled to, as + // the quantities written in the conditions reduce to them. + baseUnits map[string]string + + // branched counts the enclosing contexts a subexpression may go unevaluated in, + // where a definedness assertion over the whole query would not be equivalent. + branched int + + // machine asserts Integer arithmetic within int64, where the evaluator + // reports overflow: what a step of execution is defined under. + machine bool + + // objectives are the translated objectives, in the order they are optimized. + objectives []Objective + + // within is the objective whose own conditions are being translated: a member + // of its own it names (its `best`) is that objective's, not another's. + within *symbols.Symbol + + nonlinear bool + intDiv bool + + condLabel string + condFile string +} + +// newTranslator starts a translation of what subject states. +func newTranslator(ctx *runtime.Context, subject Subject) *translator { + return &translator{ + ctx: ctx, + model: ctx.Semantics(), + subject: subject, + features: effectiveFeatures(ctx, subject.Symbol), + vars: map[string]*Var{}, + sorts: map[string]Sort{}, + guarded: map[string]bool{}, + baseUnits: map[string]string{}, + } +} + +// effectiveFeatures maps the name a condition may use to the feature it reads, +// from the same flattened schema the evaluator resolves a name through. +func effectiveFeatures(ctx *runtime.Context, sym *symbols.Symbol) map[string]*symbols.Symbol { + features := ctx.FeaturesOf(sym) + if len(features) == 0 { + return nil + } + out := make(map[string]*symbols.Symbol, len(features)) + for _, feat := range features { + if feat.Name == "" || feat.Symbol == nil { + continue + } + out[feat.Name] = feat.Symbol + } + return out +} + +// translate builds an assertion per condition, in the order the evaluator checks +// them. A negated element instead denies the conjunction of its required +// conditions, as evaluating it negates their verdict as a whole. +func (t *translator) translate(conds []runtime.Condition) error { + var required []*Term + var labels []string + for _, cond := range conds { + owner, span := conditionOrigin(cond) + t.condLabel = cond.Label() + t.condFile = "" + if owner != nil { + t.condFile = owner.DocName + } + term, err := t.condition(cond) + if err != nil { + return err + } + if t.subject.Negated && cond.Required { + required = append(required, term) + labels = append(labels, cond.Label()) + continue + } + role := RoleAssumed + if cond.Required { + role = RoleRequired + } + t.asserts = append(t.asserts, Assertion{Term: term, From: t.provenance(role, owner, span)}) + } + if !t.subject.Negated { + return nil + } + if len(required) == 0 { + return fmt.Errorf("%s %s: %w to deny", t.subject.Kind, t.subject.Name, ErrNoConditions) + } + t.condLabel = "not (" + strings.Join(labels, " and ") + ")" + from := t.provenance(RoleDenied, t.subject.Symbol, declSpan(t.subject.Symbol)) + if t.subject.Symbol != nil { + from.File = t.subject.Symbol.DocName + from.Location = t.ctx.SourceLocation(from.File, from.Span) + } + t.asserts = append(t.asserts, Assertion{Term: Not(And(required...)), From: from}) + return nil +} + +// query assembles the translated parts, declarations ordered by name and domain +// assertions before the conditions, which is what makes a script deterministic. +func (t *translator) query() *Query { + q := &Query{ + Kind: t.subject.Kind, + Element: t.subject.Name, + Negated: t.subject.Negated, + Nonlinear: t.nonlinear, + IntegerDivision: t.intDiv, + } + for _, v := range t.vars { + if v.Unit == "" { + v.Unit = t.baseUnits[v.Dimension] + } + q.Vars = append(q.Vars, v) + } + sortVars(q.Vars) + for _, s := range t.sorts { + q.Sorts = append(q.Sorts, s) + } + sortSorts(q.Sorts) + domains := append([]Assertion(nil), t.domains...) + sortAssertions(domains) + q.Assertions = append(domains, t.pinnedAssertions(len(domains))...) + q.Assertions = append(q.Assertions, t.guards...) + q.Assertions = append(q.Assertions, t.asserts...) + q.Pinned = t.pinned + q.Unread = t.unread + for _, obj := range t.objectives { + if obj.Unit == "" { + obj.Unit = t.baseUnits[obj.Dimension] + } + q.Objectives = append(q.Objectives, obj) + } + return q +} + +// pinnedAssertions orders the fixed values by the variable they fix, and records +// where each one's assertion sits, so a core naming it names the fixed value. The +// assertions and the records they come from are ordered as one, since the record +// is what an unsat core is read back through. +func (t *translator) pinnedAssertions(offset int) []Assertion { + order := make([]int, len(t.pinned)) + for i := range order { + order[i] = i + } + sort.SliceStable(order, func(i, j int) bool { + return t.pinned[order[i]].Var.Name < t.pinned[order[j]].Var.Name + }) + assertions := make([]Assertion, 0, len(order)) + pinned := make([]PinnedValue, 0, len(order)) + for at, i := range order { + assertions = append(assertions, t.pins[i]) + record := t.pinned[i] + record.Index = offset + at + pinned = append(pinned, record) + } + t.pinned = pinned + return assertions +} + +// condition translates one condition, applying the negation it was written with. +// A group stands for the conjunction of its conditions, so negating a group +// negates that conjunction. +func (t *translator) condition(cond runtime.Condition) (*Term, error) { + if cond.Statement != nil { + return nil, t.refuse(cond.Statement, "body statement", "OpenSysML does not execute a statement in a constraint body") + } + if cond.Conflict != nil { + return nil, t.refuse(cond.Conflict.Node, "conflicting result expression", "only one owned or inherited result expression is allowed") + } + if cond.Negated { + t.branched++ + defer func() { t.branched-- }() + } + if cond.Group != nil { + parts := make([]*Term, 0, len(cond.Group)) + for _, sub := range cond.Group { + term, err := t.condition(sub) + if err != nil { + return nil, err + } + parts = append(parts, term) + } + return negate(And(parts...), cond.Negated), nil + } + if cond.Expr == nil { + return nil, t.refuse(nil, "empty condition", "it states no expression") + } + term, err := t.expr(cond.Expr, cond.Scope) + if err != nil { + return nil, err + } + if term.Sort.Kind != SortBool { + return nil, t.refuse(cond.Expr, "condition", "it yields "+term.Sort.Name+" rather than a boolean") + } + return negate(term, cond.Negated), nil +} + +// negate applies a written negation. +func negate(term *Term, negated bool) *Term { + if negated { + return Not(term) + } + return term +} + +// expr translates an expression, resolving names in scope. +func (t *translator) expr(node ast.Node, scope *symbols.Scope) (*Term, error) { + switch n := node.(type) { + case *ast.LiteralBool: + return BoolTerm(n.Value), nil + case *ast.LiteralInteger: + val, err := strconv.ParseInt(n.Value, 10, 64) + if err != nil { + return nil, t.refuse(n, "integer literal "+n.Value, "it does not fit a 64-bit integer") + } + return IntTerm(val), nil + case *ast.LiteralReal: + rat, ok := new(big.Rat).SetString(n.Value) + if !ok { + return nil, t.refuse(n, "real literal "+n.Value, "it is not an exact rational") + } + return RealTerm(rat), nil + case *ast.LiteralString: + return StringTerm(unquote(n.Value)), nil + case *ast.FeatureReference, *ast.FeatureChainExpr: + return t.reference(node, scope) + case *ast.IndexExpr: + if !n.Bracket { + return nil, t.refuse(n, "index expression", "indexing a sequence is outside the subset") + } + return t.quantity(n, scope) + case *ast.OperatorExpr: + return t.operator(n, scope) + case *ast.SequenceExpr: + if len(n.Elements) == 1 { + return t.expr(n.Elements[0], scope) + } + return nil, t.refuse(n, "sequence", "a collection is outside the subset") + } + return nil, t.refuse(node, describe(node), "it is outside the subset") +} + +// recordBaseUnits remembers the base units a dimension's magnitudes are scaled +// to, so a model reports one in the units it is expressed in rather than bare. +func (t *translator) recordBaseUnits(dim semantics.Dimension, unit semantics.UnitTerm) { + key := dimensionUnits(dim) + if key == "" || t.baseUnits[key] != "" { + return + } + t.baseUnits[key] = baseUnitsText(unit) +} + +// baseUnitsText names a reduced unit's base units as declared ("gram", +// "metre·second^-1"), its scale factor left out since a magnitude scaled to them +// carries none. +func baseUnitsText(unit semantics.UnitTerm) string { + out := "" + for _, f := range unit.Factors { + if out != "" { + out += "·" + } + out += leafName(f.Unit.Name) + if f.Exponent != 1 { + out += fmt.Sprintf("^%g", f.Exponent) + } + } + return out +} + +// leafName is the declared name at the end of a qualified one. +func leafName(name string) string { + if i := strings.LastIndex(name, "::"); i >= 0 { + return name[i+2:] + } + return name +} + +// quantity translates `magnitude [unit]`, scaling the magnitude to the base units +// its unit reduces to so that magnitudes of one dimension are comparable. +func (t *translator) quantity(n *ast.IndexExpr, scope *symbols.Scope) (*Term, error) { + unit, err := t.model.UnitTermOfExpr(scope, n.Index) + if err != nil { + return nil, t.refuse(n, "quantity", err.Error()) + } + unit = unit.Normalized() + dim, ok := t.model.DimensionOfExpr(scope, n) + if !ok { + return nil, t.refuse(n, "quantity", "the dimension of its unit is not determined statically") + } + t.recordBaseUnits(dim, unit) + scale, ok := ratOfScale(unit.Scale) + if !ok { + return nil, t.refuse(n, "quantity", "its unit reduces to a scale factor that is not an exact ratio") + } + magnitude, err := t.expr(n.Operand, scope) + if err != nil { + return nil, err + } + if !magnitude.Sort.Numeric() { + return nil, t.refuse(n, "quantity", "its magnitude yields "+magnitude.Sort.Name+" rather than a number") + } + realMagnitude := ToReal(magnitude) + if realMagnitude.Op == OpReal { + return RealTerm(new(big.Rat).Mul(realMagnitude.Real, scale)), nil + } + if scale.Cmp(big.NewRat(1, 1)) == 0 { + return realMagnitude, nil + } + return Binary(OpMul, Real, realMagnitude, RealTerm(scale)), nil +} + +// ratOfScale converts a unit's scale factor to an exact ratio. +func ratOfScale(scale semantics.Scale) (*big.Rat, bool) { + num, den := new(big.Rat), new(big.Rat) + if math.IsInf(scale.Num, 0) || math.IsNaN(scale.Num) || num.SetFloat64(scale.Num) == nil { + return nil, false + } + if math.IsInf(scale.Den, 0) || math.IsNaN(scale.Den) || den.SetFloat64(scale.Den) == nil || scale.Den == 0 { + return nil, false + } + return num.Quo(num, den), true +} + +// msgOperatorPrefix names the operator a refusal is about. +const msgOperatorPrefix = "operator `" + +// operator translates an operator application, refusing one whose meaning this +// term language does not carry. +func (t *translator) operator(n *ast.OperatorExpr, scope *symbols.Scope) (*Term, error) { + switch n.Operator { + case ast.OpNot: + return t.unaryBool(n, scope) + case ast.OpAnd, ast.OpConditionalAnd: + return t.binaryBool(n, scope, OpAnd) + case ast.OpOr, ast.OpConditionalOr: + return t.binaryBool(n, scope, OpOr) + case ast.OpXor: + return t.binaryBool(n, scope, OpXor) + case ast.OpImplies: + return t.binaryBool(n, scope, OpImplies) + case ast.OpEq: + return t.equality(n, scope, OpEq) + case ast.OpNeq: + return t.equality(n, scope, OpNe) + case ast.OpLt: + return t.comparison(n, scope, OpLt) + case ast.OpLe: + return t.comparison(n, scope, OpLe) + case ast.OpGt: + return t.comparison(n, scope, OpGt) + case ast.OpGe: + return t.comparison(n, scope, OpGe) + case ast.OpAdd: + return t.additive(n, scope, OpAdd) + case ast.OpSub: + return t.additive(n, scope, OpSub) + case ast.OpMul: + return t.multiplicative(n, scope, OpMul) + case ast.OpDiv: + return t.multiplicative(n, scope, OpDiv) + case ast.OpMod: + return t.remainder(n, scope) + case ast.OpNeg, ast.OpPos: + return t.unaryNumber(n, scope) + case ast.OpConditional: + return t.conditional(n, scope) + } + return nil, t.refuse(n, msgOperatorPrefix+n.Operator.String()+"`", operatorReason(n.Operator)) +} + +// operatorReason says why an operator outside the subset is outside it. +func operatorReason(op ast.OperatorKind) string { + switch op { + case ast.OpPow: + return "exponentiation is outside the arithmetic encoded here" + case ast.OpRange: + return "a range is a collection" + case ast.OpIndex, ast.OpAll: + return "a collection operation is outside the subset" + case ast.OpEqEqEq, ast.OpNeqEqEq: + return "identity compares objects rather than values" + case ast.OpHasType, ast.OpIsType, ast.OpAs, ast.OpMeta, ast.OpAt, ast.OpMetaAt: + return "classification and metadata are not encoded as terms" + case ast.OpNullCoalesce: + return "a null value has no term" + case ast.OpBitNot: + return "bitwise negation is outside the subset" + } + return "it is outside the subset" +} + +// unaryBool translates `not e`, whose operand is read in a negated position. +func (t *translator) unaryBool(n *ast.OperatorExpr, scope *symbols.Scope) (*Term, error) { + t.branched++ + defer func() { t.branched-- }() + arg, err := t.operandOfSort(n, scope, 0, Bool) + if err != nil { + return nil, err + } + return Not(arg), nil +} + +// binaryBool translates a boolean connective. Every connective but `and` may +// leave an operand unevaluated, or reads it negated, so its operands are branched. +func (t *translator) binaryBool(n *ast.OperatorExpr, scope *symbols.Scope, op Op) (*Term, error) { + if op != OpAnd { + t.branched++ + defer func() { t.branched-- }() + } + left, err := t.operandOfSort(n, scope, 0, Bool) + if err != nil { + return nil, err + } + right, err := t.operandOfSort(n, scope, 1, Bool) + if err != nil { + return nil, err + } + switch op { + case OpAnd: + return And(left, right), nil + case OpOr: + return Or(left, right), nil + } + return Binary(op, Bool, left, right), nil +} + +// equality translates `==` or `!=` between two values of the same sort. +func (t *translator) equality(n *ast.OperatorExpr, scope *symbols.Scope, op Op) (*Term, error) { + left, right, err := t.operands(n, scope) + if err != nil { + return nil, err + } + left, right = promote(left, right) + if !left.Sort.Equal(right.Sort) { + return nil, t.refuse(n, msgOperatorPrefix+n.Operator.String()+"`", + fmt.Sprintf("its operands yield %s and %s", left.Sort.Name, right.Sort.Name)) + } + if left.Sort.Numeric() { + if err := t.sameDimension(n, scope); err != nil { + return nil, err + } + } + return Binary(op, Bool, left, right), nil +} + +// comparison translates an ordering comparison between two numbers. +func (t *translator) comparison(n *ast.OperatorExpr, scope *symbols.Scope, op Op) (*Term, error) { + left, right, err := t.numericOperands(n, scope) + if err != nil { + return nil, err + } + if err := t.sameDimension(n, scope); err != nil { + return nil, err + } + return Binary(op, Bool, left, right), nil +} + +// additive translates `+` or `-` between two numbers of the same dimension. +func (t *translator) additive(n *ast.OperatorExpr, scope *symbols.Scope, op Op) (*Term, error) { + left, right, err := t.numericOperands(n, scope) + if err != nil { + return nil, err + } + if err := t.sameDimension(n, scope); err != nil { + return nil, err + } + return t.ranged(n, Binary(op, left.Sort, left, right)) +} + +// multiplicative translates `*` or `/`. A quotient is a Real whatever its +// operand sorts, as the evaluator's is, with a non-zero divisor asserted. +func (t *translator) multiplicative(n *ast.OperatorExpr, scope *symbols.Scope, op Op) (*Term, error) { + if op == OpDiv { + if folded, ok := t.folded(n); ok { + return folded, nil + } + } + left, right, err := t.numericOperands(n, scope) + if err != nil { + return nil, err + } + if op == OpDiv { + if err := t.divisor(n, right); err != nil { + return nil, err + } + if left.Sort.Kind == SortInt && right.Sort.Kind == SortInt { + return RatioDiv(left, right), nil + } + return Binary(OpDiv, Real, ToReal(left), ToReal(right)), nil + } + if !left.Literal() && !right.Literal() { + t.nonlinear = true + } + return t.ranged(n, Binary(OpMul, left.Sort, left, right)) +} + +// ranged asserts an Integer result within int64 where the evaluator reports +// overflow, when translating a step of execution; a Real result rounds instead. +func (t *translator) ranged(n *ast.OperatorExpr, result *Term) (*Term, error) { + if !t.machine || result.Sort.Kind != SortInt { + return result, nil + } + if !t.hoistable() { + return nil, t.refuse(n, msgOperatorPrefix+n.Operator.String()+"` on integers", + "asserting its result within int64 would deny assignments the evaluator accepts, "+ + "as this operation may go unevaluated") + } + t.guard(Int64(result), n) + return result, nil +} + +// remainder translates `%` on integers as the remainder truncating division +// leaves, which takes the sign of the dividend as the evaluator's does. +func (t *translator) remainder(n *ast.OperatorExpr, scope *symbols.Scope) (*Term, error) { + if folded, ok := t.folded(n); ok { + return folded, nil + } + left, right, err := t.numericOperands(n, scope) + if err != nil { + return nil, err + } + if left.Sort.Kind != SortInt || right.Sort.Kind != SortInt { + return nil, t.refuse(n, "operator `%` on real numbers", + "the evaluator computes it in floating point, which this encoding does not model") + } + if err := t.divisor(n, right); err != nil { + return nil, err + } + t.intDiv = true + return TruncRem(left, right), nil +} + +// divisor refuses a literal zero divisor, as the evaluator reports division by +// zero, and asserts a computed one to be non-zero: SMT-LIB's division is total, +// so a solver could otherwise satisfy a condition by dividing by zero. A divisor +// that is not a literal makes the arithmetic nonlinear. +func (t *translator) divisor(n *ast.OperatorExpr, divisor *Term) error { + if divisor.Literal() { + if isZero(divisor) { + return t.refuse(n, msgOperatorPrefix+n.Operator.String()+"` by zero", + "the evaluator reports division by zero") + } + return nil + } + if !t.hoistable() { + return t.refuse(n, msgOperatorPrefix+n.Operator.String()+"` by a computed divisor", + "asserting the divisor non-zero would deny assignments the evaluator accepts, "+ + "as this division may go unevaluated") + } + t.nonlinear = true + t.guard(Binary(OpNe, Bool, divisor, zeroOf(divisor.Sort)), n) + return nil +} + +// hoistable reports whether a definedness assertion over the whole query says +// what the evaluator says: only where the expression is always evaluated and read +// unnegated, since a division the evaluator never performs cannot constrain it. +func (t *translator) hoistable() bool { + return t.branched == 0 && !t.subject.Negated +} + +// guard asserts a side condition a translated condition needs to mean what the +// evaluator means, once per distinct term. +func (t *translator) guard(term *Term, node ast.Node) { + key := writeTerm(term) + if t.guarded[key] { + return + } + t.guarded[key] = true + t.guards = append(t.guards, Assertion{Term: term, From: t.provenance(RoleDefined, nil, node.Span())}) +} + +// folded returns what the evaluator's constant folder answers for a constant +// expression: it keeps a constant `7 / 2` a real quotient rather than truncating +// it, so the encoding must answer the same. +func (t *translator) folded(n *ast.OperatorExpr) (*Term, bool) { + val, ok := t.model.Eval(n) + if !ok { + return nil, false + } + switch val.Kind { + case semantics.ValInt: + return IntTerm(val.Int), true + case semantics.ValReal: + rat := new(big.Rat).SetFloat64(val.Real) + if rat == nil { + return nil, false + } + return RealTerm(rat), true + } + return nil, false +} + +// isZero reports whether a numeric literal is zero. +func isZero(term *Term) bool { + switch term.Op { + case OpInt: + return term.Int == 0 + case OpReal: + return term.Real.Sign() == 0 + } + return false +} + +// zeroOf is the zero of a numeric sort. +func zeroOf(sort Sort) *Term { + if sort.Kind == SortInt { + return IntTerm(0) + } + return RealTerm(new(big.Rat)) +} + +// unaryNumber translates unary `-` and `+`. +func (t *translator) unaryNumber(n *ast.OperatorExpr, scope *symbols.Scope) (*Term, error) { + if len(n.Operands) != 1 { + return nil, t.refuse(n, msgOperatorPrefix+n.Operator.String()+"`", "it takes one operand") + } + arg, err := t.expr(n.Operands[0], scope) + if err != nil { + return nil, err + } + if !arg.Sort.Numeric() { + return nil, t.refuse(n, msgOperatorPrefix+n.Operator.String()+"`", + "its operand yields "+arg.Sort.Name+" rather than a number") + } + if n.Operator == ast.OpPos { + return arg, nil + } + return t.ranged(n, negated(arg)) +} + +// negated is unary `-`, folded over a literal so a negative literal stays one: +// a literal divisor is what keeps a division linear. +func negated(arg *Term) *Term { + switch arg.Op { + case OpInt: + return IntTerm(-arg.Int) + case OpReal: + return RealTerm(new(big.Rat).Neg(arg.Real)) + } + return Unary(OpNeg, arg.Sort, arg) +} + +// conditional translates `if c ? a else b`, whose branches must share a sort. +func (t *translator) conditional(n *ast.OperatorExpr, scope *symbols.Scope) (*Term, error) { + if len(n.Operands) != 3 { + return nil, t.refuse(n, "conditional expression", "it takes a condition and two branches") + } + cond, err := t.operandOfSort(n, scope, 0, Bool) + if err != nil { + return nil, err + } + t.branched++ + defer func() { t.branched-- }() + then, err := t.expr(n.Operands[1], scope) + if err != nil { + return nil, err + } + otherwise, err := t.expr(n.Operands[2], scope) + if err != nil { + return nil, err + } + then, otherwise = promote(then, otherwise) + if !then.Sort.Equal(otherwise.Sort) { + return nil, t.refuse(n, "conditional expression", + fmt.Sprintf("its branches yield %s and %s", then.Sort.Name, otherwise.Sort.Name)) + } + return Ite(cond, then, otherwise), nil +} + +// operands translates the two operands of a binary operator. +func (t *translator) operands(n *ast.OperatorExpr, scope *symbols.Scope) (*Term, *Term, error) { + if len(n.Operands) != 2 { + return nil, nil, t.refuse(n, msgOperatorPrefix+n.Operator.String()+"`", "it takes two operands") + } + left, err := t.expr(n.Operands[0], scope) + if err != nil { + return nil, nil, err + } + right, err := t.expr(n.Operands[1], scope) + if err != nil { + return nil, nil, err + } + return left, right, nil +} + +// numericOperands translates two operands that must both be numbers, widening an +// integer one when the other is real. +func (t *translator) numericOperands(n *ast.OperatorExpr, scope *symbols.Scope) (*Term, *Term, error) { + left, right, err := t.operands(n, scope) + if err != nil { + return nil, nil, err + } + if !left.Sort.Numeric() || !right.Sort.Numeric() { + return nil, nil, t.refuse(n, msgOperatorPrefix+n.Operator.String()+"`", + fmt.Sprintf("its operands yield %s and %s rather than numbers", left.Sort.Name, right.Sort.Name)) + } + left, right = promote(left, right) + return left, right, nil +} + +// operandOfSort translates one operand and requires the sort given. +func (t *translator) operandOfSort(n *ast.OperatorExpr, scope *symbols.Scope, i int, want Sort) (*Term, error) { + if i >= len(n.Operands) { + return nil, t.refuse(n, msgOperatorPrefix+n.Operator.String()+"`", "it is missing an operand") + } + term, err := t.expr(n.Operands[i], scope) + if err != nil { + return nil, err + } + if !term.Sort.Equal(want) { + return nil, t.refuse(n.Operands[i], "operand of `"+n.Operator.String()+"`", + "it yields "+term.Sort.Name+" rather than "+want.Name) + } + return term, nil +} + +// promote widens an integer term to a real one when the other side is real. +func promote(left, right *Term) (*Term, *Term) { + if left.Sort.Kind == SortInt && right.Sort.Kind == SortReal { + return ToReal(left), right + } + if left.Sort.Kind == SortReal && right.Sort.Kind == SortInt { + return left, ToReal(right) + } + return left, right +} + +// sameDimension refuses an operator whose operands are magnitudes of different +// dimensions, which the evaluator reports as incommensurable units. A dimension +// that is not statically determined counts as dimensionless, as a bare number is. +func (t *translator) sameDimension(n *ast.OperatorExpr, scope *symbols.Scope) error { + left := t.dimensionOf(scope, n.Operands[0]) + right := t.dimensionOf(scope, n.Operands[1]) + if left.Term.Commensurable(right.Term) { + return nil + } + return t.refuse(n, msgOperatorPrefix+n.Operator.String()+"`", + fmt.Sprintf("incommensurable units: %s against %s", dimensionText(left), dimensionText(right))) +} + +// dimensionOf returns the dimension of an expression, dimensionless when it is +// not statically determined. +func (t *translator) dimensionOf(scope *symbols.Scope, node ast.Node) semantics.Dimension { + if dim, ok := t.model.DimensionOfExpr(scope, node); ok { + return dim + } + return semantics.Dimension{Term: semantics.UnitTerm{Scale: semantics.UnitScale(1)}} +} + +// dimensionText names a dimension for a message, naming a dimensionless one. +func dimensionText(dim semantics.Dimension) string { + if dim.Term.Dimensionless() { + return "a plain number" + } + return dim.String() +} + +// unquote strips the quotes a string literal's raw text carries, as the +// evaluator does. +func unquote(text string) string { + return source.StringValue(text) +} + +// describe names a node as the notation writes it, for a refusal. +func describe(node ast.Node) string { + switch node.(type) { + case nil: + return "empty expression" + case *ast.NullExpr: + return "`null`" + case *ast.LiteralInfinity: + return "`*`" + case *ast.InvocationExpr: + return "invocation" + case *ast.CollectExpr: + return "`->` collect expression" + case *ast.SelectExpr: + return "`->select` expression" + case *ast.BodyExpr: + return "body expression" + case *ast.MetadataAccessExpr: + return "metadata access" + case *ast.CastExpr: + return "cast" + case *ast.ConstructorExpr: + return "constructor" + } + return fmt.Sprintf("expression %T", node) +} + +// conditionOrigin returns the element that declared a condition and where it was +// written, descending into a group's first condition, which carries the scope a +// group has none of. +func conditionOrigin(cond runtime.Condition) (*symbols.Symbol, source.Span) { + if cond.Group != nil { + if len(cond.Group) == 0 { + return nil, source.Span{} + } + return conditionOrigin(cond.Group[0]) + } + owner := cond.Owner() + span := declSpan(owner) + if cond.Expr != nil { + span = cond.Expr.Span() + } + if cond.Statement != nil { + span = cond.Statement.Span() + } + if cond.Conflict != nil { + span = cond.Conflict.Node.Span() + } + return owner, span +} + +// declSpan is where a symbol was declared, empty for none. +func declSpan(sym *symbols.Symbol) source.Span { + if sym == nil { + return source.Span{} + } + return sym.DeclSpan +} + +// provenance records what the assertion being built came from. +func (t *translator) provenance(role Role, owner *symbols.Symbol, span source.Span) Provenance { + return Provenance{ + Kind: t.subject.Kind, + Element: t.subject.Name, + Condition: t.condLabel, + Role: role, + Declared: owner, + File: t.condFile, + Span: span, + Location: t.ctx.SourceLocation(t.condFile, span), + } +} + +// refuse reports that a construct is outside the translatable subset, naming the +// condition it appeared in and where it was written. +func (t *translator) refuse(node ast.Node, construct, reason string) error { + span := source.Span{} + if node != nil { + span = node.Span() + } + return &NotTranslatableError{ + Construct: construct, + Reason: reason, + Element: t.subject.Kind + " " + t.subject.Name, + Condition: t.condLabel, + File: t.condFile, + Span: span, + Location: t.ctx.SourceLocation(t.condFile, span), + } +} diff --git a/internal/core/solve/translate_test.go b/internal/exec/solve/translate_test.go similarity index 100% rename from internal/core/solve/translate_test.go rename to internal/exec/solve/translate_test.go diff --git a/internal/core/solve/witness_value_test.go b/internal/exec/solve/witness_value_test.go similarity index 98% rename from internal/core/solve/witness_value_test.go rename to internal/exec/solve/witness_value_test.go index 1ef1c85b5c..7aaa3f32ad 100644 --- a/internal/core/solve/witness_value_test.go +++ b/internal/exec/solve/witness_value_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // varsOf indexes a query's variables by name. diff --git a/internal/frontend/grpc/analysis.go b/internal/frontend/grpc/analysis.go new file mode 100644 index 0000000000..85fbf56260 --- /dev/null +++ b/internal/frontend/grpc/analysis.go @@ -0,0 +1,362 @@ +package grpc + +import ( + "context" + "errors" + "fmt" + "sort" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// RunAnalysis runs an analysis case, as the REPL's %analysis does: the subject +// named is instantiated and bound, the arguments bind the case's inputs, and +// the response carries every output with the verdict of each objective and +// assertion (SysML 7.22). +func (s *Service) RunAnalysis(ctx context.Context, req *pb.RunAnalysisRequest) (*pb.RunAnalysisResponse, error) { + schedule, err := s.schedulePolicy(req.Schedule) + if err != nil { + return nil, err + } + if err := s.requireCapability(CapabilityVerification); err != nil { + return nil, err + } + v, err := s.newVerifyContext(req.ModelHash, req.Engine) + if err != nil { + return nil, err + } + defer v.release() + sym, err := v.lookup(req.SymbolId) + if err != nil { + return &pb.RunAnalysisResponse{Error: err.Error()}, nil + } + if err := v.runtime.RequireAnalysisCase(sym); err != nil { + return &pb.RunAnalysisResponse{Error: err.Error(), FailureReason: failureReason(err)}, nil + } + args, resp, err := v.analysisArgs(req) + if resp != nil || err != nil { + return resp, err + } + + if policy, explores := analysis.Explores(v.engine, schedule); explores { + return s.exploreAnalysis(ctx, policy, v, req, sym) + } + if err := v.runtime.SetSchedule(schedule); err != nil { + return nil, statusError(connect.CodeInvalidArgument, err.Error()) + } + + // The choices a run made are reported with its outcome, failed or not. + run, plan, err := v.runCase(ctx, sym, args) + if gone := callerGone(ctx, err); gone != nil { + return nil, gone + } + result, verdicts := run.result, run.verdicts() + st := s.standingOf(plan) + resp = analysisResponse(&pb.RunAnalysisResponse{}, st) + if err != nil { + resp.Error = err.Error() + resp.FailureReason = failureReason(err) + // A client predating case_evaluations reads a failed run as its error alone. + if !v.service.capabilities.has(CapabilityCaseEvaluations) { + resp.Diagnostics = v.service.filterDiagnosticCapabilities(RunNoteDiagnosticsToProto(v.runtime.Notes(), v.cached)) + return resp, nil + } + } + resp.Diagnostics = v.service.filterDiagnosticCapabilities(RunNoteDiagnosticsToProto(v.runtime.Notes(), v.cached)) + // The case reports the subject it ran on: the one supplied, or the one the + // usage or the enclosing case bound. + subject := result.Subject + for _, out := range result.Outputs { + resp.Outputs = append(resp.Outputs, &pb.CalcOutput{ + Name: out.Name, + Value: v.service.valueToProto(v.runtime, out.Value, v.cached.Index), + }) + } + for i := range result.Verdicts { + resp.Verdicts = append(resp.Verdicts, st.stamp(v.analysisVerdict(&result.Verdicts[i], subject))) + } + // A case run for itself answers for no requirement, so nothing associates it. + resp.VerificationVerdicts = v.verificationVerdicts(verdicts, "") + // A client predating case_evaluations sees no evaluation, so no object only one names. + var evaluations []runtime.AnalysisEvaluation + if v.service.capabilities.has(CapabilityCaseEvaluations) { + evaluations = result.Evaluations + resp.Evaluations = v.caseEvaluations(evaluations) + } + resp.Instances = v.instanceGraphs(v.runRoots(subject, result.Outputs, evaluations)) + return resp, nil +} + +// analysisResponse writes on a case's response the standing of the plan that ran it. +func analysisResponse(resp *pb.RunAnalysisResponse, st standing) *pb.RunAnalysisResponse { + resp.Engine, resp.Strength, resp.Bounds = st.engine, st.strength, st.bounds + return resp +} + +// analysisArgs reads the request's subject and arguments on the context's own +// runtime; an unreadable one answers the request, a capability gap fails the call. +func (v *verifyContext) analysisArgs(req *pb.RunAnalysisRequest) (runtime.AnalysisArgs, *pb.RunAnalysisResponse, error) { + subject, err := v.subject(req.SubjectSymbolId) + if err != nil { + return runtime.AnalysisArgs{}, &pb.RunAnalysisResponse{Error: err.Error()}, nil + } + args := runtime.AnalysisArgs{Subject: subject} + for _, arg := range req.Arguments { + val, resp, err := v.analysisArgument(arg) + if resp != nil || err != nil { + return runtime.AnalysisArgs{}, resp, err + } + args.Positional = append(args.Positional, val) + } + if len(req.NamedArguments) > 0 { + // Read in name order so a failure names the same argument every time. + names := make([]string, 0, len(req.NamedArguments)) + for name := range req.NamedArguments { + names = append(names, name) + } + sort.Strings(names) + args.Named = make(map[string]runtime.Value, len(names)) + for _, name := range names { + val, resp, err := v.analysisArgument(req.NamedArguments[name]) + if resp != nil || err != nil { + return runtime.AnalysisArgs{}, resp, err + } + args.Named[name] = val + } + } + return args, nil, nil +} + +// caseRun is one run of a case: a verification case's verdict beside the run. +type caseRun struct { + result runtime.AnalysisResult + verified *runtime.VerificationResult +} + +// verdicts are the verification verdicts the run answered, none for an analysis case. +func (r caseRun) verdicts() []runtime.VerificationVerdict { + if r.verified == nil { + return nil + } + return append([]runtime.VerificationVerdict{r.verified.Verdict}, r.verified.Subcases...) +} + +// answer is what the run established: a verification case its verdict, an +// analysis case its outputs. +func (r caseRun) answer(err error) analysis.Answer { + if r.verified != nil { + return analysis.VerificationAnswer(*r.verified, err) + } + return analysis.CaseAnswer(r.result, err) +} + +// runCaseOn runs the case once on the context's runtime; a run failing after +// computing something keeps that beside the error. +func (v *verifyContext) runCaseOn(sym *symbols.Symbol, args runtime.AnalysisArgs) (caseRun, error) { + if runtime.IsVerificationCaseSymbol(sym) { + verified, err := v.runtime.RunVerification(sym, args, v.declaringScope(sym), nil) + if err != nil { + return caseRun{}, fmt.Errorf("verification run failed: %w", err) + } + return caseRun{result: verified.Run, verified: &verified}, nil + } + result, err := v.runtime.RunAnalysis(sym, args, v.declaringScope(sym), nil) + if err != nil { + return caseRun{result: result}, fmt.Errorf("analysis run failed: %w", err) + } + return caseRun{result: result}, nil +} + +// runCase puts one run of the case on the context's runtime to the engines. +func (v *verifyContext) runCase(ctx context.Context, sym *symbols.Symbol, args runtime.AnalysisArgs) (caseRun, analysis.Plan, error) { + return perform(ctx, v, v.cached.Index.GetFQN(sym), func(*runtime.Context) (caseRun, error) { + return v.runCaseOn(sym, args) + }, caseRun.answer) +} + +// exploreAnalysis runs the case once per linearization on a context of its own +// and answers every distinct outcome of outputs and verdicts. +func (s *Service) exploreAnalysis(ctx context.Context, schedule runtime.SchedulePolicy, v *verifyContext, req *pb.RunAnalysisRequest, sym *symbols.Symbol) (*pb.RunAnalysisResponse, error) { + x, err := s.explore(ctx, v.cached.Index.GetFQN(sym), schedule, v.engine, v.cached, func(rt *runtime.Context) (runtime.Outcome, error) { + fresh := v.on(rt) + args, resp, err := fresh.analysisArgs(req) + if err != nil { + return runtime.Outcome{}, err + } + if resp != nil { + return runtime.Outcome{}, errors.New(resp.Error) + } + run, err := fresh.runCaseOn(sym, args) + if err != nil { + return runtime.Outcome{}, err + } + return rt.VerifiedOutcome(run.result, run.verdicts()), nil + }) + if err != nil { + return nil, err + } + return analysisResponse(&pb.RunAnalysisResponse{Outcomes: x.outcomes, Exploration: x.status}, s.standingOf(x.plan)), nil +} + +// runRoots are the objects a case run reports: its subject and every object an +// output or a reported evaluation names — a trade study's alternatives. +func (v *verifyContext) runRoots(subject *runtime.Instance, outputs []runtime.CalcOutputValue, evaluations []runtime.AnalysisEvaluation) []*runtime.Instance { + roots := []*runtime.Instance{subject} + for _, out := range outputs { + roots = append(roots, v.namedInstances(out.Value)...) + } + for _, e := range evaluations { + for _, arg := range e.Arguments { + roots = append(roots, v.namedInstances(arg)...) + } + roots = append(roots, v.namedInstances(e.Result)...) + } + return roots +} + +// caseEvaluations spells for the wire each application the run made of one of +// the case's calcs as a function value, in the order made. +func (v *verifyContext) caseEvaluations(evaluations []runtime.AnalysisEvaluation) []*pb.CaseEvaluation { + if len(evaluations) == 0 { + return nil + } + out := make([]*pb.CaseEvaluation, 0, len(evaluations)) + for _, e := range evaluations { + pe := &pb.CaseEvaluation{FunctionId: e.Function, Selected: e.Selected, Tied: e.Tied} + for _, arg := range e.Arguments { + pe.Arguments = append(pe.Arguments, v.service.valueToProto(v.runtime, arg, v.cached.Index)) + } + if e.Error != nil { + pe.Error = e.Error.Error() + } else { + pe.Result = v.service.valueToProto(v.runtime, e.Result, v.cached.Index) + } + out = append(out, pe) + } + return out +} + +// namedInstances are the objects a value's wire form refers to by identity: the +// instance it is or a variant materialized, the object a function was read off, +// and those named within a sequence's, a set's or an array's elements. +func (v *verifyContext) namedInstances(val runtime.Value) []*runtime.Instance { + if id, ok := val.Object(); ok { + if inst, ok := v.runtime.Instance(id); ok { + return []*runtime.Instance{inst} + } + return nil + } + if val.Kind == runtime.ValFunction { + if self := val.FunctionSelf(); self != nil { + return []*runtime.Instance{self} + } + return nil + } + var elements []runtime.Value + switch val.Kind { + case runtime.ValSequence: + if seq := val.Sequence(); seq != nil { + elements = seq.Elements() + } + case runtime.ValSet: + if set := val.Set(); set != nil { + elements = set.Elements() + } + case runtime.ValArray: + if arr := val.Array(); arr != nil { + elements = arr.Elements + } + } + var out []*runtime.Instance + for _, elem := range elements { + out = append(out, v.namedInstances(elem)...) + } + return out +} + +// instanceGraphs is the instance graph of every root, in root order, each +// object reported once; a nil root contributes nothing. +func (v *verifyContext) instanceGraphs(roots []*runtime.Instance) []*pb.Instance { + var all []*pb.Instance + seen := make(map[int64]bool) + for _, root := range roots { + if root == nil || seen[root.ID] { + continue + } + for _, inst := range v.instanceGraph(root) { + if !seen[inst.Id] { + seen[inst.Id] = true + all = append(all, inst) + } + } + } + return all +} + +// verificationVerdicts spells for the wire what the bodies of verification cases +// answered, in the order they were reported. requirementID names the requirement +// they were reported for, so a response covering several keeps them apart. +func (v *verifyContext) verificationVerdicts(verdicts []runtime.VerificationVerdict, requirementID string) []*pb.VerificationVerdict { + if len(verdicts) == 0 { + return nil + } + out := make([]*pb.VerificationVerdict, 0, len(verdicts)) + for _, verdict := range verdicts { + out = append(out, &pb.VerificationVerdict{ + CaseId: verdict.Case, + Kind: string(verdict.Kind), + Detail: verdict.Detail, + Subcase: verdict.Subcase, + RequirementId: requirementID, + }) + } + return out +} + +// analysisArgument reads one argument off the wire against the model's index, +// so a quantity keeps the units it is commensurable with. One the service +// cannot read answers the request; one needing a capability it lacks fails the call. +func (v *verifyContext) analysisArgument(arg *pb.Value) (runtime.Value, *pb.RunAnalysisResponse, error) { + if err := v.service.requireValueCapabilities(arg); err != nil { + return runtime.Value{}, nil, err + } + val, err := protoconv.ProtoToRuntimeValue(v.runtime, arg, v.cached.Index, v.sem()) + if err != nil { + return runtime.Value{}, &pb.RunAnalysisResponse{ + Error: fmt.Sprintf("analysis argument could not be read: %v", err), + FailureReason: failureReason(err), + }, nil + } + return val, nil, nil +} + +// analysisVerdict spells what a check of the case decided as a Verdict of kind +// "objective" or "assertion": satisfied holds, not satisfied names the violated +// condition, and undecided is an evaluation failure rather than an answer. +func (v *verifyContext) analysisVerdict(verdict *runtime.AnalysisVerdict, subject *runtime.Instance) *pb.Verdict { + out := &pb.Verdict{ + Kind: verdict.Kind, + Element: verdict.Name, + Holds: verdict.Status == runtime.VerdictSatisfied, + } + if verdict.Symbol != nil { + out.ElementId = namedFQN(v.cached.Index, verdict.Symbol) + } + if subject != nil { + out.InstanceId = subject.ID + out.InstanceTypeId = namedFQN(v.cached.Index, subject.Type) + } + switch verdict.Status { + case runtime.VerdictNotSatisfied: + out.Condition = verdict.Detail + case runtime.VerdictUndecided: + out.Error = verdict.Detail + out.FailureReason = pb.FailureReason_FAILURE_REASON_EVALUATION + } + return out +} diff --git a/internal/grpc/analysis_instances_test.go b/internal/frontend/grpc/analysis_instances_test.go similarity index 100% rename from internal/grpc/analysis_instances_test.go rename to internal/frontend/grpc/analysis_instances_test.go diff --git a/internal/frontend/grpc/analysis_test.go b/internal/frontend/grpc/analysis_test.go new file mode 100644 index 0000000000..7d454eec90 --- /dev/null +++ b/internal/frontend/grpc/analysis_test.go @@ -0,0 +1,326 @@ +package grpc + +import ( + "context" + "strings" + "testing" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +const analysisModelSource = `package An { + private import ScalarValues::*; + + part def Ship { + attribute cost : Real default = 5.0; + attribute other : Real default = 7.0; + attribute cap : Real; + } + + calc def Sum { + in a : Real; + in b : Real; + return : Real = a + b; + } + + analysis def CostAnalysis { + subject s : Ship; + in limit : Real = 20.0; + out total : Real = Sum(s.cost, s.other); + objective affordable { + require constraint { total <= limit } + } + } + + part ship : Ship; + part barge : Ship { + attribute :>> cost = 30.0; + } + + analysis shipCost : CostAnalysis { + subject s = ship; + } + + analysis plain { + out x : Real = 1.0 + 2.0; + } + + analysis undecided { + subject s = ship; + out total : Real = Sum(s.cost, s.other); + objective obj { + require constraint { total <= s.cap } + } + } +} +` + +func runAnalysis(t *testing.T, srv *Service, req *pb.RunAnalysisRequest) *pb.RunAnalysisResponse { + t.Helper() + resp, err := srv.RunAnalysis(context.Background(), req) + if err != nil { + t.Fatalf("RunAnalysis: %v", err) + } + return resp +} + +func realOutput(t *testing.T, resp *pb.RunAnalysisResponse, name string) float64 { + t.Helper() + for _, out := range resp.Outputs { + if out.Name == name { + return out.Value.GetRealValue() + } + } + t.Fatalf("no output %q in %v", name, resp.Outputs) + return 0 +} + +// TestRunAnalysisUsageBindsItsOwnSubject verifies a usage whose subject is bound +// in the model runs with no request subject, reporting its outputs and the +// verdict of its objective. +func TestRunAnalysisUsageBindsItsOwnSubject(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, analysisModelSource, "analysis-usage") + + resp := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "An::shipCost"}) + if resp.Error != "" { + t.Fatalf("RunAnalysis reported %q", resp.Error) + } + if got := realOutput(t, resp, "total"); got != 12.0 { + t.Errorf("total = %v, want 12", got) + } + if len(resp.Verdicts) != 1 { + t.Fatalf("got %d verdicts, want the objective's: %v", len(resp.Verdicts), resp.Verdicts) + } + v := resp.Verdicts[0] + if v.Kind != "objective" || v.Element != "affordable" || v.ElementId != "An::CostAnalysis::affordable" { + t.Errorf("verdict names %s %q (%s), want objective affordable (An::CostAnalysis::affordable)", v.Kind, v.Element, v.ElementId) + } + if !v.Holds || v.Error != "" || v.Condition != "" { + t.Errorf("verdict = %v, want it to hold", v) + } + if v.InstanceId == 0 || v.InstanceTypeId != "An::ship" { + t.Errorf("verdict is about instance %d of %q, want the ship the usage binds", v.InstanceId, v.InstanceTypeId) + } + if len(resp.Instances) == 0 || resp.Instances[0].Id != v.InstanceId { + t.Errorf("instances = %v, want the bound subject first", resp.Instances) + } +} + +// TestRunAnalysisWithoutObjectiveReportsOutputsAlone verifies a case stating no +// objective answers with its outputs and no verdict. +func TestRunAnalysisWithoutObjectiveReportsOutputsAlone(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, analysisModelSource, "analysis-plain") + + resp := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "An::plain"}) + if resp.Error != "" { + t.Fatalf("RunAnalysis reported %q", resp.Error) + } + if got := realOutput(t, resp, "x"); got != 3.0 { + t.Errorf("x = %v, want 3", got) + } + if len(resp.Verdicts) != 0 || len(resp.Instances) != 0 { + t.Errorf("verdicts = %v, instances = %d; want none", resp.Verdicts, len(resp.Instances)) + } +} + +// TestRunAnalysisOnSubject verifies a definition runs against the object named, +// that a violated objective names its condition, and that the subject's graph +// is reported with the verdict. +func TestRunAnalysisOnSubject(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, analysisModelSource, "analysis-subject") + + resp := runAnalysis(t, srv, &pb.RunAnalysisRequest{ + ModelHash: hash, + SymbolId: "An::CostAnalysis", + SubjectSymbolId: "An::barge", + }) + if resp.Error != "" { + t.Fatalf("RunAnalysis reported %q", resp.Error) + } + if got := realOutput(t, resp, "total"); got != 37.0 { + t.Errorf("total = %v, want 37", got) + } + if len(resp.Verdicts) != 1 { + t.Fatalf("got %d verdicts, want 1: %v", len(resp.Verdicts), resp.Verdicts) + } + v := resp.Verdicts[0] + if v.Holds || v.Error != "" { + t.Errorf("verdict = %v, want it not to hold with no error", v) + } + if v.Condition != "total <= limit" { + t.Errorf("condition = %q, want the violated constraint", v.Condition) + } + if v.InstanceId == 0 || v.InstanceTypeId != "An::barge" { + t.Errorf("verdict is about instance %d of %q, want the barge", v.InstanceId, v.InstanceTypeId) + } + if len(resp.Instances) == 0 || resp.Instances[0].Id != v.InstanceId { + t.Errorf("instances = %v, want the subject first", resp.Instances) + } +} + +// TestRunAnalysisArgumentsBindInputs verifies positional and named arguments +// bind the case's inputs, the subject excluded from the positional ones. +func TestRunAnalysisArgumentsBindInputs(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, analysisModelSource, "analysis-args") + + named := runAnalysis(t, srv, &pb.RunAnalysisRequest{ + ModelHash: hash, + SymbolId: "An::CostAnalysis", + SubjectSymbolId: "An::barge", + NamedArguments: map[string]*pb.Value{"limit": {Kind: &pb.Value_RealValue{RealValue: 50.0}}}, + }) + if named.Error != "" || len(named.Verdicts) != 1 || !named.Verdicts[0].Holds { + t.Errorf("limit = 50 by name: error %q, verdicts %v; want the objective to hold", named.Error, named.Verdicts) + } + + positional := runAnalysis(t, srv, &pb.RunAnalysisRequest{ + ModelHash: hash, + SymbolId: "An::CostAnalysis", + SubjectSymbolId: "An::barge", + Arguments: []*pb.Value{{Kind: &pb.Value_RealValue{RealValue: 10.0}}}, + }) + if positional.Error != "" || len(positional.Verdicts) != 1 || positional.Verdicts[0].Holds { + t.Errorf("limit = 10 positionally: error %q, verdicts %v; want the objective violated", positional.Error, positional.Verdicts) + } + + unknown := runAnalysis(t, srv, &pb.RunAnalysisRequest{ + ModelHash: hash, + SymbolId: "An::CostAnalysis", + SubjectSymbolId: "An::barge", + NamedArguments: map[string]*pb.Value{"nope": {Kind: &pb.Value_RealValue{RealValue: 1.0}}}, + }) + if !strings.Contains(unknown.Error, "nope") || unknown.FailureReason != pb.FailureReason_FAILURE_REASON_EVALUATION { + t.Errorf("unknown argument: error %q (%v), want it named as an evaluation failure", unknown.Error, unknown.FailureReason) + } +} + +// TestRunAnalysisUndecidedObjective verifies an objective that cannot be +// evaluated is reported as an error on its verdict, not as a failed run. +func TestRunAnalysisUndecidedObjective(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, analysisModelSource, "analysis-undecided") + + resp := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "An::undecided"}) + if resp.Error != "" { + t.Fatalf("RunAnalysis reported %q", resp.Error) + } + if got := realOutput(t, resp, "total"); got != 12.0 { + t.Errorf("total = %v, want 12", got) + } + if len(resp.Verdicts) != 1 { + t.Fatalf("got %d verdicts, want 1: %v", len(resp.Verdicts), resp.Verdicts) + } + v := resp.Verdicts[0] + if v.Holds || v.Error == "" || v.FailureReason != pb.FailureReason_FAILURE_REASON_EVALUATION { + t.Errorf("verdict = %v, want an undecided one carrying its reason", v) + } + if !strings.Contains(v.Error, "cap") { + t.Errorf("error = %q, want it to name the valueless feature", v.Error) + } +} + +// TestRunAnalysisFailures verifies the in-band failures: an unbound subject and +// an unknown symbol are evaluation failures, another kind of element is a +// wrong request. +func TestRunAnalysisFailures(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, analysisModelSource, "analysis-failures") + + unbound := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "An::CostAnalysis"}) + if !strings.Contains(unbound.Error, "subject") || unbound.FailureReason != pb.FailureReason_FAILURE_REASON_EVALUATION { + t.Errorf("unbound subject: error %q (%v), want an evaluation failure naming the subject", unbound.Error, unbound.FailureReason) + } + if len(unbound.Outputs) != 0 || len(unbound.Evaluations) != 0 { + t.Errorf("unbound subject answered %v %v, want no output and no evaluation", unbound.Outputs, unbound.Evaluations) + } + // A run that never started decided nothing: its objective is reported undecided, not omitted. + if len(unbound.Verdicts) != 1 || unbound.Verdicts[0].Holds || !strings.Contains(unbound.Verdicts[0].Error, "subject") { + t.Errorf("unbound subject verdicts = %v, want the objective undecided by the unbound subject", unbound.Verdicts) + } + + wrongKind := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "An::ship"}) + if wrongKind.FailureReason != pb.FailureReason_FAILURE_REASON_WRONG_KIND || !strings.Contains(wrongKind.Error, "An::ship") { + t.Errorf("part usage: error %q (%v), want WRONG_KIND naming it", wrongKind.Error, wrongKind.FailureReason) + } + + unknown := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "An::NoSuchCase"}) + if !strings.Contains(unknown.Error, "NoSuchCase") { + t.Errorf("error = %q, want it to name the missing symbol", unknown.Error) + } + + badSubject := runAnalysis(t, srv, &pb.RunAnalysisRequest{ + ModelHash: hash, + SymbolId: "An::CostAnalysis", + SubjectSymbolId: "An::NoSuchPart", + }) + if !strings.Contains(badSubject.Error, "NoSuchPart") { + t.Errorf("error = %q, want it to name the missing subject", badSubject.Error) + } +} + +// A subject named by a path from a declaration, as `Fleet::convoy.lead`, is the +// object the path reaches in an object of the declaration built for the call, so +// what the assembly binds on it is in force — on one run and on every explored run. +func TestRunAnalysisOnANestedSubject(t *testing.T) { + const source = `package Fleet { + private import ScalarValues::*; + part def Ship { attribute cost : Real default = 5.0; } + analysis def Cost { subject s : Ship; out total : Real = s.cost; } + part def Convoy { + part lead : Ship { attribute :>> cost = 30.0; } + part escorts : Ship[2]; + } + part convoy : Convoy; +} +` + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, source, "analysis-nested-subject") + for _, schedule := range []string{"", "explore"} { + resp := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "Fleet::Cost", SubjectSymbolId: "Fleet::convoy.lead", Schedule: schedule}) + if resp.Error != "" { + t.Fatalf("schedule %q: RunAnalysis reported %q", schedule, resp.Error) + } + if schedule == "" { + if got := realOutput(t, resp, "total"); got != 30.0 { + t.Errorf("total = %v, want the convoy's 30 on its lead", got) + } + if len(resp.Instances) == 0 || resp.Instances[0].TypeSymbolId != "Fleet::Convoy::lead" { + t.Errorf("instances = %v, want the lead the path reached first", resp.Instances) + } + continue + } + if len(resp.Outcomes) != 1 || resp.Outcomes[0].Outputs["total"].GetRealValue() != 30.0 { + t.Errorf("explored outcomes = %v, want one with total 30", resp.Outcomes) + } + } + resp := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "Fleet::Cost", SubjectSymbolId: "Fleet::convoy.escorts[2]"}) + if resp.Error != "" || realOutput(t, resp, "total") != 5.0 { + t.Errorf("on escorts[2]: error %q, total %v; want the default 5", resp.Error, resp.Outputs) + } + for path, want := range map[string]string{ + "Fleet::convoy.tug": `Fleet::convoy has no feature "tug"`, + "Fleet::convoy.escorts": "escorts of Fleet::convoy holds 2 objects: pick one by index", + "Fleet::convoy.escorts[3]": "escorts[3] names none", + "#1": "names an object by id", + "Fleet::nobody.lead": "symbol not found: Fleet::nobody", + } { + resp := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "Fleet::Cost", SubjectSymbolId: path}) + if !strings.Contains(resp.Error, want) { + t.Errorf("subject %s: error %q, want %q", path, resp.Error, want) + } + } +} + +// TestRunAnalysisUnknownModel verifies an evicted or unknown model fails the +// call the way every other model-scoped RPC does. +func TestRunAnalysisUnknownModel(t *testing.T) { + srv := mustNewService(t, 10) + _, err := srv.RunAnalysis(context.Background(), &pb.RunAnalysisRequest{ModelHash: "nope", SymbolId: "An::plain"}) + if err == nil { + t.Fatal("RunAnalysis on an unknown model should fail the call") + } +} diff --git a/internal/grpc/attributes.go b/internal/frontend/grpc/attributes.go similarity index 92% rename from internal/grpc/attributes.go rename to internal/frontend/grpc/attributes.go index 4b6ca266e5..cfd10b28c0 100644 --- a/internal/grpc/attributes.go +++ b/internal/frontend/grpc/attributes.go @@ -2,11 +2,12 @@ package grpc import ( pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lexer" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // attributesOf reports the attributes an element declares and inherits, a @@ -172,7 +173,7 @@ func (sc *SymbolContext) attributeValue(sym *symbols.Symbol) (*pb.Value, string) if !ok { return nil, unit } - return ValueToProto(runtime.Value{Kind: runtime.ValConst, Const: val}, sc.Index), unit + return protoconv.ValueToProto(runtime.Value{Kind: runtime.ValConst, Const: val}, sc.Index), unit } // writesValue reports whether an attribute's declaration states a default of @@ -185,5 +186,5 @@ func writesValue(sym *symbols.Symbol) bool { // unquote reads the text a string literal spells, so a reported attribute value // is the same string evaluating the literal answers. func unquote(s string) string { - return lexer.StringValue(s) + return source.StringValue(s) } diff --git a/internal/grpc/attributes_test.go b/internal/frontend/grpc/attributes_test.go similarity index 100% rename from internal/grpc/attributes_test.go rename to internal/frontend/grpc/attributes_test.go diff --git a/internal/grpc/authoring_test.go b/internal/frontend/grpc/authoring_test.go similarity index 98% rename from internal/grpc/authoring_test.go rename to internal/frontend/grpc/authoring_test.go index a3e7d12222..022cc75dee 100644 --- a/internal/grpc/authoring_test.go +++ b/internal/frontend/grpc/authoring_test.go @@ -7,7 +7,7 @@ import ( "testing" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/edit" + "github.com/Open-MBEE/OpenSysML/internal/check/edit" ) func addMemberOp(owner, kind, name string) *pb.EditOperation { diff --git a/internal/frontend/grpc/budget_test.go b/internal/frontend/grpc/budget_test.go new file mode 100644 index 0000000000..96c2f85897 --- /dev/null +++ b/internal/frontend/grpc/budget_test.go @@ -0,0 +1,100 @@ +package grpc + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// TestNewServiceResolvesBudgets: the service reads its bounds once at +// construction, defaulting when the variables are unset. +func TestNewServiceResolvesBudgets(t *testing.T) { + t.Run("default", func(t *testing.T) { + clearBudgetEnv(t) + svc, err := NewService(4, "test") + if err != nil { + t.Fatalf("NewService: %v", err) + } + if svc.budgets != runtime.DefaultBudgets() { + t.Errorf("budgets = %+v, want the defaults %+v", svc.budgets, runtime.DefaultBudgets()) + } + }) + + t.Run("raised", func(t *testing.T) { + clearBudgetEnv(t) + t.Setenv(runtime.MaxStepsEnvVar, "1234567") + t.Setenv(runtime.MaxActionStepsEnvVar, "55555") + t.Setenv(runtime.MaxElementsEnvVar, "4321") + svc, err := NewService(4, "test") + if err != nil { + t.Fatalf("NewService: %v", err) + } + if svc.budgets.MaxSteps != 1234567 || svc.budgets.MaxActionSteps != 55555 || + svc.budgets.MaxElements != 4321 { + t.Errorf("budgets = %+v, want MaxSteps 1234567, MaxActionSteps 55555 and MaxElements 4321", svc.budgets) + } + }) + + t.Run("applied_to_every_context", func(t *testing.T) { + clearBudgetEnv(t) + t.Setenv(runtime.MaxStepsEnvVar, "777") + svc, err := NewService(4, "test") + if err != nil { + t.Fatalf("NewService: %v", err) + } + ctx, _ := svc.newRuntime(&CachedModel{Index: symbols.NewIndex()}) + if got := ctx.Budgets(); got != svc.budgets { + t.Errorf("context bounds = %+v, want the service's %+v", got, svc.budgets) + } + }) +} + +// clearBudgetEnv unsets every budget variable for the duration of the test, so +// the surrounding environment cannot decide the outcome. +func clearBudgetEnv(t *testing.T) { + t.Helper() + for _, name := range []string{ + runtime.MaxStepsEnvVar, + runtime.MaxActionStepsEnvVar, + runtime.MaxStateEventsEnvVar, + runtime.MaxDoStepsEnvVar, + runtime.MaxElementsEnvVar, + runtime.MaxCalcDepthEnvVar, + } { + t.Setenv(name, "") + } +} + +// TestNewServiceRejectsUnusableBudget: a value that is not a positive integer +// fails service construction with a message naming the variable, rather than +// falling back to the default silently. +func TestNewServiceRejectsUnusableBudget(t *testing.T) { + vars := []string{ + runtime.MaxStepsEnvVar, + runtime.MaxActionStepsEnvVar, + runtime.MaxStateEventsEnvVar, + runtime.MaxDoStepsEnvVar, + runtime.MaxElementsEnvVar, + runtime.MaxCalcDepthEnvVar, + } + for _, name := range vars { + for _, value := range []string{"0", "-1", "plenty"} { + t.Run(name+"/"+value, func(t *testing.T) { + clearBudgetEnv(t) + t.Setenv(name, value) + svc, err := NewService(4, "test") + if err == nil { + t.Fatalf("NewService accepted %s=%q", name, value) + } + if svc != nil { + t.Error("expected no service alongside the error") + } + if !strings.Contains(err.Error(), name) { + t.Errorf("error %q does not name %s", err, name) + } + }) + } + } +} diff --git a/internal/grpc/cache.go b/internal/frontend/grpc/cache.go similarity index 77% rename from internal/grpc/cache.go rename to internal/frontend/grpc/cache.go index da35be30e2..049f32a650 100644 --- a/internal/grpc/cache.go +++ b/internal/frontend/grpc/cache.go @@ -8,16 +8,16 @@ import ( "connectrpc.com/connect" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/passes" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // CachedDocument is one parsed document of a model. @@ -25,7 +25,7 @@ type CachedDocument struct { Root *ast.RootNamespace Source *source.SourceFile // For diagnostic line/col mapping ParseDiags []parser.Diagnostic // Parser diagnostics - PassesDiags []passes.Diagnostic // Semantic pass diagnostics (name-resolution, type, constraint) + PassesDiags []diag.Diagnostic // Semantic pass diagnostics (name-resolution, type, constraint) } // CachedModel holds parsed model data with semantic analysis results @@ -36,6 +36,9 @@ type CachedModel struct { Documents []*CachedDocument Index *symbols.Index // For symbol lookups by FQN Library libs.Source // the files the library in Index was built from, for their spans' text + // Mode is the conformance strictness the parse request asked for; an edit's + // notation is judged at the same strictness. + Mode diag.ConformanceMode symCtxOnce sync.Once symCtx *SymbolContext @@ -43,6 +46,11 @@ type CachedModel struct { // idle are the workers requests have given back, warm with what they resolved. idleMu sync.Mutex idle []*analysis.Worker + + // objects are the objects Instantiate created for the model, on a runtime of + // their own that outlives the requests; nil until the first Instantiate. + objectsMu sync.Mutex + objects *heldObjects } // maxIdleWorkers bounds the warm workers a model keeps: as many as can run at once, so a @@ -82,9 +90,10 @@ func (m *CachedModel) worker() (*analysis.Worker, func()) { // Semantics is the model-derived runtime part as an analysis.Model builds one. func (m *CachedModel) Semantics() (*runtime.Model, error) { resolver := resolve.New(m.Index) - sem := semantics.NewModel(resolver) + sem := passes.NewTypedModel(resolver) sem.SetSourceText(cachedSourceText(m)) model := runtime.NewModel(sem, resolver) + model.SetExpressionParser(parser.ParseOneExpression) for _, doc := range m.Documents { model.RegisterSource(doc.Source) } @@ -129,7 +138,7 @@ func (m *CachedModel) PrimaryRoot() *symbols.Scope { } // SoleDocument is the model's one document, for an operation defined on a single -// document's own source — editing it, or writing it back out. A model of several +// document's own source, such as writing it back out. A model of several // documents is refused rather than answered about one of them. func (m *CachedModel) SoleDocument() (*CachedDocument, error) { if len(m.Documents) > 1 { @@ -203,7 +212,26 @@ func (c *Cache) Put(hash string, model *CachedModel) { elem.Value.(*cacheEntry).value = model return } + c.insert(hash, model) +} + +// Add caches model under hash unless the hash is already cached, and returns +// the model cached under it: two parses of one model racing to the cache end +// up sharing the one entry, and the objects held on it, rather than replacing it. +func (c *Cache) Add(hash string, model *CachedModel) *CachedModel { + c.mu.Lock() + defer c.mu.Unlock() + + if elem, ok := c.items[hash]; ok { + c.lruList.MoveToFront(elem) + return elem.Value.(*cacheEntry).value + } + c.insert(hash, model) + return model +} +// insert adds a new entry under the write lock, evicting the LRU at capacity. +func (c *Cache) insert(hash string, model *CachedModel) { // Evict if at capacity if c.lruList.Len() >= c.maxSize { oldest := c.lruList.Back() diff --git a/internal/frontend/grpc/cache_test.go b/internal/frontend/grpc/cache_test.go new file mode 100644 index 0000000000..622feb3c5e --- /dev/null +++ b/internal/frontend/grpc/cache_test.go @@ -0,0 +1,258 @@ +package grpc + +import ( + "fmt" + "runtime" + "sync" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// mustNewCache builds a cache, failing the test if construction errors. +func mustNewCache(t *testing.T, maxSize int) *Cache { + t.Helper() + cache, err := NewCache(maxSize) + if err != nil { + t.Fatalf("NewCache(%d): %v", maxSize, err) + } + return cache +} + +// mustNewService builds a service, failing the test if construction errors. +func mustNewService(t *testing.T, cacheSize int) *Service { + t.Helper() + srv, err := NewService(cacheSize, "test") + if err != nil { + t.Fatalf("NewService(%d): %v", cacheSize, err) + } + return srv +} + +func mustNewServiceWithout(t *testing.T, unavailable ...string) *Service { + t.Helper() + srv, err := NewServiceWithUnavailableCapabilitiesForTesting(10, "test", unavailable) + if err != nil { + t.Fatalf("NewServiceWithUnavailableCapabilitiesForTesting: %v", err) + } + t.Cleanup(srv.Close) + return srv +} + +func TestCachePutGet(t *testing.T) { + cache := mustNewCache(t, 2) // Max size 2 + + hash1 := "abc123" + model1 := &CachedModel{ + Documents: []*CachedDocument{{Root: &ast.RootNamespace{}}}, + Index: symbols.NewIndex(), + } + + cache.Put(hash1, model1) + + got, ok := cache.Get(hash1) + if !ok { + t.Fatal("expected cache hit") + } + if got != model1 { + t.Error("got different model") + } +} + +func TestCacheMiss(t *testing.T) { + cache := mustNewCache(t, 2) + + _, ok := cache.Get("nonexistent") + if ok { + t.Error("expected cache miss") + } +} + +func TestCacheLRUEviction(t *testing.T) { + cache := mustNewCache(t, 2) + + model1 := &CachedModel{Documents: []*CachedDocument{{Root: &ast.RootNamespace{}}}} + model2 := &CachedModel{Documents: []*CachedDocument{{Root: &ast.RootNamespace{}}}} + model3 := &CachedModel{Documents: []*CachedDocument{{Root: &ast.RootNamespace{}}}} + + cache.Put("hash1", model1) + cache.Put("hash2", model2) + cache.Put("hash3", model3) // Should evict hash1 + + _, ok := cache.Get("hash1") + if ok { + t.Error("expected hash1 to be evicted") + } + + _, ok = cache.Get("hash2") + if !ok { + t.Error("expected hash2 to still be cached") + } + + _, ok = cache.Get("hash3") + if !ok { + t.Error("expected hash3 to be cached") + } +} + +// TestCacheAddKeepsTheFirst: Add under a cached hash hands back the entry +// already there and leaves it in place, where Put would replace it. +func TestCacheAddKeepsTheFirst(t *testing.T) { + cache := mustNewCache(t, 2) + first := &CachedModel{Documents: []*CachedDocument{{Root: &ast.RootNamespace{}}}} + second := &CachedModel{Documents: []*CachedDocument{{Root: &ast.RootNamespace{}}}} + + if cache.Add("hash", first) != first { + t.Fatal("Add of a new hash did not hand back the model added") + } + if cache.Add("hash", second) != first { + t.Error("Add under a cached hash did not hand back the entry already cached") + } + if got, _ := cache.Get("hash"); got != first { + t.Error("Add under a cached hash replaced the entry") + } + + // Adding refreshes the entry: it is the most recently used, so it outlives + // one that was only read earlier. + cache.Add("other", &CachedModel{}) + cache.Add("hash", second) + cache.Add("third", &CachedModel{}) + if _, ok := cache.Get("other"); ok { + t.Error("expected other to be evicted before the re-added hash") + } + if got, ok := cache.Get("hash"); !ok || got != first { + t.Error("expected the first entry under hash to survive") + } +} + +// TestParseModelSharesOneEntry: parses of one model racing to the cache all +// come back with the same entry, so objects held on it stay reachable. +func TestParseModelSharesOneEntry(t *testing.T) { + srv := mustNewService(t, 10) + const workers = 16 + models := make([]*CachedModel, workers) + var wg sync.WaitGroup + for i := range models { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, models[i] = srv.parseModel([]sourceInput{{ + name: "lot.sysml", language: "sysml", content: "package Lot { part def Cone; part cone : Cone; }", kind: source.KindSysML, + }}, diag.ConformanceModeOf(false)) + }(i) + } + wg.Wait() + for i, model := range models[1:] { + if model != models[0] { + t.Fatalf("parse %d came back with an entry of its own", i+1) + } + } +} + +func TestCacheThreadSafety(t *testing.T) { + cache := mustNewCache(t, 100) + var wg sync.WaitGroup + + // Concurrent writes + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + model := &CachedModel{Documents: []*CachedDocument{{Root: &ast.RootNamespace{}}}} + cache.Put(fmt.Sprintf("key%d", n), model) + }(i) + } + + // Concurrent reads + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + cache.Get(fmt.Sprintf("key%d", n)) + }(i) + } + + wg.Wait() + // If we reach here without race detector firing, thread safety works +} + +func TestCacheInvalidMaxSize(t *testing.T) { + if _, err := NewCache(0); err == nil { + t.Error("expected error for maxSize <= 0") + } + if _, err := NewService(0, "test"); err == nil { + t.Error("expected error for cacheSize <= 0") + } +} + +// A request holds a model's worker alone, hands it on warm when it is done, and a request +// arriving while every worker is held gets one of its own rather than waiting. +func TestCachedModelHandsWorkersOn(t *testing.T) { + model := &CachedModel{Documents: []*CachedDocument{{Root: &ast.RootNamespace{}}}, Index: symbols.NewIndex()} + model.Index.Freeze() + + first, releaseFirst := model.worker() + second, releaseSecond := model.worker() + if first == second { + t.Fatal("two requests holding workers at once were handed the same one") + } + releaseFirst() + third, releaseThird := model.worker() + if third != first { + t.Fatal("a request after the first released was not handed its worker warm") + } + releaseThird() + releaseSecond() + if want := min(2, maxIdleWorkers()); len(model.idle) != want { + t.Fatalf("%d idle workers after every release, want %d", len(model.idle), want) + } + + // What a request failed to resolve is the request's, not the next holder's. + w, release := model.worker() + w.Model.Resolver().Diagnostics = append(w.Model.Resolver().Diagnostics, resolve.Diagnostic{Message: "nosuch"}) + release() + again, releaseAgain := model.worker() + defer releaseAgain() + if again != w { + t.Fatal("the released worker was not the one handed on") + } + if got := len(again.Model.Resolver().Diagnostics); got != 0 { + t.Fatalf("a worker handed on carries %d diagnostics of an earlier request, want none", got) + } +} + +// A burst of requests wider than the machine leaves the model holding no more warm workers +// than can run at once; the rest are let go with the requests that built them. +func TestCachedModelKeepsABoundedNumberOfIdleWorkers(t *testing.T) { + model := &CachedModel{Documents: []*CachedDocument{{Root: &ast.RootNamespace{}}}, Index: symbols.NewIndex()} + model.Index.Freeze() + + bound := maxIdleWorkers() + releases := make([]func(), 0, bound+3) + for range bound + 3 { + _, release := model.worker() + releases = append(releases, release) + } + for _, release := range releases { + release() + } + if got := len(model.idle); got != bound { + t.Fatalf("%d idle workers after a burst of %d, want the bound %d", got, bound+3, bound) + } + _, release := model.worker() + if got := len(model.idle); got != bound-1 { + t.Fatalf("%d idle workers while a request after the burst holds one, want %d", got, bound-1) + } + + // The bound follows the parallelism: a pool filled under a wider one shrinks at the next release. + t.Cleanup(func() { runtime.GOMAXPROCS(bound) }) + runtime.GOMAXPROCS(1) + release() + if got := len(model.idle); got != 1 { + t.Fatalf("%d idle workers after the parallelism fell to 1, want 1", got) + } +} diff --git a/internal/grpc/capability_response.go b/internal/frontend/grpc/capability_response.go similarity index 83% rename from internal/grpc/capability_response.go rename to internal/frontend/grpc/capability_response.go index 7005e5e24e..9e809cb7fa 100644 --- a/internal/grpc/capability_response.go +++ b/internal/frontend/grpc/capability_response.go @@ -5,9 +5,10 @@ import ( "strings" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) func (s *Service) symbolToProto(sym *symbols.Symbol, sc *SymbolContext) *pb.SymbolInfo { @@ -29,17 +30,17 @@ func (s *Service) symbolToProto(sym *symbols.Symbol, sc *SymbolContext) *pb.Symb } func (s *Service) valueToProto(rt *runtime.Context, value runtime.Value, idx *symbols.Index) *pb.Value { - out := ValueToProtoIn(rt, value, idx) + out := protoconv.ValueToProtoIn(rt, value, idx) s.filterValueCapabilities(out) return out } -func (s *Service) instanceGraphToProto(rt *runtime.Context, inst *runtime.Instance, idx *symbols.Index) (*pb.Instance, []*pb.Instance) { - root, all := InstanceGraphToProto(rt, inst, idx) - for _, instance := range all { +func (s *Service) instanceGraphToProto(rt *runtime.Context, inst *runtime.Instance, idx *symbols.Index) protoconv.InstanceGraph { + graph := protoconv.InstanceGraphToProtoWithin(rt, inst, idx, protoconv.DefaultGraphBounds()) + for _, instance := range graph.All { s.filterInstanceCapabilities(instance) } - return root, all + return graph } func (s *Service) filterInstanceCapabilities(instance *pb.Instance) { @@ -78,10 +79,10 @@ func (s *Service) filterValueCapabilities(value *pb.Value) { } case *pb.Value_Array, *pb.Value_Vector, *pb.Value_VectorQuantity: if !s.capabilities.has(CapabilityStructuredValues) { - value.Kind = unsupportedShown(displayValue(value)) + value.Kind = protoconv.UnsupportedShown(displayValue(value)) return } - for _, nested := range nestedValues(value) { + for _, nested := range protoconv.NestedValues(value) { s.filterValueCapabilities(nested) } case *pb.Value_Set: @@ -96,13 +97,13 @@ func (s *Service) filterValueCapabilities(value *pb.Value) { func (s *Service) filterSetCapabilities(value *pb.Value) { shown := displayValue(value) if !s.capabilities.has(CapabilitySetValues) { - value.Kind = unsupportedShown(shown) + value.Kind = protoconv.UnsupportedShown(shown) return } - for _, nested := range nestedValues(value) { + for _, nested := range protoconv.NestedValues(value) { s.filterValueCapabilities(nested) - if reason, ok := unsupportedReason(nested); ok { - value.Kind = unsupportedSet(shown, reason).Kind + if reason, ok := protoconv.UnsupportedReason(nested); ok { + value.Kind = protoconv.UnsupportedSet(shown, reason).Kind return } } @@ -122,15 +123,15 @@ func (s *Service) filterLeafCapabilities(value *pb.Value) { } case *pb.Value_Complex: if !s.capabilities.has(CapabilityComplexValues) { - value.Kind = &pb.Value_Null{Null: "unsupported: complex number " + runtime.FormatComplex(ProtoToComplex(kind.Complex))} + value.Kind = &pb.Value_Null{Null: "unsupported: complex number " + runtime.FormatComplex(protoconv.ProtoToComplex(kind.Complex))} } case *pb.Value_MeasurementRef: if !s.capabilities.has(CapabilityMeasurementRefs) { - value.Kind = unsupportedShown(displayValue(value)) + value.Kind = protoconv.UnsupportedShown(displayValue(value)) } case *pb.Value_Function: if !s.capabilities.has(CapabilityFunctionValues) { - value.Kind = &pb.Value_Null{Null: unsupportedNullPrefix + runtime.ValFunction.String() + " " + kind.Function.GetCalcId()} + value.Kind = &pb.Value_Null{Null: protoconv.UnsupportedNullPrefix + runtime.ValFunction.String() + " " + kind.Function.GetCalcId()} } case *pb.Value_Infinity: if !s.capabilities.has(CapabilityInfinityValue) { @@ -138,16 +139,16 @@ func (s *Service) filterLeafCapabilities(value *pb.Value) { } case *pb.Value_TensorQuantity: if !s.capabilities.has(CapabilityTensorValues) { - value.Kind = unsupportedShown(displayValue(value)) + value.Kind = protoconv.UnsupportedShown(displayValue(value)) } case *pb.Value_Metaobject: if !s.capabilities.has(CapabilityMetaobjectValues) { - value.Kind = &pb.Value_Null{Null: unsupportedNullPrefix + runtime.ValMetaobject.String() + " " + + value.Kind = &pb.Value_Null{Null: protoconv.UnsupportedNullPrefix + runtime.ValMetaobject.String() + " " + kind.Metaobject.GetElementId() + " : " + kind.Metaobject.GetMetaclassId()} } case *pb.Value_Undetermined: if !s.capabilities.has(CapabilityUndeterminedValue) { - value.Kind = &pb.Value_Null{Null: unsupportedNullPrefix + runtime.UndeterminedText + ": " + kind.Undetermined.GetReason()} + value.Kind = &pb.Value_Null{Null: protoconv.UnsupportedNullPrefix + runtime.UndeterminedText + ": " + kind.Undetermined.GetReason()} } } } @@ -211,7 +212,7 @@ func displayValue(pv *pb.Value) runtime.Value { case *pb.Value_Metaobject: return runtime.NewMetaobject(&symbols.Symbol{Name: k.Metaobject.GetElementId()}, &symbols.Symbol{Name: k.Metaobject.GetMetaclassId()}) default: - return protoToScalar(pv) + return protoconv.ProtoToScalar(pv) } } diff --git a/internal/grpc/capability_test.go b/internal/frontend/grpc/capability_test.go similarity index 89% rename from internal/grpc/capability_test.go rename to internal/frontend/grpc/capability_test.go index 5f007d4fa2..06cda1c583 100644 --- a/internal/grpc/capability_test.go +++ b/internal/frontend/grpc/capability_test.go @@ -8,7 +8,7 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" ) // availableService is a service that supplies the capability: the default one, except @@ -93,6 +93,10 @@ func TestCapabilityGatedRequestsAreRefused(t *testing.T) { }) return err }}, + {"edit documents", CapabilityEditDocuments, func(s *Service) error { + _, err := s.ApplyEdits(ctx, &pb.ApplyEditsRequest{Document: "q.sysml"}) + return err + }}, {"inline language", CapabilityInlineLanguage, func(s *Service) error { _, err := s.ParseFile(ctx, &pb.ParseFileRequest{ Source: &pb.ParseFileRequest_Content{Content: "package P;"}, @@ -123,6 +127,10 @@ func TestCapabilityGatedRequestsAreRefused(t *testing.T) { _, err := s.VerifySatisfaction(ctx, &pb.VerifySatisfactionRequest{}) return err }}, + {"validate instance", CapabilityVerification, func(s *Service) error { + _, err := s.ValidateInstance(ctx, &pb.ValidateInstanceRequest{}) + return err + }}, {"evaluate calc", CapabilityVerification, func(s *Service) error { _, err := s.EvaluateCalc(ctx, &pb.EvaluateCalcRequest{}) return err @@ -143,6 +151,20 @@ func TestCapabilityGatedRequestsAreRefused(t *testing.T) { _, err := s.RunAnalysis(ctx, &pb.RunAnalysisRequest{Schedule: "declared"}) return err }}, + {"execute action performer", CapabilityPerformer, func(s *Service) error { + _, err := s.ExecuteAction(ctx, &pb.ExecuteActionRequest{PerformerSymbolId: "Wire::pair.craft"}) + return err + }}, + {"execute state performer", CapabilityPerformer, func(s *Service) error { + _, err := s.ExecuteState(ctx, &pb.ExecuteStateRequest{PerformerSymbolId: "Wire::pair.craft"}) + return err + }}, + {"render document html", CapabilityRenderDocumentHTML, func(s *Service) error { + _, err := s.RenderDocument(ctx, &pb.RenderDocumentRequest{ + ModelHash: "any", DocumentId: "Any", Form: "html", + }) + return err + }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/grpc/case_evaluations_test.go b/internal/frontend/grpc/case_evaluations_test.go similarity index 100% rename from internal/grpc/case_evaluations_test.go rename to internal/frontend/grpc/case_evaluations_test.go diff --git a/internal/frontend/grpc/choice_test.go b/internal/frontend/grpc/choice_test.go new file mode 100644 index 0000000000..03bca776b2 --- /dev/null +++ b/internal/frontend/grpc/choice_test.go @@ -0,0 +1,402 @@ +package grpc + +import ( + "context" + "strings" + "testing" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +// choiceDiagnostics keeps the choice-point diagnostics of a response. +func choiceDiagnostics(diags []*pb.Diagnostic) []*pb.Diagnostic { + var out []*pb.Diagnostic + for _, d := range diags { + if strings.HasPrefix(d.Message, "choice point: ") { + out = append(out, d) + } + } + return out +} + +// A run that chose among unordered alternatives reports each choice as an +// informational diagnostic located at the declaration it was made at; a run +// with one possible order reports none. +func TestExecuteAction_ChoicePointDiagnostics(t *testing.T) { + srv := mustNewService(t, 10) + + content := ` +package Test { + action tally { + attribute leftCount : Integer = 0; + attribute rightCount : Integer = 0; + + first start; + fork split; + action left { assign leftCount := leftCount + 1; } + action right { assign rightCount := rightCount + 10; } + join sync; + done; + + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then done; + } + + action single { + attribute n : Integer = 0; + first start; + then action one { assign n := 1; } + then done; + } +} +` + parseResp, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: content}, + ContentHash: "test-execute-action-choices", + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + resp, err := srv.ExecuteAction(context.Background(), &pb.ExecuteActionRequest{ + ModelHash: parseResp.ModelHash, + ActionSymbolId: "Test::tally", + }) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if resp.Error != "" { + t.Fatalf("execution error: %s", resp.Error) + } + choices := choiceDiagnostics(resp.Diagnostics) + if len(choices) != 1 { + t.Fatalf("choice diagnostics = %v, want one", resp.Diagnostics) + } + d := choices[0] + if d.Severity != "info" { + t.Errorf("severity = %q, want info", d.Severity) + } + want := "choice point: step 3: tokens 2@left, 3@right (unordered; took 3@right first)" + if d.Message != want { + t.Errorf("message = %q, want %q", d.Message, want) + } + if d.Span == nil || d.Span.StartLine == 0 { + t.Errorf("choice diagnostic carries no location: %v", d.Span) + } + + resp, err = srv.ExecuteAction(context.Background(), &pb.ExecuteActionRequest{ + ModelHash: parseResp.ModelHash, + ActionSymbolId: "Test::single", + }) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if resp.Error != "" { + t.Fatalf("execution error: %s", resp.Error) + } + if got := choiceDiagnostics(resp.Diagnostics); len(got) != 0 { + t.Errorf("a run with one order reported choices: %v", got) + } +} + +// A run that fails in a step one token had already taken reports the order the +// two tokens went in alongside the error: the failure may depend on that order. +func TestExecuteAction_ChoicePointDiagnosticsOnFailure(t *testing.T) { + srv := mustNewService(t, 10) + + content := ` +package Test { + action race { + attribute x : Integer = 0; + attribute n : Integer = 0; + first start; + fork split; + action safe { assign x := 1; } + action failing { assign x := 1 / n; } + join sync; + done; + succession first start then split; + succession first split then failing; + succession first split then safe; + succession first safe then sync; + succession first failing then sync; + succession first sync then done; + } +} +` + parseResp, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: content}, + ContentHash: "test-execute-action-choices-on-failure", + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + resp, err := srv.ExecuteAction(context.Background(), &pb.ExecuteActionRequest{ + ModelHash: parseResp.ModelHash, + ActionSymbolId: "Test::race", + }) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if !strings.Contains(resp.Error, "division by zero") { + t.Fatalf("error = %q, want the failing token's error", resp.Error) + } + choices := choiceDiagnostics(resp.Diagnostics) + want := "choice point: step 3: tokens 2@failing, 3@safe (unordered; took 3@safe first)" + if len(choices) != 1 || choices[0].Message != want || choices[0].Severity != "info" { + t.Fatalf("choice diagnostics = %v, want one info %q", choices, want) + } +} + +// An analysis whose performed action forked reports the order the executor +// took among the branches and between their writes, with the case's outputs. +func TestRunAnalysis_ChoicePointDiagnostics(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, ` +package An { + private import ScalarValues::*; + + action def Tally { + out r : Integer = 0; + first start; + fork split; + action left { assign r := r + 1; } + action right { assign r := r + 10; } + join sync; + done; + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then done; + } + + analysis forked { + out r : Integer; + perform action tally : Tally; + return : Integer = r; + } +} +`, "analysis-choices") + + resp := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "An::forked"}) + if resp.Error != "" { + t.Fatalf("RunAnalysis reported %q", resp.Error) + } + choices := choiceDiagnostics(resp.Diagnostics) + want := []string{ + "choice point: step 3: writes r := 11 by token 2, r := 10 by token 3 (unordered; r := 11 by token 2 stood)", + "choice point: step 3: tokens 2@left, 3@right (unordered; took 3@right first)", + } + if len(choices) != len(want) { + t.Fatalf("choice diagnostics = %v, want %d", resp.Diagnostics, len(want)) + } + for i, d := range choices { + if d.Message != want[i] || d.Severity != "info" { + t.Errorf("diagnostic %d = %s %q, want info %q", i, d.Severity, d.Message, want[i]) + } + } +} + +// Two transitions out of one state enabled by one event are reported on the +// state response; the first declared still fires. +func TestExecuteState_ChoicePointDiagnostics(t *testing.T) { + srv := mustNewService(t, 10) + + content := ` +package Test { + state Dispatcher { + attribute level : Integer = 8; + entry; then idle; + state idle; + state low; + state high; + transition first idle accept Go if level > 5 then low; + transition first idle accept Go if level > 7 then high; + } +} +` + parseResp, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: content}, + ContentHash: "test-execute-state-choices", + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + resp, err := srv.ExecuteState(context.Background(), &pb.ExecuteStateRequest{ + ModelHash: parseResp.ModelHash, + StateMachineSymbolId: "Test::Dispatcher", + Events: []string{"Go"}, + }) + if err != nil { + t.Fatalf("ExecuteState failed: %v", err) + } + if resp.Error != "" { + t.Fatalf("execution error: %s", resp.Error) + } + if got := strings.Join(resp.StatesVisited, ","); got != "idle,low" { + t.Fatalf("states visited %q, want idle,low", got) + } + choices := choiceDiagnostics(resp.Diagnostics) + if len(choices) != 1 { + t.Fatalf("choice diagnostics = %v, want one", resp.Diagnostics) + } + want := "choice point: state idle on accept Go: transitions 1->low, 2->high (unordered; took 1->low)" + if choices[0].Message != want || choices[0].Severity != "info" { + t.Errorf("diagnostic = %s %q, want info %q", choices[0].Severity, choices[0].Message, want) + } +} + +// One event enabling a transition in each of two regions draws the entry order and the +// firings' unit order on the state response under the default policy; `seed:1` takes another. +func TestExecuteState_RegionOrderChoiceDiagnostics(t *testing.T) { + srv := mustNewService(t, 10) + + content := ` +package Test { + state Machine { + attribute last : Integer = 0; + entry; then work; + state work parallel { + state a { entry; then a1; state a1; state a2; transition first a1 accept Go do assign last := 1 then a2; } + state b { entry; then b1; state b1; state b2; transition first b1 accept Go do assign last := 2 then b2; } + } + } +} +` + parseResp, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: content}, + ContentHash: "test-execute-state-region-order", + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + execute := func(schedule, wantVisited string, wantChoices ...string) { + t.Helper() + resp, err := srv.ExecuteState(context.Background(), &pb.ExecuteStateRequest{ + ModelHash: parseResp.ModelHash, + StateMachineSymbolId: "Test::Machine", + Events: []string{"Go"}, + Schedule: schedule, + }) + if err != nil { + t.Fatalf("ExecuteState failed: %v", err) + } + if resp.Error != "" { + t.Fatalf("execution error: %s", resp.Error) + } + if got := strings.Join(resp.StatesVisited, ","); got != wantVisited { + t.Fatalf("%q: states visited %q, want %s", schedule, got, wantVisited) + } + choices := choiceDiagnostics(resp.Diagnostics) + if len(choices) != len(wantChoices) { + t.Fatalf("%q: choice diagnostics = %v, want %d", schedule, resp.Diagnostics, len(wantChoices)) + } + for i, want := range wantChoices { + if choices[i].Message != want || choices[i].Severity != "info" { + t.Errorf("%q: diagnostic %d = %s %q, want info %q", schedule, i, choices[i].Severity, choices[i].Message, want) + } + } + } + execute("", "work,a1,b1,a2,b2", + "choice point: entering work: next a1(entry), b1(entry) (unordered; took a1(entry) first)", + "choice point: on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first)") + execute("seed:1", "work,b1,a1,a2,b2", + "choice point: entering work: next a1(entry), b1(entry) (unordered; took b1(entry) first)", + "choice point: on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first)") +} + +// A guard the run read only to report a choice and could not evaluate is an +// informational diagnostic naming the decision, the branch and the failure; the +// run itself is unchanged, and a first guard's failure still fails it. +func TestExecuteAction_UnevaluableGuardDiagnostics(t *testing.T) { + srv := mustNewService(t, 10) + + content := ` +package Test { + action route { + attribute level : Integer = 75; + attribute handler : Integer = 0; + first start; + then decide select; + if level > 50 then warn; + if 1 / (level - 75) > 0 then alarm; + action warn { assign handler := 1; } + then done; + action alarm { assign handler := 2; } + then done; + } + action broken { + attribute level : Integer = 75; + first start; + then decide select; + if 1 / (level - 75) > 0 then alarm; + if level > 50 then warn; + action warn; + then done; + action alarm; + then done; + } +} +` + parseResp, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: content}, + ContentHash: "test-execute-action-unevaluable-guard", + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + resp, err := srv.ExecuteAction(context.Background(), &pb.ExecuteActionRequest{ + ModelHash: parseResp.ModelHash, + ActionSymbolId: "Test::route", + }) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if resp.Error != "" { + t.Fatalf("execution error: %s", resp.Error) + } + if got := choiceDiagnostics(resp.Diagnostics); len(got) != 0 { + t.Errorf("an unevaluable guard was reported as a choice: %v", got) + } + var unevaluable []*pb.Diagnostic + for _, d := range resp.Diagnostics { + if strings.HasPrefix(d.Message, "guard not evaluable: ") { + unevaluable = append(unevaluable, d) + } + } + if len(unevaluable) != 1 { + t.Fatalf("unevaluable-guard diagnostics = %v, want one", resp.Diagnostics) + } + want := "guard not evaluable: step 2: decision select branch 2->alarm: division by zero (not selected)" + if unevaluable[0].Message != want || unevaluable[0].Severity != "info" { + t.Errorf("diagnostic = %s %q, want info %q", unevaluable[0].Severity, unevaluable[0].Message, want) + } + if unevaluable[0].Span == nil || unevaluable[0].Span.StartLine != 9 { + t.Errorf("diagnostic is not located at the guard: %v", unevaluable[0].Span) + } + + resp, err = srv.ExecuteAction(context.Background(), &pb.ExecuteActionRequest{ + ModelHash: parseResp.ModelHash, + ActionSymbolId: "Test::broken", + }) + if err != nil { + t.Fatalf("ExecuteAction failed: %v", err) + } + if !strings.Contains(resp.Error, "division by zero") { + t.Fatalf("error = %q, want the first guard's evaluation failure", resp.Error) + } + for _, d := range resp.Diagnostics { + if strings.HasPrefix(d.Message, "guard not evaluable: ") { + t.Errorf("the first guard's failure was reported as unevaluable: %q", d.Message) + } + } +} diff --git a/internal/grpc/connect.go b/internal/frontend/grpc/connect.go similarity index 95% rename from internal/grpc/connect.go rename to internal/frontend/grpc/connect.go index 6e035e9172..a12af22305 100644 --- a/internal/grpc/connect.go +++ b/internal/frontend/grpc/connect.go @@ -121,6 +121,11 @@ func (a *ConnectAdapter) VerifySatisfaction(ctx context.Context, req *connect.Re return connectCall(ctx, req, a.svc.VerifySatisfaction) } +// ValidateInstance checks every assertion about an object and the objects it holds. +func (a *ConnectAdapter) ValidateInstance(ctx context.Context, req *connect.Request[pb.ValidateInstanceRequest]) (*connect.Response[pb.ValidateInstanceResponse], error) { + return connectCall(ctx, req, a.svc.ValidateInstance) +} + // EvaluateCalc evaluates a calculation with the arguments given. func (a *ConnectAdapter) EvaluateCalc(ctx context.Context, req *connect.Request[pb.EvaluateCalcRequest]) (*connect.Response[pb.EvaluateCalcResponse], error) { return connectCall(ctx, req, a.svc.EvaluateCalc) diff --git a/internal/frontend/grpc/convert.go b/internal/frontend/grpc/convert.go new file mode 100644 index 0000000000..7abf677935 --- /dev/null +++ b/internal/frontend/grpc/convert.go @@ -0,0 +1,245 @@ +package grpc + +import ( + "math" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// SymbolToProtoIn converts a Symbol to protobuf SymbolInfo in an existing +// conversion context. +func SymbolToProtoIn(sym *symbols.Symbol, sc *SymbolContext) *pb.SymbolInfo { + defer sc.Lock()() + + idx := sc.Index + info := &pb.SymbolInfo{ + Id: idx.GetFQN(sym), // Fully qualified name + Name: sym.Name, + Kind: sym.Kind.String(), + Metadata: make(map[string]string), + } + + // Extract metadata from AST node + extractMetadata(sym, info.Metadata) + + // Add visibility to metadata + info.Metadata["visibility"] = visibilityToString(sym.Visibility) + + // Collect child IDs + if sym.Scope != nil { + var childIDs []string + for _, childSym := range sym.Scope.AllMembers() { + childIDs = append(childIDs, idx.GetFQN(childSym)) + } + info.ChildIds = childIDs + } + + // Static type facts: the resolved type, the declared multiplicity and every + // generalization edge. These are what a client needs to reconstruct the + // element's type without re-deriving it from the metadata strings. + info.TypeInfo = sc.typeInfoOf(sym) + info.Multiplicity = sc.multiplicityOf(sym) + info.Specializations = sc.specializationsOf(sym) + + // The attributes the element has, own and inherited, with their resolved + // types and constant default values. + info.Attributes, info.WithheldLibraryAttributes = sc.attributesOf(sym) + + return info +} + +// int32Clamp narrows a line or column number to the proto's int32, saturating +// rather than wrapping: a position past 2^31 would otherwise be reported as a +// negative one. +func int32Clamp(n int) int32 { + if n > math.MaxInt32 { + return math.MaxInt32 + } + if n < math.MinInt32 { + return math.MinInt32 + } + return int32(n) +} + +// DiagnosticToProto converts a diag.Diagnostic to protobuf. +func DiagnosticToProto(diag diag.Diagnostic, sf *source.SourceFile) *pb.Diagnostic { + li := sf.Lines() + start := li.PosAt(diag.Span.Offset) + end := li.PosAt(diag.Span.End()) + + return &pb.Diagnostic{ + Severity: diag.Severity.String(), + Message: diag.Message, + Code: diag.Code, + Span: &pb.Span{ + File: sf.Name(), + StartLine: int32Clamp(start.Line), + StartCol: int32Clamp(start.Col), + EndLine: int32Clamp(end.Line), + EndCol: int32Clamp(end.Col), + }, + } +} + +// RunNoteDiagnosticsToProto converts what a run noted about itself — its choice +// points and the guards it could not evaluate — to informational diagnostics, +// located in their model document; one outside the model carries no span. +func RunNoteDiagnosticsToProto(notes []runtime.RunNote, model *CachedModel) []*pb.Diagnostic { + if len(notes) == 0 { + return nil + } + pbDiags := make([]*pb.Diagnostic, 0, len(notes)) + for _, n := range notes { + diag := n.Diagnostic() + file, _ := n.Location() + if sf := model.document(file); sf != nil { + pbDiags = append(pbDiags, DiagnosticToProto(diag, sf)) + continue + } + pbDiags = append(pbDiags, &pb.Diagnostic{Severity: diag.Severity.String(), Message: diag.Message, Code: diag.Code}) + } + return pbDiags +} + +// SyntaxDiagnosticCode is the code every reporter gives a parser error; the +// parser itself codes only warnings. +const SyntaxDiagnosticCode = "syntax" + +// ParserDiagnosticToProto converts a parser error to protobuf. +func ParserDiagnosticToProto(diag parser.Diagnostic, sf *source.SourceFile) *pb.Diagnostic { + li := sf.Lines() + start := li.PosAt(diag.Span.Offset) + end := li.PosAt(diag.Span.End()) + + return &pb.Diagnostic{ + Severity: "error", // Parser diagnostics are always errors + Message: diag.Message, + Code: SyntaxDiagnosticCode, + Span: &pb.Span{ + File: sf.Name(), + StartLine: int32Clamp(start.Line), + StartCol: int32Clamp(start.Col), + EndLine: int32Clamp(end.Line), + EndCol: int32Clamp(end.Col), + }, + } +} + +// extractMetadata populates the metadata map from the symbol's AST node. +// Extracts: multiplicity, type, direction, abstract. +func extractMetadata(sym *symbols.Symbol, meta map[string]string) { + if sym.Decl == nil { + return + } + + switch decl := sym.Decl.(type) { + case *ast.Usage: + // Multiplicity + if decl.Multiplicity != nil { + meta["multiplicity"] = formatMultiplicity(decl.Multiplicity) + } + // Type (first typing relationship) + for _, rel := range decl.Relationships { + if rel.Kind == ast.RelTyping { + if qn, ok := rel.Target.(*ast.QualifiedName); ok { + meta["type"] = formatQualifiedName(qn) + break + } + } + } + // Direction + if decl.Direction != ast.DirNone { + meta["direction"] = decl.Direction.String() + } + // Abstract + if decl.IsAbstract { + meta["abstract"] = "true" + } + + case *ast.Definition: + // Abstract + if decl.IsAbstract { + meta["abstract"] = "true" + } + // Type (first specializes relationship for definitions) + for _, rel := range decl.Relationships { + if rel.Kind == ast.RelSpecializes { + if qn, ok := rel.Target.(*ast.QualifiedName); ok { + meta["specializes"] = formatQualifiedName(qn) + break + } + } + } + } +} + +// formatMultiplicity renders Multiplicity as "lower..upper" or "value". +func formatMultiplicity(m *ast.Multiplicity) string { + if !m.IsRange { + return formatMultiplicityBound(m.Lower) + } + lower := formatMultiplicityBound(m.Lower) + upper := formatMultiplicityBound(m.Upper) + return lower + ".." + upper +} + +// formatMultiplicityBound renders a multiplicity bound node as a string. +func formatMultiplicityBound(n ast.Node) string { + if n == nil { + return "" + } + switch v := n.(type) { + case *ast.LiteralInteger: + return v.Value + case *ast.LiteralInfinity: + return "*" + default: + return "?" + } +} + +// formatQualifiedName renders QualifiedName as "A::B::C". +func formatQualifiedName(qn *ast.QualifiedName) string { + if qn == nil { + return "" + } + var parts []string + for _, seg := range qn.Parts { + parts = append(parts, seg.Text) + } + return joinParts(parts, "::") +} + +// joinParts joins parts with separator. +func joinParts(parts []string, sep string) string { + result := "" + for i, part := range parts { + if i > 0 { + result += sep + } + result += part + } + return result +} + +// visibilityToString converts ast.Visibility to string. +func visibilityToString(v ast.Visibility) string { + switch v { + case ast.VisibilityPublic: + return "public" + case ast.VisibilityPrivate: + return "private" + case ast.VisibilityProtected: + return "protected" + case ast.VisibilityDefault: + return "default" + default: + return "default" + } +} diff --git a/internal/grpc/convert_complex_test.go b/internal/frontend/grpc/convert_complex_test.go similarity index 82% rename from internal/grpc/convert_complex_test.go rename to internal/frontend/grpc/convert_complex_test.go index d81cf3ff89..258bb3ed35 100644 --- a/internal/grpc/convert_complex_test.go +++ b/internal/frontend/grpc/convert_complex_test.go @@ -8,8 +8,9 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) const complexWireModel = ` @@ -38,7 +39,7 @@ package C { // of Reals a client would read as two values. func TestComplexToProto(t *testing.T) { idx := symbols.NewIndex() - pv := ValueToProto(runtime.NewComplex(complex(1.5, -2)), idx) + pv := protoconv.ValueToProto(runtime.NewComplex(complex(1.5, -2)), idx) c := pv.GetComplex() if c == nil { t.Fatalf("got kind %T, want complex", pv.GetKind()) @@ -50,13 +51,13 @@ func TestComplexToProto(t *testing.T) { seq := runtime.NewSequence() seq.Append(runtime.NewComplex(complex(1, 2))) seq.Append(runtime.NewComplex(complex(3, 4))) - pv = ValueToProto(runtime.NewSequenceValue(seq), idx) + pv = protoconv.ValueToProto(runtime.NewSequenceValue(seq), idx) elements := pv.GetSequence().GetElements() if len(elements) != 2 { t.Fatalf("a sequence of two Complex values crossed as %d elements", len(elements)) } for i, want := range []complex128{complex(1, 2), complex(3, 4)} { - if got := ProtoToComplex(elements[i].GetComplex()); got != want { + if got := protoconv.ProtoToComplex(elements[i].GetComplex()); got != want { t.Errorf("element %d = %v, want %v", i, got, want) } } @@ -67,18 +68,18 @@ func TestComplexToProto(t *testing.T) { func TestComplexRoundTrip(t *testing.T) { idx := symbols.NewIndex() for _, z := range []complex128{complex(0, 0), complex(1.5, -2), complex(-0.25, 1e300), complex(3, 0)} { - back, err := ProtoToValueIn(ValueToProto(runtime.NewComplex(z), idx), idx, nil) + back, err := protoconv.ProtoToValueIn(protoconv.ValueToProto(runtime.NewComplex(z), idx), idx, nil) if err != nil { - t.Fatalf("ProtoToValueIn(%v): %v", z, err) + t.Fatalf("protoconv.ProtoToValueIn(%v): %v", z, err) } if back.Kind != runtime.ValComplex || back.Complex() != z { t.Errorf("round trip of %v = %s (%v)", z, back.Kind, back) } } - empty, err := ProtoToValueIn(&pb.Value{Kind: &pb.Value_Complex{Complex: &pb.Complex{}}}, idx, nil) + empty, err := protoconv.ProtoToValueIn(&pb.Value{Kind: &pb.Value_Complex{Complex: &pb.Complex{}}}, idx, nil) if err != nil { - t.Fatalf("ProtoToValueIn(empty Complex): %v", err) + t.Fatalf("protoconv.ProtoToValueIn(empty Complex): %v", err) } if empty.Kind != runtime.ValComplex || empty.Complex() != 0 { t.Errorf("empty Complex message = %v, want 0 + 0i", empty) @@ -97,7 +98,7 @@ func TestComplexValuesCapability(t *testing.T) { } srv := mustNewServiceWithout(t, CapabilityComplexValues) - pv := &pb.Value{Kind: &pb.Value_Complex{Complex: ComplexToProto(complex(0, 1))}} + pv := &pb.Value{Kind: &pb.Value_Complex{Complex: protoconv.ComplexToProto(complex(0, 1))}} srv.filterValueCapabilities(pv) if want := "unsupported: complex number 0.0 + 1.0i"; pv.GetNull() != want { t.Errorf("withheld complex = %v, want null %q", pv, want) @@ -126,7 +127,7 @@ func TestComplexCrossesEveryValueSurface(t *testing.T) { if err != nil || eval.Error != "" { t.Fatalf("Evaluate: err = %v, error = %q", err, eval.GetError()) } - if got := ProtoToComplex(eval.Result.GetComplex()); eval.Result.GetComplex() == nil || got != complex(1.5, -2) { + if got := protoconv.ProtoToComplex(eval.Result.GetComplex()); eval.Result.GetComplex() == nil || got != complex(1.5, -2) { t.Errorf("Evaluate result = %v, want complex 1.5 - 2.0i", eval.Result) } @@ -135,7 +136,7 @@ func TestComplexCrossesEveryValueSurface(t *testing.T) { t.Fatalf("Instantiate: err = %v, error = %q", err, inst.GetError()) } z := inst.Instance.FeatureValues["z"] - if z == nil || z.Error != "" || z.Value.GetComplex() == nil || ProtoToComplex(z.Value.GetComplex()) != complex(1.5, -2) { + if z == nil || z.Error != "" || z.Value.GetComplex() == nil || protoconv.ProtoToComplex(z.Value.GetComplex()) != complex(1.5, -2) { t.Errorf("feature value z = %v, want complex 1.5 - 2.0i", z) } zs := inst.Instance.FeatureValues["zs"] @@ -143,7 +144,7 @@ func TestComplexCrossesEveryValueSurface(t *testing.T) { t.Fatalf("feature value zs = %v, want two values", zs) } for i, want := range []complex128{complex(1, 2), complex(3, 4)} { - if zs.Values[i].GetComplex() == nil || ProtoToComplex(zs.Values[i].GetComplex()) != want { + if zs.Values[i].GetComplex() == nil || protoconv.ProtoToComplex(zs.Values[i].GetComplex()) != want { t.Errorf("zs[%d] = %v, want complex %v", i, zs.Values[i], want) } } @@ -151,12 +152,12 @@ func TestComplexCrossesEveryValueSurface(t *testing.T) { act, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ ModelHash: parsed.ModelHash, ActionSymbolId: "C::conj", - Inputs: map[string]*pb.Value{"z": {Kind: &pb.Value_Complex{Complex: ComplexToProto(complex(2, 5))}}}, + Inputs: map[string]*pb.Value{"z": {Kind: &pb.Value_Complex{Complex: protoconv.ComplexToProto(complex(2, 5))}}}, }) if err != nil || act.Error != "" { t.Fatalf("ExecuteAction: err = %v, error = %q", err, act.GetError()) } - if w := act.Outputs["w"]; w.GetComplex() == nil || ProtoToComplex(w.GetComplex()) != complex(2, -5) { + if w := act.Outputs["w"]; w.GetComplex() == nil || protoconv.ProtoToComplex(w.GetComplex()) != complex(2, -5) { t.Errorf("output w = %v, want complex 2.0 - 5.0i", w) } @@ -175,7 +176,7 @@ func TestComplexCrossesEveryValueSurface(t *testing.T) { } func TestValueCarriesComplex(t *testing.T) { - z := &pb.Value{Kind: &pb.Value_Complex{Complex: ComplexToProto(complex(1, 2))}} + z := &pb.Value{Kind: &pb.Value_Complex{Complex: protoconv.ComplexToProto(complex(1, 2))}} one := &pb.Value{Kind: &pb.Value_IntValue{IntValue: 1}} sequence := func(elements ...*pb.Value) *pb.Value { return &pb.Value{Kind: &pb.Value_Sequence{Sequence: &pb.ValueSequence{Elements: elements}}} @@ -193,8 +194,8 @@ func TestValueCarriesComplex(t *testing.T) { {"nested sequence with a complex", sequence(one, sequence(sequence(z))), true}, {"empty sequence", sequence(), false}, } { - if got := ValueCarriesComplex(testcase.value); got != testcase.want { - t.Errorf("ValueCarriesComplex(%s) = %v, want %v", testcase.name, got, testcase.want) + if got := protoconv.ValueCarriesComplex(testcase.value); got != testcase.want { + t.Errorf("protoconv.ValueCarriesComplex(%s) = %v, want %v", testcase.name, got, testcase.want) } } } @@ -208,7 +209,7 @@ func TestComplexInputNeedsComplexValues(t *testing.T) { if err != nil { t.Fatalf("ParseFile: %v", err) } - z := &pb.Value{Kind: &pb.Value_Complex{Complex: ComplexToProto(complex(2, 5))}} + z := &pb.Value{Kind: &pb.Value_Complex{Complex: protoconv.ComplexToProto(complex(2, 5))}} nested := &pb.Value{Kind: &pb.Value_Sequence{Sequence: &pb.ValueSequence{Elements: []*pb.Value{ {Kind: &pb.Value_IntValue{IntValue: 1}}, {Kind: &pb.Value_Sequence{Sequence: &pb.ValueSequence{Elements: []*pb.Value{z}}}}, @@ -241,7 +242,7 @@ func TestComplexInputNeedsComplexValues(t *testing.T) { if err != nil || calc.Error != "" { t.Fatalf("EvaluateCalc: err = %v, error = %q", err, calc.GetError()) } - if calc.Result.GetComplex() == nil || ProtoToComplex(calc.Result.GetComplex()) != complex(2, 5) { + if calc.Result.GetComplex() == nil || protoconv.ProtoToComplex(calc.Result.GetComplex()) != complex(2, 5) { t.Errorf("echo(2 + 5i) = %v, want the complex back", calc.Result) } } diff --git a/internal/grpc/convert_enum_test.go b/internal/frontend/grpc/convert_enum_test.go similarity index 86% rename from internal/grpc/convert_enum_test.go rename to internal/frontend/grpc/convert_enum_test.go index b9fb3490b7..ff773687ad 100644 --- a/internal/grpc/convert_enum_test.go +++ b/internal/frontend/grpc/convert_enum_test.go @@ -5,10 +5,11 @@ import ( "testing" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) const enumWireModel = `package D { @@ -34,7 +35,7 @@ func enumWireIndex(t *testing.T, fqn string) (*symbols.Index, *symbols.Symbol) { func TestEnumLiteralToProto(t *testing.T) { idx, red := enumWireIndex(t, "D::Color::red") - pv := ValueToProto(runtime.NewEnumLiteral(red), idx) + pv := protoconv.ValueToProto(runtime.NewEnumLiteral(red), idx) lit := pv.GetEnumLiteral() if lit == nil { t.Fatalf("got kind %T, want enum_literal", pv.GetKind()) @@ -56,7 +57,7 @@ func TestEnumLiteralRoundTrip(t *testing.T) { idx, red := enumWireIndex(t, "D::Color::red") original := runtime.NewEnumLiteral(red) - back, err := ProtoToValueIn(ValueToProto(original, idx), idx, nil) + back, err := protoconv.ProtoToValueIn(protoconv.ValueToProto(original, idx), idx, nil) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -82,7 +83,7 @@ func TestEnumLiteralRoundTripInSequence(t *testing.T) { seq.Append(runtime.NewEnumLiteral(red)) seq.Append(runtime.NewEnumLiteral(green[0])) - back, err := ProtoToValueIn(ValueToProto(runtime.NewSequenceValue(seq), idx), idx, nil) + back, err := protoconv.ProtoToValueIn(protoconv.ValueToProto(runtime.NewSequenceValue(seq), idx), idx, nil) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -108,14 +109,14 @@ func TestEnumLiteralUnresolvedIsAnError(t *testing.T) { } for name, lit := range cases { pv := &pb.Value{Kind: &pb.Value_EnumLiteral{EnumLiteral: lit}} - if _, err := ProtoToValueIn(pv, idx, nil); err == nil { + if _, err := protoconv.ProtoToValueIn(pv, idx, nil); err == nil { t.Errorf("%s: got no error, want one", name) } } // Without a model there is nothing to resolve against, which is an error too. pv := &pb.Value{Kind: &pb.Value_EnumLiteral{EnumLiteral: &pb.EnumLiteral{LiteralId: "D::Color::red"}}} - if _, err := ProtoToValueIn(pv, nil, nil); err == nil || !strings.Contains(err.Error(), "no model") { + if _, err := protoconv.ProtoToValueIn(pv, nil, nil); err == nil || !strings.Contains(err.Error(), "no model") { t.Errorf("no index: got %v, want a no-model error", err) } } @@ -164,7 +165,7 @@ func TestEnumValuesCapabilityReported(t *testing.T) { func TestEnumLiteralWithoutDeclarationIsUnsupported(t *testing.T) { idx, _ := enumWireIndex(t, "D::Color::red") - pv := ValueToProto(runtime.Value{Kind: runtime.ValEnumLiteral}, idx) + pv := protoconv.ValueToProto(runtime.Value{Kind: runtime.ValEnumLiteral}, idx) if got := pv.GetNull(); !strings.Contains(got, "unresolved enumeration literal") { t.Errorf("got %q, want an unresolved-literal null", got) } diff --git a/internal/grpc/convert_enum_valued_test.go b/internal/frontend/grpc/convert_enum_valued_test.go similarity index 90% rename from internal/grpc/convert_enum_valued_test.go rename to internal/frontend/grpc/convert_enum_valued_test.go index 7da217e46e..f847fa583f 100644 --- a/internal/grpc/convert_enum_valued_test.go +++ b/internal/frontend/grpc/convert_enum_valued_test.go @@ -5,7 +5,8 @@ import ( "testing" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" ) // valuedEnumModel declares an enumeration whose literals are Integers, and an @@ -78,7 +79,7 @@ func TestScalarValuedEnumLiteralCrossesAsLiteral(t *testing.T) { } // A literal that is only its identity carries no value. idx, red := enumWireIndex(t, "D::Color::red") - if lit := ValueToProto(runtime.NewEnumLiteral(red), idx).GetEnumLiteral(); lit.GetValue() != nil { + if lit := protoconv.ValueToProto(runtime.NewEnumLiteral(red), idx).GetEnumLiteral(); lit.GetValue() != nil { t.Errorf("Color::red: value: got %v, want none", lit.GetValue()) } } @@ -124,12 +125,12 @@ func TestScalarValuedEnumLiteralRoundTrip(t *testing.T) { if err != nil { t.Fatalf("EnumerationLiteralValue: %v", err) } - pv := ValueToProto(original, idx) + pv := protoconv.ValueToProto(original, idx) wantLevelHigh(t, "Level::high", pv) for name, back := range map[string]func() (runtime.Value, error){ - "with runtime": func() (runtime.Value, error) { return ProtoToRuntimeValue(rt, pv, idx, sem) }, - "without runtime": func() (runtime.Value, error) { return ProtoToValueIn(pv, idx, sem) }, + "with runtime": func() (runtime.Value, error) { return protoconv.ProtoToRuntimeValue(rt, pv, idx, sem) }, + "without runtime": func() (runtime.Value, error) { return protoconv.ProtoToValueIn(pv, idx, sem) }, } { got, err := back() if err != nil { diff --git a/internal/grpc/convert_function_test.go b/internal/frontend/grpc/convert_function_test.go similarity index 91% rename from internal/grpc/convert_function_test.go rename to internal/frontend/grpc/convert_function_test.go index 82c82dc0af..91e8fbdd8d 100644 --- a/internal/grpc/convert_function_test.go +++ b/internal/frontend/grpc/convert_function_test.go @@ -10,7 +10,8 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" ) // functionWireModel yields calcs as values — a definition, a usage with an @@ -114,9 +115,9 @@ func TestFunctionRoundTrip(t *testing.T) { } rt, _ := srv.newRuntime(cached) - back, err := ProtoToRuntimeValue(rt, pv, idx, sem) + back, err := protoconv.ProtoToRuntimeValue(rt, pv, idx, sem) if err != nil { - t.Fatalf("ProtoToRuntimeValue(%s): %v", expr, err) + t.Fatalf("protoconv.ProtoToRuntimeValue(%s): %v", expr, err) } if back.Kind != runtime.ValFunction || runtime.FormatValue(back) != want { t.Errorf("%s read back as %s %s, want the function %s", expr, back.Kind, runtime.FormatValue(back), want) @@ -141,9 +142,9 @@ func TestFunctionRoundTrip(t *testing.T) { "sequence in set": setOf(sequenceOf(sqCube...)), } { rt, _ := srv.newRuntime(cached) - back, err := ProtoToRuntimeValue(rt, nested, idx, sem) + back, err := protoconv.ProtoToRuntimeValue(rt, nested, idx, sem) if err != nil { - t.Fatalf("ProtoToRuntimeValue(functions in a %s): %v", name, err) + t.Fatalf("protoconv.ProtoToRuntimeValue(functions in a %s): %v", name, err) } var got []string var walk func(v runtime.Value) @@ -293,26 +294,26 @@ func TestMalformedFunctionsAreRejected(t *testing.T) { val *pb.Value want error }{ - {"empty", functionValue("", 0), ErrFunctionUnbound}, - {"unknown declaration", functionValue("F::Nope", 0), ErrFunctionUnbound}, - {"declaration that is not a calc", functionValue("F::holder", 0), ErrFunctionUnbound}, - {"calc usage computing a result", functionValue("F::pickSq", 0), ErrFunctionUnbound}, - {"object of another call", functionValue("F::Sq", 12345), ErrFunctionUnbound}, + {"empty", functionValue("", 0), protoconv.ErrFunctionUnbound}, + {"unknown declaration", functionValue("F::Nope", 0), protoconv.ErrFunctionUnbound}, + {"declaration that is not a calc", functionValue("F::holder", 0), protoconv.ErrFunctionUnbound}, + {"calc usage computing a result", functionValue("F::pickSq", 0), protoconv.ErrFunctionUnbound}, + {"object of another call", functionValue("F::Sq", 12345), protoconv.ErrFunctionUnbound}, {"nested in a sequence", &pb.Value{Kind: &pb.Value_Sequence{Sequence: &pb.ValueSequence{Elements: []*pb.Value{ intValue(1), functionValue("F::Nope", 0), - }}}}, ErrFunctionUnbound}, - {"nested in an array", arrayValue([]int64{1}, functionValue("", 0)), ErrFunctionUnbound}, - {"nested in a set", setOf(intValue(1), functionValue("F::Nope", 0)), ErrFunctionUnbound}, + }}}}, protoconv.ErrFunctionUnbound}, + {"nested in an array", arrayValue([]int64{1}, functionValue("", 0)), protoconv.ErrFunctionUnbound}, + {"nested in a set", setOf(intValue(1), functionValue("F::Nope", 0)), protoconv.ErrFunctionUnbound}, {"nested in a set in a sequence", &pb.Value{Kind: &pb.Value_Sequence{Sequence: &pb.ValueSequence{Elements: []*pb.Value{ setOf(functionValue("F::Sq", 12345)), - }}}}, ErrFunctionUnbound}, - {"listed twice in a set", setOf(functionValue("F::Sq", 0), functionValue("F::Sq", 0)), ErrSetElementRepeated}, + }}}}, protoconv.ErrFunctionUnbound}, + {"listed twice in a set", setOf(functionValue("F::Sq", 0), functionValue("F::Sq", 0)), protoconv.ErrSetElementRepeated}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - val, err := ProtoToRuntimeValue(rt, tc.val, idx, sem) + val, err := protoconv.ProtoToRuntimeValue(rt, tc.val, idx, sem) if !errors.Is(err, tc.want) { - t.Fatalf("ProtoToRuntimeValue = %v, %v; want %v", val, err, tc.want) + t.Fatalf("protoconv.ProtoToRuntimeValue = %v, %v; want %v", val, err, tc.want) } if val.Kind != runtime.ValInvalid { t.Errorf("a rejected value was still returned: %v", val) @@ -325,8 +326,8 @@ func TestMalformedFunctionsAreRejected(t *testing.T) { "in array": arrayValue([]int64{1}, functionValue("F::Sq", 0)), "in set": setOf(functionValue("F::Sq", 0)), } { - if _, err := ProtoToValueIn(val, idx, sem); !errors.Is(err, ErrFunctionNeedsRuntime) { - t.Errorf("%s without a runtime: err = %v, want %v", name, err, ErrFunctionNeedsRuntime) + if _, err := protoconv.ProtoToValueIn(val, idx, sem); !errors.Is(err, protoconv.ErrFunctionNeedsRuntime) { + t.Errorf("%s without a runtime: err = %v, want %v", name, err, protoconv.ErrFunctionNeedsRuntime) } } @@ -336,8 +337,8 @@ func TestMalformedFunctionsAreRejected(t *testing.T) { if err != nil { t.Fatalf("EvaluateCalc(unknown): %v", err) } - if !strings.Contains(calc.Error, ErrFunctionUnbound.Error()) { - t.Errorf("EvaluateCalc(unknown) error = %q, want one naming %v", calc.Error, ErrFunctionUnbound) + if !strings.Contains(calc.Error, protoconv.ErrFunctionUnbound.Error()) { + t.Errorf("EvaluateCalc(unknown) error = %q, want one naming %v", calc.Error, protoconv.ErrFunctionUnbound) } calc, err = srv.EvaluateCalc(ctx, &pb.EvaluateCalcRequest{ModelHash: modelHash, SymbolId: "F::apply", Arguments: []*pb.Value{realValue(3), realValue(3)}}) if err != nil { @@ -417,11 +418,11 @@ func TestValueCarriesFunction(t *testing.T) { {"sequence with a function", sequence(one, sequence(sq)), true}, {"array of functions", arrayValue([]int64{1}, sq), true}, } { - if got := ValueCarriesFunction(tc.value); got != tc.want { - t.Errorf("ValueCarriesFunction(%s) = %v, want %v", tc.name, got, tc.want) + if got := protoconv.ValueCarriesFunction(tc.value); got != tc.want { + t.Errorf("protoconv.ValueCarriesFunction(%s) = %v, want %v", tc.name, got, tc.want) } } - if ValueCarriesStructured(sq) { + if protoconv.ValueCarriesStructured(sq) { t.Error("a bare function is not a structured value") } } diff --git a/internal/grpc/convert_infinity_test.go b/internal/frontend/grpc/convert_infinity_test.go similarity index 87% rename from internal/grpc/convert_infinity_test.go rename to internal/frontend/grpc/convert_infinity_test.go index 1ddaba0955..e27ff61f4f 100644 --- a/internal/grpc/convert_infinity_test.go +++ b/internal/frontend/grpc/convert_infinity_test.go @@ -9,9 +9,10 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // infinityWireModel yields the unbounded value as a feature value, alone and @@ -45,12 +46,12 @@ func TestInfinityRoundTrip(t *testing.T) { idx := symbols.NewIndex() unbounded := runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInfinity}} - pv := ValueToProto(unbounded, idx) + pv := protoconv.ValueToProto(unbounded, idx) if _, ok := pv.GetKind().(*pb.Value_Infinity); !ok { t.Fatalf("* crossed as %T, want the infinity arm", pv.GetKind()) } - back, err := ProtoToValueIn(pv, idx, nil) + back, err := protoconv.ProtoToValueIn(pv, idx, nil) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -59,13 +60,13 @@ func TestInfinityRoundTrip(t *testing.T) { } // The string "*" stays a string in both directions. - str := ValueToProto(runtime.NewStringValue("*"), idx) + str := protoconv.ValueToProto(runtime.NewStringValue("*"), idx) if str.GetStringValue() != "*" { t.Errorf(`"*" crossed as %T, want a string`, str.GetKind()) } - strBack, err := ProtoToValueIn(str, idx, nil) + strBack, err := protoconv.ProtoToValueIn(str, idx, nil) if err != nil { - t.Fatalf(`ProtoToValueIn("*"): %v`, err) + t.Fatalf(`protoconv.ProtoToValueIn("*"): %v`, err) } if strBack.Kind != runtime.ValString || strBack.Str() != "*" { t.Errorf(`round trip of "*" = %s (%v)`, strBack.Kind, strBack) @@ -74,7 +75,7 @@ func TestInfinityRoundTrip(t *testing.T) { seq := runtime.NewSequence() seq.Append(runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}}) seq.Append(unbounded) - elements := ValueToProto(runtime.NewSequenceValue(seq), idx).GetSequence().GetElements() + elements := protoconv.ValueToProto(runtime.NewSequenceValue(seq), idx).GetSequence().GetElements() if len(elements) != 2 { t.Fatalf("(1, *) crossed as %d elements", len(elements)) } @@ -158,12 +159,12 @@ func TestProtoToValueRefusesAFalseInfinityArm(t *testing.T) { {"in an array", &pb.Value{Kind: &pb.Value_Array{Array: &pb.Array{Dimensions: []int64{1}, Elements: []*pb.Value{falseArm}}}}}, } { t.Run(tc.name, func(t *testing.T) { - if _, err := ProtoToValueIn(tc.sent, nil, nil); !errors.Is(err, ErrInfinityNotAsserted) { + if _, err := protoconv.ProtoToValueIn(tc.sent, nil, nil); !errors.Is(err, protoconv.ErrInfinityNotAsserted) { t.Errorf("error %v, want the infinity arm refused", err) } }) } - if got, err := ProtoToValueIn(infinityValue(), nil, nil); err != nil || !got.Const.IsUnbounded() { + if got, err := protoconv.ProtoToValueIn(infinityValue(), nil, nil); err != nil || !got.Const.IsUnbounded() { t.Errorf("infinity: true read as %v (%v), want the unbounded value", got.Kind, err) } } diff --git a/internal/grpc/convert_measurement_ref_test.go b/internal/frontend/grpc/convert_measurement_ref_test.go similarity index 83% rename from internal/grpc/convert_measurement_ref_test.go rename to internal/frontend/grpc/convert_measurement_ref_test.go index 3034e6c152..544c710512 100644 --- a/internal/grpc/convert_measurement_ref_test.go +++ b/internal/frontend/grpc/convert_measurement_ref_test.go @@ -10,8 +10,9 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // measurementRefWireModel yields a measurement reference as a feature value — @@ -102,7 +103,7 @@ func TestMeasurementRefRoundTrip(t *testing.T) { } // A client echoing what the service sent gets the same reference back. - back, err := ProtoToValueIn(pv, idx, sem) + back, err := protoconv.ProtoToValueIn(pv, idx, sem) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -113,9 +114,9 @@ func TestMeasurementRefRoundTrip(t *testing.T) { return } textOnly := measurementRefValue(tc.unit, "", pm.GetUnitTerm()) - back, err = ProtoToValueIn(textOnly, idx, sem) + back, err = protoconv.ProtoToValueIn(textOnly, idx, sem) if err != nil { - t.Fatalf("ProtoToValueIn(text only): %v", err) + t.Fatalf("protoconv.ProtoToValueIn(text only): %v", err) } assertSameRef(t, back, tc.unit, tc.unitID, tc.term) @@ -124,9 +125,9 @@ func TestMeasurementRefRoundTrip(t *testing.T) { return } idOnly := measurementRefValue("", tc.unitID, pm.GetUnitTerm()) - back, err = ProtoToValueIn(idOnly, idx, sem) + back, err = protoconv.ProtoToValueIn(idOnly, idx, sem) if err != nil { - t.Fatalf("ProtoToValueIn(unit_id only): %v", err) + t.Fatalf("protoconv.ProtoToValueIn(unit_id only): %v", err) } assertSameRef(t, back, tc.unit, tc.unitID, tc.term) }) @@ -134,19 +135,19 @@ func TestMeasurementRefRoundTrip(t *testing.T) { // A qualified spelling names the same declaration as the short one. for _, text := range []string{"SI::km", "SI::kilometre", "kilometre"} { - back, err := ProtoToValueIn(measurementRefValue(text, "SI::kilometre", kilometreTerm()), idx, sem) + back, err := protoconv.ProtoToValueIn(measurementRefValue(text, "SI::kilometre", kilometreTerm()), idx, sem) if err != nil { - t.Fatalf("ProtoToValueIn(%s): %v", text, err) + t.Fatalf("protoconv.ProtoToValueIn(%s): %v", text, err) } assertSameRef(t, back, text, "SI::kilometre", "1000/1·SI::metre") } // A reference read back converts by, and equals, the one the model holds. - km, err := ProtoToValueIn(mustEvaluate(t, srv, modelHash, "SI::km"), idx, sem) + km, err := protoconv.ProtoToValueIn(mustEvaluate(t, srv, modelHash, "SI::km"), idx, sem) if err != nil { t.Fatal(err) } - m, err := ProtoToValueIn(mustEvaluate(t, srv, modelHash, "SI::m"), idx, sem) + m, err := protoconv.ProtoToValueIn(mustEvaluate(t, srv, modelHash, "SI::m"), idx, sem) if err != nil { t.Fatal(err) } @@ -168,7 +169,7 @@ func assertSameRef(t *testing.T, back runtime.Value, unit, unitID, term string) if unit != "" && ref.Unit.Text != unit { t.Errorf("read back as %q, want %q", ref.Unit.Text, unit) } - if got := describeUnitTerm(unitTermToProto(ref.Unit.Term)); got != term { + if got := describeUnitTerm(protoconv.UnitTermToProto(ref.Unit.Term)); got != term { t.Errorf("read back reducing to %s, want %s", got, term) } gotID := "" @@ -193,43 +194,43 @@ func TestMalformedMeasurementRefsAreRejected(t *testing.T) { val *pb.Value want error }{ - {"empty", measurementRefValue("", "", nil), ErrMeasurementRefEmpty}, - {"named unit without its reduction", measurementRefValue("km", "", nil), ErrUnitNotReduced}, - {"declaration without its reduction", measurementRefValue("", "SI::kilometre", nil), ErrUnitNotReduced}, - {"unusable scale", measurementRefValue("m", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 0, Factors: metreTerm().Factors}), ErrUnitScaleUnusable}, - {"zero scale", measurementRefValue("m", "", &pb.UnitTerm{ScaleNum: 0, ScaleDen: 1, Factors: metreTerm().Factors}), ErrUnitScaleUnusable}, - {"NaN scale", measurementRefValue("m", "", &pb.UnitTerm{ScaleNum: math.NaN(), ScaleDen: 1, Factors: metreTerm().Factors}), ErrUnitScaleUnusable}, - {"infinite scale", measurementRefValue("m", "", &pb.UnitTerm{ScaleNum: math.Inf(1), ScaleDen: 1, Factors: metreTerm().Factors}), ErrUnitScaleUnusable}, - {"infinite denominator", measurementRefValue("m", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: math.Inf(-1), Factors: metreTerm().Factors}), ErrUnitScaleUnusable}, - {"term-only NaN scale", measurementRefValue("", "", &pb.UnitTerm{ScaleNum: math.NaN(), ScaleDen: 1, Factors: metreTerm().Factors}), ErrUnitScaleUnusable}, - {"term-only infinite scale", measurementRefValue("", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: math.Inf(1), Factors: metreTerm().Factors}), ErrUnitScaleUnusable}, - {"NaN exponent", measurementRefValue("", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "SI::metre", Exponent: math.NaN()}}}), ErrUnitExponentUnusable}, - {"infinite exponent", measurementRefValue("", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "SI::metre", Exponent: math.Inf(-1)}}}), ErrUnitExponentUnusable}, + {"empty", measurementRefValue("", "", nil), protoconv.ErrMeasurementRefEmpty}, + {"named unit without its reduction", measurementRefValue("km", "", nil), protoconv.ErrUnitNotReduced}, + {"declaration without its reduction", measurementRefValue("", "SI::kilometre", nil), protoconv.ErrUnitNotReduced}, + {"unusable scale", measurementRefValue("m", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 0, Factors: metreTerm().Factors}), protoconv.ErrUnitScaleUnusable}, + {"zero scale", measurementRefValue("m", "", &pb.UnitTerm{ScaleNum: 0, ScaleDen: 1, Factors: metreTerm().Factors}), protoconv.ErrUnitScaleUnusable}, + {"NaN scale", measurementRefValue("m", "", &pb.UnitTerm{ScaleNum: math.NaN(), ScaleDen: 1, Factors: metreTerm().Factors}), protoconv.ErrUnitScaleUnusable}, + {"infinite scale", measurementRefValue("m", "", &pb.UnitTerm{ScaleNum: math.Inf(1), ScaleDen: 1, Factors: metreTerm().Factors}), protoconv.ErrUnitScaleUnusable}, + {"infinite denominator", measurementRefValue("m", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: math.Inf(-1), Factors: metreTerm().Factors}), protoconv.ErrUnitScaleUnusable}, + {"term-only NaN scale", measurementRefValue("", "", &pb.UnitTerm{ScaleNum: math.NaN(), ScaleDen: 1, Factors: metreTerm().Factors}), protoconv.ErrUnitScaleUnusable}, + {"term-only infinite scale", measurementRefValue("", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: math.Inf(1), Factors: metreTerm().Factors}), protoconv.ErrUnitScaleUnusable}, + {"NaN exponent", measurementRefValue("", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "SI::metre", Exponent: math.NaN()}}}), protoconv.ErrUnitExponentUnusable}, + {"infinite exponent", measurementRefValue("", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "SI::metre", Exponent: math.Inf(-1)}}}), protoconv.ErrUnitExponentUnusable}, {"repeated exponents overflowing", measurementRefValue("", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{ {UnitId: "SI::metre", Exponent: math.MaxFloat64}, {UnitId: "SI::metre", Exponent: math.MaxFloat64}, - }}), ErrUnitExponentUnusable}, - {"unknown base unit", measurementRefValue("furlong", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "SI::furlong", Exponent: 1}}}), ErrUnknownBaseUnit}, - {"factor over a part", measurementRefValue("M", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "M::convert", Exponent: 1}}}), ErrNotAMeasurementUnit}, - {"unnamed factor", measurementRefValue("x", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{Exponent: 1}}}), ErrUnknownBaseUnit}, - {"qualified text disagreeing with its reduction", measurementRefValue("SI::km", "", metreTerm()), ErrUnitTextMismatch}, - {"composed text disagreeing with its reduction", measurementRefValue("SI::m * SI::s", "", metreTerm()), ErrUnitTextMismatch}, - {"unknown declaration", measurementRefValue("km", "SI::furlong", kilometreTerm()), ErrUnknownMeasurementUnit}, - {"declaration that is not a unit", measurementRefValue("", "M::convert", kilometreTerm()), ErrNotAMeasurementUnit}, - {"declaration disagreeing with the reduction", measurementRefValue("km", "SI::kilometre", metreTerm()), ErrUnitTextMismatch}, - {"text naming another declaration", measurementRefValue("m", "SI::kilometre", kilometreTerm()), ErrUnitIDMismatch}, - {"text composing the declaration", measurementRefValue("km * km", "SI::kilometre", kilometreTerm()), ErrUnitIDMismatch}, - {"text that is no name", measurementRefValue("km +", "SI::kilometre", kilometreTerm()), ErrUnitIDMismatch}, + }}), protoconv.ErrUnitExponentUnusable}, + {"unknown base unit", measurementRefValue("furlong", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "SI::furlong", Exponent: 1}}}), protoconv.ErrUnknownBaseUnit}, + {"factor over a part", measurementRefValue("M", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "M::convert", Exponent: 1}}}), protoconv.ErrNotAMeasurementUnit}, + {"unnamed factor", measurementRefValue("x", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{Exponent: 1}}}), protoconv.ErrUnknownBaseUnit}, + {"qualified text disagreeing with its reduction", measurementRefValue("SI::km", "", metreTerm()), protoconv.ErrUnitTextMismatch}, + {"composed text disagreeing with its reduction", measurementRefValue("SI::m * SI::s", "", metreTerm()), protoconv.ErrUnitTextMismatch}, + {"unknown declaration", measurementRefValue("km", "SI::furlong", kilometreTerm()), protoconv.ErrUnknownMeasurementUnit}, + {"declaration that is not a unit", measurementRefValue("", "M::convert", kilometreTerm()), protoconv.ErrNotAMeasurementUnit}, + {"declaration disagreeing with the reduction", measurementRefValue("km", "SI::kilometre", metreTerm()), protoconv.ErrUnitTextMismatch}, + {"text naming another declaration", measurementRefValue("m", "SI::kilometre", kilometreTerm()), protoconv.ErrUnitIDMismatch}, + {"text composing the declaration", measurementRefValue("km * km", "SI::kilometre", kilometreTerm()), protoconv.ErrUnitIDMismatch}, + {"text that is no name", measurementRefValue("km +", "SI::kilometre", kilometreTerm()), protoconv.ErrUnitIDMismatch}, {"nested in a sequence", &pb.Value{Kind: &pb.Value_Sequence{Sequence: &pb.ValueSequence{Elements: []*pb.Value{ intValue(1), measurementRefValue("km", "", nil), - }}}}, ErrUnitNotReduced}, - {"nested in an array", arrayValue([]int64{1}, measurementRefValue("", "", nil)), ErrMeasurementRefEmpty}, + }}}}, protoconv.ErrUnitNotReduced}, + {"nested in an array", arrayValue([]int64{1}, measurementRefValue("", "", nil)), protoconv.ErrMeasurementRefEmpty}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - val, err := ProtoToValueIn(tc.val, idx, sem) + val, err := protoconv.ProtoToValueIn(tc.val, idx, sem) if !errors.Is(err, tc.want) { - t.Fatalf("ProtoToValueIn = %v, %v; want %v", val, err, tc.want) + t.Fatalf("protoconv.ProtoToValueIn = %v, %v; want %v", val, err, tc.want) } if val.Kind != runtime.ValInvalid { t.Errorf("a rejected value was still returned: %v", val) @@ -243,22 +244,22 @@ func TestMalformedMeasurementRefsAreRejected(t *testing.T) { "unit_id": measurementRefValue("", "SI::metre", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1}), "in array": arrayValue([]int64{1}, measurementRefValue("m", "", metreTerm())), } { - if _, err := ProtoToValueIn(val, nil, nil); !errors.Is(err, ErrMeasurementRefNeedsIndex) { - t.Errorf("%s without an index: err = %v, want %v", name, err, ErrMeasurementRefNeedsIndex) + if _, err := protoconv.ProtoToValueIn(val, nil, nil); !errors.Is(err, protoconv.ErrMeasurementRefNeedsIndex) { + t.Errorf("%s without an index: err = %v, want %v", name, err, protoconv.ErrMeasurementRefNeedsIndex) } } // A short name no declaration reducing so bears is a client's own label for // the reduction, as a Quantity's is: it names no declaration. - label, err := ProtoToValueIn(measurementRefValue("km", "", metreTerm()), idx, sem) + label, err := protoconv.ProtoToValueIn(measurementRefValue("km", "", metreTerm()), idx, sem) if err != nil || label.Kind != runtime.ValMeasurementRef { t.Fatalf("short label = %v, %v; want a reference", label, err) } - if ref := label.MeasurementRef(); ref.Declaration() != nil || ref.Unit.Text != "km" || describeUnitTerm(unitTermToProto(ref.Unit.Term)) != "SI::metre" { + if ref := label.MeasurementRef(); ref.Declaration() != nil || ref.Unit.Text != "km" || describeUnitTerm(protoconv.UnitTermToProto(ref.Unit.Term)) != "SI::metre" { t.Errorf("short label read as %v naming %v, want an opaque km reducing to SI::metre", ref, ref.Declaration()) } // A dimension-one reference names no base unit, so it needs none. - one, err := ProtoToValueIn(measurementRefValue("", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1}), nil, nil) + one, err := protoconv.ProtoToValueIn(measurementRefValue("", "", &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1}), nil, nil) if err != nil || one.Kind != runtime.ValMeasurementRef || !one.MeasurementRef().Unit.Term.Dimensionless() { t.Errorf("dimension one without an index = %v, %v; want a dimension-one reference", one, err) } @@ -336,8 +337,8 @@ func TestMeasurementRefCrossesEveryValueSurface(t *testing.T) { if err != nil { t.Fatalf("EvaluateCalc(malformed): %v", err) } - if !strings.Contains(calc.Error, ErrUnitIDMismatch.Error()) { - t.Errorf("EvaluateCalc(malformed) error = %q, want one naming %v", calc.Error, ErrUnitIDMismatch) + if !strings.Contains(calc.Error, protoconv.ErrUnitIDMismatch.Error()) { + t.Errorf("EvaluateCalc(malformed) error = %q, want one naming %v", calc.Error, protoconv.ErrUnitIDMismatch) } } @@ -429,11 +430,11 @@ func TestValueCarriesMeasurementRef(t *testing.T) { {"sequence with a reference", sequence(one, sequence(metre)), true}, {"array of references", arrayValue([]int64{1}, metre), true}, } { - if got := ValueCarriesMeasurementRef(tc.value); got != tc.want { - t.Errorf("ValueCarriesMeasurementRef(%s) = %v, want %v", tc.name, got, tc.want) + if got := protoconv.ValueCarriesMeasurementRef(tc.value); got != tc.want { + t.Errorf("protoconv.ValueCarriesMeasurementRef(%s) = %v, want %v", tc.name, got, tc.want) } } - if ValueCarriesStructured(metre) { + if protoconv.ValueCarriesStructured(metre) { t.Error("a bare reference is not a structured value") } } diff --git a/internal/grpc/convert_metaobject_test.go b/internal/frontend/grpc/convert_metaobject_test.go similarity index 83% rename from internal/grpc/convert_metaobject_test.go rename to internal/frontend/grpc/convert_metaobject_test.go index e2cdc5ed51..f12196c57c 100644 --- a/internal/grpc/convert_metaobject_test.go +++ b/internal/frontend/grpc/convert_metaobject_test.go @@ -9,7 +9,8 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" ) // metaobjectWireModel yields metaobjects through `meta` and `.metadata`, and @@ -73,7 +74,7 @@ func TestMetaobjectRoundTrip(t *testing.T) { t.Errorf("M::notADefinition crossed as %v, want ()", got) } - back, err := ProtoToValueIn(elems[1], idx, sem) + back, err := protoconv.ProtoToValueIn(elems[1], idx, sem) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -86,15 +87,15 @@ func TestMetaobjectRoundTrip(t *testing.T) { if idx.GetFQN(back.MetaobjectElement()) != want.ElementId || idx.GetFQN(back.MetaobjectClass()) != want.MetaclassId { t.Errorf("round trip names %s : %s", idx.GetFQN(back.MetaobjectElement()), idx.GetFQN(back.MetaobjectClass())) } - again := ValueToProto(back, idx) + again := protoconv.ValueToProto(back, idx) if got := again.GetMetaobject(); got.GetElementId() != want.ElementId || got.GetMetaclassId() != want.MetaclassId { t.Errorf("re-sent as %v, want %v", again, want) } // A client may omit the metaclass: the model's is used. - bare, err := ProtoToValueIn(metaobjectValue("M::seatBelt", ""), idx, sem) + bare, err := protoconv.ProtoToValueIn(metaobjectValue("M::seatBelt", ""), idx, sem) if err != nil { - t.Fatalf("ProtoToValueIn without metaclass_id: %v", err) + t.Fatalf("protoconv.ProtoToValueIn without metaclass_id: %v", err) } if idx.GetFQN(bare.MetaobjectClass()) != want.MetaclassId { t.Errorf("metaobject without metaclass_id read back under %s", idx.GetFQN(bare.MetaobjectClass())) @@ -117,25 +118,25 @@ func TestMalformedMetaobjectsAreRejected(t *testing.T) { val *pb.Value want error }{ - {"empty element_id", metaobjectValue("", "KerML::Feature"), ErrMetaobjectUnbound}, - {"unknown element", metaobjectValue("M::nope", ""), ErrMetaobjectUnbound}, - {"metaclass of another kind", metaobjectValue("M::seatBelt", "SysML::Systems::PartDefinition"), ErrMetaclassMismatch}, - {"metaclass the element conforms to but is not", metaobjectValue("M::seatBelt", "KerML::Feature"), ErrMetaclassMismatch}, - {"nested in a sequence", sequenceValue(intValue(1), metaobjectValue("M::nope", "")), ErrMetaobjectUnbound}, + {"empty element_id", metaobjectValue("", "KerML::Feature"), protoconv.ErrMetaobjectUnbound}, + {"unknown element", metaobjectValue("M::nope", ""), protoconv.ErrMetaobjectUnbound}, + {"metaclass of another kind", metaobjectValue("M::seatBelt", "SysML::Systems::PartDefinition"), protoconv.ErrMetaclassMismatch}, + {"metaclass the element conforms to but is not", metaobjectValue("M::seatBelt", "KerML::Feature"), protoconv.ErrMetaclassMismatch}, + {"nested in a sequence", sequenceValue(intValue(1), metaobjectValue("M::nope", "")), protoconv.ErrMetaobjectUnbound}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, err := ProtoToValueIn(tc.val, idx, sem) + _, err := protoconv.ProtoToValueIn(tc.val, idx, sem) if !errors.Is(err, tc.want) { - t.Fatalf("ProtoToValueIn = %v, want %v", err, tc.want) + t.Fatalf("protoconv.ProtoToValueIn = %v, want %v", err, tc.want) } if !strings.Contains(err.Error(), tc.val.GetMetaobject().GetElementId()) && tc.val.GetMetaobject() != nil && tc.val.GetMetaobject().GetElementId() != "" { t.Errorf("error %q does not name the element", err) } }) } - if _, err := ProtoToValueIn(metaobjectValue("M::seatBelt", ""), idx, nil); !errors.Is(err, ErrMetaobjectUnbound) { - t.Errorf("ProtoToValueIn without a model = %v, want %v", err, ErrMetaobjectUnbound) + if _, err := protoconv.ProtoToValueIn(metaobjectValue("M::seatBelt", ""), idx, nil); !errors.Is(err, protoconv.ErrMetaobjectUnbound) { + t.Errorf("protoconv.ProtoToValueIn without a model = %v, want %v", err, protoconv.ErrMetaobjectUnbound) } } @@ -181,12 +182,12 @@ func TestAmbiguousMetaobjectIsRejected(t *testing.T) { if n := len(idx.LookupQualified("M::seatBelt")); n != 2 { t.Fatalf("M::seatBelt resolves to %d elements, want the two declarations", n) } - _, err = ProtoToValueIn(metaobjectValue("M::seatBelt", ""), idx, sem) - if !errors.Is(err, ErrMetaobjectAmbiguous) || !strings.Contains(err.Error(), "M::seatBelt") { - t.Fatalf("ProtoToValueIn = %v, want %v naming M::seatBelt", err, ErrMetaobjectAmbiguous) + _, err = protoconv.ProtoToValueIn(metaobjectValue("M::seatBelt", ""), idx, sem) + if !errors.Is(err, protoconv.ErrMetaobjectAmbiguous) || !strings.Contains(err.Error(), "M::seatBelt") { + t.Fatalf("protoconv.ProtoToValueIn = %v, want %v naming M::seatBelt", err, protoconv.ErrMetaobjectAmbiguous) } - if _, err := ProtoToValueIn(metaobjectValue("M::V", ""), idx, sem); err != nil { - t.Errorf("ProtoToValueIn(M::V), declared once: %v", err) + if _, err := protoconv.ProtoToValueIn(metaobjectValue("M::V", ""), idx, sem); err != nil { + t.Errorf("protoconv.ProtoToValueIn(M::V), declared once: %v", err) } } @@ -220,8 +221,8 @@ func TestMetaobjectCrossesAsCalcArgument(t *testing.T) { if err != nil { t.Fatalf("EvaluateCalc(mismatched): %v", err) } - if !strings.Contains(calc.Error, ErrMetaclassMismatch.Error()) { - t.Errorf("EvaluateCalc(mismatched) error = %q, want one naming %v", calc.Error, ErrMetaclassMismatch) + if !strings.Contains(calc.Error, protoconv.ErrMetaclassMismatch.Error()) { + t.Errorf("EvaluateCalc(mismatched) error = %q, want one naming %v", calc.Error, protoconv.ErrMetaclassMismatch) } } @@ -263,10 +264,10 @@ func TestMetaobjectCapability(t *testing.T) { t.Errorf("set of metaobjects without %s = %q, want %q", CapabilityMetaobjectValues, set.GetNull(), want) } - if !ValueCarriesMetaobject(sequenceValue(intValue(1), setOf(metaobjectValue("M::seatBelt", "")))) { - t.Error("ValueCarriesMetaobject misses a metaobject nested in a set in a sequence") + if !protoconv.ValueCarriesMetaobject(sequenceValue(intValue(1), setOf(metaobjectValue("M::seatBelt", "")))) { + t.Error("protoconv.ValueCarriesMetaobject misses a metaobject nested in a set in a sequence") } - if ValueCarriesMetaobject(sequenceValue(intValue(1), stringValue("meta"))) { - t.Error("ValueCarriesMetaobject reports one where there is none") + if protoconv.ValueCarriesMetaobject(sequenceValue(intValue(1), stringValue("meta"))) { + t.Error("protoconv.ValueCarriesMetaobject reports one where there is none") } } diff --git a/internal/grpc/convert_set_tensor_test.go b/internal/frontend/grpc/convert_set_tensor_test.go similarity index 86% rename from internal/grpc/convert_set_tensor_test.go rename to internal/frontend/grpc/convert_set_tensor_test.go index 2ed4d0f027..754f0f49d0 100644 --- a/internal/grpc/convert_set_tensor_test.go +++ b/internal/frontend/grpc/convert_set_tensor_test.go @@ -9,8 +9,9 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) // setTensorWireModel yields a set and tensors of rank two and three as feature @@ -108,7 +109,7 @@ func TestSetRoundTrip(t *testing.T) { t.Errorf("%s elements on the wire in order %s, want %s", tc.expr, got, tc.want) } - back, err := ProtoToValueIn(pv, idx, sem) + back, err := protoconv.ProtoToValueIn(pv, idx, sem) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -124,11 +125,11 @@ func TestSetRoundTrip(t *testing.T) { // A client may send the elements in any order: the set read is the same. written := setOf(intValue(3), intValue(1), intValue(2)) canonical := setOf(intValue(1), intValue(2), intValue(3)) - a, err := ProtoToValueIn(written, idx, sem) + a, err := protoconv.ProtoToValueIn(written, idx, sem) if err != nil { t.Fatal(err) } - b, err := ProtoToValueIn(canonical, idx, sem) + b, err := protoconv.ProtoToValueIn(canonical, idx, sem) if err != nil { t.Fatal(err) } @@ -138,23 +139,23 @@ func TestSetRoundTrip(t *testing.T) { // The empty set, a set of sets and a set nested in a sequence read back as // themselves, the nested sets deduplicated by set equality. - empty, err := ProtoToValueIn(setOf(), idx, sem) + empty, err := protoconv.ProtoToValueIn(setOf(), idx, sem) if err != nil || empty.Kind != runtime.ValSet || empty.Set().Size() != 0 { t.Errorf("empty set read back as %v, %v", empty, err) } nested := setOf(written, setOf()) - back, err := ProtoToValueIn(nested, idx, sem) + back, err := protoconv.ProtoToValueIn(nested, idx, sem) if err != nil { t.Fatal(err) } if got := runtime.FormatValue(back); got != "Set{Set{}, Set{1, 2, 3}}" { t.Errorf("set of sets read back as %s", got) } - if got := runtime.FormatValue(displayValue(ValueToProto(back, idx))); got != "Set{Set{}, Set{1, 2, 3}}" { + if got := runtime.FormatValue(displayValue(protoconv.ValueToProto(back, idx))); got != "Set{Set{}, Set{1, 2, 3}}" { t.Errorf("set of sets crossed as %s", got) } seq := &pb.Value{Kind: &pb.Value_Sequence{Sequence: &pb.ValueSequence{Elements: []*pb.Value{written, intValue(4)}}}} - back, err = ProtoToValueIn(seq, idx, sem) + back, err = protoconv.ProtoToValueIn(seq, idx, sem) if err != nil { t.Fatal(err) } @@ -177,27 +178,27 @@ func TestMalformedSetsAreRejected(t *testing.T) { val *pb.Value want error }{ - {"repeated integer", setOf(intValue(1), intValue(2), intValue(1)), ErrSetElementRepeated}, - {"repeated string", setOf(stringValue("a"), stringValue("a")), ErrSetElementRepeated}, - {"repeated nested set", setOf(setOf(intValue(1)), setOf(intValue(1))), ErrSetElementRepeated}, - {"nested sets equal in another order", setOf(setOf(intValue(1), intValue(2)), setOf(intValue(2), intValue(1))), ErrSetElementRepeated}, - {"Integer and the equal Real", setOf(intValue(1), realValue(1)), ErrSetElementRepeated}, - {"null and the empty sequence", setOf(&pb.Value{Kind: &pb.Value_Null{}}, sequenceValue()), ErrSetElementRepeated}, - {"empty set and the empty sequence", setOf(setOf(), sequenceValue()), ErrSetElementRepeated}, - {"unset element", setOf(&pb.Value{Kind: &pb.Value_Unset{Unset: true}}), ErrUnsetNotAccepted}, - {"malformed element", setOf(arrayValue([]int64{0})), ErrArrayDimensionNotPositive}, + {"repeated integer", setOf(intValue(1), intValue(2), intValue(1)), protoconv.ErrSetElementRepeated}, + {"repeated string", setOf(stringValue("a"), stringValue("a")), protoconv.ErrSetElementRepeated}, + {"repeated nested set", setOf(setOf(intValue(1)), setOf(intValue(1))), protoconv.ErrSetElementRepeated}, + {"nested sets equal in another order", setOf(setOf(intValue(1), intValue(2)), setOf(intValue(2), intValue(1))), protoconv.ErrSetElementRepeated}, + {"Integer and the equal Real", setOf(intValue(1), realValue(1)), protoconv.ErrSetElementRepeated}, + {"null and the empty sequence", setOf(&pb.Value{Kind: &pb.Value_Null{}}, sequenceValue()), protoconv.ErrSetElementRepeated}, + {"empty set and the empty sequence", setOf(setOf(), sequenceValue()), protoconv.ErrSetElementRepeated}, + {"unset element", setOf(&pb.Value{Kind: &pb.Value_Unset{Unset: true}}), protoconv.ErrUnsetNotAccepted}, + {"malformed element", setOf(arrayValue([]int64{0})), protoconv.ErrArrayDimensionNotPositive}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, err := ProtoToValueIn(tc.val, idx, sem) + _, err := protoconv.ProtoToValueIn(tc.val, idx, sem) if !errors.Is(err, tc.want) { - t.Fatalf("ProtoToValueIn = %v, want %v", err, tc.want) + t.Fatalf("protoconv.ProtoToValueIn = %v, want %v", err, tc.want) } }) } // A set is not the sequence of its members: the two are distinct members of a set. - val, err := ProtoToValueIn(setOf(setOf(intValue(1), intValue(2)), sequenceValue(intValue(1), intValue(2)), sequenceValue(intValue(2), intValue(1))), idx, sem) + val, err := protoconv.ProtoToValueIn(setOf(setOf(intValue(1), intValue(2)), sequenceValue(intValue(1), intValue(2)), sequenceValue(intValue(2), intValue(1))), idx, sem) if err != nil || val.Kind != runtime.ValSet || val.Set().Size() != 3 { t.Fatalf("a set holding a set and the two sequences of its members = %s, %v, want three members", runtime.FormatValue(val), err) } @@ -220,7 +221,7 @@ func TestSetHoldingAMemberWithNoWireFormIsWithheldWhole(t *testing.T) { t.Fatalf("two frames read from two objects make a set of %d", set.Size()) } const want = "unsupported: set Set{spatialCF [], spatialCF []} holding coordinate frame spatialCF []" - if pv := ValueToProto(runtime.NewSetValue(set), nil); pv.GetNull() != want { + if pv := protoconv.ValueToProto(runtime.NewSetValue(set), nil); pv.GetNull() != want { t.Errorf("set of two frames crossed as %v, want null %q", pv, want) } seq := runtime.NewSequence() @@ -228,7 +229,7 @@ func TestSetHoldingAMemberWithNoWireFormIsWithheldWhole(t *testing.T) { seq.Append(frame) } seq.Append(runtime.NewSetValue(set)) - pv := ValueToProto(runtime.NewSequenceValue(seq), nil) + pv := protoconv.ValueToProto(runtime.NewSequenceValue(seq), nil) elems := pv.GetSequence().GetElements() if len(elems) != 3 || elems[0].GetNull() != "unsupported: coordinate frame spatialCF []" || elems[2].GetNull() != want { t.Errorf("sequence of two frames and their set crossed as %v", pv) @@ -239,13 +240,13 @@ func TestSetHoldingAMemberWithNoWireFormIsWithheldWhole(t *testing.T) { mixed := runtime.NewSet() mixed.Add(runtime.NewStringValue("a")) mixed.Add(frames[0]) - if pv := ValueToProto(runtime.NewSetValue(mixed), nil); pv.GetNull() != `unsupported: set Set{"a", spatialCF []} holding coordinate frame spatialCF []` { + if pv := protoconv.ValueToProto(runtime.NewSetValue(mixed), nil); pv.GetNull() != `unsupported: set Set{"a", spatialCF []} holding coordinate frame spatialCF []` { t.Errorf("set of a string and a frame crossed as %v", pv) } outer := runtime.NewSet() outer.Add(runtime.NewSetValue(mixed)) outer.Add(runtime.NewStringValue("b")) - if pv := ValueToProto(runtime.NewSetValue(outer), nil); !strings.HasPrefix(pv.GetNull(), `unsupported: set Set{"b", Set{"a", spatialCF []}} holding set `) { + if pv := protoconv.ValueToProto(runtime.NewSetValue(outer), nil); !strings.HasPrefix(pv.GetNull(), `unsupported: set Set{"b", Set{"a", spatialCF []}} holding set `) { t.Errorf("set nesting the set crossed as %v", pv) } } @@ -336,7 +337,7 @@ func TestTensorQuantityRoundTrip(t *testing.T) { t.Errorf("%s crossed as %s, want %s", tc.expr, got, tc.want) } - back, err := ProtoToValueIn(pv, idx, sem) + back, err := protoconv.ProtoToValueIn(pv, idx, sem) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -357,21 +358,21 @@ func TestTensorQuantityRoundTrip(t *testing.T) { // A tensor of rank one is its own kind, as it is in the runtime. metre := mustEvaluateQuantity(t, srv, modelHash, "3 [SI::m]") - line, err := ProtoToValueIn(tensorQuantityValue([]int64{2}, metre, metre), idx, sem) + line, err := protoconv.ProtoToValueIn(tensorQuantityValue([]int64{2}, metre, metre), idx, sem) if err != nil { t.Fatal(err) } if line.Kind != runtime.ValTensorQuantity || runtime.FormatValue(line) != "Tensor(2)[3, 3] [SI::m]" { t.Errorf("rank-one tensor read back as %s %s", line.Kind, runtime.FormatValue(line)) } - if pv := ValueToProto(line, idx); pv.GetTensorQuantity() == nil { + if pv := protoconv.ValueToProto(line, idx); pv.GetTensorQuantity() == nil { t.Errorf("rank-one tensor crossed as %T", pv.GetKind()) } // A tensor with no magnitude in a component still has no wire form. num := []semantics.Value{{Kind: semantics.ValInt, Int: 1}, {}} units := []runtime.Unit{line.TensorQuantity().Units[0], line.TensorQuantity().Units[0]} - pv := ValueToProto(runtime.NewTensorQuantityValue([]int64{2}, num, units), idx) + pv := protoconv.ValueToProto(runtime.NewTensorQuantityValue([]int64{2}, num, units), idx) if pv.GetNull() != "unsupported: tensor quantity with a non-numeric component" { t.Errorf("tensor with a non-numeric component crossed as %v", pv) } @@ -382,7 +383,7 @@ func mustEvaluateTensor(t *testing.T, srv *Service, modelHash, expr string) *run t.Helper() cached, _ := srv.cache.Get(modelHash) idx, sem := cached.Index, NewSymbolContext(cached.Index).Semantics - val, err := ProtoToValueIn(mustEvaluate(t, srv, modelHash, expr), idx, sem) + val, err := protoconv.ProtoToValueIn(mustEvaluate(t, srv, modelHash, expr), idx, sem) if err != nil || val.Kind != runtime.ValTensorQuantity { t.Fatalf("%s = %v, %v; want a tensor", expr, val, err) } @@ -404,20 +405,20 @@ func TestMalformedTensorQuantitiesAreRejected(t *testing.T) { val *pb.Value want error }{ - {"too few components", tensorQuantityValue([]int64{2, 2, 2}, metre, metre, metre, metre, metre, metre, metre), ErrTensorShapeMismatch}, - {"too many components", tensorQuantityValue([]int64{2, 2}, metre, metre, metre, metre, metre), ErrTensorShapeMismatch}, - {"rank 0 without its component", tensorQuantityValue(nil), ErrTensorShapeMismatch}, - {"zero dimension", tensorQuantityValue([]int64{0, 2}), ErrTensorDimensionNotPositive}, - {"negative dimension", tensorQuantityValue([]int64{2, -1}), ErrTensorDimensionNotPositive}, - {"overflowing shape", tensorQuantityValue([]int64{1 << 40, 1 << 40}, metre), ErrTensorShapeMismatch}, - {"unreduced unit", tensorQuantityValue([]int64{1}, unreduced), ErrUnitNotReduced}, - {"missing component", tensorQuantityValue([]int64{1}, nil), ErrTensorComponentMissing}, + {"too few components", tensorQuantityValue([]int64{2, 2, 2}, metre, metre, metre, metre, metre, metre, metre), protoconv.ErrTensorShapeMismatch}, + {"too many components", tensorQuantityValue([]int64{2, 2}, metre, metre, metre, metre, metre), protoconv.ErrTensorShapeMismatch}, + {"rank 0 without its component", tensorQuantityValue(nil), protoconv.ErrTensorShapeMismatch}, + {"zero dimension", tensorQuantityValue([]int64{0, 2}), protoconv.ErrTensorDimensionNotPositive}, + {"negative dimension", tensorQuantityValue([]int64{2, -1}), protoconv.ErrTensorDimensionNotPositive}, + {"overflowing shape", tensorQuantityValue([]int64{1 << 40, 1 << 40}, metre), protoconv.ErrTensorShapeMismatch}, + {"unreduced unit", tensorQuantityValue([]int64{1}, unreduced), protoconv.ErrUnitNotReduced}, + {"missing component", tensorQuantityValue([]int64{1}, nil), protoconv.ErrTensorComponentMissing}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, err := ProtoToValueIn(tc.val, idx, sem) + _, err := protoconv.ProtoToValueIn(tc.val, idx, sem) if !errors.Is(err, tc.want) { - t.Fatalf("ProtoToValueIn = %v, want %v", err, tc.want) + t.Fatalf("protoconv.ProtoToValueIn = %v, want %v", err, tc.want) } }) } @@ -478,8 +479,8 @@ func TestSetAndTensorCrossEveryValueSurface(t *testing.T) { if err != nil { t.Fatalf("EvaluateCalc(repeated): %v", err) } - if !strings.Contains(calc.Error, ErrSetElementRepeated.Error()) { - t.Errorf("EvaluateCalc(repeated) error = %q, want one naming %v", calc.Error, ErrSetElementRepeated) + if !strings.Contains(calc.Error, protoconv.ErrSetElementRepeated.Error()) { + t.Errorf("EvaluateCalc(repeated) error = %q, want one naming %v", calc.Error, protoconv.ErrSetElementRepeated) } calc, err = srv.EvaluateCalc(ctx, &pb.EvaluateCalcRequest{ModelHash: modelHash, SymbolId: "W::rank", Arguments: []*pb.Value{ tensorQuantityValue([]int64{2}, mustEvaluateQuantity(t, srv, modelHash, "3 [SI::m]")), @@ -487,8 +488,8 @@ func TestSetAndTensorCrossEveryValueSurface(t *testing.T) { if err != nil { t.Fatalf("EvaluateCalc(misshapen): %v", err) } - if !strings.Contains(calc.Error, ErrTensorShapeMismatch.Error()) { - t.Errorf("EvaluateCalc(misshapen) error = %q, want one naming %v", calc.Error, ErrTensorShapeMismatch) + if !strings.Contains(calc.Error, protoconv.ErrTensorShapeMismatch.Error()) { + t.Errorf("EvaluateCalc(misshapen) error = %q, want one naming %v", calc.Error, protoconv.ErrTensorShapeMismatch) } } @@ -529,7 +530,7 @@ func TestSetAndTensorCapabilities(t *testing.T) { t.Errorf("W::cube without %s = %v, want a tensor", CapabilitySetValues, got) } noComplex := mustNewServiceWithout(t, CapabilityComplexValues) - pv := setOf(intValue(1), &pb.Value{Kind: &pb.Value_Complex{Complex: ComplexToProto(complex(0, 1))}}) + pv := setOf(intValue(1), &pb.Value{Kind: &pb.Value_Complex{Complex: protoconv.ComplexToProto(complex(0, 1))}}) noComplex.filterValueCapabilities(pv) if want := "unsupported: set Set{1, 0.0 + 1.0i} holding complex number 0.0 + 1.0i"; pv.GetNull() != want { t.Errorf("set of a complex without complex_values = %v, want null %q", pv, want) @@ -601,11 +602,11 @@ func TestValueCarriesSetAndTensor(t *testing.T) { {"set of tensors", setOf(tensor), true, true}, {"vector quantity", vectorQuantityValue(&pb.Quantity{Unit: "m"}), false, false}, } { - if got := ValueCarriesSet(tc.value); got != tc.set { - t.Errorf("ValueCarriesSet(%s) = %v, want %v", tc.name, got, tc.set) + if got := protoconv.ValueCarriesSet(tc.value); got != tc.set { + t.Errorf("protoconv.ValueCarriesSet(%s) = %v, want %v", tc.name, got, tc.set) } - if got := ValueCarriesTensor(tc.value); got != tc.tensr { - t.Errorf("ValueCarriesTensor(%s) = %v, want %v", tc.name, got, tc.tensr) + if got := protoconv.ValueCarriesTensor(tc.value); got != tc.tensr { + t.Errorf("protoconv.ValueCarriesTensor(%s) = %v, want %v", tc.name, got, tc.tensr) } } } diff --git a/internal/grpc/convert_structured_test.go b/internal/frontend/grpc/convert_structured_test.go similarity index 87% rename from internal/grpc/convert_structured_test.go rename to internal/frontend/grpc/convert_structured_test.go index f0656debf3..dd6871c6bf 100644 --- a/internal/grpc/convert_structured_test.go +++ b/internal/frontend/grpc/convert_structured_test.go @@ -9,9 +9,10 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // structuredWireModel yields each structured kind as a feature value and takes a @@ -109,7 +110,7 @@ func TestArrayRoundTrip(t *testing.T) { srv := mustNewService(t, 4) _, idx, sem := mustStructuredModel(t, srv) metre := mustEvaluateQuantity(t, srv, mustParse(t, srv, quantityModel), "3 [SI::m]") - metreVal, err := ProtoToQuantity(metre, idx, sem) + metreVal, err := protoconv.ProtoToQuantity(metre, idx, sem) if err != nil { t.Fatalf("ProtoToQuantity: %v", err) } @@ -136,14 +137,14 @@ func TestArrayRoundTrip(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - pv := ValueToProto(tc.val, idx) + pv := protoconv.ValueToProto(tc.val, idx) if pv.GetArray() == nil { t.Fatalf("crossed as %T: %v", pv.GetKind(), pv) } if got, want := len(pv.GetArray().GetDimensions()), tc.val.Array().Rank(); got != want { t.Errorf("%d dimensions, want %d", got, want) } - back, err := ProtoToValueIn(pv, idx, sem) + back, err := protoconv.ProtoToValueIn(pv, idx, sem) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -160,7 +161,7 @@ func TestArrayRoundTrip(t *testing.T) { } // A quantity element keeps its reduction over the model's own base units. - back, err := ProtoToValueIn(ValueToProto(inner, idx), idx, sem) + back, err := protoconv.ProtoToValueIn(protoconv.ValueToProto(inner, idx), idx, sem) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -196,14 +197,14 @@ func TestVectorRoundTrip(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - pv := ValueToProto(tc.val, idx) + pv := protoconv.ValueToProto(tc.val, idx) if pv.GetVector() == nil { t.Fatalf("crossed as %T: %v", pv.GetKind(), pv) } if got, want := len(pv.GetVector().GetComponents()), tc.val.Vector().Dimension(); got != want { t.Errorf("%d components, want %d", got, want) } - back, err := ProtoToValueIn(pv, idx, nil) + back, err := protoconv.ProtoToValueIn(pv, idx, nil) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -227,9 +228,9 @@ func TestVectorRoundTrip(t *testing.T) { func TestVectorQuantityRoundTrip(t *testing.T) { srv, modelHash, idx, sem := mustQuantityModel(t) quantity := func(expr string) *runtime.Quantity { - val, err := ProtoToQuantity(mustEvaluateQuantity(t, srv, modelHash, expr), idx, sem) + val, err := protoconv.ProtoToQuantity(mustEvaluateQuantity(t, srv, modelHash, expr), idx, sem) if err != nil { - t.Fatalf("ProtoToQuantity(%s): %v", expr, err) + t.Fatalf("protoconv.ProtoToQuantity(%s): %v", expr, err) } return val.Quantity() } @@ -254,7 +255,7 @@ func TestVectorQuantityRoundTrip(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - pv := ValueToProto(tc.val, idx) + pv := protoconv.ValueToProto(tc.val, idx) if pv.GetVectorQuantity() == nil { t.Fatalf("crossed as %T: %v", pv.GetKind(), pv) } @@ -267,7 +268,7 @@ func TestVectorQuantityRoundTrip(t *testing.T) { t.Errorf("component %d crossed without its reduction", i+1) } } - back, err := ProtoToValueIn(pv, idx, sem) + back, err := protoconv.ProtoToValueIn(pv, idx, sem) if err != nil { t.Fatalf("ProtoToValueIn: %v", err) } @@ -302,32 +303,32 @@ func TestMalformedStructuredValuesAreRejected(t *testing.T) { val *pb.Value want error }{ - {"too few elements", arrayValue([]int64{2, 3}, intValue(1), intValue(2)), ErrArrayShapeMismatch}, - {"too many elements", arrayValue([]int64{2}, intValue(1), intValue(2), intValue(3)), ErrArrayShapeMismatch}, - {"rank 0 with two elements", arrayValue(nil, intValue(1), intValue(2)), ErrArrayShapeMismatch}, - {"zero dimension", arrayValue([]int64{0}), ErrArrayDimensionNotPositive}, - {"negative dimension", arrayValue([]int64{-1, 2}, intValue(1), intValue(2)), ErrArrayDimensionNotPositive}, - {"overflowing dimensions", arrayValue([]int64{1 << 62, 4}), ErrArrayShapeMismatch}, - {"nested malformed array", arrayValue([]int64{1}, arrayValue([]int64{2}, intValue(1))), ErrArrayShapeMismatch}, - {"array of unset", arrayValue([]int64{1}, &pb.Value{Kind: &pb.Value_Unset{Unset: true}}), ErrUnsetNotAccepted}, - {"string component", vectorValue(realValue(1), &pb.Value{Kind: &pb.Value_StringValue{StringValue: "2"}}), ErrVectorComponentNotNumeric}, - {"bool component", vectorValue(&pb.Value{Kind: &pb.Value_BoolValue{BoolValue: true}}), ErrVectorComponentNotNumeric}, - {"quantity component", vectorValue(&pb.Value{Kind: &pb.Value_Quantity{Quantity: metre}}), ErrVectorComponentNotNumeric}, - {"nested vector component", vectorValue(vectorValue(intValue(1))), ErrVectorComponentNotNumeric}, - {"empty component", vectorValue(&pb.Value{}), ErrVectorComponentNotNumeric}, - {"empty vector quantity", vectorQuantityValue(), ErrVectorQuantityEmpty}, - {"unreduced unit", vectorQuantityValue(metre, unreduced), ErrUnitNotReduced}, + {"too few elements", arrayValue([]int64{2, 3}, intValue(1), intValue(2)), protoconv.ErrArrayShapeMismatch}, + {"too many elements", arrayValue([]int64{2}, intValue(1), intValue(2), intValue(3)), protoconv.ErrArrayShapeMismatch}, + {"rank 0 with two elements", arrayValue(nil, intValue(1), intValue(2)), protoconv.ErrArrayShapeMismatch}, + {"zero dimension", arrayValue([]int64{0}), protoconv.ErrArrayDimensionNotPositive}, + {"negative dimension", arrayValue([]int64{-1, 2}, intValue(1), intValue(2)), protoconv.ErrArrayDimensionNotPositive}, + {"overflowing dimensions", arrayValue([]int64{1 << 62, 4}), protoconv.ErrArrayShapeMismatch}, + {"nested malformed array", arrayValue([]int64{1}, arrayValue([]int64{2}, intValue(1))), protoconv.ErrArrayShapeMismatch}, + {"array of unset", arrayValue([]int64{1}, &pb.Value{Kind: &pb.Value_Unset{Unset: true}}), protoconv.ErrUnsetNotAccepted}, + {"string component", vectorValue(realValue(1), &pb.Value{Kind: &pb.Value_StringValue{StringValue: "2"}}), protoconv.ErrVectorComponentNotNumeric}, + {"bool component", vectorValue(&pb.Value{Kind: &pb.Value_BoolValue{BoolValue: true}}), protoconv.ErrVectorComponentNotNumeric}, + {"quantity component", vectorValue(&pb.Value{Kind: &pb.Value_Quantity{Quantity: metre}}), protoconv.ErrVectorComponentNotNumeric}, + {"nested vector component", vectorValue(vectorValue(intValue(1))), protoconv.ErrVectorComponentNotNumeric}, + {"empty component", vectorValue(&pb.Value{}), protoconv.ErrVectorComponentNotNumeric}, + {"empty vector quantity", vectorQuantityValue(), protoconv.ErrVectorQuantityEmpty}, + {"unreduced unit", vectorQuantityValue(metre, unreduced), protoconv.ErrUnitNotReduced}, {"unknown base unit", vectorQuantityValue(&pb.Quantity{ Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, Unit: "furlong", UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "SI::furlong", Exponent: 1}}}, - }), ErrUnknownBaseUnit}, - {"component without quantity", vectorQuantityValue(metre, nil), ErrVectorComponentNotNumeric}, + }), protoconv.ErrUnknownBaseUnit}, + {"component without quantity", vectorQuantityValue(metre, nil), protoconv.ErrVectorComponentNotNumeric}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - val, err := ProtoToValueIn(tc.val, idx, sem) + val, err := protoconv.ProtoToValueIn(tc.val, idx, sem) if !errors.Is(err, tc.want) { - t.Fatalf("ProtoToValueIn = %v, %v; want %v", val, err, tc.want) + t.Fatalf("protoconv.ProtoToValueIn = %v, %v; want %v", val, err, tc.want) } if val.Kind != runtime.ValInvalid { t.Errorf("a rejected value was still returned: %v", val) @@ -336,8 +337,8 @@ func TestMalformedStructuredValuesAreRejected(t *testing.T) { } // A quantity component's reduction needs the model, as a scalar's does. - if _, err := ProtoToValueIn(vectorQuantityValue(metre), nil, nil); !errors.Is(err, ErrQuantityNeedsIndex) { - t.Errorf("without an index: err = %v, want %v", err, ErrQuantityNeedsIndex) + if _, err := protoconv.ProtoToValueIn(vectorQuantityValue(metre), nil, nil); !errors.Is(err, protoconv.ErrQuantityNeedsIndex) { + t.Errorf("without an index: err = %v, want %v", err, protoconv.ErrQuantityNeedsIndex) } } @@ -429,8 +430,8 @@ func TestStructuredValuesCrossEveryValueSurface(t *testing.T) { if err != nil { t.Fatalf("EvaluateCalc(malformed): %v", err) } - if !strings.Contains(calc.Error, ErrVectorComponentNotNumeric.Error()) { - t.Errorf("EvaluateCalc(malformed) error = %q, want one naming %v", calc.Error, ErrVectorComponentNotNumeric) + if !strings.Contains(calc.Error, protoconv.ErrVectorComponentNotNumeric.Error()) { + t.Errorf("EvaluateCalc(malformed) error = %q, want one naming %v", calc.Error, protoconv.ErrVectorComponentNotNumeric) } } @@ -464,7 +465,7 @@ func TestStructuredValuesCapability(t *testing.T) { // An array's elements are filtered like any values when the arm itself crosses. served := mustNewServiceWithout(t, CapabilityComplexValues) - pv := arrayValue([]int64{1}, &pb.Value{Kind: &pb.Value_Complex{Complex: ComplexToProto(complex(0, 1))}}) + pv := arrayValue([]int64{1}, &pb.Value{Kind: &pb.Value_Complex{Complex: protoconv.ComplexToProto(complex(0, 1))}}) served.filterValueCapabilities(pv) if pv.GetArray() == nil || !strings.Contains(pv.GetArray().GetElements()[0].GetNull(), "complex number") { t.Errorf("array of a complex without complex_values = %v, want the element withheld", pv) @@ -489,13 +490,13 @@ func TestValueCarriesStructured(t *testing.T) { {"sequence of ints", sequence(one, one), false}, {"sequence with an array", sequence(one, sequence(arrayValue([]int64{1}, one))), true}, } { - if got := ValueCarriesStructured(tc.value); got != tc.want { - t.Errorf("ValueCarriesStructured(%s) = %v, want %v", tc.name, got, tc.want) + if got := protoconv.ValueCarriesStructured(tc.value); got != tc.want { + t.Errorf("protoconv.ValueCarriesStructured(%s) = %v, want %v", tc.name, got, tc.want) } } - z := &pb.Value{Kind: &pb.Value_Complex{Complex: ComplexToProto(complex(1, 2))}} - if !ValueCarriesComplex(arrayValue([]int64{1}, z)) { - t.Error("ValueCarriesComplex misses a complex element of an array") + z := &pb.Value{Kind: &pb.Value_Complex{Complex: protoconv.ComplexToProto(complex(1, 2))}} + if !protoconv.ValueCarriesComplex(arrayValue([]int64{1}, z)) { + t.Error("protoconv.ValueCarriesComplex misses a complex element of an array") } } @@ -532,12 +533,12 @@ func TestCoordinateFrameCrossesAsUnsupported(t *testing.T) { "unsupported: coordinate frame spatialCF [m, m, m]": runtime.NewCoordinateFrameValue(frame), "unsupported: coordinate transformation a coordinate transformation (spatialCF → spatialCF)": runtime.NewCoordinateTransformationValue(&runtime.CoordinateTransformation{Source: frame, Target: frame}), } { - pv := ValueToProto(val, nil) + pv := protoconv.ValueToProto(val, nil) if pv.GetSequence() != nil || pv.GetQuantity() != nil || pv.GetStringValue() != "" { t.Fatalf("%s crossed as %T: %v", runtime.FormatValue(val), pv.GetKind(), pv) } if pv.GetNull() != want { - t.Errorf("ValueToProto(%s) = %v, want null %q", runtime.FormatValue(val), pv, want) + t.Errorf("protoconv.ValueToProto(%s) = %v, want null %q", runtime.FormatValue(val), pv, want) } } } diff --git a/internal/frontend/grpc/convert_test.go b/internal/frontend/grpc/convert_test.go new file mode 100644 index 0000000000..631e7398ac --- /dev/null +++ b/internal/frontend/grpc/convert_test.go @@ -0,0 +1,276 @@ +package grpc + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/objref" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// TestSymbolToProto verifies exported SymbolToProtoIn API. +func TestSymbolToProto(t *testing.T) { + sym := &symbols.Symbol{ + Name: "TestPart", + Kind: symbols.SymbolPartDef, + Visibility: ast.VisibilityPublic, + DeclSpan: source.Span{Offset: 10, Len: 10}, + } + + idx := symbols.NewIndex() + + proto := SymbolToProtoIn(sym, NewSymbolContext(idx)) + + if proto.Name != "TestPart" { + t.Errorf("expected name TestPart, got %s", proto.Name) + } + if proto.Kind != "partDef" { + t.Errorf("expected kind partDef, got %s", proto.Kind) + } + if proto.Id == "" { + t.Error("expected non-empty ID") + } + if proto.Metadata["visibility"] != "public" { + t.Errorf("expected visibility public, got %s", proto.Metadata["visibility"]) + } +} + +// TestDiagnosticToProto verifies DiagnosticToProto conversion. +func TestDiagnosticToProto(t *testing.T) { + diag := diag.Diagnostic{ + Severity: diag.SeverityError, + Message: "test error", + Span: source.Span{Offset: 5, Len: 4}, // "Test" at position 5 + } + + sf := source.New("test.sysml", []byte("part Test { }")) + proto := DiagnosticToProto(diag, sf) + + if proto.Severity != "error" { + t.Errorf("expected severity error, got %s", proto.Severity) + } + if proto.Message != "test error" { + t.Errorf("expected message 'test error', got %s", proto.Message) + } + if proto.Span.File != "test.sysml" { + t.Error("expected file test.sysml") + } + if proto.Span.StartLine != 1 { + t.Errorf("expected StartLine 1, got %d", proto.Span.StartLine) + } +} + +// TestConvertSpan verifies source.Span → proto.Span conversion. +func TestConvertSpan(t *testing.T) { + // Create a simple source file + content := []byte("line1\nline2\nline3") + sf := source.New("test.sysml", content) + + // Span covering "line2" (bytes 6-11) + sp := source.Span{Offset: 6, Len: 5} + + pb := DiagnosticToProto(diag.Diagnostic{Span: sp}, sf).Span + if pb.File != "test.sysml" { + t.Errorf("File: got %q, want %q", pb.File, "test.sysml") + } + if pb.StartLine != 2 { + t.Errorf("StartLine: got %d, want 2", pb.StartLine) + } + if pb.EndLine != 2 { + t.Errorf("EndLine: got %d, want 2", pb.EndLine) + } + // Columns are 1-based byte columns + if pb.StartCol != 1 { + t.Errorf("StartCol: got %d, want 1", pb.StartCol) + } + if pb.EndCol != 6 { + t.Errorf("EndCol: got %d, want 6", pb.EndCol) + } +} + +// TestConvertSymbolBasic verifies Symbol → SymbolInfo conversion for a simple part def. +func TestConvertSymbolBasic(t *testing.T) { + // Parse real model to get symbol with proper structure + content := `package Test { part def MyPart; }` + sf := source.New("test.sysml", []byte(content)) + p := parser.New(sf) + root := p.ParseFile() + + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", root) + + // Lookup MyPart symbol + syms := idx.LookupQualified("Test::MyPart") + if len(syms) == 0 { + t.Fatal("MyPart symbol not found") + } + + pbSym := SymbolToProtoIn(syms[0], NewSymbolContext(idx)) + if pbSym.Id != "Test::MyPart" { + t.Errorf("Id: got %q, want %q", pbSym.Id, "Test::MyPart") + } + if pbSym.Name != "MyPart" { + t.Errorf("Name: got %q, want %q", pbSym.Name, "MyPart") + } + if pbSym.Kind != "partDef" { + t.Errorf("Kind: got %q, want %q", pbSym.Kind, "partDef") + } +} + +// TestConvertSymbolWithChildren verifies child_ids population with FQNs. +func TestConvertSymbolWithChildren(t *testing.T) { + // Parse model with nested symbols + content := `package Parent { part def Child1; attribute def Child2; }` + sf := source.New("test.sysml", []byte(content)) + p := parser.New(sf) + root := p.ParseFile() + + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", root) + + // Lookup Parent symbol + parents := idx.LookupQualified("Parent") + if len(parents) == 0 { + t.Fatal("Parent symbol not found") + } + + pbSym := SymbolToProtoIn(parents[0], NewSymbolContext(idx)) + if len(pbSym.ChildIds) != 2 { + t.Fatalf("ChildIds: got %d, want 2", len(pbSym.ChildIds)) + } + + // Verify FQNs are present + found := map[string]bool{} + for _, id := range pbSym.ChildIds { + found[id] = true + } + if !found["Parent::Child1"] { + t.Errorf("Missing Parent::Child1 in ChildIds, got: %v", pbSym.ChildIds) + } + if !found["Parent::Child2"] { + t.Errorf("Missing Parent::Child2 in ChildIds, got: %v", pbSym.ChildIds) + } +} + +// TestConvertSymbolMetadata verifies metadata extraction from Usage node. +func TestConvertSymbolMetadata(t *testing.T) { + // Parse model with typed attribute + content := `package MyPart { attribute mass : Real [1]; }` + sf := source.New("test.sysml", []byte(content)) + p := parser.New(sf) + root := p.ParseFile() + + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", root) + + // Lookup mass symbol + syms := idx.LookupQualified("MyPart::mass") + if len(syms) == 0 { + t.Fatal("mass symbol not found") + } + + pbSym := SymbolToProtoIn(syms[0], NewSymbolContext(idx)) + + // Check metadata (parser produces "1" for single value, not "1..1") + if pbSym.Metadata["multiplicity"] != "1" { + t.Errorf("Metadata[multiplicity]: got %q, want %q", pbSym.Metadata["multiplicity"], "1") + } + if pbSym.Metadata["type"] != "Real" { + t.Errorf("Metadata[type]: got %q, want %q", pbSym.Metadata["type"], "Real") + } +} + +// TestSymbolIdIsFQN verifies Symbol.Id contains fully-qualified names for nested symbols. +func TestSymbolIdIsFQN(t *testing.T) { + // Parse nested model + content := ` +package Vehicle { + part def Engine { + part combustionChamber; + } +} +` + sf := source.New("test.sysml", []byte(content)) + p := parser.New(sf) + root := p.ParseFile() + + idx := symbols.NewIndex() + idx.AddDocument("test.sysml", root) + + // Verify Vehicle has correct FQN + vehicles := idx.LookupQualified("Vehicle") + if len(vehicles) == 0 { + t.Fatal("Vehicle symbol not found") + } + vehiclePb := SymbolToProtoIn(vehicles[0], NewSymbolContext(idx)) + if vehiclePb.Id != "Vehicle" { + t.Errorf("Vehicle Id: got %q, want %q", vehiclePb.Id, "Vehicle") + } + + // Verify Engine has FQN "Vehicle::Engine" + engines := idx.LookupQualified("Vehicle::Engine") + if len(engines) == 0 { + t.Fatal("Vehicle::Engine symbol not found") + } + enginePb := SymbolToProtoIn(engines[0], NewSymbolContext(idx)) + if enginePb.Id != "Vehicle::Engine" { + t.Errorf("Engine Id: got %q, want %q", enginePb.Id, "Vehicle::Engine") + } + if enginePb.Name != "Engine" { + t.Errorf("Engine Name: got %q, want %q", enginePb.Name, "Engine") + } + + // Verify combustionChamber has FQN "Vehicle::Engine::combustionChamber" + chambers := idx.LookupQualified("Vehicle::Engine::combustionChamber") + if len(chambers) == 0 { + t.Fatal("Vehicle::Engine::combustionChamber symbol not found") + } + chamberPb := SymbolToProtoIn(chambers[0], NewSymbolContext(idx)) + if chamberPb.Id != "Vehicle::Engine::combustionChamber" { + t.Errorf("combustionChamber Id: got %q, want %q", chamberPb.Id, "Vehicle::Engine::combustionChamber") + } + if chamberPb.Name != "combustionChamber" { + t.Errorf("combustionChamber Name: got %q, want %q", chamberPb.Name, "combustionChamber") + } + + // Verify Engine.ChildIds contains FQN + if len(enginePb.ChildIds) != 1 { + t.Fatalf("Engine ChildIds count: got %d, want 1", len(enginePb.ChildIds)) + } + if enginePb.ChildIds[0] != "Vehicle::Engine::combustionChamber" { + t.Errorf("Engine ChildIds[0]: got %q, want %q", enginePb.ChildIds[0], "Vehicle::Engine::combustionChamber") + } +} + +// TestCollectionElementsHandlesSetAndSequence verifies a collection feature value is +// marshalled whichever collection kind the runtime left in it. +func TestCollectionElementsHandlesSetAndSequence(t *testing.T) { + one := runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 1}} + two := runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: 2}} + + seq := runtime.NewSequence() + seq.Append(one) + seq.Append(two) + + set := runtime.NewSet() + set.Add(one) + set.Add(two) + + for name, val := range map[string]runtime.Value{ + "sequence": runtime.NewSequenceValue(seq), + "set": runtime.NewSetValue(set), + } { + if got := len(objref.CollectionElements(val)); got != 2 { + t.Errorf("%s: got %d elements, want 2", name, got) + } + } + + if got := objref.CollectionElements(runtime.Value{Kind: runtime.ValNull}); got != nil { + t.Errorf("non-collection: got %v, want nil", got) + } +} diff --git a/internal/frontend/grpc/describe_test.go b/internal/frontend/grpc/describe_test.go new file mode 100644 index 0000000000..1259c4e1b3 --- /dev/null +++ b/internal/frontend/grpc/describe_test.go @@ -0,0 +1,50 @@ +package grpc + +import ( + "fmt" + "strconv" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +// describeValue reports the set oneof arm of a pb.Value and its payload. +func describeValue(v *pb.Value) (string, interface{}) { + switch k := v.Kind.(type) { + case *pb.Value_IntValue: + return "int_value", k.IntValue + case *pb.Value_RealValue: + return "real_value", k.RealValue + case *pb.Value_BoolValue: + return "bool_value", k.BoolValue + case *pb.Value_StringValue: + return "string_value", k.StringValue + case *pb.Value_InstanceId: + return "instance_id", k.InstanceId + case *pb.Value_Sequence: + return "sequence", k.Sequence + case *pb.Value_Quantity: + return "quantity", describeQuantity(k.Quantity) + case *pb.Value_Null: + return "null", nil + case *pb.Value_Unset: + return "unset", nil + default: + return "no arm", nil + } +} + +// describeQuantity renders a quantity as " [] = +// ", the form the wire tests pin. +func describeQuantity(q *pb.Quantity) string { + if q == nil { + return "" + } + magnitude := "unset" + switch m := q.GetMagnitude().(type) { + case *pb.Quantity_IntMagnitude: + magnitude = strconv.FormatInt(m.IntMagnitude, 10) + case *pb.Quantity_RealMagnitude: + magnitude = strconv.FormatFloat(m.RealMagnitude, 'g', -1, 64) + } + return fmt.Sprintf("%s [%s] = %s", magnitude, q.GetUnit(), describeUnitTerm(q.GetUnitTerm())) +} diff --git a/internal/grpc/diagnostic_code_test.go b/internal/frontend/grpc/diagnostic_code_test.go similarity index 99% rename from internal/grpc/diagnostic_code_test.go rename to internal/frontend/grpc/diagnostic_code_test.go index 086a4cb844..7de80487e7 100644 --- a/internal/grpc/diagnostic_code_test.go +++ b/internal/frontend/grpc/diagnostic_code_test.go @@ -6,7 +6,7 @@ import ( "testing" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" "google.golang.org/protobuf/proto" ) diff --git a/internal/frontend/grpc/docquery.go b/internal/frontend/grpc/docquery.go new file mode 100644 index 0000000000..393b00b3d7 --- /dev/null +++ b/internal/frontend/grpc/docquery.go @@ -0,0 +1,482 @@ +package grpc + +import ( + "context" + "errors" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/doc/docir" + "github.com/Open-MBEE/OpenSysML/internal/doc/docrender" + "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/ir/docplan" + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + corequery "github.com/Open-MBEE/OpenSysML/internal/semantic/query" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// RunDocumentQuery runs a named document query with parameter bindings, the +// answer %run-query gives, as typed rows rather than formatted lines. +func (s *Service) RunDocumentQuery(ctx context.Context, req *pb.RunDocumentQueryRequest) (*pb.RunDocumentQueryResponse, error) { + if err := s.requireCapability(CapabilityDocumentQuery); err != nil { + return nil, err + } + cached, ok := s.cache.Get(req.ModelHash) + if !ok { + return nil, statusErrorf(connect.CodeNotFound, msgModelNotFound, req.ModelHash) + } + if req.QueryId == "" { + return nil, statusError(connect.CodeInvalidArgument, "a document query to run must be named in query_id") + } + // The query runs over the model's runtime and the objects it holds, as + // %run-query runs over the session's; Objects answers no rows while none are held. + held := s.objects(cached) + defer held.lock()() + qctx := held.queryContext() + sym, err := documentSymbol(qctx.Index, req.QueryId) + if err != nil { + return nil, err + } + if !queryplan.IsQueryDefinition(qctx.Index, qctx.Model, sym) { + return nil, statusErrorf(connect.CodeInvalidArgument, + "%s is not a document query: one is a calc def specializing DocumentQueries::Query", req.QueryId) + } + program, err := queryplan.Compile(qctx.Index, qctx.Model, qctx.Resolver, sym) + if err != nil { + return nil, documentStatus(err) + } + bindings, err := documentBindings(qctx.Index, qctx.Model, held, req.Bindings) + if err != nil { + return nil, err + } + result, err := queryexec.Execute(program, qctx, bindings, queryexec.Options{}) + if err != nil { + return nil, held.documentStatus(err) + } + return rowSetResponse(qctx.Index, result), nil +} + +// Forms RenderDocument renders, named as the CLI's -doc-form names them. +const ( + renderFormMarkdown = "markdown" + renderFormHTML = "html" +) + +// RenderDocument renders a named document to Markdown or HTML, as +// -render-document does. +func (s *Service) RenderDocument(ctx context.Context, req *pb.RenderDocumentRequest) (*pb.RenderDocumentResponse, error) { + if err := s.requireCapability(CapabilityRenderDocument); err != nil { + return nil, err + } + form := req.Form + if form == "" { + form = renderFormMarkdown + } + if form != renderFormMarkdown && form != renderFormHTML { + return nil, statusErrorf(connect.CodeInvalidArgument, + "form %q is not one this service renders: markdown or html", req.Form) + } + if form == renderFormHTML { + if err := s.requireCapability(CapabilityRenderDocumentHTML); err != nil { + return nil, err + } + } + cached, ok := s.cache.Get(req.ModelHash) + if !ok { + return nil, statusErrorf(connect.CodeNotFound, msgModelNotFound, req.ModelHash) + } + if req.DocumentId == "" { + return nil, statusError(connect.CodeInvalidArgument, "a document to render must be named in document_id") + } + // A document reads the objects the model holds, as -render-document reads + // the ones -instantiate created beside it. + held := s.objects(cached) + defer held.lock()() + qctx := held.queryContext() + sym, err := documentSymbol(qctx.Index, req.DocumentId) + if err != nil { + return nil, err + } + if !docplan.IsDocumentDefinition(qctx.Index, qctx.Model, sym) { + return nil, statusErrorf(connect.CodeInvalidArgument, + "%s is not a document: one is a part def specializing DocumentQueries::Document", req.DocumentId) + } + plan, err := docplan.Compile(qctx.Index, qctx.Model, qctx.Resolver, sym) + if err != nil { + return nil, documentStatus(err) + } + document, err := docir.EvaluateLinked(plan, + model.SiblingDocumentPlans(qctx.Index, qctx.Model, qctx.Resolver, sym), + qctx, queryexec.Options{}, cachedSourceText(cached)) + if err != nil { + return nil, held.documentStatus(err) + } + if form == renderFormHTML { + page, err := docrender.HTML(document, docrender.HTMLOptions{}) + if err != nil { + return nil, documentStatus(err) + } + return &pb.RenderDocumentResponse{Html: page}, nil + } + markdown, err := docrender.Markdown(document, docrender.MarkdownOptions{}) + if err != nil { + return nil, documentStatus(err) + } + return &pb.RenderDocumentResponse{Markdown: markdown}, nil +} + +// documentSymbol resolves the query or document a request names, failing with +// NOT_FOUND when the model does not declare it. +func documentSymbol(idx *symbols.Index, id string) (*symbols.Symbol, error) { + syms := lookupNamed(idx, id) + if len(syms) == 0 { + return nil, statusErrorf(connect.CodeNotFound, "symbol not found: %s", id) + } + return syms[0], nil +} + +// documentBindings converts a request's typed bindings into the engine's, an object +// one against the objects held. Repeated parameters append, as %run-query's do. +func documentBindings(idx *symbols.Index, sem *semantics.Model, held *heldObjects, bindings []*pb.DocumentQueryBinding) (queryexec.Bindings, error) { + if len(bindings) == 0 { + return nil, nil + } + out := make(queryexec.Bindings, len(bindings)) + for _, binding := range bindings { + if binding.GetParameter() == "" { + return nil, statusError(connect.CodeInvalidArgument, "a binding must name the parameter it binds") + } + for _, value := range binding.GetValues() { + bound, err := boundValue(idx, sem, held, binding.GetParameter(), value) + if err != nil { + return nil, err + } + out[binding.Parameter] = append(out[binding.Parameter], bound) + } + } + return out, nil +} + +// boundValue converts one request value. An element is bound by qualified name +// and an object by id or path among those held; infinity and verdicts are only +// ever answered, so binding them is refused. +func boundValue(idx *symbols.Index, sem *semantics.Model, held *heldObjects, parameter string, value *pb.DocumentValue) (queryexec.Value, error) { + switch kind := value.GetKind().(type) { + case *pb.DocumentValue_Object: + inst, label, err := held.resolve(parameter, kind.Object) + if err != nil { + return queryexec.Value{}, err + } + return queryexec.ObjectValue(inst, label), nil + case *pb.DocumentValue_ElementId: + syms := lookupNamed(idx, kind.ElementId) + if len(syms) == 0 { + return queryexec.Value{}, statusErrorf(connect.CodeInvalidArgument, + "binding %s names an element the model does not have: %q", parameter, kind.ElementId) + } + return queryexec.ElementValue(syms[0]), nil + case *pb.DocumentValue_StringValue: + return queryexec.StringValue(kind.StringValue), nil + case *pb.DocumentValue_IntValue: + return queryexec.IntegerValue(kind.IntValue), nil + case *pb.DocumentValue_RealValue: + return queryexec.RealValue(kind.RealValue), nil + case *pb.DocumentValue_BoolValue: + return queryexec.BooleanValue(kind.BoolValue), nil + case *pb.DocumentValue_Infinity: + return queryexec.Value{}, statusErrorf(connect.CodeInvalidArgument, + "binding %s: infinity is answered by queries, not bound to them", parameter) + case *pb.DocumentValue_Verdict: + return queryexec.Value{}, statusErrorf(connect.CodeInvalidArgument, + "binding %s: a verdict is answered by queries, not bound to them", parameter) + case *pb.DocumentValue_State: + return queryexec.Value{}, statusErrorf(connect.CodeInvalidArgument, + "binding %s: a state row is answered by queries, not bound to them", parameter) + case *pb.DocumentValue_Event: + return queryexec.Value{}, statusErrorf(connect.CodeInvalidArgument, + "binding %s: an event row is answered by queries, not bound to them", parameter) + case *pb.DocumentValue_Quantity: + bound, err := protoconv.ProtoToQuantity(kind.Quantity, idx, sem) + if err != nil { + return queryexec.Value{}, statusErrorf(connect.CodeInvalidArgument, "binding %s: %v", parameter, err) + } + quantity := bound.Quantity() + if quantity == nil { + return queryexec.Value{}, statusErrorf(connect.CodeInvalidArgument, "binding %s carries no quantity", parameter) + } + return queryexec.QuantityValue(*quantity), nil + default: + return queryexec.Value{}, statusErrorf(connect.CodeInvalidArgument, + "binding %s carries no value", parameter) + } +} + +// rowSetResponse converts an executed row set, keeping the engine's order. +func rowSetResponse(idx *symbols.Index, result *queryexec.RowSet) *pb.RunDocumentQueryResponse { + columns := result.Columns() + response := &pb.RunDocumentQueryResponse{ + Columns: make([]*pb.DocumentQueryColumn, 0, len(columns)), + } + for _, column := range columns { + response.Columns = append(response.Columns, &pb.DocumentQueryColumn{Name: column.Name()}) + } + rows := result.Rows() + response.Rows = make([]*pb.DocumentQueryRow, 0, len(rows)) + for _, row := range rows { + cells := row.Cells() + pbRow := &pb.DocumentQueryRow{ + Element: documentValue(idx, row.Element()), + Cells: make([]*pb.DocumentQueryCell, 0, len(cells)), + } + for _, cell := range cells { + values := cell.Values() + pbCell := &pb.DocumentQueryCell{Values: make([]*pb.DocumentValue, 0, len(values))} + for _, value := range values { + pbCell.Values = append(pbCell.Values, documentValue(idx, value)) + } + pbRow.Cells = append(pbRow.Cells, pbCell) + } + response.Rows = append(response.Rows, pbRow) + } + return response +} + +// documentValue converts one engine value, naming an element by qualified name +// and metamodel type, a verdict by the assertion checked and its outcome, and +// a state or event row by the fields its query exposes as properties. +func documentValue(idx *symbols.Index, value queryexec.Value) *pb.DocumentValue { + switch value.Kind() { + case queryexec.ValueElement: + sym, ok := value.Element() + if !ok { + return &pb.DocumentValue{} + } + return elementValue(idx, sym) + case queryexec.ValueObject: + return &pb.DocumentValue{Kind: &pb.DocumentValue_Object{Object: documentObject(idx, value)}} + case queryexec.ValueVerdict: + verdict, _ := value.Verdict() + return &pb.DocumentValue{Kind: &pb.DocumentValue_Verdict{Verdict: documentVerdict(idx, verdict)}} + case queryexec.ValueState: + state, _ := value.State() + return &pb.DocumentValue{Kind: &pb.DocumentValue_State{State: documentState(idx, state)}} + case queryexec.ValueEvent: + event, _ := value.Event() + return &pb.DocumentValue{Kind: &pb.DocumentValue_Event{Event: documentEvent(idx, event)}} + case queryexec.ValueString: + text, _ := value.String() + return &pb.DocumentValue{Kind: &pb.DocumentValue_StringValue{StringValue: text}} + case queryexec.ValueInteger: + integer, _ := value.Integer() + return &pb.DocumentValue{Kind: &pb.DocumentValue_IntValue{IntValue: integer}} + case queryexec.ValueReal: + realVal, _ := value.Real() + return &pb.DocumentValue{Kind: &pb.DocumentValue_RealValue{RealValue: realVal}} + case queryexec.ValueBoolean: + boolean, _ := value.Boolean() + return &pb.DocumentValue{Kind: &pb.DocumentValue_BoolValue{BoolValue: boolean}} + case queryexec.ValueInfinity: + return &pb.DocumentValue{Kind: &pb.DocumentValue_Infinity{Infinity: true}} + case queryexec.ValueQuantity: + quantity, _ := value.Quantity() + return &pb.DocumentValue{Kind: &pb.DocumentValue_Quantity{Quantity: protoconv.QuantityToProto(&quantity)}} + default: + return &pb.DocumentValue{} + } +} + +// documentObject answers an object row: its id, the path it was reached by, and +// the usage or definition it stands for. +func documentObject(idx *symbols.Index, value queryexec.Value) *pb.DocumentObject { + inst, label, _ := value.Object() + out := &pb.DocumentObject{InstanceId: inst.ID, Path: label} + if decl := value.Declaration(); decl != nil { + out.Element = elementValue(idx, decl) + } + return out +} + +// elementValue names an element by qualified name and metamodel type. +func elementValue(idx *symbols.Index, sym *symbols.Symbol) *pb.DocumentValue { + return &pb.DocumentValue{ + Kind: &pb.DocumentValue_ElementId{ElementId: idx.GetFQN(sym)}, + ElementType: corequery.MetamodelTypeNameOf(sym), + } +} + +// documentVerdict converts a verdict row: the assertion checked, where, and +// how it came out. An anonymous assertion keeps its type but has no id. +func documentVerdict(idx *symbols.Index, verdict queryexec.Verdict) *pb.DocumentVerdict { + out := &pb.DocumentVerdict{ + Assertion: &pb.DocumentValue{}, + Kind: string(verdict.Kind()), + Text: verdict.Text(), + Path: verdict.Path(), + Verdict: verdict.Status().String(), + Condition: verdict.Condition(), + Reason: verdict.Reason(), + } + if sym := verdict.Assertion(); sym != nil { + out.Assertion = &pb.DocumentValue{ + Kind: &pb.DocumentValue_ElementId{ElementId: namedFQN(idx, sym)}, + ElementType: corequery.MetamodelTypeNameOf(sym), + } + } + for _, kind := range verdict.Verification() { + out.Verification = append(out.Verification, string(kind)) + } + return out +} + +// documentState converts a state row: the object, its machine and the active +// leaf with the composite states around it. +func documentState(idx *symbols.Index, state queryexec.State) *pb.DocumentState { + inst, label := state.Object() + out := &pb.DocumentState{ + Object: documentObject(idx, queryexec.ObjectValue(inst, label)), + Machine: state.Machine(), + Name: state.Name(), + StatePath: state.Path(), + State: &pb.DocumentValue{}, + Region: state.Region(), + Enclosing: state.Enclosing(), + } + if sym := state.Declaration(); sym != nil { + out.State = elementValue(idx, sym) + } + return out +} + +// documentEvent converts an event row as the trace recorded it, the instant +// in the clock's unit as Events answers `time`. +func documentEvent(idx *symbols.Index, event queryexec.Event) *pb.DocumentEvent { + record := event.Record() + out := &pb.DocumentEvent{ + Kind: event.Kind(), + Time: documentValue(idx, event.Time()), + Machine: event.Machine(), + State: record.State, + From: record.From, + To: record.To, + Event: record.Event, + Payload: event.Payload(), + Text: event.Text(), + } + if inst, label := event.Object(); inst != nil { + out.Object = documentObject(idx, queryexec.ObjectValue(inst, label)) + } + if inst, label := event.Target(); inst != nil { + out.Target = documentObject(idx, queryexec.ObjectValue(inst, label)) + } + out.Alternatives = event.Alternatives() + out.Taken = event.Taken() + return out +} + +// documentStatus maps a typed engine failure onto the status code for it, +// keeping the engine's own message and appending the source it names. +func documentStatus(err error) error { + var planErr *queryplan.Error + if errors.As(err, &planErr) { + return statusWithOrigin(queryPlanCode(planErr.Kind), err, planErr.Origin) + } + var execErr *queryexec.Error + if errors.As(err, &execErr) { + return statusWithOrigin(queryExecCode(execErr.Kind), err, execErr.Origin) + } + var docPlanErr *docplan.Error + if errors.As(err, &docPlanErr) { + return statusWithOrigin(docPlanCode(docPlanErr.Kind), err, docPlanErr.Origin) + } + var docIRErr *docir.Error + if errors.As(err, &docIRErr) { + return statusWithOrigin(docIRCode(docIRErr), err, docIRErr.Origin) + } + return connect.NewError(connect.CodeInternal, err) +} + +// statusWithOrigin fails with the engine's message, naming the source +// declaration behind the failure when the engine reports one. +func statusWithOrigin(code connect.Code, err error, origin symbols.Origin) error { + if !origin.Located() { + return connect.NewError(code, err) + } + return statusErrorf(code, "%s (declared in %s)", err.Error(), origin.Doc) +} + +// queryPlanCode is the status a query-planning failure reports as: a fault in +// the model's own query definitions is a failed precondition of the call. +func queryPlanCode(kind queryplan.ErrorKind) connect.Code { + switch kind { + case queryplan.ErrorNotQueryDefinition: + return connect.CodeInvalidArgument + case queryplan.ErrorLibraryUnavailable, queryplan.ErrorInvalidContext: + return connect.CodeInternal + default: + return connect.CodeFailedPrecondition + } +} + +// queryExecCode is the status an execution failure reports as: a wrong binding +// is the caller's fault, an exhausted budget is a resource limit, and the rest +// are faults in the model's queries. +func queryExecCode(kind queryexec.ErrorKind) connect.Code { + switch kind { + case queryexec.ErrorUnknownBinding, queryexec.ErrorMissingBinding, + queryexec.ErrorBindingType, queryexec.ErrorBindingMultiplicity: + return connect.CodeInvalidArgument + case queryexec.ErrorVisitBudget, queryexec.ErrorInvocationBudget, + queryexec.ErrorInvocationDepth: + return connect.CodeResourceExhausted + case queryexec.ErrorInvalidContext: + return connect.CodeInternal + default: + return connect.CodeFailedPrecondition + } +} + +// docPlanCode is the status a document-planning failure reports as. +func docPlanCode(kind docplan.ErrorKind) connect.Code { + switch kind { + case docplan.ErrorNotDocumentDefinition: + return connect.CodeInvalidArgument + case docplan.ErrorLibraryUnavailable, docplan.ErrorInvalidContext: + return connect.CodeInternal + default: + return connect.CodeFailedPrecondition + } +} + +// cachedSourceText reads notation from whichever of the model's documents a span +// belongs to, and behind them from the library files its index holds, for the +// labels a diagram rendering takes verbatim. +func cachedSourceText(model *CachedModel) view.SourceText { + sources := make(map[string]*source.SourceFile, len(model.Documents)) + for _, doc := range model.Documents { + if doc.Source != nil { + sources[doc.Source.Name()] = doc.Source + } + } + if len(sources) == 0 && model.Library == nil { + return nil + } + return source.TextOf(sources, libs.Text(model.Library)) +} + +// docIRCode is the status a document-evaluation failure reports as: a failed +// query keeps its execution mapping, and the rest are the evaluator's own. +func docIRCode(err *docir.Error) connect.Code { + if err.Kind == docir.ErrorQueryExecution { + var execErr *queryexec.Error + if errors.As(err.Err, &execErr) { + return queryExecCode(execErr.Kind) + } + } + return connect.CodeInternal +} diff --git a/internal/frontend/grpc/docquery_test.go b/internal/frontend/grpc/docquery_test.go new file mode 100644 index 0000000000..a13db1b2aa --- /dev/null +++ b/internal/frontend/grpc/docquery_test.go @@ -0,0 +1,621 @@ +package grpc + +import ( + "context" + "os" + "slices" + "strings" + "testing" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec" +) + +// telescopeFixture is the document pipeline's own telescope-domain fixture, so +// the service answers exactly what the renderer's goldens lock in. +const telescopeFixture = "../../doc/docrender/testdata/telescope_report.sysml" + +// telescopeGolden is the Markdown the fixture's MassReport renders to. +const telescopeGolden = "../../doc/docrender/testdata/telescope_report.golden.md" + +// telescopeHTMLGolden is the standalone HTML page the same document renders to. +const telescopeHTMLGolden = "../../doc/docrender/testdata/telescope_report.golden.html" + +// defaultedFixture declares queries whose parameters carry defaults. +const defaultedFixture = "../../doc/docrender/testdata/defaulted_queries.sysml" + +// quantityFixture declares quantity-valued attributes and queries over them. +const quantityFixture = "../../doc/docrender/testdata/quantity_report.sysml" + +// verdictFixture declares assertions on a car and queries over their verdicts. +const verdictFixture = "../../doc/docrender/testdata/verdict_report.sysml" + +// parseTelescope loads the telescope fixture into a fresh service. +func parseTelescope(t *testing.T, srv *Service) string { + t.Helper() + return parseFixture(t, srv, telescopeFixture) +} + +// parseFixture loads a fixture file into a fresh service. +func parseFixture(t *testing.T, srv *Service, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + parsed, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: string(content)}, + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + return parsed.ModelHash +} + +// element binds a model element by qualified name. +func element(fqn string) *pb.DocumentValue { + return &pb.DocumentValue{Kind: &pb.DocumentValue_ElementId{ElementId: fqn}} +} + +// binding binds one parameter to values. +func binding(parameter string, values ...*pb.DocumentValue) *pb.DocumentQueryBinding { + return &pb.DocumentQueryBinding{Parameter: parameter, Values: values} +} + +// protoMagnitude reads a quantity's magnitude whichever arm carries it. +func protoMagnitude(q *pb.Quantity) float64 { + if realMag, ok := q.GetMagnitude().(*pb.Quantity_RealMagnitude); ok { + return realMag.RealMagnitude + } + return float64(q.GetIntMagnitude()) +} + +// stringCell reads a cell expected to hold one string value. +func stringCell(t *testing.T, cell *pb.DocumentQueryCell) string { + t.Helper() + if len(cell.Values) != 1 { + t.Fatalf("cell holds %d values, want 1", len(cell.Values)) + } + return cell.Values[0].GetStringValue() +} + +func TestRunDocumentQueryAnswersTypedRows(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseTelescope(t, srv) + + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, + QueryId: "Observatory::SubsystemTable", + Bindings: []*pb.DocumentQueryBinding{binding("root", element("Observatory::telescope"))}, + }) + if err != nil { + t.Fatalf("RunDocumentQuery failed: %v", err) + } + + columns := make([]string, 0, len(resp.Columns)) + for _, column := range resp.Columns { + columns = append(columns, column.Name) + } + if len(columns) != 2 || columns[0] != "name" || columns[1] != "mass" { + t.Fatalf("columns = %v, want [name mass]", columns) + } + + wantNames := []string{"baffle|shroud *tricky*", "mount", "optics", "segmentControl"} + wantMasses := []float64{1.5, 15, 8.5, 20} + wantElements := []string{ + "Observatory::telescope::baffle|shroud *tricky*", + "Observatory::telescope::mount", + "Observatory::telescope::optics", + "Observatory::telescope::segmentControl", + } + if len(resp.Rows) != len(wantNames) { + t.Fatalf("rows = %d, want %d", len(resp.Rows), len(wantNames)) + } + for i, row := range resp.Rows { + if got := row.Element.GetElementId(); got != wantElements[i] { + t.Errorf("row %d element = %q, want %q", i, got, wantElements[i]) + } + if got := row.Element.GetElementType(); got != "PartUsage" { + t.Errorf("row %d element type = %q, want PartUsage", i, got) + } + if len(row.Cells) != 2 { + t.Fatalf("row %d holds %d cells, want 2", i, len(row.Cells)) + } + if got := stringCell(t, row.Cells[0]); got != wantNames[i] { + t.Errorf("row %d name = %q, want %q", i, got, wantNames[i]) + } + massValues := row.Cells[1].Values + if len(massValues) != 1 || massValues[0].GetRealValue() != wantMasses[i] { + t.Errorf("row %d mass = %v, want %v", i, massValues, wantMasses[i]) + } + } +} + +// TestRunDocumentQueryAnswersQuantities: a quantity-valued attribute is answered +// as a quantity value keeping its magnitude and the unit the model spelt. +func TestRunDocumentQueryAnswersQuantities(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, quantityFixture) + + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, + QueryId: "Launcher::Masses", + Bindings: []*pb.DocumentQueryBinding{binding("root", element("Launcher::rocket"))}, + }) + if err != nil { + t.Fatalf("RunDocumentQuery failed: %v", err) + } + want := []struct { + name string + magnitude int64 + unit string + tonnes float64 + }{ + {"s1", 2290000, "kg", 2290}, + {"s2", 119000, "kg", 119}, + {"probe", 500000, "g", 500}, + } + if len(resp.Rows) != len(want) { + t.Fatalf("rows = %d, want %d", len(resp.Rows), len(want)) + } + for i, row := range resp.Rows { + if got := stringCell(t, row.Cells[0]); got != want[i].name { + t.Errorf("row %d name = %q, want %q", i, got, want[i].name) + } + mass := row.Cells[1].Values + if len(mass) != 1 || mass[0].GetQuantity() == nil { + t.Fatalf("row %d mass = %v, want one quantity", i, mass) + } + got := mass[0].GetQuantity() + if got.GetIntMagnitude() != want[i].magnitude || got.GetUnit() != want[i].unit { + t.Errorf("row %d mass = %v, want %d [%s]", i, got, want[i].magnitude, want[i].unit) + } + if len(got.GetUnitTerm().GetFactors()) == 0 { + t.Errorf("row %d mass carries no unit term: %v", i, got) + } + tonnes := row.Cells[2].Values + if len(tonnes) != 1 || tonnes[0].GetQuantity() == nil { + t.Fatalf("row %d tonnes = %v, want one quantity", i, tonnes) + } + if got := tonnes[0].GetQuantity(); got.GetRealMagnitude() != want[i].tonnes || got.GetUnit() != want[i].unit { + t.Errorf("row %d tonnes = %v, want %v [%s]", i, got, want[i].tonnes, want[i].unit) + } + } +} + +// TestRunDocumentQueryAnswersVerdicts: a row Verdicts answered over the model +// alone is a verdict value: the assertion checked on the car as declared, its +// path, status and reason, while its projected cells read the same properties. +func TestRunDocumentQueryAnswersVerdicts(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, verdictFixture) + + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, + QueryId: "Garage::Checks", + Bindings: []*pb.DocumentQueryBinding{binding("root", element("Garage::car"))}, + }) + if err != nil { + t.Fatalf("RunDocumentQuery failed: %v", err) + } + if len(resp.Rows) < 4 { + t.Fatalf("rows = %d, want the car's assertions and those of what it holds", len(resp.Rows)) + } + byText := make(map[string]*pb.DocumentVerdict) + for i, row := range resp.Rows { + verdict := row.Element.GetVerdict() + if verdict == nil { + t.Fatalf("row %d element = %v, want a verdict", i, row.Element) + } + byText[verdict.Text+" on "+verdict.Path] = verdict + if got := stringCell(t, row.Cells[0]); got != verdict.Path { + t.Errorf("row %d path cell = %q, verdict path = %q", i, got, verdict.Path) + } + if got := stringCell(t, row.Cells[2]); got != verdict.Verdict { + t.Errorf("row %d verdict cell = %q, verdict = %q", i, got, verdict.Verdict) + } + } + massOk := byText["assert constraint massOk on Garage::car"] + if massOk == nil || massOk.Verdict != "holds" || massOk.Kind != "constraint" || massOk.Reason != "" || + massOk.Assertion.GetElementId() != "Garage::Car::massOk" || + massOk.Assertion.GetElementType() != "ConstraintUsage" { + t.Errorf("massOk = %v, want a holding constraint on Garage::Car::massOk", massOk) + } + fits := byText["assert constraint fits on Garage::car"] + if fits == nil || fits.Verdict != "undecided" || !strings.Contains(fits.Reason, "capacity") { + t.Errorf("fits = %v, want undecided over the unbound capacity", fits) + } + powerLow := byText["assert constraint powerLow on Garage::car.engine"] + if powerLow == nil || powerLow.Verdict != "violated" || powerLow.Condition == "" || powerLow.Reason == "" { + t.Errorf("powerLow = %v, want violated with its condition and reason", powerLow) + } + satisfied := byText["satisfy strongEngine by car.engine on Garage::car.engine"] + if satisfied == nil || satisfied.Kind != "satisfaction" || satisfied.Verdict != "holds" || + satisfied.Assertion.GetElementId() != "" || satisfied.Assertion.GetElementType() != "SatisfyRequirementUsage" || + strings.Join(satisfied.Verification, ",") != "pass" { + t.Errorf("satisfaction = %v, want a holding anonymous satisfy verified by a pass", satisfied) + } + verified := byText["verification Garage::checkEngine on Garage::car.engine"] + if verified == nil || verified.Kind != "verification" || verified.Verdict != "holds" || + strings.Join(verified.Verification, ",") != "pass" { + t.Errorf("verification = %v, want the passing case's row", verified) + } +} + +// TestRunDocumentQueryBindsQuantities: a quantity a query answered binds back +// into a parameter of its dimension; bound to a String parameter it is refused. +func TestRunDocumentQueryBindsQuantities(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, quantityFixture) + root := binding("root", element("Launcher::rocket")) + + answered, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Launcher::Masses", Bindings: []*pb.DocumentQueryBinding{root}, + }) + if err != nil { + t.Fatalf("RunDocumentQuery Masses failed: %v", err) + } + budget := answered.Rows[0].Cells[1].Values[0] + if budget.GetQuantity() == nil { + t.Fatalf("answered mass = %v, want a quantity", budget) + } + + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Launcher::Margin", + Bindings: []*pb.DocumentQueryBinding{root, binding("budget", budget)}, + }) + if err != nil { + t.Fatalf("RunDocumentQuery Margin failed: %v", err) + } + want := []struct { + name string + margin float64 + unit string + }{{"s1", 0, "kg"}, {"s2", 2171000, "kg"}, {"probe", 2289500, "kg"}} + if len(resp.Rows) != len(want) { + t.Fatalf("rows = %d, want %d", len(resp.Rows), len(want)) + } + for i, row := range resp.Rows { + if got := stringCell(t, row.Cells[0]); got != want[i].name { + t.Errorf("row %d name = %q, want %q", i, got, want[i].name) + } + margin := row.Cells[1].Values + if len(margin) != 1 || margin[0].GetQuantity() == nil { + t.Fatalf("row %d margin = %v, want one quantity", i, margin) + } + if got := margin[0].GetQuantity(); protoMagnitude(got) != want[i].margin || got.GetUnit() != want[i].unit { + t.Errorf("row %d margin = %v, want %v [%s]", i, got, want[i].margin, want[i].unit) + } + } + + _, err = srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Launcher::Named", + Bindings: []*pb.DocumentQueryBinding{root, binding("label", budget)}, + }) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("quantity bound to a String parameter: err = %v, want %s", err, connect.CodeInvalidArgument) + } +} + +func TestRunDocumentQueryBindsScalars(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseTelescope(t, srv) + + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, + QueryId: "Observatory::HeavySubsystemNames", + Bindings: []*pb.DocumentQueryBinding{ + binding("root", element("Observatory::telescope")), + binding("threshold", &pb.DocumentValue{Kind: &pb.DocumentValue_StringValue{StringValue: "10"}}), + }, + }) + if err != nil { + t.Fatalf("RunDocumentQuery failed: %v", err) + } + names := make([]string, 0, len(resp.Rows)) + for _, row := range resp.Rows { + names = append(names, stringCell(t, row.Cells[0])) + } + if len(names) != 2 || names[0] != "mount" || names[1] != "segmentControl" { + t.Fatalf("names = %v, want [mount segmentControl]", names) + } +} + +func TestRunDocumentQueryUsesParameterDefaults(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, defaultedFixture) + + namesOf := func(t *testing.T, query string, bindings ...*pb.DocumentQueryBinding) []string { + t.Helper() + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: query, Bindings: bindings, + }) + if err != nil { + t.Fatalf("RunDocumentQuery %s failed: %v", query, err) + } + names := make([]string, 0, len(resp.Rows)) + for _, row := range resp.Rows { + names = append(names, stringCell(t, row.Cells[0])) + } + return names + } + + if got := namesOf(t, "Observatory::HeavySubsystems"); !slices.Equal(got, []string{"mount", "segmentControl"}) { + t.Errorf("defaulted names = %v, want [mount segmentControl]", got) + } + if got := namesOf(t, "Observatory::LightSubsystems"); !slices.Equal(got, []string{"mount", "optics", "segmentControl"}) { + t.Errorf("redefined default names = %v, want [mount optics segmentControl]", got) + } + got := namesOf(t, "Observatory::HeavySubsystems", + binding("root", element("Observatory::instruments")), + binding("threshold", &pb.DocumentValue{Kind: &pb.DocumentValue_StringValue{StringValue: "1"}})) + if !slices.Equal(got, []string{"spectrograph"}) { + t.Errorf("explicitly bound names = %v, want [spectrograph]", got) + } +} + +func TestRunDocumentQueryAnswersEmptyRows(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseTelescope(t, srv) + + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, + QueryId: "Observatory::MissingSubsystems", + Bindings: []*pb.DocumentQueryBinding{binding("root", element("Observatory::telescope"))}, + }) + if err != nil { + t.Fatalf("RunDocumentQuery failed: %v", err) + } + if len(resp.Columns) != 2 { + t.Errorf("columns = %d, want 2: an empty answer still says what it projects", len(resp.Columns)) + } + if len(resp.Rows) != 0 { + t.Errorf("rows = %d, want 0", len(resp.Rows)) + } +} + +func TestRunDocumentQueryIsDeterministic(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseTelescope(t, srv) + + request := &pb.RunDocumentQueryRequest{ + ModelHash: hash, + QueryId: "Observatory::SubsystemTable", + Bindings: []*pb.DocumentQueryBinding{binding("root", element("Observatory::telescope"))}, + } + first, err := srv.RunDocumentQuery(context.Background(), request) + if err != nil { + t.Fatalf("RunDocumentQuery failed: %v", err) + } + second, err := srv.RunDocumentQuery(context.Background(), request) + if err != nil { + t.Fatalf("RunDocumentQuery failed: %v", err) + } + if first.String() != second.String() { + t.Errorf("two runs answered differently:\n%s\n%s", first, second) + } +} + +func TestRunDocumentQueryFailures(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseTelescope(t, srv) + root := binding("root", element("Observatory::telescope")) + + cases := []struct { + name string + request *pb.RunDocumentQueryRequest + code connect.Code + }{ + {"unknown model", &pb.RunDocumentQueryRequest{ + ModelHash: "deadbeef", QueryId: "Observatory::SubsystemTable", + }, connect.CodeNotFound}, + {"unnamed query", &pb.RunDocumentQueryRequest{ + ModelHash: hash, + }, connect.CodeInvalidArgument}, + {"unknown query", &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::NoSuchQuery", + }, connect.CodeNotFound}, + {"not a query", &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::Subsystem", + }, connect.CodeInvalidArgument}, + {"unknown binding", &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::SubsystemTable", + Bindings: []*pb.DocumentQueryBinding{root, binding("depth", + &pb.DocumentValue{Kind: &pb.DocumentValue_IntValue{IntValue: 3}})}, + }, connect.CodeInvalidArgument}, + {"missing binding", &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::SubsystemTable", + }, connect.CodeInvalidArgument}, + {"binding type", &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::HeavySubsystemNames", + Bindings: []*pb.DocumentQueryBinding{root, binding("threshold", + &pb.DocumentValue{Kind: &pb.DocumentValue_IntValue{IntValue: 10}})}, + }, connect.CodeInvalidArgument}, + {"unknown bound element", &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::SubsystemTable", + Bindings: []*pb.DocumentQueryBinding{binding("root", element("Observatory::nothing"))}, + }, connect.CodeInvalidArgument}, + {"unnamed binding", &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::SubsystemTable", + Bindings: []*pb.DocumentQueryBinding{binding("", + &pb.DocumentValue{Kind: &pb.DocumentValue_IntValue{IntValue: 1}})}, + }, connect.CodeInvalidArgument}, + {"bound infinity", &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::SubsystemTable", + Bindings: []*pb.DocumentQueryBinding{binding("root", + &pb.DocumentValue{Kind: &pb.DocumentValue_Infinity{Infinity: true}})}, + }, connect.CodeInvalidArgument}, + {"bound verdict", &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::SubsystemTable", + Bindings: []*pb.DocumentQueryBinding{binding("root", + &pb.DocumentValue{Kind: &pb.DocumentValue_Verdict{Verdict: &pb.DocumentVerdict{Kind: "constraint"}}})}, + }, connect.CodeInvalidArgument}, + {"valueless binding", &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::SubsystemTable", + Bindings: []*pb.DocumentQueryBinding{binding("root", &pb.DocumentValue{})}, + }, connect.CodeInvalidArgument}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := srv.RunDocumentQuery(context.Background(), tc.request) + if connect.CodeOf(err) != tc.code { + t.Errorf("code = %v (%v), want %v", connect.CodeOf(err), err, tc.code) + } + }) + } +} + +func TestRunDocumentQueryRequiresCapability(t *testing.T) { + srv := mustNewServiceWithout(t, CapabilityDocumentQuery) + _, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: "any", QueryId: "Any", + }) + if connect.CodeOf(err) != connect.CodeUnimplemented { + t.Errorf("code = %v, want Unimplemented", connect.CodeOf(err)) + } +} + +func TestDocumentStatusMapsBudgetExhaustion(t *testing.T) { + cases := []struct { + kind queryexec.ErrorKind + code connect.Code + }{ + {queryexec.ErrorVisitBudget, connect.CodeResourceExhausted}, + {queryexec.ErrorInvocationBudget, connect.CodeResourceExhausted}, + {queryexec.ErrorInvocationDepth, connect.CodeResourceExhausted}, + {queryexec.ErrorUnsupportedOperation, connect.CodeFailedPrecondition}, + {queryexec.ErrorUnknownProperty, connect.CodeFailedPrecondition}, + {queryexec.ErrorUnknownBinding, connect.CodeInvalidArgument}, + } + for _, tc := range cases { + err := documentStatus(&queryexec.Error{Kind: tc.kind, Query: "Q"}) + if connect.CodeOf(err) != tc.code { + t.Errorf("%s: code = %v, want %v", tc.kind, connect.CodeOf(err), tc.code) + } + } +} + +func TestRenderDocumentMatchesTheRendererGolden(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseTelescope(t, srv) + + resp, err := srv.RenderDocument(context.Background(), &pb.RenderDocumentRequest{ + ModelHash: hash, DocumentId: "Observatory::MassReport", + }) + if err != nil { + t.Fatalf("RenderDocument failed: %v", err) + } + golden, err := os.ReadFile(telescopeGolden) + if err != nil { + t.Fatalf("read golden: %v", err) + } + if resp.Markdown != string(golden) { + t.Errorf("markdown differs from the renderer's golden:\n%s", resp.Markdown) + } +} + +// TestRenderDocumentHTMLMatchesTheRendererGolden: form "html" answers the HTML +// backend's standalone page in html and leaves markdown empty; the explicit +// "markdown" form answers what the empty form does. +func TestRenderDocumentHTMLMatchesTheRendererGolden(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseTelescope(t, srv) + + resp, err := srv.RenderDocument(context.Background(), &pb.RenderDocumentRequest{ + ModelHash: hash, DocumentId: "Observatory::MassReport", Form: "html", + }) + if err != nil { + t.Fatalf("RenderDocument(html) failed: %v", err) + } + golden, err := os.ReadFile(telescopeHTMLGolden) + if err != nil { + t.Fatalf("read golden: %v", err) + } + if resp.Html != string(golden) { + t.Errorf("html differs from the renderer's golden:\n%s", resp.Html) + } + if resp.Markdown != "" { + t.Errorf("html form also answered markdown:\n%s", resp.Markdown) + } + + resp, err = srv.RenderDocument(context.Background(), &pb.RenderDocumentRequest{ + ModelHash: hash, DocumentId: "Observatory::MassReport", Form: "markdown", + }) + if err != nil { + t.Fatalf("RenderDocument(markdown) failed: %v", err) + } + markdown, err := os.ReadFile(telescopeGolden) + if err != nil { + t.Fatalf("read golden: %v", err) + } + if resp.Markdown != string(markdown) || resp.Html != "" { + t.Errorf("markdown form: markdown matches golden = %v, html = %q", resp.Markdown == string(markdown), resp.Html) + } +} + +func TestRenderDocumentUsesParameterDefaults(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, defaultedFixture) + + resp, err := srv.RenderDocument(context.Background(), &pb.RenderDocumentRequest{ + ModelHash: hash, DocumentId: "Observatory::DefaultedReport", + }) + if err != nil { + t.Fatalf("RenderDocument failed: %v", err) + } + for _, want := range []string{ + "| mount | 15 |\n| segmentControl | 20 |", + "- mount 15\n- optics 8.5\n- segmentControl 20", + "- spectrograph 4", + } { + if !strings.Contains(resp.Markdown, want) { + t.Errorf("markdown lacks %q:\n%s", want, resp.Markdown) + } + } +} + +func TestRenderDocumentFailures(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseTelescope(t, srv) + + cases := []struct { + name string + request *pb.RenderDocumentRequest + code connect.Code + }{ + {"unknown model", &pb.RenderDocumentRequest{ + ModelHash: "deadbeef", DocumentId: "Observatory::MassReport", + }, connect.CodeNotFound}, + {"unnamed document", &pb.RenderDocumentRequest{ + ModelHash: hash, + }, connect.CodeInvalidArgument}, + {"unknown document", &pb.RenderDocumentRequest{ + ModelHash: hash, DocumentId: "Observatory::NoSuchReport", + }, connect.CodeNotFound}, + {"not a document", &pb.RenderDocumentRequest{ + ModelHash: hash, DocumentId: "Observatory::SubsystemTable", + }, connect.CodeInvalidArgument}, + {"unknown form", &pb.RenderDocumentRequest{ + ModelHash: hash, DocumentId: "Observatory::MassReport", Form: "pdf", + }, connect.CodeInvalidArgument}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := srv.RenderDocument(context.Background(), tc.request) + if connect.CodeOf(err) != tc.code { + t.Errorf("code = %v (%v), want %v", connect.CodeOf(err), err, tc.code) + } + }) + } +} + +func TestRenderDocumentRequiresCapability(t *testing.T) { + srv := mustNewServiceWithout(t, CapabilityRenderDocument) + _, err := srv.RenderDocument(context.Background(), &pb.RenderDocumentRequest{ + ModelHash: "any", DocumentId: "Any", + }) + if connect.CodeOf(err) != connect.CodeUnimplemented { + t.Errorf("code = %v, want Unimplemented", connect.CodeOf(err)) + } +} diff --git a/internal/frontend/grpc/edit.go b/internal/frontend/grpc/edit.go new file mode 100644 index 0000000000..a42bfdb3b9 --- /dev/null +++ b/internal/frontend/grpc/edit.go @@ -0,0 +1,329 @@ +package grpc + +import ( + "context" + "errors" + "sort" + "strings" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/check/edit" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// ApplyEdits edits the source a model was parsed from and returns the edited +// notation, so a client can change a model and write it back with its comments +// and layout intact. A model of several documents is edited as one, for a +// request that accepts documents: the operations target the document the +// request names, a rename or cascade delete follows references into every +// other, and the response carries each document the edits rewrote, or none +// when they were refused. A request not accepting documents is refused on such +// a model, since it reads only content. Argument faults fail the call; an edit +// the engine refuses is reported in the response's error, failure kind and +// diagnostics. Without the edit_documents capability the service answers as one +// predating documents: a model of several is refused, a named document is +// refused with UNIMPLEMENTED, and the response carries content alone. +func (s *Service) ApplyEdits(ctx context.Context, req *pb.ApplyEditsRequest) (*pb.ApplyEditsResponse, error) { + if err := s.requireCapability(CapabilityApplyEdits); err != nil { + return nil, err + } + if requestsAuthoring(req.Operations) { + if err := s.requireCapability(CapabilityAuthoring); err != nil { + return nil, err + } + } + documents := s.capabilities.has(CapabilityEditDocuments) + if req.Document != "" && !documents { + return nil, s.requireCapability(CapabilityEditDocuments) + } + if req.ModelHash == "" { + return nil, statusError(connect.CodeInvalidArgument, "model_hash is required") + } + cached, ok := s.cache.Get(req.ModelHash) + if !ok { + return nil, statusErrorf(connect.CodeNotFound, + "model %s is no longer cached: parse it again before editing it", req.ModelHash) + } + + ops, err := editOperations(req.Operations) + if err != nil { + return nil, err + } + if !documents && len(cached.Documents) != 1 { + return nil, statusErrorf(connect.CodeFailedPrecondition, + "the model has %d documents, and this service edits one alone: it lacks the %s capability", + len(cached.Documents), CapabilityEditDocuments) + } + if err := acceptsDocuments(cached, req.AcceptDocuments); err != nil { + return nil, err + } + edited, err := editedDocument(cached, req.Document) + if err != nil { + return nil, err + } + + result, err := edit.Apply(s.editModel(cached, edited), ops) + if err != nil { + resp, err := s.editRefusal(err, edited.Source) + return withoutDocuments(resp, documents), err + } + resp := editResultToProto(result, edited.Source.Name(), len(cached.Documents) == 1) + return withoutDocuments(resp, documents), nil +} + +// withoutDocuments strips the fields the edit_documents capability advertises +// from a response of a service withholding it. +func withoutDocuments(resp *pb.ApplyEditsResponse, documents bool) *pb.ApplyEditsResponse { + if documents || resp == nil { + return resp + } + resp.Documents = nil + resp.Referrers = nil + for _, applied := range resp.Applied { + applied.Document = "" + } + return resp +} + +// acceptsDocuments refuses a model of several documents for a request that did not say it +// reads the response's documents: it reads content alone, which such a model leaves empty. +func acceptsDocuments(cached *CachedModel, accept bool) error { + if accept || len(cached.Documents) == 1 { + return nil + } + return statusErrorf(connect.CodeFailedPrecondition, + "the model has %d documents, and the request reads only content: "+ + "set accept_documents to have each edited document answered in documents", len(cached.Documents)) +} + +// editedDocument is the document a request's operations target: the one it +// names, or the model's first when it names none. +func editedDocument(cached *CachedModel, name string) (*CachedDocument, error) { + if name == "" { + return cached.Primary(), nil + } + for _, doc := range cached.Documents { + if doc.Source.Name() == name { + return doc, nil + } + } + names := make([]string, 0, len(cached.Documents)) + for _, doc := range cached.Documents { + names = append(names, doc.Source.Name()) + } + return nil, statusErrorf(connect.CodeInvalidArgument, + "document %q is not one of the model's: it has %s", name, strings.Join(names, ", ")) +} + +// editModel is the cached model as the edit engine reads it: edited is the +// document the operations target, and every other is one a rename or delete +// may rewrite. +func (s *Service) editModel(cached *CachedModel, edited *CachedDocument) edit.Model { + others := make([]*CachedDocument, 0, len(cached.Documents)-1) + for _, doc := range cached.Documents { + if doc != edited { + others = append(others, doc) + } + } + return edit.Model{ + Source: edited.Source, + Root: edited.Root, + Index: cached.Index, + ParseDiags: edited.ParseDiags, + SemDiags: edited.PassesDiags, + Analysis: passes.Options{Conformance: cached.Mode}, + // The edited notation is analyzed in an index of its own, over the + // libraries and every document of the model the edit did not rewrite. + NewIndex: func() *symbols.Index { + idx, _ := s.libIndexes.get() + for _, doc := range others { + idx.AddDocumentWithKind(doc.Source.Name(), doc.Root, doc.Source.Kind()) + } + idx.ExpandWildcardImports() + return idx + }, + Other: func(name string) (edit.Document, bool) { + for _, doc := range others { + if doc.Source.Name() == name { + return edit.Document{Source: doc.Source, ParseDiags: doc.ParseDiags, SemDiags: doc.PassesDiags}, true + } + } + return edit.Document{}, false + }, + } +} + +// editResultToProto reports the documents the edits rewrote, the edited one +// first when it is among them, then the others in name order. content repeats +// the notation for a single-document model alone, so a client reading it alone +// never writes one document's notation over another's. +func editResultToProto(result *edit.Result, edited string, sole bool) *pb.ApplyEditsResponse { + resp := &pb.ApplyEditsResponse{} + if sole || len(result.Applied) > 0 { + resp.Documents = append(resp.Documents, &pb.EditedDocument{Name: edited, Content: string(result.Content)}) + resp.Applied = append(resp.Applied, appliedToProto(result.Applied, edited)...) + } + for _, other := range result.Others { + resp.Documents = append(resp.Documents, &pb.EditedDocument{Name: other.Name, Content: string(other.Content)}) + resp.Applied = append(resp.Applied, appliedToProto(other.Applied, other.Name)...) + } + if sole { + resp.Content = string(result.Content) + } + return resp +} + +func requestsAuthoring(operations []*pb.EditOperation) bool { + for _, operation := range operations { + switch operation.GetOperation().(type) { + case *pb.EditOperation_AddMember, *pb.EditOperation_Delete, *pb.EditOperation_Move: + return true + } + } + return false +} + +// editOperations reads the operations a request carries, rejecting a request +// that names none of the forms: an unset operation is a client fault rather +// than a refused edit. +func editOperations(pbOps []*pb.EditOperation) ([]edit.Operation, error) { + ops := make([]edit.Operation, 0, len(pbOps)) + for i, pbOp := range pbOps { + switch op := pbOp.GetOperation().(type) { + case *pb.EditOperation_SetValue: + ops = append(ops, edit.SetValue(op.SetValue.GetTarget(), op.SetValue.GetValue())) + case *pb.EditOperation_Rename: + ops = append(ops, edit.Rename(op.Rename.GetTarget(), op.Rename.GetNewName())) + case *pb.EditOperation_AddMember: + add := op.AddMember + member := edit.AddMember(add.GetOwner(), add.GetKind(), add.GetName()) + member.Type = add.GetType() + member.Multiplicity = add.GetMultiplicity() + member.Value = add.GetValue() + member.Specializes = append([]string(nil), add.GetSpecializes()...) + ops = append(ops, member) + case *pb.EditOperation_Delete: + del := op.Delete + ops = append(ops, edit.Delete(del.GetTarget(), del.GetCascade())) + case *pb.EditOperation_Move: + ops = append(ops, edit.Move(op.Move.GetTarget(), op.Move.GetOwner())) + default: + return nil, statusErrorf(connect.CodeInvalidArgument, + "operation %d must be set_value, rename, add_member, delete or move", i) + } + } + return ops, nil +} + +// editRefusal reports a refused edit as a response rather than a call failure: +// the request was well formed, and the answer is why the model was not edited. +func (s *Service) editRefusal(err error, sf *source.SourceFile) (*pb.ApplyEditsResponse, error) { + var refusal *edit.Error + if !errors.As(err, &refusal) { + return nil, statusErrorf(connect.CodeInternal, "apply edits: %v", err) + } + referring, referrers := sortedReferrers(refusal) + resp := &pb.ApplyEditsResponse{ + Error: refusal.Message, + Failure: editFailureToProto(refusal.Failure), + ReferringElements: referring, + Referrers: referrers, + } + // Diagnostic spans are offsets into what was diagnosed: the new value's text + // or the edited notation, not the model as the client has it. + diagnosed := refusal.Diagnosed + if diagnosed == nil { + diagnosed = sf + } + for _, diag := range refusal.Diagnostics { + resp.Diagnostics = append(resp.Diagnostics, DiagnosticToProto(diag, diagnosed)) + } + s.filterDiagnosticCapabilities(resp.Diagnostics) + return resp, nil +} + +// sortedReferrers reports a refusal's referrers in document then name order, the +// legacy spelling of each kept beside it, whichever order the engine found them in. +func sortedReferrers(refusal *edit.Error) ([]string, []*pb.Referrer) { + if len(refusal.Referring) != len(refusal.Referrers) { + return refusal.Referring, referrersToProto(refusal.Referrers) + } + order := make([]int, len(refusal.Referrers)) + for i := range order { + order[i] = i + } + sort.SliceStable(order, func(i, j int) bool { + a, b := refusal.Referrers[order[i]], refusal.Referrers[order[j]] + if a.Document != b.Document { + return a.Document < b.Document + } + return a.Name < b.Name + }) + referring := make([]string, 0, len(order)) + referrers := make([]edit.Referrer, 0, len(order)) + for _, i := range order { + referring = append(referring, refusal.Referring[i]) + referrers = append(referrers, refusal.Referrers[i]) + } + return referring, referrersToProto(referrers) +} + +// referrersToProto reports each referrer with the document declaring it. +func referrersToProto(referrers []edit.Referrer) []*pb.Referrer { + out := make([]*pb.Referrer, 0, len(referrers)) + for _, r := range referrers { + out = append(out, &pb.Referrer{Name: r.Name, Document: r.Document}) + } + return out +} + +// appliedToProto reports what each operation changed in the document named document. +func appliedToProto(applied []edit.Applied, document string) []*pb.AppliedEdit { + out := make([]*pb.AppliedEdit, 0, len(applied)) + for _, a := range applied { + out = append(out, &pb.AppliedEdit{ + OperationIndex: int32Clamp(a.OperationIndex), + Target: a.Target, + Offset: int32Clamp(a.Span.Offset), + Length: int32Clamp(a.Span.Len), + OldText: a.OldText, + NewText: a.NewText, + Document: document, + }) + } + return out +} + +// editFailures maps every refusal kind to its wire value, so a client acts on +// the kind rather than on the message text. +var editFailures = map[edit.Failure]pb.EditFailure{ + edit.FailureNone: pb.EditFailure_EDIT_FAILURE_UNSPECIFIED, + edit.FailureNoOperations: pb.EditFailure_EDIT_FAILURE_NO_OPERATIONS, + edit.FailureUnknownTarget: pb.EditFailure_EDIT_FAILURE_UNKNOWN_TARGET, + edit.FailureAmbiguousTarget: pb.EditFailure_EDIT_FAILURE_AMBIGUOUS_TARGET, + edit.FailureNotValued: pb.EditFailure_EDIT_FAILURE_NOT_VALUED, + edit.FailureInvalidValue: pb.EditFailure_EDIT_FAILURE_INVALID_VALUE, + edit.FailureInvalidName: pb.EditFailure_EDIT_FAILURE_INVALID_NAME, + edit.FailureNotNamed: pb.EditFailure_EDIT_FAILURE_NOT_NAMED, + edit.FailureRenameReferenced: pb.EditFailure_EDIT_FAILURE_RENAME_REFERENCED, + edit.FailureOverlappingEdits: pb.EditFailure_EDIT_FAILURE_OVERLAPPING_EDITS, + edit.FailureResultInvalid: pb.EditFailure_EDIT_FAILURE_RESULT_INVALID, + edit.FailureOwnerUnknown: pb.EditFailure_EDIT_FAILURE_OWNER_UNKNOWN, + edit.FailureOwnerNotNamespace: pb.EditFailure_EDIT_FAILURE_OWNER_NOT_NAMESPACE, + edit.FailureIllegalKind: pb.EditFailure_EDIT_FAILURE_ILLEGAL_KIND, + edit.FailureMemberNameTaken: pb.EditFailure_EDIT_FAILURE_MEMBER_NAME_TAKEN, + edit.FailureDeleteReferenced: pb.EditFailure_EDIT_FAILURE_DELETE_REFERENCED, + edit.FailureOwnerInsideTarget: pb.EditFailure_EDIT_FAILURE_OWNER_INSIDE_TARGET, + edit.FailureMoveReferenced: pb.EditFailure_EDIT_FAILURE_MOVE_REFERENCED, + edit.FailureReferencedElsewhere: pb.EditFailure_EDIT_FAILURE_REFERENCED_ELSEWHERE, +} + +func editFailureToProto(f edit.Failure) pb.EditFailure { + if v, ok := editFailures[f]; ok { + return v + } + return pb.EditFailure_EDIT_FAILURE_UNSPECIFIED +} diff --git a/internal/frontend/grpc/edit_documents_test.go b/internal/frontend/grpc/edit_documents_test.go new file mode 100644 index 0000000000..8a4add3bb4 --- /dev/null +++ b/internal/frontend/grpc/edit_documents_test.go @@ -0,0 +1,560 @@ +package grpc + +import ( + "context" + "strings" + "testing" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +const ( + editDocP = "package P {\n part def Base;\n part def Keep;\n part own : Base;\n}\n" + editDocQ = "package Q {\n part b : P::Base;\n part k : P::Keep;\n}\n" + editDocR = "package R {\n part c = Q::b;\n part d : P::Keep;\n}\n" +) + +// mustParsedSources parses named inline documents as one model and returns its hash. +func mustParsedSources(t *testing.T, srv *Service, named ...string) string { + t.Helper() + parsed, err := srv.ParseSources(context.Background(), &pb.ParseSourcesRequest{Documents: inlineDocuments(named...)}) + if err != nil { + t.Fatalf("ParseSources failed: %v", err) + } + if len(parsed.Diagnostics) != 0 { + t.Fatalf("fixture has diagnostics: %v", parsed.Diagnostics) + } + return parsed.ModelHash +} + +func renameOp(target, newName string) *pb.EditOperation { + return &pb.EditOperation{Operation: &pb.EditOperation_Rename{ + Rename: &pb.RenameEdit{Target: target, NewName: newName}, + }} +} + +// mustApplied applies ops to the model's first document and fails the test on +// a call failure or a refusal. +func mustApplied(t *testing.T, srv *Service, hash string, ops ...*pb.EditOperation) *pb.ApplyEditsResponse { + t.Helper() + return mustAppliedIn(t, srv, hash, "", ops...) +} + +// mustAppliedIn applies ops to the named document of the model and fails the +// test on a call failure or a refusal. +func mustAppliedIn(t *testing.T, srv *Service, hash, document string, ops ...*pb.EditOperation) *pb.ApplyEditsResponse { + t.Helper() + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ModelHash: hash, Document: document, Operations: ops, AcceptDocuments: true}) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if resp.Error != "" || resp.Failure != pb.EditFailure_EDIT_FAILURE_UNSPECIFIED { + t.Fatalf("edit refused (%s): %s", resp.Failure, resp.Error) + } + return resp +} + +// documentNames lists the documents of a response in order. +func documentNames(resp *pb.ApplyEditsResponse) []string { + names := make([]string, 0, len(resp.Documents)) + for _, doc := range resp.Documents { + names = append(names, doc.Name) + } + return names +} + +// documentContent is the edited notation of the named document, or fails. +func documentContent(t *testing.T, resp *pb.ApplyEditsResponse, name string) string { + t.Helper() + for _, doc := range resp.Documents { + if doc.Name == name { + return doc.Content + } + } + t.Fatalf("documents %v do not include %s", documentNames(resp), name) + return "" +} + +// A single-document model answers in both shapes: content as before, and +// documents with one entry, named as the parse named it, carrying the same notation. +func TestApplyEditsSingleDocumentFillsContentAndDocuments(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedModel(t, srv, editModelSource) + + resp := mustApplied(t, srv, hash, setValueOp("Demo::SC::unitMass", "1050.0[SI::kg]")) + want := strings.Replace(editModelSource, "1000.0[SI::kg]", "1050.0[SI::kg]", 1) + if resp.Content != want { + t.Errorf("content =\n%s\nwant\n%s", resp.Content, want) + } + if len(resp.Documents) != 1 { + t.Fatalf("documents = %v, want one", documentNames(resp)) + } + if resp.Documents[0].Content != resp.Content { + t.Errorf("documents[0].content differs from content:\n%s", resp.Documents[0].Content) + } + if got, want := resp.Documents[0].Name, ""; got != want { + t.Errorf("documents[0].name = %q, want the parse's name %q", got, want) + } + if len(resp.Applied) != 1 || resp.Applied[0].Document != "" { + t.Errorf("applied = %v, want one edit in ", resp.Applied) + } +} + +// A single document parsed through ParseSources under a name of its own is +// listed under that name. +func TestApplyEditsSingleDocumentIsNamedAsParsed(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "demo.sysml", editModelSource) + + resp := mustApplied(t, srv, hash, setValueOp("Demo::SC::unitMass", "1050.0[SI::kg]")) + if resp.Content == "" { + t.Error("content empty for a single-document model") + } + if got := documentNames(resp); len(got) != 1 || got[0] != "demo.sysml" { + t.Errorf("documents = %v, want [demo.sysml]", got) + } +} + +// A request not accepting documents reads only content, which a model of several leaves +// empty, so it is refused before anything is edited, whatever document it names. +func TestApplyEditsRefusesAModelOfSeveralUnlessDocumentsAreAccepted(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "p.sysml", editDocP, "q.sysml", editDocQ) + + for _, document := range []string{"", "q.sysml", "missing.sysml"} { + _, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, Document: document, Operations: []*pb.EditOperation{renameOp("P::Keep", "Kept")}, + }) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Fatalf("document %q: err = %v, want FAILED_PRECONDITION", document, err) + } + if !strings.Contains(err.Error(), "accept_documents") || !strings.Contains(err.Error(), "2 documents") { + t.Errorf("document %q: err %q does not name accept_documents and the document count", document, err) + } + } + + resp := mustApplied(t, srv, hash, renameOp("P::Keep", "Kept")) + if strings.Join(documentNames(resp), ",") != "p.sysml,q.sysml" { + t.Errorf("accepting documents: documents = %v, want p.sysml and q.sysml", documentNames(resp)) + } +} + +// A model of one document is edited whether or not the request accepts +// documents, and answers content and documents alike either way. +func TestApplyEditsSingleDocumentIgnoresAcceptDocuments(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "demo.sysml", editModelSource) + + var answers []*pb.ApplyEditsResponse + for _, accept := range []bool{false, true} { + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: accept, + Operations: []*pb.EditOperation{setValueOp("Demo::SC::unitMass", "1050.0[SI::kg]")}, + }) + if err != nil { + t.Fatalf("accept_documents=%t: ApplyEdits failed: %v", accept, err) + } + if resp.Error != "" || resp.Content == "" || len(resp.Documents) != 1 { + t.Fatalf("accept_documents=%t: error=%q content=%q documents=%v", accept, resp.Error, resp.Content, documentNames(resp)) + } + answers = append(answers, resp) + } + if answers[0].Content != answers[1].Content || answers[0].Documents[0].Content != answers[1].Documents[0].Content { + t.Error("a single-document model answered differently with and without accept_documents") + } +} + +// A service withholding edit_documents answers as one predating the fields: a +// model of one document is edited into content alone, with no documents, no +// referrers and no applied document names; a model of several is refused even +// for a request accepting documents. +func TestApplyEditsWithoutEditDocumentsAnswersContentAlone(t *testing.T) { + srv := mustNewServiceWithout(t, CapabilityEditDocuments) + ctx := context.Background() + + hash := mustParsedSources(t, srv, "demo.sysml", editModelSource) + resp, err := srv.ApplyEdits(ctx, &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: true, + Operations: []*pb.EditOperation{setValueOp("Demo::SC::unitMass", "1050.0[SI::kg]")}, + }) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if resp.Error != "" || !strings.Contains(resp.Content, "1050.0[SI::kg]") { + t.Fatalf("error=%q content=%q, want the edited notation in content", resp.Error, resp.Content) + } + if len(resp.Documents) != 0 { + t.Errorf("documents = %v, want none without the capability", documentNames(resp)) + } + if len(resp.Applied) != 1 || resp.Applied[0].Document != "" { + t.Errorf("applied = %v, want one edit naming no document", resp.Applied) + } + + refused, err := srv.ApplyEdits(ctx, &pb.ApplyEditsRequest{ + ModelHash: hash, Operations: []*pb.EditOperation{deleteOp("Demo::SC", false)}, + }) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if refused.Failure != pb.EditFailure_EDIT_FAILURE_DELETE_REFERENCED { + t.Fatalf("failure = %s (%s), want DELETE_REFERENCED", refused.Failure, refused.Error) + } + if len(refused.ReferringElements) == 0 || len(refused.Referrers) != 0 { + t.Errorf("referring_elements=%v referrers=%v, want the legacy list alone", refused.ReferringElements, refused.Referrers) + } + + several := mustParsedSources(t, srv, "p.sysml", editDocP, "q.sysml", editDocQ) + _, err = srv.ApplyEdits(ctx, &pb.ApplyEditsRequest{ + ModelHash: several, AcceptDocuments: true, Operations: []*pb.EditOperation{renameOp("P::Keep", "Kept")}, + }) + if connect.CodeOf(err) != connect.CodeFailedPrecondition || !strings.Contains(err.Error(), CapabilityEditDocuments) { + t.Errorf("model of several: err = %v, want FAILED_PRECONDITION naming %s", err, CapabilityEditDocuments) + } +} + +// A rename in a model of several documents rewrites the declaration and every +// reference, and the response lists each rewritten document by its parse name, +// the model's first document first and the others in name order. content stays +// empty: it is one document's notation, and the model has several. +func TestApplyEditsRenameCrossesDocuments(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "p.sysml", editDocP, "r.sysml", editDocR, "q.sysml", editDocQ) + + resp := mustApplied(t, srv, hash, renameOp("P::Keep", "Kept")) + if resp.Content != "" { + t.Errorf("content = %q, want empty for a model of several documents", resp.Content) + } + if strings.Join(documentNames(resp), ",") != "p.sysml,q.sysml,r.sysml" { + t.Errorf("documents = %v, want p.sysml first, then q.sysml and r.sysml", documentNames(resp)) + } + if got, want := documentContent(t, resp, "p.sysml"), strings.Replace(editDocP, "part def Keep;", "part def Kept;", 1); got != want { + t.Errorf("p.sysml =\n%s\nwant\n%s", got, want) + } + if got, want := documentContent(t, resp, "q.sysml"), strings.Replace(editDocQ, "P::Keep", "P::Kept", 1); got != want { + t.Errorf("q.sysml =\n%s\nwant\n%s", got, want) + } + if got, want := documentContent(t, resp, "r.sysml"), strings.Replace(editDocR, "P::Keep", "P::Kept", 1); got != want { + t.Errorf("r.sysml =\n%s\nwant\n%s", got, want) + } + if len(resp.Applied) != 3 { + t.Fatalf("applied %d edits, want 3 (declaration and two references)", len(resp.Applied)) + } + for i, doc := range []string{"p.sysml", "q.sysml", "r.sysml"} { + if resp.Applied[i].Document != doc { + t.Errorf("applied[%d].document = %q, want %q", i, resp.Applied[i].Document, doc) + } + } +} + +// An edit that touches one document of several returns that document alone, +// with content empty: a document the edits left as parsed is not listed. The +// request names the document its operations target. +func TestApplyEditsListsChangedDocumentsOnly(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "p.sysml", editDocP, "q.sysml", editDocQ) + + resp := mustAppliedIn(t, srv, hash, "q.sysml", renameOp("Q::k", "kept")) + if resp.Content != "" { + t.Errorf("content = %q, want empty for a model of several documents", resp.Content) + } + if got := documentNames(resp); len(got) != 1 || got[0] != "q.sysml" { + t.Fatalf("documents = %v, want [q.sysml]", got) + } + if got, want := resp.Documents[0].Content, strings.Replace(editDocQ, "part k :", "part kept :", 1); got != want { + t.Errorf("q.sysml =\n%s\nwant\n%s", got, want) + } + if len(resp.Applied) != 1 || resp.Applied[0].Document != "q.sysml" { + t.Errorf("applied = %v, want one edit in q.sysml", resp.Applied) + } +} + +// A cascade delete removes the target and its referrers in every document, +// their referrers in turn included, and returns each rewritten document. +func TestApplyEditsCascadeDeleteCrossesDocuments(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "p.sysml", editDocP, "q.sysml", editDocQ, "r.sysml", editDocR) + + resp := mustApplied(t, srv, hash, deleteOp("P::Base", true)) + if strings.Join(documentNames(resp), ",") != "p.sysml,q.sysml,r.sysml" { + t.Fatalf("documents = %v, want all three", documentNames(resp)) + } + if got, want := documentContent(t, resp, "p.sysml"), "package P {\n part def Keep;\n}\n"; got != want { + t.Errorf("p.sysml =\n%s\nwant\n%s", got, want) + } + if got, want := documentContent(t, resp, "q.sysml"), "package Q {\n part k : P::Keep;\n}\n"; got != want { + t.Errorf("q.sysml =\n%s\nwant\n%s", got, want) + } + if got, want := documentContent(t, resp, "r.sysml"), "package R {\n part d : P::Keep;\n}\n"; got != want { + t.Errorf("r.sysml =\n%s\nwant\n%s", got, want) + } +} + +// A delete without cascade of a declaration referred to from another document +// is refused, naming the referrers with their documents, and returns no notation. +func TestApplyEditsRefusalNamesReferrersInOtherDocuments(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "p.sysml", editDocP, "q.sysml", editDocQ) + + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: true, Operations: []*pb.EditOperation{deleteOp("P::Base", false)}, + }) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if resp.Failure != pb.EditFailure_EDIT_FAILURE_DELETE_REFERENCED { + t.Fatalf("failure = %s (%s), want DELETE_REFERENCED", resp.Failure, resp.Error) + } + if resp.Content != "" || len(resp.Documents) != 0 || len(resp.Applied) != 0 { + t.Errorf("a refusal returned notation: content=%q documents=%v applied=%v", + resp.Content, documentNames(resp), resp.Applied) + } + if strings.Join(resp.ReferringElements, ",") != "P::own,Q::b (q.sysml)" { + t.Errorf("referring_elements = %v, want P::own and Q::b (q.sysml)", resp.ReferringElements) + } + want := []*pb.Referrer{{Name: "P::own", Document: "p.sysml"}, {Name: "Q::b", Document: "q.sysml"}} + if len(resp.Referrers) != len(want) { + t.Fatalf("referrers = %v, want %v", resp.Referrers, want) + } + for i := range want { + if resp.Referrers[i].Name != want[i].Name || resp.Referrers[i].Document != want[i].Document { + t.Errorf("referrers[%d] = %v, want %v", i, resp.Referrers[i], want[i]) + } + } +} + +// Referrers are answered in document then name order, referring_elements +// beside them, when the engine found the edited document's own first. +func TestApplyEditsRefusalOrdersReferrersByDocument(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "z.sysml", editDocP, "a.sysml", editDocQ) + + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: true, Operations: []*pb.EditOperation{deleteOp("P::Base", false)}, + }) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if resp.Failure != pb.EditFailure_EDIT_FAILURE_DELETE_REFERENCED { + t.Fatalf("failure = %s (%s), want DELETE_REFERENCED", resp.Failure, resp.Error) + } + if strings.Join(resp.ReferringElements, ",") != "Q::b (a.sysml),P::own" { + t.Errorf("referring_elements = %v, want Q::b (a.sysml) then P::own", resp.ReferringElements) + } + want := []*pb.Referrer{{Name: "Q::b", Document: "a.sysml"}, {Name: "P::own", Document: "z.sysml"}} + if len(resp.Referrers) != len(want) { + t.Fatalf("referrers = %v, want %v", resp.Referrers, want) + } + for i := range want { + if resp.Referrers[i].Name != want[i].Name || resp.Referrers[i].Document != want[i].Document { + t.Errorf("referrers[%d] = %v, want %v", i, resp.Referrers[i], want[i]) + } + } +} + +// A move respells references in its own document only, so one referred to from +// another document is refused as REFERENCED_ELSEWHERE, naming the referrer. +func TestApplyEditsMoveReferredToFromAnotherDocumentIsRefused(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "p.sysml", editDocP, "q.sysml", editDocQ) + + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: true, Operations: []*pb.EditOperation{ + addMemberOp("P", "package", "Inner"), + moveOp("P::Keep", "P::Inner"), + }, + }) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if resp.Failure != pb.EditFailure_EDIT_FAILURE_REFERENCED_ELSEWHERE { + t.Fatalf("failure = %s (%s), want REFERENCED_ELSEWHERE", resp.Failure, resp.Error) + } + if resp.Content != "" || len(resp.Documents) != 0 || len(resp.Applied) != 0 { + t.Errorf("a refusal returned notation: content=%q documents=%v applied=%v", + resp.Content, documentNames(resp), resp.Applied) + } + if len(resp.Referrers) != 1 || resp.Referrers[0].Name != "Q::k" || resp.Referrers[0].Document != "q.sysml" { + t.Errorf("referrers = %v, want Q::k in q.sysml", resp.Referrers) + } +} + +// Operations across documents are one batch: a refused operation leaves every +// document unedited, including one another operation of the batch had rewritten. +func TestApplyEditsAcrossDocumentsIsAtomic(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "p.sysml", editDocP, "q.sysml", editDocQ) + + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: true, Operations: []*pb.EditOperation{ + renameOp("P::Keep", "Kept"), + deleteOp("P::Base", false), + }, + }) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if resp.Failure != pb.EditFailure_EDIT_FAILURE_DELETE_REFERENCED { + t.Fatalf("failure = %s (%s), want DELETE_REFERENCED", resp.Failure, resp.Error) + } + if resp.Content != "" || len(resp.Documents) != 0 || len(resp.Applied) != 0 { + t.Errorf("a refused batch returned notation: content=%q documents=%v applied=%v", + resp.Content, documentNames(resp), resp.Applied) + } +} + +// The rename is judged against every document of the model: one whose names +// would capture the new name in another document is refused, naming the site +// there, and nothing is rewritten anywhere. +func TestApplyEditsRefusesARenameAnotherDocumentWouldCapture(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, + "p.sysml", "package P {\n part def Old;\n}\n", + "q.sysml", "package Q {\n private import P::*;\n part def Fresh;\n part a : Old;\n}\n", + ) + + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: true, Operations: []*pb.EditOperation{renameOp("P::Old", "Fresh")}, + }) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if resp.Failure != pb.EditFailure_EDIT_FAILURE_INVALID_NAME { + t.Fatalf("failure = %s (%s), want INVALID_NAME", resp.Failure, resp.Error) + } + if !strings.Contains(resp.Error, "Fresh") { + t.Errorf("error %q does not name the conflict", resp.Error) + } + if strings.Join(resp.ReferringElements, ",") != "Q (q.sysml)" { + t.Errorf("referring_elements = %v, want Q (q.sysml)", resp.ReferringElements) + } + if len(resp.Referrers) != 1 || resp.Referrers[0].Name != "Q" || resp.Referrers[0].Document != "q.sysml" { + t.Errorf("referrers = %v, want Q in q.sysml", resp.Referrers) + } + if resp.Content != "" || len(resp.Documents) != 0 { + t.Errorf("a refusal returned notation: content=%q documents=%v", resp.Content, documentNames(resp)) + } +} + +// A move refused for a reference it cannot respell names the referring +// declaration of the unedited document in both the legacy and structured fields. +func TestApplyEditsNamesTheReferrerAMoveCannotRespell(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "move.sysml", + "package P {\n part def Base;\n part def H {\n part b : Base;\n }\n"+ + " part def Other;\n part h : H;\n part c : Base = h.b;\n}\n") + + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: true, Operations: []*pb.EditOperation{moveOp("P::H::b", "P::Other")}, + }) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if resp.Failure != pb.EditFailure_EDIT_FAILURE_MOVE_REFERENCED { + t.Fatalf("failure = %s (%s), want MOVE_REFERENCED", resp.Failure, resp.Error) + } + if strings.Join(resp.ReferringElements, ",") != "P::c" { + t.Errorf("referring_elements = %v, want P::c", resp.ReferringElements) + } + if len(resp.Referrers) != 1 || resp.Referrers[0].Name != "P::c" || resp.Referrers[0].Document != "move.sysml" { + t.Errorf("referrers = %v, want P::c in move.sysml", resp.Referrers) + } + if resp.Content != "" || len(resp.Documents) != 0 || len(resp.Applied) != 0 { + t.Errorf("a refusal returned notation: content=%q documents=%v", resp.Content, documentNames(resp)) + } +} + +// A request naming a document the model does not have is a call failure, not +// a refused edit, and it lists the documents the model has. +func TestApplyEditsRejectsAnUnknownDocument(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "p.sysml", editDocP, "q.sysml", editDocQ) + + _, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: true, Document: "r.sysml", Operations: []*pb.EditOperation{renameOp("P::Keep", "Kept")}, + }) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("err = %v, want INVALID_ARGUMENT", err) + } + if !strings.Contains(err.Error(), "p.sysml, q.sysml") { + t.Errorf("err %q does not list the model's documents", err) + } +} + +// An operation targeting a declaration of a document other than the one the +// request names is refused as an unknown target, naming that document. +func TestApplyEditsRefusesATargetOfAnotherDocument(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, "p.sysml", editDocP, "q.sysml", editDocQ) + + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: true, Operations: []*pb.EditOperation{renameOp("Q::k", "kept")}, + }) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if resp.Failure != pb.EditFailure_EDIT_FAILURE_UNKNOWN_TARGET || !strings.Contains(resp.Error, "q.sysml") { + t.Errorf("failure = %s (%s), want UNKNOWN_TARGET naming q.sysml", resp.Failure, resp.Error) + } + if resp.Content != "" || len(resp.Documents) != 0 { + t.Errorf("a refusal returned notation: content=%q documents=%v", resp.Content, documentNames(resp)) + } +} + +// The edited notation is validated in the whole model: a value naming a +// declaration of another document resolves, so the edit is not refused. +func TestApplyEditsValidatesAgainstTheOtherDocuments(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, + "lib.sysml", "package Lib {\n\tattribute basePower = 150;\n\tpart def Engine;\n}\n", + "top.sysml", "package Top {\n\tprivate import Lib::*;\n\tpart def Car {\n\t\tpart motor : Engine;\n\t\tattribute rating = 1;\n\t}\n}\n", + ) + + resp := mustAppliedIn(t, srv, hash, "top.sysml", setValueOp("Top::Car::rating", "Lib::basePower * 2")) + if resp.Content != "" { + t.Errorf("content = %q, want empty for a model of several documents", resp.Content) + } + if got := documentNames(resp); len(got) != 1 || got[0] != "top.sysml" { + t.Fatalf("documents = %v, want [top.sysml]", got) + } + if !strings.Contains(resp.Documents[0].Content, "attribute rating = Lib::basePower * 2;") { + t.Errorf("value not set:\n%s", resp.Documents[0].Content) + } + + refused, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, AcceptDocuments: true, Document: "top.sysml", + Operations: []*pb.EditOperation{setValueOp("Top::Car::rating", "Lib::peakPower")}, + }) + if err != nil { + t.Fatalf("ApplyEdits failed: %v", err) + } + if refused.Failure != pb.EditFailure_EDIT_FAILURE_RESULT_INVALID { + t.Errorf("failure = %s (%s), want RESULT_INVALID for a name the model lacks", refused.Failure, refused.Error) + } + if refused.Content != "" || len(refused.Documents) != 0 { + t.Errorf("a refusal returned notation: content=%q documents=%v", refused.Content, documentNames(refused)) + } +} + +// Renaming a package every other document reaches through wildcard imports +// rewrites the imports and leaves the names they surface resolving: the edited +// documents are judged with their rewritten imports, chained re-exports included. +func TestApplyEditsRenamesAWildcardImportedPackage(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParsedSources(t, srv, + "p.sysml", "package P {\n package Inner {\n part def Old;\n }\n}\n", + "m.sysml", "package M {\n public import P::**;\n}\n", + "q.sysml", "package Q {\n private import M::*;\n part a : Old;\n part b : P::Inner::Old;\n}\n", + ) + + resp := mustApplied(t, srv, hash, renameOp("P", "Fresh")) + if strings.Join(documentNames(resp), ",") != "p.sysml,m.sysml,q.sysml" { + t.Fatalf("documents = %v, want p.sysml first, then m.sysml and q.sysml", documentNames(resp)) + } + if got, want := documentContent(t, resp, "m.sysml"), "package M {\n public import Fresh::**;\n}\n"; got != want { + t.Errorf("m.sysml =\n%s\nwant\n%s", got, want) + } + if got, want := documentContent(t, resp, "q.sysml"), "package Q {\n private import M::*;\n part a : Old;\n part b : Fresh::Inner::Old;\n}\n"; got != want { + t.Errorf("q.sysml =\n%s\nwant\n%s", got, want) + } +} diff --git a/internal/grpc/edit_reindex_test.go b/internal/frontend/grpc/edit_reindex_test.go similarity index 100% rename from internal/grpc/edit_reindex_test.go rename to internal/frontend/grpc/edit_reindex_test.go diff --git a/internal/grpc/edit_test.go b/internal/frontend/grpc/edit_test.go similarity index 100% rename from internal/grpc/edit_test.go rename to internal/frontend/grpc/edit_test.go diff --git a/internal/frontend/grpc/engines.go b/internal/frontend/grpc/engines.go new file mode 100644 index 0000000000..3d0e6b0fc1 --- /dev/null +++ b/internal/frontend/grpc/engines.go @@ -0,0 +1,156 @@ +package grpc + +import ( + "context" + "errors" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// CapabilityEngines names the ListEngines RPC, the `engine` field of the +// verification and sweep requests, and the `engine`, `strength` and `bounds` +// fields of their responses and of every Verdict. +const CapabilityEngines = "engines" + +// CapabilityEnginesExternal says the service was started with -serve-external-engines and +// runs the manifest engines it names; ListEngines reports which with `served`. +const CapabilityEnginesExternal = "engines_external" + +// engineSelection reads a request's engine field. Empty is auto; anything else +// needs the engines capability and must name an engine, "auto" or "all". +func (s *Service) engineSelection(spelling string) (analysis.Selection, error) { + if spelling == "" { + return analysis.Auto(), nil + } + if err := s.requireCapability(CapabilityEngines); err != nil { + return analysis.Selection{}, err + } + selection, err := s.engines.Select(spelling) + if errors.Is(err, analysis.ErrEngineWithheld) { + return analysis.Selection{}, statusError(connect.CodeFailedPrecondition, err.Error()) + } + if err != nil { + return analysis.Selection{}, statusError(connect.CodeInvalidArgument, err.Error()) + } + // "explore" asks what the explore schedule asks, so it needs that capability too. + if _, explores := analysis.Explores(selection, runtime.DefaultSchedulePolicy); explores { + if err := s.requireCapability(CapabilityScheduleExplore); err != nil { + return analysis.Selection{}, err + } + } + return selection, nil +} + +// ListEngines lists the engines this build registers, in name order. +func (s *Service) ListEngines(_ context.Context, _ *pb.ListEnginesRequest) (*pb.ListEnginesResponse, error) { + if err := s.requireCapability(CapabilityEngines); err != nil { + return nil, err + } + listings := s.engines.Listings() + resp := &pb.ListEnginesResponse{Engines: make([]*pb.EngineInfo, 0, len(listings))} + for _, l := range listings { + info := &pb.EngineInfo{ + Name: l.Engine, + Authority: l.Authority.String(), + Answers: make([]string, 0, len(l.Questions)), + Bounds: l.Description.Bounds, + Process: l.Description.Process, + ProcessFound: l.Status.Process, + Ready: l.Ready(), + Kind: l.Origin.KindText(), + Protocol: l.Origin.ProtocolText(), + Source: l.Origin.File, + Command: l.Origin.Command, + Version: l.Origin.Version, + Served: l.Served(), + } + for _, kind := range l.Questions { + info.Answers = append(info.Answers, kind.String()) + } + if l.Status.Err != nil { + info.Unavailable = l.Status.Err.Error() + } + resp.Engines = append(resp.Engines, info) + } + return resp, nil +} + +// perform puts one execution on the request's runtime to the service's engines +// under the request's selection, under the schedule that runtime was set. +func perform[T any](ctx context.Context, v *verifyContext, subject string, call func(*runtime.Context) (T, error), answer func(T, error) analysis.Answer) (T, analysis.Plan, error) { + return performOn(ctx, v.service, v.runtime, v.engine, subject, call, answer) +} + +// check puts one constraint, requirement or satisfaction check to the engines. +func (v *verifyContext) check(ctx context.Context, subject string, call func(*runtime.Context) (runtime.CheckResult, error)) (runtime.CheckResult, analysis.Plan, error) { + return perform(ctx, v, subject, call, analysis.CheckAnswer) +} + +// performOn puts one execution on rt to the service's engines under selection, +// under the schedule rt was set. +func performOn[T any](ctx context.Context, s *Service, rt *runtime.Context, selection analysis.Selection, subject string, call func(*runtime.Context) (T, error), answer func(T, error) analysis.Answer) (T, analysis.Plan, error) { + schedule := rt.Schedule() + return analysis.Perform(ctx, s.engines, analysis.Request{ + Model: analysis.Held(rt), + Subject: subject, + Schedule: schedule, + ModelSeed: analysis.ModelSeedOf(rt), + Draws: analysis.DrawsOf(rt), + ClockStep: analysis.ClockStepOf(rt), + Budget: analysis.BudgetOf(s.budgets, schedule, analysis.Evaluate, s.jobs), + Selection: selection, + }, call, answer) +} + +// standing spells for the wire which engine a plan's answer is, how strong its +// evidence is and the bounds it ran under; a plan no engine answered is unnamed. +type standing struct { + engine string + strength string + bounds []*pb.Bound +} + +// standingOf is what a plan established, empty when the service withholds the +// engines capability or no engine was asked. +func (s *Service) standingOf(plan analysis.Plan) standing { + if !s.capabilities.has(CapabilityEngines) || len(plan.Steps) == 0 { + return standing{} + } + result := plan.Result + out := standing{engine: result.Engine, strength: result.Strength.String()} + for _, b := range result.Bounds { + out.bounds = append(out.bounds, &pb.Bound{Name: b.Name, Limit: b.Limit, Reached: b.Reached}) + } + return out +} + +// stamp writes a plan's standing on a verdict. +func (st standing) stamp(verdict *pb.Verdict) *pb.Verdict { + verdict.Engine = st.engine + verdict.Strength = st.strength + verdict.Bounds = st.bounds + return verdict +} + +// callerGone is the caller's own error when a run failed because the caller went +// away, which fails the call rather than being reported as the run's failure. +func callerGone(ctx context.Context, err error) error { + if err != nil { + return ctx.Err() + } + return nil +} + +// heldAnswer is what a behavior run established: the values it left, or nothing. +func heldAnswer(held map[string]runtime.Value, err error) analysis.Answer { + return analysis.ValuesAnswer(analysis.ValuesOf(held), err) +} + +// stateRun is what one state machine run left: its final data and the states it visited. +type stateRun struct { + final map[string]runtime.Value + visited []string +} diff --git a/internal/frontend/grpc/engines_test.go b/internal/frontend/grpc/engines_test.go new file mode 100644 index 0000000000..6123345127 --- /dev/null +++ b/internal/frontend/grpc/engines_test.go @@ -0,0 +1,417 @@ +package grpc + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "connectrpc.com/connect" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/tests/testutil/gobuild" + "google.golang.org/protobuf/proto" +) + +// ListEngines names every registered engine in name order, with its authority, +// the questions it answers and whether it can run here. +func TestListEnginesNamesEveryEngine(t *testing.T) { + srv := mustNewService(t, 10) + t.Cleanup(srv.Close) + resp, err := srv.ListEngines(context.Background(), &pb.ListEnginesRequest{}) + if err != nil { + t.Fatalf("ListEngines: %v", err) + } + want := []struct { + name, authority string + answers []string + }{ + {"check", "bounded", []string{"outcomes", "holds", "sensitive"}}, + {"explore", "proved", []string{"outcomes"}}, + {"run", "observed", []string{"evaluate"}}, + {"smt", "proved", []string{"holds", "sensitive"}}, + {"solve", "proved", []string{"satisfiable"}}, + {"sweep", "observed", []string{"sweep"}}, + } + if len(resp.Engines) != len(want) { + t.Fatalf("engines = %v, want %d", resp.Engines, len(want)) + } + for i, w := range want { + got := resp.Engines[i] + if got.Name != w.name || got.Authority != w.authority || strings.Join(got.Answers, ",") != strings.Join(w.answers, ",") { + t.Errorf("engine %d = %v, want %s %s %v", i, got, w.name, w.authority, w.answers) + } + if got.Name != "solve" && got.Name != "smt" && (!got.Ready || got.Process != "" || got.Unavailable != "") { + t.Errorf("in-process engine %s = %v, want ready with no process", got.Name, got) + } + } + for _, external := range []*pb.EngineInfo{resp.Engines[3], resp.Engines[4]} { + if external.Process == "" || external.Ready == (external.Unavailable != "") { + t.Errorf("%s = %v, want a process and ready or a reason", external.Name, external) + } + if external.Ready && external.ProcessFound == "" { + t.Errorf("%s is ready but names no found process: %v", external.Name, external) + } + } +} + +// ListEngines and the engine field need the engines capability; an unset field +// is auto and needs nothing, though the response then withholds the standing. +func TestEngineFieldNeedsTheEnginesCapability(t *testing.T) { + ctx := context.Background() + srv := mustNewServiceWithout(t, CapabilityEngines) + hash := mustVerifyModel(t, srv, verifyModelSource, "engines-withheld") + + _, err := srv.ListEngines(ctx, &pb.ListEnginesRequest{}) + if connect.CodeOf(err) != connect.CodeUnimplemented || !strings.Contains(err.Error(), CapabilityEngines) { + t.Errorf("ListEngines without engines: %v, want UNIMPLEMENTED naming %s", err, CapabilityEngines) + } + for name, call := range engineCalls(ctx, srv, hash, "run") { + if err := call(); connect.CodeOf(err) != connect.CodeUnimplemented || !strings.Contains(err.Error(), CapabilityEngines) { + t.Errorf("%s with engine run without engines: %v, want UNIMPLEMENTED naming %s", name, err, CapabilityEngines) + } + } + resp, err := srv.VerifyConstraint(ctx, &pb.VerifyConstraintRequest{ModelHash: hash, SymbolId: "Demo::Vehicle::massPositive"}) + if err != nil || resp.Error != "" || resp.Verdict == nil || !resp.Verdict.Holds { + t.Fatalf("VerifyConstraint with engine unset: %v %v", err, resp) + } + if v := resp.Verdict; v.Engine != "" || v.Strength != "" || len(v.Bounds) != 0 { + t.Errorf("verdict carries a standing the service withholds: %v", v) + } +} + +// A spelling that names no engine is INVALID_ARGUMENT on every RPC carrying the +// field, before the model is looked up. +func TestAnUnknownEngineIsInvalidArgument(t *testing.T) { + ctx := context.Background() + srv := mustNewService(t, 10) + t.Cleanup(srv.Close) + hash := mustVerifyModel(t, srv, verifyModelSource, "engines-unknown") + for _, h := range []string{hash, "missing"} { + for name, call := range engineCalls(ctx, srv, h, "nope") { + err := call() + if connect.CodeOf(err) != connect.CodeInvalidArgument || !strings.Contains(err.Error(), `"nope"`) { + t.Errorf("%s with engine nope on %q: %v, want INVALID_ARGUMENT naming the spelling", name, h, err) + } + } + } +} + +// engineCalls is every RPC carrying the engine field, made with the spelling. +func engineCalls(ctx context.Context, srv *Service, hash, engine string) map[string]func() error { + return map[string]func() error{ + "VerifyConstraint": func() error { + _, err := srv.VerifyConstraint(ctx, &pb.VerifyConstraintRequest{ModelHash: hash, SymbolId: "Demo::Vehicle::massPositive", Engine: engine}) + return err + }, + "VerifyRequirement": func() error { + _, err := srv.VerifyRequirement(ctx, &pb.VerifyRequirementRequest{ModelHash: hash, SymbolId: "Demo::Vehicle::lightEnough", Engine: engine}) + return err + }, + "VerifySatisfaction": func() error { + _, err := srv.VerifySatisfaction(ctx, &pb.VerifySatisfactionRequest{ModelHash: hash, Engine: engine}) + return err + }, + "ValidateInstance": func() error { + _, err := srv.ValidateInstance(ctx, &pb.ValidateInstanceRequest{ModelHash: hash, SymbolId: "Demo::sedan", Engine: engine}) + return err + }, + "EvaluateCalc": func() error { + _, err := srv.EvaluateCalc(ctx, &pb.EvaluateCalcRequest{ModelHash: hash, SymbolId: "Demo::add", Arguments: []*pb.Value{intProto(1), intProto(2)}, Engine: engine}) + return err + }, + "RunAnalysis": func() error { + _, err := srv.RunAnalysis(ctx, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "Demo::analysis", Engine: engine}) + return err + }, + "RunSweep": func() error { + _, err := srv.RunSweep(ctx, &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Demo::add", Ranges: []*pb.SweepRange{intRange("x", 1, 2)}, NamedArguments: map[string]*pb.Value{"y": intProto(1)}, Engine: engine}) + return err + }, + } +} + +// unreached reports whether bounds name the run engine's limits, none reached. +func unreached(bounds []*pb.Bound) bool { + if len(bounds) == 0 { + return false + } + for _, b := range bounds { + if b.Reached || b.Name == "" || b.Limit <= 0 { + return false + } + } + return true +} + +// Every verification response names the engine that answered it, the strength +// it earned and the bounds it ran under; unset, auto, run and all answer alike +// where run is the one covering engine. A violation one run saw is witnessed. +func TestVerdictsCarryEngineStrengthAndBounds(t *testing.T) { + ctx := context.Background() + srv := mustNewService(t, 10) + t.Cleanup(srv.Close) + hash := mustVerifyModel(t, srv, verifyModelSource, "engines-standing") + + for _, engine := range []string{"", "auto", "run", "all"} { + resp, err := srv.VerifyConstraint(ctx, &pb.VerifyConstraintRequest{ModelHash: hash, SymbolId: "Demo::Vehicle::massPositive", Engine: engine}) + if err != nil || resp.Error != "" || resp.Verdict == nil { + t.Fatalf("VerifyConstraint engine %q: %v %v", engine, err, resp) + } + if v := resp.Verdict; !v.Holds || v.Engine != "run" || v.Strength != "observed" || !unreached(v.Bounds) { + t.Errorf("VerifyConstraint engine %q verdict = %v, want holds by run, observed, within its bounds", engine, v) + } + + req, err := srv.VerifyRequirement(ctx, &pb.VerifyRequirementRequest{ModelHash: hash, SymbolId: "Demo::Vehicle::tiny", Engine: engine}) + if err != nil || req.Error != "" || req.Verdict == nil { + t.Fatalf("VerifyRequirement engine %q: %v %v", engine, err, req) + } + if v := req.Verdict; v.Holds || v.Engine != "run" || v.Strength != "witnessed" { + t.Errorf("VerifyRequirement engine %q verdict = %v, want violated by run, witnessed", engine, v) + } + + sat, err := srv.VerifySatisfaction(ctx, &pb.VerifySatisfactionRequest{ModelHash: hash, Engine: engine}) + if err != nil || sat.Error != "" || len(sat.Verdicts) != 2 { + t.Fatalf("VerifySatisfaction engine %q: %v %v", engine, err, sat) + } + for _, v := range sat.Verdicts { + want := "observed" + if !v.Holds { + want = "witnessed" + } + if v.Engine != "run" || v.Strength != want { + t.Errorf("VerifySatisfaction engine %q verdict = %v, want by run, %s", engine, v, want) + } + } + + calc, err := srv.EvaluateCalc(ctx, &pb.EvaluateCalcRequest{ModelHash: hash, SymbolId: "Demo::add", Arguments: []*pb.Value{intProto(1), intProto(2)}, Engine: engine}) + if err != nil || calc.Error != "" { + t.Fatalf("EvaluateCalc engine %q: %v %v", engine, err, calc) + } + if calc.Engine != "run" || calc.Strength != "observed" || !unreached(calc.Bounds) { + t.Errorf("EvaluateCalc engine %q = %v, want by run, observed, within its bounds", engine, calc) + } + } + for _, engine := range []string{"", "auto", "sweep", "all"} { + sweep := runSweep(t, srv, &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Demo::add", Ranges: []*pb.SweepRange{intRange("x", 1, 2)}, NamedArguments: map[string]*pb.Value{"y": intProto(1)}, Engine: engine}) + if sweep.Error != "" { + t.Fatalf("RunSweep engine %q reported %q", engine, sweep.Error) + } + if sweep.Engine != "sweep" || sweep.Strength != "observed" || len(sweep.Rows) != 2 { + t.Errorf("RunSweep engine %q = %s %s over %d rows, want by sweep, observed, 2 rows", engine, sweep.Engine, sweep.Strength, len(sweep.Rows)) + } + } +} + +// A named engine that does not answer the question is the answer: the verdict +// is its refusal, not covered, and no other engine is tried. +func TestNamedEngineRefusalIsFinalOverTheWire(t *testing.T) { + ctx := context.Background() + srv := mustNewService(t, 10) + t.Cleanup(srv.Close) + hash := mustVerifyModel(t, srv, verifyModelSource, "engines-refusal") + + resp, err := srv.VerifyConstraint(ctx, &pb.VerifyConstraintRequest{ModelHash: hash, SymbolId: "Demo::Vehicle::massPositive", Engine: "sweep"}) + if err != nil || resp.Verdict == nil { + t.Fatalf("VerifyConstraint engine sweep: %v %v", err, resp) + } + v := resp.Verdict + if v.Holds || !strings.Contains(v.Error, "sweep does not answer evaluate questions") || v.Strength != "not covered" { + t.Errorf("verdict = %v, want the refusal, not covered", v) + } + + sweep := runSweep(t, srv, &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Demo::add", Ranges: []*pb.SweepRange{intRange("x", 1, 2)}, NamedArguments: map[string]*pb.Value{"y": intProto(1)}, Engine: "run"}) + if !strings.Contains(sweep.Error, "run does not answer sweep questions") || len(sweep.Rows) != 0 || sweep.Strength != "not covered" { + t.Errorf("RunSweep engine run = %v, want the refusal, not covered", sweep) + } +} + +// engine=explore asks what schedule=explore asks: the same outcomes, the +// standing of the explore engine; without the schedule-explore capability it is +// refused as that schedule is. +func TestEngineExploreIsScheduleExplore(t *testing.T) { + srv := mustNewService(t, 10) + t.Cleanup(srv.Close) + hash := mustVerifyModel(t, srv, exploreModel, "engines-explore") + + bySchedule := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "Race::raced", Schedule: "explore"}) + byEngine := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "Race::raced", Engine: "explore"}) + if bySchedule.Error != "" || byEngine.Error != "" { + t.Fatalf("explore reported %q / %q", bySchedule.Error, byEngine.Error) + } + if !proto.Equal(bySchedule, byEngine) { + t.Errorf("engine explore answered\n%v\nschedule explore answered\n%v", byEngine, bySchedule) + } + if byEngine.Engine != "explore" || byEngine.Strength != "proved" || len(byEngine.Bounds) == 0 { + t.Errorf("engine explore standing = %s %s %v, want explore, proved, with its bounds", byEngine.Engine, byEngine.Strength, byEngine.Bounds) + } + for _, b := range byEngine.Bounds { + if b.Reached { + t.Errorf("a complete exploration reached bound %v", b) + } + } + + without := mustNewServiceWithout(t, CapabilityScheduleExplore) + hash = mustVerifyModel(t, without, exploreModel, "engines-explore-withheld") + _, err := without.RunAnalysis(context.Background(), &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "Race::raced", Engine: "explore"}) + if connect.CodeOf(err) != connect.CodeUnimplemented || !strings.Contains(err.Error(), CapabilityScheduleExplore) { + t.Errorf("engine explore without %s: %v, want UNIMPLEMENTED naming it", CapabilityScheduleExplore, err) + } +} + +var ( + standinOnce sync.Once + standinPath string + standinErr error +) + +// engineStandin builds the analysis package's stand-in engine once per test binary. +func engineStandin(t *testing.T) string { + t.Helper() + standinOnce.Do(func() { + dir, err := os.MkdirTemp("", "enginestandin") + if err != nil { + standinErr = err + return + } + standinPath = filepath.Join(dir, "enginestandin") + build := exec.Command("go", gobuild.Args(standinPath)...) + build.Dir = filepath.Join("..", "..", "exec", "analysis", "testdata", "enginestandin") + if out, err := build.CombinedOutput(); err != nil { + standinErr = fmt.Errorf("go build: %v\n%s", err, out) + } + }) + if standinErr != nil { + t.Fatalf("building the stand-in engine: %v", standinErr) + } + return standinPath +} + +// standinManifest points OPENSYSML_ENGINES at a manifest registering the stand-in as `standin`. +func standinManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + entry := `{"kind":"engine","name":"standin","version":"1.0.0","command":["` + engineStandin(t) + `"],` + + `"protocol":1,"answers":["holds"],"model":["sources"],"witness":"schedule","authority":"bounded"}` + file := filepath.Join(dir, "standin.json") + if err := os.WriteFile(file, []byte(entry), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(analysis.ToolsEnv, "") + t.Setenv(analysis.EnginesEnv, dir) + return file +} + +// standinInfo is the stand-in's entry in ListEngines. +func standinInfo(t *testing.T, srv *Service) *pb.EngineInfo { + t.Helper() + resp, err := srv.ListEngines(context.Background(), &pb.ListEnginesRequest{}) + if err != nil { + t.Fatalf("ListEngines: %v", err) + } + for _, e := range resp.Engines { + if e.Name == "standin" { + return e + } + } + t.Fatalf("standin is not listed in %v", resp.Engines) + return nil +} + +// A service started without -serve-external-engines lists a manifest engine with its +// origin and served false, refuses a request naming it with FAILED_PRECONDITION, keeps auto +// away from it and does not advertise engines_external. +func TestManifestEnginesAreListedButNotServedByDefault(t *testing.T) { + ctx := context.Background() + file := standinManifest(t) + srv := mustNewService(t, 10) + t.Cleanup(srv.Close) + + info := standinInfo(t, srv) + if info.Served || info.Ready || info.Kind != "engine" || info.Protocol != "stdio/1" || info.Source != file || + info.Command != engineStandin(t) || info.Version != "1.0.0" || info.Authority != "bounded" || + strings.Join(info.Answers, ",") != "holds" || info.Unavailable != "" { + t.Errorf("standin = %v, want its origin, not served and not ready with no fault", info) + } + resp, err := srv.ListEngines(ctx, &pb.ListEnginesRequest{}) + if err != nil { + t.Fatal(err) + } + for _, e := range resp.Engines { + if e.Name != "standin" && (e.Kind != "built-in" || e.Protocol != "-" || e.Source != "" || !e.Served) { + t.Errorf("built-in %s = %v, want kind built-in, no protocol or source, served", e.Name, e) + } + } + + server, err := srv.GetServerInfo(ctx, &pb.ServerInfoRequest{}) + if err != nil { + t.Fatal(err) + } + if slices.Contains(server.Capabilities, CapabilityEnginesExternal) { + t.Errorf("a service serving no manifest engine advertises %s", CapabilityEnginesExternal) + } + + hash := mustVerifyModel(t, srv, verifyModelSource, "engines-withheld-standin") + for name, call := range engineCalls(ctx, srv, hash, "standin") { + err := call() + if connect.CodeOf(err) != connect.CodeFailedPrecondition || !strings.Contains(err.Error(), "engine 'standin' is not served by this service") { + t.Errorf("%s with engine standin: %v, want FAILED_PRECONDITION naming the withholding", name, err) + } + } + verdict, err := srv.VerifyConstraint(ctx, &pb.VerifyConstraintRequest{ModelHash: hash, SymbolId: "Demo::Vehicle::massPositive", Engine: "all"}) + if err != nil || verdict.Verdict == nil || verdict.Verdict.Engine != "run" { + t.Errorf("engine all = %v %v, want the built-ins alone consulted", err, verdict) + } +} + +// Started with -serve-external-engines naming it, the service lists the engine served, +// advertises engines_external and puts a request naming it to the engine; a name that is not a +// manifest engine fails construction. +func TestServeExternalEnginesRunsTheNamedManifestEngines(t *testing.T) { + ctx := context.Background() + standinManifest(t) + srv, err := NewService(10, "test", ServeExternalEngines("standin")) + if err != nil { + t.Fatalf("NewService serving standin: %v", err) + } + t.Cleanup(srv.Close) + + if info := standinInfo(t, srv); !info.Served || !info.Ready || info.Unavailable != "" { + t.Errorf("standin = %v, want served and ready", info) + } + server, err := srv.GetServerInfo(ctx, &pb.ServerInfoRequest{}) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(server.Capabilities, CapabilityEnginesExternal) { + t.Errorf("a service serving standin does not advertise %s", CapabilityEnginesExternal) + } + + hash := mustVerifyModel(t, srv, verifyModelSource, "engines-served-standin") + resp, err := srv.VerifyConstraint(ctx, &pb.VerifyConstraintRequest{ModelHash: hash, SymbolId: "Demo::Vehicle::massPositive", Engine: "standin"}) + if err != nil || resp.Verdict == nil { + t.Fatalf("VerifyConstraint engine standin: %v %v", err, resp) + } + if v := resp.Verdict; v.Holds || v.Strength != "not covered" || !strings.Contains(v.Error, "standin does not answer evaluate questions") { + t.Errorf("verdict = %v, want the stand-in's own refusal of an evaluate question", v) + } + + if _, err := NewService(10, "test", ServeExternalEngines("run")); !errors.Is(err, analysis.ErrNotExternal) { + t.Errorf("NewService serving run: %v, want the typed refusal of a built-in", err) + } + all, err := NewService(10, "test", ServeExternalEngines(analysis.ServeAll)) + if err != nil { + t.Fatalf("NewService serving all: %v", err) + } + t.Cleanup(all.Close) + if info := standinInfo(t, all); !info.Served { + t.Errorf("standin under all = %v, want served", info) + } +} diff --git a/internal/grpc/evaluate_library_scope_test.go b/internal/frontend/grpc/evaluate_library_scope_test.go similarity index 91% rename from internal/grpc/evaluate_library_scope_test.go rename to internal/frontend/grpc/evaluate_library_scope_test.go index 905c2d3152..5c81095c7f 100644 --- a/internal/grpc/evaluate_library_scope_test.go +++ b/internal/frontend/grpc/evaluate_library_scope_test.go @@ -6,6 +6,7 @@ import ( "testing" pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" ) // Without a context an expression evaluates at the document root, where a @@ -36,7 +37,7 @@ func TestEvaluateResolvesLibraryFunctionsInTheRequestedScope(t *testing.T) { if err != nil || resp.Error != "" { t.Fatalf("Evaluate(%s, context %q): err = %v, error = %q", tc.expr, tc.context, err, resp.GetError()) } - if resp.Result.GetComplex() == nil || ProtoToComplex(resp.Result.GetComplex()) != complex(1, -1) { + if resp.Result.GetComplex() == nil || protoconv.ProtoToComplex(resp.Result.GetComplex()) != complex(1, -1) { t.Errorf("Evaluate(%s, context %q) = %v, want complex 1.0 - 1.0i", tc.expr, tc.context, resp.Result) } } diff --git a/internal/grpc/evaluate_retention_test.go b/internal/frontend/grpc/evaluate_retention_test.go similarity index 100% rename from internal/grpc/evaluate_retention_test.go rename to internal/frontend/grpc/evaluate_retention_test.go diff --git a/internal/grpc/evaluate_subject_test.go b/internal/frontend/grpc/evaluate_subject_test.go similarity index 100% rename from internal/grpc/evaluate_subject_test.go rename to internal/frontend/grpc/evaluate_subject_test.go diff --git a/internal/grpc/evaluate_whole_expression_test.go b/internal/frontend/grpc/evaluate_whole_expression_test.go similarity index 100% rename from internal/grpc/evaluate_whole_expression_test.go rename to internal/frontend/grpc/evaluate_whole_expression_test.go diff --git a/internal/frontend/grpc/explore.go b/internal/frontend/grpc/explore.go new file mode 100644 index 0000000000..ced48d387c --- /dev/null +++ b/internal/frontend/grpc/explore.go @@ -0,0 +1,77 @@ +package grpc + +import ( + "context" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// explored is what an exploration answered for the wire: its outcomes, how it +// ended, and the plan that ran it. +type explored struct { + outcomes []*pb.Outcome + status *pb.ExplorationStatus + plan analysis.Plan +} + +// explore puts the behavior's outcomes to the engines under selection, run +// performing it once per linearization on a context of its own on one of the plan's +// workers; a failed run is an outcome, a diverging replay an error. An outcome's values +// and notes are spelled from its witness run's context, which the outcome keeps. +func (s *Service) explore(ctx context.Context, subject string, policy runtime.SchedulePolicy, selection analysis.Selection, cached *CachedModel, run func(*runtime.Context) (runtime.Outcome, error)) (explored, error) { + plan, err := s.engines.Explore(ctx, analysis.Request{ + Model: s.model(cached), + Subject: subject, + Schedule: policy, + Budget: analysis.BudgetOf(s.budgets, policy, analysis.Outcomes, s.jobs), + Selection: selection, + }, run) + if err != nil { + // A caller that went away is the call failing, not a precondition unmet. + if ctx.Err() != nil { + return explored{}, ctx.Err() + } + return explored{}, statusError(connect.CodeFailedPrecondition, err.Error()) + } + x := plan.Result.Exploration() + if x == nil { + return explored{}, statusErrorf(connect.CodeFailedPrecondition, "exploration of %s reached no outcome: %s", subject, plan.Standing()) + } + outcomes := make([]*pb.Outcome, 0, len(x.Outcomes)) + for _, o := range x.Outcomes { + out := &pb.Outcome{ + FinalState: o.Outcome.FinalState, + StatesVisited: o.Outcome.StateVisits, + Linearizations: int32Clamp(o.Linearizations), + Probability: o.Probability, + } + if rt := o.Outcome.Context(); rt != nil { + out.Diagnostics = s.filterDiagnosticCapabilities(RunNoteDiagnosticsToProto(rt.Notes(), cached)) + if o.Outcome.Err == nil && len(o.Outcome.Outputs) > 0 { + out.Outputs = make(map[string]*pb.Value, len(o.Outcome.Outputs)) + for name, val := range o.Outcome.Outputs { + out.Outputs[name] = s.valueToProto(rt, val, cached.Index) + } + } + } + if o.Outcome.Err != nil { + out.Error = o.Outcome.Err.Error() + } + for _, c := range o.Witness { + out.Witness = append(out.Witness, c.String()) + } + outcomes = append(outcomes, out) + } + status := &pb.ExplorationStatus{ + Complete: x.Complete(), + Runs: int32Clamp(x.Runs), + BudgetsHit: x.BudgetsHit, + RunsBudget: int32Clamp(x.Budget.Runs), + DepthBudget: int32Clamp(x.Budget.Depth), + ProbabilitiesLowerBound: x.ProbabilitiesBounded(), + } + return explored{outcomes: outcomes, status: status, plan: plan}, nil +} diff --git a/internal/frontend/grpc/explore_test.go b/internal/frontend/grpc/explore_test.go new file mode 100644 index 0000000000..566abd5702 --- /dev/null +++ b/internal/frontend/grpc/explore_test.go @@ -0,0 +1,593 @@ +package grpc + +import ( + "context" + "errors" + "strings" + "testing" + + "connectrpc.com/connect" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +const exploreModel = ` +package Race { + private import ScalarValues::*; + + action race { + attribute x : Integer = 0; + first start; + fork split; + action a { assign x := 1; } + action b { assign x := 2; } + action c { assign x := 3; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first split then c; + succession first a then sync; + succession first b then sync; + succession first c then sync; + succession first sync then done; + } + + action straight { + attribute n : Integer = 0; + first start; + action inc { assign n := n + 1; } + done; + succession first start then inc; + succession first inc then done; + } + + action def Race { + out r : Integer = 0; + first start; + fork split; + action a { assign r := 1; } + action b { assign r := 2; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then done; + } + + analysis raced { + out r : Integer; + perform action race : Race; + return : Integer = r; + } +} +` + +func outcomeInts(t *testing.T, outcomes []*pb.Outcome, name string) []int64 { + t.Helper() + got := make([]int64, 0, len(outcomes)) + for _, o := range outcomes { + val, ok := o.Outputs[name] + if !ok { + t.Fatalf("outcome %v carries no %q", o, name) + } + got = append(got, val.GetIntValue()) + } + return got +} + +// Under explore an action answers with every distinct outcome, its linearization +// count and a witness, in canonical order, and an ordinary run's fields stay empty. +func TestExploreActionOverTheWire(t *testing.T) { + ctx := context.Background() + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, exploreModel, "explore-action") + + var first *pb.ExecuteActionResponse + for i := 0; i < 2; i++ { + resp, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ModelHash: hash, ActionSymbolId: "Race::race", Schedule: "explore"}) + if err != nil || resp.Error != "" { + t.Fatalf("ExecuteAction under explore: %v %q", err, resp.GetError()) + } + if first == nil { + first = resp + } else if resp.String() != first.String() { + t.Fatalf("second exploration answered\n%v\nthe first\n%v", resp, first) + } + } + if len(first.Outputs) != 0 || len(first.Diagnostics) != 0 { + t.Errorf("an explored run answered a single run's outputs %v / diagnostics %v", first.Outputs, first.Diagnostics) + } + x := first.Exploration + if x == nil || !x.Complete || x.Runs != 6 || len(x.BudgetsHit) != 0 || x.RunsBudget != 1024 || x.DepthBudget != 64 { + t.Fatalf("exploration status %v, want complete after 6 runs under the default budget", x) + } + if got := outcomeInts(t, first.Outcomes, "x"); len(got) != 3 || got[0] != 1 || got[1] != 2 || got[2] != 3 { + t.Fatalf("outcomes x = %v, want [1 2 3]", got) + } + for _, o := range first.Outcomes { + if o.Linearizations != 2 { + t.Errorf("outcome x=%d reached by %d linearizations, want 2", o.Outputs["x"].GetIntValue(), o.Linearizations) + } + if len(o.Witness) == 0 || !strings.Contains(o.Witness[0], "first of") { + t.Errorf("outcome x=%d witness %v, want token-order choices", o.Outputs["x"].GetIntValue(), o.Witness) + } + if o.Error != "" || o.FinalState != "" || len(o.StatesVisited) != 0 { + t.Errorf("action outcome carries state or error fields: %v", o) + } + if choices := choiceDiagnostics(o.Diagnostics); len(choices) != len(o.Witness) { + t.Errorf("outcome reports %d choice diagnostics for %d witness choices", len(choices), len(o.Witness)) + } + } +} + +// A behavior with no choice point explores in one run; a budget hit is reported +// as incomplete, naming the budget, never as an error. +func TestExploreBudgetsOverTheWire(t *testing.T) { + ctx := context.Background() + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, exploreModel, "explore-budget") + + resp, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ModelHash: hash, ActionSymbolId: "Race::straight", Schedule: "explore"}) + if err != nil || resp.Error != "" { + t.Fatalf("ExecuteAction straight: %v %q", err, resp.GetError()) + } + if x := resp.Exploration; !x.Complete || x.Runs != 1 || len(resp.Outcomes) != 1 || len(resp.Outcomes[0].Witness) != 0 { + t.Errorf("no-choice exploration: %v, outcomes %v; want one complete run with an empty witness", x, resp.Outcomes) + } + + for _, test := range []struct { + schedule, budget string + runs int32 + }{ + {"explore:runs=2", "runs", 2}, + {"explore:depth=0", "depth", 1}, + {"explore:depth=1,runs=1", "runs,depth", 1}, + } { + resp, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ModelHash: hash, ActionSymbolId: "Race::race", Schedule: test.schedule}) + if err != nil || resp.Error != "" { + t.Fatalf("ExecuteAction under %q: %v %q", test.schedule, err, resp.GetError()) + } + x := resp.Exploration + if x.Complete || x.Runs != test.runs || strings.Join(x.BudgetsHit, ",") != test.budget { + t.Errorf("under %q status %v, want incomplete on %s after %d runs", test.schedule, x, test.budget, test.runs) + } + if len(resp.Outcomes) == 0 { + t.Errorf("under %q no outcome of the runs made was reported", test.schedule) + } + } +} + +// A state machine's outcomes carry the state it rests in and the states entered. +func TestExploreStateOverTheWire(t *testing.T) { + ctx := context.Background() + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, scheduleModel, "explore-state") + + resp, err := srv.ExecuteState(ctx, &pb.ExecuteStateRequest{ + ModelHash: hash, StateMachineSymbolId: "Sched::Dispatcher", Events: []string{"Go"}, Schedule: "explore", + }) + if err != nil || resp.Error != "" { + t.Fatalf("ExecuteState under explore: %v %q", err, resp.GetError()) + } + if x := resp.Exploration; !x.Complete || x.Runs != 2 { + t.Fatalf("exploration status %v, want complete after 2 runs", x) + } + if len(resp.FinalContext) != 0 || len(resp.StatesVisited) != 0 || len(resp.Diagnostics) != 0 { + t.Errorf("an explored run answered a single run's fields: %v", resp) + } + want := []struct{ final, visited, witness string }{ + {"high", "idle,high", "state idle on accept Go -> 2->high"}, + {"low", "idle,low", "state idle on accept Go -> 1->low"}, + } + if len(resp.Outcomes) != len(want) { + t.Fatalf("outcomes %v, want %d", resp.Outcomes, len(want)) + } + for i, o := range resp.Outcomes { + if o.FinalState != want[i].final || strings.Join(o.StatesVisited, ",") != want[i].visited || o.Linearizations != 1 { + t.Errorf("outcome %d = %v, want final %s visiting %s once", i, o, want[i].final, want[i].visited) + } + if strings.Join(o.Witness, "; ") != want[i].witness { + t.Errorf("outcome %d witness %q, want %q", i, strings.Join(o.Witness, "; "), want[i].witness) + } + if o.Outputs["level"].GetIntValue() != 8 { + t.Errorf("outcome %d outputs %v, want level 8", i, o.Outputs) + } + } +} + +// pairModel is two parts of one assembly talking through a connector: the +// ground hails the craft on creation, which only a craft made inside the pair hears. +const pairModel = ` +package Wire { + private import ScalarValues::*; + item def Ping; + port def Link { in item ping : Ping; } + part def Ground { + port p : ~Link; + exhibit state hail { entry; then go; state go { entry send new Ping() via p; } } + } + part def Craft { + port p : Link; + attribute pinged : Boolean = false; + exhibit state modes { + entry; then waiting; + state waiting; + transition first waiting accept Ping via p then active; + state active { entry assign pinged := true; } + } + action look { out seen : Boolean; first start; then action read assign seen := pinged; then done; } + } + part def Pair { + part ground : Ground; + part craft : Craft; + part spares : Craft[2]; + connect craft.p to ground.p; + } + part pair : Pair; +} +` + +// A behavior performed on an object named by a path from a declaration runs on the +// object the path reaches inside an object of the declaration, so its assembly's +// connectors reach it; a machine the object exhibits is the one run. Under explore +// each run makes the object anew. A path naming no object fails the call, or the run. +func TestPerformOnANestedObjectOverTheWire(t *testing.T) { + ctx := context.Background() + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, pairModel, "nested-performer") + + for _, tc := range []struct{ performer, final string }{ + {"Wire::pair.craft", "active"}, + {"Wire::pair::craft", "active"}, + {"Wire::pair.spares[1]", "waiting"}, + {"Wire::Pair::craft", "waiting"}, + {"Wire::Craft", "waiting"}, + {"", "waiting"}, + } { + resp, err := srv.ExecuteState(ctx, &pb.ExecuteStateRequest{ + ModelHash: hash, StateMachineSymbolId: "Wire::Craft::modes", PerformerSymbolId: tc.performer, + }) + if err != nil || resp.Error != "" { + t.Fatalf("ExecuteState on %q: %v %q", tc.performer, err, resp.GetError()) + } + if got := resp.StatesVisited; got[len(got)-1] != tc.final { + t.Errorf("on %q visited %v, want to end in %s", tc.performer, got, tc.final) + } + x, err := srv.ExecuteState(ctx, &pb.ExecuteStateRequest{ + ModelHash: hash, StateMachineSymbolId: "Wire::Craft::modes", PerformerSymbolId: tc.performer, Schedule: "explore", + }) + if err != nil || x.Error != "" { + t.Fatalf("explore on %q: %v %q", tc.performer, err, x.GetError()) + } + if len(x.Outcomes) != 1 || x.Outcomes[0].FinalState != tc.final || !x.Exploration.Complete { + t.Errorf("explored on %q: %v, want one outcome ending in %s", tc.performer, x.Outcomes, tc.final) + } + if tc.performer == "" { + continue + } + if pinged, held := x.Outcomes[0].Outputs["this.pinged"]; !held || pinged.GetBoolValue() != (tc.final == "active") { + t.Errorf("explored on %q outputs %v, want this.pinged = %v", tc.performer, x.Outcomes[0].Outputs, tc.final == "active") + } + } + + act, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ + ModelHash: hash, ActionSymbolId: "Wire::Craft::look", PerformerSymbolId: "Wire::pair.craft", + }) + if err != nil || act.Error != "" || !act.Outputs["seen"].GetBoolValue() { + t.Errorf("look on pair.craft: %v %q %v, want seen = true", err, act.GetError(), act.GetOutputs()) + } + x, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ + ModelHash: hash, ActionSymbolId: "Wire::Craft::look", PerformerSymbolId: "Wire::pair.craft", Schedule: "explore", + }) + if err != nil || x.Error != "" || len(x.Outcomes) != 1 || !x.Outcomes[0].Outputs["seen"].GetBoolValue() { + t.Errorf("explored look on pair.craft: %v %q %v, want one outcome with seen = true", err, x.GetError(), x.GetOutcomes()) + } else if !x.Outcomes[0].Outputs["this.pinged"].GetBoolValue() { + t.Errorf("explored look on pair.craft outputs %v, want this.pinged = true", x.Outcomes[0].Outputs) + } + + for path, want := range map[string]string{ + "Wire::pair.tug": `Wire::pair has no feature "tug"`, + "Wire::pair.spares": "spares of Wire::pair holds 2 objects: pick one by index", + "Wire::pair.spares[3]": "spares[3] names none", + "#1": "performer #1 names an object by id", + "Wire::nobody.craft": "symbol not found: Wire::nobody", + } { + resp, err := srv.ExecuteState(ctx, &pb.ExecuteStateRequest{ + ModelHash: hash, StateMachineSymbolId: "Wire::Craft::modes", PerformerSymbolId: path, + }) + if err != nil || !strings.Contains(resp.Error, want) { + t.Errorf("on %s: %v %q, want %q", path, err, resp.GetError(), want) + } + x, err := srv.ExecuteState(ctx, &pb.ExecuteStateRequest{ + ModelHash: hash, StateMachineSymbolId: "Wire::Craft::modes", PerformerSymbolId: path, Schedule: "explore", + }) + if err != nil || len(x.Outcomes) != 1 || !strings.Contains(x.Outcomes[0].Error, want) { + t.Errorf("explored on %s: %v %v, want the one failed outcome %q", path, err, x.GetOutcomes(), want) + } + } +} + +// performerRaceModel is an object whose behaviors race two assignments to its own +// attribute and produce nothing else: only the object tells their outcomes apart. +const performerRaceModel = ` +package Tank { + private import ScalarValues::*; + part def Tank { + attribute level : Integer = 0; + action fill { + first start; + fork split; + action a { assign this.level := 1; } + action b { assign this.level := 2; } + join sync; + done; + succession first start then split; + succession first split then a; + succession first split then b; + succession first a then sync; + succession first b then sync; + succession first sync then done; + } + state modes { + entry; then filling; + state filling { entry perform fill; } + } + } + part tank : Tank; +} +` + +// An explored behavior's outcomes carry the performer's attributes under `this.`, +// so two runs leaving the object differently are two outcomes, as the CLI tables them. +func TestExploredOutcomesCarryThePerformersAttributesOverTheWire(t *testing.T) { + ctx := context.Background() + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, performerRaceModel, "performer-race") + + levels := func(outcomes []*pb.Outcome) []int64 { + var got []int64 + for _, o := range outcomes { + if o.Error != "" { + t.Fatalf("outcome failed: %s", o.Error) + } + got = append(got, o.Outputs["this.level"].GetIntValue()) + } + return got + } + act, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ + ModelHash: hash, ActionSymbolId: "Tank::Tank::fill", PerformerSymbolId: "Tank::tank", Schedule: "explore", + }) + if err != nil || act.Error != "" { + t.Fatalf("explore fill on tank: %v %q", err, act.GetError()) + } + if got := levels(act.Outcomes); len(got) != 2 || got[0] != 1 || got[1] != 2 || !act.Exploration.Complete { + t.Errorf("explored fill on tank: this.level over %v, want [1 2] complete: %v", got, act.Outcomes) + } + state, err := srv.ExecuteState(ctx, &pb.ExecuteStateRequest{ + ModelHash: hash, StateMachineSymbolId: "Tank::Tank::modes", PerformerSymbolId: "Tank::tank", Schedule: "explore", + }) + if err != nil || state.Error != "" { + t.Fatalf("explore modes on tank: %v %q", err, state.GetError()) + } + if got := levels(state.Outcomes); len(got) != 2 || got[0] != 1 || got[1] != 2 || !state.Exploration.Complete { + t.Errorf("explored modes on tank: this.level over %v, want [1 2] complete: %v", got, state.Outcomes) + } + for _, o := range state.Outcomes { + if o.FinalState != "filling" { + t.Errorf("explored modes on tank ends in %q, want filling", o.FinalState) + } + } +} + +const performedActionModel = ` +package Pump { + private import ScalarValues::*; + action def Fill { out poured : Integer; } + part def Pump { + attribute level : Integer = 0; + perform action fill : Fill { + first start; + then action pour { assign level := level + 1; assign poured := level; } + then done; + } + } + part pump : Pump; + part def Twin { perform action morning : Fill; perform action evening : Fill; } + part twin : Twin; +} +` + +// An action the performer already performs runs as that one performance, executed +// or explored, so it writes the object once; inputs for it are refused, as are +// objects performing it twice over. +func TestActionOnItsPerformerRunsOnceOverTheWire(t *testing.T) { + ctx := context.Background() + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, performedActionModel, "performed-action") + + for _, action := range []string{"Pump::Pump::fill", "Pump::Fill"} { + act, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ + ModelHash: hash, ActionSymbolId: action, PerformerSymbolId: "Pump::pump", + }) + if err != nil || act.Error != "" { + t.Fatalf("%s on pump: %v %q", action, err, act.GetError()) + } + if got := act.Outputs["poured"].GetIntValue(); got != 1 { + t.Errorf("%s on pump poured %d, want 1: one performance", action, got) + } + x, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ + ModelHash: hash, ActionSymbolId: action, PerformerSymbolId: "Pump::pump", Schedule: "explore", + }) + if err != nil || x.Error != "" || len(x.Outcomes) != 1 || x.Outcomes[0].Error != "" { + t.Fatalf("explored %s on pump: %v %q %v", action, err, x.GetError(), x.GetOutcomes()) + } + if got := x.Outcomes[0].Outputs["this.level"].GetIntValue(); got != 1 { + t.Errorf("explored %s on pump: this.level = %d, want 1: one performance", action, got) + } + if got := x.Outcomes[0].Outputs["poured"].GetIntValue(); got != 1 { + t.Errorf("explored %s on pump: poured = %d, want 1", action, got) + } + } + for _, tc := range []struct { + action, performer, want string + inputs map[string]*pb.Value + }{ + {"Pump::Pump::fill", "Pump::pump", "inputs for a performed action", map[string]*pb.Value{"level": {Kind: &pb.Value_IntValue{IntValue: 3}}}}, + {"Pump::Fill", "Pump::twin", "ambiguous action: the object performs Fill as morning and evening", nil}, + } { + act, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ + ModelHash: hash, ActionSymbolId: tc.action, PerformerSymbolId: tc.performer, Inputs: tc.inputs, + }) + if err != nil || !strings.Contains(act.Error, tc.want) { + t.Errorf("%s on %s: %v %q, want %q", tc.action, tc.performer, err, act.GetError(), tc.want) + } + x, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ + ModelHash: hash, ActionSymbolId: tc.action, PerformerSymbolId: tc.performer, Inputs: tc.inputs, Schedule: "explore", + }) + if err != nil || len(x.Outcomes) != 1 || !strings.Contains(x.Outcomes[0].Error, tc.want) { + t.Errorf("explored %s on %s: %v %v, want the one failed outcome %q", tc.action, tc.performer, err, x.GetOutcomes(), tc.want) + } + } +} + +// An analysis case's outcomes carry its outputs and verdicts. +func TestExploreAnalysisOverTheWire(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, exploreModel, "explore-analysis") + + resp := runAnalysis(t, srv, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "Race::raced", Schedule: "explore"}) + if resp.Error != "" { + t.Fatalf("RunAnalysis under explore reported %q", resp.Error) + } + if x := resp.Exploration; !x.Complete || x.Runs != 2 { + t.Fatalf("exploration status %v, want complete after 2 runs", x) + } + if len(resp.Outputs) != 0 || len(resp.Verdicts) != 0 || resp.Instances != nil { + t.Errorf("an explored run answered a single run's fields: %v", resp) + } + if got := outcomeInts(t, resp.Outcomes, "r"); len(got) != 2 || got[0] != 1 || got[1] != 2 { + t.Fatalf("outcomes r = %v, want [1 2]", got) + } +} + +// A run that fails during exploration is an outcome of its own, not a failure +// of the exploration. +func TestExploreReportsAFailingRunAsAnOutcome(t *testing.T) { + ctx := context.Background() + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, ` +package Fail { + private import ScalarValues::*; + action divide { + attribute d : Integer = 1; + attribute q : Rational = 0; + first start; + fork split; + action zero { assign d := 0; } + action one { assign d := 1; } + join sync; + action use { assign q := 10 / d; } + done; + succession first start then split; + succession first split then zero; + succession first split then one; + succession first zero then sync; + succession first one then sync; + succession first sync then use; + succession first use then done; + } +} +`, "explore-error") + + resp, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ModelHash: hash, ActionSymbolId: "Fail::divide", Schedule: "explore"}) + if err != nil || resp.Error != "" { + t.Fatalf("ExecuteAction under explore: %v %q", err, resp.GetError()) + } + if !resp.Exploration.Complete || resp.Exploration.Runs != 2 || len(resp.Outcomes) != 2 { + t.Fatalf("exploration %v with outcomes %v, want 2 outcomes over 2 runs", resp.Exploration, resp.Outcomes) + } + var failed, succeeded int + for _, o := range resp.Outcomes { + if o.Error != "" { + failed++ + if !strings.Contains(o.Error, "action execution failed") || len(o.Outputs) != 0 { + t.Errorf("error outcome %v, want the run's failure and no outputs", o) + } + } else { + succeeded++ + if o.Outputs["q"].GetRealValue() != 10 { + t.Errorf("outcome %v, want q = 10", o) + } + } + } + if failed != 1 || succeeded != 1 { + t.Errorf("%d failed and %d succeeded outcomes, want one each: %v", failed, succeeded, resp.Outcomes) + } +} + +// Exploring needs the schedule_explore capability on every RPC carrying the +// field; the other policies need only schedule. +func TestExploreIsUnimplementedWithoutItsCapability(t *testing.T) { + ctx := context.Background() + srv := mustNewServiceWithout(t, CapabilityScheduleExplore) + hash := mustVerifyModel(t, srv, scheduleModel, "explore-capability") + + calls := map[string]func(schedule string) error{ + "ExecuteAction": func(schedule string) error { + _, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ModelHash: hash, ActionSymbolId: "Sched::tally", Schedule: schedule}) + return err + }, + "ExecuteState": func(schedule string) error { + _, err := srv.ExecuteState(ctx, &pb.ExecuteStateRequest{ModelHash: hash, StateMachineSymbolId: "Sched::Dispatcher", Schedule: schedule}) + return err + }, + "RunAnalysis": func(schedule string) error { + _, err := srv.RunAnalysis(ctx, &pb.RunAnalysisRequest{ModelHash: hash, SymbolId: "Sched::forked", Schedule: schedule}) + return err + }, + } + for name, call := range calls { + for _, schedule := range []string{"explore", "explore:runs=3"} { + err := call(schedule) + if connect.CodeOf(err) != connect.CodeUnimplemented || !strings.Contains(err.Error(), CapabilityScheduleExplore) { + t.Errorf("%s under %q without %s: %v, want UNIMPLEMENTED naming it", name, schedule, CapabilityScheduleExplore, err) + } + } + if err := call("seed:2"); err != nil { + t.Errorf("%s under seed:2 without %s: %v", name, CapabilityScheduleExplore, err) + } + } + info, err := srv.GetServerInfo(ctx, &pb.ServerInfoRequest{}) + if err != nil { + t.Fatal(err) + } + for _, c := range info.Capabilities { + if c == CapabilityScheduleExplore { + t.Errorf("capabilities %v advertise %s, which the service withholds", info.Capabilities, c) + } + } +} + +// A caller that has gone away fails the call with its own error, whether the +// behavior was to run once or be explored; neither is a failed run or an unmet +// precondition. +func TestExecuteActionCanceledCallerFailsTheCall(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, exploreModel, "explore-canceled") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + for _, schedule := range []string{"", "explore"} { + resp, err := srv.ExecuteAction(ctx, &pb.ExecuteActionRequest{ModelHash: hash, ActionSymbolId: "Race::race", Schedule: schedule}) + if !errors.Is(err, context.Canceled) { + t.Errorf("schedule %q: err = %v; want context.Canceled", schedule, err) + } + if resp != nil { + t.Errorf("schedule %q: a canceled call answered %v; want no response", schedule, resp) + } + } +} diff --git a/internal/grpc/export.go b/internal/frontend/grpc/export.go similarity index 80% rename from internal/grpc/export.go rename to internal/frontend/grpc/export.go index 9770afcca1..65708301ee 100644 --- a/internal/grpc/export.go +++ b/internal/frontend/grpc/export.go @@ -7,7 +7,7 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/export" + "github.com/Open-MBEE/OpenSysML/internal/translate/convert" ) // Convert writes a model in another representation, so a client can save a model @@ -26,27 +26,27 @@ func (s *Service) Convert(ctx context.Context, req *pb.ConvertRequest) (*pb.Conv return nil, err } if req.ToFormat == "" { - return nil, statusError(connect.CodeInvalidArgument, "to_format is required: expected sysml, kerml, ttl, turtle or rdf") + return nil, statusError(connect.CodeInvalidArgument, "to_format is required: expected "+convert.FormatList) } - to, err := export.ParseFormat(req.ToFormat) + to, err := convert.ParseFormat(req.ToFormat) if err != nil { return nil, statusError(connect.CodeInvalidArgument, err.Error()) } if !to.Writable() { - return nil, statusError(connect.CodeInvalidArgument, (&export.NotWritableError{Format: to}).Error()) + return nil, statusError(connect.CodeInvalidArgument, (&convert.NotWritableError{Format: to}).Error()) } resp := &pb.ConvertResponse{FromFormat: from.String(), ToFormat: to.String()} // Marked on the response rather than left to the client to infer, so a caller // that let a format be inferred learns the mapping it got is experimental. - if export.IsExperimental(from, to) { + if convert.IsExperimental(from, to) { resp.Experimental = true - resp.ExperimentalNotice = export.Notice(from, to) + resp.ExperimentalNotice = convert.Notice(from, to) } out, syntax, err := convertModel(name, data, from, to, req.TolerateSyntaxErrors) if err != nil { resp.Error = err.Error() - var broken *export.SyntaxError + var broken *convert.SyntaxError if errors.As(err, &broken) { resp.Diagnostics = s.filterDiagnosticCapabilities(syntaxDiagnostics(broken)) } @@ -59,11 +59,11 @@ func (s *Service) Convert(ctx context.Context, req *pb.ConvertRequest) (*pb.Conv // convertModel runs the conversion, tolerating unreadable notation only when the // request asked for it. -func convertModel(name string, data []byte, from, to export.Format, tolerant bool) ([]byte, *export.SyntaxError, error) { +func convertModel(name string, data []byte, from, to convert.Format, tolerant bool) ([]byte, *convert.SyntaxError, error) { if tolerant { - return export.ConvertTolerant(name, data, from, to) + return convert.ConvertTolerant(name, data, from, to) } - out, err := export.Convert(name, data, from, to) + out, err := convert.Convert(name, data, from, to) return out, nil, err } @@ -103,9 +103,9 @@ func (s *Service) convertSource(req *pb.ConvertRequest) (string, []byte, error) // convertFrom resolves the source format, inferring it from the file name when // the request left it unset. -func convertFrom(req *pb.ConvertRequest, name string) (export.Format, error) { +func convertFrom(req *pb.ConvertRequest, name string) (convert.Format, error) { if req.FromFormat != "" { - from, err := export.ParseFormat(req.FromFormat) + from, err := convert.ParseFormat(req.FromFormat) if err != nil { return 0, statusError(connect.CodeInvalidArgument, err.Error()) } @@ -114,21 +114,21 @@ func convertFrom(req *pb.ConvertRequest, name string) (export.Format, error) { if req.GetModelHash() != "" { // Parse reads notation, so a cached model is notation whatever it was // named — including one parsed from inline content, which has no name. - return export.FormatSysML, nil + return convert.FormatSysML, nil } if req.GetFilePath() == "" { - return 0, statusError(connect.CodeInvalidArgument, "from_format is required for inline content: expected "+export.FormatList) + return 0, statusError(connect.CodeInvalidArgument, "from_format is required for inline content: expected "+convert.FormatList) } - from, err := export.FormatOfPath(name) + from, err := convert.FormatOfPath(name) if err != nil { - return 0, statusError(connect.CodeInvalidArgument, export.Advise(err, "pass from_format, or "+export.ExtensionAdvice).Error()) + return 0, statusError(connect.CodeInvalidArgument, convert.Advise(err, "pass from_format, or "+convert.ExtensionAdvice).Error()) } return from, nil } // syntaxDiagnostics reports a SyntaxError as diagnostics, with spans when the // input was notation and a bare message when it was not. -func syntaxDiagnostics(syntax *export.SyntaxError) []*pb.Diagnostic { +func syntaxDiagnostics(syntax *convert.SyntaxError) []*pb.Diagnostic { if syntax == nil { return nil } diff --git a/internal/frontend/grpc/export_test.go b/internal/frontend/grpc/export_test.go new file mode 100644 index 0000000000..650956a967 --- /dev/null +++ b/internal/frontend/grpc/export_test.go @@ -0,0 +1,368 @@ +package grpc + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/translate/convert" +) + +// The REPL imports this package for its feature-value serialization, so a test +// comparing the two runs as an external package and borrows these. +var ( + MustNewServiceForTest = mustNewService + QueryModelForTest = queryModel +) + +const convertModelSource = `package Demo { + // a comment, which notation keeps and RDF does not +part def Engine { attribute power : Real = 300.0; } +} +` + +// mustConvert converts and fails the test on a transport error or a reported +// conversion error. +func mustConvert(t *testing.T, srv *Service, req *pb.ConvertRequest) *pb.ConvertResponse { + t.Helper() + resp, err := srv.Convert(context.Background(), req) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if resp.Error != "" { + t.Fatalf("Convert reported %q, diagnostics %v", resp.Error, resp.Diagnostics) + } + return resp +} + +// TestConvertCapabilityReported verifies a client can require conversion before +// asking for it. +func TestConvertCapabilityReported(t *testing.T) { + srv := mustNewService(t, 10) + info, err := srv.GetServerInfo(context.Background(), &pb.ServerInfoRequest{}) + if err != nil { + t.Fatalf("GetServerInfo: %v", err) + } + if !slices.Contains(info.Capabilities, CapabilityConvert) { + t.Errorf("capabilities = %v, want it to contain %q", info.Capabilities, CapabilityConvert) + } +} + +// TestConvertNotationRoundTrip verifies notation written back out parses to the +// same model and keeps its comments. +func TestConvertNotationRoundTrip(t *testing.T) { + srv := mustNewService(t, 10) + + resp := mustConvert(t, srv, &pb.ConvertRequest{ + Source: &pb.ConvertRequest_Content{Content: convertModelSource}, + FromFormat: "sysml", + ToFormat: "sysml", + }) + if !strings.Contains(resp.Content, "part def Engine") { + t.Errorf("output lost the model:\n%s", resp.Content) + } + if !strings.Contains(resp.Content, "// a comment") { + t.Errorf("notation output dropped a lexical comment:\n%s", resp.Content) + } + if len(resp.Diagnostics) != 0 { + t.Errorf("diagnostics = %v, want none for a clean model", resp.Diagnostics) + } + + // Converting the output again is stable: the formatter has a fixpoint. + again := mustConvert(t, srv, &pb.ConvertRequest{ + Source: &pb.ConvertRequest_Content{Content: resp.Content}, + FromFormat: "sysml", + ToFormat: "sysml", + }) + if again.Content != resp.Content { + t.Errorf("second conversion differs:\n%s\nvs\n%s", again.Content, resp.Content) + } +} + +// TestConvertToTurtleAndBack verifies a model survives a trip through RDF. +func TestConvertToTurtleAndBack(t *testing.T) { + srv := mustNewService(t, 10) + + turtle := mustConvert(t, srv, &pb.ConvertRequest{ + Source: &pb.ConvertRequest_Content{Content: convertModelSource}, + FromFormat: "sysml", + ToFormat: "turtle", + }) + if turtle.ToFormat != "ttl" { + t.Errorf("to_format = %q, want the canonical %q", turtle.ToFormat, "ttl") + } + if !strings.Contains(turtle.Content, "Demo::Engine") { + t.Errorf("graph does not name the model's element:\n%s", turtle.Content) + } + + back := mustConvert(t, srv, &pb.ConvertRequest{ + Source: &pb.ConvertRequest_Content{Content: turtle.Content}, + FromFormat: "ttl", + ToFormat: "sysml", + }) + if !strings.Contains(back.Content, "part def Engine") { + t.Errorf("notation from the graph lost the model:\n%s", back.Content) + } +} + +// TestConvertMarksRDFExperimental verifies the response carries the RDF +// mapping's status in both directions, carries it on a refusal too, and leaves +// it off a notation conversion. +func TestConvertMarksRDFExperimental(t *testing.T) { + srv := mustNewService(t, 10) + + turtle := mustConvert(t, srv, &pb.ConvertRequest{ + Source: &pb.ConvertRequest_Content{Content: convertModelSource}, + FromFormat: "sysml", + ToFormat: "ttl", + }) + if !turtle.Experimental { + t.Error("experimental = false, want true for a conversion to RDF") + } + if !strings.Contains(turtle.ExperimentalNotice, "experimental") { + t.Errorf("experimental_notice = %q, want it to state the status", turtle.ExperimentalNotice) + } + + back := mustConvert(t, srv, &pb.ConvertRequest{ + Source: &pb.ConvertRequest_Content{Content: turtle.Content}, + FromFormat: "ttl", + ToFormat: "sysml", + }) + if !back.Experimental { + t.Error("reading RDF is experimental too, but was not marked") + } + + notation := mustConvert(t, srv, &pb.ConvertRequest{ + Source: &pb.ConvertRequest_Content{Content: convertModelSource}, + FromFormat: "sysml", + ToFormat: "sysml", + }) + if notation.Experimental || notation.ExperimentalNotice != "" { + t.Errorf("a notation conversion is stable, but was marked: %t %q", + notation.Experimental, notation.ExperimentalNotice) + } + + refused, err := srv.Convert(context.Background(), &pb.ConvertRequest{ + Source: &pb.ConvertRequest_Content{Content: "package P { part def Seat; part seat : Seat; part seat : Seat; }"}, + FromFormat: "sysml", + ToFormat: "ttl", + }) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if refused.Error == "" { + t.Fatalf("expected the mapping to refuse the duplicate declaration:\n%s", refused.Content) + } + if !refused.Experimental { + t.Error("a refusal is the experimental behavior, but was not marked") + } +} + +// TestConvertFilePathInfersFormat verifies a path source is read by the service +// and its extension names the input format. +func TestConvertFilePathInfersFormat(t *testing.T) { + srv := mustNewService(t, 10) + path := filepath.Join(t.TempDir(), "model.sysml") + if err := os.WriteFile(path, []byte(convertModelSource), 0o600); err != nil { + t.Fatal(err) + } + + resp := mustConvert(t, srv, &pb.ConvertRequest{ + Source: &pb.ConvertRequest_FilePath{FilePath: path}, + ToFormat: "ttl", + }) + if resp.FromFormat != "sysml" { + t.Errorf("from_format = %q, want it inferred as %q", resp.FromFormat, "sysml") + } +} + +// TestConvertModelHashConvertsWhatWasParsed verifies a hash converts the source +// the service parsed, so a file edited since the parse does not change it. +func TestConvertModelHashConvertsWhatWasParsed(t *testing.T) { + srv := mustNewService(t, 10) + path := filepath.Join(t.TempDir(), "model.sysml") + if err := os.WriteFile(path, []byte(convertModelSource), 0o600); err != nil { + t.Fatal(err) + } + + parsed, err := srv.ParseFile(context.Background(), + &pb.ParseFileRequest{Source: &pb.ParseFileRequest_FilePath{FilePath: path}}) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + if err := os.WriteFile(path, []byte("package Replaced { part def Other; }\n"), 0o600); err != nil { + t.Fatal(err) + } + + resp := mustConvert(t, srv, &pb.ConvertRequest{ + Source: &pb.ConvertRequest_ModelHash{ModelHash: parsed.ModelHash}, + ToFormat: "sysml", + }) + if resp.FromFormat != "sysml" { + t.Errorf("from_format = %q, want notation without being told", resp.FromFormat) + } + if !strings.Contains(resp.Content, "part def Engine") || strings.Contains(resp.Content, "Replaced") { + t.Errorf("converted the file as it stands, not the model parsed:\n%s", resp.Content) + } + + // The path source, by contrast, is read afresh. + current := mustConvert(t, srv, &pb.ConvertRequest{ + Source: &pb.ConvertRequest_FilePath{FilePath: path}, + ToFormat: "sysml", + }) + if !strings.Contains(current.Content, "Replaced") { + t.Errorf("file_path did not read the file as it stands:\n%s", current.Content) + } +} + +// TestConvertUncachedModelHashIsNotFound verifies an evicted or unknown model is +// named as such rather than converted as empty notation. +func TestConvertUncachedModelHashIsNotFound(t *testing.T) { + srv := mustNewService(t, 10) + + _, err := srv.Convert(context.Background(), &pb.ConvertRequest{ + Source: &pb.ConvertRequest_ModelHash{ModelHash: "nosuchmodel"}, + ToFormat: "sysml", + }) + if connect.CodeOf(err) != connect.CodeNotFound { + t.Errorf("err = %v, want NotFound", err) + } +} + +// TestConvertInlineContentNeedsFromFormat verifies inline content, which has no +// extension to infer from, is rejected rather than guessed at. +func TestConvertInlineContentNeedsFromFormat(t *testing.T) { + srv := mustNewService(t, 10) + + _, err := srv.Convert(context.Background(), &pb.ConvertRequest{ + Source: &pb.ConvertRequest_Content{Content: convertModelSource}, + ToFormat: "ttl", + }) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Errorf("err = %v, want InvalidArgument", err) + } +} + +// TestConvertRejectsBadArguments verifies argument faults fail the call instead +// of being reported as a conversion that did not work. +func TestConvertRejectsBadArguments(t *testing.T) { + srv := mustNewService(t, 10) + content := &pb.ConvertRequest_Content{Content: convertModelSource} + + cases := map[string]*pb.ConvertRequest{ + "no source": {ToFormat: "sysml", FromFormat: "sysml"}, + "no to_format": {Source: content, FromFormat: "sysml"}, + "unknown to": {Source: content, FromFormat: "sysml", ToFormat: "docx"}, + "unknown from": {Source: content, FromFormat: "docx", ToFormat: "sysml"}, + "xmi as target": {Source: content, FromFormat: "sysml", ToFormat: "xmi"}, + "missing file": {Source: &pb.ConvertRequest_FilePath{FilePath: "/nonexistent/model.sysml"}, ToFormat: "ttl"}, + "unknown ext": {Source: &pb.ConvertRequest_FilePath{FilePath: "model.json"}, ToFormat: "ttl"}, + "no format at all": {Source: content}, + } + for name, req := range cases { + t.Run(name, func(t *testing.T) { + if _, err := srv.Convert(context.Background(), req); err == nil { + t.Fatal("Convert accepted a request it cannot serve") + } else if code := connect.CodeOf(err); code != connect.CodeInvalidArgument && code != connect.CodeNotFound { + t.Errorf("code = %v, want InvalidArgument or NotFound", code) + } + }) + } +} + +// TestConvertReportsSyntaxErrors verifies a model the parser cannot read fails +// the conversion with its diagnostics, spans and all. +func TestConvertReportsSyntaxErrors(t *testing.T) { + srv := mustNewService(t, 10) + + resp, err := srv.Convert(context.Background(), &pb.ConvertRequest{ + Source: &pb.ConvertRequest_Content{Content: "package P { part def "}, + FromFormat: "sysml", + ToFormat: "ttl", + }) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if resp.Error == "" { + t.Fatal("unreadable notation converted to a graph without complaint") + } + if len(resp.Diagnostics) == 0 { + t.Fatal("no diagnostics for a syntax error") + } + for _, diag := range resp.Diagnostics { + if diag.Message == "" || diag.Span == nil { + t.Errorf("diagnostic %v carries no message or span", diag) + } + } +} + +// TestConvertTolerantWritesNotationAnyway verifies tolerated syntax errors are +// reported as diagnostics alongside the output, and only for notation output. +func TestConvertTolerantWritesNotationAnyway(t *testing.T) { + srv := mustNewService(t, 10) + broken := &pb.ConvertRequest_Content{Content: "package P { part def }"} + + resp := mustConvert(t, srv, &pb.ConvertRequest{ + Source: broken, + FromFormat: "sysml", + ToFormat: "sysml", + TolerateSyntaxErrors: true, + }) + if resp.Content == "" { + t.Error("tolerant notation conversion wrote nothing") + } + if len(resp.Diagnostics) == 0 { + t.Error("tolerant conversion hid the syntax errors it tolerated") + } + + // Tolerance does not extend to a graph, where a declaration the parser + // could not read would simply be absent. + graph, err := srv.Convert(context.Background(), &pb.ConvertRequest{ + Source: broken, + FromFormat: "sysml", + ToFormat: "ttl", + TolerateSyntaxErrors: true, + }) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if graph.Error == "" { + t.Error("tolerated syntax errors into RDF, which would drop declarations") + } +} + +// TestConvertMigratesXMI checks the service reads SysML v1 XMI: inline content +// with from_format xmi, and a .xmi file whose format is inferred. +func TestConvertMigratesXMI(t *testing.T) { + srv := mustNewService(t, 10) + path := filepath.Join("..", "..", "..", "tests", "migrate", "testdata", "xmi", "vehicle.xmi") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for name, req := range map[string]*pb.ConvertRequest{ + "content": {Source: &pb.ConvertRequest_Content{Content: string(data)}, FromFormat: "xmi", ToFormat: "sysml"}, + "file": {Source: &pb.ConvertRequest_FilePath{FilePath: path}, ToFormat: "sysml"}, + } { + t.Run(name, func(t *testing.T) { + resp, err := srv.Convert(context.Background(), req) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if resp.Error != "" { + t.Fatalf("conversion refused: %s", resp.Error) + } + if resp.FromFormat != "xmi" || !resp.Experimental || resp.ExperimentalNotice != convert.MigrationNotice { + t.Errorf("from_format %q, experimental %v, notice %q; want xmi, true, the migration notice", resp.FromFormat, resp.Experimental, resp.ExperimentalNotice) + } + if !strings.Contains(resp.Content, "part def Vehicle") { + t.Errorf("no migrated notation:\n%s", resp.Content) + } + }) + } +} diff --git a/internal/grpc/feature_values_test.go b/internal/frontend/grpc/feature_values_test.go similarity index 100% rename from internal/grpc/feature_values_test.go rename to internal/frontend/grpc/feature_values_test.go diff --git a/internal/frontend/grpc/held_events_test.go b/internal/frontend/grpc/held_events_test.go new file mode 100644 index 0000000000..4d0a57df15 --- /dev/null +++ b/internal/frontend/grpc/held_events_test.go @@ -0,0 +1,80 @@ +package grpc + +import ( + "context" + "fmt" + "strings" + "testing" + + "connectrpc.com/connect" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +// A cached population keeps the most recent HeldEventsEnvVar records; an Events +// query reaching past them is FAILED_PRECONDITION, not a shortened relation. +func TestHeldEventsAreBounded(t *testing.T) { + t.Setenv(HeldEventsEnvVar, "1") + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, "../../../tests/grpc/testdata/conformance/document_query_events.sysml") + holdObject(t, srv, hash, "Lamps::lamp") + holdObject(t, srv, hash, "Lamps::lamp") + + cached, _ := srv.cache.Get(hash) + if dropped, _ := srv.objects(cached).rt.Trace().Dropped(); dropped != 1 { + t.Fatalf("dropped %d records under a bound of 1, want the first lamp's entry", dropped) + } + _, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Lamps::Steps", + Bindings: []*pb.DocumentQueryBinding{binding("root", objectByPath("Lamps::lamp"))}, + }) + if err == nil { + t.Fatal("Steps over a truncated trace answered rows, want FAILED_PRECONDITION") + } + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("code = %v, want %v: %v", connect.CodeOf(err), connect.CodeFailedPrecondition, err) + } + if !strings.Contains(err.Error(), "no longer keeps") { + t.Errorf("error %q does not say the trace was truncated", err.Error()) + } +} + +// TestMaxHeldEventsFromEnv: the bound is the positive integer the variable +// holds, the default when unset; anything else is refused at construction. +func TestMaxHeldEventsFromEnv(t *testing.T) { + cases := []struct { + raw string + want int + wantErr bool + }{ + {raw: "", want: DefaultMaxHeldEvents}, + {raw: " 12 ", want: 12}, + {raw: "0", wantErr: true}, + {raw: "-3", wantErr: true}, + {raw: "lots", wantErr: true}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("%q", tc.raw), func(t *testing.T) { + t.Setenv(HeldEventsEnvVar, tc.raw) + got, err := maxHeldEventsFromEnv() + if tc.wantErr { + if err == nil { + t.Fatalf("%q was accepted as %d", tc.raw, got) + } + if !strings.Contains(err.Error(), HeldEventsEnvVar) { + t.Errorf("error does not name %s: %v", HeldEventsEnvVar, err) + } + if _, serr := NewService(4, "test"); serr == nil { + t.Error("NewService accepted an unusable held events bound") + } + return + } + if err != nil { + t.Fatalf("maxHeldEventsFromEnv(%q): %v", tc.raw, err) + } + if got != tc.want { + t.Errorf("bound %d, want %d", got, tc.want) + } + }) + } +} diff --git a/internal/grpc/homonym_test.go b/internal/frontend/grpc/homonym_test.go similarity index 97% rename from internal/grpc/homonym_test.go rename to internal/frontend/grpc/homonym_test.go index 9d24d20c52..0d865d262e 100644 --- a/internal/grpc/homonym_test.go +++ b/internal/frontend/grpc/homonym_test.go @@ -30,7 +30,7 @@ package Occurrences { // A metadata annotation written as a member of a type once collapsed that // type's members to its own: the annotation's type was resolved while the // type's supertypes were being derived, and the answer computed under that -// guard was memoized (internal/core/semantics/reference.go). +// guard was memoized (internal/semantic/semantics/reference.go). func TestAMetadataAnnotationMemberDoesNotHideInheritedAttributes(t *testing.T) { srv := mustNewService(t, 10) both := make(map[string][]string) diff --git a/internal/grpc/instance_graph_test.go b/internal/frontend/grpc/instance_graph_test.go similarity index 100% rename from internal/grpc/instance_graph_test.go rename to internal/frontend/grpc/instance_graph_test.go diff --git a/internal/grpc/instantiate_bench_test.go b/internal/frontend/grpc/instantiate_bench_test.go similarity index 88% rename from internal/grpc/instantiate_bench_test.go rename to internal/frontend/grpc/instantiate_bench_test.go index e430897350..8e0ff5c2b7 100644 --- a/internal/grpc/instantiate_bench_test.go +++ b/internal/frontend/grpc/instantiate_bench_test.go @@ -11,7 +11,7 @@ import ( // BenchmarkInstantiateWarmModel measures Instantiate on a model the service already // holds: the request path a client repeats, over the published vehicle example. func BenchmarkInstantiateWarmModel(b *testing.B) { - content, err := os.ReadFile("../../examples/pilot-corpora/sysml-examples/Vehicle Example/SysML v2 Spec Annex A SimpleVehicleModel.sysml") + content, err := os.ReadFile("../../../examples/pilot-corpora/sysml-examples/Vehicle Example/SysML v2 Spec Annex A SimpleVehicleModel.sysml") if err != nil { b.Skipf("vehicle example absent: %v", err) } diff --git a/internal/grpc/libindex.go b/internal/frontend/grpc/libindex.go similarity index 96% rename from internal/grpc/libindex.go rename to internal/frontend/grpc/libindex.go index aebb3477ec..65a3207ac4 100644 --- a/internal/grpc/libindex.go +++ b/internal/frontend/grpc/libindex.go @@ -6,9 +6,9 @@ import ( "strings" "sync" - "github.com/Open-MBEE/OpenSysML/internal/core/envvar" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/workspace/envvar" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // IndexPrewarmEnvVar names the variable saying whether the service builds the diff --git a/internal/grpc/libindex_test.go b/internal/frontend/grpc/libindex_test.go similarity index 98% rename from internal/grpc/libindex_test.go rename to internal/frontend/grpc/libindex_test.go index c3c02a93e4..25d49e3cbe 100644 --- a/internal/grpc/libindex_test.go +++ b/internal/frontend/grpc/libindex_test.go @@ -11,9 +11,9 @@ import ( "time" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/envvar" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/workspace/envvar" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) // libraryModel names the standard library, so a model analysed against a @@ -107,7 +107,7 @@ func lookupLines(idx *symbols.Index) []string { // whole index — including what each name resolves to and whether it is library // content. func TestSharedIndexMatchesFreshlyBuiltIndex(t *testing.T) { - demo, err := os.ReadFile("../../examples/combined-behavioral-demo.sysml") + demo, err := os.ReadFile("../../../examples/combined-behavioral-demo.sysml") if err != nil { t.Fatalf("read demo model: %v", err) } diff --git a/internal/grpc/library_documentation_test.go b/internal/frontend/grpc/library_documentation_test.go similarity index 100% rename from internal/grpc/library_documentation_test.go rename to internal/frontend/grpc/library_documentation_test.go diff --git a/internal/grpc/library_feature_values_test.go b/internal/frontend/grpc/library_feature_values_test.go similarity index 100% rename from internal/grpc/library_feature_values_test.go rename to internal/frontend/grpc/library_feature_values_test.go diff --git a/internal/frontend/grpc/objects.go b/internal/frontend/grpc/objects.go new file mode 100644 index 0000000000..4d78ac582c --- /dev/null +++ b/internal/frontend/grpc/objects.go @@ -0,0 +1,358 @@ +package grpc + +import ( + "errors" + "fmt" + "math" + "os" + "slices" + "sort" + "strconv" + "strings" + "sync" + + "connectrpc.com/connect" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec" + "github.com/Open-MBEE/OpenSysML/internal/exec/objref" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// HeldObjectsEnvVar names the variable bounding the objects one cached model +// holds, nested objects counted: a materialization that would pass it fails +// whole, and the model leaving the cache releases them all. +const HeldObjectsEnvVar = "OPENSYSML_GRPC_MAX_HELD_OBJECTS" + +// DefaultMaxHeldObjects is the bound HeldObjectsEnvVar takes when unset. +const DefaultMaxHeldObjects = 10000 + +// HeldEventsEnvVar names the variable bounding the event records a cached population +// keeps for Events; the oldest are dropped and a query reaching them fails. +const HeldEventsEnvVar = "OPENSYSML_GRPC_MAX_HELD_EVENTS" + +// DefaultMaxHeldEvents is the bound HeldEventsEnvVar takes when unset. +const DefaultMaxHeldEvents = 100000 + +// maxHeldObjectsFromEnv returns the positive integer HeldObjectsEnvVar holds, or +// DefaultMaxHeldObjects when it is unset or empty. +func maxHeldObjectsFromEnv() (int, error) { + return positiveFromEnv(HeldObjectsEnvVar, DefaultMaxHeldObjects, "held objects bound") +} + +// maxHeldEventsFromEnv returns the positive integer HeldEventsEnvVar holds, or +// DefaultMaxHeldEvents when it is unset or empty. +func maxHeldEventsFromEnv() (int, error) { + return positiveFromEnv(HeldEventsEnvVar, DefaultMaxHeldEvents, "held events bound") +} + +// positiveFromEnv reads a positive integer bound from the environment; an unusable value is an error. +func positiveFromEnv(name string, fallback int, what string) (int, error) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback, nil + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return 0, fmt.Errorf("%s must be a positive integer, got %q (%s)", what, raw, name) + } + return n, nil +} + +// heldObjects is the population of objects Instantiate created for one cached +// model: a runtime outliving the requests, and the names its roots were +// instantiated under. Requests hold mu while they use the runtime. +type heldObjects struct { + mu sync.Mutex + rt *runtime.Context + idx *symbols.Index + named map[string]*runtime.Instance // fqn -> the object the name denotes now + // displaced are objects a later Instantiate of their name displaced, still + // roots of their own reached by id. + displaced []*runtime.Instance +} + +// objects is the population held for cached, built by the first Instantiate +// over a runtime of its own so a request's worker never carries objects. +func (s *Service) objects(cached *CachedModel) *heldObjects { + cached.objectsMu.Lock() + defer cached.objectsMu.Unlock() + if cached.objects == nil { + model, _ := cached.Semantics() + rt := s.newRuntimeContext(model) + rt.SetMaxInstances(s.maxHeldObjects) + // Events reads the population's run, so its records are kept from the start. + rt.SetTrace(runtime.NewEventRecorder(s.maxHeldEvents)) + cached.objects = &heldObjects{ + rt: rt, + idx: cached.Index, + named: make(map[string]*runtime.Instance), + } + } + return cached.objects +} + +// lock takes exclusive use of the population and its runtime, and returns the +// function releasing it. +func (h *heldObjects) lock() func() { + h.mu.Lock() + return h.mu.Unlock +} + +// hold records inst as the object the symbol it was instantiated from denotes; +// the object the name denoted before stays held, reached by id. +func (h *heldObjects) hold(sym *symbols.Symbol, inst *runtime.Instance) { + fqn := h.idx.GetFQN(sym) + if previous, ok := h.named[fqn]; ok && previous != nil && previous.ID != inst.ID { + h.displaced = append(h.displaced, previous) + } + h.named[fqn] = inst +} + +// empty reports whether no Instantiate has created an object for the model. +func (h *heldObjects) empty() bool { + return len(h.named) == 0 && len(h.displaced) == 0 +} + +// exhausted is the RESOURCE_EXHAUSTED status of a failure at the held-objects +// bound, nil for any other; the count reported is what the model still holds. +func (h *heldObjects) exhausted(err error) error { + if !errors.Is(err, runtime.ErrInstanceLimitExceeded) { + return nil + } + return statusErrorf(connect.CodeResourceExhausted, + "the model holds %d objects, and one more would pass the %d that %s allows; raise it to hold more (the objects are released when the model leaves the cache)", + h.rt.InstanceCount(), h.rt.MaxInstances(), HeldObjectsEnvVar) +} + +// documentStatus is the status of a document failure over the held population: +// RESOURCE_EXHAUSTED when a read materialized past the bound, else the engine's own. +func (h *heldObjects) documentStatus(err error) error { + if status := h.exhausted(err); status != nil { + return status + } + return documentStatus(err) +} + +// roots are the objects a query enumerates from, each under the label it is +// reported by: named ones by qualified name in name order, displaced ones by id. +func (h *heldObjects) roots() []queryexec.Root { + roots := make([]queryexec.Root, 0, len(h.named)+len(h.displaced)) + for fqn, inst := range h.named { + roots = append(roots, queryexec.Root{Label: source.QualifiedNameText(fqn), Object: inst}) + } + sort.Slice(roots, func(i, j int) bool { return roots[i].Label < roots[j].Label }) + displaced := slices.Clone(h.displaced) + sort.Slice(displaced, func(i, j int) bool { return displaced[i].ID < displaced[j].ID }) + for _, inst := range displaced { + roots = append(roots, queryexec.Root{Label: fmt.Sprintf("#%d", inst.ID), Object: inst}) + } + return roots +} + +// queryContext is the context the model's queries execute in: its runtime and +// the objects it holds, under the labels they are reported by. +func (h *heldObjects) queryContext() queryexec.Context { + return queryexec.Context{ + Index: h.idx, + Resolver: h.rt.Resolver(), + Model: h.rt.Semantics(), + Runtime: h.rt, + Roots: h.roots(), + } +} + +// resolve is the object a request's DocumentObject binds and the label it is +// reported under: by path when one is written, else by id. A malformed +// reference is refused as written, whatever the model holds. +func (h *heldObjects) resolve(parameter string, ref *pb.DocumentObject) (*runtime.Instance, string, error) { + if ref.GetPath() == "" && ref.GetInstanceId() == 0 { + return nil, "", statusErrorf(connect.CodeInvalidArgument, + "binding %s: an object is bound by instance_id or by path, and neither was given", parameter) + } + var parsed objref.Ref + if ref.GetPath() != "" { + var err error + if parsed, err = objref.Parse(ref.GetPath()); err != nil { + return nil, "", statusErrorf(connect.CodeInvalidArgument, "binding %s: %v", parameter, err) + } + } else if ref.GetInstanceId() < 0 { + return nil, "", bindingError(parameter, notAnID(ref.GetInstanceId())) + } + if h.empty() { + return nil, "", statusErrorf(connect.CodeNotFound, + "binding %s: the model holds no objects (Instantiate creates one)", parameter) + } + var ( + inst *runtime.Instance + label string + err error + ) + if ref.GetPath() != "" { + inst, label, err = h.resolvePath(parsed) + } else { + inst, err = h.byID(ref.GetInstanceId()) + label = fmt.Sprintf("#%d", ref.GetInstanceId()) + } + if err != nil { + return nil, "", bindingError(parameter, err) + } + if id := ref.GetInstanceId(); id != 0 && ref.GetPath() != "" && inst.ID != id { + return nil, "", statusErrorf(connect.CodeInvalidArgument, + "binding %s: %s is object #%d, not #%d", parameter, label, inst.ID, id) + } + return inst, label, nil +} + +// bindingError prefixes a typed resolution failure with the parameter bound. +func bindingError(parameter string, err error) error { + var connectErr *connect.Error + if errors.As(err, &connectErr) { + return statusErrorf(connectErr.Code(), "binding %s: %s", parameter, connectErr.Message()) + } + return statusErrorf(connect.CodeInvalidArgument, "binding %s: %v", parameter, err) +} + +// notAnID refuses an id no object can have. +func notAnID(id int64) error { + return statusErrorf(connect.CodeInvalidArgument, "#%d is not an object id (ids count up from 1)", id) +} + +// byID is the object with id among those the runtime holds. +func (h *heldObjects) byID(id int64) (*runtime.Instance, error) { + if id <= 0 { + return nil, notAnID(id) + } + if inst, ok := h.rt.Instance(id); ok { + return inst, nil + } + return nil, h.unknownID(id) +} + +// unknownIDListed bounds the ids an unknown-id error spells out. +const unknownIDListed = 20 + +// unknownID reports an id no held object has, and which ids are held. +func (h *heldObjects) unknownID(id int64) error { + ids := h.rt.InstanceIDs() + if len(ids) == 0 { + return statusErrorf(connect.CodeNotFound, + "no object #%d for this model: nothing materialized has that identity (no objects have been created)", id) + } + listed := make([]string, 0, len(ids)) + for _, known := range ids[:min(len(ids), unknownIDListed)] { + listed = append(listed, fmt.Sprintf("#%d", known)) + } + more := "" + if len(ids) > unknownIDListed { + more = fmt.Sprintf(", … (%d in all)", len(ids)) + } + return statusErrorf(connect.CodeNotFound, + "no object #%d for this model: nothing materialized has that identity (the objects are %s%s)", id, strings.Join(listed, ", "), more) +} + +// resolvePath is the object a parsed reference denotes — `#3`, an instantiated +// name, or a path through feature values from either — and the label it is +// reported under. +func (h *heldObjects) resolvePath(ref objref.Ref) (*runtime.Instance, string, error) { + walker := objref.Walker{Runtime: h.rt, Index: h.idx} + if ref.ID > 0 { + inst, err := h.byID(ref.ID) + if err != nil { + return nil, "", err + } + return h.walked(walker.Walk(inst, fmt.Sprintf("#%d", ref.ID), ref.Segments)) + } + inst, fqn, rest, err := h.namedRoot(ref) + if err != nil { + return nil, "", err + } + return h.walked(walker.Walk(inst, source.QualifiedNameText(fqn), rest)) +} + +// walked types a walk's failure: the path is the caller's, so an invalid +// argument, unless materializing along it ran into the held-objects bound. +func (h *heldObjects) walked(inst *runtime.Instance, label string, err error) (*runtime.Instance, string, error) { + if err != nil { + if status := h.exhausted(err); status != nil { + return nil, "", status + } + return nil, "", statusErrorf(connect.CodeInvalidArgument, "%v", err) + } + return inst, label, nil +} + +// namedRoot finds the object a name-rooted reference starts from: the longest +// run of leading segments naming an instantiated declaration, its qualified +// name, and the segments left to walk from it. +func (h *heldObjects) namedRoot(ref objref.Ref) (*runtime.Instance, string, []objref.Segment, error) { + head := objref.Head(ref.Segments) + noInstance := "" + for i := head; i > 0; i-- { + sym, err := h.lookup(ref.Segments[:i]) + if err != nil { + return nil, "", nil, err + } + if sym == nil { + continue + } + fqn := h.idx.GetFQN(sym) + if inst, ok := h.named[fqn]; ok && inst != nil { + return inst, fqn, ref.Segments[i:], nil + } + if i == head && head < len(ref.Segments) && objref.IsNamespace(sym) { + shown := source.QualifiedNameText(fqn) + return nil, "", nil, statusErrorf(connect.CodeInvalidArgument, + "%q is not an object reference: %s is a %s, not an object: its member is written %s::%s", + ref.Text, shown, objref.NamespaceKind(sym), shown, ref.Segments[head].Text) + } + if noInstance == "" { + noInstance = fqn + } + } + if noInstance != "" { + if h.empty() { + return nil, "", nil, statusErrorf(connect.CodeNotFound, + "no instance of %q: the model holds no objects (Instantiate creates one)", source.QualifiedNameText(noInstance)) + } + return nil, "", nil, statusErrorf(connect.CodeNotFound, + "no instance of %q (use Instantiate first)", source.QualifiedNameText(noInstance)) + } + return nil, "", nil, statusErrorf(connect.CodeNotFound, "symbol not found: %s", objref.JoinTyped(ref.Segments[:head])) +} + +// lookup is the declaration segments name: by qualified name as every RPC +// reads one, or — for a lone name, as the REPL reads `car` — the one model +// declaration of that name among all declared. Nil when nothing is declared under it. +func (h *heldObjects) lookup(segments []objref.Segment) (*symbols.Symbol, error) { + name := objref.JoinTyped(segments) + if syms := lookupNamed(h.idx, name); len(syms) > 0 { + return syms[0], nil + } + if len(segments) != 1 { + return nil, nil + } + var found []*symbols.Symbol + for _, fqn := range h.idx.FQNsEndingIn(segments[0].Name, math.MaxInt) { + for _, sym := range h.idx.LookupQualified(fqn) { + if !h.idx.Library(sym) { + found = append(found, sym) + } + } + } + switch len(found) { + case 0: + return nil, nil + case 1: + return found[0], nil + } + names := make([]string, 0, len(found)) + for _, sym := range found { + names = append(names, source.QualifiedNameText(h.idx.GetFQN(sym))) + } + sort.Strings(names) + return nil, statusErrorf(connect.CodeInvalidArgument, + "%q is ambiguous: it names %s (write the qualified name)", name, strings.Join(names, ", ")) +} diff --git a/internal/frontend/grpc/objects_test.go b/internal/frontend/grpc/objects_test.go new file mode 100644 index 0000000000..846b98245e --- /dev/null +++ b/internal/frontend/grpc/objects_test.go @@ -0,0 +1,406 @@ +package grpc + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +// objectFixture declares queries over the objects a service holds and the +// assertions on them, as the REPL's %run-query tests do. +const objectFixture = "../../doc/docrender/testdata/object_report.sysml" + +// objectByID binds an object by the id Instantiate answered. +func objectByID(id int64) *pb.DocumentValue { + return &pb.DocumentValue{Kind: &pb.DocumentValue_Object{Object: &pb.DocumentObject{InstanceId: id}}} +} + +// objectByPath binds an object by the label a session reaches it under. +func objectByPath(path string) *pb.DocumentValue { + return &pb.DocumentValue{Kind: &pb.DocumentValue_Object{Object: &pb.DocumentObject{Path: path}}} +} + +// holdObject creates an object of sym and returns its root instance. +func holdObject(t *testing.T, srv *Service, hash, sym string) *pb.Instance { + t.Helper() + resp, err := srv.Instantiate(context.Background(), &pb.InstantiateRequest{ModelHash: hash, SymbolId: sym}) + if err != nil { + t.Fatalf("Instantiate %s: %v", sym, err) + } + if resp.Error != "" { + t.Fatalf("Instantiate %s: %s", sym, resp.Error) + } + return resp.Instance +} + +// queryObjects runs a query with bindings and fails the test on a status error. +func queryObjects(t *testing.T, srv *Service, hash, query string, bindings ...*pb.DocumentQueryBinding) *pb.RunDocumentQueryResponse { + t.Helper() + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: query, Bindings: bindings, + }) + if err != nil { + t.Fatalf("RunDocumentQuery %s: %v", query, err) + } + return resp +} + +// objectLabels spells the rows of a response as "path (#id)". +func objectLabels(resp *pb.RunDocumentQueryResponse) []string { + labels := make([]string, 0, len(resp.Rows)) + for _, row := range resp.Rows { + obj := row.Element.GetObject() + labels = append(labels, fmt.Sprintf("%s (#%d)", obj.GetPath(), obj.GetInstanceId())) + } + return labels +} + +// TestInstantiateHoldsObjectsForQueries: an object Instantiate created is bound +// by id, by name and by path, and answered as an object with its path and usage. +func TestInstantiateHoldsObjectsForQueries(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, objectFixture) + + car := holdObject(t, srv, hash, "Garage::car") + if car.Id != 1 { + t.Fatalf("car id = %d, want 1", car.Id) + } + + for _, root := range []*pb.DocumentValue{objectByID(1), objectByPath("Garage::car"), objectByPath("car"), objectByPath("#1")} { + resp := queryObjects(t, srv, hash, "Garage::Parts", binding("root", root)) + want := []string{"Garage::car.engine (#2)", "Garage::car.wheels[1] (#3)", "Garage::car.wheels[2] (#4)"} + if root.GetObject().GetPath() == "#1" || root.GetObject().GetInstanceId() == 1 { + want = []string{"#1.engine (#2)", "#1.wheels[1] (#3)", "#1.wheels[2] (#4)"} + } + if got := objectLabels(resp); strings.Join(got, ";") != strings.Join(want, ";") { + t.Errorf("Parts root=%v rows = %v, want %v", root.GetObject(), got, want) + } + if len(resp.Rows) == 3 { + wheel := resp.Rows[1].Element.GetObject() + if wheel.GetElement().GetElementId() != "Garage::Car::wheels" || wheel.GetElement().GetElementType() != "PartUsage" { + t.Errorf("wheel usage = %v, want Garage::Car::wheels PartUsage", wheel.GetElement()) + } + if got := resp.Rows[1].Cells[2].Values; len(got) != 1 || got[0].GetIntValue() != 30 { + t.Errorf("wheel pressure = %v, want 30", got) + } + } + } + + // A path into the graph binds the nested object, which has no parts of its own. + resp := queryObjects(t, srv, hash, "Garage::Parts", binding("root", objectByPath("car.wheels[2]"))) + if len(resp.Rows) != 0 { + t.Errorf("Parts root=car.wheels[2] rows = %v, want none", objectLabels(resp)) + } + resp = queryObjects(t, srv, hash, "Garage::Drive", binding("root", objectByPath("Garage::car"))) + if got := objectLabels(resp); len(got) != 1 || got[0] != "Garage::car (#1)" { + t.Fatalf("Drive rows = %v, want [Garage::car (#1)]", got) + } + wheels := resp.Rows[0].Cells[2].Values + if len(wheels) != 2 || wheels[0].GetObject().GetPath() != "Garage::car.wheels[1]" || wheels[1].GetObject().GetInstanceId() != 4 { + t.Errorf("Drive wheels cell = %v, want the two wheel objects", wheels) + } +} + +// TestObjectsEnumeratesHeldObjects: Objects(type = T) answers over what the model +// holds, in the order the REPL reports, and a later Instantiate joins it. +func TestObjectsEnumeratesHeldObjects(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, objectFixture) + + resp := queryObjects(t, srv, hash, "Garage::Wheels") + if len(resp.Rows) != 0 { + t.Fatalf("Wheels over no objects rows = %v, want none", objectLabels(resp)) + } + holdObject(t, srv, hash, "Garage::car") + holdObject(t, srv, hash, "Garage::spare") + resp = queryObjects(t, srv, hash, "Garage::Wheels") + want := []string{"Garage::spare (#5)", "Garage::car.wheels[1] (#3)", "Garage::car.wheels[2] (#4)"} + if got := objectLabels(resp); strings.Join(got, ";") != strings.Join(want, ";") { + t.Errorf("Wheels rows = %v, want %v", got, want) + } + if got := resp.Rows[0].Cells[1].Values; len(got) != 1 || got[0].GetIntValue() != 20 { + t.Errorf("spare pressure = %v, want 20", got) + } +} + +// TestRenderDocumentReadsHeldObjects: a document renders over the objects the +// model holds — none before Instantiate, the ones created after — as +// -render-document renders beside -instantiate. +func TestRenderDocumentReadsHeldObjects(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, objectFixture) + render := func() string { + resp, err := srv.RenderDocument(context.Background(), &pb.RenderDocumentRequest{ModelHash: hash, DocumentId: "Garage::CarReport"}) + if err != nil { + t.Fatalf("RenderDocument: %v", err) + } + return resp.Markdown + } + + before := render() + if strings.Contains(before, "car.wheels") || strings.Contains(before, "- spare 20") { + t.Fatalf("report before Instantiate names objects:\n%s", before) + } + holdObject(t, srv, hash, "Garage::car") + holdObject(t, srv, hash, "Garage::spare") + after := render() + for _, want := range []string{"| wheels\\[2\\] | Garage::car.wheels\\[2\\] | 30 |", "- Garage::spare 20", "- Garage::car.wheels\\[1\\] 30"} { + if !strings.Contains(after, want) { + t.Errorf("report after Instantiate lacks %q:\n%s", want, after) + } + } +} + +// TestInstantiateAgainKeepsTheEarlierObject: the name denotes the newest +// object; the earlier one stays held, reached by id. +func TestInstantiateAgainKeepsTheEarlierObject(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, objectFixture) + + first := holdObject(t, srv, hash, "Garage::spare") + second := holdObject(t, srv, hash, "Garage::spare") + if first.Id == second.Id { + t.Fatalf("both objects have id %d", first.Id) + } + resp := queryObjects(t, srv, hash, "Garage::Wheels") + want := []string{fmt.Sprintf("Garage::spare (#%d)", second.Id), fmt.Sprintf("#%d (#%d)", first.Id, first.Id)} + if got := objectLabels(resp); strings.Join(got, ";") != strings.Join(want, ";") { + t.Errorf("Wheels rows = %v, want %v", got, want) + } + _, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Garage::Parts", + Bindings: []*pb.DocumentQueryBinding{binding("root", &pb.DocumentValue{Kind: &pb.DocumentValue_Object{ + Object: &pb.DocumentObject{Path: "spare", InstanceId: first.Id}}})}, + }) + if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("Garage::spare is object #%d, not #%d", second.Id, first.Id)) { + t.Errorf("binding spare with the displaced id: %v, want the name to denote #%d", err, second.Id) + } +} + +// TestObjectBindingsRunConcurrently: requests over one model's objects serialize +// on the population without racing or deadlocking. +func TestObjectBindingsRunConcurrently(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, objectFixture) + holdObject(t, srv, hash, "Garage::car") + + var wg sync.WaitGroup + errs := make(chan error, 32) + for i := 0; i < 16; i++ { + wg.Add(2) + go func() { + defer wg.Done() + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Garage::Parts", + Bindings: []*pb.DocumentQueryBinding{binding("root", objectByID(1))}, + }) + if err == nil && len(resp.Rows) != 3 { + err = fmt.Errorf("Parts rows = %d, want 3", len(resp.Rows)) + } + if err != nil { + errs <- err + } + }() + go func() { + defer wg.Done() + resp, err := srv.Instantiate(context.Background(), &pb.InstantiateRequest{ModelHash: hash, SymbolId: "Garage::spare"}) + if err == nil && resp.Error != "" { + err = fmt.Errorf("Instantiate: %s", resp.Error) + } + if err != nil { + errs <- err + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + resp := queryObjects(t, srv, hash, "Garage::Wheels") + if len(resp.Rows) != 18 { + t.Errorf("Wheels rows = %d, want 2 of the car and 16 spares", len(resp.Rows)) + } +} + +// TestVerdictsOverHeldObject: Verdicts bound to a held object answers one row per +// assertion on it and what it holds, each path from the object as bound. +func TestVerdictsOverHeldObject(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, verdictFixture) + holdObject(t, srv, hash, "Garage::car") + + for _, root := range []*pb.DocumentValue{objectByPath("car"), objectByID(1)} { + resp := queryObjects(t, srv, hash, "Garage::Checks", binding("root", root)) + prefix := "Garage::car" + if root.GetObject().GetInstanceId() == 1 { + prefix = "#1" + } + got := make(map[string]string) + for i, row := range resp.Rows { + verdict := row.Element.GetVerdict() + if verdict == nil { + t.Fatalf("row %d element = %v, want a verdict", i, row.Element) + } + got[verdict.Text+" on "+verdict.Path] = verdict.Verdict + if cell := stringCell(t, row.Cells[0]); cell != verdict.Path { + t.Errorf("row %d path cell = %q, verdict path = %q", i, cell, verdict.Path) + } + } + want := map[string]string{ + "assert constraint massOk on " + prefix: "holds", + "assert constraint fits on " + prefix: "undecided", + "assert constraint powerLow on " + prefix + ".engine": "violated", + "assert constraint pressureOk on " + prefix + ".wheels[1]": "violated", + "assert constraint pressureOk on " + prefix + ".wheels[2]": "violated", + } + for text, verdict := range want { + if got[text] != verdict { + t.Errorf("root=%v: %s = %q, want %q (rows: %v)", root.GetObject(), text, got[text], verdict, got) + } + } + } + failing := queryObjects(t, srv, hash, "Garage::Failing", binding("root", objectByPath("car.wheels[2]"))) + if len(failing.Rows) != 1 || failing.Rows[0].Element.GetVerdict().GetPath() != "Garage::car.wheels[2]" { + t.Errorf("Failing root=car.wheels[2] rows = %v, want the wheel's violated pressureOk", failing.Rows) + } +} + +// TestShortNameLookupSeesEveryDeclaration: a lone name is matched against every +// declaration of that name, so one instantiated among many is found and an +// ambiguity names them all, however many sort ahead of it. +func TestShortNameLookupSeesEveryDeclaration(t *testing.T) { + var src strings.Builder + src.WriteString("package Fleet {\n\tprivate import DocumentQueries::*;\n\tprivate import KerML::Root::Element;\n") + src.WriteString("\tpart def Item;\n\tcalc def Self :> Query { in root : Element; Project(source = root, properties = (\"qualifiedName\")) }\n") + for i := 1; i <= 30; i++ { + fmt.Fprintf(&src, "\tpackage P%02d { part crate : Fleet::Item; }\n", i) + } + src.WriteString("\tpackage Z { part crate : Item; }\n}\n") + srv := mustNewService(t, 10) + hash := mustParse(t, srv, src.String()) + holdObject(t, srv, hash, "Fleet::Z::crate") + + _, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Fleet::Self", Bindings: []*pb.DocumentQueryBinding{binding("root", objectByPath("crate"))}, + }) + if err == nil { + t.Fatal("binding crate resolved, want ambiguity among 31 declarations") + } + for _, name := range []string{"Fleet::P01::crate", "Fleet::P30::crate", "Fleet::Z::crate"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("ambiguity %q does not name %s", err.Error(), name) + } + } + resp := queryObjects(t, srv, hash, "Fleet::Self", binding("root", objectByPath("Fleet::Z::crate"))) + if got := objectLabels(resp); len(got) != 1 || got[0] != "Fleet::Z::crate (#1)" { + t.Errorf("Self root=Fleet::Z::crate rows = %v, want [Fleet::Z::crate (#1)]", got) + } +} + +// TestHeldObjectsAreBounded: once a model holds the objects HeldObjectsEnvVar +// allows, Instantiate is refused with RESOURCE_EXHAUSTED naming the variable, and +// everything held stays bound; nothing is evicted behind a client's ids. +func TestHeldObjectsAreBounded(t *testing.T) { + t.Setenv(HeldObjectsEnvVar, "5") + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, objectFixture) + + // A car is four objects (car, engine, two wheels): the first fits under the + // bound, the second would pass it at its engine and is refused whole. + exhausted := func(sym string, held int) { + t.Helper() + _, err := srv.Instantiate(context.Background(), &pb.InstantiateRequest{ModelHash: hash, SymbolId: sym}) + if err == nil { + t.Fatalf("Instantiate %s succeeded, want RESOURCE_EXHAUSTED", sym) + } + if connect.CodeOf(err) != connect.CodeResourceExhausted { + t.Errorf("code = %v, want %v: %v", connect.CodeOf(err), connect.CodeResourceExhausted, err) + } + for _, text := range []string{fmt.Sprintf("holds %d objects", held), HeldObjectsEnvVar, "the 5 that"} { + if !strings.Contains(err.Error(), text) { + t.Errorf("error %q lacks %q", err.Error(), text) + } + } + } + holdObject(t, srv, hash, "Garage::car") + exhausted("Garage::car", 4) + cached, _ := srv.cache.Get(hash) + if got := srv.objects(cached).rt.InstanceCount(); got != 4 { + t.Errorf("held after the refused car = %d, want 4", got) + } + + // The population is as it was before the refused car, root and parts alike. + resp := queryObjects(t, srv, hash, "Garage::Parts", binding("root", objectByID(1))) + if got := objectLabels(resp); len(got) != 3 || got[0] != "#1.engine (#2)" { + t.Errorf("Parts root=#1 rows = %v, want the car's three parts", got) + } + resp = queryObjects(t, srv, hash, "Garage::Wheels") + want := []string{"Garage::car.wheels[1] (#3)", "Garage::car.wheels[2] (#4)"} + if got := objectLabels(resp); strings.Join(got, ";") != strings.Join(want, ";") { + t.Errorf("Wheels rows = %v, want %v", got, want) + } + _, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Garage::Parts", Bindings: []*pb.DocumentQueryBinding{binding("root", objectByID(5))}, + }) + if connect.CodeOf(err) != connect.CodeNotFound { + t.Errorf("binding the refused car's id: code = %v, want NOT_FOUND: %v", connect.CodeOf(err), err) + } + + // One object fills the bound exactly; the next is one too many. + holdObject(t, srv, hash, "Garage::spare") + exhausted("Garage::spare", 5) + + // Another model is a population of its own under the same bound. + other := mustParse(t, srv, "package Lot { part def Cone; part cone : Cone; }") + holdObject(t, srv, other, "Lot::cone") +} + +// TestMaxHeldObjectsFromEnv: the bound is the positive integer the variable +// holds, the default when it is unset, and anything else is refused at +// construction naming the variable. +func TestMaxHeldObjectsFromEnv(t *testing.T) { + cases := []struct { + raw string + want int + wantErr bool + }{ + {raw: "", want: DefaultMaxHeldObjects}, + {raw: " ", want: DefaultMaxHeldObjects}, + {raw: "1", want: 1}, + {raw: " 250 ", want: 250}, + {raw: "0", wantErr: true}, + {raw: "-1", wantErr: true}, + {raw: "many", wantErr: true}, + {raw: "1.5", wantErr: true}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("%q", tc.raw), func(t *testing.T) { + t.Setenv(HeldObjectsEnvVar, tc.raw) + got, err := maxHeldObjectsFromEnv() + if tc.wantErr { + if err == nil { + t.Fatalf("%q was accepted as %d", tc.raw, got) + } + if !strings.Contains(err.Error(), HeldObjectsEnvVar) { + t.Errorf("error does not name %s: %v", HeldObjectsEnvVar, err) + } + if _, serr := NewService(4, "test"); serr == nil { + t.Error("NewService accepted an unusable held objects bound") + } + return + } + if err != nil { + t.Fatalf("maxHeldObjectsFromEnv(%q): %v", tc.raw, err) + } + if got != tc.want { + t.Errorf("bound %d, want %d", got, tc.want) + } + }) + } +} diff --git a/internal/frontend/grpc/oslc_query.go b/internal/frontend/grpc/oslc_query.go new file mode 100644 index 0000000000..1e2f139d76 --- /dev/null +++ b/internal/frontend/grpc/oslc_query.go @@ -0,0 +1,37 @@ +package grpc + +import ( + "connectrpc.com/connect" + corequery "github.com/Open-MBEE/OpenSysML/internal/semantic/query" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +type coreQueryModel struct { + eval *queryEval + cached *CachedModel +} + +func (m *coreQueryModel) Candidates(scope []string) ([]*symbols.Symbol, error) { + return m.eval.candidates(m.cached, scope) +} + +func (m *coreQueryModel) Value(sym *symbols.Symbol, property string) ([]string, bool) { + return m.eval.reader.Values(sym, property) +} + +func (m *coreQueryModel) Identity(sym *symbols.Symbol) string { + return m.eval.sc.Index.GetFQN(sym) +} + +func (m *coreQueryModel) Type(sym *symbols.Symbol) string { + return corequery.MetamodelTypeNameOf(sym) +} + +// queryStatus reports a refused query as INVALID_ARGUMENT, wrapping the fault +// so a caller can still read its kind. +func queryStatus(err error) error { + if err == nil { + return nil + } + return connect.NewError(connect.CodeInvalidArgument, err) +} diff --git a/internal/grpc/oslc_query_repl_test.go b/internal/frontend/grpc/oslc_query_repl_test.go similarity index 93% rename from internal/grpc/oslc_query_repl_test.go rename to internal/frontend/grpc/oslc_query_repl_test.go index 03d5d45ce4..27af1387ac 100644 --- a/internal/grpc/oslc_query_repl_test.go +++ b/internal/frontend/grpc/oslc_query_repl_test.go @@ -7,8 +7,8 @@ import ( "testing" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/grpc" - "github.com/Open-MBEE/OpenSysML/internal/repl" + "github.com/Open-MBEE/OpenSysML/internal/frontend/grpc" + "github.com/Open-MBEE/OpenSysML/internal/frontend/repl" ) func TestOSLCQueryMatchesREPLQuery(t *testing.T) { diff --git a/internal/grpc/oslc_query_test.go b/internal/frontend/grpc/oslc_query_test.go similarity index 100% rename from internal/grpc/oslc_query_test.go rename to internal/frontend/grpc/oslc_query_test.go diff --git a/internal/grpc/parse_sources_test.go b/internal/frontend/grpc/parse_sources_test.go similarity index 85% rename from internal/grpc/parse_sources_test.go rename to internal/frontend/grpc/parse_sources_test.go index a252c5fce6..4aaceb76e7 100644 --- a/internal/grpc/parse_sources_test.go +++ b/internal/frontend/grpc/parse_sources_test.go @@ -118,6 +118,9 @@ func TestParseSourcesIsCapabilityGated(t *testing.T) { } } +// Convert writes one document back out, so a model of several is refused; +// ApplyEdits edits the model as a whole, so the same model is answered, and an +// empty request is refused as it is for one document: it names no edit. func TestOneDocumentOperationsRefuseAModelOfSeveral(t *testing.T) { srv := mustNewService(t, 10) defer srv.Close() @@ -138,10 +141,21 @@ func TestOneDocumentOperationsRefuseAModelOfSeveral(t *testing.T) { t.Errorf("Convert err = %v, want a FAILED_PRECONDITION naming the one-document limit", convertErr) } - _, editErr := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ModelHash: resp.ModelHash}) - if connect.CodeOf(editErr) != connect.CodeFailedPrecondition || - !strings.Contains(editErr.Error(), "one document") { - t.Errorf("ApplyEdits err = %v, want a FAILED_PRECONDITION naming the one-document limit", editErr) + _, legacyErr := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ModelHash: resp.ModelHash}) + if connect.CodeOf(legacyErr) != connect.CodeFailedPrecondition || + !strings.Contains(legacyErr.Error(), "accept_documents") { + t.Errorf("ApplyEdits err = %v, want a FAILED_PRECONDITION naming accept_documents", legacyErr) + } + + edited, editErr := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ModelHash: resp.ModelHash, AcceptDocuments: true}) + if editErr != nil { + t.Fatalf("ApplyEdits err = %v, want the empty request refused in the response", editErr) + } + if edited.Failure != pb.EditFailure_EDIT_FAILURE_NO_OPERATIONS { + t.Errorf("ApplyEdits failure = %s (%s), want NO_OPERATIONS", edited.Failure, edited.Error) + } + if edited.Content != "" || len(edited.Documents) != 0 { + t.Errorf("a refusal returned notation: content=%q documents=%v", edited.Content, edited.Documents) } } diff --git a/internal/frontend/grpc/quantity_test.go b/internal/frontend/grpc/quantity_test.go new file mode 100644 index 0000000000..978a922262 --- /dev/null +++ b/internal/frontend/grpc/quantity_test.go @@ -0,0 +1,1161 @@ +package grpc + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "testing" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// quantityModel exercises every shape of quantity a feature value can hold: one written +// with a simple unit, one computed into a compound unit, one written in a scaled +// compound unit, and one inside a nested part. +const quantityModel = ` +package P { + private import ScalarValues::*; + + part def Engine { + attribute power : ISQ::PowerValue = 300.0 [SI::W]; + } + + part def Car { + attribute m : ISQ::MassValue = 5.0 [SI::kg]; + attribute n : ScalarValues::Real = 2.0; + attribute derivedSpeed = 10.0 [SI::m] / 2.0 [SI::s]; + attribute writtenSpeed = 5.4 [SI::km/SI::h]; + attribute count = 3 [SI::m]; + part engine : Engine; + } +} +` + +// mustQuantityModel parses quantityModel and returns the service, the model hash, +// the index the model's names resolve against and the semantics over it. +func mustQuantityModel(t *testing.T) (*Service, string, *symbols.Index, *semantics.Model) { + t.Helper() + + srv := mustNewService(t, 4) + resp, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: quantityModel}, + ContentHash: "quantity-model", + }) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + for _, diag := range resp.Diagnostics { + if diag.Severity == "error" { + t.Fatalf("model has a diagnostic error: %s", diag.Message) + } + } + cached, ok := srv.cache.Get(resp.ModelHash) + if !ok { + t.Fatal("parsed model is not cached") + } + return srv, resp.ModelHash, cached.Index, NewSymbolContext(cached.Index).Semantics +} + +// mustEvaluateQuantity evaluates expr and returns the quantity it produced. +func mustEvaluateQuantity(t *testing.T, srv *Service, modelHash, expr string) *pb.Quantity { + t.Helper() + + resp, err := srv.Evaluate(context.Background(), &pb.EvaluateRequest{ + ModelHash: modelHash, + Expression: expr, + }) + if err != nil { + t.Fatalf("Evaluate(%s): %v", expr, err) + } + if resp.Error != "" { + t.Fatalf("Evaluate(%s): %s", expr, resp.Error) + } + quantity := resp.Result.GetQuantity() + if quantity == nil { + t.Fatalf("Evaluate(%s) = %v, want a quantity", expr, resp.Result) + } + return quantity +} + +// TestQuantityCrossesTheWire pins what a quantity carries: the magnitude in the +// unit written, never reduced, plus the reduction that makes it comparable. +func TestQuantityCrossesTheWire(t *testing.T) { + srv, modelHash, _, _ := mustQuantityModel(t) + + tests := []struct { + expr string + unit string + real float64 + intVal int64 + isInt bool + reduction string + wantScaled bool + }{ + // A prefixed unit reduces to its base unit and a scale: kg is 1000 grams. + {expr: "5.0 [SI::kg]", unit: "SI::kg", real: 5.0, reduction: "1000/1·SI::gram", wantScaled: true}, + {expr: "3 [SI::m]", unit: "SI::m", intVal: 3, isInt: true, reduction: "SI::metre"}, + {expr: "10.0 [SI::m] / 2.0 [SI::s]", unit: "SI::'m/s'", real: 5.0, reduction: "SI::metre·SI::second^-1"}, + {expr: "5.4 [SI::km/SI::h]", unit: "SI::km/SI::h", real: 5.4, reduction: "5/18·SI::metre·SI::second^-1", wantScaled: true}, + // Grouping the notation needs survives, so the text reads back as the unit written. + {expr: "3.0 [SI::m/(SI::s*SI::kg)]", unit: "SI::m/(SI::s*SI::kg)", real: 3.0, reduction: "1/1000·SI::gram^-1·SI::metre·SI::second^-1", wantScaled: true}, + {expr: "4.0 [(SI::m*SI::s)**2]", unit: "(SI::m*SI::s)**2", real: 4.0, reduction: "SI::metre^2·SI::second^2"}, + {expr: "8.0 [(SI::m**2)**3]", unit: "(SI::m**2)**3", real: 8.0, reduction: "SI::metre^6"}, + // A point on a measurement scale carries the scale by name and as its one + // factor: a point reduces to no unit, so the scale itself is the reduction. + {expr: "26.85 [SI::'°C_abs']", unit: "'°C_abs'", real: 26.85, reduction: "SI::degree celsius (absolute temperature scale)"}, + {expr: "26.85 [SI::'°C_abs'] + 10.0 [SI::'°C']", unit: "'°C_abs'", real: 36.85, reduction: "SI::degree celsius (absolute temperature scale)"}, + {expr: "30.0 [SI::'°C_abs'] - 20.0 [SI::'°C_abs']", unit: "'°C'", real: 10.0, reduction: "SI::kelvin"}, + {expr: "5.0 [Time::UTC] + 3.0 [SI::s]", unit: "UTC", real: 8.0, reduction: "Time::Coordinated Universal Time"}, + } + + for _, tc := range tests { + t.Run(tc.expr, func(t *testing.T) { + got := mustEvaluateQuantity(t, srv, modelHash, tc.expr) + if got.GetUnit() != tc.unit { + t.Errorf("unit = %q, want %q", got.GetUnit(), tc.unit) + } + if tc.isInt { + if got.GetIntMagnitude() != tc.intVal { + t.Errorf("int_magnitude = %d, want %d", got.GetIntMagnitude(), tc.intVal) + } + } else if got.GetRealMagnitude() != tc.real { + t.Errorf("real_magnitude = %v, want %v", got.GetRealMagnitude(), tc.real) + } + if reduction := describeUnitTerm(got.GetUnitTerm()); reduction != tc.reduction { + t.Errorf("reduction = %q, want %q", reduction, tc.reduction) + } + scaled := got.GetUnitTerm().GetScaleNum() != got.GetUnitTerm().GetScaleDen() + if scaled != tc.wantScaled { + t.Errorf("scale = %v/%v, want scaled = %v", + got.GetUnitTerm().GetScaleNum(), got.GetUnitTerm().GetScaleDen(), tc.wantScaled) + } + }) + } +} + +// TestQuantityRoundTrip is the fidelity requirement: a quantity that goes out +// and comes back is the same quantity, unit included — same magnitude, same unit +// as written, and a reduction over the very base-unit symbols it left with. +func TestQuantityRoundTrip(t *testing.T) { + srv, modelHash, idx, sem := mustQuantityModel(t) + + for _, expr := range []string{ + "5.0 [SI::kg]", + "3 [SI::m]", + "10.0 [SI::m] / 2.0 [SI::s]", + "5.4 [SI::km/SI::h]", + "(2.0 [SI::m])**2", + "3.0 [SI::m/(SI::s*SI::kg)]", + "4.0 [(SI::m*SI::s)**2]", + "8.0 [(SI::m**2)**3]", + "6.0 [SI::m/SI::s/SI::kg]", + "2.0 [SI::'m/s²'] * 3.0 [SI::s]", + "26.85 [SI::'°C_abs']", + "5.0 [Time::UTC] + 3.0 [SI::s]", + } { + t.Run(expr, func(t *testing.T) { + sent := mustEvaluateQuantity(t, srv, modelHash, expr) + + val, err := protoconv.ProtoToValueIn(&pb.Value{Kind: &pb.Value_Quantity{Quantity: sent}}, idx, sem) + if err != nil { + t.Fatalf("ProtoToValueIn: %v", err) + } + if val.Kind != runtime.ValQuantity || val.Quantity() == nil { + t.Fatalf("kind = %v, want a quantity", val.Kind) + } + + back := protoconv.QuantityToProto(val.Quantity()) + if back.GetUnit() != sent.GetUnit() { + t.Errorf("unit = %q, want %q", back.GetUnit(), sent.GetUnit()) + } + if back.GetIntMagnitude() != sent.GetIntMagnitude() || back.GetRealMagnitude() != sent.GetRealMagnitude() { + t.Errorf("magnitude = %v, want %v", back.GetMagnitude(), sent.GetMagnitude()) + } + if describeUnitTerm(back.GetUnitTerm()) != describeUnitTerm(sent.GetUnitTerm()) { + t.Errorf("reduction = %q, want %q", + describeUnitTerm(back.GetUnitTerm()), describeUnitTerm(sent.GetUnitTerm())) + } + if !val.Quantity().Unit.Term.Commensurable(mustUnitTerm(t, sent, idx, sem)) { + t.Error("round-tripped quantity is not commensurable with the one sent") + } + }) + } +} + +// TestSetOfPointsReadForARuntime: a set read for a runtime judges membership as +// the runtime does, so a point and the magnitude it equals are one element sent +// twice; with no runtime the two spellings stay apart. +func TestSetOfPointsReadForARuntime(t *testing.T) { + srv, modelHash, idx, sem := mustQuantityModel(t) + cached, ok := srv.cache.Get(modelHash) + if !ok { + t.Fatal("parsed model is not cached") + } + kelvin := mustEvaluateQuantity(t, srv, modelHash, "293.15 [SI::K]") + celsius := mustEvaluateQuantity(t, srv, modelHash, "20.0 [SI::'°C_abs']") + sent := setOf( + &pb.Value{Kind: &pb.Value_Quantity{Quantity: kelvin}}, + &pb.Value{Kind: &pb.Value_Quantity{Quantity: celsius}}, + ) + + rt, _ := srv.newRuntime(cached) + if _, err := protoconv.ProtoToRuntimeValue(rt, sent, idx, sem); !errors.Is(err, protoconv.ErrSetElementRepeated) { + t.Errorf("protoconv.ProtoToRuntimeValue({293.15 K, 20.0 °C_abs}) = %v, want %v", err, protoconv.ErrSetElementRepeated) + } + points, err := protoconv.ProtoToRuntimeValue(rt, setOf(&pb.Value{Kind: &pb.Value_Quantity{Quantity: celsius}}), idx, sem) + if err != nil || points.Kind != runtime.ValSet { + t.Fatalf("protoconv.ProtoToRuntimeValue({20.0 °C_abs}) = %s, %v, want a set", runtime.FormatValue(points), err) + } + inKelvin, err := protoconv.ProtoToValueIn(&pb.Value{Kind: &pb.Value_Quantity{Quantity: kelvin}}, idx, sem) + if err != nil { + t.Fatal(err) + } + if !points.Set().Contains(inKelvin) { + t.Errorf("{20.0 °C_abs} read for a runtime does not hold 293.15 K") + } + + val, err := protoconv.ProtoToValueIn(sent, idx, sem) + if err != nil || val.Kind != runtime.ValSet || val.Set().Size() != 2 { + t.Errorf("protoconv.ProtoToValueIn({293.15 K, 20.0 °C_abs}) = %s, %v, want two members judged with no runtime", runtime.FormatValue(val), err) + } +} + +// mustUnitTerm rebuilds the unit term of sent, for comparing a round-trip +// against a second, independent reconstruction. +func mustUnitTerm(t *testing.T, sent *pb.Quantity, idx *symbols.Index, sem *semantics.Model) semantics.UnitTerm { + t.Helper() + + val, err := protoconv.ProtoToQuantity(sent, idx, sem) + if err != nil { + t.Fatalf("ProtoToQuantity: %v", err) + } + return val.Quantity().Unit.Term +} + +// TestQuantityFromWireIsNormalized pins that a hand-built reduction — factors in +// any order, a base unit repeated, an exponent that cancels — is commensurable +// with the same unit the model derives, which compares factors element-wise. +func TestQuantityFromWireIsNormalized(t *testing.T) { + srv, modelHash, idx, sem := mustQuantityModel(t) + derived := mustEvaluateQuantity(t, srv, modelHash, "10.0 [SI::m] / 2.0 [SI::s]") + + byHand := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 5}, + Unit: "SI::m/SI::s", + UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{ + {UnitId: "SI::second", Exponent: -1}, + {UnitId: "SI::gram", Exponent: 0}, + {UnitId: "SI::metre", Exponent: 2}, + {UnitId: "SI::metre", Exponent: -1}, + }}, + } + + val, err := protoconv.ProtoToQuantity(byHand, idx, sem) + if err != nil { + t.Fatalf("ProtoToQuantity: %v", err) + } + if !val.Quantity().Unit.Term.Commensurable(mustUnitTerm(t, derived, idx, sem)) { + t.Errorf("reduction = %s, want it commensurable with %s", + val.Quantity().Unit.Term, describeUnitTerm(derived.GetUnitTerm())) + } +} + +// TestQuantityFromWireNeedsTheModel pins the two ways a quantity cannot be read +// back: without the model's symbols, and over a base unit it does not declare. +func TestQuantityFromWireNeedsTheModel(t *testing.T) { + srv, modelHash, idx, sem := mustQuantityModel(t) + sent := mustEvaluateQuantity(t, srv, modelHash, "5.0 [SI::kg]") + + if _, err := protoconv.ProtoToQuantity(sent, nil, nil); !errors.Is(err, protoconv.ErrQuantityNeedsIndex) { + t.Errorf("without an index: err = %v, want protoconv.ErrQuantityNeedsIndex", err) + } + + unknown := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: "Made::up", + UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "Made::up", Exponent: 1}}}, + } + if _, err := protoconv.ProtoToQuantity(unknown, idx, sem); !errors.Is(err, protoconv.ErrUnknownBaseUnit) { + t.Errorf("over an undeclared base unit: err = %v, want protoconv.ErrUnknownBaseUnit", err) + } + + for _, scale := range []*pb.UnitTerm{ + {ScaleNum: 1, ScaleDen: 0}, {ScaleNum: 0, ScaleDen: 1}, + {ScaleNum: math.NaN(), ScaleDen: 1}, {ScaleNum: 1, ScaleDen: math.NaN()}, + {ScaleNum: math.Inf(1), ScaleDen: 1}, {ScaleNum: 1, ScaleDen: math.Inf(-1)}, + } { + unusable := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: "SI::m", + UnitTerm: scale, + } + if _, err := protoconv.ProtoToQuantity(unusable, idx, sem); !errors.Is(err, protoconv.ErrUnitScaleUnusable) { + t.Errorf("over scale %g/%g: err = %v, want protoconv.ErrUnitScaleUnusable", + scale.ScaleNum, scale.ScaleDen, err) + } + } + + for _, exponent := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + unusable := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: "SI::m", + UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "SI::metre", Exponent: exponent}}}, + } + if _, err := protoconv.ProtoToQuantity(unusable, idx, sem); !errors.Is(err, protoconv.ErrUnitExponentUnusable) { + t.Errorf("over exponent %g: err = %v, want protoconv.ErrUnitExponentUnusable", exponent, err) + } + } + + overflowing := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: "SI::m", + UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{ + {UnitId: "SI::metre", Exponent: math.MaxFloat64}, + {UnitId: "SI::metre", Exponent: math.MaxFloat64}, + }}, + } + if _, err := protoconv.ProtoToQuantity(overflowing, idx, sem); !errors.Is(err, protoconv.ErrUnitExponentUnusable) { + t.Errorf("over repeated exponents summing past the largest double: err = %v, want protoconv.ErrUnitExponentUnusable", err) + } + + noMagnitude := &pb.Quantity{Unit: "SI::kg"} + if _, err := protoconv.ProtoToQuantity(noMagnitude, idx, sem); err == nil { + t.Error("a quantity with no magnitude must be reported, not read as zero") + } +} + +// TestQuantityOverSomethingThatIsNotAUnit pins that a reduction is only accepted +// over measurement units: a name resolving to a part, or to nothing at all, is +// rejected rather than measured in. +func TestQuantityOverSomethingThatIsNotAUnit(t *testing.T) { + _, _, idx, sem := mustQuantityModel(t) + + overAPart := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: "P::Car", + UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "P::Car", Exponent: 1}}}, + } + if _, err := protoconv.ProtoToQuantity(overAPart, idx, sem); !errors.Is(err, protoconv.ErrNotAMeasurementUnit) { + t.Errorf("over a part: err = %v, want protoconv.ErrNotAMeasurementUnit", err) + } + + // An empty name is a lookup of the document root, which would otherwise + // resolve to exactly one symbol and pass as a base unit. + unnamed := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: "made up", + UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{Exponent: 1}}}, + } + if _, err := protoconv.ProtoToQuantity(unnamed, idx, sem); !errors.Is(err, protoconv.ErrUnknownBaseUnit) { + t.Errorf("over an unnamed factor: err = %v, want protoconv.ErrUnknownBaseUnit", err) + } + + // A measurement scale is the whole reduction of a point on it, never a factor + // beside a unit or under a power. + scaleTimesUnit := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: "'°C_abs'*s", + UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{ + {UnitId: "SI::degree celsius (absolute temperature scale)", Exponent: 1}, + {UnitId: "SI::second", Exponent: 1}, + }}, + } + if _, err := protoconv.ProtoToQuantity(scaleTimesUnit, idx, sem); !errors.Is(err, protoconv.ErrScaleNotAFactor) { + t.Errorf("over a scale times a unit: err = %v, want protoconv.ErrScaleNotAFactor", err) + } + scaleSquared := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: "'°C_abs'**2", + UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{ + {UnitId: "SI::degree celsius (absolute temperature scale)", Exponent: 2}, + }}, + } + if _, err := protoconv.ProtoToQuantity(scaleSquared, idx, sem); !errors.Is(err, protoconv.ErrScaleNotAFactor) { + t.Errorf("over a scale squared: err = %v, want protoconv.ErrScaleNotAFactor", err) + } +} + +// TestQuantityFeatureValuesAndNestedQuantities drives Instantiate: every quantity feature value +// of a part, and the quantity inside the part it holds, cross as quantities. +func TestQuantityFeatureValuesAndNestedQuantities(t *testing.T) { + srv, modelHash, _, _ := mustQuantityModel(t) + + resp, err := srv.Instantiate(context.Background(), &pb.InstantiateRequest{ + ModelHash: modelHash, + SymbolId: "P::Car", + }) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if resp.Error != "" { + t.Fatalf("Instantiate: %s", resp.Error) + } + + for name, want := range map[string]string{ + "m": "5 [SI::kg] = 1000/1·SI::gram", + "derivedSpeed": "5 [SI::'m/s'] = SI::metre·SI::second^-1", + "writtenSpeed": "5.4 [SI::km/SI::h] = 5/18·SI::metre·SI::second^-1", + "count": "3 [SI::m] = SI::metre", + } { + fv, ok := resp.Instance.FeatureValues[name] + if !ok { + t.Errorf("missing feature value %q", name) + continue + } + if fv.Error != "" { + t.Errorf("feature value %q: %s", name, fv.Error) + continue + } + if got := describeQuantity(fv.Value.GetQuantity()); got != want { + t.Errorf("feature value %q = %q, want %q", name, got, want) + } + } + + // The ordinary real feature value is untouched by the quantity arm. + if got := resp.Instance.FeatureValues["n"].GetValue().GetRealValue(); got != 2.0 { + t.Errorf("feature value n = %v, want 2", got) + } + + engineID := resp.Instance.FeatureValues["engine"].GetValue().GetInstanceId() + if engineID == 0 { + t.Fatal("feature value engine holds no instance") + } + var engine *pb.Instance + for _, inst := range resp.Instances { + if inst.Id == engineID { + engine = inst + } + } + if engine == nil { + t.Fatalf("instance %d is not in the response graph", engineID) + } + wantPower := "300 [SI::W] = 1000/1·SI::gram·SI::metre^2·SI::second^-3" + if got := describeQuantity(engine.FeatureValues["power"].GetValue().GetQuantity()); got != wantPower { + t.Errorf("nested feature value power = %q, want %q", got, wantPower) + } +} + +// TestQuantityWithoutItsReduction pins that a named unit sent with no reduction +// is rejected: dimension one would make it commensurable with a bare number. +func TestQuantityWithoutItsReduction(t *testing.T) { + _, _, idx, sem := mustQuantityModel(t) + + unreduced := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 5}, + Unit: "Furlongs::furlong", + } + if _, err := protoconv.ProtoToQuantity(unreduced, idx, sem); !errors.Is(err, protoconv.ErrUnitNotReduced) { + t.Errorf("error = %v, want %v", err, protoconv.ErrUnitNotReduced) + } + + // A magnitude under no unit at all is dimension one, which is what it says. + dimensionless := &pb.Quantity{Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 5}} + val, err := protoconv.ProtoToQuantity(dimensionless, idx, sem) + if err != nil { + t.Fatalf("ProtoToQuantity: %v", err) + } + if len(val.Quantity().Unit.Term.Factors) != 0 { + t.Errorf("factors = %v, want none", val.Quantity().Unit.Term.Factors) + } +} + +// TestQuantityFromWireComposesAsWritten pins that a quantity read back from the +// wire keeps the named units its unit text composes, so an operation on it in a +// calc cancels and merges them exactly as it does for a quantity written in the model. +func TestQuantityFromWireComposesAsWritten(t *testing.T) { + srv := mustNewService(t, 4) + source := `package Q { + private import ScalarValues::*; + private import SI::*; + calc def Dist { in v; in dt; v * dt } + calc def Area { in a; in b; a * b } + calc def Per { in a; in b; a / b } + calc def Metre { 2.0 [m] } + calc def Kilometre { 3.0 [km] } +} +package Nautical { + private import MeasurementReferences::*; + private import ISQ::*; + private import SI::*; + attribute 'fathom' : LengthUnit { :>> unitConversion: ConversionByConvention { :>> referenceUnit = m; :>> conversionFactor = 1.8288; } } + attribute 'cable' : LengthUnit { :>> unitConversion: ConversionByConvention { :>> referenceUnit = m; :>> conversionFactor = 182.88; } } + calc def Fathom { 2.0 [fathom] } + calc def Cable { 1.0 [cable] } +} +package Imperial { + private import MeasurementReferences::*; + private import ISQ::*; + private import SI::*; + attribute 'fathom' : LengthUnit { :>> unitConversion: ConversionByConvention { :>> referenceUnit = m; :>> conversionFactor = 1.8288; } } + attribute 'cable' : LengthUnit { :>> unitConversion: ConversionByConvention { :>> referenceUnit = m; :>> conversionFactor = 185.3184; } } + calc def Cable { 1.0 [cable] } +} +` + hash := mustVerifyModel(t, srv, source, "quantity-composes-as-written") + evaluate := func(calc string, args ...*pb.Quantity) *pb.Quantity { + t.Helper() + req := &pb.EvaluateCalcRequest{ModelHash: hash, SymbolId: calc} + for _, arg := range args { + req.Arguments = append(req.Arguments, &pb.Value{Kind: &pb.Value_Quantity{Quantity: arg}}) + } + resp, err := srv.EvaluateCalc(context.Background(), req) + if err != nil { + t.Fatalf("EvaluateCalc %s: %v", calc, err) + } + if resp.Error != "" { + t.Fatalf("EvaluateCalc %s reported %q", calc, resp.Error) + } + if resp.Result.GetQuantity() == nil { + t.Fatalf("EvaluateCalc %s = %v, want a quantity", calc, resp.Result) + } + return resp.Result.GetQuantity() + } + + speed := mustEvaluateQuantity(t, srv, hash, "3.0 [SI::m] / 1.0 [SI::s]") + if speed.GetUnit() != "SI::'m/s'" { + t.Fatalf("speed crosses the wire in %q, want SI::'m/s'", speed.GetUnit()) + } + dist := evaluate("Q::Dist", speed, mustEvaluateQuantity(t, srv, hash, "2.0 [SI::s]")) + if got := describeQuantity(dist); got != "6 [SI::m] = SI::metre" { + t.Errorf("m/s * s over the wire = %s, want 6 [SI::m] = SI::metre", got) + } + + // A scaled named unit composed folds its scale into the magnitude, as it does + // locally: two kilometres squared are four million square metres. + byHand := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 2}, + Unit: "SI::km", + UnitTerm: mustEvaluateQuantity(t, srv, hash, "1.0 [SI::km]").GetUnitTerm(), + } + area := evaluate("Q::Area", byHand, byHand) + if got := describeQuantity(area); got != "4e+06 [SI::'m²'] = SI::metre^2" { + t.Errorf("km * km over the wire = %s, want 4e+06 [SI::'m²'] = SI::metre^2", got) + } + + // A unit whose name the notation quotes is one unit when composed: `'A/m'` + // times `m` reduces to the ampere, and squared to a base-unit product. + density := mustEvaluateQuantity(t, srv, hash, "2.0 [SI::'A/m']") + if density.GetUnit() != "SI::'A/m'" { + t.Fatalf("a quoted unit crosses the wire in %q, want SI::'A/m'", density.GetUnit()) + } + if got := describeQuantity(evaluate("Q::Area", density, mustEvaluateQuantity(t, srv, hash, "3.0 [SI::m]"))); got != "6 [SI::A] = SI::ampere" { + t.Errorf("'A/m' * m over the wire = %s, want 6 [SI::A] = SI::ampere", got) + } + if got := describeQuantity(evaluate("Q::Area", density, density)); got != "4 [A**2/m**2] = SI::ampere^2·SI::metre^-2" { + t.Errorf("'A/m' * 'A/m' over the wire = %s, want 4 [A**2/m**2] = SI::ampere^2·SI::metre^-2", got) + } + + // A unit named through an alias is the unit the alias stands for: SI::'m/s²' + // reduces as SI::'m⋅s⁻²' does and composes with SI::s to a coherent speed. + accel := mustEvaluateQuantity(t, srv, hash, "2.0 [SI::'m/s²']") + if accel.GetUnit() != "SI::'m/s²'" { + t.Fatalf("an aliased unit crosses the wire in %q, want SI::'m/s²'", accel.GetUnit()) + } + if got := describeQuantity(evaluate("Q::Area", accel, mustEvaluateQuantity(t, srv, hash, "3.0 [SI::'m⋅s⁻²']"))); got != "6 [m**2/s**4] = SI::metre^2·SI::second^-4" { + t.Errorf("'m/s²' * 'm⋅s⁻²' over the wire = %s, want 6 [m**2/s**4] = SI::metre^2·SI::second^-4", got) + } + if got := describeQuantity(evaluate("Q::Dist", accel, mustEvaluateQuantity(t, srv, hash, "3.0 [SI::s]"))); got != "6 [SI::'m/s'] = SI::metre·SI::second^-1" { + t.Errorf("'m/s²' * s over the wire = %s, want 6 [SI::'m/s'] = SI::metre·SI::second^-1", got) + } + shortAlias := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 2}, + Unit: "'m/s²'*s", + UnitTerm: speed.GetUnitTerm(), + } + if got := describeQuantity(evaluate("Q::Dist", shortAlias, mustEvaluateQuantity(t, srv, hash, "3.0 [SI::s]"))); got != "6 [SI::m] = SI::metre" { + t.Errorf("short 'm/s²'*s over the wire * s = %s, want 6 [SI::m] = SI::metre", got) + } + + // Unit text that is no unit expression is one opaque unit: still a quantity + // over the reduction sent, and still what the sender wrote, quoted as one name + // once composed so that it reads back as one unit. + opaque := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 5}, + Unit: "metres per second", + UnitTerm: speed.GetUnitTerm(), + } + got := describeQuantity(evaluate("Q::Dist", opaque, mustEvaluateQuantity(t, srv, hash, "1.0 [SI::s]"))) + if got != "5 ['metres per second'*SI::s] = SI::metre" { + t.Errorf("opaque unit over the wire = %s, want 5 ['metres per second'*SI::s] = SI::metre", got) + } + + // A unit written short, as an import let the sender write it, is the unit of + // that name reducing as sent: `m` is SI::m, and cancels or merges with SI::m + // written in full. + unqualified := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 5}, + Unit: "m/s", + UnitTerm: speed.GetUnitTerm(), + } + got = describeQuantity(evaluate("Q::Dist", unqualified, mustEvaluateQuantity(t, srv, hash, "1.0 [SI::s]"))) + if got != "5 [m] = SI::metre" { + t.Errorf("unqualified unit over the wire = %s, want 5 [m] = SI::metre", got) + } + metre := evaluate("Q::Metre") + if metre.GetUnit() != "m" { + t.Fatalf("a quantity written under an import crosses the wire in %q, want m", metre.GetUnit()) + } + got = describeQuantity(evaluate("Q::Area", metre, mustEvaluateQuantity(t, srv, hash, "3.0 [SI::m]"))) + if got != "6 [SI::'m²'] = SI::metre^2" { + t.Errorf("m * SI::m over the wire = %s, want 6 [SI::'m²'] = SI::metre^2", got) + } + + // A short name the model does not declare, or declares as a unit the + // reduction contradicts, is opaque: it was not certainly that unit. + for _, tc := range []struct{ unit, want string }{ + {"ft", "6 [SI::m*ft] = SI::metre^2"}, + {"km", "6 [SI::m*km] = SI::metre^2"}, + } { + short := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 2}, + Unit: tc.unit, + UnitTerm: metre.GetUnitTerm(), + } + got = describeQuantity(evaluate("Q::Area", short, mustEvaluateQuantity(t, srv, hash, "3.0 [SI::m]"))) + if got != tc.want { + t.Errorf("%s over a metre reduction * SI::m = %s, want %s", tc.unit, got, tc.want) + } + } + // Nor does the opaque unit merge with the resolved unit spelt the same way: + // `km**2` would read as a million square metres where the reduction has a thousand. + opaqueKm := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 2}, + Unit: "km", + UnitTerm: metre.GetUnitTerm(), + } + got = describeQuantity(evaluate("Q::Area", opaqueKm, evaluate("Q::Kilometre"))) + if got != "6 [km*km] = 1000/1·SI::metre^2" { + t.Errorf("opaque km over a metre reduction * km = %s, want 6 [km*km] = 1000/1·SI::metre^2", got) + } + + // A derived unit the model declares outside its base unit's namespace keeps + // its identity when written short: `cable` is Nautical::cable, the one unit of + // that name whose reduction is the one sent, so squared it folds to an area. + fathom := evaluate("Nautical::Fathom") + if fathom.GetUnit() != "fathom" { + t.Fatalf("a custom unit written under its package crosses the wire in %q, want fathom", fathom.GetUnit()) + } + inFull := func(unit string, term *pb.UnitTerm) *pb.Quantity { + return &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 2}, + Unit: unit, + UnitTerm: term, + } + } + cable := evaluate("Nautical::Cable") + if cable.GetUnit() != "cable" { + t.Fatalf("a custom unit written under its package crosses the wire in %q, want cable", cable.GetUnit()) + } + got = describeQuantity(evaluate("Q::Area", cable, inFull("Nautical::cable", cable.GetUnitTerm()))) + if got != "66890.1888 [SI::'m²'] = SI::metre^2" { + t.Errorf("cable * Nautical::cable over the wire = %s, want 66890.1888 [SI::'m²'] = SI::metre^2", got) + } + // Two packages declaring one short name for the same unit is an ambiguity the + // reduction cannot settle: the text stays opaque rather than picked at random. + got = describeQuantity(evaluate("Q::Area", fathom, inFull("Nautical::fathom", fathom.GetUnitTerm()))) + if got != "4 [Nautical::fathom*fathom] = 3.34450944/1·SI::metre^2" { + t.Errorf("ambiguous fathom * Nautical::fathom over the wire = %s, want 4 [Nautical::fathom*fathom] = 3.34450944/1·SI::metre^2", got) + } + // One short name written twice may name two units, each read where the + // reduction puts it: `cable*cable` over both cables is Nautical::cable times + // Imperial::cable, folding to the area their two lengths span; dividing by + // either cable written in full leaves the other's length. + imperialCable := evaluate("Imperial::Cable") + cables := evaluate("Q::Area", cable, imperialCable) + if got := describeQuantity(cables); got != "33891.028992 [SI::'m²'] = SI::metre^2" { + t.Fatalf("two cables cross the wire as %s, want 33891.028992 [SI::'m²'] = SI::metre^2", got) + } + for _, tc := range []struct { + by string + term *pb.UnitTerm + want string + }{ + {"Nautical::cable", cable.GetUnitTerm(), "92.6592 [SI::m] = SI::metre"}, + {"Imperial::cable", imperialCable.GetUnitTerm(), "91.44 [SI::m] = SI::metre"}, + } { + got = describeQuantity(evaluate("Q::Per", cables, inFull(tc.by, tc.term))) + if got != tc.want { + t.Errorf("cable*cable over the wire / %s = %s, want %s", tc.by, got, tc.want) + } + } + + // A quantity sent under no unit text is its base units, composing with the named + // units they are; under a scale it is the reduction itself, opaque. + unnamed := func(term *pb.UnitTerm) *pb.Quantity { + return &pb.Quantity{Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 2}, UnitTerm: term} + } + second := mustEvaluateQuantity(t, srv, hash, "1.0 [SI::s]") + for _, tc := range []struct { + name string + calc string + args []*pb.Quantity + want string + }{ + {"speed times seconds", "Q::Dist", []*pb.Quantity{unnamed(speed.GetUnitTerm()), second}, "2 [SI::metre] = SI::metre"}, + {"speed times a metre", "Q::Area", []*pb.Quantity{unnamed(speed.GetUnitTerm()), metre}, "4 [SI::'m²⋅s⁻¹'] = SI::metre^2·SI::second^-1"}, + {"kilometres times a metre", "Q::Area", []*pb.Quantity{unnamed(byHand.GetUnitTerm()), metre}, "4 ['1000·metre'*m] = 1000/1·SI::metre^2"}, + {"kilometres alone", "Q::Area", []*pb.Quantity{unnamed(byHand.GetUnitTerm()), unnamed(byHand.GetUnitTerm())}, "4 ['1000·metre'**2] = 1e+06/1·SI::metre^2"}, + } { + if got := describeQuantity(evaluate(tc.calc, tc.args...)); got != tc.want { + t.Errorf("%s, sent without unit text, = %s, want %s", tc.name, got, tc.want) + } + } + // A scaled ratio sent nameless names no dimension-one unit: it scales what it + // multiplies and cancels to a number, as an unnamed ratio does locally. + hundredth := unnamed(&pb.UnitTerm{ScaleNum: 1, ScaleDen: 100}) + if got := describeQuantity(evaluate("Q::Area", hundredth, metre)); got != "4 ['1/100'*m] = 1/100·SI::metre" { + t.Errorf("a nameless hundredth * m = %s, want 4 ['1/100'*m] = 1/100·SI::metre", got) + } + resp, err := srv.EvaluateCalc(context.Background(), &pb.EvaluateCalcRequest{ + ModelHash: hash, SymbolId: "Q::Area", + Arguments: []*pb.Value{{Kind: &pb.Value_Quantity{Quantity: hundredth}}, {Kind: &pb.Value_Quantity{Quantity: hundredth}}}, + }) + if err != nil || resp.Error != "" { + t.Fatalf("EvaluateCalc Q::Area over nameless hundredths: %v %q", err, resp.GetError()) + } + if realVal, ok := resp.Result.GetKind().(*pb.Value_RealValue); !ok || realVal.RealValue != 0.0004 { + t.Errorf("a nameless hundredth squared = %v, want the number 0.0004", resp.Result) + } +} + +// TestOpaqueUnitFactorsSurviveTheWire pins that a unit composed of an opaque +// factor and a unit the model declares crosses the wire with that boundary +// intact: read back, the declared unit still cancels and the opaque factor +// remains, whatever made it opaque — text that is no unit expression, a name no +// unit or two units bear, or a scaled reduction sent nameless. +func TestOpaqueUnitFactorsSurviveTheWire(t *testing.T) { + srv := mustNewService(t, 4) + modelText := `package Q { + private import ScalarValues::*; + private import SI::*; + calc def Times { in a; in b; a * b } + calc def Per { in a; in b; a / b } +} +package Nautical { + private import MeasurementReferences::*; + private import ISQ::*; + private import SI::*; + attribute 'fathom' : LengthUnit { :>> unitConversion: ConversionByConvention { :>> referenceUnit = m; :>> conversionFactor = 1.8288; } } + calc def Fathom { 1.0 [fathom] } +} +package Imperial { + private import MeasurementReferences::*; + private import ISQ::*; + private import SI::*; + attribute 'fathom' : LengthUnit { :>> unitConversion: ConversionByConvention { :>> referenceUnit = m; :>> conversionFactor = 1.8288; } } +} +` + hash := mustVerifyModel(t, srv, modelText, "opaque-factors-survive-the-wire") + evaluate := func(calc string, args ...*pb.Quantity) *pb.Quantity { + t.Helper() + req := &pb.EvaluateCalcRequest{ModelHash: hash, SymbolId: calc} + for _, arg := range args { + req.Arguments = append(req.Arguments, &pb.Value{Kind: &pb.Value_Quantity{Quantity: arg}}) + } + resp, err := srv.EvaluateCalc(context.Background(), req) + if err != nil { + t.Fatalf("EvaluateCalc %s: %v", calc, err) + } + if resp.Error != "" { + t.Fatalf("EvaluateCalc %s reported %q", calc, resp.Error) + } + if resp.Result.GetQuantity() == nil { + t.Fatalf("EvaluateCalc %s = %v, want a quantity", calc, resp.Result) + } + return resp.Result.GetQuantity() + } + quantity := func(magnitude float64, unit string, term *pb.UnitTerm) *pb.Quantity { + return &pb.Quantity{Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: magnitude}, Unit: unit, UnitTerm: term} + } + sent := func(unit string, term *pb.UnitTerm) *pb.Quantity { return quantity(6, unit, term) } + second := mustEvaluateQuantity(t, srv, hash, "1.0 [SI::s]") + metre := mustEvaluateQuantity(t, srv, hash, "1.0 [SI::m]") + speedTerm := mustEvaluateQuantity(t, srv, hash, "1.0 [SI::m] / 1.0 [SI::s]").GetUnitTerm() + kilometreTerm := mustEvaluateQuantity(t, srv, hash, "1.0 [SI::km]").GetUnitTerm() + fathomTerm := evaluate("Nautical::Fathom").GetUnitTerm() + nauticalFathom := quantity(1, "Nautical::fathom", fathomTerm) + + for _, tc := range []struct { + name string + opaque, known *pb.Quantity + composed, divided, restored string + }{ + { + "text that is no unit expression", + sent("metres per second", speedTerm), second, + "6 ['metres per second'*SI::s] = SI::metre", + "6 ['metres per second'] = SI::metre·SI::second^-1", + "1 [SI::s] = SI::second", + }, + { + "a short name no unit bears", + sent("smoot", metre.GetUnitTerm()), metre, + "6 [SI::m*smoot] = SI::metre^2", + "6 [smoot] = SI::metre", + "1 [SI::m] = SI::metre", + }, + { + "a qualified name no unit bears", + sent("Imperial::smoot", metre.GetUnitTerm()), metre, + "6 [Imperial::smoot*SI::m] = SI::metre^2", + "6 [Imperial::smoot] = SI::metre", + "1 [SI::m] = SI::metre", + }, + { + "a short name two units bear", + sent("fathom", fathomTerm), nauticalFathom, + "6 [Nautical::fathom*fathom] = 3.34450944/1·SI::metre^2", + "6 [fathom] = 3.34450944/1.8288·SI::metre", + "1 [Nautical::fathom] = 3.34450944/1.8288·SI::metre", + }, + { + "a short name spelling a keyword", + sent("then", metre.GetUnitTerm()), metre, + "6 ['then'*SI::m] = SI::metre^2", + "6 ['then'] = SI::metre", + "1 [SI::m] = SI::metre", + }, + { + "a qualified name whose segment spells a keyword", + sent("SI::then", metre.GetUnitTerm()), metre, + "6 [SI::'then'*SI::m] = SI::metre^2", + "6 [SI::'then'] = SI::metre", + "1 [SI::m] = SI::metre", + }, + { + "a qualified name led by a keyword, which is no expression", + sent("in::m", metre.GetUnitTerm()), metre, + "6 ['in::m'*SI::m] = SI::metre^2", + "6 ['in::m'] = SI::metre", + "1 [SI::m] = SI::metre", + }, + { + "a scaled reduction sent nameless", + sent("", kilometreTerm), metre, + "6 ['1000·metre'*SI::m] = 1000/1·SI::metre^2", + "6 ['1000·metre'] = 1000/1·SI::metre", + "1 [SI::m] = SI::metre", + }, + } { + t.Run(tc.name, func(t *testing.T) { + composed := evaluate("Q::Times", tc.known, tc.opaque) + if got := describeQuantity(composed); got != tc.composed { + t.Fatalf("known * opaque = %s, want %s", got, tc.composed) + } + if got := describeQuantity(evaluate("Q::Per", composed, tc.known)); got != tc.divided { + t.Errorf("(known * opaque) over the wire / known = %s, want %s", got, tc.divided) + } + // The opaque factor is one unit under one spelling: sent again, it cancels itself. + if got := describeQuantity(evaluate("Q::Per", composed, tc.opaque)); got != tc.restored { + t.Errorf("(known * opaque) over the wire / opaque = %s, want %s", got, tc.restored) + } + }) + } + + // Opaque text holding what a quoted name must escape is spelt as one name the + // notation reads back, so the declared factor beside it still cancels alone. + for _, text := range []string{"it's", `back\slash`, "metres\nper second", "metres\r\nper second", `'A/m'*m`} { + t.Run(fmt.Sprintf("opaque %q", text), func(t *testing.T) { + opaque := sent(text, speedTerm) + spelt := source.UnrestrictedNameText(text) + composed := evaluate("Q::Times", second, opaque) + if got := describeQuantity(composed); got != fmt.Sprintf("6 [%s*SI::s] = SI::metre", spelt) { + t.Fatalf("SI::s * %q = %s, want 6 [%s*SI::s] = SI::metre", text, got, spelt) + } + if got := describeQuantity(evaluate("Q::Per", composed, second)); got != fmt.Sprintf("6 [%s] = SI::metre·SI::second^-1", spelt) { + t.Errorf("(SI::s * %q) over the wire / SI::s = %s, want 6 [%s] = SI::metre·SI::second^-1", text, got, spelt) + } + if got := describeQuantity(evaluate("Q::Per", composed, opaque)); got != "1 [SI::s] = SI::second" { + t.Errorf("(SI::s * %q) over the wire / %q = %s, want 1 [SI::s] = SI::second", text, text, got) + } + }) + } + + // One text sent as two units, reducing to a metre and to a second, is two units: + // they neither merge nor cancel, and what they compose reads back as it was sent. + t.Run("one text, two reductions", func(t *testing.T) { + metres, seconds := sent("smoot", metre.GetUnitTerm()), sent("smoot", second.GetUnitTerm()) + product := evaluate("Q::Times", metres, seconds) + if got := describeQuantity(product); got != "36 [smoot*smoot] = SI::metre·SI::second" { + t.Fatalf("smoot (m) * smoot (s) = %s, want 36 [smoot*smoot] = SI::metre·SI::second", got) + } + if got := describeQuantity(evaluate("Q::Per", product, second)); got != "36 ['smoot*smoot'/SI::s] = SI::metre" { + t.Errorf("(smoot*smoot) over the wire / SI::s = %s, want 36 ['smoot*smoot'/SI::s] = SI::metre", got) + } + quotient := evaluate("Q::Per", metres, seconds) + if got := describeQuantity(quotient); got != "1 [smoot/smoot] = SI::metre·SI::second^-1" { + t.Fatalf("smoot (m) / smoot (s) = %s, want 1 [smoot/smoot] = SI::metre·SI::second^-1", got) + } + if got := describeQuantity(evaluate("Q::Times", quotient, second)); got != "1 ['smoot/smoot'*SI::s] = SI::metre" { + t.Errorf("(smoot/smoot) over the wire * SI::s = %s, want 1 ['smoot/smoot'*SI::s] = SI::metre", got) + } + }) + + // A text every name of which reads as a unit, yet contradicting the reduction + // sent, has no factor to blame: it stays one opaque unit through the wire. + contradiction := evaluate("Q::Times", sent("km", metre.GetUnitTerm()), mustEvaluateQuantity(t, srv, hash, "1.0 [SI::km]")) + if got := describeQuantity(contradiction); got != "6 [SI::km*km] = 1000/1·SI::metre^2" { + t.Fatalf("SI::km * a km reducing to a metre = %s, want 6 [SI::km*km] = 1000/1·SI::metre^2", got) + } + if got := describeQuantity(evaluate("Q::Per", contradiction, mustEvaluateQuantity(t, srv, hash, "1.0 [SI::km]"))); got != "6 ['SI::km*km'/SI::km] = SI::metre" { + t.Errorf("(SI::km * opaque km) over the wire / SI::km = %s, want 6 ['SI::km*km'/SI::km] = SI::metre", got) + } +} + +// TestQuantityFromWireRejectsUnitTextItsReductionContradicts pins that a unit +// written as one thing but reduced to another is rejected rather than read as +// the text for display and the reduction for arithmetic. +func TestQuantityFromWireRejectsUnitTextItsReductionContradicts(t *testing.T) { + srv, hash, idx, sem := mustQuantityModel(t) + seconds := mustEvaluateQuantity(t, srv, hash, "1.0 [SI::s]").GetUnitTerm() + kilograms := mustEvaluateQuantity(t, srv, hash, "1.0 [SI::kg]").GetUnitTerm() + metres := mustEvaluateQuantity(t, srv, hash, "1.0 [SI::m]").GetUnitTerm() + + for _, tc := range []struct { + name string + unit string + term *pb.UnitTerm + }{ + {"another dimension", "SI::m", seconds}, + {"a composed unit over another dimension", "SI::m/SI::s", kilograms}, + {"another scale of the same dimension", "SI::km", metres}, + {"a scale off by more than rounding", "SI::m", &pb.UnitTerm{ScaleNum: 1 + 1e-10, ScaleDen: 1, Factors: metres.GetFactors()}}, + } { + t.Run(tc.name, func(t *testing.T) { + pq := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: tc.unit, + UnitTerm: tc.term, + } + if _, err := protoconv.ProtoToQuantity(pq, idx, sem); !errors.Is(err, protoconv.ErrUnitTextMismatch) { + t.Errorf("protoconv.ProtoToQuantity(%s over %s) err = %v, want protoconv.ErrUnitTextMismatch", + tc.unit, describeUnitTerm(tc.term), err) + } + }) + } + + // A measurement scale is read from the text as a unit is, so a qualified name + // of one scale over the reduction of another, of a unit, or of a scale composed + // with a unit, contradicts it as unit text does. + celsius := mustEvaluateQuantity(t, srv, hash, "20.0 [SI::'°C_abs']").GetUnitTerm() + kelvin := mustEvaluateQuantity(t, srv, hash, "1.0 [SI::K]").GetUnitTerm() + for _, tc := range []struct { + name string + unit string + term *pb.UnitTerm + }{ + {"another scale", "Time::UTC", celsius}, + {"a unit over a scale", "SI::K", celsius}, + {"a scale over a unit", "SI::'°C_abs'", kelvin}, + {"a scale composed with a unit", "SI::'°C_abs'*SI::s", celsius}, + } { + t.Run(tc.name, func(t *testing.T) { + pq := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 20}, + Unit: tc.unit, + UnitTerm: tc.term, + } + if _, err := protoconv.ProtoToQuantity(pq, idx, sem); !errors.Is(err, protoconv.ErrUnitTextMismatch) { + t.Errorf("protoconv.ProtoToQuantity(%s over %s) err = %v, want protoconv.ErrUnitTextMismatch", + tc.unit, describeUnitTerm(tc.term), err) + } + }) + } + for _, unit := range []string{"SI::'°C_abs'", "'°C_abs'"} { + point, err := protoconv.ProtoToQuantity(&pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 20}, + Unit: unit, + UnitTerm: celsius, + }, idx, sem) + if err != nil { + t.Fatalf("protoconv.ProtoToQuantity(%s over its scale): %v", unit, err) + } + if got := point.Quantity().Unit.Product.Powers; len(got) != 1 || got[0].Unit == nil || !sem.IsMeasurementScale(got[0].Unit) { + t.Errorf("%s over its scale read as %v, want the scale by declaration", unit, point.Quantity().Unit.Product) + } + } + // A short name of a scale the reduction contradicts is opaque, as a unit's is: + // it was not certainly that scale. + for _, tc := range []struct { + unit string + term *pb.UnitTerm + }{{"UTC", celsius}, {"'°C_abs'", kelvin}} { + val, err := protoconv.ProtoToQuantity(&pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 20}, + Unit: tc.unit, + UnitTerm: tc.term, + }, idx, sem) + if err != nil { + t.Fatalf("protoconv.ProtoToQuantity(%s over %s): %v", tc.unit, describeUnitTerm(tc.term), err) + } + if got := val.Quantity().Unit.Product.Powers; len(got) != 1 || got[0].Unit != nil { + t.Errorf("%s over %s read as %v, want one opaque unit", tc.unit, describeUnitTerm(tc.term), val.Quantity().Unit.Product) + } + } + + // The same text over the reduction it does have is read, in either factor order. + agreeing := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: "SI::m/SI::s", + UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{ + {UnitId: "SI::second", Exponent: -1}, + {UnitId: "SI::metre", Exponent: 1}, + }}, + } + if _, err := protoconv.ProtoToQuantity(agreeing, idx, sem); err != nil { + t.Errorf("protoconv.ProtoToQuantity(SI::m/SI::s over metre·second^-1): %v", err) + } + + // A scale off by the rounding of composing it another way is the same scale, + // and the quantity read carries the model's own reduction, not the noisy one. + kilometres := mustEvaluateQuantity(t, srv, hash, "1.0 [SI::km]").GetUnitTerm() + noisy := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 1}, + Unit: "SI::km", + UnitTerm: &pb.UnitTerm{ScaleNum: 1000 * (1 + 0x1p-52), ScaleDen: 1, Factors: kilometres.GetFactors()}, + } + val, err := protoconv.ProtoToQuantity(noisy, idx, sem) + if err != nil { + t.Fatalf("protoconv.ProtoToQuantity(SI::km over a scale one ulp off): %v", err) + } + if got := val.Quantity().Unit.Term.Scale; got != semantics.UnitScale(1000) { + t.Errorf("SI::km read over a scale one ulp off keeps scale %v, want the model's 1000", got) + } +} + +// TestQuantityAsAnActionInput drives ExecuteAction with a quantity input: it is +// decoded against the model, and one that cannot be read is reported by name. +func TestQuantityAsAnActionInput(t *testing.T) { + srv := mustNewService(t, 4) + content := ` +package A { + private import ScalarValues::*; + + action heavier { + attribute mass : ISQ::MassValue = 1.0 [SI::kg]; + first start; + action inner { + assign mass := mass + 1.0 [SI::kg]; + } + done; + succession first start then inner; + succession first inner then done; + } +} +` + parseResp, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: content}, + ContentHash: "quantity-action-input", + }) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + for _, diag := range parseResp.Diagnostics { + if diag.Severity == "error" { + t.Fatalf("model has a diagnostic error: %s", diag.Message) + } + } + + sent := mustEvaluateQuantity(t, srv, parseResp.ModelHash, "5.0 [SI::kg]") + resp, err := srv.ExecuteAction(context.Background(), &pb.ExecuteActionRequest{ + ModelHash: parseResp.ModelHash, + ActionSymbolId: "A::heavier", + Inputs: map[string]*pb.Value{"mass": {Kind: &pb.Value_Quantity{Quantity: sent}}}, + }) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if resp.Error != "" { + t.Fatalf("ExecuteAction: %s", resp.Error) + } + want := "6 [SI::kg] = 1000/1·SI::gram" + if got := describeQuantity(resp.Outputs["mass"].GetQuantity()); got != want { + t.Errorf("output mass = %q, want %q", got, want) + } + + unreadable := &pb.Quantity{ + Magnitude: &pb.Quantity_RealMagnitude{RealMagnitude: 5}, + Unit: "Made::up", + UnitTerm: &pb.UnitTerm{ScaleNum: 1, ScaleDen: 1, Factors: []*pb.UnitFactor{{UnitId: "Made::up", Exponent: 1}}}, + } + bad, err := srv.ExecuteAction(context.Background(), &pb.ExecuteActionRequest{ + ModelHash: parseResp.ModelHash, + ActionSymbolId: "A::heavier", + Inputs: map[string]*pb.Value{"mass": {Kind: &pb.Value_Quantity{Quantity: unreadable}}}, + }) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if !strings.Contains(bad.Error, "mass") || !strings.Contains(bad.Error, "unknown base unit") { + t.Errorf("error = %q, want it to name the input and the unknown base unit", bad.Error) + } +} + +// TestQuantityInVerdict drives a constraint over quantities, whose verdict path +// reports the values it compared. +func TestQuantityInVerdict(t *testing.T) { + srv := mustNewService(t, 4) + content := ` +package V { + private import ScalarValues::*; + + part def Car { + attribute mass : ISQ::MassValue = 2500.0 [SI::kg]; + + constraint withinLimit { + mass <= 3000.0 [SI::kg] + } + } +} +` + parseResp, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: content}, + ContentHash: "quantity-verdict", + }) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + for _, diag := range parseResp.Diagnostics { + if diag.Severity == "error" { + t.Fatalf("model has a diagnostic error: %s", diag.Message) + } + } + + resp, err := srv.VerifyConstraint(context.Background(), &pb.VerifyConstraintRequest{ + ModelHash: parseResp.ModelHash, + SymbolId: "V::Car::withinLimit", + SubjectSymbolId: "V::Car", + }) + if err != nil { + t.Fatalf("VerifyConstraint: %v", err) + } + if resp.Error != "" { + t.Fatalf("VerifyConstraint: %s", resp.Error) + } + if resp.Verdict == nil || !resp.Verdict.Holds { + t.Fatalf("verdict = %v, want one that holds", resp.Verdict) + } + + // The subject's quantity feature value reads back from the verdict, which is what makes + // a verdict over quantities diagnosable from a client. + var subject *pb.Instance + for _, inst := range resp.Instances { + if inst.Id == resp.Verdict.InstanceId { + subject = inst + } + } + if subject == nil { + t.Fatalf("verdict instance %d is not in the response", resp.Verdict.InstanceId) + } + wantMass := "2500 [SI::kg] = 1000/1·SI::gram" + if got := describeQuantity(subject.FeatureValues["mass"].GetValue().GetQuantity()); got != wantMass { + t.Errorf("subject mass = %q, want %q", got, wantMass) + } +} diff --git a/internal/frontend/grpc/query.go b/internal/frontend/grpc/query.go new file mode 100644 index 0000000000..39524e6a2e --- /dev/null +++ b/internal/frontend/grpc/query.go @@ -0,0 +1,498 @@ +package grpc + +import ( + "context" + "fmt" + "math" + "slices" + "strconv" + "strings" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + corequery "github.com/Open-MBEE/OpenSysML/internal/semantic/query" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// Query property names, as the SysML v2 API & Services standard's clients write +// them. docs/reference/api.md documents what each one reports. +const ( + QueryPropID = corequery.PropertyID + QueryPropType = corequery.PropertyType + QueryPropName = corequery.PropertyName + QueryPropDeclaredName = corequery.PropertyDeclaredName + QueryPropShortName = corequery.PropertyShortName + QueryPropDeclaredShortName = corequery.PropertyDeclaredShortName + QueryPropDocumentation = corequery.PropertyDocumentation + QueryPropQualifiedName = corequery.PropertyQualifiedName + QueryPropOwner = corequery.PropertyOwner + QueryPropIsAbstract = corequery.PropertyIsAbstract + QueryPropElementType = corequery.PropertyElementType + QueryPropMultiplicityLower = corequery.PropertyMultiplicityLower + QueryPropMultiplicityUpper = corequery.PropertyMultiplicityUpper +) + +// QueryErrorKind classifies why a query could not be evaluated. Every kind fails +// the call: answering with no elements would read as "nothing matched". +type QueryErrorKind int + +const ( + // QueryErrUnknownProperty is a property no queryable property names. + QueryErrUnknownProperty QueryErrorKind = iota + 1 + // QueryErrMalformedConstraint is a constraint with no form, no operator or + // no operand. + QueryErrMalformedConstraint + // QueryErrUnorderedProperty is > or < on a property that is not ordered. + QueryErrUnorderedProperty + // QueryErrUnparsableValue is an operand the operator cannot compare. + QueryErrUnparsableValue + // QueryErrUnknownScope is a scope naming an element the model does not have. + QueryErrUnknownScope +) + +// QueryError is a query the service refuses to evaluate. It reports itself as +// INVALID_ARGUMENT, since every kind is a fault in the query, not in the model. +type QueryError struct { + Kind QueryErrorKind + Message string +} + +func (e *QueryError) Error() string { return e.Message } + +func queryErrorf(kind QueryErrorKind, format string, args ...any) *QueryError { + return &QueryError{Kind: kind, Message: fmt.Sprintf(format, args...)} +} + +// QueryPropertyNames returns every queryable property name, sorted. It is what +// an unknown-property error lists, and what docs/reference/api.md's table documents. +func QueryPropertyNames() []string { + return corequery.PropertyNames() +} + +// Query evaluates a SysML v2 API & Services Query against a parsed model. +func (s *Service) Query(ctx context.Context, req *pb.QueryRequest) (*pb.QueryResponse, error) { + if err := s.requireCapability(CapabilityQuery); err != nil { + return nil, err + } + if req.OslcQuery != "" { + if err := s.requireCapability(CapabilityOSLCQuery); err != nil { + return nil, err + } + } + cached, ok := s.cache.Get(req.ModelHash) + if !ok { + return nil, statusErrorf(connect.CodeNotFound, "model not found: %s", req.ModelHash) + } + if req.Query != nil && req.OslcQuery != "" { + return nil, statusError(connect.CodeInvalidArgument, "query and oslc_query are mutually exclusive") + } + if req.Query == nil && req.OslcQuery == "" { + return nil, queryStatus(queryErrorf(QueryErrMalformedConstraint, "query is unset")) + } + + sc := cached.SymbolContext() + defer sc.Lock()() + eval := &queryEval{sc: sc, reader: corequery.NewPropertyReader(sc.Index, sc.Resolver, sc.Semantics)} + if req.OslcQuery != "" { + parsed, err := corequery.ParseOSLC(req.OslcQuery) + if err != nil { + return nil, queryStatus(err) + } + elements, err := corequery.Evaluate(&coreQueryModel{eval: eval, cached: cached}, parsed) + if err != nil { + return nil, queryStatus(err) + } + out := make([]*pb.QueryResultElement, 0, len(elements)) + for _, element := range elements { + out = append(out, &pb.QueryResultElement{ + Id: element.ID, Type: element.Type, Properties: element.Properties, + }) + } + return &pb.QueryResponse{Elements: out}, nil + } + + // The query is judged before any element is: whether it is one the service + // can evaluate cannot depend on how many elements it happens to consider. + if err := validateConstraint(req.Query.Where); err != nil { + return nil, queryStatus(err) + } + projection, err := projectedProperties(req.Query.Select) + if err != nil { + return nil, queryStatus(err) + } + candidates, err := eval.candidates(cached, req.Query.Scope) + if err != nil { + return nil, queryStatus(err) + } + + elements := make([]*pb.QueryResultElement, 0, len(candidates)) + for _, sym := range candidates { + if !eval.matches(sym, req.Query.Where) { + continue + } + elements = append(elements, eval.project(sym, projection)) + } + return &pb.QueryResponse{Elements: elements}, nil +} + +// queryEval evaluates one query over a model's symbol context. It holds no +// element state of its own: every property is read from the index and semantic +// model on demand. +type queryEval struct { + sc *SymbolContext + reader *corequery.PropertyReader +} + +// candidates returns the elements a query considers, in declaration order: every +// loaded document of the model when the scope is empty, else each scoped element +// and everything nested inside it. +func (e *queryEval) candidates(cached *CachedModel, scope []string) ([]*symbols.Symbol, error) { + if len(scope) == 0 { + var out []*symbols.Symbol + for _, root := range cached.DocumentRoots() { + out = append(out, e.collect(root)...) + } + return out, nil + } + + var out []*symbols.Symbol + seen := make(map[*symbols.Symbol]bool) + for _, fqn := range scope { + roots := e.sc.Index.LookupQualified(fqn) + if len(roots) == 0 { + return nil, queryErrorf(QueryErrUnknownScope, + "query scope names an element the model does not have: %q", fqn) + } + for _, root := range roots { + if seen[root] { + continue + } + seen[root] = true + out = append(out, root) + for _, nested := range e.nested(root) { + if !seen[nested] { + seen[nested] = true + out = append(out, nested) + } + } + } + } + return out, nil +} + +// nested returns every element declared inside an element, in declaration +// order. It is what a scope entry expands to. +func (e *queryEval) nested(sym *symbols.Symbol) []*symbols.Symbol { + w := &elementWalk{eval: e, seen: map[*symbols.Symbol]bool{sym: true}, walked: map[*symbols.Scope]bool{}} + w.members(sym) + return w.out +} + +// collect returns every element declared in a scope tree, in declaration order, +// parents before the elements they own. +func (e *queryEval) collect(scope *symbols.Scope) []*symbols.Symbol { + w := &elementWalk{eval: e, seen: map[*symbols.Symbol]bool{}, walked: map[*symbols.Scope]bool{}} + w.scope(scope) + return w.out +} + +// elementWalk enumerates elements over the scope tree, without duplicates. +type elementWalk struct { + eval *queryEval + out []*symbols.Symbol + seen map[*symbols.Symbol]bool + walked map[*symbols.Scope]bool +} + +// scope walks a scope's members. A scope no symbol owns — a loop body, a body +// expression's parameters — is walked through, since it still declares elements. +func (w *elementWalk) scope(s *symbols.Scope) { + if s == nil || w.walked[s] { + return + } + w.walked[s] = true + for _, sym := range append(s.Members(), s.AnonymousMembers()...) { + w.visit(sym) + } + for _, child := range s.Children() { + w.scope(child) + } +} + +// visit reports an element and walks what it declares. One with no queryable +// identity is walked through but not reported: the standard identifies an +// element by `@id`, and it has none to be told apart or named by. +func (w *elementWalk) visit(sym *symbols.Symbol) { + if w.seen[sym] { + return + } + w.seen[sym] = true + if w.eval.identifies(sym) { + w.out = append(w.out, sym) + } + w.members(sym) +} + +// identifies reports whether an element's qualified name is the `@id` the +// standard's clients expect: a real qualified name that names this element back, +// so a later query may use it as a scope. +func (e *queryEval) identifies(sym *symbols.Symbol) bool { + fqn := e.sc.Index.GetFQN(sym) + if !hasQualifiedIdentity(fqn) { + return false + } + return slices.Contains(e.sc.Index.LookupQualified(fqn), sym) +} + +// hasQualifiedIdentity reports whether a qualified name identifies an element. +// An unnamed one — a doc note, an anonymous usage — has an empty segment, so its +// name is neither unique nor a name a scope could use. +func hasQualifiedIdentity(fqn string) bool { + if fqn == "" { + return false + } + for _, segment := range strings.Split(fqn, "::") { + if segment == "" { + return false + } + } + return true +} + +// members walks what an element declares. A library element restored from cache +// owns no scope, so its members are reached through the index by qualified name. +func (w *elementWalk) members(sym *symbols.Symbol) { + if sym.Scope != nil { + w.scope(sym.Scope) + return + } + for _, child := range w.eval.sc.Index.LookupDirectChildren(w.eval.sc.Index.GetFQN(sym)) { + w.visit(child) + } +} + +// projectedProperties returns the properties to report, validating each name. +// An empty selection reports every property. +func projectedProperties(selected []string) ([]string, error) { + if len(selected) == 0 { + return QueryPropertyNames(), nil + } + out := make([]string, 0, len(selected)) + for _, name := range selected { + if !corequery.IsProperty(name) { + return nil, unknownProperty(name) + } + out = append(out, name) + } + return out, nil +} + +func unknownProperty(name string) *QueryError { + return queryErrorf(QueryErrUnknownProperty, + "unknown query property %q; queryable properties are %s", + name, strings.Join(QueryPropertyNames(), ", ")) +} + +// project builds the record of a matched element: its identity and type always, +// plus the selected properties it has. +func (e *queryEval) project(sym *symbols.Symbol, selected []string) *pb.QueryResultElement { + element := &pb.QueryResultElement{ + Id: e.sc.Index.GetFQN(sym), + Type: corequery.MetamodelTypeNameOf(sym), + Properties: make(map[string]string, len(selected)), + } + for _, name := range selected { + if values, ok := e.reader.Values(sym, name); ok && len(values) != 0 { + element.Properties[name] = values[0] + } + } + return element +} + +// validateConstraint reports whether a constraint is one the service can +// evaluate at all: every fault in a query is found here, before any element is +// read, so an empty scope cannot turn a malformed query into "nothing matched". +func validateConstraint(constraint *pb.Constraint) error { + if constraint == nil { + return nil // a query with no `where` selects its whole scope + } + switch form := constraint.Constraint.(type) { + case *pb.Constraint_Primitive: + return validatePrimitive(form.Primitive) + case *pb.Constraint_Composite: + return validateComposite(form.Composite) + default: + return queryErrorf(QueryErrMalformedConstraint, + "constraint is neither a PrimitiveConstraint nor a CompositeConstraint") + } +} + +// validatePrimitive checks a comparison names a queryable property, has an +// operator, and has operands that operator can compare. +func validatePrimitive(c *pb.PrimitiveConstraint) error { + if c == nil { + return queryErrorf(QueryErrMalformedConstraint, "PrimitiveConstraint is unset") + } + if !corequery.IsProperty(c.Property) { + return unknownProperty(c.Property) + } + if len(c.Value) == 0 { + return queryErrorf(QueryErrMalformedConstraint, + "PrimitiveConstraint on %q has no value to compare against", c.Property) + } + switch c.Operator { + case pb.PrimitiveOperator_PRIMITIVE_OPERATOR_EQUAL: + return nil + case pb.PrimitiveOperator_PRIMITIVE_OPERATOR_GREATER, + pb.PrimitiveOperator_PRIMITIVE_OPERATOR_LESS: + return validateOrdered(c) + default: + return queryErrorf(QueryErrMalformedConstraint, + "PrimitiveConstraint on %q has no operator", c.Property) + } +} + +// validateComposite checks a combination has an operator, something to combine, +// and no malformed constraint anywhere beneath it, decisive or not. +func validateComposite(c *pb.CompositeConstraint) error { + if c == nil { + return queryErrorf(QueryErrMalformedConstraint, "CompositeConstraint is unset") + } + if len(c.Constraint) == 0 { + return queryErrorf(QueryErrMalformedConstraint, + "CompositeConstraint has no constraints to combine") + } + switch c.Operator { + case pb.CompositeOperator_COMPOSITE_OPERATOR_AND, pb.CompositeOperator_COMPOSITE_OPERATOR_OR: + default: + return queryErrorf(QueryErrMalformedConstraint, "CompositeConstraint has no operator") + } + for _, nested := range c.Constraint { + if err := validateConstraint(nested); err != nil { + return err + } + } + return nil +} + +// matches reports whether an element satisfies a constraint, which +// validateConstraint has already found evaluable. An unset constraint matches +// every element, as a query with no `where` selects its whole scope. +func (e *queryEval) matches(sym *symbols.Symbol, constraint *pb.Constraint) bool { + if constraint == nil { + return true + } + switch form := constraint.Constraint.(type) { + case *pb.Constraint_Primitive: + return e.matchesPrimitive(sym, form.Primitive) + case *pb.Constraint_Composite: + return e.matchesComposite(sym, form.Composite) + default: + return true + } +} + +// matchesPrimitive compares one property of an element, negating the verdict +// when the constraint is inverse. +func (e *queryEval) matchesPrimitive(sym *symbols.Symbol, c *pb.PrimitiveConstraint) bool { + values, has := e.reader.Values(sym, c.Property) + value := "" + if len(values) != 0 { + value = values[0] + } + var verdict bool + if c.Operator == pb.PrimitiveOperator_PRIMITIVE_OPERATOR_EQUAL { + verdict = has && equalsAny(value, c.Value) + } else { + verdict = has && compareOrdered(c, value) + } + if c.Inverse { + return !verdict + } + return verdict +} + +// equalsAny reports whether the property's value is one of the constraint's. +// The standard writes a single value, and its clients also write a list for +// `@type`; one value is the degenerate case of the same rule. +func equalsAny(value string, candidates []string) bool { + for _, candidate := range candidates { + if value == candidate { + return true + } + } + return false +} + +// validateOrdered checks > or < has an ordered property and one numeric operand: +// either fault is a fault in the query, never a false verdict. +func validateOrdered(c *pb.PrimitiveConstraint) error { + if !corequery.IsOrdered(c.Property) { + return queryErrorf(QueryErrUnorderedProperty, + "query property %q is not ordered, so %s cannot compare it", + c.Property, operatorText(c.Operator)) + } + if len(c.Value) != 1 { + return queryErrorf(QueryErrMalformedConstraint, + "%s on %q compares against exactly one value, got %d", + operatorText(c.Operator), c.Property, len(c.Value)) + } + if _, err := parseOrdered(c.Value[0]); err != nil { + return queryErrorf(QueryErrUnparsableValue, + "%s on %q needs a number to compare against, got %q", + operatorText(c.Operator), c.Property, c.Value[0]) + } + return nil +} + +// compareOrdered evaluates > or < against an element's own value. A value that +// is not a number fails the comparison: that is a fact about the element. +func compareOrdered(c *pb.PrimitiveConstraint, value string) bool { + operand, err := parseOrdered(c.Value[0]) + if err != nil { + return false + } + actual, err := parseOrdered(value) + if err != nil { + return false + } + if c.Operator == pb.PrimitiveOperator_PRIMITIVE_OPERATOR_GREATER { + return actual > operand + } + return actual < operand +} + +// parseOrdered reads a number, accepting the unbounded multiplicity "*" as the +// infinity it denotes. +func parseOrdered(text string) (float64, error) { + if text == "*" { + return math.Inf(1), nil + } + return strconv.ParseFloat(text, 64) +} + +// operatorText renders an operator as the standard writes it. +func operatorText(op pb.PrimitiveOperator) string { + switch op { + case pb.PrimitiveOperator_PRIMITIVE_OPERATOR_EQUAL: + return "=" + case pb.PrimitiveOperator_PRIMITIVE_OPERATOR_GREATER: + return ">" + case pb.PrimitiveOperator_PRIMITIVE_OPERATOR_LESS: + return "<" + default: + return "(no operator)" + } +} + +// matchesComposite combines the verdicts of nested constraints, short-circuiting +// once one is decisive. +func (e *queryEval) matchesComposite(sym *symbols.Symbol, c *pb.CompositeConstraint) bool { + and := c.Operator == pb.CompositeOperator_COMPOSITE_OPERATOR_AND + for _, nested := range c.Constraint { + if e.matches(sym, nested) != and { + return !and + } + } + return and +} diff --git a/internal/frontend/grpc/query_test.go b/internal/frontend/grpc/query_test.go new file mode 100644 index 0000000000..ce04f90fef --- /dev/null +++ b/internal/frontend/grpc/query_test.go @@ -0,0 +1,628 @@ +package grpc + +import ( + "context" + "errors" + "slices" + "testing" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + corequery "github.com/Open-MBEE/OpenSysML/internal/semantic/query" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// queryModel is the model the query tests run against: parts and attributes at +// two levels, with a multiplicity to compare and an abstract definition. +const queryModel = ` +package Demo { + abstract part def Vehicle { + attribute mass; + } + part def Wheel; + part vehicle : Vehicle { + part wheels : Wheel[4] { + doc /* Four road wheels, + * one per corner. */ + } + attribute vin; + } + part spare : Wheel; +} +` + +// runQuery parses queryModel and runs one query over it. +func runQuery(t *testing.T, query *pb.Query) (*pb.QueryResponse, error) { + t.Helper() + srv := mustNewService(t, 10) + parsed, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: queryModel}, + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + return srv.Query(context.Background(), &pb.QueryRequest{ + ModelHash: parsed.ModelHash, + Query: query, + }) +} + +// mustRunQuery runs a query that is expected to succeed and returns the +// qualified names it selected. +func mustRunQuery(t *testing.T, query *pb.Query) []string { + t.Helper() + resp, err := runQuery(t, query) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + ids := make([]string, 0, len(resp.Elements)) + for _, element := range resp.Elements { + ids = append(ids, element.Id) + } + return ids +} + +// primitive builds a PrimitiveConstraint as a Constraint. +func primitive(property string, op pb.PrimitiveOperator, inverse bool, values ...string) *pb.Constraint { + return &pb.Constraint{Constraint: &pb.Constraint_Primitive{ + Primitive: &pb.PrimitiveConstraint{ + Property: property, + Operator: op, + Inverse: inverse, + Value: values, + }, + }} +} + +// composite builds a CompositeConstraint as a Constraint. +func composite(op pb.CompositeOperator, nested ...*pb.Constraint) *pb.Constraint { + return &pb.Constraint{Constraint: &pb.Constraint_Composite{ + Composite: &pb.CompositeConstraint{Operator: op, Constraint: nested}, + }} +} + +const opEqual = pb.PrimitiveOperator_PRIMITIVE_OPERATOR_EQUAL + +// TestQueryCapabilityIsReported verifies a client can require the Query RPC by +// capability rather than by version. +func TestQueryCapabilityIsReported(t *testing.T) { + srv := mustNewService(t, 10) + info, err := srv.GetServerInfo(context.Background(), &pb.ServerInfoRequest{}) + if err != nil { + t.Fatalf("GetServerInfo failed: %v", err) + } + if !slices.Contains(info.Capabilities, CapabilityQuery) { + t.Errorf("capabilities = %v, want it to contain %q", info.Capabilities, CapabilityQuery) + } + if !slices.Contains(info.Capabilities, CapabilityOSLCQuery) { + t.Errorf("capabilities = %v, want it to contain %q", info.Capabilities, CapabilityOSLCQuery) + } +} + +// TestQueryByTypeSelectsThatMetamodelType verifies the cookbook payload shape: +// `@type` = ["PartUsage"] over the whole model. +func TestQueryByTypeSelectsThatMetamodelType(t *testing.T) { + ids := mustRunQuery(t, &pb.Query{ + Where: primitive(QueryPropType, opEqual, false, "PartUsage"), + }) + want := []string{"Demo::vehicle", "Demo::vehicle::wheels", "Demo::spare"} + if !slices.Equal(ids, want) { + t.Errorf("part usages = %v, want %v", ids, want) + } +} + +// TestQueryWithoutWhereSelectsWholeScope verifies an absent constraint filters +// nothing, and that enumeration reaches nested elements. +func TestQueryWithoutWhereSelectsWholeScope(t *testing.T) { + ids := mustRunQuery(t, &pb.Query{}) + want := []string{ + "Demo", + "Demo::Vehicle", + "Demo::Vehicle::mass", + "Demo::Wheel", + "Demo::vehicle", + "Demo::vehicle::wheels", + "Demo::vehicle::vin", + "Demo::spare", + } + if !slices.Equal(ids, want) { + t.Errorf("elements = %v, want %v", ids, want) + } +} + +// TestQueryScopeRestrictsToAnElementAndItsNested verifies a scope considers the +// named element and everything nested inside it, and nothing else. +func TestQueryScopeRestrictsToAnElementAndItsNested(t *testing.T) { + ids := mustRunQuery(t, &pb.Query{Scope: []string{"Demo::vehicle"}}) + want := []string{"Demo::vehicle", "Demo::vehicle::wheels", "Demo::vehicle::vin"} + if !slices.Equal(ids, want) { + t.Errorf("scoped elements = %v, want %v", ids, want) + } +} + +// TestQueryScopeAndWhereCombine verifies a scope and a constraint apply +// together. +func TestQueryScopeAndWhereCombine(t *testing.T) { + ids := mustRunQuery(t, &pb.Query{ + Scope: []string{"Demo::vehicle"}, + Where: primitive(QueryPropType, opEqual, false, "PartUsage"), + }) + want := []string{"Demo::vehicle", "Demo::vehicle::wheels"} + if !slices.Equal(ids, want) { + t.Errorf("scoped part usages = %v, want %v", ids, want) + } +} + +// TestQueryUnknownScopeFails verifies a scope naming an element the model does +// not have fails the call rather than answering with nothing. +func TestQueryUnknownScopeFails(t *testing.T) { + _, err := runQuery(t, &pb.Query{Scope: []string{"Demo::Missing"}}) + assertQueryError(t, err, QueryErrUnknownScope) +} + +// TestQuerySelectProjectsOnlyThoseProperties verifies `select` projects the +// named properties, while identity and type stay reported. +func TestQuerySelectProjectsOnlyThoseProperties(t *testing.T) { + resp, err := runQuery(t, &pb.Query{ + Select: []string{QueryPropName, QueryPropOwner}, + Where: primitive(QueryPropQualifiedName, opEqual, false, "Demo::vehicle::wheels"), + }) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + if len(resp.Elements) != 1 { + t.Fatalf("elements = %d, want 1", len(resp.Elements)) + } + element := resp.Elements[0] + if element.Id != "Demo::vehicle::wheels" || element.Type != "PartUsage" { + t.Errorf("element = %q (%s), want Demo::vehicle::wheels (PartUsage)", element.Id, element.Type) + } + want := map[string]string{QueryPropName: "wheels", QueryPropOwner: "Demo::vehicle"} + if len(element.Properties) != len(want) { + t.Fatalf("properties = %v, want only %v", element.Properties, want) + } + for name, value := range want { + if element.Properties[name] != value { + t.Errorf("properties[%q] = %q, want %q", name, element.Properties[name], value) + } + } +} + +// TestQuerySelectReportsEveryPropertyByDefault verifies an empty selection +// reports every queryable property the element has, and omits those it lacks. +func TestQuerySelectReportsEveryPropertyByDefault(t *testing.T) { + resp, err := runQuery(t, &pb.Query{ + Where: primitive(QueryPropQualifiedName, opEqual, false, "Demo::vehicle::wheels"), + }) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + if len(resp.Elements) != 1 { + t.Fatalf("elements = %d, want 1", len(resp.Elements)) + } + props := resp.Elements[0].Properties + for name, want := range map[string]string{ + QueryPropID: "Demo::vehicle::wheels", + QueryPropType: "PartUsage", + QueryPropName: "wheels", + QueryPropDeclaredName: "wheels", + QueryPropShortName: "W", + QueryPropDeclaredShortName: "W", + QueryPropDocumentation: "Four road wheels,\none per corner.", + QueryPropQualifiedName: "Demo::vehicle::wheels", + QueryPropOwner: "Demo::vehicle", + QueryPropIsAbstract: "false", + QueryPropElementType: "Demo::Wheel", + QueryPropMultiplicityLower: "4", + QueryPropMultiplicityUpper: "4", + } { + if props[name] != want { + t.Errorf("properties[%q] = %q, want %q", name, props[name], want) + } + } +} + +// TestQueryOmitsPropertiesAnElementDoesNotHave verifies a property with no +// value is absent from the record rather than reported empty. +func TestQueryOmitsPropertiesAnElementDoesNotHave(t *testing.T) { + resp, err := runQuery(t, &pb.Query{ + Where: primitive(QueryPropQualifiedName, opEqual, false, "Demo"), + }) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + if len(resp.Elements) != 1 { + t.Fatalf("elements = %d, want 1", len(resp.Elements)) + } + props := resp.Elements[0].Properties + for _, absent := range []string{QueryPropOwner, QueryPropIsAbstract, QueryPropMultiplicityLower, QueryPropShortName, QueryPropDocumentation} { + if value, ok := props[absent]; ok { + t.Errorf("properties[%q] = %q, want it absent for a top-level package", absent, value) + } + } +} + +// TestQuerySelectUnknownPropertyFails verifies projecting an unknown property +// fails the call. +func TestQuerySelectUnknownPropertyFails(t *testing.T) { + _, err := runQuery(t, &pb.Query{Select: []string{"effectiveName"}}) + assertQueryError(t, err, QueryErrUnknownProperty) +} + +// TestQueryUnknownPropertyFailsRatherThanMatchingNothing verifies an unknown +// property in a constraint is an error, not an empty result. +func TestQueryUnknownPropertyFailsRatherThanMatchingNothing(t *testing.T) { + _, err := runQuery(t, &pb.Query{ + Where: primitive("colour", opEqual, false, "red"), + }) + assertQueryError(t, err, QueryErrUnknownProperty) + if got := connect.CodeOf(err); got != connect.CodeInvalidArgument { + t.Errorf("status code = %s, want %s", got, connect.CodeInvalidArgument) + } +} + +// TestQueryInverseNegatesTheVerdict verifies `inverse` selects exactly the +// complement of the constraint within the same scope. +func TestQueryInverseNegatesTheVerdict(t *testing.T) { + plain := mustRunQuery(t, &pb.Query{ + Scope: []string{"Demo"}, + Where: primitive(QueryPropType, opEqual, false, "PartUsage"), + }) + inverse := mustRunQuery(t, &pb.Query{ + Scope: []string{"Demo"}, + Where: primitive(QueryPropType, opEqual, true, "PartUsage"), + }) + all := mustRunQuery(t, &pb.Query{Scope: []string{"Demo"}}) + if len(plain)+len(inverse) != len(all) { + t.Errorf("plain %v plus inverse %v does not partition %v", plain, inverse, all) + } + for _, id := range inverse { + if slices.Contains(plain, id) { + t.Errorf("%q matched both the constraint and its inverse", id) + } + } +} + +// TestQueryEqualMatchesAnyOfAListedValue verifies `=` against a list, which is +// how the standard's clients write a `@type` filter. +func TestQueryEqualMatchesAnyOfAListedValue(t *testing.T) { + ids := mustRunQuery(t, &pb.Query{ + Where: primitive(QueryPropType, opEqual, false, "PartDefinition", "AttributeUsage"), + }) + want := []string{"Demo::Vehicle", "Demo::Vehicle::mass", "Demo::Wheel", "Demo::vehicle::vin"} + if !slices.Equal(ids, want) { + t.Errorf("elements = %v, want %v", ids, want) + } +} + +// TestQueryCompositeNesting verifies and/or nesting, including a composite +// inside a composite. +func TestQueryCompositeNesting(t *testing.T) { + ids := mustRunQuery(t, &pb.Query{ + Where: composite(pb.CompositeOperator_COMPOSITE_OPERATOR_AND, + primitive(QueryPropType, opEqual, false, "PartUsage"), + composite(pb.CompositeOperator_COMPOSITE_OPERATOR_OR, + primitive(QueryPropName, opEqual, false, "wheels"), + primitive(QueryPropName, opEqual, false, "spare"), + ), + ), + }) + want := []string{"Demo::vehicle::wheels", "Demo::spare"} + if !slices.Equal(ids, want) { + t.Errorf("elements = %v, want %v", ids, want) + } +} + +// TestQueryOrderedComparisonOnMultiplicity verifies > and < on the one ordered +// property pair, and that a bound of a usage that declares none does not match. +func TestQueryOrderedComparisonOnMultiplicity(t *testing.T) { + greater := mustRunQuery(t, &pb.Query{ + Where: primitive(QueryPropMultiplicityLower, + pb.PrimitiveOperator_PRIMITIVE_OPERATOR_GREATER, false, "1"), + }) + if !slices.Equal(greater, []string{"Demo::vehicle::wheels"}) { + t.Errorf("multiplicityLower > 1 = %v, want [Demo::vehicle::wheels]", greater) + } + less := mustRunQuery(t, &pb.Query{ + Where: primitive(QueryPropMultiplicityUpper, + pb.PrimitiveOperator_PRIMITIVE_OPERATOR_LESS, false, "2"), + }) + if len(less) != 0 { + t.Errorf("multiplicityUpper < 2 = %v, want no elements", less) + } +} + +// TestQueryOrderedComparisonOnUnorderedPropertyFails verifies > on a property +// that is not ordered is an error rather than a false verdict. +func TestQueryOrderedComparisonOnUnorderedPropertyFails(t *testing.T) { + _, err := runQuery(t, &pb.Query{ + Where: primitive(QueryPropName, pb.PrimitiveOperator_PRIMITIVE_OPERATOR_GREATER, false, "a"), + }) + assertQueryError(t, err, QueryErrUnorderedProperty) +} + +// TestQueryOrderedComparisonAgainstNonNumberFails verifies an operand that is +// not a number is an error rather than a false verdict. +func TestQueryOrderedComparisonAgainstNonNumberFails(t *testing.T) { + _, err := runQuery(t, &pb.Query{ + Where: primitive(QueryPropMultiplicityLower, + pb.PrimitiveOperator_PRIMITIVE_OPERATOR_LESS, false, "many"), + }) + assertQueryError(t, err, QueryErrUnparsableValue) +} + +// TestQueryOrderedComparisonNeedsOneOperand verifies > and < reject a value +// list, which they have no comparison for. +func TestQueryOrderedComparisonNeedsOneOperand(t *testing.T) { + _, err := runQuery(t, &pb.Query{ + Where: primitive(QueryPropMultiplicityLower, + pb.PrimitiveOperator_PRIMITIVE_OPERATOR_GREATER, false, "1", "2"), + }) + assertQueryError(t, err, QueryErrMalformedConstraint) +} + +// TestQueryMalformedConstraintsFail verifies every shape a query can be broken +// in fails the call, naming what is wrong. +func TestQueryMalformedConstraintsFail(t *testing.T) { + cases := map[string]*pb.Constraint{ + "no form": {}, + "unset primitive": {Constraint: &pb.Constraint_Primitive{}}, + "unset composite": {Constraint: &pb.Constraint_Composite{}}, + "no operator": primitive(QueryPropName, pb.PrimitiveOperator_PRIMITIVE_OPERATOR_UNSPECIFIED, false, "vehicle"), + "no value": primitive(QueryPropName, opEqual, false), + "empty composite": composite(pb.CompositeOperator_COMPOSITE_OPERATOR_AND), + "composite operator": {Constraint: &pb.Constraint_Composite{Composite: &pb.CompositeConstraint{Constraint: []*pb.Constraint{primitive(QueryPropName, opEqual, false, "vehicle")}}}}, + } + for name, constraint := range cases { + t.Run(name, func(t *testing.T) { + _, err := runQuery(t, &pb.Query{Where: constraint}) + assertQueryError(t, err, QueryErrMalformedConstraint) + }) + } +} + +// TestQueryFaultIsReportedWithNoElementsToConsider verifies a query is judged +// before any element is: over a model that declares nothing, an invalid query +// still fails rather than reading as "nothing matched". +func TestQueryFaultIsReportedWithNoElementsToConsider(t *testing.T) { + srv := mustNewService(t, 10) + parsed, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: "// nothing but a comment\n"}, + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + empty, err := srv.Query(context.Background(), &pb.QueryRequest{ + ModelHash: parsed.ModelHash, + Query: &pb.Query{}, + }) + if err != nil { + t.Fatalf("Query over an empty model failed: %v", err) + } + if len(empty.Elements) != 0 { + t.Fatalf("elements = %v, want the model to declare none", empty.Elements) + } + + cases := map[string]struct { + where *pb.Constraint + want QueryErrorKind + }{ + "unknown property": {primitive("colour", opEqual, false, "red"), QueryErrUnknownProperty}, + "no operator": {primitive(QueryPropName, pb.PrimitiveOperator_PRIMITIVE_OPERATOR_UNSPECIFIED, false, "x"), QueryErrMalformedConstraint}, + "empty composite": {composite(pb.CompositeOperator_COMPOSITE_OPERATOR_OR), QueryErrMalformedConstraint}, + "unordered property": {primitive(QueryPropName, pb.PrimitiveOperator_PRIMITIVE_OPERATOR_GREATER, false, "1"), QueryErrUnorderedProperty}, + "unparsable operand": {primitive(QueryPropMultiplicityUpper, pb.PrimitiveOperator_PRIMITIVE_OPERATOR_LESS, false, "many"), QueryErrUnparsableValue}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := srv.Query(context.Background(), &pb.QueryRequest{ + ModelHash: parsed.ModelHash, + Query: &pb.Query{Where: tc.where}, + }) + assertQueryError(t, err, tc.want) + }) + } +} + +// TestQueryFaultUnderADecisiveConstraintIsReported verifies a malformed nested +// constraint fails the call even when a sibling already decides the verdict. +func TestQueryFaultUnderADecisiveConstraintIsReported(t *testing.T) { + _, err := runQuery(t, &pb.Query{ + Where: composite(pb.CompositeOperator_COMPOSITE_OPERATOR_OR, + primitive(QueryPropType, opEqual, false, "PartUsage"), + primitive("colour", opEqual, false, "red")), + }) + assertQueryError(t, err, QueryErrUnknownProperty) +} + +// TestQueryUnsetQueryFails verifies a request with no query at all fails. +func TestQueryUnsetQueryFails(t *testing.T) { + srv := mustNewService(t, 10) + parsed, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: queryModel}, + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + _, err = srv.Query(context.Background(), &pb.QueryRequest{ModelHash: parsed.ModelHash}) + assertQueryError(t, err, QueryErrMalformedConstraint) +} + +// TestQueryMatchingNothingIsEmptyNotAnError verifies a well-formed query that +// selects nothing answers with no elements. +func TestQueryMatchingNothingIsEmptyNotAnError(t *testing.T) { + resp, err := runQuery(t, &pb.Query{ + Where: primitive(QueryPropName, opEqual, false, "Nonexistent"), + }) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + if len(resp.Elements) != 0 { + t.Errorf("elements = %v, want none", resp.Elements) + } +} + +// TestQueryUnknownModelFails verifies a model the cache no longer holds is +// reported as NOT_FOUND rather than as an empty answer. +func TestQueryUnknownModelFails(t *testing.T) { + srv := mustNewService(t, 10) + _, err := srv.Query(context.Background(), &pb.QueryRequest{ + ModelHash: "nosuchmodel", + Query: &pb.Query{}, + }) + if got := connect.CodeOf(err); got != connect.CodeNotFound { + t.Fatalf("status code = %s, want %s (err: %v)", got, connect.CodeNotFound, err) + } +} + +// TestQueryIsAbstractSelectsAbstractDefinitions verifies the boolean property +// compares as the text of the boolean. +func TestQueryIsAbstractSelectsAbstractDefinitions(t *testing.T) { + ids := mustRunQuery(t, &pb.Query{ + Where: primitive(QueryPropIsAbstract, opEqual, false, "true"), + }) + if !slices.Equal(ids, []string{"Demo::Vehicle"}) { + t.Errorf("abstract elements = %v, want [Demo::Vehicle]", ids) + } +} + +// TestQueryTypePropertyReportsResolvedType verifies `type` reports the resolved +// type's qualified name, which is how a client follows a usage to its +// definition. +func TestQueryTypePropertyReportsResolvedType(t *testing.T) { + ids := mustRunQuery(t, &pb.Query{ + Where: primitive(QueryPropElementType, opEqual, false, "Demo::Wheel"), + }) + want := []string{"Demo::vehicle::wheels", "Demo::spare"} + if !slices.Equal(ids, want) { + t.Errorf("elements typed by Demo::Wheel = %v, want %v", ids, want) + } +} + +// TestQueryScopeMayNameALibraryElement verifies a scope is not restricted to +// the parsed document: the loaded standard library is queryable too. +func TestQueryScopeMayNameALibraryElement(t *testing.T) { + ids := mustRunQuery(t, &pb.Query{ + Scope: []string{"ScalarValues"}, + Where: primitive(QueryPropName, opEqual, false, "Real"), + }) + if !slices.Contains(ids, "ScalarValues::Real") { + t.Errorf("elements = %v, want it to contain ScalarValues::Real", ids) + } +} + +// TestQueryOmitsElementsWithNoQualifiedIdentity verifies an unnamed element — a +// doc note, an anonymous usage — is not reported: its qualified name has an +// empty segment, so it is neither unique nor a name a scope could use. +func TestQueryOmitsElementsWithNoQualifiedIdentity(t *testing.T) { + const model = ` +package Anon { + doc /* the package's documentation, which is unnamed */ + part def Rig; + part def Motor; + part rig : Rig { + part : Motor; + } + connect rig to rig; +} +` + srv := mustNewService(t, 10) + parsed, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: model}, + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + resp, err := srv.Query(context.Background(), &pb.QueryRequest{ + ModelHash: parsed.ModelHash, + Query: &pb.Query{}, + }) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + + seen := make(map[string]bool, len(resp.Elements)) + for _, element := range resp.Elements { + if !hasQualifiedIdentity(element.Id) { + t.Errorf("element id = %q, want every reported element to have a qualified name", element.Id) + } + if seen[element.Id] { + t.Errorf("element id %q was reported twice, so it identifies no element", element.Id) + } + seen[element.Id] = true + } + for _, want := range []string{"Anon", "Anon::Rig", "Anon::rig"} { + if !seen[want] { + t.Errorf("elements = %v, want it to contain %s", resp.Elements, want) + } + } +} + +// TestQueryOmitsBodyLocalDeclarations verifies an element declared inside an +// action body is not reported: the scope that declares it is owned by no symbol, +// so its qualified name is a bare local one that names no element back. +func TestQueryOmitsBodyLocalDeclarations(t *testing.T) { + const model = ` +package Demo { + action def Drive { + if true { action step; } else { action step; } + } + action step; +} +` + srv := mustNewService(t, 10) + parsed, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: model}, + }) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + resp, err := srv.Query(context.Background(), &pb.QueryRequest{ + ModelHash: parsed.ModelHash, + Query: &pb.Query{}, + }) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + + var ids []string + for _, element := range resp.Elements { + ids = append(ids, element.Id) + } + want := []string{"Demo", "Demo::Drive", "Demo::step"} + if !slices.Equal(ids, want) { + t.Errorf("element ids = %v, want %v", ids, want) + } +} + +// TestMetamodelTypeNameCoversEveryKind verifies the mapping is total over the +// kinds a parsed declaration can have. +func TestMetamodelTypeNameCoversEveryKind(t *testing.T) { + for kind := symbols.SymbolPackage; kind <= symbols.SymbolConnectorEnd; kind++ { + if corequery.MetamodelTypeName(kind) == "" { + t.Errorf("symbol kind %q has no metamodel type name", kind) + } + } + if corequery.MetamodelTypeName(symbols.SymbolUnknown) != "" { + t.Error("an unclassified declaration must have no metamodel type name") + } +} + +// assertQueryError verifies a query failed with the expected kind of typed +// error, reported as INVALID_ARGUMENT. +func assertQueryError(t *testing.T, err error, want QueryErrorKind) { + t.Helper() + if err == nil { + t.Fatalf("query succeeded, want a %v error", want) + } + var qerr *QueryError + if !errors.As(err, &qerr) { + t.Fatalf("error %v is not a *QueryError", err) + } + if qerr.Kind != want { + t.Errorf("error kind = %v, want %v (message: %s)", qerr.Kind, want, qerr.Message) + } + if got := connect.CodeOf(err); got != connect.CodeInvalidArgument { + t.Errorf("status code = %s, want %s", got, connect.CodeInvalidArgument) + } +} diff --git a/internal/frontend/grpc/related_column_test.go b/internal/frontend/grpc/related_column_test.go new file mode 100644 index 0000000000..1b9cd6bfa2 --- /dev/null +++ b/internal/frontend/grpc/related_column_test.go @@ -0,0 +1,76 @@ +package grpc + +import ( + "context" + "strings" + "testing" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +// traceMatrixFixture declares a requirement matrix whose columns are +// relationship-derived: satisfier and verifier lists, a count and a flag. +const traceMatrixFixture = "../../doc/queryexec/testdata/trace_matrix.sysml" + +// elementIDs spells a cell's element values by qualified name. +func elementIDs(cell *pb.DocumentQueryCell) []string { + ids := make([]string, 0, len(cell.Values)) + for _, value := range cell.Values { + ids = append(ids, value.GetElementId()) + } + return ids +} + +// TestRunDocumentQueryCarriesRelatedColumns: a RelatedColumn list is answered as +// every related element in traversal order, an empty list as an empty cell, +// and the count and any aggregates as an integer and a Boolean. +func TestRunDocumentQueryCarriesRelatedColumns(t *testing.T) { + srv := mustNewService(t, 10) + hash := parseFixture(t, srv, traceMatrixFixture) + + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Observatory::Matrix", + Bindings: []*pb.DocumentQueryBinding{binding("root", element("Observatory"))}, + }) + if err != nil { + t.Fatalf("RunDocumentQuery: %v", err) + } + var columns []string + for _, column := range resp.Columns { + columns = append(columns, column.Name) + } + if got := strings.Join(columns, ","); got != "name,satisfiedBy,verifiedBy,verifications,verified" { + t.Fatalf("columns = %s", got) + } + if len(resp.Rows) != 3 { + t.Fatalf("rows = %d, want 3", len(resp.Rows)) + } + + mass := resp.Rows[0] + if got := strings.Join(elementIDs(mass.Cells[1]), ","); got != "Observatory::telescope,Observatory::groundStation" { + t.Errorf("mass satisfiedBy = %s", got) + } + if got := mass.Cells[1].Values[0].GetElementType(); got != "PartUsage" { + t.Errorf("satisfier type = %s, want PartUsage", got) + } + if got := strings.Join(elementIDs(mass.Cells[2]), ","); got != "Observatory::massVerification,Observatory::pointingVerification" { + t.Errorf("mass verifiedBy = %s", got) + } + if got := mass.Cells[3].Values; len(got) != 1 || got[0].GetIntValue() != 2 { + t.Errorf("mass verifications = %v, want 2", got) + } + if got := mass.Cells[4].Values; len(got) != 1 || !got[0].GetBoolValue() { + t.Errorf("mass verified = %v, want true", got) + } + + data := resp.Rows[2] + if len(data.Cells[1].Values) != 0 || len(data.Cells[2].Values) != 0 { + t.Errorf("data requirement related cells = %v %v, want empty", data.Cells[1].Values, data.Cells[2].Values) + } + if got := data.Cells[3].Values; len(got) != 1 || got[0].GetIntValue() != 0 { + t.Errorf("data verifications = %v, want 0", got) + } + if got := data.Cells[4].Values; len(got) != 1 || got[0].GetBoolValue() { + t.Errorf("data verified = %v, want false", got) + } +} diff --git a/internal/frontend/grpc/robustness_docquery_states_events_test.go b/internal/frontend/grpc/robustness_docquery_states_events_test.go new file mode 100644 index 0000000000..735c0ac083 --- /dev/null +++ b/internal/frontend/grpc/robustness_docquery_states_events_test.go @@ -0,0 +1,112 @@ +package grpc + +import ( + "context" + "testing" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +// stateQueryModel is a lamp whose machine accepts Toggle, plus a bare part, so +// the state and event query refusals can each be reached over RunDocumentQuery. +const stateQueryModel = `package Lamps { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + attribute def Toggle; + state def LampMachine { + entry; then off; + state off; + transition off_on first off accept Toggle then on; + state on; + } + part def Lamp { exhibit state lp : LampMachine; } + part def Rock; + part lamp : Lamp; + part rock : Rock; + + calc def CurrentStates :> Query { + in root : Element; + Project(source = States(source = root), properties = ("machine", "statePath")) + } + calc def Off :> Query { + InState(name = "off") + } + calc def Nowhere :> Query { + InState(name = "orbit") + } + calc def Steps :> Query { + in root : Element; + Events(source = root) + } + calc def Window :> Query { + in root : Element; + in s : Real; + in b : Real; + Events(source = root, since = s, before = b) + } +}` + +// TestGRPCRobustnessDocumentQueryStatesEvents exercises the typed refusals of +// the state and event queries over the held population: each is a connect +// status, never a panic or a bare error. +func TestGRPCRobustnessDocumentQueryStatesEvents(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustParse(t, srv, stateQueryModel) + holdObject(t, srv, hash, "Lamps::lamp") + holdObject(t, srv, hash, "Lamps::rock") + + run := func(query string, bindings ...*pb.DocumentQueryBinding) error { + t.Helper() + _, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: query, Bindings: bindings, + }) + return err + } + code := func(err error) connect.Code { + t.Helper() + if err == nil { + t.Fatal("RunDocumentQuery succeeded, want a typed refusal") + } + return connect.CodeOf(err) + } + + t.Run("states_of_an_object_with_no_machine", func(t *testing.T) { + if got := code(run("Lamps::CurrentStates", binding("root", objectByID(3)))); got != connect.CodeFailedPrecondition { + t.Fatalf("code = %v, want FAILED_PRECONDITION", got) + } + }) + t.Run("in_state_names_a_state_no_machine_declares", func(t *testing.T) { + if got := code(run("Lamps::Nowhere")); got != connect.CodeFailedPrecondition { + t.Fatalf("code = %v, want FAILED_PRECONDITION", got) + } + }) + t.Run("events_empty_interval", func(t *testing.T) { + err := run("Lamps::Window", binding("root", objectByID(1)), + binding("s", &pb.DocumentValue{Kind: &pb.DocumentValue_RealValue{RealValue: 1}}), + binding("b", &pb.DocumentValue{Kind: &pb.DocumentValue_RealValue{RealValue: 1}})) + if got := code(err); got != connect.CodeFailedPrecondition { + t.Fatalf("code = %v, want FAILED_PRECONDITION: %v", got, err) + } + }) + t.Run("events_over_the_unrun_population", func(t *testing.T) { + // The population's run is traced from the first Instantiate, so Events + // always reads a trace over gRPC; the lamp's start recorded its entry. + resp, err := srv.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Lamps::Steps", Bindings: []*pb.DocumentQueryBinding{binding("root", objectByID(1))}, + }) + if err != nil { + t.Fatalf("RunDocumentQuery Steps: %v", err) + } + if len(resp.Rows) != 1 || resp.Rows[0].Element.GetEvent().GetKind() != "entry" { + t.Fatalf("rows = %v, want the one entry record", resp.Rows) + } + }) + t.Run("source_bound_to_an_unknown_id", func(t *testing.T) { + if got := code(run("Lamps::CurrentStates", binding("root", objectByID(99)))); got != connect.CodeNotFound { + t.Fatalf("code = %v, want NOT_FOUND", got) + } + }) +} diff --git a/internal/frontend/grpc/robustness_test.go b/internal/frontend/grpc/robustness_test.go new file mode 100644 index 0000000000..c8134039e2 --- /dev/null +++ b/internal/frontend/grpc/robustness_test.go @@ -0,0 +1,335 @@ +package grpc + +import ( + "context" + "strings" + "testing" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +// TestGRPCRobustness exercises failure modes: missing models, invalid symbols, parse errors. +// Each RPC must return typed errors, never panic. +func TestGRPCRobustness(t *testing.T) { + service := mustNewService(t, 10) // cache size 10 + + t.Run("parse_invalid_syntax", func(t *testing.T) { + req := &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{ + Content: "package test { invalid syntax (((", + }, + } + resp, err := service.ParseFile(context.Background(), req) + if err != nil { + t.Fatalf("ParseFile RPC failed: %v (should return diagnostics, not RPC error)", err) + } + if len(resp.Diagnostics) == 0 { + t.Error("Expected diagnostics for invalid syntax, got none") + } + }) + + t.Run("get_symbol_missing_model", func(t *testing.T) { + req := &pb.GetSymbolRequest{ + ModelHash: "nonexistent_hash", + SymbolId: "test::Symbol", + } + _, err := service.GetSymbol(context.Background(), req) + if err == nil { + t.Error("Expected error for missing model, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("Expected 'not found' error, got: %v", err) + } + }) + + t.Run("evaluate_missing_model", func(t *testing.T) { + req := &pb.EvaluateRequest{ + ModelHash: "nonexistent_hash", + Expression: "2 + 2", + } + _, err := service.Evaluate(context.Background(), req) + if err == nil { + t.Error("Expected error for missing model, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("Expected 'not found' error, got: %v", err) + } + }) + + t.Run("evaluate_parse_error", func(t *testing.T) { + // First parse a model + parseReq := &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{ + Content: "package test {}", + }, + } + parseResp, err := service.ParseFile(context.Background(), parseReq) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + // Try to evaluate invalid expression + evalReq := &pb.EvaluateRequest{ + ModelHash: parseResp.ModelHash, + Expression: "invalid syntax (((", + } + evalResp, err := service.Evaluate(context.Background(), evalReq) + if err != nil { + t.Fatalf("Evaluate RPC failed: %v (should return error field, not RPC error)", err) + } + if evalResp.Error == "" { + t.Error("Expected error field for invalid expression, got empty") + } + }) + + t.Run("instantiate_missing_symbol", func(t *testing.T) { + // Parse a model + parseReq := &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{ + Content: "package test {}", + }, + } + parseResp, err := service.ParseFile(context.Background(), parseReq) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + // Try to instantiate non-existent symbol + instReq := &pb.InstantiateRequest{ + ModelHash: parseResp.ModelHash, + SymbolId: "test::NonExistent", + } + instResp, err := service.Instantiate(context.Background(), instReq) + if err != nil { + t.Fatalf("Instantiate RPC failed: %v (should return error field, not RPC error)", err) + } + if instResp.Error == "" { + t.Error("Expected error field for missing symbol, got empty") + } + }) + + t.Run("execute_action_missing_symbol", func(t *testing.T) { + // Parse a model + parseReq := &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{ + Content: "package test {}", + }, + } + parseResp, err := service.ParseFile(context.Background(), parseReq) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + // Try to execute non-existent action + execReq := &pb.ExecuteActionRequest{ + ModelHash: parseResp.ModelHash, + ActionSymbolId: "test::NonExistent", + Inputs: map[string]*pb.Value{}, + } + execResp, err := service.ExecuteAction(context.Background(), execReq) + if err != nil { + t.Fatalf("ExecuteAction RPC failed: %v (should return error field, not RPC error)", err) + } + if execResp.Error == "" { + t.Error("Expected error field for missing action, got empty") + } + }) + + t.Run("execute_state_missing_symbol", func(t *testing.T) { + // Parse a model + parseReq := &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{ + Content: "package test {}", + }, + } + parseResp, err := service.ParseFile(context.Background(), parseReq) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + // Try to execute non-existent state machine + execReq := &pb.ExecuteStateRequest{ + ModelHash: parseResp.ModelHash, + StateMachineSymbolId: "test::NonExistent", + Events: []string{}, + } + execResp, err := service.ExecuteState(context.Background(), execReq) + if err != nil { + t.Fatalf("ExecuteState RPC failed: %v (should return error field, not RPC error)", err) + } + if execResp.Error == "" { + t.Error("Expected error field for missing state machine, got empty") + } + }) + + t.Run("parse_with_unavailable_standard_library", func(t *testing.T) { + // A library that would not load leaves the index without it. The request + // must still answer, reporting unresolved names as diagnostics. + svc := mustNewService(t, 10) + defer svc.Close() + svc.libIndexes = newLibraryBase(func() (*symbols.Index, libs.Source) { + idx := symbols.NewIndex() + idx.Freeze() + return idx, nil + }) + + resp, err := svc.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{ + Content: "package test { attribute def A { attribute x : ScalarValues::Real; } }", + }, + }) + if err != nil { + t.Fatalf("ParseFile RPC failed: %v (should return diagnostics, not RPC error)", err) + } + if len(resp.Diagnostics) == 0 { + t.Error("Expected diagnostics for library types that did not load, got none") + } + if _, ok := svc.cache.Get(resp.ModelHash); !ok { + t.Error("Expected the model to be cached despite the missing library") + } + }) +} + +func TestGRPCAuthoringRobustness(t *testing.T) { + service := mustNewService(t, 10) + hash := mustParsedModel(t, service, `package Demo { + part def Base; + part use : Base; +}`) + + t.Run("unknown_owner", func(t *testing.T) { + resp, err := service.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, + Operations: []*pb.EditOperation{{ + Operation: &pb.EditOperation_AddMember{AddMember: &pb.AddMemberEdit{ + Owner: "Demo::Missing", Kind: "part def", Name: "Vehicle", + }}, + }}, + }) + if err != nil { + t.Fatalf("ApplyEdits failed the call: %v", err) + } + if resp.Failure != pb.EditFailure_EDIT_FAILURE_OWNER_UNKNOWN { + t.Fatalf("failure = %s, want owner unknown", resp.Failure) + } + if resp.Content != "" || resp.Error == "" { + t.Fatalf("refusal response = content %q, error %q", resp.Content, resp.Error) + } + }) + + t.Run("delete_referenced_without_cascade", func(t *testing.T) { + resp, err := service.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: hash, + Operations: []*pb.EditOperation{{ + Operation: &pb.EditOperation_Delete{Delete: &pb.DeleteEdit{ + Target: "Demo::Base", + }}, + }}, + }) + if err != nil { + t.Fatalf("ApplyEdits failed the call: %v", err) + } + if resp.Failure != pb.EditFailure_EDIT_FAILURE_DELETE_REFERENCED { + t.Fatalf("failure = %s, want delete referenced", resp.Failure) + } + if len(resp.ReferringElements) == 0 { + t.Fatal("refusal did not identify referring elements") + } + }) +} + +// TestGRPCObjectBindingRobustness: a document-query binding to an object the +// service does not hold fails with a typed status naming the parameter, never +// a panic — nothing held, an unknown id, a path reaching no object. +func TestGRPCObjectBindingRobustness(t *testing.T) { + service := mustNewService(t, 10) + hash := parseFixture(t, service, objectFixture) + + run := func(value *pb.DocumentValue) error { + _, err := service.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Garage::Parts", + Bindings: []*pb.DocumentQueryBinding{binding("root", value)}, + }) + return err + } + expect := func(t *testing.T, err error, code connect.Code, texts ...string) { + t.Helper() + if err == nil { + t.Fatalf("got no error, want %v", code) + } + if connect.CodeOf(err) != code { + t.Errorf("code = %v, want %v: %v", connect.CodeOf(err), code, err) + } + for _, text := range texts { + if !strings.Contains(err.Error(), text) { + t.Errorf("error %q lacks %q", err.Error(), text) + } + } + } + + t.Run("nothing_held", func(t *testing.T) { + expect(t, run(objectByID(1)), connect.CodeNotFound, "binding root", "holds no objects", "Instantiate") + expect(t, run(objectByPath("car")), connect.CodeNotFound, "binding root", "holds no objects") + expect(t, run(objectByPath("car.wheels[2]")), connect.CodeNotFound, "binding root", "holds no objects") + // A malformed reference is refused the same way whether or not objects are held. + expect(t, run(objectByPath("")), connect.CodeInvalidArgument, "binding root", "instance_id or by path") + expect(t, run(objectByID(-1)), connect.CodeInvalidArgument, "binding root", "#-1 is not an object id") + expect(t, run(objectByPath("car..wheels")), connect.CodeInvalidArgument, "binding root", "not an object reference") + expect(t, run(objectByPath("car.wheels[0]")), connect.CodeInvalidArgument, "binding root", "counted from 1") + }) + + holdObject(t, service, hash, "Garage::car") + + t.Run("unknown_id", func(t *testing.T) { + expect(t, run(objectByID(99)), connect.CodeNotFound, "binding root", "no object #99", "#1") + expect(t, run(objectByPath("#99")), connect.CodeNotFound, "binding root", "no object #99") + expect(t, run(objectByPath("#99.wheels[1]")), connect.CodeNotFound, "binding root", "no object #99") + expect(t, run(objectByID(-1)), connect.CodeInvalidArgument, "binding root", "#-1 is not an object id") + }) + + t.Run("no_reference", func(t *testing.T) { + expect(t, run(objectByPath("")), connect.CodeInvalidArgument, "binding root", "instance_id or by path") + expect(t, run(objectByPath("car..wheels")), connect.CodeInvalidArgument, "binding root", "not an object reference") + expect(t, run(objectByPath("car.wheels[0]")), connect.CodeInvalidArgument, "binding root", "not an object reference", "counted from 1") + expect(t, run(objectByPath("Garage.car")), connect.CodeInvalidArgument, "binding root", "Garage is a package, not an object") + }) + + t.Run("name_not_instantiated", func(t *testing.T) { + expect(t, run(objectByPath("spare")), connect.CodeNotFound, "binding root", `no instance of "Garage::spare"`, "Instantiate first") + expect(t, run(objectByPath("Garage::spare.pressure")), connect.CodeNotFound, "binding root", `no instance of "Garage::spare"`) + expect(t, run(objectByPath("nowhere")), connect.CodeNotFound, "binding root", "symbol not found: nowhere") + expect(t, run(objectByPath("Garage::Car")), connect.CodeNotFound, "binding root", `no instance of "Garage::Car"`) + }) + + t.Run("path_reaches_no_object", func(t *testing.T) { + expect(t, run(objectByPath("car.wheels")), connect.CodeInvalidArgument, "binding root", "wheels of Garage::car holds 2 objects", "wheels[1] to wheels[2]") + expect(t, run(objectByPath("car.wheels[3]")), connect.CodeInvalidArgument, "binding root", "wheels[3] names none") + expect(t, run(objectByPath("car.engine.power")), connect.CodeInvalidArgument, "binding root", "power of Garage::car.engine holds a value (100), not an object") + expect(t, run(objectByPath("car.hood")), connect.CodeInvalidArgument, "binding root", `Garage::car has no feature "hood"`, "engine, wheels") + expect(t, run(objectByPath("#1.engine.power")), connect.CodeInvalidArgument, "binding root", "power of #1.engine holds a value (100), not an object") + }) + + t.Run("id_and_path_disagree", func(t *testing.T) { + both := &pb.DocumentValue{Kind: &pb.DocumentValue_Object{Object: &pb.DocumentObject{InstanceId: 2, Path: "car"}}} + expect(t, run(both), connect.CodeInvalidArgument, "binding root", "Garage::car is object #1, not #2") + }) + + t.Run("object_bound_to_scalar_parameter", func(t *testing.T) { + _, err := service.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: hash, QueryId: "Garage::Wheels", + Bindings: []*pb.DocumentQueryBinding{binding("root", objectByID(1))}, + }) + expect(t, err, connect.CodeInvalidArgument, "unknown binding root") + }) + + t.Run("missing_model", func(t *testing.T) { + _, err := service.RunDocumentQuery(context.Background(), &pb.RunDocumentQueryRequest{ + ModelHash: "nonexistent_hash", QueryId: "Garage::Parts", + Bindings: []*pb.DocumentQueryBinding{binding("root", objectByID(1))}, + }) + expect(t, err, connect.CodeNotFound, "not found") + }) +} diff --git a/internal/grpc/runtime_source_test.go b/internal/frontend/grpc/runtime_source_test.go similarity index 96% rename from internal/grpc/runtime_source_test.go rename to internal/frontend/grpc/runtime_source_test.go index ac8163764c..27d2c1feec 100644 --- a/internal/grpc/runtime_source_test.go +++ b/internal/frontend/grpc/runtime_source_test.go @@ -5,7 +5,7 @@ import ( "testing" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" ) // Every worker's semantic model reads documentation through the cached model's own diff --git a/internal/grpc/runtime_test.go b/internal/frontend/grpc/runtime_test.go similarity index 100% rename from internal/grpc/runtime_test.go rename to internal/frontend/grpc/runtime_test.go diff --git a/internal/grpc/schedule_test.go b/internal/frontend/grpc/schedule_test.go similarity index 100% rename from internal/grpc/schedule_test.go rename to internal/frontend/grpc/schedule_test.go diff --git a/internal/grpc/service.go b/internal/frontend/grpc/service.go similarity index 86% rename from internal/grpc/service.go rename to internal/frontend/grpc/service.go index e0c84a4294..a236e851e2 100644 --- a/internal/grpc/service.go +++ b/internal/frontend/grpc/service.go @@ -10,16 +10,17 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/ast/astcodec" - "github.com/Open-MBEE/OpenSysML/internal/core/conformance" - engineset "github.com/Open-MBEE/OpenSysML/internal/core/engines" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/passes" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + engineset "github.com/Open-MBEE/OpenSysML/internal/exec/engines" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast/astcodec" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // msgModelNotFound formats the not-found status for an unknown model hash. @@ -51,6 +52,10 @@ const CapabilityDocumentQuery = "document_query" // which renders a named document to Markdown. const CapabilityRenderDocument = "render_document" +// CapabilityRenderDocumentHTML names the capability of RenderDocumentRequest's +// form field, which asks RenderDocument for HTML instead of Markdown. +const CapabilityRenderDocumentHTML = "render_document_html" + // CapabilityOSLCQuery names the capability of evaluating OSLC Query text. const CapabilityOSLCQuery = "oslc_query" @@ -77,6 +82,11 @@ const CapabilityApplyEdits = "apply_edits" // CapabilityAuthoring names add-member and delete source authoring operations. const CapabilityAuthoring = "authoring" +// CapabilityEditDocuments names the capability of editing a model of several +// documents as one batch, for a request accepting documents, and of answering +// each edited document by name in ApplyEditsResponse.documents. +const CapabilityEditDocuments = "edit_documents" + // CapabilityInlineLanguage names explicit language selection for inline content. const CapabilityInlineLanguage = "inline_language" @@ -153,6 +163,10 @@ const CapabilityScheduleExplore = "schedule_explore" // when it ended. Without it the field is 0 whatever the run waited on. const CapabilityFinalTime = "final_time" +// CapabilityPerformer names the `performer_symbol_id` field of ExecuteActionRequest and +// ExecuteStateRequest; a service without it runs outside any object, so clients must not send it. +const CapabilityPerformer = "performer" + // CapabilityMetaobjectValues names the capability of carrying an element // reflected on as an instance of its metaclass (`x meta T`, the last element of // `x.metadata`) as Value.metaobject, rather than as an unsupported null. @@ -178,6 +192,9 @@ var capabilities = []string{ CapabilityMetaobjectValues, CapabilityUndeterminedValue, CapabilityEnginesExternal, + CapabilityEditDocuments, + CapabilityPerformer, + CapabilityRenderDocumentHTML, } type capabilityAvailability struct { @@ -237,6 +254,12 @@ type Service struct { // jobs is how many runs of one plan go concurrently, OPENSYSML_JOBS read once at // construction; no request sets it. jobs int + // maxHeldObjects bounds the objects one cached model holds for its queries, + // HeldObjectsEnvVar read once at construction. + maxHeldObjects int + // maxHeldEvents bounds the event records one cached model's population keeps + // for Events, HeldEventsEnvVar read once at construction. + maxHeldEvents int // engines answers every analysis question the runtime RPCs put, under auto. engines *analysis.Registry // version is the build version GetServerInfo reports, informational only. @@ -262,11 +285,11 @@ func ServeExternalEngines(names ...string) Option { // NewService creates a gRPC service with specified cache size, reporting // version as its build version. It returns an error if cacheSize is not -// positive, if a budget variable or OPENSYSML_JOBS holds anything but a positive -// integer, if the prewarm setting is not a non-negative integer, or if a manifest -// or a name given to ServeExternalEngines is wrong. It does not load the -// standard library: call Prewarm to have that happen in the background, ahead of -// the requests that need it. +// positive, if a budget variable, OPENSYSML_JOBS, OPENSYSML_GRPC_MAX_HELD_OBJECTS or +// OPENSYSML_GRPC_MAX_HELD_EVENTS holds anything but a positive integer, if the +// prewarm setting is not a non-negative integer, or if a manifest or a name +// given to ServeExternalEngines is wrong. It does not load the standard library: call Prewarm to have that +// happen in the background, ahead of the requests that need it. func NewService(cacheSize int, version string, opts ...Option) (*Service, error) { return newService(cacheSize, version, opts) } @@ -302,6 +325,14 @@ func newService(cacheSize int, version string, opts []Option) (*Service, error) if err != nil { return nil, err } + maxHeldObjects, err := maxHeldObjectsFromEnv() + if err != nil { + return nil, err + } + maxHeldEvents, err := maxHeldEventsFromEnv() + if err != nil { + return nil, err + } registry, err := engineset.DefaultFromEnv() if err != nil { return nil, err @@ -314,14 +345,16 @@ func newService(cacheSize int, version string, opts []Option) (*Service, error) availability.withhold(CapabilityEnginesExternal) } return &Service{ - cache: cache, - libIndexes: newLibraryBase(buildLibraryIndex), - prewarm: prewarm > 0, - budgets: budgets, - jobs: jobs, - engines: engines, - version: version, - capabilities: availability, + cache: cache, + libIndexes: newLibraryBase(buildLibraryIndex), + prewarm: prewarm > 0, + budgets: budgets, + jobs: jobs, + maxHeldObjects: maxHeldObjects, + maxHeldEvents: maxHeldEvents, + engines: engines, + version: version, + capabilities: availability, }, nil } @@ -362,42 +395,42 @@ func (s *Service) requireCapability(capability string) error { // requireValueCapabilities refuses a supplied value of a kind whose capability // is unavailable, rather than reading it as something else. func (s *Service) requireValueCapabilities(pv *pb.Value) error { - if ValueCarriesComplex(pv) { + if protoconv.ValueCarriesComplex(pv) { if err := s.requireCapability(CapabilityComplexValues); err != nil { return err } } - if ValueCarriesStructured(pv) { + if protoconv.ValueCarriesStructured(pv) { if err := s.requireCapability(CapabilityStructuredValues); err != nil { return err } } - if ValueCarriesMeasurementRef(pv) { + if protoconv.ValueCarriesMeasurementRef(pv) { if err := s.requireCapability(CapabilityMeasurementRefs); err != nil { return err } } - if ValueCarriesFunction(pv) { + if protoconv.ValueCarriesFunction(pv) { if err := s.requireCapability(CapabilityFunctionValues); err != nil { return err } } - if ValueCarriesInfinity(pv) { + if protoconv.ValueCarriesInfinity(pv) { if err := s.requireCapability(CapabilityInfinityValue); err != nil { return err } } - if ValueCarriesSet(pv) { + if protoconv.ValueCarriesSet(pv) { if err := s.requireCapability(CapabilitySetValues); err != nil { return err } } - if ValueCarriesTensor(pv) { + if protoconv.ValueCarriesTensor(pv) { if err := s.requireCapability(CapabilityTensorValues); err != nil { return err } } - if ValueCarriesMetaobject(pv) { + if protoconv.ValueCarriesMetaobject(pv) { return s.requireCapability(CapabilityMetaobjectValues) } return nil @@ -413,7 +446,12 @@ func (s *Service) newRuntime(cached *CachedModel) (*runtime.Context, func()) { // newRuntimeOver builds a runtime context under the service's budgets on a worker; // every explored run gets one of its own. func (s *Service) newRuntimeOver(w *analysis.Worker) *runtime.Context { - ctx := runtime.NewContext(w.Model, s.budgets.MaxSteps) + return s.newRuntimeContext(w.Model) +} + +// newRuntimeContext builds a runtime context over model under the service's budgets. +func (s *Service) newRuntimeContext(model *runtime.Model) *runtime.Context { + ctx := runtime.NewContext(model, s.budgets.MaxSteps) if err := ctx.SetBudgets(s.budgets); err != nil { // Unreachable: NewService validated these budgets. panic(fmt.Sprintf("grpc: invalid service budgets: %v", err)) @@ -491,7 +529,7 @@ func (s *Service) ParseFile(ctx context.Context, req *pb.ParseFileRequest) (*pb. return nil, err } - mode := conformance.ModeOf(req.StrictConformance) + mode := diag.ConformanceModeOf(req.StrictConformance) modelHash, model := s.parseModel([]sourceInput{input}, mode) return s.buildParseResponse(modelHash, model), nil } @@ -527,7 +565,7 @@ func (s *Service) ParseSources(ctx context.Context, req *pb.ParseSourcesRequest) inputs = append(inputs, input) } - modelHash, model := s.parseModel(inputs, conformance.ModeOf(req.StrictConformance)) + modelHash, model := s.parseModel(inputs, diag.ConformanceModeOf(req.StrictConformance)) roots := make([]*pb.SymbolInfo, 0, len(model.Documents)) for _, doc := range model.Documents { roots = append(roots, s.rootSymbol(model, doc)) @@ -597,7 +635,7 @@ func fileInput(path string) (sourceInput, error) { // gating in AGENTS.md §4: a document that failed to parse contributes no symbols, // so analyzing its siblings would report names as unresolved that the model // declares. -func (s *Service) parseModel(inputs []sourceInput, mode conformance.Mode) (string, *CachedModel) { +func (s *Service) parseModel(inputs []sourceInput, mode diag.ConformanceMode) (string, *CachedModel) { // Keyed by what was read, not by the hash a request carried: a hash // disagreeing with its content would serve another model. Each document's // name is part of the key, since its diagnostics name the document they came @@ -647,13 +685,14 @@ func (s *Service) parseModel(inputs []sourceInput, mode conformance.Mode) (strin if parsedClean { for i, doc := range documents { doc.PassesDiags = passes.AnalyzeWithOptions(inputs[i].name, inputs[i].kind, doc.Root, - make([]passes.Diagnostic, 0), idx, passes.Options{Conformance: mode}) + make([]diag.Diagnostic, 0), idx, passes.Options{Conformance: mode}) } } - model := &CachedModel{Documents: documents, Index: idx, Library: library} - s.cache.Put(modelHash, model) - return modelHash, model + // A parse racing another of the same model keeps the entry already cached, + // so the objects held on it stay reachable under the hash. + model := &CachedModel{Documents: documents, Index: idx, Library: library, Mode: mode} + return modelHash, s.cache.Add(modelHash, model) } // GetSymbol retrieves symbol information by FQN @@ -841,21 +880,37 @@ func (s *Service) Instantiate(ctx context.Context, req *pb.InstantiateRequest) ( } sym := syms[0] - runtimeCtx, release := s.newRuntime(cached) - defer release() - - // Instantiate - inst, err := runtimeCtx.Instantiate(sym) + // The object outlives the request: a later RunDocumentQuery on the model + // binds it by id or by the name it was created under. + held := s.objects(cached) + defer held.lock()() + runtimeCtx := held.rt + + // Serializing the graph materializes the objects under the root, so it is + // part of the creation: past the held-objects bound, none of them stays. + var graph protoconv.InstanceGraph + inst, err := runtimeCtx.InstantiateRead(sym, func(inst *runtime.Instance) error { + graph = s.instanceGraphToProto(runtimeCtx, inst, cached.Index) + for _, err := range graph.Errors { + if errors.Is(err, runtime.ErrInstanceLimitExceeded) { + return err + } + } + return nil + }) if err != nil { + if status := held.exhausted(err); status != nil { + return nil, status + } return &pb.InstantiateResponse{ Error: fmt.Sprintf("instantiation failed: %v", err), }, nil } + held.hold(sym, inst) - root, all := s.instanceGraphToProto(runtimeCtx, inst, cached.Index) return &pb.InstantiateResponse{ - Instance: root, - Instances: all, + Instance: graph.Root, + Instances: graph.All, }, nil } @@ -865,6 +920,11 @@ func (s *Service) ExecuteAction(ctx context.Context, req *pb.ExecuteActionReques if err != nil { return nil, err } + if req.PerformerSymbolId != "" { + if err := s.requireCapability(CapabilityPerformer); err != nil { + return nil, err + } + } // Lookup cached model cached, ok := s.cache.Get(req.ModelHash) @@ -895,7 +955,7 @@ func (s *Service) ExecuteAction(ctx context.Context, req *pb.ExecuteActionReques if err := s.requireValueCapabilities(pv); err != nil { return nil, nil, err } - val, cerr := ProtoToRuntimeValue(ctx, pv, cached.Index, ctx.Semantics()) + val, cerr := protoconv.ProtoToRuntimeValue(ctx, pv, cached.Index, ctx.Semantics()) if cerr != nil { return nil, &pb.ExecuteActionResponse{ Error: fmt.Sprintf("input %q could not be read: %v", name, cerr), @@ -911,8 +971,8 @@ func (s *Service) ExecuteAction(ctx context.Context, req *pb.ExecuteActionReques } if _, explores := schedule.Exploration(); explores { - // Inputs are read again on each run's own context, so an object among them - // belongs to the run that binds it. + // Inputs are read and the performer made again on each run's own context, + // so an object among them belongs to the run that binds it. x, err := s.explore(ctx, req.ActionSymbolId, schedule, analysis.Auto(), cached, func(rt *runtime.Context) (runtime.Outcome, error) { inputs, resp, err := readInputs(rt) if err != nil { @@ -921,11 +981,15 @@ func (s *Service) ExecuteAction(ctx context.Context, req *pb.ExecuteActionReques if resp != nil { return runtime.Outcome{}, errors.New(resp.Error) } - outputs, err := rt.ExecuteActionWithInputs(action, inputs) + self, err := s.performer(cached, rt, req.PerformerSymbolId) + if err != nil { + return runtime.Outcome{}, err + } + outcome, err := rt.ActionOutcomePerformedBy(action, self, inputs) if err != nil { return runtime.Outcome{}, fmt.Errorf("action execution failed: %w", err) } - return rt.ActionOutcome(outputs), nil + return outcome, nil }) if err != nil { return nil, err @@ -935,10 +999,14 @@ func (s *Service) ExecuteAction(ctx context.Context, req *pb.ExecuteActionReques if err := runtimeCtx.SetSchedule(schedule); err != nil { return nil, statusError(connect.CodeInvalidArgument, err.Error()) } + self, err := s.performer(cached, runtimeCtx, req.PerformerSymbolId) + if err != nil { + return &pb.ExecuteActionResponse{Error: err.Error()}, nil + } // Execute action with the supplied inputs outputs, _, err := performOn(ctx, s, runtimeCtx, analysis.Auto(), req.ActionSymbolId, func(rt *runtime.Context) (map[string]runtime.Value, error) { - return rt.ExecuteActionWithInputs(action, inputs) + return rt.ExecuteActionPerformedBy(action, self, inputs) }, heldAnswer) if gone := callerGone(ctx, err); gone != nil { return nil, gone @@ -975,12 +1043,24 @@ func (s *Service) finalTime(runtimeCtx *runtime.Context) float64 { return runtimeCtx.Clock().Now() } +// performer is the object a request's behavior is performed by, made on rt: the +// part/usage it named or the one a declaration-rooted path reaches; none when unnamed. +func (s *Service) performer(cached *CachedModel, rt *runtime.Context, symbolID string) (*runtime.Instance, error) { + v := &verifyContext{service: s, cached: cached, runtime: rt, release: releaseNothing} + return v.performer(symbolID) +} + // ExecuteState executes a state machine func (s *Service) ExecuteState(ctx context.Context, req *pb.ExecuteStateRequest) (*pb.ExecuteStateResponse, error) { schedule, err := s.schedulePolicy(req.Schedule) if err != nil { return nil, err } + if req.PerformerSymbolId != "" { + if err := s.requireCapability(CapabilityPerformer); err != nil { + return nil, err + } + } // Lookup cached model cached, ok := s.cache.Get(req.ModelHash) @@ -999,7 +1079,11 @@ func (s *Service) ExecuteState(ctx context.Context, req *pb.ExecuteStateRequest) if _, explores := schedule.Exploration(); explores { x, err := s.explore(ctx, req.StateMachineSymbolId, schedule, analysis.Auto(), cached, func(rt *runtime.Context) (runtime.Outcome, error) { - return rt.StateOutcomeWithEvents(stateMachine, req.Events) + self, err := s.performer(cached, rt, req.PerformerSymbolId) + if err != nil { + return runtime.Outcome{}, err + } + return rt.StateOutcomePerformedBy(stateMachine, self, req.Events) }) if err != nil { return nil, err @@ -1012,11 +1096,15 @@ func (s *Service) ExecuteState(ctx context.Context, req *pb.ExecuteStateRequest) if err := runtimeCtx.SetSchedule(schedule); err != nil { return nil, statusError(connect.CodeInvalidArgument, err.Error()) } + self, err := s.performer(cached, runtimeCtx, req.PerformerSymbolId) + if err != nil { + return &pb.ExecuteStateResponse{Error: err.Error()}, nil + } // Execute state machine, injecting the requested events and capturing the // real ordered state-visit trace. ran, _, err := performOn(ctx, s, runtimeCtx, analysis.Auto(), req.StateMachineSymbolId, func(rt *runtime.Context) (stateRun, error) { - final, visited, err := rt.ExecuteStateWithEvents(stateMachine, req.Events) + final, visited, err := rt.ExecuteStatePerformedBy(stateMachine, self, req.Events) return stateRun{final: final, visited: visited}, err }, func(ran stateRun, err error) analysis.Answer { return heldAnswer(ran.final, err) }) if gone := callerGone(ctx, err); gone != nil { diff --git a/internal/grpc/service_test.go b/internal/frontend/grpc/service_test.go similarity index 100% rename from internal/grpc/service_test.go rename to internal/frontend/grpc/service_test.go diff --git a/internal/frontend/grpc/session.go b/internal/frontend/grpc/session.go new file mode 100644 index 0000000000..607c4f23dd --- /dev/null +++ b/internal/frontend/grpc/session.go @@ -0,0 +1,739 @@ +package grpc + +import ( + "errors" + "fmt" + "math" + "strings" + "sync" + + "connectrpc.com/connect" + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/objref" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast/astcodec" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// Session is one retained run of a cached model: a runtime context of its own, +// with its clock, its scheduling policy and the objects it instantiated, that +// successive calls read and move along. It is the engine behind the public +// client's in-process session and exists only there; it has no RPC. +type Session struct { + svc *Service + cached *CachedModel + worker *analysis.Worker + release func() + rt *runtime.Context + mu sync.Mutex + closed bool +} + +// SessionFailure is a session call the model answered with a failure — a name +// it does not declare, a run that failed — as distinct from a status the +// session refuses the call with. +type SessionFailure struct { + Message string + Diagnostics []*pb.Diagnostic +} + +func (f *SessionFailure) Error() string { return f.Message } + +func sessionFailuref(format string, args ...any) *SessionFailure { + return &SessionFailure{Message: fmt.Sprintf(format, args...)} +} + +// runFailure classifies a runtime error: one at the session's object bound is +// the RESOURCE_EXHAUSTED status; any other is the model's failure, so described. +func (ss *Session) runFailure(err error, format string, args ...any) error { + if errors.Is(err, runtime.ErrInstanceLimitExceeded) { + return statusErrorf(connect.CodeResourceExhausted, + "the session holds %d objects, and one more would pass the %d that %s allows; raise it to hold more (the objects are released when the session closes)", + ss.rt.InstanceCount(), ss.rt.MaxInstances(), HeldObjectsEnvVar) + } + return sessionFailuref(format, append(args, err)...) +} + +// SessionTransition is one transition of a state machine as a session reports +// it: its ends and trigger by name, no node of the graph it was lowered to. +type SessionTransition struct { + Name string + Source string + Target string + Trigger string + Signal string + Event string + Guarded bool +} + +// Trigger kinds a SessionTransition reports. +const ( + TriggerCompletion = "completion" + TriggerSignal = "signal" + TriggerTime = "time" + TriggerChange = "change" + TriggerCall = "call" +) + +// decideFailed formats the error a decision step of dispatching a signal raised. +const decideFailed = "deciding the signal failed: %v" + +// SessionAcceptance is what dispatching a signal to an object now would do, read +// from the machines delivery would let take it; where several would, the +// schedule's due order decides which consumes it. +type SessionAcceptance struct { + // Accepted reports whether a transition of a taking machine is triggered by + // the signal, whatever its guard; a deferral alone does not set it. + Accepted bool + Fires []SessionTransition + Deferred bool + Resumes []string +} + +// Enabled reports whether dispatching the signal would do something with it: +// fire, defer or resume. +func (a SessionAcceptance) Enabled() bool { + return len(a.Fires) > 0 || a.Deferred || len(a.Resumes) > 0 +} + +// Taken reports whether a machine of the object would take the signal at all, +// be it to fire, defer, resume, or drop it because every guard is false. +func (a SessionAcceptance) Taken() bool { + return a.Accepted || a.Enabled() +} + +// SessionChoice is one choice a run made, copied from the runtime's note. +type SessionChoice struct { + Kind string + Step int + Where string + Alternatives []string + Taken int +} + +// SessionBranch is one way a performance left a decision of the action's own flow. +type SessionBranch struct { + Decision string + Target string + Else bool + Opening bool +} + +// SessionPerformance is what performing an action in a session reported. +type SessionPerformance struct { + Outputs map[string]*pb.Value + Choices []SessionChoice + Branches []SessionBranch + Diagnostics []*pb.Diagnostic +} + +// SessionAdvance is what advancing a session's clock reported. +type SessionAdvance struct { + From, To float64 + Events int64 + Steps int64 + Choices []SessionChoice + Diagnostics []*pb.Diagnostic +} + +// SessionMember is one named member a declaration's scope holds. +type SessionMember struct { + ID string + Name string + Kind string +} + +// OpenSession opens a session over a cached model, holding one of the model's +// workers until the session is closed. +func (s *Service) OpenSession(modelHash string) (*Session, error) { + cached, ok := s.cache.Get(modelHash) + if !ok { + return nil, statusErrorf(connect.CodeNotFound, msgModelNotFound, modelHash) + } + w, release := cached.worker() + rt := s.newRuntimeOver(w) + rt.SetMaxInstances(s.maxHeldObjects) + return &Session{svc: s, cached: cached, worker: w, release: release, rt: rt}, nil +} + +// Close releases the session's worker; a closed session refuses every call. +func (ss *Session) Close() { + ss.mu.Lock() + defer ss.mu.Unlock() + if ss.closed { + return + } + ss.closed = true + ss.rt = nil + ss.release() +} + +// enter takes the session's lock for one call, refusing a closed session. +func (ss *Session) enter() (func(), error) { + ss.mu.Lock() + if ss.closed { + ss.mu.Unlock() + return nil, statusError(connect.CodeUnavailable, "the session is closed") + } + return ss.mu.Unlock, nil +} + +// SetSchedule makes the session's later turns follow the scheduling policy: the +// actions it performs from now on, and the machines and clock it already drives +// from their next step on. +func (ss *Session) SetSchedule(spelling string) error { + done, err := ss.enter() + if err != nil { + return err + } + defer done() + policy, err := ss.svc.schedulePolicy(spelling) + if err != nil { + return err + } + if _, explores := policy.Exploration(); explores { + return statusErrorf(connect.CodeInvalidArgument, + "invalid scheduling policy %q: an exploration replays whole runs, which a session does not", spelling) + } + if err := ss.rt.Reschedule(policy); err != nil { + return statusError(connect.CodeInvalidArgument, err.Error()) + } + return nil +} + +// Now is the session's clock. +func (ss *Session) Now() (float64, error) { + done, err := ss.enter() + if err != nil { + return 0, err + } + defer done() + return ss.rt.Clock().Now(), nil +} + +// Instantiate instantiates the named part or usage in the session, starting the +// behaviors it exhibits, and answers the object's id. +func (ss *Session) Instantiate(symbolID string) (int64, error) { + done, err := ss.enter() + if err != nil { + return 0, err + } + defer done() + sym, err := ss.declared(symbolID) + if err != nil { + return 0, err + } + inst, err := ss.rt.Instantiate(sym) + if err != nil { + return 0, ss.runFailure(err, "instantiation of %s failed: %v", symbolID) + } + return inst.ID, nil +} + +// FeatureValue reads one feature of an object the session holds. +func (ss *Session) FeatureValue(object int64, feature string) (*pb.FeatureValue, error) { + done, err := ss.enter() + if err != nil { + return nil, err + } + defer done() + inst, err := ss.object(object) + if err != nil { + return nil, err + } + fv, err := inst.GetFeatureValue(ss.rt, feature) + if err != nil { + return nil, ss.runFailure(err, "feature %s of object %d could not be read: %v", feature, object) + } + out := &pb.FeatureValue{FeatureName: feature, Materialized: fv.Materialized} + if fv.Feature.Scalar() { + switch { + case !fv.Materialized: + case fv.Value.Kind == runtime.ValInvalid: + out.Value = &pb.Value{Kind: &pb.Value_Unset{Unset: true}} + default: + out.Value = ss.svc.valueToProto(ss.rt, fv.Value, ss.cached.Index) + } + return out, nil + } + for _, elem := range objref.CollectionElements(fv.Values) { + out.Values = append(out.Values, ss.svc.valueToProto(ss.rt, elem, ss.cached.Index)) + } + return out, nil +} + +// SetFeatureValue writes one feature of an object the session holds. +func (ss *Session) SetFeatureValue(object int64, feature string, value *pb.Value) error { + done, err := ss.enter() + if err != nil { + return err + } + defer done() + inst, err := ss.object(object) + if err != nil { + return err + } + val, err := ss.value(feature, value) + if err != nil { + return err + } + if err := inst.SetFeatureValue(ss.rt, feature, val); err != nil { + return ss.runFailure(err, "feature %s of object %d could not be written: %v", feature, object) + } + return nil +} + +// Evaluate evaluates one expression in the scope of the named declaration, or +// of the model's primary document when none is named, against the session's state. +func (ss *Session) Evaluate(expression, contextSymbolID string) (*pb.Value, error) { + done, err := ss.enter() + if err != nil { + return nil, err + } + defer done() + exprSource := source.New("", []byte(expression)) + p := parser.New(exprSource) + exprNode := p.ParseExpression() + if len(p.Diagnostics) > 0 { + var diags []*pb.Diagnostic + for _, diag := range p.Diagnostics { + diags = append(diags, ParserDiagnosticToProto(diag, exprSource)) + } + return nil, &SessionFailure{Message: "expression parse failed", Diagnostics: ss.svc.filterDiagnosticCapabilities(diags)} + } + if p.Offset() < len(strings.TrimRight(expression, " \t\r\n")) { + return nil, sessionFailuref("expression parse failed: unexpected %q after the expression", + strings.TrimSpace(expression[p.Offset():])) + } + scope := ss.cached.PrimaryRoot() + if contextSymbolID != "" { + sym, err := ss.declared(contextSymbolID) + if err != nil { + return nil, err + } + scope = evalScope(sym, ss.cached) + } + var result runtime.Value + var evalErr error + ss.rt.Resolver().Scratch(astcodec.Reachable(exprNode), func() { result, evalErr = ss.rt.EvalWithScope(exprNode, scope) }) + if evalErr != nil { + return nil, ss.runFailure(evalErr, "evaluation failed: %v") + } + return ss.svc.valueToProto(ss.rt, result, ss.cached.Index), nil +} + +// Members lists the named members the named declaration's scope holds, in +// declaration order. +func (ss *Session) Members(symbolID string) ([]SessionMember, error) { + done, err := ss.enter() + if err != nil { + return nil, err + } + defer done() + sym, err := ss.declared(symbolID) + if err != nil { + return nil, err + } + if sym.Scope == nil { + return nil, sessionFailuref("%s declares no members", symbolID) + } + var members []SessionMember + for _, member := range sym.Scope.Members() { + if member.Name == "" { + continue + } + members = append(members, SessionMember{ID: ss.cached.Index.GetFQN(member), Name: member.Name, Kind: member.Kind.String()}) + } + return members, nil +} + +// ActiveStates names the innermost active states of every machine the object +// exhibits, machine by machine in declaration order; the composite states +// enclosing them are active too. +func (ss *Session) ActiveStates(object int64) ([]string, error) { + done, err := ss.enter() + if err != nil { + return nil, err + } + defer done() + machines, err := ss.machines(object) + if err != nil { + return nil, err + } + var names []string + for _, machine := range machines { + for _, state := range machine.ActiveLeaves() { + names = append(names, state.Name) + } + } + return names, nil +} + +// Transitions lists the transitions dispatch could select now in every machine +// the object exhibits: out of each active state and then of each state +// enclosing it, innermost first, in declaration order. +func (ss *Session) Transitions(object int64) ([]SessionTransition, error) { + done, err := ss.enter() + if err != nil { + return nil, err + } + defer done() + machines, err := ss.machines(object) + if err != nil { + return nil, err + } + var out []SessionTransition + for _, machine := range machines { + for _, trans := range machine.OutgoingTransitions() { + out = append(out, transitionFact(trans)) + } + } + return out, nil +} + +// transitionFact copies what a transition is, by name, from its lowered form. +func transitionFact(trans *lower.Transition) SessionTransition { + fact := SessionTransition{ + Name: trans.Name, + Source: runtime.StateVertexName(trans.Source), + Target: runtime.StateVertexName(trans.Target), + Trigger: TriggerCompletion, + Guarded: trans.Guard != nil, + } + switch trigger := trans.Trigger.(type) { + case *ast.AcceptEvent: + fact.Trigger = TriggerSignal + if trigger.SignalType != nil && len(trigger.SignalType.Parts) > 0 { + fact.Signal = trigger.SignalType.Parts[len(trigger.SignalType.Parts)-1].Text + } else { + fact.Event = lower.FeaturePath(trigger.Subsets) + } + case *ast.TimeEvent: + fact.Trigger = TriggerTime + case *ast.ChangeEvent: + fact.Trigger = TriggerChange + case *ast.CallEvent: + fact.Trigger = TriggerCall + } + return fact +} + +// Accepts decides what dispatching the signal to the object now would do, +// without dispatching it. +func (ss *Session) Accepts(object int64, signalID string, args map[string]*pb.Value) (*SessionAcceptance, error) { + done, err := ss.enter() + if err != nil { + return nil, err + } + defer done() + machines, msg, err := ss.signal(object, signalID, args) + if err != nil { + return nil, err + } + return ss.decide(machines, msg) +} + +// decide previews the message on each machine delivery would let take it — one +// that reacts to it and does not yield it to a sibling — in exhibit order. +func (ss *Session) decide(machines []*runtime.StateExecutor, msg runtime.Message) (*SessionAcceptance, error) { + out := &SessionAcceptance{} + for _, machine := range machines { + takes, err := machine.TakesMessage(msg) + if err != nil { + return nil, ss.runFailure(err, decideFailed) + } + if !takes { + continue + } + triggered, err := machine.TriggeredBy(msg) + if err != nil { + return nil, ss.runFailure(err, decideFailed) + } + out.Accepted = out.Accepted || triggered + decision, transitions, err := machine.DecideTransitions(msg) + if err != nil { + return nil, ss.runFailure(err, decideFailed) + } + for _, trans := range transitions { + out.Fires = append(out.Fires, transitionFact(trans)) + } + out.Deferred = out.Deferred || decision.Deferred + out.Resumes = append(out.Resumes, decision.Resumes...) + } + return out, nil +} + +// Send posts the signal to the object, refusing one no machine would take or +// one every taking machine would drop; Advance then dispatches it. +func (ss *Session) Send(object int64, signalID string, args map[string]*pb.Value) (*SessionAcceptance, error) { + done, err := ss.enter() + if err != nil { + return nil, err + } + defer done() + machines, msg, err := ss.signal(object, signalID, args) + if err != nil { + return nil, err + } + acceptance, err := ss.decide(machines, msg) + if err != nil { + return nil, err + } + if !acceptance.Taken() { + return nil, statusErrorf(connect.CodeFailedPrecondition, "no active state accepts or defers %s", signalID) + } + if !acceptance.Enabled() { + return nil, statusErrorf(connect.CodeFailedPrecondition, "the active states accept %s but no guard on it holds", signalID) + } + ss.rt.PostMessage(msg) + return acceptance, nil +} + +// signal builds the message the signal definition sends to the object. +func (ss *Session) signal(object int64, signalID string, args map[string]*pb.Value) ([]*runtime.StateExecutor, runtime.Message, error) { + inst, err := ss.object(object) + if err != nil { + return nil, runtime.Message{}, err + } + machines, err := ss.machinesOf(inst, object) + if err != nil { + return nil, runtime.Message{}, err + } + sym, err := ss.declared(signalID) + if err != nil { + return nil, runtime.Message{}, err + } + if !runtime.IsSignalDefinition(sym) { + return nil, runtime.Message{}, sessionFailuref("%s is not a signal definition", signalID) + } + values, err := ss.values(args) + if err != nil { + return nil, runtime.Message{}, err + } + msg, err := ss.rt.SignalMessage(sym, values, inst) + if err != nil { + return nil, runtime.Message{}, ss.runFailure(err, "signal %s could not be built: %v", signalID) + } + return machines, msg, nil +} + +// Advance moves the session's clock by seconds, dispatching what is due. +func (ss *Session) Advance(seconds float64) (*SessionAdvance, error) { + done, err := ss.enter() + if err != nil { + return nil, err + } + defer done() + if seconds < 0 || math.IsNaN(seconds) || math.IsInf(seconds, 0) { + return nil, statusErrorf(connect.CodeInvalidArgument, "the clock advances by a finite non-negative duration, not %v", seconds) + } + report, err := ss.rt.Advance(seconds) + if err != nil { + if errors.Is(err, runtime.ErrInstanceLimitExceeded) { + return nil, ss.runFailure(err, "advance failed: %v") + } + return nil, &SessionFailure{Message: fmt.Sprintf("advance failed: %v", err), Diagnostics: ss.diagnostics(report.Notes)} + } + return &SessionAdvance{ + From: report.From, + To: report.To, + Events: report.Events, + Steps: report.Steps, + Choices: choicesOf(report.Notes), + Diagnostics: ss.diagnostics(report.Notes), + }, nil +} + +// Perform performs the named action on the object with the inputs, running it +// to completion, and reports what the run made and decided. +func (ss *Session) Perform(object int64, actionID string, inputs map[string]*pb.Value) (*SessionPerformance, error) { + done, err := ss.enter() + if err != nil { + return nil, err + } + defer done() + inst, err := ss.object(object) + if err != nil { + return nil, err + } + sym, err := ss.declared(actionID) + if err != nil { + return nil, err + } + values, err := ss.values(inputs) + if err != nil { + return nil, err + } + exec, err := ss.rt.CreateActionExecutorWithInputs(sym, inst, values) + if err != nil { + return nil, ss.runFailure(err, "action %s could not be performed: %v", actionID) + } + defer exec.Release() + exec.KeepTraversals(true) + if err := exec.RunToCompletion(); err != nil { + if errors.Is(err, runtime.ErrInstanceLimitExceeded) { + return nil, ss.runFailure(err, "action %s failed: %v", actionID) + } + return nil, &SessionFailure{Message: fmt.Sprintf("action %s failed: %v", actionID, err), Diagnostics: ss.diagnostics(exec.Notes())} + } + notes := exec.Notes() + out := &SessionPerformance{ + Outputs: make(map[string]*pb.Value), + Choices: choicesOf(notes), + Branches: branchesOf(exec), + Diagnostics: ss.diagnostics(notes), + } + for name, val := range exec.Results() { + out.Outputs[name] = ss.svc.valueToProto(ss.rt, val, ss.cached.Index) + } + return out, nil +} + +// branchesOf lists the decisions the action's own flow left, in traversal order. +func branchesOf(exec *runtime.ActionExecutor) []SessionBranch { + graph := exec.Graph() + gate := gateOf(graph) + var out []SessionBranch + for _, t := range exec.Traversals() { + if len(t.Within) > 0 { + continue + } + if _, ok := t.Edge.Source.(*ast.DecisionNode); !ok { + continue + } + branch := SessionBranch{ + Decision: runtime.ActionNodeName(t.Edge.Source), + Target: runtime.ActionNodeName(t.Edge.Target), + Opening: t.Edge.Source == gate, + } + if edge, ok := t.Edge.Decl.(*ast.ControlFlowEdge); ok { + branch.Else = edge.IsElse + } + out = append(out, branch) + } + return out +} + +// gateOf is the first decision the action's start leads to without a choice on +// the way, following each node's single succession; nil when there is none. +func gateOf(graph *lower.ActionGraph) ast.Node { + var at ast.Node + for _, node := range graph.Nodes { + if _, ok := node.(*ast.InitialNode); ok { + at = node + break + } + } + seen := map[ast.Node]bool{} + for at != nil && !seen[at] { + seen[at] = true + if _, ok := at.(*ast.DecisionNode); ok { + return at + } + edges := graph.Edges[at] + if len(edges) != 1 { + return nil + } + at = edges[0].Target + } + return nil +} + +// choicesOf copies the choice points among a run's notes. +func choicesOf(notes []runtime.RunNote) []SessionChoice { + var out []SessionChoice + for _, note := range notes { + c, ok := note.(runtime.ChoicePoint) + if !ok { + continue + } + out = append(out, SessionChoice{ + Kind: c.Kind.String(), + Step: c.Step, + Where: c.Where, + Alternatives: append([]string(nil), c.Alternatives...), + Taken: c.Taken, + }) + } + return out +} + +// diagnostics reports a run's notes as diagnostics, under the service's capabilities. +func (ss *Session) diagnostics(notes []runtime.RunNote) []*pb.Diagnostic { + return ss.svc.filterDiagnosticCapabilities(RunNoteDiagnosticsToProto(notes, ss.cached)) +} + +// declared resolves a symbol id in the session's model. +func (ss *Session) declared(symbolID string) (*symbols.Symbol, error) { + syms := lookupNamed(ss.cached.Index, symbolID) + if len(syms) == 0 { + return nil, sessionFailuref("symbol not found: %s", symbolID) + } + return syms[0], nil +} + +// object dereferences an object id the session handed out. +func (ss *Session) object(id int64) (*runtime.Instance, error) { + inst, ok := ss.rt.Instance(id) + if !ok { + return nil, sessionFailuref("object not found: %d", id) + } + return inst, nil +} + +// machines are the state machines the object exhibits, in declaration order. +func (ss *Session) machines(object int64) ([]*runtime.StateExecutor, error) { + inst, err := ss.object(object) + if err != nil { + return nil, err + } + return ss.machinesOf(inst, object) +} + +func (ss *Session) machinesOf(inst *runtime.Instance, object int64) ([]*runtime.StateExecutor, error) { + var machines []*runtime.StateExecutor + for _, exhibited := range inst.ExhibitedStates() { + if exhibited.State != nil { + machines = append(machines, exhibited.State) + } + } + if len(machines) == 0 { + return nil, sessionFailuref("object %d exhibits no state machine", object) + } + return machines, nil +} + +// value reads one named wire value as the session's runtime holds it; a value of +// a kind the service lacks the capability for is a status, an unreadable one a failure. +func (ss *Session) value(name string, pv *pb.Value) (runtime.Value, error) { + if err := ss.svc.requireValueCapabilities(pv); err != nil { + return runtime.Value{}, err + } + val, err := protoconv.ProtoToRuntimeValue(ss.rt, pv, ss.cached.Index, ss.rt.Semantics()) + if err != nil { + return runtime.Value{}, sessionFailuref("value %q could not be read: %v", name, err) + } + return val, nil +} + +// values reads named wire values as the session's runtime holds them. +func (ss *Session) values(pvs map[string]*pb.Value) (map[string]runtime.Value, error) { + if len(pvs) == 0 { + return nil, nil + } + out := make(map[string]runtime.Value, len(pvs)) + for name, pv := range pvs { + val, err := ss.value(name, pv) + if err != nil { + return nil, err + } + out[name] = val + } + return out, nil +} diff --git a/internal/grpc/spelling_test.go b/internal/frontend/grpc/spelling_test.go similarity index 100% rename from internal/grpc/spelling_test.go rename to internal/frontend/grpc/spelling_test.go diff --git a/internal/frontend/grpc/strict_conformance_test.go b/internal/frontend/grpc/strict_conformance_test.go new file mode 100644 index 0000000000..2be6436b77 --- /dev/null +++ b/internal/frontend/grpc/strict_conformance_test.go @@ -0,0 +1,210 @@ +package grpc + +import ( + "context" + "slices" + "strings" + "testing" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +// grpcExtension uses notation of ours: a warning by default, an error when the +// request asks strictly. +const grpcExtension = "package P { state def S { choice c; state a; } }" + +func TestServerAdvertisesStrictConformance(t *testing.T) { + srv := mustNewService(t, 10) + info, err := srv.GetServerInfo(context.Background(), &pb.ServerInfoRequest{}) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(info.Capabilities, CapabilityStrictConformance) { + t.Fatalf("capabilities = %v, want %q among them", info.Capabilities, CapabilityStrictConformance) + } +} + +func TestParseFileStrictConformanceEscalatesOurNotation(t *testing.T) { + srv := mustNewService(t, 10) + for _, tc := range []struct { + name string + strict bool + want string + }{ + {"default", false, "warning"}, + {"strict", true, "error"}, + } { + t.Run(tc.name, func(t *testing.T) { + resp, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: grpcExtension}, + StrictConformance: tc.strict, + }) + if err != nil { + t.Fatal(err) + } + d := notationDiagnostic(t, resp) + if d.Severity != tc.want { + t.Fatalf("severity = %q, want %q", d.Severity, tc.want) + } + }) + } +} + +// The two modes must not share a cache entry, or the second caller is answered +// with the first one's question. +func TestParseFileCachesTheModesSeparately(t *testing.T) { + srv := mustNewService(t, 10) + def, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: grpcExtension}, + }) + if err != nil { + t.Fatal(err) + } + strict, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: grpcExtension}, + StrictConformance: true, + }) + if err != nil { + t.Fatal(err) + } + if def.ModelHash == strict.ModelHash { + t.Fatalf("both modes hashed to %q", def.ModelHash) + } + if got := notationDiagnostic(t, def).Severity; got != "warning" { + t.Errorf("default severity = %q, want warning", got) + } + if got := notationDiagnostic(t, strict).Severity; got != "error" { + t.Errorf("strict severity = %q, want error", got) + } + + // Re-asking the default question after the strict one must still answer it. + again, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: grpcExtension}, + }) + if err != nil { + t.Fatal(err) + } + if got := notationDiagnostic(t, again).Severity; got != "warning" { + t.Errorf("default severity after the strict call = %q, want warning", got) + } +} + +// requireOutsideRequirement is a model an edit can make nonstandard: moving the +// `require` constraint into the part def is notation of ours, a warning by +// default and an error when the model was parsed strictly. +const requireOutsideRequirement = "package P {\n requirement def R {\n require constraint c { 1 > 0 }\n }\n part def V;\n}\n" + +// An edit's notation is judged at the strictness the model was parsed at: the +// same move is applied to the default model and refused for the strict one. +func TestApplyEditsJudgesTheEditAtTheParsedStrictness(t *testing.T) { + srv := mustNewService(t, 10) + for _, tc := range []struct { + name string + strict bool + }{ + {"default", false}, + {"strict", true}, + } { + t.Run(tc.name, func(t *testing.T) { + parsed, err := srv.ParseFile(context.Background(), &pb.ParseFileRequest{ + Source: &pb.ParseFileRequest_Content{Content: requireOutsideRequirement}, + StrictConformance: tc.strict, + }) + if err != nil { + t.Fatal(err) + } + if len(parsed.Diagnostics) != 0 { + t.Fatalf("fixture has diagnostics: %v", parsed.Diagnostics) + } + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: parsed.ModelHash, Operations: []*pb.EditOperation{moveOp("P::R::c", "P::V")}, + }) + if err != nil { + t.Fatal(err) + } + assertStrictnessVerdict(t, resp, tc.strict) + }) + } +} + +// The strictness a ParseSources request asked for follows its model into an +// edit of any of its documents. +func TestApplyEditsJudgesEachDocumentAtTheParsedStrictness(t *testing.T) { + srv := mustNewService(t, 10) + for _, tc := range []struct { + name string + strict bool + }{ + {"default", false}, + {"strict", true}, + } { + t.Run(tc.name, func(t *testing.T) { + parsed, err := srv.ParseSources(context.Background(), &pb.ParseSourcesRequest{ + Documents: inlineDocuments("lib.sysml", "package Lib {\n part def Base;\n}\n", "req.sysml", requireOutsideRequirement), + StrictConformance: tc.strict, + }) + if err != nil { + t.Fatal(err) + } + if len(parsed.Diagnostics) != 0 { + t.Fatalf("fixture has diagnostics: %v", parsed.Diagnostics) + } + resp, err := srv.ApplyEdits(context.Background(), &pb.ApplyEditsRequest{ + ModelHash: parsed.ModelHash, AcceptDocuments: true, Document: "req.sysml", + Operations: []*pb.EditOperation{moveOp("P::R::c", "P::V")}, + }) + if err != nil { + t.Fatal(err) + } + assertStrictnessVerdict(t, resp, tc.strict) + }) + } +} + +// assertStrictnessVerdict checks the move of the `require` constraint was +// applied in the default mode and refused as nonstandard in the strict one. +func assertStrictnessVerdict(t *testing.T, resp *pb.ApplyEditsResponse, strict bool) { + t.Helper() + if !strict { + if resp.Failure != pb.EditFailure_EDIT_FAILURE_UNSPECIFIED || resp.Error != "" { + t.Fatalf("default-mode edit refused (%s): %s", resp.Failure, resp.Error) + } + if len(resp.Documents) != 1 || !strings.Contains(resp.Documents[0].Content, "part def V {\n require constraint c") { + t.Fatalf("documents = %v, want the constraint moved into V", resp.Documents) + } + return + } + if resp.Failure != pb.EditFailure_EDIT_FAILURE_RESULT_INVALID { + t.Fatalf("strict-mode failure = %s (%s), want RESULT_INVALID", resp.Failure, resp.Error) + } + if !strings.Contains(resp.Error, "OpenSysML extension") { + t.Fatalf("strict-mode error %q does not name the extension", resp.Error) + } + if resp.Content != "" || len(resp.Documents) != 0 || len(resp.Applied) != 0 { + t.Fatalf("a refusal returned notation: content=%q documents=%v applied=%v", resp.Content, resp.Documents, resp.Applied) + } + var found *pb.Diagnostic + for _, d := range resp.Diagnostics { + if strings.Contains(d.Message, "OpenSysML extension") { + found = d + } + } + if found == nil || found.Severity != "error" { + t.Fatalf("diagnostics = %v, want the extension reported as an error", resp.Diagnostics) + } +} + +// notationDiagnostic is the response's single nonstandard-notation finding. +func notationDiagnostic(t *testing.T, resp *pb.ParseFileResponse) *pb.Diagnostic { + t.Helper() + var found []*pb.Diagnostic + for _, d := range resp.Diagnostics { + if strings.Contains(d.Message, "OpenSysML extension") { + found = append(found, d) + } + } + if len(found) != 1 { + t.Fatalf("got %d extension diagnostic(s) in %+v, want 1", len(found), resp.Diagnostics) + } + return found[0] +} diff --git a/internal/frontend/grpc/sweep.go b/internal/frontend/grpc/sweep.go new file mode 100644 index 0000000000..123b8149e3 --- /dev/null +++ b/internal/frontend/grpc/sweep.go @@ -0,0 +1,356 @@ +package grpc + +import ( + "context" + "errors" + "fmt" + "sort" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// sweepResultName names a calc's returned value in a row, so a calc row and an +// analysis case's outputs read alike. +const sweepResultName = "result" + +// RunSweep runs one analysis case or calc once per row of a parameter sweep, as +// the CLI's -sweep and the REPL's %sweep do: every row is an ordinary run, in a +// context of its own, with the swept parameters bound to that row's values. A +// run that failed is that row's error; only a plan that no run follows from +// fails the table. The request's subject and arguments are read once on the +// request's runtime, which answers one that cannot be read, and again in every +// row's, which is what the row's run binds. +func (s *Service) RunSweep(ctx context.Context, req *pb.RunSweepRequest) (*pb.RunSweepResponse, error) { + if err := s.requireCapability(CapabilityVerification); err != nil { + return nil, err + } + v, err := s.newVerifyContext(req.ModelHash, req.Engine) + if err != nil { + return nil, err + } + defer v.release() + sym, err := v.lookup(req.SymbolId) + if err != nil { + return &pb.RunSweepResponse{Error: err.Error()}, nil + } + // A sweep reports a row's outputs and objective verdicts, which carry no + // verification verdict, so a verification case runs through RunVerification. + if runtime.IsVerificationCaseSymbol(sym) { + return sweepFailure(fmt.Errorf("%w: %s is a verification case, which a sweep does not run; run it with RunAnalysis", + runtime.ErrNotAnAnalysis, req.SymbolId)), nil + } + isCase := runtime.IsRunnableCaseSymbol(sym) + if !isCase { + switch sym.Kind { + case symbols.SymbolCalcDef, symbols.SymbolCalcUsage: + default: + return sweepFailure(fmt.Errorf("%w: %s declares neither an analysis case nor a calc", + runtime.ErrNotACalc, req.SymbolId)), nil + } + if req.SubjectSymbolId != "" { + return sweepFailure(fmt.Errorf("%s is a calc, which has no subject", req.SymbolId)), nil + } + } + if _, err := v.subject(req.SubjectSymbolId); err != nil { + return &pb.RunSweepResponse{Error: err.Error()}, nil + } + + positional, named, resp, err := v.sweepArguments(req) + if resp != nil || err != nil { + return resp, err + } + plan, resp, err := v.sweepPlan(req) + if resp != nil || err != nil { + return resp, err + } + names := make([]string, 0, len(named)) + for name := range named { + names = append(names, name) + } + sort.Strings(names) + plan, err = v.runtime.ResolveSweepPlan(sym, plan, len(positional), names) + if err != nil { + return sweepFailure(err), nil + } + + scope := v.declaringScope(sym) + run := func(rt *runtime.Context, bindings []runtime.SweepBinding) (runtime.SweepRunResult, error) { + row := v.on(rt) + subject, err := row.subject(req.SubjectSymbolId) + if err != nil { + return runtime.SweepRunResult{}, err + } + positional, named, resp, err := row.sweepArguments(req) + if err != nil { + return runtime.SweepRunResult{}, err + } + if resp != nil { + return runtime.SweepRunResult{}, errors.New(resp.Error) + } + bound := make(map[string]runtime.Value, len(named)+len(bindings)) + for name, value := range named { + bound[name] = value + } + for _, b := range bindings { + bound[b.Param] = b.Value + } + if !isCase { + value, err := rt.InvokeCalcWith(sym, positional, bound, scope) + if err != nil { + return runtime.SweepRunResult{}, err + } + return runtime.SweepRunResult{ + Outputs: []runtime.CalcOutputValue{{Name: sweepResultName, Value: value}}, + }, nil + } + args := runtime.AnalysisArgs{Subject: subject, Positional: positional, Named: bound} + result, err := rt.RunAnalysis(sym, args, scope, nil) + // The case reports the subject it ran on: the one supplied, or the one + // the usage or the enclosing case bound. + return runtime.SweepRunResult{ + Outputs: result.Outputs, + Verdicts: result.Verdicts, + Subject: result.Subject, + Evaluations: result.Evaluations, + }, err + } + + schedule := v.runtime.Schedule() + answered, err := s.engines.Sweep(ctx, analysis.Request{ + Model: s.model(v.cached), + Subject: req.SymbolId, + Schedule: schedule, + Budget: analysis.BudgetOf(s.budgets, schedule, analysis.Sweep, s.jobs), + Selection: v.engine, + }, plan, run) + if err != nil { + // A caller that went away is the call failing, not a table reporting it. + if ctx.Err() != nil { + return nil, ctx.Err() + } + failed := sweepFailure(err) + st := s.standingOf(answered) + failed.Engine, failed.Strength, failed.Bounds = st.engine, st.strength, st.bounds + return failed, nil + } + return v.sweepResponse(answered.Result.Table(), s.standingOf(answered)), nil +} + +// sweepArguments reads the arguments every row of the sweep binds, the named +// ones in name order so a failure names the same argument every time. +func (v *verifyContext) sweepArguments(req *pb.RunSweepRequest) ([]runtime.Value, map[string]runtime.Value, *pb.RunSweepResponse, error) { + positional := make([]runtime.Value, 0, len(req.Arguments)) + for _, arg := range req.Arguments { + val, resp, err := v.sweepValue(arg, "argument") + if resp != nil || err != nil { + return nil, nil, resp, err + } + positional = append(positional, val) + } + names := make([]string, 0, len(req.NamedArguments)) + for name := range req.NamedArguments { + names = append(names, name) + } + sort.Strings(names) + named := make(map[string]runtime.Value, len(names)) + for _, name := range names { + val, resp, err := v.sweepValue(req.NamedArguments[name], "argument "+name) + if resp != nil || err != nil { + return nil, nil, resp, err + } + named[name] = val + } + return positional, named, nil, nil +} + +// sweepPlan reads the ranges and, where the request draws rather than steps, +// the number of draws and the seed they are drawn from. +func (v *verifyContext) sweepPlan(req *pb.RunSweepRequest) (runtime.SweepPlan, *pb.RunSweepResponse, error) { + plan := runtime.SweepPlan{ + Ranges: make([]runtime.SweepRange, 0, len(req.Ranges)), + Sampled: req.Samples > 0, + Samples: req.Samples, + Seed: req.Seed, + } + if req.Samples < 0 { + return plan, sweepFailure(fmt.Errorf("%w: draw at least one sample, got %d", + runtime.ErrSweepSamples, req.Samples)), nil + } + for _, r := range req.Ranges { + if r.Start == nil || r.End == nil { + return plan, sweepFailure(fmt.Errorf("%w: range %s states no %s endpoint", + runtime.ErrSweepRange, r.Parameter, endpointName(r))), nil + } + out := runtime.SweepRange{Param: r.Parameter, HasStep: r.Step != nil} + from, resp, err := v.sweepValue(r.Start, "start of range "+r.Parameter) + if resp != nil || err != nil { + return plan, resp, err + } + to, resp, err := v.sweepValue(r.End, "end of range "+r.Parameter) + if resp != nil || err != nil { + return plan, resp, err + } + out.From, out.To = from, to + if out.HasStep { + step, resp, err := v.sweepValue(r.Step, "step of range "+r.Parameter) + if resp != nil || err != nil { + return plan, resp, err + } + out.Step = step + } + plan.Ranges = append(plan.Ranges, out) + } + return plan, nil, nil +} + +// endpointName names the endpoint a range left unstated. +func endpointName(r *pb.SweepRange) string { + if r.Start == nil { + return "start" + } + return "end" +} + +// sweepValue reads one value off the wire against the model's index and the +// run's runtime, so a quantity keeps its units and a function binds its calc. +func (v *verifyContext) sweepValue(val *pb.Value, what string) (runtime.Value, *pb.RunSweepResponse, error) { + if err := v.service.requireValueCapabilities(val); err != nil { + return runtime.Value{}, nil, err + } + out, err := protoconv.ProtoToRuntimeValue(v.runtime, val, v.cached.Index, v.sem()) + if err != nil { + return runtime.Value{}, &pb.RunSweepResponse{ + Error: fmt.Sprintf("sweep %s could not be read: %v", what, err), + FailureReason: failureReason(err), + }, nil + } + return out, nil, nil +} + +// sweepFailure answers a request no run followed from. +func sweepFailure(err error) *pb.RunSweepResponse { + return &pb.RunSweepResponse{Error: err.Error(), FailureReason: failureReason(err)} +} + +// sweepResponse spells a table on the wire: one row per run, in plan order, under +// the standing of the plan that ran it. A row's values and objects are read through +// the row's own context, the one that produced them, and the contexts number their +// objects alike, so each row's are renumbered after the rows before it: the table +// then names every object once, and a row's references resolve to that row's objects. +func (v *verifyContext) sweepResponse(table runtime.SweepTable, st standing) *pb.RunSweepResponse { + resp := &pb.RunSweepResponse{ + Parameters: table.Params, + Sampled: table.Sampled, + Seed: table.Seed, + Rows: make([]*pb.SweepRow, 0, len(table.Rows)), + Engine: st.engine, + Strength: st.strength, + Bounds: st.bounds, + } + var ids rowIDs + evaluations := v.service.capabilities.has(CapabilityCaseEvaluations) + for i := range table.Rows { + row := &table.Rows[i] + in := v.on(row.Context) + out := &pb.SweepRow{ElapsedMicros: row.Elapsed.Microseconds()} + for _, binding := range row.Bindings { + out.Inputs = append(out.Inputs, &pb.CalcOutput{ + Name: binding.Param, + Value: in.service.valueToProto(in.runtime, binding.Value, in.cached.Index), + }) + } + if row.Err != nil { + out.Error = row.Err.Error() + out.FailureReason = failureReason(row.Err) + // A client predating case_evaluations reads a failed row as its error alone. + if !evaluations { + resp.Rows = append(resp.Rows, out) + continue + } + } + for _, output := range row.Outputs { + out.Outputs = append(out.Outputs, &pb.CalcOutput{ + Name: output.Name, + Value: in.service.valueToProto(in.runtime, output.Value, in.cached.Index), + }) + } + for j := range row.Verdicts { + out.Verdicts = append(out.Verdicts, st.stamp(in.analysisVerdict(&row.Verdicts[j], row.Subject))) + } + var reported []runtime.AnalysisEvaluation + if evaluations { + reported = row.Evaluations + out.Evaluations = in.caseEvaluations(reported) + } + graph := in.instanceGraphs(in.runRoots(row.Subject, row.Outputs, reported)) + ids.row(out, graph) + resp.Instances = append(resp.Instances, graph...) + resp.Rows = append(resp.Rows, out) + } + return resp +} + +// rowIDs numbers a table's objects across its rows: each row's ids, which its own +// context counted from 1, are shifted past the greatest id a row before it took. +type rowIDs struct { + shift, last int64 +} + +// row renumbers every object reference a row and its graph make, then moves the +// shift past them for the row after. +func (r *rowIDs) row(out *pb.SweepRow, graph []*pb.Instance) { + for _, in := range out.Inputs { + r.value(in.Value) + } + for _, output := range out.Outputs { + r.value(output.Value) + } + for _, verdict := range out.Verdicts { + r.id(&verdict.InstanceId) + } + for _, e := range out.Evaluations { + for _, arg := range e.Arguments { + r.value(arg) + } + r.value(e.Result) + } + for _, inst := range graph { + r.id(&inst.Id) + for _, fv := range inst.FeatureValues { + r.value(fv.Value) + for _, v := range fv.Values { + r.value(v) + } + } + } + r.shift = r.last +} + +// id shifts one reference; 0 names no object and stays so. +func (r *rowIDs) id(id *int64) { + if *id == 0 { + return + } + *id += r.shift + r.last = max(r.last, *id) +} + +// value shifts the references a value makes: an object, a function's object, and +// those of every element it holds. +func (r *rowIDs) value(v *pb.Value) { + switch k := v.GetKind().(type) { + case *pb.Value_InstanceId: + r.id(&k.InstanceId) + case *pb.Value_Function: + if k.Function != nil { + r.id(&k.Function.SelfId) + } + } + for _, nested := range protoconv.NestedValues(v) { + r.value(nested) + } +} diff --git a/internal/frontend/grpc/sweep_test.go b/internal/frontend/grpc/sweep_test.go new file mode 100644 index 0000000000..6b8a4e76dd --- /dev/null +++ b/internal/frontend/grpc/sweep_test.go @@ -0,0 +1,860 @@ +package grpc + +import ( + "context" + "errors" + "math" + "strconv" + "strings" + "sync" + "testing" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "google.golang.org/protobuf/proto" +) + +const sweepModelSource = `package Sw { + private import ScalarValues::*; + + part def Ship { + attribute cost : Real default = 5.0; + } + + calc def Twice { + in n : Integer; + return : Integer = n * 2; + } + + calc def Plus { + in a : Integer; + in b : Integer; + return : Integer = a + b; + } + + calc def Ratio { + in a : Real; + in b : Real; + return : Real = a / b; + } + + calc def Toggle { + in on : Boolean; + return : Boolean = not on; + } + + analysis def CostAnalysis { + subject s : Ship; + in limit : Real = 20.0; + out total : Real = s.cost * limit; + objective affordable { + require constraint { total <= 100.0 } + } + } + + part ship : Ship; +} +` + +func runSweep(t *testing.T, srv *Service, req *pb.RunSweepRequest) *pb.RunSweepResponse { + t.Helper() + resp, err := srv.RunSweep(context.Background(), req) + if err != nil { + t.Fatalf("RunSweep: %v", err) + } + return resp +} + +// intRange is a range between two Integers, stating no step. +func intRange(param string, from, to int64) *pb.SweepRange { + return &pb.SweepRange{Parameter: param, Start: intProto(from), End: intProto(to)} +} + +func intProto(n int64) *pb.Value { + return &pb.Value{Kind: &pb.Value_IntValue{IntValue: n}} +} + +func realProto(f float64) *pb.Value { + return &pb.Value{Kind: &pb.Value_RealValue{RealValue: f}} +} + +// rowText renders a response as one line per row — its inputs, its outputs and +// its failure — so one comparison covers the rows, their order and their values. +func rowText(resp *pb.RunSweepResponse) string { + lines := make([]string, 0, len(resp.Rows)) + for _, row := range resp.Rows { + parts := make([]string, 0, len(row.Inputs)+len(row.Outputs)+1) + for _, in := range row.Inputs { + parts = append(parts, in.Name+"="+valueText(in.Value)) + } + for _, out := range row.Outputs { + parts = append(parts, out.Name+" -> "+valueText(out.Value)) + } + if row.Error != "" { + parts = append(parts, "error: "+row.Error) + } + lines = append(lines, strings.Join(parts, " ")) + } + return strings.Join(lines, "\n") +} + +// valueText renders the kinds of value a sweep binds and returns. +func valueText(val *pb.Value) string { + switch val.GetKind().(type) { + case *pb.Value_IntValue: + return strconv.FormatInt(val.GetIntValue(), 10) + case *pb.Value_RealValue: + return semantics.FormatReal(val.GetRealValue()) + } + return val.String() +} + +// TestRunSweepIntegerRangeStepsByOne verifies a range between Integers runs one +// ordinary calc invocation per value, from its start through its end. +func TestRunSweepIntegerRangeStepsByOne(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-integers") + + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{intRange("n", 1, 4)}, + }) + if resp.Error != "" { + t.Fatalf("RunSweep reported %q", resp.Error) + } + got := rowText(resp) + want := strings.Join([]string{ + "n=1 result -> 2", + "n=2 result -> 4", + "n=3 result -> 6", + "n=4 result -> 8", + }, "\n") + if got != want { + t.Errorf("rows are\n%s\nwant\n%s", got, want) + } + if len(resp.Parameters) != 1 || resp.Parameters[0] != "n" || resp.Sampled { + t.Errorf("response reports parameters %v, sampled %v; want [n], false", resp.Parameters, resp.Sampled) + } + for i, row := range resp.Rows { + if row.ElapsedMicros < 0 { + t.Errorf("row %d took %d micros", i, row.ElapsedMicros) + } + } +} + +// TestRunSweepBindsInTheParameterType verifies a range is typed by the +// parameter it sweeps: Integer literals over a Real parameter reach each row +// as Reals, real literals over an Integer parameter as Integers, and a Real +// parameter samples reals. +func TestRunSweepBindsInTheParameterType(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-typed") + + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Ratio", + NamedArguments: map[string]*pb.Value{"b": realProto(2)}, + Ranges: []*pb.SweepRange{{Parameter: "a", Start: intProto(1), End: intProto(4), Step: intProto(1)}}, + }) + if resp.Error != "" { + t.Fatalf("RunSweep reported %q", resp.Error) + } + want := strings.Join([]string{ + "a=1.0 result -> 0.5", + "a=2.0 result -> 1.0", + "a=3.0 result -> 1.5", + "a=4.0 result -> 2.0", + }, "\n") + if got := rowText(resp); got != want { + t.Errorf("rows are\n%s\nwant\n%s", got, want) + } + for _, row := range resp.Rows { + if _, ok := row.Inputs[0].Value.GetKind().(*pb.Value_RealValue); !ok { + t.Errorf("row binds %v; want a real_value", row.Inputs[0].Value) + } + } + + resp = runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{{Parameter: "n", Start: realProto(1), End: realProto(3), Step: realProto(1)}}, + }) + if resp.Error != "" { + t.Fatalf("RunSweep reported %q", resp.Error) + } + if got, want := rowText(resp), "n=1 result -> 2\nn=2 result -> 4\nn=3 result -> 6"; got != want { + t.Errorf("rows are\n%s\nwant\n%s", got, want) + } + for _, row := range resp.Rows { + if _, ok := row.Inputs[0].Value.GetKind().(*pb.Value_IntValue); !ok { + t.Errorf("row binds %v; want an int_value", row.Inputs[0].Value) + } + } + + resp = runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Ratio", + NamedArguments: map[string]*pb.Value{"b": realProto(1)}, + Ranges: []*pb.SweepRange{intRange("a", 1, 4)}, + Samples: 4, Seed: 7, + }) + if resp.Error != "" || len(resp.Rows) != 4 { + t.Fatalf("RunSweep reported %q with %d row(s); want 4 rows", resp.Error, len(resp.Rows)) + } + for _, row := range resp.Rows { + drawn, ok := row.Inputs[0].Value.GetKind().(*pb.Value_RealValue) + if !ok || drawn.RealValue < 1 || drawn.RealValue >= 4 { + t.Errorf("drew %v; want a real_value in [1.0, 4.0)", row.Inputs[0].Value) + } + } +} + +// TestRunSweepCartesianProduct verifies several ranges run their product, the +// first range given varying slowest. +func TestRunSweepCartesianProduct(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-product") + + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Plus", + Ranges: []*pb.SweepRange{intRange("a", 1, 2), intRange("b", 10, 11)}, + }) + got := rowText(resp) + want := strings.Join([]string{ + "a=1 b=10 result -> 11", + "a=1 b=11 result -> 12", + "a=2 b=10 result -> 12", + "a=2 b=11 result -> 13", + }, "\n") + if got != want { + t.Errorf("rows are\n%s\nwant\n%s", got, want) + } + if len(resp.Parameters) != 2 || resp.Parameters[0] != "a" || resp.Parameters[1] != "b" { + t.Errorf("parameters = %v, want [a b] in the order the ranges were given", resp.Parameters) + } +} + +// TestRunSweepStatedStep verifies a range advances by the step it states, and +// includes the end where the step lands on it. +func TestRunSweepStatedStep(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-step") + + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{{ + Parameter: "n", Start: intProto(0), End: intProto(6), Step: intProto(3), + }}, + }) + if got, want := rowText(resp), "n=0 result -> 0\nn=3 result -> 6\nn=6 result -> 12"; got != want { + t.Errorf("rows are\n%s\nwant\n%s", got, want) + } +} + +// TestRunSweepAnalysisCaseOnSubject verifies an analysis case runs against the +// object named once per row, reporting each row's outputs and verdict. +func TestRunSweepAnalysisCaseOnSubject(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-analysis") + + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::CostAnalysis", SubjectSymbolId: "Sw::ship", + Ranges: []*pb.SweepRange{{ + Parameter: "limit", Start: realProto(2), End: realProto(30), Step: realProto(14), + }}, + }) + if resp.Error != "" { + t.Fatalf("RunSweep reported %q", resp.Error) + } + if len(resp.Rows) != 3 { + t.Fatalf("ran %d row(s); want 3: %s", len(resp.Rows), rowText(resp)) + } + holds := make([]bool, 0, len(resp.Rows)) + for _, row := range resp.Rows { + if len(row.Verdicts) != 1 || row.Verdicts[0].Kind != "objective" { + t.Fatalf("row verdicts = %v; want the objective's", row.Verdicts) + } + if row.Verdicts[0].InstanceTypeId != "Sw::ship" { + t.Errorf("verdict is about %q; want the subject named", row.Verdicts[0].InstanceTypeId) + } + holds = append(holds, row.Verdicts[0].Holds) + } + if want := []bool{true, true, false}; holds[0] != want[0] || holds[1] != want[1] || holds[2] != want[2] { + t.Errorf("objective held %v; want %v", holds, want) + } +} + +// TestRunSweepFailedRunIsARow verifies a run that failed is that row's typed +// error, and that the rows after it are run all the same. +func TestRunSweepFailedRunIsARow(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-failed-row") + + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Ratio", + NamedArguments: map[string]*pb.Value{"a": realProto(4)}, + Ranges: []*pb.SweepRange{intRange("b", -1, 1)}, + }) + if resp.Error != "" { + t.Fatalf("RunSweep reported %q; want the failure on its row", resp.Error) + } + if len(resp.Rows) != 3 { + t.Fatalf("ran %d row(s); want 3: %s", len(resp.Rows), rowText(resp)) + } + failed := resp.Rows[1] + if !strings.Contains(failed.Error, "division by zero") { + t.Errorf("row error = %q; want a division by zero", failed.Error) + } + if failed.FailureReason != pb.FailureReason_FAILURE_REASON_EVALUATION { + t.Errorf("row failure reason = %v; want EVALUATION", failed.FailureReason) + } + if len(failed.Outputs) != 0 { + t.Errorf("failed row reported outputs %v; want none", failed.Outputs) + } + for _, i := range []int{0, 2} { + if resp.Rows[i].Error != "" || len(resp.Rows[i].Outputs) != 1 { + t.Errorf("row %d = %v; want one output and no error", i, resp.Rows[i]) + } + } +} + +// TestRunSweepSamplesAreReproducible verifies a sampled request draws the same +// rows from the same seed, different rows from another, and echoes the seed. +func TestRunSweepSamplesAreReproducible(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-samples") + + draw := func(seed uint64) *pb.RunSweepResponse { + return runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{intRange("n", 0, 1_000_000)}, + Samples: 8, Seed: seed, + }) + } + first, again, other := draw(7), draw(7), draw(8) + if !first.Sampled || first.Seed != 7 || len(first.Rows) != 8 { + t.Fatalf("response reports sampled %v, seed %d, %d row(s); want true, 7, 8", + first.Sampled, first.Seed, len(first.Rows)) + } + if rowText(first) != rowText(again) { + t.Errorf("seed 7 drew\n%s\nthen\n%s", rowText(first), rowText(again)) + } + if rowText(first) == rowText(other) { + t.Errorf("seed 8 drew what seed 7 did:\n%s", rowText(first)) + } +} + +// TestRunSweepFailures verifies the in-band refusals: a target of another kind, +// a calc given a subject, a parameter the target does not declare, one the +// request already binds, a range no values follow from, a request naming no +// range, and a sweep beyond its budget. +func TestRunSweepFailures(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-failures") + + cases := []struct { + name string + req *pb.RunSweepRequest + wants string + reason pb.FailureReason + checked bool + }{ + { + name: "another kind of element", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::ship", + Ranges: []*pb.SweepRange{intRange("n", 1, 2)}}, + wants: "Sw::ship", reason: pb.FailureReason_FAILURE_REASON_WRONG_KIND, checked: true, + }, + { + name: "a calc given a subject", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Twice", + SubjectSymbolId: "Sw::ship", Ranges: []*pb.SweepRange{intRange("n", 1, 2)}}, + wants: "subject", + }, + { + name: "a parameter the target does not declare", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{intRange("nope", 1, 2)}}, + wants: "nope", + }, + { + name: "a parameter the request binds", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Twice", + NamedArguments: map[string]*pb.Value{"n": intProto(3)}, + Ranges: []*pb.SweepRange{intRange("n", 1, 2)}}, + wants: "both an argument", + }, + { + name: "a real range stating no step", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Ratio", + NamedArguments: map[string]*pb.Value{"b": realProto(1)}, + Ranges: []*pb.SweepRange{{ + Parameter: "a", Start: realProto(0.5), End: realProto(1), + }}}, + wants: "step", + }, + { + name: "a fractional step over an Integer parameter", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{{ + Parameter: "n", Start: realProto(1), End: realProto(3), Step: realProto(0.5), + }}}, + wants: "n : Integer", + }, + { + name: "a fractional endpoint sampled over an Integer parameter", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{{Parameter: "n", Start: realProto(1.5), End: realProto(3)}}, + Samples: 2, Seed: 1}, + wants: "n : Integer", + }, + { + name: "a range over a Boolean parameter", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Toggle", + Ranges: []*pb.SweepRange{intRange("on", 0, 1)}}, + wants: "typed by Boolean", + }, + { + name: "a step of zero", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{{ + Parameter: "n", Start: intProto(1), End: intProto(4), Step: intProto(0), + }}}, + wants: "zero", + }, + { + name: "an endpoint left unstated", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{{Parameter: "n", Start: intProto(1)}}}, + wants: "endpoint", + }, + { + name: "no range at all", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Twice"}, + wants: "no sweep range", + }, + { + name: "more runs than the budget allows", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{intRange("n", 1, 100_000)}}, + wants: "OPENSYSML_MAX_SWEEP_RUNS", + }, + { + name: "fewer samples than one", + req: &pb.RunSweepRequest{ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{intRange("n", 1, 9)}, Samples: -1}, + wants: "sample", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := runSweep(t, srv, tc.req) + if !strings.Contains(resp.Error, tc.wants) { + t.Errorf("error = %q; want it to name %q", resp.Error, tc.wants) + } + if len(resp.Rows) != 0 { + t.Errorf("a refused request ran %d row(s)", len(resp.Rows)) + } + if tc.checked && resp.FailureReason != tc.reason { + t.Errorf("failure reason = %v; want %v", resp.FailureReason, tc.reason) + } + }) + } +} + +// TestRunSweepUnknownModel verifies an evicted or unknown model fails the call +// the way every other model-scoped RPC does. +func TestRunSweepUnknownModel(t *testing.T) { + srv := mustNewService(t, 10) + _, err := srv.RunSweep(context.Background(), &pb.RunSweepRequest{ + ModelHash: "nope", SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{intRange("n", 1, 2)}, + }) + if err == nil { + t.Fatal("RunSweep on an unknown model should fail the call") + } +} + +// TestRunSweepCanceledCallerFailsTheCall verifies a caller that has gone away +// fails the call rather than being reported as a table of failed rows. +func TestRunSweepCanceledCallerFailsTheCall(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-canceled") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + resp, err := srv.RunSweep(ctx, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Twice", + Ranges: []*pb.SweepRange{intRange("n", 1, 8)}, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v; want context.Canceled", err) + } + if resp != nil { + t.Errorf("a canceled call reported %d row(s); want no response", len(resp.Rows)) + } +} + +// TestRunSweepVerdictInstancesResolve verifies every row verdict names an +// object the table carries, whose feature values a client can read. +func TestRunSweepVerdictInstancesResolve(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-instances") + + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::CostAnalysis", SubjectSymbolId: "Sw::ship", + Ranges: []*pb.SweepRange{{ + Parameter: "limit", Start: realProto(2), End: realProto(30), Step: realProto(14), + }}, + }) + if resp.Error != "" { + t.Fatalf("RunSweep reported %q", resp.Error) + } + byID := make(map[int64]*pb.Instance, len(resp.Instances)) + for _, inst := range resp.Instances { + byID[inst.Id] = inst + } + for i, row := range resp.Rows { + for _, verdict := range row.Verdicts { + if verdict.InstanceId == 0 { + t.Fatalf("row %d verdict is about no object; want the subject", i) + } + inst, ok := byID[verdict.InstanceId] + if !ok { + t.Fatalf("row %d verdict names object %d, which the table does not carry", + i, verdict.InstanceId) + } + cost, ok := inst.FeatureValues["cost"] + if !ok || cost.Value.GetRealValue() != 5.0 { + t.Errorf("row %d subject holds cost = %v; want 5", i, cost) + } + } + } +} + +// TestRunSweepRefusesAnalysisInputAnArgumentBinds verifies a case's positional +// argument binds the input its run binds, the subject skipped, so sweeping that +// input is refused rather than run twice over. +func TestRunSweepRefusesAnalysisInputAnArgumentBinds(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-collision") + + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::CostAnalysis", SubjectSymbolId: "Sw::ship", + Arguments: []*pb.Value{realProto(3)}, + Ranges: []*pb.SweepRange{{Parameter: "limit", Start: realProto(2), End: realProto(6), Step: realProto(2)}}, + }) + if !strings.Contains(resp.Error, "limit") || len(resp.Rows) != 0 { + t.Errorf("error = %q with %d row(s); want a refusal naming limit", resp.Error, len(resp.Rows)) + } +} + +// TestRunSweepRefusesSweepingTheSubject verifies a case's subject is an object +// an instantiation binds, which no range of values stands for. +func TestRunSweepRefusesSweepingTheSubject(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-subject-range") + + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::CostAnalysis", SubjectSymbolId: "Sw::ship", + Ranges: []*pb.SweepRange{intRange("s", 1, 3)}, + }) + if !strings.Contains(resp.Error, "subject") || len(resp.Rows) != 0 { + t.Errorf("error = %q with %d row(s); want a refusal naming the subject", resp.Error, len(resp.Rows)) + } +} + +// TestRunSweepRefusesNonFiniteRanges verifies a range whose endpoint or step is +// not a finite number states no run to make and is refused as a whole. +func TestRunSweepRefusesNonFiniteRanges(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-non-finite") + + cases := []struct { + name string + start float64 + end float64 + step float64 + }{ + {"start is not a number", math.NaN(), 4, 1}, + {"end is not a number", 0, math.NaN(), 1}, + {"step is not a number", 0, 4, math.NaN()}, + {"end is infinite", 0, math.Inf(1), 1}, + {"start is infinite", math.Inf(-1), 4, 1}, + {"step is infinite", 0, 4, math.Inf(1)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Ratio", + NamedArguments: map[string]*pb.Value{"b": realProto(2)}, + Ranges: []*pb.SweepRange{{ + Parameter: "a", Start: realProto(tc.start), + End: realProto(tc.end), Step: realProto(tc.step), + }}, + }) + if !strings.Contains(resp.Error, "finite") || len(resp.Rows) != 0 { + t.Errorf("error = %q with %d row(s); want a refusal naming a non-finite number", + resp.Error, len(resp.Rows)) + } + }) + } +} + +// TestRunSweepRefusesSamplingANonFiniteRange verifies a drawn range is refused +// for the same reason a stepped one is: no draw lies in it. +func TestRunSweepRefusesSamplingANonFiniteRange(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, sweepModelSource, "sweep-non-finite-samples") + + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Sw::Ratio", + NamedArguments: map[string]*pb.Value{"b": realProto(2)}, + Ranges: []*pb.SweepRange{{ + Parameter: "a", Start: realProto(0), End: realProto(math.Inf(1)), + }}, + Samples: 3, Seed: 7, + }) + if !strings.Contains(resp.Error, "finite") || len(resp.Rows) != 0 { + t.Errorf("error = %q with %d row(s); want a refusal naming a non-finite number", + resp.Error, len(resp.Rows)) + } +} + +// TestRunSweepAnswersAlikeOnOneJobAndOnEight verifies every sweep the service +// answers — a calc over a range, a failing row, a product, a sample, a case on a +// held object, trade studies and a case whose outputs nest objects — is the same +// response on eight jobs as on one apart from the time each row took: rows in plan +// order, the same inputs, outputs, verdicts, evaluations, errors and instances. +func TestRunSweepAnswersAlikeOnOneJobAndOnEight(t *testing.T) { + srv := mustNewService(t, 10) + t.Cleanup(srv.Close) + sweep := mustVerifyModel(t, srv, sweepModelSource, "sweep-jobs") + trade := mustVerifyModel(t, srv, tradeStudyModelSource, "sweep-jobs-trade") + nested := mustVerifyModel(t, srv, nestedObjectsModelSource, "sweep-jobs-nested") + + requests := []*pb.RunSweepRequest{ + {ModelHash: sweep, SymbolId: "Sw::Twice", Ranges: []*pb.SweepRange{intRange("n", 1, 12)}}, + {ModelHash: sweep, SymbolId: "Sw::Ratio", NamedArguments: map[string]*pb.Value{"a": realProto(4)}, + Ranges: []*pb.SweepRange{intRange("b", -1, 1)}}, + {ModelHash: sweep, SymbolId: "Sw::Plus", Ranges: []*pb.SweepRange{intRange("a", 1, 3), intRange("b", 10, 12)}}, + {ModelHash: sweep, SymbolId: "Sw::Twice", Ranges: []*pb.SweepRange{intRange("n", 0, 1_000_000)}, Samples: 8, Seed: 7}, + {ModelHash: sweep, SymbolId: "Sw::CostAnalysis", SubjectSymbolId: "Sw::ship", + Ranges: []*pb.SweepRange{{Parameter: "limit", Start: realProto(2), End: realProto(30), Step: realProto(4)}}}, + {ModelHash: trade, SymbolId: "Trade::weighted", + Ranges: []*pb.SweepRange{{Parameter: "cylinderWeight", Start: realProto(0), End: realProto(20), Step: realProto(5)}}}, + {ModelHash: trade, SymbolId: "Trade::perOffset", Ranges: []*pb.SweepRange{intRange("offset", 3, 4)}}, + {ModelHash: nested, SymbolId: "Nested::grouping", + Ranges: []*pb.SweepRange{{Parameter: "k", Start: realProto(1), End: realProto(4), Step: realProto(1)}}}, + } + for _, req := range requests { + t.Run(req.SymbolId, func(t *testing.T) { + answer := func(jobs int) *pb.RunSweepResponse { + srv.jobs = jobs + resp := runSweep(t, srv, req) + for _, row := range resp.Rows { + row.ElapsedMicros = 0 + } + return resp + } + one, eight := answer(1), answer(8) + if one.Error != "" || len(one.Rows) == 0 { + t.Fatalf("one job answered %q with %d row(s)", one.Error, len(one.Rows)) + } + if !proto.Equal(one, eight) { + t.Errorf("one job answered\n%v\neight jobs answered\n%v", one, eight) + } + }) + } +} + +// sweepWritesModelSource is a case whose body writes its subject and reports it, +// through a value, a verdict, a function read off it and a sequence holding it. +const sweepWritesModelSource = `package Rows { + private import ScalarValues::*; + part def Ship { + attribute cost : Real = 5.0; + calc weigh { in x : Real; return : Real = cost * x; } + } + part ship : Ship; + analysis def Bump { + subject s : Ship; + in tax : Real; + action raise { assign s.cost := s.cost + tax; } + out total : Real = s.cost; + out who : Ship = s; + out scale = s.weigh; + out crew : Ship[*] nonunique = (s, s); + objective cheap { require constraint { total <= 7.0 } } + } +} +` + +// TestRunSweepRowsNameTheirOwnObjects verifies a table whose rows ran in contexts +// of their own, each numbering its objects from 1, still names every object once: +// each row's outputs, verdict, function and sequence resolve to the object that +// row wrote, not to another row's, and the ids differ from row to row. +func TestRunSweepRowsNameTheirOwnObjects(t *testing.T) { + for _, jobs := range []int{1, 8} { + t.Run(strconv.Itoa(jobs)+" jobs", func(t *testing.T) { + srv := mustNewService(t, 10) + srv.jobs = jobs + hash := mustVerifyModel(t, srv, sweepWritesModelSource, "sweep-writes") + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Rows::Bump", SubjectSymbolId: "Rows::ship", + Ranges: []*pb.SweepRange{{Parameter: "tax", Start: realProto(1), End: realProto(3), Step: realProto(1)}}, + }) + if resp.Error != "" || len(resp.Rows) != 3 { + t.Fatalf("RunSweep = %q with %d row(s); want three rows: %s", resp.Error, len(resp.Rows), rowText(resp)) + } + byID := make(map[int64]*pb.Instance, len(resp.Instances)) + for _, inst := range resp.Instances { + if byID[inst.Id] != nil { + t.Fatalf("object %d is in the table twice", inst.Id) + } + byID[inst.Id] = inst + } + if len(resp.Instances) != 3 { + t.Errorf("the table carries %d object(s), want one ship per row", len(resp.Instances)) + } + seen := make(map[int64]int) + for i, row := range resp.Rows { + want := 6.0 + float64(i) + var ids []int64 + for _, out := range row.Outputs { + switch out.Name { + case "total": + if got := out.GetValue().GetRealValue(); got != want { + t.Errorf("row %d total = %v, want %v", i, got, want) + } + case "who", "scale", "crew": + ids = append(ids, idsOf(out.GetValue())...) + } + } + if len(row.Verdicts) != 1 || row.Verdicts[0].InstanceId == 0 { + t.Fatalf("row %d verdicts = %v, want one about the subject", i, row.Verdicts) + } + if row.Verdicts[0].Holds != (want <= 7.0) { + t.Errorf("row %d verdict holds = %v on total %v", i, row.Verdicts[0].Holds, want) + } + ids = append(ids, row.Verdicts[0].InstanceId) + if len(ids) != 5 { + t.Fatalf("row %d names %d object(s) %v, want its ship five times", i, len(ids), ids) + } + for _, id := range ids { + if id != ids[0] { + t.Fatalf("row %d names objects %v, want one ship", i, ids) + } + } + inst := byID[ids[0]] + if inst == nil { + t.Fatalf("row %d names object %d, which the table does not carry", i, ids[0]) + } + if got := inst.FeatureValues["cost"].GetValue().GetRealValue(); got != want { + t.Errorf("row %d resolves to a ship of cost %v, want %v", i, got, want) + } + if prior, ok := seen[ids[0]]; ok { + t.Errorf("rows %d and %d name the same object %d", prior, i, ids[0]) + } + seen[ids[0]] = i + } + }) + } +} + +// heldBehaviourModelSource declares subjects whose types run a behaviour — a beacon +// exhibiting a state machine, a tug performing an action parked at a wait on the +// clock — and a case reading its subject. +const heldBehaviourModelSource = `package Held { + private import ScalarValues::*; + private import SI::*; + attribute def Lit; + part def Ship { attribute cost : Real = 5.0; } + part def Beacon :> Ship { + exhibit state blinking { + entry; then off; + state off; + transition first off accept Lit do assign cost := cost + 10.0 then on; + state on; + } + } + part beacon : Beacon; + part def Tug :> Ship { + perform action tow { + first start; + then action wait accept after 2 [s]; + then action pull { assign cost := cost + 1.0; } + then done; + } + } + part tug : Tug; + analysis def Quote { + subject s : Ship; + in tax : Real; + out total : Real = s.cost * (1.0 + tax); + } +} +` + +// TestRunSweepOverASubjectRunningABehaviour verifies a sweep whose subject's type +// exhibits a state machine, or performs an action, answers on one job and on eight +// the rows the REPL and the CLI print for the same sweep over the object held there. +func TestRunSweepOverASubjectRunningABehaviour(t *testing.T) { + srv := mustNewService(t, 10) + t.Cleanup(srv.Close) + hash := mustVerifyModel(t, srv, heldBehaviourModelSource, "held-behaviour") + want := "tax=0.0 total -> 5.0\ntax=0.25 total -> 6.25\ntax=0.5 total -> 7.5" + for _, subject := range []string{"Held::beacon", "Held::tug"} { + t.Run(subject, func(t *testing.T) { + for _, jobs := range []int{1, 8} { + srv.jobs = jobs + resp := runSweep(t, srv, &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Held::Quote", SubjectSymbolId: subject, + Ranges: []*pb.SweepRange{{Parameter: "tax", Start: realProto(0), End: realProto(0.5), Step: realProto(0.25)}}, + }) + if resp.Error != "" { + t.Fatalf("%d job(s): RunSweep reported %q", jobs, resp.Error) + } + if got := rowText(resp); got != want { + t.Errorf("%d job(s) answered\n%s\nwant\n%s", jobs, got, want) + } + } + }) + } +} + +// TestRunSweepOverSubjectsRunningABehaviourConcurrently runs two sweeps over one +// verified model on two goroutines, eight jobs each, over the beacon and the tug, and +// checks each answers as it does alone. +func TestRunSweepOverSubjectsRunningABehaviourConcurrently(t *testing.T) { + srv := mustNewService(t, 10) + t.Cleanup(srv.Close) + srv.jobs = 8 + hash := mustVerifyModel(t, srv, heldBehaviourModelSource, "held-behaviour") + want := "tax=0.0 total -> 5.0\ntax=0.25 total -> 6.25\ntax=0.5 total -> 7.5" + var wg sync.WaitGroup + answers := make(chan string, 2) + for _, subject := range []string{"Held::beacon", "Held::tug"} { + wg.Add(1) + go func() { + defer wg.Done() + resp, err := srv.RunSweep(context.Background(), &pb.RunSweepRequest{ + ModelHash: hash, SymbolId: "Held::Quote", SubjectSymbolId: subject, + Ranges: []*pb.SweepRange{{Parameter: "tax", Start: realProto(0), End: realProto(0.5), Step: realProto(0.25)}}, + }) + if err != nil { + answers <- subject + ": " + err.Error() + return + } + if resp.Error != "" { + answers <- subject + ": " + resp.Error + return + } + answers <- rowText(resp) + }() + } + wg.Wait() + close(answers) + for got := range answers { + if got != want { + t.Errorf("a concurrent sweep answered\n%s\nwant\n%s", got, want) + } + } +} diff --git a/internal/grpc/typefacts.go b/internal/frontend/grpc/typefacts.go similarity index 91% rename from internal/grpc/typefacts.go rename to internal/frontend/grpc/typefacts.go index 8d5477e427..13ea7dd1c6 100644 --- a/internal/grpc/typefacts.go +++ b/internal/frontend/grpc/typefacts.go @@ -1,15 +1,15 @@ package grpc import ( - "strconv" "sync" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/passes" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // fqnScalarQuantityValue is the library type every quantity value specializes. @@ -50,9 +50,8 @@ func (sc *SymbolContext) Lock() func() { // NewSymbolContext builds a conversion context over a symbol index. func NewSymbolContext(idx *symbols.Index) *SymbolContext { resolver := resolve.New(idx) - sem := semantics.NewModel(resolver) + sem := passes.NewTypedModel(resolver) resolver.SetModel(sem) - sem.SetArgumentTyper(passes.NewArgumentTyper(resolver, sem)) return &SymbolContext{Index: idx, Resolver: resolver, Semantics: sem} } @@ -105,7 +104,7 @@ func (sc *SymbolContext) specializationsOf(sym *symbols.Symbol) []*pb.Specializa } spec := &pb.Specialization{ Kind: kind, - Declared: semantics.QualifiedNameText(qn), + Declared: qn.Text(), } if target := sc.resolveFrom(sym, qn); target != nil { spec.TargetId = sc.Index.GetFQN(target) @@ -137,22 +136,11 @@ func (sc *SymbolContext) multiplicityOf(sym *symbols.Symbol) *pb.MultiplicityInf return nil } return &pb.MultiplicityInfo{ - Lower: boundText(rng.Lower), - Upper: boundText(rng.Upper), + Lower: protoconv.BoundText(rng.Lower), + Upper: protoconv.BoundText(rng.Upper), } } -// boundText renders a multiplicity bound; an unevaluable one renders empty. -func boundText(b semantics.Bound) string { - if !b.Known { - return "" - } - if b.Infinite { - return "*" - } - return strconv.FormatInt(b.Value, 10) -} - // typeInfoOf derives the static type facts of a def or usage. Anything the // model cannot derive is left empty rather than guessed at. func (sc *SymbolContext) typeInfoOf(sym *symbols.Symbol) *pb.TypeInfo { @@ -181,7 +169,7 @@ func (sc *SymbolContext) usageTypeInfo(sym *symbols.Symbol, decl *ast.Usage) *pb if qn == nil { continue } - info.Declared = semantics.QualifiedNameText(qn) + info.Declared = qn.Text() if target := sc.resolveFrom(sym, qn); target != nil { info.ResolvedId = sc.Index.GetFQN(target) info.ResolvedKind = target.Kind.String() diff --git a/internal/grpc/typefacts_test.go b/internal/frontend/grpc/typefacts_test.go similarity index 100% rename from internal/grpc/typefacts_test.go rename to internal/frontend/grpc/typefacts_test.go diff --git a/internal/grpc/undetermined_value_test.go b/internal/frontend/grpc/undetermined_value_test.go similarity index 89% rename from internal/grpc/undetermined_value_test.go rename to internal/frontend/grpc/undetermined_value_test.go index e876cb66a4..82d31e4903 100644 --- a/internal/grpc/undetermined_value_test.go +++ b/internal/frontend/grpc/undetermined_value_test.go @@ -7,8 +7,9 @@ import ( "testing" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" ) const undeterminedModel = `package Demo { @@ -104,7 +105,7 @@ func TestEvaluate_ConstantOperandFoldsBesideUndetermined(t *testing.T) { func TestValueToProto_UndeterminedIsNotUnset(t *testing.T) { one := semantics.Bound{Value: 1, Known: true} val := runtime.NewUndeterminedValue("u has no value in the model", semantics.Range{Lower: one, Upper: one}) - got := ValueToProtoIn(nil, val, nil) + got := protoconv.ValueToProtoIn(nil, val, nil) u, ok := got.GetKind().(*pb.Value_Undetermined) if !ok { t.Fatalf("kind = %T, want undetermined", got.GetKind()) @@ -120,14 +121,14 @@ func TestValueToProto_UndeterminedIsNotUnset(t *testing.T) { // Undetermined is something to read and not to supply, as unset is. func TestProtoToValue_RejectsUndetermined(t *testing.T) { arm := &pb.Value{Kind: &pb.Value_Undetermined{Undetermined: &pb.Undetermined{Reason: "x"}}} - if _, err := ProtoToValueIn(arm, nil, nil); !errors.Is(err, ErrUndeterminedNotAccepted) { - t.Errorf("err = %v, want %v", err, ErrUndeterminedNotAccepted) + if _, err := protoconv.ProtoToValueIn(arm, nil, nil); !errors.Is(err, protoconv.ErrUndeterminedNotAccepted) { + t.Errorf("err = %v, want %v", err, protoconv.ErrUndeterminedNotAccepted) } seq := &pb.Value{Kind: &pb.Value_Sequence{Sequence: &pb.ValueSequence{ Elements: []*pb.Value{{Kind: &pb.Value_IntValue{IntValue: 1}}, arm}, }}} - if _, err := ProtoToValueIn(seq, nil, nil); !errors.Is(err, ErrUndeterminedNotAccepted) { - t.Errorf("in a sequence: err = %v, want %v", err, ErrUndeterminedNotAccepted) + if _, err := protoconv.ProtoToValueIn(seq, nil, nil); !errors.Is(err, protoconv.ErrUndeterminedNotAccepted) { + t.Errorf("in a sequence: err = %v, want %v", err, protoconv.ErrUndeterminedNotAccepted) } } diff --git a/internal/grpc/unset_feature_value_test.go b/internal/frontend/grpc/unset_feature_value_test.go similarity index 90% rename from internal/grpc/unset_feature_value_test.go rename to internal/frontend/grpc/unset_feature_value_test.go index a7fc03ee3c..9cd40c2c30 100644 --- a/internal/grpc/unset_feature_value_test.go +++ b/internal/frontend/grpc/unset_feature_value_test.go @@ -6,6 +6,7 @@ import ( "testing" pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" ) const unsetFeatureValueModel = ` @@ -72,16 +73,16 @@ func TestInstantiate_UnsetFeatureValueContributesNoInstance(t *testing.T) { // Unset says what a feature value holds, which is something to read and not to supply, so // a caller sending it is told so rather than having it read as some value. func TestProtoToValue_RejectsUnset(t *testing.T) { - _, err := ProtoToValueIn(&pb.Value{Kind: &pb.Value_Unset{Unset: true}}, nil, nil) - if !errors.Is(err, ErrUnsetNotAccepted) { - t.Errorf("err = %v, want %v", err, ErrUnsetNotAccepted) + _, err := protoconv.ProtoToValueIn(&pb.Value{Kind: &pb.Value_Unset{Unset: true}}, nil, nil) + if !errors.Is(err, protoconv.ErrUnsetNotAccepted) { + t.Errorf("err = %v, want %v", err, protoconv.ErrUnsetNotAccepted) } seq := &pb.Value{Kind: &pb.Value_Sequence{Sequence: &pb.ValueSequence{ Elements: []*pb.Value{{Kind: &pb.Value_IntValue{IntValue: 1}}, {Kind: &pb.Value_Unset{Unset: true}}}, }}} - if _, err := ProtoToValueIn(seq, nil, nil); !errors.Is(err, ErrUnsetNotAccepted) { - t.Errorf("in a sequence: err = %v, want %v", err, ErrUnsetNotAccepted) + if _, err := protoconv.ProtoToValueIn(seq, nil, nil); !errors.Is(err, protoconv.ErrUnsetNotAccepted) { + t.Errorf("in a sequence: err = %v, want %v", err, protoconv.ErrUnsetNotAccepted) } } diff --git a/internal/frontend/grpc/validate.go b/internal/frontend/grpc/validate.go new file mode 100644 index 0000000000..e32b3181ff --- /dev/null +++ b/internal/frontend/grpc/validate.go @@ -0,0 +1,103 @@ +package grpc + +import ( + "context" + "strings" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// verdictObject is the kind of the verdict about a validated object as a whole. +const verdictObject = "object" + +// ValidateInstance checks every assertion about an object of the part named and the +// objects it holds, reporting one verdict per assertion beside a summary for the object. +func (s *Service) ValidateInstance(ctx context.Context, req *pb.ValidateInstanceRequest) (*pb.ValidateInstanceResponse, error) { + if err := s.requireCapability(CapabilityVerification); err != nil { + return nil, err + } + v, err := s.newVerifyContext(req.ModelHash, req.Engine) + if err != nil { + return nil, err + } + defer v.release() + if req.SymbolId == "" { + return &pb.ValidateInstanceResponse{Error: "symbol_id names no part to validate an object of"}, nil + } + root, err := v.object(req.SymbolId) + if err != nil { + return &pb.ValidateInstanceResponse{Error: err.Error(), FailureReason: failureReason(err)}, nil + } + + // One question to the engines, the object as a whole, whose standing every + // verdict of the answer carries. + report, plan, evalErr := perform(ctx, v, req.SymbolId, func(rt *runtime.Context) (runtime.ValidationReport, error) { + return rt.ValidateObject(root, v.cached.DocumentRoots()) + }, analysis.ValidationAnswer) + if err := callerGone(ctx, evalErr); err != nil { + return nil, err + } + if evalErr != nil { + return &pb.ValidateInstanceResponse{ + Error: evalErr.Error(), + FailureReason: failureReason(evalErr), + Instances: v.instanceGraph(root), + }, nil + } + + resp := &pb.ValidateInstanceResponse{ + Summary: v.summaryVerdict(req.SymbolId, root, report, plan), + Instances: v.instanceGraph(root), + Bounded: report.Bounded, + } + // The cases verifying one requirement answer once for the response, however + // many verdicts are about that requirement. + verified := map[*symbols.Symbol]bool{} + for _, ov := range report.Verdicts { + resp.Verdicts = append(resp.Verdicts, v.objectVerdict(ov, plan)) + if req := ov.Requirement; req != nil && !verified[req] { + verified[req] = true + resp.VerificationVerdicts = append(resp.VerificationVerdicts, v.requirementVerifications(req)...) + } + } + return resp, nil +} + +// objectVerdict is one assertion's verdict about one object of a validated tree, +// naming the object by its path from the validated one. +func (v *verifyContext) objectVerdict(ov runtime.ObjectVerdict, plan analysis.Plan) *pb.Verdict { + kind := verdictConstraint + switch ov.Kind { + case runtime.AssertionRequirement: + kind = verdictRequirement + case runtime.AssertionSatisfaction: + kind = verdictSatisfy + } + out := v.verdict(kind, ov.Element, ov.Text, ov.Subject, ov.Status == runtime.ValidationHolds, ov.Err, plan) + out.InstancePath = strings.Join(ov.Path, ".") + if ov.Requirement != nil { + out.RequirementId = namedFQN(v.cached.Index, ov.Requirement) + } + return out +} + +// summaryVerdict is the verdict about the object as a whole: it holds when every +// assertion holds and the walk was complete, and is undecided with the reason otherwise. +func (v *verifyContext) summaryVerdict(symbolID string, root *runtime.Instance, report runtime.ValidationReport, plan analysis.Plan) *pb.Verdict { + out := v.service.standingOf(plan).stamp(&pb.Verdict{ + Kind: verdictObject, + ElementId: symbolID, + Element: symbolID, + Holds: report.Valid(), + InstanceId: root.ID, + InstanceTypeId: namedFQN(v.cached.Index, root.Type), + }) + if !report.Valid() && report.Status() != runtime.ValidationViolated { + out.Error = analysis.ValidationReason(report) + out.FailureReason = pb.FailureReason_FAILURE_REASON_EVALUATION + } + return out +} diff --git a/internal/frontend/grpc/validate_test.go b/internal/frontend/grpc/validate_test.go new file mode 100644 index 0000000000..25c40a87dc --- /dev/null +++ b/internal/frontend/grpc/validate_test.go @@ -0,0 +1,264 @@ +package grpc + +import ( + "context" + "strings" + "testing" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" +) + +// validateModelSource holds a car whose engine, three wheels and injector each +// carry assertions, some holding and some failing, with a satisfaction stated +// about the nested engine, so one object exercises every verdict a validation +// reports and every depth the walk reaches. +const validateModelSource = `package Demo { + part def Injector { + attribute rate = 2.0; + assert constraint ratePositive { rate > 0.0 } + } + + part def Engine { + attribute power = 300.0; + part injector : Injector; + assert constraint { power < 200.0 } + } + + part def Wheel { + attribute pressure default = 32.0; + assert constraint pressureOk { pressure >= 30.0 } + } + + part def Car { + attribute mass = 1500.0; + part engine : Engine; + part wheels : Wheel[3] { + attribute :>> pressure = 20.0; + } + assert constraint massOk { mass < 2000.0 } + requirement lightEnough { + attribute m = mass; + require constraint { m < 1600.0 } + } + } + + requirement def PowerReq { + subject e : Engine; + require constraint { e.power > 100.0 } + } + + part car : Car; + requirement strongEngine : PowerReq; + satisfy strongEngine by car.engine; + + part def Loose { + attribute a; + assert constraint { a > 0.0 } + } + part loose : Loose; + + part sound : Wheel; + + part def Crate; + part crate : Crate; +} +` + +// TestValidateInstanceReportsEveryAssertionOnEveryObject verifies a validation +// walks the object held to every depth and answers every assertion about each +// object reached, naming the object by its path. +func TestValidateInstanceReportsEveryAssertionOnEveryObject(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, validateModelSource, "validate-instance") + + resp, err := srv.ValidateInstance(context.Background(), &pb.ValidateInstanceRequest{ + ModelHash: hash, + SymbolId: "Demo::car", + }) + if err != nil { + t.Fatalf("ValidateInstance: %v", err) + } + if resp.Error != "" { + t.Fatalf("ValidateInstance reported %q", resp.Error) + } + + type want struct { + kind, element, path string + holds bool + } + wants := []want{ + {"constraint", "Demo::Car::massOk", "", true}, + {"requirement", "Demo::Car::lightEnough", "", true}, + {"constraint", "", "engine", false}, + {"satisfy", "", "engine", true}, + {"constraint", "Demo::Injector::ratePositive", "engine.injector", true}, + {"constraint", "Demo::Wheel::pressureOk", "wheels[1]", false}, + {"constraint", "Demo::Wheel::pressureOk", "wheels[2]", false}, + {"constraint", "Demo::Wheel::pressureOk", "wheels[3]", false}, + } + if len(resp.Verdicts) != len(wants) { + t.Fatalf("got %d verdicts, want %d: %v", len(resp.Verdicts), len(wants), resp.Verdicts) + } + for i, w := range wants { + got := resp.Verdicts[i] + if got.Kind != w.kind || got.ElementId != w.element || got.InstancePath != w.path || got.Holds != w.holds { + t.Errorf("verdict %d = %s %q at %q holds=%v, want %s %q at %q holds=%v", + i, got.Kind, got.ElementId, got.InstancePath, got.Holds, w.kind, w.element, w.path, w.holds) + } + if got.Error != "" { + t.Errorf("verdict %d: a false condition is a verdict, not an error: %q", i, got.Error) + } + if !got.Holds && got.Condition == "" { + t.Errorf("verdict %d fails but names no condition", i) + } + if got.InstanceId == 0 { + t.Errorf("verdict %d names no instance", i) + } + } + satisfy := resp.Verdicts[3] + if satisfy.RequirementId != "Demo::strongEngine" { + t.Errorf("satisfaction verdict requirement_id = %q, want Demo::strongEngine", satisfy.RequirementId) + } + if satisfy.Element != "satisfy strongEngine by car.engine" { + t.Errorf("satisfaction verdict element = %q", satisfy.Element) + } + + if resp.Summary == nil { + t.Fatal("no summary verdict") + } + if resp.Summary.Kind != "object" || resp.Summary.ElementId != "Demo::car" || resp.Summary.InstanceTypeId != "Demo::car" { + t.Errorf("summary = %v, want an object verdict about an object of Demo::car", resp.Summary) + } + if resp.Summary.Holds || resp.Summary.Error != "" { + t.Errorf("summary holds=%v error=%q, want a plain verdict of false", resp.Summary.Holds, resp.Summary.Error) + } + if resp.Summary.InstanceId != resp.Verdicts[0].InstanceId { + t.Errorf("summary is about instance %d, the root's assertions about %d", resp.Summary.InstanceId, resp.Verdicts[0].InstanceId) + } + if resp.Summary.Engine == "" { + t.Error("summary names no engine") + } + if resp.Bounded { + t.Error("a finite object tree was reported bounded") + } + // The car, the engine, the injector, three wheels and the requirement the + // car carries. + if len(resp.Instances) != 7 { + t.Errorf("got %d instances, want 7", len(resp.Instances)) + } +} + +// TestValidateInstanceOfAValidObjectHolds verifies an object every assertion +// about which holds gets a summary that holds. +func TestValidateInstanceOfAValidObjectHolds(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, validateModelSource, "validate-instance") + + resp, err := srv.ValidateInstance(context.Background(), &pb.ValidateInstanceRequest{ + ModelHash: hash, + SymbolId: "Demo::sound", + }) + if err != nil { + t.Fatalf("ValidateInstance: %v", err) + } + if resp.Error != "" { + t.Fatalf("ValidateInstance reported %q", resp.Error) + } + if len(resp.Verdicts) != 1 || !resp.Verdicts[0].Holds { + t.Fatalf("verdicts = %v, want one that holds", resp.Verdicts) + } + if !resp.Summary.Holds || resp.Summary.Error != "" { + t.Errorf("summary holds=%v error=%q, want it to hold", resp.Summary.Holds, resp.Summary.Error) + } +} + +// TestValidateInstanceUndecidedIsNotAVerdict verifies an assertion that cannot +// be evaluated leaves both its own verdict and the summary undecided, with the +// reason, rather than answering false. +func TestValidateInstanceUndecidedIsNotAVerdict(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, validateModelSource, "validate-instance") + + resp, err := srv.ValidateInstance(context.Background(), &pb.ValidateInstanceRequest{ + ModelHash: hash, + SymbolId: "Demo::loose", + }) + if err != nil { + t.Fatalf("ValidateInstance: %v", err) + } + if resp.Error != "" { + t.Fatalf("an unevaluable assertion is an undecided verdict, not a failure: %q", resp.Error) + } + if len(resp.Verdicts) != 1 { + t.Fatalf("verdicts = %v, want one", resp.Verdicts) + } + v := resp.Verdicts[0] + if v.Holds || v.Error == "" || v.FailureReason != pb.FailureReason_FAILURE_REASON_EVALUATION { + t.Errorf("verdict = %v, want undecided for an evaluation failure", v) + } + if resp.Summary.Holds || !strings.Contains(resp.Summary.Error, "1 undecided") { + t.Errorf("summary holds=%v error=%q, want undecided naming the one undecided assertion", + resp.Summary.Holds, resp.Summary.Error) + } + if resp.Summary.FailureReason != pb.FailureReason_FAILURE_REASON_EVALUATION { + t.Errorf("summary failure_reason = %v, want EVALUATION", resp.Summary.FailureReason) + } +} + +// TestValidateInstanceStatingNoAssertionDecidesNothing verifies an object no assertion +// is about is not shown valid: the summary neither holds nor is a violation, and says why. +func TestValidateInstanceStatingNoAssertionDecidesNothing(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, validateModelSource, "validate-instance") + + resp, err := srv.ValidateInstance(context.Background(), &pb.ValidateInstanceRequest{ + ModelHash: hash, + SymbolId: "Demo::crate", + }) + if err != nil { + t.Fatalf("ValidateInstance: %v", err) + } + if resp.Error != "" || len(resp.Verdicts) != 0 { + t.Fatalf("error=%q verdicts=%v, want an answer with no verdict", resp.Error, resp.Verdicts) + } + if resp.Summary == nil || resp.Summary.Holds || !strings.Contains(resp.Summary.Error, "states no assertion") { + t.Errorf("summary = %v, want undecided for want of an assertion", resp.Summary) + } + if resp.Summary.FailureReason != pb.FailureReason_FAILURE_REASON_EVALUATION { + t.Errorf("summary failure_reason = %v, want EVALUATION", resp.Summary.FailureReason) + } +} + +// TestValidateInstanceRefusesWhatIsNoPart verifies naming nothing, an unknown symbol or +// one with no object (a package, an attribute) is a failure, the last of the wrong kind. +func TestValidateInstanceRefusesWhatIsNoPart(t *testing.T) { + srv := mustNewService(t, 10) + hash := mustVerifyModel(t, srv, validateModelSource, "validate-instance") + + for name, tc := range map[string]struct { + symbol string + reason pb.FailureReason + }{ + "nothing": {"", pb.FailureReason_FAILURE_REASON_UNSPECIFIED}, + "unknown": {"Demo::nosuch", pb.FailureReason_FAILURE_REASON_EVALUATION}, + "package": {"Demo", pb.FailureReason_FAILURE_REASON_WRONG_KIND}, + "attribute": {"Demo::Car::mass", pb.FailureReason_FAILURE_REASON_WRONG_KIND}, + } { + resp, err := srv.ValidateInstance(context.Background(), &pb.ValidateInstanceRequest{ + ModelHash: hash, + SymbolId: tc.symbol, + }) + if err != nil { + t.Fatalf("ValidateInstance(%s): %v", name, err) + } + if resp.Error == "" { + t.Errorf("ValidateInstance(%s) reported no failure: %v", name, resp) + } + if resp.FailureReason != tc.reason { + t.Errorf("ValidateInstance(%s) failure_reason = %v (%s), want %v", name, resp.FailureReason, resp.Error, tc.reason) + } + if resp.Summary != nil || len(resp.Verdicts) != 0 { + t.Errorf("ValidateInstance(%s) reported verdicts beside its failure", name) + } + } +} diff --git a/internal/grpc/verification_multidoc_test.go b/internal/frontend/grpc/verification_multidoc_test.go similarity index 100% rename from internal/grpc/verification_multidoc_test.go rename to internal/frontend/grpc/verification_multidoc_test.go diff --git a/internal/grpc/verification_verdict_test.go b/internal/frontend/grpc/verification_verdict_test.go similarity index 100% rename from internal/grpc/verification_verdict_test.go rename to internal/frontend/grpc/verification_verdict_test.go diff --git a/internal/grpc/verify.go b/internal/frontend/grpc/verify.go similarity index 82% rename from internal/grpc/verify.go rename to internal/frontend/grpc/verify.go index a4ab8d44b9..ecc609b572 100644 --- a/internal/grpc/verify.go +++ b/internal/frontend/grpc/verify.go @@ -7,11 +7,13 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/objref" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) // Verdict kinds, as reported in Verdict.kind. @@ -32,6 +34,7 @@ func failureReason(err error) pb.FailureReason { case errors.Is(err, runtime.ErrNotAConstraint), errors.Is(err, runtime.ErrNotARequirement), errors.Is(err, runtime.ErrNotASatisfaction), + errors.Is(err, runtime.ErrNotAnObject), errors.Is(err, runtime.ErrNotACalc), errors.Is(err, runtime.ErrNotAnAnalysis): return pb.FailureReason_FAILURE_REASON_WRONG_KIND @@ -104,18 +107,91 @@ func (v *verifyContext) declaringScope(sym *symbols.Symbol) *symbols.Scope { // subject instantiates the part/usage a request named, so a verdict can be about // concrete values. An empty name is no subject, which is not an error: the -// verdict is then about declared defaults. +// verdict is then about declared defaults. A path (`Mission::mission.vehicle`) names a nested one. func (v *verifyContext) subject(symbolID string) (*runtime.Instance, error) { + return v.named("subject", symbolID) +} + +// performer is the object a request's action or machine is performed by, named +// as a subject is; empty is no performer. +func (v *verifyContext) performer(symbolID string) (*runtime.Instance, error) { + return v.named("performer", symbolID) +} + +// named is the object symbolID names in the role given: an object of the declaration it +// names, or the one a declaration-rooted path reaches, spelled with `.` or `::` as the CLI +// reads it — `Wire::pair::craft` walks into pair's object as `Wire::pair.craft` does. +func (v *verifyContext) named(role, symbolID string) (*runtime.Instance, error) { if symbolID == "" { return nil, nil } + if ref, err := objref.Parse(symbolID); objref.LooksLikePath(symbolID) || err == nil && len(ref.Segments) > 1 { + return v.objectAt(role, symbolID) + } sym, err := v.lookup(symbolID) if err != nil { return nil, err } + return v.instantiate(role, symbolID, sym) +} + +// objectAt is the object a declaration-rooted path names: the longest leading run of +// segments naming a declaration is instantiated, the rest walked from it; an id names none. +// The index holds declarations alone, so a member reached through a usage's type +// (`pair::craft`) names no root and is walked inside the usage's object. +func (v *verifyContext) objectAt(role, path string) (*runtime.Instance, error) { + ref, err := objref.Parse(path) + if err != nil { + return nil, err + } + if ref.ID > 0 { + return nil, fmt.Errorf("%s %s names an object by id, which a call creates none of: name a declaration, or a path from one such as mission.vehicle", role, path) + } + for i := objref.Head(ref.Segments); i > 0; i-- { + name := objref.JoinTyped(ref.Segments[:i]) + syms := lookupNamed(v.cached.Index, name) + if len(syms) == 0 { + continue + } + if i > 1 && len(lookupNamed(v.cached.Index, objref.DeclaredRun(ref.Segments[:i]))) == 0 { + continue + } + if objref.IsNamespace(syms[0]) { + break + } + root, err := v.instantiate(role, name, syms[0]) + if err != nil { + return nil, err + } + walker := objref.Walker{Runtime: v.runtime, Index: v.cached.Index} + inst, _, err := walker.Walk(root, name, ref.Segments[i:]) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", role, path, err) + } + return inst, nil + } + return nil, fmt.Errorf("symbol not found: %s", objref.JoinTyped(ref.Segments[:max(objref.Head(ref.Segments), 1)])) +} + +// object instantiates the symbol a request named to validate as a whole, which +// must have objects: a package or an attribute is ErrNotAnObject, a wrong kind. +func (v *verifyContext) object(symbolID string) (*runtime.Instance, error) { + sym, err := v.lookup(symbolID) + if err != nil { + return nil, err + } + if err := runtime.RequireObject(sym); err != nil { + return nil, err + } + return v.instantiate("subject", symbolID, sym) +} + +// instantiate materializes an object of sym, named in a failure by its role and +// as the request spelt it. +func (v *verifyContext) instantiate(role, symbolID string, sym *symbols.Symbol) (*runtime.Instance, error) { inst, err := v.runtime.Instantiate(sym) if err != nil { - return nil, fmt.Errorf("instantiation of subject %s failed: %w", symbolID, err) + return nil, fmt.Errorf("instantiation of %s %s failed: %w", role, symbolID, err) } return inst, nil } @@ -177,8 +253,7 @@ func (v *verifyContext) instanceGraph(inst *runtime.Instance) []*pb.Instance { if inst == nil { return nil } - _, all := v.service.instanceGraphToProto(v.runtime, inst, v.cached.Index) - return all + return v.service.instanceGraphToProto(v.runtime, inst, v.cached.Index).All } // VerifyConstraint evaluates a constraint definition or usage, as the REPL's @@ -411,7 +486,7 @@ func (s *Service) EvaluateCalc(ctx context.Context, req *pb.EvaluateCalcRequest) if err := s.requireValueCapabilities(arg); err != nil { return nil, err } - val, cerr := ProtoToRuntimeValue(v.runtime, arg, v.cached.Index, v.sem()) + val, cerr := protoconv.ProtoToRuntimeValue(v.runtime, arg, v.cached.Index, v.sem()) if cerr != nil { return &pb.EvaluateCalcResponse{ Error: fmt.Sprintf("calc argument could not be read: %v", cerr), diff --git a/internal/grpc/verify_identity_test.go b/internal/frontend/grpc/verify_identity_test.go similarity index 100% rename from internal/grpc/verify_identity_test.go rename to internal/frontend/grpc/verify_identity_test.go diff --git a/internal/grpc/verify_subject_test.go b/internal/frontend/grpc/verify_subject_test.go similarity index 100% rename from internal/grpc/verify_subject_test.go rename to internal/frontend/grpc/verify_subject_test.go diff --git a/internal/grpc/verify_test.go b/internal/frontend/grpc/verify_test.go similarity index 98% rename from internal/grpc/verify_test.go rename to internal/frontend/grpc/verify_test.go index a3623fcd27..d08cf89510 100644 --- a/internal/grpc/verify_test.go +++ b/internal/frontend/grpc/verify_test.go @@ -10,7 +10,7 @@ import ( "connectrpc.com/connect" pb "github.com/Open-MBEE/OpenSysML/api/proto" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" ) // verifyModelSource states a constraint, a requirement and satisfaction @@ -256,6 +256,12 @@ func TestVerifyUncachedModelIsNotFound(t *testing.T) { }) return err }, + "ValidateInstance": func() error { + _, err := srv.ValidateInstance(context.Background(), &pb.ValidateInstanceRequest{ + ModelHash: "nosuchmodel", SymbolId: "Demo::sedan", + }) + return err + }, "EvaluateCalc": func() error { _, err := srv.EvaluateCalc(context.Background(), &pb.EvaluateCalcRequest{ ModelHash: "nosuchmodel", SymbolId: "Demo::add", diff --git a/internal/grpc/w6g4_query_type_test.go b/internal/frontend/grpc/w6g4_query_type_test.go similarity index 96% rename from internal/grpc/w6g4_query_type_test.go rename to internal/frontend/grpc/w6g4_query_type_test.go index 3112e20d75..e709ad7f89 100644 --- a/internal/grpc/w6g4_query_type_test.go +++ b/internal/frontend/grpc/w6g4_query_type_test.go @@ -5,8 +5,8 @@ import ( "testing" pb "github.com/Open-MBEE/OpenSysML/api/proto" - corequery "github.com/Open-MBEE/OpenSysML/internal/core/query" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + corequery "github.com/Open-MBEE/OpenSysML/internal/semantic/query" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // queryTypesOf reports the `@type` every named element of a source is projected diff --git a/internal/lsp/ambiguous_invocation_navigation_test.go b/internal/frontend/lsp/ambiguous_invocation_navigation_test.go similarity index 99% rename from internal/lsp/ambiguous_invocation_navigation_test.go rename to internal/frontend/lsp/ambiguous_invocation_navigation_test.go index e9802cdcf0..f7c0c4d3ff 100644 --- a/internal/lsp/ambiguous_invocation_navigation_test.go +++ b/internal/frontend/lsp/ambiguous_invocation_navigation_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // ambiguousNavigationSrc imports two equally specific `pick(Integer)` overloads and a diff --git a/internal/lsp/base.go b/internal/frontend/lsp/base.go similarity index 100% rename from internal/lsp/base.go rename to internal/frontend/lsp/base.go diff --git a/internal/lsp/chain_member_definition_test.go b/internal/frontend/lsp/chain_member_definition_test.go similarity index 96% rename from internal/lsp/chain_member_definition_test.go rename to internal/frontend/lsp/chain_member_definition_test.go index c4d55ca31e..68afee8003 100644 --- a/internal/lsp/chain_member_definition_test.go +++ b/internal/frontend/lsp/chain_member_definition_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // Go-to-definition on a chain's last segment reaches the member a usage holds: diff --git a/internal/lsp/codeaction.go b/internal/frontend/lsp/codeaction.go similarity index 85% rename from internal/lsp/codeaction.go rename to internal/frontend/lsp/codeaction.go index 7baf68f8c3..4f97bcfe1b 100644 --- a/internal/lsp/codeaction.go +++ b/internal/frontend/lsp/codeaction.go @@ -4,11 +4,12 @@ import ( "context" "strings" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "go.lsp.dev/protocol" - "github.com/Open-MBEE/OpenSysML/internal/core/model" - "github.com/Open-MBEE/OpenSysML/internal/core/quickfix" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // CodeAction answers the code actions for a range: the quick fixes attached to @@ -37,13 +38,14 @@ func (s *Server) CodeAction(ctx context.Context, params *protocol.CodeActionPara // quickFixes returns the fixes attached to the diagnostics overlapping want. func (s *Server) quickFixes(name string, doc *model.Document, want source.Span) []protocol.CodeAction { uri := nameToURI(name) + pos := positionsOf(doc) var out []protocol.CodeAction for _, diag := range s.ws.Diagnostics(name) { if len(diag.Fixes) == 0 || !overlaps(diag.Span, want) { continue } reported := protocol.Diagnostic{ - Range: spanToRange(doc.Content, diag.Span), + Range: pos.rangeOf(diag.Span), Severity: protocol.DiagnosticSeverity(int(diag.Severity) + 1), Message: diag.Message, Code: diag.Code, @@ -55,7 +57,7 @@ func (s *Server) quickFixes(name string, doc *model.Document, want source.Span) Kind: protocol.QuickFix, Diagnostics: []protocol.Diagnostic{reported}, IsPreferred: fix.Preferred, - Edit: workspaceEdit(uri, doc.Content, fix.Edits), + Edit: workspaceEdit(uri, pos, fix.Edits), }) } } @@ -86,12 +88,12 @@ func overlaps(a, b source.Span) bool { } // workspaceEdit renders a fix's edits as an edit of the document it applies to. -func workspaceEdit(uri protocol.DocumentURI, content []byte, edits []quickfix.Edit) *protocol.WorkspaceEdit { +func workspaceEdit(uri protocol.DocumentURI, pos positions, edits []diag.Edit) *protocol.WorkspaceEdit { out := make([]protocol.TextEdit, 0, len(edits)) for _, edit := range edits { - span, text := edit.Render(content) + span, text := edit.Render(pos.content) out = append(out, protocol.TextEdit{ - Range: spanToRange(content, span), + Range: pos.rangeOf(span), NewText: text, }) } diff --git a/internal/lsp/codeaction_test.go b/internal/frontend/lsp/codeaction_test.go similarity index 99% rename from internal/lsp/codeaction_test.go rename to internal/frontend/lsp/codeaction_test.go index 7416ea7cb5..3b5e999512 100644 --- a/internal/lsp/codeaction_test.go +++ b/internal/frontend/lsp/codeaction_test.go @@ -7,7 +7,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // actionsFor opens src and asks for the code actions covering all of it. diff --git a/internal/lsp/completion.go b/internal/frontend/lsp/completion.go similarity index 97% rename from internal/lsp/completion.go rename to internal/frontend/lsp/completion.go index b654360128..cb9d5a56d7 100644 --- a/internal/lsp/completion.go +++ b/internal/frontend/lsp/completion.go @@ -6,11 +6,10 @@ import ( "go.lsp.dev/protocol" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/lexer" - "github.com/Open-MBEE/OpenSysML/internal/core/semantics" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/lexer" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // Completion implements textDocument/completion: members of what a member @@ -86,7 +85,7 @@ func (s *Server) Completion(ctx context.Context, params *protocol.CompletionPara for _, sym := range s.ws.TopLevelSymbols(name) { c.addSymbol(s, sym) } - for _, kw := range lexer.Keywords() { + for _, kw := range source.Keywords() { c.add(protocol.CompletionItem{ Label: kw, Kind: protocol.CompletionItemKindKeyword, @@ -221,7 +220,7 @@ func declaredTypeText(sym *symbols.Symbol) string { target = fr.Name } if qn, ok := target.(*ast.QualifiedName); ok { - return semantics.QualifiedNameText(qn) + return qn.Text() } } return "" diff --git a/internal/lsp/completion_test.go b/internal/frontend/lsp/completion_test.go similarity index 99% rename from internal/lsp/completion_test.go rename to internal/frontend/lsp/completion_test.go index 0812ed8615..1c659fd9ed 100644 --- a/internal/lsp/completion_test.go +++ b/internal/frontend/lsp/completion_test.go @@ -9,7 +9,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) func TestCompletionIncludesMembersAndKeywords(t *testing.T) { diff --git a/internal/frontend/lsp/conformance.go b/internal/frontend/lsp/conformance.go new file mode 100644 index 0000000000..9d2c92848f --- /dev/null +++ b/internal/frontend/lsp/conformance.go @@ -0,0 +1,78 @@ +package lsp + +import ( + "context" + + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" +) + +// strictConformanceKey is the setting an editor sets to ask the strict question, +// spelled as the CLI's -strict flag and the REPL's %strict command. +const strictConformanceKey = "strictConformance" + +// settingsSection is the section an editor nests this server's settings under. +const settingsSection = "sysml" + +// applyConformanceSettings switches the workspace's conformance mode to what a +// settings payload asks for, and reports whether it said anything: a payload +// without the setting leaves the mode alone rather than resetting it. +func (s *Server) applyConformanceSettings(payload any) bool { + strict, ok := strictConformanceSetting(payload) + if !ok { + return false + } + s.ws.SetConformanceMode(diag.ConformanceModeOf(strict)) + return true +} + +// DidChangeConfiguration applies the settings the client pushed. Only the +// conformance mode is read; a payload that does not mention it changes nothing. +func (s *Server) DidChangeConfiguration(ctx context.Context, params *protocol.DidChangeConfigurationParams) error { + if params == nil || !s.applyConformanceSettings(params.Settings) { + return nil + } + s.republishOpenDiagnostics(ctx) + return nil +} + +// strictConformanceSetting reads the strict-conformance flag out of an +// initializationOptions or didChangeConfiguration payload. Clients nest their +// settings differently, so all three shapes are accepted: +// {"strictConformance": true}, {"sysml": {"strictConformance": true}} and the +// flat {"sysml.strictConformance": true}. +func strictConformanceSetting(payload any) (bool, bool) { + settings, ok := payload.(map[string]any) + if !ok { + return false, false + } + if value, ok := settings[strictConformanceKey]; ok { + return boolSetting(value) + } + if value, ok := settings[settingsSection+"."+strictConformanceKey]; ok { + return boolSetting(value) + } + if nested, ok := settings[settingsSection].(map[string]any); ok { + if value, ok := nested[strictConformanceKey]; ok { + return boolSetting(value) + } + } + return false, false +} + +// boolSetting reads a JSON boolean, ignoring a value of any other type: a +// malformed setting is not a reason to answer a different question. +func boolSetting(value any) (bool, bool) { + strict, ok := value.(bool) + return strict, ok +} + +// republishOpenDiagnostics re-analyzes every open document and pushes the +// result, so a setting that changes what counts as an error is answered without +// waiting for the next keystroke. +func (s *Server) republishOpenDiagnostics(ctx context.Context) { + for _, name := range s.ws.OpenNames() { + s.publishOpenDiagnostics(ctx, name) + } +} diff --git a/internal/frontend/lsp/conformance_test.go b/internal/frontend/lsp/conformance_test.go new file mode 100644 index 0000000000..88a932f8bd --- /dev/null +++ b/internal/frontend/lsp/conformance_test.go @@ -0,0 +1,116 @@ +package lsp + +import ( + "context" + "testing" + + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// lspExtension uses notation of ours: a warning in the editor by default, an +// error once the editor asks strictly. +const lspExtension = "package P { attribute def Alarm; state def S { state a { defer Alarm; } } }" + +func TestInitializeReadsTheStrictConformanceOption(t *testing.T) { + for _, tc := range []struct { + name string + options any + want diag.ConformanceMode + }{ + {"flat key", map[string]any{"strictConformance": true}, diag.ConformanceStrict}, + {"nested section", map[string]any{"sysml": map[string]any{"strictConformance": true}}, diag.ConformanceStrict}, + {"dotted key", map[string]any{"sysml.strictConformance": true}, diag.ConformanceStrict}, + {"explicit false", map[string]any{"strictConformance": false}, diag.ConformanceDefault}, + {"unrelated options", map[string]any{"other": true}, diag.ConformanceDefault}, + {"no options", nil, diag.ConformanceDefault}, + } { + t.Run(tc.name, func(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + if _, err := s.Initialize(context.Background(), &protocol.InitializeParams{ + InitializationOptions: tc.options, + }); err != nil { + t.Fatal(err) + } + if got := ws.ConformanceMode(); got != tc.want { + t.Fatalf("mode = %v, want %v", got, tc.want) + } + }) + } +} + +// A malformed setting must not be read as a request to answer the other +// question. +func TestStrictConformanceIgnoresANonBooleanSetting(t *testing.T) { + ws := model.NewWorkspace(model.WithConformanceMode(diag.ConformanceStrict)) + s := NewServer(ws) + if _, err := s.Initialize(context.Background(), &protocol.InitializeParams{ + InitializationOptions: map[string]any{"strictConformance": "yes"}, + }); err != nil { + t.Fatal(err) + } + if ws.ConformanceMode() != diag.ConformanceStrict { + t.Fatalf("mode = %v, want the mode left alone", ws.ConformanceMode()) + } +} + +// Changing the setting mid-session republishes what is open, so the editor is +// not left showing the other mode's verdict. +func TestDidChangeConfigurationRepublishesUnderTheNewMode(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + fc := &fakeClient{} + s.client = fc + + ws.Open("a.sysml", []byte(lspExtension), 1) + s.publishDiagnostics(context.Background(), "a.sysml") + if sev := firstSeverity(t, fc.all()); sev != protocol.DiagnosticSeverityWarning { + t.Fatalf("default severity = %v, want warning", sev) + } + + if err := s.DidChangeConfiguration(context.Background(), &protocol.DidChangeConfigurationParams{ + Settings: map[string]any{"sysml": map[string]any{"strictConformance": true}}, + }); err != nil { + t.Fatal(err) + } + if ws.ConformanceMode() != diag.ConformanceStrict { + t.Fatalf("mode = %v, want strict", ws.ConformanceMode()) + } + if sev := firstSeverity(t, fc.all()); sev != protocol.DiagnosticSeverityError { + t.Fatalf("strict severity = %v, want error", sev) + } +} + +// A payload that says nothing about the mode publishes nothing new. +func TestDidChangeConfigurationIgnoresUnrelatedSettings(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + fc := &fakeClient{} + s.client = fc + ws.Open("a.sysml", []byte(lspExtension), 1) + + if err := s.DidChangeConfiguration(context.Background(), &protocol.DidChangeConfigurationParams{ + Settings: map[string]any{"editor": map[string]any{"tabSize": 4}}, + }); err != nil { + t.Fatal(err) + } + if got := fc.all(); len(got) != 0 { + t.Fatalf("published %d set(s), want none", len(got)) + } +} + +// firstSeverity is the severity of the first diagnostic of the last publish. +func firstSeverity(t *testing.T, published []*protocol.PublishDiagnosticsParams) protocol.DiagnosticSeverity { + t.Helper() + if len(published) == 0 { + t.Fatal("nothing published") + } + last := published[len(published)-1] + if len(last.Diagnostics) == 0 { + t.Fatal("published an empty diagnostic set") + } + return last.Diagnostics[0].Severity +} diff --git a/internal/frontend/lsp/debug.go b/internal/frontend/lsp/debug.go new file mode 100644 index 0000000000..5151973cfa --- /dev/null +++ b/internal/frontend/lsp/debug.go @@ -0,0 +1,1613 @@ +package lsp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "slices" + "sort" + "strconv" + "strings" + "sync" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// The debug service runs the behavior a rendering draws and reports where it +// stands in that rendering's IDs, so a client can overlay the run on its drawing. +const ( + // MethodDebugStart renders the view, runs the target in a runtime built from + // the workspace as it is now, and answers the initial snapshot. + MethodDebugStart = "opensysml/debug/start" + // MethodDebugStep advances the session by one step: one token move of an + // action, or one event, change or do-round of a state machine. + MethodDebugStep = "opensysml/debug/step" + // MethodDebugContinue runs until the behavior completes, waits, reaches a + // breakpoint or exhausts its budget, holding the clock where it is. + MethodDebugContinue = "opensysml/debug/continue" + // MethodDebugSend posts a signal to the behavior; the next step delivers it. + MethodDebugSend = "opensysml/debug/send" + // MethodDebugAdvance moves the runtime's clock forward, dispatching what + // falls due on the way. + MethodDebugAdvance = "opensysml/debug/advance" + // MethodDebugBreakpoints replaces the session's breakpoints with the nodes + // named, by render ID. + MethodDebugBreakpoints = "opensysml/debug/breakpoints" + // MethodDebugStop ends the session and releases its runtime. + MethodDebugStop = "opensysml/debug/stop" + // MethodDebugChanged notifies the client of a session a document change + // moved to new render IDs or ended. + MethodDebugChanged = "opensysml/debugChanged" +) + +// debugProtocolVersion is the version of the snapshot shape; it moves when a +// field a client relies on changes meaning. +const debugProtocolVersion = 1 + +// Debug session states, as debugSnapshot.State reports them. +const ( + debugReady = "ready" + debugRunning = "running" + debugWaiting = "waiting" + debugSuspended = "suspended" + debugCompleted = "completed" + debugTerminated = "terminated" + debugFailed = "failed" + debugEnded = "ended" +) + +// debugStartParams asks to run target, a state machine or action the view draws, +// performed by object when one is named (a part, item or occurrence definition or usage). +type debugStartParams struct { + TextDocument protocol.TextDocumentIdentifier `json:"textDocument"` + View string `json:"view"` + Target string `json:"target"` + Object string `json:"object,omitempty"` +} + +// debugSessionParams names the session a request drives. +type debugSessionParams struct { + Session string `json:"session"` +} + +// debugSendParams posts a signal; each argument is a SysML expression evaluated +// in the target's declaring scope, bound to the signal feature it is keyed by. +type debugSendParams struct { + Session string `json:"session"` + Signal string `json:"signal"` + Args map[string]string `json:"args,omitempty"` +} + +// debugAdvanceParams moves the clock forward by time, a duration the request +// must spell: one omitted or null is refused rather than read as zero. +type debugAdvanceParams struct { + Session string `json:"session"` + Time *float64 `json:"time"` +} + +// debugBreakpointsParams sets breakpoints on the nodes of the session's rendering +// named by ID, replacing the ones set before. +type debugBreakpointsParams struct { + Session string `json:"session"` + NodeIDs []string `json:"nodeIds"` +} + +// debugEdge names an edge of the session's rendering: its position among the +// render result's edges, with its endpoints for a client keyed by those. +type debugEdge struct { + Index int `json:"index"` + From string `json:"from"` + To string `json:"to"` +} + +// debugToken is a control token of an action flow at render node Node; Placed +// is false when it runs somewhere undrawn and Node is the innermost drawn node. +type debugToken struct { + ID int64 `json:"id"` + Node string `json:"node"` + Placed bool `json:"placed"` + Via *debugEdge `json:"via,omitempty"` + Awaiting []debugEdge `json:"awaiting,omitempty"` + Waiting string `json:"waiting,omitempty"` + Due *float64 `json:"due,omitempty"` +} + +// debugEvent is an event the behavior has yet to take: one queued in a state +// machine at the instant At, or one Pending on the runtime's message bus. +type debugEvent struct { + Event string `json:"event"` + At float64 `json:"at"` + Pending bool `json:"pending,omitempty"` +} + +// debugSnapshot is where a session stands, in the IDs of the render result of +// its view at Version. Every debug request answers one; debugChanged carries one. +type debugSnapshot struct { + Protocol int `json:"protocol"` + Session string `json:"session"` + Kind string `json:"kind"` + View string `json:"view"` + Target string `json:"target"` + Object string `json:"object,omitempty"` + Root string `json:"root"` + Version int `json:"version"` + // Revision counts the session's snapshots: a client keeps the highest it has + // seen, as a notification taken earlier may reach it after a later answer. + Revision int `json:"revision"` + // State is ready, running, waiting, suspended, completed, terminated, failed or + // ended; Reason says why for waiting, suspended, failed and ended. + State string `json:"state"` + Reason string `json:"reason,omitempty"` + Time float64 `json:"time"` + // Tokens and Taken describe an action; ActiveStates and Taken a machine. + Tokens []debugToken `json:"tokens"` + ActiveStates []string `json:"activeStates"` + // Taken are the edges traversed since the previous snapshot: the transitions + // a machine fired, in order, or the edges tokens arrived at new nodes by. + Taken []debugEdge `json:"taken"` + Queue []debugEvent `json:"queue"` + Breakpoints []string `json:"breakpoints"` + // PausedAt is the breakpoint node the last run stopped at, "" for none. + PausedAt string `json:"pausedAt,omitempty"` + // Notes are what the runtime noted during the last request. + Notes []string `json:"notes"` + // Results are an ended action's outputs, as they stood when it completed or was terminated. + Results map[string]string `json:"results,omitempty"` +} + +// Typed errors the debug requests answer. +var ( + // ErrDebugSession reports a request naming no live session. + ErrDebugSession = errors.New("no such debug session") + // ErrDebugTarget reports a target no debugger can run. + ErrDebugTarget = errors.New("cannot debug target") + // ErrDebugNode reports a breakpoint on a node the rendering does not draw. + ErrDebugNode = errors.New("no such node in the session's rendering") + // ErrDebugSignal reports a signal the behavior would not take. + ErrDebugSignal = errors.New("signal refused") + // ErrDebugEnded reports a request on a session that has ended. + ErrDebugEnded = errors.New("debug session has ended") + // ErrDebugTime reports an advance by something other than a finite duration. + ErrDebugTime = errors.New("invalid duration") +) + +// debugInvalid answers a request the client got wrong: the reply carries an +// InvalidParams code while the error still unwraps to its typed cause. +func debugInvalid(err error) error { + return &debugRequestError{cause: err, wire: jsonrpc2.Errorf(jsonrpc2.InvalidParams, "%s", err)} +} + +type debugRequestError struct { + cause error + wire *jsonrpc2.Error +} + +func (e *debugRequestError) Error() string { return e.cause.Error() } +func (e *debugRequestError) Unwrap() []error { return []error{e.cause, e.wire} } + +// debugSession runs one behavior against the rendering drawing it. +type debugSession struct { + id string + doc string + view string + kind view.Kind + target string + object string + // targetDoc and objectDoc are the documents declaring the target and the + // object: the view's own, or another the view exposes them from. + targetDoc string + objectDoc string + // viewText is the declared view's text when the session started, "" for a + // pseudo-view, which no declaration spells. + viewText string + + rt *runtime.Context + runtime *model.Runtime + targetSym *symbols.Symbol + objectSym *symbols.Symbol + action *runtime.ActionExecutor + machine *runtime.StateExecutor + // reads are the declarations the run reads — the target's, the performer's, + // what their text names and so on — as the runtime holds them. + reads []model.Dependency + // named are the declarations sends named besides those, by document and + // name: what the run reads from too. + named []dependencyKey + + // version is the document version the render IDs below belong to. + version int + rendering *view.Rendering + actions *view.ActionLocator + states *view.StateLocator + // nodes are the runtime nodes the drawn nodes stand for, by render ID. + nodes map[string]debugNode + // parents is the render node each render node is nested in. + parents map[string]string + // breakpoints are the runtime nodes stopped at, by render ID. + breakpoints map[string]debugNode + + // noted and fired count the notes and edge traversals earlier snapshots reported. + noted int + fired int + // revision is the number of snapshots taken, the last one's Revision. + revision int + // waiting explains an action parked on the clock; failure a run that + // failed; ended why the session is over. + waiting string + failure string + ended string + // paused is the render node of the breakpoint the last run stopped at, and + // pausedName what the model calls it. + paused string + pausedName string +} + +// debugNode is a runtime node a render node draws: a vertex of a state graph, +// or an action node with the nested actions whose flows it runs in. +type debugNode struct { + within []ast.Node + node ast.Node +} + +// same reports whether both draw one runtime node: the same occurrence of it, for +// an action node a nested flow reuses. +func (n debugNode) same(other debugNode) bool { + return n.node == other.node && slices.Equal(n.within, other.within) +} + +// debugService holds the server's live sessions. +type debugService struct { + mu sync.Mutex + next int + sessions map[string]*debugSession +} + +func newDebugService() *debugService { + return &debugService{sessions: make(map[string]*debugSession)} +} + +// debugHandler answers the opensysml/debug/* requests. +func (s *Server) debugHandler(inner jsonrpc2.Handler) jsonrpc2.Handler { + return func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + var result *debugSnapshot + var err error + switch req.Method() { + case MethodDebugStart: + var params debugStartParams + if perr := json.Unmarshal(req.Params(), ¶ms); perr != nil { + return reply(ctx, nil, fmt.Errorf("%s: %w", jsonrpc2.ErrParse, perr)) + } + result, err = s.DebugStart(¶ms) + case MethodDebugStep, MethodDebugContinue, MethodDebugStop: + var params debugSessionParams + if perr := json.Unmarshal(req.Params(), ¶ms); perr != nil { + return reply(ctx, nil, fmt.Errorf("%s: %w", jsonrpc2.ErrParse, perr)) + } + switch req.Method() { + case MethodDebugStep: + result, err = s.DebugStep(¶ms) + case MethodDebugContinue: + result, err = s.DebugContinue(¶ms) + default: + result, err = s.DebugStop(¶ms) + } + case MethodDebugSend: + var params debugSendParams + if perr := json.Unmarshal(req.Params(), ¶ms); perr != nil { + return reply(ctx, nil, fmt.Errorf("%s: %w", jsonrpc2.ErrParse, perr)) + } + result, err = s.DebugSend(¶ms) + case MethodDebugAdvance: + var params debugAdvanceParams + if perr := json.Unmarshal(req.Params(), ¶ms); perr != nil { + return reply(ctx, nil, fmt.Errorf("%s: %w", jsonrpc2.ErrParse, perr)) + } + result, err = s.DebugAdvance(¶ms) + case MethodDebugBreakpoints: + var params debugBreakpointsParams + if perr := json.Unmarshal(req.Params(), ¶ms); perr != nil { + return reply(ctx, nil, fmt.Errorf("%s: %w", jsonrpc2.ErrParse, perr)) + } + result, err = s.DebugBreakpoints(¶ms) + default: + return inner(ctx, reply, req) + } + if err != nil { + return reply(ctx, nil, err) + } + return reply(ctx, result, nil) + } +} + +// DebugStart answers opensysml/debug/start. +func (s *Server) DebugStart(params *debugStartParams) (*debugSnapshot, error) { + sess, err := s.debugPrepare(params) + if err != nil { + return nil, err + } + return s.debugRegister(sess) +} + +// debugPrepare builds the session params asks for, run and located in the +// documents as one reading of the workspace has them, but not yet registered. +func (s *Server) debugPrepare(params *debugStartParams) (*debugSession, error) { + name := uriToName(params.TextDocument.URI) + if strings.TrimSpace(params.Target) == "" { + return nil, debugInvalid(fmt.Errorf("%w: no target named", ErrDebugTarget)) + } + // The rendering, the runtime and the view's text are read together, so the + // IDs answered and the behavior run are of one and the same documents. + var ( + rendering *view.Rendering + snapshot *model.Snapshot + rt *model.Runtime + viewText string + ) + if err := s.ws.Read(func(r *model.Reading) (err error) { + if rendering, snapshot, err = r.RenderView(name, params.View); err != nil { + return err + } + if rendering.Kind != view.KindState && rendering.Kind != view.KindAction { + return debugInvalid(fmt.Errorf("%w: %s renders a %s, which no debugger drives", ErrDebugTarget, params.View, rendering.Kind)) + } + if rt, err = r.NewRuntime(); err != nil { + return err + } + viewText = r.DeclarationText(r.DeclaredView(name, params.View)) + return nil + }); err != nil { + return nil, err + } + target, err := rt.Named(name, params.Target) + if err != nil { + return nil, debugInvalid(fmt.Errorf("%w: %w", ErrDebugTarget, err)) + } + if target == nil { + return nil, debugInvalid(fmt.Errorf("%w: no document declares %s", ErrDebugTarget, params.Target)) + } + if err := debugTargetKind(rendering.Kind, target); err != nil { + return nil, debugInvalid(err) + } + ctx := runtime.NewContext(rt.Model(), runtime.DefaultBudgets().MaxSteps) + var objectSym *symbols.Symbol + var performer *runtime.Instance + if params.Object != "" { + if objectSym, err = rt.Named(name, params.Object); err != nil { + return nil, debugInvalid(fmt.Errorf("%w: %w", ErrDebugTarget, err)) + } + if objectSym == nil { + return nil, debugInvalid(fmt.Errorf("%w: no document declares %s to perform %s", ErrDebugTarget, params.Object, params.Target)) + } + if !debugPerformerKind(objectSym.Kind) { + return nil, debugInvalid(fmt.Errorf("%w: %s is a %s, not an object that could perform %s", ErrDebugTarget, params.Object, objectSym.Notation(), params.Target)) + } + if performer, err = ctx.Instantiate(objectSym); err != nil { + return nil, fmt.Errorf("%w: instantiate %s: %w", ErrDebugTarget, params.Object, err) + } + } + sess := &debugSession{ + doc: name, + view: params.View, + kind: rendering.Kind, + target: rt.FQN(target), + targetDoc: target.DocName, + rt: ctx, + runtime: rt, + targetSym: target, + objectSym: objectSym, + viewText: viewText, + } + if objectSym != nil { + sess.object = rt.FQN(objectSym) + sess.objectDoc = objectSym.DocName + } + if err := sess.attach(ctx, target, performer); err != nil { + return nil, err + } + sess.reads = rt.Dependencies(target, objectSym) + if err := sess.locate(rendering, target, snapshot.Rendered.Version); err != nil { + sess.release() + return nil, err + } + return sess, nil +} + +// debugRegister gives a prepared session its ID and starts answering for it. +func (s *Server) debugRegister(sess *debugSession) (*debugSnapshot, error) { + s.debug.mu.Lock() + defer s.debug.mu.Unlock() + // An edit since the session's reading is reconciled under the lock edits + // reconcile under, so none passes the session by unseen; the session has yet + // to answer, so a move is reported by its first snapshot rather than a notification. + if s.ws.Generation() != sess.runtime.Generation() && s.rebindNow(sess) && sess.ended != "" { + sess.release() + return nil, debugInvalid(fmt.Errorf("%w: %s", ErrDebugTarget, sess.ended)) + } + s.debug.next++ + sess.id = "debug-" + strconv.Itoa(s.debug.next) + s.debug.sessions[sess.id] = sess + return sess.snapshot(), nil +} + +// attach gives the session the executor of target: the one performer already +// runs when it runs target, else a fresh one, as the REPL attaches a behavior. +func (sess *debugSession) attach(ctx *runtime.Context, target *symbols.Symbol, performer *runtime.Instance) error { + var running []*runtime.ObjectBehavior + if performer != nil { + switch sess.kind { + case view.KindState: + running = performer.ExhibitedStatesOf(target) + case view.KindAction: + running = performer.PerformedActionsOf(target) + } + } + if len(running) > 1 { + usages := make([]string, 0, len(running)) + for _, b := range running { + usages = append(usages, b.Describe()) + } + return debugInvalid(fmt.Errorf("%w: %s runs %s %d times, so name the usage: %s", + ErrDebugTarget, sess.object, sess.target, len(running), strings.Join(usages, "; "))) + } + if len(running) == 1 { + sess.machine, sess.action = running[0].State, running[0].Action + } else { + var err error + switch sess.kind { + case view.KindState: + sess.machine, err = ctx.CreateStateExecutorFor(target, performer) + case view.KindAction: + sess.action, err = ctx.CreateActionExecutorFor(target, performer) + } + if err != nil { + return fmt.Errorf("%w: %s: %w", ErrDebugTarget, sess.target, err) + } + } + // The executor keeps what it fires until a snapshot reads it, and nothing more. + if sess.machine != nil { + sess.machine.KeepFired(true) + } + if sess.action != nil { + sess.action.KeepTraversals(true) + } + return nil +} + +// debugTargetKind checks target declares what a rendering of kind draws. +func debugTargetKind(kind view.Kind, target *symbols.Symbol) error { + switch kind { + case view.KindState: + if target.Kind == symbols.SymbolStateDef || target.Kind == symbols.SymbolStateUsage { + return nil + } + return fmt.Errorf("%w: %s is a %s, not a state machine", ErrDebugTarget, target.Name, target.Notation()) + case view.KindAction: + if target.Kind == symbols.SymbolActionDef || target.Kind == symbols.SymbolActionUsage { + return nil + } + return fmt.Errorf("%w: %s is a %s, not an action", ErrDebugTarget, target.Name, target.Notation()) + } + return fmt.Errorf("%w: a %s rendering draws no behavior", ErrDebugTarget, kind) +} + +// debugPerformerKind reports whether a declaration of kind is an object — a part, +// item or occurrence definition or usage — and so can perform a behavior. +func debugPerformerKind(kind symbols.SymbolKind) bool { + switch kind { + case symbols.SymbolPartDef, symbols.SymbolPartUsage, + symbols.SymbolItemDef, symbols.SymbolItemUsage, + symbols.SymbolOccurrenceDef, symbols.SymbolOccurrenceUsage, + symbols.SymbolIndividualDef, symbols.SymbolIndividualUsage: + return true + } + return false +} + +// locate binds the session to rendering, in which drawn declares the target at +// version, keeping the breakpoints on runtime nodes still drawn. +func (sess *debugSession) locate(rendering *view.Rendering, drawn *symbols.Symbol, version int) error { + var nodes map[string]debugNode + var err error + switch sess.kind { + case view.KindState: + nodes, err = sess.locateStates(rendering, drawn) + case view.KindAction: + nodes, err = sess.locateActions(rendering, drawn) + } + if err != nil { + return err + } + sess.rendering, sess.version, sess.nodes = rendering, version, nodes + sess.parents = make(map[string]string) + sess.nest(rendering.Roots, "") + kept := make(map[string]debugNode, len(sess.breakpoints)) + for id, bp := range nodes { + for _, old := range sess.breakpoints { + if old.same(bp) { + kept[id] = bp + } + } + } + sess.breakpoints = kept + sess.applyBreakpoints() + // A pause stands through a redraw; its node is named in the fresh IDs. + sess.paused, sess.pausedName = "", "" + sess.pause() + return nil +} + +// locateStates maps the drawn IDs of the machine's states and pseudostates to +// the runtime nodes they render. +func (sess *debugSession) locateStates(rendering *view.Rendering, drawn *symbols.Symbol) (map[string]debugNode, error) { + loc, err := view.LocateStates(rendering, drawn, sess.targetSym, sess.machine.Graph()) + if err != nil { + return nil, err + } + nodes := make(map[string]debugNode) + graph := sess.machine.Graph() + for _, state := range graph.States { + if id, ok := loc.Node(state); ok { + nodes[id] = debugNode{node: state} + } + } + for _, pseudo := range graph.Pseudostates { + if id, ok := loc.Node(pseudo); ok { + nodes[id] = debugNode{node: pseudo} + } + } + sess.states, sess.actions = loc, nil + return nodes, nil +} + +// locateActions maps the drawn IDs of the action's nodes, nested flows included, +// to the runtime nodes they render. +func (sess *debugSession) locateActions(rendering *view.Rendering, drawn *symbols.Symbol) (map[string]debugNode, error) { + loc, err := view.LocateActions(rendering, drawn, sess.targetSym, sess.action.Graph()) + if err != nil { + return nil, err + } + nodes := make(map[string]debugNode) + var walk func(graph *lower.ActionGraph, within []ast.Node) + walk = func(graph *lower.ActionGraph, within []ast.Node) { + for _, node := range graph.Nodes { + if id, ok := loc.Node(within, node); ok { + nodes[id] = debugNode{within: within, node: node} + } + if sub := graph.Subflows[node]; sub != nil && sub.Graph != nil && sub.Err == nil { + walk(sub.Graph, append(append([]ast.Node(nil), within...), node)) + } + } + } + walk(sess.action.Graph(), nil) + sess.actions, sess.states = loc, nil + return nodes, nil +} + +// nest records the parent of every drawn node under nodes. +func (sess *debugSession) nest(nodes []*view.Node, parent string) { + for _, n := range nodes { + if parent != "" { + sess.parents[n.ID] = parent + } + sess.nest(n.Children, n.ID) + } +} + +// applyBreakpoints sets the executor's breakpoints to the session's; a pause +// already reached at one kept stands, so the next run resumes past it. +func (sess *debugSession) applyBreakpoints() { + if sess.action != nil { + bps := make([]runtime.NodeBreakpoint, 0, len(sess.breakpoints)) + for _, bp := range sess.breakpoints { + bps = append(bps, runtime.NodeBreakpoint{Within: bp.within, Node: bp.node}) + } + sess.action.ReplaceBreakpointsAt(bps) + return + } + sess.machine.ClearBreakpoints() + for _, bp := range sess.breakpoints { + sess.machine.SetBreakpointAt(bp.node) + } +} + +// halted reports the session's executor paused at a breakpoint. +func (sess *debugSession) halted() bool { + if sess.action != nil { + return sess.action.PausedAt() != "" + } + return sess.machine.PausedAt() != nil +} + +// release lets the session's executor go. +func (sess *debugSession) release() { + if sess.action != nil { + sess.action.Release() + } + if sess.machine != nil { + sess.machine.Release() + } +} + +// session is the live session id names, locked; the caller unlocks. +func (s *Server) session(id string) (*debugSession, error) { + s.debug.mu.Lock() + sess, ok := s.debug.sessions[id] + if !ok { + s.debug.mu.Unlock() + return nil, debugInvalid(fmt.Errorf("%w: %q", ErrDebugSession, id)) + } + return sess, nil +} + +// live is a session a request may drive: one neither ended nor failed. +func (sess *debugSession) live() error { + if sess.ended != "" { + return fmt.Errorf("%w: %s", ErrDebugEnded, sess.ended) + } + return nil +} + +// DebugStep answers opensysml/debug/step. +func (s *Server) DebugStep(params *debugSessionParams) (*debugSnapshot, error) { + sess, err := s.session(params.Session) + if err != nil { + return nil, err + } + defer s.debug.mu.Unlock() + if err := sess.live(); err != nil { + return nil, err + } + sess.resume() + switch sess.kind { + case view.KindAction: + if sess.action.State().Ended() { + break + } + err := sess.action.StepToBreakpoint() + if errors.Is(err, runtime.ErrNothingDue) { + sess.waiting = debugClockWait(sess.action) + } else if err != nil { + sess.failure = err.Error() + } + case view.KindState: + if err := sess.stepMachine(); err != nil { + sess.failure = err.Error() + } + } + sess.pause() + return sess.snapshot(), nil +} + +// resume forgets what the last run ended on before the next one, releasing the +// executor from the breakpoint it paused at. +func (sess *debugSession) resume() { + sess.waiting, sess.failure = "", "" + sess.paused, sess.pausedName = "", "" + if sess.machine != nil && sess.machine.PausedAt() != nil { + sess.machine.Resume() + } + if sess.action != nil && sess.action.PausedAt() != "" { + sess.action.Resume() + } +} + +// pause records the breakpoint node the run just done stopped at, if any. +func (sess *debugSession) pause() { + if sess.machine != nil { + if vertex := sess.machine.PausedAt(); vertex != nil { + sess.paused, _ = sess.states.Node(vertex) + sess.pausedName = runtime.StateVertexName(vertex) + } + return + } + if bp, ok := sess.action.PausedBreakpoint(); ok { + sess.paused, _ = sess.actions.Node(bp.Within, bp.Node) + sess.pausedName = sess.action.PausedAt() + } +} + +// clockWaiter is an executor with waits on the clock. +type clockWaiter interface { + NextWait() (float64, bool) +} + +// debugClockWait says what a behavior parked on the clock waits for. +func debugClockWait(exec clockWaiter) string { + if due, ok := exec.NextWait(); ok { + return "waits on the clock until t=" + semantics.FormatReal(due) + } + return "waits on the clock" +} + +// stepMachine advances a state machine one step as the REPL does: a completion +// held at a breakpoint, else a change condition that fires, else the next event, +// else a round of do behavior. +func (sess *debugSession) stepMachine() error { + exec := sess.machine + if exec.HasPendingWork() || exec.WatchesChangeCondition() { + exec.Resume() + } + if exec.State() != runtime.StateRunning { + return nil + } + if exec.CompletionDue() { + if err := exec.ProcessNextEvent(); err != nil { + return fmt.Errorf("completion failed: %w", err) + } + return nil + } + fired, err := exec.PollChangeEvents() + if err != nil { + return fmt.Errorf("change condition failed: %w", err) + } + if fired { + return nil + } + if exec.HoldsEntry() { + if err := exec.ProcessNextEvent(); err != nil { + return fmt.Errorf("entry processing failed: %w", err) + } + return nil + } + if exec.EventQueue().Len() > 0 || exec.HasPendingSignal() { + if err := exec.ProcessNextEvent(); err != nil { + return fmt.Errorf("event processing failed: %w", err) + } + return nil + } + if exec.HasPendingDoWork() { + if _, err := exec.RunDoRound(); err != nil { + return fmt.Errorf("do behavior failed: %w", err) + } + return nil + } + exec.Suspend() + return nil +} + +// DebugContinue answers opensysml/debug/continue. +func (s *Server) DebugContinue(params *debugSessionParams) (*debugSnapshot, error) { + sess, err := s.session(params.Session) + if err != nil { + return nil, err + } + defer s.debug.mu.Unlock() + if err := sess.live(); err != nil { + return nil, err + } + sess.resume() + switch sess.kind { + case view.KindAction: + if sess.action.State().Ended() { + break + } + if err := sess.action.RunToQuiescence(); err != nil { + sess.failure = err.Error() + } else if sess.action.State() == runtime.StateWaiting && sess.action.PausedAt() == "" { + if _, due := sess.action.NextWait(); due && !sess.action.HasPendingSignal() { + sess.waiting = debugClockWait(sess.action) + } + } + case view.KindState: + sess.continueMachine() + } + sess.pause() + return sess.snapshot(), nil +} + +// continueMachine runs a state machine to quiescence at the current instant or +// to a breakpoint, holding the clock: an event due later waits for an advance. +func (sess *debugSession) continueMachine() { + exec := sess.machine + if exec.State().Ended() { + return + } + if err := exec.RunToQuiescence(); err != nil { + sess.failure = err.Error() + } +} + +// DebugSend answers opensysml/debug/send. +func (s *Server) DebugSend(params *debugSendParams) (*debugSnapshot, error) { + sess, err := s.session(params.Session) + if err != nil { + return nil, err + } + defer s.debug.mu.Unlock() + if err := sess.live(); err != nil { + return nil, err + } + if strings.TrimSpace(params.Signal) == "" { + return nil, debugInvalid(fmt.Errorf("%w: no signal named", ErrDebugSignal)) + } + send, err := sess.parseSend(params.Signal, params.Args) + if err != nil { + return nil, debugInvalid(fmt.Errorf("%w: %w", ErrDebugSignal, err)) + } + if change := s.debugReadsToo(sess, send.named(sess)); change != "" { + delete(s.debug.sessions, sess.id) + sess.ended = change + sess.release() + return sess.snapshot(), nil + } + msg, err := sess.signalMessage(send) + if err != nil { + return nil, debugInvalid(fmt.Errorf("%w: %w", ErrDebugSignal, err)) + } + var accepted bool + switch sess.kind { + case view.KindAction: + accepted, err = sess.action.AcceptsMessage(msg) + case view.KindState: + accepted, err = sess.machine.AcceptsMessage(msg) + } + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrDebugSignal, err) + } + if !accepted { + return nil, debugInvalid(fmt.Errorf("%w: %s accepts no signal %s now", ErrDebugSignal, sess.target, msg.SignalType)) + } + sess.rt.PostMessage(msg) + return sess.snapshot(), nil +} + +// debugSend is a send as parsed: the signal named, its definition when one is +// declared, and its arguments' expressions by name, sorted. +type debugSend struct { + signal string + def *symbols.Symbol + args []debugArgument +} + +type debugArgument struct { + name string + expr ast.Node +} + +// parseSend reads the signal and arguments of a send, evaluating nothing yet. +func (sess *debugSession) parseSend(signal string, args map[string]string) (*debugSend, error) { + name, def, err := sess.signalDefinition(signal) + if err != nil { + return nil, err + } + send := &debugSend{signal: name, def: def} + if send.def == nil && len(args) > 0 { + return nil, fmt.Errorf("no signal definition %s is declared, so it cannot carry arguments", signal) + } + if send.def != nil && !runtime.IsSignalDefinition(send.def) { + return nil, fmt.Errorf("%s is a %s, not a signal definition", signal, send.def.Notation()) + } + names := make([]string, 0, len(args)) + for name := range args { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + expr, err := parseDebugExpression(args[name]) + if err != nil { + return nil, fmt.Errorf("argument %s: %w", name, err) + } + send.args = append(send.args, debugArgument{name: name, expr: expr}) + } + return send, nil +} + +// named is the declarations the send reads: the signal's definition and what +// its arguments name, as the session's runtime resolves them. +func (send *debugSend) named(sess *debugSession) []*symbols.Symbol { + var out []*symbols.Symbol + if send.def != nil { + out = append(out, send.def) + } + for _, arg := range send.args { + out = append(out, sess.runtime.Referenced(sess.scope(), arg.expr)...) + } + return out +} + +// signalMessage builds the message a send posts: typed by the signal definition +// named, or by name alone when none is declared. +func (sess *debugSession) signalMessage(send *debugSend) (runtime.Message, error) { + if send.def == nil { + return runtime.NamedSignalMessage(send.signal, sess.performer()), nil + } + bound := make(map[string]runtime.Value, len(send.args)) + for _, arg := range send.args { + value, err := sess.rt.EvalWithScope(arg.expr, sess.scope()) + if err != nil { + return runtime.Message{}, fmt.Errorf("argument %s: %w", arg.name, err) + } + bound[arg.name] = value + } + return sess.rt.SignalMessage(send.def, bound, sess.performer()) +} + +// debugReadsToo adds to what the session reads the declarations reachable from +// named, checking the workspace still holds them as the runtime read them; the +// change that says it does not ends the session, "" when it does. +func (s *Server) debugReadsToo(sess *debugSession, named []*symbols.Symbol) string { + var roots []*symbols.Symbol + var keys []dependencyKey + for _, sym := range named { + if dep, ok := sess.runtime.DeclarationOf(sym); ok && !slices.Contains(sess.named, keyOf(dep)) && !slices.Contains(keys, keyOf(dep)) { + roots = append(roots, sym) + keys = append(keys, keyOf(dep)) + } + } + if len(roots) == 0 { + return "" + } + fresh := sess.runtime.Dependencies(roots...) + known := make(map[dependencyKey]bool, len(sess.reads)) + for _, dep := range sess.reads { + known[keyOf(dep)] = true + } + if slices.IndexFunc(fresh, func(dep model.Dependency) bool { return !known[keyOf(dep)] }) < 0 { + sess.named = append(sess.named, keys...) + return "" + } + var change string + _ = s.ws.Read(func(r *model.Reading) error { + roots, gone := sess.namedIn(r, keys) + if gone != "" { + change = gone + return nil + } + change = dependencyChange(r, sess.target, fresh, r.Dependencies(roots...)) + return nil + }) + if change != "" { + return change + } + for _, dep := range fresh { + if !known[keyOf(dep)] { + sess.reads = append(sess.reads, dep) + } + } + sess.named = append(sess.named, keys...) + return "" +} + +// namedIn is the declarations keys name as r reads them, or why one is gone. +func (sess *debugSession) namedIn(r *model.Reading, keys []dependencyKey) ([]*symbols.Symbol, string) { + roots := make([]*symbols.Symbol, 0, len(keys)) + for _, key := range keys { + sym := r.Declared(key.doc, key.fqn) + if sym == nil { + return nil, fmt.Sprintf("%s is no longer declared", key.fqn) + } + roots = append(roots, sym) + } + return roots, "" +} + +// signalDefinition is the name signal spells and the definition it resolves to +// from the target's scope, as an `accept` written there would; nil for none. +func (sess *debugSession) signalDefinition(signal string) (string, *symbols.Symbol, error) { + expr, err := parseDebugExpression(signal) + if err != nil { + return "", nil, fmt.Errorf("signal %w", err) + } + ref, ok := expr.(*ast.FeatureReference) + if !ok || ref.Name == nil || len(ref.Name.Parts) == 0 { + return "", nil, fmt.Errorf("signal %q is not a name", signal) + } + name := ref.Name.Parts[len(ref.Name.Parts)-1].Text + for _, sym := range sess.runtime.Referenced(sess.scope(), ref) { + return name, sym, nil + } + return name, nil, nil +} + +// scope is where the target's own text resolves names: its body, or the scope +// it is declared in when it has no body of its own. +func (sess *debugSession) scope() *symbols.Scope { + if sess.targetSym.Scope != nil { + return sess.targetSym.Scope + } + return sess.targetSym.OwnerScope +} + +// performer is the object performing the session's behavior, nil for none. +func (sess *debugSession) performer() *runtime.Instance { + if sess.action != nil { + return sess.action.Performer() + } + return sess.machine.Performer() +} + +// debugExprPrefix wraps an expression as a usage value so the parser reads it. +const debugExprPrefix = "attribute expr = " + +// parseDebugExpression parses one SysML expression written on its own. +func parseDebugExpression(text string) (ast.Node, error) { + if strings.TrimSpace(text) == "" { + return nil, errors.New("empty expression") + } + p := parser.New(source.New("argument", []byte(debugExprPrefix+text+";"))) + root := p.ParseFile() + if len(p.Diagnostics) > 0 { + return nil, fmt.Errorf("%q: %s", text, p.Diagnostics[0].Message) + } + if len(root.Members) == 1 { + member := root.Members[0] + if mem, ok := member.(*ast.Membership); ok { + member = mem.Member + } + if usage, ok := member.(*ast.Usage); ok && usage.Value != nil { + return usage.Value, nil + } + } + return nil, fmt.Errorf("%q is not an expression", text) +} + +// DebugAdvance answers opensysml/debug/advance. +func (s *Server) DebugAdvance(params *debugAdvanceParams) (*debugSnapshot, error) { + if params.Time == nil { + return nil, debugInvalid(fmt.Errorf("%w: no time given", ErrDebugTime)) + } + until := *params.Time + if until < 0 || math.IsNaN(until) || math.IsInf(until, 0) { + return nil, debugInvalid(fmt.Errorf("%w: time must be a finite duration of at least 0, not %v", ErrDebugTime, until)) + } + sess, err := s.session(params.Session) + if err != nil { + return nil, err + } + defer s.debug.mu.Unlock() + if err := sess.live(); err != nil { + return nil, err + } + sess.resume() + if _, err := sess.rt.AdvanceUntil(until, sess.halted); err != nil { + sess.failure = err.Error() + } + sess.pause() + return sess.snapshot(), nil +} + +// DebugBreakpoints answers opensysml/debug/breakpoints. +func (s *Server) DebugBreakpoints(params *debugBreakpointsParams) (*debugSnapshot, error) { + sess, err := s.session(params.Session) + if err != nil { + return nil, err + } + defer s.debug.mu.Unlock() + if err := sess.live(); err != nil { + return nil, err + } + set := make(map[string]debugNode, len(params.NodeIDs)) + for _, id := range params.NodeIDs { + node, ok := sess.nodes[id] + if !ok { + return nil, debugInvalid(fmt.Errorf("%w: %q is no node of %s at version %d that runs", ErrDebugNode, id, sess.view, sess.version)) + } + set[id] = node + } + sess.breakpoints = set + sess.applyBreakpoints() + return sess.snapshot(), nil +} + +// DebugStop answers opensysml/debug/stop. +func (s *Server) DebugStop(params *debugSessionParams) (*debugSnapshot, error) { + sess, err := s.session(params.Session) + if err != nil { + return nil, err + } + defer s.debug.mu.Unlock() + delete(s.debug.sessions, sess.id) + if sess.ended == "" { + sess.ended = "stopped" + } + sess.release() + return sess.snapshot(), nil +} + +// snapshot is where the session stands now. +func (sess *debugSession) snapshot() *debugSnapshot { + snap := &debugSnapshot{ + Protocol: debugProtocolVersion, + Session: sess.id, + Kind: string(sess.kind), + View: sess.view, + Target: sess.target, + Object: sess.object, + Version: sess.version, + Time: sess.rt.Clock().Now(), + Tokens: []debugToken{}, + ActiveStates: []string{}, + Taken: []debugEdge{}, + Queue: []debugEvent{}, + Breakpoints: sess.breakpointIDs(), + PausedAt: sess.paused, + Notes: []string{}, + } + sess.revision++ + snap.Revision = sess.revision + var notes []runtime.RunNote + switch sess.kind { + case view.KindAction: + snap.Root = sess.actions.Root() + sess.actionSnapshot(snap) + notes, sess.noted = sess.action.NotesSince(sess.noted), sess.action.NoteCount() + case view.KindState: + snap.Root = sess.states.Root() + sess.machineSnapshot(snap) + notes, sess.noted = sess.machine.NotesSince(sess.noted), sess.machine.NoteCount() + } + for _, note := range notes { + snap.Notes = append(snap.Notes, note.String()) + } + snap.State, snap.Reason = sess.status() + return snap +} + +// status is the session's state and the reason for it. +func (sess *debugSession) status() (string, string) { + switch { + case sess.ended != "": + return debugEnded, sess.ended + case sess.failure != "": + return debugFailed, sess.failure + case sess.waiting != "": + return debugWaiting, sess.waiting + } + var state runtime.ExecutionState + if sess.action != nil { + state = sess.action.State() + } else { + state = sess.machine.State() + } + switch state { + case runtime.StateReady: + return debugReady, "" + case runtime.StateRunning: + return debugRunning, "" + case runtime.StateWaiting: + return debugWaiting, sess.waitReason() + case runtime.StateSuspended: + if sess.machineWaitsOnClock() { + return debugWaiting, debugClockWait(sess.machine) + } + return debugSuspended, sess.suspendReason() + case runtime.StateCompleted: + return debugCompleted, "" + case runtime.StateTerminated: + return debugTerminated, "" + } + return strings.ToLower(state.String()), "" +} + +// waitReason says what a waiting behavior waits for. +func (sess *debugSession) waitReason() string { + if sess.action != nil { + if sess.action.PausedAt() != "" { + return "" + } + var waits []string + for _, tok := range sess.action.Tokens() { + if w := debugWait(tok); w != "" { + waits = append(waits, w) + } + } + return strings.Join(waits, "; ") + } + return sess.machine.SuspendReason() +} + +// machineWaitsOnClock is a quiescent machine whose only pending work is an +// event due later, which an advance of the clock delivers. +func (sess *debugSession) machineWaitsOnClock() bool { + exec := sess.machine + if exec == nil || sess.paused != "" || exec.SuspendReason() != "" || exec.HasPendingSignal() { + return false + } + _, due := exec.NextWait() + return due +} + +// suspendReason says why a behavior is suspended: a breakpoint or quiescence. +func (sess *debugSession) suspendReason() string { + if sess.paused != "" { + return "paused at breakpoint " + sess.pausedName + } + if sess.action != nil { + return "" + } + if reason := sess.machine.SuspendReason(); reason != "" { + return reason + } + return "the machine is quiescent" +} + +// breakpointIDs lists the breakpoints by render ID, in order. +func (sess *debugSession) breakpointIDs() []string { + ids := make([]string, 0, len(sess.breakpoints)) + for id := range sess.breakpoints { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +// actionSnapshot fills in the tokens of an action and the edges they took. +func (sess *debugSession) actionSnapshot(snap *debugSnapshot) { + exec := sess.action + for _, tr := range exec.TraversalsSince(sess.fired) { + if edge, ok := sess.actionEdge(tr.Within, tr.Edge); ok { + snap.Taken = append(snap.Taken, edge) + } + } + sess.fired = exec.TraversalCount() + for _, tok := range exec.Tokens() { + within := tok.Within() + id, drawn := sess.actions.Node(within, tok.Location) + t := debugToken{ID: tok.ID, Node: id, Placed: drawn, Waiting: debugWait(tok)} + if tok.Wait != nil && tok.Wait.Timed { + due := tok.Wait.Due + t.Due = &due + } + if tok.Via.Source != nil || tok.Via.Target != nil { + if edge, ok := sess.actionEdge(within, tok.Via); ok { + t.Via = &edge + } + } + for _, await := range exec.Awaiting(tok) { + if edge, ok := sess.actionEdge(within, await); ok { + t.Awaiting = append(t.Awaiting, edge) + } + } + snap.Tokens = append(snap.Tokens, t) + } + for _, msg := range sess.rt.PendingMessages() { + snap.Queue = append(snap.Queue, debugEvent{Event: debugSignalText(msg), At: snap.Time, Pending: true}) + } + if exec.State().Ended() { + results := exec.Results() + if len(results) > 0 { + snap.Results = make(map[string]string, len(results)) + for name, value := range results { + snap.Results[name] = runtime.FormatValue(value) + } + } + } +} + +// enclosing is the render node id with the nodes it is nested in below the +// root, outermost first: the states and regions an active vertex makes active. +func (sess *debugSession) enclosing(id string) []string { + var chain []string + for cur := id; cur != "" && cur != sess.root(); cur = sess.parents[cur] { + chain = append(chain, cur) + } + for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 { + chain[i], chain[j] = chain[j], chain[i] + } + return chain +} + +// root is the render node drawing the behavior itself. +func (sess *debugSession) root() string { + if sess.states != nil { + return sess.states.Root() + } + return sess.actions.Root() +} + +// actionEdge is the render edge drawing edge in the flow of within. +func (sess *debugSession) actionEdge(within []ast.Node, edge lower.ActionEdge) (debugEdge, bool) { + index, ok := sess.actions.Edge(within, edge) + if !ok { + return debugEdge{}, false + } + return sess.edgeAt(index), true +} + +// edgeAt is the render edge at index in the rendering's edges. +func (sess *debugSession) edgeAt(index int) debugEdge { + edge := sess.rendering.Edges[index] + return debugEdge{Index: index, From: edge.From, To: edge.To} +} + +// debugWait says what a parked token waits for, "" for a token that runs. +func debugWait(tok runtime.Token) string { + w := tok.Wait + if w == nil { + return "" + } + switch { + case w.Timed: + return "until t=" + semantics.FormatReal(w.Due) + case w.Trigger != "": + return w.Trigger + case w.SignalType != "" && w.ViaPort != "": + return w.SignalType + " via " + w.ViaPort + case w.SignalType != "": + return w.SignalType + case w.ViaPort != "": + return "any signal via " + w.ViaPort + } + return "any signal" +} + +// machineSnapshot fills in the active configuration of a machine, the +// transitions it took since the last snapshot, and the events it has yet to take. +func (sess *debugSession) machineSnapshot(snap *debugSnapshot) { + exec := sess.machine + seen := make(map[string]bool) + activate := func(vertex ast.Node) { + id, ok := sess.states.Node(vertex) + if !ok { + return + } + for _, id := range sess.enclosing(id) { + if !seen[id] { + seen[id] = true + snap.ActiveStates = append(snap.ActiveStates, id) + } + } + } + for _, state := range exec.ActiveStates() { + activate(state) + } + if current := exec.CurrentState(); current != nil { + activate(current) + } + for _, f := range exec.FiredSince(sess.fired) { + var index int + var ok bool + if f.Source == nil { + index, ok = sess.states.EntryTransition(f.Decl, f.Owner, f.Target) + } else { + index, ok = sess.states.Transition(f.Decl, f.Source, f.Target) + } + if ok { + snap.Taken = append(snap.Taken, sess.edgeAt(index)) + } + } + sess.fired = exec.FiredCount() + for _, event := range exec.EventQueue().Events() { + snap.Queue = append(snap.Queue, debugEvent{Event: debugEventText(event), At: event.Timestamp}) + } + for _, msg := range sess.rt.PendingMessages() { + snap.Queue = append(snap.Queue, debugEvent{Event: debugSignalText(msg), At: snap.Time, Pending: true}) + } +} + +// debugEventText names a queued event by what it carries. +func debugEventText(event runtime.Event) string { + switch payload := event.Payload.(type) { + case runtime.Message: + return debugSignalText(payload) + case runtime.Call: + return payload.Operation + "()" + } + return event.Type.String() +} + +// debugSignalText writes a message as signal(arg=value, …). +func debugSignalText(msg runtime.Message) string { + if len(msg.Payload) == 0 { + if msg.Value != nil { + return msg.SignalType + "(" + runtime.FormatValue(*msg.Value) + ")" + } + return msg.SignalType + } + names := make([]string, 0, len(msg.Payload)) + for name := range msg.Payload { + names = append(names, name) + } + sort.Strings(names) + args := make([]string, 0, len(names)) + for _, name := range names { + args = append(args, name+"="+runtime.FormatValue(msg.Payload[name])) + } + return msg.SignalType + "(" + strings.Join(args, ", ") + ")" +} + +// debugEdit applies edit to the workspace and rebinds every session under the lock +// requests hold, so none runs at documents an edit left behind; closed's sessions end. +func (s *Server) debugEdit(ctx context.Context, closed string, edit func()) { + s.debug.mu.Lock() + edit() + var changed []*debugSnapshot + for id, sess := range s.debug.sessions { + if closed != "" && slices.Contains(sess.documents(), closed) { + sess.ended = closed + " was closed" + } else if !s.rebindNow(sess) { + continue + } + if sess.ended != "" { + delete(s.debug.sessions, id) + sess.release() + } + changed = append(changed, sess.snapshot()) + } + s.debug.mu.Unlock() + s.notifyDebugChanged(ctx, changed) +} + +// rebindNow rebinds sess to the workspace as it is now, reporting whether the +// session moved, without taking a snapshot. +func (s *Server) rebindNow(sess *debugSession) bool { + var moved bool + // One reading, so the declarations compared and the rendering located in + // are of the same documents. + _ = s.ws.Read(func(r *model.Reading) error { + moved = sess.rebind(r) + return nil + }) + return moved +} + +// documents are those the session stands on: the view's, the target's and the +// object's; closing any ends the session. +func (sess *debugSession) documents() []string { + docs := []string{sess.doc} + for _, doc := range []string{sess.targetDoc, sess.objectDoc} { + if doc != "" && !slices.Contains(docs, doc) { + docs = append(docs, doc) + } + } + return docs +} + +// rebind binds sess to the documents as r reads them, reporting whether the +// session moved: to new render IDs, or to its end. +func (sess *debugSession) rebind(r *model.Reading) bool { + end := func(reason string) bool { + sess.ended = reason + return true + } + for _, doc := range sess.documents() { + if r.Document(doc) == nil { + return end(fmt.Sprintf("%s was closed", doc)) + } + } + target := r.Declared(sess.targetDoc, sess.target) + if target == nil { + return end(fmt.Sprintf("%s is no longer declared", sess.target)) + } + var object *symbols.Symbol + if sess.objectSym != nil { + if object = r.Declared(sess.objectDoc, sess.object); object == nil { + return end(fmt.Sprintf("%s is no longer declared", sess.object)) + } + } + roots, gone := sess.namedIn(r, sess.named) + if gone != "" { + return end(gone) + } + roots = append(roots, target, object) + if change := dependencyChange(r, sess.target, sess.reads, r.Dependencies(roots...)); change != "" { + return end(change) + } + rendering, snapshot, err := r.RenderView(sess.doc, sess.view) + if err != nil { + return end(fmt.Sprintf("%s no longer renders: %v", sess.view, err)) + } + if rendering.Kind != sess.kind { + return end(fmt.Sprintf("%s now renders a %s", sess.view, rendering.Kind)) + } + if sess.viewText != "" && r.DeclarationText(r.DeclaredView(sess.doc, sess.view)) != sess.viewText { + return end(fmt.Sprintf("%s was edited", sess.view)) + } + if snapshot.Rendered.Version == sess.version && sameRendering(rendering, sess.rendering) { + return false + } + if err := sess.locate(rendering, target, snapshot.Rendered.Version); err != nil { + return end(fmt.Sprintf("%s no longer draws %s: %v", sess.view, sess.target, err)) + } + return true +} + +// dependencyChange describes the first way the declarations target's run would +// read now differ from those it read: one edited, one it did not read, or one +// gone; "" for none. +func dependencyChange(r *model.Reading, target string, was, now []model.Dependency) string { + before := make(map[dependencyKey]bool, len(was)) + for _, dep := range was { + before[keyOf(dep)] = true + } + after := make(map[dependencyKey]string, len(now)) + for _, dep := range now { + after[keyOf(dep)] = dep.Text + } + for _, dep := range was { + if text, ok := after[keyOf(dep)]; ok && text != dep.Text { + return fmt.Sprintf("%s was edited", dep.Name()) + } + } + for _, dep := range now { + if !before[keyOf(dep)] { + return fmt.Sprintf("%s now reads %s", target, dep.Name()) + } + } + for _, dep := range was { + if _, ok := after[keyOf(dep)]; ok { + continue + } + if r.Declared(dep.Doc, dep.FQN) == nil { + return fmt.Sprintf("%s is no longer declared", dep.Name()) + } + return fmt.Sprintf("%s no longer reads %s", target, dep.Name()) + } + return "" +} + +// dependencyKey names a dependency apart from its text. +type dependencyKey struct{ doc, fqn string } + +func keyOf(dep model.Dependency) dependencyKey { return dependencyKey{dep.Doc, dep.FQN} } + +// sameRendering reports whether two renderings draw the same nodes and edges +// under the same IDs. +func sameRendering(a, b *view.Rendering) bool { + if a == nil || b == nil || len(a.Edges) != len(b.Edges) { + return false + } + var flatten func(nodes []*view.Node, out *[]string) + flatten = func(nodes []*view.Node, out *[]string) { + for _, n := range nodes { + *out = append(*out, fmt.Sprintf("%s|%s|%v", n.ID, n.Kind, n.Origin)) + flatten(n.Children, out) + } + } + var an, bn []string + flatten(a.Roots, &an) + flatten(b.Roots, &bn) + if len(an) != len(bn) { + return false + } + for i := range an { + if an[i] != bn[i] { + return false + } + } + for i := range a.Edges { + if a.Edges[i].From != b.Edges[i].From || a.Edges[i].To != b.Edges[i].To || a.Edges[i].Origin != b.Edges[i].Origin { + return false + } + } + return true +} + +// debugSessionsEnded ends every live session with reason, announcing each. +func (s *Server) debugSessionsEnded(ctx context.Context, reason string) { + s.debug.mu.Lock() + var ended []*debugSnapshot + for id, sess := range s.debug.sessions { + sess.ended = reason + ended = append(ended, sess.snapshot()) + delete(s.debug.sessions, id) + sess.release() + } + s.debug.mu.Unlock() + s.notifyDebugChanged(ctx, ended) +} + +// notifyDebugChanged sends debugChanged for each snapshot, in session order. +// The push is best-effort, like diagnostics: a client polls on its next request. +func (s *Server) notifyDebugChanged(ctx context.Context, snaps []*debugSnapshot) { + if s.notifier == nil { + return + } + sort.Slice(snaps, func(i, j int) bool { return snaps[i].Session < snaps[j].Session }) + for _, snap := range snaps { + _ = s.notifier.Notify(ctx, MethodDebugChanged, snap) + } +} diff --git a/internal/frontend/lsp/debug_test.go b/internal/frontend/lsp/debug_test.go new file mode 100644 index 0000000000..78ffec7959 --- /dev/null +++ b/internal/frontend/lsp/debug_test.go @@ -0,0 +1,2203 @@ +package lsp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "sync" + "testing" + "time" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// debugMachine is a parallel state machine: one region moves on a guarded +// signal into a composite state, the other on the clock. +const debugMachine = `package Machines { + private import ScalarValues::*; + private import SI::*; + + attribute def Go { attribute level : Integer; } + attribute def Halt; + + state def Ops parallel { + attribute threshold : Integer = 1; + state motion { + entry; then idle; + state idle; + state busy { + entry; then working; + state working; + state finishing; + transition first working accept Halt then finishing; + } + transition go first idle accept g : Go if g.level > threshold then busy; + } + state clock { + entry; then waiting; + state waiting; + accept after 5 [s] then elapsed; + state elapsed; + } + } + + part def Robot { + exhibit state ops : Ops; + } +} +package MachineViews { + private import StandardViewDefinitions::*; + view opsView : StateTransitionView { expose Machines::Ops; } +} +` + +// debugFlow is an action forking into a nested flow, an assignment, a timed +// wait and a signal wait, joining and deciding on what the assignment did. +const debugFlow = `package Flows { + private import ScalarValues::*; + private import SI::*; + attribute def Ping; + action def Drive { + attribute speed : Integer = 0; + first start; + fork split; + action prep { first begin; action warm; succession first begin then warm; } + action tally { assign speed := speed + 1; } + action pause accept after 5 [s]; + action listen accept Ping; + join sync; + action park; + done; + succession first start then split; + succession first split then prep; + succession first split then tally; + succession first split then pause; + succession first split then listen; + succession first prep then sync; + succession first tally then sync; + succession first pause then sync; + succession first listen then sync; + decide check; + succession first sync then check; + if speed > 0 then done; + else park; + succession first park then done; + } +} +package FlowViews { + private import StandardViewDefinitions::*; + view driveView : ActionFlowView { expose Flows::Drive; } +} +` + +// debugRecorder is a client that keeps the debugChanged snapshots it is sent. +type debugRecorder struct { + recorder + mu sync.Mutex + changed []*debugSnapshot +} + +func (r *debugRecorder) Notify(ctx context.Context, method string, params interface{}) error { + if method == MethodDebugChanged { + if snap, ok := params.(*debugSnapshot); ok { + r.mu.Lock() + r.changed = append(r.changed, snap) + r.mu.Unlock() + } + } + return r.recorder.Notify(ctx, method, params) +} + +func (r *debugRecorder) debugChanged() []*debugSnapshot { + r.mu.Lock() + defer r.mu.Unlock() + return append([]*debugSnapshot(nil), r.changed...) +} + +// debugServer is a server holding one open document, with a client that keeps +// the debugChanged notifications. +func debugServer(t *testing.T, name, src string) (*Server, uri.URI, *debugRecorder) { + t.Helper() + s := NewServer(model.NewWorkspace()) + rec := &debugRecorder{} + s.client = rec + s.notifier = rec + docURI := uri.File(name) + if err := s.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{URI: docURI, LanguageID: "sysml", Version: 1, Text: src}, + }); err != nil { + t.Fatalf("DidOpen err = %v", err) + } + return s, docURI, rec +} + +// debugCall dispatches a debug request through the handler chain, as a served +// session does, and decodes the snapshot the client would receive. +func debugCall(t *testing.T, s *Server, method string, params any) (*debugSnapshot, error) { + t.Helper() + req, err := jsonrpc2.NewCall(jsonrpc2.NewNumberID(1), method, params) + if err != nil { + t.Fatalf("build %s request: %v", method, err) + } + var ( + raw json.RawMessage + callErr error + ) + reply := func(ctx context.Context, result interface{}, err error) error { + if err != nil { + callErr = err + return nil + } + encoded, mErr := json.Marshal(result) + if mErr != nil { + t.Fatalf("marshal %s result: %v", method, mErr) + } + raw = encoded + return nil + } + handler := s.debugHandler(func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + t.Fatalf("%s was not handled: it fell through to the next handler", req.Method()) + return nil + }) + if err := handler(context.Background(), reply, req); err != nil { + t.Fatalf("dispatch %s: %v", method, err) + } + if callErr != nil { + return nil, callErr + } + var snap debugSnapshot + if err := json.Unmarshal(raw, &snap); err != nil { + t.Fatalf("decode %s result %s: %v", method, raw, err) + } + return &snap, nil +} + +// mustDebug is debugCall for a request that must succeed. +func mustDebug(t *testing.T, s *Server, method string, params any) *debugSnapshot { + t.Helper() + snap, err := debugCall(t, s, method, params) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + return snap +} + +// advanceBy is an advance request moving session's clock forward by time. +func advanceBy(session string, time float64) *debugAdvanceParams { + return &debugAdvanceParams{Session: session, Time: &time} +} + +// ids maps the names of a rendering's nodes to their IDs; names must be unique. +func ids(t *testing.T, r *renderResult) map[string]string { + t.Helper() + out := make(map[string]string, len(r.Nodes)) + for _, n := range r.Nodes { + if n.Name == "" { + continue + } + if _, dup := out[n.Name]; dup { + t.Fatalf("node name %q is drawn twice; the fixture must name nodes uniquely", n.Name) + } + out[n.Name] = n.ID + } + return out +} + +// edgeIndex is the position of the edge from one node to another in a render +// result, which is what a snapshot's edges are indexed by. +func edgeIndex(t *testing.T, r *renderResult, from, to string) int { + t.Helper() + for i, e := range r.Edges { + if e.From == from && e.To == to { + return i + } + } + t.Fatalf("no edge %s -> %s among %d edges", from, to, len(r.Edges)) + return -1 +} + +// edges spells a snapshot's edges as from->to for comparison. +func edges(list []debugEdge) []string { + out := make([]string, 0, len(list)) + for _, e := range list { + out = append(out, e.From+"->"+e.To) + } + return out +} + +// tokenAt is the token standing at node, failing when none does. +func debugTokenAt(t *testing.T, snap *debugSnapshot, node string) debugToken { + t.Helper() + for _, tok := range snap.Tokens { + if tok.Node == node { + return tok + } + } + t.Fatalf("no token at %s in %s", node, describe(snap)) + return debugToken{} +} + +// describe writes a snapshot for a failure message. +func describe(snap *debugSnapshot) string { + b, _ := json.Marshal(snap) + return string(b) +} + +func wantStrings(t *testing.T, what string, got, want []string) { + t.Helper() + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("%s = %v, want %v", what, got, want) + } +} + +func wantState(t *testing.T, snap *debugSnapshot, state string) { + t.Helper() + if snap.State != state { + t.Errorf("state = %q (%s), want %q: %s", snap.State, snap.Reason, state, describe(snap)) + } +} + +// The request and snapshot shapes cross the wire under their documented names. +func TestDebugProtocolShapes(t *testing.T) { + start := debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File("/w/m.sysml")}, + View: "V::v", Target: "P::T", Object: "P::O", + } + raw, err := json.Marshal(start) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + for _, key := range []string{"textDocument", "view", "target", "object"} { + if _, ok := fields[key]; !ok { + t.Errorf("start params lack %q: %s", key, raw) + } + } + var back debugStartParams + if err := json.Unmarshal(raw, &back); err != nil || back != start { + t.Errorf("start params round trip = %+v, %v; want %+v", back, err, start) + } + + bp := debugBreakpointsParams{Session: "debug-1", NodeIDs: []string{"n3", "n5"}} + raw, _ = json.Marshal(bp) + if want := `{"session":"debug-1","nodeIds":["n3","n5"]}`; string(raw) != want { + t.Errorf("breakpoints params = %s, want %s", raw, want) + } + send := debugSendParams{Session: "debug-1", Signal: "Go", Args: map[string]string{"level": "3"}} + raw, _ = json.Marshal(send) + if want := `{"session":"debug-1","signal":"Go","args":{"level":"3"}}`; string(raw) != want { + t.Errorf("send params = %s, want %s", raw, want) + } + raw, _ = json.Marshal(advanceBy("debug-1", 2.5)) + if want := `{"session":"debug-1","time":2.5}`; string(raw) != want { + t.Errorf("advance params = %s, want %s", raw, want) + } + for _, malformed := range []string{`{"session":"debug-1"}`, `{"session":"debug-1","time":null}`} { + var advance debugAdvanceParams + if err := json.Unmarshal([]byte(malformed), &advance); err != nil { + t.Fatal(err) + } + if advance.Time != nil { + t.Errorf("advance params %s decoded a time of %v, want none", malformed, *advance.Time) + } + } + + due := 5.0 + snap := debugSnapshot{ + Protocol: debugProtocolVersion, Session: "debug-1", Kind: "action", View: "V::v", Target: "P::T", + Root: "n0", Version: 3, Revision: 7, State: debugWaiting, Reason: "Ping", Time: 5, + Tokens: []debugToken{{ID: 2, Node: "n4", Placed: true, Via: &debugEdge{Index: 1, From: "n1", To: "n4"}, + Awaiting: []debugEdge{{Index: 2, From: "n2", To: "n4"}}, Waiting: "until t=5.0", Due: &due}}, + ActiveStates: []string{}, Taken: []debugEdge{{Index: 1, From: "n1", To: "n4"}}, + Queue: []debugEvent{{Event: "Ping", At: 5, Pending: true}}, + Breakpoints: []string{"n4"}, PausedAt: "n4", Notes: []string{"a note"}, Results: map[string]string{"speed": "1"}, + } + raw, err = json.Marshal(snap) + if err != nil { + t.Fatal(err) + } + var decoded debugSnapshot + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if again, _ := json.Marshal(decoded); string(again) != string(raw) { + t.Errorf("snapshot round trip changed it:\n%s\n%s", raw, again) + } + for _, key := range []string{"protocol", "session", "kind", "view", "target", "root", "version", "revision", "state", "reason", + "time", "tokens", "activeStates", "taken", "queue", "breakpoints", "pausedAt", "notes", "results"} { + if !strings.Contains(string(raw), `"`+key+`":`) { + t.Errorf("snapshot lacks %q: %s", key, raw) + } + } + if strings.Contains(string(raw), `"object"`) { + t.Errorf("an unset object is written: %s", raw) + } + // The empty collections a client iterates are written as such, never null. + empty, _ := json.Marshal(debugSnapshot{Tokens: []debugToken{}, ActiveStates: []string{}, Taken: []debugEdge{}, + Queue: []debugEvent{}, Breakpoints: []string{}, Notes: []string{}}) + if strings.Contains(string(empty), "null") { + t.Errorf("empty snapshot writes null: %s", empty) + } +} + +// A request naming a behavior the view does not draw, a performer that is no +// object, a session not live, a node not drawn or a signal not taken is refused +// with a typed InvalidParams error. +func TestDebugRefusesWhatItCannotRun(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine+` +package Other { + part def Widget; + view widgets : TreeView { expose Other::Widget; } + private import StandardViewDefinitions::*; +} +`) + doc := protocol.TextDocumentIdentifier{URI: docURI} + cases := []struct { + name string + params debugStartParams + want error + }{ + {"no target", debugStartParams{TextDocument: doc, View: "MachineViews::opsView"}, ErrDebugTarget}, + {"not a behavior view", debugStartParams{TextDocument: doc, View: "Other::widgets", Target: "Other::Widget"}, ErrDebugTarget}, + {"undeclared target", debugStartParams{TextDocument: doc, View: "MachineViews::opsView", Target: "Machines::Nope"}, ErrDebugTarget}, + {"wrong kind of target", debugStartParams{TextDocument: doc, View: "MachineViews::opsView", Target: "Machines::Robot"}, ErrDebugTarget}, + {"undeclared performer", debugStartParams{TextDocument: doc, View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Nope"}, ErrDebugTarget}, + {"attribute as performer", debugStartParams{TextDocument: doc, View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Ops::threshold"}, ErrDebugTarget}, + {"signal as performer", debugStartParams{TextDocument: doc, View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Go"}, ErrDebugTarget}, + {"package as performer", debugStartParams{TextDocument: doc, View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines"}, ErrDebugTarget}, + {"behavior as performer", debugStartParams{TextDocument: doc, View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Ops"}, ErrDebugTarget}, + {"view as performer", debugStartParams{TextDocument: doc, View: "MachineViews::opsView", Target: "Machines::Ops", Object: "MachineViews::opsView"}, ErrDebugTarget}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := s.DebugStart(&tc.params) + if !errors.Is(err, tc.want) { + t.Fatalf("err = %v, want %v", err, tc.want) + } + var wire *jsonrpc2.Error + if !errors.As(err, &wire) || wire.Code != jsonrpc2.InvalidParams { + t.Errorf("err = %v, want an InvalidParams reply", err) + } + }) + } + + if _, err := s.DebugStep(&debugSessionParams{Session: "debug-99"}); !errors.Is(err, ErrDebugSession) { + t.Errorf("step of an unknown session: err = %v, want %v", err, ErrDebugSession) + } + + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{TextDocument: doc, View: "MachineViews::opsView", Target: "Machines::Ops"}) + if _, err := s.DebugBreakpoints(&debugBreakpointsParams{Session: snap.Session, NodeIDs: []string{"n99"}}); !errors.Is(err, ErrDebugNode) { + t.Errorf("breakpoint on an undrawn node: err = %v, want %v", err, ErrDebugNode) + } + if _, err := s.DebugSend(&debugSendParams{Session: snap.Session}); !errors.Is(err, ErrDebugSignal) { + t.Errorf("send of no signal: err = %v, want %v", err, ErrDebugSignal) + } + if _, err := s.DebugSend(&debugSendParams{Session: snap.Session, Signal: "Machines::Robot"}); !errors.Is(err, ErrDebugSignal) { + t.Errorf("send of a part def: err = %v, want %v", err, ErrDebugSignal) + } + if _, err := s.DebugSend(&debugSendParams{Session: snap.Session, Signal: "Halt"}); !errors.Is(err, ErrDebugSignal) { + t.Errorf("send of a signal no active state accepts: err = %v, want %v", err, ErrDebugSignal) + } + if _, err := s.DebugAdvance(advanceBy(snap.Session, -1)); !errors.Is(err, ErrDebugTime) { + t.Errorf("advance by a negative duration: err = %v, want %v", err, ErrDebugTime) + } + if _, err := s.DebugAdvance(&debugAdvanceParams{Session: snap.Session}); !errors.Is(err, ErrDebugTime) { + t.Errorf("advance by no duration: err = %v, want %v", err, ErrDebugTime) + } + if _, err := debugCall(t, s, MethodDebugAdvance, json.RawMessage(`{"session":"`+snap.Session+`","time":null}`)); !errors.Is(err, ErrDebugTime) { + t.Errorf("advance by a null duration: err = %v, want %v", err, ErrDebugTime) + } + before := mustDebug(t, s, MethodDebugAdvance, advanceBy(snap.Session, 0)) + if before.Time != snap.Time { + t.Errorf("advance by zero moved the clock from %v to %v", snap.Time, before.Time) + } + stopped := mustDebug(t, s, MethodDebugStop, &debugSessionParams{Session: snap.Session}) + wantState(t, stopped, debugEnded) + if _, err := s.DebugContinue(&debugSessionParams{Session: snap.Session}); !errors.Is(err, ErrDebugSession) { + t.Errorf("continue after stop: err = %v, want %v", err, ErrDebugSession) + } +} + +// A state machine session reports active states per region, fired transitions +// and queued events in render IDs; signals, the clock and breakpoints drive it. +func TestDebugStateMachineSession(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine) + r := render(t, s, docURI, "MachineViews::opsView") + id := ids(t, r) + edge := func(from, to string) string { return id[from] + "->" + id[to] } + startOf := func(parent string) string { + for _, n := range r.Nodes { + if n.Kind == "start" && n.Parent == id[parent] { + return n.ID + } + } + t.Fatalf("no start marker in %s", parent) + return "" + } + + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", + }) + if snap.Protocol != debugProtocolVersion || snap.Kind != "state" || snap.Root != r.Nodes[0].ID || snap.Version != r.Version { + t.Errorf("start snapshot header = %s", describe(snap)) + } + if snap.Object != "Machines::Robot" || snap.Target != "Machines::Ops" { + t.Errorf("start snapshot names %q performed by %q", snap.Target, snap.Object) + } + // The robot's machine ran to quiescence at t=0 as the robot was instantiated, + // so the session begins waiting on its timer. + wantState(t, snap, debugWaiting) + if !strings.Contains(snap.Reason, "t=5") { + t.Errorf("reason = %q, want the wait until t=5", snap.Reason) + } + wantStrings(t, "initial active states", snap.ActiveStates, []string{id["motion"], id["idle"], id["clock"], id["waiting"]}) + wantStrings(t, "entry transitions", edges(snap.Taken), []string{startOf("motion") + "->" + id["idle"], startOf("clock") + "->" + id["waiting"]}) + if len(snap.Queue) != 1 || snap.Queue[0].At != 5 || snap.Queue[0].Pending { + t.Errorf("queue = %+v, want the time event due at 5", snap.Queue) + } + + // One step dispatches the queued time event, moving the clock to it. + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugRunning) + if snap.Time != 5 { + t.Errorf("time = %v, want 5", snap.Time) + } + wantStrings(t, "active after the time event", snap.ActiveStates, []string{id["motion"], id["idle"], id["clock"], id["elapsed"]}) + wantStrings(t, "taken by the time event", edges(snap.Taken), []string{edge("waiting", "elapsed")}) + if len(snap.Queue) != 0 { + t.Errorf("queue = %+v, want it drained", snap.Queue) + } + + // With nothing left to do a step quiesces the machine, and says so. + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugSuspended) + if !strings.Contains(snap.Reason, "quiesced") { + t.Errorf("reason = %q, want quiescence", snap.Reason) + } + if len(snap.Taken) != 0 { + t.Errorf("a quiescent step reports taken = %v", edges(snap.Taken)) + } + + // A signal is posted, shown pending, and delivered by the next run; a + // guard that fails leaves the machine where it was. + snap = mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: snap.Session, Signal: "Go", Args: map[string]string{"level": "0"}}) + if len(snap.Queue) != 1 || snap.Queue[0].Event != "Go(level=0)" || !snap.Queue[0].Pending { + t.Errorf("queue after send = %+v, want the pending Go", snap.Queue) + } + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugSuspended) + wantStrings(t, "active after a refused guard", snap.ActiveStates, []string{id["motion"], id["idle"], id["clock"], id["elapsed"]}) + + // A guard reading a signal feature the message never carried fails the + // run, which the snapshot reports rather than hiding. + snap = mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: snap.Session, Signal: "Go"}) + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugFailed) + if snap.Reason == "" { + t.Error("a failed run gives no reason") + } + + // A passing guard takes the transition into the composite state and its entry + // transition, in order; the composite state is active with its substate. + snap = mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: snap.Session, Signal: "Go", Args: map[string]string{"level": "1 + 2"}}) + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugRunning) + wantStrings(t, "active in the composite state", snap.ActiveStates, []string{id["motion"], id["busy"], id["working"], id["clock"], id["elapsed"]}) + wantStrings(t, "taken into the composite state", edges(snap.Taken), []string{edge("idle", "busy"), startOf("busy") + "->" + id["working"]}) + + // The clock advances without anything due; the machine stays put. + snap = mustDebug(t, s, MethodDebugAdvance, advanceBy(snap.Session, 5)) + if snap.Time != 10 { + t.Errorf("time after advance = %v, want 10", snap.Time) + } + wantStrings(t, "active after advance", snap.ActiveStates, []string{id["motion"], id["busy"], id["working"], id["clock"], id["elapsed"]}) + + // A breakpoint on a state stops a continue as the state becomes active. + snap = mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: snap.Session, NodeIDs: []string{id["finishing"]}}) + wantStrings(t, "breakpoints", snap.Breakpoints, []string{id["finishing"]}) + mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: snap.Session, Signal: "Halt"}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["finishing"] || !strings.Contains(snap.Reason, "breakpoint finishing") { + t.Errorf("pausedAt = %q reason = %q, want the breakpoint on finishing", snap.PausedAt, snap.Reason) + } + wantStrings(t, "active at the breakpoint", snap.ActiveStates, []string{id["motion"], id["busy"], id["finishing"], id["clock"], id["elapsed"]}) + wantStrings(t, "taken to the breakpoint", edges(snap.Taken), []string{edge("working", "finishing")}) + + // The pause is reported once: the next run quiesces without a breakpoint. + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != "" || strings.Contains(snap.Reason, "breakpoint") { + t.Errorf("a quiescent run after the pause still reports pausedAt = %q reason = %q", snap.PausedAt, snap.Reason) + } + + snap = mustDebug(t, s, MethodDebugStop, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugEnded) + if snap.Reason != "stopped" { + t.Errorf("stop reason = %q", snap.Reason) + } +} + +// debugDeep is a machine whose own entry transition starts in a state nested +// in a composite state, past that state's own start. +const debugDeep = `package Machines { + attribute def Halt; + state def Deep { + entry; then working::step1; + state working { + state step1; + state step2; + transition first step1 accept Halt then step2; + } + state done; + transition first working accept Halt then done; + } +} +package MachineViews { + private import StandardViewDefinitions::*; + view deepView : StateTransitionView { expose Machines::Deep; } +} +` + +// An entry transition into a nested state is taken from the start marker of the +// body it is written in — the machine's — not that of the state it lands in. +func TestDebugEntryTransitionIntoANestedState(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/m.sysml", debugDeep) + r := render(t, s, docURI, "MachineViews::deepView") + id := ids(t, r) + var machineStart string + for _, n := range r.Nodes { + if n.Kind == "start" && n.Parent == r.Nodes[0].ID { + machineStart = n.ID + } + } + if machineStart == "" { + t.Fatal("no start marker in the machine's own body") + } + + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::deepView", Target: "Machines::Deep", + }) + wantStrings(t, "initial active states", snap.ActiveStates, []string{id["working"], id["step1"]}) + wantStrings(t, "entry transition", edges(snap.Taken), []string{machineStart + "->" + id["step1"]}) +} + +// A continue runs what is due now and holds the clock: an event due later +// leaves the machine waiting, at the same instant, until an advance reaches it. +func TestDebugContinueHoldsTheClock(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine) + id := ids(t, render(t, s, docURI, "MachineViews::opsView")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", + }) + wantState(t, snap, debugRunning) + session := snap.Session + + for i := 0; i < 2; i++ { + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugWaiting) + if !strings.Contains(snap.Reason, "t=5") { + t.Errorf("continue %d: reason = %q, want the wait until t=5", i, snap.Reason) + } + if snap.Time != 0 { + t.Errorf("continue %d moved the clock to %v", i, snap.Time) + } + wantStrings(t, "active after a continue", snap.ActiveStates, []string{id["motion"], id["idle"], id["clock"], id["waiting"]}) + if len(snap.Queue) != 1 || snap.Queue[0].At != 5 { + t.Errorf("continue %d: queue = %+v, want the time event still due at 5", i, snap.Queue) + } + } + + // A signal is delivered by a continue, the clock still held. + mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: session, Signal: "Go", Args: map[string]string{"level": "2"}}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugWaiting) + if snap.Time != 0 { + t.Errorf("delivering a signal moved the clock to %v", snap.Time) + } + wantStrings(t, "active after the signal", snap.ActiveStates, []string{id["motion"], id["busy"], id["working"], id["clock"], id["waiting"]}) + + // Advancing to the event fires it; a continue then quiesces the machine. + snap = mustDebug(t, s, MethodDebugAdvance, advanceBy(session, 5)) + if snap.Time != 5 { + t.Errorf("time after advance = %v, want 5", snap.Time) + } + wantStrings(t, "active after the advance", snap.ActiveStates, []string{id["motion"], id["busy"], id["working"], id["clock"], id["elapsed"]}) + wantStrings(t, "taken by the advance", edges(snap.Taken), []string{id["waiting"] + "->" + id["elapsed"]}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if !strings.Contains(snap.Reason, "quiesced") { + t.Errorf("reason = %q, want quiescence", snap.Reason) + } +} + +// debugTransient is a machine whose timed transition enters a state it leaves +// again at the same instant. +const debugTransient = `package Transient { + private import SI::*; + state def Descent { + entry; then coasting; + state coasting; + accept after 5 [s] then braking; + state braking; + then landed; + state landed; + } +} +package TransientViews { + private import StandardViewDefinitions::*; + view descentView : StateTransitionView { expose Transient::Descent; } +} +` + +// An advance pauses at a breakpoint state as it becomes active, a state left +// again at the same instant included, the clock held at that instant. +func TestDebugAdvancePausesOnATransientState(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/t.sysml", debugTransient) + id := ids(t, render(t, s, docURI, "TransientViews::descentView")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "TransientViews::descentView", Target: "Transient::Descent", + }) + session := snap.Session + wantStrings(t, "initial active states", snap.ActiveStates, []string{id["coasting"]}) + mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["braking"]}}) + + snap = mustDebug(t, s, MethodDebugAdvance, advanceBy(session, 20)) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["braking"] || !strings.Contains(snap.Reason, "breakpoint braking") { + t.Errorf("pausedAt = %q reason = %q, want the breakpoint on braking", snap.PausedAt, snap.Reason) + } + wantStrings(t, "active at the breakpoint", snap.ActiveStates, []string{id["braking"]}) + wantStrings(t, "taken to the breakpoint", edges(snap.Taken), []string{id["coasting"] + "->" + id["braking"]}) + if snap.Time != 5 { + t.Errorf("time at the breakpoint = %v, want 5: the advance stops where it paused", snap.Time) + } + + // Resuming leaves the transient state at the held instant; the advance + // then finishes without pausing again. + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != "" { + t.Errorf("pausedAt = %q after resuming, want none", snap.PausedAt) + } + wantStrings(t, "active after resuming", snap.ActiveStates, []string{id["landed"]}) + wantStrings(t, "taken after resuming", edges(snap.Taken), []string{id["braking"] + "->" + id["landed"]}) + if snap.Time != 5 { + t.Errorf("time after resuming = %v, want 5", snap.Time) + } + snap = mustDebug(t, s, MethodDebugAdvance, advanceBy(session, 15)) + if snap.Time != 20 || snap.PausedAt != "" { + t.Errorf("after the rest of the advance: time = %v pausedAt = %q, want 20 and none", snap.Time, snap.PausedAt) + } +} + +// debugJunction is a machine whose signal routes through a junction deciding on +// a counter. +const debugJunction = `package Routing { + private import ScalarValues::*; + state def Ops { + attribute count : Integer = 0; + entry; then idle; + state idle; + junction route; + state low; + state high; + transition first idle accept Go do assign count := count + 1 then route; + transition first route if count > 5 then high; + transition first route then low; + } + attribute def Go; +} +package RoutingViews { + private import StandardViewDefinitions::*; + view opsView : StateTransitionView { expose Routing::Ops; } +} +` + +// debugSameNamedSignals declares Go twice, the machine's own after another +// package's, and one signal inside the machine's body. +const debugSameNamedSignals = `package A { + attribute def Go; +} +package B { + attribute def Go; + state def Machine { + attribute def Local; + entry; then idle; + state idle; + state going; + state done; + transition first idle accept Go then going; + transition first going accept Local then done; + } +} +package BViews { + private import StandardViewDefinitions::*; + view machineView : StateTransitionView { expose B::Machine; } +} +` + +// A signal name resolves from the target's scope, as an accept written there +// does: the machine's own Go rather than the first Go declared in the document, +// and a signal declared in its body; a qualified name still names what it says. +func TestDebugSendResolvesSignalInTargetScope(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/s.sysml", debugSameNamedSignals) + id := ids(t, render(t, s, docURI, "BViews::machineView")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "BViews::machineView", Target: "B::Machine", + }) + session := snap.Session + + if _, err := debugCall(t, s, MethodDebugSend, &debugSendParams{Session: session, Signal: "A::Go"}); !errors.Is(err, ErrDebugSignal) { + t.Errorf("send of A::Go: err = %v, want %v", err, ErrDebugSignal) + } + snap = mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: session, Signal: "Go"}) + if len(snap.Queue) != 1 || snap.Queue[0].Event != "Go" || !snap.Queue[0].Pending { + t.Errorf("queue after sending Go = %+v, want the pending Go", snap.Queue) + } + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}) + wantStrings(t, "active after Go", snap.ActiveStates, []string{id["going"]}) + + snap = mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: session, Signal: "Local"}) + if len(snap.Queue) != 1 || snap.Queue[0].Event != "Local" { + t.Errorf("queue after sending Local = %+v, want the pending Local", snap.Queue) + } + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}) + wantStrings(t, "active after Local", snap.ActiveStates, []string{id["done"]}) + + if _, err := debugCall(t, s, MethodDebugSend, &debugSendParams{Session: session, Signal: "Go(1)"}); !errors.Is(err, ErrDebugSignal) { + t.Errorf("send of an expression: err = %v, want %v", err, ErrDebugSignal) + } +} + +// A breakpoint on a pseudostate pauses the run once the dispatch routed through +// it completes, the pseudostate reported as where it paused. +func TestDebugBreakpointOnAPseudostatePauses(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/j.sysml", debugJunction) + id := ids(t, render(t, s, docURI, "RoutingViews::opsView")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "RoutingViews::opsView", Target: "Routing::Ops", + }) + session := snap.Session + snap = mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["route"]}}) + wantStrings(t, "breakpoints", snap.Breakpoints, []string{id["route"]}) + + mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: session, Signal: "Routing::Go"}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["route"] || !strings.Contains(snap.Reason, "breakpoint route") { + t.Errorf("pausedAt = %q reason = %q, want the breakpoint on route", snap.PausedAt, snap.Reason) + } + wantStrings(t, "active at the breakpoint", snap.ActiveStates, []string{id["low"]}) + wantStrings(t, "taken to the breakpoint", edges(snap.Taken), []string{id["idle"] + "->" + id["route"], id["route"] + "->" + id["low"]}) + + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + if snap.PausedAt != "" { + t.Errorf("pausedAt = %q after resuming, want none", snap.PausedAt) + } +} + +// A breakpoint on the machine's `done` pauses the run standing on it, before the +// machine completes; the next step completes it. +func TestDebugBreakpointOnDonePausesBeforeCompletion(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/h.sysml", `package Halting { + attribute def Halt; + state def Runner { + entry; then idle; + state idle; + transition first idle accept Halt then done; + } +} +package HaltingViews { + private import StandardViewDefinitions::*; + view runnerView : StateTransitionView { expose Halting::Runner; } +} +`) + id := ids(t, render(t, s, docURI, "HaltingViews::runnerView")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "HaltingViews::runnerView", Target: "Halting::Runner", + }) + session := snap.Session + snap = mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["done"]}}) + wantStrings(t, "breakpoints", snap.Breakpoints, []string{id["done"]}) + + mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: session, Signal: "Halting::Halt"}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["done"] || !strings.Contains(snap.Reason, "breakpoint done") { + t.Errorf("pausedAt = %q reason = %q, want the breakpoint on done", snap.PausedAt, snap.Reason) + } + wantStrings(t, "active at the breakpoint", snap.ActiveStates, []string{id["done"]}) + wantStrings(t, "taken to the breakpoint", edges(snap.Taken), []string{id["idle"] + "->" + id["done"]}) + + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}) + wantState(t, snap, debugCompleted) + if snap.PausedAt != "" { + t.Errorf("pausedAt = %q after completing, want none", snap.PausedAt) + } + + // Without the breakpoint the same run completes in one continue, standing + // where the paused run ended up. + plain := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "HaltingViews::runnerView", Target: "Halting::Runner", + }) + mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: plain.Session, Signal: "Halting::Halt"}) + plain = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: plain.Session}) + wantState(t, plain, debugCompleted) + wantStrings(t, "active after completing without the breakpoint", plain.ActiveStates, snap.ActiveStates) +} + +// A step that lands a token on an action breakpoint pauses there, as a continue +// does; the step after moves it past the breakpoint. +func TestDebugStepPausesAtAnActionBreakpoint(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/f.sysml", debugFlow) + id := ids(t, render(t, s, docURI, "FlowViews::driveView")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "FlowViews::driveView", Target: "Flows::Drive", + }) + session := snap.Session + mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["split"]}}) + + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["split"] || !strings.Contains(snap.Reason, "breakpoint split") { + t.Errorf("pausedAt = %q reason = %q, want the breakpoint on split", snap.PausedAt, snap.Reason) + } + if len(snap.Tokens) != 1 || snap.Tokens[0].Node != id["split"] { + t.Errorf("tokens = %+v, want the one held at split", snap.Tokens) + } + wantStrings(t, "taken to the breakpoint", edges(snap.Taken), []string{id["start"] + "->" + id["split"]}) + + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}) + wantState(t, snap, debugRunning) + if snap.PausedAt != "" { + t.Errorf("pausedAt = %q after resuming, want none", snap.PausedAt) + } + if len(snap.Tokens) != 4 || debugTokenAt(t, snap, id["tally"]).Node != id["tally"] { + t.Errorf("tokens = %+v, want the fork's four branches", snap.Tokens) + } +} + +// pausedAtSplit starts a session on the drive flow and steps it to a breakpoint +// on its fork, returning the session and the rendering's IDs. +func pausedAtSplit(t *testing.T) (*Server, string, map[string]string) { + t.Helper() + s, docURI, _ := debugServer(t, "/w/f.sysml", debugFlow) + id := ids(t, render(t, s, docURI, "FlowViews::driveView")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "FlowViews::driveView", Target: "Flows::Drive", + }) + session := snap.Session + mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["split"]}}) + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["split"] { + t.Fatalf("pausedAt = %q, want the breakpoint on split", snap.PausedAt) + } + return s, session, id +} + +// Setting the breakpoints again while paused at one keeps the pause made: the +// next step moves past it rather than pausing at the same node again. Removing +// the breakpoint and setting it again does pause the run there again. +func TestDebugBreakpointsSetAgainWhilePausedKeepThePause(t *testing.T) { + s, session, id := pausedAtSplit(t) + snap := mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["split"]}}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["split"] { + t.Errorf("pausedAt = %q after setting the same breakpoints, want still split", snap.PausedAt) + } + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}) + wantState(t, snap, debugRunning) + if snap.PausedAt != "" { + t.Errorf("pausedAt = %q after the step, want the run past split", snap.PausedAt) + } + if len(snap.Tokens) != 4 || debugTokenAt(t, snap, id["tally"]).Node != id["tally"] { + t.Errorf("tokens = %+v, want the fork's four branches", snap.Tokens) + } + + s, session, id = pausedAtSplit(t) + snap = mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session}) + if len(snap.Breakpoints) != 0 { + t.Errorf("breakpoints = %v after removing the breakpoint, want none", snap.Breakpoints) + } + mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["split"]}}) + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["split"] || len(snap.Tokens) != 1 || snap.Tokens[0].Node != id["split"] { + t.Errorf("pausedAt = %q tokens = %+v after re-setting the breakpoint, want the token held at split again", snap.PausedAt, snap.Tokens) + } +} + +// An advance resumes an action paused at a breakpoint: the clock moves, and the +// run goes past the breakpoint to what is due by then. +func TestDebugAdvanceResumesAPausedAction(t *testing.T) { + s, session, id := pausedAtSplit(t) + snap := mustDebug(t, s, MethodDebugAdvance, advanceBy(session, 5)) + if snap.Time != 5 { + t.Errorf("time after advance = %v, want 5", snap.Time) + } + wantState(t, snap, debugWaiting) + if snap.PausedAt != "" { + t.Errorf("pausedAt = %q after the advance, want the run past split", snap.PausedAt) + } + for _, tok := range snap.Tokens { + if tok.Node == id["split"] { + t.Errorf("tokens = %+v, want none still held at split", snap.Tokens) + } + } + if debugTokenAt(t, snap, id["listen"]).Node != id["listen"] { + t.Errorf("tokens = %+v, want one waiting at listen", snap.Tokens) + } + taken := edges(snap.Taken) + for _, want := range []string{id["split"] + "->" + id["pause"], id["pause"] + "->" + id["sync"]} { + if !slices.Contains(taken, want) { + t.Errorf("taken = %v, want %s among them", taken, want) + } + } +} + +// debugReused is a flow whose two nested actions each run the one action they +// inherit, so its rendering draws that declaration twice. +const debugReused = `package Reused { + action def Check { + action look; + } + action def Twice { + first start; + action a : Check { first begin; then look; } + action b : Check { first begin; then look; } + done; + succession first start then a; + succession first a then b; + succession first b then done; + } +} +package ReusedViews { + private import StandardViewDefinitions::*; + view twiceView : ActionFlowView { expose Reused::Twice; } +} +` + +// A breakpoint on one drawing of a node a nested flow reuses stops the run at +// that occurrence only, not at the same declaration another nested flow runs. +func TestDebugBreakpointOnAReusedNodeStopsAtItsOccurrence(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/r.sysml", debugReused) + r := render(t, s, docURI, "ReusedViews::twiceView") + var a, b, lookInA, lookInB string + for _, n := range r.Nodes { + switch n.Name { + case "a": + a = n.ID + case "b": + b = n.ID + } + } + for _, n := range r.Nodes { + if n.Name != "look" { + continue + } + switch n.Parent { + case a: + lookInA = n.ID + case b: + lookInB = n.ID + } + } + if lookInA == "" || lookInB == "" || lookInA == lookInB { + t.Fatalf("look is drawn as %q in a and %q in b, want one drawing in each", lookInA, lookInB) + } + + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "ReusedViews::twiceView", Target: "Reused::Twice", + }) + session := snap.Session + snap = mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{lookInB}}) + wantStrings(t, "breakpoints", snap.Breakpoints, []string{lookInB}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != lookInB || !strings.Contains(snap.Reason, "breakpoint look") { + t.Errorf("pausedAt = %q reason = %q, want the breakpoint on look within b", snap.PausedAt, snap.Reason) + } + if len(snap.Tokens) != 1 || snap.Tokens[0].Node != lookInB { + t.Errorf("tokens = %+v, want the one held at look within b", snap.Tokens) + } + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugCompleted) +} + +// The node paused at is the breakpoint the run stopped at, not the first token +// found on a breakpoint: a token still held at one already stopped at does not +// stand in for the one the run has just reached. +func TestDebugPausedAtIsTheBreakpointReachedNotTheFirstTokenOnOne(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/f.sysml", debugFlow) + id := ids(t, render(t, s, docURI, "FlowViews::driveView")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "FlowViews::driveView", Target: "Flows::Drive", + }) + session := snap.Session + mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["pause"], id["listen"]}}) + + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["pause"] || !strings.Contains(snap.Reason, "breakpoint pause") { + t.Fatalf("pausedAt = %q reason = %q, want the breakpoint on pause", snap.PausedAt, snap.Reason) + } + + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["listen"] || !strings.Contains(snap.Reason, "breakpoint listen") { + t.Errorf("pausedAt = %q reason = %q, want the breakpoint on listen", snap.PausedAt, snap.Reason) + } + var at []string + for _, tok := range snap.Tokens { + at = append(at, tok.Node) + } + if !slices.Contains(at, id["pause"]) || !slices.Contains(at, id["listen"]) { + t.Errorf("tokens at %v, want ones still held at pause and at listen", at) + } +} + +// debugCounter is a robot whose machine counts the timer firing; guards then +// tell one firing from two. +const debugCounter = `package Counting { + private import ScalarValues::*; + private import SI::*; + part def Robot { + attribute count : Integer = 0; + exhibit state ops { + entry; then waiting; + state waiting; + state elapsed; + state once; + state twice; + transition first waiting accept after 5 [s] do assign count := count + 1 then elapsed; + transition first elapsed accept after 1 [s] if count == 1 then once; + transition first elapsed accept after 1 [s] if count > 1 then twice; + } + } + state def Ops { entry; then idle; state idle; } + part def Twin { + exhibit state left : Ops; + exhibit state right : Ops; + } +} +package CountingViews { + private import StandardViewDefinitions::*; + view opsView : StateTransitionView { expose Counting::Robot::ops; } + view twinView : StateTransitionView { expose Counting::Ops; } +} +` + +// A session on an object exhibiting the target debugs the machine the object +// already runs, so its timed effect happens once; an object exhibiting it twice +// is ambiguous. +func TestDebugStartAttachesToThePerformersMachine(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/c.sysml", debugCounter) + id := ids(t, render(t, s, docURI, "CountingViews::opsView")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "CountingViews::opsView", Target: "Counting::Robot::ops", Object: "Counting::Robot", + }) + session := snap.Session + wantStrings(t, "initial active states", snap.ActiveStates, []string{id["waiting"]}) + if len(snap.Queue) != 1 { + t.Errorf("queue = %+v, want the one timer of the one machine", snap.Queue) + } + snap = mustDebug(t, s, MethodDebugAdvance, advanceBy(session, 6)) + wantStrings(t, "active after both timers", snap.ActiveStates, []string{id["once"]}) + wantStrings(t, "taken", edges(snap.Taken), []string{id["waiting"] + "->" + id["elapsed"], id["elapsed"] + "->" + id["once"]}) + + _, err := debugCall(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "CountingViews::twinView", Target: "Counting::Ops", Object: "Counting::Twin", + }) + if err == nil || !strings.Contains(err.Error(), "2 times") { + t.Errorf("start on an object exhibiting the machine twice: err = %v, want the ambiguity refused", err) + } +} + +// A session's executors keep only the firings and successions since the mark +// the last snapshot read from: each snapshot releases what the one before it +// reported, so a long-lived session's record stays bounded while the counts go on. +func TestDebugSessionReleasesTheHistoryItReports(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", + }) + session := snap.Session + s.debug.mu.Lock() + machine := s.debug.sessions[session].machine + s.debug.mu.Unlock() + if len(snap.Taken) == 0 || len(machine.FiredTransitions()) != len(snap.Taken) || machine.FiredCount() != len(snap.Taken) { + t.Fatalf("after start taken %d, kept %d of %d; want the entry transitions reported and kept", len(snap.Taken), len(machine.FiredTransitions()), machine.FiredCount()) + } + fired := machine.FiredCount() + snap = mustDebug(t, s, MethodDebugAdvance, advanceBy(session, 6)) + if len(snap.Taken) == 0 || len(machine.FiredTransitions()) != len(snap.Taken) || machine.FiredCount() != fired+len(snap.Taken) { + t.Errorf("after advance taken %d, kept %d of %d; want the timer's transition reported and kept alone, %d counted", len(snap.Taken), len(machine.FiredTransitions()), machine.FiredCount(), fired+len(snap.Taken)) + } + snap = mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session}) + if len(snap.Taken) != 0 || len(machine.FiredTransitions()) != 0 || machine.FiredCount() != fired+1 { + t.Errorf("after a request firing nothing taken %d, kept %d of %d; want none, none kept, %d counted", len(snap.Taken), len(machine.FiredTransitions()), machine.FiredCount(), fired+1) + } + + s, docURI, _ = debugServer(t, "/w/f.sysml", debugFlow) + snap = mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "FlowViews::driveView", Target: "Flows::Drive", + }) + session = snap.Session + s.debug.mu.Lock() + action := s.debug.sessions[session].action + s.debug.mu.Unlock() + var taken int + for range 3 { + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}) + taken += len(snap.Taken) + if len(snap.Taken) == 0 || len(action.Traversals()) != len(snap.Taken) || action.TraversalCount() != taken { + t.Fatalf("after a step taken %d, kept %d of %d; want the step's successions reported and kept alone, %d counted", len(snap.Taken), len(action.Traversals()), action.TraversalCount(), taken) + } + } +} + +// A machine's queue shows every message pending in its context, a message no +// state of the machine accepts now included: it is in flight all the same. +func TestDebugMachineQueueShowsEveryPendingMessage(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", + }) + session := snap.Session + if _, err := debugCall(t, s, MethodDebugSend, &debugSendParams{Session: session, Signal: "Halt"}); !errors.Is(err, ErrDebugSignal) { + t.Fatalf("send Halt to a machine in idle: err = %v, want it refused as not accepted now", err) + } + s.debug.mu.Lock() + s.debug.sessions[session].rt.PostMessage(runtime.NamedSignalMessage("Halt", nil)) + s.debug.mu.Unlock() + snap = mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session}) + var pending []string + for _, event := range snap.Queue { + if event.Pending { + pending = append(pending, event.Event) + } + } + wantStrings(t, "pending messages", pending, []string{"Halt"}) +} + +// An action session reports every token (forked, nested, parked on the clock or +// a signal, held at a join) with the edges it took, in the rendering's IDs. +func TestDebugActionSession(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/f.sysml", debugFlow) + r := render(t, s, docURI, "FlowViews::driveView") + id := ids(t, r) + edge := func(from, to string) string { return id[from] + "->" + id[to] } + + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "FlowViews::driveView", Target: "Flows::Drive", + }) + if snap.Kind != "action" || snap.Root != r.Nodes[0].ID || snap.Object != "" { + t.Errorf("start snapshot header = %s", describe(snap)) + } + wantState(t, snap, debugRunning) + if len(snap.Tokens) != 1 || snap.Tokens[0].Node != id["start"] || !snap.Tokens[0].Placed || snap.Tokens[0].Via != nil { + t.Errorf("initial tokens = %+v, want one at start", snap.Tokens) + } + + // One step moves the one token one succession on. + snap = mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugRunning) + tok := debugTokenAt(t, snap, id["split"]) + if tok.Via == nil || tok.Via.Index != edgeIndex(t, r, id["start"], id["split"]) { + t.Errorf("token at split arrived via %+v, want the edge from start", tok.Via) + } + wantStrings(t, "taken by one step", edges(snap.Taken), []string{edge("start", "split")}) + + // A breakpoint inside the nested flow stops the run as a token reaches + // it, while the other branches have parked or joined. + snap = mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: snap.Session, NodeIDs: []string{id["begin"]}}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["begin"] || !strings.Contains(snap.Reason, "breakpoint begin") { + t.Errorf("pausedAt = %q reason = %q, want the breakpoint on begin", snap.PausedAt, snap.Reason) + } + if len(snap.Tokens) != 4 { + t.Errorf("tokens = %d, want the fork's four branches: %s", len(snap.Tokens), describe(snap)) + } + nested := debugTokenAt(t, snap, id["begin"]) + if !nested.Placed { + t.Errorf("the token in the nested flow is not placed: %+v", nested) + } + joined := debugTokenAt(t, snap, id["sync"]) + wantStrings(t, "join edges awaited", edges(joined.Awaiting), []string{edge("prep", "sync"), edge("pause", "sync"), edge("listen", "sync")}) + timed := debugTokenAt(t, snap, id["pause"]) + if timed.Due == nil || *timed.Due != 5 || !strings.Contains(timed.Waiting, "t=5") { + t.Errorf("timed wait = %+v, want due at 5", timed) + } + if listening := debugTokenAt(t, snap, id["listen"]); listening.Waiting != "Ping" || listening.Due != nil { + t.Errorf("signal wait = %+v, want Ping", listening) + } + taken := edges(snap.Taken) + for _, want := range []string{edge("split", "prep"), edge("split", "tally"), edge("split", "pause"), edge("split", "listen"), edge("tally", "sync")} { + if !contains(taken, want) { + t.Errorf("taken %v lacks %s", taken, want) + } + } + + // Cleared, the breakpoint no longer holds the run, which now waits on + // the clock for the timed accept. + snap = mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: snap.Session}) + if len(snap.Breakpoints) != 0 { + t.Errorf("breakpoints after clearing = %v", snap.Breakpoints) + } + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugWaiting) + if !strings.Contains(snap.Reason, "t=5") { + t.Errorf("reason = %q, want the clock wait", snap.Reason) + } + taken = edges(snap.Taken) + for _, want := range []string{edge("begin", "warm"), edge("prep", "sync")} { + if !contains(taken, want) { + t.Errorf("taken %v lacks %s", taken, want) + } + } + + // Advancing to the due instant releases the timed branch; the signal + // branch still waits. + snap = mustDebug(t, s, MethodDebugAdvance, advanceBy(snap.Session, 5)) + wantState(t, snap, debugWaiting) + if snap.Time != 5 || snap.Reason != "Ping" { + t.Errorf("after advance time = %v reason = %q, want 5 and Ping", snap.Time, snap.Reason) + } + wantStrings(t, "taken by the clock", edges(snap.Taken), []string{edge("pause", "sync")}) + if len(debugTokenAt(t, snap, id["sync"]).Awaiting) != 1 { + t.Errorf("join awaits %+v, want only the signal branch", debugTokenAt(t, snap, id["sync"]).Awaiting) + } + + // The signal arrives, the join fires, the decision reads the assignment. + snap = mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: snap.Session, Signal: "Ping"}) + if len(snap.Queue) != 1 || snap.Queue[0].Event != "Ping" || !snap.Queue[0].Pending { + t.Errorf("queue after send = %+v", snap.Queue) + } + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugCompleted) + if len(snap.Tokens) != 0 || snap.Results["speed"] != "1" { + t.Errorf("completed snapshot = %s", describe(snap)) + } + wantStrings(t, "taken to completion", edges(snap.Taken), []string{edge("listen", "sync"), edge("sync", "check"), edge("check", "done")}) + + // Stepping a completed action changes nothing. + again := mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: snap.Session}) + wantState(t, again, debugCompleted) + if len(again.Taken) != 0 { + t.Errorf("a step after completion took %v", edges(again.Taken)) + } +} + +// An action that fails at a step reports the failure; a run that exceeds its +// budget does too, as a failure rather than a hang. +func TestDebugActionFailure(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/f.sysml", `package Flows { + private import ScalarValues::*; + action def Spin { + attribute n : Integer = 0; + first start; + action bump { assign n := n + 1; } + action fail { assign n := n / 0; } + done; + succession first start then bump; + succession first bump then fail; + succession first fail then done; + } + action def Loop { + first start; + action again; + action more; + succession first start then again; + succession first again then more; + succession first more then again; + } +} +package FlowViews { + private import StandardViewDefinitions::*; + view spin : ActionFlowView { expose Flows::Spin; } + view loop : ActionFlowView { expose Flows::Loop; } +} +`) + doc := protocol.TextDocumentIdentifier{URI: docURI} + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{TextDocument: doc, View: "FlowViews::spin", Target: "Flows::Spin"}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugFailed) + if !strings.Contains(snap.Reason, "zero") { + t.Errorf("reason = %q, want the division by zero", snap.Reason) + } + // The failure stands until the next run; the token stays where it failed. + snap = mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: snap.Session}) + wantState(t, snap, debugFailed) + if len(snap.Tokens) != 1 || snap.Tokens[0].Node != ids(t, render(t, s, docURI, "FlowViews::spin"))["fail"] { + t.Errorf("tokens after the failure = %+v, want one at fail", snap.Tokens) + } + + snap = mustDebug(t, s, MethodDebugStart, &debugStartParams{TextDocument: doc, View: "FlowViews::loop", Target: "Flows::Loop"}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: snap.Session}) + wantState(t, snap, debugFailed) + if !strings.Contains(snap.Reason, "steps") { + t.Errorf("reason = %q, want the step budget", snap.Reason) + } +} + +// An edit leaving the behavior, performer and view as they were moves the session +// to the fresh IDs, announced by debugChanged; one to the behavior ends it. +func TestDebugSessionFollowsEdits(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + ctx := context.Background() + name := uriToName(docURI) + r := render(t, s, docURI, "MachineViews::opsView") + id := ids(t, r) + + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", + }) + session := snap.Session + mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["finishing"]}}) + before := mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}) + wantStrings(t, "active before the edit", before.ActiveStates, []string{id["motion"], id["idle"], id["clock"], id["elapsed"]}) + + // Text added above the machine shifts every declaration after it. + shifted := "package Extra {\n\tpart def Spare;\n\tpart def Wheel;\n}\n" + debugMachine + s.applyDidChange(ctx, name, []rawContentChange{{Text: shifted}}, 2) + changed := rec.debugChanged() + if len(changed) != 1 || changed[0].Session != session { + t.Fatalf("debugChanged after an unrelated edit = %v, want one for %s", changed, session) + } + moved := changed[0] + if moved.State == debugEnded { + t.Fatalf("an unrelated edit ended the session: %s", moved.Reason) + } + if moved.Version != 2 { + t.Errorf("moved snapshot is at version %d, want 2", moved.Version) + } + fresh := ids(t, render(t, s, docURI, "MachineViews::opsView")) + wantStrings(t, "active after the edit", moved.ActiveStates, []string{fresh["motion"], fresh["idle"], fresh["clock"], fresh["elapsed"]}) + wantStrings(t, "breakpoints after the edit", moved.Breakpoints, []string{fresh["finishing"]}) + if len(moved.Taken) != 0 { + t.Errorf("a relocation reports taken = %v", edges(moved.Taken)) + } + + // The session still runs, against the fresh IDs. + mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: session, Signal: "Go", Args: map[string]string{"level": "2"}}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantStrings(t, "active after continuing", snap.ActiveStates, []string{fresh["motion"], fresh["busy"], fresh["working"], fresh["clock"], fresh["elapsed"]}) + + // An edit inside another declaration of the same document is unrelated too. + s.applyDidChange(ctx, name, []rawContentChange{{Text: strings.Replace(shifted, "part def Wheel;", "part def Wheel { attribute r : Real; }", 1)}}, 3) + if changed = rec.debugChanged(); len(changed) != 2 || changed[1].State == debugEnded { + t.Fatalf("debugChanged after a second unrelated edit = %v", changed) + } + + // Rewriting the machine ends the session: its execution no longer stands + // for what the document declares. + edited := strings.Replace(shifted, "state finishing;", "state finishing;\n\t\t\t\tstate cooling;", 1) + s.applyDidChange(ctx, name, []rawContentChange{{Text: edited}}, 4) + changed = rec.debugChanged() + if len(changed) != 3 { + t.Fatalf("debugChanged after editing the machine = %d notifications, want 3", len(changed)) + } + ended := changed[2] + wantState(t, ended, debugEnded) + if !strings.Contains(ended.Reason, "Machines::Ops was edited") { + t.Errorf("end reason = %q", ended.Reason) + } + if _, err := s.DebugStep(&debugSessionParams{Session: session}); !errors.Is(err, ErrDebugSession) { + t.Errorf("step after the end: err = %v, want %v", err, ErrDebugSession) + } +} + +// An edit reaches the workspace under the lock requests hold, so a request in +// flight finishes at the documents it began with and the next one finds the +// session already moved to the edited ones. +func TestDebugEditWaitsForTheRequestInFlight(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + name := uriToName(docURI) + session := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", + }).Session + + // Held as a request holds it while it drives the session. + s.debug.mu.Lock() + applied := make(chan struct{}) + go func() { + defer close(applied) + s.applyDidChange(context.Background(), name, []rawContentChange{{Text: "package Extra {\n\tpart def Spare;\n}\n" + debugMachine}}, 2) + }() + time.Sleep(50 * time.Millisecond) + if v := s.ws.Document(name).Version; v != 1 { + t.Fatalf("the edit reached the workspace at version %d while a request held the session", v) + } + if v := s.debug.sessions[session].version; v != 1 { + t.Fatalf("the session moved to version %d while a request held it", v) + } + s.debug.mu.Unlock() + <-applied + + changed := rec.debugChanged() + if len(changed) != 1 || changed[0].Version != 2 { + t.Fatalf("debugChanged = %d notifications, want one at version 2", len(changed)) + } + if snap := mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session}); snap.Version != 2 { + t.Errorf("the step after the edit answered version %d, want 2", snap.Version) + } +} + +// Every snapshot of a session, answered or notified, takes the next revision, so +// a client can tell the newer of two that arrive out of order. +func TestDebugSnapshotsAreNumbered(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", + }) + session := snap.Session + if snap.Revision != 1 { + t.Errorf("the start answered revision %d, want 1", snap.Revision) + } + last := snap.Revision + next := func(what string, snap *debugSnapshot) { + t.Helper() + if snap.Revision != last+1 { + t.Errorf("%s: revision %d follows %d, want %d", what, snap.Revision, last, last+1) + } + last = snap.Revision + } + next("step", mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: session})) + next("breakpoints", mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session})) + + s.applyDidChange(context.Background(), uriToName(docURI), []rawContentChange{{Text: "package Extra {\n\tpart def Spare;\n}\n" + debugMachine}}, 2) + changed := rec.debugChanged() + if len(changed) != 1 { + t.Fatalf("debugChanged = %v, want one moved snapshot", changed) + } + next("moved", changed[0]) + next("continue", mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session})) + + mustDebug(t, s, MethodDebugStop, &debugSessionParams{Session: session}) + another := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", + }) + if another.Session == session || another.Revision != 1 { + t.Errorf("a new session %s starts at revision %d, want its own count from 1", another.Session, another.Revision) + } +} + +// A session paused at a breakpoint when an unrelated edit redraws the view stays +// paused, and the moved snapshot names the breakpoint node in the fresh IDs, for +// a machine and for an action paused in a nested flow alike. +func TestDebugPauseFollowsEdits(t *testing.T) { + t.Run("machine", func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + id := ids(t, render(t, s, docURI, "#state")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "#state", Target: "Machines::Ops", Object: "Machines::Robot", + }) + session := snap.Session + mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["busy"]}}) + mustDebug(t, s, MethodDebugSend, &debugSendParams{Session: session, Signal: "Go", Args: map[string]string{"level": "2"}}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["busy"] { + t.Fatalf("pausedAt = %q, want the breakpoint on busy", snap.PausedAt) + } + + // A machine declared above Ops is drawn before it, moving every ID. + s.applyDidChange(context.Background(), uriToName(docURI), []rawContentChange{{Text: "package Extra {\n\tstate def Spare { state one; }\n}\n" + debugMachine}}, 2) + fresh := ids(t, render(t, s, docURI, "#state")) + if fresh["busy"] == id["busy"] { + t.Fatalf("the edit left busy at %s; the fixture must move it", id["busy"]) + } + changed := rec.debugChanged() + if len(changed) != 1 { + t.Fatalf("debugChanged = %v, want one moved snapshot", changed) + } + moved := changed[0] + wantState(t, moved, debugSuspended) + if moved.PausedAt != fresh["busy"] || !strings.Contains(moved.Reason, "breakpoint busy") { + t.Errorf("moved pausedAt = %q reason = %q, want %s, the breakpoint on busy", moved.PausedAt, moved.Reason, fresh["busy"]) + } + wantStrings(t, "moved breakpoints", moved.Breakpoints, []string{fresh["busy"]}) + + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + if snap.PausedAt != "" { + t.Errorf("pausedAt = %q after resuming, want none", snap.PausedAt) + } + wantStrings(t, "active after resuming", snap.ActiveStates, []string{fresh["motion"], fresh["busy"], fresh["working"], fresh["clock"], fresh["waiting"]}) + }) + t.Run("action", func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/f.sysml", debugFlow) + id := ids(t, render(t, s, docURI, "#action")) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "#action", Target: "Flows::Drive", + }) + session := snap.Session + mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: session, NodeIDs: []string{id["warm"]}}) + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + wantState(t, snap, debugSuspended) + if snap.PausedAt != id["warm"] { + t.Fatalf("pausedAt = %q, want the breakpoint on warm", snap.PausedAt) + } + + s.applyDidChange(context.Background(), uriToName(docURI), []rawContentChange{{Text: "package Extra {\n\taction def Spare { action one; }\n}\n" + debugFlow}}, 2) + fresh := ids(t, render(t, s, docURI, "#action")) + if fresh["warm"] == id["warm"] { + t.Fatalf("the edit left warm at %s; the fixture must move it", id["warm"]) + } + changed := rec.debugChanged() + if len(changed) != 1 { + t.Fatalf("debugChanged = %v, want one moved snapshot", changed) + } + moved := changed[0] + wantState(t, moved, debugSuspended) + if moved.PausedAt != fresh["warm"] || !strings.Contains(moved.Reason, "breakpoint") { + t.Errorf("moved pausedAt = %q reason = %q, want %s, the breakpoint on warm", moved.PausedAt, moved.Reason, fresh["warm"]) + } + if debugTokenAt(t, moved, fresh["warm"]).Node != fresh["warm"] { + t.Errorf("moved tokens = %+v, want one held at warm", moved.Tokens) + } + + snap = mustDebug(t, s, MethodDebugContinue, &debugSessionParams{Session: session}) + if snap.PausedAt != "" { + t.Errorf("pausedAt = %q after resuming, want none", snap.PausedAt) + } + }) +} + +// A start reads the workspace once and registers the session after; an edit +// landing in between is not lost. One leaving the behavior as it was moves the +// session to the fresh IDs before it answers; one to the behavior — of the same +// length, so every span stays put — refuses the start. +func TestDebugStartSeesAnEditSinceItsReading(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + ctx := context.Background() + name := uriToName(docURI) + params := &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", + } + + sess, err := s.debugPrepare(params) + if err != nil { + t.Fatalf("prepare: %v", err) + } + shifted := "package Extra {\n\tpart def Spare;\n}\n" + debugMachine + s.applyDidChange(ctx, name, []rawContentChange{{Text: shifted}}, 2) + if changed := rec.debugChanged(); len(changed) != 0 { + t.Fatalf("debugChanged for a session not yet registered: %v", changed) + } + snap, err := s.debugRegister(sess) + if err != nil { + t.Fatalf("register after an unrelated edit: %v", err) + } + if snap.Version != 2 { + t.Errorf("snapshot is at version %d, want 2", snap.Version) + } + fresh := ids(t, render(t, s, docURI, "MachineViews::opsView")) + wantStrings(t, "active states", snap.ActiveStates, []string{fresh["motion"], fresh["idle"], fresh["clock"], fresh["waiting"]}) + // The move is reported by the first snapshot, which is the one a start + // without the edit would answer: the same revision, entry edges and notes. + undisturbed := mustDebug(t, s, MethodDebugStart, params) + if snap.Revision != 1 || undisturbed.Revision != 1 { + t.Errorf("revisions = %d after the edit, %d without, want 1 for a first snapshot", snap.Revision, undisturbed.Revision) + } + if len(undisturbed.Taken) == 0 { + t.Fatalf("an undisturbed start takes no entry edges: %+v", undisturbed) + } + if !reflect.DeepEqual(snap.Taken, undisturbed.Taken) { + t.Errorf("taken = %+v after the edit, want the entry edges an undisturbed start reports: %+v", snap.Taken, undisturbed.Taken) + } + if !reflect.DeepEqual(snap.Notes, undisturbed.Notes) { + t.Errorf("notes = %q after the edit, want an undisturbed start's %q", snap.Notes, undisturbed.Notes) + } + mustDebug(t, s, MethodDebugStop, &debugSessionParams{Session: undisturbed.Session}) + stepped := mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: snap.Session}) + wantStrings(t, "active states after a step", stepped.ActiveStates, []string{fresh["motion"], fresh["idle"], fresh["clock"], fresh["elapsed"]}) + + sess, err = s.debugPrepare(params) + if err != nil { + t.Fatalf("prepare: %v", err) + } + s.applyDidChange(ctx, name, []rawContentChange{{Text: strings.Replace(shifted, "state idle;", "state lazy;", 1)}}, 3) + if changed := rec.debugChanged(); len(changed) != 1 || changed[0].Session != snap.Session || changed[0].State != debugEnded { + t.Fatalf("debugChanged after editing the machine = %v, want the end of %s", changed, snap.Session) + } + if _, err := s.debugRegister(sess); !errors.Is(err, ErrDebugTarget) || !strings.Contains(err.Error(), "Machines::Ops was edited") { + t.Errorf("register after the machine was edited: err = %v, want %v saying Machines::Ops was edited", err, ErrDebugTarget) + } + s.debug.mu.Lock() + live := len(s.debug.sessions) + s.debug.mu.Unlock() + if live != 0 { + t.Errorf("%d sessions live after the refused start, want none", live) + } +} + +// Editing the performer, removing the view or closing the document ends a +// session, each saying why; shutting the server down ends them all. +func TestDebugSessionEnds(t *testing.T) { + ctx := context.Background() + start := func(t *testing.T, s *Server, docURI uri.URI) string { + t.Helper() + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", + }) + return snap.Session + } + lastEnd := func(t *testing.T, rec *debugRecorder, session string) *debugSnapshot { + t.Helper() + changed := rec.debugChanged() + if len(changed) == 0 { + t.Fatal("no debugChanged was sent") + } + last := changed[len(changed)-1] + if last.Session != session || last.State != debugEnded { + t.Fatalf("last debugChanged = %s, want %s ended", describe(last), session) + } + return last + } + + t.Run("performer edited", func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + session := start(t, s, docURI) + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugMachine, "exhibit state ops : Ops;", "exhibit state ops : Ops;\n\t\tattribute id : Integer;", 1)}}, 2) + if reason := lastEnd(t, rec, session).Reason; !strings.Contains(reason, "Machines::Robot was edited") { + t.Errorf("reason = %q", reason) + } + }) + t.Run("view removed", func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + session := start(t, s, docURI) + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugMachine, "view opsView : StateTransitionView { expose Machines::Ops; }", "", 1)}}, 2) + if reason := lastEnd(t, rec, session).Reason; !strings.Contains(reason, "MachineViews::opsView") { + t.Errorf("reason = %q", reason) + } + }) + t.Run("view rewritten but still drawing the target", func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + session := start(t, s, docURI) + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugMachine, "{ expose Machines::Ops; }", "{ expose Machines::Ops; expose Machines::Robot; }", 1)}}, 2) + if reason := lastEnd(t, rec, session).Reason; !strings.Contains(reason, "MachineViews::opsView was edited") { + t.Errorf("reason = %q", reason) + } + }) + t.Run("pseudo-view outlives edits around the target", func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "#state:Machines::Ops", Target: "Machines::Ops", + }) + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugMachine, "attribute def Halt;", "attribute def Halt;\n\tattribute def Resume;", 1)}}, 2) + changed := rec.debugChanged() + if len(changed) != 1 || changed[0].Session != snap.Session || changed[0].State == debugEnded || changed[0].Version != 2 { + t.Fatalf("debugChanged after an edit around a pseudo-view's target = %v", changed) + } + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugMachine, "state elapsed;", "state elapsed;\n\t\t\tstate late;", 1)}}, 3) + if reason := lastEnd(t, rec, snap.Session).Reason; !strings.Contains(reason, "Machines::Ops was edited") { + t.Errorf("reason = %q", reason) + } + }) + t.Run("document closed", func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + session := start(t, s, docURI) + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{TextDocument: protocol.TextDocumentIdentifier{URI: docURI}}); err != nil { + t.Fatal(err) + } + if reason := lastEnd(t, rec, session).Reason; !strings.Contains(reason, "closed") { + t.Errorf("reason = %q", reason) + } + }) + t.Run("document closed with its text still on disk", func(t *testing.T) { + name := filepath.Join(t.TempDir(), "m.sysml") + if err := os.WriteFile(name, []byte(debugMachine), 0o600); err != nil { + t.Fatal(err) + } + s, docURI, rec := debugServer(t, name, debugMachine) + session := start(t, s, docURI) + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{TextDocument: protocol.TextDocumentIdentifier{URI: docURI}}); err != nil { + t.Fatal(err) + } + if s.ws.Document(name) == nil { + t.Fatal("closing dropped the document the workspace folder holds") + } + changed := rec.debugChanged() + if len(changed) != 1 { + t.Fatalf("debugChanged = %d notifications, want the one ending the session", len(changed)) + } + if reason := lastEnd(t, rec, session).Reason; !strings.Contains(reason, "closed") { + t.Errorf("reason = %q", reason) + } + }) + t.Run("server shut down", func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + first, second := start(t, s, docURI), start(t, s, docURI) + if err := s.Shutdown(ctx); err != nil { + t.Fatal(err) + } + changed := rec.debugChanged() + if len(changed) != 2 || changed[0].Session != first || changed[1].Session != second { + t.Fatalf("debugChanged on shutdown = %v, want %s then %s", changed, first, second) + } + for _, snap := range changed { + wantState(t, snap, debugEnded) + } + if _, err := s.DebugStep(&debugSessionParams{Session: first}); !errors.Is(err, ErrDebugSession) { + t.Errorf("step after shutdown: err = %v", err) + } + }) +} + +// debugDerived draws behaviors whose content comes from the definitions they +// specialize, declared apart from them. +const debugDerived = `package Inherited { + private import ScalarValues::*; + attribute def Go; + state def Base { + entry; then idle; + state idle; + accept Go then done; + state done; + } + action def BaseFlow { + action a; + action b; + } + package Sub { + state def Derived :> Base; + state def Typing { + entry; then run; + state run : Base; + } + action def DerivedFlow :> BaseFlow { + first start; + then a; + succession first a then b; + then done; + } + } +} +package InheritedViews { + private import StandardViewDefinitions::*; + view derivedView : StateTransitionView { expose Inherited::Sub::Derived; } + view typingView : StateTransitionView { expose Inherited::Sub::Typing; } + view flowView : ActionFlowView { expose Inherited::Sub::DerivedFlow; } +} +` + +// An edit to a declaration the target takes its content from — not the target's +// own text — ends the session: the runtime no longer stands for what is drawn. +func TestDebugSessionEndsWhenInheritedContentChanges(t *testing.T) { + ctx := context.Background() + lastEnd := func(t *testing.T, rec *debugRecorder, session string) *debugSnapshot { + t.Helper() + changed := rec.debugChanged() + if len(changed) == 0 { + t.Fatal("no debugChanged was sent") + } + last := changed[len(changed)-1] + if last.Session != session || last.State != debugEnded { + t.Fatalf("last debugChanged = %s, want %s ended", describe(last), session) + } + return last + } + cases := []struct { + name, view, target, old, new, reason string + }{ + {"state definition specialized", "InheritedViews::derivedView", "Inherited::Sub::Derived", + "state done;", "state done;\n\t\tstate cooling;", "Inherited::Base was edited"}, + {"state definition specialized is removed", "InheritedViews::derivedView", "Inherited::Sub::Derived", + "state def Base {", "state def Root {", "Inherited::Base is no longer declared"}, + {"state definition specialized is shadowed", "InheritedViews::derivedView", "Inherited::Sub::Derived", + "state def Derived :> Base;", "state def Base { entry; then off; state off; }\n\t\tstate def Derived :> Base;", + "Inherited::Sub::Derived now reads Inherited::Sub::Base"}, + {"state definition typing a nested state", "InheritedViews::typingView", "Inherited::Sub::Typing", + "state done;", "state done;\n\t\tstate cooling;", "Inherited::Base was edited"}, + {"action definition specialized", "InheritedViews::flowView", "Inherited::Sub::DerivedFlow", + "action b;", "action b { attribute n : Integer; }", "Inherited::BaseFlow was edited"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/i.sysml", debugDerived) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: tc.view, Target: tc.target, + }) + if strings.Count(debugDerived, tc.old) != 1 { + t.Fatalf("fixture writes %q %d times", tc.old, strings.Count(debugDerived, tc.old)) + } + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugDerived, tc.old, tc.new, 1)}}, 2) + if reason := lastEnd(t, rec, snap.Session).Reason; !strings.Contains(reason, tc.reason) { + t.Errorf("reason = %q, want it to name %q", reason, tc.reason) + } + }) + } + + // An edit elsewhere in the definition's package leaves the session running. + s, docURI, rec := debugServer(t, "/w/i.sysml", debugDerived) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "InheritedViews::derivedView", Target: "Inherited::Sub::Derived", + }) + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugDerived, "attribute def Go;", "attribute def Go;\n\tattribute def Stop;", 1)}}, 2) + changed := rec.debugChanged() + if len(changed) != 1 || changed[0].Session != snap.Session || changed[0].State == debugEnded { + t.Fatalf("debugChanged after an unrelated edit = %v", changed) + } +} + +// A view debugs the behavior it exposes from another document, performed by an +// object from a third; edits there move or end the session, duplicates are refused. +func TestDebugTargetDeclaredInAnotherDocument(t *testing.T) { + ctx := context.Background() + split := strings.Index(debugMachine, "package MachineViews") + machines, views := debugMachine[:split], debugMachine[split:] + open := func(t *testing.T, s *Server, name, src string) uri.URI { + t.Helper() + docURI := uri.File(name) + if err := s.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{URI: docURI, LanguageID: "sysml", Version: 1, Text: src}, + }); err != nil { + t.Fatalf("DidOpen %s: %v", name, err) + } + return docURI + } + s, viewsURI, rec := debugServer(t, "/w/views.sysml", views) + machinesURI := open(t, s, "/w/machines.sysml", machines) + open(t, s, "/w/robots.sysml", "package Robots { part def Rover { exhibit state ops : Machines::Ops; } }\n") + r := render(t, s, viewsURI, "MachineViews::opsView") + id := ids(t, r) + + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Robots::Rover", + }) + if snap.Target != "Machines::Ops" || snap.Object != "Robots::Rover" || snap.Root != r.Nodes[0].ID { + t.Fatalf("start snapshot = %s", describe(snap)) + } + wantState(t, snap, debugWaiting) + wantStrings(t, "initial active states", snap.ActiveStates, []string{id["motion"], id["idle"], id["clock"], id["waiting"]}) + + // An edit to the machine's document that leaves the machine as declared + // keeps the session running; one rewriting the machine ends it. + s.applyDidChange(ctx, uriToName(machinesURI), []rawContentChange{{Text: strings.Replace(machines, "attribute def Halt;", "attribute def Halt;\n\tattribute def Stop;", 1)}}, 2) + for _, moved := range rec.debugChanged() { + if moved.Session != snap.Session || moved.State == debugEnded { + t.Fatalf("debugChanged after an unrelated edit to the machine's document = %s", describe(moved)) + } + wantStrings(t, "active states after an unrelated edit", moved.ActiveStates, snap.ActiveStates) + } + s.applyDidChange(ctx, uriToName(machinesURI), []rawContentChange{{Text: strings.Replace(machines, "state elapsed;", "state elapsed;\n\t\tstate cooling;", 1)}}, 3) + changed := rec.debugChanged() + last := changed[len(changed)-1] + if last.Session != snap.Session || last.State != debugEnded || !strings.Contains(last.Reason, "Machines::Ops was edited") { + t.Fatalf("debugChanged after the machine was edited = %s", describe(last)) + } + + // Closing the machine's document ends a session on it too. + snap = mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, View: "MachineViews::opsView", Target: "Machines::Ops", + }) + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{TextDocument: protocol.TextDocumentIdentifier{URI: machinesURI}}); err != nil { + t.Fatalf("DidClose: %v", err) + } + changed = rec.debugChanged() + last = changed[len(changed)-1] + if last.Session != snap.Session || last.State != debugEnded || !strings.Contains(last.Reason, "machines.sysml was closed") { + t.Fatalf("debugChanged after closing the machine's document = %s", describe(last)) + } + + // The same name declared by two documents names neither. + open(t, s, "/w/machines.sysml", machines) + open(t, s, "/w/spare.sysml", machines) + _, err := s.DebugStart(&debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, View: "MachineViews::opsView", Target: "Machines::Ops", + }) + if !errors.Is(err, ErrDebugTarget) || !strings.Contains(err.Error(), "/w/machines.sysml, /w/spare.sysml") { + t.Fatalf("start of an ambiguous target: err = %v, want %v naming both documents", err, ErrDebugTarget) + } + var wire *jsonrpc2.Error + if !errors.As(err, &wire) || wire.Code != jsonrpc2.InvalidParams { + t.Errorf("err = %v, want an InvalidParams reply", err) + } +} + +// Closing the target's or the object's document ends a session even when the +// file is on disk, so the workspace goes on holding the document it declares. +func TestDebugEndsWhenTargetOrObjectDocumentClosedOnDisk(t *testing.T) { + ctx := context.Background() + split := strings.Index(debugMachine, "package MachineViews") + machines, views := debugMachine[:split], debugMachine[split:] + robots := "package Robots { part def Rover { exhibit state ops : Machines::Ops; } }\n" + for _, tc := range []struct{ closed, decl string }{ + {"machines.sysml", "Machines::Ops"}, + {"robots.sysml", "Robots::Rover"}, + } { + t.Run(tc.closed+" closed", func(t *testing.T) { + dir := t.TempDir() + for name, src := range map[string]string{"views.sysml": views, "machines.sysml": machines, "robots.sysml": robots} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o600); err != nil { + t.Fatal(err) + } + } + s, viewsURI, rec := debugServer(t, filepath.Join(dir, "views.sysml"), views) + for name, src := range map[string]string{"machines.sysml": machines, "robots.sysml": robots} { + if err := s.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{URI: uri.File(filepath.Join(dir, name)), LanguageID: "sysml", Version: 1, Text: src}, + }); err != nil { + t.Fatalf("DidOpen %s: %v", name, err) + } + } + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Robots::Rover", + }) + closed := filepath.Join(dir, tc.closed) + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(closed)}}); err != nil { + t.Fatalf("DidClose: %v", err) + } + if s.ws.Document(closed) == nil { + t.Fatalf("closing dropped %s, which is on disk", tc.closed) + } + changed := rec.debugChanged() + if len(changed) != 1 { + t.Fatalf("debugChanged = %d notifications, want the one ending the session", len(changed)) + } + last := changed[0] + if last.Session != snap.Session || last.State != debugEnded || !strings.Contains(last.Reason, tc.closed+" was closed") { + t.Fatalf("debugChanged after closing %s's document = %s", tc.decl, describe(last)) + } + if _, err := s.DebugStep(&debugSessionParams{Session: snap.Session}); !errors.Is(err, ErrDebugSession) { + t.Errorf("step after the close: err = %v, want %v", err, ErrDebugSession) + } + }) + } +} + +// debugReads is a machine and an action whose run reads beyond what they +// inherit: a performer's supertype and feature types, an invoked action, a +// signal's schema and a value named from another package. +const debugReads = `package Consts { + private import ScalarValues::*; + attribute threshold : Integer = 2; + attribute boost : Integer = 7; +} +package Deps { + private import ScalarValues::*; + attribute def Speed { attribute level : Integer; } + item def Cargo { attribute mass : Real = 1.0; } + part def Base { attribute limit : Integer = 3; } + action def Brake { action grip; } + action def Drive { + action brake : Brake; + first start; + then brake; + then done; + } + state def Ops { + entry; then idle; + state idle; + transition first idle accept s : Speed if s.level > Consts::threshold then run; + state run; + } + package Bots { + part def Robot :> Base { + attribute load : Cargo; + perform action drive : Drive; + exhibit state ops : Ops; + } + } +} +package DepViews { + private import StandardViewDefinitions::*; + view opsView : StateTransitionView { expose Deps::Ops; } + view driveView : ActionFlowView { expose Deps::Drive; } +} +` + +// A session ends when any declaration its run reads is edited: the performer's +// supertype or a feature's type, an invoked action, a signal's schema, a value a +// guard names, or one a send's argument named; and only when one is. +func TestDebugSessionEndsWhenWhatItReadsChanges(t *testing.T) { + ctx := context.Background() + lastEnd := func(t *testing.T, rec *debugRecorder, session string) *debugSnapshot { + t.Helper() + changed := rec.debugChanged() + if len(changed) == 0 { + t.Fatal("no debugChanged was sent") + } + last := changed[len(changed)-1] + if last.Session != session || last.State != debugEnded { + t.Fatalf("last debugChanged = %s, want %s ended", describe(last), session) + } + return last + } + cases := []struct { + name, view, target, object, old, new, reason string + }{ + {"performer supertype", "DepViews::opsView", "Deps::Ops", "Deps::Bots::Robot", + "limit : Integer = 3", "limit : Integer = 4", "Deps::Base was edited"}, + {"performer feature type", "DepViews::opsView", "Deps::Ops", "Deps::Bots::Robot", + "mass : Real = 1.0", "mass : Real = 2.0", "Deps::Cargo was edited"}, + {"invoked action", "DepViews::driveView", "Deps::Drive", "", + "action grip;", "action grip; action release;", "Deps::Brake was edited"}, + {"signal schema", "DepViews::opsView", "Deps::Ops", "", + "attribute level : Integer;", "attribute level : Real;", "Deps::Speed was edited"}, + {"value a guard names", "DepViews::opsView", "Deps::Ops", "", + "threshold : Integer = 2", "threshold : Integer = 5", "Consts::threshold was edited"}, + {"value a guard names is removed", "DepViews::opsView", "Deps::Ops", "", + "threshold : Integer = 2", "limit : Integer = 2", "Consts::threshold is no longer declared"}, + {"performer supertype resolves elsewhere", "DepViews::opsView", "Deps::Ops", "Deps::Bots::Robot", + "package Bots {", "package Bots {\n\t\tpart def Base;", "Deps::Ops now reads Deps::Bots::Base"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/d.sysml", debugReads) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: tc.view, Target: tc.target, Object: tc.object, + }) + if strings.Count(debugReads, tc.old) != 1 { + t.Fatalf("fixture writes %q %d times", tc.old, strings.Count(debugReads, tc.old)) + } + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugReads, tc.old, tc.new, 1)}}, 2) + if reason := lastEnd(t, rec, snap.Session).Reason; !strings.Contains(reason, tc.reason) { + t.Errorf("reason = %q, want it to name %q", reason, tc.reason) + } + }) + } + + t.Run("signal a bare trigger names", func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "MachineViews::opsView", Target: "Machines::Ops", + }) + edited := strings.Replace(debugMachine, "attribute def Halt;", "attribute def Halt { attribute why : Integer; }", 1) + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: edited}}, 2) + if reason := lastEnd(t, rec, snap.Session).Reason; !strings.Contains(reason, "Machines::Halt was edited") { + t.Errorf("reason = %q, want it to name Machines::Halt", reason) + } + }) + + t.Run("value a send named", func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/d.sysml", debugReads) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "DepViews::opsView", Target: "Deps::Ops", + }) + edited := strings.Replace(debugReads, "boost : Integer = 7", "boost : Integer = 8", 1) + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: edited}}, 2) + if changed := rec.debugChanged(); len(changed) != 1 || changed[0].State == debugEnded { + t.Fatalf("debugChanged after editing a value the run has not read = %v", changed) + } + sent := mustDebug(t, s, MethodDebugSend, &debugSendParams{ + Session: snap.Session, Signal: "Speed", Args: map[string]string{"level": "Consts::boost"}, + }) + if sent.State != debugEnded || !strings.Contains(sent.Reason, "Consts::boost was edited") { + t.Fatalf("send naming an edited value = %s, want ended for Consts::boost", describe(sent)) + } + if _, err := s.DebugStep(&debugSessionParams{Session: snap.Session}); !errors.Is(err, ErrDebugSession) { + t.Errorf("step after the end: %v, want %v", err, ErrDebugSession) + } + + s, docURI, rec = debugServer(t, "/w/d.sysml", debugReads) + snap = mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "DepViews::opsView", Target: "Deps::Ops", + }) + sent = mustDebug(t, s, MethodDebugSend, &debugSendParams{ + Session: snap.Session, Signal: "Speed", Args: map[string]string{"level": "Consts::boost"}, + }) + if sent.State == debugEnded { + t.Fatalf("send naming an unchanged value = %s", describe(sent)) + } + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: edited}}, 2) + if reason := lastEnd(t, rec, snap.Session).Reason; !strings.Contains(reason, "Consts::boost was edited") { + t.Errorf("reason = %q, want it to name Consts::boost", reason) + } + }) + + // An edit to a declaration the run never reads leaves the session running, + // as does one to the notes and comments after declarations it does read. + unread := []struct{ name, old, new string }{ + {"unrelated edit", "boost : Integer = 7", "boost : Integer = 8"}, + {"note after a read declaration", "attribute threshold : Integer = 2;", "attribute threshold : Integer = 2; // the limit"}, + {"comment after the target", "state run;\n\t}", "state run;\n\t} /* the machine */"}, + {"note after the view", "expose Deps::Ops; }", "expose Deps::Ops; } // drawn"}, + {"comment after the view", "expose Deps::Ops; }", "expose Deps::Ops; } /* drawn */"}, + } + for _, tc := range unread { + t.Run(tc.name, func(t *testing.T) { + s, docURI, rec := debugServer(t, "/w/d.sysml", debugReads) + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "DepViews::opsView", Target: "Deps::Ops", Object: "Deps::Bots::Robot", + }) + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugReads, tc.old, tc.new, 1)}}, 2) + changed := rec.debugChanged() + if len(changed) != 1 || changed[0].Session != snap.Session || changed[0].State == debugEnded { + t.Fatalf("debugChanged after the edit = %v", changed) + } + stepped := mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: snap.Session}) + if stepped.State == debugEnded { + t.Fatalf("step after the edit = %s", describe(stepped)) + } + }) + } +} + +// Sessions run in runtimes of their own: two over one machine do not share +// state, and rendering the view they draw on is unchanged by their running. +func TestDebugSessionsAreIsolated(t *testing.T) { + s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine) + plain := render(t, s, docURI, "MachineViews::opsView") + params := &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", + } + first := mustDebug(t, s, MethodDebugStart, params) + second := mustDebug(t, s, MethodDebugStart, params) + if first.Session == second.Session { + t.Fatalf("two starts share the session %s", first.Session) + } + stepped := mustDebug(t, s, MethodDebugStep, &debugSessionParams{Session: first.Session}) + if stepped.Time != 5 { + t.Fatalf("first session time = %v, want 5", stepped.Time) + } + idle := mustDebug(t, s, MethodDebugBreakpoints, &debugBreakpointsParams{Session: second.Session}) + if idle.Time != 0 || fmt.Sprint(idle.ActiveStates) != fmt.Sprint(second.ActiveStates) { + t.Errorf("the second session moved with the first: %s", describe(idle)) + } + after := render(t, s, docURI, "MachineViews::opsView") + plainJSON, _ := json.Marshal(plain) + afterJSON, _ := json.Marshal(after) + if string(plainJSON) != string(afterJSON) { + t.Errorf("rendering changed while sessions ran:\n%s\n%s", plainJSON, afterJSON) + } +} diff --git a/internal/lsp/definition.go b/internal/frontend/lsp/definition.go similarity index 97% rename from internal/lsp/definition.go rename to internal/frontend/lsp/definition.go index fa4388efd4..791e70de06 100644 --- a/internal/lsp/definition.go +++ b/internal/frontend/lsp/definition.go @@ -5,7 +5,7 @@ import ( "go.lsp.dev/protocol" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // Definition returns the declaration location of the reference under the cursor, diff --git a/internal/lsp/definition_test.go b/internal/frontend/lsp/definition_test.go similarity index 99% rename from internal/lsp/definition_test.go rename to internal/frontend/lsp/definition_test.go index ecd86eae8f..c7f42ec604 100644 --- a/internal/lsp/definition_test.go +++ b/internal/frontend/lsp/definition_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) func TestDefinitionJumpsToDeclaration(t *testing.T) { diff --git a/internal/lsp/diagnostics.go b/internal/frontend/lsp/diagnostics.go similarity index 97% rename from internal/lsp/diagnostics.go rename to internal/frontend/lsp/diagnostics.go index aa95da9aed..1399aedee2 100644 --- a/internal/lsp/diagnostics.go +++ b/internal/frontend/lsp/diagnostics.go @@ -44,9 +44,10 @@ func (s *Server) sendDiagnosticsLocked(ctx context.Context, name string) { out := []protocol.Diagnostic{} if content, diags, ok := s.ws.AnalyzedContent(name); ok { out = make([]protocol.Diagnostic, 0, len(diags)) + pos := positionsFor(content) for _, d := range diags { out = append(out, protocol.Diagnostic{ - Range: spanToRange(content, d.Span), + Range: pos.rangeOf(d.Span), Severity: protocol.DiagnosticSeverity(int(d.Severity) + 1), Message: d.Message, Code: d.Code, diff --git a/internal/lsp/diagnostics_test.go b/internal/frontend/lsp/diagnostics_test.go similarity index 98% rename from internal/lsp/diagnostics_test.go rename to internal/frontend/lsp/diagnostics_test.go index 36e2988b1d..2e504cf5e4 100644 --- a/internal/lsp/diagnostics_test.go +++ b/internal/frontend/lsp/diagnostics_test.go @@ -9,7 +9,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // baseClient stubs all 12 protocol.Client methods; only PublishDiagnostics is overridden by fakeClient. @@ -95,7 +95,7 @@ func TestPublishDiagnosticsReportsSyntaxError(t *testing.T) { if len(got.Diagnostics) == 0 { t.Fatalf("expected at least one diagnostic") } - // passes.SeverityError (0) -> LSP severity 1. + // diag.SeverityError (0) -> LSP severity 1. if got.Diagnostics[0].Severity != protocol.DiagnosticSeverityError { t.Errorf("severity = %v, want %v", got.Diagnostics[0].Severity, protocol.DiagnosticSeverityError) } diff --git a/internal/frontend/lsp/document.go b/internal/frontend/lsp/document.go new file mode 100644 index 0000000000..47e13bcee9 --- /dev/null +++ b/internal/frontend/lsp/document.go @@ -0,0 +1,65 @@ +package lsp + +import ( + "github.com/Open-MBEE/OpenSysML/internal/doc/docrender" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" +) + +// The custom methods a document-preview client speaks, alongside the diagram +// methods in render.go. +const ( + // MethodDocuments lists the document definitions the workspace holds. + MethodDocuments = "opensysml/documents" + // MethodRenderDocument renders a named document definition as Markdown. + MethodRenderDocument = "opensysml/renderDocument" +) + +// documentsResult lists the document definitions of the workspace, each by +// qualified name and the file declaring it. +type documentsResult struct { + Documents []documentInfo `json:"documents"` +} + +// documentInfo is one document definition a client may ask to render. +type documentInfo struct { + Name string `json:"name"` + URI string `json:"uri"` +} + +// renderDocumentParams asks for the Markdown rendering of the document +// definition Name names. DiagramForm is the source its graph-shaped diagrams +// are written as, mermaid, dot or plantuml; empty is mermaid. +type renderDocumentParams struct { + Name string `json:"name"` + DiagramForm string `json:"diagramForm,omitempty"` +} + +// renderDocumentResult is the rendered document. +type renderDocumentResult struct { + Name string `json:"name"` + Markdown string `json:"markdown"` +} + +// Documents answers opensysml/documents: the document definitions declared +// across the workspace, in qualified-name order. +func (s *Server) Documents() *documentsResult { + out := &documentsResult{Documents: []documentInfo{}} + for _, def := range s.ws.DocumentDefinitions() { + out.Documents = append(out.Documents, documentInfo{ + Name: def.FQN, + URI: string(s.documentURI(def.Doc)), + }) + } + return out +} + +// RenderDocument answers opensysml/renderDocument: the named document compiled, +// evaluated and rendered as Markdown, or the typed error stopping it. +func (s *Server) RenderDocument(params *renderDocumentParams) (*renderDocumentResult, error) { + opts := docrender.MarkdownOptions{DiagramForm: view.Form(params.DiagramForm)} + markdown, err := s.ws.RenderDocumentMarkdown(params.Name, opts) + if err != nil { + return nil, err + } + return &renderDocumentResult{Name: params.Name, Markdown: markdown}, nil +} diff --git a/internal/frontend/lsp/document_test.go b/internal/frontend/lsp/document_test.go new file mode 100644 index 0000000000..fd58b425d5 --- /dev/null +++ b/internal/frontend/lsp/document_test.go @@ -0,0 +1,553 @@ +package lsp + +import ( + "context" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// documentModel is a native telescope model with a query library and a document +// definition, so every document-authoring feature has something to work on. +const documentModel = `package Observatory { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + part def Subsystem { + attribute mass : Real; + } + + part telescope { + part optics : Subsystem { + attribute redefines mass = 8.5; + } + } + + /* Subsystems finds every part usage below the root. */ + calc def Subsystems :> Query { + in root : Element; + WhereType( + source = Descendants(source = root, maxDepth = 3), + type = "PartUsage" + ) + } + + calc def SubsystemTable :> Query { + in root : Element; + Project( + source = Subsystems(root = root), + properties = ("name", "mass") + ) + } + + part def MassReport :> Document { + attribute redefines title = "Telescope Mass Report"; + + part masses : Table { + attribute redefines caption = "All subsystems by mass"; + calc rows : SubsystemTable { + in root = telescope; + } + } + } +} +` + +func openDocumentModel(t *testing.T) (*model.Workspace, *Server, string) { + t.Helper() + ws := model.NewWorkspace() + s := NewServer(ws) + name := uri.File("/tmp/document.sysml").Filename() + ws.Open(name, []byte(documentModel), 1) + return ws, s, name +} + +func documentPosParams(name string, src, anchor string, t *testing.T) protocol.TextDocumentPositionParams { + t.Helper() + off := strings.Index(src, anchor) + if off < 0 { + t.Fatalf("anchor %q not in fixture", anchor) + } + return protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(src), off), + } +} + +func TestDefinitionQueryInvocationToCalcDef(t *testing.T) { + _, s, name := openDocumentModel(t) + locs, err := s.Definition(context.Background(), &protocol.DefinitionParams{ + TextDocumentPositionParams: documentPosParams(name, documentModel, "SubsystemTable {", t), + }) + if err != nil { + t.Fatalf("Definition err = %v", err) + } + if len(locs) != 1 { + t.Fatalf("locations = %d, want 1", len(locs)) + } + want := offsetToPosition([]byte(documentModel), strings.Index(documentModel, "calc def SubsystemTable")) + if locs[0].Range.Start.Line != want.Line { + t.Errorf("decl line = %d, want %d (calc def SubsystemTable)", locs[0].Range.Start.Line, want.Line) + } +} + +func TestDefinitionBindingNameToQueryParameter(t *testing.T) { + _, s, name := openDocumentModel(t) + locs, err := s.Definition(context.Background(), &protocol.DefinitionParams{ + TextDocumentPositionParams: documentPosParams(name, documentModel, "root = telescope", t), + }) + if err != nil { + t.Fatalf("Definition err = %v", err) + } + if len(locs) != 1 { + t.Fatalf("locations = %d, want 1 (the query's `in root` parameter)", len(locs)) + } + // SubsystemTable's own `in root : Element;`, not Subsystems'. + table := strings.Index(documentModel, "calc def SubsystemTable") + want := offsetToPosition([]byte(documentModel), table+strings.Index(documentModel[table:], "in root")) + if locs[0].Range.Start.Line != want.Line { + t.Errorf("param line = %d, want %d (SubsystemTable's in root)", locs[0].Range.Start.Line, want.Line) + } +} + +func TestDefinitionBindingValueToModelElement(t *testing.T) { + _, s, name := openDocumentModel(t) + locs, err := s.Definition(context.Background(), &protocol.DefinitionParams{ + TextDocumentPositionParams: documentPosParams(name, documentModel, "telescope;", t), + }) + if err != nil { + t.Fatalf("Definition err = %v", err) + } + if len(locs) != 1 { + t.Fatalf("locations = %d, want 1 (part telescope)", len(locs)) + } + want := offsetToPosition([]byte(documentModel), strings.Index(documentModel, "part telescope")) + if locs[0].Range.Start.Line != want.Line { + t.Errorf("decl line = %d, want %d (part telescope)", locs[0].Range.Start.Line, want.Line) + } +} + +func TestHoverQueryInvocationShowsQueryDef(t *testing.T) { + _, s, name := openDocumentModel(t) + hov, err := s.Hover(context.Background(), &protocol.HoverParams{ + TextDocumentPositionParams: documentPosParams(name, documentModel, "SubsystemTable {", t), + }) + if err != nil { + t.Fatalf("Hover err = %v", err) + } + if hov == nil || !strings.Contains(hov.Contents.Value, "calc def SubsystemTable") { + t.Fatalf("hover = %+v, want the query's calc def signature", hov) + } +} + +func TestHoverQueryReferenceShowsDocComment(t *testing.T) { + _, s, name := openDocumentModel(t) + hov, err := s.Hover(context.Background(), &protocol.HoverParams{ + TextDocumentPositionParams: documentPosParams(name, documentModel, "Subsystems(root", t), + }) + if err != nil { + t.Fatalf("Hover err = %v", err) + } + if hov == nil || !strings.Contains(hov.Contents.Value, "finds every part usage") { + t.Fatalf("hover = %+v, want the referenced query's doc comment", hov) + } +} + +func TestHoverDocumentLibraryType(t *testing.T) { + _, s, name := openDocumentModel(t) + hov, err := s.Hover(context.Background(), &protocol.HoverParams{ + TextDocumentPositionParams: documentPosParams(name, documentModel, "Document {", t), + }) + if err != nil { + t.Fatalf("Hover err = %v", err) + } + if hov == nil || !strings.Contains(hov.Contents.Value, "part def") || + !strings.Contains(hov.Contents.Value, "Document") { + t.Fatalf("hover = %+v, want the library Document part def", hov) + } +} + +func completionLabels(t *testing.T, s *Server, name, anchor string) []string { + t.Helper() + list, err := s.Completion(context.Background(), &protocol.CompletionParams{ + TextDocumentPositionParams: documentPosParams(name, documentModel, anchor, t), + }) + if err != nil { + t.Fatalf("Completion err = %v", err) + } + labels := make([]string, 0, len(list.Items)) + for _, item := range list.Items { + labels = append(labels, item.Label) + } + return labels +} + +func TestCompletionCalcTypingPositionOffersQueries(t *testing.T) { + _, s, name := openDocumentModel(t) + labels := completionLabels(t, s, name, "SubsystemTable {") + if !containsLabel(labels, "SubsystemTable") || !containsLabel(labels, "Subsystems") { + t.Fatalf("labels = %v, want the workspace query definitions", labels) + } + if containsLabel(labels, "Subsystem") || containsLabel(labels, "telescope") { + t.Fatalf("labels = %v, want no non-query part definitions or usages", labels) + } + if !containsLabel(labels, "DocumentQueries") { + t.Fatalf("labels = %v, want packages so a qualified query stays reachable", labels) + } +} + +func TestCompletionBindingPositionOffersQueryParameters(t *testing.T) { + _, s, name := openDocumentModel(t) + labels := completionLabels(t, s, name, "root = telescope") + if len(labels) != 1 || labels[0] != "root" { + t.Fatalf("labels = %v, want exactly the query's `in` parameters", labels) + } +} + +// queryLibModel is a query library another package imports, so completion is +// exercised across documents and through package qualifiers. +const queryLibModel = `package QueryLib { + private import DocumentQueries::*; + private import KerML::Root::Element; + + calc def Everything :> Query { + in root : Element; + Descendants(source = root, maxDepth = 3) + } + + calc def NoInputs :> Query { + Descendants(source = Everything, maxDepth = 1) + } + + part def Widget; +} +` + +const reportModel = `package Reports { + private import DocumentQueries::*; + private import QueryLib::*; + + alias TableQuery for QueryLib::Everything; + + part def WidgetReport :> Document { + attribute redefines title = "Widgets"; + + part w : Table { + calc rows : Everything { + in root = QueryLib; + } + } + + part all : Table { + calc every : QueryLib:: { + } + } + + part none : Table { + calc bare : NoInputs { + } + } + + part q : Table { + calc qual : QueryLib::NoInputs { + } + } + } +} +` + +func openReportModel(t *testing.T) (*Server, string) { + t.Helper() + ws := model.NewWorkspace() + s := NewServer(ws) + ws.Open(uri.File("/tmp/lib.sysml").Filename(), []byte(queryLibModel), 1) + name := uri.File("/tmp/report.sysml").Filename() + ws.Open(name, []byte(reportModel), 1) + return s, name +} + +func completionLabelsIn(t *testing.T, s *Server, name, src, anchor string, delta int) []string { + t.Helper() + off := strings.Index(src, anchor) + if off < 0 { + t.Fatalf("anchor %q not in fixture", anchor) + } + list, err := s.Completion(context.Background(), &protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(src), off+delta), + }, + }) + if err != nil { + t.Fatalf("Completion err = %v", err) + } + labels := make([]string, 0, len(list.Items)) + for _, item := range list.Items { + labels = append(labels, item.Label) + } + return labels +} + +func TestCompletionCalcTypingPositionOffersImportedQueries(t *testing.T) { + s, name := openReportModel(t) + labels := completionLabelsIn(t, s, name, reportModel, "Everything {", 0) + if !containsLabel(labels, "Everything") || !containsLabel(labels, "NoInputs") { + t.Fatalf("labels = %v, want the queries imported from QueryLib", labels) + } + if containsLabel(labels, "Widget") { + t.Fatalf("labels = %v, want no imported non-query definitions", labels) + } +} + +func TestCompletionQualifiedCalcTypingPositionFiltersToQueries(t *testing.T) { + s, name := openReportModel(t) + anchor := "QueryLib:: {" + labels := completionLabelsIn(t, s, name, reportModel, anchor, len("QueryLib::")) + if !containsLabel(labels, "Everything") || !containsLabel(labels, "NoInputs") { + t.Fatalf("labels = %v, want QueryLib's query definitions", labels) + } + if containsLabel(labels, "Widget") { + t.Fatalf("labels = %v, want no non-query package members", labels) + } +} + +func TestCompletionBindingPositionOfParameterlessQueryOffersNothing(t *testing.T) { + s, name := openReportModel(t) + anchor := "calc bare : NoInputs {" + labels := completionLabelsIn(t, s, name, reportModel, anchor, len(anchor)) + if len(labels) != 0 { + t.Fatalf("labels = %v, want none: the query declares no parameters", labels) + } +} + +func TestCompletionCalcTypingPositionOffersQueryAlias(t *testing.T) { + s, name := openReportModel(t) + labels := completionLabelsIn(t, s, name, reportModel, "Everything {", 0) + if !containsLabel(labels, "TableQuery") { + t.Fatalf("labels = %v, want the TableQuery alias spelling", labels) + } +} + +func reportHover(t *testing.T, s *Server, name, anchor string, delta int) *protocol.Hover { + t.Helper() + off := strings.Index(reportModel, anchor) + if off < 0 { + t.Fatalf("anchor %q not in fixture", anchor) + } + hov, err := s.Hover(context.Background(), &protocol.HoverParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(reportModel), off+delta), + }, + }) + if err != nil { + t.Fatalf("Hover err = %v", err) + } + return hov +} + +func TestHoverQualifiedReferenceSegments(t *testing.T) { + s, name := openReportModel(t) + anchor := "QueryLib::NoInputs {" + if hov := reportHover(t, s, name, anchor, 0); hov == nil || + !strings.Contains(hov.Contents.Value, "package QueryLib") { + t.Fatalf("qualifier hover = %+v, want package QueryLib", hov) + } + if hov := reportHover(t, s, name, anchor, len("QueryLib::")); hov == nil || + !strings.Contains(hov.Contents.Value, "calc def NoInputs") { + t.Fatalf("member hover = %+v, want calc def NoInputs", hov) + } + if hov := reportHover(t, s, name, anchor, len("QueryLib:")); hov != nil && + strings.Contains(hov.Contents.Value, "NoInputs") { + t.Fatalf("separator hover = %+v, want not the reference target", hov) + } +} + +func TestRenderDocumentAmbiguousName(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + ws.Open("one.sysml", []byte(documentModel), 1) + ws.Open("two.sysml", []byte(documentModel), 1) + if _, err := s.RenderDocument(&renderDocumentParams{Name: "Observatory::MassReport"}); err == nil || + !strings.Contains(err.Error(), "names 2 elements") { + t.Fatalf("err = %v, want an ambiguity error", err) + } +} + +func containsLabel(labels []string, want string) bool { + for _, l := range labels { + if l == want { + return true + } + } + return false +} + +func TestPublishDiagnosticsDocumentPlanLive(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + fc := &fakeClient{} + s.client = fc + name := "report.sysml" + // The document is missing its title, a document-plan diagnostic. + broken := strings.Replace(documentModel, + "attribute redefines title = \"Telescope Mass Report\";\n", "", 1) + ws.Open(name, []byte(broken), 1) + + s.publishDiagnostics(context.Background(), name) + published := fc.all() + if len(published) != 1 { + t.Fatalf("published = %d, want 1", len(published)) + } + var found *protocol.Diagnostic + for i, d := range published[0].Diagnostics { + if d.Code == "document-plan-missing-title" { + found = &published[0].Diagnostics[i] + } + } + if found == nil { + t.Fatalf("diagnostics = %v, want document-plan-missing-title", published[0].Diagnostics) + } + if found.Source != "document-plan" || found.Severity != protocol.DiagnosticSeverityError { + t.Errorf("diagnostic = %+v, want source document-plan at error severity", found) + } + want := offsetToPosition([]byte(broken), strings.Index(broken, "part def MassReport")) + if found.Range.Start.Line != want.Line { + t.Errorf("range starts line %d, want %d (the document definition)", found.Range.Start.Line, want.Line) + } + + // Restoring the title clears the diagnostic on the next publish. + ws.Update(name, []byte(documentModel), 2) + s.publishDiagnostics(context.Background(), name) + published = fc.all() + last := published[len(published)-1] + for _, d := range last.Diagnostics { + if d.Source == "document-plan" || d.Source == "document-query" { + t.Fatalf("edit left stale diagnostic: %+v", d) + } + } +} + +func TestDocumentsListsWorkspaceDocuments(t *testing.T) { + _, s, name := openDocumentModel(t) + res := s.Documents() + if len(res.Documents) != 1 { + t.Fatalf("documents = %+v, want the one MassReport", res.Documents) + } + if res.Documents[0].Name != "Observatory::MassReport" { + t.Errorf("name = %q, want Observatory::MassReport", res.Documents[0].Name) + } + if res.Documents[0].URI != string(uri.File(name)) { + t.Errorf("uri = %q, want %q", res.Documents[0].URI, uri.File(name)) + } +} + +func TestRenderDocumentMarkdown(t *testing.T) { + _, s, _ := openDocumentModel(t) + res, err := s.RenderDocument(&renderDocumentParams{Name: "Observatory::MassReport"}) + if err != nil { + t.Fatalf("RenderDocument err = %v", err) + } + for _, want := range []string{ + "# Telescope Mass Report", + "*All subsystems by mass*", + "| name | mass |", + "| optics | 8.5 |", + } { + if !strings.Contains(res.Markdown, want) { + t.Errorf("markdown missing %q:\n%s", want, res.Markdown) + } + } +} + +// diagramDocumentModel adds a report whose diagram draws a view, so the +// render's diagramForm has a graph-shaped block to act on. +const diagramDocumentModel = `package Imaging { + private import Views::*; + private import DocumentQueries::*; + + port def DataPort; + part def Camera { port output : DataPort; } + part def Recorder { port input : DataPort; } + + part imagingChain { + part camera : Camera; + part recorder : Recorder; + connection link connect camera.output to recorder.input; + } + + view chainView { + expose imagingChain; + render asInterconnectionDiagram; + } + + part def ChainReport :> Document { + attribute redefines title = "Imaging Chain"; + + part chain : Diagram { + attribute redefines caption = "The imaging chain"; + ref redefines source = chainView; + } + } +} +` + +// TestRenderDocumentDiagramForm writes the document's graph-shaped diagrams +// as Mermaid when diagramForm is absent and as DOT or PlantUML when named; the +// Mermaid block opens on the frontmatter its two-line cluster title needs. +func TestRenderDocumentDiagramForm(t *testing.T) { + ws, s, _ := openDocumentModel(t) + ws.Open(uri.File("/tmp/imaging.sysml").Filename(), []byte(diagramDocumentModel), 1) + mermaidHeader := "---\nconfig:\n flowchart:\n subGraphTitleMargin:\n bottom: 24\n---\n%% Imaging::chainView — interconnection rendering" + cases := map[string]struct{ fence, header string }{ + "": {"```mermaid\n", mermaidHeader}, + "mermaid": {"```mermaid\n", mermaidHeader}, + "dot": {"```dot\n", "// view: Imaging::chainView\n// kind: interconnection\n"}, + "plantuml": {"```plantuml\n", "@startuml\n' Imaging::chainView — interconnection rendering"}, + } + for form, want := range cases { + res, err := s.RenderDocument(&renderDocumentParams{Name: "Imaging::ChainReport", DiagramForm: form}) + if err != nil { + t.Fatalf("diagramForm %q: %v", form, err) + } + if !strings.Contains(res.Markdown, want.fence+want.header) { + t.Errorf("diagramForm %q: markdown missing %q:\n%s", form, want.fence+want.header, res.Markdown) + } + if strings.Count(res.Markdown, "```") != 2 { + t.Errorf("diagramForm %q: want exactly one fenced block:\n%s", form, res.Markdown) + } + if form == "plantuml" && (!strings.Contains(res.Markdown, "n1 -[thickness=3]- n2 : link\n") || !strings.Contains(res.Markdown, "@enduml\n```")) { + t.Errorf("diagramForm %q: not a PlantUML interconnection:\n%s", form, res.Markdown) + } + } + if _, err := s.RenderDocument(&renderDocumentParams{Name: "Imaging::ChainReport", DiagramForm: "svg"}); err == nil || + !strings.Contains(err.Error(), `no diagram form is named "svg"`) || !strings.Contains(err.Error(), "mermaid, dot, plantuml") { + t.Fatalf("err = %v, want an unknown-form error naming the forms", err) + } + for _, form := range []string{"dot", "plantuml"} { + res, err := s.RenderDocument(&renderDocumentParams{Name: "Observatory::MassReport", DiagramForm: form}) + if err != nil { + t.Fatalf("table-only document as %s: %v", form, err) + } + if !strings.Contains(res.Markdown, "| name | mass |") || strings.Contains(res.Markdown, "```") { + t.Errorf("a table is not a table under %s:\n%s", form, res.Markdown) + } + } +} + +func TestRenderDocumentTypedErrors(t *testing.T) { + _, s, _ := openDocumentModel(t) + if _, err := s.RenderDocument(&renderDocumentParams{Name: "Observatory::Subsystem"}); err == nil || + !strings.Contains(err.Error(), "is not a document") { + t.Fatalf("err = %v, want a not-a-document error", err) + } + if _, err := s.RenderDocument(&renderDocumentParams{Name: "Observatory::Nothing"}); err == nil || + !strings.Contains(err.Error(), "no element named") { + t.Fatalf("err = %v, want a no-element error", err) + } +} diff --git a/internal/lsp/f9_contextual_completion_test.go b/internal/frontend/lsp/f9_contextual_completion_test.go similarity index 95% rename from internal/lsp/f9_contextual_completion_test.go rename to internal/frontend/lsp/f9_contextual_completion_test.go index 4445f55c48..85e3fba0f0 100644 --- a/internal/lsp/f9_contextual_completion_test.go +++ b/internal/frontend/lsp/f9_contextual_completion_test.go @@ -7,9 +7,9 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/lexer" - "github.com/Open-MBEE/OpenSysML/internal/core/model" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/syntax/lexer" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // Contextual words are syntax the lexer does not reserve, so completion has to diff --git a/internal/frontend/lsp/files.go b/internal/frontend/lsp/files.go new file mode 100644 index 0000000000..dbd93ccdb2 --- /dev/null +++ b/internal/frontend/lsp/files.go @@ -0,0 +1,379 @@ +package lsp + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +//lint:file-ignore SA1019 rootUri/rootPath are deprecated, but see legacyRoot. + +// maxScannedFileSize caps what the folder scan reads. +const maxScannedFileSize = 8 << 20 + +// setFolders records the folders the session was initialized with. +func (s *Server) setFolders(folders []string) { + s.mu.Lock() + defer s.mu.Unlock() + s.folders = folders +} + +// setHoverMarkdown records whether the client renders Markdown hovers. +func (s *Server) setHoverMarkdown(ok bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.hoverMarkdown = ok +} + +// wantsMarkdownHover reports whether hovers should be rendered as Markdown. +func (s *Server) wantsMarkdownHover() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.hoverMarkdown +} + +// setCompletionMarkdown records whether the client renders Markdown completion +// documentation. +func (s *Server) setCompletionMarkdown(ok bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.completionMarkdown = ok +} + +// wantsMarkdownCompletion reports whether completion documentation should be +// rendered as Markdown. +func (s *Server) wantsMarkdownCompletion() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.completionMarkdown +} + +// setCrossDocument records whether the client speaks the cross-document +// diagram contract. +func (s *Server) setCrossDocument(ok bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.crossDocument = ok +} + +// clientSpeaksCrossDocument reports whether renderings may name another +// document's declarations, which the client then pins its layouts to. +func (s *Server) clientSpeaksCrossDocument() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.crossDocument +} + +// initializeFolders returns a session's folders, preferring workspaceFolders and +// falling back to the deprecated rootUri/rootPath older clients send instead. +func initializeFolders(params *protocol.InitializeParams) []string { + var out []string + seen := map[string]bool{} + add := func(path string) { + if path == "" || seen[path] { + return + } + seen[path] = true + out = append(out, path) + } + for _, folder := range params.WorkspaceFolders { + add(uriToName(protocol.DocumentURI(folder.URI))) + } + if len(out) == 0 { + add(legacyRoot(params)) + } + return out +} + +// legacyRoot returns the root a client that predates workspaceFolders sends; +// these deprecated fields are the only ones such a client populates. +func legacyRoot(params *protocol.InitializeParams) string { + if params.RootURI != "" { + return uriToName(params.RootURI) + } + return params.RootPath +} + +// loadFolders indexes every model source under the session's folders, so a name +// declared in a file the editor never opened still resolves. +func (s *Server) loadFolders(ctx context.Context) { + s.mu.Lock() + // Copied: a folder change can rewrite the slice while this walk runs. + folders := append([]string(nil), s.folders...) + s.mu.Unlock() + + s.debugEdit(ctx, "", func() { + for _, folder := range folders { + s.loadFolder(folder) + } + }) + s.refreshOpenDiagnostics(ctx, "") +} + +// loadFolder reads the model sources one folder holds, skipping hidden and +// vendored directories and passing over entries it cannot read. +func (s *Server) loadFolder(folder string) { s.scanFolder(folder, -1) } + +// scanFolder is loadFolder over at most budget directories, every directory +// when budget is negative; a walk that runs out of budget stops where it is. +func (s *Server) scanFolder(folder string, budget int) { + if folder == "" { + return + } + _ = filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if d != nil && d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.IsDir() { + if path != folder && skipDir(d.Name()) { + return fs.SkipDir + } + if budget == 0 { + return fs.SkipAll + } + budget-- + return nil + } + if !model.IsModelSource(path) { + return nil + } + if info, statErr := d.Info(); statErr != nil || info.Size() > maxScannedFileSize { + return nil + } + s.loadFromDisk(path) + return nil + }) +} + +// skipDir reports whether a directory holds no model sources worth indexing. +func skipDir(name string) bool { + return strings.HasPrefix(name, ".") || name == "node_modules" +} + +// loadFromDisk records a file's bytes as its on-disk content, reindexing it +// unless an open buffer is authoritative. A file that is gone, or too large to +// serve, is forgotten; any other read failure leaves what was indexed in place, +// since it may be transient. +func (s *Server) loadFromDisk(path string) { + if info, err := os.Stat(path); err == nil && info.Size() > maxScannedFileSize { + s.ws.DeleteOnDisk(path) + return + } + // #nosec G304 -- path comes from the folder the client asked to serve. + content, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + s.ws.DeleteOnDisk(path) + } + return + } + s.ws.SetOnDisk(path, content) +} + +// DidChangeWatchedFiles reindexes model files changed outside the editor. +// A deletion leaves an open buffer alone; it is still authoritative. +func (s *Server) DidChangeWatchedFiles(ctx context.Context, params *protocol.DidChangeWatchedFilesParams) error { + var sources []*protocol.FileEvent + for _, event := range params.Changes { + if model.IsModelSource(uriToName(event.URI)) { + sources = append(sources, event) + } + } + if len(sources) == 0 { + return nil + } + s.debugEdit(ctx, "", func() { + for _, event := range sources { + if event.Type == protocol.FileChangeTypeDeleted { + s.ws.DeleteOnDisk(uriToName(event.URI)) + } else { + s.loadFromDisk(uriToName(event.URI)) + } + } + }) + for _, event := range sources { + if event.Type == protocol.FileChangeTypeDeleted { + s.publishDiagnostics(ctx, uriToName(event.URI)) + } + } + s.refreshOpenDiagnostics(ctx, "") + return nil +} + +// DidChangeWorkspaceFolders indexes the model sources a folder added to the +// session holds, and drops those a removed folder contributed. The file watcher +// reports changes only, so a newly added folder has to be walked here. +func (s *Server) DidChangeWorkspaceFolders(ctx context.Context, params *protocol.DidChangeWorkspaceFoldersParams) error { + s.debugEdit(ctx, "", func() { + for _, folder := range params.Event.Removed { + s.dropFolder(uriToName(protocol.DocumentURI(folder.URI))) + } + for _, folder := range params.Event.Added { + s.addFolder(uriToName(protocol.DocumentURI(folder.URI))) + } + // A document whose folder was just removed is now a lone file. + for _, name := range s.ws.OpenNames() { + s.indexOpenedDirectory(name) + } + }) + s.refreshOpenDiagnostics(ctx, "") + return nil +} + +// maxOpenedDirs bounds the directories the scan for an opened document's +// siblings visits: the document's directory was not chosen as a workspace, and +// may be a home directory. +const maxOpenedDirs = 2000 + +// indexOpenedDirectory indexes the directory of a document opened outside every +// folder, so sibling imports resolve; it is rescanned once all its documents +// close. The filesystem root is never indexed: walking it can take minutes. +func (s *Server) indexOpenedDirectory(name string) { + if !filepath.IsAbs(name) { + return + } + dir := filepath.Dir(name) + if dir == filepath.VolumeName(name)+string(filepath.Separator) { + return + } + s.mu.Lock() + if underAnyFolder(name, s.folders) || s.openDirs[dir] { + s.mu.Unlock() + return + } + if s.openDirs == nil { + s.openDirs = map[string]bool{} + } + s.openDirs[dir] = true + s.mu.Unlock() + s.scanFolder(dir, maxOpenedDirs) +} + +// releaseOpenedDirectories forgets every indexed directory no open document lies +// under any more, with the siblings only it contributed, so the next document +// opened there is scanned afresh and a deleted sibling does not linger. The +// document just closed stays indexed, as one closed under a folder does. +func (s *Server) releaseOpenedDirectories(closed string) { + open := s.ws.OpenNames() + s.mu.Lock() + var released, kept []string + for dir := range s.openDirs { + if underAnyOpen(dir, open) { + kept = append(kept, dir) + } else { + released = append(released, dir) + delete(s.openDirs, dir) + } + } + folders := append(kept, s.folders...) + s.mu.Unlock() + if len(released) == 0 { + return + } + for _, name := range s.ws.DocumentNames() { + if name != closed && underAnyFolder(name, released) && !underAnyFolder(name, folders) && !s.ws.IsOpen(name) { + s.ws.DeleteOnDisk(name) + } + } +} + +// underAnyOpen reports whether one of the open documents lies inside dir. +func underAnyOpen(dir string, open []string) bool { + for _, name := range open { + if underFolder(name, dir) { + return true + } + } + return false +} + +// addFolder records a folder and indexes the model sources under it. +func (s *Server) addFolder(folder string) { + if folder == "" { + return + } + s.mu.Lock() + for _, known := range s.folders { + if known == folder { + s.mu.Unlock() + return + } + } + s.folders = append(s.folders, folder) + s.mu.Unlock() + s.loadFolder(folder) +} + +// dropFolder forgets a folder and the documents only it contributed; folders can +// nest, and an open buffer stays regardless, since the editor still shows it. +func (s *Server) dropFolder(folder string) { + if folder == "" { + return + } + s.mu.Lock() + kept := make([]string, 0, len(s.folders)) + for _, known := range s.folders { + if known != folder { + kept = append(kept, known) + } + } + s.folders = kept + s.mu.Unlock() + + for _, name := range s.ws.DocumentNames() { + if underFolder(name, folder) && !underAnyFolder(name, kept) { + s.ws.DeleteOnDisk(name) + } + } +} + +// underAnyFolder reports whether name lies inside one of the folders. +func underAnyFolder(name string, folders []string) bool { + for _, folder := range folders { + if underFolder(name, folder) { + return true + } + } + return false +} + +// underFolder reports whether name lies inside folder. +func underFolder(name, folder string) bool { + rel, err := filepath.Rel(folder, name) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// refreshOpenDiagnostics republishes diagnostics for the open documents, whose +// analysis depends on the rest of the workspace, skipping the name a caller has +// just published on its own. +func (s *Server) refreshOpenDiagnostics(ctx context.Context, except string) { + for _, name := range s.ws.OpenNames() { + if name != except { + // A tab closed mid-sweep has had its markers withdrawn already. + s.publishOpenDiagnostics(ctx, name) + } + } +} + +// queueOpenDiagnostics is refreshOpenDiagnostics once an editor burst settles; +// a refresh re-analyzes every open document the edit reached and republishes +// the others from the workspace's cache, still too much to pay per keystroke. +func (s *Server) queueOpenDiagnostics(ctx context.Context, except string) { + if s.crossDoc == nil { + s.refreshOpenDiagnostics(ctx, except) + return + } + // The sweep outlives the notification, whose context is cancelled on return. + ctx = context.WithoutCancel(ctx) + s.crossDoc.Trigger(except, func() { s.refreshOpenDiagnostics(ctx, except) }) +} diff --git a/internal/frontend/lsp/files_test.go b/internal/frontend/lsp/files_test.go new file mode 100644 index 0000000000..86b500ffd1 --- /dev/null +++ b/internal/frontend/lsp/files_test.go @@ -0,0 +1,911 @@ +package lsp + +import ( + "context" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +const ( + libSource = "package Lib {\n part def Widget;\n}\n" + mainSource = "package Main {\n private import Lib::*;\n part w : Widget;\n}\n" +) + +// multiFileWorkspace writes lib.sysml and main.sysml into a temporary folder and +// returns a server initialized on it, with the folder scan already done. +func multiFileWorkspace(t *testing.T) (*Server, *fakeClient, string, string, string) { + t.Helper() + dir := t.TempDir() + lib := filepath.Join(dir, "lib.sysml") + main := filepath.Join(dir, "main.sysml") + if err := os.WriteFile(lib, []byte(libSource), 0o600); err != nil { + t.Fatalf("write lib: %v", err) + } + if err := os.WriteFile(main, []byte(mainSource), 0o600); err != nil { + t.Fatalf("write main: %v", err) + } + + s := NewServer(model.NewWorkspace()) + fc := &fakeClient{} + s.client = fc + ctx := context.Background() + if _, err := s.Initialize(ctx, &protocol.InitializeParams{ + WorkspaceFolders: []protocol.WorkspaceFolder{{URI: string(uri.File(dir)), Name: "multi"}}, + }); err != nil { + t.Fatalf("Initialize err = %v", err) + } + if err := s.Initialized(ctx, &protocol.InitializedParams{}); err != nil { + t.Fatalf("Initialized err = %v", err) + } + return s, fc, dir, lib, main +} + +// diagnosticsFor returns the messages of the last diagnostics published for name. +func diagnosticsFor(fc *fakeClient, name string) []string { + msgs, _ := lastDiagnostics(fc, name) + return msgs +} + +// lastDiagnostics returns the messages of the last diagnostics published for +// name, and whether any were published at all. +func lastDiagnostics(fc *fakeClient, name string) ([]string, bool) { + var msgs []string + found := false + for _, p := range fc.all() { + if p.URI != uri.File(name) { + continue + } + found = true + msgs = msgs[:0] + for _, d := range p.Diagnostics { + msgs = append(msgs, d.Message) + } + } + return msgs, found +} + +// waitForDiagnostics polls until name's last published diagnostics satisfy want, +// which the debounced cross-document refresh reaches asynchronously. +func waitForDiagnostics(t *testing.T, fc *fakeClient, name string, want func([]string) bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + msgs := diagnosticsFor(fc, name) + if want(msgs) { + return + } + if time.Now().After(deadline) { + t.Fatalf("diagnostics for %s = %v, still unwanted after 5s", name, msgs) + } + time.Sleep(10 * time.Millisecond) + } +} + +func openFile(t *testing.T, s *Server, path, text string) { + t.Helper() + if err := s.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: uri.File(path), + LanguageID: "sysml", + Version: 1, + Text: text, + }, + }); err != nil { + t.Fatalf("DidOpen err = %v", err) + } +} + +func TestFolderScanResolvesNamesFromUnopenedFiles(t *testing.T) { + s, fc, _, lib, main := multiFileWorkspace(t) + + // Only main.sysml is opened; Lib::Widget lives in a file the editor never + // showed, and must still resolve. + openFile(t, s, main, mainSource) + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Fatalf("diagnostics for main = %v, want none", msgs) + } + if s.ws.Document(lib) == nil { + t.Errorf("lib.sysml not indexed by the folder scan") + } + if s.ws.IsOpen(lib) { + t.Errorf("lib.sysml reported open; the scan records on-disk content only") + } +} + +func TestFolderScanSkipsHiddenDirectories(t *testing.T) { + s, _, dir, _, _ := multiFileWorkspace(t) + + hidden := filepath.Join(dir, ".git", "hidden.sysml") + if err := os.MkdirAll(filepath.Dir(hidden), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(hidden, []byte("package Hidden;\n"), 0o600); err != nil { + t.Fatalf("write hidden: %v", err) + } + s.loadFolder(dir) + if s.ws.Document(hidden) != nil { + t.Errorf("indexed %q, want hidden directories skipped", hidden) + } +} + +func TestWatchedFileCreateAndChangeReindex(t *testing.T) { + s, fc, dir, _, main := multiFileWorkspace(t) + ctx := context.Background() + openFile(t, s, main, mainSource) + + // A second definition file appears outside the editor; main.sysml can use it + // as soon as the client reports the creation. + extra := filepath.Join(dir, "extra.sysml") + if err := os.WriteFile(extra, []byte("package Extra {\n part def Gizmo;\n}\n"), 0o600); err != nil { + t.Fatalf("write extra: %v", err) + } + if err := s.DidChangeWatchedFiles(ctx, &protocol.DidChangeWatchedFilesParams{ + Changes: []*protocol.FileEvent{{URI: uri.File(extra), Type: protocol.FileChangeTypeCreated}}, + }); err != nil { + t.Fatalf("DidChangeWatchedFiles err = %v", err) + } + if s.ws.Document(extra) == nil { + t.Fatalf("extra.sysml not indexed after a create event") + } + + usesGizmo := "package Main {\n private import Extra::*;\n part g : Gizmo;\n}\n" + if err := s.DidChange(ctx, &protocol.DidChangeTextDocumentParams{ + TextDocument: protocol.VersionedTextDocumentIdentifier{ + TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: uri.File(main)}, + Version: 2, + }, + // A whole-document range: the typed DidChange path splices at [0,0) for a + // zero Range, which would leave the old text in place (see sync.go). + ContentChanges: []protocol.TextDocumentContentChangeEvent{{ + Range: protocol.Range{End: protocol.Position{Line: 4}}, + Text: usesGizmo, + }}, + }); err != nil { + t.Fatalf("DidChange err = %v", err) + } + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Fatalf("diagnostics for main = %v, want none", msgs) + } + + // Rewriting the file on disk renames what it declares; the open document's + // diagnostics follow. + if err := os.WriteFile(extra, []byte("package Extra {\n part def Doohickey;\n}\n"), 0o600); err != nil { + t.Fatalf("rewrite extra: %v", err) + } + if err := s.DidChangeWatchedFiles(ctx, &protocol.DidChangeWatchedFilesParams{ + Changes: []*protocol.FileEvent{{URI: uri.File(extra), Type: protocol.FileChangeTypeChanged}}, + }); err != nil { + t.Fatalf("DidChangeWatchedFiles err = %v", err) + } + if msgs := diagnosticsFor(fc, main); len(msgs) == 0 { + t.Fatalf("diagnostics for main = none, want an unresolved Gizmo") + } +} + +func TestWatchedFileDeleteUnindexesClosedDocument(t *testing.T) { + s, fc, _, lib, main := multiFileWorkspace(t) + ctx := context.Background() + openFile(t, s, main, mainSource) + + if err := os.Remove(lib); err != nil { + t.Fatalf("remove lib: %v", err) + } + if err := s.DidChangeWatchedFiles(ctx, &protocol.DidChangeWatchedFilesParams{ + Changes: []*protocol.FileEvent{{URI: uri.File(lib), Type: protocol.FileChangeTypeDeleted}}, + }); err != nil { + t.Fatalf("DidChangeWatchedFiles err = %v", err) + } + if s.ws.Document(lib) != nil { + t.Errorf("lib.sysml still indexed after deletion") + } + if msgs := diagnosticsFor(fc, main); len(msgs) == 0 { + t.Fatalf("diagnostics for main = none, want unresolved references") + } +} + +func TestWatchedFileDeleteKeepsOpenBuffer(t *testing.T) { + s, fc, _, lib, main := multiFileWorkspace(t) + ctx := context.Background() + openFile(t, s, main, mainSource) + openFile(t, s, lib, libSource) + + // The file is gone from disk, but its editor buffer is still authoritative. + if err := os.Remove(lib); err != nil { + t.Fatalf("remove lib: %v", err) + } + if err := s.DidChangeWatchedFiles(ctx, &protocol.DidChangeWatchedFilesParams{ + Changes: []*protocol.FileEvent{{URI: uri.File(lib), Type: protocol.FileChangeTypeDeleted}}, + }); err != nil { + t.Fatalf("DidChangeWatchedFiles err = %v", err) + } + if s.ws.Document(lib) == nil { + t.Fatalf("open buffer for lib.sysml dropped on disk deletion") + } + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Fatalf("diagnostics for main = %v, want none", msgs) + } +} + +func TestDidCloseKeepsDocumentIndexedFromDisk(t *testing.T) { + s, fc, _, lib, main := multiFileWorkspace(t) + ctx := context.Background() + openFile(t, s, main, mainSource) + openFile(t, s, lib, libSource) + + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(lib)}, + }); err != nil { + t.Fatalf("DidClose err = %v", err) + } + if s.ws.Document(lib) == nil { + t.Fatalf("lib.sysml unindexed by closing its tab") + } + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Fatalf("diagnostics for main = %v, want none", msgs) + } +} + +func TestDidCloseDropsDocumentWithNoFileOnDisk(t *testing.T) { + s := NewServer(model.NewWorkspace()) + s.client = &fakeClient{} + name := uri.File(filepath.Join(t.TempDir(), "untitled.sysml")).Filename() + openFile(t, s, name, "package P;\n") + + if err := s.DidClose(context.Background(), &protocol.DidCloseTextDocumentParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + }); err != nil { + t.Fatalf("DidClose err = %v", err) + } + if s.ws.Document(name) != nil { + t.Errorf("document with no file on disk kept after close") + } +} + +func TestDidCloseWithdrawsDiagnostics(t *testing.T) { + s, fc, _, lib, _ := multiFileWorkspace(t) + ctx := context.Background() + + // The buffer holds an error the file on disk does not. Closing discards the + // buffer, and a closed document's markers are never refreshed again, so they + // are withdrawn rather than frozen at what closing happened to see. + openFile(t, s, lib, "package Lib {\n part def Widget;\n part b : Nope;\n}\n") + if msgs := diagnosticsFor(fc, lib); len(msgs) == 0 { + t.Fatalf("diagnostics for the lib buffer = none, want an unresolved Nope") + } + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(lib)}, + }); err != nil { + t.Fatalf("DidClose err = %v", err) + } + msgs, published := lastDiagnostics(fc, lib) + if !published || len(msgs) != 0 { + t.Fatalf("diagnostics for the closed lib = %v (published=%v), want an empty set", msgs, published) + } +} + +// serialClient records whether two diagnostics sends ever overlapped. +type serialClient struct { + baseClient + mu sync.Mutex + active int + overlaps atomic.Bool +} + +func (c *serialClient) PublishDiagnostics(ctx context.Context, params *protocol.PublishDiagnosticsParams) error { + c.mu.Lock() + c.active++ + if c.active > 1 { + c.overlaps.Store(true) + } + c.mu.Unlock() + time.Sleep(time.Millisecond) + c.mu.Lock() + c.active-- + c.mu.Unlock() + return nil +} + +func TestPublishDiagnosticsSerialized(t *testing.T) { + s, _, _, lib, main := multiFileWorkspace(t) + ctx := context.Background() + openFile(t, s, main, mainSource) + openFile(t, s, lib, libSource) + + // The coalesced sweep publishes from a timer goroutine while handlers publish + // from theirs; an overlap would let the older set land last on the client. + sc := &serialClient{} + s.client = sc + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + name := main + if i%2 == 0 { + name = lib + } + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 10; j++ { + s.publishDiagnostics(ctx, name) + s.clearDiagnostics(ctx, name) + } + }() + } + wg.Wait() + if sc.overlaps.Load() { + t.Errorf("diagnostics sends overlapped, want them serialized") + } +} + +// closeOnPublishClient closes a document while a sweep is publishing, the window +// in which a closed tab's withdrawn markers could be republished. +type closeOnPublishClient struct { + baseClient + ws *model.Workspace + trigger string + close string + + mu sync.Mutex + closed bool + afterClose []string +} + +func (c *closeOnPublishClient) PublishDiagnostics(ctx context.Context, params *protocol.PublishDiagnosticsParams) error { + name := uriToName(params.URI) + c.mu.Lock() + if c.closed { + c.afterClose = append(c.afterClose, name) + } + trip := !c.closed && name == c.trigger + c.mu.Unlock() + if trip { + c.ws.Close(c.close) + c.mu.Lock() + c.closed = true + c.mu.Unlock() + } + return nil +} + +func TestRefreshOpenDiagnosticsSkipsDocumentClosedMidSweep(t *testing.T) { + // Open-document order is map order, so repeat until the sweep hits main first. + for i := 0; i < 10; i++ { + s, _, _, lib, main := multiFileWorkspace(t) + openFile(t, s, main, mainSource) + openFile(t, s, lib, libSource) + + cc := &closeOnPublishClient{ws: s.ws, trigger: main, close: lib} + s.client = cc + s.refreshOpenDiagnostics(context.Background(), "") + + cc.mu.Lock() + after := append([]string(nil), cc.afterClose...) + cc.mu.Unlock() + for _, name := range after { + if name == lib { + t.Fatalf("lib.sysml diagnostics republished after its tab closed") + } + } + } +} + +func TestDidChangeWorkspaceFoldersIndexesAddedFolder(t *testing.T) { + s, fc, _, _, main := multiFileWorkspace(t) + ctx := context.Background() + + // main.sysml uses a name declared in a folder added after initialize; the + // client's watcher reports changes only, so the folder has to be walked. + openFile(t, s, main, "package Main {\n private import Extra::*;\n part g : Gadget;\n}\n") + if msgs := diagnosticsFor(fc, main); len(msgs) == 0 { + t.Fatalf("diagnostics for main = none, want Gadget unresolved before the folder is added") + } + // Outside the initial folder, which stays registered and would otherwise + // still cover the file after the added folder goes away. + added := t.TempDir() + extra := filepath.Join(added, "extra.sysml") + if err := os.WriteFile(extra, []byte("package Extra {\n part def Gadget;\n}\n"), 0o600); err != nil { + t.Fatalf("write extra: %v", err) + } + if err := s.DidChangeWorkspaceFolders(ctx, &protocol.DidChangeWorkspaceFoldersParams{ + Event: protocol.WorkspaceFoldersChangeEvent{ + Added: []protocol.WorkspaceFolder{{URI: string(uri.File(added)), Name: "extra"}}, + }, + }); err != nil { + t.Fatalf("DidChangeWorkspaceFolders err = %v", err) + } + if s.ws.Document(extra) == nil { + t.Errorf("extra.sysml not indexed after its folder was added") + } + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Errorf("diagnostics for main = %v, want none once the folder is indexed", msgs) + } + + // Removing the folder again unindexes what it contributed. + if err := s.DidChangeWorkspaceFolders(ctx, &protocol.DidChangeWorkspaceFoldersParams{ + Event: protocol.WorkspaceFoldersChangeEvent{ + Removed: []protocol.WorkspaceFolder{{URI: string(uri.File(added)), Name: "extra"}}, + }, + }); err != nil { + t.Fatalf("DidChangeWorkspaceFolders err = %v", err) + } + if s.ws.Document(extra) != nil { + t.Errorf("extra.sysml still indexed after its folder was removed") + } + if msgs := diagnosticsFor(fc, main); len(msgs) == 0 { + t.Errorf("diagnostics for main = none, want Gadget unresolved again") + } +} + +// A document left open when its folder is removed is a lone file from then on, +// so its directory is indexed for it as though it had been opened that way. +func TestRemovingFolderAroundOpenFileKeepsSiblingsIndexed(t *testing.T) { + s, fc, dir, lib, main := multiFileWorkspace(t) + ctx := context.Background() + + openFile(t, s, main, mainSource) + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Fatalf("diagnostics for main = %v, want none", msgs) + } + if err := s.DidChangeWorkspaceFolders(ctx, &protocol.DidChangeWorkspaceFoldersParams{ + Event: protocol.WorkspaceFoldersChangeEvent{ + Removed: []protocol.WorkspaceFolder{{URI: string(uri.File(dir)), Name: "multi"}}, + }, + }); err != nil { + t.Fatalf("DidChangeWorkspaceFolders err = %v", err) + } + if s.ws.Document(lib) == nil { + t.Errorf("lib.sysml unindexed while main.sysml, which imports it, is still open") + } + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Errorf("diagnostics for main = %v, want none after its folder was removed", msgs) + } + + // Closing the document releases the directory as for any lone file. + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(main)}, + }); err != nil { + t.Fatalf("DidClose err = %v", err) + } + if s.ws.Document(lib) != nil { + t.Errorf("lib.sysml still indexed after the last document under its directory closed") + } +} + +func TestDidChangeWorkspaceFoldersKeepsOpenBuffer(t *testing.T) { + s, _, dir, lib, _ := multiFileWorkspace(t) + + // A removed folder leaves a buffer the editor still shows alone. + openFile(t, s, lib, libSource) + if err := s.DidChangeWorkspaceFolders(context.Background(), &protocol.DidChangeWorkspaceFoldersParams{ + Event: protocol.WorkspaceFoldersChangeEvent{ + Removed: []protocol.WorkspaceFolder{{URI: string(uri.File(dir)), Name: "multi"}}, + }, + }); err != nil { + t.Fatalf("DidChangeWorkspaceFolders err = %v", err) + } + if s.ws.Document(lib) == nil { + t.Errorf("open lib.sysml dropped with its folder") + } +} + +func TestDidChangeWorkspaceFoldersKeepsNestedFolder(t *testing.T) { + s, _, dir, lib, _ := multiFileWorkspace(t) + ctx := context.Background() + + // LSP allows nested folders: removing the outer one must leave the files the + // inner one still contributes indexed. + nested := filepath.Join(dir, "nested") + inner := filepath.Join(nested, "inner.sysml") + if err := os.MkdirAll(nested, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(inner, []byte("package Inner {\n part def Gizmo;\n}\n"), 0o600); err != nil { + t.Fatalf("write inner: %v", err) + } + if err := s.DidChangeWorkspaceFolders(ctx, &protocol.DidChangeWorkspaceFoldersParams{ + Event: protocol.WorkspaceFoldersChangeEvent{ + Added: []protocol.WorkspaceFolder{{URI: string(uri.File(nested)), Name: "nested"}}, + }, + }); err != nil { + t.Fatalf("DidChangeWorkspaceFolders err = %v", err) + } + if err := s.DidChangeWorkspaceFolders(ctx, &protocol.DidChangeWorkspaceFoldersParams{ + Event: protocol.WorkspaceFoldersChangeEvent{ + Removed: []protocol.WorkspaceFolder{{URI: string(uri.File(dir)), Name: "multi"}}, + }, + }); err != nil { + t.Fatalf("DidChangeWorkspaceFolders err = %v", err) + } + if s.ws.Document(inner) == nil { + t.Errorf("inner.sysml unindexed with the outer folder, though its own folder remains") + } + if s.ws.Document(lib) != nil { + t.Errorf("lib.sysml still indexed after the only folder holding it was removed") + } +} + +func TestLoadFromDiskSkipsOversizedFile(t *testing.T) { + s, _, dir, _, _ := multiFileWorkspace(t) + + // The scan's size cap has to hold on the watched-file path too, or the first + // change event pulls a file the startup walk refused to read. + huge := filepath.Join(dir, "huge.sysml") + body := append([]byte(libSource), make([]byte, maxScannedFileSize)...) + if err := os.WriteFile(huge, body, 0o600); err != nil { + t.Fatalf("write huge: %v", err) + } + if err := s.DidChangeWatchedFiles(context.Background(), &protocol.DidChangeWatchedFilesParams{ + Changes: []*protocol.FileEvent{{URI: uri.File(huge), Type: protocol.FileChangeTypeCreated}}, + }); err != nil { + t.Fatalf("DidChangeWatchedFiles err = %v", err) + } + if s.ws.Document(huge) != nil { + t.Errorf("huge.sysml indexed though it exceeds the scan's size cap") + } +} + +func TestLoadFromDiskKeepsIndexOnUnreadableFile(t *testing.T) { + s, _, _, lib, _ := multiFileWorkspace(t) + + // A read that fails for any reason other than the file being gone — a lock + // or a momentary permission problem — must not unindex what it declares. + if err := os.Chmod(lib, 0o000); err != nil { + t.Fatalf("chmod lib: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(lib, 0o600) }) + if _, err := os.ReadFile(lib); err == nil { + t.Skip("file still readable; test needs an unreadable file") + } + s.loadFromDisk(lib) + if s.ws.Document(lib) == nil { + t.Errorf("lib.sysml unindexed by a transient read failure") + } +} + +func TestWatchedFileDeleteClearsDiagnostics(t *testing.T) { + s, fc, _, lib, _ := multiFileWorkspace(t) + ctx := context.Background() + + // Diagnostics reach the client for lib.sysml, and then the file disappears. + openFile(t, s, lib, "package Lib {\n part b : Nope;\n}\n") + if msgs := diagnosticsFor(fc, lib); len(msgs) == 0 { + t.Fatalf("diagnostics for lib = none, want an unresolved Nope") + } + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(lib)}, + }); err != nil { + t.Fatalf("DidClose err = %v", err) + } + if err := os.Remove(lib); err != nil { + t.Fatalf("remove lib: %v", err) + } + if err := s.DidChangeWatchedFiles(ctx, &protocol.DidChangeWatchedFilesParams{ + Changes: []*protocol.FileEvent{{URI: uri.File(lib), Type: protocol.FileChangeTypeDeleted}}, + }); err != nil { + t.Fatalf("DidChangeWatchedFiles err = %v", err) + } + msgs, published := lastDiagnostics(fc, lib) + if !published || len(msgs) != 0 { + t.Fatalf("diagnostics for the deleted lib = %v (published=%v), want an empty set", msgs, published) + } +} + +func TestDidChangeRefreshesOtherOpenDocuments(t *testing.T) { + s, fc, _, lib, main := multiFileWorkspace(t) + ctx := context.Background() + openFile(t, s, main, mainSource) + openFile(t, s, lib, libSource) + + // An unsaved edit to lib.sysml renames what main.sysml uses; main's markers + // must follow the edit burst, not wait for a save. + if err := s.DidChange(ctx, &protocol.DidChangeTextDocumentParams{ + TextDocument: protocol.VersionedTextDocumentIdentifier{ + TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: uri.File(lib)}, + Version: 2, + }, + ContentChanges: []protocol.TextDocumentContentChangeEvent{ + {Text: "package Lib {\n part def Gadget;\n}\n"}, + }, + }); err != nil { + t.Fatalf("DidChange err = %v", err) + } + waitForDiagnostics(t, fc, main, func(msgs []string) bool { return len(msgs) > 0 }) +} + +func TestDidChangeCoalescesOtherOpenDocuments(t *testing.T) { + s, fc, _, lib, main := multiFileWorkspace(t) + ctx := context.Background() + openFile(t, s, main, mainSource) + openFile(t, s, lib, libSource) + + // Typing is a burst of changes; re-analyzing main.sysml on each one would + // serialize behind the workspace lock, so the sweep runs once at the end. + for version := 2; version < 12; version++ { + if err := s.DidChange(ctx, &protocol.DidChangeTextDocumentParams{ + TextDocument: protocol.VersionedTextDocumentIdentifier{ + TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: uri.File(lib)}, + Version: int32(version), + }, + ContentChanges: []protocol.TextDocumentContentChangeEvent{ + {Text: "package Lib {\n part def Gadget;\n}\n"}, + }, + }); err != nil { + t.Fatalf("DidChange err = %v", err) + } + } + waitForDiagnostics(t, fc, main, func(msgs []string) bool { return len(msgs) > 0 }) + if got := publishCount(fc, main); got > 5 { + t.Errorf("published %d times for main across 10 edits, want the burst coalesced", got) + } +} + +// publishCount reports how many times diagnostics were published for name. +func publishCount(fc *fakeClient, name string) int { + n := 0 + for _, p := range fc.all() { + if p.URI == uri.File(name) { + n++ + } + } + return n +} + +func TestInitializeFoldersFallsBackToRoot(t *testing.T) { + dir := t.TempDir() + got := initializeFolders(&protocol.InitializeParams{RootURI: uri.File(dir)}) + if len(got) != 1 || got[0] != uri.File(dir).Filename() { + t.Errorf("folders = %v, want [%s]", got, uri.File(dir).Filename()) + } + got = initializeFolders(&protocol.InitializeParams{RootPath: dir}) + if len(got) != 1 || got[0] != dir { + t.Errorf("folders = %v, want [%s]", got, dir) + } +} + +// A document opened under none of the session's folders has its own directory +// indexed, so the sibling files it imports resolve as inside a folder. +func TestOpeningFileOutsideFoldersIndexesItsDirectory(t *testing.T) { + dir := t.TempDir() + lib := filepath.Join(dir, "parts", "lib.sysml") + main := filepath.Join(dir, "main.sysml") + if err := os.MkdirAll(filepath.Dir(lib), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lib, []byte(libSource), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(main, []byte(mainSource), 0o600); err != nil { + t.Fatal(err) + } + + s := NewServer(model.NewWorkspace()) + fc := &fakeClient{} + s.client = fc + ctx := context.Background() + if _, err := s.Initialize(ctx, &protocol.InitializeParams{}); err != nil { + t.Fatalf("Initialize err = %v", err) + } + if err := s.Initialized(ctx, &protocol.InitializedParams{}); err != nil { + t.Fatalf("Initialized err = %v", err) + } + + openFile(t, s, main, mainSource) + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Fatalf("diagnostics for main = %v, want none", msgs) + } + if s.ws.Document(lib) == nil { + t.Errorf("lib.sysml not indexed when its sibling was opened") + } + if s.ws.IsOpen(lib) { + t.Errorf("lib.sysml reported open; the scan records on-disk content only") + } +} + +// A document at the filesystem root indexes no siblings: scanning it would +// walk the whole filesystem. +func TestOpeningFileAtFilesystemRootIndexesNothing(t *testing.T) { + s := NewServer(model.NewWorkspace()) + fc := &fakeClient{} + s.client = fc + ctx := context.Background() + if _, err := s.Initialize(ctx, &protocol.InitializeParams{}); err != nil { + t.Fatalf("Initialize err = %v", err) + } + if err := s.Initialized(ctx, &protocol.InitializedParams{}); err != nil { + t.Fatalf("Initialized err = %v", err) + } + + name := filepath.Join(string(filepath.Separator), "m.sysml") + openFile(t, s, name, "package M {\n part def A;\n}\n") + if len(s.openDirs) != 0 { + t.Errorf("openDirs = %v, want none", s.openDirs) + } + if msgs := diagnosticsFor(fc, name); len(msgs) != 0 { + t.Fatalf("diagnostics for %s = %v, want none", name, msgs) + } +} + +// The directory scan an open document triggers never overwrites another open +// buffer, whose text the editor owns. +func TestOpeningFileOutsideFoldersKeepsOpenBuffers(t *testing.T) { + dir := t.TempDir() + lib := filepath.Join(dir, "lib.sysml") + main := filepath.Join(dir, "main.sysml") + if err := os.WriteFile(lib, []byte("package Lib {\n}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(main, []byte(mainSource), 0o600); err != nil { + t.Fatal(err) + } + + s := NewServer(model.NewWorkspace()) + fc := &fakeClient{} + s.client = fc + if _, err := s.Initialize(context.Background(), &protocol.InitializeParams{}); err != nil { + t.Fatalf("Initialize err = %v", err) + } + + // The editor holds Widget in an unsaved buffer; the disk copy lacks it. + openFile(t, s, lib, libSource) + openFile(t, s, main, mainSource) + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Fatalf("diagnostics for main = %v, want none", msgs) + } + if !s.ws.IsOpen(lib) { + t.Errorf("lib.sysml no longer open after the sibling scan") + } +} + +// No watcher reports edits outside the folders, so a lone file's directory is +// scanned again when it is opened after every document under it was closed. +func TestReopeningFileOutsideFoldersRescansItsDirectory(t *testing.T) { + dir := t.TempDir() + lib := filepath.Join(dir, "lib.sysml") + main := filepath.Join(dir, "main.sysml") + if err := os.WriteFile(lib, []byte(libSource), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(main, []byte(mainSource), 0o600); err != nil { + t.Fatal(err) + } + + s := NewServer(model.NewWorkspace()) + fc := &fakeClient{} + s.client = fc + ctx := context.Background() + if _, err := s.Initialize(ctx, &protocol.InitializeParams{}); err != nil { + t.Fatalf("Initialize err = %v", err) + } + + openFile(t, s, main, mainSource) + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Fatalf("diagnostics for main = %v, want none", msgs) + } + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(main)}, + }); err != nil { + t.Fatalf("DidClose err = %v", err) + } + + // Widget is renamed on disk while nothing under dir is open. + if err := os.WriteFile(lib, []byte("package Lib {\n part def Gadget;\n}\n"), 0o600); err != nil { + t.Fatal(err) + } + openFile(t, s, main, mainSource) + if msgs := diagnosticsFor(fc, main); len(msgs) == 0 { + t.Errorf("main resolved Widget from a stale copy of lib.sysml after reopening") + } +} + +// A sibling deleted while nothing under its directory is open is gone from the +// index once the directory is released; a rescan never sees it to forget it. +func TestClosingLastFileOutsideFoldersDropsDeletedSiblings(t *testing.T) { + dir := t.TempDir() + lib := filepath.Join(dir, "lib.sysml") + main := filepath.Join(dir, "main.sysml") + if err := os.WriteFile(lib, []byte(libSource), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(main, []byte(mainSource), 0o600); err != nil { + t.Fatal(err) + } + + s := NewServer(model.NewWorkspace()) + fc := &fakeClient{} + s.client = fc + ctx := context.Background() + if _, err := s.Initialize(ctx, &protocol.InitializeParams{}); err != nil { + t.Fatalf("Initialize err = %v", err) + } + + openFile(t, s, main, mainSource) + if msgs := diagnosticsFor(fc, main); len(msgs) != 0 { + t.Fatalf("diagnostics for main = %v, want none", msgs) + } + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(main)}, + }); err != nil { + t.Fatalf("DidClose err = %v", err) + } + if s.ws.Document(lib) != nil { + t.Errorf("lib.sysml still indexed after every document under its directory closed") + } + if s.ws.Document(main) == nil { + t.Errorf("main.sysml forgotten on close; a closed document keeps its on-disk content") + } + + if err := os.Remove(lib); err != nil { + t.Fatal(err) + } + openFile(t, s, main, mainSource) + if msgs := diagnosticsFor(fc, main); len(msgs) == 0 { + t.Errorf("main resolved Widget from a deleted lib.sysml after reopening") + } +} + +// A directory stays indexed while a document in a subdirectory is open, and is +// released, along with the subdirectory, once that document closes too. +func TestNestedFilesOutsideFoldersReleaseEveryDirectory(t *testing.T) { + dir := t.TempDir() + parts := filepath.Join(dir, "parts") + if err := os.Mkdir(parts, 0o700); err != nil { + t.Fatal(err) + } + lib := filepath.Join(parts, "lib.sysml") + main := filepath.Join(dir, "main.sysml") + if err := os.WriteFile(lib, []byte(libSource), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(main, []byte(mainSource), 0o600); err != nil { + t.Fatal(err) + } + + s := NewServer(model.NewWorkspace()) + fc := &fakeClient{} + s.client = fc + ctx := context.Background() + if _, err := s.Initialize(ctx, &protocol.InitializeParams{}); err != nil { + t.Fatalf("Initialize err = %v", err) + } + closeFile := func(path string) { + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(path)}, + }); err != nil { + t.Fatalf("DidClose err = %v", err) + } + } + + openFile(t, s, main, mainSource) + openFile(t, s, lib, libSource) + closeFile(main) + s.mu.Lock() + dirs := len(s.openDirs) + s.mu.Unlock() + if dirs != 2 { + t.Errorf("indexed directories after closing main = %d, want dir and parts while lib is open", dirs) + } + closeFile(lib) + s.mu.Lock() + dirs = len(s.openDirs) + s.mu.Unlock() + if dirs != 0 { + t.Errorf("indexed directories after closing every document = %d, want none", dirs) + } + + // Widget is renamed on disk while nothing under dir is open. + if err := os.WriteFile(lib, []byte("package Lib {\n part def Gadget;\n}\n"), 0o600); err != nil { + t.Fatal(err) + } + openFile(t, s, main, mainSource) + if msgs := diagnosticsFor(fc, main); len(msgs) == 0 { + t.Errorf("main resolved Widget from a stale copy of parts/lib.sysml after reopening") + } +} diff --git a/internal/lsp/flow_payload_definition_test.go b/internal/frontend/lsp/flow_payload_definition_test.go similarity index 97% rename from internal/lsp/flow_payload_definition_test.go rename to internal/frontend/lsp/flow_payload_definition_test.go index bb585109eb..f8dba693cf 100644 --- a/internal/lsp/flow_payload_definition_test.go +++ b/internal/frontend/lsp/flow_payload_definition_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // TestDefinitionFlowPayloadDeclaration covers navigation to the payload a diff --git a/internal/lsp/formatting.go b/internal/frontend/lsp/formatting.go similarity index 99% rename from internal/lsp/formatting.go rename to internal/frontend/lsp/formatting.go index b9616f16e9..0358d45cbf 100644 --- a/internal/lsp/formatting.go +++ b/internal/frontend/lsp/formatting.go @@ -11,7 +11,7 @@ import ( "go.lsp.dev/protocol" - "github.com/Open-MBEE/OpenSysML/internal/core/format" + "github.com/Open-MBEE/OpenSysML/internal/syntax/format" ) // formatSource is the formatter behind both formatting requests; tests swap it diff --git a/internal/lsp/formatting_test.go b/internal/frontend/lsp/formatting_test.go similarity index 99% rename from internal/lsp/formatting_test.go rename to internal/frontend/lsp/formatting_test.go index 87a8b180a1..e7cfe748f5 100644 --- a/internal/lsp/formatting_test.go +++ b/internal/frontend/lsp/formatting_test.go @@ -12,9 +12,9 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/format" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/syntax/format" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) var spaces4 = protocol.FormattingOptions{TabSize: 4, InsertSpaces: true} @@ -360,8 +360,8 @@ func formattingCorpus(t *testing.T) []string { t.Helper() var paths []string for _, pattern := range []string{ - "../core/format/testdata/*.sysml", - "../../examples/*.sysml", + "../../syntax/format/testdata/*.sysml", + "../../../examples/*.sysml", "../repl/testdata/*.sysml", } { matches, err := filepath.Glob(pattern) diff --git a/internal/lsp/handler.go b/internal/frontend/lsp/handler.go similarity index 98% rename from internal/lsp/handler.go rename to internal/frontend/lsp/handler.go index 1948f953db..b971cbf0f2 100644 --- a/internal/lsp/handler.go +++ b/internal/frontend/lsp/handler.go @@ -64,7 +64,7 @@ func (s *Server) applyDidChange(ctx context.Context, name string, changes []rawC for _, ch := range changes { content = applyRawContentChange(content, ch) } - s.ws.Update(name, content, version) + s.debugEdit(ctx, "", func() { s.ws.Update(name, content, version) }) s.publishDiagnostics(ctx, name) s.queueOpenDiagnostics(ctx, name) } diff --git a/internal/lsp/handler_test.go b/internal/frontend/lsp/handler_test.go similarity index 99% rename from internal/lsp/handler_test.go rename to internal/frontend/lsp/handler_test.go index bf1cbdde07..6f2361d314 100644 --- a/internal/lsp/handler_test.go +++ b/internal/frontend/lsp/handler_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // These tests drive textDocument/didChange with the framed JSON an editor sends, diff --git a/internal/lsp/hover.go b/internal/frontend/lsp/hover.go similarity index 93% rename from internal/lsp/hover.go rename to internal/frontend/lsp/hover.go index 8690186c98..ad4d0ac5bd 100644 --- a/internal/lsp/hover.go +++ b/internal/frontend/lsp/hover.go @@ -7,11 +7,11 @@ import ( "go.lsp.dev/protocol" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/identity" - "github.com/Open-MBEE/OpenSysML/internal/core/lexer" - "github.com/Open-MBEE/OpenSysML/internal/core/resolve" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/identity" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // Hover returns type/kind information for the declaration under the cursor, in @@ -31,7 +31,7 @@ func (s *Server) Hover(ctx context.Context, params *protocol.HoverParams) (*prot if target, span, ok := s.referencedSegment(name, *ref, offset); ok && target != nil { signature := target.Notation() if target.Name != "" { - signature += " " + lexer.NameText(target.Name) + signature += " " + source.NameText(target.Name) } rng := spanToRange(content, span) return &protocol.Hover{ @@ -51,7 +51,7 @@ func (s *Server) Hover(ctx context.Context, params *protocol.HoverParams) (*prot signature := sym.Notation() if sym.Name != "" { - signature += " " + lexer.NameText(sym.Name) + signature += " " + source.NameText(sym.Name) } // A metadata body declaration implicitly redefines a feature of the // annotation's metadata definition (KerML 7.4.7); name it and its type. @@ -158,7 +158,7 @@ func (s *Server) hoverContents(signature string, comments []string, elementID st func docCommentProse(comments []string) string { var paragraphs []string for _, comment := range comments { - if prose := lexer.CommentBody(comment); prose != "" { + if prose := source.CommentBody(comment); prose != "" { paragraphs = append(paragraphs, strings.ReplaceAll(prose, "\n", " \n")) } } diff --git a/internal/lsp/hover_test.go b/internal/frontend/lsp/hover_test.go similarity index 91% rename from internal/lsp/hover_test.go rename to internal/frontend/lsp/hover_test.go index 307db086d6..7c96d662f0 100644 --- a/internal/lsp/hover_test.go +++ b/internal/frontend/lsp/hover_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) func TestHoverShowsKindAndName(t *testing.T) { @@ -373,6 +373,31 @@ func TestHoverStatesIdentityInPlainText(t *testing.T) { } } +// Hovering a declaration in a workspace copy of a library file, rooted at the +// library's package, states the norm's id; a reference to it from another file +// resolves to the copy and states the same. +func TestHoverWorkspaceCopyOfLibraryFileStatesNormativeIdentity(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + lib := ws.LibraryDocument("Kernel Libraries/Kernel Data Type Library/ScalarValues.kerml") + if lib == nil { + t.Fatal("ScalarValues.kerml not bundled") + } + copyName := uri.File("/tmp/ScalarValues.kerml").Filename() + src := string(lib.Content) + ws.Open(copyName, lib.Content, 1) + const want = "Element id 14c0aa22-5489-59b5-b438-ded26e83ba31 (normative, KerML)" + if res := hoverInSrc(t, s, copyName, src, strings.Index(src, "datatype Real ")+len("datatype ")); !strings.Contains(res.Contents.Value, want) { + t.Errorf("hover on the copy's Real = %q, want %q", res.Contents.Value, want) + } + user := uri.File("/tmp/uses.sysml").Filename() + use := "package P { attribute mass : ScalarValues::Real; }" + ws.Open(user, []byte(use), 1) + if res := hoverInSrc(t, s, user, use, strings.Index(use, "Real;")); !strings.Contains(res.Contents.Value, want) { + t.Errorf("hover on a reference to the copy's Real = %q, want %q", res.Contents.Value, want) + } +} + // Hovering a library declaration in its own bundled document states the norm's id. func TestHoverLibraryDeclarationStatesNormativeIdentity(t *testing.T) { ws := model.NewWorkspace() diff --git a/internal/frontend/lsp/identity.go b/internal/frontend/lsp/identity.go new file mode 100644 index 0000000000..1d405f60d7 --- /dev/null +++ b/internal/frontend/lsp/identity.go @@ -0,0 +1,257 @@ +package lsp + +import ( + "bytes" + "fmt" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/identity" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/lexer" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// identityActionKind is the kind of the opt-in identity annotation actions. +const identityActionKind = protocol.RefactorRewrite + +// placeholderProjectID is what a minted ProjectRef binds to until the user +// fills in the repository project. +const placeholderProjectID = "" + +// identityActions offers, for the declaration whose header the range touches, +// minting an ElementId it lacks and binding an unbound root to a project. An id +// the norm fixes is not lacking, so a library element is never minted for. +func (s *Server) identityActions(name string, doc *model.Document, want source.Span) ([]protocol.CodeAction, error) { + sym := s.declarationAt(doc, want) + if sym == nil { + return nil, nil + } + info, ok := s.ws.IdentityOf(name, sym) + if !ok { + return nil, nil + } + root := identity.Root(info.Symbol) + uri := nameToURI(name) + var out []protocol.CodeAction + if info.Scope == nil && root == info.Symbol { + out = append(out, protocol.CodeAction{ + Title: fmt.Sprintf("Bind '%s' to a project", sym.Name), + Kind: identityActionKind, + Edit: workspaceEdit(uri, positionsOf(doc), annotate(doc.Content, []annotation{projectRef(root)})), + }) + } + if info.Annotated || info.Normative() { + return out, nil + } + id, err := reposync.MintUUID() + if err != nil { + return nil, err + } + title := fmt.Sprintf("Annotate '%s' with a minted element id", sym.Name) + notes := []annotation{elementID(info.Symbol, info.FQN, id)} + if info.Scope == nil { + title += fmt.Sprintf(" and bind '%s' to a project", root.Name) + notes = append([]annotation{projectRef(root)}, notes...) + } + out = append(out, protocol.CodeAction{ + Title: title, + Kind: identityActionKind, + Edit: workspaceEdit(uri, positionsOf(doc), annotate(doc.Content, notes)), + }) + return out, nil +} + +// declarationAt returns the declaration whose header — the text before its +// body, or the whole declaration when it has none — the range lies in, trivia +// and comments at either end of a selection (a whole-line selection) skipped. +func (s *Server) declarationAt(doc *model.Document, want source.Span) *symbols.Symbol { + at, until := want.Offset, want.End() + if tokens := trimComments(tokensIn(doc.Content, want)); len(tokens) > 0 { + at, until = tokens[0].Span.Offset, tokens[len(tokens)-1].Span.End() + } + sym := symbolAtOffset(doc.Scope, at) + if sym == nil || sym.Decl == nil || s.ws.EnclosingMetadataBody(sym.OwnerScope) != nil { + return nil + } + body, hasBody := bodyOf(doc.Content, sym.DeclSpan) + end := body.End() + if hasBody { + end = body.Offset + 1 + } + if until > end { + return nil + } + return sym +} + +// trimComments drops the regular comments at either end of tokens. +func trimComments(tokens []lexer.Token) []lexer.Token { + for len(tokens) > 0 && tokens[0].Kind == lexer.RegularComment { + tokens = tokens[1:] + } + for len(tokens) > 0 && tokens[len(tokens)-1].Kind == lexer.RegularComment { + tokens = tokens[:len(tokens)-1] + } + return tokens +} + +// annotation is one metadata annotation to write: inline in the target's body +// when it has one, else standalone (about-form) at the end of the file. +type annotation struct { + target *symbols.Symbol + inline string + about string +} + +func elementID(target *symbols.Symbol, fqn, id string) annotation { + path := source.QualifiedNameText(fqn) + return annotation{target: target, inline: identity.ElementIdInline(id), about: identity.ElementIdAbout(path, id)} +} + +// projectRef binds a root declaration, so its qualified name is its own. +func projectRef(target *symbols.Symbol) annotation { + path := source.NameText(target.Name) + return annotation{ + target: target, + inline: identity.ProjectRefInline(placeholderProjectID), + about: identity.ProjectRefAbout(path, placeholderProjectID), + } +} + +// annotate computes the edits writing the annotations, in order, without +// touching any other text of the file. +func annotate(content []byte, notes []annotation) []diag.Edit { + var edits []diag.Edit + var appended []string + inline := make(map[*symbols.Symbol][]string) + var order []*symbols.Symbol + for _, note := range notes { + if _, hasBody := bodyOf(content, note.target.DeclSpan); !hasBody { + appended = append(appended, note.about) + continue + } + if _, seen := inline[note.target]; !seen { + order = append(order, note.target) + } + inline[note.target] = append(inline[note.target], note.inline) + } + for _, target := range order { + body, _ := bodyOf(content, target.DeclSpan) + edits = append(edits, insertInBody(content, body, inline[target])...) + } + if len(appended) > 0 { + text := strings.Join(appended, "\n") + "\n" + if len(content) > 0 && content[len(content)-1] != '\n' { + text = "\n" + text + } + edits = append(edits, diag.Insert(len(content), text)) + } + return edits +} + +// insertInBody places texts at the head of a body: on their own lines when the +// members have theirs, before the first member otherwise, alone if none. +func insertInBody(content []byte, body source.Span, texts []string) []diag.Edit { + open, closeAt := body.Offset, body.End()-1 + anchor, ownLine, hasMember := bodyAnchor(content, open, closeAt) + if !hasMember { + interior := source.Span{Offset: open + 1, Len: closeAt - open - 1} + joined := " " + strings.Join(texts, " ") + " " + if strings.TrimSpace(string(content[open+1:closeAt])) == "" { + return []diag.Edit{diag.Replace(interior, joined)} + } + return []diag.Edit{diag.Insert(open+1, strings.TrimRight(joined, " "))} + } + edits := make([]diag.Edit, 0, len(texts)) + for _, text := range texts { + if ownLine { + edits = append(edits, diag.InsertLine(anchor, text)) + } else { + edits = append(edits, diag.Insert(anchor, text+" ")) + } + } + return edits +} + +// bodyAnchor is where a member-bearing body starts: the first token (notes +// included) opening its own line, else the first member on the brace's line. +func bodyAnchor(content []byte, open, closeAt int) (anchor int, ownLine, hasMember bool) { + first := firstTokenOffset(content, open+1, closeAt) + if first < 0 { + return 0, false, false + } + lx := lexer.New(source.New("", content[open+1:first])) + for tok := lx.Next(); tok.Kind != lexer.EOF; tok = lx.Next() { + if tok.Kind == lexer.Whitespace { + continue + } + if off := open + 1 + tok.Span.Offset; bytes.Contains(content[open+1:off], []byte{'\n'}) { + return off, true, true + } + } + return first, bytes.Contains(content[open+1:first], []byte{'\n'}), true +} + +// bodyOf spans the braces of the body a declaration ends in; hasBody is false +// for one ending in a semicolon or whose body is not closed. +func bodyOf(content []byte, decl source.Span) (body source.Span, hasBody bool) { + tokens := tokensIn(content, decl) + if len(tokens) == 0 { + return source.Span{}, false + } + last := tokens[len(tokens)-1] + if last.Kind != lexer.RBrace { + return source.Span{Offset: decl.Offset, Len: last.Span.End() - decl.Offset}, false + } + depth := 0 + for i := len(tokens) - 1; i >= 0; i-- { + switch tokens[i].Kind { + case lexer.RBrace: + depth++ + case lexer.LBrace: + depth-- + if depth == 0 { + open := tokens[i].Span.Offset + return source.Span{Offset: open, Len: last.Span.End() - open}, true + } + } + } + return source.Span{}, false +} + +// firstTokenOffset is the offset of the first token in [from, to), or -1 when +// only trivia lies there. +func firstTokenOffset(content []byte, from, to int) int { + tokens := tokensIn(content, source.Span{Offset: from, Len: to - from}) + if len(tokens) == 0 { + return -1 + } + return tokens[0].Span.Offset +} + +// tokensIn lexes the text of span, trivia dropped, with spans in content offsets. +func tokensIn(content []byte, span source.Span) []lexer.Token { + end := span.End() + if span.Offset < 0 || end > len(content) || span.Offset >= end { + return nil + } + lx := lexer.New(source.New("", content[span.Offset:end])) + var out []lexer.Token + for { + tok := lx.Next() + if tok.Kind == lexer.EOF { + return out + } + if tok.IsTrivia() { + continue + } + tok.Span.Offset += span.Offset + out = append(out, tok) + } +} diff --git a/internal/frontend/lsp/identity_test.go b/internal/frontend/lsp/identity_test.go new file mode 100644 index 0000000000..194dfed892 --- /dev/null +++ b/internal/frontend/lsp/identity_test.go @@ -0,0 +1,541 @@ +package lsp + +import ( + "context" + "regexp" + "sort" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/identity" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// uuidV4 matches a lowercase RFC 4122 version-4 UUID. +var uuidV4 = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +// cursorAt is the empty range at the first occurrence of marker in src. +func cursorAt(t *testing.T, src, marker string) protocol.Range { + t.Helper() + off := strings.Index(src, marker) + if off < 0 { + t.Fatalf("marker %q not in source", marker) + } + pos := offsetToPosition([]byte(src), off) + return protocol.Range{Start: pos, End: pos} +} + +// identityActionsFor returns the identity annotation actions offered at rng. +func identityActionsFor(t *testing.T, file, src string, rng protocol.Range) []protocol.CodeAction { + t.Helper() + var out []protocol.CodeAction + for _, act := range actionsFor(t, file, src, rng) { + if act.Kind == identityActionKind { + out = append(out, act) + } + } + return out +} + +// mintAction returns the one minting action offered at rng, or fails. +func mintAction(t *testing.T, file, src string, rng protocol.Range) protocol.CodeAction { + t.Helper() + var minting []protocol.CodeAction + for _, act := range identityActionsFor(t, file, src, rng) { + if strings.HasPrefix(act.Title, "Annotate ") { + minting = append(minting, act) + } + } + if len(minting) != 1 { + t.Fatalf("minting actions = %+v, want one", minting) + } + if len(minting[0].Diagnostics) != 0 { + t.Errorf("minting is opt-in, yet the action is attached to diagnostics %+v", minting[0].Diagnostics) + } + return minting[0] +} + +// applyAll returns src with every edit of the action applied, later positions +// first so earlier offsets stay valid, inserts at one position in array order. +func applyAll(t *testing.T, src string, act protocol.CodeAction, file string) string { + t.Helper() + if act.Edit == nil { + t.Fatalf("action %q has no workspace edit", act.Title) + } + edits := act.Edit.Changes[uri.File(file)] + if len(edits) == 0 { + t.Fatalf("action %q has no edits for %s", act.Title, file) + } + content := []byte(src) + order := make([]int, len(edits)) + for i := range order { + order[i] = i + } + sort.SliceStable(order, func(i, j int) bool { + a, b := positionToOffset(content, edits[order[i]].Range.Start), positionToOffset(content, edits[order[j]].Range.Start) + if a != b { + return a > b + } + return order[i] > order[j] + }) + out := src + for _, i := range order { + out = apply(t, out, edits[i]) + } + return out +} + +// mintedID extracts the id the action's edits declare, checking its shape. +func mintedID(t *testing.T, act protocol.CodeAction, file string) string { + t.Helper() + idOf := regexp.MustCompile(`id = "([^"]*)"`) + for _, e := range act.Edit.Changes[uri.File(file)] { + if m := idOf.FindStringSubmatch(e.NewText); m != nil { + if !uuidV4.MatchString(m[1]) { + t.Errorf("minted id %q is no UUID v4", m[1]) + } + return m[1] + } + } + t.Fatalf("no id in edits %+v", act.Edit.Changes) + return "" +} + +// identityAfter analyzes the annotated source and returns the identity of the +// named element, failing on any error the annotation introduced. +func identityAfter(t *testing.T, file, src, fqn string) *identity.Info { + t.Helper() + ws := model.NewWorkspace() + name := uri.File(file).Filename() + ws.Open(name, []byte(src), 1) + for _, d := range ws.Diagnostics(name) { + if d.Severity == diag.SeverityError { + t.Errorf("annotated source reports %s: %s", d.Code, d.Message) + } + } + syms := ws.LookupQualified(fqn) + if len(syms) != 1 { + t.Fatalf("%s resolves to %d symbols in\n%s", fqn, len(syms), src) + } + info, ok := ws.IdentityOf(name, syms[0]) + if !ok { + t.Fatalf("no identity for %s", fqn) + } + return info +} + +const scopedSrc = "package Vehicles {\n @IdentityMetadata::ProjectRef { projectId = \"p-1\"; }\n\n // the chassis\n part def Chassis {\n attribute mass : ScalarValues::Real;\n }\n part def Wheel {\n @IdentityMetadata::ElementId { id = \"wheel-id\"; }\n }\n /* two per vehicle */ part def Axle;\n part front : Wheel;\n}\n" + +func TestIdentityActionOfferedOnUnannotatedDeclaration(t *testing.T) { + const file = "/tmp/identity_offered.sysml" + act := mintAction(t, file, scopedSrc, cursorAt(t, scopedSrc, "Chassis")) + if act.Title != "Annotate 'Chassis' with a minted element id" { + t.Errorf("title = %q", act.Title) + } + id := mintedID(t, act, file) + got := applyAll(t, scopedSrc, act, file) + want := strings.Replace(scopedSrc, " part def Chassis {\n", " part def Chassis {\n @IdentityMetadata::ElementId { id = \""+id+"\"; }\n", 1) + if got != want { + t.Errorf("applied =\n%s\nwant\n%s", got, want) + } + info := identityAfter(t, file, got, "Vehicles::Chassis") + if !info.Annotated || !info.Declared || info.EffectiveID != id { + t.Errorf("identity after = %+v, want the minted id %s declared", info, id) + } + if info.Scope == nil || info.Scope.ProjectID != "p-1" { + t.Errorf("scope after = %+v, want the existing ProjectRef", info.Scope) + } +} + +// selection is the range from the start of the line holding from through to. +func selection(t *testing.T, src, from, to string) protocol.Range { + t.Helper() + start, end := strings.Index(src, from), strings.Index(src, to) + if start < 0 || end < 0 { + t.Fatalf("markers %q, %q not in source", from, to) + } + start = strings.LastIndexByte(src[:start], '\n') + 1 + return protocol.Range{Start: offsetToPosition([]byte(src), start), End: offsetToPosition([]byte(src), end+len(to))} +} + +func TestIdentityActionOfferedOnSelectedHeader(t *testing.T) { + const file = "/tmp/identity_selected.sysml" + for _, tc := range []struct{ from, to, name string }{ + {"part def Chassis", "Chassis {", "Chassis"}, + {"// the chassis", "def Chassis", "Chassis"}, + {"/* two per vehicle */", "Axle;", "Axle"}, + {"part def Chassis", "Chassis {\n", "Chassis"}, + {"part def Axle", "Axle;\n", "Axle"}, + } { + act := mintAction(t, file, scopedSrc, selection(t, scopedSrc, tc.from, tc.to)) + if want := "Annotate '" + tc.name + "' with a minted element id"; act.Title != want { + t.Errorf("selection %q..%q: title = %q, want %q", tc.from, tc.to, act.Title, want) + } + } + for _, tc := range []struct{ from, to string }{ + {"part def Chassis", "attribute mass"}, + {"part def Axle", "part front"}, + } { + if acts := identityActionsFor(t, file, scopedSrc, selection(t, scopedSrc, tc.from, tc.to)); len(acts) != 0 { + t.Errorf("selection %q..%q: actions = %+v, want none", tc.from, tc.to, acts) + } + } + // Blank and note lines of the body name no other declaration, so a selection + // running through them still means the header; the first member ends that. + const noted = "package P {\n @IdentityMetadata::ProjectRef { projectId = \"p\"; }\n part def Frame {\n\n // weight\n //* rough */\n attribute w;\n }\n}\n" + if act := mintAction(t, file, noted, selection(t, noted, "part def Frame", "rough */\n")); act.Title != "Annotate 'Frame' with a minted element id" { + t.Errorf("selection through body notes: title = %q", act.Title) + } + if acts := identityActionsFor(t, file, noted, selection(t, noted, "part def Frame", "attribute")); len(acts) != 0 { + t.Errorf("selection through to the first member: actions = %+v, want none", acts) + } +} + +func TestIdentityActionNotOfferedOnAnnotatedDeclaration(t *testing.T) { + const file = "/tmp/identity_annotated.sysml" + if acts := identityActionsFor(t, file, scopedSrc, cursorAt(t, scopedSrc, "Wheel {")); len(acts) != 0 { + t.Errorf("actions on an annotated element = %+v, want none", acts) + } +} + +func TestIdentityActionMintsFreshIDs(t *testing.T) { + const file = "/tmp/identity_fresh.sysml" + rng := cursorAt(t, scopedSrc, "Chassis") + first := mintedID(t, mintAction(t, file, scopedSrc, rng), file) + second := mintedID(t, mintAction(t, file, scopedSrc, rng), file) + if first == second { + t.Errorf("two requests minted the same id %s", first) + } +} + +func TestIdentityActionAppendsAboutFormForBodilessDeclaration(t *testing.T) { + const file = "/tmp/identity_about.sysml" + act := mintAction(t, file, scopedSrc, cursorAt(t, scopedSrc, "Axle")) + id := mintedID(t, act, file) + got := applyAll(t, scopedSrc, act, file) + want := scopedSrc + "metadata : IdentityMetadata::ElementId about Vehicles::Axle { id = \"" + id + "\"; }\n" + if got != want { + t.Errorf("applied =\n%s\nwant\n%s", got, want) + } + info := identityAfter(t, file, got, "Vehicles::Axle") + if !info.Declared || info.EffectiveID != id || len(info.Declarations) != 1 || !info.Declarations[0].About { + t.Errorf("identity after = %+v, want one about-form declaration of %s", info, id) + } +} + +func TestIdentityActionAppendsAfterMissingFinalNewline(t *testing.T) { + const file = "/tmp/identity_nonl.sysml" + src := strings.TrimSuffix(scopedSrc, "\n") + act := mintAction(t, file, src, cursorAt(t, src, "front")) + id := mintedID(t, act, file) + got := applyAll(t, src, act, file) + want := src + "\nmetadata : IdentityMetadata::ElementId about Vehicles::front { id = \"" + id + "\"; }\n" + if got != want { + t.Errorf("applied =\n%s\nwant\n%s", got, want) + } + if info := identityAfter(t, file, got, "Vehicles::front"); info.EffectiveID != id { + t.Errorf("effective id = %q, want %s", info.EffectiveID, id) + } +} + +func TestIdentityActionQuotesUnrestrictedNames(t *testing.T) { + const file = "/tmp/identity_quoted.sysml" + const src = "package 'My Project' {\n @IdentityMetadata::ProjectRef { projectId = \"p\"; }\n part def 'Front Wheel';\n}\n" + act := mintAction(t, file, src, cursorAt(t, src, "'Front Wheel'")) + id := mintedID(t, act, file) + got := applyAll(t, src, act, file) + want := src + "metadata : IdentityMetadata::ElementId about 'My Project'::'Front Wheel' { id = \"" + id + "\"; }\n" + if got != want { + t.Errorf("applied =\n%s\nwant\n%s", got, want) + } + if info := identityAfter(t, file, got, "My Project::Front Wheel"); info.EffectiveID != id { + t.Errorf("effective id = %q, want %s", info.EffectiveID, id) + } +} + +func TestIdentityActionInlinePlacements(t *testing.T) { + const file = "/tmp/identity_inline.sysml" + const head = "package P {\n @IdentityMetadata::ProjectRef { projectId = \"p\"; }\n" + cases := []struct { + name, decl, want string + }{ + {"empty body", " part def A {}\n", " part def A { @IdentityMetadata::ElementId { id = \"ID\"; } }\n"}, + {"empty multi-line body", " part def A {\n }\n", " part def A { @IdentityMetadata::ElementId { id = \"ID\"; } }\n"}, + {"body of notes only", " part def A { // todo\n }\n", " part def A { @IdentityMetadata::ElementId { id = \"ID\"; } // todo\n }\n"}, + {"one-line body", " part def A { attribute x; }\n", " part def A { @IdentityMetadata::ElementId { id = \"ID\"; } attribute x; }\n"}, + {"members on own lines", " part def A {\n // first\n attribute x;\n }\n", " part def A {\n @IdentityMetadata::ElementId { id = \"ID\"; }\n // first\n attribute x;\n }\n"}, + {"note on the brace line", " part def A { // todo\n attribute x;\n }\n", " part def A { // todo\n @IdentityMetadata::ElementId { id = \"ID\"; }\n attribute x;\n }\n"}, + {"multi-line note before member", " part def A {\n //* why\n x */\n attribute x;\n }\n", " part def A {\n @IdentityMetadata::ElementId { id = \"ID\"; }\n //* why\n x */\n attribute x;\n }\n"}, + {"comment member", " part def A {\n /* doc */\n }\n", " part def A {\n @IdentityMetadata::ElementId { id = \"ID\"; }\n /* doc */\n }\n"}, + {"usage body", " part def T;\n part a : T {\n part b : T;\n }\n", " part def T;\n part a : T {\n @IdentityMetadata::ElementId { id = \"ID\"; }\n part b : T;\n }\n"}, + {"braces in header", " part def T;\n part a : T = new T() {\n part b : T;\n }\n", " part def T;\n part a : T = new T() {\n @IdentityMetadata::ElementId { id = \"ID\"; }\n part b : T;\n }\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src := head + tc.decl + "}\n" + marker := "part def A" + fqn := "P::A" + if strings.Contains(tc.decl, "part a") { + marker, fqn = "part a", "P::a" + } + act := mintAction(t, file, src, cursorAt(t, src, marker)) + id := mintedID(t, act, file) + got := applyAll(t, src, act, file) + want := head + strings.ReplaceAll(tc.want, "ID", id) + "}\n" + if got != want { + t.Errorf("applied =\n%s\nwant\n%s", got, want) + } + if info := identityAfter(t, file, got, fqn); info.EffectiveID != id { + t.Errorf("effective id = %q, want %s", info.EffectiveID, id) + } + }) + } +} + +func TestIdentityActionAnnotatesEveryBodyKind(t *testing.T) { + const file = "/tmp/identity_bodies.sysml" + const head = "package P {\n @IdentityMetadata::ProjectRef { projectId = \"p\"; }\n" + cases := []struct { + name, decl, marker, fqn string + }{ + {"enumeration", " enum def Color { red; green; }\n", "enum def Color", "P::Color"}, + {"action with statements", " action def Go {\n first start;\n then action step;\n then done;\n }\n", "action def Go", "P::Go"}, + {"state", " state def Machine {\n entry; then off;\n state off;\n }\n", "state def Machine", "P::Machine"}, + {"nested state", " state def Machine {\n state off {\n entry; then idle;\n state idle;\n }\n }\n", "state off", "P::Machine::off"}, + {"nested package", " package Inner {\n part def A;\n }\n", "package Inner", "P::Inner"}, + {"port", " port def Plug {\n in attribute v;\n }\n", "port def Plug", "P::Plug"}, + {"requirement", " requirement def Safe {\n doc /* safe */\n }\n", "requirement def Safe", "P::Safe"}, + {"calculation", " calc def Sum {\n in a : ScalarValues::Real;\n return : ScalarValues::Real = a;\n }\n", "calc def Sum", "P::Sum"}, + {"constraint", " constraint def Positive {\n in x : ScalarValues::Real;\n x > 0\n }\n", "constraint def Positive", "P::Positive"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src := head + tc.decl + "}\n" + act := mintAction(t, file, src, cursorAt(t, src, tc.marker)) + id := mintedID(t, act, file) + got := applyAll(t, src, act, file) + if !strings.Contains(got, identity.ElementIdInline(id)) { + t.Errorf("applied =\n%s\nwant an inline annotation", got) + } + if info := identityAfter(t, file, got, tc.fqn); info.EffectiveID != id { + t.Errorf("effective id = %q, want %s in\n%s", info.EffectiveID, id, got) + } + }) + } +} + +func TestIdentityActionOnlyOnDeclarationHeader(t *testing.T) { + const file = "/tmp/identity_header.sysml" + for _, marker := range []string{"attribute mass", "ScalarValues::Real", "wheel-id", " @IdentityMetadata::ElementId"} { + rng := cursorAt(t, scopedSrc, marker) + acts := identityActionsFor(t, file, scopedSrc, rng) + for _, act := range acts { + if strings.Contains(act.Title, "'Wheel'") || strings.Contains(act.Title, "'Vehicles'") { + t.Errorf("at %q: offered %q for the enclosing declaration", marker, act.Title) + } + } + } + if acts := identityActionsFor(t, file, scopedSrc, cursorAt(t, scopedSrc, "the chassis")); len(acts) != 0 { + t.Errorf("actions in trivia = %+v, want none", acts) + } + if acts := identityActionsFor(t, file, scopedSrc, wholeFile); len(acts) != 0 { + t.Errorf("actions for a range spanning bodies = %+v, want none", acts) + } +} + +func TestIdentityActionWithoutProjectRefBindsRoot(t *testing.T) { + const file = "/tmp/identity_unscoped.sysml" + const src = "package P {\n part def A {\n attribute x;\n }\n}\n" + act := mintAction(t, file, src, cursorAt(t, src, "part def A")) + if act.Title != "Annotate 'A' with a minted element id and bind 'P' to a project" { + t.Errorf("title = %q", act.Title) + } + id := mintedID(t, act, file) + got := applyAll(t, src, act, file) + want := "package P {\n @IdentityMetadata::ProjectRef { projectId = \"\"; }\n part def A {\n @IdentityMetadata::ElementId { id = \"" + id + "\"; }\n attribute x;\n }\n}\n" + if got != want { + t.Errorf("applied =\n%s\nwant\n%s", got, want) + } + info := identityAfter(t, file, got, "P::A") + if info.EffectiveID != id || info.Scope == nil || info.Scope.ProjectID != "" { + t.Errorf("identity after = %+v, want %s bound to the placeholder project", info, id) + } +} + +func TestIdentityActionWithoutProjectRefOnRootItself(t *testing.T) { + const file = "/tmp/identity_root.sysml" + const src = "package P {\n part def A;\n}\n" + acts := identityActionsFor(t, file, src, cursorAt(t, src, "package P")) + if len(acts) != 2 { + t.Fatalf("actions = %+v, want the binding and the minting one", acts) + } + bind, mint := acts[0], acts[1] + if bind.Title != "Bind 'P' to a project" { + t.Errorf("companion title = %q", bind.Title) + } + if got := applyAll(t, src, bind, file); got != "package P {\n @IdentityMetadata::ProjectRef { projectId = \"\"; }\n part def A;\n}\n" { + t.Errorf("bound =\n%s", got) + } + id := mintedID(t, mint, file) + got := applyAll(t, src, mint, file) + want := "package P {\n @IdentityMetadata::ProjectRef { projectId = \"\"; }\n @IdentityMetadata::ElementId { id = \"" + id + "\"; }\n part def A;\n}\n" + if got != want { + t.Errorf("applied =\n%s\nwant\n%s", got, want) + } + info := identityAfter(t, file, got, "P") + if info.EffectiveID != id || info.Scope == nil || info.Scope.Symbol != info.Symbol { + t.Errorf("identity after = %+v, want %s scoped by its own ProjectRef", info, id) + } +} + +func TestIdentityActionWithoutProjectRefOnBodilessRoot(t *testing.T) { + const file = "/tmp/identity_bodiless_root.sysml" + const src = "part def X;\n" + act := mintAction(t, file, src, cursorAt(t, src, "X")) + id := mintedID(t, act, file) + got := applyAll(t, src, act, file) + want := src + "metadata : IdentityMetadata::ProjectRef about X { projectId = \"\"; }\nmetadata : IdentityMetadata::ElementId about X { id = \"" + id + "\"; }\n" + if got != want { + t.Errorf("applied =\n%s\nwant\n%s", got, want) + } + info := identityAfter(t, file, got, "X") + if info.EffectiveID != id || info.Scope == nil { + t.Errorf("identity after = %+v, want %s in a project scope", info, id) + } +} + +func TestIdentityActionBoundRootOffersNoBinding(t *testing.T) { + const file = "/tmp/identity_bound_root.sysml" + acts := identityActionsFor(t, file, scopedSrc, cursorAt(t, scopedSrc, "package Vehicles")) + if len(acts) != 1 || acts[0].Title != "Annotate 'Vehicles' with a minted element id" { + t.Errorf("actions on a bound root = %+v, want only the minting one", acts) + } +} + +func TestIdentityActionHonorsKindFilter(t *testing.T) { + const file = "/tmp/identity_only.sysml" + ws := model.NewWorkspace() + s := NewServer(ws) + name := uri.File(file).Filename() + ws.Open(name, []byte(scopedSrc), 1) + for _, tc := range []struct { + only []protocol.CodeActionKind + want int + }{ + {[]protocol.CodeActionKind{protocol.QuickFix}, 0}, + {[]protocol.CodeActionKind{protocol.Refactor}, 1}, + {[]protocol.CodeActionKind{protocol.RefactorRewrite}, 1}, + {nil, 1}, + } { + acts, err := s.CodeAction(context.Background(), &protocol.CodeActionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Range: cursorAt(t, scopedSrc, "Chassis"), + Context: protocol.CodeActionContext{Only: tc.only}, + }) + if err != nil { + t.Fatal(err) + } + if len(acts) != tc.want { + t.Errorf("only=%v: actions = %+v, want %d", tc.only, acts, tc.want) + } + } +} + +// A library element's id is fixed by the norm, so nothing is offered to mint one. +func TestIdentityActionNotOfferedOnLibraryDeclaration(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + const file = "Kernel Libraries/Kernel Data Type Library/ScalarValues.kerml" + doc := ws.LibraryDocument(file) + if doc == nil { + t.Fatalf("library document %q not bundled", file) + } + at := strings.Index(string(doc.Content), "datatype Real ") + len("datatype ") + acts, err := s.identityActions(file, doc, source.Span{Offset: at, Len: len("Real")}) + if err != nil { + t.Fatalf("identityActions err = %v", err) + } + if len(acts) != 0 { + t.Errorf("actions on ScalarValues::Real = %+v, want none", acts) + } +} + +// A copy of a library file open in the workspace, rooted at the library's +// package, is the library as the RDF mapping reads it: its elements carry the +// norm's ids and nothing is offered to mint one. Rooted elsewhere it is the +// user's file, whose ids are derived and minted as any other. +func TestWorkspaceCopyOfLibraryFileIsTheLibrary(t *testing.T) { + const file = "/tmp/ScalarValues.kerml" + const bundled = "Kernel Libraries/Kernel Data Type Library/ScalarValues.kerml" + ws := model.NewWorkspace() + lib := ws.LibraryDocument(bundled) + if lib == nil { + t.Fatal("ScalarValues.kerml not bundled") + } + src := string(lib.Content) + name := uri.File(file).Filename() + // The copy's Real, not the library's: both may resolve under the qualified name. + copyIdentity := func(text string) *identity.Info { + t.Helper() + ws.Open(name, []byte(text), 1) + for _, sym := range ws.LookupQualified("ScalarValues::Real") { + if sym.DocName == name { + info, ok := ws.IdentityOf(name, sym) + if !ok { + t.Fatal("no identity for the copy's Real") + } + return info + } + } + t.Fatal("the copy declares no ScalarValues::Real") + return nil + } + const norm = "14c0aa22-5489-59b5-b438-ded26e83ba31" + real := copyIdentity(src) + if !real.Normative() || real.EffectiveID != norm || real.Language != identity.KerML { + t.Errorf("Real in a workspace copy = %+v, want the norm's KerML id %s", real, norm) + } + if got := ws.StandsInFor(name); got != bundled { + t.Errorf("the copy stands in for %q, want %q", got, bundled) + } + if acts := identityActionsFor(t, file, src, cursorAt(t, src, "datatype Real ")); len(acts) != 0 { + t.Errorf("actions on the copy's Real = %+v, want none", acts) + } + + // Rooted at a package of the user's, the same text is the user's file. + own := strings.Replace(src, "standard library package ScalarValues", "package MyScalarValues", 1) + if own == src { + t.Fatalf("ScalarValues is not declared where expected:\n%s", src) + } + ws.Open(name, []byte(own), 2) + var mine *symbols.Symbol + for _, sym := range ws.LookupQualified("MyScalarValues::Real") { + if sym.DocName == name { + mine = sym + } + } + if mine == nil { + t.Fatal("the renamed copy declares no MyScalarValues::Real") + } + info, ok := ws.IdentityOf(name, mine) + if !ok || info.Source != identity.SourceDerived || info.Normative() { + t.Errorf("Real under the user's package = %+v, want a derived id", info) + } + if got := ws.StandsInFor(name); got != "" { + t.Errorf("the renamed copy stands in for %q, want nothing", got) + } + if act := mintAction(t, file, own, cursorAt(t, own, "datatype Real ")).Title; !strings.HasPrefix(act, "Annotate 'Real' with a minted element id") { + t.Errorf("action on the user's Real = %q", act) + } + + // Restored to the library's package, the copy is the library again. + if real = copyIdentity(src); !real.Normative() || real.EffectiveID != norm { + t.Errorf("Real in the restored copy = %+v, want the norm's id %s", real, norm) + } +} diff --git a/internal/lsp/if_branch_test.go b/internal/frontend/lsp/if_branch_test.go similarity index 98% rename from internal/lsp/if_branch_test.go rename to internal/frontend/lsp/if_branch_test.go index ced10624bf..68b7791551 100644 --- a/internal/lsp/if_branch_test.go +++ b/internal/frontend/lsp/if_branch_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // ifBranchSource declares `brake` twice — once in each branch of the same diff --git a/internal/frontend/lsp/implicit_typing_test.go b/internal/frontend/lsp/implicit_typing_test.go new file mode 100644 index 0000000000..273b4bdaf1 --- /dev/null +++ b/internal/frontend/lsp/implicit_typing_test.go @@ -0,0 +1,62 @@ +package lsp + +import ( + "context" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// implicitStateSrc references `done`, which an untyped state usage inherits from +// its implicit standard library base States::StateAction. +const implicitStateSrc = "package P {\n\tstate machine {\n\t\tstate normal;\n\t\tconstraint { Time::TimeOf(normal.done) > 0 [SI::s] }\n\t}\n}\n" + +// TestPublishDiagnosticsAcceptsImplicitlyInheritedMember covers the editor view +// of implicit usage typing: a member reached through the implicit base must not +// be squiggled, matching what the resolver reports. +func TestPublishDiagnosticsAcceptsImplicitlyInheritedMember(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + fc := &fakeClient{} + s.client = fc + + name := "implicit.sysml" + ws.Open(name, []byte(implicitStateSrc), 1) + s.publishDiagnostics(context.Background(), name) + + if len(fc.published) != 1 { + t.Fatalf("published count = %d, want 1", len(fc.published)) + } + if diags := fc.published[0].Diagnostics; len(diags) != 0 { + t.Fatalf("diagnostics = %+v, want none", diags) + } +} + +// TestHoverOnUntypedUsageStillReportsItsKind covers hover over the untyped usage +// itself: implicit typing must not change how the declaration is described. +func TestHoverOnUntypedUsageStillReportsItsKind(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + name := uri.File("/tmp/implicit_hover.sysml").Filename() + ws.Open(name, []byte(implicitStateSrc), 1) + defer ws.Close(name) + + off := strings.Index(implicitStateSrc, "state normal") + len("state ") + res, err := s.Hover(context.Background(), &protocol.HoverParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(implicitStateSrc), off), + }, + }) + if err != nil { + t.Fatalf("Hover err = %v", err) + } + if res == nil || !strings.Contains(res.Contents.Value, "state") || + !strings.Contains(res.Contents.Value, "normal") { + t.Fatalf("hover on `normal` = %+v, want its own kind and name", res) + } +} diff --git a/internal/frontend/lsp/integration_test.go b/internal/frontend/lsp/integration_test.go new file mode 100644 index 0000000000..6904fbaf00 --- /dev/null +++ b/internal/frontend/lsp/integration_test.go @@ -0,0 +1,138 @@ +package lsp + +import ( + "context" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +func TestLSPEndToEnd(t *testing.T) { + ctx := context.Background() + ws := model.NewWorkspace() + s := NewServer(ws) + + // Lifecycle. + initRes, err := s.Initialize(ctx, &protocol.InitializeParams{}) + if err != nil { + t.Fatalf("Initialize err = %v", err) + } + if initRes.Capabilities.HoverProvider == nil { + t.Fatal("HoverProvider not advertised") + } + if err := s.Initialized(ctx, &protocol.InitializedParams{}); err != nil { + t.Fatalf("Initialized err = %v", err) + } + + // Open a document. + name := "e2e.sysml" + src := "package P { namespace N; }\nimport P::N;\n" + docURI := uri.File(name) + if err := s.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: docURI, + LanguageID: "sysml", + Version: 1, + Text: src, + }, + }); err != nil { + t.Fatalf("DidOpen err = %v", err) + } + + posOf := func(sub string, nth int) protocol.Position { + idx := -1 + for i := 0; i < nth; i++ { + idx = strings.Index(src[idx+1:], sub) + idx + 1 + } + return offsetToPosition([]byte(src), idx) + } + + // Hover on the declaration N (first "N"). + hov, err := s.Hover(ctx, &protocol.HoverParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + Position: posOf("N", 1), + }, + }) + if err != nil { + t.Fatalf("Hover err = %v", err) + } + if hov == nil || !strings.Contains(hov.Contents.Value, "N") { + t.Errorf("Hover = %+v, want content mentioning N", hov) + } + + // Definition from the reference "N" in "import P::N" (second "N"). + defs, err := s.Definition(ctx, &protocol.DefinitionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + Position: posOf("N", 2), + }, + }) + if err != nil { + t.Fatalf("Definition err = %v", err) + } + if len(defs) != 1 { + t.Errorf("Definition = %d locations, want 1", len(defs)) + } + + // References for the declaration P (IncludeDeclaration). + refs, err := s.References(ctx, &protocol.ReferenceParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + Position: posOf("P", 1), + }, + Context: protocol.ReferenceContext{IncludeDeclaration: true}, + }) + if err != nil { + t.Fatalf("References err = %v", err) + } + if len(refs) < 1 { + t.Errorf("References = %d, want >= 1", len(refs)) + } + + // documentSymbol. + syms, err := s.DocumentSymbol(ctx, &protocol.DocumentSymbolParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + }) + if err != nil { + t.Fatalf("DocumentSymbol err = %v", err) + } + if len(syms) != 1 { + t.Errorf("DocumentSymbol top-level = %d, want 1", len(syms)) + } + + // workspace/symbol. + wsyms, err := s.Symbols(ctx, &protocol.WorkspaceSymbolParams{Query: "P"}) + if err != nil { + t.Fatalf("Symbols err = %v", err) + } + if len(wsyms) < 1 { + t.Errorf("Symbols(P) = %d, want >= 1", len(wsyms)) + } + + // completion. + comp, err := s.Completion(ctx, &protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + Position: offsetToPosition([]byte(src), len(src)), + }, + }) + if err != nil { + t.Fatalf("Completion err = %v", err) + } + if comp == nil || len(comp.Items) == 0 { + t.Error("Completion returned no items") + } + + // Shutdown / Exit. + if err := s.Shutdown(ctx); err != nil { + t.Fatalf("Shutdown err = %v", err) + } + if err := s.Exit(ctx); err != nil { + t.Fatalf("Exit err = %v", err) + } +} diff --git a/internal/lsp/invocation_navigation_test.go b/internal/frontend/lsp/invocation_navigation_test.go similarity index 98% rename from internal/lsp/invocation_navigation_test.go rename to internal/frontend/lsp/invocation_navigation_test.go index 9b6a57271a..fd902f8a86 100644 --- a/internal/lsp/invocation_navigation_test.go +++ b/internal/frontend/lsp/invocation_navigation_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // overloadNavigationSrc imports two same-named calcs; each call's argument diff --git a/internal/lsp/library_invocation_test.go b/internal/frontend/lsp/library_invocation_test.go similarity index 97% rename from internal/lsp/library_invocation_test.go rename to internal/frontend/lsp/library_invocation_test.go index 19e1badf78..830dbd858d 100644 --- a/internal/lsp/library_invocation_test.go +++ b/internal/frontend/lsp/library_invocation_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/model" - "github.com/Open-MBEE/OpenSysML/internal/repl" + "github.com/Open-MBEE/OpenSysML/internal/frontend/repl" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // unimportedLibraryCallSrc calls Kernel Function Library functions without diff --git a/internal/frontend/lsp/lifecycle.go b/internal/frontend/lsp/lifecycle.go new file mode 100644 index 0000000000..905571f029 --- /dev/null +++ b/internal/frontend/lsp/lifecycle.go @@ -0,0 +1,187 @@ +package lsp + +import ( + "context" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" +) + +// Initialize advertises the server capabilities this plan implements and records +// the session's folders, scanned later so the handshake is not delayed. +func (s *Server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) { + s.setFolders(initializeFolders(params)) + if params != nil { + s.applyConformanceSettings(params.InitializationOptions) + s.setHoverMarkdown(clientRendersMarkdownHover(params.Capabilities)) + s.setCompletionMarkdown(clientRendersMarkdownCompletion(params.Capabilities)) + s.setCrossDocument(clientAdvertisesCrossDocument(params.Capabilities)) + } + return &protocol.InitializeResult{ + Capabilities: protocol.ServerCapabilities{ + TextDocumentSync: &protocol.TextDocumentSyncOptions{ + OpenClose: true, + Change: protocol.TextDocumentSyncKindIncremental, + // Without this a client sends no didSave, so the save-time + // cross-document refresh would never run. + Save: &protocol.SaveOptions{IncludeText: false}, + }, + HoverProvider: true, + DefinitionProvider: true, + ReferencesProvider: true, + DocumentSymbolProvider: true, + WorkspaceSymbolProvider: true, + CompletionProvider: &protocol.CompletionOptions{ + TriggerCharacters: []string{":", "."}, + }, + DocumentFormattingProvider: true, + DocumentRangeFormattingProvider: true, + RenameProvider: &protocol.RenameOptions{PrepareProvider: true}, + SemanticTokensProvider: &semanticTokensProvider{ + Legend: semanticTokensLegend(), + Full: true, + Range: true, + }, + CodeActionProvider: &protocol.CodeActionOptions{ + CodeActionKinds: []protocol.CodeActionKind{protocol.QuickFix, identityActionKind}, + }, + // A client that draws diagrams speaks opensysml/render, which is no + // protocol method; this is how it learns the server serves it. + Experimental: map[string]any{ + "openSysmlRender": true, + "openSysmlRenderDocument": true, + "openSysmlApplyModelEdit": true, + "openSysmlStdlibContent": true, + "openSysmlDebug": true, + CrossDocumentCapability: true, + RenderPaletteCapability: true, + RenderFormsCapability: renderFormNames(), + }, + // Folders added mid-session are only indexed if the client reports them. + Workspace: &protocol.ServerCapabilitiesWorkspace{ + WorkspaceFolders: &protocol.ServerCapabilitiesWorkspaceFolders{ + Supported: true, + ChangeNotifications: true, + }, + }, + }, + ServerInfo: &protocol.ServerInfo{ + Name: "sysml-lsp", + Version: "0.1.0", + }, + }, nil +} + +// clientRendersMarkdownHover reports whether the client advertised Markdown +// among the content formats it accepts for hover. +func clientRendersMarkdownHover(caps protocol.ClientCapabilities) bool { + if caps.TextDocument == nil || caps.TextDocument.Hover == nil { + return false + } + for _, format := range caps.TextDocument.Hover.ContentFormat { + if format == protocol.Markdown { + return true + } + } + return false +} + +// clientRendersMarkdownCompletion reports whether the client advertised Markdown +// among the formats it accepts for completion item documentation. +func clientRendersMarkdownCompletion(caps protocol.ClientCapabilities) bool { + if caps.TextDocument == nil || caps.TextDocument.Completion == nil || caps.TextDocument.Completion.CompletionItem == nil { + return false + } + for _, format := range caps.TextDocument.Completion.CompletionItem.DocumentationFormat { + if format == protocol.Markdown { + return true + } + } + return false +} + +// clientAdvertisesCrossDocument reports whether the client listed the +// cross-document diagram contract among its experimental capabilities. +func clientAdvertisesCrossDocument(caps protocol.ClientCapabilities) bool { + experimental, ok := caps.Experimental.(map[string]any) + return ok && experimental[CrossDocumentCapability] == true +} + +// Initialized indexes the session's folders, so cross-file names resolve without +// the editor having opened every file. +func (s *Server) Initialized(ctx context.Context, params *protocol.InitializedParams) error { + s.loadFolders(ctx) + return nil +} + +// Shutdown prepares the server for exit: the session stays readable, but only +// the exit notification is still answered (LSP 3.17 §Shutdown Request). Live +// debug sessions end with it, releasing what they hold. +func (s *Server) Shutdown(ctx context.Context) error { + s.markShutdown() + s.debugSessionsEnded(ctx, "the server is shutting down") + return nil +} + +// markShutdown records that the client asked for a shutdown. +func (s *Server) markShutdown() { + s.mu.Lock() + defer s.mu.Unlock() + s.shutdownReceived = true +} + +// Exit ends the session. It records the status the process owes its client and +// releases Run, which owns the stream, rather than closing it from under the +// read loop that dispatched this notification. +func (s *Server) Exit(ctx context.Context) error { + s.mu.Lock() + s.exitReceived = true + s.mu.Unlock() + s.exitOnce.Do(func() { close(s.exited) }) + return nil +} + +// ExitCode is the process status LSP 3.17 asks of a served session: 0 when exit +// followed a shutdown request, 1 when exit arrived without one. A session the +// client ended by closing the stream instead served to its end, so it is 0. +func (s *Server) ExitCode() int { + s.mu.Lock() + defer s.mu.Unlock() + if s.exitReceived && !s.shutdownReceived { + return 1 + } + return 0 +} + +// shutdownRequested reports whether a shutdown request has been served. +func (s *Server) shutdownRequested() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.shutdownReceived +} + +// lifecycleHandler enforces the shutdown/exit half of the lifecycle. It runs on +// the read loop, ahead of the asynchronous dispatch, so that what arrives after +// a shutdown is judged against the state the client saw when it sent it: a +// request is answered InvalidRequest, a notification other than exit is dropped, +// and exit ends the session. +func (s *Server) lifecycleHandler(inner jsonrpc2.Handler) jsonrpc2.Handler { + return func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + switch req.Method() { + case protocol.MethodExit: + return reply(ctx, nil, s.Exit(ctx)) + case protocol.MethodShutdown: + // Recorded here, not in the handler: the asynchronous dispatch could + // otherwise run after the next message has been judged. + s.markShutdown() + return inner(ctx, reply, req) + } + if !s.shutdownRequested() { + return inner(ctx, reply, req) + } + if _, isRequest := req.(*jsonrpc2.Call); isRequest { + return reply(ctx, nil, jsonrpc2.ErrInvalidRequest) + } + return reply(ctx, nil, nil) + } +} diff --git a/internal/frontend/lsp/lifecycle_test.go b/internal/frontend/lsp/lifecycle_test.go new file mode 100644 index 0000000000..8ea50152c6 --- /dev/null +++ b/internal/frontend/lsp/lifecycle_test.go @@ -0,0 +1,235 @@ +package lsp + +import ( + "bufio" + "context" + "net" + "reflect" + "testing" + "time" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +func TestInitializeAdvertisesCapabilities(t *testing.T) { + s := NewServer(model.NewWorkspace()) + res, err := s.Initialize(context.Background(), &protocol.InitializeParams{}) + if err != nil { + t.Fatalf("Initialize error: %v", err) + } + sync, ok := res.Capabilities.TextDocumentSync.(*protocol.TextDocumentSyncOptions) + if !ok { + t.Fatalf("TextDocumentSync = %T, want *protocol.TextDocumentSyncOptions", res.Capabilities.TextDocumentSync) + } + if !sync.OpenClose { + t.Error("OpenClose = false, want true") + } + if sync.Change != protocol.TextDocumentSyncKindIncremental { + t.Errorf("Change = %v, want Incremental", sync.Change) + } + // A client only sends didSave when the server asks for it. + if sync.Save == nil { + t.Error("Save not advertised, so didSave never arrives") + } + if res.Capabilities.HoverProvider != true { + t.Error("HoverProvider not advertised") + } + if res.Capabilities.DefinitionProvider != true { + t.Error("DefinitionProvider not advertised") + } + if res.Capabilities.ReferencesProvider != true { + t.Error("ReferencesProvider not advertised") + } + if res.Capabilities.DocumentSymbolProvider != true { + t.Error("DocumentSymbolProvider not advertised") + } + if res.Capabilities.WorkspaceSymbolProvider != true { + t.Error("WorkspaceSymbolProvider not advertised") + } + if res.Capabilities.DocumentFormattingProvider != true { + t.Error("DocumentFormattingProvider not advertised") + } + if res.Capabilities.DocumentRangeFormattingProvider != true { + t.Error("DocumentRangeFormattingProvider not advertised") + } + if cp := res.Capabilities.CompletionProvider; cp == nil { + t.Error("CompletionProvider not advertised") + } else if !reflect.DeepEqual(cp.TriggerCharacters, []string{":", "."}) { + t.Errorf("TriggerCharacters = %v, want [: .]", cp.TriggerCharacters) + } +} + +func TestShutdownAndExit(t *testing.T) { + s := NewServer(model.NewWorkspace()) + if err := s.Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown error: %v", err) + } + if err := s.Exit(context.Background()); err != nil { + t.Errorf("Exit error: %v", err) + } + if code := s.ExitCode(); code != 0 { + t.Errorf("ExitCode after shutdown then exit = %d, want 0", code) + } +} + +// The exit notification must release Run, whatever the client asked before it: +// the status it earns is 0 after a shutdown and 1 without one. +func TestExitEndsTheSessionWithTheStatusLSPRequires(t *testing.T) { + for _, tc := range []struct { + name string + shutdown bool + want int + }{ + {name: "exit after shutdown", shutdown: true, want: 0}, + {name: "exit without shutdown", shutdown: false, want: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { _ = client.Close() }) + + s := NewServer(model.NewWorkspace()) + done := make(chan error, 1) + go func() { done <- s.Run(context.Background(), server) }() + + if err := client.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { + t.Fatalf("SetDeadline: %v", err) + } + r := bufio.NewReader(client) + if tc.shutdown { + if err := writeMessage(client, map[string]any{"jsonrpc": "2.0", "id": 1, "method": "shutdown"}); err != nil { + t.Fatalf("write shutdown: %v", err) + } + if msg := readMessage(t, r); msg["error"] != nil { + t.Fatalf("shutdown answered %v, want a result", msg) + } + } + if err := writeMessage(client, map[string]any{"jsonrpc": "2.0", "method": "exit"}); err != nil { + t.Fatalf("write exit: %v", err) + } + + select { + case err := <-done: + if err != nil { + t.Errorf("Run returned %v, want nil for a session the client exited", err) + } + case <-time.After(10 * time.Second): + t.Fatal("Run did not return after the exit notification") + } + if code := s.ExitCode(); code != tc.want { + t.Errorf("ExitCode = %d, want %d", code, tc.want) + } + }) + } +} + +// A notification after shutdown carries no id to refuse, so it never reaches the +// handlers at all — before shutdown the same notification does. +func TestNotificationAfterShutdownIsDropped(t *testing.T) { + didOpen, err := jsonrpc2.NewNotification(protocol.MethodTextDocumentDidOpen, protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: "file:///tmp/dropped.sysml", LanguageID: "sysml", Version: 1, Text: "package P;\n", + }, + }) + if err != nil { + t.Fatalf("NewNotification: %v", err) + } + shutdown, err := jsonrpc2.NewCall(jsonrpc2.NewNumberID(1), protocol.MethodShutdown, nil) + if err != nil { + t.Fatalf("NewCall: %v", err) + } + reply := func(ctx context.Context, result any, err error) error { return nil } + + for _, shutDown := range []bool{false, true} { + served := false + s := NewServer(model.NewWorkspace()) + handler := s.lifecycleHandler(func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + served = true + return reply(ctx, nil, nil) + }) + if shutDown { + if err := handler(context.Background(), reply, shutdown); err != nil { + t.Fatalf("shutdown: %v", err) + } + served = false + } + if err := handler(context.Background(), reply, didOpen); err != nil { + t.Fatalf("didOpen: %v", err) + } + if served == shutDown { + t.Errorf("didOpen reached the handlers = %v with shutdown = %v", served, shutDown) + } + } +} + +// After a shutdown request the session owes every further request the +// InvalidRequest error, and every further notification silence. +func TestAfterShutdownOnlyExitIsServed(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { _ = client.Close() }) + + s := NewServer(model.NewWorkspace()) + done := make(chan error, 1) + go func() { done <- s.Run(context.Background(), server) }() + + if err := client.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { + t.Fatalf("SetDeadline: %v", err) + } + r := bufio.NewReader(client) + // net.Pipe is unbuffered, so the writes run alongside the reads below. + writeErr := make(chan error, 1) + go func() { + for _, msg := range []any{ + map[string]any{"jsonrpc": "2.0", "id": 1, "method": "shutdown"}, + map[string]any{"jsonrpc": "2.0", "id": 2, "method": "textDocument/documentSymbol", + "params": map[string]any{"textDocument": map[string]any{"uri": "file:///tmp/after-shutdown.sysml"}}}, + map[string]any{"jsonrpc": "2.0", "id": 3, "method": "textDocument/hover", + "params": map[string]any{"textDocument": map[string]any{"uri": "file:///tmp/after-shutdown.sysml"}, + "position": map[string]any{"line": 0, "character": 0}}}, + } { + if err := writeMessage(client, msg); err != nil { + writeErr <- err + return + } + } + writeErr <- nil + }() + + // Replies may arrive in any order: the dispatch below the wrapper is + // asynchronous, so each is judged by its own id. + answers := map[int]map[string]any{} + for len(answers) < 3 { + msg := readMessage(t, r) + id, ok := msg["id"].(float64) + if !ok { + t.Fatalf("the server sent %v after shutdown, want only the replies owed", msg) + } + answers[int(id)] = msg + } + if answers[1]["error"] != nil { + t.Fatalf("shutdown answered %v, want a result", answers[1]) + } + for _, id := range []int{2, 3} { + failure, ok := answers[id]["error"].(map[string]any) + if !ok { + t.Fatalf("request %d after shutdown answered %v, want an error", id, answers[id]) + } + if code, _ := failure["code"].(float64); int(code) != int(jsonrpc2.InvalidRequest) { + t.Errorf("request %d error code = %v, want %d (InvalidRequest)", id, failure["code"], jsonrpc2.InvalidRequest) + } + } + + if err := <-writeErr; err != nil { + t.Fatalf("write: %v", err) + } + if err := writeMessage(client, map[string]any{"jsonrpc": "2.0", "method": "exit"}); err != nil { + t.Fatalf("write exit: %v", err) + } + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Run did not return after the exit notification") + } +} diff --git a/internal/lsp/linediff.go b/internal/frontend/lsp/linediff.go similarity index 100% rename from internal/lsp/linediff.go rename to internal/frontend/lsp/linediff.go diff --git a/internal/lsp/linediff_test.go b/internal/frontend/lsp/linediff_test.go similarity index 100% rename from internal/lsp/linediff_test.go rename to internal/frontend/lsp/linediff_test.go diff --git a/internal/frontend/lsp/metadata.go b/internal/frontend/lsp/metadata.go new file mode 100644 index 0000000000..bf98556239 --- /dev/null +++ b/internal/frontend/lsp/metadata.go @@ -0,0 +1,36 @@ +package lsp + +import ( + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// isAnnotationBodyScope reports whether scope is the anonymous scope built for +// the members of a `@A { ... }` annotation body. +func isAnnotationBodyScope(scope *symbols.Scope) bool { + if scope == nil || !scope.BodyLocal() { + return false + } + _, ok := scope.Node().(*ast.PrefixMetadata) + return ok +} + +// usageNameAt returns the usage declaration whose own name contains offset, or +// nil when the cursor is elsewhere. +func usageNameAt(scope *symbols.Scope, offset int) *symbols.Symbol { + sym := symbolAtOffset(scope, offset) + if sym == nil { + return nil + } + usage, ok := sym.Decl.(*ast.Usage) + if !ok { + return nil + } + for _, sp := range []source.Span{usage.Ident.NameSpan, usage.Ident.ShortNameSpan} { + if sp.Len > 0 && offset >= sp.Offset && offset < sp.End() { + return sym + } + } + return nil +} diff --git a/internal/frontend/lsp/metadata_body_test.go b/internal/frontend/lsp/metadata_body_test.go new file mode 100644 index 0000000000..3b18b383d3 --- /dev/null +++ b/internal/frontend/lsp/metadata_body_test.go @@ -0,0 +1,366 @@ +package lsp + +import ( + "context" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// metadataBodySrc annotates item p with @Anno: `own` is Anno's own feature, +// `inherited` comes from Base, `outer` is a feature of the enclosing item. +const metadataBodySrc = `metadata def Base { + attribute inherited; +} +metadata def Anno :> Base { + attribute own : ScalarValues::Integer; +} +item p { + attribute outer; + @Anno { + own = outer; + inherited = 2; + } +} +item q { + @Missing { + ghost = 1; + } +} +` + +func openMetadataBodyDoc(t *testing.T) (*Server, string) { + t.Helper() + ws := model.NewWorkspace() + s := NewServer(ws) + name := uri.File("/tmp/metabody.sysml").Filename() + ws.Open(name, []byte(metadataBodySrc), 1) + return s, name +} + +func hoverAt(t *testing.T, s *Server, name string, offset int) *protocol.Hover { + t.Helper() + h, err := s.Hover(context.Background(), &protocol.HoverParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(metadataBodySrc), offset), + }, + }) + if err != nil { + t.Fatalf("Hover err = %v", err) + } + return h +} + +func definitionAt(t *testing.T, s *Server, name string, offset int) []protocol.Location { + t.Helper() + locs, err := s.Definition(context.Background(), &protocol.DefinitionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(metadataBodySrc), offset), + }, + }) + if err != nil { + t.Fatalf("Definition err = %v", err) + } + return locs +} + +func bodyCompletionAt(t *testing.T, s *Server, name string, offset int) map[string]bool { + t.Helper() + list, err := s.Completion(context.Background(), &protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(metadataBodySrc), offset), + }, + }) + if err != nil { + t.Fatalf("Completion err = %v", err) + } + labels := map[string]bool{} + for _, item := range list.Items { + labels[item.Label] = true + } + return labels +} + +func TestMetadataBodyHoverNamesRedefinedFeature(t *testing.T) { + s, name := openMetadataBodyDoc(t) + + h := hoverAt(t, s, name, strings.Index(metadataBodySrc, "own = outer")) + if h == nil { + t.Fatal("hover on body declaration returned nil") + } + text := h.Contents.Value + if !strings.Contains(text, "redefines Anno::own") { + t.Errorf("hover = %q, want it to name Anno::own", text) + } + if !strings.Contains(text, ": ScalarValues::Integer") { + t.Errorf("hover = %q, want it to name the feature's type", text) + } +} + +func TestMetadataBodyDefinitionJumpsToMetadataFeature(t *testing.T) { + s, name := openMetadataBodyDoc(t) + + locs := definitionAt(t, s, name, strings.Index(metadataBodySrc, "own = outer")) + if len(locs) != 1 { + t.Fatalf("locations = %d, want 1", len(locs)) + } + want := strings.Index(metadataBodySrc, "attribute own") + if got := positionToOffset([]byte(metadataBodySrc), locs[0].Range.Start); got != want { + t.Errorf("definition offset = %d, want %d (Anno's own declaration)", got, want) + } +} + +func TestMetadataBodyDefinitionJumpsToInheritedFeature(t *testing.T) { + s, name := openMetadataBodyDoc(t) + + locs := definitionAt(t, s, name, strings.Index(metadataBodySrc, "inherited = 2")) + if len(locs) != 1 { + t.Fatalf("locations = %d, want 1", len(locs)) + } + want := strings.Index(metadataBodySrc, "attribute inherited") + if got := positionToOffset([]byte(metadataBodySrc), locs[0].Range.Start); got != want { + t.Errorf("definition offset = %d, want %d (Base's inherited declaration)", got, want) + } +} + +func TestMetadataBodyValueResolvesInEnclosingScope(t *testing.T) { + s, name := openMetadataBodyDoc(t) + + locs := definitionAt(t, s, name, strings.Index(metadataBodySrc, "outer;\n\t\tinherited")) + if len(locs) != 1 { + t.Fatalf("locations = %d, want 1", len(locs)) + } + want := strings.Index(metadataBodySrc, "attribute outer") + if got := positionToOffset([]byte(metadataBodySrc), locs[0].Range.Start); got != want { + t.Errorf("definition offset = %d, want %d (the enclosing item's feature)", got, want) + } +} + +func TestMetadataBodyCompletionOffersDefinitionFeatures(t *testing.T) { + s, name := openMetadataBodyDoc(t) + + // Declaration position: the start of the `inherited = 2;` statement. + labels := bodyCompletionAt(t, s, name, strings.Index(metadataBodySrc, "inherited = 2")) + if !labels["own"] || !labels["inherited"] { + t.Errorf("completion = %v, want Anno's own and inherited features", labels) + } + if labels["outer"] || labels["p"] { + t.Errorf("completion = %v, must not offer the enclosing namespace's names", labels) + } +} + +func TestMetadataBodyCompletionValuePositionUsesEnclosingScope(t *testing.T) { + s, name := openMetadataBodyDoc(t) + + // Value position: just after `own = `. + labels := bodyCompletionAt(t, s, name, strings.Index(metadataBodySrc, "outer;\n\t\tinherited")) + if !labels["outer"] { + t.Errorf("completion = %v, want the enclosing item's feature outer", labels) + } +} + +func TestMetadataBodyUnresolvedMetaclassDegradesQuietly(t *testing.T) { + s, name := openMetadataBodyDoc(t) + ghost := strings.Index(metadataBodySrc, "ghost = 1") + + if h := hoverAt(t, s, name, ghost); h != nil { + text := h.Contents.Value + if strings.Contains(text, "redefines") { + t.Errorf("hover under unresolved metaclass = %q, want no redefinition", text) + } + } + if locs := definitionAt(t, s, name, ghost); len(locs) != 0 { + t.Errorf("definition under unresolved metaclass = %v, want none", locs) + } + // Completion must not error; whatever it offers, nothing comes from a + // metadata definition that does not exist. + bodyCompletionAt(t, s, name, ghost) +} + +func TestMetadataBodyNestedAnnotation(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + name := uri.File("/tmp/metanest.sysml").Filename() + src := `metadata def Tag { + attribute label; +} +metadata def Anno { + attribute own; +} +item p { + @Anno { + @Tag { + label = 3; + } + own = 1; + } +} +` + ws.Open(name, []byte(src), 1) + + locs, err := s.Definition(context.Background(), &protocol.DefinitionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(src), strings.Index(src, "label = 3")), + }, + }) + if err != nil { + t.Fatalf("Definition err = %v", err) + } + if len(locs) != 1 { + t.Fatalf("locations = %d, want 1", len(locs)) + } + want := strings.Index(src, "attribute label") + if got := positionToOffset([]byte(src), locs[0].Range.Start); got != want { + t.Errorf("nested definition offset = %d, want %d (Tag's label)", got, want) + } +} + +// Declarations nested in a metadata body redefine the members of the feature +// the enclosing declaration redefines, in either language and at any depth. +func TestMetadataBodyNestedDeclarationsFollowTheRedefinedFeature(t *testing.T) { + kerml := `package P { + class U { feature d; } + class T { feature b; feature c : U; } + metaclass M { feature a : T; } + class C { + @M { a { b = 1; c { d = 2; } } } + } + metadata m : M about C { :>> a { b = 3; c { d = 4; } } } +} +` + sysml := strings.NewReplacer( + "class U", "attribute def U", + "class T", "attribute def T", + "class C", "part def C", + "metaclass", "metadata def", + "feature", "attribute", + ).Replace(kerml) + for file, src := range map[string]string{"/tmp/metanest.kerml": kerml, "/tmp/metanest.sysml": sysml} { + ws := model.NewWorkspace() + s := NewServer(ws) + name := uri.File(file).Filename() + ws.Open(name, []byte(src), 1) + keyword := "feature " + if strings.HasSuffix(file, ".sysml") { + keyword = "attribute " + } + at := func(anchor string) protocol.TextDocumentPositionParams { + return protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(src), strings.Index(src, anchor)), + } + } + for anchor, want := range map[string]string{ + "b = 1": "P::T::b", + "d = 2": "P::U::d", + "b = 3": "P::T::b", + "d = 4": "P::U::d", + } { + locs, err := s.Definition(context.Background(), &protocol.DefinitionParams{TextDocumentPositionParams: at(anchor)}) + if err != nil { + t.Fatalf("%s: Definition err = %v", file, err) + } + if len(locs) != 1 { + t.Fatalf("%s: definitions of %q = %d, want 1", file, anchor, len(locs)) + } + leaf := want[strings.LastIndex(want, "::")+2:] + wantOffset := strings.Index(src, keyword+leaf+";") + if got := positionToOffset([]byte(src), locs[0].Range.Start); got != wantOffset { + t.Errorf("%s: definition of %q at offset %d, want %d (%s)", file, anchor, got, wantOffset, want) + } + h, err := s.Hover(context.Background(), &protocol.HoverParams{TextDocumentPositionParams: at(anchor)}) + if err != nil || h == nil { + t.Fatalf("%s: Hover on %q = %v, %v", file, anchor, h, err) + } + if !strings.Contains(h.Contents.Value, "redefines "+want) { + t.Errorf("%s: hover on %q = %q, want it to redefine %s", file, anchor, h.Contents.Value, want) + } + } + for anchor, want := range map[string][]string{ + "b = 1": {"b", "c"}, + "d = 2": {"d"}, + "d = 4": {"d"}, + } { + list, err := s.Completion(context.Background(), &protocol.CompletionParams{TextDocumentPositionParams: at(anchor)}) + if err != nil { + t.Fatalf("%s: Completion err = %v", file, err) + } + labels := map[string]bool{} + for _, item := range list.Items { + labels[item.Label] = true + } + for _, label := range want { + if !labels[label] { + t.Errorf("%s: completion at %q = %v, want %s", file, anchor, labels, label) + } + } + if labels["a"] || labels["M"] || labels["C"] { + t.Errorf("%s: completion at %q = %v, must offer only the nested feature's members", file, anchor, labels) + } + } + } +} + +func TestMetadataBodyDocumentSymbols(t *testing.T) { + s, name := openMetadataBodyDoc(t) + + out, err := s.DocumentSymbol(context.Background(), &protocol.DocumentSymbolParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + }) + if err != nil { + t.Fatalf("DocumentSymbol err = %v", err) + } + var names []string + var walk func([]protocol.DocumentSymbol) + walk = func(syms []protocol.DocumentSymbol) { + for _, ds := range syms { + names = append(names, ds.Name) + walk(ds.Children) + } + } + for _, v := range out { + ds := v.(protocol.DocumentSymbol) + names = append(names, ds.Name) + walk(ds.Children) + } + joined := strings.Join(names, " ") + if !strings.Contains(joined, "own") || !strings.Contains(joined, "inherited") { + t.Errorf("document symbols = %v, want the body declarations own and inherited", names) + } +} + +func TestMetadataBodySemanticTokens(t *testing.T) { + s, name := openMetadataBodyDoc(t) + + toks, err := s.SemanticTokensFull(context.Background(), &protocol.SemanticTokensParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + }) + if err != nil { + t.Fatalf("SemanticTokensFull err = %v", err) + } + content := []byte(metadataBodySrc) + covered := map[int]bool{} + line, char := uint32(0), uint32(0) + for i := 0; i+4 < len(toks.Data); i += 5 { + line += toks.Data[i] + if toks.Data[i] == 0 { + char += toks.Data[i+1] + } else { + char = toks.Data[i+1] + } + covered[positionToOffset(content, protocol.Position{Line: line, Character: char})] = true + } + for _, anchor := range []string{"own = outer", "outer;\n\t\tinherited"} { + if !covered[strings.Index(metadataBodySrc, anchor)] { + t.Errorf("no semantic token at %q", anchor) + } + } +} diff --git a/internal/frontend/lsp/modeledit.go b/internal/frontend/lsp/modeledit.go new file mode 100644 index 0000000000..583408ec5a --- /dev/null +++ b/internal/frontend/lsp/modeledit.go @@ -0,0 +1,564 @@ +package lsp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strings" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + + modeledit "github.com/Open-MBEE/OpenSysML/internal/check/edit" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// MethodApplyModelEdit turns diagram actions into a WorkspaceEdit the client +// applies to the document and to every other document the edit reached: the +// server rewrites nothing itself. +const MethodApplyModelEdit = "opensysml/applyModelEdit" + +// The operation kinds a modelEditOperation names. +const ( + EditSetValue = "setValue" + EditRename = "rename" + EditAddMember = "addMember" + EditAddConnection = "addConnection" + EditDelete = "delete" + EditMove = "move" + EditSetLayout = "setLayout" + EditSetRoute = "setRoute" + EditSetCanvas = "setCanvas" +) + +// applyModelEditParams asks for the operations to be applied to the document as +// the client holds it, at the version the client last sent. +type applyModelEditParams struct { + TextDocument protocol.TextDocumentIdentifier `json:"textDocument"` + Version int `json:"version"` + Operations []modelEditOperation `json:"operations"` +} + +// modelEditOperation is one modeledit.Operation on the wire. Kind selects the +// operation; the other fields are read as that operation reads them. Elements +// are named by qualified name, as a rendering's nodes report them. +// +// The DiagramLayout kinds place what a rendering draws: setLayout writes the +// Layout of the node Target, setRoute the Route of the edge Target, setCanvas +// the Canvas of the view Target. A setLayout or setRoute may give Declaration +// instead of Target, the range a rendering reports for a node or edge no +// qualified name reaches. DeclaredIn names the document declaring the target — +// the document the range is one of, the one Target must be declared in — or is +// empty for the requested one; Digest is the origin's digest of that document's +// text, and a target of another document is refused as stale when that text has +// changed since it was rendered. View names the view whose body states a +// Layout or Route, so it applies in that view alone; left empty, the annotation +// goes inline into Target's declaration and applies in every view. A setLayout +// with no Layout, a setRoute with no or an empty Route and a setCanvas with no +// Canvas clear the annotation. +type modelEditOperation struct { + Kind string `json:"kind"` + Target string `json:"target,omitempty"` + Declaration *protocol.Range `json:"declaration,omitempty"` + DeclaredIn protocol.DocumentURI `json:"declaredIn,omitempty"` + Digest string `json:"digest,omitempty"` + Value string `json:"value,omitempty"` + NewName string `json:"newName,omitempty"` + Owner string `json:"owner,omitempty"` + MemberKind string `json:"memberKind,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Multiplicity string `json:"multiplicity,omitempty"` + Specializes []string `json:"specializes,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Cascade bool `json:"cascade,omitempty"` + View string `json:"view,omitempty"` + Layout *modelEditLayout `json:"layout,omitempty"` + Route []renderPoint `json:"route,omitempty"` + Canvas *renderCanvas `json:"canvas,omitempty"` +} + +// modelEditLayout is a node's geometry as setLayout writes it, in the units +// opensysml/render reports: pixels, y down. Width and Height are written both +// or neither; Collapsed is written only when set. +type modelEditLayout struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Width *float64 `json:"width,omitempty"` + Height *float64 `json:"height,omitempty"` + Collapsed bool `json:"collapsed,omitempty"` +} + +// applyModelEditResult is exactly one of: an edit to apply, the refusals that +// kept the model as it was, or a stale version. Version is the document version +// the answer was made at, which tells a stale client how far behind it was. The +// edit holds one versioned TextDocumentEdit per document it rewrites, the +// requested document first when it is among them; the others carry the versions +// the server holds, null for one it read from disk, so a client refuses to apply +// them to text that has moved on rather than land them wrong. +type applyModelEditResult struct { + Edit *protocol.WorkspaceEdit `json:"edit,omitempty"` + Refused []modelEditRefusal `json:"refused,omitempty"` + Stale bool `json:"stale,omitempty"` + Version int `json:"version"` +} + +// modelEditRefusal is why an operation was not applied. Operation is its index +// in the request, or -1 when the edited model as a whole was refused. Referring +// names the declarations that refer to a target whose delete or rename was +// refused, qualified by document when that is another; Referrers tells each +// name from its document, so a client can list them by file. +type modelEditRefusal struct { + Operation int `json:"operation"` + Failure string `json:"failure"` + Message string `json:"message"` + Diagnostics []protocol.Diagnostic `json:"diagnostics,omitempty"` + Referring []string `json:"referring,omitempty"` + Referrers []modelEditReferrer `json:"referrers,omitempty"` +} + +// modelEditReferrer is one referring declaration and the document declaring it. +type modelEditReferrer struct { + Name string `json:"name"` + URI protocol.DocumentURI `json:"uri"` +} + +// editPalette lists the declarations a diagram of one rendering kind offers to +// add, in the document's language; Typed are the Members that take a type. +// Owners lists, for each Member only some bodies offer (`subject`) and for the +// notation of each drawn declaration that is such a member, the nodes whose +// declaration offers it; a kind absent from Owners goes into any node. +type editPalette struct { + Members []string `json:"members"` + Connections []string `json:"connections"` + Typed []string `json:"typed"` + Owners map[string][]string `json:"owners,omitempty"` +} + +// declaredNode is a drawn node the document declares, for admission once every +// confined kind of the rendering is known. +type declaredNode struct { + id string + decl ast.Node +} + +// confine lists kind in Owners when only some bodies offer it, so a move of a +// node declared with kind learns where it may go. +func (p *editPalette) confine(kind string) { + if _, listed := p.Owners[kind]; listed || !modeledit.MemberKindOwnerBound(kind) { + return + } + if p.Owners == nil { + p.Owners = map[string][]string{} + } + p.Owners[kind] = []string{} +} + +// admit records that the node with id, declared by decl, may own the members +// only some bodies offer. +func (p *editPalette) admit(id string, decl ast.Node) { + for kind, ids := range p.Owners { + if modeledit.MemberKindAdmittedBy(decl, kind) { + p.Owners[kind] = append(ids, id) + } + } +} + +// modelEditHandler dispatches opensysml/applyModelEdit and passes everything +// else on. +func (s *Server) modelEditHandler(inner jsonrpc2.Handler) jsonrpc2.Handler { + return func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + if req.Method() != MethodApplyModelEdit { + return inner(ctx, reply, req) + } + var params applyModelEditParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(ctx, nil, fmt.Errorf("%s: %w", jsonrpc2.ErrParse, err)) + } + result, err := s.ApplyModelEdit(¶ms) + if err != nil { + return reply(ctx, nil, err) + } + return reply(ctx, result, nil) + } +} + +// ApplyModelEdit answers opensysml/applyModelEdit: the edits that make the +// document say what the operations ask, or why it cannot. The document is read +// at the version the client named; any other version is reported stale, since +// an edit computed against text the client no longer has would land wrong. The +// other documents a rename, a delete or a layout operation reaches are read at +// the versions the server holds, which their edits carry; a document read for a +// target's declaration and left as it was carries its version with no edits, so +// that the client refuses the edit once that document has moved on too. +func (s *Server) ApplyModelEdit(params *applyModelEditParams) (*applyModelEditResult, error) { + name := uriToName(params.TextDocument.URI) + doc := s.ws.Document(name) + if doc == nil { + return nil, fmt.Errorf("%s: no such document", name) + } + if doc.Version != params.Version { + return &applyModelEditResult{Stale: true, Version: doc.Version}, nil + } + ops := make([]modeledit.Operation, 0, len(params.Operations)) + var read []*model.Document + for i, op := range params.Operations { + converted, declaring, err := s.operation(doc, op) + var stale *model.StaleError + if errors.As(err, &stale) { + return &applyModelEditResult{Stale: true, Version: doc.Version}, nil + } + if err != nil { + return nil, fmt.Errorf("%s: operation %d: %w", jsonrpc2.ErrInvalidParams, i, err) + } + ops = append(ops, converted) + if declaring != nil { + read = append(read, declaring) + } + } + result, version, ok, err := s.ws.ApplyEdit(name, ops, read) + if !ok { + return nil, fmt.Errorf("%s: no such document", name) + } + var stale *model.StaleError + if version != params.Version || errors.As(err, &stale) { + return &applyModelEditResult{Stale: true, Version: version}, nil + } + if err != nil { + var refusal *modeledit.Error + if !errors.As(err, &refusal) { + return nil, err + } + return &applyModelEditResult{Refused: []modelEditRefusal{s.refusal(refusal, doc.Content)}, Version: version}, nil + } + changes := make([]protocol.TextDocumentEdit, 0, len(result.Documents)) + for _, edited := range result.Documents { + change, err := documentChange(edited) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + return &applyModelEditResult{ + Version: version, + Edit: &protocol.WorkspaceEdit{DocumentChanges: changes}, + }, nil +} + +// documentChange is the versioned edit turning one document into its rewrite, +// computed from the content the rewrite was made of; no edits when the two are +// the same. A document read from disk has no client version, which the null +// version says. +func documentChange(edited model.DocumentEdit) (protocol.TextDocumentEdit, error) { + change := protocol.TextDocumentEdit{ + TextDocument: protocol.OptionalVersionedTextDocumentIdentifier{ + TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: nameToURI(edited.Name)}, + }, + Edits: textEdits(edited.Original, edited.Content), + } + if !edited.Open { + return change, nil + } + if edited.Version > math.MaxInt32 { + return protocol.TextDocumentEdit{}, fmt.Errorf("%s: document version %d exceeds int32", edited.Name, edited.Version) + } + v := int32(edited.Version) // #nosec G115 -- bounds checked above; the client sent it as int32. + change.TextDocument.Version = &v + return change, nil +} + +// operation reads a wire operation of doc as the edit operation it names, its +// target declared in the document DeclaredIn names: a Declaration a range of +// that document's text, a Target a name it declares. Another document than doc +// is read at the text the operation's digest names, and returned as the +// snapshot the target was read in, so that the edit can be pinned to it; a +// digest of other text is a *model.StaleError, since the range may fall on, or +// the name reach, another declaration now. +func (s *Server) operation(doc *model.Document, op modelEditOperation) (modeledit.Operation, *model.Document, error) { + if op.DeclaredIn == "" { + converted, err := op.operation(doc.Content) + return converted, nil, err + } + name := uriToName(op.DeclaredIn) + if name == doc.Name { + converted, err := op.operation(doc.Content) + if err != nil { + return modeledit.Operation{}, nil, err + } + return converted.DeclaredIn(name), nil, nil + } + if op.Digest == "" { + return modeledit.Operation{}, nil, fmt.Errorf("a target declared in %s, which declaredIn names, needs the digest of the text it was rendered from", op.DeclaredIn) + } + // A library document is never rewritten, so there is no snapshot to pin. + pinned := s.ws.Document(name) + declaring := pinned + if declaring == nil { + declaring = s.ws.LibraryDocument(name) + } + if declaring == nil { + return modeledit.Operation{}, nil, fmt.Errorf("%s, which declaredIn names, is no document the server holds", op.DeclaredIn) + } + if declaring.Digest() != op.Digest { + return modeledit.Operation{}, nil, &model.StaleError{Name: name} + } + converted, err := op.operation(declaring.Content) + if err != nil { + return modeledit.Operation{}, nil, err + } + return converted.DeclaredIn(name), pinned, nil +} + +// operation reads the wire operation as the edit operation it names; content +// is the document a Declaration range is a range of. +func (op modelEditOperation) operation(content []byte) (modeledit.Operation, error) { + if op.Declaration != nil && op.Kind != EditSetLayout && op.Kind != EditSetRoute { + return modeledit.Operation{}, fmt.Errorf("a declaration stands in for the target of a %s or %s alone", EditSetLayout, EditSetRoute) + } + if op.Declaration != nil && op.Target != "" { + return modeledit.Operation{}, errors.New("an operation targets its element by name or by declaration, not both") + } + if op.DeclaredIn != "" && op.Kind != EditSetLayout && op.Kind != EditSetRoute { + return modeledit.Operation{}, fmt.Errorf("declaredIn names the document declaring the target of a %s or %s alone", EditSetLayout, EditSetRoute) + } + switch op.Kind { + case EditSetValue: + return modeledit.SetValue(op.Target, op.Value), nil + case EditRename: + return modeledit.Rename(op.Target, op.NewName), nil + case EditAddMember: + out := modeledit.AddMember(op.Owner, op.MemberKind, op.Name) + out.Type, out.Multiplicity, out.Value, out.Specializes = op.Type, op.Multiplicity, op.Value, op.Specializes + return out, nil + case EditAddConnection: + out := modeledit.AddConnection(op.Owner, op.MemberKind, op.From, op.To, op.Name) + out.Type = op.Type + return out, nil + case EditDelete: + return modeledit.Delete(op.Target, op.Cascade), nil + case EditMove: + return modeledit.Move(op.Target, op.Owner), nil + case EditSetLayout: + layout, err := op.Layout.layout() + if err != nil { + return modeledit.Operation{}, err + } + if op.Declaration != nil { + return modeledit.SetLayoutAt(rangeToSpan(content, *op.Declaration), op.View, layout), nil + } + return modeledit.SetLayout(op.Target, op.View, layout), nil + case EditSetRoute: + var route *semantics.Route + if len(op.Route) > 0 { + route = &semantics.Route{Points: make([]semantics.Waypoint, len(op.Route))} + for i, p := range op.Route { + route.Points[i] = semantics.Waypoint{X: p.X, Y: p.Y} + } + } + if op.Declaration != nil { + return modeledit.SetRouteAt(rangeToSpan(content, *op.Declaration), op.View, route), nil + } + return modeledit.SetRoute(op.Target, op.View, route), nil + case EditSetCanvas: + canvas, err := op.Canvas.canvas() + if err != nil { + return modeledit.Operation{}, err + } + return modeledit.SetCanvas(op.Target, canvas), nil + } + return modeledit.Operation{}, fmt.Errorf("kind %q is none of %s", op.Kind, + strings.Join([]string{EditSetValue, EditRename, EditAddMember, EditAddConnection, EditDelete, EditMove, EditSetLayout, EditSetRoute, EditSetCanvas}, ", ")) +} + +// layout reads the wire geometry as the edit layer writes it; nil clears. +func (l *modelEditLayout) layout() (*semantics.Layout, error) { + if l == nil { + return nil, nil + } + if (l.Width == nil) != (l.Height == nil) { + return nil, errors.New("a layout sizes its node with both width and height or with neither") + } + out := &semantics.Layout{X: l.X, Y: l.Y, Collapsed: l.Collapsed} + if l.Width != nil { + out.Width, out.Height, out.HasSize = *l.Width, *l.Height, true + } + return out, nil +} + +// canvas reads the wire canvas as the edit layer writes it; nil clears. +func (c *renderCanvas) canvas() (*semantics.Canvas, error) { + if c == nil { + return nil, nil + } + if (c.Width == nil) != (c.Height == nil) { + return nil, errors.New("a canvas sizes the drawing surface with both width and height or with neither") + } + out := &semantics.Canvas{Unit: c.Unit} + if c.Width != nil { + out.Width, out.Height, out.HasSize = *c.Width, *c.Height, true + } + return out, nil +} + +// refusal reports an edit refusal to the client. Diagnostics of the edited +// notation are located in it, not in the document the client holds, so they are +// carried as messages with their ranges in the text that was refused. +func (s *Server) refusal(e *modeledit.Error, content []byte) modelEditRefusal { + out := modelEditRefusal{ + Operation: e.OperationIndex, + Failure: e.Failure.String(), + Message: e.Message, + Referring: e.Referring, + } + for _, r := range e.Referrers { + out.Referrers = append(out.Referrers, modelEditReferrer{Name: r.Name, URI: nameToURI(r.Document)}) + } + diagnosed := content + if e.Diagnosed != nil { + diagnosed = e.Diagnosed.Bytes() + } + pos := positionsFor(diagnosed) + for _, d := range e.Diagnostics { + out.Diagnostics = append(out.Diagnostics, protocol.Diagnostic{ + Range: pos.rangeOf(d.Span), + Severity: protocol.DiagnosticSeverity(int(d.Severity) + 1), + Message: d.Message, + Code: d.Code, + Source: d.Source, + }) + } + return out +} + +// textEdits returns the edits turning content into edited, one per run of +// changed lines, so an insertion into a body is one edit at the body's end and +// a rename is one edit per line it touched. +func textEdits(content, edited []byte) []protocol.TextEdit { + oldLines, oldOffsets := cutLines(content) + newLines, _ := cutLines(edited) + table := newLineTable(content, oldOffsets) + ids := map[string]int{} + intern := func(lines []string) []int { + out := make([]int, len(lines)) + for i, line := range lines { + id, ok := ids[line] + if !ok { + id = len(ids) + ids[line] = id + } + out[i] = id + } + return out + } + edits := []protocol.TextEdit{} + for _, h := range diffLines(intern(oldLines), intern(newLines)) { + start, end := oldOffsets[h.oldStart], oldOffsets[h.oldEnd] + text := strings.Join(newLines[h.newStart:h.newEnd], "") + if h.oldEnd-h.oldStart == 1 && h.newEnd-h.newStart == 1 { + from, to, replacement, _ := trimCommon(oldLines[h.oldStart], newLines[h.newStart]) + start, end, text = start+from, start+to, replacement + } + edits = append(edits, protocol.TextEdit{ + Range: protocol.Range{Start: table.position(start), End: table.position(end)}, + NewText: text, + }) + } + return edits +} + +// palette is what a diagram of kind offers to add in lang: the member and +// connection kinds it draws. A table draws rows, not nodes, so it offers none. +func palette(kind view.Kind, lang source.Kind) *editPalette { + members, connections := modeledit.MemberKinds(lang), modeledit.ConnectionKinds(lang) + keep := func(list []string, want func(string) bool) []string { + out := []string{} + for _, item := range list { + if want(item) { + out = append(out, item) + } + } + return out + } + anyOf := func(names ...string) func(string) bool { + return func(item string) bool { + for _, name := range names { + if item == name { + return true + } + } + return false + } + } + var p *editPalette + switch kind { + case view.KindInterconnection: + p = &editPalette{ + Members: keep(members, anyOf("part", "port", "item", "attribute", "feature")), + Connections: keep(connections, anyOf("connection", "interface", "flow", "binding", "allocation", "connector")), + } + case view.KindState: + p = &editPalette{ + Members: keep(members, anyOf("state")), + Connections: keep(connections, anyOf("transition", "succession")), + } + case view.KindAction, view.KindSequence: + p = &editPalette{ + Members: keep(members, anyOf("action", "fork", "join", "merge", "decide", "step", "item")), + Connections: keep(connections, anyOf("succession", "flow")), + } + case view.KindTree: + p = &editPalette{Members: members, Connections: connections} + default: + return nil + } + p.Typed = keep(p.Members, modeledit.MemberKindTyped) + for _, kind := range keep(p.Members, modeledit.MemberKindOwnerBound) { + if p.Owners == nil { + p.Owners = map[string][]string{} + } + p.Owners[kind] = []string{} + } + return p +} + +// nodeOwners lists the namespaces declaring sym, nearest first, as an edit names +// them; false when sym or a namespace declaring it is unnamed, so no qualified +// name reaches it. +func nodeOwners(sym *symbols.Symbol) ([]renderOwner, bool) { + if sym.Name == "" { + return nil, false + } + owners := []renderOwner{} + for scope := sym.OwnerScope; scope != nil && scope.Owner() != nil; scope = scope.Owner().OwnerScope { + owner := scope.Owner() + if owner.Name == "" { + return nil, false + } + owners = append(owners, renderOwner{FQN: notationName(owner), Feature: owner.IsFeature()}) + } + return owners, true +} + +// notationName spells sym's qualified name as the notation does, each name +// quoted on its own, which is how opensysml/applyModelEdit reads a target. +func notationName(sym *symbols.Symbol) string { + return source.QualifiedNameOf(symbols.NameChain(sym)) +} + +// nodeSymbol is the declaration a rendering node or edge was built from, named +// or not, in doc, the document declaring it; nil for no document, or for a +// lowering sequenced without a declaration of its own. +func nodeSymbol(doc *model.Document, o view.Origin) *symbols.Symbol { + if doc == nil || doc.Scope == nil { + return nil + } + return doc.Scope.DeclaredAt(o.Span) +} diff --git a/internal/frontend/lsp/modeledit_cross_document_test.go b/internal/frontend/lsp/modeledit_cross_document_test.go new file mode 100644 index 0000000000..53a4be1cb6 --- /dev/null +++ b/internal/frontend/lsp/modeledit_cross_document_test.go @@ -0,0 +1,571 @@ +package lsp + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" +) + +// The two documents of a workspace whose view draws what the other declares. +const ( + engineParts = "package Machinery {\n\t// The engine and what turns in it.\n\tpart def Engine {\n\t\tpart rotor;\n\t\tpart stator;\n\t\tconnection connect rotor to stator;\n\t}\n}\n" + + engineViews = "package EngineViews {\n\tprivate import Views::*;\n\tprivate import StandardViewDefinitions::*;\n\n\tview engineView : InterconnectionView {\n\t\texpose Machinery::Engine;\n\t}\n}\n" +) + +// engineWorkspace serves views.sysml, opened first, and parts.sysml. +func engineWorkspace(t *testing.T) (s *Server, viewsURI, partsURI uri.URI) { + t.Helper() + s, viewsURI = renderServer(t, "views.sysml", engineViews) + partsURI = uri.File("parts.sysml") + openDoc(t, s, partsURI, engineParts) + return s, viewsURI, partsURI +} + +// nodeNamed is the rendered node of that name. +func nodeNamed(t *testing.T, r *renderResult, name string) renderNode { + t.Helper() + for _, n := range r.Nodes { + if n.Name == name { + return n + } + } + t.Fatalf("no node %q among %+v", name, r.Nodes) + return renderNode{} +} + +// tellChanged sends the document's new text to the server at version. +func tellChanged(t *testing.T, s *Server, docURI uri.URI, content string, version int32) { + t.Helper() + encoded, _ := json.Marshal(map[string]string{"text": content}) + sendDidChange(t, s, docURI, version, []json.RawMessage{encoded}) +} + +// A node drawn from another document carries what a layout operation targets +// it by — its qualified name and owners, else its declaration range — and is +// located in that document, whose range the declaration is. +func TestRenderLocatesNodesInTheDocumentDeclaringThem(t *testing.T) { + s, viewsURI, partsURI := engineWorkspace(t) + drawn := render(t, s, viewsURI, "EngineViews::engineView") + rotor := nodeNamed(t, drawn, "rotor") + if rotor.FQN != "Machinery::Engine::rotor" || rotor.Notation != "part" { + t.Errorf("rotor = %+v, want its qualified name and notation", rotor) + } + if want := []renderOwner{{FQN: "Machinery::Engine"}, {FQN: "Machinery"}}; !reflect.DeepEqual(rotor.Owners, want) { + t.Errorf("rotor owners = %+v, want %+v", rotor.Owners, want) + } + if rotor.Origin == nil || rotor.Origin.URI != partsURI { + t.Fatalf("rotor origin = %+v, want one in %s", rotor.Origin, partsURI) + } + if got := rotor.Origin.Range.Start; got != (protocol.Position{Line: 3, Character: 2}) { + t.Errorf("rotor is located at %+v of parts.sysml, want line 3 where `part rotor;` is", got) + } + if rotor.Declaration != nil { + t.Errorf("rotor has a declaration range %+v besides its fqn", *rotor.Declaration) + } + if len(drawn.Edges) != 1 { + t.Fatalf("edges = %+v, want the one connection", drawn.Edges) + } + edge := drawn.Edges[0] + if edge.FQN != "" || edge.Declaration == nil || edge.Origin == nil || edge.Origin.URI != partsURI { + t.Fatalf("edge = %+v, want an unnamed one located in parts.sysml with a declaration range", edge) + } + if *edge.Declaration != edge.Origin.Range || edge.Declaration.Start.Line != 5 { + t.Errorf("edge declaration = %+v, want the origin's range on line 5 of parts.sysml", *edge.Declaration) + } + engine := nodeNamed(t, drawn, "Machinery::Engine") + if engine.FQN != "Machinery::Engine" || engine.Origin == nil || engine.Origin.URI != partsURI { + t.Errorf("Engine = %+v, want its fqn, located in parts.sysml", engine) + } + if rotor.DeclaredHere || engine.DeclaredHere { + t.Errorf("rotor %v, Engine %v: another document's declarations are marked as views.sysml's own", rotor.DeclaredHere, engine.DeclaredHere) + } +} + +// Which operations reach a node is stated, not inferred from its qualified +// name: the requested document's own declarations are marked so, another +// workspace document's carry a name for a layout alone, and a bundled library's +// carry none, only where to navigate to. +func TestRenderMarksWhatEachDocumentsNodesAdmit(t *testing.T) { + const views = "package EngineViews {\n\tprivate import Views::*;\n\tprivate import StandardViewDefinitions::*;\n\n\tpart def Mount;\n\tview engineView : InterconnectionView {\n\t\texpose Mount;\n\t\texpose Machinery::Engine;\n\t\texpose Parts::Part;\n\t}\n}\n" + s, viewsURI := renderServer(t, "views.sysml", views) + partsURI := uri.File("parts.sysml") + openDoc(t, s, partsURI, engineParts) + drawn := render(t, s, viewsURI, "EngineViews::engineView") + + mount := nodeNamed(t, drawn, "EngineViews::Mount") + if mount.FQN != "EngineViews::Mount" || !mount.DeclaredHere || mount.Origin == nil || mount.Origin.URI != viewsURI { + t.Errorf("Mount = %+v, want its fqn marked as views.sysml's own", mount) + } + rotor := nodeNamed(t, drawn, "rotor") + if rotor.FQN != "Machinery::Engine::rotor" || rotor.DeclaredHere || rotor.Origin == nil || rotor.Origin.URI != partsURI { + t.Errorf("rotor = %+v, want its fqn, not marked as views.sysml's own", rotor) + } + libraryURI := libraryURI("Systems Library/Parts.sysml") + var library []renderNode + for _, n := range drawn.Nodes { + if n.Origin != nil && n.Origin.URI == libraryURI { + library = append(library, n) + } + } + if len(library) == 0 { + t.Fatalf("nodes = %+v, want some located in the bundled Parts.sysml", drawn.Nodes) + } + for _, n := range library { + if n.FQN != "" || n.Declaration != nil || n.DeclaredHere || n.Notation != "" || n.Owners != nil { + t.Errorf("library node %+v, want its origin alone: no operation reaches a bundled library file", n) + } + if n.Origin.Digest == "" { + t.Errorf("library node %q carries no digest", n.Name) + } + } + for _, e := range drawn.Edges { + if e.Origin != nil && e.Origin.URI == libraryURI && (e.FQN != "" || e.Declaration != nil) { + t.Errorf("library edge %+v, want its origin alone", e) + } + } +} + +// A client that did not advertise the cross-document contract reads every named +// node as the requested document's own and places it by name alone, so another +// document's declarations are located for it but named to it no more than +// before: it can neither drag them unpinned nor offer them a rename or a delete. +func TestRenderNamesOnlyTheRequestedDocumentsDeclarationsToAClientWithoutTheCapability(t *testing.T) { + const views = "package EngineViews {\n\tprivate import Views::*;\n\tprivate import StandardViewDefinitions::*;\n\n\tpart def Mount;\n\tview engineView : InterconnectionView {\n\t\texpose Mount;\n\t\texpose Machinery::Engine;\n\t}\n}\n" + s := initializedServer(t, nil) + viewsURI, partsURI := uri.File("views.sysml"), uri.File("parts.sysml") + openDoc(t, s, viewsURI, views) + openDoc(t, s, partsURI, engineParts) + drawn := render(t, s, viewsURI, "EngineViews::engineView") + + mount := nodeNamed(t, drawn, "EngineViews::Mount") + if mount.FQN != "EngineViews::Mount" || !mount.DeclaredHere || mount.Notation != "part def" { + t.Errorf("Mount = %+v, want views.sysml's own declaration named as ever", mount) + } + for _, name := range []string{"Machinery::Engine", "rotor", "stator"} { + n := nodeNamed(t, drawn, name) + if n.Origin == nil || n.Origin.URI != partsURI || n.Origin.Digest == "" { + t.Errorf("%s = %+v, want it located in parts.sysml", name, n) + } + if n.FQN != "" || n.Declaration != nil || n.DeclaredHere || n.Notation != "" || n.Owners != nil { + t.Errorf("%s = %+v, want another document's declaration unnamed to a client that would not pin it", name, n) + } + } + if len(drawn.Edges) != 1 { + t.Fatalf("edges = %+v, want the one connection", drawn.Edges) + } + if e := drawn.Edges[0]; e.FQN != "" || e.Declaration != nil || e.Origin == nil || e.Origin.URI != partsURI { + t.Errorf("edge = %+v, want parts.sysml's connection located there and unnamed", e) + } + + // The same session, with the client's capability, names them all. + s.setCrossDocument(true) + named := render(t, s, viewsURI, "EngineViews::engineView") + if rotor := nodeNamed(t, named, "rotor"); rotor.FQN != "Machinery::Engine::rotor" || rotor.DeclaredHere { + t.Errorf("rotor = %+v, want its fqn for a client that pins it", rotor) + } + if e := named.Edges[0]; e.Declaration == nil { + t.Errorf("edge = %+v, want its declaration range for a client that pins it", e) + } +} + +// Dragging a node the view draws from another document writes the Layout into +// the view's own body: one change, to the rendered document alone. +func TestApplyModelEditSetLayoutInViewOfAnotherDocumentsElement(t *testing.T) { + s, viewsURI, partsURI := engineWorkspace(t) + drawn := render(t, s, viewsURI, "EngineViews::engineView") + rotor := nodeNamed(t, drawn, "rotor") + drag := modelEditOperation{Kind: EditSetLayout, Target: rotor.FQN, View: drawn.View, Layout: &modelEditLayout{X: 120, Y: 40}} + out := applyModelEdit(t, s, viewsURI, drawn.Version, drag) + applied, redrawn := redraw(t, s, viewsURI, engineViews, out, 2, drawn.View) + if want := golden(t, s, viewsURI.Filename(), drag); applied != want { + t.Errorf("applied edit:\n%s\nwant:\n%s", applied, want) + } + if !strings.Contains(applied, "\t\texpose Machinery::Engine;\n\t\tmetadata DiagramLayout::Layout about Machinery::Engine::rotor { x = 120; y = 40; }\n\t}\n") { + t.Errorf("Layout not stated in the view body:\n%s", applied) + } + if string(s.ws.Document(partsURI.Filename()).Content) != engineParts { + t.Error("parts.sysml changed for a view-local layout") + } + placed := nodeNamed(t, redrawn, "rotor") + if placed.X == nil || placed.Y == nil || *placed.X != 120 || *placed.Y != 40 { + t.Errorf("redrawn rotor = %+v, want x 120, y 40", placed) + } +} + +// One drag may write into several documents: a Layout into the view's body and +// a Route inline into the connection another document declares, targeted by +// the declaration range of that document. The edit holds one versioned change +// per document, the requested one first, each at the version the server holds. +func TestApplyModelEditLayoutReachesSeveralDocumentsInOneRequest(t *testing.T) { + s, viewsURI, partsURI := engineWorkspace(t) + tellChanged(t, s, partsURI, engineParts, 4) + drawn := render(t, s, viewsURI, "EngineViews::engineView") + rotor := nodeNamed(t, drawn, "rotor") + edge := drawn.Edges[0] + + out := applyModelEdit(t, s, viewsURI, drawn.Version, + modelEditOperation{Kind: EditSetLayout, Target: rotor.FQN, View: drawn.View, Layout: &modelEditLayout{X: 5, Y: 6}}, + modelEditOperation{Kind: EditSetRoute, Declaration: edge.Declaration, DeclaredIn: protocol.DocumentURI(edge.Origin.URI), Digest: edge.Origin.Digest, + Route: []renderPoint{{X: 30, Y: 90}, {X: 30, Y: 10}}}, + ) + if out.Edit == nil || out.Refused != nil || out.Stale { + t.Fatalf("result = %+v, want an edit", out) + } + if got := documentURIs(out.Edit); !reflect.DeepEqual(got, []uri.URI{viewsURI, partsURI}) { + t.Fatalf("documentChanges = %v, want views.sysml then parts.sysml", got) + } + viewsChange := documentChangeFor(t, out.Edit, viewsURI) + if v := viewsChange.TextDocument.Version; v == nil || *v != 1 { + t.Errorf("views version = %v, want the request's 1", v) + } + partsChange := documentChangeFor(t, out.Edit, partsURI) + if v := partsChange.TextDocument.Version; v == nil || *v != 4 { + t.Errorf("parts version = %v, want the server's 4", v) + } + views := applyDocumentChange(t, engineViews, viewsChange) + if !strings.Contains(views, "\t\tmetadata DiagramLayout::Layout about Machinery::Engine::rotor { x = 5; y = 6; }\n") { + t.Errorf("views.sysml:\n%s", views) + } + parts := applyDocumentChange(t, engineParts, partsChange) + wantParts := strings.Replace(engineParts, "\t\tconnection connect rotor to stator;\n", + "\t\tconnection connect rotor to stator {\n\t\t\t@DiagramLayout::Route { points = (30, 90, 30, 10); }\n\t\t}\n", 1) + if parts != wantParts { + t.Errorf("parts.sysml:\n--- want\n%s\n--- got\n%s", wantParts, parts) + } + + tellChanged(t, s, viewsURI, views, 2) + tellChanged(t, s, partsURI, parts, 5) + redrawn := render(t, s, viewsURI, drawn.View) + placed := nodeNamed(t, redrawn, "rotor") + if placed.X == nil || *placed.X != 5 || placed.Y == nil || *placed.Y != 6 { + t.Errorf("redrawn rotor = %+v, want x 5, y 6", placed) + } + if got := redrawn.Edges[0].Route; len(got) != 2 || got[0] != (renderPoint{X: 30, Y: 90}) || got[1] != (renderPoint{X: 30, Y: 10}) { + t.Errorf("redrawn route = %+v", got) + } + if redrawn.Edges[0].Declaration == nil || redrawn.Edges[0].Declaration.Start.Line != 5 { + t.Errorf("redrawn edge = %+v, want its declaration still on line 5 of parts.sysml", redrawn.Edges[0]) + } + + // Dragging again updates the annotations in place, and clearing removes them with their lines. + out = applyModelEdit(t, s, viewsURI, 2, + modelEditOperation{Kind: EditSetLayout, Target: rotor.FQN, View: drawn.View, Layout: &modelEditLayout{X: 7, Y: 8}}, + modelEditOperation{Kind: EditSetRoute, Declaration: redrawn.Edges[0].Declaration, DeclaredIn: protocol.DocumentURI(partsURI), Digest: redrawn.Edges[0].Origin.Digest, + Route: []renderPoint{{X: 1, Y: 2}}}, + ) + if out.Edit == nil || out.Refused != nil { + t.Fatalf("second drag = %+v, want an edit", out) + } + views = applyDocumentChange(t, views, documentChangeFor(t, out.Edit, viewsURI)) + parts = applyDocumentChange(t, parts, documentChangeFor(t, out.Edit, partsURI)) + if strings.Count(views, "DiagramLayout::Layout") != 1 || !strings.Contains(views, "{ x = 7; y = 8; }") { + t.Errorf("views.sysml after the second drag:\n%s", views) + } + if strings.Count(parts, "DiagramLayout::Route") != 1 || !strings.Contains(parts, "points = (1, 2);") { + t.Errorf("parts.sysml after the second drag:\n%s", parts) + } + tellChanged(t, s, viewsURI, views, 3) + tellChanged(t, s, partsURI, parts, 6) + redrawn = render(t, s, viewsURI, drawn.View) + out = applyModelEdit(t, s, viewsURI, 3, + modelEditOperation{Kind: EditSetLayout, Target: rotor.FQN, View: drawn.View}, + modelEditOperation{Kind: EditSetRoute, Declaration: redrawn.Edges[0].Declaration, DeclaredIn: protocol.DocumentURI(partsURI), Digest: redrawn.Edges[0].Origin.Digest}, + ) + if out.Edit == nil || out.Refused != nil { + t.Fatalf("clearing = %+v, want an edit", out) + } + if got := applyDocumentChange(t, views, documentChangeFor(t, out.Edit, viewsURI)); got != engineViews { + t.Errorf("clearing did not restore views.sysml:\n%s", got) + } + if got := applyDocumentChange(t, parts, documentChangeFor(t, out.Edit, partsURI)); got != engineParts { + t.Errorf("clearing did not restore parts.sysml:\n%s", got) + } +} + +// A document drawn directly places what it draws from another document inline, +// in that document: the requested document, unchanged, heads the changes with +// none, pinned at its version. A declaration range is read in the document +// declaredIn names; without it, the range is one of the requested document, +// where nothing is declared. +func TestApplyModelEditPlacesInlineIntoTheDeclaringDocument(t *testing.T) { + const motor = "package Motors {\n\tstate def Motor {\n\t\tstate off;\n\t\tstate on;\n\t\ttransition first off then on;\n\t}\n}\n" + const fleet = "package Fleet {\n\tstate motor : Motors::Motor;\n}\n" + s, fleetURI := renderServer(t, "fleet.sysml", fleet) + motorURI := uri.File("motor.sysml") + openDoc(t, s, motorURI, motor) + tellChanged(t, s, motorURI, motor, 2) + + drawn := render(t, s, fleetURI, "#state") + if drawn.View != "" { + t.Fatalf("a direct rendering names view %q", drawn.View) + } + off := nodeNamed(t, drawn, "off") + if off.FQN != "Motors::Motor::off" || off.Origin == nil || off.Origin.URI != motorURI { + t.Fatalf("off = %+v, want Motors::Motor::off located in motor.sysml", off) + } + if len(drawn.Edges) != 1 || drawn.Edges[0].Declaration == nil || drawn.Edges[0].Origin.URI != motorURI { + t.Fatalf("edges = %+v, want the unnamed transition located in motor.sysml", drawn.Edges) + } + edge := drawn.Edges[0] + + out := applyModelEdit(t, s, fleetURI, drawn.Version, + modelEditOperation{Kind: EditSetLayout, Target: off.FQN, Layout: &modelEditLayout{X: 10, Y: 20}}, + modelEditOperation{Kind: EditSetRoute, Declaration: edge.Declaration, DeclaredIn: protocol.DocumentURI(motorURI), Digest: edge.Origin.Digest, Route: []renderPoint{{X: 3, Y: 4}}}, + ) + if out.Edit == nil || out.Refused != nil || out.Stale { + t.Fatalf("result = %+v, want an edit", out) + } + if got := documentURIs(out.Edit); !reflect.DeepEqual(got, []uri.URI{fleetURI, motorURI}) { + t.Fatalf("documentChanges = %v, want fleet.sysml then motor.sysml", got) + } + pinned(t, documentChangeFor(t, out.Edit, fleetURI), drawn.Version) + change := documentChangeFor(t, out.Edit, motorURI) + if v := change.TextDocument.Version; v == nil || *v != 2 { + t.Errorf("motor version = %v, want the server's 2", v) + } + placed := applyDocumentChange(t, motor, change) + want := strings.Replace(motor, "\t\tstate off;\n", "\t\tstate off {\n\t\t\t@DiagramLayout::Layout { x = 10; y = 20; }\n\t\t}\n", 1) + want = strings.Replace(want, "\t\ttransition first off then on;\n", "\t\ttransition first off then on {\n\t\t\t@DiagramLayout::Route { points = (3, 4); }\n\t\t}\n", 1) + if placed != want { + t.Errorf("motor.sysml:\n--- want\n%s\n--- got\n%s", want, placed) + } + tellChanged(t, s, motorURI, placed, 3) + redrawn := render(t, s, fleetURI, "#state") + if redrawn.Version != drawn.Version { + t.Errorf("fleet.sysml rendered at version %d, want %d: it was not edited", redrawn.Version, drawn.Version) + } + if got := nodeNamed(t, redrawn, "off"); got.X == nil || *got.X != 10 || got.Y == nil || *got.Y != 20 { + t.Errorf("redrawn off = %+v, want x 10, y 20", got) + } + if got := redrawn.Edges[0].Route; len(got) != 1 || got[0] != (renderPoint{X: 3, Y: 4}) { + t.Errorf("redrawn route = %+v, want (3, 4)", got) + } + + unplaced := applyModelEdit(t, s, fleetURI, drawn.Version, + modelEditOperation{Kind: EditSetRoute, Declaration: edge.Declaration, Route: []renderPoint{{X: 3, Y: 4}}}) + if unplaced.Edit != nil || len(unplaced.Refused) != 1 || unplaced.Refused[0].Failure != "unknown-target" { + t.Fatalf("a declaration range of motor.sysml read in fleet.sysml: %+v, want an unknown-target refusal", unplaced) + } +} + +// A Canvas sizes the view in the view's document, wherever the request comes +// from; the requesting document, unchanged, is pinned with no edits. +func TestApplyModelEditSetCanvasOfViewInAnotherDocument(t *testing.T) { + s, viewsURI, partsURI := engineWorkspace(t) + out := applyModelEdit(t, s, partsURI, 1, + modelEditOperation{Kind: EditSetCanvas, Target: "EngineViews::engineView", Canvas: &renderCanvas{Unit: "px", Width: float(1200), Height: float(800)}}) + if out.Edit == nil || out.Refused != nil { + t.Fatalf("result = %+v, want an edit", out) + } + if got := documentURIs(out.Edit); !reflect.DeepEqual(got, []uri.URI{partsURI, viewsURI}) { + t.Fatalf("documentChanges = %v, want parts.sysml then views.sysml", got) + } + pinned(t, documentChangeFor(t, out.Edit, partsURI), 1) + sized := applyDocumentChange(t, engineViews, documentChangeFor(t, out.Edit, viewsURI)) + if !strings.Contains(sized, "\t\texpose Machinery::Engine;\n\t\t@DiagramLayout::Canvas { unit = \"px\"; width = 1200; height = 800; }\n\t}\n") { + t.Errorf("views.sysml:\n%s", sized) + } +} + +// A document the server read from disk is written at no version; a bundled +// library declaration is never written, and the refusal names its file. +func TestApplyModelEditLayoutIntoDiskAndLibraryDocuments(t *testing.T) { + s, viewsURI, _ := engineWorkspace(t) + depotName := uri.File("depot.sysml").Filename() + const depot = "package Depot {\n\tpart spare : Machinery::Engine;\n}\n" + s.ws.SetOnDisk(depotName, []byte(depot)) + + out := applyModelEdit(t, s, viewsURI, 1, + modelEditOperation{Kind: EditSetLayout, Target: "Depot::spare", Layout: &modelEditLayout{X: 1, Y: 2}}) + if out.Edit == nil || out.Refused != nil { + t.Fatalf("result = %+v, want an edit", out) + } + if got := documentURIs(out.Edit); !reflect.DeepEqual(got, []uri.URI{viewsURI, uri.File(depotName)}) { + t.Fatalf("documentChanges = %v, want views.sysml then depot.sysml", got) + } + pinned(t, documentChangeFor(t, out.Edit, viewsURI), 1) + change := documentChangeFor(t, out.Edit, uri.File(depotName)) + if change.TextDocument.Version != nil { + t.Errorf("depot version = %d, want none for a document read from disk", *change.TextDocument.Version) + } + if got := applyDocumentChange(t, depot, change); !strings.Contains(got, "\tpart spare : Machinery::Engine {\n\t\t@DiagramLayout::Layout { x = 1; y = 2; }\n\t}\n") { + t.Errorf("depot.sysml:\n%s", got) + } + + out = applyModelEdit(t, s, viewsURI, 1, + modelEditOperation{Kind: EditSetLayout, Target: "Parts::Part", Layout: &modelEditLayout{X: 1, Y: 2}}) + if out.Edit != nil || len(out.Refused) != 1 { + t.Fatalf("layout of a library declaration: %+v, want one refusal", out) + } + if r := out.Refused[0]; r.Failure != "referenced-elsewhere" || !strings.Contains(r.Message, "bundled library file Systems Library/Parts.sysml") { + t.Errorf("refusal = %+v, want referenced-elsewhere naming the library file", r) + } +} + +// Validation judges every document written: when the annotation would not +// resolve in the other document, the whole request is refused with that +// document's diagnostics, and neither document changes. +func TestApplyModelEditLayoutRefusesWhenAnotherDocumentBecomesInvalid(t *testing.T) { + shadowed := strings.Replace(engineViews, "\tview engineView", "\tpart def DiagramLayout;\n\tview engineView", 1) + s, partsURI := renderServer(t, "parts.sysml", engineParts) + viewsURI := uri.File("views.sysml") + openDoc(t, s, viewsURI, shadowed) + + out := applyModelEdit(t, s, partsURI, 1, + modelEditOperation{Kind: EditSetLayout, Target: "Machinery::Engine::stator", Layout: &modelEditLayout{X: 1, Y: 2}}, + modelEditOperation{Kind: EditSetLayout, Target: "Machinery::Engine::rotor", View: "EngineViews::engineView", Layout: &modelEditLayout{X: 5, Y: 6}}, + ) + if out.Edit != nil || len(out.Refused) != 1 { + t.Fatalf("result = %+v, want one refusal and no edit", out) + } + r := out.Refused[0] + if r.Failure != "result-invalid" || r.Operation != -1 || !strings.Contains(r.Message, "in "+viewsURI.Filename()) { + t.Errorf("refusal = %+v, want result-invalid naming views.sysml", r) + } + if len(r.Diagnostics) == 0 || !strings.Contains(r.Diagnostics[0].Message, "DiagramLayout::Layout") { + t.Errorf("diagnostics = %+v, want the unresolved DiagramLayout::Layout", r.Diagnostics) + } + if string(s.ws.Document(partsURI.Filename()).Content) != engineParts || string(s.ws.Document(viewsURI.Filename()).Content) != shadowed { + t.Error("a refused request changed a document") + } +} + +// declaredIn must name a document the server holds, accompanies a setLayout or +// setRoute alone, and naming another document must come with the digest of the +// text its target was rendered from: each fault is an invalid request, not a +// refusal. +func TestApplyModelEditRejectsMisplacedDeclaredIn(t *testing.T) { + s, viewsURI, partsURI := engineWorkspace(t) + drawn := render(t, s, viewsURI, "EngineViews::engineView") + decl := drawn.Edges[0].Declaration + digest := drawn.Edges[0].Origin.Digest + for _, tc := range []struct { + op modelEditOperation + want string + }{ + {modelEditOperation{Kind: EditSetRoute, Declaration: decl, DeclaredIn: protocol.DocumentURI(uri.File("elsewhere.sysml")), Digest: digest, Route: []renderPoint{{X: 1, Y: 2}}}, "no document the server holds"}, + {modelEditOperation{Kind: EditSetLayout, Target: "Machinery::Engine::rotor", DeclaredIn: protocol.DocumentURI(uri.File("elsewhere.sysml")), Digest: digest, Layout: &modelEditLayout{X: 1, Y: 2}}, "no document the server holds"}, + {modelEditOperation{Kind: EditRename, Target: "Machinery::Engine", DeclaredIn: protocol.DocumentURI(partsURI), Digest: digest, NewName: "Motor"}, "setLayout or setRoute alone"}, + {modelEditOperation{Kind: EditSetCanvas, Target: "EngineViews::engineView", DeclaredIn: protocol.DocumentURI(viewsURI), Canvas: &renderCanvas{Unit: "px"}}, "setLayout or setRoute alone"}, + {modelEditOperation{Kind: EditSetRoute, Declaration: decl, DeclaredIn: protocol.DocumentURI(partsURI), Route: []renderPoint{{X: 1, Y: 2}}}, "needs the digest"}, + {modelEditOperation{Kind: EditSetLayout, Target: "Machinery::Engine::rotor", DeclaredIn: protocol.DocumentURI(partsURI), Layout: &modelEditLayout{X: 1, Y: 2}}, "needs the digest"}, + } { + _, err := call(t, s, MethodApplyModelEdit, &applyModelEditParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, + Version: 1, + Operations: []modelEditOperation{tc.op}, + }) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%+v: err = %v, want an invalid-params error saying %q", tc.op, err, tc.want) + } + } + // Naming the requested document is the same as naming none. + own := render(t, s, partsURI, "#interconnection:Machinery::Engine") + if len(own.Edges) != 1 || own.Edges[0].Declaration == nil { + t.Fatalf("edges of parts.sysml drawn directly = %+v", own.Edges) + } + out := applyModelEdit(t, s, partsURI, 1, + modelEditOperation{Kind: EditSetRoute, Declaration: own.Edges[0].Declaration, DeclaredIn: protocol.DocumentURI(partsURI), Route: []renderPoint{{X: 1, Y: 2}}}) + if out.Edit == nil || out.Refused != nil { + t.Fatalf("declaredIn naming the requested document: %+v, want an edit", out) + } + if got := documentURIs(out.Edit); !reflect.DeepEqual(got, []uri.URI{partsURI}) { + t.Errorf("documentChanges = %v, want parts.sysml", got) + } +} + +// A declaration range of another document is a range of the text it was +// rendered from. When that document changes before the edit — here a line is +// added above the connection, so the range now spells a different declaration — +// the request is answered stale rather than written where the range now falls; +// the same text at a new version is not stale, since the range still holds. +func TestApplyModelEditIsStaleWhenAnotherDocumentsDeclarationMoved(t *testing.T) { + s, viewsURI, partsURI := engineWorkspace(t) + drawn := render(t, s, viewsURI, "EngineViews::engineView") + edge := drawn.Edges[0] + if edge.Declaration == nil || edge.Origin == nil || edge.Origin.URI != partsURI || edge.Origin.Digest == "" { + t.Fatalf("edge = %+v, want a declaration range of parts.sysml with its digest", edge) + } + steer := modelEditOperation{Kind: EditSetRoute, Declaration: edge.Declaration, DeclaredIn: protocol.DocumentURI(partsURI), + Digest: edge.Origin.Digest, Route: []renderPoint{{X: 1, Y: 2}}} + + shifted := strings.Replace(engineParts, "\t\tpart rotor;\n", "\t\tpart rotor;\n\t\tpart shaft;\n", 1) + tellChanged(t, s, partsURI, shifted, 2) + out := applyModelEdit(t, s, viewsURI, drawn.Version, steer) + if !out.Stale || out.Edit != nil || out.Refused != nil { + t.Fatalf("after parts.sysml moved the connection: %+v, want stale", out) + } + if out.Version != drawn.Version { + t.Errorf("stale version = %d, want the requested document's %d, which did not change", out.Version, drawn.Version) + } + if string(s.ws.Document(partsURI.Filename()).Content) != shifted { + t.Error("a stale request changed parts.sysml") + } + + tellChanged(t, s, partsURI, engineParts, 3) + out = applyModelEdit(t, s, viewsURI, drawn.Version, steer) + if out.Edit == nil || out.Stale || out.Refused != nil { + t.Fatalf("parts.sysml restored to the rendered text: %+v, want an edit", out) + } + change := documentChangeFor(t, out.Edit, partsURI) + if v := change.TextDocument.Version; v == nil || *v != 3 { + t.Errorf("parts version = %v, want the server's 3", v) + } + if got := applyDocumentChange(t, engineParts, change); !strings.Contains(got, "connect rotor to stator {\n\t\t\t@DiagramLayout::Route { points = (1, 2); }") { + t.Errorf("parts.sysml:\n%s", got) + } +} + +// A node another document declares is placed by qualified name at the text it +// was rendered from: once that document changed — a namesake standing where the +// rendered declaration was, say — the answer is stale rather than a placement of +// whatever the name reaches now; and the name must be declared in the document +// the node's origin named, not in a namesake's document. The edit pins that +// document, which a view-local Layout leaves as it was, at its version with no +// edits, so that a client applies the edit only while it still holds that text. +func TestApplyModelEditIsStaleWhenAnotherDocumentsNamedDeclarationWasReplaced(t *testing.T) { + s, viewsURI, partsURI := engineWorkspace(t) + drawn := render(t, s, viewsURI, "EngineViews::engineView") + rotor := nodeNamed(t, drawn, "rotor") + if rotor.FQN == "" || rotor.Origin == nil || rotor.Origin.URI != partsURI || rotor.Origin.Digest == "" { + t.Fatalf("rotor = %+v, want its qualified name and its origin in parts.sysml with a digest", rotor) + } + place := modelEditOperation{Kind: EditSetLayout, Target: rotor.FQN, DeclaredIn: protocol.DocumentURI(partsURI), + Digest: rotor.Origin.Digest, View: drawn.View, Layout: &modelEditLayout{X: 7, Y: 8}} + + replaced := strings.Replace(engineParts, "\t\tpart rotor;\n", "\t\t// A rotor of another making.\n\t\tpart rotor;\n", 1) + tellChanged(t, s, partsURI, replaced, 2) + out := applyModelEdit(t, s, viewsURI, drawn.Version, place) + if !out.Stale || out.Edit != nil || out.Refused != nil { + t.Fatalf("after parts.sysml replaced rotor: %+v, want stale", out) + } + if string(s.ws.Document(viewsURI.Filename()).Content) != engineViews { + t.Error("a stale request changed views.sysml") + } + + tellChanged(t, s, partsURI, engineParts, 3) + out = applyModelEdit(t, s, viewsURI, drawn.Version, place) + if out.Edit == nil || out.Stale || out.Refused != nil { + t.Fatalf("parts.sysml restored to the rendered text: %+v, want an edit", out) + } + if got := documentURIs(out.Edit); !reflect.DeepEqual(got, []uri.URI{viewsURI, partsURI}) { + t.Fatalf("documentChanges = %v, want views.sysml then the unchanged parts.sysml", got) + } + if got := applyDocumentChange(t, engineViews, documentChangeFor(t, out.Edit, viewsURI)); !strings.Contains(got, "metadata DiagramLayout::Layout about Machinery::Engine::rotor { x = 7; y = 8; }") { + t.Errorf("views.sysml:\n%s", got) + } + pinned(t, documentChangeFor(t, out.Edit, partsURI), 3) + + // The name is placed only when declared in the document the origin named. + misplaced := place + misplaced.DeclaredIn, misplaced.Digest = protocol.DocumentURI(viewsURI), s.ws.Document(viewsURI.Filename()).Digest() + out = applyModelEdit(t, s, viewsURI, drawn.Version, misplaced) + if len(out.Refused) != 1 || out.Refused[0].Failure != "unknown-target" || !strings.Contains(out.Refused[0].Message, "parts.sysml, not in") || !strings.HasSuffix(out.Refused[0].Message, "views.sysml as stated") { + t.Fatalf("rotor stated to be declared in views.sysml: %+v, want an unknown-target refusal", out) + } +} diff --git a/internal/lsp/modeledit_test.go b/internal/frontend/lsp/modeledit_test.go similarity index 98% rename from internal/lsp/modeledit_test.go rename to internal/frontend/lsp/modeledit_test.go index aae5aa7142..66db73c8bc 100644 --- a/internal/lsp/modeledit_test.go +++ b/internal/frontend/lsp/modeledit_test.go @@ -10,9 +10,9 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - modeledit "github.com/Open-MBEE/OpenSysML/internal/core/edit" - "github.com/Open-MBEE/OpenSysML/internal/core/source" - "github.com/Open-MBEE/OpenSysML/internal/core/view" + modeledit "github.com/Open-MBEE/OpenSysML/internal/check/edit" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // editModel is a document with comments and blank lines an edit must not touch. @@ -121,7 +121,7 @@ func golden(t *testing.T, s *Server, name string, ops ...modelEditOperation) str } converted = append(converted, c) } - result, _, ok, err := s.ws.ApplyEdit(name, converted) + result, _, ok, err := s.ws.ApplyEdit(name, converted, nil) if !ok || err != nil { t.Fatalf("edit.Apply: ok=%v err=%v", ok, err) } @@ -540,6 +540,17 @@ func TestApplyModelEditVersionsOtherDocumentsAsHeld(t *testing.T) { } } +// pinned asserts a change that edits nothing, pinning its document at version. +func pinned(t *testing.T, change protocol.TextDocumentEdit, version int) { + t.Helper() + if len(change.Edits) != 0 { + t.Errorf("%s has edits %+v, want none", change.TextDocument.URI, change.Edits) + } + if v := change.TextDocument.Version; v == nil || int(*v) != version { + t.Errorf("%s pinned at version %v, want %d", change.TextDocument.URI, v, version) + } +} + // documentURIs lists the documents an edit changes, in the edit's order. func documentURIs(edit *protocol.WorkspaceEdit) []uri.URI { out := make([]uri.URI, 0, len(edit.DocumentChanges)) diff --git a/internal/frontend/lsp/owned_constraint_test.go b/internal/frontend/lsp/owned_constraint_test.go new file mode 100644 index 0000000000..6b6b28739e --- /dev/null +++ b/internal/frontend/lsp/owned_constraint_test.go @@ -0,0 +1,97 @@ +package lsp + +import ( + "context" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +const ownedConstraintSrc = `package P { + constraint def C; + constraint c0 : C; + requirement def R { + require constraint c : C default = c0; + } + requirement def S :> R { + require constraint :>> c = c0; + } +}` + +func openOwnedConstraintDoc(t *testing.T) (*Server, string) { + t.Helper() + ws := model.NewWorkspace() + s := NewServer(ws) + name := uri.File("/tmp/owned_constraint.sysml").Filename() + ws.Open(name, []byte(ownedConstraintSrc), 1) + return s, name +} + +// A named `require constraint c` is a member of its requirement, so `:>> c` +// in a specializing requirement jumps to its declaration. +func TestDefinitionNamedOwnedConstraintRedefinition(t *testing.T) { + s, name := openOwnedConstraintDoc(t) + off := strings.LastIndex(ownedConstraintSrc, ":>> c") + len(":>> ") + locs, err := s.Definition(context.Background(), &protocol.DefinitionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(ownedConstraintSrc), off), + }, + }) + if err != nil { + t.Fatalf("Definition err = %v", err) + } + if len(locs) != 1 { + t.Fatalf("locations = %d, want 1", len(locs)) + } + if locs[0].Range.Start.Line != 4 { + t.Errorf("decl line = %d, want 4", locs[0].Range.Start.Line) + } +} + +func TestHoverNamedOwnedConstraint(t *testing.T) { + s, name := openOwnedConstraintDoc(t) + off := strings.Index(ownedConstraintSrc, "constraint c :") + len("constraint ") + res, err := s.Hover(context.Background(), &protocol.HoverParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition([]byte(ownedConstraintSrc), off), + }, + }) + if err != nil { + t.Fatalf("Hover err = %v", err) + } + if res == nil || !strings.Contains(res.Contents.Value, "constraint") || !strings.Contains(res.Contents.Value, "c") { + t.Fatalf("hover = %+v, want a constraint usage named c", res) + } +} + +func TestDocumentSymbolListsNamedOwnedConstraint(t *testing.T) { + s, name := openOwnedConstraintDoc(t) + res, err := s.DocumentSymbol(context.Background(), &protocol.DocumentSymbolParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + }) + if err != nil { + t.Fatalf("DocumentSymbol err = %v", err) + } + pkg, ok := res[0].(protocol.DocumentSymbol) + if !ok { + t.Fatalf("result[0] type = %T, want protocol.DocumentSymbol", res[0]) + } + for _, child := range pkg.Children { + if child.Name != "R" { + continue + } + for _, member := range child.Children { + if member.Name == "c" { + return + } + } + t.Fatalf("R.Children = %+v, want a member c", child.Children) + } + t.Fatalf("pkg.Children = %+v, want R", pkg.Children) +} diff --git a/internal/frontend/lsp/posmap.go b/internal/frontend/lsp/posmap.go new file mode 100644 index 0000000000..538a70bf24 --- /dev/null +++ b/internal/frontend/lsp/posmap.go @@ -0,0 +1,130 @@ +package lsp + +import ( + "math" + "unicode/utf8" + + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// positions converts between byte offsets and LSP positions of one text, from +// its line index. +type positions struct { + content []byte + lines *source.LineIndex +} + +// positionsOf converts in doc's text, sharing the document's cached line index. +func positionsOf(doc *model.Document) positions { + return positions{content: doc.Content, lines: doc.Lines()} +} + +// positionsFor converts in content, indexing its lines once. +func positionsFor(content []byte) positions { + return positions{content: content, lines: source.NewLineIndex(content)} +} + +// position converts a byte offset to a 0-based LSP Position whose Character is +// a UTF-16 code-unit column. +func (p positions) position(offset int) protocol.Position { + if offset < 0 { + offset = 0 + } + if offset > len(p.content) { + offset = len(p.content) + } + pos := p.lines.PosAt(offset) + lineStart := offset - (pos.Col - 1) + char := utf16Len(p.content[lineStart:offset]) + return protocol.Position{Line: uint32Clamp(pos.Line - 1), Character: uint32Clamp(char)} +} + +// offset converts a 0-based LSP Position (UTF-16 column) to a byte offset. +// Out-of-range positions clamp to the end of content. +func (p positions) offset(pos protocol.Position) int { + i := p.lines.OffsetAt(source.Pos{Line: int(pos.Line) + 1, Col: 1}) + if i < 0 { + return len(p.content) + } + units := 0 + for i < len(p.content) && p.content[i] != '\n' { + if units >= int(pos.Character) { + break + } + r, size := utf8.DecodeRune(p.content[i:]) + units += utf16RuneLen(r) + i += size + } + return i +} + +// rangeOf converts a core byte Span to an LSP Range. +func (p positions) rangeOf(sp source.Span) protocol.Range { + return protocol.Range{ + Start: p.position(sp.Offset), + End: p.position(sp.End()), + } +} + +// offsetToPosition converts a byte offset in content to a 0-based LSP Position +// whose Character is a UTF-16 code-unit column. +func offsetToPosition(content []byte, offset int) protocol.Position { + return positionsFor(content).position(offset) +} + +// positionToOffset converts a 0-based LSP Position (UTF-16 column) to a byte +// offset in content. Out-of-range positions clamp to the end of content. +func positionToOffset(content []byte, pos protocol.Position) int { + return positionsFor(content).offset(pos) +} + +// spanToRange converts a core byte Span to an LSP Range. +func spanToRange(content []byte, sp source.Span) protocol.Range { + return positionsFor(content).rangeOf(sp) +} + +// rangeToSpan converts an LSP Range to a core byte Span. +func rangeToSpan(content []byte, r protocol.Range) source.Span { + p := positionsFor(content) + start := p.offset(r.Start) + end := p.offset(r.End) + if end < start { + end = start + } + return source.Span{Offset: start, Len: end - start} +} + +// uint32Clamp narrows a line or column number to the protocol's uint32, +// saturating rather than wrapping. +func uint32Clamp(n int) uint32 { + if n < 0 { + return 0 + } + if n > math.MaxUint32 { + return math.MaxUint32 + } + return uint32(n) +} + +// utf16Len returns the number of UTF-16 code units in b. +func utf16Len(b []byte) int { + n := 0 + for i := 0; i < len(b); { + r, size := utf8.DecodeRune(b[i:]) + n += utf16RuneLen(r) + i += size + } + return n +} + +// utf16RuneLen returns the number of UTF-16 code units for r: 2 for +// astral-plane runes (> U+FFFF, encoded as a surrogate pair), else 1. +func utf16RuneLen(r rune) int { + if r > 0xFFFF { + return 2 + } + return 1 +} diff --git a/internal/frontend/lsp/posmap_test.go b/internal/frontend/lsp/posmap_test.go new file mode 100644 index 0000000000..43305a8fc5 --- /dev/null +++ b/internal/frontend/lsp/posmap_test.go @@ -0,0 +1,232 @@ +package lsp + +import ( + "bytes" + "testing" + "unicode/utf8" + + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +func TestOffsetToPositionASCII(t *testing.T) { + content := []byte("package P;\nnamespace N;\n") + // offset of 'N' in "namespace N" — line 1 (0-based), after "namespace " + nOffset := 11 + len("namespace ") + pos := offsetToPosition(content, nOffset) + if pos.Line != 1 { + t.Errorf("Line = %d, want 1", pos.Line) + } + if pos.Character != 10 { + t.Errorf("Character = %d, want 10", pos.Character) + } +} + +func TestOffsetToPositionUTF16Astral(t *testing.T) { + // "x = 😀;" — the emoji is 4 UTF-8 bytes and 2 UTF-16 code units. + content := []byte("x = 😀;") + semicolonByteOffset := len("x = 😀") // byte offset of ';' + pos := offsetToPosition(content, semicolonByteOffset) + if pos.Line != 0 { + t.Errorf("Line = %d, want 0", pos.Line) + } + // "x = " is 4 UTF-16 units, emoji is 2 => ';' at UTF-16 col 6 + if pos.Character != 6 { + t.Errorf("Character = %d, want 6", pos.Character) + } +} + +func TestPositionToOffsetRoundTrip(t *testing.T) { + content := []byte("alpha\nbéta 😀 x\n") + for _, off := range []int{0, 3, 6, 7, 11, 12} { + pos := offsetToPosition(content, off) + got := positionToOffset(content, pos) + if got != off { + t.Errorf("round trip offset %d -> %+v -> %d", off, pos, got) + } + } +} + +func TestOffsetToPositionEdgeCases(t *testing.T) { + content := []byte("ab\ncd") + tests := []struct { + name string + content []byte + offset int + wantLine uint32 + wantCharacter uint32 + }{ + {"negative clamps to start", content, -5, 0, 0}, + {"past EOF clamps to end", content, 100, 1, 2}, + {"empty content any offset", []byte{}, 5, 0, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos := offsetToPosition(tt.content, tt.offset) + if pos.Line != tt.wantLine || pos.Character != tt.wantCharacter { + t.Errorf("offsetToPosition = %+v, want {Line:%d Character:%d}", pos, tt.wantLine, tt.wantCharacter) + } + }) + } +} + +func TestPositionToOffsetEdgeCases(t *testing.T) { + content := []byte("ab\ncd") + tests := []struct { + name string + pos protocol.Position + want int + }{ + {"line past EOF clamps to len", protocol.Position{Line: 9, Character: 0}, len(content)}, + {"character past line end stops before newline", protocol.Position{Line: 0, Character: 99}, 2}, + {"character past last line end clamps to len", protocol.Position{Line: 1, Character: 99}, len(content)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := positionToOffset(content, tt.pos); got != tt.want { + t.Errorf("positionToOffset(%+v) = %d, want %d", tt.pos, got, tt.want) + } + }) + } +} + +func TestPositionToOffsetInvalidUTF8NoPanic(t *testing.T) { + content := []byte{0xff, 0xfe, '\n', 0xe4} + // Must not panic or loop; each bad byte advances by one. + _ = positionToOffset(content, protocol.Position{Line: 0, Character: 5}) + _ = offsetToPosition(content, 3) +} + +func TestSpanToRange(t *testing.T) { + content := []byte("package P;\nnamespace N;\n") + sp := source.Span{Offset: 8, Len: 1} // 'P' + r := spanToRange(content, sp) + want := protocol.Range{ + Start: protocol.Position{Line: 0, Character: 8}, + End: protocol.Position{Line: 0, Character: 9}, + } + if r != want { + t.Errorf("spanToRange = %+v, want %+v", r, want) + } +} + +// linearOffsetToPosition is the pre-index scan offsetToPosition was, kept here +// as the reference the line-indexed positions must agree with. +func linearOffsetToPosition(content []byte, offset int) protocol.Position { + if offset < 0 { + offset = 0 + } + if offset > len(content) { + offset = len(content) + } + line := 0 + lineStart := 0 + for i := 0; i < offset; i++ { + if content[i] == '\n' { + line++ + lineStart = i + 1 + } + } + char := utf16Len(content[lineStart:offset]) + return protocol.Position{Line: uint32Clamp(line), Character: uint32Clamp(char)} +} + +// linearPositionToOffset is the pre-index walk positionToOffset was. +func linearPositionToOffset(content []byte, pos protocol.Position) int { + line := 0 + i := 0 + for line < int(pos.Line) && i < len(content) { + if content[i] == '\n' { + line++ + } + i++ + } + units := 0 + for i < len(content) && content[i] != '\n' { + if units >= int(pos.Character) { + break + } + r, size := utf8.DecodeRune(content[i:]) + units += utf16RuneLen(r) + i += size + } + return i +} + +func TestPositionsAgreesWithLinearScan(t *testing.T) { + contents := map[string][]byte{ + "ascii multi-line": []byte("package P;\nnamespace N;\npart x;"), + "crlf": []byte("a\r\nb\r\nc"), + "multibyte": []byte("café\nbéta\n"), + "astral": []byte("x = 😀;\ny = 😀😀;\n"), + "empty": {}, + } + for name, content := range contents { + t.Run(name+"/position", func(t *testing.T) { + pos := positionsFor(content) + offsets := []int{-10, -1, 0, 1, len(content) / 2, len(content) - 1, len(content), len(content) + 1, len(content) + 100} + for _, off := range offsets { + if got, want := pos.position(off), linearOffsetToPosition(content, off); got != want { + t.Errorf("position(%d) = %+v, want %+v", off, got, want) + } + } + }) + t.Run(name+"/offset", func(t *testing.T) { + pos := positionsFor(content) + positionsIn := []protocol.Position{ + {Line: 0, Character: 0}, + {Line: 0, Character: 1}, + {Line: 1, Character: 0}, + {Line: 1, Character: 3}, + {Line: 0, Character: 999}, // character past line end + {Line: 999, Character: 0}, // line past last line + {Line: 999, Character: 999}, // both past the end + } + for _, pin := range positionsIn { + if got, want := pos.offset(pin), linearPositionToOffset(content, pin); got != want { + t.Errorf("offset(%+v) = %d, want %d", pin, got, want) + } + } + }) + t.Run(name+"/rangeOf", func(t *testing.T) { + pos := positionsFor(content) + spans := []source.Span{ + {Offset: 0, Len: 0}, + {Offset: 0, Len: len(content)}, + {Offset: len(content) / 2, Len: 1}, + {Offset: len(content), Len: 0}, + {Offset: len(content) + 5, Len: 3}, + } + for _, sp := range spans { + want := protocol.Range{ + Start: linearOffsetToPosition(content, sp.Offset), + End: linearOffsetToPosition(content, sp.End()), + } + if got := pos.rangeOf(sp); got != want { + t.Errorf("rangeOf(%+v) = %+v, want %+v", sp, got, want) + } + } + }) + } +} + +func BenchmarkSpanToRangeLarge(b *testing.B) { + var buf bytes.Buffer + for buf.Len() < 10<<20 { + buf.WriteString("part somePart : SomeDef { attribute x : Real; }\n") + } + content := buf.Bytes() + pos := positionsFor(content) + spans := make([]source.Span, 10000) + for i := range spans { + off := (i * 977) % len(content) + spans[i] = source.Span{Offset: off, Len: 4} + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, sp := range spans { + _ = pos.rangeOf(sp) + } + } +} diff --git a/internal/lsp/protocol_features_test.go b/internal/frontend/lsp/protocol_features_test.go similarity index 98% rename from internal/lsp/protocol_features_test.go rename to internal/frontend/lsp/protocol_features_test.go index defec6621b..12e91ad0c7 100644 --- a/internal/lsp/protocol_features_test.go +++ b/internal/frontend/lsp/protocol_features_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // Regression: semanticTokens/full and codeAction were answered with JSON-RPC diff --git a/internal/frontend/lsp/references.go b/internal/frontend/lsp/references.go new file mode 100644 index 0000000000..93407b2eb8 --- /dev/null +++ b/internal/frontend/lsp/references.go @@ -0,0 +1,76 @@ +package lsp + +import ( + "context" + + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// References returns every location naming the symbol under the cursor, in all +// workspace documents, at whichever segment of a qualified name denotes it. The +// cursor may sit in a bundled library document; the declaration it names is +// located there when it is a library one. Uses inside the library itself are +// not enumerated. +func (s *Server) References(ctx context.Context, params *protocol.ReferenceParams) ([]protocol.Location, error) { + name := uriToName(params.TextDocument.URI) + doc := s.document(name) + if doc == nil || doc.Scope == nil { + return nil, nil + } + target := s.referenceTarget(name, doc, params.Position) + if target == nil { + return nil, nil + } + + var out []protocol.Location + seen := map[protocol.Location]bool{} + posOf := map[string]positions{} + add := func(docName string, content []byte, span source.Span) { + pos, ok := posOf[docName] + if !ok { + pos = positionsFor(content) + posOf[docName] = pos + } + loc := protocol.Location{URI: s.documentURI(docName), Range: pos.rangeOf(span)} + if seen[loc] { + return + } + seen[loc] = true + out = append(out, loc) + } + + if params.Context.IncludeDeclaration { + loc := s.symbolLocation(name, target) + seen[loc] = true + out = append(out, loc) + } + // Both identities of a segment: the element it reaches and, where it wrote + // an alias name, the alias — each names the target for a reader. + for _, ref := range s.ws.ReferencesTo(target) { + add(ref.Doc, ref.Content, ref.Span) + } + return out, nil +} + +// referenceTarget returns the symbol named at pos: the segment of a reference +// under the cursor, or the declaration the cursor sits in. A call tied between +// overloads names no one declaration, so it has no references to list. +func (s *Server) referenceTarget(name string, doc *model.Document, pos protocol.Position) *symbols.Symbol { + offset := positionToOffset(doc.Content, pos) + if ref := refAtOffset(collectRefs(doc.AST, doc.Scope), offset); ref != nil { + if sym, _, ok := s.referencedSegment(name, *ref, offset); ok && sym != nil { + return sym + } + if sym, ok := s.ws.ResolveReferenceInDoc(name, *ref); ok { + return sym + } + if onCalledName(*ref, offset) && len(s.ws.AmbiguousInvocationInDoc(name, *ref)) > 0 { + return nil + } + } + return symbolAtOffset(doc.Scope, offset) +} diff --git a/internal/lsp/references_bench_test.go b/internal/frontend/lsp/references_bench_test.go similarity index 98% rename from internal/lsp/references_bench_test.go rename to internal/frontend/lsp/references_bench_test.go index 70c19c01c5..529d966ca2 100644 --- a/internal/lsp/references_bench_test.go +++ b/internal/frontend/lsp/references_bench_test.go @@ -9,7 +9,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // benchWorkspaceDocs is the size of the synthetic workspace the benchmarks diff --git a/internal/lsp/references_index_test.go b/internal/frontend/lsp/references_index_test.go similarity index 97% rename from internal/lsp/references_index_test.go rename to internal/frontend/lsp/references_index_test.go index 34b8582ed9..950f77a303 100644 --- a/internal/lsp/references_index_test.go +++ b/internal/frontend/lsp/references_index_test.go @@ -8,8 +8,8 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/conformance" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // A declaration in lib, a use of it through a wildcard import, and a same-named @@ -184,6 +184,6 @@ func TestReferencesDropClosedAndRemovedDocuments(t *testing.T) { func TestReferencesSurviveConformanceModeChange(t *testing.T) { ws, s, n := openRefIndexWorkspace(t) wantLocations(t, indexReferencesAt(t, ws, s, n["lib"], "Wheel"), n["use"]) - ws.SetConformanceMode(conformance.ModeStrict) + ws.SetConformanceMode(diag.ConformanceStrict) wantLocations(t, indexReferencesAt(t, ws, s, n["lib"], "Wheel"), n["use"]) } diff --git a/internal/frontend/lsp/references_test.go b/internal/frontend/lsp/references_test.go new file mode 100644 index 0000000000..7e17556d23 --- /dev/null +++ b/internal/frontend/lsp/references_test.go @@ -0,0 +1,165 @@ +package lsp + +import ( + "context" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +func TestReferencesFindsUses(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + // Use an absolute name so uri.File(name).Filename() round-trips to the same key. + name := uri.File("/tmp/ref.sysml").Filename() + // "N" is declared once (namespace N) and referenced twice (P::N in two imports). + src := "package P { namespace N; }\nimport P::N;\nimport P::N;" + ws.Open(name, []byte(src), 1) + + // Cursor on the declaration "N" inside "namespace N;". + off := strings.Index(src, "N") + pos := offsetToPosition([]byte(src), off) + + locs, err := s.References(context.Background(), &protocol.ReferenceParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: pos, + }, + Context: protocol.ReferenceContext{IncludeDeclaration: false}, + }) + if err != nil { + t.Fatalf("References err = %v", err) + } + // The two "P::N" imports each resolve their terminal segment to N => 2 references. + if len(locs) != 2 { + t.Fatalf("references = %d, want 2", len(locs)) + } + for _, l := range locs { + if l.URI != uri.File(name) { + t.Errorf("URI = %q, want %q", l.URI, uri.File(name)) + } + } + + // The reference range must narrow to the terminal "N" segment, not the whole + // "P::N" dotted name. In "import P::N;", "N" sits one column after "P::". + for _, l := range locs { + off := positionToOffset([]byte(src), l.Range.Start) + if got := src[off]; got != 'N' { + t.Errorf("reference range starts at %q (offset %d), want 'N'", got, off) + } + endOff := positionToOffset([]byte(src), l.Range.End) + if endOff-off != 1 { + t.Errorf("reference range length = %d, want 1 (just \"N\")", endOff-off) + } + } +} + +func TestReferencesFromUseSiteIncludingDeclaration(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + name := uri.File("/tmp/ref2.sysml").Filename() + src := "package P { namespace N; }\nimport P::N;\nimport P::N;" + ws.Open(name, []byte(src), 1) + + // Cursor on the terminal "N" of the FIRST "P::N" import (a use site, exercising + // the refAtOffset branch), with IncludeDeclaration:true. + useLine := strings.Index(src, "import P::N;") + off := strings.Index(src[useLine:], "N") + useLine + pos := offsetToPosition([]byte(src), off) + + locs, err := s.References(context.Background(), &protocol.ReferenceParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: pos, + }, + Context: protocol.ReferenceContext{IncludeDeclaration: true}, + }) + if err != nil { + t.Fatalf("References err = %v", err) + } + // Declaration + two references = 3 locations. + if len(locs) != 3 { + t.Fatalf("references = %d, want 3", len(locs)) + } +} + +func TestReferencesSpanDocuments(t *testing.T) { + s, _, _, lib, main := multiFileWorkspace(t) + openFile(t, s, main, mainSource) + + // Cursor on the declaration of Widget in lib.sysml, which main.sysml uses. + pos := offsetToPosition([]byte(libSource), strings.Index(libSource, "Widget")) + locs, err := s.References(context.Background(), &protocol.ReferenceParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(lib)}, + Position: pos, + }, + Context: protocol.ReferenceContext{IncludeDeclaration: false}, + }) + if err != nil { + t.Fatalf("References err = %v", err) + } + if len(locs) != 1 { + t.Fatalf("references = %d (%v), want 1 in main.sysml", len(locs), locs) + } + if locs[0].URI != uri.File(main) { + t.Errorf("URI = %q, want %q", locs[0].URI, uri.File(main)) + } + off := positionToOffset([]byte(mainSource), locs[0].Range.Start) + if got := mainSource[off : off+len("Widget")]; got != "Widget" { + t.Errorf("reference range covers %q, want %q", got, "Widget") + } +} + +func TestReferencesFromUseSiteSpanDocuments(t *testing.T) { + s, _, _, lib, main := multiFileWorkspace(t) + openFile(t, s, main, mainSource) + + // Cursor on the use of Widget in main.sysml: the declaration it resolves to + // lives in another document, and is reported there. + pos := offsetToPosition([]byte(mainSource), strings.Index(mainSource, "Widget")) + locs, err := s.References(context.Background(), &protocol.ReferenceParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(main)}, + Position: pos, + }, + Context: protocol.ReferenceContext{IncludeDeclaration: true}, + }) + if err != nil { + t.Fatalf("References err = %v", err) + } + if len(locs) != 2 { + t.Fatalf("references = %d (%v), want declaration + one use", len(locs), locs) + } + if locs[0].URI != uri.File(lib) { + t.Errorf("declaration URI = %q, want %q", locs[0].URI, uri.File(lib)) + } +} + +func TestReferencesCursorOnNothing(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + name := uri.File("/tmp/ref3.sysml").Filename() + src := "package P { namespace N; }\nimport P::N;" + ws.Open(name, []byte(src), 1) + + // Cursor on the "import" keyword (not a symbol declaration, not a ref QN). + pos := offsetToPosition([]byte(src), strings.Index(src, "import")) + locs, err := s.References(context.Background(), &protocol.ReferenceParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: pos, + }, + Context: protocol.ReferenceContext{IncludeDeclaration: true}, + }) + if err != nil { + t.Fatalf("References err = %v", err) + } + if len(locs) != 0 { + t.Fatalf("references = %d, want 0", len(locs)) + } +} diff --git a/internal/frontend/lsp/rename.go b/internal/frontend/lsp/rename.go new file mode 100644 index 0000000000..adef8dfc0f --- /dev/null +++ b/internal/frontend/lsp/rename.go @@ -0,0 +1,180 @@ +package lsp + +import ( + "context" + "fmt" + + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// renameTarget is the name under the rename cursor: the symbol it belongs to, +// which of that symbol's names it is (long or short) and where the declaration +// states that name. +type renameTarget struct { + sym *symbols.Symbol + name string + declSpan source.Span +} + +// PrepareRename reports the range the client should offer for editing, and +// rejects positions that name nothing renameable before the user types. +func (s *Server) PrepareRename(ctx context.Context, params *protocol.PrepareRenameParams) (*protocol.Range, error) { + name := uriToName(params.TextDocument.URI) + _, span, err := s.renameTargetAt(name, params.Position) + if err != nil { + return nil, err + } + rng := spanToRange(s.ws.Document(name).Content, span) + return &rng, nil +} + +// Rename renames the name under the cursor — a declaration's long or short name — +// where the declaration states it and at every reference across the workspace's +// documents written with that name, including the qualifier positions of +// multi-segment names (`A::B` when renaming A). A reference written with the +// element's other name still resolves afterwards, so it is left as written. +func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*protocol.WorkspaceEdit, error) { + name := uriToName(params.TextDocument.URI) + target, _, err := s.renameTargetAt(name, params.Position) + if err != nil { + return nil, err + } + if err := validateNewName(params.NewName); err != nil { + return nil, err + } + if c := s.ws.RenameConflict(target.sym, target.name, params.NewName); c != nil { + return nil, c + } + + changes := map[protocol.DocumentURI][]protocol.TextEdit{} + // One name occurrence is edited once however many times it is collected: a + // shorthand redefinition (`part redefines x;`) is both the declaration and a + // reference at the same span, and clients reject overlapping edits. + edited := map[protocol.DocumentURI]map[source.Span]bool{} + posOf := map[string]positions{} + addEdit := func(docName string, content []byte, span source.Span) { + uri := nameToURI(docName) + if edited[uri] == nil { + edited[uri] = map[source.Span]bool{} + } + if edited[uri][span] { + return + } + edited[uri][span] = true + pos, ok := posOf[docName] + if !ok { + pos = positionsFor(content) + posOf[docName] = pos + } + changes[uri] = append(changes[uri], protocol.TextEdit{ + Range: pos.rangeOf(span), + NewText: params.NewName, + }) + } + + // The declaration itself. + declDoc := s.ws.Document(target.sym.DocName) + addEdit(target.sym.DocName, declDoc.Content, target.declSpan) + + // Every segment, in every document, that writes this name of target: an alias + // use is rewritten by renaming the alias and not by renaming its target. + for _, ref := range s.ws.NameReferencesTo(target.sym, target.name) { + addEdit(ref.Doc, ref.Content, ref.Span) + } + return &protocol.WorkspaceEdit{Changes: changes}, nil +} + +// renameTargetAt returns the name at pos and the span of that name as written +// there (a declaration's long or short identifier, or one segment of a reference). +func (s *Server) renameTargetAt(name string, pos protocol.Position) (renameTarget, source.Span, error) { + doc := s.ws.Document(name) + if doc == nil || doc.Scope == nil { + return renameTarget{}, source.Span{}, fmt.Errorf("no document %q", name) + } + offset := positionToOffset(doc.Content, pos) + + // On a reference: rename the symbol the containing segment denotes, so + // renaming from the `A` of `A::B` renames A, not B. + if ref := refAtOffset(collectRefs(doc.AST, doc.Scope), offset); ref != nil { + segs := s.ws.ResolveReferenceNameSegmentsInDoc(name, *ref) + for i, part := range ref.QN.Parts { + if offset < part.Span.Offset || offset >= part.Span.End() { + continue + } + if i < len(segs) && segs[i] != nil { + return s.renameable(segs[i], part.Text, part.Span) + } + if i == len(ref.QN.Parts)-1 && len(s.ws.AmbiguousInvocationInDoc(name, *ref)) > 0 { + return renameTarget{}, source.Span{}, fmt.Errorf("cannot rename %q: the call is ambiguous between several overloads", part.Text) + } + return renameTarget{}, source.Span{}, fmt.Errorf("cannot rename %q: unresolved", part.Text) + } + } + + // On a declaration: the cursor must be on a declared identifier itself — the + // name or the short name between its angle brackets — not merely somewhere + // inside the declaration's body. + if sym := symbolAtOffset(doc.Scope, offset); sym != nil { + if sp := sym.NameSpan; sp.Len > 0 && offset >= sp.Offset && offset < sp.End() { + return s.renameable(sym, sym.Name, sp) + } + if sp := shortNameSpan(sym); sp.Len > 0 && offset >= sp.Offset && offset < sp.End() { + return s.renameable(sym, sym.ShortName, sp) + } + } + return renameTarget{}, source.Span{}, fmt.Errorf("no renameable name at this position") +} + +// renameable rejects symbols whose declaration this server cannot edit, and +// otherwise pairs the name written at span with where sym's declaration states it. +func (s *Server) renameable(sym *symbols.Symbol, name string, span source.Span) (renameTarget, source.Span, error) { + if sym.NameSpan.Len == 0 { + return renameTarget{}, source.Span{}, fmt.Errorf("cannot rename %q: no declared name", sym.Name) + } + if s.ws.Document(sym.DocName) == nil { + // Standard-library and other out-of-workspace declarations: renaming + // the references alone would break the model. + return renameTarget{}, source.Span{}, fmt.Errorf("cannot rename %q: declared outside the workspace", sym.Name) + } + target := renameTarget{sym: sym, name: name, declSpan: sym.NameSpan} + if name != sym.Name { + if sp := shortNameSpan(sym); name == sym.ShortName && sp.Len > 0 { + target.declSpan = sp + } else { + return renameTarget{}, source.Span{}, fmt.Errorf("cannot rename %q: not a name %q declares", name, sym.Name) + } + } + return target, span, nil +} + +// shortNameSpan is where sym's declaration states its short name (``), or an +// empty span when it states none. +func shortNameSpan(sym *symbols.Symbol) source.Span { + id, ok := symbols.DeclIdent(sym.Decl) + if !ok || id.ShortName == "" { + return source.Span{} + } + return id.ShortNameSpan +} + +// validateNewName rejects names that would not lex as the identifier they are +// meant to replace. +func validateNewName(name string) error { + if name == "" { + return fmt.Errorf("new name is empty") + } + if source.IsKeyword(name) { + return fmt.Errorf("%q is a keyword", name) + } + for i := 0; i < len(name); i++ { + c := name[i] + ok := c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (i > 0 && c >= '0' && c <= '9') + if !ok { + return fmt.Errorf("%q is not a valid identifier", name) + } + } + return nil +} diff --git a/internal/lsp/rename_conflict_test.go b/internal/frontend/lsp/rename_conflict_test.go similarity index 99% rename from internal/lsp/rename_conflict_test.go rename to internal/frontend/lsp/rename_conflict_test.go index 090606ab2d..5a21b073ce 100644 --- a/internal/lsp/rename_conflict_test.go +++ b/internal/frontend/lsp/rename_conflict_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" "go.lsp.dev/protocol" "go.lsp.dev/uri" ) diff --git a/internal/lsp/rename_test.go b/internal/frontend/lsp/rename_test.go similarity index 99% rename from internal/lsp/rename_test.go rename to internal/frontend/lsp/rename_test.go index 82b78c5dd0..af40aa3597 100644 --- a/internal/lsp/rename_test.go +++ b/internal/frontend/lsp/rename_test.go @@ -9,7 +9,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // applyRename runs Rename at the first occurrence of cursor in the named diff --git a/internal/frontend/lsp/render.go b/internal/frontend/lsp/render.go new file mode 100644 index 0000000000..798e31877d --- /dev/null +++ b/internal/frontend/lsp/render.go @@ -0,0 +1,511 @@ +package lsp + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strconv" + "strings" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// The custom methods a diagram client speaks. They are not in the protocol, so +// they are dispatched ahead of the library's own handler. +const ( + // MethodRender renders a view of a document, or an element directly. + MethodRender = "opensysml/render" + // MethodViews lists the views a document declares. + MethodViews = "opensysml/views" + // MethodRenderChanged tells a client the renderings of a document are out of + // date. It carries no artifact: the client pulls a fresh one. + MethodRenderChanged = "opensysml/renderChanged" +) + +// CrossDocumentCapability is the experimental capability a client and the server +// each advertise when they speak the cross-document diagram contract: renderings +// naming other documents' declarations and layouts pinned with declaredIn. +const CrossDocumentCapability = "openSysmlCrossDocumentLayout" + +// RenderPaletteCapability is the experimental capability the server advertises +// when a render request's palette colours the result's nodes with fill and border. +const RenderPaletteCapability = "openSysmlRenderPalette" + +// RenderFormsCapability is the experimental capability whose value lists the +// forms opensysml/render writes, so a client offers exactly those. +const RenderFormsCapability = "openSysmlRenderForms" + +// renderFormNames lists the forms in the order the writer defines them. +func renderFormNames() []string { + names := make([]string, 0, len(view.Forms())) + for _, form := range view.Forms() { + names = append(names, string(form)) + } + return names +} + +// renderParams asks for one rendering. View names a view the document declares, +// or a supported pseudo-view (`#` or `#:`); empty renders the +// document's own view. Form is the artifact written, defaulting to the machine +// form of the rendering's kind. Palette names the palette that fills the nodes by +// keyword family, in the artifact and as each node's Fill and Border; empty is black and white. +type renderParams struct { + TextDocument protocol.TextDocumentIdentifier `json:"textDocument"` + View string `json:"view,omitempty"` + Form string `json:"form,omitempty"` + Palette string `json:"palette,omitempty"` +} + +// renderResult is one rendering: the artifact a client draws, plus the nodes and +// edges it is made of, each located in the source it was declared in. +type renderResult struct { + View string `json:"view"` + Kind string `json:"kind"` + Stated string `json:"stated"` + Form string `json:"form"` + Artifact string `json:"artifact"` + Nodes []renderNode `json:"nodes"` + Edges []renderEdge `json:"edges"` + Rows []renderRow `json:"rows,omitempty"` + Columns []string `json:"columns,omitempty"` + Notices []string `json:"notices"` + Canvas *renderCanvas `json:"canvas,omitempty"` + Palette *editPalette `json:"palette,omitempty"` + Version int `json:"version"` +} + +// renderNode is one node of a rendering, with the range of the declaration it +// was built from when there is one, and its position when a Layout gives one. +// FQN names that declaration the way opensysml/applyModelEdit targets it, and +// Owners the namespaces declaring it, nearest first, drawn or not. Declaration +// stands in for FQN when no qualified name reaches the node: a layout operation +// targets the declaration at that range of the document Origin names. Both are +// given for a declaration of a workspace document alone, a library's being +// beyond every operation; DeclaredHere marks the requested document's own, the +// only ones the operations besides a layout reach. Fill and Border are the +// `#RRGGBB` colours the palette gives the node, as the DOT and PlantUML forms draw it; absent +// for a node left black and white, and for every node when no palette is asked for. +type renderNode struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + NameSynthesized bool `json:"nameSynthesized,omitempty"` + Type string `json:"type"` + Detail string `json:"detail"` + Parent string `json:"parent,omitempty"` + Fill string `json:"fill,omitempty"` + Border string `json:"border,omitempty"` + FQN string `json:"fqn,omitempty"` + DeclaredHere bool `json:"declaredHere,omitempty"` + Notation string `json:"notation,omitempty"` + Owners []renderOwner `json:"owners,omitempty"` + Declaration *protocol.Range `json:"declaration,omitempty"` + Origin *renderOrigin `json:"origin,omitempty"` + X *float64 `json:"x,omitempty"` + Y *float64 `json:"y,omitempty"` + Width *float64 `json:"width,omitempty"` + Height *float64 `json:"height,omitempty"` + Collapsed bool `json:"collapsed,omitempty"` +} + +// renderOwner is a namespace declaring a node: its qualified name, and whether +// it is a feature, which an end path chains through with `.` rather than `::`. +type renderOwner struct { + FQN string `json:"fqn"` + Feature bool `json:"feature"` +} + +// renderEdge is one edge of a rendering, located at the connector, transition, +// succession or flow it was written as, with the waypoints a Route gives it. +// FQN and Declaration identify that declaration to a setRoute as a node's do. +type renderEdge struct { + From string `json:"from"` + To string `json:"to"` + Label string `json:"label"` + Kind string `json:"kind"` + FQN string `json:"fqn,omitempty"` + Declaration *protocol.Range `json:"declaration,omitempty"` + Origin *renderOrigin `json:"origin,omitempty"` + Route []renderPoint `json:"route,omitempty"` +} + +// renderPoint is one waypoint of an edge, in the canvas's pixels, y down. +type renderPoint struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// renderCanvas is the drawing surface the view states with a Canvas annotation. +type renderCanvas struct { + Unit string `json:"unit,omitempty"` + Width *float64 `json:"width,omitempty"` + Height *float64 `json:"height,omitempty"` +} + +// renderRow is one row of a table rendering, located at the element it reports. +type renderRow struct { + Cells []string `json:"cells"` + Origin *renderOrigin `json:"origin,omitempty"` +} + +// renderOrigin is where an element was declared: Range is the whole declaration, +// SelectionRange the declared identifier alone, which is where a client goes. +// Digest fingerprints the text the ranges are of; an operation naming a range +// of another document hands it back, so a range of text since changed is +// refused rather than misread. +type renderOrigin struct { + URI uri.URI `json:"uri"` + Range protocol.Range `json:"range"` + SelectionRange *protocol.Range `json:"selectionRange,omitempty"` + Digest string `json:"digest"` +} + +// viewsParams asks for the views a document declares. +type viewsParams struct { + TextDocument protocol.TextDocumentIdentifier `json:"textDocument"` +} + +// viewsResult lists a document's views, unsupported ones included so a client +// can say why they cannot be drawn. +type viewsResult struct { + Views []viewInfo `json:"views"` + PseudoViews []string `json:"pseudoViews"` +} + +// viewInfo is one view a document declares. Range is its whole declaration and +// SelectionRange its name, so a client can tell which view the cursor is in. +type viewInfo struct { + Name string `json:"name"` + Kind string `json:"kind"` + Supported bool `json:"supported"` + Reason string `json:"reason,omitempty"` + Range *protocol.Range `json:"range,omitempty"` + SelectionRange *protocol.Range `json:"selectionRange,omitempty"` +} + +// renderChangedParams tells a client which document's renderings went stale, and +// at which version. +type renderChangedParams struct { + TextDocument protocol.TextDocumentIdentifier `json:"textDocument"` + Version int `json:"version"` +} + +// renderHandler dispatches the custom render methods, which the protocol library +// does not know, and passes everything else on. +func (s *Server) renderHandler(inner jsonrpc2.Handler) jsonrpc2.Handler { + return func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + switch req.Method() { + case MethodRender: + var params renderParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(ctx, nil, fmt.Errorf("%s: %w", jsonrpc2.ErrParse, err)) + } + result, err := s.Render(¶ms) + if err != nil { + return reply(ctx, nil, err) + } + return reply(ctx, result, nil) + case MethodViews: + var params viewsParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(ctx, nil, fmt.Errorf("%s: %w", jsonrpc2.ErrParse, err)) + } + return reply(ctx, s.Views(¶ms), nil) + case MethodDocuments: + return reply(ctx, s.Documents(), nil) + case MethodRenderDocument: + var params renderDocumentParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(ctx, nil, fmt.Errorf("%s: %w", jsonrpc2.ErrParse, err)) + } + result, err := s.RenderDocument(¶ms) + if err != nil { + return reply(ctx, nil, err) + } + return reply(ctx, result, nil) + } + return inner(ctx, reply, req) + } +} + +// Views answers opensysml/views: the views the document declares, with the +// rendering kind each states and why an unsupported one cannot be drawn. +func (s *Server) Views(params *viewsParams) *viewsResult { + name := uriToName(params.TextDocument.URI) + out := &viewsResult{Views: []viewInfo{}, PseudoViews: view.PseudoViewSpecs()} + views, doc := s.ws.Views(name) + for _, info := range views { + listed := viewInfo{ + Name: info.Name, + Kind: string(info.Kind), + Supported: info.Supported, + Reason: info.Reason, + } + if origin := s.originOf(doc, info.Origin); origin != nil { + listed.Range = &origin.Range + listed.SelectionRange = origin.SelectionRange + } + out.Views = append(out.Views, listed) + } + return out +} + +// Render answers opensysml/render: the rendering of the view or element asked +// for, in the form asked for, at the version of the document it was made from: +// version, node FQNs, ranges and digests all come from the one read of the +// workspace the rendering was made under. +func (s *Server) Render(params *renderParams) (*renderResult, error) { + name := uriToName(params.TextDocument.URI) + rendering, snapshot, err := s.ws.RenderView(name, params.View) + if err != nil { + return nil, err + } + doc := snapshot.Rendered + origin := func(o view.Origin) *renderOrigin { return s.originIn(snapshot, o) } + form, err := renderForm(rendering, params.Form) + if err != nil { + return nil, err + } + colors, err := renderPalette(params.Palette) + if err != nil { + return nil, err + } + artifact, err := rendering.WriteWith(form, view.Options{Palette: colors}) + if err != nil { + return nil, err + } + fills, err := rendering.Fills(colors) + if err != nil { + return nil, err + } + data := rendering.Data() + out := &renderResult{ + View: data.View, + Kind: string(data.Kind), + Stated: data.Stated, + Form: string(form), + Artifact: artifact, + Nodes: make([]renderNode, 0, len(data.Nodes)), + Edges: make([]renderEdge, 0, len(data.Edges)), + Columns: data.Columns, + Notices: data.Notices, + Palette: palette(data.Kind, source.KindOf(name)), + Version: doc.Version, + } + if out.Notices == nil { + out.Notices = []string{} + } + if c := data.Canvas; c != nil { + out.Canvas = &renderCanvas{Unit: c.Unit} + if c.HasSize { + w, h := c.Width, c.Height + out.Canvas.Width, out.Canvas.Height = &w, &h + } + } + s.renderNodes(out, snapshot, data.Nodes, fills) + s.renderEdges(out, snapshot, data.Edges) + for _, row := range data.Rows { + out.Rows = append(out.Rows, renderRow{Cells: row.Cells, Origin: origin(row.Origin)}) + } + return out, nil +} + +// renderNodes converts the rendering's nodes into out, each coloured as fills +// says; a node the document declares confines the palette to its notation and +// is admitted once all are known. +func (s *Server) renderNodes(out *renderResult, snapshot *model.Snapshot, nodes []view.NodeData, fills map[string]view.Fill) { + name := snapshot.Rendered.Name + var declared []declaredNode + for _, node := range nodes { + n := renderNode{ + ID: node.ID, + Kind: node.Kind, + Name: node.Name, + NameSynthesized: node.NameSynthesized, + Type: node.Type, + Detail: node.Detail, + Parent: node.Parent, + Fill: fills[node.ID].Fill, + Border: fills[node.ID].Border, + } + declaring := s.declaring(snapshot, node.Origin) + n.Origin = s.originOf(declaring, node.Origin) + if sym := nodeSymbol(s.targetable(snapshot, declaring), node.Origin); sym != nil { + if owners, ok := nodeOwners(sym); ok { + n.FQN = notationName(sym) + n.Notation = sym.Notation() + n.Owners = owners + n.DeclaredHere = declaring.Name == name + if out.Palette != nil && n.DeclaredHere { + out.Palette.confine(n.Notation) + declared = append(declared, declaredNode{node.ID, sym.Decl}) + } + } else { + decl := positionsOf(declaring).rangeOf(sym.DeclSpan) + n.Declaration = &decl + } + } + if g := node.Geometry; g != nil { + x, y := g.X, g.Y + n.X, n.Y, n.Collapsed = &x, &y, g.Collapsed + if g.HasSize { + w, h := g.Width, g.Height + n.Width, n.Height = &w, &h + } + } + out.Nodes = append(out.Nodes, n) + } + for _, d := range declared { + out.Palette.admit(d.id, d.decl) + } +} + +// renderEdges converts the rendering's edges into out, each with its route and +// the FQN or declaration range of the element it comes from. +func (s *Server) renderEdges(out *renderResult, snapshot *model.Snapshot, edges []view.EdgeData) { + for _, edge := range edges { + e := renderEdge{ + From: edge.From, + To: edge.To, + Label: edge.Label, + Kind: edge.Kind.String(), + } + declaring := s.declaring(snapshot, edge.Origin) + e.Origin = s.originOf(declaring, edge.Origin) + if sym := nodeSymbol(s.targetable(snapshot, declaring), edge.Origin); sym != nil { + if _, ok := nodeOwners(sym); ok { + e.FQN = notationName(sym) + } else { + decl := positionsOf(declaring).rangeOf(sym.DeclSpan) + e.Declaration = &decl + } + } + for _, p := range edge.Route { + e.Route = append(e.Route, renderPoint{X: p.X, Y: p.Y}) + } + out.Edges = append(out.Edges, e) + } +} + +// renderForm is the form to write: the one asked for, else the machine form of +// the rendering's kind. A form the writer does not know is refused rather than +// silently replaced. +func renderForm(rendering *view.Rendering, asked string) (view.Form, error) { + if asked == "" { + return rendering.Kind.MachineForm(), nil + } + form := view.Form(asked) + if slices.Contains(view.Forms(), form) { + return form, nil + } + names := make([]string, 0, len(view.Forms())) + for _, form := range view.Forms() { + names = append(names, strconv.Quote(string(form))) + } + return "", fmt.Errorf("%q is no rendering form: write %s or %s", asked, strings.Join(names[:len(names)-1], ", "), names[len(names)-1]) +} + +// renderPalette is the palette a request names, none when it names none, and +// an error listing the palettes there are when it names something else. +func renderPalette(asked string) (view.Palette, error) { + if asked == "" { + return "", nil + } + palette, ok := view.ParsePalette(asked) + if !ok { + return "", &view.UnknownPaletteError{Name: asked} + } + return palette, nil +} + +// declaring is the document an origin is located in, as the rendering read it: +// the snapshot's document of that name, else the bundled library file of that +// name, which is never rewritten; nil for an origin with no locatable +// declaration or in a document the session does not hold. +func (s *Server) declaring(snapshot *model.Snapshot, o view.Origin) *model.Document { + if !o.Located() { + return nil + } + if doc := snapshot.Document(o.Doc); doc != nil { + return doc + } + return s.ws.LibraryDocument(o.Doc) +} + +// targetable is declaring when the client may target its declarations: not a +// bundled library file, and not another document than the rendered one for a +// client that would place its declarations by name alone, unpinned. +func (s *Server) targetable(snapshot *model.Snapshot, declaring *model.Document) *model.Document { + if declaring == nil || s.ws.IsLibraryDocument(declaring.Name) { + return nil + } + if declaring.Name != snapshot.Rendered.Name && !s.clientSpeaksCrossDocument() { + return nil + } + return declaring +} + +// originIn is a core origin as a client navigates to it, placed in the text of +// the document declaring it as the rendering read it. +func (s *Server) originIn(snapshot *model.Snapshot, o view.Origin) *renderOrigin { + return s.originOf(s.declaring(snapshot, o), o) +} + +// originOf places o in doc, the document declaring it; a standard library +// declaration is located in its sysml-stdlib document. Nil for no document. +func (s *Server) originOf(doc *model.Document, o view.Origin) *renderOrigin { + if doc == nil { + return nil + } + pos := positionsOf(doc) + out := &renderOrigin{URI: s.documentURI(o.Doc), Range: pos.rangeOf(o.Span), Digest: doc.Digest()} + if o.Name.Len > 0 { + name := pos.rangeOf(o.Name) + out.SelectionRange = &name + } + return out +} + +// queueRenderChanged tells the client the renderings of a document are stale, +// once an editor burst settles: a redraw per keystroke is a rendering of text +// that has already been superseded. It is debounced on the window the +// cross-document sweep uses, and so always follows the diagnostics of the +// analysis it reports. +func (s *Server) queueRenderChanged(ctx context.Context, name string) { + if s.notifier == nil { + return + } + if s.renderNotify == nil { + s.notifyRenderChanged(ctx, name) + return + } + // The notification outlives the request whose context is cancelled on return. + ctx = context.WithoutCancel(ctx) + s.renderNotify.Trigger(name, func() { s.notifyRenderChanged(ctx, name) }) +} + +// notifyRenderChanged sends the notification, carrying no artifact: the client +// pulls a fresh one, so nothing is rendered for a hidden panel. +func (s *Server) notifyRenderChanged(ctx context.Context, name string) { + notifier := s.notifier + if notifier == nil { + return + } + version := 0 + if doc := s.ws.Document(name); doc != nil { + version = doc.Version + } + // Best-effort push, like diagnostics: a failed notification has no recovery + // path here, and the client re-renders on the next edit. + _ = notifier.Notify(ctx, MethodRenderChanged, &renderChangedParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: nameToURI(name)}, + Version: version, + }) +} diff --git a/internal/frontend/lsp/render_test.go b/internal/frontend/lsp/render_test.go new file mode 100644 index 0000000000..64532f6471 --- /dev/null +++ b/internal/frontend/lsp/render_test.go @@ -0,0 +1,1062 @@ +package lsp + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" + "sync" + "testing" + "time" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// renderModel declares one view per rendering kind this package produces, over a +// model that has parts to connect, a state machine and an action flow, so a +// rendering of each kind has something in it. +const renderModel = `package Kit { + part def Widget { + part cog : Cog; + part gear : Cog; + connect cog to gear; + } + part def Cog; + + state def WidgetStates { + entry; then off; + state off; + state running; + transition first off then running; + } + + action def Assemble { + action cut; + action fit; + first cut then fit; + } +} + +package KitViews { + private import Views::*; + private import StandardViewDefinitions::*; + + view widgetTree { + expose Kit::Widget; + } + + view widgetParts : InterconnectionView { + expose Kit::Widget; + } + + view widgetStates : StateTransitionView { + expose Kit::WidgetStates; + } + + view widgetActions : ActionFlowView { + expose Kit::Assemble; + } + + view widgetTable : GridView { + expose Kit::Widget; + } + + view widgetSequence : SequenceView { + expose Kit::Widget; + } + + view widgetGeometry : GeometryView { + expose Kit::Widget; + } +} +` + +// recorder is a client that records diagnostics and custom notifications in the +// order they were sent, so their ordering is testable. +type recorder struct { + baseClient + mu sync.Mutex + sent []string +} + +func (r *recorder) PublishDiagnostics(ctx context.Context, params *protocol.PublishDiagnosticsParams) error { + r.record("publishDiagnostics") + return nil +} + +func (r *recorder) Notify(ctx context.Context, method string, params interface{}) error { + r.record(method) + return nil +} + +func (r *recorder) record(what string) { + r.mu.Lock() + defer r.mu.Unlock() + r.sent = append(r.sent, what) +} + +func (r *recorder) all() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.sent...) +} + +// renderServer is a server holding one open document, as a session of an +// editor speaking the cross-document diagram contract does. +func renderServer(t *testing.T, name, src string) (*Server, uri.URI) { + t.Helper() + s := initializedServer(t, map[string]any{CrossDocumentCapability: true}) + docURI := uri.File(name) + if err := s.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: docURI, LanguageID: "sysml", Version: 1, Text: src, + }, + }); err != nil { + t.Fatalf("DidOpen err = %v", err) + } + return s, docURI +} + +// initializedServer is a server a client initialized with those experimental +// capabilities, none for a client predating them. +func initializedServer(t *testing.T, experimental map[string]any) *Server { + t.Helper() + s := NewServer(model.NewWorkspace()) + s.client = &recorder{} + var caps protocol.ClientCapabilities + if experimental != nil { + caps.Experimental = experimental + } + if _, err := s.Initialize(context.Background(), &protocol.InitializeParams{Capabilities: caps}); err != nil { + t.Fatalf("Initialize err = %v", err) + } + return s +} + +// call dispatches a custom request the way a served session does, through the +// handler chain, and returns the raw result the client would receive. +func call(t *testing.T, s *Server, method string, params any) (json.RawMessage, error) { + t.Helper() + req, err := jsonrpc2.NewCall(jsonrpc2.NewNumberID(1), method, params) + if err != nil { + t.Fatalf("build %s request: %v", method, err) + } + var ( + raw json.RawMessage + callErr error + ) + reply := func(ctx context.Context, result interface{}, err error) error { + if err != nil { + callErr = err + return nil + } + encoded, mErr := json.Marshal(result) + if mErr != nil { + t.Fatalf("marshal %s result: %v", method, mErr) + } + raw = encoded + return nil + } + handler := s.renderHandler(s.modelEditHandler(func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + t.Fatalf("%s was not handled: it fell through to the next handler", req.Method()) + return nil + })) + if err := handler(context.Background(), reply, req); err != nil { + t.Fatalf("dispatch %s: %v", method, err) + } + return raw, callErr +} + +// render is one opensysml/render request, decoded. +func render(t *testing.T, s *Server, docURI uri.URI, viewName string) *renderResult { + t.Helper() + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: viewName, + }) + if err != nil { + t.Fatalf("render %q: %v", viewName, err) + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + return &out +} + +// Every rendering kind this package produces is served over the protocol, with +// the artifact a client draws and the nodes it is made of. +func TestRenderServesEverySupportedKind(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + cases := []struct { + view string + kind view.Kind + form view.Form + }{ + {"KitViews::widgetTree", view.KindTree, view.FormMermaid}, + {"KitViews::widgetParts", view.KindInterconnection, view.FormMermaid}, + {"KitViews::widgetStates", view.KindState, view.FormMermaid}, + {"KitViews::widgetActions", view.KindAction, view.FormMermaid}, + {"KitViews::widgetTable", view.KindTable, view.FormMarkdown}, + {"KitViews::widgetSequence", view.KindSequence, view.FormMermaid}, + } + for _, tc := range cases { + t.Run(string(tc.kind), func(t *testing.T) { + out := render(t, s, docURI, tc.view) + if out.View != tc.view { + t.Errorf("view = %q, want %q", out.View, tc.view) + } + if out.Kind != string(tc.kind) { + t.Errorf("kind = %q, want %q", out.Kind, tc.kind) + } + if out.Form != string(tc.form) { + t.Errorf("form = %q, want %q", out.Form, tc.form) + } + if strings.TrimSpace(out.Artifact) == "" { + t.Error("artifact is empty") + } + if out.Version != 1 { + t.Errorf("version = %d, want the version the document was opened at", out.Version) + } + if tc.kind == view.KindTable { + if len(out.Rows) == 0 { + t.Error("a table rendering carries no rows") + } + return + } + if len(out.Nodes) == 0 { + t.Fatalf("%s rendering carries no nodes", tc.kind) + } + located := 0 + for _, node := range out.Nodes { + if node.Origin == nil { + continue + } + located++ + if node.Origin.URI != docURI { + t.Errorf("node %q is located in %q, want %q", node.ID, node.Origin.URI, docURI) + } + } + if located == 0 { + t.Errorf("no node of the %s rendering is located in the source", tc.kind) + } + }) + } +} + +// A node's origin is the range of the declaration it was built from, so clicking +// it lands on that declaration. +func TestRenderOriginsLocateTheDeclaration(t *testing.T) { + src := "package Kit {\n\tpart def Widget {\n\t\tpart cog : Cog;\n\t}\n\tpart def Cog;\n}\n" + s, docURI := renderServer(t, "kit.sysml", src) + out := render(t, s, docURI, "#tree") + for _, node := range out.Nodes { + if node.Name != "cog" { + continue + } + if node.Origin == nil { + t.Fatal("the node for cog carries no origin") + } + // `part cog : Cog;` is the third line, indented by one tab. + if node.Origin.Range.Start.Line != 2 || node.Origin.Range.Start.Character != 2 { + t.Fatalf("origin starts at %+v, want line 2 character 2", node.Origin.Range.Start) + } + // The selection range is `cog` alone, so clicking selects the name + // rather than the whole declaration. + sel := node.Origin.SelectionRange + if sel == nil { + t.Fatal("the node for cog carries no selection range") + } + if sel.Start.Line != 2 || sel.Start.Character != 7 || sel.End.Character != 10 { + t.Fatalf("selection range = %+v, want `cog` on line 2", *sel) + } + return + } + t.Fatalf("the #tree rendering has no node for cog: %+v", out.Nodes) +} + +// A node carries its declared type as a field of its own, so a client never +// parses the detail — which holds only the notes — to recover it. +func TestRenderNodesCarryTheTypeApartFromTheDetail(t *testing.T) { + src := "package Kit {\n\tpart def Widget {\n\t\tpart cog : Cog;\n\t\tpart gear;\n\t}\n\tpart def Cog;\n}\n" + s, docURI := renderServer(t, "kit.sysml", src) + out := render(t, s, docURI, "#tree") + want := map[string]renderNode{ + "Kit::Widget": {Kind: "part def"}, + "cog": {Kind: "part", Type: "Cog"}, + "gear": {Kind: "part"}, + } + for _, node := range out.Nodes { + expected, ok := want[node.Name] + if !ok { + continue + } + delete(want, node.Name) + if node.Kind != expected.Kind || node.Type != expected.Type || node.Detail != "" { + t.Errorf("node %s = kind %q type %q detail %q, want kind %q type %q and no detail", + node.Name, node.Kind, node.Type, node.Detail, expected.Kind, expected.Type) + } + } + for name := range want { + t.Errorf("the #tree rendering has no node named %s: %+v", name, out.Nodes) + } + wire, err := json.Marshal(out.Nodes) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{`"type":"Cog"`, `"type":""`, `"detail":""`} { + if !strings.Contains(string(wire), field) { + t.Errorf("the wire form lacks %s:\n%s", field, wire) + } + } +} + +// A behavior rendering serves the type of a typed action or state usage the same +// way, apart from the notes the detail holds. +func TestRenderBehaviorNodesCarryTheTypeApartFromTheDetail(t *testing.T) { + src := "package Ops {\n" + + "\taction def Warm;\n" + + "\taction run {\n\t\tfirst start;\n\t\taction warm : Warm;\n\t\tsuccession first start then warm;\n\t}\n" + + "\tstate def Heating;\n" + + "\tstate def Boiler {\n\t\tentry; then idle;\n\t\tstate idle;\n\t\tstate heating : Heating;\n" + + "\t\ttransition first idle then heating;\n\t}\n" + + "}\n" + s, docURI := renderServer(t, "ops.sysml", src) + want := map[string]renderNode{ + "warm": {Kind: "action", Type: "Warm"}, + "start": {Kind: "initial"}, + "heating": {Kind: "state", Type: "Heating"}, + "idle": {Kind: "state", Detail: "initial"}, + } + for view, field := range map[string]string{"#action:Ops::run": `"type":"Warm"`, "#state:Ops::Boiler": `"type":"Heating"`} { + out := render(t, s, docURI, view) + for _, node := range out.Nodes { + expected, ok := want[node.Name] + if !ok { + continue + } + delete(want, node.Name) + if node.Kind != expected.Kind || node.Type != expected.Type || node.Detail != expected.Detail { + t.Errorf("%s: node %s = kind %q type %q detail %q, want kind %q type %q detail %q", view, + node.Name, node.Kind, node.Type, node.Detail, expected.Kind, expected.Type, expected.Detail) + } + } + wire, err := json.Marshal(out.Nodes) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(wire), field) || !strings.Contains(string(wire), `"type":""`) { + t.Errorf("%s: the wire form lacks %s beside an empty type:\n%s", view, field, wire) + } + } + for name := range want { + t.Errorf("no behavior rendering has a node named %s", name) + } +} + +// A pseudo-view renders a document that declares no view, and says so. +func TestRenderPseudoViewOfADocumentWithNoViews(t *testing.T) { + s, docURI := renderServer(t, "plain.sysml", "package Kit {\n\tpart def Widget {\n\t\tpart cog : Cog;\n\t}\n\tpart def Cog;\n}\n") + out := render(t, s, docURI, "#tree") + if out.Kind != string(view.KindTree) { + t.Errorf("kind = %q, want %q", out.Kind, view.KindTree) + } + if out.View != "" { + t.Errorf("view = %q, want empty: no view was declared", out.View) + } + if !strings.Contains(out.Stated, "no view declared") { + t.Errorf("stated = %q, want it to say no view was declared", out.Stated) + } + + // The same document without a view named is refused, pointing at pseudo-views. + if _, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + }); err == nil || !strings.Contains(err.Error(), "#tree") { + t.Errorf("err = %v, want it to point at a pseudo-view", err) + } +} + +// A view name no longer in the document is refused with a message naming it, +// which is what a panel holding a stale pick receives. +func TestRenderRefusesAStaleViewName(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + if _, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::goneView", + }); err == nil || !strings.Contains(err.Error(), "no view named KitViews::goneView") { + t.Errorf("err = %v, want it to say there is no such view", err) + } + if _, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File("gone.sysml")}, + }); err == nil || !strings.Contains(err.Error(), "no such document") { + t.Errorf("err = %v, want it to say there is no such document", err) + } +} + +// An unsupported rendering kind is refused with the reason, and the view stays +// in the listing so a panel can say why it cannot be drawn. +func TestRenderAndViewsReportAnUnsupportedKind(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + _, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetGeometry", + }) + if err == nil || !strings.Contains(err.Error(), "geometry rendering") { + t.Fatalf("err = %v, want it to say a geometry rendering is not supported", err) + } + + raw, err := call(t, s, MethodViews, &viewsParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + }) + if err != nil { + t.Fatalf("views: %v", err) + } + var listing viewsResult + if err := json.Unmarshal(raw, &listing); err != nil { + t.Fatalf("decode views result: %v", err) + } + if !slices.Equal(listing.PseudoViews, view.PseudoViewSpecs()) { + t.Errorf("pseudoViews = %v, want %v", listing.PseudoViews, view.PseudoViewSpecs()) + } + if !slices.Contains(listing.PseudoViews, "#sequence") { + t.Errorf("pseudoViews = %v, want it to contain #sequence", listing.PseudoViews) + } + if len(listing.Views) != 7 { + t.Fatalf("listed %d views, want 7: %+v", len(listing.Views), listing.Views) + } + kinds := map[string]viewInfo{} + for _, info := range listing.Views { + kinds[info.Name] = info + } + for name, kind := range map[string]view.Kind{ + "KitViews::widgetTree": view.KindTree, + "KitViews::widgetParts": view.KindInterconnection, + "KitViews::widgetStates": view.KindState, + "KitViews::widgetActions": view.KindAction, + "KitViews::widgetTable": view.KindTable, + "KitViews::widgetSequence": view.KindSequence, + } { + info, ok := kinds[name] + if !ok { + t.Fatalf("%s is not listed", name) + } + if info.Kind != string(kind) || !info.Supported { + t.Errorf("%s: kind = %q supported = %v, want %q supported", name, info.Kind, info.Supported, kind) + } + } + geometry := kinds["KitViews::widgetGeometry"] + if geometry.Supported { + t.Error("the geometry view is listed as supported") + } + if !strings.Contains(geometry.Reason, "geometry rendering") { + t.Errorf("reason = %q, want it to say a geometry rendering is not supported", geometry.Reason) + } +} + +// Each listed view carries the range of its declaration and of its name in the +// document, in LSP positions, so a client can tell which view the cursor is in. +func TestViewsLocateEachDeclaration(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + raw, err := call(t, s, MethodViews, &viewsParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + }) + if err != nil { + t.Fatalf("views: %v", err) + } + var listing viewsResult + if err := json.Unmarshal(raw, &listing); err != nil { + t.Fatalf("decode views result: %v", err) + } + lines := strings.Split(renderModel, "\n") + text := func(r protocol.Range) string { + if r.Start.Line != r.End.Line { + return strings.Join(append([]string{lines[r.Start.Line][r.Start.Character:]}, lines[r.Start.Line+1:r.End.Line]...), "\n") + + "\n" + lines[r.End.Line][:r.End.Character] + } + return lines[r.Start.Line][r.Start.Character:r.End.Character] + } + for _, info := range listing.Views { + if info.Range == nil || info.SelectionRange == nil { + t.Fatalf("%s: range = %v selectionRange = %v, want both", info.Name, info.Range, info.SelectionRange) + } + short := strings.TrimPrefix(info.Name, "KitViews::") + if got := text(*info.SelectionRange); got != short { + t.Errorf("%s: selectionRange covers %q, want %q", info.Name, got, short) + } + decl := text(*info.Range) + if !strings.HasPrefix(decl, "view "+short) || !strings.HasSuffix(decl, "}") { + t.Errorf("%s: range covers %q, want the whole view declaration", info.Name, decl) + } + } + // Two declarations never overlap, so a cursor is in at most one of them. + before := func(a, b protocol.Position) bool { + return a.Line < b.Line || (a.Line == b.Line && a.Character <= b.Character) + } + for i, a := range listing.Views { + for _, b := range listing.Views[i+1:] { + if !before(a.Range.End, b.Range.Start) && !before(b.Range.End, a.Range.Start) { + t.Errorf("%s and %s overlap: %v and %v", a.Name, b.Name, *a.Range, *b.Range) + } + } + } +} + +// A form the writer does not know is refused rather than silently replaced, and +// a known one is honored. +func TestRenderHonorsTheFormAsked(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetTree", + Form: string(view.FormText), + }) + if err != nil { + t.Fatalf("render as text: %v", err) + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + if out.Form != string(view.FormText) { + t.Errorf("form = %q, want %q", out.Form, view.FormText) + } + if _, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetTree", + Form: "png", + }); err == nil || !strings.Contains(err.Error(), "no rendering form") || !strings.Contains(err.Error(), `"dot"`) || !strings.Contains(err.Error(), `"plantuml"`) { + t.Errorf("err = %v, want it to refuse the form and offer dot and plantuml", err) + } +} + +// Every form the server advertises in initialize is answered in that form when +// asked for by a view that has it, and the advertised list is the writer's own. +func TestRenderAnswersEveryAdvertisedForm(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + res, err := s.Initialize(context.Background(), &protocol.InitializeParams{}) + if err != nil { + t.Fatalf("Initialize err = %v", err) + } + experimental, ok := res.Capabilities.Experimental.(map[string]any) + if !ok { + t.Fatalf("Experimental = %#v, want a map", res.Capabilities.Experimental) + } + advertised, ok := experimental[RenderFormsCapability].([]string) + if !ok { + t.Fatalf("%s = %#v, want a list of forms", RenderFormsCapability, experimental[RenderFormsCapability]) + } + if want := []string{"text", "mermaid", "markdown", "dot", "plantuml"}; !slices.Equal(advertised, want) { + t.Fatalf("%s = %v, want %v", RenderFormsCapability, advertised, want) + } + // A table is the one kind written in Markdown; the tree view has every other form. + viewFor := map[string]string{"markdown": "KitViews::widgetTable"} + for _, form := range advertised { + name := viewFor[form] + if name == "" { + name = "KitViews::widgetTree" + } + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: name, + Form: form, + }) + if err != nil { + t.Errorf("render %s as %s: %v", name, form, err) + continue + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode %s render result: %v", form, err) + } + if out.Form != form { + t.Errorf("%s: form = %q, want %q", name, out.Form, form) + } + if out.Artifact == "" { + t.Errorf("%s as %s: empty artifact", name, form) + } + } +} + +// The DOT form is honored for a graph-shaped view, is the same rendering as a +// digraph, and is refused for a kind that has none with the forms it has. +func TestRenderWritesDotWhenAskedFor(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + for _, name := range []string{"KitViews::widgetTree", "KitViews::widgetParts", "KitViews::widgetStates", "KitViews::widgetActions"} { + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: name, + Form: string(view.FormDot), + }) + if err != nil { + t.Fatalf("%s as dot: %v", name, err) + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + if out.Form != string(view.FormDot) { + t.Errorf("%s: form = %q, want %q", name, out.Form, view.FormDot) + } + for _, want := range []string{"// view: " + name, "// layout: dot", "digraph \"" + name + "\" {"} { + if !strings.Contains(out.Artifact, want) { + t.Errorf("%s: artifact is missing %q:\n%s", name, want, out.Artifact) + } + } + if len(out.Nodes) == 0 { + t.Errorf("%s: the DOT result carries no nodes", name) + } + } + for _, name := range []string{"KitViews::widgetTable", "KitViews::widgetSequence"} { + _, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: name, + Form: string(view.FormDot), + }) + if err == nil || !strings.Contains(err.Error(), "not written as dot") { + t.Errorf("%s as dot: err = %v, want the form refused", name, err) + } + } +} + +// The PlantUML form is honored for every graph-shaped view, the sequence +// included, and is refused for a table with the forms it has. +func TestRenderWritesPlantUMLWhenAskedFor(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + for _, name := range []string{"KitViews::widgetTree", "KitViews::widgetParts", "KitViews::widgetStates", "KitViews::widgetActions", "KitViews::widgetSequence"} { + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: name, + Form: string(view.FormPlantUML), + }) + if err != nil { + t.Fatalf("%s as plantuml: %v", name, err) + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + if out.Form != string(view.FormPlantUML) { + t.Errorf("%s: form = %q, want %q", name, out.Form, view.FormPlantUML) + } + for _, want := range []string{"@startuml\n' " + name + " — ", "\n", "\n@enduml\n"} { + if !strings.Contains(out.Artifact, want) { + t.Errorf("%s: artifact is missing %q:\n%s", name, want, out.Artifact) + } + } + if len(out.Nodes) == 0 { + t.Errorf("%s: the PlantUML result carries no nodes", name) + } + } + _, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetTable", + Form: string(view.FormPlantUML), + }) + if err == nil || !strings.Contains(err.Error(), "not written as plantuml") { + t.Errorf("table as plantuml: err = %v, want the form refused", err) + } + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetTree", + Form: string(view.FormPlantUML), + Palette: string(view.PaletteOkabeIto), + }) + if err != nil { + t.Fatalf("render plantuml with a palette: %v", err) + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + if !strings.Contains(out.Artifact, ">> #") { + t.Errorf("the PlantUML artifact is not filled from the palette:\n%s", out.Artifact) + } + if _, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetTree", + Form: string(view.FormPlantUML), + Palette: "rainbow", + }); err == nil || !strings.Contains(err.Error(), `unknown palette "rainbow"`) { + t.Errorf("err = %v, want it to refuse the palette by name", err) + } +} + +// A palette in the request fills the DOT artifact's nodes, is noted as not +// represented in a Mermaid artifact, and an unknown one is refused by name +// with the palettes there are. +func TestRenderFillsFromThePaletteAsked(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetTree", + Form: string(view.FormDot), + Palette: string(view.PaletteOkabeIto), + }) + if err != nil { + t.Fatalf("render with a palette: %v", err) + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + if !strings.Contains(out.Artifact, `fillcolor="#`) || !strings.Contains(out.Artifact, "penwidth=1, label=<") { + t.Errorf("the DOT artifact is not filled from the palette:\n%s", out.Artifact) + } + raw, err = call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetTree", + Form: string(view.FormMermaid), + Palette: string(view.PaletteViridis), + }) + if err != nil { + t.Fatalf("render Mermaid with a palette: %v", err) + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + if !strings.Contains(out.Artifact, "%% not represented: palette viridis; only the DOT and PlantUML forms fill nodes by keyword family") { + t.Errorf("Mermaid does not note the palette:\n%s", out.Artifact) + } + _, err = call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetTree", + Form: string(view.FormDot), + Palette: "rainbow", + }) + want := `unknown palette "rainbow"; the palettes are okabe-ito, tol-bright, tol-muted, tol-light, brewer-set2, brewer-dark2, viridis, cividis` + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want it to refuse the palette by name", err) + } +} + +// A palette colours the result's nodes as the DOT artifact of the same request +// does, hex for hex, whatever form is asked for; a control node stays uncoloured, +// and no node is coloured when no palette is asked for. +func TestRenderNodesCarryThePaletteFills(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + fillLine := regexp.MustCompile(`^\s*"([^"]+)" \[.*fillcolor="(#[0-9A-F]{6})", color="(#[0-9A-F]{6})"`) + for _, form := range []view.Form{view.FormDot, view.FormMermaid} { + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetStates", + Form: string(form), + Palette: string(view.PaletteTolBright), + }) + if err != nil { + t.Fatalf("render %s with a palette: %v", form, err) + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + got := map[string][2]string{} + for _, n := range out.Nodes { + if n.Fill != "" || n.Border != "" { + got[n.ID] = [2]string{n.Fill, n.Border} + } + if n.Kind == "initial" && (n.Fill != "" || n.Border != "") { + t.Errorf("%s: the initial pseudostate %s is coloured %s/%s", form, n.ID, n.Fill, n.Border) + } + } + if len(got) < 2 { + t.Errorf("%s: only %d nodes coloured: %+v", form, len(got), out.Nodes) + } + if form == view.FormDot { + want := map[string][2]string{} + for _, line := range strings.Split(out.Artifact, "\n") { + if m := fillLine.FindStringSubmatch(line); m != nil { + want[m[1]] = [2]string{m[2], m[3]} + } + } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("nodes coloured %v, the DOT artifact %v", got, want) + } + } + } + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetStates", + }) + if err != nil { + t.Fatalf("render without a palette: %v", err) + } + if strings.Contains(string(raw), `"fill"`) || strings.Contains(string(raw), `"border"`) { + t.Errorf("a rendering without a palette carries colours:\n%s", raw) + } +} + +// A sequence participant carries the fill alone, as PlantUML colours no +// participant border; the border key is absent from the wire, not empty. +func TestRenderSequenceParticipantsFillWithoutBorder(t *testing.T) { + s, docURI := renderServer(t, "kit.sysml", renderModel) + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::widgetSequence", + Form: string(view.FormPlantUML), + Palette: string(view.PaletteOkabeIto), + }) + if err != nil { + t.Fatalf("render the sequence with a palette: %v", err) + } + var out renderResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + filled := 0 + for _, n := range out.Nodes { + if n.Fill != "" { + filled++ + } + if n.Border != "" { + t.Errorf("participant %s has border %s, want none", n.ID, n.Border) + } + } + if filled == 0 { + t.Errorf("no participant filled: %+v", out.Nodes) + } + if strings.Contains(string(raw), `"border"`) { + t.Errorf("a sequence rendering carries a border key:\n%s", raw) + } + if strings.Contains(out.Artifact, ";line:") { + t.Errorf("the PlantUML artifact colours a participant border:\n%s", out.Artifact) + } +} + +// A view's layout annotations reach the client as geometry on nodes and edges +// and a canvas on the result; a rendering without any carries none of the fields. +func TestRenderCarriesLayoutGeometry(t *testing.T) { + const src = `package Kit { + private import DiagramLayout::*; + part def Widget { + part cog : Cog; + part gear : Cog { @Layout { x = 5; y = 6; } } + connection mesh : Mesh connect cog to gear; + } + part def Cog; + connection def Mesh; +} + +package KitViews { + private import Views::*; + private import StandardViewDefinitions::*; + private import DiagramLayout::*; + + view placed : InterconnectionView { + expose Kit::Widget; + @Canvas { unit = "px"; width = 640; height = 0; } + metadata Layout about Kit::Widget::cog { x = 10; y = 20; width = 90; height = 40; collapsed = true; } + metadata Route about Kit::Widget::mesh { points = (1, 2, 3, 4); } + } +} +` + s, docURI := renderServer(t, "kit.sysml", src) + raw, err := call(t, s, MethodRender, &renderParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, + View: "KitViews::placed", + }) + if err != nil { + t.Fatalf("render: %v", err) + } + var out struct { + Nodes []map[string]json.RawMessage `json:"nodes"` + Edges []map[string]json.RawMessage `json:"edges"` + Canvas map[string]json.RawMessage `json:"canvas"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode render result: %v", err) + } + if got := fmt.Sprintf("%s|%s|%s", out.Canvas["unit"], out.Canvas["width"], out.Canvas["height"]); got != `"px"|640|0` { + t.Errorf("canvas = %s, want unit px, width 640 and the explicit height 0", got) + } + nodeBy := func(name string) map[string]json.RawMessage { + for _, n := range out.Nodes { + if string(n["name"]) == fmt.Sprintf("%q", name) { + return n + } + } + t.Fatalf("no node named %s in %v", name, out.Nodes) + return nil + } + cog := nodeBy("cog") + if got := fmt.Sprintf("%s %s %s %s %s", cog["x"], cog["y"], cog["width"], cog["height"], cog["collapsed"]); got != "10 20 90 40 true" { + t.Errorf("cog geometry = %q, want the view-local Layout", got) + } + gear := nodeBy("gear") + if got := fmt.Sprintf("%s %s %s %s %s", gear["x"], gear["y"], gear["width"], gear["height"], gear["collapsed"]); got != "5 6 " { + t.Errorf("gear geometry = %q, want the inline Layout with no size and no collapsed field", got) + } + for _, n := range out.Nodes { + if name := string(n["name"]); name == `"cog"` || name == `"gear"` { + continue + } + for _, field := range []string{"x", "y", "width", "height", "collapsed"} { + if _, ok := n[field]; ok { + t.Errorf("unplaced node %s carries %q", n["name"], field) + } + } + } + if len(out.Edges) != 1 { + t.Fatalf("edges = %v, want the one connection", out.Edges) + } + if got := string(out.Edges[0]["route"]); got != `[{"x":1,"y":2},{"x":3,"y":4}]` { + t.Errorf("route = %s, want the connector's Route waypoints", got) + } + + s, docURI = renderServer(t, "plain.sysml", renderModel) + plain := render(t, s, docURI, "KitViews::widgetParts") + if plain.Canvas != nil { + t.Errorf("a view without a Canvas carries %+v", plain.Canvas) + } + for _, n := range plain.Nodes { + if n.X != nil || n.Y != nil || n.Width != nil || n.Height != nil || n.Collapsed { + t.Errorf("unannotated node %+v carries geometry", n) + } + } + for _, e := range plain.Edges { + if e.Route != nil { + t.Errorf("unannotated edge %+v carries a route", e) + } + } +} + +// An edit is followed by the notification that the renderings went stale, after +// the diagnostics of the same analysis, at the version the edit produced. +func TestDidChangeNotifiesRenderChangedAfterDiagnostics(t *testing.T) { + s := NewServer(model.NewWorkspace()) + rec := &recorder{} + s.client = rec + s.notifier = rec + ctx := context.Background() + docURI := uri.File("kit.sysml") + if err := s.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: docURI, LanguageID: "sysml", Version: 1, Text: "package Kit {\n\tpart def Widget;\n}\n", + }, + }); err != nil { + t.Fatalf("DidOpen err = %v", err) + } + // A burst of edits, as typing is. + for version := 2; version <= 6; version++ { + s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{ + {Text: fmt.Sprintf("package Kit {\n\tpart def Widget;\n\tpart def Cog%d;\n}\n", version)}, + }, version) + } + + // The notification arrives once the burst settles rather than per keystroke. + deadline := time.Now().Add(5 * time.Second) + var sent []string + for time.Now().Before(deadline) { + sent = rec.all() + if len(sent) > 0 && sent[len(sent)-1] == MethodRenderChanged { + break + } + time.Sleep(10 * time.Millisecond) + } + if len(sent) == 0 || sent[len(sent)-1] != MethodRenderChanged { + t.Fatalf("sent %v, want it to end with %s", sent, MethodRenderChanged) + } + firstRender := -1 + for i, what := range sent { + if what == MethodRenderChanged { + firstRender = i + break + } + } + if firstRender <= 0 || sent[firstRender-1] != "publishDiagnostics" { + t.Fatalf("sent %v, want the diagnostics of an analysis before its %s", sent, MethodRenderChanged) + } + if renders, publishes := countOf(sent, MethodRenderChanged), countOf(sent, "publishDiagnostics"); renders >= publishes { + t.Errorf("sent %d %s notifications for %d publications, want fewer: the burst is debounced", renders, MethodRenderChanged, publishes) + } + + // The notification reports the version the rendering would be made from. + out := render(t, s, docURI, "#tree") + if out.Version != 6 { + t.Errorf("version = %d, want the version the edit produced", out.Version) + } +} + +// countOf is how many times what was sent. +func countOf(sent []string, what string) int { + n := 0 + for _, s := range sent { + if s == what { + n++ + } + } + return n +} + +// The server tells a client it speaks the render methods, so an old server and a +// new client degrade instead of erroring. +func TestInitializeAdvertisesTheRenderCapability(t *testing.T) { + s := NewServer(model.NewWorkspace()) + res, err := s.Initialize(context.Background(), &protocol.InitializeParams{}) + if err != nil { + t.Fatalf("Initialize err = %v", err) + } + experimental, ok := res.Capabilities.Experimental.(map[string]any) + if !ok { + t.Fatalf("Experimental = %#v, want a map", res.Capabilities.Experimental) + } + if experimental["openSysmlRender"] != true { + t.Errorf("openSysmlRender = %#v, want true", experimental["openSysmlRender"]) + } + if experimental[CrossDocumentCapability] != true { + t.Errorf("%s = %#v, want true", CrossDocumentCapability, experimental[CrossDocumentCapability]) + } + if experimental[RenderPaletteCapability] != true { + t.Errorf("%s = %#v, want true", RenderPaletteCapability, experimental[RenderPaletteCapability]) + } +} + +// A rendering's version, node names, FQNs and ranges all describe one document +// snapshot, however the document changes while renders are in flight: a node +// built from one revision is never named through the scope of another. +func TestRenderSnapshotsOneDocumentRevision(t *testing.T) { + // Both names are five letters, so the declarations share a span across + // revisions and a scope of the wrong revision would still find a symbol. + revisions := []string{"package P {\n part def Alpha;\n}\n", "package P {\n part def Bravo;\n}\n"} + s, docURI := renderServer(t, "flip.sysml", revisions[0]) + name := docURI.Filename() + + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + for version := 2; ; version++ { + select { + case <-stop: + return + default: + } + s.ws.Update(name, []byte(revisions[(version-1)%2]), version) + } + }() + defer func() { close(stop); <-done }() + + params := &renderParams{TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "#tree"} + for i := 0; i < 300; i++ { + out, err := s.Render(params) + if err != nil { + t.Fatalf("render: %v", err) + } + declared := []string{"Alpha", "Bravo"}[(out.Version-1)%2] + other := []string{"Bravo", "Alpha"}[(out.Version-1)%2] + for _, n := range out.Nodes { + if strings.Contains(n.Name, other) || strings.Contains(n.FQN, other) { + t.Fatalf("version %d declares %s, rendering has node %q with fqn %q", out.Version, declared, n.Name, n.FQN) + } + if strings.Contains(n.Name, declared) && n.FQN != "P::"+declared { + t.Fatalf("version %d node %q has fqn %q, want %q", out.Version, n.Name, n.FQN, "P::"+declared) + } + } + } +} diff --git a/internal/lsp/require_reference_test.go b/internal/frontend/lsp/require_reference_test.go similarity index 97% rename from internal/lsp/require_reference_test.go rename to internal/frontend/lsp/require_reference_test.go index aad5848430..92d57b3ba5 100644 --- a/internal/lsp/require_reference_test.go +++ b/internal/frontend/lsp/require_reference_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // The requirement named by `require Q::r` is a reference like any other, so the diff --git a/internal/frontend/lsp/requirement_member_short_name_test.go b/internal/frontend/lsp/requirement_member_short_name_test.go new file mode 100644 index 0000000000..6546bc8415 --- /dev/null +++ b/internal/frontend/lsp/requirement_member_short_name_test.go @@ -0,0 +1,189 @@ +package lsp + +import ( + "context" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +const requirementShortNameSrc = `package P { + part def T; + constraint def C; + requirement def R { + subject x : T; + assume constraint ac : C; + require constraint rc : C; + subject : T; + } + requirement def R2 :> R { + subject :>> s; + assume constraint :>> a; + require constraint :>> r; + subject :>> t; + } + part p : R::t; +}` + +func openRequirementShortNameDoc(t *testing.T) (*Server, *model.Workspace, string) { + t.Helper() + ws := model.NewWorkspace() + s := NewServer(ws) + name := uri.File("/tmp/requirement_short_names.sysml").Filename() + ws.Open(name, []byte(requirementShortNameSrc), 1) + return s, ws, name +} + +func requirementShortNamePos(t *testing.T, anchor string, skip int) protocol.Position { + t.Helper() + off := strings.Index(requirementShortNameSrc, anchor) + if off < 0 { + t.Fatalf("anchor %q not found", anchor) + } + return offsetToPosition([]byte(requirementShortNameSrc), off+skip) +} + +// The short name of a subject, assume or require member is a name a reference +// jumps from, as `part

    ` is: `:>> s` lands on the subject that declares . +func TestDefinitionRequirementMemberShortName(t *testing.T) { + s, _, name := openRequirementShortNameDoc(t) + cases := []struct { + anchor string + line uint32 + }{ + {":>> s;", 4}, + {":>> a;", 5}, + {":>> r;", 6}, + {":>> t;", 7}, + {"R::t;", 7}, + } + for _, tc := range cases { + locs, err := s.Definition(context.Background(), &protocol.DefinitionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: requirementShortNamePos(t, tc.anchor, len(tc.anchor)-2), + }, + }) + if err != nil { + t.Fatalf("%s: Definition err = %v", tc.anchor, err) + } + if len(locs) != 1 || locs[0].Range.Start.Line != tc.line { + t.Errorf("%s: locations = %+v, want one on line %d", tc.anchor, locs, tc.line) + } + } +} + +// Hovering a reference written with the short name describes the member it +// names; hovering the short name in the declaration describes that member. +func TestHoverRequirementMemberShortName(t *testing.T) { + s, _, name := openRequirementShortNameDoc(t) + hover := func(anchor string, skip int) string { + res, err := s.Hover(context.Background(), &protocol.HoverParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: requirementShortNamePos(t, anchor, skip), + }, + }) + if err != nil { + t.Fatalf("%s: Hover err = %v", anchor, err) + } + if res == nil { + t.Fatalf("%s: no hover", anchor) + } + return res.Contents.Value + } + for _, tc := range []struct{ anchor, want string }{ + {":>> s;", "subject x"}, + {":>> a;", "assume constraint ac"}, + {":>> r;", "require constraint rc"}, + {":>> t;", "subject t"}, + } { + if got := hover(tc.anchor, len(tc.anchor)-2); !strings.Contains(got, tc.want) { + t.Errorf("hover at %s = %q, want %q", tc.anchor, got, tc.want) + } + } + if got := hover(" x", 1); !strings.Contains(got, "subject x") { + t.Errorf("hover on = %q, want the subject x", got) + } + if got := hover(" :", 1); !strings.Contains(got, "subject t") { + t.Errorf("hover on = %q, want the subject t", got) + } +} + +// Document symbols list a member under its name, or under its short name when +// that is the only name it declares. +func TestDocumentSymbolRequirementMemberShortNames(t *testing.T) { + s, _, name := openRequirementShortNameDoc(t) + res, err := s.DocumentSymbol(context.Background(), &protocol.DocumentSymbolParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + }) + if err != nil { + t.Fatalf("DocumentSymbol err = %v", err) + } + pkg := res[0].(protocol.DocumentSymbol) + var names []string + for _, child := range pkg.Children { + if child.Name != "R" { + continue + } + for _, member := range child.Children { + names = append(names, member.Name) + } + } + if got := strings.Join(names, " "); got != "x ac rc t" { + t.Errorf("R members = %q, want %q", got, "x ac rc t") + } +} + +// Member completion offers the short names of a requirement's subject, assume +// and require members beside their names. +func TestCompletionOffersRequirementMemberShortNames(t *testing.T) { + src := strings.Replace(requirementShortNameSrc, "part p : R::t;", "part p : R::t;\n\tpart q : R::", 1) + items := completionAt(t, src, "part q : R::") + for _, label := range []string{"s", "x", "a", "ac", "r", "rc", "t"} { + if _, ok := items[label]; !ok { + t.Errorf("completion of R:: missing %q; got %v", label, labelsOf(items)) + } + } +} + +// Renaming from the declaration of a short-name-only subject renames the short +// name and every reference to it, as for `part

    ;`. Renaming a subject's name +// leaves references written with its short name alone, as for `part

    x`, and +// renaming from such a reference renames the short name instead. +func TestRenameRequirementMemberShortName(t *testing.T) { + _, ws, name := openRequirementShortNameDoc(t) + got, err := applyRename(t, ws, name, "t> : T", "target") + if err != nil { + t.Fatalf("Rename err = %v", err) + } + want := strings.NewReplacer(" : T", " : T", ":>> t;", ":>> target;", "R::t;", "R::target;"). + Replace(requirementShortNameSrc) + if got[name] != want { + t.Fatalf("got:\n%s\nwant:\n%s", got[name], want) + } + + _, ws, name = openRequirementShortNameDoc(t) + got, err = applyRename(t, ws, name, "x : T", "vehicle") + if err != nil { + t.Fatalf("Rename err = %v", err) + } + want = strings.NewReplacer(" x : T", " vehicle : T").Replace(requirementShortNameSrc) + if got[name] != want { + t.Fatalf("got:\n%s\nwant:\n%s", got[name], want) + } + + _, ws, name = openRequirementShortNameDoc(t) + got, err = applyRename(t, ws, name, "s;", "veh") + if err != nil { + t.Fatalf("Rename err = %v", err) + } + want = strings.NewReplacer(" x : T", " x : T", ":>> s;", ":>> veh;").Replace(requirementShortNameSrc) + if got[name] != want { + t.Fatalf("got:\n%s\nwant:\n%s", got[name], want) + } +} diff --git a/internal/frontend/lsp/run_test.go b/internal/frontend/lsp/run_test.go new file mode 100644 index 0000000000..9dfaed5ce1 --- /dev/null +++ b/internal/frontend/lsp/run_test.go @@ -0,0 +1,194 @@ +package lsp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net" + "strconv" + "strings" + "testing" + "time" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// writeMessage frames obj as an LSP message on conn. +func writeMessage(conn net.Conn, obj any) error { + body, err := json.Marshal(obj) + if err != nil { + return err + } + _, err = fmt.Fprintf(conn, "Content-Length: %d\r\n\r\n%s", len(body), body) + return err +} + +// readMessage reads one framed LSP message from r, failing on unframed output. +func readMessage(t *testing.T, r *bufio.Reader) map[string]any { + t.Helper() + msg, err := readFramedMessage(r) + if err != nil { + t.Fatal(err) + } + return msg +} + +// readFramedMessage reads one framed LSP message from r; it is usable off the +// test goroutine, where a failing test cannot be reported directly. +func readFramedMessage(r *bufio.Reader) (map[string]any, error) { + length := -1 + for { + line, err := r.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("read header: %w", err) + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + break + } + name, value, ok := strings.Cut(line, ":") + if !ok { + return nil, fmt.Errorf("unframed server output: %q", line) + } + if strings.EqualFold(strings.TrimSpace(name), "Content-Length") { + length, err = strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return nil, fmt.Errorf("bad Content-Length %q: %w", value, err) + } + } + } + if length < 0 { + return nil, fmt.Errorf("message without Content-Length header") + } + body := make([]byte, length) + for read := 0; read < length; { + n, err := r.Read(body[read:]) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + read += n + } + var msg map[string]any + if err := json.Unmarshal(body, &msg); err != nil { + return nil, fmt.Errorf("unmarshal %q: %w", body, err) + } + return msg, nil +} + +// A served session must handle $/cancelRequest: an unwrapped handler answers it +// as an unknown method, and protocol.CancelHandler rejects the numeric ids real +// clients send as malformed. +func TestRunHandlerChainHandlesCancelRequest(t *testing.T) { + for _, id := range []any{int32(7), "req-7"} { + handler := runHandler(NewServer(model.NewWorkspace())) + req, err := jsonrpc2.NewNotification(protocol.MethodCancelRequest, protocol.CancelParams{ID: id}) + if err != nil { + t.Fatalf("NewNotification: %v", err) + } + replied := make(chan error, 1) + reply := func(ctx context.Context, result any, err error) error { + replied <- err + return nil + } + if err := handler(context.Background(), reply, req); err != nil { + t.Fatalf("handler: %v", err) + } + select { + case err := <-replied: + if err != nil { + t.Errorf("$/cancelRequest with id %v answered with %v, want it handled", id, err) + } + case <-time.After(5 * time.Second): + t.Fatalf("$/cancelRequest with id %v was never answered", id) + } + } +} + +// Regression: Run must start exactly one read loop. protocol.NewServer already +// starts one, so an extra conn.Go raced two readers over the same stream and +// corrupted framing after a few messages. +func TestRunServesEveryRequestOverOneStream(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { _ = client.Close() }) + + s := NewServer(model.NewWorkspace()) + done := make(chan error, 1) + go func() { done <- s.Run(context.Background(), server) }() + + // Writes run concurrently with reads: net.Pipe is unbuffered, so the + // server's diagnostics notifications must be drained as they are sent. + const requests = 25 + writeErr := make(chan error, 1) + go func() { + messages := []any{ + map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": map[string]any{"capabilities": map[string]any{}}, + }, + map[string]any{"jsonrpc": "2.0", "method": "initialized", "params": map[string]any{}}, + map[string]any{ + "jsonrpc": "2.0", "method": "textDocument/didOpen", + "params": map[string]any{"textDocument": map[string]any{ + "uri": "file:///tmp/run.sysml", "languageId": "sysml", "version": 1, + "text": "package P {\n part def Wheel { attribute pressure; }\n part w : Wheel;\n}\n", + }}, + }, + } + for id := 2; id <= requests+1; id++ { + messages = append(messages, map[string]any{ + "jsonrpc": "2.0", "id": id, "method": "textDocument/completion", + "params": map[string]any{ + "textDocument": map[string]any{"uri": "file:///tmp/run.sysml"}, + "position": map[string]any{"line": 2, "character": 18}, + }, + }) + } + messages = append(messages, map[string]any{"jsonrpc": "2.0", "id": 100, "method": "shutdown"}) + for _, msg := range messages { + if err := writeMessage(client, msg); err != nil { + writeErr <- err + return + } + } + writeErr <- nil + }() + + if err := client.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + r := bufio.NewReader(client) + // Responses may arrive in any order: the handler chain is asynchronous. + seen := map[int]bool{} + for len(seen) < requests+2 { + msg := readMessage(t, r) + id, ok := msg["id"].(float64) + if !ok { + continue // a notification, e.g. publishDiagnostics + } + if seen[int(id)] { + t.Fatalf("duplicate response for id %d", int(id)) + } + seen[int(id)] = true + } + for id := 1; id <= requests+1; id++ { + if !seen[id] { + t.Errorf("no response for request id %d", id) + } + } + if !seen[100] { + t.Error("no response for the shutdown request") + } + if err := <-writeErr; err != nil { + t.Fatalf("write: %v", err) + } + _ = client.Close() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Run did not return after the stream closed") + } +} diff --git a/internal/lsp/semantictokens.go b/internal/frontend/lsp/semantictokens.go similarity index 88% rename from internal/lsp/semantictokens.go rename to internal/frontend/lsp/semantictokens.go index 2c98ee81eb..ab91cb538e 100644 --- a/internal/lsp/semantictokens.go +++ b/internal/frontend/lsp/semantictokens.go @@ -7,8 +7,8 @@ import ( "go.lsp.dev/protocol" - "github.com/Open-MBEE/OpenSysML/internal/core/highlight" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/semantic/highlight" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // semanticTokensProvider is the LSP 3.16 capability shape, declared here because @@ -38,32 +38,28 @@ func semanticTokensLegend() protocol.SemanticTokensLegend { // SemanticTokensFull answers the semantic tokens of a whole document, a bundled // library one included. func (s *Server) SemanticTokensFull(ctx context.Context, params *protocol.SemanticTokensParams) (*protocol.SemanticTokens, error) { - name := uriToName(params.TextDocument.URI) - doc := s.document(name) - if doc == nil { + content, toks := s.ws.HighlightTokens(uriToName(params.TextDocument.URI)) + if content == nil { return &protocol.SemanticTokens{}, nil } - return &protocol.SemanticTokens{ - Data: encodeTokens(doc.Content, s.ws.HighlightTokens(name)), - }, nil + return &protocol.SemanticTokens{Data: encodeTokens(content, toks)}, nil } // SemanticTokensRange answers the tokens overlapping a range, the document's // tokens filtered: highlighting a name resolves the whole document either way. func (s *Server) SemanticTokensRange(ctx context.Context, params *protocol.SemanticTokensRangeParams) (*protocol.SemanticTokens, error) { - name := uriToName(params.TextDocument.URI) - doc := s.document(name) - if doc == nil { + content, toks := s.ws.HighlightTokens(uriToName(params.TextDocument.URI)) + if content == nil { return &protocol.SemanticTokens{}, nil } - want := rangeToSpan(doc.Content, params.Range) + want := rangeToSpan(content, params.Range) var in []highlight.Token - for _, tok := range s.ws.HighlightTokens(name) { + for _, tok := range toks { if tok.Span.Offset < want.End() && tok.Span.End() > want.Offset { in = append(in, tok) } } - return &protocol.SemanticTokens{Data: encodeTokens(doc.Content, in)}, nil + return &protocol.SemanticTokens{Data: encodeTokens(content, in)}, nil } // encodeTokens encodes tokens relative to their predecessor in UTF-16 units, diff --git a/internal/lsp/semantictokens_test.go b/internal/frontend/lsp/semantictokens_test.go similarity index 98% rename from internal/lsp/semantictokens_test.go rename to internal/frontend/lsp/semantictokens_test.go index b8a50bdb2c..39a4a5bd76 100644 --- a/internal/lsp/semantictokens_test.go +++ b/internal/frontend/lsp/semantictokens_test.go @@ -8,8 +8,8 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/highlight" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/semantic/highlight" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // decoded is one semantic token read back from the protocol's relative encoding. diff --git a/internal/lsp/server.go b/internal/frontend/lsp/server.go similarity index 90% rename from internal/lsp/server.go rename to internal/frontend/lsp/server.go index 5ab2d8eb46..241920c03e 100644 --- a/internal/lsp/server.go +++ b/internal/frontend/lsp/server.go @@ -12,7 +12,7 @@ import ( "go.lsp.dev/protocol" "go.uber.org/zap" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // Server is the SysML v2 language server. It wraps a single model.Workspace and @@ -37,6 +37,8 @@ type Server struct { // renderNotify coalesces opensysml/renderChanged the same way, so a diagram // client redraws from settled text rather than from every keystroke. renderNotify *model.Debouncer + // debug holds the live opensysml/debug sessions. + debug *debugService // pubMu serializes analyze-and-send, so the sweep's timer goroutine and a // notification handler cannot deliver one document's diagnostics out of order. @@ -46,11 +48,15 @@ type Server struct { shutdownReceived bool exitReceived bool folders []string + // openDirs are the directories indexed for documents opened under no folder. + openDirs map[string]bool // hoverMarkdown records that the client advertised Markdown for hover. hoverMarkdown bool // completionMarkdown records that the client advertised Markdown for // completion item documentation. completionMarkdown bool + // crossDocument records that the client advertised CrossDocumentCapability. + crossDocument bool } // notifier sends a notification by method name, which a client connection does. @@ -69,6 +75,7 @@ func NewServer(ws *model.Workspace) *Server { exited: make(chan struct{}), crossDoc: model.NewDebouncer(crossDocRefreshWindow), renderNotify: model.NewDebouncer(crossDocRefreshWindow), + debug: newDebugService(), } } @@ -100,7 +107,7 @@ func (s *Server) Run(ctx context.Context, rwc io.ReadWriteCloser) error { // runHandler is the chain a served session reads with: cancellation, async // dispatch so one slow request cannot stall the stream, and a reply per request. func runHandler(s *Server) jsonrpc2.Handler { - serve := s.stdlibHandler(s.renderHandler(s.modelEditHandler(s.changeHandler(protocol.ServerHandler(s, jsonrpc2.MethodNotFoundHandler))))) + serve := s.stdlibHandler(s.debugHandler(s.renderHandler(s.modelEditHandler(s.changeHandler(protocol.ServerHandler(s, jsonrpc2.MethodNotFoundHandler)))))) // The lifecycle wrapper runs outside AsyncHandler so that shutdown, exit and // the messages after them are ordered as the client sent them. return s.lifecycleHandler(cancelHandler(jsonrpc2.AsyncHandler(jsonrpc2.ReplyHandler(serve)))) diff --git a/internal/frontend/lsp/server_test.go b/internal/frontend/lsp/server_test.go new file mode 100644 index 0000000000..d0e70bdba7 --- /dev/null +++ b/internal/frontend/lsp/server_test.go @@ -0,0 +1,18 @@ +package lsp + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +func TestNewServerWrapsWorkspace(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + if s == nil { + t.Fatal("NewServer returned nil") + } + if s.ws != ws { + t.Fatal("server does not hold the given workspace") + } +} diff --git a/internal/lsp/short_name_rename_test.go b/internal/frontend/lsp/short_name_rename_test.go similarity index 99% rename from internal/lsp/short_name_rename_test.go rename to internal/frontend/lsp/short_name_rename_test.go index 0dddf14a5b..5efede8bc4 100644 --- a/internal/lsp/short_name_rename_test.go +++ b/internal/frontend/lsp/short_name_rename_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // A short-named definition referenced by both of its names, unqualified and as diff --git a/internal/lsp/stdlib.go b/internal/frontend/lsp/stdlib.go similarity index 100% rename from internal/lsp/stdlib.go rename to internal/frontend/lsp/stdlib.go diff --git a/internal/lsp/stdlib_test.go b/internal/frontend/lsp/stdlib_test.go similarity index 99% rename from internal/lsp/stdlib_test.go rename to internal/frontend/lsp/stdlib_test.go index 18d960516a..1025d69201 100644 --- a/internal/lsp/stdlib_test.go +++ b/internal/frontend/lsp/stdlib_test.go @@ -14,9 +14,9 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/libs" - "github.com/Open-MBEE/OpenSysML/internal/core/model" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) const ( diff --git a/internal/frontend/lsp/symbols.go b/internal/frontend/lsp/symbols.go new file mode 100644 index 0000000000..db9c76789d --- /dev/null +++ b/internal/frontend/lsp/symbols.go @@ -0,0 +1,118 @@ +package lsp + +import ( + "context" + "strings" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// DocumentSymbol returns the hierarchical symbol tree for a document, a bundled +// library one included. +func (s *Server) DocumentSymbol(ctx context.Context, params *protocol.DocumentSymbolParams) ([]interface{}, error) { + name := uriToName(params.TextDocument.URI) + doc := s.document(name) + if doc == nil || doc.Scope == nil { + return nil, nil + } + syms := walkDocumentSymbols(positionsOf(doc), doc.Scope) + out := make([]interface{}, 0, len(syms)) + for _, ds := range syms { + out = append(out, ds) + } + return out, nil +} + +// walkDocumentSymbols converts a scope's members (and their child scopes) into +// a DocumentSymbol tree. A metadata annotation body's declarations live in an +// anonymous child scope no member owns, so those scopes are walked too. +func walkDocumentSymbols(pos positions, scope *symbols.Scope) []protocol.DocumentSymbol { + var out []protocol.DocumentSymbol + for _, sym := range scope.Members() { + if sym.Name == "" { + continue + } + ds := protocol.DocumentSymbol{ + Name: sym.Name, + Kind: lspSymbolKind(sym.Kind), + Range: pos.rangeOf(sym.DeclSpan), + SelectionRange: pos.rangeOf(sym.DeclSpan), + } + if sym.Scope != nil { + ds.Children = walkDocumentSymbols(pos, sym.Scope) + } + out = append(out, ds) + } + for _, child := range scope.Children() { + if isAnnotationBodyScope(child) { + out = append(out, walkDocumentSymbols(pos, child)...) + } + } + return out +} + +// lspSymbolKind maps a core SymbolKind to the LSP SymbolKind. +func lspSymbolKind(k symbols.SymbolKind) protocol.SymbolKind { + switch k { + case symbols.SymbolPackage: + return protocol.SymbolKindPackage + case symbols.SymbolNamespace: + return protocol.SymbolKindNamespace + case symbols.SymbolAlias: + return protocol.SymbolKindVariable + case symbols.SymbolDependency, symbols.SymbolRelationship: + return protocol.SymbolKindModule + case symbols.SymbolComment, symbols.SymbolDocumentation, symbols.SymbolTextualRepresentation: + return protocol.SymbolKindString + default: + return protocol.SymbolKindObject + } +} + +// Symbols implements workspace/symbol: a flat, query-filtered list of all +// symbols across every open document. +func (s *Server) Symbols(ctx context.Context, params *protocol.WorkspaceSymbolParams) ([]protocol.SymbolInformation, error) { + query := strings.ToLower(params.Query) + + var out []protocol.SymbolInformation + for _, name := range s.ws.DocumentNames() { + doc := s.ws.Document(name) + if doc == nil || doc.Scope == nil { + continue + } + uriStr := s.documentURI(name) + collectWorkspaceSymbols(doc.Scope, "", query, positionsOf(doc), uriStr, &out) + } + return out, nil +} + +// collectWorkspaceSymbols recursively appends matching symbols to out. +func collectWorkspaceSymbols(scope *symbols.Scope, container, query string, pos positions, docURI uri.URI, out *[]protocol.SymbolInformation) { + for _, sym := range scope.Members() { + if sym.Name == "" { + continue + } + if query == "" || strings.Contains(strings.ToLower(sym.Name), query) { + *out = append(*out, protocol.SymbolInformation{ + Name: sym.Name, + Kind: lspSymbolKind(sym.Kind), + Location: protocol.Location{ + URI: docURI, + Range: pos.rangeOf(sym.DeclSpan), + }, + ContainerName: container, + }) + } + if sym.Scope != nil { + collectWorkspaceSymbols(sym.Scope, sym.Name, query, pos, docURI, out) + } + } + for _, child := range scope.Children() { + if isAnnotationBodyScope(child) { + collectWorkspaceSymbols(child, container, query, pos, docURI, out) + } + } +} diff --git a/internal/frontend/lsp/symbols_test.go b/internal/frontend/lsp/symbols_test.go new file mode 100644 index 0000000000..b900588ba1 --- /dev/null +++ b/internal/frontend/lsp/symbols_test.go @@ -0,0 +1,43 @@ +package lsp + +import ( + "context" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +func TestDocumentSymbolReturnsTree(t *testing.T) { + ws := model.NewWorkspace() + s := NewServer(ws) + // Open under the resolved absolute name so it round-trips through + // uri.File(name).Filename() inside the handler. + name := uri.File("/tmp/d.sysml").Filename() + ws.Open(name, []byte("package P { namespace N; }"), 1) + + res, err := s.DocumentSymbol(context.Background(), &protocol.DocumentSymbolParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + }) + if err != nil { + t.Fatalf("DocumentSymbol err = %v", err) + } + if len(res) != 1 { + t.Fatalf("top-level symbols = %d, want 1", len(res)) + } + pkg, ok := res[0].(protocol.DocumentSymbol) + if !ok { + t.Fatalf("result[0] type = %T, want protocol.DocumentSymbol", res[0]) + } + if pkg.Name != "P" || pkg.Kind != protocol.SymbolKindPackage { + t.Errorf("pkg = %q kind %v, want P/Package", pkg.Name, pkg.Kind) + } + if len(pkg.Children) != 1 || pkg.Children[0].Name != "N" { + t.Fatalf("pkg.Children = %+v, want [N]", pkg.Children) + } + if pkg.Children[0].Kind != protocol.SymbolKindNamespace { + t.Errorf("N kind = %v, want Namespace", pkg.Children[0].Kind) + } +} diff --git a/internal/lsp/sync.go b/internal/frontend/lsp/sync.go similarity index 83% rename from internal/lsp/sync.go rename to internal/frontend/lsp/sync.go index b685878a9c..6f48c86d8b 100644 --- a/internal/lsp/sync.go +++ b/internal/frontend/lsp/sync.go @@ -8,14 +8,19 @@ import ( // DidOpen registers a newly opened document with the workspace. The buffer the // editor sends can differ from what was read from disk, so the other open -// documents are refreshed too. A library document is served from the bundled -// text the index holds, so opening one changes nothing. +// documents are refreshed too. A document under none of the session's folders +// has its directory indexed, so its imports of sibling files resolve. A library +// document is served from the bundled text the index holds, so opening one +// changes nothing. func (s *Server) DidOpen(ctx context.Context, params *protocol.DidOpenTextDocumentParams) error { if isLibraryURI(params.TextDocument.URI) { return nil } name := uriToName(params.TextDocument.URI) - s.ws.Open(name, []byte(params.TextDocument.Text), int(params.TextDocument.Version)) + s.debugEdit(ctx, "", func() { + s.ws.Open(name, []byte(params.TextDocument.Text), int(params.TextDocument.Version)) + s.indexOpenedDirectory(name) + }) s.publishDiagnostics(ctx, name) s.queueOpenDiagnostics(ctx, name) return nil @@ -53,8 +58,11 @@ func (s *Server) DidClose(ctx context.Context, params *protocol.DidCloseTextDocu return nil } name := uriToName(params.TextDocument.URI) - s.loadFromDisk(name) - s.ws.Close(name) + s.debugEdit(ctx, name, func() { + s.loadFromDisk(name) + s.ws.Close(name) + s.releaseOpenedDirectories(name) + }) s.clearDiagnostics(ctx, name) s.queueOpenDiagnostics(ctx, name) return nil diff --git a/internal/lsp/sync_test.go b/internal/frontend/lsp/sync_test.go similarity index 98% rename from internal/lsp/sync_test.go rename to internal/frontend/lsp/sync_test.go index c91fbcfb9d..320c96b397 100644 --- a/internal/lsp/sync_test.go +++ b/internal/frontend/lsp/sync_test.go @@ -9,7 +9,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) func TestDidOpenRegistersDocument(t *testing.T) { diff --git a/internal/lsp/uri.go b/internal/frontend/lsp/uri.go similarity index 97% rename from internal/lsp/uri.go rename to internal/frontend/lsp/uri.go index 90568751f7..47911ad45c 100644 --- a/internal/lsp/uri.go +++ b/internal/frontend/lsp/uri.go @@ -7,7 +7,7 @@ import ( "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // LibraryScheme is the URI scheme the server gives the bundled standard library: diff --git a/internal/lsp/w6b_alias_consumers_test.go b/internal/frontend/lsp/w6b_alias_consumers_test.go similarity index 98% rename from internal/lsp/w6b_alias_consumers_test.go rename to internal/frontend/lsp/w6b_alias_consumers_test.go index c5e14eda4a..35e51e276c 100644 --- a/internal/lsp/w6b_alias_consumers_test.go +++ b/internal/frontend/lsp/w6b_alias_consumers_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) const w6bAliasSrc = "package ShapeItems {\n\tpart def Cube {\n\t\tattribute length;\n\t}\n" + diff --git a/internal/lsp/w6b_alias_rename_test.go b/internal/frontend/lsp/w6b_alias_rename_test.go similarity index 98% rename from internal/lsp/w6b_alias_rename_test.go rename to internal/frontend/lsp/w6b_alias_rename_test.go index d21377d402..7f35466a91 100644 --- a/internal/lsp/w6b_alias_rename_test.go +++ b/internal/frontend/lsp/w6b_alias_rename_test.go @@ -8,7 +8,7 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // An alias declaration, a use of the alias name and a use qualified through it. diff --git a/internal/frontend/lsp/walk.go b/internal/frontend/lsp/walk.go new file mode 100644 index 0000000000..2a5730d2bf --- /dev/null +++ b/internal/frontend/lsp/walk.go @@ -0,0 +1,64 @@ +package lsp + +import ( + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// collectRefs gathers every qualified-name reference in a document with the +// scope it resolves in (see resolve.References). +func collectRefs(root *ast.RootNamespace, rootScope *symbols.Scope) []resolve.Reference { + return resolve.References(root, rootScope) +} + +// refAtOffset returns the reference whose qualified-name span contains offset. +func refAtOffset(refs []resolve.Reference, offset int) *resolve.Reference { + for i := range refs { + sp := refs[i].QN.Span() + if offset >= sp.Offset && offset < sp.End() { + return &refs[i] + } + } + return nil +} + +// segmentAt returns the index of the qualified-name segment containing offset, +// or -1 when the cursor is between segments. +func segmentAt(ref resolve.Reference, offset int) int { + for i, p := range ref.QN.Parts { + if offset >= p.Span.Offset && offset < p.Span.End() { + return i + } + } + return -1 +} + +// onCalledName reports whether offset is on the last segment of ref, the one an +// invocation calls, rather than on a qualifier before it. A `::` between +// segments counts as the whole reference, which names what the last segment does. +func onCalledName(ref resolve.Reference, offset int) bool { + idx := segmentAt(ref, offset) + return idx < 0 || idx == len(ref.QN.Parts)-1 +} + +// referencedSegment resolves the qualified-name segment containing offset with +// that segment's span: a qualifier names the namespace it denotes, the last +// segment the reference's target. false when no segment resolves there. +func (s *Server) referencedSegment(doc string, ref resolve.Reference, offset int) (*symbols.Symbol, source.Span, bool) { + parts := ref.QN.Parts + idx := segmentAt(ref, offset) + if idx < 0 { + return nil, source.Span{}, false + } + if idx == len(parts)-1 { + target, ok := s.ws.ResolveReferenceInDoc(doc, ref) + return target, parts[idx].Span, ok + } + segs := s.ws.ResolveReferenceSegmentsInDoc(doc, ref) + if idx >= len(segs) || segs[idx] == nil { + return nil, source.Span{}, false + } + return segs[idx], parts[idx].Span, true +} diff --git a/internal/frontend/lsp/walk_test.go b/internal/frontend/lsp/walk_test.go new file mode 100644 index 0000000000..74a85ac6b2 --- /dev/null +++ b/internal/frontend/lsp/walk_test.go @@ -0,0 +1,254 @@ +package lsp + +import ( + "context" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// bodyRefSource uses `speed` from every expression position a behavioral body +// offers. The declaration plus 13 uses means rename must produce 14 edits and +// references must report 13. +const bodyRefSource = `package P { + private import ScalarValues::*; + attribute speed : Integer = 0; + attribute bare : Integer = speed; + attribute paren : Integer = (speed); + attribute operand : Integer = speed + 1; + calc plain { return r = speed; } + calc def Plain { return r = speed; } + constraint inRange { speed > 0 } + requirement Req { + assume constraint { speed > 0 } + require constraint { speed < 100 } + } + action drive { + attribute local : Integer = 0; + assign local := speed; + } + state Machine { + attribute seen : Integer = 0; + entry; then i; + state i; + state running { + entry { assign seen := speed; } + do { assign seen := speed; } + exit { assign seen := speed; } + } + state stopped; + succession first i then running; + transition first running if speed > 90 then stopped; + } +} +` + +const wantBodyRefUses = 13 + +func TestRenameEditsBareReferencesInBehaviorBodies(t *testing.T) { + ws := model.NewWorkspace() + name := openRenameDoc(t, ws, "/tmp/walk_rename.sysml", bodyRefSource) + + edits := renameEdits(t, ws, name, "speed :", "velocity") + if len(edits) != wantBodyRefUses+1 { + t.Fatalf("Rename produced %d edit(s), want %d (declaration + %d uses)", + len(edits), wantBodyRefUses+1, wantBodyRefUses) + } + + got, err := applyRename(t, ws, name, "speed :", "velocity") + if err != nil { + t.Fatalf("Rename err = %v", err) + } + if strings.Contains(got[name], "speed") { + t.Errorf("speed still referenced after rename:\n%s", got[name]) + } +} + +func TestReferencesFindsBareReferencesInBehaviorBodies(t *testing.T) { + ws := model.NewWorkspace() + name := openRenameDoc(t, ws, "/tmp/walk_refs.sysml", bodyRefSource) + + s := NewServer(ws) + doc := ws.Document(name) + off := strings.Index(string(doc.Content), "speed :") + locs, err := s.References(context.Background(), &protocol.ReferenceParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(name)}, + Position: offsetToPosition(doc.Content, off), + }, + Context: protocol.ReferenceContext{IncludeDeclaration: false}, + }) + if err != nil { + t.Fatalf("References err = %v", err) + } + if len(locs) != wantBodyRefUses { + t.Fatalf("References found %d use(s), want %d:\n%v", len(locs), wantBodyRefUses, locs) + } +} + +// A bare trigger name is a signal injected by the event source, not a reference +// to the like-named declaration, so renaming that declaration must leave it be. +func TestRenameLeavesSignalTriggerNames(t *testing.T) { + ws := model.NewWorkspace() + src := `package P { + private import ScalarValues::*; + attribute go : Integer = 0; + state Machine { + entry; then i; + state i; + state a; + state b; + succession first i then a; + transition first a when go then b; + } +} +` + name := openRenameDoc(t, ws, "/tmp/walk_trigger.sysml", src) + + got, err := applyRename(t, ws, name, "go :", "start") + if err != nil { + t.Fatalf("Rename err = %v", err) + } + if !strings.Contains(got[name], "attribute start : Integer = 0;") { + t.Errorf("declaration not renamed:\n%s", got[name]) + } + if !strings.Contains(got[name], "when go then b;") { + t.Errorf("signal trigger name was rewritten:\n%s", got[name]) + } +} + +// A connector end that declares its own name refers to the feature it +// reference-subsets, so renaming that feature must rewrite the reference and +// leave the end's name alone. +func TestRenameRewritesConnectorEndReferenceTarget(t *testing.T) { + ws := model.NewWorkspace() + src := `package P { + port def OutPort; + port def InPort; + interface def FuelInterface { + end supplierPort : OutPort; + end consumerPort : InPort; + } + part vehicle { + part tankAssy { port fuelTankPort : OutPort; } + part eng { port engineFuelPort : InPort; } + interface : FuelInterface connect + supplierPort ::> tankAssy.fuelTankPort to + consumerPort ::> eng.engineFuelPort; + } +} +` + name := openRenameDoc(t, ws, "/tmp/walk_connectorend.sysml", src) + + got, err := applyRename(t, ws, name, "tankAssy {", "tank") + if err != nil { + t.Fatalf("Rename err = %v", err) + } + if !strings.Contains(got[name], "supplierPort ::> tank.fuelTankPort") { + t.Errorf("the feature the end attaches to was not renamed:\n%s", got[name]) + } + + got, err = applyRename(t, ws, name, "supplierPort : OutPort", "supply") + if err != nil { + t.Fatalf("Rename err = %v", err) + } + if !strings.Contains(got[name], "end supply : OutPort;") { + t.Errorf("the definition's end was not renamed:\n%s", got[name]) + } +} + +// An explicit `:>>` on a connector end names an end of the connector's type, so +// renaming that end must rewrite the clause even in the plain-name spelling. +func TestRenameRewritesConnectorEndRedefinitionTarget(t *testing.T) { + ws := model.NewWorkspace() + src := `package P { + part def TireBead; + connection def PressureSeat { + end [1] part bead : TireBead; + end [1] part rim; + } + part wheelAssy { + part t { part bead : TireBead; } + part w { part rim; } + connection : PressureSeat connect + seatRim :>> rim references w.rim to + seatBead :>> bead references t.bead; + } +} +` + name := openRenameDoc(t, ws, "/tmp/walk_endredef.sysml", src) + + got, err := applyRename(t, ws, name, "rim;", "mountingRim") + if err != nil { + t.Fatalf("Rename err = %v", err) + } + if !strings.Contains(got[name], "seatRim :>> mountingRim references w.rim") { + t.Errorf("the redefinition target was not renamed:\n%s", got[name]) + } +} + +// A body expression's parameter is its own declaration, so renaming a +// same-named outer feature must not rewrite the parameter's uses inside the +// body, while a name the body only reads from outside is still rewritten. +func TestRenameLeavesBodyExpressionParameters(t *testing.T) { + ws := model.NewWorkspace() + src := `package P { + private import ScalarValues::*; + private import ControlFunctions::*; + attribute s : Integer = 1; + action def Sample { + in attribute samples : Real[*]; + assert constraint { samples->forAll { in s : Real; s > 0 } } + assert constraint { samples->forAll { in x : Real; x > s } } + } +} +` + name := openRenameDoc(t, ws, "/tmp/walk_bodyexpr.sysml", src) + + got, err := applyRename(t, ws, name, "s : Integer", "threshold") + if err != nil { + t.Fatalf("Rename err = %v", err) + } + if !strings.Contains(got[name], "attribute threshold : Integer = 1;") { + t.Errorf("declaration not renamed:\n%s", got[name]) + } + if !strings.Contains(got[name], "in s : Real; s > 0") { + t.Errorf("body-expression parameter was rewritten:\n%s", got[name]) + } + if !strings.Contains(got[name], "x > threshold") { + t.Errorf("reference to the outer feature was not renamed:\n%s", got[name]) + } +} + +// Renaming from a use of a body-expression parameter must edit the parameter's +// own declaration, not the body's opening brace, and must leave the same-named +// outer feature alone. +func TestRenameBodyExpressionParameterFromUse(t *testing.T) { + ws := model.NewWorkspace() + src := `package P { + private import ScalarValues::*; + private import ControlFunctions::*; + attribute s : Integer = 1; + action def Sample { + in attribute samples : Real[*]; + assert constraint { samples->forAll { in s : Real; s > 0 } } + } +} +` + name := openRenameDoc(t, ws, "/tmp/walk_bodyparam.sysml", src) + + got, err := applyRename(t, ws, name, "s > 0", "sample") + if err != nil { + t.Fatalf("Rename err = %v", err) + } + if !strings.Contains(got[name], "in sample : Real; sample > 0") { + t.Errorf("parameter declaration and use not both renamed:\n%s", got[name]) + } + if !strings.Contains(got[name], "attribute s : Integer = 1;") { + t.Errorf("outer feature was rewritten:\n%s", got[name]) + } +} diff --git a/internal/lsp/workspace_symbol_test.go b/internal/frontend/lsp/workspace_symbol_test.go similarity index 97% rename from internal/lsp/workspace_symbol_test.go rename to internal/frontend/lsp/workspace_symbol_test.go index 278a1f0063..16422e338d 100644 --- a/internal/lsp/workspace_symbol_test.go +++ b/internal/frontend/lsp/workspace_symbol_test.go @@ -6,7 +6,7 @@ import ( "go.lsp.dev/protocol" - "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) func TestWorkspaceSymbolFindsByQuery(t *testing.T) { diff --git a/internal/frontend/protoconv/convert.go b/internal/frontend/protoconv/convert.go new file mode 100644 index 0000000000..aaabc75801 --- /dev/null +++ b/internal/frontend/protoconv/convert.go @@ -0,0 +1,1534 @@ +// Package protoconv converts between the runtime's values and instances and the +// protobuf messages of the API, for every frontend that emits that shape. +package protoconv + +import ( + "cmp" + "errors" + "fmt" + "maps" + "math" + "slices" + "strconv" + "strings" + + pb "github.com/Open-MBEE/OpenSysML/api/proto" + "github.com/Open-MBEE/OpenSysML/internal/exec/objref" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// BoundText renders a multiplicity bound; an unevaluable one renders empty. +func BoundText(b semantics.Bound) string { + if !b.Known { + return "" + } + if b.Infinite { + return "*" + } + return strconv.FormatInt(b.Value, 10) +} + +// ValueToProto converts runtime.Value to protobuf Value. An enumeration literal +// is named by its declaration, which idx supplies the qualified name of. +func ValueToProto(val runtime.Value, idx *symbols.Index) *pb.Value { + return ValueToProtoIn(nil, val, idx) +} + +// ValueToProtoIn converts a value read from a live context, which is what tells +// an object holding no value from one with features: the former crosses as the +// unset arm, as every other surface reads it. rt may be nil for a value no +// context materialized. +func ValueToProtoIn(rt *runtime.Context, val runtime.Value, idx *symbols.Index) *pb.Value { + if rt != nil && rt.HoldsNoValue(val) { + return &pb.Value{Kind: &pb.Value_Unset{Unset: true}} + } + if val.EnumerationLiteral() != nil { + return enumLiteralValueToProto(val, idx) + } + switch val.Kind { + case runtime.ValConst: + // Map semantics.Value to protobuf based on type + switch val.Const.Kind { + case semantics.ValInt: + return &pb.Value{Kind: &pb.Value_IntValue{IntValue: val.Const.Int}} + case semantics.ValReal: + return &pb.Value{Kind: &pb.Value_RealValue{RealValue: val.Const.Real}} + case semantics.ValBool: + return &pb.Value{Kind: &pb.Value_BoolValue{BoolValue: val.Const.Bool}} + case semantics.ValInfinity: + return &pb.Value{Kind: &pb.Value_Infinity{Infinity: true}} + default: + return &pb.Value{Kind: &pb.Value_Null{Null: "unsupported const kind"}} + } + case runtime.ValString: + return &pb.Value{Kind: &pb.Value_StringValue{StringValue: val.Str()}} + case runtime.ValNull: + return &pb.Value{Kind: &pb.Value_Null{Null: ""}} + case runtime.ValInstance: + return &pb.Value{Kind: &pb.Value_InstanceId{InstanceId: val.Instance}} + case runtime.ValSequence: + // Recursively convert sequence elements + var pbElements []*pb.Value + if val.Sequence() != nil { + for _, elem := range val.Sequence().Elements() { + pbElements = append(pbElements, ValueToProtoIn(rt, elem, idx)) + } + } + return &pb.Value{Kind: &pb.Value_Sequence{Sequence: &pb.ValueSequence{Elements: pbElements}}} + case runtime.ValSet: + return setToProto(rt, val, idx) + case runtime.ValVariant: + // The wire Value has no variant form: the object a selected variant + // materialized is reported by identity, a valueless selection as unsupported. + if id, ok := val.Object(); ok { + return &pb.Value{Kind: &pb.Value_InstanceId{InstanceId: id}} + } + return &pb.Value{Kind: &pb.Value_Null{Null: "unsupported: variant selection"}} + case runtime.ValQuantity: + pq := QuantityToProto(val.Quantity()) + if pq == nil { + return &pb.Value{Kind: &pb.Value_Null{Null: "unsupported: quantity with a non-numeric magnitude"}} + } + return &pb.Value{Kind: &pb.Value_Quantity{Quantity: pq}} + case runtime.ValEnumLiteral: + return enumLiteralValueToProto(val, idx) + case runtime.ValComplex: + return &pb.Value{Kind: &pb.Value_Complex{Complex: ComplexToProto(val.Complex())}} + case runtime.ValArray: + return &pb.Value{Kind: &pb.Value_Array{Array: arrayToProto(rt, val.Array(), idx)}} + case runtime.ValVector: + return &pb.Value{Kind: &pb.Value_Vector{Vector: vectorToProto(val.Vector())}} + case runtime.ValVectorQuantity: + pvq := vectorQuantityToProto(val.VectorQuantity()) + if pvq == nil { + return &pb.Value{Kind: &pb.Value_Null{Null: "unsupported: vector quantity with a non-numeric component"}} + } + return &pb.Value{Kind: &pb.Value_VectorQuantity{VectorQuantity: pvq}} + case runtime.ValMeasurementRef: + return &pb.Value{Kind: &pb.Value_MeasurementRef{MeasurementRef: MeasurementRefToProto(val.MeasurementRef())}} + case runtime.ValFunction: + if val.FunctionClosesOverBody() { + return &pb.Value{Kind: &pb.Value_Null{Null: "unsupported: function " + val.FunctionName() + " closing over a body's bindings"}} + } + return &pb.Value{Kind: &pb.Value_Function{Function: functionToProto(val, idx)}} + case runtime.ValTensorQuantity: + ptq := tensorQuantityToProto(val.TensorQuantity()) + if ptq == nil { + return &pb.Value{Kind: &pb.Value_Null{Null: "unsupported: tensor quantity with a non-numeric component"}} + } + return &pb.Value{Kind: &pb.Value_TensorQuantity{TensorQuantity: ptq}} + case runtime.ValMetaobject: + meta := metaobjectToProto(val, idx) + if meta == nil { + return &pb.Value{Kind: &pb.Value_Null{Null: "unsupported: metaobject of an element with no qualified name"}} + } + return &pb.Value{Kind: &pb.Value_Metaobject{Metaobject: meta}} + case runtime.ValUndetermined: + return &pb.Value{Kind: &pb.Value_Undetermined{Undetermined: undeterminedToProto(val.Undetermined())}} + case runtime.ValCoordinateFrame, runtime.ValCoordinateTransformation: + // No wire arm carries a frame's axes or a transformation's placement. + return &pb.Value{Kind: UnsupportedShown(val)} + default: + return &pb.Value{Kind: &pb.Value_Null{Null: "unsupported"}} + } +} + +// undeterminedToProto carries why a model-level result is open and how many +// values it would hold, the count's bounds as MultiplicityInfo spells them. +func undeterminedToProto(u *runtime.Undetermined) *pb.Undetermined { + count := u.Count() + return &pb.Undetermined{ + Reason: u.Reason(), + Count: &pb.MultiplicityInfo{Lower: BoundText(count.Lower), Upper: BoundText(count.Upper)}, + } +} + +// functionToProto names a function by the calc declaration it is a value of and +// the object it closes over, if any. +func functionToProto(val runtime.Value, idx *symbols.Index) *pb.Function { + fn := &pb.Function{CalcId: val.FunctionName()} + if idx != nil && val.Function() != nil { + fn.CalcId = idx.GetFQN(val.Function()) + } + if self := val.FunctionSelf(); self != nil { + fn.SelfId = self.ID + } + return fn +} + +// enumLiteralValueToProto sends a value that is an enumeration literal — its +// identity alone, or a scalar the literal equals — through the enum_literal arm. +func enumLiteralValueToProto(val runtime.Value, idx *symbols.Index) *pb.Value { + lit := enumLiteralToProto(val, idx) + if lit == nil { + return &pb.Value{Kind: &pb.Value_Null{Null: "unsupported: unresolved enumeration literal"}} + } + return &pb.Value{Kind: &pb.Value_EnumLiteral{EnumLiteral: lit}} +} + +// metaobjectToProto names a metaobject by the element it reflects, which is its +// identity, and by that element's own metaclass. Nil when either is unnamed: an +// anonymous element has no qualified name a receiver could bind. +func metaobjectToProto(val runtime.Value, idx *symbols.Index) *pb.Metaobject { + element, metaclass := val.MetaobjectElement(), val.MetaobjectClass() + if element == nil || metaclass == nil || element.Name == "" || metaclass.Name == "" { + return nil + } + elementID, metaclassID := element.Name, metaclass.Name + if idx != nil { + elementID, metaclassID = idx.GetFQN(element), idx.GetFQN(metaclass) + } + return &pb.Metaobject{ElementId: elementID, MetaclassId: metaclassID} +} + +// enumLiteralToProto names a literal by the declaration it is, which is its +// identity, and by the enumeration declaring it, carrying the scalar a valued +// literal equals. Nil for an unresolved literal. +func enumLiteralToProto(val runtime.Value, idx *symbols.Index) *pb.EnumLiteral { + sym := val.EnumerationLiteral() + if sym == nil { + return nil + } + lit := &pb.EnumLiteral{ + LiteralId: idx.GetFQN(sym), + Name: runtime.NewEnumLiteral(sym).LiteralText(), + } + if enum := semantics.EnumerationOwning(sym); enum != nil { + lit.EnumerationId = idx.GetFQN(enum) + } + if val.Kind != runtime.ValEnumLiteral { + lit.Value = ValueToProto(val.Scalar(), idx) + } + return lit +} + +// ComplexToProto marshals a complex number as its rectangular parts. +func ComplexToProto(z complex128) *pb.Complex { + return &pb.Complex{Real: real(z), Imaginary: imag(z)} +} + +// ProtoToComplex is the complex number a Complex message carries; an empty +// message is zero, as every proto3 default is. +func ProtoToComplex(pc *pb.Complex) complex128 { + return complex(pc.GetReal(), pc.GetImaginary()) +} + +// arrayToProto marshals an array as its dimensions and its row-major elements, +// each converted as any value is. +func arrayToProto(rt *runtime.Context, a *runtime.Array, idx *symbols.Index) *pb.Array { + pa := &pb.Array{Dimensions: slices.Clone(a.Dimensions)} + for _, elem := range a.Elements { + pa.Elements = append(pa.Elements, ValueToProtoIn(rt, elem, idx)) + } + return pa +} + +// setToProto marshals a set's distinct elements in canonical order, each +// converted as any value is. A member with no wire form withholds the whole +// set: sent as nulls, two such members would read as one repeated. +func setToProto(rt *runtime.Context, val runtime.Value, idx *symbols.Index) *pb.Value { + ps := &pb.ValueSet{} + if s := val.Set(); s != nil { + for _, elem := range s.Elements() { + pv := ValueToProtoIn(rt, elem, idx) + if reason, ok := UnsupportedReason(pv); ok { + return UnsupportedSet(val, reason) + } + ps.Elements = append(ps.Elements, pv) + } + } + return &pb.Value{Kind: &pb.Value_Set{Set: ps}} +} + +// UnsupportedNullPrefix opens the non-empty null arm a value without a wire +// form crosses as; the reason follows it. +const UnsupportedNullPrefix = "unsupported: " + +// UnsupportedShown is the null arm a value of a kind the wire has no arm for +// crosses as, naming the kind and the value as the REPL shows it. +func UnsupportedShown(shown runtime.Value) *pb.Value_Null { + return &pb.Value_Null{Null: UnsupportedNullPrefix + shown.Kind.String() + " " + runtime.FormatValue(shown)} +} + +// UnsupportedReason reads the non-empty null arm a value without a wire form +// crosses as; the SysML `null` is the empty one. +func UnsupportedReason(pv *pb.Value) (string, bool) { + if null, ok := pv.GetKind().(*pb.Value_Null); ok && null.Null != "" { + return strings.TrimPrefix(null.Null, UnsupportedNullPrefix), true + } + return "", false +} + +// UnsupportedSet is the null arm a set holding a member without a wire form +// crosses as, naming the set and the member's reason. +func UnsupportedSet(shown runtime.Value, reason string) *pb.Value { + return &pb.Value{Kind: &pb.Value_Null{Null: UnsupportedNullPrefix + runtime.ValSet.String() + " " + runtime.FormatValue(shown) + " holding " + reason}} +} + +// tensorQuantityToProto marshals a tensor as its dimensions and one Quantity +// per row-major component; nil if a component's magnitude is not a number. +func tensorQuantityToProto(tq *runtime.TensorQuantity) *pb.TensorQuantity { + if tq == nil { + return nil + } + ptq := &pb.TensorQuantity{Dimensions: slices.Clone(tq.Dimensions)} + for i := range tq.Num { + pq := QuantityToProto(&runtime.Quantity{Num: tq.Num[i], Unit: tq.Units[i]}) + if pq == nil { + return nil + } + ptq.Components = append(ptq.Components, pq) + } + return ptq +} + +// vectorToProto marshals a vector's components, Integer and Real kept apart. +func vectorToProto(v *runtime.Vector) *pb.Vector { + pv := &pb.Vector{} + for _, elem := range v.Elements { + pv.Components = append(pv.Components, ValueToProto(runtime.Value{Kind: runtime.ValConst, Const: elem}, nil)) + } + return pv +} + +// vectorQuantityToProto marshals a vector quantity as one Quantity per axis; +// nil if a component's magnitude is not a number. +func vectorQuantityToProto(vq *runtime.VectorQuantity) *pb.VectorQuantity { + pvq := &pb.VectorQuantity{} + for i := range vq.Num { + pq := QuantityToProto(&runtime.Quantity{Num: vq.Num[i], Unit: vq.Units[i]}) + if pq == nil { + return nil + } + pvq.Components = append(pvq.Components, pq) + } + return pvq +} + +// MeasurementRefToProto marshals a bare reference: the unit as written, what it +// reduces to, and the one declaration it names when it names one. +func MeasurementRefToProto(ref *runtime.MeasurementRef) *pb.MeasurementRef { + pm := &pb.MeasurementRef{Unit: ref.Unit.Text, UnitTerm: UnitTermToProto(ref.Unit.Term)} + if decl := ref.Declaration(); decl != nil { + pm.UnitId = symbols.FQNOf(decl) + } + return pm +} + +// QuantityToProto marshals a quantity: the magnitude in the unit written, plus +// what that unit reduces to. Reports nil for a magnitude that is not a number. +func QuantityToProto(q *runtime.Quantity) *pb.Quantity { + if q == nil { + return nil + } + pq := &pb.Quantity{Unit: q.Unit.Text, UnitTerm: UnitTermToProto(q.Unit.Term)} + switch q.Num.Kind { + case semantics.ValInt: + pq.Magnitude = &pb.Quantity_IntMagnitude{IntMagnitude: q.Num.Int} + case semantics.ValReal: + pq.Magnitude = &pb.Quantity_RealMagnitude{RealMagnitude: q.Num.Real} + default: + return nil + } + return pq +} + +// UnitTermToProto marshals a unit's reduction to base units, naming each base +// unit by qualified name: a symbol pointer means nothing to another process. +func UnitTermToProto(term semantics.UnitTerm) *pb.UnitTerm { + pt := &pb.UnitTerm{ScaleNum: term.Scale.Num, ScaleDen: term.Scale.Den} + for _, f := range term.Factors { + pt.Factors = append(pt.Factors, &pb.UnitFactor{ + UnitId: symbols.FQNOf(f.Unit), + Exponent: f.Exponent, + }) + } + return pt +} + +var ( + // ErrQuantityNeedsIndex reports a quantity converted from the wire without + // the model's symbols, which its base units can only be resolved against. + ErrQuantityNeedsIndex = errors.New("a quantity needs the model's symbols to be converted from the wire") + + // ErrUnknownBaseUnit reports a base unit the model does not declare, so no + // unit can be rebuilt from the reduction naming it. + ErrUnknownBaseUnit = errors.New("unknown base unit") + + // ErrNotAMeasurementUnit reports a reduction naming a symbol that is not a + // measurement unit, which nothing is measured in. + ErrNotAMeasurementUnit = errors.New("not a measurement unit") + + // ErrScaleNotAFactor reports a reduction composing a measurement scale with + // other factors, a scale or a power: a point on a scale is no unit to compose. + ErrScaleNotAFactor = errors.New("measurement scale is not a unit factor") + + // ErrUnitScaleUnusable reports a unit reduction whose scale is zero, undefined + // or not finite, which no magnitude can be converted through. + ErrUnitScaleUnusable = errors.New("unit scale is not a usable ratio") + + // ErrUnitExponentUnusable reports a unit factor raised to a power that is not + // a finite number, which measures no dimension. + ErrUnitExponentUnusable = errors.New("unit exponent is not a finite number") + + // ErrUnitNotReduced reports a named unit sent without its reduction, over + // which alone commensurability is decided. + ErrUnitNotReduced = errors.New("unit carries no reduction to base units") + + // ErrUnitTextMismatch reports a unit whose text names units that do not + // reduce to the unit_term sent with it, so the two describe different units. + ErrUnitTextMismatch = errors.New("unit as written does not reduce to its unit_term") + // ErrUnsetNotAccepted reports the unset arm arriving as an input. It reports + // that a feature value holds no value, which is something to read, not to supply. + ErrUnsetNotAccepted = errors.New("unset is not a value a caller can supply") + // ErrUndeterminedNotAccepted reports the undetermined arm arriving as an input. + // It reports that the model fixes no answer, which is something to read, not to supply. + ErrUndeterminedNotAccepted = errors.New("undetermined is not a value a caller can supply") + + // ErrInfinityNotAsserted reports the infinity arm arriving as false. The arm + // is the unbounded value itself, so false states no value at all. + ErrInfinityNotAsserted = errors.New("the infinity arm states no value unless it is true") + + // ErrArrayDimensionNotPositive reports an array sent with a dimension of no + // extent, which Collections::Array declares as dimensions: Positive. + ErrArrayDimensionNotPositive = errors.New("array dimension is not positive") + + // ErrArrayShapeMismatch reports an array whose elements do not fill its + // dimensions, so no row-major reading of them has that shape. + ErrArrayShapeMismatch = errors.New("array elements do not fill its dimensions") + + // ErrVectorComponentNotNumeric reports a vector component sent as something + // other than an Integer or a Real, which a numerical vector has none of. + ErrVectorComponentNotNumeric = errors.New("vector component is not a number") + + // ErrVectorQuantityEmpty reports a vector quantity of no components, whose + // num is Number[1..*]. + ErrVectorQuantityEmpty = errors.New("vector quantity has no components") + + // ErrMeasurementRefNeedsIndex reports a measurement reference converted from + // the wire without the model's symbols, which its units resolve against. + ErrMeasurementRefNeedsIndex = errors.New("a measurement reference needs the model's symbols to be converted from the wire") + + // ErrMeasurementRefEmpty reports a measurement reference sent with no unit + // text, no reduction and no declaration: nothing it could refer to. + ErrMeasurementRefEmpty = errors.New("measurement reference names no unit") + + // ErrUnknownMeasurementUnit reports a unit_id the model does not declare + // uniquely, so no reference can name the declaration. + ErrUnknownMeasurementUnit = errors.New("unknown measurement unit") + + // ErrUnitIDMismatch reports a unit_id whose declaration the unit text sent + // with it does not spell, so the two name different units. + ErrUnitIDMismatch = errors.New("unit as written does not spell unit_id") + + // ErrFunctionNeedsRuntime reports a Function read with no runtime to bind + // its calc in: only a runtime can turn a calc's name into a callable value. + ErrFunctionNeedsRuntime = errors.New("function needs a runtime to bind its calc") + + // ErrFunctionUnbound reports a Function naming no calc of the model read as + // a function, or an object the runtime does not hold. + ErrFunctionUnbound = errors.New("function names no calc of this model") + + // ErrMetaobjectUnbound reports a Metaobject naming no element of the model, + // or one no reflective metaclass of the model's libraries classifies. + ErrMetaobjectUnbound = errors.New("metaobject names no element of this model") + + // ErrMetaobjectAmbiguous reports a Metaobject whose element_id names more than + // one element of the model, so it identifies none of them. + ErrMetaobjectAmbiguous = errors.New("metaobject names more than one element of this model") + + // ErrMetaclassMismatch reports a Metaobject sent with a metaclass_id other + // than the one the model classifies its element by. + ErrMetaclassMismatch = errors.New("metaclass_id is not the element's metaclass") + + // ErrSetElementRepeated reports a set sent with an element twice, which a + // set holds once; a sender meaning both meant a sequence. + ErrSetElementRepeated = errors.New("set element is repeated") + + // ErrTensorDimensionNotPositive reports a tensor sent with a dimension of no + // extent, which its TensorMeasurementReference declares as Positive. + ErrTensorDimensionNotPositive = errors.New("tensor dimension is not positive") + + // ErrTensorShapeMismatch reports a tensor whose components do not fill its + // dimensions, so no row-major reading of them has that shape. + ErrTensorShapeMismatch = errors.New("tensor components do not fill its dimensions") + + // ErrTensorComponentMissing reports a tensor component sent as an empty + // message, which carries no quantity. + ErrTensorComponentMissing = errors.New("tensor component carries no quantity") +) + +// ValueCarriesSet reports whether a value, or any value nested in it, is a +// ValueSet: the kind set_values governs. +func ValueCarriesSet(pv *pb.Value) bool { + return valueCarries(pv, func(v *pb.Value) bool { + _, ok := v.GetKind().(*pb.Value_Set) + return ok + }) +} + +// ValueCarriesTensor reports whether a value, or any value nested in it, is a +// TensorQuantity: the kind tensor_values governs. +func ValueCarriesTensor(pv *pb.Value) bool { + return valueCarries(pv, func(v *pb.Value) bool { + _, ok := v.GetKind().(*pb.Value_TensorQuantity) + return ok + }) +} + +// ValueCarriesMeasurementRef reports whether a value, or any value nested in +// it, is a MeasurementRef: the kind measurement_refs governs. +func ValueCarriesMeasurementRef(pv *pb.Value) bool { + return valueCarries(pv, func(v *pb.Value) bool { + _, ok := v.GetKind().(*pb.Value_MeasurementRef) + return ok + }) +} + +// ValueCarriesFunction reports whether a value, or any value nested in it, is a +// Function: the kind function_values governs. +func ValueCarriesFunction(pv *pb.Value) bool { + return valueCarries(pv, func(v *pb.Value) bool { + _, ok := v.GetKind().(*pb.Value_Function) + return ok + }) +} + +// ValueCarriesMetaobject reports whether a value, or any value nested in it, is +// a Metaobject: the kind metaobject_values governs. +func ValueCarriesMetaobject(pv *pb.Value) bool { + return valueCarries(pv, func(v *pb.Value) bool { + _, ok := v.GetKind().(*pb.Value_Metaobject) + return ok + }) +} + +// ValueCarriesInfinity reports whether a value, or any value nested in it, is +// the unbounded value: the kind infinity_value governs. +func ValueCarriesInfinity(pv *pb.Value) bool { + return valueCarries(pv, func(v *pb.Value) bool { + _, ok := v.GetKind().(*pb.Value_Infinity) + return ok + }) +} + +// ValueCarriesComplex reports whether a value, or any value nested in it, is a +// Complex: the kind the complex_values capability governs. +func ValueCarriesComplex(pv *pb.Value) bool { + return valueCarries(pv, func(v *pb.Value) bool { + _, ok := v.GetKind().(*pb.Value_Complex) + return ok + }) +} + +// ValueCarriesStructured reports whether a value, or any value nested in it, is +// an Array, a Vector or a VectorQuantity: the kinds structured_values governs. +func ValueCarriesStructured(pv *pb.Value) bool { + return valueCarries(pv, func(v *pb.Value) bool { + switch v.GetKind().(type) { + case *pb.Value_Array, *pb.Value_Vector, *pb.Value_VectorQuantity: + return true + } + return false + }) +} + +// valueCarries reports whether is holds of pv or of any value nested in it. +func valueCarries(pv *pb.Value, is func(*pb.Value) bool) bool { + if is(pv) { + return true + } + for _, nested := range NestedValues(pv) { + if valueCarries(nested, is) { + return true + } + } + return false +} + +// NestedValues lists the Values a value holds directly: a sequence's or a set's +// elements, an array's elements and a vector's components. +func NestedValues(pv *pb.Value) []*pb.Value { + switch k := pv.GetKind().(type) { + case *pb.Value_Sequence: + return k.Sequence.GetElements() + case *pb.Value_Set: + return k.Set.GetElements() + case *pb.Value_Array: + return k.Array.GetElements() + case *pb.Value_Vector: + return k.Vector.GetComponents() + } + return nil +} + +// ProtoToValueIn converts a protobuf Value to a runtime.Value in the model idx +// and sem describe, resolving a quantity's base units against them. Inverse of +// ValueToProto. A function, which only a runtime can bind, is refused: read +// one with ProtoToRuntimeValue. +func ProtoToValueIn(pv *pb.Value, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + return ProtoToRuntimeValue(nil, pv, idx, sem) +} + +// ProtoToRuntimeValue is ProtoToValueIn for a value bound for the runtime rt, +// which is what a function's calc is bound in and its object looked up in; +// rt may be nil for a value naming no function. +func ProtoToRuntimeValue(rt *runtime.Context, pv *pb.Value, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + if pv == nil { + return runtime.Value{Kind: runtime.ValNull}, nil + } + switch k := pv.GetKind().(type) { + case *pb.Value_Unset: + return runtime.Value{}, ErrUnsetNotAccepted + case *pb.Value_Undetermined: + return runtime.Value{}, ErrUndeterminedNotAccepted + case *pb.Value_Quantity: + return ProtoToQuantity(k.Quantity, idx, sem) + case *pb.Value_EnumLiteral: + return enumLiteralFromProto(rt, k.EnumLiteral, idx, sem) + case *pb.Value_Function: + return functionFromProto(rt, k.Function, idx) + case *pb.Value_Metaobject: + return metaobjectFromProto(k.Metaobject, idx, sem) + case *pb.Value_Sequence: + seq := runtime.NewSequence() + if k.Sequence != nil { + for _, elem := range k.Sequence.Elements { + val, err := ProtoToRuntimeValue(rt, elem, idx, sem) + if err != nil { + return runtime.Value{}, err + } + seq.Append(val) + } + } + return runtime.NewSequenceValue(seq), nil + case *pb.Value_Set: + return protoToSet(rt, k.Set, idx, sem) + case *pb.Value_TensorQuantity: + return protoToTensorQuantity(k.TensorQuantity, idx, sem) + case *pb.Value_Array: + return protoToArray(rt, k.Array, idx, sem) + case *pb.Value_Vector: + return protoToVector(k.Vector) + case *pb.Value_VectorQuantity: + return protoToVectorQuantity(k.VectorQuantity, idx, sem) + case *pb.Value_MeasurementRef: + return ProtoToMeasurementRef(k.MeasurementRef, idx, sem) + case *pb.Value_Infinity: + if !k.Infinity { + return runtime.Value{}, ErrInfinityNotAsserted + } + return ProtoToScalar(pv), nil + default: + return ProtoToScalar(pv), nil + } +} + +// functionFromProto binds a function to the calc its calc_id names in rt's +// model, read as a value in its own scope. Objects live only within the call that +// created them, so a self_id names none of this call's: it is refused rather +// than matched to whichever object this call happened to number the same. +func functionFromProto(rt *runtime.Context, fn *pb.Function, idx *symbols.Index) (runtime.Value, error) { + if fn == nil || fn.GetCalcId() == "" { + return runtime.Value{}, fmt.Errorf("%w: calc_id is empty", ErrFunctionUnbound) + } + if rt == nil || idx == nil { + return runtime.Value{}, fmt.Errorf("%w: function %s", ErrFunctionNeedsRuntime, fn.GetCalcId()) + } + if fn.GetSelfId() != 0 { + return runtime.Value{}, fmt.Errorf( + "%w: %s: self_id %d names no object of this call: an object lives only within the response that created it", + ErrFunctionUnbound, fn.GetCalcId(), fn.GetSelfId()) + } + for _, sym := range idx.LookupQualified(fn.GetCalcId()) { + val, isFunction, err := rt.FunctionValue(sym) + if !isFunction { + continue + } + if err != nil { + return runtime.Value{}, fmt.Errorf("%w: %s: %v", ErrFunctionUnbound, fn.GetCalcId(), err) + } + return val, nil + } + return runtime.Value{}, fmt.Errorf("%w: %s is not a calc", ErrFunctionUnbound, fn.GetCalcId()) +} + +// metaobjectFromProto binds a metaobject to the one element its element_id +// names, reflected on as the metaclass the model classifies it by. A name two +// declarations share identifies neither, and a metaclass_id naming another +// metaclass is refused rather than read as a cast. +func metaobjectFromProto(meta *pb.Metaobject, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + if meta == nil || meta.GetElementId() == "" { + return runtime.Value{}, fmt.Errorf("%w: element_id is empty", ErrMetaobjectUnbound) + } + if idx == nil || sem == nil { + return runtime.Value{}, fmt.Errorf("%w: metaobject %s: no model to resolve it against", ErrMetaobjectUnbound, meta.GetElementId()) + } + var element, metaclass *symbols.Symbol + for _, sym := range idx.LookupQualified(meta.GetElementId()) { + mc := sem.MetaclassOf(sym) + if mc == nil || sym == element { + continue + } + if element != nil { + return runtime.Value{}, fmt.Errorf("%w: %s", ErrMetaobjectAmbiguous, meta.GetElementId()) + } + element, metaclass = sym, mc + } + if element == nil { + return runtime.Value{}, fmt.Errorf("%w: %s", ErrMetaobjectUnbound, meta.GetElementId()) + } + if meta.GetMetaclassId() != "" && meta.GetMetaclassId() != idx.GetFQN(metaclass) { + return runtime.Value{}, fmt.Errorf("%w: %s is classified by %s, not %s", + ErrMetaclassMismatch, meta.GetElementId(), idx.GetFQN(metaclass), meta.GetMetaclassId()) + } + return runtime.NewMetaobject(element, metaclass), nil +} + +// protoToSet rebuilds a set from elements sent in any order, refusing one sent +// twice rather than reading the two as one. +func protoToSet(rt *runtime.Context, ps *pb.ValueSet, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + set := runtime.NewSetIn(rt) + for i, elem := range ps.GetElements() { + val, err := ProtoToRuntimeValue(rt, elem, idx, sem) + if err != nil { + return runtime.Value{}, err + } + if set.Contains(val) { + return runtime.Value{}, fmt.Errorf("%w: element %d, %s", ErrSetElementRepeated, i+1, runtime.FormatValue(val)) + } + set.Add(val) + } + return runtime.NewSetValue(set), nil +} + +// protoToTensorQuantity rebuilds a tensor of any rank, refusing a shape its +// components do not fill, each component read exactly as a scalar Quantity is. +func protoToTensorQuantity(ptq *pb.TensorQuantity, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + dimensions := slices.Clone(ptq.GetDimensions()) + if err := CheckTensorShape(dimensions, len(ptq.GetComponents())); err != nil { + return runtime.Value{}, err + } + num := make([]semantics.Value, 0, len(ptq.GetComponents())) + units := make([]runtime.Unit, 0, len(ptq.GetComponents())) + for i, comp := range ptq.GetComponents() { + if comp == nil { + return runtime.Value{}, fmt.Errorf("%w: component %d", ErrTensorComponentMissing, i+1) + } + val, err := ProtoToQuantity(comp, idx, sem) + if err != nil { + return runtime.Value{}, fmt.Errorf("component %d: %w", i+1, err) + } + q := val.Quantity() + num = append(num, q.Num) + units = append(units, q.Unit) + } + return runtime.NewTensorQuantityValue(dimensions, num, units), nil +} + +// protoToArray rebuilds an array, refusing a shape its elements do not fill +// rather than reading them under some other shape. +func protoToArray(rt *runtime.Context, pa *pb.Array, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + dimensions := slices.Clone(pa.GetDimensions()) + if err := CheckArrayShape(dimensions, len(pa.GetElements())); err != nil { + return runtime.Value{}, err + } + elements := make([]runtime.Value, 0, len(pa.GetElements())) + for _, elem := range pa.GetElements() { + val, err := ProtoToRuntimeValue(rt, elem, idx, sem) + if err != nil { + return runtime.Value{}, err + } + elements = append(elements, val) + } + return runtime.NewArrayValue(dimensions, elements), nil +} + +// CheckArrayShape reports whether count elements fill dimensions in row-major +// order: every dimension positive and their product (one for rank 0) count. +func CheckArrayShape(dimensions []int64, count int) error { + return checkShape(dimensions, count, ErrArrayDimensionNotPositive, ErrArrayShapeMismatch) +} + +// CheckTensorShape is CheckArrayShape for a tensor's components, reported as +// the tensor errors. +func CheckTensorShape(dimensions []int64, count int) error { + return checkShape(dimensions, count, ErrTensorDimensionNotPositive, ErrTensorShapeMismatch) +} + +func checkShape(dimensions []int64, count int, notPositive, mismatch error) error { + size := int64(1) + for i, d := range dimensions { + if d < 1 { + return fmt.Errorf("%w: dimension %d is %d", notPositive, i+1, d) + } + if size > math.MaxInt64/d { + return fmt.Errorf("%w: flattenedSize of dimensions %v exceeds the Integer range", mismatch, dimensions) + } + size *= d + } + if size != int64(count) { + return fmt.Errorf("%w: %d elements under dimensions %v (flattenedSize %d)", + mismatch, count, dimensions, size) + } + return nil +} + +// protoToVector rebuilds a vector from components that are each an Integer or +// a Real, refusing any other arm rather than reading it as a number. +func protoToVector(pv *pb.Vector) (runtime.Value, error) { + components := make([]semantics.Value, 0, len(pv.GetComponents())) + for i, comp := range pv.GetComponents() { + num, err := protoToNumber(comp) + if err != nil { + return runtime.Value{}, fmt.Errorf("%w: component %d", err, i+1) + } + components = append(components, num) + } + return runtime.NewVectorValue(components), nil +} + +// protoToNumber is the Integer or Real a value holds; any other arm is an error. +func protoToNumber(pv *pb.Value) (semantics.Value, error) { + switch k := pv.GetKind().(type) { + case *pb.Value_IntValue: + return semantics.Value{Kind: semantics.ValInt, Int: k.IntValue}, nil + case *pb.Value_RealValue: + return semantics.Value{Kind: semantics.ValReal, Real: k.RealValue}, nil + } + return semantics.Value{}, ErrVectorComponentNotNumeric +} + +// protoToVectorQuantity rebuilds a vector quantity axis by axis, each +// component read exactly as a scalar Quantity is. +func protoToVectorQuantity(pvq *pb.VectorQuantity, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + if len(pvq.GetComponents()) == 0 { + return runtime.Value{}, ErrVectorQuantityEmpty + } + num := make([]semantics.Value, 0, len(pvq.GetComponents())) + units := make([]runtime.Unit, 0, len(pvq.GetComponents())) + for i, comp := range pvq.GetComponents() { + if comp == nil { + return runtime.Value{}, fmt.Errorf("%w: component %d carries no quantity", ErrVectorComponentNotNumeric, i+1) + } + val, err := ProtoToQuantity(comp, idx, sem) + if err != nil { + return runtime.Value{}, fmt.Errorf("component %d: %w", i+1, err) + } + q := val.Quantity() + num = append(num, q.Num) + units = append(units, q.Unit) + } + return runtime.NewVectorQuantityValue(num, units), nil +} + +// ProtoToQuantity rebuilds a quantity from the wire: the magnitude as sent, in +// the unit as written — read as the product of the named units it composes, so +// an operation over it cancels and merges them — over the base units idx +// resolves its reduction to. +func ProtoToQuantity(pq *pb.Quantity, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + if pq == nil { + return runtime.Value{Kind: runtime.ValNull}, nil + } + if (idx == nil || sem == nil) && len(pq.GetUnitTerm().GetFactors()) > 0 { + return runtime.Value{}, fmt.Errorf("%w: %s", ErrQuantityNeedsIndex, pq.GetUnit()) + } + if pq.GetUnitTerm() == nil && pq.GetUnit() != "" { + return runtime.Value{}, fmt.Errorf("%w: %s", ErrUnitNotReduced, pq.GetUnit()) + } + term, err := protoToUnitTerm(pq.GetUnitTerm(), idx, sem) + if err != nil { + return runtime.Value{}, err + } + + var num semantics.Value + switch m := pq.GetMagnitude().(type) { + case *pb.Quantity_IntMagnitude: + num = semantics.Value{Kind: semantics.ValInt, Int: m.IntMagnitude} + case *pb.Quantity_RealMagnitude: + num = semantics.Value{Kind: semantics.ValReal, Real: m.RealMagnitude} + default: + return runtime.Value{}, fmt.Errorf("quantity in %q carries no magnitude", pq.GetUnit()) + } + + product, term, err := unitProductOfText(pq.GetUnit(), term, idx, sem) + if err != nil { + return runtime.Value{}, err + } + text := pq.GetUnit() + if text == "" && !product.IsEmpty() { + text = product.String() + } + unit := runtime.Unit{Text: text, Product: product, Term: term} + return runtime.NewQuantityValue(&runtime.Quantity{Num: num, Unit: unit}), nil +} + +// ProtoToMeasurementRef rebuilds a bare reference from the wire: by the +// declaration unit_id names, checked against the unit text and reduction sent +// with it, or else by the unit text read exactly as a Quantity's is. +func ProtoToMeasurementRef(pm *pb.MeasurementRef, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + if pm.GetUnit() == "" && pm.GetUnitTerm() == nil && pm.GetUnitId() == "" { + return runtime.Value{}, ErrMeasurementRefEmpty + } + if (idx == nil || sem == nil) && (len(pm.GetUnitTerm().GetFactors()) > 0 || pm.GetUnitId() != "") { + return runtime.Value{}, fmt.Errorf("%w: %s", ErrMeasurementRefNeedsIndex, pm.GetUnit()) + } + if pm.GetUnitTerm() == nil { + return runtime.Value{}, fmt.Errorf("%w: %s", ErrUnitNotReduced, cmp.Or(pm.GetUnit(), pm.GetUnitId())) + } + term, err := protoToUnitTerm(pm.GetUnitTerm(), idx, sem) + if err != nil { + return runtime.Value{}, err + } + if pm.GetUnitId() != "" { + return declaredMeasurementRef(pm.GetUnitId(), pm.GetUnit(), term, idx, sem) + } + product, term, err := unitProductOfText(pm.GetUnit(), term, idx, sem) + if err != nil { + return runtime.Value{}, err + } + text := pm.GetUnit() + if text == "" && !product.IsEmpty() { + text = product.String() + } + return runtime.NewMeasurementRefValue(runtime.Unit{Text: text, Product: product, Term: term}), nil +} + +// declaredMeasurementRef is the reference to the unit declaration id names, refused +// unless it is a measurement unit reducing to term that text, if any, spells. +func declaredMeasurementRef(id, text string, term semantics.UnitTerm, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + matches := idx.LookupQualified(id) + if len(matches) != 1 { + return runtime.Value{}, fmt.Errorf("%w: %s", ErrUnknownMeasurementUnit, id) + } + unit, ok := sem.MeasurementUnitOf(matches[0]) + if !ok { + return runtime.Value{}, fmt.Errorf("%w: %s", ErrNotAMeasurementUnit, id) + } + declared, err := sem.UnitTermOf(unit) + if err != nil { + return runtime.Value{}, fmt.Errorf("%w: %s: %w", ErrUnitNotReduced, id, err) + } + if !reducesTo(declared, term) { + return runtime.Value{}, fmt.Errorf("%w: %s reduces to %s, unit_term is %s", ErrUnitTextMismatch, id, declared, term) + } + if text != "" && !textSpellsUnit(text, unit, idx, sem) { + return runtime.Value{}, fmt.Errorf("%w: %s is not %s", ErrUnitIDMismatch, text, id) + } + return runtime.DeclaredMeasurementRef(unit, text, declared), nil +} + +// textSpellsUnit reports whether unit text is one name for the declaration unit: +// its symbol or name (`km`, `kilometre`), or a qualified name resolving to it. +func textSpellsUnit(text string, unit *symbols.Symbol, idx *symbols.Index, sem *semantics.Model) bool { + p := parser.New(source.New("", []byte(text))) + expr := p.ParseExpression() + if expr == nil || len(p.Diagnostics) > 0 || p.Offset() != len(text) { + return false + } + spellings := []string{source.NameText(unit.Name)} + if unit.ShortName != "" { + spellings = append(spellings, source.NameText(unit.ShortName)) + } + product, err := sem.UnitProductOfExprBy(expr, func(qn *ast.QualifiedName) (*symbols.Symbol, bool) { + if len(qn.Parts) == 1 { + return unit, slices.Contains(spellings, source.NameText(qn.Parts[0].Text)) + } + matches := idx.LookupQualified(qn.Text()) + if len(matches) != 1 { + return nil, false + } + sym, ok := sem.MeasurementUnitOf(matches[0]) + return unit, ok && sym == unit + }) + if err != nil || len(product.Powers) != 1 { + return false + } + return product.Powers[0].Unit == unit && product.Powers[0].Exponent == 1 +} + +// unitProductOfText reads unit text as a product of the model's units that reduces +// to term; a short name is the one unit so named that fits. Text that does not read +// so keeps the factors it can name, the rest opaque (see partialUnitProduct); text +// that is no unit expression is one opaque unit. The reduction returned is the +// model's where the text is read in full, else term as sent. +func unitProductOfText(text string, term semantics.UnitTerm, idx *symbols.Index, sem *semantics.Model) (semantics.UnitProduct, semantics.UnitTerm, error) { + if text == "" { + return unnamedUnitProduct(term), term, nil + } + opaque := semantics.OpaqueUnitProduct(text, term) + if idx == nil || sem == nil { + return opaque, term, nil + } + p := parser.New(source.New("", []byte(text))) + expr := p.ParseExpression() + if expr == nil || len(p.Diagnostics) > 0 || p.Offset() != len(text) { + return opaque, term, nil + } + unitAt := func(fqn string) (*symbols.Symbol, bool) { + matches := idx.LookupQualified(fqn) + if len(matches) != 1 { + return nil, false + } + return measurementRefOf(matches[0], sem) + } + var short []*ast.QualifiedName + product, err := sem.UnitProductOfExprBy(expr, func(qn *ast.QualifiedName) (*symbols.Symbol, bool) { + if sym, ok := unitAt(qn.Text()); ok { + return sym, true + } + if len(qn.Parts) == 1 && !slices.Contains(short, qn) { + short = append(short, qn) + } + return nil, false + }) + if err != nil { + return opaque, term, nil + } + if len(short) == 0 { + implied, ok := impliedTerm(product, sem) + if !ok { + return partialUnitProduct(expr, opaque, term, unitAt, idx, sem), term, nil + } + if !reducesTo(implied, term) { + return semantics.UnitProduct{}, semantics.UnitTerm{}, fmt.Errorf("%w: %s reduces to %s, unit_term is %s", + ErrUnitTextMismatch, text, implied, term) + } + return product, implied, nil + } + + readings := shortUnitReadings(short, idx, sem) + var matches []shortUnitReading + for _, reading := range readings { + product, err := sem.UnitProductOfExprBy(expr, func(qn *ast.QualifiedName) (*symbols.Symbol, bool) { + if sym, ok := unitAt(qn.Text()); ok { + return sym, true + } + sym, ok := reading.units[qn] + return sym, ok + }) + if err != nil { + continue + } + implied, ok := impliedTerm(product, sem) + if !ok || !reducesTo(implied, term) { + continue + } + // Two readings of one product, `m*m` as A::m·B::m and as B::m·A::m, are one reading. + if slices.ContainsFunc(matches, func(m shortUnitReading) bool { return m.product.Equal(product) }) { + continue + } + reading.product, reading.term = product, implied + matches = append(matches, reading) + } + if len(matches) > 1 { + matches = slices.DeleteFunc(matches, func(r shortUnitReading) bool { + return !r.besideBaseUnits(term) + }) + } + if len(matches) != 1 { + return partialUnitProduct(expr, opaque, term, unitAt, idx, sem), term, nil + } + return matches[0].product, matches[0].term, nil +} + +// partialUnitProduct reads a unit text name by name — a qualified name or a short name +// one unit bears is that unit, any other an opaque factor — so the units read still cancel. +// Text whose every name reads, yet contradicts term, is opaque as a whole. +func partialUnitProduct( + expr ast.Node, + opaque semantics.UnitProduct, + term semantics.UnitTerm, + unitAt func(string) (*symbols.Symbol, bool), + idx *symbols.Index, + sem *semantics.Model, +) semantics.UnitProduct { + unreadNames := map[string]int{} + product, err := sem.UnitProductOfExprBy(expr, func(qn *ast.QualifiedName) (*symbols.Symbol, bool) { + if sym, ok := unitAt(qn.Text()); ok { + return sym, true + } + if len(qn.Parts) == 1 { + if units := unitsNamed(qn.Text(), idx, sem); len(units) == 1 { + return units[0], true + } + } + unreadNames[qn.Text()]++ + return nil, false + }) + if err != nil { + return opaque + } + // One unread name written twice is two units the text cannot tell apart. + for _, n := range unreadNames { + if n > 1 { + return opaque + } + } + known := semantics.UnitTerm{Scale: semantics.UnitScale(1)} + var unread []int + for i, f := range product.Powers { + if f.Unit == nil { + unread = append(unread, i) + continue + } + // A scale composed with anything is text no reduction is; nothing of it is read. + if sem.IsMeasurementScale(f.Unit) { + return opaque + } + factor, err := sem.UnitTermOf(f.Unit) + if err != nil { + return opaque + } + known = known.Times(factor.Pow(f.Exponent)) + } + if len(unread) == 0 { + return opaque + } + // A lone opaque factor is what term leaves once the units read are taken out. + if len(unread) == 1 { + f := &product.Powers[unread[0]] + reduces := term.DividedBy(known).Pow(1 / f.Exponent) + f.DimensionOne, f.Reduces = reduces.Dimensionless(), &reduces + } + return product +} + +// shortUnitReading is one assignment of a unit text's short names, occurrence by +// occurrence, to units: `m*m` may name two units both written m. +type shortUnitReading struct { + units map[*ast.QualifiedName]*symbols.Symbol + product semantics.UnitProduct + term semantics.UnitTerm +} + +// besideBaseUnits reports whether every unit read is declared beside a base unit of term. +func (r shortUnitReading) besideBaseUnits(term semantics.UnitTerm) bool { + namespaces := baseUnitNamespaces(term) + for _, sym := range r.units { + if !slices.Contains(namespaces, namespaceOf(sym)) { + return false + } + } + return true +} + +// maxShortUnitReadings bounds the readings tried for one unit text. +const maxShortUnitReadings = 1024 + +// shortUnitReadings enumerates, in a fixed order, every assignment of the short +// name occurrences to units so named; none if a name has no unit or there are too many. +func shortUnitReadings(names []*ast.QualifiedName, idx *symbols.Index, sem *semantics.Model) []shortUnitReading { + candidates := make([][]*symbols.Symbol, len(names)) + total := 1 + for i, qn := range names { + candidates[i] = unitsNamed(qn.Text(), idx, sem) + total *= len(candidates[i]) + if total == 0 || total > maxShortUnitReadings { + return nil + } + } + readings := make([]shortUnitReading, 0, total) + for k := range total { + units := make(map[*ast.QualifiedName]*symbols.Symbol, len(names)) + rem := k + for i, qn := range names { + units[qn] = candidates[i][rem%len(candidates[i])] + rem /= len(candidates[i]) + } + readings = append(readings, shortUnitReading{units: units}) + } + return readings +} + +// unitsNamed lists, once each in qualified-name order, the units and measurement +// scales under a short name. +func unitsNamed(name string, idx *symbols.Index, sem *semantics.Model) []*symbols.Symbol { + var units []*symbols.Symbol + for _, fqn := range idx.FQNsEndingIn(name, math.MaxInt) { + for _, sym := range idx.LookupQualified(fqn) { + if unit, ok := measurementRefOf(sym, sem); ok && !slices.Contains(units, unit) { + units = append(units, unit) + } + } + } + return units +} + +// measurementRefOf is the measurement unit or scale sym names; false for anything else. +func measurementRefOf(sym *symbols.Symbol, sem *semantics.Model) (*symbols.Symbol, bool) { + if unit, ok := sem.MeasurementUnitOf(sym); ok { + return unit, true + } + if sem.IsMeasurementScale(sym) { + return sym, true + } + return nil, false +} + +// termOfMeasurementRef reduces a unit to base units, and a measurement scale to +// itself: a point on it is commensurable with nothing but another point on it. +func termOfMeasurementRef(sym *symbols.Symbol, sem *semantics.Model) (semantics.UnitTerm, error) { + if sem.IsMeasurementScale(sym) { + return semantics.UnitTerm{Scale: semantics.UnitScale(1), Factors: []semantics.UnitFactor{{Unit: sym, Exponent: 1}}}, nil + } + return sem.UnitTermOf(sym) +} + +// impliedTerm reduces a product of resolved units; false if one is unresolved or unreducible. +func impliedTerm(product semantics.UnitProduct, sem *semantics.Model) (semantics.UnitTerm, bool) { + implied := semantics.UnitTerm{Scale: semantics.UnitScale(1)} + for _, f := range product.Powers { + if f.Unit == nil { + return semantics.UnitTerm{}, false + } + factor, err := termOfMeasurementRef(f.Unit, sem) + if err != nil { + return semantics.UnitTerm{}, false + } + implied = implied.Times(factor.Pow(f.Exponent)) + } + return implied, true +} + +// reducesTo reports whether two reductions are one unit: commensurable at one scale. +func reducesTo(implied, term semantics.UnitTerm) bool { + return implied.Commensurable(term) && sameScale(implied.Scale, term.Scale) +} + +// namespaceOf is the qualified name of the namespace declaring sym, or "" for a root. +func namespaceOf(sym *symbols.Symbol) string { + if sym.OwnerScope == nil || sym.OwnerScope.Owner() == nil { + return "" + } + return symbols.FQNOf(sym.OwnerScope.Owner()) +} + +// baseUnitNamespaces lists, in factor order and once each, the namespaces +// declaring the base units a reduction is over. +func baseUnitNamespaces(term semantics.UnitTerm) []string { + var out []string + for _, f := range term.Factors { + if f.Unit == nil { + continue + } + if ns := namespaceOf(f.Unit); ns != "" && !slices.Contains(out, ns) { + out = append(out, ns) + } + } + return out +} + +// unnamedUnitProduct is the unit of a quantity sent under no text: its base units +// at scale one, else the reduction as one opaque unit that names no dimension-one unit. +func unnamedUnitProduct(term semantics.UnitTerm) semantics.UnitProduct { + if !sameScale(term.Scale, semantics.UnitScale(1)) { + product := semantics.OpaqueUnitProduct(term.String(), term) + product.Powers[0].DimensionOne = false + return product + } + product := semantics.UnitProduct{} + for _, f := range term.Factors { + name := source.QualifiedNameText(symbols.FQNOf(f.Unit)) + product = product.Times(semantics.NamedUnitProduct(f.Unit, name, false).Pow(f.Exponent)) + } + return product +} + +// scaleTolerance is the relative difference two orders of composing one scale's +// factors can round to: a fraction of an ulp per multiplication, over at most dozens. +const scaleTolerance = 64 * 0x1p-52 + +// sameScale reports whether two scale ratios agree to within the rounding of +// composing them; a ratio further off is another scale, not noise. +func sameScale(a, b semantics.Scale) bool { + return math.Abs(semantics.ConvertMagnitude(1, a, b)-1) <= scaleTolerance +} + +// protoToUnitTerm rebuilds a unit's reduction, normalized so a term sent in any +// factor order is commensurable with the same unit derived in the model. A name +// the model does not declare uniquely as a measurement unit is an error, not a +// factor over whatever symbol it happened to resolve to. +func protoToUnitTerm(pt *pb.UnitTerm, idx *symbols.Index, sem *semantics.Model) (semantics.UnitTerm, error) { + if pt == nil { + // A magnitude sent under no unit at all: dimension one. + return semantics.UnitTerm{Scale: semantics.UnitScale(1)}, nil + } + scale := semantics.Scale{Num: pt.GetScaleNum(), Den: pt.GetScaleDen()} + if scale.IsZero() || !finite(scale.Num) || !finite(scale.Den) { + return semantics.UnitTerm{}, fmt.Errorf("%w: %g/%g", ErrUnitScaleUnusable, scale.Num, scale.Den) + } + term := semantics.UnitTerm{Scale: scale} + var pointOn *symbols.Symbol + for _, f := range pt.GetFactors() { + // An empty name is a lookup of the document root, so it is rejected here + // rather than resolved to a symbol that measures nothing. + if f.GetUnitId() == "" { + return semantics.UnitTerm{}, fmt.Errorf("%w: unit factor names no unit", ErrUnknownBaseUnit) + } + matches := idx.LookupQualified(f.GetUnitId()) + if len(matches) != 1 { + return semantics.UnitTerm{}, fmt.Errorf("%w: %s", ErrUnknownBaseUnit, f.GetUnitId()) + } + unit, ok := sem.MeasurementUnitOf(matches[0]) + if !ok { + // A point on a measurement scale reduces to the scale alone. + if !sem.IsMeasurementScale(matches[0]) { + return semantics.UnitTerm{}, fmt.Errorf("%w: %s", ErrNotAMeasurementUnit, f.GetUnitId()) + } + unit, pointOn = matches[0], matches[0] + } + term.Factors = append(term.Factors, semantics.UnitFactor{ + Unit: unit, + Exponent: f.GetExponent(), + }) + } + // Checked after normalization: repeated factors sum their exponents, and + // finite powers can overflow in that sum. + term = term.Normalized() + for _, f := range term.Factors { + if !finite(f.Exponent) { + return semantics.UnitTerm{}, fmt.Errorf("%w: %s**%g", ErrUnitExponentUnusable, symbols.FQNOf(f.Unit), f.Exponent) + } + } + if pointOn != nil { + if _, ok := sem.MeasurementScaleOf(term); !ok { + return semantics.UnitTerm{}, fmt.Errorf("%w: %s in %s", ErrScaleNotAFactor, symbols.FQNOf(pointOn), term) + } + } + return term, nil +} + +// finite reports whether a wire double is a number a unit term can carry. +func finite(x float64) bool { return !math.IsNaN(x) && !math.IsInf(x, 0) } + +// enumLiteralFromProto resolves a literal against the model, since a literal is +// the declaration it names: one the model does not declare has no identity here. +// The model says what a valued literal equals; the wire's value is consulted +// only when no runtime is at hand to evaluate the declaration. +func enumLiteralFromProto(rt *runtime.Context, lit *pb.EnumLiteral, idx *symbols.Index, sem *semantics.Model) (runtime.Value, error) { + if lit == nil || lit.GetLiteralId() == "" { + return runtime.Value{}, fmt.Errorf("enumeration literal: literal_id names no declaration") + } + if idx == nil { + return runtime.Value{}, fmt.Errorf("enumeration literal %s: no model to resolve it against", lit.GetLiteralId()) + } + for _, sym := range idx.LookupQualified(lit.GetLiteralId()) { + if semantics.EnumerationOwning(sym) == nil { + continue + } + if rt != nil { + val, _, err := rt.EnumerationLiteralValue(sym) + return val, err + } + if lit.GetValue() == nil { + return runtime.NewEnumLiteral(sym), nil + } + scalar, err := ProtoToValueIn(lit.GetValue(), idx, sem) + if err != nil { + return runtime.Value{}, fmt.Errorf("enumeration literal %s: %w", lit.GetLiteralId(), err) + } + return runtime.EnumeratedValue(sym, scalar), nil + } + return runtime.Value{}, fmt.Errorf("%s is not an enumeration literal of this model", lit.GetLiteralId()) +} + +// ProtoToScalar converts the arms of Value that name no symbol and hold no +// nested value. +func ProtoToScalar(pv *pb.Value) runtime.Value { + switch k := pv.GetKind().(type) { + case *pb.Value_IntValue: + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInt, Int: k.IntValue}} + case *pb.Value_RealValue: + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: k.RealValue}} + case *pb.Value_BoolValue: + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: k.BoolValue}} + case *pb.Value_StringValue: + return runtime.NewStringValue(k.StringValue) + case *pb.Value_InstanceId: + return runtime.Value{Kind: runtime.ValInstance, Instance: k.InstanceId} + case *pb.Value_Null: + return runtime.Value{Kind: runtime.ValNull} + case *pb.Value_Complex: + return runtime.NewComplex(ProtoToComplex(k.Complex)) + case *pb.Value_Infinity: + if !k.Infinity { + return runtime.Value{Kind: runtime.ValNull} + } + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValInfinity}} + default: + return runtime.Value{Kind: runtime.ValNull} + } +} + +const ( + // maxGraphDepth bounds how deep Instantiate expands nested objects. + maxGraphDepth = 8 + // maxGraphInstances caps how many instances one response serializes. + maxGraphInstances = 1000 +) + +// GraphBounds is how far InstanceGraphToProtoWithin expands an object graph: +// nested objects to Depth, and Instances objects in all. +type GraphBounds struct { + Depth int + Instances int +} + +// DefaultGraphBounds are the bounds the service serializes an instance graph under. +func DefaultGraphBounds() GraphBounds { + return GraphBounds{Depth: maxGraphDepth, Instances: maxGraphInstances} +} + +// InstanceGraph is an object graph serialized by InstanceGraphToProtoWithin. +type InstanceGraph struct { + Root *pb.Instance + All []*pb.Instance // the root first + // Truncated reports that the instance bound cut the graph short. + Truncated bool + // Errors are the typed failures behind every FeatureValue.Error in All. + Errors []error +} + +// InstanceGraphToProto converts inst and every instance reachable from it. The +// root is returned first; runtime instances live only for the duration of a +// request, so the whole reachable graph is serialized while the context is alive. +// +// Expansion stops at a child whose type is already on the path, at maxGraphDepth +// and at maxGraphInstances: reading a composite feature value materializes the object it +// holds, so a self-referential part would otherwise instantiate forever. An +// unexpanded child stays a bare instance id. +func InstanceGraphToProto(rt *runtime.Context, inst *runtime.Instance, idx *symbols.Index) (*pb.Instance, []*pb.Instance) { + g := InstanceGraphToProtoWithin(rt, inst, idx, DefaultGraphBounds()) + return g.Root, g.All +} + +// InstanceGraphToProtoWithin is InstanceGraphToProto under the given bounds; the +// type cycle guard applies whatever the bounds. +func InstanceGraphToProtoWithin(rt *runtime.Context, inst *runtime.Instance, idx *symbols.Index, bounds GraphBounds) InstanceGraph { + var g InstanceGraph + seen := make(map[int64]bool) + onPath := make(map[*symbols.Symbol]bool) + + var walk func(*runtime.Instance, int) *pb.Instance + walk = func(cur *runtime.Instance, depth int) *pb.Instance { + if seen[cur.ID] { + return nil + } + if len(g.All) >= bounds.Instances { + g.Truncated = true + return nil + } + seen[cur.ID] = true + onPath[cur.Type] = true + defer delete(onPath, cur.Type) + + // InstanceToProto reads every feature value through GetFeatureValue, which is what + // lazily materializes the children the ids below resolve to. + pbInst := instanceToProto(rt, cur, idx, func(err error) { g.Errors = append(g.Errors, err) }) + g.All = append(g.All, pbInst) + + if depth >= bounds.Depth { + return pbInst + } + + // In name order, so the graph is serialized in the same order every run. + for _, name := range slices.Sorted(maps.Keys(pbInst.FeatureValues)) { + for _, id := range instanceRefs(pbInst.FeatureValues[name]) { + child, ok := rt.Instance(id) + if !ok || onPath[child.Type] { + continue + } + walk(child, depth+1) + } + } + return pbInst + } + + g.Root = walk(inst, 0) + return g +} + +// instanceRefs collects the instance IDs a feature value references, scalar or not. +func instanceRefs(fv *pb.FeatureValue) []int64 { + var ids []int64 + var collect func(*pb.Value) + collect = func(v *pb.Value) { + if k, ok := v.GetKind().(*pb.Value_InstanceId); ok { + ids = append(ids, k.InstanceId) + } + for _, nested := range NestedValues(v) { + collect(nested) + } + } + if fv.Value != nil { + collect(fv.Value) + } + for _, v := range fv.Values { + collect(v) + } + return ids +} + +// InstanceToProto converts runtime.Instance to protobuf Instance. Feature values +// are read through Instance.GetFeatureValue, so a derived default is evaluated against +// the instance rather than reported as unmaterialized. +// Features are read in name order, because reading one materializes the object it +// holds, so map order would decide the ids those objects are given. +func InstanceToProto(rt *runtime.Context, inst *runtime.Instance, idx *symbols.Index) *pb.Instance { + return instanceToProto(rt, inst, idx, func(error) { /* unmaterialized features are silent */ }) +} + +// instanceToProto is InstanceToProto, handing each feature value it could not +// read to failed as the runtime's typed error before reporting its text. +func instanceToProto(rt *runtime.Context, inst *runtime.Instance, idx *symbols.Index, failed func(error)) *pb.Instance { + pbValues := make(map[string]*pb.FeatureValue) + + for _, name := range slices.Sorted(maps.Keys(inst.FeatureValues)) { + fv, err := inst.GetFeatureValue(rt, name) + if err != nil { + failed(err) + pbValues[name] = &pb.FeatureValue{ + FeatureName: name, + Error: err.Error(), + } + continue + } + + pbValue := &pb.FeatureValue{ + FeatureName: name, + Materialized: fv.Materialized, + } + + // Check multiplicity to determine single- vs multi-valued + if fv.Feature.Scalar() { + // Single-valued. An unmaterialized one holds no value; marshalling it + // anyway would report the empty value as an unsupported null. A + // materialized one holding nothing is unset, as every surface reads it. + switch { + case !fv.Materialized: + case fv.Value.Kind == runtime.ValInvalid: + pbValue.Value = &pb.Value{Kind: &pb.Value_Unset{Unset: true}} + default: + pbValue.Value = ValueToProtoIn(rt, fv.Value, idx) + } + } else { + for _, elem := range objref.CollectionElements(fv.Values) { + pbValue.Values = append(pbValue.Values, ValueToProtoIn(rt, elem, idx)) + } + } + + pbValues[name] = pbValue + } + + return &pb.Instance{ + Id: inst.ID, + TypeSymbolId: idx.GetFQN(inst.Type), + FeatureValues: pbValues, + } +} diff --git a/internal/frontend/repl/analysis.go b/internal/frontend/repl/analysis.go new file mode 100644 index 0000000000..39e8483fbc --- /dev/null +++ b/internal/frontend/repl/analysis.go @@ -0,0 +1,325 @@ +package repl + +import ( + "errors" + "fmt" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +const analysisUsage = "usage: %analysis [()] []" + +// analysisInvocation is `%analysis`'s tail taken apart: the case's name, the +// argument list written in parentheses after it, and the object named after +// that as the case's subject. +type analysisInvocation struct { + name string + argText string + object string +} + +// splitAnalysisArgs takes apart `%analysis`'s tail: `Case`, `Case ship`, +// `Case(3.0, limit = 4.0)` or `Case(3.0) ship`. Arguments are written as an +// invocation so a bare word after the name is unambiguously the subject. +func splitAnalysisArgs(tail string) (analysisInvocation, error) { + tail = strings.TrimSpace(tail) + cut := indexOutsideName(tail, " \t(") + if cut < 0 { + return analysisInvocation{name: tail}, nil + } + inv := analysisInvocation{name: tail[:cut]} + rest := strings.TrimSpace(tail[cut:]) + if strings.HasPrefix(rest, "(") { + end := closingParen(rest) + if end < 0 { + return analysisInvocation{}, fmt.Errorf("argument list %q is not closed", rest) + } + inv.argText = rest[1:end] + rest = strings.TrimSpace(rest[end+1:]) + } + if rest == "" { + return inv, nil + } + if indexOutsideName(rest, " \t") >= 0 { + return analysisInvocation{}, fmt.Errorf("%q does not name one object; arguments are written in parentheses after the case's name", rest) + } + inv.object = rest + return inv, nil +} + +// analysisArgs are an invocation's arguments parsed and not yet evaluated: +// positional ones in order, and the ones written as ` = `. +type analysisArgs struct { + positional []argExpr + named []argument +} + +// parseAnalysisArgs parses an argument list in which a positional argument and +// one binding a parameter by name may be mixed, as a case's inputs allow. +func parseAnalysisArgs(text string) (analysisArgs, error) { + var args analysisArgs + for _, arg := range splitArgs(text) { + if isNamedArgument(arg) { + named, err := parseArguments([]string{arg}) + if err != nil { + return analysisArgs{}, err + } + args.named = append(args.named, named...) + continue + } + expr, err := parseWholeExpr(arg) + if err != nil { + return analysisArgs{}, err + } + args.positional = append(args.positional, argExpr{expr: expr, text: arg}) + } + return args, nil +} + +// doAnalysis carries out %analysis at the prompt. +func (s *Session) doAnalysis(tail string) ([]string, bool, error) { + inv, err := splitAnalysisArgs(tail) + if err != nil { + return []string{errPrefix + err.Error(), analysisUsage}, false, nil + } + if inv.name == "" { + return []string{analysisUsage}, false, nil + } + return s.withTrace(s.analysisVerdict(inv)).Lines, false, nil +} + +// analysisVerdict runs an analysis case and reports what it computed and +// decided. A run that could not be made is unresolved; one whose objective or +// assertion did not hold fails; one a check left undecided is unresolved too, +// since it decided nothing about the model. A verification case answers with +// the verdict of its body as well, which its status reports. A run that failed +// after evaluating some of what it declares reports those evaluations and the +// verdicts left undecided beneath the error. +func (s *Session) analysisVerdict(inv analysisInvocation) Verdict { + run, err := s.runAnalysis(inv) + return s.caseVerdict(inv, run, err) +} + +// caseVerdict reports a run of the case inv names, run already made; err is the +// error the run ended with, nil when it completed. +func (s *Session) caseVerdict(inv analysisInvocation, run caseRun, err error) Verdict { + label := inv.name + if inv.argText != "" { + label += "(" + strings.TrimSpace(inv.argText) + ")" + } + if err != nil { + verdict := unresolvedVerdict(label, err.Error()) + s.reportCaseRun(&verdict, run.result) + return standing(verdict, run.plan) + } + result, subject, subjectLabel := run.result, run.subject, run.label + + status := VerdictHolds + for _, v := range result.Verdicts { + switch v.Status { + case runtime.VerdictNotSatisfied: + if status == VerdictHolds { + status = VerdictFails + } + case runtime.VerdictUndecided: + status = VerdictUnresolved + } + } + // The case's own body decides its status; a subcase answers for itself, + // since the library states no roll-up into the case performing it. + for _, v := range run.verdicts { + if s := verificationStatus(v.Kind); !v.Subcase && s > status { + status = s + } + } + mark := statusMark(status) + on := "" + if subject != nil { + on = " on " + objectMention(subject, subjectLabel) + } + verdict := Verdict{Subject: label, Status: status, Lines: []string{fmt.Sprintf("%s %s%s", mark, label, on)}} + s.reportCaseRun(&verdict, result) + for _, v := range run.verdicts { + verdict.Verifications = append(verdict.Verifications, VerificationVerdict{ + Case: v.Case, Kind: string(v.Kind), Detail: v.Detail, Subcase: v.Subcase, + }) + verdict.Lines = append(verdict.Lines, " "+verificationLine(v)) + } + return standing(verdict, run.plan) +} + +// reportCaseRun adds to verdict what a case run produced: each output, each +// objective and assertion verdict, then each evaluation the run made of one of +// the case's calcs — a trade study's alternatives in subject order, the +// selected one marked, and those evaluating alike marked tied. +func (s *Session) reportCaseRun(verdict *Verdict, result runtime.AnalysisResult) { + reportCaseRunIn(s.rtCtx, verdict, result) +} + +// reportCaseRunIn reports a case run made in ctx, which its values are read through. +func reportCaseRunIn(ctx *runtime.Context, verdict *Verdict, result runtime.AnalysisResult) { + for _, out := range result.Outputs { + text := objectText(ctx, out.Value) + verdict.Lines = append(verdict.Lines, fmt.Sprintf(" %s = %s", out.Name, text)) + verdict.Values = append(verdict.Values, NamedValue{Name: out.Name, Value: text}) + } + for _, v := range result.Verdicts { + name := v.Kind + " " + v.Name + text := v.Status.String() + if v.Detail != "" { + text += ": " + v.Detail + } + verdict.Lines = append(verdict.Lines, fmt.Sprintf(" %s: %s", name, text)) + verdict.Values = append(verdict.Values, NamedValue{Name: name, Value: text}) + } + for _, e := range result.Evaluations { + evaluation, text := evaluationOf(ctx, result.Case, e) + verdict.Lines = append(verdict.Lines, " "+text) + verdict.Evaluations = append(verdict.Evaluations, evaluation) + } +} + +// evaluationOf reports one evaluation a run of caseName made, as data and as the +// line a report prints: the call relative to the case, its result or error, and +// whether it was selected or tied. +func evaluationOf(ctx *runtime.Context, caseName string, e runtime.AnalysisEvaluation) (Evaluation, string) { + evaluation := Evaluation{Function: e.Function, Selected: e.Selected, Tied: e.Tied} + for _, arg := range e.Arguments { + evaluation.Arguments = append(evaluation.Arguments, objectText(ctx, arg)) + } + text := fmt.Sprintf("%s(%s)", strings.TrimPrefix(e.Function, caseName+"::"), strings.Join(evaluation.Arguments, ", ")) + if e.Error != nil { + evaluation.Error = e.Error.Error() + text += ": error: " + evaluation.Error + } else { + evaluation.Result = formatValue(ctx, e.Result) + text += " = " + evaluation.Result + } + switch { + case e.Selected: + text += " [selected]" + case e.Tied: + text += " [tied]" + } + return evaluation, text +} + +// objectText spells a value as a report names it: an object that is the +// occurrence of a usage by that usage, anything else as %eval prints it. +func objectText(ctx *runtime.Context, val runtime.Value) string { + if val.Kind == runtime.ValInstance { + if inst, ok := ctx.Instance(val.Instance); ok { + if usage := ctx.OccurrenceUsage(inst); usage != "" { + return fmt.Sprintf("%s (object #%d)", usage, inst.ID) + } + } + } + return formatValue(ctx, val) +} + +// caseRun is what one run of a case produced: what it computed and decided, the +// object it ran on, and, for a verification case, the verdict of its body and of +// every subcase it performed. plan is how the engines answered, nil for a run +// made inside a linearization. +type caseRun struct { + result runtime.AnalysisResult + subject *runtime.Instance + label string + verdicts []runtime.VerificationVerdict + plan *analysis.Plan +} + +// runAnalysis resolves the case an invocation names, evaluates its arguments +// and the object named as its subject, and runs it in the session's context. A +// usage nested in a type is run as a feature of the object the session holds for +// that type, as a constraint is checked on the object carrying it. +func (s *Session) runAnalysis(inv analysisInvocation) (caseRun, error) { + sym, fqn, err := s.analysisSymbol(inv) + if err != nil { + return caseRun{}, err + } + ctx, err := s.getOrCreateRuntime() + if err != nil { + return caseRun{}, err + } + return s.runAnalysisIn(s.dispatched(), ctx, inv, sym, fqn, heldObjects{s}) +} + +// analysisSymbol resolves the case an invocation names. It is resolved before the +// runtime is built, so a misspelling is reported as one whatever the session holds. +func (s *Session) analysisSymbol(inv analysisInvocation) (*symbols.Symbol, string, error) { + doc := s.ws.Document(docName) + if doc == nil || doc.Scope == nil { + return nil, "", errors.New("no declarations loaded") + } + return s.lookupSymbolOfKinds(inv.name, + symbols.SymbolAnalysisCaseDef, symbols.SymbolAnalysisCaseUsage, + symbols.SymbolVerificationCaseDef, symbols.SymbolVerificationCaseUsage) +} + +// runAnalysisIn runs a case in ctx as x makes runs, finding the object named as +// its subject, and the one owning a usage nested in a type, where objects finds them. +func (s *Session) runAnalysisIn(x execution, ctx *runtime.Context, inv analysisInvocation, sym *symbols.Symbol, fqn string, objects runObjects) (caseRun, error) { + if err := ctx.RequireAnalysisCase(sym); err != nil { + return caseRun{}, err + } + + parsed, err := parseAnalysisArgs(inv.argText) + if err != nil { + return caseRun{}, err + } + var args runtime.AnalysisArgs + scope := s.promptScope() + for _, arg := range parsed.positional { + val, err := ctx.EvalWithScope(arg.expr, scope) + if err != nil { + return caseRun{}, fmt.Errorf("evaluation of argument %q failed: %w", arg.text, err) + } + args.Positional = append(args.Positional, val) + } + if args.Named, err = s.evalArguments(ctx, parsed.named); err != nil { + return caseRun{}, err + } + + run := caseRun{} + if inv.object != "" { + if run.subject, run.label, err = objects.object(inv.object); err != nil { + return caseRun{}, err + } + args.Subject = run.subject + } + + // A usage owned by a type is a feature of an object of that type, which the + // session holds when one was created; a package-level case has no such owner. + self := nestedCaseOwner(sym, fqn, objects) + runScope := declaringScope(sym, s.ws.Document(docName).Scope) + + // A verification case runs the same body; asking the run for its verdict too + // reports it beside what the run computed. + if runtime.IsVerificationCaseSymbol(sym) { + verified, plan, err := x.runVerification(fqn, ctx, func(ctx *runtime.Context) (runtime.VerificationResult, error) { + return ctx.RunVerification(sym, args, runScope, self) + }) + run.plan = plan + if err != nil { + return run, err + } + run.result = verified.Run + run.verdicts = append([]runtime.VerificationVerdict{verified.Verdict}, verified.Subcases...) + return run, nil + } + result, plan, err := x.runCase(fqn, ctx, func(ctx *runtime.Context) (runtime.AnalysisResult, error) { + return ctx.RunAnalysis(sym, args, runScope, self) + }) + run.result, run.plan = result, plan + if err != nil { + if errors.Is(err, runtime.ErrNotAnAnalysis) { + return caseRun{plan: plan}, err + } + return run, fmt.Errorf("analysis run failed: %w", err) + } + return run, nil +} diff --git a/internal/repl/analysis_test.go b/internal/frontend/repl/analysis_test.go similarity index 100% rename from internal/repl/analysis_test.go rename to internal/frontend/repl/analysis_test.go diff --git a/internal/repl/bareexpr_test.go b/internal/frontend/repl/bareexpr_test.go similarity index 100% rename from internal/repl/bareexpr_test.go rename to internal/frontend/repl/bareexpr_test.go diff --git a/internal/repl/behavior_body_names_test.go b/internal/frontend/repl/behavior_body_names_test.go similarity index 100% rename from internal/repl/behavior_body_names_test.go rename to internal/frontend/repl/behavior_body_names_test.go diff --git a/internal/frontend/repl/bench_test.go b/internal/frontend/repl/bench_test.go new file mode 100644 index 0000000000..e924d71fbd --- /dev/null +++ b/internal/frontend/repl/bench_test.go @@ -0,0 +1,212 @@ +package repl + +import ( + "fmt" + "runtime" + "strings" + "testing" +) + +// Benchmarks over synthetic models of a stated size: a super-linear cost is +// visible only across sizes. See docs/internals/performance.md. +// +// go test ./internal/frontend/repl -run '^$' -bench . -benchmem +// +// Beyond the standard figures, B/element is memory allocated per model element, +// and live-B/op is memory the loaded model holds with the session reachable. +const benchElementsPerPart = 5 // part def, calc def, action def, state machine, part usage + +// modelSizes are the element counts the load benchmarks run at. They double, so +// a super-linear cost shows as a per-element figure that grows with size. +var modelSizes = []int{50, 200, 800} + +// emptyModel has no elements, so its load cost is what a session costs before it +// holds anything: the standard library it indexes names against. +const emptyModel = "package BenchModel {\n import ScalarValues::*;\n}\n" + +// syntheticModel returns parts repetitions of a part definition, a calculation, +// an action, a state machine and a part usage. Each part refers to the next, so +// name resolution has work that grows with the model. +func syntheticModel(parts int) string { + var b strings.Builder + b.WriteString("package BenchModel {\n import ScalarValues::*;\n") + for i := 0; i < parts; i++ { + fmt.Fprintf(&b, ` + part def Comp%[1]d { + attribute mass : Real; + attribute power : Real; + part sub : Comp%[2]d; + constraint MassOK { + mass > 0.0 + } + } + calc def Calc%[1]d { + in a : Real; + in b : Real; + return : Real = a * b + %[1]d; + } + action def Act%[1]d { + in x : Real; + out y : Real = x * 2.0; + } + state SM%[1]d { + entry; then start; + state start; + state idle; + state running; + + succession first start then idle; + transition first idle then running; + transition first running then done; + } + part inst%[1]d : Comp%[1]d { + attribute :>> mass = %[1]d.0; + attribute :>> power = %[1]d.5; + } +`, i, (i+1)%parts) + } + b.WriteString("}\n") + return b.String() +} + +func warningModel(parts int) string { + var b strings.Builder + b.WriteString("package BenchWarnings {\n") + for i := 0; i < parts; i++ { + fmt.Fprintf(&b, " attribute flag%d = 1 == \"one\";\n", i) + } + b.WriteString("}\n") + return b.String() +} + +// loadModel loads src into a fresh session, failing if it does not analyse +// cleanly, since an erroring model skips the later passes. +func loadModel(tb testing.TB, src string) *Session { + tb.Helper() + sess := NewSession() + sess.SubmitFiles([]SourceFile{{Name: "bench.sysml", Text: src}}) + if sess.HasErrors() { + tb.Fatalf("the benchmark model did not analyse cleanly:\n%s", strings.Join(sess.DiagnosticLines(), "\n")) + } + return sess +} + +// liveHeap returns the reachable heap, collecting first so that what it reports +// is what is held. +func liveHeap() uint64 { + runtime.GC() + var m runtime.MemStats + runtime.ReadMemStats(&m) + return m.HeapAlloc +} + +// BenchmarkLoadModel measures parsing, name resolution and the validation passes +// over a whole model, which is what `sysml -validate` spends its time in. +func BenchmarkLoadModel(b *testing.B) { + sizes := append([]int{0}, modelSizes...) + for _, parts := range sizes { + src := emptyModel + if parts > 0 { + src = syntheticModel(parts) + } + elements := parts * benchElementsPerPart + b.Run(fmt.Sprintf("elements=%d", elements), func(b *testing.B) { + before := liveHeap() + sess := loadModel(b, src) + held := liveHeap() - before + runtime.KeepAlive(sess) + + var start, end runtime.MemStats + runtime.ReadMemStats(&start) + b.ResetTimer() + for i := 0; i < b.N; i++ { + loadModel(b, src) + } + b.StopTimer() + runtime.ReadMemStats(&end) + + allocated := (end.TotalAlloc - start.TotalAlloc) / uint64(b.N) + b.ReportMetric(float64(held), "live-B/op") + if elements > 0 { + b.ReportMetric(float64(allocated)/float64(elements), "B/element") + } + }) + } +} + +// benchmarkRun measures one run over an already-loaded model, at each model size: +// a figure that grows with model size is a cost the run pays for the model. +func benchmarkRun(b *testing.B, run func(b *testing.B, sess *Session)) { + for _, parts := range modelSizes { + src := syntheticModel(parts) + b.Run(fmt.Sprintf("elements=%d", parts*benchElementsPerPart), func(b *testing.B) { + sess := loadModel(b, src) + // The first run builds the session's runtime and indexes the standard + // library, a cost of the session rather than of a run. + run(b, sess) + b.ResetTimer() + for i := 0; i < b.N; i++ { + run(b, sess) + } + }) + } +} + +// BenchmarkRunStateMachine measures starting a state machine in a loaded model. +func BenchmarkRunStateMachine(b *testing.B) { + benchmarkRun(b, func(b *testing.B, sess *Session) { + if v := sess.RunStateMachine("SM0"); !v.Holds() { + b.Fatalf("SM0 did not run: %v", v.Lines) + } + }) +} + +// BenchmarkRunCalc measures evaluating a calculation in a loaded model, which is +// the expression evaluator over a model whose scopes it must search. +func BenchmarkRunCalc(b *testing.B) { + benchmarkRun(b, func(b *testing.B, sess *Session) { + if v := sess.RunCalc("Calc0(2.0, 3.0)"); !v.Holds() { + b.Fatalf("Calc0 did not run: %v", v.Lines) + } + }) +} + +// BenchmarkInstantiate measures creating an object of a part definition, which +// is what a check about an object pays before it evaluates anything. +func BenchmarkInstantiate(b *testing.B) { + benchmarkRun(b, func(b *testing.B, sess *Session) { + if _, err := sess.InstantiateNamed("Comp0"); err != nil { + b.Fatal(err) + } + }) +} + +// BenchmarkDiagnostics measures rendering and locating diagnostics across a +// warning-emitting model, one warning per declared attribute. +func BenchmarkDiagnostics(b *testing.B) { + for _, attributes := range modelSizes { + src := warningModel(attributes) + b.Run(fmt.Sprintf("attributes=%d", attributes), func(b *testing.B) { + sess := NewSession() + sess.Submit(src) + diagnostics := len(sess.Diagnostics()) + if diagnostics == 0 { + b.Fatal("warning model produced no diagnostics") + } + + var start, end runtime.MemStats + runtime.ReadMemStats(&start) + b.ResetTimer() + for i := 0; i < b.N; i++ { + sess.DiagnosticLines() + sess.LocatedDiagnostics() + } + b.StopTimer() + runtime.ReadMemStats(&end) + + allocated := (end.TotalAlloc - start.TotalAlloc) / uint64(b.N) + b.ReportMetric(float64(allocated)/float64(2*diagnostics), "B/diagnostic") + runtime.KeepAlive(sess) + }) + } +} diff --git a/internal/frontend/repl/budget_test.go b/internal/frontend/repl/budget_test.go new file mode 100644 index 0000000000..6cda87f4b0 --- /dev/null +++ b/internal/frontend/repl/budget_test.go @@ -0,0 +1,160 @@ +package repl + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// TestSessionBudgetsDefault: a fresh session runs on the default bounds. +func TestSessionBudgetsDefault(t *testing.T) { + if got := NewSession().Budgets(); got != runtime.DefaultBudgets() { + t.Errorf("Budgets() = %+v, want %+v", got, runtime.DefaultBudgets()) + } +} + +// TestSetBudgets: raised bounds are adopted and apply to the runtime context +// created afterwards; a non-positive bound is refused. +func TestSetBudgets(t *testing.T) { + s := NewSession() + if res := s.Submit("package P { part def Q; }"); len(res.Diagnostics) > 0 { + t.Fatalf("submit reported diagnostics: %v", res.Diagnostics) + } + if _, err := s.getOrCreateRuntime(); err != nil { + t.Fatalf("getOrCreateRuntime: %v", err) + } + + s.instances["P::Q"] = &runtime.Instance{ID: 1} + + want := runtime.Budgets{MaxSteps: 4200, MaxActionSteps: 42, MaxStateEvents: 43, MaxDoSteps: 44, MaxElements: 45, MaxCalcDepth: 46, MaxSweepRuns: 47} + if err := s.SetBudgets(want); err != nil { + t.Fatalf("SetBudgets: %v", err) + } + if got := s.Budgets(); got != want { + t.Errorf("Budgets() = %+v, want %+v", got, want) + } + ctx, err := s.getOrCreateRuntime() + if err != nil { + t.Fatalf("getOrCreateRuntime: %v", err) + } + if got := ctx.Budgets(); got != want { + t.Errorf("runtime context bounds = %+v, want %+v", got, want) + } + // Instances belonged to the discarded context, whose IDs the new one reuses. + if len(s.instances) != 0 { + t.Errorf("instances survived the new context: %v", s.instances) + } + + for _, bad := range []runtime.Budgets{ + {MaxSteps: 0, MaxActionSteps: 1, MaxStateEvents: 1, MaxDoSteps: 1, MaxElements: 1, MaxCalcDepth: 1, MaxSweepRuns: 1}, + {MaxSteps: 1, MaxActionSteps: 1, MaxStateEvents: -1, MaxDoSteps: 1, MaxElements: 1, MaxCalcDepth: 1, MaxSweepRuns: 1}, + {MaxSteps: 1, MaxActionSteps: 1, MaxStateEvents: 1, MaxDoSteps: 1, MaxElements: -1, MaxCalcDepth: 1, MaxSweepRuns: 1}, + {MaxSteps: 1, MaxActionSteps: 1, MaxStateEvents: 1, MaxDoSteps: 1, MaxElements: 1, MaxCalcDepth: -1, MaxSweepRuns: 1}, + {MaxSteps: 1, MaxActionSteps: 1, MaxStateEvents: 1, MaxDoSteps: 1, MaxElements: 1, MaxCalcDepth: 1, MaxSweepRuns: -1}, + {}, + } { + if err := s.SetBudgets(bad); err == nil { + t.Errorf("SetBudgets(%+v) was accepted", bad) + } + } + if got := s.Budgets(); got != want { + t.Errorf("a refused set changed the bounds to %+v", got) + } +} + +// TestBudgetCommandShowsEveryBound: %budget reports the session's own bounds, +// each named with the variable that raises it. +func TestBudgetCommandShowsEveryBound(t *testing.T) { + s := NewSession() + budgets := runtime.DefaultBudgets() + budgets.MaxElements = 4242 + if err := s.SetBudgets(budgets); err != nil { + t.Fatalf("SetBudgets: %v", err) + } + wants(t, run(t, s, "%budget"), + runtime.MaxStepsEnvVar, + runtime.MaxActionStepsEnvVar, + runtime.MaxStateEventsEnvVar, + runtime.MaxDoStepsEnvVar, + "4242", + runtime.MaxElementsEnvVar, + runtime.MaxCalcDepthEnvVar, + runtime.MaxSweepRunsEnvVar) +} + +// TestAdvanceIsBoundedBySessionBudgets: %advance drains a machine that never +// settles up to the session's own bounds, and says which one stopped it instead +// of looking like a machine that had settled. +func TestAdvanceIsBoundedBySessionBudgets(t *testing.T) { + t.Run("event_budget", func(t *testing.T) { + s := loadFixture(t, "testdata/state_spin.sysml") + budgets := runtime.DefaultBudgets() + budgets.MaxStateEvents = 7 + if err := s.SetBudgets(budgets); err != nil { + t.Fatalf("SetBudgets: %v", err) + } + run(t, s, "%state Spin") + wants(t, run(t, s, "%advance 1000"), + "(7 event(s) processed)", + "Stopped at the event budget (7 events; raise "+runtime.MaxStateEventsEnvVar, + "All 7 event(s) were processed at simulation time 0.0 without advancing it") + }) + + // A finite same-time chain longer than the budget gets the one-instant note, + // which does not claim the chain is endless. + t.Run("event_budget_finite_chain", func(t *testing.T) { + s := loadFixture(t, "testdata/state_chain.sysml") + budgets := runtime.DefaultBudgets() + budgets.MaxStateEvents = 2 + if err := s.SetBudgets(budgets); err != nil { + t.Fatalf("SetBudgets: %v", err) + } + run(t, s, "%state Chain") + wants(t, run(t, s, "%advance 1000"), + "Stopped at the event budget (2 events; raise "+runtime.MaxStateEventsEnvVar, + "All 2 event(s) were processed at simulation time 0.0 without advancing it") + }) + + // A chain that completes on the final budgeted event has settled: no + // one-instant note, and the completion line still shows. + t.Run("event_budget_completed_on_last_event", func(t *testing.T) { + s := loadFixture(t, "testdata/state_chain.sysml") + budgets := runtime.DefaultBudgets() + budgets.MaxStateEvents = 3 + if err := s.SetBudgets(budgets); err != nil { + t.Fatalf("SetBudgets: %v", err) + } + run(t, s, "%state Chain") + got := run(t, s, "%advance 1000") + wants(t, got, "State machine completed") + rejects(t, got, "without advancing it") + }) + + // A machine whose loop is timed makes progress each cycle, so the budget + // stop must not claim a larger budget cannot help. + t.Run("event_budget_time_advancing", func(t *testing.T) { + s := loadFixture(t, "testdata/state_tick.sysml") + budgets := runtime.DefaultBudgets() + budgets.MaxStateEvents = 7 + if err := s.SetBudgets(budgets); err != nil { + t.Fatalf("SetBudgets: %v", err) + } + run(t, s, "%state Tick") + got := run(t, s, "%advance 1000") + wants(t, got, + "Stopped at the event budget (7 events; raise "+runtime.MaxStateEventsEnvVar) + rejects(t, got, "without advancing it") + }) + + t.Run("do_budget", func(t *testing.T) { + s := loadFixture(t, "testdata/state_spin.sysml") + budgets := runtime.DefaultBudgets() + budgets.MaxDoSteps = 5 + if err := s.SetBudgets(budgets); err != nil { + t.Fatalf("SetBudgets: %v", err) + } + run(t, s, "%state Spin") + wants(t, run(t, s, "%advance 1000"), + "Stopped at the do action budget (5 steps; raise "+runtime.MaxDoStepsEnvVar) + }) +} diff --git a/internal/repl/calc_compile_test.go b/internal/frontend/repl/calc_compile_test.go similarity index 100% rename from internal/repl/calc_compile_test.go rename to internal/frontend/repl/calc_compile_test.go diff --git a/internal/repl/carryover.go b/internal/frontend/repl/carryover.go similarity index 95% rename from internal/repl/carryover.go rename to internal/frontend/repl/carryover.go index 1a117607cf..6b02da0ead 100644 --- a/internal/repl/carryover.go +++ b/internal/frontend/repl/carryover.go @@ -1,9 +1,10 @@ package repl import ( + "slices" "sort" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" ) // carried is one object the session materialized, together with the shapes it @@ -89,6 +90,7 @@ func (s *Session) carryOverObjects(over carryover) []string { dropped := held - len(kept) - len(keptUnnamed) s.instances = kept s.unnamed = keptUnnamed + s.given = slices.DeleteFunc(s.given, func(fqn string) bool { return kept[fqn] == nil }) var notices []string if note := behaviorsRestartedNotice(restarted); note != "" { notices = append(notices, note) diff --git a/internal/repl/carryover_eval_test.go b/internal/frontend/repl/carryover_eval_test.go similarity index 100% rename from internal/repl/carryover_eval_test.go rename to internal/frontend/repl/carryover_eval_test.go diff --git a/internal/frontend/repl/check.go b/internal/frontend/repl/check.go new file mode 100644 index 0000000000..61f91087b5 --- /dev/null +++ b/internal/frontend/repl/check.go @@ -0,0 +1,289 @@ +package repl + +import ( + "fmt" + "strings" + "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// SolveStatus is what a solver answered about an element's conditions. It is +// kept apart from VerdictStatus: satisfiability is not evaluation. +type SolveStatus int + +const ( + // SolveSat means an assignment satisfying the conditions exists. + SolveSat SolveStatus = iota + // SolveUnsat means no assignment satisfies them. + SolveUnsat + // SolveUnknown means the solver did not decide: it timed out, or gave up on + // arithmetic it cannot decide. + SolveUnknown + // SolveUnbounded means the conditions are satisfiable but an objective + // improves without limit, so it has no optimum. + SolveUnbounded + // SolveNoOptimum means the conditions are satisfiable and an objective's + // optimum was not established: only a bound, or an unverified answer. + SolveNoOptimum + // SolveUnavailable means nothing was asked: no solver is installed, the + // element is outside the translatable subset, or the solver failed. + SolveUnavailable +) + +// String names the status for a report a machine reads. +func (s SolveStatus) String() string { + switch s { + case SolveSat: + return "sat" + case SolveUnsat: + return "unsat" + case SolveUnknown: + return "unknown" + case SolveUnbounded: + return "unbounded" + case SolveNoOptimum: + return "no-optimum" + default: + return "unavailable" + } +} + +// SolveReport is what a solver answered about one element, with the lines the +// REPL prints for it. +type SolveReport struct { + // Subject is what was checked, spelled as the caller named it. + Subject string + Status SolveStatus + Lines []string + // Solver names the solver that answered, empty when none did. + Solver string + // Plan is how the engines answered; nil for a report made before any was asked. + Plan *analysis.Plan +} + +// Satisfiable reports whether the solver found the conditions satisfiable. +func (r SolveReport) Satisfiable() bool { return r.Status == SolveSat } + +// CheckSolve asks a solver whether the named constraint, requirement or +// satisfaction can be satisfied at all. Experimental: SysML v2 defines no solving. +func (s *Session) CheckSolve(name string) []SolveReport { + defer s.enter()() + return s.checkSolve(name) +} + +func (s *Session) checkSolve(name string) []SolveReport { + queries, bad := s.solveQueries(name) + if bad != nil { + return []SolveReport{*bad} + } + plan, err := s.solveWith(name, queries, (*solve.Solver).Solve) + return solveReports(name, queries, plan, err, solveQueryReport) +} + +// solveQueryReport renders the solver's answer about one query. +func solveQueryReport(name string, q *solve.Query, solved analysis.Evaluation) SolveReport { + result, err := solved.Solved, solved.Err + if err != nil { + return unavailableReport(name, err.Error()) + } + subject := solveSubject(q) + switch result.Status { + case solve.StatusSat: + lines := []string{fmt.Sprintf("✓ %s is satisfiable (%s)", subject, solveDetail(result))} + return SolveReport{Subject: name, Status: SolveSat, Solver: result.Solver, + Lines: append(lines, assignmentLines(result.Model)...)} + case solve.StatusUnsat: + if report, rounded := roundedUnsatReport(name, subject, result, q); rounded { + return report + } + return SolveReport{Subject: name, Status: SolveUnsat, Solver: result.Solver, Lines: []string{ + fmt.Sprintf("✗ %s is unsatisfiable (%s)", subject, solveDetail(result)), + }} + default: + lines := []string{fmt.Sprintf("? %s is undecided (%s)", subject, solveDetail(result))} + if reason := solveReason(result, q); reason != "" { + lines = append(lines, " "+reason) + } + return SolveReport{Subject: name, Status: SolveUnknown, Solver: result.Solver, Lines: lines} + } +} + +// roundedUnsat explains an exact-real unsat left undecided: the evaluator +// rounds these conditions in float64, which the exact encoding does not model. +const roundedUnsat = "Reason: no exact-real values satisfy it, but the evaluator rounds these conditions in floating point, which may still accept values" + +// roundedClaim explains a solver claim withheld outright: what it would state +// about exact reals does not decide the evaluator's floating-point arithmetic. +const roundedClaim = "Reason: the conditions round in floating point when evaluated, which the exact-real encoding does not decide" + +// roundedUnsatReport downgrades an unsat about conditions the evaluator rounds: +// exact-real unsatisfiability does not decide the evaluator's own arithmetic. +func roundedUnsatReport(name, subject string, result *solve.Result, q *solve.Query) (SolveReport, bool) { + if !q.Rounded() { + return SolveReport{}, false + } + return SolveReport{Subject: name, Status: SolveUnknown, Solver: result.Solver, Lines: []string{ + fmt.Sprintf("? %s is undecided (%s)", subject, solveDetail(result)), + " " + roundedUnsat, + }}, true +} + +// solveSubject names the element a report is about, as a verdict about it would. +func solveSubject(q *solve.Query) string { + subject := strings.ToUpper(q.Kind[:1]) + q.Kind[1:] + " " + q.Element + if q.Negated { + return subject + " (negated)" + } + return subject +} + +// solveDetail is the solver and the time it took, as the report's suffix. +func solveDetail(result *solve.Result) string { + return fmt.Sprintf("%s, %s", result.Solver, result.Elapsed.Round(time.Millisecond)) +} + +// solveReason explains an undecided answer: what the solver said, and that the +// query left the arithmetic it decides when it said nothing. +func solveReason(result *solve.Result, q *solve.Query) string { + switch { + case result.Reason != "": + return "Reason: " + result.Reason + case q.Nonlinear: + return "Reason: the query uses nonlinear arithmetic" + default: + return "" + } +} + +// assignmentLines renders a satisfying model, one indented line per variable. +func assignmentLines(model []solve.Assignment) []string { + out := make([]string, 0, len(model)) + for _, a := range model { + line := fmt.Sprintf(" %s = %s", notationName(a.Var.Name), a.Value) + if !a.Rendered { + line += " (as the solver wrote it)" + } + out = append(out, line) + } + return out +} + +// unavailableReport reports a check that was never made, phrased as the prompt +// reports any command it cannot carry out. +func unavailableReport(subject, msg string) SolveReport { + return SolveReport{Subject: subject, Status: SolveUnavailable, Lines: []string{"error: " + msg}} +} + +// solveQueries translates what the named element states, leaving every value +// free. Its second result is non-nil for a check that cannot be made at all. +func (s *Session) solveQueries(name string) ([]*solve.Query, *SolveReport) { + queries, _, bad := s.solveQueriesWith(name, nil) + return queries, bad +} + +// pinner supplies the values a query about subject fixes, given the resolved +// element; subject is the element the query is translated from, which for a +// satisfaction is the requirement rather than the element asserting it. +type pinner func(target checkTarget, subject *symbols.Symbol) ([]solve.Pin, []solve.Unfixed, *SolveReport) + +// solveQueriesWith translates what the named element states, fixing the values +// the pinner supplies. Its second result names the features whose value could not +// be read, which stay free. +func (s *Session) solveQueriesWith(name string, pins pinner) ([]*solve.Query, []solve.Unfixed, *SolveReport) { + target, bad := s.resolveCheckTarget(name) + if bad != nil { + report := unavailableReport(name, strings.TrimPrefix(bad.Lines[0], "error: ")) + return nil, nil, &report + } + fixed, unfixed, prob := s.fixedFor(target, target.sym, pins) + if prob != nil { + return nil, nil, prob + } + switch { + case runtime.RequireConstraint(target.sym) == nil: + q, err := solve.ConstraintWith(target.ctx, target.sym, target.scope, fixed) + return oneQuery(name, q, unfixed, err) + case runtime.RequireRequirement(target.sym) == nil: + q, err := solve.RequirementWith(target.ctx, target.sym, target.scope, fixed) + return oneQuery(name, q, unfixed, err) + } + if a, err := target.ctx.SatisfyAssertionOf(target.sym); err == nil { + fixed, unfixed, prob := s.fixedFor(target, a.Symbol, pins) + if prob != nil { + return nil, nil, prob + } + q, qerr := solve.SatisfactionWith(target.ctx, a, fixed) + return oneQuery(name, q, unfixed, qerr) + } + return s.satisfactionQueries(name, target, pins) +} + +// fixedFor asks the pinner for the values a query about subject fixes, and fixes +// none when there is no pinner. +func (s *Session) fixedFor( + target checkTarget, + subject *symbols.Symbol, + pins pinner, +) ([]solve.Pin, []solve.Unfixed, *SolveReport) { + if pins == nil { + return nil, nil, nil + } + return pins(target, subject) +} + +// satisfactionQueries translates every satisfaction assertion an element states, +// which is how an anonymous `assert satisfy` is reached. +func (s *Session) satisfactionQueries( + name string, + target checkTarget, + pins pinner, +) ([]*solve.Query, []solve.Unfixed, *SolveReport) { + if target.sym.Scope == nil { + report := unavailableReport(name, fmt.Sprintf("%s is not a constraint, requirement or satisfaction assertion", name)) + return nil, nil, &report + } + assertions := target.ctx.SatisfyAssertionsIn(target.sym.Scope) + if len(assertions) == 0 { + report := unavailableReport(name, fmt.Sprintf("no satisfaction assertion in %s", target.fqn)) + return nil, nil, &report + } + queries := make([]*solve.Query, 0, len(assertions)) + var unfixed []solve.Unfixed + for _, a := range assertions { + fixed, notRead, prob := s.fixedFor(target, a.Symbol, pins) + if prob != nil { + return nil, nil, prob + } + unfixed = append(unfixed, notRead...) + q, err := solve.SatisfactionWith(target.ctx, a, fixed) + if err != nil { + report := unavailableReport(name, err.Error()) + return nil, nil, &report + } + queries = append(queries, q) + } + return queries, unfixed, nil +} + +// oneQuery is a single translated query, or the report explaining why the +// element could not be translated. +func oneQuery(name string, q *solve.Query, unfixed []solve.Unfixed, err error) ([]*solve.Query, []solve.Unfixed, *SolveReport) { + if err != nil { + report := unavailableReport(name, err.Error()) + return nil, nil, &report + } + return []*solve.Query{q}, unfixed, nil +} + +// doCheck carries out %check. +func (s *Session) doCheck(name string) ([]string, bool, error) { + var out []string + for _, r := range s.checkSolve(name) { + out = append(out, r.Lines...) + } + return out, false, nil +} diff --git a/internal/frontend/repl/check_test.go b/internal/frontend/repl/check_test.go new file mode 100644 index 0000000000..a66ba932ff --- /dev/null +++ b/internal/frontend/repl/check_test.go @@ -0,0 +1,238 @@ +package repl + +import ( + "os" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" +) + +// solverRequiredEnv makes an absent solver a failure rather than a skip, so CI +// runs these checks instead of quietly passing without a solver. +const solverRequiredEnv = "OPENSYSML_REQUIRE_SMT" + +// requireSolver skips a solver-dependent check when no solver is installed, +// unless OPENSYSML_REQUIRE_SMT says one must be. +func requireSolver(t *testing.T) { + t.Helper() + if _, err := solve.Discover(); err != nil { + if os.Getenv(solverRequiredEnv) != "" { + t.Fatalf("%s is set but no solver was found: %v", solverRequiredEnv, err) + } + t.Skipf("no SMT solver installed: %v", err) + } +} + +// checkSession is a session holding one model, ready for %check. +func checkSession(t *testing.T, src string) *Session { + t.Helper() + s := NewSession() + if res := s.Submit(src); len(res.Diagnostics) > 0 { + t.Fatalf("fixture has diagnostics: %v", res.Diagnostics) + } + return s +} + +const checkModel = ` +package Check { + private import ScalarValues::Integer; + + constraint def Satisfiable { + in i : Integer; + assert constraint { i > 3 and i < 8 } + } + + constraint def Contradictory { + in i : Integer; + assert constraint { i > 8 and i < 3 } + } + + requirement def Ranged { + subject i : Integer; + require constraint { i >= 0 } + } + + constraint def Exponential { + in i : Integer; + assert constraint { i ** 2 == 4 } + } +} +` + +func TestCheckReportsSatisfiableWithAnAssignment(t *testing.T) { + requireSolver(t) + s := checkSession(t, checkModel) + got := run(t, s, "%check Satisfiable") + wants(t, got, "✓ Constraint Satisfiable is satisfiable", "Check::Satisfiable::i = ") + rejects(t, got, "error:") + + reports := s.CheckSolve("Satisfiable") + if len(reports) != 1 || reports[0].Status != SolveSat { + t.Fatalf("CheckSolve answered %v, want one sat report", reports) + } + if !reports[0].Satisfiable() || reports[0].Solver == "" { + t.Errorf("report %+v does not name the solver that answered", reports[0]) + } +} + +func TestCheckReportsUnsatisfiable(t *testing.T) { + requireSolver(t) + s := checkSession(t, checkModel) + got := run(t, s, "%check Contradictory") + wants(t, got, "✗ Constraint Contradictory is unsatisfiable") + rejects(t, got, "error:", "=") + + if reports := s.CheckSolve("Contradictory"); reports[0].Status != SolveUnsat { + t.Errorf("status is %s, want unsat", reports[0].Status) + } +} + +// An exact-real unsat about conditions the evaluator rounds is reported +// undecided: the evaluator's float64 arithmetic may still accept values. +func TestCheckLeavesRoundedUnsatUndecided(t *testing.T) { + requireSolver(t) + s := checkSession(t, ` + package Check { + private import ScalarValues::Integer; + constraint def HalfUlp { + in a : Integer; + assert constraint { a == 9007199254740993 } + assert constraint { a / 2 == 4503599627370496.0 } + } + }`) + got := run(t, s, "%check HalfUlp") + wants(t, got, "? Constraint HalfUlp is undecided", "rounds these conditions in floating point") + rejects(t, got, "unsatisfiable") + + if reports := s.CheckSolve("HalfUlp"); reports[0].Status != SolveUnknown { + t.Errorf("status is %s, want unknown", reports[0].Status) + } +} + +func TestCheckSolvesARequirement(t *testing.T) { + requireSolver(t) + s := checkSession(t, checkModel) + got := run(t, s, "%check Ranged") + wants(t, got, "✓ Requirement Ranged is satisfiable") +} + +// A verdict about satisfiability is not a verdict about evaluation: %check +// answers `sat` where %constraint cannot evaluate the unbound parameter at all. +func TestCheckIsDistinctFromConstraintEvaluation(t *testing.T) { + requireSolver(t) + s := checkSession(t, checkModel) + if v := s.CheckConstraint("Satisfiable"); v.Status == VerdictHolds { + t.Fatalf("expected %%constraint not to hold on an unbound parameter, got %v", v.Lines) + } + if reports := s.CheckSolve("Satisfiable"); reports[0].Status != SolveSat { + t.Errorf("status is %s, want sat", reports[0].Status) + } +} + +func TestCheckReportsAnUntranslatableElement(t *testing.T) { + s := checkSession(t, checkModel) + got := run(t, s, "%check Exponential") + wants(t, got, "error:", "not translatable for solving") + + if reports := s.CheckSolve("Exponential"); reports[0].Status != SolveUnavailable { + t.Errorf("status is %s, want unavailable", reports[0].Status) + } +} + +func TestCheckRejectsAnElementStatingNothingToSolve(t *testing.T) { + s := checkSession(t, "package Check { part def P; }") + got := run(t, s, "%check P") + wants(t, got, "error:") +} + +func TestCheckReportsAnUnknownName(t *testing.T) { + s := checkSession(t, checkModel) + wants(t, run(t, s, "%check Nope"), "error:") +} + +func TestCheckWithoutAName(t *testing.T) { + s := checkSession(t, checkModel) + wants(t, run(t, s, "%check"), "usage: %check ") +} + +// An absent solver is a typed error naming what to install, never a fabricated +// verdict, which the prompt reports as it reports any command it cannot carry out. +func TestCheckReportsAnAbsentSolver(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + t.Setenv(solve.SolverEnv, "") + s := checkSession(t, checkModel) + got := run(t, s, "%check Satisfiable") + wants(t, got, "error:", "z3") + rejects(t, got, "satisfiable", "unsatisfiable") + + if reports := s.CheckSolve("Satisfiable"); reports[0].Status != SolveUnavailable { + t.Errorf("status is %s, want unavailable", reports[0].Status) + } +} + +// A solver that answers nothing usable is a process failure, distinguished from +// `unknown`: no verdict was reached at all. +func TestCheckReportsASolverProcessFailure(t *testing.T) { + dir := t.TempDir() + script := dir + "/mute" + if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 3\n"), 0o700); err != nil { // #nosec G306 -- a test's own executable + t.Fatalf("write fake solver: %v", err) + } + t.Setenv(solve.SolverEnv, script) + s := checkSession(t, checkModel) + got := run(t, s, "%check Satisfiable") + wants(t, got, "error:", "mute") + rejects(t, got, "unknown", "is satisfiable") +} + +// An undecided answer stays undecided: it is reported as neither satisfiable +// nor unsatisfiable, with the reason the solver gave. +func TestCheckReportsAnUndecidedAnswer(t *testing.T) { + script := t.TempDir() + "/undecided" + // Both replies are written up front, then the script drains the query with + // shell builtins alone so it outlives every write the driver makes. + fake := "#!/bin/sh\nprintf 'unknown\\n(:reason-unknown \"incomplete\")\\n'\nwhile read -r line; do :; done\n" + if err := os.WriteFile(script, []byte(fake), 0o700); err != nil { // #nosec G306 -- a test's own executable + t.Fatalf("write fake solver: %v", err) + } + t.Setenv(solve.SolverEnv, script) + s := checkSession(t, checkModel) + got := run(t, s, "%check Satisfiable") + wants(t, got, "? Constraint Satisfiable is undecided", "Reason: incomplete") + rejects(t, got, "is satisfiable", "is unsatisfiable", "error:") + + if reports := s.CheckSolve("Satisfiable"); reports[0].Status != SolveUnknown { + t.Errorf("status is %s, want unknown: %s", reports[0].Status, strings.Join(reports[0].Lines, "\n")) + } +} + +func TestCheckIsListedInHelpAndCompletion(t *testing.T) { + help := strings.Join(helpText(), "\n") + wants(t, help, "%check ") + found := false + for _, name := range metaCommands() { + if name == "%check" { + found = true + } + } + if !found { + t.Error("the check command is not dispatched") + } +} + +// A %check leaves an action debugging session running: it declares nothing. +func TestCheckKeepsADebuggingSession(t *testing.T) { + requireSolver(t) + s := checkSession(t, checkModel+` +package Debug { + action def Walk { + first start; + then action step1; + then done; + } +}`) + wants(t, run(t, s, "%action Debug::Walk"), "Walk") + run(t, s, "%check Satisfiable") + rejects(t, run(t, s, "%tokens"), "no active") +} diff --git a/internal/repl/checker.go b/internal/frontend/repl/checker.go similarity index 96% rename from internal/repl/checker.go rename to internal/frontend/repl/checker.go index ec4e306dd5..adf166c7fc 100644 --- a/internal/repl/checker.go +++ b/internal/frontend/repl/checker.go @@ -8,9 +8,9 @@ import ( "strings" "time" - "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // checkSettings is what the check engines are asked beside the behaviors: the features @@ -470,11 +470,15 @@ func (s *Session) checkVerdict(inv *freshInvocation, policy runtime.SchedulePoli subject, label := inv.subject(), inv.label() ctx := s.planContext() free := s.checker.frees() + modelSeed, draws, step := s.askedModelSeed(), s.draws, s.clockStep s.state.Unlock() answered, err := s.engines.Check(ctx, analysis.Request{ Model: model, Subject: subject, Schedule: policy, + ModelSeed: modelSeed, + Draws: draws, + ClockStep: step, Budget: budget, Selection: selection, }, kind, free, asks.check, asks.holds, asks.run) @@ -612,7 +616,11 @@ func checkedVerdict(subject, label string, result analysis.Result) Verdict { v.Lines = append(v.Lines, fmt.Sprintf("? %s: %s", label, report.Status())) } for i, violation := range report.Violations { - v.Lines = append(v.Lines, " violation: "+violation.String()+witnessPath(checked.Violations, i)) + mass := runtime.FormatWeight(violation.Mass) + if report.MassBounded { + mass = "≥ " + mass + } + v.Lines = append(v.Lines, " violation: "+violation.String()+" (probability "+mass+")"+witnessPath(checked.Violations, i)) } for i, d := range report.Divergent { v.Lines = append(v.Lines, " divergent: "+d.String()) @@ -680,18 +688,21 @@ func inputLines(inputs []analysis.Input) string { return strings.Join(parts, ", ") } -// witnessLine spells a witness: the file it was written to, else its choices as one -// schedule, "no choices" for an empty one. +// witnessLine spells a witness: the file it was written to, else its draws and +// choices as one schedule, "no choices" for an empty one. func witnessLine(w *analysis.Witness) string { if w.Written != "" { return w.Written } - if len(w.Choices) == 0 { + if len(w.Draws) == 0 && len(w.Choices) == 0 { return "no choices" } - parts := make([]string, len(w.Choices)) - for i, c := range w.Choices { - parts[i] = c.String() + parts := make([]string, 0, len(w.Draws)+len(w.Choices)) + for _, d := range w.Draws { + parts = append(parts, d.String()) + } + for _, c := range w.Choices { + parts = append(parts, c.String()) } return strings.Join(parts, "; ") } diff --git a/internal/repl/checker_state_test.go b/internal/frontend/repl/checker_state_test.go similarity index 100% rename from internal/repl/checker_state_test.go rename to internal/frontend/repl/checker_state_test.go diff --git a/internal/repl/checker_test.go b/internal/frontend/repl/checker_test.go similarity index 91% rename from internal/repl/checker_test.go rename to internal/frontend/repl/checker_test.go index b699484449..cdc8e4a09f 100644 --- a/internal/repl/checker_test.go +++ b/internal/frontend/repl/checker_test.go @@ -6,6 +6,9 @@ import ( "strings" "testing" "time" + + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" ) // checkTankSource is a fork race on a performer's attribute, with a property @@ -233,7 +236,7 @@ func TestReplayStepsTheRunAWitnessRecords(t *testing.T) { // A witness explore writes for a step the clock retries replays under %replay: the // step waits on the clock with the move kept, and %continue takes it at the retry. func TestReplayStepsAnOrderDrawnAfterTheClockRetriesAStep(t *testing.T) { - model, err := os.ReadFile(filepath.Join("..", "core", "runtime", "testdata", "conformance", + model, err := os.ReadFile(filepath.Join("..", "..", "exec", "runtime", "testdata", "conformance", "action_explore_performed_and_accept_due_together.sysml")) if err != nil { t.Fatal(err) @@ -285,7 +288,7 @@ func TestReplayStepsAWitnessOfNoChoice(t *testing.T) { run(t, s, "%instantiate Plant::tank") witness := filepath.Join(dir, "Plant.Tank.overfill@Plant.tank.violation-1.witness") wantVerdict(t, s.RunAction("Plant::Tank::overfill", "Plant::tank"), VerdictFails, - "violation: Plant::Tank::low is false after 2 moves (witness "+witness+")") + "violation: Plant::Tank::low is false after 2 moves (probability 1) (witness "+witness+")") content, err := os.ReadFile(witness) if err != nil { t.Fatal(err) @@ -348,3 +351,31 @@ func TestReplayRefusesMovesLeftWhenTheRunCompletes(t *testing.T) { wants(t, out, refused) rejects(t, out, "Action completed") } + +// A witness spelled inline names its draws before its choices; one of draws alone +// is not `no choices`. +func TestWitnessLineSpellsTheDraws(t *testing.T) { + draw, err := runtime.ParseDraw("draw uniformInteger(0, 1) = 1") + if err != nil { + t.Fatal(err) + } + choice, err := runtime.ParseChoice("step 3: 2@left first of 2@left, 3@right") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + w analysis.Witness + want string + }{ + {"none", analysis.Witness{}, "no choices"}, + {"draw", analysis.Witness{Draws: []runtime.DrawTaken{draw}}, "draw uniformInteger(0, 1) = 1"}, + {"both", analysis.Witness{Draws: []runtime.DrawTaken{draw}, Choices: []runtime.ChoiceTaken{choice}}, + "draw uniformInteger(0, 1) = 1; step 3: 2@left first of 2@left, 3@right"}, + {"written", analysis.Witness{Draws: []runtime.DrawTaken{draw}, Written: "/w/one.witness"}, "/w/one.witness"}, + } { + if got := witnessLine(&tc.w); got != tc.want { + t.Errorf("%s: witnessLine = %q, want %q", tc.name, got, tc.want) + } + } +} diff --git a/internal/frontend/repl/choice_test.go b/internal/frontend/repl/choice_test.go new file mode 100644 index 0000000000..4a130909bf --- /dev/null +++ b/internal/frontend/repl/choice_test.go @@ -0,0 +1,251 @@ +package repl + +import "testing" + +const choiceForkSource = ` +package Debug { + private import ScalarValues::*; + action tally { + attribute leftCount : Integer = 0; + attribute rightCount : Integer = 0; + + first start; + fork split; + action left { assign leftCount := leftCount + 1; } + action right { assign rightCount := rightCount + 10; } + join sync; + done; + + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then done; + } +} +` + +// A debugger step that picked among unordered tokens says so in one line, and +// points at %trace when the choices themselves are not being shown. +func TestStepReportsChoicePoints(t *testing.T) { + s := loadSource(t, choiceForkSource) + run(t, s, "%action tally") + + // start -> split: one token, nothing to choose. + rejects(t, run(t, s, "%step"), "choice point") + // split forks: still one token acted. + rejects(t, run(t, s, "%step"), "choice point") + // left and right both act: the executor ordered them. + wants(t, run(t, s, "%step"), "✓ Step complete", " 1 choice point; %trace on to see them") +} + +// With tracing on the choice is in the trace, so the summary drops the hint. +func TestStepChoiceSummaryWithTraceOn(t *testing.T) { + s := loadSource(t, choiceForkSource) + run(t, s, "%trace on") + run(t, s, "%action tally") + run(t, s, "%step") + run(t, s, "%step") + out := run(t, s, "%step") + wants(t, out, "choice step 3: tokens 2@left, 3@right (unordered; took 3@right first)", " 1 choice point") + rejects(t, out, "%trace on to see them") +} + +// %continue counts the choices of the run it completed, and the plural form +// when there were several. +func TestContinueReportsChoicePoints(t *testing.T) { + s := loadSource(t, choiceForkSource) + run(t, s, "%action tally") + wants(t, run(t, s, "%continue"), "✓ Action completed", " 1 choice point; %trace on to see them") + + s = loadSource(t, ` +package Debug { + private import ScalarValues::*; + action race { + attribute x : Integer = 0; + first start; + fork split; + action left { assign x := 1; } + action right { assign x := 2; } + join sync; + done; + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then done; + } +} +`) + run(t, s, "%action race") + wants(t, run(t, s, "%continue"), "✓ Action completed", " 2 choice points; %trace on to see them") +} + +// A run that fails still reports the choices it made before failing, so the +// error can be told from a scheduling artefact. +func TestContinueReportsChoicePointsBeforeFailure(t *testing.T) { + s := loadSource(t, ` +package Debug { + private import ScalarValues::*; + action crash { + attribute x : Rational = 0; + attribute zero : Integer = 0; + first start; + fork split; + action left { assign x := 1; } + action right { assign x := 2; } + join sync; + action divide { assign x := x / zero; } + done; + succession first start then split; + succession first split then left; + succession first split then right; + succession first left then sync; + succession first right then sync; + succession first sync then divide; + succession first divide then done; + } +} +`) + run(t, s, "%action crash") + wants(t, run(t, s, "%continue"), "error: execution failed:", " 2 choice points; %trace on to see them") +} + +// %advance on a state machine reports a transition conflict it dispatched +// through; an event with one enabled transition reports none. +func TestAdvanceReportsChoicePoints(t *testing.T) { + s := loadSource(t, ` +package Debug { + private import ScalarValues::*; + attribute def Go; + attribute def Stop; + state def Dispatcher { + attribute level : Integer = 8; + entry; then idle; + state idle; + state low; + state high; + state halted; + transition first idle accept Go if level > 5 then low; + transition first idle accept Go if level > 7 then high; + transition first low accept Stop then halted; + } + part def Router { + exhibit state dispatch : Dispatcher; + } + part router : Router; +} +`) + wants(t, run(t, s, "%instantiate router"), "✓ Created instance") + wants(t, run(t, s, "%state router"), "Current state: idle") + run(t, s, "%send Go") + wants(t, run(t, s, "%advance 1"), "Current state: low", " 1 choice point; %trace on to see them") + run(t, s, "%send Stop") + out := run(t, s, "%advance 1") + wants(t, out, "Current state: halted") + rejects(t, out, "choice point") +} + +// One event enabling a transition in each of two regions draws the firings' order under the +// default policy, one choice: counted in the summary line, shown in the trace when it is on. +func TestAdvanceReportsRegionOrderChoice(t *testing.T) { + src := ` +package Debug { + private import ScalarValues::*; + attribute def Go; + state def Machine { + attribute last : Integer = 0; + entry; then work; + state work parallel { + state a { entry; then a1; state a1; state a2; transition first a1 accept Go do assign last := 1 then a2; } + state b { entry; then b1; state b1; state b2; transition first b1 accept Go do assign last := 2 then b2; } + } + } + part def Rig { + exhibit state run : Machine; + } + part rig : Rig; +} +` + s := loadSource(t, src) + wants(t, run(t, s, "%instantiate rig"), "✓ Created instance") + wants(t, run(t, s, "%state rig"), "Current state: a1 | b1") + run(t, s, "%send Go") + out := run(t, s, "%advance 1") + wants(t, out, "Current state: a2 | b2", " 1 choice point; %trace on to see them") + rejects(t, out, "region order") + + s = loadSource(t, src) + run(t, s, "%trace on") + run(t, s, "%instantiate rig") + run(t, s, "%state rig") + run(t, s, "%send Go") + out = run(t, s, "%advance 1") + wants(t, out, + "choice on accept Go: next a1(exit), b1(exit) (unordered; took a1(exit) first)", + " 1 choice point") + rejects(t, out, "%trace on to see them") +} + +// A guard read only to report a choice that could not be evaluated is counted in +// the summary line beside the choices, and shown in the trace when it is on. +func TestStepReportsUnevaluableGuards(t *testing.T) { + src := ` +package Debug { + private import ScalarValues::*; + action route { + attribute level : Integer = 75; + attribute handler : Integer = 0; + first start; + then decide select; + if level > 50 then warn; + if 1 / (level - 75) > 0 then alarm; + action warn { assign handler := 1; } + then done; + action alarm { assign handler := 2; } + then done; + } + action mixed { + attribute level : Integer = 75; + attribute handler : Integer = 0; + first start; + then decide select; + if level > 50 then warn; + if level > 70 then alarm; + if 1 / (level - 75) > 0 then halt; + action warn { assign handler := 1; } + then done; + action alarm { assign handler := 2; } + then done; + action halt { assign handler := 3; } + then done; + } +} +` + s := loadSource(t, src) + run(t, s, "%action route") + rejects(t, run(t, s, "%step"), "guard not evaluable") + out := run(t, s, "%step") + wants(t, out, "✓ Step complete", " 1 guard not evaluable; %trace on to see them") + rejects(t, out, "choice point") + + s = loadSource(t, src) + run(t, s, "%trace on") + run(t, s, "%action route") + run(t, s, "%step") + out = run(t, s, "%step") + wants(t, out, + "unevaluable guard step 2: decision select branch 2->alarm: division by zero (not selected)", + " 1 guard not evaluable") + rejects(t, out, "%trace on to see them") + + s = loadSource(t, src) + run(t, s, "%action route") + wants(t, run(t, s, "%continue"), "✓ Action completed", " 1 guard not evaluable; %trace on to see them") + + run(t, s, "%action mixed") + wants(t, run(t, s, "%continue"), "✓ Action completed", + " 1 choice point; 1 guard not evaluable; %trace on to see them") +} diff --git a/internal/frontend/repl/classifier_behavior_test.go b/internal/frontend/repl/classifier_behavior_test.go new file mode 100644 index 0000000000..f77557fe27 --- /dev/null +++ b/internal/frontend/repl/classifier_behavior_test.go @@ -0,0 +1,246 @@ +package repl + +import ( + "strings" + "testing" +) + +// Materializing a part starts the machine its type exhibits, so %state binds the +// debugger to that object's machine rather than a detached run of the usage. +func TestStateDebugsTheMachineAnObjectExhibits(t *testing.T) { + s := loadFixture(t, "testdata/exhibited_machine.sysml") + run(t, s, "%instantiate Obj::Monitor") + + got := run(t, s, "%state Obj::Monitor") + wants(t, got, "exhibited by object #", "modes", "Current state: idle") + + // The entry action of the state the machine settled in wrote the object's + // own feature value. + wants(t, run(t, s, "%features Obj::Monitor"), "count = 1") +} + +// %current, %events, %step and %advance drive the object's machine, which the +// machine's own timer left waiting when the object was materialized. +func TestObjectMachineDebuggingCommands(t *testing.T) { + s := loadFixture(t, "testdata/exhibited_machine.sysml") + run(t, s, "%instantiate Obj::Monitor") + run(t, s, "%state Obj::Monitor") + + wants(t, run(t, s, "%current"), "idle") + wants(t, run(t, s, "%events"), "1 events") + wants(t, run(t, s, "%step"), "Event dispatched", "Current state: awake") + wants(t, run(t, s, "%advance 5"), "time is now 15.0") + wants(t, run(t, s, "%current"), "awake") + wants(t, run(t, s, "%features Obj::Monitor"), "count = 11") +} + +// A second %instantiate is a second object with its own machine, and says which +// object the name now denotes. +func TestSecondInstantiateIsAnotherObject(t *testing.T) { + s := loadFixture(t, "testdata/exhibited_machine.sysml") + first := objectIDIn(t, run(t, s, "%instantiate Obj::Monitor")) + + again := run(t, s, "%instantiate Obj::Monitor") + wants(t, again, "now denotes this object", "object #"+first+" is displaced from that name", "behavior of its own", "stays reachable as #"+first) + if objectIDIn(t, run(t, s, "%features Obj::Monitor")) == first { + t.Errorf("second %%instantiate reused object #%s:\n%s", first, again) + } +} + +// A machine parked on a change condition is stepped again once something else +// makes the condition true: %step polls the watched conditions rather than +// reporting the machine as suspended forever. +func TestStepDispatchesAConditionMadeTrueElsewhere(t *testing.T) { + s := loadFixture(t, "testdata/change_condition_object.sysml") + run(t, s, "%instantiate Watch::Sensor") + run(t, s, "%state Watch::Sensor") + + wants(t, run(t, s, "%current"), "idle") + wants(t, run(t, s, "%step"), "waiting on change condition") + + run(t, s, "%invoke Watch::Sensor trip") + wants(t, run(t, s, "%step"), "Change event dispatched", "Current state: alerted") +} + +// An operation of the object's type runs with the object as its performer, so +// what it writes is that object's feature value. +func TestInvokeRunsAnOperationOnTheObject(t *testing.T) { + s := loadFixture(t, "testdata/exhibited_machine.sysml") + run(t, s, "%instantiate Obj::Monitor") + + wants(t, run(t, s, "%invoke Obj::Monitor bumpBy n=4"), "Invoked bumpBy on object #") + wants(t, run(t, s, "%features Obj::Monitor"), "count = 5") +} + +// A positional argument list binds the operation's input parameters in declaration +// order, an expression with spaces included. +func TestInvokeBindsPositionalArguments(t *testing.T) { + s := loadFixture(t, "testdata/exhibited_machine.sysml") + run(t, s, "%instantiate Obj::Monitor") + + wants(t, run(t, s, "%invoke Obj::Monitor bumpBy 4"), "Invoked bumpBy on object #") + wants(t, run(t, s, "%features Obj::Monitor"), "count = 5") + wants(t, run(t, s, "%invoke Obj::Monitor bumpBy 2 + 3"), "Invoked bumpBy on object #") + wants(t, run(t, s, "%features Obj::Monitor"), "count = 10") +} + +// A string literal argument reaches the operation as written, its quotes and the +// spaces inside it included, positionally and by name. +func TestInvokeKeepsStringLiteralArguments(t *testing.T) { + s := loadFixture(t, "testdata/exhibited_machine.sysml") + run(t, s, "%instantiate Obj::Monitor") + + wants(t, run(t, s, `%invoke Obj::Monitor setLabel "ready now"`), "Invoked setLabel on object #") + wants(t, run(t, s, "%features Obj::Monitor"), `label = "ready now"`) + wants(t, run(t, s, `%invoke Obj::Monitor setLabel text="by name"`), "Invoked setLabel on object #") + wants(t, run(t, s, "%features Obj::Monitor"), `label = "by name"`) + wants(t, run(t, s, `%invoke Obj::Monitor setLabel ""`), "Invoked setLabel on object #") + wants(t, run(t, s, "%features Obj::Monitor"), `label = ""`) + wants(t, run(t, s, `%invoke Obj::Monitor setLabel "a" "b"`), "error:", "takes 1 input parameter(s), got 2 argument(s)") +} + +// %invoke reports its usage, an operation the type does not own, an argument +// naming no parameter, a parameter left unbound, a list mixing the positional and +// the named form, a surplus positional argument and one bound twice. +func TestInvokeReportsItsFailureModes(t *testing.T) { + s := loadFixture(t, "testdata/exhibited_machine.sysml") + run(t, s, "%instantiate Obj::Monitor") + + wants(t, run(t, s, "%invoke Obj::Monitor"), "usage: %invoke") + wants(t, run(t, s, "%invoke Obj::Monitor missing"), "error:", "missing") + wants(t, run(t, s, "%invoke Obj::Monitor bumpBy"), "error:", "unbound parameter") + wants(t, run(t, s, "%invoke Obj::Monitor bumpBy other=1"), "error:", "unbound parameter") + wants(t, run(t, s, "%invoke Obj::Monitor bumpBy n"), "error:", "unresolved reference") + wants(t, run(t, s, "%invoke Obj::Monitor bumpBy 1 n=2"), "error:", "positional and named arguments mixed") + wants(t, run(t, s, "%invoke Obj::Monitor bumpBy 1 2"), "error:", "takes 1 input parameter(s), got 2 argument(s)") + wants(t, run(t, s, "%invoke Obj::Monitor bumpBy n=1 n=2"), "error:", "parameter n is given more than one argument") + wants(t, run(t, s, "%features Obj::Monitor"), "count = 1") +} + +// An unrelated declaration submitted while an object's machine is being debugged +// keeps the object and its identity, and restarts its machine from the initial +// state with a reported reason: an execution belongs to the analysis it started +// in, so it is never resumed on the values the discarded run left behind. +func TestObjectMachineRestartsOverAnUnrelatedDeclaration(t *testing.T) { + s := loadFixture(t, "testdata/exhibited_machine.sysml") + run(t, s, "%instantiate Obj::Monitor") + started := run(t, s, "%state Obj::Monitor") + id := objectIDIn(t, started) + // Drive the machine on, so a resumed execution would be visible as `awake` + // and `count = 11` rather than the initial state below. + run(t, s, "%step") + wants(t, run(t, s, "%features Obj::Monitor"), "count = 11") + + res := s.Submit("package Other { part def Unrelated; }") + if len(res.Diagnostics) > 0 { + t.Fatalf("unrelated declaration has diagnostics: %v", res.Diagnostics) + } + wants(t, strings.Join(res.Notices, "\n"), "restarted from its initial state") + + wants(t, run(t, s, "%current"), "idle") + wants(t, run(t, s, "%features Obj::Monitor"), "count = 1", "ID: "+id) +} + +// A restarted machine runs in the context the submission built, so the debugger +// drives its queue there rather than the discarded one. +func TestRestartedMachineRunsInTheNewContext(t *testing.T) { + s := loadFixture(t, "testdata/exhibited_machine.sysml") + run(t, s, "%instantiate Obj::Monitor") + run(t, s, "%state Obj::Monitor") + + if res := s.Submit("package Other { part def Unrelated; }"); len(res.Diagnostics) > 0 { + t.Fatalf("unrelated declaration has diagnostics: %v", res.Diagnostics) + } + // The restarted machine's own timer is queued in the new context, so the + // debugger drives it there instead of reporting nothing to do. + wants(t, run(t, s, "%events"), "1 events") + wants(t, run(t, s, "%step"), "Event dispatched", "Current state: awake") + wants(t, run(t, s, "%features Obj::Monitor"), "count = 11") +} + +// Re-declaring the machine an object exhibits drops that object and says so: a +// rewritten body never resumes on values the old one wrote. +func TestRewritingTheExhibitedMachineDropsTheObject(t *testing.T) { + s := loadFixture(t, "testdata/exhibited_machine.sysml") + run(t, s, "%instantiate Obj::Monitor") + wants(t, run(t, s, "%features Obj::Monitor"), "count = 1") + + rewritten := ` + package Obj { + part def Monitor { + attribute count = 0; + exhibit state modes { + entry; then idle; + state idle { entry action bump { assign count := count + 5; } } + } + } + } + ` + res := s.Submit(rewritten) + if len(res.Diagnostics) > 0 { + t.Fatalf("rewritten declaration has diagnostics: %v", res.Diagnostics) + } + wants(t, strings.Join(res.Notices, "\n"), "dropped") + wants(t, run(t, s, "%features Obj::Monitor"), "no instance") + + run(t, s, "%instantiate Obj::Monitor") + wants(t, run(t, s, "%features Obj::Monitor"), "count = 5") +} + +// A machine is not something an object exhibits, so materializing the machine +// itself leaves it debuggable as a machine another object performs. +func TestStateDebugsAMachineMaterializedByName(t *testing.T) { + s := loadFixture(t, "../../exec/runtime/testdata/conformance/variant_connection_per_owner.sysml") + run(t, s, "%instantiate VariantRouting::alpha") + run(t, s, "%instantiate VariantRouting::Router::Route") + + wants(t, run(t, s, "%state VariantRouting::Router::Route VariantRouting::alpha"), "Started state machine executor") + wants(t, run(t, s, "%advance 1"), "Current state: arrived") +} + +// A session over a machine an object merely performs stays on that machine: only +// a session over the object's own exhibited machine follows a restart. +func TestStateOverAPerformedMachineStaysOnIt(t *testing.T) { + s := loadFixture(t, "testdata/performed_machine.sysml") + run(t, s, "%instantiate Two::g") + wants(t, run(t, s, "%state Two::Check Two::g"), "Started state machine executor") + + if res := s.Submit("package Other { part def Unrelated; }"); len(res.Diagnostics) > 0 { + t.Fatalf("unrelated declaration has diagnostics: %v", res.Diagnostics) + } + + wants(t, run(t, s, "%current"), "checking") + wants(t, run(t, s, "%advance 5"), "Current state: checked") +} + +// A part exhibiting no machine is reported as such rather than debugged. +func TestStateReportsAnObjectExhibitingNoMachine(t *testing.T) { + s := loadFixture(t, "../../exec/runtime/testdata/conformance/variant_connection_per_owner.sysml") + run(t, s, "%instantiate VariantRouting::alpha") + + wants(t, run(t, s, "%state VariantRouting::alpha"), "exhibits no state machine") +} + +// objectIDIn reports the object identity a command's output names, written either +// as `#` or as `ID: `. +func objectIDIn(t *testing.T, out string) string { + t.Helper() + at, width := strings.Index(out, "#"), 1 + if named := strings.Index(out, "ID: "); named >= 0 && (at < 0 || named < at) { + at, width = named, len("ID: ") + } + if at < 0 { + t.Fatalf("output names no object identity:\n%s", out) + } + id := "" + for _, r := range out[at+width:] { + if r < '0' || r > '9' { + break + } + id += string(r) + } + if id == "" { + t.Fatalf("output names no object identity:\n%s", out) + } + return id +} diff --git a/internal/frontend/repl/clock_step_test.go b/internal/frontend/repl/clock_step_test.go new file mode 100644 index 0000000000..095d14b80f --- /dev/null +++ b/internal/frontend/repl/clock_step_test.go @@ -0,0 +1,65 @@ +package repl + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// %clock-step shows and sets the step the runs' clock ticks by: under a step the +// waits of a %runs table come due on the ticks, 0 restores the continuous clock, +// and a step that is no finite, non-negative number is refused and leaves it. +func TestClockStepTicksTheRunsClock(t *testing.T) { + s := runsSession(t) + wants(t, run(t, s, "%clock-step"), "clock step: none (a continuous clock)") + wants(t, sweepTable(run(t, s, "%runs 2 7 MC::acquire clock")), "24.59324592607634 [s]", "4.848060164697906 [s]") + wants(t, run(t, s, "%clock-step 1"), "clock step: 1.0 s") + if s.ClockStep() != 1 { + t.Errorf("ClockStep() = %v, want 1", s.ClockStep()) + } + wants(t, sweepTable(run(t, s, "%runs 2 7 MC::acquire clock")), "runs MC::acquire — 2 run(s), seed 7", "| 25.0 [s]", "| 5.0 [s]") + wants(t, run(t, s, "%clock-step 0.5"), "clock step: 0.5 s") + wants(t, sweepTable(run(t, s, "%runs 2 7 MC::acquire clock")), "| 25.0 [s]", "| 5.0 [s]") + wants(t, run(t, s, "%clock-step -1"), "invalid clock step: a clock steps by a finite, non-negative number of seconds, not -1.0") + wants(t, run(t, s, "%clock-step soon"), `invalid clock step: "soon" is not a number of seconds`) + wants(t, run(t, s, "%clock-step 1 2"), "usage: %clock-step []") + if s.ClockStep() != 0.5 { + t.Errorf("ClockStep() = %v after a refused %%clock-step, want 0.5 still", s.ClockStep()) + } + wants(t, run(t, s, "%clock-step 0"), "clock step: none (a continuous clock)") + wants(t, sweepTable(run(t, s, "%runs 2 7 MC::acquire clock")), "24.59324592607634 [s]") + if err := s.SetClockStep(-2); !errors.Is(err, runtime.ErrClockStep) { + t.Errorf("SetClockStep(-2) = %v, want ErrClockStep", err) + } +} + +// The clock step reaches the debugger's run too: under a step of 10 s a wait of +// 2 s parks its token until t=10, and once the step is cleared, on the clock the +// session keeps at t=10, until t=12. +func TestClockStepAppliesToTheDebugger(t *testing.T) { + s := runsSession(t) + wants(t, run(t, s, "%clock-step 10"), "clock step: 10.0 s") + wants(t, run(t, s, "%action MC::timed"), "Started action executor") + wants(t, stepToTheWait(t, s), "Use %advance 10.0 to move the clock from t=0.0 to the earliest wait") + wants(t, run(t, s, "%advance 10"), "Advanced to 10.0", "Action completed") + + wants(t, run(t, s, "%clock-step 0"), "clock step: none (a continuous clock)") + wants(t, run(t, s, "%action MC::timed"), "Started action executor") + wants(t, stepToTheWait(t, s), "Use %advance 2.0 to move the clock from t=10.0 to the earliest wait") + wants(t, run(t, s, "%advance 2"), "Advanced to 12.0", "Action completed") +} + +// stepToTheWait steps the action under debug until its token waits on the clock. +func stepToTheWait(t *testing.T, s *Session) string { + t.Helper() + for i := 0; i < 8; i++ { + out := run(t, s, "%step") + if strings.Contains(out, "waits on the clock") { + return out + } + } + t.Fatal("the action never waited on the clock") + return "" +} diff --git a/internal/frontend/repl/compare.go b/internal/frontend/repl/compare.go new file mode 100644 index 0000000000..51b5e93e10 --- /dev/null +++ b/internal/frontend/repl/compare.go @@ -0,0 +1,707 @@ +package repl + +import ( + "fmt" + "math" + "slices" + "sort" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/translate/simresults" +) + +// CompareOptions say how migrated run configurations are run beside the +// results their tool stored. +type CompareOptions struct { + // Runs replaces every configuration's numberOfRuns when positive. + Runs int64 + // Seed is the seed the runs derive theirs from; nil leaves it to the session's, if any. + Seed *uint64 + // Draws replaces every configuration's durationSimulationMode when set. + Draws *runtime.DrawPolicy + // ClockStep replaces every configuration's clock step when set; 0 is a continuous clock. + ClockStep *float64 + // Observe names the stored observables compared, each with the feature of the run that answers + // it; without any, every stored observable is read from the target's feature of its own name. + Observe []ObservablePair + // Only names the configurations compared, by qualified or simple name; all when empty. + Only []string +} + +// The verdict-name prefix and the runs-column label the comparison repeats. +const ( + comparePrefix = "compare " + openSysMLLabel = "OpenSysML (" +) + +// ObservablePair is a stored observable and the feature of the run that answers +// it; an empty Feature is the target's feature of the observable's own name. +type ObservablePair struct { + Stored string + Feature string +} + +// CompareResults runs each configuration the sidecar indexes as its tool ran +// it — on its target, for its run count, under its draw policy — and reports +// the tool's distribution of every observable beside the runs', one verdict +// per configuration. Numbers are read as they are; nothing is scaled or tuned. +func (s *Session) CompareResults(results *simresults.Results, opts CompareOptions) []Verdict { + defer s.enter()() + if results == nil || len(results.Configurations) == 0 { + return []Verdict{unresolvedVerdict("compare", "the results index no run configuration")} + } + var verdicts []Verdict + selected, refused := opts.selection(results.Configurations) + for i := range results.Configurations { + cfg := &results.Configurations[i] + if len(opts.Only) > 0 && !selected[i] { + continue + } + verdicts = append(verdicts, s.withTrace(s.compareVerdict(cfg, results.Repeats(i), opts))) + } + return append(verdicts, refused...) +} + +// selection resolves each name of Only to the one configuration it names — by +// id or qualified name, or by simple name when one alone bears it — marking it +// in selected; a name naming none or several is a refusal instead. +func (o CompareOptions) selection(cfgs []simresults.ConfigurationResults) (selected []bool, refused []Verdict) { + selected = make([]bool, len(cfgs)) + for _, name := range o.Only { + var exact, simple []int + for i := range cfgs { + switch { + case name == cfgs[i].ID || name == cfgs[i].Name: + exact = append(exact, i) + case sameName(name, cfgs[i].Name): + simple = append(simple, i) + } + } + found := exact + if len(found) == 0 { + found = simple + } + switch len(found) { + case 1: + selected[found[0]] = true + case 0: + refused = append(refused, unresolvedVerdict(comparePrefix+name, fmt.Sprintf("no configuration is named %s", name))) + default: + names := make([]string, len(found)) + for j, i := range found { + names[j] = cfgs[i].Name + } + refused = append(refused, unresolvedVerdict(comparePrefix+name, + fmt.Sprintf("%d configurations are named %s (%s); name one by its qualified name", len(found), name, strings.Join(names, ", ")))) + } + } + return selected, refused +} + +// sameName reports whether name spells qualified or its last segment, quoted or +// bare; a `::` inside a quoted segment is part of that segment, not a separator. +func sameName(name, qualified string) bool { + if name == qualified { + return true + } + segments, ok := nameSegments(qualified) + if !ok { + return nameText(name) == nameText(qualified) + } + want := nameText(name) + return want == strings.Join(segments, "::") || want == segments[len(segments)-1] +} + +// compareVerdict compares one configuration: a refusal names what the runs +// cannot be made without, else the table of both distributions — the runs' +// alone, against a row saying so, when the tool stored no result. repeats +// notes the stored snapshots another configuration's repeat. +func (s *Session) compareVerdict(cfg *simresults.ConfigurationResults, repeats []string, opts CompareOptions) Verdict { + label := comparePrefix + cfg.Name + if cfg.Behavior == "" { + return unresolvedVerdict(label, withNotes("the configuration "+cfg.Name+" performs no migrated behavior", cfg.Notes)) + } + notes := repeats + count := cfg.Runs + if opts.Runs > 0 { + count = opts.Runs + } + if count <= 0 { + count = 1 + notes = append(notes, "the configuration states no numberOfRuns, so one run is made, as its tool makes without one; -runs makes more") + } + policy := s.draws + switch { + case opts.Draws != nil: + policy = *opts.Draws + case cfg.Draws != "": + parsed, err := runtime.ParseDrawPolicy(cfg.Draws) + if err != nil { + return unresolvedVerdict(label, fmt.Sprintf("the durationSimulationMode %q of the configuration %s is no draw policy", cfg.Draws, cfg.Name)) + } + policy = parsed + } + step := cfg.ClockStep + if opts.ClockStep != nil { + step = *opts.ClockStep + } + if err := runtime.CheckClockStep(step); err != nil { + return unresolvedVerdict(label, "the clock step of the configuration "+cfg.Name+" cannot be run on: "+err.Error()) + } + inv, unresolved := s.resolveInvocation([]Behavior{{Name: cfg.Name}}, nil, nil) + if inv == nil { + v := unresolved[0] + v.Subject = label + return v + } + answered, table, err := s.runsTable(inv, count, opts.Seed, nil, policy, step) + if err != nil { + return standing(unresolvedVerdict(label, "the configuration "+cfg.Name+" could not be run: "+err.Error()), answered) + } + completed := 0 + var failures []string + for _, row := range table.Rows { + if row.Err != nil { + failures = append(failures, row.Err.Error()) + continue + } + completed++ + } + header := fmt.Sprintf("%s — %s in %s; %d run(s) by OpenSysML, draws %s", label, + storedRuns(cfg), orNone(cfg.Location), completed, policy) + if !table.Seedless { + header += fmt.Sprintf(", seed %d", table.Seed) + } + if step > 0 { + header += ", clock step " + semantics.FormatReal(step) + " s" + } + lines := append(sweepTraces(table), header) + lines = append(lines, comparisonTable(cfg, table, opts.Observe)...) + for _, f := range dedupe(failures) { + lines = append(lines, "error: "+f) + } + for _, n := range append(notes, cfg.Notes...) { + lines = append(lines, "note: "+n) + } + status := VerdictHolds + if len(failures) > 0 { + status = VerdictFails + } + return standing(Verdict{Subject: label, Status: status, Lines: lines}, answered) +} + +// storedRuns spells how many runs the tool stored of a configuration, and over +// how many snapshots when one summarises several. +func storedRuns(cfg *simresults.ConfigurationResults) string { + runs := cfg.StoredRuns() + if len(cfg.Snapshots) == 0 { + return "no stored run" + } + if runs == int64(len(cfg.Snapshots)) { + return fmt.Sprintf("%d stored run(s)", runs) + } + return fmt.Sprintf("%d stored run(s) over %d snapshot(s)", runs, len(cfg.Snapshots)) +} + +// comparisonTable is one row per observable and side — the tool's stored +// numbers, the runs' values, and the relative difference of each statistic. An +// observable the tool summarised is compared by count and mean, the statistics +// it kept; one the tool stored nothing of has the runs' row alone. +func comparisonTable(cfg *simresults.ConfigurationResults, table runtime.SweepTable, observe []ObservablePair) []string { + cells := [][]string{{"observable", "source", "runs", "min", "mean", "p50", "p90", "max"}} + var notes []string + for _, pair := range comparedObservables(cfg, table, observe) { + cells, notes = comparisonRows(cells, notes, cfg, table, pair) + } + widths := make([]int, len(cells[0])) + for _, row := range cells { + for i, cell := range row { + widths[i] = max(widths[i], len([]rune(cell))) + } + } + lines := []string{renderSweepRow(cells[0], widths, " | "), sweepRule(widths)} + for _, row := range cells[1:] { + lines = append(lines, renderSweepRow(row, widths, " | ")) + } + return append(lines, notes...) +} + +// comparisonRows writes the table rows and notes one compared observable earns. +func comparisonRows(cells [][]string, notes []string, cfg *simresults.ConfigurationResults, table runtime.SweepTable, pair ObservablePair) ([][]string, []string) { + name, feature := pair.Stored, pair.Feature + if len(cfg.Snapshots) == 0 { + cells = append(cells, []string{name, "tool (no stored result to compare)", "0", "", "", "", "", ""}) + return runRows(cells, notes, name, feature, table, nil) + } + if !slices.Contains(cfg.Observables, name) { + return cells, append(notes, fmt.Sprintf("note: the tool stored no observable named %s, which %s was to answer", name, feature)) + } + stored, pooled := storedDistribution(cfg, name) + if stored == nil { + return cells, append(notes, fmt.Sprintf("note: no snapshot holds a number for %s", name)) + } + if pooled { + cells, notes = pooledRows(cells, notes, cfg, name, stored) + } else { + cells = append(cells, statisticsRow(name, "tool", stored, "")) + } + tool := &comparison{stored: stored, pooled: pooled} + if pooled && name == cfg.Analysis { + tool.declared = declaredStatistics(cfg, name) + } + return runRows(cells, notes, name, feature, table, tool) +} + +// pooledRows writes the tool's row for a pooled observable and the notes its +// per-run summaries earn. +func pooledRows(cells [][]string, notes []string, cfg *simresults.ConfigurationResults, name string, stored *runtime.Distribution) ([][]string, []string) { + cells = append(cells, []string{name, "tool", fmt.Sprint(stored.Count), "", withUnit(drawnMean(stored), ""), "", "", ""}) + for _, snap := range cfg.Summarised(name) { + st := snap.Statistics + note := fmt.Sprintf("note: %s summarises %d run(s) of %s: mean %s", orUnnamed(snap.Name), st.Runs, name, spell(st.Mean)) + if st.Deviation != nil { + note += ", deviation " + spell(*st.Deviation) + } + if st.OutOfSpec != nil { + note += fmt.Sprintf(", %d out of specification", *st.OutOfSpec) + } + notes = append(notes, note) + } + if apart := cfg.Disagreeing(name); len(apart) > 0 { + names := make([]string, len(apart)) + for i, snap := range apart { + names[i] = orUnnamed(snap.Name) + } + notes = append(notes, fmt.Sprintf("note: the summaries %s of %s lie more than three standard errors apart, so they cannot be of runs of one and the same model, and the tool's mean of %s blends them", strings.Join(names, ", "), name, name)) + } + return cells, notes +} + +// comparison is the tool's side of one observable: its distribution, pooled from +// summaries (count and mean alone) or over the runs it stored one by one, and the +// statistics of the migrated analysis case of it, when one was written. +type comparison struct { + stored *runtime.Distribution + pooled bool + declared *declared +} + +// declared are the statistics of a Simulation::MonteCarlo case of the observable it +// analyses: the returns the case declares, then the outputs of the case the tool +// stored a value of without a return; with the tool's pooled value of each. +type declared struct { + analysis string + statistics []string + returned int + deviation *float64 + outOfSpec *int64 +} + +// caseOutputs are the outputs of Simulation::MonteCarlo by the tool's statistic each +// is bound from, in the order the case declares them. +var caseOutputs = []struct{ statistic, output string }{ + {simresults.StatisticRuns, runtime.MonteCarloRunsOutput}, + {simresults.StatisticMean, runtime.MonteCarloMeanOutput}, + {simresults.StatisticDeviation, runtime.MonteCarloDeviationOutput}, + {simresults.StatisticOutOfSpec, runtime.MonteCarloOutOfSpecOutput}, +} + +// caseOutput is the output of Simulation::MonteCarlo the tool's statistic is bound from. +func caseOutput(statistic string) string { + for _, o := range caseOutputs { + if o.statistic == statistic { + return o.output + } + } + return "" +} + +// declaredStatistics are the statistics of the analysis case written for cfg's +// target, nil when none was: its declared returns, then the case's other outputs +// the tool's summaries of observable stored, with the tool's values pooled. +func declaredStatistics(cfg *simresults.ConfigurationResults, observable string) *declared { + if cfg.AnalysisCase == "" { + return nil + } + d := &declared{analysis: cfg.AnalysisCase, statistics: slices.Clone(cfg.Statistics), returned: len(cfg.Statistics)} + if dev, ok := storedDeviation(cfg, observable); ok { + d.deviation = &dev + } + var out int64 + counted := false + for _, s := range cfg.Summarised(observable) { + if s.Statistics.OutOfSpec != nil { + out += *s.Statistics.OutOfSpec + counted = true + } + } + if counted { + d.outOfSpec = &out + } + for _, o := range caseOutputs { + if slices.Contains(d.statistics, o.statistic) || !storedStatistic(cfg, observable, o.statistic) { + continue + } + d.statistics = append(d.statistics, o.statistic) + } + return d +} + +// storedStatistic reports whether a summary of observable stored statistic: the +// count and mean always, a deviation or out-of-specification count only when recorded. +func storedStatistic(cfg *simresults.ConfigurationResults, observable, statistic string) bool { + for _, s := range cfg.Summarised(observable) { + switch statistic { + case simresults.StatisticRuns, simresults.StatisticMean: + return true + case simresults.StatisticDeviation: + if s.Statistics.Deviation != nil { + return true + } + case simresults.StatisticOutOfSpec: + if s.Statistics.OutOfSpec != nil { + return true + } + } + } + return false +} + +// storedDistribution is the tool's distribution of observable: over the numbers +// its snapshots hold run by run, or pooled — count and mean — with the runs the +// snapshots summarise, when any does; nil when the tool stored no number of it. +func storedDistribution(cfg *simresults.ConfigurationResults, observable string) (d *runtime.Distribution, pooled bool) { + values := cfg.Values(observable) + summarised := cfg.Summarised(observable) + if len(summarised) == 0 { + return runtime.Distribute(reals(values)), false + } + weighted := make([]runtime.Counted, 0, len(values)+len(summarised)) + for _, v := range values { + weighted = append(weighted, runtime.Counted{Count: 1, Value: v}) + } + for _, s := range summarised { + weighted = append(weighted, runtime.Counted{Count: s.Statistics.Runs, Value: s.Statistics.Mean}) + } + mean, runs := runtime.WeightedMean(weighted) + if runs == 0 { + return nil, true + } + return &runtime.Distribution{Count: int(runs), Mean: mean}, true +} + +// storedDeviation pools the tool's sample deviation of observable over stored runs and +// summaries (spread about each mean plus its offset); false when one kept no deviation. +func storedDeviation(cfg *simresults.ConfigurationResults, observable string) (float64, bool) { + pooled, _ := storedDistribution(cfg, observable) + if pooled == nil || pooled.Count < 2 { + return 0, false + } + var spreads []runtime.Spread + for _, v := range cfg.Values(observable) { + spreads = append(spreads, runtime.Spread{Weight: 1, Value: v, About: true}) + } + for _, s := range cfg.Summarised(observable) { + st := s.Statistics + if st.Deviation == nil { + return 0, false + } + n := float64(st.Runs) + spreads = append(spreads, runtime.Spread{Weight: n - 1, Value: *st.Deviation}, runtime.Spread{Weight: n, Value: st.Mean, About: true}) + } + return runtime.PooledDeviation(pooled.Mean, spreads, pooled.Count-1), true +} + +// runRows appends the runs' row of one observable — and the difference from the +// tool's when there is one — or the note saying why the runs are not compared. +func runRows(cells [][]string, notes []string, name, feature string, table runtime.SweepTable, tool *comparison) ([][]string, []string) { + ran, units, other, missing := runValues(table, feature) + d := runtime.Distribute(ran) + switch { + case len(ran) == 0 && other == 0: + cells = append(cells, []string{"", openSysMLLabel + feature + ")", "0", "", "", "", "", ""}) + notes = append(notes, fmt.Sprintf("note: no completed run produced %s, which answers %s", feature, name)) + return cells, notes + case missing > 0: + cells = append(cells, []string{"", openSysMLLabel + feature + ")", fmt.Sprint(len(ran)), "", "", "", "", ""}) + note := fmt.Sprintf("note: %s was produced by %d of the %d completed run(s)", feature, len(ran)+other, len(ran)+other+missing) + if other > 0 { + note += fmt.Sprintf(" and holds no number in %d of those", other) + } + notes = append(notes, note+fmt.Sprintf(", so %s is not compared", name)) + return cells, notes + case d == nil: + cells = append(cells, []string{"", openSysMLLabel + feature + ")", "0", "", "", "", "", ""}) + notes = append(notes, fmt.Sprintf("note: %s holds no number in any completed run, so %s is not compared", feature, name)) + return cells, notes + case other > 0: + cells = append(cells, []string{"", openSysMLLabel + feature + ")", fmt.Sprint(len(ran)), "", "", "", "", ""}) + notes = append(notes, fmt.Sprintf("note: %s holds no number in %d of the %d completed run(s) that produced it, so %s is not compared", feature, other, other+len(ran), name)) + return cells, notes + case len(units) > 1: + cells = append(cells, []string{"", openSysMLLabel + feature + ")", fmt.Sprint(len(ran)), "", "", "", "", ""}) + notes = append(notes, fmt.Sprintf("note: %s came to numbers in more than one unit (%s) over the completed runs, so %s is not compared", feature, unitList(units), name)) + return cells, notes + } + cells = append(cells, statisticsRow("", openSysMLLabel+feature+")", d, units[0])) + switch { + case tool == nil: + case tool.pooled: + cells = append(cells, []string{"", "difference", "", "", relative(drawnMean(tool.stored), drawnMean(d)), "", "", ""}) + switch { + case tool.declared != nil: + notes = append(notes, statisticsTable(name, feature, tool, d)...) + case d.Count < 2: + notes = append(notes, fmt.Sprintf("note: %s came to no deviation over the %d completed run(s): fewer than two define none", feature, d.Count)) + default: + notes = append(notes, fmt.Sprintf("note: %s came to a deviation of %s over the %d completed run(s)", feature, spell(d.Deviation), d.Count)) + } + default: + cells = append(cells, differenceRow(tool.stored, d)) + } + return cells, notes +} + +// statisticsTable is one row per statistic of the case — a declared return, or an +// output the tool stored — with the tool's pooled value, the runs' by the same +// aggregation, and their difference; N and OutOfSpec are shown, not differenced. +func statisticsTable(name, feature string, tool *comparison, d *runtime.Distribution) []string { + cells := [][]string{{"statistic", "of the case", "tool", openSysMLLabel + feature + ")", "difference"}} + var notes []string + for i, stat := range tool.declared.statistics { + of := "out " + caseOutput(stat) + if i < tool.declared.returned { + of = "return " + stat + } + switch stat { + case simresults.StatisticMean: + cells = append(cells, []string{stat, of, spell(tool.stored.Mean), spell(d.Mean), relative(drawnMean(tool.stored), drawnMean(d))}) + case simresults.StatisticDeviation: + stored := "" + if tool.declared.deviation != nil { + stored = spell(*tool.declared.deviation) + } + switch { + case d.Count < 2: + cells = append(cells, []string{stat, of, stored, "", ""}) + notes = append(notes, fmt.Sprintf("note: %s came to no deviation over the %d completed run(s): fewer than two define none, so %s is not compared", feature, d.Count, stat)) + case tool.declared.deviation == nil: + cells = append(cells, []string{stat, of, "", spell(d.Deviation), ""}) + notes = append(notes, fmt.Sprintf("note: a summary of %s kept no deviation, so the tool's is not pooled and %s is not compared", name, stat)) + default: + cells = append(cells, []string{stat, of, stored, spell(d.Deviation), relative(realValue(*tool.declared.deviation), realValue(d.Deviation))}) + } + case simresults.StatisticRuns: + cells = append(cells, []string{stat, of, fmt.Sprint(tool.stored.Count), fmt.Sprint(d.Count), ""}) + case simresults.StatisticOutOfSpec: + stored := "" + if tool.declared.outOfSpec != nil { + stored = fmt.Sprint(*tool.declared.outOfSpec) + } + cells = append(cells, []string{stat, of, stored, "", ""}) + notes = append(notes, fmt.Sprintf("note: %s counts the runs the tool found out of specification by its own criterion, which no migrated check evaluates, so it is not compared", stat)) + } + } + widths := make([]int, len(cells[0])) + for _, row := range cells { + for i, cell := range row { + widths[i] = max(widths[i], len([]rune(cell))) + } + } + lines := []string{fmt.Sprintf("statistics of %s by %s:", name, tool.declared.analysis), renderSweepRow(cells[0], widths, " | "), sweepRule(widths)} + for _, row := range cells[1:] { + lines = append(lines, renderSweepRow(row, widths, " | ")) + } + return append(lines, notes...) +} + +// spell writes a statistic as the tool's numbers are written in the table. +func spell(v float64) string { + return withUnit(realValue(v), "") +} + +// realValue wraps a statistic as the Real the tool wrote it as. +func realValue(v float64) semantics.Value { + return semantics.Value{Kind: semantics.ValReal, Real: v} +} + +func orUnnamed(name string) string { + if name == "" { + return "an unnamed snapshot" + } + return strconv.Quote(name) +} + +// comparedObservables are the pairs asked for, or every stored observable when +// none was — the observable the configuration's analysis summarises, else every +// feature the completed runs produced a number for, when the tool stored none; +// one naming no feature is read from the feature of its own name that the +// target object holds — `target.Time_Total` — or the bare name when the +// configuration runs on no target. +func comparedObservables(cfg *simresults.ConfigurationResults, table runtime.SweepTable, observe []ObservablePair) []ObservablePair { + pairs := make([]ObservablePair, 0, max(len(observe), len(cfg.Observables))) + switch { + case len(observe) > 0: + pairs = append(pairs, observe...) + case len(cfg.Snapshots) == 0 && cfg.Analysis != "": + pairs = append(pairs, ObservablePair{Stored: cfg.Analysis}) + case len(cfg.Snapshots) == 0: + for _, feature := range numericOutputs(table) { + pairs = append(pairs, ObservablePair{Stored: strings.TrimPrefix(feature, cfg.Target+"."), Feature: feature}) + } + default: + for _, name := range cfg.Observables { + pairs = append(pairs, ObservablePair{Stored: name}) + } + } + for i := range pairs { + if pairs[i].Feature != "" { + continue + } + pairs[i].Feature = pairs[i].Stored + if cfg.Target != "" { + pairs[i].Feature = cfg.Target + "." + pairs[i].Stored + } + } + return pairs +} + +// numericOutputs names, sorted, every feature some completed run produced as a number. +func numericOutputs(table runtime.SweepTable) []string { + seen := map[string]bool{} + var names []string + for _, row := range table.Rows { + if row.Err != nil { + continue + } + for _, out := range row.Outputs { + if _, ok := runtime.MagnitudeValue(out.Value); ok && !seen[out.Name] { + seen[out.Name] = true + names = append(names, out.Name) + } + } + } + sort.Strings(names) + return names +} + +// runValues collects the numbers a feature came to in the completed runs, the +// distinct units they came in, in order of first appearance ("" for a bare number), +// how many completed runs produced the feature as no number, and how many did not produce it. +func runValues(table runtime.SweepTable, feature string) (numbers []semantics.Value, units []string, other, missing int) { + for _, row := range table.Rows { + if row.Err != nil { + continue + } + produced := false + for _, out := range row.Outputs { + if out.Name != feature { + continue + } + produced = true + n, ok := runtime.MagnitudeValue(out.Value) + if !ok { + other++ + continue + } + numbers = append(numbers, n) + unit := "" + if q := out.Value.Quantity(); q != nil { + unit = q.Unit.String() + } + if !slices.Contains(units, unit) { + units = append(units, unit) + } + } + if !produced { + missing++ + } + } + return numbers, units, other, missing +} + +// unitList spells the units numbers came in, a bare number's as "none". +func unitList(units []string) string { + names := make([]string, len(units)) + for i, u := range units { + if u == "" { + u = "none" + } + names[i] = u + } + return strings.Join(names, ", ") +} + +// reals wraps stored numbers as the Reals the tool wrote them as. +func reals(values []float64) []semantics.Value { + out := make([]semantics.Value, len(values)) + for i, v := range values { + out[i] = realValue(v) + } + return out +} + +// statisticsRow spells one distribution: its count and five statistics. +func statisticsRow(observable, source string, d *runtime.Distribution, unit string) []string { + return []string{observable, source, fmt.Sprint(d.Count), + withUnit(d.Min, unit), withUnit(drawnMean(d), unit), withUnit(d.P50, unit), withUnit(d.P90, unit), withUnit(d.Max, unit)} +} + +// differenceRow is the runs' statistics relative to the tool's, (ran − stored) / stored. +func differenceRow(stored, ran *runtime.Distribution) []string { + return []string{"", "difference", "", + relative(stored.Min, ran.Min), relative(drawnMean(stored), drawnMean(ran)), + relative(stored.P50, ran.P50), relative(stored.P90, ran.P90), relative(stored.Max, ran.Max)} +} + +// relative spells (ran − stored) / stored as a signed percentage; a zero +// reference has no relative difference, so the absolute one is given. +func relative(stored, ran semantics.Value) string { + a, b := realOf(stored), realOf(ran) + if a == 0 { + if b == 0 { + return "+0.0%" + } + return fmt.Sprintf("%+.4g (of 0)", b) + } + return fmt.Sprintf("%+.1f%%", (b-a)/math.Abs(a)*100) +} + +func realOf(v semantics.Value) float64 { + if v.Kind == semantics.ValInt { + return float64(v.Int) + } + return v.Real +} + +// withNotes appends the sidecar's notes to a refusal, so it says why there is nothing to compare. +func withNotes(msg string, notes []string) string { + if len(notes) == 0 { + return msg + } + return msg + "; " + strings.Join(notes, "; ") +} + +func orNone(s string) string { + if s == "" { + return "no result location" + } + return s +} + +// dedupe sorts messages and drops repeats. +func dedupe(messages []string) []string { + seen := map[string]bool{} + var out []string + for _, m := range messages { + if !seen[m] { + seen[m] = true + out = append(out, m) + } + } + sort.Strings(out) + return out +} diff --git a/internal/frontend/repl/compare_test.go b/internal/frontend/repl/compare_test.go new file mode 100644 index 0000000000..59a8b7e22d --- /dev/null +++ b/internal/frontend/repl/compare_test.go @@ -0,0 +1,659 @@ +package repl + +import ( + "fmt" + "math" + "strings" + "sync" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/translate/simresults" +) + +// compareModel: a target whose behavior shaky fails on some draws (10 / k, k drawn +// from {0, 1}) and whose behavior steady never does. +const compareModel = `package Cfg { + private import ScalarValues::*; + private import RandomFunctions::*; + part def Probe { + attribute total : Real = 1.0; + action shaky { + attribute k : Integer = uniformInteger(0, 1); + first start; + then assign total := 10.0 / k; + then done; + } + action steady { first start; then assign total := 4.0; then done; } + } + individual def probe :> Probe; + action def 'Group 0' { part target : probe; perform action run ::> target.shaky; } + action def 'Group 1' { part target : probe; perform action run ::> target.steady; } + action def 'Sub::Group' { part target : probe; perform action run ::> target.steady; } +}` + +func compareSession(t *testing.T) *Session { + t.Helper() + s := NewSession() + if errs := errorDiagnostics(s.Submit(compareModel).Diagnostics); len(errs) > 0 { + t.Fatalf("model has errors: %v", errs) + } + return s +} + +func compareResults(name string, runs int64) *simresults.Results { + return &simresults.Results{Source: "probe.xmi", Configurations: []simresults.ConfigurationResults{{ + ID: "_c", Name: "Cfg::" + name, Runs: runs, Target: "target", Behavior: "run", + Location: "Results", + Observables: []string{"total"}, + Snapshots: []simresults.Snapshot{ + {ID: "_r1", Values: map[string]float64{"total": 4.0}}, + {ID: "_r2", Values: map[string]float64{"total": 6.0}}, + }, + }}} +} + +// A comparison holds when every run completes and fails when any run failed, +// as a %runs table with a failed row does, with each error under the table. +func TestCompareFailsWhenAnyRunFails(t *testing.T) { + s := compareSession(t) + seed := uint64(1) + + steady := s.CompareResults(compareResults("'Group 1'", 4), CompareOptions{Seed: &seed}) + if len(steady) != 1 || !steady[0].Holds() { + t.Fatalf("every run completing = %+v", steady) + } + lines := strings.Join(steady[0].Lines, "\n") + if !strings.Contains(lines, "4 run(s) by OpenSysML") || strings.Contains(lines, "error:") { + t.Errorf("lines of a comparison every run of which completed:\n%s", lines) + } + + shaky := s.CompareResults(compareResults("'Group 0'", 8), CompareOptions{Seed: &seed}) + if len(shaky) != 1 { + t.Fatalf("CompareResults = %d verdict(s), want 1", len(shaky)) + } + v := shaky[0] + lines = strings.Join(v.Lines, "\n") + if v.Status != VerdictFails { + t.Fatalf("a comparison some runs of which failed = %s, want fails:\n%s", v.Status, lines) + } + if !strings.Contains(lines, "error: ") || !strings.Contains(lines, "division by zero") { + t.Errorf("the failure is not under the table:\n%s", lines) + } + completed := 0 + for _, line := range v.Lines { + if _, err := fmt.Sscanf(line, "compare Cfg::'Group 0' — 2 stored run(s) in Results; %d run(s) by OpenSysML", &completed); err == nil { + break + } + } + if completed <= 0 || completed >= 8 { + t.Errorf("the header counts %d completed run(s), want some of 8:\n%s", completed, lines) + } + if !strings.Contains(lines, "| tool ") || !strings.Contains(lines, "| OpenSysML") { + t.Errorf("the completed runs are not tabled beside the stored ones:\n%s", lines) + } +} + +// A comparison draws under the configuration's own policy without setting the +// session's: whoever reads %draws while the runs are made sees the session's. +func TestCompareKeepsTheSessionDrawPolicy(t *testing.T) { + s := compareSession(t) + seed := uint64(1) + results := compareResults("'Group 0'", 64) + results.Configurations[0].Draws = "max" + + done := make(chan struct{}) + var seen sync.Map + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-done: + return + default: + seen.Store(s.Draws(), true) + } + } + }() + got := s.CompareResults(results, CompareOptions{Seed: &seed}) + close(done) + wg.Wait() + + seen.Range(func(policy, _ any) bool { + if policy != runtime.DrawRandom { + t.Errorf("the session's draws read %s while the comparison ran, want %s", policy, runtime.DrawRandom) + } + return true + }) + if s.Draws() != runtime.DrawRandom { + t.Errorf("the session's draws are %s after the comparison, want %s", s.Draws(), runtime.DrawRandom) + } + if len(got) != 1 || !got[0].Holds() { + t.Fatalf("a comparison under max = %+v, want it to hold", got) + } + if lines := strings.Join(got[0].Lines, "\n"); !strings.Contains(lines, "64 run(s) by OpenSysML, draws max") { + t.Errorf("the runs were not made under the configuration's policy:\n%s", lines) + } +} + +// An observable the completed runs produce in more than one unit — a quantity in +// some, a bare number or another unit in others — has no one distribution to set +// beside the tool's, whichever run comes first; runs in one unit are compared in it. +func TestComparisonTableRefusesMixedUnits(t *testing.T) { + quantity := func(n float64, unit string) runtime.Value { + return runtime.NewQuantityValue(&runtime.Quantity{ + Num: semantics.Value{Kind: semantics.ValReal, Real: n}, + Unit: runtime.Unit{Text: unit}, + }) + } + bare := runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: 3}} + row := func(v runtime.Value) runtime.SweepRow { + return runtime.SweepRow{Outputs: []runtime.CalcOutputValue{{Name: "target.total", Value: v}}} + } + cfg := &compareResults("'Group 1'", 2).Configurations[0] + for name, rows := range map[string][]runtime.SweepRow{ + "quantity first": {row(quantity(2, "s")), row(bare)}, + "bare first": {row(bare), row(quantity(2, "s"))}, + "two units": {row(quantity(2, "s")), row(quantity(2000, "ms"))}, + "failed run apart": {row(quantity(2, "s")), {Err: fmt.Errorf("boom")}, row(bare)}, + } { + got := strings.Join(comparisonTable(cfg, runtime.SweepTable{Target: "Cfg::'Group 1'", Rows: rows}, nil), "\n") + if !strings.Contains(got, "note: target.total came to numbers in more than one unit (") || !strings.Contains(got, "so total is not compared") { + t.Errorf("%s: mixed units compared:\n%s", name, got) + } + if strings.Contains(got, "difference") { + t.Errorf("%s: a difference is given over mixed units:\n%s", name, got) + } + } + same := runtime.SweepTable{Target: "Cfg::'Group 1'", Rows: []runtime.SweepRow{row(quantity(2, "s")), row(quantity(4, "s"))}} + got := strings.Join(comparisonTable(cfg, same, nil), "\n") + if !strings.Contains(got, "2.0 [s]") || !strings.Contains(got, "difference") { + t.Errorf("one unit not compared in it:\n%s", got) + } +} + +// An observable some completed runs produce as no number — a boolean, a string — +// is not compared over the runs that produced a number, whichever come first: the +// note counts the runs that hold no number, as it says when none does. +func TestComparisonTableRefusesNonnumericRuns(t *testing.T) { + number := func(n float64) runtime.Value { + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: n}} + } + flag := runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: true}} + row := func(v runtime.Value) runtime.SweepRow { + return runtime.SweepRow{Outputs: []runtime.CalcOutputValue{{Name: "target.total", Value: v}}} + } + cfg := &compareResults("'Group 1'", 2).Configurations[0] + for name, rows := range map[string][]runtime.SweepRow{ + "number first": {row(number(2)), row(flag), row(number(4))}, + "no number first": {row(flag), row(number(2)), row(number(4))}, + "failed run apart": {row(number(2)), {Err: fmt.Errorf("boom")}, row(flag), row(number(4))}, + } { + got := strings.Join(comparisonTable(cfg, runtime.SweepTable{Target: "Cfg::'Group 1'", Rows: rows}, nil), "\n") + if !strings.Contains(got, "note: target.total holds no number in 1 of the 3 completed run(s) that produced it, so total is not compared") { + t.Errorf("%s: a run holding no number is not counted:\n%s", name, got) + } + if strings.Contains(got, "difference") { + t.Errorf("%s: a difference is given over runs holding no number:\n%s", name, got) + } + } + none := runtime.SweepTable{Target: "Cfg::'Group 1'", Rows: []runtime.SweepRow{row(flag), row(flag)}} + got := strings.Join(comparisonTable(cfg, none, nil), "\n") + if !strings.Contains(got, "note: target.total holds no number in any completed run, so total is not compared") { + t.Errorf("no run holding a number:\n%s", got) + } +} + +// An observable some completed runs do not produce at all — the target's attribute +// was never given a value in them — is not compared over the runs that produced it, +// whichever come first: the note counts the runs that produced it against all completed. +func TestComparisonTableRefusesRunsMissingTheObservable(t *testing.T) { + number := func(n float64) runtime.Value { + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: n}} + } + flag := runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValBool, Bool: true}} + row := func(v runtime.Value) runtime.SweepRow { + return runtime.SweepRow{Outputs: []runtime.CalcOutputValue{{Name: "target.total", Value: v}}} + } + without := runtime.SweepRow{Outputs: []runtime.CalcOutputValue{{Name: "clock", Value: number(1)}}} + cfg := &compareResults("'Group 1'", 2).Configurations[0] + for name, rows := range map[string][]runtime.SweepRow{ + "number first": {row(number(2)), without, row(number(4))}, + "missing first": {without, row(number(2)), row(number(4))}, + "failed run apart": {row(number(2)), {Err: fmt.Errorf("boom")}, without, row(number(4))}, + } { + got := strings.Join(comparisonTable(cfg, runtime.SweepTable{Target: "Cfg::'Group 1'", Rows: rows}, nil), "\n") + if !strings.Contains(got, "note: target.total was produced by 2 of the 3 completed run(s), so total is not compared") { + t.Errorf("%s: a run producing no value is not counted:\n%s", name, got) + } + if strings.Contains(got, "difference") { + t.Errorf("%s: a difference is given over runs producing no value:\n%s", name, got) + } + } + mixed := runtime.SweepTable{Target: "Cfg::'Group 1'", Rows: []runtime.SweepRow{row(number(2)), without, row(flag)}} + got := strings.Join(comparisonTable(cfg, mixed, nil), "\n") + if !strings.Contains(got, "note: target.total was produced by 2 of the 3 completed run(s) and holds no number in 1 of those, so total is not compared") { + t.Errorf("a run producing no value beside one holding no number:\n%s", got) + } +} + +// A configuration whose tool stored no snapshot is run all the same: its table +// has the runs' row under one saying there is no stored result, over every +// numeric feature the runs produced, or over the observable its analysis +// summarises when it has one; none is skipped. +func TestCompareRunsConfigurationsWithoutStoredResults(t *testing.T) { + s := compareSession(t) + seed := uint64(1) + results := compareResults("'Group 1'", 3) + results.Configurations[0].Snapshots = nil + results.Configurations[0].Observables = nil + results.Configurations[0].Location = "Empty" + + got := s.CompareResults(results, CompareOptions{Seed: &seed}) + if len(got) != 1 || !got[0].Holds() { + t.Fatalf("a configuration without stored results = %+v, want it run", got) + } + lines := strings.Join(got[0].Lines, "\n") + for _, want := range []string{ + "compare Cfg::'Group 1' — no stored run in Empty; 3 run(s) by OpenSysML", + "clock | tool (no stored result to compare) | 0 |", + " | OpenSysML (clock) | 3 | 0.0 [s] | 0.0 [s] | 0.0 [s] | 0.0 [s] | 0.0 [s]", + "total | tool (no stored result to compare) | 0 |", + " | OpenSysML (target.total) | 3 | 4.0 | 4.0 | 4.0 | 4.0 | 4.0", + } { + if !strings.Contains(lines, want) { + t.Errorf("the table lacks %q:\n%s", want, lines) + } + } + if strings.Contains(lines, "difference") { + t.Errorf("a difference is given against no stored result:\n%s", lines) + } + + results.Configurations[0].Analysis = "total" + got = s.CompareResults(results, CompareOptions{Seed: &seed}) + if len(got) != 1 || !got[0].Holds() { + t.Fatalf("a configuration analysing total = %+v, want it run", got) + } + lines = strings.Join(got[0].Lines, "\n") + if !strings.Contains(lines, "total | tool (no stored result to compare)") { + t.Errorf("the analysed observable is not compared:\n%s", lines) + } + results.Configurations[0].Analysis = "elapsed" + got = s.CompareResults(results, CompareOptions{Seed: &seed}) + lines = strings.Join(got[0].Lines, "\n") + if !strings.Contains(lines, "elapsed | tool (no stored result to compare)") || strings.Contains(lines, "\ntotal ") { + t.Errorf("the analysed observable alone is not compared:\n%s", lines) + } + if !strings.Contains(lines, "note: no completed run produced target.elapsed, which answers elapsed") { + t.Errorf("an analysed observable the runs never produce is not noted:\n%s", lines) + } +} + +// A configuration stating no numberOfRuns is run once, as its tool runs it, and +// the table says so; -runs overrides that as it does a stated count. +func TestCompareDefaultsToOneRun(t *testing.T) { + s := compareSession(t) + seed := uint64(1) + results := compareResults("'Group 1'", 0) + + got := s.CompareResults(results, CompareOptions{Seed: &seed}) + if len(got) != 1 || !got[0].Holds() { + t.Fatalf("a configuration without numberOfRuns = %+v, want one run", got) + } + lines := strings.Join(got[0].Lines, "\n") + if !strings.Contains(lines, "2 stored run(s) in Results; 1 run(s) by OpenSysML") { + t.Errorf("one run is not made:\n%s", lines) + } + if !strings.Contains(lines, "note: the configuration states no numberOfRuns, so one run is made, as its tool makes without one; -runs makes more") { + t.Errorf("the default is not noted:\n%s", lines) + } + + got = s.CompareResults(results, CompareOptions{Seed: &seed, Runs: 5}) + lines = strings.Join(got[0].Lines, "\n") + if !strings.Contains(lines, "5 run(s) by OpenSysML") || strings.Contains(lines, "states no numberOfRuns") { + t.Errorf("-runs does not override the default:\n%s", lines) + } +} + +// A summarising snapshot two configurations store alike is noted under each as a +// likely copy, naming the other configuration and its result location. +func TestCompareNotesSnapshotsStoredTwice(t *testing.T) { + s := compareSession(t) + seed := uint64(1) + summary := simresults.Snapshot{ + ID: "_sum", Name: "analysis", Values: map[string]float64{"total": 10.0}, + Statistics: &simresults.Statistics{Observable: "total", Runs: 8, Mean: 10.0, Deviation: simresults.Real(1.5)}, + } + results := compareResults("'Group 1'", 2) + results.Configurations[0].Snapshots = []simresults.Snapshot{summary} + twin := results.Configurations[0] + twin.ID, twin.Name, twin.Location = "_d", "Cfg::'Sub::Group'", "'Other Results'" + results.Configurations = append(results.Configurations, twin) + + got := s.CompareResults(results, CompareOptions{Seed: &seed}) + if len(got) != 2 || !got[0].Holds() || !got[1].Holds() { + t.Fatalf("CompareResults = %+v, want both to hold", got) + } + for i, want := range []string{ + `note: the snapshot "analysis" bears the name and the statistics of total of a snapshot of the configuration Cfg::'Sub::Group' (in 'Other Results'), so one may be a copy of the other`, + `note: the snapshot "analysis" bears the name and the statistics of total of a snapshot of the configuration Cfg::'Group 1' (in Results), so one may be a copy of the other`, + } { + if lines := strings.Join(got[i].Lines, "\n"); !strings.Contains(lines, want) { + t.Errorf("verdict %d lacks %q:\n%s", i, want, lines) + } + } +} + +// A configuration whose behavior was not migrated is refused naming it, so a +// refusal printed among other failures to run still says which configuration +// it is about, with the sidecar's notes saying why. +func TestCompareNamesTheConfigurationItRefuses(t *testing.T) { + s := compareSession(t) + seed := uint64(1) + results := compareResults("'Group 1'", 2) + results.Configurations[0].Behavior = "" + results.Configurations[0].Notes = []string{"the configuration names no execution target, so it runs no behavior"} + + got := s.CompareResults(results, CompareOptions{Seed: &seed}) + if len(got) != 1 || got[0].Status != VerdictUnresolved { + t.Fatalf("a configuration without behavior = %+v, want a refusal", got) + } + lines := strings.Join(got[0].Lines, "\n") + want := "error: the configuration Cfg::'Group 1' performs no migrated behavior; the configuration names no execution target, so it runs no behavior" + if lines != want { + t.Errorf("refusal:\n%s\nwant:\n%s", lines, want) + } +} + +// An observable the tool summarised — a snapshot holding the mean of several +// runs, not one run's value — is compared by count and mean alone, pooled with +// the runs stored one by one; the statistics the tool did not keep stay blank, +// and each summary is noted with what it recorded. +func TestComparisonTablePoolsSummarisedResults(t *testing.T) { + number := func(n float64) runtime.Value { + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: n}} + } + row := func(n float64) runtime.SweepRow { + return runtime.SweepRow{Outputs: []runtime.CalcOutputValue{{Name: "target.total", Value: number(n)}}} + } + cfg := &compareResults("'Group 1'", 2).Configurations[0] + cfg.Analysis = "total" + cfg.Snapshots = append(cfg.Snapshots, simresults.Snapshot{ + ID: "_sum", Name: "analysis", Values: map[string]float64{"total": 10.0}, + Statistics: &simresults.Statistics{Observable: "total", Runs: 8, Mean: 10.0, Deviation: simresults.Real(1.5)}, + }) + if runs := cfg.StoredRuns(); runs != 10 { + t.Errorf("StoredRuns = %d, want 10: two stored one by one and eight summarised", runs) + } + if got := storedRuns(cfg); got != "10 stored run(s) over 3 snapshot(s)" { + t.Errorf("storedRuns = %q", got) + } + + table := runtime.SweepTable{Target: "Cfg::'Group 1'", Rows: []runtime.SweepRow{row(8), row(10)}} + got := strings.Join(comparisonTable(cfg, table, nil), "\n") + for _, want := range []string{ + "total | tool | 10 | | 9.0 | | |", + " | OpenSysML (target.total) | 2 | 8.0 | 9.0 | 8.0 | 10.0 | 10.0", + " | difference | | | +0.0% | | |", + `note: "analysis" summarises 8 run(s) of total: mean 10.0, deviation 1.5`, + "note: target.total came to a deviation of 1.4142135623730951 over the 2 completed run(s)", + } { + if !strings.Contains(got, want) { + t.Errorf("the pooled table lacks %q:\n%s", want, got) + } + } + + cfg.Snapshots = cfg.Snapshots[2:] + got = strings.Join(comparisonTable(cfg, table, nil), "\n") + if !strings.Contains(got, "| tool | 8 | | 10.0 |") || !strings.Contains(got, "| -10.0% |") { + t.Errorf("a summary alone is not compared by count and mean:\n%s", got) + } + if strings.Contains(got, "standard errors apart") { + t.Errorf("a summary alone is said to disagree:\n%s", got) + } + + // Summaries whose means lie further apart than sampling error allows are of + // other model states than one, and the table says so. + cfg.Snapshots = append(cfg.Snapshots, + simresults.Snapshot{ID: "_near", Name: "again", Values: map[string]float64{"total": 10.5}, Statistics: &simresults.Statistics{Observable: "total", Runs: 8, Mean: 10.5, Deviation: simresults.Real(1.5)}}, + simresults.Snapshot{ID: "_far", Name: "other", Values: map[string]float64{"total": 20.0}, Statistics: &simresults.Statistics{Observable: "total", Runs: 8, Mean: 20.0, Deviation: simresults.Real(1.5)}}, + ) + got = strings.Join(comparisonTable(cfg, table, nil), "\n") + want := `note: the summaries "analysis", "again", "other" of total lie more than three standard errors apart, so they cannot be of runs of one and the same model, and the tool's mean of total blends them` + if !strings.Contains(got, want) { + t.Errorf("disagreeing summaries are not noted:\n%s", got) + } +} + +// A simple name naming configurations of several packages compares none of them +// and says which it could name; an id or a qualified name compares its one. +func TestCompareRefusesAnAmbiguousName(t *testing.T) { + s := compareSession(t) + seed := uint64(1) + results := compareResults("'Group 1'", 2) + twin := results.Configurations[0] + twin.ID, twin.Name = "_d", "Other::'Group 1'" + results.Configurations = append(results.Configurations, twin) + + got := s.CompareResults(results, CompareOptions{Seed: &seed, Only: []string{"Group 1"}}) + if len(got) != 1 || got[0].Holds() || got[0].Subject != "compare Group 1" { + t.Fatalf("an ambiguous name = %+v, want one refusal", got) + } + if lines := strings.Join(got[0].Lines, "\n"); !strings.Contains(lines, "2 configurations are named Group 1 (Cfg::'Group 1', Other::'Group 1')") { + t.Errorf("the refusal does not list the configurations:\n%s", lines) + } + + for name, subject := range map[string]string{"Cfg::'Group 1'": "compare Cfg::'Group 1'", "_d": "compare Other::'Group 1'"} { + got := s.CompareResults(results, CompareOptions{Seed: &seed, Only: []string{name}}) + if len(got) != 1 || got[0].Subject != subject { + t.Errorf("%s = %+v, want the one configuration %s", name, got, subject) + } + } + one := results.Configurations[:1] + got = s.CompareResults(&simresults.Results{Source: results.Source, Configurations: one}, CompareOptions{Seed: &seed, Only: []string{"Group 1", "'Group 1'"}}) + if len(got) != 1 || !got[0].Holds() { + t.Errorf("a simple name borne by one configuration = %+v, want it compared once", got) + } +} + +func TestSameNameSplitsOutsideQuotes(t *testing.T) { + for _, c := range []struct { + name, qualified string + want bool + }{ + {"A::B", "Cfg::'A::B'", true}, + {"'A::B'", "Cfg::'A::B'", true}, + {"B", "Cfg::'A::B'", false}, + {"B'", "Cfg::'A::B'", false}, + {"A::B", "Cfg::A::B", false}, + {"B", "Cfg::A::B", true}, + {`'it\'s'`, `Cfg::'it\'s'`, true}, + {"'it'", `Cfg::'it\'s'`, false}, + {"s'", `Cfg::'it\'s'`, false}, + {"Group 1", "'Cfg::Pkg'::'Group 1'", true}, + {"Pkg'::'Group 1'", "'Cfg::Pkg'::'Group 1'", false}, + {"Group 1", "Group 1", true}, + {"Group 1", "'Group 1'", true}, + } { + if got := sameName(c.name, c.qualified); got != c.want { + t.Errorf("sameName(%q, %q) = %t, want %t", c.name, c.qualified, got, c.want) + } + } + + s := compareSession(t) + seed := uint64(1) + results := compareResults("'Sub::Group'", 2) + for _, name := range []string{"Sub::Group", "'Sub::Group'", "Cfg::'Sub::Group'"} { + got := s.CompareResults(results, CompareOptions{Seed: &seed, Only: []string{name}}) + if len(got) != 1 || !got[0].Holds() || got[0].Subject != "compare Cfg::'Sub::Group'" { + t.Errorf("-action %s = %+v, want the configuration named 'Sub::Group' compared", name, got) + } + } + got := s.CompareResults(results, CompareOptions{Seed: &seed, Only: []string{"Group"}}) + if len(got) != 1 || got[0].Holds() || !strings.Contains(strings.Join(got[0].Lines, "\n"), "no configuration is named Group") { + t.Errorf("-action Group over a configuration named 'Sub::Group' = %+v, want a refusal", got) + } +} + +// An observable a migrated analysis case is written for is compared statistic by +// statistic: pooled mean and deviation differenced, counts side by side, OutOfSpec noted. +func TestComparisonTableComparesTheDeclaredStatistics(t *testing.T) { + number := func(n float64) runtime.Value { + return runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: n}} + } + row := func(n float64) runtime.SweepRow { + return runtime.SweepRow{Outputs: []runtime.CalcOutputValue{{Name: "target.total", Value: number(n)}}} + } + cfg := &compareResults("'Group 1'", 2).Configurations[0] + cfg.Analysis = "total" + cfg.AnalysisCase = "'Probe Monte Carlo'" + cfg.Statistics = []string{simresults.StatisticMean, simresults.StatisticDeviation, simresults.StatisticRuns, simresults.StatisticOutOfSpec} + cfg.Snapshots = []simresults.Snapshot{{ + ID: "_sum", Name: "analysis", Values: map[string]float64{"total": 10.0}, + Statistics: &simresults.Statistics{Observable: "total", Runs: 3, Mean: 10.0, Deviation: simresults.Real(2.0), OutOfSpec: simresults.Count(1)}, + }} + table := runtime.SweepTable{Target: "Cfg::'Group 1'", Rows: []runtime.SweepRow{row(8), row(10), row(12)}} + got := strings.Join(comparisonTable(cfg, table, nil), "\n") + for _, want := range []string{ + "statistics of total by 'Probe Monte Carlo':", + "statistic | of the case | tool | OpenSysML (target.total) | difference", + "Mean | return Mean | 10.0 | 10.0 | +0.0%", + "Deviation | return Deviation | 2.0 | 2.0 | +0.0%", + "N | return N | 3 | 3 |", + "OutOfSpec | return OutOfSpec | 1 | |", + "note: OutOfSpec counts the runs the tool found out of specification by its own criterion, which no migrated check evaluates, so it is not compared", + } { + if !strings.Contains(got, want) { + t.Errorf("the statistics table lacks %q:\n%s", want, got) + } + } + if strings.Contains(got, "came to a deviation of") { + t.Errorf("the deviation is noted in prose beside the statistic:\n%s", got) + } + + // A case returning Mean alone still computes the other outputs; those the tool + // stored are compared as the case's outputs, after the return, in the case's order. + cfg.Statistics = []string{simresults.StatisticMean} + got = strings.Join(comparisonTable(cfg, table, nil), "\n") + for _, want := range []string{ + "Mean | return Mean | 10.0 | 10.0 | +0.0%\n" + + "N | out runs | 3 | 3 |\n" + + "Deviation | out deviation | 2.0 | 2.0 | +0.0%\n" + + "OutOfSpec | out outOfSpec | 1 | |", + } { + if !strings.Contains(got, want) { + t.Errorf("the table of a case returning Mean alone lacks %q:\n%s", want, got) + } + } + if len(cfg.Statistics) != 1 { + t.Errorf("the sidecar's declared returns grew to %v", cfg.Statistics) + } + cfg.Statistics = []string{simresults.StatisticMean, simresults.StatisticDeviation, simresults.StatisticRuns, simresults.StatisticOutOfSpec} + + // The tool's deviation pools the runs stored one by one with each summary's + // spread about its own mean and its mean's offset from the pooled one. + cfg.Snapshots = append(cfg.Snapshots, simresults.Snapshot{ID: "_r1", Values: map[string]float64{"total": 14.0}}) + if dev, ok := storedDeviation(cfg, "total"); !ok || spell(dev) != "2.581988897471611" { + t.Errorf("storedDeviation = %v, %v; want sqrt(20/3) over the four pooled runs", dev, ok) + } + + // The pooled mean and deviation stay finite while the runs are, however far apart: + // the squares of the offsets and of the summaries' deviations are never taken raw. + huge := &compareResults("'Group 1'", 2).Configurations[0] + huge.Analysis = "total" + huge.Snapshots = []simresults.Snapshot{ + {ID: "_r1", Values: map[string]float64{"total": -1e200}}, + {ID: "_r2", Values: map[string]float64{"total": 1e200}}, + } + if dev, ok := storedDeviation(huge, "total"); !ok || math.Abs(dev-math.Sqrt2*1e200) > 1e185 { + t.Errorf("storedDeviation over ±1e200 = %v, %v; want %v", dev, ok, math.Sqrt2*1e200) + } + huge.Snapshots = []simresults.Snapshot{ + {ID: "_s1", Statistics: &simresults.Statistics{Observable: "total", Runs: 2, Mean: -1e200, Deviation: simresults.Real(1e200)}}, + {ID: "_s2", Statistics: &simresults.Statistics{Observable: "total", Runs: 2, Mean: 1e200, Deviation: simresults.Real(1e200)}}, + } + if d, pooled := storedDistribution(huge, "total"); !pooled || d.Count != 4 || d.Mean != 0 { + t.Errorf("storedDistribution over summaries at ±1e200 = %+v, %v; want four runs with a mean of 0", d, pooled) + } + if dev, ok := storedDeviation(huge, "total"); !ok || math.Abs(dev-math.Sqrt2*1e200) > 1e185 { + t.Errorf("storedDeviation over summaries at ±1e200 = %v, %v; want %v", dev, ok, math.Sqrt2*1e200) + } + // Nor is a run's or a summary's offset from the pooled mean taken raw: runs at + // the ends of the Real range are pooled to the deviation they have. + max := math.MaxFloat64 + huge.Snapshots = []simresults.Snapshot{ + {ID: "_r1", Values: map[string]float64{"total": -max}}, + {ID: "_s1", Statistics: &simresults.Statistics{Observable: "total", Runs: 3, Mean: max, Deviation: simresults.Real(0)}}, + } + if dev, ok := storedDeviation(huge, "total"); !ok || math.Abs(dev-max) > 1e293 { + t.Errorf("storedDeviation over -max and a summary at max = %v, %v; want %v", dev, ok, max) + } + + // A summary that kept no deviation leaves the tool's blank and says so. + cfg.Snapshots[0].Statistics.Deviation = nil + got = strings.Join(comparisonTable(cfg, table, nil), "\n") + for _, want := range []string{ + "Deviation | return Deviation | | 2.0 |", + "note: a summary of total kept no deviation, so the tool's is not pooled and Deviation is not compared", + } { + if !strings.Contains(got, want) { + t.Errorf("the table without a stored deviation lacks %q:\n%s", want, got) + } + } + + // One completed run has no sample deviation: the runs' is left blank, not 0, and + // the tool's stands alone uncompared. + cfg.Snapshots[0].Statistics.Deviation = simresults.Real(2.0) + one := runtime.SweepTable{Target: "Cfg::'Group 1'", Rows: []runtime.SweepRow{row(10)}} + got = strings.Join(comparisonTable(cfg, one, nil), "\n") + for _, want := range []string{ + "Deviation | return Deviation | 2.581988897471611 | |", + "N | return N | 4 | 1 |", + "note: target.total came to no deviation over the 1 completed run(s): fewer than two define none, so Deviation is not compared", + } { + if !strings.Contains(got, want) { + t.Errorf("the table over one run lacks %q:\n%s", want, got) + } + } + if strings.Contains(got, "| 0.0 ") || strings.Contains(got, "-100.0%") { + t.Errorf("the one run's deviation is compared as 0:\n%s", got) + } +} + +// A summary's run count is pooled exactly: one a Real cannot hold is neither rounded +// in the pooled count nor in the table's N. +func TestComparisonTablePoolsRunCountsExactly(t *testing.T) { + const runs = int64(1<<53) + 1 + cfg := &compareResults("'Group 1'", 2).Configurations[0] + cfg.Analysis = "total" + cfg.AnalysisCase = "'Probe Monte Carlo'" + cfg.Statistics = []string{simresults.StatisticMean, simresults.StatisticRuns} + cfg.Snapshots = []simresults.Snapshot{ + {ID: "_sum", Statistics: &simresults.Statistics{Observable: "total", Runs: runs, Mean: 10.0}}, + {ID: "_r1", Values: map[string]float64{"total": 10.0}}, + {ID: "_r2", Values: map[string]float64{"total": 10.0}}, + } + want := runs + 2 + if runs == int64(float64(runs)) || want == int64(float64(want)) { + t.Fatalf("%d rounds to itself as a Real; the count under test must not", want) + } + if d, pooled := storedDistribution(cfg, "total"); !pooled || int64(d.Count) != want || d.Mean != 10.0 { + t.Errorf("storedDistribution = %+v, %v; want %d runs with a mean of 10.0", d, pooled, want) + } + table := runtime.SweepTable{Target: "Cfg::'Group 1'", Rows: []runtime.SweepRow{{ + Outputs: []runtime.CalcOutputValue{{Name: "target.total", Value: runtime.Value{Kind: runtime.ValConst, Const: semantics.Value{Kind: semantics.ValReal, Real: 10.0}}}}, + }}} + got := strings.Join(comparisonTable(cfg, table, nil), "\n") + for _, line := range []string{ + fmt.Sprintf("total | tool | %d |", want), + fmt.Sprintf("note: an unnamed snapshot summarises %d run(s) of total", runs), + fmt.Sprintf("N | return N | %d | 1 |", want), + } { + if !strings.Contains(got, line) { + t.Errorf("the table lacks the exact count %q:\n%s", line, got) + } + } +} diff --git a/internal/frontend/repl/compile.go b/internal/frontend/repl/compile.go new file mode 100644 index 0000000000..6a93cb0896 --- /dev/null +++ b/internal/frontend/repl/compile.go @@ -0,0 +1,28 @@ +package repl + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/translate/codegen" +) + +// CompileCalc compiles the named calc def and every calc it invokes to the +// codegen IR. The error names the construct that kept a calc from compiling. +func (s *Session) CompileCalc(name string) (*codegen.Program, error) { + defer s.enter()() + sym, _, err := s.lookupSymbolOfKinds(name, symbols.SymbolCalcDef, symbols.SymbolCalcUsage) + if err != nil { + return nil, err + } + idx := s.browseIndex() + if idx == nil { + return nil, fmt.Errorf("no declarations loaded") + } + resolver := resolve.New(idx) + model := semantics.NewModel(resolver) + resolver.SetModel(model) + return codegen.New(model, resolver).Compile(sym) +} diff --git a/internal/repl/compile_bench_test.go b/internal/frontend/repl/compile_bench_test.go similarity index 96% rename from internal/repl/compile_bench_test.go rename to internal/frontend/repl/compile_bench_test.go index 5d905b2726..74f44500eb 100644 --- a/internal/repl/compile_bench_test.go +++ b/internal/frontend/repl/compile_bench_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/codegen" + "github.com/Open-MBEE/OpenSysML/internal/translate/codegen" ) // BenchmarkCompiledCalc times each invocation interpreted and as each backend's diff --git a/internal/frontend/repl/compile_test.go b/internal/frontend/repl/compile_test.go new file mode 100644 index 0000000000..5d46065756 --- /dev/null +++ b/internal/frontend/repl/compile_test.go @@ -0,0 +1,604 @@ +package repl + +import ( + "errors" + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/translate/codegen" +) + +// compiledCase invokes one calc; the interpreter is the oracle for what the +// compiled program must print, or which failure it must report. +type compiledCase struct { + calc string + args []string +} + +var compiledCases = []compiledCase{ + {"Fib", []string{"0"}}, {"Fib", []string{"1"}}, {"Fib", []string{"20"}}, + {"SumTo", []string{"0"}}, {"SumTo", []string{"1000"}}, {"SumTo", []string{"200000"}}, + {"Arith", []string{"7", "3"}}, {"Arith", []string{"-7", "3"}}, {"Arith", []string{"7", "0"}}, + {"Arith", []string{"9223372036854775807", "1"}}, + {"Quot", []string{"7", "2"}}, {"Quot", []string{"1", "3"}}, {"Quot", []string{"-1", "3"}}, + {"Quot", []string{"1", "0"}}, {"Quot", []string{"9007199254740993", "1"}}, + {"Quot", []string{"9223372036854775807", "-9223372036854775808"}}, + {"Mixed", []string{"1.5", "3"}}, {"Mixed", []string{"0.1", "0"}}, + {"Pow", []string{"-4"}}, {"Pow", []string{"3037000500"}}, + {"RealPow", []string{"2.0", "0.5"}}, {"RealPow", []string{"0.0", "-1.0"}}, {"RealPow", []string{"-8.0", "0.5"}}, + {"RealPow", []string{"10.0", "400.0"}}, + {"Neg", []string{"5"}}, {"Neg", []string{"-9223372036854775808"}}, + {"Logic", []string{"false", "true", "0"}}, {"Logic", []string{"false", "false", "0"}}, + {"Logic", []string{"true", "true", "0"}}, {"Logic", []string{"true", "true", "5"}}, + {"Compare", []string{"2.0", "2"}}, {"Compare", []string{"2.5", "2"}}, {"Compare", []string{"0.1", "1"}}, + {"Sign", []string{"-9"}}, {"Sign", []string{"0"}}, {"Sign", []string{"3"}}, + {"FirstAbove", []string{"50"}}, {"FirstAbove", []string{"0"}}, + {"Collatz", []string{"27"}}, {"Collatz", []string{"1"}}, + {"Hypot", []string{"3.0", "4.0"}}, + {"Trailing", []string{"1.5", "2"}}, + {"Named", []string{"7", "3"}}, + {"Ratio", []string{"3", "4"}}, {"Ratio", []string{"0", "4"}}, + {"Even", []string{"7"}}, {"Even", []string{"10"}}, {"Even", []string{"100000"}}, // exceeds the recursion limit + {"Big", []string{"1"}}, {"Big", []string{"0"}}, + {"Order", []string{"0", "9223372036854775807"}}, {"Order", []string{"3", "9223372036854775807"}}, + {"Order", []string{"3", "4"}}, + {"UntilLoop", []string{"0"}}, {"UntilLoop", []string{"-3"}}, {"UntilLoop", []string{"5"}}, + {"UntilLocal", []string{"0"}}, {"UntilLocal", []string{"3"}}, + {"WhileUntil", []string{"0"}}, {"WhileUntil", []string{"5"}}, {"WhileUntil", []string{"20"}}, + {"Hypot", []string{"1e-400", "4.0"}}, {"Hypot", []string{"1e-320", "4.0"}}, {"Hypot", []string{"1e400", "4.0"}}, + {"Hypot", []string{"0e-400", "4.0"}}, {"Hypot", []string{"3.0E0", "+4.0"}}, + {"fib", []string{"10"}}, {"Specialized", []string{"12"}}, {"ViaUsage", []string{"11"}}, + {"NamedOrder", []string{"0", "9223372036854775807"}}, {"NamedOrder", []string{"3", "4"}}, + {"OrderArgs", []string{"0", "9223372036854775807"}}, {"OrderArgs", []string{"3", "9223372036854775807"}}, + {"Nat", []string{"5"}}, {"Nat", []string{"0"}}, {"Nat", []string{"-1"}}, + {"Pos", []string{"2"}}, {"Pos", []string{"1"}}, {"Pos", []string{"0"}}, + {"PosLoc", []string{"2"}}, {"PosLoc", []string{"1"}}, + {"One", []string{"21"}}, + {"Collide", []string{"1"}}, + {"Lib::Sqrt", []string{"2.0"}}, {"Lib::Sqrt", []string{"0.0"}}, {"Lib::Sqrt", []string{"-1.0"}}, + {"Lib::Floor", []string{"2.5"}}, {"Lib::Floor", []string{"-2.5"}}, {"Lib::Floor", []string{"1e300"}}, {"Lib::Floor", []string{"9.3e18"}}, + {"Lib::RealExt", []string{"-1.5", "2.0"}}, {"Lib::RealExt", []string{"0.0", "-0.0"}}, + {"Lib::IntExt", []string{"-7", "3"}}, {"Lib::IntExt", []string{"-9223372036854775808", "0"}}, + {"Lib::MixedExt", []string{"2", "2.5"}}, {"Lib::MixedExt", []string{"3", "-0.5"}}, + {"Lib::IntAbs", []string{"-4"}}, {"Lib::IntAbs", []string{"-9223372036854775808"}}, + {"Lib::NatMax", []string{"3", "5"}}, {"Lib::NatMax", []string{"3", "-1"}}, {"Lib::NatMax", []string{"-2", "-1"}}, + {"Lib::Zero", []string{"0.0"}}, {"Lib::Zero", []string{"1.0"}}, {"Lib::Zero", []string{"0.5"}}, + {"Lib::Trig", []string{"0.5"}}, {"Lib::Trig", []string{"0.0"}}, {"Lib::Trig", []string{"1e308"}}, + {"Lib::Arc", []string{"0.5"}}, {"Lib::Arc", []string{"2.0"}}, + {"Lib::Deg", []string{"1.0"}}, {"Lib::Deg", []string{"1e308"}}, + {"Lib::Exp", []string{"1.0"}}, {"Lib::Exp", []string{"0.0"}}, {"Lib::Exp", []string{"710.0"}}, + {"Lib::Log", []string{"1000.0", "10.0"}}, {"Lib::Log", []string{"8.0", "2.0"}}, {"Lib::Log", []string{"9.0", "3.0"}}, + {"Lib::Log", []string{"9.0", "1.0"}}, {"Lib::Log", []string{"9.0", "0.0"}}, {"Lib::Log", []string{"-9.0", "3.0"}}, + {"Lib::Atan2", []string{"1.0", "1.0"}}, {"Lib::Atan2", []string{"0.0", "0.0"}}, {"Lib::Atan2", []string{"0.0", "-1.0"}}, + {"Lib::NamedLib", []string{"1.0", "-1.0"}}, + {"Seq::Sequence", []string{"5"}}, {"Seq::Sequence", []string{"0"}}, {"Seq::Library", []string{"2.5"}}, + {"Seq::Unbound", []string{"1"}}, + {"Seq::AddS", []string{"(1,2)"}}, {"Seq::AddS", []string{"null"}}, {"Seq::AddS", []string{"4"}}, {"Seq::AddS", []string{"(3)"}}, + {"Seq::AddS2", []string{"(1,2)"}}, {"Seq::AddS2", []string{"null"}}, {"Seq::AddS2", []string{"(3)"}}, + {"Seq::AddR", []string{"(1.5)"}}, {"Seq::AddR", []string{"null"}}, + {"Seq::LtS", []string{"(1)"}}, {"Seq::LtS", []string{"(1,2)"}}, {"Seq::LtS", []string{"null"}}, {"Seq::LtS", []string{"0"}}, + {"Seq::AndS", []string{"(true)"}}, {"Seq::AndS", []string{"null"}}, {"Seq::AndS", []string{"(true,false)"}}, + {"Seq::AndS2", []string{"(true)"}}, {"Seq::AndS2", []string{"null"}}, + {"Seq::NotS", []string{"(true)"}}, {"Seq::NotS", []string{"null"}}, {"Seq::NotS", []string{"(true,true)"}}, + {"Seq::IfS", []string{"(true)"}}, {"Seq::IfS", []string{"null"}}, {"Seq::IfS", []string{"false"}}, + {"Seq::NegS", []string{"(3)"}}, {"Seq::NegS", []string{"null"}}, {"Seq::NegS", []string{"(1,2)"}}, + {"Seq::EqS", []string{"(1)"}}, {"Seq::EqS", []string{"null"}}, {"Seq::EqS", []string{"(1,2)"}}, {"Seq::EqS", []string{"2"}}, + {"Seq::EqS2", []string{"(1)"}}, {"Seq::EqS2", []string{"null"}}, {"Seq::EqS2", []string{"(1,2)"}}, + {"Seq::IdS", []string{"(1)"}}, {"Seq::IdS", []string{"null"}}, {"Seq::IdS", []string{"(1,2)"}}, + {"Seq::IdN", []string{"null"}}, {"Seq::IdN", []string{"1"}}, {"Seq::IdN", []string{"()"}}, + {"Seq::IdE", []string{"(1,2)", "(1,2)"}}, {"Seq::IdE", []string{"null", "null"}}, {"Seq::IdE", []string{"null", "1"}}, {"Seq::IdE", []string{"1", "(1)"}}, + {"Seq::IxS", []string{"(1,2)", "2"}}, {"Seq::IxS", []string{"(1,2)", "0"}}, {"Seq::IxS", []string{"(1,2)", "3"}}, {"Seq::IxS", []string{"null", "1"}}, {"Seq::IxS", []string{"4", "1"}}, + {"Seq::PowS", []string{"(3)"}}, {"Seq::PowS", []string{"(1,2)"}}, + {"Seq::WhS", []string{"false"}}, {"Seq::WhS", []string{"(false)"}}, {"Seq::WhS", []string{"null"}}, + {"Seq::RetS", []string{"(1,2)"}}, {"Seq::RetS", []string{"null"}}, {"Seq::RetS", []string{"(4)"}}, + {"Seq::LocS", []string{"(1,2)"}}, {"Seq::LocS", []string{"4"}}, + {"Seq::LocU", []string{"4"}}, {"Seq::LocU2", []string{"4"}}, + {"Seq::MinE", []string{"(3,1,2)"}}, {"Seq::MinE", []string{"null"}}, {"Seq::MinE", []string{"7"}}, + {"Seq::MinR", []string{"(3,1)"}}, + {"Seq::RedN", []string{"(1,2,3)"}}, {"Seq::RedN", []string{"null"}}, {"Seq::RedN", []string{"(7)"}}, + {"Seq::FA", []string{"(2,4)"}}, {"Seq::FA", []string{"null"}}, {"Seq::FA", []string{"(-1,0)"}}, + {"Seq::Col2", []string{"(1,2,3)"}}, {"Seq::Col2", []string{"null"}}, {"Seq::Col2", []string{"()"}}, + {"Seq::Sz", []string{"(1,2)"}}, {"Seq::Sz", []string{"null"}}, {"Seq::Sz", []string{"5"}}, + {"Seq::Hd", []string{"(1,2)"}}, {"Seq::Hd", []string{"null"}}, {"Seq::Hd", []string{"()"}}, + {"Seq::Hd2", []string{"(1,2)"}}, {"Seq::Hd2", []string{"null"}}, {"Seq::Hd2", []string{"()"}}, + {"Seq::CmpS", []string{"5", "(5)"}}, {"Seq::CmpS", []string{"5", "(5,5)"}}, {"Seq::CmpS", []string{"null", "null"}}, + {"Seq::CmpS", []string{"(1,2)", "(1,2)"}}, {"Seq::CmpS", []string{"(1,2)", "(1,3)"}}, {"Seq::CmpS", []string{"null", "()"}}, {"Seq::CmpS", []string{"()", "()"}}, + {"Seq::Nst", []string{"(1,2,3)"}}, {"Seq::Nst", []string{"null"}}, + {"Seq::Rng2", []string{"5"}}, {"Seq::Rng2", []string{"-2"}}, + {"Seq::M2", []string{"(1,2)"}}, {"Seq::M2", []string{"(1)"}}, {"Seq::M2", []string{"null"}}, {"Seq::M2", []string{"(1,2,3,4)"}}, + {"Seq::Ret2", []string{"(1,2)"}}, {"Seq::Ret2", []string{"(1)"}}, {"Seq::Ret2", []string{"null"}}, + {"Seq::Ass", []string{"(1,2)"}}, {"Seq::Ass", []string{"(1,2,3)"}}, {"Seq::Ass", []string{"null"}}, + {"Seq::LocM1", []string{"(1)"}}, {"Seq::LocM1", []string{"(1,2)"}}, {"Seq::LocM1", []string{"null"}}, + {"Seq::LocN", []string{"(1,2)"}}, {"Seq::LocN", []string{"(1,-2)"}}, {"Seq::LocN", []string{"null"}}, + {"Seq::LocAny2", []string{"(1,2)"}}, {"Seq::LocAny2", []string{"(1)"}}, {"Seq::LocAny2", []string{"(1,2,3)"}}, + {"Seq::Ret1", []string{"(1)"}}, {"Seq::Ret1", []string{"(1,2)"}}, {"Seq::Ret1", []string{"null"}}, + {"Seq::RetN", []string{"(1,2)"}}, {"Seq::RetN", []string{"(-1,2)"}}, {"Seq::RetN", []string{"null"}}, + {"Seq::Coal", []string{"(1,2)"}}, {"Seq::Coal", []string{"null"}}, {"Seq::Coal", []string{"(3)"}}, {"Seq::Coal", []string{"()"}}, + {"Seq::Tern", []string{"true", "(1,2)"}}, {"Seq::Tern", []string{"false", "(1,2)"}}, {"Seq::Tern", []string{"true", "null"}}, + {"Seq::Big", []string{"1000"}}, {"Seq::Big", []string{"1000000"}}, // the second exceeds the element budget + {"Seq::Big2", []string{"1000000"}}, + {"Seq::LazyAnd", []string{"(1,2)", "9"}}, {"Seq::LazyAnd", []string{"(1,2,3,4,5,6)", "9"}}, {"Seq::LazyAnd", []string{"(1,2,3,4,5,6)", "2"}}, + {"Seq::LazyOr", []string{"()", "9"}}, {"Seq::LazyOr", []string{"(1,2)", "9"}}, {"Seq::LazyOr", []string{"(1,-2)", "2"}}, + {"Seq::LazyImp", []string{"()", "9"}}, {"Seq::LazyImp", []string{"(1,2)", "9"}}, {"Seq::LazyImp", []string{"(1,2)", "1"}}, + {"Seq::LazyBig", []string{"()", "1000000"}}, {"Seq::LazyBig", []string{"(1)", "1000000"}}, {"Seq::LazyBig", []string{"(1)", "3"}}, + {"Seq::BodyLoc", []string{"(1,2,3)"}}, {"Seq::BodyLoc", []string{"null"}}, {"Seq::BodyLoc", []string{"4"}}, + {"Seq::BodySel", []string{"(1,2,3)"}}, {"Seq::BodySel", []string{"()"}}, + {"Seq::BodyUnread", []string{"(1,2,3)"}}, // the local is never read, so its failing initializer never runs + {"Seq::BodyCond", []string{"(1,2)"}}, {"Seq::BodyCond", []string{"(1,2,3)"}}, + {"Seq::BodyDoc", []string{"(1,2,3)"}}, {"Seq::BodyDoc", []string{"null"}}, + {"Seq::Cnt", []string{"(1,2,3)"}}, {"Seq::Cnt", []string{"null"}}, {"Seq::Cnt", []string{"(1,3)"}}, + {"Seq::IncAt", []string{"(1,2)", "1"}}, {"Seq::IncAt", []string{"(1,2)", "3"}}, {"Seq::IncAt", []string{"(1,2)", "4"}}, {"Seq::IncAt", []string{"null", "1"}}, {"Seq::IncAt", []string{"null", "2"}}, + {"Seq::Sub3", []string{"(1,2,3)", "1", "2"}}, {"Seq::Sub3", []string{"(1,2,3)", "2", "1"}}, {"Seq::Sub3", []string{"(1,2,3)", "0", "2"}}, {"Seq::Sub3", []string{"(1,2,3)", "1", "4"}}, {"Seq::Sub3", []string{"null", "1", "1"}}, + {"Seq::ExAt", []string{"(1,2,3)", "1", "2"}}, {"Seq::ExAt", []string{"(1,2,3)", "0", "2"}}, {"Seq::ExAt", []string{"null", "1", "1"}}, {"Seq::ExAt", []string{"4", "1", "1"}}, + {"Seq::ForR", []string{"(1,2,3)"}}, {"Seq::ForR", []string{"null"}}, {"Seq::ForR", []string{"4"}}, + {"Seq::Churn", []string{"0"}}, {"Seq::Churn", []string{"7"}}, + {"Seq::ChurnFor", []string{"0"}}, {"Seq::ChurnFor", []string{"9"}}, + {"Seq::ChurnNest", []string{"0"}}, {"Seq::ChurnNest", []string{"5"}}, + {"Seq::UntilSeq", []string{"1"}}, {"Seq::UntilSeq", []string{"50"}}, {"Seq::UntilSeq", []string{"1000"}}, + {"Seq::ForB", []string{"4"}}, {"Seq::ForB", []string{"0"}}, + {"Seq::RS", []string{"(1,2,3)"}}, {"Seq::RS", []string{"null"}}, {"Seq::RS", []string{"4"}}, + {"Seq::MaxS", []string{"(1.5,2.5)"}}, {"Seq::MaxS", []string{"null"}}, + {"Seq::UniqI", []string{"(2,3)"}}, {"Seq::UniqI", []string{"(2,2)"}}, {"Seq::UniqI", []string{"(1,2)"}}, {"Seq::UniqI", []string{"null"}}, + {"Seq::UniqR", []string{"(1.5,2.5)"}}, {"Seq::UniqR", []string{"(0.0,-0.0)"}}, {"Seq::UniqR", []string{"(1.5,2.5,1.5)"}}, + {"Seq::UniqB", []string{"(true,false)"}}, {"Seq::UniqB", []string{"(false,true,false)"}}, + {"Seq::UniqAs", []string{"3"}}, {"Seq::UniqAs", []string{"4"}}, + {"Seq::UniqLoc", []string{"(1,2)"}}, {"Seq::UniqLoc", []string{"(1,1)"}}, + {"Seq::UniqLocInit", []string{"(1,2)"}}, {"Seq::UniqLocInit", []string{"(1,1)"}}, + {"Seq::UniqLocFree", []string{"(1,2)"}}, {"Seq::UniqLocFree", []string{"(1,1)"}}, + {"Seq::UniqLocAny", []string{"(1,1)"}}, + {"Overloads::PickInt", []string{"7"}}, {"Overloads::PickReal", []string{"7.0"}}, {"Overloads::PickFlag", []string{"true"}}, + {"Overloads::PickQualified", []string{"7"}}, {"Overloads::PickQualified", []string{"-7"}}, + {"Fn::ApplySq", []string{"3.0"}}, {"Fn::ApplySq", []string{"1e200"}}, + {"Fn::ApplyUsage", []string{"3.0"}}, + {"Fn::ApplyRecip", []string{"4.0"}}, {"Fn::ApplyRecip", []string{"0.0"}}, + {"Fn::ApplySqrt", []string{"2.0"}}, {"Fn::ApplySqrt", []string{"-1.0"}}, + {"Fn::ApplyFloor", []string{"-2.5"}}, {"Fn::ApplyFloor", []string{"1e300"}}, + {"Fn::CondApply", []string{"true", "3.0"}}, {"Fn::CondApply", []string{"false", "3.0"}}, + {"Fn::PassSq", []string{"4.0"}}, + {"Fn::IterateSq", []string{"3", "2.0"}}, {"Fn::IterateSq", []string{"0", "2.0"}}, {"Fn::IterateSq", []string{"12", "2.0"}}, + {"Fn::TwoSq", []string{"4.0"}}, + {"Fn::ByNameSq", []string{"3.0"}}, + {"Fn::IntBoth", []string{"1"}}, {"Fn::IntBoth", []string{"2"}}, + {"Fn::Fold2Add", []string{"1.5", "2.0"}}, + {"Fn::TypedSq", []string{"3.0"}}, {"Fn::TypedUsage", []string{"3.0"}}, {"Fn::TypedSub", []string{"3.0"}}, + {"Fn::QualSq", []string{"3.0"}}, {"Fn::QualByNameSq", []string{"3.0"}}, + {"Fn::QualRecipRange", []string{"(1.0,2.0)"}}, {"Fn::QualRecipRange", []string{"(1.0,0.0,2.0)"}}, + {"Fn::SqRange", []string{"(1.0,2.0,3.0)"}}, {"Fn::SqRange", []string{"null"}}, {"Fn::SqRange", []string{"2.0"}}, {"Fn::SqRange", []string{"()"}}, + {"Fn::RecipRange", []string{"(1.0,2.0)"}}, {"Fn::RecipRange", []string{"(1.0,0.0,2.0)"}}, + {"Fn::HalfDomain", []string{"(1.0,2.0)"}}, {"Fn::HalfDomain", []string{"null"}}, {"Fn::HalfDomain", []string{"()"}}, {"Fn::HalfDomain", []string{"2.0"}}, + {"Fn::IncBoth", []string{"(1,2)"}}, {"Fn::IncBoth", []string{"null"}}, + {"Fn::QuarterRange", []string{"(1,1)"}}, {"Fn::QuarterRange", []string{"(1,2,3)"}}, + {"Fn::SqrtRange", []string{"(4.0,9.0)"}}, {"Fn::SqrtRange", []string{"(4.0,-1.0)"}}, + {"Fn::UsageRange", []string{"(3.0)"}}, + {"Fn::SumRange", []string{"(1.0,2.0)"}}, {"Fn::SumRange", []string{"null"}}, + {"Fn::ParenSample", []string{"(2.0,3.0)"}}, {"Fn::ParenDomain", []string{"(2.0,4.0)"}}, {"Fn::ParenDomain", []string{"(2.0,0.0)"}}, {"Fn::ParenApply", []string{"3.0"}}, + {"Fn::NullDomain", []string{"1.0"}}, {"Fn::NullRange", []string{"1"}}, {"Fn::NullSqrtRange", []string{"1.0"}}, {"Fn::NullSum", []string{"1.5"}}, + {"Fn::NullAbsRange", []string{"1"}}, {"Fn::NullAbsDomain", []string{"1"}}, + {"Fn::BodySample", []string{"(1,2,3)"}}, {"Fn::BodySample", []string{"null"}}, {"Fn::BodySample", []string{"0"}}, + {"Fn::BodySampleRecip", []string{"(2.0,4.0)"}}, {"Fn::BodySampleRecip", []string{"(2.0,1.0,4.0)"}}, + {"Fn::BodySampleUnread", []string{"(2.0,0.0)"}}, + // The recursion budget is 10000 frames: the entry calc, Sample, and Down n+1 times. + {"Fn::DownDirect", []string{"9998"}}, {"Fn::DownDirect", []string{"9999"}}, + {"Fn::DownRange", []string{"9997"}}, {"Fn::DownRange", []string{"9998"}}, + {"Fn::DownDomain", []string{"9997"}}, {"Fn::DownDomain", []string{"9998"}}, + // The domain is computed before Sample's frame: the entry calc and Deep n+1 times. + {"Fn::DeepRange", []string{"9998"}}, {"Fn::DeepRange", []string{"9999"}}, + {"Fn::DeepDomain", []string{"9998"}}, {"Fn::DeepDomain", []string{"9999"}}, +} + +// transcendental calcs call libm functions whose last bit is the library's, so the +// C target may differ from Go's math by an ulp; every other value must agree exactly. +var transcendental = map[string]bool{ + "Lib::Trig": true, "Lib::Arc": true, "Lib::Exp": true, "Lib::Log": true, "Lib::Atan2": true, "Lib::NamedLib": true, +} + +// withinUlps reports whether two printed Reals are at most n float64 steps apart. +func withinUlps(a, b string, n uint64) bool { + x, err1 := strconv.ParseFloat(a, 64) + y, err2 := strconv.ParseFloat(b, 64) + if err1 != nil || err2 != nil || (x < 0) != (y < 0) { + return false + } + bx, by := math.Float64bits(math.Abs(x)), math.Float64bits(math.Abs(y)) + if bx < by { + bx, by = by, bx + } + return bx-by <= n +} + +// failureClass is the part of a failure both surfaces spell the same way: the +// class of a scalar fault, else the whole message once the interpreter's +// context labels and the program's calc name are stripped. +func failureClass(calc, msg string) string { + for _, class := range []string{"arithmetic overflow", "arithmetic domain", "division by zero", "calc recursion limit exceeded", "typed by Natural", "typed by Positive", "requires Natural", "collection element limit exceeded"} { + if strings.Contains(msg, class) { + return class + } + } + msg = strings.TrimSpace(msg) + if _, rest, ok := strings.Cut(msg, "Compiled::"+calc+": "); ok { + msg = rest + } + for again := true; again; { + again = false + for _, label := range []string{"evaluating the returned expression: ", "calculation body: ", "result: "} { + if rest, ok := strings.CutPrefix(msg, label); ok { + msg, again = rest, true + } + } + } + return msg +} + +// interpreted answers a case with the interpreter: the value, or the failure. +func interpreted(t *testing.T, s *Session, c compiledCase) (value, failure string) { + t.Helper() + v := s.RunCalc("Compiled::" + c.calc + "(" + strings.Join(c.args, ", ") + ")") + if v.Status == VerdictHolds { + for _, line := range v.Lines { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "= "); ok { + return rest, "" + } + } + t.Fatalf("%s%v: no value in %q", c.calc, c.args, v.Lines) + } + return "", failureClass(c.calc, strings.Join(verdictLines(v), "\n")) +} + +// verdictLines is the verdict without its standing line, which no compiled program prints. +func verdictLines(v Verdict) []string { + var lines []string + for _, line := range v.Lines { + if !strings.HasPrefix(line, standingPrefix) { + lines = append(lines, line) + } + } + return lines +} + +// compiledRun answers a case with the executable: the value, or the failure. +func compiledRun(t *testing.T, exe string, c compiledCase) (value, failure string) { + t.Helper() + out, err := exec.Command(exe, c.args...).CombinedOutput() + text := strings.TrimSpace(string(out)) + if err == nil { + return text, "" + } + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 1 { + t.Fatalf("%s %v: %v\n%s", exe, c.args, err, text) + } + return "", failureClass(c.calc, text) +} + +// loadCompileFixture loads the fixture into a session whose step budget is +// lifted: compiled code has none, so the oracle must run each case to its end. +func loadCompileFixture(t testing.TB) *Session { + t.Helper() + data, err := os.ReadFile("testdata/compile_calcs.sysml") + if err != nil { + t.Fatal(err) + } + s := NewSession() + if errs := errorDiagnostics(s.Submit(string(data)).Diagnostics); len(errs) > 0 { + t.Fatalf("fixture has errors: %v", errs) + } + budgets := runtime.DefaultBudgets() + budgets.MaxSteps = 1 << 40 + if err := s.SetBudgets(budgets); err != nil { + t.Fatal(err) + } + return s +} + +// The compiled program computes what the interpreter computes, prints it as +// the interpreter prints it, and fails where the interpreter fails. +func TestCompiledCalcsAgreeWithInterpreter(t *testing.T) { + for _, target := range codegen.Targets() { + t.Run(string(target), func(t *testing.T) { + t.Parallel() + if target == codegen.TargetC { + if _, err := exec.LookPath("cc"); err != nil { + t.Skip("no C compiler on PATH") + } + } + s := loadCompileFixture(t) + exes := map[string]string{} + dir := t.TempDir() + for _, c := range compiledCases { + exe, built := exes[c.calc] + if !built { + program, err := s.CompileCalc("Compiled::" + c.calc) + if err != nil { + t.Fatalf("compile %s: %v", c.calc, err) + } + exe = filepath.Join(dir, c.calc) + if err := codegen.Build(program, target, exe); err != nil { + t.Fatalf("build %s: %v", c.calc, err) + } + exes[c.calc] = exe + } + wantValue, wantFailure := interpreted(t, s, c) + gotValue, gotFailure := compiledRun(t, exe, c) + if gotFailure == wantFailure && gotValue != wantValue && target == codegen.TargetC && transcendental[c.calc] && withinUlps(gotValue, wantValue, 2) { + continue + } + if gotValue != wantValue || gotFailure != wantFailure { + t.Errorf("%s(%s): compiled = (%q, %q), interpreted = (%q, %q)", + c.calc, strings.Join(c.args, ", "), gotValue, gotFailure, wantValue, wantFailure) + } + } + for _, repeat := range []string{"0", "-1", "x", "2x", ""} { + out, err := exec.Command(exes["Fib"], "--repeat", repeat, "10").CombinedOutput() + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 2 { + t.Errorf("--repeat %q: got %v %q, want usage and exit status 2", repeat, err, out) + } + } + for _, arg := range []string{"inf", "-inf", "nan", "1x", "", "0x1p-2", "0x1.8p1", "1_000.5", " 1.5", "1.5 ", "+", ".", "1e", "1e+", "Infinity"} { + out, err := exec.Command(exes["Hypot"], arg, "4.0").CombinedOutput() + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 2 || !strings.Contains(string(out), "is not a finite Real") { + t.Errorf("Hypot(%q, 4.0): got %v %q, want not a Real and exit status 2", arg, err, out) + } + } + if out, err := exec.Command(exes["Fib"], "--repeat", "3", "10").Output(); err != nil || strings.TrimSpace(string(out)) != "55" { + t.Errorf("--repeat 3: got %q, %v", out, err) + } + }) + } +} + +// The element budget charges the arguments a run holds, as the interpreter's +// charges the literals it evaluates them from; a lowered OPENSYSML_MAX_ELEMENTS +// makes that visible with small inputs. The Real copy an Integer collection +// widens to is charged too: the interpreter keeps Integers in Real slots and +// holds no copy, so at the limit the program fails where the interpreter runs, +// never the reverse. +func TestCompiledBudgetChargesInputsAndWidening(t *testing.T) { + const limit = 10 + cases := []compiledCase{ + {"Seq::BigIn", []string{"(1,2,3,4,5,6,7,8,9,10)"}}, {"Seq::BigIn", []string{"(1,2,3,4,5,6,7,8,9,10,11)"}}, + {"Seq::LazyBig", []string{"()", "100"}}, {"Seq::LazyBig", []string{"(1)", "100"}}, + // A Domain or Range read collects a sequence of its own: 8 held plus 2 more fit, 9 plus 2 do not. + {"Fn::SizeDomain", []string{"2", "8"}}, {"Fn::SizeDomain", []string{"2", "9"}}, + {"Fn::SizeRange", []string{"2", "8"}}, {"Fn::SizeRange", []string{"2", "9"}}, + // Two pairs are 6 elements over a domain of 2 selected from 2 (10 fit) or from 3 (11 do not). + {"Fn::SamplePairs", []string{"2"}}, {"Fn::SamplePairs", []string{"3"}}, + } + // 5 Integers and their 5 Reals fit the budget of 10; 6 and 6 do not. + widened := []struct{ n, sum, want, failure string }{{"5", "15.0", "15.0", ""}, {"6", "21.0", "", "collection element limit exceeded"}} + for _, target := range codegen.Targets() { + t.Run(string(target), func(t *testing.T) { + if target == codegen.TargetC { + if _, err := exec.LookPath("cc"); err != nil { + t.Skip("no C compiler on PATH") + } + } + s := loadCompileFixture(t) + budgets := runtime.DefaultBudgets() + budgets.MaxSteps = 1 << 40 + budgets.MaxElements = limit + if err := s.SetBudgets(budgets); err != nil { + t.Fatal(err) + } + t.Setenv(runtime.MaxElementsEnvVar, strconv.Itoa(limit)) + dir := t.TempDir() + exes := map[string]string{} + for _, c := range cases { + exe, built := exes[c.calc] + if !built { + program, err := s.CompileCalc("Compiled::" + c.calc) + if err != nil { + t.Fatalf("compile %s: %v", c.calc, err) + } + exe = filepath.Join(dir, c.calc) + if err := codegen.Build(program, target, exe); err != nil { + t.Fatalf("build %s: %v", c.calc, err) + } + exes[c.calc] = exe + } + wantValue, wantFailure := interpreted(t, s, c) + gotValue, gotFailure := compiledRun(t, exe, c) + if gotValue != wantValue || gotFailure != wantFailure { + t.Errorf("%s(%s): compiled = (%q, %q), interpreted = (%q, %q)", + c.calc, strings.Join(c.args, ", "), gotValue, gotFailure, wantValue, wantFailure) + } + } + // The arguments stay charged across repeats: the second run must not find + // room the first did not have. + out, err := exec.Command(exes["Seq::BigIn"], "--repeat", "3", "(1,2,3,4,5,6,7,8,9,10,11)").CombinedOutput() + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 1 || !strings.Contains(string(out), "element limit exceeded") { + t.Errorf("--repeat over budget: got %v %q, want the element limit failure", err, out) + } + if out, err := exec.Command(exes["Seq::BigIn"], "--repeat", "3", "(1,2,3,4,5,6,7,8,9,10)").Output(); err != nil || strings.TrimSpace(string(out)) != "10" { + t.Errorf("--repeat within budget: got %q, %v", out, err) + } + program, err := s.CompileCalc("Compiled::Seq::BigW") + if err != nil { + t.Fatal(err) + } + exe := filepath.Join(dir, "BigW") + if err := codegen.Build(program, target, exe); err != nil { + t.Fatal(err) + } + for _, w := range widened { + c := compiledCase{"Seq::BigW", []string{w.n}} + if v, f := compiledRun(t, exe, c); v != w.want || f != w.failure { + t.Errorf("BigW(%s): compiled = (%q, %q), want (%q, %q)", w.n, v, f, w.want, w.failure) + } + if v, f := interpreted(t, s, c); v != w.sum || f != "" { + t.Errorf("BigW(%s): interpreted = (%q, %q), want (%q, \"\"): it holds no widened copy", w.n, v, f, w.sum) + } + } + }) + } +} + +// A C loop's memory is bounded by what it keeps live, not by how many passes +// it makes: gigabytes of dead temporaries complete under a 64 MB limit. +func TestCompiledCLoopMemoryIsBounded(t *testing.T) { + if _, err := exec.LookPath("cc"); err != nil { + t.Skip("no C compiler on PATH") + } + if out, err := exec.Command("sh", "-c", "ulimit -v 65536").CombinedOutput(); err != nil { + t.Skipf("no address-space limit here: %v %s", err, out) + } + s := loadCompileFixture(t) + dir := t.TempDir() + for calc, arg := range map[string]string{"Churn": "50000", "ChurnFor": "50000", "ChurnNest": "10000"} { + program, err := s.CompileCalc("Compiled::Seq::" + calc) + if err != nil { + t.Fatal(err) + } + exe := filepath.Join(dir, calc) + if err := codegen.Build(program, codegen.TargetC, exe); err != nil { + t.Fatal(err) + } + out, err := exec.Command("sh", "-c", `ulimit -v 65536 && exec "$0" "$1"`, exe, arg).CombinedOutput() + if err != nil { + t.Errorf("%s(%s) under a 64 MB limit: %v\n%s", calc, arg, err, out) + } + } +} + +// A calc outside the subset is refused with the reason, never compiled wrong. +func TestCompileRefusesWhatItCannotCompile(t *testing.T) { + s := loadCompileFixture(t) + for _, tc := range []struct{ calc, reason string }{ + {"StringResult", "String"}, + {"Defaulted", "default value"}, + {"OutOnly", "`out`"}, + {"RealToNatural", "requires Integer arguments"}, + {"Refined", "members of its own"}, + {"DynamicIntPow", "non-literal Integer exponent"}, + {"Narrowed", "a Real bound to x, which is Integer"}, + {"RecordParam", "type Refused::Point is not Integer, Real or Boolean"}, + {"EnumParam", "type Refused::Color is not Integer, Real or Boolean"}, + {"Extent", "operator 'all'"}, + {"RealIntIdentity", "'===' between Real and Integer"}, + {"SelectNonBoolean", "select whose body yields Integer, not a Boolean"}, + {"CollectNull", "collect whose body yields null"}, + {"CoalesceWidened", "a Real at the right operand of '??', which holds Integer[0..*]"}, + {"MixedEquality", "a Integer[0..*] at the left operand of '==', which holds Real[0..*]"}, + {"MixedSame", "same over Integer and Real collections"}, + {"MixedUnion", "union over Integer and Real collections"}, + {"CalcParam", "parameter f binds a function value, which a program cannot take on its command line"}, + {"EscapingParam", "the function value f escaping as the result"}, + {"ReturnedFunction", "an invocation where a function value is expected"}, + {"FunctionEquality", "the function value f where a value is expected"}, + {"FunctionAsValue", "the function value Refused::Sq where a value is expected"}, + {"ValueAsFunction", "a, a Real, where a function value is expected"}, + {"ChosenFunction", "an `if` choosing a function value at run time"}, + {"WrongArity", "Refused::Add2 takes 2 arguments, 1 given"}, + {"WrongName", "Refused::Sq2 has no parameter v"}, + {"UnrelatedTyped", `in calc Refused::UnrelatedTyped: argument for parameter "f": cannot bind the function value Refused::Sq2 to a parameter typed by Compiled::Fn::Sq`}, + {"LibraryTyped", "cannot bind the function value RealFunctions::sqrt to a parameter typed by Compiled::Fn::Sq"}, + {"GeneralForSub", "cannot bind the function value Compiled::Fn::Sq to a parameter typed by Compiled::Fn::SubSq"}, + {"ForwardedUnrelated", "cannot bind the function value Refused::Sq2 to a parameter typed by Compiled::Fn::Sq"}, + {"IntegerNullRange", "a Real[0..*] at result, which holds Integer[0..*]"}, + {"BodyClosure", "a body-local calc usage"}, + {"OuterClosure", "a calc declared in the body of Refused::BodyClosure, whose function value closes over that run's bindings"}, + {"ObjectClosure", "a calc read off an object through a feature chain, whose function value closes over that object"}, + {"ObjectCalc", "a calc owned by Refused::Scaler, whose function value closes over that object"}, + {"ReceiverQualified", "an invocation of a function value with a receiver (`x->f()`)"}, + {"ForeignQualified", "in calc Compiled::Fn::ApplyQual::f: an `in calc` parameter invoked outside the body of the calc declaring it"}, + {"SampleArity", "Sample of Refused::Add2, which does not take one value argument"}, + {"SampledValue", "s, a SampledFunction, where a value is expected"}, + {"SampledEscapes", "type SampledFunctions::SampledFunction is not Integer, Real or Boolean"}, + {"UnevaluatedFunction", "ControlFunctions::collect binds its arguments unevaluated and cannot be read as a value"}, + {"SetParam", "type Collections::Set is not Integer, Real or Boolean"}, + {"SetElements", "type Collections::Set is not Integer, Real or Boolean"}, + {"SetLocal", "type Collections::Set is not Integer, Real or Boolean"}, + {"SubsetLocal", "attribute ys redefines or subsets a feature, inheriting a shape it does not state"}, + {"TensorParam", "type Quantities::TensorQuantityValue is not Integer, Real or Boolean"}, + {"TensorBuilt", "type Quantities::TensorQuantityValue is not Integer, Real or Boolean"}, + {"MetaCast", "a `meta` cast, whose metaobject reflects a model element and has no native representation"}, + } { + _, err := s.CompileCalc("Refused::" + tc.calc) + if err == nil { + t.Errorf("%s: compiled, want a refusal mentioning %q", tc.calc, tc.reason) + continue + } + if !errors.Is(err, codegen.ErrUnsupported) { + t.Errorf("%s: %v is not an ErrUnsupported", tc.calc, err) + } + if !strings.Contains(err.Error(), tc.reason) { + t.Errorf("%s: %v does not mention %q", tc.calc, err, tc.reason) + } + } + if _, err := s.CompileCalc("Compiled::Nowhere"); err == nil { + t.Error("an unknown calc compiled") + } +} + +// A call several visible declarations fit equally is refused, naming them, +// as the checker and the interpreter report it. +func TestCompileRefusesAmbiguousCall(t *testing.T) { + s := NewSession() + s.Submit(`package Amb { + private import ScalarValues::*; + package A { calc def pick { in x : Integer; return : Integer = 1; } } + package B { calc def pick { in x : Integer; return : Integer = 2; } } + private import A::*; + private import B::*; + calc def Pick { in n : Integer; return : Integer = pick(n); } + }`) + _, err := s.CompileCalc("Amb::Pick") + if !errors.Is(err, codegen.ErrUnsupported) { + t.Fatalf("got %v, want an ErrUnsupported refusal", err) + } + for _, want := range []string{"ambiguous", "Amb::A::pick", "Amb::B::pick"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%v does not mention %q", err, want) + } + } +} + +// A call binding one parameter by name twice is refused by the type checker; +// the native target declines it rather than binding the later value. +func TestCompileRefusesAParameterBoundTwice(t *testing.T) { + s := NewSession() + res := s.Submit(`package Twice { + private import ScalarValues::*; + calc def Add { in a : Integer; in b : Integer; return : Integer = a + b; } + calc def Dup { in x : Integer; return : Integer = Add(a = x, a = 1, b = 2); } + }`) + errs := errorDiagnostics(res.Diagnostics) + if len(errs) != 1 || !strings.Contains(errs[0].Message, `Add binds parameter "a" twice`) { + t.Fatalf("diagnostics = %v, want Add binds parameter \"a\" twice", errs) + } + _, err := s.CompileCalc("Twice::Dup") + if !errors.Is(err, codegen.ErrUnsupported) || !strings.Contains(err.Error(), "binds parameter a twice") { + t.Fatalf("CompileCalc(Twice::Dup) = %v, want an ErrUnsupported naming the parameter", err) + } +} + +// The generated source is deterministic and names the calc it came from. +func TestCompiledSourceNamesTheCalc(t *testing.T) { + s := loadCompileFixture(t) + program, err := s.CompileCalc("Compiled::Fib") + if err != nil { + t.Fatal(err) + } + for _, target := range codegen.Targets() { + src, err := codegen.Source(program, target) + if err != nil { + t.Fatal(err) + } + again, _ := codegen.Source(program, target) + if string(src) != string(again) { + t.Errorf("%s: two renderings differ", target) + } + if !strings.Contains(string(src), "Compiled::Fib") { + t.Errorf("%s: source does not name Compiled::Fib", target) + } + } +} diff --git a/internal/repl/complete.go b/internal/frontend/repl/complete.go similarity index 94% rename from internal/repl/complete.go rename to internal/frontend/repl/complete.go index d3567bd73c..754b785bf5 100644 --- a/internal/repl/complete.go +++ b/internal/frontend/repl/complete.go @@ -8,10 +8,11 @@ import ( "unicode" "unicode/utf8" - "github.com/Open-MBEE/OpenSysML/internal/core/lexer" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" - "github.com/Open-MBEE/OpenSysML/internal/core/view" + "github.com/Open-MBEE/OpenSysML/internal/exec/objref" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // completionLimit bounds a completion answer: the library registers tens of @@ -383,7 +384,7 @@ func (s *Session) peekObject(text string) (objectShape, bool) { if s.rtCtx == nil { return objectShape{}, false } - ref, err := parseObjectRef(text) + ref, err := objref.Parse(text) if err != nil { return objectShape{}, false } @@ -392,12 +393,12 @@ func (s *Session) peekObject(text string) (objectShape, bool) { rest []objectSegment ok bool ) - if ref.id > 0 { + if ref.ID > 0 { // A connector a carry-over set aside is offered by id but not built to // complete a path from. - root, _ = s.heldByID(ref.id) + root, _ = s.heldByID(ref.ID) ok = root != nil - rest = ref.segments + rest = ref.Segments } else { var rerr error root, _, rest, rerr = s.namedRoot(ref) @@ -408,18 +409,18 @@ func (s *Session) peekObject(text string) (objectShape, bool) { } shape := objectShape{inst: root, typ: root.Type} for _, seg := range rest { - feat := featureNamed(s.rtCtx.FeaturesOf(shape.typ), seg.name) - if feat == nil || feat.Scalar() != (seg.index == 0) { + feat := featureNamed(s.rtCtx.FeaturesOf(shape.typ), seg.Name) + if feat == nil || feat.Scalar() != (seg.Index == 0) { return objectShape{}, false } - if fv := heldFeatureValue(shape.inst, seg.name); fv != nil { + if fv := heldFeatureValue(shape.inst, seg.Name); fv != nil { val := fv.Value - if seg.index > 0 { - elements := collectionElements(fv.Values) - if seg.index > len(elements) { + if seg.Index > 0 { + elements := objref.CollectionElements(fv.Values) + if seg.Index > len(elements) { return objectShape{}, false } - val = elements[seg.index-1] + val = elements[seg.Index-1] } id, isObject := val.Object() if !isObject { @@ -433,7 +434,7 @@ func (s *Session) peekObject(text string) (objectShape, bool) { continue } typ := s.objectTypeOf(feat) - if typ == nil || max(seg.index, 1) > s.elementCount(shape, feat) { + if typ == nil || max(seg.Index, 1) > s.elementCount(shape, feat) { return objectShape{}, false } shape = objectShape{typ: typ} @@ -453,7 +454,7 @@ func (s *Session) holdsObjects(shape objectShape, feat *runtime.EffectiveFeature _, isObject := fv.Value.Object() return isObject } - for _, el := range collectionElements(fv.Values) { + for _, el := range objref.CollectionElements(fv.Values) { if _, isObject := el.Object(); isObject { return true } @@ -472,7 +473,7 @@ func (s *Session) objectTypeOf(feat *runtime.EffectiveFeature) *symbols.Symbol { if typ := s.rtCtx.CompositeTypeOf(feat); typ != nil { return typ } - if s.rtCtx.Semantics().IsConnectorUsage(feat.Symbol) { + if s.rtCtx.Semantics().IsConnectorObjectUsage(feat.Symbol) { return feat.Symbol } return nil @@ -514,7 +515,7 @@ func (s *Session) featureCompletions(shape objectShape, prefix, partial string) if feat.Name == "" || !s.holdsObjects(shape, feat) { continue } - name := prefix + lexer.NameText(feat.Name) + name := prefix + source.NameText(feat.Name) if feat.Scalar() { candidates = append(candidates, name) continue @@ -551,7 +552,7 @@ func (s *Session) elementsToHold(shape objectShape, feat *runtime.EffectiveFeatu } return 0 } - return len(collectionElements(fv.Values)) + return len(objref.CollectionElements(fv.Values)) } if reading[feat.Name] || s.objectTypeOf(feat) == nil { return 0 @@ -600,7 +601,7 @@ func (s *Session) nameCompletions(word string) []string { } for _, name := range s.declaredSymbolNames() { - add(lexer.NameText(name)) + add(source.NameText(name)) } for _, b := range runtime.Builtins() { add(b.Name) diff --git a/internal/repl/complete_test.go b/internal/frontend/repl/complete_test.go similarity index 100% rename from internal/repl/complete_test.go rename to internal/frontend/repl/complete_test.go diff --git a/internal/repl/complex_test.go b/internal/frontend/repl/complex_test.go similarity index 100% rename from internal/repl/complex_test.go rename to internal/frontend/repl/complex_test.go diff --git a/internal/frontend/repl/conformance.go b/internal/frontend/repl/conformance.go new file mode 100644 index 0000000000..fbe884bfff --- /dev/null +++ b/internal/frontend/repl/conformance.go @@ -0,0 +1,41 @@ +package repl + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" +) + +// ConformanceMode reports the strictness the session judges notation at. +func (s *Session) ConformanceMode() diag.ConformanceMode { + defer s.reading()() + return s.ws.ConformanceMode() +} + +// SetConformanceMode switches what the session asks of its model: whether +// notation no SysML v2 production admits is a warning or an error. It takes +// effect at once — the buffer is re-analyzed on the next request. +func (s *Session) SetConformanceMode(mode diag.ConformanceMode) { + defer s.enter()() + s.ws.SetConformanceMode(mode) +} + +// doStrict shows or sets the conformance mode, reporting what the buffer looks +// like under it: a mode change is asked in order to see its answer. +func (s *Session) doStrict(args []string) []string { + if len(args) == 0 { + return []string{fmt.Sprintf("strict: %s", onOff(s.ws.ConformanceMode().IsStrict()))} + } + var mode diag.ConformanceMode + switch args[0] { + case "on": + mode = diag.ConformanceStrict + case "off": + mode = diag.ConformanceDefault + default: + return []string{fmt.Sprintf("error: unknown strict setting %q (want on or off)", args[0])} + } + s.ws.SetConformanceMode(mode) + out := []string{fmt.Sprintf("strict: %s", onOff(mode.IsStrict()))} + return append(out, s.diagnosticLines()...) +} diff --git a/internal/frontend/repl/conformance_test.go b/internal/frontend/repl/conformance_test.go new file mode 100644 index 0000000000..3c5c2f233c --- /dev/null +++ b/internal/frontend/repl/conformance_test.go @@ -0,0 +1,117 @@ +package repl + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" +) + +// stateExtension is notation of ours: a warning at the prompt by default, an +// error when the prompt is asked strictly. +const stateExtension = "attribute def Alarm; state def S { state a { defer Alarm; } }\n" + +func TestStrictMetaCommandReportsAndSetsTheMode(t *testing.T) { + s := NewSession() + if got := meta(t, s, "%strict"); len(got) == 0 || !strings.Contains(got[0], "off") { + t.Fatalf("%%strict = %v, want it to report off", got) + } + if got := meta(t, s, "%strict on"); len(got) == 0 || !strings.Contains(got[0], "on") { + t.Fatalf("%%strict on = %v, want it to report on", got) + } + if s.ConformanceMode() != diag.ConformanceStrict { + t.Fatalf("mode = %v, want strict", s.ConformanceMode()) + } + if got := meta(t, s, "%strict off"); len(got) == 0 || !strings.Contains(got[0], "off") { + t.Fatalf("%%strict off = %v, want it to report off", got) + } + if s.ConformanceMode() != diag.ConformanceDefault { + t.Fatalf("mode = %v, want default", s.ConformanceMode()) + } +} + +func TestStrictMetaCommandRejectsAnUnknownSetting(t *testing.T) { + s := NewSession() + got := meta(t, s, "%strict maybe") + if len(got) != 1 || !strings.Contains(got[0], "error") { + t.Fatalf("%%strict maybe = %v, want one error line", got) + } + if s.ConformanceMode() != diag.ConformanceDefault { + t.Fatal("a rejected setting must leave the mode alone") + } +} + +// The mode decides what the same submission weighs. +func TestStrictModeEscalatesNotationAtThePrompt(t *testing.T) { + s := NewSession() + s.Submit(stateExtension) + if notationErrors(s.Diagnostics()) != 0 { + t.Fatalf("default mode errored: %v", s.Diagnostics()) + } + strict := NewSession() + meta(t, strict, "%strict on") + strict.Submit(stateExtension) + if notationErrors(strict.Diagnostics()) == 0 { + t.Fatalf("strict mode reported no notation error: %v", strict.Diagnostics()) + } +} + +// Switching the mode re-reports the buffer instead of serving the other mode's +// verdict from the cache. +func TestStrictMetaCommandRepeatsTheDiagnostics(t *testing.T) { + s := NewSession() + s.Submit(stateExtension) + out := strings.Join(meta(t, s, "%strict on"), "\n") + if !strings.Contains(out, passes.CodeNonstandardNotation) && !strings.Contains(out, "defer") { + t.Fatalf("%%strict on = %q, want the buffer's findings restated", out) + } +} + +// A bare import is an error about the writing, so the model it names still runs +// by default; asked strictly, the file is rejected and nothing runs. +func TestNotationErrorStopsTheRunOnlyWhenAskedStrictly(t *testing.T) { + const bareImport = "package Q { part def A; }\npackage P { import Q::*; part def X; }\n" + s := NewSession() + s.Submit(bareImport) + if !hasImportError(s.Diagnostics()) { + t.Fatalf("the bare import reported no error: %v", s.Diagnostics()) + } + if s.HasErrors() { + t.Errorf("default mode refused to run a model whose only error is notation: %v", s.Diagnostics()) + } + strict := NewSession() + meta(t, strict, "%strict on") + strict.Submit(bareImport) + if !strict.HasErrors() { + t.Errorf("strict mode ran a file it rejects: %v", strict.Diagnostics()) + } +} + +func meta(t *testing.T, s *Session, line string) []string { + t.Helper() + out, quit, err := s.runMeta(line) + if err != nil || quit { + t.Fatalf("%s: err=%v quit=%v", line, err, quit) + } + return out +} + +func hasImportError(diags []diag.Diagnostic) bool { + for _, d := range diags { + if d.Severity == diag.SeverityError && d.Code == "import-visibility" { + return true + } + } + return false +} + +func notationErrors(diags []diag.Diagnostic) int { + var n int + for _, d := range diags { + if d.Severity == diag.SeverityError && d.Code == passes.CodeNonstandardNotation { + n++ + } + } + return n +} diff --git a/internal/repl/continuation.go b/internal/frontend/repl/continuation.go similarity index 88% rename from internal/repl/continuation.go rename to internal/frontend/repl/continuation.go index 1426607e40..291370c49b 100644 --- a/internal/repl/continuation.go +++ b/internal/frontend/repl/continuation.go @@ -1,8 +1,8 @@ package repl import ( - "github.com/Open-MBEE/OpenSysML/internal/core/lexer" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/syntax/lexer" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) // needsContinuation reports whether buf has unbalanced open brackets, i.e. the diff --git a/internal/repl/continuation_test.go b/internal/frontend/repl/continuation_test.go similarity index 100% rename from internal/repl/continuation_test.go rename to internal/frontend/repl/continuation_test.go diff --git a/internal/frontend/repl/cookbook_states_test.go b/internal/frontend/repl/cookbook_states_test.go new file mode 100644 index 0000000000..f17d7e9122 --- /dev/null +++ b/internal/frontend/repl/cookbook_states_test.go @@ -0,0 +1,57 @@ +package repl + +import ( + "path/filepath" + "testing" +) + +// TestCookbookStateAndEventRecipes drives the manual's cookbook model as the +// cookbook does and checks the recipes print the rows it quotes. +func TestCookbookStateAndEventRecipes(t *testing.T) { + s := NewSession() + source := filepath.Join("..", "..", "..", "docs", "manual", "examples", "cookbook.sysml") + if _, err := s.LoadFile(source); err != nil { + t.Fatalf("load cookbook: %v", err) + } + for _, cmd := range []string{ + "%trace on", + "%instantiate dome", + "%instantiate spareDome", + "%state control dome", + "%send Open", + "%advance 1", + "%send Slew(azimuth = 120.0)", + "%advance 1", + "%send Open to spareDome", + "%advance 0.5", + "%send Close to spareDome", + "%advance 0.5", + } { + run(t, s, cmd) + } + wants(t, run(t, s, "%run-query DomeStates root=#1"), + "✓ Query Cookbook::DomeStates returned 2 rows", + "Columns: machine, statePath, region, enclosing", + "Row 1: #1.control in open.slewing", + `statePath = "open.slewing"`, + `region = "pointing"`, + `enclosing = "open"`, + "Row 2: #1.control in open.opened", + `region = "shutter"`) + wants(t, run(t, s, "%run-query Opened"), + "✓ Query Cookbook::Opened returned 1 row", + "Row 1: Cookbook::dome (#1)", + `qualifiedName = "Cookbook::dome"`) + wants(t, run(t, s, "%run-query Accepted root=#1"), + "✓ Query Cookbook::Accepted returned 1 row", + "Row 1: t=1 Cookbook::dome.control: accept Slew", + "time = 1.0 [s]", + `event = "Slew"`, + `payload = "azimuth = 120.0"`) + wants(t, run(t, s, "%run-query Accepted root=spareDome"), + "✓ Query Cookbook::Accepted returned 1 row", + "Row 1: t=2 Cookbook::spareDome.control: accept Open", + "time = 2.0 [s]", + `event = "Open"`, + "payload = (none)") +} diff --git a/internal/repl/coordinate_frame_test.go b/internal/frontend/repl/coordinate_frame_test.go similarity index 100% rename from internal/repl/coordinate_frame_test.go rename to internal/frontend/repl/coordinate_frame_test.go diff --git a/internal/repl/declnames.go b/internal/frontend/repl/declnames.go similarity index 96% rename from internal/repl/declnames.go rename to internal/frontend/repl/declnames.go index 6f481b4d04..582b56d849 100644 --- a/internal/repl/declnames.go +++ b/internal/frontend/repl/declnames.go @@ -1,6 +1,6 @@ package repl -import "github.com/Open-MBEE/OpenSysML/internal/core/ast" +import "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" // declaredNames returns the replaceable top-level names introduced by a parsed // submission, in source order. A named declaration is replaceable: re-typing it diff --git a/internal/repl/declnames_test.go b/internal/frontend/repl/declnames_test.go similarity index 82% rename from internal/repl/declnames_test.go rename to internal/frontend/repl/declnames_test.go index d58fe9600d..0111a72f1e 100644 --- a/internal/repl/declnames_test.go +++ b/internal/frontend/repl/declnames_test.go @@ -3,9 +3,9 @@ package repl import ( "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" - "github.com/Open-MBEE/OpenSysML/internal/core/parser" - "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) func parseRoot(src string) *ast.RootNamespace { diff --git a/internal/repl/discover.go b/internal/frontend/repl/discover.go similarity index 97% rename from internal/repl/discover.go rename to internal/frontend/repl/discover.go index 1ca6a1a18d..f716b4ecb3 100644 --- a/internal/repl/discover.go +++ b/internal/frontend/repl/discover.go @@ -6,10 +6,10 @@ import ( "sort" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/model" - "github.com/Open-MBEE/OpenSysML/internal/core/runtime" - "github.com/Open-MBEE/OpenSysML/internal/core/suggest" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/suggest" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // searchLimit bounds a %search listing: the library declares thousands of @@ -56,7 +56,7 @@ func (s *Session) doSearch(substr string) ([]string, bool, error) { matches = append(matches, match{ fqn: fqn, kind: sym.Notation(), - onName: strings.Contains(suggest.LastSegment(lower), want), + onName: strings.Contains(symbols.LastSegment(lower), want), }) } if len(matches) == 0 { @@ -132,7 +132,7 @@ func (s *Session) suggestSymbol(name string) []string { if idx == nil { return nil } - simple := suggest.LastSegment(name) + simple := symbols.LastSegment(name) if hits := suggest.Nearest(simple, s.declaredSymbolNames()); len(hits) > 0 { return hits } diff --git a/internal/repl/discover_test.go b/internal/frontend/repl/discover_test.go similarity index 100% rename from internal/repl/discover_test.go rename to internal/frontend/repl/discover_test.go diff --git a/internal/frontend/repl/docquery.go b/internal/frontend/repl/docquery.go new file mode 100644 index 0000000000..0a89919431 --- /dev/null +++ b/internal/frontend/repl/docquery.go @@ -0,0 +1,444 @@ +package repl + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +// runQueryUsage is what %run-query accepts: a document query's name and its +// entry bindings. +const runQueryUsage = "usage: %run-query [= ...]" + +// RunDocumentQuery compiles the named document query, binds its entry +// parameters and executes it. invocation is what `%run-query` takes: a name +// followed by `=` bindings. +func (s *Session) RunDocumentQuery(invocation string) Verdict { + defer s.enter()() + fields := splitQueryArgs(strings.TrimSpace(invocation)) + if len(fields) == 0 { + return s.withTrace(unresolvedVerdict(invocation, "a document query to run must be named")) + } + name := fields[0] + lines, values, err := s.runDocumentQuery(name, fields[1:]) + if err != nil { + return s.withTrace(unresolvedVerdict(name, err.Error())) + } + return s.withTrace(Verdict{Subject: name, Status: VerdictHolds, Lines: lines, Values: values}) +} + +// doRunQuery carries out %run-query, reporting a query that could not be run +// the way the prompt reports any command it cannot carry out. +func (s *Session) doRunQuery(invocation string) ([]string, bool, error) { + fields := splitQueryArgs(strings.TrimSpace(invocation)) + if len(fields) == 0 { + return []string{runQueryUsage}, false, nil + } + lines, _, err := s.runDocumentQuery(fields[0], fields[1:]) + if err != nil { + return []string{errPrefix + err.Error()}, false, nil + } + return lines, false, nil +} + +// splitQueryArgs splits a %run-query invocation on whitespace, keeping quoted +// text together with its quotes so a binding expression parses as written. +func splitQueryArgs(line string) []string { + var args []string + var current strings.Builder + quote := rune(0) + escaped := false + for _, r := range line { + switch { + case escaped: + current.WriteRune(r) + escaped = false + case r == '\\': + current.WriteRune(r) + escaped = true + case quote != 0: + current.WriteRune(r) + if r == quote { + quote = 0 + } + case r == '"' || r == '\'': + quote = r + current.WriteRune(r) + case r == ' ' || r == '\t': + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + if current.Len() > 0 { + args = append(args, current.String()) + } + return args +} + +// regroupBindings joins tokens back into `=` bindings, +// so an expression may contain unquoted spaces. A new binding starts at a +// `name=` token outside any bracket; other tokens extend the one before. +func regroupBindings(tokens []string) []string { + var out []string + depth := 0 + for _, token := range tokens { + if depth <= 0 && (startsBinding(token) || len(out) == 0) { + out = append(out, token) + } else { + out[len(out)-1] += " " + token + } + depth += bracketDelta(token) + } + return out +} + +// startsBinding reports whether a token opens a `=` +// binding: an identifier followed by a single `=`. +func startsBinding(token string) bool { + i := strings.IndexRune(token, '=') + if i <= 0 || (i+1 < len(token) && token[i+1] == '=') { + return false + } + for j, r := range token[:i] { + alpha := r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + if !alpha && (j == 0 || r < '0' || r > '9') { + return false + } + } + return true +} + +// bracketDelta counts a token's unquoted bracket openings minus closings. +func bracketDelta(token string) int { + delta := 0 + quote := rune(0) + escaped := false + for _, r := range token { + switch { + case escaped: + escaped = false + case r == '\\': + escaped = true + case quote != 0: + if r == quote { + quote = 0 + } + case r == '"' || r == '\'': + quote = r + case r == '(' || r == '[' || r == '{': + delta++ + case r == ')' || r == ']' || r == '}': + delta-- + } + } + return delta +} + +// runDocumentQuery resolves the named query definition, compiles it to a plan, +// binds the arguments and executes the plan against the session's model. +func (s *Session) runDocumentQuery(name string, args []string) ([]string, []NamedValue, error) { + sym, fqn, err := s.lookupSymbol(name) + if err != nil { + return nil, nil, err + } + ctx, err := s.getOrCreateRuntime() + if err != nil { + return nil, nil, fmt.Errorf("runtime init: %w", err) + } + idx := s.browseIndex() + model, resolver := ctx.Semantics(), ctx.Resolver() + if !queryplan.IsQueryDefinition(idx, model, sym) { + return nil, nil, fmt.Errorf("%s is not a document query: one is a calc def specializing DocumentQueries::Query", notationName(fqn)) + } + program, err := queryplan.Compile(idx, model, resolver, sym) + if err != nil { + return nil, nil, err + } + bindings, err := s.queryBindings(ctx, regroupBindings(args)) + if err != nil { + return nil, nil, err + } + result, err := queryexec.Execute(program, s.queryContext(ctx), bindings, queryexec.Options{}) + if err != nil { + return nil, nil, err + } + lines, values := renderRowSet(notationName(fqn), result) + return lines, values, nil +} + +// queryContext is the context the session's queries execute in: its model, and +// the objects it holds under the labels it reports them by. +func (s *Session) queryContext(ctx *runtime.Context) queryexec.Context { + carriers := s.rootCarriers() + roots := make([]queryexec.Root, 0, len(carriers)) + for _, root := range carriers { + roots = append(roots, queryexec.Root{Label: root.name, Object: root.inst}) + } + return queryexec.Context{ + Index: s.browseIndex(), + Resolver: ctx.Resolver(), + Model: ctx.Semantics(), + Runtime: ctx, + Roots: roots, + } +} + +// queryBindings reads the `=` arguments of %run-query. +// Repeating a parameter appends to its binding, so a 0..* parameter can be +// given several values. +func (s *Session) queryBindings(ctx *runtime.Context, args []string) (queryexec.Bindings, error) { + if len(args) == 0 { + return nil, nil + } + bindings := make(queryexec.Bindings, len(args)) + for _, arg := range args { + param, expr, ok := strings.Cut(arg, "=") + param, expr = strings.TrimSpace(param), strings.TrimSpace(expr) + if !ok || param == "" || expr == "" { + return nil, fmt.Errorf("binding %q is not written as =", arg) + } + values, err := s.bindingValues(ctx, param, expr) + if err != nil { + return nil, err + } + bindings[param] = append(bindings[param], values...) + } + return bindings, nil +} + +// bindingValues reads one binding: a held object, else a model element by name, +// else the value of an expression evaluated at the prompt. +func (s *Session) bindingValues(ctx *runtime.Context, param, expr string) ([]queryexec.Value, error) { + if inst, label, err := s.boundObject(expr); err != nil { + return nil, fmt.Errorf("binding %s: %w", param, err) + } else if inst != nil { + return []queryexec.Value{queryexec.ObjectValue(inst, label)}, nil + } + sym, _, lerr := s.lookupSymbol(expr) + if lerr == nil && sym != nil { + return []queryexec.Value{queryexec.ElementValue(sym)}, nil + } + var ambiguous *AmbiguousNameError + if errors.As(lerr, &ambiguous) { + return nil, fmt.Errorf("binding %s: %w", param, lerr) + } + node, diags := parseExprAlone(expr) + if len(diags) > 0 { + return nil, exprError(expr, diags[0].Message, diags[0].Span, len(exprPrefix)) + } + if node == nil { + return nil, fmt.Errorf("binding %s: could not parse %q", param, expr) + } + value, err := ctx.EvalWithScope(node, s.promptScope()) + if err != nil { + return nil, fmt.Errorf("binding %s: %w", param, err) + } + values, err := s.queryValues(ctx, value) + if err != nil { + return nil, fmt.Errorf("binding %s: %w", param, err) + } + return values, nil +} + +// boundObject resolves an object reference (`#3`, an instantiated name, a path +// like `car.wheels[2]`); a name holding no object binds nothing, so it may be an element. +func (s *Session) boundObject(expr string) (*runtime.Instance, string, error) { + inst, label, err := s.resolveObject(expr) + if err == nil { + return inst, label, nil + } + if strings.HasPrefix(expr, "#") { + return nil, "", err + } + return nil, "", nil +} + +// queryValues converts a prompt value into binding values: an object under its +// label, a collection element by element, a null as nothing. +func (s *Session) queryValues(ctx *runtime.Context, value runtime.Value) ([]queryexec.Value, error) { + if lit := value.EnumerationLiteral(); lit != nil { + return []queryexec.Value{queryexec.ElementValue(lit)}, nil + } + if id, ok := value.Object(); ok { + if ctx.HoldsNoValue(value) { + return nil, nil + } + inst, held := ctx.Instance(id) + if !held { + return nil, &UnknownObjectIDError{ID: id, Known: s.heldIDs()} + } + label, labelled := s.heldLabel(id) + if !labelled { + label = fmt.Sprintf("#%d", id) + } + return []queryexec.Value{queryexec.ObjectValue(inst, label)}, nil + } + switch value.Kind { + case runtime.ValConst: + switch value.Const.Kind { + case semantics.ValInt: + return []queryexec.Value{queryexec.IntegerValue(value.Const.Int)}, nil + case semantics.ValReal: + return []queryexec.Value{queryexec.RealValue(value.Const.Real)}, nil + case semantics.ValBool: + return []queryexec.Value{queryexec.BooleanValue(value.Const.Bool)}, nil + } + return nil, fmt.Errorf("%s cannot be bound to a query parameter", runtime.FormatValue(value)) + case runtime.ValString: + return []queryexec.Value{queryexec.StringValue(value.Str())}, nil + case runtime.ValNull: + return nil, nil + case runtime.ValSequence: + if value.Sequence() == nil { + return nil, nil + } + return s.queryValueList(ctx, value.Sequence().Elements()) + case runtime.ValSet: + if value.Set() == nil { + return nil, nil + } + return s.queryValueList(ctx, value.Set().Elements()) + case runtime.ValArray: + return s.queryValueList(ctx, value.Array().Elements) + case runtime.ValVector: + components := value.Vector().Elements + elements := make([]runtime.Value, len(components)) + for i, c := range components { + elements[i] = runtime.Value{Kind: runtime.ValConst, Const: c} + } + return s.queryValueList(ctx, elements) + case runtime.ValMeasurementRef: + // A reference to one declared unit is that element; a composed unit names none. + if decl := value.MeasurementRef().Declaration(); decl != nil { + return []queryexec.Value{queryexec.ElementValue(decl)}, nil + } + return nil, fmt.Errorf("the measurement reference %s names no single declaration to bind to a query parameter", runtime.FormatValue(value)) + case runtime.ValTensorQuantity: + return nil, fmt.Errorf("a tensor quantity %s cannot be bound to a query parameter: its components are measured, and a query takes bare scalars or elements", runtime.FormatValue(value)) + case runtime.ValCoordinateFrame: + // A declared frame or scale is that element; one composed by arithmetic names none. + if decl := value.CoordinateFrame().Decl; decl != nil && value.CoordinateFrame().Object != 0 { + return []queryexec.Value{queryexec.ElementValue(decl)}, nil + } + return nil, fmt.Errorf("the coordinate frame %s names no single declaration to bind to a query parameter", runtime.FormatValue(value)) + case runtime.ValCoordinateTransformation: + if decl := value.CoordinateTransformation().Decl; decl != nil { + return []queryexec.Value{queryexec.ElementValue(decl)}, nil + } + return nil, fmt.Errorf("the coordinate transformation %s names no single declaration to bind to a query parameter", runtime.FormatValue(value)) + default: + return nil, fmt.Errorf("a %s cannot be bound to a query parameter", value.Kind) + } +} + +func (s *Session) queryValueList(ctx *runtime.Context, elements []runtime.Value) ([]queryexec.Value, error) { + var out []queryexec.Value + for _, element := range elements { + values, err := s.queryValues(ctx, element) + if err != nil { + return nil, err + } + out = append(out, values...) + } + return out, nil +} + +// renderRowSet reports an executed query's ordered rows and projected cells as +// the prompt prints them, with the row and column counts as reportable values. +func renderRowSet(name string, result *queryexec.RowSet) ([]string, []NamedValue) { + rows := result.Rows() + columns := result.Columns() + lines := []string{fmt.Sprintf("✓ Query %s returned %s", name, countOf(len(rows), "row", "rows"))} + names := make([]string, len(columns)) + for i, column := range columns { + names[i] = column.Name() + } + if len(names) > 0 { + lines = append(lines, " Columns: "+strings.Join(names, ", ")) + } + for i, row := range rows { + lines = append(lines, fmt.Sprintf(" Row %d: %s", i+1, formatQueryValue(row.Element()))) + for j, cell := range row.Cells() { + if j >= len(names) { + break + } + lines = append(lines, fmt.Sprintf(" %s = %s", names[j], formatQueryCell(cell))) + } + } + values := []NamedValue{{Name: "rows", Value: strconv.Itoa(len(rows))}} + if len(names) > 0 { + values = append(values, NamedValue{Name: "columns", Value: strings.Join(names, ", ")}) + } + return lines, values +} + +// formatQueryCell renders one projected cell: nothing, one value, or a +// bracketed sequence of values. +func formatQueryCell(cell queryexec.Cell) string { + values := cell.Values() + switch len(values) { + case 0: + return "(none)" + case 1: + return formatQueryValue(values[0]) + default: + parts := make([]string, len(values)) + for i, value := range values { + parts[i] = formatQueryValue(value) + } + return "[" + strings.Join(parts, ", ") + "]" + } +} + +// formatQueryValue renders one query value with the notation the prompt uses +// for runtime results. +func formatQueryValue(value queryexec.Value) string { + if sym, ok := value.Element(); ok { + if fqn := symbols.FQNOf(sym); fqn != "" { + return notationName(fqn) + } + return sym.Name + } + if inst, label, ok := value.Object(); ok { + return fmt.Sprintf("%s (#%d)", label, inst.ID) + } + if verdict, ok := value.Verdict(); ok { + return verdict.Summary() + } + if state, ok := value.State(); ok { + return state.Label() + } + if event, ok := value.Event(); ok { + return event.Summary() + } + if text, ok := value.String(); ok { + return strconv.Quote(text) + } + if integer, ok := value.Integer(); ok { + return strconv.FormatInt(integer, 10) + } + if realVal, ok := value.Real(); ok { + return semantics.FormatReal(realVal) + } + if boolean, ok := value.Boolean(); ok { + return strconv.FormatBool(boolean) + } + if value.Kind() == queryexec.ValueInfinity { + return "*" + } + if quantity, ok := value.Quantity(); ok { + return quantity.String() + } + return string(value.Kind()) +} diff --git a/internal/frontend/repl/docquery_states_test.go b/internal/frontend/repl/docquery_states_test.go new file mode 100644 index 0000000000..8855f1c310 --- /dev/null +++ b/internal/frontend/repl/docquery_states_test.go @@ -0,0 +1,142 @@ +package repl + +import "testing" + +// stateQueryModel declares two lamps whose machine has orthogonal regions, with +// queries over the states the lamps are in and the steps their trace records. +const stateQueryModel = `package Lamps { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + private import SI::*; + + attribute def Toggle; + attribute def Boost; + attribute def Dim { attribute level : Integer; } + + state def LampMachine { + attribute brightness : Integer = 0; + entry; then off; + state off; + transition off_on first off accept Toggle then on; + state on parallel { + state light { + entry; then run; + state run; + transition run_dim first run accept d : Dim do assign brightness := d.level then dim; + state dim; + } + state fan { + entry; then slow; + state slow; + transition slow_fast first slow accept Boost then fast; + state fast; + } + } + transition on_off first on accept Toggle then off; + } + part def Lamp { exhibit state lp : LampMachine; } + part lamp1 : Lamp; + part lamp2 : Lamp; + + calc def CurrentStates :> Query { + Project( + source = States(source = Objects(type = "Lamp")), + properties = ("path", "machine", "statePath", "region", "enclosing") + ) + } + calc def Lit :> Query { + InState(name = "on") + } + calc def Steps :> Query { + in root : Element; + Project( + source = Events(source = root, kind = "accept, transition", since = 1 [s], before = 2.5 [s]), + properties = ("time", "kind", "event", "from", "to", "payload") + ) + } +} +` + +func stateQuerySession(t *testing.T) *Session { + t.Helper() + s := NewSession() + if res := s.Submit(stateQueryModel); len(errorDiagnostics(res.Diagnostics)) > 0 { + t.Fatalf("model did not analyse cleanly: %v", res.Diagnostics) + } + return s +} + +// TestRunQueryStatesOverSession checks %run-query reads the states the session's +// objects are in, one row per active leaf across regions, and the inverse lookup. +func TestRunQueryStatesOverSession(t *testing.T) { + s := stateQuerySession(t) + wants(t, run(t, s, "%run-query CurrentStates"), "✓ Query Lamps::CurrentStates returned 0 rows") + + run(t, s, "%instantiate lamp1") + run(t, s, "%instantiate lamp2") + wants(t, run(t, s, "%run-query CurrentStates"), + "✓ Query Lamps::CurrentStates returned 2 rows", + "Row 1: Lamps::lamp1.lp in off", + `statePath = "off"`, + "Row 2: Lamps::lamp2.lp in off") + wants(t, run(t, s, "%run-query Lit"), "✓ Query Lamps::Lit returned 0 rows") + + run(t, s, "%state lp lamp1") + run(t, s, "%send Toggle") + run(t, s, "%advance 1") + wants(t, run(t, s, "%run-query CurrentStates"), + "✓ Query Lamps::CurrentStates returned 3 rows", + "Row 1: Lamps::lamp1.lp in on.run", + `region = "light"`, + `enclosing = "on"`, + "Row 2: Lamps::lamp1.lp in on.slow", + `region = "fan"`, + "Row 3: Lamps::lamp2.lp in off") + wants(t, run(t, s, "%run-query Lit"), + "✓ Query Lamps::Lit returned 1 row", + "Row 1: Lamps::lamp1 (#1)") +} + +// TestRunQueryEventsOverSession checks %run-query reads the recorded trace: refused +// without tracing, then the interval's accepts and transitions, refused again after %trace off. +func TestRunQueryEventsOverSession(t *testing.T) { + s := stateQuerySession(t) + run(t, s, "%instantiate lamp1") + run(t, s, "%instantiate lamp2") + wants(t, run(t, s, "%run-query Steps root=lamp1"), + "error:", "reads the session's trace, and this session records none") + + run(t, s, "%trace on") + run(t, s, "%state lp lamp1") + run(t, s, "%send Toggle") + run(t, s, "%advance 1") + run(t, s, "%send Dim(level=3)") + run(t, s, "%advance 1") + run(t, s, "%send Boost") + run(t, s, "%send Toggle to lamp2") + run(t, s, "%advance 0.5") + run(t, s, "%send Toggle to lamp2") + run(t, s, "%advance 0.5") + + wants(t, run(t, s, "%run-query Steps root=lamp1"), + "✓ Query Lamps::Steps returned 4 rows", + "Columns: time, kind, event, from, to, payload", + "Row 1: t=1 Lamps::lamp1.lp: accept Dim", + "time = 1.0 [s]", + `kind = "accept"`, + `payload = "level = 3"`, + "Row 2: t=1 Lamps::lamp1.lp: transition: run -> dim (event: accept Dim)", + `from = "run"`, + `to = "dim"`, + "Row 3: t=2 Lamps::lamp1.lp: accept Boost", + "Row 4: t=2 Lamps::lamp1.lp: transition: slow -> fast (event: accept Boost)") + wants(t, run(t, s, "%run-query Steps root=lamp2"), + "✓ Query Lamps::Steps returned 2 rows", + "Row 1: t=2 Lamps::lamp2.lp: accept Toggle", + "Row 2: t=2 Lamps::lamp2.lp: transition: off -> on (event: accept Toggle)") + + run(t, s, "%trace off") + wants(t, run(t, s, "%run-query Steps root=lamp1"), + "error:", "reads the session's trace, and this session records none") +} diff --git a/internal/frontend/repl/docquery_test.go b/internal/frontend/repl/docquery_test.go new file mode 100644 index 0000000000..5397c9e522 --- /dev/null +++ b/internal/frontend/repl/docquery_test.go @@ -0,0 +1,598 @@ +package repl + +import ( + "reflect" + "strings" + "testing" +) + +// docQueryModel declares document queries over a small part tree: a projecting +// query, one relying on a default, one redefining inherited defaults, one +// composing another by name, and one traversing named relationships, plus one +// naming an unsupported kind. +const docQueryModel = `package Observatory { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + part def Subsystem { + attribute mass : Real; + } + + part telescope { + part optics : Subsystem { + attribute redefines mass = 8.5; + } + part segmentControl : Subsystem { + attribute redefines mass = 20.0; + } + part mount : Subsystem { + attribute redefines mass = 15.0; + } + } + + calc def HeavySubsystems :> Query { + in root : Element; + Project( + source = OrderBy( + source = WhereFeature( + source = WhereType( + source = Descendants(source = root, maxDepth = 3), + type = "PartUsage" + ), + 'feature' = "mass", + operator = ">=", + value = "10" + ), + property = "name", + direction = "ascending", + missing = "last", + multiple = "error" + ), + properties = ("name", "mass") + ) + } + + calc def PlainCalc { + in x : Real; + x + } + + calc def NamedSubsystems :> Query { + in root : Element; + in pattern : String default "mo"; + WhereName(source = OwnedElements(source = root), operator = "startsWith", value = pattern) + } + + calc def DefaultedSubsystems :> NamedSubsystems { + in redefines root = telescope; + in redefines pattern default "seg"; + } + + calc def ComposedQuery :> Query { + in root : Element; + HeavySubsystems(root = root) + } + + calc def RelatedQuery :> Query { + in root : Element; + RelatedElements( + source = root, + relationshipKind = "typing", + direction = "incoming", + maxDepth = 1 + ) + } + + calc def UnknownRelatedQuery :> Query { + in root : Element; + RelatedElements( + source = root, + relationshipKind = "containment", + direction = "outgoing", + maxDepth = 1 + ) + } +} +` + +func docQuerySession(t *testing.T) *Session { + t.Helper() + s := NewSession() + if res := s.Submit(docQueryModel); len(errorDiagnostics(res.Diagnostics)) > 0 { + t.Fatalf("model did not analyse cleanly: %v", res.Diagnostics) + } + return s +} + +func TestRunQueryProjectsOrderedRows(t *testing.T) { + s := docQuerySession(t) + got := run(t, s, "%run-query HeavySubsystems root=Observatory::telescope") + wants(t, got, + "✓ Query Observatory::HeavySubsystems returned 2 rows", + "Columns: name, mass", + "Row 1: Observatory::telescope::mount", + `name = "mount"`, + "mass = 15.0", + "Row 2: Observatory::telescope::segmentControl", + "mass = 20.0", + ) + if strings.Index(got, "mount") > strings.Index(got, "segmentControl") { + t.Errorf("rows are not in the query's order:\n%s", got) + } +} + +func TestRunDocumentQueryVerdict(t *testing.T) { + s := docQuerySession(t) + v := s.RunDocumentQuery("HeavySubsystems root=telescope") + if !v.Holds() { + t.Fatalf("verdict = %s: %v", v.Status, v.Lines) + } + values := map[string]string{} + for _, nv := range v.Values { + values[nv.Name] = nv.Value + } + if values["rows"] != "2" || values["columns"] != "name, mass" { + t.Errorf("values = %v", v.Values) + } +} + +func TestRunQueryUsageAndUnknownName(t *testing.T) { + s := docQuerySession(t) + wants(t, run(t, s, "%run-query"), "usage: %run-query [= ...]") + wants(t, run(t, s, "%run-query NoSuchQuery"), "error:", "NoSuchQuery") + wants(t, run(t, s, "%run-query HeavySubsystems telescope"), + "error:", "=") +} + +func TestRunQueryRejectsNonQueryDefinition(t *testing.T) { + s := docQuerySession(t) + wants(t, run(t, s, "%run-query PlainCalc x=1.0"), + "error:", "not a document query", "DocumentQueries::Query") +} + +func TestRunQuerySurfacesTypedExecutionFailures(t *testing.T) { + s := docQuerySession(t) + wants(t, run(t, s, "%run-query HeavySubsystems"), + "error:", "requires binding root") + wants(t, run(t, s, "%run-query HeavySubsystems root=telescope depth=3"), + "error:", "unknown binding depth") + wants(t, run(t, s, "%run-query HeavySubsystems root=1"), + "error:", "binding root has type integer, expected") + // An unsupported relationship kind is a typed execution failure. + wants(t, run(t, s, "%run-query UnknownRelatedQuery root=telescope"), + "error:", `does not support relationship kind "containment"`) +} + +func TestRunQueryTraversesNamedRelationships(t *testing.T) { + s := docQuerySession(t) + wants(t, run(t, s, "%run-query RelatedQuery root=Subsystem"), + "✓ Query Observatory::RelatedQuery returned 3 rows", + "Row 1: Observatory::telescope::optics", + "Row 2: Observatory::telescope::segmentControl", + "Row 3: Observatory::telescope::mount", + ) +} + +func TestRunQueryExecutesComposedQueries(t *testing.T) { + s := docQuerySession(t) + wants(t, run(t, s, "%run-query ComposedQuery root=telescope"), + "✓ Query Observatory::ComposedQuery returned 2 rows", + "Columns: name, mass", + "Row 1: Observatory::telescope::mount", + "Row 2: Observatory::telescope::segmentControl") +} + +func TestRunQueryUsesParameterDefaults(t *testing.T) { + s := docQuerySession(t) + // An omitted parameter takes its declared default; an explicit binding overrides it. + wants(t, run(t, s, "%run-query NamedSubsystems root=telescope"), + "✓ Query Observatory::NamedSubsystems returned 1 row", + "Row 1: Observatory::telescope::mount") + wants(t, run(t, s, `%run-query NamedSubsystems root=telescope pattern="op"`), + "✓ Query Observatory::NamedSubsystems returned 1 row", + "Row 1: Observatory::telescope::optics") + // An element-naming default binds that element, so every parameter may be omitted. + wants(t, run(t, s, "%run-query DefaultedSubsystems"), + "✓ Query Observatory::DefaultedSubsystems returned 1 row", + "Row 1: Observatory::telescope::segmentControl") +} + +// The REPL looks names up in the document's own scope tree while the runtime +// model reads the index, so a parameter typed by a declaration of the session +// must accept that declaration's values whichever tree each came through. +func TestRunQueryConformsAcrossScopeTrees(t *testing.T) { + s := NewSession() + res := s.Submit(`package Site { + private import DocumentQueries::*; + enum def Color { red; green; } + part def Telescope; + part def GroundStation :> Telescope; + part telescope : Telescope { part optics; } + part groundStation : GroundStation { part antenna; } + calc def Painted :> Query { + in hue : Color = Color::red; + OwnedElements(source = telescope) + } + calc def Sited :> Query { + in site : Telescope = groundStation; + OwnedElements(source = telescope) + } +} +`) + if len(errorDiagnostics(res.Diagnostics)) > 0 { + t.Fatalf("model did not analyse cleanly: %v", res.Diagnostics) + } + wants(t, run(t, s, "%run-query Painted"), + "✓ Query Site::Painted returned 1 row", "Row 1: Site::telescope::optics") + wants(t, run(t, s, "%run-query Painted hue=Color::green"), + "✓ Query Site::Painted returned 1 row") + wants(t, run(t, s, "%run-query Painted hue=telescope"), + "error:", "binding hue has type element, expected Site::Color") + wants(t, run(t, s, "%run-query Sited"), + "✓ Query Site::Sited returned 1 row") + wants(t, run(t, s, "%run-query Sited site=groundStation"), + "✓ Query Site::Sited returned 1 row") + wants(t, run(t, s, "%run-query Sited site=telescope"), + "✓ Query Site::Sited returned 1 row") +} + +func TestRunQueryBindingExpressions(t *testing.T) { + s := docQuerySession(t) + wants(t, run(t, s, `%run-query NamedSubsystems root=telescope pattern="mo"`), + "✓ Query Observatory::NamedSubsystems returned 1 row", + "Row 1: Observatory::telescope::mount") + wants(t, run(t, s, "%run-query HeavySubsystems root=noSuchName"), + "error:", "binding root") +} + +func TestRunQuerySpacedBindingExpressions(t *testing.T) { + s := docQuerySession(t) + wants(t, run(t, s, `%run-query NamedSubsystems root=telescope pattern="m" + "o"`), + "✓ Query Observatory::NamedSubsystems returned 1 row", + "Row 1: Observatory::telescope::mount") + wants(t, run(t, s, `%run-query NamedSubsystems root=telescope pattern=("m" + "o")`), + "✓ Query Observatory::NamedSubsystems returned 1 row") + wants(t, run(t, s, `%run-query NamedSubsystems pattern="m" + "o" root=telescope`), + "✓ Query Observatory::NamedSubsystems returned 1 row") +} + +func TestRegroupBindings(t *testing.T) { + cases := []struct { + tokens []string + want []string + }{ + {[]string{"limit=1", "+", "2"}, []string{"limit=1 + 2"}}, + {[]string{"a=1", "b=2"}, []string{"a=1", "b=2"}}, + {[]string{"a=(1", "+", "2)", "b=3"}, []string{"a=(1 + 2)", "b=3"}}, + {[]string{`s="a b"`, "+", `"c"`}, []string{`s="a b" + "c"`}}, + {[]string{"a=x", "==", "y"}, []string{"a=x == y"}}, + {[]string{"telescope"}, []string{"telescope"}}, + } + for _, c := range cases { + if got := regroupBindings(c.tokens); !reflect.DeepEqual(got, c.want) { + t.Errorf("regroupBindings(%q) = %q, want %q", c.tokens, got, c.want) + } + } +} + +func TestRunQueryListedInHelpAndCompletion(t *testing.T) { + s := docQuerySession(t) + wants(t, run(t, s, "%help"), "%run-query [

    =...]") + comp := s.Complete("%run-que", len("%run-que")) + found := false + for _, cand := range comp.Candidates { + if cand == "%run-query" { + found = true + } + } + if !found { + t.Errorf("%%run-query is not completed: %v", comp.Candidates) + } +} + +// A measurement reference binds as the one unit declaration it names, so a query +// traverses from it; a unit composed of several names no element to bind. +func TestRunQueryBindsAMeasurementReference(t *testing.T) { + s := docQuerySession(t) + wants(t, run(t, s, `%run-query NamedSubsystems root=SI::km pattern="unit"`), + "✓ Query Observatory::NamedSubsystems returned 1 row", + "Row 1: SI::kilometre::unitConversion") + wants(t, run(t, s, `%run-query NamedSubsystems root=SI::m / SI::s pattern="unit"`), + "error:", "the measurement reference m/s names no single declaration to bind to a query parameter") +} + +// A measurement scale binds as the declaration it is, so a query traverses from +// it; its placement binds as the transformation the library declares. +func TestRunQueryBindsACoordinateFrame(t *testing.T) { + s := docQuerySession(t) + wants(t, run(t, s, `%run-query NamedSubsystems root=SI::'°C_abs' pattern="unit"`), + "✓ Query Observatory::NamedSubsystems returned 1 row", + "Row 1: SI::'degree celsius (absolute temperature scale)'::unit") + wants(t, run(t, s, `%run-query NamedSubsystems root=SI::'°C_abs'.transformation pattern="origin"`), + "✓ Query Observatory::NamedSubsystems returned 1 row", + "Row 1: SI::'degree celsius (absolute temperature scale)'::zeroDegreeCelsiusToKelvinShift::origin") +} + +// objectQueryModel declares queries over a car whose wheels the session can +// instantiate: one over what an object owns, one over the objects a session +// holds, and one that only model elements answer. +const objectQueryModel = `package Garage { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + part def Wheel { + attribute pressure : Integer = 30; + action inflate { + in delta : Integer; + first set; + action set { + assign pressure := pressure + delta; + } + } + } + + part def Car { + part wheels : Wheel[2]; + } + + part car : Car; + + calc def Parts :> Query { + in root : Element; + Project(source = OwnedElements(source = root), properties = ("name", "pressure")) + } + + calc def Wheels :> Query { + Project(source = Objects(type = "Wheel"), properties = ("pressure")) + } + + calc def Typing :> Query { + in root : Element; + RelatedElements(source = root, relationshipKind = "typing", direction = "outgoing", maxDepth = 1) + } +} +` + +func objectQuerySession(t *testing.T) *Session { + t.Helper() + s := NewSession() + if res := s.Submit(objectQueryModel); len(errorDiagnostics(res.Diagnostics)) > 0 { + t.Fatalf("model did not analyse cleanly: %v", res.Diagnostics) + } + return s +} + +// A held object wins over the element it was instantiated from, so the query +// runs over the object's parts under their paths; before instantiation the same +// binding is the element, whose usage owns nothing of its own. +func TestRunQueryBindsHeldObjectOverElement(t *testing.T) { + s := objectQuerySession(t) + wants(t, run(t, s, "%run-query Parts root=Garage::Car"), + "✓ Query Garage::Parts returned 1 row", + "Row 1: Garage::Car::wheels", + "pressure = 30") + wants(t, run(t, s, "%run-query Parts root=car"), "✓ Query Garage::Parts returned 0 rows") + wants(t, run(t, s, "%instantiate Garage::car"), "Created instance") + wants(t, run(t, s, "%run-query Parts root=car"), + "✓ Query Garage::Parts returned 2 rows", + "Row 1: Garage::car.wheels[1] (#2)", + `name = "wheels[1]"`, + "pressure = 30", + "Row 2: Garage::car.wheels[2] (#3)") + wants(t, run(t, s, "%run-query Parts root=Garage::car"), "returned 2 rows") + wants(t, run(t, s, "%run-query Parts root=Garage::Car"), "returned 1 row", "Row 1: Garage::Car::wheels") +} + +// `#id` and a path through a held object bind that object, and a query reads +// the value an object holds now, not the declared default. +func TestRunQueryBindsObjectByIDAndPath(t *testing.T) { + s := objectQuerySession(t) + wants(t, run(t, s, "%instantiate Garage::car"), "Created instance") + wants(t, run(t, s, "%run-query Parts root=#1"), "returned 2 rows", "Row 1: #1.wheels[1] (#2)") + wants(t, run(t, s, "%run-query Parts root=car.wheels[2]"), "returned 0 rows") + wants(t, run(t, s, "%invoke car.wheels[2] inflate delta=5"), "Invoked inflate on object #3") + got := run(t, s, "%run-query Wheels") + wants(t, got, + "✓ Query Garage::Wheels returned 2 rows", + "Row 1: Garage::car.wheels[1] (#2)", + "pressure = 30", + "Row 2: Garage::car.wheels[2] (#3)", + "pressure = 35") + if strings.Index(got, "pressure = 30") > strings.Index(got, "pressure = 35") { + t.Errorf("wheel rows are out of session order:\n%s", got) + } + wants(t, run(t, s, "%run-query Parts root=#99"), "error:", "binding root", "#99") +} + +// A query over model elements only refuses an object row with the operation +// and the object it was given. +func TestRunQueryRefusesObjectRowsInElementOperations(t *testing.T) { + s := objectQuerySession(t) + wants(t, run(t, s, "%run-query Typing root=car"), "✓ Query Garage::Typing returned 1 row", "Garage::Car") + wants(t, run(t, s, "%instantiate Garage::car"), "Created instance") + wants(t, run(t, s, "%run-query Typing root=car"), "error:", + "operation related-elements applies to model elements, not to object Garage::car") +} + +// objectDocumentModel adds a document over the object query model: a table +// bound to the car and a list of every wheel the session holds. +const objectDocumentModel = objectQueryModel + `package Reports { + private import DocumentQueries::*; + private import Garage::*; + + part def CarReport :> Document { + attribute redefines title = "Car Report"; + part parts : Table { + calc rows : Parts { + in root = car; + } + } + part wheels : List { + calc items : Wheels; + } + } +} +` + +// A document renders over what the session holds: before instantiation its +// table shows the declared usage's parts (none of its own) and no objects; after +// it, the objects by path, with the values they hold now. +func TestRenderDocumentOverHeldObjects(t *testing.T) { + s := NewSession() + if res := s.Submit(objectDocumentModel); len(errorDiagnostics(res.Diagnostics)) > 0 { + t.Fatalf("model did not analyse cleanly: %v", res.Diagnostics) + } + declared := run(t, s, "%render-document Reports::CarReport") + wants(t, declared, "# Car Report", "| name | pressure |\n| --- | --- |") + if strings.Contains(declared, "wheels\\[") || strings.Contains(declared, "- 30") { + t.Errorf("a session holding nothing rendered objects:\n%s", declared) + } + + wants(t, run(t, s, "%instantiate Garage::car"), "Created instance") + wants(t, run(t, s, "%invoke car.wheels[2] inflate delta=5"), "Invoked inflate on object #3") + wants(t, run(t, s, "%render-document Reports::CarReport"), + "# Car Report", + "| name | pressure |", + `| wheels\[1\] | 30 |`, + `| wheels\[2\] | 35 |`, + "- 30\n- 35") +} + +// verdictQueryModel declares a car whose own constraint holds and whose wheels' +// constraint fails once instantiated, with queries over the verdicts about it. +const verdictQueryModel = `package Garage { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + part def Wheel { + attribute pressure : Integer = 30; + assert constraint inflated { pressure >= 25 } + action deflate { + in delta : Integer; + first set; + action set { + assign pressure := pressure - delta; + } + } + } + + part def Car { + attribute mass : Integer = 1500; + part wheels : Wheel[2]; + assert constraint light { mass < 2000 } + } + + part car : Car; + + requirement def LightCar { + subject c : Car; + require constraint { c.mass < 1800 } + } + requirement lightCar : LightCar; + satisfy lightCar by car; + + calc def Checks :> Query { + in root : Element; + Project(source = Verdicts(source = root), properties = ("path", "verdict", "reason")) + } + + calc def Failing :> Query { + in root : Element; + WhereFeature(source = Verdicts(source = root), 'feature' = "verdict", operator = "=", value = "violated") + } +} +` + +// Verdicts about a held object read it as it stands, one row per assertion on +// the object and the objects it holds; before instantiation the same binding is +// the element, checked as declared. +func TestRunQueryReportsVerdicts(t *testing.T) { + s := NewSession() + if res := s.Submit(verdictQueryModel); len(errorDiagnostics(res.Diagnostics)) > 0 { + t.Fatalf("model did not analyse cleanly: %v", res.Diagnostics) + } + wants(t, run(t, s, "%run-query Checks root=car"), + "✓ Query Garage::Checks returned 4 rows", + "Row 1: assert constraint light on Garage::car: holds", + `path = "Garage::car"`, + `verdict = "holds"`, + "reason = (none)", + "Row 2: satisfy lightCar by car on Garage::car: holds", + "Row 3: assert constraint inflated on Garage::car.wheels[1]: holds") + wants(t, run(t, s, "%run-query Failing root=car"), "✓ Query Garage::Failing returned 0 rows") + wants(t, run(t, s, "%instantiate Garage::car"), "Created instance") + wants(t, run(t, s, "%invoke car.wheels[2] deflate delta=10"), "Invoked deflate on object #3") + wants(t, run(t, s, "%run-query Checks root=car"), + "✓ Query Garage::Checks returned 4 rows", + "Row 2: satisfy lightCar by car on Garage::car: holds", + "Row 4: assert constraint inflated on Garage::car.wheels[2]: violated", + `path = "Garage::car.wheels[2]"`, + `verdict = "violated"`, + `reason = "constraint inflated: assertion evaluated to false: pressure >= 25"`) + wants(t, run(t, s, "%run-query Failing root=#1"), + "✓ Query Garage::Failing returned 1 row", + "Row 1: assert constraint inflated on #1.wheels[2]: violated") + // Asked again, the object answers the same rows: the satisfaction's subject + // binding classified the car, which restates no assertion about it. + wants(t, run(t, s, "%run-query Checks root=car"), + "✓ Query Garage::Checks returned 4 rows", + "Row 1: assert constraint light on Garage::car: holds", + "Row 2: satisfy lightCar by car on Garage::car: holds") +} + +// nestedVerdictQueryModel nests a requirement in a part, so the case names it by +// a feature chain, and checks it from a part nested in the design. +const nestedVerdictQueryModel = `package Descent { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + part def Engine { attribute thrust : Real; } + requirement def ThrustMargin { + subject engine : Engine; + require constraint { engine.thrust >= 3000.0 } + } + part specification { + requirement thrust : ThrustMargin; + } + part lander { + part propulsion { + part engine : Engine { attribute :>> thrust = 2800.0; } + satisfy specification.thrust by engine; + } + } + verification def FireEngine { + subject engine : Engine; + VerificationCases::PassIf(engine.thrust >= 3000.0) + } + verification hotFire : FireEngine { + subject engine = lander.propulsion.engine; + objective { verify specification.thrust; } + } + calc def Checks :> Query { + in root : Element; + Project(source = Verdicts(source = root), properties = ("kind", "verdict")) + } +}` + +// TestRunQueryVerdictsFindCasesVerifyingANestedRequirement pins that the cases verifying a +// requirement named by feature chain are reported for a declared root and a held object alike. +func TestRunQueryVerdictsFindCasesVerifyingANestedRequirement(t *testing.T) { + s := NewSession() + if res := s.Submit(nestedVerdictQueryModel); len(errorDiagnostics(res.Diagnostics)) > 0 { + t.Fatalf("model did not analyse cleanly: %v", res.Diagnostics) + } + wants(t, run(t, s, "%run-query Checks root=Descent::lander"), + "✓ Query Descent::Checks returned 2 rows", + "Row 1: satisfy specification::thrust by engine on Descent::lander.propulsion.engine: violated", + "Row 2: verification Descent::hotFire on Descent::lander.propulsion.engine: violated", + `kind = "verification"`) + wants(t, run(t, s, "%instantiate Descent::lander"), "Created instance") + wants(t, run(t, s, "%run-query Checks root=#1"), + "✓ Query Descent::Checks returned 2 rows", + "Row 2: verification Descent::hotFire on #1.propulsion.engine: violated") + wants(t, run(t, s, "%validate #1"), "Verification Descent::hotFire verdict: fail") +} diff --git a/internal/repl/docrender.go b/internal/frontend/repl/docrender.go similarity index 90% rename from internal/repl/docrender.go rename to internal/frontend/repl/docrender.go index 01c3be07d2..7eb6894adc 100644 --- a/internal/repl/docrender.go +++ b/internal/frontend/repl/docrender.go @@ -6,13 +6,13 @@ import ( "sort" "strings" - "github.com/Open-MBEE/OpenSysML/internal/core/docir" - "github.com/Open-MBEE/OpenSysML/internal/core/docplan" - "github.com/Open-MBEE/OpenSysML/internal/core/docrender" - "github.com/Open-MBEE/OpenSysML/internal/core/model" - "github.com/Open-MBEE/OpenSysML/internal/core/queryexec" - "github.com/Open-MBEE/OpenSysML/internal/core/symbols" - "github.com/Open-MBEE/OpenSysML/internal/core/view" + "github.com/Open-MBEE/OpenSysML/internal/doc/docir" + "github.com/Open-MBEE/OpenSysML/internal/doc/docrender" + "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec" + "github.com/Open-MBEE/OpenSysML/internal/ir/docplan" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // renderDocumentUsage is what %render-document accepts: a document's name, @@ -39,6 +39,13 @@ func (s *Session) RenderDocumentHTML(invocation string, opts docrender.HTMLOptio return docrender.HTML(document, opts) } +// EvaluateDocument compiles the named document definition and evaluates its +// queries against the session's model, for a backend rendering the result. +func (s *Session) EvaluateDocument(invocation string) (*docir.Document, error) { + defer s.enter()() + return s.evaluateDocument(invocation) +} + func (s *Session) renderDocumentMarkdown(invocation string, opts docrender.MarkdownOptions) (string, error) { document, err := s.evaluateDocument(invocation) if err != nil { @@ -77,7 +84,7 @@ func (s *Session) evaluateDocument(invocation string) (*docir.Document, error) { } return docir.EvaluateLinked(plan, model.SiblingDocumentPlans(idx, sem, resolver, sym), - queryexec.Context{Index: idx, Resolver: resolver, Model: sem}, + s.queryContext(ctx), queryexec.Options{}, s.sessionSourceText()) } @@ -146,7 +153,7 @@ func (s *Session) renderDocumentSet( plans = append(plans, plan) } documents, err := docir.EvaluateSet(plans, - queryexec.Context{Index: idx, Resolver: resolver, Model: sem}, + s.queryContext(ctx), queryexec.Options{}, s.sessionSourceText()) if err != nil { diff --git a/internal/repl/docrender_test.go b/internal/frontend/repl/docrender_test.go similarity index 97% rename from internal/repl/docrender_test.go rename to internal/frontend/repl/docrender_test.go index acae57cc35..de41c59640 100644 --- a/internal/repl/docrender_test.go +++ b/internal/frontend/repl/docrender_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/Open-MBEE/OpenSysML/internal/core/docrender" - "github.com/Open-MBEE/OpenSysML/internal/core/view" + "github.com/Open-MBEE/OpenSysML/internal/doc/docrender" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" ) // docRenderModel declares a document over a small part tree: a titled report @@ -113,7 +113,7 @@ func TestRenderDocumentPrintsMarkdown(t *testing.T) { "# Telescope Mass Report", "Mass rollup for the telescope assembly.", "## Heavy Subsystems", - "\n*Heavy subsystems by mass*", + "*Heavy subsystems by mass*", "| name | mass |", "| --- | --- |", "| mount | 15 |", diff --git a/internal/frontend/repl/draws_test.go b/internal/frontend/repl/draws_test.go new file mode 100644 index 0000000000..4ca55842c8 --- /dev/null +++ b/internal/frontend/repl/draws_test.go @@ -0,0 +1,52 @@ +package repl + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" +) + +// %draws shows and sets how runs resolve their RandomFunctions draws; under min, +// max or average a %runs needs no seed and every run agrees, under random the +// seed is required, and a word that is no policy is refused. +func TestDrawsFixesTheRunsWithoutASeed(t *testing.T) { + s := runsSession(t) + wants(t, run(t, s, "%draws"), "draws: random") + wants(t, run(t, s, "%runs 3 MC::acquire clock"), "the seed may be left out only under a fixed %draws policy") + wants(t, run(t, s, "%draws max"), "draws: max") + if s.Draws() != runtime.DrawMax { + t.Errorf("Draws() = %s, want max", s.Draws()) + } + got := sweepTable(run(t, s, "%runs 3 MC::acquire clock")) + wants(t, got, "runs MC::acquire — 3 run(s), no seed", + "1 | 80.0 [s] |